diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index f1bfd0187de..3cc31692b3a 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -35,6 +35,17 @@ The image installs a hash-locked, pinned Deep Agents Code release with NVIDIA pr NemoClaw writes `/sandbox/.deepagents/config.toml` with an OpenAI-compatible provider pointed at `https://inference.local/v1`, uses a scoped placeholder API key for that managed route, and sets `use_responses_api = false` for Chat Completions compatibility. NemoClaw/OpenShell keeps real provider credentials in credential handling and does not write them into the Deep Agents config file. +## Choose the Default Sandbox + +When you manage multiple sandboxes, use the Deep Agents alias to promote a registered Deep Agents Code sandbox to the default. + +```bash +nemo-deepagents use +``` + +The command updates NemoClaw's host-side registry. +It does not modify the sandbox or the `dcode` configuration. + ## Use the Harness Connect to the sandbox, then launch the terminal UI. diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index ffe27349a7b..914737590f6 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -391,6 +391,21 @@ nemohermes list [--json] nemohermes list --json ``` +### `nemohermes use ` + +Promote a registered sandbox to the default. +This is the first-class replacement for hand-editing `~/.nemoclaw/sandboxes.json`; it updates the registry through the same atomic, lock-guarded path that `nemohermes onboard` uses for the initial default. +Subsequent commands and the `NEMOCLAW_SANDBOX_NAME` resolution order then pick up the new default automatically. +Pass `--json` to receive a machine-readable result indicating whether the registry was updated, the sandbox was already the default, or the name is unknown. + +`nemohermes use` is a thin selector and never mutates the sandbox itself. +It fails with a non-zero exit and a known-sandbox list when the requested name is not registered, so scripts can branch safely on the outcome. + +```bash +nemohermes use +nemohermes use --json +``` + ### `nemohermes deploy` diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d1ab647a0af..ebc9a914932 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -485,6 +485,21 @@ $$nemoclaw list [--json] $$nemoclaw list --json ``` +### `$$nemoclaw use ` + +Promote a registered sandbox to the default. +This is the first-class replacement for hand-editing `~/.nemoclaw/sandboxes.json`; it updates the registry through the same atomic, lock-guarded path that `$$nemoclaw onboard` uses for the initial default. +Subsequent commands and the `NEMOCLAW_SANDBOX_NAME` resolution order then pick up the new default automatically. +Pass `--json` to receive a machine-readable result indicating whether the registry was updated, the sandbox was already the default, or the name is unknown. + +`$$nemoclaw use` is a thin selector and never mutates the sandbox itself. +It fails with a non-zero exit and a known-sandbox list when the requested name is not registered, so scripts can branch safely on the outcome. + +```bash +$$nemoclaw use +$$nemoclaw use --json +``` + ### `$$nemoclaw deploy` diff --git a/src/commands/use.ts b/src/commands/use.ts new file mode 100644 index 00000000000..ac34e5a3bb8 --- /dev/null +++ b/src/commands/use.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args } from "@oclif/core"; +import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; +import { buildUseCommandDeps, runUseCommand } from "../lib/use-command-deps"; + +export default class UseCommand extends NemoClawCommand { + static id = "use"; + static strict = true; + static enableJsonFlag = true; + static summary = "Set the default sandbox"; + static description = + "Promote a registered sandbox to the default. Updates the sandbox registry atomically so subsequent commands and scripts use the chosen sandbox without hand-editing on-disk state."; + static usage = ["use [--json]"]; + static examples = ["<%= config.bin %> use alpha", "<%= config.bin %> use alpha --json"]; + static args = { + sandboxName: Args.string({ + name: "name", + description: "Sandbox name to promote to the default", + required: true, + }), + }; + static flags = {}; + + public async run(): Promise { + const { args } = await this.parse(UseCommand); + const deps = buildUseCommandDeps(); + const result = runUseCommand(args.sandboxName, deps); + const json = this.jsonEnabled(); + if (result.outcome === "not-found") { + if (json) { + process.exitCode = 1; + return result; + } + const known = result.knownSandboxes.length > 0 ? result.knownSandboxes.join(", ") : "(none)"; + this.error(`Sandbox not found: ${result.sandboxName}. Known sandboxes: ${known}.`, { + exit: 1, + }); + } + if (json) return result; + if (result.outcome === "already-default") { + this.log(`Sandbox '${result.sandboxName}' is already the default.`); + return; + } + const previous = result.previousDefault ? ` (was '${result.previousDefault}')` : ""; + this.log(`Default sandbox set to '${result.sandboxName}'${previous}.`); + } +} diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 30ed37430e0..131945ccddb 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -498,6 +498,14 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { description: "Run uninstall.sh (local only; no remote fallback)", }, ], + use: [ + { + group: "Sandbox Management", + order: 2.5, + usage: "nemoclaw use ", + flags: "[--json]", + }, + ], update: [ { group: "Upgrade", diff --git a/src/lib/use-command-deps.test.ts b/src/lib/use-command-deps.test.ts new file mode 100644 index 00000000000..c2a845f2044 --- /dev/null +++ b/src/lib/use-command-deps.test.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; +import { runUseCommand, type UseCommandDeps } from "./use-command-deps"; + +function makeDeps( + overrides: Partial<{ + sandboxes: ReadonlyArray; + defaultSandbox: string | null; + setDefault: (name: string) => boolean; + }> = {}, +): UseCommandDeps & { + setDefault: ReturnType; + listSandboxes: ReturnType; +} { + const sandboxes = (overrides.sandboxes ?? []).map((name) => ({ name })); + const defaultSandbox = overrides.defaultSandbox ?? null; + const setDefault = vi.fn(overrides.setDefault ?? ((_name: string) => true)); + const listSandboxes = vi.fn(() => ({ sandboxes, defaultSandbox })); + return { listSandboxes, setDefault }; +} + +describe("runUseCommand", () => { + it("reports unknown sandbox with the known list and skips the registry write", () => { + const deps = makeDeps({ sandboxes: ["alpha", "beta"], defaultSandbox: "alpha" }); + + const result = runUseCommand("gamma", deps); + + expect(result).toEqual({ + outcome: "not-found", + sandboxName: "gamma", + knownSandboxes: ["alpha", "beta"], + }); + expect(deps.setDefault).not.toHaveBeenCalled(); + }); + + it("returns already-default and skips the registry write when the chosen sandbox is the default", () => { + const deps = makeDeps({ sandboxes: ["alpha", "beta"], defaultSandbox: "alpha" }); + + const result = runUseCommand("alpha", deps); + + expect(result).toEqual({ outcome: "already-default", sandboxName: "alpha" }); + expect(deps.setDefault).not.toHaveBeenCalled(); + }); + + it("promotes the chosen sandbox and reports the previous default", () => { + const deps = makeDeps({ sandboxes: ["alpha", "beta"], defaultSandbox: "alpha" }); + + const result = runUseCommand("beta", deps); + + expect(result).toEqual({ + outcome: "set", + sandboxName: "beta", + previousDefault: "alpha", + }); + expect(deps.setDefault).toHaveBeenCalledTimes(1); + expect(deps.setDefault).toHaveBeenCalledWith("beta"); + }); + + it("reports the first default when the registry currently has none", () => { + const deps = makeDeps({ sandboxes: ["alpha"], defaultSandbox: null }); + + const result = runUseCommand("alpha", deps); + + expect(result).toEqual({ + outcome: "set", + sandboxName: "alpha", + previousDefault: null, + }); + expect(deps.setDefault).toHaveBeenCalledWith("alpha"); + }); + + it("downgrades to not-found when the registry refuses the write due to a concurrent removal", () => { + const deps = makeDeps({ + sandboxes: ["alpha", "beta"], + defaultSandbox: "alpha", + setDefault: () => false, + }); + + const result = runUseCommand("beta", deps); + + expect(result).toEqual({ + outcome: "not-found", + sandboxName: "beta", + knownSandboxes: ["alpha", "beta"], + }); + expect(deps.setDefault).toHaveBeenCalledWith("beta"); + }); + + it("refreshes the known sandbox list after a failed setDefault so the diagnostic excludes the concurrently removed sandbox", () => { + const listSandboxes = vi + .fn() + .mockReturnValueOnce({ + sandboxes: [{ name: "alpha" }, { name: "beta" }], + defaultSandbox: "alpha", + }) + .mockReturnValueOnce({ sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha" }); + const setDefault = vi.fn(() => false); + const deps: UseCommandDeps = { listSandboxes, setDefault }; + + const result = runUseCommand("beta", deps); + + expect(result).toEqual({ + outcome: "not-found", + sandboxName: "beta", + knownSandboxes: ["alpha"], + }); + expect(listSandboxes).toHaveBeenCalledTimes(2); + expect(setDefault).toHaveBeenCalledWith("beta"); + }); +}); diff --git a/src/lib/use-command-deps.ts b/src/lib/use-command-deps.ts new file mode 100644 index 00000000000..93d61e95c20 --- /dev/null +++ b/src/lib/use-command-deps.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as registry from "./state/registry"; + +export interface UseCommandDeps { + readonly listSandboxes: () => { + readonly sandboxes: ReadonlyArray<{ readonly name: string }>; + readonly defaultSandbox: string | null; + }; + readonly setDefault: (name: string) => boolean; +} + +export type UseCommandResult = + | { + readonly outcome: "set"; + readonly sandboxName: string; + readonly previousDefault: string | null; + } + | { + readonly outcome: "already-default"; + readonly sandboxName: string; + } + | { + readonly outcome: "not-found"; + readonly sandboxName: string; + readonly knownSandboxes: ReadonlyArray; + }; + +export function buildUseCommandDeps(): UseCommandDeps { + return { + listSandboxes: () => registry.listSandboxes(), + setDefault: (name) => registry.setDefault(name), + }; +} + +export function runUseCommand(sandboxName: string, deps: UseCommandDeps): UseCommandResult { + const current = deps.listSandboxes(); + const known = current.sandboxes.map((sb) => sb.name); + if (!known.includes(sandboxName)) { + return { outcome: "not-found", sandboxName, knownSandboxes: known }; + } + if (current.defaultSandbox === sandboxName) { + return { outcome: "already-default", sandboxName }; + } + const updated = deps.setDefault(sandboxName); + if (!updated) { + // setDefault rechecks existence under the registry lock. Refresh after a + // concurrent removal so the not-found diagnostic reflects post-lock state. + const refreshed = deps.listSandboxes(); + return { + outcome: "not-found", + sandboxName, + knownSandboxes: refreshed.sandboxes.map((sb) => sb.name), + }; + } + return { outcome: "set", sandboxName, previousDefault: current.defaultSandbox }; +} diff --git a/test/nemo-deepagents-alias.test.ts b/test/nemo-deepagents-alias.test.ts index 69677ad67a7..df9607e6916 100644 --- a/test/nemo-deepagents-alias.test.ts +++ b/test/nemo-deepagents-alias.test.ts @@ -75,6 +75,25 @@ function runNemoClaw( } } +function createDeepAgentsRegistry(): { home: string; registryPath: string } { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemo-deepagents-use-")); + const registryDir = path.join(home, ".nemoclaw"); + const registryPath = path.join(registryDir, "sandboxes.json"); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + registryPath, + JSON.stringify({ + sandboxes: { + "dcode-alpha": { name: "dcode-alpha", agent: "langchain-deepagents-code" }, + "dcode-beta": { name: "dcode-beta", agent: "langchain-deepagents-code" }, + }, + defaultSandbox: "dcode-alpha", + }), + { mode: 0o600 }, + ); + return { home, registryPath }; +} + describe("nemo-deepagents alias", () => { it("package-style nemo-deepagents symlink exists and is executable", () => { expect(fs.existsSync(DEEPAGENTS_CLI)).toBe(true); @@ -101,9 +120,59 @@ describe("nemo-deepagents alias", () => { expect(code).toBe(0); expect(out).toContain("NemoDeepAgents"); expect(out).toContain("nemo-deepagents onboard"); + expect(out).toContain("nemo-deepagents use "); expect(out).not.toContain("nemoclaw onboard"); }); + it("promotes a registered Deep Agents sandbox through the alias command", () => { + const { home, registryPath } = createDeepAgentsRegistry(); + + try { + const { code, out } = runDeepAgents("use dcode-beta", { HOME: home }); + + expect(code).toBe(0); + expect(out).toContain("Default sandbox set to 'dcode-beta' (was 'dcode-alpha')."); + expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual( + expect.objectContaining({ defaultSandbox: "dcode-beta" }), + ); + } finally { + fs.rmSync(home, { force: true, recursive: true }); + } + }); + + it("reports an already-default Deep Agents sandbox through the alias command", () => { + const { home } = createDeepAgentsRegistry(); + + try { + const { code, out } = runDeepAgents("use dcode-alpha", { HOME: home }); + + expect(code).toBe(0); + expect(out).toContain("Sandbox 'dcode-alpha' is already the default."); + } finally { + fs.rmSync(home, { force: true, recursive: true }); + } + }); + + it("returns structured not-found output through the alias command", () => { + const { home, registryPath } = createDeepAgentsRegistry(); + + try { + const { code, out } = runDeepAgents("use dcode-missing --json", { HOME: home }); + + expect(code).toBe(1); + expect(JSON.parse(out)).toEqual({ + outcome: "not-found", + sandboxName: "dcode-missing", + knownSandboxes: ["dcode-alpha", "dcode-beta"], + }); + expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual( + expect.objectContaining({ defaultSandbox: "dcode-alpha" }), + ); + } finally { + fs.rmSync(home, { force: true, recursive: true }); + } + }); + it("routes nemo-deepagents uninstall as a global command, not a sandbox connect command", () => { const { code, out } = runDeepAgents("uninstall --help"); expect(code).toBe(0); diff --git a/test/package-contract/cli/command-registry.test.ts b/test/package-contract/cli/command-registry.test.ts index da635d34e9e..904303583f5 100644 --- a/test/package-contract/cli/command-registry.test.ts +++ b/test/package-contract/cli/command-registry.test.ts @@ -183,13 +183,14 @@ describe("command-registry", () => { }); describe("globalCommandTokens()", () => { - it("returns the exact set of 25 tokens matching the global dispatch commands", () => { + it("returns the exact set of 26 tokens matching the global dispatch commands", () => { const tokens = globalCommandTokens(); const expected = new Set([ "agents", "onboard", "update", "list", + "use", "deploy", "setup", "setup-spark", @@ -284,6 +285,17 @@ describe("command-registry", () => { } } }); + + it("exposes the default-sandbox command in root help", () => { + expect(canonicalUsageList()).toContain("nemoclaw use "); + expect(commandsByGroup().get("Sandbox Management")).toContainEqual( + expect.objectContaining({ + commandId: "use", + flags: "[--json]", + usage: "nemoclaw use ", + }), + ); + }); }); describe("GROUP_ORDER", () => {