diff --git a/completions/sandlot.fish b/completions/sandlot.fish index cfe0af9..f523db5 100644 --- a/completions/sandlot.fish +++ b/completions/sandlot.fish @@ -11,7 +11,7 @@ function __sandlot_sessions end # All top-level subcommands (used for __fish_seen_subcommand_from checks) -set -l __sandlot_cmds new list open review shell close rm merge save diff show log dir cleanup vm +set -l __sandlot_cmds new list open review shell close rm merge save diff show log dir cd init cleanup vm set -l __sandlot_vm_cmds create start shell status info stop destroy # Disable file completions @@ -31,12 +31,14 @@ complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a diff complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a show -d 'Show prompt and diff for a branch' complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a log -d 'Show commits not on main' complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a dir -d 'Print the worktree path' +complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a cd -d 'Change to a branch worktree directory' +complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a init -d 'Print shell init script' complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a cleanup -d 'Remove stale sessions' complete -c sandlot -n "not __fish_seen_subcommand_from $__sandlot_cmds" -a vm -d 'Manage the sandlot VM' # ── Session branch completions ─────────────────────────────────────── -for cmd in open review shell close rm merge save diff show log dir +for cmd in open review shell close rm merge save diff show log dir cd complete -c sandlot -n "__fish_seen_subcommand_from $cmd; and not __fish_seen_subcommand_from vm" -a '(__sandlot_sessions)' end @@ -71,5 +73,9 @@ complete -c sandlot -n "__fish_seen_subcommand_from vm; and not __fish_seen_subc # ── Global options ─────────────────────────────────────────────────── +# ── init shell completions ─────────────────────────────────────── + +complete -c sandlot -n '__fish_seen_subcommand_from init' -a 'fish bash zsh' -d 'Shell type' + complete -c sandlot -s h -l help -d 'Show help' complete -c sandlot -s V -l version -d 'Show version' diff --git a/src/cli.ts b/src/cli.ts index 9d74dfb..7dbfe99 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,6 +24,8 @@ import { action as editAction } from "./commands/edit.ts" import { action as cleanupAction } from "./commands/cleanup.ts" import { register as registerVmCommands } from "./commands/vm.ts" import { action as completionsAction } from "./commands/completions.ts" +import { action as initAction } from "./commands/init.ts" +import { action as cdAction } from "./commands/cd.ts" const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json() @@ -172,6 +174,12 @@ program .description("Print the worktree path for a session") .action(dirAction) +program + .command("cd") + .argument("", "branch name") + .description("Change to a branch's worktree directory") + .action(cdAction) + // ── Admin ─────────────────────────────────────────────────────────── program.commandsGroup("Admin Commands:") @@ -208,6 +216,12 @@ program .description("Output fish shell completions") .action((opts: { install?: boolean }) => completionsAction(program, opts)) +program + .command("init") + .argument("", "shell type (fish, bash, zsh)") + .description("Print shell init script (eval in your shell config)") + .action((shell: string) => initAction(program, shell)) + // ── Default: `sandlot` → `sandlot list` ───────────────────────────── if (process.argv.length === 2) { diff --git a/src/commands/cd.ts b/src/commands/cd.ts new file mode 100644 index 0000000..d67d4f4 --- /dev/null +++ b/src/commands/cd.ts @@ -0,0 +1,7 @@ +import { die } from "../fmt.ts" + +export function action(): never { + die( + `"sandlot cd" requires shell integration.\n\nAdd one of these to your shell config:\n\n Fish (~/.config/fish/config.fish):\n sandlot init fish | source\n\n Bash (~/.bashrc):\n eval "$(sandlot init bash)"\n\n Zsh (~/.zshrc):\n eval "$(sandlot init zsh)"` + ) +} diff --git a/src/commands/completions.ts b/src/commands/completions.ts index 39b2888..54a144f 100644 --- a/src/commands/completions.ts +++ b/src/commands/completions.ts @@ -1,22 +1,9 @@ import type { Command, Option } from "commander" -/** Generate fish completions dynamically from the Commander program tree. */ -export function action(program: Command, opts: { install?: boolean } = {}) { - if (opts.install) { - const dest = "~/.config/fish/completions/sandlot.fish" - const lines = [ - "#!/bin/sh", - `mkdir -p ~/.config/fish/completions`, - `sandlot completions > ${dest}`, - `echo "Installed fish completions to ${dest}"`, - ] - process.stdout.write(lines.join("\n") + "\n") - return - } - +/** Generate fish completion lines from the Commander program tree. */ +export function generateFishCompletions(program: Command): string[] { const lines: string[] = [ "# Fish completions for sandlot (auto-generated)", - "# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish", "", "complete -c sandlot -f", "", @@ -65,6 +52,26 @@ export function action(program: Command, opts: { install?: boolean } = {}) { } lines.push("") + return lines +} + +/** Generate fish completions dynamically from the Commander program tree. */ +export function action(program: Command, opts: { install?: boolean } = {}) { + if (opts.install) { + const dest = "~/.config/fish/completions/sandlot.fish" + const lines = [ + "#!/bin/sh", + `mkdir -p ~/.config/fish/completions`, + `sandlot completions > ${dest}`, + `echo "Installed fish completions to ${dest}"`, + ] + process.stdout.write(lines.join("\n") + "\n") + return + } + + const lines = generateFishCompletions(program) + // Add install hint at the top + lines.splice(1, 0, "# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish") process.stdout.write(lines.join("\n") + "\n") } diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..bb04bda --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,88 @@ +import type { Command } from "commander" +import { die } from "../fmt.ts" +import { generateFishCompletions } from "./completions.ts" + +export function action(program: Command, shell: string) { + switch (shell) { + case "fish": + return emitFish(program) + case "bash": + return emitBash(program) + case "zsh": + return emitZsh(program) + default: + die(`Unsupported shell: ${shell}. Supported shells: fish, bash, zsh`) + } +} + +function emitFish(program: Command) { + const lines: string[] = [ + "function sandlot --wraps sandlot --description 'Sandlot CLI wrapper'", + " if test (count $argv) -ge 1; and test \"$argv[1]\" = cd", + " set -l dir (command sandlot dir $argv[2..])", + " and cd $dir", + " else", + " command sandlot $argv", + " end", + "end", + "", + ...generateFishCompletions(program), + ] + process.stdout.write(lines.join("\n") + "\n") +} + +function emitBash(program: Command) { + const { subcommands, branchCommands } = collectCommands(program) + const lines: string[] = [ + "sandlot() {", + ' if [ "$#" -ge 1 ] && [ "$1" = "cd" ]; then', + " local dir", + ' dir="$(command sandlot dir "${@:2}")" && cd "$dir"', + " else", + ' command sandlot "$@"', + " fi", + "}", + "", + "_sandlot_completions() {", + ' local cur prev', + ' cur="${COMP_WORDS[COMP_CWORD]}"', + ' prev="${COMP_WORDS[COMP_CWORD-1]}"', + "", + " if [ \"$COMP_CWORD\" -eq 1 ]; then", + ` COMPREPLY=( $(compgen -W "${subcommands.join(" ")}" -- "$cur") )`, + " return", + " fi", + "", + ` case "$prev" in`, + ` ${branchCommands.join("|")})`, + ' local branches', + ' branches="$(command sandlot list --json 2>/dev/null | grep -o \'"branch": *"[^"]*"\' | sed \'s/.*"\\([^"]*\\)"$/\\1/\')"', + ' COMPREPLY=( $(compgen -W "$branches" -- "$cur") )', + " return", + " ;;", + " esac", + "}", + "complete -F _sandlot_completions sandlot", + ] + process.stdout.write(lines.join("\n") + "\n") +} + +function emitZsh(program: Command) { + // Prepend bashcompinit setup, then emit the same bash script + process.stdout.write("autoload -Uz bashcompinit && bashcompinit\n") + emitBash(program) +} + +function collectCommands(program: Command): { subcommands: string[]; branchCommands: string[] } { + const subcommands: string[] = [] + const branchCommands: string[] = [] + + for (const cmd of program.commands) { + if ((cmd as any)._hidden) continue + subcommands.push(cmd.name()) + const hasBranch = cmd.registeredArguments.some(a => a.name() === "branch") + if (hasBranch) branchCommands.push(cmd.name()) + } + + return { subcommands, branchCommands } +}