diff --git a/README.md b/README.md index 7bcc9b2..73c5d25 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index ee0eba0..c0516c8 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -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. diff --git a/docs/adding-detections.md b/docs/adding-detections.md index 466b972..99bdbcd 100644 --- a/docs/adding-detections.md +++ b/docs/adding-detections.md @@ -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 diff --git a/internal/detector/aicli.go b/internal/detector/aicli.go index 3626d45..81ae47a 100644 --- a/internal/detector/aicli.go +++ b/internal/detector/aicli.go @@ -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{ @@ -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", @@ -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 diff --git a/internal/detector/aicli_test.go b/internal/detector/aicli_test.go index 2375f19..c09e471 100644 --- a/internal/detector/aicli_test.go +++ b/internal/detector/aicli_test.go @@ -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