diff --git a/README.md b/README.md index 7a1e7888..0b77ff65 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ Install with npm: npm install -g @loongphy/codex-auth ``` +Installed command: + +- `codex-auth` + You can also run it without a global install: ```shell @@ -83,6 +87,39 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | Command | Description | |---------|-------------| | [`codex-auth config live --interval `](./docs/commands/config.md) | Configure live TUI refresh interval | +| `codex-auth completion ` | Print a shell completion script | + +## Shell Completion + +Generate completions with: + +```shell +codex-auth completion bash +codex-auth completion zsh +codex-auth completion fish +``` + +Install Fish completions with: + +```shell +mkdir -p ~/.config/fish/completions +codex-auth completion fish > ~/.config/fish/completions/codex-auth.fish +source ~/.config/fish/completions/codex-auth.fish +``` + +Install Bash completions with: + +```shell +mkdir -p ~/.local/share/bash-completion/completions +codex-auth completion bash > ~/.local/share/bash-completion/completions/codex-auth +``` + +Install Zsh completions with: + +```shell +mkdir -p ~/.zsh/completions +codex-auth completion zsh > ~/.zsh/completions/_codex-auth +``` ## Quick Examples diff --git a/bin/codex-auth.js b/bin/codex-auth.js index 9c38cd04..da070517 100755 --- a/bin/codex-auth.js +++ b/bin/codex-auth.js @@ -80,6 +80,10 @@ function resolveBinary() { } return binaryPath; } catch (error) { + const localBinaryPath = resolveLocalBinary(); + if (localBinaryPath) { + return localBinaryPath; + } console.error( `Missing platform package ${packageName}. Reinstall @loongphy/codex-auth on ${process.platform}/${process.arch}.` ); @@ -90,6 +94,15 @@ function resolveBinary() { } } +function resolveLocalBinary() { + const binaryName = process.platform === "win32" ? "codex-auth.exe" : "codex-auth"; + const localBinaryPath = path.join(__dirname, "..", "zig-out", "bin", binaryName); + if (fs.existsSync(localBinaryPath)) { + return localBinaryPath; + } + return null; +} + const binaryPath = resolveBinary(); const child = spawnSync(binaryPath, process.argv.slice(2), { stdio: "inherit", diff --git a/docs/commands/README.md b/docs/commands/README.md index 8d3cae45..83e2a47b 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -14,6 +14,7 @@ This directory documents command behavior by command. Use `codex-auth | `remove` | [docs/commands/remove.md](./remove.md) | | `alias` | [docs/commands/alias.md](./alias.md) | | `clean` | [docs/commands/clean.md](./clean.md) | +| `completion` | [docs/commands/completion.md](./completion.md) | | `config` | [docs/commands/config.md](./config.md) | ## Shared Behavior diff --git a/docs/commands/completion.md b/docs/commands/completion.md new file mode 100644 index 00000000..7bf5a19b --- /dev/null +++ b/docs/commands/completion.md @@ -0,0 +1,43 @@ +# `codex-auth completion` + +## Usage + +```shell +codex-auth completion bash +codex-auth completion zsh +codex-auth completion fish +``` + +## Bash + +`completion bash` prints a Bash completion script to stdout. + +Install it with: + +```shell +mkdir -p ~/.local/share/bash-completion/completions +codex-auth completion bash > ~/.local/share/bash-completion/completions/codex-auth +``` + +## Zsh + +`completion zsh` prints a Zsh completion script to stdout. + +Install it with: + +```shell +mkdir -p ~/.zsh/completions +codex-auth completion zsh > ~/.zsh/completions/_codex-auth +``` + +## Fish + +`completion fish` prints a Fish completion script to stdout. + +Install it with: + +```shell +mkdir -p ~/.config/fish/completions +codex-auth completion fish > ~/.config/fish/completions/codex-auth.fish +source ~/.config/fish/completions/codex-auth.fish +``` diff --git a/docs/pr-screenshots/bash.png b/docs/pr-screenshots/bash.png new file mode 100644 index 00000000..3fb58ce2 Binary files /dev/null and b/docs/pr-screenshots/bash.png differ diff --git a/docs/pr-screenshots/fish-main.png b/docs/pr-screenshots/fish-main.png new file mode 100644 index 00000000..a5310e82 Binary files /dev/null and b/docs/pr-screenshots/fish-main.png differ diff --git a/docs/pr-screenshots/fish-switch.png b/docs/pr-screenshots/fish-switch.png new file mode 100644 index 00000000..ea264b87 Binary files /dev/null and b/docs/pr-screenshots/fish-switch.png differ diff --git a/docs/pr-screenshots/zsh-main.png b/docs/pr-screenshots/zsh-main.png new file mode 100644 index 00000000..6d262d01 Binary files /dev/null and b/docs/pr-screenshots/zsh-main.png differ diff --git a/docs/pr-screenshots/zsh-switch.png b/docs/pr-screenshots/zsh-switch.png new file mode 100644 index 00000000..4a14b689 Binary files /dev/null and b/docs/pr-screenshots/zsh-switch.png differ diff --git a/src/cli/commands/completion.zig b/src/cli/commands/completion.zig new file mode 100644 index 00000000..885ef68e --- /dev/null +++ b/src/cli/commands/completion.zig @@ -0,0 +1,46 @@ +const std = @import("std"); +const types = @import("../types.zig"); +const common = @import("common.zig"); + +pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { + if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) { + return .{ .command = .{ .help = .completion } }; + } + if (args.len == 0) { + return common.usageErrorResult(allocator, .completion, "`completion` requires a shell name.", .{}); + } + + const shell_name = std.mem.sliceTo(args[0], 0); + if (args.len == 1) { + if (std.mem.eql(u8, shell_name, "bash")) { + return .{ .command = .{ .completion = .{ .shell = .bash } } }; + } + if (std.mem.eql(u8, shell_name, "zsh")) { + return .{ .command = .{ .completion = .{ .shell = .zsh } } }; + } + if (std.mem.eql(u8, shell_name, "fish")) { + return .{ .command = .{ .completion = .{ .shell = .fish } } }; + } + } + if (std.mem.eql(u8, shell_name, "query")) { + return parseQuery(allocator, args[1..]); + } + if (args.len > 1) { + return common.usageErrorResult(allocator, .completion, "unexpected argument after `completion`: `{s}`.", .{ + std.mem.sliceTo(args[1], 0), + }); + } + return common.usageErrorResult(allocator, .completion, "unknown completion shell `{s}`.", .{shell_name}); +} + +fn parseQuery(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { + if (args.len != 1) { + return common.usageErrorResult(allocator, .completion, "`completion query` requires a target.", .{}); + } + + const target_name = std.mem.sliceTo(args[0], 0); + if (std.mem.eql(u8, target_name, "switch")) { + return .{ .command = .{ .completion = .{ .query = .switch_account } } }; + } + return common.usageErrorResult(allocator, .completion, "unknown completion query target `{s}`.", .{target_name}); +} diff --git a/src/cli/commands/root.zig b/src/cli/commands/root.zig index 70e2b1ca..61734ed7 100644 --- a/src/cli/commands/root.zig +++ b/src/cli/commands/root.zig @@ -4,6 +4,7 @@ const common = @import("common.zig"); const alias = @import("alias.zig"); const clean = @import("clean.zig"); +const completion = @import("completion.zig"); const config = @import("config.zig"); const export_auth = @import("export.zig"); const import_auth = @import("import.zig"); @@ -46,6 +47,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type if (std.mem.eql(u8, cmd, "remove")) return remove.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "alias")) return alias.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "clean")) return clean.parse(allocator, args[2..]); + if (std.mem.eql(u8, cmd, "completion")) return completion.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "config")) return config.parse(allocator, args[2..]); return common.usageErrorResult(allocator, .top_level, "unknown command `{s}`.", .{cmd}); @@ -106,6 +108,7 @@ fn helpTopicForName(name: []const u8) ?types.HelpTopic { if (std.mem.eql(u8, name, "remove")) return .remove_account; if (std.mem.eql(u8, name, "alias")) return .alias; if (std.mem.eql(u8, name, "clean")) return .clean; + if (std.mem.eql(u8, name, "completion")) return .completion; if (std.mem.eql(u8, name, "config")) return .config; return null; } diff --git a/src/cli/completion.zig b/src/cli/completion.zig new file mode 100644 index 00000000..de1bf306 --- /dev/null +++ b/src/cli/completion.zig @@ -0,0 +1,303 @@ +const std = @import("std"); +const io_util = @import("../core/io_util.zig"); +const types = @import("types.zig"); +const registry = @import("../registry/root.zig"); +const display_rows = @import("../tui/display.zig"); + +pub fn printCompletion(shell: types.CompletionShell) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try writeCompletion(out, shell); + try out.flush(); +} + +pub fn printQueryCompletion( + allocator: std.mem.Allocator, + codex_home: []const u8, + target: types.CompletionQueryTarget, +) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + switch (target) { + .switch_account => try writeSwitchQueryCompletion(out, allocator, codex_home), + } + try out.flush(); +} + +pub fn writeCompletion(out: *std.Io.Writer, shell: types.CompletionShell) !void { + switch (shell) { + .bash => try writeBashCompletion(out), + .zsh => try writeZshCompletion(out), + .fish => try writeFishCompletion(out), + } +} + +fn writeBashCompletion(out: *std.Io.Writer) !void { + try out.writeAll( + \\_codex_auth_switch_queries() { + \\ codex-auth completion query switch 2>/dev/null + \\} + \\ + \\_codex_auth_complete() { + \\ local cur prev cword + \\ COMPREPLY=() + \\ cur="${COMP_WORDS[COMP_CWORD]}" + \\ prev="" + \\ if (( COMP_CWORD > 0 )); then + \\ prev="${COMP_WORDS[COMP_CWORD-1]}" + \\ fi + \\ cword=$COMP_CWORD + \\ + \\ local commands="help list login import export switch remove alias clean completion config" + \\ local global_flags="--help -h --version -V" + \\ + \\ if (( cword == 1 )); then + \\ COMPREPLY=( $(compgen -W "$commands $global_flags" -- "$cur") ) + \\ return + \\ fi + \\ + \\ case "${COMP_WORDS[1]}" in + \\ help) + \\ COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) + \\ ;; + \\ list) + \\ COMPREPLY=( $(compgen -W "--live --active --api --skip-api" -- "$cur") ) + \\ ;; + \\ login) + \\ COMPREPLY=( $(compgen -W "--device-auth" -- "$cur") ) + \\ ;; + \\ import) + \\ if [[ "$prev" == "--alias" ]]; then + \\ return + \\ fi + \\ COMPREPLY=( $(compgen -W "--alias --cpa --purge" -- "$cur") ) + \\ ;; + \\ export) + \\ COMPREPLY=( $(compgen -W "--cpa" -- "$cur") ) + \\ ;; + \\ switch) + \\ COMPREPLY=( $(compgen -W "--live --api --skip-api $( _codex_auth_switch_queries | cut -f1 ) $( _codex_auth_switch_queries | cut -f2 )" -- "$cur") ) + \\ ;; + \\ remove) + \\ COMPREPLY=( $(compgen -W "--live --api --skip-api --all" -- "$cur") ) + \\ ;; + \\ alias) + \\ if (( cword == 2 )); then + \\ COMPREPLY=( $(compgen -W "set clear" -- "$cur") ) + \\ fi + \\ ;; + \\ clean) + \\ COMPREPLY=( $(compgen -W "background" -- "$cur") ) + \\ ;; + \\ completion) + \\ COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) + \\ ;; + \\ config) + \\ if (( cword == 2 )); then + \\ COMPREPLY=( $(compgen -W "live" -- "$cur") ) + \\ else + \\ COMPREPLY=( $(compgen -W "--interval" -- "$cur") ) + \\ fi + \\ ;; + \\ esac + \\} + \\ + \\complete -F _codex_auth_complete codex-auth + ); +} + +fn writeZshCompletion(out: *std.Io.Writer) !void { + try out.writeAll( + \\#compdef codex-auth + \\ + \\_codex_auth_switch_queries() { + \\ local value description + \\ local -a emails + \\ while IFS=$'\t' read -r value description; do + \\ [[ -z "$description" ]] && continue + \\ emails+=("$description") + \\ done <<< "$(codex-auth completion query switch 2>/dev/null)" + \\ (( ${#emails[@]} == 0 )) && return 1 + \\ compadd -Q -l -- "${emails[@]}" + \\} + \\ + \\_codex-auth() { + \\ local context state line + \\ if (( CURRENT >= 3 )) && [[ "$words[2]" == "switch" ]]; then + \\ if [[ "$PREFIX" == -* ]]; then + \\ _values 'flag' '--live[Open the live switch UI]' '--api[Load usage and account data from APIs]' '--skip-api[Load usage and account data from local data only]' + \\ else + \\ _codex_auth_switch_queries || _message 'no switch targets' + \\ fi + \\ return + \\ fi + \\ + \\ _arguments -C \ + \\ '(-h --help)'{-h,--help}'[Show help]' \ + \\ '(-V --version)'{-V,--version}'[Show version]' \ + \\ '1:command:->command' \ + \\ '*::arg:->args' + \\ + \\ case $state in + \\ command) + \\ _values 'command' \ + \\ 'help[Show command-specific help]' \ + \\ 'list[List available accounts]' \ + \\ 'login[Login and add the current account]' \ + \\ 'import[Import auth files or rebuild the registry]' \ + \\ 'export[Export stored account auth files]' \ + \\ 'switch[Switch the active account]' \ + \\ 'remove[Remove one or more accounts]' \ + \\ 'alias[Set or clear account aliases]' \ + \\ 'clean[Delete backup and stale files under accounts/]' \ + \\ 'completion[Generate shell completion scripts]' \ + \\ 'config[Manage configuration]' + \\ ;; + \\ args) + \\ case $words[2] in + \\ help) + \\ _values 'command' help list login import export switch remove alias clean completion config + \\ ;; + \\ list) + \\ _values 'flag' '--live[Open a live-updating table]' '--active[Refresh only the active account before rendering]' '--api[Load usage and account data from APIs]' '--skip-api[Load usage and account data from local data only]' + \\ ;; + \\ login) + \\ _values 'flag' '--device-auth[Run codex login with device auth]' + \\ ;; + \\ import) + \\ _values 'flag' '--alias[Set an alias for a single imported account]' '--cpa[Import CPA flat token JSON]' '--purge[Rebuild registry.json from auth files]' + \\ ;; + \\ export) + \\ _values 'flag' '--cpa[Export CPA flat token JSON]' + \\ ;; + \\ switch) + \\ _values 'flag' '--live[Open the live switch UI]' '--api[Load usage and account data from APIs]' '--skip-api[Load usage and account data from local data only]' + \\ ;; + \\ remove) + \\ _values 'flag' '--live[Open the live remove UI]' '--api[Load usage and account data from APIs]' '--skip-api[Load usage and account data from local data only]' '--all[Remove every stored account]' + \\ ;; + \\ alias) + \\ if (( CURRENT == 3 )); then + \\ _values 'action' set clear + \\ fi + \\ ;; + \\ clean) + \\ _values 'target' background + \\ ;; + \\ completion) + \\ _values 'shell' bash zsh fish + \\ ;; + \\ config) + \\ if (( CURRENT == 3 )); then + \\ _values 'section' live + \\ else + \\ _values 'flag' '--interval[Set the live refresh interval in seconds]' + \\ fi + \\ ;; + \\ esac + \\ ;; + \\ esac + \\} + \\ + \\compdef _codex-auth codex-auth + ); +} + +fn writeFishCompletion(out: *std.Io.Writer) !void { + try out.writeAll( + \\function __fish_codex_auth_switch_queries + \\ codex-auth completion query switch 2>/dev/null + \\end + \\ + \\function __fish_codex_auth_needs_command + \\ not __fish_seen_subcommand_from help list login import export switch remove alias clean completion config + \\end + \\ + \\function __fish_codex_auth_using_command + \\ __fish_seen_subcommand_from $argv + \\end + \\ + ); + + try out.writeAll("complete -c codex-auth -e\n"); + try out.writeAll("complete -c codex-auth -f\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -l help -s h -d 'Show help'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -l version -s V -d 'Show version'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a help -d 'Show command-specific help'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a list -d 'List available accounts'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a login -d 'Login and add the current account'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a import -d 'Import auth files or rebuild the registry'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a export -d 'Export stored account auth files'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a switch -d 'Switch the active account'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a remove -d 'Remove one or more accounts'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a alias -d 'Set or clear account aliases'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a clean -d 'Delete backup and stale files under accounts/'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a completion -d 'Generate shell completion scripts'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_needs_command' -a config -d 'Manage configuration'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command help' -a 'list login import export switch remove alias clean completion config'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command list' -l live -d 'Open a live-updating table'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command list' -l active -d 'Refresh only the active account before rendering'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command list' -l api -d 'Load usage and account data from APIs'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command list' -l skip-api -d 'Load usage and account data from local data only'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command login' -l device-auth -d 'Run codex login with device auth'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command import' -l alias -r -d 'Set an alias for a single imported account'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command import' -l cpa -d 'Import CPA flat token JSON'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command import' -l purge -d 'Rebuild registry.json from auth files'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command import' -f -a '(__fish_complete_path)' -d 'Auth file or directory'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command export' -l cpa -d 'Export CPA flat token JSON'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command export' -f -a '(__fish_complete_path)' -d 'Export directory'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command switch' -l live -d 'Open the live switch UI'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command switch' -l api -d 'Load usage and account data from APIs'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command switch' -l skip-api -d 'Load usage and account data from local data only'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command switch' -a '(__fish_codex_auth_switch_queries)' -d 'Switch target'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command remove' -l live -d 'Open the live remove UI'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command remove' -l api -d 'Load usage and account data from APIs'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command remove' -l skip-api -d 'Load usage and account data from local data only'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command remove' -l all -d 'Remove every stored account'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command alias' -a set -d 'Set one stored account alias'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command alias' -a clear -d 'Clear one stored account alias'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command clean' -a background -d 'Delete stale background files'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command completion' -a 'bash zsh fish' -d 'Generate shell completions'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_codex_auth_using_command config' -a live -d 'Manage live refresh settings'\n"); + try out.writeAll("complete -c codex-auth -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from live' -l interval -r -d 'Set the live refresh interval in seconds'\n"); +} + +fn writeSwitchQueryCompletion(out: *std.Io.Writer, allocator: std.mem.Allocator, codex_home: []const u8) !void { + var reg = registry.loadRegistry(allocator, codex_home) catch return; + defer reg.deinit(allocator); + + var display = display_rows.buildDisplayRows(allocator, ®, null) catch return; + defer display.deinit(allocator); + + var seen = std.StringHashMap(void).init(allocator); + defer { + var iter = seen.keyIterator(); + while (iter.next()) |key| allocator.free(key.*); + seen.deinit(); + } + + for (display.selectable_row_indices, 0..) |row_idx, displayed_idx| { + const account_idx = display.rows[row_idx].account_index orelse continue; + const rec = ®.accounts.items[account_idx]; + + var number_buf: [16]u8 = undefined; + const display_number = std.fmt.bufPrint(&number_buf, "{d:0>2}", .{displayed_idx + 1}) catch unreachable; + try writeUniqueCandidate(out, &seen, display_number, rec.email, allocator); + } +} + +fn writeUniqueCandidate( + out: *std.Io.Writer, + seen: *std.StringHashMap(void), + value: []const u8, + description: []const u8, + allocator: std.mem.Allocator, +) !void { + if (value.len == 0) return; + const entry = try seen.getOrPut(value); + if (entry.found_existing) return; + entry.key_ptr.* = try allocator.dupe(u8, value); + try out.print("{s}\t{s}\n", .{ value, description }); +} diff --git a/src/cli/help.zig b/src/cli/help.zig index c9164ced..f4630b1a 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -55,6 +55,7 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "alias clear "); try writeCommandSummary(out, use_color, "clean", "Delete backup and stale files under accounts/"); try writeCommandDetail(out, use_color, "clean background"); + try writeCommandSummary(out, use_color, "completion ", "Print a shell completion script"); try writeCommandSummary(out, use_color, "config", "Manage configuration"); try writeCommandDetail(out, use_color, "config live --interval "); @@ -130,6 +131,7 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { .remove_account => "remove", .alias => "alias", .clean => "clean", + .completion => "completion", .config => "config", }; } @@ -145,20 +147,21 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .remove_account => "Remove one or more accounts by alias, email, display number, or partial query.", .alias => "Set or clear an account alias by alias, email, display number, or partial query.", .clean => "Delete backup and stale files under accounts/.", + .completion => "Generate shell completion scripts.", .config => "Manage live refresh configuration.", }; } fn commandHelpHasExamples(topic: HelpTopic) bool { return switch (topic) { - .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config => true, + .import_auth, .export_auth, .switch_account, .remove_account, .alias, .completion, .config => true, else => false, }; } fn commandHelpHasOptions(topic: HelpTopic) bool { return switch (topic) { - .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config => true, + .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .alias, .completion, .config => true, else => false, }; } @@ -218,6 +221,11 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth clean\n"); try out.writeAll(" codex-auth clean background\n"); }, + .completion => { + try out.writeAll(" codex-auth completion bash\n"); + try out.writeAll(" codex-auth completion zsh\n"); + try out.writeAll(" codex-auth completion fish\n"); + }, .config => { try out.writeAll(" codex-auth config live --interval \n"); }, @@ -235,6 +243,7 @@ pub fn helpCommandForTopic(topic: HelpTopic) []const u8 { .remove_account => "codex-auth remove --help", .alias => "codex-auth alias --help", .clean => "codex-auth clean --help", + .completion => "codex-auth completion --help", .config => "codex-auth config --help", }; } @@ -287,6 +296,11 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" clear \n"); try out.writeAll(" Remove one stored account alias without remote refresh.\n"); }, + .completion => { + try out.writeAll(" bash Print Bash completion commands to stdout.\n"); + try out.writeAll(" zsh Print Zsh completion commands to stdout.\n"); + try out.writeAll(" fish Print Fish completion commands to stdout.\n"); + }, .config => { try out.writeAll(" live --interval \n"); try out.writeAll(" Set the live TUI refresh interval from 5 to 3600 seconds.\n"); @@ -359,6 +373,12 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth clean\n"); try out.writeAll(" codex-auth clean background\n"); }, + .completion => { + try out.writeAll(" codex-auth completion bash > ~/.local/share/bash-completion/completions/codex-auth\n"); + try out.writeAll(" codex-auth completion zsh > ~/.zsh/completions/_codex-auth\n"); + try out.writeAll(" codex-auth completion fish > ~/.config/fish/completions/codex-auth.fish\n"); + try out.writeAll(" source ~/.config/fish/completions/codex-auth.fish\n"); + }, .config => { try out.writeAll(" codex-auth config live --interval 60\n"); }, diff --git a/src/cli/live_switch.zig b/src/cli/live_switch.zig index 4b8d1cf5..cc7c2226 100644 --- a/src/cli/live_switch.zig +++ b/src/cli/live_switch.zig @@ -182,31 +182,33 @@ pub fn runSwitchLiveActions( switch (key) { .move_up => { if (try live_tui.moveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg, .up)) { - number_len = 0; follow_selection = true; } else { live_tui.scrollListViewportBy(rows.items.len, page_rows, &viewport_start, .up, wheel_rows); follow_selection = false; } + number_len = 0; needs_render = true; }, .move_down => { if (try live_tui.moveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg, .down)) { - number_len = 0; follow_selection = true; } else { live_tui.scrollListViewportBy(rows.items.len, page_rows, &viewport_start, .down, wheel_rows); follow_selection = false; } + number_len = 0; needs_render = true; }, .keyboard_up => { - if (try live_tui.moveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg, .up)) number_len = 0; + _ = try live_tui.moveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg, .up); + number_len = 0; follow_selection = true; needs_render = true; }, .keyboard_down => { - if (try live_tui.moveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg, .down)) number_len = 0; + _ = try live_tui.moveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg, .down); + number_len = 0; follow_selection = true; needs_render = true; }, @@ -221,22 +223,26 @@ pub fn runSwitchLiveActions( needs_render = true; }, .page_up => { - if (try live_tui.moveSelectedIndexBy(allocator, &selected_account_key, rows, borrowed.reg, .up, page_rows)) number_len = 0; + _ = try live_tui.moveSelectedIndexBy(allocator, &selected_account_key, rows, borrowed.reg, .up, page_rows); + number_len = 0; follow_selection = true; needs_render = true; }, .page_down => { - if (try live_tui.moveSelectedIndexBy(allocator, &selected_account_key, rows, borrowed.reg, .down, page_rows)) number_len = 0; + _ = try live_tui.moveSelectedIndexBy(allocator, &selected_account_key, rows, borrowed.reg, .down, page_rows); + number_len = 0; follow_selection = true; needs_render = true; }, .home => { - if (try live_tui.moveSelectedIndexToEdge(allocator, &selected_account_key, rows, borrowed.reg, .up)) number_len = 0; + _ = try live_tui.moveSelectedIndexToEdge(allocator, &selected_account_key, rows, borrowed.reg, .up); + number_len = 0; follow_selection = true; needs_render = true; }, .end => { - if (try live_tui.moveSelectedIndexToEdge(allocator, &selected_account_key, rows, borrowed.reg, .down)) number_len = 0; + _ = try live_tui.moveSelectedIndexToEdge(allocator, &selected_account_key, rows, borrowed.reg, .down); + number_len = 0; follow_selection = true; needs_render = true; }, @@ -288,13 +294,15 @@ pub fn runSwitchLiveActions( .byte => |ch| { if (isQuitKey(ch)) return; if (ch == 'k') { - if (try live_tui.moveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg, .up)) number_len = 0; + _ = try live_tui.moveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg, .up); + number_len = 0; follow_selection = true; needs_render = true; continue; } if (ch == 'j') { - if (try live_tui.moveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg, .down)) number_len = 0; + _ = try live_tui.moveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg, .down); + number_len = 0; follow_selection = true; needs_render = true; continue; diff --git a/src/cli/picker_switch.zig b/src/cli/picker_switch.zig index ad13b5aa..243c4a5e 100644 --- a/src/cli/picker_switch.zig +++ b/src/cli/picker_switch.zig @@ -294,26 +294,26 @@ fn selectInteractiveFromIndices( .move_up, .keyboard_up, .scroll_up, .page_up => { if (rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; - number_len = 0; } + number_len = 0; }, .home => { if (rows.selectable_row_indices.len != 0) { idx = 0; - number_len = 0; } + number_len = 0; }, .move_down, .keyboard_down, .scroll_down, .page_down => { if (rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; - number_len = 0; } + number_len = 0; }, .end => { if (rows.selectable_row_indices.len != 0) { idx = rows.selectable_row_indices.len - 1; - number_len = 0; } + number_len = 0; }, .quit => return null, .keyboard_enhancement_supported, .ignore => {}, @@ -499,26 +499,26 @@ fn selectInteractive( .move_up, .keyboard_up, .scroll_up, .page_up => { if (rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; - number_len = 0; } + number_len = 0; }, .home => { if (rows.selectable_row_indices.len != 0) { idx = 0; - number_len = 0; } + number_len = 0; }, .move_down, .keyboard_down, .scroll_down, .page_down => { if (rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; - number_len = 0; } + number_len = 0; }, .end => { if (rows.selectable_row_indices.len != 0) { idx = rows.selectable_row_indices.len - 1; - number_len = 0; } + number_len = 0; }, .quit => return null, .keyboard_enhancement_supported, .ignore => {}, diff --git a/src/cli/root.zig b/src/cli/root.zig index 38fcb210..a8e7eee3 100644 --- a/src/cli/root.zig +++ b/src/cli/root.zig @@ -1,5 +1,6 @@ pub const types = @import("types.zig"); pub const commands = @import("commands/root.zig"); +pub const completion = @import("completion.zig"); pub const help = @import("help.zig"); pub const output = @import("output.zig"); pub const login = @import("login.zig"); diff --git a/src/cli/tui.zig b/src/cli/tui.zig index 045bc06a..5a03d886 100644 --- a/src/cli/tui.zig +++ b/src/cli/tui.zig @@ -229,12 +229,11 @@ else pub fn writeTuiEnterTo(out: *std.Io.Writer) !void { try out.writeAll("\x1b[?1049h\x1b[?25l\x1b[?1007h"); - try out.writeAll("\x1b[?u\x1b[>7u"); try out.writeAll("\x1b[H\x1b[J"); } pub fn writeTuiExitTo(out: *std.Io.Writer) !void { - try out.writeAll("\x1b[<1u\x1b[?1007l\x1b[?25h\x1b[?1049l"); + try out.writeAll("\x1b[?1007l\x1b[?25h\x1b[?1049l"); } pub fn writeTuiResetFrameTo(out: *std.Io.Writer) !void { @@ -484,8 +483,8 @@ pub const TuiSession = struct { tui_escape_sequence_timeout_ms, ); switch (escape.action) { - .move_up => appendTuiInputKey(keys, &key_count, if (self.keyboard_enhancement_supported) .scroll_up else .move_up), - .move_down => appendTuiInputKey(keys, &key_count, if (self.keyboard_enhancement_supported) .scroll_down else .move_down), + .move_up => appendTuiInputKey(keys, &key_count, .move_up), + .move_down => appendTuiInputKey(keys, &key_count, .move_down), .keyboard_up => appendTuiInputKey(keys, &key_count, .keyboard_up), .keyboard_down => appendTuiInputKey(keys, &key_count, .keyboard_down), .page_up => appendTuiInputKey(keys, &key_count, .page_up), @@ -811,7 +810,7 @@ pub fn readTuiEscapeAction( switch (try pollTuiInput(tty, timeout_ms, poll_error_mask)) { .timeout => return .{ - .action = if (seq_len == 0) .quit else .ignore, + .action = if (seq_len == 0 or (seq_len == 1 and seq[0] == 0x1b)) .quit else .ignore, .buffered_bytes_consumed = buffered_bytes_consumed, }, .closed => return .{ diff --git a/src/cli/types.zig b/src/cli/types.zig index c6103f39..0fed2abf 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -50,6 +50,16 @@ pub const CleanTarget = enum { accounts, background }; pub const CleanOptions = struct { target: CleanTarget = .accounts, }; +pub const CompletionShell = enum { + bash, + zsh, + fish, +}; +pub const CompletionQueryTarget = enum { switch_account }; +pub const CompletionOptions = union(enum) { + shell: CompletionShell, + query: CompletionQueryTarget, +}; pub const LiveOptions = struct { interval_seconds: u16, }; @@ -64,6 +74,7 @@ pub const HelpTopic = enum { remove_account, alias, clean, + completion, config, }; @@ -76,6 +87,7 @@ pub const Command = union(enum) { remove_account: RemoveOptions, alias: AliasOptions, clean: CleanOptions, + completion: CompletionOptions, config: ConfigOptions, version: void, help: HelpTopic, diff --git a/src/workflows/completion.zig b/src/workflows/completion.zig new file mode 100644 index 00000000..2b69f4bc --- /dev/null +++ b/src/workflows/completion.zig @@ -0,0 +1,9 @@ +const std = @import("std"); +const cli = @import("../cli/root.zig"); + +pub fn handleCompletion(allocator: std.mem.Allocator, codex_home: ?[]const u8, opts: cli.types.CompletionOptions) !void { + switch (opts) { + .shell => |shell| try cli.completion.printCompletion(shell), + .query => |target| try cli.completion.printQueryCompletion(allocator, codex_home.?, target), + } +} diff --git a/src/workflows/root.zig b/src/workflows/root.zig index 3e26dcc3..0a01c0da 100644 --- a/src/workflows/root.zig +++ b/src/workflows/root.zig @@ -15,6 +15,7 @@ const preflight = @import("preflight.zig"); const live_flow = @import("live.zig"); const help_workflow = @import("help.zig"); const clean_workflow = @import("clean.zig"); +const completion_workflow = @import("completion.zig"); const config_workflow = @import("config.zig"); const list_workflow = @import("list.zig"); const login_workflow = @import("login.zig"); @@ -127,6 +128,10 @@ fn runMain(init: std.process.Init.Minimal) !void { const needs_codex_home = switch (cmd) { .version => false, .help => false, + .completion => |opts| switch (opts) { + .shell => false, + .query => true, + }, else => true, }; const codex_home = if (needs_codex_home) try registry.resolveCodexHome(allocator) else null; @@ -147,6 +152,7 @@ fn runMain(init: std.process.Init.Minimal) !void { .remove_account => |opts| try remove_workflow.handleRemove(allocator, codex_home.?, opts), .alias => |opts| try alias_workflow.handleAlias(allocator, codex_home.?, opts), .clean => |opts| try clean_workflow.handleClean(allocator, codex_home.?, opts), + .completion => |opts| try completion_workflow.handleCompletion(allocator, codex_home, opts), } } diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 2f183b92..e74cf4e0 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -327,6 +327,50 @@ test "Scenario: Given command help selector when parsing then command-specific h try expectHelp(result, .list); } +test "Scenario: Given completion shells when parsing then shell is preserved" { + const gpa = std.testing.allocator; + const cases = [_]struct { + shell_name: [:0]const u8, + shell: cli.types.CompletionShell, + }{ + .{ .shell_name = "bash", .shell = .bash }, + .{ .shell_name = "zsh", .shell = .zsh }, + .{ .shell_name = "fish", .shell = .fish }, + }; + + for (cases) |case| { + const args = [_][:0]const u8{ "codex-auth", "completion", case.shell_name }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .completion => |opts| try std.testing.expectEqual(case.shell, opts.shell), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } + } +} + +test "Scenario: Given completion without shell when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "completion" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .completion, "requires a shell name"); +} + +test "Scenario: Given unknown completion shell when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "completion", "tcsh" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .completion, "unknown completion shell `tcsh`"); +} + test "Scenario: Given help when rendering then login and command help notes are shown" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); @@ -339,6 +383,7 @@ test "Scenario: Given help when rendering then login and command help notes are try std.testing.expect(std.mem.indexOf(u8, help, "list [--live] [--active] [--api|--skip-api]") != null); try std.testing.expect(std.mem.indexOf(u8, help, "switch [--live] [--api|--skip-api]") != null); try std.testing.expect(std.mem.indexOf(u8, help, "alias set ") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "completion ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "config live --interval ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "auto enable") == null); } @@ -448,6 +493,91 @@ test "Scenario: Given config help when rendering then live mode is explained" { try std.testing.expect(std.mem.indexOf(u8, config_help, "auto") == null); } +test "Scenario: Given completion help when rendering then shell install flows are explained" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.help.writeCommandHelp(&aw.writer, false, .completion); + + const help = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth completion bash") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth completion zsh") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth completion fish") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "bash Print Bash completion commands to stdout.") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "zsh Print Zsh completion commands to stdout.") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "fish Print Fish completion commands to stdout.") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "~/.local/share/bash-completion/completions/codex-auth") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "~/.zsh/completions/_codex-auth") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "~/.config/fish/completions/codex-auth.fish") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "source ~/.config/fish/completions/codex-auth.fish") != null); +} + +test "Scenario: Given completion query switch when parsing then switch target is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "completion", "query", "switch" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .completion => |opts| switch (opts) { + .query => |target| try std.testing.expectEqual(cli.types.CompletionQueryTarget.switch_account, target), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given bash completion when rendering then commands and switch query support are included" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.completion.writeCompletion(&aw.writer, .bash); + + const script = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, script, "_codex_auth_complete()") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "codex-auth completion query switch") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "complete -F _codex_auth_complete codex-auth") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "bash zsh fish") != null); +} + +test "Scenario: Given zsh completion when rendering then commands and switch query support are included" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.completion.writeCompletion(&aw.writer, .zsh); + + const script = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, script, "#compdef codex-auth") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "codex-auth completion query switch") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "_values 'shell' bash zsh fish") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "_codex_auth_switch_queries") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "compadd -Q -l -- \"${emails[@]}\"") != null); +} + +test "Scenario: Given fish completion when rendering then commands and flags are included" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.completion.writeCompletion(&aw.writer, .fish); + + const script = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, script, "complete -c codex-auth -e") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "__fish_codex_auth_switch_queries") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "-a completion -d 'Generate shell completion scripts'") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "__fish_codex_auth_using_command completion") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "-a 'bash zsh fish' -d 'Generate shell completions'") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "-l device-auth -d 'Run codex login with device auth'") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "complete -c codex-auth -n '__fish_codex_auth_using_command switch' -a '(__fish_codex_auth_switch_queries)'") != null); + try std.testing.expect(std.mem.indexOf(u8, script, "-l interval -r -d 'Set the live refresh interval in seconds'") != null); +} + test "Scenario: Given scanned import report when rendering then stdout and stderr match the import format" { const gpa = std.testing.allocator; var stdout_aw: std.Io.Writer.Allocating = .init(gpa); diff --git a/tests/tui_session_test.zig b/tests/tui_session_test.zig index 6bf51fa4..84f875f6 100644 --- a/tests/tui_session_test.zig +++ b/tests/tui_session_test.zig @@ -45,6 +45,12 @@ test "Scenario: Given keyboard enhancement responses and keys when classifying t try std.testing.expectEqual(TuiEscapeAction.keyboard_up, result.action); } +test "Scenario: Given lone escape key when reading it then it quits" { + const result = try readTuiEscapeAction(std.Io.File.stdin(), "", 0, 0); + try std.testing.expectEqual(TuiEscapeAction.quit, result.action); + try std.testing.expectEqual(@as(usize, 0), result.buffered_bytes_consumed); +} + test "Scenario: Given tty paging and mouse wheel escape suffixes when classifying them then scrolling actions are recognized" { switch (classifyTuiEscapeSuffix("[6~")) { .navigation => |direction| try std.testing.expectEqual(TuiNavigation.page_down, direction), @@ -93,9 +99,9 @@ test "Scenario: Given shared TUI screen lifecycle when writing it then switch an try writeTuiExitTo(&aw.writer); try std.testing.expectEqualStrings( - "\x1b[?1049h\x1b[?25l\x1b[?1007h\x1b[?u\x1b[>7u" ++ + "\x1b[?1049h\x1b[?25l\x1b[?1007h" ++ "\x1b[H\x1b[J" ++ - "\x1b[<1u\x1b[?1007l\x1b[?25h\x1b[?1049l", + "\x1b[?1007l\x1b[?25h\x1b[?1049l", aw.written(), ); try std.testing.expect(std.mem.indexOf(u8, aw.written(), "\x1b[?1007h") != null);