Skip to content
Merged
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
157 changes: 157 additions & 0 deletions src/completion-fast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Lightweight manifest-based completion for the fast path.
*
* This module MUST NOT import registry, discovery, or any heavy module.
* It only reads pre-compiled cli-manifest.json files synchronously.
*/

import * as fs from 'node:fs';

const BUILTIN_COMMANDS = [
'list',
'validate',
'verify',
'explore',
'probe',
'synthesize',
'generate',
'cascade',
'doctor',
'plugin',
'install',
'register',
'completion',
];

interface ManifestCompletionEntry {
site: string;
name: string;
aliases?: string[];
}

/**
* Returns true only if ALL manifest files exist and are readable.
* If any source lacks a manifest (e.g. user adapters without a compiled manifest),
* the fast path must not be used — otherwise those adapters would silently
* disappear from completion results.
*/
export function hasAllManifests(manifestPaths: string[]): boolean {
for (const p of manifestPaths) {
try {
fs.accessSync(p);
} catch {
return false;
}
}
return manifestPaths.length > 0;
}

/**
* Lightweight completion that reads directly from manifest JSON files,
* bypassing full CLI discovery and adapter loading.
*/
export function getCompletionsFromManifest(words: string[], cursor: number, manifestPaths: string[]): string[] {
const entries = loadManifestEntries(manifestPaths);
if (entries === null) {
return [];
}

if (cursor <= 1) {
const sites = new Set<string>();
for (const entry of entries) {
sites.add(entry.site);
}
return [...BUILTIN_COMMANDS, ...sites].sort();
}

const site = words[0];
if (BUILTIN_COMMANDS.includes(site)) {
return [];
}

if (cursor === 2) {
const subcommands: string[] = [];
for (const entry of entries) {
if (entry.site === site) {
subcommands.push(entry.name);
if (entry.aliases?.length) subcommands.push(...entry.aliases);
}
}
return [...new Set(subcommands)].sort();
}

return [];
}

// ── Shell script generators (pure strings, no registry dependency) ───────

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
)'
`;
}

const SHELL_SCRIPTS: Record<string, () => string> = {
bash: bashCompletionScript,
zsh: zshCompletionScript,
fish: fishCompletionScript,
};

/**
* Print completion script for the given shell. Returns true if handled, false if unknown shell.
*/
export function printCompletionScriptFast(shell: string): boolean {
const gen = SHELL_SCRIPTS[shell];
if (!gen) return false;
process.stdout.write(gen());
return true;
}

function loadManifestEntries(manifestPaths: string[]): ManifestCompletionEntry[] | null {
const entries: ManifestCompletionEntry[] = [];
let found = false;
for (const manifestPath of manifestPaths) {
try {
const raw = fs.readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(raw) as ManifestCompletionEntry[];
entries.push(...manifest);
found = true;
} catch { /* skip missing/unreadable */ }
}
return found ? entries : null;
}
1 change: 1 addition & 0 deletions src/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const BUILTIN_COMMANDS = [

/**
* Return completion candidates given the current command-line words and cursor index.
* Requires full CLI discovery to have been run (uses getRegistry()).
*
* @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)
Expand Down
77 changes: 64 additions & 13 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,76 @@ if (process.platform !== 'win32') {
process.env.PATH = [...cur].join(':');
}

import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, USER_CLIS_DIR } from './discovery.js';
import { getCompletions } from './completion.js';
import { runCli } from './cli.js';
import { emitHook } from './hooks.js';
import { installNodeNetwork } from './node-network.js';
import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
import { getCompletionsFromManifest, hasAllManifests, printCompletionScriptFast } from './completion-fast.js';
import { getCliManifestPath } from './package-paths.js';
import { PKG_VERSION } from './version.js';
import { EXIT_CODES } from './errors.js';

installNodeNetwork();

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const BUILTIN_CLIS = path.resolve(__dirname, '..', 'clis');
const USER_CLIS = USER_CLIS_DIR;
const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');

// ── Ultra-fast path: lightweight commands bypass full discovery ──────────
// These are high-frequency or trivial paths that must not pay the startup tax.
const argv = process.argv.slice(2);

// Fast path: --version (only when it's the top-level intent, not passed to a subcommand)
// e.g. `opencli --version` or `opencli -V`, but NOT `opencli gh --version`
if (argv[0] === '--version' || argv[0] === '-V') {
process.stdout.write(PKG_VERSION + '\n');
process.exit(EXIT_CODES.SUCCESS);
}

// Fast path: completion <shell> — print shell script without discovery
if (argv[0] === 'completion' && argv.length >= 2) {
if (printCompletionScriptFast(argv[1])) {
process.exit(EXIT_CODES.SUCCESS);
}
// Unknown shell — fall through to full path for proper error handling
}

// Fast path: --get-completions — read from manifest, skip discovery
const getCompIdx = process.argv.indexOf('--get-completions');
if (getCompIdx !== -1) {
// Only require manifest for directories that actually exist.
// If user clis dir doesn't exist, there are no user adapters to miss.
const manifestPaths = [getCliManifestPath(BUILTIN_CLIS)];
try { fs.accessSync(USER_CLIS); manifestPaths.push(getCliManifestPath(USER_CLIS)); } catch { /* no user dir */ }
if (hasAllManifests(manifestPaths)) {
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++;
} else {
words.push(rest[i]);
}
}
if (cursor === undefined) cursor = words.length;
const candidates = getCompletionsFromManifest(words, cursor, manifestPaths);
process.stdout.write(candidates.join('\n') + '\n');
process.exit(EXIT_CODES.SUCCESS);
}
// No manifest — fall through to full discovery path below
}

// ── Full startup path ───────────────────────────────────────────────────
// Dynamic imports: these are deferred so the fast path above never pays the cost.
const { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters } = await import('./discovery.js');
const { getCompletions } = await import('./completion.js');
const { runCli } = await import('./cli.js');
const { emitHook } = await import('./hooks.js');
const { installNodeNetwork } = await import('./node-network.js');
const { registerUpdateNoticeOnExit, checkForUpdateBackground } = await import('./update-check.js');

installNodeNetwork();

// Sequential: plugins must run after built-in discovery so they can override built-in commands.
await ensureUserCliCompatShims();
Expand All @@ -42,17 +95,15 @@ registerUpdateNoticeOnExit();
// Kick off background fetch for next run (non-blocking)
checkForUpdateBackground();

// ── Fast-path: handle --get-completions before commander parses ─────────
// Usage: opencli --get-completions --cursor <N> [word1 word2 ...]
const getCompIdx = process.argv.indexOf('--get-completions');
// ── Fallback completion: manifest unavailable, use full registry ─────────
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
i++;
} else {
words.push(rest[i]);
}
Expand Down