diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a780b0f7..66baf324 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/codeql/codeql-cli-package-security.yml b/.github/codeql/codeql-cli-package-security.yml index f63d53ad..08c65cb8 100644 --- a/.github/codeql/codeql-cli-package-security.yml +++ b/.github/codeql/codeql-cli-package-security.yml @@ -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 diff --git a/.github/workflows/skill-publish.yml b/.github/workflows/skill-publish.yml index dc06681e..c9fbcedc 100644 --- a/.github/workflows/skill-publish.yml +++ b/.github/workflows/skill-publish.yml @@ -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") diff --git a/CHANGELOG.md b/CHANGELOG.md index 668e5f5f..b9f51083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.22.0 - 2026-06-15 + +### Changes + +- CLI: remove the `clawhub sync` command. `clawhub skill publish ` 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee98cd3a..d83961b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: diff --git a/README.md b/README.md index 8f6122d3..2128c9e1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Common CLI flows: - Browse unified catalog (skills + plugins): `clawhub package explore`, `clawhub package inspect ` - Manage local installs: `clawhub install `, `clawhub pin `, `clawhub unpin `, `clawhub uninstall `, `clawhub list`, `clawhub update --all` - Inspect without installing: `clawhub inspect ` -- Publish/sync skills: `clawhub skill publish `, `clawhub sync` +- Publish skills: `clawhub skill publish ` - Publish plugins: `clawhub package publish ` - 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 `, `clawhub skill merge ` diff --git a/docs/README.md b/docs/README.md index 5e54a533..a2512028 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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: diff --git a/docs/clawhub.md b/docs/clawhub.md index 90883691..44986ae0 100644 --- a/docs/clawhub.md +++ b/docs/clawhub.md @@ -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: diff --git a/docs/cli.md b/docs/cli.md index 33ec2790..9410a028 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 ` -- 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 ` 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 ` 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 `. ```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 ` - 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 ` - 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 ` extra scan roots - - `--all` upload without prompting - - `--dry-run` show plan only - - `--json` machine-readable summary for CI - - `--owner ` publish under a user or org publisher - - `--bump patch|minor|major` (default: patch) - - `--changelog ` (non-interactive) - - `--tags a,b,c` (default: latest) - - `--concurrency ` - - `--source-repo `, `--source-commit `, `--source-ref ` for GitHub provenance - -`sync` does not report install telemetry. - ### Install telemetry - Sent after `clawhub install ` when logged in, unless diff --git a/docs/publishing.md b/docs/publishing.md index 6fa744ed..29da0297 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -26,102 +26,22 @@ clawhub login clawhub skill publish ./my-skill \ --slug my-skill \ --name "My Skill" \ - --version 1.0.0 \ --owner ``` Use `--owner ` 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 -clawhub sync --all --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/. - 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/." - 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: - 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: - 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: @@ -130,22 +50,7 @@ jobs: clawhub_token: ${{ secrets.CLAWHUB_TOKEN }} ``` -Replace `` 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 diff --git a/docs/quickstart.md b/docs/quickstart.md index 0fd16542..1d2aa00f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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 diff --git a/docs/skill-format.md b/docs/skill-format.md index 8dff2d19..72d04a02 100644 --- a/docs/skill-format.md +++ b/docs/skill-format.md @@ -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): diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 52590fe0..975d8362 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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: diff --git a/e2e/clawhub.e2e.test.ts b/e2e/clawhub.e2e.test.ts index 7d7b8385..f09c1f66 100644 --- a/e2e/clawhub.e2e.test.ts +++ b/e2e/clawhub.e2e.test.ts @@ -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((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((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(); diff --git a/packages/clawhub/README.md b/packages/clawhub/README.md index fa033021..c9664720 100644 --- a/packages/clawhub/README.md +++ b/packages/clawhub/README.md @@ -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`) diff --git a/packages/clawhub/package.json b/packages/clawhub/package.json index 119853a3..f2554caa 100644 --- a/packages/clawhub/package.json +++ b/packages/clawhub/package.json @@ -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": { diff --git a/packages/clawhub/src/cli.ts b/packages/clawhub/src/cli.ts index 499405b2..519dda5f 100644 --- a/packages/clawhub/src/cli.ts +++ b/packages/clawhub/src/cli.ts @@ -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 ", "Display name") .option("--owner ", "Publish under an org/user publisher handle") .option("--migrate-owner", "Move an existing skill to the selected owner when republishing") - .option("--version ", "Version (semver)") + .option("--version ", "Explicit version (defaults to 1.0.0 or next patch)") .option("--fork-of ", "Mark as a fork of an existing skill") .option("--changelog ", "Changelog text") .option("--tags ", "Comma-separated tags", "latest") + .option("--dry-run", "Preview without publishing") + .option("--json", "Output JSON") + .option("--source-repo ", "GitHub source repository") + .option("--source-commit ", "GitHub source commit") + .option("--source-ref ", "GitHub source ref") + .option("--source-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 ", "Display name") .option("--owner ", "Publish under an org/user publisher handle") .option("--migrate-owner", "Move an existing skill to the selected owner when republishing") - .option("--version ", "Version (semver)") + .option("--version ", "Explicit version (defaults to 1.0.0 or next patch)") .option("--fork-of ", "Mark as a fork of an existing skill") .option("--changelog ", "Changelog text") .option("--tags ", "Comma-separated tags", "latest") + .option("--dry-run", "Preview without publishing") + .option("--json", "Output JSON") + .option("--source-repo ", "GitHub source repository") + .option("--source-commit ", "GitHub source commit") + .option("--source-ref ", "GitHub source ref") + .option("--source-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 ", "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 ", "Publish under an org/user publisher handle") - .option("--bump ", "Version bump for updates (patch|minor|major)", "patch") - .option("--changelog ", "Changelog to use for updates (non-interactive)") - .option("--tags ", "Comma-separated tags", "latest") - .option("--concurrency ", "Concurrent registry/file checks", (value) => - Number.parseInt(value, 10), - ) - .option("--source-repo ", "GitHub repo URL or owner/name for source provenance") - .option("--source-commit ", "Git commit SHA for source provenance") - .option("--source-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; }); diff --git a/packages/clawhub/src/cli/clawdbotConfig.test.ts b/packages/clawhub/src/cli/clawdbotConfig.test.ts index ef33f0eb..2c8744fb 100644 --- a/packages/clawhub/src/cli/clawdbotConfig.test.ts +++ b/packages/clawhub/src/cli/clawdbotConfig.test.ts @@ -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)); }); }); diff --git a/packages/clawhub/src/cli/clawdbotConfig.ts b/packages/clawhub/src/cli/clawdbotConfig.ts index 4decb342..ff59c718 100644 --- a/packages/clawhub/src/cli/clawdbotConfig.ts +++ b/packages/clawhub/src/cli/clawdbotConfig.ts @@ -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; -}; - -export async function resolveClawdbotSkillRoots(): Promise { - const roots: string[] = []; - const labels: Record = {}; - - 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 { const config = await readClawdbotConfig(); const openclawConfig = await readOpenclawConfig(); @@ -144,61 +96,3 @@ async function readConfigFile(path: string): Promise { return null; } } - -function addConfigRoots( - config: ClawdbotConfig, - roots: string[], - labels: Record, - 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, 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}`; -} diff --git a/packages/clawhub/src/cli/commands/installTelemetry.ts b/packages/clawhub/src/cli/commands/installTelemetry.ts new file mode 100644 index 00000000..bae0bdfe --- /dev/null +++ b/packages/clawhub/src/cli/commands/installTelemetry.ts @@ -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}`; +} diff --git a/packages/clawhub/src/cli/commands/publish.test.ts b/packages/clawhub/src/cli/commands/publish.test.ts index 2564b208..9eb1d30a 100644 --- a/packages/clawhub/src/cli/commands/publish.test.ts +++ b/packages/clawhub/src/cli/commands/publish.test.ts @@ -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; +} diff --git a/packages/clawhub/src/cli/commands/publish.ts b/packages/clawhub/src/cli/commands/publish.ts index 3c681086..7231e6f3 100644 --- a/packages/clawhub/src/cli/commands/publish.ts +++ b/packages/clawhub/src/cli/commands/publish.ts @@ -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 { 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 " 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 { + 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>) { return files.filter((file) => file.relPath.trim().toLowerCase() !== "skill-card.md"); } diff --git a/packages/clawhub/src/cli/commands/skills.ts b/packages/clawhub/src/cli/commands/skills.ts index c985d48a..13a6165a 100644 --- a/packages/clawhub/src/cli/commands/skills.ts +++ b/packages/clawhub/src/cli/commands/skills.ts @@ -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; diff --git a/packages/clawhub/src/cli/commands/sync.test.ts b/packages/clawhub/src/cli/commands/sync.test.ts deleted file mode 100644 index 80efe856..00000000 --- a/packages/clawhub/src/cli/commands/sync.test.ts +++ /dev/null @@ -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 = (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, - }) 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); - }); -}); diff --git a/packages/clawhub/src/cli/commands/sync.ts b/packages/clawhub/src/cli/commands/sync.ts deleted file mode 100644 index 371c6ae5..00000000 --- a/packages/clawhub/src/cli/commands/sync.ts +++ /dev/null @@ -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, -) { - 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>; - 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`); -} diff --git a/packages/clawhub/src/cli/commands/syncHelpers.test.ts b/packages/clawhub/src/cli/commands/syncHelpers.test.ts deleted file mode 100644 index 20e198fc..00000000 --- a/packages/clawhub/src/cli/commands/syncHelpers.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/packages/clawhub/src/cli/commands/syncHelpers.ts b/packages/clawhub/src/cli/commands/syncHelpers.ts deleted file mode 100644 index f0f72b14..00000000 --- a/packages/clawhub/src/cli/commands/syncHelpers.ts +++ /dev/null @@ -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( - items: T[], - limit: number, - fn: (item: T) => Promise, -) { - 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 { - 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) { - const all: SkillFolder[] = []; - const rootsWithSkills: string[] = []; - const uniqueRoots = await dedupeRoots(roots); - const skillsByRoot: Record = {}; - const rootLabels: Record = {}; - 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(); - 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(); - 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 { - if (params.all || !params.allowPrompt) return candidates; - - const valueByKey = new Map(); - 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(); - 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`; -} diff --git a/packages/clawhub/src/cli/commands/syncTypes.ts b/packages/clawhub/src/cli/commands/syncTypes.ts deleted file mode 100644 index 4fe715a9..00000000 --- a/packages/clawhub/src/cli/commands/syncTypes.ts +++ /dev/null @@ -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; -}; diff --git a/packages/clawhub/src/cli/scanSkills.test.ts b/packages/clawhub/src/cli/scanSkills.test.ts deleted file mode 100644 index 40a31e24..00000000 --- a/packages/clawhub/src/cli/scanSkills.test.ts +++ /dev/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); - }); -}); diff --git a/packages/clawhub/src/cli/scanSkills.ts b/packages/clawhub/src/cli/scanSkills.ts deleted file mode 100644 index 3bbf1c14..00000000 --- a/packages/clawhub/src/cli/scanSkills.ts +++ /dev/null @@ -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 { - 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 { - 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; -} diff --git a/packages/clawhub/test-artifact/cli.artifact.test.ts b/packages/clawhub/test-artifact/cli.artifact.test.ts index df95d398..3e3d98c3 100644 --- a/packages/clawhub/test-artifact/cli.artifact.test.ts +++ b/packages/clawhub/test-artifact/cli.artifact.test.ts @@ -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 "); + 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([ diff --git a/specs/manual-testing.md b/specs/manual-testing.md index 67b99936..6cede858 100644 --- a/specs/manual-testing.md +++ b/specs/manual-testing.md @@ -61,10 +61,6 @@ read_when: - Cleanup: - `bun clawhub delete manual-skill- --yes` -## Sync - -- `bun clawhub sync --dry-run --all` - ## Playwright (menu smoke) Run against prod: diff --git a/src/__tests__/skill-publish-workflow.test.ts b/src/__tests__/skill-publish-workflow.test.ts new file mode 100644 index 00000000..ec97b7a1 --- /dev/null +++ b/src/__tests__/skill-publish-workflow.test.ts @@ -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"); + }); +});