diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aeb72f3..7d20c01 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,10 @@ on: jobs: test: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 diff --git a/README.md b/README.md index 56c4398..a73d9c3 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,14 @@ go install github.com/kwrkb/cli-help-db@latest **1. Generate and install the hook** ```bash +# Linux / macOS mkdir -p ~/.claude/hooks cli-help-db hook --lazy > ~/.claude/hooks/auto-help.sh chmod +x ~/.claude/hooks/auto-help.sh + +# Windows (Git Bash / MSYS2) +mkdir -p ~/.claude/hooks +cli-help-db hook --lazy > ~/.claude/hooks/auto-help.sh ``` **2. Add to `~/.claude/settings.json`** @@ -56,6 +61,10 @@ cp config.example.yaml ~/Library/Application\ Support/cli-help-db/config.yaml # Linux mkdir -p ~/.config/cli-help-db cp config.example.yaml ~/.config/cli-help-db/config.yaml + +# Windows (Git Bash / MSYS2) +mkdir -p "$APPDATA/cli-help-db" +cp config.example.yaml "$APPDATA/cli-help-db/config.yaml" ``` Edit the `commands` list in the config file, then run: @@ -101,6 +110,7 @@ Config file location (resolved via Go's `os.UserConfigDir()`): |----|------| | macOS | `~/Library/Application Support/cli-help-db/config.yaml` | | Linux | `~/.config/cli-help-db/config.yaml` | +| Windows | `%APPDATA%\cli-help-db\config.yaml` | See [`config.example.yaml`](config.example.yaml) for a full example: @@ -146,9 +156,14 @@ go install github.com/kwrkb/cli-help-db@latest **1. フックを生成・インストール** ```bash +# Linux / macOS mkdir -p ~/.claude/hooks cli-help-db hook --lazy > ~/.claude/hooks/auto-help.sh chmod +x ~/.claude/hooks/auto-help.sh + +# Windows (Git Bash / MSYS2) +mkdir -p ~/.claude/hooks +cli-help-db hook --lazy > ~/.claude/hooks/auto-help.sh ``` **2. `~/.claude/settings.json` に追加** @@ -185,6 +200,10 @@ cp config.example.yaml ~/Library/Application\ Support/cli-help-db/config.yaml # Linux mkdir -p ~/.config/cli-help-db cp config.example.yaml ~/.config/cli-help-db/config.yaml + +# Windows (Git Bash / MSYS2) +mkdir -p "$APPDATA/cli-help-db" +cp config.example.yaml "$APPDATA/cli-help-db/config.yaml" ``` 設定ファイルの `commands` リストを編集してから: @@ -230,6 +249,7 @@ cli-help-db build |----|------| | macOS | `~/Library/Application Support/cli-help-db/config.yaml` | | Linux | `~/.config/cli-help-db/config.yaml` | +| Windows | `%APPDATA%\cli-help-db\config.yaml` | 完全な例は [`config.example.yaml`](config.example.yaml) を参照: diff --git a/VISION.md b/VISION.md index 502d1f9..6714bd9 100644 --- a/VISION.md +++ b/VISION.md @@ -8,29 +8,6 @@ Claude Code sometimes misuses CLI tool options (wrong flags, deprecated syntax, A Go CLI tool that **pre-builds a static help database** from commands on `$PATH`. The database is a directory of plain-text files (one per command), designed to be looked up instantly by a shell hook at Claude Code runtime. -## Core Commands - -### `scan` -Enumerate executable files on `$PATH` and display them. Useful for discovering available commands before building the database. - -### `build` -Collect help text for specified commands and save as one file per command. Incremental by default (skips existing entries); `--force` for full re-collection. - -- **Help source fallback order**: `--help` → `-h` → `man` -- **Line limit**: Trim output to a configurable maximum (default: 60 lines) -- **Timeout**: Per-command execution timeout (default: 3 seconds) -- **Parallelism**: Concurrent collection with bounded goroutines -- **`--all`**: Scan all `$PATH` commands instead of config whitelist -- **`--dry-run`**: Preview target commands without collecting - -### `list` -Display commands currently stored in the database. - -### `hook` -Generate an `auto-help.sh` hook script that Claude Code can use to look up help text from the database at runtime via `additionalContext`. - -- **`--lazy`**: Enable on-demand collection — automatically fetch and cache `--help` for unknown commands on first use - ## Design Principles ### Whitelist-First @@ -38,11 +15,6 @@ The default mode operates on an explicit whitelist of commands defined in the co A full-scan mode (`--all`) is available but opt-in — scanning every binary on `$PATH` without a whitelist is noisy and slow. -### Configuration -- **Config file**: `~/.config/cli-help-db/config.yaml` -- **Output directory**: `~/.claude/cli-help/` (default, configurable) -- Config specifies: command whitelist, line limit, timeout, output path - ### Minimal Dependencies Standard library only where possible. No frameworks. External dependencies are added only when they provide clear, justified value (e.g., YAML/TOML parsing). diff --git a/config.example.yaml b/config.example.yaml index c1769e7..2170f02 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,7 +1,8 @@ # cli-help-db configuration # Default location: -# macOS: ~/Library/Application Support/cli-help-db/config.yaml -# Linux: ~/.config/cli-help-db/config.yaml +# macOS: ~/Library/Application Support/cli-help-db/config.yaml +# Linux: ~/.config/cli-help-db/config.yaml +# Windows: %APPDATA%\cli-help-db\config.yaml # Whitelist of commands to collect --help output for. # Only these commands will be indexed (unless --all flag is used). diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 647b36b..c98c30e 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -28,7 +28,7 @@ func ScanDirs(dirs []string) []string { continue } name := e.Name() - if !seen[name] && isExecutable(filepath.Join(dir, name), e) { + if !seen[name] && isExecutable(e) { seen[name] = true names = append(names, name) } @@ -42,7 +42,7 @@ func pathDirs() []string { return strings.Split(os.Getenv("PATH"), sep) } -func isExecutable(path string, entry os.DirEntry) bool { +func isExecutable(entry os.DirEntry) bool { if runtime.GOOS == "windows" { return hasWindowsExecExt(entry.Name()) } @@ -84,10 +84,17 @@ func Exists(name string) bool { } // Filter returns only the names that exist as executables on $PATH. +// On Windows, matches are tried both with and without executable extensions +// (e.g., "curl" matches "curl.exe"). func Filter(names []string) []string { all := make(map[string]bool) for _, n := range Scan() { all[n] = true + // On Windows, also index without extension so "curl" matches "curl.exe" + if runtime.GOOS == "windows" { + base := strings.TrimSuffix(n, filepath.Ext(n)) + all[base] = true + } } var result []string for _, n := range names { diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index a064e0f..19c6f2b 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -3,22 +3,36 @@ package scanner import ( "os" "path/filepath" + "runtime" "testing" ) +// execName returns a filename that is considered executable on the current OS. +// On Windows, appends ".exe"; on Unix, returns the name as-is (relying on permission bits). +func execName(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} + func TestScanDirs_Basic(t *testing.T) { dir := t.TempDir() // Create executable files for _, name := range []string{"foo", "bar", "baz"} { - path := filepath.Join(dir, name) + path := filepath.Join(dir, execName(name)) if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0755); err != nil { t.Fatal(err) } } - // Create a non-executable file - if err := os.WriteFile(filepath.Join(dir, "noexec"), []byte("data"), 0644); err != nil { + // Create a non-executable file (no exec extension on Windows, no exec bit on Unix) + noexecName := "noexec" + if runtime.GOOS == "windows" { + noexecName = "noexec.txt" // .txt is not in PATHEXT + } + if err := os.WriteFile(filepath.Join(dir, noexecName), []byte("data"), 0644); err != nil { t.Fatal(err) } @@ -34,12 +48,12 @@ func TestScanDirs_Basic(t *testing.T) { nameSet[n] = true } - for _, want := range []string{"foo", "bar", "baz"} { + for _, want := range []string{execName("foo"), execName("bar"), execName("baz")} { if !nameSet[want] { - t.Errorf("expected %q in results", want) + t.Errorf("expected %q in results, got %v", want, names) } } - if nameSet["noexec"] { + if nameSet[noexecName] { t.Error("non-executable file should not be included") } if nameSet["subdir"] { @@ -51,9 +65,11 @@ func TestScanDirs_Dedup(t *testing.T) { dir1 := t.TempDir() dir2 := t.TempDir() + dupName := execName("dup") + // Same name in both dirs for _, dir := range []string{dir1, dir2} { - path := filepath.Join(dir, "dup") + path := filepath.Join(dir, dupName) if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0755); err != nil { t.Fatal(err) } @@ -62,12 +78,12 @@ func TestScanDirs_Dedup(t *testing.T) { names := ScanDirs([]string{dir1, dir2}) count := 0 for _, n := range names { - if n == "dup" { + if n == dupName { count++ } } if count != 1 { - t.Errorf("expected 1 occurrence of 'dup', got %d", count) + t.Errorf("expected 1 occurrence of %q, got %d", dupName, count) } } diff --git a/internal/scanner/scanner_windows_test.go b/internal/scanner/scanner_windows_test.go new file mode 100644 index 0000000..e6041bb --- /dev/null +++ b/internal/scanner/scanner_windows_test.go @@ -0,0 +1,99 @@ +//go:build windows + +package scanner + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func TestScanDirs_WindowsExecExtensions(t *testing.T) { + dir := t.TempDir() + + // Create files with various Windows executable extensions + exts := []string{".exe", ".cmd", ".bat"} + for _, ext := range exts { + name := "tool" + ext + if err := os.WriteFile(filepath.Join(dir, name), []byte(""), 0644); err != nil { + t.Fatal(err) + } + } + + // Create a non-executable file + if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte(""), 0644); err != nil { + t.Fatal(err) + } + + names := ScanDirs([]string{dir}) + nameSet := make(map[string]bool) + for _, n := range names { + nameSet[n] = true + } + + for _, ext := range exts { + want := "tool" + ext + if !nameSet[want] { + t.Errorf("expected %q in results", want) + } + } + if nameSet["readme.txt"] { + t.Error(".txt file should not be included") + } +} + +func TestFilter_WindowsExtensionStripping(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + + for _, name := range []string{"curl.exe", "git.exe", "jq.exe"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(""), 0644); err != nil { + t.Fatal(err) + } + } + + testCases := []struct { + name string + input []string + want []string + }{ + { + name: "names without extension", + input: []string{"curl", "git", "jq", "nonexistent"}, + want: []string{"curl", "git", "jq"}, + }, + { + name: "names with extension", + input: []string{"curl.exe", "git.exe", "jq.exe", "nonexistent.exe"}, + want: []string{"curl.exe", "git.exe", "jq.exe"}, + }, + { + name: "mixed names", + input: []string{"curl", "git.exe", "nonexistent"}, + want: []string{"curl", "git.exe"}, + }, + { + name: "no matches", + input: []string{"foo", "bar"}, + want: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := Filter(tc.input) + + if len(got) == 0 && len(tc.want) == 0 { + return + } + + sort.Strings(got) + sort.Strings(tc.want) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Filter() = %v, want %v", got, tc.want) + } + }) + } +}