From 7d8f11676d04d1e0b9d170527dd6fdfedbf81329 Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan Date: Sat, 4 Apr 2026 22:09:15 +0530 Subject: [PATCH] feat(cli): add --json output for list, status, and policy-list commands Add a --json flag to three CLI commands for machine-readable output: - nemoclaw list --json - nemoclaw status --json - nemoclaw policy-list --json JSON mode reads directly from the local registry without requiring a live OpenShell gateway, making it safe for scripting and CI pipelines. Fixes #753 Signed-off-by: Abhishek Chauhan --- bin/nemoclaw.js | 75 +++++++++++++++++++--- test/json-output.test.js | 132 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 test/json-output.test.js diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 8dd37d6bf..65248a99e 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -1067,7 +1067,27 @@ function showStatus() { showServiceStatus({ sandboxName: defaultSandbox || undefined }); } -async function listSandboxes() { +async function listSandboxes(args = []) { + const jsonMode = args.includes("--json"); + + if (jsonMode) { + const { sandboxes, defaultSandbox } = registry.listSandboxes(); + const output = { + sandboxes: sandboxes.map((sb) => ({ + name: sb.name, + default: sb.name === defaultSandbox, + model: sb.model || null, + provider: sb.provider || null, + gpuEnabled: sb.gpuEnabled || false, + policies: sb.policies || [], + createdAt: sb.createdAt || null, + })), + defaultSandbox, + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + const recovery = await recoverRegistryEntries(); const { sandboxes, defaultSandbox } = recovery; if (sandboxes.length === 0) { @@ -1132,8 +1152,30 @@ async function sandboxConnect(sandboxName) { } // eslint-disable-next-line complexity -async function sandboxStatus(sandboxName) { +async function sandboxStatus(sandboxName, args = []) { + const jsonMode = args.includes("--json"); const sb = registry.getSandbox(sandboxName); + + if (jsonMode) { + const nimStat = + sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName); + const output = { + name: sandboxName, + model: (sb && sb.model) || null, + provider: (sb && sb.provider) || null, + gpuEnabled: (sb && sb.gpuEnabled) || false, + policies: (sb && sb.policies) || [], + createdAt: (sb && sb.createdAt) || null, + nim: { + running: nimStat.running, + healthy: nimStat.healthy || false, + container: nimStat.container || null, + }, + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + const live = parseGatewayInference( captureOpenshell(["inference", "get"], { ignoreError: true }).output, ); @@ -1303,10 +1345,25 @@ async function sandboxPolicyAdd(sandboxName) { policies.applyPreset(sandboxName, answer); } -function sandboxPolicyList(sandboxName) { +function sandboxPolicyList(sandboxName, args = []) { + const jsonMode = args.includes("--json"); const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); + if (jsonMode) { + const output = { + sandbox: sandboxName, + presets: allPresets.map((p) => ({ + name: p.name, + description: p.description, + applied: applied.includes(p.name), + })), + applied, + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + console.log(""); console.log(` Policy presets for sandbox '${sandboxName}':`); allPresets.forEach((p) => { @@ -1377,15 +1434,15 @@ function help() { nemoclaw setup-spark Set up on DGX Spark ${D}(fixes cgroup v2 + Docker)${R} ${G}Sandbox Management:${R} - ${B}nemoclaw list${R} List all sandboxes + ${B}nemoclaw list${R} ${D}[--json]${R} List all sandboxes nemoclaw connect Shell into a running sandbox - nemoclaw status Sandbox health + NIM status + nemoclaw status ${D}[--json]${R} Sandbox health + NIM status nemoclaw logs ${D}[--follow]${R} Stream sandbox logs nemoclaw destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R} ${G}Policy Presets:${R} nemoclaw policy-add Add a network or filesystem policy preset - nemoclaw policy-list List presets ${D}(● = applied)${R} + nemoclaw policy-list ${D}[--json]${R} List presets ${D}(● = applied)${R} ${G}Deploy:${R} nemoclaw deploy Deploy to a Brev VM and start services @@ -1456,7 +1513,7 @@ const [cmd, ...args] = process.argv.slice(2); uninstall(args); break; case "list": - await listSandboxes(); + await listSandboxes(args); break; case "--version": case "-v": { @@ -1482,7 +1539,7 @@ const [cmd, ...args] = process.argv.slice(2); await sandboxConnect(cmd); break; case "status": - await sandboxStatus(cmd); + await sandboxStatus(cmd, actionArgs); break; case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); @@ -1491,7 +1548,7 @@ const [cmd, ...args] = process.argv.slice(2); await sandboxPolicyAdd(cmd); break; case "policy-list": - sandboxPolicyList(cmd); + sandboxPolicyList(cmd, actionArgs); break; case "destroy": await sandboxDestroy(cmd, actionArgs); diff --git a/test/json-output.test.js b/test/json-output.test.js new file mode 100644 index 000000000..cec991059 --- /dev/null +++ b/test/json-output.test.js @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); + +function run(args, home) { + try { + const out = execSync(`${JSON.stringify(process.execPath)} "${CLI}" ${args}`, { + encoding: "utf-8", + timeout: 10000, + env: { ...process.env, HOME: home }, + }); + return { code: 0, out }; + } catch (err) { + return { code: err.status, out: (err.stdout || "") + (err.stderr || "") }; + } +} + +describe("--json output", () => { + let tmpHome; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-json-test-")); + const nemoclawDir = path.join(tmpHome, ".nemoclaw"); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "my-assistant", + sandboxes: { + "my-assistant": { + name: "my-assistant", + createdAt: "2026-04-04T00:00:00.000Z", + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-nim", + gpuEnabled: false, + policies: ["pypi", "npm"], + }, + }, + }), + ); + }); + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + describe("list --json", () => { + it("outputs valid JSON with sandbox array", () => { + const r = run("list --json", tmpHome); + expect(r.code).toBe(0); + const data = JSON.parse(r.out); + expect(data.sandboxes).toBeInstanceOf(Array); + expect(data.sandboxes).toHaveLength(1); + expect(data.sandboxes[0]).toMatchObject({ + name: "my-assistant", + default: true, + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-nim", + gpuEnabled: false, + policies: ["pypi", "npm"], + }); + expect(data.defaultSandbox).toBe("my-assistant"); + }); + + it("outputs empty array when no sandboxes", () => { + const emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-json-empty-")); + try { + const r = run("list --json", emptyHome); + expect(r.code).toBe(0); + const data = JSON.parse(r.out); + expect(data.sandboxes).toEqual([]); + } finally { + fs.rmSync(emptyHome, { recursive: true, force: true }); + } + }); + + // Note: `list` without --json requires a live OpenShell gateway for + // registry recovery, so it cannot be tested without the full runtime. + }); + + describe(" status --json", () => { + it("outputs valid JSON with sandbox details", () => { + const r = run("my-assistant status --json", tmpHome); + expect(r.code).toBe(0); + const data = JSON.parse(r.out); + expect(data.name).toBe("my-assistant"); + expect(data.model).toBe("nvidia/nemotron-3-super-120b-a12b"); + expect(data.provider).toBe("nvidia-nim"); + expect(data.gpuEnabled).toBe(false); + expect(data.policies).toEqual(["pypi", "npm"]); + expect(data.nim).toBeDefined(); + expect(typeof data.nim.running).toBe("boolean"); + }); + }); + + describe(" policy-list --json", () => { + it("outputs valid JSON with presets and applied status", () => { + const r = run("my-assistant policy-list --json", tmpHome); + expect(r.code).toBe(0); + const data = JSON.parse(r.out); + expect(data.sandbox).toBe("my-assistant"); + expect(data.presets).toBeInstanceOf(Array); + expect(data.presets.length).toBeGreaterThan(0); + expect(data.applied).toEqual(["pypi", "npm"]); + + // Check that presets have the right shape + const pypi = data.presets.find((p) => p.name === "pypi"); + expect(pypi).toBeDefined(); + expect(pypi.applied).toBe(true); + expect(typeof pypi.description).toBe("string"); + + // Check an unapplied preset + const docker = data.presets.find((p) => p.name === "docker"); + expect(docker).toBeDefined(); + expect(docker.applied).toBe(false); + }); + }); + + describe("help text", () => { + it("shows --json in help output", () => { + const r = run("help", tmpHome); + expect(r.out).toContain("--json"); + }); + }); +});