diff --git a/src/lib/cli/oclif-runner.test.ts b/src/lib/cli/oclif-runner.test.ts index 22c31a146ad..ad75e65b7a7 100644 --- a/src/lib/cli/oclif-runner.test.ts +++ b/src/lib/cli/oclif-runner.test.ts @@ -43,13 +43,44 @@ class UnexpectedArgsError extends Error { } describe("runOclifArgv", () => { + let originalArgv: string[]; + + beforeEach(() => { + executeMock.mockReset(); + loadMock.mockReset(); + runCommandMock.mockReset(); + loadMock.mockResolvedValue(makeConfig()); + originalArgv = process.argv; + process.argv = ["/usr/bin/node", "/repo/bin/nemoclaw.js", "alpha", "status"]; + }); + + afterEach(() => { + process.argv = originalArgv; + }); + it("executes native oclif argv with branded package metadata", async () => { const config = makeConfig(); loadMock.mockResolvedValue(config); - executeMock.mockResolvedValue(undefined); + executeMock.mockImplementation(async () => { + expect(process.argv).toEqual([ + "/usr/bin/node", + "/repo/bin/nemoclaw.js", + "sandbox", + "channels", + "start", + "--help", + ]); + }); await runOclifArgv(["sandbox", "channels", "start", "--help"], { rootDir: "/repo" }); + expect(process.argv).toEqual([ + "/usr/bin/node", + "/repo/bin/nemoclaw.js", + "alpha", + "status", + ]); + expect(loadMock).toHaveBeenCalledWith("/repo"); expect(executeMock).toHaveBeenCalledWith({ args: ["sandbox", "channels", "start", "--help"], @@ -62,6 +93,32 @@ describe("runOclifArgv", () => { expect(config.options.pjson.oclif.bin).toBe("nemoclaw"); expect(config.plugins.get("root")?.pjson.oclif.bin).toBe("nemoclaw"); }); + + it("restores process argv when native oclif execution throws", async () => { + const error = new Error("Missing 1 required arg: channel"); + executeMock.mockImplementation(async () => { + expect(process.argv).toEqual([ + "/usr/bin/node", + "/repo/bin/nemoclaw.js", + "sandbox", + "channels", + "add", + "alpha", + ]); + throw error; + }); + + await expect( + runOclifArgv(["sandbox", "channels", "add", "alpha"], { rootDir: "/repo" }), + ).rejects.toBe(error); + + expect(process.argv).toEqual([ + "/usr/bin/node", + "/repo/bin/nemoclaw.js", + "alpha", + "status", + ]); + }); }); describe("runOclifCommandById", () => { diff --git a/src/lib/cli/oclif-runner.ts b/src/lib/cli/oclif-runner.ts index ad01000ea23..75d017ae077 100644 --- a/src/lib/cli/oclif-runner.ts +++ b/src/lib/cli/oclif-runner.ts @@ -127,11 +127,19 @@ export async function runOclifCommandById( export async function runOclifArgv(args: string[], opts: OclifCommandRunOptions): Promise { const config = await OclifConfig.load(opts.rootDir); applyBrandedBin(config); - await executeOclif({ - args, - loadOptions: { - root: opts.rootDir, - pjson: config.pjson, - }, - }); + const originalArgv = process.argv; + // oclif's parse-error help renderer consults process.argv, not just the + // explicit execute({ args }) value, so keep both views on the native route. + process.argv = [originalArgv[0] ?? process.execPath, originalArgv[1] ?? CLI_NAME, ...args]; + try { + await executeOclif({ + args, + loadOptions: { + root: opts.rootDir, + pjson: config.pjson, + }, + }); + } finally { + process.argv = originalArgv; + } } diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 070f3cb886e..bc47165ff6d 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -131,8 +131,9 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { { "group": "Messaging Channels", "order": 21, + "usage": "nemoclaw channels add ", "description": "Save credentials and rebuild", - "flags": " [--dry-run]" + "flags": "[--dry-run]" } ], "sandbox:channels:list": [ @@ -145,24 +146,27 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { { "group": "Messaging Channels", "order": 22, + "usage": "nemoclaw channels remove ", "description": "Remove a configured messaging channel", - "flags": " [--dry-run]" + "flags": "[--dry-run]" } ], "sandbox:channels:start": [ { "group": "Messaging Channels", "order": 24, + "usage": "nemoclaw channels start ", "description": "Re-enable a previously stopped channel", - "flags": " [--dry-run]" + "flags": "[--dry-run]" } ], "sandbox:channels:stop": [ { "group": "Messaging Channels", "order": 23, + "usage": "nemoclaw channels stop ", "description": "Disable channel (keeps credentials)", - "flags": " [--dry-run]" + "flags": "[--dry-run]" } ], "sandbox:config:get": [ diff --git a/test/cli.test.ts b/test/cli.test.ts index 8467be0c55f..b9de8fb45df 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -2049,18 +2049,36 @@ describe("CLI dispatch", () => { expect(snapshots.out).toContain("No snapshots found for 'alpha'."); }); - it("policy and channel mutations reject missing parser-owned values before dispatch", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-mutation-missing-values-")); - writeSandboxRegistry(home); - - const missingPolicyFile = runWithEnv("alpha policy-add --from-file 2>&1", { HOME: home }); - expect(missingPolicyFile.code).not.toBe(0); - expect(missingPolicyFile.out).toContain("--from-file"); + it( + "policy and channel mutations reject missing parser-owned values before dispatch", + testTimeoutOptions(30_000), + () => { + const home = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-cli-mutation-missing-values-"), + ); + writeSandboxRegistry(home); - const missingChannel = runWithEnv("alpha channels add 2>&1", { HOME: home }); - expect(missingChannel.code).not.toBe(0); - expect(missingChannel.out).toContain("channel"); - }); + const missingPolicyFile = runWithEnv("alpha policy-add --from-file 2>&1", { + HOME: home, + }); + expect(missingPolicyFile.code).not.toBe(0); + expect(missingPolicyFile.out).toContain("--from-file"); + + for (const action of ["add", "remove", "start", "stop"]) { + const missingChannel = runWithEnv(`alpha channels ${action} 2>&1`, { HOME: home }); + expect(missingChannel.code).toBe(PARSER_EXIT_CODE); + expect(missingChannel.out).toContain("Missing 1 required arg:"); + expect(missingChannel.out).toContain("channel Messaging channel"); + expect(missingChannel.out).toContain("USAGE"); + expect(missingChannel.out).toContain( + `$ nemoclaw sandbox channels ${action} [--dry-run]`, + ); + expect(missingChannel.out).not.toContain("RequiredArgsError"); + expect(missingChannel.out).not.toContain("at validateArgs"); + expect(missingChannel.out).not.toContain(`Command alpha:channels:${action} not found`); + } + }, + ); it("diagnostic commands reject invalid parser-owned flags before dispatch", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-diagnostics-invalid-flags-")); diff --git a/test/e2e/e2e-cloud-experimental/check-docs.sh b/test/e2e/e2e-cloud-experimental/check-docs.sh index 76cdfeff907..5bce5927033 100755 --- a/test/e2e/e2e-cloud-experimental/check-docs.sh +++ b/test/e2e/e2e-cloud-experimental/check-docs.sh @@ -173,17 +173,32 @@ JSON # shellcheck disable=SC2016 # log text: backticks are documentation markers, not command substitution log '[cli] phase 2/2: extract ### `nemoclaw …` headings from commands reference' - # Allow optional MyST suffix on the same line, e.g. ### `nemoclaw onboard` {#anchor} - # Strip argument placeholders (, [optional]) to match canonical usage signatures. + # Allow optional MyST suffix on the same line, e.g. ### `nemoclaw onboard` {#anchor}. + # Preserve placeholders that are part of the canonical help signature, but + # keep accepting docs-only suffixes such as `snapshot restore [selector]`. grep -E '^### `nemoclaw ' "$COMMANDS_MD" | LC_ALL=C perl -CS -ne ' + BEGIN { + my $help_path = shift @ARGV; + open my $help_fh, "<", $help_path or die "open help list: $!"; + while (my $line = <$help_fh>) { + chomp $line; + $help{$line} = 1; + } + close $help_fh; + } if (/^### `([^`]+)`\s*(?:\{[^}]+\})?\s*$/) { my $c = $1; - while ($c =~ s/\s*\[[^\]]*\]\s*$//) {} - while ($c =~ s/\s+<[^>]+>\s*$//) {} $c =~ s/\s+$//; + while (!$help{$c}) { + my $changed = 0; + $changed ||= ($c =~ s/\s*\[[^\]]*\]\s*$//); + $changed ||= ($c =~ s/\s+<[^>]+>\s*$//); + $c =~ s/\s+$//; + last unless $changed; + } print "$c\n"; } - ' | LC_ALL=C sort -u >"$_tmp/doc.txt" + ' "$_tmp/help.txt" | LC_ALL=C sort -u >"$_tmp/doc.txt" local _n_doc _n_doc="$(wc -l <"$_tmp/doc.txt" | tr -d " ")" @@ -239,6 +254,11 @@ JSON bt = index(line, "`") if (bt > 0) { cand = substr(line, 1, bt - 1) + sub(/[[:space:]]+$/, "", cand) + if (cand == target) { + in_sec = 1 + next + } while (sub(/[[:space:]]*\[[^]]*\][[:space:]]*$/, "", cand)) {} while (sub(/[[:space:]]+<[^>]+>[[:space:]]*$/, "", cand)) {} sub(/[[:space:]]+$/, "", cand) diff --git a/test/root-help.test.ts b/test/root-help.test.ts index 005efdcdb5c..9d4d2859dd7 100644 --- a/test/root-help.test.ts +++ b/test/root-help.test.ts @@ -22,4 +22,18 @@ describe("root help", () => { expect(output).not.toContain("Agent config is read-only inside the sandbox"); expect(output).not.toContain("Landlock enforced"); }); + + it("shows channel as a required positional argument in channel command signatures", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + renderRootHelp(); + + const output = log.mock.calls.map(([line]) => String(line)).join("\n"); + for (const action of ["add", "remove", "start", "stop"]) { + expect(output).toContain(`nemoclaw channels ${action} `); + expect(output).not.toMatch( + new RegExp(`nemoclaw channels ${action}\\\\s{2,}[^\\n]*`), + ); + } + }); });