Skip to content
1 change: 0 additions & 1 deletion src/lib/nemoclaw-runtime-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export interface NemoClawRuntimeBridge {
sandboxConnect: (sandboxName: string, options?: SandboxConnectOptions) => Promise<void>;
sandboxDestroy: (sandboxName: string, args?: string[]) => Promise<void>;
sandboxRebuild: (sandboxName: string, args?: string[]) => Promise<void>;
sandboxSkillInstall: (sandboxName: string, args?: string[]) => Promise<void>;
sandboxStatus: (sandboxName: string) => Promise<void>;
upgradeSandboxes: (args?: string[]) => Promise<void>;
}
Expand Down
5 changes: 4 additions & 1 deletion src/lib/sandbox-runtime-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export async function installSandboxSkill(
sandboxName: string,
args: string[] = [],
): Promise<void> {
await getNemoClawRuntimeBridge().sandboxSkillInstall(sandboxName, args);
const { installSandboxSkill: installExtractedSandboxSkill } = require("./sandbox-skill-install-action") as {
installSandboxSkill: (sandboxName: string, args?: string[]) => Promise<void>;
};
await installExtractedSandboxSkill(sandboxName, args);
}

export async function runSandboxSnapshot(sandboxName: string, args: string[]): Promise<void> {
Expand Down
224 changes: 224 additions & 0 deletions src/lib/sandbox-skill-install-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/* v8 ignore start -- exercised through CLI subprocess skill install tests. */

import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { CLI_NAME } from "./branding";
import { captureOpenshell } from "./openshell-runtime";
import { ensureLiveSandboxOrExit } from "./sandbox-gateway-state-action";
import * as skillInstall from "./skill-install";
import { D, G, R, YW } from "./terminal-style";

const agentRuntime = require("../../bin/lib/agent-runtime");

export function printSkillInstallUsage(): void {
console.log("");
console.log(` Usage: ${CLI_NAME} <sandbox> skill install <path>`);
console.log("");
console.log(" Deploy a skill directory to a running sandbox.");
console.log(
" <path> must be a skill directory containing a SKILL.md (with 'name:' frontmatter),",
);
console.log(
" or a direct path to a SKILL.md file. All non-dot files in the directory are uploaded.",
);
console.log("");
}

export function looksLikeOpenClawPlugin(candidatePath: string): boolean {
const dir =
fs.existsSync(candidatePath) && fs.statSync(candidatePath).isDirectory()
? candidatePath
: path.dirname(candidatePath);
if (!fs.existsSync(dir)) return false;
if (fs.existsSync(path.join(dir, "openclaw.plugin.json"))) return true;

const packageJsonPath = path.join(dir, "package.json");
if (!fs.existsSync(packageJsonPath)) return false;
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
const openclawBlock = packageJson?.openclaw;
return Boolean(
packageJson?.["openclaw.plugin"] === true ||
openclawBlock === true ||
(typeof openclawBlock === "object" &&
openclawBlock !== null &&
(openclawBlock.plugin === true ||
typeof openclawBlock.entry === "string" ||
typeof openclawBlock.main === "string" ||
(Array.isArray(openclawBlock.extensions) && openclawBlock.extensions.length > 0))),
);
} catch {
return false;
}
}

export function printPluginInstallHint(): void {
console.error(" This looks like an OpenClaw plugin, not a SKILL.md agent skill.");
console.error(" `skill install` only accepts skill directories or direct SKILL.md paths.");
console.error(
" To use an OpenClaw plugin today, bake it into a custom sandbox image with `nemoclaw onboard --from <Dockerfile>`.",
);
}

/**
* Install or update a local skill directory into a live sandbox and perform
* any agent-specific post-install refresh needed for the new content to load.
*/
export async function installSandboxSkill(
sandboxName: string,
args: string[] = [],
): Promise<void> {
const sub = args[0];
if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
printSkillInstallUsage();
return;
}

if (sub !== "install") {
console.error(` Unknown skill subcommand: ${sub}`);
console.error(" Valid subcommands: install");
process.exit(1);
}

const skillPath = args[1];
const extraArgs = args.slice(2);
if (skillPath === "--help" || skillPath === "-h" || skillPath === "help") {
printSkillInstallUsage();
return;
}
if (extraArgs.length > 0) {
console.error(` Unknown argument(s) for skill install: ${extraArgs.join(", ")}`);
console.error(` Usage: ${CLI_NAME} <sandbox> skill install <path>`);
process.exit(1);
}
if (!skillPath) {
console.error(` Usage: ${CLI_NAME} <sandbox> skill install <path>`);
console.error(" <path> must be a directory containing a SKILL.md file.");
process.exit(1);
}

const resolvedPath = path.resolve(skillPath);

// Accept a directory containing SKILL.md, or a direct path to SKILL.md.
let skillDir: string;
let skillMdPath: string;
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
skillDir = resolvedPath;
skillMdPath = path.join(resolvedPath, "SKILL.md");
} else if (fs.existsSync(resolvedPath) && resolvedPath.endsWith("SKILL.md")) {
skillDir = path.dirname(resolvedPath);
skillMdPath = resolvedPath;
} else {
console.error(` No SKILL.md found at '${resolvedPath}'.`);
console.error(" <path> must be a skill directory or a direct path to SKILL.md.");
if (looksLikeOpenClawPlugin(resolvedPath)) {
printPluginInstallHint();
}
process.exit(1);
}

if (!fs.existsSync(skillMdPath)) {
console.error(` No SKILL.md found in '${skillDir}'.`);
console.error(" The skill directory must contain a SKILL.md file.");
if (looksLikeOpenClawPlugin(skillDir)) {
printPluginInstallHint();
}
process.exit(1);
}

// 1. Validate frontmatter
let frontmatter;
try {
const content = fs.readFileSync(skillMdPath, "utf-8");
frontmatter = skillInstall.parseFrontmatter(content);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error(` ${errorMessage}`);
process.exit(1);
}

const collected = skillInstall.collectFiles(skillDir);
if (collected.unsafePaths.length > 0) {
console.error(" Skill directory contains files with unsafe characters:");
for (const p of collected.unsafePaths) console.error(` ${p}`);
console.error(" File names must match [A-Za-z0-9._-/]. Rename or remove them.");
process.exit(1);
}
if (collected.skippedDotfiles.length > 0) {
console.log(
` ${D}Skipping ${collected.skippedDotfiles.length} hidden path(s): ${collected.skippedDotfiles.join(", ")}${R}`,
);
}
const fileLabel = collected.files.length === 1 ? "1 file" : `${collected.files.length} files`;
console.log(` ${G}✓${R} Validated SKILL.md (name: ${frontmatter.name}, ${fileLabel})`);

// 2. Ensure sandbox is live
await ensureLiveSandboxOrExit(sandboxName);

// 3. Resolve agent and paths
const agent = agentRuntime.getSessionAgent(sandboxName);
const paths = skillInstall.resolveSkillPaths(agent, frontmatter.name);

// 4. Get SSH config
const sshConfigResult = captureOpenshell(["sandbox", "ssh-config", sandboxName], {
ignoreError: true,
});
if (sshConfigResult.status !== 0) {
console.error(" Failed to obtain SSH configuration for the sandbox.");
process.exit(1);
}

const tmpSshConfig = path.join(
os.tmpdir(),
`nemoclaw-ssh-skill-${process.pid}-${Date.now()}.conf`,
);
fs.writeFileSync(tmpSshConfig, sshConfigResult.output, { mode: 0o600 });

try {
const ctx = { configFile: tmpSshConfig, sandboxName };

// 5. Check if skill already exists (update vs fresh install)
const isUpdate = skillInstall.checkExisting(ctx, paths);

// 6. Upload skill directory
const { uploaded, failed } = skillInstall.uploadDirectory(ctx, skillDir, paths.uploadDir);
if (failed.length > 0) {
console.error(` Failed to upload ${failed.length} file(s): ${failed.join(", ")}`);
process.exit(1);
}
console.log(` ${G}✓${R} Uploaded ${uploaded} file(s) to sandbox`);

// 7. Post-install (OpenClaw mirror + refresh, or restart hint).
// OpenClaw caches skill content per session, so always refresh the
// session index after an install/update to avoid stale SKILL.md data.
const post = skillInstall.postInstall(ctx, paths, skillDir);
for (const msg of post.messages) {
if (msg.startsWith("Warning:")) {
console.error(` ${YW}${msg}${R}`);
} else {
console.log(` ${D}${msg}${R}`);
}
}

// 8. Verify
const verified = skillInstall.verifyInstall(ctx, paths);
if (verified) {
const verb = isUpdate ? "updated" : "installed";
console.log(` ${G}✓${R} Skill '${frontmatter.name}' ${verb}`);
} else {
console.error(` Skill uploaded but verification failed at ${paths.uploadDir}/SKILL.md`);
process.exit(1);
}
} finally {
try {
fs.unlinkSync(tmpSshConfig);
} catch {
/* ignore */
}
}
}
Loading
Loading