mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
+25
@@ -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`.
|
||||
|
||||
@@ -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,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:",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user