Skip to content

Commit e9352b4

Browse files
authored
feat: autocomplete clis executing through a package manager (#26)
* init * prettier * update * cli completions * pnpm install * fix: handle complete command manually * fix: completion-handler __complete => complete * fix: remove examples form package.json * fix: generateCompletionScript function * move the package manager completion logic directly into the parse method * prettier
1 parent 07acf01 commit e9352b4

File tree

8 files changed

+368
-61
lines changed

8 files changed

+368
-61
lines changed

bin/cli.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env node
2+
3+
import cac from 'cac';
4+
import { script, Completion } from '../src/index.js';
5+
import tab from '../src/cac.js';
6+
7+
import { setupCompletionForPackageManager } from './completion-handlers';
8+
9+
const packageManagers = ['npm', 'pnpm', 'yarn', 'bun'];
10+
const shells = ['zsh', 'bash', 'fish', 'powershell'];
11+
12+
async function main() {
13+
const cli = cac('tab');
14+
15+
const args = process.argv.slice(2);
16+
if (args.length >= 2 && args[1] === 'complete') {
17+
const packageManager = args[0];
18+
19+
if (!packageManagers.includes(packageManager)) {
20+
console.error(`Error: Unsupported package manager "${packageManager}"`);
21+
console.error(
22+
`Supported package managers: ${packageManagers.join(', ')}`
23+
);
24+
process.exit(1);
25+
}
26+
27+
const dashIndex = process.argv.indexOf('--');
28+
if (dashIndex !== -1) {
29+
const completion = new Completion();
30+
setupCompletionForPackageManager(packageManager, completion);
31+
const toComplete = process.argv.slice(dashIndex + 1);
32+
await completion.parse(toComplete);
33+
process.exit(0);
34+
} else {
35+
console.error(`Error: Expected '--' followed by command to complete`);
36+
console.error(
37+
`Example: ${packageManager} exec @bombsh/tab ${packageManager} complete -- command-to-complete`
38+
);
39+
process.exit(1);
40+
}
41+
}
42+
43+
cli
44+
.command(
45+
'<packageManager> <shell>',
46+
'Generate shell completion script for a package manager'
47+
)
48+
.action(async (packageManager, shell) => {
49+
if (!packageManagers.includes(packageManager)) {
50+
console.error(`Error: Unsupported package manager "${packageManager}"`);
51+
console.error(
52+
`Supported package managers: ${packageManagers.join(', ')}`
53+
);
54+
process.exit(1);
55+
}
56+
57+
if (!shells.includes(shell)) {
58+
console.error(`Error: Unsupported shell "${shell}"`);
59+
console.error(`Supported shells: ${shells.join(', ')}`);
60+
process.exit(1);
61+
}
62+
63+
generateCompletionScript(packageManager, shell);
64+
});
65+
66+
const completion = tab(cli);
67+
68+
cli.parse();
69+
}
70+
71+
// function generateCompletionScript(packageManager: string, shell: string) {
72+
// const name = packageManager;
73+
// const executable = process.env.npm_execpath
74+
// ? `${packageManager} exec @bombsh/tab ${packageManager}`
75+
// : `node ${process.argv[1]} ${packageManager}`;
76+
// script(shell as any, name, executable);
77+
// }
78+
79+
function generateCompletionScript(packageManager: string, shell: string) {
80+
const name = packageManager;
81+
// this always points at the actual file on disk (TESTING)
82+
const executable = `node ${process.argv[1]} ${packageManager}`;
83+
script(shell as any, name, executable);
84+
}
85+
86+
main().catch(console.error);

bin/completion-handlers.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { Completion } from '../src/index.js';
2+
import { execSync } from 'child_process';
3+
4+
const DEBUG = false; // for debugging purposes
5+
6+
function debugLog(...args: any[]) {
7+
if (DEBUG) {
8+
console.error('[DEBUG]', ...args);
9+
}
10+
}
11+
12+
async function checkCliHasCompletions(
13+
cliName: string,
14+
packageManager: string
15+
): Promise<boolean> {
16+
try {
17+
debugLog(`Checking if ${cliName} has completions via ${packageManager}`);
18+
const command = `${packageManager} ${cliName} complete --`;
19+
const result = execSync(command, {
20+
encoding: 'utf8',
21+
stdio: ['pipe', 'pipe', 'ignore'],
22+
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)
23+
});
24+
const hasCompletions = !!result.trim();
25+
debugLog(`${cliName} supports completions: ${hasCompletions}`);
26+
return hasCompletions;
27+
} catch (error) {
28+
debugLog(`Error checking completions for ${cliName}:`, error);
29+
return false;
30+
}
31+
}
32+
33+
async function getCliCompletions(
34+
cliName: string,
35+
packageManager: string,
36+
args: string[]
37+
): Promise<string[]> {
38+
try {
39+
const completeArgs = args.map((arg) =>
40+
arg.includes(' ') ? `"${arg}"` : arg
41+
);
42+
const completeCommand = `${packageManager} ${cliName} complete -- ${completeArgs.join(' ')}`;
43+
debugLog(`Getting completions with command: ${completeCommand}`);
44+
45+
const result = execSync(completeCommand, {
46+
encoding: 'utf8',
47+
stdio: ['pipe', 'pipe', 'ignore'],
48+
timeout: 1000, // same: longer timeout needed for shell completion system (shell → node → package manager → cli)
49+
});
50+
51+
const completions = result.trim().split('\n').filter(Boolean);
52+
debugLog(`Got ${completions.length} completions from ${cliName}`);
53+
return completions;
54+
} catch (error) {
55+
debugLog(`Error getting completions from ${cliName}:`, error);
56+
return [];
57+
}
58+
}
59+
60+
export function setupCompletionForPackageManager(
61+
packageManager: string,
62+
completion: Completion
63+
) {
64+
if (packageManager === 'pnpm') {
65+
setupPnpmCompletions(completion);
66+
} else if (packageManager === 'npm') {
67+
setupNpmCompletions(completion);
68+
} else if (packageManager === 'yarn') {
69+
setupYarnCompletions(completion);
70+
} else if (packageManager === 'bun') {
71+
setupBunCompletions(completion);
72+
}
73+
74+
completion.setPackageManager(packageManager);
75+
}
76+
77+
export function setupPnpmCompletions(completion: Completion) {
78+
completion.addCommand('add', 'Install a package', [], async () => []);
79+
completion.addCommand('remove', 'Remove a package', [], async () => []);
80+
completion.addCommand(
81+
'install',
82+
'Install all dependencies',
83+
[],
84+
async () => []
85+
);
86+
completion.addCommand('update', 'Update packages', [], async () => []);
87+
completion.addCommand('exec', 'Execute a command', [], async () => []);
88+
completion.addCommand('run', 'Run a script', [], async () => []);
89+
completion.addCommand('publish', 'Publish package', [], async () => []);
90+
completion.addCommand('test', 'Run tests', [], async () => []);
91+
completion.addCommand('build', 'Build project', [], async () => []);
92+
}
93+
94+
export function setupNpmCompletions(completion: Completion) {
95+
completion.addCommand('install', 'Install a package', [], async () => []);
96+
completion.addCommand('uninstall', 'Uninstall a package', [], async () => []);
97+
completion.addCommand('run', 'Run a script', [], async () => []);
98+
completion.addCommand('test', 'Run tests', [], async () => []);
99+
completion.addCommand('publish', 'Publish package', [], async () => []);
100+
completion.addCommand('update', 'Update packages', [], async () => []);
101+
completion.addCommand('start', 'Start the application', [], async () => []);
102+
completion.addCommand('build', 'Build project', [], async () => []);
103+
}
104+
105+
export function setupYarnCompletions(completion: Completion) {
106+
completion.addCommand('add', 'Add a package', [], async () => []);
107+
completion.addCommand('remove', 'Remove a package', [], async () => []);
108+
completion.addCommand('run', 'Run a script', [], async () => []);
109+
completion.addCommand('test', 'Run tests', [], async () => []);
110+
completion.addCommand('publish', 'Publish package', [], async () => []);
111+
completion.addCommand('install', 'Install dependencies', [], async () => []);
112+
completion.addCommand('build', 'Build project', [], async () => []);
113+
}
114+
115+
export function setupBunCompletions(completion: Completion) {
116+
completion.addCommand('add', 'Add a package', [], async () => []);
117+
completion.addCommand('remove', 'Remove a package', [], async () => []);
118+
completion.addCommand('run', 'Run a script', [], async () => []);
119+
completion.addCommand('test', 'Run tests', [], async () => []);
120+
completion.addCommand('install', 'Install dependencies', [], async () => []);
121+
completion.addCommand('update', 'Update packages', [], async () => []);
122+
completion.addCommand('build', 'Build project', [], async () => []);
123+
}
Lines changed: 33 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#!/usr/bin/env node
22

3-
const cac = require('cac');
3+
import cac from 'cac';
4+
import tab from '../../dist/src/cac.js';
5+
46
const cli = cac('demo-cli-cac');
57

68
// Define version and help
@@ -29,61 +31,40 @@ cli
2931
console.log('Options:', options);
3032
});
3133

32-
// Manual implementation of completion for CAC
33-
if (process.argv[2] === '__complete') {
34-
const args = process.argv.slice(3);
35-
const toComplete = args[args.length - 1] || '';
36-
const previousArgs = args.slice(0, -1);
37-
38-
// Root command completion
39-
if (previousArgs.length === 0) {
40-
console.log('start\tStart the application');
41-
console.log('build\tBuild the application');
42-
console.log('--help\tDisplay help');
43-
console.log('--version\tOutput the version number');
44-
console.log('-c\tSpecify config file');
45-
console.log('--config\tSpecify config file');
46-
console.log('-d\tEnable debugging');
47-
console.log('--debug\tEnable debugging');
48-
process.exit(0);
49-
}
50-
51-
// Subcommand completion
52-
if (previousArgs[0] === 'start') {
53-
console.log('-p\tPort to use');
54-
console.log('--port\tPort to use');
55-
console.log('--help\tDisplay help');
34+
// Set up completion using the cac adapter
35+
const completion = await tab(cli);
5636

57-
// Port value completion if --port is the last arg
58-
if (
59-
previousArgs[previousArgs.length - 1] === '--port' ||
60-
previousArgs[previousArgs.length - 1] === '-p'
61-
) {
62-
console.log('3000\tDefault port');
63-
console.log('8080\tAlternative port');
37+
// custom config for options
38+
for (const command of completion.commands.values()) {
39+
for (const [optionName, config] of command.options.entries()) {
40+
if (optionName === '--port') {
41+
config.handler = () => {
42+
return [
43+
{ value: '3000', description: 'Default port' },
44+
{ value: '8080', description: 'Alternative port' },
45+
];
46+
};
6447
}
65-
process.exit(0);
66-
}
6748

68-
if (previousArgs[0] === 'build') {
69-
console.log('-m\tBuild mode');
70-
console.log('--mode\tBuild mode');
71-
console.log('--help\tDisplay help');
49+
if (optionName === '--mode') {
50+
config.handler = () => {
51+
return [
52+
{ value: 'development', description: 'Development mode' },
53+
{ value: 'production', description: 'Production mode' },
54+
{ value: 'test', description: 'Test mode' },
55+
];
56+
};
57+
}
7258

73-
// Mode value completion if --mode is the last arg
74-
if (
75-
previousArgs[previousArgs.length - 1] === '--mode' ||
76-
previousArgs[previousArgs.length - 1] === '-m'
77-
) {
78-
console.log('development\tDevelopment mode');
79-
console.log('production\tProduction mode');
80-
console.log('test\tTest mode');
59+
if (optionName === '--config') {
60+
config.handler = () => {
61+
return [
62+
{ value: 'config.json', description: 'JSON config file' },
63+
{ value: 'config.js', description: 'JavaScript config file' },
64+
];
65+
};
8166
}
82-
process.exit(0);
8367
}
84-
85-
process.exit(0);
86-
} else {
87-
// Parse CLI args
88-
cli.parse();
8968
}
69+
70+
cli.parse();

examples/demo-cli-cac/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"version": "1.0.0",
44
"description": "Demo CLI using CAC for testing tab completions with pnpm",
55
"main": "demo-cli-cac.js",
6+
"type": "module",
67
"bin": {
78
"demo-cli-cac": "./demo-cli-cac.js"
89
},

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
{
2-
"name": "tab",
2+
"name": "@bombsh/tab",
33
"version": "0.0.0",
4-
"description": "",
54
"main": "./dist/index.js",
65
"types": "./dist/index.d.ts",
76
"type": "module",
7+
"bin": {
8+
"tab": "./dist/bin/cli.js"
9+
},
810
"scripts": {
911
"test": "vitest",
1012
"type-check": "tsc --noEmit",
1113
"format": "prettier --write .",
1214
"format:check": "prettier --check .",
1315
"build": "tsdown",
1416
"prepare": "pnpm build",
15-
"lint": "eslint src \"./*.ts\""
17+
"lint": "eslint src \"./*.ts\"",
18+
"test-cli": "tsx bin/cli.ts"
1619
},
1720
"files": [
1821
"dist"
@@ -34,7 +37,6 @@
3437
"vitest": "^2.1.3"
3538
},
3639
"dependencies": {
37-
"examples": "link:./examples",
3840
"mri": "^1.2.0"
3941
},
4042
"exports": {

pnpm-lock.yaml

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)