diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c174ec110..9e4f44ba6fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Memory/LanceDB: bound memory recall embedding queries with a new `recallMaxChars` setting, prefer the latest user message over channel prompt metadata during auto-recall, and document the knob so small Ollama embedding models avoid context-length failures. Fixes #56780. Thanks @rungmc357 and @zak-collaborator. +- CLI/skills: resolve workspace-backed skills commands from `--agent`, then the current agent workspace, before falling back to the default agent, so multi-agent ClawHub installs, updates, and status checks stay scoped to the active workspace. Fixes #56161; carries forward #72726. Thanks @langbowang and @luyao618. - Plugin SDK: fall back from partial bundled plugin directory overrides to package source public surfaces while preserving `OPENCLAW_DISABLE_BUNDLED_PLUGINS` as a hard disable. (#72817) Thanks @serkonyc. - Agents/ACPX: stop forwarding Codex ACP timeout config controls that Codex rejects while preserving OpenClaw's run-timeout watchdog for ACP subagents. Fixes #73052. Thanks @pfrederiksen and @richa65. - Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys. diff --git a/docs/cli/skills.md b/docs/cli/skills.md index 88ba29bfa94..7d279cf522f 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -25,21 +25,29 @@ openclaw skills search --limit 20 --json openclaw skills install openclaw skills install --version openclaw skills install --force +openclaw skills install --agent openclaw skills update openclaw skills update --all +openclaw skills update --all --agent openclaw skills list openclaw skills list --eligible openclaw skills list --json openclaw skills list --verbose +openclaw skills list --agent openclaw skills info openclaw skills info --json +openclaw skills info --agent openclaw skills check openclaw skills check --json +openclaw skills check --agent ``` `search`/`install`/`update` use ClawHub directly and install into the active workspace `skills/` directory. `list`/`info`/`check` still inspect the local -skills visible to the current workspace and config. +skills visible to the current workspace and config. Workspace-backed commands +resolve the target workspace from `--agent `, then the current working +directory when it is inside a configured agent workspace, then the default +agent. This CLI `install` command downloads skill folders from ClawHub. Gateway-backed skill dependency installs triggered from onboarding or Skills settings use the @@ -52,6 +60,8 @@ Notes: - `search --limit ` caps returned results. - `install --force` overwrites an existing workspace skill folder for the same slug. +- `--agent ` targets one configured agent workspace and overrides current + working directory inference. - `update --all` only updates tracked ClawHub installs in the active workspace. - `list` is the default action when no subcommand is provided. - `list`, `info`, and `check` write their rendered output to stdout. With diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 13f2aa54db9..b1792f29293 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -69,8 +69,11 @@ const mocks = vi.hoisted(() => { }); return { loadConfigMock: vi.fn(() => ({})), - resolveDefaultAgentIdMock: vi.fn(() => "main"), - resolveAgentWorkspaceDirMock: vi.fn(() => "/tmp/workspace"), + resolveDefaultAgentIdMock: vi.fn((_config: unknown) => "main"), + resolveAgentIdByWorkspacePathMock: vi.fn( + (_config: unknown, _workspacePath: string): string | undefined => undefined, + ), + resolveAgentWorkspaceDirMock: vi.fn((_config: unknown, _agentId: string) => "/tmp/workspace"), searchSkillsFromClawHubMock: vi.fn(), installSkillFromClawHubMock: vi.fn(), updateSkillsFromClawHubMock: vi.fn(), @@ -87,6 +90,7 @@ const mocks = vi.hoisted(() => { const { loadConfigMock, resolveDefaultAgentIdMock, + resolveAgentIdByWorkspacePathMock, resolveAgentWorkspaceDirMock, searchSkillsFromClawHubMock, installSkillFromClawHubMock, @@ -110,8 +114,11 @@ vi.mock("../config/config.js", () => ({ })); vi.mock("../agents/agent-scope.js", () => ({ - resolveDefaultAgentId: () => mocks.resolveDefaultAgentIdMock(), - resolveAgentWorkspaceDir: () => mocks.resolveAgentWorkspaceDirMock(), + resolveAgentIdByWorkspacePath: (config: unknown, workspacePath: string) => + mocks.resolveAgentIdByWorkspacePathMock(config, workspacePath), + resolveDefaultAgentId: (config: unknown) => mocks.resolveDefaultAgentIdMock(config), + resolveAgentWorkspaceDir: (config: unknown, agentId: string) => + mocks.resolveAgentWorkspaceDirMock(config, agentId), })); vi.mock("../agents/skills-clawhub.js", () => ({ @@ -143,6 +150,7 @@ describe("skills cli commands", () => { runtimeErrors.length = 0; loadConfigMock.mockReset(); resolveDefaultAgentIdMock.mockReset(); + resolveAgentIdByWorkspacePathMock.mockReset(); resolveAgentWorkspaceDirMock.mockReset(); searchSkillsFromClawHubMock.mockReset(); installSkillFromClawHubMock.mockReset(); @@ -152,6 +160,7 @@ describe("skills cli commands", () => { loadConfigMock.mockReturnValue({}); resolveDefaultAgentIdMock.mockReturnValue("main"); + resolveAgentIdByWorkspacePathMock.mockReturnValue(undefined); resolveAgentWorkspaceDirMock.mockReturnValue("/tmp/workspace"); searchSkillsFromClawHubMock.mockResolvedValue([]); installSkillFromClawHubMock.mockResolvedValue({ @@ -168,6 +177,21 @@ describe("skills cli commands", () => { defaultRuntime.exit.mockClear(); }); + async function withCwd(cwd: string, run: () => Promise) { + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd); + try { + await run(); + } finally { + cwdSpy.mockRestore(); + } + } + + function routeWorkspaceByAgent() { + resolveAgentWorkspaceDirMock.mockImplementation( + (_config: unknown, agentId: string) => `/tmp/workspace-${agentId}`, + ); + } + it("searches ClawHub skills from the native CLI", async () => { searchSkillsFromClawHubMock.mockResolvedValue([ { @@ -211,6 +235,73 @@ describe("skills cli commands", () => { ).toBe(true); }); + it("installs a skill into the cwd-inferred agent workspace", async () => { + routeWorkspaceByAgent(); + resolveAgentIdByWorkspacePathMock.mockReturnValue("writer"); + installSkillFromClawHubMock.mockResolvedValue({ + ok: true, + slug: "calendar", + version: "1.2.3", + targetDir: "/tmp/workspace-writer/skills/calendar", + }); + + await withCwd("/tmp/workspace-writer/project", async () => { + await runCommand(["skills", "install", "calendar"]); + }); + + expect(resolveAgentIdByWorkspacePathMock).toHaveBeenCalledWith( + {}, + "/tmp/workspace-writer/project", + ); + expect(installSkillFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/tmp/workspace-writer", + }), + ); + }); + + it("lets --agent override cwd-inferred workspace for installs", async () => { + routeWorkspaceByAgent(); + resolveAgentIdByWorkspacePathMock.mockReturnValue("writer"); + installSkillFromClawHubMock.mockResolvedValue({ + ok: true, + slug: "calendar", + version: "1.2.3", + targetDir: "/tmp/workspace-main/skills/calendar", + }); + + await withCwd("/tmp/workspace-writer", async () => { + await runCommand(["skills", "install", "calendar", "--agent", "main"]); + }); + + expect(resolveAgentIdByWorkspacePathMock).not.toHaveBeenCalled(); + expect(resolveAgentWorkspaceDirMock).toHaveBeenCalledWith({}, "main"); + expect(installSkillFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/tmp/workspace-main", + }), + ); + }); + + it("honors parent --agent for subcommands", async () => { + routeWorkspaceByAgent(); + installSkillFromClawHubMock.mockResolvedValue({ + ok: true, + slug: "calendar", + version: "1.2.3", + targetDir: "/tmp/workspace-writer/skills/calendar", + }); + + await runCommand(["skills", "--agent", "writer", "install", "calendar"]); + + expect(resolveAgentWorkspaceDirMock).toHaveBeenCalledWith({}, "writer"); + expect(installSkillFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/tmp/workspace-writer", + }), + ); + }); + it("updates all tracked ClawHub skills", async () => { readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]); updateSkillsFromClawHubMock.mockResolvedValue([ @@ -238,6 +329,60 @@ describe("skills cli commands", () => { expect(runtimeErrors).toEqual([]); }); + it("updates tracked ClawHub skills in the cwd-inferred agent workspace", async () => { + routeWorkspaceByAgent(); + resolveAgentIdByWorkspacePathMock.mockReturnValue("writer"); + readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]); + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: true, + slug: "calendar", + previousVersion: "1.2.2", + version: "1.2.3", + changed: true, + targetDir: "/tmp/workspace-writer/skills/calendar", + }, + ]); + + await withCwd("/tmp/workspace-writer", async () => { + await runCommand(["skills", "update", "--all"]); + }); + + expect(readTrackedClawHubSkillSlugsMock).toHaveBeenCalledWith("/tmp/workspace-writer"); + expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith({ + workspaceDir: "/tmp/workspace-writer", + slug: undefined, + logger: expect.any(Object), + }); + }); + + it("lets --agent override cwd-inferred workspace for updates", async () => { + routeWorkspaceByAgent(); + resolveAgentIdByWorkspacePathMock.mockReturnValue("writer"); + readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]); + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: true, + slug: "calendar", + previousVersion: "1.2.2", + version: "1.2.3", + changed: true, + targetDir: "/tmp/workspace-main/skills/calendar", + }, + ]); + + await withCwd("/tmp/workspace-writer", async () => { + await runCommand(["skills", "update", "calendar", "--agent", "main"]); + }); + + expect(resolveAgentIdByWorkspacePathMock).not.toHaveBeenCalled(); + expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith({ + workspaceDir: "/tmp/workspace-main", + slug: "calendar", + logger: expect.any(Object), + }); + }); + it.each([ { label: "list", @@ -283,6 +428,59 @@ describe("skills cli commands", () => { assert(payload); }); + it.each([ + ["list", ["skills", "list", "--json"]], + ["info", ["skills", "info", "calendar", "--json"]], + ["check", ["skills", "check", "--json"]], + ["default", ["skills"]], + ])("routes skills %s through the cwd-inferred agent workspace", async (_label, argv) => { + routeWorkspaceByAgent(); + resolveAgentIdByWorkspacePathMock.mockReturnValue("writer"); + + await withCwd("/tmp/workspace-writer", async () => { + await runCommand(argv); + }); + + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace-writer", { + config: {}, + }); + }); + + it.each([ + ["list", ["skills", "list", "--agent", "writer", "--json"]], + ["info", ["skills", "info", "calendar", "--agent", "writer", "--json"]], + ["check", ["skills", "check", "--agent", "writer", "--json"]], + ["default", ["skills", "--agent", "writer"]], + ])("routes skills %s through the explicit agent workspace", async (_label, argv) => { + routeWorkspaceByAgent(); + resolveAgentIdByWorkspacePathMock.mockReturnValue("main"); + + await withCwd("/tmp/workspace-main", async () => { + await runCommand(argv); + }); + + expect(resolveAgentIdByWorkspacePathMock).not.toHaveBeenCalled(); + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace-writer", { + config: {}, + }); + }); + + it("falls back to the default agent outside configured workspaces", async () => { + routeWorkspaceByAgent(); + resolveDefaultAgentIdMock.mockReturnValue("main"); + resolveAgentIdByWorkspacePathMock.mockReturnValue(undefined); + + await withCwd("/tmp/unrelated", async () => { + await runCommand(["skills", "list", "--json"]); + }); + + expect(resolveAgentIdByWorkspacePathMock).toHaveBeenCalledWith({}, "/tmp/unrelated"); + expect(resolveDefaultAgentIdMock).toHaveBeenCalledWith({}); + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace-main", { + config: {}, + }); + }); + it("keeps non-JSON skills list output on stdout with human-readable formatting", async () => { await runCommand(["skills", "list"]); diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 909efaa2782..c1bbe34860d 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -1,5 +1,9 @@ import type { Command } from "commander"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + resolveAgentIdByWorkspacePath, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { installSkillFromClawHub, readTrackedClawHubSkillSlugs, @@ -11,6 +15,7 @@ import { defaultRuntime } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { resolveOptionFromCommand } from "./cli-utils.js"; import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; export type { @@ -24,16 +29,48 @@ type SkillStatusReport = Awaited< ReturnType<(typeof import("../agents/skills-status.js"))["buildWorkspaceSkillStatus"]> >; -async function loadSkillsStatusReport(): Promise { +type ResolveSkillsWorkspaceOptions = { + agentId?: string; + cwd?: string; +}; + +function resolveSkillsWorkspace(options?: ResolveSkillsWorkspaceOptions): { + config: ReturnType; + workspaceDir: string; +} { const config = getRuntimeConfig(); - const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const explicitAgentId = normalizeOptionalString(options?.agentId); + const inferredAgentId = explicitAgentId + ? undefined + : resolveAgentIdByWorkspacePath(config, options?.cwd ?? process.cwd()); + const agentId = explicitAgentId ?? inferredAgentId ?? resolveDefaultAgentId(config); + return { + config, + workspaceDir: resolveAgentWorkspaceDir(config, agentId), + }; +} + +function resolveAgentOption( + command: Command | undefined, + opts?: { agent?: string }, +): string | undefined { + return resolveOptionFromCommand(command, "agent") ?? opts?.agent; +} + +async function loadSkillsStatusReport( + options?: ResolveSkillsWorkspaceOptions, +): Promise { + const { config, workspaceDir } = resolveSkillsWorkspace(options); const { buildWorkspaceSkillStatus } = await import("../agents/skills-status.js"); return buildWorkspaceSkillStatus(workspaceDir, { config }); } -async function runSkillsAction(render: (report: SkillStatusReport) => string): Promise { +async function runSkillsAction( + render: (report: SkillStatusReport) => string, + options?: ResolveSkillsWorkspaceOptions, +): Promise { try { - const report = await loadSkillsStatusReport(); + const report = await loadSkillsStatusReport(options); defaultRuntime.writeStdout(render(report)); } catch (err) { defaultRuntime.error(String(err)); @@ -41,9 +78,8 @@ async function runSkillsAction(render: (report: SkillStatusReport) => string): P } } -function resolveActiveWorkspaceDir(): string { - const config = getRuntimeConfig(); - return resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); +function resolveActiveWorkspaceDir(options?: ResolveSkillsWorkspaceOptions): string { + return resolveSkillsWorkspace(options).workspaceDir; } /** @@ -53,6 +89,7 @@ export function registerSkillsCli(program: Command) { const skills = program .command("skills") .description("List and inspect available skills") + .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") .addHelpText( "after", () => @@ -96,78 +133,96 @@ export function registerSkillsCli(program: Command) { .argument("", "ClawHub skill slug") .option("--version ", "Install a specific version") .option("--force", "Overwrite an existing workspace skill", false) - .action(async (slug: string, opts: { version?: string; force?: boolean }) => { - try { - const workspaceDir = resolveActiveWorkspaceDir(); - const result = await installSkillFromClawHub({ - workspaceDir, - slug, - version: opts.version, - force: Boolean(opts.force), - logger: { - info: (message) => defaultRuntime.log(message), - }, - }); - if (!result.ok) { - defaultRuntime.error(result.error); + .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") + .action( + async ( + slug: string, + opts: { version?: string; force?: boolean; agent?: string }, + command: Command, + ) => { + try { + const workspaceDir = resolveActiveWorkspaceDir({ + agentId: resolveAgentOption(command, opts), + }); + const result = await installSkillFromClawHub({ + workspaceDir, + slug, + version: opts.version, + force: Boolean(opts.force), + logger: { + info: (message) => defaultRuntime.log(message), + }, + }); + if (!result.ok) { + defaultRuntime.error(result.error); + defaultRuntime.exit(1); + return; + } + defaultRuntime.log(`Installed ${result.slug}@${result.version} -> ${result.targetDir}`); + } catch (err) { + defaultRuntime.error(String(err)); defaultRuntime.exit(1); - return; } - defaultRuntime.log(`Installed ${result.slug}@${result.version} -> ${result.targetDir}`); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }); + }, + ); skills .command("update") .description("Update ClawHub-installed skills in the active workspace") .argument("[slug]", "Single skill slug") .option("--all", "Update all tracked ClawHub skills", false) - .action(async (slug: string | undefined, opts: { all?: boolean }) => { - try { - if (!slug && !opts.all) { - defaultRuntime.error("Provide a skill slug or use --all."); - defaultRuntime.exit(1); - return; - } - if (slug && opts.all) { - defaultRuntime.error("Use either a skill slug or --all."); - defaultRuntime.exit(1); - return; - } - const workspaceDir = resolveActiveWorkspaceDir(); - const tracked = await readTrackedClawHubSkillSlugs(workspaceDir); - if (opts.all && tracked.length === 0) { - defaultRuntime.log("No tracked ClawHub skills to update."); - return; - } - const results = await updateSkillsFromClawHub({ - workspaceDir, - slug, - logger: { - info: (message) => defaultRuntime.log(message), - }, - }); - for (const result of results) { - if (!result.ok) { - defaultRuntime.error(result.error); - continue; + .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") + .action( + async ( + slug: string | undefined, + opts: { all?: boolean; agent?: string }, + command: Command, + ) => { + try { + if (!slug && !opts.all) { + defaultRuntime.error("Provide a skill slug or use --all."); + defaultRuntime.exit(1); + return; } - if (result.changed) { - defaultRuntime.log( - `Updated ${result.slug}: ${result.previousVersion ?? "unknown"} -> ${result.version}`, - ); - continue; + if (slug && opts.all) { + defaultRuntime.error("Use either a skill slug or --all."); + defaultRuntime.exit(1); + return; } - defaultRuntime.log(`${result.slug} already at ${result.version}`); + const workspaceDir = resolveActiveWorkspaceDir({ + agentId: resolveAgentOption(command, opts), + }); + const tracked = await readTrackedClawHubSkillSlugs(workspaceDir); + if (opts.all && tracked.length === 0) { + defaultRuntime.log("No tracked ClawHub skills to update."); + return; + } + const results = await updateSkillsFromClawHub({ + workspaceDir, + slug, + logger: { + info: (message) => defaultRuntime.log(message), + }, + }); + for (const result of results) { + if (!result.ok) { + defaultRuntime.error(result.error); + continue; + } + if (result.changed) { + defaultRuntime.log( + `Updated ${result.slug}: ${result.previousVersion ?? "unknown"} -> ${result.version}`, + ); + continue; + } + defaultRuntime.log(`${result.slug} already at ${result.version}`); + } + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); } - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }); + }, + ); skills .command("list") @@ -175,29 +230,45 @@ export function registerSkillsCli(program: Command) { .option("--json", "Output as JSON", false) .option("--eligible", "Show only eligible (ready to use) skills", false) .option("-v, --verbose", "Show more details including missing requirements", false) - .action(async (opts) => { - await runSkillsAction((report) => formatSkillsList(report, opts)); - }); + .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") + .action( + async ( + opts: { json?: boolean; eligible?: boolean; verbose?: boolean; agent?: string }, + command: Command, + ) => { + await runSkillsAction((report) => formatSkillsList(report, opts), { + agentId: resolveAgentOption(command, opts), + }); + }, + ); skills .command("info") .description("Show detailed information about a skill") .argument("", "Skill name") .option("--json", "Output as JSON", false) - .action(async (name, opts) => { - await runSkillsAction((report) => formatSkillInfo(report, name, opts)); + .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") + .action(async (name: string, opts: { json?: boolean; agent?: string }, command: Command) => { + await runSkillsAction((report) => formatSkillInfo(report, name, opts), { + agentId: resolveAgentOption(command, opts), + }); }); skills .command("check") .description("Check which skills are ready vs missing requirements") .option("--json", "Output as JSON", false) - .action(async (opts) => { - await runSkillsAction((report) => formatSkillsCheck(report, opts)); + .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") + .action(async (opts: { json?: boolean; agent?: string }, command: Command) => { + await runSkillsAction((report) => formatSkillsCheck(report, opts), { + agentId: resolveAgentOption(command, opts), + }); }); // Default action (no subcommand) - show list - skills.action(async () => { - await runSkillsAction((report) => formatSkillsList(report, {})); + skills.action(async (opts: { agent?: string }, command: Command) => { + await runSkillsAction((report) => formatSkillsList(report, {}), { + agentId: resolveAgentOption(command, opts), + }); }); }