diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 708e5233eaa..e0918c12060 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -112,6 +112,441 @@ reviews: - NemoClaw, OpenClaw, and OpenShell must use correct casing. - No emoji in technical prose. + # ── E2E test recommendations ────────────────────────────────── + # Maps sensitive file paths to the nightly E2E jobs that exercise them. + # CodeRabbit surfaces these as review comments on PRs that touch a + # matched path, so reviewers know which E2E jobs to run before merge. + # + # When adding a new E2E job to nightly-e2e.yaml, add a matching + # path_instructions entry below. The cross-validation test in + # test/validate-e2e-coverage.test.ts verifies consistency. + + - path: "scripts/nemoclaw-start.sh" + instructions: &e2e-entrypoint | + This file is a sandbox entrypoint script. Changes affect every + sandbox boot and are invisible to unit tests (Landlock, non-root + execution, process lifecycle). + + **E2E test recommendation:** + - `sandbox-survival-e2e` — gateway restart recovery + - `sandbox-operations-e2e` — process recovery after gateway kill + - `cloud-e2e` — full onboard + cloud inference + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=sandbox-survival-e2e,sandbox-operations-e2e,cloud-e2e + ``` + + - path: "scripts/lib/sandbox-init.sh" + instructions: *e2e-entrypoint + + - path: "Dockerfile" + instructions: &e2e-dockerfile | + This file affects the sandbox container image. Layer ordering, + permissions, and baked config changes are only testable with a + real container build. + + **E2E test recommendation:** + - `cloud-e2e` — full onboard + cloud inference + - `sandbox-survival-e2e` — gateway restart recovery + - `hermes-e2e` — Hermes agent onboard + inference + - `rebuild-openclaw-e2e` — workspace state survives rebuild + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=cloud-e2e,sandbox-survival-e2e,hermes-e2e,rebuild-openclaw-e2e + ``` + + - path: "Dockerfile.base" + instructions: *e2e-dockerfile + + - path: "nemoclaw-blueprint/scripts/http-proxy-fix.js" + instructions: | + This file is the L7 proxy rewrite script. Changes affect all + inference routing through the proxy. FORWARD-mode path needs + manual validation until a dedicated forward-proxy-e2e exists. + + **E2E test recommendation:** + - `cloud-e2e` — full inference through the proxy chain + - `inference-routing-e2e` — credential isolation + error classification + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=cloud-e2e,inference-routing-e2e + ``` + + - path: "src/lib/onboard.ts" + instructions: | + This file contains core onboarding logic. Changes here affect + the full sandbox creation and configuration flow. + + **E2E test recommendation:** + - `cloud-e2e` — full onboard + cloud inference + - `sandbox-operations-e2e` — multi-sandbox lifecycle + - `rebuild-openclaw-e2e` — workspace state survives rebuild + - `channels-stop-start-e2e` — channel disable/enable lifecycle across + rebuild with cached Telegram credentials + - `messaging-compatible-endpoint-e2e` — Telegram + compatible endpoint + inference.local regression + - `hermes-discord-e2e` — Hermes Discord config schema + placeholder + isolation + - `hermes-slack-e2e` — Hermes Slack policy + Python placeholder egress + - `openshell-gateway-upgrade-e2e` — stale Linux Docker-driver gateway + process restart after OpenShell upgrade + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=cloud-e2e,sandbox-operations-e2e,rebuild-openclaw-e2e,channels-stop-start-e2e,messaging-compatible-endpoint-e2e,hermes-discord-e2e,hermes-slack-e2e,openshell-gateway-upgrade-e2e + ``` + + - path: "src/lib/onboard/channel-state.ts" + instructions: &e2e-channel-stop-start | + This file controls disabled channel resolution used during onboard + and rebuild. Changes affect whether `channels stop` persists across + sandbox destroy/recreate and whether `channels start` reattaches + cached credentials. + + **E2E test recommendation:** + - `channels-stop-start-e2e` — Telegram channel stop/start lifecycle + across rebuild with cached credentials + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=channels-stop-start-e2e + ``` + + - path: "src/lib/state/onboard-session.ts" + instructions: *e2e-channel-stop-start + + - path: "src/lib/actions/sandbox/rebuild.ts" + instructions: *e2e-channel-stop-start + + - path: "src/commands/sandbox/channels/**" + instructions: *e2e-channel-stop-start + + - path: "src/lib/sandbox/channels-command-support.ts" + instructions: *e2e-channel-stop-start + + - path: "test/e2e/test-channels-stop-start.sh" + instructions: *e2e-channel-stop-start + + - path: "src/lib/actions/inference-set.ts" + instructions: | + This file switches the OpenShell inference route and patches the + selected running agent config. Changes here affect OpenClaw and + Hermes model switching without a sandbox rebuild. + + **E2E test recommendation:** + - `openclaw-inference-switch-e2e` — OpenClaw route + config patch + + live OpenClaw request after switch + - `hermes-inference-switch-e2e` — Hermes route + config.yaml patch + + live Hermes request after switch + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=openclaw-inference-switch-e2e,hermes-inference-switch-e2e + ``` + + - path: "src/nemoclaw.ts" + instructions: | + This file contains CLI dispatch, status, recovery, and connect + functions. Changes affect sandbox lifecycle commands. + + **E2E test recommendation:** + - `sandbox-survival-e2e` — gateway restart recovery + - `sandbox-operations-e2e` — process recovery after gateway kill + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=sandbox-survival-e2e,sandbox-operations-e2e + ``` + + - path: "src/lib/cluster-image-patch.ts" + instructions: &e2e-overlayfs | + This file handles Docker 26+ overlayfs compatibility. Changes + are only testable under real Docker + K3s execution. + + **E2E test recommendation:** + - `overlayfs-autofix-e2e` — Docker 26+ nested-mount auto-fix + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=overlayfs-autofix-e2e + ``` + + - path: "src/lib/onboard/preflight.ts" + instructions: *e2e-overlayfs + + - path: "src/lib/deploy/**" + instructions: | + This file contains deployment lifecycle logic (start/stop + cloudflared tunnel). + + **E2E test recommendation:** + - `tunnel-lifecycle-e2e` — start/stop cloudflared tunnel + - `state-backup-restore-e2e` — backup/restore workspace state + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=tunnel-lifecycle-e2e,state-backup-restore-e2e + ``` + + - path: "src/lib/state/sandbox.ts" + instructions: | + This file manages sandbox state (backup, restore, rebuild, + snapshot). Changes affect data persistence across sandbox + lifecycle operations. + + **E2E test recommendation:** + - `state-backup-restore-e2e` — backup/restore workspace state + - `snapshot-commands-e2e` — snapshot create/list/restore lifecycle + - `rebuild-openclaw-e2e` — workspace state survives rebuild + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=state-backup-restore-e2e,snapshot-commands-e2e,rebuild-openclaw-e2e + ``` + + - path: "src/lib/shields/**" + instructions: | + These files control shields down/up, config mutability, audit + trail, and auto-restore timer. + + **E2E test recommendation:** + - `shields-config-e2e` — shields lifecycle + config get/set/rotate + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=shields-config-e2e + ``` + + - path: "agents/hermes/**" + instructions: | + This directory contains the Hermes agent. Changes affect + multi-agent onboarding, health probes, and inference routing. + + **E2E test recommendation:** + - `hermes-e2e` — Hermes onboard + health probe + live inference + - `hermes-inference-switch-e2e` — Hermes route + config.yaml patch + + live Hermes request after switch + - `hermes-discord-e2e` — Hermes Discord config schema + placeholder + isolation + - `hermes-slack-e2e` — Hermes Slack policy + Python placeholder egress + - `rebuild-hermes-e2e` — Hermes upgrade path + - `rebuild-hermes-stale-base-e2e` — Hermes rebuild with stale cached base image + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=hermes-e2e,hermes-inference-switch-e2e,hermes-discord-e2e,hermes-slack-e2e,rebuild-hermes-e2e,rebuild-hermes-stale-base-e2e + ``` + + - path: "test/e2e/test-hermes-slack-e2e.sh" + instructions: | + This script validates Hermes Slack onboarding, policy scoping, Slack + credential providers, and Python Slack API egress through OpenShell + placeholder substitution. + + **E2E test recommendation:** + - `hermes-slack-e2e` — Hermes Slack policy + Python placeholder egress + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=hermes-slack-e2e + ``` + + - path: "nemoclaw-blueprint/policies/**" + instructions: | + This directory contains network policy definitions and presets. + Changes affect sandbox egress rules and SSRF filtering. + + **E2E test recommendation:** + - `network-policy-e2e` — deny-by-default, whitelist, hot-reload, SSRF + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=network-policy-e2e + ``` + + - path: "test/e2e/test-issue-2478-crash-loop-recovery.sh" + instructions: | + STAYS_IN_PR_UNTIL_SHIP — long-running soak test for the gateway + recovery preload chain hardening (issue #2478). Removed in the + same commit that drops the test file before merge. + + **E2E test recommendation:** + - `issue-2478-crash-loop-recovery-e2e` — gateway recovery preload + chain hardening soak + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=issue-2478-crash-loop-recovery-e2e + ``` + + # ── Split cloud-experimental tests (#2644) ────────────────── + - path: "test/e2e/test-cloud-onboard-e2e.sh" + instructions: &e2e-cloud-onboard | + This script tests the public installer flow, Landlock read-only + enforcement, API key leak detection, and inference.local HTTPS. + + **E2E test recommendation:** + - `cloud-onboard-e2e` — public installer + sandbox health + security + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=cloud-onboard-e2e + ``` + + - path: "test/e2e/e2e-cloud-experimental/checks/**" + instructions: *e2e-cloud-onboard + + - path: "test/e2e/test-cloud-inference-e2e.sh" + instructions: &e2e-cloud-inference | + This script tests live chat via inference.local and validates + the skill filesystem layout inside the sandbox. + + **E2E test recommendation:** + - `cloud-inference-e2e` — live cloud inference + skill filesystem + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=cloud-inference-e2e + ``` + + - path: "test/e2e/test-openclaw-inference-switch.sh" + instructions: | + This script validates OpenClaw model/provider switching with + `nemoclaw inference set`, including OpenShell route state, + /sandbox/.openclaw/openclaw.json, config hash recomputation, and + live requests after the switch. + + **E2E test recommendation:** + - `openclaw-inference-switch-e2e` — OpenClaw route + config patch + + live OpenClaw request after switch + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=openclaw-inference-switch-e2e + ``` + + - path: "test/e2e/test-hermes-inference-switch.sh" + instructions: | + This script validates Hermes model/provider switching with + `nemohermes inference set`, including OpenShell route state, + /sandbox/.hermes/config.yaml, config hash recomputation, .env + preservation, and live requests after the switch. + + **E2E test recommendation:** + - `hermes-inference-switch-e2e` — Hermes route + config.yaml patch + + live Hermes request after switch + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=hermes-inference-switch-e2e + ``` + + - path: "test/e2e/test-skill-agent-e2e.sh" + instructions: &e2e-skill-agent | + This script tests skill injection into the sandbox and verifies + the agent reads the skill and returns a verification token. + + **E2E test recommendation:** + - `skill-agent-e2e` — skill injection + agent verification + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=skill-agent-e2e + ``` + + - path: "test/e2e/e2e-cloud-experimental/features/**" + instructions: *e2e-skill-agent + + - path: "test/e2e/test-docs-validation.sh" + instructions: &e2e-docs-validation | + This script validates CLI/docs parity (nemoclaw --help vs + commands.md) and markdown link resolution. + + **E2E test recommendation:** + - `docs-validation-e2e` — CLI/docs parity + link validation + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=docs-validation-e2e + ``` + + - path: "test/e2e/e2e-cloud-experimental/check-docs.sh" + instructions: *e2e-docs-validation + + - path: "docs/reference/commands.md" + instructions: *e2e-docs-validation + + - path: "test/e2e/test-messaging-compatible-endpoint.sh" + instructions: | + This script validates the Telegram + OpenAI-compatible endpoint + regression path with a local mock endpoint. Changes here affect + the hermetic coverage for managed inference.local routing from + messaging-triggered agent turns. + + **E2E test recommendation:** + - `messaging-compatible-endpoint-e2e` — Telegram + compatible + endpoint inference.local routing + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=messaging-compatible-endpoint-e2e + ``` + + - path: "test/e2e/test-kimi-inference-compat.sh" + instructions: &e2e-kimi-inference-compat | + This script validates Kimi K2.6 managed inference.local + compatibility with a hermetic OpenAI-compatible mock endpoint. + Changes here affect OpenClaw tool-call splitting and trajectory + proof for the Kimi compatibility path. + + **E2E test recommendation:** + - `kimi-inference-compat-e2e` — Kimi managed inference.local + compatibility and safe exec splitting + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=kimi-inference-compat-e2e + ``` + + - path: "nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/**" + instructions: *e2e-kimi-inference-compat + + - path: "test/e2e/test-openshell-gateway-upgrade.sh" + instructions: | + This script validates the old OpenShell install upgrade guard for + Linux Docker-driver gateway processes. + + **E2E test recommendation:** + - `openshell-gateway-upgrade-e2e` — stale gateway process restart after + OpenShell upgrade + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=openshell-gateway-upgrade-e2e + ``` + + - path: "test/e2e/test-gateway-health-honest.sh" + instructions: | + This script is the coverage guard for #3111. It sabotages the + openshell-gateway binary with a shim that crashes on startup and + asserts onboard does NOT falsely log "Docker-driver gateway is + healthy" and does exit non-zero. Exercises the NemoClaw-side + false-positive (detached-zombie isPidAlive + metadata-only + isGatewayHealthy) regardless of OpenShell packaging choices. + + **Regression E2E test recommendation:** + - Run `regression-e2e.yaml` with the jobs input set to + gateway-health-honest-e2e to validate false-positive health + checks when the gateway binary crashes on startup (#3111). + + To run selectively: + ``` + gh workflow run regression-e2e.yaml --ref -f 'jobs=gateway-health-honest-e2e' + ``` + - path: ".github/workflows/nightly-e2e.yaml" instructions: | This is the nightly E2E workflow definition. Changes here affect diff --git a/package.json b/package.json index 285da157d9d..212328eed89 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "format:ts": "cd nemoclaw && npm run lint:fix && npm run format", "check:installer-hash": "bash scripts/check-installer-hash.sh", "typecheck": "tsc -p jsconfig.json", - "build:cli": "tsc -p tsconfig.src.json && if find nemoclaw-blueprint/scripts -name '*.ts' -print -quit | grep -q .; then tsc -p nemoclaw-blueprint/tsconfig.json; fi", + "build:cli": "tsc -p tsconfig.src.json && node dist/lib/cli/generate-oclif-metadata-manifest.js && if find nemoclaw-blueprint/scripts -name '*.ts' -print -quit | grep -q .; then tsc -p nemoclaw-blueprint/tsconfig.json; fi", "typecheck:cli": "tsc -p tsconfig.cli.json", "validate:configs": "tsx scripts/validate-configs.ts", "type-safety:hotspots": "tsx scripts/type-safety-hotspots.ts", diff --git a/scripts/checks/layer-import-boundaries.ts b/scripts/checks/layer-import-boundaries.ts index b5bbc5c7336..50754d52a03 100644 --- a/scripts/checks/layer-import-boundaries.ts +++ b/scripts/checks/layer-import-boundaries.ts @@ -21,7 +21,7 @@ type ImportRef = { }; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const SRC_LIB_ROOT = path.join(REPO_ROOT, "src", "lib"); +const SRC_ROOT = path.join(REPO_ROOT, "src"); const SKIP_DIRS = new Set([".git", "coverage", "dist", "node_modules"]); function toRepoPath(absPath: string): string { @@ -119,7 +119,7 @@ function isAdapterFile(repoPath: string): boolean { } function isCommandFile(repoPath: string): boolean { - return repoPath.startsWith("src/lib/commands/"); + return repoPath.startsWith("src/commands/"); } function isActionFile(repoPath: string): boolean { @@ -127,10 +127,6 @@ function isActionFile(repoPath: string): boolean { return /(^|\/)[^/]+-actions?\.ts$/.test(repoPath); } -function isGeneratedOrRegistryCommandFile(repoPath: string): boolean { - return repoPath.endsWith("/common.ts"); -} - function importTargetsForbiddenLayer( fromAbsPath: string, ref: ImportRef, @@ -181,7 +177,7 @@ function checkDomainFile(absPath: string, repoPath: string, violations: Violatio const target = importTargetsForbiddenLayer( absPath, ref, - ["src/lib/adapters/", "src/lib/commands/", "src/lib/cli/"], + ["src/lib/adapters/", "src/commands/", "src/lib/cli/"], true, ); if (target) { @@ -236,7 +232,7 @@ function checkActionFile(absPath: string, repoPath: string, violations: Violatio function checkAdapterFile(absPath: string, repoPath: string, violations: Violation[]): void { for (const ref of collectImportRefs(absPath)) { - const target = importTargetsForbiddenLayer(absPath, ref, ["src/lib/commands/"], true); + const target = importTargetsForbiddenLayer(absPath, ref, ["src/commands/"], true); if (target) { addViolation( violations, @@ -251,7 +247,6 @@ function checkAdapterFile(absPath: string, repoPath: string, violations: Violati } function checkCommandFile(absPath: string, repoPath: string, violations: Violation[]): void { - if (isGeneratedOrRegistryCommandFile(repoPath)) return; const sourceFile = sourceFileFor(absPath); let commandClassCount = 0; @@ -286,7 +281,7 @@ function checkCommandFile(absPath: string, repoPath: string, violations: Violati } } -export function findLayerImportBoundaryViolations(root = SRC_LIB_ROOT): Violation[] { +export function findLayerImportBoundaryViolations(root = SRC_ROOT): Violation[] { const violations: Violation[] = []; for (const absPath of walk(root)) { const repoPath = toRepoPath(absPath); diff --git a/src/commands/README.md b/src/commands/README.md index 8cfa8289616..2a09a10ffa7 100644 --- a/src/commands/README.md +++ b/src/commands/README.md @@ -4,18 +4,20 @@ # `src/commands` This tree is the oclif discovery surface for the packaged `nemoclaw` CLI. -Each file is intentionally thin: it exports a command class from `src/lib/commands/**` -and attaches NemoClaw's public display metadata. +Command entrypoint files define the oclif command class directly. Do not add new +public command shims that only re-export from `src/lib/**`, and do not recreate a +parallel command layer under `src/lib`. Prefer `src/lib//**` for shared +parser helpers. ```text src/commands/.ts - -> import command implementation from src/lib/commands/** - -> wrap with src/lib/cli/command-display.ts metadata + -> parse flags/args + -> call src/lib/actions/** or small src/lib/ command-support helpers ``` -Keep behavior out of this tree. Product behavior belongs in `src/lib/actions/**`, pure -planning and classification belongs in `src/lib/domain/**`, and host/runtime boundaries -belong in `src/lib/adapters/**`. +Keep product behavior out of this tree. Command classes should stay thin: product +behavior belongs in `src/lib/actions/**`, pure planning and classification belongs in +`src/lib/domain/**`, and host/runtime boundaries belong in `src/lib/adapters/**`. Hidden `nemoclaw internal ...` entrypoints live under `src/commands/internal/**`; see `src/commands/internal/README.md` for their narrower compatibility contract. diff --git a/src/commands/backup-all.ts b/src/commands/backup-all.ts index 0a95230b3c6..8e20e7da56f 100644 --- a/src/commands/backup-all.ts +++ b/src/commands/backup-all.ts @@ -1,15 +1,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../lib/commands/maintenance/backup-all"; -import { withCommandDisplay } from "../lib/cli/command-display"; +import { runBackupAllAction } from "../lib/actions/global"; +import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw backup-all", - description: "Back up all sandbox state before upgrade", - group: "Backup", - scope: "global", - order: 40, - }, -]); +export default class BackupAllCommand extends NemoClawCommand { + 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 examples = ["<%= config.bin %> backup-all"]; + static flags = {}; + + public async run(): Promise { + await this.parse(BackupAllCommand); + runBackupAllAction(); + } +} diff --git a/src/lib/commands/credentials.test.ts b/src/commands/credentials.test.ts similarity index 96% rename from src/lib/commands/credentials.test.ts rename to src/commands/credentials.test.ts index eca150e5704..d04b7fab4c0 100644 --- a/src/lib/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -9,8 +9,8 @@ const mocks = vi.hoisted(() => ({ runOpenshellProviderCommand: vi.fn(), })); -vi.mock("../credentials/store", () => ({ prompt: mocks.prompt })); -vi.mock("../actions/global", () => ({ +vi.mock("../lib/credentials/store", () => ({ prompt: mocks.prompt })); +vi.mock("../lib/actions/global", () => ({ recoverNamedGatewayRuntime: mocks.recoverNamedGatewayRuntime, runOpenshellProviderCommand: mocks.runOpenshellProviderCommand, })); diff --git a/src/commands/credentials.ts b/src/commands/credentials.ts index bf8c7ef1878..08daa4bd45a 100644 --- a/src/commands/credentials.ts +++ b/src/commands/credentials.ts @@ -1,4 +1,26 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export { default } from "../lib/commands/credentials"; +import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; + +import { printCredentialsUsage } from "../lib/credentials/command-support"; + +export default class CredentialsCommand extends NemoClawCommand { + 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 examples = [ + "<%= config.bin %> credentials list", + "<%= config.bin %> credentials reset nvidia-prod --yes", + ]; + static flags = { + }; + + public async run(): Promise { + await this.parse(CredentialsCommand); + printCredentialsUsage(this.log.bind(this)); + } +} diff --git a/src/commands/credentials/list.ts b/src/commands/credentials/list.ts index 4dbe73a3ec5..4c405f3992c 100644 --- a/src/commands/credentials/list.ts +++ b/src/commands/credentials/list.ts @@ -1,15 +1,59 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/credentials/list"; -import { withCommandDisplay } from "../../lib/cli/command-display"; - -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw credentials list", - description: "List stored credential keys", - group: "Credentials", - scope: "global", - order: 38, - }, -]); +import { CLI_NAME } from "../../lib/cli/branding"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; + +import { runOpenshellProviderCommand } from "../../lib/actions/global"; +import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../lib/adapters/openshell/timeouts"; +import { isBridgeProviderName, recoverGatewayOrExit } from "../../lib/credentials/command-support"; + +export default class CredentialsListCommand extends NemoClawCommand { + 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 examples = ["<%= config.bin %> credentials list"]; + static flags = { + }; + + public async run(): Promise { + await this.parse(CredentialsListCommand); + if (!(await recoverGatewayOrExit("query", (lines) => this.failWithLines(lines)))) return; + + const result = runOpenshellProviderCommand(["provider", "list", "--names"], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + if (result.status !== 0) { + this.failWithLines([ + " Could not query OpenShell gateway. Is it running?", + ` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`, + ]); + return; + } + + 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.`); + } + } +} diff --git a/src/commands/credentials/reset.ts b/src/commands/credentials/reset.ts index 934a27eb8e1..8780a53cbc6 100644 --- a/src/commands/credentials/reset.ts +++ b/src/commands/credentials/reset.ts @@ -1,15 +1,86 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/credentials/reset"; -import { withCommandDisplay } from "../../lib/cli/command-display"; - -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw credentials reset", - description: "Remove a stored credential so onboard re-prompts", - group: "Credentials", - scope: "global", - order: 39, - }, -]); +import { Args } from "@oclif/core"; +import { CLI_NAME } from "../../lib/cli/branding"; +import { yesFlag } from "../../lib/cli/common-flags"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; + +import { runOpenshellProviderCommand } from "../../lib/actions/global"; +import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../lib/adapters/openshell/timeouts"; +import { isBridgeProviderName, recoverGatewayOrExit } from "../../lib/credentials/command-support"; +import { prompt as askPrompt } from "../../lib/credentials/store"; + +export default class CredentialsResetCommand extends NemoClawCommand { + 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 examples = [ + "<%= config.bin %> credentials reset nvidia-prod", + "<%= config.bin %> credentials reset nvidia-prod --yes", + ]; + static args = { + provider: Args.string({ + name: "PROVIDER", + description: "OpenShell provider name", + required: true, + }), + }; + static flags = { + yes: yesFlag(), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(CredentialsResetCommand); + const key = args.provider; + + if (isBridgeProviderName(key)) { + this.failWithLines([ + ` '${key}' is a per-sandbox messaging bridge, not a credential.`, + ` Use \`${CLI_NAME} channels remove \` to retire`, + " the integration (it tears down the bridge provider and rebuilds the sandbox),", + ` or \`${CLI_NAME} channels stop <…>\` to pause it without clearing tokens.`, + ]); + return; + } + + 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; + } + } + + if (!(await recoverGatewayOrExit("reach", (lines) => this.failWithLines(lines)))) return; + + const result = runOpenshellProviderCommand(["provider", "delete", key], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + 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; + } + + const lines = [` Could not remove provider '${key}'.`]; + if (/^[A-Z][A-Z0-9_]+$/.test(key)) { + lines.push( + "", + ` '${key}' looks like a credential env variable name.`, + " As of this release, 'credentials reset' takes an OpenShell", + ` provider name. Run '${CLI_NAME} credentials list' to see the`, + " registered providers, then retry with one of those names.", + ); + } + const stderr = String(result.stderr || "").trim(); + if (stderr) lines.push(` ${stderr}`); + this.failWithLines(lines); + } +} diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 6bfdebc1575..895ed2b600c 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -1,16 +1,93 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../lib/commands/debug"; -import { withCommandDisplay } from "../lib/cli/command-display"; - -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw debug", - description: "Collect diagnostics for bug reports", - flags: "[--quick] [--sandbox NAME]", - group: "Troubleshooting", - scope: "global", - order: 37, - }, -]); +import { Flags } from "@oclif/core"; +import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; + +import { CLI_NAME } from "../lib/cli/branding"; +import { runDebug } from "../lib/diagnostics/debug"; +import type { DebugOptions } from "../lib/diagnostics/debug"; +import type { RunDebugCommandDeps } from "../lib/diagnostics/debug-command"; +import { runDebugCommandWithOptions } from "../lib/diagnostics/debug-command"; +import type { CaptureOpenshellResult } from "../lib/adapters/openshell/client"; +import { captureOpenshellCommand } from "../lib/adapters/openshell/client"; +import { OPENSHELL_PROBE_TIMEOUT_MS } from "../lib/adapters/openshell/timeouts"; +import * as registry from "../lib/state/registry"; +import { resolveOpenshell } from "../lib/adapters/openshell/resolve"; +import { parseLiveSandboxNames } from "../lib/runtime-recovery"; + +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, + }; +} + +export default class DebugCliCommand extends NemoClawCommand { + static id = "debug"; + static strict = true; + static summary = "Collect diagnostics for bug reports"; + static description = "Collect NemoClaw diagnostic information."; + static usage = ["debug [--quick|-q] [--output FILE|-o FILE] [--sandbox NAME]"]; + static examples = [ + "<%= config.bin %> debug --quick", + "<%= config.bin %> debug --sandbox alpha", + "<%= config.bin %> debug --output /tmp/nemoclaw-debug.tar.gz", + ]; + static flags = { + quick: Flags.boolean({ char: "q", description: "Only collect minimal diagnostics" }), + output: Flags.string({ char: "o", description: "Write a tarball to FILE" }), + sandbox: Flags.string({ description: "Target sandbox name" }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DebugCliCommand); + const options: DebugOptions = {}; + if (flags.quick) options.quick = true; + if (flags.output) options.output = flags.output; + if (flags.sandbox) options.sandboxName = flags.sandbox; + runDebugCommandWithOptions(options, buildDebugCommandDeps(this.config.root)); + } +} diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 75011005b50..12740430905 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -1,16 +1,30 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../lib/commands/deploy"; -import { withCommandDisplay } from "../lib/cli/command-display"; +import { Args } from "@oclif/core"; +import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw deploy", - description: "Deprecated Brev-specific bootstrap path", - group: "Compatibility Commands", - deprecated: true, - scope: "global", - order: 31, - }, -]); +import { runDeployAction } from "../lib/actions/global"; + +export default class DeployCliCommand extends NemoClawCommand { + static id = "deploy"; + static strict = true; + static summary = "Deprecated Brev-specific bootstrap path"; + static description = "Deprecated compatibility command for Brev-specific deployment."; + static usage = ["deploy [instance-name]"]; + static examples = ["<%= config.bin %> deploy my-gpu-instance"]; + static args = { + instanceName: Args.string({ + name: "instance-name", + description: "Brev instance name", + required: false, + }), + }; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(DeployCliCommand); + await runDeployAction(args.instanceName); + } +} diff --git a/src/commands/gc.ts b/src/commands/gc.ts index c75abe7f9e5..9066eb726fa 100644 --- a/src/commands/gc.ts +++ b/src/commands/gc.ts @@ -1,16 +1,29 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../lib/commands/maintenance/gc"; -import { withCommandDisplay } from "../lib/cli/command-display"; +import { runGarbageCollectImagesAction } from "../lib/actions/global"; +import { dryRunFlag, forceFlag, yesFlag } from "../lib/cli/common-flags"; +import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw gc", - description: "Remove orphaned sandbox Docker images", - flags: "(--yes|-y|--force, --dry-run)", - group: "Cleanup", - scope: "global", - order: 42, - }, -]); +export default class GarbageCollectImagesCommand extends NemoClawCommand { + 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|-y|--force]"]; + static examples = ["<%= config.bin %> gc --dry-run", "<%= config.bin %> gc --yes"]; + static flags = { + "dry-run": dryRunFlag("Show images that would be removed without deleting"), + yes: yesFlag(), + force: forceFlag(), + }; + + public async run(): Promise { + const { flags } = await this.parse(GarbageCollectImagesCommand); + await runGarbageCollectImagesAction({ + dryRun: flags["dry-run"] === true, + force: flags.force === true, + yes: flags.yes === true, + }); + } +} diff --git a/src/lib/commands/global-oclif-command-adapters.test.ts b/src/commands/global-oclif-command-adapters.test.ts similarity index 76% rename from src/lib/commands/global-oclif-command-adapters.test.ts rename to src/commands/global-oclif-command-adapters.test.ts index 0c223bef2e6..f2e1e56bd8f 100644 --- a/src/lib/commands/global-oclif-command-adapters.test.ts +++ b/src/commands/global-oclif-command-adapters.test.ts @@ -20,22 +20,22 @@ const mocks = vi.hoisted(() => ({ showStatusCommand: vi.fn(), })); -vi.mock("../inventory", () => ({ +vi.mock("../lib/inventory", () => ({ getSandboxInventory: mocks.getSandboxInventory, getStatusReport: mocks.getStatusReport, renderSandboxInventoryText: mocks.renderSandboxInventoryText, showStatusCommand: mocks.showStatusCommand, })); -vi.mock("../list-command-deps", () => ({ +vi.mock("../lib/list-command-deps", () => ({ buildListCommandDeps: mocks.buildListCommandDeps, })); -vi.mock("../status-command-deps", () => ({ +vi.mock("../lib/status-command-deps", () => ({ buildStatusCommandDeps: mocks.buildStatusCommandDeps, })); -vi.mock("../actions/global", () => ({ +vi.mock("../lib/actions/global", () => ({ runBackupAllAction: mocks.runBackupAllAction, runGarbageCollectImagesAction: mocks.runGarbageCollectImagesAction, runOnboardAction: mocks.runOnboardAction, @@ -44,7 +44,7 @@ vi.mock("../actions/global", () => ({ runUpgradeSandboxesAction: mocks.runUpgradeSandboxesAction, })); -vi.mock("../actions/inference-set", () => ({ +vi.mock("../lib/actions/inference-set", () => ({ InferenceSetError: class InferenceSetError extends Error { exitCode: number; @@ -56,7 +56,7 @@ vi.mock("../actions/inference-set", () => ({ runInferenceSet: mocks.runInferenceSet, })); -vi.mock("../actions/inference-get", () => ({ +vi.mock("../lib/actions/inference-get", () => ({ InferenceGetError: class InferenceGetError extends Error { exitCode: number; @@ -68,14 +68,14 @@ vi.mock("../actions/inference-get", () => ({ runInferenceGet: mocks.runInferenceGet, })); -import { InferenceGetError } from "../actions/inference-get"; -import { InferenceSetError } from "../actions/inference-set"; +import { InferenceGetError } from "../lib/actions/inference-get"; +import { InferenceSetError } from "../lib/actions/inference-set"; import InferenceGetCommand from "./inference/get"; import InferenceSetCommand from "./inference/set"; import ListCommand from "./list"; -import BackupAllCommand from "./maintenance/backup-all"; -import GarbageCollectImagesCommand from "./maintenance/gc"; -import UpgradeSandboxesCommand from "./maintenance/upgrade-sandboxes"; +import BackupAllCommand from "./backup-all"; +import GarbageCollectImagesCommand from "./gc"; +import UpgradeSandboxesCommand from "./upgrade-sandboxes"; import OnboardCliCommand from "./onboard"; import SetupCliCommand from "./setup"; import SetupSparkCliCommand from "./setup-spark"; @@ -124,6 +124,28 @@ describe("global oclif command adapters", () => { expect(mocks.showStatusCommand).toHaveBeenCalledWith({ statusDeps: true }); }); + it("maps status JSON output into oclif JSON handling", async () => { + const report = { + schemaVersion: 1, + defaultSandbox: "alpha", + liveInference: null, + gatewayHealth: null, + sandboxes: [], + services: [], + }; + mocks.getStatusReport.mockReturnValueOnce(report); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + await StatusCommand.run(["--json"], rootDir); + expect(mocks.buildStatusCommandDeps).toHaveBeenCalledWith(rootDir); + expect(mocks.getStatusReport).toHaveBeenCalledWith({ statusDeps: true }); + expect(mocks.showStatusCommand).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls.at(-1)?.[0]))).toEqual(report); + } finally { + log.mockRestore(); + } + }); + it("maps maintenance flags to typed action options", async () => { await BackupAllCommand.run([], rootDir); await UpgradeSandboxesCommand.run(["--check", "--yes"], rootDir); @@ -174,10 +196,19 @@ describe("global oclif command adapters", () => { }); }); - it("maps inference get flags into the inference action", async () => { - await InferenceGetCommand.run(["--json"], rootDir); - - expect(mocks.runInferenceGet).toHaveBeenCalledWith({ json: true }); + it("maps inference get JSON output into oclif JSON handling", async () => { + mocks.runInferenceGet.mockResolvedValueOnce({ provider: "nvidia-prod", model: "nvidia/model-a" }); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + await InferenceGetCommand.run(["--json"], rootDir); + expect(mocks.runInferenceGet).toHaveBeenCalledWith({ quiet: true }); + expect(JSON.parse(String(log.mock.calls.at(-1)?.[0]))).toEqual({ + provider: "nvidia-prod", + model: "nvidia/model-a", + }); + } finally { + log.mockRestore(); + } }); it("records inference action failures without throwing oclif ExitError", async () => { diff --git a/src/commands/inference/get.ts b/src/commands/inference/get.ts index 864e9eab171..536c0797a37 100644 --- a/src/commands/inference/get.ts +++ b/src/commands/inference/get.ts @@ -1,16 +1,33 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/inference/get"; -import { withCommandDisplay } from "../../lib/cli/command-display"; +import { + InferenceGetError, + runInferenceGet, +} from "../../lib/actions/inference-get"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw inference get", - description: "Show the active inference provider and model", - flags: "[--json]", - group: "Services", - scope: "global", - order: 36, - }, -]); +export default class InferenceGetCommand extends NemoClawCommand { + static id = "inference:get"; + static strict = true; + static enableJsonFlag = true; + static summary = "Show the active NemoClaw inference route"; + static description = "Read the live OpenShell inference route through the NemoClaw CLI."; + static usage = ["inference get [--json]"]; + static examples = ["<%= config.bin %> inference get", "<%= config.bin %> inference get --json"]; + static flags = {}; + + public async run(): Promise { + await this.parse(InferenceGetCommand); + try { + const result = await runInferenceGet({ quiet: this.jsonEnabled() }); + if (this.jsonEnabled()) return result; + } catch (error) { + if (error instanceof InferenceGetError) { + this.failWithLines([error.message], error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/inference/set.ts b/src/commands/inference/set.ts index 9f84bb3b9de..7c518ef992b 100644 --- a/src/commands/inference/set.ts +++ b/src/commands/inference/set.ts @@ -1,16 +1,66 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/inference/set"; -import { withCommandDisplay } from "../../lib/cli/command-display"; +import { Flags } from "@oclif/core"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw inference set", - description: "Switch inference and sync the running agent config", - flags: "--provider --model [--sandbox ] [--no-verify]", - group: "Services", - scope: "global", - order: 37, - }, -]); +function requiredNonEmptyFlag(description: string) { + return Flags.string({ + description, + parse: async (input: string) => { + const trimmed = input.trim(); + if (!trimmed) throw new Error(`${description} cannot be empty`); + return trimmed; + }, + required: true, + }); +} + +import { + InferenceSetError, + runInferenceSet, +} from "../../lib/actions/inference-set"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; + +export default class InferenceSetCommand extends NemoClawCommand { + static id = "inference:set"; + static strict = true; + static summary = "Switch the NemoClaw inference model"; + static description = + "Update the OpenShell inference route and sync the running OpenClaw or Hermes sandbox config."; + static usage = [ + "inference set --provider --model [--sandbox ] [--no-verify]", + ]; + static examples = [ + "<%= config.bin %> inference set --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b", + "<%= config.bin %> inference set --provider openai-api --model gpt-5.4 --sandbox my-assistant", + ]; + static flags = { + provider: requiredNonEmptyFlag("OpenShell inference provider name"), + model: requiredNonEmptyFlag("Model id to route through the selected provider"), + sandbox: Flags.string({ + description: + "Registered sandbox to sync; defaults to the NemoClaw default sandbox or the unambiguous Hermes sandbox under nemohermes", + }), + "no-verify": Flags.boolean({ + description: "Pass --no-verify through to openshell inference set", + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(InferenceSetCommand); + try { + await runInferenceSet({ + provider: flags.provider, + model: flags.model, + sandboxName: flags.sandbox ?? null, + noVerify: flags["no-verify"] === true, + }); + } catch (error) { + if (error instanceof InferenceSetError) { + this.failWithLines([error.message], error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/internal/installer/normalize-env.ts b/src/commands/internal/installer/normalize-env.ts index a7c2fb3ae79..65d4e3f31bb 100644 --- a/src/commands/internal/installer/normalize-env.ts +++ b/src/commands/internal/installer/normalize-env.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Flags } from "@oclif/core"; +import { jsonFlag } from "../../../lib/cli/common-flags"; import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; import { normalizeInstallerEnv } from "../../../lib/actions/installer/plan"; @@ -14,7 +15,7 @@ export default class InternalInstallerNormalizeEnvCommand extends NemoClawComman static usage = ["internal installer normalize-env [--json]"]; static examples = ["<%= config.bin %> internal installer normalize-env --provider cloud --json"]; static flags = { - json: Flags.boolean({ description: "Print normalized values as JSON" }), + json: jsonFlag("Print normalized values as JSON"), "install-ref": Flags.string({ description: "NEMOCLAW_INSTALL_REF value" }), "install-tag": Flags.string({ description: "NEMOCLAW_INSTALL_TAG value" }), provider: Flags.string({ description: "NEMOCLAW_PROVIDER value" }), diff --git a/src/commands/internal/installer/plan.ts b/src/commands/internal/installer/plan.ts index 1a118a8f9c3..8d43e79d7ef 100644 --- a/src/commands/internal/installer/plan.ts +++ b/src/commands/internal/installer/plan.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Flags } from "@oclif/core"; +import { jsonFlag } from "../../../lib/cli/common-flags"; import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; import { buildInstallerPlan } from "../../../lib/actions/installer/plan"; @@ -14,7 +15,7 @@ export default class InternalInstallerPlanCommand extends NemoClawCommand { static usage = ["internal installer plan [--json]"]; static examples = ["<%= config.bin %> internal installer plan --json --provider nim --install-ref v0.1.0"]; static flags = { - json: Flags.boolean({ description: "Print the installer plan as JSON" }), + json: jsonFlag("Print the installer plan as JSON"), "install-ref": Flags.string({ description: "Install ref override" }), "install-tag": Flags.string({ description: "Install tag fallback" }), "git-describe-version": Flags.string({ description: "git describe version fallback", hidden: true }), diff --git a/src/commands/internal/installer/resolve-release-tag.ts b/src/commands/internal/installer/resolve-release-tag.ts index c4c270af6ea..8ed3532ec3e 100644 --- a/src/commands/internal/installer/resolve-release-tag.ts +++ b/src/commands/internal/installer/resolve-release-tag.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Flags } from "@oclif/core"; +import { jsonFlag } from "../../../lib/cli/common-flags"; import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; import { resolveInstallRef } from "../../../lib/domain/installer/ref"; @@ -14,7 +15,7 @@ export default class InternalInstallerResolveReleaseTagCommand extends NemoClawC static usage = ["internal installer resolve-release-tag [--json]"]; static examples = ["<%= config.bin %> internal installer resolve-release-tag --install-ref v0.1.0"]; static flags = { - json: Flags.boolean({ description: "Print the resolved ref as JSON" }), + json: jsonFlag("Print the resolved ref as JSON"), "install-ref": Flags.string({ description: "NEMOCLAW_INSTALL_REF value" }), "install-tag": Flags.string({ description: "NEMOCLAW_INSTALL_TAG value" }), }; diff --git a/src/commands/internal/uninstall/classify-shim.ts b/src/commands/internal/uninstall/classify-shim.ts index 2b59619b25c..358606bd053 100644 --- a/src/commands/internal/uninstall/classify-shim.ts +++ b/src/commands/internal/uninstall/classify-shim.ts @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; +import { jsonFlag } from "../../../lib/cli/common-flags"; import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; import { classifyShimPath } from "../../../lib/actions/uninstall/plan"; @@ -17,7 +18,7 @@ export default class InternalUninstallClassifyShimCommand extends NemoClawComman path: Args.string({ description: "Shim path to classify", required: true }), }; static flags = { - json: Flags.boolean({ description: "Print classification as JSON" }), + json: jsonFlag("Print classification as JSON"), }; public async run(): Promise { diff --git a/src/commands/internal/uninstall/plan.ts b/src/commands/internal/uninstall/plan.ts index a118f19a95f..1064c83e3f1 100644 --- a/src/commands/internal/uninstall/plan.ts +++ b/src/commands/internal/uninstall/plan.ts @@ -2,10 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { Flags } from "@oclif/core"; +import { CLI_DISPLAY_NAME, CLI_NAME } from "../../../lib/cli/branding"; +import { jsonFlag } from "../../../lib/cli/common-flags"; import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; import { buildHostUninstallPlan } from "../../../lib/actions/uninstall/plan"; -import { CLI_DISPLAY_NAME, CLI_NAME } from "../../../lib/cli/branding"; export default class InternalUninstallPlanCommand extends NemoClawCommand { static hidden = true; @@ -15,7 +16,7 @@ export default class InternalUninstallPlanCommand extends NemoClawCommand { static usage = ["internal uninstall plan [--json] [--delete-models] [--keep-openshell]"]; static examples = [`${CLI_NAME} internal uninstall plan --json --yes`]; static flags = { - json: Flags.boolean({ description: "Print the uninstall plan as JSON" }), + json: jsonFlag("Print the uninstall plan as JSON"), yes: Flags.boolean({ description: "Accepted for parity with run-plan; ignored while planning" }), "delete-models": Flags.boolean({ description: `Plan removal of ${CLI_DISPLAY_NAME}-pulled Ollama models` }), "keep-openshell": Flags.boolean({ description: "Keep the openshell binary installed" }), diff --git a/src/commands/list.ts b/src/commands/list.ts index 15f16a22b6c..d1953a47854 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,16 +1,30 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../lib/commands/list"; -import { withCommandDisplay } from "../lib/cli/command-display"; +import { getSandboxInventory, renderSandboxInventoryText } from "../lib/inventory"; +import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; +import { buildListCommandDeps } from "../lib/list-command-deps"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw list", - description: "List all sandboxes", - flags: "[--json]", - group: "Sandbox Management", - scope: "global", - order: 2, - }, -]); +export default class ListCommand extends NemoClawCommand { + static id = "list"; + static strict = true; + static enableJsonFlag = true; + static summary = "List all sandboxes"; + static description = + "List all registered sandboxes with their model, provider, and policy presets."; + static usage = ["list [--json]"]; + static examples = ["<%= config.bin %> list", "<%= config.bin %> list --json"]; + static flags = {}; + + public async run(): Promise { + await this.parse(ListCommand); + const deps = buildListCommandDeps(); + const inventory = await getSandboxInventory(deps); + if (this.jsonEnabled()) { + return inventory; + } + + const liveInference = inventory.sandboxes.length > 0 ? deps.getLiveInference() : null; + renderSandboxInventoryText(inventory, this.log.bind(this), liveInference); + } +} diff --git a/src/lib/commands/onboard.test.ts b/src/commands/onboard.test.ts similarity index 95% rename from src/lib/commands/onboard.test.ts rename to src/commands/onboard.test.ts index ea7608afd6b..6c0fcc2c6a6 100644 --- a/src/lib/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -3,10 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runOnboardAction } from "../actions/global"; +import { runOnboardAction } from "../lib/actions/global"; import OnboardCliCommand from "./onboard"; -vi.mock("../actions/global", () => ({ +vi.mock("../lib/actions/global", () => ({ runOnboardAction: vi.fn().mockResolvedValue(undefined), runSetupAction: vi.fn().mockResolvedValue(undefined), runSetupSparkAction: vi.fn().mockResolvedValue(undefined), diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index ea8b4b56c8a..c9faf6a297c 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -1,22 +1,28 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../lib/commands/onboard"; -import { withCommandDisplay } from "../lib/cli/command-display"; +import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw onboard", - description: "Configure inference endpoint and credentials", - group: "Getting Started", - scope: "global", - order: 0, - }, - { - usage: "nemoclaw onboard --from", - description: "Use a custom Dockerfile for the sandbox image", - group: "Getting Started", - scope: "global", - order: 1, - }, -]); +import { runOnboardAction } from "../lib/actions/global"; +import { + buildOnboardFlags, + onboardExamples, + type OnboardFlags, + onboardUsage, + toLegacyOnboardArgs, +} from "../lib/onboard/command-support"; + +export default class OnboardCliCommand extends NemoClawCommand { + static id = "onboard"; + static strict = true; + static summary = "Configure inference endpoint and credentials"; + static description = "Configure inference, credentials, and sandbox settings."; + static usage = onboardUsage; + static examples = onboardExamples; + static flags = buildOnboardFlags(); + + public async run(): Promise { + const { flags } = await this.parse(OnboardCliCommand); + await runOnboardAction(toLegacyOnboardArgs(flags as OnboardFlags)); + } +} diff --git a/src/commands/root/help.ts b/src/commands/root/help.ts index 0930217c28b..147cb0ba349 100644 --- a/src/commands/root/help.ts +++ b/src/commands/root/help.ts @@ -1,32 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/root/help"; -import { withCommandDisplay } from "../../lib/cli/command-display"; +import { showRootHelp } from "../../lib/actions/global"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw help", - description: "Show help", - group: "Getting Started", - hidden: true, - scope: "global", - order: 44, - }, - { - usage: "nemoclaw --help", - description: "Show help", - group: "Getting Started", - hidden: true, - scope: "global", - order: 45, - }, - { - usage: "nemoclaw -h", - description: "Show help", - group: "Getting Started", - hidden: true, - scope: "global", - order: 46, - }, -]); +export default class RootHelpCommand extends NemoClawCommand { + static id = "root:help"; + static hidden = true; + static strict = false; + static summary = "Show help"; + + public async run(): Promise { + this.parsed = true; + showRootHelp(); + } +} diff --git a/src/commands/root/version.ts b/src/commands/root/version.ts index 95e77908fd8..ea3381a958d 100644 --- a/src/commands/root/version.ts +++ b/src/commands/root/version.ts @@ -1,24 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/root/version"; -import { withCommandDisplay } from "../../lib/cli/command-display"; +import { showVersion } from "../../lib/actions/global"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw --version", - description: "Show version", - group: "Getting Started", - hidden: true, - scope: "global", - order: 47, - }, - { - usage: "nemoclaw -v", - description: "Show version", - group: "Getting Started", - hidden: true, - scope: "global", - order: 48, - }, -]); +export default class VersionCommand extends NemoClawCommand { + static id = "root:version"; + static hidden = true; + static strict = true; + static summary = "Show version"; + + public async run(): Promise { + this.parsed = true; + showVersion(); + } +} diff --git a/src/commands/sandbox/channels/add.ts b/src/commands/sandbox/channels/add.ts index c58f5b53e65..fe3f25a5d96 100644 --- a/src/commands/sandbox/channels/add.ts +++ b/src/commands/sandbox/channels/add.ts @@ -1,16 +1,30 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/channels/add"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw channels add", - description: "Save credentials and rebuild", - flags: " [--dry-run]", - group: "Messaging Channels", - scope: "sandbox", - order: 21, - }, -]); +import { + buildChannelArgs, + channelMutationArgs, + channelMutationFlags, + getChannelsRuntimeBridge, +} from "../../../lib/sandbox/channels-command-support"; + +export default class ChannelsAddCommand extends NemoClawCommand { + static id = "sandbox:channels:add"; + static strict = true; + static summary = "Save messaging channel credentials and rebuild"; + static description = "Store credentials for a messaging channel and queue a sandbox rebuild."; + static usage = [" [--dry-run]"]; + static examples = ["<%= config.bin %> sandbox channels add alpha telegram"]; + static args = channelMutationArgs; + static flags = channelMutationFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(ChannelsAddCommand); + await getChannelsRuntimeBridge().sandboxChannelsAdd( + args.sandboxName, + buildChannelArgs(args.channel, flags), + ); + } +} diff --git a/src/commands/sandbox/channels/list.ts b/src/commands/sandbox/channels/list.ts index a053b236640..8139f05a3f8 100644 --- a/src/commands/sandbox/channels/list.ts +++ b/src/commands/sandbox/channels/list.ts @@ -1,15 +1,26 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/channels/list"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; - -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw channels list", - description: "List supported messaging channels", - group: "Messaging Channels", - scope: "sandbox", - order: 20, - }, -]); +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +import { listSandboxChannels } from "../../../lib/actions/sandbox/policy-channel"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class SandboxChannelsListCommand extends NemoClawCommand { + 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 = [""]; + static examples = ["<%= config.bin %> sandbox channels list alpha"]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(SandboxChannelsListCommand); + listSandboxChannels(args.sandboxName); + } +} diff --git a/src/lib/commands/sandbox/channels/mutate.test.ts b/src/commands/sandbox/channels/mutate.test.ts similarity index 96% rename from src/lib/commands/sandbox/channels/mutate.test.ts rename to src/commands/sandbox/channels/mutate.test.ts index 7b36bdd475c..6478904f3e4 100644 --- a/src/lib/commands/sandbox/channels/mutate.test.ts +++ b/src/commands/sandbox/channels/mutate.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import ChannelsAddCommand from "./add"; -import { setChannelsRuntimeBridgeFactoryForTest } from "./common"; +import { setChannelsRuntimeBridgeFactoryForTest } from "../../../lib/sandbox/channels-command-support"; import ChannelsRemoveCommand from "./remove"; import ChannelsStartCommand from "./start"; import ChannelsStopCommand from "./stop"; diff --git a/src/commands/sandbox/channels/remove.ts b/src/commands/sandbox/channels/remove.ts index f82e484865e..5d9c91d3814 100644 --- a/src/commands/sandbox/channels/remove.ts +++ b/src/commands/sandbox/channels/remove.ts @@ -1,16 +1,30 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/channels/remove"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw channels remove", - description: "Remove a configured messaging channel", - flags: " [--dry-run]", - group: "Messaging Channels", - scope: "sandbox", - order: 22, - }, -]); +import { + buildChannelArgs, + channelMutationArgs, + channelMutationFlags, + getChannelsRuntimeBridge, +} from "../../../lib/sandbox/channels-command-support"; + +export default class ChannelsRemoveCommand extends NemoClawCommand { + static id = "sandbox:channels:remove"; + static strict = true; + static summary = "Clear messaging channel credentials and rebuild"; + static description = "Remove credentials for a messaging channel and queue a sandbox rebuild."; + static usage = [" [--dry-run]"]; + static examples = ["<%= config.bin %> sandbox channels remove alpha slack --dry-run"]; + static args = channelMutationArgs; + static flags = channelMutationFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(ChannelsRemoveCommand); + await getChannelsRuntimeBridge().sandboxChannelsRemove( + args.sandboxName, + buildChannelArgs(args.channel, flags), + ); + } +} diff --git a/src/commands/sandbox/channels/start.ts b/src/commands/sandbox/channels/start.ts index c2d186c05c4..69f3f44c688 100644 --- a/src/commands/sandbox/channels/start.ts +++ b/src/commands/sandbox/channels/start.ts @@ -1,16 +1,30 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/channels/start"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw channels start", - description: "Re-enable a previously stopped channel", - flags: " [--dry-run]", - group: "Messaging Channels", - scope: "sandbox", - order: 24, - }, -]); +import { + buildChannelArgs, + channelMutationArgs, + channelMutationFlags, + getChannelsRuntimeBridge, +} from "../../../lib/sandbox/channels-command-support"; + +export default class ChannelsStartCommand extends NemoClawCommand { + static id = "sandbox:channels:start"; + static strict = true; + static summary = "Re-enable a stopped messaging channel"; + static description = "Re-enable a previously stopped messaging channel."; + static usage = [" [--dry-run]"]; + static examples = ["<%= config.bin %> sandbox channels start alpha discord"]; + static args = channelMutationArgs; + static flags = channelMutationFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(ChannelsStartCommand); + await getChannelsRuntimeBridge().sandboxChannelsStart( + args.sandboxName, + buildChannelArgs(args.channel, flags), + ); + } +} diff --git a/src/commands/sandbox/channels/stop.ts b/src/commands/sandbox/channels/stop.ts index ff578e84645..c01cef8d531 100644 --- a/src/commands/sandbox/channels/stop.ts +++ b/src/commands/sandbox/channels/stop.ts @@ -1,16 +1,30 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/channels/stop"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw channels stop", - description: "Disable channel (keeps credentials)", - flags: " [--dry-run]", - group: "Messaging Channels", - scope: "sandbox", - order: 23, - }, -]); +import { + buildChannelArgs, + channelMutationArgs, + channelMutationFlags, + getChannelsRuntimeBridge, +} from "../../../lib/sandbox/channels-command-support"; + +export default class ChannelsStopCommand extends NemoClawCommand { + static id = "sandbox:channels:stop"; + static strict = true; + static summary = "Disable channel without wiping credentials"; + static description = "Disable a messaging channel while keeping credentials in the gateway."; + static usage = [" [--dry-run]"]; + static examples = ["<%= config.bin %> sandbox channels stop alpha discord"]; + static args = channelMutationArgs; + static flags = channelMutationFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(ChannelsStopCommand); + await getChannelsRuntimeBridge().sandboxChannelsStop( + args.sandboxName, + buildChannelArgs(args.channel, flags), + ); + } +} diff --git a/src/commands/sandbox/config/get.ts b/src/commands/sandbox/config/get.ts index 0530c6a7c01..6b46f5d27c9 100644 --- a/src/commands/sandbox/config/get.ts +++ b/src/commands/sandbox/config/get.ts @@ -1,17 +1,46 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/config/get"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw config get", - description: "Get sandbox configuration", - flags: "[--key ] [--format json|yaml]", - group: "Sandbox Management", - hidden: true, - scope: "sandbox", - order: 28, - }, -]); +import * as sandboxConfig from "../../../lib/sandbox/config"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class SandboxConfigGetCommand extends NemoClawCommand { + static id = "sandbox:config:get"; + static strict = true; + static summary = "Get sandbox configuration"; + static description = "Read sanitized sandbox agent configuration."; + static usage = [" [--key dotpath] [--format json|yaml]"]; + static examples = [ + "<%= config.bin %> sandbox config get alpha", + "<%= config.bin %> sandbox config get alpha --key model --format yaml", + ]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + key: Flags.string({ description: "Dotpath to read from the sanitized config" }), + format: Flags.string({ + description: "Output format", + options: ["json", "yaml"], + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxConfigGetCommand); + try { + sandboxConfig.configGet(args.sandboxName, { + key: flags.key ?? null, + format: flags.format ?? "json", + }); + } catch (error) { + if (error instanceof sandboxConfig.SandboxConfigError) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/config/rotate-token.ts b/src/commands/sandbox/config/rotate-token.ts new file mode 100644 index 00000000000..544857b967d --- /dev/null +++ b/src/commands/sandbox/config/rotate-token.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +import * as sandboxConfig from "../../../lib/sandbox/config"; + +const sandboxNameArg = Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, +}); + +export default class SandboxConfigRotateTokenCommand extends NemoClawCommand { + static id = "sandbox:config:rotate-token"; + static hidden = true; + static strict = true; + static summary = "Rotate sandbox provider credentials"; + static description = + "Rotate sandbox provider credentials through the configured OpenShell provider."; + static usage = [" [--from-env ] [--stdin]"]; + static examples = [ + "<%= config.bin %> sandbox config rotate-token alpha", + "<%= config.bin %> sandbox config rotate-token alpha --from-env NVIDIA_API_KEY", + ]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + "from-env": Flags.string({ + description: "Read the replacement credential from this environment variable", + }), + stdin: Flags.boolean({ description: "Read the replacement credential from stdin" }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxConfigRotateTokenCommand); + try { + await sandboxConfig.configRotateToken(args.sandboxName, { + fromEnv: flags["from-env"] ?? null, + fromStdin: flags.stdin ?? false, + }); + } catch (error) { + if (error instanceof sandboxConfig.SandboxConfigError) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/config/set.ts b/src/commands/sandbox/config/set.ts index 01ec6d03304..36576ac0bbd 100644 --- a/src/commands/sandbox/config/set.ts +++ b/src/commands/sandbox/config/set.ts @@ -1,24 +1,58 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/config/set"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw config set", - description: "Set sandbox configuration with SSRF validation", - group: "Sandbox Management", - hidden: true, - scope: "sandbox", - order: 29, - }, - { - usage: "nemoclaw config rotate-token", - description: "Rotate sandbox provider credentials", - group: "Sandbox Management", - hidden: true, - scope: "sandbox", - order: 30, - }, -]); +import { Args, Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +import * as sandboxConfig from "../../../lib/sandbox/config"; + +const sandboxNameArg = Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, +}); + +export default class SandboxConfigSetCommand extends NemoClawCommand { + static id = "sandbox:config:set"; + static strict = true; + static summary = "Set sandbox configuration"; + static description = "Set sandbox agent configuration with new-path and SSRF validation."; + static usage = [" --key --value [--restart] [--config-accept-new-path]"]; + static examples = [ + "<%= config.bin %> alpha config set --key model --value nvidia/nemotron", + '<%= config.bin %> alpha config set --key web_search --value true --restart', + ]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + key: Flags.string({ description: "Dotpath to update in the config", required: true }), + value: Flags.string({ + description: "Value to write; JSON values are parsed when possible", + required: true, + }), + restart: Flags.boolean({ description: "Signal the sandbox agent process to reload after writing" }), + "config-accept-new-path": Flags.boolean({ + description: "Allow creating a config key that does not already exist", + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxConfigSetCommand); + try { + await sandboxConfig.configSet(args.sandboxName, { + key: flags.key ?? null, + value: flags.value ?? null, + restart: flags.restart ?? false, + acceptNewPath: flags["config-accept-new-path"] ?? false, + }); + } catch (error) { + if (error instanceof sandboxConfig.SandboxConfigError) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/connect.ts b/src/commands/sandbox/connect.ts index 7991445740a..eafc9cad66c 100644 --- a/src/commands/sandbox/connect.ts +++ b/src/commands/sandbox/connect.ts @@ -1,16 +1,41 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/sandbox/connect"; -import { withCommandDisplay } from "../../lib/cli/command-display"; +import { Args, Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw connect", - description: "Shell into a running sandbox", - flags: "[--probe-only]", - group: "Sandbox Management", - scope: "sandbox", - order: 3, - }, -]); +import { CLI_NAME } from "../../lib/cli/branding"; +import { connectSandbox } from "../../lib/actions/sandbox/connect"; + +export default class ConnectCliCommand extends NemoClawCommand { + static id = "sandbox:connect"; + static strict = true; + static summary = "Shell into a running sandbox"; + static description = "Connect to a running sandbox."; + static usage = [" [--probe-only]"]; + static examples = [ + "<%= config.bin %> sandbox connect alpha", + "<%= config.bin %> sandbox connect alpha --probe-only", + ]; + static args = { + sandboxName: Args.string({ name: "sandbox", description: "Sandbox name", required: true }), + }; + static flags = { + "probe-only": Flags.boolean({ description: "Recover and check the sandbox without opening SSH" }), + "dangerously-skip-permissions": Flags.boolean({ hidden: true }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(ConnectCliCommand); + if (flags["dangerously-skip-permissions"]) { + this.failWithLines([ + " --dangerously-skip-permissions was removed; use shields commands instead.", + ` Usage: ${CLI_NAME} connect [--probe-only]`, + ]); + return; + } + await connectSandbox(args.sandboxName, { + probeOnly: Boolean(flags["probe-only"]), + }); + } +} diff --git a/src/commands/sandbox/destroy.ts b/src/commands/sandbox/destroy.ts index c848fccd391..1522a11fa83 100644 --- a/src/commands/sandbox/destroy.ts +++ b/src/commands/sandbox/destroy.ts @@ -1,16 +1,43 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/sandbox/destroy"; -import { withCommandDisplay } from "../../lib/cli/command-display"; +import { Args, Flags } from "@oclif/core"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw destroy", - description: "Stop NIM + delete sandbox", - flags: "[--yes|-y|--force] [--cleanup-gateway|--no-cleanup-gateway]", - group: "Sandbox Management", - scope: "sandbox", - order: 15, - }, -]); +import { destroySandbox } from "../../lib/actions/sandbox/destroy"; +import { forceFlag, yesFlag } from "../../lib/cli/common-flags"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; + +export default class DestroyCliCommand extends NemoClawCommand { + static id = "sandbox:destroy"; + static strict = true; + static summary = "Stop NIM and delete sandbox"; + static description = "Destroy a sandbox and remove its local registry entry."; + static usage = [" [--yes|-y|--force] [--cleanup-gateway|--no-cleanup-gateway]"]; + static examples = [ + "<%= config.bin %> sandbox destroy alpha", + "<%= config.bin %> sandbox destroy alpha --yes", + "<%= config.bin %> sandbox destroy alpha --yes --cleanup-gateway", + ]; + static args = { + sandboxName: Args.string({ name: "sandbox", description: "Sandbox name", required: true }), + }; + static flags = { + yes: yesFlag(), + force: forceFlag(), + "cleanup-gateway": Flags.boolean({ + description: + "When destroying the last sandbox, also tear down the shared NemoClaw gateway. Default: preserve. NEMOCLAW_CLEANUP_GATEWAY=1 sets the same default.", + allowNo: true, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(DestroyCliCommand); + const cleanupGatewayFlag = flags["cleanup-gateway"]; + await destroySandbox(args.sandboxName, { + force: flags.force === true, + yes: flags.yes === true, + ...(cleanupGatewayFlag === undefined ? {} : { cleanupGateway: cleanupGatewayFlag }), + }); + } +} diff --git a/src/commands/sandbox/doctor.ts b/src/commands/sandbox/doctor.ts index c030aafa673..44220834dcf 100644 --- a/src/commands/sandbox/doctor.ts +++ b/src/commands/sandbox/doctor.ts @@ -1,16 +1,38 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/sandbox/doctor"; -import { withCommandDisplay } from "../../lib/cli/command-display"; +import { Args } from "@oclif/core"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw doctor", - description: "Run host, gateway, sandbox, and inference health checks", - flags: "[--json]", - group: "Sandbox Management", - scope: "sandbox", - order: 5, - }, -]); +import { runSandboxDoctor } from "../../lib/actions/sandbox/doctor"; + +export default class SandboxDoctorCliCommand extends NemoClawCommand { + static id = "sandbox:doctor"; + static strict = true; + static enableJsonFlag = true; + static summary = "Diagnose sandbox and gateway health"; + static description = "Run host, gateway, sandbox, inference, messaging, and local service diagnostics."; + static usage = [" [--json]"]; + static examples = ["<%= config.bin %> sandbox doctor alpha", "<%= config.bin %> sandbox doctor alpha --json"]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = {}; + + public async run(): Promise { + const { args } = await this.parse(SandboxDoctorCliCommand); + const report = await runSandboxDoctor( + args.sandboxName, + this.jsonEnabled() ? ["--json"] : [], + { quietJson: this.jsonEnabled() }, + ); + if (this.jsonEnabled()) { + if (report && report.failed > 0) process.exitCode = 1; + return report; + } + } +} diff --git a/src/lib/commands/sandbox/exec.test.ts b/src/commands/sandbox/exec.test.ts similarity index 97% rename from src/lib/commands/sandbox/exec.test.ts rename to src/commands/sandbox/exec.test.ts index b9430eac6a8..7a11a8f1d7c 100644 --- a/src/lib/commands/sandbox/exec.test.ts +++ b/src/commands/sandbox/exec.test.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const execSandboxMock = vi.hoisted(() => vi.fn(async () => {})); -vi.mock("../../actions/sandbox/exec", () => ({ +vi.mock("../../lib/actions/sandbox/exec", () => ({ execSandbox: execSandboxMock, })); diff --git a/src/commands/sandbox/exec.ts b/src/commands/sandbox/exec.ts index 04be3b8602d..e54c64018b2 100644 --- a/src/commands/sandbox/exec.ts +++ b/src/commands/sandbox/exec.ts @@ -1,16 +1,43 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/sandbox/exec"; -import { withCommandDisplay } from "../../lib/cli/command-display"; +import { Args, Flags } from "@oclif/core"; +import { execSandbox } from "../../lib/actions/sandbox/exec"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw exec", - description: "Run a command non-interactively in a running sandbox", - flags: "[--workdir ] [--tty|--no-tty] [--timeout ] -- [args...]", - group: "Sandbox Management", - scope: "sandbox", - order: 4, - }, -]); +export default class SandboxExecCommand extends NemoClawCommand { + static id = "sandbox:exec"; + static strict = false; + static summary = "Run a command non-interactively in a running sandbox"; + static description = + "Run a single command inside a running sandbox via the OpenShell exec endpoint. The command runs as the sandbox user (HOME=/sandbox) and exits with the remote command's exit code. Use `--` to separate exec options from the user command."; + static usage = [" [--workdir ] [--tty|--no-tty] [--timeout ] -- [args...]"]; + static examples = [ + "<%= config.bin %> sandbox exec alpha -- openclaw agent --agent main -m hi", + "<%= config.bin %> sandbox exec alpha --workdir /sandbox -- ls -la", + ]; + static args = { + sandboxName: Args.string({ name: "sandbox", description: "Sandbox name", required: true }), + }; + static flags = { + workdir: Flags.string({ description: "Working directory inside the sandbox" }), + tty: Flags.boolean({ + allowNo: true, + description: "Allocate a pseudo-terminal; defaults to auto-detection", + }), + timeout: Flags.integer({ + min: 0, + description: "Timeout in seconds (0 = no timeout)", + }), + }; + + public async run(): Promise { + const { args, flags, argv } = await this.parse(SandboxExecCommand); + const cmd = argv.slice(1) as string[]; + await execSandbox(args.sandboxName, cmd, { + workdir: flags.workdir, + tty: typeof flags.tty === "boolean" ? flags.tty : null, + timeoutSeconds: flags.timeout, + }); + } +} diff --git a/src/commands/sandbox/gateway/token.ts b/src/commands/sandbox/gateway/token.ts index 408f9a910eb..ab89ca03116 100644 --- a/src/commands/sandbox/gateway/token.ts +++ b/src/commands/sandbox/gateway/token.ts @@ -1,16 +1,99 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/gateway-token"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; - -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw gateway-token", - description: "Print the OpenClaw gateway auth token to stdout", - flags: "[--quiet|-q]", - group: "Sandbox Management", - scope: "sandbox", - order: 14, - }, -]); +import { Args } from "@oclif/core"; +import { quietFlag } from "../../../lib/cli/common-flags"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +import { GatewayTokenCommandError, runGatewayTokenCommand } from "../../../lib/gateway-token-command"; + +type GatewayTokenRuntimeBridge = { + fetchGatewayAuthTokenFromSandbox: (sandboxName: string) => string | null; + getSandboxAgent: (sandboxName: string) => string | null; +}; + +let runtimeBridgeFactory = (): GatewayTokenRuntimeBridge => { + const onboard = require("../../../lib/onboard") as Pick< + GatewayTokenRuntimeBridge, + "fetchGatewayAuthTokenFromSandbox" + >; + const registry = require("../../../lib/state/registry") as { + getSandbox: (name: string) => { agent?: string | null } | null; + }; + return { + fetchGatewayAuthTokenFromSandbox: onboard.fetchGatewayAuthTokenFromSandbox, + getSandboxAgent: (sandboxName: string) => { + try { + return registry.getSandbox(sandboxName)?.agent ?? null; + } catch { + return null; + } + }, + }; +}; + +export function setGatewayTokenRuntimeBridgeFactoryForTest( + factory: () => GatewayTokenRuntimeBridge, +): void { + runtimeBridgeFactory = factory; +} + +function getRuntimeBridge(): GatewayTokenRuntimeBridge { + return runtimeBridgeFactory(); +} + +export default class GatewayTokenCliCommand extends NemoClawCommand { + 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 = [" [--quiet|-q]"]; + static examples = [ + "<%= config.bin %> sandbox gateway token alpha", + "<%= config.bin %> sandbox gateway token alpha --quiet", + ]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + quiet: quietFlag("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.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE") { + this.setExitCode(0); + return; + } + throw err; + }); + + const runtime = getRuntimeBridge(); + try { + runGatewayTokenCommand( + args.sandboxName, + { quiet: flags.quiet === true }, + { + fetchToken: runtime.fetchGatewayAuthTokenFromSandbox, + getSandboxAgent: runtime.getSandboxAgent, + }, + ); + // CodeRabbit #3182: if a prior run() left process.exitCode = 1, a later + // successful invocation must still report success. Always overwrite. + this.setExitCode(0); + } catch (error) { + if (error instanceof GatewayTokenCommandError) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/hosts/add.ts b/src/commands/sandbox/hosts/add.ts index 89596af94d8..ca1da881058 100644 --- a/src/commands/sandbox/hosts/add.ts +++ b/src/commands/sandbox/hosts/add.ts @@ -1,16 +1,39 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/hosts/add"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw hosts-add", - description: "Add a sandbox /etc/hosts alias", - flags: "(--dry-run)", - group: "Policy Presets", - scope: "sandbox", - order: 19.1, - }, -]); +import { + buildHostAliasArgs, + getHostsRuntimeBridge, + hostAliasAddArgs, + hostAliasMutationFlags, + isHostAliasFailure, +} from "../../../lib/sandbox/hosts-command-support"; + +export default class HostsAddCommand extends NemoClawCommand { + static id = "sandbox:hosts:add"; + static strict = true; + static summary = "Add a sandbox /etc/hosts alias"; + static description = "Add a host alias to the sandbox pod template."; + static usage = [" [--dry-run]"]; + static examples = ["<%= config.bin %> sandbox hosts add alpha searxng.local 192.168.1.105"]; + static args = hostAliasAddArgs; + static flags = hostAliasMutationFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(HostsAddCommand); + try { + getHostsRuntimeBridge().addSandboxHostAlias( + args.sandboxName, + buildHostAliasArgs([args.hostname, args.ip], flags), + ); + } catch (error) { + if (isHostAliasFailure(error)) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/hosts/list.ts b/src/commands/sandbox/hosts/list.ts index 0e16b537819..e13fb0128e0 100644 --- a/src/commands/sandbox/hosts/list.ts +++ b/src/commands/sandbox/hosts/list.ts @@ -1,15 +1,31 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/hosts/list"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; - -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw hosts-list", - description: "List sandbox host aliases", - group: "Policy Presets", - scope: "sandbox", - order: 19.2, - }, -]); +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +import { getHostsRuntimeBridge, hostAliasSandboxArgs, isHostAliasFailure } from "../../../lib/sandbox/hosts-command-support"; + +export default class HostsListCommand extends NemoClawCommand { + static id = "sandbox:hosts:list"; + static strict = true; + static summary = "List sandbox host aliases"; + static description = "List host aliases configured on the sandbox resource."; + static usage = [""]; + static examples = ["<%= config.bin %> sandbox hosts list alpha"]; + static args = hostAliasSandboxArgs; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(HostsListCommand); + try { + getHostsRuntimeBridge().listSandboxHostAliases(args.sandboxName); + } catch (error) { + if (isHostAliasFailure(error)) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/hosts/remove.ts b/src/commands/sandbox/hosts/remove.ts index 8ab8df701f1..cfe25c35a55 100644 --- a/src/commands/sandbox/hosts/remove.ts +++ b/src/commands/sandbox/hosts/remove.ts @@ -1,16 +1,39 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/hosts/remove"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw hosts-remove", - description: "Remove a sandbox /etc/hosts alias", - flags: "(--dry-run)", - group: "Policy Presets", - scope: "sandbox", - order: 19.3, - }, -]); +import { + buildHostAliasArgs, + getHostsRuntimeBridge, + hostAliasMutationArgs, + hostAliasMutationFlags, + isHostAliasFailure, +} from "../../../lib/sandbox/hosts-command-support"; + +export default class HostsRemoveCommand extends NemoClawCommand { + static id = "sandbox:hosts:remove"; + static strict = true; + static summary = "Remove a sandbox /etc/hosts alias"; + static description = "Remove a host alias from the sandbox pod template."; + static usage = [" [--dry-run]"]; + static examples = ["<%= config.bin %> sandbox hosts remove alpha searxng.local"]; + static args = hostAliasMutationArgs; + static flags = hostAliasMutationFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(HostsRemoveCommand); + try { + getHostsRuntimeBridge().removeSandboxHostAlias( + args.sandboxName, + buildHostAliasArgs([args.hostname], flags), + ); + } catch (error) { + if (isHostAliasFailure(error)) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/lib/commands/sandbox/logs.test.ts b/src/commands/sandbox/logs.test.ts similarity index 100% rename from src/lib/commands/sandbox/logs.test.ts rename to src/commands/sandbox/logs.test.ts diff --git a/src/commands/sandbox/logs.ts b/src/commands/sandbox/logs.ts index e830f047c2b..417ee02aa3f 100644 --- a/src/commands/sandbox/logs.ts +++ b/src/commands/sandbox/logs.ts @@ -1,16 +1,75 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/sandbox/logs"; -import { withCommandDisplay } from "../../lib/cli/command-display"; - -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw logs", - description: "Stream sandbox logs", - flags: "[--follow] [--tail |-n ] [--since ]", - group: "Sandbox Management", - scope: "sandbox", - order: 6, +import { Args, Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; + +import { logsSinceDurationFlag } from "../../lib/cli/duration-flags"; +import type { SandboxLogsOptions } from "../../lib/domain/sandbox/log-options"; +import { DEFAULT_SANDBOX_LOG_LINES } from "../../lib/domain/sandbox/log-options"; +type SandboxLogsRuntimeBridge = { + sandboxLogs: (sandboxName: string, options: SandboxLogsOptions) => void; +}; + +const DEFAULT_SANDBOX_LOG_LINE_COUNT = Number(DEFAULT_SANDBOX_LOG_LINES); + +let runtimeBridgeFactory = (): SandboxLogsRuntimeBridge => ({ + sandboxLogs: (sandboxName, options) => { + const { showSandboxLogs } = require("../../lib/actions/sandbox/logs") as { + showSandboxLogs: (sandboxName: string, options: SandboxLogsOptions) => void; + }; + showSandboxLogs(sandboxName, options); }, -]); +}); + +export function setSandboxLogsRuntimeBridgeFactoryForTest( + factory: () => SandboxLogsRuntimeBridge, +): void { + runtimeBridgeFactory = factory; +} + +function getRuntimeBridge() { + return runtimeBridgeFactory(); +} + +export default class SandboxLogsCommand extends NemoClawCommand { + 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 = [" [--follow] [--tail |-n ] [--since ]"]; + static examples = [ + "<%= config.bin %> sandbox logs alpha", + "<%= config.bin %> sandbox logs alpha --tail 100", + "<%= config.bin %> sandbox logs alpha --since 5m", + "<%= config.bin %> sandbox logs alpha --follow", + ]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + follow: Flags.boolean({ description: "Follow logs until interrupted" }), + tail: Flags.integer({ + char: "n", + default: DEFAULT_SANDBOX_LOG_LINE_COUNT, + description: "Number of log lines to return", + min: 1, + }), + since: logsSinceDurationFlag({ + description: "Only show logs from this duration ago, such as 5m, 1h, or 30s", + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxLogsCommand); + getRuntimeBridge().sandboxLogs(args.sandboxName, { + follow: flags.follow === true, + lines: String(flags.tail), + since: flags.since ?? null, + }); + } +} diff --git a/src/lib/commands/sandbox/oclif-command-adapters.test.ts b/src/commands/sandbox/oclif-command-adapters.test.ts similarity index 68% rename from src/lib/commands/sandbox/oclif-command-adapters.test.ts rename to src/commands/sandbox/oclif-command-adapters.test.ts index 4187810ca8a..88b90f999cd 100644 --- a/src/lib/commands/sandbox/oclif-command-adapters.test.ts +++ b/src/commands/sandbox/oclif-command-adapters.test.ts @@ -3,50 +3,66 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ - configGet: vi.fn(), - connectSandbox: vi.fn().mockResolvedValue(undefined), - destroySandbox: vi.fn().mockResolvedValue(undefined), - listSandboxChannels: vi.fn(), - listSandboxPolicies: vi.fn(), - rebuildSandbox: vi.fn().mockResolvedValue(undefined), - runSandboxDoctor: vi.fn().mockResolvedValue(undefined), - shieldsDown: vi.fn(), - shieldsStatus: vi.fn(), - shieldsUp: vi.fn(), - showSandboxStatus: vi.fn().mockResolvedValue(undefined), -})); +const mocks = vi.hoisted(() => { + class SandboxConfigError extends Error { + lines: readonly string[]; + exitCode: number; + + constructor(lines: string | readonly string[], exitCode = 1) { + const normalized = Array.isArray(lines) ? lines : [lines]; + super(normalized.join("\n")); + this.lines = normalized; + this.exitCode = exitCode; + } + } + + return { + configGet: vi.fn(), + connectSandbox: vi.fn().mockResolvedValue(undefined), + destroySandbox: vi.fn().mockResolvedValue(undefined), + listSandboxChannels: vi.fn(), + listSandboxPolicies: vi.fn(), + rebuildSandbox: vi.fn().mockResolvedValue(undefined), + runSandboxDoctor: vi.fn().mockResolvedValue(undefined), + shieldsDown: vi.fn(), + shieldsStatus: vi.fn(), + shieldsUp: vi.fn(), + showSandboxStatus: vi.fn().mockResolvedValue(undefined), + SandboxConfigError, + }; +}); -vi.mock("../../actions/sandbox/connect", () => ({ +vi.mock("../../lib/actions/sandbox/connect", () => ({ connectSandbox: mocks.connectSandbox, })); -vi.mock("../../actions/sandbox/destroy", () => ({ +vi.mock("../../lib/actions/sandbox/destroy", () => ({ destroySandbox: mocks.destroySandbox, })); -vi.mock("../../actions/sandbox/rebuild", () => ({ +vi.mock("../../lib/actions/sandbox/rebuild", () => ({ rebuildSandbox: mocks.rebuildSandbox, })); -vi.mock("../../actions/sandbox/status", () => ({ +vi.mock("../../lib/actions/sandbox/status", () => ({ showSandboxStatus: mocks.showSandboxStatus, })); -vi.mock("../../actions/sandbox/policy-channel", () => ({ +vi.mock("../../lib/actions/sandbox/policy-channel", () => ({ listSandboxChannels: mocks.listSandboxChannels, listSandboxPolicies: mocks.listSandboxPolicies, })); -vi.mock("../../sandbox/config", () => ({ +vi.mock("../../lib/sandbox/config", () => ({ configGet: mocks.configGet, + SandboxConfigError: mocks.SandboxConfigError, })); -vi.mock("../../actions/sandbox/doctor", () => ({ +vi.mock("../../lib/actions/sandbox/doctor", () => ({ runSandboxDoctor: mocks.runSandboxDoctor, })); -vi.mock("../../shields", () => ({ +vi.mock("../../lib/shields", () => ({ shieldsDown: mocks.shieldsDown, shieldsStatus: mocks.shieldsStatus, shieldsUp: mocks.shieldsUp, @@ -133,6 +149,25 @@ describe("sandbox oclif command adapters", () => { expect(mocks.configGet).toHaveBeenCalledWith("alpha", { key: "model", format: "yaml" }); }); + it("maps config action failures to oclif exit codes", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + mocks.configGet.mockImplementationOnce(() => { + throw new mocks.SandboxConfigError(["config missing", "try again"], 5); + }); + + await expect(SandboxConfigGetCommand.run(["alpha"], rootDir)).resolves.toBeUndefined(); + expect(process.exitCode).toBe(5); + expect(error).toHaveBeenCalledWith("config missing"); + expect(error).toHaveBeenCalledWith("try again"); + } finally { + process.exitCode = previousExitCode; + error.mockRestore(); + } + }); + it("maps doctor and shields commands to action helpers", async () => { await SandboxDoctorCliCommand.run(["alpha", "--json"], rootDir); await ShieldsDownCommand.run( @@ -142,7 +177,7 @@ describe("sandbox oclif command adapters", () => { await ShieldsUpCommand.run(["alpha"], rootDir); await ShieldsStatusCommand.run(["alpha"], rootDir); - expect(mocks.runSandboxDoctor).toHaveBeenCalledWith("alpha", ["--json"]); + expect(mocks.runSandboxDoctor).toHaveBeenCalledWith("alpha", ["--json"], { quietJson: true }); expect(mocks.shieldsDown).toHaveBeenCalledWith("alpha", { timeout: "5m", reason: "debugging", diff --git a/src/commands/sandbox/policy/add.ts b/src/commands/sandbox/policy/add.ts index 1845fc768b1..4d9dd176948 100644 --- a/src/commands/sandbox/policy/add.ts +++ b/src/commands/sandbox/policy/add.ts @@ -1,16 +1,49 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/policy/add"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw policy-add", - description: "Add a network or filesystem policy preset", - flags: "(--yes, -y, --dry-run, --from-file , --from-dir )", - group: "Policy Presets", - scope: "sandbox", - order: 17, - }, -]); +import { + appendCommonPolicyFlags, + getPolicyRuntimeBridge, + policyMutationArgs, + policyMutationFlags, +} from "../../../lib/sandbox/policy-command-support"; + +export default class PolicyAddCommand extends NemoClawCommand { + static id = "sandbox:policy:add"; + static strict = true; + static summary = "Add a network or filesystem policy preset"; + static description = "Add a built-in or custom policy preset to a sandbox."; + static usage = [ + " [preset] [--yes|-y] [--dry-run] [--from-file ] [--from-dir ]", + ]; + static examples = [ + "<%= config.bin %> sandbox policy add alpha slack --yes", + "<%= config.bin %> sandbox policy add alpha --from-file ./policy.yaml --dry-run", + "<%= config.bin %> sandbox policy add alpha --from-dir ./policies --yes", + ]; + static args = policyMutationArgs; + static flags = { + ...policyMutationFlags, + "from-file": Flags.string({ + description: "Load one custom preset YAML file", + exclusive: ["from-dir"], + }), + "from-dir": Flags.string({ + description: "Load all custom preset YAML files in a directory", + exclusive: ["from-file"], + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(PolicyAddCommand); + const legacyArgs: string[] = []; + if (args.preset) legacyArgs.push(args.preset); + appendCommonPolicyFlags(legacyArgs, flags); + if (flags["from-file"]) legacyArgs.push("--from-file", flags["from-file"]); + if (flags["from-dir"]) legacyArgs.push("--from-dir", flags["from-dir"]); + await getPolicyRuntimeBridge().sandboxPolicyAdd(args.sandboxName, legacyArgs); + } +} diff --git a/src/commands/sandbox/policy/list.ts b/src/commands/sandbox/policy/list.ts index 956d02cdcaa..0fe43c15128 100644 --- a/src/commands/sandbox/policy/list.ts +++ b/src/commands/sandbox/policy/list.ts @@ -1,15 +1,26 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/policy/list"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; - -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw policy-list", - description: "List presets (\u25cf = applied)", - group: "Policy Presets", - scope: "sandbox", - order: 19, - }, -]); +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +import { listSandboxPolicies } from "../../../lib/actions/sandbox/policy-channel"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class SandboxPolicyListCommand extends NemoClawCommand { + 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 = [""]; + static examples = ["<%= config.bin %> sandbox policy list alpha"]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(SandboxPolicyListCommand); + listSandboxPolicies(args.sandboxName); + } +} diff --git a/src/lib/commands/sandbox/policy/mutate.test.ts b/src/commands/sandbox/policy/mutate.test.ts similarity index 96% rename from src/lib/commands/sandbox/policy/mutate.test.ts rename to src/commands/sandbox/policy/mutate.test.ts index eca4ef92907..2a213675459 100644 --- a/src/lib/commands/sandbox/policy/mutate.test.ts +++ b/src/commands/sandbox/policy/mutate.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import PolicyAddCommand from "./add"; -import { setPolicyRuntimeBridgeFactoryForTest } from "./common"; +import { setPolicyRuntimeBridgeFactoryForTest } from "../../../lib/sandbox/policy-command-support"; import PolicyRemoveCommand from "./remove"; const rootDir = process.cwd(); diff --git a/src/commands/sandbox/policy/remove.ts b/src/commands/sandbox/policy/remove.ts index 95902fdfc33..fbdd8a38f6d 100644 --- a/src/commands/sandbox/policy/remove.ts +++ b/src/commands/sandbox/policy/remove.ts @@ -1,16 +1,33 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/policy/remove"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw policy-remove", - description: "Remove an applied policy preset (built-in or custom)", - flags: "(--yes, -y, --dry-run)", - group: "Policy Presets", - scope: "sandbox", - order: 18, - }, -]); +import { + appendCommonPolicyFlags, + getPolicyRuntimeBridge, + policyMutationArgs, + policyMutationFlags, +} from "../../../lib/sandbox/policy-command-support"; + +export default class PolicyRemoveCommand extends NemoClawCommand { + static id = "sandbox:policy:remove"; + static strict = true; + static summary = "Remove an applied policy preset"; + static description = "Remove a built-in or custom policy preset from a sandbox."; + static usage = [" [preset] [--yes|-y] [--dry-run]"]; + static examples = [ + "<%= config.bin %> sandbox policy remove alpha slack --yes", + "<%= config.bin %> sandbox policy remove alpha slack --dry-run", + ]; + static args = policyMutationArgs; + static flags = policyMutationFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(PolicyRemoveCommand); + const legacyArgs: string[] = []; + if (args.preset) legacyArgs.push(args.preset); + appendCommonPolicyFlags(legacyArgs, flags); + await getPolicyRuntimeBridge().sandboxPolicyRemove(args.sandboxName, legacyArgs); + } +} diff --git a/src/commands/sandbox/rebuild.ts b/src/commands/sandbox/rebuild.ts index 51468c5ea53..8ea6d59abcc 100644 --- a/src/commands/sandbox/rebuild.ts +++ b/src/commands/sandbox/rebuild.ts @@ -1,16 +1,37 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/commands/sandbox/rebuild"; -import { withCommandDisplay } from "../../lib/cli/command-display"; +import { Args, Flags } from "@oclif/core"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw rebuild", - description: "Upgrade sandbox to current agent version", - flags: "[--yes|-y|--force] [--verbose|-v]", - group: "Sandbox Management", - scope: "sandbox", - order: 13, - }, -]); +import { rebuildSandbox } from "../../lib/actions/sandbox/rebuild"; +import { forceFlag, yesFlag } from "../../lib/cli/common-flags"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; + +export default class RebuildCliCommand extends NemoClawCommand { + static id = "sandbox:rebuild"; + static strict = true; + static summary = "Upgrade sandbox to current agent version"; + static description = "Back up, recreate, and restore a sandbox using the current agent image."; + static usage = [" [--yes|-y|--force] [--verbose|-v]"]; + static examples = [ + "<%= config.bin %> sandbox rebuild alpha", + "<%= config.bin %> sandbox rebuild alpha --yes --verbose", + ]; + static args = { + sandboxName: Args.string({ name: "sandbox", description: "Sandbox name", required: true }), + }; + static flags = { + yes: yesFlag(), + force: forceFlag(), + verbose: Flags.boolean({ char: "v", description: "Show verbose rebuild diagnostics" }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(RebuildCliCommand); + await rebuildSandbox(args.sandboxName, { + force: flags.force === true, + verbose: flags.verbose === true, + yes: flags.yes === true, + }); + } +} diff --git a/src/commands/sandbox/recover.ts b/src/commands/sandbox/recover.ts index 38dc854eea6..acbfcc702bd 100644 --- a/src/commands/sandbox/recover.ts +++ b/src/commands/sandbox/recover.ts @@ -1,15 +1,27 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../lib/recover-cli-command"; -import { withCommandDisplay } from "../../lib/cli/command-display"; - -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw recover", - description: "Restart the sandbox gateway and dashboard port-forward", - group: "Sandbox Management", - scope: "sandbox", - order: 3.5, - }, -]); +import { Args } from "@oclif/core"; + +import { connectSandbox } from "../../lib/actions/sandbox/connect"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; + +export default class RecoverCliCommand extends NemoClawCommand { + static id = "sandbox:recover"; + static strict = true; + static summary = "Restart the sandbox gateway and dashboard port-forward"; + static description = + "Re-run the sandbox-side gateway recovery and re-establish the host-side dashboard port-forward without opening an SSH session. Equivalent to `connect --probe-only`; safe to re-run."; + static usage = [""]; + static examples = ["<%= config.bin %> sandbox recover alpha"]; + static args = { + sandboxName: Args.string({ name: "sandbox", description: "Sandbox name", required: true }), + }; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(RecoverCliCommand); + await connectSandbox(args.sandboxName, { probeOnly: true }); + } +} diff --git a/src/lib/commands/sandbox/share.test.ts b/src/commands/sandbox/share.test.ts similarity index 54% rename from src/lib/commands/sandbox/share.test.ts rename to src/commands/sandbox/share.test.ts index d04e821f5b7..b758ec41f8c 100644 --- a/src/lib/commands/sandbox/share.test.ts +++ b/src/commands/sandbox/share.test.ts @@ -3,20 +3,36 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ - printShareUsageAndExit: vi.fn(() => { - throw new Error("share usage requested"); - }), - runShareMount: vi.fn().mockResolvedValue(undefined), - runShareStatus: vi.fn(), - runShareUnmount: vi.fn(), -})); +const mocks = vi.hoisted(() => { + class ShareCommandError extends Error { + lines: readonly string[]; + exitCode: number; + + constructor(lines: string | readonly string[], exitCode = 1) { + const normalized = Array.isArray(lines) ? lines : [lines]; + super(normalized.join("\n")); + this.lines = normalized; + this.exitCode = exitCode; + } + } -vi.mock("../../share-command", () => ({ + return { + printShareUsageAndExit: vi.fn(() => { + throw new ShareCommandError("share usage requested"); + }), + runShareMount: vi.fn().mockResolvedValue(undefined), + runShareStatus: vi.fn(), + runShareUnmount: vi.fn(), + ShareCommandError, + }; +}); + +vi.mock("../../lib/share-command", () => ({ printShareUsageAndExit: mocks.printShareUsageAndExit, runShareMount: mocks.runShareMount, runShareStatus: mocks.runShareStatus, runShareUnmount: mocks.runShareUnmount, + ShareCommandError: mocks.ShareCommandError, })); import ShareCommand from "./share"; @@ -32,9 +48,18 @@ describe("share oclif command adapters", () => { }); it("routes parent share usage through the usage action", async () => { - await expect(ShareCommand.run(["alpha"], rootDir)).rejects.toThrow("share usage requested"); - - expect(mocks.printShareUsageAndExit).toHaveBeenCalledWith(1); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + await expect(ShareCommand.run(["alpha"], rootDir)).resolves.toBeUndefined(); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith("share usage requested"); + expect(mocks.printShareUsageAndExit).toHaveBeenCalledWith(1); + } finally { + process.exitCode = previousExitCode; + error.mockRestore(); + } }); it("maps share subcommand args to share actions", async () => { diff --git a/src/commands/sandbox/share.ts b/src/commands/sandbox/share.ts index b17e68c6624..06c1bd54da5 100644 --- a/src/commands/sandbox/share.ts +++ b/src/commands/sandbox/share.ts @@ -1,4 +1,38 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export { default } from "../../lib/commands/sandbox/share"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; + +import { printShareUsageAndExit, ShareCommandError } from "../../lib/share-command"; +import { sandboxNameArg } from "../../lib/sandbox/command-support"; + +export default class ShareCommand extends NemoClawCommand { + static id = "sandbox:share"; + static strict = true; + static summary = "Mount/unmount sandbox filesystem on the host via SSHFS"; + static description = "Share files between host and sandbox using SSHFS over OpenShell's SSH proxy."; + static usage = [" "]; + static examples = [ + "<%= config.bin %> sandbox share mount alpha", + "<%= config.bin %> sandbox share unmount alpha", + "<%= config.bin %> sandbox share status alpha", + ]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + }; + + public async run(): Promise { + await this.parse(ShareCommand); + try { + printShareUsageAndExit(1); + } catch (error) { + if (error instanceof ShareCommandError) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/share/mount.ts b/src/commands/sandbox/share/mount.ts index d88df75c7b7..017bb7ad7d0 100644 --- a/src/commands/sandbox/share/mount.ts +++ b/src/commands/sandbox/share/mount.ts @@ -1,16 +1,52 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/share/mount"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { Args } from "@oclif/core"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw share mount", - description: "Mount sandbox filesystem on the host via SSHFS", - flags: "[sandbox-path] [local-mount-point]", - group: "Sandbox Management", - scope: "sandbox", - order: 10, - }, -]); +import { runShareMount, ShareCommandError } from "../../../lib/share-command"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class ShareMountCommand extends NemoClawCommand { + static id = "sandbox:share:mount"; + static strict = true; + static summary = "Mount sandbox filesystem on the host"; + static description = "Mount a sandbox path on the host using SSHFS over OpenShell's SSH proxy."; + static usage = [" [sandbox-path] [local-mount-point]"]; + static examples = [ + "<%= config.bin %> sandbox share mount alpha", + "<%= config.bin %> sandbox share mount alpha /workspace ~/mnt/alpha", + ]; + static args = { + sandboxName: sandboxNameArg, + sandboxPath: Args.string({ + name: "sandbox-path", + description: "Path inside the sandbox to mount", + required: false, + }), + localMountPoint: Args.string({ + name: "local-mount-point", + description: "Host path for the SSHFS mount", + required: false, + }), + }; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(ShareMountCommand); + try { + await runShareMount({ + sandboxName: args.sandboxName, + remotePath: args.sandboxPath, + localMount: args.localMountPoint, + }); + } catch (error) { + if (error instanceof ShareCommandError) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/share/status.ts b/src/commands/sandbox/share/status.ts index 89ba114819b..69a2d7e4eda 100644 --- a/src/commands/sandbox/share/status.ts +++ b/src/commands/sandbox/share/status.ts @@ -1,16 +1,35 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/share/status"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { Args } from "@oclif/core"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw share status", - description: "Check whether the sandbox filesystem is currently mounted", - flags: "[local-mount-point]", - group: "Sandbox Management", - scope: "sandbox", - order: 12, - }, -]); +import { runShareStatus } from "../../../lib/share-command"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class ShareStatusCommand extends NemoClawCommand { + static id = "sandbox:share:status"; + static strict = true; + static summary = "Show sandbox share mount status"; + static description = "Check whether a sandbox filesystem share is currently mounted on the host."; + static usage = [" [local-mount-point]"]; + static examples = [ + "<%= config.bin %> sandbox share status alpha", + "<%= config.bin %> sandbox share status alpha ~/mnt/alpha", + ]; + static args = { + sandboxName: sandboxNameArg, + localMountPoint: Args.string({ + name: "local-mount-point", + description: "Host mount path to check", + required: false, + }), + }; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(ShareStatusCommand); + runShareStatus({ sandboxName: args.sandboxName, localMount: args.localMountPoint }); + } +} diff --git a/src/commands/sandbox/share/unmount.ts b/src/commands/sandbox/share/unmount.ts index 990e26ea7f0..c7a54c42a23 100644 --- a/src/commands/sandbox/share/unmount.ts +++ b/src/commands/sandbox/share/unmount.ts @@ -1,16 +1,43 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/share/unmount"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { Args } from "@oclif/core"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw share unmount", - description: "Unmount a previously mounted sandbox filesystem", - flags: "[local-mount-point]", - group: "Sandbox Management", - scope: "sandbox", - order: 11, - }, -]); +import { runShareUnmount, ShareCommandError } from "../../../lib/share-command"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class ShareUnmountCommand extends NemoClawCommand { + static id = "sandbox:share:unmount"; + static strict = true; + static summary = "Unmount a shared sandbox filesystem"; + static description = "Unmount a previously mounted sandbox filesystem from the host."; + static usage = [" [local-mount-point]"]; + static examples = [ + "<%= config.bin %> sandbox share unmount alpha", + "<%= config.bin %> sandbox share unmount alpha ~/mnt/alpha", + ]; + static args = { + sandboxName: sandboxNameArg, + localMountPoint: Args.string({ + name: "local-mount-point", + description: "Host mount path to unmount", + required: false, + }), + }; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(ShareUnmountCommand); + try { + runShareUnmount({ sandboxName: args.sandboxName, localMount: args.localMountPoint }); + } catch (error) { + if (error instanceof ShareCommandError) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/shields/down.ts b/src/commands/sandbox/shields/down.ts index e9676a74b73..97ed6ecfc2b 100644 --- a/src/commands/sandbox/shields/down.ts +++ b/src/commands/sandbox/shields/down.ts @@ -1,16 +1,33 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/shields/down"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw shields down", - description: "Lower sandbox security shields", - group: "Sandbox Management", - hidden: true, - scope: "sandbox", - order: 25, - }, -]); +import { shieldsTimeoutDurationFlag } from "../../../lib/cli/duration-flags"; +import * as shields from "../../../lib/shields/index"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class ShieldsDownCommand extends NemoClawCommand { + static id = "sandbox:shields:down"; + static hidden = true; + static strict = true; + static summary = "Lower sandbox security shields"; + static description = "Temporarily lower sandbox shields."; + static usage = [" [--timeout 5m] [--reason ] [--policy permissive]"]; + static args = { sandboxName: sandboxNameArg }; + static flags = { + timeout: shieldsTimeoutDurationFlag({ description: "Duration before shields are restored" }), + reason: Flags.string({ description: "Reason for lowering shields" }), + policy: Flags.string({ description: "Policy to apply while shields are down" }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(ShieldsDownCommand); + shields.shieldsDown(args.sandboxName, { + timeout: flags.timeout ?? null, + reason: flags.reason ?? null, + policy: flags.policy ?? "permissive", + }); + } +} diff --git a/src/commands/sandbox/shields/status.ts b/src/commands/sandbox/shields/status.ts index b7ec5c3e00e..b19911f3d05 100644 --- a/src/commands/sandbox/shields/status.ts +++ b/src/commands/sandbox/shields/status.ts @@ -1,16 +1,24 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/shields/status"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw shields status", - description: "Show current shields state", - group: "Sandbox Management", - hidden: true, - scope: "sandbox", - order: 27, - }, -]); +import * as shields from "../../../lib/shields/index"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class ShieldsStatusCommand extends NemoClawCommand { + static id = "sandbox:shields:status"; + static hidden = true; + static strict = true; + static summary = "Show current shields state"; + static description = "Show current sandbox shields state."; + static usage = [""]; + static args = { sandboxName: sandboxNameArg }; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(ShieldsStatusCommand); + shields.shieldsStatus(args.sandboxName); + } +} diff --git a/src/commands/sandbox/shields/up.ts b/src/commands/sandbox/shields/up.ts index 166dcc2781a..a3ba3a52db5 100644 --- a/src/commands/sandbox/shields/up.ts +++ b/src/commands/sandbox/shields/up.ts @@ -1,16 +1,24 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/shields/up"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw shields up", - description: "Raise sandbox security shields", - group: "Sandbox Management", - hidden: true, - scope: "sandbox", - order: 26, - }, -]); +import * as shields from "../../../lib/shields/index"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class ShieldsUpCommand extends NemoClawCommand { + static id = "sandbox:shields:up"; + static hidden = true; + static strict = true; + static summary = "Raise sandbox security shields"; + static description = "Restore sandbox shields from the saved snapshot."; + static usage = [""]; + static args = { sandboxName: sandboxNameArg }; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(ShieldsUpCommand); + shields.shieldsUp(args.sandboxName); + } +} diff --git a/src/lib/commands/sandbox/skill.test.ts b/src/commands/sandbox/skill.test.ts similarity index 94% rename from src/lib/commands/sandbox/skill.test.ts rename to src/commands/sandbox/skill.test.ts index a5d3cbdf8a9..444bcb8eafd 100644 --- a/src/lib/commands/sandbox/skill.test.ts +++ b/src/commands/sandbox/skill.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import SkillCliCommand from "./skill"; -import { setSkillInstallRuntimeBridgeFactoryForTest } from "./skill/common"; +import { setSkillInstallRuntimeBridgeFactoryForTest } from "../../lib/sandbox/skill-command-support"; import SkillInstallCliCommand from "./skill/install"; const rootDir = process.cwd(); diff --git a/src/commands/sandbox/skill.ts b/src/commands/sandbox/skill.ts index 9fe2e3cabcb..cf6cea0183e 100644 --- a/src/commands/sandbox/skill.ts +++ b/src/commands/sandbox/skill.ts @@ -1,4 +1,24 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export { default } from "../../lib/commands/sandbox/skill"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; +import { getSkillInstallRuntimeBridge } from "../../lib/sandbox/skill-command-support"; + +export default class SkillCliCommand extends NemoClawCommand { + static id = "sandbox:skill"; + static strict = false; + static summary = "Show skill command usage"; + static description = "Show skill install usage or report unknown skill subcommands."; + static usage = ["install "]; + static examples = ["<%= config.bin %> sandbox skill install alpha ./my-skill"]; + + public async run(): Promise { + this.parsed = true; + const [sandboxName, ...actionArgs] = this.argv; + if (!sandboxName || sandboxName.trim() === "") { + this.failWithLines(["Missing required sandboxName for skill."], 2); + return; + } + await getSkillInstallRuntimeBridge().sandboxSkillInstall(sandboxName, actionArgs); + } +} diff --git a/src/commands/sandbox/skill/install.ts b/src/commands/sandbox/skill/install.ts index 8ba09071696..ddb2c11c96c 100644 --- a/src/commands/sandbox/skill/install.ts +++ b/src/commands/sandbox/skill/install.ts @@ -1,15 +1,41 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/skill/install"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; - -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw skill install", - description: "Deploy a skill directory to the sandbox", - group: "Skills", - scope: "sandbox", - order: 16, - }, -]); +import { Args } from "@oclif/core"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +import { getSkillInstallRuntimeBridge } from "../../../lib/sandbox/skill-command-support"; + +export default class SkillInstallCliCommand extends NemoClawCommand { + 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 = [" "]; + static examples = [ + "<%= config.bin %> sandbox skill install alpha ./my-skill", + "<%= config.bin %> sandbox skill install alpha ./my-skill/SKILL.md", + ]; + 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: true, + }), + }; + static flags = { + }; + + public async run(): Promise { + const { args } = await this.parse(SkillInstallCliCommand); + await getSkillInstallRuntimeBridge().sandboxSkillInstall(args.sandboxName, [ + "install", + args.skillPath, + ]); + } +} diff --git a/src/lib/commands/sandbox/snapshot.test.ts b/src/commands/sandbox/snapshot.test.ts similarity index 95% rename from src/lib/commands/sandbox/snapshot.test.ts rename to src/commands/sandbox/snapshot.test.ts index 841edb15f90..0c998753a1b 100644 --- a/src/lib/commands/sandbox/snapshot.test.ts +++ b/src/commands/sandbox/snapshot.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import SnapshotCommand from "./snapshot"; -import { setSnapshotRuntimeBridgeFactoryForTest } from "./snapshot/common"; +import { setSnapshotRuntimeBridgeFactoryForTest } from "../../lib/sandbox/snapshot-command-support"; import SnapshotCreateCommand from "./snapshot/create"; import SnapshotListCommand from "./snapshot/list"; import SnapshotRestoreCommand from "./snapshot/restore"; diff --git a/src/commands/sandbox/snapshot.ts b/src/commands/sandbox/snapshot.ts index b34814d9d86..39458039731 100644 --- a/src/commands/sandbox/snapshot.ts +++ b/src/commands/sandbox/snapshot.ts @@ -1,4 +1,35 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export { default } from "../../lib/commands/sandbox/snapshot"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; +import { getSnapshotRuntimeBridge, sandboxNameArg, snapshotCommandError } from "../../lib/sandbox/snapshot-command-support"; + +export default class SnapshotCommand extends NemoClawCommand { + static id = "sandbox:snapshot"; + static strict = true; + static summary = "Show snapshot usage"; + static description = "Show snapshot usage for create, list, and restore subcommands."; + static usage = [" "]; + static examples = [ + "<%= config.bin %> sandbox snapshot create alpha", + "<%= config.bin %> sandbox snapshot list alpha", + "<%= config.bin %> sandbox snapshot restore alpha", + ]; + static args = { + sandboxName: sandboxNameArg, + }; + + public async run(): Promise { + const { args } = await this.parse(SnapshotCommand); + try { + await getSnapshotRuntimeBridge().sandboxSnapshot(args.sandboxName, []); + } catch (error) { + const snapshotError = snapshotCommandError(error); + if (snapshotError) { + this.failWithLines(snapshotError.lines, snapshotError.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/snapshot/create.ts b/src/commands/sandbox/snapshot/create.ts index a25afabfe04..7635a9505e5 100644 --- a/src/commands/sandbox/snapshot/create.ts +++ b/src/commands/sandbox/snapshot/create.ts @@ -1,16 +1,43 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import Command from "../../../lib/commands/sandbox/snapshot/create"; -import { withCommandDisplay } from "../../../lib/cli/command-display"; +import { Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -export default withCommandDisplay(Command, [ - { - usage: "nemoclaw snapshot create", - description: "Create a snapshot of sandbox state", - flags: "[--name ]", - group: "Sandbox Management", - scope: "sandbox", - order: 7, - }, -]); +import { getSnapshotRuntimeBridge, sandboxNameArg, snapshotCommandError } from "../../../lib/sandbox/snapshot-command-support"; + +export default class SnapshotCreateCommand extends NemoClawCommand { + 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 = [" [--name