Skip to content

Commit 9bf44b3

Browse files
feat: support forcing pending ClawHub installs
1 parent 5dd5075 commit 9bf44b3

5 files changed

Lines changed: 140 additions & 1 deletion

File tree

src/cli/skills-cli.commands.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,25 @@ describe("skills cli commands", () => {
566566
);
567567
});
568568

569+
it("passes --force-install through for ClawHub skill installs", async () => {
570+
installSkillFromClawHubMock.mockResolvedValue({
571+
ok: true,
572+
slug: "calendar",
573+
version: "1.2.3",
574+
targetDir: "/tmp/workspace/skills/calendar",
575+
});
576+
577+
await runCommand(["skills", "install", "calendar", "--force-install"]);
578+
579+
expect(installSkillFromClawHubMock).toHaveBeenCalledWith(
580+
expect.objectContaining({
581+
workspaceDir: "/tmp/workspace",
582+
slug: "calendar",
583+
forceInstall: true,
584+
}),
585+
);
586+
});
587+
569588
it("rejects using --global and --agent together for installs", async () => {
570589
await expect(
571590
runCommand(["skills", "install", "calendar", "--global", "--agent", "main"]),
@@ -613,6 +632,30 @@ describe("skills cli commands", () => {
613632
expect(runtimeErrors).toStrictEqual([]);
614633
});
615634

635+
it("passes --force-install through for ClawHub skill updates", async () => {
636+
readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]);
637+
updateSkillsFromClawHubMock.mockResolvedValue([
638+
{
639+
ok: true,
640+
slug: "calendar",
641+
previousVersion: "1.2.2",
642+
version: "1.2.3",
643+
changed: true,
644+
targetDir: "/tmp/workspace/skills/calendar",
645+
},
646+
]);
647+
648+
await runCommand(["skills", "update", "--all", "--force-install"]);
649+
650+
expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith(
651+
expect.objectContaining({
652+
workspaceDir: "/tmp/workspace",
653+
slug: undefined,
654+
forceInstall: true,
655+
}),
656+
);
657+
});
658+
616659
it("updates tracked ClawHub skills in the cwd-inferred agent workspace", async () => {
617660
routeWorkspaceByAgent();
618661
resolveAgentIdByWorkspacePathMock.mockReturnValue("writer");

src/cli/skills-cli.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,11 @@ export function registerSkillsCli(program: Command) {
287287
.argument("<slug>", "ClawHub skill slug, git:<repo>, or local skill directory")
288288
.option("--version <version>", "Install a specific version")
289289
.option("--force", "Overwrite an existing workspace skill", false)
290+
.option(
291+
"--force-install",
292+
"Install a pending GitHub-backed skill before ClawHub scan completes",
293+
false,
294+
)
290295
.option("--global", "Install into the shared managed skills directory", false)
291296
.option("--agent <id>", "Target agent workspace (defaults to cwd-inferred, then default agent)")
292297
.option("--as <slug>", "Install a git/local skill under this slug")
@@ -296,6 +301,7 @@ export function registerSkillsCli(program: Command) {
296301
opts: {
297302
version?: string;
298303
force?: boolean;
304+
forceInstall?: boolean;
299305
global?: boolean;
300306
agent?: string;
301307
as?: string;
@@ -345,6 +351,7 @@ export function registerSkillsCli(program: Command) {
345351
slug,
346352
version: opts.version,
347353
force: Boolean(opts.force),
354+
...(opts.forceInstall ? { forceInstall: true } : {}),
348355
logger: {
349356
info: (message) => defaultRuntime.log(message),
350357
},
@@ -367,12 +374,17 @@ export function registerSkillsCli(program: Command) {
367374
.description("Update ClawHub-installed skills in the active or shared managed directory")
368375
.argument("[slug]", "Single skill slug")
369376
.option("--all", "Update all tracked ClawHub skills", false)
377+
.option(
378+
"--force-install",
379+
"Install a pending GitHub-backed skill before ClawHub scan completes",
380+
false,
381+
)
370382
.option("--global", "Update skills in the shared managed skills directory", false)
371383
.option("--agent <id>", "Target agent workspace (defaults to cwd-inferred, then default agent)")
372384
.action(
373385
async (
374386
slug: string | undefined,
375-
opts: { all?: boolean; global?: boolean; agent?: string },
387+
opts: { all?: boolean; forceInstall?: boolean; global?: boolean; agent?: string },
376388
command: Command,
377389
) => {
378390
try {
@@ -398,6 +410,7 @@ export function registerSkillsCli(program: Command) {
398410
const results = await updateSkillsFromClawHub({
399411
workspaceDir,
400412
slug,
413+
...(opts.forceInstall ? { forceInstall: true } : {}),
401414
logger: {
402415
info: (message) => defaultRuntime.log(message),
403416
},

src/infra/clawhub.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,13 +1066,17 @@ export async function fetchClawHubSkillInstallResolution(params: {
10661066
token?: string;
10671067
timeoutMs?: number;
10681068
fetchImpl?: FetchLike;
1069+
forceInstall?: boolean;
10691070
}): Promise<ClawHubSkillInstallResolutionResponse> {
10701071
const { response, url, hasToken } = await clawhubRequest({
10711072
baseUrl: params.baseUrl,
10721073
path: `/api/v1/skills/${encodeURIComponent(params.slug)}/install`,
10731074
token: params.token,
10741075
timeoutMs: params.timeoutMs,
10751076
fetchImpl: params.fetchImpl,
1077+
search: {
1078+
forceInstall: params.forceInstall ? "1" : undefined,
1079+
},
10761080
});
10771081
const isStructuredBlock = [403, 409, 410, 423].includes(response.status);
10781082
if (!response.ok && !isStructuredBlock) {

src/skills/lifecycle/clawhub.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,46 @@ describe("skills-clawhub", () => {
325325
});
326326
});
327327

328+
it("passes forceInstall to the ClawHub install resolver", async () => {
329+
const commit = "b".repeat(40);
330+
fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({
331+
ok: true,
332+
slug: "aiq-deploy",
333+
installKind: "github",
334+
github: {
335+
repo: "NVIDIA/skills",
336+
path: "skills/aiq-deploy",
337+
commit,
338+
contentHash: "hash-aiq-deploy",
339+
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
340+
},
341+
});
342+
withExtractedArchiveRootMock.mockImplementationOnce(async (params) => {
343+
return await params.onExtracted("/tmp/extracted-github-repo");
344+
});
345+
installPackageDirMock.mockResolvedValueOnce({
346+
ok: true,
347+
targetDir: "/tmp/workspace/skills/aiq-deploy",
348+
});
349+
350+
const result = await installSkillFromClawHub({
351+
workspaceDir: "/tmp/workspace",
352+
slug: "aiq-deploy",
353+
forceInstall: true,
354+
});
355+
356+
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
357+
slug: "aiq-deploy",
358+
baseUrl: undefined,
359+
forceInstall: true,
360+
});
361+
expectInstalledSkill(result, {
362+
slug: "aiq-deploy",
363+
version: commit,
364+
targetDir: "/tmp/workspace/skills/aiq-deploy",
365+
});
366+
});
367+
328368
it("keeps ClawHub install telemetry best-effort", async () => {
329369
reportClawHubSkillInstallTelemetryMock.mockRejectedValueOnce(new Error("telemetry down"));
330370

@@ -458,6 +498,40 @@ describe("skills-clawhub", () => {
458498
}
459499
});
460500

501+
it("passes forceInstall to resolver for tracked updates", async () => {
502+
const slug = "agentreceipt";
503+
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);
504+
fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({
505+
ok: true,
506+
slug,
507+
installKind: "archive",
508+
archive: {
509+
version: "1.0.0",
510+
downloadUrl: `https://legacy.clawhub.ai/api/v1/download?slug=${encodeURIComponent(slug)}&version=1.0.0`,
511+
},
512+
});
513+
installPackageDirMock.mockResolvedValueOnce({
514+
ok: true,
515+
targetDir: path.join(workspaceDir, "skills", slug),
516+
});
517+
518+
try {
519+
const results = await updateSkillsFromClawHub({
520+
workspaceDir,
521+
forceInstall: true,
522+
});
523+
524+
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
525+
slug,
526+
baseUrl: "https://legacy.clawhub.ai",
527+
forceInstall: true,
528+
});
529+
expectLegacyUpdateSuccess(results, workspaceDir, slug);
530+
} finally {
531+
await fs.rm(workspaceDir, { recursive: true, force: true });
532+
}
533+
});
534+
461535
it("updates a legacy Unicode slug when requested explicitly", async () => {
462536
const slug = "re\u0430ct";
463537
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);

src/skills/lifecycle/clawhub.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ type ClawHubInstallParams = {
137137
version?: string;
138138
baseUrl?: string;
139139
force?: boolean;
140+
forceInstall?: boolean;
140141
logger?: Logger;
141142
config?: OpenClawConfig;
142143
};
@@ -916,6 +917,7 @@ async function performClawHubSkillInstall(
916917
await fetchClawHubSkillInstallResolution({
917918
slug: params.slug,
918919
baseUrl: params.baseUrl,
920+
forceInstall: params.forceInstall,
919921
}),
920922
);
921923
if (latestResolution.installKind === "github") {
@@ -1080,6 +1082,7 @@ export async function installSkillFromClawHub(params: {
10801082
version?: string;
10811083
baseUrl?: string;
10821084
force?: boolean;
1085+
forceInstall?: boolean;
10831086
logger?: Logger;
10841087
config?: OpenClawConfig;
10851088
}): Promise<InstallClawHubSkillResult> {
@@ -1090,6 +1093,7 @@ export async function updateSkillsFromClawHub(params: {
10901093
workspaceDir: string;
10911094
slug?: string;
10921095
baseUrl?: string;
1096+
forceInstall?: boolean;
10931097
logger?: Logger;
10941098
config?: OpenClawConfig;
10951099
}): Promise<UpdateClawHubSkillResult[]> {
@@ -1123,6 +1127,7 @@ export async function updateSkillsFromClawHub(params: {
11231127
slug: tracked.slug,
11241128
baseUrl: tracked.baseUrl,
11251129
force: true,
1130+
forceInstall: params.forceInstall,
11261131
logger: params.logger,
11271132
config: params.config,
11281133
});

0 commit comments

Comments
 (0)