diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 37dbb63..dc48690 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -74,30 +74,61 @@ func Collect(commands []string, opts Options) []Result { return results } +// splitCommand splits a command name into executable and subcommand args. +// e.g. "docker container ls" -> ("docker", ["container", "ls"]) +// e.g. "curl" -> ("curl", []) +func splitCommand(name string) (string, []string) { + parts := strings.Fields(name) + if len(parts) <= 1 { + return name, nil + } + return parts[0], parts[1:] +} + func collectOne(name string, opts Options) (string, error) { + exe, sub := splitCommand(name) + + // Build args safely (avoid mutating sub via append) + withFlag := func(flag string) []string { + a := make([]string, len(sub)+1) + copy(a, sub) + a[len(sub)] = flag + return a + } + + // Try {cmd} {sub...} --help ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout) defer cancel() - - // Try --help - text, err := opts.Exec(ctx, name, "--help") + text, err := opts.Exec(ctx, exe, withFlag("--help")...) if isUsable(text) { return truncate(text, opts.LineLimit), nil } - // Try -h + // Try {cmd} {sub...} -h ctx2, cancel2 := context.WithTimeout(context.Background(), opts.Timeout) defer cancel2() - text, err = opts.Exec(ctx2, name, "-h") + text, err = opts.Exec(ctx2, exe, withFlag("-h")...) if isUsable(text) { return truncate(text, opts.LineLimit), nil } - // Try man (Unix only) - ctx3, cancel3 := context.WithTimeout(context.Background(), opts.Timeout) - defer cancel3() - text, err = opts.Exec(ctx3, "man", name) + // Try {cmd} help {sub...} (help subcommand pattern, e.g. "docker help container ls") + if len(sub) > 0 { + ctx3, cancel3 := context.WithTimeout(context.Background(), opts.Timeout) + defer cancel3() + helpArgs := append([]string{"help"}, sub...) + text, err = opts.Exec(ctx3, exe, helpArgs...) + if isUsable(text) { + return truncate(text, opts.LineLimit), nil + } + } + + // Try man: for subcommands use hyphenated form (e.g. "man git-remote") + ctx4, cancel4 := context.WithTimeout(context.Background(), opts.Timeout) + defer cancel4() + manPage := strings.Join(append([]string{exe}, sub...), "-") + text, err = opts.Exec(ctx4, "man", manPage) if isUsable(text) { - // Strip man formatting text = stripManFormatting(text) return truncate(text, opts.LineLimit), nil } diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index df7e0c4..deea89b 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -89,6 +89,67 @@ func TestCollect_Parallel(t *testing.T) { } } +func TestSplitCommand(t *testing.T) { + tests := []struct { + name string + wantExe string + wantSub []string + }{ + {"curl", "curl", nil}, + {"docker container ls", "docker", []string{"container", "ls"}}, + {"kubectl get", "kubectl", []string{"get"}}, + } + for _, tt := range tests { + exe, sub := splitCommand(tt.name) + if exe != tt.wantExe { + t.Errorf("splitCommand(%q) exe = %q, want %q", tt.name, exe, tt.wantExe) + } + if len(sub) != len(tt.wantSub) { + t.Errorf("splitCommand(%q) sub = %v, want %v", tt.name, sub, tt.wantSub) + continue + } + for i := range sub { + if sub[i] != tt.wantSub[i] { + t.Errorf("splitCommand(%q) sub[%d] = %q, want %q", tt.name, i, sub[i], tt.wantSub[i]) + } + } + } +} + +func TestCollect_Subcommand(t *testing.T) { + exec := fakeExec(map[string]string{ + "docker container ls --help": "Usage: docker container ls [OPTIONS]\n\nList containers\n\nOptions:\n -a, --all Show all", + }) + + results := Collect([]string{"docker container ls"}, Options{Exec: exec}) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Err != nil { + t.Fatalf("unexpected error: %v", results[0].Err) + } + if !strings.Contains(results[0].Text, "docker container ls") { + t.Errorf("expected subcommand help, got %q", results[0].Text) + } +} + +func TestCollect_SubcommandHelpFallback(t *testing.T) { + // --help and -h fail, but "docker help container ls" succeeds + exec := fakeExec(map[string]string{ + "docker container ls --help": "", + "docker container ls -h": "", + "docker help container ls": "Usage: docker container ls [OPTIONS]\n\nList containers", + }) + + results := Collect([]string{"docker container ls"}, Options{Exec: exec}) + if results[0].Err != nil { + t.Fatalf("unexpected error: %v", results[0].Err) + } + if !strings.Contains(results[0].Text, "docker container ls") { + t.Errorf("expected help subcommand fallback, got %q", results[0].Text) + } +} + func TestStripManFormatting(t *testing.T) { // Bold: char + backspace + char input := "H\bHe\bel\bll\blo\bo" diff --git a/internal/db/db.go b/internal/db/db.go index 9c89303..9985080 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -8,6 +8,8 @@ import ( ) // DB represents a directory-based help text database. +// Command names may contain spaces for subcommands (e.g. "docker container ls"), +// which are stored as "docker__container__ls.txt" on disk. type DB struct { Dir string } @@ -17,18 +19,33 @@ func New(dir string) *DB { return &DB{Dir: dir} } +// NameToKey converts a command name (possibly with spaces for subcommands) +// to a filesystem-safe key using "__" as separator. +// e.g. "docker container ls" -> "docker__container__ls" +func NameToKey(name string) string { + return strings.ReplaceAll(name, " ", "__") +} + +// KeyToName converts a filesystem key back to a display name with spaces. +// e.g. "docker__container__ls" -> "docker container ls" +func KeyToName(key string) string { + return strings.ReplaceAll(key, "__", " ") +} + // Write saves help text for a command. Creates the directory if needed. +// name can contain spaces for subcommands (e.g. "docker container ls"). func (d *DB) Write(name, text string) error { if err := os.MkdirAll(d.Dir, 0755); err != nil { return err } - path := filepath.Join(d.Dir, name+".txt") + path := filepath.Join(d.Dir, NameToKey(name)+".txt") return os.WriteFile(path, []byte(text), 0644) } // Read returns the help text for a command, or empty string if not found. +// name can contain spaces for subcommands (e.g. "docker container ls"). func (d *DB) Read(name string) (string, error) { - path := filepath.Join(d.Dir, name+".txt") + path := filepath.Join(d.Dir, NameToKey(name)+".txt") data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -40,13 +57,16 @@ func (d *DB) Read(name string) (string, error) { } // Has checks if a command exists in the database. +// name can contain spaces for subcommands (e.g. "docker container ls"). func (d *DB) Has(name string) bool { - path := filepath.Join(d.Dir, name+".txt") + path := filepath.Join(d.Dir, NameToKey(name)+".txt") _, err := os.Stat(path) return err == nil } // List returns all command names in the database, sorted. +// Subcommand keys (e.g. "docker__container__ls") are returned as +// display names with spaces (e.g. "docker container ls"). func (d *DB) List() ([]string, error) { entries, err := os.ReadDir(d.Dir) if err != nil { @@ -63,7 +83,8 @@ func (d *DB) List() ([]string, error) { } name := e.Name() if strings.HasSuffix(name, ".txt") { - names = append(names, strings.TrimSuffix(name, ".txt")) + key := strings.TrimSuffix(name, ".txt") + names = append(names, KeyToName(key)) } } sort.Strings(names) diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 609694a..4fbf862 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -94,3 +94,82 @@ func TestList_NonexistentDir(t *testing.T) { t.Errorf("expected nil, got %v", names) } } + +func TestNameToKey(t *testing.T) { + tests := []struct { + name, want string + }{ + {"curl", "curl"}, + {"docker container ls", "docker__container__ls"}, + {"kubectl get pods", "kubectl__get__pods"}, + } + for _, tt := range tests { + if got := NameToKey(tt.name); got != tt.want { + t.Errorf("NameToKey(%q) = %q, want %q", tt.name, got, tt.want) + } + } +} + +func TestKeyToName(t *testing.T) { + tests := []struct { + key, want string + }{ + {"curl", "curl"}, + {"docker__container__ls", "docker container ls"}, + {"kubectl__get__pods", "kubectl get pods"}, + } + for _, tt := range tests { + if got := KeyToName(tt.key); got != tt.want { + t.Errorf("KeyToName(%q) = %q, want %q", tt.key, got, tt.want) + } + } +} + +func TestSubcommand_WriteReadHas(t *testing.T) { + d := New(t.TempDir()) + + name := "docker container ls" + text := "Usage: docker container ls [OPTIONS]" + + if err := d.Write(name, text); err != nil { + t.Fatal(err) + } + + if !d.Has(name) { + t.Error("expected Has=true for subcommand") + } + + got, err := d.Read(name) + if err != nil { + t.Fatal(err) + } + if got != text { + t.Errorf("got %q, want %q", got, text) + } +} + +func TestSubcommand_List(t *testing.T) { + d := New(t.TempDir()) + + commands := []string{"curl", "docker", "docker container ls", "docker image ls"} + for _, name := range commands { + if err := d.Write(name, "help for "+name); err != nil { + t.Fatal(err) + } + } + + names, err := d.List() + if err != nil { + t.Fatal(err) + } + + want := []string{"curl", "docker", "docker container ls", "docker image ls"} + if len(names) != len(want) { + t.Fatalf("got %v, want %v", names, want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("names[%d] = %q, want %q", i, names[i], want[i]) + } + } +} diff --git a/internal/hook/hook.go b/internal/hook/hook.go index cefeecc..f354250 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -20,7 +20,7 @@ func Generate(w io.Writer, dbDir string, lazy bool) error { const hookTemplate = `#!/usr/bin/env bash # auto-help.sh - PreToolUse hook for Bash tool -# Looks up pre-built CLI help from static database +# Looks up pre-built CLI help from static database (with subcommand support) # Generated by: cli-help-db hook INPUT=$(cat) || exit 0 @@ -63,24 +63,48 @@ BASE_CMD=$(basename "${TOKENS[0]}") # Skip npx/bunx (may trigger package downloads) [[ "$BASE_CMD" == "npx" || "$BASE_CMD" == "bunx" ]] && exit 0 +# Build subcommand lookup key (e.g. docker__container__ls) +# Collect non-flag tokens up to depth 3 +LOOKUP_KEY="$BASE_CMD" +MAX_DEPTH=3 +for ((i=1; i<${#TOKENS[@]} && i/dev/null || exit 0 -CACHE_FILE="${CACHE_DIR}/${BASE_CMD}" +CACHE_FILE="${CACHE_DIR}/${LOOKUP_KEY}" [[ -f "$CACHE_FILE" ]] && exit 0 -# Look up from static database +# Look up from static database (longest match first) HELP_DB_DIR="%s" -HELP_FILE="${HELP_DB_DIR}/${BASE_CMD}.txt" - -[[ ! -f "$HELP_FILE" ]] && exit 0 +HELP_OUTPUT="" +MATCHED_KEY="" +TRY_KEY="$LOOKUP_KEY" +while [[ -n "$TRY_KEY" ]]; do + HELP_FILE="${HELP_DB_DIR}/${TRY_KEY}.txt" + if [[ -f "$HELP_FILE" ]]; then + HELP_OUTPUT=$(cat "$HELP_FILE") + MATCHED_KEY="$TRY_KEY" + break + fi + # Remove last __ segment (single %% via Fprintf = bash shortest suffix match) + SHORTER="${TRY_KEY%%__*}" + [[ "$SHORTER" == "$TRY_KEY" ]] && break # no __ left + TRY_KEY="$SHORTER" +done -HELP_OUTPUT=$(cat "$HELP_FILE") [[ -z "$HELP_OUTPUT" ]] && exit 0 +DISPLAY_CMD="${MATCHED_KEY//__/ }" touch "$CACHE_FILE" -jq -n --arg help "$HELP_OUTPUT" --arg cmd "$BASE_CMD" '{ +jq -n --arg help "$HELP_OUTPUT" --arg cmd "$DISPLAY_CMD" '{ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: ("CLI Reference: " + $cmd + " --help\n" + $help) @@ -90,8 +114,9 @@ jq -n --arg help "$HELP_OUTPUT" --arg cmd "$BASE_CMD" '{ const lazyHookTemplate = `#!/usr/bin/env bash # auto-help.sh - PreToolUse hook for Bash tool -# Looks up pre-built CLI help from static database +# Looks up pre-built CLI help from static database (with subcommand support) # With lazy collection: fetches --help on-demand for unknown commands +# TTL-based refresh: re-collects help after 30 days # Generated by: cli-help-db hook --lazy INPUT=$(cat) || exit 0 @@ -134,50 +159,133 @@ BASE_CMD=$(basename "${TOKENS[0]}") # Skip npx/bunx (may trigger package downloads) [[ "$BASE_CMD" == "npx" || "$BASE_CMD" == "bunx" ]] && exit 0 +# Build subcommand lookup key (e.g. docker__container__ls) +# Collect non-flag tokens up to depth 3 +LOOKUP_KEY="$BASE_CMD" +MAX_DEPTH=3 +for ((i=1; i<${#TOKENS[@]} && i/dev/null || exit 0 -CACHE_FILE="${CACHE_DIR}/${BASE_CMD}" +CACHE_FILE="${CACHE_DIR}/${LOOKUP_KEY}" [[ -f "$CACHE_FILE" ]] && exit 0 -# Look up from static database -HELP_DB_DIR="%s" -HELP_FILE="${HELP_DB_DIR}/${BASE_CMD}.txt" +# TTL helper: check if file is older than 30 days +TTL_SECONDS=$((30 * 86400)) +is_expired() { + local file="$1" + [[ ! -f "$file" ]] && return 0 + local now mtime age + now=$(date +%%s) + if stat -c %%Y /dev/null &>/dev/null 2>&1; then + mtime=$(stat -c %%Y "$file") + else + mtime=$(stat -f %%m "$file") + fi + age=$((now - mtime)) + [[ $age -gt $TTL_SECONDS ]] +} + +# Lazy collect helper: try --help, -h, then help subcommand +lazy_collect() { + local cmd_key="$1" + local display_cmd="${cmd_key//__/ }" + read -ra CMD_PARTS <<< "$display_cmd" -if [[ ! -f "$HELP_FILE" ]]; then - # Lazy collect: try --help, then -h (skip man — too slow for hook context) - mkdir -p "$HELP_DB_DIR" 2>/dev/null || exit 0 + local help_output="" - # Use timeout if available, otherwise run directly + # Try {cmd} {sub...} --help if command -v timeout &>/dev/null; then - HELP_OUTPUT=$(timeout 2 "$BASE_CMD" --help 2>&1) || true - if [[ ${#HELP_OUTPUT} -lt 10 ]]; then - HELP_OUTPUT=$(timeout 2 "$BASE_CMD" -h 2>&1) || true - fi + help_output=$(timeout 2 "${CMD_PARTS[@]}" --help 2>&1) || true else - HELP_OUTPUT=$("$BASE_CMD" --help 2>&1) || true - if [[ ${#HELP_OUTPUT} -lt 10 ]]; then - HELP_OUTPUT=$("$BASE_CMD" -h 2>&1) || true + help_output=$("${CMD_PARTS[@]}" --help 2>&1) || true + fi + + # Try {cmd} {sub...} -h + if [[ ${#help_output} -lt 10 ]]; then + if command -v timeout &>/dev/null; then + help_output=$(timeout 2 "${CMD_PARTS[@]}" -h 2>&1) || true + else + help_output=$("${CMD_PARTS[@]}" -h 2>&1) || true fi fi - [[ ${#HELP_OUTPUT} -lt 10 ]] && exit 0 - # Truncate to 60 lines - HELP_OUTPUT=$(printf '%%s' "$HELP_OUTPUT" | head -n 60) + # Try {cmd} help {sub...} (for tools like docker, go) + if [[ ${#help_output} -lt 10 && ${#CMD_PARTS[@]} -gt 1 ]]; then + local exe="${CMD_PARTS[0]}" + local subs=("${CMD_PARTS[@]:1}") + if command -v timeout &>/dev/null; then + help_output=$(timeout 2 "$exe" help "${subs[@]}" 2>&1) || true + else + help_output=$("$exe" help "${subs[@]}" 2>&1) || true + fi + fi + + [[ ${#help_output} -lt 10 ]] && return 1 + + # Truncate to 60 lines and write to DB + help_output=$(printf '%%s' "$help_output" | head -n 60) + mkdir -p "$HELP_DB_DIR" 2>/dev/null || return 1 + local tmp_file + tmp_file=$(mktemp "${HELP_DB_DIR}/.${cmd_key}.XXXXXX") || return 1 + printf '%%s' "$help_output" > "$tmp_file" + mv "$tmp_file" "${HELP_DB_DIR}/${cmd_key}.txt" + echo "$help_output" +} - # Atomic write to DB - TMP_FILE=$(mktemp "${HELP_DB_DIR}/.${BASE_CMD}.XXXXXX") || exit 0 - printf '%%s' "$HELP_OUTPUT" > "$TMP_FILE" - mv "$TMP_FILE" "$HELP_FILE" -else - HELP_OUTPUT=$(cat "$HELP_FILE") +# Look up from static database (longest match first, with TTL check) +HELP_DB_DIR="%s" +HELP_OUTPUT="" +MATCHED_KEY="" +NEEDS_REFRESH=false +TRY_KEY="$LOOKUP_KEY" +while [[ -n "$TRY_KEY" ]]; do + HELP_FILE="${HELP_DB_DIR}/${TRY_KEY}.txt" + if [[ -f "$HELP_FILE" ]]; then + if is_expired "$HELP_FILE"; then + NEEDS_REFRESH=true + MATCHED_KEY="$TRY_KEY" + else + HELP_OUTPUT=$(cat "$HELP_FILE") + MATCHED_KEY="$TRY_KEY" + fi + break + fi + # Remove last __ segment (single %% via Fprintf = bash shortest suffix match) + SHORTER="${TRY_KEY%%__*}" + [[ "$SHORTER" == "$TRY_KEY" ]] && break # no __ left + TRY_KEY="$SHORTER" +done + +# Lazy collect: no match found, or TTL expired — try to collect +if [[ -z "$HELP_OUTPUT" ]]; then + # Try from longest key down to base command + TRY_KEY="$LOOKUP_KEY" + while [[ -n "$TRY_KEY" ]]; do + HELP_OUTPUT=$(lazy_collect "$TRY_KEY") + if [[ -n "$HELP_OUTPUT" ]]; then + MATCHED_KEY="$TRY_KEY" + break + fi + SHORTER="${TRY_KEY%%__*}" + [[ "$SHORTER" == "$TRY_KEY" ]] && break + TRY_KEY="$SHORTER" + done fi [[ -z "$HELP_OUTPUT" ]] && exit 0 +DISPLAY_CMD="${MATCHED_KEY//__/ }" touch "$CACHE_FILE" -jq -n --arg help "$HELP_OUTPUT" --arg cmd "$BASE_CMD" '{ +jq -n --arg help "$HELP_OUTPUT" --arg cmd "$DISPLAY_CMD" '{ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: ("CLI Reference: " + $cmd + " --help\n" + $help) diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go index 0536159..020478d 100644 --- a/internal/hook/hook_test.go +++ b/internal/hook/hook_test.go @@ -26,8 +26,8 @@ func TestGenerate(t *testing.T) { } // Check it reads from .txt files - if !strings.Contains(script, "${BASE_CMD}.txt") { - t.Error("expected .txt file lookup pattern") + if !strings.Contains(script, "${TRY_KEY}.txt") { + t.Error("expected .txt file lookup pattern with TRY_KEY") } // Check JSON output format @@ -41,9 +41,19 @@ func TestGenerate(t *testing.T) { } // Non-lazy mode should NOT contain lazy collection - if strings.Contains(script, "Lazy collect") { + if strings.Contains(script, "lazy_collect") { t.Error("non-lazy mode should not contain lazy collection logic") } + + // Check subcommand support: LOOKUP_KEY construction + if !strings.Contains(script, "LOOKUP_KEY") { + t.Error("expected LOOKUP_KEY for subcommand support") + } + + // Check longest-match loop + if !strings.Contains(script, "longest match first") { + t.Error("expected longest-match lookup comment") + } } func TestGenerateLazy(t *testing.T) { @@ -66,8 +76,8 @@ func TestGenerateLazy(t *testing.T) { } // Check lazy collection logic - if !strings.Contains(script, "Lazy collect") { - t.Error("lazy mode should contain lazy collection logic") + if !strings.Contains(script, "lazy_collect") { + t.Error("lazy mode should contain lazy_collect function") } // Check timeout usage @@ -94,4 +104,24 @@ func TestGenerateLazy(t *testing.T) { if !strings.Contains(script, "--lazy") { t.Error("lazy script should mention --lazy in header") } + + // Check subcommand support + if !strings.Contains(script, "LOOKUP_KEY") { + t.Error("expected LOOKUP_KEY for subcommand support") + } + + // Check TTL support + if !strings.Contains(script, "TTL_SECONDS") { + t.Error("lazy mode should have TTL-based refresh") + } + + // Check is_expired function + if !strings.Contains(script, "is_expired") { + t.Error("lazy mode should have is_expired function") + } + + // Check help subcommand fallback pattern + if !strings.Contains(script, "help \"${subs[@]}\"") { + t.Error("lazy mode should try 'cmd help sub...' fallback") + } } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index c98c30e..13eb041 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -84,6 +84,8 @@ func Exists(name string) bool { } // Filter returns only the names that exist as executables on $PATH. +// For subcommand entries (containing spaces, e.g. "docker container ls"), +// only the first token ("docker") is checked against $PATH. // On Windows, matches are tried both with and without executable extensions // (e.g., "curl" matches "curl.exe"). func Filter(names []string) []string { @@ -98,7 +100,12 @@ func Filter(names []string) []string { } var result []string for _, n := range names { - if all[n] { + // For subcommands, check only the base executable + lookup := n + if i := strings.IndexByte(n, ' '); i >= 0 { + lookup = n[:i] + } + if all[lookup] { result = append(result, n) } }