diff --git a/README.md b/README.md index 75790a9..1d3dc37 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # codebase-context -## Local-first second brain for AI Agents working on your codebase +## Local-first second brain for AI agents working on your codebase [![npm version](https://img.shields.io/npm/v/codebase-context)](https://www.npmjs.com/package/codebase-context) [![license](https://img.shields.io/npm/l/codebase-context)](./LICENSE) [![node](https://img.shields.io/node/v/codebase-context)](./package.json) @@ -20,7 +20,7 @@ Here's what codebase-context does: One tool call returns all of it. Local-first - your code never leaves your machine. - + ## Quick Start @@ -93,9 +93,57 @@ Open Settings > MCP and add: } ``` -## Codex +### Codex -Run codex mcp add codebase-context npx -y codebase-context "/path/to/your/project" +```bash +codex mcp add codebase-context npx -y codebase-context "/path/to/your/project" +``` + +## New to this codebase? + +Three commands to get what usually takes a new developer weeks to piece together: + +```bash +# What tech stack, architecture, and file count? +npx -y codebase-context metadata + +# What does the team actually code like right now? +npx -y codebase-context patterns + +# What team decisions were made (and why)? +npx -y codebase-context memory list +``` + +This is also what your AI agent consumes automatically via MCP tools; the CLI is the human-readable version. + +### CLI preview + +```text +$ npx -y codebase-context patterns +┌─ Team Patterns ──────────────────────────────────────────────────────┐ +│ │ +│ UNIT TEST FRAMEWORK │ +│ USE: Vitest – 96% adoption │ +│ alt CAUTION: Jest – 4% minority pattern │ +│ │ +│ STATE MANAGEMENT │ +│ PREFER: RxJS – 63% adoption │ +│ alt Redux-style store – 25% │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +```text +$ npx -y codebase-context search --query "file watcher" --intent edit --limit 1 +┌─ Search: "file watcher" ─── intent: edit ────────────────────────────┐ +│ Quality: ok (1.00) │ +│ Ready to edit: YES │ +│ │ +│ Best example: index.ts │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +See `docs/cli.md` for the full CLI gallery. ## What It Actually Does @@ -135,43 +183,8 @@ getToken(): string { Default output is lean — if the agent wants code, it calls `read_file`. -```json -{ - "searchQuality": { "status": "ok", "confidence": 0.72 }, - "preflight": { - "ready": false, - "nextAction": "2 of 5 callers aren't in results — search for src/app.module.ts", - "patterns": { - "do": ["HttpInterceptorFn — 97%", "standalone components — 84%"], - "avoid": ["constructor injection — 3% (declining)"] - }, - "bestExample": "src/auth/auth.interceptor.ts", - "impact": { - "coverage": "3/5 callers in results", - "files": ["src/app.module.ts", "src/boot.ts"] - }, - "whatWouldHelp": [ - "Search for src/app.module.ts to cover the main caller", - "Call get_team_patterns for auth/ injection patterns" - ] - }, - "results": [ - { - "file": "src/auth/auth.interceptor.ts:1-20", - "summary": "HTTP interceptor that attaches auth token to outgoing requests", - "score": 0.72, - "type": "service:core", - "trend": "Rising", - "relationships": { "importedByCount": 4, "hasTests": true }, - "hints": { - "callers": ["src/app.module.ts", "src/boot.ts"], - "tests": ["src/auth/auth.interceptor.spec.ts"] - } - } - ], - "relatedMemories": ["Always use HttpInterceptorFn (0.97)"] -} -``` +For scripting and automation, every CLI command accepts `--json` for machine output (stdout = JSON; logs/errors go to stderr). +See `docs/capabilities.md` for the field reference. Lean enough to fit on one screen. If search quality is low, preflight blocks edits instead of faking confidence. @@ -194,18 +207,18 @@ Record a decision once. It surfaces automatically in search results and prefligh ### All Tools -| Tool | What it does | -| ------------------------------ | ------------------------------------------------------------------------------------------- | -| `search_codebase` | Hybrid search + decision card. Pass `intent="edit"` to get `ready`, `nextAction`, patterns, caller coverage, and `whatWouldHelp`. | -| `get_team_patterns` | Pattern frequencies, golden files, conflict detection | +| Tool | What it does | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `search_codebase` | Hybrid search + decision card. Pass `intent="edit"` to get `ready`, `nextAction`, patterns, caller coverage, and `whatWouldHelp`. | +| `get_team_patterns` | Pattern frequencies, golden files, conflict detection | | `get_symbol_references` | Find concrete references to a symbol (usageCount + top snippets). `confidence: "syntactic"` = static/source-based only; no runtime or dynamic dispatch. | -| `remember` | Record a convention, decision, gotcha, or failure | -| `get_memory` | Query team memory with confidence decay scoring | -| `get_codebase_metadata` | Project structure, frameworks, dependencies | -| `get_style_guide` | Style guide rules for the current project | -| `detect_circular_dependencies` | Import cycles between files | -| `refresh_index` | Re-index (full or incremental) + extract git memories | -| `get_indexing_status` | Progress and stats for the current index | +| `remember` | Record a convention, decision, gotcha, or failure | +| `get_memory` | Query team memory with confidence decay scoring | +| `get_codebase_metadata` | Project structure, frameworks, dependencies | +| `get_style_guide` | Style guide rules for the current project | +| `detect_circular_dependencies` | Import cycles between files | +| `refresh_index` | Re-index (full or incremental) + extract git memories | +| `get_indexing_status` | Progress and stats for the current index | ## Evaluation Harness (`npm run eval`) @@ -229,7 +242,7 @@ npm run eval -- tests/fixtures/codebases/eval-controlled tests/fixtures/codebase The retrieval pipeline is designed around one goal: give the agent the right context, not just any file that matches. -- **Definition-first ranking** - for exact-name lookups (e.g. a symbol name), the file that *defines* the symbol ranks above files that only use it. +- **Definition-first ranking** - for exact-name lookups (e.g. a symbol name), the file that _defines_ the symbol ranks above files that only use it. - **Intent classification** - knows whether "AuthService" is a name lookup or "how does auth work" is conceptual. Adjusts keyword/semantic weights accordingly. - **Hybrid fusion (RRF)** - combines keyword and semantic search using Reciprocal Rank Fusion instead of brittle score averaging. - **Query expansion** - conceptual queries automatically expand with domain-relevant terms (auth → login, token, session, guard). @@ -289,64 +302,72 @@ Structured filters available: `framework`, `language`, `componentType`, `layer` ## CLI Reference -All MCP tools are available as CLI commands — no AI agent required. Useful for scripting, debugging, and CI workflows. +All MCP tools are available as CLI commands — no AI agent required. Useful for onboarding, scripting, debugging, and CI workflows. +For formatted examples and “money shots”, see `docs/cli.md`. Set `CODEBASE_ROOT` to your project root, or run from the project directory. ```bash # Search the indexed codebase -npx codebase-context search --query "authentication middleware" -npx codebase-context search --query "auth" --intent edit --limit 5 +npx -y codebase-context search --query "authentication middleware" +npx -y codebase-context search --query "auth" --intent edit --limit 5 # Project structure, frameworks, and dependencies -npx codebase-context metadata +npx -y codebase-context metadata # Index state and progress -npx codebase-context status +npx -y codebase-context status # Re-index the codebase -npx codebase-context reindex -npx codebase-context reindex --incremental --reason "added new service" +npx -y codebase-context reindex +npx -y codebase-context reindex --incremental --reason "added new service" # Style guide rules -npx codebase-context style-guide -npx codebase-context style-guide --query "naming" --category patterns +npx -y codebase-context style-guide +npx -y codebase-context style-guide --query "naming" --category patterns # Team patterns (DI, state, testing, etc.) -npx codebase-context patterns -npx codebase-context patterns --category testing +npx -y codebase-context patterns +npx -y codebase-context patterns --category testing # Symbol references -npx codebase-context refs --symbol "UserService" -npx codebase-context refs --symbol "handleLogin" --limit 20 +npx -y codebase-context refs --symbol "UserService" +npx -y codebase-context refs --symbol "handleLogin" --limit 20 # Circular dependency detection -npx codebase-context cycles -npx codebase-context cycles --scope src/features +npx -y codebase-context cycles +npx -y codebase-context cycles --scope src/features # Memory management -npx codebase-context memory list -npx codebase-context memory list --category conventions --type convention -npx codebase-context memory list --query "auth" --json -npx codebase-context memory add --type convention --category tooling --memory "Use pnpm, not npm" --reason "Workspace support and speed" -npx codebase-context memory remove +npx -y codebase-context memory list +npx -y codebase-context memory list --category conventions --type convention +npx -y codebase-context memory list --query "auth" --json +npx -y codebase-context memory add --type convention --category tooling --memory "Use pnpm, not npm" --reason "Workspace support and speed" +npx -y codebase-context memory remove ``` All commands accept `--json` for raw JSON output suitable for piping and scripting. -## Tip: Ensuring your AI Agent recalls memory: +## What to add to your CLAUDE.md / AGENTS.md -Add this to `.cursorrules`, `CLAUDE.md`, or `AGENTS.md`: +Paste this into `.cursorrules`, `CLAUDE.md`, `AGENTS.md`, or wherever your AI reads project instructions: -``` -## Codebase Context +```markdown +## Codebase Context (MCP) + +**Start of every task:** Call `get_memory` to load team conventions before writing any code. -**At start of each task:** Call `get_memory` to load team conventions. +**Before editing existing code:** Call `search_codebase` with `intent: "edit"`. If the preflight card says `ready: false`, read the listed files before touching anything. -**When user says "remember this" or "record this":** -- Call `remember` tool IMMEDIATELY before doing anything else. +**Before writing new code:** Call `get_team_patterns` to check how the team handles DI, state, testing, and library wrappers — don't introduce a new pattern if one already exists. + +**When asked to "remember" or "record" something:** Call `remember` immediately, before doing anything else. + +**When adding imports that cross module boundaries:** Call `detect_circular_dependencies` with the relevant scope after adding the import. ``` +These are the behaviors that make the most difference day-to-day. Copy, trim what doesn't apply to your stack, and add it once. + ## Links - [Motivation](./MOTIVATION.md) - Research and design rationale diff --git a/docs/capabilities.md b/docs/capabilities.md index 9714562..1250cea 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -4,14 +4,15 @@ Technical reference for what `codebase-context` ships today. For the user-facing ## CLI Reference -All 10 MCP tools are exposed as CLI subcommands. Set `CODEBASE_ROOT` or run from the project directory. +All shipped capabilities are available locally via the CLI (human-readable by default, `--json` for automation). +For a “gallery” of commands and examples, see `docs/cli.md`. | Command | Flags | Maps to | |---|---|---| | `search --query ` | `--intent explore\|edit\|refactor\|migrate`, `--limit `, `--lang `, `--framework `, `--layer ` | `search_codebase` | | `metadata` | — | `get_codebase_metadata` | | `status` | — | `get_indexing_status` | -| `reindex` | `--incremental`, `--reason ` | `refresh_index` | +| `reindex` | `--incremental`, `--reason ` | equivalent to `refresh_index` | | `style-guide` | `--query `, `--category ` | `get_style_guide` | | `patterns` | `--category all\|di\|state\|testing\|libraries` | `get_team_patterns` | | `refs --symbol ` | `--limit ` | `get_symbol_references` | @@ -95,8 +96,9 @@ Returned as `preflight` when search `intent` is `edit`, `refactor`, or `migrate` }; bestExample?: string; // Top 1 golden file (path format) impact?: { - coverage: string; // "X/Y callers in results" - files: string[]; // Top 3 impact candidates (files importing results) + coverage?: string; // "X/Y callers in results" + files?: string[]; // Back-compat: top impact candidates (paths only) + details?: Array<{ file: string; line?: number; hop: 1 | 2 }>; // When available }; whatWouldHelp?: string[]; // Concrete next steps (max 4) when ready=false } @@ -111,7 +113,8 @@ Returned as `preflight` when search `intent` is `edit`, `refactor`, or `migrate` - `patterns.avoid`: declining patterns, ranked by % (useful for migrations) - `bestExample`: exemplar file for the area under edit - `impact.coverage`: shows caller visibility ("3/5 callers in results" means 2 callers weren't searched yet) -- `impact.files`: which files import the results (helps find blind spots) +- `impact.details`: richer impact candidates with optional `line` and hop distance (1 = direct, 2 = transitive) +- `impact.files`: back-compat list of impact candidate paths (when details aren’t available) - `whatWouldHelp`: specific next searches, tool calls, or files to check that would close evidence gaps ### How `ready` is determined diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..ff7ce8c --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,194 @@ +# CLI Gallery (Human-readable) + +`codebase-context` exposes its MCP tools as a local CLI so humans can: + +- Onboard themselves onto an unfamiliar repo +- Debug what the MCP server is doing +- Use outputs in CI/scripts (via `--json`) + +> Output depends on the repo you run it against. The examples below are illustrative (paths, counts, and detected frameworks will vary). + +## How to run + +```bash +# Run from a repo root, or set CODEBASE_ROOT explicitly: +CODEBASE_ROOT=/path/to/repo npx -y codebase-context status + +# Every command supports --json (machine output). Human mode is default. +npx -y codebase-context patterns --json +``` + +### ASCII fallback + +If your terminal doesn’t render Unicode box-drawing cleanly: + +```bash +CODEBASE_CONTEXT_ASCII=1 npx -y codebase-context patterns +``` + +## Commands + +- `metadata` — tech stack overview +- `patterns` — team conventions + adoption/trends +- `search --query ` — ranked results; add `--intent edit` for a preflight card +- `refs --symbol ` — concrete reference evidence +- `cycles` — circular dependency detection +- `status` — index status/progress +- `reindex` — rebuild index (full or incremental) +- `style-guide` — find style guide sections in docs +- `memory list|add|remove` — manage team memory (stored in `.codebase-context/memory.json`) + +--- + +## `metadata` + +```bash +npx -y codebase-context metadata +``` + +Example output: + +```text +┌─ codebase-context [monorepo] ────────────────────────────────────────┐ +│ │ +│ Framework: Angular unknown Architecture: mixed │ +│ 130 files · 24,211 lines · 1077 components │ +│ │ +│ Dependencies: @huggingface/transformers · @lancedb/lancedb · │ +│ @modelcontextprotocol/sdk · @typescript-eslint/typescript-estree · │ +│ chokidar · fuse.js (+14 more) │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## `patterns` + +```bash +npx -y codebase-context patterns +``` + +Example output (truncated): + +```text +┌─ Team Patterns ──────────────────────────────────────────────────────┐ +│ │ +│ UNIT TEST FRAMEWORK │ +│ USE: Vitest – 96% adoption │ +│ alt CAUTION: Jest – 4% minority pattern │ +│ │ +│ STATE MANAGEMENT │ +│ PREFER: RxJS – 63% adoption │ +│ alt Redux-style store – 25% │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## `search` + +```bash +npx -y codebase-context search --query "file watcher" --intent edit --limit 3 +``` + +Example output (truncated): + +```text +┌─ Search: "file watcher" ─── intent: edit ────────────────────────────┐ +│ Quality: ok (1.00) │ +│ Ready to edit: YES │ +│ │ +│ Best example: index.ts │ +└──────────────────────────────────────────────────────────────────────┘ + +1. src/core/file-watcher.ts:44-74 + confidence: ██████████ 1.18 + typescript module in file-watcher.ts: startFileWatcher :: (...) +``` + +## `refs` + +```bash +npx -y codebase-context refs --symbol "startFileWatcher" --limit 10 +``` + +Example output (truncated): + +```text +┌─ startFileWatcher ─── 11 references ─── static analysis ─────────────┐ +│ │ +│ startFileWatcher │ +│ │ │ +│ ├─ file-watcher.test.ts:5 │ +│ │ import { startFileWatcher } from '../src/core/file-watcher.... │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## `cycles` + +```bash +npx -y codebase-context cycles --scope src +``` + +Example output: + +```text +┌─ Circular Dependencies ──────────────────────────────────────────────┐ +│ │ +│ No cycles found · 98 files · 260 edges · 2.7 avg deps │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## `status` + +```bash +npx -y codebase-context status +``` + +Example output: + +```text +┌─ Index Status ───────────────────────────────────────────────────────┐ +│ │ +│ State: ready │ +│ Root: /path/to/repo │ +│ │ +│ → Use refresh_index to manually trigger re-indexing when needed. │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## `reindex` + +```bash +npx -y codebase-context reindex +npx -y codebase-context reindex --incremental --reason "changed watcher logic" +``` + +## `style-guide` + +```bash +npx -y codebase-context style-guide --query "naming" +``` + +Example output: + +```text +No style guides found. + Hint: Try broader terms like 'naming', 'patterns', 'testing', 'components' +``` + +## `memory` + +```bash +npx -y codebase-context memory list +npx -y codebase-context memory list --query "watcher" + +npx -y codebase-context memory add \ + --type gotcha \ + --category tooling \ + --memory "Use pnpm, not npm" \ + --reason "Workspace support and speed" + +npx -y codebase-context memory remove +``` diff --git a/src/cli-formatters.ts b/src/cli-formatters.ts index dfa15b7..5493d7e 100644 --- a/src/cli-formatters.ts +++ b/src/cli-formatters.ts @@ -21,6 +21,71 @@ import type { export const BOX_WIDTH = 72; +type Charset = 'unicode' | 'ascii'; + +function parseEnvBoolean(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +function getCharset(): Charset { + if (parseEnvBoolean(process.env.CODEBASE_CONTEXT_ASCII)) return 'ascii'; + if (process.stdout && process.stdout.isTTY === false) return 'ascii'; + return 'unicode'; +} + +type CliGlyphs = { + charset: Charset; + box: { + tl: string; + tr: string; + bl: string; + br: string; + h: string; + v: string; + }; + tree: { + tee: string; + elbow: string; + pipe: string; + }; + arrow: string; + leftRight: string; + dot: string; + warn: string; + bar: { + full: string; + empty: string; + }; +}; + +const UNICODE_GLYPHS: CliGlyphs = { + charset: 'unicode', + box: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' }, + tree: { tee: '├─', elbow: '└─', pipe: '│' }, + arrow: '→', + leftRight: '↔', + dot: '·', + warn: '⚠', + bar: { full: '█', empty: '░' } +}; + +const ASCII_GLYPHS: CliGlyphs = { + charset: 'ascii', + box: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }, + tree: { tee: '|-', elbow: '`-', pipe: '|' }, + arrow: '->', + leftRight: '<->', + dot: '*', + warn: '!', + bar: { full: '#', empty: '.' } +}; + +function getGlyphs(): CliGlyphs { + return getCharset() === 'ascii' ? ASCII_GLYPHS : UNICODE_GLYPHS; +} + export function shortPath(filePath: string, rootPath: string): string { const normalized = filePath.replace(/\\/g, '/'); const normalizedRoot = rootPath.replace(/\\/g, '/'); @@ -45,7 +110,7 @@ export function formatTrend(trend?: string): string { export function formatType(type?: string): string { if (!type) return ''; - // "interceptor:core" → "interceptor (core)", "resolver:unknown" → "resolver" + // "interceptor:core" -> "interceptor (core)", "resolver:unknown" -> "resolver" const [compType, layer] = type.split(':'); if (!layer || layer === 'unknown') return compType; return `${compType} (${layer})`; @@ -60,9 +125,10 @@ export function padLeft(str: string, len: number): string { } export function barChart(pct: number, width: number = 10): string { + const g = getGlyphs(); const clamped = Math.max(0, Math.min(100, pct)); const filled = Math.round((clamped / 100) * width); - return '\u2588'.repeat(filled) + '\u2591'.repeat(width - filled); + return g.bar.full.repeat(filled) + g.bar.empty.repeat(width - filled); } export function scoreBar(score: number, width: number = 10): string { @@ -92,20 +158,21 @@ export function wrapLine(text: string, maxWidth: number): string[] { } export function drawBox(title: string, lines: string[], width: number = 60): string[] { + const g = getGlyphs(); const output: string[] = []; const inner = width - 4; // 2 for "| " + 2 for " |" - const dashes = '\u2500'; - const titlePart = `\u250c\u2500 ${title} `; + const dashes = g.box.h; + const titlePart = `${g.box.tl}${g.box.h} ${title} `; const remaining = Math.max(0, width - titlePart.length - 1); - output.push(titlePart + dashes.repeat(remaining) + '\u2510'); + output.push(titlePart + dashes.repeat(remaining) + g.box.tr); for (const line of lines) { const wrapped = wrapLine(line, inner); for (const wl of wrapped) { const padded = wl + ' '.repeat(Math.max(0, inner - wl.length)); - output.push(`\u2502 ${padded} \u2502`); + output.push(`${g.box.v} ${padded} ${g.box.v}`); } } - output.push('\u2514' + dashes.repeat(width - 2) + '\u2518'); + output.push(g.box.bl + dashes.repeat(width - 2) + g.box.br); return output; } @@ -115,6 +182,7 @@ export function getCycleFiles(cycle: CycleItem): string[] { } export function formatPatterns(data: PatternResponse): void { + const g = getGlyphs(); const { patterns, goldenFiles, memories, conflicts } = data; const lines: string[] = []; @@ -127,7 +195,7 @@ export function formatPatterns(data: PatternResponse): void { .replace(/^./, (s) => s.toUpperCase()) .trim(); if (ei > 0) { - lines.push(' ' + '\u2500'.repeat(66)); + lines.push(' ' + g.box.h.repeat(66)); } lines.push(''); lines.push(label.toUpperCase()); @@ -159,7 +227,7 @@ export function formatPatterns(data: PatternResponse): void { const topUsed = data.topUsed; if (topUsed && topUsed.length > 0) { lines.push(''); - lines.push('\u2500'.repeat(66)); + lines.push(g.box.h.repeat(66)); lines.push(''); lines.push('TOP LIBRARIES'); for (const lib of topUsed.slice(0, 15) as LibraryEntry[]) { @@ -170,7 +238,7 @@ export function formatPatterns(data: PatternResponse): void { if (goldenFiles && goldenFiles.length > 0) { lines.push(''); - lines.push('\u2500'.repeat(66)); + lines.push(g.box.h.repeat(66)); lines.push(''); lines.push('GOLDEN FILES'); for (const gf of goldenFiles.slice(0, 5)) { @@ -181,7 +249,7 @@ export function formatPatterns(data: PatternResponse): void { if (conflicts && conflicts.length > 0) { lines.push(''); - lines.push('\u2500'.repeat(66)); + lines.push(g.box.h.repeat(66)); lines.push(''); lines.push('CONFLICTS'); for (const c of conflicts) { @@ -195,7 +263,7 @@ export function formatPatterns(data: PatternResponse): void { if (memories && memories.length > 0) { lines.push(''); - lines.push('\u2500'.repeat(66)); + lines.push(g.box.h.repeat(66)); lines.push(''); lines.push('MEMORIES'); for (const m of memories.slice(0, 5)) { @@ -219,6 +287,7 @@ export function formatSearch( query?: string, intent?: string ): void { + const g = getGlyphs(); const { searchQuality: quality, preflight, results, relatedMemories: memories } = data; const boxLines: string[] = []; @@ -285,7 +354,7 @@ export function formatSearch( if (whatWouldHelp && whatWouldHelp.length > 0) { boxLines.push(''); for (const h of whatWouldHelp) { - boxLines.push(`\u2192 ${h}`); + boxLines.push(`${g.arrow} ${h}`); } } } @@ -294,7 +363,7 @@ export function formatSearch( if (query) titleParts.push(`"${query}"`); if (intent) titleParts.push(`intent: ${intent}`); const boxTitle = - titleParts.length > 0 ? `Search: ${titleParts.join(' \u2500\u2500\u2500 ')}` : 'Search'; + titleParts.length > 0 ? `Search: ${titleParts.join(` ${g.box.h.repeat(3)} `)}` : 'Search'; console.log(''); if (boxLines.length > 0) { @@ -305,7 +374,7 @@ export function formatSearch( console.log(''); } else if (quality) { const status = quality.status === 'ok' ? 'ok' : 'low confidence'; - console.log(` ${results?.length ?? 0} results · quality: ${status}`); + console.log(` ${results?.length ?? 0} results ${g.dot} quality: ${status}`); console.log(''); } @@ -323,7 +392,7 @@ export function formatSearch( if (trendPart) metaParts.push(trendPart); console.log(`${i + 1}. ${file}`); - console.log(` ${metaParts.join(' \u00b7 ')}`); + console.log(` ${metaParts.join(` ${g.dot} `)}`); const summary = r.summary ?? ''; if (summary) { @@ -332,7 +401,7 @@ export function formatSearch( } if (r.patternWarning) { - console.log(` \u26a0 ${r.patternWarning}`); + console.log(` ${g.warn} ${r.patternWarning}`); } const hints = r.hints; @@ -350,7 +419,7 @@ export function formatSearch( while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') trimmed.pop(); const shown = trimmed.slice(0, 8); for (const sl of shown) { - console.log(` \u2502 ${sl}`); + console.log(` ${g.box.v} ${sl}`); } } @@ -368,6 +437,7 @@ export function formatSearch( } export function formatRefs(data: RefsResponse, rootPath: string): void { + const g = getGlyphs(); const { symbol, usageCount: count, confidence, usages } = data; const lines: string[] = []; @@ -375,11 +445,11 @@ export function formatRefs(data: RefsResponse, rootPath: string): void { lines.push(String(symbol)); if (usages && usages.length > 0) { - lines.push('\u2502'); + lines.push(g.tree.pipe); for (let i = 0; i < usages.length; i++) { const u: RefsUsage = usages[i]; const isLast = i === usages.length - 1; - const branch = isLast ? '\u2514\u2500' : '\u251c\u2500'; + const branch = isLast ? g.tree.elbow : g.tree.tee; const file = shortPath(u.file ?? '', rootPath); lines.push(`${branch} ${file}:${u.line}`); @@ -390,7 +460,7 @@ export function formatRefs(data: RefsResponse, rootPath: string): void { .map((l) => l.trim()) .filter(Boolean) .slice(0, 2); - const indent = isLast ? ' ' : '\u2502 '; + const indent = isLast ? ' ' : `${g.tree.pipe} `; const maxPrev = BOX_WIDTH - 10; for (const pl of nonEmpty) { const clipped = pl.length > maxPrev ? pl.slice(0, maxPrev - 3) + '...' : pl; @@ -399,7 +469,7 @@ export function formatRefs(data: RefsResponse, rootPath: string): void { } if (!isLast) { - lines.push('\u2502'); + lines.push(g.tree.pipe); } } } @@ -408,7 +478,7 @@ export function formatRefs(data: RefsResponse, rootPath: string): void { const confLabel = confidence === 'syntactic' ? 'static analysis' : (confidence ?? 'static analysis'); - const boxTitle = `${symbol} \u2500\u2500\u2500 ${count} references \u2500\u2500\u2500 ${confLabel}`; + const boxTitle = `${symbol} ${g.box.h.repeat(3)} ${count} references ${g.box.h.repeat(3)} ${confLabel}`; const boxOut = drawBox(boxTitle, lines, BOX_WIDTH); console.log(''); for (const l of boxOut) { @@ -418,6 +488,7 @@ export function formatRefs(data: RefsResponse, rootPath: string): void { } export function formatCycles(data: CyclesResponse, rootPath: string): void { + const g = getGlyphs(); const cycles = data.cycles ?? []; const stats = data.graphStats; @@ -434,7 +505,7 @@ export function formatCycles(data: CyclesResponse, rootPath: string): void { const lines: string[] = []; lines.push(''); - lines.push(statParts.join(' \u00b7 ')); + lines.push(statParts.join(` ${g.dot} `)); for (const c of cycles) { const sev = (c.severity ?? 'low').toLowerCase(); @@ -443,9 +514,9 @@ export function formatCycles(data: CyclesResponse, rootPath: string): void { lines.push(''); if (nodes.length === 2) { - lines.push(` ${sevLabel} ${nodes[0]} \u2194 ${nodes[1]}`); + lines.push(` ${sevLabel} ${nodes[0]} ${g.leftRight} ${nodes[1]}`); } else { - const arrow = ' \u2192 '; + const arrow = ` ${g.arrow} `; const full = nodes.join(arrow); if (full.length <= 60) { lines.push(` ${sevLabel} ${full}`); @@ -483,6 +554,8 @@ export function formatMetadata(data: MetadataResponse): void { return; } + const g = getGlyphs(); + const lines: string[] = []; lines.push(''); @@ -518,7 +591,7 @@ export function formatMetadata(data: MetadataResponse): void { if (stats.totalFiles != null) statParts.push(`${stats.totalFiles} files`); if (stats.totalLines != null) statParts.push(`${stats.totalLines.toLocaleString()} lines`); if (stats.totalComponents != null) statParts.push(`${stats.totalComponents} components`); - if (statParts.length > 0) lines.push(statParts.join(' · ')); + if (statParts.length > 0) lines.push(statParts.join(` ${g.dot} `)); } // Dependencies @@ -529,7 +602,7 @@ export function formatMetadata(data: MetadataResponse): void { `Dependencies: ${deps .slice(0, 6) .map((d: MetadataDependency) => d.name) - .join(' · ')}${deps.length > 6 ? ` (+${deps.length - 6} more)` : ''}` + .join(` ${g.dot} `)}${deps.length > 6 ? ` (+${deps.length - 6} more)` : ''}` ); } @@ -559,7 +632,7 @@ export function formatMetadata(data: MetadataResponse): void { `Modules: ${modules .slice(0, 6) .map((mod) => mod.name) - .join(' · ')}${modules.length > 6 ? ` (+${modules.length - 6})` : ''}` + .join(` ${g.dot} `)}${modules.length > 6 ? ` (+${modules.length - 6})` : ''}` ); } @@ -576,7 +649,85 @@ export function formatMetadata(data: MetadataResponse): void { console.log(''); } +type IndexingStatusPayload = { + status?: string; + rootPath?: string; + lastIndexed?: string; + stats?: { + totalFiles?: number; + indexedFiles?: number; + totalChunks?: number; + duration?: string; + incremental?: boolean; + }; + progress?: { + phase?: string; + percentage?: number; + filesProcessed?: number; + totalFiles?: number; + }; + error?: string; + hint?: string; +}; + +export function formatStatus(data: IndexingStatusPayload, rootPath: string): void { + const g = getGlyphs(); + const lines: string[] = []; + lines.push(''); + + const state = data.status ?? 'unknown'; + lines.push(`State: ${state}`); + + const effectiveRoot = data.rootPath ?? rootPath; + if (effectiveRoot) { + lines.push(`Root: ${effectiveRoot}`); + } + + if (data.lastIndexed) { + lines.push(`Last: ${data.lastIndexed}`); + } + + if (data.stats) { + const s = data.stats; + const parts: string[] = []; + if (s.indexedFiles != null) parts.push(`${s.indexedFiles} files`); + if (s.totalChunks != null) parts.push(`${s.totalChunks} chunks`); + if (s.duration) parts.push(s.duration); + if (typeof s.incremental === 'boolean') parts.push(s.incremental ? 'incremental' : 'full'); + if (parts.length > 0) lines.push(`Stats: ${parts.join(` ${g.dot} `)}`); + } + + if (data.progress) { + const p = data.progress; + const pct = typeof p.percentage === 'number' ? p.percentage : undefined; + const phase = p.phase ?? 'working'; + if (pct != null) { + lines.push(`Progress: ${padRight(phase, 12)} ${barChart(pct, 18)} ${pct}%`); + } else { + lines.push(`Progress: ${phase}`); + } + } + + if (data.error) { + lines.push(''); + lines.push(`${g.warn} ${data.error}`); + } + + if (data.hint) { + lines.push(''); + lines.push(`${g.arrow} ${data.hint}`); + } + + lines.push(''); + + const boxOut = drawBox('Index Status', lines, BOX_WIDTH); + console.log(''); + for (const l of boxOut) console.log(l); + console.log(''); +} + export function formatStyleGuide(data: StyleGuideResponse, rootPath: string): void { + const g = getGlyphs(); if (data.status === 'no_results' || !data.results || data.results.length === 0) { console.log(''); console.log('No style guides found.'); @@ -601,12 +752,12 @@ export function formatStyleGuide(data: StyleGuideResponse, rootPath: string): vo } else { const filePart = `${totalFiles} file${totalFiles === 1 ? '' : 's'}`; const matchPart = `${totalMatches} match${totalMatches === 1 ? '' : 'es'}`; - countParts.push(`${filePart} · ${matchPart}`); + countParts.push(`${filePart} ${g.dot} ${matchPart}`); } lines.push(countParts[0]); if (data.notice) { - lines.push(`\u2192 ${data.notice}`); + lines.push(`${g.arrow} ${data.notice}`); } for (const result of data.results) { @@ -665,6 +816,14 @@ export function formatJson( } switch (command) { + case 'status': { + try { + formatStatus(data as IndexingStatusPayload, rootPath ?? ''); + } catch { + console.log(JSON.stringify(data, null, 2)); + } + break; + } case 'metadata': { try { formatMetadata(data as MetadataResponse); diff --git a/src/cli-memory.ts b/src/cli-memory.ts index 82cbb6e..6921338 100644 --- a/src/cli-memory.ts +++ b/src/cli-memory.ts @@ -3,7 +3,7 @@ */ import path from 'path'; -import type { Memory, MemoryCategory, MemoryType } from './types/index.js'; +import type { Memory } from './types/index.js'; import { CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME } from './constants/codebase-context.js'; import { appendMemoryFile, @@ -19,69 +19,100 @@ const MEMORY_CATEGORIES = [ 'testing', 'dependencies', 'conventions' -] as const satisfies readonly MemoryCategory[]; +] as const; +type CliMemoryCategory = (typeof MEMORY_CATEGORIES)[number]; -const MEMORY_TYPES = [ - 'convention', - 'decision', - 'gotcha', - 'failure' -] as const satisfies readonly MemoryType[]; +const MEMORY_TYPES = ['convention', 'decision', 'gotcha', 'failure'] as const; +type CliMemoryType = (typeof MEMORY_TYPES)[number]; const MEMORY_CATEGORY_SET: ReadonlySet = new Set(MEMORY_CATEGORIES); -function isMemoryCategory(value: string): value is MemoryCategory { +function isCliMemoryCategory(value: string): value is CliMemoryCategory { return MEMORY_CATEGORY_SET.has(value); } const MEMORY_TYPE_SET: ReadonlySet = new Set(MEMORY_TYPES); -function isMemoryType(value: string): value is MemoryType { +function isCliMemoryType(value: string): value is CliMemoryType { return MEMORY_TYPE_SET.has(value); } -function exitWithError(message: string): never { - console.error(message); - process.exit(1); -} - export async function handleMemoryCli(args: string[]): Promise { // Resolve project root: use CODEBASE_ROOT env or cwd (argv[2] is "memory", not a path) const cliRoot = process.env.CODEBASE_ROOT || process.cwd(); const memoryPath = path.join(cliRoot, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME); const subcommand = args[0]; // list | add | remove + const useJson = args.includes('--json'); + + const listUsage = + 'Usage: codebase-context memory list [--category ] [--type ] [--query ] [--json]'; + const addUsage = + 'Usage: codebase-context memory add --type --category --memory --reason [--json]'; + const removeUsage = 'Usage: codebase-context memory remove [--json]'; + + const exitWithUsageError = (message: string, usage?: string): never => { + if (useJson) { + console.log( + JSON.stringify( + { + status: 'error', + message, + ...(usage ? { usage } : {}) + }, + null, + 2 + ) + ); + } else { + console.error(message); + if (usage) console.error(usage); + } + process.exit(1); + }; if (subcommand === 'list') { const memories = await readMemoriesFile(memoryPath); - const opts: { category?: MemoryCategory; type?: MemoryType; query?: string } = {}; + const opts: { category?: CliMemoryCategory; type?: CliMemoryType; query?: string } = {}; for (let i = 1; i < args.length; i++) { if (args[i] === '--category') { const value = args[i + 1]; if (!value || value.startsWith('--')) { - exitWithError( - `Error: --category requires a value. Allowed: ${MEMORY_CATEGORIES.join(', ')}` + exitWithUsageError( + `Error: --category requires a value. Allowed: ${MEMORY_CATEGORIES.join(', ')}`, + listUsage ); } - if (!isMemoryCategory(value)) { - exitWithError( - `Error: invalid --category "${value}". Allowed: ${MEMORY_CATEGORIES.join(', ')}` + + if (isCliMemoryCategory(value)) { + opts.category = value; + } else { + exitWithUsageError( + `Error: invalid --category "${value}". Allowed: ${MEMORY_CATEGORIES.join(', ')}`, + listUsage ); } - opts.category = value; i++; } else if (args[i] === '--type') { const value = args[i + 1]; if (!value || value.startsWith('--')) { - exitWithError(`Error: --type requires a value. Allowed: ${MEMORY_TYPES.join(', ')}`); + exitWithUsageError( + `Error: --type requires a value. Allowed: ${MEMORY_TYPES.join(', ')}`, + listUsage + ); } - if (!isMemoryType(value)) { - exitWithError(`Error: invalid --type "${value}". Allowed: ${MEMORY_TYPES.join(', ')}`); + + if (isCliMemoryType(value)) { + opts.type = value; + } else { + exitWithUsageError( + `Error: invalid --type "${value}". Allowed: ${MEMORY_TYPES.join(', ')}`, + listUsage + ); } - opts.type = value; i++; } else if (args[i] === '--query') { const value = args[i + 1]; if (!value || value.startsWith('--')) { - exitWithError('Error: --query requires a value.'); + exitWithUsageError('Error: --query requires a value.', listUsage); } opts.query = value; i++; @@ -92,7 +123,6 @@ export async function handleMemoryCli(args: string[]): Promise { const filtered = filterMemories(memories, opts); const enriched = withConfidence(filtered); - const useJson = args.includes('--json'); if (useJson) { console.log(JSON.stringify(enriched, null, 2)); @@ -111,8 +141,8 @@ export async function handleMemoryCli(args: string[]): Promise { } } } else if (subcommand === 'add') { - let type: MemoryType = 'decision'; - let category: MemoryCategory | undefined; + let type: CliMemoryType = 'decision'; + let category: CliMemoryCategory | undefined; let memory: string | undefined; let reason: string | undefined; @@ -120,92 +150,120 @@ export async function handleMemoryCli(args: string[]): Promise { if (args[i] === '--type') { const value = args[i + 1]; if (!value || value.startsWith('--')) { - exitWithError(`Error: --type requires a value. Allowed: ${MEMORY_TYPES.join(', ')}`); + exitWithUsageError( + `Error: --type requires a value. Allowed: ${MEMORY_TYPES.join(', ')}`, + addUsage + ); } - if (!isMemoryType(value)) { - exitWithError(`Error: invalid --type "${value}". Allowed: ${MEMORY_TYPES.join(', ')}`); + + if (isCliMemoryType(value)) { + type = value; + } else { + exitWithUsageError( + `Error: invalid --type "${value}". Allowed: ${MEMORY_TYPES.join(', ')}`, + addUsage + ); } - type = value; i++; } else if (args[i] === '--category') { const value = args[i + 1]; if (!value || value.startsWith('--')) { - exitWithError( - `Error: --category requires a value. Allowed: ${MEMORY_CATEGORIES.join(', ')}` + exitWithUsageError( + `Error: --category requires a value. Allowed: ${MEMORY_CATEGORIES.join(', ')}`, + addUsage ); } - if (!isMemoryCategory(value)) { - exitWithError( - `Error: invalid --category "${value}". Allowed: ${MEMORY_CATEGORIES.join(', ')}` + + if (isCliMemoryCategory(value)) { + category = value; + } else { + exitWithUsageError( + `Error: invalid --category "${value}". Allowed: ${MEMORY_CATEGORIES.join(', ')}`, + addUsage ); } - category = value; i++; } else if (args[i] === '--memory') { const value = args[i + 1]; if (!value || value.startsWith('--')) { - exitWithError('Error: --memory requires a value.'); + exitWithUsageError('Error: --memory requires a value.', addUsage); } memory = value; i++; } else if (args[i] === '--reason') { const value = args[i + 1]; if (!value || value.startsWith('--')) { - exitWithError('Error: --reason requires a value.'); + exitWithUsageError('Error: --reason requires a value.', addUsage); } reason = value; i++; + } else if (args[i] === '--json') { + // handled above } } if (!category || !memory || !reason) { - console.error( - 'Usage: codebase-context memory add --type --category --memory --reason ' - ); - console.error('Required: --category, --memory, --reason'); - process.exit(1); + exitWithUsageError('Error: required flags missing: --category, --memory, --reason', addUsage); + return; } + const requiredCategory = category; + const requiredMemory = memory; + const requiredReason = reason; + const crypto = await import('crypto'); - const hashContent = `${type}:${category}:${memory}:${reason}`; + const hashContent = `${type}:${requiredCategory}:${requiredMemory}:${requiredReason}`; const hash = crypto.createHash('sha256').update(hashContent).digest('hex'); const id = hash.substring(0, 12); const newMemory: Memory = { id, type, - category, - memory, - reason, + category: requiredCategory, + memory: requiredMemory, + reason: requiredReason, date: new Date().toISOString() }; const result = await appendMemoryFile(memoryPath, newMemory); + if (useJson) { + console.log(JSON.stringify(result, null, 2)); + return; + } + if (result.status === 'duplicate') { console.log(`Already exists: [${id}] ${memory}`); - } else { - console.log(`Added: [${id}] ${memory}`); + return; } + + console.log(`Added: [${id}] ${memory}`); } else if (subcommand === 'remove') { - const id = args[1]; - if (!id) { - console.error('Usage: codebase-context memory remove '); - process.exit(1); + const id = args.slice(1).find((value) => value !== '--json' && !value.startsWith('--')); + if (id === undefined) { + exitWithUsageError('Error: missing memory id.', removeUsage); + return; } const result = await removeMemory(memoryPath, id); if (result.status === 'not_found') { - console.error(`Memory not found: ${id}`); + if (useJson) { + console.log(JSON.stringify({ status: 'not_found', id }, null, 2)); + } else { + console.error(`Memory not found: ${id}`); + } process.exit(1); - } else { - console.log(`Removed: ${id}`); } + + if (useJson) { + console.log(JSON.stringify({ status: 'removed', id }, null, 2)); + return; + } + + console.log(`Removed: ${id}`); } else { - console.error('Usage: codebase-context memory '); - console.error(''); - console.error(' list [--category ] [--type ] [--query ] [--json]'); - console.error(' add --type --category --memory --reason '); - console.error(' remove '); - process.exit(1); + exitWithUsageError( + 'Error: unknown subcommand. Expected: list | add | remove', + 'Usage: codebase-context memory ' + ); } } diff --git a/src/cli.ts b/src/cli.ts index eedc326..3aea7d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -82,6 +82,8 @@ function printUsage(): void { console.log(''); console.log('Environment:'); console.log(' CODEBASE_ROOT Project root path (default: cwd)'); + console.log(' CODEBASE_CONTEXT_ASCII=1 Force ASCII-only box output'); + console.log(' CODEBASE_CONTEXT_DEBUG=1 Enable verbose logs'); } async function initToolContext(): Promise { @@ -328,7 +330,7 @@ export async function handleCliCommand(argv: string[]): Promise { const incremental = booleanFlag(flags, 'incremental', usage); await ctx.performIndexing(incremental, reason); const statusResult = await dispatchTool('get_indexing_status', {}, ctx); - formatJson(extractText(statusResult), useJson); + formatJson(extractText(statusResult), useJson, 'status', ctx.rootPath); return; } case 'style-guide': { diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 4fb411d..933c265 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'path'; +import os from 'os'; +import { promises as fs } from 'fs'; const toolMocks = vi.hoisted(() => ({ dispatchTool: vi.fn() @@ -83,6 +86,38 @@ describe('CLI', () => { expect(toolMocks.dispatchTool).not.toHaveBeenCalled(); }); + it('status renders human output (not raw JSON)', async () => { + const originalAscii = process.env.CODEBASE_CONTEXT_ASCII; + process.env.CODEBASE_CONTEXT_ASCII = '1'; + + try { + toolMocks.dispatchTool.mockResolvedValue({ + content: [ + { + type: 'text', + text: JSON.stringify({ + status: 'indexing', + rootPath: '/tmp/repo', + stats: { indexedFiles: 10, totalChunks: 42, duration: '1.23s', incremental: true }, + progress: { phase: 'embedding', percentage: 60 }, + hint: 'Use refresh_index to manually trigger re-indexing when needed.' + }) + } + ] + }); + + await handleCliCommand(['status']); + + const out = logSpy.mock.calls.map((c) => String(c[0] ?? '')).join('\n'); + expect(out).toMatch(/Index Status/); + expect(out).toMatch(/\+\- Index Status/); + expect(out).toMatch(/Progress:/); + } finally { + if (originalAscii === undefined) delete process.env.CODEBASE_CONTEXT_ASCII; + else process.env.CODEBASE_CONTEXT_ASCII = originalAscii; + } + }); + it('formatting falls back safely on unexpected JSON', async () => { toolMocks.dispatchTool.mockResolvedValue({ content: [{ type: 'text', text: JSON.stringify({ foo: 'bar' }) }] @@ -97,5 +132,68 @@ describe('CLI', () => { await expect(handleMemoryCli(['list', '--type', 'nope'])).rejects.toThrow(/process\.exit:1/); expect(errorSpy).toHaveBeenCalled(); }); + + it('memory add/remove support --json output', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cli-memory-json-')); + const originalEnvRoot = process.env.CODEBASE_ROOT; + process.env.CODEBASE_ROOT = tempDir; + + try { + logSpy.mockClear(); + await handleMemoryCli([ + 'add', + '--type', + 'decision', + '--category', + 'tooling', + '--memory', + 'Use pnpm, not npm', + '--reason', + 'Workspace support and speed', + '--json' + ]); + + const addedText = String(logSpy.mock.calls.at(-1)?.[0] ?? ''); + const added = JSON.parse(addedText) as { status: string; memory?: { id?: string } }; + expect(added.status).toBe('added'); + const id = added.memory?.id; + expect(typeof id).toBe('string'); + + logSpy.mockClear(); + await handleMemoryCli([ + 'add', + '--type', + 'decision', + '--category', + 'tooling', + '--memory', + 'Use pnpm, not npm', + '--reason', + 'Workspace support and speed', + '--json' + ]); + const dupText = String(logSpy.mock.calls.at(-1)?.[0] ?? ''); + const dup = JSON.parse(dupText) as { status: string }; + expect(dup.status).toBe('duplicate'); + + logSpy.mockClear(); + await handleMemoryCli(['remove', String(id), '--json']); + const removedText = String(logSpy.mock.calls.at(-1)?.[0] ?? ''); + const removed = JSON.parse(removedText) as { status: string; id: string }; + expect(removed).toEqual({ status: 'removed', id }); + + logSpy.mockClear(); + await expect(handleMemoryCli(['remove', 'does-not-exist', '--json'])).rejects.toThrow( + /process\.exit:1/ + ); + const notFoundText = String(logSpy.mock.calls.at(-1)?.[0] ?? ''); + const notFound = JSON.parse(notFoundText) as { status: string; id: string }; + expect(notFound).toEqual({ status: 'not_found', id: 'does-not-exist' }); + } finally { + if (originalEnvRoot === undefined) delete process.env.CODEBASE_ROOT; + else process.env.CODEBASE_ROOT = originalEnvRoot; + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); });