Skip to content
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4b7a4a2
refactor(cli): extract sandbox live state helpers
cv May 2, 2026
2ce87dd
refactor(cli): extract sandbox skill install action
cv May 2, 2026
4cd3cf3
refactor(cli): extract sandbox connect action
cv May 3, 2026
e9dd46e
refactor(cli): extract sandbox status action
cv May 3, 2026
aab6c86
refactor(cli): extract sandbox doctor action
cv May 3, 2026
8908521
refactor(cli): extract sandbox destroy action
cv May 3, 2026
56e4f05
refactor(cli): extract sandbox rebuild action
cv May 3, 2026
8bf1958
refactor(cli): extract upgrade sandboxes action
cv May 3, 2026
38eb84d
refactor(cli): remove runtime bridge
cv May 3, 2026
b2ad5da
refactor(cli): remove legacy dispatch fallbacks
cv May 3, 2026
edd2650
refactor(cli): expose explicit main entrypoint
cv May 3, 2026
a15da95
refactor(cli): add oclif examples for utility commands
cv May 3, 2026
4f57ebb
refactor(cli): validate logs flags with oclif
cv May 3, 2026
8b2d077
refactor(cli): improve sandbox diagnostic command metadata
cv May 3, 2026
a09cc51
refactor(cli): tighten policy and channel parser validation
cv May 3, 2026
75857dc
refactor(cli): improve snapshot command metadata
cv May 3, 2026
11c0676
refactor(cli): require skill install path in oclif
cv May 3, 2026
05f9eca
refactor(cli): add lifecycle confirmation flag aliases
cv May 3, 2026
4ebeae4
refactor(cli): split share into oclif subcommands
cv May 3, 2026
7d72437
Revert "refactor(cli): split share into oclif subcommands"
cv May 3, 2026
0ee5ca5
refactor(cli): split share into oclif subcommands
cv May 3, 2026
928219c
refactor(cli): model debug flags with oclif
cv May 3, 2026
36cce5e
refactor(cli): model onboard flags with oclif
cv May 3, 2026
702afb1
docs: sync oclif UX command reference
cv May 3, 2026
3224350
refactor(cli): extract public argv normalizer
cv May 3, 2026
cd4cdb8
refactor(cli): rename oclif dispatch module
cv May 3, 2026
9499ca6
refactor(cli): normalize policy command ids
cv May 3, 2026
42028ef
test(cli): require oclif command metadata
cv May 3, 2026
a05c6b3
refactor(cli): return typed debug parse results
cv May 3, 2026
1875fe9
refactor(cli): add public command display ids
cv May 3, 2026
36f1dbe
refactor(cli): use oclif summaries in root help
cv May 3, 2026
78bfd5a
refactor(cli): table-drive sandbox dispatch
cv May 3, 2026
96ce61f
refactor(cli): normalize gateway token command id
cv May 3, 2026
266ebc9
refactor(cli): render public oclif help
cv May 3, 2026
dc98994
refactor(cli): add shared oclif command base
cv May 3, 2026
d0d2a70
refactor(cli): use oclif flag relationships
cv May 3, 2026
8396c06
refactor(cli): pass lifecycle typed options
cv May 3, 2026
1532b19
refactor(cli): parse durations with oclif flags
cv May 3, 2026
7c0445a
refactor(cli): project public help through oclif
cv May 3, 2026
3978c32
test(cli): cover oclif metadata routing helpers
cv May 3, 2026
864eb30
merge(main): reconcile oclif routing coverage
cv May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ The wizard creates an OpenShell gateway, registers inference providers, builds t
Use this command for new installs and for recreating a sandbox after changes to policy or configuration.

```console
$ nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from <Dockerfile>] [--name <sandbox>] [--agent <name>] [--control-ui-port <N>] [--yes-i-accept-third-party-software]
$ nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--from <Dockerfile>] [--name <sandbox>] [--agent <name>] [--control-ui-port <N>] [--yes-i-accept-third-party-software]
```

:::{warning}
Expand Down Expand Up @@ -315,12 +315,12 @@ $ nemoclaw my-assistant doctor [--json]
### `nemoclaw <name> logs`

View sandbox logs.
Use `--follow` to stream output in real time.
Use `--follow` to stream output in real time, `--tail <lines>` (or `-n <lines>`) to choose how many lines to show, and `--since <duration>` for recent OpenShell logs such as `5m`, `1h`, or `30s`.
The command reads both OpenClaw gateway output and OpenShell audit events, so policy denials appear alongside the gateway log stream.
If one log source is unavailable, NemoClaw prints a warning and keeps reading the remaining source.

```console
$ nemoclaw my-assistant logs [--follow]
$ nemoclaw my-assistant logs [--follow] [--tail <lines>|-n <lines>] [--since <duration>]
```

### `nemoclaw <name> gateway-token`
Expand Down Expand Up @@ -357,7 +357,7 @@ If you want to upgrade the sandbox while preserving state, use `nemoclaw <name>
:::

If another terminal has an active SSH session to the sandbox, `destroy` prints an active-session warning and requires a second confirmation before it proceeds.
Pass `--yes` or `--force` to skip the prompt in scripted workflows.
Pass `--yes`, `-y`, or `--force` to skip the prompt in scripted workflows.

```console
$ nemoclaw my-assistant destroy
Expand Down Expand Up @@ -546,16 +546,16 @@ Credentials are stripped from backups before storage.
Policy presets applied to the old sandbox are reapplied to the new one so your egress rules survive the rebuild.

```console
$ nemoclaw my-assistant rebuild [--yes] [--verbose]
$ nemoclaw my-assistant rebuild [--yes|-y|--force] [--verbose|-v]
```

| Flag | Description |
|------|-------------|
| `--yes`, `--force` | Skip the confirmation prompt |
| `--verbose` | Log SSH commands, exit codes, and session state (also enabled by `NEMOCLAW_REBUILD_VERBOSE=1`) |
| `--yes`, `-y`, `--force` | Skip the confirmation prompt |
| `--verbose`, `-v` | Log SSH commands, exit codes, and session state (also enabled by `NEMOCLAW_REBUILD_VERBOSE=1`) |

If another terminal has an active SSH session to the sandbox, `rebuild` prints an active-session warning and requires confirmation before destroying the sandbox.
Pass `--yes` or `--force` to skip the prompt in scripted workflows.
Pass `--yes`, `-y`, or `--force` to skip the prompt in scripted workflows.

The sandbox must be running for the backup step to succeed.
After restore, the command runs `openclaw doctor --fix` for cross-version structure repair.
Expand All @@ -567,14 +567,14 @@ NemoClaw resolves the digest of `ghcr.io/nvidia/nemoclaw/sandbox-base:latest` fr
Sandboxes that match the current digest are left alone.

```console
$ nemoclaw upgrade-sandboxes [--check] [--auto] [--yes]
$ nemoclaw upgrade-sandboxes [--check] [--auto] [--yes|-y]
```

| Flag | Description |
|------|-------------|
| `--check` | List stale sandboxes without rebuilding any of them. Exits non-zero if any are stale. |
| `--auto` | Rebuild every stale sandbox without prompting. Used by the installer to upgrade in place. |
| `--yes` | Skip the confirmation prompt for the rebuild plan. |
| `--yes`, `-y` | Skip the confirmation prompt for the rebuild plan. |

Each rebuild reuses the same workspace backup-and-restore flow as `nemoclaw <name> rebuild`, so workspace files survive the upgrade.
If the registry is unreachable (offline or firewalled hosts), NemoClaw falls back to the unpinned `:latest` tag and reports that the digest could not be resolved instead of failing.
Expand Down Expand Up @@ -791,14 +791,14 @@ Gathers system info, Docker state, gateway logs, and sandbox status into a summa
Use `--sandbox <name>` to target a specific sandbox, `--quick` for a smaller snapshot, or `--output <path>` to save a tarball that you can attach to an issue.

```console
$ nemoclaw debug [--quick] [--sandbox NAME] [--output PATH]
$ nemoclaw debug [--quick|-q] [--sandbox NAME] [--output PATH|-o PATH]
```

| Flag | Description |
|------|-------------|
| `--quick` | Collect minimal diagnostics only |
| `--quick`, `-q` | Collect minimal diagnostics only |
| `--sandbox NAME` | Target a specific sandbox (default: auto-detect) |
| `--output PATH` | Write diagnostics tarball to the given path |
| `--output PATH`, `-o PATH` | Write diagnostics tarball to the given path |

If `--output` is set and the tarball cannot be written (for example, the destination directory is missing or read-only), the command exits non-zero so scripts can detect the failure.

Expand Down Expand Up @@ -833,13 +833,13 @@ The `destroy` and `rebuild` commands clean up the image automatically, but image
This command lists all `openshell/sandbox-from:*` images, cross-references the sandbox registry, and removes any that are no longer associated with a registered sandbox.

```console
$ nemoclaw gc [--dry-run] [--yes|--force]
$ nemoclaw gc [--dry-run] [--yes|-y|--force]
```

| Flag | Description |
|------|-------------|
| `--dry-run` | List orphaned images without removing them |
| `--yes`, `--force` | Skip the confirmation prompt |
| `--yes`, `-y`, `--force` | Skip the confirmation prompt |

### `nemoclaw uninstall`

Expand Down
14 changes: 14 additions & 0 deletions src/lib/channels-mutate-cli-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,18 @@ describe("channels mutation oclif commands", () => {
]);
expect(runtime.sandboxChannelsStop).toHaveBeenCalledWith("alpha", ["slack"]);
});

it("requires a channel before dispatch", async () => {
const runtime = {
sandboxChannelsAdd: vi.fn().mockResolvedValue(undefined),
sandboxChannelsRemove: vi.fn().mockResolvedValue(undefined),
sandboxChannelsStart: vi.fn().mockResolvedValue(undefined),
sandboxChannelsStop: vi.fn().mockResolvedValue(undefined),
};
setChannelsRuntimeBridgeFactoryForTest(() => runtime);

await expect(ChannelsAddCommand.run(["alpha"], rootDir)).rejects.toThrow(/channel/i);

expect(runtime.sandboxChannelsAdd).not.toHaveBeenCalled();
});
});
6 changes: 5 additions & 1 deletion src/lib/channels-mutate-cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function getRuntimeBridge(): ChannelsRuntimeBridge {
}

const sandboxNameArg = Args.string({ name: "sandbox", description: "Sandbox name", required: true });
const channelArg = Args.string({ name: "channel", description: "Messaging channel", required: false });
const channelArg = Args.string({ name: "channel", description: "Messaging channel", required: true });

function buildArgs(channel: string | undefined, flags: { "dry-run"?: boolean }): string[] {
const args: string[] = [];
Expand All @@ -62,6 +62,7 @@ export class ChannelsAddCommand extends Command {
static summary = "Save messaging channel credentials and rebuild";
static description = "Store credentials for a messaging channel and queue a sandbox rebuild.";
static usage = ["<name> channels add <channel> [--dry-run]"];
static examples = ["<%= config.bin %> alpha channels add telegram"];
static args = channelMutationArgs;
static flags = channelMutationFlags;

Expand All @@ -77,6 +78,7 @@ export class ChannelsRemoveCommand extends Command {
static summary = "Clear messaging channel credentials and rebuild";
static description = "Remove credentials for a messaging channel and queue a sandbox rebuild.";
static usage = ["<name> channels remove <channel> [--dry-run]"];
static examples = ["<%= config.bin %> alpha channels remove slack --dry-run"];
static args = channelMutationArgs;
static flags = channelMutationFlags;

Expand All @@ -92,6 +94,7 @@ export class ChannelsStopCommand extends Command {
static summary = "Disable channel without wiping credentials";
static description = "Disable a messaging channel while keeping credentials in the gateway.";
static usage = ["<name> channels stop <channel> [--dry-run]"];
static examples = ["<%= config.bin %> alpha channels stop discord"];
static args = channelMutationArgs;
static flags = channelMutationFlags;

Expand All @@ -107,6 +110,7 @@ export class ChannelsStartCommand extends Command {
static summary = "Re-enable a stopped messaging channel";
static description = "Re-enable a previously stopped messaging channel.";
static usage = ["<name> channels start <channel> [--dry-run]"];
static examples = ["<%= config.bin %> alpha channels start discord"];
static args = channelMutationArgs;
static flags = channelMutationFlags;

Expand Down
93 changes: 93 additions & 0 deletions src/lib/cli-argv-normalizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { normalizeArgv, suggestCommand } from "./cli-argv-normalizer";

const globalCommands = new Set(["list", "status", "onboard", "--version"]);
const isConnectFlag = (arg: string | undefined) => arg === "--probe-only" || arg === "--help";

describe("normalizeArgv", () => {
it("normalizes root help aliases", () => {
expect(normalizeArgv([], { globalCommands, isSandboxConnectFlag: isConnectFlag })).toEqual({
kind: "rootHelp",
});
expect(normalizeArgv(["--help"], { globalCommands, isSandboxConnectFlag: isConnectFlag })).toEqual({
kind: "rootHelp",
});
});

it("normalizes internal dump commands", () => {
expect(
normalizeArgv(["--dump-commands"], { globalCommands, isSandboxConnectFlag: isConnectFlag }),
).toEqual({ kind: "dumpCommands" });
});

it("normalizes global commands", () => {
expect(
normalizeArgv(["list", "--json"], { globalCommands, isSandboxConnectFlag: isConnectFlag }),
).toEqual({ kind: "global", command: "list", args: ["--json"] });
});

it("normalizes explicit sandbox actions", () => {
expect(
normalizeArgv(["alpha", "status"], { globalCommands, isSandboxConnectFlag: isConnectFlag }),
).toEqual({
kind: "sandbox",
sandboxName: "alpha",
action: "status",
actionArgs: [],
connectHelpRequested: false,
});
});

it("normalizes bare and implicit connect invocations", () => {
expect(
normalizeArgv(["alpha"], { globalCommands, isSandboxConnectFlag: isConnectFlag }),
).toEqual({
kind: "sandbox",
sandboxName: "alpha",
action: "connect",
actionArgs: [],
connectHelpRequested: false,
});
expect(
normalizeArgv(["alpha", "--probe-only"], {
globalCommands,
isSandboxConnectFlag: isConnectFlag,
}),
).toEqual({
kind: "sandbox",
sandboxName: "alpha",
action: "connect",
actionArgs: ["--probe-only"],
connectHelpRequested: false,
});
});

it("tracks connect help requests", () => {
expect(
normalizeArgv(["alpha", "connect", "--help"], {
globalCommands,
isSandboxConnectFlag: isConnectFlag,
}),
).toMatchObject({
kind: "sandbox",
sandboxName: "alpha",
action: "connect",
actionArgs: ["--help"],
connectHelpRequested: true,
});
});
});

describe("suggestCommand", () => {
it("suggests close global command typos", () => {
expect(suggestCommand("liost", globalCommands)).toBe("list");
});

it("ignores flag-like commands", () => {
expect(suggestCommand("version", globalCommands)).toBeNull();
});
});
88 changes: 88 additions & 0 deletions src/lib/cli-argv-normalizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

export type NormalizedRootHelpArgv = { kind: "rootHelp" };
export type NormalizedDumpCommandsArgv = { kind: "dumpCommands" };
export type NormalizedGlobalArgv = { kind: "global"; command: string; args: string[] };
export type NormalizedSandboxArgv = {
kind: "sandbox";
sandboxName: string;
action: string;
actionArgs: string[];
connectHelpRequested: boolean;
};

export type NormalizedArgv =
| NormalizedRootHelpArgv
| NormalizedDumpCommandsArgv
| NormalizedGlobalArgv
| NormalizedSandboxArgv;

export type NormalizeArgvOptions = {
globalCommands: ReadonlySet<string>;
isSandboxConnectFlag: (arg: string | undefined) => boolean;
};

export function normalizeArgv(argv: readonly string[], opts: NormalizeArgvOptions): NormalizedArgv {
const [cmd, ...args] = argv;

if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
return { kind: "rootHelp" };
}

if (cmd === "--dump-commands") {
return { kind: "dumpCommands" };
}

if (opts.globalCommands.has(cmd)) {
return { kind: "global", command: cmd, args };
}

const firstSandboxArg = args[0];
const implicitConnectArg = opts.isSandboxConnectFlag(firstSandboxArg);
const action = !firstSandboxArg || implicitConnectArg ? "connect" : firstSandboxArg;
const actionArgs = !firstSandboxArg || implicitConnectArg ? args : args.slice(1);

return {
kind: "sandbox",
sandboxName: cmd,
action,
actionArgs,
connectHelpRequested:
action === "connect" && actionArgs.some((arg) => arg === "--help" || arg === "-h"),
};
}

function editDistance(left: string, right: string): number {
const rows = left.length + 1;
const cols = right.length + 1;
const matrix: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0));
for (let i = 0; i < rows; i++) matrix[i][0] = i;
for (let j = 0; j < cols; j++) matrix[0][j] = j;
for (let i = 1; i < rows; i++) {
for (let j = 1; j < cols; j++) {
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
);
}
}
return matrix[left.length][right.length];
}

export function suggestCommand(token: string, commands: Iterable<string>): string | null {
let best: { command: string; distance: number } | null = null;
for (const command of commands) {
if (command.startsWith("-")) continue;
const distance = editDistance(token, command);
if (!best || distance < best.distance) {
best = { command, distance };
}
}
if (!best) return null;
if (best.distance <= 1) return best.command;
if (token.length >= 5 && best.distance <= 2) return best.command;
return null;
}
26 changes: 26 additions & 0 deletions src/lib/command-display-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { COMMANDS, visibleCommands } from "./command-registry";
import commands from "../../dist/lib/oclif-commands.js";

describe("public command display metadata", () => {
it("maps every command display entry to a registered oclif command", () => {
const registered = new Set(Object.keys(commands));
const missing = COMMANDS.filter((command) => !registered.has(command.commandId)).map(
(command) => `${command.usage} -> ${command.commandId}`,
);

expect(missing).toEqual([]);
});

it("keeps visible command display metadata scoped and grouped", () => {
const invalid = visibleCommands()
.filter((command) => !command.group || !command.scope || !command.commandId)
.map((command) => command.usage);

expect(invalid).toEqual([]);
});
});
Loading
Loading