import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; export const SEEDS_CLI_PACKAGE = "@os-eco/seeds-cli"; export const SEEDS_READ_ONLY_TOOLS = [ "seeds_prime", "seeds_list", "seeds_show", "seeds_doctor", "seeds_plan_prompt", "seeds_plan_show", "seeds_plan_validate", ] as const; export const SEEDS_MUTATING_TOOLS = [ "seeds_create", "seeds_update", "seeds_close", "seeds_relation", "seeds_project", "seeds_plan_submit", "seeds_plan_review", "seeds_plan_outcome", ] as const; export const ALL_SEEDS_TOOLS = [ ...SEEDS_READ_ONLY_TOOLS, ...SEEDS_MUTATING_TOOLS, ] as const; export type SeedsCliRunner = "sd" | "bunx" | "npx"; export type SeedsResultKind = | "success" | "missing_binary" | "missing_project" | "usage_error" | "git_error" | "json_error" | "runtime_error"; export interface SeedsIssue { id: string; title: string; status?: string; type?: string; priority?: number | string; assignee?: string; description?: string; labels?: string[]; blockedBy?: string[]; blocks?: string[]; createdAt?: string; updatedAt?: string; } export interface SeedsDoctorCheck { name: string; status: "pass" | "warn" | "fail" | string; message: string; details?: string[]; fixable?: boolean; } export interface SeedsStats { total?: number; open?: number; inProgress?: number; closed?: number; blocked?: number; byType?: Record; byPriority?: Record; byLabel?: Record; } export interface SeedsJsonResult { success?: boolean; command?: string; error?: string; message?: string; [key: string]: unknown; } export interface SeedsExecAttempt { runner: SeedsCliRunner; command: string; args: string[]; stdout: string; stderr: string; code: number; killed: boolean; binaryMissing: boolean; } export interface SeedsExecResult { stdout: string; stderr: string; code: number; killed: boolean; binaryMissing?: boolean; runner: SeedsCliRunner; args: string[]; attempts: SeedsExecAttempt[]; } export interface SeedsCommandOptions { cwd: string; signal?: AbortSignal; timeout?: number; json?: boolean; } export interface SeedsCommandResult { ok: boolean; text: string; details: { kind: SeedsResultKind; cwd: string; runner: SeedsCliRunner; args: string[]; exitCode: number; killed: boolean; binaryMissing: boolean; command?: string; payload?: SeedsJsonResult; stdout: string; stderr: string; attempts: SeedsExecAttempt[]; }; } interface ExecLikeResult { stdout: string; stderr: string; code: number; killed: boolean; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function ensureJsonArgs(args: string[], enabled = true): string[] { if (!enabled || args.includes("--json")) return [...args]; return [...args, "--json"]; } export function isCommandNotFound(stderr: string, code: number): boolean { if (code === 127) return true; const text = stderr.toLowerCase(); return ( text.includes("command not found") || text.includes("no such file") || text.includes("is not recognized") || text.includes("cannot find") || text.includes("enoent") ); } export function isBinaryMissing(result: ExecLikeResult): boolean { if (result.code === 0) return false; if (isCommandNotFound(result.stderr, result.code)) return true; return !result.killed && result.stderr === "" && result.stdout === ""; } export function parseSeedsPayload(text: string): SeedsJsonResult | undefined { const trimmed = text.trim(); if (!trimmed) return undefined; try { const parsed = JSON.parse(trimmed) as unknown; return isRecord(parsed) ? (parsed as SeedsJsonResult) : undefined; } catch { return undefined; } } function getPayloadMessage(payload?: SeedsJsonResult): string { if (!payload) return ""; if (typeof payload.error === "string" && payload.error.trim()) { return payload.error.trim(); } if (typeof payload.message === "string" && payload.message.trim()) { return payload.message.trim(); } return ""; } function compactText(stdout: string, stderr: string): string { return [stdout.trim(), stderr.trim()].filter(Boolean).join("\n"); } function makeAttempt( runner: SeedsCliRunner, command: string, args: string[], result: ExecLikeResult, ): SeedsExecAttempt { return { runner, command, args, stdout: result.stdout, stderr: result.stderr, code: result.code, killed: result.killed, binaryMissing: isBinaryMissing(result), }; } export function isNotSeedsProject(stderr: string, stdout: string): boolean { const payload = parseSeedsPayload(stdout) ?? parseSeedsPayload(stderr); const text = ( getPayloadMessage(payload) || compactText(stdout, stderr) ).toLowerCase(); return ( text.includes("not in a seeds project") || (text.includes("run `sd init`") && text.includes("seeds")) ); } export function normalizeSeedsErrorMessage( stderr: string, stdout: string, binaryMissing = false, ): string { if (binaryMissing) { return ( "Seeds CLI is not available.\n\n" + "Tried, in order:\n" + "- `sd`\n" + `- \`bunx --package ${SEEDS_CLI_PACKAGE} sd\`\n` + `- \`npx --yes ${SEEDS_CLI_PACKAGE}\`\n\n` + "Install it globally, or make bun/npm available in your PATH, then retry." ); } if (isNotSeedsProject(stderr, stdout)) { return ( "Not in a Seeds project. Seeds is per-project.\n\n" + "Run `sd init` in the repository you want Pi to manage, or switch to a repo that already has `.seeds/`." ); } const payload = parseSeedsPayload(stdout) ?? parseSeedsPayload(stderr); const message = getPayloadMessage(payload) || compactText(stdout, stderr); return `Seeds error: ${message || "Unknown Seeds error"}`; } export async function execSeedsCli( pi: ExtensionAPI, args: string[], options: SeedsCommandOptions, ): Promise { const finalArgs = ensureJsonArgs(args, options.json ?? true); const runners: Array<{ runner: SeedsCliRunner; command: string; args: string[]; }> = [ { runner: "sd", command: "sd", args: finalArgs }, { runner: "bunx", command: "bunx", args: ["--package", SEEDS_CLI_PACKAGE, "sd", ...finalArgs], }, { runner: "npx", command: "npx", args: ["--yes", SEEDS_CLI_PACKAGE, ...finalArgs], }, ]; const attempts: SeedsExecAttempt[] = []; for (const runner of runners) { const result = await pi.exec(runner.command, runner.args, { cwd: options.cwd, signal: options.signal, timeout: options.timeout, }); const attempt = makeAttempt( runner.runner, runner.command, runner.args, result, ); attempts.push(attempt); if (!attempt.binaryMissing || runner.runner === "npx") { return { stdout: result.stdout, stderr: result.stderr, code: result.code, killed: result.killed, binaryMissing: attempt.binaryMissing && runner.runner === "npx" ? true : undefined, runner: runner.runner, args: finalArgs, attempts, }; } } return { stdout: "", stderr: "", code: 1, killed: false, binaryMissing: true, runner: "npx", args: finalArgs, attempts, }; } export function classifySeedsResult( raw: SeedsExecResult, payload?: SeedsJsonResult, ): SeedsResultKind { if (payload?.success === true || (raw.code === 0 && !payload)) { return "success"; } if (raw.binaryMissing) return "missing_binary"; const combinedText = getPayloadMessage(payload) || compactText(raw.stdout, raw.stderr); const command = typeof payload?.command === "string" ? payload.command : undefined; if (isNotSeedsProject(raw.stderr, raw.stdout)) return "missing_project"; if ( /required option|unknown option|too many arguments|usage:/i.test( combinedText, ) ) { return "usage_error"; } if ( command === "sync" && /git|commit|branch|repository|index\.lock|working tree/i.test(combinedText) ) { return "git_error"; } if (raw.stdout.trim() && !payload) return "json_error"; return "runtime_error"; } function formatFailureText( kind: SeedsResultKind, raw: SeedsExecResult, payload?: SeedsJsonResult, ): string { const payloadText = getPayloadMessage(payload); switch (kind) { case "missing_binary": return normalizeSeedsErrorMessage(raw.stderr, raw.stdout, true); case "missing_project": return "Seeds is not initialized in this project. Run `sd init` first."; case "usage_error": case "git_error": case "runtime_error": return ( payloadText || compactText(raw.stdout, raw.stderr) || "Seeds command failed." ); case "json_error": return `Seeds returned non-JSON output:\n${raw.stdout.trim()}`; default: return ( payloadText || compactText(raw.stdout, raw.stderr) || "Seeds command failed." ); } } export async function runSeedsCommand( pi: ExtensionAPI, args: string[], options: SeedsCommandOptions, ): Promise { const raw = await execSeedsCli(pi, args, options); const payload = parseSeedsPayload(raw.stdout); const kind = classifySeedsResult(raw, payload); const ok = kind === "success"; const text = ok ? payload ? formatSeedsResult(payload) : compactText(raw.stdout, raw.stderr) || "Seeds command completed." : formatFailureText(kind, raw, payload); return { ok, text, details: { kind, cwd: options.cwd, runner: raw.runner, args: raw.args, exitCode: raw.code, killed: raw.killed, binaryMissing: Boolean(raw.binaryMissing), command: typeof payload?.command === "string" ? payload.command : undefined, payload, stdout: raw.stdout, stderr: raw.stderr, attempts: raw.attempts, }, }; } export async function execSeeds( pi: ExtensionAPI, args: string[], signal?: AbortSignal, cwd = process.cwd(), ): Promise { return execSeedsCli(pi, args, { cwd, signal }); } export async function runSeedsJson( pi: ExtensionAPI, args: string[], signal?: AbortSignal, cwd = process.cwd(), ): Promise<{ data: SeedsJsonResult; result: SeedsExecResult }> { const result = await execSeedsCli(pi, args, { cwd, signal }); const payload = parseSeedsPayload(result.stdout); const kind = classifySeedsResult(result, payload); if (kind !== "success") { throw new Error(formatFailureText(kind, result, payload)); } if (!payload) { throw new Error("Seeds returned unexpected output; expected JSON."); } return { data: payload, result }; } export async function runSeedsText( pi: ExtensionAPI, args: string[], signal?: AbortSignal, cwd = process.cwd(), ): Promise<{ text: string; result: SeedsExecResult }> { const result = await execSeedsCli(pi, args, { cwd, signal }); const payload = parseSeedsPayload(result.stdout); const kind = classifySeedsResult(result, payload); if (kind !== "success") { throw new Error(formatFailureText(kind, result, payload)); } const text = payload ? formatSeedsResult(payload) : result.stdout; return { text, result }; } export function findSeedsRoot(startDir: string): string | null { let current = path.resolve(startDir); while (true) { const candidate = path.join(current, ".seeds"); if (fs.existsSync(candidate)) return current; const parent = path.dirname(current); if (parent === current) return null; current = parent; } } function formatLabels(labels?: string[]): string { if (!labels || labels.length === 0) return ""; return ` labels:${labels.join(",")}`; } function _formatIssueLine(issue: SeedsIssue): string { const meta = [ issue.status ? `[${issue.status}]` : undefined, issue.type, issue.priority !== undefined ? `P${issue.priority}` : undefined, issue.assignee ? `@${issue.assignee}` : undefined, ] .filter(Boolean) .join(" "); const blocked = issue.blockedBy?.length ? ` blocked-by:${issue.blockedBy.join(",")}` : ""; return `- ${issue.id}${meta ? ` ${meta}` : ""} — ${issue.title}${formatLabels(issue.labels)}${blocked}`; } function formatIssueDetails(issue: SeedsIssue): string { const lines = [`# ${issue.id}: ${issue.title}`, ""]; lines.push(`- Status: ${issue.status ?? "unknown"}`); lines.push(`- Type: ${issue.type ?? "unknown"}`); if (issue.priority !== undefined) { lines.push(`- Priority: P${issue.priority}`); } if (issue.assignee) { lines.push(`- Assignee: ${issue.assignee}`); } if (issue.labels && issue.labels.length > 0) { lines.push(`- Labels: ${issue.labels.join(", ")}`); } if (issue.blockedBy && issue.blockedBy.length > 0) { lines.push(`- Blocked by: ${issue.blockedBy.join(", ")}`); } if (issue.blocks && issue.blocks.length > 0) { lines.push(`- Blocks: ${issue.blocks.join(", ")}`); } if (issue.createdAt) { lines.push(`- Created: ${issue.createdAt}`); } if (issue.updatedAt) { lines.push(`- Updated: ${issue.updatedAt}`); } if (issue.description) { lines.push("", "## Description", "", issue.description); } return lines.join("\n"); } function formatIssueList( _heading: string, issues: SeedsIssue[], count?: number, ): string { if (issues.length === 0) { return "No issues found."; } const total = count ?? issues.length; const lines = [`${total} issue${total === 1 ? "" : "s"} found:`]; for (const issue of issues) { lines.push(`- ${issue.id}: ${issue.title}`); } return lines.join("\n"); } function formatCounts( heading: string, counts: Record | undefined, prefix = "", ): string[] { const entries = Object.entries(counts ?? {}); if (entries.length === 0) { return [`- ${heading}: none`]; } const text = entries .sort((a, b) => a[0].localeCompare(b[0])) .map(([key, value]) => `${prefix}${key}:${value}`) .join(", "); return [`- ${heading}: ${text}`]; } function formatStats(stats: SeedsStats): string { const lines = ["# Seeds Stats", ""]; lines.push(`- Total: ${stats.total ?? 0}`); lines.push(`- Open: ${stats.open ?? 0}`); lines.push(`- In progress: ${stats.inProgress ?? 0}`); lines.push(`- Closed: ${stats.closed ?? 0}`); lines.push(`- Blocked: ${stats.blocked ?? 0}`); lines.push(...formatCounts("By type", stats.byType)); lines.push(...formatCounts("By priority", stats.byPriority, "P")); lines.push(...formatCounts("By label", stats.byLabel)); return lines.join("\n"); } function formatDoctor( checks: SeedsDoctorCheck[], summary?: Record, ): string { if (summary && checks.length === 0) { return `Doctor summary: ${summary.pass ?? 0} pass, ${summary.warn ?? 0} warn, ${summary.fail ?? 0} fail.`; } const lines = ["# Seeds Doctor", ""]; if (summary) { lines.push( `Summary: pass=${summary.pass ?? 0} warn=${summary.warn ?? 0} fail=${summary.fail ?? 0}`, "", ); } for (const check of checks) { const status = String(check.status ?? "unknown").toUpperCase(); lines.push(`- ${status} ${check.name}: ${check.message}`); if (check.details && check.details.length > 0) { for (const detail of check.details) { lines.push(` - ${detail}`); } } } return lines.join("\n"); } function formatPrimeContent(content: string): string { const note = "> Pi note: Seeds is project-local here. If this repo also uses `agent/plans/*-todo.md` execution checklists, keep using them alongside Seeds.\n"; return `${note}\n${content.trim()}`; } function formatProjectResult(data: SeedsJsonResult): string { const command = String(data.command ?? "project"); switch (command) { case "init": return `Initialized Seeds in ${String(data.dir ?? ".seeds")}.`; case "sync": { if (typeof data.hasChanges === "boolean") { const changes = String(data.changes ?? "").trim(); return data.hasChanges ? `Seeds sync status: changes pending${changes ? `\n\n${changes}` : ""}` : "Seeds sync status: clean."; } return "Seeds sync completed."; } case "onboard": { if (typeof data.status === "string") { return `Seeds onboard status: ${data.status}.`; } const action = String(data.action ?? "updated"); const file = String(data.file ?? "project docs"); return `Seeds onboard ${action}: ${file}`; } default: return JSON.stringify(data, null, 2); } } function formatPlanReviewResult(data: SeedsJsonResult): string { return `Recorded plan review for ${String(data.plan_id ?? "plan")} by ${String(data.reviewedBy ?? "unknown reviewer")}.`; } function formatPlanOutcomeResult(data: SeedsJsonResult): string { const lines = [ `Recorded plan outcome for ${String(data.plan_id ?? "plan")}.`, "", `- Result: ${String(data.outcome ?? "unknown")}`, ]; if (typeof data.outcomeNote === "string" && data.outcomeNote.trim()) { lines.push(`- Note: ${data.outcomeNote.trim()}`); } if (typeof data.open_children === "number") { lines.push(`- Open children: ${data.open_children}`); } return lines.join("\n"); } function formatPlanValidateResult(data: SeedsJsonResult): string { return `Validated Seeds plan ${String(data.plan_id ?? data.id ?? "plan")}.`; } export function formatSeedsResult(data: SeedsJsonResult): string { const command = String(data.command ?? ""); if (command === "prime" && typeof data.content === "string") { return formatPrimeContent(data.content); } if (command === "show" && data.issue && typeof data.issue === "object") { return formatIssueDetails(data.issue as SeedsIssue); } if ( ["list", "ready", "blocked"].includes(command) && Array.isArray(data.issues) ) { const heading = command === "ready" ? "Seeds Ready" : command === "blocked" ? "Seeds Blocked" : "Seeds List"; return formatIssueList( heading, data.issues as SeedsIssue[], typeof data.count === "number" ? data.count : undefined, ); } if (command === "stats" && data.stats && typeof data.stats === "object") { return formatStats(data.stats as SeedsStats); } if (command === "doctor") { return formatDoctor( Array.isArray(data.checks) ? (data.checks as SeedsDoctorCheck[]) : [], (data.summary as Record | undefined) ?? undefined, ); } if (command === "create" && typeof data.id === "string") { return `Created Seeds issue ${data.id}.`; } if (command === "update" && data.issue && typeof data.issue === "object") { return `Updated Seeds issue ${(data.issue as SeedsIssue).id}.\n\n${formatIssueDetails(data.issue as SeedsIssue)}`; } if (command === "close" && Array.isArray(data.closed)) { return `Closed Seeds issues: ${(data.closed as string[]).join(", ")}`; } if (["dep add", "dep remove"].includes(command)) { return `${command} ok: ${String(data.issueId ?? "")} -> ${String(data.dependsOnId ?? "")}`; } if (command === "dep list") { const blockedBy = Array.isArray(data.blockedBy) ? (data.blockedBy as string[]) : []; const blocks = Array.isArray(data.blocks) ? (data.blocks as string[]) : []; return [ `# Dependencies for ${String(data.issueId ?? "issue")}`, "", `- Blocked by: ${blockedBy.length > 0 ? blockedBy.join(", ") : "none"}`, `- Blocks: ${blocks.length > 0 ? blocks.join(", ") : "none"}`, ].join("\n"); } if (["block", "unblock"].includes(command)) { return JSON.stringify(data, null, 2); } if (command === "label list-all") { const counts = (data.counts as Record | undefined) ?? {}; return ["# Seeds Labels", "", ...formatCounts("Labels", counts)].join("\n"); } if (command === "plan review") { return formatPlanReviewResult(data); } if (command === "plan outcome") { return formatPlanOutcomeResult(data); } if (command === "plan validate") { return formatPlanValidateResult(data); } if (["init", "sync", "onboard"].includes(command)) { return formatProjectResult(data); } return JSON.stringify(data, null, 2); } export async function maybeSpillLongText( content: string, limit = 8_000, ): Promise { if (content.length <= limit) return content; const spillPath = path.join(os.tmpdir(), `pi-seeds-${Date.now()}.md`); fs.writeFileSync(spillPath, content, "utf8"); const truncated = content.slice(0, limit); return ( `${truncated}\n\n` + `... [truncated; ${content.length - limit} more characters]\n` + `Full output written to: ${spillPath}` ); }