Repository navigation
refactor(cli): migrate skill install command to oclif #2797
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 9 commits
116111c
5e997cb
88cd958
3f748d6
0bc877a
3fd15fc
81c62bb
7d6a6fb
3f17a4f
e615567
54263d4
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,207 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| /* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ | ||
|
|
||
| import { Args, Command, Flags } from "@oclif/core"; | ||
|
|
||
| import { CLI_DISPLAY_NAME, CLI_NAME } from "./branding"; | ||
| import { prompt as askPrompt } from "./credentials"; | ||
|
|
||
| interface SpawnLikeResult { | ||
| status: number | null; | ||
| stdout?: string | Buffer; | ||
| stderr?: string | Buffer; | ||
| } | ||
|
|
||
| interface RuntimeBridge { | ||
| recoverNamedGatewayRuntime: () => Promise<{ recovered: boolean }>; | ||
| runOpenshell: ( | ||
| args: string[], | ||
| opts?: { ignoreError?: boolean; stdio?: import("node:child_process").StdioOptions }, | ||
| ) => SpawnLikeResult; | ||
| } | ||
|
|
||
| // Suffixes that mark per-sandbox messaging integrations in the gateway's | ||
| // provider list. These are managed by `channels`, not `credentials`. | ||
| const BRIDGE_PROVIDER_SUFFIXES: readonly string[] = [ | ||
| "-telegram-bridge", | ||
| "-discord-bridge", | ||
| "-slack-bridge", | ||
| "-slack-app", | ||
| ]; | ||
|
|
||
| function getRuntimeBridge(): RuntimeBridge { | ||
| return require("../nemoclaw") as RuntimeBridge; | ||
| } | ||
|
|
||
| function isBridgeProviderName(name: string): boolean { | ||
| return BRIDGE_PROVIDER_SUFFIXES.some((suffix) => name.endsWith(suffix)); | ||
| } | ||
|
|
||
| function printCredentialsUsage(log: (message?: string) => void = console.log): void { | ||
| log(""); | ||
| log(` Usage: ${CLI_NAME} credentials <subcommand>`); | ||
| log(""); | ||
| log(" Subcommands:"); | ||
| log(" list List provider credentials registered with the OpenShell gateway"); | ||
| log(" reset <PROVIDER> [--yes] Remove a provider credential so onboard re-prompts"); | ||
| log(""); | ||
| log(" Credentials live in the OpenShell gateway. Inspect with `openshell provider list`."); | ||
| log(" Nothing is persisted to host disk; deploy/non-onboard commands read from env vars."); | ||
| log(""); | ||
| } | ||
|
|
||
| async function recoverGatewayOrExit(kind: "query" | "reach"): Promise<void> { | ||
| const runtime = getRuntimeBridge(); | ||
| const recovery = await runtime.recoverNamedGatewayRuntime(); | ||
| if (recovery.recovered) return; | ||
|
|
||
| if (kind === "query") { | ||
| console.error(` Could not query the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`); | ||
| } else { | ||
| console.error(` Could not reach the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`); | ||
| } | ||
| console.error(` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| export class CredentialsCommand extends Command { | ||
| static id = "credentials"; | ||
| static strict = true; | ||
| static summary = "Manage provider credentials"; | ||
| static description = | ||
| "List or reset provider credentials registered with the OpenShell gateway."; | ||
| static usage = ["credentials <list|reset>"]; | ||
| static flags = { | ||
| help: Flags.help({ char: "h" }), | ||
| }; | ||
|
|
||
| public async run(): Promise<void> { | ||
| await this.parse(CredentialsCommand); | ||
| printCredentialsUsage(this.log.bind(this)); | ||
| } | ||
| } | ||
|
|
||
| export class CredentialsListCommand extends Command { | ||
| static id = "credentials:list"; | ||
| static strict = true; | ||
| static summary = "List stored credential providers"; | ||
| static description = "List provider credentials registered with the OpenShell gateway."; | ||
| static usage = ["credentials list"]; | ||
| static flags = { | ||
| help: Flags.help({ char: "h" }), | ||
| }; | ||
|
|
||
| public async run(): Promise<void> { | ||
| await this.parse(CredentialsListCommand); | ||
| await recoverGatewayOrExit("query"); | ||
|
|
||
| const runtime = getRuntimeBridge(); | ||
| const result = runtime.runOpenshell(["provider", "list", "--names"], { | ||
| ignoreError: true, | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| }); | ||
| if (result.status !== 0) { | ||
| console.error(" Could not query OpenShell gateway. Is it running?"); | ||
| console.error(` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const allNames = String(result.stdout || "") | ||
| .split("\n") | ||
| .map((name) => name.trim()) | ||
| .filter((name) => name.length > 0); | ||
| const credentialNames = allNames.filter((name) => !isBridgeProviderName(name)).sort(); | ||
| const bridgeNames = allNames.filter((name) => isBridgeProviderName(name)); | ||
|
|
||
| if (credentialNames.length === 0) { | ||
| this.log(" No provider credentials registered."); | ||
| } else { | ||
| this.log(" Providers registered with the OpenShell gateway:"); | ||
| for (const name of credentialNames) { | ||
| this.log(` ${name}`); | ||
| } | ||
| } | ||
| if (bridgeNames.length > 0) { | ||
| this.log(""); | ||
| this.log(` ${String(bridgeNames.length)} per-sandbox messaging bridge(s) are also registered.`); | ||
| this.log(` Manage those with \`${CLI_NAME} <sandbox> channels list/remove/stop\` — not this command.`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export class CredentialsResetCommand extends Command { | ||
| static id = "credentials:reset"; | ||
| static strict = true; | ||
| static summary = "Remove a provider credential"; | ||
| static description = "Remove a provider credential so onboard re-prompts for it."; | ||
| static usage = ["credentials reset <PROVIDER> [--yes]"]; | ||
| static args = { | ||
| provider: Args.string({ | ||
| name: "PROVIDER", | ||
| description: "OpenShell provider name", | ||
| required: false, | ||
| }), | ||
| }; | ||
| static flags = { | ||
| help: Flags.help({ char: "h" }), | ||
| yes: Flags.boolean({ char: "y", description: "Skip the confirmation prompt" }), | ||
| }; | ||
|
|
||
| public async run(): Promise<void> { | ||
| const { args, flags } = await this.parse(CredentialsResetCommand); | ||
| const key = args.provider; | ||
|
|
||
| if (!key || key.startsWith("-")) { | ||
| console.error(` Usage: ${CLI_NAME} credentials reset <PROVIDER> [--yes]`); | ||
| console.error(` PROVIDER is an OpenShell provider name. Run '${CLI_NAME} credentials list' first.`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| if (isBridgeProviderName(key)) { | ||
| console.error(` '${key}' is a per-sandbox messaging bridge, not a credential.`); | ||
| console.error( | ||
| ` Use \`${CLI_NAME} <sandbox> channels remove <telegram|discord|slack>\` to retire`, | ||
| ); | ||
| console.error(" the integration (it tears down the bridge provider and rebuilds the sandbox),"); | ||
| console.error(` or \`${CLI_NAME} <sandbox> channels stop <…>\` to pause it without clearing tokens.`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| if (!flags.yes) { | ||
| const answer = (await askPrompt(` Remove provider '${key}' from the OpenShell gateway? [y/N]: `)) | ||
| .trim() | ||
| .toLowerCase(); | ||
| if (answer !== "y" && answer !== "yes") { | ||
| this.log(" Cancelled."); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| await recoverGatewayOrExit("reach"); | ||
|
|
||
| const runtime = getRuntimeBridge(); | ||
| const result = runtime.runOpenshell(["provider", "delete", key], { | ||
| ignoreError: true, | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| }); | ||
| if (result.status === 0) { | ||
| this.log(` Removed provider '${key}' from the OpenShell gateway.`); | ||
| this.log(` Re-run '${CLI_NAME} onboard' to enter a new value.`); | ||
| return; | ||
| } | ||
|
|
||
| console.error(` Could not remove provider '${key}'.`); | ||
| if (/^[A-Z][A-Z0-9_]+$/.test(key)) { | ||
| console.error(""); | ||
| console.error(` '${key}' looks like a credential env variable name.`); | ||
| console.error(" As of this release, 'credentials reset' takes an OpenShell"); | ||
| console.error(` provider name. Run '${CLI_NAME} credentials list' to see the`); | ||
| console.error(" registered providers, then retry with one of those names."); | ||
| } | ||
| const stderr = String(result.stderr || "").trim(); | ||
| if (stderr) console.error(` ${stderr}`); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| /* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ | ||
|
|
||
|
Comment on lines
+4
to
+5
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. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Avoid file-wide coverage ignore for behaviorful debug logic. Line 4 suppresses coverage for the entire module, including default-sandbox resolution and OpenShell probe warning paths. That makes regressions harder to catch in this command. ♻️ Proposed fix-/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */🤖 Prompt for AI Agents |
||
| import { Command } from "@oclif/core"; | ||
|
|
||
| import type { CaptureOpenshellResult } from "./openshell"; | ||
| import type { RunDebugCommandDeps } from "./debug-command"; | ||
| import { CLI_NAME } from "./branding"; | ||
| import { runDebug } from "./debug"; | ||
| import { runDebugCommand } from "./debug-command"; | ||
| import { OPENSHELL_PROBE_TIMEOUT_MS } from "./openshell-timeouts"; | ||
| import { captureOpenshellCommand } from "./openshell"; | ||
| import { parseLiveSandboxNames } from "./runtime-recovery"; | ||
| import * as registry from "./registry"; | ||
| import { resolveOpenshell } from "./resolve-openshell"; | ||
|
|
||
| const useColor = !process.env.NO_COLOR && !!process.stderr.isTTY; | ||
| const B = useColor ? "\x1b[1m" : ""; | ||
| const R = useColor ? "\x1b[0m" : ""; | ||
| const RD = useColor ? "\x1b[1;31m" : ""; | ||
|
|
||
| function captureOpenshell(rootDir: string, args: string[]): CaptureOpenshellResult { | ||
| const openshell = resolveOpenshell(); | ||
| if (!openshell) { | ||
| return { status: 1, output: "" }; | ||
| } | ||
| return captureOpenshellCommand(openshell, args, { | ||
| cwd: rootDir, | ||
| ignoreError: true, | ||
| timeout: OPENSHELL_PROBE_TIMEOUT_MS, | ||
| }); | ||
| } | ||
|
|
||
| function buildDebugCommandDeps(rootDir: string): RunDebugCommandDeps { | ||
| const getDefaultSandbox = (): string | undefined => { | ||
| const { defaultSandbox, sandboxes } = registry.listSandboxes(); | ||
| if (!defaultSandbox) return undefined; | ||
| if (!sandboxes.find((sandbox) => sandbox.name === defaultSandbox)) { | ||
| console.error( | ||
| `${RD}Warning:${R} default sandbox '${defaultSandbox}' is no longer in the registry.`, | ||
| ); | ||
| console.error( | ||
| ` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`, | ||
| ); | ||
| return undefined; | ||
| } | ||
| const liveList = captureOpenshell(rootDir, ["sandbox", "list"]); | ||
| if (liveList.status === 0 && !parseLiveSandboxNames(liveList.output).has(defaultSandbox)) { | ||
| console.error( | ||
| `${RD}Warning:${R} default sandbox '${defaultSandbox}' exists in the local registry but not in OpenShell.`, | ||
| ); | ||
| console.error( | ||
| ` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`, | ||
| ); | ||
| return undefined; | ||
| } | ||
| return defaultSandbox; | ||
| }; | ||
|
|
||
| return { | ||
| getDefaultSandbox, | ||
| runDebug, | ||
| log: console.log, | ||
| error: console.error, | ||
| exit: (code: number) => process.exit(code), | ||
| }; | ||
| } | ||
|
|
||
| export default class DebugCliCommand extends Command { | ||
| static id = "debug"; | ||
| static strict = false; | ||
| static summary = "Collect diagnostics for bug reports"; | ||
| static description = "Collect NemoClaw diagnostic information."; | ||
| static usage = ["debug [--quick] [--output FILE] [--sandbox NAME]"]; | ||
|
|
||
| public async run(): Promise<void> { | ||
| this.parsed = true; | ||
| runDebugCommand(this.argv, buildDebugCommandDeps(this.config.root)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| /* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ | ||
|
|
||
| import { Args, Command, Flags } from "@oclif/core"; | ||
|
|
||
| import { runGatewayTokenCommand } from "./gateway-token-command"; | ||
|
|
||
| const { fetchGatewayAuthTokenFromSandbox } = require("./onboard") as { | ||
| fetchGatewayAuthTokenFromSandbox: (sandboxName: string) => string | null; | ||
| }; | ||
|
|
||
| export default class GatewayTokenCliCommand extends Command { | ||
| static id = "sandbox:gateway-token"; | ||
| static strict = true; | ||
| static summary = "Print the OpenClaw gateway auth token to stdout"; | ||
| static description = "Print the OpenClaw gateway auth token for a running sandbox to stdout."; | ||
| static usage = ["<name> gateway-token [--quiet|-q]"]; | ||
| static args = { | ||
| sandboxName: Args.string({ | ||
| name: "sandbox", | ||
| description: "Sandbox name", | ||
| required: true, | ||
| }), | ||
| }; | ||
| static flags = { | ||
| help: Flags.help({ char: "h" }), | ||
| quiet: Flags.boolean({ char: "q", description: "Suppress the stderr security warning" }), | ||
| }; | ||
|
|
||
| public async run(): Promise<void> { | ||
| const { args, flags } = await this.parse(GatewayTokenCliCommand); | ||
| // Suppress EPIPE traces when the consumer closes the pipe early | ||
| // (e.g. `... | head -c 0`). The token has already been written. | ||
| process.stdout.on("error", (err: NodeJS.ErrnoException) => { | ||
| if (err.code === "EPIPE") process.exit(0); | ||
| }); | ||
|
|
||
| const exitCode = runGatewayTokenCommand( | ||
| args.sandboxName, | ||
| { quiet: flags.quiet === true }, | ||
| { fetchToken: fetchGatewayAuthTokenFromSandbox }, | ||
| ); | ||
| if (exitCode !== 0) this.exit(exitCode); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Narrow the coverage suppression scope in this file.
Line 4 starts a file-wide ignore region, but this module contains non-trivial runtime and error-path logic. Blanket suppression here will hide useful branch coverage signals.
♻️ Proposed fix
-/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */🤖 Prompt for AI Agents