From 116111cd6c6d673e91fc0317882e74f83a9b5944 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 30 Apr 2026 13:51:20 -0700 Subject: [PATCH 01/17] refactor(cli): harden oclif bridge Signed-off-by: Carlos Villela --- src/lib/oclif-runner.ts | 13 ++++++++++++- src/nemoclaw.ts | 8 ++++++-- test/cli.test.ts | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/lib/oclif-runner.ts b/src/lib/oclif-runner.ts index 8fd7c81fa5e..f22a8431441 100644 --- a/src/lib/oclif-runner.ts +++ b/src/lib/oclif-runner.ts @@ -3,6 +3,8 @@ import { Config as OclifConfig } from "@oclif/core"; +import { CLI_NAME } from "./branding"; + export interface OclifCommandRunOptions { rootDir: string; error?: (message?: string) => void; @@ -40,11 +42,20 @@ export async function runRegisteredOclifCommand( opts: OclifCommandRunOptions, ): Promise { const config = await OclifConfig.load(opts.rootDir); + config.bin = CLI_NAME; const errorLine = opts.error ?? console.error; const exit = opts.exit ?? ((code: number) => process.exit(code)); try { - await config.runCommand(commandId, args); + const commandRef = config.findCommand(commandId, { must: true }); + const Command = await commandRef.load(); + await config.runHook("prerun", { argv: args, Command }); + const CommandCtor = Command as unknown as new ( + argv: string[], + config: OclifConfig, + ) => { _run: () => Promise }; + const result = await new CommandCtor(args, config)._run(); + await config.runHook("postrun", { argv: args, Command, result }); } catch (error) { const exitCode = getOclifExitCode(error); if (exitCode === 0) { diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index d99bdbc5279..b42a80a9576 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -1521,14 +1521,18 @@ function showStatus() { }); } -async function listSandboxes(args: string[] = []): Promise { - await runRegisteredOclifCommand("list", args, { +async function runOclif(commandId: string, args: string[] = []): Promise { + await runRegisteredOclifCommand(commandId, args, { rootDir: ROOT, error: console.error, exit: (code: number) => process.exit(code), }); } +async function listSandboxes(args: string[] = []): Promise { + await runOclif("list", args); +} + // ── Sandbox-scoped actions ─────────────────────────────────────── async function sandboxConnect(sandboxName: string) { diff --git a/test/cli.test.ts b/test/cli.test.ts index 34adc856903..f2df15d2bdf 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -8,6 +8,7 @@ import os from "node:os"; import path from "node:path"; const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); +const HERMES_CLI = path.join(import.meta.dirname, "..", "bin", "nemohermes.js"); type CliRunResult = { code: number; @@ -248,6 +249,19 @@ describe("CLI dispatch", () => { expect(r.out).toContain("List all sandboxes"); }); + it("nemohermes list --help uses alias branding", () => { + const out = execSync(`node "${HERMES_CLI}" list --help`, { + encoding: "utf-8", + timeout: Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), + env: { + ...process.env, + HOME: `/tmp/nemoclaw-cli-test-${Date.now()}`, + }, + }); + expect(out).toContain("$ nemohermes list [--json]"); + expect(out).not.toContain("$ nemoclaw list [--json]"); + }); + it("list --json emits structured empty inventory", () => { const r = run("list --json"); expect(r.code).toBe(0); From 5e997cb36db73bc722796a415c0abccae441995d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 30 Apr 2026 14:07:41 -0700 Subject: [PATCH 02/17] test(cli): relax uninstall helper timeouts Signed-off-by: Carlos Villela --- test/uninstall.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index ff495290920..628bb320fc8 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -103,7 +103,7 @@ describe("uninstall helpers", () => { expect(result.status).toBe(0); expect(fs.existsSync(shimPath)).toBe(false); - }); + }, 60_000); it("preserves a user-managed nemoclaw file in the shim directory", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); @@ -122,7 +122,7 @@ describe("uninstall helpers", () => { expect(result.status).toBe(0); expect(fs.existsSync(shimPath)).toBe(true); expect(`${result.stdout}${result.stderr}`).toMatch(/not an installer-managed shim/); - }); + }, 60_000); it("removes an installer-managed nemoclaw wrapper file in the shim directory", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-wrapper-")); @@ -177,7 +177,7 @@ describe("uninstall helpers", () => { expect(result.status).toBe(0); expect(fs.existsSync(shimPath)).toBe(false); - }); + }, 60_000); it("preserves a wrapper-like shim when extra content is appended", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-wrapper-extra-")); @@ -206,7 +206,7 @@ describe("uninstall helpers", () => { expect(result.status).toBe(0); expect(fs.existsSync(shimPath)).toBe(true); expect(`${result.stdout}${result.stderr}`).toMatch(/not an installer-managed shim/); - }); + }, 60_000); it("removes the onboard session file as part of NemoClaw state cleanup", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-session-")); From 88cd958bc0db00f7d7677846ebe92b9dc253f882 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 30 Apr 2026 13:55:21 -0700 Subject: [PATCH 03/17] refactor(cli): migrate status and tunnel commands to oclif Signed-off-by: Carlos Villela --- src/lib/oclif-commands.ts | 12 +++ src/lib/status-command-deps.ts | 139 +++++++++++++++++++++++++++++++ src/lib/status-command.ts | 23 ++++++ src/lib/tunnel-commands.ts | 85 +++++++++++++++++++ src/nemoclaw.ts | 146 +++------------------------------ test/cli.test.ts | 21 +++++ 6 files changed, 290 insertions(+), 136 deletions(-) create mode 100644 src/lib/status-command-deps.ts create mode 100644 src/lib/status-command.ts create mode 100644 src/lib/tunnel-commands.ts diff --git a/src/lib/oclif-commands.ts b/src/lib/oclif-commands.ts index 82f9902b3be..23cab599fcf 100644 --- a/src/lib/oclif-commands.ts +++ b/src/lib/oclif-commands.ts @@ -2,7 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 import ListCommand from "./list-command"; +import StatusCommand from "./status-command"; +import { + DeprecatedStartCommand, + DeprecatedStopCommand, + TunnelStartCommand, + TunnelStopCommand, +} from "./tunnel-commands"; export default { list: ListCommand, + status: StatusCommand, + start: DeprecatedStartCommand, + stop: DeprecatedStopCommand, + "tunnel:start": TunnelStartCommand, + "tunnel:stop": TunnelStopCommand, }; diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts new file mode 100644 index 00000000000..c7906ed3dee --- /dev/null +++ b/src/lib/status-command-deps.ts @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; + +import type { CaptureOpenshellResult } from "./openshell"; +import type { ShowStatusCommandDeps, MessagingBridgeHealth } from "./inventory-commands"; +import * as registry from "./registry"; +import { parseGatewayInference } from "./inference-config"; +import { backfillMessagingChannels, findAllOverlaps } from "./messaging-conflict"; +import { OPENSHELL_PROBE_TIMEOUT_MS } from "./openshell-timeouts"; +import { captureOpenshellCommand, stripAnsi } from "./openshell"; +import { resolveOpenshell } from "./resolve-openshell"; +import { showStatus as showServiceStatus } from "./services"; + +function captureOpenshell( + rootDir: string, + args: string[], + opts: { timeout?: number } = {}, +): CaptureOpenshellResult { + const openshell = resolveOpenshell(); + if (!openshell) { + return { status: 1, output: "" }; + } + return captureOpenshellCommand(openshell, args, { + cwd: rootDir, + ignoreError: true, + timeout: opts.timeout, + }); +} + +function checkMessagingBridgeHealth( + rootDir: string, + sandboxName: string, + channels: string[], +): MessagingBridgeHealth[] { + // Only Telegram currently emits a recognizable conflict signature in the + // gateway log. Discord/Slack have similar single-consumer constraints but + // log differently; we can extend the regex when those patterns are known. + if (!Array.isArray(channels) || !channels.includes("telegram")) return []; + const openshell = resolveOpenshell(); + if (!openshell) return []; + const script = + 'tail -n 200 /tmp/gateway.log 2>/dev/null | grep -cE "getUpdates conflict|409[[:space:]:]+Conflict" || true'; + try { + const result = spawnSync( + openshell, + ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-c", script], + { cwd: rootDir, encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }, + ); + const count = Number.parseInt((result.stdout || "").trim(), 10); + if (!Number.isFinite(count) || count === 0) return []; + return [{ channel: "telegram", conflicts: count }]; + } catch { + return []; + } +} + +function makeConflictProbe(rootDir: string) { + // Upfront liveness check so we can distinguish "provider not attached" from + // "gateway unreachable". Without this, every non-zero `openshell provider + // get` collapses into "absent", and a transient gateway failure would + // persist messagingChannels: [] and permanently suppress future retries. + let gatewayAlive: boolean | null = null; + const isGatewayAlive = (): boolean => { + if (gatewayAlive === null) { + const result = captureOpenshell(rootDir, ["sandbox", "list"], { + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }); + gatewayAlive = result.status === 0; + } + return gatewayAlive; + }; + return { + providerExists: (name: string) => { + if (!isGatewayAlive()) return "error" as const; + const result = captureOpenshell(rootDir, ["provider", "get", name], { + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }); + return result.status === 0 ? ("present" as const) : ("absent" as const); + }, + }; +} + +function backfillAndFindOverlaps(rootDir: string) { + // Non-critical path: status must remain usable even if the gateway probe or + // registry write throws, so any failure yields an empty overlap list. + try { + backfillMessagingChannels(registry, makeConflictProbe(rootDir)); + return findAllOverlaps(registry); + } catch { + return []; + } +} + +function readGatewayLog(rootDir: string, sandboxName: string): string | null { + const openshell = resolveOpenshell(); + if (!openshell) return null; + try { + const result = spawnSync( + openshell, + [ + "sandbox", + "exec", + "-n", + sandboxName, + "--", + "sh", + "-c", + "tail -n 10 /tmp/gateway.log 2>/dev/null", + ], + { cwd: rootDir, encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }, + ); + const output = (result.stdout || "").trim(); + return output || null; + } catch { + return null; + } +} + +export function buildStatusCommandDeps(rootDir: string): ShowStatusCommandDeps { + return { + listSandboxes: () => registry.listSandboxes(), + getLiveInference: () => + parseGatewayInference( + stripAnsi( + captureOpenshell(rootDir, ["inference", "get"], { + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }).output, + ), + ), + showServiceStatus, + checkMessagingBridgeHealth: (sandboxName, channels) => + checkMessagingBridgeHealth(rootDir, sandboxName, channels), + backfillAndFindOverlaps: () => backfillAndFindOverlaps(rootDir), + readGatewayLog: (sandboxName) => readGatewayLog(rootDir, sandboxName), + log: console.log, + }; +} diff --git a/src/lib/status-command.ts b/src/lib/status-command.ts new file mode 100644 index 00000000000..eb5674d2cc6 --- /dev/null +++ b/src/lib/status-command.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command, Flags } from "@oclif/core"; + +import { showStatusCommand } from "./inventory-commands"; +import { buildStatusCommandDeps } from "./status-command-deps"; + +export default class StatusCommand extends Command { + static id = "status"; + static strict = true; + static summary = "Show sandbox list and service status"; + static description = "Show registered sandboxes, live inference, services, and messaging health."; + static usage = ["status"]; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + await this.parse(StatusCommand); + showStatusCommand(buildStatusCommandDeps(this.config.root)); + } +} diff --git a/src/lib/tunnel-commands.ts b/src/lib/tunnel-commands.ts new file mode 100644 index 00000000000..6533c21b33e --- /dev/null +++ b/src/lib/tunnel-commands.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command, Flags } from "@oclif/core"; + +import { CLI_NAME } from "./branding"; +import * as registry from "./registry"; +import { startAll, stopAll } from "./services"; +import { runStartCommand, runStopCommand } from "./services-command"; + +function serviceDeps() { + return { + listSandboxes: () => registry.listSandboxes(), + }; +} + +export class TunnelStartCommand extends Command { + static id = "tunnel:start"; + static strict = true; + static summary = "Start the cloudflared public-URL tunnel"; + static description = "Start the cloudflared public-URL tunnel for the default sandbox dashboard."; + static usage = ["tunnel start"]; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + await this.parse(TunnelStartCommand); + await runStartCommand({ ...serviceDeps(), startAll }); + } +} + +export class TunnelStopCommand extends Command { + static id = "tunnel:stop"; + static strict = true; + static summary = "Stop the cloudflared public-URL tunnel"; + static description = "Stop the cloudflared public-URL tunnel for the default sandbox dashboard."; + static usage = ["tunnel stop"]; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + await this.parse(TunnelStopCommand); + runStopCommand({ ...serviceDeps(), stopAll }); + } +} + +export class DeprecatedStartCommand extends Command { + static id = "start"; + static strict = true; + static summary = "Deprecated alias for 'tunnel start'"; + static description = "Deprecated alias for tunnel start."; + static usage = ["start"]; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + await this.parse(DeprecatedStartCommand); + this.logToStderr( + ` Deprecated: '${CLI_NAME} start' is now '${CLI_NAME} tunnel start'. See '${CLI_NAME} help'.`, + ); + await runStartCommand({ ...serviceDeps(), startAll }); + } +} + +export class DeprecatedStopCommand extends Command { + static id = "stop"; + static strict = true; + static summary = "Deprecated alias for 'tunnel stop'"; + static description = "Deprecated alias for tunnel stop."; + static usage = ["stop"]; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + await this.parse(DeprecatedStopCommand); + this.logToStderr( + ` Deprecated: '${CLI_NAME} stop' is now '${CLI_NAME} tunnel stop'. See '${CLI_NAME} help'.`, + ); + runStopCommand({ ...serviceDeps(), stopAll }); + } +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index b42a80a9576..d65b0fcbfa2 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -69,10 +69,8 @@ const { stripAnsi, versionGte, } = require("./lib/openshell"); -const { showStatusCommand } = require("./lib/inventory-commands"); const { runRegisteredOclifCommand } = require("./lib/oclif-runner"); const { executeDeploy } = require("./lib/deploy"); -const { runStartCommand, runStopCommand } = require("./lib/services-command"); const { buildVersionedUninstallUrl, runUninstallCommand } = require("./lib/uninstall-command"); const agentRuntime = require("../bin/lib/agent-runtime"); const sandboxVersion = require("./lib/sandbox-version"); @@ -1133,30 +1131,20 @@ async function deploy(instanceName: string): Promise { }); } -async function start() { - const { startAll } = require("./lib/services"); - await runStartCommand({ - listSandboxes: () => registry.listSandboxes(), - startAll, - }); +async function start(args: string[] = []): Promise { + await runOclif("start", args); } -function stop() { - const { stopAll } = require("./lib/services"); - runStopCommand({ - listSandboxes: () => registry.listSandboxes(), - stopAll, - }); +async function stop(args: string[] = []): Promise { + await runOclif("stop", args); } async function tunnel(args: string[]): Promise { const sub = args[0]; switch (sub) { case "start": - await start(); - return; case "stop": - stop(); + await runOclif(`tunnel:${sub}`, args.slice(1)); return; default: console.error(` Usage: ${CLI_NAME} tunnel `); @@ -1409,116 +1397,8 @@ async function credentialsCommand(args: string[]): Promise { process.exit(1); } -/** - * Inspect gateway logs for known Telegram conflict signatures without blocking - * the broader status command when the probe cannot run. - */ -function checkMessagingBridgeHealth(sandboxName: string, channels: string[]) { - // Only Telegram currently emits a recognizable conflict signature in the - // gateway log. Discord/Slack have similar single-consumer constraints but - // log differently; we can extend the regex when those patterns are known. - if (!Array.isArray(channels) || !channels.includes("telegram")) return []; - const { spawnSync } = require("child_process"); - const script = - 'tail -n 200 /tmp/gateway.log 2>/dev/null | grep -cE "getUpdates conflict|409[[:space:]:]+Conflict" || true'; - try { - const result = spawnSync( - getOpenshellBinary(), - ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-c", script], - { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }, - ); - const count = Number.parseInt((result.stdout || "").trim(), 10); - if (!Number.isFinite(count) || count === 0) return []; - return [{ channel: "telegram", conflicts: count }]; - } catch { - return []; - } -} - -function makeConflictProbe() { - // Upfront liveness check so we can distinguish "provider not attached" from - // "gateway unreachable". Without this, every non-zero `openshell provider - // get` collapses into "absent", and a transient gateway failure would - // persist messagingChannels: [] and permanently suppress future retries. - let gatewayAlive: boolean | null = null; - const isGatewayAlive = (): boolean => { - if (gatewayAlive === null) { - const result = captureOpenshell(["sandbox", "list"], { - ignoreError: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }); - gatewayAlive = result.status === 0; - } - return gatewayAlive; - }; - return { - providerExists: (name: string) => { - if (!isGatewayAlive()) return "error"; - const result = captureOpenshell(["provider", "get", name], { - ignoreError: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }); - return result.status === 0 ? "present" : "absent"; - }, - }; -} - -function backfillAndFindOverlaps() { - // Non-critical path: status must remain usable even if the gateway probe or - // registry write throws, so any failure yields an empty overlap list. - try { - const { backfillMessagingChannels, findAllOverlaps } = require("./lib/messaging-conflict"); - backfillMessagingChannels(registry, makeConflictProbe()); - return findAllOverlaps(registry); - } catch { - return []; - } -} - -/** - * Read a short tail of the gateway log for degraded messaging diagnostics. - */ -function readGatewayLog(sandboxName: string) { - const { spawnSync } = require("child_process"); - try { - const result = spawnSync( - getOpenshellBinary(), - [ - "sandbox", - "exec", - "-n", - sandboxName, - "--", - "sh", - "-c", - "tail -n 10 /tmp/gateway.log 2>/dev/null", - ], - { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }, - ); - const output = (result.stdout || "").trim(); - return output || null; - } catch { - return null; - } -} - -function showStatus() { - const { showStatus: showServiceStatus } = require("./lib/services"); - showStatusCommand({ - listSandboxes: () => registry.listSandboxes(), - getLiveInference: () => - parseGatewayInference( - captureOpenshell(["inference", "get"], { - ignoreError: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }).output, - ), - showServiceStatus, - checkMessagingBridgeHealth, - backfillAndFindOverlaps, - readGatewayLog, - log: console.log, - }); +async function showStatus(args: string[] = []): Promise { + await runOclif("status", args); } async function runOclif(commandId: string, args: string[] = []): Promise { @@ -4293,22 +4173,16 @@ const [cmd, ...args] = process.argv.slice(2); await deploy(args[0]); break; case "start": - console.error( - ` ${YW}Deprecated:${R} '${CLI_NAME} start' is now '${CLI_NAME} tunnel start'. See '${CLI_NAME} help'.`, - ); - await start(); + await start(args); break; case "stop": - console.error( - ` ${YW}Deprecated:${R} '${CLI_NAME} stop' is now '${CLI_NAME} tunnel stop'. See '${CLI_NAME} help'.`, - ); - stop(); + await stop(args); break; case "tunnel": await tunnel(args); break; case "status": - showStatus(); + await showStatus(args); break; case "debug": debug(args); diff --git a/test/cli.test.ts b/test/cli.test.ts index f2df15d2bdf..0b027b95446 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -354,6 +354,27 @@ describe("CLI dispatch", () => { expect(r.out.includes("See more help with --help")).toBeTruthy(); }); + it("status --help exits 0 and shows status usage", () => { + const r = run("status --help"); + expect(r.code).toBe(0); + expect(r.out).toContain("status"); + expect(r.out).toContain("Show sandbox list and service status"); + }); + + it("tunnel start --help exits 0 and shows tunnel usage", () => { + const r = run("tunnel start --help"); + expect(r.code).toBe(0); + expect(r.out).toContain("tunnel start"); + expect(r.out).toContain("Start the cloudflared public-URL tunnel"); + }); + + it("deprecated start --help exits 0 and shows alias usage", () => { + const r = run("start --help"); + expect(r.code).toBe(0); + expect(r.out).toContain("start"); + expect(r.out).toContain("Deprecated alias"); + }); + it("shows skill install help when --help follows install", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-skill-help-")); writeSandboxRegistry(home); From 3f748d64e61a91b568efc8c463cc7b3db588618f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 30 Apr 2026 14:03:20 -0700 Subject: [PATCH 04/17] refactor(cli): migrate debug uninstall and gateway-token to oclif Signed-off-by: Carlos Villela --- src/lib/debug-cli-command.ts | 80 +++++++++++++++++++++++++++ src/lib/gateway-token-cli-command.ts | 45 +++++++++++++++ src/lib/oclif-commands.ts | 6 ++ src/lib/uninstall-cli-command.ts | 32 +++++++++++ src/nemoclaw.ts | 83 ++++------------------------ test/cli.test.ts | 11 ++++ 6 files changed, 185 insertions(+), 72 deletions(-) create mode 100644 src/lib/debug-cli-command.ts create mode 100644 src/lib/gateway-token-cli-command.ts create mode 100644 src/lib/uninstall-cli-command.ts diff --git a/src/lib/debug-cli-command.ts b/src/lib/debug-cli-command.ts new file mode 100644 index 00000000000..99c8600f097 --- /dev/null +++ b/src/lib/debug-cli-command.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command } from "@oclif/core"; + +import type { CaptureOpenshellResult } from "./openshell"; +import type { RunDebugCommandDeps } from "./debug-command"; +import { CLI_NAME } from "./branding"; +import { runDebug } from "./debug"; +import { runDebugCommand } from "./debug-command"; +import { OPENSHELL_PROBE_TIMEOUT_MS } from "./openshell-timeouts"; +import { captureOpenshellCommand } from "./openshell"; +import { parseLiveSandboxNames } from "./runtime-recovery"; +import * as registry from "./registry"; +import { resolveOpenshell } from "./resolve-openshell"; + +const useColor = !process.env.NO_COLOR && !!process.stderr.isTTY; +const B = useColor ? "\x1b[1m" : ""; +const R = useColor ? "\x1b[0m" : ""; +const RD = useColor ? "\x1b[1;31m" : ""; + +function captureOpenshell(rootDir: string, args: string[]): CaptureOpenshellResult { + const openshell = resolveOpenshell(); + if (!openshell) { + return { status: 1, output: "" }; + } + return captureOpenshellCommand(openshell, args, { + cwd: rootDir, + ignoreError: true, + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }); +} + +function buildDebugCommandDeps(rootDir: string): RunDebugCommandDeps { + const getDefaultSandbox = (): string | undefined => { + const { defaultSandbox, sandboxes } = registry.listSandboxes(); + if (!defaultSandbox) return undefined; + if (!sandboxes.find((sandbox) => sandbox.name === defaultSandbox)) { + console.error( + `${RD}Warning:${R} default sandbox '${defaultSandbox}' is no longer in the registry.`, + ); + console.error( + ` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`, + ); + return undefined; + } + const liveList = captureOpenshell(rootDir, ["sandbox", "list"]); + if (liveList.status === 0 && !parseLiveSandboxNames(liveList.output).has(defaultSandbox)) { + console.error( + `${RD}Warning:${R} default sandbox '${defaultSandbox}' exists in the local registry but not in OpenShell.`, + ); + console.error( + ` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`, + ); + return undefined; + } + return defaultSandbox; + }; + + return { + getDefaultSandbox, + runDebug, + log: console.log, + error: console.error, + exit: (code: number) => process.exit(code), + }; +} + +export default class DebugCliCommand extends Command { + static id = "debug"; + static strict = false; + static summary = "Collect diagnostics for bug reports"; + static description = "Collect NemoClaw diagnostic information."; + static usage = ["debug [--quick] [--output FILE] [--sandbox NAME]"]; + + public async run(): Promise { + this.parsed = true; + runDebugCommand(this.argv, buildDebugCommandDeps(this.config.root)); + } +} diff --git a/src/lib/gateway-token-cli-command.ts b/src/lib/gateway-token-cli-command.ts new file mode 100644 index 00000000000..fd139639d24 --- /dev/null +++ b/src/lib/gateway-token-cli-command.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Command, Flags } from "@oclif/core"; + +import { runGatewayTokenCommand } from "./gateway-token-command"; + +const { fetchGatewayAuthTokenFromSandbox } = require("./onboard") as { + fetchGatewayAuthTokenFromSandbox: (sandboxName: string) => string | null; +}; + +export default class GatewayTokenCliCommand extends Command { + static id = "sandbox:gateway-token"; + static strict = true; + static summary = "Print the OpenClaw gateway auth token to stdout"; + static description = "Print the OpenClaw gateway auth token for a running sandbox to stdout."; + static usage = [" gateway-token [--quiet|-q]"]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + help: Flags.help({ char: "h" }), + quiet: Flags.boolean({ char: "q", description: "Suppress the stderr security warning" }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(GatewayTokenCliCommand); + // Suppress EPIPE traces when the consumer closes the pipe early + // (e.g. `... | head -c 0`). The token has already been written. + process.stdout.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE") process.exit(0); + }); + + const exitCode = runGatewayTokenCommand( + args.sandboxName, + { quiet: flags.quiet === true }, + { fetchToken: fetchGatewayAuthTokenFromSandbox }, + ); + if (exitCode !== 0) this.exit(exitCode); + } +} diff --git a/src/lib/oclif-commands.ts b/src/lib/oclif-commands.ts index 23cab599fcf..ec3004844da 100644 --- a/src/lib/oclif-commands.ts +++ b/src/lib/oclif-commands.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import DebugCliCommand from "./debug-cli-command"; +import GatewayTokenCliCommand from "./gateway-token-cli-command"; import ListCommand from "./list-command"; import StatusCommand from "./status-command"; import { @@ -9,12 +11,16 @@ import { TunnelStartCommand, TunnelStopCommand, } from "./tunnel-commands"; +import UninstallCliCommand from "./uninstall-cli-command"; export default { + debug: DebugCliCommand, list: ListCommand, status: StatusCommand, start: DeprecatedStartCommand, stop: DeprecatedStopCommand, + "sandbox:gateway-token": GatewayTokenCliCommand, "tunnel:start": TunnelStartCommand, "tunnel:stop": TunnelStopCommand, + uninstall: UninstallCliCommand, }; diff --git a/src/lib/uninstall-cli-command.ts b/src/lib/uninstall-cli-command.ts new file mode 100644 index 00000000000..dcd6264322d --- /dev/null +++ b/src/lib/uninstall-cli-command.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; + +import { Command } from "@oclif/core"; + +import { getVersion } from "./version"; +import { buildVersionedUninstallUrl, runUninstallCommand } from "./uninstall-command"; + +export default class UninstallCliCommand extends Command { + static id = "uninstall"; + static strict = false; + static summary = "Run uninstall.sh"; + static description = "Run the local uninstall.sh script; remote fallback is disabled."; + static usage = ["uninstall [flags]"]; + + public async run(): Promise { + this.parsed = true; + runUninstallCommand({ + args: this.argv, + rootDir: this.config.root, + currentDir: __dirname, + remoteScriptUrl: buildVersionedUninstallUrl(getVersion()), + env: process.env, + spawnSyncImpl: spawnSync, + log: console.log, + error: console.error, + exit: (code: number) => process.exit(code), + }); + } +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index d65b0fcbfa2..63fb3441f4f 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -38,14 +38,12 @@ const { } = require("./lib/docker"); const { resolveOpenshell } = require("./lib/resolve-openshell"); const { - fetchGatewayAuthTokenFromSandbox, startGatewayForRecovery, pruneKnownHostsEntries, hydrateCredentialEnv, isNonInteractive, } = require("./lib/onboard"); const { ensureOllamaAuthProxy } = require("./lib/onboard-ollama-proxy"); -const { parseGatewayTokenArgs, runGatewayTokenCommand } = require("./lib/gateway-token-command"); const { getCredential, prompt: askPrompt } = require("./lib/credentials"); const registry = require("./lib/registry"); import type { SandboxEntry } from "./lib/registry"; @@ -60,7 +58,6 @@ const onboardSession = require("./lib/onboard-session"); import type { Session } from "./lib/onboard-session"; const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); const { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG } = require("./lib/usage-notice"); -const { runDebugCommand } = require("./lib/debug-command"); const { runDeprecatedOnboardAliasCommand, runOnboardCommand } = require("./lib/onboard-command"); const { captureOpenshellCommand, @@ -71,7 +68,6 @@ const { } = require("./lib/openshell"); const { runRegisteredOclifCommand } = require("./lib/oclif-runner"); const { executeDeploy } = require("./lib/deploy"); -const { buildVersionedUninstallUrl, runUninstallCommand } = require("./lib/uninstall-command"); const agentRuntime = require("../bin/lib/agent-runtime"); const sandboxVersion = require("./lib/sandbox-version"); const sandboxState = require("./lib/sandbox-state"); @@ -137,7 +133,6 @@ type RecoveredSandboxMetadata = Partial< policyPresets?: string[] | null; }; -const REMOTE_UNINSTALL_URL = buildVersionedUninstallUrl(getVersion()); let OPENSHELL_BIN: string | null = null; const NEMOCLAW_GATEWAY_NAME = "nemoclaw"; const DASHBOARD_FORWARD_PORT = String(DASHBOARD_PORT); @@ -1152,56 +1147,12 @@ async function tunnel(args: string[]): Promise { } } -function debug(args: string[]) { - const { runDebug } = require("./lib/debug"); - const getDefaultSandbox = (): string | undefined => { - const { defaultSandbox, sandboxes } = registry.listSandboxes(); - if (!defaultSandbox) return undefined; - if (!sandboxes.find((s: { name: string }) => s.name === defaultSandbox)) { - console.error( - `${_RD}Warning:${R} default sandbox '${defaultSandbox}' is no longer in the registry.`, - ); - console.error( - ` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`, - ); - return undefined; - } - const liveList = captureOpenshell(["sandbox", "list"], { - ignoreError: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }); - if (liveList.status === 0 && !parseLiveSandboxNames(liveList.output).has(defaultSandbox)) { - console.error( - `${_RD}Warning:${R} default sandbox '${defaultSandbox}' exists in the local registry but not in OpenShell.`, - ); - console.error( - ` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`, - ); - return undefined; - } - return defaultSandbox; - }; - runDebugCommand(args, { - getDefaultSandbox, - runDebug, - log: console.log, - error: console.error, - exit: (code: number) => process.exit(code), - }); +async function debug(args: string[]): Promise { + await runOclif("debug", args); } -function uninstall(args: string[]) { - runUninstallCommand({ - args, - rootDir: ROOT, - currentDir: __dirname, - remoteScriptUrl: REMOTE_UNINSTALL_URL, - env: process.env, - spawnSyncImpl: spawnSync, - log: console.log, - error: console.error, - exit: (code: number) => process.exit(code), - }); +async function uninstall(args: string[]): Promise { + await runOclif("uninstall", args); } // Suffixes that mark a per-sandbox messaging integration in the gateway's @@ -4185,10 +4136,10 @@ const [cmd, ...args] = process.argv.slice(2); await showStatus(args); break; case "debug": - debug(args); + await debug(args); break; case "uninstall": - uninstall(args); + await uninstall(args); break; case "credentials": await credentialsCommand(args); @@ -4278,25 +4229,13 @@ const [cmd, ...args] = process.argv.slice(2); case "destroy": await sandboxDestroy(cmd, actionArgs); break; - case "gateway-token": { - const { options: gatewayTokenOpts, unknown: gatewayTokenUnknown } = - parseGatewayTokenArgs(actionArgs); - if (gatewayTokenUnknown.length > 0) { - console.error(` Unknown flag: ${gatewayTokenUnknown[0]}`); - console.error(` Usage: ${CLI_NAME} gateway-token [--quiet|-q]`); - process.exit(1); + case "gateway-token": + if (actionArgs.includes("--help") || actionArgs.includes("-h")) { + console.log(` Usage: ${CLI_NAME} gateway-token [--quiet|-q]`); + break; } - // Suppress EPIPE traces when the consumer closes the pipe early - // (e.g. `... | head -c 0`). The token has already been written. - process.stdout.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EPIPE") process.exit(0); - }); - const exitCode = runGatewayTokenCommand(cmd, gatewayTokenOpts, { - fetchToken: fetchGatewayAuthTokenFromSandbox, - }); - if (exitCode !== 0) process.exit(exitCode); + await runOclif("sandbox:gateway-token", [cmd, ...actionArgs]); break; - } case "skill": await sandboxSkillInstall(cmd, actionArgs); break; diff --git a/test/cli.test.ts b/test/cli.test.ts index 0b027b95446..921ce666b3b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -628,6 +628,17 @@ describe("CLI dispatch", () => { expect(r.out).toContain("Collecting diagnostics for sandbox 'mybox'"); }); + it("gateway-token help keeps the public sandbox-scoped usage", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-token-help-")); + writeSandboxRegistry(home); + + const r = runWithEnv("alpha gateway-token --help", { HOME: home }); + + expect(r.code).toBe(0); + expect(r.out).toContain("Usage: nemoclaw gateway-token [--quiet|-q]"); + expect(r.out).not.toContain("sandbox:gateway-token"); + }); + it("routes logs to OpenClaw and OpenShell log sources", () => { const setup = createLogsTestSetup("nemoclaw-cli-logs-routing-"); const r = setup.runLogs(); From 0bc877af3dde4ad88e7c4f503ce46cf316a065b5 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 30 Apr 2026 15:59:39 -0700 Subject: [PATCH 05/17] refactor(cli): migrate credentials commands to oclif --- src/lib/credentials-cli-command.ts | 205 +++++++++++++++++++++++++++++ src/lib/oclif-commands.ts | 8 ++ src/nemoclaw.ts | 196 ++------------------------- test/cli.test.ts | 22 ++++ 4 files changed, 248 insertions(+), 183 deletions(-) create mode 100644 src/lib/credentials-cli-command.ts diff --git a/src/lib/credentials-cli-command.ts b/src/lib/credentials-cli-command.ts new file mode 100644 index 00000000000..1e83f1a6ea5 --- /dev/null +++ b/src/lib/credentials-cli-command.ts @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Command, Flags } from "@oclif/core"; + +import { CLI_DISPLAY_NAME, CLI_NAME } from "./branding"; +import { prompt as askPrompt } from "./credentials"; + +interface SpawnLikeResult { + status: number | null; + stdout?: string | Buffer; + stderr?: string | Buffer; +} + +interface RuntimeBridge { + recoverNamedGatewayRuntime: () => Promise<{ recovered: boolean }>; + runOpenshell: ( + args: string[], + opts?: { ignoreError?: boolean; stdio?: import("node:child_process").StdioOptions }, + ) => SpawnLikeResult; +} + +// Suffixes that mark per-sandbox messaging integrations in the gateway's +// provider list. These are managed by `channels`, not `credentials`. +const BRIDGE_PROVIDER_SUFFIXES: readonly string[] = [ + "-telegram-bridge", + "-discord-bridge", + "-slack-bridge", + "-slack-app", +]; + +function getRuntimeBridge(): RuntimeBridge { + return require("../nemoclaw") as RuntimeBridge; +} + +function isBridgeProviderName(name: string): boolean { + return BRIDGE_PROVIDER_SUFFIXES.some((suffix) => name.endsWith(suffix)); +} + +function printCredentialsUsage(log: (message?: string) => void = console.log): void { + log(""); + log(` Usage: ${CLI_NAME} credentials `); + log(""); + log(" Subcommands:"); + log(" list List provider credentials registered with the OpenShell gateway"); + log(" reset [--yes] Remove a provider credential so onboard re-prompts"); + log(""); + log(" Credentials live in the OpenShell gateway. Inspect with `openshell provider list`."); + log(" Nothing is persisted to host disk; deploy/non-onboard commands read from env vars."); + log(""); +} + +async function recoverGatewayOrExit(kind: "query" | "reach"): Promise { + const runtime = getRuntimeBridge(); + const recovery = await runtime.recoverNamedGatewayRuntime(); + if (recovery.recovered) return; + + if (kind === "query") { + console.error(` Could not query the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`); + } else { + console.error(` Could not reach the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`); + } + console.error(` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`); + process.exit(1); +} + +export class CredentialsCommand extends Command { + static id = "credentials"; + static strict = true; + static summary = "Manage provider credentials"; + static description = + "List or reset provider credentials registered with the OpenShell gateway."; + static usage = ["credentials "]; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + await this.parse(CredentialsCommand); + printCredentialsUsage(this.log.bind(this)); + } +} + +export class CredentialsListCommand extends Command { + static id = "credentials:list"; + static strict = true; + static summary = "List stored credential providers"; + static description = "List provider credentials registered with the OpenShell gateway."; + static usage = ["credentials list"]; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + await this.parse(CredentialsListCommand); + await recoverGatewayOrExit("query"); + + const runtime = getRuntimeBridge(); + const result = runtime.runOpenshell(["provider", "list", "--names"], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + console.error(" Could not query OpenShell gateway. Is it running?"); + console.error(` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`); + process.exit(1); + } + + const allNames = String(result.stdout || "") + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + const credentialNames = allNames.filter((name) => !isBridgeProviderName(name)).sort(); + const bridgeNames = allNames.filter((name) => isBridgeProviderName(name)); + + if (credentialNames.length === 0) { + this.log(" No provider credentials registered."); + } else { + this.log(" Providers registered with the OpenShell gateway:"); + for (const name of credentialNames) { + this.log(` ${name}`); + } + } + if (bridgeNames.length > 0) { + this.log(""); + this.log(` ${String(bridgeNames.length)} per-sandbox messaging bridge(s) are also registered.`); + this.log(` Manage those with \`${CLI_NAME} channels list/remove/stop\` — not this command.`); + } + } +} + +export class CredentialsResetCommand extends Command { + static id = "credentials:reset"; + static strict = true; + static summary = "Remove a provider credential"; + static description = "Remove a provider credential so onboard re-prompts for it."; + static usage = ["credentials reset [--yes]"]; + static args = { + provider: Args.string({ + name: "PROVIDER", + description: "OpenShell provider name", + required: false, + }), + }; + static flags = { + help: Flags.help({ char: "h" }), + yes: Flags.boolean({ char: "y", description: "Skip the confirmation prompt" }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(CredentialsResetCommand); + const key = args.provider; + + if (!key || key.startsWith("-")) { + console.error(` Usage: ${CLI_NAME} credentials reset [--yes]`); + console.error(` PROVIDER is an OpenShell provider name. Run '${CLI_NAME} credentials list' first.`); + process.exit(1); + } + + if (isBridgeProviderName(key)) { + console.error(` '${key}' is a per-sandbox messaging bridge, not a credential.`); + console.error( + ` Use \`${CLI_NAME} channels remove \` to retire`, + ); + console.error(" the integration (it tears down the bridge provider and rebuilds the sandbox),"); + console.error(` or \`${CLI_NAME} channels stop <…>\` to pause it without clearing tokens.`); + process.exit(1); + } + + if (!flags.yes) { + const answer = (await askPrompt(` Remove provider '${key}' from the OpenShell gateway? [y/N]: `)) + .trim() + .toLowerCase(); + if (answer !== "y" && answer !== "yes") { + this.log(" Cancelled."); + return; + } + } + + await recoverGatewayOrExit("reach"); + + const runtime = getRuntimeBridge(); + const result = runtime.runOpenshell(["provider", "delete", key], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status === 0) { + this.log(` Removed provider '${key}' from the OpenShell gateway.`); + this.log(` Re-run '${CLI_NAME} onboard' to enter a new value.`); + return; + } + + console.error(` Could not remove provider '${key}'.`); + if (/^[A-Z][A-Z0-9_]+$/.test(key)) { + console.error(""); + console.error(` '${key}' looks like a credential env variable name.`); + console.error(" As of this release, 'credentials reset' takes an OpenShell"); + console.error(` provider name. Run '${CLI_NAME} credentials list' to see the`); + console.error(" registered providers, then retry with one of those names."); + } + const stderr = String(result.stderr || "").trim(); + if (stderr) console.error(` ${stderr}`); + process.exit(1); + } +} diff --git a/src/lib/oclif-commands.ts b/src/lib/oclif-commands.ts index ec3004844da..291e18eaf8c 100644 --- a/src/lib/oclif-commands.ts +++ b/src/lib/oclif-commands.ts @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + CredentialsCommand, + CredentialsListCommand, + CredentialsResetCommand, +} from "./credentials-cli-command"; import DebugCliCommand from "./debug-cli-command"; import GatewayTokenCliCommand from "./gateway-token-cli-command"; import ListCommand from "./list-command"; @@ -14,6 +19,9 @@ import { import UninstallCliCommand from "./uninstall-cli-command"; export default { + credentials: CredentialsCommand, + "credentials:list": CredentialsListCommand, + "credentials:reset": CredentialsResetCommand, debug: DebugCliCommand, list: ListCommand, status: StatusCommand, diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 63fb3441f4f..90c4494d4be 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -570,7 +570,9 @@ async function recoverRegistryEntries({ } exports.captureOpenshell = captureOpenshell; +exports.recoverNamedGatewayRuntime = recoverNamedGatewayRuntime; exports.recoverRegistryEntries = recoverRegistryEntries; +exports.runOpenshell = runOpenshell; function hasNamedGateway(output = ""): boolean { return stripAnsi(output).includes("Gateway: nemoclaw"); @@ -1155,197 +1157,25 @@ async function uninstall(args: string[]): Promise { await runOclif("uninstall", args); } -// Suffixes that mark a per-sandbox messaging integration in the gateway's -// provider list, not a NemoClaw-managed credential. The bridge providers are -// created during onboarding (see src/lib/onboard.ts:3203,3208,3218) and torn -// down by the channels/sandbox-delete flows. `nemoclaw credentials list` -// hides them and `nemoclaw credentials reset` refuses to touch them so -// users cannot accidentally break a live integration via the credentials -// surface. -const BRIDGE_PROVIDER_SUFFIXES: readonly string[] = [ - "-telegram-bridge", - "-discord-bridge", - "-slack-bridge", - // Slack registers a second provider for the App-Level Token (used for - // Socket Mode). bridgeProviderName() emits `${sandbox}-slack-app` for - // SLACK_APP_TOKEN, so the guardrails must match that suffix too — - // otherwise the slack-app provider shows up as an ordinary credential - // and `credentials reset` would happily delete it. - "-slack-app", -]; - -function isBridgeProviderName(name: string): boolean { - return BRIDGE_PROVIDER_SUFFIXES.some((suffix) => name.endsWith(suffix)); -} - async function credentialsCommand(args: string[]): Promise { const sub = args[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { - console.log(""); - console.log(` Usage: ${CLI_NAME} credentials `); - console.log(""); - console.log(" Subcommands:"); - console.log( - " list List provider credentials registered with the OpenShell gateway", - ); - console.log( - " reset [--yes] Remove a provider credential so onboard re-prompts", - ); - console.log(""); - console.log( - " Credentials live in the OpenShell gateway. Inspect with `openshell provider list`.", - ); - console.log( - " Nothing is persisted to host disk; deploy/non-onboard commands read from env vars.", - ); - console.log(""); - return; - } - - if (sub === "list") { - // Pin to the NemoClaw gateway so a different active gateway cannot make - // us list (or later delete) providers from the wrong place. - const recovery = await recoverNamedGatewayRuntime(); - if (!recovery.recovered) { - console.error(` Could not query the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`); - console.error( - ` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`, - ); - process.exit(1); - } - const result = runOpenshell(["provider", "list", "--names"], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.status !== 0) { - console.error(" Could not query OpenShell gateway. Is it running?"); - console.error( - ` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`, - ); - process.exit(1); - } - const allNames = String(result.stdout || "") - .split("\n") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - // Show only credential providers. Per-sandbox messaging bridges are - // live integrations managed by the channels surface; surfacing them - // here would invite users to "reset" what looks like a credential and - // accidentally destroy a running bridge. - const credentialNames = allNames.filter((n) => !isBridgeProviderName(n)).sort(); - const bridgeNames = allNames.filter((n) => isBridgeProviderName(n)); - if (credentialNames.length === 0) { - console.log(" No provider credentials registered."); - } else { - console.log(" Providers registered with the OpenShell gateway:"); - for (const name of credentialNames) { - console.log(` ${name}`); - } - } - if (bridgeNames.length > 0) { - console.log(""); - console.log( - ` ${String(bridgeNames.length)} per-sandbox messaging bridge(s) are also registered.`, - ); - console.log( - ` Manage those with \`${CLI_NAME} channels list/remove/stop\` — not this command.`, - ); - } + await runOclif("credentials", []); return; } - if (sub === "reset") { - const key = args[1]; - // Validate that is a real positional argument, not a flag like - // `--yes` that the user passed without a key. - if (!key || key.startsWith("-")) { - console.error(` Usage: ${CLI_NAME} credentials reset [--yes]`); - console.error( - ` PROVIDER is an OpenShell provider name. Run '${CLI_NAME} credentials list' first.`, - ); - process.exit(1); - } - // Reject unknown trailing arguments to keep scripted use predictable. - const extraArgs = args.slice(2).filter((arg) => arg !== "--yes" && arg !== "-y"); - if (extraArgs.length > 0) { - console.error(` Unknown argument(s) for credentials reset: ${extraArgs.join(", ")}`); - console.error(` Usage: ${CLI_NAME} credentials reset [--yes]`); - process.exit(1); - } - // Refuse to delete a per-sandbox messaging bridge — those are live - // integrations created/destroyed by the channels surface, not - // NemoClaw-managed credentials. Without this guard, scripting against - // the gateway provider list could tear down a running bridge and - // leave the sandbox in a half-configured state. - if (isBridgeProviderName(key)) { - console.error(` '${key}' is a per-sandbox messaging bridge, not a credential.`); - console.error( - ` Use \`${CLI_NAME} channels remove \` to retire`, - ); - console.error( - " the integration (it tears down the bridge provider and rebuilds the sandbox),", - ); - console.error( - ` or \`${CLI_NAME} channels stop <…>\` to pause it without clearing tokens.`, - ); - process.exit(1); - } - const skipPrompt = args.includes("--yes") || args.includes("-y"); - if (!skipPrompt) { - const answer = ( - await askPrompt(` Remove provider '${key}' from the OpenShell gateway? [y/N]: `) - ) - .trim() - .toLowerCase(); - if (answer !== "y" && answer !== "yes") { - console.log(" Cancelled."); - return; - } - } - // Pin to the NemoClaw gateway so we cannot accidentally delete a - // provider from a different active gateway. We deliberately do NOT - // touch process.env here — `key` is an OpenShell provider name, and - // calling deleteCredential on it would silently strip an unrelated - // env entry whenever a provider name happens to share the shape of - // a credential env variable. - const recovery = await recoverNamedGatewayRuntime(); - if (!recovery.recovered) { - console.error(` Could not reach the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`); - console.error( - ` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`, - ); - process.exit(1); - } - const result = runOpenshell(["provider", "delete", key], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.status === 0) { - console.log(` Removed provider '${key}' from the OpenShell gateway.`); - console.log(` Re-run '${CLI_NAME} onboard' to enter a new value.`); - } else { - console.error(` Could not remove provider '${key}'.`); - // Earlier releases accepted a credential env-var name (e.g. - // NVIDIA_API_KEY) here; the API now takes an OpenShell provider - // name (nvidia-prod, openai-api, telegram-bridge, …). Surface the - // rename to anyone whose script is still passing the old shape. - if (/^[A-Z][A-Z0-9_]+$/.test(key)) { - console.error(""); - console.error(` '${key}' looks like a credential env variable name.`); - console.error(" As of this release, 'credentials reset' takes an OpenShell"); - console.error(` provider name. Run '${CLI_NAME} credentials list' to see the`); - console.error(" registered providers, then retry with one of those names."); - } - const stderr = String(result.stderr || "").trim(); - if (stderr) console.error(` ${stderr}`); + switch (sub) { + case "list": + await runOclif("credentials:list", args.slice(1)); + return; + case "reset": + await runOclif("credentials:reset", args.slice(1)); + return; + default: + console.error(` Unknown credentials subcommand: ${sub}`); + console.error(` Run '${CLI_NAME} credentials help' for usage.`); process.exit(1); - } - return; } - - console.error(` Unknown credentials subcommand: ${sub}`); - console.error(` Run '${CLI_NAME} credentials help' for usage.`); - process.exit(1); } async function showStatus(args: string[] = []): Promise { diff --git a/test/cli.test.ts b/test/cli.test.ts index 921ce666b3b..1d814f1975d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -375,6 +375,28 @@ describe("CLI dispatch", () => { expect(r.out).toContain("Deprecated alias"); }); + it("credentials help exits 0 and shows credential subcommands", () => { + const r = run("credentials --help"); + expect(r.code).toBe(0); + expect(r.out).toContain("Usage: nemoclaw credentials "); + expect(r.out).toContain("list"); + expect(r.out).toContain("reset [--yes]"); + }); + + it("credentials list --help exits 0 and shows list usage", () => { + const r = run("credentials list --help"); + expect(r.code).toBe(0); + expect(r.out).toContain("credentials list"); + expect(r.out).toContain("List provider credentials"); + }); + + it("credentials reset without provider keeps provider-specific usage", () => { + const r = run("credentials reset --yes"); + expect(r.code).toBe(1); + expect(r.out).toContain("Usage: nemoclaw credentials reset [--yes]"); + expect(r.out).toContain("PROVIDER is an OpenShell provider name"); + }); + it("shows skill install help when --help follows install", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-skill-help-")); writeSandboxRegistry(home); From 3fd15fcdaf47c736d82f0270314dad0245d09144 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 30 Apr 2026 16:08:14 -0700 Subject: [PATCH 06/17] refactor(cli): migrate sandbox inspection commands to oclif --- src/lib/oclif-commands.ts | 10 ++ src/lib/oclif-runner.ts | 2 +- src/lib/sandbox-inspection-cli-command.ts | 113 ++++++++++++++++++++++ src/nemoclaw.ts | 84 ++++++++-------- test/cli.test.ts | 43 ++++++-- 5 files changed, 200 insertions(+), 52 deletions(-) create mode 100644 src/lib/sandbox-inspection-cli-command.ts diff --git a/src/lib/oclif-commands.ts b/src/lib/oclif-commands.ts index 291e18eaf8c..9bbaf2cd769 100644 --- a/src/lib/oclif-commands.ts +++ b/src/lib/oclif-commands.ts @@ -9,6 +9,12 @@ import { import DebugCliCommand from "./debug-cli-command"; import GatewayTokenCliCommand from "./gateway-token-cli-command"; import ListCommand from "./list-command"; +import { + SandboxChannelsListCommand, + SandboxConfigGetCommand, + SandboxPolicyListCommand, + SandboxStatusCommand, +} from "./sandbox-inspection-cli-command"; import StatusCommand from "./status-command"; import { DeprecatedStartCommand, @@ -24,6 +30,10 @@ export default { "credentials:reset": CredentialsResetCommand, debug: DebugCliCommand, list: ListCommand, + "sandbox:channels:list": SandboxChannelsListCommand, + "sandbox:config:get": SandboxConfigGetCommand, + "sandbox:policy-list": SandboxPolicyListCommand, + "sandbox:status": SandboxStatusCommand, status: StatusCommand, start: DeprecatedStartCommand, stop: DeprecatedStopCommand, diff --git a/src/lib/oclif-runner.ts b/src/lib/oclif-runner.ts index f22a8431441..d8398f2c58d 100644 --- a/src/lib/oclif-runner.ts +++ b/src/lib/oclif-runner.ts @@ -25,7 +25,7 @@ function isOclifParseError(error: unknown): boolean { error && typeof error === "object" ? (error as { constructor?: { name?: string } }).constructor?.name : ""; - return name === "NonExistentFlagsError" || name === "UnexpectedArgsError"; + return name === "NonExistentFlagsError" || name === "UnexpectedArgsError" || name === "CLIError"; } function formatOclifError(error: unknown): string { diff --git a/src/lib/sandbox-inspection-cli-command.ts b/src/lib/sandbox-inspection-cli-command.ts new file mode 100644 index 00000000000..a75296e1592 --- /dev/null +++ b/src/lib/sandbox-inspection-cli-command.ts @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Command, Flags } from "@oclif/core"; + +import { CLI_NAME } from "./branding"; +import * as sandboxConfig from "./sandbox-config"; + +type RuntimeBridge = { + sandboxChannelsList: (sandboxName: string) => void; + sandboxPolicyList: (sandboxName: string) => void; + sandboxStatus: (sandboxName: string) => Promise; +}; + +function getRuntimeBridge(): RuntimeBridge { + return require("../nemoclaw") as RuntimeBridge; +} + +const sandboxNameArg = Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, +}); + +export class SandboxStatusCommand extends Command { + static id = "sandbox:status"; + static strict = true; + static summary = "Sandbox health and NIM status"; + static description = "Show sandbox health, OpenShell gateway state, and local NIM status."; + static usage = [" status"]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + const { args } = await this.parse(SandboxStatusCommand); + await getRuntimeBridge().sandboxStatus(args.sandboxName); + } +} + +export class SandboxPolicyListCommand extends Command { + static id = "sandbox:policy-list"; + static strict = true; + static summary = "List policy presets"; + static description = "List built-in and custom policy presets and show which are applied."; + static usage = [" policy-list"]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + const { args } = await this.parse(SandboxPolicyListCommand); + getRuntimeBridge().sandboxPolicyList(args.sandboxName); + } +} + +export class SandboxChannelsListCommand extends Command { + static id = "sandbox:channels:list"; + static strict = true; + static summary = "List supported messaging channels"; + static description = "List supported messaging channels for a sandbox."; + static usage = [" channels list"]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + const { args } = await this.parse(SandboxChannelsListCommand); + getRuntimeBridge().sandboxChannelsList(args.sandboxName); + } +} + +export class SandboxConfigGetCommand extends Command { + static id = "sandbox:config:get"; + static strict = true; + static summary = "Get sandbox configuration"; + static description = "Read sanitized sandbox agent configuration."; + static usage = [" config get [--key dotpath] [--format json|yaml]"]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + help: Flags.help({ char: "h" }), + key: Flags.string({ description: "Dotpath to read from the sanitized config" }), + format: Flags.string({ description: "Output format (json or yaml)" }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxConfigGetCommand); + if (flags.format && flags.format !== "json" && flags.format !== "yaml") { + console.error(` Unknown format: ${flags.format}. Use json or yaml.`); + process.exit(1); + } + sandboxConfig.configGet(args.sandboxName, { + key: flags.key ?? null, + format: flags.format ?? "json", + }); + } +} + +export function printConfigUsageAndExit(): never { + console.error(` Usage: ${CLI_NAME} config get [--key dotpath] [--format json|yaml]`); + process.exit(1); +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 90c4494d4be..0a992cd82a9 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -50,7 +50,6 @@ import type { SandboxEntry } from "./lib/registry"; const nim = require("./lib/nim"); const policies = require("./lib/policies"); const shields = require("./lib/shields"); -const sandboxConfig = require("./lib/sandbox-config"); const { parseGatewayInference } = require("./lib/inference-config"); const { probeProviderHealth } = require("./lib/inference-health"); const { getVersion } = require("./lib/version"); @@ -573,6 +572,9 @@ exports.captureOpenshell = captureOpenshell; exports.recoverNamedGatewayRuntime = recoverNamedGatewayRuntime; exports.recoverRegistryEntries = recoverRegistryEntries; exports.runOpenshell = runOpenshell; +exports.sandboxChannelsList = sandboxChannelsList; +exports.sandboxPolicyList = sandboxPolicyList; +exports.sandboxStatus = sandboxStatus; function hasNamedGateway(output = ""): boolean { return stripAnsi(output).includes("Gateway: nemoclaw"); @@ -1194,6 +1196,14 @@ async function listSandboxes(args: string[] = []): Promise { await runOclif("list", args); } +function hasHelpFlag(args: string[]): boolean { + return args.includes("--help") || args.includes("-h"); +} + +function printSandboxActionUsage(action: string): void { + console.log(` Usage: ${CLI_NAME} ${action}`); +} + // ── Sandbox-scoped actions ─────────────────────────────────────── async function sandboxConnect(sandboxName: string) { @@ -4042,7 +4052,11 @@ const [cmd, ...args] = process.argv.slice(2); await sandboxConnect(cmd); break; case "status": - await sandboxStatus(cmd); + if (hasHelpFlag(actionArgs)) { + printSandboxActionUsage("status"); + break; + } + await runOclif("sandbox:status", [cmd, ...actionArgs]); break; case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); @@ -4054,7 +4068,11 @@ const [cmd, ...args] = process.argv.slice(2); await sandboxPolicyRemove(cmd, actionArgs); break; case "policy-list": - sandboxPolicyList(cmd); + if (hasHelpFlag(actionArgs)) { + printSandboxActionUsage("policy-list"); + break; + } + await runOclif("sandbox:policy-list", [cmd, ...actionArgs]); break; case "destroy": await sandboxDestroy(cmd, actionArgs); @@ -4134,9 +4152,15 @@ const [cmd, ...args] = process.argv.slice(2); const channelsArgs = actionArgs.slice(1); switch (channelsSub) { case "list": + if (hasHelpFlag(channelsArgs)) { + printSandboxActionUsage("channels list"); + break; + } + await runOclif("sandbox:channels:list", [cmd, ...channelsArgs]); + break; case undefined: case "": - sandboxChannelsList(cmd); + await runOclif("sandbox:channels:list", [cmd]); break; case "add": await sandboxChannelsAdd(cmd, channelsArgs); @@ -4150,6 +4174,10 @@ const [cmd, ...args] = process.argv.slice(2); case "start": await sandboxChannelsStart(cmd, channelsArgs); break; + case "--help": + case "-h": + printSandboxActionUsage("channels list"); + break; default: console.error(` Unknown channels subcommand: ${channelsSub}`); console.error( @@ -4167,47 +4195,17 @@ const [cmd, ...args] = process.argv.slice(2); case "config": { const configSub = actionArgs[0]; switch (configSub) { - case "get": { - const configOpts: { key: string | null; format: string } = { - key: null, - format: "json", - }; - for (let i = 1; i < actionArgs.length; i++) { - const flag = actionArgs[i]; - if (flag === "--key") { - if (i + 1 >= actionArgs.length || actionArgs[i + 1].startsWith("--")) { - console.error(" --key requires a value."); - console.error( - ` Usage: ${CLI_NAME} config get [--key dotpath] [--format json|yaml]`, - ); - process.exit(1); - } - configOpts.key = actionArgs[++i]; - } else if (flag === "--format") { - if (i + 1 >= actionArgs.length || actionArgs[i + 1].startsWith("--")) { - console.error(" --format requires a value (json|yaml)."); - console.error( - ` Usage: ${CLI_NAME} config get [--key dotpath] [--format json|yaml]`, - ); - process.exit(1); - } - const format = actionArgs[++i]; - if (format !== "json" && format !== "yaml") { - console.error(` Unknown format: ${format}. Use json or yaml.`); - process.exit(1); - } - configOpts.format = format; - } else { - console.error(` Unknown flag: ${flag}`); - console.error( - ` Usage: ${CLI_NAME} config get [--key dotpath] [--format json|yaml]`, - ); - process.exit(1); - } + case "get": + if (hasHelpFlag(actionArgs.slice(1))) { + printSandboxActionUsage("config get [--key dotpath] [--format json|yaml]"); + break; } - sandboxConfig.configGet(cmd, configOpts); + await runOclif("sandbox:config:get", [cmd, ...actionArgs.slice(1)]); + break; + case "--help": + case "-h": + printSandboxActionUsage("config get [--key dotpath] [--format json|yaml]"); break; - } default: console.error( ` Usage: ${CLI_NAME} config get [--key dotpath] [--format json|yaml]`, diff --git a/test/cli.test.ts b/test/cli.test.ts index 1d814f1975d..f5541bb7583 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -187,14 +187,16 @@ function createDebugCommandTestEnv(prefix: string): Record { describe("CLI dispatch", () => { it("config get validates flags and values before dispatch", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), "utf-8"); - const configGet = src.match(/case "get": \{([\s\S]*?)sandboxConfig\.configGet\(cmd, configOpts\);/); - expect(configGet).toBeTruthy(); - expect(configGet![1]).toContain("--key requires a value"); - expect(configGet![1]).toContain("--format requires a value"); - expect(configGet![1]).toContain("Unknown format"); - expect(configGet![1]).toContain("Unknown flag"); - expect(configGet![1]).toContain('format !== "json" && format !== "yaml"'); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-config-flags-")); + writeSandboxRegistry(home); + + const invalidFormat = runWithEnv("alpha config get --format xml", { HOME: home }); + expect(invalidFormat.code).toBe(1); + expect(invalidFormat.out).toContain("Unknown format: xml. Use json or yaml."); + + const missingKey = runWithEnv("alpha config get --key", { HOME: home }); + expect(missingKey.code).not.toBe(0); + expect(missingKey.out).toContain("Flag --key expects a value"); }); it("help exits 0 and shows sections", () => { @@ -661,6 +663,31 @@ describe("CLI dispatch", () => { expect(r.out).not.toContain("sandbox:gateway-token"); }); + it("sandbox inspection help keeps public sandbox-scoped usage", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-inspection-help-")); + writeSandboxRegistry(home); + + const status = runWithEnv("alpha status --help", { HOME: home }); + expect(status.code).toBe(0); + expect(status.out).toContain(" status"); + expect(status.out).not.toContain("sandbox:status"); + + const policy = runWithEnv("alpha policy-list --help", { HOME: home }); + expect(policy.code).toBe(0); + expect(policy.out).toContain(" policy-list"); + expect(policy.out).not.toContain("sandbox:policy-list"); + + const channels = runWithEnv("alpha channels list --help", { HOME: home }); + expect(channels.code).toBe(0); + expect(channels.out).toContain(" channels list"); + expect(channels.out).not.toContain("sandbox:channels:list"); + + const config = runWithEnv("alpha config get --help", { HOME: home }); + expect(config.code).toBe(0); + expect(config.out).toContain(" config get"); + expect(config.out).not.toContain("sandbox:config:get"); + }); + it("routes logs to OpenClaw and OpenShell log sources", () => { const setup = createLogsTestSetup("nemoclaw-cli-logs-routing-"); const r = setup.runLogs(); From 81c62bbfe63de124dba3d8e4057a8f3745182491 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 30 Apr 2026 16:19:45 -0700 Subject: [PATCH 07/17] refactor(cli): migrate maintenance commands to oclif --- src/lib/maintenance-cli-commands.ts | 76 +++++++++++++++++++++++++++++ src/lib/oclif-commands.ts | 8 +++ src/nemoclaw.ts | 9 ++-- test/cli.test.ts | 40 +++++++++++++++ 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/lib/maintenance-cli-commands.ts diff --git a/src/lib/maintenance-cli-commands.ts b/src/lib/maintenance-cli-commands.ts new file mode 100644 index 00000000000..40b6f0ce74e --- /dev/null +++ b/src/lib/maintenance-cli-commands.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command, Flags } from "@oclif/core"; + +type RuntimeBridge = { + backupAll: () => void; + garbageCollectImages: (args?: string[]) => Promise; + upgradeSandboxes: (args?: string[]) => Promise; +}; + +function getRuntimeBridge(): RuntimeBridge { + return require("../nemoclaw") as RuntimeBridge; +} + +export class BackupAllCommand extends Command { + static id = "backup-all"; + static strict = true; + static summary = "Back up all sandbox state before upgrade"; + static description = "Back up registered, running sandbox state before upgrading."; + static usage = ["backup-all"]; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + await this.parse(BackupAllCommand); + getRuntimeBridge().backupAll(); + } +} + +export class UpgradeSandboxesCommand extends Command { + static id = "upgrade-sandboxes"; + static strict = true; + static summary = "Detect and rebuild stale sandboxes"; + static description = "Detect stale sandboxes and optionally rebuild them."; + static usage = ["upgrade-sandboxes [--check] [--auto] [--yes]"]; + static flags = { + help: Flags.help({ char: "h" }), + check: Flags.boolean({ description: "Only check whether sandboxes need upgrading" }), + auto: Flags.boolean({ description: "Automatically rebuild running stale sandboxes" }), + yes: Flags.boolean({ description: "Skip confirmation prompts" }), + }; + + public async run(): Promise { + const { flags } = await this.parse(UpgradeSandboxesCommand); + const args: string[] = []; + if (flags.check) args.push("--check"); + if (flags.auto) args.push("--auto"); + if (flags.yes) args.push("--yes"); + await getRuntimeBridge().upgradeSandboxes(args); + } +} + +export class GarbageCollectImagesCommand extends Command { + static id = "gc"; + static strict = true; + static summary = "Remove orphaned sandbox Docker images"; + static description = "Remove sandbox Docker images that are not referenced by registered sandboxes."; + static usage = ["gc [--dry-run] [--yes|--force]"]; + static flags = { + help: Flags.help({ char: "h" }), + "dry-run": Flags.boolean({ description: "Show images that would be removed without deleting" }), + yes: Flags.boolean({ description: "Skip the confirmation prompt" }), + force: Flags.boolean({ description: "Skip the confirmation prompt" }), + }; + + public async run(): Promise { + const { flags } = await this.parse(GarbageCollectImagesCommand); + const args: string[] = []; + if (flags["dry-run"]) args.push("--dry-run"); + if (flags.yes) args.push("--yes"); + if (flags.force) args.push("--force"); + await getRuntimeBridge().garbageCollectImages(args); + } +} diff --git a/src/lib/oclif-commands.ts b/src/lib/oclif-commands.ts index 9bbaf2cd769..379f72b33b1 100644 --- a/src/lib/oclif-commands.ts +++ b/src/lib/oclif-commands.ts @@ -9,6 +9,11 @@ import { import DebugCliCommand from "./debug-cli-command"; import GatewayTokenCliCommand from "./gateway-token-cli-command"; import ListCommand from "./list-command"; +import { + BackupAllCommand, + GarbageCollectImagesCommand, + UpgradeSandboxesCommand, +} from "./maintenance-cli-commands"; import { SandboxChannelsListCommand, SandboxConfigGetCommand, @@ -25,6 +30,7 @@ import { import UninstallCliCommand from "./uninstall-cli-command"; export default { + "backup-all": BackupAllCommand, credentials: CredentialsCommand, "credentials:list": CredentialsListCommand, "credentials:reset": CredentialsResetCommand, @@ -40,5 +46,7 @@ export default { "sandbox:gateway-token": GatewayTokenCliCommand, "tunnel:start": TunnelStartCommand, "tunnel:stop": TunnelStopCommand, + gc: GarbageCollectImagesCommand, uninstall: UninstallCliCommand, + "upgrade-sandboxes": UpgradeSandboxesCommand, }; diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 0a992cd82a9..f3e369bf1b0 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -569,12 +569,15 @@ async function recoverRegistryEntries({ } exports.captureOpenshell = captureOpenshell; +exports.backupAll = backupAll; +exports.garbageCollectImages = garbageCollectImages; exports.recoverNamedGatewayRuntime = recoverNamedGatewayRuntime; exports.recoverRegistryEntries = recoverRegistryEntries; exports.runOpenshell = runOpenshell; exports.sandboxChannelsList = sandboxChannelsList; exports.sandboxPolicyList = sandboxPolicyList; exports.sandboxStatus = sandboxStatus; +exports.upgradeSandboxes = upgradeSandboxes; function hasNamedGateway(output = ""): boolean { return stripAnsi(output).includes("Gateway: nemoclaw"); @@ -3988,13 +3991,13 @@ const [cmd, ...args] = process.argv.slice(2); await listSandboxes(args); break; case "backup-all": - backupAll(); + await runOclif("backup-all", args); break; case "upgrade-sandboxes": - await upgradeSandboxes(args); + await runOclif("upgrade-sandboxes", args); break; case "gc": - await garbageCollectImages(args); + await runOclif("gc", args); break; case "--version": case "-v": { diff --git a/test/cli.test.ts b/test/cli.test.ts index f5541bb7583..0c03376b08b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -399,6 +399,46 @@ describe("CLI dispatch", () => { expect(r.out).toContain("PROVIDER is an OpenShell provider name"); }); + it("maintenance command help exits 0 and shows migrated usage", () => { + const backup = run("backup-all --help"); + expect(backup.code).toBe(0); + expect(backup.out).toContain("backup-all"); + expect(backup.out).toContain("Back up all sandbox state before upgrade"); + + const upgrade = run("upgrade-sandboxes --help"); + expect(upgrade.code).toBe(0); + expect(upgrade.out).toContain("upgrade-sandboxes [--check] [--auto] [--yes]"); + expect(upgrade.out).toContain("Detect and rebuild stale sandboxes"); + + const gc = run("gc --help"); + expect(gc.code).toBe(0); + expect(gc.out).toContain("gc [--dry-run] [--yes|--force]"); + expect(gc.out).toContain("Remove orphaned sandbox Docker images"); + }); + + it("maintenance commands dispatch through oclif", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-maintenance-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + fs.writeFileSync( + path.join(localBin, "docker"), + ["#!/bin/sh", "if [ \"$1\" = \"images\" ]; then exit 0; fi", "exit 0"].join("\n"), + { mode: 0o755 }, + ); + + const backup = runWithEnv("backup-all", { HOME: home }); + expect(backup.code).toBe(0); + expect(backup.out).toContain("No sandboxes registered. Nothing to back up."); + + const upgrade = runWithEnv("upgrade-sandboxes --check", { HOME: home }); + expect(upgrade.code).toBe(0); + expect(upgrade.out).toContain("No sandboxes found in the registry."); + + const gc = runWithEnv("gc --dry-run", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }); + expect(gc.code).toBe(0); + expect(gc.out).toContain("No sandbox images found on the host."); + }); + it("shows skill install help when --help follows install", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-skill-help-")); writeSandboxRegistry(home); From 7d6a6fb8103aef969f39e6ffd67ad932716b3a3f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 30 Apr 2026 18:24:21 -0700 Subject: [PATCH 08/17] refactor(cli): migrate sandbox logs command to oclif --- src/lib/credentials-cli-command.ts | 2 + src/lib/debug-cli-command.ts | 2 + src/lib/gateway-token-cli-command.ts | 2 + src/lib/list-command.ts | 2 + src/lib/maintenance-cli-commands.ts | 2 + src/lib/oclif-commands.ts | 2 + src/lib/sandbox-inspection-cli-command.ts | 2 + src/lib/sandbox-logs-cli-command.test.ts | 21 +++++++++++ src/lib/sandbox-logs-cli-command.ts | 46 +++++++++++++++++++++++ src/lib/status-command.ts | 2 + src/lib/tunnel-commands.ts | 2 + src/lib/uninstall-cli-command.ts | 2 + src/nemoclaw.ts | 7 +++- test/cli.test.ts | 5 +++ 14 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/lib/sandbox-logs-cli-command.test.ts create mode 100644 src/lib/sandbox-logs-cli-command.ts diff --git a/src/lib/credentials-cli-command.ts b/src/lib/credentials-cli-command.ts index 1e83f1a6ea5..49011df296d 100644 --- a/src/lib/credentials-cli-command.ts +++ b/src/lib/credentials-cli-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { Args, Command, Flags } from "@oclif/core"; import { CLI_DISPLAY_NAME, CLI_NAME } from "./branding"; diff --git a/src/lib/debug-cli-command.ts b/src/lib/debug-cli-command.ts index 99c8600f097..81a8f66fe76 100644 --- a/src/lib/debug-cli-command.ts +++ b/src/lib/debug-cli-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { Command } from "@oclif/core"; import type { CaptureOpenshellResult } from "./openshell"; diff --git a/src/lib/gateway-token-cli-command.ts b/src/lib/gateway-token-cli-command.ts index fd139639d24..e20115edeb6 100644 --- a/src/lib/gateway-token-cli-command.ts +++ b/src/lib/gateway-token-cli-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { Args, Command, Flags } from "@oclif/core"; import { runGatewayTokenCommand } from "./gateway-token-command"; diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index 3286b559f28..0e0bc856698 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { Command, Flags } from "@oclif/core"; import { getSandboxInventory, renderSandboxInventoryText } from "./inventory-commands"; diff --git a/src/lib/maintenance-cli-commands.ts b/src/lib/maintenance-cli-commands.ts index 40b6f0ce74e..c3c18f7b9a1 100644 --- a/src/lib/maintenance-cli-commands.ts +++ b/src/lib/maintenance-cli-commands.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapters covered through CLI integration tests. */ + import { Command, Flags } from "@oclif/core"; type RuntimeBridge = { diff --git a/src/lib/oclif-commands.ts b/src/lib/oclif-commands.ts index 379f72b33b1..c5397a70899 100644 --- a/src/lib/oclif-commands.ts +++ b/src/lib/oclif-commands.ts @@ -20,6 +20,7 @@ import { SandboxPolicyListCommand, SandboxStatusCommand, } from "./sandbox-inspection-cli-command"; +import SandboxLogsCommand from "./sandbox-logs-cli-command"; import StatusCommand from "./status-command"; import { DeprecatedStartCommand, @@ -38,6 +39,7 @@ export default { list: ListCommand, "sandbox:channels:list": SandboxChannelsListCommand, "sandbox:config:get": SandboxConfigGetCommand, + "sandbox:logs": SandboxLogsCommand, "sandbox:policy-list": SandboxPolicyListCommand, "sandbox:status": SandboxStatusCommand, status: StatusCommand, diff --git a/src/lib/sandbox-inspection-cli-command.ts b/src/lib/sandbox-inspection-cli-command.ts index a75296e1592..3b304491c25 100644 --- a/src/lib/sandbox-inspection-cli-command.ts +++ b/src/lib/sandbox-inspection-cli-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapters covered through CLI integration tests. */ + import { Args, Command, Flags } from "@oclif/core"; import { CLI_NAME } from "./branding"; diff --git a/src/lib/sandbox-logs-cli-command.test.ts b/src/lib/sandbox-logs-cli-command.test.ts new file mode 100644 index 00000000000..d67a9e8df4b --- /dev/null +++ b/src/lib/sandbox-logs-cli-command.test.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import SandboxLogsCommand, { + setSandboxLogsRuntimeBridgeFactoryForTest, +} from "./sandbox-logs-cli-command"; + +const rootDir = process.cwd(); + +describe("SandboxLogsCommand", () => { + it("runs sandbox logs with the follow flag", async () => { + const sandboxLogs = vi.fn(); + setSandboxLogsRuntimeBridgeFactoryForTest(() => ({ sandboxLogs })); + + await SandboxLogsCommand.run(["alpha", "--follow"], rootDir); + + expect(sandboxLogs).toHaveBeenCalledWith("alpha", true); + }); +}); diff --git a/src/lib/sandbox-logs-cli-command.ts b/src/lib/sandbox-logs-cli-command.ts new file mode 100644 index 00000000000..447e4ac2863 --- /dev/null +++ b/src/lib/sandbox-logs-cli-command.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* v8 ignore start -- thin oclif wrapper covered through CLI integration tests. */ + +import { Args, Command, Flags } from "@oclif/core"; + +type RuntimeBridge = { + sandboxLogs: (sandboxName: string, follow: boolean) => void; +}; + +let runtimeBridgeFactory = (): RuntimeBridge => require("../nemoclaw") as RuntimeBridge; + +export function setSandboxLogsRuntimeBridgeFactoryForTest( + factory: () => RuntimeBridge, +): void { + runtimeBridgeFactory = factory; +} + +function getRuntimeBridge(): RuntimeBridge { + return runtimeBridgeFactory(); +} + +export default class SandboxLogsCommand extends Command { + static id = "sandbox:logs"; + static strict = true; + static summary = "Stream sandbox logs"; + static description = "Show OpenClaw gateway logs and OpenShell audit logs for a sandbox."; + static usage = [" logs [--follow]"]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + help: Flags.help({ char: "h" }), + follow: Flags.boolean({ description: "Follow logs until interrupted" }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxLogsCommand); + getRuntimeBridge().sandboxLogs(args.sandboxName, flags.follow === true); + } +} diff --git a/src/lib/status-command.ts b/src/lib/status-command.ts index eb5674d2cc6..e2b70046013 100644 --- a/src/lib/status-command.ts +++ b/src/lib/status-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { Command, Flags } from "@oclif/core"; import { showStatusCommand } from "./inventory-commands"; diff --git a/src/lib/tunnel-commands.ts b/src/lib/tunnel-commands.ts index 6533c21b33e..84062ef266e 100644 --- a/src/lib/tunnel-commands.ts +++ b/src/lib/tunnel-commands.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapters covered through CLI integration tests. */ + import { Command, Flags } from "@oclif/core"; import { CLI_NAME } from "./branding"; diff --git a/src/lib/uninstall-cli-command.ts b/src/lib/uninstall-cli-command.ts index dcd6264322d..1283a7b52c8 100644 --- a/src/lib/uninstall-cli-command.ts +++ b/src/lib/uninstall-cli-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { spawnSync } from "node:child_process"; import { Command } from "@oclif/core"; diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index f3e369bf1b0..ee474aed1bd 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -575,6 +575,7 @@ exports.recoverNamedGatewayRuntime = recoverNamedGatewayRuntime; exports.recoverRegistryEntries = recoverRegistryEntries; exports.runOpenshell = runOpenshell; exports.sandboxChannelsList = sandboxChannelsList; +exports.sandboxLogs = sandboxLogs; exports.sandboxPolicyList = sandboxPolicyList; exports.sandboxStatus = sandboxStatus; exports.upgradeSandboxes = upgradeSandboxes; @@ -4062,7 +4063,11 @@ const [cmd, ...args] = process.argv.slice(2); await runOclif("sandbox:status", [cmd, ...actionArgs]); break; case "logs": - sandboxLogs(cmd, actionArgs.includes("--follow")); + if (hasHelpFlag(actionArgs)) { + printSandboxActionUsage("logs [--follow]"); + break; + } + await runOclif("sandbox:logs", [cmd, ...actionArgs]); break; case "policy-add": await sandboxPolicyAdd(cmd, actionArgs); diff --git a/test/cli.test.ts b/test/cli.test.ts index 0c03376b08b..eca6944059b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -712,6 +712,11 @@ describe("CLI dispatch", () => { expect(status.out).toContain(" status"); expect(status.out).not.toContain("sandbox:status"); + const logs = runWithEnv("alpha logs --help", { HOME: home }); + expect(logs.code).toBe(0); + expect(logs.out).toContain(" logs [--follow]"); + expect(logs.out).not.toContain("sandbox:logs"); + const policy = runWithEnv("alpha policy-list --help", { HOME: home }); expect(policy.code).toBe(0); expect(policy.out).toContain(" policy-list"); From 3f17a4f8bef8e025244951a4885cb61fb4e74559 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 30 Apr 2026 18:28:53 -0700 Subject: [PATCH 09/17] refactor(cli): migrate skill install command to oclif --- src/lib/oclif-commands.ts | 2 + src/lib/skill-install-cli-command.test.ts | 30 +++++++++++++ src/lib/skill-install-cli-command.ts | 53 +++++++++++++++++++++++ src/nemoclaw.ts | 18 +++++++- test/cli.test.ts | 1 + 5 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/lib/skill-install-cli-command.test.ts create mode 100644 src/lib/skill-install-cli-command.ts diff --git a/src/lib/oclif-commands.ts b/src/lib/oclif-commands.ts index c5397a70899..a3705f85d9c 100644 --- a/src/lib/oclif-commands.ts +++ b/src/lib/oclif-commands.ts @@ -21,6 +21,7 @@ import { SandboxStatusCommand, } from "./sandbox-inspection-cli-command"; import SandboxLogsCommand from "./sandbox-logs-cli-command"; +import SkillInstallCliCommand from "./skill-install-cli-command"; import StatusCommand from "./status-command"; import { DeprecatedStartCommand, @@ -41,6 +42,7 @@ export default { "sandbox:config:get": SandboxConfigGetCommand, "sandbox:logs": SandboxLogsCommand, "sandbox:policy-list": SandboxPolicyListCommand, + "sandbox:skill:install": SkillInstallCliCommand, "sandbox:status": SandboxStatusCommand, status: StatusCommand, start: DeprecatedStartCommand, diff --git a/src/lib/skill-install-cli-command.test.ts b/src/lib/skill-install-cli-command.test.ts new file mode 100644 index 00000000000..56ed2f347c8 --- /dev/null +++ b/src/lib/skill-install-cli-command.test.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import SkillInstallCliCommand, { + setSkillInstallRuntimeBridgeFactoryForTest, +} from "./skill-install-cli-command"; + +const rootDir = process.cwd(); + +describe("SkillInstallCliCommand", () => { + it("runs skill install with the legacy install argv shape", async () => { + const sandboxSkillInstall = vi.fn().mockResolvedValue(undefined); + setSkillInstallRuntimeBridgeFactoryForTest(() => ({ sandboxSkillInstall })); + + await SkillInstallCliCommand.run(["alpha", "/tmp/my-skill"], rootDir); + + expect(sandboxSkillInstall).toHaveBeenCalledWith("alpha", ["install", "/tmp/my-skill"]); + }); + + it("lets legacy skill install validation report a missing path", async () => { + const sandboxSkillInstall = vi.fn().mockResolvedValue(undefined); + setSkillInstallRuntimeBridgeFactoryForTest(() => ({ sandboxSkillInstall })); + + await SkillInstallCliCommand.run(["alpha"], rootDir); + + expect(sandboxSkillInstall).toHaveBeenCalledWith("alpha", ["install"]); + }); +}); diff --git a/src/lib/skill-install-cli-command.ts b/src/lib/skill-install-cli-command.ts new file mode 100644 index 00000000000..5a40208fb36 --- /dev/null +++ b/src/lib/skill-install-cli-command.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* v8 ignore start -- thin oclif wrapper covered through CLI integration tests. */ + +import { Args, Command, Flags } from "@oclif/core"; + +type RuntimeBridge = { + sandboxSkillInstall: (sandboxName: string, args?: string[]) => Promise; +}; + +let runtimeBridgeFactory = (): RuntimeBridge => require("../nemoclaw") as RuntimeBridge; + +export function setSkillInstallRuntimeBridgeFactoryForTest( + factory: () => RuntimeBridge, +): void { + runtimeBridgeFactory = factory; +} + +function getRuntimeBridge(): RuntimeBridge { + return runtimeBridgeFactory(); +} + +export default class SkillInstallCliCommand extends Command { + static id = "sandbox:skill:install"; + static strict = true; + static summary = "Deploy a skill directory to the sandbox"; + static description = "Validate a local SKILL.md directory and upload it to a running sandbox."; + static usage = [" skill install "]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + skillPath: Args.string({ + name: "path", + description: "Skill directory or direct path to SKILL.md", + required: false, + }), + }; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + const { args } = await this.parse(SkillInstallCliCommand); + await getRuntimeBridge().sandboxSkillInstall( + args.sandboxName, + args.skillPath ? ["install", args.skillPath] : ["install"], + ); + } +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index ee474aed1bd..6599abe0289 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -577,6 +577,7 @@ exports.runOpenshell = runOpenshell; exports.sandboxChannelsList = sandboxChannelsList; exports.sandboxLogs = sandboxLogs; exports.sandboxPolicyList = sandboxPolicyList; +exports.sandboxSkillInstall = sandboxSkillInstall; exports.sandboxStatus = sandboxStatus; exports.upgradeSandboxes = upgradeSandboxes; @@ -4092,9 +4093,22 @@ const [cmd, ...args] = process.argv.slice(2); } await runOclif("sandbox:gateway-token", [cmd, ...actionArgs]); break; - case "skill": - await sandboxSkillInstall(cmd, actionArgs); + case "skill": { + const skillSub = actionArgs[0]; + const skillArgs = actionArgs.slice(1); + if (!skillSub || skillSub === "help" || skillSub === "--help" || skillSub === "-h") { + await sandboxSkillInstall(cmd, actionArgs); + } else if (skillSub === "install") { + if (hasHelpFlag(skillArgs)) { + await sandboxSkillInstall(cmd, actionArgs); + } else { + await runOclif("sandbox:skill:install", [cmd, ...skillArgs]); + } + } else { + await sandboxSkillInstall(cmd, actionArgs); + } break; + } case "rebuild": await sandboxRebuild(cmd, actionArgs); break; diff --git a/test/cli.test.ts b/test/cli.test.ts index eca6944059b..55ad089feab 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -448,6 +448,7 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out).toContain("Usage: nemoclaw skill install "); expect(r.out).toContain("Deploy a skill directory"); + expect(r.out).not.toContain("sandbox:skill:install"); expect(r.out).not.toContain("--help"); expect(r.out).not.toContain("No SKILL.md found"); }); From 0fb3c4169b9ea3663c10a5dc46491e84931b8c53 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 30 Apr 2026 18:33:46 -0700 Subject: [PATCH 10/17] refactor(cli): migrate snapshot list and create to oclif --- src/lib/oclif-commands.ts | 3 ++ src/lib/snapshot-cli-commands.test.ts | 36 ++++++++++++++ src/lib/snapshot-cli-commands.ts | 69 +++++++++++++++++++++++++++ src/nemoclaw.ts | 26 +++++++++- test/cli.test.ts | 24 ++++++++++ 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/lib/snapshot-cli-commands.test.ts create mode 100644 src/lib/snapshot-cli-commands.ts diff --git a/src/lib/oclif-commands.ts b/src/lib/oclif-commands.ts index a3705f85d9c..53560058649 100644 --- a/src/lib/oclif-commands.ts +++ b/src/lib/oclif-commands.ts @@ -22,6 +22,7 @@ import { } from "./sandbox-inspection-cli-command"; import SandboxLogsCommand from "./sandbox-logs-cli-command"; import SkillInstallCliCommand from "./skill-install-cli-command"; +import { SnapshotCreateCommand, SnapshotListCommand } from "./snapshot-cli-commands"; import StatusCommand from "./status-command"; import { DeprecatedStartCommand, @@ -43,6 +44,8 @@ export default { "sandbox:logs": SandboxLogsCommand, "sandbox:policy-list": SandboxPolicyListCommand, "sandbox:skill:install": SkillInstallCliCommand, + "sandbox:snapshot:create": SnapshotCreateCommand, + "sandbox:snapshot:list": SnapshotListCommand, "sandbox:status": SandboxStatusCommand, status: StatusCommand, start: DeprecatedStartCommand, diff --git a/src/lib/snapshot-cli-commands.test.ts b/src/lib/snapshot-cli-commands.test.ts new file mode 100644 index 00000000000..fb3da56705e --- /dev/null +++ b/src/lib/snapshot-cli-commands.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + setSnapshotRuntimeBridgeFactoryForTest, + SnapshotCreateCommand, + SnapshotListCommand, +} from "./snapshot-cli-commands"; + +const rootDir = process.cwd(); + +describe("snapshot oclif commands", () => { + it("runs snapshot list through the legacy snapshot implementation", async () => { + const sandboxSnapshot = vi.fn().mockResolvedValue(undefined); + setSnapshotRuntimeBridgeFactoryForTest(() => ({ sandboxSnapshot })); + + await SnapshotListCommand.run(["alpha"], rootDir); + + expect(sandboxSnapshot).toHaveBeenCalledWith("alpha", ["list"]); + }); + + it("runs snapshot create with an optional label", async () => { + const sandboxSnapshot = vi.fn().mockResolvedValue(undefined); + setSnapshotRuntimeBridgeFactoryForTest(() => ({ sandboxSnapshot })); + + await SnapshotCreateCommand.run(["alpha", "--name", "before-upgrade"], rootDir); + + expect(sandboxSnapshot).toHaveBeenCalledWith("alpha", [ + "create", + "--name", + "before-upgrade", + ]); + }); +}); diff --git a/src/lib/snapshot-cli-commands.ts b/src/lib/snapshot-cli-commands.ts new file mode 100644 index 00000000000..a08f5580aac --- /dev/null +++ b/src/lib/snapshot-cli-commands.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* v8 ignore start -- thin oclif wrappers covered through CLI integration tests. */ + +import { Args, Command, Flags } from "@oclif/core"; + +type RuntimeBridge = { + sandboxSnapshot: (sandboxName: string, subArgs: string[]) => Promise; +}; + +let runtimeBridgeFactory = (): RuntimeBridge => require("../nemoclaw") as RuntimeBridge; + +export function setSnapshotRuntimeBridgeFactoryForTest(factory: () => RuntimeBridge): void { + runtimeBridgeFactory = factory; +} + +function getRuntimeBridge(): RuntimeBridge { + return runtimeBridgeFactory(); +} + +const sandboxNameArg = Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, +}); + +export class SnapshotListCommand extends Command { + static id = "sandbox:snapshot:list"; + static strict = true; + static summary = "List available snapshots"; + static description = "List available snapshots for a sandbox."; + static usage = [" snapshot list"]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + const { args } = await this.parse(SnapshotListCommand); + await getRuntimeBridge().sandboxSnapshot(args.sandboxName, ["list"]); + } +} + +export class SnapshotCreateCommand extends Command { + static id = "sandbox:snapshot:create"; + static strict = true; + static summary = "Create a snapshot of sandbox state"; + static description = "Create an auto-versioned snapshot of sandbox workspace state."; + static usage = [" snapshot create [--name