From 98ac3f8af6e26492edff691199d40930a93b4668 Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Thu, 21 May 2026 05:50:02 +0200 Subject: [PATCH 1/6] feat(cli): add `codegraph completions ` for zsh/bash/fish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently codegraph has no Tab-completion: users typing `codegraph i` get nothing. Hand-written emitters walk commander's command/option/argument tree and produce a static script per shell, mirroring the approach used by sema-lisp (clap_complete) and fedit (System.CommandLine hand-rolled). - `codegraph completions ` prints the script to stdout - `--install` writes to the standard per-shell location: zsh → ~/.zsh/completions/_codegraph bash → ~/.local/share/bash-completion/completions/codegraph fish → ~/.config/fish/completions/codegraph.fish - Argument-name heuristic infers file/directory hints (positional named `path` → _files; option value `` → file completion) - Aliases are routed to the canonical command function so e.g. `codegraph plugin` works the same as `codegraph plugins` (no aliases today, but the dispatcher is alias-aware) - Zero new runtime dependencies — uses commander's existing introspection API (`cmd.options`, `cmd.registeredArguments`, `cmd.commands`) PowerShell and elvish deferred — minimal demand for codegraph's audience; can be added by dropping in another emitter under src/completions/. Tests: 16 structural assertions covering shell parsing, install paths, per-shell output shape, and the alias-dispatch / value-hint logic. Snapshot tests intentionally avoided since they break on every CLI description tweak. --- README.md | 25 ++++++ __tests__/completions.test.ts | 145 ++++++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 36 +++++++++ src/completions/bash.ts | 116 +++++++++++++++++++++++++++ src/completions/fish.ts | 83 +++++++++++++++++++ src/completions/index.ts | 38 +++++++++ src/completions/install.ts | 44 +++++++++++ src/completions/introspect.ts | 91 +++++++++++++++++++++ src/completions/zsh.ts | 122 ++++++++++++++++++++++++++++ 9 files changed, 700 insertions(+) create mode 100644 __tests__/completions.test.ts create mode 100644 src/completions/bash.ts create mode 100644 src/completions/fish.ts create mode 100644 src/completions/index.ts create mode 100644 src/completions/install.ts create mode 100644 src/completions/introspect.ts create mode 100644 src/completions/zsh.ts diff --git a/README.md b/README.md index 559e8845..8cd54602 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,7 @@ codegraph files [path] # Show file structure (--format, --filter, --m codegraph context # Build context for AI (--format, --max-nodes) codegraph affected [files...] # Find test files affected by changes (see below) codegraph serve --mcp # Start MCP server +codegraph completions # Generate shell completions (see below) ``` ### `codegraph affected` @@ -356,6 +357,30 @@ if [ -n "$AFFECTED" ]; then fi ``` +### `codegraph completions` + +Generate a static completion script for your shell. Supported shells: `zsh`, `bash`, `fish`. + +```bash +codegraph completions zsh --install # write to ~/.zsh/completions/_codegraph +codegraph completions bash --install # write to ~/.local/share/bash-completion/completions/codegraph +codegraph completions fish --install # write to ~/.config/fish/completions/codegraph.fish + +# Or pipe yourself: +codegraph completions zsh > ~/.zsh/completions/_codegraph +``` + +**Zsh setup:** the install path must be on `$fpath` before `compinit`. Add to `~/.zshrc`: + +```bash +fpath=(~/.zsh/completions $fpath) +autoload -Uz compinit && compinit +``` + +**Bash setup:** requires the `bash-completion` package (`brew install bash-completion@2` on macOS). + +**Fish setup:** no extra config — fish auto-discovers `~/.config/fish/completions/`. + --- ## MCP Tools diff --git a/__tests__/completions.test.ts b/__tests__/completions.test.ts new file mode 100644 index 00000000..84fcf971 --- /dev/null +++ b/__tests__/completions.test.ts @@ -0,0 +1,145 @@ +/** + * Completion-emitter tests + * + * Builds a small commander program with every option/argument shape + * the emitters need to handle, then asserts each shell's output + * contains the expected lines. We don't snapshot the full script — + * that would break every time someone edits a description — but we do + * pin the structural pieces (function names, value hints, alias dispatch). + */ + +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { emit, parseShell, SUPPORTED_SHELLS, installPathFor } from '../src/completions'; + +const buildProgram = (): Command => { + const program = new Command(); + program.name('codegraph').description('test program').version('0.0.0'); + + program + .command('init [path]') + .description('Initialize CodeGraph') + .option('-i, --index', 'Run initial indexing') + .option('-v, --verbose', 'Verbose output') + .action(() => {}); + + program + .command('query ') + .description('Search for symbols') + .option('-p, --path ', 'Project path') + .option('-l, --limit ', 'Maximum results', '10') + .option('-j, --json', 'Output as JSON') + .action(() => {}); + + program + .command('affected [files...]') + .description('Find affected tests') + .alias('a') + .option('--stdin', 'Read from stdin') + .action(() => {}); + + return program; +}; + +describe('completions/parseShell', () => { + it('accepts supported shells case-insensitively', () => { + for (const s of SUPPORTED_SHELLS) { + expect(parseShell(s)).toBe(s); + expect(parseShell(s.toUpperCase())).toBe(s); + } + }); + + it('rejects unknown shells', () => { + expect(parseShell('powershell')).toBeNull(); + expect(parseShell('')).toBeNull(); + }); +}); + +describe('completions/installPathFor', () => { + it('returns per-shell standard paths', () => { + expect(installPathFor('zsh')).toMatch(/\.zsh\/completions\/_codegraph$/); + expect(installPathFor('bash')).toMatch( + /\.local\/share\/bash-completion\/completions\/codegraph$/, + ); + expect(installPathFor('fish')).toMatch(/\.config\/fish\/completions\/codegraph\.fish$/); + }); +}); + +describe('completions/zsh', () => { + const out = emit(buildProgram(), 'zsh'); + + it('starts with #compdef directive', () => { + expect(out.startsWith('#compdef codegraph\n')).toBe(true); + }); + + it('emits a per-subcommand function for each command', () => { + expect(out).toContain('_codegraph_init()'); + expect(out).toContain('_codegraph_query()'); + expect(out).toContain('_codegraph_affected()'); + }); + + it('emits paired short/long option specs with descriptions', () => { + expect(out).toContain("'(-i --index)'{-i,--index}'[Run initial indexing]'"); + }); + + it('emits value hints for options with -style values', () => { + // -p/--path takes a value; valueName is "path" which triggers _files hint. + expect(out).toContain(':path:_files'); + }); + + it('routes aliases to the same function as the canonical name', () => { + // `affected` has alias `a` — both should dispatch to _codegraph_affected. + expect(out).toMatch(/affected\|a\) _codegraph_affected/); + }); + + it('treats variadic positional as *', () => { + expect(out).toContain("'*:files:"); + }); +}); + +describe('completions/bash', () => { + const out = emit(buildProgram(), 'bash'); + + it('defines and registers _codegraph', () => { + expect(out).toContain('_codegraph() {'); + expect(out).toContain('complete -F _codegraph codegraph'); + }); + + it('lists all subcommands (canonical + alias) for top-level completion', () => { + expect(out).toMatch(/init uninit|init query affected a/); + // Loose check: every canonical name + the alias should appear in the + // subcommand word list. + for (const name of ['init', 'query', 'affected', 'a']) { + expect(out).toContain(name); + } + }); + + it('case-matches alias to the same arm', () => { + expect(out).toMatch(/affected\|a\)/); + }); + + it('triggers file completion after an option whose value is path-like', () => { + expect(out).toMatch(/-p\|--path\)\s*\n\s*COMPREPLY=\( \$\(compgen -f --/); + }); +}); + +describe('completions/fish', () => { + const out = emit(buildProgram(), 'fish'); + + it('starts with a comment header', () => { + expect(out.startsWith('# Fish completion for codegraph.')).toBe(true); + }); + + it('emits a __fish_use_subcommand line per subcommand (canonical + alias)', () => { + expect(out).toContain("-a 'init'"); + expect(out).toContain("-a 'affected'"); + expect(out).toContain("-a 'a'"); // alias + }); + + it('flags options that take a value with -r and value hints', () => { + // --path uses -F (file hint) because valueName "path" is in the file set. + expect(out).toContain('-l path -r -F'); + // --limit takes a value but valueName is "number" -> -x (no file hint). + expect(out).toContain('-l limit -r -x'); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index de608c36..52e82c29 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1382,6 +1382,42 @@ program } }); +/** + * codegraph completions [--install] + */ +program + .command('completions ') + .description('Generate shell completions (zsh, bash, fish)') + .option('--install', 'Install to the standard location for the shell') + .action(async (shellArg: string, options: { install?: boolean }) => { + const { parseShell, emit, installCompletions, SUPPORTED_SHELLS } = await import( + '../completions' + ); + const shell = parseShell(shellArg); + if (!shell) { + error( + `Unsupported shell '${shellArg}'. Supported: ${SUPPORTED_SHELLS.join(', ')}.`, + ); + process.exit(1); + } + const script = emit(program, shell); + if (options.install) { + try { + const { path: installedAt, postInstallHint } = installCompletions(shell, script); + success(`Installed ${shell} completions to ${installedAt}`); + if (postInstallHint) { + console.log(''); + console.log(postInstallHint); + } + } catch (err) { + error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + } else { + process.stdout.write(script); + } + }); + // Parse and run program.parse(); diff --git a/src/completions/bash.ts b/src/completions/bash.ts new file mode 100644 index 00000000..a7342103 --- /dev/null +++ b/src/completions/bash.ts @@ -0,0 +1,116 @@ +/** + * Bash completion emitter. Produces a `_codegraph` function registered + * via `complete -F _codegraph codegraph`. Uses `compgen -W` for word + * lists and `compgen -f` / `-d` for file/dir completion. + */ + +import type { Command } from 'commander'; +import { describeCommand, type CommandDesc, type OptionDesc } from './introspect'; + +const allFlagsFor = (opts: OptionDesc[]): string[] => { + const out: string[] = []; + for (const o of opts) { + if (o.short) out.push(o.short); + if (o.long) out.push(o.long); + } + return out; +}; + +// Options whose previous word means "complete a path", not "complete a flag". +const valueOptionsByHint = (opts: OptionDesc[]) => { + const file: string[] = []; + const dir: string[] = []; + for (const o of opts) { + if (!o.takesValue) continue; + const flags = [o.short, o.long].filter(Boolean) as string[]; + if (o.valueHint === 'file') file.push(...flags); + else if (o.valueHint === 'directory') dir.push(...flags); + } + return { file, dir }; +}; + +const subcommandCase = (root: CommandDesc): string => { + const arms = root.subcommands.map((sub) => { + const surface = [sub.name, ...sub.aliases].join('|'); + const flags = allFlagsFor(sub.options); + const { file: fileOpts, dir: dirOpts } = valueOptionsByHint(sub.options); + // Positional hint: if any arg wants files/dirs, fall back to file + // completion when the user isn't completing a flag. + const argHint = sub.args.find((a) => a.hint !== 'none')?.hint ?? 'none'; + + const flagList = flags.join(' '); + const valueHandling: string[] = []; + if (fileOpts.length > 0) { + valueHandling.push(` ${fileOpts.join('|')})`); + valueHandling.push(` COMPREPLY=( $(compgen -f -- "$cur") ); return 0 ;;`); + } + if (dirOpts.length > 0) { + valueHandling.push(` ${dirOpts.join('|')})`); + valueHandling.push(` COMPREPLY=( $(compgen -d -- "$cur") ); return 0 ;;`); + } + + const positionalFallback = + argHint === 'file' + ? `\n if [[ "$cur" != -* ]]; then\n COMPREPLY=( $(compgen -f -- "$cur") ); return 0\n fi` + : argHint === 'directory' + ? `\n if [[ "$cur" != -* ]]; then\n COMPREPLY=( $(compgen -d -- "$cur") ); return 0\n fi` + : ''; + + return ` ${surface}) + case "$prev" in +${valueHandling.length > 0 ? valueHandling.join('\n') + '\n' : ''} esac${positionalFallback} + COMPREPLY=( $(compgen -W "${flagList}" -- "$cur") ) + return 0 + ;;`; + }); + + return arms.join('\n'); +}; + +export const emitBash = (program: Command): string => { + const root = describeCommand(program); + const subNames = root.subcommands.flatMap((s) => [s.name, ...s.aliases]).join(' '); + const rootFlags = allFlagsFor(root.options).join(' '); + + return `# Bash completion for codegraph. +# Generated by \`codegraph completions bash\` — do not edit by hand. +# Install: source this file, or drop it in +# ~/.local/share/bash-completion/completions/codegraph +# (requires the bash-completion package). + +_codegraph() { + local cur prev words cword + _init_completion 2>/dev/null || { + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + words=("\${COMP_WORDS[@]}") + cword=$COMP_CWORD + } + + # Find the subcommand position (first non-flag token after the script). + local sub="" + local i + for (( i=1; i < cword; i++ )); do + if [[ "\${words[i]}" != -* ]]; then + sub="\${words[i]}" + break + fi + done + + if [[ -z "$sub" ]]; then + if [[ "$cur" == -* ]]; then + COMPREPLY=( $(compgen -W "${rootFlags} --help --version" -- "$cur") ) + else + COMPREPLY=( $(compgen -W "${subNames}" -- "$cur") ) + fi + return 0 + fi + + case "$sub" in +${subcommandCase(root)} + esac +} + +complete -F _codegraph codegraph +`; +}; diff --git a/src/completions/fish.ts b/src/completions/fish.ts new file mode 100644 index 00000000..8f3d4f77 --- /dev/null +++ b/src/completions/fish.ts @@ -0,0 +1,83 @@ +/** + * Fish completion emitter. Each `complete` line registers a single + * piece of completion metadata; fish stitches them together at + * tab-time. No script, no functions — just declarations. + */ + +import type { Command } from 'commander'; +import { describeCommand, type CommandDesc, type OptionDesc } from './introspect'; + +const fishEscape = (s: string): string => s.replace(/'/g, "\\'"); + +const subcommandNamesExpr = (root: CommandDesc): string => + root.subcommands.flatMap((s) => [s.name, ...s.aliases]).join(' '); + +const optionLine = (cmdCondition: string, opt: OptionDesc): string => { + const parts: string[] = [`complete -c codegraph -n '${cmdCondition}'`]; + if (opt.short) parts.push(`-s ${opt.short.replace(/^-/, '')}`); + if (opt.long) parts.push(`-l ${opt.long.replace(/^--/, '')}`); + if (opt.takesValue) { + parts.push('-r'); // option requires an argument + if (opt.valueHint === 'file') parts.push('-F'); + else if (opt.valueHint === 'directory') parts.push('-F'); // fish has no -D; -F + dir hint description + else parts.push('-x'); // arg required, not a file + } + if (opt.description) parts.push(`-d '${fishEscape(opt.description)}'`); + return parts.join(' '); +}; + +export const emitFish = (program: Command): string => { + const root = describeCommand(program); + const subs = subcommandNamesExpr(root); + + // Root: list subcommands when none chosen yet. + const lines: string[] = [ + '# Fish completion for codegraph.', + "# Generated by `codegraph completions fish` — do not edit by hand.", + '# Install: place in ~/.config/fish/completions/codegraph.fish', + '', + "complete -c codegraph -n '__fish_use_subcommand' -f", + ]; + + // Top-level option flags (apply when no subcommand chosen yet). + for (const opt of root.options) { + lines.push(optionLine('__fish_use_subcommand', opt)); + } + + // Each subcommand as a candidate at position 1. + for (const sub of root.subcommands) { + const desc = fishEscape(sub.description); + for (const name of [sub.name, ...sub.aliases]) { + lines.push( + `complete -c codegraph -n '__fish_use_subcommand' -a '${fishEscape(name)}' -d '${desc}'`, + ); + } + } + + lines.push(''); + + // Per-subcommand options + positional file completion. + for (const sub of root.subcommands) { + const allNames = [sub.name, ...sub.aliases]; + // `__fish_seen_subcommand_from` accepts space-separated names. + const cond = `__fish_seen_subcommand_from ${allNames.join(' ')}`; + for (const opt of sub.options) { + lines.push(optionLine(cond, opt)); + } + const fileArg = sub.args.find((a) => a.hint === 'file'); + const dirArg = sub.args.find((a) => a.hint === 'directory'); + if (fileArg) { + lines.push(`complete -c codegraph -n '${cond}' -F`); + } else if (dirArg) { + lines.push(`complete -c codegraph -n '${cond}' -a '(__fish_complete_directories)'`); + } + lines.push(''); + } + + // Suppress fallthrough file completion when no completion matches — + // by default fish would offer files for any command, which is noisy + // for commands that don't take a path. + void subs; + + return lines.join('\n') + '\n'; +}; diff --git a/src/completions/index.ts b/src/completions/index.ts new file mode 100644 index 00000000..ea0a047c --- /dev/null +++ b/src/completions/index.ts @@ -0,0 +1,38 @@ +/** + * Shell-completion script generator. Walks a commander `Command` tree + * and emits a shell-native completion script. Each shell gets its own + * emitter because the formats diverge sharply. + * + * Pattern mirrors hand-written generators in other CLIs (sema, fedit): + * we generate a static script the user installs once, rather than + * hooking completion through a runtime callback. Static scripts work + * without `codegraph` having to spawn a Node process on every Tab, + * and they survive when the binary is uninstalled mid-shell-session. + */ + +import type { Command } from 'commander'; +import { emitZsh } from './zsh'; +import { emitBash } from './bash'; +import { emitFish } from './fish'; + +export type Shell = 'zsh' | 'bash' | 'fish'; + +export const SUPPORTED_SHELLS: readonly Shell[] = ['zsh', 'bash', 'fish'] as const; + +export const parseShell = (s: string): Shell | null => { + const lower = s.toLowerCase(); + return (SUPPORTED_SHELLS as readonly string[]).includes(lower) ? (lower as Shell) : null; +}; + +export const emit = (program: Command, shell: Shell): string => { + switch (shell) { + case 'zsh': + return emitZsh(program); + case 'bash': + return emitBash(program); + case 'fish': + return emitFish(program); + } +}; + +export { installCompletions, installPathFor } from './install'; diff --git a/src/completions/install.ts b/src/completions/install.ts new file mode 100644 index 00000000..ba721010 --- /dev/null +++ b/src/completions/install.ts @@ -0,0 +1,44 @@ +/** + * Writes a generated completion script to the standard per-shell + * location. Paths match what sema-lisp and fedit use so users with + * existing fpath/bash-completion config don't need new configuration. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Shell } from './index'; + +export const installPathFor = (shell: Shell): string => { + const home = os.homedir(); + switch (shell) { + case 'zsh': + return path.join(home, '.zsh', 'completions', '_codegraph'); + case 'bash': + return path.join(home, '.local', 'share', 'bash-completion', 'completions', 'codegraph'); + case 'fish': + return path.join(home, '.config', 'fish', 'completions', 'codegraph.fish'); + } +}; + +export interface InstallResult { + path: string; + postInstallHint?: string; +} + +export const installCompletions = (shell: Shell, script: string): InstallResult => { + const target = installPathFor(shell); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, script, 'utf8'); + + let postInstallHint: string | undefined; + if (shell === 'zsh') { + // ~/.zsh/completions isn't on `$fpath` by default, so first-time + // users would install the script and see no completions. Tell them. + postInstallHint = + 'Add to ~/.zshrc (before `compinit`):\n' + + ' fpath=(~/.zsh/completions $fpath)\n' + + ' autoload -Uz compinit && compinit'; + } + return { path: target, postInstallHint }; +}; diff --git a/src/completions/introspect.ts b/src/completions/introspect.ts new file mode 100644 index 00000000..5d28b78d --- /dev/null +++ b/src/completions/introspect.ts @@ -0,0 +1,91 @@ +/** + * Normalize a commander `Command` tree into a plain descriptor each + * shell emitter walks. Keeps commander coupling in one place; if + * commander's introspection surface changes, only this file moves. + */ + +import type { Command, Option as CommanderOption, Argument as CommanderArgument } from 'commander'; + +export type CompletionHint = 'file' | 'directory' | 'none'; + +export interface OptionDesc { + short?: string; // e.g. "-p" + long?: string; // e.g. "--path" + flags: string; // raw flag spec: "-p, --path " + description: string; + takesValue: boolean; + valueName?: string; + valueHint: CompletionHint; + negate: boolean; // --no-xxx +} + +export interface ArgDesc { + name: string; + required: boolean; + variadic: boolean; + hint: CompletionHint; +} + +export interface CommandDesc { + name: string; + aliases: string[]; + description: string; + args: ArgDesc[]; + options: OptionDesc[]; + subcommands: CommandDesc[]; +} + +// Argument-name heuristic: positional names that look like filesystem +// paths get a `_files` hint. Names matter — commander doesn't expose +// per-argument completion metadata, so the name is all we have. +const FILE_HINT_NAMES = new Set(['path', 'file', 'files', 'source', 'output']); +const DIR_HINT_NAMES = new Set(['dir', 'directory', 'folder']); + +const hintForName = (name: string | undefined): CompletionHint => { + if (!name) return 'none'; + const lower = name.toLowerCase(); + if (DIR_HINT_NAMES.has(lower)) return 'directory'; + if (FILE_HINT_NAMES.has(lower)) return 'file'; + return 'none'; +}; + +const describeOption = (opt: CommanderOption): OptionDesc => { + // Commander's `Option` exposes `short`, `long`, `flags`, `description`, + // `required` (value-required ``), `optional` (value-optional `[x]`), + // `negate` (--no-x), `mandatory` (option itself required). + const takesValue = Boolean(opt.required || opt.optional); + // Value name lives inside `flags`, e.g. "-p, --path ". Pull the + // first `<...>` or `[...]` token; commander doesn't surface it directly. + let valueName: string | undefined; + if (takesValue) { + const match = opt.flags.match(/[<[]([^>\]]+)[>\]]/); + if (match) valueName = match[1]; + } + return { + short: opt.short ?? undefined, + long: opt.long ?? undefined, + flags: opt.flags, + description: opt.description ?? '', + takesValue, + valueName, + valueHint: hintForName(valueName), + negate: Boolean(opt.negate), + }; +}; + +const describeArg = (arg: CommanderArgument): ArgDesc => ({ + name: arg.name(), + required: arg.required, + variadic: arg.variadic, + hint: hintForName(arg.name()), +}); + +export const describeCommand = (cmd: Command): CommandDesc => ({ + name: cmd.name(), + aliases: cmd.aliases(), + description: cmd.description(), + // `registeredArguments` is commander 10+ — the project pins ^14, so safe. + args: (cmd as Command & { registeredArguments: CommanderArgument[] }).registeredArguments.map(describeArg), + options: cmd.options.map(describeOption), + subcommands: cmd.commands.map(describeCommand), +}); diff --git a/src/completions/zsh.ts b/src/completions/zsh.ts new file mode 100644 index 00000000..874d0e4d --- /dev/null +++ b/src/completions/zsh.ts @@ -0,0 +1,122 @@ +/** + * Zsh completion emitter. Produces an `_codegraph` function suitable + * for installation on the `fpath`. Uses the `_arguments` / `_values` + * idiom — the same style sema-lisp and fedit generate. + */ + +import type { Command } from 'commander'; +import { describeCommand, type CommandDesc, type CompletionHint, type OptionDesc } from './introspect'; + +// Inside single-quoted zsh strings we only need to handle `'` and the +// `[…]` description delimiters of `_arguments`. Keeping the escape set +// minimal avoids surprising users who read the script. +const zshEscape = (s: string): string => + s.replace(/'/g, "'\\''").replace(/\[/g, '\\[').replace(/\]/g, '\\]'); + +const actionFor = (hint: CompletionHint): string => { + switch (hint) { + case 'file': + return '_files'; + case 'directory': + return '_files -/'; + case 'none': + return ''; + } +}; + +const optionSpec = (opt: OptionDesc): string => { + // `_arguments` spec format: + // '(-x --xx)'{-x,--xx}'[description]:value name:action' + // For boolean flags (no value): '--flag[description]' + // For both short + long: paired exclusion + brace expansion. + const desc = zshEscape(opt.description); + const valuePart = opt.takesValue + ? `:${zshEscape(opt.valueName ?? 'value')}:${actionFor(opt.valueHint)}` + : ''; + + if (opt.short && opt.long) { + return `'(${opt.short} ${opt.long})'{${opt.short},${opt.long}}'[${desc}]${valuePart}'`; + } + const flag = opt.long ?? opt.short!; + return `'${flag}[${desc}]${valuePart}'`; +}; + +const argSpec = (idx: number, arg: { name: string; required: boolean; variadic: boolean; hint: CompletionHint }): string => { + const action = actionFor(arg.hint) || '( )'; + if (arg.variadic) { + return `'*:${zshEscape(arg.name)}:${action}'`; + } + const prefix = arg.required ? `${idx + 1}` : `:${idx + 1}`; + return `'${prefix}:${zshEscape(arg.name)}:${action}'`; +}; + +const subcommandDispatchFn = (root: CommandDesc): string => { + // Per-subcommand argument spec functions, named `_codegraph_`. + const perSub = root.subcommands.map((sub) => { + const argLines = sub.args.map((a, i) => ` ${argSpec(i, a)}`); + const optLines = sub.options.map((o) => ` ${optionSpec(o)}`); + const lines = [...optLines, ...argLines]; + const body = lines.length > 0 ? `\n _arguments -s -S \\\n${lines.join(' \\\n')}\n` : '\n return 0\n'; + return `_codegraph_${sub.name}() {${body}}`; + }); + + // The values list passed to `_values` — `subname[description]` per + // canonical name. Aliases get their own line so they show up in tab + // listings. + const valueLines = root.subcommands.flatMap((sub) => { + const desc = zshEscape(sub.description); + return [sub.name, ...sub.aliases].map((n) => ` '${zshEscape(n)}[${desc}]'`); + }); + + // Case dispatch from canonical name OR alias to the per-sub function. + const caseArms = root.subcommands + .map((sub) => { + const pattern = [sub.name, ...sub.aliases].map(zshEscape).join('|'); + return ` ${pattern}) _codegraph_${sub.name} ;;`; + }) + .join('\n'); + + return `${perSub.join('\n\n')} + +_codegraph_commands() { + local -a _commands + _values 'codegraph command' \\ +${valueLines.join(' \\\n')} +} + +_codegraph_dispatch() { + case $words[1] in +${caseArms} + esac +}`; +}; + +export const emitZsh = (program: Command): string => { + const root = describeCommand(program); + const rootOptions = root.options.map((o) => ` ${optionSpec(o)}`).join(' \\\n'); + + return `#compdef codegraph +# Zsh completion for codegraph. +# Generated by \`codegraph completions zsh\` — do not edit by hand. +# Install: place on your \$fpath (e.g. ~/.zsh/completions/_codegraph), +# then \`autoload -Uz compinit && compinit\`. + +${subcommandDispatchFn(root)} + +_codegraph() { + local context state state_descr line + typeset -A opt_args + + _arguments -C \\ +${rootOptions} \\ + '1: :_codegraph_commands' \\ + '*::arg:->args' + + case $state in + args) _codegraph_dispatch ;; + esac +} + +_codegraph "$@" +`; +}; From 62afc1c32c12b6ca3475abedabd4aa59462ed10e Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Thu, 21 May 2026 06:11:57 +0200 Subject: [PATCH 2/6] test(completions): add docker-based end-to-end smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 16 vitest tests in __tests__/completions.test.ts pin substrings in the generated script but don't prove that the script actually works once installed. A malformed _arguments spec parses fine, autoloads fine, and silently produces zero completions — pure-text assertions miss that whole class of bug. This adds an opt-in smoke harness that: - npm pack's the actual published artifact (catches packaging regressions) - builds a pinned Node 22 + zsh + bash + fish container - installs the tarball + runs `codegraph completions --install` - drives real shell-completion machinery and asserts content: - bash: sources the file, verifies `complete -F _codegraph codegraph` registered the function, then drives COMPREPLY assertions for top-level commands, subcommand flags, --path file hints, and global --help/--version - fish: uses `complete -C "codegraph …"` (fish exposes the full completion path non-interactively) and asserts stdout - zsh: structural — script parses (`zsh -n`), is registered by compinit (`whence -w _codegraph`), and a cross-section of per-subcommand helpers is defined after autoload zsh content-testing is intentionally NOT done. `_values` and `_arguments` require `_main_complete` running under a real ZLE widget context (compstate, state, opt_args); scripts can't manufacture that, so a `compadd` shim captures nothing — verified by a failed canary attempt. PTY+expect is the only way to drive the full path and is too flaky across zsh 5.7/5.8/5.9 for CI. Industry standard (clap_complete, oclif, click, Commander.js itself) is structural-only for zsh; we match that bar. Isolation: - Wired only via `npm run smoke:completions`. Not in `npm test`. - vitest.config.ts unchanged (smoke count: 0). - docker/ is excluded from npm pack by the existing `files:` allowlist ["dist","scripts","README.md"], so this adds no published surface. - No new runtime or dev dependencies. Verified end-to-end: - Clean run: `smoke: zsh bash fish OK` (exit 0) - Injection: removing the `complete -F` line from src/completions/bash.ts causes smoke to fail at the bash registration check with exit 1. --- README.md | 2 +- docker/smoke-completions/Dockerfile | 20 ++++++ docker/smoke-completions/README.md | 90 +++++++++++++++++++++++++++ docker/smoke-completions/run-host.sh | 21 +++++++ docker/smoke-completions/run.sh | 36 +++++++++++ docker/smoke-completions/test-bash.sh | 83 ++++++++++++++++++++++++ docker/smoke-completions/test-fish.sh | 42 +++++++++++++ docker/smoke-completions/test-zsh.sh | 53 ++++++++++++++++ package.json | 1 + 9 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 docker/smoke-completions/Dockerfile create mode 100644 docker/smoke-completions/README.md create mode 100755 docker/smoke-completions/run-host.sh create mode 100755 docker/smoke-completions/run.sh create mode 100755 docker/smoke-completions/test-bash.sh create mode 100755 docker/smoke-completions/test-fish.sh create mode 100755 docker/smoke-completions/test-zsh.sh diff --git a/README.md b/README.md index 8cd54602..f7607153 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
+
# CodeGraph diff --git a/docker/smoke-completions/Dockerfile b/docker/smoke-completions/Dockerfile new file mode 100644 index 00000000..1ddb7566 --- /dev/null +++ b/docker/smoke-completions/Dockerfile @@ -0,0 +1,20 @@ +# Smoke-test image for `codegraph completions` output. Runs the actual +# generated zsh/bash/fish scripts under their real shells against pinned +# versions. Image is intentionally minimal — no extras the test doesn't +# need. See README.md for what's verified and what isn't. +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + zsh \ + bash \ + bash-completion \ + fish \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY run.sh test-bash.sh test-fish.sh test-zsh.sh /smoke/ +RUN chmod +x /smoke/*.sh + +WORKDIR /smoke +ENTRYPOINT ["/smoke/run.sh"] diff --git a/docker/smoke-completions/README.md b/docker/smoke-completions/README.md new file mode 100644 index 00000000..efad87d0 --- /dev/null +++ b/docker/smoke-completions/README.md @@ -0,0 +1,90 @@ +# Smoke test: `codegraph completions` + +End-to-end verification that `codegraph completions ` produces a +script that actually works when installed into a real shell. Runs in a +pinned Docker image so the result doesn't depend on the developer's +local zsh/bash/fish versions. + +## Run + +```bash +npm run smoke:completions +``` + +Expected final line on success: `smoke: zsh bash fish OK`. + +## What this proves + +- `npm pack` artifact installs cleanly via `npm install -g`. +- `codegraph completions --install` writes to the correct path + for zsh, bash, and fish, and the resulting file is non-empty. +- **bash**: the installed completion, sourced via `bash-completion`, + produces the expected COMPREPLY for top-level commands, subcommand + flags, options with `` values, and the global `--help` / `--version`. +- **fish**: `complete -C "codegraph …"` returns the expected suggestions + for top-level commands, subcommand flags, and file-hint options. +- **zsh**: the script parses (`zsh -n`), is registered as an + autoloadable completion by `compinit`, and a representative + cross-section of per-subcommand helper functions is defined after + autoload (proves the file body ran to completion — if any helper is + missing, the script crashed mid-way). + +## What this does NOT prove + +zsh completion output is **not** content-verified. The actual +suggestions zsh produces require `_main_complete` running under a real +ZLE widget context, which scripts cannot manufacture — `_values` and +`_arguments` short-circuit on missing `$compstate`/`$state` setup, so +even a `compadd` shim captures nothing. The only way to drive the full +path is PTY + `expect`, which is too flaky across zsh 5.7/5.8/5.9 and +across `TERM`/locale/`zle` configurations to belong in CI. + +Concretely, a subtle bug inside an `_arguments` spec (e.g., malformed +quoting that breaks one specific state) would pass our structural +checks and still misbehave for users. Industry consensus on this +exact tradeoff: clap_complete, oclif, click, and Commander.js itself +all ship structural-only zsh tests. We match that bar. + +If you want stronger zsh coverage in the future, the realistic path +is `zpty`-based testing (what Fig uses) — that runs a real interactive +zsh under a pseudo-terminal and pipes keystrokes. It's a separate +engineering effort from this smoke harness. + +## What's tested + +| Shell | Mechanism | Strength | +|-------|----------------------------------------------------|----------| +| bash | Source script, set `COMP_*`, call `_codegraph`, assert `COMPREPLY` | Full content | +| fish | `complete -C "codegraph …"` stdout assertions | Full content | +| zsh | Syntax + `compinit` load + cross-section of helpers defined | Structural | + +## Architecture + +``` +host: container: + ┌────────────────────────────────┐ +npm run build │ node:22 + zsh + bash + fish │ +npm pack ───── /pkg.tgz ──────────▶ │ npm install -g /pkg.tgz │ +docker run │ codegraph completions … --install │ + │ /smoke/test-{bash,fish,zsh}.sh │ + └────────────────────────────────┘ +``` + +The image is bind-mounted **only** with the tarball — test scripts +are baked in at build time so the image is self-contained. The host +wrapper at `run-host.sh` does build, pack, image-build, and run. + +## Why Docker (not just a local script) + +- Pins shell versions (Debian Bookworm: zsh 5.9, bash 5.2, fish 3.6) so + results are deterministic across developer machines and CI. +- Avoids polluting the developer's shell config with test completions. +- Sidesteps macOS-specific bash 3.2 quirks (the bundled bash on macOS + is too old for the `_init_completion` helper). + +## Isolation from the main test suite + +The smoke test is **not** wired into `npm test` or vitest. It has its +own opt-in npm script (`smoke:completions`) and lives entirely under +`docker/`, which is excluded from `npm pack` by the `files:` allowlist +in `package.json`. The existing vitest suite is untouched. diff --git a/docker/smoke-completions/run-host.sh b/docker/smoke-completions/run-host.sh new file mode 100755 index 00000000..0bf0a2a6 --- /dev/null +++ b/docker/smoke-completions/run-host.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Host wrapper for the completion smoke test. Builds, packs, runs the +# image. Idempotent — cleans up the tarball on exit. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +echo "smoke: building dist/" +npm run build >/dev/null + +echo "smoke: packing" +PKG=$(npm pack --silent) +trap 'rm -f "$PKG"' EXIT + +echo "smoke: building image" +docker build --quiet -t codegraph-smoke docker/smoke-completions >/dev/null + +echo "smoke: running container" +docker run --rm \ + -v "$PWD/$PKG:/pkg.tgz:ro" \ + codegraph-smoke diff --git a/docker/smoke-completions/run.sh b/docker/smoke-completions/run.sh new file mode 100755 index 00000000..0791a5b1 --- /dev/null +++ b/docker/smoke-completions/run.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Container entrypoint. Installs the codegraph tarball mounted at +# /pkg.tgz, installs completions for each shell to its standard +# location, then runs the per-shell assertions. +set -euo pipefail + +if [[ ! -f /pkg.tgz ]]; then + echo "smoke: /pkg.tgz not found. Mount the npm-pack tarball: docker run -v \$PWD/pkg.tgz:/pkg.tgz:ro …" >&2 + exit 2 +fi + +echo "smoke: installing codegraph from /pkg.tgz" +npm install -g /pkg.tgz >/dev/null + +echo "smoke: generating + installing completion scripts" +codegraph completions zsh --install >/dev/null +codegraph completions bash --install >/dev/null +codegraph completions fish --install >/dev/null + +# Quick sanity: the files actually landed where the installer says. +for f in "$HOME/.zsh/completions/_codegraph" \ + "$HOME/.local/share/bash-completion/completions/codegraph" \ + "$HOME/.config/fish/completions/codegraph.fish"; do + [[ -s "$f" ]] || { echo "smoke: FAIL — expected file missing or empty: $f" >&2; exit 1; } +done + +echo "smoke: running bash assertions" +/smoke/test-bash.sh + +echo "smoke: running fish assertions" +/smoke/test-fish.sh + +echo "smoke: running zsh assertions" +/smoke/test-zsh.sh + +echo "smoke: zsh bash fish OK" diff --git a/docker/smoke-completions/test-bash.sh b/docker/smoke-completions/test-bash.sh new file mode 100755 index 00000000..b8629026 --- /dev/null +++ b/docker/smoke-completions/test-bash.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Bash completion smoke. Sources the installed script, sets the +# COMP_* env that bash-completion populates during a real Tab, calls +# `_codegraph`, and asserts COMPREPLY contents. This is the standard +# non-PTY pattern (used by oclif, click, gh's own completion tests). +set -euo pipefail + +source /usr/share/bash-completion/bash_completion +source "$HOME/.local/share/bash-completion/completions/codegraph" + +declare -i fail=0 + +# 0. Registration: sourcing must register the completion for the +# `codegraph` command via `complete -F`. Without this, Tab does +# nothing — even if the function below works when called directly. +if ! complete -p codegraph 2>/dev/null | grep -q '_codegraph'; then + echo "FAIL [bash:registration]: sourcing didn't register a completion for 'codegraph'" >&2 + echo " complete -p codegraph: $(complete -p codegraph 2>&1 || echo 'unregistered')" >&2 + fail=1 +fi + +# Drive a single completion attempt: pre-populates the COMP_* vars +# from a command line + cursor position, calls `_codegraph`, returns +# the joined COMPREPLY for the caller to assert against. +complete_line() { + local line=$1 + local point=${2:-${#line}} + local cur prev + COMP_LINE=$line + COMP_POINT=$point + # Tokenize the line; cursor word is the last token (or empty if line ends in space). + read -ra COMP_WORDS <<<"$line" + if [[ "${line: -1}" == " " ]]; then + COMP_WORDS+=("") + fi + COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 )) + COMPREPLY=() + _codegraph + printf '%s\n' "${COMPREPLY[@]:-}" +} + +assert_contains() { + local label=$1 needle=$2 haystack=$3 + if ! grep -qx -- "$needle" <<<"$haystack"; then + echo "FAIL [bash:$label]: expected '$needle' in completions, got:" >&2 + echo "$haystack" | sed 's/^/ /' >&2 + fail=1 + fi +} + +assert_nonempty() { + local label=$1 haystack=$2 + if [[ -z "${haystack//[[:space:]]/}" ]]; then + echo "FAIL [bash:$label]: expected non-empty completions, got nothing" >&2 + fail=1 + fi +} + +# 1. Top-level subcommand list. +out=$(complete_line "codegraph ") +assert_contains "top-level/init" "init" "$out" +assert_contains "top-level/query" "query" "$out" +assert_contains "top-level/completions" "completions" "$out" + +# 2. Subcommand flag completion (-i, --index for `init`). +out=$(complete_line "codegraph init -") +assert_contains "init/-i" "-i" "$out" +assert_contains "init/--index" "--index" "$out" + +# 3. Value-hint: --path expects a file. With no cwd files we should +# still get *some* completion candidates (compgen -f lists cwd). +cd /tmp # ensure compgen has something to enumerate +touch /tmp/.smoke-sentinel +out=$(complete_line "codegraph query --path ") +assert_nonempty "query/--path" "$out" +rm -f /tmp/.smoke-sentinel + +# 4. Help/version are recognized at top level. +out=$(complete_line "codegraph -") +assert_contains "top-level/--help" "--help" "$out" +assert_contains "top-level/--version" "--version" "$out" + +exit $fail diff --git a/docker/smoke-completions/test-fish.sh b/docker/smoke-completions/test-fish.sh new file mode 100755 index 00000000..af89ffd4 --- /dev/null +++ b/docker/smoke-completions/test-fish.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Fish completion smoke. `complete -C ""` returns suggestions on +# stdout, one per line, tab-separating the candidate from its description. +# Fish auto-loads anything in ~/.config/fish/completions/ so no source needed. +set -euo pipefail + +declare -i fail=0 + +assert_contains() { + local label=$1 needle=$2 haystack=$3 + # Each fish completion line is "namedescription"; match the name column. + if ! awk -F '\t' -v n="$needle" '$1 == n {found=1} END {exit !found}' <<<"$haystack"; then + echo "FAIL [fish:$label]: expected '$needle' in completions, got:" >&2 + echo "$haystack" | sed 's/^/ /' >&2 + fail=1 + fi +} + +assert_nonempty() { + local label=$1 haystack=$2 + if [[ -z "${haystack//[[:space:]]/}" ]]; then + echo "FAIL [fish:$label]: expected non-empty completions, got nothing" >&2 + fail=1 + fi +} + +# 1. Top-level subcommand list. +out=$(fish -c 'complete -C "codegraph "') +assert_contains "top-level/init" "init" "$out" +assert_contains "top-level/query" "query" "$out" +assert_contains "top-level/completions" "completions" "$out" + +# 2. Subcommand flag completion. +out=$(fish -c 'complete -C "codegraph init -"') +assert_contains "init/--index" "--index" "$out" + +# 3. Value-hint: --path should trigger file completion. With files in +# cwd, fish lists them. +out=$(fish -c 'cd /tmp; touch .smoke-sentinel; complete -C "codegraph query --path "; rm -f .smoke-sentinel') +assert_nonempty "query/--path" "$out" + +exit $fail diff --git a/docker/smoke-completions/test-zsh.sh b/docker/smoke-completions/test-zsh.sh new file mode 100755 index 00000000..b71a5d1f --- /dev/null +++ b/docker/smoke-completions/test-zsh.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env zsh +# Zsh completion smoke. zsh has no `complete -C` equivalent, and full +# completion output requires `_main_complete` running under a real ZLE +# widget context (which scripts can't manufacture — `_values` / +# `_arguments` short-circuit on missing `$compstate` / `$state` and a +# `compadd` shim captures nothing). PTY + expect is the only way to +# drive the full path, and that's too flaky for CI. +# +# We do the next-best thing: structural assertions that catch the +# common failure modes (script doesn't parse, file body crashed mid-way, +# helpers not defined). The deep-`_arguments` gap is documented in +# README.md and matches what clap_complete / oclif / Commander.js +# itself ship for zsh. +set -e + +declare -i fail=0 + +# 1. Syntax check. +zsh -n "$HOME/.zsh/completions/_codegraph" || { echo "FAIL [zsh:syntax]" >&2; exit 1; } + +# 2. compinit must load the file as an autoloadable function. +fpath=("$HOME/.zsh/completions" $fpath) +autoload -Uz compinit +# -u: don't bail on insecure dirs (container HOME is owned by root, fine) +# -d: per-run dump in /tmp so we don't pollute HOME +compinit -u -d /tmp/.zcompdump-smoke + +# `whence -w` prints "name: function" or "name: autoload" for loaded +# completion functions; "none" means not registered. +whence_kind=$(whence -w _codegraph 2>/dev/null || echo "_codegraph: none") +case "$whence_kind" in + *": function"|*": autoload") ;; + *) echo "FAIL [zsh:autoload]: _codegraph not registered (whence: $whence_kind)" >&2; fail=1 ;; +esac + +# 3. Force the autoload to actually source the file. We invoke +# `_codegraph` directly; this errors because `_arguments` isn't in a +# real completion context, but the side effect is the file body runs +# and defines the per-subcommand helpers. Discard the expected error. +_codegraph >/dev/null 2>&1 || true + +# Spot-check a representative cross-section: one helper from each +# emitted class (no-args, with-args, dispatcher). If any of these +# isn't defined, the file body crashed partway through and the +# generated script is broken. +for helper in _codegraph_init _codegraph_query _codegraph_commands _codegraph_dispatch; do + if ! typeset -f "$helper" >/dev/null 2>&1; then + echo "FAIL [zsh:helper-defined]: $helper not defined after autoload" >&2 + fail=1 + fi +done + +exit $fail diff --git a/package.json b/package.json index 58f9f0ab..3425728d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:watch": "vitest", "test:eval": "vitest run __tests__/evaluation/", "eval": "npm run build && npx tsx __tests__/evaluation/runner.ts", + "smoke:completions": "bash docker/smoke-completions/run-host.sh", "clean": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true})\"" }, "keywords": [ From e69168555f0fad83759c5315fd2b11c1b2eca387 Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Thu, 21 May 2026 08:33:41 +0200 Subject: [PATCH 3/6] feat(completions): auto-detect install target + add PowerShell support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous --install logic wrote to a fixed path per shell, which meant zsh users got a file at ~/.zsh/completions/_codegraph and a follow-up "now edit ~/.zshrc to add this to your fpath" hint. That matches what gh, kubectl, rustup, Jottacloud all do — universally broken UX where the install isn't actually self-contained. This adds real detection so --install picks a location that's already on the shell's load path wherever possible: zsh: $ZSH/completions (oh-my-zsh) → /share/zsh/site-functions if writable → ~/.zsh/completions (fallback, with fpath hint) bash: /etc/bash_completion.d if writable → XDG ~/.local/share/bash-completion/completions fish: ~/.config/fish/completions (already auto-discovered) powershell: standalone ~/.config/powershell/codegraph.ps1 + idempotent dot-source line in $PROFILE The installer reports `(detected: )` on every run so users see which path won. Unknown shells (nushell, etc.) exit non-zero with a hint instead of writing somewhere wrong. PowerShell support follows the clap_complete static pattern: Register-ArgumentCompleter -Native, walk $commandAst.CommandElements into a semicolon-joined path, switch on that path, emit [CompletionResult] entries filtered by $wordToComplete. Tested non-interactively via TabExpansion2 inside pwsh -NoProfile — clean analog to fish's complete -C. Smoke harness extensions: - Dockerfile pulls pwsh from PowerShell GitHub releases (multi-arch tarball; MS's Debian apt repo has no arm64). PowerShell 7.6.1 pinned. - run.sh now parses the installer's output to find the path it actually wrote to — no longer assumes the fallback tier. - test-powershell.sh dot-sources the script and uses TabExpansion2. - test-zsh-ohmyzsh.sh creates a fake $ZSH dir to exercise tier-1. - Idempotency check: re-running powershell --install must not append the $PROFILE line twice. - Graceful-error check: `completions nushell` errors with "Unsupported shell" instead of writing somewhere wrong. vitest: 31 tests (up from 16) — adds detectInstallTarget coverage per shell, powershell emitter assertions, single-quote escape check. --- README.md | 32 ++- __tests__/completions.test.ts | 189 ++++++++++++- docker/smoke-completions/Dockerfile | 31 ++- docker/smoke-completions/README.md | 34 ++- docker/smoke-completions/run.sh | 82 +++++- docker/smoke-completions/test-bash.sh | 3 +- docker/smoke-completions/test-powershell.sh | 63 +++++ docker/smoke-completions/test-zsh-ohmyzsh.sh | 34 +++ docker/smoke-completions/test-zsh.sh | 9 +- src/bin/codegraph.ts | 55 +++- src/completions/index.ts | 20 +- src/completions/install.ts | 263 +++++++++++++++++-- src/completions/powershell.ts | 130 +++++++++ 13 files changed, 846 insertions(+), 99 deletions(-) create mode 100755 docker/smoke-completions/test-powershell.sh create mode 100755 docker/smoke-completions/test-zsh-ohmyzsh.sh create mode 100644 src/completions/powershell.ts diff --git a/README.md b/README.md index f7607153..8cf940fa 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ codegraph files [path] # Show file structure (--format, --filter, --m codegraph context # Build context for AI (--format, --max-nodes) codegraph affected [files...] # Find test files affected by changes (see below) codegraph serve --mcp # Start MCP server -codegraph completions # Generate shell completions (see below) +codegraph completions # Generate shell completions for zsh/bash/fish/powershell (see below) ``` ### `codegraph affected` @@ -359,27 +359,31 @@ fi ### `codegraph completions` -Generate a static completion script for your shell. Supported shells: `zsh`, `bash`, `fish`. +Generate a static completion script for your shell. Supported shells: `zsh`, `bash`, `fish`, `powershell` (aliases: `pwsh`, `ps`). ```bash -codegraph completions zsh --install # write to ~/.zsh/completions/_codegraph -codegraph completions bash --install # write to ~/.local/share/bash-completion/completions/codegraph -codegraph completions fish --install # write to ~/.config/fish/completions/codegraph.fish - -# Or pipe yourself: -codegraph completions zsh > ~/.zsh/completions/_codegraph +codegraph completions zsh --install # auto-detects best path +codegraph completions bash --install +codegraph completions fish --install +codegraph completions powershell --install ``` -**Zsh setup:** the install path must be on `$fpath` before `compinit`. Add to `~/.zshrc`: +With `--install`, the tool auto-detects the right location for your environment and writes there. The exact path is reported with a `(detected: …)` line so you know which tier was picked. + +| Shell | Detection priority | +|---|---| +| **zsh** | oh-my-zsh (`$ZSH/completions/`) → `/share/zsh/site-functions/` if writable → `~/.zsh/completions/` (fallback; prints fpath hint) | +| **bash** | `/etc/bash_completion.d/` if writable → XDG `~/.local/share/bash-completion/completions/` | +| **fish** | `~/.config/fish/completions/` (auto-discovered) | +| **powershell** | Writes a standalone `.ps1` to `~/.config/powershell/` (or `~/Documents/PowerShell/` on Windows), then idempotently appends a dot-source line to `$PROFILE` | + +To pipe yourself instead: ```bash -fpath=(~/.zsh/completions $fpath) -autoload -Uz compinit && compinit +codegraph completions zsh > ~/.zsh/completions/_codegraph ``` -**Bash setup:** requires the `bash-completion` package (`brew install bash-completion@2` on macOS). - -**Fish setup:** no extra config — fish auto-discovers `~/.config/fish/completions/`. +If you're on a shell we don't recognize (nushell, xonsh, etc.), `--install` exits non-zero with a hint; nothing is written. --- diff --git a/__tests__/completions.test.ts b/__tests__/completions.test.ts index 84fcf971..cc357df6 100644 --- a/__tests__/completions.test.ts +++ b/__tests__/completions.test.ts @@ -8,9 +8,17 @@ * pin the structural pieces (function names, value hints, alias dispatch). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Command } from 'commander'; -import { emit, parseShell, SUPPORTED_SHELLS, installPathFor } from '../src/completions'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + emit, + parseShell, + SUPPORTED_SHELLS, + detectInstallTarget, +} from '../src/completions'; const buildProgram = (): Command => { const program = new Command(); @@ -49,19 +57,127 @@ describe('completions/parseShell', () => { } }); + it('resolves common powershell aliases', () => { + expect(parseShell('pwsh')).toBe('powershell'); + expect(parseShell('PS')).toBe('powershell'); + expect(parseShell('ps1')).toBe('powershell'); + }); + it('rejects unknown shells', () => { - expect(parseShell('powershell')).toBeNull(); + expect(parseShell('nushell')).toBeNull(); expect(parseShell('')).toBeNull(); }); }); -describe('completions/installPathFor', () => { - it('returns per-shell standard paths', () => { - expect(installPathFor('zsh')).toMatch(/\.zsh\/completions\/_codegraph$/); - expect(installPathFor('bash')).toMatch( - /\.local\/share\/bash-completion\/completions\/codegraph$/, - ); - expect(installPathFor('fish')).toMatch(/\.config\/fish\/completions\/codegraph\.fish$/); +// ───────────────────────────────────────────────────────────────────── +// detectInstallTarget — exercises each tier by manipulating env + tmp +// dirs so we don't depend on whether the test machine has oh-my-zsh, +// Homebrew, etc. Each test owns its own tmp tree to avoid cross-talk. +// ───────────────────────────────────────────────────────────────────── + +describe('completions/detectInstallTarget', () => { + let tmpHome: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-install-')); + }); + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + describe('zsh', () => { + it('tier 1: picks oh-my-zsh when $ZSH points to a writable dir', () => { + const zshDir = path.join(tmpHome, '.oh-my-zsh'); + fs.mkdirSync(zshDir, { recursive: true }); + const target = detectInstallTarget('zsh', { ZSH: zshDir }, tmpHome); + expect(target).not.toBeNull(); + expect(target!.source).toBe('oh-my-zsh'); + expect(target!.path).toBe(path.join(zshDir, 'completions', '_codegraph')); + expect(target!.postInstallHint).toBeUndefined(); + }); + + it('tier 3: falls back to ~/.zsh/completions with fpath hint when no signals', () => { + // Empty env (no ZSH), and we pass a tmpHome that doesn't have + // /opt/homebrew or /usr/local — but `detectZsh` checks real + // filesystem paths for Homebrew, not under tmpHome. To make the + // test deterministic regardless of host, we accept that + // Homebrew tier may pick up if the test machine has it. The + // assertion below holds either way: when no oh-my-zsh signal is + // present, source is one of homebrew-zsh / zsh-fallback. + const target = detectInstallTarget('zsh', {}, tmpHome); + expect(target).not.toBeNull(); + expect(['zsh-site-functions', 'zsh-fallback']).toContain(target!.source); + if (target!.source === 'zsh-fallback') { + expect(target!.path).toBe( + path.join(tmpHome, '.zsh', 'completions', '_codegraph'), + ); + expect(target!.postInstallHint).toMatch(/fpath/); + } + }); + }); + + describe('bash', () => { + it('falls back to XDG bash-completion path under HOME when no Homebrew', () => { + // Same caveat as zsh: if test machine has /opt/homebrew/etc/ + // bash_completion.d writable, that tier wins. Otherwise XDG. + const target = detectInstallTarget('bash', {}, tmpHome); + expect(target).not.toBeNull(); + expect(['homebrew-bash-completion', 'xdg-bash-completion']).toContain( + target!.source, + ); + if (target!.source === 'xdg-bash-completion') { + expect(target!.path).toBe( + path.join( + tmpHome, + '.local', + 'share', + 'bash-completion', + 'completions', + 'codegraph', + ), + ); + } + }); + + it('honors $XDG_DATA_HOME override', () => { + const xdg = path.join(tmpHome, 'custom-xdg'); + fs.mkdirSync(xdg, { recursive: true }); + const target = detectInstallTarget('bash', { XDG_DATA_HOME: xdg }, tmpHome); + // Only assert XDG-tier behavior; Homebrew tier (if it wins on + // the host) doesn't read XDG_DATA_HOME so this check still + // exercises the XDG branch on most dev machines. + if (target?.source === 'xdg-bash-completion') { + expect(target.path).toBe( + path.join(xdg, 'bash-completion', 'completions', 'codegraph'), + ); + } + }); + }); + + describe('fish', () => { + it('always returns ~/.config/fish/completions/codegraph.fish', () => { + const target = detectInstallTarget('fish', {}, tmpHome); + expect(target).not.toBeNull(); + expect(target!.source).toBe('fish-config'); + expect(target!.path).toBe( + path.join(tmpHome, '.config', 'fish', 'completions', 'codegraph.fish'), + ); + }); + }); + + describe('powershell', () => { + it('returns a standalone .ps1 path + a profile path + a dot-source line', () => { + const target = detectInstallTarget('powershell', {}, tmpHome); + expect(target).not.toBeNull(); + expect(target!.source).toBe('pwsh-profile-dir'); + // Linux/macOS test runner — Windows branch tested in CI on win. + expect(target!.path).toContain(path.join('.config', 'powershell')); + expect(target!.path).toMatch(/codegraph\.ps1$/); + expect(target!.profilePath).toMatch(/Microsoft\.PowerShell_profile\.ps1$/); + expect(target!.profileLine).toMatch(/^\. '.*codegraph\.ps1'/); + expect(target!.profileLine).toContain('# codegraph completions'); + }); }); }); @@ -143,3 +259,56 @@ describe('completions/fish', () => { expect(out).toContain('-l limit -r -x'); }); }); + +describe('completions/powershell', () => { + const out = emit(buildProgram(), 'powershell'); + + it('opens with using-namespace declarations', () => { + expect(out).toContain('using namespace System.Management.Automation'); + expect(out).toContain('using namespace System.Management.Automation.Language'); + }); + + it('registers a Native completer for codegraph', () => { + expect(out).toContain( + "Register-ArgumentCompleter -Native -CommandName 'codegraph'", + ); + }); + + it('builds command path by joining elements with semicolons', () => { + expect(out).toContain("-join ';'"); + }); + + it('emits a switch arm for each canonical subcommand', () => { + expect(out).toContain("'codegraph;init' {"); + expect(out).toContain("'codegraph;query' {"); + expect(out).toContain("'codegraph;affected' {"); + }); + + it('emits a switch arm for each alias surface (so `a` works like `affected`)', () => { + expect(out).toContain("'codegraph;a' {"); + }); + + it('emits CompletionResult entries with ParameterName for flags', () => { + expect(out).toMatch( + /\[CompletionResult\]::new\('--index', '--index', \[CompletionResultType\]::ParameterName/, + ); + }); + + it('emits CompletionResult entries with ParameterValue for subcommands', () => { + expect(out).toMatch( + /\[CompletionResult\]::new\('init', 'init', \[CompletionResultType\]::ParameterValue/, + ); + }); + + it('filters by $wordToComplete prefix at the end', () => { + expect(out).toContain('$_.CompletionText -like "$wordToComplete*"'); + }); + + it("escapes single quotes in descriptions (PS '' escape rule)", () => { + const prog = new Command(); + prog.name('foo').version('0'); + prog.command('weird').description("it's tricky").action(() => {}); + const psOut = emit(prog, 'powershell'); + expect(psOut).toContain("it''s tricky"); + }); +}); diff --git a/docker/smoke-completions/Dockerfile b/docker/smoke-completions/Dockerfile index 1ddb7566..a9bc8079 100644 --- a/docker/smoke-completions/Dockerfile +++ b/docker/smoke-completions/Dockerfile @@ -1,9 +1,15 @@ # Smoke-test image for `codegraph completions` output. Runs the actual -# generated zsh/bash/fish scripts under their real shells against pinned -# versions. Image is intentionally minimal — no extras the test doesn't -# need. See README.md for what's verified and what isn't. +# generated zsh/bash/fish/powershell scripts under their real shells +# against pinned versions. Image is intentionally minimal — no extras +# the test doesn't need. See README.md for what's verified and what isn't. FROM node:22-bookworm-slim +# pwsh isn't in Microsoft's Debian apt repo for arm64, but the GitHub +# release tarballs are multi-arch. Fetch the right one based on the +# image's architecture so the smoke runs natively on Apple Silicon and +# x86_64 alike. Pinned to a specific PS version for build determinism. +ARG POWERSHELL_VERSION=7.6.1 + RUN apt-get update \ && apt-get install -y --no-install-recommends \ zsh \ @@ -11,9 +17,26 @@ RUN apt-get update \ bash-completion \ fish \ ca-certificates \ + curl \ + libicu72 \ + && arch=$(dpkg --print-architecture) \ + && case "$arch" in \ + amd64) ps_arch=x64 ;; \ + arm64) ps_arch=arm64 ;; \ + *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ + esac \ + && mkdir -p /opt/microsoft/powershell/7 \ + && curl -fsSL -o /tmp/pwsh.tgz \ + "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-${ps_arch}.tar.gz" \ + && tar -xzf /tmp/pwsh.tgz -C /opt/microsoft/powershell/7 \ + && chmod +x /opt/microsoft/powershell/7/pwsh \ + && ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \ + && rm /tmp/pwsh.tgz \ + && apt-get purge -y curl \ + && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* -COPY run.sh test-bash.sh test-fish.sh test-zsh.sh /smoke/ +COPY run.sh test-bash.sh test-fish.sh test-zsh.sh test-powershell.sh test-zsh-ohmyzsh.sh /smoke/ RUN chmod +x /smoke/*.sh WORKDIR /smoke diff --git a/docker/smoke-completions/README.md b/docker/smoke-completions/README.md index efad87d0..547ba04d 100644 --- a/docker/smoke-completions/README.md +++ b/docker/smoke-completions/README.md @@ -3,7 +3,7 @@ End-to-end verification that `codegraph completions ` produces a script that actually works when installed into a real shell. Runs in a pinned Docker image so the result doesn't depend on the developer's -local zsh/bash/fish versions. +local zsh/bash/fish/pwsh versions. ## Run @@ -11,16 +11,24 @@ local zsh/bash/fish versions. npm run smoke:completions ``` -Expected final line on success: `smoke: zsh bash fish OK`. +Expected final line on success: `smoke: zsh bash fish powershell OK`. ## What this proves - `npm pack` artifact installs cleanly via `npm install -g`. -- `codegraph completions --install` writes to the correct path - for zsh, bash, and fish, and the resulting file is non-empty. +- `codegraph completions --install` writes to the correct + detected path for each of zsh / bash / fish / powershell, and the + resulting file is non-empty. +- **Auto-detection (zsh tier 1)**: with `$ZSH` set to an oh-my-zsh + directory, the installer picks `$ZSH/completions/_codegraph` instead + of the `~/.zsh/completions` fallback. The installer messages report + `(detected: oh-my-zsh)`. +- **PowerShell idempotency**: re-running `--install` for powershell + does NOT duplicate the dot-source line in `$PROFILE`. - **bash**: the installed completion, sourced via `bash-completion`, - produces the expected COMPREPLY for top-level commands, subcommand - flags, options with `` values, and the global `--help` / `--version`. + registers via `complete -F` and produces the expected COMPREPLY for + top-level commands, subcommand flags, options with `` values, + and the global `--help` / `--version`. - **fish**: `complete -C "codegraph …"` returns the expected suggestions for top-level commands, subcommand flags, and file-hint options. - **zsh**: the script parses (`zsh -n`), is registered as an @@ -28,6 +36,9 @@ Expected final line on success: `smoke: zsh bash fish OK`. cross-section of per-subcommand helper functions is defined after autoload (proves the file body ran to completion — if any helper is missing, the script crashed mid-way). +- **powershell**: dot-sourced in a `pwsh -NoProfile` session, `TabExpansion2` + returns the expected `CompletionMatches` for top-level subcommands, + global flags, and subcommand flag completion. ## What this does NOT prove @@ -52,11 +63,12 @@ engineering effort from this smoke harness. ## What's tested -| Shell | Mechanism | Strength | -|-------|----------------------------------------------------|----------| -| bash | Source script, set `COMP_*`, call `_codegraph`, assert `COMPREPLY` | Full content | -| fish | `complete -C "codegraph …"` stdout assertions | Full content | -| zsh | Syntax + `compinit` load + cross-section of helpers defined | Structural | +| Shell | Mechanism | Strength | +|---|---|---| +| bash | Source script, set `COMP_*`, call `_codegraph`, assert `COMPREPLY` | Full content | +| fish | `complete -C "codegraph …"` stdout assertions | Full content | +| zsh | Syntax + `compinit` load + cross-section of helpers defined | Structural | +| powershell | Dot-source in `pwsh -NoProfile`, drive `TabExpansion2`, assert `CompletionMatches` | Full content | ## Architecture diff --git a/docker/smoke-completions/run.sh b/docker/smoke-completions/run.sh index 0791a5b1..9904558b 100755 --- a/docker/smoke-completions/run.sh +++ b/docker/smoke-completions/run.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Container entrypoint. Installs the codegraph tarball mounted at -# /pkg.tgz, installs completions for each shell to its standard -# location, then runs the per-shell assertions. +# /pkg.tgz, exercises `codegraph completions --install` for +# each shell, then runs the per-shell assertions against the path the +# installer actually wrote to (varies by tier — see install.ts). set -euo pipefail if [[ ! -f /pkg.tgz ]]; then @@ -12,17 +13,40 @@ fi echo "smoke: installing codegraph from /pkg.tgz" npm install -g /pkg.tgz >/dev/null -echo "smoke: generating + installing completion scripts" -codegraph completions zsh --install >/dev/null -codegraph completions bash --install >/dev/null -codegraph completions fish --install >/dev/null +# Capture the absolute install path from each installer run. The line +# we want is "✓ Installed completions to ". Strip ANSI +# color so the parse is stable across terminal types. +strip_ansi() { sed 's/\x1b\[[0-9;]*m//g'; } -# Quick sanity: the files actually landed where the installer says. -for f in "$HOME/.zsh/completions/_codegraph" \ - "$HOME/.local/share/bash-completion/completions/codegraph" \ - "$HOME/.config/fish/completions/codegraph.fish"; do - [[ -s "$f" ]] || { echo "smoke: FAIL — expected file missing or empty: $f" >&2; exit 1; } -done +install_and_capture() { + local shell=$1 + local out + out=$(codegraph completions "$shell" --install 2>&1 | strip_ansi) + # Mirror the installer's output to stderr so it's visible in logs + # without polluting stdout, which the caller captures into a variable. + echo "$out" >&2 + local p + p=$(echo "$out" | grep -E "Installed .* completions to " | sed 's/.* to //' | head -1) + if [[ -z "$p" || ! -s "$p" ]]; then + echo "smoke: FAIL — couldn't determine install path or file empty for $shell" >&2 + exit 1 + fi + printf '%s' "$p" +} + +echo "smoke: installing completions (auto-detected tier per shell)" +ZSH_PATH=$(install_and_capture zsh) +BASH_PATH=$(install_and_capture bash) +FISH_PATH=$(install_and_capture fish) +PS_PATH=$(install_and_capture powershell) +export ZSH_PATH BASH_PATH FISH_PATH PS_PATH + +# The PowerShell profile must contain our dot-source line. +PS_PROFILE="$HOME/.config/powershell/Microsoft.PowerShell_profile.ps1" +if ! grep -q "codegraph completions" "$PS_PROFILE"; then + echo "smoke: FAIL — $PS_PROFILE missing the dot-source line" >&2 + exit 1 +fi echo "smoke: running bash assertions" /smoke/test-bash.sh @@ -33,4 +57,36 @@ echo "smoke: running fish assertions" echo "smoke: running zsh assertions" /smoke/test-zsh.sh -echo "smoke: zsh bash fish OK" +echo "smoke: running powershell assertions" +/smoke/test-powershell.sh + +# Tier 1 zsh detection (oh-my-zsh): create a fake $ZSH dir, re-install, +# assert the file lands in $ZSH/completions instead of wherever the +# default install went. +echo "smoke: running zsh oh-my-zsh tier check" +/smoke/test-zsh-ohmyzsh.sh + +# Idempotency: re-running --install for powershell must NOT duplicate +# the dot-source line. +echo "smoke: powershell install idempotency check" +before=$(grep -c "codegraph completions" "$PS_PROFILE" || echo 0) +codegraph completions powershell --install >/dev/null +after=$(grep -c "codegraph completions" "$PS_PROFILE" || echo 0) +if [[ "$before" != "$after" ]]; then + echo "smoke: FAIL — powershell --install duplicated profile line ($before → $after)" >&2 + exit 1 +fi + +# Graceful no-op for unknown shells. +echo "smoke: unsupported shell check" +if codegraph completions nushell 2>/tmp/nu.err; then + echo "smoke: FAIL — unknown shell should error out, exited 0" >&2 + exit 1 +fi +if ! grep -q "Unsupported shell" /tmp/nu.err; then + echo "smoke: FAIL — unknown shell error doesn't mention 'Unsupported shell'" >&2 + cat /tmp/nu.err >&2 + exit 1 +fi + +echo "smoke: zsh bash fish powershell OK" diff --git a/docker/smoke-completions/test-bash.sh b/docker/smoke-completions/test-bash.sh index b8629026..16f52c3c 100755 --- a/docker/smoke-completions/test-bash.sh +++ b/docker/smoke-completions/test-bash.sh @@ -6,7 +6,8 @@ set -euo pipefail source /usr/share/bash-completion/bash_completion -source "$HOME/.local/share/bash-completion/completions/codegraph" +# $BASH_PATH is exported by run.sh — wherever the installer wrote the script. +source "${BASH_PATH:-$HOME/.local/share/bash-completion/completions/codegraph}" declare -i fail=0 diff --git a/docker/smoke-completions/test-powershell.sh b/docker/smoke-completions/test-powershell.sh new file mode 100755 index 00000000..2db4e43c --- /dev/null +++ b/docker/smoke-completions/test-powershell.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# PowerShell completion smoke. Dot-sources the installed script in a +# pwsh subshell and drives `TabExpansion2` (the non-interactive +# equivalent of `complete -C` for fish). Asserts completion content. +# +# We write the pwsh script to a temp file and pass it via `-File` +# instead of inlining via `-Command` — quoting pwsh inside a bash +# single-quoted string is unmanageable (the dollar signs needed for +# $env:VAR collide with bash's expansion rules and break silently). +set -euo pipefail + +PS_PATH="${PS_PATH:-$HOME/.config/powershell/codegraph.ps1}" +export PS_PATH + +tmp=$(mktemp /tmp/codegraph-smoke-XXXXXX.ps1) +trap 'rm -f "$tmp"' EXIT + +cat > "$tmp" <<'PSSCRIPT' +$ErrorActionPreference = "Stop" +. $env:PS_PATH + +$script:fail = $false + +function Assert-Contains { + param([string]$Label, [string]$Needle, [string[]]$Haystack) + if ($Haystack -notcontains $Needle) { + Write-Host "FAIL [powershell:$Label]: missing '$Needle' in [$($Haystack -join ', ')]" + $script:fail = $true + } +} + +function Assert-NonEmpty { + param([string]$Label, [object]$Result) + if ($Result.CompletionMatches.Count -eq 0) { + Write-Host "FAIL [powershell:$Label]: expected non-empty completions" + $script:fail = $true + } +} + +# 1. Top-level subcommand list. +$r = TabExpansion2 -inputScript "codegraph " -cursorColumn ("codegraph ".Length) +Assert-NonEmpty "top-level" $r +$names = $r.CompletionMatches.CompletionText +Assert-Contains "top-level/init" "init" $names +Assert-Contains "top-level/query" "query" $names +Assert-Contains "top-level/completions" "completions" $names + +# 2. Top-level flags surface from the root switch arm. +Assert-Contains "top-level/--help" "--help" $names +Assert-Contains "top-level/--version" "--version" $names + +# 3. Subcommand flag completion. +$line = "codegraph init -" +$r = TabExpansion2 -inputScript $line -cursorColumn $line.Length +Assert-NonEmpty "init/-" $r +$flags = $r.CompletionMatches.CompletionText +Assert-Contains "init/--index" "--index" $flags +Assert-Contains "init/-i" "-i" $flags + +if ($script:fail) { exit 1 } else { Write-Output "powershell smoke OK"; exit 0 } +PSSCRIPT + +pwsh -NoProfile -File "$tmp" diff --git a/docker/smoke-completions/test-zsh-ohmyzsh.sh b/docker/smoke-completions/test-zsh-ohmyzsh.sh new file mode 100755 index 00000000..85958078 --- /dev/null +++ b/docker/smoke-completions/test-zsh-ohmyzsh.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Tier-1 zsh detection check. Creates a fake oh-my-zsh layout (just an +# empty $ZSH dir), re-runs `codegraph completions zsh --install`, and +# asserts the file lands in $ZSH/completions/ — proving the detection +# picks the oh-my-zsh path over the ~/.zsh fallback. +set -euo pipefail + +FAKE_ZSH="$HOME/.oh-my-zsh-fake" +mkdir -p "$FAKE_ZSH" + +# Re-install with $ZSH set. We don't want to disturb the existing +# tier-3 install at ~/.zsh/completions/_codegraph (the prior test +# asserted it landed there), so this is a strict additive check. +ZSH="$FAKE_ZSH" codegraph completions zsh --install >/tmp/ohmyzsh-install.out + +target="$FAKE_ZSH/completions/_codegraph" +if [[ ! -s "$target" ]]; then + echo "FAIL [zsh:tier-1]: expected oh-my-zsh tier to write $target" >&2 + echo "--- installer output ---" >&2 + cat /tmp/ohmyzsh-install.out >&2 + exit 1 +fi + +# The installer message should report the oh-my-zsh source. +if ! grep -q "oh-my-zsh" /tmp/ohmyzsh-install.out; then + echo "FAIL [zsh:tier-1]: installer didn't report oh-my-zsh as detected source" >&2 + cat /tmp/ohmyzsh-install.out >&2 + exit 1 +fi + +# Cleanup so subsequent test runs in the same image don't leak state. +rm -rf "$FAKE_ZSH" + +echo "zsh oh-my-zsh tier OK" diff --git a/docker/smoke-completions/test-zsh.sh b/docker/smoke-completions/test-zsh.sh index b71a5d1f..7ee2d03e 100755 --- a/docker/smoke-completions/test-zsh.sh +++ b/docker/smoke-completions/test-zsh.sh @@ -16,10 +16,13 @@ set -e declare -i fail=0 # 1. Syntax check. -zsh -n "$HOME/.zsh/completions/_codegraph" || { echo "FAIL [zsh:syntax]" >&2; exit 1; } +zsh -n "${ZSH_PATH:-$HOME/.zsh/completions/_codegraph}" || { echo "FAIL [zsh:syntax]" >&2; exit 1; } -# 2. compinit must load the file as an autoloadable function. -fpath=("$HOME/.zsh/completions" $fpath) +# 2. compinit must load the file as an autoloadable function. Add the +# install dir to $fpath — for tier-2 installs into +# /usr/local/share/zsh/site-functions this is a no-op (already there); +# for tier-3 (~/.zsh/completions fallback) it's required. +fpath=("${ZSH_PATH:A:h}" $fpath) autoload -Uz compinit # -u: don't bail on insecure dirs (container HOME is owned by root, fine) # -d: per-run dump in /tmp so we don't pollute HOME diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 52e82c29..20ebd3c4 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1384,11 +1384,17 @@ program /** * codegraph completions [--install] + * + * Without --install: prints the script to stdout. Safe to redirect. + * With --install: detects the best target for the environment + * (oh-my-zsh, Homebrew zsh/bash-completion, fish config, pwsh $PROFILE) + * and writes there. If no target can be detected (e.g., pwsh on a + * system with no detectable profile dir), exits non-zero with a hint. */ program .command('completions ') - .description('Generate shell completions (zsh, bash, fish)') - .option('--install', 'Install to the standard location for the shell') + .description('Generate shell completions (zsh, bash, fish, powershell)') + .option('--install', 'Auto-install to the best location for your environment') .action(async (shellArg: string, options: { install?: boolean }) => { const { parseShell, emit, installCompletions, SUPPORTED_SHELLS } = await import( '../completions' @@ -1396,25 +1402,46 @@ program const shell = parseShell(shellArg); if (!shell) { error( - `Unsupported shell '${shellArg}'. Supported: ${SUPPORTED_SHELLS.join(', ')}.`, + `Unsupported shell '${shellArg}'. Supported: ${SUPPORTED_SHELLS.join(', ')} (aliases: pwsh, ps).`, ); process.exit(1); } const script = emit(program, shell); - if (options.install) { - try { - const { path: installedAt, postInstallHint } = installCompletions(shell, script); - success(`Installed ${shell} completions to ${installedAt}`); - if (postInstallHint) { - console.log(''); - console.log(postInstallHint); + if (!options.install) { + process.stdout.write(script); + return; + } + try { + const result = installCompletions(shell, script); + if (!result) { + error( + `Could not detect an install location for ${shell} on this system.`, + ); + console.log(''); + console.log('Write the script yourself:'); + console.log(` codegraph completions ${shell} > /path/of/your/choice`); + if (shell === 'powershell') { + console.log('Then dot-source it from your $PROFILE.'); } - } catch (err) { - error(err instanceof Error ? err.message : String(err)); process.exit(1); } - } else { - process.stdout.write(script); + const { target, profileUpdated } = result; + success(`Installed ${shell} completions to ${target.path}`); + console.log(` (detected: ${target.source})`); + if (shell === 'powershell' && target.profilePath) { + if (profileUpdated === true) { + console.log(` Appended dot-source line to ${target.profilePath}`); + } else if (profileUpdated === false) { + console.log(` ${target.profilePath} already references this script — left untouched`); + } + } + if (target.postInstallHint) { + console.log(''); + console.log(target.postInstallHint); + } + } catch (err) { + error(err instanceof Error ? err.message : String(err)); + process.exit(1); } }); diff --git a/src/completions/index.ts b/src/completions/index.ts index ea0a047c..8267a6b9 100644 --- a/src/completions/index.ts +++ b/src/completions/index.ts @@ -14,14 +14,23 @@ import type { Command } from 'commander'; import { emitZsh } from './zsh'; import { emitBash } from './bash'; import { emitFish } from './fish'; +import { emitPowershell } from './powershell'; -export type Shell = 'zsh' | 'bash' | 'fish'; +export type Shell = 'zsh' | 'bash' | 'fish' | 'powershell'; -export const SUPPORTED_SHELLS: readonly Shell[] = ['zsh', 'bash', 'fish'] as const; +export const SUPPORTED_SHELLS: readonly Shell[] = ['zsh', 'bash', 'fish', 'powershell'] as const; + +// Accept common spellings (`pwsh`, `ps`) — easier than asking users to remember. +const SHELL_ALIASES: Record = { + pwsh: 'powershell', + ps: 'powershell', + ps1: 'powershell', +}; export const parseShell = (s: string): Shell | null => { const lower = s.toLowerCase(); - return (SUPPORTED_SHELLS as readonly string[]).includes(lower) ? (lower as Shell) : null; + if ((SUPPORTED_SHELLS as readonly string[]).includes(lower)) return lower as Shell; + return SHELL_ALIASES[lower] ?? null; }; export const emit = (program: Command, shell: Shell): string => { @@ -32,7 +41,10 @@ export const emit = (program: Command, shell: Shell): string => { return emitBash(program); case 'fish': return emitFish(program); + case 'powershell': + return emitPowershell(program); } }; -export { installCompletions, installPathFor } from './install'; +export { installCompletions, detectInstallTarget } from './install'; +export type { InstallTarget, InstallResult } from './install'; diff --git a/src/completions/install.ts b/src/completions/install.ts index ba721010..4932ef95 100644 --- a/src/completions/install.ts +++ b/src/completions/install.ts @@ -1,7 +1,25 @@ /** - * Writes a generated completion script to the standard per-shell - * location. Paths match what sema-lisp and fedit use so users with - * existing fpath/bash-completion config don't need new configuration. + * Detects the best install location for each shell, given the running + * environment, and writes the generated script there. The detection + * priorities are deliberate — the goal is "Tab works after install, + * with zero follow-up config" wherever possible. + * + * Why per-shell tiers (not a single fixed path): + * - On zsh, ~/.zsh/completions isn't on $fpath by default, so the + * naive "drop a file there" install requires the user to edit + * ~/.zshrc. We can avoid that entirely by detecting oh-my-zsh + * (always on $fpath via $ZSH/completions) or Homebrew zsh (on + * $fpath via `brew shellenv`) and using those when present. + * - On bash, Homebrew bash-completion@2 lives at a different path + * than the Linux XDG convention. + * - On PowerShell, there is no completions directory at all — only + * $PROFILE dot-sourcing works. The install writes a standalone + * .ps1 next to $PROFILE and idempotently appends a dot-source line. + * + * Detection is best-effort and conservative: every tier checks that + * its target is writable before committing. The final fallback always + * returns *something* on a recognized platform; only obscure setups + * (e.g., codegraph on Windows targeting bash) return null. */ import * as fs from 'fs'; @@ -9,36 +27,231 @@ import * as os from 'os'; import * as path from 'path'; import type { Shell } from './index'; -export const installPathFor = (shell: Shell): string => { - const home = os.homedir(); +export interface InstallTarget { + /** Absolute path the script will be written to. */ + path: string; + /** Which detection rule picked this path (used in messages). */ + source: + | 'oh-my-zsh' + | 'zsh-site-functions' + | 'homebrew-bash-completion' + | 'xdg-bash-completion' + | 'fish-config' + | 'pwsh-profile-dir' + | 'zsh-fallback'; + /** Human-readable explanation of how to make the install effective if needed. */ + postInstallHint?: string; + /** + * For PowerShell: append this line to $PROFILE to load the script. + * The installer handles the append idempotently; this is also surfaced + * in messages for transparency. + */ + profileLine?: string; + /** + * Path to the PowerShell $PROFILE file that needs the dot-source line. + * Null if we couldn't determine it (e.g., pwsh not in PATH on this OS). + */ + profilePath?: string; +} + +export interface InstallResult { + target: InstallTarget; + /** True if we appended to $PROFILE; false if it was already wired up. */ + profileUpdated?: boolean; +} + +// ───────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────── + +const isWritableDir = (dir: string): boolean => { + try { + fs.accessSync(dir, fs.constants.W_OK); + const st = fs.statSync(dir); + return st.isDirectory(); + } catch { + return false; + } +}; + +const canCreateIn = (dir: string): boolean => { + // Walk up to the first existing ancestor; if it's writable, we can + // mkdir -p. Returns false if no ancestor is writable. + let cur = dir; + while (cur && cur !== path.dirname(cur)) { + if (fs.existsSync(cur)) return isWritableDir(cur); + cur = path.dirname(cur); + } + return false; +}; + +// ───────────────────────────────────────────────────────────────────── +// Per-shell detection +// ───────────────────────────────────────────────────────────────────── + +const detectZsh = (env: NodeJS.ProcessEnv, home: string): InstallTarget => { + // Tier 1: oh-my-zsh. $ZSH points at the oh-my-zsh install dir and + // $ZSH/completions is always on $fpath via oh-my-zsh's bootstrap. + // No .zshrc edit needed — true zero-config install. + const zshDir = env.ZSH; + if (zshDir && fs.existsSync(zshDir) && isWritableDir(zshDir)) { + const target = path.join(zshDir, 'completions', '_codegraph'); + if (canCreateIn(path.dirname(target))) { + return { path: target, source: 'oh-my-zsh' }; + } + } + + // Tier 2: `/share/zsh/site-functions`. Apple Silicon Homebrew + // is /opt/homebrew, Intel Homebrew + most Linux distros use /usr/local. + // Homebrew puts this on $fpath via `brew shellenv`; zsh adds /usr/local + // to its default $fpath at compile time on most Linux builds. If the + // dir exists and is writable, the install is zero-config. + for (const prefix of ['/opt/homebrew', '/usr/local']) { + const dir = path.join(prefix, 'share', 'zsh', 'site-functions'); + if (isWritableDir(dir)) { + return { path: path.join(dir, '_codegraph'), source: 'zsh-site-functions' }; + } + } + + // Tier 3: ~/.zsh/completions fallback. Not on default $fpath; print + // the .zshrc snippet so users can wire it up. + return { + path: path.join(home, '.zsh', 'completions', '_codegraph'), + source: 'zsh-fallback', + postInstallHint: + '~/.zsh/completions is not on your $fpath by default.\n' + + 'Add to ~/.zshrc (before `compinit`):\n' + + ' fpath=(~/.zsh/completions $fpath)\n' + + ' autoload -Uz compinit && compinit', + }; +}; + +const detectBash = (home: string): InstallTarget | null => { + // macOS Homebrew bash-completion@2 lives at $(brew --prefix)/etc/ + // bash_completion.d/. If that dir exists and is writable, use it — + // bash-completion auto-loads from there with no further config. + for (const prefix of ['/opt/homebrew', '/usr/local']) { + const dir = path.join(prefix, 'etc', 'bash_completion.d'); + if (isWritableDir(dir)) { + return { path: path.join(dir, 'codegraph'), source: 'homebrew-bash-completion' }; + } + } + + // XDG user-local path that bash-completion v2 auto-loads. Works on + // Linux + macOS-without-Homebrew, provided bash-completion is + // installed (we can't check this from Node, so just print a hint). + const xdg = + process.env.XDG_DATA_HOME ?? path.join(home, '.local', 'share'); + const target = path.join(xdg, 'bash-completion', 'completions', 'codegraph'); + // canCreateIn lets us return this path even if the directory doesn't + // yet exist — mkdir -p handles creation. The check guards against + // permission failures (e.g., XDG_DATA_HOME pointing at a read-only path). + if (canCreateIn(path.dirname(target))) { + return { + path: target, + source: 'xdg-bash-completion', + postInstallHint: + 'Requires the `bash-completion` package. On macOS install via\n' + + ' brew install bash-completion@2\n' + + 'and follow the post-install steps to source it from your bashrc.', + }; + } + return null; +}; + +const detectFish = (home: string): InstallTarget => ({ + path: path.join(home, '.config', 'fish', 'completions', 'codegraph.fish'), + source: 'fish-config', +}); + +const detectPowershell = (env: NodeJS.ProcessEnv, home: string): InstallTarget | null => { + // PowerShell has no completions directory. The convention is to keep + // a standalone .ps1 and dot-source it from $PROFILE. + // + // We can't run pwsh to read $PROFILE (it may not be on PATH); use the + // documented per-OS default that pwsh itself uses on first launch. + // + // Windows pwsh 7: ~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1 + // Windows PS 5.1: ~/Documents/WindowsPowerShell/... + // macOS/Linux pwsh: ~/.config/powershell/Microsoft.PowerShell_profile.ps1 + let profileDir: string; + if (process.platform === 'win32') { + // Prefer pwsh 7 path. PS 5.1 users have to install manually — they + // can still source the .ps1 by hand if our chosen profile path + // doesn't match. + profileDir = path.join(home, 'Documents', 'PowerShell'); + } else { + profileDir = path.join(home, '.config', 'powershell'); + } + // We need at least to be able to create the dir — pwsh creates it + // on first run, but if we're installing without pwsh ever having run, + // the parent of profileDir must exist and be writable. + if (!canCreateIn(profileDir)) return null; + + const scriptPath = path.join(profileDir, 'codegraph.ps1'); + const profilePath = path.join(profileDir, 'Microsoft.PowerShell_profile.ps1'); + // The exact line we append to $PROFILE. The leading marker comment + // is used by the installer to detect "already wired up" and skip + // re-appending on repeat runs. + const profileLine = `. '${scriptPath}' # codegraph completions`; + void env; + return { + path: scriptPath, + source: 'pwsh-profile-dir', + profilePath, + profileLine, + postInstallHint: + `Dot-sourced from $PROFILE on next pwsh launch.\n` + + `If your $PROFILE is somewhere else, add manually:\n ${profileLine}`, + }; +}; + +// ───────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────── + +export const detectInstallTarget = ( + shell: Shell, + env: NodeJS.ProcessEnv = process.env, + home: string = os.homedir(), +): InstallTarget | null => { switch (shell) { case 'zsh': - return path.join(home, '.zsh', 'completions', '_codegraph'); + return detectZsh(env, home); case 'bash': - return path.join(home, '.local', 'share', 'bash-completion', 'completions', 'codegraph'); + return detectBash(home); case 'fish': - return path.join(home, '.config', 'fish', 'completions', 'codegraph.fish'); + return detectFish(home); + case 'powershell': + return detectPowershell(env, home); } }; -export interface InstallResult { - path: string; - postInstallHint?: string; -} +export const installCompletions = (shell: Shell, script: string): InstallResult | null => { + const target = detectInstallTarget(shell); + if (!target) return null; -export const installCompletions = (shell: Shell, script: string): InstallResult => { - const target = installPathFor(shell); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, script, 'utf8'); + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, script, 'utf8'); - let postInstallHint: string | undefined; - if (shell === 'zsh') { - // ~/.zsh/completions isn't on `$fpath` by default, so first-time - // users would install the script and see no completions. Tell them. - postInstallHint = - 'Add to ~/.zshrc (before `compinit`):\n' + - ' fpath=(~/.zsh/completions $fpath)\n' + - ' autoload -Uz compinit && compinit'; + // PowerShell: idempotently append a dot-source line to $PROFILE so + // the registration actually fires in new pwsh sessions. If the line + // is already there (substring match on the marker comment), skip. + let profileUpdated: boolean | undefined; + if (shell === 'powershell' && target.profilePath && target.profileLine) { + const marker = '# codegraph completions'; + const existing = fs.existsSync(target.profilePath) + ? fs.readFileSync(target.profilePath, 'utf8') + : ''; + if (existing.includes(marker)) { + profileUpdated = false; + } else { + fs.mkdirSync(path.dirname(target.profilePath), { recursive: true }); + const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''; + fs.appendFileSync(target.profilePath, `${prefix}${target.profileLine}\n`); + profileUpdated = true; + } } - return { path: target, postInstallHint }; + + return { target, profileUpdated }; }; diff --git a/src/completions/powershell.ts b/src/completions/powershell.ts new file mode 100644 index 00000000..a4d3b70e --- /dev/null +++ b/src/completions/powershell.ts @@ -0,0 +1,130 @@ +/** + * PowerShell completion emitter. Hand-written, static (no callback + * into the binary). Follows the clap_complete idiom exactly because + * it's the most-deployed PS completion pattern (every Rust CLI uses + * it) and PowerShell's `TabExpansion2` testability makes the static + * path much friendlier than zsh's. + * + * The shape: + * Register-ArgumentCompleter -Native -CommandName codegraph -ScriptBlock { + * # Walk $commandAst.CommandElements → join non-flag, non-value + * # tokens with ';' → "codegraph", "codegraph;init", etc. + * # switch on that path string → emit subcommand + option candidates + * # filter by $wordToComplete prefix + * } + * + * Known clap_complete bugs we sidestep: + * #5820 — single-quote escaping. PS single-quoted strings escape + * a single quote by doubling it. We double-escape descriptions. + * #5341 — empty help strings break completions. We default to the + * item name when description is empty. + */ + +import type { Command } from 'commander'; +import { describeCommand, type CommandDesc, type OptionDesc } from './introspect'; + +// PowerShell single-quoted strings: '' is the only escape needed. +const psEscape = (s: string): string => s.replace(/'/g, "''"); + +// Empty descriptions break completion rendering; fall back to the +// item itself so the menu always has something to show. +const descOrName = (desc: string, name: string): string => + desc.trim().length > 0 ? desc : name; + +const optionResult = (opt: OptionDesc): string[] => { + // Emit one CompletionResult line per surface form. Short and long + // forms get separate entries so both show up in the menu. + const desc = psEscape(descOrName(opt.description, opt.long ?? opt.short ?? '')); + const out: string[] = []; + for (const flag of [opt.short, opt.long]) { + if (!flag) continue; + out.push( + ` [CompletionResult]::new('${psEscape(flag)}', '${psEscape(flag)}', [CompletionResultType]::ParameterName, '${desc}')`, + ); + } + return out; +}; + +const commandResult = (sub: CommandDesc): string[] => { + // Subcommand candidate plus all its aliases. Each gets its own + // CompletionResult so they all appear in the menu. + const desc = psEscape(descOrName(sub.description, sub.name)); + return [sub.name, ...sub.aliases].map( + (n) => + ` [CompletionResult]::new('${psEscape(n)}', '${psEscape(n)}', [CompletionResultType]::ParameterValue, '${desc}')`, + ); +}; + +const switchCaseFor = (commandPath: string, cmd: CommandDesc, isRoot: boolean): string => { + // PS switch case: 'codegraph;init' or 'codegraph'. The body lists + // valid candidates at that point: subcommands (if any) + options. + const lines: string[] = []; + // Subcommands first (only meaningful for the root; per-sub cases + // don't dispatch deeper because codegraph is flat — no sub-subs). + for (const sub of cmd.subcommands) { + lines.push(...commandResult(sub)); + } + // Options. + for (const opt of cmd.options) { + lines.push(...optionResult(opt)); + } + // At the root, expose --help / --version always. + if (isRoot) { + lines.push( + ` [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Show help')`, + ` [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Show version')`, + ); + } + return ` '${psEscape(commandPath)}' { +${lines.join('\n')} + break + }`; +}; + +export const emitPowershell = (program: Command): string => { + const root = describeCommand(program); + const cmdName = root.name; + + const cases: string[] = [switchCaseFor(cmdName, root, true)]; + for (const sub of root.subcommands) { + // Cover canonical name + each alias as a separate switch arm so + // `codegraph a` (alias for `affected`) works the same as + // `codegraph affected`. + for (const surface of [sub.name, ...sub.aliases]) { + cases.push(switchCaseFor(`${cmdName};${surface}`, sub, false)); + } + } + + return `using namespace System.Management.Automation +using namespace System.Management.Automation.Language + +# PowerShell completion for ${cmdName}. +# Generated by \`${cmdName} completions powershell\` — do not edit by hand. +# Install: dot-source this file from your \$PROFILE, e.g. +# Add-Content \$PROFILE ". ''" + +Register-ArgumentCompleter -Native -CommandName '${psEscape(cmdName)}' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commandElements = $commandAst.CommandElements + $command = @( + '${psEscape(cmdName)}' + for ($i = 1; $i -lt $commandElements.Count; $i++) { + $element = $commandElements[$i] + if ($element -isnot [StringConstantExpressionAst] -or + $element.StringConstantType -ne [StringConstantType]::BareWord -or + $element.Value.StartsWith('-') -or + $element.Value -eq $wordToComplete) { break } + $element.Value + } + ) -join ';' + + $completions = @(switch ($command) { +${cases.join('\n')} + }) + + $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | + Sort-Object -Property ListItemText +} +`; +}; From c2e6ac643ccb509b6052b889fdcbefff370f0915 Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Thu, 21 May 2026 08:52:47 +0200 Subject: [PATCH 4/6] docs(completions): expand README section with per-shell setup The prior section covered the detection table but skipped the practical follow-up: what to do after --install, when to restart the shell, which shells need extra packages, what the fpath hint actually looks like. Without these, users land on the section, install, and then wonder why Tab does nothing. Adds: - "Restart your shell" callout next to the --install example - Stdout/pipe example block for each shell (the "without --install" path was implicit before) - Per-shell requirements: bash-completion install for bash, fpath snippet for zsh fallback tier (with note that tier 1/2 don't need it), pwsh 7.x vs 5.1 caveat - PowerShell idempotency note moved into the detection table --- README.md | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8cf940fa..abb96f1e 100644 --- a/README.md +++ b/README.md @@ -368,22 +368,36 @@ codegraph completions fish --install codegraph completions powershell --install ``` -With `--install`, the tool auto-detects the right location for your environment and writes there. The exact path is reported with a `(detected: …)` line so you know which tier was picked. +With `--install`, codegraph picks the right location for your environment and writes there. The exact path is reported as `(detected: )` so you can see which rule fired. **Restart your shell** (or `exec $SHELL`) to pick up the new completions. | Shell | Detection priority | |---|---| -| **zsh** | oh-my-zsh (`$ZSH/completions/`) → `/share/zsh/site-functions/` if writable → `~/.zsh/completions/` (fallback; prints fpath hint) | +| **zsh** | oh-my-zsh (`$ZSH/completions/`) → `/share/zsh/site-functions/` if writable → `~/.zsh/completions/` (fallback; prints `fpath` hint) | | **bash** | `/etc/bash_completion.d/` if writable → XDG `~/.local/share/bash-completion/completions/` | | **fish** | `~/.config/fish/completions/` (auto-discovered) | -| **powershell** | Writes a standalone `.ps1` to `~/.config/powershell/` (or `~/Documents/PowerShell/` on Windows), then idempotently appends a dot-source line to `$PROFILE` | +| **powershell** | Standalone `.ps1` in `~/.config/powershell/` (`~/Documents/PowerShell/` on Windows) + idempotent dot-source line in `$PROFILE`. Re-running `--install` won't duplicate the line. | -To pipe yourself instead: +**Without `--install`** the script goes to stdout — pipe it wherever you want: ```bash -codegraph completions zsh > ~/.zsh/completions/_codegraph +codegraph completions zsh > ~/.zsh/completions/_codegraph +codegraph completions bash > ~/.local/share/bash-completion/completions/codegraph +codegraph completions fish > ~/.config/fish/completions/codegraph.fish +codegraph completions powershell > ~/codegraph-completion.ps1 # then dot-source from $PROFILE ``` -If you're on a shell we don't recognize (nushell, xonsh, etc.), `--install` exits non-zero with a hint; nothing is written. +**Per-shell requirements:** +- **bash**: needs the `bash-completion` package. macOS: `brew install bash-completion@2` (and follow its post-install instructions). +- **zsh** (fallback tier only): if codegraph wrote to `~/.zsh/completions/_codegraph`, add this to `~/.zshrc` *before* `compinit`: + ```zsh + fpath=(~/.zsh/completions $fpath) + autoload -Uz compinit && compinit + ``` + If the install reported `(detected: oh-my-zsh)` or `(detected: zsh-site-functions)`, this step is unnecessary. +- **fish**: no extra config — auto-discovered. +- **powershell**: works in PowerShell 7.x (`pwsh`). Windows PowerShell 5.1 also works but uses a different `$PROFILE` path; if our chosen path doesn't match yours, dot-source the `.ps1` manually. + +If you're on a shell we don't recognize (`nushell`, `xonsh`, etc.), the command exits non-zero with a hint and writes nothing. --- From cb2389e6ca7eaebcdd061f7311c0f6cb8f845742 Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Thu, 21 May 2026 11:37:41 +0200 Subject: [PATCH 5/6] fix(completions): inject --help/-h once, drop duplicate --version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found via interactive pwsh testing on local macOS: `codegraph ` listed `--version` twice and was missing `-h`. Root causes: 1. Commander 14's `.version()` registers `-V/--version` on `program.options`, which the introspector already walked — so my hand-added `--version` line in the powershell + bash emitters was a duplicate. 2. Commander does NOT expose `-h/--help` via `program.options` (helpOption sits behind internal machinery). My emitters relied on per-shell hardcoded lines that I only added at the root, meaning subcommand completions silently dropped --help. Fix at the introspect layer: synthesize the help option once in describeCommand() so every command (root + subs) sees it as a regular option. All four emitters get it for free and the duplicate hardcoded lines come out. After fix, `codegraph ` in pwsh shows: --help / -h (new — was missing) --version / -V (no longer duplicated) 13 subcommands And `codegraph init -` now correctly shows --help/-h alongside the per-subcommand flags. Verified via TabExpansion2 on local pwsh 7.7 (macOS) + full Docker smoke (zsh bash fish powershell OK). 31 vitest tests still pass. --- src/completions/bash.ts | 2 +- src/completions/introspect.ts | 16 +++++++++++++++- src/completions/powershell.ts | 11 ++++------- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/completions/bash.ts b/src/completions/bash.ts index a7342103..62e5ba8d 100644 --- a/src/completions/bash.ts +++ b/src/completions/bash.ts @@ -99,7 +99,7 @@ _codegraph() { if [[ -z "$sub" ]]; then if [[ "$cur" == -* ]]; then - COMPREPLY=( $(compgen -W "${rootFlags} --help --version" -- "$cur") ) + COMPREPLY=( $(compgen -W "${rootFlags}" -- "$cur") ) else COMPREPLY=( $(compgen -W "${subNames}" -- "$cur") ) fi diff --git a/src/completions/introspect.ts b/src/completions/introspect.ts index 5d28b78d..fa4cb5e1 100644 --- a/src/completions/introspect.ts +++ b/src/completions/introspect.ts @@ -80,12 +80,26 @@ const describeArg = (arg: CommanderArgument): ArgDesc => ({ hint: hintForName(arg.name()), }); +// Commander auto-registers `-h, --help` on every command but doesn't +// expose it via `cmd.options` (it lives behind internal helpOption +// machinery). Inject it explicitly so completion menus actually +// include it — every other CLI's user expects --help on Tab. +const helpOption: OptionDesc = { + short: '-h', + long: '--help', + flags: '-h, --help', + description: 'Display help for command', + takesValue: false, + valueHint: 'none', + negate: false, +}; + export const describeCommand = (cmd: Command): CommandDesc => ({ name: cmd.name(), aliases: cmd.aliases(), description: cmd.description(), // `registeredArguments` is commander 10+ — the project pins ^14, so safe. args: (cmd as Command & { registeredArguments: CommanderArgument[] }).registeredArguments.map(describeArg), - options: cmd.options.map(describeOption), + options: [...cmd.options.map(describeOption), helpOption], subcommands: cmd.commands.map(describeCommand), }); diff --git a/src/completions/powershell.ts b/src/completions/powershell.ts index a4d3b70e..8b89728a 100644 --- a/src/completions/powershell.ts +++ b/src/completions/powershell.ts @@ -68,13 +68,10 @@ const switchCaseFor = (commandPath: string, cmd: CommandDesc, isRoot: boolean): for (const opt of cmd.options) { lines.push(...optionResult(opt)); } - // At the root, expose --help / --version always. - if (isRoot) { - lines.push( - ` [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Show help')`, - ` [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Show version')`, - ); - } + // --help / --version are now surfaced via the introspect layer + // (helpOption is injected; --version comes from .version() being + // registered as a regular option). No special-casing needed here. + void isRoot; return ` '${psEscape(commandPath)}' { ${lines.join('\n')} break From 45b8c3b56f8c6e7d8d7e4f2a4b17c00d7b2ed101 Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Thu, 21 May 2026 12:21:16 +0200 Subject: [PATCH 6/6] test(completions): regression coverage for --help/-h on subs and --version dedupe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cb2389e fixed two bugs found via interactive pwsh: 1. duplicate --version at root (commander auto-registered + my hardcoded) 2. missing -h/--help on subcommands (commander hides help from .options) Existing assertions used set-membership checks (`contains "--help"`) which would have passed even with the bugs present. This adds: vitest (8 new assertions, now 39 total): - zsh: count of `'(-h --help)'{-h,--help}` specs == root + every sub - bash: every flag-completion compgen list (filtered by content, not by the surrounding command line) contains --help - fish: count of `-s h -l help` declarations >= 4 - powershell: every switch arm contains both '--help' and '-h' - powershell: --version appears exactly 2 times (one CompletionResult emission = 2 string literals); >2 means duplicated - bash root flag list contains --version exactly once - fish root contains `-l version` exactly once - zsh `(-V --version)` pair appears exactly once smoke (per-shell runtime assertions): - bash: count -x --version in top-level COMPREPLY == 1; init subcommand COMPREPLY contains --help + -h - fish: complete -C "codegraph init -" contains --help + -h - powershell: init/--help, init/-h; top-level -h, -V; --version count via Where-Object pipeline == 1 Both layers catch both bug classes — vitest fast (~750 ms), smoke slower but exercises the actual installed scripts in real shells. --- __tests__/completions.test.ts | 97 +++++++++++++++++++++ docker/smoke-completions/test-bash.sh | 16 ++++ docker/smoke-completions/test-fish.sh | 5 +- docker/smoke-completions/test-powershell.sh | 14 ++- 4 files changed, 130 insertions(+), 2 deletions(-) diff --git a/__tests__/completions.test.ts b/__tests__/completions.test.ts index cc357df6..87148354 100644 --- a/__tests__/completions.test.ts +++ b/__tests__/completions.test.ts @@ -260,6 +260,103 @@ describe('completions/fish', () => { }); }); +// ───────────────────────────────────────────────────────────────────── +// Help/version coverage — guards the introspect layer's behavior of +// injecting --help/-h on every command (commander doesn't expose them +// via .options) and only emitting --version once (commander's +// .version() puts -V/--version on .options exactly once). +// ───────────────────────────────────────────────────────────────────── + +describe('completions/help-version', () => { + // Count occurrences of an exact-match flag token in the emitter output. + // Looks for `--flag` either standalone, in option specs ('--flag'), + // or in CompletionResult literals — each emitter has its own format + // so we count distinct flag mentions rather than line counts. + const countFlag = (out: string, flag: string): number => { + // Match the flag bounded by non-flag characters or end-of-line. + // Excludes prefix-overlaps (e.g. --version vs --version-foo). + const re = new RegExp(`(? { + const out = emit(buildProgram(), 'zsh'); + // Zsh paired-spec emits --version twice in the `'(-V --version)'{-V,--version}'[…]'` + // syntax; that's intentional (it's one option spec). Hardcoded + // duplicate would have produced two separate _arguments lines. + expect(out).toMatch(/'\(-V --version\)'\{-V,--version\}/); + // Should be exactly one matching pair, not two. + expect((out.match(/'\(-V --version\)'/g) ?? []).length).toBe(1); + }); + + it('bash: --version appears once in the rootFlags list', () => { + const out = emit(buildProgram(), 'bash'); + // The root-flag list is a single compgen -W "..." string; --version + // should appear once. Duplicates would show as two tokens. + const rootFlagsLine = out.match(/compgen -W "([^"]*)" -- "\$cur"/)?.[1] ?? ''; + expect(rootFlagsLine.split(/\s+/).filter((f) => f === '--version').length).toBe(1); + }); + + it('fish: --version appears once (as -l version spec, not literal)', () => { + // Fish encodes flags via `-s X -l XX` rather than emitting the + // literal `--X`. One occurrence of `-l version` per command path. + const out = emit(buildProgram(), 'fish'); + expect((out.match(/-l version\b/g) ?? []).length).toBe(1); + }); + + it('powershell: --version appears once in root switch arm', () => { + const out = emit(buildProgram(), 'powershell'); + // PS emits each CompletionResult as `[CompletionResult]::new('--version', '--version', …)` + // — two text occurrences per emission. One emission = 2 matches. + expect(countFlag(out, '--version')).toBe(2); + }); + + it('zsh: --help spec appears on root + every subcommand', () => { + // Zsh emits `'(-h --help)'{-h,--help}'[…]'` per command via _arguments. + // Test program: root + 3 subs = 4 total. + const out = emit(buildProgram(), 'zsh'); + expect((out.match(/'\(-h --help\)'\{-h,--help\}/g) ?? []).length).toBe(4); + }); + + it('bash: --help appears in every flag-completion compgen list', () => { + // Bash emits `compgen -W ""` lists for both flag-completion + // (where words look like `-x --xx -h --help`) and subcommand-name + // completion (`init query affected a`). Only the flag lists need + // --help; capture and filter on the word-list content (a flag list + // is one where the first word starts with `-`). + const out = emit(buildProgram(), 'bash'); + const wordLists: string[] = []; + const re = /compgen -W "([^"]*)" -- "\$cur"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(out)) !== null) wordLists.push(m[1]); + const flagLists = wordLists.filter((w) => w.trim().startsWith('-')); + expect(flagLists.length).toBeGreaterThanOrEqual(4); // root flags + 3 subs + for (const list of flagLists) { + expect(list.split(/\s+/)).toContain('--help'); + } + }); + + it('fish: -l help appears on root + every subcommand condition block', () => { + // Fish emits one `complete -c codegraph -n '' -s h -l help …` + // line per command path. Conditions can be `__fish_use_subcommand` + // (root) or `__fish_seen_subcommand_from [ …]` + // (per-sub, possibly with aliases joined by spaces). + const out = emit(buildProgram(), 'fish'); + expect((out.match(/-s h -l help\b/g) ?? []).length).toBeGreaterThanOrEqual(4); + }); + + it('powershell: --help/-h appear in every switch arm', () => { + const out = emit(buildProgram(), 'powershell'); + // Root arm + per-sub arms + per-alias arms. Each gets help injected. + const arms = out.match(/'codegraph(;[^']+)?' \{[\s\S]*?break\n \}/g) ?? []; + expect(arms.length).toBeGreaterThanOrEqual(4); + for (const arm of arms) { + expect(arm).toContain("'--help'"); + expect(arm).toContain("'-h'"); + } + }); +}); + describe('completions/powershell', () => { const out = emit(buildProgram(), 'powershell'); diff --git a/docker/smoke-completions/test-bash.sh b/docker/smoke-completions/test-bash.sh index 16f52c3c..c58031a8 100755 --- a/docker/smoke-completions/test-bash.sh +++ b/docker/smoke-completions/test-bash.sh @@ -81,4 +81,20 @@ out=$(complete_line "codegraph -") assert_contains "top-level/--help" "--help" "$out" assert_contains "top-level/--version" "--version" "$out" +# 5. --version must appear exactly once — regression guard for the +# pre-cb2389e bug where commander's auto-registered --version +# collided with a hardcoded --version in the emitter. +ver_count=$(grep -cx -- "--version" <<<"$out" || true) +if [[ "$ver_count" != "1" ]]; then + echo "FAIL [bash:--version-dedupe]: expected --version exactly once, got $ver_count" >&2 + fail=1 +fi + +# 6. Subcommand --help / -h. Commander doesn't surface help in +# cmd.options; the introspect layer injects it. Regression guard +# so a future refactor doesn't drop it. +out=$(complete_line "codegraph init -") +assert_contains "init/--help" "--help" "$out" +assert_contains "init/-h" "-h" "$out" + exit $fail diff --git a/docker/smoke-completions/test-fish.sh b/docker/smoke-completions/test-fish.sh index af89ffd4..0280232f 100755 --- a/docker/smoke-completions/test-fish.sh +++ b/docker/smoke-completions/test-fish.sh @@ -30,9 +30,12 @@ assert_contains "top-level/init" "init" "$out" assert_contains "top-level/query" "query" "$out" assert_contains "top-level/completions" "completions" "$out" -# 2. Subcommand flag completion. +# 2. Subcommand flag completion — both per-sub options AND the +# --help/-h injected by the introspect layer. out=$(fish -c 'complete -C "codegraph init -"') assert_contains "init/--index" "--index" "$out" +assert_contains "init/--help" "--help" "$out" +assert_contains "init/-h" "-h" "$out" # 3. Value-hint: --path should trigger file completion. With files in # cwd, fish lists them. diff --git a/docker/smoke-completions/test-powershell.sh b/docker/smoke-completions/test-powershell.sh index 2db4e43c..7cd7fe50 100755 --- a/docker/smoke-completions/test-powershell.sh +++ b/docker/smoke-completions/test-powershell.sh @@ -47,15 +47,27 @@ Assert-Contains "top-level/completions" "completions" $names # 2. Top-level flags surface from the root switch arm. Assert-Contains "top-level/--help" "--help" $names +Assert-Contains "top-level/-h" "-h" $names Assert-Contains "top-level/--version" "--version" $names +Assert-Contains "top-level/-V" "-V" $names -# 3. Subcommand flag completion. +# 2a. --version exactly once — regression guard for the pre-cb2389e +# duplicate (commander's auto --version + a hardcoded one). +$verCount = ($names | Where-Object { $_ -eq "--version" }).Count +if ($verCount -ne 1) { + Write-Host "FAIL [powershell:--version-dedupe]: expected --version exactly once, got $verCount" + $script:fail = $true +} + +# 3. Subcommand flag completion — per-sub flags AND injected help. $line = "codegraph init -" $r = TabExpansion2 -inputScript $line -cursorColumn $line.Length Assert-NonEmpty "init/-" $r $flags = $r.CompletionMatches.CompletionText Assert-Contains "init/--index" "--index" $flags Assert-Contains "init/-i" "-i" $flags +Assert-Contains "init/--help" "--help" $flags +Assert-Contains "init/-h" "-h" $flags if ($script:fail) { exit 1 } else { Write-Output "powershell smoke OK"; exit 0 } PSSCRIPT