Skip to content

Commit be5d6a9

Browse files
authored
Merge pull request #64 from muxinc/autocomplete-mux-completion-install
Add `mux completions install` command
2 parents d10e905 + f301e68 commit be5d6a9

5 files changed

Lines changed: 305 additions & 2 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,17 @@ The binary is self-contained and has no dependencies.
8282

8383
## Shell Completions
8484

85-
Enable tab completion for commands, subcommands, and options by adding the appropriate line to your shell's config file:
85+
Enable tab completion for commands, subcommands, and options in your shell:
86+
87+
```bash
88+
mux completions install
89+
```
90+
91+
This detects your shell and adds the appropriate source line to your config file (e.g. `~/.zshrc`). Restart your shell or source the file to activate completions.
92+
93+
### Manual setup
94+
95+
If you prefer to configure completions yourself, add the appropriate line to your shell's config file:
8696

8797
**Bash:** Add the following line to `~/.bashrc`:
8898
```bash
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Command } from '@cliffy/command';
2+
import {
3+
detectShell,
4+
getCompletionLine,
5+
getRcFilePath,
6+
installCompletions,
7+
} from '../lib/completions.ts';
8+
9+
export const completionsInstallCommand = new Command()
10+
.description(
11+
'Install shell completions by adding the source line to your shell config file',
12+
)
13+
.option(
14+
'-s, --shell <shell:string>',
15+
'Shell to install for (zsh, bash, fish)',
16+
)
17+
.action(async (options) => {
18+
const shell = options.shell ?? detectShell();
19+
20+
if (!shell) {
21+
throw new Error(
22+
'Could not detect your shell. Please specify one with --shell (zsh, bash, fish).',
23+
);
24+
}
25+
26+
if (!['zsh', 'bash', 'fish'].includes(shell)) {
27+
throw new Error(
28+
`Unsupported shell: ${shell}. Supported shells: zsh, bash, fish.`,
29+
);
30+
}
31+
32+
const rcPath = getRcFilePath(shell as 'zsh' | 'bash' | 'fish');
33+
const result = await installCompletions(shell as 'zsh' | 'bash' | 'fish');
34+
35+
if (result.alreadyInstalled) {
36+
console.log(`Shell completions already configured in ${rcPath}`);
37+
return;
38+
}
39+
40+
console.log(
41+
`✅ Added \`${getCompletionLine(shell as 'zsh' | 'bash' | 'fish')}\` to ${rcPath}`,
42+
);
43+
console.log(` Restart your shell or run: source ${rcPath}`);
44+
});

src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { CompletionsCommand } from '@cliffy/command/completions';
44
import pkg from '../package.json';
55
import { annotationsCommand } from './commands/annotations/index.ts';
66
import { assetsCommand } from './commands/assets/index.ts';
7+
import { completionsInstallCommand } from './commands/completions-install.ts';
78
import { deliveryUsageCommand } from './commands/delivery-usage/index.ts';
89
import { dimensionsCommand } from './commands/dimensions/index.ts';
910
import { drmConfigurationsCommand } from './commands/drm-configurations/index.ts';
@@ -86,7 +87,10 @@ const cli = new Command()
8687
.command('exports', exportsCommand)
8788
.command('webhooks', webhooksCommand)
8889
.command('whoami', whoamiCommand)
89-
.command('completions', new CompletionsCommand());
90+
.command(
91+
'completions',
92+
new CompletionsCommand().command('install', completionsInstallCommand),
93+
);
9094

9195
// Run the CLI
9296
if (import.meta.main) {

src/lib/completions.test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import {
6+
detectShell,
7+
getCompletionLine,
8+
getRcFilePath,
9+
installCompletions,
10+
} from './completions.ts';
11+
12+
describe('completions', () => {
13+
describe('detectShell', () => {
14+
let originalShell: string | undefined;
15+
16+
beforeEach(() => {
17+
originalShell = process.env.SHELL;
18+
});
19+
20+
afterEach(() => {
21+
if (originalShell === undefined) {
22+
delete process.env.SHELL;
23+
} else {
24+
process.env.SHELL = originalShell;
25+
}
26+
});
27+
28+
it('should detect zsh', () => {
29+
process.env.SHELL = '/bin/zsh';
30+
expect(detectShell()).toBe('zsh');
31+
});
32+
33+
it('should detect bash', () => {
34+
process.env.SHELL = '/bin/bash';
35+
expect(detectShell()).toBe('bash');
36+
});
37+
38+
it('should detect fish', () => {
39+
process.env.SHELL = '/usr/local/bin/fish';
40+
expect(detectShell()).toBe('fish');
41+
});
42+
43+
it('should return null for unsupported shells', () => {
44+
process.env.SHELL = '/bin/csh';
45+
expect(detectShell()).toBeNull();
46+
});
47+
48+
it('should return null when SHELL is not set', () => {
49+
delete process.env.SHELL;
50+
expect(detectShell()).toBeNull();
51+
});
52+
});
53+
54+
describe('getRcFilePath', () => {
55+
it('should return ~/.zshrc for zsh', () => {
56+
const result = getRcFilePath('zsh');
57+
expect(result).toEndWith('.zshrc');
58+
});
59+
60+
it('should return ~/.bashrc for bash', () => {
61+
const result = getRcFilePath('bash');
62+
expect(result).toEndWith('.bashrc');
63+
});
64+
65+
it('should return ~/.config/fish/config.fish for fish', () => {
66+
const result = getRcFilePath('fish');
67+
expect(result).toEndWith(join('.config', 'fish', 'config.fish'));
68+
});
69+
});
70+
71+
describe('getCompletionLine', () => {
72+
it('should return source line for zsh', () => {
73+
expect(getCompletionLine('zsh')).toBe('source <(mux completions zsh)');
74+
});
75+
76+
it('should return source line for bash', () => {
77+
expect(getCompletionLine('bash')).toBe('source <(mux completions bash)');
78+
});
79+
80+
it('should return source line for fish', () => {
81+
expect(getCompletionLine('fish')).toBe(
82+
'source (mux completions fish | psub)',
83+
);
84+
});
85+
});
86+
87+
describe('installCompletions', () => {
88+
let testDir: string;
89+
90+
beforeEach(async () => {
91+
testDir = await mkdtemp(join(tmpdir(), 'mux-cli-completions-test-'));
92+
});
93+
94+
afterEach(async () => {
95+
await rm(testDir, { recursive: true, force: true });
96+
});
97+
98+
it('should append completion line to an existing rc file', async () => {
99+
const rcPath = join(testDir, '.zshrc');
100+
await writeFile(rcPath, '# existing content\n');
101+
102+
const result = await installCompletions('zsh', rcPath);
103+
104+
expect(result.installed).toBe(true);
105+
expect(result.alreadyInstalled).toBe(false);
106+
107+
const content = await readFile(rcPath, 'utf-8');
108+
expect(content).toContain('source <(mux completions zsh)');
109+
expect(content).toStartWith('# existing content\n');
110+
});
111+
112+
it('should create the rc file if it does not exist', async () => {
113+
const rcPath = join(testDir, '.zshrc');
114+
115+
const result = await installCompletions('zsh', rcPath);
116+
117+
expect(result.installed).toBe(true);
118+
const content = await readFile(rcPath, 'utf-8');
119+
expect(content).toContain('source <(mux completions zsh)');
120+
});
121+
122+
it('should not duplicate if already installed', async () => {
123+
const rcPath = join(testDir, '.zshrc');
124+
await writeFile(rcPath, 'source <(mux completions zsh)\n');
125+
126+
const result = await installCompletions('zsh', rcPath);
127+
128+
expect(result.installed).toBe(false);
129+
expect(result.alreadyInstalled).toBe(true);
130+
131+
const content = await readFile(rcPath, 'utf-8');
132+
const matches = content.match(/source <\(mux completions zsh\)/g);
133+
expect(matches).toHaveLength(1);
134+
});
135+
136+
it('should detect existing install even with surrounding content', async () => {
137+
const rcPath = join(testDir, '.bashrc');
138+
await writeFile(
139+
rcPath,
140+
'# stuff\nsource <(mux completions bash)\n# more stuff\n',
141+
);
142+
143+
const result = await installCompletions('bash', rcPath);
144+
145+
expect(result.installed).toBe(false);
146+
expect(result.alreadyInstalled).toBe(true);
147+
});
148+
149+
it('should work for fish shell', async () => {
150+
const rcPath = join(testDir, 'config.fish');
151+
152+
const result = await installCompletions('fish', rcPath);
153+
154+
expect(result.installed).toBe(true);
155+
const content = await readFile(rcPath, 'utf-8');
156+
expect(content).toContain('source (mux completions fish | psub)');
157+
});
158+
159+
it('should add a trailing newline after the completion line', async () => {
160+
const rcPath = join(testDir, '.zshrc');
161+
await writeFile(rcPath, '# existing\n');
162+
163+
await installCompletions('zsh', rcPath);
164+
165+
const content = await readFile(rcPath, 'utf-8');
166+
expect(content).toEndWith('\n');
167+
});
168+
});
169+
});

src/lib/completions.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { existsSync } from 'node:fs';
2+
import { readFile, writeFile } from 'node:fs/promises';
3+
import { homedir } from 'node:os';
4+
import { basename, join } from 'node:path';
5+
6+
type Shell = 'zsh' | 'bash' | 'fish';
7+
8+
const SUPPORTED_SHELLS: Shell[] = ['zsh', 'bash', 'fish'];
9+
10+
/**
11+
* Detect the user's shell from the SHELL environment variable.
12+
*/
13+
export function detectShell(): Shell | null {
14+
const shell = process.env.SHELL;
15+
if (!shell) return null;
16+
17+
const name = basename(shell);
18+
if (SUPPORTED_SHELLS.includes(name as Shell)) {
19+
return name as Shell;
20+
}
21+
return null;
22+
}
23+
24+
/**
25+
* Get the path to the rc file for a given shell.
26+
*/
27+
export function getRcFilePath(shell: Shell): string {
28+
const home = homedir();
29+
switch (shell) {
30+
case 'zsh':
31+
return join(home, '.zshrc');
32+
case 'bash':
33+
return join(home, '.bashrc');
34+
case 'fish':
35+
return join(home, '.config', 'fish', 'config.fish');
36+
}
37+
}
38+
39+
/**
40+
* Get the completion source line for a given shell.
41+
*/
42+
export function getCompletionLine(shell: Shell): string {
43+
if (shell === 'fish') {
44+
return 'source (mux completions fish | psub)';
45+
}
46+
return `source <(mux completions ${shell})`;
47+
}
48+
49+
export interface InstallResult {
50+
installed: boolean;
51+
alreadyInstalled: boolean;
52+
}
53+
54+
/**
55+
* Install the completion source line into an rc file.
56+
*/
57+
export async function installCompletions(
58+
shell: Shell,
59+
rcPath?: string,
60+
): Promise<InstallResult> {
61+
const filePath = rcPath ?? getRcFilePath(shell);
62+
const line = getCompletionLine(shell);
63+
64+
let existing = '';
65+
if (existsSync(filePath)) {
66+
existing = await readFile(filePath, 'utf-8');
67+
if (existing.includes(line)) {
68+
return { installed: false, alreadyInstalled: true };
69+
}
70+
}
71+
72+
const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
73+
await writeFile(filePath, `${existing}${separator}${line}\n`);
74+
75+
return { installed: true, alreadyInstalled: false };
76+
}

0 commit comments

Comments
 (0)