Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,17 @@ The binary is self-contained and has no dependencies.

## Shell Completions

Enable tab completion for commands, subcommands, and options by adding the appropriate line to your shell's config file:
Enable tab completion for commands, subcommands, and options in your shell:

```bash
mux completions install
```

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.

### Manual setup

If you prefer to configure completions yourself, add the appropriate line to your shell's config file:

**Bash:** Add the following line to `~/.bashrc`:
```bash
Expand Down
44 changes: 44 additions & 0 deletions src/commands/completions-install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Command } from '@cliffy/command';
import {
detectShell,
getCompletionLine,
getRcFilePath,
installCompletions,
} from '../lib/completions.ts';

export const completionsInstallCommand = new Command()
.description(
'Install shell completions by adding the source line to your shell config file',
)
.option(
'-s, --shell <shell:string>',
'Shell to install for (zsh, bash, fish)',
)
.action(async (options) => {
const shell = options.shell ?? detectShell();

if (!shell) {
throw new Error(
'Could not detect your shell. Please specify one with --shell (zsh, bash, fish).',
);
}

if (!['zsh', 'bash', 'fish'].includes(shell)) {
throw new Error(
`Unsupported shell: ${shell}. Supported shells: zsh, bash, fish.`,
);
}

const rcPath = getRcFilePath(shell as 'zsh' | 'bash' | 'fish');
const result = await installCompletions(shell as 'zsh' | 'bash' | 'fish');

if (result.alreadyInstalled) {
console.log(`Shell completions already configured in ${rcPath}`);
return;
}

console.log(
`✅ Added \`${getCompletionLine(shell as 'zsh' | 'bash' | 'fish')}\` to ${rcPath}`,
);
console.log(` Restart your shell or run: source ${rcPath}`);
});
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CompletionsCommand } from '@cliffy/command/completions';
import pkg from '../package.json';
import { annotationsCommand } from './commands/annotations/index.ts';
import { assetsCommand } from './commands/assets/index.ts';
import { completionsInstallCommand } from './commands/completions-install.ts';
import { deliveryUsageCommand } from './commands/delivery-usage/index.ts';
import { dimensionsCommand } from './commands/dimensions/index.ts';
import { drmConfigurationsCommand } from './commands/drm-configurations/index.ts';
Expand Down Expand Up @@ -86,7 +87,10 @@ const cli = new Command()
.command('exports', exportsCommand)
.command('webhooks', webhooksCommand)
.command('whoami', whoamiCommand)
.command('completions', new CompletionsCommand());
.command(
'completions',
new CompletionsCommand().command('install', completionsInstallCommand),
);

// Run the CLI
if (import.meta.main) {
Expand Down
169 changes: 169 additions & 0 deletions src/lib/completions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
detectShell,
getCompletionLine,
getRcFilePath,
installCompletions,
} from './completions.ts';

describe('completions', () => {
describe('detectShell', () => {
let originalShell: string | undefined;

beforeEach(() => {
originalShell = process.env.SHELL;
});

afterEach(() => {
if (originalShell === undefined) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
});

it('should detect zsh', () => {
process.env.SHELL = '/bin/zsh';
expect(detectShell()).toBe('zsh');
});

it('should detect bash', () => {
process.env.SHELL = '/bin/bash';
expect(detectShell()).toBe('bash');
});

it('should detect fish', () => {
process.env.SHELL = '/usr/local/bin/fish';
expect(detectShell()).toBe('fish');
});

it('should return null for unsupported shells', () => {
process.env.SHELL = '/bin/csh';
expect(detectShell()).toBeNull();
});

it('should return null when SHELL is not set', () => {
delete process.env.SHELL;
expect(detectShell()).toBeNull();
});
});

describe('getRcFilePath', () => {
it('should return ~/.zshrc for zsh', () => {
const result = getRcFilePath('zsh');
expect(result).toEndWith('.zshrc');
});

it('should return ~/.bashrc for bash', () => {
const result = getRcFilePath('bash');
expect(result).toEndWith('.bashrc');
});

it('should return ~/.config/fish/config.fish for fish', () => {
const result = getRcFilePath('fish');
expect(result).toEndWith(join('.config', 'fish', 'config.fish'));
});
});

describe('getCompletionLine', () => {
it('should return source line for zsh', () => {
expect(getCompletionLine('zsh')).toBe('source <(mux completions zsh)');
});

it('should return source line for bash', () => {
expect(getCompletionLine('bash')).toBe('source <(mux completions bash)');
});

it('should return source line for fish', () => {
expect(getCompletionLine('fish')).toBe(
'source (mux completions fish | psub)',
);
});
});

describe('installCompletions', () => {
let testDir: string;

beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), 'mux-cli-completions-test-'));
});

afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});

it('should append completion line to an existing rc file', async () => {
const rcPath = join(testDir, '.zshrc');
await writeFile(rcPath, '# existing content\n');

const result = await installCompletions('zsh', rcPath);

expect(result.installed).toBe(true);
expect(result.alreadyInstalled).toBe(false);

const content = await readFile(rcPath, 'utf-8');
expect(content).toContain('source <(mux completions zsh)');
expect(content).toStartWith('# existing content\n');
});

it('should create the rc file if it does not exist', async () => {
const rcPath = join(testDir, '.zshrc');

const result = await installCompletions('zsh', rcPath);

expect(result.installed).toBe(true);
const content = await readFile(rcPath, 'utf-8');
expect(content).toContain('source <(mux completions zsh)');
});

it('should not duplicate if already installed', async () => {
const rcPath = join(testDir, '.zshrc');
await writeFile(rcPath, 'source <(mux completions zsh)\n');

const result = await installCompletions('zsh', rcPath);

expect(result.installed).toBe(false);
expect(result.alreadyInstalled).toBe(true);

const content = await readFile(rcPath, 'utf-8');
const matches = content.match(/source <\(mux completions zsh\)/g);
expect(matches).toHaveLength(1);
});

it('should detect existing install even with surrounding content', async () => {
const rcPath = join(testDir, '.bashrc');
await writeFile(
rcPath,
'# stuff\nsource <(mux completions bash)\n# more stuff\n',
);

const result = await installCompletions('bash', rcPath);

expect(result.installed).toBe(false);
expect(result.alreadyInstalled).toBe(true);
});

it('should work for fish shell', async () => {
const rcPath = join(testDir, 'config.fish');

const result = await installCompletions('fish', rcPath);

expect(result.installed).toBe(true);
const content = await readFile(rcPath, 'utf-8');
expect(content).toContain('source (mux completions fish | psub)');
});

it('should add a trailing newline after the completion line', async () => {
const rcPath = join(testDir, '.zshrc');
await writeFile(rcPath, '# existing\n');

await installCompletions('zsh', rcPath);

const content = await readFile(rcPath, 'utf-8');
expect(content).toEndWith('\n');
});
});
});
76 changes: 76 additions & 0 deletions src/lib/completions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';

type Shell = 'zsh' | 'bash' | 'fish';

const SUPPORTED_SHELLS: Shell[] = ['zsh', 'bash', 'fish'];

/**
* Detect the user's shell from the SHELL environment variable.
*/
export function detectShell(): Shell | null {
const shell = process.env.SHELL;
if (!shell) return null;

const name = basename(shell);
if (SUPPORTED_SHELLS.includes(name as Shell)) {
return name as Shell;
}
return null;
}

/**
* Get the path to the rc file for a given shell.
*/
export function getRcFilePath(shell: Shell): string {
const home = homedir();
switch (shell) {
case 'zsh':
return join(home, '.zshrc');
case 'bash':
return join(home, '.bashrc');
case 'fish':
return join(home, '.config', 'fish', 'config.fish');
}
}

/**
* Get the completion source line for a given shell.
*/
export function getCompletionLine(shell: Shell): string {
if (shell === 'fish') {
return 'source (mux completions fish | psub)';
}
return `source <(mux completions ${shell})`;
}

export interface InstallResult {
installed: boolean;
alreadyInstalled: boolean;
}

/**
* Install the completion source line into an rc file.
*/
export async function installCompletions(
shell: Shell,
rcPath?: string,
): Promise<InstallResult> {
const filePath = rcPath ?? getRcFilePath(shell);
const line = getCompletionLine(shell);

let existing = '';
if (existsSync(filePath)) {
existing = await readFile(filePath, 'utf-8');
if (existing.includes(line)) {
return { installed: false, alreadyInstalled: true };
}
}

const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
await writeFile(filePath, `${existing}${separator}${line}\n`);

return { installed: true, alreadyInstalled: false };
}
Loading