feat: add external CLI hub for discovery, auto-installation, and execution of external tools.
This commit is contained in:
1 parent
36bc57a962
commit
b3e32d8a05
5 files changed
+303
-2
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user