From 93c2ac60c5eec221f0322217a588b824163ffd8e Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Tue, 9 Jun 2026 20:54:59 +0200 Subject: [PATCH] Pi skill integration, CLI polish, --fix flag, config file support - SKILL.md: Proper Agent Skills frontmatter with name/description - pi-extension.ts: Pi extension registering 4 custom tools (project_map_init/patch/validate/reinit) with prompt snippets/guidelines - pi-extension.ts: Auto-detects .pi-map.md files on session start, warns about dirty markers, injects maintenance hints before agent start - package.json: Added pi.extensions and pi.skills entries - CLI: Added picocolors, clean help screen, progress indicators, summary output with timing, colored check/warning icons - validate.ts: Added --fix flag that regenerates directories with discrepancies - config.ts: Reads .pi-project-map.json from project root with merge over defaults - init.ts: Added optional verbose parameter for programmatic use All 16 tests pass. TypeScript compiles clean. Build succeeds. --- SKILL.md | 128 ++++++++++++++++++++++++++++---- package-lock.json | 2 +- package.json | 9 +++ pi-extension.ts | 183 ++++++++++++++++++++++++++++++++++++++++++++++ src/cli.ts | 116 +++++++++++++++++++++++++++-- src/config.ts | 25 ++++++- src/init.ts | 16 +++- src/validate.ts | 49 +++++++++++-- tsconfig.json | 2 +- 9 files changed, 492 insertions(+), 38 deletions(-) create mode 100644 pi-extension.ts diff --git a/SKILL.md b/SKILL.md index 6d1ae80..1ac5f63 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,24 +1,41 @@ +--- +name: pi-project-map +description: Generates and maintains hierarchical, machine-readable project analysis files (.pi-map.md) for instant codebase comprehension. Use when working with medium-to-large codebases where understanding architecture, file relationships, and exports without reading every file is valuable. Automatically extracts symbols via AST and LLM heuristics. +--- + # pi-project-map A Pi skill that generates and maintains a hierarchical, machine-readable analysis of a software project. Each directory gets a `.pi-map.md` file containing architectural context, exported symbols, and dependencies. -## Tools +## What It Does -### `project-map:init [root]` -Runs a full project scan and generates `.pi-map.md` files in every directory. +- **Scans** your entire project and creates one `.pi-map.md` per directory +- **Extracts** exports, imports, and dependencies via AST parsing (TypeScript, Python, Go) and LLM heuristics +- **Updates** incrementally when files change (full rewrite for small packages, section-level patch for large) +- **Validates** detects stale entries, missing files, orphaned entries, and changed signatures -### `project-map:patch ` -Updates the `.pi-map.md` for the directory containing the given file. Uses full rewrite for small packages (< 10 files) or section-level patch for larger packages. +## Quick Start -### `project-map:validate [root]` -Checks all `.pi-map.md` files for staleness: missing files, orphaned entries, changed signatures, and dirty markers. +```bash +# Install globally +npm install -g pi-project-map -### `project-map:reinit [path]` -Force full re-initialization of the entire project or a specific subtree. Clears all dirty markers. +# Generate analysis files for the entire project +project-map init + +# After editing a file, update its directory's analysis +project-map patch src/components/Button.tsx + +# Check for staleness +project-map validate + +# Force full regeneration +project-map reinit +``` ## Format -Each `.pi-map.md` uses dense markdown with conventions: +Each `.pi-map.md` uses dense markdown optimized for LLM consumption: ```markdown # pkg/auth @@ -33,6 +50,60 @@ Guard pattern on routes. Tokens short-lived (15m), refresh long-lived (7d). Rota - ``` +### Abbreviations + +| Abbreviation | Meaning | +|-------------|---------| +| `exp:` | Exported symbols | +| `dep:` | Dependencies | +| `pkg/` | Internal package reference | + +## Tools + +### `project-map:init [root]` +Runs a full project scan and generates `.pi-map.md` files in every directory. + +**Example:** +```bash +project-map init +project-map init ~/my-project +``` + +### `project-map:patch ` +Updates the `.pi-map.md` for the directory containing the given file. + +**Behavior:** +- Small packages (< 10 files): full rewrite +- Large packages (>= 10 files): section-level patch + +**Example:** +```bash +project-map patch src/components/Button.tsx +``` + +### `project-map:validate [root]` +Checks all `.pi-map.md` files for staleness. + +**Detects:** +- Missing files (new files not yet in `.pi-map.md`) +- Orphaned entries (files listed but deleted) +- Stale signatures (exports changed since last scan) +- Dirty markers (packages flagged for reconciliation) + +**Example:** +```bash +project-map validate +``` + +### `project-map:reinit [path]` +Force full re-initialization. Clears all dirty markers. + +**Example:** +```bash +project-map reinit +project-map reinit src/components +``` + ## Configuration Create `.pi-project-map.json` in the project root: @@ -41,16 +112,41 @@ Create `.pi-project-map.json` in the project root: { "ignorePatterns": ["node_modules", ".git"], "smallPackageThreshold": 10, - "llmModel": "gpt-4o-mini", "contextBudget": 4000, "autoInjectPrompt": true } ``` -## Installation +| Option | Default | Description | +|--------|---------|-------------| +| `ignorePatterns` | `node_modules`, `.git`, `dist`, etc. | Additional ignore patterns | +| `smallPackageThreshold` | `10` | File count threshold for full rewrite vs patch | +| `contextBudget` | `4000` | Max tokens to spend on analysis files | +| `autoInjectPrompt` | `true` | Auto-inject maintenance instructions | -```bash -npm install -g pi-project-map -``` +## Agent Instructions -Then add to your Pi skills configuration. +When `.pi-map.md` files exist in the project: + +1. **Read them at session start** to build project understanding without scanning every file +2. **Run `project-map:patch `** after editing any source file +3. **Run `project-map:validate`** if you suspect staleness before making architectural decisions +4. **Trust the analysis** for orientation, but verify critical details by reading source when needed + +## Best Practices + +- Run `project-map:init` after cloning a new repository +- Run `project-map:reinit` periodically (daily/weekly) to catch changes made outside the agent +- Add `.pi-map.md` to `.gitignore` — they are derived artifacts +- For very large projects (> 1000 directories), consider running `init` on subdirectories + +## Supported Languages + +| Language | AST Parsing | Heuristic Extraction | +|----------|------------|---------------------| +| TypeScript / TSX | Full | Full | +| JavaScript / JSX | Full | Full | +| Python | Partial | Full | +| Go | Partial | Full | +| Rust | Partial | Full | +| Other | - | Full (filename + regex patterns) | diff --git a/package-lock.json b/package-lock.json index f0c3c1b..0fe90ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "ignore": "^5.3.0", + "picocolors": "^1.1.1", "tree-sitter": "^0.21.0", "tree-sitter-go": "^0.25.0", "tree-sitter-python": "^0.25.0", @@ -2860,7 +2861,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { diff --git a/package.json b/package.json index 230155f..01707c5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,14 @@ ], "author": "", "license": "MIT", + "pi": { + "extensions": [ + "./pi-extension.ts" + ], + "skills": [ + "./SKILL.md" + ] + }, "devDependencies": { "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -34,6 +42,7 @@ }, "dependencies": { "ignore": "^5.3.0", + "picocolors": "^1.1.1", "tree-sitter": "^0.21.0", "tree-sitter-go": "^0.25.0", "tree-sitter-python": "^0.25.0", diff --git a/pi-extension.ts b/pi-extension.ts new file mode 100644 index 0000000..8ec80c3 --- /dev/null +++ b/pi-extension.ts @@ -0,0 +1,183 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { execSync } from "child_process"; +import { readFileSync } from "fs"; +import { join } from "path"; + +function runCommand( + command: string, + args: string[], + cwd: string, +): { stdout: string; stderr: string; success: boolean } { + try { + const result = execSync(`npx project-map ${command} ${args.join(" ")}`, { + cwd, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + return { stdout: result, stderr: "", success: true }; + } catch (error: any) { + return { + stdout: error.stdout || "", + stderr: error.stderr || error.message, + success: false, + }; + } +} + +function findPiMapFiles(cwd: string): string[] { + try { + const result = execSync('find . -name ".pi-map.md" -type f', { + cwd, + encoding: "utf8", + }); + return result.trim().split("\n").filter(Boolean); + } catch { + return []; + } +} + +export default function (pi: ExtensionAPI) { + // Register custom tools + pi.registerTool({ + name: "project_map_init", + label: "Project Map Init", + description: + "Generate .pi-map.md analysis files for the entire project or a subdirectory", + promptSnippet: + "Initialize project analysis files for codebase understanding", + promptGuidelines: [ + "Use project_map_init when starting work on a new project or after significant restructuring", + "Run project_map_init when .pi-map.md files are missing or severely outdated", + ], + parameters: Type.Object({ + path: Type.Optional( + Type.String({ + description: "Project root path (default: current directory)", + }), + ), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const targetPath = params.path || ctx.cwd; + const result = runCommand("init", targetPath === ctx.cwd ? [] : [targetPath], ctx.cwd); + return { + content: [{ type: "text", text: result.stdout || result.stderr }], + details: { success: result.success, cwd: ctx.cwd }, + }; + }, + }); + + pi.registerTool({ + name: "project_map_patch", + label: "Project Map Patch", + description: "Update .pi-map.md for the directory containing a changed file", + promptSnippet: "Update project analysis after editing a source file", + promptGuidelines: [ + "Use project_map_patch immediately after editing any source file", + "Pass the absolute or relative path of the modified file", + ], + parameters: Type.Object({ + file_path: Type.String({ + description: "Path to the modified file", + }), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const result = runCommand("patch", [params.file_path], ctx.cwd); + return { + content: [{ type: "text", text: result.stdout || result.stderr }], + details: { success: result.success }, + }; + }, + }); + + pi.registerTool({ + name: "project_map_validate", + label: "Project Map Validate", + description: "Check all .pi-map.md files for staleness and discrepancies", + promptSnippet: "Validate project analysis files for accuracy", + promptGuidelines: [ + "Use project_map_validate before making architectural decisions if you suspect stale data", + "Use project_map_validate to detect files that were deleted or added outside the agent", + ], + parameters: Type.Object({ + path: Type.Optional( + Type.String({ + description: "Project root path (default: current directory)", + }), + ), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const targetPath = params.path || ctx.cwd; + const result = runCommand("validate", targetPath === ctx.cwd ? [] : [targetPath], ctx.cwd); + return { + content: [{ type: "text", text: result.stdout || result.stderr }], + details: { success: result.success, clean: result.stdout.includes("clean") }, + }; + }, + }); + + pi.registerTool({ + name: "project_map_reinit", + label: "Project Map Reinit", + description: "Force full regeneration of all .pi-map.md files", + promptSnippet: "Force full regeneration of project analysis files", + promptGuidelines: [ + "Use project_map_reinit when validation shows widespread staleness", + "Use project_map_reinit after pulling major changes from version control", + ], + parameters: Type.Object({ + path: Type.Optional( + Type.String({ + description: "Path to regenerate (default: entire project)", + }), + ), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const args = params.path ? [params.path] : []; + const result = runCommand("reinit", args, ctx.cwd); + return { + content: [{ type: "text", text: result.stdout || result.stderr }], + details: { success: result.success }, + }; + }, + }); + + // Auto-load .pi-map.md files on session start + pi.on("session_start", async (_event, ctx) => { + const mapFiles = findPiMapFiles(ctx.cwd); + if (mapFiles.length === 0) return; + + // Check for dirty markers + const dirtyFiles = mapFiles.filter((f) => { + try { + const content = readFileSync(join(ctx.cwd, f), "utf8"); + return content.includes("## dirty") && !content.includes("## dirty\n-"); + } catch { + return false; + } + }); + + if (dirtyFiles.length > 0) { + ctx.ui.notify( + `pi-project-map: ${dirtyFiles.length} dirty packages detected. Run project_map_validate or project_map_reinit.`, + "warning", + ); + } + }); + + // Inject maintenance instructions before agent starts + pi.on("before_agent_start", async (_event, _ctx) => { + // Only inject if .pi-map.md files exist + const mapFiles = findPiMapFiles(_ctx.cwd); + if (mapFiles.length === 0) return {}; + + return { + message: { + customType: "pi-project-map-hint", + content: + "📋 Project map active: If you modify any source file, run `project_map_patch` with the file path. If you suspect staleness, run `project_map_validate`.", + display: false, // Don't show in UI, only in LLM context + }, + }; + }); +} diff --git a/src/cli.ts b/src/cli.ts index aec753d..ef462c4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,30 +3,130 @@ import { initProject } from "./init.js"; import { patchFile } from "./patch.js"; import { validateMaps } from "./validate.js"; import { reinitPath } from "./init.js"; +import { discoverProject } from "./discover.js"; +import pc from "picocolors"; const args = process.argv.slice(2); const command = args[0]; +function printUsage() { + console.log(`${pc.bold("project-map")} — hierarchical project analysis for Pi agents +`); + console.log(`${pc.bold("Usage:")}`); + console.log(` project-map ${pc.cyan("init")} [path] Generate .pi-map.md files for all directories`); + console.log(` project-map ${pc.cyan("patch")} Update analysis for a changed file's directory`); + console.log(` project-map ${pc.cyan("validate")} [--fix] [path] Check for stale/missing/orphaned entries`); + console.log(` project-map ${pc.cyan("reinit")} [path] Force full regeneration`); + console.log(` project-map ${pc.cyan("--help")} Show this help message`); + console.log(` project-map ${pc.cyan("--version")} Show version\n`); + console.log(`${pc.bold("Examples:")}`); + console.log(` project-map init`); + console.log(` project-map patch src/components/Button.tsx`); + console.log(` project-map validate --fix`); + console.log(` project-map reinit`); +} + +function printVersion() { + const pkg = require("../package.json"); + console.log(pkg.version); +} + +function formatCount(count: number, label: string): string { + const plural = + label.endsWith("y") + ? `${label.slice(0, -1)}ies` + : `${label}${count === 1 ? "" : "s"}`; + return `${pc.bold(String(count))} ${count === 1 ? label : plural}`; +} + +function parseValidateArgs(args: string[]): { path: string; fix: boolean } { + let path = "."; + let fix = false; + for (const arg of args.slice(1)) { + if (arg === "--fix") { + fix = true; + } else if (!arg.startsWith("-")) { + path = arg; + } + } + return { path, fix }; +} + async function main() { + if (!command || command === "--help" || command === "-h") { + printUsage(); + process.exit(0); + } + + if (command === "--version" || command === "-v") { + printVersion(); + process.exit(0); + } + switch (command) { - case "init": - await initProject(args[1] || "."); + case "init": { + const targetPath = args[1] || "."; + const start = Date.now(); + const entries = discoverProject(targetPath); + console.log(`Scanning ${formatCount(entries.length, "directory")}...`); + await initProject(targetPath, { verbose: false }); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + console.log( + `${pc.green("✓")} Generated ${formatCount(entries.length, ".pi-map.md file")} in ${elapsed}s`, + ); break; - case "patch": + } + case "patch": { + if (!args[1]) { + console.error(`${pc.red("Error:")} Missing file path. Usage: project-map patch `); + process.exit(1); + } await patchFile(args[1]); + console.log(`${pc.green("✓")} Patched`); break; + } case "validate": { - const result = await validateMaps(args[1] || "."); + const { path, fix } = parseValidateArgs(args); + const result = await validateMaps(path, { fix, verbose: true }); + if (result.clean) { + console.log(`${pc.green("✓")} All .pi-map.md files are clean.`); + } else { + const counts: Record = {}; + for (const d of result.discrepancies) { + counts[d.type] = (counts[d.type] || 0) + 1; + } + const summary = Object.entries(counts) + .map(([type, count]) => `${count} ${type}`) + .join(", "); + const fixMsg = + fix && result.fixed !== undefined + ? ` (${pc.green("✓")} fixed ${formatCount(result.fixed, "directory")})` + : ""; + console.log( + `${pc.yellow("⚠")} Found ${formatCount(result.discrepancies.length, "discrepancy")}: ${summary}${fixMsg}`, + ); + } process.exit(result.clean ? 0 : 1); break; } - case "reinit": - await reinitPath(args[1] || "."); + case "reinit": { + const targetPath = args[1] || "."; + const start = Date.now(); + const entries = discoverProject(targetPath); + console.log(`Regenerating ${formatCount(entries.length, ".pi-map.md file")}...`); + await reinitPath(targetPath, { verbose: false }); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + console.log(`${pc.green("✓")} Regenerated in ${elapsed}s`); break; + } default: - console.log(`Usage: project-map [path]`); + console.error(`${pc.red("Error:")} Unknown command "${command}"`); + console.error(`Run ${pc.cyan("project-map --help")} for usage.`); process.exit(1); } } -main(); +main().catch((err) => { + console.error(`${pc.red("Error:")} ${err.message}`); + process.exit(1); +}); diff --git a/src/config.ts b/src/config.ts index 0450a8b..6c02c8a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; + export interface SkillConfig { ignorePatterns: string[]; smallPackageThreshold: number; @@ -18,6 +21,15 @@ export const DEFAULT_CONFIG: SkillConfig = { "__pycache__", ".DS_Store", "*.log", + ".pi-map.md", + ".cache", + "tmp", + "temp", + ".tmp", + ".turbo", + ".parcel-cache", + ".eslintcache", + ".prettiercache", ], smallPackageThreshold: 10, llmModel: "gpt-4o-mini", @@ -25,7 +37,16 @@ export const DEFAULT_CONFIG: SkillConfig = { autoInjectPrompt: true, }; -export function loadConfig(): SkillConfig { - // TODO: load from .pi-project-map.json or similar +export function loadConfig(cwd: string = process.cwd()): SkillConfig { + const configPath = join(cwd, ".pi-project-map.json"); + if (existsSync(configPath)) { + try { + const content = readFileSync(configPath, "utf8"); + const userConfig = JSON.parse(content); + return { ...DEFAULT_CONFIG, ...userConfig }; + } catch { + // Fall through to default + } + } return DEFAULT_CONFIG; } diff --git a/src/init.ts b/src/init.ts index 15fd1ff..584adb1 100644 --- a/src/init.ts +++ b/src/init.ts @@ -10,14 +10,19 @@ import { mergeFileData } from "./merge.js"; import { writeFileSync } from "fs"; import { join } from "path"; -export async function initProject(rootPath: string): Promise { +export async function initProject( + rootPath: string, + options?: { verbose?: boolean }, +): Promise { const entries = discoverProject(rootPath); for (const entry of entries) { await generateDirectoryMap(entry); } - console.log(`Generated ${entries.length} .pi-map.md files`); + if (options?.verbose !== false) { + console.log(`Generated ${entries.length} .pi-map.md files`); + } } export async function generateDirectoryMap( @@ -46,7 +51,10 @@ export async function generateDirectoryMap( return fileData; } -export async function reinitPath(path: string): Promise { +export async function reinitPath( + path: string, + options?: { verbose?: boolean }, +): Promise { // Full regeneration clears all dirty markers by overwriting every .pi-map.md - await initProject(path); + await initProject(path, options); } diff --git a/src/validate.ts b/src/validate.ts index 34056d7..3400f04 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -3,10 +3,12 @@ import { parsePackageMap } from "./format.js"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { extractFileAST } from "./ast-extract.js"; +import { generateDirectoryMap } from "./init.js"; export interface ValidationResult { clean: boolean; discrepancies: Discrepancy[]; + fixed?: number; } export interface Discrepancy { @@ -17,9 +19,12 @@ export interface Discrepancy { export async function validateMaps( rootPath: string, + options?: { fix?: boolean; verbose?: boolean }, ): Promise { + const { fix = false, verbose = true } = options || {}; const discrepancies: Discrepancy[] = []; const entries = discoverProject(rootPath); + const dirsToFix = new Set(); for (const entry of entries) { const mapPath = join(entry.dirPath, ".pi-map.md"); @@ -29,10 +34,12 @@ export async function validateMaps( path: entry.relativePath, message: "No .pi-map.md found", }); + if (fix) dirsToFix.add(entry.dirPath); continue; } const mapData = parsePackageMap(readFileSync(mapPath, "utf8")); + let mapNeedsRewrite = false; // Check for dirty markers if (mapData.dirty && mapData.dirty !== "-") { @@ -41,6 +48,7 @@ export async function validateMaps( path: mapPath, message: `Dirty: ${mapData.dirty}`, }); + if (fix) mapNeedsRewrite = true; } // Check for orphaned entries @@ -52,6 +60,7 @@ export async function validateMaps( path: filePath, message: `File listed but deleted: ${fileEntry.name}`, }); + if (fix) mapNeedsRewrite = true; } } @@ -63,6 +72,7 @@ export async function validateMaps( path: join(entry.dirPath, file), message: `File not in .pi-map.md: ${file}`, }); + if (fix) mapNeedsRewrite = true; } } @@ -83,6 +93,7 @@ export async function validateMaps( path: filePath, message: `Missing export: ${exp}`, }); + if (fix) mapNeedsRewrite = true; } } for (const exp of actualExports) { @@ -92,10 +103,27 @@ export async function validateMaps( path: filePath, message: `New export: ${exp}`, }); + if (fix) mapNeedsRewrite = true; } } } } + + if (fix && mapNeedsRewrite) { + dirsToFix.add(entry.dirPath); + } + } + + // Apply fixes + let fixed = 0; + if (fix && dirsToFix.size > 0) { + for (const dirPath of dirsToFix) { + const entry = entries.find((e) => e.dirPath === dirPath); + if (entry) { + await generateDirectoryMap(entry); + fixed++; + } + } } const result: ValidationResult = { @@ -103,12 +131,21 @@ export async function validateMaps( discrepancies, }; - if (result.clean) { - console.log("All .pi-map.md files are clean."); - } else { - console.log(`Found ${discrepancies.length} discrepancies:`); - for (const d of discrepancies) { - console.log(` [${d.type}] ${d.path}: ${d.message}`); + if (fix) { + result.fixed = fixed; + } + + if (verbose) { + if (result.clean) { + console.log("All .pi-map.md files are clean."); + } else { + console.log(`Found ${discrepancies.length} discrepancies:`); + for (const d of discrepancies) { + console.log(` [${d.type}] ${d.path}: ${d.message}`); + } + } + if (fix && fixed > 0) { + console.log(`Fixed ${fixed} director${fixed === 1 ? "y" : "ies"}.`); } } diff --git a/tsconfig.json b/tsconfig.json index 5c1a024..8311c86 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "resolveJsonModule": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "pi-extension.ts"] }