@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/index.js
CHANGED
|
@@ -1,66 +1,101 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import {
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
async serversEnsure(servers, options = {}) {
|
|
10
|
-
const path = options.path ?? mcp.globalConfigPath();
|
|
11
|
-
const currentText = await getOptionalFile(path);
|
|
12
|
-
const current = getParsedConfig(currentText, path);
|
|
13
|
-
const nextServers = { ...current.mcpServers };
|
|
14
|
-
for (const [name, entry] of Object.entries(servers)) {
|
|
15
|
-
nextServers[name] = mergeEntry(nextServers[name], entry);
|
|
16
|
-
}
|
|
17
|
-
const next = { ...current, mcpServers: nextServers };
|
|
18
|
-
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
19
|
-
if (changed && !options.dryRun) {
|
|
20
|
-
await mkdir(dirname(path), { recursive: true });
|
|
21
|
-
await writeFile(path, `${JSON.stringify(next, null, 2)}
|
|
22
|
-
`, "utf8");
|
|
23
|
-
}
|
|
24
|
-
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
25
|
-
}
|
|
26
|
-
};
|
|
27
|
-
function mergeEntry(current, required) {
|
|
28
|
-
const merged = { ...current, ...required };
|
|
29
|
-
if (current?.env || required.env)
|
|
30
|
-
merged.env = { ...current?.env, ...required.env };
|
|
31
|
-
return merged;
|
|
32
|
-
}
|
|
33
|
-
function getParsedConfig(content, path) {
|
|
34
|
-
if (!content?.trim())
|
|
35
|
-
return { mcpServers: {} };
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/fsx.ts
|
|
8
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
9
|
+
async function readDirectoryIfExists(path) {
|
|
36
10
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
throw new Error("mcpServers is not an object");
|
|
43
|
-
return { ...value, mcpServers: servers ?? {} };
|
|
44
|
-
} catch {
|
|
45
|
-
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
11
|
+
return await readdir(path, { withFileTypes: true });
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (isMissingPath(error))
|
|
14
|
+
return [];
|
|
15
|
+
throw error;
|
|
46
16
|
}
|
|
47
17
|
}
|
|
48
|
-
async function
|
|
18
|
+
async function readTextIfExists(path) {
|
|
49
19
|
try {
|
|
50
20
|
return await readFile(path, "utf8");
|
|
51
21
|
} catch (error) {
|
|
52
|
-
if (error
|
|
22
|
+
if (isMissingPath(error))
|
|
53
23
|
return;
|
|
54
24
|
throw error;
|
|
55
25
|
}
|
|
56
26
|
}
|
|
57
|
-
function
|
|
58
|
-
return
|
|
27
|
+
function isMissingPath(error) {
|
|
28
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
59
29
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
30
|
+
|
|
31
|
+
// src/config.ts
|
|
32
|
+
var modelReferenceSchema = z.string().trim().min(1);
|
|
33
|
+
var agentConfigSchema = z.object({
|
|
34
|
+
models: z.array(modelReferenceSchema).optional()
|
|
35
|
+
}).strict();
|
|
36
|
+
var diffpiConfigSchema = z.object({
|
|
37
|
+
agents: z.record(z.string(), agentConfigSchema).optional()
|
|
38
|
+
}).strict();
|
|
39
|
+
function diffpiConfigPaths(homeDir = homedir()) {
|
|
40
|
+
const directory = join(homeDir, ".difflab", "diffpi");
|
|
41
|
+
return {
|
|
42
|
+
yaml: join(directory, "config.yaml"),
|
|
43
|
+
json: join(directory, "config.json")
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function loadDiffpiConfig(options = {}) {
|
|
47
|
+
const paths = diffpiConfigPaths(options.homeDir);
|
|
48
|
+
for (const [format, path] of [
|
|
49
|
+
["yaml", paths.yaml],
|
|
50
|
+
["json", paths.json]
|
|
51
|
+
]) {
|
|
52
|
+
const content = await readTextIfExists(path);
|
|
53
|
+
if (content === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
const value = format === "yaml" ? parseYamlConfig(content) : JSON.parse(content);
|
|
57
|
+
return { config: diffpiConfigSchema.parse(value ?? {}), path };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
60
|
+
throw new Error(`Invalid Diffpi config at ${path}: ${reason}`, { cause: error });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { config: {} };
|
|
64
|
+
}
|
|
65
|
+
function resolveAgentModelPreferences(agentId, profilePreferences, config) {
|
|
66
|
+
const override = config.agents?.[agentId];
|
|
67
|
+
if (override && Object.hasOwn(override, "models"))
|
|
68
|
+
return [...override.models ?? []];
|
|
69
|
+
return [...profilePreferences];
|
|
70
|
+
}
|
|
71
|
+
function findPreferredModel(models, preference) {
|
|
72
|
+
const normalizedPreference = normalizeModelReference(preference);
|
|
73
|
+
const exactReference = models.find((model) => normalizeModelReference(`${model.provider}/${model.id}`) === normalizedPreference);
|
|
74
|
+
if (exactReference)
|
|
75
|
+
return exactReference;
|
|
76
|
+
const idPreference = preference.includes("/") ? preference.slice(preference.indexOf("/") + 1) : preference;
|
|
77
|
+
const normalizedIdPreference = normalizeModelReference(idPreference);
|
|
78
|
+
const exactId = models.find((model) => normalizeModelReference(model.id) === normalizedIdPreference);
|
|
79
|
+
if (exactId)
|
|
80
|
+
return exactId;
|
|
81
|
+
const preferenceTokens = normalizedIdPreference.split("-").filter(Boolean);
|
|
82
|
+
return models.find((model) => {
|
|
83
|
+
const modelTokens = new Set(normalizeModelReference(model.id).split("-").filter(Boolean));
|
|
84
|
+
return preferenceTokens.every((token) => modelTokens.has(token));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function parseYamlConfig(content) {
|
|
88
|
+
const document = content.replace(/^\uFEFF/, "").replace(/^---[^\S\r\n]*(?:#.*)?(?:\r?\n|$)/, "");
|
|
89
|
+
return parseFrontmatter(`---
|
|
90
|
+
${document}
|
|
91
|
+
---
|
|
92
|
+
`).frontmatter;
|
|
93
|
+
}
|
|
94
|
+
function normalizeModelReference(value) {
|
|
95
|
+
return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
96
|
+
}
|
|
97
|
+
// src/environment.ts
|
|
98
|
+
import { basename } from "node:path";
|
|
64
99
|
|
|
65
100
|
// src/process.ts
|
|
66
101
|
import { constants } from "node:fs";
|
|
@@ -93,18 +128,35 @@ function run(command, args, options = {}) {
|
|
|
93
128
|
const child = spawn(command, args, {
|
|
94
129
|
cwd: options.cwd,
|
|
95
130
|
env: options.env ?? process.env,
|
|
96
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
131
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
97
132
|
});
|
|
98
133
|
let stdout = "";
|
|
99
134
|
let stderr = "";
|
|
100
|
-
|
|
101
|
-
|
|
135
|
+
const stdoutChunks = [];
|
|
136
|
+
const stderrChunks = [];
|
|
137
|
+
const unbounded = options.capture === "unbounded";
|
|
138
|
+
child.stdout?.on("data", (chunk) => {
|
|
139
|
+
const text = chunk.toString();
|
|
140
|
+
if (unbounded)
|
|
141
|
+
stdoutChunks.push(text);
|
|
142
|
+
else
|
|
143
|
+
stdout = appendBounded(stdout, text);
|
|
102
144
|
});
|
|
103
|
-
child.stderr
|
|
104
|
-
|
|
145
|
+
child.stderr?.on("data", (chunk) => {
|
|
146
|
+
const text = chunk.toString();
|
|
147
|
+
if (unbounded)
|
|
148
|
+
stderrChunks.push(text);
|
|
149
|
+
else
|
|
150
|
+
stderr = appendBounded(stderr, text);
|
|
105
151
|
});
|
|
106
152
|
child.on("error", reject);
|
|
107
|
-
child.on("close", (code) => resolve({
|
|
153
|
+
child.on("close", (code) => resolve({
|
|
154
|
+
code: code ?? 1,
|
|
155
|
+
stdout: unbounded ? stdoutChunks.join("") : stdout,
|
|
156
|
+
stderr: unbounded ? stderrChunks.join("") : stderr
|
|
157
|
+
}));
|
|
158
|
+
if (options.input !== undefined && child.stdin)
|
|
159
|
+
child.stdin.end(options.input);
|
|
108
160
|
});
|
|
109
161
|
}
|
|
110
162
|
async function runChecked(command, args, options = {}) {
|
|
@@ -119,7 +171,629 @@ function appendBounded(current, next) {
|
|
|
119
171
|
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
120
172
|
}
|
|
121
173
|
|
|
174
|
+
// src/zed.ts
|
|
175
|
+
import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
|
|
176
|
+
import { homedir as homedir2 } from "node:os";
|
|
177
|
+
import { dirname, join as join3 } from "node:path";
|
|
178
|
+
var ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
|
|
179
|
+
var REVIEW_KEYBINDING = "cmd-alt-r";
|
|
180
|
+
var REVIEW_TASK = {
|
|
181
|
+
label: ZED_REVIEW_TASK_NAME,
|
|
182
|
+
command: "tuicr",
|
|
183
|
+
args: ["-w"],
|
|
184
|
+
cwd: "$ZED_WORKTREE_ROOT",
|
|
185
|
+
use_new_terminal: true,
|
|
186
|
+
reveal: "always"
|
|
187
|
+
};
|
|
188
|
+
function zedTasksPath(homeDir = homedir2()) {
|
|
189
|
+
return join3(homeDir, ".config", "zed", "tasks.json");
|
|
190
|
+
}
|
|
191
|
+
function zedKeymapPath(homeDir = homedir2()) {
|
|
192
|
+
return join3(homeDir, ".config", "zed", "keymap.json");
|
|
193
|
+
}
|
|
194
|
+
async function ensureZedReviewTask(homeDir = homedir2()) {
|
|
195
|
+
const path = zedTasksPath(homeDir);
|
|
196
|
+
const currentText = await readOptional(path);
|
|
197
|
+
const tasks = parseJsonArray(currentText, path);
|
|
198
|
+
const index = tasks.findIndex((task) => task.label === ZED_REVIEW_TASK_NAME);
|
|
199
|
+
const next = [...tasks];
|
|
200
|
+
if (index >= 0)
|
|
201
|
+
next[index] = { ...tasks[index], ...REVIEW_TASK };
|
|
202
|
+
else
|
|
203
|
+
next.push(REVIEW_TASK);
|
|
204
|
+
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
205
|
+
if (changed)
|
|
206
|
+
await writeJson(path, next);
|
|
207
|
+
return { path, changed, existed: currentText !== undefined };
|
|
208
|
+
}
|
|
209
|
+
async function ensureZedReviewKeybinding(homeDir = homedir2()) {
|
|
210
|
+
const path = zedKeymapPath(homeDir);
|
|
211
|
+
const currentText = await readOptional(path);
|
|
212
|
+
const entries = parseJsonArray(currentText, path);
|
|
213
|
+
const alreadyBound = entries.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
|
|
214
|
+
if (alreadyBound)
|
|
215
|
+
return { path, changed: false, existed: currentText !== undefined };
|
|
216
|
+
const next = [
|
|
217
|
+
...entries,
|
|
218
|
+
{ context: "Workspace", bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_REVIEW_TASK_NAME }] } }
|
|
219
|
+
];
|
|
220
|
+
await writeJson(path, next);
|
|
221
|
+
return { path, changed: true, existed: currentText !== undefined };
|
|
222
|
+
}
|
|
223
|
+
function bindsReviewTask(payload) {
|
|
224
|
+
return typeof payload === "object" && payload !== null && payload.task_name === ZED_REVIEW_TASK_NAME;
|
|
225
|
+
}
|
|
226
|
+
async function readOptional(path) {
|
|
227
|
+
try {
|
|
228
|
+
return await readFile2(path, "utf8");
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
231
|
+
return;
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function parseJsonArray(content, path) {
|
|
236
|
+
if (!content?.trim())
|
|
237
|
+
return [];
|
|
238
|
+
let value;
|
|
239
|
+
try {
|
|
240
|
+
value = JSON.parse(content);
|
|
241
|
+
} catch {
|
|
242
|
+
throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
|
|
243
|
+
}
|
|
244
|
+
if (!Array.isArray(value))
|
|
245
|
+
throw new Error(`Expected a JSON array in ${path}.`);
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
async function writeJson(path, value) {
|
|
249
|
+
await mkdir(dirname(path), { recursive: true });
|
|
250
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}
|
|
251
|
+
`, "utf8");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/environment.ts
|
|
255
|
+
function detectIde(env = process.env) {
|
|
256
|
+
const program = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
257
|
+
if (env.ZED_TERM === "true" || program === "zed")
|
|
258
|
+
return "zed";
|
|
259
|
+
if (env.CURSOR_TRACE_ID || program === "cursor")
|
|
260
|
+
return "cursor";
|
|
261
|
+
if (env.WINDSURF_ENV || program === "windsurf")
|
|
262
|
+
return "windsurf";
|
|
263
|
+
if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
|
|
264
|
+
return "jetbrains";
|
|
265
|
+
if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
|
|
266
|
+
return "vscode";
|
|
267
|
+
return "unknown";
|
|
268
|
+
}
|
|
269
|
+
function detectMux(env = process.env) {
|
|
270
|
+
if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
|
|
271
|
+
return "zellij";
|
|
272
|
+
if (env.TMUX)
|
|
273
|
+
return "tmux";
|
|
274
|
+
if (env.STY)
|
|
275
|
+
return "screen";
|
|
276
|
+
return "none";
|
|
277
|
+
}
|
|
278
|
+
function detectShell(env = process.env) {
|
|
279
|
+
return env.SHELL ? basename(env.SHELL) : "unknown";
|
|
280
|
+
}
|
|
281
|
+
async function detectVcs(cwd) {
|
|
282
|
+
const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
|
|
283
|
+
const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
|
284
|
+
const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
|
|
285
|
+
return { ...parseRemote(remote), branch, root };
|
|
286
|
+
}
|
|
287
|
+
function parseRemote(remote) {
|
|
288
|
+
const empty = { provider: "none", host: "", owner: "", repo: "" };
|
|
289
|
+
if (!remote)
|
|
290
|
+
return empty;
|
|
291
|
+
const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
|
|
292
|
+
const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
293
|
+
const match = scp ?? url;
|
|
294
|
+
if (!match)
|
|
295
|
+
return empty;
|
|
296
|
+
const host = match[1];
|
|
297
|
+
const segments = match[2].split("/").filter(Boolean);
|
|
298
|
+
if (segments.length < 2)
|
|
299
|
+
return { ...empty, host };
|
|
300
|
+
const repo = segments.at(-1) ?? "";
|
|
301
|
+
const owner = segments.slice(0, -1).join("/");
|
|
302
|
+
const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
|
|
303
|
+
return { provider, host, owner, repo };
|
|
304
|
+
}
|
|
305
|
+
async function openInNewTab(command, opts) {
|
|
306
|
+
const env = opts.env ?? process.env;
|
|
307
|
+
const name = opts.name ?? "review";
|
|
308
|
+
const printable = command.join(" ");
|
|
309
|
+
const mux = detectMux(env);
|
|
310
|
+
if (mux !== "none") {
|
|
311
|
+
const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
|
|
312
|
+
if (opened)
|
|
313
|
+
return opened;
|
|
314
|
+
}
|
|
315
|
+
if (detectIde(env) === "zed") {
|
|
316
|
+
try {
|
|
317
|
+
await ensureZedReviewTask(opts.homeDir);
|
|
318
|
+
return {
|
|
319
|
+
launched: false,
|
|
320
|
+
configured: true,
|
|
321
|
+
via: "zed-task",
|
|
322
|
+
command: printable,
|
|
323
|
+
taskName: ZED_REVIEW_TASK_NAME,
|
|
324
|
+
instruction: `Run the Zed task "${ZED_REVIEW_TASK_NAME}".`
|
|
325
|
+
};
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
return { launched: false, via: "print", command: printable };
|
|
329
|
+
}
|
|
330
|
+
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
331
|
+
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
332
|
+
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
333
|
+
if (result.code === 0)
|
|
334
|
+
return { launched: true, via: "zellij", command: printable };
|
|
335
|
+
const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
336
|
+
if (fallback.code === 0)
|
|
337
|
+
return { launched: true, via: "zellij-run", command: printable };
|
|
338
|
+
}
|
|
339
|
+
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
340
|
+
const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
341
|
+
if (result.code === 0)
|
|
342
|
+
return { launched: true, via: "tmux", command: printable };
|
|
343
|
+
}
|
|
344
|
+
if (mux === "screen" && await findExecutable("screen")) {
|
|
345
|
+
const result = await run("screen", screenWindowArgs(command, cwd, name));
|
|
346
|
+
if (result.code === 0)
|
|
347
|
+
return { launched: true, via: "screen", command: printable };
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
function screenWindowArgs(command, cwd, name) {
|
|
352
|
+
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
353
|
+
}
|
|
354
|
+
// src/forge.ts
|
|
355
|
+
function createForge(vcs) {
|
|
356
|
+
if (vcs.provider === "github")
|
|
357
|
+
return new GithubForge(vcs);
|
|
358
|
+
if (vcs.provider === "gitlab")
|
|
359
|
+
return new GitlabForge(vcs);
|
|
360
|
+
throw new Error("No forge detected from the git remote. Use --local for an offline review.");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
class GithubForge {
|
|
364
|
+
vcs;
|
|
365
|
+
provider = "github";
|
|
366
|
+
constructor(vcs) {
|
|
367
|
+
this.vcs = vcs;
|
|
368
|
+
}
|
|
369
|
+
repoFlag() {
|
|
370
|
+
return ["--repo", `${this.vcs.owner}/${this.vcs.repo}`];
|
|
371
|
+
}
|
|
372
|
+
async createDraftPr(options) {
|
|
373
|
+
const args = [
|
|
374
|
+
"pr",
|
|
375
|
+
"create",
|
|
376
|
+
...this.repoFlag(),
|
|
377
|
+
"--title",
|
|
378
|
+
options.title,
|
|
379
|
+
"--body",
|
|
380
|
+
options.body,
|
|
381
|
+
"--base",
|
|
382
|
+
options.base,
|
|
383
|
+
"--head",
|
|
384
|
+
options.head
|
|
385
|
+
];
|
|
386
|
+
if (options.draft !== false)
|
|
387
|
+
args.push("--draft");
|
|
388
|
+
await runChecked("gh", args);
|
|
389
|
+
const ref = await this.viewPr(options.head);
|
|
390
|
+
if (!ref)
|
|
391
|
+
throw new Error("Draft PR created but could not be resolved.");
|
|
392
|
+
return ref;
|
|
393
|
+
}
|
|
394
|
+
async viewPr(idOrBranch) {
|
|
395
|
+
const args = [
|
|
396
|
+
"pr",
|
|
397
|
+
"view",
|
|
398
|
+
idOrBranch,
|
|
399
|
+
...this.repoFlag(),
|
|
400
|
+
"--json",
|
|
401
|
+
"number,title,url,isDraft,baseRefName,headRefName"
|
|
402
|
+
];
|
|
403
|
+
const result = await run("gh", args);
|
|
404
|
+
if (result.code !== 0) {
|
|
405
|
+
if (isConfirmedMissingChange("github", result.stderr || result.stdout))
|
|
406
|
+
return;
|
|
407
|
+
throw commandFailure("gh", args, result);
|
|
408
|
+
}
|
|
409
|
+
if (!result.stdout.trim())
|
|
410
|
+
throw new Error("GitHub returned an empty pull request response.");
|
|
411
|
+
let data;
|
|
412
|
+
try {
|
|
413
|
+
data = JSON.parse(result.stdout);
|
|
414
|
+
} catch {
|
|
415
|
+
throw new Error("Cannot parse the GitHub pull request response as JSON.");
|
|
416
|
+
}
|
|
417
|
+
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") {
|
|
418
|
+
throw new Error("GitHub returned an invalid pull request response.");
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
number: data.number,
|
|
422
|
+
title: data.title,
|
|
423
|
+
url: data.url,
|
|
424
|
+
isDraft: data.isDraft,
|
|
425
|
+
baseRef: data.baseRefName,
|
|
426
|
+
headRef: data.headRefName
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
async defaultBranch() {
|
|
430
|
+
const result = await runChecked("gh", [
|
|
431
|
+
"repo",
|
|
432
|
+
"view",
|
|
433
|
+
`${this.vcs.owner}/${this.vcs.repo}`,
|
|
434
|
+
"--json",
|
|
435
|
+
"defaultBranchRef",
|
|
436
|
+
"--jq",
|
|
437
|
+
".defaultBranchRef.name"
|
|
438
|
+
]);
|
|
439
|
+
return requireBranchName(result.stdout, "GitHub");
|
|
440
|
+
}
|
|
441
|
+
async prDiff(id) {
|
|
442
|
+
return (await runChecked("gh", ["pr", "diff", String(id), ...this.repoFlag()], { capture: "unbounded" })).stdout;
|
|
443
|
+
}
|
|
444
|
+
async prChecks(id) {
|
|
445
|
+
return (await run("gh", ["pr", "checks", String(id), ...this.repoFlag()])).stdout;
|
|
446
|
+
}
|
|
447
|
+
async createPendingReview(id, comments, body) {
|
|
448
|
+
const payload = {
|
|
449
|
+
body,
|
|
450
|
+
comments: comments.map((comment) => ({
|
|
451
|
+
path: comment.file,
|
|
452
|
+
line: comment.line,
|
|
453
|
+
side: comment.side ?? "RIGHT",
|
|
454
|
+
body: comment.body
|
|
455
|
+
}))
|
|
456
|
+
};
|
|
457
|
+
await runChecked("gh", ["api", "--method", "POST", `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
|
|
458
|
+
}
|
|
459
|
+
async submitReview(id, event, body) {
|
|
460
|
+
const pending = await runChecked("gh", [
|
|
461
|
+
"api",
|
|
462
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`,
|
|
463
|
+
"--jq",
|
|
464
|
+
'[.[] | select(.state=="PENDING")] | last | .id'
|
|
465
|
+
]);
|
|
466
|
+
const reviewId = pending.stdout.trim();
|
|
467
|
+
const endpoint = githubReviewSubmissionEndpoint(this.vcs.owner, this.vcs.repo, id, reviewId);
|
|
468
|
+
const args = ["api", "--method", "POST", endpoint, "-f", `event=${event}`];
|
|
469
|
+
if (body.trim())
|
|
470
|
+
args.push("-f", `body=${body}`);
|
|
471
|
+
await runChecked("gh", args);
|
|
472
|
+
}
|
|
473
|
+
async markReady(id) {
|
|
474
|
+
await runChecked("gh", ["pr", "ready", String(id), ...this.repoFlag()]);
|
|
475
|
+
}
|
|
476
|
+
async closePr(id, comment) {
|
|
477
|
+
const args = ["pr", "close", String(id), ...this.repoFlag()];
|
|
478
|
+
if (comment)
|
|
479
|
+
args.push("--comment", comment);
|
|
480
|
+
await runChecked("gh", args);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
class GitlabForge {
|
|
485
|
+
vcs;
|
|
486
|
+
provider = "gitlab";
|
|
487
|
+
constructor(vcs) {
|
|
488
|
+
this.vcs = vcs;
|
|
489
|
+
}
|
|
490
|
+
project() {
|
|
491
|
+
return `${this.vcs.owner}/${this.vcs.repo}`;
|
|
492
|
+
}
|
|
493
|
+
async createDraftPr(options) {
|
|
494
|
+
await runChecked("glab", [
|
|
495
|
+
"mr",
|
|
496
|
+
"create",
|
|
497
|
+
"--repo",
|
|
498
|
+
this.project(),
|
|
499
|
+
"--title",
|
|
500
|
+
`Draft: ${options.title}`,
|
|
501
|
+
"--description",
|
|
502
|
+
options.body,
|
|
503
|
+
"--target-branch",
|
|
504
|
+
options.base,
|
|
505
|
+
"--source-branch",
|
|
506
|
+
options.head,
|
|
507
|
+
"--yes"
|
|
508
|
+
]);
|
|
509
|
+
const ref = await this.viewPr(options.head);
|
|
510
|
+
if (!ref)
|
|
511
|
+
throw new Error("Draft MR created but could not be resolved.");
|
|
512
|
+
return ref;
|
|
513
|
+
}
|
|
514
|
+
async viewPr(idOrBranch) {
|
|
515
|
+
const args = ["mr", "view", idOrBranch, "--repo", this.project(), "--output", "json"];
|
|
516
|
+
const result = await run("glab", args);
|
|
517
|
+
if (result.code !== 0) {
|
|
518
|
+
if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
|
|
519
|
+
return;
|
|
520
|
+
throw commandFailure("glab", args, result);
|
|
521
|
+
}
|
|
522
|
+
if (!result.stdout.trim())
|
|
523
|
+
throw new Error("GitLab returned an empty merge request response.");
|
|
524
|
+
let data;
|
|
525
|
+
try {
|
|
526
|
+
data = JSON.parse(result.stdout);
|
|
527
|
+
} catch {
|
|
528
|
+
throw new Error("Cannot parse the GitLab merge request response as JSON.");
|
|
529
|
+
}
|
|
530
|
+
if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string") {
|
|
531
|
+
throw new Error("GitLab returned an invalid merge request response.");
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
number: data.iid,
|
|
535
|
+
title: data.title,
|
|
536
|
+
url: data.web_url,
|
|
537
|
+
isDraft: Boolean(data.draft ?? data.work_in_progress),
|
|
538
|
+
baseRef: data.target_branch,
|
|
539
|
+
headRef: data.source_branch
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
async defaultBranch() {
|
|
543
|
+
const result = await runChecked("glab", [
|
|
544
|
+
"api",
|
|
545
|
+
`projects/${encodeURIComponent(this.project())}`,
|
|
546
|
+
"--jq",
|
|
547
|
+
".default_branch"
|
|
548
|
+
]);
|
|
549
|
+
return requireBranchName(result.stdout, "GitLab");
|
|
550
|
+
}
|
|
551
|
+
async prDiff(id) {
|
|
552
|
+
return (await runChecked("glab", ["mr", "diff", String(id), "--repo", this.project()], { capture: "unbounded" })).stdout;
|
|
553
|
+
}
|
|
554
|
+
async prChecks() {
|
|
555
|
+
return (await run("glab", ["ci", "status", "--repo", this.project()])).stdout;
|
|
556
|
+
}
|
|
557
|
+
async createPendingReview(id, comments, body) {
|
|
558
|
+
const endpoint = `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`;
|
|
559
|
+
if (body.trim()) {
|
|
560
|
+
await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
561
|
+
input: JSON.stringify({ note: body })
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
if (comments.length === 0)
|
|
565
|
+
return;
|
|
566
|
+
const response = await runChecked("glab", [
|
|
567
|
+
"api",
|
|
568
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}`
|
|
569
|
+
]);
|
|
570
|
+
const diffRefs = parseGitlabDiffRefs(response.stdout);
|
|
571
|
+
for (const comment of comments) {
|
|
572
|
+
const payload = {
|
|
573
|
+
note: comment.body,
|
|
574
|
+
position: {
|
|
575
|
+
...diffRefs,
|
|
576
|
+
position_type: "text",
|
|
577
|
+
new_path: comment.file,
|
|
578
|
+
old_path: comment.file,
|
|
579
|
+
new_line: comment.side === "LEFT" ? undefined : comment.line,
|
|
580
|
+
old_line: comment.side === "LEFT" ? comment.line : undefined
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
584
|
+
input: JSON.stringify(payload)
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async submitReview(id, event, body) {
|
|
589
|
+
assertReviewEventSupported(this.provider, event);
|
|
590
|
+
const drafts = await runChecked("glab", [
|
|
591
|
+
"api",
|
|
592
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`
|
|
593
|
+
]);
|
|
594
|
+
const hasDrafts = hasGitlabDraftNotes(drafts.stdout);
|
|
595
|
+
if (hasDrafts) {
|
|
596
|
+
await runChecked("glab", [
|
|
597
|
+
"api",
|
|
598
|
+
"--method",
|
|
599
|
+
"POST",
|
|
600
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes/bulk_publish`
|
|
601
|
+
]);
|
|
602
|
+
} else if (body.trim()) {
|
|
603
|
+
await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", body]);
|
|
604
|
+
}
|
|
605
|
+
if (event === "APPROVE")
|
|
606
|
+
await runChecked("glab", ["mr", "approve", String(id), "--repo", this.project()]);
|
|
607
|
+
}
|
|
608
|
+
async markReady(id) {
|
|
609
|
+
await runChecked("glab", ["mr", "update", String(id), "--repo", this.project(), "--ready"]);
|
|
610
|
+
}
|
|
611
|
+
async closePr(id, comment) {
|
|
612
|
+
if (comment)
|
|
613
|
+
await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", comment]);
|
|
614
|
+
await runChecked("glab", ["mr", "close", String(id), "--repo", this.project()]);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function assertReviewEventSupported(provider, event) {
|
|
618
|
+
if (provider === "gitlab" && event === "REQUEST_CHANGES") {
|
|
619
|
+
throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function isConfirmedMissingChange(provider, output) {
|
|
623
|
+
const message = output.toLowerCase();
|
|
624
|
+
if (provider === "github") {
|
|
625
|
+
return message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest");
|
|
626
|
+
}
|
|
627
|
+
if (provider === "gitlab") {
|
|
628
|
+
return message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message);
|
|
629
|
+
}
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
function commandFailure(command, args, result) {
|
|
633
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
634
|
+
return new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
635
|
+
}
|
|
636
|
+
function requireBranchName(output, provider) {
|
|
637
|
+
const branch = output.trim();
|
|
638
|
+
if (!branch || branch === "null")
|
|
639
|
+
throw new Error(`${provider} did not return a default branch.`);
|
|
640
|
+
return branch;
|
|
641
|
+
}
|
|
642
|
+
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
643
|
+
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
644
|
+
}
|
|
645
|
+
function parseGitlabDiffRefs(input) {
|
|
646
|
+
let data;
|
|
647
|
+
try {
|
|
648
|
+
data = JSON.parse(input);
|
|
649
|
+
} catch {
|
|
650
|
+
throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
|
|
651
|
+
}
|
|
652
|
+
const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
|
|
653
|
+
if (!base_sha || !start_sha || !head_sha) {
|
|
654
|
+
throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
|
|
655
|
+
}
|
|
656
|
+
return { base_sha, start_sha, head_sha };
|
|
657
|
+
}
|
|
658
|
+
function hasGitlabDraftNotes(input) {
|
|
659
|
+
try {
|
|
660
|
+
const data = JSON.parse(input);
|
|
661
|
+
return Array.isArray(data) && data.length > 0;
|
|
662
|
+
} catch {
|
|
663
|
+
throw new Error("Cannot complete GitLab review: the draft notes response was not valid JSON.");
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// src/gates.ts
|
|
667
|
+
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
668
|
+
var MISE_GATES = ["format:check", "lint", "test"];
|
|
669
|
+
function checkConventionalSubject(subject) {
|
|
670
|
+
const trimmed = subject.trim();
|
|
671
|
+
const ok = CONVENTIONAL_COMMIT.test(trimmed);
|
|
672
|
+
return {
|
|
673
|
+
name: "conventional-subject",
|
|
674
|
+
status: ok ? "pass" : "warn",
|
|
675
|
+
detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
async function runMiseGates(cwd) {
|
|
679
|
+
const tasks = await discoverMiseTasks(cwd);
|
|
680
|
+
const results = [];
|
|
681
|
+
for (const gate of MISE_GATES) {
|
|
682
|
+
const targets = tasks.get(gate) ?? [];
|
|
683
|
+
if (targets.length === 0) {
|
|
684
|
+
results.push({ name: gate, status: "skip", detail: "no mise recipe" });
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
|
|
688
|
+
const result = await run("mise", ["run", ...invocations], { cwd });
|
|
689
|
+
results.push({
|
|
690
|
+
name: gate,
|
|
691
|
+
status: result.code === 0 ? "pass" : "fail",
|
|
692
|
+
detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return results;
|
|
696
|
+
}
|
|
697
|
+
function ciGate(checksOutput) {
|
|
698
|
+
const text = checksOutput.toLowerCase();
|
|
699
|
+
if (!text.trim())
|
|
700
|
+
return { name: "ci", status: "skip", detail: "no CI output" };
|
|
701
|
+
if (/\bfail|error\b/.test(text))
|
|
702
|
+
return { name: "ci", status: "warn", detail: "CI failing" };
|
|
703
|
+
if (/\bpending|in progress|queued\b/.test(text))
|
|
704
|
+
return { name: "ci", status: "warn", detail: "CI pending" };
|
|
705
|
+
return { name: "ci", status: "pass", detail: "CI green" };
|
|
706
|
+
}
|
|
707
|
+
async function discoverMiseTasks(cwd) {
|
|
708
|
+
const result = await run("mise", ["tasks", "--json", "--all"], { cwd });
|
|
709
|
+
if (result.code !== 0)
|
|
710
|
+
return new Map;
|
|
711
|
+
return parseMiseTasks(result.stdout);
|
|
712
|
+
}
|
|
713
|
+
function parseMiseTasks(input) {
|
|
714
|
+
let tasks;
|
|
715
|
+
try {
|
|
716
|
+
tasks = JSON.parse(input);
|
|
717
|
+
} catch {
|
|
718
|
+
return new Map;
|
|
719
|
+
}
|
|
720
|
+
if (!Array.isArray(tasks))
|
|
721
|
+
return new Map;
|
|
722
|
+
const found = new Map;
|
|
723
|
+
for (const gate of MISE_GATES) {
|
|
724
|
+
const targets = tasks.flatMap((task) => {
|
|
725
|
+
if (typeof task.name !== "string")
|
|
726
|
+
return [];
|
|
727
|
+
return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
|
|
728
|
+
});
|
|
729
|
+
if (targets.length > 0)
|
|
730
|
+
found.set(gate, [...new Set(targets)]);
|
|
731
|
+
}
|
|
732
|
+
return found;
|
|
733
|
+
}
|
|
734
|
+
// src/mcp.ts
|
|
735
|
+
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
736
|
+
import { homedir as homedir3 } from "node:os";
|
|
737
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
738
|
+
var mcp = {
|
|
739
|
+
globalConfigPath(homeDir = homedir3()) {
|
|
740
|
+
return join4(homeDir, ".config", "mcp", "mcp.json");
|
|
741
|
+
},
|
|
742
|
+
async serversEnsure(servers, options = {}) {
|
|
743
|
+
const path = options.path ?? mcp.globalConfigPath();
|
|
744
|
+
const currentText = await getOptionalFile(path);
|
|
745
|
+
const current = getParsedConfig(currentText, path);
|
|
746
|
+
const nextServers = { ...current.mcpServers };
|
|
747
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
748
|
+
nextServers[name] = mergeEntry(nextServers[name], entry);
|
|
749
|
+
}
|
|
750
|
+
const next = { ...current, mcpServers: nextServers };
|
|
751
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
752
|
+
if (changed && !options.dryRun) {
|
|
753
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
754
|
+
await writeFile2(path, `${JSON.stringify(next, null, 2)}
|
|
755
|
+
`, "utf8");
|
|
756
|
+
}
|
|
757
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
function mergeEntry(current, required) {
|
|
761
|
+
const merged = { ...current, ...required };
|
|
762
|
+
if (current?.env || required.env)
|
|
763
|
+
merged.env = { ...current?.env, ...required.env };
|
|
764
|
+
return merged;
|
|
765
|
+
}
|
|
766
|
+
function getParsedConfig(content, path) {
|
|
767
|
+
if (!content?.trim())
|
|
768
|
+
return { mcpServers: {} };
|
|
769
|
+
try {
|
|
770
|
+
const value = JSON.parse(content);
|
|
771
|
+
if (!isRecord(value))
|
|
772
|
+
throw new Error("not an object");
|
|
773
|
+
const servers = value.mcpServers;
|
|
774
|
+
if (servers !== undefined && !isRecord(servers))
|
|
775
|
+
throw new Error("mcpServers is not an object");
|
|
776
|
+
return { ...value, mcpServers: servers ?? {} };
|
|
777
|
+
} catch {
|
|
778
|
+
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function getOptionalFile(path) {
|
|
782
|
+
try {
|
|
783
|
+
return await readFile3(path, "utf8");
|
|
784
|
+
} catch (error) {
|
|
785
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
786
|
+
return;
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function isRecord(value) {
|
|
791
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
792
|
+
}
|
|
122
793
|
// src/mise.ts
|
|
794
|
+
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
795
|
+
import { homedir as homedir4 } from "node:os";
|
|
796
|
+
import { basename as basename2, dirname as dirname3, join as join5 } from "node:path";
|
|
123
797
|
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
124
798
|
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
125
799
|
var mise = {
|
|
@@ -127,11 +801,11 @@ var mise = {
|
|
|
127
801
|
return findExecutable(name);
|
|
128
802
|
},
|
|
129
803
|
async install(options = {}) {
|
|
130
|
-
const homeDir = options.homeDir ??
|
|
804
|
+
const homeDir = options.homeDir ?? homedir4();
|
|
131
805
|
const platform = options.platform ?? process.platform;
|
|
132
806
|
if (platform === "win32")
|
|
133
807
|
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
134
|
-
const installedPath =
|
|
808
|
+
const installedPath = join5(homeDir, ".local", "bin", "mise");
|
|
135
809
|
if (options.dryRun)
|
|
136
810
|
return installedPath;
|
|
137
811
|
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
@@ -141,8 +815,8 @@ var mise = {
|
|
|
141
815
|
return executable;
|
|
142
816
|
},
|
|
143
817
|
async hookEnsure(executable, options = {}) {
|
|
144
|
-
const homeDir = options.homeDir ??
|
|
145
|
-
const hook = getShellHook(
|
|
818
|
+
const homeDir = options.homeDir ?? homedir4();
|
|
819
|
+
const hook = getShellHook(basename2(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
146
820
|
const current = await getOptionalFile2(hook.path);
|
|
147
821
|
if (current.includes(MISE_HOOK_START))
|
|
148
822
|
return { path: hook.path, changed: false, planned: false };
|
|
@@ -151,8 +825,8 @@ var mise = {
|
|
|
151
825
|
const separator = current.length === 0 || current.endsWith(`
|
|
152
826
|
`) ? "" : `
|
|
153
827
|
`;
|
|
154
|
-
await
|
|
155
|
-
await
|
|
828
|
+
await mkdir3(dirname3(hook.path), { recursive: true });
|
|
829
|
+
await writeFile3(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
156
830
|
return { path: hook.path, changed: true, planned: false };
|
|
157
831
|
},
|
|
158
832
|
async toolCheckGlobal(executable, tool, minimumVersion) {
|
|
@@ -169,7 +843,7 @@ var mise = {
|
|
|
169
843
|
async toolInstallLocal(executable, specification, cwd = process.cwd()) {
|
|
170
844
|
await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
|
|
171
845
|
},
|
|
172
|
-
async toolUpdateAllGlobal(executable, homeDir =
|
|
846
|
+
async toolUpdateAllGlobal(executable, homeDir = homedir4()) {
|
|
173
847
|
await runChecked(executable, ["upgrade"], { cwd: homeDir });
|
|
174
848
|
}
|
|
175
849
|
};
|
|
@@ -178,7 +852,7 @@ function getShellHook(shell, executable, homeDir) {
|
|
|
178
852
|
switch (shell.toLowerCase()) {
|
|
179
853
|
case "zsh":
|
|
180
854
|
return {
|
|
181
|
-
path:
|
|
855
|
+
path: join5(homeDir, ".zshrc"),
|
|
182
856
|
content: `${MISE_HOOK_START}
|
|
183
857
|
eval "$(${command} activate zsh)"
|
|
184
858
|
${MISE_HOOK_END}
|
|
@@ -186,7 +860,7 @@ ${MISE_HOOK_END}
|
|
|
186
860
|
};
|
|
187
861
|
case "fish":
|
|
188
862
|
return {
|
|
189
|
-
path:
|
|
863
|
+
path: join5(homeDir, ".config", "fish", "config.fish"),
|
|
190
864
|
content: `${MISE_HOOK_START}
|
|
191
865
|
${command} activate fish | source
|
|
192
866
|
${MISE_HOOK_END}
|
|
@@ -195,7 +869,7 @@ ${MISE_HOOK_END}
|
|
|
195
869
|
case "nu":
|
|
196
870
|
case "nushell":
|
|
197
871
|
return {
|
|
198
|
-
path:
|
|
872
|
+
path: join5(homeDir, ".config", "nushell", "config.nu"),
|
|
199
873
|
content: `${MISE_HOOK_START}
|
|
200
874
|
let mise_bin = ${command}
|
|
201
875
|
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
@@ -206,7 +880,7 @@ ${MISE_HOOK_END}
|
|
|
206
880
|
};
|
|
207
881
|
case "xonsh":
|
|
208
882
|
return {
|
|
209
|
-
path:
|
|
883
|
+
path: join5(homeDir, ".xonshrc"),
|
|
210
884
|
content: `${MISE_HOOK_START}
|
|
211
885
|
execx($(${command} activate xonsh))
|
|
212
886
|
${MISE_HOOK_END}
|
|
@@ -214,7 +888,7 @@ ${MISE_HOOK_END}
|
|
|
214
888
|
};
|
|
215
889
|
case "elvish":
|
|
216
890
|
return {
|
|
217
|
-
path:
|
|
891
|
+
path: join5(homeDir, ".config", "elvish", "rc.elv"),
|
|
218
892
|
content: `${MISE_HOOK_START}
|
|
219
893
|
var mise: = (ns [&])
|
|
220
894
|
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
@@ -225,7 +899,7 @@ ${MISE_HOOK_END}
|
|
|
225
899
|
case "pwsh":
|
|
226
900
|
case "powershell":
|
|
227
901
|
return {
|
|
228
|
-
path:
|
|
902
|
+
path: join5(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
229
903
|
content: `${MISE_HOOK_START}
|
|
230
904
|
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
231
905
|
${MISE_HOOK_END}
|
|
@@ -234,7 +908,7 @@ ${MISE_HOOK_END}
|
|
|
234
908
|
case "bash":
|
|
235
909
|
default:
|
|
236
910
|
return {
|
|
237
|
-
path:
|
|
911
|
+
path: join5(homeDir, ".bashrc"),
|
|
238
912
|
content: `${MISE_HOOK_START}
|
|
239
913
|
eval "$(${command} activate bash)"
|
|
240
914
|
${MISE_HOOK_END}
|
|
@@ -273,7 +947,7 @@ function isVersionAtLeast(version, minimumVersion) {
|
|
|
273
947
|
}
|
|
274
948
|
async function getOptionalFile2(path) {
|
|
275
949
|
try {
|
|
276
|
-
return await
|
|
950
|
+
return await readFile4(path, "utf8");
|
|
277
951
|
} catch (error) {
|
|
278
952
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
279
953
|
return "";
|
|
@@ -283,80 +957,354 @@ async function getOptionalFile2(path) {
|
|
|
283
957
|
function getShellQuoted(value) {
|
|
284
958
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
285
959
|
}
|
|
286
|
-
// src/
|
|
287
|
-
import {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
960
|
+
// src/modes.ts
|
|
961
|
+
import {
|
|
962
|
+
getAgentDir,
|
|
963
|
+
parseFrontmatter as parseFrontmatter2
|
|
964
|
+
} from "@earendil-works/pi-coding-agent";
|
|
965
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
966
|
+
import { homedir as homedir5 } from "node:os";
|
|
967
|
+
import { basename as basename3, extname, join as join7 } from "node:path";
|
|
968
|
+
|
|
969
|
+
// src/assets.ts
|
|
970
|
+
import { existsSync } from "node:fs";
|
|
971
|
+
import { dirname as dirname4, join as join6 } from "node:path";
|
|
972
|
+
import { fileURLToPath } from "node:url";
|
|
973
|
+
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
974
|
+
const moduleDir = dirname4(fileURLToPath(moduleUrl));
|
|
975
|
+
const candidates = [
|
|
976
|
+
join6(moduleDir, "agents"),
|
|
977
|
+
join6(moduleDir, "..", "agents"),
|
|
978
|
+
join6(moduleDir, "..", "..", "agents")
|
|
979
|
+
];
|
|
980
|
+
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// src/modes.ts
|
|
984
|
+
async function discoverAgentModes(options) {
|
|
985
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
986
|
+
const homeDir = options.homeDir ?? homedir5();
|
|
987
|
+
const modes = new Map;
|
|
988
|
+
const diagnostics = [];
|
|
989
|
+
await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
|
|
990
|
+
await loadAgentModes(join7(agentDir, "agents"), "user agent", modes, diagnostics);
|
|
991
|
+
if (options.includeSkills) {
|
|
992
|
+
await loadSkillModes(join7(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
|
|
993
|
+
await loadSkillModes(join7(agentDir, "skills"), "pi user skill", modes, diagnostics);
|
|
994
|
+
}
|
|
995
|
+
if (options.projectTrusted === true) {
|
|
996
|
+
if (options.includeSkills) {
|
|
997
|
+
await loadSkillModes(join7(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
|
|
998
|
+
await loadSkillModes(join7(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
|
|
313
999
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
1000
|
+
await loadAgentModes(join7(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
|
|
1001
|
+
await loadAgentModes(join7(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
|
|
1002
|
+
}
|
|
1003
|
+
const userConfig = await loadDiffpiConfig({ homeDir });
|
|
1004
|
+
const configuredModes = [...modes.values()].map((mode) => ({
|
|
1005
|
+
...mode,
|
|
1006
|
+
modelPreferences: resolveAgentModelPreferences(mode.id, mode.modelPreferences, userConfig.config)
|
|
1007
|
+
}));
|
|
1008
|
+
return {
|
|
1009
|
+
modes: configuredModes.sort((left, right) => left.id.localeCompare(right.id)),
|
|
1010
|
+
diagnostics
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
function resolveAgentMode(modes, requested) {
|
|
1014
|
+
const name = requested.trim();
|
|
1015
|
+
if (!name)
|
|
1016
|
+
return { ok: false, message: "Agent name is required." };
|
|
1017
|
+
const exact = modes.find((mode) => mode.id === name);
|
|
1018
|
+
if (exact)
|
|
1019
|
+
return { ok: true, active: exact, message: `Active inline agent: ${exact.id}.` };
|
|
1020
|
+
const lowerName = name.toLowerCase();
|
|
1021
|
+
const matches = modes.filter((mode) => mode.id.toLowerCase() === lowerName);
|
|
1022
|
+
if (matches.length === 1) {
|
|
1023
|
+
const active = matches[0];
|
|
1024
|
+
return { ok: true, active, message: `Active inline agent: ${active.id}.` };
|
|
1025
|
+
}
|
|
1026
|
+
if (matches.length > 1) {
|
|
1027
|
+
return {
|
|
1028
|
+
ok: false,
|
|
1029
|
+
message: `Inline agent "${name}" is ambiguous. Use one of: ${matches.map((mode) => mode.id).join(", ")}.`
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
return { ok: false, message: `Unknown inline agent "${name}". Run /skill:mode or diffpi_modes_list.` };
|
|
1033
|
+
}
|
|
1034
|
+
function createModeController(pi, options = {}) {
|
|
1035
|
+
let active;
|
|
1036
|
+
let baseline;
|
|
1037
|
+
const updateStatus = (ctx) => {
|
|
1038
|
+
ctx.ui.setStatus(MODE_STATUS_KEY, active ? `mode: ${active.id}` : undefined);
|
|
1039
|
+
};
|
|
1040
|
+
const list = (ctx, listOptions = {}) => discoverAgentModes({
|
|
1041
|
+
cwd: ctx.cwd,
|
|
1042
|
+
agentDir: options.agentDir,
|
|
1043
|
+
bundledAgentsDir: options.bundledAgentsDir,
|
|
1044
|
+
homeDir: options.homeDir,
|
|
1045
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
1046
|
+
includeSkills: listOptions.includeSkills
|
|
1047
|
+
});
|
|
1048
|
+
return {
|
|
1049
|
+
list,
|
|
1050
|
+
async set(agent, ctx) {
|
|
1051
|
+
const catalog = await list(ctx, { includeSkills: agent.includes(":") });
|
|
1052
|
+
const result = resolveAgentMode(catalog.modes, agent);
|
|
1053
|
+
if (!result.ok || !result.active)
|
|
1054
|
+
return result;
|
|
1055
|
+
baseline ??= captureRuntime(pi, ctx);
|
|
1056
|
+
if (active && baseline)
|
|
1057
|
+
await restoreRuntime(pi, baseline, ctx);
|
|
1058
|
+
active = result.active;
|
|
1059
|
+
const runtimeMessage = await applyModeRuntime(pi, active, ctx);
|
|
1060
|
+
pi.appendEntry(MODE_STATE_ENTRY, { active, baseline });
|
|
1061
|
+
updateStatus(ctx);
|
|
1062
|
+
return { ...result, message: `${result.message} ${runtimeMessage}` };
|
|
1063
|
+
},
|
|
1064
|
+
async unset(ctx) {
|
|
1065
|
+
if (!active)
|
|
1066
|
+
return { ok: true, message: "Inline agent is already clear." };
|
|
1067
|
+
if (baseline)
|
|
1068
|
+
await restoreRuntime(pi, baseline, ctx);
|
|
1069
|
+
active = undefined;
|
|
1070
|
+
pi.appendEntry(MODE_STATE_ENTRY, { active: null });
|
|
1071
|
+
baseline = undefined;
|
|
1072
|
+
updateStatus(ctx);
|
|
1073
|
+
return { ok: true, message: "Inline agent cleared. The previous model, thinking, tools, and prompt resume." };
|
|
1074
|
+
},
|
|
1075
|
+
async restore(ctx) {
|
|
1076
|
+
const previousActive = active;
|
|
1077
|
+
const previousBaseline = baseline;
|
|
1078
|
+
const entry = [...ctx.sessionManager.getBranch()].reverse().find((candidate) => candidate.type === "custom" && candidate.customType === MODE_STATE_ENTRY);
|
|
1079
|
+
const restored = entry?.data?.active;
|
|
1080
|
+
const restoredBaseline = entry?.data?.baseline;
|
|
1081
|
+
if (isAgentModeSnapshot(restored)) {
|
|
1082
|
+
active = restored;
|
|
1083
|
+
baseline = isModeBaseline(restoredBaseline) ? restoredBaseline : previousBaseline;
|
|
1084
|
+
await applyModeRuntime(pi, active, ctx);
|
|
1085
|
+
} else {
|
|
1086
|
+
if (previousActive && previousBaseline)
|
|
1087
|
+
pi.setActiveTools(previousBaseline.tools);
|
|
1088
|
+
active = undefined;
|
|
1089
|
+
baseline = undefined;
|
|
1090
|
+
}
|
|
1091
|
+
updateStatus(ctx);
|
|
1092
|
+
},
|
|
1093
|
+
apply(systemPrompt) {
|
|
1094
|
+
if (!active)
|
|
1095
|
+
return systemPrompt;
|
|
1096
|
+
if (active.promptStrategy === "replace")
|
|
1097
|
+
return active.systemPrompt;
|
|
1098
|
+
return `${systemPrompt}
|
|
1099
|
+
|
|
1100
|
+
## Active inline agent: ${active.label}
|
|
1101
|
+
|
|
1102
|
+
${active.systemPrompt}`;
|
|
1103
|
+
},
|
|
1104
|
+
getActive() {
|
|
1105
|
+
return active;
|
|
343
1106
|
}
|
|
344
|
-
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
var MODE_STATE_ENTRY = "diffpi-mode-state";
|
|
1110
|
+
var MODE_STATUS_KEY = "diffpi-mode";
|
|
1111
|
+
var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
|
|
1112
|
+
var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
|
|
1113
|
+
var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
1114
|
+
async function applyModeRuntime(pi, mode, ctx) {
|
|
1115
|
+
let selectedModel;
|
|
1116
|
+
if (mode.modelPreferences.length > 0) {
|
|
1117
|
+
const scoped = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : undefined;
|
|
1118
|
+
const availableModels = scoped ?? ctx.modelRegistry.getAvailable();
|
|
1119
|
+
for (const preference of mode.modelPreferences) {
|
|
1120
|
+
const model = findPreferredModel(availableModels, preference);
|
|
1121
|
+
if (model && await pi.setModel(model)) {
|
|
1122
|
+
selectedModel = `${model.provider}/${model.id}`;
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (mode.thinkingLevel)
|
|
1128
|
+
pi.setThinkingLevel(mode.thinkingLevel);
|
|
1129
|
+
if (mode.tools.length > 0) {
|
|
1130
|
+
const availableTools = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
1131
|
+
const selectedTools = [...new Set([...mode.tools, ...MODE_CONTROL_TOOLS])].filter((tool) => availableTools.has(tool));
|
|
1132
|
+
if (selectedTools.length > 0)
|
|
1133
|
+
pi.setActiveTools(selectedTools);
|
|
345
1134
|
}
|
|
1135
|
+
const parts = [];
|
|
1136
|
+
if (mode.modelPreferences.length > 0) {
|
|
1137
|
+
parts.push(selectedModel ? `Model: ${selectedModel}.` : "No preferred model was available; kept the current model.");
|
|
1138
|
+
}
|
|
1139
|
+
if (mode.thinkingLevel)
|
|
1140
|
+
parts.push(`Thinking: ${mode.thinkingLevel}.`);
|
|
1141
|
+
if (mode.tools.length > 0)
|
|
1142
|
+
parts.push("Applied the profile tool set.");
|
|
1143
|
+
return parts.join(" ") || "The profile changes the prompt only.";
|
|
1144
|
+
}
|
|
1145
|
+
async function restoreRuntime(pi, state, ctx) {
|
|
1146
|
+
if (state.model) {
|
|
1147
|
+
const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
|
|
1148
|
+
if (model)
|
|
1149
|
+
await pi.setModel(model);
|
|
1150
|
+
}
|
|
1151
|
+
pi.setThinkingLevel(state.thinkingLevel);
|
|
1152
|
+
pi.setActiveTools(state.tools);
|
|
1153
|
+
}
|
|
1154
|
+
function captureRuntime(pi, ctx) {
|
|
1155
|
+
return {
|
|
1156
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
|
|
1157
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
1158
|
+
tools: pi.getActiveTools()
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
async function loadSkillModes(skillsDir, source, modes, diagnostics) {
|
|
1162
|
+
const entries = await readDirectoryIfExists(skillsDir);
|
|
1163
|
+
for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1164
|
+
await loadAgentModes(join7(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
|
|
1168
|
+
const entries = await readDirectoryIfExists(directory);
|
|
1169
|
+
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1170
|
+
const path = join7(directory, entry.name);
|
|
1171
|
+
try {
|
|
1172
|
+
const content = await readFile5(path, "utf8");
|
|
1173
|
+
const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
1174
|
+
if (frontmatter.enabled === false || frontmatter.inline === false)
|
|
1175
|
+
continue;
|
|
1176
|
+
const name = getFrontmatterText(frontmatter.name) ?? basename3(path, extname(path));
|
|
1177
|
+
const systemPrompt = body.trim();
|
|
1178
|
+
if (!name || name.includes(":") || !systemPrompt) {
|
|
1179
|
+
diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
const id = skillName ? `${skillName}:${name}` : name;
|
|
1183
|
+
modes.set(id, {
|
|
1184
|
+
id,
|
|
1185
|
+
label: getFrontmatterText(frontmatter.display_name) ?? name,
|
|
1186
|
+
description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename3(path)}`,
|
|
1187
|
+
systemPrompt,
|
|
1188
|
+
promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
|
|
1189
|
+
modelPreferences: [
|
|
1190
|
+
...getFrontmatterList(frontmatter.model),
|
|
1191
|
+
...getFrontmatterList(frontmatter.model_fallbacks)
|
|
1192
|
+
],
|
|
1193
|
+
thinkingLevel: getThinkingLevel(frontmatter.thinking),
|
|
1194
|
+
tools: getFrontmatterList(frontmatter.tools),
|
|
1195
|
+
source,
|
|
1196
|
+
sourcePath: path
|
|
1197
|
+
});
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
diagnostics.push(`Skipped ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
function getFrontmatterText(value) {
|
|
1204
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
1205
|
+
}
|
|
1206
|
+
function getFrontmatterList(value) {
|
|
1207
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
1208
|
+
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
1209
|
+
}
|
|
1210
|
+
function getThinkingLevel(value) {
|
|
1211
|
+
const level = getFrontmatterText(value);
|
|
1212
|
+
return level && THINKING_LEVELS.has(level) ? level : undefined;
|
|
1213
|
+
}
|
|
1214
|
+
function isAgentModeSnapshot(value) {
|
|
1215
|
+
if (!value || typeof value !== "object")
|
|
1216
|
+
return false;
|
|
1217
|
+
const candidate = value;
|
|
1218
|
+
return typeof candidate.id === "string" && typeof candidate.label === "string" && typeof candidate.description === "string" && typeof candidate.systemPrompt === "string" && (candidate.promptStrategy === "append" || candidate.promptStrategy === "replace") && Array.isArray(candidate.modelPreferences) && (candidate.thinkingLevel === undefined || THINKING_LEVELS.has(candidate.thinkingLevel)) && Array.isArray(candidate.tools) && typeof candidate.source === "string" && typeof candidate.sourcePath === "string";
|
|
1219
|
+
}
|
|
1220
|
+
function isModeBaseline(value) {
|
|
1221
|
+
if (!value || typeof value !== "object")
|
|
1222
|
+
return false;
|
|
1223
|
+
const candidate = value;
|
|
1224
|
+
const model = candidate.model;
|
|
1225
|
+
return (model === undefined || typeof model.provider === "string" && typeof model.id === "string") && candidate.thinkingLevel !== undefined && THINKING_LEVELS.has(candidate.thinkingLevel) && Array.isArray(candidate.tools) && candidate.tools.every((tool) => typeof tool === "string");
|
|
1226
|
+
}
|
|
1227
|
+
// src/pi.ts
|
|
1228
|
+
import { mkdir as mkdir4, writeFile as writeFile4 } from "node:fs/promises";
|
|
1229
|
+
import { homedir as homedir6 } from "node:os";
|
|
1230
|
+
import { dirname as dirname5, join as join8 } from "node:path";
|
|
1231
|
+
var pi = {
|
|
1232
|
+
executableCheck: findPiExecutable,
|
|
1233
|
+
packageList: listPiPackages,
|
|
1234
|
+
packageCheck: hasPiPackage,
|
|
1235
|
+
packageInstall: installPiPackage,
|
|
1236
|
+
agentDir: resolvePiAgentDir,
|
|
1237
|
+
agentEnsure: ensurePiAgent,
|
|
1238
|
+
skillCheckGlobal: checkGlobalPiSkill,
|
|
1239
|
+
skillInstallGlobal: installGlobalPiSkills,
|
|
1240
|
+
configEnsure: ensurePiConfig
|
|
346
1241
|
};
|
|
347
|
-
function
|
|
348
|
-
|
|
1242
|
+
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
1243
|
+
const path = join8(agentDir, "agents", filename);
|
|
1244
|
+
const currentText = await readTextIfExists(path);
|
|
1245
|
+
const changed = currentText !== content;
|
|
1246
|
+
if (changed && !dryRun) {
|
|
1247
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
1248
|
+
await writeFile4(path, content, "utf8");
|
|
1249
|
+
}
|
|
1250
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
349
1251
|
}
|
|
350
|
-
async function
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return;
|
|
356
|
-
throw error;
|
|
1252
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join8(homedir6(), ".agents", "skills")) {
|
|
1253
|
+
const roots = [join8(agentDir, "skills"), sharedSkillsDir];
|
|
1254
|
+
for (const root of roots) {
|
|
1255
|
+
if (await readTextIfExists(join8(root, name, "SKILL.md")) !== undefined)
|
|
1256
|
+
return true;
|
|
357
1257
|
}
|
|
1258
|
+
return false;
|
|
1259
|
+
}
|
|
1260
|
+
async function installGlobalPiSkills(miseExecutable, source, names) {
|
|
1261
|
+
const selection = names.flatMap((name) => ["--skill", name]);
|
|
1262
|
+
await runChecked(miseExecutable, [
|
|
1263
|
+
"x",
|
|
1264
|
+
"node@22",
|
|
1265
|
+
"--",
|
|
1266
|
+
"npx",
|
|
1267
|
+
"-y",
|
|
1268
|
+
"skills",
|
|
1269
|
+
"add",
|
|
1270
|
+
source,
|
|
1271
|
+
...selection,
|
|
1272
|
+
"--global",
|
|
1273
|
+
"--agent",
|
|
1274
|
+
"pi",
|
|
1275
|
+
"--yes"
|
|
1276
|
+
]);
|
|
1277
|
+
}
|
|
1278
|
+
async function ensurePiConfig(path, update, dryRun = false) {
|
|
1279
|
+
const currentText = await readTextIfExists(path);
|
|
1280
|
+
const current = parseJsonObject(currentText, path);
|
|
1281
|
+
const next = update(current);
|
|
1282
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
1283
|
+
if (changed && !dryRun) {
|
|
1284
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
1285
|
+
await writeFile4(path, `${JSON.stringify(next, null, 2)}
|
|
1286
|
+
`, "utf8");
|
|
1287
|
+
}
|
|
1288
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
1289
|
+
}
|
|
1290
|
+
async function findPiExecutable() {
|
|
1291
|
+
return findExecutable("pi");
|
|
1292
|
+
}
|
|
1293
|
+
async function listPiPackages(executable) {
|
|
1294
|
+
return (await runChecked(executable, ["list"])).stdout;
|
|
358
1295
|
}
|
|
359
|
-
function
|
|
1296
|
+
function hasPiPackage(listOutput, source) {
|
|
1297
|
+
if (listOutput.includes(source))
|
|
1298
|
+
return true;
|
|
1299
|
+
return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
|
|
1300
|
+
}
|
|
1301
|
+
async function installPiPackage(executable, source) {
|
|
1302
|
+
await runChecked(executable, ["install", source]);
|
|
1303
|
+
}
|
|
1304
|
+
function resolvePiAgentDir(homeDir = homedir6()) {
|
|
1305
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join8(process.env.XDG_CONFIG_HOME, "pi") : join8(homeDir, ".pi", "agent"));
|
|
1306
|
+
}
|
|
1307
|
+
function parseJsonObject(content, path) {
|
|
360
1308
|
if (!content?.trim())
|
|
361
1309
|
return {};
|
|
362
1310
|
try {
|
|
@@ -366,9 +1314,290 @@ function getParsedObject(content, path) {
|
|
|
366
1314
|
} catch {}
|
|
367
1315
|
throw new Error(`Expected valid JSON object in ${path}.`);
|
|
368
1316
|
}
|
|
1317
|
+
// src/store.ts
|
|
1318
|
+
import { createHash } from "node:crypto";
|
|
1319
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1320
|
+
import { mkdir as mkdir5, realpath, symlink } from "node:fs/promises";
|
|
1321
|
+
import { homedir as homedir7 } from "node:os";
|
|
1322
|
+
import { isAbsolute, join as join9, resolve } from "node:path";
|
|
1323
|
+
var STORE_LINK = join9(".pi", "diffpi");
|
|
1324
|
+
async function gitToplevel(cwd) {
|
|
1325
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
1326
|
+
const top = result.stdout.trim();
|
|
1327
|
+
return result.code === 0 && top ? top : resolve(cwd);
|
|
1328
|
+
}
|
|
1329
|
+
async function computeProjectSlug(cwd) {
|
|
1330
|
+
const root = await gitToplevel(cwd);
|
|
1331
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
1332
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
1333
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
1334
|
+
const common = commonResult.stdout.trim();
|
|
1335
|
+
let commonPath = root;
|
|
1336
|
+
if (commonResult.code === 0 && common) {
|
|
1337
|
+
const resolvedCommon = isAbsolute(common) ? common : join9(root, common);
|
|
1338
|
+
commonPath = resolve(resolvedCommon);
|
|
1339
|
+
}
|
|
1340
|
+
const canonicalCommon = await canonicalPath(commonPath);
|
|
1341
|
+
const identity = remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${canonicalCommon}`;
|
|
1342
|
+
const name = remote ? repositoryName(remote) : basename4(resolve(canonicalCommon, "..")) || basename4(root);
|
|
1343
|
+
const readable = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
1344
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12);
|
|
1345
|
+
return `${readable}-${digest}`;
|
|
1346
|
+
}
|
|
1347
|
+
function storeGlobalRoot(homeDir = homedir7()) {
|
|
1348
|
+
return join9(homeDir, ".difflab", "diffpi", "projects");
|
|
1349
|
+
}
|
|
1350
|
+
async function ensureStore(cwd, homeDir = homedir7()) {
|
|
1351
|
+
const root = await gitToplevel(cwd);
|
|
1352
|
+
const slug = await computeProjectSlug(root);
|
|
1353
|
+
const dest = join9(storeGlobalRoot(homeDir), slug);
|
|
1354
|
+
const link = join9(root, STORE_LINK);
|
|
1355
|
+
if (existsSync2(link))
|
|
1356
|
+
return { slug, root, dest, link, linked: true };
|
|
1357
|
+
await mkdir5(dest, { recursive: true });
|
|
1358
|
+
await mkdir5(join9(root, ".pi"), { recursive: true });
|
|
1359
|
+
await symlink(dest, link);
|
|
1360
|
+
return { slug, root, dest, link, linked: true };
|
|
1361
|
+
}
|
|
1362
|
+
async function storeDir(cwd, homeDir = homedir7()) {
|
|
1363
|
+
return (await ensureStore(cwd, homeDir)).dest;
|
|
1364
|
+
}
|
|
1365
|
+
async function reviewsDir(cwd, homeDir = homedir7()) {
|
|
1366
|
+
const dir = join9(await storeDir(cwd, homeDir), "reviews");
|
|
1367
|
+
await mkdir5(dir, { recursive: true });
|
|
1368
|
+
return dir;
|
|
1369
|
+
}
|
|
1370
|
+
async function sessionsDir(cwd, homeDir = homedir7()) {
|
|
1371
|
+
const dir = join9(await storeDir(cwd, homeDir), "sessions");
|
|
1372
|
+
await mkdir5(dir, { recursive: true });
|
|
1373
|
+
return dir;
|
|
1374
|
+
}
|
|
1375
|
+
async function canonicalPath(path) {
|
|
1376
|
+
try {
|
|
1377
|
+
return await realpath(path);
|
|
1378
|
+
} catch {
|
|
1379
|
+
return resolve(path);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
function normalizeRemote(remote) {
|
|
1383
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
1384
|
+
}
|
|
1385
|
+
function repositoryName(remote) {
|
|
1386
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
1387
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
1388
|
+
}
|
|
1389
|
+
function basename4(path) {
|
|
1390
|
+
const parts = resolve(path).split(/[/\\]/).filter(Boolean);
|
|
1391
|
+
return parts.at(-1) ?? "";
|
|
1392
|
+
}
|
|
1393
|
+
// src/tuicr.ts
|
|
1394
|
+
import { readFile as readFile6, realpath as realpath2 } from "node:fs/promises";
|
|
1395
|
+
import { resolve as resolve2 } from "node:path";
|
|
1396
|
+
async function tuicrAvailable() {
|
|
1397
|
+
return (await run("tuicr", ["--version"])).code === 0;
|
|
1398
|
+
}
|
|
1399
|
+
async function listSessions(repo = ".") {
|
|
1400
|
+
const result = await run("tuicr", ["review", "list", "--repo", repo]);
|
|
1401
|
+
if (result.code !== 0 || !result.stdout.trim())
|
|
1402
|
+
return [];
|
|
1403
|
+
let raw;
|
|
1404
|
+
try {
|
|
1405
|
+
raw = JSON.parse(result.stdout);
|
|
1406
|
+
} catch {
|
|
1407
|
+
return [];
|
|
1408
|
+
}
|
|
1409
|
+
return raw.map((entry) => ({
|
|
1410
|
+
slug: entry.slug,
|
|
1411
|
+
kind: entry.kind,
|
|
1412
|
+
path: entry.path,
|
|
1413
|
+
updatedAt: entry.updated_at,
|
|
1414
|
+
commentCount: entry.comment_count,
|
|
1415
|
+
anchor: entry.anchor,
|
|
1416
|
+
active: entry.active
|
|
1417
|
+
}));
|
|
1418
|
+
}
|
|
1419
|
+
async function resolveSession(cwd, branch) {
|
|
1420
|
+
return findMatchingSession(await listSessions(cwd), cwd, branch);
|
|
1421
|
+
}
|
|
1422
|
+
async function findMatchingSession(sessions, cwd, branch) {
|
|
1423
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
1424
|
+
for (const session of sessions) {
|
|
1425
|
+
if (session.kind !== "local")
|
|
1426
|
+
continue;
|
|
1427
|
+
try {
|
|
1428
|
+
const data = await readSession(session.path);
|
|
1429
|
+
if (data.branch_name !== branch || !data.repo_path)
|
|
1430
|
+
continue;
|
|
1431
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
1432
|
+
return session;
|
|
1433
|
+
} catch {}
|
|
1434
|
+
}
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
async function readSession(path) {
|
|
1438
|
+
const content = await readFile6(path, "utf8");
|
|
1439
|
+
try {
|
|
1440
|
+
return JSON.parse(content);
|
|
1441
|
+
} catch {
|
|
1442
|
+
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
async function addComment(session, body, opts = {}) {
|
|
1446
|
+
const args = ["review", "add", "--session", session, body];
|
|
1447
|
+
if (opts.targetFile)
|
|
1448
|
+
args.push("--target-file", opts.targetFile);
|
|
1449
|
+
if (opts.line !== undefined)
|
|
1450
|
+
args.push("--line", String(opts.line));
|
|
1451
|
+
if (opts.username)
|
|
1452
|
+
args.push("--username", opts.username);
|
|
1453
|
+
await runChecked("tuicr", args);
|
|
1454
|
+
}
|
|
1455
|
+
async function launch(cwd) {
|
|
1456
|
+
return openInNewTab(["tuicr", "-w"], { cwd, name: "tuicr" });
|
|
1457
|
+
}
|
|
1458
|
+
function toFindings(session) {
|
|
1459
|
+
const comments = [];
|
|
1460
|
+
const bodyParts = (session.review_comments ?? []).map((comment) => comment.content);
|
|
1461
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
1462
|
+
const fileComments = (entry.file_comments ?? []).map((comment) => comment.content);
|
|
1463
|
+
if (fileComments.length > 0)
|
|
1464
|
+
bodyParts.push(`File: ${file}
|
|
1465
|
+
|
|
1466
|
+
${fileComments.join(`
|
|
1467
|
+
|
|
1468
|
+
`)}`);
|
|
1469
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
1470
|
+
const line = Number.parseInt(lineKey, 10);
|
|
1471
|
+
if (!Number.isFinite(line))
|
|
1472
|
+
continue;
|
|
1473
|
+
for (const lineComment of lineComments) {
|
|
1474
|
+
comments.push({ file, line, side: lineComment.side === "old" ? "LEFT" : "RIGHT", body: lineComment.content });
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
return {
|
|
1479
|
+
comments,
|
|
1480
|
+
body: bodyParts.join(`
|
|
1481
|
+
|
|
1482
|
+
`)
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
async function canonicalPath2(path) {
|
|
1486
|
+
try {
|
|
1487
|
+
return await realpath2(path);
|
|
1488
|
+
} catch {
|
|
1489
|
+
return resolve2(path);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
// src/review.ts
|
|
1493
|
+
import { join as join10 } from "node:path";
|
|
1494
|
+
import { z as z2 } from "zod";
|
|
1495
|
+
var severitySchema = z2.enum(["BLOCKING", "CONSIDER", "NOTE"]);
|
|
1496
|
+
var findingSchema = z2.object({
|
|
1497
|
+
file: z2.string().min(1),
|
|
1498
|
+
line: z2.number().int().nonnegative(),
|
|
1499
|
+
severity: severitySchema,
|
|
1500
|
+
body: z2.string().min(1),
|
|
1501
|
+
reference: z2.string().optional().default("")
|
|
1502
|
+
});
|
|
1503
|
+
var findingsSchema = z2.array(findingSchema);
|
|
1504
|
+
function reviewSlug(input) {
|
|
1505
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
1506
|
+
}
|
|
1507
|
+
function mmddyy(date = new Date) {
|
|
1508
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
1509
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
1510
|
+
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
|
1511
|
+
return `${mm}${dd}${yy}`;
|
|
1512
|
+
}
|
|
1513
|
+
function reviewRecordName(branch, date = new Date) {
|
|
1514
|
+
return `${mmddyy(date)}-${reviewSlug(branch)}`;
|
|
1515
|
+
}
|
|
1516
|
+
function dedupeFindings(findings) {
|
|
1517
|
+
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
1518
|
+
const byKey = new Map;
|
|
1519
|
+
for (const finding of findings) {
|
|
1520
|
+
const key = `${finding.file}:${finding.line}`;
|
|
1521
|
+
const existing = byKey.get(key);
|
|
1522
|
+
if (!existing || rank[finding.severity] > rank[existing.severity])
|
|
1523
|
+
byKey.set(key, finding);
|
|
1524
|
+
}
|
|
1525
|
+
return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
|
|
1526
|
+
}
|
|
1527
|
+
function toReviewComments(findings) {
|
|
1528
|
+
return findings.filter((finding) => finding.line > 0).map((finding) => ({
|
|
1529
|
+
file: finding.file,
|
|
1530
|
+
line: finding.line,
|
|
1531
|
+
side: "RIGHT",
|
|
1532
|
+
body: renderCommentBody(finding)
|
|
1533
|
+
}));
|
|
1534
|
+
}
|
|
1535
|
+
function renderReviewDoc(input) {
|
|
1536
|
+
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
1537
|
+
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
1538
|
+
if (input.number !== undefined)
|
|
1539
|
+
lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
|
|
1540
|
+
if (input.author)
|
|
1541
|
+
lines.push(`- **Author**: ${input.author}`);
|
|
1542
|
+
if (input.headRef && input.baseRef)
|
|
1543
|
+
lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
|
|
1544
|
+
if (input.additions !== undefined)
|
|
1545
|
+
lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
|
|
1546
|
+
lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
|
|
1547
|
+
if (input.overallIssues.length > 0) {
|
|
1548
|
+
lines.push("## Overall issues", "");
|
|
1549
|
+
for (const issue of input.overallIssues)
|
|
1550
|
+
lines.push(`- ${issue}`);
|
|
1551
|
+
lines.push("");
|
|
1552
|
+
}
|
|
1553
|
+
lines.push("## Verification", "");
|
|
1554
|
+
for (const gate of input.gates)
|
|
1555
|
+
lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
|
|
1556
|
+
lines.push("");
|
|
1557
|
+
if (input.notVerified.length > 0) {
|
|
1558
|
+
lines.push("## What was NOT verified", "");
|
|
1559
|
+
for (const item of input.notVerified)
|
|
1560
|
+
lines.push(`- ${item}`);
|
|
1561
|
+
lines.push("");
|
|
1562
|
+
}
|
|
1563
|
+
lines.push("## Inline Comments", "");
|
|
1564
|
+
for (const finding of anchored) {
|
|
1565
|
+
lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
|
|
1566
|
+
if (finding.reference)
|
|
1567
|
+
lines.push(`> **Reference:** ${finding.reference}`, "");
|
|
1568
|
+
lines.push("---", "");
|
|
1569
|
+
}
|
|
1570
|
+
return `${lines.join(`
|
|
1571
|
+
`).trimEnd()}
|
|
1572
|
+
`;
|
|
1573
|
+
}
|
|
1574
|
+
function renderPrBody(intent, changes, validation) {
|
|
1575
|
+
const lines = ["## Intent", "", intent, "", "## Changes", ""];
|
|
1576
|
+
for (const change of changes)
|
|
1577
|
+
lines.push(`- ${change}`);
|
|
1578
|
+
lines.push("", "## Validation", "");
|
|
1579
|
+
for (const step of validation)
|
|
1580
|
+
lines.push(`- [ ] ${step}`);
|
|
1581
|
+
lines.push("- [ ] Existing tests pass", "");
|
|
1582
|
+
return `${lines.join(`
|
|
1583
|
+
`).trimEnd()}
|
|
1584
|
+
`;
|
|
1585
|
+
}
|
|
1586
|
+
function reviewWorkingDir(storeReviewsDir, slug) {
|
|
1587
|
+
return join10(storeReviewsDir, slug);
|
|
1588
|
+
}
|
|
1589
|
+
function renderCommentBody(finding) {
|
|
1590
|
+
const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
|
|
1591
|
+
const reference = finding.reference ? `
|
|
1592
|
+
|
|
1593
|
+
> **Reference:** ${finding.reference}` : "";
|
|
1594
|
+
return `${prefix}${finding.body}${reference}`;
|
|
1595
|
+
}
|
|
369
1596
|
// src/setup.ts
|
|
370
|
-
import {
|
|
371
|
-
import {
|
|
1597
|
+
import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
|
|
1598
|
+
import { readdir as readdir2, readFile as readFile7 } from "node:fs/promises";
|
|
1599
|
+
import { homedir as homedir8 } from "node:os";
|
|
1600
|
+
import { basename as basename5, join as join11 } from "node:path";
|
|
372
1601
|
var MISE_DEPENDENCIES = [
|
|
373
1602
|
{ name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
|
|
374
1603
|
{ name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
|
|
@@ -399,9 +1628,14 @@ var PI_SKILL_SOURCES = [
|
|
|
399
1628
|
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
|
|
400
1629
|
];
|
|
401
1630
|
var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
|
|
1631
|
+
var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
|
|
1632
|
+
var FORGE_DEPENDENCIES = {
|
|
1633
|
+
github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
|
|
1634
|
+
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
1635
|
+
};
|
|
402
1636
|
async function ensureMise(options = {}) {
|
|
403
|
-
const homeDir = options.homeDir ??
|
|
404
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
1637
|
+
const homeDir = options.homeDir ?? homedir8();
|
|
1638
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join11(homeDir, ".local", "bin", "mise"));
|
|
405
1639
|
if (current)
|
|
406
1640
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
407
1641
|
reportProgress(options, "Installing mise");
|
|
@@ -430,7 +1664,10 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
|
|
|
430
1664
|
async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
431
1665
|
const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
|
|
432
1666
|
const actions = [];
|
|
433
|
-
|
|
1667
|
+
const dependencies = [...MISE_DEPENDENCIES];
|
|
1668
|
+
if (options.forge && options.forge !== "none")
|
|
1669
|
+
dependencies.push(FORGE_DEPENDENCIES[options.forge]);
|
|
1670
|
+
for (const dependency of dependencies) {
|
|
434
1671
|
const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
|
|
435
1672
|
if (installed) {
|
|
436
1673
|
actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
|
|
@@ -446,18 +1683,33 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
446
1683
|
async function ensurePiPlugins(options = {}) {
|
|
447
1684
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
448
1685
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
449
|
-
const webSearch = await pi.configEnsure(
|
|
1686
|
+
const webSearch = await pi.configEnsure(join11(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
450
1687
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
451
|
-
const lsp = await pi.configEnsure(
|
|
1688
|
+
const lsp = await pi.configEnsure(join11(agentDir, "pi-lsp.json"), (config) => ({
|
|
452
1689
|
...config,
|
|
453
1690
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
454
1691
|
}), options.dryRun);
|
|
455
1692
|
actions.push(getConfigSetupAction("pi-lsp settings", lsp));
|
|
456
1693
|
return actions;
|
|
457
1694
|
}
|
|
1695
|
+
async function ensurePiAgents(options = {}) {
|
|
1696
|
+
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
1697
|
+
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
|
|
1698
|
+
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
1699
|
+
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));
|
|
1700
|
+
const actions = [];
|
|
1701
|
+
for (const entry of entries) {
|
|
1702
|
+
const id = basename5(entry.name, ".md").replace(/^diffpi-/, "");
|
|
1703
|
+
const source = await readFile7(join11(bundledAgentsDir, entry.name), "utf8");
|
|
1704
|
+
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
1705
|
+
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
1706
|
+
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
1707
|
+
}
|
|
1708
|
+
return actions;
|
|
1709
|
+
}
|
|
458
1710
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
459
1711
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
460
|
-
const sharedSkillsDir =
|
|
1712
|
+
const sharedSkillsDir = join11(options.homeDir ?? homedir8(), ".agents", "skills");
|
|
461
1713
|
const actions = [];
|
|
462
1714
|
for (const source of PI_SKILL_SOURCES) {
|
|
463
1715
|
const missing = [];
|
|
@@ -501,6 +1753,12 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
|
|
|
501
1753
|
} else if (options.issueTracker === "jira") {
|
|
502
1754
|
servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
|
|
503
1755
|
}
|
|
1756
|
+
if (options.forge === "github") {
|
|
1757
|
+
servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
|
|
1758
|
+
} else if (options.forge === "gitlab") {
|
|
1759
|
+
const host = (await detectVcs(projectDir)).host || "gitlab.com";
|
|
1760
|
+
servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
|
|
1761
|
+
}
|
|
504
1762
|
const result = await mcp.serversEnsure(servers, {
|
|
505
1763
|
dryRun: options.dryRun,
|
|
506
1764
|
path: mcp.globalConfigPath(options.homeDir)
|
|
@@ -514,13 +1772,81 @@ async function setupPi(options = {}) {
|
|
|
514
1772
|
actions.push(await ensureMiseHooks(miseResult.executable, options));
|
|
515
1773
|
actions.push(...await ensureMiseDeps(miseResult.executable, options));
|
|
516
1774
|
actions.push(...await ensurePiPlugins(options));
|
|
1775
|
+
actions.push(...await ensurePiAgents(options));
|
|
517
1776
|
actions.push(...await ensurePiSkills(miseResult.executable, options));
|
|
518
1777
|
actions.push(...await ensureMcpAdapters(miseResult.executable, options));
|
|
1778
|
+
if (options.bindZedKey)
|
|
1779
|
+
actions.push(...await ensureZedIntegration(options));
|
|
519
1780
|
return {
|
|
520
1781
|
actions,
|
|
521
|
-
restartPi: actions
|
|
1782
|
+
restartPi: setupRequiresRestart(actions)
|
|
522
1783
|
};
|
|
523
1784
|
}
|
|
1785
|
+
function setupRequiresRestart(actions) {
|
|
1786
|
+
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"));
|
|
1787
|
+
}
|
|
1788
|
+
function materializeAgentModels(content, agentId, config, availableModels) {
|
|
1789
|
+
const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
1790
|
+
const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
|
|
1791
|
+
const preferences = resolveAgentModelPreferences(agentId, profilePreferences, config);
|
|
1792
|
+
let selectedIndex = availableModels === undefined && preferences.length > 0 ? 0 : -1;
|
|
1793
|
+
let selectedModel = selectedIndex === 0 ? preferences[0] : undefined;
|
|
1794
|
+
if (availableModels) {
|
|
1795
|
+
for (const [index, preference] of preferences.entries()) {
|
|
1796
|
+
const match = findPreferredModel(availableModels, preference);
|
|
1797
|
+
if (!match)
|
|
1798
|
+
continue;
|
|
1799
|
+
selectedIndex = index;
|
|
1800
|
+
selectedModel = `${match.provider}/${match.id}`;
|
|
1801
|
+
break;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
const fallbacks = preferences.filter((_preference, index) => index !== selectedIndex);
|
|
1805
|
+
return replaceAgentModelFields(content, selectedModel, fallbacks);
|
|
1806
|
+
}
|
|
1807
|
+
function replaceAgentModelFields(content, model, fallbacks) {
|
|
1808
|
+
const newline = content.includes(`\r
|
|
1809
|
+
`) ? `\r
|
|
1810
|
+
` : `
|
|
1811
|
+
`;
|
|
1812
|
+
const lines = content.replaceAll(`\r
|
|
1813
|
+
`, `
|
|
1814
|
+
`).split(`
|
|
1815
|
+
`);
|
|
1816
|
+
const closingDelimiter = lines.indexOf("---", 1);
|
|
1817
|
+
if (lines[0] !== "---" || closingDelimiter < 0)
|
|
1818
|
+
return content;
|
|
1819
|
+
const frontmatter = lines.slice(1, closingDelimiter).filter((line) => !/^model(?:_fallbacks)?:/.test(line));
|
|
1820
|
+
if (model)
|
|
1821
|
+
frontmatter.push(`model: ${model}`);
|
|
1822
|
+
if (fallbacks.length > 0)
|
|
1823
|
+
frontmatter.push(`model_fallbacks: ${fallbacks.join(", ")}`);
|
|
1824
|
+
return ["---", ...frontmatter, "---", ...lines.slice(closingDelimiter + 1)].join(newline);
|
|
1825
|
+
}
|
|
1826
|
+
async function ensureZedIntegration(options = {}) {
|
|
1827
|
+
if (options.dryRun) {
|
|
1828
|
+
const actions = [createSetupAction("Zed review task", "planned", "tasks.json")];
|
|
1829
|
+
if (options.bindZedKey)
|
|
1830
|
+
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
1831
|
+
return actions;
|
|
1832
|
+
}
|
|
1833
|
+
const actions = [];
|
|
1834
|
+
try {
|
|
1835
|
+
const task = await ensureZedReviewTask(options.homeDir);
|
|
1836
|
+
actions.push(createSetupAction("Zed review task", task.changed ? "installed" : "ready", task.path));
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
actions.push(createSetupAction("Zed review task", "skipped", error instanceof Error ? error.message : String(error)));
|
|
1839
|
+
}
|
|
1840
|
+
if (options.bindZedKey) {
|
|
1841
|
+
try {
|
|
1842
|
+
const key = await ensureZedReviewKeybinding(options.homeDir);
|
|
1843
|
+
actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
|
|
1844
|
+
} catch (error) {
|
|
1845
|
+
actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
return actions;
|
|
1849
|
+
}
|
|
524
1850
|
async function ensurePiPackages(packages, options) {
|
|
525
1851
|
const executable = await pi.executableCheck();
|
|
526
1852
|
if (!executable && !options.dryRun)
|
|
@@ -542,6 +1868,10 @@ ${source}`;
|
|
|
542
1868
|
}
|
|
543
1869
|
return actions;
|
|
544
1870
|
}
|
|
1871
|
+
function getTextList(value) {
|
|
1872
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
1873
|
+
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
1874
|
+
}
|
|
545
1875
|
function getConfigSetupAction(name, result) {
|
|
546
1876
|
if (!result.changed)
|
|
547
1877
|
return createSetupAction(name, "ready", result.path);
|
|
@@ -559,14 +1889,63 @@ function getRecord(value) {
|
|
|
559
1889
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
560
1890
|
}
|
|
561
1891
|
export {
|
|
1892
|
+
CONVENTIONAL_COMMIT,
|
|
1893
|
+
ZED_REVIEW_TASK_NAME,
|
|
1894
|
+
addComment,
|
|
1895
|
+
checkConventionalSubject,
|
|
1896
|
+
ciGate,
|
|
1897
|
+
computeProjectSlug,
|
|
1898
|
+
createForge,
|
|
1899
|
+
createModeController,
|
|
1900
|
+
dedupeFindings,
|
|
1901
|
+
detectIde,
|
|
1902
|
+
detectMux,
|
|
1903
|
+
detectShell,
|
|
1904
|
+
detectVcs,
|
|
1905
|
+
diffpiConfigPaths,
|
|
1906
|
+
discoverAgentModes,
|
|
562
1907
|
ensureMcpAdapters,
|
|
563
1908
|
ensureMise,
|
|
564
1909
|
ensureMiseDeps,
|
|
565
1910
|
ensureMiseHooks,
|
|
1911
|
+
ensurePiAgents,
|
|
566
1912
|
ensurePiPlugins,
|
|
567
1913
|
ensurePiSkills,
|
|
1914
|
+
ensureStore,
|
|
1915
|
+
ensureZedReviewKeybinding,
|
|
1916
|
+
ensureZedReviewTask,
|
|
1917
|
+
findPreferredModel,
|
|
1918
|
+
findingSchema,
|
|
1919
|
+
findingsSchema,
|
|
1920
|
+
gitToplevel,
|
|
1921
|
+
launch,
|
|
1922
|
+
listSessions,
|
|
1923
|
+
loadDiffpiConfig,
|
|
568
1924
|
mcp,
|
|
569
1925
|
mise,
|
|
1926
|
+
mmddyy,
|
|
1927
|
+
openInNewTab,
|
|
1928
|
+
parseRemote,
|
|
570
1929
|
pi,
|
|
571
|
-
|
|
1930
|
+
readSession,
|
|
1931
|
+
renderPrBody,
|
|
1932
|
+
renderReviewDoc,
|
|
1933
|
+
resolveAgentMode,
|
|
1934
|
+
resolveAgentModelPreferences,
|
|
1935
|
+
resolveSession,
|
|
1936
|
+
reviewRecordName,
|
|
1937
|
+
reviewSlug,
|
|
1938
|
+
reviewWorkingDir,
|
|
1939
|
+
reviewsDir,
|
|
1940
|
+
runMiseGates,
|
|
1941
|
+
sessionsDir,
|
|
1942
|
+
setupPi,
|
|
1943
|
+
severitySchema,
|
|
1944
|
+
storeDir,
|
|
1945
|
+
storeGlobalRoot,
|
|
1946
|
+
toFindings,
|
|
1947
|
+
toReviewComments,
|
|
1948
|
+
tuicrAvailable,
|
|
1949
|
+
zedKeymapPath,
|
|
1950
|
+
zedTasksPath
|
|
572
1951
|
};
|