feat(cli): restore skill sync command

Restores the ClawHub CLI sync command as a one-way skill publish workflow without install/download telemetry updates.
This commit is contained in:
Patrick Erichsen
2026-06-22 18:45:59 -07:00
committed by GitHub
parent 51d42badcf
commit bbe887faac
10 changed files with 1407 additions and 10 deletions
+25
View File
@@ -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 <dir>` 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 <handle>` 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 <slug>`
- Requires `clawhub login`.
+2
View File
@@ -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
+46
View File
@@ -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 <dir...>", "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 <handle>", "Publish under an org/user publisher handle")
.option("--bump <type>", "Version bump for updates (patch|minor|major)", "patch")
.option("--changelog <text>", "Changelog to use for updates")
.option("--tags <tags>", "Comma-separated tags", "latest")
.option("--concurrency <n>", "Concurrent registry/file checks", (value) =>
Number.parseInt(value, 10),
)
.option("--source-repo <repo>", "GitHub repo URL or owner/name for source provenance")
.option("--source-commit <sha>", "Git commit SHA for source provenance")
.option("--source-ref <ref>", "Git ref for source provenance")
.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:",
+10 -5
View File
@@ -50,6 +50,7 @@ export async function cmdPublish(
sourcePath?: string;
dryRun?: boolean;
json?: boolean;
quiet?: boolean;
},
): Promise<SkillPublishResult> {
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<ReturnType<typeof listTextFiles>>) {
return files.filter((file) => file.relPath.trim().toLowerCase() !== "skill-card.md");
}
@@ -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 = <T>(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<SkillOrigin | null> => 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();
});
});
+497
View File
@@ -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<typeof buildSourceProvenance>,
) {
return {
slug: candidate.slug,
displayName: candidate.displayName,
folder: candidate.folder,
status: candidate.status,
version,
latestVersion: candidate.latestVersion,
fileCount: candidate.fileCount,
fingerprint: candidate.fingerprint,
...(source ? { source } : {}),
};
}
function buildSourceProvenance(
opts: GlobalOpts,
skill: Candidate,
options: SyncOptions,
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<ReturnType<typeof formatPublishJson>>;
published: Array<{ slug: string; folder: string; version: string }>;
failed: Array<{ slug: string; message: string }>;
}) {
const skipped = params.duplicates.map((duplicate) => ({
slug: duplicate.replace(/\s+\(\d+\)$/, ""),
reason: "duplicate-slug",
detail: duplicate,
}));
return {
ok: params.ok,
dryRun: params.dryRun,
registry: params.registry,
roots: params.roots,
...(params.owner ? { owner: params.owner } : {}),
summary: {
wouldPublish: params.wouldPublish.length,
published: params.published.length,
alreadySynced: params.alreadySynced.length,
skipped: skipped.length,
failed: params.failed.length,
},
wouldPublish: params.wouldPublish,
published: params.published,
alreadySynced: params.alreadySynced,
skipped,
failed: params.failed,
};
}
function writeSyncJson(value: unknown) {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
@@ -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<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
) {
const results = Array.from({ length: items.length }) as R[];
let nextIndex = 0;
const workerCount = Math.min(Math.max(1, limit), items.length || 1);
async function worker() {
while (true) {
const index = nextIndex;
nextIndex += 1;
if (index >= items.length) return;
results[index] = await fn(items[index] as T);
}
}
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}
export async function checkRegistrySyncState(
registry: string,
skill: LocalSkill,
ownerHandle?: string,
token?: string,
): Promise<Candidate> {
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<string, SkillFolder[]> = {};
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<string, SkillFolder>();
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<string>();
const unique: string[] = [];
for (const root of roots) {
const resolved = resolve(root);
const canonical = await realpath(resolved).catch(() => null);
const key = canonical ?? resolved;
if (seen.has(key)) continue;
seen.add(key);
unique.push(key);
}
return unique;
}
export async function selectToUpload(
candidates: Candidate[],
params: { allowPrompt: boolean; all: boolean; bump: "patch" | "minor" | "major" },
): Promise<Candidate[]> {
if (params.all || !params.allowPrompt) return candidates;
const valueByKey = new Map<string, Candidate>();
const choices = candidates.map((candidate) => {
const key = candidate.folder;
valueByKey.set(key, candidate);
return {
value: key,
label: `${candidate.slug} ${formatActionableStatus(candidate, params.bump)}`,
hint: `${abbreviatePath(candidate.folder)} | ${candidate.fileCount} files`,
};
});
const picked = await multiselect({
message: "Select skills to 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<string, SkillFolder[]>();
for (const skill of skills) {
const existing = bySlug.get(skill.slug);
if (existing) existing.push(skill);
else bySlug.set(skill.slug, [skill]);
}
const unique: SkillFolder[] = [];
const duplicates: string[] = [];
for (const [slug, entries] of bySlug.entries()) {
unique.push(entries[0] as SkillFolder);
if (entries.length > 1) duplicates.push(`${slug} (${entries.length})`);
}
return { skills: unique, duplicates };
}
function formatActionableStatus(candidate: Candidate, bump: "patch" | "minor" | "major"): string {
if (candidate.status === "new") return "NEW (publish 1.0.0)";
const latest = candidate.latestVersion;
const next = latest ? semver.inc(latest, bump) : null;
if (latest && next) return `LOCAL CHANGES latest ${latest}; publish ${next}`;
return "LOCAL CHANGES";
}
export function formatActionableLine(
candidate: Candidate,
bump: "patch" | "minor" | "major",
): string {
return `${candidate.slug} ${formatActionableStatus(candidate, bump)} (${candidate.fileCount} files)`;
}
function formatSyncedLine(candidate: Candidate): string {
const version = candidate.matchVersion ?? candidate.latestVersion ?? "unknown";
return `${candidate.slug} synced (${version})`;
}
export function formatSyncedSummary(candidate: Candidate): string {
const version = candidate.matchVersion ?? candidate.latestVersion;
return version ? `${candidate.slug}@${version}` : candidate.slug;
}
export function formatBulletList(lines: string[], max: number): string {
if (lines.length <= max) return lines.map((line) => `- ${line}`).join("\n");
const head = lines.slice(0, max);
const rest = lines.length - head.length;
return [...head, `... +${rest} more`].map((line) => `- ${line}`).join("\n");
}
export function formatSyncedDisplay(synced: Candidate[]) {
const lines = synced.map(formatSyncedLine);
if (lines.length <= 12) return formatBulletList(lines, 12);
return formatCommaList(synced.map(formatSyncedSummary), 24);
}
export function formatCommaList(values: string[], max: number) {
if (values.length === 0) return "";
if (values.length <= max) return values.join(", ");
const head = values.slice(0, Math.max(1, max - 1));
const rest = values.length - head.length;
return `${head.join(", ")}, ... +${rest} more`;
}
@@ -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;
};
+65
View File
@@ -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<SkillFolder[]> {
const absRoot = resolve(root);
const rootStat = await stat(absRoot).catch(() => null);
if (!rootStat || !rootStat.isDirectory()) return [];
const direct = await isSkillFolder(absRoot);
if (direct) return [direct];
const entries = await readdir(absRoot, { withFileTypes: true }).catch(() => []);
const folders = entries
.filter((entry) => entry.isDirectory())
.map((entry) => join(absRoot, entry.name));
const results: SkillFolder[] = [];
for (const folder of folders) {
const found = await isSkillFolder(folder);
if (found) results.push(found);
}
return results.sort((a, b) => a.slug.localeCompare(b.slug));
}
export function getFallbackSkillRoots(workdir: string) {
const home = resolveHome();
const roots = [
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<SkillFolder | null> {
const marker = await findSkillMarker(folder);
if (!marker) return null;
const base = basename(folder);
const slug = sanitizeSlug(base);
if (!slug) return null;
const displayName = titleCase(base);
return { folder, slug, displayName };
}
async function findSkillMarker(folder: string) {
const candidates = ["SKILL.md", "skill.md"];
for (const name of candidates) {
const path = join(folder, name);
const st = await stat(path).catch(() => null);
if (st?.isFile()) return path;
}
return null;
}
@@ -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 () => {