Skip to content
Draft
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
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,74 @@ my-cli complete zsh > ~/.my-cli-completion.zsh
echo 'source ~/.my-cli-completion.zsh' >> ~/.zshrc
```

## Installing Completions for Users

Asking users to copy-paste `source <(my-cli complete zsh)` into their shellrc is a friction point. tab ships an installer that detects the user's shell + environment and writes the completion file (or appends to their PowerShell profile) in the right place — no shellrc edits required for most setups.

Use it from a dedicated subcommand, an `init` flow, or a `postinstall` hook:

```typescript
import { installShellCompletions } from '@bomb.sh/tab/install';

// in your `my-cli completions install` handler:
await installShellCompletions({
name: 'my-cli', // optional — auto-detected from argv/package.json
executable: 'my-cli', // optional — defaults to `name`
shell: 'auto', // 'zsh' | 'bash' | 'fish' | 'powershell' | 'auto'
});
```

The installer is **opt-in, idempotent, and never touches `.zshrc` / `.bashrc` on its own.** When it can't complete the install cleanly (e.g. the CLI isn't on PATH, the user's zsh has no `compinit`, macOS bash without `bash-completion`), it returns a structured `needs-user-action` or `blocked` result with concrete remediation steps.

```typescript
const result = await installShellCompletions({ name: 'my-cli', dryRun: true });

result.status; // 'installed' | 'already-installed' | 'updated'
// | 'needs-user-action' | 'blocked' | 'failed'
result.actions; // files we wrote / would write (with `performed` flag)
result.userInstructions;// numbered next-steps the user must take, if any
result.warnings; // e.g. detected a conflicting Homebrew completion
result.detected; // PATH reachability, install method, shell env probe
```

**Options**

| Option | Default | Description |
| --- | --- | --- |
| `name` | auto-detected | Command name (drives filenames: `_my-cli`, `my-cli.fish`, …) |
| `executable` | `name` | How to invoke the CLI from the generated completion script |
| `shell` | `'auto'` | Target shell, or `'auto'` to detect from the current process |
| `dryRun` | `false` | Compute the plan without writing anything |
| `force` | `false` | Overwrite an existing completion file we did not manage |
| `print` | `'on-error'` | Print a summary to stderr — `true`, `false`, or `'on-error'` |
| `verbose` | `false` | Log detection steps to stderr |

**What the installer covers per shell**

| Shell | Target | When the user has to do something |
| --- | --- | --- |
| fish | `~/.config/fish/completions/<name>.fish` | never |
| zsh | first writable `$fpath` dir, else Homebrew `site-functions`, else `~/.zsh/completions` | only if `compinit` is missing or the target dir isn't in `$fpath` (clear instructions are returned) |
| bash | `$XDG_DATA_HOME/bash-completion/completions/<name>` | only if `bash-completion` isn't installed (macOS default bash) — install hint is returned |
| powershell | sentinel-wrapped block in `$PROFILE.CurrentUserAllHosts` | only if execution policy is `Restricted` |

The installer always returns its result — you can render your own UI, print the structured plan, or chain it into a larger `init` flow.

### Uninstalling

A matching `uninstallShellCompletions` removes whatever the installer wrote. It only touches files that carry our `managed-by=tab` marker (or sentinel-wrapped blocks in PowerShell profiles), so it won't clobber a user's hand-written or Homebrew-installed completion.

```typescript
import { uninstallShellCompletions } from '@bomb.sh/tab/install';

await uninstallShellCompletions({
name: 'my-cli',
shell: 'auto',
});
```

For zsh, the uninstaller walks every dir we might have written to (current `$fpath`, Homebrew `site-functions`, `~/.zsh/completions`) so it cleans up even if the user's environment has changed since install. `dryRun`, `force`, `print`, and `verbose` work the same way as on the installer.

## Package Manager Completions

As mentioned earlier, tab provides completions for package managers as well:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"./cac": "./dist/cac.mjs",
"./citty": "./dist/citty.mjs",
"./commander": "./dist/commander.mjs",
"./install": "./dist/install.mjs",
"./package.json": "./package.json"
},
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
Expand Down
117 changes: 117 additions & 0 deletions src/install/bash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, platform } from 'node:os';
import { dirname, join } from 'node:path';
import * as bash from '../bash';
import type { ShellInstaller, ShellUninstaller } from './context';
import { detectBashCompletion } from './detect';
import { inspectFile, makeFileMarker } from './markers';

function bashTarget(name: string): string {
const xdg = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
return join(xdg, 'bash-completion', 'completions', name);
}

function bashCompletionInstallHint(): string {
if (platform() === 'darwin') {
return [
'Install bash-completion (macOS default bash has none):',
' brew install bash-completion@2',
'Then add to your ~/.bash_profile:',
' [[ -r "$(brew --prefix)/etc/profile.d/bash_completion.sh" ]] && . "$(brew --prefix)/etc/profile.d/bash_completion.sh"',
].join('\n ');
}
return [
'Install bash-completion via your package manager:',
' apt install bash-completion # Debian/Ubuntu',
' dnf install bash-completion # Fedora',
' pacman -S bash-completion # Arch',
'Then start a new shell.',
].join('\n ');
}

export const installBash: ShellInstaller = (ctx) => {
const result = ctx.startResult('bash');
const target = bashTarget(ctx.name);

const bc = detectBashCompletion();
ctx.log(`bash-completion present: ${bc.present}`);

result.detected.shellEnv = {
bashCompletionPresent: bc.present,
bashCompletionLoader: bc.loaderPath,
targetDir: dirname(target),
};

const existing = inspectFile(target);
if (existing.managedByTab && existing.version === ctx.version && bc.present) {
result.status = 'already-installed';
result.actions.push({ type: 'write-file', path: target, performed: false });
return result;
}
if (!existing.managedByTab && ctx.fileExists(target) && !ctx.force) {
result.status = 'blocked';
result.explanation = `An unmanaged completion file already exists at ${target}.`;
result.userInstructions.push(
`Remove ${target} and re-run, or pass { force: true } to overwrite.`
);
return result;
}

const marker = makeFileMarker(ctx.name, ctx.version, '#');
const script = `${marker}\n${bash.generate(ctx.name, ctx.executable)}`;

if (!ctx.dryRun) {
try {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, script);
} catch (err) {
result.status = 'blocked';
result.explanation = `Failed to write completion file: ${(err as Error).message}`;
return result;
}
}
result.actions.push({
type: 'write-file',
path: target,
performed: !ctx.dryRun,
});

if (!bc.present) {
result.status = 'needs-user-action';
result.userInstructions.push(bashCompletionInstallHint());
return result;
}

result.status = existing.managedByTab ? 'updated' : 'installed';
result.userInstructions.push(
'Restart your shell or run `exec bash` to load the new completions.'
);
return result;
};

export const uninstallBash: ShellUninstaller = (ctx) => {
const result = ctx.startResult('bash');
const target = bashTarget(ctx.name);

if (!ctx.fileExists(target)) return result;

const info = inspectFile(target);
if (!info.managedByTab && !ctx.force) {
result.status = 'blocked';
result.explanation = `${target} exists but was not written by tab; refusing to remove.`;
return result;
}

if (!ctx.dryRun) {
try {
rmSync(target);
} catch (err) {
result.status = 'failed';
result.explanation = (err as Error).message;
return result;
}
}
result.actions.push({ type: 'remove-file', path: target, performed: !ctx.dryRun });
result.status = 'uninstalled';
return result;
};
94 changes: 94 additions & 0 deletions src/install/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { existsSync } from 'node:fs';
import type {
InstallMethod,
InstallResult,
SupportedShell,
UninstallResult,
} from './types';

export type InstallContext = {

Check failure on line 9 in src/install/context.ts

View workflow job for this annotation

GitHub Actions / Lint and Type Check

Use an `interface` instead of a `type`
name: string;
executable: string;
version: string;
dryRun: boolean;
force: boolean;
verbose: boolean;
detected: {
pathReachable: boolean;
resolvedPath?: string;
installMethod: InstallMethod;
};
startResult: (shell: SupportedShell) => InstallResult;
fileExists: (path: string) => boolean;
log: (msg: string) => void;
};

export type ShellInstaller = (ctx: InstallContext) => InstallResult;

export type UninstallContext = {

Check failure on line 28 in src/install/context.ts

View workflow job for this annotation

GitHub Actions / Lint and Type Check

Use an `interface` instead of a `type`
name: string;
dryRun: boolean;
force: boolean;
verbose: boolean;
startResult: (shell: SupportedShell) => UninstallResult;
fileExists: (path: string) => boolean;
log: (msg: string) => void;
};

export type ShellUninstaller = (ctx: UninstallContext) => UninstallResult;

export function makeUninstallContext(input: {
name: string;
dryRun: boolean;
force: boolean;
verbose: boolean;
}): UninstallContext {
return {
...input,
startResult: (shell) => ({
shell,
status: 'not-installed',
actions: [],
warnings: [],
}),
fileExists: (p) => existsSync(p),
log: (msg) => {
if (input.verbose) {
console.error(`[tab/uninstall] ${msg}`);
}
},
};
}

export function makeContext(input: {
name: string;
executable: string;
version: string;
dryRun: boolean;
force: boolean;
verbose: boolean;
detected: InstallContext['detected'];
}): InstallContext {
return {
...input,
startResult: (shell) => ({
shell,
status: 'installed',
detected: {
pathReachable: input.detected.pathReachable,
resolvedPath: input.detected.resolvedPath,
installMethod: input.detected.installMethod,
shellEnv: {},
},
actions: [],
userInstructions: [],
warnings: [],
}),
fileExists: (p) => existsSync(p),
log: (msg) => {
if (input.verbose) {
console.error(`[tab/install] ${msg}`);
}
},
};
}
Loading
Loading