Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`**
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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` に追加**
Expand Down Expand Up @@ -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` リストを編集してから:
Expand Down Expand Up @@ -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) を参照:

Expand Down
28 changes: 0 additions & 28 deletions VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,13 @@ 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
The default mode operates on an explicit whitelist of commands defined in the config file. Users control exactly which tools get indexed.

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).

Expand Down
5 changes: 3 additions & 2 deletions config.example.yaml
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
11 changes: 9 additions & 2 deletions internal/scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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())
}
Expand Down Expand Up @@ -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 {
Expand Down
34 changes: 25 additions & 9 deletions internal/scanner/scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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"] {
Expand All @@ -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)
}
Expand All @@ -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)
}
}

Expand Down
99 changes: 99 additions & 0 deletions internal/scanner/scanner_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//go:build windows

package scanner

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict Windows-only tests to Windows builds

This new test file has no //go:build windows guard, so both tests run on non-Windows CI jobs too. In ubuntu-latest (added in .github/workflows/test.yml), ScanDirs uses Unix execute bits, and the test fixtures are created with mode 0644, so entries like tool.exe are not treated as executables and the assertions fail. This makes the Linux leg of the matrix fail even though the behavior under test is Windows-specific.

Useful? React with 👍 / 👎.


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)
}
})
}
}
Comment on lines +47 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

このテスト TestFilter_WindowsExtensionStripping は、テスト対象である Filter 関数を直接呼び出す代わりに、その内部ロジックを再実装しています。このアプローチでは、Filter 関数のリファクタリング時にテストが追従できず、コードの保守性を損なう可能性があります。

テストの堅牢性を高めるため、Filter 関数を直接呼び出してその振る舞いを検証するブラックボックステストに書き換えることを推奨します。Go 1.17以降で利用可能な t.Setenv を使用して PATH 環境変数を設定することで、Scan 関数をモックすることなく、Filter 関数のエンドツーエンドの動作をテストできます。

以下に、テーブル駆動テストを用いたリファクタリング案を示します。この変更により、テストの可読性が向上し、さまざまなケースを網羅しやすくなります。

注: この変更には reflectsort パッケージのインポートが必要です。

func TestFilter_WindowsExtensionStripping(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("PATH", dir)

	executables := []string{"curl.exe", "git.exe", "jq.exe"}
	for _, name := range executables {
		path := filepath.Join(dir, name)
		if err := os.WriteFile(path, []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 // Pass: both are empty (nil or empty slice)
			}

			sort.Strings(got)
			sort.Strings(tc.want)
			if !reflect.DeepEqual(got, tc.want) {
				t.Errorf("Filter() = %v, want %v", got, tc.want)
			}
		})
	}
}

Loading