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
14 changes: 14 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ Print the installed NemoClaw CLI version.
$ nemoclaw --version
```

### `nemoclaw resources`

Display host hardware inventory and configured sandbox resource profiles.
Use `--json` for machine-readable CPU, memory, GPU, Kubernetes allocatable-capacity, and profile data.

```console
$ nemoclaw resources [--json]
```

If the gateway is not running, Kubernetes allocatable fields are omitted and host CPU/RAM totals are still shown.

### `nemoclaw onboard`

Run the interactive setup wizard (recommended for new installs).
Expand Down Expand Up @@ -1257,6 +1268,9 @@ These flags toggle optional behaviors during onboarding; set them before running
| `NEMOCLAW_OVERLAY_SNAPSHOTTER` | snapshotter name | Selects the containerd overlay snapshotter for sandbox builds. Empty (default) preserves containerd's choice. |
| `NEMOCLAW_SKIP_TELEGRAM_REACHABILITY` | `1` to enable | Skips the Telegram bot reachability probe during onboard (useful in restricted networks). |
| `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH` | `1` to enable | Accepts a new sandbox config path without an interactive prompt when the stored path differs from the discovered one. |
| `NEMOCLAW_RESOURCE_PROFILE` | profile name or `default` | Selects a sandbox CPU/RAM resource profile from the blueprint during onboarding. `default` means no resource preference, so NemoClaw passes no OpenShell CPU or memory flags. Unknown names fail fast. |
| `NEMOCLAW_CPU` | percentage or Kubernetes CPU quantity | Overrides the selected profile's CPU size passed to OpenShell `--cpu`. Percentages resolve against detected capacity. |
| `NEMOCLAW_RAM` | percentage or Kubernetes memory quantity | Overrides the selected profile's memory size passed to OpenShell `--memory`. Percentages resolve against detected capacity. |
| `NEMOCLAW_SANDBOX_GPU` | `auto`, `1`, or `0` | Controls sandbox GPU passthrough during onboarding. `auto` enables GPU passthrough when an NVIDIA GPU is detected, `1` requires GPU passthrough, and `0` forces CPU-only sandbox creation. |
| `NEMOCLAW_SANDBOX_GPU_DEVICE` | OpenShell GPU device selector | Selects the GPU device passed with `openshell sandbox create --gpu-device`. Requires explicit sandbox GPU enablement with `NEMOCLAW_SANDBOX_GPU=1` (or `--sandbox-gpu` for CLI-driven onboarding); otherwise onboarding rejects the selector instead of treating it as an implicit opt-in. |
| `NEMOCLAW_DOCKER_GPU_PATCH` | `0` to disable, anything else to keep the default | Controls the Linux Docker-driver GPU sandbox compatibility patch. Set to `0` only as an escape hatch when the patch fails and you need onboarding to continue without patching the GPU sandbox container. |
Expand Down
13 changes: 13 additions & 0 deletions nemoclaw-blueprint/blueprint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ components:
name: "openclaw"
forward_ports:
- 18789
resource_profiles:
creator:
cpu: "50%"
memory: "50%"
gamer:
cpu: "25%"
memory: "25%"
game-developer:
cpu: "60%"
memory: "60%"
developer:
cpu: "75%"
memory: "75%"

inference:
profiles:
Expand Down
13 changes: 13 additions & 0 deletions schemas/blueprint.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@
"forward_ports": {
"type": "array",
"items": { "type": "integer", "minimum": 1, "maximum": 65535 }
},
"resource_profiles": {
"type": "object",
"description": "Named resource profiles for sandbox CPU/memory sizing. Values can be absolute Kubernetes quantities (e.g. '4', '8Gi') or percentages of detected hardware (e.g. '25%').",
"additionalProperties": {
"type": "object",
"required": ["cpu", "memory"],
"properties": {
"cpu": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" },
"memory": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" }
},
"additionalProperties": false
}
}
}
},
Expand Down
32 changes: 32 additions & 0 deletions src/commands/resources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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

import ResourcesCommand from "../../dist/commands/resources.js";

const rootDir = process.cwd();

describe("ResourcesCommand", () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it("returns the hardware resource object in JSON mode", async () => {
const result = await ResourcesCommand.run(["--json"], rootDir);
expect(result).toEqual(expect.objectContaining({
cpu: expect.objectContaining({ cores: expect.any(Number), model: expect.any(String) }),
memory: expect.objectContaining({ totalMB: expect.any(Number), swapMB: expect.any(Number) }),
}));
});

it("prints human-readable output without returning data in text mode", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(ResourcesCommand.run([], rootDir)).resolves.toBeUndefined();
expect(logSpy).toHaveBeenCalledWith(" Hardware Resources");
} finally {
logSpy.mockRestore();
}
});
});
26 changes: 26 additions & 0 deletions src/commands/resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command";
import { getHardwareResources, printHardwareResources } from "../lib/resources-cmd";

export default class ResourcesCommand extends NemoClawCommand {
static id = "resources";
static strict = true;
static enableJsonFlag = true;
static summary = "Show hardware inventory (CPU cores, RAM, GPU VRAM)";
static description =
"Display available hardware resources including CPU core count and model, " +
"total system RAM and swap, Kubernetes node allocatable capacity (when a " +
"gateway is running), and NVIDIA GPU name and VRAM. Supports --json for " +
"machine-readable output.";
static usage = ["resources [--json]"];
static examples = ["<%= config.bin %> resources", "<%= config.bin %> resources --json"];
static flags = {};

public async run(): Promise<unknown> {
await this.parse(ResourcesCommand);
if (this.jsonEnabled()) return getHardwareResources();
printHardwareResources(false);
}
}
1 change: 1 addition & 0 deletions src/lib/cli/command-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type CommandGroup =
| "Credentials"
| "Backup"
| "Upgrade"
| "Resources"
| "Cleanup";

/**
Expand Down
20 changes: 11 additions & 9 deletions src/lib/cli/command-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import { getRegisteredOclifCommandsMetadata } from "./oclif-metadata";

describe("command-registry", () => {
describe("COMMANDS array", () => {
it("should contain exactly 61 commands", () => {
// 27 global (21 visible + 6 hidden help/version aliases)
it("should contain exactly 62 commands", () => {
// 28 global (22 visible + 6 hidden help/version aliases)
// 34 sandbox (28 visible + 6 hidden shields/config)
expect(COMMANDS).toHaveLength(61);
expect(COMMANDS).toHaveLength(62);
});

it("should have no duplicate usage strings", () => {
Expand All @@ -39,9 +39,9 @@ describe("command-registry", () => {
});

describe("globalCommands()", () => {
it("should return exactly 27 entries", () => {
// 21 visible + 6 hidden (help, --help, -h, version, --version, -v)
expect(globalCommands()).toHaveLength(27);
it("should return exactly 28 entries", () => {
// 22 visible + 6 hidden (help, --help, -h, version, --version, -v)
expect(globalCommands()).toHaveLength(28);
});

it("every entry has scope global", () => {
Expand All @@ -65,10 +65,10 @@ describe("command-registry", () => {
});

describe("visibleCommands()", () => {
it("should exclude 12 hidden commands (49 visible)", () => {
it("should exclude 12 hidden commands (50 visible)", () => {
// 6 hidden global (help, --help, -h, version, --version, -v) +
// 6 hidden sandbox (shields×3, config get/set/rotate-token)
expect(visibleCommands()).toHaveLength(49);
expect(visibleCommands()).toHaveLength(50);
});

it("no visible command has hidden=true", () => {
Expand Down Expand Up @@ -171,7 +171,7 @@ describe("command-registry", () => {
});

describe("globalCommandTokens()", () => {
it("returns the exact set of 23 tokens matching the global dispatch commands", () => {
it("returns the exact set of 24 tokens matching the global dispatch commands", () => {
const tokens = globalCommandTokens();
const expected = new Set([
"onboard",
Expand All @@ -191,6 +191,7 @@ describe("command-registry", () => {
"upgrade-sandboxes",
"gc",
"inference",
"resources",
"help",
"version",
"--help",
Expand Down Expand Up @@ -280,6 +281,7 @@ describe("command-registry", () => {
"Credentials",
"Backup",
"Upgrade",
"Resources",
"Cleanup",
]);
});
Expand Down
1 change: 1 addition & 0 deletions src/lib/cli/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const GROUP_ORDER: readonly CommandGroup[] = [
"Credentials",
"Backup",
"Upgrade",
"Resources",
"Cleanup",
] as const;

Expand Down
8 changes: 8 additions & 0 deletions src/lib/cli/public-display-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ const PUBLIC_DISPLAY_LAYOUT: Record<string, readonly PublicDisplayLayout[]> = {
"hidden": true
}
],
"resources": [
{
"group": "Resources",
"order": 900,
"description": "Show hardware inventory (CPU cores, RAM, GPU VRAM)",
"flags": "[--json]"
}
],
"root:version": [
{
"group": "Getting Started",
Expand Down
8 changes: 4 additions & 4 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const dockerGpuSandboxCreate: typeof import("./onboard/docker-gpu-sandbox-create
const dockerDriverGatewayLaunch: typeof import("./onboard/docker-driver-gateway-launch") = require("./onboard/docker-driver-gateway-launch");
const { findReadableNvidiaCdiSpecFiles, parseDockerCdiSpecDirs }: typeof import("./onboard/docker-cdi") = require("./onboard/docker-cdi");
const { buildSandboxGpuCreateArgs, getSandboxReadyTimeoutSecs }: typeof import("./onboard/sandbox-gpu-create") = require("./onboard/sandbox-gpu-create");
const { appendResourceFlagsForProfile, selectResourceProfileForSandbox }: typeof import("./onboard/resource-profile-selection") = require("./onboard/resource-profile-selection");
const {
isValidProxyHost,
isValidProxyPort,
Expand Down Expand Up @@ -535,8 +536,6 @@ const OPENCLAW_LAUNCH_AGENT_PLIST = "~/Library/LaunchAgents/ai.openclaw.gateway.

const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/";

// Re-export shared JSON types under the names used throughout this module.
// See src/lib/core/json-types.ts for the canonical definitions.
import type {
JsonObject as LooseObject,
} from "./core/json-types";
Expand Down Expand Up @@ -2942,6 +2941,7 @@ async function createSandbox(
agent: AgentDefinition | null = null,
controlUiPort: number | null = null,
sandboxGpuConfig: SandboxGpuConfig | null = null,
resourceProfile: import("./resources-cmd").ResourceProfile | null = null,
hermesToolGateways: string[] = [],
) {
step(6, 8, "Creating sandbox");
Expand Down Expand Up @@ -3531,8 +3531,6 @@ async function createSandbox(
};
process.on("exit", cleanupBuildCtx);

// Create sandbox (use -- echo to avoid dropping into interactive shell)
// Pass the base policy so sandbox starts in proxy mode (required for policy updates later)
const defaultPolicyPath = path.join(
ROOT,
"nemoclaw-blueprint",
Expand Down Expand Up @@ -3606,6 +3604,7 @@ async function createSandbox(
}),
];

appendResourceFlagsForProfile(createArgs, resourceProfile, getOpenshellBinary(), { isNonInteractive, note, prompt, promptOrDefault });
// Create OpenShell providers for messaging credentials so they flow through
// the provider/placeholder system instead of raw env vars. The L7 proxy
// rewrites Authorization headers (Bearer/Bot) and URL-path segments
Expand Down Expand Up @@ -7340,6 +7339,7 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
setupMessagingChannels,
readMessagingChannelConfigFromEnv,
promptValidatedSandboxName,
selectResourceProfileForSandbox: () => selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }),
stopStaleDashboardListenersForSandbox,
listRegistrySandboxes: registry.listSandboxes,
createSandbox,
Expand Down
11 changes: 8 additions & 3 deletions src/lib/onboard/machine/handlers/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ type Agent = { displayName?: string } | null;
type WebSearchConfig = { fetchEnabled: true };
type MessagingChannelConfig = Record<string, string>;
type SandboxGpuConfig = { sandboxGpuEnabled: boolean; mode: string };
type ResourceProfile = { cpu: string; memory: string };

function createDeps(overrides: Partial<SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChannelConfig, SandboxGpuConfig>["deps"]> = {}) {
function createDeps(overrides: Partial<SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChannelConfig, SandboxGpuConfig, ResourceProfile>["deps"]> = {}) {
let session = createSession();
const calls = {
note: vi.fn(),
Expand All @@ -30,6 +31,7 @@ function createDeps(overrides: Partial<SandboxStateOptions<Gpu, Agent, WebSearch
getRecordedChannels: vi.fn(() => null),
setupMessaging: vi.fn(async () => [] as string[]),
promptName: vi.fn(async () => "my-assistant"),
selectResourceProfile: vi.fn(async () => null as ResourceProfile | null),
stopStale: vi.fn(),
createSandbox: vi.fn(async () => "my-assistant"),
updateSandbox: vi.fn(),
Expand Down Expand Up @@ -72,6 +74,7 @@ function createDeps(overrides: Partial<SandboxStateOptions<Gpu, Agent, WebSearch
setupMessagingChannels: calls.setupMessaging,
readMessagingChannelConfigFromEnv: () => null,
promptValidatedSandboxName: calls.promptName,
selectResourceProfileForSandbox: calls.selectResourceProfile,
stopStaleDashboardListenersForSandbox: calls.stopStale,
listRegistrySandboxes: () => ({ sandboxes: [{ name: "old" }] }),
createSandbox: calls.createSandbox,
Expand All @@ -92,9 +95,9 @@ function createDeps(overrides: Partial<SandboxStateOptions<Gpu, Agent, WebSearch
}

function baseOptions(
deps: SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChannelConfig, SandboxGpuConfig>["deps"],
deps: SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChannelConfig, SandboxGpuConfig, ResourceProfile>["deps"],
session: Session | null = createSession(),
): SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChannelConfig, SandboxGpuConfig> {
): SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChannelConfig, SandboxGpuConfig, ResourceProfile> {
return {
resume: false,
fresh: false,
Expand Down Expand Up @@ -143,6 +146,7 @@ describe("handleSandboxState", () => {
null,
null,
{ sandboxGpuEnabled: false, mode: "0" },
null,
[],
);
expect(calls.updateSandbox).toHaveBeenCalledWith("my-assistant", expect.objectContaining({ model: "model", provider: "provider" }));
Expand Down Expand Up @@ -286,6 +290,7 @@ describe("handleSandboxState", () => {
null,
null,
{ sandboxGpuEnabled: false, mode: "0" },
null,
[],
);
expect(result.webSearchConfig).toBeNull();
Expand Down
11 changes: 8 additions & 3 deletions src/lib/onboard/machine/handlers/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import type { Session, SessionUpdates } from "../../../state/onboard-session";

export interface SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChannelConfig, SandboxGpuConfig> {
export interface SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChannelConfig, SandboxGpuConfig, ResourceProfile> {
resume: boolean;
fresh: boolean;
resumeAgentChanged: boolean;
Expand Down Expand Up @@ -57,6 +57,7 @@ export interface SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChann
setupMessagingChannels(agent: Agent, existingChannels: string[] | null): Promise<string[]>;
readMessagingChannelConfigFromEnv(): MessagingChannelConfig | null;
promptValidatedSandboxName(agent: Agent): Promise<string>;
selectResourceProfileForSandbox(): Promise<ResourceProfile | null>;
stopStaleDashboardListenersForSandbox(sandboxes: unknown[], sandboxName: string): void;
listRegistrySandboxes(): { sandboxes: unknown[] };
createSandbox(
Expand All @@ -71,6 +72,7 @@ export interface SandboxStateOptions<Gpu, Agent, WebSearchConfig, MessagingChann
agent: Agent,
controlUiPort: number | null,
sandboxGpuConfig: SandboxGpuConfig,
resourceProfile: ResourceProfile | null,
hermesToolGateways: string[],
): Promise<string>;
updateSandboxRegistry(sandboxName: string, updates: Record<string, unknown>): void;
Expand Down Expand Up @@ -101,7 +103,7 @@ function sameEffectiveTelegramRequireMention(left: boolean | null, right: boolea
return (left ?? false) === (right ?? false);
}

export async function handleSandboxState<Gpu, Agent, WebSearchConfig, MessagingChannelConfig, SandboxGpuConfig>({
export async function handleSandboxState<Gpu, Agent, WebSearchConfig, MessagingChannelConfig, SandboxGpuConfig, ResourceProfile>({
resume,
fresh,
resumeAgentChanged,
Expand All @@ -126,7 +128,8 @@ export async function handleSandboxState<Gpu, Agent, WebSearchConfig, MessagingC
Agent,
WebSearchConfig,
MessagingChannelConfig,
SandboxGpuConfig
SandboxGpuConfig,
ResourceProfile
>): Promise<SandboxStateResult<WebSearchConfig>> {
const webSearchSupportProbePath = fromDockerfile ? deps.resolvePath(fromDockerfile) : null;
const webSearchSupported = deps.agentSupportsWebSearch(agent, webSearchSupportProbePath, rootDir);
Expand Down Expand Up @@ -272,6 +275,7 @@ export async function handleSandboxState<Gpu, Agent, WebSearchConfig, MessagingC
});

if (!sandboxName) sandboxName = await deps.promptValidatedSandboxName(agent);
const resourceProfile = await deps.selectResourceProfileForSandbox();
if (fresh) deps.stopStaleDashboardListenersForSandbox(deps.listRegistrySandboxes().sandboxes, sandboxName);
sandboxName = await deps.createSandbox(
gpu,
Expand All @@ -285,6 +289,7 @@ export async function handleSandboxState<Gpu, Agent, WebSearchConfig, MessagingC
agent,
controlUiPort,
sandboxGpuConfig,
resourceProfile,
hermesToolGateways,
);
webSearchConfig = nextWebSearchConfig;
Expand Down
Loading
Loading