diff --git a/README.2.md b/README.2.md new file mode 100644 index 0000000..83aea61 --- /dev/null +++ b/README.2.md @@ -0,0 +1,108 @@ +> A video showcasing how pnpm autocompletions work on a test CLI command like `my-cli` + +# tab + +> Instant feedback for your CLI tool when hitting [TAB] in your terminal + +As CLI tooling authors, if we can spare our users a second or two by not checking the documentation or writing the `-h` option, we're doing them a huge favor. The unconscious loves hitting the [TAB] key. It always expects feedback. So it feels disappointing when hitting that key in the terminal but then nothing happens. That frustration is apparent across the whole JavaScript CLI tooling ecosystem. + +Autocompletions are the solution to not break the user's flow. The issue is they're not simple to add. `zsh` expects them in one way, and `bash` in another way. Then where do we provide them so the shell environment parses them? Too many headaches to ease the user's experience. Whether it's worth it or not is out of the question. Because tab is the solution to this complexity. + +`my-cli.ts`: + +```typescript +import t from '@bombsh/tab'; + +t.name('my-cli'); + +t.command('start', 'start the development server'); + +if (process.argv[2] === 'complete') { + const [shell, ...args] = process.argv.slice(3); + if (shell === '--') { + t.parse(args); + } else { + t.setup(shell, x); + } +} +``` + +This `my-cli.ts` would be equipped with all the tools required to provide autocompletions. + +```bash +node my-cli.ts complete -- "st" +``` + +``` +start start the development server +:0 +``` + +This output was generated by the `t.parse` method to autocomplete "st" to "start". + +Obviously, the user won't be running that command directly in their terminal. They'd be running something like this. + +```bash +source <(node my-cli.ts complete zsh) +``` + +Now whenever the shell sees `my-cli`, it would bring the autocompletions we wrote for this CLI tool. The `node my-cli.ts complete zsh` part would output the zsh script that loads the autocompletions provided by `t.parse` which then would be executed using `source`. + +The autocompletions only live through the current shell session. To set them up across all terminal sessions, the autocompletion script should be injected in the `.zshrc` file. + +```bash +my-cli complete zsh > ~/completion-for-my-cli.zsh && echo 'source ~/completion-for-my-cli.zsh' >> ~/.zshrc +``` + +Or + +```bash +echo 'source <(npx --offline my-cli complete zsh)' >> ~/.zshrc +``` + +This is an example of autocompletions on a global CLI command that is usually installed using the `-g` flag (e.g. `npm add -g my-cli`) which is available across the computer. + +--- + +While working on tab, we came to the realization that most JavaScript CLIs are not global CLI commands but rather, per-project dependencies. + +For instance, Vite won't be installed globally and instead it'd be always installed on a project. Here's an example usage: + +```bash +pnpm vite dev +``` + +Rather than installing it globally. This example is pretty rare: + +```bash +vite dev +``` + +So in this case, a computer might have hundreds of Vite instances each installed separately and potentially from different versions on different projects. + +We were looking for a fluid strategy that would be able to load the autocompletions from each of these dependencies on a per-project basis. + +And that made us develop our own autocompletion abstraction over npm, pnpm and yarn. This would help tab identify which binaries are available in a project and which of these binaries provide autocompletions. So the user would not have to `source` anything or inject any script in their `.zshrc`. + +They'd only have to run this command once and inject it in their shell config. + +```bash +npx @bombsh/tab pnpm zsh +``` + +These autocompletions on top of the normal autocompletions that these package managers provide are going to be way more powerful. + +These new autocompletions on top of package managers would help us with autocompletions on commands like `pnpm vite` and other global or per-project binaries. The only requirement would be that the npm binary itself would be a tab-compatible binary. + +What is a tab-compatible binary? It's a tool that provides the `complete` subcommand that was showcased above. Basically any CLI tool that uses tab for its autocompletions is a tab-compatible binary. + +```bash +pnpm my-cli complete -- +``` + +``` +start start the development server +:0 +``` + +We are planning to maintain these package manager autocompletions on our own and turn them into full-fledged autocompletions that touch on every part of our package managers. diff --git a/README.md b/README.md index e998dff..d8bf104 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -[![tweet-1827921103093932490](https://github.com/user-attachments/assets/21521787-7936-44be-8d3c-8214cd2fcee9)](https://x.com/karpathy/status/1827921103093932490) - # tab Shell autocompletions are largely missing in the javascript cli ecosystem. This tool is an attempt to make autocompletions come out of the box for any cli tool. diff --git a/bin/cli.ts b/bin/cli.ts new file mode 100644 index 0000000..d204be5 --- /dev/null +++ b/bin/cli.ts @@ -0,0 +1,88 @@ +#!/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'); + + // TODO: aren't these conditions are already handled by cac? + 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) { + // TOOD: there's no Completion anymore + 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); + }); + + 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..20b8496 --- /dev/null +++ b/bin/completion-handlers.ts @@ -0,0 +1,126 @@ +// TODO: i do not see any completion functionality in this file. nothing is being provided for the defined commands of these package managers. this is a blocker for release. every each of them should be handled. +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); + } + + // TODO: the core functionality of tab should have nothing related to package managers. even though completion is not there anymore, but this is something to consider. + 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 () => [] + ); + // TODO: empty functions should be replaced with noop functions rather than creating that many empty functions + 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 new file mode 100755 index 0000000..c25560f --- /dev/null +++ b/examples/demo-cli-cac/demo-cli-cac.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +import cac from 'cac'; +import tab from '../../dist/src/cac.js'; + +const cli = cac('demo-cli-cac'); + +// Define version and help +cli.version('1.0.0'); +cli.help(); + +// Global options +cli.option('-c, --config ', 'Specify config file'); +cli.option('-d, --debug', 'Enable debugging'); + +// Start command +cli + .command('start', 'Start the application') + .option('-p, --port ', 'Port to use', { default: '3000' }) + .action((options) => { + console.log('Starting application...'); + console.log('Options:', options); + }); + +// Build command +cli + .command('build', 'Build the application') + .option('-m, --mode ', 'Build mode', { default: 'production' }) + .action((options) => { + console.log('Building application...'); + console.log('Options:', options); + }); + +// Set up completion using the cac adapter +const completion = await tab(cli); + +// 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' }, + ]; + }; + } + + if (optionName === '--mode') { + config.handler = () => { + return [ + { value: 'development', description: 'Development mode' }, + { value: 'production', description: 'Production mode' }, + { value: 'test', description: 'Test mode' }, + ]; + }; + } + + if (optionName === '--config') { + config.handler = () => { + return [ + { value: 'config.json', description: 'JSON config file' }, + { value: 'config.js', description: 'JavaScript config file' }, + ]; + }; + } + } +} + +cli.parse(); diff --git a/examples/demo-cli-cac/package.json b/examples/demo-cli-cac/package.json new file mode 100644 index 0000000..9ccc20d --- /dev/null +++ b/examples/demo-cli-cac/package.json @@ -0,0 +1,16 @@ +{ + "name": "demo-cli-cac", + "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" + }, + "scripts": { + "start": "node demo-cli-cac.js" + }, + "dependencies": { + "cac": "^6.7.14" + } +} diff --git a/examples/demo-cli-cac/pnpm-lock.yaml b/examples/demo-cli-cac/pnpm-lock.yaml new file mode 100644 index 0000000..f59734a --- /dev/null +++ b/examples/demo-cli-cac/pnpm-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + cac: + specifier: ^6.7.14 + version: 6.7.14 + +packages: + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + +snapshots: + + cac@6.7.14: {} diff --git a/examples/demo.cac.ts b/examples/demo.cac.ts index 0db2069..3297fe3 100644 --- a/examples/demo.cac.ts +++ b/examples/demo.cac.ts @@ -1,5 +1,6 @@ import cac from 'cac'; import tab from '../src/cac'; +import type { Command, Option, OptionsMap } from '../src/t'; const cli = cac('vite'); @@ -22,64 +23,87 @@ cli cli.command('dev build', 'Build project').action((options) => {}); -cli.command('lint [...files]', 'Lint project').action((files, options) => {}); - -const completion = await tab(cli); +cli + .command('copy ', 'Copy files') + .action((source, destination, options) => {}); -for (const command of completion.commands.values()) { - if (command.name === 'lint') { - command.handler = () => { - return [ - { value: 'main.ts', description: 'Main file' }, - { value: 'index.ts', description: 'Index file' }, - ]; - }; - } +cli.command('lint [...files]', 'Lint project').action((files, options) => {}); - for (const [o, config] of command.options.entries()) { - if (o === '--port') { - config.handler = () => { - return [ - { value: '3000', description: 'Development server port' }, - { value: '8080', description: 'Alternative port' }, - ]; - }; - } - if (o === '--host') { - config.handler = () => { - return [ - { value: 'localhost', description: 'Localhost' }, - { value: '0.0.0.0', description: 'All interfaces' }, - ]; - }; - } - if (o === '--config') { - config.handler = () => { - return [ - { value: 'vite.config.ts', description: 'Vite config file' }, - { value: 'vite.config.js', description: 'Vite config file' }, - ]; - }; - } - if (o === '--mode') { - config.handler = () => { - return [ - { value: 'development', description: 'Development mode' }, - { value: 'production', description: 'Production mode' }, - ]; - }; - } - if (o === '--logLevel') { - config.handler = () => { - return [ - { value: 'info', description: 'Info level' }, - { value: 'warn', description: 'Warn level' }, - { value: 'error', description: 'Error level' }, - { value: 'silent', description: 'Silent level' }, - ]; - }; - } - } -} +// Note: With the new t.ts API, handlers are configured through the completionConfig parameter +// rather than by modifying the returned completion object directly +await tab(cli, { + subCommands: { + copy: { + args: { + source: function (complete) { + complete('src/', 'Source directory'); + complete('dist/', 'Distribution directory'); + complete('public/', 'Public assets'); + }, + destination: function (complete) { + complete('build/', 'Build output'); + complete('release/', 'Release directory'); + complete('backup/', 'Backup location'); + }, + }, + }, + lint: { + args: { + files: function (complete) { + complete('main.ts', 'Main file'); + complete('index.ts', 'Index file'); + }, + }, + }, + dev: { + options: { + port: function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap + ) { + complete('3000', 'Development server port'); + complete('8080', 'Alternative port'); + }, + host: function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap + ) { + complete('localhost', 'Localhost'); + complete('0.0.0.0', 'All interfaces'); + }, + }, + }, + }, + options: { + config: function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap + ) { + complete('vite.config.ts', 'Vite config file'); + complete('vite.config.js', 'Vite config file'); + }, + mode: function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap + ) { + complete('development', 'Development mode'); + complete('production', 'Production mode'); + }, + logLevel: function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap + ) { + complete('info', 'Info level'); + complete('warn', 'Warn level'); + complete('error', 'Error level'); + complete('silent', 'Silent level'); + }, + }, +}); cli.parse(); diff --git a/examples/demo.citty.ts b/examples/demo.citty.ts index 938b91e..2442178 100644 --- a/examples/demo.citty.ts +++ b/examples/demo.citty.ts @@ -1,4 +1,9 @@ -import { defineCommand, createMain, CommandDef, ArgsDef } from 'citty'; +import { + defineCommand, + createMain, + type CommandDef, + type ArgsDef, +} from 'citty'; import tab from '../src/citty'; const main = defineCommand({ @@ -8,6 +13,11 @@ const main = defineCommand({ description: 'Vite CLI', }, args: { + project: { + type: 'positional', + description: 'Project name', + required: true, + }, config: { type: 'string', description: 'Use specified config file', @@ -55,6 +65,26 @@ const buildCommand = defineCommand({ run: () => {}, }); +const copyCommand = defineCommand({ + meta: { + name: 'copy', + description: 'Copy files', + }, + args: { + source: { + type: 'positional', + description: 'Source file or directory', + required: true, + }, + destination: { + type: 'positional', + description: 'Destination file or directory', + required: true, + }, + }, + run: () => {}, +}); + const lintCommand = defineCommand({ meta: { name: 'lint', @@ -73,53 +103,67 @@ const lintCommand = defineCommand({ main.subCommands = { dev: devCommand, build: buildCommand, + copy: copyCommand, lint: lintCommand, } as Record>; const completion = await tab(main, { + args: { + project: function (complete) { + complete('my-app', 'My application'); + complete('my-lib', 'My library'); + complete('my-tool', 'My tool'); + }, + }, options: { - config: { - handler: () => [ - { value: 'vite.config.ts', description: 'Vite config file' }, - { value: 'vite.config.js', description: 'Vite config file' }, - ], + config: function (this: any, complete) { + complete('vite.config.ts', 'Vite config file'); + complete('vite.config.js', 'Vite config file'); }, - mode: { - handler: () => [ - { value: 'development', description: 'Development mode' }, - { value: 'production', description: 'Production mode' }, - ], + mode: function (this: any, complete) { + complete('development', 'Development mode'); + complete('production', 'Production mode'); }, - logLevel: { - handler: () => [ - { value: 'info', description: 'Info level' }, - { value: 'warn', description: 'Warn level' }, - { value: 'error', description: 'Error level' }, - { value: 'silent', description: 'Silent level' }, - ], + logLevel: function (this: any, complete) { + complete('info', 'Info level'); + complete('warn', 'Warn level'); + complete('error', 'Error level'); + complete('silent', 'Silent level'); }, }, subCommands: { + copy: { + args: { + source: function (complete) { + complete('src/', 'Source directory'); + complete('dist/', 'Distribution directory'); + complete('public/', 'Public assets'); + }, + destination: function (complete) { + complete('build/', 'Build output'); + complete('release/', 'Release directory'); + complete('backup/', 'Backup location'); + }, + }, + }, lint: { - handler: () => [ - { value: 'main.ts', description: 'Main file' }, - { value: 'index.ts', description: 'Index file' }, - ], + args: { + files: function (complete) { + complete('main.ts', 'Main file'); + complete('index.ts', 'Index file'); + }, + }, }, dev: { options: { - port: { - handler: () => [ - { value: '3000', description: 'Development server port' }, - { value: '8080', description: 'Alternative port' }, - ], + port: function (this: any, complete) { + complete('3000', 'Development server port'); + complete('8080', 'Alternative port'); }, - host: { - handler: () => [ - { value: 'localhost', description: 'Localhost' }, - { value: '0.0.0.0', description: 'All interfaces' }, - ], + host: function (this: any, complete) { + complete('localhost', 'Localhost'); + complete('0.0.0.0', 'All interfaces'); }, }, }, diff --git a/examples/demo.commander.ts b/examples/demo.commander.ts index e7f33da..db8169a 100644 --- a/examples/demo.commander.ts +++ b/examples/demo.commander.ts @@ -107,14 +107,5 @@ for (const command of completion.commands.values()) { } } -// Test completion directly if the first argument is "test-completion" -if (process.argv[2] === 'test-completion') { - const args = process.argv.slice(3); - console.log('Testing completion with args:', args); - completion.parse(args).then(() => { - // Done - }); -} else { - // Parse command line arguments - program.parse(); -} +// Parse command line arguments +program.parse(); diff --git a/examples/demo.t.ts b/examples/demo.t.ts new file mode 100644 index 0000000..21f277d --- /dev/null +++ b/examples/demo.t.ts @@ -0,0 +1,132 @@ +import t from '../src/t'; + +// Global options +t.option( + 'config', + 'Use specified config file', + function (complete) { + complete('vite.config.ts', 'Vite config file'); + complete('vite.config.js', 'Vite config file'); + }, + 'c' +); + +t.option( + 'mode', + 'Set env mode', + function (complete) { + complete('development', 'Development mode'); + complete('production', 'Production mode'); + }, + 'm' +); + +t.option( + 'logLevel', + 'info | warn | error | silent', + function (complete) { + complete('info', 'Info level'); + complete('warn', 'Warn level'); + complete('error', 'Error level'); + complete('silent', 'Silent level'); + }, + 'l' +); + +// Root command argument +t.argument('project', function (complete) { + complete('my-app', 'My application'); + complete('my-lib', 'My library'); + complete('my-tool', 'My tool'); +}); + +// Dev command +const devCmd = t.command('dev', 'Start dev server'); +devCmd.option( + 'host', + 'Specify hostname', + function (complete) { + complete('localhost', 'Localhost'); + complete('0.0.0.0', 'All interfaces'); + }, + 'H' +); + +devCmd.option( + 'port', + 'Specify port', + function (complete) { + complete('3000', 'Development server port'); + complete('8080', 'Alternative port'); + }, + 'p' +); + +// Serve command +const serveCmd = t.command('serve', 'Start the server'); +serveCmd.option( + 'host', + 'Specify hostname', + function (complete) { + complete('localhost', 'Localhost'); + complete('0.0.0.0', 'All interfaces'); + }, + 'H' +); + +serveCmd.option( + 'port', + 'Specify port', + function (complete) { + complete('3000', 'Development server port'); + complete('8080', 'Alternative port'); + }, + 'p' +); + +// Build command +t.command('dev build', 'Build project'); + +// Copy command with multiple arguments +const copyCmd = t + .command('copy', 'Copy files') + .argument('source', function (complete) { + complete('src/', 'Source directory'); + complete('dist/', 'Distribution directory'); + complete('public/', 'Public assets'); + }) + .argument('destination', function (complete) { + complete('build/', 'Build output'); + complete('release/', 'Release directory'); + complete('backup/', 'Backup location'); + }); + +// Lint command with variadic arguments +const lintCmd = t.command('lint', 'Lint project').argument( + 'files', + function (complete) { + complete('main.ts', 'Main file'); + complete('index.ts', 'Index file'); + complete('src/', 'Source directory'); + complete('tests/', 'Tests directory'); + }, + true +); // Variadic argument for multiple files + +// Handle completion command +if (process.argv[2] === 'complete') { + const shell = process.argv[3]; + if (shell && ['zsh', 'bash', 'fish', 'powershell'].includes(shell)) { + t.setup('vite', 'pnpm tsx examples/demo.t.ts', shell); + } else { + // Parse completion arguments (everything after --) + const separatorIndex = process.argv.indexOf('--'); + const completionArgs = + separatorIndex !== -1 ? process.argv.slice(separatorIndex + 1) : []; + t.parse(completionArgs); + } +} else { + // Regular CLI usage (just show help for demo) + console.log('Vite CLI Demo'); + console.log('Use "complete" command for shell completion'); +} diff --git a/examples/tiny-cli/package.json b/examples/tiny-cli/package.json new file mode 100644 index 0000000..7ee7f80 --- /dev/null +++ b/examples/tiny-cli/package.json @@ -0,0 +1,9 @@ +{ + "name": "tiny-cli", + "version": "1.0.0", + "description": "Minimal CLI for testing tab completions with pnpm", + "main": "tiny-cli.js", + "bin": { + "tiny-cli": "./tiny-cli.js" + } +} diff --git a/examples/tiny-cli/tiny-cli.js b/examples/tiny-cli/tiny-cli.js new file mode 100755 index 0000000..4b6fe1d --- /dev/null +++ b/examples/tiny-cli/tiny-cli.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +if (process.argv[2] === '__complete') { + console.log('hello\tSay hello'); + console.log('world\tSay world'); + process.exit(0); +} else { + const command = process.argv[2]; + if (command === 'hello') { + console.log('Hello!'); + } else if (command === 'world') { + console.log('World!'); + } else { + console.log('Usage: tiny-cli [hello|world]'); + } +} diff --git a/package.json b/package.json index 57f2ffd..16d3296 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,21 @@ { - "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", + "test": "vitest run", "type-check": "tsc --noEmit", "format": "prettier --write .", "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" diff --git a/pnpm-script-extended/pnpm-shell-completion-extended.plugin.zsh b/pnpm-script-extended/pnpm-shell-completion-extended.plugin.zsh new file mode 100644 index 0000000..17968bb --- /dev/null +++ b/pnpm-script-extended/pnpm-shell-completion-extended.plugin.zsh @@ -0,0 +1,242 @@ +#compdef pnpm + +# ----------------------------------------------------------------------------- +# pnpm Shell Completion Extension +# Adds support for tab completion of CLIs executed through pnpm +# ----------------------------------------------------------------------------- + +# Set to 1 to enable debug logging, 0 to disable +PNPM_TAB_DEBUG=0 +DEBUG_FILE="/tmp/pnpm-completion-debug.log" + +# Debug logging function +_pnpm_tab_debug() { + if [[ $PNPM_TAB_DEBUG -eq 1 ]]; then + echo "$(date): $*" >> $DEBUG_FILE + fi +} + +_pnpm_tab_debug "Loading pnpm completion script with CLI tab completion support" + +# Run a CLI tool through pnpm directly +_pnpm_run_cli() { + local cli_name=$1 + shift + local cli_args=("$@") + + _pnpm_tab_debug "Running CLI via pnpm: $cli_name ${cli_args[*]}" + # Execute the command through pnpm directly + pnpm $cli_name "${cli_args[@]}" 2>/dev/null +} + +# Complete commands using pnpm execution +_pnpm_complete_cli() { + local cli_name=$1 + shift + local cli_args=("$@") + local output + + _pnpm_tab_debug "Completing $cli_name with args: ${cli_args[*]}" + + # Add __complete as the first argument + cli_args=("__complete" "${cli_args[@]}") + output=$(_pnpm_run_cli $cli_name "${cli_args[@]}" 2>&1) + _pnpm_tab_debug "Completion output from pnpm: $output" + + # Process the output for ZSH completion + if [[ -n "$output" ]]; then + # Convert the output into a format that ZSH can use for completion + local -a completions + + # Process each line of the output + while IFS=$'\n' read -r line; do + _pnpm_tab_debug "Processing line: $line" + # Check if the line has a tab character (description) + if [[ "$line" == *$'\t'* ]]; then + # Split the line at the tab character + local value=${line%%$'\t'*} + local description=${line#*$'\t'} + _pnpm_tab_debug "Adding completion with description: $value -> $description" + completions+=("${value}:${description}") + else + # No description + _pnpm_tab_debug "Adding completion without description: $line" + completions+=("$line") + fi + done <<< "$output" + + # Use _describe to present the completions + if [[ ${#completions[@]} -gt 0 ]]; then + _pnpm_tab_debug "Found ${#completions[@]} completions, calling _describe" + _describe "completions" completions + return 0 + fi + fi + + _pnpm_tab_debug "No completions found, falling back to file completion" + # If we couldn't get or process completions, fall back to file completion + _files + return 1 +} + +# Check if a CLI supports __complete by running it through pnpm +_pnpm_cli_has_completions() { + local cli_name=$1 + _pnpm_tab_debug "Checking if $cli_name has completions via pnpm" + + # Try to execute the __complete command through pnpm + if output=$(_pnpm_run_cli $cli_name "__complete" 2>/dev/null) && [[ -n "$output" ]]; then + _pnpm_tab_debug "$cli_name supports completions via pnpm: $output" + return 0 + fi + + _pnpm_tab_debug "$cli_name does not support completions via pnpm" + return 1 +} + +# Original pnpm-shell-completion logic +if command -v pnpm-shell-completion &> /dev/null; then + pnpm_comp_bin="$(which pnpm-shell-completion)" + _pnpm_tab_debug "Found pnpm-shell-completion at $pnpm_comp_bin" +else + pnpm_comp_bin="$(dirname $0)/pnpm-shell-completion" + _pnpm_tab_debug "Using relative pnpm-shell-completion at $pnpm_comp_bin" +fi + +_pnpm() { + typeset -A opt_args + _pnpm_tab_debug "Starting pnpm completion, words: ${words[*]}" + + _arguments \ + '(--filter -F)'{--filter,-F}'=:flag:->filter' \ + ':command:->scripts' \ + '*:: :->command_args' + + local target_pkg=${opt_args[--filter]:-$opt_args[-F]} + _pnpm_tab_debug "State: $state, target_pkg: $target_pkg" + + case $state in + filter) + if [[ -f ./pnpm-workspace.yaml ]] && [[ -x "$pnpm_comp_bin" ]]; then + _pnpm_tab_debug "Using pnpm-shell-completion for filter packages" + _values 'filter packages' $(FEATURE=filter $pnpm_comp_bin) + else + _pnpm_tab_debug "No workspace or pnpm-shell-completion for filter" + _message "package filter" + fi + ;; + scripts) + if [[ -x "$pnpm_comp_bin" ]]; then + _pnpm_tab_debug "Using pnpm-shell-completion for scripts" + _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) \ + add remove install update publish + else + _pnpm_tab_debug "Using basic pnpm commands (no pnpm-shell-completion)" + _values 'scripts' \ + 'add:Add a dependency' \ + 'install:Install dependencies' \ + 'remove:Remove a dependency' \ + 'update:Update dependencies' \ + 'publish:Publish package' \ + 'run:Run script' + fi + ;; + command_args) + local cmd=$words[1] + _pnpm_tab_debug "Completing command args for $cmd" + + # Get the pnpm command if available + local pnpm_cmd="" + if [[ -x "$pnpm_comp_bin" ]]; then + pnpm_cmd=$(FEATURE=pnpm_cmd $pnpm_comp_bin $words 2>/dev/null) + _pnpm_tab_debug "pnpm-shell-completion identified command: $pnpm_cmd" + else + pnpm_cmd=$cmd + _pnpm_tab_debug "Using first word as command: $pnpm_cmd" + fi + + # Check if this is a potential CLI command that might support tab completion + if [[ $cmd != "add" && $cmd != "remove" && $cmd != "install" && + $cmd != "update" && $cmd != "publish" && $cmd != "i" && + $cmd != "rm" && $cmd != "up" && $cmd != "run" ]]; then + + _pnpm_tab_debug "Checking if $cmd has tab completions via pnpm" + # Check if the command has tab completions through pnpm + if _pnpm_cli_has_completions $cmd; then + _pnpm_tab_debug "$cmd has tab completions via pnpm, passing args" + # Pass remaining arguments to the CLI's completion + local cli_args=("${words[@]:2}") + _pnpm_complete_cli $cmd "${cli_args[@]}" + return + fi + fi + + # Fall back to default pnpm completion behavior + _pnpm_tab_debug "Using standard completion for pnpm $pnpm_cmd" + case $pnpm_cmd in + add) + _arguments \ + '(--global -g)'{--global,-g}'[Install as a global package]' \ + '(--save-dev -D)'{--save-dev,-D}'[Save package to your `devDependencies`]' \ + '--save-peer[Save package to your `peerDependencies` and `devDependencies`]' + ;; + install|i) + _arguments \ + '(--dev -D)'{--dev,-D}'[Only `devDependencies` are installed regardless of the `NODE_ENV`]' \ + '--fix-lockfile[Fix broken lockfile entries automatically]' \ + '--force[Force reinstall dependencies]' \ + "--ignore-scripts[Don't run lifecycle scripts]" \ + '--lockfile-only[Dependencies are not downloaded. Only `pnpm-lock.yaml` is updated]' \ + '--no-optional[`optionalDependencies` are not installed]' \ + '--offline[Trigger an error if any required dependencies are not available in local store]' \ + '--prefer-offline[Skip staleness checks for cached data, but request missing data from the server]' \ + '(--prod -P)'{--prod,-P}"[Packages in \`devDependencies\` won't be installed]" + ;; + remove|rm|why) + if [[ -f ./package.json && -x "$pnpm_comp_bin" ]]; then + _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) + else + _message "package name" + fi + ;; + update|upgrade|up) + _arguments \ + '(--dev -D)'{--dev,-D}'[Update packages only in "devDependencies"]' \ + '(--global -g)'{--global,-g}'[Update globally installed packages]' \ + '(--interactive -i)'{--interactive,-i}'[Show outdated dependencies and select which ones to update]' \ + '(--latest -L)'{--latest,-L}'[Ignore version ranges in package.json]' \ + "--no-optional[Don't update packages in \`optionalDependencies\`]" \ + '(--prod -P)'{--prod,-P}'[Update packages only in "dependencies" and "optionalDependencies"]' \ + '(--recursive -r)'{--recursive,-r}'[Update in every package found in subdirectories or every workspace package]' + if [[ -f ./package.json && -x "$pnpm_comp_bin" ]]; then + _values 'deps' $(FEATURE=deps TARGET_PKG=$target_pkg $pnpm_comp_bin) + fi + ;; + publish) + _arguments \ + '--access=[Tells the registry whether this package should be published as public or restricted]: :(public restricted)' \ + '--dry-run[Does everything a publish would do except actually publishing to the registry]' \ + '--force[Packages are proceeded to be published even if their current version is already in the registry]' \ + '--ignore-scripts[Ignores any publish related lifecycle scripts (prepublishOnly, postpublish, and the like)]' \ + "--no-git-checks[Don't check if current branch is your publish branch, clean, and up to date]" \ + '--otp[Specify a one-time password]' \ + '--publish-branch[Sets branch name to publish]' \ + '(--recursive -r)'{--recursive,-r}'[Publish all packages from the workspace]' \ + '--tag=[Registers the published package with the given tag]' + ;; + run) + if [[ -f ./package.json && -x "$pnpm_comp_bin" ]]; then + _values 'scripts' $(FEATURE=scripts TARGET_PKG=$target_pkg ZSH=true $pnpm_comp_bin) + else + _message "script name" + fi + ;; + *) + _files + esac + esac +} + +compdef _pnpm pnpm + +_pnpm_tab_debug "pnpm extended completion script loaded" \ No newline at end of file diff --git a/src/cac.ts b/src/cac.ts index 8ddc14e..d69f60a 100644 --- a/src/cac.ts +++ b/src/cac.ts @@ -3,8 +3,12 @@ import * as bash from './bash'; import * as fish from './fish'; import * as powershell from './powershell'; import type { CAC } from 'cac'; -import { Completion } from './index'; -import { CompletionConfig, noopHandler, assertDoubleDashes } from './shared'; +import { assertDoubleDashes } from './shared'; +import { OptionHandler } from './t'; +import { CompletionConfig } from './shared'; +import t from './t'; + +const noopOptionHandler: OptionHandler = function () {}; const execPath = process.execPath; const processArgs = process.argv.slice(1); @@ -21,9 +25,7 @@ function quoteIfNeeded(path: string): string { export default async function tab( instance: CAC, completionConfig?: CompletionConfig -) { - const completion = new Completion(); - +): Promise { // Add all commands and their options for (const cmd of [instance.globalCommand, ...instance.commands]) { if (cmd.name === 'complete') continue; // Skip completion command @@ -38,13 +40,36 @@ export default async function tab( ? completionConfig : completionConfig?.subCommands?.[cmd.name]; - // Add command to completion - const commandName = completion.addCommand( - isRootCommand ? '' : cmd.name, - cmd.description || '', - args, - commandCompletionConfig?.handler ?? noopHandler - ); + // Add command to completion using t.ts API + const commandName = isRootCommand ? '' : cmd.name; + const command = isRootCommand + ? t + : t.command(commandName, cmd.description || ''); + + // Set args for the command + if (command) { + // Extract argument names from command usage + const argMatches = + cmd.rawName.match(/<([^>]+)>|\[\.\.\.([^\]]+)\]/g) || []; + const argNames = argMatches.map((match) => { + if (match.startsWith('<') && match.endsWith('>')) { + return match.slice(1, -1); // Remove < > + } else if (match.startsWith('[...') && match.endsWith(']')) { + return match.slice(4, -1); // Remove [... ] + } + return match; + }); + + args.forEach((variadic, index) => { + const argName = argNames[index] || `arg${index}`; + const argHandler = commandCompletionConfig?.args?.[argName]; + if (argHandler) { + command.argument(argName, argHandler, variadic); + } else { + command.argument(argName, undefined, variadic); + } + }); + } // Add command options for (const option of [...instance.globalCommand.options, ...cmd.options]) { @@ -52,13 +77,16 @@ export default async function tab( const shortFlag = option.name.match(/^-([a-zA-Z]), --/)?.[1]; const argName = option.name.replace(/^-[a-zA-Z], --/, ''); - completion.addOption( - commandName, - `--${argName}`, // Remove the short flag part if it exists - option.description || '', - commandCompletionConfig?.options?.[argName]?.handler ?? noopHandler, - shortFlag - ); + // Add option using t.ts API + const targetCommand = isRootCommand ? t : command; + if (targetCommand) { + targetCommand.option( + argName, // Store just the option name without -- prefix + option.description || '', + commandCompletionConfig?.options?.[argName] ?? noopOptionHandler, + shortFlag + ); + } } } @@ -96,13 +124,11 @@ export default async function tab( run: false, }); - // const matchedCommand = instance.matchedCommand?.name || ''; - // const potentialCommand = args.join(' ') - // console.log(potentialCommand) - return completion.parse(args); + // Use t.ts parse method instead of completion.parse + return t.parse(args); } } }); - return completion; + return t; } diff --git a/src/citty.ts b/src/citty.ts index f7affe4..731a8eb 100644 --- a/src/citty.ts +++ b/src/citty.ts @@ -3,7 +3,6 @@ import * as zsh from './zsh'; import * as bash from './bash'; import * as fish from './fish'; import * as powershell from './powershell'; -import { Completion } from './index'; import type { ArgsDef, CommandDef, @@ -11,7 +10,9 @@ import type { SubCommandsDef, } from 'citty'; import { generateFigSpec } from './fig'; -import { CompletionConfig, noopHandler, assertDoubleDashes } from './shared'; +import { CompletionConfig, assertDoubleDashes } from './shared'; +import { OptionHandler, Command, Option, OptionsMap } from './t'; +import t from './t'; function quoteIfNeeded(path: string) { return path.includes(' ') ? `'${path}'` : path; @@ -31,8 +32,62 @@ function isConfigPositional(config: CommandDef) { ); } +// Convert Handler from index.ts to OptionHandler from t.ts +function convertOptionHandler(handler: any): OptionHandler { + return function ( + this: Option, + complete: (value: string, description: string) => void, + options: OptionsMap, + previousArgs?: string[], + toComplete?: string, + endsWithSpace?: boolean + ) { + // For short flags with equals sign and a value, don't complete (citty behavior) + // Check if this is a short flag option and if the toComplete looks like a value + if ( + this.alias && + toComplete && + toComplete !== '' && + !toComplete.startsWith('-') + ) { + // This might be a short flag with equals sign and a value + // Check if the previous args contain a short flag with equals sign + if (previousArgs && previousArgs.length > 0) { + const lastArg = previousArgs[previousArgs.length - 1]; + if (lastArg.includes('=')) { + const [flag, value] = lastArg.split('='); + if (flag.startsWith('-') && !flag.startsWith('--') && value !== '') { + return; // Don't complete short flags with equals sign and value + } + } + } + } + + // Call the old handler with the proper context + const result = handler( + previousArgs || [], + toComplete || '', + endsWithSpace || false + ); + + if (Array.isArray(result)) { + result.forEach((item: any) => + complete(item.value, item.description || '') + ); + } else if (result && typeof result.then === 'function') { + // Handle async handlers + result.then((items: any[]) => { + items.forEach((item: any) => + complete(item.value, item.description || '') + ); + }); + } + }; +} + +const noopOptionHandler: OptionHandler = function () {}; + async function handleSubCommands( - completion: Completion, subCommands: SubCommandsDef, parentCmd?: string, completionConfig?: Record @@ -47,20 +102,34 @@ async function handleSubCommands( throw new Error('Invalid meta or missing description.'); } const isPositional = isConfigPositional(config); - const name = completion.addCommand( - cmd, - meta.description, - isPositional ? [false] : [], - subCompletionConfig?.handler ?? noopHandler, - parentCmd - ); + + // Add command using t.ts API + const commandName = parentCmd ? `${parentCmd} ${cmd}` : cmd; + const command = t.command(cmd, meta.description); + + // Set args for the command if it has positional arguments + if (isPositional && config.args) { + // Add arguments with completion handlers from subCompletionConfig args + for (const [argName, argConfig] of Object.entries(config.args)) { + const conf = argConfig as ArgDef; + if (conf.type === 'positional') { + // Check if this is a variadic argument (required: false for variadic in citty) + const isVariadic = conf.required === false; + const argHandler = subCompletionConfig?.args?.[argName]; + if (argHandler) { + command.argument(argName, argHandler, isVariadic); + } else { + command.argument(argName, undefined, isVariadic); + } + } + } + } // Handle nested subcommands recursively if (subCommands) { await handleSubCommands( - completion, subCommands, - name, + commandName, subCompletionConfig?.subCommands ); } @@ -80,11 +149,11 @@ async function handleSubCommands( : conf.alias : undefined; - completion.addOption( - name, - `--${argName}`, + // Add option using t.ts API - store without -- prefix + command.option( + argName, conf.description ?? '', - subCompletionConfig?.options?.[argName]?.handler ?? noopHandler, + subCompletionConfig?.options?.[argName] ?? noopOptionHandler, shortFlag ); } @@ -95,9 +164,7 @@ async function handleSubCommands( export default async function tab( instance: CommandDef, completionConfig?: CompletionConfig -) { - const completion = new Completion(); - +): Promise { const meta = await resolve(instance.meta); if (!meta) { @@ -113,17 +180,25 @@ export default async function tab( throw new Error('Invalid or missing subCommands.'); } - const root = ''; const isPositional = isConfigPositional(instance); - completion.addCommand( - root, - meta?.description ?? '', - isPositional ? [false] : [], - completionConfig?.handler ?? noopHandler - ); + + // Set args for the root command if it has positional arguments + if (isPositional && instance.args) { + for (const [argName, argConfig] of Object.entries(instance.args)) { + const conf = argConfig as PositionalArgDef; + if (conf.type === 'positional') { + const isVariadic = conf.required === false; + const argHandler = completionConfig?.args?.[argName]; + if (argHandler) { + t.argument(argName, argHandler, isVariadic); + } else { + t.argument(argName, undefined, isVariadic); + } + } + } + } await handleSubCommands( - completion, subCommands, undefined, completionConfig?.subCommands @@ -132,11 +207,11 @@ export default async function tab( if (instance.args) { for (const [argName, argConfig] of Object.entries(instance.args)) { const conf = argConfig as PositionalArgDef; - completion.addOption( - root, - `--${argName}`, + // Add option using t.ts API - store without -- prefix + t.option( + argName, conf.description ?? '', - completionConfig?.options?.[argName]?.handler ?? noopHandler + completionConfig?.options?.[argName] ?? noopOptionHandler ); } } @@ -190,14 +265,8 @@ export default async function tab( assertDoubleDashes(name); const extra = ctx.rawArgs.slice(ctx.rawArgs.indexOf('--') + 1); - // const args = (await resolve(instance.args))!; - // const parsed = parseArgs(extra, args); - // TODO: this is not ideal at all - // const matchedCommand = parsed._.join(' ').trim(); //TODO: this was passed to parse line 170 - // TODO: `command lint i` does not work because `lint` and `i` are potential commands - // instead the potential command should only be `lint` - // and `i` is the to be completed part - return completion.parse(extra); + // Use t.ts parse method instead of completion.parse + return t.parse(extra); } } }, @@ -205,7 +274,7 @@ export default async function tab( subCommands.complete = completeCommand; - return completion; + return t; } type Resolvable = T | Promise | (() => T) | (() => Promise); diff --git a/src/fig.ts b/src/fig.ts index 8a5f71e..7fa675e 100644 --- a/src/fig.ts +++ b/src/fig.ts @@ -114,6 +114,7 @@ async function processCommand( return spec; } +// TODO: this should be an extension of t.setup function and not something like this. export async function generateFigSpec( command: CommandDef ): Promise { diff --git a/src/index.ts b/src/index.ts index 5e0286a..bcd16d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,64 @@ 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'; +import { Completion as CompletionItem } from './t'; + +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. @@ -61,16 +119,16 @@ export type Positional = { completion: Handler; }; -type Item = { - description: string; - value: string; +type CompletionResult = { + items: CompletionItem[]; + suppressDefault: boolean; }; export type Handler = ( previousArgs: string[], toComplete: string, endsWithSpace: boolean -) => Item[] | Promise; +) => CompletionItem[] | Promise; type Option = { description: string; @@ -89,8 +147,14 @@ type Command = { export class Completion { commands = new Map(); - completions: Item[] = []; + completions: CompletionItem[] = []; 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 +235,50 @@ export class Completion { } async parse(args: string[]) { + this.result = { items: [], suppressDefault: false }; + + // TODO: i did not notice this, this should not be handled here at all. package manager completions are something on top of this. just like any other completion system that is going to be built on top of tab. + // 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/src/shared.ts b/src/shared.ts index 798b525..e815b9d 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,20 +1,15 @@ -import { Handler } from './index'; +import { OptionHandler, ArgumentHandler } from './t'; -export const noopHandler: Handler = () => { - return []; +export const noopHandler: OptionHandler = function () { + // No-op handler for options }; // TODO (43081j): use type inference some day, so we can type-check // that the sub commands exist, the options exist, etc. export interface CompletionConfig { - handler?: Handler; subCommands?: Record; - options?: Record< - string, - { - handler: Handler; - } - >; + options?: Record; + args?: Record; } export function assertDoubleDashes(programName: string = 'cli'): void { diff --git a/src/t.ts b/src/t.ts new file mode 100644 index 0000000..5ecdada --- /dev/null +++ b/src/t.ts @@ -0,0 +1,447 @@ +// Shell directive constants +const ShellCompDirective = { + ShellCompDirectiveError: 1 << 0, + ShellCompDirectiveNoSpace: 1 << 1, + ShellCompDirectiveNoFileComp: 1 << 2, + ShellCompDirectiveFilterFileExt: 1 << 3, + ShellCompDirectiveFilterDirs: 1 << 4, + ShellCompDirectiveKeepOrder: 1 << 5, + shellCompDirectiveMaxValue: 1 << 6, + ShellCompDirectiveDefault: 0, +}; + +export type OptionsMap = Map; + +type Complete = (value: string, description: string) => void; + +export type OptionHandler = ( + this: Option, + complete: Complete, + options: OptionsMap +) => void; + +// Completion result types +export type Completion = { + description?: string; + value: string; +}; + +export type ArgumentHandler = ( + this: Argument, + complete: Complete, + options: OptionsMap +) => void; + +export class Argument { + name: string; + variadic: boolean; + command: Command; + handler?: ArgumentHandler; + + constructor( + command: Command, + name: string, + handler?: ArgumentHandler, + variadic: boolean = false + ) { + this.command = command; + this.name = name; + this.handler = handler; + this.variadic = variadic; + } +} + +export class Option { + value: string; + description: string; + command: Command; + handler?: OptionHandler; + alias?: string; + // TODO: handle boolean options + + constructor( + command: Command, + value: string, + description: string, + handler?: OptionHandler, + alias?: string + ) { + this.command = command; + this.value = value; + this.description = description; + this.handler = handler; + this.alias = alias; + } +} + +export class Command { + value: string; + description: string; + options = new Map(); + arguments = new Map(); + parent?: Command; + + constructor(value: string, description: string) { + this.value = value; + this.description = description; + } + + option( + value: string, + description: string, + handler?: OptionHandler, + alias?: string + ) { + const option = new Option(this, value, description, handler, alias); + this.options.set(value, option); + return this; + } + + argument(name: string, handler?: ArgumentHandler, variadic: boolean = false) { + const arg = new Argument(this, name, handler, variadic); + this.arguments.set(name, arg); + return this; + } +} + +import * as zsh from './zsh'; +import * as bash from './bash'; +import * as fish from './fish'; +import * as powershell from './powershell'; +import assert from 'node:assert'; + +export class RootCommand extends Command { + commands = new Map(); + completions: Completion[] = []; + directive = ShellCompDirective.ShellCompDirectiveDefault; + + constructor() { + super('', ''); + } + + command(value: string, description: string) { + const c = new Command(value, description); + this.commands.set(value, c); + return c; + } + + // Utility method to strip options from args for command matching + private stripOptions(args: string[]): string[] { + const parts: string[] = []; + let i = 0; + + while (i < args.length) { + const arg = args[i]; + + if (arg.startsWith('-')) { + i++; // Skip the option + if (i < args.length && !args[i].startsWith('-')) { + i++; // Skip the option value + } + } else { + parts.push(arg); + i++; + } + } + + return parts; + } + + // Find the appropriate command based on args + private matchCommand(args: string[]): [Command, string[]] { + args = this.stripOptions(args); + const parts: string[] = []; + let remaining: string[] = []; + let matched: Command = this; + + for (let i = 0; i < args.length; i++) { + const k = args[i]; + parts.push(k); + const potential = this.commands.get(parts.join(' ')); + + if (potential) { + matched = potential; + } else { + remaining = args.slice(i, args.length); + break; + } + } + + return [matched, remaining]; + } + + // Determine if we should complete flags + private shouldCompleteFlags( + lastPrevArg: string | undefined, + toComplete: string, + endsWithSpace: boolean + ): boolean { + return lastPrevArg?.startsWith('-') || toComplete.startsWith('-'); + } + + // Determine if we should complete commands + private shouldCompleteCommands( + toComplete: string, + endsWithSpace: boolean + ): boolean { + return !toComplete.startsWith('-'); + } + + // Handle flag completion (names and values) + private handleFlagCompletion( + command: Command, + previousArgs: string[], + toComplete: string, + endsWithSpace: boolean, + lastPrevArg: string | undefined + ) { + // Handle flag value completion + let optionName: string | undefined; + let valueToComplete = toComplete; + + if (toComplete.includes('=')) { + const [flag, value] = toComplete.split('='); + optionName = flag; + valueToComplete = value || ''; + } else if (lastPrevArg?.startsWith('-')) { + optionName = lastPrevArg; + } + + if (optionName) { + const option = this.findOption(command, optionName); + if (option?.handler) { + const suggestions: Completion[] = []; + option.handler.call( + option, + (value: string, description: string) => + suggestions.push({ value, description }), + command.options + ); + + this.completions = toComplete.includes('=') + ? suggestions.map((s) => ({ + value: `${optionName}=${s.value}`, + description: s.description, + })) + : suggestions; + } + return; + } + + // Handle flag name completion + if (toComplete.startsWith('-')) { + const isShortFlag = + toComplete.startsWith('-') && !toComplete.startsWith('--'); + const cleanToComplete = toComplete.replace(/^-+/, ''); + + for (const [name, option] of command.options) { + if ( + isShortFlag && + option.alias && + `-${option.alias}`.startsWith(toComplete) + ) { + this.completions.push({ + value: `-${option.alias}`, + description: option.description, + }); + } else if (!isShortFlag && name.startsWith(cleanToComplete)) { + this.completions.push({ + value: `--${name}`, + description: option.description, + }); + } + } + } + } + + // Helper method to find an option by name or alias + private findOption(command: Command, optionName: string): Option | undefined { + // Try direct match (with dashes) + let option = command.options.get(optionName); + if (option) return option; + + // Try without dashes (the actual storage format) + option = command.options.get(optionName.replace(/^-+/, '')); + if (option) return option; + + // Try by short alias + for (const [name, opt] of command.options) { + if (opt.alias && `-${opt.alias}` === optionName) { + return opt; + } + } + + return undefined; + } + + // Handle command completion + private handleCommandCompletion(previousArgs: string[], toComplete: string) { + const commandParts = previousArgs.filter(Boolean); + + for (const [k, command] of this.commands) { + if (k === '') continue; + + const parts = k.split(' '); + const match = parts + .slice(0, commandParts.length) + .every((part, i) => part === commandParts[i]); + + if (match && parts[commandParts.length]?.startsWith(toComplete)) { + this.completions.push({ + value: parts[commandParts.length], + description: command.description, + }); + } + } + } + + // Handle positional argument completion + private handlePositionalCompletion( + command: Command, + previousArgs: string[], + toComplete: string, + endsWithSpace: boolean + ) { + // Get the current argument position (subtract command name) + const commandParts = command.value.split(' ').length; + const currentArgIndex = Math.max(0, previousArgs.length - commandParts); + const argumentEntries = Array.from(command.arguments.entries()); + + // If we have arguments defined + if (argumentEntries.length > 0) { + // Find the appropriate argument for the current position + let targetArgument: Argument | undefined; + + if (currentArgIndex < argumentEntries.length) { + // We're within the defined arguments + const [argName, argument] = argumentEntries[currentArgIndex]; + targetArgument = argument; + } else { + // We're beyond the defined arguments, check if the last argument is variadic + const lastArgument = argumentEntries[argumentEntries.length - 1][1]; + if (lastArgument.variadic) { + targetArgument = lastArgument; + } + } + + // If we found a target argument with a handler, use it + if ( + targetArgument && + targetArgument.handler && + typeof targetArgument.handler === 'function' + ) { + const suggestions: Completion[] = []; + targetArgument.handler.call( + targetArgument, + (value: string, description: string) => + suggestions.push({ value, description }), + command.options + ); + this.completions.push(...suggestions); + } + } + } + + // Format and output completion results + private complete(toComplete: string) { + this.directive = ShellCompDirective.ShellCompDirectiveNoFileComp; + + const seen = new Set(); + this.completions + .filter((comp) => { + if (seen.has(comp.value)) return false; + seen.add(comp.value); + return true; + }) + .filter((comp) => comp.value.startsWith(toComplete)) + .forEach((comp) => + console.log(`${comp.value}\t${comp.description ?? ''}`) + ); + console.log(`:${this.directive}`); + } + + parse(args: string[]) { + this.completions = []; + + const endsWithSpace = args[args.length - 1] === ''; + + if (endsWithSpace) { + args.pop(); + } + + let toComplete = args[args.length - 1] || ''; + const previousArgs = args.slice(0, -1); + + if (endsWithSpace) { + previousArgs.push(toComplete); + toComplete = ''; + } + + const [matchedCommand] = this.matchCommand(previousArgs); + const lastPrevArg = previousArgs[previousArgs.length - 1]; + + // 1. Handle flag/option completion + if (this.shouldCompleteFlags(lastPrevArg, toComplete, endsWithSpace)) { + this.handleFlagCompletion( + matchedCommand, + previousArgs, + toComplete, + endsWithSpace, + lastPrevArg + ); + } else { + // 2. Handle command/subcommand completion + if (this.shouldCompleteCommands(toComplete, endsWithSpace)) { + this.handleCommandCompletion(previousArgs, toComplete); + } + // 3. Handle positional arguments + if (matchedCommand && matchedCommand.arguments.size > 0) { + this.handlePositionalCompletion( + matchedCommand, + previousArgs, + toComplete, + endsWithSpace + ); + } + } + + this.complete(toComplete); + } + + setup(name: string, executable: string, shell: string) { + assert( + shell === 'zsh' || + shell === 'bash' || + shell === 'fish' || + shell === 'powershell', + 'Unsupported shell' + ); + + switch (shell) { + case 'zsh': { + const script = zsh.generate(name, executable); + console.log(script); + break; + } + case 'bash': { + const script = bash.generate(name, executable); + console.log(script); + break; + } + case 'fish': { + const script = fish.generate(name, executable); + console.log(script); + break; + } + case 'powershell': { + const script = powershell.generate(name, executable); + console.log(script); + break; + } + } + } +} + +const t = new RootCommand(); + +export default t; diff --git a/tests/__snapshots__/cli.test.ts.snap b/tests/__snapshots__/cli.test.ts.snap index 0b24152..0527244 100644 --- a/tests/__snapshots__/cli.test.ts.snap +++ b/tests/__snapshots__/cli.test.ts.snap @@ -1,5 +1,44 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`cli completion tests for cac > --config option tests > should complete --config option values 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for cac > --config option tests > should complete --config option with equals sign 1`] = ` +"--config=vite.config.ts Vite config file +--config=vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for cac > --config option tests > should complete --config option with partial input 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for cac > --config option tests > should complete short flag -c option values 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > --config option tests > should complete short flag -c option with partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > --config option tests > should not suggest --config after it has been used 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + exports[`cli completion tests for cac > cli option completion tests > should complete option for partial input '{ partial: '--p', expected: '--port' }' 1`] = ` "--port Specify port :4 @@ -44,6 +83,35 @@ exports[`cli completion tests for cac > cli option value handling > should resol " `; +exports[`cli completion tests for cac > copy command argument handlers > should complete destination argument with build suggestions 1`] = ` +"build/ Build output +release/ Release directory +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for cac > copy command argument handlers > should complete source argument with directory suggestions 1`] = ` +"src/ Source directory +dist/ Distribution directory +public/ Public assets +:4 +" +`; + +exports[`cli completion tests for cac > copy command argument handlers > should filter destination suggestions when typing partial input 1`] = ` +"build/ Build output +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for cac > copy command argument handlers > should filter source suggestions when typing partial input 1`] = ` +"src/ Source directory +:4 +" +`; + exports[`cli completion tests for cac > edge case completions for end with space > should keep suggesting the --port option if user typed partial but didn't end with space 1`] = ` "--port Specify port :4 @@ -64,6 +132,32 @@ exports[`cli completion tests for cac > edge case completions for end with space " `; +exports[`cli completion tests for cac > lint command argument handlers > should complete files argument with file suggestions 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for cac > lint command argument handlers > should continue completing variadic files argument after first file 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for cac > lint command argument handlers > should continue completing variadic suggestions after first file 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for cac > lint command argument handlers > should filter file suggestions when typing partial input 1`] = ` +"main.ts Main file +:4 +" +`; + exports[`cli completion tests for cac > positional argument completions > should complete multiple positional arguments when ending with part of the value 1`] = ` "index.ts Index file :4 @@ -84,6 +178,82 @@ index.ts Index file " `; +exports[`cli completion tests for cac > root command argument tests > should complete root command project argument 1`] = ` +"dev Start dev server +serve Start the server +copy Copy files +lint Lint project +:4 +" +`; + +exports[`cli completion tests for cac > root command argument tests > should complete root command project argument after options 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > root command argument tests > should complete root command project argument with options and partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > root command argument tests > should complete root command project argument with partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command --logLevel option values 1`] = ` +"info Info level +warn Warn level +error Error level +silent Silent level +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command --logLevel option with partial input 1`] = ` +"info Info level +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command --mode option values 1`] = ` +"development Development mode +production Production mode +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command --mode option with partial input 1`] = ` +"development Development mode +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command options after project argument 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command options with partial input after project argument 1`] = ` +"--mode Set env mode +:4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command short flag -l option values 1`] = ` +":4 +" +`; + +exports[`cli completion tests for cac > root command option tests > should complete root command short flag -m option values 1`] = ` +":4 +" +`; + exports[`cli completion tests for cac > short flag handling > should handle global short flags 1`] = ` ":4 " @@ -110,11 +280,52 @@ exports[`cli completion tests for cac > short flag handling > should not show du exports[`cli completion tests for cac > should complete cli options 1`] = ` "dev Start dev server serve Start the server +copy Copy files lint Lint project :4 " `; +exports[`cli completion tests for citty > --config option tests > should complete --config option values 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for citty > --config option tests > should complete --config option with equals sign 1`] = ` +"--config=vite.config.ts Vite config file +--config=vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for citty > --config option tests > should complete --config option with partial input 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for citty > --config option tests > should complete short flag -c option values 1`] = ` +":4 +" +`; + +exports[`cli completion tests for citty > --config option tests > should complete short flag -c option with partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for citty > --config option tests > should not suggest --config after it has been used 1`] = ` +"--project Project name +--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + exports[`cli completion tests for citty > cli option completion tests > should complete option for partial input '{ partial: '--p', expected: '--port' }' 1`] = ` "--port Specify port :4 @@ -141,7 +352,8 @@ exports[`cli completion tests for citty > cli option exclusion tests > should no exports[`cli completion tests for citty > cli option value handling > should handle unknown options with no completions 1`] = `":4"`; exports[`cli completion tests for citty > cli option value handling > should not show duplicate options 1`] = ` -"--config Use specified config file +"--project Project name +--config Use specified config file --mode Set env mode --logLevel info | warn | error | silent :4 @@ -161,6 +373,35 @@ exports[`cli completion tests for citty > cli option value handling > should res " `; +exports[`cli completion tests for citty > copy command argument handlers > should complete destination argument with build suggestions 1`] = ` +"build/ Build output +release/ Release directory +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for citty > copy command argument handlers > should complete source argument with directory suggestions 1`] = ` +"src/ Source directory +dist/ Distribution directory +public/ Public assets +:4 +" +`; + +exports[`cli completion tests for citty > copy command argument handlers > should filter destination suggestions when typing partial input 1`] = ` +"build/ Build output +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for citty > copy command argument handlers > should filter source suggestions when typing partial input 1`] = ` +"src/ Source directory +:4 +" +`; + exports[`cli completion tests for citty > edge case completions for end with space > should keep suggesting the --port option if user typed partial but didn't end with space 1`] = ` "--port Specify port :4 @@ -181,6 +422,135 @@ exports[`cli completion tests for citty > edge case completions for end with spa " `; +exports[`cli completion tests for citty > lint command argument handlers > should complete files argument with file suggestions 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > lint command argument handlers > should continue completing variadic files argument after first file 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > lint command argument handlers > should continue completing variadic suggestions after first file 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > lint command argument handlers > should filter file suggestions when typing partial input 1`] = ` +"main.ts Main file +:4 +" +`; + +exports[`cli completion tests for citty > positional argument completions > should complete multiple positional arguments when ending with part of the value 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > positional argument completions > should complete multiple positional arguments when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > positional argument completions > should complete single positional argument when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +:4 +" +`; + +exports[`cli completion tests for citty > root command argument tests > should complete root command project argument 1`] = ` +"dev Start dev server +build Build project +copy Copy files +lint Lint project +my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for citty > root command argument tests > should complete root command project argument after options 1`] = ` +":4 +" +`; + +exports[`cli completion tests for citty > root command argument tests > should complete root command project argument with options and partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for citty > root command argument tests > should complete root command project argument with partial input 1`] = ` +"my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command --logLevel option values 1`] = ` +"info Info level +warn Warn level +error Error level +silent Silent level +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command --logLevel option with partial input 1`] = ` +"info Info level +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command --mode option values 1`] = ` +"development Development mode +production Production mode +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command --mode option with partial input 1`] = ` +"development Development mode +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command options after project argument 1`] = ` +"--project Project name +--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command options with partial input after project argument 1`] = ` +"--mode Set env mode +:4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command short flag -l option values 1`] = ` +":4 +" +`; + +exports[`cli completion tests for citty > root command option tests > should complete root command short flag -m option values 1`] = ` +":4 +" +`; + exports[`cli completion tests for citty > short flag handling > should handle global short flags 1`] = ` ":4 " @@ -193,12 +563,14 @@ exports[`cli completion tests for citty > short flag handling > should handle sh `; exports[`cli completion tests for citty > short flag handling > should handle short flag with equals sign 1`] = ` -":4 +"-p=3000 Development server port +:4 " `; exports[`cli completion tests for citty > short flag handling > should not show duplicate options when short flag is used 1`] = ` -"--config Use specified config file +"--project Project name +--config Use specified config file --mode Set env mode --logLevel info | warn | error | silent :4 @@ -208,7 +580,11 @@ exports[`cli completion tests for citty > short flag handling > should not show exports[`cli completion tests for citty > should complete cli options 1`] = ` "dev Start dev server build Build project +copy Copy files lint Lint project +my-app My application +my-lib My library +my-tool My tool :4 " `; @@ -229,3 +605,321 @@ lint Lint source files :4 " `; + +exports[`cli completion tests for t > --config option tests > should complete --config option values 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > --config option tests > should complete --config option with equals sign 1`] = ` +"--config=vite.config.ts Vite config file +--config=vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > --config option tests > should complete --config option with partial input 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > --config option tests > should complete short flag -c option values 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > --config option tests > should complete short flag -c option with partial input 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > --config option tests > should not suggest --config after it has been used 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for t > cli option completion tests > should complete option for partial input '{ partial: '--p', expected: '--port' }' 1`] = ` +"--port Specify port +:4 +" +`; + +exports[`cli completion tests for t > cli option completion tests > should complete option for partial input '{ partial: '-H', expected: '-H' }' 1`] = ` +"-H Specify hostname +:4 +" +`; + +exports[`cli completion tests for t > cli option completion tests > should complete option for partial input '{ partial: '-p', expected: '-p' }' 1`] = ` +"-p Specify port +:4 +" +`; + +exports[`cli completion tests for t > cli option exclusion tests > should not suggest already specified option '{ specified: '--config', shouldNotContain: '--config' }' 1`] = ` +":4 +" +`; + +exports[`cli completion tests for t > cli option value handling > should handle unknown options with no completions 1`] = `":4"`; + +exports[`cli completion tests for t > cli option value handling > should not show duplicate options 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for t > cli option value handling > should resolve config option values correctly 1`] = ` +"vite.config.ts Vite config file +vite.config.js Vite config file +:4 +" +`; + +exports[`cli completion tests for t > cli option value handling > should resolve port value correctly 1`] = ` +"--port=3000 Development server port +:4 +" +`; + +exports[`cli completion tests for t > copy command argument handlers > should complete destination argument with build suggestions 1`] = ` +"build/ Build output +release/ Release directory +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for t > copy command argument handlers > should complete source argument with directory suggestions 1`] = ` +"src/ Source directory +dist/ Distribution directory +public/ Public assets +:4 +" +`; + +exports[`cli completion tests for t > copy command argument handlers > should filter destination suggestions when typing partial input 1`] = ` +"build/ Build output +backup/ Backup location +:4 +" +`; + +exports[`cli completion tests for t > copy command argument handlers > should filter source suggestions when typing partial input 1`] = ` +"src/ Source directory +:4 +" +`; + +exports[`cli completion tests for t > edge case completions for end with space > should keep suggesting the --port option if user typed partial but didn't end with space 1`] = ` +"--port Specify port +:4 +" +`; + +exports[`cli completion tests for t > edge case completions for end with space > should suggest port values if user ends with space after \`--port\` 1`] = ` +"3000 Development server port +8080 Alternative port +:4 +" +`; + +exports[`cli completion tests for t > edge case completions for end with space > should suggest port values if user typed \`--port=\` and hasn't typed a space or value yet 1`] = ` +"--port=3000 Development server port +--port=8080 Alternative port +:4 +" +`; + +exports[`cli completion tests for t > lint command argument handlers > should complete files argument with file suggestions 1`] = ` +"main.ts Main file +index.ts Index file +src/ Source directory +tests/ Tests directory +:4 +" +`; + +exports[`cli completion tests for t > lint command argument handlers > should continue completing variadic files argument after first file 1`] = ` +"main.ts Main file +index.ts Index file +src/ Source directory +tests/ Tests directory +:4 +" +`; + +exports[`cli completion tests for t > lint command argument handlers > should continue completing variadic suggestions after first file 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for t > lint command argument handlers > should filter file suggestions when typing partial input 1`] = ` +"main.ts Main file +:4 +" +`; + +exports[`cli completion tests for t > positional argument completions > should complete multiple positional arguments when ending with part of the value 1`] = ` +"index.ts Index file +:4 +" +`; + +exports[`cli completion tests for t > positional argument completions > should complete multiple positional arguments when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +src/ Source directory +tests/ Tests directory +:4 +" +`; + +exports[`cli completion tests for t > positional argument completions > should complete single positional argument when ending with space 1`] = ` +"main.ts Main file +index.ts Index file +src/ Source directory +tests/ Tests directory +:4 +" +`; + +exports[`cli completion tests for t > root command argument tests > should complete root command project argument 1`] = ` +"dev Start dev server +serve Start the server +copy Copy files +lint Lint project +my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for t > root command argument tests > should complete root command project argument after options 1`] = ` +":4 +" +`; + +exports[`cli completion tests for t > root command argument tests > should complete root command project argument with options and partial input 1`] = ` +":4 +" +`; + +exports[`cli completion tests for t > root command argument tests > should complete root command project argument with partial input 1`] = ` +"my-app My application +my-lib My library +my-tool My tool +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command --logLevel option values 1`] = ` +"info Info level +warn Warn level +error Error level +silent Silent level +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command --logLevel option with partial input 1`] = ` +"info Info level +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command --mode option values 1`] = ` +"development Development mode +production Production mode +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command --mode option with partial input 1`] = ` +"development Development mode +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command options after project argument 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command options with partial input after project argument 1`] = ` +"--mode Set env mode +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command short flag -l option values 1`] = ` +"info Info level +warn Warn level +error Error level +silent Silent level +:4 +" +`; + +exports[`cli completion tests for t > root command option tests > should complete root command short flag -m option values 1`] = ` +"development Development mode +production Production mode +:4 +" +`; + +exports[`cli completion tests for t > short flag handling > should handle global short flags 1`] = ` +"-c Use specified config file +:4 +" +`; + +exports[`cli completion tests for t > short flag handling > should handle short flag value completion 1`] = ` +"-p Specify port +:4 +" +`; + +exports[`cli completion tests for t > short flag handling > should handle short flag with equals sign 1`] = ` +"-p=3000 Development server port +:4 +" +`; + +exports[`cli completion tests for t > short flag handling > should not show duplicate options when short flag is used 1`] = ` +"--config Use specified config file +--mode Set env mode +--logLevel info | warn | error | silent +:4 +" +`; + +exports[`cli completion tests for t > should complete cli options 1`] = ` +"dev Start dev server +serve Start the server +copy Copy files +lint Lint project +my-app My application +my-lib My library +my-tool My tool +:4 +" +`; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index e538367..da2b863 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -13,21 +13,19 @@ function runCommand(command: string): Promise { }); } -const cliTools = ['citty', 'cac', 'commander']; +const cliTools = ['t', 'citty', 'cac', 'commander']; describe.each(cliTools)('cli completion tests for %s', (cliTool) => { // For Commander, we need to skip most of the tests since it handles completion differently const shouldSkipTest = cliTool === 'commander'; // Commander uses a different command structure for completion + // TODO: why commander does that? our convention is the -- part which should be always there. const commandPrefix = cliTool === 'commander' ? `pnpm tsx examples/demo.${cliTool}.ts complete` : `pnpm tsx examples/demo.${cliTool}.ts complete --`; - // Use 'dev' for citty and 'serve' for other tools - const commandName = cliTool === 'citty' ? 'dev' : 'serve'; - it.runIf(!shouldSkipTest)('should complete cli options', async () => { const output = await runCommand(`${commandPrefix}`); expect(output).toMatchSnapshot(); @@ -43,7 +41,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { test.each(optionTests)( "should complete option for partial input '%s'", async ({ partial }) => { - const command = `${commandPrefix} ${commandName} ${partial}`; + const command = `${commandPrefix} dev ${partial}`; const output = await runCommand(command); expect(output).toMatchSnapshot(); } @@ -67,7 +65,7 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { describe.runIf(!shouldSkipTest)('cli option value handling', () => { it('should resolve port value correctly', async () => { - const command = `${commandPrefix} ${commandName} --port=3`; + const command = `${commandPrefix} dev --port=3`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); @@ -91,23 +89,137 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); + describe.runIf(!shouldSkipTest)('--config option tests', () => { + it('should complete --config option values', async () => { + const command = `${commandPrefix} --config ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete --config option with partial input', async () => { + const command = `${commandPrefix} --config vite.config`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete --config option with equals sign', async () => { + const command = `${commandPrefix} --config=vite.config`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete short flag -c option values', async () => { + const command = `${commandPrefix} -c ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete short flag -c option with partial input', async () => { + const command = `${commandPrefix} -c vite.config`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should not suggest --config after it has been used', async () => { + const command = `${commandPrefix} --config vite.config.ts --`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); + + describe.runIf(!shouldSkipTest)('root command argument tests', () => { + it('should complete root command project argument', async () => { + const command = `${commandPrefix} ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command project argument with partial input', async () => { + const command = `${commandPrefix} my-`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command project argument after options', async () => { + const command = `${commandPrefix} --config vite.config.ts ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command project argument with options and partial input', async () => { + const command = `${commandPrefix} --mode development my-`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); + + describe.runIf(!shouldSkipTest)('root command option tests', () => { + it('should complete root command --mode option values', async () => { + const command = `${commandPrefix} --mode ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command --mode option with partial input', async () => { + const command = `${commandPrefix} --mode dev`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command --logLevel option values', async () => { + const command = `${commandPrefix} --logLevel ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command --logLevel option with partial input', async () => { + const command = `${commandPrefix} --logLevel i`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command short flag -m option values', async () => { + const command = `${commandPrefix} -m ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command short flag -l option values', async () => { + const command = `${commandPrefix} -l ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command options after project argument', async () => { + const command = `${commandPrefix} my-app --`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete root command options with partial input after project argument', async () => { + const command = `${commandPrefix} my-app --m`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); + describe.runIf(!shouldSkipTest)( 'edge case completions for end with space', () => { it('should suggest port values if user ends with space after `--port`', async () => { - const command = `${commandPrefix} ${commandName} --port ""`; + const command = `${commandPrefix} dev --port ""`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); it("should keep suggesting the --port option if user typed partial but didn't end with space", async () => { - const command = `${commandPrefix} ${commandName} --po`; + const command = `${commandPrefix} dev --po`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); it("should suggest port values if user typed `--port=` and hasn't typed a space or value yet", async () => { - const command = `${commandPrefix} ${commandName} --port=`; + const command = `${commandPrefix} dev --port=`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); @@ -116,13 +228,13 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { describe.runIf(!shouldSkipTest)('short flag handling', () => { it('should handle short flag value completion', async () => { - const command = `${commandPrefix} ${commandName} -p `; + const command = `${commandPrefix} dev -p `; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); it('should handle short flag with equals sign', async () => { - const command = `${commandPrefix} ${commandName} -p=3`; + const command = `${commandPrefix} dev -p=3`; const output = await runCommand(command); expect(output).toMatchSnapshot(); }); @@ -140,28 +252,77 @@ describe.each(cliTools)('cli completion tests for %s', (cliTool) => { }); }); - describe.runIf(!shouldSkipTest && cliTool !== 'citty')( - 'positional argument completions', - () => { - it('should complete multiple positional arguments when ending with space', async () => { - const command = `${commandPrefix} lint ""`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + describe.runIf(!shouldSkipTest)('positional argument completions', () => { + it('should complete multiple positional arguments when ending with space', async () => { + const command = `${commandPrefix} lint ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it('should complete multiple positional arguments when ending with part of the value', async () => { - const command = `${commandPrefix} lint ind`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); + it('should complete multiple positional arguments when ending with part of the value', async () => { + const command = `${commandPrefix} lint ind`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); - it('should complete single positional argument when ending with space', async () => { - const command = `${commandPrefix} lint main.ts ""`; - const output = await runCommand(command); - expect(output).toMatchSnapshot(); - }); - } - ); + it('should complete single positional argument when ending with space', async () => { + const command = `${commandPrefix} lint main.ts ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); + + describe.runIf(!shouldSkipTest)('copy command argument handlers', () => { + it('should complete source argument with directory suggestions', async () => { + const command = `${commandPrefix} copy ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should complete destination argument with build suggestions', async () => { + const command = `${commandPrefix} copy src/ ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should filter source suggestions when typing partial input', async () => { + const command = `${commandPrefix} copy s`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should filter destination suggestions when typing partial input', async () => { + const command = `${commandPrefix} copy src/ b`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); + + describe.runIf(!shouldSkipTest)('lint command argument handlers', () => { + it('should complete files argument with file suggestions', async () => { + const command = `${commandPrefix} lint ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should filter file suggestions when typing partial input', async () => { + const command = `${commandPrefix} lint m`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should continue completing variadic files argument after first file', async () => { + const command = `${commandPrefix} lint main.ts ""`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + + it('should continue completing variadic suggestions after first file', async () => { + const command = `${commandPrefix} lint main.ts i`; + const output = await runCommand(command); + expect(output).toMatchSnapshot(); + }); + }); }); // Add specific tests for Commander 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,