Skip to content

Commit 571f12e

Browse files
AmirSa12Aslemammad
andauthored
feat: add pnpm completion script (#23)
* init * update * new implementation * prettier * 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 * tab -> t wip * before some ai changes * huge wip * grammar issues * prettier --------- Co-authored-by: Mohammad Bagher Abiyat <37929992+Aslemammad@users.noreply.github.com>
1 parent 6c42c55 commit 571f12e

24 files changed

Lines changed: 2617 additions & 220 deletions

README.2.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
> A video showcasing how pnpm autocompletions work on a test CLI command like `my-cli`
2+
3+
# tab
4+
5+
> Instant feedback for your CLI tool when hitting [TAB] in your terminal
6+
7+
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.
8+
9+
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.
10+
11+
`my-cli.ts`:
12+
13+
```typescript
14+
import t from '@bombsh/tab';
15+
16+
t.name('my-cli');
17+
18+
t.command('start', 'start the development server');
19+
20+
if (process.argv[2] === 'complete') {
21+
const [shell, ...args] = process.argv.slice(3);
22+
if (shell === '--') {
23+
t.parse(args);
24+
} else {
25+
t.setup(shell, x);
26+
}
27+
}
28+
```
29+
30+
This `my-cli.ts` would be equipped with all the tools required to provide autocompletions.
31+
32+
```bash
33+
node my-cli.ts complete -- "st"
34+
```
35+
36+
```
37+
start start the development server
38+
:0
39+
```
40+
41+
This output was generated by the `t.parse` method to autocomplete "st" to "start".
42+
43+
Obviously, the user won't be running that command directly in their terminal. They'd be running something like this.
44+
45+
```bash
46+
source <(node my-cli.ts complete zsh)
47+
```
48+
49+
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`.
50+
51+
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.
52+
53+
```bash
54+
my-cli complete zsh > ~/completion-for-my-cli.zsh && echo 'source ~/completion-for-my-cli.zsh' >> ~/.zshrc
55+
```
56+
57+
Or
58+
59+
```bash
60+
echo 'source <(npx --offline my-cli complete zsh)' >> ~/.zshrc
61+
```
62+
63+
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.
64+
65+
---
66+
67+
While working on tab, we came to the realization that most JavaScript CLIs are not global CLI commands but rather, per-project dependencies.
68+
69+
For instance, Vite won't be installed globally and instead it'd be always installed on a project. Here's an example usage:
70+
71+
```bash
72+
pnpm vite dev
73+
```
74+
75+
Rather than installing it globally. This example is pretty rare:
76+
77+
```bash
78+
vite dev
79+
```
80+
81+
So in this case, a computer might have hundreds of Vite instances each installed separately and potentially from different versions on different projects.
82+
83+
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.
84+
85+
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`.
86+
87+
They'd only have to run this command once and inject it in their shell config.
88+
89+
```bash
90+
npx @bombsh/tab pnpm zsh
91+
```
92+
93+
These autocompletions on top of the normal autocompletions that these package managers provide are going to be way more powerful.
94+
95+
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.
96+
97+
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.
98+
99+
```bash
100+
pnpm my-cli complete --
101+
```
102+
103+
```
104+
start start the development server
105+
:0
106+
```
107+
108+
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.

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
[![tweet-1827921103093932490](https://github.com/user-attachments/assets/21521787-7936-44be-8d3c-8214cd2fcee9)](https://x.com/karpathy/status/1827921103093932490)
2-
31
# tab
42

53
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.

bin/cli.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
// TODO: aren't these conditions are already handled by cac?
16+
const args = process.argv.slice(2);
17+
if (args.length >= 2 && args[1] === 'complete') {
18+
const packageManager = args[0];
19+
20+
if (!packageManagers.includes(packageManager)) {
21+
console.error(`Error: Unsupported package manager "${packageManager}"`);
22+
console.error(
23+
`Supported package managers: ${packageManagers.join(', ')}`
24+
);
25+
process.exit(1);
26+
}
27+
28+
const dashIndex = process.argv.indexOf('--');
29+
if (dashIndex !== -1) {
30+
// TOOD: there's no Completion anymore
31+
const completion = new Completion();
32+
setupCompletionForPackageManager(packageManager, completion);
33+
const toComplete = process.argv.slice(dashIndex + 1);
34+
await completion.parse(toComplete);
35+
process.exit(0);
36+
} else {
37+
console.error(`Error: Expected '--' followed by command to complete`);
38+
console.error(
39+
`Example: ${packageManager} exec @bombsh/tab ${packageManager} complete -- command-to-complete`
40+
);
41+
process.exit(1);
42+
}
43+
}
44+
45+
cli
46+
.command(
47+
'<packageManager> <shell>',
48+
'Generate shell completion script for a package manager'
49+
)
50+
.action(async (packageManager, shell) => {
51+
if (!packageManagers.includes(packageManager)) {
52+
console.error(`Error: Unsupported package manager "${packageManager}"`);
53+
console.error(
54+
`Supported package managers: ${packageManagers.join(', ')}`
55+
);
56+
process.exit(1);
57+
}
58+
59+
if (!shells.includes(shell)) {
60+
console.error(`Error: Unsupported shell "${shell}"`);
61+
console.error(`Supported shells: ${shells.join(', ')}`);
62+
process.exit(1);
63+
}
64+
65+
generateCompletionScript(packageManager, shell);
66+
});
67+
68+
tab(cli);
69+
70+
cli.parse();
71+
}
72+
73+
// function generateCompletionScript(packageManager: string, shell: string) {
74+
// const name = packageManager;
75+
// const executable = process.env.npm_execpath
76+
// ? `${packageManager} exec @bombsh/tab ${packageManager}`
77+
// : `node ${process.argv[1]} ${packageManager}`;
78+
// script(shell as any, name, executable);
79+
// }
80+
81+
function generateCompletionScript(packageManager: string, shell: string) {
82+
const name = packageManager;
83+
// this always points at the actual file on disk (TESTING)
84+
const executable = `node ${process.argv[1]} ${packageManager}`;
85+
script(shell as any, name, executable);
86+
}
87+
88+
main().catch(console.error);

bin/completion-handlers.ts

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

0 commit comments

Comments
 (0)