@difflab/pi 0.1.0 → 0.2.0-rc.202609170717.9cb91d1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/agents/diffpi-autonomous.md +15 -0
- package/agents/diffpi-copilot.md +22 -0
- package/agents/diffpi-orchestrator.md +23 -0
- package/agents/diffpi-planner.md +15 -0
- package/agents/diffpi-reviewer.md +27 -0
- package/agents/diffpi-tutor.md +20 -0
- package/agents/diffpi-worker.md +19 -0
- package/dist/assets.d.ts +2 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/environment.d.ts +40 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/extensions/index.js +2054 -182
- package/dist/forge.d.ts +48 -0
- package/dist/forge.d.ts.map +1 -0
- package/dist/fsx.d.ts +6 -0
- package/dist/fsx.d.ts.map +1 -0
- package/dist/gates.d.ts +12 -0
- package/dist/gates.d.ts.map +1 -0
- package/dist/index.d.ts +19 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1528 -149
- package/dist/modes.d.ts +59 -0
- package/dist/modes.d.ts.map +1 -0
- package/dist/pi.d.ts +21 -5
- package/dist/pi.d.ts.map +1 -1
- package/dist/process.d.ts +2 -0
- package/dist/process.d.ts.map +1 -1
- package/dist/review.d.ts +57 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/store.d.ts +15 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/tools/index.d.ts +5 -2
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +1763 -171
- package/dist/tools/modes.d.ts +4 -0
- package/dist/tools/modes.d.ts.map +1 -0
- package/dist/tools/review.d.ts +7 -0
- package/dist/tools/review.d.ts.map +1 -0
- package/dist/tools/setup.d.ts.map +1 -1
- package/dist/tuicr.d.ts +43 -0
- package/dist/tuicr.d.ts.map +1 -0
- package/dist/zed.d.ts +11 -0
- package/dist/zed.d.ts.map +1 -0
- package/package.json +2 -1
- package/skills/diffpi-setup/SKILL.md +15 -1
- package/skills/mode/SKILL.md +38 -0
- package/skills/review/SKILL.md +13 -0
- package/skills/review/references/workflows/address.md +6 -0
- package/skills/review/references/workflows/complete.md +6 -0
- package/skills/review/references/workflows/help.md +12 -0
- package/skills/review/references/workflows/merge.md +6 -0
- package/skills/review/references/workflows/new.md +6 -0
- package/skills/review/references/workflows/open.md +6 -0
- package/skills/review/references/workflows/publish.md +5 -0
package/dist/tools/index.js
CHANGED
|
@@ -25,83 +25,119 @@ function createDiffpiReloadTool(pi) {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// src/tools/
|
|
28
|
+
// src/tools/modes.ts
|
|
29
29
|
import { defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
|
|
30
30
|
import { z as z2 } from "zod";
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
var
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
31
|
+
var emptyParametersSchema = z2.object({});
|
|
32
|
+
var emptyParameters = z2.toJSONSchema(emptyParametersSchema, { io: "input" });
|
|
33
|
+
var listParametersSchema = z2.object({
|
|
34
|
+
includeSkills: z2.boolean().optional().describe("Include skill-owned agents using skill:agent ids.")
|
|
35
|
+
});
|
|
36
|
+
var listParameters = z2.toJSONSchema(listParametersSchema, { io: "input" });
|
|
37
|
+
var setParametersSchema = z2.object({
|
|
38
|
+
agent: z2.string().trim().min(1).describe("Inline agent id from diffpi_modes_list.")
|
|
39
|
+
});
|
|
40
|
+
var setParameters = z2.toJSONSchema(setParametersSchema, { io: "input" });
|
|
41
|
+
function createModeTools(controller) {
|
|
42
|
+
return [
|
|
43
|
+
defineTool2({
|
|
44
|
+
name: "diffpi_modes_list",
|
|
45
|
+
label: "diffpi modes list",
|
|
46
|
+
description: "List inline agents shared with the subagent plugin, optionally including skill-owned agents.",
|
|
47
|
+
promptSnippet: "List inline agents before selecting one when the requested agent is unclear",
|
|
48
|
+
promptGuidelines: [
|
|
49
|
+
"Call diffpi_modes_list when the user asks which inline agents are available.",
|
|
50
|
+
"Set includeSkills to true only when the user asks for skill agents or runs /skill:mode --include-skills.",
|
|
51
|
+
"Inline mode applies the profile prompt, first available preferred model, thinking level, and available tools."
|
|
52
|
+
],
|
|
53
|
+
parameters: listParameters,
|
|
54
|
+
executionMode: "parallel",
|
|
55
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
56
|
+
const params = listParametersSchema.parse(input);
|
|
57
|
+
const catalog = await controller.list(ctx, { includeSkills: params.includeSkills });
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text", text: formatCatalog(catalog, controller.getActive()?.id) }],
|
|
60
|
+
details: { active: controller.getActive()?.id, catalog }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}),
|
|
64
|
+
defineTool2({
|
|
65
|
+
name: "diffpi_modes_set",
|
|
66
|
+
label: "diffpi modes set",
|
|
67
|
+
description: "Set a validated available agent as the inline behavioral agent for subsequent chat turns.",
|
|
68
|
+
promptSnippet: "Set the inline behavioral agent only after the user chooses one",
|
|
69
|
+
promptGuidelines: [
|
|
70
|
+
"Call diffpi_modes_set only after the user explicitly selects an agent.",
|
|
71
|
+
"Use the exact skill:agent id for a skill-owned agent.",
|
|
72
|
+
"The selected prompt takes effect on the next model turn."
|
|
73
|
+
],
|
|
74
|
+
parameters: setParameters,
|
|
75
|
+
executionMode: "sequential",
|
|
76
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
77
|
+
const params = setParametersSchema.parse(input);
|
|
78
|
+
const result = await controller.set(params.agent, ctx);
|
|
79
|
+
if (!result.ok)
|
|
80
|
+
throw new Error(result.message);
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: "text", text: `${result.message} The prompt takes effect on the next turn.` }],
|
|
83
|
+
details: { active: result.active }
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
defineTool2({
|
|
88
|
+
name: "diffpi_modes_unset",
|
|
89
|
+
label: "diffpi modes unset",
|
|
90
|
+
description: "Clear the inline behavioral agent and restore default Pi prompting for subsequent turns.",
|
|
91
|
+
promptSnippet: "Clear the inline agent when the user asks for default behavior",
|
|
92
|
+
promptGuidelines: [
|
|
93
|
+
"Call diffpi_modes_unset only when the user explicitly asks to clear the active inline agent."
|
|
94
|
+
],
|
|
95
|
+
parameters: emptyParameters,
|
|
96
|
+
executionMode: "sequential",
|
|
97
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
98
|
+
emptyParametersSchema.parse(input);
|
|
99
|
+
const result = await controller.unset(ctx);
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: "text", text: result.message }],
|
|
102
|
+
details: { active: controller.getActive()?.id }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
];
|
|
67
107
|
}
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!isRecord(value))
|
|
74
|
-
throw new Error("not an object");
|
|
75
|
-
const servers = value.mcpServers;
|
|
76
|
-
if (servers !== undefined && !isRecord(servers))
|
|
77
|
-
throw new Error("mcpServers is not an object");
|
|
78
|
-
return { ...value, mcpServers: servers ?? {} };
|
|
79
|
-
} catch {
|
|
80
|
-
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
108
|
+
function formatCatalog(catalog, active) {
|
|
109
|
+
const lines = [`Active inline agent: ${active ?? "default"}.`, "", "Available inline agents:"];
|
|
110
|
+
for (const mode of catalog.modes) {
|
|
111
|
+
const runtime = [mode.modelPreferences[0], mode.thinkingLevel].filter(Boolean).join(", ");
|
|
112
|
+
lines.push(`- ${mode.id} [${mode.promptStrategy}${runtime ? `; ${runtime}` : ""}] — ${sanitize(mode.description)} (${mode.source})`);
|
|
81
113
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
} catch (error) {
|
|
87
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
88
|
-
return;
|
|
89
|
-
throw error;
|
|
114
|
+
if (catalog.diagnostics.length > 0) {
|
|
115
|
+
lines.push("", "Skipped agent files:");
|
|
116
|
+
for (const diagnostic of catalog.diagnostics)
|
|
117
|
+
lines.push(`- ${sanitize(diagnostic)}`);
|
|
90
118
|
}
|
|
119
|
+
lines.push("", "Inline mode applies the profile prompt, preferred available model, thinking level, and tool set.");
|
|
120
|
+
return lines.join(`
|
|
121
|
+
`);
|
|
91
122
|
}
|
|
92
|
-
function
|
|
93
|
-
return value
|
|
123
|
+
function sanitize(value) {
|
|
124
|
+
return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
94
125
|
}
|
|
95
126
|
|
|
96
|
-
// src/
|
|
97
|
-
import {
|
|
98
|
-
import {
|
|
99
|
-
import {
|
|
127
|
+
// src/tools/review.ts
|
|
128
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
129
|
+
import { mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises";
|
|
130
|
+
import { join as join5 } from "node:path";
|
|
131
|
+
import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent";
|
|
132
|
+
import { z as z4 } from "zod";
|
|
133
|
+
|
|
134
|
+
// src/environment.ts
|
|
135
|
+
import { basename } from "node:path";
|
|
100
136
|
|
|
101
137
|
// src/process.ts
|
|
102
138
|
import { constants } from "node:fs";
|
|
103
139
|
import { access } from "node:fs/promises";
|
|
104
|
-
import { delimiter, join
|
|
140
|
+
import { delimiter, join } from "node:path";
|
|
105
141
|
import { spawn } from "node:child_process";
|
|
106
142
|
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
107
143
|
async function findExecutable(name) {
|
|
@@ -116,7 +152,7 @@ async function findExecutable(name) {
|
|
|
116
152
|
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
117
153
|
if (!directory)
|
|
118
154
|
continue;
|
|
119
|
-
const candidate =
|
|
155
|
+
const candidate = join(directory, name);
|
|
120
156
|
try {
|
|
121
157
|
await access(candidate, constants.X_OK);
|
|
122
158
|
return candidate;
|
|
@@ -129,18 +165,35 @@ function run(command, args, options = {}) {
|
|
|
129
165
|
const child = spawn(command, args, {
|
|
130
166
|
cwd: options.cwd,
|
|
131
167
|
env: options.env ?? process.env,
|
|
132
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
168
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
133
169
|
});
|
|
134
170
|
let stdout = "";
|
|
135
171
|
let stderr = "";
|
|
136
|
-
|
|
137
|
-
|
|
172
|
+
const stdoutChunks = [];
|
|
173
|
+
const stderrChunks = [];
|
|
174
|
+
const unbounded = options.capture === "unbounded";
|
|
175
|
+
child.stdout?.on("data", (chunk) => {
|
|
176
|
+
const text = chunk.toString();
|
|
177
|
+
if (unbounded)
|
|
178
|
+
stdoutChunks.push(text);
|
|
179
|
+
else
|
|
180
|
+
stdout = appendBounded(stdout, text);
|
|
138
181
|
});
|
|
139
|
-
child.stderr
|
|
140
|
-
|
|
182
|
+
child.stderr?.on("data", (chunk) => {
|
|
183
|
+
const text = chunk.toString();
|
|
184
|
+
if (unbounded)
|
|
185
|
+
stderrChunks.push(text);
|
|
186
|
+
else
|
|
187
|
+
stderr = appendBounded(stderr, text);
|
|
141
188
|
});
|
|
142
189
|
child.on("error", reject);
|
|
143
|
-
child.on("close", (code) => resolve({
|
|
190
|
+
child.on("close", (code) => resolve({
|
|
191
|
+
code: code ?? 1,
|
|
192
|
+
stdout: unbounded ? stdoutChunks.join("") : stdout,
|
|
193
|
+
stderr: unbounded ? stderrChunks.join("") : stderr
|
|
194
|
+
}));
|
|
195
|
+
if (options.input !== undefined && child.stdin)
|
|
196
|
+
child.stdin.end(options.input);
|
|
144
197
|
});
|
|
145
198
|
}
|
|
146
199
|
async function runChecked(command, args, options = {}) {
|
|
@@ -155,7 +208,1422 @@ function appendBounded(current, next) {
|
|
|
155
208
|
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
156
209
|
}
|
|
157
210
|
|
|
211
|
+
// src/zed.ts
|
|
212
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
213
|
+
import { homedir } from "node:os";
|
|
214
|
+
import { dirname, join as join2 } from "node:path";
|
|
215
|
+
var ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
|
|
216
|
+
var REVIEW_KEYBINDING = "cmd-alt-r";
|
|
217
|
+
var REVIEW_TASK = {
|
|
218
|
+
label: ZED_REVIEW_TASK_NAME,
|
|
219
|
+
command: "tuicr",
|
|
220
|
+
args: ["-w"],
|
|
221
|
+
cwd: "$ZED_WORKTREE_ROOT",
|
|
222
|
+
use_new_terminal: true,
|
|
223
|
+
reveal: "always"
|
|
224
|
+
};
|
|
225
|
+
function zedTasksPath(homeDir = homedir()) {
|
|
226
|
+
return join2(homeDir, ".config", "zed", "tasks.json");
|
|
227
|
+
}
|
|
228
|
+
function zedKeymapPath(homeDir = homedir()) {
|
|
229
|
+
return join2(homeDir, ".config", "zed", "keymap.json");
|
|
230
|
+
}
|
|
231
|
+
async function ensureZedReviewTask(homeDir = homedir()) {
|
|
232
|
+
const path = zedTasksPath(homeDir);
|
|
233
|
+
const currentText = await readOptional(path);
|
|
234
|
+
const tasks = parseJsonArray(currentText, path);
|
|
235
|
+
const index = tasks.findIndex((task) => task.label === ZED_REVIEW_TASK_NAME);
|
|
236
|
+
const next = [...tasks];
|
|
237
|
+
if (index >= 0)
|
|
238
|
+
next[index] = { ...tasks[index], ...REVIEW_TASK };
|
|
239
|
+
else
|
|
240
|
+
next.push(REVIEW_TASK);
|
|
241
|
+
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
242
|
+
if (changed)
|
|
243
|
+
await writeJson(path, next);
|
|
244
|
+
return { path, changed, existed: currentText !== undefined };
|
|
245
|
+
}
|
|
246
|
+
async function ensureZedReviewKeybinding(homeDir = homedir()) {
|
|
247
|
+
const path = zedKeymapPath(homeDir);
|
|
248
|
+
const currentText = await readOptional(path);
|
|
249
|
+
const entries = parseJsonArray(currentText, path);
|
|
250
|
+
const alreadyBound = entries.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
|
|
251
|
+
if (alreadyBound)
|
|
252
|
+
return { path, changed: false, existed: currentText !== undefined };
|
|
253
|
+
const next = [
|
|
254
|
+
...entries,
|
|
255
|
+
{ context: "Workspace", bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_REVIEW_TASK_NAME }] } }
|
|
256
|
+
];
|
|
257
|
+
await writeJson(path, next);
|
|
258
|
+
return { path, changed: true, existed: currentText !== undefined };
|
|
259
|
+
}
|
|
260
|
+
function bindsReviewTask(payload) {
|
|
261
|
+
return typeof payload === "object" && payload !== null && payload.task_name === ZED_REVIEW_TASK_NAME;
|
|
262
|
+
}
|
|
263
|
+
async function readOptional(path) {
|
|
264
|
+
try {
|
|
265
|
+
return await readFile(path, "utf8");
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
268
|
+
return;
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function parseJsonArray(content, path) {
|
|
273
|
+
if (!content?.trim())
|
|
274
|
+
return [];
|
|
275
|
+
let value;
|
|
276
|
+
try {
|
|
277
|
+
value = JSON.parse(content);
|
|
278
|
+
} catch {
|
|
279
|
+
throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
|
|
280
|
+
}
|
|
281
|
+
if (!Array.isArray(value))
|
|
282
|
+
throw new Error(`Expected a JSON array in ${path}.`);
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
async function writeJson(path, value) {
|
|
286
|
+
await mkdir(dirname(path), { recursive: true });
|
|
287
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}
|
|
288
|
+
`, "utf8");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/environment.ts
|
|
292
|
+
function detectIde(env = process.env) {
|
|
293
|
+
const program = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
294
|
+
if (env.ZED_TERM === "true" || program === "zed")
|
|
295
|
+
return "zed";
|
|
296
|
+
if (env.CURSOR_TRACE_ID || program === "cursor")
|
|
297
|
+
return "cursor";
|
|
298
|
+
if (env.WINDSURF_ENV || program === "windsurf")
|
|
299
|
+
return "windsurf";
|
|
300
|
+
if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
|
|
301
|
+
return "jetbrains";
|
|
302
|
+
if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
|
|
303
|
+
return "vscode";
|
|
304
|
+
return "unknown";
|
|
305
|
+
}
|
|
306
|
+
function detectMux(env = process.env) {
|
|
307
|
+
if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
|
|
308
|
+
return "zellij";
|
|
309
|
+
if (env.TMUX)
|
|
310
|
+
return "tmux";
|
|
311
|
+
if (env.STY)
|
|
312
|
+
return "screen";
|
|
313
|
+
return "none";
|
|
314
|
+
}
|
|
315
|
+
function detectShell(env = process.env) {
|
|
316
|
+
return env.SHELL ? basename(env.SHELL) : "unknown";
|
|
317
|
+
}
|
|
318
|
+
async function detectVcs(cwd) {
|
|
319
|
+
const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
|
|
320
|
+
const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
|
321
|
+
const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
|
|
322
|
+
return { ...parseRemote(remote), branch, root };
|
|
323
|
+
}
|
|
324
|
+
function parseRemote(remote) {
|
|
325
|
+
const empty = { provider: "none", host: "", owner: "", repo: "" };
|
|
326
|
+
if (!remote)
|
|
327
|
+
return empty;
|
|
328
|
+
const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
|
|
329
|
+
const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
330
|
+
const match = scp ?? url;
|
|
331
|
+
if (!match)
|
|
332
|
+
return empty;
|
|
333
|
+
const host = match[1];
|
|
334
|
+
const segments = match[2].split("/").filter(Boolean);
|
|
335
|
+
if (segments.length < 2)
|
|
336
|
+
return { ...empty, host };
|
|
337
|
+
const repo = segments.at(-1) ?? "";
|
|
338
|
+
const owner = segments.slice(0, -1).join("/");
|
|
339
|
+
const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
|
|
340
|
+
return { provider, host, owner, repo };
|
|
341
|
+
}
|
|
342
|
+
async function openInNewTab(command, opts) {
|
|
343
|
+
const env = opts.env ?? process.env;
|
|
344
|
+
const name = opts.name ?? "review";
|
|
345
|
+
const printable = command.join(" ");
|
|
346
|
+
const mux = detectMux(env);
|
|
347
|
+
if (mux !== "none") {
|
|
348
|
+
const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
|
|
349
|
+
if (opened)
|
|
350
|
+
return opened;
|
|
351
|
+
}
|
|
352
|
+
if (detectIde(env) === "zed") {
|
|
353
|
+
try {
|
|
354
|
+
await ensureZedReviewTask(opts.homeDir);
|
|
355
|
+
return {
|
|
356
|
+
launched: false,
|
|
357
|
+
configured: true,
|
|
358
|
+
via: "zed-task",
|
|
359
|
+
command: printable,
|
|
360
|
+
taskName: ZED_REVIEW_TASK_NAME,
|
|
361
|
+
instruction: `Run the Zed task "${ZED_REVIEW_TASK_NAME}".`
|
|
362
|
+
};
|
|
363
|
+
} catch {}
|
|
364
|
+
}
|
|
365
|
+
return { launched: false, via: "print", command: printable };
|
|
366
|
+
}
|
|
367
|
+
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
368
|
+
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
369
|
+
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
370
|
+
if (result.code === 0)
|
|
371
|
+
return { launched: true, via: "zellij", command: printable };
|
|
372
|
+
const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
373
|
+
if (fallback.code === 0)
|
|
374
|
+
return { launched: true, via: "zellij-run", command: printable };
|
|
375
|
+
}
|
|
376
|
+
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
377
|
+
const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
378
|
+
if (result.code === 0)
|
|
379
|
+
return { launched: true, via: "tmux", command: printable };
|
|
380
|
+
}
|
|
381
|
+
if (mux === "screen" && await findExecutable("screen")) {
|
|
382
|
+
const result = await run("screen", screenWindowArgs(command, cwd, name));
|
|
383
|
+
if (result.code === 0)
|
|
384
|
+
return { launched: true, via: "screen", command: printable };
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
function screenWindowArgs(command, cwd, name) {
|
|
389
|
+
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/forge.ts
|
|
393
|
+
function createForge(vcs) {
|
|
394
|
+
if (vcs.provider === "github")
|
|
395
|
+
return new GithubForge(vcs);
|
|
396
|
+
if (vcs.provider === "gitlab")
|
|
397
|
+
return new GitlabForge(vcs);
|
|
398
|
+
throw new Error("No forge detected from the git remote. Use --local for an offline review.");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
class GithubForge {
|
|
402
|
+
vcs;
|
|
403
|
+
provider = "github";
|
|
404
|
+
constructor(vcs) {
|
|
405
|
+
this.vcs = vcs;
|
|
406
|
+
}
|
|
407
|
+
repoFlag() {
|
|
408
|
+
return ["--repo", `${this.vcs.owner}/${this.vcs.repo}`];
|
|
409
|
+
}
|
|
410
|
+
async createDraftPr(options) {
|
|
411
|
+
const args = [
|
|
412
|
+
"pr",
|
|
413
|
+
"create",
|
|
414
|
+
...this.repoFlag(),
|
|
415
|
+
"--title",
|
|
416
|
+
options.title,
|
|
417
|
+
"--body",
|
|
418
|
+
options.body,
|
|
419
|
+
"--base",
|
|
420
|
+
options.base,
|
|
421
|
+
"--head",
|
|
422
|
+
options.head
|
|
423
|
+
];
|
|
424
|
+
if (options.draft !== false)
|
|
425
|
+
args.push("--draft");
|
|
426
|
+
await runChecked("gh", args);
|
|
427
|
+
const ref = await this.viewPr(options.head);
|
|
428
|
+
if (!ref)
|
|
429
|
+
throw new Error("Draft PR created but could not be resolved.");
|
|
430
|
+
return ref;
|
|
431
|
+
}
|
|
432
|
+
async viewPr(idOrBranch) {
|
|
433
|
+
const args = [
|
|
434
|
+
"pr",
|
|
435
|
+
"view",
|
|
436
|
+
idOrBranch,
|
|
437
|
+
...this.repoFlag(),
|
|
438
|
+
"--json",
|
|
439
|
+
"number,title,url,isDraft,baseRefName,headRefName"
|
|
440
|
+
];
|
|
441
|
+
const result = await run("gh", args);
|
|
442
|
+
if (result.code !== 0) {
|
|
443
|
+
if (isConfirmedMissingChange("github", result.stderr || result.stdout))
|
|
444
|
+
return;
|
|
445
|
+
throw commandFailure("gh", args, result);
|
|
446
|
+
}
|
|
447
|
+
if (!result.stdout.trim())
|
|
448
|
+
throw new Error("GitHub returned an empty pull request response.");
|
|
449
|
+
let data;
|
|
450
|
+
try {
|
|
451
|
+
data = JSON.parse(result.stdout);
|
|
452
|
+
} catch {
|
|
453
|
+
throw new Error("Cannot parse the GitHub pull request response as JSON.");
|
|
454
|
+
}
|
|
455
|
+
if (typeof data.number !== "number" || typeof data.title !== "string" || typeof data.url !== "string" || typeof data.isDraft !== "boolean" || typeof data.baseRefName !== "string" || typeof data.headRefName !== "string") {
|
|
456
|
+
throw new Error("GitHub returned an invalid pull request response.");
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
number: data.number,
|
|
460
|
+
title: data.title,
|
|
461
|
+
url: data.url,
|
|
462
|
+
isDraft: data.isDraft,
|
|
463
|
+
baseRef: data.baseRefName,
|
|
464
|
+
headRef: data.headRefName
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
async defaultBranch() {
|
|
468
|
+
const result = await runChecked("gh", [
|
|
469
|
+
"repo",
|
|
470
|
+
"view",
|
|
471
|
+
`${this.vcs.owner}/${this.vcs.repo}`,
|
|
472
|
+
"--json",
|
|
473
|
+
"defaultBranchRef",
|
|
474
|
+
"--jq",
|
|
475
|
+
".defaultBranchRef.name"
|
|
476
|
+
]);
|
|
477
|
+
return requireBranchName(result.stdout, "GitHub");
|
|
478
|
+
}
|
|
479
|
+
async prDiff(id) {
|
|
480
|
+
return (await runChecked("gh", ["pr", "diff", String(id), ...this.repoFlag()], { capture: "unbounded" })).stdout;
|
|
481
|
+
}
|
|
482
|
+
async prChecks(id) {
|
|
483
|
+
return (await run("gh", ["pr", "checks", String(id), ...this.repoFlag()])).stdout;
|
|
484
|
+
}
|
|
485
|
+
async createPendingReview(id, comments, body) {
|
|
486
|
+
const payload = {
|
|
487
|
+
body,
|
|
488
|
+
comments: comments.map((comment) => ({
|
|
489
|
+
path: comment.file,
|
|
490
|
+
line: comment.line,
|
|
491
|
+
side: comment.side ?? "RIGHT",
|
|
492
|
+
body: comment.body
|
|
493
|
+
}))
|
|
494
|
+
};
|
|
495
|
+
await runChecked("gh", ["api", "--method", "POST", `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
|
|
496
|
+
}
|
|
497
|
+
async submitReview(id, event, body) {
|
|
498
|
+
const pending = await runChecked("gh", [
|
|
499
|
+
"api",
|
|
500
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`,
|
|
501
|
+
"--jq",
|
|
502
|
+
'[.[] | select(.state=="PENDING")] | last | .id'
|
|
503
|
+
]);
|
|
504
|
+
const reviewId = pending.stdout.trim();
|
|
505
|
+
const endpoint = githubReviewSubmissionEndpoint(this.vcs.owner, this.vcs.repo, id, reviewId);
|
|
506
|
+
const args = ["api", "--method", "POST", endpoint, "-f", `event=${event}`];
|
|
507
|
+
if (body.trim())
|
|
508
|
+
args.push("-f", `body=${body}`);
|
|
509
|
+
await runChecked("gh", args);
|
|
510
|
+
}
|
|
511
|
+
async markReady(id) {
|
|
512
|
+
await runChecked("gh", ["pr", "ready", String(id), ...this.repoFlag()]);
|
|
513
|
+
}
|
|
514
|
+
async closePr(id, comment) {
|
|
515
|
+
const args = ["pr", "close", String(id), ...this.repoFlag()];
|
|
516
|
+
if (comment)
|
|
517
|
+
args.push("--comment", comment);
|
|
518
|
+
await runChecked("gh", args);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
class GitlabForge {
|
|
523
|
+
vcs;
|
|
524
|
+
provider = "gitlab";
|
|
525
|
+
constructor(vcs) {
|
|
526
|
+
this.vcs = vcs;
|
|
527
|
+
}
|
|
528
|
+
project() {
|
|
529
|
+
return `${this.vcs.owner}/${this.vcs.repo}`;
|
|
530
|
+
}
|
|
531
|
+
async createDraftPr(options) {
|
|
532
|
+
await runChecked("glab", [
|
|
533
|
+
"mr",
|
|
534
|
+
"create",
|
|
535
|
+
"--repo",
|
|
536
|
+
this.project(),
|
|
537
|
+
"--title",
|
|
538
|
+
`Draft: ${options.title}`,
|
|
539
|
+
"--description",
|
|
540
|
+
options.body,
|
|
541
|
+
"--target-branch",
|
|
542
|
+
options.base,
|
|
543
|
+
"--source-branch",
|
|
544
|
+
options.head,
|
|
545
|
+
"--yes"
|
|
546
|
+
]);
|
|
547
|
+
const ref = await this.viewPr(options.head);
|
|
548
|
+
if (!ref)
|
|
549
|
+
throw new Error("Draft MR created but could not be resolved.");
|
|
550
|
+
return ref;
|
|
551
|
+
}
|
|
552
|
+
async viewPr(idOrBranch) {
|
|
553
|
+
const args = ["mr", "view", idOrBranch, "--repo", this.project(), "--output", "json"];
|
|
554
|
+
const result = await run("glab", args);
|
|
555
|
+
if (result.code !== 0) {
|
|
556
|
+
if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
|
|
557
|
+
return;
|
|
558
|
+
throw commandFailure("glab", args, result);
|
|
559
|
+
}
|
|
560
|
+
if (!result.stdout.trim())
|
|
561
|
+
throw new Error("GitLab returned an empty merge request response.");
|
|
562
|
+
let data;
|
|
563
|
+
try {
|
|
564
|
+
data = JSON.parse(result.stdout);
|
|
565
|
+
} catch {
|
|
566
|
+
throw new Error("Cannot parse the GitLab merge request response as JSON.");
|
|
567
|
+
}
|
|
568
|
+
if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string") {
|
|
569
|
+
throw new Error("GitLab returned an invalid merge request response.");
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
number: data.iid,
|
|
573
|
+
title: data.title,
|
|
574
|
+
url: data.web_url,
|
|
575
|
+
isDraft: Boolean(data.draft ?? data.work_in_progress),
|
|
576
|
+
baseRef: data.target_branch,
|
|
577
|
+
headRef: data.source_branch
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
async defaultBranch() {
|
|
581
|
+
const result = await runChecked("glab", [
|
|
582
|
+
"api",
|
|
583
|
+
`projects/${encodeURIComponent(this.project())}`,
|
|
584
|
+
"--jq",
|
|
585
|
+
".default_branch"
|
|
586
|
+
]);
|
|
587
|
+
return requireBranchName(result.stdout, "GitLab");
|
|
588
|
+
}
|
|
589
|
+
async prDiff(id) {
|
|
590
|
+
return (await runChecked("glab", ["mr", "diff", String(id), "--repo", this.project()], { capture: "unbounded" })).stdout;
|
|
591
|
+
}
|
|
592
|
+
async prChecks() {
|
|
593
|
+
return (await run("glab", ["ci", "status", "--repo", this.project()])).stdout;
|
|
594
|
+
}
|
|
595
|
+
async createPendingReview(id, comments, body) {
|
|
596
|
+
const endpoint = `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`;
|
|
597
|
+
if (body.trim()) {
|
|
598
|
+
await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
599
|
+
input: JSON.stringify({ note: body })
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
if (comments.length === 0)
|
|
603
|
+
return;
|
|
604
|
+
const response = await runChecked("glab", [
|
|
605
|
+
"api",
|
|
606
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}`
|
|
607
|
+
]);
|
|
608
|
+
const diffRefs = parseGitlabDiffRefs(response.stdout);
|
|
609
|
+
for (const comment of comments) {
|
|
610
|
+
const payload = {
|
|
611
|
+
note: comment.body,
|
|
612
|
+
position: {
|
|
613
|
+
...diffRefs,
|
|
614
|
+
position_type: "text",
|
|
615
|
+
new_path: comment.file,
|
|
616
|
+
old_path: comment.file,
|
|
617
|
+
new_line: comment.side === "LEFT" ? undefined : comment.line,
|
|
618
|
+
old_line: comment.side === "LEFT" ? comment.line : undefined
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
622
|
+
input: JSON.stringify(payload)
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async submitReview(id, event, body) {
|
|
627
|
+
assertReviewEventSupported(this.provider, event);
|
|
628
|
+
const drafts = await runChecked("glab", [
|
|
629
|
+
"api",
|
|
630
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`
|
|
631
|
+
]);
|
|
632
|
+
const hasDrafts = hasGitlabDraftNotes(drafts.stdout);
|
|
633
|
+
if (hasDrafts) {
|
|
634
|
+
await runChecked("glab", [
|
|
635
|
+
"api",
|
|
636
|
+
"--method",
|
|
637
|
+
"POST",
|
|
638
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes/bulk_publish`
|
|
639
|
+
]);
|
|
640
|
+
} else if (body.trim()) {
|
|
641
|
+
await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", body]);
|
|
642
|
+
}
|
|
643
|
+
if (event === "APPROVE")
|
|
644
|
+
await runChecked("glab", ["mr", "approve", String(id), "--repo", this.project()]);
|
|
645
|
+
}
|
|
646
|
+
async markReady(id) {
|
|
647
|
+
await runChecked("glab", ["mr", "update", String(id), "--repo", this.project(), "--ready"]);
|
|
648
|
+
}
|
|
649
|
+
async closePr(id, comment) {
|
|
650
|
+
if (comment)
|
|
651
|
+
await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", comment]);
|
|
652
|
+
await runChecked("glab", ["mr", "close", String(id), "--repo", this.project()]);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function assertReviewEventSupported(provider, event) {
|
|
656
|
+
if (provider === "gitlab" && event === "REQUEST_CHANGES") {
|
|
657
|
+
throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function isConfirmedMissingChange(provider, output) {
|
|
661
|
+
const message = output.toLowerCase();
|
|
662
|
+
if (provider === "github") {
|
|
663
|
+
return message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest");
|
|
664
|
+
}
|
|
665
|
+
if (provider === "gitlab") {
|
|
666
|
+
return message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message);
|
|
667
|
+
}
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
function commandFailure(command, args, result) {
|
|
671
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
672
|
+
return new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
673
|
+
}
|
|
674
|
+
function requireBranchName(output, provider) {
|
|
675
|
+
const branch = output.trim();
|
|
676
|
+
if (!branch || branch === "null")
|
|
677
|
+
throw new Error(`${provider} did not return a default branch.`);
|
|
678
|
+
return branch;
|
|
679
|
+
}
|
|
680
|
+
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
681
|
+
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
682
|
+
}
|
|
683
|
+
function parseGitlabDiffRefs(input) {
|
|
684
|
+
let data;
|
|
685
|
+
try {
|
|
686
|
+
data = JSON.parse(input);
|
|
687
|
+
} catch {
|
|
688
|
+
throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
|
|
689
|
+
}
|
|
690
|
+
const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
|
|
691
|
+
if (!base_sha || !start_sha || !head_sha) {
|
|
692
|
+
throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
|
|
693
|
+
}
|
|
694
|
+
return { base_sha, start_sha, head_sha };
|
|
695
|
+
}
|
|
696
|
+
function hasGitlabDraftNotes(input) {
|
|
697
|
+
try {
|
|
698
|
+
const data = JSON.parse(input);
|
|
699
|
+
return Array.isArray(data) && data.length > 0;
|
|
700
|
+
} catch {
|
|
701
|
+
throw new Error("Cannot complete GitLab review: the draft notes response was not valid JSON.");
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/gates.ts
|
|
706
|
+
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
707
|
+
var MISE_GATES = ["format:check", "lint", "test"];
|
|
708
|
+
function checkConventionalSubject(subject) {
|
|
709
|
+
const trimmed = subject.trim();
|
|
710
|
+
const ok = CONVENTIONAL_COMMIT.test(trimmed);
|
|
711
|
+
return {
|
|
712
|
+
name: "conventional-subject",
|
|
713
|
+
status: ok ? "pass" : "warn",
|
|
714
|
+
detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
async function runMiseGates(cwd) {
|
|
718
|
+
const tasks = await discoverMiseTasks(cwd);
|
|
719
|
+
const results = [];
|
|
720
|
+
for (const gate of MISE_GATES) {
|
|
721
|
+
const targets = tasks.get(gate) ?? [];
|
|
722
|
+
if (targets.length === 0) {
|
|
723
|
+
results.push({ name: gate, status: "skip", detail: "no mise recipe" });
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
|
|
727
|
+
const result = await run("mise", ["run", ...invocations], { cwd });
|
|
728
|
+
results.push({
|
|
729
|
+
name: gate,
|
|
730
|
+
status: result.code === 0 ? "pass" : "fail",
|
|
731
|
+
detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
return results;
|
|
735
|
+
}
|
|
736
|
+
function ciGate(checksOutput) {
|
|
737
|
+
const text = checksOutput.toLowerCase();
|
|
738
|
+
if (!text.trim())
|
|
739
|
+
return { name: "ci", status: "skip", detail: "no CI output" };
|
|
740
|
+
if (/\bfail|error\b/.test(text))
|
|
741
|
+
return { name: "ci", status: "warn", detail: "CI failing" };
|
|
742
|
+
if (/\bpending|in progress|queued\b/.test(text))
|
|
743
|
+
return { name: "ci", status: "warn", detail: "CI pending" };
|
|
744
|
+
return { name: "ci", status: "pass", detail: "CI green" };
|
|
745
|
+
}
|
|
746
|
+
async function discoverMiseTasks(cwd) {
|
|
747
|
+
const result = await run("mise", ["tasks", "--json", "--all"], { cwd });
|
|
748
|
+
if (result.code !== 0)
|
|
749
|
+
return new Map;
|
|
750
|
+
return parseMiseTasks(result.stdout);
|
|
751
|
+
}
|
|
752
|
+
function parseMiseTasks(input) {
|
|
753
|
+
let tasks;
|
|
754
|
+
try {
|
|
755
|
+
tasks = JSON.parse(input);
|
|
756
|
+
} catch {
|
|
757
|
+
return new Map;
|
|
758
|
+
}
|
|
759
|
+
if (!Array.isArray(tasks))
|
|
760
|
+
return new Map;
|
|
761
|
+
const found = new Map;
|
|
762
|
+
for (const gate of MISE_GATES) {
|
|
763
|
+
const targets = tasks.flatMap((task) => {
|
|
764
|
+
if (typeof task.name !== "string")
|
|
765
|
+
return [];
|
|
766
|
+
return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
|
|
767
|
+
});
|
|
768
|
+
if (targets.length > 0)
|
|
769
|
+
found.set(gate, [...new Set(targets)]);
|
|
770
|
+
}
|
|
771
|
+
return found;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/review.ts
|
|
775
|
+
import { join as join3 } from "node:path";
|
|
776
|
+
import { z as z3 } from "zod";
|
|
777
|
+
var severitySchema = z3.enum(["BLOCKING", "CONSIDER", "NOTE"]);
|
|
778
|
+
var findingSchema = z3.object({
|
|
779
|
+
file: z3.string().min(1),
|
|
780
|
+
line: z3.number().int().nonnegative(),
|
|
781
|
+
severity: severitySchema,
|
|
782
|
+
body: z3.string().min(1),
|
|
783
|
+
reference: z3.string().optional().default("")
|
|
784
|
+
});
|
|
785
|
+
var findingsSchema = z3.array(findingSchema);
|
|
786
|
+
function reviewSlug(input) {
|
|
787
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
788
|
+
}
|
|
789
|
+
function mmddyy(date = new Date) {
|
|
790
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
791
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
792
|
+
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
|
793
|
+
return `${mm}${dd}${yy}`;
|
|
794
|
+
}
|
|
795
|
+
function reviewRecordName(branch, date = new Date) {
|
|
796
|
+
return `${mmddyy(date)}-${reviewSlug(branch)}`;
|
|
797
|
+
}
|
|
798
|
+
function dedupeFindings(findings) {
|
|
799
|
+
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
800
|
+
const byKey = new Map;
|
|
801
|
+
for (const finding of findings) {
|
|
802
|
+
const key = `${finding.file}:${finding.line}`;
|
|
803
|
+
const existing = byKey.get(key);
|
|
804
|
+
if (!existing || rank[finding.severity] > rank[existing.severity])
|
|
805
|
+
byKey.set(key, finding);
|
|
806
|
+
}
|
|
807
|
+
return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
|
|
808
|
+
}
|
|
809
|
+
function toReviewComments(findings) {
|
|
810
|
+
return findings.filter((finding) => finding.line > 0).map((finding) => ({
|
|
811
|
+
file: finding.file,
|
|
812
|
+
line: finding.line,
|
|
813
|
+
side: "RIGHT",
|
|
814
|
+
body: renderCommentBody(finding)
|
|
815
|
+
}));
|
|
816
|
+
}
|
|
817
|
+
function renderReviewDoc(input) {
|
|
818
|
+
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
819
|
+
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
820
|
+
if (input.number !== undefined)
|
|
821
|
+
lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
|
|
822
|
+
if (input.author)
|
|
823
|
+
lines.push(`- **Author**: ${input.author}`);
|
|
824
|
+
if (input.headRef && input.baseRef)
|
|
825
|
+
lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
|
|
826
|
+
if (input.additions !== undefined)
|
|
827
|
+
lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
|
|
828
|
+
lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
|
|
829
|
+
if (input.overallIssues.length > 0) {
|
|
830
|
+
lines.push("## Overall issues", "");
|
|
831
|
+
for (const issue of input.overallIssues)
|
|
832
|
+
lines.push(`- ${issue}`);
|
|
833
|
+
lines.push("");
|
|
834
|
+
}
|
|
835
|
+
lines.push("## Verification", "");
|
|
836
|
+
for (const gate of input.gates)
|
|
837
|
+
lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
|
|
838
|
+
lines.push("");
|
|
839
|
+
if (input.notVerified.length > 0) {
|
|
840
|
+
lines.push("## What was NOT verified", "");
|
|
841
|
+
for (const item of input.notVerified)
|
|
842
|
+
lines.push(`- ${item}`);
|
|
843
|
+
lines.push("");
|
|
844
|
+
}
|
|
845
|
+
lines.push("## Inline Comments", "");
|
|
846
|
+
for (const finding of anchored) {
|
|
847
|
+
lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
|
|
848
|
+
if (finding.reference)
|
|
849
|
+
lines.push(`> **Reference:** ${finding.reference}`, "");
|
|
850
|
+
lines.push("---", "");
|
|
851
|
+
}
|
|
852
|
+
return `${lines.join(`
|
|
853
|
+
`).trimEnd()}
|
|
854
|
+
`;
|
|
855
|
+
}
|
|
856
|
+
function reviewWorkingDir(storeReviewsDir, slug) {
|
|
857
|
+
return join3(storeReviewsDir, slug);
|
|
858
|
+
}
|
|
859
|
+
function renderCommentBody(finding) {
|
|
860
|
+
const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
|
|
861
|
+
const reference = finding.reference ? `
|
|
862
|
+
|
|
863
|
+
> **Reference:** ${finding.reference}` : "";
|
|
864
|
+
return `${prefix}${finding.body}${reference}`;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/store.ts
|
|
868
|
+
import { createHash } from "node:crypto";
|
|
869
|
+
import { existsSync } from "node:fs";
|
|
870
|
+
import { mkdir as mkdir2, realpath, symlink } from "node:fs/promises";
|
|
871
|
+
import { homedir as homedir2 } from "node:os";
|
|
872
|
+
import { isAbsolute, join as join4, resolve } from "node:path";
|
|
873
|
+
var STORE_LINK = join4(".pi", "diffpi");
|
|
874
|
+
async function gitToplevel(cwd) {
|
|
875
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
876
|
+
const top = result.stdout.trim();
|
|
877
|
+
return result.code === 0 && top ? top : resolve(cwd);
|
|
878
|
+
}
|
|
879
|
+
async function computeProjectSlug(cwd) {
|
|
880
|
+
const root = await gitToplevel(cwd);
|
|
881
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
882
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
883
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
884
|
+
const common = commonResult.stdout.trim();
|
|
885
|
+
let commonPath = root;
|
|
886
|
+
if (commonResult.code === 0 && common) {
|
|
887
|
+
const resolvedCommon = isAbsolute(common) ? common : join4(root, common);
|
|
888
|
+
commonPath = resolve(resolvedCommon);
|
|
889
|
+
}
|
|
890
|
+
const canonicalCommon = await canonicalPath(commonPath);
|
|
891
|
+
const identity = remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${canonicalCommon}`;
|
|
892
|
+
const name = remote ? repositoryName(remote) : basename2(resolve(canonicalCommon, "..")) || basename2(root);
|
|
893
|
+
const readable = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
894
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12);
|
|
895
|
+
return `${readable}-${digest}`;
|
|
896
|
+
}
|
|
897
|
+
function storeGlobalRoot(homeDir = homedir2()) {
|
|
898
|
+
return join4(homeDir, ".difflab", "diffpi", "projects");
|
|
899
|
+
}
|
|
900
|
+
async function ensureStore(cwd, homeDir = homedir2()) {
|
|
901
|
+
const root = await gitToplevel(cwd);
|
|
902
|
+
const slug = await computeProjectSlug(root);
|
|
903
|
+
const dest = join4(storeGlobalRoot(homeDir), slug);
|
|
904
|
+
const link = join4(root, STORE_LINK);
|
|
905
|
+
if (existsSync(link))
|
|
906
|
+
return { slug, root, dest, link, linked: true };
|
|
907
|
+
await mkdir2(dest, { recursive: true });
|
|
908
|
+
await mkdir2(join4(root, ".pi"), { recursive: true });
|
|
909
|
+
await symlink(dest, link);
|
|
910
|
+
return { slug, root, dest, link, linked: true };
|
|
911
|
+
}
|
|
912
|
+
async function storeDir(cwd, homeDir = homedir2()) {
|
|
913
|
+
return (await ensureStore(cwd, homeDir)).dest;
|
|
914
|
+
}
|
|
915
|
+
async function reviewsDir(cwd, homeDir = homedir2()) {
|
|
916
|
+
const dir = join4(await storeDir(cwd, homeDir), "reviews");
|
|
917
|
+
await mkdir2(dir, { recursive: true });
|
|
918
|
+
return dir;
|
|
919
|
+
}
|
|
920
|
+
async function canonicalPath(path) {
|
|
921
|
+
try {
|
|
922
|
+
return await realpath(path);
|
|
923
|
+
} catch {
|
|
924
|
+
return resolve(path);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
function normalizeRemote(remote) {
|
|
928
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
929
|
+
}
|
|
930
|
+
function repositoryName(remote) {
|
|
931
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
932
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
933
|
+
}
|
|
934
|
+
function basename2(path) {
|
|
935
|
+
const parts = resolve(path).split(/[/\\]/).filter(Boolean);
|
|
936
|
+
return parts.at(-1) ?? "";
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/tuicr.ts
|
|
940
|
+
import { readFile as readFile2, realpath as realpath2 } from "node:fs/promises";
|
|
941
|
+
import { resolve as resolve2 } from "node:path";
|
|
942
|
+
async function listSessions(repo = ".") {
|
|
943
|
+
const result = await run("tuicr", ["review", "list", "--repo", repo]);
|
|
944
|
+
if (result.code !== 0 || !result.stdout.trim())
|
|
945
|
+
return [];
|
|
946
|
+
let raw;
|
|
947
|
+
try {
|
|
948
|
+
raw = JSON.parse(result.stdout);
|
|
949
|
+
} catch {
|
|
950
|
+
return [];
|
|
951
|
+
}
|
|
952
|
+
return raw.map((entry) => ({
|
|
953
|
+
slug: entry.slug,
|
|
954
|
+
kind: entry.kind,
|
|
955
|
+
path: entry.path,
|
|
956
|
+
updatedAt: entry.updated_at,
|
|
957
|
+
commentCount: entry.comment_count,
|
|
958
|
+
anchor: entry.anchor,
|
|
959
|
+
active: entry.active
|
|
960
|
+
}));
|
|
961
|
+
}
|
|
962
|
+
async function resolveSession(cwd, branch) {
|
|
963
|
+
return findMatchingSession(await listSessions(cwd), cwd, branch);
|
|
964
|
+
}
|
|
965
|
+
async function findMatchingSession(sessions, cwd, branch) {
|
|
966
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
967
|
+
for (const session of sessions) {
|
|
968
|
+
if (session.kind !== "local")
|
|
969
|
+
continue;
|
|
970
|
+
try {
|
|
971
|
+
const data = await readSession(session.path);
|
|
972
|
+
if (data.branch_name !== branch || !data.repo_path)
|
|
973
|
+
continue;
|
|
974
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
975
|
+
return session;
|
|
976
|
+
} catch {}
|
|
977
|
+
}
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
async function readSession(path) {
|
|
981
|
+
const content = await readFile2(path, "utf8");
|
|
982
|
+
try {
|
|
983
|
+
return JSON.parse(content);
|
|
984
|
+
} catch {
|
|
985
|
+
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
async function launch(cwd) {
|
|
989
|
+
return openInNewTab(["tuicr", "-w"], { cwd, name: "tuicr" });
|
|
990
|
+
}
|
|
991
|
+
function toFindings(session) {
|
|
992
|
+
const comments = [];
|
|
993
|
+
const bodyParts = (session.review_comments ?? []).map((comment) => comment.content);
|
|
994
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
995
|
+
const fileComments = (entry.file_comments ?? []).map((comment) => comment.content);
|
|
996
|
+
if (fileComments.length > 0)
|
|
997
|
+
bodyParts.push(`File: ${file}
|
|
998
|
+
|
|
999
|
+
${fileComments.join(`
|
|
1000
|
+
|
|
1001
|
+
`)}`);
|
|
1002
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
1003
|
+
const line = Number.parseInt(lineKey, 10);
|
|
1004
|
+
if (!Number.isFinite(line))
|
|
1005
|
+
continue;
|
|
1006
|
+
for (const lineComment of lineComments) {
|
|
1007
|
+
comments.push({ file, line, side: lineComment.side === "old" ? "LEFT" : "RIGHT", body: lineComment.content });
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
comments,
|
|
1013
|
+
body: bodyParts.join(`
|
|
1014
|
+
|
|
1015
|
+
`)
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
async function canonicalPath2(path) {
|
|
1019
|
+
try {
|
|
1020
|
+
return await realpath2(path);
|
|
1021
|
+
} catch {
|
|
1022
|
+
return resolve2(path);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/tools/review.ts
|
|
1027
|
+
var contextSchema = z4.object({ cwd: z4.string().optional() });
|
|
1028
|
+
var localSchema = contextSchema.extend({ local: z4.boolean().optional() });
|
|
1029
|
+
var openSchema = localSchema.extend({ title: z4.string().optional(), base: z4.string().optional() });
|
|
1030
|
+
var submitSchema = localSchema.extend({
|
|
1031
|
+
findings: findingsSchema,
|
|
1032
|
+
overallIssues: z4.array(z4.string()).optional(),
|
|
1033
|
+
notVerified: z4.array(z4.string()).optional(),
|
|
1034
|
+
title: z4.string().optional()
|
|
1035
|
+
});
|
|
1036
|
+
var completeSchema = contextSchema.extend({
|
|
1037
|
+
action: z4.enum(["accept", "reject", "close", "local"]),
|
|
1038
|
+
comment: z4.string().optional()
|
|
1039
|
+
});
|
|
1040
|
+
var eventSchema = localSchema.extend({ event: z4.enum(["APPROVE", "REQUEST_CHANGES", "COMMENT"]).optional() });
|
|
1041
|
+
var respondSchema = contextSchema.extend({
|
|
1042
|
+
body: z4.string().min(1),
|
|
1043
|
+
file: z4.string().optional(),
|
|
1044
|
+
line: z4.number().int().positive().optional()
|
|
1045
|
+
});
|
|
1046
|
+
function parameters(schema) {
|
|
1047
|
+
return z4.toJSONSchema(schema, { io: "input" });
|
|
1048
|
+
}
|
|
1049
|
+
function cwdOf(params) {
|
|
1050
|
+
return params.cwd ?? process.cwd();
|
|
1051
|
+
}
|
|
1052
|
+
function result(text, details = {}) {
|
|
1053
|
+
return { content: [{ type: "text", text }], details };
|
|
1054
|
+
}
|
|
1055
|
+
function conventionalMergeGuard(subject) {
|
|
1056
|
+
return checkConventionalSubject(subject);
|
|
1057
|
+
}
|
|
1058
|
+
function assertGitHubMergeReady(input) {
|
|
1059
|
+
let data;
|
|
1060
|
+
try {
|
|
1061
|
+
data = JSON.parse(input);
|
|
1062
|
+
} catch {
|
|
1063
|
+
throw new Error("Merge blocked: GitHub readiness response was not valid JSON.");
|
|
1064
|
+
}
|
|
1065
|
+
const blockers = [];
|
|
1066
|
+
if (data.state !== "OPEN")
|
|
1067
|
+
blockers.push(`pull request state is ${data.state ?? "unknown"}`);
|
|
1068
|
+
if (data.isDraft)
|
|
1069
|
+
blockers.push("pull request is still a draft");
|
|
1070
|
+
if (data.reviewDecision !== "APPROVED") {
|
|
1071
|
+
blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
|
|
1072
|
+
}
|
|
1073
|
+
if (data.mergeStateStatus !== "CLEAN") {
|
|
1074
|
+
blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
|
|
1075
|
+
}
|
|
1076
|
+
for (const check of data.statusCheckRollup ?? []) {
|
|
1077
|
+
const name = check.name ?? check.context ?? "unnamed check";
|
|
1078
|
+
if (check.__typename === "CheckRun") {
|
|
1079
|
+
if (check.status !== "COMPLETED")
|
|
1080
|
+
blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
|
|
1081
|
+
else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? "")) {
|
|
1082
|
+
blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
|
|
1083
|
+
}
|
|
1084
|
+
} else if (check.state !== "SUCCESS") {
|
|
1085
|
+
blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (blockers.length > 0)
|
|
1089
|
+
throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
|
|
1090
|
+
}
|
|
1091
|
+
function reviewSubmissionBody(body) {
|
|
1092
|
+
return body.trim() || "Inline comments only.";
|
|
1093
|
+
}
|
|
1094
|
+
function createReviewTools() {
|
|
1095
|
+
return [
|
|
1096
|
+
defineTool3({
|
|
1097
|
+
name: "review_context",
|
|
1098
|
+
label: "review context",
|
|
1099
|
+
description: "Read-only orientation for the current forge, environment, store, branch, PR, and tuicr session.",
|
|
1100
|
+
promptSnippet: "Call review_context first",
|
|
1101
|
+
promptGuidelines: ["Call this before every review workflow."],
|
|
1102
|
+
parameters: parameters(contextSchema),
|
|
1103
|
+
executionMode: "parallel",
|
|
1104
|
+
async execute(_id, input) {
|
|
1105
|
+
const params = contextSchema.parse(input);
|
|
1106
|
+
const cwd = cwdOf(params);
|
|
1107
|
+
const vcs = await detectVcs(cwd);
|
|
1108
|
+
const store = await ensureStore(cwd);
|
|
1109
|
+
const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
|
|
1110
|
+
const forge = vcs.provider === "none" ? undefined : createForge(vcs);
|
|
1111
|
+
const pr = forge ? await forge.viewPr(vcs.branch) : undefined;
|
|
1112
|
+
const baseRef = pr?.baseRef ?? (forge ? await forge.defaultBranch() : "local");
|
|
1113
|
+
const session = await resolveSession(cwd, vcs.branch);
|
|
1114
|
+
return result([
|
|
1115
|
+
`Forge: ${vcs.provider}${vcs.provider === "none" ? "" : ` (${vcs.owner}/${vcs.repo})`}`,
|
|
1116
|
+
`Branch: ${vcs.branch} → ${baseRef}`,
|
|
1117
|
+
`Env: ide=${env.ide} mux=${env.mux} shell=${env.shell}`,
|
|
1118
|
+
`Store: ${store.link} → ${store.dest}`,
|
|
1119
|
+
pr ? `PR/MR: #${pr.number} ${pr.url}` : "PR/MR: none",
|
|
1120
|
+
session ? `tuicr: ${session.slug} (${session.commentCount} comments)` : "tuicr: none"
|
|
1121
|
+
].join(`
|
|
1122
|
+
`), { vcs, env, store, pr, session, baseRef });
|
|
1123
|
+
}
|
|
1124
|
+
}),
|
|
1125
|
+
defineTool3({
|
|
1126
|
+
name: "review_open",
|
|
1127
|
+
label: "review open",
|
|
1128
|
+
description: "Create a draft PR/MR, or launch a local tuicr review.",
|
|
1129
|
+
promptSnippet: "Call review_open to start",
|
|
1130
|
+
promptGuidelines: ["Use local for the offline tuicr flow."],
|
|
1131
|
+
parameters: parameters(openSchema),
|
|
1132
|
+
executionMode: "sequential",
|
|
1133
|
+
async execute(_id, input) {
|
|
1134
|
+
const params = openSchema.parse(input);
|
|
1135
|
+
const cwd = cwdOf(params);
|
|
1136
|
+
await ensureStore(cwd);
|
|
1137
|
+
const vcs = await detectVcs(cwd);
|
|
1138
|
+
if (params.local || vcs.provider === "none") {
|
|
1139
|
+
const launched = await launch(cwd);
|
|
1140
|
+
const record = join5(await reviewsDir(cwd), `${reviewRecordName(vcs.branch)}.md`);
|
|
1141
|
+
if (!existsSync2(record))
|
|
1142
|
+
await writeFile2(record, `# Local review: ${vcs.branch}
|
|
1143
|
+
`, "utf8");
|
|
1144
|
+
return result(launched.launched ? `Opened tuicr (${launched.via}). Record: ${record}` : `${launched.instruction ?? `Run: ${launched.command}`}
|
|
1145
|
+
Record: ${record}`, { launched, record });
|
|
1146
|
+
}
|
|
1147
|
+
const forge = createForge(vcs);
|
|
1148
|
+
const pr = await forge.createDraftPr({
|
|
1149
|
+
title: params.title ?? deriveTitle(vcs.branch),
|
|
1150
|
+
body: "<!-- fill in intent, changes, validation -->",
|
|
1151
|
+
base: params.base ?? await forge.defaultBranch(),
|
|
1152
|
+
head: vcs.branch
|
|
1153
|
+
});
|
|
1154
|
+
return result(`Draft PR/MR created: ${pr.url}`, { pr });
|
|
1155
|
+
}
|
|
1156
|
+
}),
|
|
1157
|
+
defineTool3({
|
|
1158
|
+
name: "review_diff",
|
|
1159
|
+
label: "review diff",
|
|
1160
|
+
description: "Fetch the forge diff or the local working-tree diff.",
|
|
1161
|
+
promptSnippet: "Call review_diff for the code under review",
|
|
1162
|
+
promptGuidelines: ["Ground findings in this diff."],
|
|
1163
|
+
parameters: parameters(localSchema),
|
|
1164
|
+
executionMode: "parallel",
|
|
1165
|
+
async execute(_id, input) {
|
|
1166
|
+
const params = localSchema.parse(input);
|
|
1167
|
+
const cwd = cwdOf(params);
|
|
1168
|
+
const vcs = await detectVcs(cwd);
|
|
1169
|
+
if (params.local || vcs.provider === "none") {
|
|
1170
|
+
const diff = await run("git", ["-C", cwd, "diff", "HEAD"]);
|
|
1171
|
+
return result(diff.stdout || "No working-tree changes.", { diff: diff.stdout });
|
|
1172
|
+
}
|
|
1173
|
+
const pr = await createForge(vcs).viewPr(vcs.branch);
|
|
1174
|
+
if (!pr)
|
|
1175
|
+
return result("No open PR/MR. Run review_open first.");
|
|
1176
|
+
const diff = await createForge(vcs).prDiff(pr.number);
|
|
1177
|
+
return result(diff || "Empty diff.", { diff, pr });
|
|
1178
|
+
}
|
|
1179
|
+
}),
|
|
1180
|
+
defineTool3({
|
|
1181
|
+
name: "review_gates",
|
|
1182
|
+
label: "review gates",
|
|
1183
|
+
description: "Run format, lint, test, conventional-subject, and available CI checks.",
|
|
1184
|
+
promptSnippet: "Call review_gates before submitting findings",
|
|
1185
|
+
promptGuidelines: ["Report skipped gates as skipped."],
|
|
1186
|
+
parameters: parameters(localSchema),
|
|
1187
|
+
executionMode: "parallel",
|
|
1188
|
+
async execute(_id, input) {
|
|
1189
|
+
const params = localSchema.parse(input);
|
|
1190
|
+
const cwd = cwdOf(params);
|
|
1191
|
+
const vcs = await detectVcs(cwd);
|
|
1192
|
+
const gates = await runMiseGates(cwd);
|
|
1193
|
+
const subject = (await run("git", ["-C", cwd, "log", "-1", "--format=%s"])).stdout.trim();
|
|
1194
|
+
if (subject)
|
|
1195
|
+
gates.push(checkConventionalSubject(subject));
|
|
1196
|
+
if (!params.local && vcs.provider !== "none") {
|
|
1197
|
+
const forge = createForge(vcs);
|
|
1198
|
+
const pr = await forge.viewPr(vcs.branch);
|
|
1199
|
+
if (pr)
|
|
1200
|
+
gates.push(ciGate(await forge.prChecks(pr.number)));
|
|
1201
|
+
}
|
|
1202
|
+
return result(gates.map((gate) => `- ${gate.name}: ${gate.status} — ${gate.detail}`).join(`
|
|
1203
|
+
`), {
|
|
1204
|
+
results: gates
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}),
|
|
1208
|
+
defineTool3({
|
|
1209
|
+
name: "review_submit",
|
|
1210
|
+
label: "review submit",
|
|
1211
|
+
description: "Render findings to the shared review store and create a pending forge review unless local.",
|
|
1212
|
+
promptSnippet: "Call review_submit with the findings JSON",
|
|
1213
|
+
promptGuidelines: ["Use concrete file and line values."],
|
|
1214
|
+
parameters: parameters(submitSchema),
|
|
1215
|
+
executionMode: "sequential",
|
|
1216
|
+
async execute(_id, input) {
|
|
1217
|
+
const params = submitSchema.parse(input);
|
|
1218
|
+
const cwd = cwdOf(params);
|
|
1219
|
+
await ensureStore(cwd);
|
|
1220
|
+
const vcs = await detectVcs(cwd);
|
|
1221
|
+
const findings = dedupeFindings(params.findings);
|
|
1222
|
+
const slug = reviewSlug(params.title ?? vcs.branch) || "review";
|
|
1223
|
+
const dir = reviewWorkingDir(await reviewsDir(cwd), slug);
|
|
1224
|
+
await mkdir3(dir, { recursive: true });
|
|
1225
|
+
const gates = await runMiseGates(cwd);
|
|
1226
|
+
const forge = !params.local && vcs.provider !== "none" ? createForge(vcs) : undefined;
|
|
1227
|
+
const pr = forge ? await forge.viewPr(vcs.branch) : undefined;
|
|
1228
|
+
const baseRef = pr?.baseRef ?? (forge ? await forge.defaultBranch() : "local");
|
|
1229
|
+
const docPath = join5(dir, "new-review.md");
|
|
1230
|
+
await writeFile2(docPath, renderReviewDoc({
|
|
1231
|
+
title: params.title ?? vcs.branch,
|
|
1232
|
+
headRef: vcs.branch,
|
|
1233
|
+
baseRef,
|
|
1234
|
+
findings,
|
|
1235
|
+
overallIssues: params.overallIssues ?? [],
|
|
1236
|
+
gates,
|
|
1237
|
+
notVerified: params.notVerified ?? []
|
|
1238
|
+
}), "utf8");
|
|
1239
|
+
if (!forge)
|
|
1240
|
+
return result(`Local review written: ${docPath}`, { docPath, count: findings.length });
|
|
1241
|
+
if (!pr)
|
|
1242
|
+
return result(`No open PR/MR. Review written: ${docPath}`, { docPath });
|
|
1243
|
+
await forge.createPendingReview(pr.number, toReviewComments(findings), reviewSubmissionBody((params.overallIssues ?? []).join(`
|
|
1244
|
+
`)));
|
|
1245
|
+
return result(`Pending review posted to #${pr.number}. Doc: ${docPath}`, {
|
|
1246
|
+
docPath,
|
|
1247
|
+
pr,
|
|
1248
|
+
count: findings.length
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
}),
|
|
1252
|
+
defineTool3({
|
|
1253
|
+
name: "review_comments",
|
|
1254
|
+
label: "review comments",
|
|
1255
|
+
description: "Read unresolved local tuicr comments. Forge thread retrieval is delegated to the forge MCP when available.",
|
|
1256
|
+
promptSnippet: "Call review_comments before addressing findings",
|
|
1257
|
+
promptGuidelines: ["Use forge MCP thread tools when the review is remote."],
|
|
1258
|
+
parameters: parameters(localSchema),
|
|
1259
|
+
executionMode: "parallel",
|
|
1260
|
+
async execute(_id, input) {
|
|
1261
|
+
const params = localSchema.parse(input);
|
|
1262
|
+
const cwd = cwdOf(params);
|
|
1263
|
+
const vcs = await detectVcs(cwd);
|
|
1264
|
+
const session = await resolveSession(cwd, vcs.branch);
|
|
1265
|
+
if (!session)
|
|
1266
|
+
return result("No tuicr session found.");
|
|
1267
|
+
const normalized = toFindings(await readSession(session.path));
|
|
1268
|
+
return result(normalized.comments.map((comment) => `${comment.file}:${comment.line} — ${comment.body}`).join(`
|
|
1269
|
+
`) || "No comments.", { session, comments: normalized.comments });
|
|
1270
|
+
}
|
|
1271
|
+
}),
|
|
1272
|
+
defineTool3({
|
|
1273
|
+
name: "review_respond",
|
|
1274
|
+
label: "review respond",
|
|
1275
|
+
description: "Append a response to the local review record; remote responses should use the forge MCP thread tool.",
|
|
1276
|
+
promptSnippet: "Call review_respond after addressing a local comment",
|
|
1277
|
+
promptGuidelines: ["For remote reviews, prefer the forge MCP response and resolve tools."],
|
|
1278
|
+
parameters: parameters(respondSchema),
|
|
1279
|
+
executionMode: "sequential",
|
|
1280
|
+
async execute(_id, input) {
|
|
1281
|
+
const params = respondSchema.parse(input);
|
|
1282
|
+
const cwd = cwdOf(params);
|
|
1283
|
+
const path = join5(await reviewsDir(cwd), `${reviewRecordName((await detectVcs(cwd)).branch)}.md`);
|
|
1284
|
+
await mkdir3(join5(path, ".."), { recursive: true });
|
|
1285
|
+
await writeFile2(path, `
|
|
1286
|
+
## Response${params.file ? ` — ${params.file}:${params.line ?? 1}` : ""}
|
|
1287
|
+
|
|
1288
|
+
${params.body}
|
|
1289
|
+
`, { encoding: "utf8", flag: "a" });
|
|
1290
|
+
return result(`Response recorded: ${path}`, { path });
|
|
1291
|
+
}
|
|
1292
|
+
}),
|
|
1293
|
+
defineTool3({
|
|
1294
|
+
name: "review_publish",
|
|
1295
|
+
label: "review publish",
|
|
1296
|
+
description: "Mark a draft ready and submit its pending forge review, optionally publishing a local tuicr session.",
|
|
1297
|
+
promptSnippet: "Call review_publish to publish",
|
|
1298
|
+
promptGuidelines: ["Pass local to publish a tuicr session first."],
|
|
1299
|
+
parameters: parameters(eventSchema),
|
|
1300
|
+
executionMode: "sequential",
|
|
1301
|
+
async execute(_id, input) {
|
|
1302
|
+
const params = eventSchema.parse(input);
|
|
1303
|
+
const cwd = cwdOf(params);
|
|
1304
|
+
const vcs = await detectVcs(cwd);
|
|
1305
|
+
if (vcs.provider === "none")
|
|
1306
|
+
return result("No forge detected; local review remains in the shared store.");
|
|
1307
|
+
const forge = createForge(vcs);
|
|
1308
|
+
const pr = await forge.viewPr(vcs.branch);
|
|
1309
|
+
if (!pr)
|
|
1310
|
+
return result("No open PR/MR for this branch.");
|
|
1311
|
+
const event = params.event ?? "COMMENT";
|
|
1312
|
+
assertReviewEventSupported(forge.provider, event);
|
|
1313
|
+
let body = "";
|
|
1314
|
+
if (params.local) {
|
|
1315
|
+
const session = await resolveSession(cwd, vcs.branch);
|
|
1316
|
+
if (!session)
|
|
1317
|
+
return result("No tuicr session to publish.");
|
|
1318
|
+
const normalized = toFindings(await readSession(session.path));
|
|
1319
|
+
body = reviewSubmissionBody(normalized.body);
|
|
1320
|
+
await forge.createPendingReview(pr.number, normalized.comments, body);
|
|
1321
|
+
}
|
|
1322
|
+
if (pr.isDraft)
|
|
1323
|
+
await forge.markReady(pr.number);
|
|
1324
|
+
await forge.submitReview(pr.number, event, body);
|
|
1325
|
+
return result(`Published #${pr.number} (${event}).`, { pr, event });
|
|
1326
|
+
}
|
|
1327
|
+
}),
|
|
1328
|
+
defineTool3({
|
|
1329
|
+
name: "review_complete",
|
|
1330
|
+
label: "review complete",
|
|
1331
|
+
description: "Approve, request changes, close, or archive a review. This never merges.",
|
|
1332
|
+
promptSnippet: "Call review_complete to finish without merging",
|
|
1333
|
+
promptGuidelines: ["Use review_merge separately for merging."],
|
|
1334
|
+
parameters: parameters(completeSchema),
|
|
1335
|
+
executionMode: "sequential",
|
|
1336
|
+
async execute(_id, input) {
|
|
1337
|
+
const params = completeSchema.parse(input);
|
|
1338
|
+
const cwd = cwdOf(params);
|
|
1339
|
+
const vcs = await detectVcs(cwd);
|
|
1340
|
+
if (params.action === "local") {
|
|
1341
|
+
const source = await resolveSession(cwd, vcs.branch);
|
|
1342
|
+
const dest = uniqueRecordPath(await reviewsDir(cwd), reviewRecordName(vcs.branch));
|
|
1343
|
+
if (source) {
|
|
1344
|
+
const normalized = toFindings(await readSession(source.path));
|
|
1345
|
+
await writeFile2(dest, `# Completed review: ${vcs.branch}
|
|
1346
|
+
|
|
1347
|
+
${normalized.body}
|
|
1348
|
+
|
|
1349
|
+
${normalized.comments.map((comment) => `- ${comment.file}:${comment.line} — ${comment.body}`).join(`
|
|
1350
|
+
`)}
|
|
1351
|
+
`, "utf8");
|
|
1352
|
+
} else
|
|
1353
|
+
await writeFile2(dest, `# Completed review: ${vcs.branch}
|
|
1354
|
+
`, "utf8");
|
|
1355
|
+
return result(`Local review archived: ${dest}`, { dest });
|
|
1356
|
+
}
|
|
1357
|
+
if (vcs.provider === "none")
|
|
1358
|
+
return result("No forge detected. Use action=local for a local review.");
|
|
1359
|
+
const forge = createForge(vcs);
|
|
1360
|
+
const pr = await forge.viewPr(vcs.branch);
|
|
1361
|
+
if (!pr)
|
|
1362
|
+
return result("No open PR/MR for this branch.");
|
|
1363
|
+
if (params.action === "accept") {
|
|
1364
|
+
if (pr.isDraft)
|
|
1365
|
+
await forge.markReady(pr.number);
|
|
1366
|
+
await forge.submitReview(pr.number, "APPROVE", params.comment ?? "Approved.");
|
|
1367
|
+
return result(`Approved #${pr.number}. Merge separately with review_merge.`, { pr });
|
|
1368
|
+
}
|
|
1369
|
+
if (params.action === "reject") {
|
|
1370
|
+
await forge.submitReview(pr.number, "REQUEST_CHANGES", params.comment ?? "Requesting changes.");
|
|
1371
|
+
return result(`Requested changes on #${pr.number}.`, { pr });
|
|
1372
|
+
}
|
|
1373
|
+
await forge.closePr(pr.number, params.comment);
|
|
1374
|
+
return result(`Closed #${pr.number}.`, { pr });
|
|
1375
|
+
}
|
|
1376
|
+
}),
|
|
1377
|
+
defineTool3({
|
|
1378
|
+
name: "review_merge",
|
|
1379
|
+
label: "review merge",
|
|
1380
|
+
description: "Squash-merge an approved GitHub PR after checking its conventional subject.",
|
|
1381
|
+
promptSnippet: "Call review_merge only after review_complete accept",
|
|
1382
|
+
promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
|
|
1383
|
+
parameters: parameters(contextSchema.extend({ subject: z4.string().optional() })),
|
|
1384
|
+
executionMode: "sequential",
|
|
1385
|
+
async execute(_id, input) {
|
|
1386
|
+
const params = contextSchema.extend({ subject: z4.string().optional() }).parse(input);
|
|
1387
|
+
const cwd = cwdOf(params);
|
|
1388
|
+
const vcs = await detectVcs(cwd);
|
|
1389
|
+
if (vcs.provider !== "github")
|
|
1390
|
+
return result("review_merge currently supports GitHub only.");
|
|
1391
|
+
const pr = await createForge(vcs).viewPr(vcs.branch);
|
|
1392
|
+
if (!pr)
|
|
1393
|
+
return result("No open PR/MR for this branch.");
|
|
1394
|
+
const subject = params.subject ?? pr.title;
|
|
1395
|
+
const guard = conventionalMergeGuard(subject);
|
|
1396
|
+
if (guard.status !== "pass") {
|
|
1397
|
+
return result(`Merge blocked: ${guard.detail}`, { pr, guard });
|
|
1398
|
+
}
|
|
1399
|
+
const readiness = await runChecked("gh", [
|
|
1400
|
+
"pr",
|
|
1401
|
+
"view",
|
|
1402
|
+
String(pr.number),
|
|
1403
|
+
"--repo",
|
|
1404
|
+
`${vcs.owner}/${vcs.repo}`,
|
|
1405
|
+
"--json",
|
|
1406
|
+
"isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
|
|
1407
|
+
], { capture: "unbounded" });
|
|
1408
|
+
assertGitHubMergeReady(readiness.stdout);
|
|
1409
|
+
await runChecked("gh", [
|
|
1410
|
+
"pr",
|
|
1411
|
+
"merge",
|
|
1412
|
+
String(pr.number),
|
|
1413
|
+
"--repo",
|
|
1414
|
+
`${vcs.owner}/${vcs.repo}`,
|
|
1415
|
+
"--squash",
|
|
1416
|
+
"--subject",
|
|
1417
|
+
subject
|
|
1418
|
+
]);
|
|
1419
|
+
return result(`Merged #${pr.number} with subject: ${subject}.`, { pr, guard });
|
|
1420
|
+
}
|
|
1421
|
+
}),
|
|
1422
|
+
defineTool3({
|
|
1423
|
+
name: "review_launch",
|
|
1424
|
+
label: "review launch",
|
|
1425
|
+
description: "Open tuicr in a mux tab, configure its Zed task, or print the command.",
|
|
1426
|
+
promptSnippet: "Call review_launch for the interactive tuicr TUI",
|
|
1427
|
+
promptGuidelines: ["Show the returned command when launch cannot open a tab."],
|
|
1428
|
+
parameters: parameters(contextSchema),
|
|
1429
|
+
executionMode: "sequential",
|
|
1430
|
+
async execute(_id, input) {
|
|
1431
|
+
const params = contextSchema.parse(input);
|
|
1432
|
+
const launchResult = await launch(cwdOf(params));
|
|
1433
|
+
return result(launchResult.launched ? `Opened tuicr (${launchResult.via}).` : launchResult.instruction ?? `Run: ${launchResult.command}`, {
|
|
1434
|
+
launch: launchResult
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
})
|
|
1438
|
+
];
|
|
1439
|
+
}
|
|
1440
|
+
function deriveTitle(branch) {
|
|
1441
|
+
return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
|
|
1442
|
+
}
|
|
1443
|
+
function uniqueRecordPath(dir, base) {
|
|
1444
|
+
let path = join5(dir, `${base}.md`);
|
|
1445
|
+
let count = 2;
|
|
1446
|
+
while (existsSync2(path))
|
|
1447
|
+
path = join5(dir, `${base}-${count++}.md`);
|
|
1448
|
+
return path;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// src/tools/setup.ts
|
|
1452
|
+
import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
|
|
1453
|
+
import { z as z6 } from "zod";
|
|
1454
|
+
|
|
1455
|
+
// src/setup.ts
|
|
1456
|
+
import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
|
|
1457
|
+
import { readdir as readdir2, readFile as readFile6 } from "node:fs/promises";
|
|
1458
|
+
import { homedir as homedir7 } from "node:os";
|
|
1459
|
+
import { basename as basename4, join as join11 } from "node:path";
|
|
1460
|
+
|
|
1461
|
+
// src/assets.ts
|
|
1462
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1463
|
+
import { dirname as dirname2, join as join6 } from "node:path";
|
|
1464
|
+
import { fileURLToPath } from "node:url";
|
|
1465
|
+
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
1466
|
+
const moduleDir = dirname2(fileURLToPath(moduleUrl));
|
|
1467
|
+
const candidates = [
|
|
1468
|
+
join6(moduleDir, "agents"),
|
|
1469
|
+
join6(moduleDir, "..", "agents"),
|
|
1470
|
+
join6(moduleDir, "..", "..", "agents")
|
|
1471
|
+
];
|
|
1472
|
+
return candidates.find((path) => existsSync3(path)) ?? candidates[1];
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// src/config.ts
|
|
1476
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
1477
|
+
import { z as z5 } from "zod";
|
|
1478
|
+
import { homedir as homedir3 } from "node:os";
|
|
1479
|
+
import { join as join7 } from "node:path";
|
|
1480
|
+
|
|
1481
|
+
// src/fsx.ts
|
|
1482
|
+
import { readdir, readFile as readFile3 } from "node:fs/promises";
|
|
1483
|
+
async function readTextIfExists(path) {
|
|
1484
|
+
try {
|
|
1485
|
+
return await readFile3(path, "utf8");
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
if (isMissingPath(error))
|
|
1488
|
+
return;
|
|
1489
|
+
throw error;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
function isMissingPath(error) {
|
|
1493
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// src/config.ts
|
|
1497
|
+
var modelReferenceSchema = z5.string().trim().min(1);
|
|
1498
|
+
var agentConfigSchema = z5.object({
|
|
1499
|
+
models: z5.array(modelReferenceSchema).optional()
|
|
1500
|
+
}).strict();
|
|
1501
|
+
var diffpiConfigSchema = z5.object({
|
|
1502
|
+
agents: z5.record(z5.string(), agentConfigSchema).optional()
|
|
1503
|
+
}).strict();
|
|
1504
|
+
function diffpiConfigPaths(homeDir = homedir3()) {
|
|
1505
|
+
const directory = join7(homeDir, ".difflab", "diffpi");
|
|
1506
|
+
return {
|
|
1507
|
+
yaml: join7(directory, "config.yaml"),
|
|
1508
|
+
json: join7(directory, "config.json")
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
async function loadDiffpiConfig(options = {}) {
|
|
1512
|
+
const paths = diffpiConfigPaths(options.homeDir);
|
|
1513
|
+
for (const [format, path] of [
|
|
1514
|
+
["yaml", paths.yaml],
|
|
1515
|
+
["json", paths.json]
|
|
1516
|
+
]) {
|
|
1517
|
+
const content = await readTextIfExists(path);
|
|
1518
|
+
if (content === undefined)
|
|
1519
|
+
continue;
|
|
1520
|
+
try {
|
|
1521
|
+
const value = format === "yaml" ? parseYamlConfig(content) : JSON.parse(content);
|
|
1522
|
+
return { config: diffpiConfigSchema.parse(value ?? {}), path };
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1525
|
+
throw new Error(`Invalid Diffpi config at ${path}: ${reason}`, { cause: error });
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
return { config: {} };
|
|
1529
|
+
}
|
|
1530
|
+
function resolveAgentModelPreferences(agentId, profilePreferences, config) {
|
|
1531
|
+
const override = config.agents?.[agentId];
|
|
1532
|
+
if (override && Object.hasOwn(override, "models"))
|
|
1533
|
+
return [...override.models ?? []];
|
|
1534
|
+
return [...profilePreferences];
|
|
1535
|
+
}
|
|
1536
|
+
function findPreferredModel(models, preference) {
|
|
1537
|
+
const normalizedPreference = normalizeModelReference(preference);
|
|
1538
|
+
const exactReference = models.find((model) => normalizeModelReference(`${model.provider}/${model.id}`) === normalizedPreference);
|
|
1539
|
+
if (exactReference)
|
|
1540
|
+
return exactReference;
|
|
1541
|
+
const idPreference = preference.includes("/") ? preference.slice(preference.indexOf("/") + 1) : preference;
|
|
1542
|
+
const normalizedIdPreference = normalizeModelReference(idPreference);
|
|
1543
|
+
const exactId = models.find((model) => normalizeModelReference(model.id) === normalizedIdPreference);
|
|
1544
|
+
if (exactId)
|
|
1545
|
+
return exactId;
|
|
1546
|
+
const preferenceTokens = normalizedIdPreference.split("-").filter(Boolean);
|
|
1547
|
+
return models.find((model) => {
|
|
1548
|
+
const modelTokens = new Set(normalizeModelReference(model.id).split("-").filter(Boolean));
|
|
1549
|
+
return preferenceTokens.every((token) => modelTokens.has(token));
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
function parseYamlConfig(content) {
|
|
1553
|
+
const document = content.replace(/^\uFEFF/, "").replace(/^---[^\S\r\n]*(?:#.*)?(?:\r?\n|$)/, "");
|
|
1554
|
+
return parseFrontmatter(`---
|
|
1555
|
+
${document}
|
|
1556
|
+
---
|
|
1557
|
+
`).frontmatter;
|
|
1558
|
+
}
|
|
1559
|
+
function normalizeModelReference(value) {
|
|
1560
|
+
return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/mcp.ts
|
|
1564
|
+
import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
1565
|
+
import { homedir as homedir4 } from "node:os";
|
|
1566
|
+
import { dirname as dirname3, join as join8 } from "node:path";
|
|
1567
|
+
var mcp = {
|
|
1568
|
+
globalConfigPath(homeDir = homedir4()) {
|
|
1569
|
+
return join8(homeDir, ".config", "mcp", "mcp.json");
|
|
1570
|
+
},
|
|
1571
|
+
async serversEnsure(servers, options = {}) {
|
|
1572
|
+
const path = options.path ?? mcp.globalConfigPath();
|
|
1573
|
+
const currentText = await getOptionalFile(path);
|
|
1574
|
+
const current = getParsedConfig(currentText, path);
|
|
1575
|
+
const nextServers = { ...current.mcpServers };
|
|
1576
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
1577
|
+
nextServers[name] = mergeEntry(nextServers[name], entry);
|
|
1578
|
+
}
|
|
1579
|
+
const next = { ...current, mcpServers: nextServers };
|
|
1580
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
1581
|
+
if (changed && !options.dryRun) {
|
|
1582
|
+
await mkdir4(dirname3(path), { recursive: true });
|
|
1583
|
+
await writeFile3(path, `${JSON.stringify(next, null, 2)}
|
|
1584
|
+
`, "utf8");
|
|
1585
|
+
}
|
|
1586
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
function mergeEntry(current, required) {
|
|
1590
|
+
const merged = { ...current, ...required };
|
|
1591
|
+
if (current?.env || required.env)
|
|
1592
|
+
merged.env = { ...current?.env, ...required.env };
|
|
1593
|
+
return merged;
|
|
1594
|
+
}
|
|
1595
|
+
function getParsedConfig(content, path) {
|
|
1596
|
+
if (!content?.trim())
|
|
1597
|
+
return { mcpServers: {} };
|
|
1598
|
+
try {
|
|
1599
|
+
const value = JSON.parse(content);
|
|
1600
|
+
if (!isRecord(value))
|
|
1601
|
+
throw new Error("not an object");
|
|
1602
|
+
const servers = value.mcpServers;
|
|
1603
|
+
if (servers !== undefined && !isRecord(servers))
|
|
1604
|
+
throw new Error("mcpServers is not an object");
|
|
1605
|
+
return { ...value, mcpServers: servers ?? {} };
|
|
1606
|
+
} catch {
|
|
1607
|
+
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
async function getOptionalFile(path) {
|
|
1611
|
+
try {
|
|
1612
|
+
return await readFile4(path, "utf8");
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
1615
|
+
return;
|
|
1616
|
+
throw error;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
function isRecord(value) {
|
|
1620
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
158
1623
|
// src/mise.ts
|
|
1624
|
+
import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
|
|
1625
|
+
import { homedir as homedir5 } from "node:os";
|
|
1626
|
+
import { basename as basename3, dirname as dirname4, join as join9 } from "node:path";
|
|
159
1627
|
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
160
1628
|
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
161
1629
|
var mise = {
|
|
@@ -163,11 +1631,11 @@ var mise = {
|
|
|
163
1631
|
return findExecutable(name);
|
|
164
1632
|
},
|
|
165
1633
|
async install(options = {}) {
|
|
166
|
-
const homeDir = options.homeDir ??
|
|
1634
|
+
const homeDir = options.homeDir ?? homedir5();
|
|
167
1635
|
const platform = options.platform ?? process.platform;
|
|
168
1636
|
if (platform === "win32")
|
|
169
1637
|
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
170
|
-
const installedPath =
|
|
1638
|
+
const installedPath = join9(homeDir, ".local", "bin", "mise");
|
|
171
1639
|
if (options.dryRun)
|
|
172
1640
|
return installedPath;
|
|
173
1641
|
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
@@ -177,8 +1645,8 @@ var mise = {
|
|
|
177
1645
|
return executable;
|
|
178
1646
|
},
|
|
179
1647
|
async hookEnsure(executable, options = {}) {
|
|
180
|
-
const homeDir = options.homeDir ??
|
|
181
|
-
const hook = getShellHook(
|
|
1648
|
+
const homeDir = options.homeDir ?? homedir5();
|
|
1649
|
+
const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
182
1650
|
const current = await getOptionalFile2(hook.path);
|
|
183
1651
|
if (current.includes(MISE_HOOK_START))
|
|
184
1652
|
return { path: hook.path, changed: false, planned: false };
|
|
@@ -187,8 +1655,8 @@ var mise = {
|
|
|
187
1655
|
const separator = current.length === 0 || current.endsWith(`
|
|
188
1656
|
`) ? "" : `
|
|
189
1657
|
`;
|
|
190
|
-
await
|
|
191
|
-
await
|
|
1658
|
+
await mkdir5(dirname4(hook.path), { recursive: true });
|
|
1659
|
+
await writeFile4(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
192
1660
|
return { path: hook.path, changed: true, planned: false };
|
|
193
1661
|
},
|
|
194
1662
|
async toolCheckGlobal(executable, tool, minimumVersion) {
|
|
@@ -205,7 +1673,7 @@ var mise = {
|
|
|
205
1673
|
async toolInstallLocal(executable, specification, cwd = process.cwd()) {
|
|
206
1674
|
await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
|
|
207
1675
|
},
|
|
208
|
-
async toolUpdateAllGlobal(executable, homeDir =
|
|
1676
|
+
async toolUpdateAllGlobal(executable, homeDir = homedir5()) {
|
|
209
1677
|
await runChecked(executable, ["upgrade"], { cwd: homeDir });
|
|
210
1678
|
}
|
|
211
1679
|
};
|
|
@@ -214,7 +1682,7 @@ function getShellHook(shell, executable, homeDir) {
|
|
|
214
1682
|
switch (shell.toLowerCase()) {
|
|
215
1683
|
case "zsh":
|
|
216
1684
|
return {
|
|
217
|
-
path:
|
|
1685
|
+
path: join9(homeDir, ".zshrc"),
|
|
218
1686
|
content: `${MISE_HOOK_START}
|
|
219
1687
|
eval "$(${command} activate zsh)"
|
|
220
1688
|
${MISE_HOOK_END}
|
|
@@ -222,7 +1690,7 @@ ${MISE_HOOK_END}
|
|
|
222
1690
|
};
|
|
223
1691
|
case "fish":
|
|
224
1692
|
return {
|
|
225
|
-
path:
|
|
1693
|
+
path: join9(homeDir, ".config", "fish", "config.fish"),
|
|
226
1694
|
content: `${MISE_HOOK_START}
|
|
227
1695
|
${command} activate fish | source
|
|
228
1696
|
${MISE_HOOK_END}
|
|
@@ -231,7 +1699,7 @@ ${MISE_HOOK_END}
|
|
|
231
1699
|
case "nu":
|
|
232
1700
|
case "nushell":
|
|
233
1701
|
return {
|
|
234
|
-
path:
|
|
1702
|
+
path: join9(homeDir, ".config", "nushell", "config.nu"),
|
|
235
1703
|
content: `${MISE_HOOK_START}
|
|
236
1704
|
let mise_bin = ${command}
|
|
237
1705
|
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
@@ -242,7 +1710,7 @@ ${MISE_HOOK_END}
|
|
|
242
1710
|
};
|
|
243
1711
|
case "xonsh":
|
|
244
1712
|
return {
|
|
245
|
-
path:
|
|
1713
|
+
path: join9(homeDir, ".xonshrc"),
|
|
246
1714
|
content: `${MISE_HOOK_START}
|
|
247
1715
|
execx($(${command} activate xonsh))
|
|
248
1716
|
${MISE_HOOK_END}
|
|
@@ -250,7 +1718,7 @@ ${MISE_HOOK_END}
|
|
|
250
1718
|
};
|
|
251
1719
|
case "elvish":
|
|
252
1720
|
return {
|
|
253
|
-
path:
|
|
1721
|
+
path: join9(homeDir, ".config", "elvish", "rc.elv"),
|
|
254
1722
|
content: `${MISE_HOOK_START}
|
|
255
1723
|
var mise: = (ns [&])
|
|
256
1724
|
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
@@ -261,7 +1729,7 @@ ${MISE_HOOK_END}
|
|
|
261
1729
|
case "pwsh":
|
|
262
1730
|
case "powershell":
|
|
263
1731
|
return {
|
|
264
|
-
path:
|
|
1732
|
+
path: join9(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
265
1733
|
content: `${MISE_HOOK_START}
|
|
266
1734
|
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
267
1735
|
${MISE_HOOK_END}
|
|
@@ -270,7 +1738,7 @@ ${MISE_HOOK_END}
|
|
|
270
1738
|
case "bash":
|
|
271
1739
|
default:
|
|
272
1740
|
return {
|
|
273
|
-
path:
|
|
1741
|
+
path: join9(homeDir, ".bashrc"),
|
|
274
1742
|
content: `${MISE_HOOK_START}
|
|
275
1743
|
eval "$(${command} activate bash)"
|
|
276
1744
|
${MISE_HOOK_END}
|
|
@@ -309,7 +1777,7 @@ function isVersionAtLeast(version, minimumVersion) {
|
|
|
309
1777
|
}
|
|
310
1778
|
async function getOptionalFile2(path) {
|
|
311
1779
|
try {
|
|
312
|
-
return await
|
|
1780
|
+
return await readFile5(path, "utf8");
|
|
313
1781
|
} catch (error) {
|
|
314
1782
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
315
1783
|
return "";
|
|
@@ -321,79 +1789,86 @@ function getShellQuoted(value) {
|
|
|
321
1789
|
}
|
|
322
1790
|
|
|
323
1791
|
// src/pi.ts
|
|
324
|
-
import { mkdir as
|
|
325
|
-
import { homedir as
|
|
326
|
-
import { dirname as
|
|
1792
|
+
import { mkdir as mkdir6, writeFile as writeFile5 } from "node:fs/promises";
|
|
1793
|
+
import { homedir as homedir6 } from "node:os";
|
|
1794
|
+
import { dirname as dirname5, join as join10 } from "node:path";
|
|
327
1795
|
var pi = {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
1796
|
+
executableCheck: findPiExecutable,
|
|
1797
|
+
packageList: listPiPackages,
|
|
1798
|
+
packageCheck: hasPiPackage,
|
|
1799
|
+
packageInstall: installPiPackage,
|
|
1800
|
+
agentDir: resolvePiAgentDir,
|
|
1801
|
+
agentEnsure: ensurePiAgent,
|
|
1802
|
+
skillCheckGlobal: checkGlobalPiSkill,
|
|
1803
|
+
skillInstallGlobal: installGlobalPiSkills,
|
|
1804
|
+
configEnsure: ensurePiConfig
|
|
1805
|
+
};
|
|
1806
|
+
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
1807
|
+
const path = join10(agentDir, "agents", filename);
|
|
1808
|
+
const currentText = await readTextIfExists(path);
|
|
1809
|
+
const changed = currentText !== content;
|
|
1810
|
+
if (changed && !dryRun) {
|
|
1811
|
+
await mkdir6(dirname5(path), { recursive: true });
|
|
1812
|
+
await writeFile5(path, content, "utf8");
|
|
1813
|
+
}
|
|
1814
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
1815
|
+
}
|
|
1816
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join10(homedir6(), ".agents", "skills")) {
|
|
1817
|
+
const roots = [join10(agentDir, "skills"), sharedSkillsDir];
|
|
1818
|
+
for (const root of roots) {
|
|
1819
|
+
if (await readTextIfExists(join10(root, name, "SKILL.md")) !== undefined)
|
|
336
1820
|
return true;
|
|
337
|
-
return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
|
|
338
|
-
},
|
|
339
|
-
async packageInstall(executable, source) {
|
|
340
|
-
await runChecked(executable, ["install", source]);
|
|
341
|
-
},
|
|
342
|
-
agentDir(homeDir = homedir3()) {
|
|
343
|
-
return getAgentDir(homeDir);
|
|
344
|
-
},
|
|
345
|
-
async skillCheckGlobal(name, agentDir = getAgentDir(), sharedSkillsDir = join4(homedir3(), ".agents", "skills")) {
|
|
346
|
-
const roots = [join4(agentDir, "skills"), sharedSkillsDir];
|
|
347
|
-
for (const root of roots) {
|
|
348
|
-
if (await getOptionalFile3(join4(root, name, "SKILL.md")) !== undefined)
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
|
-
return false;
|
|
352
|
-
},
|
|
353
|
-
async skillInstallGlobal(miseExecutable, source, names) {
|
|
354
|
-
const selection = names.flatMap((name) => ["--skill", name]);
|
|
355
|
-
await runChecked(miseExecutable, [
|
|
356
|
-
"x",
|
|
357
|
-
"node@22",
|
|
358
|
-
"--",
|
|
359
|
-
"npx",
|
|
360
|
-
"-y",
|
|
361
|
-
"skills",
|
|
362
|
-
"add",
|
|
363
|
-
source,
|
|
364
|
-
...selection,
|
|
365
|
-
"--global",
|
|
366
|
-
"--agent",
|
|
367
|
-
"pi",
|
|
368
|
-
"--yes"
|
|
369
|
-
]);
|
|
370
|
-
},
|
|
371
|
-
async configEnsure(path, update, dryRun = false) {
|
|
372
|
-
const currentText = await getOptionalFile3(path);
|
|
373
|
-
const current = getParsedObject(currentText, path);
|
|
374
|
-
const next = update(current);
|
|
375
|
-
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
376
|
-
if (changed && !dryRun) {
|
|
377
|
-
await mkdir3(dirname3(path), { recursive: true });
|
|
378
|
-
await writeFile3(path, `${JSON.stringify(next, null, 2)}
|
|
379
|
-
`, "utf8");
|
|
380
|
-
}
|
|
381
|
-
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
382
1821
|
}
|
|
383
|
-
|
|
384
|
-
function getAgentDir(homeDir = homedir3()) {
|
|
385
|
-
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join4(process.env.XDG_CONFIG_HOME, "pi") : join4(homeDir, ".pi", "agent"));
|
|
1822
|
+
return false;
|
|
386
1823
|
}
|
|
387
|
-
async function
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1824
|
+
async function installGlobalPiSkills(miseExecutable, source, names) {
|
|
1825
|
+
const selection = names.flatMap((name) => ["--skill", name]);
|
|
1826
|
+
await runChecked(miseExecutable, [
|
|
1827
|
+
"x",
|
|
1828
|
+
"node@22",
|
|
1829
|
+
"--",
|
|
1830
|
+
"npx",
|
|
1831
|
+
"-y",
|
|
1832
|
+
"skills",
|
|
1833
|
+
"add",
|
|
1834
|
+
source,
|
|
1835
|
+
...selection,
|
|
1836
|
+
"--global",
|
|
1837
|
+
"--agent",
|
|
1838
|
+
"pi",
|
|
1839
|
+
"--yes"
|
|
1840
|
+
]);
|
|
1841
|
+
}
|
|
1842
|
+
async function ensurePiConfig(path, update, dryRun = false) {
|
|
1843
|
+
const currentText = await readTextIfExists(path);
|
|
1844
|
+
const current = parseJsonObject(currentText, path);
|
|
1845
|
+
const next = update(current);
|
|
1846
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
1847
|
+
if (changed && !dryRun) {
|
|
1848
|
+
await mkdir6(dirname5(path), { recursive: true });
|
|
1849
|
+
await writeFile5(path, `${JSON.stringify(next, null, 2)}
|
|
1850
|
+
`, "utf8");
|
|
394
1851
|
}
|
|
1852
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
1853
|
+
}
|
|
1854
|
+
async function findPiExecutable() {
|
|
1855
|
+
return findExecutable("pi");
|
|
1856
|
+
}
|
|
1857
|
+
async function listPiPackages(executable) {
|
|
1858
|
+
return (await runChecked(executable, ["list"])).stdout;
|
|
395
1859
|
}
|
|
396
|
-
function
|
|
1860
|
+
function hasPiPackage(listOutput, source) {
|
|
1861
|
+
if (listOutput.includes(source))
|
|
1862
|
+
return true;
|
|
1863
|
+
return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
|
|
1864
|
+
}
|
|
1865
|
+
async function installPiPackage(executable, source) {
|
|
1866
|
+
await runChecked(executable, ["install", source]);
|
|
1867
|
+
}
|
|
1868
|
+
function resolvePiAgentDir(homeDir = homedir6()) {
|
|
1869
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join10(process.env.XDG_CONFIG_HOME, "pi") : join10(homeDir, ".pi", "agent"));
|
|
1870
|
+
}
|
|
1871
|
+
function parseJsonObject(content, path) {
|
|
397
1872
|
if (!content?.trim())
|
|
398
1873
|
return {};
|
|
399
1874
|
try {
|
|
@@ -435,9 +1910,14 @@ var PI_SKILL_SOURCES = [
|
|
|
435
1910
|
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
|
|
436
1911
|
];
|
|
437
1912
|
var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
|
|
1913
|
+
var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
|
|
1914
|
+
var FORGE_DEPENDENCIES = {
|
|
1915
|
+
github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
|
|
1916
|
+
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
1917
|
+
};
|
|
438
1918
|
async function ensureMise(options = {}) {
|
|
439
|
-
const homeDir = options.homeDir ??
|
|
440
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
1919
|
+
const homeDir = options.homeDir ?? homedir7();
|
|
1920
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join11(homeDir, ".local", "bin", "mise"));
|
|
441
1921
|
if (current)
|
|
442
1922
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
443
1923
|
reportProgress(options, "Installing mise");
|
|
@@ -466,7 +1946,10 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
|
|
|
466
1946
|
async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
467
1947
|
const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
|
|
468
1948
|
const actions = [];
|
|
469
|
-
|
|
1949
|
+
const dependencies = [...MISE_DEPENDENCIES];
|
|
1950
|
+
if (options.forge && options.forge !== "none")
|
|
1951
|
+
dependencies.push(FORGE_DEPENDENCIES[options.forge]);
|
|
1952
|
+
for (const dependency of dependencies) {
|
|
470
1953
|
const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
|
|
471
1954
|
if (installed) {
|
|
472
1955
|
actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
|
|
@@ -482,18 +1965,33 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
482
1965
|
async function ensurePiPlugins(options = {}) {
|
|
483
1966
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
484
1967
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
485
|
-
const webSearch = await pi.configEnsure(
|
|
1968
|
+
const webSearch = await pi.configEnsure(join11(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
486
1969
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
487
|
-
const lsp = await pi.configEnsure(
|
|
1970
|
+
const lsp = await pi.configEnsure(join11(agentDir, "pi-lsp.json"), (config) => ({
|
|
488
1971
|
...config,
|
|
489
1972
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
490
1973
|
}), options.dryRun);
|
|
491
1974
|
actions.push(getConfigSetupAction("pi-lsp settings", lsp));
|
|
492
1975
|
return actions;
|
|
493
1976
|
}
|
|
1977
|
+
async function ensurePiAgents(options = {}) {
|
|
1978
|
+
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
1979
|
+
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR;
|
|
1980
|
+
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
1981
|
+
const entries = (await readdir2(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
|
|
1982
|
+
const actions = [];
|
|
1983
|
+
for (const entry of entries) {
|
|
1984
|
+
const id = basename4(entry.name, ".md").replace(/^diffpi-/, "");
|
|
1985
|
+
const source = await readFile6(join11(bundledAgentsDir, entry.name), "utf8");
|
|
1986
|
+
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
1987
|
+
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
1988
|
+
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
1989
|
+
}
|
|
1990
|
+
return actions;
|
|
1991
|
+
}
|
|
494
1992
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
495
1993
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
496
|
-
const sharedSkillsDir =
|
|
1994
|
+
const sharedSkillsDir = join11(options.homeDir ?? homedir7(), ".agents", "skills");
|
|
497
1995
|
const actions = [];
|
|
498
1996
|
for (const source of PI_SKILL_SOURCES) {
|
|
499
1997
|
const missing = [];
|
|
@@ -537,6 +2035,12 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
|
|
|
537
2035
|
} else if (options.issueTracker === "jira") {
|
|
538
2036
|
servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
|
|
539
2037
|
}
|
|
2038
|
+
if (options.forge === "github") {
|
|
2039
|
+
servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
|
|
2040
|
+
} else if (options.forge === "gitlab") {
|
|
2041
|
+
const host = (await detectVcs(projectDir)).host || "gitlab.com";
|
|
2042
|
+
servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
|
|
2043
|
+
}
|
|
540
2044
|
const result = await mcp.serversEnsure(servers, {
|
|
541
2045
|
dryRun: options.dryRun,
|
|
542
2046
|
path: mcp.globalConfigPath(options.homeDir)
|
|
@@ -550,13 +2054,81 @@ async function setupPi(options = {}) {
|
|
|
550
2054
|
actions.push(await ensureMiseHooks(miseResult.executable, options));
|
|
551
2055
|
actions.push(...await ensureMiseDeps(miseResult.executable, options));
|
|
552
2056
|
actions.push(...await ensurePiPlugins(options));
|
|
2057
|
+
actions.push(...await ensurePiAgents(options));
|
|
553
2058
|
actions.push(...await ensurePiSkills(miseResult.executable, options));
|
|
554
2059
|
actions.push(...await ensureMcpAdapters(miseResult.executable, options));
|
|
2060
|
+
if (options.bindZedKey)
|
|
2061
|
+
actions.push(...await ensureZedIntegration(options));
|
|
555
2062
|
return {
|
|
556
2063
|
actions,
|
|
557
|
-
restartPi: actions
|
|
2064
|
+
restartPi: setupRequiresRestart(actions)
|
|
558
2065
|
};
|
|
559
2066
|
}
|
|
2067
|
+
function setupRequiresRestart(actions) {
|
|
2068
|
+
return actions.some((item) => (item.status === "installed" || item.status === "updated") && (item.name.startsWith("pi package ") || item.name.startsWith("pi agent ") || item.name.startsWith("pi skill ") || item.name === "MCP configuration" || item.name === "web search settings" || item.name === "pi-lsp settings"));
|
|
2069
|
+
}
|
|
2070
|
+
function materializeAgentModels(content, agentId, config, availableModels) {
|
|
2071
|
+
const { frontmatter } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
2072
|
+
const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
|
|
2073
|
+
const preferences = resolveAgentModelPreferences(agentId, profilePreferences, config);
|
|
2074
|
+
let selectedIndex = availableModels === undefined && preferences.length > 0 ? 0 : -1;
|
|
2075
|
+
let selectedModel = selectedIndex === 0 ? preferences[0] : undefined;
|
|
2076
|
+
if (availableModels) {
|
|
2077
|
+
for (const [index, preference] of preferences.entries()) {
|
|
2078
|
+
const match = findPreferredModel(availableModels, preference);
|
|
2079
|
+
if (!match)
|
|
2080
|
+
continue;
|
|
2081
|
+
selectedIndex = index;
|
|
2082
|
+
selectedModel = `${match.provider}/${match.id}`;
|
|
2083
|
+
break;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
const fallbacks = preferences.filter((_preference, index) => index !== selectedIndex);
|
|
2087
|
+
return replaceAgentModelFields(content, selectedModel, fallbacks);
|
|
2088
|
+
}
|
|
2089
|
+
function replaceAgentModelFields(content, model, fallbacks) {
|
|
2090
|
+
const newline = content.includes(`\r
|
|
2091
|
+
`) ? `\r
|
|
2092
|
+
` : `
|
|
2093
|
+
`;
|
|
2094
|
+
const lines = content.replaceAll(`\r
|
|
2095
|
+
`, `
|
|
2096
|
+
`).split(`
|
|
2097
|
+
`);
|
|
2098
|
+
const closingDelimiter = lines.indexOf("---", 1);
|
|
2099
|
+
if (lines[0] !== "---" || closingDelimiter < 0)
|
|
2100
|
+
return content;
|
|
2101
|
+
const frontmatter = lines.slice(1, closingDelimiter).filter((line) => !/^model(?:_fallbacks)?:/.test(line));
|
|
2102
|
+
if (model)
|
|
2103
|
+
frontmatter.push(`model: ${model}`);
|
|
2104
|
+
if (fallbacks.length > 0)
|
|
2105
|
+
frontmatter.push(`model_fallbacks: ${fallbacks.join(", ")}`);
|
|
2106
|
+
return ["---", ...frontmatter, "---", ...lines.slice(closingDelimiter + 1)].join(newline);
|
|
2107
|
+
}
|
|
2108
|
+
async function ensureZedIntegration(options = {}) {
|
|
2109
|
+
if (options.dryRun) {
|
|
2110
|
+
const actions = [createSetupAction("Zed review task", "planned", "tasks.json")];
|
|
2111
|
+
if (options.bindZedKey)
|
|
2112
|
+
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
2113
|
+
return actions;
|
|
2114
|
+
}
|
|
2115
|
+
const actions = [];
|
|
2116
|
+
try {
|
|
2117
|
+
const task = await ensureZedReviewTask(options.homeDir);
|
|
2118
|
+
actions.push(createSetupAction("Zed review task", task.changed ? "installed" : "ready", task.path));
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
actions.push(createSetupAction("Zed review task", "skipped", error instanceof Error ? error.message : String(error)));
|
|
2121
|
+
}
|
|
2122
|
+
if (options.bindZedKey) {
|
|
2123
|
+
try {
|
|
2124
|
+
const key = await ensureZedReviewKeybinding(options.homeDir);
|
|
2125
|
+
actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
|
|
2126
|
+
} catch (error) {
|
|
2127
|
+
actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
return actions;
|
|
2131
|
+
}
|
|
560
2132
|
async function ensurePiPackages(packages, options) {
|
|
561
2133
|
const executable = await pi.executableCheck();
|
|
562
2134
|
if (!executable && !options.dryRun)
|
|
@@ -578,6 +2150,10 @@ ${source}`;
|
|
|
578
2150
|
}
|
|
579
2151
|
return actions;
|
|
580
2152
|
}
|
|
2153
|
+
function getTextList(value) {
|
|
2154
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
2155
|
+
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
2156
|
+
}
|
|
581
2157
|
function getConfigSetupAction(name, result) {
|
|
582
2158
|
if (!result.changed)
|
|
583
2159
|
return createSetupAction(name, "ready", result.path);
|
|
@@ -596,11 +2172,13 @@ function getRecord(value) {
|
|
|
596
2172
|
}
|
|
597
2173
|
|
|
598
2174
|
// src/tools/setup.ts
|
|
599
|
-
var setupParametersSchema =
|
|
600
|
-
issueTracker:
|
|
2175
|
+
var setupParametersSchema = z6.object({
|
|
2176
|
+
issueTracker: z6.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
|
|
2177
|
+
forge: z6.enum(["none", "github", "gitlab"]).default("none").describe("Forge to configure for /review. Installs gh or glab and registers its MCP server."),
|
|
2178
|
+
bindZedKey: z6.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
|
|
601
2179
|
});
|
|
602
|
-
var setupParameters =
|
|
603
|
-
var diffpiSetupTool =
|
|
2180
|
+
var setupParameters = z6.toJSONSchema(setupParametersSchema, { io: "input" });
|
|
2181
|
+
var diffpiSetupTool = defineTool4({
|
|
604
2182
|
name: "diffpi_setup",
|
|
605
2183
|
label: "diffpi setup",
|
|
606
2184
|
description: "Install or repair the @difflab/pi environment. This mutates user-level tool installations and configuration files.",
|
|
@@ -613,11 +2191,14 @@ var diffpiSetupTool = defineTool2({
|
|
|
613
2191
|
],
|
|
614
2192
|
parameters: setupParameters,
|
|
615
2193
|
executionMode: "sequential",
|
|
616
|
-
async execute(_toolCallId, input, _signal, onUpdate) {
|
|
2194
|
+
async execute(_toolCallId, input, _signal, onUpdate, ctx) {
|
|
617
2195
|
const params = setupParametersSchema.parse(input);
|
|
618
2196
|
const result = await setupPi({
|
|
619
2197
|
issueTracker: params.issueTracker,
|
|
2198
|
+
forge: params.forge,
|
|
2199
|
+
bindZedKey: params.bindZedKey,
|
|
620
2200
|
installMiseHook: true,
|
|
2201
|
+
availableModels: ctx.modelRegistry.getAvailable(),
|
|
621
2202
|
onProgress(message) {
|
|
622
2203
|
onUpdate?.({ content: [{ type: "text", text: message }], details: {} });
|
|
623
2204
|
}
|
|
@@ -625,7 +2206,7 @@ var diffpiSetupTool = defineTool2({
|
|
|
625
2206
|
return formatResult(result, "Setup complete.");
|
|
626
2207
|
}
|
|
627
2208
|
});
|
|
628
|
-
var diffpiValidateTool =
|
|
2209
|
+
var diffpiValidateTool = defineTool4({
|
|
629
2210
|
name: "diffpi_validate",
|
|
630
2211
|
label: "diffpi validate",
|
|
631
2212
|
description: "Inspect the @difflab/pi environment without installing software or changing configuration files.",
|
|
@@ -637,12 +2218,15 @@ var diffpiValidateTool = defineTool2({
|
|
|
637
2218
|
],
|
|
638
2219
|
parameters: setupParameters,
|
|
639
2220
|
executionMode: "sequential",
|
|
640
|
-
async execute(_toolCallId, input) {
|
|
2221
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
641
2222
|
const params = setupParametersSchema.parse(input);
|
|
642
2223
|
const result = await setupPi({
|
|
643
2224
|
issueTracker: params.issueTracker,
|
|
2225
|
+
forge: params.forge,
|
|
2226
|
+
bindZedKey: params.bindZedKey,
|
|
644
2227
|
installMiseHook: true,
|
|
645
|
-
dryRun: true
|
|
2228
|
+
dryRun: true,
|
|
2229
|
+
availableModels: ctx.modelRegistry.getAvailable()
|
|
646
2230
|
});
|
|
647
2231
|
const incomplete = result.actions.some((item) => item.status === "planned");
|
|
648
2232
|
return formatResult(result, incomplete ? "Setup is incomplete." : "Setup is ready.");
|
|
@@ -663,12 +2247,20 @@ ${lines.join(`
|
|
|
663
2247
|
}
|
|
664
2248
|
|
|
665
2249
|
// src/tools/index.ts
|
|
666
|
-
function createPiTools(pi) {
|
|
667
|
-
return [
|
|
2250
|
+
function createPiTools(pi, modes) {
|
|
2251
|
+
return [
|
|
2252
|
+
diffpiSetupTool,
|
|
2253
|
+
diffpiValidateTool,
|
|
2254
|
+
createDiffpiReloadTool(pi),
|
|
2255
|
+
...createModeTools(modes),
|
|
2256
|
+
...createReviewTools()
|
|
2257
|
+
];
|
|
668
2258
|
}
|
|
669
2259
|
export {
|
|
670
2260
|
createDiffpiReloadTool,
|
|
2261
|
+
createModeTools,
|
|
671
2262
|
createPiTools,
|
|
2263
|
+
createReviewTools,
|
|
672
2264
|
diffpiSetupTool,
|
|
673
2265
|
diffpiValidateTool
|
|
674
2266
|
};
|