Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,19 @@ Files with unsafe path characters are rejected to prevent shell injection.
If the skill already exists on the sandbox, the command updates it in place and preserves chat history.
For new installs, the agent session index is refreshed so the agent discovers the skill on the next session.

### `nemoclaw <name> skill remove <skill>`

Remove an installed skill from a running sandbox by skill name.
The command validates the skill name, removes the sandbox upload directory, removes the OpenClaw home-directory mirror when present, and refreshes the agent session index so the remaining skills are rediscovered on the next session.

```bash
nemoclaw my-assistant skill remove my-skill
```

Use the skill name from the `SKILL.md` frontmatter, not the local directory name.
Skill names must contain only alphanumeric characters, dots, hyphens, and underscores, and cannot be `.` or `..`.
For non-OpenClaw agents, restart the agent gateway if prompted so the removal takes effect.

### `nemoclaw <name> rebuild`

Upgrade a sandbox to the current agent version while preserving workspace state.
Expand Down
43 changes: 41 additions & 2 deletions src/commands/sandbox/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const installSandboxSkill = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const removeSandboxSkill = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));

vi.mock("../../lib/actions/sandbox/skill-install", () => ({
installSandboxSkill,
removeSandboxSkill,
}));

import SkillCliCommand from "./skill";
import SkillInstallCliCommand from "./skill/install";
import SkillRemoveCliCommand from "./skill/remove";

const rootDir = process.cwd();

function clearSkillMocks(): void {
installSandboxSkill.mockClear();
removeSandboxSkill.mockClear();
}

describe("SkillCliCommand", () => {
beforeEach(() => {
installSandboxSkill.mockClear();
clearSkillMocks();
});

it("records a parser-style failure when the sandbox name is missing", async () => {
Expand All @@ -29,6 +37,7 @@ describe("SkillCliCommand", () => {
expect(process.exitCode).toBe(2);
expect(error).toHaveBeenCalledWith("Missing required sandboxName for skill.");
expect(installSandboxSkill).not.toHaveBeenCalled();
expect(removeSandboxSkill).not.toHaveBeenCalled();
} finally {
process.exitCode = previousExitCode;
}
Expand All @@ -37,7 +46,7 @@ describe("SkillCliCommand", () => {

describe("SkillInstallCliCommand", () => {
beforeEach(() => {
installSandboxSkill.mockClear();
clearSkillMocks();
});

it("runs skill install with typed action options", async () => {
Expand All @@ -55,3 +64,33 @@ describe("SkillInstallCliCommand", () => {
expect(installSandboxSkill).not.toHaveBeenCalled();
});
});

describe("SkillRemoveCliCommand", () => {
beforeEach(() => {
clearSkillMocks();
});

it("runs skill remove with typed action options", async () => {
await SkillRemoveCliCommand.run(["alpha", "my-skill"], rootDir);

expect(removeSandboxSkill).toHaveBeenCalledWith("alpha", {
command: "remove",
name: "my-skill",
});
});

it("allows help as a removable skill name", async () => {
await SkillRemoveCliCommand.run(["alpha", "help"], rootDir);

expect(removeSandboxSkill).toHaveBeenCalledWith("alpha", {
command: "remove",
name: "help",
});
});

it("requires a skill name before dispatch", async () => {
await expect(SkillRemoveCliCommand.run(["alpha"], rootDir)).rejects.toThrow(/skill/i);

expect(removeSandboxSkill).not.toHaveBeenCalled();
});
});
9 changes: 6 additions & 3 deletions src/commands/sandbox/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ export default class SkillCliCommand extends NemoClawCommand {
static id = "sandbox:skill";
static strict = false;
static summary = "Show skill command usage";
static description = "Show skill install usage or report unknown skill subcommands.";
static usage = ["install <name> <path>"];
static examples = ["<%= config.bin %> sandbox skill install alpha ./my-skill"];
static description = "Show skill install/remove usage or report unknown skill subcommands.";
static usage = ["install <name> <path>", "remove <name> <skill>"];
static examples = [
"<%= config.bin %> sandbox skill install alpha ./my-skill",
"<%= config.bin %> sandbox skill remove alpha my-skill",
];

public async run(): Promise<void> {
this.parsed = true;
Expand Down
36 changes: 36 additions & 0 deletions src/commands/sandbox/skill/remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Args } from "@oclif/core";
import { removeSandboxSkill } from "../../../lib/actions/sandbox/skill-install";
import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command";

export default class SkillRemoveCliCommand extends NemoClawCommand {
static id = "sandbox:skill:remove";
static strict = true;
static summary = "Remove an installed skill from the sandbox";
static description = "Remove an installed SKILL.md agent skill from a running sandbox.";
static usage = ["<name> <skill>"];
static examples = ["<%= config.bin %> sandbox skill remove alpha my-skill"];
static args = {
sandboxName: Args.string({
name: "sandbox",
description: "Sandbox name",
required: true,
}),
skillName: Args.string({
name: "skill",
description: "Skill name from SKILL.md frontmatter",
required: true,
}),
};
static flags = {};

public async run(): Promise<void> {
const { args } = await this.parse(SkillRemoveCliCommand);
await removeSandboxSkill(args.sandboxName, {
command: "remove",
name: args.skillName,
});
}
}
221 changes: 221 additions & 0 deletions src/lib/actions/sandbox/skill-install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const captureSandboxSshConfig = vi.hoisted(() => vi.fn());
const getSessionAgent = vi.hoisted(() => vi.fn());
const ensureLiveSandboxOrExit = vi.hoisted(() => vi.fn());
const skillInstall = vi.hoisted(() => ({
validateSkillName: vi.fn(),
resolveSkillPaths: vi.fn(),
checkExisting: vi.fn(),
removeSkill: vi.fn(),
verifyRemove: vi.fn(),
parseFrontmatter: vi.fn(),
collectFiles: vi.fn(),
uploadDirectory: vi.fn(),
postInstall: vi.fn(),
verifyInstall: vi.fn(),
}));

vi.mock("../../adapters/openshell/runtime", () => ({
captureSandboxSshConfig,
}));

vi.mock("../../agent/runtime", () => ({
getSessionAgent,
}));

vi.mock("../../skill-install", () => skillInstall);

vi.mock("./gateway-state", () => ({
ensureLiveSandboxOrExit,
}));

import { installSandboxSkill, removeSandboxSkill } from "./skill-install";

const paths = {
uploadDir: "/sandbox/.openclaw/skills/demo-skill",
mirrorDir: "$HOME/.openclaw/skills/demo-skill",
sessionFile: "/sandbox/.openclaw/agents/main/sessions/sessions.json",
isOpenClaw: true,
};

const agent = { name: "openclaw", configPaths: { dir: "/sandbox/.openclaw" } };

function makeSkillDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-action-skill-"));
fs.writeFileSync(path.join(dir, "SKILL.md"), "---\nname: demo-skill\n---\n# Demo\n");
return dir;
}

function restoreExitCode(previousExitCode: typeof process.exitCode): void {
process.exitCode = previousExitCode;
}

describe("sandbox skill action orchestration", () => {
let previousExitCode: typeof process.exitCode;

beforeEach(() => {
previousExitCode = process.exitCode;
process.exitCode = undefined;
vi.clearAllMocks();

captureSandboxSshConfig.mockReturnValue({ status: 0, output: "Host openshell-alpha\n" });
ensureLiveSandboxOrExit.mockResolvedValue(undefined);
getSessionAgent.mockReturnValue(agent);
skillInstall.validateSkillName.mockReturnValue(true);
skillInstall.resolveSkillPaths.mockReturnValue(paths);
skillInstall.checkExisting.mockReturnValue(true);
skillInstall.removeSkill.mockReturnValue({
success: true,
removedUploadDir: true,
removedMirrorDir: true,
clearedSessions: true,
messages: [],
});
skillInstall.verifyRemove.mockReturnValue(true);
skillInstall.parseFrontmatter.mockReturnValue({ name: "demo-skill" });
skillInstall.collectFiles.mockReturnValue({
files: ["SKILL.md"],
skippedDotfiles: [],
unsafePaths: [],
});
skillInstall.uploadDirectory.mockReturnValue({
uploaded: 1,
failed: [],
skippedDotfiles: [],
unsafePaths: [],
});
skillInstall.postInstall.mockReturnValue({ success: true, messages: [] });
skillInstall.verifyInstall.mockReturnValue(true);
});

afterEach(() => {
restoreExitCode(previousExitCode);
vi.restoreAllMocks();
});

it("fails skill remove when SSH config capture fails", async () => {
captureSandboxSshConfig.mockReturnValue({ status: 1, output: "" });
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const exit = vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
throw new Error(`process.exit ${code}`);
}) as typeof process.exit);

await expect(removeSandboxSkill("alpha", { name: "demo-skill" })).rejects.toThrow(
"process.exit 1",
);

expect(ensureLiveSandboxOrExit).toHaveBeenCalledWith("alpha");
expect(captureSandboxSshConfig).toHaveBeenCalledWith("alpha", expect.any(Object));
expect(error).toHaveBeenCalledWith(" Failed to obtain SSH configuration for the sandbox.");
expect(skillInstall.checkExisting).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(1);
});

it("treats unknown skill existence as fatal for remove and deletes the temp SSH config", async () => {
let tempConfig = "";
skillInstall.checkExisting.mockImplementation((ctx) => {
tempConfig = ctx.configFile;
expect(fs.existsSync(tempConfig)).toBe(true);
return null;
});
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);

await removeSandboxSkill("alpha", { name: "demo-skill" });

expect(process.exitCode).toBe(1);
expect(error).toHaveBeenCalledWith(
" Could not check if skill 'demo-skill' exists — sandbox may be unreachable.",
);
expect(skillInstall.removeSkill).not.toHaveBeenCalled();
expect(skillInstall.verifyRemove).not.toHaveBeenCalled();
expect(tempConfig).not.toBe("");
expect(fs.existsSync(tempConfig)).toBe(false);
});

it("reports an absent skill for remove and deletes the temp SSH config", async () => {
let tempConfig = "";
skillInstall.checkExisting.mockImplementation((ctx) => {
tempConfig = ctx.configFile;
return false;
});
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);

await removeSandboxSkill("alpha", { name: "demo-skill" });

expect(process.exitCode).toBe(1);
expect(error).toHaveBeenCalledWith(" Skill 'demo-skill' is not installed in sandbox 'alpha'.");
expect(skillInstall.removeSkill).not.toHaveBeenCalled();
expect(skillInstall.verifyRemove).not.toHaveBeenCalled();
expect(tempConfig).not.toBe("");
expect(fs.existsSync(tempConfig)).toBe(false);
});

it("removes and verifies an existing skill, then deletes the temp SSH config", async () => {
let tempConfig = "";
skillInstall.checkExisting.mockImplementation((ctx, resolvedPaths) => {
tempConfig = ctx.configFile;
expect(resolvedPaths).toBe(paths);
return true;
});
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

await removeSandboxSkill("alpha", { name: "demo-skill" });

expect(ensureLiveSandboxOrExit).toHaveBeenCalledWith("alpha");
expect(getSessionAgent).toHaveBeenCalledWith("alpha");
expect(skillInstall.resolveSkillPaths).toHaveBeenCalledWith(agent, "demo-skill");
expect(skillInstall.removeSkill).toHaveBeenCalledWith(
expect.objectContaining({ configFile: tempConfig, sandboxName: "alpha" }),
paths,
);
expect(skillInstall.verifyRemove).toHaveBeenCalledWith(
expect.objectContaining({ configFile: tempConfig, sandboxName: "alpha" }),
paths,
);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Skill 'demo-skill' removed"));
expect(fs.existsSync(tempConfig)).toBe(false);
expect(process.exitCode).toBeUndefined();
});

it("continues skill install when the existence probe is unknown because upload plus verify are authoritative", async () => {
const skillDir = makeSkillDir();
let tempConfig = "";
skillInstall.checkExisting.mockImplementation((ctx) => {
tempConfig = ctx.configFile;
return null;
});
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

try {
await installSandboxSkill("alpha", { command: "install", path: skillDir });
} finally {
fs.rmSync(skillDir, { recursive: true, force: true });
}

expect(error).toHaveBeenCalledWith(
expect.stringContaining(
"Warning: could not check sandbox for existing skill — treating as fresh install.",
),
);
expect(skillInstall.uploadDirectory).toHaveBeenCalledWith(
expect.objectContaining({ configFile: tempConfig, sandboxName: "alpha" }),
skillDir,
paths.uploadDir,
);
expect(skillInstall.verifyInstall).toHaveBeenCalledWith(
expect.objectContaining({ configFile: tempConfig, sandboxName: "alpha" }),
paths,
);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Skill 'demo-skill' installed"));
expect(fs.existsSync(tempConfig)).toBe(false);
expect(process.exitCode).toBeUndefined();
});
});
Loading
Loading