@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/extensions/index.js
CHANGED
|
@@ -13323,11 +13323,376 @@ function rpiv_ask_user_question_default(pi) {
|
|
|
13323
13323
|
registerAskUserQuestionReconciler(pi);
|
|
13324
13324
|
}
|
|
13325
13325
|
|
|
13326
|
+
// src/modes.ts
|
|
13327
|
+
import {
|
|
13328
|
+
getAgentDir,
|
|
13329
|
+
parseFrontmatter as parseFrontmatter2
|
|
13330
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13331
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
13332
|
+
import { homedir as homedir3 } from "node:os";
|
|
13333
|
+
import { basename, extname, join as join5 } from "node:path";
|
|
13334
|
+
|
|
13335
|
+
// src/assets.ts
|
|
13336
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
13337
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
13338
|
+
import { fileURLToPath } from "node:url";
|
|
13339
|
+
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
13340
|
+
const moduleDir = dirname2(fileURLToPath(moduleUrl));
|
|
13341
|
+
const candidates = [
|
|
13342
|
+
join3(moduleDir, "agents"),
|
|
13343
|
+
join3(moduleDir, "..", "agents"),
|
|
13344
|
+
join3(moduleDir, "..", "..", "agents")
|
|
13345
|
+
];
|
|
13346
|
+
return candidates.find((path) => existsSync2(path)) ?? candidates[1];
|
|
13347
|
+
}
|
|
13348
|
+
|
|
13349
|
+
// src/config.ts
|
|
13350
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
13351
|
+
import { z } from "zod";
|
|
13352
|
+
import { homedir as homedir2 } from "node:os";
|
|
13353
|
+
import { join as join4 } from "node:path";
|
|
13354
|
+
|
|
13355
|
+
// src/fsx.ts
|
|
13356
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
13357
|
+
async function readDirectoryIfExists(path) {
|
|
13358
|
+
try {
|
|
13359
|
+
return await readdir(path, { withFileTypes: true });
|
|
13360
|
+
} catch (error) {
|
|
13361
|
+
if (isMissingPath(error))
|
|
13362
|
+
return [];
|
|
13363
|
+
throw error;
|
|
13364
|
+
}
|
|
13365
|
+
}
|
|
13366
|
+
async function readTextIfExists(path) {
|
|
13367
|
+
try {
|
|
13368
|
+
return await readFile(path, "utf8");
|
|
13369
|
+
} catch (error) {
|
|
13370
|
+
if (isMissingPath(error))
|
|
13371
|
+
return;
|
|
13372
|
+
throw error;
|
|
13373
|
+
}
|
|
13374
|
+
}
|
|
13375
|
+
function isMissingPath(error) {
|
|
13376
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
13377
|
+
}
|
|
13378
|
+
|
|
13379
|
+
// src/config.ts
|
|
13380
|
+
var modelReferenceSchema = z.string().trim().min(1);
|
|
13381
|
+
var agentConfigSchema = z.object({
|
|
13382
|
+
models: z.array(modelReferenceSchema).optional()
|
|
13383
|
+
}).strict();
|
|
13384
|
+
var diffpiConfigSchema = z.object({
|
|
13385
|
+
agents: z.record(z.string(), agentConfigSchema).optional()
|
|
13386
|
+
}).strict();
|
|
13387
|
+
function diffpiConfigPaths(homeDir = homedir2()) {
|
|
13388
|
+
const directory = join4(homeDir, ".difflab", "diffpi");
|
|
13389
|
+
return {
|
|
13390
|
+
yaml: join4(directory, "config.yaml"),
|
|
13391
|
+
json: join4(directory, "config.json")
|
|
13392
|
+
};
|
|
13393
|
+
}
|
|
13394
|
+
async function loadDiffpiConfig(options = {}) {
|
|
13395
|
+
const paths = diffpiConfigPaths(options.homeDir);
|
|
13396
|
+
for (const [format, path] of [
|
|
13397
|
+
["yaml", paths.yaml],
|
|
13398
|
+
["json", paths.json]
|
|
13399
|
+
]) {
|
|
13400
|
+
const content = await readTextIfExists(path);
|
|
13401
|
+
if (content === undefined)
|
|
13402
|
+
continue;
|
|
13403
|
+
try {
|
|
13404
|
+
const value = format === "yaml" ? parseYamlConfig(content) : JSON.parse(content);
|
|
13405
|
+
return { config: diffpiConfigSchema.parse(value ?? {}), path };
|
|
13406
|
+
} catch (error) {
|
|
13407
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
13408
|
+
throw new Error(`Invalid Diffpi config at ${path}: ${reason}`, { cause: error });
|
|
13409
|
+
}
|
|
13410
|
+
}
|
|
13411
|
+
return { config: {} };
|
|
13412
|
+
}
|
|
13413
|
+
function resolveAgentModelPreferences(agentId, profilePreferences, config) {
|
|
13414
|
+
const override = config.agents?.[agentId];
|
|
13415
|
+
if (override && Object.hasOwn(override, "models"))
|
|
13416
|
+
return [...override.models ?? []];
|
|
13417
|
+
return [...profilePreferences];
|
|
13418
|
+
}
|
|
13419
|
+
function findPreferredModel(models, preference) {
|
|
13420
|
+
const normalizedPreference = normalizeModelReference(preference);
|
|
13421
|
+
const exactReference = models.find((model) => normalizeModelReference(`${model.provider}/${model.id}`) === normalizedPreference);
|
|
13422
|
+
if (exactReference)
|
|
13423
|
+
return exactReference;
|
|
13424
|
+
const idPreference = preference.includes("/") ? preference.slice(preference.indexOf("/") + 1) : preference;
|
|
13425
|
+
const normalizedIdPreference = normalizeModelReference(idPreference);
|
|
13426
|
+
const exactId = models.find((model) => normalizeModelReference(model.id) === normalizedIdPreference);
|
|
13427
|
+
if (exactId)
|
|
13428
|
+
return exactId;
|
|
13429
|
+
const preferenceTokens = normalizedIdPreference.split("-").filter(Boolean);
|
|
13430
|
+
return models.find((model) => {
|
|
13431
|
+
const modelTokens = new Set(normalizeModelReference(model.id).split("-").filter(Boolean));
|
|
13432
|
+
return preferenceTokens.every((token) => modelTokens.has(token));
|
|
13433
|
+
});
|
|
13434
|
+
}
|
|
13435
|
+
function parseYamlConfig(content) {
|
|
13436
|
+
const document = content.replace(/^\uFEFF/, "").replace(/^---[^\S\r\n]*(?:#.*)?(?:\r?\n|$)/, "");
|
|
13437
|
+
return parseFrontmatter(`---
|
|
13438
|
+
${document}
|
|
13439
|
+
---
|
|
13440
|
+
`).frontmatter;
|
|
13441
|
+
}
|
|
13442
|
+
function normalizeModelReference(value) {
|
|
13443
|
+
return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
13444
|
+
}
|
|
13445
|
+
|
|
13446
|
+
// src/modes.ts
|
|
13447
|
+
async function discoverAgentModes(options) {
|
|
13448
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
13449
|
+
const homeDir = options.homeDir ?? homedir3();
|
|
13450
|
+
const modes = new Map;
|
|
13451
|
+
const diagnostics = [];
|
|
13452
|
+
await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
|
|
13453
|
+
await loadAgentModes(join5(agentDir, "agents"), "user agent", modes, diagnostics);
|
|
13454
|
+
if (options.includeSkills) {
|
|
13455
|
+
await loadSkillModes(join5(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
|
|
13456
|
+
await loadSkillModes(join5(agentDir, "skills"), "pi user skill", modes, diagnostics);
|
|
13457
|
+
}
|
|
13458
|
+
if (options.projectTrusted === true) {
|
|
13459
|
+
if (options.includeSkills) {
|
|
13460
|
+
await loadSkillModes(join5(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
|
|
13461
|
+
await loadSkillModes(join5(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
|
|
13462
|
+
}
|
|
13463
|
+
await loadAgentModes(join5(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
|
|
13464
|
+
await loadAgentModes(join5(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
|
|
13465
|
+
}
|
|
13466
|
+
const userConfig = await loadDiffpiConfig({ homeDir });
|
|
13467
|
+
const configuredModes = [...modes.values()].map((mode) => ({
|
|
13468
|
+
...mode,
|
|
13469
|
+
modelPreferences: resolveAgentModelPreferences(mode.id, mode.modelPreferences, userConfig.config)
|
|
13470
|
+
}));
|
|
13471
|
+
return {
|
|
13472
|
+
modes: configuredModes.sort((left, right) => left.id.localeCompare(right.id)),
|
|
13473
|
+
diagnostics
|
|
13474
|
+
};
|
|
13475
|
+
}
|
|
13476
|
+
function resolveAgentMode(modes, requested) {
|
|
13477
|
+
const name = requested.trim();
|
|
13478
|
+
if (!name)
|
|
13479
|
+
return { ok: false, message: "Agent name is required." };
|
|
13480
|
+
const exact = modes.find((mode) => mode.id === name);
|
|
13481
|
+
if (exact)
|
|
13482
|
+
return { ok: true, active: exact, message: `Active inline agent: ${exact.id}.` };
|
|
13483
|
+
const lowerName = name.toLowerCase();
|
|
13484
|
+
const matches = modes.filter((mode) => mode.id.toLowerCase() === lowerName);
|
|
13485
|
+
if (matches.length === 1) {
|
|
13486
|
+
const active = matches[0];
|
|
13487
|
+
return { ok: true, active, message: `Active inline agent: ${active.id}.` };
|
|
13488
|
+
}
|
|
13489
|
+
if (matches.length > 1) {
|
|
13490
|
+
return {
|
|
13491
|
+
ok: false,
|
|
13492
|
+
message: `Inline agent "${name}" is ambiguous. Use one of: ${matches.map((mode) => mode.id).join(", ")}.`
|
|
13493
|
+
};
|
|
13494
|
+
}
|
|
13495
|
+
return { ok: false, message: `Unknown inline agent "${name}". Run /skill:mode or diffpi_modes_list.` };
|
|
13496
|
+
}
|
|
13497
|
+
function createModeController(pi, options = {}) {
|
|
13498
|
+
let active;
|
|
13499
|
+
let baseline;
|
|
13500
|
+
const updateStatus = (ctx) => {
|
|
13501
|
+
ctx.ui.setStatus(MODE_STATUS_KEY, active ? `mode: ${active.id}` : undefined);
|
|
13502
|
+
};
|
|
13503
|
+
const list = (ctx, listOptions = {}) => discoverAgentModes({
|
|
13504
|
+
cwd: ctx.cwd,
|
|
13505
|
+
agentDir: options.agentDir,
|
|
13506
|
+
bundledAgentsDir: options.bundledAgentsDir,
|
|
13507
|
+
homeDir: options.homeDir,
|
|
13508
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
13509
|
+
includeSkills: listOptions.includeSkills
|
|
13510
|
+
});
|
|
13511
|
+
return {
|
|
13512
|
+
list,
|
|
13513
|
+
async set(agent, ctx) {
|
|
13514
|
+
const catalog = await list(ctx, { includeSkills: agent.includes(":") });
|
|
13515
|
+
const result = resolveAgentMode(catalog.modes, agent);
|
|
13516
|
+
if (!result.ok || !result.active)
|
|
13517
|
+
return result;
|
|
13518
|
+
baseline ??= captureRuntime(pi, ctx);
|
|
13519
|
+
if (active && baseline)
|
|
13520
|
+
await restoreRuntime(pi, baseline, ctx);
|
|
13521
|
+
active = result.active;
|
|
13522
|
+
const runtimeMessage = await applyModeRuntime(pi, active, ctx);
|
|
13523
|
+
pi.appendEntry(MODE_STATE_ENTRY, { active, baseline });
|
|
13524
|
+
updateStatus(ctx);
|
|
13525
|
+
return { ...result, message: `${result.message} ${runtimeMessage}` };
|
|
13526
|
+
},
|
|
13527
|
+
async unset(ctx) {
|
|
13528
|
+
if (!active)
|
|
13529
|
+
return { ok: true, message: "Inline agent is already clear." };
|
|
13530
|
+
if (baseline)
|
|
13531
|
+
await restoreRuntime(pi, baseline, ctx);
|
|
13532
|
+
active = undefined;
|
|
13533
|
+
pi.appendEntry(MODE_STATE_ENTRY, { active: null });
|
|
13534
|
+
baseline = undefined;
|
|
13535
|
+
updateStatus(ctx);
|
|
13536
|
+
return { ok: true, message: "Inline agent cleared. The previous model, thinking, tools, and prompt resume." };
|
|
13537
|
+
},
|
|
13538
|
+
async restore(ctx) {
|
|
13539
|
+
const previousActive = active;
|
|
13540
|
+
const previousBaseline = baseline;
|
|
13541
|
+
const entry = [...ctx.sessionManager.getBranch()].reverse().find((candidate) => candidate.type === "custom" && candidate.customType === MODE_STATE_ENTRY);
|
|
13542
|
+
const restored = entry?.data?.active;
|
|
13543
|
+
const restoredBaseline = entry?.data?.baseline;
|
|
13544
|
+
if (isAgentModeSnapshot(restored)) {
|
|
13545
|
+
active = restored;
|
|
13546
|
+
baseline = isModeBaseline(restoredBaseline) ? restoredBaseline : previousBaseline;
|
|
13547
|
+
await applyModeRuntime(pi, active, ctx);
|
|
13548
|
+
} else {
|
|
13549
|
+
if (previousActive && previousBaseline)
|
|
13550
|
+
pi.setActiveTools(previousBaseline.tools);
|
|
13551
|
+
active = undefined;
|
|
13552
|
+
baseline = undefined;
|
|
13553
|
+
}
|
|
13554
|
+
updateStatus(ctx);
|
|
13555
|
+
},
|
|
13556
|
+
apply(systemPrompt) {
|
|
13557
|
+
if (!active)
|
|
13558
|
+
return systemPrompt;
|
|
13559
|
+
if (active.promptStrategy === "replace")
|
|
13560
|
+
return active.systemPrompt;
|
|
13561
|
+
return `${systemPrompt}
|
|
13562
|
+
|
|
13563
|
+
## Active inline agent: ${active.label}
|
|
13564
|
+
|
|
13565
|
+
${active.systemPrompt}`;
|
|
13566
|
+
},
|
|
13567
|
+
getActive() {
|
|
13568
|
+
return active;
|
|
13569
|
+
}
|
|
13570
|
+
};
|
|
13571
|
+
}
|
|
13572
|
+
var MODE_STATE_ENTRY = "diffpi-mode-state";
|
|
13573
|
+
var MODE_STATUS_KEY = "diffpi-mode";
|
|
13574
|
+
var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
|
|
13575
|
+
var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
|
|
13576
|
+
var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
13577
|
+
async function applyModeRuntime(pi, mode, ctx) {
|
|
13578
|
+
let selectedModel;
|
|
13579
|
+
if (mode.modelPreferences.length > 0) {
|
|
13580
|
+
const scoped = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : undefined;
|
|
13581
|
+
const availableModels = scoped ?? ctx.modelRegistry.getAvailable();
|
|
13582
|
+
for (const preference of mode.modelPreferences) {
|
|
13583
|
+
const model = findPreferredModel(availableModels, preference);
|
|
13584
|
+
if (model && await pi.setModel(model)) {
|
|
13585
|
+
selectedModel = `${model.provider}/${model.id}`;
|
|
13586
|
+
break;
|
|
13587
|
+
}
|
|
13588
|
+
}
|
|
13589
|
+
}
|
|
13590
|
+
if (mode.thinkingLevel)
|
|
13591
|
+
pi.setThinkingLevel(mode.thinkingLevel);
|
|
13592
|
+
if (mode.tools.length > 0) {
|
|
13593
|
+
const availableTools = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
13594
|
+
const selectedTools = [...new Set([...mode.tools, ...MODE_CONTROL_TOOLS])].filter((tool) => availableTools.has(tool));
|
|
13595
|
+
if (selectedTools.length > 0)
|
|
13596
|
+
pi.setActiveTools(selectedTools);
|
|
13597
|
+
}
|
|
13598
|
+
const parts = [];
|
|
13599
|
+
if (mode.modelPreferences.length > 0) {
|
|
13600
|
+
parts.push(selectedModel ? `Model: ${selectedModel}.` : "No preferred model was available; kept the current model.");
|
|
13601
|
+
}
|
|
13602
|
+
if (mode.thinkingLevel)
|
|
13603
|
+
parts.push(`Thinking: ${mode.thinkingLevel}.`);
|
|
13604
|
+
if (mode.tools.length > 0)
|
|
13605
|
+
parts.push("Applied the profile tool set.");
|
|
13606
|
+
return parts.join(" ") || "The profile changes the prompt only.";
|
|
13607
|
+
}
|
|
13608
|
+
async function restoreRuntime(pi, state, ctx) {
|
|
13609
|
+
if (state.model) {
|
|
13610
|
+
const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
|
|
13611
|
+
if (model)
|
|
13612
|
+
await pi.setModel(model);
|
|
13613
|
+
}
|
|
13614
|
+
pi.setThinkingLevel(state.thinkingLevel);
|
|
13615
|
+
pi.setActiveTools(state.tools);
|
|
13616
|
+
}
|
|
13617
|
+
function captureRuntime(pi, ctx) {
|
|
13618
|
+
return {
|
|
13619
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
|
|
13620
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
13621
|
+
tools: pi.getActiveTools()
|
|
13622
|
+
};
|
|
13623
|
+
}
|
|
13624
|
+
async function loadSkillModes(skillsDir, source, modes, diagnostics) {
|
|
13625
|
+
const entries = await readDirectoryIfExists(skillsDir);
|
|
13626
|
+
for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
13627
|
+
await loadAgentModes(join5(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
|
|
13628
|
+
}
|
|
13629
|
+
}
|
|
13630
|
+
async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
|
|
13631
|
+
const entries = await readDirectoryIfExists(directory);
|
|
13632
|
+
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
13633
|
+
const path = join5(directory, entry.name);
|
|
13634
|
+
try {
|
|
13635
|
+
const content = await readFile2(path, "utf8");
|
|
13636
|
+
const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
13637
|
+
if (frontmatter.enabled === false || frontmatter.inline === false)
|
|
13638
|
+
continue;
|
|
13639
|
+
const name = getFrontmatterText(frontmatter.name) ?? basename(path, extname(path));
|
|
13640
|
+
const systemPrompt = body.trim();
|
|
13641
|
+
if (!name || name.includes(":") || !systemPrompt) {
|
|
13642
|
+
diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
|
|
13643
|
+
continue;
|
|
13644
|
+
}
|
|
13645
|
+
const id = skillName ? `${skillName}:${name}` : name;
|
|
13646
|
+
modes.set(id, {
|
|
13647
|
+
id,
|
|
13648
|
+
label: getFrontmatterText(frontmatter.display_name) ?? name,
|
|
13649
|
+
description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename(path)}`,
|
|
13650
|
+
systemPrompt,
|
|
13651
|
+
promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
|
|
13652
|
+
modelPreferences: [
|
|
13653
|
+
...getFrontmatterList(frontmatter.model),
|
|
13654
|
+
...getFrontmatterList(frontmatter.model_fallbacks)
|
|
13655
|
+
],
|
|
13656
|
+
thinkingLevel: getThinkingLevel(frontmatter.thinking),
|
|
13657
|
+
tools: getFrontmatterList(frontmatter.tools),
|
|
13658
|
+
source,
|
|
13659
|
+
sourcePath: path
|
|
13660
|
+
});
|
|
13661
|
+
} catch (error) {
|
|
13662
|
+
diagnostics.push(`Skipped ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
13663
|
+
}
|
|
13664
|
+
}
|
|
13665
|
+
}
|
|
13666
|
+
function getFrontmatterText(value) {
|
|
13667
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
13668
|
+
}
|
|
13669
|
+
function getFrontmatterList(value) {
|
|
13670
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
13671
|
+
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
13672
|
+
}
|
|
13673
|
+
function getThinkingLevel(value) {
|
|
13674
|
+
const level = getFrontmatterText(value);
|
|
13675
|
+
return level && THINKING_LEVELS.has(level) ? level : undefined;
|
|
13676
|
+
}
|
|
13677
|
+
function isAgentModeSnapshot(value) {
|
|
13678
|
+
if (!value || typeof value !== "object")
|
|
13679
|
+
return false;
|
|
13680
|
+
const candidate = value;
|
|
13681
|
+
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";
|
|
13682
|
+
}
|
|
13683
|
+
function isModeBaseline(value) {
|
|
13684
|
+
if (!value || typeof value !== "object")
|
|
13685
|
+
return false;
|
|
13686
|
+
const candidate = value;
|
|
13687
|
+
const model = candidate.model;
|
|
13688
|
+
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");
|
|
13689
|
+
}
|
|
13690
|
+
|
|
13326
13691
|
// src/tools/reload.ts
|
|
13327
13692
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
13328
|
-
import { z } from "zod";
|
|
13329
|
-
var reloadParametersSchema =
|
|
13330
|
-
var reloadParameters =
|
|
13693
|
+
import { z as z2 } from "zod";
|
|
13694
|
+
var reloadParametersSchema = z2.object({});
|
|
13695
|
+
var reloadParameters = z2.toJSONSchema(reloadParametersSchema, { io: "input" });
|
|
13331
13696
|
function createDiffpiReloadTool(pi) {
|
|
13332
13697
|
return defineTool({
|
|
13333
13698
|
name: "diffpi_reload",
|
|
@@ -13350,83 +13715,119 @@ function createDiffpiReloadTool(pi) {
|
|
|
13350
13715
|
});
|
|
13351
13716
|
}
|
|
13352
13717
|
|
|
13353
|
-
// src/tools/
|
|
13718
|
+
// src/tools/modes.ts
|
|
13354
13719
|
import { defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
|
|
13355
|
-
import { z as
|
|
13356
|
-
|
|
13357
|
-
|
|
13358
|
-
|
|
13359
|
-
|
|
13360
|
-
|
|
13361
|
-
|
|
13362
|
-
|
|
13363
|
-
|
|
13364
|
-
|
|
13365
|
-
var
|
|
13366
|
-
|
|
13367
|
-
|
|
13368
|
-
|
|
13369
|
-
|
|
13370
|
-
|
|
13371
|
-
|
|
13372
|
-
|
|
13373
|
-
|
|
13374
|
-
|
|
13375
|
-
|
|
13376
|
-
|
|
13377
|
-
|
|
13378
|
-
|
|
13379
|
-
|
|
13380
|
-
|
|
13381
|
-
|
|
13382
|
-
|
|
13383
|
-
|
|
13384
|
-
|
|
13385
|
-
|
|
13386
|
-
};
|
|
13387
|
-
|
|
13388
|
-
|
|
13389
|
-
|
|
13390
|
-
|
|
13391
|
-
|
|
13720
|
+
import { z as z3 } from "zod";
|
|
13721
|
+
var emptyParametersSchema = z3.object({});
|
|
13722
|
+
var emptyParameters = z3.toJSONSchema(emptyParametersSchema, { io: "input" });
|
|
13723
|
+
var listParametersSchema = z3.object({
|
|
13724
|
+
includeSkills: z3.boolean().optional().describe("Include skill-owned agents using skill:agent ids.")
|
|
13725
|
+
});
|
|
13726
|
+
var listParameters = z3.toJSONSchema(listParametersSchema, { io: "input" });
|
|
13727
|
+
var setParametersSchema = z3.object({
|
|
13728
|
+
agent: z3.string().trim().min(1).describe("Inline agent id from diffpi_modes_list.")
|
|
13729
|
+
});
|
|
13730
|
+
var setParameters = z3.toJSONSchema(setParametersSchema, { io: "input" });
|
|
13731
|
+
function createModeTools(controller) {
|
|
13732
|
+
return [
|
|
13733
|
+
defineTool2({
|
|
13734
|
+
name: "diffpi_modes_list",
|
|
13735
|
+
label: "diffpi modes list",
|
|
13736
|
+
description: "List inline agents shared with the subagent plugin, optionally including skill-owned agents.",
|
|
13737
|
+
promptSnippet: "List inline agents before selecting one when the requested agent is unclear",
|
|
13738
|
+
promptGuidelines: [
|
|
13739
|
+
"Call diffpi_modes_list when the user asks which inline agents are available.",
|
|
13740
|
+
"Set includeSkills to true only when the user asks for skill agents or runs /skill:mode --include-skills.",
|
|
13741
|
+
"Inline mode applies the profile prompt, first available preferred model, thinking level, and available tools."
|
|
13742
|
+
],
|
|
13743
|
+
parameters: listParameters,
|
|
13744
|
+
executionMode: "parallel",
|
|
13745
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
13746
|
+
const params = listParametersSchema.parse(input);
|
|
13747
|
+
const catalog = await controller.list(ctx, { includeSkills: params.includeSkills });
|
|
13748
|
+
return {
|
|
13749
|
+
content: [{ type: "text", text: formatCatalog(catalog, controller.getActive()?.id) }],
|
|
13750
|
+
details: { active: controller.getActive()?.id, catalog }
|
|
13751
|
+
};
|
|
13752
|
+
}
|
|
13753
|
+
}),
|
|
13754
|
+
defineTool2({
|
|
13755
|
+
name: "diffpi_modes_set",
|
|
13756
|
+
label: "diffpi modes set",
|
|
13757
|
+
description: "Set a validated available agent as the inline behavioral agent for subsequent chat turns.",
|
|
13758
|
+
promptSnippet: "Set the inline behavioral agent only after the user chooses one",
|
|
13759
|
+
promptGuidelines: [
|
|
13760
|
+
"Call diffpi_modes_set only after the user explicitly selects an agent.",
|
|
13761
|
+
"Use the exact skill:agent id for a skill-owned agent.",
|
|
13762
|
+
"The selected prompt takes effect on the next model turn."
|
|
13763
|
+
],
|
|
13764
|
+
parameters: setParameters,
|
|
13765
|
+
executionMode: "sequential",
|
|
13766
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
13767
|
+
const params = setParametersSchema.parse(input);
|
|
13768
|
+
const result = await controller.set(params.agent, ctx);
|
|
13769
|
+
if (!result.ok)
|
|
13770
|
+
throw new Error(result.message);
|
|
13771
|
+
return {
|
|
13772
|
+
content: [{ type: "text", text: `${result.message} The prompt takes effect on the next turn.` }],
|
|
13773
|
+
details: { active: result.active }
|
|
13774
|
+
};
|
|
13775
|
+
}
|
|
13776
|
+
}),
|
|
13777
|
+
defineTool2({
|
|
13778
|
+
name: "diffpi_modes_unset",
|
|
13779
|
+
label: "diffpi modes unset",
|
|
13780
|
+
description: "Clear the inline behavioral agent and restore default Pi prompting for subsequent turns.",
|
|
13781
|
+
promptSnippet: "Clear the inline agent when the user asks for default behavior",
|
|
13782
|
+
promptGuidelines: [
|
|
13783
|
+
"Call diffpi_modes_unset only when the user explicitly asks to clear the active inline agent."
|
|
13784
|
+
],
|
|
13785
|
+
parameters: emptyParameters,
|
|
13786
|
+
executionMode: "sequential",
|
|
13787
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
13788
|
+
emptyParametersSchema.parse(input);
|
|
13789
|
+
const result = await controller.unset(ctx);
|
|
13790
|
+
return {
|
|
13791
|
+
content: [{ type: "text", text: result.message }],
|
|
13792
|
+
details: { active: controller.getActive()?.id }
|
|
13793
|
+
};
|
|
13794
|
+
}
|
|
13795
|
+
})
|
|
13796
|
+
];
|
|
13392
13797
|
}
|
|
13393
|
-
function
|
|
13394
|
-
|
|
13395
|
-
|
|
13396
|
-
|
|
13397
|
-
|
|
13398
|
-
if (!isRecord(value))
|
|
13399
|
-
throw new Error("not an object");
|
|
13400
|
-
const servers = value.mcpServers;
|
|
13401
|
-
if (servers !== undefined && !isRecord(servers))
|
|
13402
|
-
throw new Error("mcpServers is not an object");
|
|
13403
|
-
return { ...value, mcpServers: servers ?? {} };
|
|
13404
|
-
} catch {
|
|
13405
|
-
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
13798
|
+
function formatCatalog(catalog, active) {
|
|
13799
|
+
const lines = [`Active inline agent: ${active ?? "default"}.`, "", "Available inline agents:"];
|
|
13800
|
+
for (const mode of catalog.modes) {
|
|
13801
|
+
const runtime = [mode.modelPreferences[0], mode.thinkingLevel].filter(Boolean).join(", ");
|
|
13802
|
+
lines.push(`- ${mode.id} [${mode.promptStrategy}${runtime ? `; ${runtime}` : ""}] — ${sanitize(mode.description)} (${mode.source})`);
|
|
13406
13803
|
}
|
|
13407
|
-
|
|
13408
|
-
|
|
13409
|
-
|
|
13410
|
-
|
|
13411
|
-
} catch (error) {
|
|
13412
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
13413
|
-
return;
|
|
13414
|
-
throw error;
|
|
13804
|
+
if (catalog.diagnostics.length > 0) {
|
|
13805
|
+
lines.push("", "Skipped agent files:");
|
|
13806
|
+
for (const diagnostic of catalog.diagnostics)
|
|
13807
|
+
lines.push(`- ${sanitize(diagnostic)}`);
|
|
13415
13808
|
}
|
|
13809
|
+
lines.push("", "Inline mode applies the profile prompt, preferred available model, thinking level, and tool set.");
|
|
13810
|
+
return lines.join(`
|
|
13811
|
+
`);
|
|
13416
13812
|
}
|
|
13417
|
-
function
|
|
13418
|
-
return value
|
|
13813
|
+
function sanitize(value) {
|
|
13814
|
+
return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
13419
13815
|
}
|
|
13420
13816
|
|
|
13421
|
-
// src/
|
|
13422
|
-
import {
|
|
13423
|
-
import {
|
|
13424
|
-
import {
|
|
13817
|
+
// src/tools/review.ts
|
|
13818
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
13819
|
+
import { mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises";
|
|
13820
|
+
import { join as join10 } from "node:path";
|
|
13821
|
+
import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent";
|
|
13822
|
+
import { z as z5 } from "zod";
|
|
13823
|
+
|
|
13824
|
+
// src/environment.ts
|
|
13825
|
+
import { basename as basename2 } from "node:path";
|
|
13425
13826
|
|
|
13426
13827
|
// src/process.ts
|
|
13427
13828
|
import { constants } from "node:fs";
|
|
13428
13829
|
import { access } from "node:fs/promises";
|
|
13429
|
-
import { delimiter, join as
|
|
13830
|
+
import { delimiter, join as join6 } from "node:path";
|
|
13430
13831
|
import { spawn as spawn2 } from "node:child_process";
|
|
13431
13832
|
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
13432
13833
|
async function findExecutable(name) {
|
|
@@ -13441,7 +13842,7 @@ async function findExecutable(name) {
|
|
|
13441
13842
|
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
13442
13843
|
if (!directory)
|
|
13443
13844
|
continue;
|
|
13444
|
-
const candidate =
|
|
13845
|
+
const candidate = join6(directory, name);
|
|
13445
13846
|
try {
|
|
13446
13847
|
await access(candidate, constants.X_OK);
|
|
13447
13848
|
return candidate;
|
|
@@ -13454,18 +13855,35 @@ function run(command, args, options = {}) {
|
|
|
13454
13855
|
const child = spawn2(command, args, {
|
|
13455
13856
|
cwd: options.cwd,
|
|
13456
13857
|
env: options.env ?? process.env,
|
|
13457
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
13858
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
13458
13859
|
});
|
|
13459
13860
|
let stdout = "";
|
|
13460
13861
|
let stderr = "";
|
|
13461
|
-
|
|
13462
|
-
|
|
13862
|
+
const stdoutChunks = [];
|
|
13863
|
+
const stderrChunks = [];
|
|
13864
|
+
const unbounded = options.capture === "unbounded";
|
|
13865
|
+
child.stdout?.on("data", (chunk) => {
|
|
13866
|
+
const text = chunk.toString();
|
|
13867
|
+
if (unbounded)
|
|
13868
|
+
stdoutChunks.push(text);
|
|
13869
|
+
else
|
|
13870
|
+
stdout = appendBounded(stdout, text);
|
|
13463
13871
|
});
|
|
13464
|
-
child.stderr
|
|
13465
|
-
|
|
13872
|
+
child.stderr?.on("data", (chunk) => {
|
|
13873
|
+
const text = chunk.toString();
|
|
13874
|
+
if (unbounded)
|
|
13875
|
+
stderrChunks.push(text);
|
|
13876
|
+
else
|
|
13877
|
+
stderr = appendBounded(stderr, text);
|
|
13466
13878
|
});
|
|
13467
13879
|
child.on("error", reject);
|
|
13468
|
-
child.on("close", (code) => resolve({
|
|
13880
|
+
child.on("close", (code) => resolve({
|
|
13881
|
+
code: code ?? 1,
|
|
13882
|
+
stdout: unbounded ? stdoutChunks.join("") : stdout,
|
|
13883
|
+
stderr: unbounded ? stderrChunks.join("") : stderr
|
|
13884
|
+
}));
|
|
13885
|
+
if (options.input !== undefined && child.stdin)
|
|
13886
|
+
child.stdin.end(options.input);
|
|
13469
13887
|
});
|
|
13470
13888
|
}
|
|
13471
13889
|
async function runChecked(command, args, options = {}) {
|
|
@@ -13480,7 +13898,1320 @@ function appendBounded(current, next) {
|
|
|
13480
13898
|
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
13481
13899
|
}
|
|
13482
13900
|
|
|
13901
|
+
// src/zed.ts
|
|
13902
|
+
import { mkdir, readFile as readFile3, writeFile } from "node:fs/promises";
|
|
13903
|
+
import { homedir as homedir4 } from "node:os";
|
|
13904
|
+
import { dirname as dirname3, join as join7 } from "node:path";
|
|
13905
|
+
var ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
|
|
13906
|
+
var REVIEW_KEYBINDING = "cmd-alt-r";
|
|
13907
|
+
var REVIEW_TASK = {
|
|
13908
|
+
label: ZED_REVIEW_TASK_NAME,
|
|
13909
|
+
command: "tuicr",
|
|
13910
|
+
args: ["-w"],
|
|
13911
|
+
cwd: "$ZED_WORKTREE_ROOT",
|
|
13912
|
+
use_new_terminal: true,
|
|
13913
|
+
reveal: "always"
|
|
13914
|
+
};
|
|
13915
|
+
function zedTasksPath(homeDir = homedir4()) {
|
|
13916
|
+
return join7(homeDir, ".config", "zed", "tasks.json");
|
|
13917
|
+
}
|
|
13918
|
+
function zedKeymapPath(homeDir = homedir4()) {
|
|
13919
|
+
return join7(homeDir, ".config", "zed", "keymap.json");
|
|
13920
|
+
}
|
|
13921
|
+
async function ensureZedReviewTask(homeDir = homedir4()) {
|
|
13922
|
+
const path = zedTasksPath(homeDir);
|
|
13923
|
+
const currentText = await readOptional(path);
|
|
13924
|
+
const tasks = parseJsonArray(currentText, path);
|
|
13925
|
+
const index = tasks.findIndex((task) => task.label === ZED_REVIEW_TASK_NAME);
|
|
13926
|
+
const next = [...tasks];
|
|
13927
|
+
if (index >= 0)
|
|
13928
|
+
next[index] = { ...tasks[index], ...REVIEW_TASK };
|
|
13929
|
+
else
|
|
13930
|
+
next.push(REVIEW_TASK);
|
|
13931
|
+
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
13932
|
+
if (changed)
|
|
13933
|
+
await writeJson(path, next);
|
|
13934
|
+
return { path, changed, existed: currentText !== undefined };
|
|
13935
|
+
}
|
|
13936
|
+
async function ensureZedReviewKeybinding(homeDir = homedir4()) {
|
|
13937
|
+
const path = zedKeymapPath(homeDir);
|
|
13938
|
+
const currentText = await readOptional(path);
|
|
13939
|
+
const entries = parseJsonArray(currentText, path);
|
|
13940
|
+
const alreadyBound = entries.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
|
|
13941
|
+
if (alreadyBound)
|
|
13942
|
+
return { path, changed: false, existed: currentText !== undefined };
|
|
13943
|
+
const next = [
|
|
13944
|
+
...entries,
|
|
13945
|
+
{ context: "Workspace", bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_REVIEW_TASK_NAME }] } }
|
|
13946
|
+
];
|
|
13947
|
+
await writeJson(path, next);
|
|
13948
|
+
return { path, changed: true, existed: currentText !== undefined };
|
|
13949
|
+
}
|
|
13950
|
+
function bindsReviewTask(payload) {
|
|
13951
|
+
return typeof payload === "object" && payload !== null && payload.task_name === ZED_REVIEW_TASK_NAME;
|
|
13952
|
+
}
|
|
13953
|
+
async function readOptional(path) {
|
|
13954
|
+
try {
|
|
13955
|
+
return await readFile3(path, "utf8");
|
|
13956
|
+
} catch (error) {
|
|
13957
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
13958
|
+
return;
|
|
13959
|
+
throw error;
|
|
13960
|
+
}
|
|
13961
|
+
}
|
|
13962
|
+
function parseJsonArray(content, path) {
|
|
13963
|
+
if (!content?.trim())
|
|
13964
|
+
return [];
|
|
13965
|
+
let value;
|
|
13966
|
+
try {
|
|
13967
|
+
value = JSON.parse(content);
|
|
13968
|
+
} catch {
|
|
13969
|
+
throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
|
|
13970
|
+
}
|
|
13971
|
+
if (!Array.isArray(value))
|
|
13972
|
+
throw new Error(`Expected a JSON array in ${path}.`);
|
|
13973
|
+
return value;
|
|
13974
|
+
}
|
|
13975
|
+
async function writeJson(path, value) {
|
|
13976
|
+
await mkdir(dirname3(path), { recursive: true });
|
|
13977
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}
|
|
13978
|
+
`, "utf8");
|
|
13979
|
+
}
|
|
13980
|
+
|
|
13981
|
+
// src/environment.ts
|
|
13982
|
+
function detectIde(env = process.env) {
|
|
13983
|
+
const program = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
13984
|
+
if (env.ZED_TERM === "true" || program === "zed")
|
|
13985
|
+
return "zed";
|
|
13986
|
+
if (env.CURSOR_TRACE_ID || program === "cursor")
|
|
13987
|
+
return "cursor";
|
|
13988
|
+
if (env.WINDSURF_ENV || program === "windsurf")
|
|
13989
|
+
return "windsurf";
|
|
13990
|
+
if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
|
|
13991
|
+
return "jetbrains";
|
|
13992
|
+
if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
|
|
13993
|
+
return "vscode";
|
|
13994
|
+
return "unknown";
|
|
13995
|
+
}
|
|
13996
|
+
function detectMux(env = process.env) {
|
|
13997
|
+
if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
|
|
13998
|
+
return "zellij";
|
|
13999
|
+
if (env.TMUX)
|
|
14000
|
+
return "tmux";
|
|
14001
|
+
if (env.STY)
|
|
14002
|
+
return "screen";
|
|
14003
|
+
return "none";
|
|
14004
|
+
}
|
|
14005
|
+
function detectShell(env = process.env) {
|
|
14006
|
+
return env.SHELL ? basename2(env.SHELL) : "unknown";
|
|
14007
|
+
}
|
|
14008
|
+
async function detectVcs(cwd) {
|
|
14009
|
+
const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
|
|
14010
|
+
const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
|
14011
|
+
const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
|
|
14012
|
+
return { ...parseRemote(remote), branch, root };
|
|
14013
|
+
}
|
|
14014
|
+
function parseRemote(remote) {
|
|
14015
|
+
const empty = { provider: "none", host: "", owner: "", repo: "" };
|
|
14016
|
+
if (!remote)
|
|
14017
|
+
return empty;
|
|
14018
|
+
const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
|
|
14019
|
+
const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
14020
|
+
const match = scp ?? url;
|
|
14021
|
+
if (!match)
|
|
14022
|
+
return empty;
|
|
14023
|
+
const host = match[1];
|
|
14024
|
+
const segments = match[2].split("/").filter(Boolean);
|
|
14025
|
+
if (segments.length < 2)
|
|
14026
|
+
return { ...empty, host };
|
|
14027
|
+
const repo = segments.at(-1) ?? "";
|
|
14028
|
+
const owner = segments.slice(0, -1).join("/");
|
|
14029
|
+
const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
|
|
14030
|
+
return { provider, host, owner, repo };
|
|
14031
|
+
}
|
|
14032
|
+
async function openInNewTab(command, opts) {
|
|
14033
|
+
const env = opts.env ?? process.env;
|
|
14034
|
+
const name = opts.name ?? "review";
|
|
14035
|
+
const printable = command.join(" ");
|
|
14036
|
+
const mux = detectMux(env);
|
|
14037
|
+
if (mux !== "none") {
|
|
14038
|
+
const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
|
|
14039
|
+
if (opened)
|
|
14040
|
+
return opened;
|
|
14041
|
+
}
|
|
14042
|
+
if (detectIde(env) === "zed") {
|
|
14043
|
+
try {
|
|
14044
|
+
await ensureZedReviewTask(opts.homeDir);
|
|
14045
|
+
return {
|
|
14046
|
+
launched: false,
|
|
14047
|
+
configured: true,
|
|
14048
|
+
via: "zed-task",
|
|
14049
|
+
command: printable,
|
|
14050
|
+
taskName: ZED_REVIEW_TASK_NAME,
|
|
14051
|
+
instruction: `Run the Zed task "${ZED_REVIEW_TASK_NAME}".`
|
|
14052
|
+
};
|
|
14053
|
+
} catch {}
|
|
14054
|
+
}
|
|
14055
|
+
return { launched: false, via: "print", command: printable };
|
|
14056
|
+
}
|
|
14057
|
+
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
14058
|
+
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
14059
|
+
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
14060
|
+
if (result.code === 0)
|
|
14061
|
+
return { launched: true, via: "zellij", command: printable };
|
|
14062
|
+
const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
14063
|
+
if (fallback.code === 0)
|
|
14064
|
+
return { launched: true, via: "zellij-run", command: printable };
|
|
14065
|
+
}
|
|
14066
|
+
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
14067
|
+
const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
14068
|
+
if (result.code === 0)
|
|
14069
|
+
return { launched: true, via: "tmux", command: printable };
|
|
14070
|
+
}
|
|
14071
|
+
if (mux === "screen" && await findExecutable("screen")) {
|
|
14072
|
+
const result = await run("screen", screenWindowArgs(command, cwd, name));
|
|
14073
|
+
if (result.code === 0)
|
|
14074
|
+
return { launched: true, via: "screen", command: printable };
|
|
14075
|
+
}
|
|
14076
|
+
return;
|
|
14077
|
+
}
|
|
14078
|
+
function screenWindowArgs(command, cwd, name) {
|
|
14079
|
+
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
14080
|
+
}
|
|
14081
|
+
|
|
14082
|
+
// src/forge.ts
|
|
14083
|
+
function createForge(vcs) {
|
|
14084
|
+
if (vcs.provider === "github")
|
|
14085
|
+
return new GithubForge(vcs);
|
|
14086
|
+
if (vcs.provider === "gitlab")
|
|
14087
|
+
return new GitlabForge(vcs);
|
|
14088
|
+
throw new Error("No forge detected from the git remote. Use --local for an offline review.");
|
|
14089
|
+
}
|
|
14090
|
+
|
|
14091
|
+
class GithubForge {
|
|
14092
|
+
vcs;
|
|
14093
|
+
provider = "github";
|
|
14094
|
+
constructor(vcs) {
|
|
14095
|
+
this.vcs = vcs;
|
|
14096
|
+
}
|
|
14097
|
+
repoFlag() {
|
|
14098
|
+
return ["--repo", `${this.vcs.owner}/${this.vcs.repo}`];
|
|
14099
|
+
}
|
|
14100
|
+
async createDraftPr(options) {
|
|
14101
|
+
const args = [
|
|
14102
|
+
"pr",
|
|
14103
|
+
"create",
|
|
14104
|
+
...this.repoFlag(),
|
|
14105
|
+
"--title",
|
|
14106
|
+
options.title,
|
|
14107
|
+
"--body",
|
|
14108
|
+
options.body,
|
|
14109
|
+
"--base",
|
|
14110
|
+
options.base,
|
|
14111
|
+
"--head",
|
|
14112
|
+
options.head
|
|
14113
|
+
];
|
|
14114
|
+
if (options.draft !== false)
|
|
14115
|
+
args.push("--draft");
|
|
14116
|
+
await runChecked("gh", args);
|
|
14117
|
+
const ref = await this.viewPr(options.head);
|
|
14118
|
+
if (!ref)
|
|
14119
|
+
throw new Error("Draft PR created but could not be resolved.");
|
|
14120
|
+
return ref;
|
|
14121
|
+
}
|
|
14122
|
+
async viewPr(idOrBranch) {
|
|
14123
|
+
const args = [
|
|
14124
|
+
"pr",
|
|
14125
|
+
"view",
|
|
14126
|
+
idOrBranch,
|
|
14127
|
+
...this.repoFlag(),
|
|
14128
|
+
"--json",
|
|
14129
|
+
"number,title,url,isDraft,baseRefName,headRefName"
|
|
14130
|
+
];
|
|
14131
|
+
const result = await run("gh", args);
|
|
14132
|
+
if (result.code !== 0) {
|
|
14133
|
+
if (isConfirmedMissingChange("github", result.stderr || result.stdout))
|
|
14134
|
+
return;
|
|
14135
|
+
throw commandFailure("gh", args, result);
|
|
14136
|
+
}
|
|
14137
|
+
if (!result.stdout.trim())
|
|
14138
|
+
throw new Error("GitHub returned an empty pull request response.");
|
|
14139
|
+
let data;
|
|
14140
|
+
try {
|
|
14141
|
+
data = JSON.parse(result.stdout);
|
|
14142
|
+
} catch {
|
|
14143
|
+
throw new Error("Cannot parse the GitHub pull request response as JSON.");
|
|
14144
|
+
}
|
|
14145
|
+
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") {
|
|
14146
|
+
throw new Error("GitHub returned an invalid pull request response.");
|
|
14147
|
+
}
|
|
14148
|
+
return {
|
|
14149
|
+
number: data.number,
|
|
14150
|
+
title: data.title,
|
|
14151
|
+
url: data.url,
|
|
14152
|
+
isDraft: data.isDraft,
|
|
14153
|
+
baseRef: data.baseRefName,
|
|
14154
|
+
headRef: data.headRefName
|
|
14155
|
+
};
|
|
14156
|
+
}
|
|
14157
|
+
async defaultBranch() {
|
|
14158
|
+
const result = await runChecked("gh", [
|
|
14159
|
+
"repo",
|
|
14160
|
+
"view",
|
|
14161
|
+
`${this.vcs.owner}/${this.vcs.repo}`,
|
|
14162
|
+
"--json",
|
|
14163
|
+
"defaultBranchRef",
|
|
14164
|
+
"--jq",
|
|
14165
|
+
".defaultBranchRef.name"
|
|
14166
|
+
]);
|
|
14167
|
+
return requireBranchName(result.stdout, "GitHub");
|
|
14168
|
+
}
|
|
14169
|
+
async prDiff(id) {
|
|
14170
|
+
return (await runChecked("gh", ["pr", "diff", String(id), ...this.repoFlag()], { capture: "unbounded" })).stdout;
|
|
14171
|
+
}
|
|
14172
|
+
async prChecks(id) {
|
|
14173
|
+
return (await run("gh", ["pr", "checks", String(id), ...this.repoFlag()])).stdout;
|
|
14174
|
+
}
|
|
14175
|
+
async createPendingReview(id, comments, body) {
|
|
14176
|
+
const payload = {
|
|
14177
|
+
body,
|
|
14178
|
+
comments: comments.map((comment) => ({
|
|
14179
|
+
path: comment.file,
|
|
14180
|
+
line: comment.line,
|
|
14181
|
+
side: comment.side ?? "RIGHT",
|
|
14182
|
+
body: comment.body
|
|
14183
|
+
}))
|
|
14184
|
+
};
|
|
14185
|
+
await runChecked("gh", ["api", "--method", "POST", `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
|
|
14186
|
+
}
|
|
14187
|
+
async submitReview(id, event, body) {
|
|
14188
|
+
const pending = await runChecked("gh", [
|
|
14189
|
+
"api",
|
|
14190
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`,
|
|
14191
|
+
"--jq",
|
|
14192
|
+
'[.[] | select(.state=="PENDING")] | last | .id'
|
|
14193
|
+
]);
|
|
14194
|
+
const reviewId = pending.stdout.trim();
|
|
14195
|
+
const endpoint = githubReviewSubmissionEndpoint(this.vcs.owner, this.vcs.repo, id, reviewId);
|
|
14196
|
+
const args = ["api", "--method", "POST", endpoint, "-f", `event=${event}`];
|
|
14197
|
+
if (body.trim())
|
|
14198
|
+
args.push("-f", `body=${body}`);
|
|
14199
|
+
await runChecked("gh", args);
|
|
14200
|
+
}
|
|
14201
|
+
async markReady(id) {
|
|
14202
|
+
await runChecked("gh", ["pr", "ready", String(id), ...this.repoFlag()]);
|
|
14203
|
+
}
|
|
14204
|
+
async closePr(id, comment) {
|
|
14205
|
+
const args = ["pr", "close", String(id), ...this.repoFlag()];
|
|
14206
|
+
if (comment)
|
|
14207
|
+
args.push("--comment", comment);
|
|
14208
|
+
await runChecked("gh", args);
|
|
14209
|
+
}
|
|
14210
|
+
}
|
|
14211
|
+
|
|
14212
|
+
class GitlabForge {
|
|
14213
|
+
vcs;
|
|
14214
|
+
provider = "gitlab";
|
|
14215
|
+
constructor(vcs) {
|
|
14216
|
+
this.vcs = vcs;
|
|
14217
|
+
}
|
|
14218
|
+
project() {
|
|
14219
|
+
return `${this.vcs.owner}/${this.vcs.repo}`;
|
|
14220
|
+
}
|
|
14221
|
+
async createDraftPr(options) {
|
|
14222
|
+
await runChecked("glab", [
|
|
14223
|
+
"mr",
|
|
14224
|
+
"create",
|
|
14225
|
+
"--repo",
|
|
14226
|
+
this.project(),
|
|
14227
|
+
"--title",
|
|
14228
|
+
`Draft: ${options.title}`,
|
|
14229
|
+
"--description",
|
|
14230
|
+
options.body,
|
|
14231
|
+
"--target-branch",
|
|
14232
|
+
options.base,
|
|
14233
|
+
"--source-branch",
|
|
14234
|
+
options.head,
|
|
14235
|
+
"--yes"
|
|
14236
|
+
]);
|
|
14237
|
+
const ref = await this.viewPr(options.head);
|
|
14238
|
+
if (!ref)
|
|
14239
|
+
throw new Error("Draft MR created but could not be resolved.");
|
|
14240
|
+
return ref;
|
|
14241
|
+
}
|
|
14242
|
+
async viewPr(idOrBranch) {
|
|
14243
|
+
const args = ["mr", "view", idOrBranch, "--repo", this.project(), "--output", "json"];
|
|
14244
|
+
const result = await run("glab", args);
|
|
14245
|
+
if (result.code !== 0) {
|
|
14246
|
+
if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
|
|
14247
|
+
return;
|
|
14248
|
+
throw commandFailure("glab", args, result);
|
|
14249
|
+
}
|
|
14250
|
+
if (!result.stdout.trim())
|
|
14251
|
+
throw new Error("GitLab returned an empty merge request response.");
|
|
14252
|
+
let data;
|
|
14253
|
+
try {
|
|
14254
|
+
data = JSON.parse(result.stdout);
|
|
14255
|
+
} catch {
|
|
14256
|
+
throw new Error("Cannot parse the GitLab merge request response as JSON.");
|
|
14257
|
+
}
|
|
14258
|
+
if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string") {
|
|
14259
|
+
throw new Error("GitLab returned an invalid merge request response.");
|
|
14260
|
+
}
|
|
14261
|
+
return {
|
|
14262
|
+
number: data.iid,
|
|
14263
|
+
title: data.title,
|
|
14264
|
+
url: data.web_url,
|
|
14265
|
+
isDraft: Boolean(data.draft ?? data.work_in_progress),
|
|
14266
|
+
baseRef: data.target_branch,
|
|
14267
|
+
headRef: data.source_branch
|
|
14268
|
+
};
|
|
14269
|
+
}
|
|
14270
|
+
async defaultBranch() {
|
|
14271
|
+
const result = await runChecked("glab", [
|
|
14272
|
+
"api",
|
|
14273
|
+
`projects/${encodeURIComponent(this.project())}`,
|
|
14274
|
+
"--jq",
|
|
14275
|
+
".default_branch"
|
|
14276
|
+
]);
|
|
14277
|
+
return requireBranchName(result.stdout, "GitLab");
|
|
14278
|
+
}
|
|
14279
|
+
async prDiff(id) {
|
|
14280
|
+
return (await runChecked("glab", ["mr", "diff", String(id), "--repo", this.project()], { capture: "unbounded" })).stdout;
|
|
14281
|
+
}
|
|
14282
|
+
async prChecks() {
|
|
14283
|
+
return (await run("glab", ["ci", "status", "--repo", this.project()])).stdout;
|
|
14284
|
+
}
|
|
14285
|
+
async createPendingReview(id, comments, body) {
|
|
14286
|
+
const endpoint = `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`;
|
|
14287
|
+
if (body.trim()) {
|
|
14288
|
+
await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
14289
|
+
input: JSON.stringify({ note: body })
|
|
14290
|
+
});
|
|
14291
|
+
}
|
|
14292
|
+
if (comments.length === 0)
|
|
14293
|
+
return;
|
|
14294
|
+
const response = await runChecked("glab", [
|
|
14295
|
+
"api",
|
|
14296
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}`
|
|
14297
|
+
]);
|
|
14298
|
+
const diffRefs = parseGitlabDiffRefs(response.stdout);
|
|
14299
|
+
for (const comment of comments) {
|
|
14300
|
+
const payload = {
|
|
14301
|
+
note: comment.body,
|
|
14302
|
+
position: {
|
|
14303
|
+
...diffRefs,
|
|
14304
|
+
position_type: "text",
|
|
14305
|
+
new_path: comment.file,
|
|
14306
|
+
old_path: comment.file,
|
|
14307
|
+
new_line: comment.side === "LEFT" ? undefined : comment.line,
|
|
14308
|
+
old_line: comment.side === "LEFT" ? comment.line : undefined
|
|
14309
|
+
}
|
|
14310
|
+
};
|
|
14311
|
+
await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
14312
|
+
input: JSON.stringify(payload)
|
|
14313
|
+
});
|
|
14314
|
+
}
|
|
14315
|
+
}
|
|
14316
|
+
async submitReview(id, event, body) {
|
|
14317
|
+
assertReviewEventSupported(this.provider, event);
|
|
14318
|
+
const drafts = await runChecked("glab", [
|
|
14319
|
+
"api",
|
|
14320
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`
|
|
14321
|
+
]);
|
|
14322
|
+
const hasDrafts = hasGitlabDraftNotes(drafts.stdout);
|
|
14323
|
+
if (hasDrafts) {
|
|
14324
|
+
await runChecked("glab", [
|
|
14325
|
+
"api",
|
|
14326
|
+
"--method",
|
|
14327
|
+
"POST",
|
|
14328
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes/bulk_publish`
|
|
14329
|
+
]);
|
|
14330
|
+
} else if (body.trim()) {
|
|
14331
|
+
await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", body]);
|
|
14332
|
+
}
|
|
14333
|
+
if (event === "APPROVE")
|
|
14334
|
+
await runChecked("glab", ["mr", "approve", String(id), "--repo", this.project()]);
|
|
14335
|
+
}
|
|
14336
|
+
async markReady(id) {
|
|
14337
|
+
await runChecked("glab", ["mr", "update", String(id), "--repo", this.project(), "--ready"]);
|
|
14338
|
+
}
|
|
14339
|
+
async closePr(id, comment) {
|
|
14340
|
+
if (comment)
|
|
14341
|
+
await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", comment]);
|
|
14342
|
+
await runChecked("glab", ["mr", "close", String(id), "--repo", this.project()]);
|
|
14343
|
+
}
|
|
14344
|
+
}
|
|
14345
|
+
function assertReviewEventSupported(provider, event) {
|
|
14346
|
+
if (provider === "gitlab" && event === "REQUEST_CHANGES") {
|
|
14347
|
+
throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
|
|
14348
|
+
}
|
|
14349
|
+
}
|
|
14350
|
+
function isConfirmedMissingChange(provider, output) {
|
|
14351
|
+
const message = output.toLowerCase();
|
|
14352
|
+
if (provider === "github") {
|
|
14353
|
+
return message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest");
|
|
14354
|
+
}
|
|
14355
|
+
if (provider === "gitlab") {
|
|
14356
|
+
return message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message);
|
|
14357
|
+
}
|
|
14358
|
+
return false;
|
|
14359
|
+
}
|
|
14360
|
+
function commandFailure(command, args, result) {
|
|
14361
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
14362
|
+
return new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
14363
|
+
}
|
|
14364
|
+
function requireBranchName(output, provider) {
|
|
14365
|
+
const branch = output.trim();
|
|
14366
|
+
if (!branch || branch === "null")
|
|
14367
|
+
throw new Error(`${provider} did not return a default branch.`);
|
|
14368
|
+
return branch;
|
|
14369
|
+
}
|
|
14370
|
+
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
14371
|
+
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
14372
|
+
}
|
|
14373
|
+
function parseGitlabDiffRefs(input) {
|
|
14374
|
+
let data;
|
|
14375
|
+
try {
|
|
14376
|
+
data = JSON.parse(input);
|
|
14377
|
+
} catch {
|
|
14378
|
+
throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
|
|
14379
|
+
}
|
|
14380
|
+
const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
|
|
14381
|
+
if (!base_sha || !start_sha || !head_sha) {
|
|
14382
|
+
throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
|
|
14383
|
+
}
|
|
14384
|
+
return { base_sha, start_sha, head_sha };
|
|
14385
|
+
}
|
|
14386
|
+
function hasGitlabDraftNotes(input) {
|
|
14387
|
+
try {
|
|
14388
|
+
const data = JSON.parse(input);
|
|
14389
|
+
return Array.isArray(data) && data.length > 0;
|
|
14390
|
+
} catch {
|
|
14391
|
+
throw new Error("Cannot complete GitLab review: the draft notes response was not valid JSON.");
|
|
14392
|
+
}
|
|
14393
|
+
}
|
|
14394
|
+
|
|
14395
|
+
// src/gates.ts
|
|
14396
|
+
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
14397
|
+
var MISE_GATES = ["format:check", "lint", "test"];
|
|
14398
|
+
function checkConventionalSubject(subject) {
|
|
14399
|
+
const trimmed = subject.trim();
|
|
14400
|
+
const ok = CONVENTIONAL_COMMIT.test(trimmed);
|
|
14401
|
+
return {
|
|
14402
|
+
name: "conventional-subject",
|
|
14403
|
+
status: ok ? "pass" : "warn",
|
|
14404
|
+
detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
|
|
14405
|
+
};
|
|
14406
|
+
}
|
|
14407
|
+
async function runMiseGates(cwd) {
|
|
14408
|
+
const tasks = await discoverMiseTasks(cwd);
|
|
14409
|
+
const results = [];
|
|
14410
|
+
for (const gate of MISE_GATES) {
|
|
14411
|
+
const targets = tasks.get(gate) ?? [];
|
|
14412
|
+
if (targets.length === 0) {
|
|
14413
|
+
results.push({ name: gate, status: "skip", detail: "no mise recipe" });
|
|
14414
|
+
continue;
|
|
14415
|
+
}
|
|
14416
|
+
const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
|
|
14417
|
+
const result = await run("mise", ["run", ...invocations], { cwd });
|
|
14418
|
+
results.push({
|
|
14419
|
+
name: gate,
|
|
14420
|
+
status: result.code === 0 ? "pass" : "fail",
|
|
14421
|
+
detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
|
|
14422
|
+
});
|
|
14423
|
+
}
|
|
14424
|
+
return results;
|
|
14425
|
+
}
|
|
14426
|
+
function ciGate(checksOutput) {
|
|
14427
|
+
const text = checksOutput.toLowerCase();
|
|
14428
|
+
if (!text.trim())
|
|
14429
|
+
return { name: "ci", status: "skip", detail: "no CI output" };
|
|
14430
|
+
if (/\bfail|error\b/.test(text))
|
|
14431
|
+
return { name: "ci", status: "warn", detail: "CI failing" };
|
|
14432
|
+
if (/\bpending|in progress|queued\b/.test(text))
|
|
14433
|
+
return { name: "ci", status: "warn", detail: "CI pending" };
|
|
14434
|
+
return { name: "ci", status: "pass", detail: "CI green" };
|
|
14435
|
+
}
|
|
14436
|
+
async function discoverMiseTasks(cwd) {
|
|
14437
|
+
const result = await run("mise", ["tasks", "--json", "--all"], { cwd });
|
|
14438
|
+
if (result.code !== 0)
|
|
14439
|
+
return new Map;
|
|
14440
|
+
return parseMiseTasks(result.stdout);
|
|
14441
|
+
}
|
|
14442
|
+
function parseMiseTasks(input) {
|
|
14443
|
+
let tasks;
|
|
14444
|
+
try {
|
|
14445
|
+
tasks = JSON.parse(input);
|
|
14446
|
+
} catch {
|
|
14447
|
+
return new Map;
|
|
14448
|
+
}
|
|
14449
|
+
if (!Array.isArray(tasks))
|
|
14450
|
+
return new Map;
|
|
14451
|
+
const found = new Map;
|
|
14452
|
+
for (const gate of MISE_GATES) {
|
|
14453
|
+
const targets = tasks.flatMap((task) => {
|
|
14454
|
+
if (typeof task.name !== "string")
|
|
14455
|
+
return [];
|
|
14456
|
+
return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
|
|
14457
|
+
});
|
|
14458
|
+
if (targets.length > 0)
|
|
14459
|
+
found.set(gate, [...new Set(targets)]);
|
|
14460
|
+
}
|
|
14461
|
+
return found;
|
|
14462
|
+
}
|
|
14463
|
+
|
|
14464
|
+
// src/review.ts
|
|
14465
|
+
import { join as join8 } from "node:path";
|
|
14466
|
+
import { z as z4 } from "zod";
|
|
14467
|
+
var severitySchema = z4.enum(["BLOCKING", "CONSIDER", "NOTE"]);
|
|
14468
|
+
var findingSchema = z4.object({
|
|
14469
|
+
file: z4.string().min(1),
|
|
14470
|
+
line: z4.number().int().nonnegative(),
|
|
14471
|
+
severity: severitySchema,
|
|
14472
|
+
body: z4.string().min(1),
|
|
14473
|
+
reference: z4.string().optional().default("")
|
|
14474
|
+
});
|
|
14475
|
+
var findingsSchema = z4.array(findingSchema);
|
|
14476
|
+
function reviewSlug(input) {
|
|
14477
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
14478
|
+
}
|
|
14479
|
+
function mmddyy(date = new Date) {
|
|
14480
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
14481
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
14482
|
+
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
|
14483
|
+
return `${mm}${dd}${yy}`;
|
|
14484
|
+
}
|
|
14485
|
+
function reviewRecordName(branch, date = new Date) {
|
|
14486
|
+
return `${mmddyy(date)}-${reviewSlug(branch)}`;
|
|
14487
|
+
}
|
|
14488
|
+
function dedupeFindings(findings) {
|
|
14489
|
+
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
14490
|
+
const byKey = new Map;
|
|
14491
|
+
for (const finding of findings) {
|
|
14492
|
+
const key = `${finding.file}:${finding.line}`;
|
|
14493
|
+
const existing = byKey.get(key);
|
|
14494
|
+
if (!existing || rank[finding.severity] > rank[existing.severity])
|
|
14495
|
+
byKey.set(key, finding);
|
|
14496
|
+
}
|
|
14497
|
+
return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
|
|
14498
|
+
}
|
|
14499
|
+
function toReviewComments(findings) {
|
|
14500
|
+
return findings.filter((finding) => finding.line > 0).map((finding) => ({
|
|
14501
|
+
file: finding.file,
|
|
14502
|
+
line: finding.line,
|
|
14503
|
+
side: "RIGHT",
|
|
14504
|
+
body: renderCommentBody(finding)
|
|
14505
|
+
}));
|
|
14506
|
+
}
|
|
14507
|
+
function renderReviewDoc(input) {
|
|
14508
|
+
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
14509
|
+
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
14510
|
+
if (input.number !== undefined)
|
|
14511
|
+
lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
|
|
14512
|
+
if (input.author)
|
|
14513
|
+
lines.push(`- **Author**: ${input.author}`);
|
|
14514
|
+
if (input.headRef && input.baseRef)
|
|
14515
|
+
lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
|
|
14516
|
+
if (input.additions !== undefined)
|
|
14517
|
+
lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
|
|
14518
|
+
lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
|
|
14519
|
+
if (input.overallIssues.length > 0) {
|
|
14520
|
+
lines.push("## Overall issues", "");
|
|
14521
|
+
for (const issue of input.overallIssues)
|
|
14522
|
+
lines.push(`- ${issue}`);
|
|
14523
|
+
lines.push("");
|
|
14524
|
+
}
|
|
14525
|
+
lines.push("## Verification", "");
|
|
14526
|
+
for (const gate of input.gates)
|
|
14527
|
+
lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
|
|
14528
|
+
lines.push("");
|
|
14529
|
+
if (input.notVerified.length > 0) {
|
|
14530
|
+
lines.push("## What was NOT verified", "");
|
|
14531
|
+
for (const item of input.notVerified)
|
|
14532
|
+
lines.push(`- ${item}`);
|
|
14533
|
+
lines.push("");
|
|
14534
|
+
}
|
|
14535
|
+
lines.push("## Inline Comments", "");
|
|
14536
|
+
for (const finding of anchored) {
|
|
14537
|
+
lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
|
|
14538
|
+
if (finding.reference)
|
|
14539
|
+
lines.push(`> **Reference:** ${finding.reference}`, "");
|
|
14540
|
+
lines.push("---", "");
|
|
14541
|
+
}
|
|
14542
|
+
return `${lines.join(`
|
|
14543
|
+
`).trimEnd()}
|
|
14544
|
+
`;
|
|
14545
|
+
}
|
|
14546
|
+
function reviewWorkingDir(storeReviewsDir, slug) {
|
|
14547
|
+
return join8(storeReviewsDir, slug);
|
|
14548
|
+
}
|
|
14549
|
+
function renderCommentBody(finding) {
|
|
14550
|
+
const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
|
|
14551
|
+
const reference = finding.reference ? `
|
|
14552
|
+
|
|
14553
|
+
> **Reference:** ${finding.reference}` : "";
|
|
14554
|
+
return `${prefix}${finding.body}${reference}`;
|
|
14555
|
+
}
|
|
14556
|
+
|
|
14557
|
+
// src/store.ts
|
|
14558
|
+
import { createHash } from "node:crypto";
|
|
14559
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
14560
|
+
import { mkdir as mkdir2, realpath, symlink } from "node:fs/promises";
|
|
14561
|
+
import { homedir as homedir5 } from "node:os";
|
|
14562
|
+
import { isAbsolute as isAbsolute2, join as join9, resolve as resolve2 } from "node:path";
|
|
14563
|
+
var STORE_LINK = join9(".pi", "diffpi");
|
|
14564
|
+
async function gitToplevel(cwd) {
|
|
14565
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
14566
|
+
const top = result.stdout.trim();
|
|
14567
|
+
return result.code === 0 && top ? top : resolve2(cwd);
|
|
14568
|
+
}
|
|
14569
|
+
async function computeProjectSlug(cwd) {
|
|
14570
|
+
const root = await gitToplevel(cwd);
|
|
14571
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
14572
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
14573
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
14574
|
+
const common = commonResult.stdout.trim();
|
|
14575
|
+
let commonPath = root;
|
|
14576
|
+
if (commonResult.code === 0 && common) {
|
|
14577
|
+
const resolvedCommon = isAbsolute2(common) ? common : join9(root, common);
|
|
14578
|
+
commonPath = resolve2(resolvedCommon);
|
|
14579
|
+
}
|
|
14580
|
+
const canonicalCommon = await canonicalPath(commonPath);
|
|
14581
|
+
const identity = remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${canonicalCommon}`;
|
|
14582
|
+
const name = remote ? repositoryName(remote) : basename3(resolve2(canonicalCommon, "..")) || basename3(root);
|
|
14583
|
+
const readable = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
14584
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12);
|
|
14585
|
+
return `${readable}-${digest}`;
|
|
14586
|
+
}
|
|
14587
|
+
function storeGlobalRoot(homeDir = homedir5()) {
|
|
14588
|
+
return join9(homeDir, ".difflab", "diffpi", "projects");
|
|
14589
|
+
}
|
|
14590
|
+
async function ensureStore(cwd, homeDir = homedir5()) {
|
|
14591
|
+
const root = await gitToplevel(cwd);
|
|
14592
|
+
const slug = await computeProjectSlug(root);
|
|
14593
|
+
const dest = join9(storeGlobalRoot(homeDir), slug);
|
|
14594
|
+
const link = join9(root, STORE_LINK);
|
|
14595
|
+
if (existsSync3(link))
|
|
14596
|
+
return { slug, root, dest, link, linked: true };
|
|
14597
|
+
await mkdir2(dest, { recursive: true });
|
|
14598
|
+
await mkdir2(join9(root, ".pi"), { recursive: true });
|
|
14599
|
+
await symlink(dest, link);
|
|
14600
|
+
return { slug, root, dest, link, linked: true };
|
|
14601
|
+
}
|
|
14602
|
+
async function storeDir(cwd, homeDir = homedir5()) {
|
|
14603
|
+
return (await ensureStore(cwd, homeDir)).dest;
|
|
14604
|
+
}
|
|
14605
|
+
async function reviewsDir(cwd, homeDir = homedir5()) {
|
|
14606
|
+
const dir = join9(await storeDir(cwd, homeDir), "reviews");
|
|
14607
|
+
await mkdir2(dir, { recursive: true });
|
|
14608
|
+
return dir;
|
|
14609
|
+
}
|
|
14610
|
+
async function canonicalPath(path) {
|
|
14611
|
+
try {
|
|
14612
|
+
return await realpath(path);
|
|
14613
|
+
} catch {
|
|
14614
|
+
return resolve2(path);
|
|
14615
|
+
}
|
|
14616
|
+
}
|
|
14617
|
+
function normalizeRemote(remote) {
|
|
14618
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
14619
|
+
}
|
|
14620
|
+
function repositoryName(remote) {
|
|
14621
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
14622
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
14623
|
+
}
|
|
14624
|
+
function basename3(path) {
|
|
14625
|
+
const parts = resolve2(path).split(/[/\\]/).filter(Boolean);
|
|
14626
|
+
return parts.at(-1) ?? "";
|
|
14627
|
+
}
|
|
14628
|
+
|
|
14629
|
+
// src/tuicr.ts
|
|
14630
|
+
import { readFile as readFile4, realpath as realpath2 } from "node:fs/promises";
|
|
14631
|
+
import { resolve as resolve3 } from "node:path";
|
|
14632
|
+
async function listSessions(repo = ".") {
|
|
14633
|
+
const result = await run("tuicr", ["review", "list", "--repo", repo]);
|
|
14634
|
+
if (result.code !== 0 || !result.stdout.trim())
|
|
14635
|
+
return [];
|
|
14636
|
+
let raw;
|
|
14637
|
+
try {
|
|
14638
|
+
raw = JSON.parse(result.stdout);
|
|
14639
|
+
} catch {
|
|
14640
|
+
return [];
|
|
14641
|
+
}
|
|
14642
|
+
return raw.map((entry) => ({
|
|
14643
|
+
slug: entry.slug,
|
|
14644
|
+
kind: entry.kind,
|
|
14645
|
+
path: entry.path,
|
|
14646
|
+
updatedAt: entry.updated_at,
|
|
14647
|
+
commentCount: entry.comment_count,
|
|
14648
|
+
anchor: entry.anchor,
|
|
14649
|
+
active: entry.active
|
|
14650
|
+
}));
|
|
14651
|
+
}
|
|
14652
|
+
async function resolveSession(cwd, branch) {
|
|
14653
|
+
return findMatchingSession(await listSessions(cwd), cwd, branch);
|
|
14654
|
+
}
|
|
14655
|
+
async function findMatchingSession(sessions, cwd, branch) {
|
|
14656
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
14657
|
+
for (const session of sessions) {
|
|
14658
|
+
if (session.kind !== "local")
|
|
14659
|
+
continue;
|
|
14660
|
+
try {
|
|
14661
|
+
const data = await readSession(session.path);
|
|
14662
|
+
if (data.branch_name !== branch || !data.repo_path)
|
|
14663
|
+
continue;
|
|
14664
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
14665
|
+
return session;
|
|
14666
|
+
} catch {}
|
|
14667
|
+
}
|
|
14668
|
+
return;
|
|
14669
|
+
}
|
|
14670
|
+
async function readSession(path) {
|
|
14671
|
+
const content = await readFile4(path, "utf8");
|
|
14672
|
+
try {
|
|
14673
|
+
return JSON.parse(content);
|
|
14674
|
+
} catch {
|
|
14675
|
+
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
14676
|
+
}
|
|
14677
|
+
}
|
|
14678
|
+
async function launch(cwd) {
|
|
14679
|
+
return openInNewTab(["tuicr", "-w"], { cwd, name: "tuicr" });
|
|
14680
|
+
}
|
|
14681
|
+
function toFindings(session) {
|
|
14682
|
+
const comments = [];
|
|
14683
|
+
const bodyParts = (session.review_comments ?? []).map((comment) => comment.content);
|
|
14684
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
14685
|
+
const fileComments = (entry.file_comments ?? []).map((comment) => comment.content);
|
|
14686
|
+
if (fileComments.length > 0)
|
|
14687
|
+
bodyParts.push(`File: ${file}
|
|
14688
|
+
|
|
14689
|
+
${fileComments.join(`
|
|
14690
|
+
|
|
14691
|
+
`)}`);
|
|
14692
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
14693
|
+
const line = Number.parseInt(lineKey, 10);
|
|
14694
|
+
if (!Number.isFinite(line))
|
|
14695
|
+
continue;
|
|
14696
|
+
for (const lineComment of lineComments) {
|
|
14697
|
+
comments.push({ file, line, side: lineComment.side === "old" ? "LEFT" : "RIGHT", body: lineComment.content });
|
|
14698
|
+
}
|
|
14699
|
+
}
|
|
14700
|
+
}
|
|
14701
|
+
return {
|
|
14702
|
+
comments,
|
|
14703
|
+
body: bodyParts.join(`
|
|
14704
|
+
|
|
14705
|
+
`)
|
|
14706
|
+
};
|
|
14707
|
+
}
|
|
14708
|
+
async function canonicalPath2(path) {
|
|
14709
|
+
try {
|
|
14710
|
+
return await realpath2(path);
|
|
14711
|
+
} catch {
|
|
14712
|
+
return resolve3(path);
|
|
14713
|
+
}
|
|
14714
|
+
}
|
|
14715
|
+
|
|
14716
|
+
// src/tools/review.ts
|
|
14717
|
+
var contextSchema = z5.object({ cwd: z5.string().optional() });
|
|
14718
|
+
var localSchema = contextSchema.extend({ local: z5.boolean().optional() });
|
|
14719
|
+
var openSchema = localSchema.extend({ title: z5.string().optional(), base: z5.string().optional() });
|
|
14720
|
+
var submitSchema = localSchema.extend({
|
|
14721
|
+
findings: findingsSchema,
|
|
14722
|
+
overallIssues: z5.array(z5.string()).optional(),
|
|
14723
|
+
notVerified: z5.array(z5.string()).optional(),
|
|
14724
|
+
title: z5.string().optional()
|
|
14725
|
+
});
|
|
14726
|
+
var completeSchema = contextSchema.extend({
|
|
14727
|
+
action: z5.enum(["accept", "reject", "close", "local"]),
|
|
14728
|
+
comment: z5.string().optional()
|
|
14729
|
+
});
|
|
14730
|
+
var eventSchema = localSchema.extend({ event: z5.enum(["APPROVE", "REQUEST_CHANGES", "COMMENT"]).optional() });
|
|
14731
|
+
var respondSchema = contextSchema.extend({
|
|
14732
|
+
body: z5.string().min(1),
|
|
14733
|
+
file: z5.string().optional(),
|
|
14734
|
+
line: z5.number().int().positive().optional()
|
|
14735
|
+
});
|
|
14736
|
+
function parameters5(schema) {
|
|
14737
|
+
return z5.toJSONSchema(schema, { io: "input" });
|
|
14738
|
+
}
|
|
14739
|
+
function cwdOf(params) {
|
|
14740
|
+
return params.cwd ?? process.cwd();
|
|
14741
|
+
}
|
|
14742
|
+
function result(text, details = {}) {
|
|
14743
|
+
return { content: [{ type: "text", text }], details };
|
|
14744
|
+
}
|
|
14745
|
+
function conventionalMergeGuard(subject) {
|
|
14746
|
+
return checkConventionalSubject(subject);
|
|
14747
|
+
}
|
|
14748
|
+
function assertGitHubMergeReady(input) {
|
|
14749
|
+
let data;
|
|
14750
|
+
try {
|
|
14751
|
+
data = JSON.parse(input);
|
|
14752
|
+
} catch {
|
|
14753
|
+
throw new Error("Merge blocked: GitHub readiness response was not valid JSON.");
|
|
14754
|
+
}
|
|
14755
|
+
const blockers = [];
|
|
14756
|
+
if (data.state !== "OPEN")
|
|
14757
|
+
blockers.push(`pull request state is ${data.state ?? "unknown"}`);
|
|
14758
|
+
if (data.isDraft)
|
|
14759
|
+
blockers.push("pull request is still a draft");
|
|
14760
|
+
if (data.reviewDecision !== "APPROVED") {
|
|
14761
|
+
blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
|
|
14762
|
+
}
|
|
14763
|
+
if (data.mergeStateStatus !== "CLEAN") {
|
|
14764
|
+
blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
|
|
14765
|
+
}
|
|
14766
|
+
for (const check of data.statusCheckRollup ?? []) {
|
|
14767
|
+
const name = check.name ?? check.context ?? "unnamed check";
|
|
14768
|
+
if (check.__typename === "CheckRun") {
|
|
14769
|
+
if (check.status !== "COMPLETED")
|
|
14770
|
+
blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
|
|
14771
|
+
else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? "")) {
|
|
14772
|
+
blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
|
|
14773
|
+
}
|
|
14774
|
+
} else if (check.state !== "SUCCESS") {
|
|
14775
|
+
blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
|
|
14776
|
+
}
|
|
14777
|
+
}
|
|
14778
|
+
if (blockers.length > 0)
|
|
14779
|
+
throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
|
|
14780
|
+
}
|
|
14781
|
+
function reviewSubmissionBody(body) {
|
|
14782
|
+
return body.trim() || "Inline comments only.";
|
|
14783
|
+
}
|
|
14784
|
+
function createReviewTools() {
|
|
14785
|
+
return [
|
|
14786
|
+
defineTool3({
|
|
14787
|
+
name: "review_context",
|
|
14788
|
+
label: "review context",
|
|
14789
|
+
description: "Read-only orientation for the current forge, environment, store, branch, PR, and tuicr session.",
|
|
14790
|
+
promptSnippet: "Call review_context first",
|
|
14791
|
+
promptGuidelines: ["Call this before every review workflow."],
|
|
14792
|
+
parameters: parameters5(contextSchema),
|
|
14793
|
+
executionMode: "parallel",
|
|
14794
|
+
async execute(_id, input) {
|
|
14795
|
+
const params = contextSchema.parse(input);
|
|
14796
|
+
const cwd = cwdOf(params);
|
|
14797
|
+
const vcs = await detectVcs(cwd);
|
|
14798
|
+
const store = await ensureStore(cwd);
|
|
14799
|
+
const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
|
|
14800
|
+
const forge = vcs.provider === "none" ? undefined : createForge(vcs);
|
|
14801
|
+
const pr = forge ? await forge.viewPr(vcs.branch) : undefined;
|
|
14802
|
+
const baseRef = pr?.baseRef ?? (forge ? await forge.defaultBranch() : "local");
|
|
14803
|
+
const session = await resolveSession(cwd, vcs.branch);
|
|
14804
|
+
return result([
|
|
14805
|
+
`Forge: ${vcs.provider}${vcs.provider === "none" ? "" : ` (${vcs.owner}/${vcs.repo})`}`,
|
|
14806
|
+
`Branch: ${vcs.branch} → ${baseRef}`,
|
|
14807
|
+
`Env: ide=${env.ide} mux=${env.mux} shell=${env.shell}`,
|
|
14808
|
+
`Store: ${store.link} → ${store.dest}`,
|
|
14809
|
+
pr ? `PR/MR: #${pr.number} ${pr.url}` : "PR/MR: none",
|
|
14810
|
+
session ? `tuicr: ${session.slug} (${session.commentCount} comments)` : "tuicr: none"
|
|
14811
|
+
].join(`
|
|
14812
|
+
`), { vcs, env, store, pr, session, baseRef });
|
|
14813
|
+
}
|
|
14814
|
+
}),
|
|
14815
|
+
defineTool3({
|
|
14816
|
+
name: "review_open",
|
|
14817
|
+
label: "review open",
|
|
14818
|
+
description: "Create a draft PR/MR, or launch a local tuicr review.",
|
|
14819
|
+
promptSnippet: "Call review_open to start",
|
|
14820
|
+
promptGuidelines: ["Use local for the offline tuicr flow."],
|
|
14821
|
+
parameters: parameters5(openSchema),
|
|
14822
|
+
executionMode: "sequential",
|
|
14823
|
+
async execute(_id, input) {
|
|
14824
|
+
const params = openSchema.parse(input);
|
|
14825
|
+
const cwd = cwdOf(params);
|
|
14826
|
+
await ensureStore(cwd);
|
|
14827
|
+
const vcs = await detectVcs(cwd);
|
|
14828
|
+
if (params.local || vcs.provider === "none") {
|
|
14829
|
+
const launched = await launch(cwd);
|
|
14830
|
+
const record = join10(await reviewsDir(cwd), `${reviewRecordName(vcs.branch)}.md`);
|
|
14831
|
+
if (!existsSync4(record))
|
|
14832
|
+
await writeFile2(record, `# Local review: ${vcs.branch}
|
|
14833
|
+
`, "utf8");
|
|
14834
|
+
return result(launched.launched ? `Opened tuicr (${launched.via}). Record: ${record}` : `${launched.instruction ?? `Run: ${launched.command}`}
|
|
14835
|
+
Record: ${record}`, { launched, record });
|
|
14836
|
+
}
|
|
14837
|
+
const forge = createForge(vcs);
|
|
14838
|
+
const pr = await forge.createDraftPr({
|
|
14839
|
+
title: params.title ?? deriveTitle(vcs.branch),
|
|
14840
|
+
body: "<!-- fill in intent, changes, validation -->",
|
|
14841
|
+
base: params.base ?? await forge.defaultBranch(),
|
|
14842
|
+
head: vcs.branch
|
|
14843
|
+
});
|
|
14844
|
+
return result(`Draft PR/MR created: ${pr.url}`, { pr });
|
|
14845
|
+
}
|
|
14846
|
+
}),
|
|
14847
|
+
defineTool3({
|
|
14848
|
+
name: "review_diff",
|
|
14849
|
+
label: "review diff",
|
|
14850
|
+
description: "Fetch the forge diff or the local working-tree diff.",
|
|
14851
|
+
promptSnippet: "Call review_diff for the code under review",
|
|
14852
|
+
promptGuidelines: ["Ground findings in this diff."],
|
|
14853
|
+
parameters: parameters5(localSchema),
|
|
14854
|
+
executionMode: "parallel",
|
|
14855
|
+
async execute(_id, input) {
|
|
14856
|
+
const params = localSchema.parse(input);
|
|
14857
|
+
const cwd = cwdOf(params);
|
|
14858
|
+
const vcs = await detectVcs(cwd);
|
|
14859
|
+
if (params.local || vcs.provider === "none") {
|
|
14860
|
+
const diff = await run("git", ["-C", cwd, "diff", "HEAD"]);
|
|
14861
|
+
return result(diff.stdout || "No working-tree changes.", { diff: diff.stdout });
|
|
14862
|
+
}
|
|
14863
|
+
const pr = await createForge(vcs).viewPr(vcs.branch);
|
|
14864
|
+
if (!pr)
|
|
14865
|
+
return result("No open PR/MR. Run review_open first.");
|
|
14866
|
+
const diff = await createForge(vcs).prDiff(pr.number);
|
|
14867
|
+
return result(diff || "Empty diff.", { diff, pr });
|
|
14868
|
+
}
|
|
14869
|
+
}),
|
|
14870
|
+
defineTool3({
|
|
14871
|
+
name: "review_gates",
|
|
14872
|
+
label: "review gates",
|
|
14873
|
+
description: "Run format, lint, test, conventional-subject, and available CI checks.",
|
|
14874
|
+
promptSnippet: "Call review_gates before submitting findings",
|
|
14875
|
+
promptGuidelines: ["Report skipped gates as skipped."],
|
|
14876
|
+
parameters: parameters5(localSchema),
|
|
14877
|
+
executionMode: "parallel",
|
|
14878
|
+
async execute(_id, input) {
|
|
14879
|
+
const params = localSchema.parse(input);
|
|
14880
|
+
const cwd = cwdOf(params);
|
|
14881
|
+
const vcs = await detectVcs(cwd);
|
|
14882
|
+
const gates = await runMiseGates(cwd);
|
|
14883
|
+
const subject = (await run("git", ["-C", cwd, "log", "-1", "--format=%s"])).stdout.trim();
|
|
14884
|
+
if (subject)
|
|
14885
|
+
gates.push(checkConventionalSubject(subject));
|
|
14886
|
+
if (!params.local && vcs.provider !== "none") {
|
|
14887
|
+
const forge = createForge(vcs);
|
|
14888
|
+
const pr = await forge.viewPr(vcs.branch);
|
|
14889
|
+
if (pr)
|
|
14890
|
+
gates.push(ciGate(await forge.prChecks(pr.number)));
|
|
14891
|
+
}
|
|
14892
|
+
return result(gates.map((gate) => `- ${gate.name}: ${gate.status} — ${gate.detail}`).join(`
|
|
14893
|
+
`), {
|
|
14894
|
+
results: gates
|
|
14895
|
+
});
|
|
14896
|
+
}
|
|
14897
|
+
}),
|
|
14898
|
+
defineTool3({
|
|
14899
|
+
name: "review_submit",
|
|
14900
|
+
label: "review submit",
|
|
14901
|
+
description: "Render findings to the shared review store and create a pending forge review unless local.",
|
|
14902
|
+
promptSnippet: "Call review_submit with the findings JSON",
|
|
14903
|
+
promptGuidelines: ["Use concrete file and line values."],
|
|
14904
|
+
parameters: parameters5(submitSchema),
|
|
14905
|
+
executionMode: "sequential",
|
|
14906
|
+
async execute(_id, input) {
|
|
14907
|
+
const params = submitSchema.parse(input);
|
|
14908
|
+
const cwd = cwdOf(params);
|
|
14909
|
+
await ensureStore(cwd);
|
|
14910
|
+
const vcs = await detectVcs(cwd);
|
|
14911
|
+
const findings = dedupeFindings(params.findings);
|
|
14912
|
+
const slug = reviewSlug(params.title ?? vcs.branch) || "review";
|
|
14913
|
+
const dir = reviewWorkingDir(await reviewsDir(cwd), slug);
|
|
14914
|
+
await mkdir3(dir, { recursive: true });
|
|
14915
|
+
const gates = await runMiseGates(cwd);
|
|
14916
|
+
const forge = !params.local && vcs.provider !== "none" ? createForge(vcs) : undefined;
|
|
14917
|
+
const pr = forge ? await forge.viewPr(vcs.branch) : undefined;
|
|
14918
|
+
const baseRef = pr?.baseRef ?? (forge ? await forge.defaultBranch() : "local");
|
|
14919
|
+
const docPath = join10(dir, "new-review.md");
|
|
14920
|
+
await writeFile2(docPath, renderReviewDoc({
|
|
14921
|
+
title: params.title ?? vcs.branch,
|
|
14922
|
+
headRef: vcs.branch,
|
|
14923
|
+
baseRef,
|
|
14924
|
+
findings,
|
|
14925
|
+
overallIssues: params.overallIssues ?? [],
|
|
14926
|
+
gates,
|
|
14927
|
+
notVerified: params.notVerified ?? []
|
|
14928
|
+
}), "utf8");
|
|
14929
|
+
if (!forge)
|
|
14930
|
+
return result(`Local review written: ${docPath}`, { docPath, count: findings.length });
|
|
14931
|
+
if (!pr)
|
|
14932
|
+
return result(`No open PR/MR. Review written: ${docPath}`, { docPath });
|
|
14933
|
+
await forge.createPendingReview(pr.number, toReviewComments(findings), reviewSubmissionBody((params.overallIssues ?? []).join(`
|
|
14934
|
+
`)));
|
|
14935
|
+
return result(`Pending review posted to #${pr.number}. Doc: ${docPath}`, {
|
|
14936
|
+
docPath,
|
|
14937
|
+
pr,
|
|
14938
|
+
count: findings.length
|
|
14939
|
+
});
|
|
14940
|
+
}
|
|
14941
|
+
}),
|
|
14942
|
+
defineTool3({
|
|
14943
|
+
name: "review_comments",
|
|
14944
|
+
label: "review comments",
|
|
14945
|
+
description: "Read unresolved local tuicr comments. Forge thread retrieval is delegated to the forge MCP when available.",
|
|
14946
|
+
promptSnippet: "Call review_comments before addressing findings",
|
|
14947
|
+
promptGuidelines: ["Use forge MCP thread tools when the review is remote."],
|
|
14948
|
+
parameters: parameters5(localSchema),
|
|
14949
|
+
executionMode: "parallel",
|
|
14950
|
+
async execute(_id, input) {
|
|
14951
|
+
const params = localSchema.parse(input);
|
|
14952
|
+
const cwd = cwdOf(params);
|
|
14953
|
+
const vcs = await detectVcs(cwd);
|
|
14954
|
+
const session = await resolveSession(cwd, vcs.branch);
|
|
14955
|
+
if (!session)
|
|
14956
|
+
return result("No tuicr session found.");
|
|
14957
|
+
const normalized = toFindings(await readSession(session.path));
|
|
14958
|
+
return result(normalized.comments.map((comment) => `${comment.file}:${comment.line} — ${comment.body}`).join(`
|
|
14959
|
+
`) || "No comments.", { session, comments: normalized.comments });
|
|
14960
|
+
}
|
|
14961
|
+
}),
|
|
14962
|
+
defineTool3({
|
|
14963
|
+
name: "review_respond",
|
|
14964
|
+
label: "review respond",
|
|
14965
|
+
description: "Append a response to the local review record; remote responses should use the forge MCP thread tool.",
|
|
14966
|
+
promptSnippet: "Call review_respond after addressing a local comment",
|
|
14967
|
+
promptGuidelines: ["For remote reviews, prefer the forge MCP response and resolve tools."],
|
|
14968
|
+
parameters: parameters5(respondSchema),
|
|
14969
|
+
executionMode: "sequential",
|
|
14970
|
+
async execute(_id, input) {
|
|
14971
|
+
const params = respondSchema.parse(input);
|
|
14972
|
+
const cwd = cwdOf(params);
|
|
14973
|
+
const path = join10(await reviewsDir(cwd), `${reviewRecordName((await detectVcs(cwd)).branch)}.md`);
|
|
14974
|
+
await mkdir3(join10(path, ".."), { recursive: true });
|
|
14975
|
+
await writeFile2(path, `
|
|
14976
|
+
## Response${params.file ? ` — ${params.file}:${params.line ?? 1}` : ""}
|
|
14977
|
+
|
|
14978
|
+
${params.body}
|
|
14979
|
+
`, { encoding: "utf8", flag: "a" });
|
|
14980
|
+
return result(`Response recorded: ${path}`, { path });
|
|
14981
|
+
}
|
|
14982
|
+
}),
|
|
14983
|
+
defineTool3({
|
|
14984
|
+
name: "review_publish",
|
|
14985
|
+
label: "review publish",
|
|
14986
|
+
description: "Mark a draft ready and submit its pending forge review, optionally publishing a local tuicr session.",
|
|
14987
|
+
promptSnippet: "Call review_publish to publish",
|
|
14988
|
+
promptGuidelines: ["Pass local to publish a tuicr session first."],
|
|
14989
|
+
parameters: parameters5(eventSchema),
|
|
14990
|
+
executionMode: "sequential",
|
|
14991
|
+
async execute(_id, input) {
|
|
14992
|
+
const params = eventSchema.parse(input);
|
|
14993
|
+
const cwd = cwdOf(params);
|
|
14994
|
+
const vcs = await detectVcs(cwd);
|
|
14995
|
+
if (vcs.provider === "none")
|
|
14996
|
+
return result("No forge detected; local review remains in the shared store.");
|
|
14997
|
+
const forge = createForge(vcs);
|
|
14998
|
+
const pr = await forge.viewPr(vcs.branch);
|
|
14999
|
+
if (!pr)
|
|
15000
|
+
return result("No open PR/MR for this branch.");
|
|
15001
|
+
const event = params.event ?? "COMMENT";
|
|
15002
|
+
assertReviewEventSupported(forge.provider, event);
|
|
15003
|
+
let body = "";
|
|
15004
|
+
if (params.local) {
|
|
15005
|
+
const session = await resolveSession(cwd, vcs.branch);
|
|
15006
|
+
if (!session)
|
|
15007
|
+
return result("No tuicr session to publish.");
|
|
15008
|
+
const normalized = toFindings(await readSession(session.path));
|
|
15009
|
+
body = reviewSubmissionBody(normalized.body);
|
|
15010
|
+
await forge.createPendingReview(pr.number, normalized.comments, body);
|
|
15011
|
+
}
|
|
15012
|
+
if (pr.isDraft)
|
|
15013
|
+
await forge.markReady(pr.number);
|
|
15014
|
+
await forge.submitReview(pr.number, event, body);
|
|
15015
|
+
return result(`Published #${pr.number} (${event}).`, { pr, event });
|
|
15016
|
+
}
|
|
15017
|
+
}),
|
|
15018
|
+
defineTool3({
|
|
15019
|
+
name: "review_complete",
|
|
15020
|
+
label: "review complete",
|
|
15021
|
+
description: "Approve, request changes, close, or archive a review. This never merges.",
|
|
15022
|
+
promptSnippet: "Call review_complete to finish without merging",
|
|
15023
|
+
promptGuidelines: ["Use review_merge separately for merging."],
|
|
15024
|
+
parameters: parameters5(completeSchema),
|
|
15025
|
+
executionMode: "sequential",
|
|
15026
|
+
async execute(_id, input) {
|
|
15027
|
+
const params = completeSchema.parse(input);
|
|
15028
|
+
const cwd = cwdOf(params);
|
|
15029
|
+
const vcs = await detectVcs(cwd);
|
|
15030
|
+
if (params.action === "local") {
|
|
15031
|
+
const source = await resolveSession(cwd, vcs.branch);
|
|
15032
|
+
const dest = uniqueRecordPath(await reviewsDir(cwd), reviewRecordName(vcs.branch));
|
|
15033
|
+
if (source) {
|
|
15034
|
+
const normalized = toFindings(await readSession(source.path));
|
|
15035
|
+
await writeFile2(dest, `# Completed review: ${vcs.branch}
|
|
15036
|
+
|
|
15037
|
+
${normalized.body}
|
|
15038
|
+
|
|
15039
|
+
${normalized.comments.map((comment) => `- ${comment.file}:${comment.line} — ${comment.body}`).join(`
|
|
15040
|
+
`)}
|
|
15041
|
+
`, "utf8");
|
|
15042
|
+
} else
|
|
15043
|
+
await writeFile2(dest, `# Completed review: ${vcs.branch}
|
|
15044
|
+
`, "utf8");
|
|
15045
|
+
return result(`Local review archived: ${dest}`, { dest });
|
|
15046
|
+
}
|
|
15047
|
+
if (vcs.provider === "none")
|
|
15048
|
+
return result("No forge detected. Use action=local for a local review.");
|
|
15049
|
+
const forge = createForge(vcs);
|
|
15050
|
+
const pr = await forge.viewPr(vcs.branch);
|
|
15051
|
+
if (!pr)
|
|
15052
|
+
return result("No open PR/MR for this branch.");
|
|
15053
|
+
if (params.action === "accept") {
|
|
15054
|
+
if (pr.isDraft)
|
|
15055
|
+
await forge.markReady(pr.number);
|
|
15056
|
+
await forge.submitReview(pr.number, "APPROVE", params.comment ?? "Approved.");
|
|
15057
|
+
return result(`Approved #${pr.number}. Merge separately with review_merge.`, { pr });
|
|
15058
|
+
}
|
|
15059
|
+
if (params.action === "reject") {
|
|
15060
|
+
await forge.submitReview(pr.number, "REQUEST_CHANGES", params.comment ?? "Requesting changes.");
|
|
15061
|
+
return result(`Requested changes on #${pr.number}.`, { pr });
|
|
15062
|
+
}
|
|
15063
|
+
await forge.closePr(pr.number, params.comment);
|
|
15064
|
+
return result(`Closed #${pr.number}.`, { pr });
|
|
15065
|
+
}
|
|
15066
|
+
}),
|
|
15067
|
+
defineTool3({
|
|
15068
|
+
name: "review_merge",
|
|
15069
|
+
label: "review merge",
|
|
15070
|
+
description: "Squash-merge an approved GitHub PR after checking its conventional subject.",
|
|
15071
|
+
promptSnippet: "Call review_merge only after review_complete accept",
|
|
15072
|
+
promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
|
|
15073
|
+
parameters: parameters5(contextSchema.extend({ subject: z5.string().optional() })),
|
|
15074
|
+
executionMode: "sequential",
|
|
15075
|
+
async execute(_id, input) {
|
|
15076
|
+
const params = contextSchema.extend({ subject: z5.string().optional() }).parse(input);
|
|
15077
|
+
const cwd = cwdOf(params);
|
|
15078
|
+
const vcs = await detectVcs(cwd);
|
|
15079
|
+
if (vcs.provider !== "github")
|
|
15080
|
+
return result("review_merge currently supports GitHub only.");
|
|
15081
|
+
const pr = await createForge(vcs).viewPr(vcs.branch);
|
|
15082
|
+
if (!pr)
|
|
15083
|
+
return result("No open PR/MR for this branch.");
|
|
15084
|
+
const subject = params.subject ?? pr.title;
|
|
15085
|
+
const guard = conventionalMergeGuard(subject);
|
|
15086
|
+
if (guard.status !== "pass") {
|
|
15087
|
+
return result(`Merge blocked: ${guard.detail}`, { pr, guard });
|
|
15088
|
+
}
|
|
15089
|
+
const readiness = await runChecked("gh", [
|
|
15090
|
+
"pr",
|
|
15091
|
+
"view",
|
|
15092
|
+
String(pr.number),
|
|
15093
|
+
"--repo",
|
|
15094
|
+
`${vcs.owner}/${vcs.repo}`,
|
|
15095
|
+
"--json",
|
|
15096
|
+
"isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
|
|
15097
|
+
], { capture: "unbounded" });
|
|
15098
|
+
assertGitHubMergeReady(readiness.stdout);
|
|
15099
|
+
await runChecked("gh", [
|
|
15100
|
+
"pr",
|
|
15101
|
+
"merge",
|
|
15102
|
+
String(pr.number),
|
|
15103
|
+
"--repo",
|
|
15104
|
+
`${vcs.owner}/${vcs.repo}`,
|
|
15105
|
+
"--squash",
|
|
15106
|
+
"--subject",
|
|
15107
|
+
subject
|
|
15108
|
+
]);
|
|
15109
|
+
return result(`Merged #${pr.number} with subject: ${subject}.`, { pr, guard });
|
|
15110
|
+
}
|
|
15111
|
+
}),
|
|
15112
|
+
defineTool3({
|
|
15113
|
+
name: "review_launch",
|
|
15114
|
+
label: "review launch",
|
|
15115
|
+
description: "Open tuicr in a mux tab, configure its Zed task, or print the command.",
|
|
15116
|
+
promptSnippet: "Call review_launch for the interactive tuicr TUI",
|
|
15117
|
+
promptGuidelines: ["Show the returned command when launch cannot open a tab."],
|
|
15118
|
+
parameters: parameters5(contextSchema),
|
|
15119
|
+
executionMode: "sequential",
|
|
15120
|
+
async execute(_id, input) {
|
|
15121
|
+
const params = contextSchema.parse(input);
|
|
15122
|
+
const launchResult = await launch(cwdOf(params));
|
|
15123
|
+
return result(launchResult.launched ? `Opened tuicr (${launchResult.via}).` : launchResult.instruction ?? `Run: ${launchResult.command}`, {
|
|
15124
|
+
launch: launchResult
|
|
15125
|
+
});
|
|
15126
|
+
}
|
|
15127
|
+
})
|
|
15128
|
+
];
|
|
15129
|
+
}
|
|
15130
|
+
function deriveTitle(branch) {
|
|
15131
|
+
return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
|
|
15132
|
+
}
|
|
15133
|
+
function uniqueRecordPath(dir, base) {
|
|
15134
|
+
let path = join10(dir, `${base}.md`);
|
|
15135
|
+
let count = 2;
|
|
15136
|
+
while (existsSync4(path))
|
|
15137
|
+
path = join10(dir, `${base}-${count++}.md`);
|
|
15138
|
+
return path;
|
|
15139
|
+
}
|
|
15140
|
+
|
|
15141
|
+
// src/tools/setup.ts
|
|
15142
|
+
import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
|
|
15143
|
+
import { z as z6 } from "zod";
|
|
15144
|
+
|
|
15145
|
+
// src/setup.ts
|
|
15146
|
+
import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
|
|
15147
|
+
import { readdir as readdir2, readFile as readFile7 } from "node:fs/promises";
|
|
15148
|
+
import { homedir as homedir9 } from "node:os";
|
|
15149
|
+
import { basename as basename5, join as join14 } from "node:path";
|
|
15150
|
+
|
|
15151
|
+
// src/mcp.ts
|
|
15152
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
|
|
15153
|
+
import { homedir as homedir6 } from "node:os";
|
|
15154
|
+
import { dirname as dirname4, join as join11 } from "node:path";
|
|
15155
|
+
var mcp = {
|
|
15156
|
+
globalConfigPath(homeDir = homedir6()) {
|
|
15157
|
+
return join11(homeDir, ".config", "mcp", "mcp.json");
|
|
15158
|
+
},
|
|
15159
|
+
async serversEnsure(servers, options = {}) {
|
|
15160
|
+
const path = options.path ?? mcp.globalConfigPath();
|
|
15161
|
+
const currentText = await getOptionalFile(path);
|
|
15162
|
+
const current = getParsedConfig(currentText, path);
|
|
15163
|
+
const nextServers = { ...current.mcpServers };
|
|
15164
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
15165
|
+
nextServers[name] = mergeEntry(nextServers[name], entry);
|
|
15166
|
+
}
|
|
15167
|
+
const next = { ...current, mcpServers: nextServers };
|
|
15168
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
15169
|
+
if (changed && !options.dryRun) {
|
|
15170
|
+
await mkdir4(dirname4(path), { recursive: true });
|
|
15171
|
+
await writeFile3(path, `${JSON.stringify(next, null, 2)}
|
|
15172
|
+
`, "utf8");
|
|
15173
|
+
}
|
|
15174
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
15175
|
+
}
|
|
15176
|
+
};
|
|
15177
|
+
function mergeEntry(current, required) {
|
|
15178
|
+
const merged = { ...current, ...required };
|
|
15179
|
+
if (current?.env || required.env)
|
|
15180
|
+
merged.env = { ...current?.env, ...required.env };
|
|
15181
|
+
return merged;
|
|
15182
|
+
}
|
|
15183
|
+
function getParsedConfig(content, path) {
|
|
15184
|
+
if (!content?.trim())
|
|
15185
|
+
return { mcpServers: {} };
|
|
15186
|
+
try {
|
|
15187
|
+
const value = JSON.parse(content);
|
|
15188
|
+
if (!isRecord(value))
|
|
15189
|
+
throw new Error("not an object");
|
|
15190
|
+
const servers = value.mcpServers;
|
|
15191
|
+
if (servers !== undefined && !isRecord(servers))
|
|
15192
|
+
throw new Error("mcpServers is not an object");
|
|
15193
|
+
return { ...value, mcpServers: servers ?? {} };
|
|
15194
|
+
} catch {
|
|
15195
|
+
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
15196
|
+
}
|
|
15197
|
+
}
|
|
15198
|
+
async function getOptionalFile(path) {
|
|
15199
|
+
try {
|
|
15200
|
+
return await readFile5(path, "utf8");
|
|
15201
|
+
} catch (error) {
|
|
15202
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
15203
|
+
return;
|
|
15204
|
+
throw error;
|
|
15205
|
+
}
|
|
15206
|
+
}
|
|
15207
|
+
function isRecord(value) {
|
|
15208
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
15209
|
+
}
|
|
15210
|
+
|
|
13483
15211
|
// src/mise.ts
|
|
15212
|
+
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
|
|
15213
|
+
import { homedir as homedir7 } from "node:os";
|
|
15214
|
+
import { basename as basename4, dirname as dirname5, join as join12 } from "node:path";
|
|
13484
15215
|
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
13485
15216
|
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
13486
15217
|
var mise = {
|
|
@@ -13488,11 +15219,11 @@ var mise = {
|
|
|
13488
15219
|
return findExecutable(name);
|
|
13489
15220
|
},
|
|
13490
15221
|
async install(options = {}) {
|
|
13491
|
-
const homeDir = options.homeDir ??
|
|
15222
|
+
const homeDir = options.homeDir ?? homedir7();
|
|
13492
15223
|
const platform = options.platform ?? process.platform;
|
|
13493
15224
|
if (platform === "win32")
|
|
13494
15225
|
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
13495
|
-
const installedPath =
|
|
15226
|
+
const installedPath = join12(homeDir, ".local", "bin", "mise");
|
|
13496
15227
|
if (options.dryRun)
|
|
13497
15228
|
return installedPath;
|
|
13498
15229
|
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
@@ -13502,8 +15233,8 @@ var mise = {
|
|
|
13502
15233
|
return executable;
|
|
13503
15234
|
},
|
|
13504
15235
|
async hookEnsure(executable, options = {}) {
|
|
13505
|
-
const homeDir = options.homeDir ??
|
|
13506
|
-
const hook = getShellHook(
|
|
15236
|
+
const homeDir = options.homeDir ?? homedir7();
|
|
15237
|
+
const hook = getShellHook(basename4(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
13507
15238
|
const current = await getOptionalFile2(hook.path);
|
|
13508
15239
|
if (current.includes(MISE_HOOK_START))
|
|
13509
15240
|
return { path: hook.path, changed: false, planned: false };
|
|
@@ -13512,8 +15243,8 @@ var mise = {
|
|
|
13512
15243
|
const separator = current.length === 0 || current.endsWith(`
|
|
13513
15244
|
`) ? "" : `
|
|
13514
15245
|
`;
|
|
13515
|
-
await
|
|
13516
|
-
await
|
|
15246
|
+
await mkdir5(dirname5(hook.path), { recursive: true });
|
|
15247
|
+
await writeFile4(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
13517
15248
|
return { path: hook.path, changed: true, planned: false };
|
|
13518
15249
|
},
|
|
13519
15250
|
async toolCheckGlobal(executable, tool, minimumVersion) {
|
|
@@ -13530,7 +15261,7 @@ var mise = {
|
|
|
13530
15261
|
async toolInstallLocal(executable, specification, cwd = process.cwd()) {
|
|
13531
15262
|
await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
|
|
13532
15263
|
},
|
|
13533
|
-
async toolUpdateAllGlobal(executable, homeDir =
|
|
15264
|
+
async toolUpdateAllGlobal(executable, homeDir = homedir7()) {
|
|
13534
15265
|
await runChecked(executable, ["upgrade"], { cwd: homeDir });
|
|
13535
15266
|
}
|
|
13536
15267
|
};
|
|
@@ -13539,7 +15270,7 @@ function getShellHook(shell, executable, homeDir) {
|
|
|
13539
15270
|
switch (shell.toLowerCase()) {
|
|
13540
15271
|
case "zsh":
|
|
13541
15272
|
return {
|
|
13542
|
-
path:
|
|
15273
|
+
path: join12(homeDir, ".zshrc"),
|
|
13543
15274
|
content: `${MISE_HOOK_START}
|
|
13544
15275
|
eval "$(${command} activate zsh)"
|
|
13545
15276
|
${MISE_HOOK_END}
|
|
@@ -13547,7 +15278,7 @@ ${MISE_HOOK_END}
|
|
|
13547
15278
|
};
|
|
13548
15279
|
case "fish":
|
|
13549
15280
|
return {
|
|
13550
|
-
path:
|
|
15281
|
+
path: join12(homeDir, ".config", "fish", "config.fish"),
|
|
13551
15282
|
content: `${MISE_HOOK_START}
|
|
13552
15283
|
${command} activate fish | source
|
|
13553
15284
|
${MISE_HOOK_END}
|
|
@@ -13556,7 +15287,7 @@ ${MISE_HOOK_END}
|
|
|
13556
15287
|
case "nu":
|
|
13557
15288
|
case "nushell":
|
|
13558
15289
|
return {
|
|
13559
|
-
path:
|
|
15290
|
+
path: join12(homeDir, ".config", "nushell", "config.nu"),
|
|
13560
15291
|
content: `${MISE_HOOK_START}
|
|
13561
15292
|
let mise_bin = ${command}
|
|
13562
15293
|
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
@@ -13567,7 +15298,7 @@ ${MISE_HOOK_END}
|
|
|
13567
15298
|
};
|
|
13568
15299
|
case "xonsh":
|
|
13569
15300
|
return {
|
|
13570
|
-
path:
|
|
15301
|
+
path: join12(homeDir, ".xonshrc"),
|
|
13571
15302
|
content: `${MISE_HOOK_START}
|
|
13572
15303
|
execx($(${command} activate xonsh))
|
|
13573
15304
|
${MISE_HOOK_END}
|
|
@@ -13575,7 +15306,7 @@ ${MISE_HOOK_END}
|
|
|
13575
15306
|
};
|
|
13576
15307
|
case "elvish":
|
|
13577
15308
|
return {
|
|
13578
|
-
path:
|
|
15309
|
+
path: join12(homeDir, ".config", "elvish", "rc.elv"),
|
|
13579
15310
|
content: `${MISE_HOOK_START}
|
|
13580
15311
|
var mise: = (ns [&])
|
|
13581
15312
|
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
@@ -13586,7 +15317,7 @@ ${MISE_HOOK_END}
|
|
|
13586
15317
|
case "pwsh":
|
|
13587
15318
|
case "powershell":
|
|
13588
15319
|
return {
|
|
13589
|
-
path:
|
|
15320
|
+
path: join12(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
13590
15321
|
content: `${MISE_HOOK_START}
|
|
13591
15322
|
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
13592
15323
|
${MISE_HOOK_END}
|
|
@@ -13595,7 +15326,7 @@ ${MISE_HOOK_END}
|
|
|
13595
15326
|
case "bash":
|
|
13596
15327
|
default:
|
|
13597
15328
|
return {
|
|
13598
|
-
path:
|
|
15329
|
+
path: join12(homeDir, ".bashrc"),
|
|
13599
15330
|
content: `${MISE_HOOK_START}
|
|
13600
15331
|
eval "$(${command} activate bash)"
|
|
13601
15332
|
${MISE_HOOK_END}
|
|
@@ -13634,7 +15365,7 @@ function isVersionAtLeast(version, minimumVersion) {
|
|
|
13634
15365
|
}
|
|
13635
15366
|
async function getOptionalFile2(path) {
|
|
13636
15367
|
try {
|
|
13637
|
-
return await
|
|
15368
|
+
return await readFile6(path, "utf8");
|
|
13638
15369
|
} catch (error) {
|
|
13639
15370
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
13640
15371
|
return "";
|
|
@@ -13646,79 +15377,86 @@ function getShellQuoted(value) {
|
|
|
13646
15377
|
}
|
|
13647
15378
|
|
|
13648
15379
|
// src/pi.ts
|
|
13649
|
-
import { mkdir as
|
|
13650
|
-
import { homedir as
|
|
13651
|
-
import { dirname as
|
|
15380
|
+
import { mkdir as mkdir6, writeFile as writeFile5 } from "node:fs/promises";
|
|
15381
|
+
import { homedir as homedir8 } from "node:os";
|
|
15382
|
+
import { dirname as dirname6, join as join13 } from "node:path";
|
|
13652
15383
|
var pi = {
|
|
13653
|
-
|
|
13654
|
-
|
|
13655
|
-
|
|
13656
|
-
|
|
13657
|
-
|
|
13658
|
-
|
|
13659
|
-
|
|
13660
|
-
|
|
15384
|
+
executableCheck: findPiExecutable,
|
|
15385
|
+
packageList: listPiPackages,
|
|
15386
|
+
packageCheck: hasPiPackage,
|
|
15387
|
+
packageInstall: installPiPackage,
|
|
15388
|
+
agentDir: resolvePiAgentDir,
|
|
15389
|
+
agentEnsure: ensurePiAgent,
|
|
15390
|
+
skillCheckGlobal: checkGlobalPiSkill,
|
|
15391
|
+
skillInstallGlobal: installGlobalPiSkills,
|
|
15392
|
+
configEnsure: ensurePiConfig
|
|
15393
|
+
};
|
|
15394
|
+
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
15395
|
+
const path = join13(agentDir, "agents", filename);
|
|
15396
|
+
const currentText = await readTextIfExists(path);
|
|
15397
|
+
const changed = currentText !== content;
|
|
15398
|
+
if (changed && !dryRun) {
|
|
15399
|
+
await mkdir6(dirname6(path), { recursive: true });
|
|
15400
|
+
await writeFile5(path, content, "utf8");
|
|
15401
|
+
}
|
|
15402
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
15403
|
+
}
|
|
15404
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join13(homedir8(), ".agents", "skills")) {
|
|
15405
|
+
const roots = [join13(agentDir, "skills"), sharedSkillsDir];
|
|
15406
|
+
for (const root of roots) {
|
|
15407
|
+
if (await readTextIfExists(join13(root, name, "SKILL.md")) !== undefined)
|
|
13661
15408
|
return true;
|
|
13662
|
-
return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
|
|
13663
|
-
},
|
|
13664
|
-
async packageInstall(executable, source) {
|
|
13665
|
-
await runChecked(executable, ["install", source]);
|
|
13666
|
-
},
|
|
13667
|
-
agentDir(homeDir = homedir4()) {
|
|
13668
|
-
return getAgentDir(homeDir);
|
|
13669
|
-
},
|
|
13670
|
-
async skillCheckGlobal(name, agentDir = getAgentDir(), sharedSkillsDir = join6(homedir4(), ".agents", "skills")) {
|
|
13671
|
-
const roots = [join6(agentDir, "skills"), sharedSkillsDir];
|
|
13672
|
-
for (const root of roots) {
|
|
13673
|
-
if (await getOptionalFile3(join6(root, name, "SKILL.md")) !== undefined)
|
|
13674
|
-
return true;
|
|
13675
|
-
}
|
|
13676
|
-
return false;
|
|
13677
|
-
},
|
|
13678
|
-
async skillInstallGlobal(miseExecutable, source, names) {
|
|
13679
|
-
const selection = names.flatMap((name) => ["--skill", name]);
|
|
13680
|
-
await runChecked(miseExecutable, [
|
|
13681
|
-
"x",
|
|
13682
|
-
"node@22",
|
|
13683
|
-
"--",
|
|
13684
|
-
"npx",
|
|
13685
|
-
"-y",
|
|
13686
|
-
"skills",
|
|
13687
|
-
"add",
|
|
13688
|
-
source,
|
|
13689
|
-
...selection,
|
|
13690
|
-
"--global",
|
|
13691
|
-
"--agent",
|
|
13692
|
-
"pi",
|
|
13693
|
-
"--yes"
|
|
13694
|
-
]);
|
|
13695
|
-
},
|
|
13696
|
-
async configEnsure(path, update, dryRun = false) {
|
|
13697
|
-
const currentText = await getOptionalFile3(path);
|
|
13698
|
-
const current = getParsedObject(currentText, path);
|
|
13699
|
-
const next = update(current);
|
|
13700
|
-
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
13701
|
-
if (changed && !dryRun) {
|
|
13702
|
-
await mkdir3(dirname4(path), { recursive: true });
|
|
13703
|
-
await writeFile3(path, `${JSON.stringify(next, null, 2)}
|
|
13704
|
-
`, "utf8");
|
|
13705
|
-
}
|
|
13706
|
-
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
13707
15409
|
}
|
|
13708
|
-
|
|
13709
|
-
function getAgentDir(homeDir = homedir4()) {
|
|
13710
|
-
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join6(process.env.XDG_CONFIG_HOME, "pi") : join6(homeDir, ".pi", "agent"));
|
|
15410
|
+
return false;
|
|
13711
15411
|
}
|
|
13712
|
-
async function
|
|
13713
|
-
|
|
13714
|
-
|
|
13715
|
-
|
|
13716
|
-
|
|
13717
|
-
|
|
13718
|
-
|
|
15412
|
+
async function installGlobalPiSkills(miseExecutable, source, names) {
|
|
15413
|
+
const selection = names.flatMap((name) => ["--skill", name]);
|
|
15414
|
+
await runChecked(miseExecutable, [
|
|
15415
|
+
"x",
|
|
15416
|
+
"node@22",
|
|
15417
|
+
"--",
|
|
15418
|
+
"npx",
|
|
15419
|
+
"-y",
|
|
15420
|
+
"skills",
|
|
15421
|
+
"add",
|
|
15422
|
+
source,
|
|
15423
|
+
...selection,
|
|
15424
|
+
"--global",
|
|
15425
|
+
"--agent",
|
|
15426
|
+
"pi",
|
|
15427
|
+
"--yes"
|
|
15428
|
+
]);
|
|
15429
|
+
}
|
|
15430
|
+
async function ensurePiConfig(path, update, dryRun = false) {
|
|
15431
|
+
const currentText = await readTextIfExists(path);
|
|
15432
|
+
const current = parseJsonObject(currentText, path);
|
|
15433
|
+
const next = update(current);
|
|
15434
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
15435
|
+
if (changed && !dryRun) {
|
|
15436
|
+
await mkdir6(dirname6(path), { recursive: true });
|
|
15437
|
+
await writeFile5(path, `${JSON.stringify(next, null, 2)}
|
|
15438
|
+
`, "utf8");
|
|
13719
15439
|
}
|
|
15440
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
15441
|
+
}
|
|
15442
|
+
async function findPiExecutable() {
|
|
15443
|
+
return findExecutable("pi");
|
|
15444
|
+
}
|
|
15445
|
+
async function listPiPackages(executable) {
|
|
15446
|
+
return (await runChecked(executable, ["list"])).stdout;
|
|
15447
|
+
}
|
|
15448
|
+
function hasPiPackage(listOutput, source) {
|
|
15449
|
+
if (listOutput.includes(source))
|
|
15450
|
+
return true;
|
|
15451
|
+
return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
|
|
15452
|
+
}
|
|
15453
|
+
async function installPiPackage(executable, source) {
|
|
15454
|
+
await runChecked(executable, ["install", source]);
|
|
15455
|
+
}
|
|
15456
|
+
function resolvePiAgentDir(homeDir = homedir8()) {
|
|
15457
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join13(process.env.XDG_CONFIG_HOME, "pi") : join13(homeDir, ".pi", "agent"));
|
|
13720
15458
|
}
|
|
13721
|
-
function
|
|
15459
|
+
function parseJsonObject(content, path) {
|
|
13722
15460
|
if (!content?.trim())
|
|
13723
15461
|
return {};
|
|
13724
15462
|
try {
|
|
@@ -13760,9 +15498,14 @@ var PI_SKILL_SOURCES = [
|
|
|
13760
15498
|
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
|
|
13761
15499
|
];
|
|
13762
15500
|
var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
|
|
15501
|
+
var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
|
|
15502
|
+
var FORGE_DEPENDENCIES = {
|
|
15503
|
+
github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
|
|
15504
|
+
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
15505
|
+
};
|
|
13763
15506
|
async function ensureMise(options = {}) {
|
|
13764
|
-
const homeDir = options.homeDir ??
|
|
13765
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
15507
|
+
const homeDir = options.homeDir ?? homedir9();
|
|
15508
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join14(homeDir, ".local", "bin", "mise"));
|
|
13766
15509
|
if (current)
|
|
13767
15510
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
13768
15511
|
reportProgress(options, "Installing mise");
|
|
@@ -13791,7 +15534,10 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
|
|
|
13791
15534
|
async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
13792
15535
|
const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
|
|
13793
15536
|
const actions = [];
|
|
13794
|
-
|
|
15537
|
+
const dependencies = [...MISE_DEPENDENCIES];
|
|
15538
|
+
if (options.forge && options.forge !== "none")
|
|
15539
|
+
dependencies.push(FORGE_DEPENDENCIES[options.forge]);
|
|
15540
|
+
for (const dependency of dependencies) {
|
|
13795
15541
|
const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
|
|
13796
15542
|
if (installed) {
|
|
13797
15543
|
actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
|
|
@@ -13807,18 +15553,33 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
13807
15553
|
async function ensurePiPlugins(options = {}) {
|
|
13808
15554
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
13809
15555
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
13810
|
-
const webSearch = await pi.configEnsure(
|
|
15556
|
+
const webSearch = await pi.configEnsure(join14(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
13811
15557
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
13812
|
-
const lsp = await pi.configEnsure(
|
|
15558
|
+
const lsp = await pi.configEnsure(join14(agentDir, "pi-lsp.json"), (config) => ({
|
|
13813
15559
|
...config,
|
|
13814
15560
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
13815
15561
|
}), options.dryRun);
|
|
13816
15562
|
actions.push(getConfigSetupAction("pi-lsp settings", lsp));
|
|
13817
15563
|
return actions;
|
|
13818
15564
|
}
|
|
15565
|
+
async function ensurePiAgents(options = {}) {
|
|
15566
|
+
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
15567
|
+
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
|
|
15568
|
+
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
15569
|
+
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));
|
|
15570
|
+
const actions = [];
|
|
15571
|
+
for (const entry of entries) {
|
|
15572
|
+
const id = basename5(entry.name, ".md").replace(/^diffpi-/, "");
|
|
15573
|
+
const source = await readFile7(join14(bundledAgentsDir, entry.name), "utf8");
|
|
15574
|
+
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
15575
|
+
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
15576
|
+
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
15577
|
+
}
|
|
15578
|
+
return actions;
|
|
15579
|
+
}
|
|
13819
15580
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
13820
15581
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
13821
|
-
const sharedSkillsDir =
|
|
15582
|
+
const sharedSkillsDir = join14(options.homeDir ?? homedir9(), ".agents", "skills");
|
|
13822
15583
|
const actions = [];
|
|
13823
15584
|
for (const source of PI_SKILL_SOURCES) {
|
|
13824
15585
|
const missing = [];
|
|
@@ -13862,6 +15623,12 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
|
|
|
13862
15623
|
} else if (options.issueTracker === "jira") {
|
|
13863
15624
|
servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
|
|
13864
15625
|
}
|
|
15626
|
+
if (options.forge === "github") {
|
|
15627
|
+
servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
|
|
15628
|
+
} else if (options.forge === "gitlab") {
|
|
15629
|
+
const host = (await detectVcs(projectDir)).host || "gitlab.com";
|
|
15630
|
+
servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
|
|
15631
|
+
}
|
|
13865
15632
|
const result = await mcp.serversEnsure(servers, {
|
|
13866
15633
|
dryRun: options.dryRun,
|
|
13867
15634
|
path: mcp.globalConfigPath(options.homeDir)
|
|
@@ -13875,13 +15642,81 @@ async function setupPi(options = {}) {
|
|
|
13875
15642
|
actions.push(await ensureMiseHooks(miseResult.executable, options));
|
|
13876
15643
|
actions.push(...await ensureMiseDeps(miseResult.executable, options));
|
|
13877
15644
|
actions.push(...await ensurePiPlugins(options));
|
|
15645
|
+
actions.push(...await ensurePiAgents(options));
|
|
13878
15646
|
actions.push(...await ensurePiSkills(miseResult.executable, options));
|
|
13879
15647
|
actions.push(...await ensureMcpAdapters(miseResult.executable, options));
|
|
15648
|
+
if (options.bindZedKey)
|
|
15649
|
+
actions.push(...await ensureZedIntegration(options));
|
|
13880
15650
|
return {
|
|
13881
15651
|
actions,
|
|
13882
|
-
restartPi: actions
|
|
15652
|
+
restartPi: setupRequiresRestart(actions)
|
|
13883
15653
|
};
|
|
13884
15654
|
}
|
|
15655
|
+
function setupRequiresRestart(actions) {
|
|
15656
|
+
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"));
|
|
15657
|
+
}
|
|
15658
|
+
function materializeAgentModels(content, agentId, config, availableModels) {
|
|
15659
|
+
const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
15660
|
+
const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
|
|
15661
|
+
const preferences = resolveAgentModelPreferences(agentId, profilePreferences, config);
|
|
15662
|
+
let selectedIndex = availableModels === undefined && preferences.length > 0 ? 0 : -1;
|
|
15663
|
+
let selectedModel = selectedIndex === 0 ? preferences[0] : undefined;
|
|
15664
|
+
if (availableModels) {
|
|
15665
|
+
for (const [index, preference] of preferences.entries()) {
|
|
15666
|
+
const match = findPreferredModel(availableModels, preference);
|
|
15667
|
+
if (!match)
|
|
15668
|
+
continue;
|
|
15669
|
+
selectedIndex = index;
|
|
15670
|
+
selectedModel = `${match.provider}/${match.id}`;
|
|
15671
|
+
break;
|
|
15672
|
+
}
|
|
15673
|
+
}
|
|
15674
|
+
const fallbacks = preferences.filter((_preference, index) => index !== selectedIndex);
|
|
15675
|
+
return replaceAgentModelFields(content, selectedModel, fallbacks);
|
|
15676
|
+
}
|
|
15677
|
+
function replaceAgentModelFields(content, model, fallbacks) {
|
|
15678
|
+
const newline = content.includes(`\r
|
|
15679
|
+
`) ? `\r
|
|
15680
|
+
` : `
|
|
15681
|
+
`;
|
|
15682
|
+
const lines = content.replaceAll(`\r
|
|
15683
|
+
`, `
|
|
15684
|
+
`).split(`
|
|
15685
|
+
`);
|
|
15686
|
+
const closingDelimiter = lines.indexOf("---", 1);
|
|
15687
|
+
if (lines[0] !== "---" || closingDelimiter < 0)
|
|
15688
|
+
return content;
|
|
15689
|
+
const frontmatter = lines.slice(1, closingDelimiter).filter((line) => !/^model(?:_fallbacks)?:/.test(line));
|
|
15690
|
+
if (model)
|
|
15691
|
+
frontmatter.push(`model: ${model}`);
|
|
15692
|
+
if (fallbacks.length > 0)
|
|
15693
|
+
frontmatter.push(`model_fallbacks: ${fallbacks.join(", ")}`);
|
|
15694
|
+
return ["---", ...frontmatter, "---", ...lines.slice(closingDelimiter + 1)].join(newline);
|
|
15695
|
+
}
|
|
15696
|
+
async function ensureZedIntegration(options = {}) {
|
|
15697
|
+
if (options.dryRun) {
|
|
15698
|
+
const actions = [createSetupAction("Zed review task", "planned", "tasks.json")];
|
|
15699
|
+
if (options.bindZedKey)
|
|
15700
|
+
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
15701
|
+
return actions;
|
|
15702
|
+
}
|
|
15703
|
+
const actions = [];
|
|
15704
|
+
try {
|
|
15705
|
+
const task = await ensureZedReviewTask(options.homeDir);
|
|
15706
|
+
actions.push(createSetupAction("Zed review task", task.changed ? "installed" : "ready", task.path));
|
|
15707
|
+
} catch (error) {
|
|
15708
|
+
actions.push(createSetupAction("Zed review task", "skipped", error instanceof Error ? error.message : String(error)));
|
|
15709
|
+
}
|
|
15710
|
+
if (options.bindZedKey) {
|
|
15711
|
+
try {
|
|
15712
|
+
const key = await ensureZedReviewKeybinding(options.homeDir);
|
|
15713
|
+
actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
|
|
15714
|
+
} catch (error) {
|
|
15715
|
+
actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
|
|
15716
|
+
}
|
|
15717
|
+
}
|
|
15718
|
+
return actions;
|
|
15719
|
+
}
|
|
13885
15720
|
async function ensurePiPackages(packages, options) {
|
|
13886
15721
|
const executable = await pi.executableCheck();
|
|
13887
15722
|
if (!executable && !options.dryRun)
|
|
@@ -13903,6 +15738,10 @@ ${source}`;
|
|
|
13903
15738
|
}
|
|
13904
15739
|
return actions;
|
|
13905
15740
|
}
|
|
15741
|
+
function getTextList(value) {
|
|
15742
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
15743
|
+
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
15744
|
+
}
|
|
13906
15745
|
function getConfigSetupAction(name, result) {
|
|
13907
15746
|
if (!result.changed)
|
|
13908
15747
|
return createSetupAction(name, "ready", result.path);
|
|
@@ -13921,11 +15760,13 @@ function getRecord(value) {
|
|
|
13921
15760
|
}
|
|
13922
15761
|
|
|
13923
15762
|
// src/tools/setup.ts
|
|
13924
|
-
var setupParametersSchema =
|
|
13925
|
-
issueTracker:
|
|
15763
|
+
var setupParametersSchema = z6.object({
|
|
15764
|
+
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."),
|
|
15765
|
+
forge: z6.enum(["none", "github", "gitlab"]).default("none").describe("Forge to configure for /review. Installs gh or glab and registers its MCP server."),
|
|
15766
|
+
bindZedKey: z6.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
|
|
13926
15767
|
});
|
|
13927
|
-
var setupParameters =
|
|
13928
|
-
var diffpiSetupTool =
|
|
15768
|
+
var setupParameters = z6.toJSONSchema(setupParametersSchema, { io: "input" });
|
|
15769
|
+
var diffpiSetupTool = defineTool4({
|
|
13929
15770
|
name: "diffpi_setup",
|
|
13930
15771
|
label: "diffpi setup",
|
|
13931
15772
|
description: "Install or repair the @difflab/pi environment. This mutates user-level tool installations and configuration files.",
|
|
@@ -13938,11 +15779,14 @@ var diffpiSetupTool = defineTool2({
|
|
|
13938
15779
|
],
|
|
13939
15780
|
parameters: setupParameters,
|
|
13940
15781
|
executionMode: "sequential",
|
|
13941
|
-
async execute(_toolCallId, input, _signal, onUpdate) {
|
|
15782
|
+
async execute(_toolCallId, input, _signal, onUpdate, ctx) {
|
|
13942
15783
|
const params = setupParametersSchema.parse(input);
|
|
13943
15784
|
const result = await setupPi({
|
|
13944
15785
|
issueTracker: params.issueTracker,
|
|
15786
|
+
forge: params.forge,
|
|
15787
|
+
bindZedKey: params.bindZedKey,
|
|
13945
15788
|
installMiseHook: true,
|
|
15789
|
+
availableModels: ctx.modelRegistry.getAvailable(),
|
|
13946
15790
|
onProgress(message) {
|
|
13947
15791
|
onUpdate?.({ content: [{ type: "text", text: message }], details: {} });
|
|
13948
15792
|
}
|
|
@@ -13950,7 +15794,7 @@ var diffpiSetupTool = defineTool2({
|
|
|
13950
15794
|
return formatResult(result, "Setup complete.");
|
|
13951
15795
|
}
|
|
13952
15796
|
});
|
|
13953
|
-
var diffpiValidateTool =
|
|
15797
|
+
var diffpiValidateTool = defineTool4({
|
|
13954
15798
|
name: "diffpi_validate",
|
|
13955
15799
|
label: "diffpi validate",
|
|
13956
15800
|
description: "Inspect the @difflab/pi environment without installing software or changing configuration files.",
|
|
@@ -13962,12 +15806,15 @@ var diffpiValidateTool = defineTool2({
|
|
|
13962
15806
|
],
|
|
13963
15807
|
parameters: setupParameters,
|
|
13964
15808
|
executionMode: "sequential",
|
|
13965
|
-
async execute(_toolCallId, input) {
|
|
15809
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
13966
15810
|
const params = setupParametersSchema.parse(input);
|
|
13967
15811
|
const result = await setupPi({
|
|
13968
15812
|
issueTracker: params.issueTracker,
|
|
15813
|
+
forge: params.forge,
|
|
15814
|
+
bindZedKey: params.bindZedKey,
|
|
13969
15815
|
installMiseHook: true,
|
|
13970
|
-
dryRun: true
|
|
15816
|
+
dryRun: true,
|
|
15817
|
+
availableModels: ctx.modelRegistry.getAvailable()
|
|
13971
15818
|
});
|
|
13972
15819
|
const incomplete = result.actions.some((item) => item.status === "planned");
|
|
13973
15820
|
return formatResult(result, incomplete ? "Setup is incomplete." : "Setup is ready.");
|
|
@@ -13988,31 +15835,56 @@ ${lines.join(`
|
|
|
13988
15835
|
}
|
|
13989
15836
|
|
|
13990
15837
|
// src/tools/index.ts
|
|
13991
|
-
function createPiTools(pi) {
|
|
13992
|
-
return [
|
|
15838
|
+
function createPiTools(pi, modes) {
|
|
15839
|
+
return [
|
|
15840
|
+
diffpiSetupTool,
|
|
15841
|
+
diffpiValidateTool,
|
|
15842
|
+
createDiffpiReloadTool(pi),
|
|
15843
|
+
...createModeTools(modes),
|
|
15844
|
+
...createReviewTools()
|
|
15845
|
+
];
|
|
13993
15846
|
}
|
|
13994
15847
|
|
|
13995
15848
|
// extensions/index.ts
|
|
13996
15849
|
var RELOAD_COMMAND = "diffpi-reload";
|
|
13997
|
-
var
|
|
15850
|
+
var REVIEW_COMMAND = "review";
|
|
15851
|
+
var SKILL_ROUTING_GUIDANCE = `## Skill and tool routing
|
|
13998
15852
|
Use the docs-search skill before web search for library or API documentation.
|
|
13999
15853
|
Use docs-manage when the required documentation is absent or stale.
|
|
14000
|
-
Use fetch-url for a one-time read that does not belong in the documentation index
|
|
15854
|
+
Use fetch-url for a one-time read that does not belong in the documentation index.
|
|
15855
|
+
Use the context-mode skill for commands, tests, builds, logs, API responses, and other output that can be large.
|
|
15856
|
+
Use ctx_execute or ctx_execute_file to analyze that output, and use ctx_fetch_and_index with ctx_search for external documentation.`;
|
|
14001
15857
|
function difflabPiExtension(pi) {
|
|
14002
15858
|
rpiv_ask_user_question_default(pi);
|
|
15859
|
+
const modes = createModeController(pi);
|
|
14003
15860
|
pi.registerCommand(RELOAD_COMMAND, {
|
|
14004
15861
|
description: "Reload extensions, skills, prompts, themes, and context files",
|
|
14005
15862
|
handler: async (_args, ctx) => {
|
|
14006
15863
|
await ctx.reload();
|
|
14007
15864
|
}
|
|
14008
15865
|
});
|
|
14009
|
-
for (const tool of createPiTools(pi))
|
|
15866
|
+
for (const tool of createPiTools(pi, modes))
|
|
14010
15867
|
pi.registerTool(tool);
|
|
14011
|
-
pi.
|
|
14012
|
-
|
|
15868
|
+
pi.registerCommand(REVIEW_COMMAND, {
|
|
15869
|
+
description: "Code review: open, new, address, publish, complete, merge (add --local for tuicr)",
|
|
15870
|
+
handler: (args) => {
|
|
15871
|
+
const invocation = args.trim() || "help";
|
|
15872
|
+
pi.sendMessage({
|
|
15873
|
+
customType: "diffpi-review-command",
|
|
15874
|
+
display: false,
|
|
15875
|
+
content: `The user ran /review ${invocation}. Follow the review skill dispatcher. Call review_context first, then the matching review_* tools. Do not perform unrelated work.`
|
|
15876
|
+
}, { triggerTurn: true });
|
|
15877
|
+
return Promise.resolve();
|
|
15878
|
+
}
|
|
15879
|
+
});
|
|
15880
|
+
pi.on("session_start", async (_event, ctx) => modes.restore(ctx));
|
|
15881
|
+
pi.on("session_tree", async (_event, ctx) => modes.restore(ctx));
|
|
15882
|
+
pi.on("before_agent_start", (event) => {
|
|
15883
|
+
const defaultPrompt = `${event.systemPrompt}
|
|
14013
15884
|
|
|
14014
|
-
${
|
|
14015
|
-
|
|
15885
|
+
${SKILL_ROUTING_GUIDANCE}`;
|
|
15886
|
+
return { systemPrompt: modes.apply(defaultPrompt) };
|
|
15887
|
+
});
|
|
14016
15888
|
}
|
|
14017
15889
|
export {
|
|
14018
15890
|
difflabPiExtension as default
|