mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat(cli)!: remove sync command (#2669)
* feat(cli)!: remove sync command * feat(cli): reconcile skill publishes * chore: release clawhub cli 0.22.0 * fix(cli): publish new skills after resolver miss * feat(workflow)!: remove bump input
This commit is contained in:
@@ -90,8 +90,6 @@
|
||||
/packages/clawhub/src/cli/commands/packages.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/publish.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/transfer.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/sync.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/scanSkills.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/schema/openclawContract.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/schema/packages.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/schema/routes.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
|
||||
@@ -26,9 +26,7 @@ paths:
|
||||
- packages/clawhub/src/cli/commands/ownership.ts
|
||||
- packages/clawhub/src/cli/commands/packages.ts
|
||||
- packages/clawhub/src/cli/commands/publish.ts
|
||||
- packages/clawhub/src/cli/commands/sync.ts
|
||||
- packages/clawhub/src/cli/commands/transfer.ts
|
||||
- packages/clawhub/src/cli/scanSkills.ts
|
||||
- packages/clawhub/src/schema/openclawContract.ts
|
||||
- packages/clawhub/src/schema/packages.ts
|
||||
- packages/clawhub/src/schema/routes.ts
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
type: string
|
||||
default: ""
|
||||
root:
|
||||
description: Directory containing skill folders for bulk catalog publishing.
|
||||
description: Directory containing skill folders for catalog publishing.
|
||||
required: false
|
||||
type: string
|
||||
default: skills
|
||||
@@ -28,11 +28,6 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
bump:
|
||||
description: Version bump for updated skills. One of patch, minor, or major.
|
||||
required: false
|
||||
type: string
|
||||
default: patch
|
||||
registry:
|
||||
description: ClawHub registry URL.
|
||||
required: false
|
||||
@@ -53,7 +48,7 @@ on:
|
||||
required: false
|
||||
outputs:
|
||||
publish_json:
|
||||
description: Structured JSON output from clawhub sync.
|
||||
description: Structured JSON output from skill publishing.
|
||||
value: ${{ jobs.publish.outputs.publish_json }}
|
||||
|
||||
env:
|
||||
@@ -96,8 +91,10 @@ jobs:
|
||||
|
||||
audience = "clawhub-workflow-source"
|
||||
joiner = "&" if "?" in request_url else "?"
|
||||
token_url = f"{request_url}{joiner}audience={audience}"
|
||||
request = Request(token_url, headers={"Authorization": f"Bearer {request_token}"})
|
||||
request = Request(
|
||||
f"{request_url}{joiner}audience={audience}",
|
||||
headers={"Authorization": f"Bearer {request_token}"},
|
||||
)
|
||||
with urlopen(request) as response:
|
||||
payload = json.load(response)
|
||||
|
||||
@@ -121,8 +118,7 @@ jobs:
|
||||
f"job_workflow_ref={workflow_ref!r} job_workflow_sha={workflow_sha!r}"
|
||||
)
|
||||
|
||||
output_path = Path(os.environ["GITHUB_OUTPUT"])
|
||||
with output_path.open("a", encoding="utf-8") as fh:
|
||||
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"repository={repo}\n")
|
||||
fh.write(f"ref={workflow_sha}\n")
|
||||
PY
|
||||
@@ -142,10 +138,7 @@ jobs:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
run: |
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ -n "$CLAWHUB_TOKEN" ]]; then
|
||||
if [[ "$DRY_RUN" == "true" || -n "$CLAWHUB_TOKEN" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::Real skill publishes need secrets.clawhub_token. GitHub OIDC trusted publishing for skills is not supported yet."
|
||||
@@ -168,103 +161,133 @@ jobs:
|
||||
|
||||
path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-config.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"registry": os.environ["CLAWHUB_REGISTRY"],
|
||||
"token": os.environ["CLAWHUB_TOKEN"],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
json.dumps({"registry": os.environ["CLAWHUB_REGISTRY"], "token": os.environ["CLAWHUB_TOKEN"]}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(path)
|
||||
PY
|
||||
echo "CLAWHUB_CONFIG_PATH=$RUNNER_TEMP/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Resolve sync command
|
||||
- name: Run skill publishes
|
||||
env:
|
||||
INPUT_SKILL_PATH: ${{ inputs.skill_path }}
|
||||
INPUT_ROOT: ${{ inputs.root }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
INPUT_OWNER: ${{ inputs.owner }}
|
||||
INPUT_TAGS: ${{ inputs.tags }}
|
||||
INPUT_BUMP: ${{ inputs.bump }}
|
||||
INPUT_SITE: ${{ inputs.site }}
|
||||
INPUT_REGISTRY: ${{ inputs.registry }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
SOURCE_REPOSITORY: ${{ github.repository }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
skill_path = os.environ["INPUT_SKILL_PATH"].strip()
|
||||
root = os.environ["INPUT_ROOT"].strip() or "skills"
|
||||
scan_root = skill_path or root
|
||||
source_commit = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
|
||||
source_ref = os.environ["INPUT_REF"].strip() or os.environ["GITHUB_REF"].strip()
|
||||
|
||||
cli_entry = (
|
||||
Path(os.environ["GITHUB_WORKSPACE"])
|
||||
/ "clawhub-source"
|
||||
/ "packages"
|
||||
/ "clawhub"
|
||||
/ "src"
|
||||
/ "cli.ts"
|
||||
)
|
||||
if not cli_entry.exists():
|
||||
workspace = Path(os.environ["GITHUB_WORKSPACE"]).resolve()
|
||||
cli_entry = workspace / "clawhub-source" / "packages" / "clawhub" / "src" / "cli.ts"
|
||||
if not cli_entry.is_file():
|
||||
raise SystemExit(f"Missing ClawHub CLI entrypoint at {cli_entry}")
|
||||
|
||||
cmd = [
|
||||
"bun",
|
||||
str(cli_entry),
|
||||
"--workdir",
|
||||
scan_root,
|
||||
"--dir",
|
||||
".",
|
||||
"sync",
|
||||
"--all",
|
||||
"--json",
|
||||
"--no-clawdbot-roots",
|
||||
"--site",
|
||||
os.environ["INPUT_SITE"],
|
||||
"--registry",
|
||||
os.environ["INPUT_REGISTRY"],
|
||||
"--bump",
|
||||
os.environ["INPUT_BUMP"].strip() or "patch",
|
||||
"--source-repo",
|
||||
os.environ["GITHUB_REPOSITORY"],
|
||||
"--source-commit",
|
||||
source_commit,
|
||||
]
|
||||
def resolve_inside_workspace(raw_path):
|
||||
path = (workspace / raw_path).resolve()
|
||||
try:
|
||||
path.relative_to(workspace)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"Publish path must be inside the caller repository: {raw_path}") from exc
|
||||
return path
|
||||
|
||||
if os.environ["INPUT_DRY_RUN"] == "true":
|
||||
cmd.append("--dry-run")
|
||||
def is_skill_folder(path):
|
||||
return path.is_dir() and any((path / name).is_file() for name in ("SKILL.md", "skill.md"))
|
||||
|
||||
skill_path = os.environ["INPUT_SKILL_PATH"].strip()
|
||||
root_input = os.environ["INPUT_ROOT"].strip() or "skills"
|
||||
if skill_path:
|
||||
targets = [resolve_inside_workspace(skill_path)]
|
||||
if not is_skill_folder(targets[0]):
|
||||
raise SystemExit(f"skill_path is not a skill folder: {skill_path}")
|
||||
else:
|
||||
root = resolve_inside_workspace(root_input)
|
||||
if is_skill_folder(root):
|
||||
targets = [root]
|
||||
elif root.is_dir():
|
||||
targets = sorted(
|
||||
(child for child in root.iterdir() if is_skill_folder(child)),
|
||||
key=lambda child: child.name.lower(),
|
||||
)
|
||||
else:
|
||||
targets = []
|
||||
if not targets:
|
||||
raise SystemExit(f"No skill folders found under: {root_input}")
|
||||
|
||||
source_commit = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=workspace, text=True
|
||||
).strip()
|
||||
source_ref = os.environ["INPUT_REF"].strip() or os.environ["SOURCE_REF"].strip()
|
||||
dry_run = os.environ["INPUT_DRY_RUN"] == "true"
|
||||
owner = os.environ["INPUT_OWNER"].strip()
|
||||
tags = os.environ["INPUT_TAGS"].strip()
|
||||
if owner:
|
||||
cmd += ["--owner", owner]
|
||||
if tags:
|
||||
cmd += ["--tags", tags]
|
||||
if source_ref:
|
||||
cmd += ["--source-ref", source_ref]
|
||||
|
||||
path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-skill-publish-command.sh"
|
||||
shell_line = " ".join(shlex.quote(part) for part in cmd)
|
||||
path.write_text("#!/usr/bin/env bash\nset -euo pipefail\n" + shell_line + "\n", encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
print(shell_line)
|
||||
results = {"wouldPublish": [], "published": [], "alreadySynced": [], "skipped": [], "failed": []}
|
||||
status_keys = {
|
||||
"would-publish": "wouldPublish",
|
||||
"published": "published",
|
||||
"unchanged": "alreadySynced",
|
||||
}
|
||||
|
||||
for target in targets:
|
||||
relative_path = target.relative_to(workspace).as_posix()
|
||||
command = [
|
||||
"bun", str(cli_entry),
|
||||
"--workdir", str(workspace),
|
||||
"--site", os.environ["INPUT_SITE"],
|
||||
"--registry", os.environ["INPUT_REGISTRY"],
|
||||
"skill", "publish", relative_path,
|
||||
"--json",
|
||||
"--source-repo", os.environ["SOURCE_REPOSITORY"],
|
||||
"--source-commit", source_commit,
|
||||
"--source-path", relative_path,
|
||||
]
|
||||
if dry_run:
|
||||
command.append("--dry-run")
|
||||
if owner:
|
||||
command += ["--owner", owner]
|
||||
if tags:
|
||||
command += ["--tags", tags]
|
||||
if source_ref:
|
||||
command += ["--source-ref", source_ref]
|
||||
|
||||
completed = subprocess.run(command, cwd=workspace, capture_output=True, text=True)
|
||||
if completed.returncode != 0:
|
||||
message = completed.stderr.strip() or completed.stdout.strip() or f"exit {completed.returncode}"
|
||||
results["failed"].append({"slug": target.name, "folder": relative_path, "message": message})
|
||||
continue
|
||||
try:
|
||||
result = json.loads(completed.stdout)
|
||||
results[status_keys[result["status"]]].append(result)
|
||||
except (KeyError, ValueError, json.JSONDecodeError) as exc:
|
||||
results["failed"].append({"slug": target.name, "folder": relative_path, "message": f"Invalid publish output: {exc}"})
|
||||
|
||||
output = {
|
||||
"ok": not results["failed"],
|
||||
"dryRun": dry_run,
|
||||
"registry": os.environ["INPUT_REGISTRY"],
|
||||
"roots": [skill_path or root_input],
|
||||
**({"owner": owner.lstrip("@") } if owner else {}),
|
||||
"summary": {key: len(value) for key, value in results.items()},
|
||||
**results,
|
||||
}
|
||||
output_path = Path(os.environ["RUNNER_TEMP"]) / "skill-publish.json"
|
||||
output_path.write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(output, indent=2))
|
||||
if results["failed"]:
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
- name: Run skill sync
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"$RUNNER_TEMP/clawhub-skill-publish-command.sh" | tee "$RUNNER_TEMP/skill-publish.json"
|
||||
|
||||
- name: Capture workflow outputs
|
||||
id: capture
|
||||
run: |
|
||||
@@ -274,11 +297,8 @@ jobs:
|
||||
from pathlib import Path
|
||||
|
||||
output_path = Path(os.environ["RUNNER_TEMP"]) / "skill-publish.json"
|
||||
raw = output_path.read_text(encoding="utf-8").strip()
|
||||
parsed = json.loads(raw)
|
||||
|
||||
github_output = Path(os.environ["GITHUB_OUTPUT"])
|
||||
with github_output.open("a", encoding="utf-8") as fh:
|
||||
parsed = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as fh:
|
||||
fh.write("publish_json<<__CLAWHUB_JSON__\n")
|
||||
fh.write(json.dumps(parsed, indent=2))
|
||||
fh.write("\n__CLAWHUB_JSON__\n")
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.22.0 - 2026-06-15
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI: remove the `clawhub sync` command. `clawhub skill publish <path>` now skips unchanged content, defaults new skills to `1.0.0`, defaults changed skills to the next patch version, and supports dry-run/JSON output.
|
||||
- GitHub Actions: preserve catalog publishing through the reusable `skill-publish.yml` workflow, which invokes ordinary `skill publish` once per skill folder.
|
||||
|
||||
## 0.21.0 - 2026-06-11
|
||||
|
||||
### Changes
|
||||
|
||||
+1
-1
@@ -208,7 +208,7 @@ Manual smoke tests are documented in [`specs/manual-testing.md`](specs/manual-te
|
||||
## Skill Publishing
|
||||
|
||||
- Skill format reference: [`docs/skill-format.md`](docs/skill-format.md)
|
||||
- End-to-end walkthrough (search, install, publish, sync): [`docs/quickstart.md`](docs/quickstart.md)
|
||||
- End-to-end walkthrough (search, install, and publish): [`docs/quickstart.md`](docs/quickstart.md)
|
||||
|
||||
Quick publish:
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ Common CLI flows:
|
||||
- Browse unified catalog (skills + plugins): `clawhub package explore`, `clawhub package inspect <name>`
|
||||
- Manage local installs: `clawhub install <slug>`, `clawhub pin <slug>`, `clawhub unpin <slug>`, `clawhub uninstall <slug>`, `clawhub list`, `clawhub update --all`
|
||||
- Inspect without installing: `clawhub inspect <slug>`
|
||||
- Publish/sync skills: `clawhub skill publish <path>`, `clawhub sync`
|
||||
- Publish skills: `clawhub skill publish <path>`
|
||||
- Publish plugins: `clawhub package publish <source>`
|
||||
- Code-plugin manifests must include `openclaw.compat.pluginApi` and `openclaw.build.openclawVersion`; see [`docs/cli.md`](docs/cli.md) for a minimal example.
|
||||
- Canonicalize owned skills: `clawhub skill rename <slug> <new-slug>`, `clawhub skill merge <source> <target>`
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ Reading order:
|
||||
6. `docs/skill-format.md`: skill bundle metadata and package shape.
|
||||
7. `docs/auth.md`: GitHub OAuth, API tokens, and CLI login.
|
||||
8. `docs/telemetry.md`: install telemetry and how to opt out.
|
||||
9. `docs/troubleshooting.md`: user-facing CLI, install, publish, sync, update, and API fixes.
|
||||
9. `docs/troubleshooting.md`: user-facing CLI, install, publish, update, and API fixes.
|
||||
|
||||
Policy, API, and trust docs:
|
||||
|
||||
|
||||
+2
-3
@@ -14,7 +14,7 @@ sidebarTitle: "ClawHub"
|
||||
ClawHub is the public registry for OpenClaw skills and plugins.
|
||||
|
||||
- Use native `openclaw` commands to search, install, and update skills and to install plugins from ClawHub.
|
||||
- Use the separate `clawhub` CLI for registry auth, publishing, sync, and delete/undelete workflows.
|
||||
- Use the separate `clawhub` CLI for registry auth, publishing, and delete/undelete workflows.
|
||||
|
||||
Site: [clawhub.ai](https://clawhub.ai)
|
||||
|
||||
@@ -37,7 +37,7 @@ openclaw plugins update --all
|
||||
```
|
||||
|
||||
Install the ClawHub CLI when you want registry-authenticated workflows such as
|
||||
publish, sync, or delete/undelete:
|
||||
publish or delete/undelete:
|
||||
|
||||
```bash
|
||||
npm i -g clawhub
|
||||
@@ -85,7 +85,6 @@ clawhub package explore --family code-plugin
|
||||
clawhub package inspect episodic-claw
|
||||
clawhub package publish your-org/your-plugin --dry-run
|
||||
clawhub package publish your-org/your-plugin
|
||||
clawhub sync --all
|
||||
```
|
||||
|
||||
The CLI also has skill install/update commands for direct registry workflows:
|
||||
|
||||
+24
-69
@@ -1,8 +1,8 @@
|
||||
---
|
||||
summary: "CLI reference: commands, flags, config, lockfile, and sync behavior."
|
||||
summary: "CLI reference: commands, flags, config, and lockfile behavior."
|
||||
read_when:
|
||||
- Using the ClawHub CLI
|
||||
- Debugging install, update, publish, or sync
|
||||
- Debugging install, update, or publish
|
||||
---
|
||||
|
||||
# CLI
|
||||
@@ -175,8 +175,14 @@ Stores your API token + cached registry URL.
|
||||
|
||||
### `skill publish <path>`
|
||||
|
||||
- Publishes via `POST /api/v1/skills` (multipart).
|
||||
- Requires semver: `--version 1.2.3`.
|
||||
- Compares the local bundle fingerprint with ClawHub and exits successfully when
|
||||
the content is already published.
|
||||
- New skills default to `1.0.0`; changed skills default to the next patch
|
||||
version.
|
||||
- `--version <version>` explicitly selects a version and publishes even when the
|
||||
content matches an existing version.
|
||||
- `--dry-run` resolves the publish without uploading; `--json` prints a
|
||||
machine-readable result.
|
||||
- `--owner <handle>` publishes under an org/user publisher handle when the
|
||||
actor has publisher access.
|
||||
- `--migrate-owner` moves an existing skill to `--owner` while publishing a new
|
||||
@@ -188,9 +194,22 @@ Stores your API token + cached registry URL.
|
||||
- Legacy alias: `publish <path>`.
|
||||
|
||||
```bash
|
||||
clawhub skill publish ./my-skill --version 1.0.0
|
||||
clawhub skill publish ./my-skill --dry-run
|
||||
clawhub skill publish ./my-skill
|
||||
clawhub skill publish ./my-skill --version 2.0.0
|
||||
```
|
||||
|
||||
#### GitHub Actions
|
||||
|
||||
ClawHub's reusable
|
||||
[`skill-publish.yml`](https://github.com/openclaw/clawhub/blob/main/.github/workflows/skill-publish.yml)
|
||||
workflow calls `skill publish` for one `skill_path`, or for each immediate skill
|
||||
folder under `root` (default: `skills`). It skips unchanged skills and uses the
|
||||
same automatic patch-version behavior.
|
||||
|
||||
Set `dry_run: true` to preview without a token. Real publishes require the
|
||||
`clawhub_token` secret.
|
||||
|
||||
### `scan --slug <slug>`
|
||||
|
||||
- Requires `clawhub login`.
|
||||
@@ -222,46 +241,6 @@ clawhub scan download gifgrep --version 1.2.3
|
||||
clawhub scan download @scope/demo --version 2.0.0 --kind plugin --output report.zip
|
||||
```
|
||||
|
||||
#### GitHub Actions
|
||||
|
||||
ClawHub ships an official reusable workflow at
|
||||
[`/.github/workflows/skill-publish.yml`](../.github/workflows/skill-publish.yml)
|
||||
for skill repos and catalog repos.
|
||||
|
||||
Typical catalog setup:
|
||||
|
||||
```yaml
|
||||
name: Skill Publish
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dry-run:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: openclaw/clawhub/.github/workflows/skill-publish.yml@v1
|
||||
with:
|
||||
owner: nvidia
|
||||
dry_run: true
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: openclaw/clawhub/.github/workflows/skill-publish.yml@v1
|
||||
with:
|
||||
owner: nvidia
|
||||
dry_run: false
|
||||
secrets:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `root` defaults to `skills` for catalog repos.
|
||||
- Pass `skill_path: skills/review-helper` to process one skill folder.
|
||||
- `owner` maps to the CLI `--owner` flag; omit it to publish as the authenticated user.
|
||||
- V1 skill publishing uses `clawhub_token`; GitHub OIDC trusted publishing is package-only for now.
|
||||
|
||||
### `delete <slug>`
|
||||
|
||||
- Without `--version`, soft-delete a skill (owner, moderator, or admin).
|
||||
@@ -776,30 +755,6 @@ Example:
|
||||
clawhub package trusted-publisher delete @openclaw/example-plugin
|
||||
```
|
||||
|
||||
### `sync`
|
||||
|
||||
- Scans for local skill folders and publishes new/changed ones.
|
||||
- Roots can be any folder: a skills directory or a single skill folder with `SKILL.md`.
|
||||
- Auto-adds Clawdbot skill roots when `~/.clawdbot/clawdbot.json` is present:
|
||||
- `agent.workspace/skills` (main agent)
|
||||
- `routing.agents.*.workspace/skills` (per-agent)
|
||||
- `~/.clawdbot/skills` (shared)
|
||||
- `skills.load.extraDirs` (shared packs)
|
||||
- Respects `CLAWDBOT_CONFIG_PATH` / `CLAWDBOT_STATE_DIR` and `OPENCLAW_CONFIG_PATH` / `OPENCLAW_STATE_DIR`.
|
||||
- Flags:
|
||||
- `--root <dir...>` extra scan roots
|
||||
- `--all` upload without prompting
|
||||
- `--dry-run` show plan only
|
||||
- `--json` machine-readable summary for CI
|
||||
- `--owner <handle>` publish under a user or org publisher
|
||||
- `--bump patch|minor|major` (default: patch)
|
||||
- `--changelog <text>` (non-interactive)
|
||||
- `--tags a,b,c` (default: latest)
|
||||
- `--concurrency <n>`
|
||||
- `--source-repo <repo>`, `--source-commit <sha>`, `--source-ref <ref>` for GitHub provenance
|
||||
|
||||
`sync` does not report install telemetry.
|
||||
|
||||
### Install telemetry
|
||||
|
||||
- Sent after `clawhub install <slug>` when logged in, unless
|
||||
|
||||
+9
-104
@@ -26,102 +26,22 @@ clawhub login
|
||||
clawhub skill publish ./my-skill \
|
||||
--slug my-skill \
|
||||
--name "My Skill" \
|
||||
--version 1.0.0 \
|
||||
--owner <owner>
|
||||
```
|
||||
|
||||
Use `--owner <handle>` when publishing to an org owner. Omit it to publish as
|
||||
the authenticated user.
|
||||
the authenticated user. Publishing skips unchanged content. A new skill starts
|
||||
at `1.0.0`, and later changes automatically publish the next patch version. Pass
|
||||
`--version` only when you need an explicit version.
|
||||
|
||||
For catalog repos, use `sync` to scan folders containing `SKILL.md` and publish
|
||||
new or changed skills:
|
||||
|
||||
```bash
|
||||
clawhub sync --dry-run --owner <owner>
|
||||
clawhub sync --all --owner <owner>
|
||||
```
|
||||
|
||||
Use `--dry-run` first to see the plan without uploading.
|
||||
|
||||
### GitHub Actions for Skills
|
||||
|
||||
If you want to run skill publishing from CI, call ClawHub's reusable
|
||||
[`skill-publish.yml` workflow](https://github.com/openclaw/clawhub/blob/main/.github/workflows/skill-publish.yml)
|
||||
from a small workflow in your repo.
|
||||
|
||||
The example below is shaped for a catalog repo: operators choose whether to
|
||||
preview the full catalog, publish one skill folder, or publish the whole
|
||||
catalog.
|
||||
For catalog repos, use ClawHub's reusable
|
||||
[`skill-publish.yml` workflow](https://github.com/openclaw/clawhub/blob/main/.github/workflows/skill-publish.yml).
|
||||
It calls `skill publish` for each immediate skill folder under `root` (default:
|
||||
`skills`), or only the folder supplied as `skill_path`.
|
||||
|
||||
```yaml
|
||||
name: Publish Skills to ClawHub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: What to run.
|
||||
type: choice
|
||||
required: true
|
||||
default: dry-run
|
||||
options:
|
||||
- dry-run
|
||||
- publish-single
|
||||
- publish-catalog
|
||||
skill_path:
|
||||
description: Skill folder for publish-single, for example skills/<slug>.
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
validate-single:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'publish-single'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate single-skill input
|
||||
env:
|
||||
SKILL_PATH: ${{ inputs.skill_path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${SKILL_PATH}" ]]; then
|
||||
echo "::error::skill_path is required when mode is publish-single."
|
||||
exit 1
|
||||
fi
|
||||
case "${SKILL_PATH}" in
|
||||
skills/*) ;;
|
||||
*)
|
||||
echo "::error::skill_path must point under skills/, for example skills/<slug>."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
dry-run:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'dry-run'
|
||||
uses: openclaw/clawhub/.github/workflows/skill-publish.yml@main
|
||||
with:
|
||||
owner: <owner>
|
||||
dry_run: true
|
||||
secrets:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
|
||||
publish-single:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'publish-single'
|
||||
needs: validate-single
|
||||
uses: openclaw/clawhub/.github/workflows/skill-publish.yml@main
|
||||
with:
|
||||
owner: <owner>
|
||||
skill_path: ${{ inputs.skill_path }}
|
||||
dry_run: false
|
||||
secrets:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
|
||||
publish-catalog:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'publish-catalog'
|
||||
publish:
|
||||
uses: openclaw/clawhub/.github/workflows/skill-publish.yml@main
|
||||
with:
|
||||
owner: <owner>
|
||||
@@ -130,22 +50,7 @@ jobs:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
```
|
||||
|
||||
Replace `<owner>` with your ClawHub owner handle. The called workflow defaults to
|
||||
scanning `skills/`; pass `skill_path` only when you want to process one folder.
|
||||
|
||||
Before running a real publish, sign in as a ClawHub user that can publish to the
|
||||
selected owner, then store the current CLI token as a `CLAWHUB_TOKEN` repository
|
||||
secret:
|
||||
|
||||
```bash
|
||||
clawhub login --label "Skills GitHub Actions"
|
||||
gh secret set CLAWHUB_TOKEN \
|
||||
--repo OWNER/REPO \
|
||||
--body "$(clawhub token)"
|
||||
```
|
||||
|
||||
Start with `dry-run`, then publish one skill with `publish-single`, and only then
|
||||
use `publish-catalog` for the full catalog.
|
||||
Use `dry_run: true` to preview new and changed skills without publishing.
|
||||
|
||||
## Plugins
|
||||
|
||||
|
||||
+15
-23
@@ -92,14 +92,28 @@ files.
|
||||
clawhub skill publish ./my-skill \
|
||||
--slug my-skill \
|
||||
--name "My Skill" \
|
||||
--version 1.0.0 \
|
||||
--changelog "Initial release"
|
||||
```
|
||||
|
||||
The command skips unchanged content. New skills start at `1.0.0`; later changes
|
||||
automatically publish the next patch version. Use `--dry-run` to preview or
|
||||
`--version` to choose an explicit version.
|
||||
|
||||
Before publishing, check the metadata in `SKILL.md`. Declare required
|
||||
environment variables, tools, and permissions so users can understand what the
|
||||
skill needs before they install it. See [Skill format](./skill-format.md).
|
||||
|
||||
For repositories containing multiple skills, the reusable GitHub workflow calls
|
||||
`skill publish` for each immediate skill folder under `skills/`:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
preview:
|
||||
uses: openclaw/clawhub/.github/workflows/skill-publish.yml@main
|
||||
with:
|
||||
dry_run: true
|
||||
```
|
||||
|
||||
## Publish a plugin
|
||||
|
||||
Publish a plugin from a local folder, a GitHub repo, a GitHub ref, or an
|
||||
@@ -116,28 +130,6 @@ fields, source attribution, and upload plan without publishing.
|
||||
Code plugins must include OpenClaw compatibility metadata in `package.json`,
|
||||
including `openclaw.compat.pluginApi` and `openclaw.build.openclawVersion`.
|
||||
|
||||
## Sync skills you maintain
|
||||
|
||||
`sync` scans skill folders and publishes new or changed skills that are not
|
||||
already synchronized.
|
||||
|
||||
```bash
|
||||
clawhub sync --all --dry-run
|
||||
clawhub sync --all
|
||||
```
|
||||
|
||||
For catalog repos, ClawHub also provides a reusable GitHub workflow. By
|
||||
default it scans `skills/`; pass `skill_path` to process one folder.
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
dry-run:
|
||||
uses: openclaw/clawhub/.github/workflows/skill-publish.yml@v1
|
||||
with:
|
||||
owner: nvidia
|
||||
dry_run: true
|
||||
```
|
||||
|
||||
## Inspect before installing
|
||||
|
||||
Before installing, use the ClawHub web page or CLI detail commands to inspect
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "Skill folder format, required files, allowed file types, limits."
|
||||
read_when:
|
||||
- Publishing skills
|
||||
- Debugging publish/sync failures
|
||||
- Debugging publish failures
|
||||
---
|
||||
|
||||
# Skill format
|
||||
@@ -18,7 +18,7 @@ Required:
|
||||
Optional:
|
||||
|
||||
- any supporting _text-based_ files (see “Allowed files”)
|
||||
- `.clawhubignore` (ignore patterns for publish/sync, legacy `.clawdhubignore`)
|
||||
- `.clawhubignore` (ignore patterns for publishing, legacy `.clawdhubignore`)
|
||||
- `.gitignore` (also honored)
|
||||
|
||||
Local install metadata (written by the CLI):
|
||||
|
||||
+1
-17
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Troubleshooting ClawHub sign-in, install, publish, sync, update, and API issues."
|
||||
summary: "Troubleshooting ClawHub sign-in, install, publish, update, and API issues."
|
||||
read_when:
|
||||
- ClawHub CLI or OpenClaw registry commands fail
|
||||
- A package cannot be installed, published, or updated
|
||||
@@ -87,22 +87,6 @@ publishers.
|
||||
- Check that the source URL is public or accessible to ClawHub.
|
||||
- For GitHub sources, use `owner/repo`, `owner/repo@ref`, or a full GitHub URL.
|
||||
|
||||
## `sync` says no skills were found
|
||||
|
||||
`sync` looks for folders containing `SKILL.md` or `skill.md`.
|
||||
|
||||
Point it at the roots you want to scan:
|
||||
|
||||
```bash
|
||||
clawhub sync --root /path/to/skills
|
||||
```
|
||||
|
||||
Preview first if you are unsure what will publish:
|
||||
|
||||
```bash
|
||||
clawhub sync --all --dry-run --no-input
|
||||
```
|
||||
|
||||
## `update` refuses because of local changes
|
||||
|
||||
The local files do not match any version ClawHub knows about. Choose one:
|
||||
|
||||
@@ -447,135 +447,6 @@ describe("clawhub e2e", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sync dry-run finds skills from an explicit root", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const root = await mkdtemp(join(tmpdir(), "clawhub-e2e-sync-"));
|
||||
try {
|
||||
const skillDir = join(root, "cool-skill");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Skill\n", "utf8");
|
||||
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"sync",
|
||||
"--dry-run",
|
||||
"--all",
|
||||
"--root",
|
||||
root,
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/error:/i);
|
||||
expect(result.stdout).toMatch(/Dry run/i);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sync continues after a per-skill publish failure", async () => {
|
||||
const publishedSlugs: string[] = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (req.method === "GET" && url.pathname === ApiRoutes.whoami) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ user: { handle: "tester" } }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === ApiRoutes.resolve) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Skill not found");
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === ApiRoutes.skills) {
|
||||
const body = await readRequestBody(req);
|
||||
const slug = body.includes('"slug":"failed-skill"') ? "failed-skill" : "successful-skill";
|
||||
publishedSlugs.push(slug);
|
||||
if (slug === "failed-skill") {
|
||||
res.writeHead(409, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Registry rejected failed-skill");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, skillId: "skill-successful", versionId: "version-1" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
const cfg = await makeTempConfig(registry, "test-token");
|
||||
const root = await mkdtemp(join(tmpdir(), "clawhub-e2e-sync-publish-"));
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-sync-workdir-"));
|
||||
const stateDir = await mkdtemp(join(tmpdir(), "clawhub-e2e-sync-state-"));
|
||||
try {
|
||||
for (const slug of ["failed-skill", "successful-skill"]) {
|
||||
const skillDir = join(root, slug);
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), `# ${slug}\n`, "utf8");
|
||||
}
|
||||
|
||||
const result = await spawnCommand(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"sync",
|
||||
"--all",
|
||||
"--root",
|
||||
root,
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--site",
|
||||
registry,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1",
|
||||
CLAWDBOT_STATE_DIR: stateDir,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(publishedSlugs).toEqual(["failed-skill", "successful-skill"]);
|
||||
expect(result.stdout).toMatch(/Failed to upload/);
|
||||
expect(result.stdout).toMatch(/failed-skill: Registry rejected failed-skill/);
|
||||
expect(result.stdout).toMatch(/Uploaded 1 of 2 skill\(s\)\. 1 failed\./);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("update resolves installed bundle fingerprints that include skill-card.md", async () => {
|
||||
let resolvedHash: string | null = null;
|
||||
const server = createServer(async (req, res) => {
|
||||
@@ -793,61 +664,6 @@ describe("clawhub e2e", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sync dry-run finds skills from clawdbot.json roots", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const root = await mkdtemp(join(tmpdir(), "clawhub-e2e-clawdbot-"));
|
||||
const stateDir = join(root, "state");
|
||||
const configPath = join(root, "clawdbot.json");
|
||||
const workspace = join(root, "clawd-work");
|
||||
const skillsRoot = join(workspace, "skills");
|
||||
const skillDir = join(skillsRoot, "auto-skill");
|
||||
|
||||
try {
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Skill\n", "utf8");
|
||||
|
||||
const config = `{
|
||||
// JSON5-style comments + trailing commas
|
||||
routing: {
|
||||
agents: {
|
||||
work: { name: 'Work', workspace: '${workspace}', },
|
||||
},
|
||||
},
|
||||
}`;
|
||||
await writeFile(configPath, config, "utf8");
|
||||
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "sync", "--dry-run", "--all", "--site", site, "--registry", registry],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1",
|
||||
CLAWDBOT_CONFIG_PATH: configPath,
|
||||
CLAWDBOT_STATE_DIR: stateDir,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/error:/i);
|
||||
expect(result.stdout).toMatch(/Dry run/i);
|
||||
expect(result.stdout).toMatch(/auto-skill/i);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("package publish --dry-run from a GitHub repo shows a summary", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
|
||||
@@ -49,8 +49,8 @@ clawhub pin bear-notes --reason "scanner-flagged while awaiting moderation"
|
||||
clawhub update --all
|
||||
clawhub update --all --no-input --force
|
||||
clawhub unpin bear-notes
|
||||
clawhub skill publish ./my-skill-pack --slug my-skill-pack --name "My Skill Pack" --version 1.2.0 --changelog "Fixes + docs"
|
||||
clawhub skill publish ./org-skill --owner openclaw --version 1.2.0 --changelog "Org publish"
|
||||
clawhub skill publish ./my-skill-pack --slug my-skill-pack --name "My Skill Pack" --changelog "Fixes + docs"
|
||||
clawhub skill publish ./org-skill --owner openclaw --changelog "Org publish"
|
||||
clawhub package explore --family skill
|
||||
clawhub package explore --family code-plugin
|
||||
clawhub package inspect @openclaw/example-plugin
|
||||
@@ -170,16 +170,6 @@ bun run --cwd packages/clawhub verify
|
||||
|
||||
`test` runs source tests only. `test:artifact` builds `dist/` and runs a small smoke suite against the built CLI entrypoint.
|
||||
|
||||
## Sync (upload local skills)
|
||||
|
||||
```bash
|
||||
# Start anywhere; scans workdir first, then legacy Clawdis/Clawd/OpenClaw/Moltbot locations.
|
||||
clawhub sync
|
||||
|
||||
# Explicit roots + non-interactive dry-run
|
||||
clawhub sync --root ../clawdis/skills --all --dry-run
|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
- Site: `https://clawhub.ai` (override via `--site` or `CLAWHUB_SITE`, legacy `CLAWDHUB_SITE`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.0",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
|
||||
"homepage": "https://clawhub.ai",
|
||||
"bugs": {
|
||||
|
||||
+15
-60
@@ -46,7 +46,6 @@ import {
|
||||
cmdUpdate,
|
||||
} from "./cli/commands/skills.js";
|
||||
import { cmdStarSkill } from "./cli/commands/star.js";
|
||||
import { cmdSync } from "./cli/commands/sync.js";
|
||||
import {
|
||||
cmdTransferAccept,
|
||||
cmdTransferCancel,
|
||||
@@ -59,7 +58,6 @@ import { configureCommanderHelp, styleEnvBlock, styleTitle } from "./cli/helpSty
|
||||
import { DEFAULT_REGISTRY, DEFAULT_SITE } from "./cli/registry.js";
|
||||
import type { GlobalOpts } from "./cli/types.js";
|
||||
import { fail } from "./cli/ui.js";
|
||||
import { readGlobalConfig } from "./config.js";
|
||||
|
||||
const program = new Command()
|
||||
.name("clawhub")
|
||||
@@ -368,10 +366,16 @@ registerCommand(program, ["publish"])
|
||||
.option("--name <name>", "Display name")
|
||||
.option("--owner <handle>", "Publish under an org/user publisher handle")
|
||||
.option("--migrate-owner", "Move an existing skill to the selected owner when republishing")
|
||||
.option("--version <version>", "Version (semver)")
|
||||
.option("--version <version>", "Explicit version (defaults to 1.0.0 or next patch)")
|
||||
.option("--fork-of <slug[@version]>", "Mark as a fork of an existing skill")
|
||||
.option("--changelog <text>", "Changelog text")
|
||||
.option("--tags <tags>", "Comma-separated tags", "latest")
|
||||
.option("--dry-run", "Preview without publishing")
|
||||
.option("--json", "Output JSON")
|
||||
.option("--source-repo <repo>", "GitHub source repository")
|
||||
.option("--source-commit <sha>", "GitHub source commit")
|
||||
.option("--source-ref <ref>", "GitHub source ref")
|
||||
.option("--source-path <path>", "Path to the skill within the source repository")
|
||||
.action(async (folder, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPublish(opts, folder, options);
|
||||
@@ -462,10 +466,16 @@ registerCommand(skill, ["skill", "publish"])
|
||||
.option("--name <name>", "Display name")
|
||||
.option("--owner <handle>", "Publish under an org/user publisher handle")
|
||||
.option("--migrate-owner", "Move an existing skill to the selected owner when republishing")
|
||||
.option("--version <version>", "Version (semver)")
|
||||
.option("--version <version>", "Explicit version (defaults to 1.0.0 or next patch)")
|
||||
.option("--fork-of <slug[@version]>", "Mark as a fork of an existing skill")
|
||||
.option("--changelog <text>", "Changelog text")
|
||||
.option("--tags <tags>", "Comma-separated tags", "latest")
|
||||
.option("--dry-run", "Preview without publishing")
|
||||
.option("--json", "Output JSON")
|
||||
.option("--source-repo <repo>", "GitHub source repository")
|
||||
.option("--source-commit <sha>", "GitHub source commit")
|
||||
.option("--source-ref <ref>", "GitHub source ref")
|
||||
.option("--source-path <path>", "Path to the skill within the source repository")
|
||||
.action(async (folder, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPublish(opts, folder, options);
|
||||
@@ -827,62 +837,7 @@ registerCommand(program, ["unstar"])
|
||||
await cmdUnstarSkill(opts, slug, options, isInputAllowed());
|
||||
});
|
||||
|
||||
registerCommand(program, ["sync"])
|
||||
.description("Scan local skills and publish new/updated ones")
|
||||
.option("--root <dir...>", "Extra scan roots (one or more)")
|
||||
.option("--all", "Upload all new/updated skills without prompting")
|
||||
.option("--dry-run", "Show what would be uploaded")
|
||||
.option("--json", "Output JSON")
|
||||
.option("--owner <handle>", "Publish under an org/user publisher handle")
|
||||
.option("--bump <type>", "Version bump for updates (patch|minor|major)", "patch")
|
||||
.option("--changelog <text>", "Changelog to use for updates (non-interactive)")
|
||||
.option("--tags <tags>", "Comma-separated tags", "latest")
|
||||
.option("--concurrency <n>", "Concurrent registry/file checks", (value) =>
|
||||
Number.parseInt(value, 10),
|
||||
)
|
||||
.option("--source-repo <repo>", "GitHub repo URL or owner/name for source provenance")
|
||||
.option("--source-commit <sha>", "Git commit SHA for source provenance")
|
||||
.option("--source-ref <ref>", "Git ref for source provenance")
|
||||
.addOption(
|
||||
new Option("--clawdbot-roots", "Include Clawdbot-configured roots").default(true, "enabled"),
|
||||
)
|
||||
.addOption(new Option("--no-clawdbot-roots", "Disable Clawdbot-configured roots"))
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
const bump =
|
||||
options.bump === "patch" || options.bump === "minor" || options.bump === "major"
|
||||
? options.bump
|
||||
: fail("--bump must be patch, minor, or major");
|
||||
const concurrency = options.concurrency ?? 6;
|
||||
if (concurrency < 1 || concurrency > 32) fail("--concurrency must be between 1 and 32");
|
||||
await cmdSync(
|
||||
opts,
|
||||
{
|
||||
root: options.root,
|
||||
all: options.all,
|
||||
dryRun: options.dryRun,
|
||||
json: options.json,
|
||||
owner: options.owner,
|
||||
bump,
|
||||
changelog: options.changelog,
|
||||
tags: options.tags,
|
||||
concurrency,
|
||||
clawdbotRoots: options.clawdbotRoots,
|
||||
sourceRepo: options.sourceRepo,
|
||||
sourceCommit: options.sourceCommit,
|
||||
sourceRef: options.sourceRef,
|
||||
},
|
||||
isInputAllowed(),
|
||||
);
|
||||
});
|
||||
|
||||
program.action(async () => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
const cfg = await readGlobalConfig();
|
||||
if (cfg?.token) {
|
||||
await cmdSync(opts, {}, isInputAllowed());
|
||||
return;
|
||||
}
|
||||
program.action(() => {
|
||||
program.outputHelp();
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveHome } from "../homedir.js";
|
||||
import { resolveClawdbotDefaultWorkspace, resolveClawdbotSkillRoots } from "./clawdbotConfig.js";
|
||||
import { resolveClawdbotDefaultWorkspace } from "./clawdbotConfig.js";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
@@ -12,65 +12,7 @@ afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe("resolveClawdbotSkillRoots", () => {
|
||||
it("reads JSON5 config and resolves per-agent + shared skill roots", async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), "clawhub-clawdbot-"));
|
||||
const home = join(base, "home");
|
||||
const stateDir = join(base, "state");
|
||||
const configPath = join(base, "clawdbot.json");
|
||||
const openclawStateDir = join(base, "openclaw-state");
|
||||
|
||||
process.env.HOME = home;
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
process.env.CLAWDBOT_CONFIG_PATH = configPath;
|
||||
process.env.OPENCLAW_STATE_DIR = openclawStateDir;
|
||||
process.env.OPENCLAW_CONFIG_PATH = join(openclawStateDir, "openclaw.json");
|
||||
|
||||
const config = `{
|
||||
// JSON5 comments + trailing commas supported
|
||||
agents: {
|
||||
defaults: { workspace: '~/clawd-main', },
|
||||
list: [
|
||||
{ id: 'work', name: 'Work Bot', workspace: '~/clawd-work', },
|
||||
{ id: 'family', workspace: '~/clawd-family', },
|
||||
],
|
||||
},
|
||||
// legacy entries still supported
|
||||
agent: { workspace: '~/clawd-legacy', },
|
||||
routing: {
|
||||
agents: {
|
||||
work: { name: 'Work Bot', workspace: '~/clawd-work', },
|
||||
family: { workspace: '~/clawd-family' },
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
load: { extraDirs: ['~/shared/skills', '/opt/skills',], },
|
||||
},
|
||||
}`;
|
||||
await writeFile(configPath, config, "utf8");
|
||||
|
||||
const { roots, labels } = await resolveClawdbotSkillRoots();
|
||||
|
||||
const expectedRoots = [
|
||||
resolve(stateDir, "skills"),
|
||||
resolve(openclawStateDir, "skills"),
|
||||
resolve(home, "clawd-main", "skills"),
|
||||
resolve(home, "clawd-work", "skills"),
|
||||
resolve(home, "clawd-family", "skills"),
|
||||
resolve(home, "shared", "skills"),
|
||||
resolve("/opt/skills"),
|
||||
];
|
||||
|
||||
expect(roots).toEqual(expect.arrayContaining(expectedRoots));
|
||||
expect(labels[resolve(stateDir, "skills")]).toBe("Shared skills");
|
||||
expect(labels[resolve(openclawStateDir, "skills")]).toBe("OpenClaw: Shared skills");
|
||||
expect(labels[resolve(home, "clawd-main", "skills")]).toBe("Agent: main");
|
||||
expect(labels[resolve(home, "clawd-work", "skills")]).toBe("Agent: Work Bot");
|
||||
expect(labels[resolve(home, "clawd-family", "skills")]).toBe("Agent: family");
|
||||
expect(labels[resolve(home, "shared", "skills")]).toBe("Extra: skills");
|
||||
expect(labels[resolve("/opt/skills")]).toBe("Extra: skills");
|
||||
});
|
||||
|
||||
describe("resolveClawdbotDefaultWorkspace", () => {
|
||||
it("resolves default workspace from agents.defaults and agents.list", async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), "clawhub-clawdbot-default-"));
|
||||
const home = join(base, "home");
|
||||
@@ -146,36 +88,8 @@ describe("resolveClawdbotSkillRoots", () => {
|
||||
await mkdir(join(base, "config"), { recursive: true });
|
||||
await writeFile(configPath, config, "utf8");
|
||||
|
||||
const { roots, labels } = await resolveClawdbotSkillRoots();
|
||||
|
||||
expect(roots).toEqual(
|
||||
expect.arrayContaining([
|
||||
resolve(stateDir, "skills"),
|
||||
resolve(openclawStateDir, "skills"),
|
||||
resolve(join(base, "workspace-main"), "skills"),
|
||||
]),
|
||||
);
|
||||
expect(labels[resolve(stateDir, "skills")]).toBe("Shared skills");
|
||||
expect(labels[resolve(openclawStateDir, "skills")]).toBe("OpenClaw: Shared skills");
|
||||
expect(labels[resolve(join(base, "workspace-main"), "skills")]).toBe("Agent: main");
|
||||
});
|
||||
|
||||
it("returns shared skills root when config is missing", async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), "clawhub-clawdbot-missing-"));
|
||||
const stateDir = join(base, "state");
|
||||
const configPath = join(base, "missing", "clawdbot.json");
|
||||
const openclawStateDir = join(base, "openclaw-state");
|
||||
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
process.env.CLAWDBOT_CONFIG_PATH = configPath;
|
||||
process.env.OPENCLAW_STATE_DIR = openclawStateDir;
|
||||
process.env.OPENCLAW_CONFIG_PATH = join(openclawStateDir, "openclaw.json");
|
||||
|
||||
const { roots, labels } = await resolveClawdbotSkillRoots();
|
||||
|
||||
expect(roots).toEqual([resolve(stateDir, "skills"), resolve(openclawStateDir, "skills")]);
|
||||
expect(labels[resolve(stateDir, "skills")]).toBe("Shared skills");
|
||||
expect(labels[resolve(openclawStateDir, "skills")]).toBe("OpenClaw: Shared skills");
|
||||
const workspace = await resolveClawdbotDefaultWorkspace();
|
||||
expect(workspace).toBe(resolve(join(base, "workspace-main")));
|
||||
});
|
||||
|
||||
it("uses $HOME over os.homedir() for tilde expansion", async () => {
|
||||
@@ -228,11 +142,7 @@ describe("resolveClawdbotSkillRoots", () => {
|
||||
}`;
|
||||
await writeFile(configPath, config, "utf8");
|
||||
|
||||
const { roots, labels } = await resolveClawdbotSkillRoots();
|
||||
expect(roots).toEqual(
|
||||
expect.arrayContaining([resolve(stateDir, "skills"), resolve(workspace, "skills")]),
|
||||
);
|
||||
expect(labels[resolve(stateDir, "skills")]).toBe("OpenClaw: Shared skills");
|
||||
expect(labels[resolve(workspace, "skills")]).toBe("OpenClaw: Agent: main");
|
||||
const resolvedWorkspace = await resolveClawdbotDefaultWorkspace();
|
||||
expect(resolvedWorkspace).toBe(resolve(workspace));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { resolveHome } from "../homedir.js";
|
||||
|
||||
@@ -9,60 +9,12 @@ type ClawdbotConfig = {
|
||||
defaults?: { workspace?: string };
|
||||
list?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
default?: boolean;
|
||||
}>;
|
||||
};
|
||||
routing?: {
|
||||
agents?: Record<
|
||||
string,
|
||||
{
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
skills?: {
|
||||
load?: {
|
||||
extraDirs?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ClawdbotSkillRoots = {
|
||||
roots: string[];
|
||||
labels: Record<string, string>;
|
||||
};
|
||||
|
||||
export async function resolveClawdbotSkillRoots(): Promise<ClawdbotSkillRoots> {
|
||||
const roots: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
const clawdbotStateDir = resolveClawdbotStateDir();
|
||||
const sharedSkills = resolveUserPath(join(clawdbotStateDir, "skills"));
|
||||
pushRoot(roots, labels, sharedSkills, "Shared skills");
|
||||
|
||||
const openclawStateDir = resolveOpenclawStateDir();
|
||||
const openclawShared = resolveUserPath(join(openclawStateDir, "skills"));
|
||||
pushRoot(roots, labels, openclawShared, "OpenClaw: Shared skills");
|
||||
|
||||
const [clawdbotConfig, openclawConfig] = await Promise.all([
|
||||
readClawdbotConfig(),
|
||||
readOpenclawConfig(),
|
||||
]);
|
||||
if (!clawdbotConfig && !openclawConfig) return { roots, labels };
|
||||
|
||||
if (clawdbotConfig) {
|
||||
addConfigRoots(clawdbotConfig, roots, labels);
|
||||
}
|
||||
if (openclawConfig) {
|
||||
addConfigRoots(openclawConfig, roots, labels, "OpenClaw");
|
||||
}
|
||||
|
||||
return { roots, labels };
|
||||
}
|
||||
|
||||
export async function resolveClawdbotDefaultWorkspace(): Promise<string | null> {
|
||||
const config = await readClawdbotConfig();
|
||||
const openclawConfig = await readOpenclawConfig();
|
||||
@@ -144,61 +96,3 @@ async function readConfigFile(path: string): Promise<ClawdbotConfig | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addConfigRoots(
|
||||
config: ClawdbotConfig,
|
||||
roots: string[],
|
||||
labels: Record<string, string>,
|
||||
labelPrefix?: string,
|
||||
) {
|
||||
const prefix = labelPrefix ? `${labelPrefix}: ` : "";
|
||||
|
||||
const mainWorkspace = resolveUserPath(
|
||||
config.agents?.defaults?.workspace ?? config.agent?.workspace ?? "",
|
||||
);
|
||||
if (mainWorkspace) {
|
||||
pushRoot(roots, labels, join(mainWorkspace, "skills"), `${prefix}Agent: main`);
|
||||
}
|
||||
|
||||
const listedAgents = config.agents?.list ?? [];
|
||||
for (const entry of listedAgents) {
|
||||
const workspace = resolveUserPath(entry?.workspace ?? "");
|
||||
if (!workspace) continue;
|
||||
const name = entry?.name?.trim() || entry?.id?.trim() || "agent";
|
||||
pushRoot(roots, labels, join(workspace, "skills"), `${prefix}Agent: ${name}`);
|
||||
}
|
||||
|
||||
const agents = config.routing?.agents ?? {};
|
||||
for (const [agentId, entry] of Object.entries(agents)) {
|
||||
const workspace = resolveUserPath(entry?.workspace ?? "");
|
||||
if (!workspace) continue;
|
||||
const name = entry?.name?.trim() || agentId;
|
||||
pushRoot(roots, labels, join(workspace, "skills"), `${prefix}Agent: ${name}`);
|
||||
}
|
||||
|
||||
const extraDirs = config.skills?.load?.extraDirs ?? [];
|
||||
for (const dir of extraDirs) {
|
||||
const resolved = resolveUserPath(dir);
|
||||
if (!resolved) continue;
|
||||
const label = `${prefix}Extra: ${basename(resolved) || resolved}`;
|
||||
pushRoot(roots, labels, resolved, label);
|
||||
}
|
||||
}
|
||||
|
||||
function pushRoot(roots: string[], labels: Record<string, string>, root: string, label?: string) {
|
||||
const resolved = resolveUserPath(root);
|
||||
if (!resolved) return;
|
||||
if (!roots.includes(resolved)) roots.push(resolved);
|
||||
if (!label) return;
|
||||
const existing = labels[resolved];
|
||||
if (!existing) {
|
||||
labels[resolved] = label;
|
||||
return;
|
||||
}
|
||||
const parts = existing
|
||||
.split(", ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.includes(label)) return;
|
||||
labels[resolved] = `${existing}, ${label}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolveHome } from "../../homedir.js";
|
||||
import { apiRequest } from "../../http.js";
|
||||
import { ApiCliTelemetryInstallResponseSchema, LegacyApiRoutes } from "../../schema/index.js";
|
||||
|
||||
export async function reportInstalledSkillsTelemetryIfEnabled(params: {
|
||||
token: string | undefined;
|
||||
registry: string;
|
||||
root: string;
|
||||
slug: string;
|
||||
version?: string | null;
|
||||
}) {
|
||||
if (!params.token || isTelemetryDisabled()) return;
|
||||
const slug = params.slug.trim();
|
||||
if (!slug) return;
|
||||
|
||||
try {
|
||||
await apiRequest(
|
||||
params.registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
token: params.token,
|
||||
body: {
|
||||
event: "install",
|
||||
slug,
|
||||
version: params.version ?? undefined,
|
||||
rootId: rootTelemetryId(params.root),
|
||||
rootLabel: formatRootLabel(params.root),
|
||||
},
|
||||
},
|
||||
ApiCliTelemetryInstallResponseSchema,
|
||||
);
|
||||
} catch {
|
||||
// Install telemetry is best-effort; local installs must not fail because
|
||||
// metrics reporting is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function isTelemetryDisabled() {
|
||||
const raw = process.env.CLAWHUB_DISABLE_TELEMETRY ?? process.env.CLAWDHUB_DISABLE_TELEMETRY;
|
||||
if (!raw) return false;
|
||||
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function rootTelemetryId(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function formatRootLabel(value: string) {
|
||||
const home = resolveHome();
|
||||
if (value === home) return "~";
|
||||
|
||||
const normalized = value.replaceAll("\\", "/");
|
||||
const normalizedHome = home.replaceAll("\\", "/");
|
||||
const isHome = normalized === normalizedHome || normalized.startsWith(`${normalizedHome}/`);
|
||||
|
||||
const stripped = isHome ? normalized.slice(normalizedHome.length).replace(/^\//, "") : normalized;
|
||||
const parts = stripped.split("/").filter(Boolean);
|
||||
const tail = parts.slice(-2).join("/");
|
||||
|
||||
if (!tail) return isHome ? "~" : "…";
|
||||
return isHome ? `~/${tail}` : `…/${tail}`;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
@@ -38,7 +38,152 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
httpMocks.apiRequest.mockResolvedValue({
|
||||
match: null,
|
||||
latestVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdPublish", () => {
|
||||
it("skips publishing when the local skill already matches ClawHub", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "unchanged-skill");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8");
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
match: { version: "1.2.3" },
|
||||
latestVersion: { version: "1.2.3" },
|
||||
});
|
||||
|
||||
const result = await cmdPublish(makeOpts(workdir), "unchanged-skill", {});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "unchanged",
|
||||
slug: "unchanged-skill",
|
||||
version: "1.2.3",
|
||||
});
|
||||
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults a new skill to version 1.0.0", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "new-skill");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8");
|
||||
httpMocks.apiRequest.mockRejectedValueOnce(
|
||||
new Error("Skill not found or unavailable to this account."),
|
||||
);
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
skillId: "skill_1",
|
||||
versionId: "ver_1",
|
||||
});
|
||||
|
||||
const result = await cmdPublish(makeOpts(workdir), "new-skill", {});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "published",
|
||||
slug: "new-skill",
|
||||
version: "1.0.0",
|
||||
});
|
||||
expect(publishPayload()).toMatchObject({ version: "1.0.0" });
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults a changed skill to the next patch version", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "changed-skill");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "SKILL.md"), "# Changed skill\n", "utf8");
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
match: null,
|
||||
latestVersion: { version: "1.2.3" },
|
||||
});
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
skillId: "skill_1",
|
||||
versionId: "ver_2",
|
||||
});
|
||||
|
||||
const result = await cmdPublish(makeOpts(workdir), "changed-skill", {});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "published",
|
||||
slug: "changed-skill",
|
||||
version: "1.2.4",
|
||||
});
|
||||
expect(publishPayload()).toMatchObject({ version: "1.2.4" });
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes an explicit version even when the content already matches", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "explicit-version");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8");
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
match: { version: "1.2.3" },
|
||||
latestVersion: { version: "1.2.3" },
|
||||
});
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
skillId: "skill_1",
|
||||
versionId: "ver_2",
|
||||
});
|
||||
|
||||
const result = await cmdPublish(makeOpts(workdir), "explicit-version", {
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "published",
|
||||
slug: "explicit-version",
|
||||
version: "2.0.0",
|
||||
});
|
||||
expect(publishPayload()).toMatchObject({ version: "2.0.0" });
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("previews the resolved publish without requiring auth", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "preview-skill");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "SKILL.md"), "# Changed skill\n", "utf8");
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
match: null,
|
||||
latestVersion: { version: "2.0.0" },
|
||||
});
|
||||
|
||||
const result = await cmdPublish(makeOpts(workdir), "preview-skill", { dryRun: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "would-publish",
|
||||
slug: "preview-skill",
|
||||
version: "2.0.1",
|
||||
});
|
||||
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes SKILL.md from disk (mocked HTTP)", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
@@ -302,3 +447,15 @@ describe("cmdPublish", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function publishPayload() {
|
||||
const publishCall = httpMocks.apiRequestForm.mock.calls.find((call) => {
|
||||
const request = call[1] as { path?: string } | undefined;
|
||||
return request?.path === "/api/v1/skills";
|
||||
});
|
||||
if (!publishCall) throw new Error("Missing publish call");
|
||||
const form = (publishCall[1] as { form?: FormData }).form;
|
||||
const payload = form?.get("payload");
|
||||
if (typeof payload !== "string") throw new Error("Missing publish payload");
|
||||
return JSON.parse(payload) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import semver from "semver";
|
||||
import { apiRequestForm } from "../../http.js";
|
||||
import { ApiRoutes, ApiV1PublishResponseSchema } from "../../schema/index.js";
|
||||
import { listTextFiles } from "../../skills.js";
|
||||
import { requireAuthToken } from "../authToken.js";
|
||||
import { apiRequest, apiRequestForm, registryUrl } from "../../http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1PublishResponseSchema,
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
} from "../../schema/index.js";
|
||||
import { hashSkillFiles, listTextFiles } from "../../skills.js";
|
||||
import { getOptionalAuthToken, requireAuthToken } from "../authToken.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
import { sanitizeSlug, titleCase } from "../slug.js";
|
||||
import type { GlobalOpts } from "../types.js";
|
||||
import { createSpinner, fail, formatError } from "../ui.js";
|
||||
import { normalizeGitHubRepo } from "./github.js";
|
||||
|
||||
type SkillPublishResult = {
|
||||
ok: true;
|
||||
status: "unchanged" | "would-publish" | "published";
|
||||
slug: string;
|
||||
displayName: string;
|
||||
folder: string;
|
||||
version: string;
|
||||
latestVersion: string | null;
|
||||
fileCount: number;
|
||||
fingerprint: string;
|
||||
versionId?: string;
|
||||
};
|
||||
|
||||
export async function cmdPublish(
|
||||
opts: GlobalOpts,
|
||||
folderArg: string,
|
||||
@@ -27,8 +44,10 @@ export async function cmdPublish(
|
||||
sourceCommit?: string;
|
||||
sourceRef?: string;
|
||||
sourcePath?: string;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
},
|
||||
) {
|
||||
): Promise<SkillPublishResult> {
|
||||
const folder = folderArg ? resolve(opts.workdir, folderArg) : null;
|
||||
if (!folder) fail("Path required");
|
||||
const folderStat = await stat(folder).catch(() => null);
|
||||
@@ -37,13 +56,12 @@ export async function cmdPublish(
|
||||
fail('This looks like a plugin. Use "clawhub package publish <source>" instead.');
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
|
||||
const slug = options.slug ?? sanitizeSlug(basename(folder));
|
||||
const displayName = options.name ?? titleCase(basename(folder));
|
||||
const ownerHandle = options.owner?.trim().replace(/^@+/, "");
|
||||
const version = options.version;
|
||||
const explicitVersion = options.version;
|
||||
const changelog = options.changelog ?? "";
|
||||
const tagsValue = options.tags ?? "latest";
|
||||
const tags = tagsValue
|
||||
@@ -57,9 +75,9 @@ export async function cmdPublish(
|
||||
|
||||
if (!slug) fail("--slug required");
|
||||
if (!displayName) fail("--name required");
|
||||
if (!version || !semver.valid(version)) fail("--version must be valid semver");
|
||||
if (explicitVersion && !semver.valid(explicitVersion)) fail("--version must be valid semver");
|
||||
|
||||
const spinner = createSpinner(`Preparing ${slug}@${version}`);
|
||||
const spinner = options.json ? null : createSpinner(`Preparing ${slug}`);
|
||||
try {
|
||||
const filesOnDisk = stripGeneratedSkillCards(
|
||||
await ensureRootManifestFile(folder, await listTextFiles(folder)),
|
||||
@@ -74,6 +92,45 @@ export async function cmdPublish(
|
||||
fail("SKILL.md required");
|
||||
}
|
||||
|
||||
const hashed = hashSkillFiles(filesOnDisk);
|
||||
const optionalToken = await getOptionalAuthToken();
|
||||
const resolved = await resolveSkillVersion(registry, slug, hashed.fingerprint, optionalToken);
|
||||
const latestVersion = resolved.latestVersion?.version ?? null;
|
||||
|
||||
if (!explicitVersion && resolved.match) {
|
||||
const result = buildPublishResult({
|
||||
status: "unchanged",
|
||||
slug,
|
||||
displayName,
|
||||
folder,
|
||||
version: resolved.match.version,
|
||||
latestVersion,
|
||||
fileCount: filesOnDisk.length,
|
||||
fingerprint: hashed.fingerprint,
|
||||
});
|
||||
spinner?.succeed(`OK. ${slug}@${result.version} is already published`);
|
||||
writePublishJsonIfRequested(options.json, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const version = explicitVersion ?? resolveAutomaticVersion(latestVersion);
|
||||
if (options.dryRun) {
|
||||
const result = buildPublishResult({
|
||||
status: "would-publish",
|
||||
slug,
|
||||
displayName,
|
||||
folder,
|
||||
version,
|
||||
latestVersion,
|
||||
fileCount: filesOnDisk.length,
|
||||
fingerprint: hashed.fingerprint,
|
||||
});
|
||||
spinner?.succeed(`Would publish ${slug}@${version}`);
|
||||
writePublishJsonIfRequested(options.json, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"payload",
|
||||
@@ -94,25 +151,76 @@ export async function cmdPublish(
|
||||
let index = 0;
|
||||
for (const file of filesOnDisk) {
|
||||
index += 1;
|
||||
spinner.text = `Uploading ${file.relPath} (${index}/${filesOnDisk.length})`;
|
||||
if (spinner) spinner.text = `Uploading ${file.relPath} (${index}/${filesOnDisk.length})`;
|
||||
const blob = new Blob([Buffer.from(file.bytes)], { type: file.contentType ?? "text/plain" });
|
||||
form.append("files", blob, file.relPath);
|
||||
}
|
||||
|
||||
spinner.text = `Publishing ${slug}@${version}`;
|
||||
if (spinner) spinner.text = `Publishing ${slug}@${version}`;
|
||||
const result = await apiRequestForm(
|
||||
registry,
|
||||
{ method: "POST", path: ApiRoutes.skills, token, form },
|
||||
ApiV1PublishResponseSchema,
|
||||
);
|
||||
|
||||
spinner.succeed(`OK. Published ${slug}@${version} (${result.versionId})`);
|
||||
const publishResult = buildPublishResult({
|
||||
status: "published",
|
||||
slug,
|
||||
displayName,
|
||||
folder,
|
||||
version,
|
||||
latestVersion,
|
||||
fileCount: filesOnDisk.length,
|
||||
fingerprint: hashed.fingerprint,
|
||||
versionId: result.versionId,
|
||||
});
|
||||
spinner?.succeed(`OK. Published ${slug}@${version} (${result.versionId})`);
|
||||
writePublishJsonIfRequested(options.json, publishResult);
|
||||
return publishResult;
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error));
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSkillVersion(
|
||||
registry: string,
|
||||
slug: string,
|
||||
fingerprint: string,
|
||||
token?: string,
|
||||
) {
|
||||
const url = registryUrl(ApiRoutes.resolve, registry);
|
||||
url.searchParams.set("slug", slug);
|
||||
url.searchParams.set("hash", fingerprint);
|
||||
try {
|
||||
return await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", url: url.toString(), token },
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
);
|
||||
} catch (error) {
|
||||
if (/skill not found|HTTP 404/i.test(formatError(error))) {
|
||||
return { match: null, latestVersion: null };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAutomaticVersion(latestVersion: string | null) {
|
||||
if (!latestVersion) return "1.0.0";
|
||||
const nextVersion = semver.inc(latestVersion, "patch");
|
||||
if (!nextVersion) fail(`Latest ClawHub version is not valid semver: ${latestVersion}`);
|
||||
return nextVersion;
|
||||
}
|
||||
|
||||
function buildPublishResult(result: Omit<SkillPublishResult, "ok">): SkillPublishResult {
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
|
||||
function writePublishJsonIfRequested(json: boolean | undefined, result: SkillPublishResult) {
|
||||
if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function stripGeneratedSkillCards(files: Awaited<ReturnType<typeof listTextFiles>>) {
|
||||
return files.filter((file) => file.relPath.trim().toLowerCase() !== "skill-card.md");
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ import { getOptionalAuthToken, requireAuthToken } from "../authToken.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
import type { GlobalOpts, ResolveResult } from "../types.js";
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from "../ui.js";
|
||||
import { reportInstalledSkillsTelemetryIfEnabled } from "./installTelemetry.js";
|
||||
import { presentModerationPlan, reportModerationPlan } from "./moderationPlan.js";
|
||||
import { reportInstalledSkillsTelemetryIfEnabled } from "./syncHelpers.js";
|
||||
|
||||
type SkillReportOptions = {
|
||||
version?: string;
|
||||
|
||||
@@ -1,886 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
const mockIntro = vi.fn();
|
||||
const mockOutro = vi.fn();
|
||||
const mockLog = vi.fn();
|
||||
const mockMultiselect = vi.fn(async (_args?: unknown) => [] as string[]);
|
||||
let interactive = false;
|
||||
const mocked = <T>(value: T) =>
|
||||
value as T & { mockImplementation: (...args: unknown[]) => unknown };
|
||||
|
||||
const defaultFindSkillFolders = async (root: string) => {
|
||||
if (!root.endsWith("/scan")) return [];
|
||||
return [
|
||||
{ folder: "/scan/new-skill", slug: "new-skill", displayName: "New Skill" },
|
||||
{ folder: "/scan/synced-skill", slug: "synced-skill", displayName: "Synced Skill" },
|
||||
{ folder: "/scan/update-skill", slug: "update-skill", displayName: "Update Skill" },
|
||||
];
|
||||
};
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
intro: (value: string) => mockIntro(value),
|
||||
outro: (value: string) => mockOutro(value),
|
||||
multiselect: (args: unknown) => mockMultiselect(args),
|
||||
text: vi.fn(async () => ""),
|
||||
isCancel: () => false,
|
||||
}));
|
||||
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
httpMocks.downloadZip.mockImplementation(
|
||||
async (_registry?: unknown, _args?: unknown) => new Uint8Array([1, 2, 3]),
|
||||
);
|
||||
const mockApiRequest = httpMocks.apiRequest;
|
||||
const mockFail = uiMocks.fail;
|
||||
const mockSpinner = uiMocks.spinner;
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => mockFail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => interactive,
|
||||
promptConfirm: uiMocks.promptConfirm,
|
||||
}));
|
||||
|
||||
vi.mock("../scanSkills.js", () => ({
|
||||
findSkillFolders: vi.fn(defaultFindSkillFolders),
|
||||
getFallbackSkillRoots: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
const mockResolveClawdbotSkillRoots = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
roots: [] as string[],
|
||||
labels: {} as Record<string, string>,
|
||||
}) as const,
|
||||
);
|
||||
vi.mock("../clawdbotConfig.js", () => ({
|
||||
resolveClawdbotSkillRoots: () => mockResolveClawdbotSkillRoots(),
|
||||
}));
|
||||
|
||||
const mockListTextFiles = vi.fn(async (folder: string) => [
|
||||
{ relPath: "SKILL.md", bytes: new TextEncoder().encode(folder) },
|
||||
]);
|
||||
const mockHashSkillFiles = vi.fn((files: Array<{ relPath: string; bytes: Uint8Array }>) => ({
|
||||
fingerprint: files
|
||||
.map((file) => `${file.relPath}:${Buffer.from(file.bytes).toString("hex")}`)
|
||||
.join("|"),
|
||||
files: [],
|
||||
}));
|
||||
const mockHashSkillZip = vi.fn((_zip?: Uint8Array) => ({
|
||||
fingerprint: "remote-fingerprint",
|
||||
files: [],
|
||||
}));
|
||||
const mockReadSkillOrigin = vi.fn(async (_folder?: string) => null);
|
||||
vi.mock("../../skills.js", () => ({
|
||||
listTextFiles: (folder: string) => mockListTextFiles(folder),
|
||||
hashSkillFiles: (files: Array<{ relPath: string; bytes: Uint8Array }>) =>
|
||||
mockHashSkillFiles(files),
|
||||
hashSkillZip: (zip: Uint8Array) => mockHashSkillZip(zip),
|
||||
readSkillOrigin: (folder: string) => mockReadSkillOrigin(folder),
|
||||
}));
|
||||
|
||||
const mockCmdPublish = vi.fn();
|
||||
vi.mock("./publish.js", () => ({
|
||||
cmdPublish: (opts: unknown, folder: unknown, options?: unknown) =>
|
||||
mockCmdPublish(opts, folder, options),
|
||||
}));
|
||||
|
||||
const { cmdSync } = await import("./sync");
|
||||
|
||||
function makeOpts() {
|
||||
return makeGlobalOpts();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockCmdPublish.mockReset();
|
||||
process.exitCode = undefined;
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
mocked(findSkillFolders).mockImplementation(defaultFindSkillFolders);
|
||||
});
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => {
|
||||
mockLog(args.map(String).join(" "));
|
||||
});
|
||||
describe("cmdSync", () => {
|
||||
it("classifies skills as new/update/synced (dry-run, mocked HTTP)", async () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
if (slug === "synced-skill") {
|
||||
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
|
||||
}
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: true }, true);
|
||||
|
||||
expect(mockCmdPublish).not.toHaveBeenCalled();
|
||||
|
||||
const output = mockLog.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toMatch(/Already synced/);
|
||||
expect(output).toMatch(/synced-skill/);
|
||||
|
||||
const dryRunOutro = mockOutro.mock.calls.at(-1)?.[0];
|
||||
expect(String(dryRunOutro)).toMatch(/Dry run: would upload 2 skill/);
|
||||
});
|
||||
|
||||
it("emits CI JSON dry-run without requiring auth", async () => {
|
||||
interactive = false;
|
||||
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
if (slug === "synced-skill") {
|
||||
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
|
||||
}
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
let output = "";
|
||||
try {
|
||||
await cmdSync(
|
||||
makeOpts(),
|
||||
{
|
||||
root: ["/scan"],
|
||||
all: true,
|
||||
dryRun: true,
|
||||
json: true,
|
||||
owner: "nvidia",
|
||||
sourceRepo: "NVIDIA/skills",
|
||||
sourceCommit: "abc123",
|
||||
sourceRef: "refs/heads/main",
|
||||
},
|
||||
false,
|
||||
);
|
||||
output = String(stdoutWrite.mock.calls.at(-1)?.[0] ?? "").trim();
|
||||
} finally {
|
||||
stdoutWrite.mockRestore();
|
||||
}
|
||||
|
||||
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
|
||||
expect(mockCmdPublish).not.toHaveBeenCalled();
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
expect(mockIntro).not.toHaveBeenCalled();
|
||||
expect(mockOutro).not.toHaveBeenCalled();
|
||||
|
||||
const parsed = JSON.parse(output) as {
|
||||
ok: boolean;
|
||||
dryRun: boolean;
|
||||
owner?: string;
|
||||
summary: { wouldPublish: number; alreadySynced: number; failed: number };
|
||||
wouldPublish: Array<{
|
||||
slug: string;
|
||||
version: string;
|
||||
status: string;
|
||||
source?: { repo: string };
|
||||
}>;
|
||||
alreadySynced: Array<{ slug: string; version: string }>;
|
||||
published: unknown[];
|
||||
failed: unknown[];
|
||||
};
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.dryRun).toBe(true);
|
||||
expect(parsed.owner).toBe("nvidia");
|
||||
expect(parsed.summary).toMatchObject({ wouldPublish: 2, alreadySynced: 1, failed: 0 });
|
||||
expect(parsed.wouldPublish.map((entry) => [entry.slug, entry.version, entry.status])).toEqual([
|
||||
["new-skill", "1.0.0", "new"],
|
||||
["update-skill", "1.0.1", "update"],
|
||||
]);
|
||||
expect(parsed.wouldPublish[0]?.source?.repo).toBe("NVIDIA/skills");
|
||||
expect(parsed.alreadySynced).toEqual([
|
||||
expect.objectContaining({ slug: "synced-skill", version: "1.2.3" }),
|
||||
]);
|
||||
expect(parsed.published).toEqual([]);
|
||||
expect(parsed.failed).toEqual([]);
|
||||
});
|
||||
|
||||
it("prints bullet lists and selects all actionable by default", async () => {
|
||||
interactive = true;
|
||||
mockMultiselect.mockImplementation(async (args?: unknown) => {
|
||||
const { initialValues } = args as { initialValues: string[] };
|
||||
return initialValues;
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
if (slug === "synced-skill") {
|
||||
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
|
||||
}
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: false, dryRun: false, bump: "patch" }, true);
|
||||
|
||||
const output = mockLog.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toMatch(/To sync/);
|
||||
expect(output).toMatch(/- new-skill/);
|
||||
expect(output).toMatch(/- update-skill/);
|
||||
expect(output).toMatch(/Already synced/);
|
||||
expect(output).toMatch(/- synced-skill/);
|
||||
|
||||
const lastCall = mockMultiselect.mock.calls.at(-1);
|
||||
const promptArgs = lastCall ? (lastCall[0] as { initialValues: string[] }) : undefined;
|
||||
expect(promptArgs?.initialValues.length).toBe(2);
|
||||
expect(mockCmdPublish).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("passes owner and source provenance into real bulk publishes", async () => {
|
||||
interactive = false;
|
||||
const opts = makeGlobalOpts("/repo");
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
if (root !== "/repo/skills") return [];
|
||||
return [
|
||||
{ folder: "/repo/skills/new-skill", slug: "new-skill", displayName: "New Skill" },
|
||||
{ folder: "/repo/skills/update-skill", slug: "update-skill", displayName: "Update Skill" },
|
||||
];
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "mikehollinger" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") throw new Error("Skill not found");
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await cmdSync(
|
||||
opts,
|
||||
{
|
||||
root: ["/repo/skills"],
|
||||
all: true,
|
||||
dryRun: false,
|
||||
owner: "nvidia",
|
||||
tags: "latest,catalog",
|
||||
sourceRepo: "https://github.com/NVIDIA/skills",
|
||||
sourceCommit: "abc123",
|
||||
sourceRef: "refs/heads/main",
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockCmdPublish).toHaveBeenCalledTimes(2);
|
||||
expect(mockCmdPublish.mock.calls.map((call) => call[2])).toEqual([
|
||||
expect.objectContaining({
|
||||
slug: "new-skill",
|
||||
owner: "nvidia",
|
||||
version: "1.0.0",
|
||||
tags: "latest,catalog",
|
||||
sourceRepo: "https://github.com/NVIDIA/skills",
|
||||
sourceCommit: "abc123",
|
||||
sourceRef: "refs/heads/main",
|
||||
sourcePath: "skills/new-skill",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
slug: "update-skill",
|
||||
owner: "nvidia",
|
||||
version: "1.0.1",
|
||||
tags: "latest,catalog",
|
||||
sourceRepo: "https://github.com/NVIDIA/skills",
|
||||
sourceCommit: "abc123",
|
||||
sourceRef: "refs/heads/main",
|
||||
sourcePath: "skills/update-skill",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses dot source path for a root skill publish", async () => {
|
||||
interactive = false;
|
||||
const opts = makeGlobalOpts("/repo");
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
if (root !== "/repo") return [];
|
||||
return [{ folder: "/repo", slug: "root-skill", displayName: "Root Skill" }];
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "mikehollinger" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await cmdSync(
|
||||
opts,
|
||||
{
|
||||
all: true,
|
||||
dryRun: false,
|
||||
sourceRepo: "NVIDIA/root-skill",
|
||||
sourceCommit: "abc123",
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockCmdPublish).toHaveBeenCalledTimes(1);
|
||||
expect(mockCmdPublish.mock.calls[0]?.[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
slug: "root-skill",
|
||||
sourcePath: ".",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("labels unmatched local content as proposed publish versions, not registry updates", async () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
if (slug === "synced-skill") {
|
||||
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
|
||||
}
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: true }, true);
|
||||
|
||||
const output = mockLog.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toMatch(/update-skill\s+LOCAL CHANGES latest 1\.0\.0; publish 1\.0\.1/);
|
||||
expect(output).toMatch(/new-skill\s+NEW \(publish 1\.0\.0\)/);
|
||||
expect(output).not.toMatch(/UPDATE 1\.0\.0/);
|
||||
});
|
||||
|
||||
it("shows condensed synced list when nothing to sync", async () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
return { match: { version: "1.0.0" }, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false }, true);
|
||||
|
||||
const output = mockLog.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toMatch(/Already synced/);
|
||||
expect(output).toMatch(/new-skill@1.0.0/);
|
||||
expect(output).toMatch(/synced-skill@1.0.0/);
|
||||
expect(output).not.toMatch(/\n-/);
|
||||
|
||||
const outro = mockOutro.mock.calls.at(-1)?.[0];
|
||||
expect(String(outro)).toMatch(/Nothing to sync/);
|
||||
});
|
||||
|
||||
it("dedupes duplicate slugs before publishing", async () => {
|
||||
interactive = false;
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
if (!root.endsWith("/scan")) return [];
|
||||
return [
|
||||
{ folder: "/scan/dup-skill", slug: "dup-skill", displayName: "Dup Skill" },
|
||||
{ folder: "/scan/dup-skill-copy", slug: "dup-skill", displayName: "Dup Skill" },
|
||||
];
|
||||
});
|
||||
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
return { match: null, latestVersion: null };
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false }, true);
|
||||
|
||||
expect(mockCmdPublish).toHaveBeenCalledTimes(1);
|
||||
const output = mockLog.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toMatch(/Skipped duplicate slugs/);
|
||||
expect(output).toMatch(/dup-skill/);
|
||||
});
|
||||
|
||||
it("prints labeled roots when clawdbot roots are detected", async () => {
|
||||
interactive = false;
|
||||
mockResolveClawdbotSkillRoots.mockResolvedValueOnce({
|
||||
roots: ["/auto"],
|
||||
labels: { "/auto": "Agent: Work" },
|
||||
});
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
if (root === "/auto") {
|
||||
return [{ folder: "/auto/alpha", slug: "alpha", displayName: "Alpha" }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { all: true, dryRun: true }, true);
|
||||
|
||||
const output = mockLog.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toMatch(/Roots with skills/);
|
||||
expect(output).toMatch(/Agent: Work/);
|
||||
});
|
||||
|
||||
it("can disable auto-discovered clawdbot roots for CI exact scans", async () => {
|
||||
interactive = false;
|
||||
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const scannedRoots: string[] = [];
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
scannedRoots.push(root);
|
||||
if (root === "/scan") {
|
||||
return [{ folder: "/scan/ci-skill", slug: "ci-skill", displayName: "CI Skill" }];
|
||||
}
|
||||
if (root === "/auto") {
|
||||
return [{ folder: "/auto/auto-skill", slug: "auto-skill", displayName: "Auto Skill" }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
mockResolveClawdbotSkillRoots.mockResolvedValueOnce({
|
||||
roots: ["/auto"],
|
||||
labels: { "/auto": "Agent: Work" },
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
let output = "";
|
||||
try {
|
||||
await cmdSync(
|
||||
makeOpts(),
|
||||
{ root: ["/scan"], all: true, dryRun: true, json: true, clawdbotRoots: false },
|
||||
false,
|
||||
);
|
||||
output = String(stdoutWrite.mock.calls.at(-1)?.[0] ?? "").trim();
|
||||
} finally {
|
||||
stdoutWrite.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockResolveClawdbotSkillRoots).not.toHaveBeenCalled();
|
||||
expect(scannedRoots).not.toContain("/auto");
|
||||
const parsed = JSON.parse(output) as {
|
||||
roots: string[];
|
||||
wouldPublish: Array<{ slug: string }>;
|
||||
};
|
||||
expect(parsed.roots).toEqual(["/work", "/work/skills", "/scan"]);
|
||||
expect(parsed.wouldPublish.map((entry) => entry.slug)).toEqual(["ci-skill"]);
|
||||
});
|
||||
|
||||
it("reports fallback roots used for JSON sync output", async () => {
|
||||
interactive = false;
|
||||
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const { findSkillFolders, getFallbackSkillRoots } = await import("../scanSkills.js");
|
||||
mocked(getFallbackSkillRoots).mockImplementation(() => ["/fallback"]);
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
if (root === "/fallback") {
|
||||
return [
|
||||
{
|
||||
folder: "/fallback/fallback-skill",
|
||||
slug: "fallback-skill",
|
||||
displayName: "Fallback Skill",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
let output = "";
|
||||
try {
|
||||
await cmdSync(makeOpts(), { all: true, dryRun: true, json: true }, false);
|
||||
output = String(stdoutWrite.mock.calls.at(-1)?.[0] ?? "").trim();
|
||||
} finally {
|
||||
stdoutWrite.mockRestore();
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(output) as {
|
||||
roots: string[];
|
||||
wouldPublish: Array<{ slug: string }>;
|
||||
};
|
||||
expect(parsed.roots).toEqual(["/fallback"]);
|
||||
expect(parsed.wouldPublish.map((entry) => entry.slug)).toEqual(["fallback-skill"]);
|
||||
});
|
||||
|
||||
it("does not fall back to ambient roots when exact CI scans find no skills", async () => {
|
||||
interactive = false;
|
||||
const { findSkillFolders, getFallbackSkillRoots } = await import("../scanSkills.js");
|
||||
mocked(findSkillFolders).mockImplementation(async () => []);
|
||||
|
||||
await expect(
|
||||
cmdSync(
|
||||
makeOpts(),
|
||||
{ root: ["/scan"], all: true, dryRun: true, json: true, clawdbotRoots: false },
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow("No skills found (checked configured roots)");
|
||||
|
||||
expect(mockResolveClawdbotSkillRoots).not.toHaveBeenCalled();
|
||||
expect(getFallbackSkillRoots).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows empty changelog for updates (interactive)", async () => {
|
||||
interactive = true;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
if (slug === "synced-skill") {
|
||||
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
|
||||
}
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false, bump: "patch" }, true);
|
||||
|
||||
const calls = mockCmdPublish.mock.calls.map(
|
||||
(call) => call[2] as { slug: string; changelog: string },
|
||||
);
|
||||
const update = calls.find((c) => c.slug === "update-skill");
|
||||
if (!update) throw new Error("Missing update-skill publish");
|
||||
expect(update.changelog).toBe("");
|
||||
});
|
||||
|
||||
it("continues uploading after a publish failure", async () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
if (slug === "synced-skill") {
|
||||
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
|
||||
}
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
mockCmdPublish.mockImplementation(async (_opts, _folder, options?: unknown) => {
|
||||
const { slug } = options as { slug: string };
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Registry rejected upload");
|
||||
}
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false, bump: "patch" }, true);
|
||||
|
||||
expect(mockCmdPublish).toHaveBeenCalledTimes(2);
|
||||
expect(mockCmdPublish.mock.calls.map((call) => (call[2] as { slug: string }).slug)).toEqual([
|
||||
"new-skill",
|
||||
"update-skill",
|
||||
]);
|
||||
|
||||
const output = mockLog.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toMatch(/Failed to upload/);
|
||||
expect(output).toMatch(/new-skill/);
|
||||
expect(output).toMatch(/Registry rejected upload/);
|
||||
|
||||
const outro = mockOutro.mock.calls.at(-1)?.[0];
|
||||
expect(String(outro)).toMatch(/Uploaded 1 of 2 skill\(s\). 1 failed/);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("continues uploading after an alias slug conflict publish failure", async () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
if (slug === "synced-skill") {
|
||||
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
|
||||
}
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
mockCmdPublish.mockImplementation(async (_opts, _folder, options?: unknown) => {
|
||||
const { slug } = options as { slug: string };
|
||||
if (slug === "new-skill") {
|
||||
throw new Error(
|
||||
"Slug redirects to an existing skill. Choose a different slug. Existing skill: /alice/demo",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false, bump: "patch" }, true);
|
||||
|
||||
expect(mockCmdPublish).toHaveBeenCalledTimes(2);
|
||||
expect(mockCmdPublish.mock.calls.map((call) => (call[2] as { slug: string }).slug)).toEqual([
|
||||
"new-skill",
|
||||
"update-skill",
|
||||
]);
|
||||
|
||||
const output = mockLog.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toMatch(/Failed to upload/);
|
||||
expect(output).toMatch(/Slug redirects to an existing skill/);
|
||||
expect(output).toMatch(/Existing skill: \/alice\/demo/);
|
||||
|
||||
const outro = mockOutro.mock.calls.at(-1)?.[0];
|
||||
expect(String(outro)).toMatch(/Uploaded 1 of 2 skill\(s\). 1 failed/);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("continues uploading after a locked slug publish failure", async () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
if (slug === "synced-skill") {
|
||||
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
|
||||
}
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
mockCmdPublish.mockImplementation(async (_opts, _folder, options?: unknown) => {
|
||||
const { slug } = options as { slug: string };
|
||||
if (slug === "new-skill") {
|
||||
throw new Error(
|
||||
"This slug is locked to a deleted or banned account. If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false, bump: "patch" }, true);
|
||||
|
||||
expect(mockCmdPublish).toHaveBeenCalledTimes(2);
|
||||
expect(mockCmdPublish.mock.calls.map((call) => (call[2] as { slug: string }).slug)).toEqual([
|
||||
"new-skill",
|
||||
"update-skill",
|
||||
]);
|
||||
|
||||
const output = mockLog.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toMatch(/Failed to upload/);
|
||||
expect(output).toMatch(/This slug is locked to a deleted or banned account/);
|
||||
|
||||
const outro = mockOutro.mock.calls.at(-1)?.[0];
|
||||
expect(String(outro)).toMatch(/Uploaded 1 of 2 skill\(s\). 1 failed/);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("records unrelated publish failures as per-skill failures", async () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
if (slug === "synced-skill") {
|
||||
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
|
||||
}
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
mockCmdPublish.mockRejectedValueOnce(new Error("HTTP 500"));
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false, bump: "patch" }, true);
|
||||
|
||||
expect(mockCmdPublish).toHaveBeenCalledTimes(2);
|
||||
expect(mockCmdPublish.mock.calls.map((call) => (call[2] as { slug: string }).slug)).toEqual([
|
||||
"new-skill",
|
||||
"update-skill",
|
||||
]);
|
||||
|
||||
const output = mockLog.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toMatch(/Failed to upload/);
|
||||
expect(output).toMatch(/new-skill: HTTP 500/);
|
||||
|
||||
const outro = mockOutro.mock.calls.at(-1)?.[0];
|
||||
expect(String(outro)).toMatch(/Uploaded 1 of 2 skill\(s\). 1 failed/);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("records publishes that resolve with a non-zero exitCode as per-skill failures", async () => {
|
||||
interactive = false;
|
||||
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
if (slug === "new-skill") {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
if (slug === "synced-skill") {
|
||||
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
|
||||
}
|
||||
if (slug === "update-skill") {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
mockCmdPublish.mockImplementation(async (_opts, _folder, options?: unknown) => {
|
||||
const { slug } = options as { slug: string };
|
||||
if (slug === "new-skill") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
let output = "";
|
||||
try {
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false, json: true }, true);
|
||||
output = String(stdoutWrite.mock.calls.at(-1)?.[0] ?? "").trim();
|
||||
} finally {
|
||||
stdoutWrite.mockRestore();
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(output) as {
|
||||
ok: boolean;
|
||||
published: Array<{ slug: string }>;
|
||||
failed: Array<{ slug: string; message: string }>;
|
||||
};
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.published.map((entry) => entry.slug)).toEqual(["update-skill"]);
|
||||
expect(parsed.failed).toEqual([
|
||||
{ slug: "new-skill", message: "Publish command exited with code 1" },
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("aborts command-level failures before publishing", async () => {
|
||||
interactive = false;
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
if (!root.endsWith("/scan")) return [];
|
||||
return [{ folder: "/scan/update-skill", slug: "update-skill", displayName: "Update Skill" }];
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
cmdSync(
|
||||
makeOpts(),
|
||||
{ root: ["/scan"], all: true, dryRun: false, bump: "not-semver" as never },
|
||||
true,
|
||||
),
|
||||
).rejects.toThrow("Could not bump version for update-skill");
|
||||
|
||||
expect(mockCmdPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report install telemetry from sync", async () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
return { match: { version: "1.0.0" }, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
throw new Error(`Unexpected apiRequest: ${args.path}`);
|
||||
});
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: true }, true);
|
||||
expect(
|
||||
mockApiRequest.mock.calls.some((call) => call[1]?.path === "/api/cli/telemetry/install"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,469 +0,0 @@
|
||||
import { isAbsolute, relative } from "node:path";
|
||||
import { intro, outro } from "@clack/prompts";
|
||||
import { hashSkillFiles, listTextFiles, readSkillOrigin } from "../../skills.js";
|
||||
import { getOptionalAuthToken, requireAuthToken } from "../authToken.js";
|
||||
import { resolveClawdbotSkillRoots } from "../clawdbotConfig.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
import { getFallbackSkillRoots } from "../scanSkills.js";
|
||||
import type { GlobalOpts } from "../types.js";
|
||||
import { createSpinner, fail, formatError, isInteractive } from "../ui.js";
|
||||
import { normalizeGitHubRepo } from "./github.js";
|
||||
import { cmdPublish } from "./publish.js";
|
||||
import {
|
||||
buildScanRoots,
|
||||
checkRegistrySyncState,
|
||||
dedupeSkillsBySlug,
|
||||
formatActionableLine,
|
||||
formatBulletList,
|
||||
formatCommaList,
|
||||
formatList,
|
||||
formatSyncedDisplay,
|
||||
formatSyncedSummary,
|
||||
getRegistryWithAuth,
|
||||
mapWithConcurrency,
|
||||
normalizeConcurrency,
|
||||
printSection,
|
||||
resolvePublishMeta,
|
||||
scanRootsWithLabels,
|
||||
selectToUpload,
|
||||
} from "./syncHelpers.js";
|
||||
import type { Candidate, LocalSkill, SyncOptions } from "./syncTypes.js";
|
||||
|
||||
export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllowed: boolean) {
|
||||
const jsonMode = options.json === true;
|
||||
const allowPrompt = !jsonMode && isInteractive() && inputAllowed !== false;
|
||||
if (!jsonMode) intro("ClawHub sync");
|
||||
|
||||
const token = options.dryRun ? await getOptionalAuthToken() : await requireAuthToken();
|
||||
|
||||
const registry = token
|
||||
? await getRegistryWithAuth(opts, token)
|
||||
: await getRegistry(opts, { cache: true });
|
||||
const selectedRoots = buildScanRoots(opts, options.root);
|
||||
const includeClawdbotRoots = options.clawdbotRoots !== false;
|
||||
const clawdbotRoots = includeClawdbotRoots
|
||||
? await resolveClawdbotSkillRoots()
|
||||
: { roots: [], labels: {} };
|
||||
const combinedRoots = Array.from(
|
||||
new Set([...selectedRoots, ...clawdbotRoots.roots].map((root) => root.trim()).filter(Boolean)),
|
||||
);
|
||||
const concurrency = normalizeConcurrency(options.concurrency);
|
||||
|
||||
const spinner = jsonMode ? null : createSpinner("Scanning for local skills");
|
||||
const primaryScan = await scanRootsWithLabels(combinedRoots, clawdbotRoots.labels);
|
||||
let scan = primaryScan;
|
||||
let outputRoots = primaryScan.roots;
|
||||
if (primaryScan.skills.length === 0) {
|
||||
if (!includeClawdbotRoots) {
|
||||
fail("No skills found (checked configured roots)");
|
||||
}
|
||||
const fallback = getFallbackSkillRoots(opts.workdir);
|
||||
const fallbackScan = await scanRootsWithLabels(fallback);
|
||||
spinner?.stop();
|
||||
scan = fallbackScan;
|
||||
outputRoots = fallbackScan.roots;
|
||||
if (fallbackScan.skills.length === 0)
|
||||
fail("No skills found (checked workdir and known Clawdis/Clawd locations)");
|
||||
if (!jsonMode) {
|
||||
printSection(
|
||||
`No skills in workdir. Found ${fallbackScan.skills.length} in fallback locations.`,
|
||||
formatList(fallbackScan.rootsWithSkills, 10),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
spinner?.stop();
|
||||
const labeledRoots = primaryScan.rootsWithSkills
|
||||
.map((root) => {
|
||||
const label = primaryScan.rootLabels?.[root];
|
||||
return label ? `${label} (${root})` : root;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (!jsonMode && labeledRoots.length > 0) {
|
||||
printSection("Roots with skills", formatList(labeledRoots, 10));
|
||||
}
|
||||
}
|
||||
const deduped = dedupeSkillsBySlug(scan.skills);
|
||||
const skills = deduped.skills;
|
||||
if (!jsonMode && deduped.duplicates.length > 0) {
|
||||
printSection("Skipped duplicate slugs", formatCommaList(deduped.duplicates, 16));
|
||||
}
|
||||
const parsingSpinner = jsonMode ? null : createSpinner("Parsing local skills");
|
||||
const locals: LocalSkill[] = [];
|
||||
try {
|
||||
let done = 0;
|
||||
const parsed = await mapWithConcurrency(skills, Math.min(concurrency, 12), async (skill) => {
|
||||
const filesOnDisk = await listTextFiles(skill.folder);
|
||||
const hashed = hashSkillFiles(filesOnDisk);
|
||||
const origin = await readSkillOrigin(skill.folder);
|
||||
done += 1;
|
||||
if (parsingSpinner) parsingSpinner.text = `Parsing local skills ${done}/${skills.length}`;
|
||||
return {
|
||||
...skill,
|
||||
fingerprint: hashed.fingerprint,
|
||||
fileCount: filesOnDisk.length,
|
||||
origin,
|
||||
};
|
||||
});
|
||||
locals.push(...parsed);
|
||||
} catch (error) {
|
||||
parsingSpinner?.fail(formatError(error));
|
||||
throw error;
|
||||
} finally {
|
||||
parsingSpinner?.stop();
|
||||
}
|
||||
|
||||
const candidatesSpinner = jsonMode ? null : createSpinner("Checking registry sync state");
|
||||
const candidates: Candidate[] = [];
|
||||
const resolveSupport: { value: boolean | null } = { value: null };
|
||||
try {
|
||||
let done = 0;
|
||||
const resolved = await mapWithConcurrency(locals, Math.min(concurrency, 16), async (skill) => {
|
||||
try {
|
||||
return await checkRegistrySyncState(registry, skill, resolveSupport, token);
|
||||
} finally {
|
||||
done += 1;
|
||||
if (candidatesSpinner) {
|
||||
candidatesSpinner.text = `Checking registry sync state ${done}/${locals.length}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
candidates.push(...resolved);
|
||||
} catch (error) {
|
||||
candidatesSpinner?.fail(formatError(error));
|
||||
throw error;
|
||||
} finally {
|
||||
candidatesSpinner?.stop();
|
||||
}
|
||||
|
||||
const synced = candidates.filter((candidate) => candidate.status === "synced");
|
||||
const actionable = candidates.filter((candidate) => candidate.status !== "synced");
|
||||
const bump = options.bump ?? "patch";
|
||||
|
||||
if (actionable.length === 0) {
|
||||
if (jsonMode) {
|
||||
writeSyncJson(
|
||||
buildSyncJsonOutput({
|
||||
ok: true,
|
||||
dryRun: Boolean(options.dryRun),
|
||||
registry,
|
||||
roots: outputRoots,
|
||||
owner: normalizeOwner(options.owner),
|
||||
duplicates: deduped.duplicates,
|
||||
alreadySynced: synced.map(formatSyncedJson),
|
||||
wouldPublish: [],
|
||||
published: [],
|
||||
failed: [],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (synced.length > 0) {
|
||||
printSection("Already synced", formatCommaList(synced.map(formatSyncedSummary), 16));
|
||||
}
|
||||
outro("Nothing to sync.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jsonMode) {
|
||||
printSection(
|
||||
"To sync",
|
||||
formatBulletList(
|
||||
actionable.map((candidate) => formatActionableLine(candidate, bump)),
|
||||
20,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!jsonMode && synced.length > 0) {
|
||||
printSection("Already synced", formatSyncedDisplay(synced));
|
||||
}
|
||||
|
||||
const selected = await selectToUpload(actionable, {
|
||||
allowPrompt,
|
||||
all: Boolean(options.all),
|
||||
bump,
|
||||
});
|
||||
if (selected.length === 0) {
|
||||
if (jsonMode) {
|
||||
writeSyncJson(
|
||||
buildSyncJsonOutput({
|
||||
ok: true,
|
||||
dryRun: Boolean(options.dryRun),
|
||||
registry,
|
||||
roots: outputRoots,
|
||||
owner: normalizeOwner(options.owner),
|
||||
duplicates: deduped.duplicates,
|
||||
alreadySynced: synced.map(formatSyncedJson),
|
||||
wouldPublish: [],
|
||||
published: [],
|
||||
failed: [],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
outro("Nothing selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
const plannedPublishes = selected.map((skill) => {
|
||||
const source = buildSourceProvenance(opts, skill, options);
|
||||
return { skill, source };
|
||||
});
|
||||
|
||||
if (options.dryRun) {
|
||||
const wouldPublish = await Promise.all(
|
||||
plannedPublishes.map(async ({ skill, source }) => {
|
||||
const { publishVersion } = await resolvePublishMeta(skill, {
|
||||
bump,
|
||||
allowPrompt,
|
||||
changelogFlag: options.changelog,
|
||||
});
|
||||
return formatPublishJson(skill, publishVersion, source);
|
||||
}),
|
||||
);
|
||||
if (jsonMode) {
|
||||
writeSyncJson(
|
||||
buildSyncJsonOutput({
|
||||
ok: true,
|
||||
dryRun: true,
|
||||
registry,
|
||||
roots: outputRoots,
|
||||
owner: normalizeOwner(options.owner),
|
||||
duplicates: deduped.duplicates,
|
||||
alreadySynced: synced.map(formatSyncedJson),
|
||||
wouldPublish,
|
||||
published: [],
|
||||
failed: [],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
outro(`Dry run: would upload ${selected.length} skill(s).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = options.tags ?? "latest";
|
||||
const failedUploads: Array<{ slug: string; message: string }> = [];
|
||||
let uploaded = 0;
|
||||
|
||||
const published: Array<{ slug: string; folder: string; version: string }> = [];
|
||||
for (const { skill, source } of plannedPublishes) {
|
||||
const { publishVersion, changelog } = await resolvePublishMeta(skill, {
|
||||
bump,
|
||||
allowPrompt,
|
||||
changelogFlag: options.changelog,
|
||||
});
|
||||
const forkOf =
|
||||
skill.origin && normalizeRegistry(skill.origin.registry) === normalizeRegistry(registry)
|
||||
? skill.origin.slug !== skill.slug
|
||||
? `${skill.origin.slug}@${skill.origin.installedVersion}`
|
||||
: undefined
|
||||
: undefined;
|
||||
try {
|
||||
const previousExitCode = process.exitCode;
|
||||
await cmdPublish(opts, skill.folder, {
|
||||
slug: skill.slug,
|
||||
name: skill.displayName,
|
||||
owner: normalizeOwner(options.owner),
|
||||
version: publishVersion,
|
||||
changelog,
|
||||
tags,
|
||||
forkOf,
|
||||
...(source
|
||||
? {
|
||||
sourceRepo: options.sourceRepo,
|
||||
sourceCommit: options.sourceCommit,
|
||||
sourceRef: options.sourceRef,
|
||||
sourcePath: source.path,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const publishExitCode = process.exitCode;
|
||||
if (isNonZeroExitCode(publishExitCode) && publishExitCode !== previousExitCode) {
|
||||
process.exitCode = previousExitCode;
|
||||
failedUploads.push({
|
||||
slug: skill.slug,
|
||||
message: `Publish command exited with code ${String(publishExitCode)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
uploaded += 1;
|
||||
published.push({ slug: skill.slug, folder: skill.folder, version: publishVersion });
|
||||
} catch (error) {
|
||||
failedUploads.push({ slug: skill.slug, message: formatError(error) });
|
||||
}
|
||||
}
|
||||
|
||||
if (failedUploads.length > 0) {
|
||||
if (jsonMode) {
|
||||
writeSyncJson(
|
||||
buildSyncJsonOutput({
|
||||
ok: false,
|
||||
dryRun: false,
|
||||
registry,
|
||||
roots: outputRoots,
|
||||
owner: normalizeOwner(options.owner),
|
||||
duplicates: deduped.duplicates,
|
||||
alreadySynced: synced.map(formatSyncedJson),
|
||||
wouldPublish: [],
|
||||
published,
|
||||
failed: failedUploads,
|
||||
}),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
printSection(
|
||||
"Failed to upload",
|
||||
formatBulletList(
|
||||
failedUploads.map((failure) => `${failure.slug}: ${failure.message}`),
|
||||
20,
|
||||
),
|
||||
);
|
||||
outro(`Uploaded ${uploaded} of ${selected.length} skill(s). ${failedUploads.length} failed.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonMode) {
|
||||
writeSyncJson(
|
||||
buildSyncJsonOutput({
|
||||
ok: true,
|
||||
dryRun: false,
|
||||
registry,
|
||||
roots: outputRoots,
|
||||
owner: normalizeOwner(options.owner),
|
||||
duplicates: deduped.duplicates,
|
||||
alreadySynced: synced.map(formatSyncedJson),
|
||||
wouldPublish: [],
|
||||
published,
|
||||
failed: [],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
outro(`Uploaded ${selected.length} skill(s).`);
|
||||
}
|
||||
|
||||
function normalizeRegistry(value: string) {
|
||||
return value.trim().replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isNonZeroExitCode(value: string | number | null | undefined) {
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") return value.trim() !== "" && value.trim() !== "0";
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeOwner(value: string | undefined) {
|
||||
return value?.trim().replace(/^@+/, "") || undefined;
|
||||
}
|
||||
|
||||
function formatSyncedJson(candidate: Candidate) {
|
||||
return {
|
||||
slug: candidate.slug,
|
||||
folder: candidate.folder,
|
||||
version: candidate.matchVersion ?? candidate.latestVersion ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
function formatPublishJson(
|
||||
candidate: Candidate,
|
||||
version: string,
|
||||
source: ReturnType<typeof buildSourceProvenance>,
|
||||
) {
|
||||
return {
|
||||
slug: candidate.slug,
|
||||
displayName: candidate.displayName,
|
||||
folder: candidate.folder,
|
||||
status: candidate.status,
|
||||
version,
|
||||
latestVersion: candidate.latestVersion,
|
||||
fileCount: candidate.fileCount,
|
||||
fingerprint: candidate.fingerprint,
|
||||
...(source ? { source } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSourceProvenance(opts: GlobalOpts, skill: Candidate, options: SyncOptions) {
|
||||
const rawRepo = options.sourceRepo?.trim();
|
||||
const commit = options.sourceCommit?.trim();
|
||||
if (!rawRepo && !commit && !options.sourceRef?.trim()) return undefined;
|
||||
if (!rawRepo || !commit) fail("--source-repo and --source-commit must be provided together");
|
||||
const repo = normalizeGitHubRepo(rawRepo);
|
||||
if (!repo) fail("--source-repo must be a GitHub repo or URL");
|
||||
return {
|
||||
kind: "github" as const,
|
||||
url: `https://github.com/${repo}`,
|
||||
repo,
|
||||
ref: options.sourceRef?.trim() || commit,
|
||||
commit,
|
||||
path: sourcePathForSkill(opts, skill.folder),
|
||||
};
|
||||
}
|
||||
|
||||
function sourcePathForSkill(opts: GlobalOpts, folder: string) {
|
||||
return (
|
||||
relativeInside(process.cwd(), folder) ??
|
||||
relativeInside(opts.workdir, folder) ??
|
||||
relativeInside(opts.dir, folder) ??
|
||||
normalizeSourcePath(folder)
|
||||
);
|
||||
}
|
||||
|
||||
function relativeInside(base: string, target: string) {
|
||||
const rel = relative(base, target);
|
||||
if (!rel) return ".";
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) return null;
|
||||
return normalizeSourcePath(rel);
|
||||
}
|
||||
|
||||
function normalizeSourcePath(value: string) {
|
||||
const normalized = value
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
return normalized || ".";
|
||||
}
|
||||
|
||||
function buildSyncJsonOutput(params: {
|
||||
ok: boolean;
|
||||
dryRun: boolean;
|
||||
registry: string;
|
||||
roots: string[];
|
||||
owner?: string;
|
||||
duplicates: string[];
|
||||
alreadySynced: Array<{ slug: string; folder: string; version: string }>;
|
||||
wouldPublish: Array<ReturnType<typeof formatPublishJson>>;
|
||||
published: Array<{ slug: string; folder: string; version: string }>;
|
||||
failed: Array<{ slug: string; message: string }>;
|
||||
}) {
|
||||
const skipped = params.duplicates.map((duplicate) => ({
|
||||
slug: duplicate.replace(/\s+\(\d+\)$/, ""),
|
||||
reason: "duplicate-slug",
|
||||
detail: duplicate,
|
||||
}));
|
||||
return {
|
||||
ok: params.ok,
|
||||
dryRun: params.dryRun,
|
||||
registry: params.registry,
|
||||
roots: params.roots,
|
||||
...(params.owner ? { owner: params.owner } : {}),
|
||||
summary: {
|
||||
wouldPublish: params.wouldPublish.length,
|
||||
published: params.published.length,
|
||||
alreadySynced: params.alreadySynced.length,
|
||||
skipped: skipped.length,
|
||||
failed: params.failed.length,
|
||||
},
|
||||
wouldPublish: params.wouldPublish,
|
||||
published: params.published,
|
||||
alreadySynced: params.alreadySynced,
|
||||
skipped,
|
||||
failed: params.failed,
|
||||
};
|
||||
}
|
||||
|
||||
function writeSyncJson(value: unknown) {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const httpMocks = vi.hoisted(() => ({
|
||||
apiRequest: vi.fn(),
|
||||
downloadZip: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../scanSkills.js", () => ({
|
||||
findSkillFolders: vi.fn(async (root: string) => {
|
||||
if (root.endsWith("/with-skill")) {
|
||||
return [{ folder: `${root}/demo`, slug: "demo", displayName: "Demo" }];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema: unknown) =>
|
||||
httpMocks.apiRequest(registry, args, schema),
|
||||
downloadZip: (registry: unknown, args: unknown) => httpMocks.downloadZip(registry, args),
|
||||
}));
|
||||
|
||||
vi.mock("../../skills.js", () => ({
|
||||
hashSkillZip: () => ({ fingerprint: "remote-fingerprint", files: [] }),
|
||||
}));
|
||||
|
||||
const { checkRegistrySyncState, scanRootsWithLabels } = await import("./syncHelpers.js");
|
||||
|
||||
describe("checkRegistrySyncState", () => {
|
||||
it("does not classify fallback registry failures as new skills", async () => {
|
||||
httpMocks.apiRequest.mockRejectedValueOnce(new Error("HTTP 500"));
|
||||
|
||||
await expect(
|
||||
checkRegistrySyncState(
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
folder: "/tmp/demo",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
fingerprint: "local-fingerprint",
|
||||
fileCount: 1,
|
||||
origin: null,
|
||||
},
|
||||
{ value: false },
|
||||
),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
|
||||
it("still classifies explicit fallback not-found responses as new skills", async () => {
|
||||
httpMocks.apiRequest.mockRejectedValueOnce(new Error("HTTP 404"));
|
||||
|
||||
await expect(
|
||||
checkRegistrySyncState(
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
folder: "/tmp/demo",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
fingerprint: "local-fingerprint",
|
||||
fileCount: 1,
|
||||
origin: null,
|
||||
},
|
||||
{ value: false },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "new",
|
||||
matchVersion: null,
|
||||
latestVersion: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanRootsWithLabels", () => {
|
||||
it("attaches labels to roots with skills", async () => {
|
||||
const roots = ["/tmp/with-skill", "/tmp/empty", "/tmp/with-skill"];
|
||||
const labels = { "/tmp/with-skill": "Agent: Work" };
|
||||
|
||||
const result = await scanRootsWithLabels(roots, labels);
|
||||
|
||||
expect(result.rootsWithSkills).toEqual(["/tmp/with-skill"]);
|
||||
expect(result.rootLabels).toEqual({ "/tmp/with-skill": "Agent: Work" });
|
||||
expect(result.skills.map((skill) => skill.slug)).toEqual(["demo"]);
|
||||
});
|
||||
});
|
||||
@@ -1,392 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { isCancel, multiselect } from "@clack/prompts";
|
||||
import semver from "semver";
|
||||
import { resolveHome } from "../../homedir.js";
|
||||
import { apiRequest, downloadZip } from "../../http.js";
|
||||
import {
|
||||
ApiCliTelemetryInstallResponseSchema,
|
||||
ApiRoutes,
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
ApiV1SkillResponseSchema,
|
||||
ApiV1WhoamiResponseSchema,
|
||||
LegacyApiRoutes,
|
||||
} from "../../schema/index.js";
|
||||
import { hashSkillZip } from "../../skills.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
import { findSkillFolders, type SkillFolder } from "../scanSkills.js";
|
||||
import type { GlobalOpts } from "../types.js";
|
||||
import { fail, formatError } from "../ui.js";
|
||||
import type { Candidate, LocalSkill } from "./syncTypes.js";
|
||||
|
||||
export async function reportInstalledSkillsTelemetryIfEnabled(params: {
|
||||
token: string | undefined;
|
||||
registry: string;
|
||||
root: string;
|
||||
slug: string;
|
||||
version?: string | null;
|
||||
}) {
|
||||
if (!params.token || isTelemetryDisabled()) return;
|
||||
const slug = params.slug.trim();
|
||||
if (!slug) return;
|
||||
|
||||
try {
|
||||
await apiRequest(
|
||||
params.registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
token: params.token,
|
||||
body: {
|
||||
event: "install",
|
||||
slug,
|
||||
version: params.version ?? undefined,
|
||||
rootId: rootTelemetryId(params.root),
|
||||
rootLabel: formatRootLabel(params.root),
|
||||
},
|
||||
},
|
||||
ApiCliTelemetryInstallResponseSchema,
|
||||
);
|
||||
} catch {
|
||||
// Install telemetry is best-effort; local installs must not fail because
|
||||
// metrics reporting is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function isTelemetryDisabled() {
|
||||
const raw = process.env.CLAWHUB_DISABLE_TELEMETRY ?? process.env.CLAWDHUB_DISABLE_TELEMETRY;
|
||||
if (!raw) return false;
|
||||
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function buildScanRoots(opts: GlobalOpts, extraRoots: string[] | undefined) {
|
||||
const roots = [opts.workdir, opts.dir, ...(extraRoots ?? [])];
|
||||
return Array.from(new Set(roots.map((root) => resolve(root))));
|
||||
}
|
||||
|
||||
export function normalizeConcurrency(value: number | undefined) {
|
||||
const raw = typeof value === "number" ? value : 4;
|
||||
const rounded = Number.isFinite(raw) ? Math.round(raw) : 4;
|
||||
return Math.min(32, Math.max(1, rounded));
|
||||
}
|
||||
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
) {
|
||||
const results = Array.from({ length: items.length }) as R[];
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(Math.max(1, limit), items.length || 1);
|
||||
|
||||
async function worker() {
|
||||
while (true) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await fn(items[index] as T);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function checkRegistrySyncState(
|
||||
registry: string,
|
||||
skill: LocalSkill,
|
||||
resolveSupport: { value: boolean | null },
|
||||
token?: string,
|
||||
): Promise<Candidate> {
|
||||
if (resolveSupport.value !== false) {
|
||||
try {
|
||||
const resolved = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.resolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`,
|
||||
token,
|
||||
},
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
);
|
||||
resolveSupport.value = true;
|
||||
const latestVersion = resolved.latestVersion?.version ?? null;
|
||||
const matchVersion = resolved.match?.version ?? null;
|
||||
if (!latestVersion) {
|
||||
return {
|
||||
...skill,
|
||||
status: "new",
|
||||
matchVersion: null,
|
||||
latestVersion: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...skill,
|
||||
status: matchVersion ? "synced" : "update",
|
||||
matchVersion,
|
||||
latestVersion,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = formatError(error);
|
||||
if (/skill not found/i.test(message) || /HTTP 404/i.test(message)) {
|
||||
resolveSupport.value = true;
|
||||
return {
|
||||
...skill,
|
||||
status: "new",
|
||||
matchVersion: null,
|
||||
latestVersion: null,
|
||||
};
|
||||
}
|
||||
if (/no matching routes found/i.test(message)) {
|
||||
resolveSupport.value = false;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let meta: { latestVersion?: { version?: string | null } | null } | null;
|
||||
try {
|
||||
meta = await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}`, token },
|
||||
ApiV1SkillResponseSchema,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = formatError(error);
|
||||
if (/skill not found/i.test(message) || /HTTP 404/i.test(message)) {
|
||||
meta = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const latestVersion = meta?.latestVersion?.version ?? null;
|
||||
if (!latestVersion) {
|
||||
return {
|
||||
...skill,
|
||||
status: "new",
|
||||
matchVersion: null,
|
||||
latestVersion: null,
|
||||
};
|
||||
}
|
||||
|
||||
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion, token });
|
||||
const remote = hashSkillZip(zip).fingerprint;
|
||||
const matchVersion = remote === skill.fingerprint ? latestVersion : null;
|
||||
|
||||
return {
|
||||
...skill,
|
||||
status: matchVersion ? "synced" : "update",
|
||||
matchVersion,
|
||||
latestVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanRootsWithLabels(roots: string[], labels?: Record<string, string>) {
|
||||
const all: SkillFolder[] = [];
|
||||
const rootsWithSkills: string[] = [];
|
||||
const uniqueRoots = await dedupeRoots(roots);
|
||||
const skillsByRoot: Record<string, SkillFolder[]> = {};
|
||||
const rootLabels: Record<string, string> = {};
|
||||
for (const root of uniqueRoots) {
|
||||
const found = await findSkillFolders(root);
|
||||
skillsByRoot[root] = found;
|
||||
if (found.length > 0) rootsWithSkills.push(root);
|
||||
all.push(...found);
|
||||
if (labels?.[root]) rootLabels[root] = labels[root] as string;
|
||||
}
|
||||
const byFolder = new Map<string, SkillFolder>();
|
||||
for (const folder of all) {
|
||||
byFolder.set(folder.folder, folder);
|
||||
}
|
||||
return {
|
||||
roots: uniqueRoots,
|
||||
skillsByRoot,
|
||||
skills: Array.from(byFolder.values()),
|
||||
rootsWithSkills,
|
||||
rootLabels,
|
||||
};
|
||||
}
|
||||
|
||||
async function dedupeRoots(roots: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const root of roots) {
|
||||
const resolved = resolve(root);
|
||||
const canonical = await realpath(resolved).catch(() => null);
|
||||
const key = canonical ?? resolved;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
unique.push(key);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
export async function selectToUpload(
|
||||
candidates: Candidate[],
|
||||
params: { allowPrompt: boolean; all: boolean; bump: "patch" | "minor" | "major" },
|
||||
): Promise<Candidate[]> {
|
||||
if (params.all || !params.allowPrompt) return candidates;
|
||||
|
||||
const valueByKey = new Map<string, Candidate>();
|
||||
const choices = candidates.map((candidate) => {
|
||||
const key = candidate.folder;
|
||||
valueByKey.set(key, candidate);
|
||||
return {
|
||||
value: key,
|
||||
label: `${candidate.slug} ${formatActionableStatus(candidate, params.bump)}`,
|
||||
hint: `${abbreviatePath(candidate.folder)} | ${candidate.fileCount} files`,
|
||||
};
|
||||
});
|
||||
|
||||
const picked = await multiselect({
|
||||
message: "Select skills to upload",
|
||||
options: choices,
|
||||
initialValues: choices.map((choice) => choice.value),
|
||||
required: false,
|
||||
});
|
||||
if (isCancel(picked)) fail("Canceled");
|
||||
const selected = picked.map((key) => valueByKey.get(key)).filter(Boolean) as Candidate[];
|
||||
return selected;
|
||||
}
|
||||
|
||||
export async function resolvePublishMeta(
|
||||
skill: Candidate,
|
||||
params: { bump: "patch" | "minor" | "major"; allowPrompt: boolean; changelogFlag?: string },
|
||||
) {
|
||||
if (skill.status === "new") {
|
||||
return { publishVersion: "1.0.0", changelog: "" };
|
||||
}
|
||||
|
||||
const latest = skill.latestVersion;
|
||||
if (!latest) fail(`Could not resolve latest version for ${skill.slug}`);
|
||||
const publishVersion = semver.inc(latest, params.bump);
|
||||
if (!publishVersion) fail(`Could not bump version for ${skill.slug}`);
|
||||
|
||||
const fromFlag = params.changelogFlag?.trim();
|
||||
if (fromFlag) return { publishVersion, changelog: fromFlag };
|
||||
|
||||
return { publishVersion, changelog: "" };
|
||||
}
|
||||
|
||||
export async function getRegistryWithAuth(opts: GlobalOpts, token: string) {
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", path: ApiRoutes.whoami, token },
|
||||
ApiV1WhoamiResponseSchema,
|
||||
);
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function formatList(values: string[], max: number) {
|
||||
if (values.length === 0) return "";
|
||||
const shown = values.map(abbreviatePath);
|
||||
if (shown.length <= max) return shown.join("\n");
|
||||
const head = shown.slice(0, Math.max(1, max - 1));
|
||||
const rest = values.length - head.length;
|
||||
return [...head, `… +${rest} more`].join("\n");
|
||||
}
|
||||
|
||||
export function printSection(title: string, body?: string) {
|
||||
const trimmed = body?.trim();
|
||||
if (!trimmed) {
|
||||
console.log(title);
|
||||
return;
|
||||
}
|
||||
if (trimmed.includes("\n")) {
|
||||
console.log(`\n${title}\n${trimmed}`);
|
||||
return;
|
||||
}
|
||||
console.log(`${title}: ${trimmed}`);
|
||||
}
|
||||
|
||||
function abbreviatePath(value: string) {
|
||||
const home = resolveHome();
|
||||
if (value.startsWith(home)) return `~${value.slice(home.length)}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
function rootTelemetryId(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function formatRootLabel(value: string) {
|
||||
const home = resolveHome();
|
||||
if (value === home) return "~";
|
||||
|
||||
const normalized = value.replaceAll("\\", "/");
|
||||
const normalizedHome = home.replaceAll("\\", "/");
|
||||
const isHome = normalized === normalizedHome || normalized.startsWith(`${normalizedHome}/`);
|
||||
|
||||
const stripped = isHome ? normalized.slice(normalizedHome.length).replace(/^\//, "") : normalized;
|
||||
const parts = stripped.split("/").filter(Boolean);
|
||||
const tail = parts.slice(-2).join("/");
|
||||
|
||||
if (!tail) return isHome ? "~" : "…";
|
||||
return isHome ? `~/${tail}` : `…/${tail}`;
|
||||
}
|
||||
|
||||
export function dedupeSkillsBySlug(skills: SkillFolder[]) {
|
||||
const bySlug = new Map<string, SkillFolder[]>();
|
||||
for (const skill of skills) {
|
||||
const existing = bySlug.get(skill.slug);
|
||||
if (existing) existing.push(skill);
|
||||
else bySlug.set(skill.slug, [skill]);
|
||||
}
|
||||
const unique: SkillFolder[] = [];
|
||||
const duplicates: string[] = [];
|
||||
for (const [slug, entries] of bySlug.entries()) {
|
||||
unique.push(entries[0] as SkillFolder);
|
||||
if (entries.length > 1) duplicates.push(`${slug} (${entries.length})`);
|
||||
}
|
||||
return { skills: unique, duplicates };
|
||||
}
|
||||
|
||||
function formatActionableStatus(candidate: Candidate, bump: "patch" | "minor" | "major"): string {
|
||||
if (candidate.status === "new") return "NEW (publish 1.0.0)";
|
||||
const latest = candidate.latestVersion;
|
||||
const next = latest ? semver.inc(latest, bump) : null;
|
||||
if (latest && next) return `LOCAL CHANGES latest ${latest}; publish ${next}`;
|
||||
return "LOCAL CHANGES";
|
||||
}
|
||||
|
||||
export function formatActionableLine(
|
||||
candidate: Candidate,
|
||||
bump: "patch" | "minor" | "major",
|
||||
): string {
|
||||
return `${candidate.slug} ${formatActionableStatus(candidate, bump)} (${candidate.fileCount} files)`;
|
||||
}
|
||||
|
||||
function formatSyncedLine(candidate: Candidate): string {
|
||||
const version = candidate.matchVersion ?? candidate.latestVersion ?? "unknown";
|
||||
return `${candidate.slug} synced (${version})`;
|
||||
}
|
||||
|
||||
export function formatSyncedSummary(candidate: Candidate): string {
|
||||
const version = candidate.matchVersion ?? candidate.latestVersion;
|
||||
return version ? `${candidate.slug}@${version}` : candidate.slug;
|
||||
}
|
||||
|
||||
export function formatBulletList(lines: string[], max: number): string {
|
||||
if (lines.length <= max) return lines.map((line) => `- ${line}`).join("\n");
|
||||
const head = lines.slice(0, max);
|
||||
const rest = lines.length - head.length;
|
||||
return [...head, `... +${rest} more`].map((line) => `- ${line}`).join("\n");
|
||||
}
|
||||
|
||||
export function formatSyncedDisplay(synced: Candidate[]) {
|
||||
const lines = synced.map(formatSyncedLine);
|
||||
if (lines.length <= 12) return formatBulletList(lines, 12);
|
||||
return formatCommaList(synced.map(formatSyncedSummary), 24);
|
||||
}
|
||||
|
||||
export function formatCommaList(values: string[], max: number) {
|
||||
if (values.length === 0) return "";
|
||||
if (values.length <= max) return values.join(", ");
|
||||
const head = values.slice(0, Math.max(1, max - 1));
|
||||
const rest = values.length - head.length;
|
||||
return `${head.join(", ")}, ... +${rest} more`;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { SkillOrigin } from "../../skills.js";
|
||||
import type { SkillFolder } from "../scanSkills.js";
|
||||
|
||||
export type SyncOptions = {
|
||||
root?: string[];
|
||||
all?: boolean;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
owner?: string;
|
||||
bump?: "patch" | "minor" | "major";
|
||||
changelog?: string;
|
||||
tags?: string;
|
||||
concurrency?: number;
|
||||
clawdbotRoots?: boolean;
|
||||
sourceRepo?: string;
|
||||
sourceCommit?: string;
|
||||
sourceRef?: string;
|
||||
};
|
||||
|
||||
export type Candidate = SkillFolder & {
|
||||
fingerprint: string;
|
||||
fileCount: number;
|
||||
origin: SkillOrigin | null;
|
||||
status: "synced" | "new" | "update";
|
||||
matchVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
};
|
||||
|
||||
export type LocalSkill = SkillFolder & {
|
||||
fingerprint: string;
|
||||
fileCount: number;
|
||||
origin: SkillOrigin | null;
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findSkillFolders, getFallbackSkillRoots } from "./scanSkills";
|
||||
|
||||
async function makeTmpDir() {
|
||||
return mkdtemp(join(tmpdir(), "clawhub-scan-"));
|
||||
}
|
||||
|
||||
describe("scanSkills", () => {
|
||||
it("detects a single skill folder (root contains SKILL.md)", async () => {
|
||||
const root = await makeTmpDir();
|
||||
try {
|
||||
await writeFile(join(root, "SKILL.md"), "# Skill\n", "utf8");
|
||||
const found = await findSkillFolders(root);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]?.folder).toBe(resolve(root));
|
||||
expect(found[0]?.slug).toBeTruthy();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects skills in a skills directory (subfolders)", async () => {
|
||||
const root = await makeTmpDir();
|
||||
try {
|
||||
const skillsDir = join(root, "skills");
|
||||
const folder = join(skillsDir, "cool-skill");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8");
|
||||
|
||||
const found = await findSkillFolders(skillsDir);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]?.slug).toBe("cool-skill");
|
||||
expect(found[0]?.folder).toBe(resolve(folder));
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores plural skills.md marker files", async () => {
|
||||
const root = await makeTmpDir();
|
||||
try {
|
||||
const folder = join(root, "docs");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "skills.md"), "# Docs\n", "utf8");
|
||||
|
||||
const found = await findSkillFolders(root);
|
||||
expect(found).toHaveLength(0);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("includes known legacy roots", () => {
|
||||
const roots = getFallbackSkillRoots("/tmp/anywhere");
|
||||
expect(roots.some((p) => p.endsWith("/clawdis/skills"))).toBe(true);
|
||||
expect(roots.some((p) => p.endsWith("/clawd/skills"))).toBe(true);
|
||||
expect(roots.some((p) => p.endsWith("/clawdbot/skills"))).toBe(true);
|
||||
expect(roots.some((p) => p.endsWith("/openclaw/skills"))).toBe(true);
|
||||
expect(roots.some((p) => p.endsWith("/moltbot/skills"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { resolveHome } from "../homedir.js";
|
||||
import { sanitizeSlug, titleCase } from "./slug.js";
|
||||
|
||||
export type SkillFolder = {
|
||||
folder: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export async function findSkillFolders(root: string): Promise<SkillFolder[]> {
|
||||
const absRoot = resolve(root);
|
||||
const rootStat = await stat(absRoot).catch(() => null);
|
||||
if (!rootStat || !rootStat.isDirectory()) return [];
|
||||
|
||||
const direct = await isSkillFolder(absRoot);
|
||||
if (direct) return [direct];
|
||||
|
||||
const entries = await readdir(absRoot, { withFileTypes: true }).catch(() => []);
|
||||
const folders = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => join(absRoot, entry.name));
|
||||
const results: SkillFolder[] = [];
|
||||
for (const folder of folders) {
|
||||
const found = await isSkillFolder(folder);
|
||||
if (found) results.push(found);
|
||||
}
|
||||
return results.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
}
|
||||
|
||||
export function getFallbackSkillRoots(workdir: string) {
|
||||
const home = resolveHome();
|
||||
const roots = [
|
||||
// adjacent repo installs
|
||||
resolve(workdir, "..", "clawdis", "skills"),
|
||||
resolve(workdir, "..", "clawdis", "Skills"),
|
||||
resolve(workdir, "..", "clawdbot", "skills"),
|
||||
resolve(workdir, "..", "clawdbot", "Skills"),
|
||||
resolve(workdir, "..", "openclaw", "skills"),
|
||||
resolve(workdir, "..", "openclaw", "Skills"),
|
||||
resolve(workdir, "..", "moltbot", "skills"),
|
||||
resolve(workdir, "..", "moltbot", "Skills"),
|
||||
|
||||
// legacy locations
|
||||
resolve(home, "clawd", "skills"),
|
||||
resolve(home, "clawd", "Skills"),
|
||||
resolve(home, ".clawd", "skills"),
|
||||
resolve(home, ".clawd", "Skills"),
|
||||
|
||||
resolve(home, "clawdbot", "skills"),
|
||||
resolve(home, "clawdbot", "Skills"),
|
||||
resolve(home, ".clawdbot", "skills"),
|
||||
resolve(home, ".clawdbot", "Skills"),
|
||||
|
||||
resolve(home, "clawdis", "skills"),
|
||||
resolve(home, "clawdis", "Skills"),
|
||||
resolve(home, ".clawdis", "skills"),
|
||||
resolve(home, ".clawdis", "Skills"),
|
||||
|
||||
resolve(home, "openclaw", "skills"),
|
||||
resolve(home, "openclaw", "Skills"),
|
||||
resolve(home, ".openclaw", "skills"),
|
||||
resolve(home, ".openclaw", "Skills"),
|
||||
|
||||
resolve(home, "moltbot", "skills"),
|
||||
resolve(home, "moltbot", "Skills"),
|
||||
resolve(home, ".moltbot", "skills"),
|
||||
resolve(home, ".moltbot", "Skills"),
|
||||
|
||||
// macOS App Support legacy
|
||||
resolve(home, "Library", "Application Support", "clawdbot", "skills"),
|
||||
resolve(home, "Library", "Application Support", "clawdbot", "Skills"),
|
||||
resolve(home, "Library", "Application Support", "clawdis", "skills"),
|
||||
resolve(home, "Library", "Application Support", "clawdis", "Skills"),
|
||||
resolve(home, "Library", "Application Support", "openclaw", "skills"),
|
||||
resolve(home, "Library", "Application Support", "openclaw", "Skills"),
|
||||
resolve(home, "Library", "Application Support", "moltbot", "skills"),
|
||||
resolve(home, "Library", "Application Support", "moltbot", "Skills"),
|
||||
];
|
||||
return Array.from(new Set(roots));
|
||||
}
|
||||
|
||||
async function isSkillFolder(folder: string): Promise<SkillFolder | null> {
|
||||
const marker = await findSkillMarker(folder);
|
||||
if (!marker) return null;
|
||||
const base = basename(folder);
|
||||
const slug = sanitizeSlug(base);
|
||||
if (!slug) return null;
|
||||
const displayName = titleCase(base);
|
||||
return { folder, slug, displayName };
|
||||
}
|
||||
|
||||
async function findSkillMarker(folder: string) {
|
||||
const candidates = ["SKILL.md", "skill.md"];
|
||||
for (const name of candidates) {
|
||||
const path = join(folder, name);
|
||||
const st = await stat(path).catch(() => null);
|
||||
if (st?.isFile()) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -193,6 +193,18 @@ async function startLocalRegistry() {
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/resolve") {
|
||||
if (url.searchParams.get("slug") === "new-skill") {
|
||||
response.writeHead(404, { "Content-Type": "text/plain" });
|
||||
response.end("Skill not found");
|
||||
return;
|
||||
}
|
||||
if (url.searchParams.get("slug") === "changed-skill") {
|
||||
writeJson(response, 200, {
|
||||
match: null,
|
||||
latestVersion: { version: "1.2.3" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeJson(response, 200, {
|
||||
match: { version: "1.0.0" },
|
||||
latestVersion: { version: "1.0.0" },
|
||||
@@ -231,6 +243,78 @@ async function writeConfigWithToken(root: string, registry: string) {
|
||||
}
|
||||
|
||||
describe("built CLI artifact", () => {
|
||||
it("documents automatic skill publish versions without a bump flag", () => {
|
||||
const result = runNode([binPath, "skill", "publish", "--help"]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("--version <version>");
|
||||
expect(result.stdout).toContain("--dry-run");
|
||||
expect(result.stdout).toContain("--json");
|
||||
expect(result.stdout).not.toContain("--bump");
|
||||
});
|
||||
|
||||
it("resolves the next patch version in skill publish dry-run json mode", async () => {
|
||||
const { registry, requests } = await startLocalRegistry();
|
||||
const workdir = await makeTmpDir("clawhub-artifact-skill-publish-");
|
||||
const skillDir = join(workdir, "changed-skill");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Changed skill\n", "utf8");
|
||||
|
||||
const result = await runNodeAsync([
|
||||
binPath,
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--registry",
|
||||
registry,
|
||||
"skill",
|
||||
"publish",
|
||||
"changed-skill",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
status: "would-publish",
|
||||
slug: "changed-skill",
|
||||
version: "1.2.4",
|
||||
latestVersion: "1.2.3",
|
||||
});
|
||||
expect(requests.map((request) => request.method)).toEqual(["GET"]);
|
||||
expect(requests[0]?.path).toMatch(/^\/api\/v1\/resolve\?slug=changed-skill&hash=/);
|
||||
});
|
||||
|
||||
it("defaults a new skill to 1.0.0 when the resolver returns 404", async () => {
|
||||
const { registry } = await startLocalRegistry();
|
||||
const workdir = await makeTmpDir("clawhub-artifact-new-skill-publish-");
|
||||
const skillDir = join(workdir, "new-skill");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# New skill\n", "utf8");
|
||||
|
||||
const result = await runNodeAsync([
|
||||
binPath,
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--registry",
|
||||
registry,
|
||||
"skill",
|
||||
"publish",
|
||||
"new-skill",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
status: "would-publish",
|
||||
slug: "new-skill",
|
||||
version: "1.0.0",
|
||||
latestVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs help from the published bin entrypoint", async () => {
|
||||
const result = runNode([binPath, "--help"]);
|
||||
|
||||
@@ -246,16 +330,13 @@ describe("built CLI artifact", () => {
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain("Usage: clawhub");
|
||||
expect(result.stdout).toContain("sync");
|
||||
expect(result.stdout).not.toContain("sync");
|
||||
});
|
||||
|
||||
it("runs sync for bare logged-in invocations", async () => {
|
||||
it("prints help for bare logged-in invocations", async () => {
|
||||
const { registry, requests } = await startLocalRegistry();
|
||||
const workdir = await makeTmpDir("clawhub-artifact-bare-sync-");
|
||||
const workdir = await makeTmpDir("clawhub-artifact-bare-help-");
|
||||
const configPath = await writeConfigWithToken(workdir, registry);
|
||||
const skillDir = join(workdir, "skills", "demo");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Demo\n\nA local sync fixture.\n", "utf8");
|
||||
|
||||
const result = await runNodeAsync(
|
||||
[binPath, "--workdir", workdir, "--registry", registry, "--no-input"],
|
||||
@@ -263,20 +344,16 @@ describe("built CLI artifact", () => {
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).not.toContain("Usage: clawhub");
|
||||
expect(requests.map((request) => request.path)).toContain("/api/v1/whoami");
|
||||
expect(requests.map((request) => request.path)).toContainEqual(
|
||||
expect.stringMatching(/^\/api\/v1\/resolve\?slug=demo&hash=/),
|
||||
);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain("Usage: clawhub");
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("exposes sync help for reusable publishing workflows", async () => {
|
||||
const result = runNode([binPath, "sync", "--help"]);
|
||||
it("does not expose the removed sync command", async () => {
|
||||
const result = runNode([binPath, "sync"]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain("Usage: clawhub sync");
|
||||
expect(result.stdout).toContain("Scan local skills and publish new/updated ones");
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("error: unknown command 'sync'");
|
||||
});
|
||||
|
||||
it("reports unknown top-level commands clearly", async () => {
|
||||
@@ -295,24 +372,6 @@ describe("built CLI artifact", () => {
|
||||
expect(result.stderr).not.toContain("too many arguments");
|
||||
});
|
||||
|
||||
it("rejects invalid sync bump values before scanning", async () => {
|
||||
const workdir = await makeTmpDir("clawhub-artifact-invalid-bump-");
|
||||
const result = runNode([
|
||||
binPath,
|
||||
"--workdir",
|
||||
workdir,
|
||||
"sync",
|
||||
"--bump",
|
||||
"banana",
|
||||
"--dry-run",
|
||||
"--no-clawdbot-roots",
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("--bump must be patch, minor, or major");
|
||||
expect(result.stderr).not.toContain("No skills found");
|
||||
});
|
||||
|
||||
it("does not mask unknown global options", async () => {
|
||||
const result = runNode([binPath, "--bad", "nope"]);
|
||||
|
||||
@@ -452,43 +511,6 @@ describe("built CLI artifact", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not send install telemetry from the built sync command", async () => {
|
||||
const { registry, requests } = await startLocalRegistry();
|
||||
const workdir = await makeTmpDir("clawhub-artifact-sync-");
|
||||
const configPath = await writeConfigWithToken(workdir, registry);
|
||||
const skillDir = join(workdir, "skills", "demo");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Demo\n\nA local sync fixture.\n", "utf8");
|
||||
|
||||
const result = await runNodeAsync(
|
||||
[
|
||||
binPath,
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--registry",
|
||||
registry,
|
||||
"sync",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--no-clawdbot-roots",
|
||||
],
|
||||
{ CLAWHUB_CONFIG_PATH: configPath },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const output = JSON.parse(result.stdout.trim()) as { ok: boolean; dryRun: boolean };
|
||||
expect(output).toMatchObject({ ok: true, dryRun: true });
|
||||
|
||||
expect(
|
||||
requests.filter((request) => request.path.startsWith("/api/cli/telemetry/")),
|
||||
).toHaveLength(0);
|
||||
expect(requests.map((request) => request.path)).toEqual([
|
||||
"/api/v1/whoami",
|
||||
expect.stringMatching(/^\/api\/v1\/resolve\?slug=demo&hash=/),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the built dist free of compiled test files", async () => {
|
||||
expect(dirname(distCliPath)).toBe(join(packageRoot, "dist"));
|
||||
const result = runNode([
|
||||
|
||||
@@ -61,10 +61,6 @@ read_when:
|
||||
- Cleanup:
|
||||
- `bun clawhub delete manual-skill-<ts> --yes`
|
||||
|
||||
## Sync
|
||||
|
||||
- `bun clawhub sync --dry-run --all`
|
||||
|
||||
## Playwright (menu smoke)
|
||||
|
||||
Run against prod:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
describe("skill publish workflow", () => {
|
||||
it("publishes one skill at a time without recreating the sync command", () => {
|
||||
const workflow = readFileSync(resolve(".github/workflows/skill-publish.yml"), "utf8");
|
||||
|
||||
expect(() => parseYaml(workflow)).not.toThrow();
|
||||
expect(workflow).toContain("skill publish");
|
||||
expect(workflow).toContain("INPUT_SKILL_PATH");
|
||||
expect(workflow).toContain("INPUT_ROOT");
|
||||
expect(workflow).toContain("--dry-run");
|
||||
expect(workflow).toContain("--json");
|
||||
expect(workflow).toContain("--source-repo");
|
||||
expect(workflow).toContain("--source-commit");
|
||||
expect(workflow).toContain("alreadySynced");
|
||||
expect(workflow).toContain("wouldPublish");
|
||||
expect(workflow).not.toMatch(/\bsync\b/);
|
||||
expect(workflow).not.toContain("--bump");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user