/** * pi-ast-grep: structural code search, scan, rewrite, and outline for Pi. * * Loads as a Pi extension (see package.json "pi" field or use -e): * pi -e ./src/index.ts * * Registers: * - ast_grep_run structural pattern search * - ast_grep_scan YAML rule scanning * - ast_grep_rewrite preview/apply structural rewrites * - ast_grep_outline compact structural map of files and directories * - ast_grep_debug_query tree-sitter CST/AST dump for patterns * - ast_grep_languages supported language catalog * - /ast-grep-check verify the ast-grep binary is reachable * - /ast-grep-rules manage the saved-rules library (save/list/get/delete/validate) * * The ast-grep binary is resolved from AST_GREP_BIN or PATH. */ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { checkBinary, cleanupSpillDirs, DEFAULT_TIMEOUT_MS, piExecRunner, runAstGrep } from "./cli.ts"; import { LANGUAGES } from "./languages.ts"; import { hasActiveAstGrepTools, loadPromotionEnabled, promotionBlock } from "./promotion.ts"; import { applyRulesMutation, loadRules, parseRulesCommand, RULES_ENTRY_TYPE, ruleLanguage, type SavedRules, } from "./rules.ts"; import { clearPreviewState, createAstGrepTools, TOOL_NAMES } from "./tools.ts"; const STATUS_KEY = "ast-grep"; /** Shown when the installed ast-grep predates the ast_grep_outline floor (0.44). */ const OUTLINE_FLOOR_WARNING = "(older than 0.44; ast_grep_outline requires 0.44+)"; export default function piAstGrep(pi: ExtensionAPI): void { const runner = piExecRunner(pi); for (const tool of createAstGrepTools(runner)) { pi.registerTool(tool); } pi.registerCommand("ast-grep-check", { description: "Verify the ast-grep CLI is available and the extension tools are active", handler: async (_args, ctx) => { try { const check = await checkBinary(runner); const registered = new Set(pi.getAllTools().map((tool) => tool.name)); const missing = TOOL_NAMES.filter((name) => !registered.has(name)); // Tools missing from the session registry mean a session tool // filter (--tools, --exclude-tools), not a broken extension: this // factory registers the tools before the command, so a loaded // command implies the registration ran. Only the binary check fails. pi.setSessionName("ast-grep-ok"); const status = check.satisfiesFloor ? `ast-grep ${check.version}` : `ast-grep ${check.version} ${OUTLINE_FLOOR_WARNING}`; ctx.ui.setStatus(STATUS_KEY, status); const toolsNote = missing.length === 0 ? `${TOOL_NAMES.length} tools active` : `${TOOL_NAMES.length - missing.length} of ${TOOL_NAMES.length} tools active (session tool filter: ${missing.join(", ")})`; const notify = check.satisfiesFloor ? `ast-grep ${check.version} at ${check.binary}; ${toolsNote}` : `ast-grep ${check.version} at ${check.binary}; ${toolsNote}; ${OUTLINE_FLOOR_WARNING}`; ctx.ui.notify(notify, "info"); } catch (error) { const message = error instanceof Error ? error.message : String(error); ctx.ui.setStatus(STATUS_KEY, undefined); ctx.ui.notify(`ast-grep check failed: ${message}`, "error"); } }, }); // Surface binary availability in the footer once the session is up. pi.on("session_start", async (_event, ctx) => { try { const check = await checkBinary(runner); const status = check.satisfiesFloor ? `ast-grep ${check.version}` : `ast-grep ${check.version} ${OUTLINE_FLOOR_WARNING}`; ctx.ui.setStatus(STATUS_KEY, status); } catch (error) { const message = error instanceof Error ? error.message : String(error); ctx.ui.setStatus(STATUS_KEY, `missing ast-grep: ${message}`); } }); // Runtime promotion: when the ast-grep tools are active, append a bounded // guidance block to the system prompt so models reach for them at the right // moments. Toggleable via `.pi/ast-grep.json` `promotion.enabled` (default // true). No-op when no ast-grep tool is active, when promotion is disabled, // or when the binary is missing (the footer already warns about that). pi.on("before_agent_start", async (event, ctx) => { const promotionEnabled = loadPromotionEnabled(ctx.cwd, ctx.isProjectTrusted()); if (!promotionEnabled) { return; } const selectedTools = typeof event.systemPromptOptions?.selectedTools === "object" && event.systemPromptOptions?.selectedTools !== null ? event.systemPromptOptions.selectedTools : []; if (!hasActiveAstGrepTools(selectedTools)) { return; } try { // The block must not advertise tools that cannot run; a missing or // unexecutable binary makes the hook a silent no-op. await checkBinary(runner); } catch { return; } return { systemPrompt: `${event.systemPrompt}\n\n${promotionBlock()}` }; }); // Manage the saved-rules library (save/list/get/delete/validate). Rules are // loaded fresh from the current branch on every invocation, so branch // navigation and reloads need no extra restore work here. pi.registerCommand("ast-grep-rules", { description: "Manage the saved ast-grep rules library: save , list, get , delete , validate ", handler: async (args, ctx) => { const current = loadRules(ctx.sessionManager); const parsed = parseRulesCommand(args); if (parsed.op === "validate") { // validate is not a mutation: check the saved rule against the real // ast-grep binary on a throwaway fixture and reply, never persist. const name = parsed.name; if (name === undefined) { return; } const yaml = current.rules[name]; if (yaml === undefined) { pi.sendMessage({ customType: RULES_ENTRY_TYPE, content: `no saved rule ${name}`, display: true }); return; } const language = ruleLanguage(yaml); if (language === undefined) { pi.sendMessage({ customType: RULES_ENTRY_TYPE, content: `rule ${name} has no language: line`, display: true, }); return; } const catalogEntry = LANGUAGES.find( (candidate) => candidate.name === language || candidate.aliases.includes(language), ); if (catalogEntry === undefined) { pi.sendMessage({ customType: RULES_ENTRY_TYPE, content: `rule ${name} language '${language}' is not in the catalog`, display: true, }); return; } const extension = catalogEntry.extensions[0] ?? "txt"; const dir = await mkdtemp(join(tmpdir(), "pi-ast-grep-validate-")); const fixture = join(dir, `rule.${extension}`); await writeFile(fixture, "x\n", "utf8"); try { // scan exits 0 without matches and 1 when it finds error-severity // diagnostics; both count as "the rule parsed and ran fine". await runAstGrep(runner, ["scan", "--inline-rules", yaml, fixture], { allowCodes: [0, 1], timeoutMs: DEFAULT_TIMEOUT_MS, }); pi.sendMessage({ customType: RULES_ENTRY_TYPE, content: `rule ${name} is valid`, display: true }); } catch (error) { const message = error instanceof Error ? error.message : String(error); pi.sendMessage({ customType: RULES_ENTRY_TYPE, content: `rule ${name} failed validation: ${message}`, display: true, }); } finally { await rm(dir, { recursive: true, force: true }); } return; } const { next, message } = applyRulesMutation(current, parsed.op, parsed.name, parsed.yaml); // Persist only real changes: failed mutations return `current` by reference. if ((parsed.op === "save" || parsed.op === "delete") && next !== current) { pi.appendEntry(RULES_ENTRY_TYPE, next); } // Custom messages participate in LLM context, so the reply reaches the // model even though command handlers bypass the agent. pi.sendMessage({ customType: RULES_ENTRY_TYPE, content: message, display: true, }); }, }); // The rewrite preview-first gate tracks in-session state; never leak it // across sessions, and drop spill temp dirs from truncated outputs. pi.on("session_shutdown", async () => { clearPreviewState(); await cleanupSpillDirs(); }); }