From de962eb5fbd57aa6c9dcf6e48f30e095b5e65c5e Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 17 Mar 2026 16:33:19 +0800 Subject: [PATCH] feat: support commands completion (#32) Add full shell tab-completion for opencli, supporting Bash, Zsh, and Fish. Co-authored-by: RinChanNOWWW Co-authored-by: Claude --- package-lock.json | 11 +-- package.json | 1 + scripts/postinstall.js | 179 +++++++++++++++++++++++++++++++++++++++++ src/completion.ts | 129 +++++++++++++++++++++++++++++ src/main.ts | 29 +++++++ 5 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 scripts/postinstall.js create mode 100644 src/completion.ts diff --git a/package-lock.json b/package-lock.json index 53e645e..95b3183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "@jackwener/opencli", - "version": "0.7.5", + "version": "0.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackwener/opencli", - "version": "0.7.5", - "license": "BSD-3-Clause", + "version": "0.7.4", + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { "chalk": "^5.3.0", "cli-table3": "^0.6.5", @@ -894,7 +895,6 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1563,7 +1563,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1806,7 +1805,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -1848,7 +1846,6 @@ "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", diff --git a/package.json b/package.json index f0db161..d18f0f9 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "clean-yaml": "node -e \"const{readdirSync:r,rmSync:d,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(dir){if(!e(dir))return;for(const f of r(dir)){const fp=p.join(dir,f);s(fp).isDirectory()?w(fp):/\\.ya?ml$/.test(f)&&d(fp)}}w('dist/clis')\"", "copy-yaml": "node -e \"const{readdirSync:r,copyFileSync:c,mkdirSync:m,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(src,dst){if(!e(src))return;for(const f of r(src)){const sp=p.join(src,f),dp=p.join(dst,f);s(sp).isDirectory()?w(sp,dp):/\\.ya?ml$/.test(f)&&(m(p.dirname(dp),{recursive:!0}),c(sp,dp))}}w('src/clis','dist/clis')\"", "start": "node dist/main.js", + "postinstall": "node scripts/postinstall.js || true", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit", "prepublishOnly": "npm run build", diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 0000000..7e955d5 --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +/** + * postinstall script — automatically install shell completion files. + * + * Detects the user's default shell and writes the completion script to the + * standard system completion directory so that tab-completion works immediately + * after `npm install -g`. + * + * Supported shells: bash, zsh, fish. + * + * This script is intentionally plain Node.js (no TypeScript, no imports from + * the main source tree) so that it can run without a build step. + */ + +import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + + +// ── Completion script content ────────────────────────────────────────────── + +const BASH_COMPLETION = `# Bash completion for opencli (auto-installed) +_opencli_completions() { + local cur words cword + _get_comp_words_by_ref -n : cur words cword + + local completions + completions=$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null) + + COMPREPLY=( $(compgen -W "$completions" -- "$cur") ) + __ltrim_colon_completions "$cur" +} +complete -F _opencli_completions opencli +`; + +const ZSH_COMPLETION = `#compdef opencli +# Zsh completion for opencli (auto-installed) +_opencli() { + local -a completions + local cword=$((CURRENT - 1)) + completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"}) + compadd -a completions +} +_opencli +`; + +const FISH_COMPLETION = `# Fish completion for opencli (auto-installed) +complete -c opencli -f -a '( + set -l tokens (commandline -cop) + set -l cursor (count (commandline -cop)) + opencli --get-completions --cursor $cursor $tokens[2..] 2>/dev/null +)' +`; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function detectShell() { + const shell = process.env.SHELL || ''; + if (shell.includes('zsh')) return 'zsh'; + if (shell.includes('bash')) return 'bash'; + if (shell.includes('fish')) return 'fish'; + return null; +} + +function ensureDir(dir) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +/** + * Ensure fpath contains the custom completions directory in .zshrc + */ +function ensureZshFpath(completionsDir, zshrcPath) { + const fpathLine = `fpath=(${completionsDir} $fpath)`; + const autoloadLine = `autoload -Uz compinit && compinit`; + + if (!existsSync(zshrcPath)) { + writeFileSync(zshrcPath, `${fpathLine}\n${autoloadLine}\n`, 'utf8'); + return; + } + + const content = readFileSync(zshrcPath, 'utf8'); + + // Check if completions dir is already in fpath + if (content.includes(completionsDir)) { + return; // already configured + } + + // Append fpath configuration + let addition = `\n# opencli completion\n${fpathLine}\n`; + if (!content.includes('compinit')) { + addition += `${autoloadLine}\n`; + } + appendFileSync(zshrcPath, addition, 'utf8'); +} + +// ── Main ─────────────────────────────────────────────────────────────────── + +function main() { + // Skip in CI environments + if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) { + return; + } + + // Only install completion for global installs and npm link + const isGlobal = process.env.npm_config_global === 'true'; + if (!isGlobal) { + return; + } + + const shell = detectShell(); + if (!shell) { + // Cannot determine shell; silently skip + return; + } + + const home = homedir(); + + try { + switch (shell) { + case 'zsh': { + const completionsDir = join(home, '.zsh', 'completions'); + const completionFile = join(completionsDir, '_opencli'); + ensureDir(completionsDir); + writeFileSync(completionFile, ZSH_COMPLETION, 'utf8'); + + // Ensure fpath is set up in .zshrc + const zshrcPath = join(home, '.zshrc'); + ensureZshFpath(completionsDir, zshrcPath); + + console.log(`✓ Zsh completion installed to ${completionFile}`); + console.log(` Restart your shell or run: source ~/.zshrc`); + break; + } + case 'bash': { + // Try system-level first, fall back to user-level + const userCompDir = join(home, '.bash_completion.d'); + const completionFile = join(userCompDir, 'opencli'); + ensureDir(userCompDir); + writeFileSync(completionFile, BASH_COMPLETION, 'utf8'); + + // Ensure .bashrc sources the completion directory + const bashrcPath = join(home, '.bashrc'); + if (existsSync(bashrcPath)) { + const content = readFileSync(bashrcPath, 'utf8'); + if (!content.includes('.bash_completion.d/opencli')) { + appendFileSync(bashrcPath, + `\n# opencli completion\n[ -f "${completionFile}" ] && source "${completionFile}"\n`, + 'utf8' + ); + } + } + + console.log(`✓ Bash completion installed to ${completionFile}`); + console.log(` Restart your shell or run: source ~/.bashrc`); + break; + } + case 'fish': { + const completionsDir = join(home, '.config', 'fish', 'completions'); + const completionFile = join(completionsDir, 'opencli.fish'); + ensureDir(completionsDir); + writeFileSync(completionFile, FISH_COMPLETION, 'utf8'); + + console.log(`✓ Fish completion installed to ${completionFile}`); + console.log(` Restart your shell to activate.`); + break; + } + } + } catch (err) { + // Completion install is best-effort; never fail the package install + if (process.env.OPENCLI_VERBOSE) { + console.error(`Warning: Could not install shell completion: ${err.message}`); + } + } +} + +main(); diff --git a/src/completion.ts b/src/completion.ts new file mode 100644 index 0000000..bdfbecb --- /dev/null +++ b/src/completion.ts @@ -0,0 +1,129 @@ +/** + * Shell tab-completion support for opencli. + * + * Provides: + * - Shell script generators for bash, zsh, and fish + * - Dynamic completion logic that returns candidates for the current cursor position + */ + +import { getRegistry } from './registry.js'; + +// ── Dynamic completion logic ─────────────────────────────────────────────── + +/** + * Built-in (non-dynamic) top-level commands. + */ +const BUILTIN_COMMANDS = [ + 'list', + 'validate', + 'verify', + 'explore', + 'probe', // alias for explore + 'synthesize', + 'generate', + 'cascade', + 'doctor', + 'setup', + 'completion', +]; + +/** + * Return completion candidates given the current command-line words and cursor index. + * + * @param words - The argv after 'opencli' (words[0] is the first arg, e.g. site name) + * @param cursor - 1-based position of the word being completed (1 = first arg) + */ +export function getCompletions(words: string[], cursor: number): string[] { + // cursor === 1 → completing the first argument (site name or built-in command) + if (cursor <= 1) { + const sites = new Set(); + for (const [, cmd] of getRegistry()) { + sites.add(cmd.site); + } + return [...BUILTIN_COMMANDS, ...sites].sort(); + } + + const site = words[0]; + + // If the first word is a built-in command, no further completion + if (BUILTIN_COMMANDS.includes(site)) { + return []; + } + + // cursor === 2 → completing the sub-command name under a site + if (cursor === 2) { + const subcommands: string[] = []; + for (const [, cmd] of getRegistry()) { + if (cmd.site === site) { + subcommands.push(cmd.name); + } + } + return subcommands.sort(); + } + + // cursor >= 3 → no further completion + return []; +} + +// ── Shell script generators ──────────────────────────────────────────────── + +export function bashCompletionScript(): string { + return `# Bash completion for opencli +# Add to ~/.bashrc: eval "$(opencli completion bash)" +_opencli_completions() { + local cur words cword + _get_comp_words_by_ref -n : cur words cword + + local completions + completions=$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null) + + COMPREPLY=( $(compgen -W "$completions" -- "$cur") ) + __ltrim_colon_completions "$cur" +} +complete -F _opencli_completions opencli +`; +} + +export function zshCompletionScript(): string { + return `# Zsh completion for opencli +# Add to ~/.zshrc: eval "$(opencli completion zsh)" +_opencli() { + local -a completions + local cword=$((CURRENT - 1)) + completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"}) + compadd -a completions +} +compdef _opencli opencli +`; +} + +export function fishCompletionScript(): string { + return `# Fish completion for opencli +# Add to ~/.config/fish/config.fish: opencli completion fish | source +complete -c opencli -f -a '( + set -l tokens (commandline -cop) + set -l cursor (count (commandline -cop)) + opencli --get-completions --cursor $cursor $tokens[2..] 2>/dev/null +)' +`; +} + +/** + * Print the completion script for the requested shell. + */ +export function printCompletionScript(shell: string): void { + switch (shell) { + case 'bash': + process.stdout.write(bashCompletionScript()); + break; + case 'zsh': + process.stdout.write(zshCompletionScript()); + break; + case 'fish': + process.stdout.write(fishCompletionScript()); + break; + default: + console.error(`Unsupported shell: ${shell}. Supported: bash, zsh, fish`); + process.exitCode = 1; + } +} diff --git a/src/main.ts b/src/main.ts index cdf71dc..30c68fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,7 @@ import { render as renderOutput } from './output.js'; import { PlaywrightMCP } from './browser.js'; import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js'; import { PKG_VERSION } from './version.js'; +import { getCompletions, printCompletionScript } from './completion.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -22,6 +23,27 @@ const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis'); await discoverClis(BUILTIN_CLIS, USER_CLIS); +// ── Fast-path: handle --get-completions before commander parses ───────── +// Usage: opencli --get-completions --cursor [word1 word2 ...] +const getCompIdx = process.argv.indexOf('--get-completions'); +if (getCompIdx !== -1) { + const rest = process.argv.slice(getCompIdx + 1); + let cursor: number | undefined; + const words: string[] = []; + for (let i = 0; i < rest.length; i++) { + if (rest[i] === '--cursor' && i + 1 < rest.length) { + cursor = parseInt(rest[i + 1], 10); + i++; // skip the value + } else { + words.push(rest[i]); + } + } + if (cursor === undefined) cursor = words.length; + const candidates = getCompletions(words, cursor); + process.stdout.write(candidates.join('\n') + '\n'); + process.exit(0); +} + const program = new Command(); program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION); @@ -128,6 +150,13 @@ program.command('setup') await runSetup({ cliVersion: PKG_VERSION, token: opts.token }); }); +program.command('completion') + .description('Output shell completion script') + .argument('', 'Shell type: bash, zsh, or fish') + .action((shell) => { + printCompletionScript(shell); + }); + // ── Dynamic site commands ────────────────────────────────────────────────── const registry = getRegistry();