import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { buildExpandedParams, kylinFrameForCall, kylinFrameForResult } from "../../vera-theme/src/public"; import { registerStatusSegment } from "./status-segment"; import { startJob } from "./background"; import { syncGitignoreExcludes } from "./gitignore-sync"; const execFileAsync = promisify(execFile); const DEFAULT_MAX_BYTES = 50 * 1024; const DEFAULT_MAX_LINES = 2000; const DEFAULT_SEARCH_LIMIT = 5; const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; function truncate(text: string, max = 70): string { const normalized = String(text ?? "").trim(); if (!normalized) return ""; return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized; } function textFromResult(result: any): string { return String(result?.content?.find?.((item: any) => item?.type === "text")?.text ?? ""); } function cccErrorMessage(result: any, fallback: string): string { return truncate(textFromResult(result) || result?.details?.error || fallback, 160); } function buildSearchParamSummary(args: any, theme: any): string { const query = truncate(String(args?.query ?? ""), 58) || "(empty query)"; const paths = Array.isArray(args?.paths) && args.paths.length > 0 ? args.paths.join(", ") : "all"; return theme.fg("text", query) + " in " + theme.fg("dim", truncate(paths, 48)); } function buildIndexParamSummary(args: any, theme: any): string { const mode = args?.background === false ? "foreground" : "background"; return theme.fg("dim", mode); } function buildInitParamSummary(args: any, theme: any): string { const parts: string[] = []; if (args?.force) parts.push(theme.fg("muted", "--force")); const mode = args?.background === false ? "foreground" : "background"; parts.push(theme.fg("dim", mode)); return parts.join(" "); } function searchMatchCount(text: string): number { const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); const fileLines = lines.filter((line) => /(?:^|\s)[\w@./-]+\.[A-Za-z0-9]+(?::\d+)?/.test(line)); if (fileLines.length > 0) return fileLines.length; if (/no\s+(results|matches)/i.test(text)) return 0; return lines.length; } function firstNonEmptyLine(text: string): string { return text.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "completed"; } function buildSearchExpandedBody(result: any, theme: any): string[] { const lines = textFromResult(result).split(/\r?\n/).filter((line) => line.trim().length > 0); return lines.map((line) => { const color = /(?:^|\s)[\w@./-]+\.[A-Za-z0-9]+(?::\d+)?/.test(line) ? "text" : "dim"; return theme.fg(color, line); }); } function parseIndexedFiles(text: string): number | null { const match = text.match(/indexed\s+(\d+)\s+files?/i) ?? text.match(/(\d+)\s+files?\b/i); return match ? Number(match[1]) : null; } type TruncationMode = "head" | "tail"; interface CccRunResult { command: string; cwd: string; stdout: string; stderr: string; } function shellQuote(value: string): string { if (/^[A-Za-z0-9_./:@=-]+$/.test(value)) return value; return JSON.stringify(value); } function formatCommand(args: string[]): string { return ["ccc", ...args].map(shellQuote).join(" "); } function countLines(text: string): number { if (!text.length) return 0; return text.split(/\r?\n/).length; } function truncateUtf8(text: string, maxBytes: number, mode: TruncationMode): string { const bytes = Buffer.byteLength(text, "utf8"); if (bytes <= maxBytes) return text; if (mode === "head") { let end = text.length; while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > maxBytes) end -= 1; return text.slice(0, end); } let start = 0; while (start < text.length && Buffer.byteLength(text.slice(start), "utf8") > maxBytes) start += 1; return text.slice(start); } function truncateText(text: string, mode: TruncationMode): { text: string; truncated: boolean; note?: string } { const originalBytes = Buffer.byteLength(text, "utf8"); const originalLines = countLines(text); let next = text; let truncated = false; const lines = next.split(/\r?\n/); if (lines.length > DEFAULT_MAX_LINES) { truncated = true; next = mode === "head" ? lines.slice(0, DEFAULT_MAX_LINES).join("\n") : lines.slice(lines.length - DEFAULT_MAX_LINES).join("\n"); } const afterLineBytes = Buffer.byteLength(next, "utf8"); if (afterLineBytes > DEFAULT_MAX_BYTES) { truncated = true; next = truncateUtf8(next, DEFAULT_MAX_BYTES, mode); } const note = truncated ? `[output truncated: ${originalLines} lines, ${originalBytes} bytes total; showing ${countLines(next)} lines, ${Buffer.byteLength(next, "utf8")} bytes]` : undefined; return { text: note ? `${next}${next.endsWith("\n") ? "" : "\n\n"}${note}` : next, truncated, note, }; } function normalizeMaybePath(value: string): string { return value.startsWith("@") ? value.slice(1) : value; } function combineOutputs(stdout: string, stderr: string): string { const parts = [stdout.trim(), stderr.trim() ? `[stderr]\n${stderr.trim()}` : ""].filter(Boolean); return parts.join("\n\n").trim(); } function renderFailure(error: any, command: string): string { if (error?.code === "ENOENT") { return [ `Command not found: ${command}`, "Install cocoindex-code and ensure `ccc` is available on PATH.", ].join("\n"); } if (error?.name === "AbortError") { return `Cancelled: ${command}`; } const combined = combineOutputs(String(error?.stdout ?? ""), String(error?.stderr ?? error?.message ?? "")); const truncated = truncateText(combined || String(error?.message ?? "Unknown ccc error."), "tail").text; const exitInfo = typeof error?.code === "number" ? ` (exit ${error.code})` : ""; const lines = [`${command} failed${exitInfo}.`, truncated]; if (/Not in an initialized project directory/i.test(combined)) { lines.push("Hint: run ccc_init in the project root, or run ccc_index to auto-initialize before indexing."); } return lines.join("\n\n"); } async function runCcc(args: string[], ctx: ExtensionContext, signal?: AbortSignal, timeout = DEFAULT_COMMAND_TIMEOUT_MS): Promise { const command = formatCommand(args); try { const result = await execFileAsync("ccc", args, { cwd: ctx.cwd, timeout, maxBuffer: 8 * 1024 * 1024, signal, env: { ...process.env, NO_COLOR: "1", TERM: "dumb", PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", }, }); return { command, cwd: ctx.cwd, stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), }; } catch (error) { throw new Error(renderFailure(error, command)); } } function createResult(run: CccRunResult, mode: TruncationMode, details: Record = {}) { const combined = combineOutputs(run.stdout, run.stderr); const truncated = truncateText(combined || "Command completed with no output.", mode); return { content: [{ type: "text" as const, text: truncated.text }], details: { command: run.command, cwd: run.cwd, truncated: truncated.truncated, ...details, }, }; } function registerCccSearchTool(pi: ExtensionAPI): void { pi.registerTool({ name: "ccc_search", label: "CCC Search", description: "Search the current codebase using CocoIndex Code semantic search (`ccc search`). Best for conceptual, fuzzy, or behavior-based code lookup when exact grep terms are unknown.", promptSnippet: "Use ccc_search for semantic code search when the user describes behavior or intent rather than exact filenames or symbols.", promptGuidelines: [ "Prefer ccc_search for conceptual code retrieval: behavior, architecture, responsibilities, or fuzzy descriptions.", "Prefer fd/rg/read first when you already know the exact filename, path, or literal text to search.", "After ccc_search returns candidates, read the matched files directly before making edits or firm conclusions.", ], parameters: Type.Object({ query: Type.String({ description: "Natural-language code search query or code snippet." }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100, description: `Maximum results to return. Default ${DEFAULT_SEARCH_LIMIT}.` })), offset: Type.Optional(Type.Number({ minimum: 0, description: "Result offset for pagination. Default 0." })), refresh_index: Type.Optional(Type.Boolean({ description: "Refresh the index before searching. Default true." })), languages: Type.Optional(Type.Array(Type.String(), { description: "Optional language filters, e.g. ['python', 'typescript']." })), paths: Type.Optional(Type.Array(Type.String(), { description: "Optional path globs relative to project root, e.g. ['src/*', 'docs/*.md']." })), }), renderShell: "self" as const, renderCall(args, theme, ctx) { const state = ctx.state as { startedAt?: number }; if (state.startedAt === undefined) state.startedAt = Date.now(); return kylinFrameForCall(ctx, { name: "ccc_search", theme, status: "pending", paramSummary: buildSearchParamSummary(args, theme), startedAt: state.startedAt, }); }, renderResult(result, { expanded, isPartial }, theme, context) { const state = context.state as { startedAt?: number }; const startedAt = state.startedAt; const paramSummary = buildSearchParamSummary(context.args, theme); const expandedParams = expanded ? buildExpandedParams([ { label: "query", value: context.args?.query, multiline: true }, { label: "paths", value: context.args?.paths }, { label: "languages", value: context.args?.languages }, { label: "limit", value: context.args?.limit }, { label: "offset", value: context.args?.offset }, { label: "refresh_index", value: context.args?.refresh_index }, ], theme) : undefined; if (isPartial) { return kylinFrameForResult(context, { name: "ccc_search", theme, status: "pending", paramSummary, resultSummary: theme.fg("dim", "searching"), expanded, expandedParams, startedAt }); } if (result.isError) { return kylinFrameForResult(context, { name: "ccc_search", theme, status: "error", paramSummary, errorMessage: cccErrorMessage(result, "ccc_search failed"), expanded, expandedParams, startedAt }); } const body = buildSearchExpandedBody(result, theme); const count = searchMatchCount(textFromResult(result)); return kylinFrameForResult(context, { name: "ccc_search", theme, status: "success", paramSummary, resultSummary: theme.fg(count > 0 ? "success" : "dim", `${count} match${count === 1 ? "" : "es"}`), expanded, expandedParams, expandedBody: expanded ? body : undefined, expandedTotalLines: body.length, startedAt, }); }, async execute(_toolCallId, params, signal, onUpdate, ctx) { const query = String(params.query ?? "").trim(); if (!query) throw new Error("ccc_search requires a non-empty query."); const limit = Number.isFinite(params.limit) ? Number(params.limit) : DEFAULT_SEARCH_LIMIT; const offset = Number.isFinite(params.offset) ? Number(params.offset) : 0; const refreshIndex = params.refresh_index ?? true; const languages = Array.isArray(params.languages) ? params.languages.map((value) => String(value).trim()).filter(Boolean) : []; const paths = Array.isArray(params.paths) ? params.paths.map((value) => normalizeMaybePath(String(value).trim())).filter(Boolean) : []; const args = ["search"]; if (refreshIndex) args.push("--refresh"); for (const language of languages) args.push("--lang", language); for (const path of paths) args.push("--path", path); args.push("--offset", String(offset), "--limit", String(limit), query); onUpdate?.({ content: [{ type: "text" as const, text: "Running semantic code search with ccc..." }], details: { phase: "searching", query }, }); if (refreshIndex) await syncGitignoreExcludes(ctx.cwd); const run = await runCcc(args, ctx, signal); return createResult(run, "head", { phase: "done", query, limit, offset, refresh_index: refreshIndex, languages, paths, }); }, }); } function registerCccIndexTool(pi: ExtensionAPI): void { pi.registerTool({ name: "ccc_index", label: "CCC Index", description: "Build or refresh the CocoIndex Code semantic index for the current project (`ccc index`). Defaults to background execution; result appears in the next CCC Index Status segment. Pass background=false to block until completion.", promptSnippet: "Use ccc_index to warm the semantic index in background after init or before a large batch of searches. ccc_search auto-refreshes incrementally, so explicit ccc_index is rarely needed otherwise.", promptGuidelines: [ "Defaults to background — returns immediately; completion shows up in the next CCC Index Status segment.", "Pass background: false only when the caller needs the indexing log inline and accepts blocking the turn.", ], parameters: Type.Object({ background: Type.Optional(Type.Boolean({ description: "Run in background (default true). Set false to block until completion and return the full output inline." })), }), renderShell: "self" as const, renderCall(args, theme, ctx) { const state = ctx.state as { startedAt?: number }; if (state.startedAt === undefined) state.startedAt = Date.now(); return kylinFrameForCall(ctx, { name: "ccc_index", theme, status: "pending", paramSummary: buildIndexParamSummary(args, theme), startedAt: state.startedAt, }); }, renderResult(result, { expanded, isPartial }, theme, context) { const state = context.state as { startedAt?: number }; const startedAt = state.startedAt; const paramSummary = buildIndexParamSummary(context.args, theme); const expandedParams = expanded ? buildExpandedParams([ { label: "background", value: context.args?.background }, ], theme) : undefined; if (isPartial) return kylinFrameForResult(context, { name: "ccc_index", theme, status: "pending", paramSummary, resultSummary: theme.fg("dim", "indexing"), expanded, expandedParams, startedAt }); if (result.isError) return kylinFrameForResult(context, { name: "ccc_index", theme, status: "error", paramSummary, errorMessage: cccErrorMessage(result, "ccc_index failed"), expanded, expandedParams, startedAt }); const details = (result as any)?.details; if (details?.background === true) { const jobId = String(details?.jobId ?? ""); const summary = details?.reused ? `running (${jobId})` : `queued (${jobId})`; return kylinFrameForResult(context, { name: "ccc_index", theme, status: "success", paramSummary, resultSummary: theme.fg("dim", summary), expanded, expandedParams, startedAt, }); } const indexed = parseIndexedFiles(textFromResult(result)); return kylinFrameForResult(context, { name: "ccc_index", theme, status: "success", paramSummary, resultSummary: theme.fg("success", indexed == null ? "indexed" : `indexed ${indexed} files`), expanded, expandedParams, startedAt, }); }, async execute(_toolCallId, params, signal, onUpdate, ctx) { await syncGitignoreExcludes(ctx.cwd); const background = params?.background !== false; if (background) { const { job, reused } = startJob({ cwd: ctx.cwd, kind: "index", args: ["index"], notify: (msg, level) => ctx.ui.notify(msg, level), }); const text = reused ? `ccc_index already running in background (job ${job.id}). Result will appear in the next CCC Index Status segment.` : `Started ccc_index in background (job ${job.id}). Result will appear in the next CCC Index Status segment.`; return { content: [{ type: "text" as const, text }], details: { jobId: job.id, kind: "index", background: true, reused, startedAt: job.startedAt }, }; } onUpdate?.({ content: [{ type: "text" as const, text: "Refreshing CocoIndex Code index..." }], details: { phase: "indexing" }, }); const run = await runCcc(["index"], ctx, signal, 10 * 60_000); return createResult(run, "tail", { phase: "done", background: false }); }, }); } function registerCccInitTool(pi: ExtensionAPI): void { pi.registerTool({ name: "ccc_init", label: "CCC Init", description: "Initialize the current project for CocoIndex Code (`ccc init`). Creates `.cocoindex_code/` settings and updates `.gitignore`.", promptSnippet: "Use ccc_init when the current project has not been initialized for CocoIndex Code yet.", promptGuidelines: [ "Use ccc_init only when the project root is the intended initialization target.", "Remember that ccc_init writes project settings and updates .gitignore.", ], parameters: Type.Object({ force: Type.Optional(Type.Boolean({ description: "Skip parent directory warning. Default false." })), background: Type.Optional(Type.Boolean({ description: "Run in background (default true). Set false to block until completion and return the full output inline." })), }), renderShell: "self" as const, renderCall(args, theme, ctx) { const state = ctx.state as { startedAt?: number }; if (state.startedAt === undefined) state.startedAt = Date.now(); return kylinFrameForCall(ctx, { name: "ccc_init", theme, status: "pending", paramSummary: buildInitParamSummary(args, theme), startedAt: state.startedAt, }); }, renderResult(result, { expanded, isPartial }, theme, context) { const state = context.state as { startedAt?: number }; const startedAt = state.startedAt; const paramSummary = buildInitParamSummary(context.args, theme); const expandedParams = expanded ? buildExpandedParams([ { label: "force", value: context.args?.force }, { label: "background", value: context.args?.background }, ], theme) : undefined; if (isPartial) return kylinFrameForResult(context, { name: "ccc_init", theme, status: "pending", paramSummary, resultSummary: theme.fg("dim", "initializing"), expanded, expandedParams, startedAt }); if (result.isError) return kylinFrameForResult(context, { name: "ccc_init", theme, status: "error", paramSummary, errorMessage: cccErrorMessage(result, "ccc_init failed"), expanded, expandedParams, startedAt }); const details = (result as any)?.details; if (details?.background === true) { const jobId = String(details?.jobId ?? ""); const summary = details?.reused ? `running (${jobId})` : `queued (${jobId})`; return kylinFrameForResult(context, { name: "ccc_init", theme, status: "success", paramSummary, resultSummary: theme.fg("dim", summary), expanded, expandedParams, startedAt, }); } return kylinFrameForResult(context, { name: "ccc_init", theme, status: "success", paramSummary, resultSummary: theme.fg("success", "initialized"), expanded, expandedParams, startedAt, }); }, async execute(_toolCallId, params, signal, onUpdate, ctx) { const background = params?.background !== false; const initArgs = ["init"]; if (params?.force) initArgs.push("--force"); if (background) { const { job, reused } = startJob({ cwd: ctx.cwd, kind: "init", args: initArgs, notify: (msg, level) => ctx.ui.notify(msg, level), }); const text = reused ? `ccc_init already running in background (job ${job.id}). Result will appear in the next CCC Index Status segment.` : `Started ccc_init in background (job ${job.id}). Result will appear in the next CCC Index Status segment.`; return { content: [{ type: "text" as const, text }], details: { jobId: job.id, kind: "init", background: true, reused, force: Boolean(params?.force), startedAt: job.startedAt }, }; } onUpdate?.({ content: [{ type: "text" as const, text: "Initializing CocoIndex Code for the current project..." }], details: { phase: "initializing" }, }); const run = await runCcc(initArgs, ctx, signal); await syncGitignoreExcludes(ctx.cwd); return createResult(run, "tail", { phase: "done", force: Boolean(params?.force), background: false }); }, }); } function registerCccDoctorTool(pi: ExtensionAPI): void { pi.registerTool({ name: "ccc_doctor", label: "CCC Doctor", description: "Run CocoIndex Code diagnostics (`ccc doctor`) to inspect daemon health, embedding model setup, settings, and common failure causes.", promptSnippet: "Use ccc_doctor when CocoIndex Code is failing, missing, misconfigured, or returning suspicious results.", parameters: Type.Object({}), renderShell: "self" as const, renderCall(_args, theme, ctx) { const state = ctx.state as { startedAt?: number }; if (state.startedAt === undefined) state.startedAt = Date.now(); return kylinFrameForCall(ctx, { name: "ccc_doctor", theme, status: "pending", startedAt: state.startedAt, }); }, renderResult(result, { isPartial }, theme, context) { const state = context.state as { startedAt?: number }; const startedAt = state.startedAt; if (isPartial) return kylinFrameForResult(context, { name: "ccc_doctor", theme, status: "pending", resultSummary: theme.fg("dim", "checking"), startedAt }); if (result.isError) return kylinFrameForResult(context, { name: "ccc_doctor", theme, status: "error", errorMessage: cccErrorMessage(result, "ccc_doctor failed"), startedAt }); return kylinFrameForResult(context, { name: "ccc_doctor", theme, status: "success", resultSummary: theme.fg("dim", truncate(firstNonEmptyLine(textFromResult(result)), 80)), startedAt, }); }, async execute(_toolCallId, _params, signal, _onUpdate, ctx) { const run = await runCcc(["doctor"], ctx, signal); return createResult(run, "head", { phase: "done" }); }, }); } export default function veraCccTools(pi: ExtensionAPI): void { registerCccSearchTool(pi); registerCccIndexTool(pi); registerCccInitTool(pi); registerCccDoctorTool(pi); registerStatusSegment(pi); }