sync local pi changes

This commit is contained in:
alex wiesner
2026-04-09 23:14:57 +01:00
parent 18245c778e
commit ec378ebd28
128 changed files with 22510 additions and 3436 deletions

View File

@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mergeDevToolsConfig } from "./config.ts";
test("mergeDevToolsConfig lets project defaults override global defaults and replace same-name profiles", () => {
const merged = mergeDevToolsConfig(
{
defaults: { formatTimeoutMs: 8000, maxDiagnosticsPerFile: 10 },
profiles: [
{
name: "typescript",
match: ["**/*.ts"],
workspaceRootMarkers: ["package.json"],
formatter: { kind: "command", command: ["prettier", "--write", "{file}"] },
diagnostics: [],
},
],
},
{
defaults: { formatTimeoutMs: 3000 },
profiles: [
{
name: "typescript",
match: ["src/**/*.ts"],
workspaceRootMarkers: ["tsconfig.json"],
formatter: { kind: "command", command: ["biome", "format", "--write", "{file}"] },
diagnostics: [],
},
],
},
);
assert.equal(merged.defaults.formatTimeoutMs, 3000);
assert.equal(merged.defaults.maxDiagnosticsPerFile, 10);
assert.deepEqual(merged.profiles.map((profile) => profile.name), ["typescript"]);
assert.deepEqual(merged.profiles[0]?.match, ["src/**/*.ts"]);
});

View File

@@ -0,0 +1,38 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { Value } from "@sinclair/typebox/value";
import { DevToolsConfigSchema, type DevToolsConfig } from "./schema.ts";
export function mergeDevToolsConfig(globalConfig?: DevToolsConfig, projectConfig?: DevToolsConfig): DevToolsConfig {
const defaults = {
...(globalConfig?.defaults ?? {}),
...(projectConfig?.defaults ?? {}),
};
const globalProfiles = new Map((globalConfig?.profiles ?? []).map((profile) => [profile.name, profile]));
const mergedProfiles = [...(projectConfig?.profiles ?? [])];
for (const profile of globalProfiles.values()) {
if (!mergedProfiles.some((candidate) => candidate.name === profile.name)) {
mergedProfiles.push(profile);
}
}
return { defaults, profiles: mergedProfiles };
}
function readConfigIfPresent(path: string): DevToolsConfig | undefined {
if (!existsSync(path)) return undefined;
const parsed = JSON.parse(readFileSync(path, "utf8"));
if (!Value.Check(DevToolsConfigSchema, parsed)) {
const [firstError] = [...Value.Errors(DevToolsConfigSchema, parsed)];
throw new Error(`Invalid dev-tools config at ${path}: ${firstError?.message ?? "validation failed"}`);
}
return parsed as DevToolsConfig;
}
export function loadDevToolsConfig(cwd: string, agentDir: string): DevToolsConfig | undefined {
const globalPath = resolve(agentDir, "dev-tools.json");
const projectPath = resolve(cwd, ".pi/dev-tools.json");
return mergeDevToolsConfig(readConfigIfPresent(globalPath), readConfigIfPresent(projectPath));
}

View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createCommandDiagnosticsBackend } from "./command-backend.ts";
test("eslint-json parser returns normalized diagnostics", async () => {
const backend = createCommandDiagnosticsBackend({
execCommand: async () => ({
code: 1,
stdout: JSON.stringify([
{
filePath: "/repo/src/app.ts",
messages: [
{
ruleId: "no-console",
severity: 2,
message: "Unexpected console statement.",
line: 2,
column: 3,
},
],
},
]),
stderr: "",
}),
});
const result = await backend.collect({
absolutePath: "/repo/src/app.ts",
workspaceRoot: "/repo",
backend: {
kind: "command",
parser: "eslint-json",
command: ["eslint", "--format", "json", "{file}"],
},
});
assert.equal(result.status, "ok");
assert.equal(result.items[0]?.severity, "error");
assert.equal(result.items[0]?.message, "Unexpected console statement.");
});

View File

@@ -0,0 +1,45 @@
import type { DiagnosticsConfig } from "../schema.ts";
import type { DiagnosticsState, NormalizedDiagnostic } from "./types.ts";
function parseEslintJson(stdout: string): NormalizedDiagnostic[] {
const parsed = JSON.parse(stdout) as Array<any>;
return parsed.flatMap((entry) =>
(entry.messages ?? []).map((message: any) => ({
severity: message.severity === 2 ? "error" : "warning",
message: message.message,
line: message.line,
column: message.column,
source: "eslint",
code: message.ruleId ?? undefined,
})),
);
}
export function createCommandDiagnosticsBackend(deps: {
execCommand: (
command: string,
args: string[],
options: { cwd: string; timeout?: number },
) => Promise<{ code: number; stdout: string; stderr: string }>;
}) {
return {
async collect(input: {
absolutePath: string;
workspaceRoot: string;
backend: Extract<DiagnosticsConfig, { kind: "command" }>;
timeoutMs?: number;
}): Promise<DiagnosticsState> {
const [command, ...args] = input.backend.command.map((part) => part.replaceAll("{file}", input.absolutePath));
const result = await deps.execCommand(command, args, { cwd: input.workspaceRoot, timeout: input.timeoutMs });
try {
if (input.backend.parser === "eslint-json") {
return { status: "ok", items: parseEslintJson(result.stdout) };
}
return { status: "unavailable", items: [], message: `Unsupported diagnostics parser: ${input.backend.parser}` };
} catch (error) {
return { status: "unavailable", items: [], message: (error as Error).message };
}
},
};
}

View File

@@ -0,0 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createLspClientManager } from "./lsp-client.ts";
test("collectForFile sends initialize + didOpen and resolves publishDiagnostics", async () => {
const notifications: Array<{ method: string; params: any }> = [];
const manager = createLspClientManager({
createConnection: async () => ({
async initialize() {},
async openTextDocument(params) {
notifications.push({ method: "textDocument/didOpen", params });
},
async collectDiagnostics() {
return [
{
severity: "error",
message: "Type 'number' is not assignable to type 'string'.",
line: 1,
column: 7,
source: "tsserver",
},
];
},
async dispose() {},
}),
});
const result = await manager.collectForFile({
key: "typescript:/repo",
absolutePath: "/repo/src/app.ts",
workspaceRoot: "/repo",
languageId: "typescript",
text: "const x: string = 1\n",
command: ["typescript-language-server", "--stdio"],
});
assert.equal(result.status, "ok");
assert.equal(result.items[0]?.source, "tsserver");
assert.equal(notifications[0]?.method, "textDocument/didOpen");
});

View File

@@ -0,0 +1,102 @@
import { spawn } from "node:child_process";
import { pathToFileURL } from "node:url";
import * as rpc from "vscode-jsonrpc/node";
import type { DiagnosticsState } from "./types.ts";
const INITIALIZE = new rpc.RequestType<any, any, void, void>("initialize");
const DID_OPEN = new rpc.NotificationType<any, void>("textDocument/didOpen");
const INITIALIZED = new rpc.NotificationType<any, void>("initialized");
const PUBLISH_DIAGNOSTICS = new rpc.NotificationType<any, void>("textDocument/publishDiagnostics");
type LspConnection = {
initialize(): Promise<void>;
openTextDocument(params: any): Promise<void>;
collectDiagnostics(): Promise<DiagnosticsState["items"]>;
dispose(): Promise<void>;
};
export function createLspClientManager(deps: {
createConnection?: (input: { workspaceRoot: string; command: string[] }) => Promise<LspConnection>;
} = {}) {
const clients = new Map<string, LspConnection>();
async function defaultCreateConnection(input: { workspaceRoot: string; command: string[] }): Promise<LspConnection> {
const [command, ...args] = input.command;
const child = spawn(command, args, {
cwd: input.workspaceRoot,
stdio: ["pipe", "pipe", "pipe"],
});
const connection = rpc.createMessageConnection(
new rpc.StreamMessageReader(child.stdout),
new rpc.StreamMessageWriter(child.stdin),
);
let lastDiagnostics: DiagnosticsState["items"] = [];
connection.onNotification(PUBLISH_DIAGNOSTICS, (params: any) => {
lastDiagnostics = (params.diagnostics ?? []).map((diagnostic: any) => ({
severity: diagnostic.severity === 1 ? "error" : diagnostic.severity === 2 ? "warning" : "info",
message: diagnostic.message,
line: diagnostic.range?.start?.line !== undefined ? diagnostic.range.start.line + 1 : undefined,
column: diagnostic.range?.start?.character !== undefined ? diagnostic.range.start.character + 1 : undefined,
source: diagnostic.source ?? "lsp",
code: diagnostic.code ? String(diagnostic.code) : undefined,
}));
});
connection.listen();
await connection.sendRequest(INITIALIZE, {
processId: process.pid,
rootUri: pathToFileURL(input.workspaceRoot).href,
capabilities: {},
});
connection.sendNotification(INITIALIZED, {});
return {
async initialize() {},
async openTextDocument(params: any) {
connection.sendNotification(DID_OPEN, params);
},
async collectDiagnostics() {
await new Promise((resolve) => setTimeout(resolve, 100));
return lastDiagnostics;
},
async dispose() {
connection.dispose();
child.kill();
},
};
}
return {
async collectForFile(input: {
key: string;
absolutePath: string;
workspaceRoot: string;
languageId: string;
text: string;
command: string[];
}): Promise<DiagnosticsState> {
let client = clients.get(input.key);
if (!client) {
client = await (deps.createConnection ?? defaultCreateConnection)({
workspaceRoot: input.workspaceRoot,
command: input.command,
});
clients.set(input.key, client);
await client.initialize();
}
await client.openTextDocument({
textDocument: {
uri: pathToFileURL(input.absolutePath).href,
languageId: input.languageId,
version: 1,
text: input.text,
},
});
return { status: "ok", items: await client.collectDiagnostics() };
},
};
}

View File

@@ -0,0 +1,19 @@
export interface NormalizedDiagnostic {
severity: "error" | "warning" | "info";
message: string;
line?: number;
column?: number;
source: string;
code?: string;
}
export interface DiagnosticsState {
status: "ok" | "unavailable";
items: NormalizedDiagnostic[];
message?: string;
}
export interface CapabilityGap {
path: string;
message: string;
}

View File

@@ -0,0 +1,16 @@
import test from "node:test";
import assert from "node:assert/strict";
import devToolsExtension from "../index.ts";
test("the extension entrypoint registers edit, write, and setup suggestion tools", () => {
const registeredTools: string[] = [];
devToolsExtension({
registerTool(tool: { name: string }) {
registeredTools.push(tool.name);
},
on() {},
} as any);
assert.deepEqual(registeredTools.sort(), ["dev_tools_suggest_setup", "edit", "write"]);
});

View File

@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createCommandFormatterRunner } from "./command-runner.ts";
test("formatFile expands {file} and executes in the workspace root", async () => {
let captured: { command: string; args: string[]; cwd?: string } | undefined;
const runner = createCommandFormatterRunner({
execCommand: async (command, args, options) => {
captured = { command, args, cwd: options.cwd };
return { code: 0, stdout: "", stderr: "" };
},
});
const result = await runner.formatFile({
absolutePath: "/repo/src/app.ts",
workspaceRoot: "/repo",
formatter: { kind: "command", command: ["biome", "format", "--write", "{file}"] },
});
assert.equal(result.status, "formatted");
assert.deepEqual(captured, {
command: "biome",
args: ["format", "--write", "/repo/src/app.ts"],
cwd: "/repo",
});
});

View File

@@ -0,0 +1,33 @@
import type { FormatterConfig } from "../schema.ts";
export function createCommandFormatterRunner(deps: {
execCommand: (
command: string,
args: string[],
options: { cwd: string; timeout?: number },
) => Promise<{ code: number; stdout: string; stderr: string }>;
}) {
return {
async formatFile(input: {
absolutePath: string;
workspaceRoot: string;
formatter: FormatterConfig;
timeoutMs?: number;
}) {
const [command, ...args] = input.formatter.command.map((part) => part.replaceAll("{file}", input.absolutePath));
const result = await deps.execCommand(command, args, {
cwd: input.workspaceRoot,
timeout: input.timeoutMs,
});
if (result.code !== 0) {
return {
status: "failed" as const,
message: (result.stderr || result.stdout || `formatter exited with ${result.code}`).trim(),
};
}
return { status: "formatted" as const };
},
};
}

View File

@@ -0,0 +1,26 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveProfileForPath } from "./profiles.ts";
test("resolveProfileForPath finds the first matching profile and nearest workspace root", () => {
const result = resolveProfileForPath(
{
defaults: {},
profiles: [
{
name: "typescript",
match: ["src/**/*.ts"],
workspaceRootMarkers: ["package.json", "tsconfig.json"],
formatter: { kind: "command", command: ["biome", "format", "--write", "{file}"] },
diagnostics: [],
},
],
},
"/repo/src/app.ts",
"/repo",
["/repo/package.json", "/repo/src/app.ts"],
);
assert.equal(result?.profile.name, "typescript");
assert.equal(result?.workspaceRoot, "/repo");
});

View File

@@ -0,0 +1,47 @@
import { dirname, relative, resolve } from "node:path";
import picomatch from "picomatch";
import type { DevToolsConfig, DevToolsProfile } from "./schema.ts";
export interface ResolvedProfileMatch {
profile: DevToolsProfile;
workspaceRoot: string;
}
export function resolveProfileForPath(
config: DevToolsConfig,
absolutePath: string,
cwd: string,
knownPaths: string[] = [],
): ResolvedProfileMatch | undefined {
const normalizedPath = resolve(absolutePath);
const relativePath = relative(cwd, normalizedPath).replace(/\\/g, "/");
for (const profile of config.profiles) {
if (!profile.match.some((pattern) => picomatch(pattern)(relativePath))) {
continue;
}
const workspaceRoot = findWorkspaceRoot(normalizedPath, cwd, profile.workspaceRootMarkers, knownPaths);
return { profile, workspaceRoot };
}
return undefined;
}
function findWorkspaceRoot(filePath: string, cwd: string, markers: string[], knownPaths: string[]): string {
let current = dirname(filePath);
const root = resolve(cwd);
while (current.startsWith(root)) {
for (const marker of markers) {
if (knownPaths.includes(resolve(current, marker))) {
return current;
}
}
const next = dirname(current);
if (next === current) break;
current = next;
}
return root;
}

View File

@@ -0,0 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import { probeProject } from "./project-probe.ts";
test("probeProject recognizes a TypeScript workspace and suggests biome + tsserver", async () => {
const result = await probeProject({
cwd: "/repo",
exists: async (path) => ["/repo/package.json", "/repo/tsconfig.json"].includes(path),
});
assert.equal(result.ecosystem, "typescript");
assert.match(result.summary, /Biome/);
assert.match(result.summary, /typescript-language-server/);
});

View File

@@ -0,0 +1,68 @@
import { access } from "node:fs/promises";
import { resolve } from "node:path";
export interface ProjectProbeResult {
ecosystem: string;
summary: string;
}
export async function probeProject(deps: {
cwd: string;
exists?: (path: string) => Promise<boolean>;
}): Promise<ProjectProbeResult> {
const exists = deps.exists ?? (async (path: string) => {
try {
await access(path);
return true;
} catch {
return false;
}
});
const cwd = resolve(deps.cwd);
const hasPackageJson = await exists(resolve(cwd, "package.json"));
const hasTsconfig = await exists(resolve(cwd, "tsconfig.json"));
const hasPyproject = await exists(resolve(cwd, "pyproject.toml"));
const hasCargo = await exists(resolve(cwd, "Cargo.toml"));
const hasGoMod = await exists(resolve(cwd, "go.mod"));
if (hasPackageJson && hasTsconfig) {
return {
ecosystem: "typescript",
summary: "TypeScript project detected. Recommended: Biome for formatting/linting and typescript-language-server for diagnostics.",
};
}
if (hasPyproject) {
return {
ecosystem: "python",
summary: "Python project detected. Recommended: Ruff for formatting/linting and basedpyright or pylsp for diagnostics.",
};
}
if (hasCargo) {
return {
ecosystem: "rust",
summary: "Rust project detected. Recommended: rustfmt + cargo clippy and rust-analyzer.",
};
}
if (hasGoMod) {
return {
ecosystem: "go",
summary: "Go project detected. Recommended: gofmt/goimports and gopls.",
};
}
if (hasPackageJson) {
return {
ecosystem: "javascript",
summary: "JavaScript project detected. Recommended: Biome or Prettier+ESLint, plus TypeScript language tooling if applicable.",
};
}
return {
ecosystem: "unknown",
summary: "No known project toolchain markers detected. Add dev-tools profiles for your formatter, linter, and language server.",
};
}

View File

@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createDevToolsRuntime } from "./runtime.ts";
test("refreshDiagnostics falls back to command diagnostics when LSP is unavailable", async () => {
const runtime = createDevToolsRuntime({
cwd: "/repo",
agentDir: "/agent",
loadConfig: () => ({
defaults: { maxDiagnosticsPerFile: 5 },
profiles: [
{
name: "typescript",
match: ["src/**/*.ts"],
languageId: "typescript",
workspaceRootMarkers: ["package.json"],
formatter: { kind: "command", command: ["biome", "format", "--write", "{file}"] },
diagnostics: [
{ kind: "lsp", command: ["typescript-language-server", "--stdio"] },
{ kind: "command", parser: "eslint-json", command: ["eslint", "--format", "json", "{file}"] },
],
},
],
}),
knownPaths: ["/repo/package.json"],
formatterRunner: { formatFile: async () => ({ status: "skipped" }) },
lspBackend: {
collectForFile: async () => ({ status: "unavailable", items: [], message: "spawn ENOENT" }),
},
commandBackend: {
collect: async () => ({
status: "ok",
items: [{ severity: "error", message: "Unexpected console statement.", line: 2, column: 3, source: "eslint" }],
}),
},
probeProject: async () => ({ ecosystem: "typescript", summary: "TypeScript project detected." }),
});
await runtime.refreshDiagnosticsForPath("/repo/src/app.ts", "console.log('x')\n");
const promptBlock = runtime.getPromptBlock() ?? "";
assert.match(promptBlock, /Unexpected console statement/);
assert.match(promptBlock, /spawn ENOENT/);
});

View File

@@ -0,0 +1,134 @@
import { readFile } from "node:fs/promises";
import { loadDevToolsConfig } from "./config.ts";
import type { CapabilityGap, DiagnosticsState } from "./diagnostics/types.ts";
import { probeProject } from "./project-probe.ts";
import { resolveProfileForPath } from "./profiles.ts";
import { buildPromptBlock } from "./summary.ts";
export interface FormatResult {
status: "formatted" | "skipped" | "failed";
message?: string;
}
export interface DevToolsRuntime {
formatAfterMutation(absolutePath: string): Promise<FormatResult>;
noteMutation(absolutePath: string, formatResult: FormatResult): void;
setDiagnostics(path: string, state: DiagnosticsState): void;
recordCapabilityGap(path: string, message: string): void;
getPromptBlock(): string | undefined;
refreshDiagnosticsForPath(absolutePath: string, text?: string): Promise<void>;
}
type LoadedConfig = ReturnType<typeof loadDevToolsConfig>;
export function createDevToolsRuntime(deps: {
cwd: string;
agentDir: string;
loadConfig?: () => LoadedConfig;
knownPaths?: string[];
formatterRunner: { formatFile: (input: any) => Promise<FormatResult> };
lspBackend: { collectForFile: (input: any) => Promise<DiagnosticsState> };
commandBackend: { collect: (input: any) => Promise<DiagnosticsState> };
probeProject?: typeof probeProject;
}): DevToolsRuntime {
const diagnosticsByFile = new Map<string, DiagnosticsState>();
const capabilityGaps: CapabilityGap[] = [];
const getConfig = () => deps.loadConfig?.() ?? loadDevToolsConfig(deps.cwd, deps.agentDir);
function setDiagnostics(path: string, state: DiagnosticsState) {
diagnosticsByFile.set(path, state);
}
function recordCapabilityGap(path: string, message: string) {
if (!capabilityGaps.some((gap) => gap.path === path && gap.message === message)) {
capabilityGaps.push({ path, message });
}
}
function getPromptBlock() {
const config = getConfig();
const maxDiagnosticsPerFile = config?.defaults?.maxDiagnosticsPerFile ?? 10;
if (diagnosticsByFile.size === 0 && capabilityGaps.length === 0) return undefined;
return buildPromptBlock({
maxDiagnosticsPerFile,
diagnosticsByFile,
capabilityGaps,
});
}
async function refreshDiagnosticsForPath(absolutePath: string, text?: string) {
const config = getConfig();
if (!config) {
recordCapabilityGap(absolutePath, "No dev-tools config found.");
return;
}
const match = resolveProfileForPath(config, absolutePath, deps.cwd, deps.knownPaths ?? []);
if (!match) {
const probe = await (deps.probeProject ?? probeProject)({ cwd: deps.cwd });
recordCapabilityGap(absolutePath, `No profile matched. ${probe.summary}`);
return;
}
const fileText = text ?? await readFile(absolutePath, "utf8");
for (const backend of match.profile.diagnostics) {
if (backend.kind === "lsp") {
const lspResult = await deps.lspBackend.collectForFile({
key: `${match.profile.languageId ?? "plain"}:${match.workspaceRoot}`,
absolutePath,
workspaceRoot: match.workspaceRoot,
languageId: match.profile.languageId ?? "plaintext",
text: fileText,
command: backend.command,
});
if (lspResult.status === "ok") {
setDiagnostics(absolutePath, lspResult);
if (lspResult.message) recordCapabilityGap(absolutePath, lspResult.message);
return;
}
recordCapabilityGap(absolutePath, lspResult.message ?? "LSP diagnostics unavailable.");
continue;
}
const commandResult = await deps.commandBackend.collect({
absolutePath,
workspaceRoot: match.workspaceRoot,
backend,
timeoutMs: config.defaults?.diagnosticTimeoutMs,
});
setDiagnostics(absolutePath, commandResult);
return;
}
recordCapabilityGap(absolutePath, "No diagnostics backend succeeded.");
}
return {
async formatAfterMutation(absolutePath: string) {
const config = getConfig();
if (!config) return { status: "skipped" as const };
const match = resolveProfileForPath(config, absolutePath, deps.cwd, deps.knownPaths ?? []);
if (!match?.profile.formatter) return { status: "skipped" as const };
return deps.formatterRunner.formatFile({
absolutePath,
workspaceRoot: match.workspaceRoot,
formatter: match.profile.formatter,
timeoutMs: config.defaults?.formatTimeoutMs,
});
},
noteMutation(absolutePath: string, formatResult: FormatResult) {
if (formatResult.status === "failed") {
recordCapabilityGap(absolutePath, formatResult.message ?? "Formatter failed.");
}
void refreshDiagnosticsForPath(absolutePath);
},
setDiagnostics,
recordCapabilityGap,
getPromptBlock,
refreshDiagnosticsForPath,
};
}

View File

@@ -0,0 +1,40 @@
import { Type, type Static } from "@sinclair/typebox";
const CommandSchema = Type.Object({
kind: Type.Literal("command"),
command: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
});
const LspSchema = Type.Object({
kind: Type.Literal("lsp"),
command: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
});
const CommandDiagnosticsSchema = Type.Object({
kind: Type.Literal("command"),
parser: Type.String({ minLength: 1 }),
command: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
});
export const DevToolsProfileSchema = Type.Object({
name: Type.String({ minLength: 1 }),
match: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
languageId: Type.Optional(Type.String({ minLength: 1 })),
workspaceRootMarkers: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
formatter: Type.Optional(CommandSchema),
diagnostics: Type.Array(Type.Union([LspSchema, CommandDiagnosticsSchema])),
});
export const DevToolsConfigSchema = Type.Object({
defaults: Type.Optional(Type.Object({
formatTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
diagnosticTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
maxDiagnosticsPerFile: Type.Optional(Type.Integer({ minimum: 1 })),
})),
profiles: Type.Array(DevToolsProfileSchema, { minItems: 1 }),
});
export type DevToolsProfile = Static<typeof DevToolsProfileSchema>;
export type DevToolsConfig = Static<typeof DevToolsConfigSchema>;
export type FormatterConfig = NonNullable<DevToolsProfile["formatter"]>;
export type DiagnosticsConfig = DevToolsProfile["diagnostics"][number];

View File

@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildPromptBlock } from "./summary.ts";
test("buildPromptBlock caps diagnostics per file and includes capability gaps", () => {
const block = buildPromptBlock({
maxDiagnosticsPerFile: 1,
diagnosticsByFile: new Map([
[
"/repo/src/app.ts",
{
status: "ok",
items: [
{ severity: "error", message: "Unexpected console statement.", line: 2, column: 3, source: "eslint" },
{ severity: "warning", message: "Unused variable.", line: 4, column: 9, source: "eslint" },
],
},
],
]),
capabilityGaps: [{ path: "/repo/src/app.ts", message: "Configured executable `eslint` not found in PATH." }],
});
assert.match(block, /app.ts: 1 error, 1 warning/);
assert.match(block, /Unexpected console statement/);
assert.doesNotMatch(block, /Unused variable/);
assert.match(block, /not found in PATH/);
});

View File

@@ -0,0 +1,31 @@
import type { CapabilityGap, DiagnosticsState } from "./diagnostics/types.ts";
export function buildPromptBlock(input: {
maxDiagnosticsPerFile: number;
diagnosticsByFile: Map<string, DiagnosticsState>;
capabilityGaps: CapabilityGap[];
}) {
const lines = ["Current changed-file diagnostics:"];
for (const [path, state] of input.diagnosticsByFile) {
if (state.status === "unavailable") {
lines.push(`- ${path}: diagnostics unavailable (${state.message ?? "unknown error"})`);
continue;
}
const errors = state.items.filter((item) => item.severity === "error");
const warnings = state.items.filter((item) => item.severity === "warning");
lines.push(`- ${path}: ${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`);
for (const item of state.items.slice(0, input.maxDiagnosticsPerFile)) {
const location = item.line ? `:${item.line}${item.column ? `:${item.column}` : ""}` : "";
lines.push(` - ${item.severity.toUpperCase()}${location} ${item.message}`);
}
}
for (const gap of input.capabilityGaps) {
lines.push(`- setup gap for ${gap.path}: ${gap.message}`);
}
return lines.join("\n");
}

View File

@@ -0,0 +1,54 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createFormattedEditTool } from "./edit.ts";
test("edit applies the replacement and then formats the file", async () => {
const dir = await mkdtemp(join(tmpdir(), "dev-tools-edit-"));
const path = join(dir, "app.ts");
await writeFile(path, "const x=1\n", "utf8");
const tool = createFormattedEditTool(dir, {
formatAfterMutation: async (absolutePath) => {
await writeFile(absolutePath, "const x = 2;\n", "utf8");
return { status: "formatted" };
},
noteMutation() {},
});
await tool.execute(
"tool-1",
{ path, edits: [{ oldText: "const x=1", newText: "const x=2" }] },
undefined,
undefined,
undefined,
);
assert.equal(await readFile(path, "utf8"), "const x = 2;\n");
});
test("edit preserves the changed file when formatter fails", async () => {
const dir = await mkdtemp(join(tmpdir(), "dev-tools-edit-"));
const path = join(dir, "app.ts");
await writeFile(path, "const x=1\n", "utf8");
const tool = createFormattedEditTool(dir, {
formatAfterMutation: async () => ({ status: "failed", message: "formatter not found" }),
noteMutation() {},
});
await assert.rejects(
() => tool.execute(
"tool-1",
{ path, edits: [{ oldText: "const x=1", newText: "const x=2" }] },
undefined,
undefined,
undefined,
),
/Auto-format failed/,
);
assert.equal(await readFile(path, "utf8"), "const x=2\n");
});

View File

@@ -0,0 +1,21 @@
import { createEditToolDefinition } from "@mariozechner/pi-coding-agent";
import { constants } from "node:fs";
import { access, readFile, writeFile } from "node:fs/promises";
import type { DevToolsRuntime } from "../runtime.ts";
export function createFormattedEditTool(cwd: string, runtime: DevToolsRuntime) {
return createEditToolDefinition(cwd, {
operations: {
access: (absolutePath) => access(absolutePath, constants.R_OK | constants.W_OK),
readFile: (absolutePath) => readFile(absolutePath),
writeFile: async (absolutePath, content) => {
await writeFile(absolutePath, content, "utf8");
const formatResult = await runtime.formatAfterMutation(absolutePath);
runtime.noteMutation(absolutePath, formatResult);
if (formatResult.status === "failed") {
throw new Error(`Auto-format failed for ${absolutePath}: ${formatResult.message}`);
}
},
},
});
}

View File

@@ -0,0 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createSetupSuggestTool } from "./setup-suggest.ts";
test("dev_tools_suggest_setup returns a concrete recommendation string", async () => {
const tool = createSetupSuggestTool({
suggestSetup: async () => "TypeScript project detected. Recommended: bunx biome init and npm i -D typescript-language-server.",
});
const result = await tool.execute("tool-1", {}, undefined, undefined, undefined);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
assert.match(text, /TypeScript project detected/);
assert.match(text, /biome/);
});

View File

@@ -0,0 +1,17 @@
import { Type } from "@sinclair/typebox";
export function createSetupSuggestTool(deps: { suggestSetup: () => Promise<string> }) {
return {
name: "dev_tools_suggest_setup",
label: "Dev Tools Suggest Setup",
description: "Suggest formatter/linter/LSP setup for the current project.",
parameters: Type.Object({}),
async execute() {
const text = await deps.suggestSetup();
return {
content: [{ type: "text" as const, text }],
details: { suggestion: text },
};
},
};
}

View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createFormattedWriteTool } from "./write.ts";
test("write keeps the file when auto-format fails", async () => {
const dir = await mkdtemp(join(tmpdir(), "dev-tools-write-"));
const path = join(dir, "app.ts");
const tool = createFormattedWriteTool(dir, {
formatAfterMutation: async () => ({ status: "failed", message: "biome missing" }),
noteMutation() {},
});
await assert.rejects(
() => tool.execute("tool-1", { path, content: "const x=1\n" }, undefined, undefined, undefined),
/Auto-format failed/,
);
assert.equal(await readFile(path, "utf8"), "const x=1\n");
});
test("write calls formatting immediately after writing", async () => {
const dir = await mkdtemp(join(tmpdir(), "dev-tools-write-"));
const path = join(dir, "app.ts");
const tool = createFormattedWriteTool(dir, {
formatAfterMutation: async (absolutePath) => {
await writeFile(absolutePath, "const x = 1;\n", "utf8");
return { status: "formatted" };
},
noteMutation() {},
});
await tool.execute("tool-1", { path, content: "const x=1\n" }, undefined, undefined, undefined);
assert.equal(await readFile(path, "utf8"), "const x = 1;\n");
});

View File

@@ -0,0 +1,23 @@
import { createWriteToolDefinition } from "@mariozechner/pi-coding-agent";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { DevToolsRuntime } from "../runtime.ts";
export function createFormattedWriteTool(cwd: string, runtime: DevToolsRuntime) {
const original = createWriteToolDefinition(cwd, {
operations: {
mkdir: (dir) => mkdir(dir, { recursive: true }).then(() => {}),
writeFile: async (absolutePath, content) => {
await mkdir(dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content, "utf8");
const formatResult = await runtime.formatAfterMutation(absolutePath);
runtime.noteMutation(absolutePath, formatResult);
if (formatResult.status === "failed") {
throw new Error(`Auto-format failed for ${absolutePath}: ${formatResult.message}`);
}
},
},
});
return original;
}