diff --git a/README.md b/README.md index b58be735..03702851 100644 --- a/README.md +++ b/README.md @@ -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 ...` 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 ` 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` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 2c4de509..1c0c16af 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 ...` 命名空间,同时继续兼容 `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` | diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 52d9b429..a6a429c0 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -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. diff --git a/src/cli.ts b/src/cli.ts index fe358144..056b1074 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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}`) : ''}`); - } - 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(); }); @@ -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) ──────────────────────── diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index 6d79fce5..e29c8067 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -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(); + }); +}); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 66204848..882ad1aa 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -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 ', '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 ', '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.')); diff --git a/src/completion.test.ts b/src/completion.test.ts new file mode 100644 index 00000000..e6943ecf --- /dev/null +++ b/src/completion.test.ts @@ -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([]); + }); +}); diff --git a/src/completion.ts b/src/completion.ts index 00c031ae..3d563434 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -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(); + 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(); - 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); } diff --git a/src/execution.ts b/src/execution.ts index ab0b5e1b..806cc258 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -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(); @@ -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.', diff --git a/src/external.ts b/src/external.ts index 72939e8c..ab4ec426 100644 --- a/src/external.ts +++ b/src/external.ts @@ -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; +} diff --git a/src/main.ts b/src/main.ts index 7115fcb7..a4cca07b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(); diff --git a/src/registry.ts b/src/registry.ts index c7363c52..4c16b18b 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -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, diff --git a/src/serialization.test.ts b/src/serialization.test.ts index 2bcdfd3c..3c82b1a0 100644 --- a/src/serialization.test.ts +++ b/src/serialization.test.ts @@ -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'); + }); }); diff --git a/src/serialization.ts b/src/serialization.ts index c28ab671..cdb6a066 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -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(' | '));