Skip to content
2 changes: 1 addition & 1 deletion src/commands/internal/dev/npm-link-or-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ export default class InternalDevNpmLinkOrShimCommand extends NemoClawCommand {
public async run(): Promise<void> {
const { flags } = await this.parse(InternalDevNpmLinkOrShimCommand);
const result = runNpmLinkOrShim({ repoRoot: flags["repo-root"] ?? this.config.root });
if (result.status !== 0) process.exit(result.status);
this.applyExitResult(result);
}
}
8 changes: 1 addition & 7 deletions src/commands/internal/dns/fix-coredns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,6 @@ export default class InternalDnsFixCoreDnsCommand extends NemoClawCommand {
public async run(): Promise<void> {
const { args } = await this.parse(InternalDnsFixCoreDnsCommand);
const result = runFixCoreDns({ gatewayName: args.gatewayName });
if (result.exitCode !== 0) {
if (result.message) {
this.error(result.message, { exit: result.exitCode });
} else {
this.exit(result.exitCode);
}
}
this.applyExitResult(result);
}
}
5 changes: 1 addition & 4 deletions src/commands/internal/dns/setup-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ export default class InternalDnsSetupProxyCommand extends NemoClawCommand {
public async run(): Promise<void> {
const { args } = await this.parse(InternalDnsSetupProxyCommand);
const result = runSetupDnsProxy({ gatewayName: args.gatewayName, sandboxName: args.sandboxName });
if (result.exitCode !== 0) {
if (result.message) console.error(result.message);
process.exit(result.exitCode);
}
this.applyExitResult(result);
}
}
2 changes: 1 addition & 1 deletion src/commands/internal/uninstall/run-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ export default class InternalUninstallRunPlanCommand extends NemoClawCommand {
gatewayName: flags.gateway,
keepOpenShell: flags["keep-openshell"] ?? false,
});
process.exit(result.exitCode);
this.applyExitResult(result);
}
}
57 changes: 57 additions & 0 deletions src/lib/cli/nemoclaw-oclif-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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

import { NemoClawCommand, type CommandExitResult } from "./nemoclaw-oclif-command";

class TestCommand extends NemoClawCommand {
static id = "test";

public async run(): Promise<void> {
// Test-only command wrapper.
}

public apply(result: CommandExitResult): void {
this.applyExitResult(result);
}

public fail(lines: readonly string[], code?: number): void {
this.failWithLines(lines, code);
}
}

function makeCommand(): TestCommand {
return Object.create(TestCommand.prototype) as TestCommand;
}

describe("NemoClawCommand", () => {
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
});

it("records status-like command results without throwing", () => {
makeCommand().apply({ status: 7 });

expect(process.exitCode).toBe(7);
});

it("prefers exitCode and prints failure messages", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);

makeCommand().apply({ exitCode: 3, message: "boom", status: 7 });

expect(process.exitCode).toBe(3);
expect(error).toHaveBeenCalledWith("boom");
});

it("prints multi-line failures and records the requested code", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);

makeCommand().fail(["line 1", "line 2"], 9);

expect(process.exitCode).toBe(9);
expect(error.mock.calls).toEqual([["line 1"], ["line 2"]]);
});
});
26 changes: 26 additions & 0 deletions src/lib/cli/nemoclaw-oclif-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@

import { Command, Flags } from "@oclif/core";

export type CommandExitResult = {
exitCode?: number | null;
message?: string | null;
status?: number | null;
};

/**
* Shared oclif base for NemoClaw commands.
*
Expand All @@ -17,4 +23,24 @@ export abstract class NemoClawCommand extends Command {
protected logJson(json: unknown): void {
console.log(JSON.stringify(json, null, 2));
}

protected setExitCode(code: number): void {
process.exitCode = code;
}

protected failWithLines(lines: readonly string[], code = 1): void {
for (const line of lines) console.error(line);
this.setExitCode(code);
}

protected applyExitResult(result: CommandExitResult): void {
const code =
typeof result.exitCode === "number"
? result.exitCode
: typeof result.status === "number"
? result.status
: 0;
if (code !== 0 && result.message) this.failWithLines([result.message], code);
else this.setExitCode(code);
}
}
24 changes: 15 additions & 9 deletions src/lib/commands/credentials/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,21 @@ export function printCredentialsUsage(log: (message?: string) => void = console.
log("");
}

export async function recoverGatewayOrExit(kind: "query" | "reach"): Promise<void> {
export function credentialsGatewayRecoveryFailureLines(kind: "query" | "reach"): string[] {
const action = kind === "query" ? "query" : "reach";
return [
` Could not ${action} the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`,
` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`,
];
}

export async function recoverGatewayOrExit(
kind: "query" | "reach",
reportFailure: (lines: readonly string[]) => void = (lines) => lines.forEach((line) => console.error(line)),
): Promise<boolean> {
const recovery = await recoverNamedGatewayRuntime();
if (recovery.recovered) return;
if (recovery.recovered) return true;

if (kind === "query") {
console.error(` Could not query the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`);
} else {
console.error(` Could not reach the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`);
}
console.error(` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`);
process.exit(1);
reportFailure(credentialsGatewayRecoveryFailureLines(kind));
return false;
}
10 changes: 6 additions & 4 deletions src/lib/commands/credentials/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,19 @@ export default class CredentialsListCommand extends NemoClawCommand {

public async run(): Promise<void> {
await this.parse(CredentialsListCommand);
await recoverGatewayOrExit("query");
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) {
console.error(" Could not query OpenShell gateway. Is it running?");
console.error(` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`);
process.exit(1);
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 || "")
Expand Down
40 changes: 22 additions & 18 deletions src/lib/commands/credentials/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,21 @@ export default class CredentialsResetCommand extends NemoClawCommand {
const key = args.provider;

if (!key || key.startsWith("-")) {
console.error(` Usage: ${CLI_NAME} credentials reset <PROVIDER> [--yes]`);
console.error(` PROVIDER is an OpenShell provider name. Run '${CLI_NAME} credentials list' first.`);
process.exit(1);
this.failWithLines([
` Usage: ${CLI_NAME} credentials reset <PROVIDER> [--yes]`,
` PROVIDER is an OpenShell provider name. Run '${CLI_NAME} credentials list' first.`,
]);
return;
}

if (isBridgeProviderName(key)) {
console.error(` '${key}' is a per-sandbox messaging bridge, not a credential.`);
console.error(
this.failWithLines([
` '${key}' is a per-sandbox messaging bridge, not a credential.`,
` Use \`${CLI_NAME} <sandbox> channels remove <telegram|discord|slack>\` to retire`,
);
console.error(" the integration (it tears down the bridge provider and rebuilds the sandbox),");
console.error(` or \`${CLI_NAME} <sandbox> channels stop <…>\` to pause it without clearing tokens.`);
process.exit(1);
" the integration (it tears down the bridge provider and rebuilds the sandbox),",
` or \`${CLI_NAME} <sandbox> channels stop <…>\` to pause it without clearing tokens.`,
]);
return;
}

if (!flags.yes) {
Expand All @@ -61,7 +63,7 @@ export default class CredentialsResetCommand extends NemoClawCommand {
}
}

await recoverGatewayOrExit("reach");
if (!(await recoverGatewayOrExit("reach", (lines) => this.failWithLines(lines)))) return;

const result = runOpenshellProviderCommand(["provider", "delete", key], {
ignoreError: true,
Expand All @@ -74,16 +76,18 @@ export default class CredentialsResetCommand extends NemoClawCommand {
return;
}

console.error(` Could not remove provider '${key}'.`);
const lines = [` Could not remove provider '${key}'.`];
if (/^[A-Z][A-Z0-9_]+$/.test(key)) {
console.error("");
console.error(` '${key}' looks like a credential env variable name.`);
console.error(" As of this release, 'credentials reset' takes an OpenShell");
console.error(` provider name. Run '${CLI_NAME} credentials list' to see the`);
console.error(" registered providers, then retry with one of those names.");
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) console.error(` ${stderr}`);
process.exit(1);
if (stderr) lines.push(` ${stderr}`);
this.failWithLines(lines);
}
}
5 changes: 4 additions & 1 deletion src/lib/commands/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ function buildDebugCommandDeps(rootDir: string): RunDebugCommandDeps {
runDebug,
log: console.log,
error: console.error,
exit: (code: number) => process.exit(code),
exit: (code: number): never => {
process.exitCode = code;
return undefined as never;
},
};
}

Expand Down
14 changes: 6 additions & 8 deletions src/lib/commands/gateway-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default class GatewayTokenCliCommand extends NemoClawCommand {
// (e.g. `... | head -c 0`). The token has already been written.
process.stdout.once("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EPIPE") {
process.exit(0);
this.setExitCode(0);
return;
}
throw err;
Expand All @@ -83,12 +83,10 @@ export default class GatewayTokenCliCommand extends NemoClawCommand {
getSandboxAgent: runtime.getSandboxAgent,
},
);
// NCQ #3180: avoid this.exit(code), which throws @oclif/core ExitError.
// The legacy `nemoclaw <name> gateway-token` dispatch did not catch the
// throw, leaking a raw JS stack trace to the user. Always assigning
// process.exitCode keeps the diagnostic output clean and prevents a
// stale non-zero code from a prior run() in the same process from
// bleeding through on a successful invocation.
process.exitCode = exitCode;
// NCQ #3180: avoid throwing ExitError. The legacy
// `nemoclaw <name> gateway-token` dispatch historically leaked raw JS
// stacks for thrown exits; the shared helper records the status without
// throwing and clears stale non-zero codes on success.
this.setExitCode(exitCode);
}
}
38 changes: 36 additions & 2 deletions src/lib/commands/global-oclif-command-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,30 @@ vi.mock("../actions/global", () => ({

vi.mock("../actions/inference-set", () => ({
InferenceSetError: class InferenceSetError extends Error {
exitCode = 1;
exitCode: number;

constructor(message: string, exitCode = 1) {
super(message);
this.exitCode = exitCode;
}
},
runInferenceSet: mocks.runInferenceSet,
}));

vi.mock("../actions/inference-get", () => ({
InferenceGetError: class InferenceGetError extends Error {
exitCode = 1;
exitCode: number;

constructor(message: string, exitCode = 1) {
super(message);
this.exitCode = exitCode;
}
},
runInferenceGet: mocks.runInferenceGet,
}));

import { InferenceGetError } from "../actions/inference-get";
import { InferenceSetError } from "../actions/inference-set";
import InferenceGetCommand from "./inference/get";
import InferenceSetCommand from "./inference/set";
import ListCommand from "./list";
Expand Down Expand Up @@ -167,4 +179,26 @@ describe("global oclif command adapters", () => {

expect(mocks.runInferenceGet).toHaveBeenCalledWith({ json: true });
});

it("records inference action failures without throwing oclif ExitError", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const previousExitCode = process.exitCode;
process.exitCode = undefined;
try {
mocks.runInferenceGet.mockRejectedValueOnce(new InferenceGetError("route missing", 3));
mocks.runInferenceSet.mockRejectedValueOnce(new InferenceSetError("route rejected", 4));

await expect(InferenceGetCommand.run([], rootDir)).resolves.toBeUndefined();
expect(process.exitCode).toBe(3);
expect(error).toHaveBeenCalledWith("route missing");

await expect(
InferenceSetCommand.run(["--provider", "nvidia-prod", "--model", "nvidia/model-a"], rootDir),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(4);
expect(error).toHaveBeenCalledWith("route rejected");
} finally {
process.exitCode = previousExitCode;
}
});
});
3 changes: 2 additions & 1 deletion src/lib/commands/inference/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export default class InferenceGetCommand extends NemoClawCommand {
await runInferenceGet({ json: flags.json === true });
} catch (error) {
if (error instanceof InferenceGetError) {
this.error(error.message, { exit: error.exitCode });
this.failWithLines([error.message], error.exitCode);
return;
}
throw error;
}
Expand Down
3 changes: 2 additions & 1 deletion src/lib/commands/inference/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export default class InferenceSetCommand extends NemoClawCommand {
});
} catch (error) {
if (error instanceof InferenceSetError) {
this.error(error.message, { exit: error.exitCode });
this.failWithLines([error.message], error.exitCode);
return;
}
throw error;
}
Expand Down
4 changes: 1 addition & 3 deletions src/lib/commands/maintenance/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ export default class UpdateCommand extends NemoClawCommand {
rootDir: this.config.root,
},
);
if (result.status !== 0) {
this.exit(result.status);
}
this.applyExitResult(result);
}
}
4 changes: 2 additions & 2 deletions src/lib/commands/sandbox/config/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default class SandboxConfigGetCommand extends NemoClawCommand {
}
}

export function printConfigUsageAndExit(): never {
export function printConfigUsageAndExit(): void {
console.error(` Usage: ${CLI_NAME} <name> config get [--key dotpath] [--format json|yaml]`);
process.exit(1);
process.exitCode = 1;
}
Loading
Loading