Repository navigation
feat: add shell completion for nemoclaw CLI #160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
c5edc91
6728e40
3bafc6e
28d1f66
e92b117
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| 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 || [], | ||
| }); | ||
|
Comment on lines
+128
to
+150
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate imported sandbox names before persisting them. Line 128 accepts 🤖 Prompt for AI Agents |
||
|
|
||
| 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); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| 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, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,12 +17,16 @@ const { | |
| const registry = require("./lib/registry"); | ||
| const nim = require("./lib/nim"); | ||
| const policies = require("./lib/policies"); | ||
| const backup = require("./lib/backup"); | ||
|
|
||
| // ── Global commands ────────────────────────────────────────────── | ||
|
|
||
| const GLOBAL_COMMANDS = new Set([ | ||
| "onboard", "list", "deploy", "setup", "setup-spark", | ||
| "start", "stop", "status", "uninstall", | ||
| "start", "stop", "status", | ||
| "uninstall", | ||
| "backups", | ||
| "completion", | ||
| "help", "--help", "-h", | ||
| ]); | ||
|
|
||
|
|
@@ -60,6 +64,12 @@ function exitWithSpawnResult(result) { | |
| process.exit(1); | ||
| } | ||
|
|
||
| const SANDBOX_ACTIONS = [ | ||
| "connect", "status", "logs", "policy-add", "policy-list", "destroy", "export" | ||
| ]; | ||
|
|
||
| const SHELL_TYPES = ["bash", "zsh", "fish"]; | ||
|
|
||
| // ── Commands ───────────────────────────────────────────────────── | ||
|
|
||
| async function onboard(args) { | ||
|
|
@@ -338,6 +348,136 @@ 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) { | ||
| if (!shell || !SHELL_TYPES.includes(shell)) { | ||
| console.log(" Usage: nemoclaw completion <shell>"); | ||
| 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; | ||
|
Comment on lines
+458
to
+474
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Return a failure status for unsupported shells.
💡 Suggested fix function printCompletion(shell) {
if (!shell || !SHELL_TYPES.includes(shell)) {
- console.log(" Usage: nemoclaw completion <shell>");
- 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;
+ console.error(" Usage: nemoclaw completion <shell>");
+ console.error("");
+ console.error(" Generate shell completion scripts.");
+ console.error("");
+ console.error(" Shells supported: bash, zsh, fish");
+ console.error("");
+ console.error(" Example:");
+ console.error(" # Bash:");
+ console.error(" nemoclaw completion bash >> ~/.bashrc");
+ console.error("");
+ console.error(" # Zsh:");
+ console.error(" nemoclaw completion zsh >> ~/.zshrc");
+ console.error("");
+ console.error(" # Fish:");
+ console.error(" nemoclaw completion fish > ~/.config/fish/completions/nemoclaw.fish");
+ process.exit(1);
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| const globalCmds = Array.from(GLOBAL_COMMANDS).filter(c => !c.startsWith("-")); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| 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(" ") : ""; | ||
|
Comment on lines
+465
to
+487
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sandbox name completion is still a snapshot.
|
||
|
|
||
| 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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| 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}" | ||
| `); | ||
|
Comment on lines
+489
to
+544
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n bin/nemoclaw.js | sed -n '472,527p'Repository: NVIDIA/NemoClaw Length of output: 2033 🏁 Script executed: rg -n "globalCmds|SANDBOX_ACTIONS" bin/nemoclaw.js -B 2 -A 2Repository: NVIDIA/NemoClaw Length of output: 1179 🏁 Script executed: rg -n "import|export" bin/nemoclaw.js | head -30Repository: NVIDIA/NemoClaw Length of output: 914 Add path completion for The completion scripts only return command/action word lists and suppress path completion for Add conditional branches or fallback handlers to enable path completion after these commands. Quick validation: 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| // ── Help ───────────────────────────────────────────────────────── | ||
|
|
||
| function help() { | ||
|
|
@@ -355,6 +495,11 @@ function help() { | |
| nemoclaw <name> status Show sandbox status and health | ||
| nemoclaw <name> logs [--follow] View sandbox logs | ||
| nemoclaw <name> destroy Stop NIM + delete sandbox | ||
| nemoclaw <name> export [path] Export sandbox backup | ||
|
|
||
| Backup & Restore: | ||
| nemoclaw backups List all backups | ||
| nemoclaw import <path> [name] Import a sandbox from backup | ||
|
|
||
| Policy Presets: | ||
| nemoclaw <name> policy-add Add a policy preset to a sandbox | ||
|
|
@@ -374,6 +519,9 @@ function help() { | |
| --keep-openshell Leave the openshell binary installed | ||
| --delete-models Remove NemoClaw-pulled Ollama models | ||
|
|
||
| Shell Completion: | ||
| nemoclaw completion <shell> Generate shell completion script | ||
|
|
||
| Credentials are prompted on first use, then saved securely | ||
| in ~/.nemoclaw/credentials.json (mode 600). | ||
| `); | ||
|
|
@@ -402,11 +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 <backup-file> [new-sandbox-name]"); | ||
| process.exit(1); | ||
| } | ||
| importBackup(backupPath, newName); | ||
| return; | ||
| } | ||
|
|
||
| // Sandbox-scoped commands: nemoclaw <name> <action> | ||
| const sandbox = registry.getSandbox(cmd); | ||
| if (sandbox) { | ||
|
|
@@ -420,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; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.