Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6f75710
feat(completion): add auto-refresh for shell completion caches
toiroakr Apr 28, 2026
c6e5f14
fix(completion): propagate globalArgsSchema through install/refresh
toiroakr Apr 28, 2026
5e87a82
fix(completion): correct exit codes and symlink-aware refresh
dqn May 10, 2026
d545cef
fix(completion): gate refresh on existing cache and align bin lookup …
dqn May 10, 2026
fbf0349
fix(completion): probe GNU stat before BSD stat in shell loaders
dqn May 10, 2026
3e6fcd6
fix(completion): single-quote escape hardcoded cache paths in rc loader
dqn May 10, 2026
f7be6de
fix(completion): use path-only lookup in rc loaders
dqn May 10, 2026
283f878
fix: bypass user lifecycle for internal subcommands and keep stale co…
dqn May 10, 2026
05f48a8
fix(completion): regenerate via internal subcommand and reload fish i…
dqn May 10, 2026
0458c58
fix(completion): tighten internal subcommand detection and complete f…
dqn May 10, 2026
2360324
refactor(completion): consolidate shell detection and fix stale doc r…
dqn May 10, 2026
cf25c8a
refactor: deduplicate completion option-shape construction
dqn May 10, 2026
2a7e8a3
refactor(completion): drop dead empty-binPath guard in header builder
dqn May 10, 2026
4264da1
test(completion): cover refresh wiring, hook gates, and runMain hook …
dqn May 10, 2026
776e060
docs: cover completion auto-refresh in API reference and runMain JSDoc
dqn May 10, 2026
6f4bcbc
Merge pull request #390 from toiroakr/follow-up/pr-349-completion-aut…
toiroakr May 11, 2026
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
14 changes: 14 additions & 0 deletions .changeset/completion-auto-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"politty": patch
---

Add auto-refresh for shell completion caches.

Generated bash/zsh/fish scripts now embed a `# politty-bin-sig: <mtime>` header. The cache is regenerated automatically through two complementary paths:

- A small rc-loader snippet (printed by `<program> completion <shell> --loader`) that bash/zsh source on every shell startup. It compares the binary's mtime against the cache header and rewrites the cache when they differ before sourcing it.
- A detached `__refresh-completion` child that `runMain` spawns on every CLI invocation, keeping caches warm even when shells aren't restarted.

For fish, the autoload file written by `<program> completion fish --install` ends with a self-rewriting block that runs on TAB and replaces itself when stale.

New `--install` and `--loader` flags on the `completion` subcommand. New `WithCompletionOptions.cacheDir` and `WithCompletionOptions.programVersion`. Set `POLITTY_NO_COMPLETION_REFRESH=1` to disable the runMain background hook.
29 changes: 19 additions & 10 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ const extracted = extractFields(schema);

### `withCompletionCommand`

Wraps a command with shell completion support. Adds both a `completion` subcommand and a hidden `__complete` command for dynamic completion.
Wraps a command with shell completion support. Adds a `completion` subcommand, a hidden `__complete` command for dynamic completion, and a hidden `__refresh-completion` command used by the on-disk cache auto-refresh path. See [Shell Completion](./shell-completion.md#auto-refresh) for details on the refresh flow and the `POLITTY_NO_COMPLETION_REFRESH` opt-out.

```typescript
function withCompletionCommand<T extends AnyCommand>(
Expand All @@ -266,9 +266,12 @@ function withCompletionCommand<T extends AnyCommand>(

**WithCompletionOptions:**

| Property | Type | Description |
| ------------- | --------- | ------------------------------------------------ |
| `programName` | `string?` | Override program name (defaults to command.name) |
| Property | Type | Description |
| ------------------ | ------------- | --------------------------------------------------------------------------------------------------------------------- |
| `programName` | `string?` | Override program name (defaults to command.name) |
| `globalArgsSchema` | `ArgsSchema?` | Global args schema for deriving global options in completion |
| `cacheDir` | `string?` | Hardcode the on-disk cache directory used by the rc loader and the runMain background refresh (overrides XDG default) |
| `programVersion` | `string?` | Program version embedded in the script header |

#### Example

Expand All @@ -285,8 +288,9 @@ const mainCommand = withCompletionCommand(
);

// Now includes:
// - mycli completion bash|zsh|fish
// - mycli completion bash|zsh|fish [--install] [--loader] [--instructions]
// - mycli __complete -- <args>
// - mycli __refresh-completion <shell> (hidden; spawned by the rc loader / runMain hook)

runMain(mainCommand);
```
Expand All @@ -310,11 +314,16 @@ function generateCompletion(command: AnyCommand, options: CompletionOptions): Co

**CompletionOptions:**

| Property | Type | Description |
| --------------------- | ----------- | -------------------------------------- |
| `shell` | `ShellType` | Target shell: "bash", "zsh", or "fish" |
| `programName` | `string` | Program name as invoked |
| `includeDescriptions` | `boolean?` | Include descriptions (default: true) |
| Property | Type | Description |
| --------------------- | ------------- | ------------------------------------------------------------------------------------------------------ |
| `shell` | `ShellType` | Target shell: "bash", "zsh", or "fish" |
| `programName` | `string` | Program name as invoked |
| `includeSubcommands` | `boolean?` | Include subcommand completions (default: true) |
| `includeDescriptions` | `boolean?` | Include descriptions (default: true) |
| `globalArgsSchema` | `ArgsSchema?` | Global args schema for deriving global options in completion |
| `binPath` | `string?` | Path to the binary whose mtime is the freshness signature (defaults to `process.argv[1]`) |
| `programVersion` | `string?` | Program version embedded in the script header |
| `cacheDir` | `string?` | Cache directory hardcoded into the generated rc loader (defaults to XDG cache dir resolved at runtime) |

#### Return Value

Expand Down
58 changes: 56 additions & 2 deletions docs/shell-completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ For quick setup, see the [README](../README.md#shell-completion). For type signa

## How It Works

`withCompletionCommand` adds two subcommands to your CLI:
`withCompletionCommand` adds three subcommands to your CLI:

- **`completion <shell>`** — Generates a shell script that users source in their shell config
- **`completion <shell>`** — Generates a shell script that users source in their shell config. With `--install`, writes it to its on-disk cache (bash/zsh) or autoload location (fish). With `--loader`, prints the rc-loader snippet (bash/zsh only).
- **`__complete`** (hidden) — The dynamic completion engine, called on every TAB press
- **`__refresh-completion <shell>`** (hidden) — Re-installs the on-disk cache when the binary's mtime changes. Used by the rc loader and the runMain background hook.

The generated shell scripts are thin wrappers. When a user presses TAB, the shell calls:

Expand All @@ -21,6 +22,59 @@ All logic runs in JavaScript: parsing the command line context, resolving candid

Command aliases defined via `aliases` in `defineCommand()` are automatically included in both static completion scripts and dynamic completion candidates.

## Auto-refresh

When the CLI binary is upgraded, the cached completion script becomes stale — for example, a renamed subcommand will no longer auto-complete. politty refreshes the cache automatically through two complementary paths:

1. **rc loader** (bash/zsh) — A small snippet in `~/.bashrc` / `~/.zshrc` checks the binary's mtime against the cache header on every shell startup; if they don't match, the cache is regenerated before being sourced. This guarantees the very next shell sees a correct cache.
2. **runMain background hook** — Every time the CLI runs (except when handling `__complete` / `__refresh-completion` / `completion` itself), `runMain` spawns a detached `__refresh-completion <shell>` child. The child does the same mtime-vs-header comparison and rewrites the cache only when stale. This keeps the cache warm even for users who never restart their shell.

For fish, there's no rc loader. Instead, the autoload file written by `<program> completion fish --install` ends with a self-rewriting block that runs on every TAB press and replaces itself in place when the binary's mtime changes.

All paths are best-effort: any I/O failure is silently swallowed because a stale or missing completion is preferable to a broken shell.

### Setup

```bash
# Bash / zsh: install the cache once, then add the loader to your rc file.
mycli completion bash --install
mycli completion bash --loader >> ~/.bashrc # or ~/.zshrc with `zsh`

# Fish: just install the autoload file. Fish picks it up automatically.
mycli completion fish --install
```

### Cache location

By default the cache lives at `${XDG_CACHE_HOME:-$HOME/.cache}/<program>/completion.<shell>`. You can hardcode an alternate location at wrap-time:

```typescript
const main = withCompletionCommand(rootCommand, {
programName: "mycli",
cacheDir: "/opt/mycli/cache", // overrides the XDG default in both the loader and refresh paths
});
```

For fish, the autoload file always lives at `${XDG_CONFIG_HOME:-$HOME/.config}/fish/completions/<program>.fish` since fish dictates that path.

### Header format

Every generated script starts with a small machine-readable header:

```
# politty-completion-version: 1
# politty-bin-sig: 1730000000
# program: mycli
# program-version: 1.2.3
# shell: bash
```

`politty-bin-sig` is the binary's mtime in seconds. The rc loader and `__refresh-completion` compare this against the live binary to decide whether to rewrite the cache. `program-version` is included only when you pass `programVersion` to `withCompletionCommand`.

### Disabling auto-refresh

Set `POLITTY_NO_COMPLETION_REFRESH=1` in your environment to disable the runMain background hook. The rc loader (bash/zsh) is unaffected by this variable; remove it from your rc file if you want to disable the loader path too.

## Completion Types

### Enum (Auto-detected)
Expand Down
10 changes: 9 additions & 1 deletion src/completion/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
optTakesValueEntries,
sanitize,
} from "./extractor.js";
import { buildHeaderLines } from "./header.js";
import type {
CompletableOption,
CompletablePositional,
Expand Down Expand Up @@ -280,7 +281,14 @@ export function generateBashCompletion(
const visibleSubs = getVisibleSubs(root.subcommands);

const lines: string[] = [];
lines.push(`# Bash completion for ${programName}`);
lines.push(
...buildHeaderLines({
programName,
shell: "bash",
binPath: options.binPath,
programVersion: options.programVersion,
}),
);
lines.push(`# Generated by politty`);
lines.push(``);

Expand Down
54 changes: 53 additions & 1 deletion src/completion/fish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getVisibleSubs,
sanitize,
} from "./extractor.js";
import { buildHeaderLines, computeBinSig, resolveBinPath } from "./header.js";
import type {
CompletableOption,
CompletablePositional,
Expand Down Expand Up @@ -255,10 +256,61 @@ export function generateFishCompletion(
const visibleSubs = getVisibleSubs(root.subcommands);

const lines: string[] = [];
lines.push(`# Fish completion for ${programName}`);
lines.push(
...buildHeaderLines({
programName,
shell: "fish",
binPath: options.binPath,
programVersion: options.programVersion,
}),
);
lines.push(`# Generated by politty`);
lines.push(``);

// Self-rewriting autoload header. Fish autoloads completion files
// from `$__fish_config_dir/completions/<prog>.fish` lazily, so the
// refresh check has to live in the file itself. When the binary's
// mtime no longer matches the embedded sig, we regenerate the file
// in place via the hidden __refresh-completion subcommand, then
// `source` the rewritten file so the *current* session picks up the
// new definitions, and `return` from this script so the rest of the
// *old* file (stale helper functions and `complete` registrations)
// doesn't run on top of the freshly sourced new definitions.
// Failures are silent — a stale completion is preferable to a
// shell-startup error.
//
// We invoke __refresh-completion (internal) instead of
// `<bin> completion fish`: the foreground completion command runs
// user setup/cleanup/prompt and validates required globalArgs, which
// can fail or block when triggered from autoload.
const sig = computeBinSig(resolveBinPath(programName, options.binPath));
const refreshFn = `__${fn}_refresh_completion`;
lines.push(`function ${refreshFn} --no-scope-shadowing`);
lines.push(` set -l _bin (command -v ${programName})`);
lines.push(` test -z "$_bin"; and return 1`);
// `-L` follows symlinks so the shell-side mtime matches Node's
// `fs.statSync`, mirroring the bash/zsh loader. Probe order matches
// the bash/zsh loader: GNU (`-c`) first because `-f` is filesystem
// mode there and would otherwise dump filesystem info into `_sig`.
lines.push(
` set -l _sig (stat -L -c '%Y' "$_bin" 2>/dev/null; or stat -L -f '%m' "$_bin" 2>/dev/null)`,
);
lines.push(` test "$_sig" = "${sig}"; and return 1`);
lines.push(` set -l _target "$__fish_config_dir/completions/${programName}.fish"`);
lines.push(` "$_bin" __refresh-completion fish 2>/dev/null`);
lines.push(` and source "$_target" 2>/dev/null`);
lines.push(` and return 0`);
lines.push(` return 1`);
lines.push(`end`);
lines.push(`${refreshFn}`);
lines.push(`set -l _politty_refreshed $status`);
lines.push(`functions -e ${refreshFn}`);
// `return` from a sourced fish script aborts the rest of the source
// call, so the stale `complete -c` lines below do not execute when
// the fresh script has already been sourced.
lines.push(`test $_politty_refreshed -eq 0; and return`);
lines.push(``);

// Helper: check if option is already used
lines.push(`function __${fn}_not_used --no-scope-shadowing`);
lines.push(` for _chk in $argv`);
Expand Down
91 changes: 91 additions & 0 deletions src/completion/header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Static-script header utilities.
*
* Every completion script generated by politty starts with a small
* machine-readable header. The rc loader and the runMain background
* refresh path use the `# politty-bin-sig:` line to detect when the
* cached script is stale relative to the binary on disk.
*/

import { statSync } from "node:fs";
import { join } from "node:path";
import type { ShellType } from "./types.js";

/** Schema version of the header itself. Bump when the header layout changes. */
export const COMPLETION_VERSION = 1;

/**
* Read the binary's mtime in whole seconds (matches POSIX `stat -c %Y` /
* BSD `stat -f %m`). Returns `"0"` on failure so the header is always
* well-formed.
*/
export function computeBinSig(binPath: string): string {
try {
return Math.floor(statSync(binPath).mtimeMs / 1000).toString();
} catch {
return "0";
}
}

/**
* Walk `$PATH` looking for an executable named `programName`. Returns
* the first match's full path, or `null` when not found. We mirror the
* shell's `command -v <prog>` here so the sig embedded in the header
* (computed by Node) lines up with what the rc loader stat-checks at
* runtime — including pnpm/npm bin shims that wrap the real entrypoint.
* Without this alignment, shimmed installs would never match the
* embedded sig and the cache would regenerate on every shell startup.
*/
function findOnPath(programName: string): string | null {
// eslint-disable-next-line no-control-regex -- intentional null-byte rejection to block path injection in `programName`
if (!programName || /[/\\\0]/.test(programName)) return null;
const path = process.env.PATH ?? "";
for (const dir of path.split(":")) {
if (!dir) continue;
const candidate = join(dir, programName);
try {
if (statSync(candidate).isFile()) return candidate;
} catch {
// skip
}
}
return null;
}

/**
* Resolve the binary path used for sig computation and stat checks.
*
* Order: explicit override → `$PATH` lookup of `programName` → `process.argv[1]`.
* The `$PATH` lookup keeps Node-side and shell-side stats pointed at the
* same shim file when the CLI is invoked through a package-manager bin shim.
*/
export function resolveBinPath(programName: string, override?: string | undefined): string {
if (override) return override;
return findOnPath(programName) ?? process.argv[1] ?? "";
}

export interface HeaderOptions {
programName: string;
shell: ShellType;
binPath?: string | undefined;
programVersion?: string | undefined;
}

/**
* Build the header lines (no trailing blank line). Returned without a
* leading `#!` so each generator can prepend its own shebang/compdef
* marker.
*/
export function buildHeaderLines(opts: HeaderOptions): string[] {
const sig = computeBinSig(resolveBinPath(opts.programName, opts.binPath));
const lines = [
`# politty-completion-version: ${COMPLETION_VERSION}`,
`# politty-bin-sig: ${sig}`,
`# program: ${opts.programName}`,
];
if (opts.programVersion) {
lines.push(`# program-version: ${opts.programVersion}`);
}
lines.push(`# shell: ${opts.shell}`);
return lines;
}
Loading
Loading