diff --git a/README.md b/README.md index 2d7017e5..0e87acb8 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,23 @@ If you have `uv` [installed](https://docs.astral.sh/uv/getting-started/installat make test ``` +## Shell completion + +Optional bash/zsh completion for commands, ticket IDs, statuses, types and flags. + +```bash +./install-completion.sh +exec "$SHELL" +``` + +The installer writes to the standard completion directory for your shell and +appends the source line to your rc file if needed. To wire it up by hand +instead, source `ticket-completion.bash` or `ticket-completion.zsh` from your +`.bashrc` / `.zshrc`. + +Ticket IDs are completed from the same `.tickets/` directory `tk` itself +resolves, so it honours `TICKETS_DIR` and the walk up parent directories. + ## Migrating from Beads ```bash diff --git a/install-completion.sh b/install-completion.sh new file mode 100755 index 00000000..5b7383c7 --- /dev/null +++ b/install-completion.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Install shell completion for ticket/tk into the user's completion directory. +# No sudo, no system paths. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" + +install_bash() { + local dir="$DATA_HOME/bash-completion/completions" + mkdir -p "$dir" + cp "$SCRIPT_DIR/ticket-completion.bash" "$dir/ticket" + ln -sf ticket "$dir/tk" + echo "bash: installed $dir/ticket" + echo " requires the bash-completion package to be loaded in your shell" +} + +install_zsh() { + local dir="$DATA_HOME/zsh/site-functions" + mkdir -p "$dir" + cp "$SCRIPT_DIR/ticket-completion.zsh" "$dir/_ticket" + echo "zsh: installed $dir/_ticket" + + if [[ -f "$HOME/.zshrc" ]] && ! grep -qF "$dir" "$HOME/.zshrc"; then + cat >> "$HOME/.zshrc" <&2; exit 1; } +done + +case "${1:-auto}" in + bash) install_bash ;; + zsh) install_zsh ;; + auto) + if [[ -f "$HOME/.bashrc" || "$(basename "${SHELL:-}")" == bash ]]; then + install_bash + fi + if [[ -f "$HOME/.zshrc" || "$(basename "${SHELL:-}")" == zsh ]]; then + install_zsh + fi + ;; + *) echo "usage: $0 [bash|zsh|auto]" >&2; exit 1 ;; +esac + +echo +echo "Restart your shell (exec \"\$SHELL\") to pick it up." diff --git a/ticket-completion.bash b/ticket-completion.bash new file mode 100644 index 00000000..62febaa6 --- /dev/null +++ b/ticket-completion.bash @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Bash completion for ticket command + +# Locate .tickets the same way ticket does: TICKETS_DIR wins, else walk parents +_ticket_find_dir() { + [[ -n "${TICKETS_DIR:-}" ]] && { echo "$TICKETS_DIR"; return 0; } + + local dir="$PWD" + while [[ "$dir" != "/" ]]; do + if [[ -d "$dir/.tickets" ]]; then + echo "$dir/.tickets" + return 0 + fi + dir=$(dirname "$dir") + done + + [[ -d "/.tickets" ]] && { echo "/.tickets"; return 0; } + return 1 +} + +# Get all ticket IDs from .tickets directory +_ticket_get_ids() { + local dir + dir=$(_ticket_find_dir) || return 0 + [[ -d "$dir" ]] || return 0 + + local ids=() + local file + for file in "$dir"/*.md; do + if [[ -f "$file" ]]; then + ids+=("$(basename "$file" .md)") + fi + done + echo "${ids[@]}" +} + +# compopt only exists in bash 4+; degrade quietly on bash 3.2 (macOS default) +_ticket_nospace() { + if type compopt &>/dev/null; then + compopt -o nospace 2>/dev/null + fi + return 0 +} + +_ticket_completion() { + local cur prev words cword + if declare -F _init_completion >/dev/null; then + _init_completion || return + else + # bash-completion is not loaded; derive the same variables ourselves + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + fi + + # Main commands + local commands="create start close reopen status dep undep link unlink ls list ready blocked closed show add-note super edit query migrate-beads help" + + # Valid statuses + local statuses="open in_progress closed" + + # Commands that need ticket ID(s) + local id_commands="start close reopen status show edit add-note" + local multi_id_commands="link dep undep unlink" + + # Get the main command (first word after 'ticket') + local cmd="" + if [[ $cword -ge 1 ]]; then + cmd="${words[1]}" + fi + + case $cword in + 1) + # Complete main commands + COMPREPLY=($(compgen -W "$commands" -- "$cur")) + return 0 + ;; + 2) + case "$cmd" in + # Commands that need a ticket ID + start|close|reopen|show|edit|add-note) + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + return 0 + ;; + # status needs ticket ID first + status) + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + return 0 + ;; + # dep has subcommands or ticket ID + dep) + COMPREPLY=($(compgen -W "tree cycle $(_ticket_get_ids)" -- "$cur")) + return 0 + ;; + # Commands that need multiple ticket IDs + link|undep|unlink) + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + return 0 + ;; + # ls can have --status flag + ls|list) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--status= -a -T" -- "$cur")) + [[ ${#COMPREPLY[@]} -eq 1 ]] && [[ "${COMPREPLY[0]}" == *= ]] && _ticket_nospace + fi + return 0 + ;; + ready|blocked) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "-a -T" -- "$cur")) + fi + return 0 + ;; + super) + COMPREPLY=($(compgen -W "$commands" -- "$cur")) + return 0 + ;; + # closed can have --limit flag + closed) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--limit= -a -T" -- "$cur")) + [[ ${#COMPREPLY[@]} -eq 1 ]] && [[ "${COMPREPLY[0]}" == *= ]] && _ticket_nospace + fi + return 0 + ;; + # create has many options + create) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "-d --description --design --acceptance -t --type -p --priority -a --assignee --external-ref --parent --tags" -- "$cur")) + fi + return 0 + ;; + esac + ;; + 3) + case "$cmd" in + # status needs status value after ticket ID + status) + COMPREPLY=($(compgen -W "$statuses" -- "$cur")) + return 0 + ;; + # dep can be 'tree' with options or regular dep with ticket ID + dep) + if [[ "${words[2]}" == "cycle" ]]; then + return 0 + elif [[ "${words[2]}" == "tree" ]]; then + # dep tree needs ticket ID or --full flag + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--full" -- "$cur")) + else + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + fi + else + # Regular dep needs second ticket ID + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + fi + return 0 + ;; + # undep and unlink need second ticket ID + undep|unlink) + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + return 0 + ;; + # link can have more ticket IDs + link) + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + return 0 + ;; + # create options + create) + case "$prev" in + -t|--type|-T) + COMPREPLY=($(compgen -W "bug feature task epic chore" -- "$cur")) + return 0 + ;; + -p|--priority) + COMPREPLY=($(compgen -W "0 1 2 3 4" -- "$cur")) + return 0 + ;; + --parent) + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + return 0 + ;; + -d|--description|--design|--acceptance|-a|--assignee|--external-ref|--tags) + # These need text input, no completion + return 0 + ;; + *) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "-d --description --design --acceptance -t --type -p --priority -a --assignee --external-ref --parent --tags" -- "$cur")) + fi + return 0 + ;; + esac + ;; + esac + ;; + *) + # Handle additional arguments + case "$cmd" in + # dep tree with --full flag + dep) + if [[ "${words[2]}" == "tree" ]]; then + if [[ "${words[3]}" == "--full" ]] && [[ $cword -eq 4 ]]; then + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + elif [[ "$cur" == -* ]] && [[ $cword -eq 4 ]]; then + COMPREPLY=($(compgen -W "--full" -- "$cur")) + fi + fi + return 0 + ;; + # link can accept multiple ticket IDs + link) + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + return 0 + ;; + # create options can appear at any position + create) + case "$prev" in + -t|--type|-T) + COMPREPLY=($(compgen -W "bug feature task epic chore" -- "$cur")) + return 0 + ;; + -p|--priority) + COMPREPLY=($(compgen -W "0 1 2 3 4" -- "$cur")) + return 0 + ;; + --parent) + COMPREPLY=($(compgen -W "$(_ticket_get_ids)" -- "$cur")) + return 0 + ;; + -d|--description|--design|--acceptance|-a|--assignee|--external-ref|--tags) + return 0 + ;; + *) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "-d --description --design --acceptance -t --type -p --priority -a --assignee --external-ref --parent --tags" -- "$cur")) + fi + return 0 + ;; + esac + ;; + esac + ;; + esac + + return 0 +} + +# Register the completion function for the ticket command +complete -F _ticket_completion ticket +complete -F _ticket_completion tk diff --git a/ticket-completion.zsh b/ticket-completion.zsh new file mode 100644 index 00000000..41d33f77 --- /dev/null +++ b/ticket-completion.zsh @@ -0,0 +1,157 @@ +#compdef ticket tk + +# Zsh completion for ticket command (also works with 'tk' alias) + +# Get all ticket IDs from .tickets directory +# Locate .tickets the same way ticket does: TICKETS_DIR wins, else walk parents +_ticket_find_dir() { + [[ -n "${TICKETS_DIR:-}" ]] && { echo "$TICKETS_DIR"; return 0; } + + local dir="$PWD" + while [[ "$dir" != "/" ]]; do + if [[ -d "$dir/.tickets" ]]; then + echo "$dir/.tickets" + return 0 + fi + dir="${dir:h}" + done + + [[ -d "/.tickets" ]] && { echo "/.tickets"; return 0; } + return 1 +} + +_ticket_get_ids() { + local dir + dir=$(_ticket_find_dir) || return 0 + [[ -d "$dir" ]] || return 0 + + local ids=() + local file + for file in "$dir"/*.md(N); do + ids+=("${file:t:r}") + done + echo ${ids[@]} +} + +_ticket() { + local line state + + _arguments -C \ + '1: :->command' \ + '*::arg:->args' + + case $state in + command) + local commands=( + 'create:Create a new ticket' + 'start:Set status to in_progress' + 'close:Set status to closed' + 'reopen:Set status to open' + 'status:Update ticket status' + 'dep:Add dependency or show dependency tree' + 'undep:Remove dependency' + 'link:Link tickets together' + 'unlink:Remove link between tickets' + 'ls:List tickets' + 'list:List tickets' + 'ready:List ready tickets' + 'blocked:List blocked tickets' + 'closed:List recently closed tickets' + 'show:Display ticket' + 'edit:Open ticket in editor' + 'add-note:Append note to ticket' + 'query:Output tickets as JSON' + 'migrate-beads:Import from beads' + 'super:Bypass plugins and run the built-in command' + 'help:Show help' + ) + _describe 'command' commands + ;; + args) + local cmd="${line[1]}" + case "$cmd" in + start|close|reopen|show|edit|add-note) + _arguments "1: :($(_ticket_get_ids))" + ;; + status) + case $CURRENT in + 2) + _arguments "1: :($(_ticket_get_ids))" + ;; + 3) + _arguments "1: :(open in_progress closed)" + ;; + esac + ;; + dep) + case $CURRENT in + 2) + local subcmds=( + 'tree:Show dependency tree' + 'cycle:Find dependency cycles in open tickets' + ) + _describe 'subcommand' subcmds + _arguments "1: :($(_ticket_get_ids))" + ;; + 3) + if [[ "${line[2]}" == "tree" ]]; then + _arguments \ + '--full[Show full tree without deduplication]' \ + "1: :($(_ticket_get_ids))" + else + _arguments "1: :($(_ticket_get_ids))" + fi + ;; + 4) + if [[ "${line[2]}" == "tree" ]]; then + _arguments "1: :($(_ticket_get_ids))" + fi + ;; + esac + ;; + undep|unlink) + case $CURRENT in + 2|3) + _arguments "1: :($(_ticket_get_ids))" + ;; + esac + ;; + link) + _arguments "*: :($(_ticket_get_ids))" + ;; + ls|list) + _arguments \ + '--status=[Filter by status]:status:(open in_progress closed)' \ + '-a[Filter by assignee]:assignee:' \ + '-T[Filter by type]:type:(bug feature task epic chore)' + ;; + ready|blocked) + _arguments \ + '-a[Filter by assignee]:assignee:' \ + '-T[Filter by type]:type:(bug feature task epic chore)' + ;; + closed) + _arguments \ + '--limit=[Limit number of results]:limit:' \ + '-a[Filter by assignee]:assignee:' \ + '-T[Filter by type]:type:(bug feature task epic chore)' + ;; + create) + _arguments \ + '1:title:' \ + '(-d --description)'{-d,--description}'[Description text]:description:' \ + '--design[Design notes]:design:' \ + '--acceptance[Acceptance criteria]:acceptance:' \ + '(-t --type)'{-t,--type}'[Ticket type]:type:(bug feature task epic chore)' \ + '(-p --priority)'{-p,--priority}'[Priority]:priority:(0 1 2 3 4)' \ + '(-a --assignee)'{-a,--assignee}'[Assignee]:assignee:' \ + '--external-ref[External reference]:reference:' \ + '--tags[Comma-separated tags]:tags:' \ + "--parent[Parent ticket]:parent:($(_ticket_get_ids))" + ;; + esac + ;; + esac +} + +_ticket "$@" \ No newline at end of file