fix: scope skills cli to active agent workspace

This commit is contained in:
Peter Steinberger
2026-04-28 01:05:15 +01:00
parent 32d76e2429
commit 1d3170b16f
4 changed files with 363 additions and 83 deletions

View File

@@ -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.

View File

@@ -25,21 +25,29 @@ openclaw skills search --limit 20 --json
openclaw skills install <slug>
openclaw skills install <slug> --version <version>
openclaw skills install <slug> --force
openclaw skills install <slug> --agent <id>
openclaw skills update <slug>
openclaw skills update --all
openclaw skills update --all --agent <id>
openclaw skills list
openclaw skills list --eligible
openclaw skills list --json
openclaw skills list --verbose
openclaw skills list --agent <id>
openclaw skills info <name>
openclaw skills info <name> --json
openclaw skills info <name> --agent <id>
openclaw skills check
openclaw skills check --json
openclaw skills check --agent <id>
```
`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 <id>`, 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 <n>` caps returned results.
- `install --force` overwrites an existing workspace skill folder for the same
slug.
- `--agent <id>` 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

View File

@@ -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<void>) {
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"]);

View File

@@ -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<SkillStatusReport> {
type ResolveSkillsWorkspaceOptions = {
agentId?: string;
cwd?: string;
};
function resolveSkillsWorkspace(options?: ResolveSkillsWorkspaceOptions): {
config: ReturnType<typeof getRuntimeConfig>;
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<string>(command, "agent") ?? opts?.agent;
}
async function loadSkillsStatusReport(
options?: ResolveSkillsWorkspaceOptions,
): Promise<SkillStatusReport> {
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<void> {
async function runSkillsAction(
render: (report: SkillStatusReport) => string,
options?: ResolveSkillsWorkspaceOptions,
): Promise<void> {
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 <id>", "Target agent workspace (defaults to cwd-inferred, then default agent)")
.addHelpText(
"after",
() =>
@@ -96,78 +133,96 @@ export function registerSkillsCli(program: Command) {
.argument("<slug>", "ClawHub skill slug")
.option("--version <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 <id>", "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 <id>", "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 <id>", "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("<name>", "Skill name")
.option("--json", "Output as JSON", false)
.action(async (name, opts) => {
await runSkillsAction((report) => formatSkillInfo(report, name, opts));
.option("--agent <id>", "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 <id>", "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),
});
});
}