From c5edc91f287640cd657c13cf95ffa9a345aa7119 Mon Sep 17 00:00:00 2001 From: vasanth53 Date: Tue, 17 Mar 2026 14:56:06 +0530 Subject: [PATCH 1/4] feat: add shell completion for nemoclaw CLI Add completion command supporting bash, zsh, and fish shells. Completes issue #155. --- bin/nemoclaw.js | 107 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 69381877a96..7ca163b005b 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -22,7 +22,9 @@ const policies = require("./lib/policies"); const GLOBAL_COMMANDS = new Set([ "onboard", "list", "deploy", "setup", "setup-spark", - "start", "stop", "status", "uninstall", + "start", "stop", "status", + "uninstall", + "completion", "help", "--help", "-h", ]); @@ -60,6 +62,12 @@ function exitWithSpawnResult(result) { process.exit(1); } +const SANDBOX_ACTIONS = [ + "connect", "status", "logs", "policy-add", "policy-list", "destroy" +]; + +const SHELL_TYPES = ["bash", "zsh", "fish"]; + // ── Commands ───────────────────────────────────────────────────── async function onboard(args) { @@ -338,6 +346,99 @@ function sandboxDestroy(sandboxName) { console.log(` ✓ Sandbox '${sandboxName}' destroyed`); } +// ── Shell Completion ───────────────────────────────────────────── + +function printCompletion(shell) { + if (!shell || !SHELL_TYPES.includes(shell)) { + console.log(" Usage: nemoclaw completion "); + console.log(""); + console.log(" Generate shell completion scripts."); + console.log(""); + console.log(" Shells supported: bash, zsh, fish"); + console.log(""); + console.log(" Example:"); + console.log(" # Bash:"); + console.log(" nemoclaw completion bash >> ~/.bashrc"); + console.log(""); + console.log(" # Zsh:"); + console.log(" nemoclaw completion zsh >> ~/.zshrc"); + console.log(""); + console.log(" # Fish:"); + console.log(" nemoclaw completion fish > ~/.config/fish/completions/nemoclaw.fish"); + return; + } + + const globalCmds = Array.from(GLOBAL_COMMANDS).filter(c => !c.startsWith("-")); + let sandboxNames = []; + try { + sandboxNames = registry.listSandboxes().sandboxes.map(s => s.name); + } catch { + sandboxNames = []; + } + + const sandboxNamesStr = sandboxNames.length > 0 ? sandboxNames.join(" ") : ""; + const sandboxNamesZsh = sandboxNames.length > 0 ? sandboxNames.map(s => `"${s}"`).join(" ") : ""; + const sandboxNamesFish = sandboxNames.length > 0 ? sandboxNames.map(s => `'${s}'`).join(" ") : ""; + + if (shell === "bash") { + console.log(`_nemoclaw_completions() { + local cur prev opts + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + + # Global commands + opts="${globalCmds.join(" ")}" + + # Sandbox names (if previous word is a known sandbox) + if [[ " ${sandboxNamesStr} " =~ " $prev " ]]; then + opts="${SANDBOX_ACTIONS.join(" ")}" + fi + + # Also add sandbox names as possible first argument + opts="$opts ${sandboxNamesStr}" + + COMPREPLY=(\$(compgen -W "\$opts" -- \$cur)) + return 0 +} + +complete -F _nemoclaw_completions nemoclaw`); + } else if (shell === "zsh") { + console.log(`# nemoclaw zsh completion + +local -a global_cmds +global_cmds=(${globalCmds.map(c => `"${c}"`).join(" ")}) + +local -a sandbox_actions +sandbox_actions=(${SANDBOX_ACTIONS.map(a => `"${a}"`).join(" ")}) + +local -a sandbox_names +sandbox_names=(${sandboxNamesZsh}) + +_nemoclaw() { + local -a cmd + cmd=(\${words[1,CURRENT-1]}) + + # Check if first word is a sandbox name + if [[ " \${sandbox_names[@]} " =~ " \${cmd[1]} " ]]; then + _describe 'sandbox actions' sandbox_actions + else + _describe 'commands' global_cmds + _describe 'sandboxes' sandbox_names + fi +} + +compdef _nemoclaw nemoclaw`); + } else if (shell === "fish") { + console.log(`# nemoclaw fish completion + +complete -c nemoclaw -f -a "${globalCmds.join(" ")} ${sandboxNamesStr}" -n "test (count (commandline -opc)) -eq 1" + +complete -c nemoclaw -f -a "${SANDBOX_ACTIONS.join(" ")}" -n "test (count (commandline -opc)) -ge 2; and contains (commandline -opc | head -1) ${sandboxNamesFish}" +`); + } +} + // ── Help ───────────────────────────────────────────────────────── function help() { @@ -374,6 +475,9 @@ function help() { --keep-openshell Leave the openshell binary installed --delete-models Remove NemoClaw-pulled Ollama models + Shell Completion: + nemoclaw completion Generate shell completion script + Credentials are prompted on first use, then saved securely in ~/.nemoclaw/credentials.json (mode 600). `); @@ -402,6 +506,7 @@ const [cmd, ...args] = process.argv.slice(2); case "status": showStatus(); break; case "uninstall": uninstall(args); break; case "list": listSandboxes(); break; + case "completion": printCompletion(args[0]); break; default: help(); break; } return; From 6728e40bfed5563a029bf9bb6f690901426cd845 Mon Sep 17 00:00:00 2001 From: vasanth53 Date: Tue, 17 Mar 2026 15:14:54 +0530 Subject: [PATCH 2/4] feat: add sandbox export/import backup commands Add backup and restore functionality for sandboxes: - nemoclaw backups - list all backups - nemoclaw export - export sandbox to backup file - nemoclaw import - import sandbox from backup --- bin/lib/backup.js | 168 ++++++++++++++++++++++++++++++++++++++++++++++ bin/nemoclaw.js | 62 ++++++++++++++++- 2 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 bin/lib/backup.js diff --git a/bin/lib/backup.js b/bin/lib/backup.js new file mode 100644 index 00000000000..3d42a862f23 --- /dev/null +++ b/bin/lib/backup.js @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Sandbox backup and restore functionality + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { execSync } = require("child_process"); +const registry = require("./registry"); + +const BACKUP_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "backups"); + +function ensureBackupDir() { + fs.mkdirSync(BACKUP_DIR, { recursive: true, mode: 0o700 }); +} + +function listBackups() { + ensureBackupDir(); + const files = fs.readdirSync(BACKUP_DIR).filter(f => f.endsWith(".json")); + const backups = []; + + for (const file of files) { + try { + const content = JSON.parse(fs.readFileSync(path.join(BACKUP_DIR, file), "utf-8")); + backups.push({ + name: content.metadata.name, + createdAt: content.metadata.createdAt, + path: path.join(BACKUP_DIR, file), + size: fs.statSync(path.join(BACKUP_DIR, file)).size, + }); + } catch {} + } + + return backups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); +} + +function exportSandbox(sandboxName, outputPath) { + const sandbox = registry.getSandbox(sandboxName); + if (!sandbox) { + console.error(` Sandbox not found: ${sandboxName}`); + return null; + } + + ensureBackupDir(); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const defaultName = `${sandboxName}-${timestamp}.json`; + const backupPath = outputPath || path.join(BACKUP_DIR, defaultName); + + let policyContent = ""; + try { + policyContent = execSync(`openshell policy get ${sandboxName} 2>/dev/null`, { + encoding: "utf-8", + timeout: 10000 + }); + } catch { + policyContent = ""; + } + + const backup = { + version: "1.0", + metadata: { + name: sandboxName, + createdAt: new Date().toISOString(), + nemoclawVersion: require("../package.json").version, + }, + sandbox: { + name: sandbox.name, + model: sandbox.model, + provider: sandbox.provider, + gpuEnabled: sandbox.gpuEnabled, + policies: sandbox.policies || [], + }, + policy: policyContent, + }; + + const dir = path.dirname(backupPath); + if (dir !== BACKUP_DIR) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + fs.writeFileSync(backupPath, JSON.stringify(backup, null, 2), { mode: 0o600 }); + console.log(` Exported sandbox '${sandboxName}' to: ${backupPath}`); + + return backupPath; +} + +function importSandbox(backupPath, newName) { + if (!fs.existsSync(backupPath)) { + console.error(` Backup file not found: ${backupPath}`); + return false; + } + + let backup; + try { + backup = JSON.parse(fs.readFileSync(backupPath, "utf-8")); + } catch { + console.error(` Invalid backup file: ${backupPath}`); + return false; + } + + if (!backup.version || !backup.sandbox) { + console.error(" Invalid backup format"); + return false; + } + + const sandboxName = newName || backup.sandbox.name; + + console.log(` Creating sandbox '${sandboxName}' from backup...`); + + try { + execSync(`openshell sandbox exists ${sandboxName} 2>/dev/null`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }); + console.error(` Sandbox '${sandboxName}' already exists. Use a different name or delete it first.`); + return false; + } catch {} + + console.log(` Note: This only imports the registry config.`); + console.log(` You need to manually recreate the sandbox and apply policies.`); + + registry.registerSandbox({ + name: sandboxName, + model: backup.sandbox.model, + provider: backup.sandbox.provider, + gpuEnabled: backup.sandbox.gpuEnabled, + policies: backup.sandbox.policies || [], + }); + + if (backup.policy) { + const policyPath = path.join(os.tmpdir(), `nemoclaw-restore-${Date.now()}.yaml`); + fs.writeFileSync(policyPath, backup.policy); + try { + execSync(`openshell policy set --policy "${policyPath}" --wait ${sandboxName}`, { + encoding: "utf-8", + timeout: 30000, + }); + console.log(` Restored policy for '${sandboxName}'`); + } catch (err) { + console.warn(` Warning: Could not restore policy: ${err.message}`); + } finally { + fs.unlinkSync(policyPath); + } + } + + console.log(` Imported sandbox '${sandboxName}' from backup`); + return true; +} + +function deleteBackup(backupPath) { + if (!fs.existsSync(backupPath)) { + console.error(` Backup not found: ${backupPath}`); + return false; + } + fs.unlinkSync(backupPath); + console.log(` Deleted backup: ${backupPath}`); + return true; +} + +module.exports = { + BACKUP_DIR, + listBackups, + exportSandbox, + importSandbox, + deleteBackup, +}; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 7ca163b005b..ef179854252 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -17,6 +17,7 @@ const { const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); +const backup = require("./lib/backup"); // ── Global commands ────────────────────────────────────────────── @@ -24,6 +25,7 @@ const GLOBAL_COMMANDS = new Set([ "onboard", "list", "deploy", "setup", "setup-spark", "start", "stop", "status", "uninstall", + "backups", "completion", "help", "--help", "-h", ]); @@ -63,7 +65,7 @@ function exitWithSpawnResult(result) { } const SANDBOX_ACTIONS = [ - "connect", "status", "logs", "policy-add", "policy-list", "destroy" + "connect", "status", "logs", "policy-add", "policy-list", "destroy", "export" ]; const SHELL_TYPES = ["bash", "zsh", "fish"]; @@ -346,6 +348,43 @@ function sandboxDestroy(sandboxName) { console.log(` ✓ Sandbox '${sandboxName}' destroyed`); } +// ── Backup functions ───────────────────────────────────────────── + +function listBackups() { + const backups = backup.listBackups(); + if (backups.length === 0) { + console.log(""); + console.log(" No backups found."); + console.log(` Backups are stored in: ${backup.BACKUP_DIR}`); + console.log(""); + return; + } + console.log(""); + console.log(" Backups:"); + for (const b of backups) { + const size = (b.size / 1024).toFixed(1); + console.log(` ${b.name}`); + console.log(` Created: ${new Date(b.createdAt).toLocaleString()}`); + console.log(` Size: ${size} KB`); + console.log(` Path: ${b.path}`); + console.log(""); + } +} + +function sandboxExport(sandboxName, outputPath) { + const result = backup.exportSandbox(sandboxName, outputPath); + if (!result) { + process.exit(1); + } +} + +function importBackup(backupPath, newName) { + const result = backup.importSandbox(backupPath, newName); + if (!result) { + process.exit(1); + } +} + // ── Shell Completion ───────────────────────────────────────────── function printCompletion(shell) { @@ -456,6 +495,11 @@ function help() { nemoclaw status Show sandbox status and health nemoclaw logs [--follow] View sandbox logs nemoclaw destroy Stop NIM + delete sandbox + nemoclaw export [path] Export sandbox backup + + Backup & Restore: + nemoclaw backups List all backups + nemoclaw import [name] Import a sandbox from backup Policy Presets: nemoclaw policy-add Add a policy preset to a sandbox @@ -506,12 +550,25 @@ const [cmd, ...args] = process.argv.slice(2); case "status": showStatus(); break; case "uninstall": uninstall(args); break; case "list": listSandboxes(); break; + case "backups": listBackups(); break; case "completion": printCompletion(args[0]); break; default: help(); break; } return; } + // Import command (special case - not a sandbox name) + if (cmd === "import") { + const backupPath = args[0]; + const newName = args[1]; + if (!backupPath) { + console.error(" Usage: nemoclaw import [new-sandbox-name]"); + process.exit(1); + } + importBackup(backupPath, newName); + return; + } + // Sandbox-scoped commands: nemoclaw const sandbox = registry.getSandbox(cmd); if (sandbox) { @@ -525,9 +582,10 @@ const [cmd, ...args] = process.argv.slice(2); case "policy-add": await sandboxPolicyAdd(cmd); break; case "policy-list": sandboxPolicyList(cmd); break; case "destroy": sandboxDestroy(cmd); break; + case "export": sandboxExport(cmd, actionArgs[0]); break; default: console.error(` Unknown action: ${action}`); - console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy`); + console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy, export`); process.exit(1); } return; From 3bafc6e9163767345e019d425a393681844ad1bf Mon Sep 17 00:00:00 2001 From: vasanth53 Date: Fri, 20 Mar 2026 12:44:21 +0530 Subject: [PATCH 3/4] fix: address PR review comments for shell completion - Fix package.json path in backup.js (../../package.json) - Replace execSync with execFileSync to prevent shell injection - Secure temp file handling with mkdtempSync and proper permissions - Add 'import' to globalCmds completion list - Fix zsh completion to check cmd[2] instead of cmd[1] - Fix fish completion to check second token --- bin/lib/backup.js | 20 ++++++++++++-------- bin/nemoclaw.js | 8 ++++---- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/bin/lib/backup.js b/bin/lib/backup.js index 3d42a862f23..7d2a37bfb35 100644 --- a/bin/lib/backup.js +++ b/bin/lib/backup.js @@ -6,7 +6,7 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); -const { execSync } = require("child_process"); +const { execFileSync } = require("child_process"); const registry = require("./registry"); const BACKUP_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "backups"); @@ -50,9 +50,10 @@ function exportSandbox(sandboxName, outputPath) { let policyContent = ""; try { - policyContent = execSync(`openshell policy get ${sandboxName} 2>/dev/null`, { + policyContent = execFileSync("openshell", ["policy", "get", sandboxName], { encoding: "utf-8", - timeout: 10000 + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"] }); } catch { policyContent = ""; @@ -63,7 +64,7 @@ function exportSandbox(sandboxName, outputPath) { metadata: { name: sandboxName, createdAt: new Date().toISOString(), - nemoclawVersion: require("../package.json").version, + nemoclawVersion: require("../../package.json").version, }, sandbox: { name: sandbox.name, @@ -110,7 +111,7 @@ function importSandbox(backupPath, newName) { console.log(` Creating sandbox '${sandboxName}' from backup...`); try { - execSync(`openshell sandbox exists ${sandboxName} 2>/dev/null`, { + execFileSync("openshell", ["sandbox", "exists", sandboxName], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); @@ -130,18 +131,21 @@ function importSandbox(backupPath, newName) { }); if (backup.policy) { - const policyPath = path.join(os.tmpdir(), `nemoclaw-restore-${Date.now()}.yaml`); - fs.writeFileSync(policyPath, backup.policy); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-restore-")); + const policyPath = path.join(tmpDir, "policy.yaml"); + fs.writeFileSync(policyPath, backup.policy, { mode: 0o600 }); try { - execSync(`openshell policy set --policy "${policyPath}" --wait ${sandboxName}`, { + execFileSync("openshell", ["policy", "set", "--policy", policyPath, "--wait", sandboxName], { encoding: "utf-8", timeout: 30000, + stdio: ["pipe", "pipe", "pipe"] }); console.log(` Restored policy for '${sandboxName}'`); } catch (err) { console.warn(` Warning: Could not restore policy: ${err.message}`); } finally { fs.unlinkSync(policyPath); + fs.rmdirSync(tmpDir); } } diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index ef179854252..6ef7945358f 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -407,7 +407,7 @@ function printCompletion(shell) { return; } - const globalCmds = Array.from(GLOBAL_COMMANDS).filter(c => !c.startsWith("-")); + const globalCmds = [...Array.from(GLOBAL_COMMANDS).filter(c => !c.startsWith("-")), "import"]; let sandboxNames = []; try { sandboxNames = registry.listSandboxes().sandboxes.map(s => s.name); @@ -458,8 +458,8 @@ _nemoclaw() { local -a cmd cmd=(\${words[1,CURRENT-1]}) - # Check if first word is a sandbox name - if [[ " \${sandbox_names[@]} " =~ " \${cmd[1]} " ]]; then + # Check if first argument is a sandbox name + if [[ " \${sandbox_names[@]} " =~ " \${cmd[2]} " ]]; then _describe 'sandbox actions' sandbox_actions else _describe 'commands' global_cmds @@ -473,7 +473,7 @@ compdef _nemoclaw nemoclaw`); complete -c nemoclaw -f -a "${globalCmds.join(" ")} ${sandboxNamesStr}" -n "test (count (commandline -opc)) -eq 1" -complete -c nemoclaw -f -a "${SANDBOX_ACTIONS.join(" ")}" -n "test (count (commandline -opc)) -ge 2; and contains (commandline -opc | head -1) ${sandboxNamesFish}" +complete -c nemoclaw -f -a "${SANDBOX_ACTIONS.join(" ")}" -n "test (count (commandline -opc)) -ge 2; and contains (commandline -opc)[2] ${sandboxNamesFish}" `); } } From e92b117187f9aade621ada73fd0310b1b1f1bec0 Mon Sep 17 00:00:00 2001 From: vasanth53 Date: Sun, 22 Mar 2026 19:41:41 +0530 Subject: [PATCH 4/4] Add docstrings to improve coverage for CodeRabbit pre-merge checks --- bin/lib/backup.js | 24 ++++++++++++++++++++++++ bin/nemoclaw.js | 17 +++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/bin/lib/backup.js b/bin/lib/backup.js index 7d2a37bfb35..c123a321c1a 100644 --- a/bin/lib/backup.js +++ b/bin/lib/backup.js @@ -11,10 +11,17 @@ const registry = require("./registry"); const BACKUP_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "backups"); +/** + * Ensures the backup directory exists with appropriate permissions. + */ function ensureBackupDir() { fs.mkdirSync(BACKUP_DIR, { recursive: true, mode: 0o700 }); } +/** + * Lists all sandbox backups in the backup directory. + * @returns {Array<{name: string, createdAt: string, path: string, size: number}>} + */ function listBackups() { ensureBackupDir(); const files = fs.readdirSync(BACKUP_DIR).filter(f => f.endsWith(".json")); @@ -35,6 +42,12 @@ function listBackups() { return backups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); } +/** + * Exports a sandbox to a backup file. + * @param {string} sandboxName - Name of the sandbox to export. + * @param {string} [outputPath] - Optional output path for the backup file. + * @returns {string|null} Path to the created backup file, or null if sandbox not found. + */ function exportSandbox(sandboxName, outputPath) { const sandbox = registry.getSandbox(sandboxName); if (!sandbox) { @@ -87,6 +100,12 @@ function exportSandbox(sandboxName, outputPath) { return backupPath; } +/** + * Imports a sandbox from a backup file. + * @param {string} backupPath - Path to the backup file. + * @param {string} [newName] - Optional new name for the imported sandbox. + * @returns {boolean} True if import succeeded, false otherwise. + */ function importSandbox(backupPath, newName) { if (!fs.existsSync(backupPath)) { console.error(` Backup file not found: ${backupPath}`); @@ -153,6 +172,11 @@ function importSandbox(backupPath, newName) { return true; } +/** + * Deletes a backup file. + * @param {string} backupPath - Path to the backup file to delete. + * @returns {boolean} True if deletion succeeded, false otherwise. + */ function deleteBackup(backupPath) { if (!fs.existsSync(backupPath)) { console.error(` Backup not found: ${backupPath}`); diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 02a0d477aa4..cba22b145bf 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -400,6 +400,9 @@ async function sandboxDestroy(sandboxName, args = []) { // ── Backup functions ───────────────────────────────────────────── +/** + * Lists all sandbox backups with formatted output. + */ function listBackups() { const backups = backup.listBackups(); if (backups.length === 0) { @@ -421,6 +424,11 @@ function listBackups() { } } +/** + * Exports a sandbox to a backup file. + * @param {string} sandboxName - Name of the sandbox to export. + * @param {string} [outputPath] - Optional output path for the backup. + */ function sandboxExport(sandboxName, outputPath) { const result = backup.exportSandbox(sandboxName, outputPath); if (!result) { @@ -428,6 +436,11 @@ function sandboxExport(sandboxName, outputPath) { } } +/** + * Imports a sandbox from a backup file. + * @param {string} backupPath - Path to the backup file. + * @param {string} [newName] - Optional new name for the sandbox. + */ function importBackup(backupPath, newName) { const result = backup.importSandbox(backupPath, newName); if (!result) { @@ -437,6 +450,10 @@ function importBackup(backupPath, newName) { // ── Shell Completion ───────────────────────────────────────────── +/** + * Prints shell completion script for the specified shell. + * @param {string} shell - Shell type (bash, zsh, or fish). + */ function printCompletion(shell) { if (!shell || !SHELL_TYPES.includes(shell)) { console.log(" Usage: nemoclaw completion ");