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:
Patrick Erichsen
2026-06-15 18:22:52 -07:00
committed by GitHub
parent 69dd3b5f68
commit e3e5705d89
33 changed files with 657 additions and 2897 deletions
-2
View File
@@ -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
+108 -88
View File
@@ -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")
+7
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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:
-184
View File
@@ -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();
+2 -12
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -107
View File
@@ -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>;
}
+121 -13
View File
@@ -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");
}
+1 -1
View File
@@ -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);
});
});
-469
View File
@@ -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);
});
});
-102
View File
@@ -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([
-4
View File
@@ -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");
});
});