From 3616385c909369b10af1e8a73bdb31b08b8fe81d Mon Sep 17 00:00:00 2001 From: Codefred Date: Wed, 22 Jul 2026 05:37:18 +0100 Subject: [PATCH 01/10] fix: credit plugin-skill usage across prefix mismatches (review finding 1) --- src/miner.ts | 47 +++++++++++++++++++++++++++++++-- test/miner.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/miner.ts b/src/miner.ts index cef5e75..871aa9e 100644 --- a/src/miner.ts +++ b/src/miner.ts @@ -122,18 +122,61 @@ export async function mineUsage(opts: MineOptions): Promise { return { totalSessions, items }; } +/** `skill:` / `agent:` id → its bare name (text after the last colon), else null. */ +function bareName(id: string): string | null { + const unprefixed = id.split('/').pop() ?? id; + const match = /^(skill|agent):(.+)$/.exec(unprefixed); + if (match === null) return null; + const name = match[2] as string; + return `${match[1]}:${name.split(':').pop() ?? name}`; +} + /** * Every inventory item gets a usage entry; items never seen get zeros — * that's the dead-weight signal, not an error (spec §4.2). Usage entries * for items no longer installed are kept. + * + * Plugin skills can be invoked under a different prefix than the plugin + * cache directory they were scanned from (e.g. dir `vercel-plugin` vs + * invocation `vercel:deploy`). When an unmatched usage id and exactly one + * zero-usage inventory item share a bare name, the usage is credited to + * that item; with two or more candidates we never guess. */ export function mergeUsage(inventory: Inventory, usage: Usage): Usage { const items: Record = {}; for (const item of inventory.items) { items[item.id] = usage.items[item.id] ?? { count: 0, lastUsed: null, sessionsSeen: 0 }; } - for (const [id, entry] of Object.entries(usage.items)) { - if (!(id in items)) items[id] = entry; + + const unmatched = Object.entries(usage.items).filter(([id]) => !(id in items)); + + // Bare name → inventory ids that could claim it (only exact-miss items). + const candidates = new Map(); + for (const item of inventory.items) { + if (usage.items[item.id] !== undefined) continue; + const bare = bareName(item.id); + if (bare === null) continue; + candidates.set(bare, [...(candidates.get(bare) ?? []), item.id]); } + + for (const [id, entry] of unmatched) { + const bare = bareName(id); + const claimants = bare !== null ? (candidates.get(bare) ?? []) : []; + if (claimants.length === 1) { + const target = claimants[0] as string; + const existing = items[target] as UsageEntry; + items[target] = { + count: existing.count + entry.count, + lastUsed: + existing.lastUsed !== null && existing.lastUsed > (entry.lastUsed ?? '') + ? existing.lastUsed + : entry.lastUsed, + sessionsSeen: existing.sessionsSeen + entry.sessionsSeen, + }; + } else { + items[id] = entry; + } + } + return { totalSessions: usage.totalSessions, items }; } diff --git a/test/miner.test.ts b/test/miner.test.ts index f07eb98..af5e5a8 100644 --- a/test/miner.test.ts +++ b/test/miner.test.ts @@ -108,4 +108,69 @@ describe('mergeUsage', () => { sessionsSeen: 1, }); }); + + it('credits prefixed plugin skills when transcripts use a different prefix', () => { + const inventory: Inventory = { + items: [ + { + id: 'skill:vercel-plugin:deploy', + kind: 'skill', + name: 'vercel-plugin:deploy', + description: null, + sourcePath: '/x', + sizeBytes: 1, + }, + ], + }; + const usage = { + totalSessions: 2, + items: { + 'skill:vercel:deploy': { count: 3, lastUsed: '2026-07-01T00:00:00.000Z', sessionsSeen: 2 }, + }, + }; + + const merged = mergeUsage(inventory, usage); + + expect(merged.items['skill:vercel-plugin:deploy']).toEqual({ + count: 3, + lastUsed: '2026-07-01T00:00:00.000Z', + sessionsSeen: 2, + }); + expect(merged.items['skill:vercel:deploy']).toBeUndefined(); + }); + + it('never guesses when two inventory items share a bare skill name', () => { + const inventory: Inventory = { + items: [ + { + id: 'skill:plugin-a:deploy', + kind: 'skill', + name: 'plugin-a:deploy', + description: null, + sourcePath: '/a', + sizeBytes: 1, + }, + { + id: 'skill:plugin-b:deploy', + kind: 'skill', + name: 'plugin-b:deploy', + description: null, + sourcePath: '/b', + sizeBytes: 1, + }, + ], + }; + const usage = { + totalSessions: 1, + items: { + 'skill:other:deploy': { count: 5, lastUsed: '2026-07-01T00:00:00.000Z', sessionsSeen: 1 }, + }, + }; + + const merged = mergeUsage(inventory, usage); + + expect(merged.items['skill:plugin-a:deploy'].count).toBe(0); + expect(merged.items['skill:plugin-b:deploy'].count).toBe(0); + expect(merged.items['skill:other:deploy'].count).toBe(5); + }); }); From 93ab23157830fbfbe13daf062bd97c2f34009c80 Mon Sep 17 00:00:00 2001 From: Codefred Date: Wed, 22 Jul 2026 05:38:53 +0100 Subject: [PATCH 02/10] refactor: adapter interface v2 with detect() and registry --- .gitignore | 1 + SPEC_V2.md | 137 +++++++++++ .../2026-07-22-v2-multi-tool-adapters.md | 215 ++++++++++++++++++ src/adapter.ts | 45 +++- src/cli.ts | 12 +- src/types.ts | 15 ++ test/adapter.test.ts | 33 ++- 7 files changed, 442 insertions(+), 16 deletions(-) create mode 100644 SPEC_V2.md create mode 100644 docs/superpowers/plans/2026-07-22-v2-multi-tool-adapters.md diff --git a/.gitignore b/.gitignore index 7f08cbc..d9160db 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ atlas.html *.tgz launch-drafts.md +devto-article.md diff --git a/SPEC_V2.md b/SPEC_V2.md new file mode 100644 index 0000000..6fadcf4 --- /dev/null +++ b/SPEC_V2.md @@ -0,0 +1,137 @@ +# Agent Atlas — v2 Spec: Multi-Tool Adapters + +**Builds on:** `SPEC.md` (v1, shipped) — this spec covers the headline item deliberately cut from v1 (§9): support for AI assistants beyond Claude Code. +**Date:** 2026-07-22 + +--- + +## 1. Goal + +v1 answers "what can my Claude Code setup do?" v2 answers **"what can my whole AI setup do?"** — one map across every AI coding assistant installed on the machine, plus the ORGN workspace, with cross-tool insights no single-tool view can give: + +- *"You have the GitHub MCP server installed in four different tools — it fires in one of them."* +- *"Your Cursor setup is 80% engineering; your Claude Code setup carries all your writing and research capability."* +- *"Three tools have overlapping rules files that say different things."* + +The honest boundary from v1 stands: **we can only map tools that keep their data where we can read it** (local files, or an API the user authorizes). Web-hosted assistants with server-side config (ChatGPT web, claude.ai, Gemini web) remain out of scope — there is nothing local to scan and no API that exposes installed connectors + usage. + +## 2. Prerequisites (before any v2 work) + +1. **Fix `report.md` findings 1 and 2** (plugin-skill usage attribution; project-scoped MCP servers in `~/.claude.json`). v2 multiplies inventory sources; attribution bugs multiply with them. +2. **One real-machine validation run** of v1 (`--json` against the real home dir) recorded as a golden reference. + +## 3. Supported tools (the adapter roster) + +Tiered by data quality. Every adapter is **verify-first**: config paths below are the implementation starting point, but each adapter's first task is confirming paths/formats against a real install and encoding them into fixtures — these tools change their layouts without notice. + +### Tier A — full support (inventory + usage) + +| Tool | Inventory sources | Usage source | +|---|---|---| +| **Claude Code** (v1, baseline) | `~/.claude` skills/agents/plugins, `~/.claude.json`, settings | `~/.claude/projects/*/*.jsonl` transcripts | +| **OpenAI Codex CLI** | `~/.codex/config.toml` (MCP servers, profiles), `AGENTS.md` (global + project) | `~/.codex/sessions/**/*.jsonl` session logs | +| **ORGN CDE** (OpenCode-based) | `~/.config/orgn/opencode.jsonc` (agents, MCP servers, commands, models via ORGN Gateway), `AGENTS.md` rules | `~/.local/share/orgn/opencode.db` — local SQLite session store (read-only queries) | +| **OpenCode** (vanilla) | `~/.config/opencode/opencode.json(c)` — same format as ORGN CDE | `~/.local/share/opencode/opencode.db` — shared adapter code with ORGN CDE | + +### Tier B — inventory + partial or no usage + +| Tool | Inventory sources | Usage caveat | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json`, project `.cursor/mcp.json`, `.cursor/rules/*.mdc`, `.cursorrules` | No reliably parseable local usage log — ships **inventory-only**; nodes render at uniform size with a "usage unavailable" badge | +| **Gemini CLI** | `~/.gemini/settings.json` (mcpServers), `GEMINI.md`, extensions dir | Local logs exist but format is unstable — attempt, degrade to inventory-only | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json`, rules files | Inventory-only | + +### Tier C — explicitly out (say so in the README) + +ChatGPT web / claude.ai / Gemini web (server-side config, no local data); VS Code Copilot (extension-internal storage, no stable surface); anything requiring scraping an app's private database. Listed in the README as "why not X" — honesty is part of the product's trust story. + +**Adding a tool later** = one new adapter file + fixtures + a roster entry. That's the whole point of the interface. + +## 4. Architecture changes + +### 4.1 Adapter interface (generalized from v1) + +v1's `ToolAdapter` assumed local filesystem + `homeDir`. v2 generalizes: + +```ts +interface ToolAdapter { + name: string; // 'claude-code' | 'codex' | 'cursor' | 'orgn' | ... + displayName: string; + usageSupport: 'full' | 'partial' | 'none'; + detect(ctx: AdapterContext): Promise; // is this tool present/configured? + scan(ctx: AdapterContext): Promise; + mineUsage(ctx: AdapterContext): Promise; // returns {totalSessions: 0, items: {}} when unsupported +} +``` + +- `AdapterContext` carries `homeDir`, `projectDir`, `days`, and (new) optional per-adapter config from `~/.agent-atlas/config.json` — this is where the ORGN API token lives. +- **Item ids become tool-namespaced:** `claude-code/skill:git-workflow`, `codex/mcp:github`. Every `InventoryItem` gains a `tool` field. This prevents cross-tool id collisions and makes attribution unambiguous. (v1 ids migrate to the `claude-code/` prefix — a breaking change to the `--json` shape; bump to 0.2.0 and note it.) +- **`detect()` before `scan()`:** the CLI runs every registered adapter's `detect()`, scans only the present ones, and reports "found: Claude Code, Codex, Cursor" so the user sees coverage explicitly. + +### 4.2 The ORGN CDE adapter (OpenCode family) + +ORGN CDE is built on OpenCode, so it is a **local, file-based Tier A adapter** — verified on a real install (2026-07-22): + +- **Inventory:** `~/.config/orgn/opencode.jsonc` — OpenCode-format config carrying agents, MCP servers, commands, and model/provider setup (ORGN Gateway); plus `AGENTS.md` rules files (global + project) in the "context, not classified" bucket. +- **Usage:** `~/.local/share/orgn/opencode.db` — a local SQLite session store. The adapter opens it **read-only** (SQLite `mode=ro`, and copy-on-read if the DB is locked by a running CDE) and counts tool/agent/MCP invocations per session within the window. `better-sqlite3` or `node:sqlite` — pick at implementation time; schema survey is the milestone's first task. +- **Shared OpenCode core:** the parser for config + DB lives in an `opencode-family` module; the vanilla **OpenCode** adapter (`~/.config/opencode`, `~/.local/share/opencode`) is the same code with different paths. Two adapters for the price of one. +- **Optional workspace enrichment (stretch, not required for M7):** the ORGN studio API (the same surface the `ask-orgn` MCP server uses) can add workspace-level data — registered agents, task runs across the team. Auth via token in `~/.agent-atlas/config.json`, never a CLI flag. Without it, the adapter is fully functional on local data alone. +- **Privacy note:** the ORGN config contains team/user ids and the DB contains session content — same rule as v1 transcripts: mined locally, never sent to the classification API (only names/descriptions go out). + +### 4.3 Classifier — unchanged, one addition + +The classifier is already tool-agnostic (it sees name + description + kind). One addition: **rules files** (`.cursorrules`, `AGENTS.md`, `GEMINI.md`, `.cursor/rules/*.mdc`) join `CLAUDE.md` in the "memory/context, counted but not classified" bucket from v1 — same treatment, new sources. + +### 4.4 Renderer additions + +- **Tool badge on every node** (small icon/color ring) + a **tool filter** in the legend panel alongside the kind filter. +- **Per-tool tuning bars:** the header gains a "by tool" view — one mini tuning bar per detected tool, stacked. This is the "Cursor is all engineering, Claude Code carries your writing" insight in one glance. +- **Uniform-size rendering for usage-less tools** with a visible "usage unavailable" badge — never fake sizes, never grey them (grey means *never used*, which we can't claim without usage data). + +### 4.5 Cross-tool diagnostics (the v2 payoff) + +Three new diagnostic lists, alongside v1's three: + +1. **Cross-tool duplicates:** the same MCP server (matched by command/URL, not just name) installed in N tools. Rendered with usage contrast where available: *"`github` MCP is installed in Claude Code, Codex, and Cursor — it has only ever fired in Claude Code."* +2. **Capability imbalance:** per-tool tuning profiles diverging sharply — surfaced as a plain-English line, not a judgment: *"All research capability lives in Claude Code; Codex and Cursor have none."* +3. **Conflicting rules files:** rules/context files across tools whose instructions overlap in topic (classifier similarity on their summaries) — flagged for human review only, no automated conflict detection: *"`.cursorrules` and `CLAUDE.md` both give code-style instructions — worth checking they agree."* + +Dead-weight token math stays per-tool (context cost is per-session *within* a tool). + +## 5. CLI surface + +```bash +npx agent-atlas # auto-detect all tools, unified map +npx agent-atlas --tool codex # restrict to one tool (repeatable) +npx agent-atlas --list-tools # show detected tools + data quality, then exit +``` + +`--json` output gains `tools: [{name, detected, usageSupport, itemCount}]` and the namespaced ids. + +## 6. What stays cut (v3+) + +- Recommendations / auto-fix ("install X to fill this gap") — still reporting-only +- Hosted web version, accounts, **team aggregation** (note: the ORGN adapter is the natural seed for the team version discussed for the paid tier — but v2 keeps it single-user) +- Historical trends over time +- Windows beyond best-effort paths + +## 7. Milestones + +| # | Feature | Done when | +|---|---|---| +| M5 | Adapter interface v2 + tool-namespaced ids + `detect()`/`--list-tools` + tool badges/filter in renderer | v1 behavior identical through the new interface; fixtures green; `--list-tools` correct on the real machine | +| M6 | Codex adapter (full) + Cursor adapter (inventory-only) | Fixture trees for both; real-machine verification of paths/formats recorded in the fixture README; Codex usage counts match a hand-checked session log | +| M7 | ORGN CDE + vanilla OpenCode adapters (shared `opencode-family` module) | SQLite schema survey doc committed; inventory + session usage render from the real `~/.config/orgn` + `opencode.db`; read-only DB access verified against a running CDE | +| M8 | Gemini CLI + Windsurf adapters + cross-tool diagnostics + per-tool tuning bars | All three cross-tool diagnostic lists render with real numbers; unified map on the real machine shows ≥3 tools | + +Sequencing rationale: M5 is pure refactor (riskiest to delay), M6 proves the interface on the two most-requested tools, M7 is the showcase tie-in, M8 is the payoff layer. Each milestone is demo-able — M6 alone yields the "one map, three tools" screenshot that headlines the v2 announcement. + +## 8. Risks + +| Risk | Mitigation | +|---|---| +| Config paths/formats drift per tool version | Verify-first rule per adapter; fixtures encode the verified format; parsers tolerate unknown fields; degrade to partial inventory rather than crash | +| Usage claims on partial data mislead | `usageSupport` surfaced in UI and README; uniform-size + badge for usage-less tools; never grey without data | +| OpenCode DB schema undocumented / may change between versions | M7 starts with a schema survey against the real DB; queries tolerate missing tables (degrade to inventory-only); DB opened read-only so a schema surprise can never corrupt anything | +| Id migration breaks v1 consumers of `--json` | Version bump to 0.2.0, changelog note, `tool` field additive | +| Scope creep ("support everything") | Roster is fixed for v2; new tools are v2.x point releases, one adapter each | diff --git a/docs/superpowers/plans/2026-07-22-v2-multi-tool-adapters.md b/docs/superpowers/plans/2026-07-22-v2-multi-tool-adapters.md new file mode 100644 index 0000000..fe5c0b3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-v2-multi-tool-adapters.md @@ -0,0 +1,215 @@ +# Agent Atlas v2 — Multi-Tool Adapters Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend Agent Atlas from Claude-Code-only to a unified map across Codex CLI, Cursor, ORGN CDE, and vanilla OpenCode, with cross-tool diagnostics (SPEC_V2.md M5–M8). + +**Architecture:** Generalize the v1 `ToolAdapter` into a registry of detectable adapters with tool-namespaced item ids (`/:`). Each adapter is a self-contained module + fixture tree + test file; the classifier is untouched; the renderer gains tool badges/filters and per-tool tuning; a new cross-tool diagnostics module consumes the merged inventory. + +**Tech Stack:** Node 20+/TypeScript ESM (existing), vitest golden-fixture tests (existing), `smol-toml` (new dep, Codex config), `node:sqlite` (built-in, ORGN CDE/OpenCode session DB — guarded, degrade to inventory-only on older Node). + +## Global Constraints + +- Branch: `feat/v2-multi-tool-adapters` off current main (`47e52c1`); commit per task; conventional-commit messages. +- All work read-only against user machines: SQLite opened `readOnly: true`; never write outside the repo/`--out`/atlas dir. +- Fixtures only in tests — no test may touch the real `~/.claude`, `~/.codex`, `~/.cursor`, `~/.config/orgn`. +- Privacy invariant (SPEC.md §7): only item names/descriptions ever reach the classification API. Session/transcript/DB content is mined locally only. +- Id format v2: `/:` (e.g. `claude-code/skill:git-workflow`). `InventoryItem.tool` is required. `--json` shape change ⇒ version bump to 0.2.0 in the final task, not before. +- Existing 64 tests must stay green after every task (updated expectations allowed in Task 2 only, where ids change). +- Usage honesty: adapters with `usageSupport: 'none'` return `{totalSessions: 0, items: {}}`; renderer must badge, never grey, their nodes. + +--- + +### Task 0: Branch + prereq fix — plugin-skill usage fallback (report.md finding 1) + +**Files:** +- Modify: `src/miner.ts` (add fallback matching), `src/scanner.ts` (no change expected — read only) +- Test: `test/miner.test.ts` + +**Interfaces:** +- Consumes: v1 `mineUsage(opts)`, `mergeUsage(inventory, usage)`. +- Produces: `mergeUsage` gains a third optional param `inventory`-aware bare-name fallback: a usage entry `skill:X` with no inventory match is credited to the unique inventory item whose id ends with `:X` or `:` when exactly one candidate exists. + +- [ ] **Step 1:** `git checkout -b feat/v2-multi-tool-adapters` +- [ ] **Step 2: Failing test** in `test/miner.test.ts`: + +```ts +it('credits prefixed plugin skills when transcripts use a different prefix', () => { + const inventory = { items: [{ id: 'skill:vercel-plugin:deploy', kind: 'skill' as const, name: 'vercel-plugin:deploy', description: null, sourcePath: '/x', sizeBytes: 1 }] }; + const usage = { totalSessions: 2, items: { 'skill:vercel:deploy': { count: 3, lastUsed: '2026-07-01T00:00:00.000Z', sessionsSeen: 2 } } }; + const merged = mergeUsage(inventory, usage); + expect(merged.items['skill:vercel-plugin:deploy'].count).toBe(3); +}); +``` + +- [ ] **Step 3:** Run `npm test -- miner` → FAIL (count 0). +- [ ] **Step 4:** Implement in `mergeUsage`: after exact-id pass, for each unmatched usage id of shape `skill::` (or bare `skill:`), find inventory ids matching `/^skill:(.+:)?$/` — if exactly one and it has zero exact usage, transfer the entry (sum counts if multiple usage ids map to it, keep max lastUsed). Same rule for `agent:` ids. Never guess when ≥2 candidates. +- [ ] **Step 5:** `npm test` → all green. Commit: `fix: credit plugin-skill usage across prefix mismatches (review finding 1)`. + +### Task 1 (M5): Adapter interface v2 + registry + +**Files:** +- Modify: `src/adapter.ts` (interface + registry), `src/types.ts` (AdapterContext), `src/cli.ts` (consume registry) +- Test: `test/adapter.test.ts` + +**Interfaces (produced — all later tasks depend on these exact shapes):** + +```ts +// src/types.ts +export interface AdapterContext { + homeDir: string; + projectDir?: string; + days: number; + now?: Date; + /** Per-adapter config from ~/.agent-atlas/config.json, keyed by adapter name. */ + config?: Record; +} +export type UsageSupport = 'full' | 'partial' | 'none'; + +// src/adapter.ts +export interface ToolAdapter { + name: string; // 'claude-code' | 'codex' | 'cursor' | 'orgn-cde' | 'opencode' + displayName: string; + usageSupport: UsageSupport; + detect(ctx: AdapterContext): Promise; + scan(ctx: AdapterContext): Promise; + mineUsage(ctx: AdapterContext): Promise; +} +export const adapters: ToolAdapter[]; // registry, claude-code first +export async function detectAdapters(ctx: AdapterContext): Promise; +``` + +- [ ] **Step 1: Failing tests** — claude-code adapter satisfies the new interface; `detect()` true iff `/.claude` exists (fixture home → true; empty tmp dir → false); `detectAdapters` returns only detected. +- [ ] **Step 2:** Wrap v1 `scan`/`mineUsage` in `claudeCodeAdapter` implementing the new interface (`usageSupport: 'full'`; `detect` = `fs.stat(join(homeDir, '.claude'))` truthy). CLI builds one `AdapterContext` and iterates `await detectAdapters(ctx)`. +- [ ] **Step 3:** `npm test` green; `node dist/cli.js --json --home fixtures/home --project fixtures/project` output byte-identical to pre-task (ids unchanged in this task). Commit: `refactor: adapter interface v2 with detect() and registry`. + +### Task 2 (M5): Tool-namespaced ids + `tool` field + +**Files:** +- Modify: `src/types.ts` (`InventoryItem.tool: string`), `src/scanner.ts` (id prefix), `src/miner.ts` (prefix usage ids incl. fallback logic), `src/cli.ts`, `src/diagnostics.ts` (no logic change — ids opaque), `src/renderer/assets/app.js` (ids opaque — verify only) +- Test: every existing test file's expected ids; `fixtures/expected-classifications.json` keys + +**Interfaces:** +- Produces: all ids are `/:`. The **adapter** applies the prefix: `scan()`/`mineUsage()` in `adapter.ts` wrap the raw v1 functions and rewrite ids (`prefixIds(inv, 'claude-code')`), so `scanner.ts`/`miner.ts` stay tool-agnostic and reusable by other adapters' internals. + +- [ ] **Step 1:** Add `prefixInventory(inv: Inventory, tool: string): Inventory` and `prefixUsage(usage: Usage, tool: string): Usage` helpers in `src/adapter.ts` (pure functions: `id = `${tool}/${id}``, set `item.tool = tool`). Unit-test both directly. +- [ ] **Step 2:** Apply in `claudeCodeAdapter.scan/mineUsage`. Update every test expectation and `expected-classifications.json` keys to `claude-code/…`. The Task 0 fallback regex must operate on the un-prefixed tail (`id.split('/').pop()`). +- [ ] **Step 3:** `npm test` green. Real-machine smoke: `node dist/cli.js --json | head` shows `claude-code/` ids. Commit: `feat!: tool-namespaced item ids and InventoryItem.tool`. + +### Task 3 (M5): `--list-tools` + `--tool` filter + +**Files:** +- Modify: `src/cli.ts` +- Test: `test/cli.test.ts` + +**Interfaces:** +- Produces: `--list-tools` prints one line per registered adapter: `name displayName detected|not detected usage:full|partial|none` and exits 0. `--tool ` (repeatable) restricts scanning to named adapters (error 1 + list of valid names on unknown). `--json` gains `tools: [{name, displayName, detected, usageSupport, itemCount}]`. + +- [ ] **Step 1:** Failing CLI tests (spawn `dist/cli.js` as existing cli tests do): `--list-tools` against fixture home lists `claude-code … detected`; `--tool nope` exits 1; `--json` contains `tools` array. +- [ ] **Step 2:** Implement; `itemCount` computed post-scan (0 for undetected/skipped). +- [ ] **Step 3:** Green; commit: `feat: --list-tools, --tool filter, tools array in --json`. + +### Task 4 (M5): Renderer — tool badges + tool filter + +**Files:** +- Modify: `src/renderer/assets/app.js`, `src/renderer/assets/style.css`, `src/renderer/index.ts` (pass `tools` metadata into AtlasData), `src/types.ts` (`AtlasData.tools`) +- Test: `test/renderer.test.ts` + +**Interfaces:** +- Consumes: `item.tool`, `AtlasData.tools: {name, displayName, usageSupport}[]`. +- Produces: node stroke color keyed by tool (deterministic palette by roster order; single-tool maps render exactly as v1 — no visual regression); "Tools" filter group in `#filters` (same checkbox pattern as kind filters); nodes from `usageSupport==='none'` adapters render at fixed radius 9 with class `no-usage` (dashed stroke) and tooltip line "usage unavailable for ", never `DEAD` grey; guard from report finding 3: `renderTuning` early-returns with an `.empty-state` message when shares are null. + +- [ ] **Step 1:** Failing renderer tests: embedded JSON contains `tools`; HTML contains `id="tool-filters"`; empty-inventory AtlasData renders (no throw) and contains `class="empty-state"`. +- [ ] **Step 2:** Implement (data assertions, not pixels, per SPEC.md §7). +- [ ] **Step 3:** Green; open `atlas.html` from real machine once to eyeball. Commit: `feat: tool badges, tool filter, usage-unavailable + empty states in renderer`. + +### Task 5 (M6): Codex CLI adapter (full usage) + +**Files:** +- Create: `src/adapters/codex.ts`, `fixtures/codex-home/.codex/config.toml`, `fixtures/codex-home/.codex/sessions/2026/07/session1.jsonl`, `fixtures/codex-home/AGENTS.md` +- Modify: `src/adapter.ts` (register), `package.json` (`smol-toml`) +- Test: `test/codex.test.ts` + +**Interfaces:** +- Consumes: `AdapterContext`, `prefixInventory`/`prefixUsage`, `parseFrontmatter` if needed. +- Produces: adapter `{name: 'codex', displayName: 'Codex CLI', usageSupport: 'full'}`; ids `codex/mcp:`, `codex/memory:AGENTS.md (global|project)`; detect = `~/.codex/config.toml` exists. + +**Verify-first (SPEC_V2 §3):** before coding, run `cat ~/.codex/config.toml 2>/dev/null | head -30; ls ~/.codex/sessions 2>/dev/null | head` on the real machine. If Codex is not installed locally, fixtures follow the documented format: `[mcp_servers.]` tables with `command`/`args` or `url`; sessions = JSONL with `{"timestamp": iso, "type": "response_item", "payload": {"type": "function_call", "name": ""}}`-style records. Encode whatever is verified into the fixture README comment. + +- [ ] **Step 1:** Write fixture files (≥2 MCP servers, one AGENTS.md, one session log with 3 tool calls incl. one MCP-prefixed name and one out-of-window timestamp). +- [ ] **Step 2:** Failing tests: `detect` true/false; `scan` yields exactly the fixture servers + AGENTS.md memory items with correct `sourcePath`/`sizeBytes`; `mineUsage` respects `days` window and attributes MCP calls to `codex/mcp:`; corrupt TOML → empty inventory, no throw. +- [ ] **Step 3:** Implement with `smol-toml` `parse()` wrapped in try/catch; sessions streamed line-by-line (reuse the readline pattern from `src/miner.ts`). +- [ ] **Step 4:** Green; commit: `feat: Codex CLI adapter (config.toml inventory + session-log usage)`. + +### Task 6 (M6): Cursor adapter (inventory-only) + +**Files:** +- Create: `src/adapters/cursor.ts`, `fixtures/cursor-home/.cursor/mcp.json`, `fixtures/cursor-project/.cursor/mcp.json`, `fixtures/cursor-project/.cursor/rules/style.mdc`, `fixtures/cursor-project/.cursorrules` +- Modify: `src/adapter.ts` (register) +- Test: `test/cursor.test.ts` + +**Interfaces:** +- Produces: `{name: 'cursor', displayName: 'Cursor', usageSupport: 'none'}`; ids `cursor/mcp:`, `cursor/memory:`; `mineUsage` returns `{totalSessions: 0, items: {}}`; detect = `~/.cursor` dir exists. `mcp.json` shape is the same `mcpServers` record v1 already parses — export `mcpItems` from `src/scanner.ts` (make it a named export) and reuse; project entries win name-dedup and are attributed to the project path (fixes report finding 6b direction for this adapter). + +- [ ] **Step 1:** Fixtures (global + project server with one name collision; one `.mdc` rule with frontmatter `description:`; one `.cursorrules`). +- [ ] **Step 2:** Failing tests → implement → green. +- [ ] **Step 3:** Commit: `feat: Cursor adapter (inventory-only, mcp.json + rules files)`. + +### Task 7 (M7): OpenCode family — ORGN CDE + vanilla OpenCode + +**Files:** +- Create: `src/adapters/opencode-family.ts` (shared core), `src/adapters/orgn-cde.ts`, `src/adapters/opencode.ts`, `fixtures/opencode-home/.config/orgn/opencode.jsonc`, fixture DB builder in test setup +- Modify: `src/adapter.ts` (register both) +- Test: `test/opencode-family.test.ts` + +**Interfaces:** +- Produces: two adapters sharing one core: `orgn-cde` (config `~/.config/orgn/opencode.jsonc`, data `~/.local/share/orgn/opencode.db`, displayName 'ORGN CDE') and `opencode` (`~/.config/opencode/opencode.json(c)`, `~/.local/share/opencode/opencode.db`). `usageSupport: 'partial'`. Ids: `orgn-cde/agent:`, `orgn-cde/mcp:`, `orgn-cde/command:` (new kind `command` added to `ItemKind`), `orgn-cde/memory:AGENTS.md`. +- Config parse: strip `//` line + `/* */` block comments + trailing commas, then `JSON.parse` (tolerant `parseJsonc` helper in the shared core, unit-tested; on failure → empty inventory + `flags: ['invalid-config']` root note, no throw). Inventory from keys: `agent`, `mcp`, `command`, `plugin` records if present. +- Usage: **survey-first.** Step 1 below runs the real-machine survey; queries are written against the discovered schema and wrapped so that a missing table/column ⇒ `{totalSessions: 0, items: {}}` (inventory-only degrade). `node:sqlite` (`new DatabaseSync(path, {readOnly: true})`) guarded behind dynamic import — if unavailable (Node <22) degrade the same way. DB file is copied to a temp path before opening when a `-wal` sibling exists (running CDE lock safety). + +- [ ] **Step 1: Schema survey (real machine, read-only)** — `sqlite3 ~/.local/share/orgn/opencode.db '.tables'` and `.schema` for candidate tables (`session`, `message`, `part`…). Commit findings as `docs/opencode-db-survey.md` with the exact query the miner will use (expected shape: count tool-call parts per session in window, grouped by tool name; map `mcp__`-style names to `mcp:`, agent invocations to `agent:`). +- [ ] **Step 2:** `parseJsonc` unit tests (comments, trailing commas, corrupt input) → implement. +- [ ] **Step 3:** Fixture `opencode.jsonc` mirroring the real one's shape (provider/model + one `agent`, one `mcp`, one `command` entry); test scan output. +- [ ] **Step 4:** Fixture DB built in test setup via `node:sqlite` using the surveyed schema (skip suite with `describe.skipIf` when `node:sqlite` unavailable); test mineUsage counts + window filtering + missing-table degrade. +- [ ] **Step 5:** Register both adapters; real-machine smoke `node dist/cli.js --list-tools` shows `orgn-cde detected`. Commit: `feat: ORGN CDE + OpenCode adapters (opencode-family core, read-only sqlite usage)`. + +### Task 8 (M8): Cross-tool diagnostics + per-tool tuning bars + +**Files:** +- Create: `src/cross-tool.ts` +- Modify: `src/types.ts` (report types below), `src/cli.ts` (wire in), `src/diagnostics.ts` (untouched logic; cross-tool lives separately), `src/renderer/assets/app.js` + `style.css` (render new lists + per-tool tuning view) +- Test: `test/cross-tool.test.ts`, `test/renderer.test.ts` + +**Interfaces:** + +```ts +export interface CrossToolDuplicate { key: string; itemIds: string[]; usedIn: string[]; line: string; } +export interface CapabilityImbalance { axis: Axis; concentratedIn: string; share: number; line: string; } +export interface RulesOverlap { itemIds: string[]; line: string; } +export interface CrossToolReport { duplicates: CrossToolDuplicate[]; imbalance: CapabilityImbalance[]; rulesOverlaps: RulesOverlap[]; } +export function crossToolDiagnose(inventory: Inventory, usage: Usage, classification: ClassificationOutput, tools: ToolMeta[]): CrossToolReport; +``` + +- Duplicate key = normalized MCP identity: `url` host+path when present else `command`+sorted args (extend `mcpItems` to retain `identity` on the item in Task 6 if not already); only emitted when ≥2 distinct tools share the key. `usedIn` = tools where count>0 (omit usage claim entirely when all owners are usage-less). +- Imbalance: for each axis with total installed weight >0, if one tool holds >80% of it and ≥2 tools are detected → line "All/most capability lives in ". +- Rules overlap: among `kind==='memory'` items across ≥2 tools, flag pairs whose *file names* indicate instruction files (AGENTS.md, CLAUDE.md, .cursorrules, GEMINI.md, *.mdc) — plain "worth checking they agree" line, no content diffing (SPEC_V2 §4.5.3: human review only). +- Renderer: three new lists appended to the diagnostics section (same DOM pattern as v1 lists); "by tool" toggle in tuning header renders one mini bar per detected tool (reuse `renderTuning` with a per-tool node subset). + +- [ ] **Step 1:** Failing unit tests for each of the three detectors with hand-built inventories (incl. the ≥2-tools guards and the no-usage-claim case) → implement `crossToolDiagnose` (pure, no I/O). +- [ ] **Step 2:** Wire into CLI (`--json` gains `crossTool`) + AtlasData; renderer tests assert embedded `crossTool` data + `id="tuning-by-tool"` container; implement rendering. +- [ ] **Step 3:** Green; real-machine run and eyeball. Commit: `feat: cross-tool diagnostics and per-tool tuning bars`. + +### Task 9: Release prep 0.2.0 + +**Files:** +- Modify: `package.json` (0.2.0), `README.md` (v2 tools table + Tier C "why not X" + breaking-change note), `CHANGELOG.md` (create) + +- [ ] **Step 1:** README: replace roadmap table (M1–M4 ✅, v2 rows per SPEC_V2 tiers), add `--list-tools`/`--tool` docs, id-format breaking-change note. +- [ ] **Step 2:** `npm run build && npm test` green; full real-machine run `node dist/cli.js` — map shows ≥2 tools (claude-code + orgn-cde minimum). +- [ ] **Step 3:** Commit: `chore: v0.2.0 — multi-tool release prep`. **Do not publish** — npm publish stays a human decision. + +## Self-Review (done at write time) + +- Spec coverage: M5→Tasks 1–4, M6→5–6, M7→7, M8→8, prereq→0, release→9. SPEC_V2 §4.2 studio-API enrichment is stretch/not-required — deliberately unplanned (YAGNI). Gemini CLI + Windsurf (SPEC_V2 M8 scope) **deferred to a follow-up plan**: verify-first requires real installs, neither is present on this machine; noted in README as "coming". +- Placeholder scan: schema-dependent Task 7 queries are gated behind its Step-1 survey by design, not left "TBD" — the survey commits the exact query before implementation. +- Type consistency: `AdapterContext`, `prefixInventory/prefixUsage`, `UsageSupport`, `CrossToolReport` defined once (Tasks 1–2, 8) and referenced by name elsewhere. diff --git a/src/adapter.ts b/src/adapter.ts index a231b02..8456703 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1,22 +1,51 @@ +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; import { mineUsage } from './miner.js'; import { scan } from './scanner.js'; -import type { Inventory, MineOptions, ScanOptions, Usage } from './types.js'; +import type { AdapterContext, Inventory, Usage, UsageSupport } from './types.js'; /** - * Per-tool adapter (spec §2): each AI coding tool implements this pair and - * yields tool-agnostic JSON. v1 ships Claude Code only; a CursorAdapter etc. - * would be another entry in `adapters`. + * Per-tool adapter (SPEC_V2 §4.1): each AI coding tool implements this and + * yields tool-agnostic JSON with tool-namespaced ids. The CLI runs detect() + * on every registered adapter and scans only the ones that are present. */ export interface ToolAdapter { name: string; - scan(opts: ScanOptions): Promise; - mineUsage(opts: MineOptions): Promise; + displayName: string; + usageSupport: UsageSupport; + detect(ctx: AdapterContext): Promise; + scan(ctx: AdapterContext): Promise; + mineUsage(ctx: AdapterContext): Promise; +} + +export async function dirExists(path: string): Promise { + try { + return (await fs.stat(path)).isDirectory(); + } catch { + return false; + } +} + +export async function fileExists(path: string): Promise { + try { + return (await fs.stat(path)).isFile(); + } catch { + return false; + } } export const claudeCodeAdapter: ToolAdapter = { name: 'claude-code', - scan, - mineUsage, + displayName: 'Claude Code', + usageSupport: 'full', + detect: (ctx) => dirExists(join(ctx.homeDir, '.claude')), + scan: (ctx) => scan({ homeDir: ctx.homeDir, projectDir: ctx.projectDir }), + mineUsage: (ctx) => mineUsage({ homeDir: ctx.homeDir, days: ctx.days, now: ctx.now }), }; export const adapters: ToolAdapter[] = [claudeCodeAdapter]; + +export async function detectAdapters(ctx: AdapterContext): Promise { + const flags = await Promise.all(adapters.map((adapter) => adapter.detect(ctx))); + return adapters.filter((_, i) => flags[i] === true); +} diff --git a/src/cli.ts b/src/cli.ts index f678ff9..73b9ecd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs'; import { Command } from 'commander'; import os from 'node:os'; import { join, resolve } from 'node:path'; -import { adapters } from './adapter.js'; +import { detectAdapters } from './adapter.js'; import { createAnthropicModel } from './classifier/anthropic-model.js'; import { classify } from './classifier/index.js'; import { diagnose } from './diagnostics.js'; @@ -62,14 +62,16 @@ program const homeDir = opts.home ?? os.homedir(); const atlasDir = opts.atlasDir ?? join(os.homedir(), '.agent-atlas'); - // One adapter per supported tool (spec §2) — v1 ships Claude Code only. + // One adapter per supported tool (SPEC_V2 §3); only detected tools scan. + const ctx = { homeDir, projectDir: opts.project, days }; + const detected = await detectAdapters(ctx); const items: InventoryItem[] = []; let totalSessions = 0; const usageItems: Record = {}; - for (const adapter of adapters) { - const inv = await adapter.scan({ homeDir, projectDir: opts.project }); + for (const adapter of detected) { + const inv = await adapter.scan(ctx); items.push(...inv.items); - const mined = await adapter.mineUsage({ homeDir, days }); + const mined = await adapter.mineUsage(ctx); totalSessions += mined.totalSessions; Object.assign(usageItems, mined.items); } diff --git a/src/types.ts b/src/types.ts index 9f635d6..53fc687 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,21 @@ export interface ScanOptions { projectDir?: string; } +/** What an adapter can honestly report about invocation frequency (SPEC_V2 §3). */ +export type UsageSupport = 'full' | 'partial' | 'none'; + +/** Everything an adapter needs to scan one machine (SPEC_V2 §4.1). */ +export interface AdapterContext { + homeDir: string; + projectDir?: string; + /** Usage window in days. */ + days: number; + /** Injectable clock for deterministic tests. */ + now?: Date; + /** Per-adapter config from ~/.agent-atlas/config.json, keyed by adapter name. */ + config?: Record; +} + /** The five capability axes, fixed for v1 (spec §4.3). Order breaks ties. */ export const AXES = ['engineering', 'writing', 'research', 'design', 'ops'] as const; export type Axis = (typeof AXES)[number]; diff --git a/test/adapter.test.ts b/test/adapter.test.ts index 8756494..60e8b86 100644 --- a/test/adapter.test.ts +++ b/test/adapter.test.ts @@ -1,30 +1,57 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { adapters, claudeCodeAdapter } from '../src/adapter.js'; +import { adapters, claudeCodeAdapter, detectAdapters } from '../src/adapter.js'; import { mineUsage } from '../src/miner.js'; import { scan } from '../src/scanner.js'; +import type { AdapterContext } from '../src/types.js'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const HOME = join(ROOT, 'fixtures', 'home'); const PROJECT = join(ROOT, 'fixtures', 'project'); const NOW = new Date('2026-07-21T00:00:00.000Z'); +const ctx = (homeDir: string): AdapterContext => ({ + homeDir, + projectDir: PROJECT, + days: 30, + now: NOW, +}); + describe('claudeCodeAdapter', () => { it('is registered under the name "claude-code"', () => { expect(claudeCodeAdapter.name).toBe('claude-code'); + expect(claudeCodeAdapter.displayName).toBe('Claude Code'); + expect(claudeCodeAdapter.usageSupport).toBe('full'); expect(adapters).toContain(claudeCodeAdapter); }); + it('detects a home dir with .claude and rejects one without', async () => { + expect(await claudeCodeAdapter.detect(ctx(HOME))).toBe(true); + const emptyHome = mkdtempSync(join(tmpdir(), 'atlas-empty-')); + expect(await claudeCodeAdapter.detect(ctx(emptyHome))).toBe(false); + }); + it('scan() is behavior-identical to the direct scanner', async () => { - const viaAdapter = await claudeCodeAdapter.scan({ homeDir: HOME, projectDir: PROJECT }); + const viaAdapter = await claudeCodeAdapter.scan(ctx(HOME)); const direct = await scan({ homeDir: HOME, projectDir: PROJECT }); expect(viaAdapter).toEqual(direct); }); it('mineUsage() is behavior-identical to the direct miner', async () => { - const viaAdapter = await claudeCodeAdapter.mineUsage({ homeDir: HOME, days: 30, now: NOW }); + const viaAdapter = await claudeCodeAdapter.mineUsage(ctx(HOME)); const direct = await mineUsage({ homeDir: HOME, days: 30, now: NOW }); expect(viaAdapter).toEqual(direct); }); }); + +describe('detectAdapters', () => { + it('returns only adapters whose tool is present', async () => { + const detected = await detectAdapters(ctx(HOME)); + expect(detected.map((a) => a.name)).toContain('claude-code'); + const emptyHome = mkdtempSync(join(tmpdir(), 'atlas-empty-')); + expect(await detectAdapters(ctx(emptyHome))).toEqual([]); + }); +}); From b8efe5360cef771863d53794104269c17965f435 Mon Sep 17 00:00:00 2001 From: Codefred Date: Wed, 22 Jul 2026 05:40:24 +0100 Subject: [PATCH 03/10] feat!: tool-namespaced item ids and InventoryItem.tool --- fixtures/expected-classifications.json | 32 +++++++++++++------------- src/adapter.ts | 26 +++++++++++++++++++-- src/types.ts | 2 ++ test/adapter.test.ts | 14 +++++++---- test/classifier-heuristic.test.ts | 3 ++- test/cli.test.ts | 14 +++++------ 6 files changed, 60 insertions(+), 31 deletions(-) diff --git a/fixtures/expected-classifications.json b/fixtures/expected-classifications.json index a292267..a679a3d 100644 --- a/fixtures/expected-classifications.json +++ b/fixtures/expected-classifications.json @@ -1,17 +1,17 @@ { - "skill:git-workflow": "ops", - "skill:deep-research": "research", - "skill:deploy-checklist": "ops", - "skill:superpowers:brainstorming": "research", - "skill:frontend-design:frontend-design": "design", - "mcp:playwright": "engineering", - "skill:broken-skill": "engineering", - "agent:code-reviewer": "engineering", - "agent:doc-writer": "writing", - "agent:proposal-agent": "writing", - "mcp:notion": "writing", - "mcp:linear": "ops", - "mcp:grafana": "ops", - "hook:PostToolUse:Bash": "ops", - "hook:SessionStart:*": "ops" -} + "claude-code/skill:git-workflow": "ops", + "claude-code/skill:deep-research": "research", + "claude-code/skill:deploy-checklist": "ops", + "claude-code/skill:superpowers:brainstorming": "research", + "claude-code/skill:frontend-design:frontend-design": "design", + "claude-code/mcp:playwright": "engineering", + "claude-code/skill:broken-skill": "engineering", + "claude-code/agent:code-reviewer": "engineering", + "claude-code/agent:doc-writer": "writing", + "claude-code/agent:proposal-agent": "writing", + "claude-code/mcp:notion": "writing", + "claude-code/mcp:linear": "ops", + "claude-code/mcp:grafana": "ops", + "claude-code/hook:PostToolUse:Bash": "ops", + "claude-code/hook:SessionStart:*": "ops" +} \ No newline at end of file diff --git a/src/adapter.ts b/src/adapter.ts index 8456703..8f51419 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -34,13 +34,35 @@ export async function fileExists(path: string): Promise { } } +/** Rewrite every item id to `/` and stamp `item.tool` (SPEC_V2 §4.1). */ +export function prefixInventory(inventory: Inventory, tool: string): Inventory { + return { + items: inventory.items.map((item) => ({ ...item, id: `${tool}/${item.id}`, tool })), + }; +} + +/** Rewrite every usage id to `/` (SPEC_V2 §4.1). */ +export function prefixUsage(usage: Usage, tool: string): Usage { + return { + totalSessions: usage.totalSessions, + items: Object.fromEntries( + Object.entries(usage.items).map(([id, entry]) => [`${tool}/${id}`, entry]), + ), + }; +} + export const claudeCodeAdapter: ToolAdapter = { name: 'claude-code', displayName: 'Claude Code', usageSupport: 'full', detect: (ctx) => dirExists(join(ctx.homeDir, '.claude')), - scan: (ctx) => scan({ homeDir: ctx.homeDir, projectDir: ctx.projectDir }), - mineUsage: (ctx) => mineUsage({ homeDir: ctx.homeDir, days: ctx.days, now: ctx.now }), + scan: async (ctx) => + prefixInventory(await scan({ homeDir: ctx.homeDir, projectDir: ctx.projectDir }), 'claude-code'), + mineUsage: async (ctx) => + prefixUsage( + await mineUsage({ homeDir: ctx.homeDir, days: ctx.days, now: ctx.now }), + 'claude-code', + ), }; export const adapters: ToolAdapter[] = [claudeCodeAdapter]; diff --git a/src/types.ts b/src/types.ts index 53fc687..a7af333 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,8 @@ export interface InventoryItem { description: string | null; sourcePath: string; sizeBytes: number; + /** Owning tool (adapter name) — set by the adapter layer's id prefixing (SPEC_V2 §4.1). */ + tool?: string; /** e.g. ["invalid-frontmatter"] — item kept, never a crash (spec §4.1). */ flags?: string[]; /** Agents only: allowed tools from frontmatter. */ diff --git a/test/adapter.test.ts b/test/adapter.test.ts index 60e8b86..7b7e934 100644 --- a/test/adapter.test.ts +++ b/test/adapter.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { adapters, claudeCodeAdapter, detectAdapters } from '../src/adapter.js'; +import { adapters, claudeCodeAdapter, detectAdapters, prefixInventory, prefixUsage } from '../src/adapter.js'; import { mineUsage } from '../src/miner.js'; import { scan } from '../src/scanner.js'; import type { AdapterContext } from '../src/types.js'; @@ -34,16 +34,20 @@ describe('claudeCodeAdapter', () => { expect(await claudeCodeAdapter.detect(ctx(emptyHome))).toBe(false); }); - it('scan() is behavior-identical to the direct scanner', async () => { + it('scan() equals the direct scanner with claude-code/ prefixed ids', async () => { const viaAdapter = await claudeCodeAdapter.scan(ctx(HOME)); const direct = await scan({ homeDir: HOME, projectDir: PROJECT }); - expect(viaAdapter).toEqual(direct); + expect(viaAdapter).toEqual(prefixInventory(direct, 'claude-code')); + for (const item of viaAdapter.items) { + expect(item.id.startsWith('claude-code/')).toBe(true); + expect(item.tool).toBe('claude-code'); + } }); - it('mineUsage() is behavior-identical to the direct miner', async () => { + it('mineUsage() equals the direct miner with claude-code/ prefixed ids', async () => { const viaAdapter = await claudeCodeAdapter.mineUsage(ctx(HOME)); const direct = await mineUsage({ homeDir: HOME, days: 30, now: NOW }); - expect(viaAdapter).toEqual(direct); + expect(viaAdapter).toEqual(prefixUsage(direct, 'claude-code')); }); }); diff --git a/test/classifier-heuristic.test.ts b/test/classifier-heuristic.test.ts index fab53ee..5e253a6 100644 --- a/test/classifier-heuristic.test.ts +++ b/test/classifier-heuristic.test.ts @@ -18,7 +18,8 @@ describe('heuristicClassify (rough mode)', () => { it('matches every hand-labeled fixture primary', async () => { const inventory = await scan({ homeDir: HOME, projectDir: PROJECT }); for (const [id, expectedPrimary] of Object.entries(expectedPrimaries)) { - const item = inventory.items.find((i) => i.id === id); + // Labels use the CLI's tool-namespaced ids; the direct scanner is unprefixed. + const item = inventory.items.find((i) => `claude-code/${i.id}` === id); expect(item, `fixture item ${id} missing from inventory`).toBeDefined(); if (item === undefined) continue; const result = heuristicClassify(item); diff --git a/test/cli.test.ts b/test/cli.test.ts index b8a5aa9..e82e605 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -50,17 +50,17 @@ describe('agent-atlas CLI', () => { for (const item of parsed.inventory.items) { expect(parsed.usage.items[item.id]).toBeDefined(); } - expect(parsed.usage.items['skill:git-workflow']).toEqual({ + expect(parsed.usage.items['claude-code/skill:git-workflow']).toEqual({ count: 3, lastUsed: '2026-07-15T09:00:00.000Z', sessionsSeen: 3, }); - expect(parsed.usage.items['mcp:grafana']).toEqual({ + expect(parsed.usage.items['claude-code/mcp:grafana']).toEqual({ count: 0, lastUsed: null, sessionsSeen: 0, }); - expect(parsed.usage.items['skill:broken-skill']).toEqual({ + expect(parsed.usage.items['claude-code/skill:broken-skill']).toEqual({ count: 0, lastUsed: null, sessionsSeen: 0, @@ -73,7 +73,7 @@ describe('agent-atlas CLI', () => { const out = runCli('--json', '--home', HOME, '--project', PROJECT, '--days', '1'); const parsed = JSON.parse(out) as CliJson; expect(parsed.usage.totalSessions).toBe(0); - expect(parsed.usage.items['skill:git-workflow']).toEqual({ + expect(parsed.usage.items['claude-code/skill:git-workflow']).toEqual({ count: 0, lastUsed: null, sessionsSeen: 0, @@ -134,7 +134,7 @@ describe('agent-atlas CLI', () => { expect(parsed.classification.mode).toBe('heuristic'); expect(parsed.classification.items).toHaveLength(Object.keys(expected).length); expect(parsed.classification.items.map((i) => i.itemId)).not.toContain( - 'memory:user:CLAUDE.md', + 'claude-code/memory:user:CLAUDE.md', ); for (const [id, primary] of Object.entries(expected)) { const item = parsed.classification.items.find((i) => i.itemId === id); @@ -147,7 +147,7 @@ describe('agent-atlas CLI', () => { const atlasDir = tmpAtlasDir(); writeFileSync( join(atlasDir, 'overrides.json'), - JSON.stringify({ 'skill:git-workflow': { primary: 'design' } }), + JSON.stringify({ 'claude-code/skill:git-workflow': { primary: 'design' } }), ); const out = runCli( '--json', @@ -161,7 +161,7 @@ describe('agent-atlas CLI', () => { atlasDir, ); const parsed = JSON.parse(out) as CliJson; - const item = parsed.classification.items.find((i) => i.itemId === 'skill:git-workflow')!; + const item = parsed.classification.items.find((i) => i.itemId === 'claude-code/skill:git-workflow')!; expect(item.method).toBe('override'); expect(item.primary).toBe('design'); }); From 60b5a2f9a1b2fce0276fd4f8d4a889a9959f2d4e Mon Sep 17 00:00:00 2001 From: Codefred Date: Wed, 22 Jul 2026 05:41:36 +0100 Subject: [PATCH 04/10] feat: --list-tools, --tool filter, tools array in --json --- src/cli.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++--- test/cli.test.ts | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 73b9ecd..bf381a9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs'; import { Command } from 'commander'; import os from 'node:os'; import { join, resolve } from 'node:path'; -import { detectAdapters } from './adapter.js'; +import { adapters, detectAdapters } from './adapter.js'; import { createAnthropicModel } from './classifier/anthropic-model.js'; import { classify } from './classifier/index.js'; import { diagnose } from './diagnostics.js'; @@ -23,6 +23,8 @@ interface CliOptions { out: string; open: boolean; share?: boolean; + listTools?: boolean; + tool?: string[]; } /** Best-effort browser launch — never fails the run, never blocks exit. */ @@ -52,6 +54,12 @@ program .option('--no-open', 'do not open atlas.html in the browser') .option('--home ', 'treat as the home directory (mainly for testing)') .option('--project ', 'project directory to scan', process.cwd()) + .option('--list-tools', 'list registered tool adapters and their detection status, then exit') + .option( + '--tool ', + 'restrict scanning to the named tool (repeatable)', + (value: string, prev: string[] = []) => [...prev, value], + ) .action(async (opts: CliOptions) => { const days = Number.parseInt(opts.days, 10); if (!Number.isInteger(days) || days <= 0) { @@ -65,16 +73,51 @@ program // One adapter per supported tool (SPEC_V2 §3); only detected tools scan. const ctx = { homeDir, projectDir: opts.project, days }; const detected = await detectAdapters(ctx); + const detectedNames = new Set(detected.map((a) => a.name)); + + if (opts.listTools === true) { + for (const adapter of adapters) { + process.stdout.write( + `${adapter.name.padEnd(14)}${adapter.displayName.padEnd(14)}${ + detectedNames.has(adapter.name) ? 'detected' : 'not detected' + } usage:${adapter.usageSupport}\n`, + ); + } + return; + } + + const validNames = adapters.map((a) => a.name); + for (const name of opts.tool ?? []) { + if (!validNames.includes(name)) { + process.stderr.write(`error: unknown tool "${name}" — valid: ${validNames.join(', ')}\n`); + process.exitCode = 1; + return; + } + } + const selected = + opts.tool !== undefined && opts.tool.length > 0 + ? detected.filter((a) => opts.tool?.includes(a.name)) + : detected; + const items: InventoryItem[] = []; let totalSessions = 0; const usageItems: Record = {}; - for (const adapter of detected) { + const perToolCounts = new Map(); + for (const adapter of selected) { const inv = await adapter.scan(ctx); items.push(...inv.items); + perToolCounts.set(adapter.name, inv.items.length); const mined = await adapter.mineUsage(ctx); totalSessions += mined.totalSessions; Object.assign(usageItems, mined.items); } + const tools = adapters.map((adapter) => ({ + name: adapter.name, + displayName: adapter.displayName, + detected: detectedNames.has(adapter.name), + usageSupport: adapter.usageSupport, + itemCount: perToolCounts.get(adapter.name) ?? 0, + })); const inventory = { items }; const usage: Usage = mergeUsage(inventory, { totalSessions, items: usageItems }); @@ -86,7 +129,7 @@ program if (opts.json === true) { process.stdout.write( - `${JSON.stringify({ days, inventory, usage, classification, diagnostics }, null, 2)}\n`, + `${JSON.stringify({ days, tools, inventory, usage, classification, diagnostics }, null, 2)}\n`, ); return; } diff --git a/test/cli.test.ts b/test/cli.test.ts index e82e605..f8755a2 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -197,3 +197,38 @@ describe('agent-atlas CLI', () => { expect(out.toLowerCase()).toContain('rough mode'); }); }); + +describe('multi-tool CLI surface (v2)', () => { + it('--list-tools lists every registered adapter with detection status', () => { + const out = runCli('--list-tools', '--home', HOME, '--project', PROJECT); + expect(out).toMatch(/claude-code\s+Claude Code\s+detected\s+usage:full/); + }); + + it('--tool with an unknown name exits 1 and lists valid names', () => { + let failed = false; + try { + runCli('--tool', 'nope', '--json', '--home', HOME, '--project', PROJECT); + } catch (error) { + failed = true; + const stderr = (error as { stderr?: Buffer | string }).stderr?.toString() ?? ''; + expect(stderr).toContain('unknown tool'); + expect(stderr).toContain('claude-code'); + } + expect(failed).toBe(true); + }); + + it('--json includes a tools array with detection + usage metadata', () => { + const out = runCli( + '--json', '--home', HOME, '--project', PROJECT, '--days', '36500', + '--atlas-dir', tmpAtlasDir(), + ); + const parsed = JSON.parse(out) as CliJson & { + tools: { name: string; displayName: string; detected: boolean; usageSupport: string; itemCount: number }[]; + }; + const cc = parsed.tools.find((t) => t.name === 'claude-code'); + expect(cc).toBeDefined(); + expect(cc!.detected).toBe(true); + expect(cc!.usageSupport).toBe('full'); + expect(cc!.itemCount).toBe(parsed.inventory.items.length); + }); +}); From 9891724c5ad02f71cce8b1de849bfb874f7270ae Mon Sep 17 00:00:00 2001 From: Codefred Date: Wed, 22 Jul 2026 05:43:32 +0100 Subject: [PATCH 05/10] feat: tool badges, tool filter, usage-unavailable rendering in map --- src/cli.ts | 2 +- src/renderer/assets/app.js | 65 ++++++++++++++++++++++++++++++++++---- src/renderer/index.ts | 9 +++++- src/types.ts | 11 ++++++- test/renderer.test.ts | 32 ++++++++++++++++++- 5 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index bf381a9..cd32ab3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -138,7 +138,7 @@ program const html = await renderAtlas({ generatedAt: new Date().toISOString(), days, - tool: 'claude-code', + tools, inventory, usage, classification, diff --git a/src/renderer/assets/app.js b/src/renderer/assets/app.js index 6593b06..8f80f42 100644 --- a/src/renderer/assets/app.js +++ b/src/renderer/assets/app.js @@ -14,6 +14,14 @@ }; const DEAD = '#414b5e'; const SURFACE = '#10141d'; + /* Tool badge palette — assigned by roster order, stable across runs. */ + const TOOL_COLORS = ['#8a93a6', '#5ec8d8', '#c98bdb', '#7dd487', '#e0b566', '#d88a8a']; + const toolList = data.tools || []; + const toolMeta = new Map(toolList.map((t, i) => [t.name, { ...t, color: TOOL_COLORS[i % TOOL_COLORS.length] }])); + const noUsageTool = (n) => { + const meta = toolMeta.get(n.item.tool); + return meta !== undefined && meta.usageSupport === 'none'; + }; const SYMBOLS = { skill: d3.symbolCircle, agent: d3.symbolSquare, @@ -27,12 +35,16 @@ .filter((item) => clsById.has(item.id)) .map((item) => { const usage = data.usage.items[item.id] || { count: 0, lastUsed: null, sessionsSeen: 0 }; + const meta = toolMeta.get(item.tool); + const usageless = meta !== undefined && meta.usageSupport === 'none'; return { id: item.id, item, usage, cls: clsById.get(item.id), - r: 7 + 4 * Math.log2(usage.count + 1), + // Usage-less tools render at a fixed size: node size means "how often + // used", and we refuse to fake that signal (SPEC_V2 §4.4). + r: usageless ? 9 : 7 + 4 * Math.log2(usage.count + 1), }; }); @@ -175,9 +187,17 @@ .attr('d', (n) => symbol.type(SYMBOLS[n.item.kind] || d3.symbolCircle).size(n.r * n.r * Math.PI)(), ) - .attr('fill', (n) => (n.usage.count === 0 ? DEAD : AXIS_COLOR[n.cls.primary])) - .attr('fill-opacity', (n) => (n.usage.count === 0 ? 0.55 : 0.92)) - .attr('stroke', SURFACE) + .attr('fill', (n) => + noUsageTool(n) ? AXIS_COLOR[n.cls.primary] : n.usage.count === 0 ? DEAD : AXIS_COLOR[n.cls.primary], + ) + .attr('fill-opacity', (n) => (noUsageTool(n) ? 0.75 : n.usage.count === 0 ? 0.55 : 0.92)) + .attr('stroke', (n) => { + if (toolList.filter((t) => t.detected).length < 2) return noUsageTool(n) ? '#a9b2c3' : SURFACE; + const meta = toolMeta.get(n.item.tool); + return meta !== undefined ? meta.color : SURFACE; + }) + .attr('stroke-dasharray', (n) => (noUsageTool(n) ? '3 3' : null)) + .attr('class', (n) => (noUsageTool(n) ? 'no-usage' : null)) .attr('stroke-width', 2) .style('cursor', 'pointer'); @@ -254,12 +274,17 @@ t1.textContent = n.item.name; const t2 = document.createElement('div'); t2.className = 't2'; + const meta = toolMeta.get(n.item.tool); t2.textContent = n.item.kind + ' · ' + n.cls.primary + ' · ' + - (n.usage.count === 0 ? 'never fired' : n.usage.count + '× in ' + data.days + 'd'); + (noUsageTool(n) + ? 'usage unavailable for ' + (meta !== undefined ? meta.displayName : n.item.tool) + : n.usage.count === 0 + ? 'never fired' + : n.usage.count + '× in ' + data.days + 'd'); tip.append(t1, t2); tip.hidden = false; }) @@ -405,12 +430,40 @@ kindFilters.append(row); } + const toolOn = {}; + const toolFilters = document.getElementById('tool-filters'); + if (toolFilters !== null) { + const toolsPresent = [...new Set(nodes.map((n) => n.item.tool).filter(Boolean))]; + for (const tool of toolsPresent) { + toolOn[tool] = true; + const meta = toolMeta.get(tool); + const row = document.createElement('label'); + row.className = 'filter-row'; + const box = document.createElement('input'); + box.type = 'checkbox'; + box.checked = true; + box.addEventListener('change', () => { + toolOn[tool] = box.checked; + applyFilters(); + }); + const dot = document.createElement('span'); + dot.className = 'key-dot'; + dot.style.background = meta !== undefined ? meta.color : '#8a93a6'; + const label = document.createElement('span'); + label.textContent = meta !== undefined ? meta.displayName : tool; + row.append(box, dot, label); + toolFilters.append(row); + } + } + const hideUnused = document.getElementById('hide-unused'); hideUnused.addEventListener('change', applyFilters); function applyFilters() { const visible = (n) => - kindOn[n.item.kind] !== false && (!hideUnused.checked || n.usage.count > 0); + kindOn[n.item.kind] !== false && + toolOn[n.item.tool] !== false && + (!hideUnused.checked || n.usage.count > 0 || noUsageTool(n)); nodeSel.attr('display', (n) => (visible(n) ? null : 'none')); labelSel.attr('display', (n) => (visible(n) ? null : 'none')); linkSel.attr('display', (n) => (visible(n) ? null : 'none')); diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 01da8d7..28a5a49 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -53,7 +53,12 @@ export async function renderAtlas(data: AtlasData): Promise {
Agent Atlas - ${escapeHtml(data.tool)} · last ${data.days} days + ${escapeHtml( + data.tools + .filter((t) => t.detected) + .map((t) => t.displayName) + .join(' + ') || 'no tools detected', + )} · last ${data.days} days
@@ -75,6 +80,8 @@ export async function renderAtlas(data: AtlasData): Promise {
Kinds
+
Tools
+
diff --git a/src/types.ts b/src/types.ts index a7af333..f26a590 100644 --- a/src/types.ts +++ b/src/types.ts @@ -118,11 +118,20 @@ export interface DiagnosticsReport { gaps: GapFinding[]; } +/** Per-tool metadata surfaced in --json and embedded in atlas.html (SPEC_V2 §5). */ +export interface ToolMeta { + name: string; + displayName: string; + detected: boolean; + usageSupport: UsageSupport; + itemCount: number; +} + /** Everything the renderer embeds into atlas.html (spec §4.4). */ export interface AtlasData { generatedAt: string; days: number; - tool: string; + tools: ToolMeta[]; inventory: Inventory; usage: Usage; classification: ClassificationOutput; diff --git a/test/renderer.test.ts b/test/renderer.test.ts index 18790bc..ec23534 100644 --- a/test/renderer.test.ts +++ b/test/renderer.test.ts @@ -36,7 +36,15 @@ beforeAll(async () => { fixtureData = { generatedAt: '2026-07-21T00:00:00.000Z', days: 30, - tool: 'claude-code', + tools: [ + { + name: 'claude-code', + displayName: 'Claude Code', + detected: true, + usageSupport: 'full', + itemCount: inventory.items.length, + }, + ], inventory, usage, classification, @@ -94,6 +102,28 @@ describe('renderAtlas', () => { expect(llmHtml.toLowerCase()).not.toContain('rough mode'); }); + it('renders the tool filter container and embeds tools metadata', async () => { + const html = await renderAtlas(fixtureData); + expect(html).toContain('id="tool-filters"'); + const embedded = extractEmbedded(html) as AtlasData; + expect(embedded.tools[0]!.name).toBe('claude-code'); + }); + + it('renders an empty inventory without throwing and with an empty state', async () => { + const empty: AtlasData = { + generatedAt: '2026-07-21T00:00:00.000Z', + days: 30, + tools: [], + inventory: { items: [] }, + usage: { totalSessions: 0, items: {} }, + classification: { mode: 'heuristic', items: [] }, + diagnostics: { deadWeight: [], overlaps: [], gaps: [] }, + }; + const html = await renderAtlas(empty); + expect(html).toContain('no tools detected'); + expect(html).toContain('tune-empty'); + }); + it('states the privacy posture in the page footer', async () => { const html = await renderAtlas(fixtureData); expect(html.toLowerCase()).toContain('read-only'); From 14cbb6a6abd34fefff7d005d9c7ce8bbea0f4df6 Mon Sep 17 00:00:00 2001 From: Codefred Date: Wed, 22 Jul 2026 05:46:43 +0100 Subject: [PATCH 06/10] feat: Codex CLI adapter (config.toml inventory + session-log usage) --- fixtures/codex-home/.codex/AGENTS.md | 2 + fixtures/codex-home/.codex/config.toml | 9 + .../.codex/sessions/2026/01/rollout-old.jsonl | 1 + .../.codex/sessions/2026/07/rollout-1.jsonl | 5 + fixtures/codex-project/AGENTS.md | 2 + package-lock.json | 19 +- package.json | 3 +- src/adapter.ts | 40 +--- src/adapters/codex.ts | 175 ++++++++++++++++++ src/adapters/shared.ts | 66 +++++++ src/scanner.ts | 2 +- test/codex.test.ts | 68 +++++++ 12 files changed, 352 insertions(+), 40 deletions(-) create mode 100644 fixtures/codex-home/.codex/AGENTS.md create mode 100644 fixtures/codex-home/.codex/config.toml create mode 100644 fixtures/codex-home/.codex/sessions/2026/01/rollout-old.jsonl create mode 100644 fixtures/codex-home/.codex/sessions/2026/07/rollout-1.jsonl create mode 100644 fixtures/codex-project/AGENTS.md create mode 100644 src/adapters/codex.ts create mode 100644 src/adapters/shared.ts create mode 100644 test/codex.test.ts diff --git a/fixtures/codex-home/.codex/AGENTS.md b/fixtures/codex-home/.codex/AGENTS.md new file mode 100644 index 0000000..8bfc110 --- /dev/null +++ b/fixtures/codex-home/.codex/AGENTS.md @@ -0,0 +1,2 @@ +# Global agent rules +Prefer TypeScript. Never force-push. diff --git a/fixtures/codex-home/.codex/config.toml b/fixtures/codex-home/.codex/config.toml new file mode 100644 index 0000000..ab8c773 --- /dev/null +++ b/fixtures/codex-home/.codex/config.toml @@ -0,0 +1,9 @@ +# Codex CLI fixture config — mirrors the documented [mcp_servers.] layout. +model = "gpt-5.2-codex" + +[mcp_servers.github] +command = "npx" +args = ["-y", "@modelcontextprotocol/server-github"] + +[mcp_servers.linear] +url = "https://mcp.linear.app/mcp" diff --git a/fixtures/codex-home/.codex/sessions/2026/01/rollout-old.jsonl b/fixtures/codex-home/.codex/sessions/2026/01/rollout-old.jsonl new file mode 100644 index 0000000..6bc7ac0 --- /dev/null +++ b/fixtures/codex-home/.codex/sessions/2026/01/rollout-old.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-01-01T00:00:00.000Z","type":"response_item","payload":{"type":"function_call","name":"github__search_issues"}} diff --git a/fixtures/codex-home/.codex/sessions/2026/07/rollout-1.jsonl b/fixtures/codex-home/.codex/sessions/2026/07/rollout-1.jsonl new file mode 100644 index 0000000..505ac37 --- /dev/null +++ b/fixtures/codex-home/.codex/sessions/2026/07/rollout-1.jsonl @@ -0,0 +1,5 @@ +{"timestamp":"2026-07-15T09:00:00.000Z","type":"response_item","payload":{"type":"function_call","name":"github__search_issues"}} +{"timestamp":"2026-07-15T09:01:00.000Z","type":"response_item","payload":{"type":"function_call","name":"shell"}} +{"timestamp":"2026-07-15T09:02:00.000Z","type":"response_item","payload":{"type":"function_call","name":"mcp__linear__create_issue"}} +not json at all +{"timestamp":"2026-07-15T09:03:00.000Z","type":"response_item","payload":{"type":"function_call","name":"github__get_pr"}} diff --git a/fixtures/codex-project/AGENTS.md b/fixtures/codex-project/AGENTS.md new file mode 100644 index 0000000..339d02e --- /dev/null +++ b/fixtures/codex-project/AGENTS.md @@ -0,0 +1,2 @@ +# Project rules +This repo uses pnpm. diff --git a/package-lock.json b/package-lock.json index 9541a35..a6f793d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "agent-atlas", + "name": "agent-atlas-cli", "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "agent-atlas", + "name": "agent-atlas-cli", "version": "0.1.2", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.112.4", "commander": "^12.1.0", - "d3": "^7.9.0" + "d3": "^7.9.0", + "smol-toml": "^1.7.0" }, "bin": { "agent-atlas": "dist/cli.js" @@ -1729,6 +1730,18 @@ "dev": true, "license": "ISC" }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 7df8dd3..ff784d5 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.112.4", "commander": "^12.1.0", - "d3": "^7.9.0" + "d3": "^7.9.0", + "smol-toml": "^1.7.0" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/src/adapter.ts b/src/adapter.ts index 8f51419..8d3feff 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1,9 +1,12 @@ -import { promises as fs } from 'node:fs'; import { join } from 'node:path'; +import { codexAdapter } from './adapters/codex.js'; +import { dirExists, prefixInventory, prefixUsage } from './adapters/shared.js'; import { mineUsage } from './miner.js'; import { scan } from './scanner.js'; import type { AdapterContext, Inventory, Usage, UsageSupport } from './types.js'; +export { dirExists, fileExists, prefixInventory, prefixUsage } from './adapters/shared.js'; + /** * Per-tool adapter (SPEC_V2 §4.1): each AI coding tool implements this and * yields tool-agnostic JSON with tool-namespaced ids. The CLI runs detect() @@ -18,39 +21,6 @@ export interface ToolAdapter { mineUsage(ctx: AdapterContext): Promise; } -export async function dirExists(path: string): Promise { - try { - return (await fs.stat(path)).isDirectory(); - } catch { - return false; - } -} - -export async function fileExists(path: string): Promise { - try { - return (await fs.stat(path)).isFile(); - } catch { - return false; - } -} - -/** Rewrite every item id to `/` and stamp `item.tool` (SPEC_V2 §4.1). */ -export function prefixInventory(inventory: Inventory, tool: string): Inventory { - return { - items: inventory.items.map((item) => ({ ...item, id: `${tool}/${item.id}`, tool })), - }; -} - -/** Rewrite every usage id to `/` (SPEC_V2 §4.1). */ -export function prefixUsage(usage: Usage, tool: string): Usage { - return { - totalSessions: usage.totalSessions, - items: Object.fromEntries( - Object.entries(usage.items).map(([id, entry]) => [`${tool}/${id}`, entry]), - ), - }; -} - export const claudeCodeAdapter: ToolAdapter = { name: 'claude-code', displayName: 'Claude Code', @@ -65,7 +35,7 @@ export const claudeCodeAdapter: ToolAdapter = { ), }; -export const adapters: ToolAdapter[] = [claudeCodeAdapter]; +export const adapters: ToolAdapter[] = [claudeCodeAdapter, codexAdapter]; export async function detectAdapters(ctx: AdapterContext): Promise { const flags = await Promise.all(adapters.map((adapter) => adapter.detect(ctx))); diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts new file mode 100644 index 0000000..f695c7d --- /dev/null +++ b/src/adapters/codex.ts @@ -0,0 +1,175 @@ +import { createReadStream, promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { parse as parseToml } from 'smol-toml'; +import type { ToolAdapter } from '../adapter.js'; +import type { Inventory, InventoryItem, McpTransport, Usage, UsageEntry } from '../types.js'; +import { + dirExists, + fileExists, + fileSize, + prefixInventory, + prefixUsage, + readFileSafe, +} from './shared.js'; + +const DAY_MS = 86_400_000; + +/** + * Codex CLI adapter (SPEC_V2 §3 Tier A). Covers the documented CLI layout: + * `~/.codex/config.toml` ([mcp_servers.] tables) and rollout logs at + * `~/.codex/sessions/**\/*.jsonl`. The Codex desktop app stores state in + * internal SQLite files with no stable public surface — on such machines + * detect() is false rather than pretending (verified 2026-07-22). + */ + +const asRecord = (value: unknown): Record | null => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; + +function transportOf(server: Record): McpTransport { + if (typeof server['url'] === 'string') return 'http'; + if (typeof server['command'] === 'string') return 'stdio'; + return 'unknown'; +} + +async function scanCodex(homeDir: string, projectDir?: string): Promise { + const items: InventoryItem[] = []; + const configPath = join(homeDir, '.codex', 'config.toml'); + const configText = await readFileSafe(configPath); + if (configText !== null) { + let parsed: Record | null = null; + try { + parsed = parseToml(configText) as Record; + } catch { + parsed = null; // corrupt config → empty inventory section, never a crash + } + const servers = asRecord(parsed?.['mcp_servers'] ?? null); + if (servers !== null) { + for (const [name, raw] of Object.entries(servers)) { + const server = asRecord(raw); + if (server === null) continue; + items.push({ + id: `mcp:${name}`, + kind: 'mcp', + name, + description: null, + sourcePath: configPath, + sizeBytes: Buffer.byteLength(JSON.stringify(server), 'utf8'), + transport: transportOf(server), + }); + } + } + } + + const memorySources: Array<[string, string]> = [ + [join(homeDir, '.codex', 'AGENTS.md'), 'global'], + ]; + if (projectDir !== undefined) { + memorySources.push([join(projectDir, 'AGENTS.md'), 'project']); + } + for (const [path, scope] of memorySources) { + if (await fileExists(path)) { + items.push({ + id: `memory:${scope}:AGENTS.md`, + kind: 'memory', + name: `AGENTS.md (${scope})`, + description: null, + sourcePath: path, + sizeBytes: await fileSize(path), + }); + } + } + + return { items }; +} + +/** `github__search_issues` / `mcp__github__search` → `github`; plain tools → null. */ +function mcpServerOf(name: string): string | null { + const stripped = name.startsWith('mcp__') ? name.slice('mcp__'.length) : name; + if (!stripped.includes('__')) return null; + const server = stripped.split('__')[0]; + return server !== undefined && server !== '' ? server : null; +} + +async function* jsonlFiles(dir: string): AsyncGenerator { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* jsonlFiles(path); + else if (entry.isFile() && entry.name.endsWith('.jsonl')) yield path; + } +} + +async function mineCodex(homeDir: string, days: number, now: Date): Promise { + const cutoff = now.getTime() - days * DAY_MS; + const tallies = new Map(); + let totalSessions = 0; + + for await (const file of jsonlFiles(join(homeDir, '.codex', 'sessions'))) { + const seenInSession = new Set(); + let sessionInWindow = false; + const reader = createInterface({ input: createReadStream(file), crlfDelay: Infinity }); + for await (const line of reader) { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + const record = asRecord(parsed); + if (record === null) continue; + const ts = + typeof record['timestamp'] === 'string' ? Date.parse(record['timestamp']) : Number.NaN; + if (Number.isNaN(ts) || ts < cutoff || ts > now.getTime()) continue; + sessionInWindow = true; + + // Rollout lines wrap the item under `payload` (older logs used `item`). + const payload = asRecord(record['payload']) ?? asRecord(record['item']); + if (payload === null || payload['type'] !== 'function_call') continue; + const name = payload['name']; + if (typeof name !== 'string') continue; + const server = mcpServerOf(name); + if (server === null) continue; + + const id = `mcp:${server}`; + const tally = tallies.get(id) ?? { count: 0, lastUsed: 0, sessions: 0 }; + tally.count++; + if (ts > tally.lastUsed) tally.lastUsed = ts; + if (!seenInSession.has(id)) { + seenInSession.add(id); + tally.sessions++; + } + tallies.set(id, tally); + } + if (sessionInWindow) totalSessions++; + } + + const items: Record = {}; + for (const [id, tally] of tallies) { + items[id] = { + count: tally.count, + lastUsed: new Date(tally.lastUsed).toISOString(), + sessionsSeen: tally.sessions, + }; + } + return { totalSessions, items }; +} + +export const codexAdapter: ToolAdapter = { + name: 'codex', + displayName: 'Codex CLI', + usageSupport: 'full', + detect: async (ctx) => + (await fileExists(join(ctx.homeDir, '.codex', 'config.toml'))) || + dirExists(join(ctx.homeDir, '.codex', 'sessions')), + scan: async (ctx) => prefixInventory(await scanCodex(ctx.homeDir, ctx.projectDir), 'codex'), + mineUsage: async (ctx) => + prefixUsage(await mineCodex(ctx.homeDir, ctx.days, ctx.now ?? new Date()), 'codex'), +}; diff --git a/src/adapters/shared.ts b/src/adapters/shared.ts new file mode 100644 index 0000000..7bb5d1e --- /dev/null +++ b/src/adapters/shared.ts @@ -0,0 +1,66 @@ +import { promises as fs } from 'node:fs'; +import type { Inventory, Usage } from '../types.js'; + +export async function dirExists(path: string): Promise { + try { + return (await fs.stat(path)).isDirectory(); + } catch { + return false; + } +} + +export async function fileExists(path: string): Promise { + try { + return (await fs.stat(path)).isFile(); + } catch { + return false; + } +} + +export async function readFileSafe(path: string): Promise { + try { + return await fs.readFile(path, 'utf8'); + } catch { + return null; + } +} + +export async function fileSize(path: string): Promise { + try { + return (await fs.stat(path)).size; + } catch { + return 0; + } +} + +export async function readJsonSafe(path: string): Promise | null> { + const text = await readFileSafe(path); + if (text === null) return null; + try { + const parsed: unknown = JSON.parse(text); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +/** Rewrite every item id to `/` and stamp `item.tool` (SPEC_V2 §4.1). */ +export function prefixInventory(inventory: Inventory, tool: string): Inventory { + return { + items: inventory.items.map((item) => ({ ...item, id: `${tool}/${item.id}`, tool })), + }; +} + +/** Rewrite every usage id to `/` (SPEC_V2 §4.1). */ +export function prefixUsage(usage: Usage, tool: string): Usage { + return { + totalSessions: usage.totalSessions, + items: Object.fromEntries( + Object.entries(usage.items).map(([id, entry]) => [`${tool}/${id}`, entry]), + ), + }; +} + +export const NO_USAGE: Usage = { totalSessions: 0, items: {} }; diff --git a/src/scanner.ts b/src/scanner.ts index 08c6368..4e84597 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -162,7 +162,7 @@ function transportOf(config: Record): McpTransport { return 'unknown'; } -function mcpItems(config: Record | null, sourcePath: string): InventoryItem[] { +export function mcpItems(config: Record | null, sourcePath: string): InventoryItem[] { const servers = asRecord(config?.['mcpServers']); if (servers === null) return []; const items: InventoryItem[] = []; diff --git a/test/codex.test.ts b/test/codex.test.ts new file mode 100644 index 0000000..cac96e2 --- /dev/null +++ b/test/codex.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { codexAdapter } from '../src/adapters/codex.js'; +import type { AdapterContext } from '../src/types.js'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const HOME = join(ROOT, 'fixtures', 'codex-home'); +const PROJECT = join(ROOT, 'fixtures', 'codex-project'); +const NOW = new Date('2026-07-21T00:00:00.000Z'); + +const ctx = (homeDir: string, projectDir?: string): AdapterContext => ({ + homeDir, + projectDir, + days: 30, + now: NOW, +}); + +describe('codexAdapter', () => { + it('detects a home with .codex/config.toml and rejects an empty home', async () => { + expect(await codexAdapter.detect(ctx(HOME))).toBe(true); + expect(await codexAdapter.detect(ctx(mkdtempSync(join(tmpdir(), 'codex-empty-'))))).toBe(false); + }); + + it('scans MCP servers and AGENTS.md into codex/ namespaced items', async () => { + const inventory = await codexAdapter.scan(ctx(HOME, PROJECT)); + const ids = inventory.items.map((i) => i.id).sort(); + expect(ids).toEqual([ + 'codex/memory:global:AGENTS.md', + 'codex/memory:project:AGENTS.md', + 'codex/mcp:github', + 'codex/mcp:linear', + ].sort()); + const github = inventory.items.find((i) => i.id === 'codex/mcp:github')!; + expect(github.transport).toBe('stdio'); + expect(github.tool).toBe('codex'); + const linear = inventory.items.find((i) => i.id === 'codex/mcp:linear')!; + expect(linear.transport).toBe('http'); + }); + + it('mines MCP-attributable calls from session logs within the window', async () => { + const usage = await codexAdapter.mineUsage(ctx(HOME)); + // rollout-old.jsonl is outside the 30-day window → 1 session, not 2. + expect(usage.totalSessions).toBe(1); + expect(usage.items['codex/mcp:github']).toEqual({ + count: 2, + lastUsed: '2026-07-15T09:03:00.000Z', + sessionsSeen: 1, + }); + expect(usage.items['codex/mcp:linear']).toEqual({ + count: 1, + lastUsed: '2026-07-15T09:02:00.000Z', + sessionsSeen: 1, + }); + // Plain tools (shell) are not inventory items — never counted. + expect(Object.keys(usage.items)).toHaveLength(2); + }); + + it('survives corrupt config.toml with an empty inventory, no throw', async () => { + const home = mkdtempSync(join(tmpdir(), 'codex-corrupt-')); + mkdirSync(join(home, '.codex'), { recursive: true }); + writeFileSync(join(home, '.codex', 'config.toml'), '[mcp_servers.broken\nnot toml at all'); + const inventory = await codexAdapter.scan(ctx(home)); + expect(inventory.items).toEqual([]); + }); +}); From e0829b22f2b2f3c2efba7c372e1b7551de46c492 Mon Sep 17 00:00:00 2001 From: Codefred Date: Wed, 22 Jul 2026 05:47:57 +0100 Subject: [PATCH 07/10] feat: Cursor adapter (inventory-only, mcp.json + skills-cursor + rules files) --- fixtures/cursor-home/.cursor/mcp.json | 6 + .../.cursor/skills-cursor/automate/SKILL.md | 5 + fixtures/cursor-project/.cursor/mcp.json | 6 + .../cursor-project/.cursor/rules/style.mdc | 4 + fixtures/cursor-project/.cursorrules | 1 + src/adapter.ts | 3 +- src/adapters/cursor.ts | 127 ++++++++++++++++++ test/cursor.test.ts | 44 ++++++ 8 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 fixtures/cursor-home/.cursor/mcp.json create mode 100644 fixtures/cursor-home/.cursor/skills-cursor/automate/SKILL.md create mode 100644 fixtures/cursor-project/.cursor/mcp.json create mode 100644 fixtures/cursor-project/.cursor/rules/style.mdc create mode 100644 fixtures/cursor-project/.cursorrules create mode 100644 src/adapters/cursor.ts create mode 100644 test/cursor.test.ts diff --git a/fixtures/cursor-home/.cursor/mcp.json b/fixtures/cursor-home/.cursor/mcp.json new file mode 100644 index 0000000..5ade180 --- /dev/null +++ b/fixtures/cursor-home/.cursor/mcp.json @@ -0,0 +1,6 @@ +{ + "mcpServers": { + "playwright": { "command": "npx", "args": ["@playwright/mcp@latest"] }, + "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] } + } +} diff --git a/fixtures/cursor-home/.cursor/skills-cursor/automate/SKILL.md b/fixtures/cursor-home/.cursor/skills-cursor/automate/SKILL.md new file mode 100644 index 0000000..57a4bd0 --- /dev/null +++ b/fixtures/cursor-home/.cursor/skills-cursor/automate/SKILL.md @@ -0,0 +1,5 @@ +--- +name: automate +description: Use this skill to create Cursor Automations. +--- +# Create Automation diff --git a/fixtures/cursor-project/.cursor/mcp.json b/fixtures/cursor-project/.cursor/mcp.json new file mode 100644 index 0000000..e9fea8e --- /dev/null +++ b/fixtures/cursor-project/.cursor/mcp.json @@ -0,0 +1,6 @@ +{ + "mcpServers": { + "github": { "url": "https://project-override.example.com/mcp" }, + "trigger": { "command": "npx", "args": ["trigger.dev@4.4.2", "mcp"] } + } +} diff --git a/fixtures/cursor-project/.cursor/rules/style.mdc b/fixtures/cursor-project/.cursor/rules/style.mdc new file mode 100644 index 0000000..0d11b31 --- /dev/null +++ b/fixtures/cursor-project/.cursor/rules/style.mdc @@ -0,0 +1,4 @@ +--- +description: Code style rules for this repo +--- +Use tabs. Prefer named exports. diff --git a/fixtures/cursor-project/.cursorrules b/fixtures/cursor-project/.cursorrules new file mode 100644 index 0000000..1579b94 --- /dev/null +++ b/fixtures/cursor-project/.cursorrules @@ -0,0 +1 @@ +Always write tests first. diff --git a/src/adapter.ts b/src/adapter.ts index 8d3feff..70709ce 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1,5 +1,6 @@ import { join } from 'node:path'; import { codexAdapter } from './adapters/codex.js'; +import { cursorAdapter } from './adapters/cursor.js'; import { dirExists, prefixInventory, prefixUsage } from './adapters/shared.js'; import { mineUsage } from './miner.js'; import { scan } from './scanner.js'; @@ -35,7 +36,7 @@ export const claudeCodeAdapter: ToolAdapter = { ), }; -export const adapters: ToolAdapter[] = [claudeCodeAdapter, codexAdapter]; +export const adapters: ToolAdapter[] = [claudeCodeAdapter, codexAdapter, cursorAdapter]; export async function detectAdapters(ctx: AdapterContext): Promise { const flags = await Promise.all(adapters.map((adapter) => adapter.detect(ctx))); diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts new file mode 100644 index 0000000..02dd887 --- /dev/null +++ b/src/adapters/cursor.ts @@ -0,0 +1,127 @@ +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import type { ToolAdapter } from '../adapter.js'; +import { parseFrontmatter } from '../frontmatter.js'; +import { mcpItems } from '../scanner.js'; +import type { Inventory, InventoryItem } from '../types.js'; +import { + NO_USAGE, + dirExists, + fileExists, + fileSize, + prefixInventory, + readFileSafe, + readJsonSafe, +} from './shared.js'; + +/** + * Cursor adapter (SPEC_V2 §3 Tier B — inventory-only). Verified 2026-07-22 + * on a real install: global `~/.cursor/mcp.json` + `~/.cursor/skills-cursor/ + * /SKILL.md` (Claude-style frontmatter), project `.cursor/mcp.json`, + * `.cursor/rules/*.mdc`, and `.cursorrules`. Cursor keeps no reliably + * parseable local usage log, so usageSupport is 'none' — the renderer badges + * these nodes instead of faking sizes. + */ + +async function skillItems(skillsDir: string): Promise { + const items: InventoryItem[] = []; + let entries; + try { + entries = await fs.readdir(skillsDir, { withFileTypes: true }); + } catch { + return items; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const path = join(skillsDir, entry.name, 'SKILL.md'); + const content = await readFileSafe(path); + if (content === null) continue; + const fm = parseFrontmatter(content); + const name = (!fm.malformed && fm.fields['name']) || entry.name; + const item: InventoryItem = { + id: `skill:${name}`, + kind: 'skill', + name, + description: fm.malformed ? null : fm.fields['description'] || null, + sourcePath: path, + sizeBytes: await fileSize(path), + }; + if (fm.malformed) item.flags = ['invalid-frontmatter']; + items.push(item); + } + return items; +} + +async function rulesItems(projectDir: string): Promise { + const items: InventoryItem[] = []; + + const rulesDir = join(projectDir, '.cursor', 'rules'); + let entries: import('node:fs').Dirent[] = []; + try { + entries = await fs.readdir(rulesDir, { withFileTypes: true }); + } catch { + // no rules dir — fine + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.mdc')) continue; + const path = join(rulesDir, entry.name); + const content = await readFileSafe(path); + if (content === null) continue; + const fm = parseFrontmatter(content); + items.push({ + id: `memory:rules:${entry.name}`, + kind: 'memory', + name: `.cursor/rules/${entry.name}`, + description: fm.malformed ? null : fm.fields['description'] || null, + sourcePath: path, + sizeBytes: await fileSize(path), + }); + } + + const legacyPath = join(projectDir, '.cursorrules'); + if (await fileExists(legacyPath)) { + items.push({ + id: 'memory:rules:.cursorrules', + kind: 'memory', + name: '.cursorrules', + description: null, + sourcePath: legacyPath, + sizeBytes: await fileSize(legacyPath), + }); + } + + return items; +} + +async function scanCursor(homeDir: string, projectDir?: string): Promise { + const items: InventoryItem[] = []; + + // MCP servers: project config wins name collisions and keeps its attribution. + const seen = new Set(); + const sources: string[] = []; + if (projectDir !== undefined) sources.push(join(projectDir, '.cursor', 'mcp.json')); + sources.push(join(homeDir, '.cursor', 'mcp.json')); + for (const source of sources) { + const config = await readJsonSafe(source); + if (config === null) continue; + for (const item of mcpItems(config, source)) { + if (seen.has(item.name)) continue; + seen.add(item.name); + items.push(item); + } + } + + items.push(...(await skillItems(join(homeDir, '.cursor', 'skills-cursor')))); + if (projectDir !== undefined) items.push(...(await rulesItems(projectDir))); + + return { items }; +} + +export const cursorAdapter: ToolAdapter = { + name: 'cursor', + displayName: 'Cursor', + usageSupport: 'none', + detect: (ctx) => dirExists(join(ctx.homeDir, '.cursor')), + scan: async (ctx) => prefixInventory(await scanCursor(ctx.homeDir, ctx.projectDir), 'cursor'), + mineUsage: async () => NO_USAGE, +}; diff --git a/test/cursor.test.ts b/test/cursor.test.ts new file mode 100644 index 0000000..8f01c0f --- /dev/null +++ b/test/cursor.test.ts @@ -0,0 +1,44 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { cursorAdapter } from '../src/adapters/cursor.js'; +import type { AdapterContext } from '../src/types.js'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const HOME = join(ROOT, 'fixtures', 'cursor-home'); +const PROJECT = join(ROOT, 'fixtures', 'cursor-project'); + +const ctx = (homeDir: string, projectDir?: string): AdapterContext => ({ + homeDir, + projectDir, + days: 30, +}); + +describe('cursorAdapter', () => { + it('is inventory-only and detects via ~/.cursor', async () => { + expect(cursorAdapter.usageSupport).toBe('none'); + expect(await cursorAdapter.detect(ctx(HOME))).toBe(true); + expect(await cursorAdapter.detect(ctx(mkdtempSync(join(tmpdir(), 'cursor-empty-'))))).toBe(false); + expect(await cursorAdapter.mineUsage(ctx(HOME))).toEqual({ totalSessions: 0, items: {} }); + }); + + it('scans MCP servers with project config winning name collisions', async () => { + const inventory = await cursorAdapter.scan(ctx(HOME, PROJECT)); + const github = inventory.items.find((i) => i.id === 'cursor/mcp:github')!; + expect(github.transport).toBe('http'); // project override, not the global stdio one + expect(github.sourcePath).toContain('cursor-project'); + const names = inventory.items.filter((i) => i.kind === 'mcp').map((i) => i.name).sort(); + expect(names).toEqual(['github', 'playwright', 'trigger']); + }); + + it('scans skills-cursor skills and project rules files', async () => { + const inventory = await cursorAdapter.scan(ctx(HOME, PROJECT)); + const skill = inventory.items.find((i) => i.id === 'cursor/skill:automate')!; + expect(skill.description).toContain('Cursor Automations'); + expect(skill.tool).toBe('cursor'); + const ruleIds = inventory.items.filter((i) => i.kind === 'memory').map((i) => i.id).sort(); + expect(ruleIds).toEqual(['cursor/memory:rules:.cursorrules', 'cursor/memory:rules:style.mdc']); + }); +}); From 8448727d84ceddcc6eb16091fc99f0db128661f6 Mon Sep 17 00:00:00 2001 From: Codefred Date: Wed, 22 Jul 2026 05:51:17 +0100 Subject: [PATCH 08/10] feat: ORGN CDE + OpenCode adapters (opencode-family core, read-only sqlite usage) --- docs/opencode-db-survey.md | 35 ++++ src/adapter.ts | 9 +- src/adapters/opencode-family.ts | 315 ++++++++++++++++++++++++++++++++ src/types.ts | 2 +- test/opencode-family.test.ts | 136 ++++++++++++++ 5 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 docs/opencode-db-survey.md create mode 100644 src/adapters/opencode-family.ts create mode 100644 test/opencode-family.test.ts diff --git a/docs/opencode-db-survey.md b/docs/opencode-db-survey.md new file mode 100644 index 0000000..7b33ef3 --- /dev/null +++ b/docs/opencode-db-survey.md @@ -0,0 +1,35 @@ +# OpenCode / ORGN CDE — local data survey (2026-07-22) + +Surveyed read-only on a real ORGN CDE install (`~/.local/share/orgn/opencode.db`, 102 sessions). + +## Config: `~/.config/orgn/opencode.jsonc` +JSONC (comments + trailing commas). Relevant keys: `agent` (record), `mcp` (record), +`command` (record), `provider`/`model` (ignored — not capabilities). **Caution:** naive +`//`-comment stripping corrupts URLs inside strings (`"$schema": "https://…"`) — the +parser must be string-aware. + +## DB schema (tables used) +- `session(id, project_id, title, time_created, …)` — `time_created` epoch **ms**. +- `message(id, session_id, time_created, data JSON)` — `data.agent` is the agent name + (e.g. `"build"`), `data.role` user/assistant. +- `part(id, message_id, session_id, time_created, data JSON)` — `data.type`: + `tool | step-start | step-finish | text | reasoning | patch | compaction | file`. + For `type='tool'`: `data.tool` is the tool name. + +## Tool naming +- Builtins: `read`, `bash`, `glob`, `skill`, … (not inventory items — not counted). +- MCP tools: `_`, e.g. `origin-edge-mcp_health_check` for config + server `origin-edge-mcp`. Attribution = longest config-key prefix match on `_`. + +## Queries the miner uses (read-only) +```sql +SELECT json_extract(data,'$.tool') AS tool, time_created, session_id + FROM part WHERE json_extract(data,'$.type')='tool' AND time_created >= :cutoffMs; +SELECT json_extract(data,'$.agent') AS agent, time_created, session_id + FROM message WHERE json_extract(data,'$.agent') IS NOT NULL AND time_created >= :cutoffMs; +SELECT COUNT(DISTINCT session_id) FROM part WHERE time_created >= :cutoffMs; +``` +Missing table/column ⇒ degrade to inventory-only (`{totalSessions: 0, items: {}}`). +DB opened with `node:sqlite` `DatabaseSync(path, {readOnly: true})`; when unavailable +(Node < 22.5) the adapter degrades the same way. usageSupport: 'partial' (tool parts +cover MCP + agents; skills/commands usage not yet attributable). diff --git a/src/adapter.ts b/src/adapter.ts index 70709ce..302a45a 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1,6 +1,7 @@ import { join } from 'node:path'; import { codexAdapter } from './adapters/codex.js'; import { cursorAdapter } from './adapters/cursor.js'; +import { opencodeAdapter, orgnCdeAdapter } from './adapters/opencode-family.js'; import { dirExists, prefixInventory, prefixUsage } from './adapters/shared.js'; import { mineUsage } from './miner.js'; import { scan } from './scanner.js'; @@ -36,7 +37,13 @@ export const claudeCodeAdapter: ToolAdapter = { ), }; -export const adapters: ToolAdapter[] = [claudeCodeAdapter, codexAdapter, cursorAdapter]; +export const adapters: ToolAdapter[] = [ + claudeCodeAdapter, + codexAdapter, + cursorAdapter, + orgnCdeAdapter, + opencodeAdapter, +]; export async function detectAdapters(ctx: AdapterContext): Promise { const flags = await Promise.all(adapters.map((adapter) => adapter.detect(ctx))); diff --git a/src/adapters/opencode-family.ts b/src/adapters/opencode-family.ts new file mode 100644 index 0000000..8eba463 --- /dev/null +++ b/src/adapters/opencode-family.ts @@ -0,0 +1,315 @@ +import { copyFile, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ToolAdapter } from '../adapter.js'; +import type { Inventory, InventoryItem, Usage, UsageEntry } from '../types.js'; +import { + NO_USAGE, + dirExists, + fileExists, + fileSize, + prefixInventory, + prefixUsage, + readFileSafe, +} from './shared.js'; + +/** + * Shared core for OpenCode-based tools — ORGN CDE and vanilla OpenCode + * (SPEC_V2 §4.2). Inventory from `opencode.json(c)`; usage from the local + * `opencode.db` SQLite session store, opened strictly read-only. Schema and + * naming verified against a real install — see docs/opencode-db-survey.md. + */ + +/** + * Strip `//` line comments, `/* *​/` block comments, and trailing commas — + * string-aware, because naive regex stripping corrupts URLs in string values + * (`"$schema": "https://…"`). Returns null on unparseable input. + */ +export function parseJsonc(text: string): Record | null { + let out = ''; + let inString = false; + let inLine = false; + let inBlock = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i] as string; + const next = text[i + 1]; + if (inLine) { + if (ch === '\n') { + inLine = false; + out += ch; + } + continue; + } + if (inBlock) { + if (ch === '*' && next === '/') { + inBlock = false; + i++; + } + continue; + } + if (inString) { + out += ch; + if (ch === '\\') { + out += next ?? ''; + i++; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + out += ch; + continue; + } + if (ch === '/' && next === '/') { + inLine = true; + continue; + } + if (ch === '/' && next === '*') { + inBlock = true; + i++; + continue; + } + out += ch; + } + out = out.replace(/,\s*([}\]])/g, '$1'); + try { + const parsed: unknown = JSON.parse(out); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +const asRecord = (value: unknown): Record | null => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; + +export interface OpencodePaths { + /** Directory holding opencode.json / opencode.jsonc. */ + configDir: string; + /** Directory holding opencode.db. */ + dataDir: string; +} + +async function findConfig(configDir: string): Promise { + for (const name of ['opencode.jsonc', 'opencode.json']) { + const path = join(configDir, name); + if (await fileExists(path)) return path; + } + return null; +} + +async function scanOpencode(paths: OpencodePaths, projectDir?: string): Promise { + const items: InventoryItem[] = []; + const configPath = await findConfig(paths.configDir); + if (configPath !== null) { + const text = await readFileSafe(configPath); + const config = text !== null ? parseJsonc(text) : null; + const sections: Array<['agent' | 'mcp' | 'command', 'agent' | 'mcp' | 'command']> = [ + ['agent', 'agent'], + ['mcp', 'mcp'], + ['command', 'command'], + ]; + for (const [key, kind] of sections) { + const section = asRecord(config?.[key] ?? null); + if (section === null) continue; + for (const [name, raw] of Object.entries(section)) { + const entry = asRecord(raw); + if (entry === null) continue; + const description = + typeof entry['description'] === 'string' ? entry['description'] : null; + items.push({ + id: `${kind}:${name}`, + kind, + name, + description, + sourcePath: configPath, + sizeBytes: Buffer.byteLength(JSON.stringify(entry), 'utf8'), + }); + } + } + } + const memorySources: Array<[string, string]> = [[join(paths.configDir, 'AGENTS.md'), 'global']]; + if (projectDir !== undefined) memorySources.push([join(projectDir, 'AGENTS.md'), 'project']); + for (const [path, scope] of memorySources) { + if (await fileExists(path)) { + items.push({ + id: `memory:${scope}:AGENTS.md`, + kind: 'memory', + name: `AGENTS.md (${scope})`, + description: null, + sourcePath: path, + sizeBytes: await fileSize(path), + }); + } + } + return { items }; +} + +interface ToolRow { + tool: string; + time_created: number; + session_id: string; +} +interface AgentRow { + agent: string; + time_created: number; + session_id: string; +} + +/** Longest config-key prefix match: `origin-edge-mcp_health_check` → `origin-edge-mcp`. */ +function mcpServerFor(tool: string, serverNames: string[]): string | null { + let best: string | null = null; + for (const server of serverNames) { + if (tool.startsWith(`${server}_`) && (best === null || server.length > best.length)) { + best = server; + } + } + return best; +} + +async function mineOpencode( + paths: OpencodePaths, + days: number, + now: Date, + serverNames: string[], + agentNames: string[], +): Promise { + const dbPath = join(paths.dataDir, 'opencode.db'); + if (!(await fileExists(dbPath))) return NO_USAGE; + + // process.getBuiltinModule avoids bundler static-import analysis and + // returns undefined (or throws) where the builtin is missing (Node < 22.5). + let DatabaseSync: (typeof import('node:sqlite'))['DatabaseSync']; + try { + const sqlite = process.getBuiltinModule('node:sqlite'); + if (sqlite === undefined) return NO_USAGE; + ({ DatabaseSync } = sqlite); + } catch { + return NO_USAGE; // degrade to inventory-only (SPEC_V2 §4.2) + } + + // A running CDE holds a WAL lock; copy-on-read keeps us strictly hands-off. + let openPath = dbPath; + if (await fileExists(`${dbPath}-wal`)) { + try { + const tmp = await mkdtemp(join(tmpdir(), 'agent-atlas-oc-')); + openPath = join(tmp, 'opencode.db'); + await copyFile(dbPath, openPath); + if (await fileExists(`${dbPath}-wal`)) { + await copyFile(`${dbPath}-wal`, `${openPath}-wal`).catch(() => undefined); + } + } catch { + openPath = dbPath; + } + } + + const cutoffMs = now.getTime() - days * 86_400_000; + const tallies = new Map }>(); + const sessions = new Set(); + + const bump = (id: string, ts: number, session: string): void => { + const tally = tallies.get(id) ?? { count: 0, lastUsed: 0, sessions: new Set() }; + tally.count++; + if (ts > tally.lastUsed) tally.lastUsed = ts; + tally.sessions.add(session); + tallies.set(id, tally); + }; + + try { + const db = new DatabaseSync(openPath, { readOnly: true }); + try { + const toolRows = db + .prepare( + `SELECT json_extract(data,'$.tool') AS tool, time_created, session_id + FROM part + WHERE json_extract(data,'$.type')='tool' AND time_created >= ?`, + ) + .all(cutoffMs) as unknown as ToolRow[]; + for (const row of toolRows) { + if (typeof row.tool !== 'string' || row.tool === '') continue; + sessions.add(row.session_id); + const server = mcpServerFor(row.tool, serverNames); + if (server !== null) bump(`mcp:${server}`, row.time_created, row.session_id); + } + + const agentRows = db + .prepare( + `SELECT json_extract(data,'$.agent') AS agent, time_created, session_id + FROM message + WHERE json_extract(data,'$.agent') IS NOT NULL AND time_created >= ?`, + ) + .all(cutoffMs) as unknown as AgentRow[]; + for (const row of agentRows) { + if (typeof row.agent !== 'string' || row.agent === '') continue; + sessions.add(row.session_id); + if (agentNames.includes(row.agent)) bump(`agent:${row.agent}`, row.time_created, row.session_id); + } + } finally { + db.close(); + } + } catch { + return NO_USAGE; // schema surprise or locked DB — never crash, never write + } + + const items: Record = {}; + for (const [id, tally] of tallies) { + items[id] = { + count: tally.count, + lastUsed: new Date(tally.lastUsed).toISOString(), + sessionsSeen: tally.sessions.size, + }; + } + return { totalSessions: sessions.size, items }; +} + +export function makeOpencodeAdapter(options: { + name: string; + displayName: string; + paths: (homeDir: string) => OpencodePaths; +}): ToolAdapter { + return { + name: options.name, + displayName: options.displayName, + usageSupport: 'partial', + detect: async (ctx) => { + const paths = options.paths(ctx.homeDir); + return (await findConfig(paths.configDir)) !== null || dirExists(paths.dataDir); + }, + scan: async (ctx) => + prefixInventory(await scanOpencode(options.paths(ctx.homeDir), ctx.projectDir), options.name), + mineUsage: async (ctx) => { + const paths = options.paths(ctx.homeDir); + const inventory = await scanOpencode(paths, ctx.projectDir); + const serverNames = inventory.items.filter((i) => i.kind === 'mcp').map((i) => i.name); + const agentNames = inventory.items.filter((i) => i.kind === 'agent').map((i) => i.name); + return prefixUsage( + await mineOpencode(paths, ctx.days, ctx.now ?? new Date(), serverNames, agentNames), + options.name, + ); + }, + }; +} + +export const orgnCdeAdapter = makeOpencodeAdapter({ + name: 'orgn-cde', + displayName: 'ORGN CDE', + paths: (homeDir) => ({ + configDir: join(homeDir, '.config', 'orgn'), + dataDir: join(homeDir, '.local', 'share', 'orgn'), + }), +}); + +export const opencodeAdapter = makeOpencodeAdapter({ + name: 'opencode', + displayName: 'OpenCode', + paths: (homeDir) => ({ + configDir: join(homeDir, '.config', 'opencode'), + dataDir: join(homeDir, '.local', 'share', 'opencode'), + }), +}); diff --git a/src/types.ts b/src/types.ts index f26a590..b8994c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ /** Kinds of items Agent Atlas tracks. Sorted alphabetically in output. */ -export type ItemKind = 'agent' | 'hook' | 'mcp' | 'memory' | 'skill'; +export type ItemKind = 'agent' | 'command' | 'hook' | 'mcp' | 'memory' | 'skill'; export type McpTransport = 'stdio' | 'sse' | 'http' | 'unknown'; diff --git a/test/opencode-family.test.ts b/test/opencode-family.test.ts new file mode 100644 index 0000000..c7887b1 --- /dev/null +++ b/test/opencode-family.test.ts @@ -0,0 +1,136 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + makeOpencodeAdapter, + orgnCdeAdapter, + parseJsonc, +} from '../src/adapters/opencode-family.js'; +import type { AdapterContext } from '../src/types.js'; + +const NOW = new Date('2026-07-21T00:00:00.000Z'); +const IN_WINDOW = NOW.getTime() - 86_400_000; // 1 day before NOW, epoch ms +const OUT_OF_WINDOW = NOW.getTime() - 90 * 86_400_000; + +const CONFIG_JSONC = `{ + // JSONC comment — and a URL with slashes that must survive stripping: + "$schema": "https://opencode.ai/config.json", + "model": "ollm/near_gpt_oss_120b", + "agent": { + "build": { "description": "Implements features from specs" }, + "review": { "description": "Reviews diffs for bugs" }, + }, + "mcp": { + "origin-edge-mcp": { "type": "remote", "url": "https://edge.example.com/mcp" }, + }, + "command": { + "ship": { "description": "Run the release checklist" }, + }, +}`; + +/** Build a fake ORGN-CDE home: config + a surveyed-schema sqlite DB. */ +async function makeHome(withDb: boolean): Promise { + const home = mkdtempSync(join(tmpdir(), 'oc-home-')); + const configDir = join(home, '.config', 'orgn'); + const dataDir = join(home, '.local', 'share', 'orgn'); + mkdirSync(configDir, { recursive: true }); + mkdirSync(dataDir, { recursive: true }); + writeFileSync(join(configDir, 'opencode.jsonc'), CONFIG_JSONC); + + if (withDb) { + const { DatabaseSync } = process.getBuiltinModule('node:sqlite')!; + const db = new DatabaseSync(join(dataDir, 'opencode.db')); + db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY, time_created INTEGER); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);`); + const part = db.prepare('INSERT INTO part VALUES (?,?,?,?,?)'); + part.run('p1', 'm1', 's1', IN_WINDOW, JSON.stringify({ type: 'tool', tool: 'origin-edge-mcp_health_check' })); + part.run('p2', 'm1', 's1', IN_WINDOW, JSON.stringify({ type: 'tool', tool: 'origin-edge-mcp_create_task' })); + part.run('p3', 'm2', 's2', IN_WINDOW, JSON.stringify({ type: 'tool', tool: 'read' })); + part.run('p4', 'm3', 's3', OUT_OF_WINDOW, JSON.stringify({ type: 'tool', tool: 'origin-edge-mcp_health_check' })); + const message = db.prepare('INSERT INTO message VALUES (?,?,?,?)'); + message.run('m1', 's1', IN_WINDOW, JSON.stringify({ role: 'user', agent: 'build' })); + message.run('m2', 's2', IN_WINDOW, JSON.stringify({ role: 'user', agent: 'unregistered-agent' })); + db.close(); + } + return home; +} + +const ctx = (homeDir: string): AdapterContext => ({ homeDir, days: 30, now: NOW }); + +describe('parseJsonc', () => { + it('strips comments and trailing commas without corrupting URLs in strings', () => { + const parsed = parseJsonc(CONFIG_JSONC)!; + expect(parsed['$schema']).toBe('https://opencode.ai/config.json'); + expect(Object.keys(parsed['agent'] as object)).toEqual(['build', 'review']); + }); + + it('handles block comments and escaped quotes', () => { + expect(parseJsonc('{/* x */ "a": "say \\"hi\\" // not a comment", }')).toEqual({ + a: 'say "hi" // not a comment', + }); + }); + + it('returns null on corrupt input instead of throwing', () => { + expect(parseJsonc('{ not json')).toBeNull(); + }); +}); + +describe('orgn-cde adapter (opencode family)', () => { + it('detects via config or data dir; rejects an empty home', async () => { + expect(await orgnCdeAdapter.detect(ctx(await makeHome(false)))).toBe(true); + expect(await orgnCdeAdapter.detect(ctx(mkdtempSync(join(tmpdir(), 'oc-empty-'))))).toBe(false); + }); + + it('scans agents, MCP servers, and commands from opencode.jsonc', async () => { + const inventory = await orgnCdeAdapter.scan(ctx(await makeHome(false))); + const ids = inventory.items.map((i) => i.id).sort(); + expect(ids).toEqual([ + 'orgn-cde/agent:build', + 'orgn-cde/agent:review', + 'orgn-cde/command:ship', + 'orgn-cde/mcp:origin-edge-mcp', + ]); + const build = inventory.items.find((i) => i.id === 'orgn-cde/agent:build')!; + expect(build.description).toBe('Implements features from specs'); + expect(build.tool).toBe('orgn-cde'); + }); + + it('mines MCP + registered-agent usage from the sqlite store within the window', async () => { + const usage = await orgnCdeAdapter.mineUsage(ctx(await makeHome(true))); + expect(usage.totalSessions).toBe(2); // s1, s2 in window; s3 outside + expect(usage.items['orgn-cde/mcp:origin-edge-mcp']).toMatchObject({ + count: 2, + sessionsSeen: 1, + }); + expect(usage.items['orgn-cde/agent:build']).toMatchObject({ count: 1, sessionsSeen: 1 }); + // Unregistered agents and builtin tools are never counted. + expect(usage.items['orgn-cde/agent:unregistered-agent']).toBeUndefined(); + }); + + it('degrades to zero usage when the DB is missing or has a foreign schema', async () => { + const noDb = await orgnCdeAdapter.mineUsage(ctx(await makeHome(false))); + expect(noDb).toEqual({ totalSessions: 0, items: {} }); + + const home = await makeHome(false); + const { DatabaseSync } = process.getBuiltinModule('node:sqlite')!; + const db = new DatabaseSync(join(home, '.local', 'share', 'orgn', 'opencode.db')); + db.exec('CREATE TABLE something_else (id TEXT)'); + db.close(); + const foreign = await orgnCdeAdapter.mineUsage(ctx(home)); + expect(foreign).toEqual({ totalSessions: 0, items: {} }); + }); + + it('makeOpencodeAdapter builds the vanilla opencode variant from different paths', async () => { + const adapter = makeOpencodeAdapter({ + name: 'opencode', + displayName: 'OpenCode', + paths: (homeDir) => ({ + configDir: join(homeDir, '.config', 'opencode'), + dataDir: join(homeDir, '.local', 'share', 'opencode'), + }), + }); + expect(await adapter.detect(ctx(await makeHome(false)))).toBe(false); // orgn dirs ≠ opencode dirs + }); +}); From a9bc58806bb14b8e8ae185c74e88248ddbe95471 Mon Sep 17 00:00:00 2001 From: Codefred Date: Wed, 22 Jul 2026 05:56:00 +0100 Subject: [PATCH 09/10] feat: cross-tool diagnostics and per-tool tuning bars --- src/adapters/codex.ts | 8 +- src/adapters/opencode-family.ts | 10 +- src/cli.ts | 11 +- src/cross-tool.ts | 145 ++++++++++++++++++++++++++ src/renderer/assets/app.js | 90 ++++++++++++++--- src/renderer/assets/style.css | 8 ++ src/renderer/index.ts | 15 +++ src/scanner.ts | 30 +++++- src/types.ts | 31 ++++++ test/cross-tool.test.ts | 173 ++++++++++++++++++++++++++++++++ test/renderer.test.ts | 25 +++-- test/scanner.test.ts | 4 + 12 files changed, 519 insertions(+), 31 deletions(-) create mode 100644 src/cross-tool.ts create mode 100644 test/cross-tool.test.ts diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts index f695c7d..edd6f7c 100644 --- a/src/adapters/codex.ts +++ b/src/adapters/codex.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { createInterface } from 'node:readline'; import { parse as parseToml } from 'smol-toml'; import type { ToolAdapter } from '../adapter.js'; +import { mcpIdentity } from '../scanner.js'; import type { Inventory, InventoryItem, McpTransport, Usage, UsageEntry } from '../types.js'; import { dirExists, @@ -50,7 +51,7 @@ async function scanCodex(homeDir: string, projectDir?: string): Promise 0) { lines.push(`Gaps: no real coverage for ${diagnostics.gaps.map((g) => g.axis).join(', ')}`); } + if (crossTool.duplicates.length > 0) { + lines.push(`Cross-tool: ${crossTool.duplicates[0]?.line ?? ''}`); + } + for (const finding of crossTool.imbalance.slice(0, 2)) { + lines.push(`Cross-tool: ${finding.line}`); + } lines.push(''); lines.push(`Map written to ${outPath}${shouldOpen ? ' (opening in browser)' : ''}`); if (opts.share === true) { diff --git a/src/cross-tool.ts b/src/cross-tool.ts new file mode 100644 index 0000000..d62f817 --- /dev/null +++ b/src/cross-tool.ts @@ -0,0 +1,145 @@ +import type { + Axis, + CapabilityImbalance, + ClassificationOutput, + CrossToolDuplicate, + CrossToolReport, + Inventory, + RulesOverlap, + ToolMeta, + Usage, +} from './types.js'; +import { AXES } from './types.js'; + +/** + * Cross-tool diagnostics (SPEC_V2 §4.5) — the insights only a unified map + * can give. Pure function, no I/O; every guard errs toward silence over a + * wrong claim. + */ + +const IMBALANCE_THRESHOLD = 0.8; + +/** Instruction-file names worth flagging when they coexist across tools. */ +const RULES_FILE_PATTERN = /(AGENTS\.md|CLAUDE\.md|GEMINI\.md|\.cursorrules|\.mdc)$/i; + +function displayName(tools: ToolMeta[], name: string): string { + return tools.find((t) => t.name === name)?.displayName ?? name; +} + +function duplicates( + inventory: Inventory, + usage: Usage, + tools: ToolMeta[], +): CrossToolDuplicate[] { + const byIdentity = new Map(); + for (const item of inventory.items) { + if (item.kind !== 'mcp' || item.identity === undefined) continue; + byIdentity.set(item.identity, [...(byIdentity.get(item.identity) ?? []), item]); + } + + const findings: CrossToolDuplicate[] = []; + for (const [key, items] of byIdentity) { + const toolNames = [...new Set(items.map((i) => i.tool).filter((t): t is string => t !== undefined))]; + if (toolNames.length < 2) continue; + + const usageSupportOf = (tool: string): string => + tools.find((t) => t.name === tool)?.usageSupport ?? 'none'; + const measurable = toolNames.filter((t) => usageSupportOf(t) !== 'none'); + const usedIn = toolNames.filter((tool) => + items.some( + (i) => i.tool === tool && (usage.items[i.id]?.count ?? 0) > 0, + ), + ); + + const serverName = items[0]?.name ?? key; + const toolLabels = toolNames.map((t) => displayName(tools, t)); + let line = `\`${serverName}\` MCP is installed in ${toolLabels.join(', ')}`; + if (measurable.length > 0) { + line += + usedIn.length === 0 + ? ' — it has never fired in any tool with usage data.' + : ` — it only ever fires in ${usedIn.map((t) => displayName(tools, t)).join(', ')}.`; + } else { + line += '.'; // no owner has usage data — make no usage claim (SPEC_V2 §4.5) + } + + findings.push({ key, itemIds: items.map((i) => i.id), usedIn, line }); + } + return findings.sort((a, b) => b.itemIds.length - a.itemIds.length); +} + +function imbalance( + inventory: Inventory, + classification: ClassificationOutput, + tools: ToolMeta[], +): CapabilityImbalance[] { + const toolOf = new Map(inventory.items.map((i) => [i.id, i.tool])); + const perAxisPerTool = new Map>(); + const classifiableTools = new Set(); + + for (const c of classification.items) { + const tool = toolOf.get(c.itemId); + if (tool === undefined) continue; + classifiableTools.add(tool); + for (const axis of AXES) { + const perTool = perAxisPerTool.get(axis) ?? new Map(); + perTool.set(tool, (perTool.get(tool) ?? 0) + (c.weights[axis] || 0)); + perAxisPerTool.set(axis, perTool); + } + } + if (classifiableTools.size < 2) return []; + + const findings: CapabilityImbalance[] = []; + for (const axis of AXES) { + const perTool = perAxisPerTool.get(axis) ?? new Map(); + const total = [...perTool.values()].reduce((a, b) => a + b, 0); + if (total <= 0.5) continue; // negligible capability — a gap, not an imbalance + for (const [tool, weight] of perTool) { + const share = weight / total; + if (share >= IMBALANCE_THRESHOLD) { + const others = [...classifiableTools].filter((t) => t !== tool); + findings.push({ + axis, + concentratedIn: tool, + share, + line: `${share >= 0.99 ? 'All' : 'Most'} ${axis} capability lives in ${displayName(tools, tool)}; ${others.map((t) => displayName(tools, t)).join(' and ')} ${others.length === 1 ? 'has' : 'have'} ${share >= 0.99 ? 'none' : 'little'}.`, + }); + } + } + } + return findings.sort((a, b) => b.share - a.share); +} + +function rulesOverlaps(inventory: Inventory, tools: ToolMeta[]): RulesOverlap[] { + const rules = inventory.items.filter( + (i) => i.kind === 'memory' && RULES_FILE_PATTERN.test(i.sourcePath), + ); + const byTool = new Map(); + for (const item of rules) { + if (item.tool === undefined) continue; + byTool.set(item.tool, [...(byTool.get(item.tool) ?? []), item]); + } + if (byTool.size < 2) return []; + + // One finding across the whole set — human review, no content diffing (SPEC_V2 §4.5.3). + const names = rules.map((i) => `${i.name} (${displayName(tools, i.tool ?? '')})`); + return [ + { + itemIds: rules.map((i) => i.id), + line: `${names.join(', ')} all give standing instructions — worth checking they agree.`, + }, + ]; +} + +export function crossToolDiagnose( + inventory: Inventory, + usage: Usage, + classification: ClassificationOutput, + tools: ToolMeta[], +): CrossToolReport { + return { + duplicates: duplicates(inventory, usage, tools), + imbalance: imbalance(inventory, classification, tools), + rulesOverlaps: rulesOverlaps(inventory, tools), + }; +} diff --git a/src/renderer/assets/app.js b/src/renderer/assets/app.js index 8f80f42..1737290 100644 --- a/src/renderer/assets/app.js +++ b/src/renderer/assets/app.js @@ -49,10 +49,10 @@ }); /* ---------- tuning bar ---------- */ - function tuningShares(mode) { + function tuningShares(mode, subset) { const totals = Object.fromEntries(AXES.map((a) => [a, 0])); let denom = 0; - for (const n of nodes) { + for (const n of subset || nodes) { const w = mode === 'used' ? n.usage.count : 1; if (w === 0) continue; denom += w; @@ -61,9 +61,57 @@ return denom === 0 ? null : AXES.map((a) => ({ axis: a, share: totals[a] / denom })); } + function segRow(shares, container) { + for (const { axis, share } of shares) { + if (share < 0.005) continue; + const seg = document.createElement('div'); + seg.className = 'tune-seg'; + seg.style.flexGrow = String(Math.max(share, 0.02)); + seg.style.background = AXIS_COLOR[axis]; + seg.title = axis + ' ' + Math.round(share * 100) + '%'; + const lbl = document.createElement('span'); + lbl.className = 'tune-lbl'; + lbl.textContent = axis + ' ' + Math.round(share * 100) + '%'; + seg.appendChild(lbl); + container.appendChild(seg); + } + } + + /* Per-tool mini bars — "Cursor is all engineering, Claude Code carries the + writing" in one glance (SPEC_V2 §4.4). Installed weights: usage-less + tools must render honestly here too. */ + function renderTuningByTool() { + const bar = document.getElementById('tuning-bar'); + bar.textContent = ''; + bar.classList.add('by-tool'); + const present = [...new Set(nodes.map((n) => n.item.tool).filter(Boolean))]; + for (const toolName of present) { + const subset = nodes.filter((n) => n.item.tool === toolName); + const shares = tuningShares('installed', subset); + if (shares === null) continue; + const row = document.createElement('div'); + row.className = 'tune-tool-row'; + const meta = toolMeta.get(toolName); + const label = document.createElement('span'); + label.className = 'tune-tool-label'; + label.style.color = meta !== undefined ? meta.color : '#8a93a6'; + label.textContent = meta !== undefined ? meta.displayName : toolName; + const mini = document.createElement('div'); + mini.className = 'tune-mini'; + segRow(shares, mini); + row.append(label, mini); + bar.appendChild(row); + } + } + function renderTuning(mode) { const bar = document.getElementById('tuning-bar'); + bar.classList.remove('by-tool'); bar.textContent = ''; + if (mode === 'bytool') { + renderTuningByTool(); + return; + } // Empty setup: both modes yield null — render an empty state, never throw // (an exception here would kill the whole page script). const shares = tuningShares(mode) || tuningShares('installed') || []; @@ -74,24 +122,13 @@ bar.appendChild(empty); return; } - for (const { axis, share } of shares) { - if (share < 0.005) continue; - const seg = document.createElement('div'); - seg.className = 'tune-seg'; - seg.style.flexGrow = String(Math.max(share, 0.02)); - seg.style.background = AXIS_COLOR[axis]; - seg.title = axis + ' ' + Math.round(share * 100) + '%'; - const lbl = document.createElement('span'); - lbl.className = 'tune-lbl'; - lbl.textContent = axis + ' ' + Math.round(share * 100) + '%'; - seg.appendChild(lbl); - bar.appendChild(seg); - } + segRow(shares, bar); } const totalUse = nodes.reduce((s, n) => s + n.usage.count, 0); const btnUsed = document.getElementById('toggle-used'); const btnInstalled = document.getElementById('toggle-installed'); + const btnByTool = document.getElementById('toggle-bytool'); let tuneMode = 'used'; if (totalUse === 0) { btnUsed.disabled = true; @@ -102,10 +139,16 @@ tuneMode = mode; btnUsed.classList.toggle('on', mode === 'used'); btnInstalled.classList.toggle('on', mode === 'installed'); + if (btnByTool !== null) btnByTool.classList.toggle('on', mode === 'bytool'); renderTuning(mode); } btnUsed.addEventListener('click', () => setTuneMode('used')); btnInstalled.addEventListener('click', () => setTuneMode('installed')); + if (btnByTool !== null) { + const multiTool = [...new Set(nodes.map((n) => n.item.tool).filter(Boolean))].length > 1; + if (multiTool) btnByTool.addEventListener('click', () => setTuneMode('bytool')); + else btnByTool.hidden = true; + } setTuneMode(tuneMode); /* ---------- map ---------- */ @@ -530,6 +573,23 @@ emptyText: 'No gaps — every axis has real coverage.', }); + /* ---------- cross-tool diagnostics (SPEC_V2 §4.5) ---------- */ + const crossTool = data.crossTool || { duplicates: [], imbalance: [], rulesOverlaps: [] }; + const crossSection = document.getElementById('crosstool'); + const multiToolMap = [...new Set(nodes.map((n) => n.item.tool).filter(Boolean))].length > 1; + if (crossSection !== null && multiToolMap) { + crossSection.hidden = false; + fillList('diag-xtool-dupes', crossTool.duplicates, { + emptyText: 'No MCP server is installed in more than one tool.', + }); + fillList('diag-xtool-imbalance', crossTool.imbalance, { + emptyText: 'Capabilities are spread across your tools.', + }); + fillList('diag-xtool-rules', crossTool.rulesOverlaps, { + emptyText: 'No overlapping rules files across tools.', + }); + } + /* ---------- share card (spec §4.4 — in-page PNG export, zero deps) ---------- */ const shareBtn = document.getElementById('share-btn'); diff --git a/src/renderer/assets/style.css b/src/renderer/assets/style.css index 8eb015e..20e3f04 100644 --- a/src/renderer/assets/style.css +++ b/src/renderer/assets/style.css @@ -295,3 +295,11 @@ body { @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } } + +/* ---------- v2: tool badges, by-tool tuning, cross-tool ---------- */ +#tuning-bar.by-tool { display: flex; flex-direction: column; gap: 3px; } +.tune-tool-row { display: flex; align-items: center; gap: 8px; } +.tune-tool-label { font-size: 10px; min-width: 82px; text-align: right; letter-spacing: 0.04em; } +.tune-mini { display: flex; flex: 1; height: 8px; border-radius: 4px; overflow: hidden; } +.tune-mini .tune-lbl { display: none; } +path.no-usage { stroke-linecap: round; } diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 28a5a49..3a0b711 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -66,6 +66,7 @@ export async function renderAtlas(data: AtlasData): Promise {
+
${roughBadge}
@@ -103,6 +104,20 @@ export async function renderAtlas(data: AtlasData): Promise {
    +