diff --git a/README.md b/README.md index 5e73237..68ab1ea 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Turn ANY Electron application into a CLI tool! Recombine, script, and extend app - **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw! - **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser. - **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies. +- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, kubectl, etc). Zero setup. - **Self-healing setup** — `opencli setup` verifies Browser Bridge connectivity; `opencli doctor` diagnoses daemon, extension, and live browser connectivity. - **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration. - **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections. @@ -147,6 +148,28 @@ Run `opencli list` for the live registry. | **yahoo-finance** | `quote` | Browser | | **sinafinance** | `news` | 🌐 Public | +### External CLI Hub + +OpenCLI acts as a universal hub for your existing command-line tools. It provides unified discovery, automatic installation, and pure passthrough execution. + +| External CLI | Description | Commands Example | +|--------------|-------------|------------------| +| **gh** | GitHub CLI | `opencli gh pr list --limit 5` | +| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` | +| **docker** | Docker command-line interface | `opencli docker ps` | +| **kubectl** | Kubernetes command-line tool | `opencli kubectl get pods` | +| **readwise** | Readwise & Reader CLI | `opencli readwise login` | + +**Zero Configuration**: OpenCLI purely passes your inputs to the underlying binary via standard I/O streams. The external CLI works exactly as it naturally would, maintaining its standard output formats. + +**Auto-Installation**: If you run `opencli gh ...` and `gh` is not installed on your system, OpenCLI will automatically try to install it using your system's package manager (e.g., `brew install gh`) before seamlessly re-running the command. + +**Register Your Own**: +Add any local CLI to your OpenCLI registry so AI agents can automatically discover it via the `opencli list` command. +```bash +opencli register mycli +``` + ### Desktop App Adapters Each desktop adapter has its own detailed documentation with commands reference, setup guide, and examples: diff --git a/SKILL.md b/SKILL.md index 754f554..2b0a02b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -168,9 +168,11 @@ opencli antigravity watch # 流式监听增量消息 ### Management Commands ```bash -opencli list # List all commands +opencli list # List all commands (including External CLIs) opencli list --json # JSON output opencli list -f yaml # YAML output +opencli install # Auto-install an external CLI (e.g., gh, obsidian) +opencli register # Register a local custom CLI for unified discovery opencli validate # Validate all CLI definitions opencli validate bilibili # Validate specific site opencli setup # Interactive Browser Bridge setup and connectivity check diff --git a/src/cli.ts b/src/cli.ts index 17ac287..89a6943 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { PKG_VERSION } from './version.js'; import { printCompletionScript } from './completion.js'; import { CliError } from './errors.js'; import { shouldUseBrowserSession } from './capabilityRouting.js'; +import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js'; export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { const program = new Command(); @@ -47,7 +48,19 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { for (const cmd of cmds) { const tag = strategyLabel(cmd) === 'public' ? chalk.green('[public]') : chalk.yellow(`[${strategyLabel(cmd)}]`); console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`); } console.log(); } - console.log(chalk.dim(` ${commands.length} commands across ${sites.size} sites`)); console.log(); + + const externalClis = loadExternalClis(); + if (externalClis.length > 0) { + console.log(chalk.bold.cyan(` external CLIs`)); + for (const ext of externalClis) { + const isInstalled = isBinaryInstalled(ext.binary); + const tag = isInstalled ? chalk.green('[installed]') : chalk.yellow('[auto-install]'); + console.log(` ${ext.name} ${tag}${ext.description ? chalk.dim(` — ${ext.description}`) : ''}`); + } + console.log(); + } + + console.log(chalk.dim(` ${commands.length} built-in commands across ${sites.size} sites, ${externalClis.length} external CLIs`)); console.log(); }); program.command('validate').description('Validate CLI definitions').argument('[target]', 'site or site/name') @@ -109,6 +122,48 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { printCompletionScript(shell); }); + const externalClis = loadExternalClis(); + + program.command('install') + .description('Install an external CLI') + .argument('', 'Name of the external CLI') + .action((name: string) => { + const ext = externalClis.find(e => e.name === name); + if (!ext) { + console.error(chalk.red(`External CLI '${name}' not found in registry.`)); + process.exitCode = 1; + return; + } + installExternalCli(ext); + }); + + program.command('register') + .description('Register an external CLI') + .argument('', 'Name of the CLI') + .option('--binary ', 'Binary name if different from name') + .option('--install ', 'Auto-install command') + .option('--desc ', 'Description') + .action((name, opts) => { + registerExternalCli(name, opts.binary, opts.install, opts.desc); + }); + + for (const ext of externalClis) { + if (program.commands.some(c => c.name() === ext.name)) continue; + program.command(ext.name) + .description(`(External) ${ext.description || ext.name}`) + .allowUnknownOption() + .action(() => { + // Retrieve args passed to the external CLI + // Commander consumes standard args before the action, so we must slice process.argv directly. + const extIndex = process.argv.indexOf(ext.name); + const args = process.argv.slice(extIndex + 1); + executeExternalCli(ext.name, args).catch(err => { + console.error(chalk.red(`Error: ${err.message}`)); + process.exitCode = 1; + }); + }); + } + // ── Antigravity serve (built-in, long-running) ────────────────────────────── const antigravityCmd = program.command('antigravity').description('antigravity commands'); diff --git a/src/external-clis.yaml b/src/external-clis.yaml new file mode 100644 index 0000000..2ef51df --- /dev/null +++ b/src/external-clis.yaml @@ -0,0 +1,39 @@ +- name: gh + binary: gh + description: "GitHub CLI — repos, PRs, issues, releases, gists" + homepage: "https://cli.github.com" + tags: [github, git, dev] + install: + mac: "brew install gh" + +- name: obsidian + binary: obsidian + description: "Obsidian vault management — notes, search, tags, tasks, sync" + homepage: "https://obsidian.md/help/cli" + tags: [notes, knowledge, markdown] + install: + mac: "brew install --cask obsidian" + +- name: readwise + binary: readwise + description: "Readwise & Reader CLI — highlights, annotations, reading list" + homepage: "https://github.com/readwiseio/readwise-cli" + tags: [reading, highlights] + install: + default: "npm install -g @readwiseio/readwise-cli" + +- name: kubectl + binary: kubectl + description: "Kubernetes command-line tool" + homepage: "https://kubernetes.io/docs/reference/kubectl/" + tags: [kubernetes, k8s, devops] + install: + mac: "brew install kubectl" + +- name: docker + binary: docker + description: "Docker command-line interface" + homepage: "https://docs.docker.com/engine/reference/commandline/cli/" + tags: [docker, containers, devops] + install: + mac: "brew install --cask docker" diff --git a/src/external.ts b/src/external.ts new file mode 100644 index 0000000..6991ae5 --- /dev/null +++ b/src/external.ts @@ -0,0 +1,182 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { spawnSync, execSync } from 'node:child_process'; +import yaml from 'js-yaml'; +import chalk from 'chalk'; +import { log } from './logger.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export interface ExternalCliInstall { + mac?: string; + linux?: string; + windows?: string; + default?: string; +} + +export interface ExternalCliConfig { + name: string; + binary: string; + description?: string; + homepage?: string; + tags?: string[]; + install?: ExternalCliInstall; +} + +function getUserRegistryPath(): string { + const home = os.homedir(); + return path.join(home, '.opencli', 'external-clis.yaml'); +} + +export function loadExternalClis(): ExternalCliConfig[] { + const configs = new Map(); + + // 1. Load built-in + const builtinPath = path.resolve(__dirname, 'external-clis.yaml'); + try { + if (fs.existsSync(builtinPath)) { + const raw = fs.readFileSync(builtinPath, 'utf8'); + const parsed = (yaml.load(raw) || []) as ExternalCliConfig[]; + for (const item of parsed) configs.set(item.name, item); + } + } catch (err: any) { + log.warn(`Failed to parse built-in external-clis.yaml: ${err.message}`); + } + + // 2. Load user custom + const userPath = getUserRegistryPath(); + try { + if (fs.existsSync(userPath)) { + const raw = fs.readFileSync(userPath, 'utf8'); + const parsed = (yaml.load(raw) || []) as ExternalCliConfig[]; + for (const item of parsed) { + configs.set(item.name, item); // Overwrite built-in if duplicated + } + } + } catch (err: any) { + log.warn(`Failed to parse user external-clis.yaml: ${err.message}`); + } + + return Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name)); +} + +export function isBinaryInstalled(binary: string): boolean { + try { + const isWindows = os.platform() === 'win32'; + const cmd = isWindows ? 'where' : 'command -v'; + execSync(`${cmd} ${binary}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +export function getInstallCmd(installConfig?: ExternalCliInstall): string | null { + if (!installConfig) return null; + const platform = os.platform(); + if (platform === 'darwin' && installConfig.mac) return installConfig.mac; + if (platform === 'linux' && installConfig.linux) return installConfig.linux; + if (platform === 'win32' && installConfig.windows) return installConfig.windows; + if (installConfig.default) return installConfig.default; + return null; +} + +export function installExternalCli(cli: ExternalCliConfig): boolean { + if (!cli.install) { + console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`)); + console.error(`Please install '${cli.binary}' manually.`); + return false; + } + + const cmd = getInstallCmd(cli.install); + if (!cmd) { + console.error(chalk.red(`No install command for your platform (${os.platform()}) for '${cli.name}'.`)); + if (cli.homepage) console.error(`See: ${cli.homepage}`); + return false; + } + + console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`)); + console.log(chalk.dim(`$ ${cmd}`)); + try { + execSync(cmd, { stdio: 'inherit' }); + console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`)); + return true; + } catch (err: any) { + console.error(chalk.red(`❌ Failed to install '${cli.name}': ${err.message}`)); + return false; + } +} + +export async function executeExternalCli(name: string, args: string[]): Promise { + const configs = loadExternalClis(); + const cli = configs.find((c) => c.name === name); + if (!cli) { + throw new Error(`External CLI '${name}' not found in registry.`); + } + + // 1. Check if installed + if (!isBinaryInstalled(cli.binary)) { + // 2. Try to auto install + const success = installExternalCli(cli); + if (!success) { + process.exitCode = 1; + return; + } + } + + // 3. Passthrough execution + // We use spawnSync to properly inherit stdio and block until completion + const result = spawnSync(cli.binary, args, { stdio: 'inherit' }); + if (result.error) { + console.error(chalk.red(`Failed to execute '${cli.binary}': ${result.error.message}`)); + process.exitCode = 1; + return; + } + + if (result.status !== null) { + process.exitCode = result.status; + } +} + +export function registerExternalCli(name: string, binary?: string, install?: string, description?: string): void { + const userPath = getUserRegistryPath(); + const configDir = path.dirname(userPath); + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + let items: ExternalCliConfig[] = []; + if (fs.existsSync(userPath)) { + try { + const raw = fs.readFileSync(userPath, 'utf8'); + items = (yaml.load(raw) || []) as ExternalCliConfig[]; + } catch { + // Ignore + } + } + + const existingIndex = items.findIndex((c) => c.name === name); + + const newItem: ExternalCliConfig = { + name, + binary: binary || name, + }; + if (description) newItem.description = description; + if (install) newItem.install = { default: install }; + + if (existingIndex >= 0) { + // Merge + items[existingIndex] = { ...items[existingIndex], ...newItem }; + console.log(chalk.green(`Updated '${name}' in user registry.`)); + } else { + items.push(newItem); + console.log(chalk.green(`Registered '${name}' in user registry.`)); + } + + const dump = yaml.dump(items, { indent: 2, sortKeys: true }); + fs.writeFileSync(userPath, dump, 'utf8'); + console.log(chalk.dim(userPath)); +}