Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 58 additions & 1 deletion src/lib/cli/oclif-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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", () => {
Expand Down
22 changes: 15 additions & 7 deletions src/lib/cli/oclif-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,19 @@ export async function runOclifCommandById(
export async function runOclifArgv(args: string[], opts: OclifCommandRunOptions): Promise<void> {
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;
}
}
12 changes: 8 additions & 4 deletions src/lib/cli/public-display-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ const PUBLIC_DISPLAY_LAYOUT: Record<string, readonly PublicDisplayLayout[]> = {
{
"group": "Messaging Channels",
"order": 21,
"usage": "nemoclaw <name> channels add <channel>",
"description": "Save credentials and rebuild",
"flags": "<channel> [--dry-run]"
"flags": "[--dry-run]"
}
],
"sandbox:channels:list": [
Expand All @@ -145,24 +146,27 @@ const PUBLIC_DISPLAY_LAYOUT: Record<string, readonly PublicDisplayLayout[]> = {
{
"group": "Messaging Channels",
"order": 22,
"usage": "nemoclaw <name> channels remove <channel>",
"description": "Remove a configured messaging channel",
"flags": "<channel> [--dry-run]"
"flags": "[--dry-run]"
}
],
"sandbox:channels:start": [
{
"group": "Messaging Channels",
"order": 24,
"usage": "nemoclaw <name> channels start <channel>",
"description": "Re-enable a previously stopped channel",
"flags": "<channel> [--dry-run]"
"flags": "[--dry-run]"
}
],
"sandbox:channels:stop": [
{
"group": "Messaging Channels",
"order": 23,
"usage": "nemoclaw <name> channels stop <channel>",
"description": "Disable channel (keeps credentials)",
"flags": "<channel> [--dry-run]"
"flags": "[--dry-run]"
}
],
"sandbox:config:get": [
Expand Down
40 changes: 29 additions & 11 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} <name> <channel> [--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-"));
Expand Down
30 changes: 25 additions & 5 deletions test/e2e/e2e-cloud-experimental/check-docs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<arg>, [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 " ")"
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions test/root-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> channels ${action} <channel>`);
expect(output).not.toMatch(
new RegExp(`nemoclaw <name> channels ${action}\\\\s{2,}[^\\n]*<channel>`),
);
}
});
});
Loading