Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detec
| Category | Examples |
| -------------------- | ---------------------------------------------------------------------------------------- |
| IDEs & Desktop Apps | VS Code, Cursor, Windsurf, Antigravity, Zed, Claude, Copilot, JetBrains suite (13 IDEs), Eclipse, Android Studio |
| AI CLI Tools | Claude Code, Codex, Gemini CLI, Kiro, GitHub Copilot CLI, Aider, OpenCode, Cursor Agent |
| AI CLI Tools | Claude Code, Codex, Gemini CLI, Kiro, GitHub Copilot CLI, Aider, PatchWarden, OpenCode, Cursor Agent |
| AI Agents | Claude Cowork, OpenClaw, ClawdBot, GPT-Engineer |
| AI Frameworks | Ollama, LM Studio, LocalAI, Text Generation WebUI |
| MCP Server Configs | Claude Desktop, Claude Code, Cursor, Windsurf, Antigravity, Zed, Open Interpreter, Codex |
Expand Down
4 changes: 4 additions & 0 deletions SCAN_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ Detection is cross-platform — binaries are located via `$PATH` lookup and home
| GitHub Copilot CLI | Microsoft | `copilot`, `gh-copilot` | `~/.config/github-copilot` |
| Microsoft AI Shell | Microsoft | `aish`, `ai` | `~/.aish` |
| Aider | OpenSource| `aider` | `~/.aider` |
| PatchWarden | OpenSource| `patchwarden` | — |
| OpenCode | OpenSource| `opencode` | `~/.config/opencode` |
| Cursor Agent | Cursor | `cursor-agent` | `~/.cursor` |

PatchWarden detection is binary-first. A repository `.patchwarden/` directory
may contain stale task artifacts and is not treated as installation evidence.

## General-Purpose AI Agents

Detection is cross-platform — home-relative paths and `$PATH` lookups work on macOS, Windows, and Linux.
Expand Down
1 change: 1 addition & 0 deletions docs/adding-detections.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ The CLI tool detector uses a `cliToolSpec` struct:
| `Binaries` | Binary names to search for in `$PATH`, or home-relative paths (use `~` prefix) |
| `ConfigDirs` | Config directory paths to check (use `~` for home directory) |
| `VersionFlag` | Override the default `--version` flag (e.g., `-v`) |
| `MetadataOnly` | Set to `true` when the binary must never be executed for version discovery; missing metadata reports `unknown` |
| `VerifyFunc` | Optional function to verify the binary is the correct tool (e.g., for generic names like `q`) |

### Example: Adding a hypothetical "DevPilot" CLI
Expand Down
22 changes: 16 additions & 6 deletions internal/detector/aicli.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import (
)

type cliToolSpec struct {
Name string
Vendor string
Binaries []string // binary names or paths (~ expanded at runtime)
ConfigDirs []string // config directory candidates (~ expanded)
VersionFlag string // flag to get version; defaults to "--version"
VerifyFunc func(ctx context.Context, exec executor.Executor, log *progress.Logger, binary string) bool
Name string
Vendor string
Binaries []string // binary names or paths (~ expanded at runtime)
ConfigDirs []string // config directory candidates (~ expanded)
VersionFlag string // flag to get version; defaults to "--version"
MetadataOnly bool // never execute the binary for version discovery
VerifyFunc func(ctx context.Context, exec executor.Executor, log *progress.Logger, binary string) bool
}

var cliToolDefinitions = []cliToolSpec{
Expand Down Expand Up @@ -104,6 +105,12 @@ var cliToolDefinitions = []cliToolSpec{
Binaries: []string{"aider"},
ConfigDirs: []string{"~/.aider"},
},
{
Name: "patchwarden",
Vendor: "OpenSource",
Binaries: []string{"patchwarden"},
MetadataOnly: true,
},
{
Name: "opencode",
Vendor: "OpenSource",
Expand Down Expand Up @@ -236,6 +243,9 @@ func (d *AICLIDetector) getVersion(ctx context.Context, spec cliToolSpec, binary
if v := versionmeta.FromBinary(ctx, d.exec, binaryPath); v != "" {
return v
}
if spec.MetadataOnly {
return "unknown"
}
flag := "--version"
if spec.VersionFlag != "" {
flag = spec.VersionFlag
Expand Down
108 changes: 108 additions & 0 deletions internal/detector/aicli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,114 @@ func TestAICLIDetector_FindsCursorAgent(t *testing.T) {
}
}

func TestAICLIDetector_FindsPatchWardenFromNPMMetadata(t *testing.T) {
mock := executor.NewMock()
shim := "/usr/local/bin/patchwarden"
target := "/usr/local/lib/node_modules/patchwarden/dist/index.js"
pkgRoot := "/usr/local/lib/node_modules/patchwarden"
mock.SetPath("patchwarden", shim)
mock.SetSymlink(shim, target)
mock.SetFile(pkgRoot+"/package.json", []byte(`{"name":"patchwarden","version":"1.6.2"}`))

det := NewAICLIDetector(mock)
results := det.Detect(context.Background())

var got *model.AITool
for i, result := range results {
if result.Name == "patchwarden" {
got = &results[i]
break
}
}
if got == nil {
t.Fatal("patchwarden not found")
}
if got.Vendor != "OpenSource" {
t.Errorf("expected vendor OpenSource, got %s", got.Vendor)
}
if got.Type != "cli_tool" {
t.Errorf("expected type cli_tool, got %s", got.Type)
}
if got.Version != "1.6.2" {
t.Errorf("expected version 1.6.2 from npm metadata, got %s", got.Version)
}
if got.BinaryPath != shim {
t.Errorf("expected binary_path %s, got %s", shim, got.BinaryPath)
}
if got.InstallPath != pkgRoot {
t.Errorf("expected install_path %s, got %s", pkgRoot, got.InstallPath)
}
if got.ConfigDir != "" {
t.Errorf("expected no user-level config dir, got %s", got.ConfigDir)
}
}

func TestAICLIDetector_PatchWardenSkipsVersionExecutionWithoutMetadata(t *testing.T) {
mock := executor.NewMock()
bin := "/usr/local/bin/patchwarden"
mock.SetPath("patchwarden", bin)
// PatchWarden is an MCP stdio entrypoint, not a conventional --version CLI.
// If the detector executes this stub, it would incorrectly report 9.9.9.
mock.SetCommand("9.9.9\n", "", 0, bin, "--version")

det := NewAICLIDetector(mock)
results := det.Detect(context.Background())

for _, result := range results {
if result.Name == "patchwarden" {
if result.Version != "unknown" {
t.Errorf("expected metadata-only version to remain unknown, got %s", result.Version)
}
return
}
}
t.Fatal("patchwarden not found")
}

func TestAICLIDetector_FindsPatchWardenWindowsNPMShim(t *testing.T) {
mock := executor.NewMock()
mock.SetGOOS(model.PlatformWindows)
shim := `C:\Users\testuser\AppData\Roaming\npm\patchwarden.cmd`
pkgRoot := `C:\Users\testuser\AppData\Roaming\npm\node_modules\patchwarden`
mock.SetPath("patchwarden", shim)
mock.SetFile(shim, []byte(`"%_prog%" "%dp0%\node_modules\patchwarden\dist\index.js" %*`))
mock.SetFile(pkgRoot+`\package.json`, []byte(`{"name":"patchwarden","version":"1.6.2"}`))

det := NewAICLIDetector(mock)
results := det.Detect(context.Background())

for _, result := range results {
if result.Name != "patchwarden" {
continue
}
if result.Version != "1.6.2" {
t.Errorf("expected version 1.6.2 from Windows npm metadata, got %s", result.Version)
}
if result.InstallPath != pkgRoot {
t.Errorf("expected install_path %s, got %s", pkgRoot, result.InstallPath)
}
if result.ConfigDir != "" {
t.Errorf("expected no user-level config dir, got %s", result.ConfigDir)
}
return
}
t.Fatal("patchwarden not found via Windows npm shim")
}

func TestAICLIDetector_DoesNotTreatPatchWardenArtifactsAsInstallation(t *testing.T) {
mock := executor.NewMock()
mock.SetDir("/Users/testuser/.patchwarden")

det := NewAICLIDetector(mock)
results := det.Detect(context.Background())

for _, result := range results {
if result.Name == "patchwarden" {
t.Fatalf("stale .patchwarden artifacts must not prove installation: %+v", result)
}
}
}

// TestAICLIDetector_ResolvesNpmInstallPath asserts that when the binary on
// PATH is a symlink to a node_modules package (the standard layout for
// claude-code, codex, opencode, etc.), the detector surfaces both the shim
Expand Down