diff --git a/bin/cli.ts b/bin/cli.ts new file mode 100644 index 0000000..9dca369 --- /dev/null +++ b/bin/cli.ts @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +import cac from 'cac'; +import { script, Completion } from '../src/index.js'; +import tab from '../src/cac.js'; + +import { setupCompletionForPackageManager } from './completion-handlers'; + +const packageManagers = ['npm', 'pnpm', 'yarn', 'bun']; +const shells = ['zsh', 'bash', 'fish', 'powershell']; + +async function main() { + const cli = cac('tab'); + + const args = process.argv.slice(2); + if (args.length >= 2 && args[1] === 'complete') { + const packageManager = args[0]; + + if (!packageManagers.includes(packageManager)) { + console.error(`Error: Unsupported package manager "${packageManager}"`); + console.error( + `Supported package managers: ${packageManagers.join(', ')}` + ); + process.exit(1); + } + + const dashIndex = process.argv.indexOf('--'); + if (dashIndex !== -1) { + const completion = new Completion(); + setupCompletionForPackageManager(packageManager, completion); + const toComplete = process.argv.slice(dashIndex + 1); + await completion.parse(toComplete); + process.exit(0); + } else { + console.error(`Error: Expected '--' followed by command to complete`); + console.error( + `Example: ${packageManager} exec @bombsh/tab ${packageManager} complete -- command-to-complete` + ); + process.exit(1); + } + } + + cli + .command( + ' ', + 'Generate shell completion script for a package manager' + ) + .action(async (packageManager, shell) => { + if (!packageManagers.includes(packageManager)) { + console.error(`Error: Unsupported package manager "${packageManager}"`); + console.error( + `Supported package managers: ${packageManagers.join(', ')}` + ); + process.exit(1); + } + + if (!shells.includes(shell)) { + console.error(`Error: Unsupported shell "${shell}"`); + console.error(`Supported shells: ${shells.join(', ')}`); + process.exit(1); + } + + generateCompletionScript(packageManager, shell); + }); + + const completion = tab(cli); + + cli.parse(); +} + +// function generateCompletionScript(packageManager: string, shell: string) { +// const name = packageManager; +// const executable = process.env.npm_execpath +// ? `${packageManager} exec @bombsh/tab ${packageManager}` +// : `node ${process.argv[1]} ${packageManager}`; +// script(shell as any, name, executable); +// } + +function generateCompletionScript(packageManager: string, shell: string) { + const name = packageManager; + // this always points at the actual file on disk (TESTING) + const executable = `node ${process.argv[1]} ${packageManager}`; + script(shell as any, name, executable); +} + +main().catch(console.error); diff --git a/bin/completion-handlers.ts b/bin/completion-handlers.ts new file mode 100644 index 0000000..e51f867 --- /dev/null +++ b/bin/completion-handlers.ts @@ -0,0 +1,123 @@ +import { Completion } from '../src/index.js'; +import { execSync } from 'child_process'; + +const DEBUG = false; // for debugging purposes + +function debugLog(...args: any[]) { + if (DEBUG) { + console.error('[DEBUG]', ...args); + } +} + +async function checkCliHasCompletions( + cliName: string, + packageManager: string +): Promise { + try { + debugLog(`Checking if ${cliName} has completions via ${packageManager}`); + const command = `${packageManager} ${cliName} complete --`; + const result = execSync(command, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 1000, // AMIR: we still havin issues with this, it still hangs if a cli doesn't have completions. longer timeout needed for shell completion system (shell → node → package manager → cli) + }); + const hasCompletions = !!result.trim(); + debugLog(`${cliName} supports completions: ${hasCompletions}`); + return hasCompletions; + } catch (error) { + debugLog(`Error checking completions for ${cliName}:`, error); + return false; + } +} + +async function getCliCompletions( + cliName: string, + packageManager: string, + args: string[] +): Promise { + try { + const completeArgs = args.map((arg) => + arg.includes(' ') ? `"${arg}"` : arg + ); + const completeCommand = `${packageManager} ${cliName} complete -- ${completeArgs.join(' ')}`; + debugLog(`Getting completions with command: ${completeCommand}`); + + const result = execSync(completeCommand, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 1000, // same: longer timeout needed for shell completion system (shell → node → package manager → cli) + }); + + const completions = result.trim().split('\n').filter(Boolean); + debugLog(`Got ${completions.length} completions from ${cliName}`); + return completions; + } catch (error) { + debugLog(`Error getting completions from ${cliName}:`, error); + return []; + } +} + +export function setupCompletionForPackageManager( + packageManager: string, + completion: Completion +) { + if (packageManager === 'pnpm') { + setupPnpmCompletions(completion); + } else if (packageManager === 'npm') { + setupNpmCompletions(completion); + } else if (packageManager === 'yarn') { + setupYarnCompletions(completion); + } else if (packageManager === 'bun') { + setupBunCompletions(completion); + } + + completion.setPackageManager(packageManager); +} + +export function setupPnpmCompletions(completion: Completion) { + completion.addCommand('add', 'Install a package', [], async () => []); + completion.addCommand('remove', 'Remove a package', [], async () => []); + completion.addCommand( + 'install', + 'Install all dependencies', + [], + async () => [] + ); + completion.addCommand('update', 'Update packages', [], async () => []); + completion.addCommand('exec', 'Execute a command', [], async () => []); + completion.addCommand('run', 'Run a script', [], async () => []); + completion.addCommand('publish', 'Publish package', [], async () => []); + completion.addCommand('test', 'Run tests', [], async () => []); + completion.addCommand('build', 'Build project', [], async () => []); +} + +export function setupNpmCompletions(completion: Completion) { + completion.addCommand('install', 'Install a package', [], async () => []); + completion.addCommand('uninstall', 'Uninstall a package', [], async () => []); + completion.addCommand('run', 'Run a script', [], async () => []); + completion.addCommand('test', 'Run tests', [], async () => []); + completion.addCommand('publish', 'Publish package', [], async () => []); + completion.addCommand('update', 'Update packages', [], async () => []); + completion.addCommand('start', 'Start the application', [], async () => []); + completion.addCommand('build', 'Build project', [], async () => []); +} + +export function setupYarnCompletions(completion: Completion) { + completion.addCommand('add', 'Add a package', [], async () => []); + completion.addCommand('remove', 'Remove a package', [], async () => []); + completion.addCommand('run', 'Run a script', [], async () => []); + completion.addCommand('test', 'Run tests', [], async () => []); + completion.addCommand('publish', 'Publish package', [], async () => []); + completion.addCommand('install', 'Install dependencies', [], async () => []); + completion.addCommand('build', 'Build project', [], async () => []); +} + +export function setupBunCompletions(completion: Completion) { + completion.addCommand('add', 'Add a package', [], async () => []); + completion.addCommand('remove', 'Remove a package', [], async () => []); + completion.addCommand('run', 'Run a script', [], async () => []); + completion.addCommand('test', 'Run tests', [], async () => []); + completion.addCommand('install', 'Install dependencies', [], async () => []); + completion.addCommand('update', 'Update packages', [], async () => []); + completion.addCommand('build', 'Build project', [], async () => []); +} diff --git a/examples/demo-cli-cac/demo-cli-cac.js b/examples/demo-cli-cac/demo-cli-cac.js index 5190d1e..c25560f 100755 --- a/examples/demo-cli-cac/demo-cli-cac.js +++ b/examples/demo-cli-cac/demo-cli-cac.js @@ -1,6 +1,8 @@ #!/usr/bin/env node -const cac = require('cac'); +import cac from 'cac'; +import tab from '../../dist/src/cac.js'; + const cli = cac('demo-cli-cac'); // Define version and help @@ -29,61 +31,40 @@ cli console.log('Options:', options); }); -// Manual implementation of completion for CAC -if (process.argv[2] === '__complete') { - const args = process.argv.slice(3); - const toComplete = args[args.length - 1] || ''; - const previousArgs = args.slice(0, -1); - - // Root command completion - if (previousArgs.length === 0) { - console.log('start\tStart the application'); - console.log('build\tBuild the application'); - console.log('--help\tDisplay help'); - console.log('--version\tOutput the version number'); - console.log('-c\tSpecify config file'); - console.log('--config\tSpecify config file'); - console.log('-d\tEnable debugging'); - console.log('--debug\tEnable debugging'); - process.exit(0); - } - - // Subcommand completion - if (previousArgs[0] === 'start') { - console.log('-p\tPort to use'); - console.log('--port\tPort to use'); - console.log('--help\tDisplay help'); +// Set up completion using the cac adapter +const completion = await tab(cli); - // Port value completion if --port is the last arg - if ( - previousArgs[previousArgs.length - 1] === '--port' || - previousArgs[previousArgs.length - 1] === '-p' - ) { - console.log('3000\tDefault port'); - console.log('8080\tAlternative port'); +// custom config for options +for (const command of completion.commands.values()) { + for (const [optionName, config] of command.options.entries()) { + if (optionName === '--port') { + config.handler = () => { + return [ + { value: '3000', description: 'Default port' }, + { value: '8080', description: 'Alternative port' }, + ]; + }; } - process.exit(0); - } - if (previousArgs[0] === 'build') { - console.log('-m\tBuild mode'); - console.log('--mode\tBuild mode'); - console.log('--help\tDisplay help'); + if (optionName === '--mode') { + config.handler = () => { + return [ + { value: 'development', description: 'Development mode' }, + { value: 'production', description: 'Production mode' }, + { value: 'test', description: 'Test mode' }, + ]; + }; + } - // Mode value completion if --mode is the last arg - if ( - previousArgs[previousArgs.length - 1] === '--mode' || - previousArgs[previousArgs.length - 1] === '-m' - ) { - console.log('development\tDevelopment mode'); - console.log('production\tProduction mode'); - console.log('test\tTest mode'); + if (optionName === '--config') { + config.handler = () => { + return [ + { value: 'config.json', description: 'JSON config file' }, + { value: 'config.js', description: 'JavaScript config file' }, + ]; + }; } - process.exit(0); } - - process.exit(0); -} else { - // Parse CLI args - cli.parse(); } + +cli.parse(); diff --git a/examples/demo-cli-cac/package.json b/examples/demo-cli-cac/package.json index f2e279b..9ccc20d 100644 --- a/examples/demo-cli-cac/package.json +++ b/examples/demo-cli-cac/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "Demo CLI using CAC for testing tab completions with pnpm", "main": "demo-cli-cac.js", + "type": "module", "bin": { "demo-cli-cac": "./demo-cli-cac.js" }, diff --git a/package.json b/package.json index ea3ff32..02259f5 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { - "name": "tab", + "name": "@bombsh/tab", "version": "0.0.0", - "description": "", "main": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", + "bin": { + "tab": "./dist/bin/cli.js" + }, "scripts": { "test": "vitest", "type-check": "tsc --noEmit", @@ -12,7 +14,8 @@ "format:check": "prettier --check .", "build": "tsdown", "prepare": "pnpm build", - "lint": "eslint src \"./*.ts\"" + "lint": "eslint src \"./*.ts\"", + "test-cli": "tsx bin/cli.ts" }, "files": [ "dist" @@ -34,7 +37,6 @@ "vitest": "^2.1.3" }, "dependencies": { - "examples": "link:./examples", "mri": "^1.2.0" }, "exports": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6acace..78da1d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - examples: - specifier: link:./examples - version: link:examples mri: specifier: ^1.2.0 version: 1.2.0 diff --git a/src/index.ts b/src/index.ts index 5e0286a..87d9eba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,63 @@ import * as zsh from './zsh'; import * as bash from './bash'; import * as fish from './fish'; import * as powershell from './powershell'; +import { execSync } from 'child_process'; + +const DEBUG = false; + +function debugLog(...args: any[]) { + if (DEBUG) { + console.error('[DEBUG]', ...args); + } +} + +async function checkCliHasCompletions( + cliName: string, + packageManager: string +): Promise { + try { + debugLog(`Checking if ${cliName} has completions via ${packageManager}`); + const command = `${packageManager} ${cliName} complete --`; + const result = execSync(command, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 1000, + }); + const hasCompletions = !!result.trim(); + debugLog(`${cliName} supports completions: ${hasCompletions}`); + return hasCompletions; + } catch (error) { + debugLog(`Error checking completions for ${cliName}:`, error); + return false; + } +} + +async function getCliCompletions( + cliName: string, + packageManager: string, + args: string[] +): Promise { + try { + const completeArgs = args.map((arg) => + arg.includes(' ') ? `"${arg}"` : arg + ); + const completeCommand = `${packageManager} ${cliName} complete -- ${completeArgs.join(' ')}`; + debugLog(`Getting completions with command: ${completeCommand}`); + + const result = execSync(completeCommand, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: 1000, + }); + + const completions = result.trim().split('\n').filter(Boolean); + debugLog(`Got ${completions.length} completions from ${cliName}`); + return completions; + } catch (error) { + debugLog(`Error getting completions from ${cliName}:`, error); + return []; + } +} // ShellCompRequestCmd is the name of the hidden command that is used to request // completion results from the program. It is used by the shell completion scripts. @@ -62,10 +119,15 @@ export type Positional = { }; type Item = { - description: string; + description?: string; value: string; }; +type CompletionResult = { + items: Item[]; + suppressDefault: boolean; +}; + export type Handler = ( previousArgs: string[], toComplete: string, @@ -91,6 +153,12 @@ export class Completion { commands = new Map(); completions: Item[] = []; directive = ShellCompDirective.ShellCompDirectiveDefault; + result: CompletionResult = { items: [], suppressDefault: false }; + private packageManager: string | null = null; + + setPackageManager(packageManager: string) { + this.packageManager = packageManager; + } // vite [...files] // args: [false, false, true], only the last argument can be variadic @@ -171,6 +239,49 @@ export class Completion { } async parse(args: string[]) { + this.result = { items: [], suppressDefault: false }; + + // Handle package manager completions first + if (this.packageManager && args.length >= 1) { + const potentialCliName = args[0]; + const knownCommands = [...this.commands.keys()]; + + if (!knownCommands.includes(potentialCliName)) { + const hasCompletions = await checkCliHasCompletions( + potentialCliName, + this.packageManager + ); + + if (hasCompletions) { + const cliArgs = args.slice(1); + const suggestions = await getCliCompletions( + potentialCliName, + this.packageManager, + cliArgs + ); + + if (suggestions.length > 0) { + this.result.suppressDefault = true; + + for (const suggestion of suggestions) { + if (suggestion.startsWith(':')) continue; + + if (suggestion.includes('\t')) { + const [value, description] = suggestion.split('\t'); + this.result.items.push({ value, description }); + } else { + this.result.items.push({ value: suggestion }); + } + } + + this.completions = this.result.items; + this.complete(''); + return; + } + } + } + } + const endsWithSpace = args[args.length - 1] === ''; if (endsWithSpace) { diff --git a/tsdown.config.ts b/tsdown.config.ts index c4141b9..fb7a5ab 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,13 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['src/index.ts', 'src/citty.ts', 'src/cac.ts', 'src/commander.ts'], + entry: [ + 'src/index.ts', + 'src/citty.ts', + 'src/cac.ts', + 'src/commander.ts', + 'bin/cli.ts', + ], format: ['esm'], dts: true, clean: true,