feat(external): register passthrough CLIs under ext namespace
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+15
-1
@@ -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 化,从而组合出各种神奇的能力。
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+26
-22
@@ -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();
|
||||
@@ -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';
|
||||
|
||||
@@ -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}`) : ''}`);
|
||||
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();
|
||||
}
|
||||
|
||||
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(chalk.dim(` ${registryCommands.length} adapter/plugin commands across ${sites.size - (sites.has(EXTERNAL_SITE) ? 1 : 0)} sites, ${externalCommands.length} external CLIs`));
|
||||
console.log();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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) ────────────────────────
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+20
-11
@@ -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 {
|
||||
@@ -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));
|
||||
|
||||
@@ -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.`;
|
||||
@@ -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.'));
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+12
-4
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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.',
|
||||
|
||||
+42
-8
@@ -7,8 +7,10 @@ import yaml from 'js-yaml';
|
||||
import chalk from 'chalk';
|
||||
import { log } from './logger.js';
|
||||
import { EXIT_CODES, getErrorMessage } from './errors.js';
|
||||
import { registerCommand, Strategy, type CliCommand } from './registry.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const EXTERNAL_SITE = 'ext';
|
||||
|
||||
export interface ExternalCliInstall {
|
||||
mac?: string;
|
||||
@@ -168,13 +170,7 @@ export function installExternalCli(cli: ExternalCliConfig): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): void {
|
||||
const configs = preloaded ?? loadExternalClis();
|
||||
const cli = configs.find((c) => c.name === name);
|
||||
if (!cli) {
|
||||
throw new Error(`External CLI '${name}' not found in registry.`);
|
||||
}
|
||||
|
||||
export function executeExternalCliConfig(cli: ExternalCliConfig, args: string[]): void {
|
||||
// 1. Check if installed
|
||||
if (!isBinaryInstalled(cli.binary)) {
|
||||
// 2. Try to auto install
|
||||
@@ -192,12 +188,21 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (result.status !== null) {
|
||||
process.exitCode = result.status;
|
||||
}
|
||||
}
|
||||
|
||||
export function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): void {
|
||||
const configs = preloaded ?? loadExternalClis();
|
||||
const cli = configs.find((c) => c.name === name);
|
||||
if (!cli) {
|
||||
throw new Error(`External CLI '${name}' not found in registry.`);
|
||||
}
|
||||
executeExternalCliConfig(cli, args);
|
||||
}
|
||||
|
||||
export interface RegisterOptions {
|
||||
binary?: string;
|
||||
install?: string;
|
||||
@@ -244,3 +249,32 @@ export function registerExternalCli(name: string, opts?: RegisterOptions): void
|
||||
_cachedExternalClis = null; // Invalidate cache so next load reflects the change
|
||||
console.log(chalk.dim(userPath));
|
||||
}
|
||||
|
||||
export function buildExternalCliCommand(cli: ExternalCliConfig): CliCommand {
|
||||
return {
|
||||
site: EXTERNAL_SITE,
|
||||
name: cli.name,
|
||||
description: cli.description ?? `Passthrough to ${cli.binary}`,
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{
|
||||
name: 'args',
|
||||
positional: true,
|
||||
variadic: true,
|
||||
help: `Arguments passed through to ${cli.binary}`,
|
||||
},
|
||||
],
|
||||
source: 'external-registry',
|
||||
execution: 'external-binary',
|
||||
passthrough: true,
|
||||
aliases: [cli.name],
|
||||
externalCli: cli,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerExternalCliCommands(configs: ExternalCliConfig[] = loadExternalClis()): CliCommand[] {
|
||||
const commands = configs.map(buildExternalCliCommand);
|
||||
for (const cmd of commands) registerCommand(cmd);
|
||||
return commands;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { emitHook } from './hooks.js';
|
||||
import { installNodeNetwork } from './node-network.js';
|
||||
import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
|
||||
import { EXIT_CODES } from './errors.js';
|
||||
import { registerExternalCliCommands } from './external.js';
|
||||
|
||||
installNodeNetwork();
|
||||
|
||||
@@ -34,6 +35,7 @@ const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
|
||||
// Sequential: plugins must run after built-in discovery so they can override built-in commands.
|
||||
await discoverClis(BUILTIN_CLIS, USER_CLIS);
|
||||
await discoverPlugins();
|
||||
registerExternalCliCommands();
|
||||
|
||||
// Register exit hook: notice appears after command output (same as npm/gh/yarn)
|
||||
registerUpdateNoticeOnExit();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Core registry: Strategy enum, Arg/CliCommand interfaces, cli() registration.
|
||||
*/
|
||||
|
||||
import type { ExternalCliConfig } from './external.js';
|
||||
import type { IPage } from './types.js';
|
||||
|
||||
export enum Strategy {
|
||||
@@ -18,6 +19,7 @@ export interface Arg {
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
positional?: boolean;
|
||||
variadic?: boolean;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
}
|
||||
@@ -46,6 +48,14 @@ export interface CliCommand {
|
||||
source?: string;
|
||||
footerExtra?: (kwargs: CommandArgs) => string | undefined;
|
||||
requiredEnv?: RequiredEnv[];
|
||||
/** Execution backend used to run this command. */
|
||||
execution?: 'adapter' | 'external-binary';
|
||||
/** Whether Commander should forward raw args to the underlying executor. */
|
||||
passthrough?: boolean;
|
||||
/** Additional top-level aliases that resolve to this command. */
|
||||
aliases?: string[];
|
||||
/** Backing external CLI config for passthrough commands. */
|
||||
externalCli?: ExternalCliConfig;
|
||||
/** Deprecation note shown in help / execution warnings. */
|
||||
deprecated?: boolean | string;
|
||||
/** Preferred replacement command, if any. */
|
||||
@@ -99,6 +109,10 @@ export function cli(opts: CliOptions): CliCommand {
|
||||
timeoutSeconds: opts.timeoutSeconds,
|
||||
footerExtra: opts.footerExtra,
|
||||
requiredEnv: opts.requiredEnv,
|
||||
execution: opts.execution,
|
||||
passthrough: opts.passthrough,
|
||||
aliases: opts.aliases,
|
||||
externalCli: opts.externalCli,
|
||||
deprecated: opts.deprecated,
|
||||
replacedBy: opts.replacedBy,
|
||||
navigateBefore: opts.navigateBefore,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CliCommand } from './registry.js';
|
||||
import { Strategy } from './registry.js';
|
||||
import { formatRegistryHelpText } from './serialization.js';
|
||||
import { formatRegistryHelpText, serializeCommand } from './serialization.js';
|
||||
|
||||
describe('formatRegistryHelpText', () => {
|
||||
it('summarizes long choices lists so help text stays readable', () => {
|
||||
@@ -23,4 +23,33 @@ describe('formatRegistryHelpText', () => {
|
||||
|
||||
expect(formatRegistryHelpText(cmd)).toContain('--field: all-fields, topic, title, author, ... (+3 more)');
|
||||
});
|
||||
|
||||
it('includes execution metadata for external passthrough commands', () => {
|
||||
const cmd: CliCommand = {
|
||||
site: 'ext',
|
||||
name: 'gh',
|
||||
description: 'GitHub CLI',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [{ name: 'args', positional: true, variadic: true }],
|
||||
execution: 'external-binary',
|
||||
passthrough: true,
|
||||
aliases: ['gh'],
|
||||
externalCli: {
|
||||
name: 'gh',
|
||||
binary: 'gh',
|
||||
description: 'GitHub CLI',
|
||||
homepage: 'https://cli.github.com',
|
||||
tags: ['github'],
|
||||
},
|
||||
};
|
||||
|
||||
const serialized = serializeCommand(cmd);
|
||||
expect(serialized.execution).toBe('external-binary');
|
||||
expect(serialized.passthrough).toBe(true);
|
||||
expect(serialized.aliases).toEqual(['gh']);
|
||||
expect(serialized.binary).toBe('gh');
|
||||
expect(formatRegistryHelpText(cmd)).toContain('Execution: external-binary');
|
||||
expect(formatRegistryHelpText(cmd)).toContain('Passthrough: yes');
|
||||
});
|
||||
});
|
||||
|
||||
+16
-1
@@ -15,6 +15,7 @@ export type SerializedArg = {
|
||||
type: string;
|
||||
required: boolean;
|
||||
positional: boolean;
|
||||
variadic: boolean;
|
||||
choices: string[];
|
||||
default: unknown;
|
||||
help: string;
|
||||
@@ -27,6 +28,7 @@ export function serializeArg(a: Arg): SerializedArg {
|
||||
type: a.type ?? 'string',
|
||||
required: !!a.required,
|
||||
positional: !!a.positional,
|
||||
variadic: !!a.variadic,
|
||||
choices: a.choices ?? [],
|
||||
default: a.default ?? null,
|
||||
help: a.help ?? '',
|
||||
@@ -45,6 +47,12 @@ export function serializeCommand(cmd: CliCommand) {
|
||||
args: cmd.args.map(serializeArg),
|
||||
columns: cmd.columns ?? [],
|
||||
domain: cmd.domain ?? null,
|
||||
execution: cmd.execution ?? 'adapter',
|
||||
passthrough: !!cmd.passthrough,
|
||||
aliases: cmd.aliases ?? [],
|
||||
binary: cmd.externalCli?.binary ?? null,
|
||||
homepage: cmd.externalCli?.homepage ?? null,
|
||||
tags: cmd.externalCli?.tags ?? [],
|
||||
deprecated: cmd.deprecated ?? null,
|
||||
replacedBy: cmd.replacedBy ?? null,
|
||||
};
|
||||
@@ -56,7 +64,10 @@ export function serializeCommand(cmd: CliCommand) {
|
||||
export function formatArgSummary(args: Arg[]): string {
|
||||
return args
|
||||
.map(a => {
|
||||
if (a.positional) return a.required ? `<${a.name}>` : `[${a.name}]`;
|
||||
if (a.positional) {
|
||||
const label = a.variadic ? `${a.name}...` : a.name;
|
||||
return a.required ? `<${label}>` : `[${label}]`;
|
||||
}
|
||||
return a.required ? `--${a.name}` : `[--${a.name}]`;
|
||||
})
|
||||
.join(' ');
|
||||
@@ -79,7 +90,11 @@ export function formatRegistryHelpText(cmd: CliCommand): string {
|
||||
const meta: string[] = [];
|
||||
meta.push(`Strategy: ${strategyLabel(cmd)}`);
|
||||
meta.push(`Browser: ${cmd.browser ? 'yes' : 'no'}`);
|
||||
meta.push(`Execution: ${cmd.execution ?? 'adapter'}`);
|
||||
if (cmd.passthrough) meta.push('Passthrough: yes');
|
||||
if (cmd.externalCli?.binary) meta.push(`Binary: ${cmd.externalCli.binary}`);
|
||||
if (cmd.domain) meta.push(`Domain: ${cmd.domain}`);
|
||||
if (cmd.aliases?.length) meta.push(`Aliases: ${cmd.aliases.join(', ')}`);
|
||||
if (cmd.deprecated) meta.push(`Deprecated: ${typeof cmd.deprecated === 'string' ? cmd.deprecated : 'yes'}`);
|
||||
if (cmd.replacedBy) meta.push(`Use instead: ${cmd.replacedBy}`);
|
||||
lines.push(meta.join(' | '));
|
||||
|
||||
Reference in New Issue
Block a user