Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** i

**Built for AI Agents** — Configure an instruction in your `AGENT.md` or `.cursorrules` to run `opencli list` via Bash. The AI will automatically discover and invoke all available tools.

**CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. Auto-installs missing tools via your package manager (e.g. if `gh` isn't installed, `opencli gh ...` runs `brew install gh` first then re-executes seamlessly).
**CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. External tools now have a canonical `opencli ext <tool> ...` namespace, while curated top-level aliases like `opencli gh ...` stay supported for convenience. Missing tools can still be auto-installed before passthrough execution.

**CLI for Electron Apps** — Turn any Electron application into a CLI tool. Recombine, script, and extend apps like Antigravity Ultra from the terminal. AI agents can now control other AI apps natively.

Expand Down Expand Up @@ -130,6 +130,20 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n

OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install <tool>` automatically before re-running the command).

External tools are registered in the unified command registry under the canonical `ext` namespace:

```bash
opencli ext gh pr list --limit 5
opencli ext docker ps
```

For convenience, curated top-level aliases still work:

```bash
opencli gh pr list --limit 5
opencli docker ps
```

| External CLI | Description | Example |
|--------------|-------------|---------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
Expand Down
16 changes: 15 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

OpenCLI 将任何网站、本地 CLI 或 Electron 应用(如 Antigravity)变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube,以及 `gh`、`docker` 等[多种站点与工具](#内置命令) — 复用浏览器登录态,AI 驱动探索。

**专为 AI Agent 打造**:只需在全局 `.cursorrules` 或 `AGENT.md` 中配置简单指令,引导 AI 通过 Bash 执行 `opencli list` 来检索可用的 CLI 工具及其用法。随后,将你常用的 CLI 列表整合注册进去(`opencli register mycli`),AI 便能瞬间学会自动调用相应的本地工具
**专为 AI Agent 打造**:只需在全局 `.cursorrules` 或 `AGENT.md` 中配置简单指令,引导 AI 通过 Bash 执行 `opencli list` 来检索可用的 CLI 工具及其用法。随后,将你常用的 CLI 列表整合注册进去(`opencli register mycli`),AI 便能瞬间学会自动调用相应的本地工具。外部工具现在有统一的 `opencli ext <tool> ...` 命名空间,同时继续兼容 `opencli gh ...` 这类顶层 alias。

**opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!**
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
Expand Down Expand Up @@ -194,6 +194,20 @@ npm install -g @jackwener/opencli@latest

OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、自动安装和纯透传执行。

外部 CLI 现在会以统一的 `ext` 命名空间注册进命令系统:

```bash
opencli ext gh pr list --limit 5
opencli ext docker ps
```

为了兼容已有习惯,常用工具的顶层 alias 依然保留:

```bash
opencli gh pr list --limit 5
opencli docker ps
```

| 外部 CLI | 描述 | 示例 |
|----------|------|------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
Expand Down
6 changes: 3 additions & 3 deletions docs/developer/architecture.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Architecture

OpenCLI is built on a **Dual-Engine Architecture** that supports both declarative YAML pipelines and programmatic TypeScript adapters.
OpenCLI is built on a **Dual-Engine Architecture** with a unified command registry. Built-in adapters, plugins, and external CLI passthrough commands all register into the same command model, while using different execution backends.

## High-Level Architecture

Expand Down Expand Up @@ -32,13 +32,13 @@ OpenCLI is built on a **Dual-Engine Architecture** that supports both declarativ
## Core Modules

### Registry (`src/registry.ts`)
Central command registry. All adapters register their commands via the `cli()` function with metadata: site, name, description, domain, strategy, args, columns.
Central command registry. Built-in adapters, plugins, and external CLI passthrough commands register here with shared metadata: site, name, description, strategy, args, execution backend, aliases, and optional binary metadata.

### Discovery (`src/discovery.ts`)
CLI discovery and manifest loading. Discovers commands from YAML and TypeScript adapter files, parses YAML pipelines, and registers them into the central registry.

### Execution (`src/execution.ts`)
Command execution: argument validation, lazy loading of adapter modules, and executing the appropriate handler function.
Command execution: argument validation, lazy loading of adapter modules, and dispatching to the appropriate execution backend (`adapter` or `external-binary`).

### Commander Adapter (`src/commanderAdapter.ts`)
Bridges the Registry commands to Commander.js subcommands. Handles positional args, named options, browser session wiring, and output formatting. Isolates all Commander-specific logic so the core is framework-agnostic.
Expand Down
48 changes: 26 additions & 22 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import { render as renderOutput } from './output.js';
import { getBrowserFactory, browserSession } from './runtime.js';
import { PKG_VERSION } from './version.js';
import { printCompletionScript } from './completion.js';
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
import { EXTERNAL_SITE, loadExternalClis, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
import { registerAllCommands } from './commanderAdapter.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';
import { executeCommand } from './execution.js';

export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
const program = new Command();
Expand All @@ -37,6 +38,8 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
.action((opts) => {
const registry = getRegistry();
const commands = [...registry.values()].sort((a, b) => fullName(a).localeCompare(fullName(b)));
const externalCommands = commands.filter((cmd) => cmd.execution === 'external-binary');
const registryCommands = commands.filter((cmd) => cmd.execution !== 'external-binary');
const fmt = opts.json && opts.format === 'table' ? 'json' : opts.format;
const isStructured = fmt === 'json' || fmt === 'yaml';

Expand Down Expand Up @@ -74,29 +77,26 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.log(chalk.bold(' opencli') + chalk.dim(' — available commands'));
console.log();
for (const [site, cmds] of sites) {
console.log(chalk.bold.cyan(` ${site}`));
const siteLabel = site === EXTERNAL_SITE ? `${site} ${chalk.dim('(external)')}` : site;
console.log(chalk.bold.cyan(` ${siteLabel}`));
for (const cmd of cmds) {
const label = strategyLabel(cmd);
const tag = label === 'public'
? chalk.green('[public]')
: chalk.yellow(`[${label}]`);
console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`);
}
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}`) : ''}`);
let tag: string;
if (cmd.execution === 'external-binary' && cmd.externalCli) {
const installed = isBinaryInstalled(cmd.externalCli.binary);
tag = installed ? chalk.green('[installed]') : chalk.yellow('[auto-install]');
} else {
const label = strategyLabel(cmd);
tag = label === 'public'
? chalk.green('[public]')
: chalk.yellow(`[${label}]`);
}
const aliasNote = cmd.aliases?.length ? chalk.dim(` · alias: ${cmd.aliases.join(', ')}`) : '';
console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}${aliasNote}`);
}
console.log();
}

console.log(chalk.dim(` ${commands.length} built-in commands across ${sites.size} sites, ${externalClis.length} external CLIs`));
console.log(chalk.dim(` ${registryCommands.length} adapter/plugin commands across ${sites.size - (sites.has(EXTERNAL_SITE) ? 1 : 0)} sites, ${externalCommands.length} external CLIs`));
console.log();
});

Expand Down Expand Up @@ -471,13 +471,17 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
registerExternalCli(name, { binary: opts.binary, install: opts.install, description: opts.desc });
});

function passthroughExternal(name: string, parsedArgs?: string[]) {
async function passthroughExternal(name: string, parsedArgs?: string[]) {
const args = parsedArgs ?? (() => {
const idx = process.argv.indexOf(name);
return process.argv.slice(idx + 1);
})();
try {
executeExternalCli(name, args, externalClis);
const cmd = getRegistry().get(`${EXTERNAL_SITE}/${name}`);
if (!cmd) {
throw new Error(`External CLI '${name}' not found in registry.`);
}
await executeCommand(cmd, { args });
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
Expand All @@ -493,7 +497,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
.allowUnknownOption()
.passThroughOptions()
.helpOption(false)
.action((args: string[]) => passthroughExternal(ext.name, args));
.action(async (args: string[]) => passthroughExternal(ext.name, args));
}

// ── Antigravity serve (long-running, special case) ────────────────────────
Expand Down
34 changes: 34 additions & 0 deletions src/commanderAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,37 @@ describe('commanderAdapter boolean alias support', () => {
expect(kwargs.undo).toBe(false);
});
});

describe('commanderAdapter external passthrough support', () => {
const cmd: CliCommand = {
site: 'ext',
name: 'gh',
description: 'GitHub CLI',
browser: false,
args: [{ name: 'args', positional: true, variadic: true, help: 'Raw args' }],
execution: 'external-binary',
passthrough: true,
externalCli: { name: 'gh', binary: 'gh' },
};

beforeEach(() => {
mockExecuteCommand.mockReset();
mockExecuteCommand.mockResolvedValue(null);
mockRenderOutput.mockReset();
delete process.env.OPENCLI_VERBOSE;
process.exitCode = undefined;
});

it('forwards raw variadic args without rendering opencli output', async () => {
const program = new Command().enablePositionalOptions();
const siteCmd = program.command('ext');
registerCommandToProgram(siteCmd, cmd);

await program.parseAsync(['node', 'opencli', 'ext', 'gh', 'pr', 'list', '--limit', '5']);

expect(mockExecuteCommand).toHaveBeenCalled();
const kwargs = mockExecuteCommand.mock.calls[0][1];
expect(kwargs.args).toEqual(['pr', 'list', '--limit', '5']);
expect(mockRenderOutput).not.toHaveBeenCalled();
});
});
31 changes: 20 additions & 11 deletions src/commanderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi

const deprecatedSuffix = cmd.deprecated ? ' [deprecated]' : '';
const subCmd = siteCmd.command(cmd.name).description(`${cmd.description}${deprecatedSuffix}`);
const isPassthrough = cmd.passthrough === true && cmd.execution === 'external-binary';

// Register positional args first, then named options
const positionalArgs: typeof cmd.args = [];
for (const arg of cmd.args) {
if (arg.positional) {
const bracket = arg.required ? `<${arg.name}>` : `[${arg.name}]`;
const label = arg.variadic ? `${arg.name}...` : arg.name;
const bracket = arg.required ? `<${label}>` : `[${label}]`;
subCmd.argument(bracket, arg.help ?? '');
positionalArgs.push(arg);
} else {
Expand All @@ -67,9 +69,13 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
else subCmd.option(flag, arg.help ?? '');
}
}
subCmd
.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
.option('-v, --verbose', 'Debug output', false);
if (isPassthrough) {
subCmd.allowUnknownOption().passThroughOptions().helpOption(false);
} else {
subCmd
.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
.option('-v, --verbose', 'Debug output', false);
}

subCmd.addHelpText('after', formatRegistryHelpText(cmd));

Expand All @@ -86,15 +92,17 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
const v = actionArgs[i];
if (v !== undefined) kwargs[positionalArgs[i].name] = v;
}
for (const arg of cmd.args) {
if (arg.positional) continue;
const camelName = arg.name.replace(/-([a-z])/g, (_m, ch: string) => ch.toUpperCase());
const v = optionsRecord[arg.name] ?? optionsRecord[camelName];
if (v !== undefined) kwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name);
if (!isPassthrough) {
for (const arg of cmd.args) {
if (arg.positional) continue;
const camelName = arg.name.replace(/-([a-z])/g, (_m, ch: string) => ch.toUpperCase());
const v = optionsRecord[arg.name] ?? optionsRecord[camelName];
if (v !== undefined) kwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name);
}
}

const verbose = optionsRecord.verbose === true;
const format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
const verbose = !isPassthrough && optionsRecord.verbose === true;
const format = !isPassthrough && typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
if (verbose) process.env.OPENCLI_VERBOSE = '1';
if (cmd.deprecated) {
const message = typeof cmd.deprecated === 'string' ? cmd.deprecated : `${fullName(cmd)} is deprecated.`;
Expand All @@ -103,6 +111,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
}

const result = await executeCommand(cmd, kwargs, verbose);
if (isPassthrough) return;

if (verbose && (!result || (Array.isArray(result) && result.length === 0))) {
console.error(chalk.yellow('[Verbose] Warning: Command returned an empty result.'));
Expand Down
27 changes: 27 additions & 0 deletions src/completion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { getCompletions } from './completion.js';
import { registerCommand } from './registry.js';
import { buildExternalCliCommand } from './external.js';

describe('completion external CLI support', () => {
it('offers ext and top-level aliases on the first argument', () => {
registerCommand(buildExternalCliCommand({
name: 'gh',
binary: 'gh',
description: 'GitHub CLI',
}));

const completions = getCompletions([], 1);
expect(completions).toContain('ext');
expect(completions).toContain('gh');
});

it('offers external tools as subcommands under ext', () => {
const completions = getCompletions(['ext'], 2);
expect(completions).toContain('gh');
});

it('stops completion after a top-level external alias', () => {
expect(getCompletions(['gh'], 2)).toEqual([]);
});
});
16 changes: 12 additions & 4 deletions src/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,34 @@ const BUILTIN_COMMANDS = [
* @param cursor - 1-based position of the word being completed (1 = first arg)
*/
export function getCompletions(words: string[], cursor: number): string[] {
const registry = [...getRegistry().values()];
const externalAliases = new Set<string>();
for (const cmd of registry) {
if (cmd.execution === 'external-binary') {
for (const alias of cmd.aliases ?? []) externalAliases.add(alias);
}
}

// cursor === 1 → completing the first argument (site name or built-in command)
if (cursor <= 1) {
const sites = new Set<string>();
for (const [, cmd] of getRegistry()) {
for (const cmd of registry) {
sites.add(cmd.site);
}
return [...BUILTIN_COMMANDS, ...sites].sort();
return [...BUILTIN_COMMANDS, ...sites, ...externalAliases].sort();
}

const site = words[0];

// If the first word is a built-in command, no further completion
if (BUILTIN_COMMANDS.includes(site)) {
if (BUILTIN_COMMANDS.includes(site) || externalAliases.has(site)) {
return [];
}

// cursor === 2 → completing the sub-command name under a site
if (cursor === 2) {
const subcommands: string[] = [];
for (const [, cmd] of getRegistry()) {
for (const cmd of registry) {
if (cmd.site === site) {
subcommands.push(cmd.name);
}
Expand Down
8 changes: 8 additions & 0 deletions src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMM
import { emitHook, type HookContext } from './hooks.js';
import { checkDaemonStatus } from './browser/discover.js';
import { log } from './logger.js';
import { executeExternalCliConfig } from './external.js';

const _loadedModules = new Set<string>();

Expand Down Expand Up @@ -100,6 +101,13 @@ async function runCommand(

if (cmd.func) return cmd.func(page as IPage, kwargs, debug);
if (cmd.pipeline) return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
if (cmd.execution === 'external-binary' && cmd.externalCli) {
const args = Array.isArray(kwargs.args)
? kwargs.args.map((arg) => String(arg))
: [];
executeExternalCliConfig(cmd.externalCli, args);
return null;
}
throw new CommandExecutionError(
`Command ${fullName(cmd)} has no func or pipeline`,
'This is likely a bug in the adapter definition. Please report this issue.',
Expand Down
Loading
Loading