From bbe887faacd7d09591bec5ae3a00bdf819b52118 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Mon, 22 Jun 2026 18:45:59 -0700 Subject: [PATCH] feat(cli): restore skill sync command Restores the ClawHub CLI sync command as a one-way skill publish workflow without install/download telemetry updates. --- docs/cli.md | 25 + packages/clawhub/README.md | 2 + packages/clawhub/src/cli.ts | 46 ++ packages/clawhub/src/cli/commands/publish.ts | 15 +- .../clawhub/src/cli/commands/sync.test.ts | 423 +++++++++++++++ packages/clawhub/src/cli/commands/sync.ts | 497 ++++++++++++++++++ .../clawhub/src/cli/commands/syncHelpers.ts | 260 +++++++++ .../clawhub/src/cli/commands/syncTypes.ts | 29 + packages/clawhub/src/cli/scanSkills.ts | 65 +++ .../test-artifact/cli.artifact.test.ts | 55 +- 10 files changed, 1407 insertions(+), 10 deletions(-) create mode 100644 packages/clawhub/src/cli/commands/sync.test.ts create mode 100644 packages/clawhub/src/cli/commands/sync.ts create mode 100644 packages/clawhub/src/cli/commands/syncHelpers.ts create mode 100644 packages/clawhub/src/cli/commands/syncTypes.ts create mode 100644 packages/clawhub/src/cli/scanSkills.ts diff --git a/docs/cli.md b/docs/cli.md index 484beb54..c34e2357 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -210,6 +210,31 @@ same automatic patch-version behavior. Set `dry_run: true` to preview without a token. Real publishes require the `clawhub_token` secret. +### `sync` + +- Scans the current workdir, the configured skills directory, and any + `--root ` folders for local skill folders containing `SKILL.md` or + `skill.md`. +- Compares each local skill fingerprint with ClawHub and publishes only new or + changed skills. +- New skills publish as `1.0.0`; changed skills publish the next patch version + by default. Use `--bump minor|major` for update batches that should move by a + larger semver step. +- `--dry-run` shows the publish plan without uploading; `--json` prints a + machine-readable plan. +- `--all` publishes every new or changed skill without prompting. Without + `--all`, interactive terminals let you select the skills to publish. +- `--owner ` publishes under an org/user publisher handle when the + actor has publisher access. +- `sync` is one-way publish only. It does not install, update, download, or + report install/download telemetry. + +```bash +clawhub sync --all --dry-run +clawhub sync --all +clawhub sync --root ./skills --owner openclaw --bump minor +``` + ### `scan --slug ` - Requires `clawhub login`. diff --git a/packages/clawhub/README.md b/packages/clawhub/README.md index 62d6c896..e1169b1b 100644 --- a/packages/clawhub/README.md +++ b/packages/clawhub/README.md @@ -51,6 +51,8 @@ clawhub update --all --no-input --force clawhub unpin bear-notes 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 sync --all --dry-run +clawhub sync --all clawhub package explore --family skill clawhub package explore --family code-plugin clawhub package inspect @openclaw/example-plugin diff --git a/packages/clawhub/src/cli.ts b/packages/clawhub/src/cli.ts index fd3ea03a..d5f7471d 100644 --- a/packages/clawhub/src/cli.ts +++ b/packages/clawhub/src/cli.ts @@ -46,6 +46,7 @@ import { cmdUpdate, } from "./cli/commands/skills.js"; import { cmdStarSkill } from "./cli/commands/star.js"; +import { cmdSync } from "./cli/commands/sync.js"; import { cmdTransferAccept, cmdTransferCancel, @@ -861,6 +862,50 @@ registerCommand(program, ["unstar"]) await cmdUnstarSkill(opts, slug, options, isInputAllowed()); }); +registerCommand(program, ["sync"]) + .description("Scan local skills and publish new or changed ones") + .option("--root ", "Extra scan roots (one or more)") + .option("--all", "Publish all new or changed skills without prompting") + .option("--dry-run", "Show what would be published") + .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") + .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") + .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, + sourceRepo: options.sourceRepo, + sourceCommit: options.sourceCommit, + sourceRef: options.sourceRef, + }, + isInputAllowed(), + ); + }); + applyCommandHelpGroups(program, { login: "Auth:", logout: "Auth:", @@ -878,6 +923,7 @@ applyCommandHelpGroups(program, { star: "Skills:", unstar: "Skills:", publish: "Publishing:", + sync: "Publishing:", skill: "Publishing:", publisher: "Publishing:", package: "Packages:", diff --git a/packages/clawhub/src/cli/commands/publish.ts b/packages/clawhub/src/cli/commands/publish.ts index 663c17c2..e484c777 100644 --- a/packages/clawhub/src/cli/commands/publish.ts +++ b/packages/clawhub/src/cli/commands/publish.ts @@ -50,6 +50,7 @@ export async function cmdPublish( sourcePath?: string; dryRun?: boolean; json?: boolean; + quiet?: boolean; }, ): Promise { const folder = folderArg ? resolve(opts.workdir, folderArg) : null; @@ -86,11 +87,9 @@ export async function cmdPublish( if (!displayName) fail("--name required"); if (explicitVersion && !semver.valid(explicitVersion)) fail("--version must be valid semver"); - const spinner = options.json ? null : createCrabLoader(`Preparing ${slug}`); + const spinner = options.json || options.quiet ? null : createCrabLoader(`Preparing ${slug}`); try { - const filesOnDisk = stripGeneratedSkillCards( - await ensureRootManifestFile(folder, await listTextFiles(folder)), - ); + const filesOnDisk = await prepareSkillFilesForPublish(folder); if (filesOnDisk.length === 0) fail("No files found"); if ( !filesOnDisk.some((file) => { @@ -226,7 +225,7 @@ function parseCsv(value: string | undefined) { .filter(Boolean); } -async function resolveDefaultOwnerHandle(registry: string, token: string) { +export async function resolveDefaultOwnerHandle(registry: string, token: string) { const whoami = await apiRequest( registry, { method: "GET", path: ApiRoutes.whoami, token }, @@ -277,6 +276,12 @@ function writePublishJsonIfRequested(json: boolean | undefined, result: SkillPub if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } +export async function prepareSkillFilesForPublish(folder: string) { + return stripGeneratedSkillCards( + await ensureRootManifestFile(folder, await listTextFiles(folder)), + ); +} + function stripGeneratedSkillCards(files: Awaited>) { return files.filter((file) => file.relPath.trim().toLowerCase() !== "skill-card.md"); } diff --git a/packages/clawhub/src/cli/commands/sync.test.ts b/packages/clawhub/src/cli/commands/sync.test.ts new file mode 100644 index 00000000..8587711b --- /dev/null +++ b/packages/clawhub/src/cli/commands/sync.test.ts @@ -0,0 +1,423 @@ +/* @vitest-environment node */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createAuthTokenModuleMocks, + createHttpModuleMocks, + createRegistryModuleMocks, + createUiModuleMocks, + makeGlobalOpts, +} from "../../../test/cliCommandTestKit.js"; +import type { SkillOrigin } from "../../skills.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: `${root}/new-skill`, slug: "new-skill", displayName: "New Skill" }, + { folder: `${root}/synced-skill`, slug: "synced-skill", displayName: "Synced Skill" }, + { folder: `${root}/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), + isCancel: () => false, +})); + +const authTokenMocks = createAuthTokenModuleMocks(); +const registryMocks = createRegistryModuleMocks(); +const httpMocks = createHttpModuleMocks(); +const uiMocks = createUiModuleMocks(); +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", () => ({ + createCrabLoader: vi.fn(() => mockSpinner), + fail: (message: string) => mockFail(message), + formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + isInteractive: () => interactive, +})); + +vi.mock("../scanSkills.js", () => ({ + findSkillFolders: vi.fn(defaultFindSkillFolders), + getFallbackSkillRoots: vi.fn(() => []), +})); + +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 mockReadSkillOrigin = vi.fn(async (_folder?: string): Promise => null); +vi.mock("../../skills.js", () => ({ + listTextFiles: (folder: string) => mockListTextFiles(folder), + hashSkillFiles: (files: Array<{ relPath: string; bytes: Uint8Array }>) => + mockHashSkillFiles(files), + readSkillOrigin: (folder: string) => mockReadSkillOrigin(folder), +})); + +const mockCmdPublish = vi.fn(); +const mockPrepareSkillFilesForPublish = vi.fn(async (folder: string) => mockListTextFiles(folder)); +vi.mock("./publish.js", () => ({ + cmdPublish: (opts: unknown, folder: unknown, options?: unknown) => + mockCmdPublish(opts, folder, options), + prepareSkillFilesForPublish: (folder: string) => mockPrepareSkillFilesForPublish(folder), + resolveDefaultOwnerHandle: async (_registry: string, _token: string) => "steipete", +})); + +const { cmdSync } = await import("./sync"); + +function makeOpts() { + return makeGlobalOpts(); +} + +afterEach(async () => { + vi.clearAllMocks(); + mockCmdPublish.mockReset(); + mockPrepareSkillFilesForPublish.mockImplementation(async (folder: string) => + mockListTextFiles(folder), + ); + mockReadSkillOrigin.mockImplementation(async (_folder?: string) => null); + process.exitCode = undefined; + const { findSkillFolders, getFallbackSkillRoots } = await import("../scanSkills.js"); + mocked(findSkillFolders).mockImplementation(defaultFindSkillFolders); + mocked(getFallbackSkillRoots).mockImplementation(() => []); +}); + +vi.spyOn(console, "log").mockImplementation((...args) => { + mockLog(args.map(String).join(" ")); +}); + +describe("cmdSync", () => { + 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: ${String(args.path)}`); + }); + + let output = ""; + try { + await cmdSync( + makeOpts(), + { + root: ["/scan"], + all: true, + dryRun: true, + json: true, + owner: "nvidia", + }, + false, + ); + output = String(stdoutWrite.mock.calls.at(-1)?.[0] ?? "").trim(); + } finally { + stdoutWrite.mockRestore(); + } + + expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled(); + expect(mockCmdPublish).not.toHaveBeenCalled(); + expect(mockPrepareSkillFilesForPublish).toHaveBeenCalledTimes(3); + for (const call of mockApiRequest.mock.calls) { + const path = String(call[1]?.path ?? ""); + if (path.startsWith("/api/v1/resolve?")) { + expect(new URL(`https://x.test${path}`).searchParams.get("ownerHandle")).toBe("nvidia"); + } + } + 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 }>; + 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.alreadySynced).toEqual([ + expect.objectContaining({ slug: "synced-skill", version: "1.2.3" }), + ]); + expect(parsed.published).toEqual([]); + expect(parsed.failed).toEqual([]); + }); + + it("publishes selected skills without reporting install telemetry", 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?")) { + 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: ${String(args.path)}`); + }); + + await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false }, true); + + expect(mockCmdPublish).toHaveBeenCalledTimes(2); + expect(mockCmdPublish.mock.calls.map((call) => (call[2] as { slug: string }).slug)).toEqual([ + "new-skill", + "update-skill", + ]); + for (const call of mockApiRequest.mock.calls) { + const path = String(call[1]?.path ?? ""); + if (path.startsWith("/api/v1/resolve?")) { + expect(new URL(`https://x.test${path}`).searchParams.get("ownerHandle")).toBe("steipete"); + } + } + expect(mockCmdPublish.mock.calls.map((call) => (call[2] as { owner: string }).owner)).toEqual([ + "steipete", + "steipete", + ]); + expect( + mockApiRequest.mock.calls.some((call) => call[1]?.path === "/api/cli/telemetry/install"), + ).toBe(false); + }); + + it("owner-qualifies fork provenance from installed origins", async () => { + interactive = false; + mockReadSkillOrigin.mockImplementation(async (folder?: string) => + folder?.endsWith("/new-skill") + ? { + version: 1, + registry: "https://clawhub.ai", + slug: "new-skill", + ownerHandle: "openclaw", + installedVersion: "1.2.3", + installedAt: 1, + } + : null, + ); + 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?")) { + 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"); + return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } }; + } + throw new Error(`Unexpected apiRequest: ${String(args.path)}`); + }); + + await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false }, false); + + expect(mockCmdPublish).toHaveBeenCalledTimes(1); + expect(mockCmdPublish.mock.calls[0]?.[2]).toMatchObject({ + slug: "new-skill", + forkOf: "@openclaw/new-skill@1.2.3", + }); + }); + + it("resolves relative roots against --workdir and keeps source paths relative", async () => { + interactive = false; + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/workspace/scan"); + const { findSkillFolders } = await import("../scanSkills.js"); + 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 === "synced-skill") { + return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } }; + } + throw new Error("Skill not found"); + } + throw new Error(`Unexpected apiRequest: ${String(args.path)}`); + }); + + try { + await cmdSync( + makeGlobalOpts("/workspace"), + { + root: ["scan"], + all: true, + dryRun: false, + sourceRepo: "example/tools", + sourceCommit: "1234567890abcdef", + }, + false, + ); + } finally { + cwdSpy.mockRestore(); + } + + expect(findSkillFolders).toHaveBeenCalledWith("/workspace/scan"); + expect( + mockCmdPublish.mock.calls.map((call) => (call[2] as { sourcePath?: string }).sourcePath), + ).toEqual(["scan/new-skill", "scan/update-skill"]); + }); + + it("uses scan-root-relative source paths for skills outside --workdir", async () => { + interactive = false; + 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 === "synced-skill") { + return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } }; + } + throw new Error("Skill not found"); + } + throw new Error(`Unexpected apiRequest: ${String(args.path)}`); + }); + + await cmdSync( + makeGlobalOpts("/workspace"), + { + root: ["/external/scan"], + all: true, + dryRun: false, + sourceRepo: "example/tools", + sourceCommit: "1234567890abcdef", + }, + false, + ); + + expect( + mockCmdPublish.mock.calls.map((call) => (call[2] as { sourcePath?: string }).sourcePath), + ).toEqual(["new-skill", "update-skill"]); + }); + + it("keeps real sync JSON output owned by sync", 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: ${String(args.path)}`); + }); + mockCmdPublish.mockImplementation((_opts: unknown, _folder: unknown, options?: unknown) => { + if (!(options as { quiet?: boolean } | undefined)?.quiet) { + process.stdout.write("child publish output\n"); + } + return { + version: (options as { version?: string } | undefined)?.version ?? "1.0.0", + }; + }); + + let output = ""; + try { + await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: false, json: true }, false); + expect(stdoutWrite).toHaveBeenCalledTimes(1); + output = String(stdoutWrite.mock.calls[0]?.[0] ?? ""); + } finally { + stdoutWrite.mockRestore(); + } + + const parsed = JSON.parse(output); + expect(parsed).toMatchObject({ + ok: true, + summary: { published: 2, failed: 0 }, + }); + expect(mockCmdPublish.mock.calls.map((call) => (call[2] as { quiet?: boolean }).quiet)).toEqual( + [true, true], + ); + }); + + it("requires --all for non-interactive publish mode", async () => { + interactive = false; + 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 === "synced-skill") { + return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } }; + } + throw new Error("Skill not found"); + } + throw new Error(`Unexpected apiRequest: ${String(args.path)}`); + }); + + await expect(cmdSync(makeOpts(), { root: ["/scan"], dryRun: false }, false)).rejects.toThrow( + "Pass --all", + ); + + expect(mockMultiselect).not.toHaveBeenCalled(); + expect(mockCmdPublish).not.toHaveBeenCalled(); + }); + + it("refuses real --all publishes from fallback roots", async () => { + interactive = false; + const { findSkillFolders, getFallbackSkillRoots } = await import("../scanSkills.js"); + mocked(findSkillFolders).mockImplementation(async (root: string) => { + if (root === "/work" || root === "/work/skills") return []; + if (root === "/fallback/skills") { + return [ + { + folder: "/fallback/skills/private-skill", + slug: "private-skill", + displayName: "Private Skill", + }, + ]; + } + return []; + }); + mocked(getFallbackSkillRoots).mockImplementation(() => ["/fallback/skills"]); + 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: ${String(args.path)}`); + }); + + await expect(cmdSync(makeOpts(), { all: true, dryRun: false }, false)).rejects.toThrow( + "Refusing to publish fallback skill roots with --all", + ); + + expect(mockCmdPublish).not.toHaveBeenCalled(); + expect(mockPrepareSkillFilesForPublish).not.toHaveBeenCalled(); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/clawhub/src/cli/commands/sync.ts b/packages/clawhub/src/cli/commands/sync.ts new file mode 100644 index 00000000..21c5c437 --- /dev/null +++ b/packages/clawhub/src/cli/commands/sync.ts @@ -0,0 +1,497 @@ +import { isAbsolute, relative } from "node:path"; +import { intro, outro } from "@clack/prompts"; +import { hashSkillFiles, readSkillOrigin } from "../../skills.js"; +import { getOptionalAuthToken, requireAuthToken } from "../authToken.js"; +import { getRegistry } from "../registry.js"; +import { getFallbackSkillRoots } from "../scanSkills.js"; +import type { GlobalOpts } from "../types.js"; +import { createCrabLoader, fail, formatError, isInteractive } from "../ui.js"; +import { normalizeGitHubRepo } from "./github.js"; +import { cmdPublish, prepareSkillFilesForPublish, resolveDefaultOwnerHandle } from "./publish.js"; +import { + buildScanRoots, + checkRegistrySyncState, + dedupeSkillsBySlug, + formatActionableLine, + formatBulletList, + formatCommaList, + formatList, + formatSyncedDisplay, + formatSyncedSummary, + 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 = await getRegistry(opts, { cache: true }); + const ownerHandle = await resolveSyncOwnerHandle(registry, token, options.owner); + const selectedRoots = buildScanRoots(opts, options.root); + const concurrency = normalizeConcurrency(options.concurrency); + + const spinner = jsonMode ? null : createCrabLoader("Scanning for local skills"); + const primaryScan = await scanRootsWithLabels(selectedRoots); + let scan = primaryScan; + let outputRoots = primaryScan.roots; + if (primaryScan.skills.length === 0) { + const fallbackScan = await scanRootsWithLabels(getFallbackSkillRoots(opts.workdir)); + spinner?.stop(); + scan = fallbackScan; + outputRoots = fallbackScan.roots; + if (fallbackScan.skills.length === 0) fail("No skills found (checked configured roots)"); + if (!options.dryRun && options.all) { + fail( + "Refusing to publish fallback skill roots with --all. Pass --root for the skills to sync.", + ); + } + if (!jsonMode) { + printSection( + `No skills in workdir. Found ${fallbackScan.skills.length} in fallback locations.`, + formatList(fallbackScan.rootsWithSkills, 10), + ); + } + } else { + spinner?.stop(); + if (!jsonMode && primaryScan.rootsWithSkills.length > 0) { + printSection("Roots with skills", formatList(primaryScan.rootsWithSkills, 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 parsingLoader = jsonMode ? null : createCrabLoader("Parsing local skills"); + const locals: LocalSkill[] = []; + try { + let done = 0; + const parsed = await mapWithConcurrency(skills, Math.min(concurrency, 12), async (skill) => { + const filesOnDisk = await prepareSkillFilesForPublish(skill.folder); + const hashed = hashSkillFiles(filesOnDisk); + const origin = await readSkillOrigin(skill.folder); + done += 1; + if (parsingLoader) parsingLoader.text = `Parsing local skills ${done}/${skills.length}`; + return { + ...skill, + fingerprint: hashed.fingerprint, + fileCount: filesOnDisk.length, + origin, + }; + }); + locals.push(...parsed); + } catch (error) { + parsingLoader?.fail(formatError(error)); + throw error; + } finally { + parsingLoader?.stop(); + } + + const candidatesLoader = jsonMode ? null : createCrabLoader("Checking registry sync state"); + const candidates: Candidate[] = []; + try { + let done = 0; + const resolved = await mapWithConcurrency(locals, Math.min(concurrency, 16), async (skill) => { + try { + return await checkRegistrySyncState(registry, skill, ownerHandle, token); + } finally { + done += 1; + if (candidatesLoader) { + candidatesLoader.text = `Checking registry sync state ${done}/${locals.length}`; + } + } + }); + candidates.push(...resolved); + } catch (error) { + candidatesLoader?.fail(formatError(error)); + throw error; + } finally { + candidatesLoader?.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) { + writeNoActionOutput({ + jsonMode, + dryRun: Boolean(options.dryRun), + registry, + roots: outputRoots, + owner: ownerHandle, + duplicates: deduped.duplicates, + synced, + }); + return; + } + + if (!jsonMode) { + printSection( + "To sync", + formatBulletList( + actionable.map((candidate) => formatActionableLine(candidate, bump)), + 20, + ), + ); + } + if (!jsonMode && synced.length > 0) { + printSection("Already synced", formatSyncedDisplay(synced)); + } + + if (!options.dryRun && !options.all && !allowPrompt) { + fail("Pass --all to publish every detected local skill without prompting."); + } + + const selected = await selectToUpload(actionable, { + allowPrompt, + all: Boolean(options.all), + bump, + }); + if (selected.length === 0) { + writeNoActionOutput({ + jsonMode, + dryRun: Boolean(options.dryRun), + registry, + roots: outputRoots, + owner: ownerHandle, + duplicates: deduped.duplicates, + synced, + outroText: "Nothing selected.", + }); + return; + } + + const plannedPublishes = selected.map((skill) => { + const source = buildSourceProvenance(opts, skill, options, outputRoots); + return { skill, source }; + }); + + if (options.dryRun) { + const wouldPublish = plannedPublishes.map(({ skill, source }) => { + const { publishVersion } = resolvePublishMeta(skill, { + bump, + changelogFlag: options.changelog, + }); + return formatPublishJson(skill, publishVersion, source); + }); + if (jsonMode) { + writeSyncJson( + buildSyncJsonOutput({ + ok: true, + dryRun: true, + registry, + roots: outputRoots, + owner: ownerHandle, + duplicates: deduped.duplicates, + alreadySynced: synced.map(formatSyncedJson), + wouldPublish, + published: [], + failed: [], + }), + ); + return; + } + outro(`Dry run: would publish ${selected.length} skill(s).`); + return; + } + + const tags = options.tags ?? "latest"; + const failedUploads: Array<{ slug: string; message: string }> = []; + const published: Array<{ slug: string; folder: string; version: string }> = []; + + for (const { skill, source } of plannedPublishes) { + const { publishVersion, changelog } = resolvePublishMeta(skill, { + bump, + changelogFlag: options.changelog, + }); + const forkOf = buildForkOf(skill, registry, ownerHandle); + try { + const previousExitCode = process.exitCode; + const result = await cmdPublish(opts, skill.folder, { + slug: skill.slug, + name: skill.displayName, + owner: ownerHandle, + version: publishVersion, + changelog, + tags, + forkOf, + quiet: jsonMode, + ...(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; + } + published.push({ + slug: skill.slug, + folder: skill.folder, + version: result?.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: ownerHandle, + duplicates: deduped.duplicates, + alreadySynced: synced.map(formatSyncedJson), + wouldPublish: [], + published, + failed: failedUploads, + }), + ); + process.exitCode = 1; + return; + } + printSection( + "Failed to publish", + formatBulletList( + failedUploads.map((failure) => `${failure.slug}: ${failure.message}`), + 20, + ), + ); + outro( + `Published ${published.length} of ${selected.length} skill(s). ${failedUploads.length} failed.`, + ); + process.exitCode = 1; + return; + } + + if (jsonMode) { + writeSyncJson( + buildSyncJsonOutput({ + ok: true, + dryRun: false, + registry, + roots: outputRoots, + owner: ownerHandle, + duplicates: deduped.duplicates, + alreadySynced: synced.map(formatSyncedJson), + wouldPublish: [], + published, + failed: [], + }), + ); + return; + } + + outro(`Published ${selected.length} skill(s).`); +} + +function writeNoActionOutput(params: { + jsonMode: boolean; + dryRun: boolean; + registry: string; + roots: string[]; + owner?: string; + duplicates: string[]; + synced: Candidate[]; + outroText?: string; +}) { + if (params.jsonMode) { + writeSyncJson( + buildSyncJsonOutput({ + ok: true, + dryRun: params.dryRun, + registry: params.registry, + roots: params.roots, + owner: params.owner, + duplicates: params.duplicates, + alreadySynced: params.synced.map(formatSyncedJson), + wouldPublish: [], + published: [], + failed: [], + }), + ); + return; + } + if (params.synced.length > 0) { + printSection("Already synced", formatCommaList(params.synced.map(formatSyncedSummary), 16)); + } + outro(params.outroText ?? "Nothing to sync."); +} + +function normalizeRegistry(value: string) { + return value.trim().replace(/\/+$/, "").toLowerCase(); +} + +function buildForkOf(skill: Candidate, registry: string, ownerHandle: string | undefined) { + const origin = skill.origin; + if (!origin || normalizeRegistry(origin.registry) !== normalizeRegistry(registry)) + return undefined; + const originOwner = normalizeOwner(origin.ownerHandle); + const publishOwner = normalizeOwner(ownerHandle); + if (origin.slug === skill.slug && originOwner === publishOwner) return undefined; + const ref = `${origin.slug}@${origin.installedVersion}`; + return originOwner ? `@${originOwner}/${ref}` : ref; +} + +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; +} + +async function resolveSyncOwnerHandle(registry: string, token: string | undefined, owner?: string) { + const explicitOwner = normalizeOwner(owner); + if (explicitOwner || !token) return explicitOwner; + return await resolveDefaultOwnerHandle(registry, token); +} + +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, + scanRoots: string[], +) { + 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, scanRoots), + }; +} + +function sourcePathForSkill(opts: GlobalOpts, folder: string, scanRoots: string[]) { + const bases = Array.from( + new Set([opts.workdir, opts.dir, ...scanRoots, process.cwd()].map(normalizeSourcePathBase)), + ); + for (const base of bases) { + const rel = relativeInside(base, folder); + if (rel) return rel; + } + fail( + "Source provenance requires each skill folder to be inside the current directory, --workdir, configured skills directory, or a --root directory.", + ); + throw new Error("unreachable"); +} + +function normalizeSourcePathBase(value: string) { + return value.replace(/\/+$/, "") || "/"; +} + +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.ts b/packages/clawhub/src/cli/commands/syncHelpers.ts new file mode 100644 index 00000000..03a1d57d --- /dev/null +++ b/packages/clawhub/src/cli/commands/syncHelpers.ts @@ -0,0 +1,260 @@ +import { realpath } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; +import { isCancel, multiselect } from "@clack/prompts"; +import semver from "semver"; +import { resolveHome } from "../../homedir.js"; +import { apiRequest } from "../../http.js"; +import { ApiRoutes, ApiV1SkillResolveResponseSchema } from "../../schema/index.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 function buildScanRoots(opts: GlobalOpts, extraRoots: string[] | undefined) { + const roots = [opts.workdir, opts.dir, ...(extraRoots ?? [])]; + return Array.from(new Set(roots.map((root) => resolveScanRoot(opts, root)))); +} + +function resolveScanRoot(opts: GlobalOpts, root: string) { + return isAbsolute(root) ? resolve(root) : resolve(opts.workdir, 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, + ownerHandle?: string, + token?: string, +): Promise { + try { + const params = new URLSearchParams({ + slug: skill.slug, + hash: skill.fingerprint, + }); + if (ownerHandle) params.set("ownerHandle", ownerHandle); + const resolved = await apiRequest( + registry, + { + method: "GET", + path: `${ApiRoutes.resolve}?${params.toString()}`, + token, + }, + ApiV1SkillResolveResponseSchema, + ); + 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)) { + return { ...skill, status: "new", matchVersion: null, latestVersion: null }; + } + throw error; + } +} + +export async function scanRootsWithLabels(roots: string[]) { + const all: SkillFolder[] = []; + const rootsWithSkills: string[] = []; + const uniqueRoots = await dedupeRoots(roots); + const skillsByRoot: Record = {}; + for (const root of uniqueRoots) { + const found = await findSkillFolders(root); + skillsByRoot[root] = found; + if (found.length > 0) rootsWithSkills.push(root); + all.push(...found); + } + const byFolder = new Map(); + for (const folder of all) { + byFolder.set(folder.folder, folder); + } + return { + roots: uniqueRoots, + skillsByRoot, + skills: Array.from(byFolder.values()), + rootsWithSkills, + }; +} + +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 publish", + options: choices, + initialValues: choices.map((choice) => choice.value), + required: false, + }); + if (isCancel(picked)) fail("Canceled"); + return picked.map((key) => valueByKey.get(key)).filter(Boolean) as Candidate[]; +} + +export function resolvePublishMeta( + skill: Candidate, + params: { bump: "patch" | "minor" | "major"; 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(); + return { publishVersion, changelog: fromFlag ?? "" }; +} + +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; +} + +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 new file mode 100644 index 00000000..f11f579d --- /dev/null +++ b/packages/clawhub/src/cli/commands/syncTypes.ts @@ -0,0 +1,29 @@ +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; + sourceRepo?: string; + sourceCommit?: string; + sourceRef?: string; +}; + +export type LocalSkill = SkillFolder & { + fingerprint: string; + fileCount: number; + origin: SkillOrigin | null; +}; + +export type Candidate = LocalSkill & { + status: "synced" | "new" | "update"; + matchVersion: string | null; + latestVersion: string | null; +}; diff --git a/packages/clawhub/src/cli/scanSkills.ts b/packages/clawhub/src/cli/scanSkills.ts new file mode 100644 index 00000000..78378349 --- /dev/null +++ b/packages/clawhub/src/cli/scanSkills.ts @@ -0,0 +1,65 @@ +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 = [ + resolve(workdir, "..", "openclaw", "skills"), + resolve(workdir, "..", "openclaw", "Skills"), + resolve(home, ".openclaw", "skills"), + resolve(home, ".openclaw", "Skills"), + resolve(home, "openclaw", "skills"), + resolve(home, "openclaw", "Skills"), + resolve(home, "Library", "Application Support", "openclaw", "skills"), + resolve(home, "Library", "Application Support", "openclaw", "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 10c20e94..255134ec 100644 --- a/packages/clawhub/test-artifact/cli.artifact.test.ts +++ b/packages/clawhub/test-artifact/cli.artifact.test.ts @@ -330,7 +330,7 @@ describe("built CLI artifact", () => { expect(result.status).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout).toContain("Usage: clawhub"); - expect(result.stdout).not.toContain("sync"); + expect(result.stdout).toContain("sync"); }); it("prints help for bare logged-in invocations", async () => { @@ -349,11 +349,56 @@ describe("built CLI artifact", () => { expect(requests).toHaveLength(0); }); - it("does not expose the removed sync command", async () => { - const result = runNode([binPath, "sync"]); + it("exposes the restored sync command", async () => { + const result = runNode([binPath, "sync", "--help"]); - expect(result.status).toBe(1); - expect(result.stderr).toContain("error: unknown command 'sync'"); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Scan local skills and publish new or changed ones"); + expect(result.stdout).toContain("--dry-run"); + expect(result.stdout).toContain("--json"); + }); + + it("plans sync publishes without install telemetry", async () => { + const { registry, requests } = await startLocalRegistry(); + const workdir = await makeTmpDir("clawhub-artifact-sync-"); + const root = join(workdir, "skills"); + await mkdir(join(root, "new-skill"), { recursive: true }); + await mkdir(join(root, "changed-skill"), { recursive: true }); + await mkdir(join(root, "synced-skill"), { recursive: true }); + await writeFile(join(root, "new-skill", "SKILL.md"), "# New\n", "utf8"); + await writeFile(join(root, "changed-skill", "SKILL.md"), "# Changed\n", "utf8"); + await writeFile(join(root, "synced-skill", "SKILL.md"), "# Synced\n", "utf8"); + + const result = await runNodeAsync([ + binPath, + "--workdir", + workdir, + "--registry", + registry, + "--no-input", + "sync", + "--root", + root, + "--all", + "--dry-run", + "--json", + ]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout) as { + ok: boolean; + summary: { wouldPublish: number; alreadySynced: number }; + wouldPublish: Array<{ slug: string; version: string; status: string }>; + }; + expect(parsed.ok).toBe(true); + expect(parsed.summary).toMatchObject({ wouldPublish: 2, alreadySynced: 1 }); + expect(parsed.wouldPublish.map((entry) => [entry.slug, entry.version, entry.status])).toEqual([ + ["changed-skill", "1.2.4", "update"], + ["new-skill", "1.0.0", "new"], + ]); + expect(requests.map((request) => request.path)).not.toContain("/api/cli/telemetry/install"); }); it("reports unknown top-level commands clearly", async () => {