From 70eb884a908fcdf4ea078dcea61c114c9d9b4075 Mon Sep 17 00:00:00 2001 From: HwangJohn Date: Wed, 17 Jun 2026 16:26:50 +0900 Subject: [PATCH 1/6] fix(cli): clarify status command scope Signed-off-by: HwangJohn --- docs/reference/cli-selection-guide.mdx | 3 ++- docs/reference/commands-nemohermes.mdx | 8 ++++-- docs/reference/commands.mdx | 8 ++++-- src/commands/sandbox/status.ts | 6 +++-- src/commands/status.ts | 5 ++-- src/lib/actions/root-help.ts | 6 +++++ src/lib/actions/sandbox/status-flow.test.ts | 1 + src/lib/actions/sandbox/status.ts | 1 + src/lib/cli/public-dispatch.ts | 27 +++++++++++++++++++++ src/lib/cli/public-display-defaults.ts | 3 ++- src/lib/inventory/index.test.ts | 1 + src/lib/inventory/index.ts | 3 ++- test/cli-oclif-compatibility.test.ts | 22 ++++++++++++----- test/cli/status-routing.test.ts | 13 ++++++++-- test/root-help.test.ts | 12 +++++++++ 15 files changed, 100 insertions(+), 19 deletions(-) diff --git a/docs/reference/cli-selection-guide.mdx b/docs/reference/cli-selection-guide.mdx index 21b10dfca15..72a0426c63e 100644 --- a/docs/reference/cli-selection-guide.mdx +++ b/docs/reference/cli-selection-guide.mdx @@ -158,7 +158,8 @@ openshell sandbox exec -n my-assistant -- cat /tmp/gateway.log ### Check Health or Logs Use `$$nemoclaw status` and `$$nemoclaw logs` first. -They combine NemoClaw registry data, OpenShell state, OpenClaw process health, inference health, policy details, and messaging-channel warnings. +They combine NemoClaw registry data, OpenShell state, OpenClaw process health, inference health, policy details, and messaging-channel warnings for that sandbox. +Use `$$nemoclaw status` only for the global all-sandbox and host-service overview. Use `openshell sandbox list`, `openshell sandbox get`, `openshell logs -n 20`, or `openshell doctor check` when debugging lower-level OpenShell behavior. When using `openshell logs` directly, `-n ` controls the line count; use `--tail` only when you want live OpenShell log streaming. diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index d79758d8732..44a766b11b2 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -499,7 +499,9 @@ nemohermes my-assistant recover ### `nemohermes status` -Show sandbox status, health, and inference configuration. +Show sandbox-scoped status, health, and inference configuration for one registered sandbox. +Use this form when you care about a specific sandbox's live OpenShell state, agent runtime, inference health, GPU proof, permissions, and recovery hints. +Do not pass a sandbox name to `nemohermes status`; that command is the global all-sandbox/service overview. Pass `--json` to emit a structured per-sandbox report instead of the text renderer. The JSON output includes at least `schemaVersion`, `name`, `found`, `model`, `provider`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `failureLayer`, and `dockerPaused`. @@ -1434,7 +1436,9 @@ This command remains as a compatibility alias to `nemohermes tunnel stop`. ### `nemohermes status` -Show the sandbox list and the status of host auxiliary services (for example cloudflared). +Show the global sandbox list and the status of host auxiliary services (for example cloudflared). +This command is host-wide: it summarizes registered sandboxes, the default sandbox's live inference route, messaging overlap warnings, gateway health, and host services. +Use `nemohermes status` when you need one sandbox's live health and recovery guidance. Pass `--json` for machine-readable output with registered sandboxes, service state, inference routes, and messaging health. For each listed sandbox, the text output includes the configured inference provider and model plus whether an active SSH session is connected. Host-service PID lookup honors `NEMOCLAW_SANDBOX_NAME`, then `NEMOCLAW_SANDBOX`, then `SANDBOX_NAME`, then the registry default. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 679ab4e59de..0b8dd51a484 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -628,7 +628,9 @@ $$nemoclaw my-assistant recover ### `$$nemoclaw status` -Show sandbox status, health, and inference configuration. +Show sandbox-scoped status, health, and inference configuration for one registered sandbox. +Use this form when you care about a specific sandbox's live OpenShell state, agent runtime, inference health, GPU proof, permissions, and recovery hints. +Do not pass a sandbox name to `$$nemoclaw status`; that command is the global all-sandbox/service overview. Pass `--json` to emit a structured per-sandbox report instead of the text renderer. The JSON output includes at least `schemaVersion`, `name`, `found`, `model`, `provider`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `failureLayer`, and `dockerPaused`. @@ -1737,7 +1739,9 @@ This command remains as a compatibility alias to `$$nemoclaw tunnel stop`. ### `$$nemoclaw status` -Show the sandbox list and the status of host auxiliary services (for example cloudflared). +Show the global sandbox list and the status of host auxiliary services (for example cloudflared). +This command is host-wide: it summarizes registered sandboxes, the default sandbox's live inference route, messaging overlap warnings, gateway health, and host services. +Use `$$nemoclaw status` when you need one sandbox's live health and recovery guidance. Pass `--json` for machine-readable output with registered sandboxes, service state, inference routes, and messaging health. For each listed sandbox, the text output includes the configured inference provider and model plus whether an active SSH session is connected. Host-service PID lookup honors `NEMOCLAW_SANDBOX_NAME`, then `NEMOCLAW_SANDBOX`, then `SANDBOX_NAME`, then the registry default. diff --git a/src/commands/sandbox/status.ts b/src/commands/sandbox/status.ts index 14dfe792f0f..4467e71d7fe 100644 --- a/src/commands/sandbox/status.ts +++ b/src/commands/sandbox/status.ts @@ -11,10 +11,12 @@ export default class SandboxStatusCommand extends NemoClawCommand { static id = "sandbox:status"; static strict = true; static enableJsonFlag = true; - static summary = "Sandbox health and NIM status"; - static description = "Show sandbox health, OpenShell gateway state, and local NIM status."; + static summary = "Show one sandbox's health and runtime status"; + static description = + "Show one sandbox's health, OpenShell gateway state, inference status, and local NIM status. Use global `status` for the all-sandbox/service overview."; static usage = [" [--json]"]; static examples = [ + "<%= config.bin %> alpha status", "<%= config.bin %> sandbox status alpha", "<%= config.bin %> sandbox status alpha --json", ]; diff --git a/src/commands/status.ts b/src/commands/status.ts index e8124bfeb93..83aaea49e6b 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -9,8 +9,9 @@ export default class StatusCommand extends NemoClawCommand { static id = "status"; static strict = true; static enableJsonFlag = true; - static summary = "Show sandbox list and service status"; - static description = "Show registered sandboxes, live inference, services, and messaging health."; + static summary = "Show global sandbox and host service status"; + static description = + "Show the global overview across registered sandboxes, live inference, host services, and messaging health. Use ` status` for one sandbox."; static usage = ["status [--json]"]; static examples = ["<%= config.bin %> status", "<%= config.bin %> status --json"]; static flags = {}; diff --git a/src/lib/actions/root-help.ts b/src/lib/actions/root-help.ts index cb9bdc75693..ece0d77e53c 100644 --- a/src/lib/actions/root-help.ts +++ b/src/lib/actions/root-help.ts @@ -46,6 +46,12 @@ export function help(): void { lines.push(""); lines.push(` ${B}${G}${CLI_DISPLAY_NAME}${R} ${D}v${getVersion()}${R}`); lines.push(` ${D}Deploy more secure, always-on AI assistants with a single command.${R}`); + lines.push( + ` ${D}Global commands inspect host-wide state; sandbox commands start with a sandbox name.${R}`, + ); + lines.push( + ` ${D}Use \`${CLI_NAME} status\` for the global overview, and \`${CLI_NAME} status\` for one sandbox.${R}`, + ); for (const [group, cmds] of grouped) { lines.push(""); diff --git a/src/lib/actions/sandbox/status-flow.test.ts b/src/lib/actions/sandbox/status-flow.test.ts index ffab813dfb6..189864fd0ec 100644 --- a/src/lib/actions/sandbox/status-flow.test.ts +++ b/src/lib/actions/sandbox/status-flow.test.ts @@ -178,6 +178,7 @@ describe("showSandboxStatus flow", () => { await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("Sandbox-scoped status for 'alpha'"); expect(output).toContain("Sandbox: alpha"); expect(output).toContain("Model: nvidia/nemotron-live"); expect(output).toContain("Inference: healthy"); diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 124f7dde50a..a7da86fb4d3 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -181,6 +181,7 @@ export async function showSandboxStatus(sandboxName: string): Promise { } if (sb) { console.log(""); + console.log(` Sandbox-scoped status for '${sb.name}':`); console.log(` Sandbox: ${sb.name}`); console.log(` Model: ${currentModel}`); console.log(` Provider: ${currentProvider}`); diff --git a/src/lib/cli/public-dispatch.ts b/src/lib/cli/public-dispatch.ts index 3b555088e84..2f66819fa1e 100644 --- a/src/lib/cli/public-dispatch.ts +++ b/src/lib/cli/public-dispatch.ts @@ -199,6 +199,29 @@ function printDispatchUsageError( process.exit(1); } +function findGlobalStatusSandboxArgument(args: readonly string[]): string | null { + for (const arg of args) { + if (arg === "--json") { + continue; + } + if (arg.startsWith("-")) { + return null; + } + return arg; + } + return null; +} + +function printGlobalStatusScopeHint(sandboxName: string, args: readonly string[]): never { + const jsonFlag = args.includes("--json") ? " --json" : ""; + console.error(` '${CLI_NAME} status' shows the global sandbox/service overview.`); + console.error(` It does not take a sandbox name.`); + console.error(""); + console.error(` Run: ${CLI_NAME} ${sandboxName} status${jsonFlag}`); + console.error(` Or for global JSON: ${CLI_NAME} status --json`); + process.exit(2); +} + async function recoverRequestedSandboxIfNeeded( sandboxName: string, action: string, @@ -305,6 +328,10 @@ export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promi } if (normalized.kind === "global") { + if (normalized.command === "status") { + const sandboxName = findGlobalStatusSandboxArgument(normalized.args); + if (sandboxName) printGlobalStatusScopeHint(sandboxName, normalized.args); + } await runPublicTranslationResult( translatePublicGlobalArgv(normalized.command, normalized.args), ); diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 626ef47c9a4..41905090c83 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -426,7 +426,7 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { { group: "Sandbox Management", order: 4, - description: "Sandbox health + NIM status", + description: "One sandbox's health, gateway, inference, and NIM status", }, ], setup: [ @@ -454,6 +454,7 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { { group: "Services", order: 36, + description: "Global sandbox and host service status", flags: "[--json]", }, ], diff --git a/src/lib/inventory/index.test.ts b/src/lib/inventory/index.test.ts index 137ac0577e7..1b528a4b1a5 100644 --- a/src/lib/inventory/index.test.ts +++ b/src/lib/inventory/index.test.ts @@ -599,6 +599,7 @@ describe("inventory commands", () => { log: (message = "") => lines.push(message), }); + expect(lines).toContain(" Global status (registered sandboxes and host services):"); expect(lines).toContain(" Sandboxes:"); // Default sandbox shows the live gateway model (#2369), annotated with // the onboarded model when they differ. diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 39db0334648..066826992f0 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -414,9 +414,10 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { const sandboxList = deps.listSandboxes(); const { sandboxes } = sandboxList; const resolvedDefault = resolveDefaultSandboxName(() => sandboxList) ?? null; + log(""); + log(" Global status (registered sandboxes and host services):"); if (sandboxes.length > 0) { const live = deps.getLiveInference(); - log(""); log(" Sandboxes:"); for (const sb of sandboxes) { const isDefault = sb.name === resolvedDefault; diff --git a/test/cli-oclif-compatibility.test.ts b/test/cli-oclif-compatibility.test.ts index b397d4d94ee..d3dbac5c299 100644 --- a/test/cli-oclif-compatibility.test.ts +++ b/test/cli-oclif-compatibility.test.ts @@ -328,6 +328,15 @@ describe("oclif compatibility dispatch", () => { const runOclifArgv = vi.fn(async () => undefined); const runOclifCommandById = vi.fn(async () => undefined); + const stderr: string[] = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((message = "") => { + stderr.push(String(message)); + }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: string | number | null, + ) => { + throw new Error(`process.exit:${String(code)}`); + }) as never); process.env.NEMOCLAW_DISABLE_AUTO_DISPATCH = "1"; requireCache[runnerPath] = { @@ -348,15 +357,16 @@ describe("oclif compatibility dispatch", () => { delete require.cache[publicDispatchPath]; const { dispatchCli } = require(cliPath); - await dispatchCli(["status", "bogus"]); + await expect(dispatchCli(["status", "bogus"])).rejects.toThrow("process.exit:2"); - expect(runOclifCommandById).toHaveBeenCalledWith( - "status", - ["bogus"], - expect.objectContaining({ rootDir: process.cwd() }), - ); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(stderr.join("\n")).toContain("Run: nemoclaw bogus status"); + expect(runOclifCommandById).not.toHaveBeenCalled(); expect(runOclifArgv).not.toHaveBeenCalled(); + errorSpy.mockClear(); + exitSpy.mockClear(); + stderr.length = 0; runOclifArgv.mockClear(); runOclifCommandById.mockClear(); diff --git a/test/cli/status-routing.test.ts b/test/cli/status-routing.test.ts index 00ec4fc29bd..5d47cb52d35 100644 --- a/test/cli/status-routing.test.ts +++ b/test/cli/status-routing.test.ts @@ -13,7 +13,8 @@ describe("CLI status routing", () => { const r = run("status --help"); expect(r.code).toBe(0); expect(r.out).toContain("status [--json]"); - expect(r.out).toContain("Show sandbox list and service status"); + expect(r.out).toContain("Show global sandbox and host service status"); + expect(r.out).toContain("Use ` status` for one sandbox"); }); it("sandbox status --help advertises --json flag", () => { @@ -23,6 +24,7 @@ describe("CLI status routing", () => { expect(r.code).toBe(0); expect(r.out).toContain("--json"); expect(r.out).toContain("$ nemoclaw sandbox status [--json]"); + expect(r.out).toContain("$ nemoclaw alpha status"); expect(r.out).toContain("$ nemoclaw sandbox status alpha --json"); const alias = runWithEnv("alpha status --help", { HOME: home }); @@ -39,7 +41,14 @@ describe("CLI status routing", () => { it("status rejects unexpected positional arguments through current dispatch path", () => { const r = run("status bogus"); expect(r.code).toBe(2); - expect(r.out).toContain("Unexpected argument: bogus"); + expect(r.out).toContain("'nemoclaw status' shows the global sandbox/service overview"); + expect(r.out).toContain("Run: nemoclaw bogus status"); + }); + + it("status preserves --json in wrong-form sandbox status guidance", () => { + const r = run("status --json alpha"); + expect(r.code).toBe(2); + expect(r.out).toContain("Run: nemoclaw alpha status --json"); }); it("sandbox-first status rejects unexpected positional arguments through command-id dispatch", () => { diff --git a/test/root-help.test.ts b/test/root-help.test.ts index 9d4d2859dd7..624ab933327 100644 --- a/test/root-help.test.ts +++ b/test/root-help.test.ts @@ -23,6 +23,18 @@ describe("root help", () => { expect(output).not.toContain("Landlock enforced"); }); + it("explains global commands versus sandbox-scoped commands", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + renderRootHelp(); + + const output = log.mock.calls.map(([line]) => String(line)).join("\n"); + expect(output).toContain("Global commands inspect host-wide state"); + expect(output).toContain("sandbox commands start with a sandbox name"); + expect(output).toContain("nemoclaw status"); + expect(output).toContain("nemoclaw status"); + }); + it("shows channel as a required positional argument in channel command signatures", () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); From 3884237694840df5ff8f6282a36653d61704d5d6 Mon Sep 17 00:00:00 2001 From: HwangJohn Date: Wed, 17 Jun 2026 21:42:34 +0900 Subject: [PATCH 2/6] docs(cli): address status review warnings Signed-off-by: HwangJohn --- docs/reference/commands-nemohermes.mdx | 2 +- docs/reference/commands.mdx | 2 +- src/lib/actions/sandbox/status.ts | 4 +++- src/lib/cli/public-dispatch.ts | 6 +++++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 44a766b11b2..2308a67a737 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1437,7 +1437,7 @@ This command remains as a compatibility alias to `nemohermes tunnel stop`. ### `nemohermes status` Show the global sandbox list and the status of host auxiliary services (for example cloudflared). -This command is host-wide: it summarizes registered sandboxes, the default sandbox's live inference route, messaging overlap warnings, gateway health, and host services. +This command is host-wide. It summarizes registered sandboxes, the default sandbox's live inference route, messaging overlap warnings, gateway health, and host services. Use `nemohermes status` when you need one sandbox's live health and recovery guidance. Pass `--json` for machine-readable output with registered sandboxes, service state, inference routes, and messaging health. For each listed sandbox, the text output includes the configured inference provider and model plus whether an active SSH session is connected. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 0b8dd51a484..3b93bd25fce 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1740,7 +1740,7 @@ This command remains as a compatibility alias to `$$nemoclaw tunnel stop`. ### `$$nemoclaw status` Show the global sandbox list and the status of host auxiliary services (for example cloudflared). -This command is host-wide: it summarizes registered sandboxes, the default sandbox's live inference route, messaging overlap warnings, gateway health, and host services. +This command is host-wide. It summarizes registered sandboxes, the default sandbox's live inference route, messaging overlap warnings, gateway health, and host services. Use `$$nemoclaw status` when you need one sandbox's live health and recovery guidance. Pass `--json` for machine-readable output with registered sandboxes, service state, inference routes, and messaging health. For each listed sandbox, the text output includes the configured inference provider and model plus whether an active SSH session is connected. diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index a7da86fb4d3..612bc27c6b2 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -150,7 +150,8 @@ function printMissingLiveSandboxStatusGuidance( ); } -// eslint-disable-next-line complexity +/* eslint-disable complexity */ +/** Render sandbox-scoped status and surface recovery guidance for one registered sandbox. */ export async function showSandboxStatus(sandboxName: string): Promise { const preflight = await getSandboxStatusPreflight(registry.getSandbox(sandboxName)); // #2666: never let an unexpected throw from the gateway probe (e.g. openshell @@ -487,3 +488,4 @@ export async function showSandboxStatus(sandboxName: string): Promise { } console.log(""); } +/* eslint-enable complexity */ diff --git a/src/lib/cli/public-dispatch.ts b/src/lib/cli/public-dispatch.ts index 2f66819fa1e..341f1936f46 100644 --- a/src/lib/cli/public-dispatch.ts +++ b/src/lib/cli/public-dispatch.ts @@ -199,6 +199,7 @@ function printDispatchUsageError( process.exit(1); } +/** Returns the sandbox-like positional argument passed to global `status`, if one exists. */ function findGlobalStatusSandboxArgument(args: readonly string[]): string | null { for (const arg of args) { if (arg === "--json") { @@ -212,6 +213,7 @@ function findGlobalStatusSandboxArgument(args: readonly string[]): string | null return null; } +/** Prints the correction for `status ` and exits with the usage-error status code. */ function printGlobalStatusScopeHint(sandboxName: string, args: readonly string[]): never { const jsonFlag = args.includes("--json") ? " --json" : ""; console.error(` '${CLI_NAME} status' shows the global sandbox/service overview.`); @@ -305,7 +307,8 @@ async function runPublicTranslationResult( // ── Dispatch ───────────────────────────────────────────────────── -// eslint-disable-next-line complexity +/* eslint-disable complexity */ +/** Normalize public argv and route it to oclif or sandbox-first command handlers. */ export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promise { if (argv[0] === "internal" || argv[0] === "sandbox") { await runNativeOclifArgv(argv); @@ -414,3 +417,4 @@ export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promi console.error(` Run '${CLI_NAME} help' for usage.`); process.exit(1); } +/* eslint-enable complexity */ From da7cb4701b2092b9e7331bdf3916cf7055a743db Mon Sep 17 00:00:00 2001 From: HwangJohn Date: Wed, 17 Jun 2026 22:04:08 +0900 Subject: [PATCH 3/6] refactor(cli): split status dispatch review paths Signed-off-by: HwangJohn --- src/lib/actions/sandbox/status.ts | 4 +- src/lib/cli/public-dispatch.ts | 70 ++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 612bc27c6b2..a888c2e63d4 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { printOpenShellStateRpcIssue } from "../../adapters/openshell/gateway-drift"; -import { getSandboxTargetGatewayName } from "./gateway-target"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import * as agentRuntime from "../../agent/runtime"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; @@ -22,6 +21,7 @@ import { getSandboxDockerRuntime } from "./docker-health"; import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier"; import type { SandboxGatewayState } from "./gateway-state"; import { printGatewayLifecycleHint, printWrongGatewayActiveGuidance } from "./gateway-state"; +import { getSandboxTargetGatewayName } from "./gateway-target"; import { isSandboxGatewayRunningForStatus } from "./process-recovery"; import { getSandboxStatusPreflight, @@ -150,7 +150,6 @@ function printMissingLiveSandboxStatusGuidance( ); } -/* eslint-disable complexity */ /** Render sandbox-scoped status and surface recovery guidance for one registered sandbox. */ export async function showSandboxStatus(sandboxName: string): Promise { const preflight = await getSandboxStatusPreflight(registry.getSandbox(sandboxName)); @@ -488,4 +487,3 @@ export async function showSandboxStatus(sandboxName: string): Promise { } console.log(""); } -/* eslint-enable complexity */ diff --git a/src/lib/cli/public-dispatch.ts b/src/lib/cli/public-dispatch.ts index 341f1936f46..90fb4edbde2 100644 --- a/src/lib/cli/public-dispatch.ts +++ b/src/lib/cli/public-dispatch.ts @@ -20,12 +20,19 @@ const { globalCommandTokens, sandboxActionTokens, } = require("./command-registry"); -import { normalizeArgv, suggestCommand, type NormalizedSandboxArgv } from "./argv-normalizer"; + +import { + type NormalizedArgv, + type NormalizedGlobalArgv, + type NormalizedSandboxArgv, + normalizeArgv, + suggestCommand, +} from "./argv-normalizer"; import { getRegisteredOclifCommandMetadata } from "./oclif-metadata"; import { + type PublicTranslationResult, translatePublicGlobalArgv, translatePublicSandboxArgv, - type PublicTranslationResult, } from "./public-argv-translation"; // ── Global commands (derived from command registry) ────────────── @@ -307,19 +314,7 @@ async function runPublicTranslationResult( // ── Dispatch ───────────────────────────────────────────────────── -/* eslint-disable complexity */ -/** Normalize public argv and route it to oclif or sandbox-first command handlers. */ -export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promise { - if (argv[0] === "internal" || argv[0] === "sandbox") { - await runNativeOclifArgv(argv); - return; - } - - const normalized = normalizeArgv(argv, { - globalCommands: GLOBAL_COMMANDS, - isSandboxConnectFlag: isPublicSandboxConnectFlag, - }); - +async function dispatchNormalizedArgv(normalized: NormalizedArgv, argv: string[]): Promise { if (normalized.kind === "rootHelp") { await runDirectOclifCommand("root:help", []); return; @@ -331,16 +326,25 @@ export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promi } if (normalized.kind === "global") { - if (normalized.command === "status") { - const sandboxName = findGlobalStatusSandboxArgument(normalized.args); - if (sandboxName) printGlobalStatusScopeHint(sandboxName, normalized.args); - } - await runPublicTranslationResult( - translatePublicGlobalArgv(normalized.command, normalized.args), - ); + await dispatchGlobalArgv(normalized); return; } + await dispatchSandboxArgv(normalized, argv); +} + +async function dispatchGlobalArgv(normalized: NormalizedGlobalArgv): Promise { + if (normalized.command === "status") { + const sandboxName = findGlobalStatusSandboxArgument(normalized.args); + if (sandboxName) printGlobalStatusScopeHint(sandboxName, normalized.args); + } + await runPublicTranslationResult(translatePublicGlobalArgv(normalized.command, normalized.args)); +} + +async function dispatchSandboxArgv( + normalized: NormalizedSandboxArgv, + argv: string[], +): Promise { const cmd = normalized.sandboxName; const rawArgsAfterCmd = argv.slice(1); const requestedSandboxAction = normalized.action; @@ -400,7 +404,10 @@ export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promi return; } - // Unknown command — suggest + printUnknownSandboxOrCommand(cmd); +} + +function printUnknownSandboxOrCommand(cmd: string): never { console.error(` Unknown command: ${cmd}`); console.error(""); @@ -417,4 +424,19 @@ export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promi console.error(` Run '${CLI_NAME} help' for usage.`); process.exit(1); } -/* eslint-enable complexity */ + +/** Normalize public argv and route it to oclif or sandbox-first command handlers. */ +export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promise { + if (argv[0] === "internal" || argv[0] === "sandbox") { + await runNativeOclifArgv(argv); + return; + } + + await dispatchNormalizedArgv( + normalizeArgv(argv, { + globalCommands: GLOBAL_COMMANDS, + isSandboxConnectFlag: isPublicSandboxConnectFlag, + }), + argv, + ); +} From 2c58a90cdf53ef16fd0a2237c56428931f8064aa Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 1 Jul 2026 13:38:50 -0700 Subject: [PATCH 4/6] test(cli): cover status-scope flag ordering and unknown-flag precedence (#5540) - status bogus --json (flag after the name) still preserves --json in the hint - status --bogus alpha surfaces the unknown-flag error, not the scope hint Signed-off-by: Charan Jagwani Co-Authored-By: Claude Opus 4.8 (1M context) --- test/cli/status-routing.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/cli/status-routing.test.ts b/test/cli/status-routing.test.ts index 5d47cb52d35..106f0078676 100644 --- a/test/cli/status-routing.test.ts +++ b/test/cli/status-routing.test.ts @@ -51,6 +51,19 @@ describe("CLI status routing", () => { expect(r.out).toContain("Run: nemoclaw alpha status --json"); }); + it("status preserves --json when the flag follows the sandbox name", () => { + const r = run("status bogus --json"); + expect(r.code).toBe(2); + expect(r.out).toContain("Run: nemoclaw bogus status --json"); + }); + + it("status surfaces an unknown flag rather than the scope hint when a name follows it", () => { + const r = run("status --bogus alpha"); + expect(r.code).toBe(2); + expect(r.out).toContain("Nonexistent flag: --bogus"); + expect(r.out).not.toContain("does not take a sandbox name"); + }); + it("sandbox-first status rejects unexpected positional arguments through command-id dispatch", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-extra-")); writeSandboxRegistry(home); From 0f1b9940de0c688d60c83a1461e22a0eaa72e98c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 1 Jul 2026 14:00:57 -0700 Subject: [PATCH 5/6] fix(cli): validate status scope hints Signed-off-by: Carlos Villela --- src/commands/status.ts | 2 +- src/lib/actions/root-help.ts | 2 +- src/lib/cli/public-dispatch.ts | 24 +++++++++++++----------- test/cli/status-routing.test.ts | 29 ++++++++++++++++++++++++++++- test/root-help.test.ts | 2 +- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/commands/status.ts b/src/commands/status.ts index 83aaea49e6b..0037f554978 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getStatusReport, showStatusCommand } from "../lib/inventory/index"; import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; +import { getStatusReport, showStatusCommand } from "../lib/inventory/index"; import { buildStatusCommandDeps } from "../lib/status-command-deps"; export default class StatusCommand extends NemoClawCommand { diff --git a/src/lib/actions/root-help.ts b/src/lib/actions/root-help.ts index 1781523ac4e..52b8672ce23 100644 --- a/src/lib/actions/root-help.ts +++ b/src/lib/actions/root-help.ts @@ -47,7 +47,7 @@ export function help(): void { lines.push(` ${B}${G}${CLI_DISPLAY_NAME}${R} ${D}v${getVersion()}${R}`); lines.push(` ${D}Deploy more secure, always-on AI assistants with a single command.${R}`); lines.push( - ` ${D}Global commands inspect host-wide state; sandbox commands start with a sandbox name.${R}`, + ` ${D}Global commands run without a sandbox-name prefix; sandbox commands start with a sandbox name.${R}`, ); lines.push( ` ${D}Use \`${CLI_NAME} status\` for the global overview, and \`${CLI_NAME} status\` for one sandbox.${R}`, diff --git a/src/lib/cli/public-dispatch.ts b/src/lib/cli/public-dispatch.ts index 9e360f93c29..73a724dce9b 100644 --- a/src/lib/cli/public-dispatch.ts +++ b/src/lib/cli/public-dispatch.ts @@ -212,25 +212,27 @@ function printDispatchUsageError( /** Returns the sandbox-like positional argument passed to global `status`, if one exists. */ function findGlobalStatusSandboxArgument(args: readonly string[]): string | null { - for (const arg of args) { - if (arg === "--json") { - continue; - } - if (arg.startsWith("-")) { - return null; - } - return arg; + const positionals = args.filter((arg) => !["--json", "--help", "-h"].includes(arg)); + if (positionals.some((arg) => arg.startsWith("-")) || positionals.length !== 1) return null; + try { + validateName(positionals[0], "sandbox name"); + return positionals[0]; + } catch { + return null; } - return null; } /** Prints the correction for `status ` and exits with the usage-error status code. */ function printGlobalStatusScopeHint(sandboxName: string, args: readonly string[]): never { - const jsonFlag = args.includes("--json") ? " --json" : ""; + const forwardedFlags = [ + ...(args.includes("--json") ? ["--json"] : []), + ...(args.some((arg) => arg === "--help" || arg === "-h") ? ["--help"] : []), + ]; + const flagSuffix = forwardedFlags.length > 0 ? ` ${forwardedFlags.join(" ")}` : ""; console.error(` '${CLI_NAME} status' shows the global sandbox/service overview.`); console.error(` It does not take a sandbox name.`); console.error(""); - console.error(` Run: ${CLI_NAME} ${sandboxName} status${jsonFlag}`); + console.error(` Run: ${CLI_NAME} ${sandboxName} status${flagSuffix}`); console.error(` Or for global JSON: ${CLI_NAME} status --json`); process.exit(2); } diff --git a/test/cli/status-routing.test.ts b/test/cli/status-routing.test.ts index 106f0078676..3eaabbfd578 100644 --- a/test/cli/status-routing.test.ts +++ b/test/cli/status-routing.test.ts @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { describe, expect, it } from "vitest"; import { run, runWithEnv, writeSandboxRegistry } from "./helpers"; @@ -64,6 +64,33 @@ describe("CLI status routing", () => { expect(r.out).not.toContain("does not take a sandbox name"); }); + it("status surfaces an unknown flag rather than the scope hint when it follows a name", () => { + const r = run("status alpha --bogus"); + expect(r.code).toBe(2); + expect(r.out).toContain("Nonexistent flag: --bogus"); + expect(r.out).not.toContain("does not take a sandbox name"); + }); + + it("status leaves multiple unexpected names to the strict parser", () => { + const r = run("status alpha beta"); + expect(r.code).toBe(2); + expect(r.out).toContain("Unexpected arguments: alpha, beta"); + expect(r.out).not.toContain("does not take a sandbox name"); + }); + + it("status preserves help when correcting a sandbox-like argument", () => { + const r = run("status alpha --help"); + expect(r.code).toBe(2); + expect(r.out).toContain("Run: nemoclaw alpha status --help"); + }); + + it("status never emits an unsafe sandbox token in a copy-paste command", () => { + const r = run("status 'alpha;echo pwned'"); + expect(r.code).toBe(2); + expect(r.out).toContain("Unexpected argument: alpha;echo pwned"); + expect(r.out).not.toContain("Run:"); + }); + it("sandbox-first status rejects unexpected positional arguments through command-id dispatch", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-extra-")); writeSandboxRegistry(home); diff --git a/test/root-help.test.ts b/test/root-help.test.ts index f455b08f2df..2fb8b413fb3 100644 --- a/test/root-help.test.ts +++ b/test/root-help.test.ts @@ -29,7 +29,7 @@ describe("root help", () => { renderRootHelp(); const output = log.mock.calls.map(([line]) => String(line)).join("\n"); - expect(output).toContain("Global commands inspect host-wide state"); + expect(output).toContain("Global commands run without a sandbox-name prefix"); expect(output).toContain("sandbox commands start with a sandbox name"); expect(output).toContain("nemoclaw status"); expect(output).toContain("nemoclaw status"); From c61098b5a06b885955c1bb901868884c7f8d858b Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 1 Jul 2026 14:15:15 -0700 Subject: [PATCH 6/6] fix(cli): avoid invalid status scope hints Signed-off-by: Carlos Villela --- src/lib/cli/public-dispatch.ts | 12 +++++++----- test/cli/status-routing.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/lib/cli/public-dispatch.ts b/src/lib/cli/public-dispatch.ts index 73a724dce9b..9420f13c303 100644 --- a/src/lib/cli/public-dispatch.ts +++ b/src/lib/cli/public-dispatch.ts @@ -38,6 +38,7 @@ import { // ── Global commands (derived from command registry) ────────────── const GLOBAL_COMMANDS = globalCommandTokens(); +const NATIVE_OCLIF_NAMESPACES = new Set(["internal", "sandbox"]); type RegistryModule = typeof import("../state/registry"); type RegistryRecoveryModule = typeof import("../registry-recovery-action"); @@ -214,6 +215,9 @@ function printDispatchUsageError( function findGlobalStatusSandboxArgument(args: readonly string[]): string | null { const positionals = args.filter((arg) => !["--json", "--help", "-h"].includes(arg)); if (positionals.some((arg) => arg.startsWith("-")) || positionals.length !== 1) return null; + if (GLOBAL_COMMANDS.has(positionals[0]) || NATIVE_OCLIF_NAMESPACES.has(positionals[0])) { + return null; + } try { validateName(positionals[0], "sandbox name"); return positionals[0]; @@ -224,10 +228,8 @@ function findGlobalStatusSandboxArgument(args: readonly string[]): string | null /** Prints the correction for `status ` and exits with the usage-error status code. */ function printGlobalStatusScopeHint(sandboxName: string, args: readonly string[]): never { - const forwardedFlags = [ - ...(args.includes("--json") ? ["--json"] : []), - ...(args.some((arg) => arg === "--help" || arg === "-h") ? ["--help"] : []), - ]; + const helpRequested = hasHelpFlag(args); + const forwardedFlags = helpRequested ? ["--help"] : args.includes("--json") ? ["--json"] : []; const flagSuffix = forwardedFlags.length > 0 ? ` ${forwardedFlags.join(" ")}` : ""; console.error(` '${CLI_NAME} status' shows the global sandbox/service overview.`); console.error(` It does not take a sandbox name.`); @@ -442,7 +444,7 @@ function printUnknownSandboxOrCommand(cmd: string): never { /** Normalize public argv and route it to oclif or sandbox-first command handlers. */ export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promise { - if (argv[0] === "internal" || argv[0] === "sandbox") { + if (argv[0] && NATIVE_OCLIF_NAMESPACES.has(argv[0])) { await runNativeOclifArgv(argv); return; } diff --git a/test/cli/status-routing.test.ts b/test/cli/status-routing.test.ts index 3eaabbfd578..3f6a064c13a 100644 --- a/test/cli/status-routing.test.ts +++ b/test/cli/status-routing.test.ts @@ -84,6 +84,28 @@ describe("CLI status routing", () => { expect(r.out).toContain("Run: nemoclaw alpha status --help"); }); + it.each([ + "status", + "help", + "sandbox", + "internal", + ])("status does not suggest reserved command token %s as a sandbox name", (token) => { + const r = run(`status ${token}`); + expect(r.code).toBe(2); + expect(r.out).toContain(`Unexpected argument: ${token}`); + expect(r.out).not.toContain("Run:"); + }); + + it.each([ + "status alpha --json --help", + "status alpha --help --json", + ])("status gives help precedence in combined-flag scope guidance for %s", (command) => { + const r = run(command); + expect(r.code).toBe(2); + expect(r.out).toContain("Run: nemoclaw alpha status --help"); + expect(r.out).not.toContain("Run: nemoclaw alpha status --json --help"); + }); + it("status never emits an unsafe sandbox token in a copy-paste command", () => { const r = run("status 'alpha;echo pwned'"); expect(r.code).toBe(2);