main.ts
1 #!/usr/bin/env node 2 /** 3 * opencli — Make any website your CLI. AI-powered. 4 */ 5 6 // Ensure standard system paths are available for child processes. 7 // Some environments (GUI apps, cron, IDE terminals) launch with a minimal PATH 8 // that excludes /usr/local/bin, /usr/sbin, etc., causing external CLIs to fail. 9 if (process.platform !== 'win32') { 10 const std = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']; 11 const cur = new Set((process.env.PATH ?? '').split(':').filter(Boolean)); 12 for (const p of std) cur.add(p); 13 process.env.PATH = [...cur].join(':'); 14 } 15 16 import * as fs from 'node:fs'; 17 import * as os from 'node:os'; 18 import * as path from 'node:path'; 19 import { fileURLToPath } from 'node:url'; 20 import { getCompletionsFromManifest, hasAllManifests, printCompletionScriptFast } from './completion-fast.js'; 21 import { findPackageRoot, getCliManifestPath } from './package-paths.js'; 22 import { PKG_VERSION } from './version.js'; 23 import { EXIT_CODES } from './errors.js'; 24 25 const __filename = fileURLToPath(import.meta.url); 26 const __dirname = path.dirname(__filename); 27 // Adapters are JS-first and live at <package-root>/clis/. 28 // Use findPackageRoot so the path works both in dev (src/main.ts) and prod (dist/src/main.js). 29 const BUILTIN_CLIS = path.join(findPackageRoot(__filename), 'clis'); 30 const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis'); 31 32 // ── Session lifecycle flags ────────────────────────────────────────────── 33 // `--live` / `--focus` are top-level-ish toggles that tweak the automation 34 // window's lifecycle. We strip them from argv before Commander runs so they 35 // can be placed anywhere and work on any subcommand (adapter or browser). 36 { 37 const liveIdx = process.argv.indexOf('--live'); 38 if (liveIdx !== -1) { 39 process.env.OPENCLI_LIVE = '1'; 40 process.argv.splice(liveIdx, 1); 41 } 42 const focusIdx = process.argv.indexOf('--focus'); 43 if (focusIdx !== -1) { 44 process.env.OPENCLI_WINDOW_FOCUSED = '1'; 45 process.argv.splice(focusIdx, 1); 46 } 47 } 48 49 // ── Ultra-fast path: lightweight commands bypass full discovery ────────── 50 // These are high-frequency or trivial paths that must not pay the startup tax. 51 const argv = process.argv.slice(2); 52 53 // Fast path: --version (only when it's the top-level intent, not passed to a subcommand) 54 // e.g. `opencli --version` or `opencli -V`, but NOT `opencli gh --version` 55 if (argv[0] === '--version' || argv[0] === '-V') { 56 process.stdout.write(PKG_VERSION + '\n'); 57 process.exit(EXIT_CODES.SUCCESS); 58 } 59 60 // Fast path: completion <shell> — print shell script without discovery 61 if (argv[0] === 'completion' && argv.length >= 2) { 62 if (printCompletionScriptFast(argv[1])) { 63 process.exit(EXIT_CODES.SUCCESS); 64 } 65 // Unknown shell — fall through to full path for proper error handling 66 } 67 68 // Fast path: --get-completions — read from manifest, skip discovery 69 const getCompIdx = process.argv.indexOf('--get-completions'); 70 if (getCompIdx !== -1) { 71 // Only include manifests that actually exist on disk. 72 // With sparse override, the user clis dir may exist but have no manifest. 73 const manifestPaths = [getCliManifestPath(BUILTIN_CLIS)]; 74 const userManifest = getCliManifestPath(USER_CLIS); 75 try { fs.accessSync(userManifest); manifestPaths.push(userManifest); } catch { /* no user manifest */ } 76 if (hasAllManifests(manifestPaths)) { 77 const rest = process.argv.slice(getCompIdx + 1); 78 let cursor: number | undefined; 79 const words: string[] = []; 80 for (let i = 0; i < rest.length; i++) { 81 if (rest[i] === '--cursor' && i + 1 < rest.length) { 82 cursor = parseInt(rest[i + 1], 10); 83 i++; 84 } else { 85 words.push(rest[i]); 86 } 87 } 88 if (cursor === undefined) cursor = words.length; 89 const candidates = getCompletionsFromManifest(words, cursor, manifestPaths); 90 process.stdout.write(candidates.join('\n') + '\n'); 91 process.exit(EXIT_CODES.SUCCESS); 92 } 93 // No manifest — fall through to full discovery path below 94 } 95 96 // ── Full startup path ─────────────────────────────────────────────────── 97 // Dynamic imports: these are deferred so the fast path above never pays the cost. 98 const { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters } = await import('./discovery.js'); 99 const { getCompletions } = await import('./completion.js'); 100 const { runCli } = await import('./cli.js'); 101 const { emitHook } = await import('./hooks.js'); 102 const { installNodeNetwork } = await import('./node-network.js'); 103 const { registerUpdateNoticeOnExit, checkForUpdateBackground } = await import('./update-check.js'); 104 105 installNodeNetwork(); 106 107 // Parallelise independent startup I/O: 108 // - Built-in adapter discovery has no dependency on user-dir setup. 109 // - ensureUserCliCompatShims and ensureUserAdapters operate on different paths 110 // (~/.opencli/node_modules/ vs ~/.opencli/clis/ + adapter-manifest.json). 111 // - registerCommand() overwrites on name collision (see registry.ts), so 112 // user-CLI discovery MUST run after built-in discovery to preserve the 113 // intended override order (user adapters override built-in ones). 114 // - discoverPlugins runs last: plugins may override both built-in and user CLIs. 115 const [, ,] = await Promise.all([ 116 ensureUserCliCompatShims(), 117 ensureUserAdapters(), 118 discoverClis(BUILTIN_CLIS), 119 ]); 120 await discoverClis(USER_CLIS); 121 await discoverPlugins(); 122 123 // Register exit hook: notice appears after command output (same as npm/gh/yarn) 124 registerUpdateNoticeOnExit(); 125 // Kick off background fetch for next run (non-blocking) 126 checkForUpdateBackground(); 127 128 // ── Fallback completion: manifest unavailable, use full registry ───────── 129 if (getCompIdx !== -1) { 130 const rest = process.argv.slice(getCompIdx + 1); 131 let cursor: number | undefined; 132 const words: string[] = []; 133 for (let i = 0; i < rest.length; i++) { 134 if (rest[i] === '--cursor' && i + 1 < rest.length) { 135 cursor = parseInt(rest[i + 1], 10); 136 i++; 137 } else { 138 words.push(rest[i]); 139 } 140 } 141 if (cursor === undefined) cursor = words.length; 142 const candidates = getCompletions(words, cursor); 143 process.stdout.write(candidates.join('\n') + '\n'); 144 process.exit(EXIT_CODES.SUCCESS); 145 } 146 147 await emitHook('onStartup', { command: '__startup__', args: {} }); 148 runCli(BUILTIN_CLIS, USER_CLIS);