diff --git a/src/lib/nemoclaw-runtime-bridge.ts b/src/lib/nemoclaw-runtime-bridge.ts index ff51cd553c3..3f72b20ff53 100644 --- a/src/lib/nemoclaw-runtime-bridge.ts +++ b/src/lib/nemoclaw-runtime-bridge.ts @@ -11,7 +11,6 @@ export interface NemoClawRuntimeBridge { sandboxConnect: (sandboxName: string, options?: SandboxConnectOptions) => Promise; sandboxDestroy: (sandboxName: string, args?: string[]) => Promise; sandboxRebuild: (sandboxName: string, args?: string[]) => Promise; - sandboxSkillInstall: (sandboxName: string, args?: string[]) => Promise; sandboxStatus: (sandboxName: string) => Promise; upgradeSandboxes: (args?: string[]) => Promise; } diff --git a/src/lib/sandbox-runtime-actions.ts b/src/lib/sandbox-runtime-actions.ts index 1d4a7f8ccdc..d8eb927e9f6 100644 --- a/src/lib/sandbox-runtime-actions.ts +++ b/src/lib/sandbox-runtime-actions.ts @@ -37,7 +37,10 @@ export async function installSandboxSkill( sandboxName: string, args: string[] = [], ): Promise { - await getNemoClawRuntimeBridge().sandboxSkillInstall(sandboxName, args); + const { installSandboxSkill: installExtractedSandboxSkill } = require("./sandbox-skill-install-action") as { + installSandboxSkill: (sandboxName: string, args?: string[]) => Promise; + }; + await installExtractedSandboxSkill(sandboxName, args); } export async function runSandboxSnapshot(sandboxName: string, args: string[]): Promise { diff --git a/src/lib/sandbox-skill-install-action.ts b/src/lib/sandbox-skill-install-action.ts new file mode 100644 index 00000000000..0704881fa40 --- /dev/null +++ b/src/lib/sandbox-skill-install-action.ts @@ -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} skill install `); + console.log(""); + console.log(" Deploy a skill directory to a running sandbox."); + console.log( + " 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 `.", + ); +} + +/** + * 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 { + 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} skill install `); + process.exit(1); + } + if (!skillPath) { + console.error(` Usage: ${CLI_NAME} skill install `); + console.error(" 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(" 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 */ + } + } +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 38e0d29bf64..a2990a722ee 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -78,7 +78,6 @@ const agentRuntime = require("../bin/lib/agent-runtime"); const sandboxVersion = require("./lib/sandbox-version"); const sandboxState = require("./lib/sandbox-state"); const { parseRestoreArgs } = sandboxState; -const skillInstall = require("./lib/skill-install"); const { sleepSeconds } = require("./lib/wait"); const { parseSandboxPhase } = require("./lib/gateway-state"); const { @@ -528,7 +527,6 @@ exports.runtimeBridge = { sandboxConnect, sandboxDestroy, sandboxRebuild, - sandboxSkillInstall, sandboxStatus, upgradeSandboxes, }; @@ -1659,211 +1657,6 @@ async function sandboxStatus(sandboxName: string) { console.log(""); } -function printSkillInstallUsage(): void { - console.log(""); - console.log(` Usage: ${CLI_NAME} skill install `); - console.log(""); - console.log(" Deploy a skill directory to a running sandbox."); - console.log( - " 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(""); -} - -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; - } -} - -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 `.", - ); -} - -/** - * 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. - */ -async function sandboxSkillInstall(sandboxName: string, args: string[] = []): Promise { - 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} skill install `); - process.exit(1); - } - if (!skillPath) { - console.error(` Usage: ${CLI_NAME} skill install `); - console.error(" 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(" 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 */ - } - } -} - function cleanupSandboxServices( sandboxName: string, { stopHostServices = false }: { stopHostServices?: boolean } = {}, @@ -2738,9 +2531,13 @@ async function runDispatchResult( await addSandboxPolicy(sandboxName, actionArgs); return; } - case "skill": - await sandboxSkillInstall(sandboxName, actionArgs); + case "skill": { + const { installSandboxSkill } = require("./lib/sandbox-skill-install-action") as { + installSandboxSkill: (sandboxName: string, args?: string[]) => Promise; + }; + await installSandboxSkill(sandboxName, actionArgs); return; + } case "snapshot": { const { runSandboxSnapshot } = require("./lib/snapshot-action") as { runSandboxSnapshot: (sandboxName: string, args: string[]) => Promise;