diff --git a/docs/clawhub/cli.md b/docs/clawhub/cli.md index 189d570c87a8..3598ffcedadc 100644 --- a/docs/clawhub/cli.md +++ b/docs/clawhub/cli.md @@ -1,8 +1,9 @@ --- -summary: "ClawHub CLI entry points for discovering, installing, publishing, and verifying OpenClaw skills and plugins." +summary: "ClawHub CLI entry points for discovering, installing, removing, publishing, and verifying OpenClaw skills and plugins." read_when: - You want to use ClawHub from the command line - You want to install ClawHub skills or plugins through OpenClaw + - You need to remove an installed ClawHub skill - You want to publish ClawHub packages title: "ClawHub CLI" --- @@ -13,8 +14,8 @@ Two command-line surfaces talk to ClawHub: - `openclaw skills` / `openclaw plugins` - discover, install, and update packages for a local OpenClaw agent or Gateway. -- The standalone `clawhub` CLI - publisher workflows: login, publish, sync, - and transfer. +- The standalone `clawhub` CLI - remove installed skills and handle publisher + workflows including login, publish, sync, and transfer. ## Discover and install @@ -60,6 +61,28 @@ enforces scan and force-install policy before returning a pinned commit. - **Official ClawHub publishers/packages and bundled OpenClaw sources** skip the trust prompt and security-verdict fetch entirely. +## Remove an installed skill + +If the standalone ClawHub CLI is not already installed, install it explicitly: + +```bash +npm i -g clawhub +clawhub uninstall @owner/my-skill +``` + +The command asks for confirmation, then removes the installed skill directory +and its ClawHub lockfile entry. Select the original agent workspace or shared +OpenClaw state directory when the installation is outside the current workdir: + +```bash +clawhub --workdir /path/to/agent-workspace uninstall @owner/my-skill +clawhub --workdir ~/.openclaw uninstall @owner/my-skill +``` + +For a custom `OPENCLAW_STATE_DIR`, replace `~/.openclaw` with that configured +directory. See [Remove a ClawHub skill](/cli/skills#remove-a-clawhub-skill) for +workspace targeting and skill refresh behavior. + ## Publish and maintain Install the standalone CLI once, then log in: diff --git a/docs/cli/skills.md b/docs/cli/skills.md index a219703e6214..48951ed9e865 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -3,6 +3,7 @@ summary: "CLI reference for `openclaw skills` (search/install/update/verify/list read_when: - You want to see which skills are available and ready to run - You want to search ClawHub or install skills from ClawHub, Git, or local directories + - You need to remove an installed ClawHub skill - You want to verify a ClawHub skill with ClawHub - You want to debug missing binaries/env/config for skills title: "Skills" @@ -134,6 +135,39 @@ non-interactive command should continue after that review. Official ClawHub skill publishers and bundled OpenClaw skill sources bypass this release-trust prompt. +## Remove a ClawHub skill + +Use the standalone ClawHub CLI to remove a ClawHub-tracked skill. If the CLI +is not installed, install it explicitly first: + +```bash +npm i -g clawhub +clawhub uninstall @owner/my-skill +``` + +The CLI asks for confirmation before deleting the skill directory and its +`.clawhub/lock.json` entry. Use the installed skill's owner-qualified name or +bare slug, not its original `skills-sh:` reference. + +Select the same root where the skill was installed: the agent workspace for an +agent-specific skill, or the OpenClaw state directory for a shared skill +installed with `--global`: + +```bash +clawhub --workdir /path/to/agent-workspace uninstall @owner/my-skill +clawhub --workdir ~/.openclaw uninstall @owner/my-skill +``` + +If `OPENCLAW_STATE_DIR` is set, use that configured state directory for shared +skills instead: + +```bash +clawhub --workdir "$OPENCLAW_STATE_DIR" uninstall @owner/my-skill +``` + +The default [skills watcher](/tools/skills#snapshots-and-refresh) picks up the +removal on the next agent turn. If watching is disabled, start a new session. + ## Skill Workshop `openclaw skills workshop` manages pending skill proposals in the selected diff --git a/skills/clawhub/SKILL.md b/skills/clawhub/SKILL.md index dbe7891b647a..a395cb99e0c0 100644 --- a/skills/clawhub/SKILL.md +++ b/skills/clawhub/SKILL.md @@ -1,13 +1,13 @@ --- name: clawhub -description: "Search ClawHub for skills when a requested capability is not already available; install, verify, update, publish, or sync skills." +description: "Search ClawHub for skills when a requested capability is not already available; install, verify, update, uninstall, publish, or sync skills." --- # ClawHub Use `openclaw skills` to discover and manage skills for the current OpenClaw -agent. Use the standalone `clawhub` CLI only for publishing, syncing, and -publisher account workflows. +agent. Use the standalone `clawhub` CLI to uninstall installed ClawHub skills +and for publishing, syncing, and publisher account workflows. ## Discover skills @@ -38,6 +38,34 @@ openclaw skills update --all Use `--global` with `install` or `update` to manage skills shared by all local agents. +## Remove an installed skill + +Obtain user approval before uninstalling. If the standalone ClawHub CLI is not +installed, install it explicitly: + +```bash +npm i -g clawhub +clawhub uninstall @owner/my-skill +``` + +The CLI asks for confirmation before removing the skill and its lockfile entry. +Use the original agent workspace for agent-specific skills or the OpenClaw +state directory for skills installed with `--global`: + +```bash +clawhub --workdir /path/to/agent-workspace uninstall @owner/my-skill +clawhub --workdir ~/.openclaw uninstall @owner/my-skill +``` + +If `OPENCLAW_STATE_DIR` is set, use its value instead of `~/.openclaw`: + +```bash +clawhub --workdir "$OPENCLAW_STATE_DIR" uninstall @owner/my-skill +``` + +The default skills watcher refreshes the available skills on the next agent +turn. If watching is disabled, start a new session. + ## Publish skills Install the standalone ClawHub CLI for publisher workflows: diff --git a/src/infra/clawhub-install-trust.ts b/src/infra/clawhub-install-trust.ts index 856c5db02a30..eaabd19cfaec 100644 --- a/src/infra/clawhub-install-trust.ts +++ b/src/infra/clawhub-install-trust.ts @@ -4,6 +4,7 @@ import { stripAnsi, visibleWidth } from "../../packages/terminal-core/src/ansi.j import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { formatTerminalLink } from "../../packages/terminal-core/src/terminal-link.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; +import { quoteCliArg } from "../cli/quote-cli-arg.js"; import { fetchClawHubPackageSecurity, fetchClawHubSkillVerification, @@ -63,11 +64,9 @@ type ClawHubInstallLogger = { terminalLinks?: boolean; }; -type ClawHubTrustSubject = { - kind: "plugin" | "skill"; - packageName: string; - ownerHandle?: string; -}; +type ClawHubTrustSubject = + | { kind: "plugin"; packageName: string } + | { kind: "skill"; packageName: string; workspaceDir: string; ownerHandle?: string }; type ClawHubSkillSecurityLinks = { subject: string; @@ -536,6 +535,15 @@ function formatClawHubTrustWarning(params: { params.assessment.disposition, ), formatClawHubRawLinks({ subject: params.subject, links }), + ...(params.subject.kind === "skill" && malicious && params.mode === "update" + ? [ + `Remove installed skill: clawhub --workdir ${quoteCliArg( + sanitizeTerminalText(params.subject.workspaceDir), + )} uninstall ${quoteCliArg( + sanitizeTerminalText(formatClawHubSubjectPackageName(params.subject)), + )}`, + ] + : []), ].join("\n"); } if (params.assessment.disposition === "review-required") { diff --git a/src/skills/lifecycle/clawhub-install-core.ts b/src/skills/lifecycle/clawhub-install-core.ts index 0dfc3f02ba13..04366c6f6c70 100644 --- a/src/skills/lifecycle/clawhub-install-core.ts +++ b/src/skills/lifecycle/clawhub-install-core.ts @@ -430,6 +430,7 @@ export async function ensureClawHubSkillTrustAcknowledged( subject: { kind: "skill", packageName: params.slug, + workspaceDir: params.workspaceDir, ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), }, version: params.version, diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index ebcde1d5f375..e8afc5e4c9e1 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -1,4 +1,5 @@ // ClawHub lifecycle tests cover registry metadata lookup and error handling. +import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; @@ -1935,58 +1936,135 @@ describe("skills-clawhub", () => { }); }); - it("explains that a malicious skill update will not be downloaded", async () => { - const workspaceDir = await tempDirs.make("openclaw-skill-malicious-update-"); - const warnings: string[] = []; - await writeClawHubOriginFixture({ - workspaceDir, - slug: "agentreceipt", - installedVersion: "0.9.0", - }); - fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ - schema: "clawhub.skill.security-verdicts.v1", - items: [ - { - ok: false, - decision: "fail", - reasons: ["scan:malicious"], - requestedSlug: "agentreceipt", - requestedVersion: "1.0.0", - slug: "agentreceipt", - version: "1.0.0", - security: { - status: "malicious", - passed: false, + it.each([ + { + ownerHandle: undefined, + skillRef: "agentreceipt", + shellArg: "agentreceipt", + workspaceName: "agent-workspace", + }, + { + ownerHandle: "acme", + skillRef: "@acme/agentreceipt", + shellArg: "@acme/agentreceipt", + workspaceName: "different agent workspace", + }, + { + ownerHandle: undefined, + skillRef: "agentreceipt", + shellArg: "agentreceipt", + workspaceName: ".openclaw", + }, + { + ownerHandle: undefined, + skillRef: "a;printf X", + shellArg: "'a;printf X'", + workspaceName: "shared state;printf PWN", + }, + { + ownerHandle: undefined, + skillRef: "b$(printf X)", + shellArg: "'b$(printf X)'", + workspaceName: "agent$(printf PWN)", + }, + { + ownerHandle: undefined, + skillRef: "c`printf X`", + shellArg: "'c`printf X`'", + workspaceName: "agent`printf PWN`", + }, + { + ownerHandle: undefined, + skillRef: "white space", + shellArg: "'white space'", + workspaceName: "custom state directory", + }, + { + ownerHandle: undefined, + skillRef: "d'$(printf X)", + shellArg: "'d'\\''$(printf X)'", + workspaceName: "agent's $(printf PWN) state", + }, + ])( + "explains that a malicious skill update will not be downloaded ($skillRef)", + async ({ ownerHandle, skillRef, shellArg, workspaceName }) => { + const tempRoot = await tempDirs.make("openclaw-skill-malicious-update-"); + const workspaceDir = path.join(tempRoot, workspaceName); + const warnings: string[] = []; + const slug = ownerHandle ? skillRef.slice(skillRef.indexOf("/") + 1) : skillRef; + await writeClawHubOriginFixture({ + workspaceDir, + slug, + ownerHandle, + installedVersion: "0.9.0", + }); + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["scan:malicious"], + requestedSlug: slug, + requestedVersion: "1.0.0", + slug, + version: "1.0.0", + ...(ownerHandle ? { publisherHandle: ownerHandle } : {}), + security: { + status: "malicious", + passed: false, + }, }, + ], + }); + + const results = await updateSkillsFromClawHub({ + workspaceDir, + slug: skillRef, + logger: { + warn: (message) => warnings.push(message), }, - ], - }); + }); - const results = await updateSkillsFromClawHub({ - workspaceDir, - slug: "agentreceipt", - logger: { - warn: (message) => warnings.push(message), - }, - }); + expect(results).toEqual([ + expect.objectContaining({ + ok: false, + code: "clawhub_download_blocked", + error: "ClawHub blocked this release; update was not started.", + }), + ]); + expect(warnings.join("\n")).toContain( + "Latest skill version is marked malicious; OpenClaw will not download it.", + ); + const workspaceArg = /^[A-Za-z0-9_/:=.,@%+-]+$/.test(workspaceDir) + ? workspaceDir + : `'${workspaceDir.replaceAll("'", "'\\''")}'`; + const uninstallCommand = `clawhub --workdir ${workspaceArg} uninstall ${shellArg}`; + const actionLine = expectDefined( + warnings + .join("\n") + .split("\n") + .find((line) => line.startsWith("Remove installed skill: ")), + "malicious skill warning remediation", + ); + expect(actionLine).toBe(`Remove installed skill: ${uninstallCommand}`); + expect(warnings.join("\n")).toContain("independently reviewed it."); + expect(warnings.join("\n")).not.toContain("Choose a different version"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); - expect(results).toEqual([ - expect.objectContaining({ - ok: false, - code: "clawhub_download_blocked", - error: "ClawHub blocked this release; update was not started.", - }), - ]); - expect(warnings.join("\n")).toContain( - "Latest skill version is marked malicious; OpenClaw will not download it.", - ); - expect(warnings.join("\n")).toContain( - "Uninstall the installed skill unless you have independently reviewed it.", - ); - expect(warnings.join("\n")).not.toContain("Choose a different version"); - expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); - expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); - }); + const shellResult = spawnSync( + "sh", + [ + "-c", + `clawhub() { printf '%s\\n' "$@"; }\n${actionLine.slice("Remove installed skill: ".length)}`, + ], + { encoding: "utf8" }, + ); + expect(shellResult.status).toBe(0); + expect(shellResult.stdout).toBe(`--workdir\n${workspaceDir}\nuninstall\n${skillRef}\n`); + }, + ); it("updates owner-qualified ClawHub skills when the requested owner matches tracking", async () => { const workspaceDir = await tempDirs.make("openclaw-owner-update-request-");