Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions bin/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/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'];

const cli = cac('tab');

cli
.command(
'<packageManager> complete',
'Process completion requests from shell'
)
.action(async (packageManager) => {
if (!packageManagers.includes(packageManager)) {
console.error(`Error: Unsupported package manager "${packageManager}"`);
console.error(
`Supported package managers: ${packageManagers.join(', ')}`
);
process.exit(1);
}

const dashIndex = process.argv.indexOf('--');
if (dashIndex !== -1) {
const completion = new Completion();
setupCompletionForPackageManager(packageManager, completion);
const toComplete = process.argv.slice(dashIndex + 1);
await completion.parse(toComplete);
process.exit(0);
} else {
console.error(`Error: Expected '--' followed by command to complete`);
console.error(
`Example: ${packageManager} exec @bombsh/tab ${packageManager} complete -- command-to-complete`
);
process.exit(1);
}
});

cli
.command(
'<packageManager> <shell>',
'Generate shell completion script for a package manager'
)
.action(async (packageManager, shell) => {
if (shell === 'complete') {
Comment thread
AmirSa12 marked this conversation as resolved.
Outdated
const dashIndex = process.argv.indexOf('--');
Comment thread
AmirSa12 marked this conversation as resolved.
Outdated
if (dashIndex !== -1) {
const completion = new Completion();
setupCompletionForPackageManager(packageManager, completion);
const toComplete = process.argv.slice(dashIndex + 1);
await completion.parse(toComplete);
process.exit(0);
} else {
console.error(`Error: Expected '--' followed by command to complete`);
console.error(
`Example: ${packageManager} exec @bombsh/tab ${packageManager} complete -- command-to-complete`
);
process.exit(1);
}
return;
}

if (!packageManagers.includes(packageManager)) {
console.error(`Error: Unsupported package manager "${packageManager}"`);
console.error(
`Supported package managers: ${packageManagers.join(', ')}`
);
process.exit(1);
}

if (!shells.includes(shell)) {
console.error(`Error: Unsupported shell "${shell}"`);
console.error(`Supported shells: ${shells.join(', ')}`);
process.exit(1);
}

generateCompletionScript(packageManager, shell);
});

const completion = tab(cli);

cli.parse();

function generateCompletionScript(packageManager: string, shell: string) {
const name = packageManager;
const executable = process.env.npm_execpath
? `${packageManager} exec @bombsh/tab ${packageManager}`
: `node ${process.argv[1]} ${packageManager}`;
script(shell as any, name, executable);
}
185 changes: 185 additions & 0 deletions bin/completion-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
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<boolean> {
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.
});
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<string[]> {
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 [];
}
}

export function setupCompletionForPackageManager(
packageManager: string,
completion: Completion
) {
if (packageManager === 'pnpm') {
setupPnpmCompletions(completion);
} else if (packageManager === 'npm') {
setupNpmCompletions(completion);
} else if (packageManager === 'yarn') {
setupYarnCompletions(completion);
} else if (packageManager === 'bun') {
setupBunCompletions(completion);
}

completion.onBeforeParse(async (args: string[]) => {
debugLog(`onBeforeParse: args =`, args);

if (args.length >= 1) {
const potentialCliName = args[0];
const knownCommands = [...completion.commands.keys()];

debugLog(
`Potential CLI: ${potentialCliName}, Known commands:`,
knownCommands
);

if (knownCommands.includes(potentialCliName)) {
debugLog(`${potentialCliName} is a known command, skipping CLI check`);
return;
}

const hasCompletions = await checkCliHasCompletions(
potentialCliName,
packageManager
);
if (hasCompletions) {
debugLog(
`${potentialCliName} supports completions, getting suggestions`
);

const cliArgs = args.slice(1);
const suggestions = await getCliCompletions(
Comment thread
AmirSa12 marked this conversation as resolved.
Outdated
potentialCliName,
packageManager,
cliArgs
);

if (suggestions.length > 0) {
debugLog(`Processing ${suggestions.length} suggestions`);

completion.result.suppressDefault = true;

for (const suggestion of suggestions) {
if (suggestion.startsWith(':')) {
debugLog(`Skipping directive: ${suggestion}`);
continue;
}

if (suggestion.includes('\t')) {
const [value, description] = suggestion.split('\t');
debugLog(
`Adding completion with description: ${value} -> ${description}`
);
completion.result.items.push({ value, description });
} else {
debugLog(`Adding completion without description: ${suggestion}`);
completion.result.items.push({ value: suggestion });
}
}
} else {
debugLog(`No suggestions found for ${potentialCliName}`);
}
} else {
debugLog(`${potentialCliName} does not support completions`);
}
}
});
}

export function setupPnpmCompletions(completion: Completion) {
completion.addCommand('add', 'Install a package', [], async () => []);
Comment thread
AmirSa12 marked this conversation as resolved.
completion.addCommand('remove', 'Remove a package', [], async () => []);
completion.addCommand(
'install',
'Install all dependencies',
[],
async () => []
);
completion.addCommand('update', 'Update packages', [], async () => []);
completion.addCommand('exec', 'Execute a command', [], async () => []);
completion.addCommand('run', 'Run a script', [], async () => []);
completion.addCommand('publish', 'Publish package', [], async () => []);
completion.addCommand('test', 'Run tests', [], async () => []);
completion.addCommand('build', 'Build project', [], async () => []);
}

export function setupNpmCompletions(completion: Completion) {
completion.addCommand('install', 'Install a package', [], async () => []);
completion.addCommand('uninstall', 'Uninstall a package', [], async () => []);
completion.addCommand('run', 'Run a script', [], async () => []);
completion.addCommand('test', 'Run tests', [], async () => []);
completion.addCommand('publish', 'Publish package', [], async () => []);
completion.addCommand('update', 'Update packages', [], async () => []);
completion.addCommand('start', 'Start the application', [], async () => []);
completion.addCommand('build', 'Build project', [], async () => []);
}

export function setupYarnCompletions(completion: Completion) {
completion.addCommand('add', 'Add a package', [], async () => []);
completion.addCommand('remove', 'Remove a package', [], async () => []);
completion.addCommand('run', 'Run a script', [], async () => []);
completion.addCommand('test', 'Run tests', [], async () => []);
completion.addCommand('publish', 'Publish package', [], async () => []);
completion.addCommand('install', 'Install dependencies', [], async () => []);
completion.addCommand('build', 'Build project', [], async () => []);
}

export function setupBunCompletions(completion: Completion) {
completion.addCommand('add', 'Add a package', [], async () => []);
completion.addCommand('remove', 'Remove a package', [], async () => []);
completion.addCommand('run', 'Run a script', [], async () => []);
completion.addCommand('test', 'Run tests', [], async () => []);
completion.addCommand('install', 'Install dependencies', [], async () => []);
completion.addCommand('update', 'Update packages', [], async () => []);
completion.addCommand('build', 'Build project', [], async () => []);
}
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
"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"
Expand Down
24 changes: 23 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ export type Positional = {
};

type Item = {
description: string;
description?: string;
value: string;
};

type CompletionResult = {
items: Item[];
suppressDefault: boolean;
};

export type Handler = (
previousArgs: string[],
toComplete: string,
Expand All @@ -91,6 +96,12 @@ export class Completion {
commands = new Map<string, Command>();
completions: Item[] = [];
directive = ShellCompDirective.ShellCompDirectiveDefault;
result: CompletionResult = { items: [], suppressDefault: false };
private beforeParseFn: ((args: string[]) => Promise<void>) | null = null;

onBeforeParse(fn: (args: string[]) => Promise<void>) {
this.beforeParseFn = fn;
}

// vite <entry> <another> [...files]
// args: [false, false, true], only the last argument can be variadic
Expand Down Expand Up @@ -171,6 +182,17 @@ export class Completion {
}

async parse(args: string[]) {
this.result = { items: [], suppressDefault: false };

if (this.beforeParseFn) {
await this.beforeParseFn(args);
if (this.result.suppressDefault && this.result.items.length > 0) {
this.completions = this.result.items;
this.complete('');
return;
}
}

const endsWithSpace = args[args.length - 1] === '';

if (endsWithSpace) {
Expand Down
8 changes: 7 additions & 1 deletion tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down