feat: add external CLI hub for discovery, auto-installation, and execution of external tools.

This commit is contained in:
jackwener committed 2026-03-20 20:30:40 +08:00
1 parent 36bc57a962
commit b3e32d8a05
5 files changed
+303 -2

No files matched your search

+23
View File
@@ -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:
+3 -1
View File
@@ -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 <name> # Auto-install an external CLI (e.g., gh, obsidian)
opencli register <name> # 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
+56 -1
View File
@@ -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>', '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>', 'Name of the CLI')
.option('--binary <bin>', 'Binary name if different from name')
.option('--install <cmd>', 'Auto-install command')
.option('--desc <text>', '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');
+39
View File
@@ -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"
+182
View File
@@ -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<string, ExternalCliConfig>();
// 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<void> {
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));
}