sync local pi changes
This commit is contained in:
50
.pi/agent/extensions/dev-tools/index.ts
Normal file
50
.pi/agent/extensions/dev-tools/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { createCommandFormatterRunner } from "./src/formatting/command-runner.ts";
|
||||
import { createCommandDiagnosticsBackend } from "./src/diagnostics/command-backend.ts";
|
||||
import { createLspClientManager } from "./src/diagnostics/lsp-client.ts";
|
||||
import { createSetupSuggestTool } from "./src/tools/setup-suggest.ts";
|
||||
import { probeProject } from "./src/project-probe.ts";
|
||||
import { createFormattedWriteTool } from "./src/tools/write.ts";
|
||||
import { createFormattedEditTool } from "./src/tools/edit.ts";
|
||||
import { createDevToolsRuntime } from "./src/runtime.ts";
|
||||
|
||||
export default function devTools(pi: ExtensionAPI) {
|
||||
const cwd = process.cwd();
|
||||
const agentDir = process.env.PI_CODING_AGENT_DIR ?? `${process.env.HOME}/.pi/agent`;
|
||||
|
||||
const runtime = createDevToolsRuntime({
|
||||
cwd,
|
||||
agentDir,
|
||||
formatterRunner: createCommandFormatterRunner({
|
||||
execCommand: async (command, args, options) => {
|
||||
const result = await pi.exec(command, args, { timeout: options.timeout });
|
||||
return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
||||
},
|
||||
}),
|
||||
commandBackend: createCommandDiagnosticsBackend({
|
||||
execCommand: async (command, args, options) => {
|
||||
const result = await pi.exec(command, args, { timeout: options.timeout });
|
||||
return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
||||
},
|
||||
}),
|
||||
lspBackend: createLspClientManager(),
|
||||
probeProject,
|
||||
});
|
||||
|
||||
pi.registerTool(createFormattedEditTool(cwd, runtime));
|
||||
pi.registerTool(createFormattedWriteTool(cwd, runtime));
|
||||
pi.registerTool(createSetupSuggestTool({
|
||||
suggestSetup: async () => {
|
||||
const probe = await probeProject({ cwd });
|
||||
return probe.summary;
|
||||
},
|
||||
}));
|
||||
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
const block = runtime.getPromptBlock();
|
||||
if (!block) return;
|
||||
return {
|
||||
systemPrompt: `${event.systemPrompt}\n\n${block}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
4386
.pi/agent/extensions/dev-tools/package-lock.json
generated
Normal file
4386
.pi/agent/extensions/dev-tools/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
.pi/agent/extensions/dev-tools/package.json
Normal file
23
.pi/agent/extensions/dev-tools/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "pi-dev-tools-extension",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tsx --test src/*.test.ts src/**/*.test.ts"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": ["./index.ts"]
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"picomatch": "^4.0.2",
|
||||
"vscode-jsonrpc": "^8.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.66.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
37
.pi/agent/extensions/dev-tools/src/config.test.ts
Normal file
37
.pi/agent/extensions/dev-tools/src/config.test.ts
Normal 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"]);
|
||||
});
|
||||
38
.pi/agent/extensions/dev-tools/src/config.ts
Normal file
38
.pi/agent/extensions/dev-tools/src/config.ts
Normal 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));
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
102
.pi/agent/extensions/dev-tools/src/diagnostics/lsp-client.ts
Normal file
102
.pi/agent/extensions/dev-tools/src/diagnostics/lsp-client.ts
Normal 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() };
|
||||
},
|
||||
};
|
||||
}
|
||||
19
.pi/agent/extensions/dev-tools/src/diagnostics/types.ts
Normal file
19
.pi/agent/extensions/dev-tools/src/diagnostics/types.ts
Normal 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;
|
||||
}
|
||||
16
.pi/agent/extensions/dev-tools/src/extension.test.ts
Normal file
16
.pi/agent/extensions/dev-tools/src/extension.test.ts
Normal 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"]);
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
26
.pi/agent/extensions/dev-tools/src/profiles.test.ts
Normal file
26
.pi/agent/extensions/dev-tools/src/profiles.test.ts
Normal 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");
|
||||
});
|
||||
47
.pi/agent/extensions/dev-tools/src/profiles.ts
Normal file
47
.pi/agent/extensions/dev-tools/src/profiles.ts
Normal 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;
|
||||
}
|
||||
14
.pi/agent/extensions/dev-tools/src/project-probe.test.ts
Normal file
14
.pi/agent/extensions/dev-tools/src/project-probe.test.ts
Normal 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/);
|
||||
});
|
||||
68
.pi/agent/extensions/dev-tools/src/project-probe.ts
Normal file
68
.pi/agent/extensions/dev-tools/src/project-probe.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
44
.pi/agent/extensions/dev-tools/src/runtime.test.ts
Normal file
44
.pi/agent/extensions/dev-tools/src/runtime.test.ts
Normal 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/);
|
||||
});
|
||||
134
.pi/agent/extensions/dev-tools/src/runtime.ts
Normal file
134
.pi/agent/extensions/dev-tools/src/runtime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
.pi/agent/extensions/dev-tools/src/schema.ts
Normal file
40
.pi/agent/extensions/dev-tools/src/schema.ts
Normal 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];
|
||||
27
.pi/agent/extensions/dev-tools/src/summary.test.ts
Normal file
27
.pi/agent/extensions/dev-tools/src/summary.test.ts
Normal 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/);
|
||||
});
|
||||
31
.pi/agent/extensions/dev-tools/src/summary.ts
Normal file
31
.pi/agent/extensions/dev-tools/src/summary.ts
Normal 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");
|
||||
}
|
||||
54
.pi/agent/extensions/dev-tools/src/tools/edit.test.ts
Normal file
54
.pi/agent/extensions/dev-tools/src/tools/edit.test.ts
Normal 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");
|
||||
});
|
||||
21
.pi/agent/extensions/dev-tools/src/tools/edit.ts
Normal file
21
.pi/agent/extensions/dev-tools/src/tools/edit.ts
Normal 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}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
17
.pi/agent/extensions/dev-tools/src/tools/setup-suggest.ts
Normal file
17
.pi/agent/extensions/dev-tools/src/tools/setup-suggest.ts
Normal 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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
40
.pi/agent/extensions/dev-tools/src/tools/write.test.ts
Normal file
40
.pi/agent/extensions/dev-tools/src/tools/write.test.ts
Normal 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");
|
||||
});
|
||||
23
.pi/agent/extensions/dev-tools/src/tools/write.ts
Normal file
23
.pi/agent/extensions/dev-tools/src/tools/write.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user