feat: add native clawhub install flows

This commit is contained in:
Peter Steinberger
2026-03-22 17:03:32 +00:00
parent c7788773bf
commit 91b2800241
25 changed files with 2471 additions and 208 deletions

View File

@@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.fn(() => ({}));
const resolveDefaultAgentIdMock = vi.fn(() => "main");
const resolveAgentWorkspaceDirMock = vi.fn(() => "/tmp/workspace");
const installSkillFromClawHubMock = vi.fn();
const updateSkillsFromClawHubMock = vi.fn();
vi.mock("../../config/config.js", () => ({
loadConfig: () => loadConfigMock(),
writeConfigFile: vi.fn(),
}));
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: vi.fn(() => ["main"]),
resolveDefaultAgentId: () => resolveDefaultAgentIdMock(),
resolveAgentWorkspaceDir: () => resolveAgentWorkspaceDirMock(),
}));
vi.mock("../../agents/skills-clawhub.js", () => ({
installSkillFromClawHub: (...args: unknown[]) => installSkillFromClawHubMock(...args),
updateSkillsFromClawHub: (...args: unknown[]) => updateSkillsFromClawHubMock(...args),
}));
const { skillsHandlers } = await import("./skills.js");
describe("skills gateway handlers (clawhub)", () => {
beforeEach(() => {
loadConfigMock.mockReset();
resolveDefaultAgentIdMock.mockReset();
resolveAgentWorkspaceDirMock.mockReset();
installSkillFromClawHubMock.mockReset();
updateSkillsFromClawHubMock.mockReset();
loadConfigMock.mockReturnValue({});
resolveDefaultAgentIdMock.mockReturnValue("main");
resolveAgentWorkspaceDirMock.mockReturnValue("/tmp/workspace");
});
it("installs a ClawHub skill through skills.install", async () => {
installSkillFromClawHubMock.mockResolvedValue({
ok: true,
slug: "calendar",
version: "1.2.3",
targetDir: "/tmp/workspace/skills/calendar",
});
let ok: boolean | null = null;
let response: unknown;
let error: unknown;
await skillsHandlers["skills.install"]({
params: {
source: "clawhub",
slug: "calendar",
version: "1.2.3",
},
req: {} as never,
client: null as never,
isWebchatConnect: () => false,
context: {} as never,
respond: (success, result, err) => {
ok = success;
response = result;
error = err;
},
});
expect(installSkillFromClawHubMock).toHaveBeenCalledWith({
workspaceDir: "/tmp/workspace",
slug: "calendar",
version: "1.2.3",
force: false,
});
expect(ok).toBe(true);
expect(error).toBeUndefined();
expect(response).toMatchObject({
ok: true,
message: "Installed calendar@1.2.3",
slug: "calendar",
version: "1.2.3",
});
});
it("updates ClawHub skills through skills.update", async () => {
updateSkillsFromClawHubMock.mockResolvedValue([
{
ok: true,
slug: "calendar",
previousVersion: "1.2.2",
version: "1.2.3",
changed: true,
targetDir: "/tmp/workspace/skills/calendar",
},
]);
let ok: boolean | null = null;
let response: unknown;
let error: unknown;
await skillsHandlers["skills.update"]({
params: {
source: "clawhub",
slug: "calendar",
},
req: {} as never,
client: null as never,
isWebchatConnect: () => false,
context: {} as never,
respond: (success, result, err) => {
ok = success;
response = result;
error = err;
},
});
expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith({
workspaceDir: "/tmp/workspace",
slug: "calendar",
});
expect(ok).toBe(true);
expect(error).toBeUndefined();
expect(response).toMatchObject({
ok: true,
skillKey: "calendar",
config: {
source: "clawhub",
results: [
{
ok: true,
slug: "calendar",
version: "1.2.3",
},
],
},
});
});
it("rejects ClawHub skills.update requests without slug or all", async () => {
let ok: boolean | null = null;
let error: { code?: string; message?: string } | undefined;
await skillsHandlers["skills.update"]({
params: {
source: "clawhub",
},
req: {} as never,
client: null as never,
isWebchatConnect: () => false,
context: {} as never,
respond: (success, _result, err) => {
ok = success;
error = err as { code?: string; message?: string } | undefined;
},
});
expect(ok).toBe(false);
expect(error?.message).toContain('requires "slug" or "all"');
expect(updateSkillsFromClawHubMock).not.toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,7 @@ import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { installSkillFromClawHub, updateSkillsFromClawHub } from "../../agents/skills-clawhub.js";
import { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
@@ -123,13 +124,44 @@ export const skillsHandlers: GatewayRequestHandlers = {
);
return;
}
const cfg = loadConfig();
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
if (params && typeof params === "object" && "source" in params && params.source === "clawhub") {
const p = params as {
source: "clawhub";
slug: string;
version?: string;
force?: boolean;
};
const result = await installSkillFromClawHub({
workspaceDir: workspaceDirRaw,
slug: p.slug,
version: p.version,
force: Boolean(p.force),
});
respond(
result.ok,
result.ok
? {
ok: true,
message: `Installed ${result.slug}@${result.version}`,
stdout: "",
stderr: "",
code: 0,
slug: result.slug,
version: result.version,
targetDir: result.targetDir,
}
: result,
result.ok ? undefined : errorShape(ErrorCodes.UNAVAILABLE, result.error),
);
return;
}
const p = params as {
name: string;
installId: string;
timeoutMs?: number;
};
const cfg = loadConfig();
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const result = await installSkill({
workspaceDir: workspaceDirRaw,
skillName: p.name,
@@ -155,6 +187,54 @@ export const skillsHandlers: GatewayRequestHandlers = {
);
return;
}
if (params && typeof params === "object" && "source" in params && params.source === "clawhub") {
const p = params as {
source: "clawhub";
slug?: string;
all?: boolean;
};
if (!p.slug && !p.all) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, 'clawhub skills.update requires "slug" or "all"'),
);
return;
}
if (p.slug && p.all) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
'clawhub skills.update accepts either "slug" or "all", not both',
),
);
return;
}
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const results = await updateSkillsFromClawHub({
workspaceDir,
slug: p.slug,
});
const errors = results.filter((result) => !result.ok);
respond(
errors.length === 0,
{
ok: errors.length === 0,
skillKey: p.slug ?? "*",
config: {
source: "clawhub",
results,
},
},
errors.length === 0
? undefined
: errorShape(ErrorCodes.UNAVAILABLE, errors.map((result) => result.error).join("; ")),
);
return;
}
const p = params as {
skillKey: string;
enabled?: boolean;