@oh-my-pi/pi-coding-agent 16.0.0 → 16.0.1
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/CHANGELOG.md +115 -133
- package/dist/cli.js +158 -130
- package/dist/types/config/settings-schema.d.ts +22 -0
- package/dist/types/discovery/helpers.d.ts +7 -0
- package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
- package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
- package/dist/types/modes/types.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +11 -1
- package/dist/types/session/session-manager.d.ts +4 -1
- package/dist/types/task/index.d.ts +21 -0
- package/dist/types/tools/github-cache.d.ts +5 -4
- package/dist/types/tools/job.d.ts +1 -0
- package/dist/types/web/search/index.d.ts +2 -2
- package/dist/types/web/search/provider.d.ts +2 -0
- package/package.json +12 -12
- package/src/cli/args.ts +1 -0
- package/src/collab/host.ts +1 -1
- package/src/config/settings-schema.ts +23 -1
- package/src/discovery/claude-plugins.ts +3 -42
- package/src/discovery/github.ts +101 -6
- package/src/discovery/helpers.ts +11 -0
- package/src/eval/__tests__/prelude-agent.test.ts +73 -0
- package/src/eval/js/shared/prelude.txt +12 -3
- package/src/eval/py/prelude.py +26 -2
- package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
- package/src/extensibility/plugins/loader.ts +3 -2
- package/src/extensibility/plugins/manager.ts +4 -3
- package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
- package/src/extensibility/plugins/runtime-config.ts +9 -0
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/internal-urls/issue-pr-protocol.ts +8 -4
- package/src/main.ts +5 -1
- package/src/modes/acp/acp-agent.ts +3 -3
- package/src/modes/components/settings-defs.ts +7 -0
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +4 -3
- package/src/modes/controllers/input-controller.ts +1 -0
- package/src/modes/controllers/selector-controller.ts +7 -0
- package/src/modes/interactive-mode.ts +47 -0
- package/src/modes/rpc/rpc-mode.ts +3 -3
- package/src/modes/runtime-init.ts +2 -1
- package/src/modes/types.ts +5 -0
- package/src/prompts/agents/designer.md +8 -0
- package/src/prompts/review-request.md +1 -1
- package/src/prompts/system/subagent-system-prompt.md +4 -1
- package/src/prompts/tools/eval.md +13 -3
- package/src/prompts/tools/irc.md +1 -1
- package/src/sdk.ts +9 -1
- package/src/session/agent-session.ts +125 -18
- package/src/session/session-manager.ts +3 -1
- package/src/slash-commands/builtin-registry.ts +5 -2
- package/src/task/executor.ts +5 -4
- package/src/task/index.ts +70 -9
- package/src/tools/github-cache.ts +32 -7
- package/src/tools/job.ts +14 -1
- package/src/web/search/index.ts +2 -2
- package/src/web/search/provider.ts +14 -2
package/src/eval/py/prelude.py
CHANGED
|
@@ -519,7 +519,7 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
519
519
|
text = res.get("text") if isinstance(res, dict) else res
|
|
520
520
|
return json.loads(text) if schema is not None else text
|
|
521
521
|
|
|
522
|
-
def agent(prompt, *, agent_type="task", model=None, label=None, schema=None):
|
|
522
|
+
def agent(prompt, *, agent_type="task", model=None, label=None, schema=None, return_handle=False):
|
|
523
523
|
"""Run a subagent and return its final output.
|
|
524
524
|
|
|
525
525
|
`agent_type` selects the subagent definition (default "task"). Pass
|
|
@@ -527,6 +527,15 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
527
527
|
id, and `schema` to request structured JSON output; when `schema` is
|
|
528
528
|
supplied the parsed object is returned. Share background by writing a
|
|
529
529
|
local:// file and referencing it in the prompt.
|
|
530
|
+
|
|
531
|
+
Set `return_handle=True` to receive a DAG node dict instead of bare
|
|
532
|
+
text: ``{"text", "output", "handle", "id", "agent"}`` where ``handle``
|
|
533
|
+
is the spawned agent's recoverable ``agent://<id>`` URI. A downstream
|
|
534
|
+
``pipeline``/``parallel`` stage embeds that ``handle`` (or ``output``)
|
|
535
|
+
in its prompt so a large transcript flows through the graph by
|
|
536
|
+
reference, never re-inlined. When ``schema`` is also set the parsed
|
|
537
|
+
object lands under ``"data"``. If the bridge returns no recoverable id
|
|
538
|
+
the node still resolves with ``handle=None`` — the helper never throws.
|
|
530
539
|
"""
|
|
531
540
|
args = {"prompt": prompt}
|
|
532
541
|
if agent_type is not None:
|
|
@@ -539,7 +548,22 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
539
548
|
args["schema"] = schema
|
|
540
549
|
res = _bridge_call("__agent__", args)
|
|
541
550
|
text = res.get("text") if isinstance(res, dict) else res
|
|
542
|
-
|
|
551
|
+
parsed = json.loads(text) if schema is not None else text
|
|
552
|
+
if not return_handle:
|
|
553
|
+
return parsed
|
|
554
|
+
details = res.get("details") if isinstance(res, dict) else None
|
|
555
|
+
if not isinstance(details, dict) or details.get("id") is None:
|
|
556
|
+
return {"text": text, "output": text, "handle": None, "id": None, "agent": None}
|
|
557
|
+
node = {
|
|
558
|
+
"text": text,
|
|
559
|
+
"output": text,
|
|
560
|
+
"handle": f"agent://{details['id']}",
|
|
561
|
+
"id": details["id"],
|
|
562
|
+
"agent": details.get("agent"),
|
|
563
|
+
}
|
|
564
|
+
if schema is not None:
|
|
565
|
+
node["data"] = parsed
|
|
566
|
+
return node
|
|
543
567
|
|
|
544
568
|
def _concurrency_limit():
|
|
545
569
|
"""Worker-pool ceiling from the host ``task.maxConcurrency`` setting.
|
|
@@ -17,6 +17,7 @@ import type { HookCommandContext } from "../../../../extensibility/hooks/types";
|
|
|
17
17
|
import reviewCustomRequestTemplate from "../../../../prompts/review-custom-request.md" with { type: "text" };
|
|
18
18
|
import reviewHeadlessRequestTemplate from "../../../../prompts/review-headless-request.md" with { type: "text" };
|
|
19
19
|
import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" };
|
|
20
|
+
import * as gh from "../../../../tools/gh";
|
|
20
21
|
import * as git from "../../../../utils/git";
|
|
21
22
|
import * as jj from "../../../../utils/jj";
|
|
22
23
|
|
|
@@ -45,6 +46,25 @@ interface CurrentReviewDiff {
|
|
|
45
46
|
mode: string;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
interface ReviewPrRef {
|
|
50
|
+
repo: string;
|
|
51
|
+
number: number;
|
|
52
|
+
raw: string;
|
|
53
|
+
kind: "github-url" | "pr-url";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ParsedReviewArgs {
|
|
57
|
+
prRef: ReviewPrRef | undefined;
|
|
58
|
+
extraInstructions: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ReviewMenuChoice =
|
|
62
|
+
| { kind: "detected-pr"; ref: ReviewPrRef }
|
|
63
|
+
| { kind: "base-branch" }
|
|
64
|
+
| { kind: "uncommitted" }
|
|
65
|
+
| { kind: "commit" }
|
|
66
|
+
| { kind: "custom" };
|
|
67
|
+
|
|
48
68
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
69
|
// Exclusion patterns for noise files
|
|
50
70
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -204,6 +224,7 @@ function getDiffPreview(hunks: string, maxLines: number): string {
|
|
|
204
224
|
const MAX_DIFF_CHARS = 50_000; // Don't include diff above this
|
|
205
225
|
const MAX_FILES_FOR_INLINE_DIFF = 20; // Don't include diff if more files than this
|
|
206
226
|
const DEFAULT_LARGE_DIFF_INSTRUCTION = "MUST run `git diff`/`git show` for assigned files";
|
|
227
|
+
const DEFAULT_CONTEXT_INSTRUCTION = "MAY read full file context as needed via `read`";
|
|
207
228
|
const GIT_UNCOMMITTED_DIFF_INSTRUCTION =
|
|
208
229
|
"MUST run both `git diff -- <path>` and `git diff --cached -- <path>` for assigned files";
|
|
209
230
|
const JJ_UNCOMMITTED_DIFF_INSTRUCTION = "MUST run `jj --ignore-working-copy diff --git -- <path>` for assigned files";
|
|
@@ -215,7 +236,7 @@ function buildReviewPrompt(
|
|
|
215
236
|
mode: string,
|
|
216
237
|
stats: DiffStats,
|
|
217
238
|
rawDiff: string,
|
|
218
|
-
options: { additionalInstructions?: string; diffInstruction?: string } = {},
|
|
239
|
+
options: { additionalInstructions?: string; diffInstruction?: string; contextInstruction?: string } = {},
|
|
219
240
|
): string {
|
|
220
241
|
const agentCount = getRecommendedAgentCount(stats);
|
|
221
242
|
const skipDiff = rawDiff.length > MAX_DIFF_CHARS || stats.files.length > MAX_FILES_FOR_INLINE_DIFF;
|
|
@@ -242,6 +263,7 @@ function buildReviewPrompt(
|
|
|
242
263
|
linesPerFile,
|
|
243
264
|
additionalInstructions: options.additionalInstructions,
|
|
244
265
|
diffInstruction: options.diffInstruction ?? DEFAULT_LARGE_DIFF_INSTRUCTION,
|
|
266
|
+
contextInstruction: options.contextInstruction ?? DEFAULT_CONTEXT_INSTRUCTION,
|
|
245
267
|
});
|
|
246
268
|
}
|
|
247
269
|
|
|
@@ -253,6 +275,203 @@ function buildHeadlessReviewPrompt(focus?: string): string {
|
|
|
253
275
|
return prompt.render(reviewHeadlessRequestTemplate, { focus });
|
|
254
276
|
}
|
|
255
277
|
|
|
278
|
+
const REVIEW_CONTEXT_PR_LIMIT = 3;
|
|
279
|
+
const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
280
|
+
const PR_SCHEME_PATTERN = /^pr:\/\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/([1-9]\d*)(?:\/diff(?:\/(?:all|[1-9]\d*))?)?$/;
|
|
281
|
+
const PR_REF_TEXT_PATTERN = /https:\/\/github\.com\/[^\s<>"']+|pr:\/\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[^\s<>"']+/g;
|
|
282
|
+
|
|
283
|
+
function stripTrailingPrRefPunctuation(text: string): string {
|
|
284
|
+
return text.replace(/[.,)\]>]+$/g, "");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isValidRepoSegment(segment: string | undefined): segment is string {
|
|
288
|
+
return segment !== undefined && REPO_SEGMENT_PATTERN.test(segment);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function parsePositivePrNumber(value: string | undefined): number | undefined {
|
|
292
|
+
if (value === undefined || !/^[1-9]\d*$/.test(value)) return undefined;
|
|
293
|
+
const parsed = Number(value);
|
|
294
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseGithubPrUrl(text: string): ReviewPrRef | undefined {
|
|
298
|
+
let url: URL;
|
|
299
|
+
try {
|
|
300
|
+
url = new URL(text);
|
|
301
|
+
} catch {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (url.protocol !== "https:" || url.hostname !== "github.com") return undefined;
|
|
306
|
+
|
|
307
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
308
|
+
if (parts.length < 4 || parts[2] !== "pull") return undefined;
|
|
309
|
+
|
|
310
|
+
const [owner, repo, , numberPart] = parts;
|
|
311
|
+
if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return undefined;
|
|
312
|
+
|
|
313
|
+
const number = parsePositivePrNumber(numberPart);
|
|
314
|
+
if (number === undefined) return undefined;
|
|
315
|
+
|
|
316
|
+
return { repo: `${owner}/${repo}`, number, raw: text, kind: "github-url" };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parsePrSchemeRef(text: string): ReviewPrRef | undefined {
|
|
320
|
+
const match = PR_SCHEME_PATTERN.exec(text);
|
|
321
|
+
if (!match) return undefined;
|
|
322
|
+
|
|
323
|
+
const [, owner, repo, numberPart] = match;
|
|
324
|
+
const number = parsePositivePrNumber(numberPart);
|
|
325
|
+
if (number === undefined) return undefined;
|
|
326
|
+
|
|
327
|
+
return { repo: `${owner}/${repo}`, number, raw: text, kind: "pr-url" };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function parseReviewPrRef(text: string): ReviewPrRef | undefined {
|
|
331
|
+
const candidate = stripTrailingPrRefPunctuation(text);
|
|
332
|
+
return parseGithubPrUrl(candidate) ?? parsePrSchemeRef(candidate);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function buildPrLargeDiffInstruction(ref: ReviewPrRef): string {
|
|
336
|
+
const prDiffUrl = `pr://${ref.repo}/${ref.number}/diff`;
|
|
337
|
+
return `MUST read assigned PR file diffs from \`${prDiffUrl}/all\` or per-file \`${prDiffUrl}/<index>\`; NEVER use local \`git diff\`/\`git show\` for PR diff content`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function buildPrContextInstruction(ref: ReviewPrRef): string {
|
|
341
|
+
const prDiffUrl = `pr://${ref.repo}/${ref.number}/diff`;
|
|
342
|
+
return `MUST NOT read local workspace files for PR file context; use the fetched PR diff and \`${prDiffUrl}/all\` or per-file \`${prDiffUrl}/<index>\` only`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function extractReviewPrRefFromArgs(args: string[]): ParsedReviewArgs {
|
|
346
|
+
let prRef: ReviewPrRef | undefined;
|
|
347
|
+
let prRefIndex = -1;
|
|
348
|
+
for (const [idx, arg] of args.entries()) {
|
|
349
|
+
const parsed = parseReviewPrRef(arg);
|
|
350
|
+
if (parsed) {
|
|
351
|
+
prRef = parsed;
|
|
352
|
+
prRefIndex = idx;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
prRef,
|
|
359
|
+
extraInstructions: args.filter((_, idx) => idx !== prRefIndex).join(" "),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function extractReviewPrRefsFromText(text: string): ReviewPrRef[] {
|
|
364
|
+
return Array.from(text.matchAll(PR_REF_TEXT_PATTERN), match => parseReviewPrRef(match[0])).filter(
|
|
365
|
+
(ref): ref is ReviewPrRef => ref !== undefined,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function buildReviewPromptFromDiff(
|
|
370
|
+
ctx: HookCommandContext,
|
|
371
|
+
mode: string,
|
|
372
|
+
diffText: string,
|
|
373
|
+
extraInstructions: string | undefined,
|
|
374
|
+
emptyMessage: string,
|
|
375
|
+
options: { diffInstruction?: string; filteredMessage?: string; contextInstruction?: string } = {},
|
|
376
|
+
): string | undefined {
|
|
377
|
+
if (!diffText.trim()) {
|
|
378
|
+
if (ctx.hasUI) ctx.ui.notify(emptyMessage, "warning");
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const stats = parseDiff(diffText);
|
|
383
|
+
if (stats.files.length === 0) {
|
|
384
|
+
if (ctx.hasUI)
|
|
385
|
+
ctx.ui.notify(options.filteredMessage ?? "No reviewable files (all changes filtered out)", "warning");
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return buildReviewPrompt(mode, stats, diffText, {
|
|
390
|
+
additionalInstructions: extraInstructions,
|
|
391
|
+
diffInstruction: options.diffInstruction,
|
|
392
|
+
contextInstruction: options.contextInstruction,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function buildPrReviewPrompt(
|
|
397
|
+
api: CustomCommandAPI,
|
|
398
|
+
ctx: HookCommandContext,
|
|
399
|
+
ref: ReviewPrRef,
|
|
400
|
+
extraInstructions: string,
|
|
401
|
+
): Promise<string | undefined> {
|
|
402
|
+
let diffText: string;
|
|
403
|
+
try {
|
|
404
|
+
const lookup = await gh.getOrFetchPrDiff({ cwd: api.cwd, repo: ref.repo, number: ref.number });
|
|
405
|
+
diffText = lookup.payload.unified;
|
|
406
|
+
} catch (err) {
|
|
407
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
408
|
+
const failure = `Failed to fetch PR diff for ${ref.repo}#${ref.number}: ${message}`;
|
|
409
|
+
if (ctx.hasUI) {
|
|
410
|
+
ctx.ui.notify(failure, "error");
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
return failure;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const promptText = buildReviewPromptFromDiff(
|
|
417
|
+
ctx,
|
|
418
|
+
`PR ${ref.repo}#${ref.number}`,
|
|
419
|
+
diffText,
|
|
420
|
+
extraInstructions || undefined,
|
|
421
|
+
`PR ${ref.repo}#${ref.number} has no diff content available`,
|
|
422
|
+
{ diffInstruction: buildPrLargeDiffInstruction(ref), contextInstruction: buildPrContextInstruction(ref) },
|
|
423
|
+
);
|
|
424
|
+
if (promptText !== undefined || ctx.hasUI) return promptText;
|
|
425
|
+
return `Unable to review PR ${ref.repo}#${ref.number}: no diff content available.`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
429
|
+
return typeof value === "object" && value !== null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function getTextContentParts(content: unknown): string[] {
|
|
433
|
+
if (typeof content === "string") return [content];
|
|
434
|
+
if (!Array.isArray(content)) return [];
|
|
435
|
+
|
|
436
|
+
const parts: string[] = [];
|
|
437
|
+
for (const item of content) {
|
|
438
|
+
if (isRecord(item) && item.type === "text" && typeof item.text === "string") {
|
|
439
|
+
parts.push(item.text);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return parts;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function findRecentPrRefs(ctx: HookCommandContext, limit: number): ReviewPrRef[] {
|
|
446
|
+
const refs: ReviewPrRef[] = [];
|
|
447
|
+
const seen = new Set<string>();
|
|
448
|
+
const entries = ctx.sessionManager.getBranch();
|
|
449
|
+
|
|
450
|
+
for (let idx = entries.length - 1; idx >= 0 && refs.length < limit; idx--) {
|
|
451
|
+
const entry = entries[idx];
|
|
452
|
+
if (entry?.type !== "message") continue;
|
|
453
|
+
const message = entry.message;
|
|
454
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
455
|
+
|
|
456
|
+
const parts = getTextContentParts(message.content);
|
|
457
|
+
for (let partIdx = parts.length - 1; partIdx >= 0; partIdx--) {
|
|
458
|
+
const part = parts[partIdx];
|
|
459
|
+
const partRefs = extractReviewPrRefsFromText(part);
|
|
460
|
+
for (let refIdx = partRefs.length - 1; refIdx >= 0; refIdx--) {
|
|
461
|
+
const ref = partRefs[refIdx];
|
|
462
|
+
const key = `${ref.repo.toLowerCase()}#${ref.number}`;
|
|
463
|
+
if (seen.has(key)) continue;
|
|
464
|
+
seen.add(key);
|
|
465
|
+
refs.push(ref);
|
|
466
|
+
if (refs.length >= limit) break;
|
|
467
|
+
}
|
|
468
|
+
if (refs.length >= limit) break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return refs;
|
|
473
|
+
}
|
|
474
|
+
|
|
256
475
|
export class ReviewCommand implements CustomCommand {
|
|
257
476
|
name = "review";
|
|
258
477
|
description = "Launch interactive code review";
|
|
@@ -260,36 +479,56 @@ export class ReviewCommand implements CustomCommand {
|
|
|
260
479
|
constructor(private api: CustomCommandAPI) {}
|
|
261
480
|
|
|
262
481
|
async execute(args: string[], ctx: HookCommandContext): Promise<string | undefined> {
|
|
263
|
-
|
|
264
|
-
|
|
482
|
+
const parsedArgs = extractReviewPrRefFromArgs(args);
|
|
483
|
+
if (parsedArgs.prRef) {
|
|
484
|
+
return buildPrReviewPrompt(this.api, ctx, parsedArgs.prRef, parsedArgs.extraInstructions);
|
|
265
485
|
}
|
|
266
486
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
487
|
+
const extraInstructions = parsedArgs.extraInstructions || undefined;
|
|
488
|
+
if (!ctx.hasUI) {
|
|
489
|
+
return buildHeadlessReviewPrompt(extraInstructions);
|
|
490
|
+
}
|
|
270
491
|
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
492
|
+
const choices: Array<{ label: string; value: ReviewMenuChoice }> = [
|
|
493
|
+
...findRecentPrRefs(ctx, REVIEW_CONTEXT_PR_LIMIT).map(ref => ({
|
|
494
|
+
label: `Review PR ${ref.repo}#${ref.number} from conversation`,
|
|
495
|
+
value: { kind: "detected-pr" as const, ref },
|
|
496
|
+
})),
|
|
497
|
+
{
|
|
498
|
+
label: "1. Review against a base branch (PR Style)",
|
|
499
|
+
value: { kind: "base-branch" },
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
label: "2. Review uncommitted changes",
|
|
503
|
+
value: { kind: "uncommitted" },
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
label: "3. Review a specific commit",
|
|
507
|
+
value: { kind: "commit" },
|
|
508
|
+
},
|
|
509
|
+
];
|
|
510
|
+
|
|
511
|
+
if (!extraInstructions) {
|
|
512
|
+
choices.push({
|
|
513
|
+
label: "4. Custom review instructions",
|
|
514
|
+
value: { kind: "custom" },
|
|
515
|
+
});
|
|
516
|
+
}
|
|
283
517
|
|
|
284
|
-
const
|
|
518
|
+
const selected = await ctx.ui.select(
|
|
519
|
+
"Review Mode",
|
|
520
|
+
choices.map(choice => choice.label),
|
|
521
|
+
);
|
|
522
|
+
if (!selected) return undefined;
|
|
285
523
|
|
|
286
|
-
|
|
524
|
+
const selectedChoice = choices.find(choice => choice.label === selected)?.value;
|
|
525
|
+
if (!selectedChoice) return undefined;
|
|
287
526
|
|
|
288
|
-
|
|
527
|
+
switch (selectedChoice.kind) {
|
|
528
|
+
case "detected-pr":
|
|
529
|
+
return buildPrReviewPrompt(this.api, ctx, selectedChoice.ref, extraInstructions ?? "");
|
|
289
530
|
|
|
290
|
-
|
|
291
|
-
case 1: {
|
|
292
|
-
// PR-style review against base branch
|
|
531
|
+
case "base-branch": {
|
|
293
532
|
const branches = await getGitBranches(this.api);
|
|
294
533
|
if (branches.length === 0) {
|
|
295
534
|
ctx.ui.notify("No git branches found", "error");
|
|
@@ -308,62 +547,43 @@ export class ReviewCommand implements CustomCommand {
|
|
|
308
547
|
return undefined;
|
|
309
548
|
}
|
|
310
549
|
|
|
311
|
-
|
|
312
|
-
ctx
|
|
313
|
-
return undefined;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const stats = parseDiff(diffText);
|
|
317
|
-
if (stats.files.length === 0) {
|
|
318
|
-
ctx.ui.notify("No reviewable files (all changes filtered out)", "warning");
|
|
319
|
-
return undefined;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
return buildReviewPrompt(
|
|
550
|
+
return buildReviewPromptFromDiff(
|
|
551
|
+
ctx,
|
|
323
552
|
`Reviewing changes between \`${baseBranch}\` and \`${currentBranch}\` (PR-style)`,
|
|
324
|
-
stats,
|
|
325
553
|
diffText,
|
|
326
|
-
|
|
554
|
+
extraInstructions,
|
|
555
|
+
`No changes between ${baseBranch} and ${currentBranch}`,
|
|
327
556
|
);
|
|
328
557
|
}
|
|
329
558
|
|
|
330
|
-
case
|
|
559
|
+
case "uncommitted": {
|
|
331
560
|
const reviewDiff = await getUncommittedReviewDiff(this.api).catch(err => {
|
|
332
561
|
ctx.ui.notify(`Failed to get diff: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
333
562
|
return undefined;
|
|
334
563
|
});
|
|
335
564
|
if (!reviewDiff) return undefined;
|
|
336
565
|
|
|
337
|
-
|
|
338
|
-
ctx
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
return undefined;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
return buildReviewPrompt(reviewDiff.mode, stats, reviewDiff.diffText, {
|
|
349
|
-
additionalInstructions: extraInstructions,
|
|
350
|
-
diffInstruction: reviewDiff.diffInstruction,
|
|
351
|
-
});
|
|
566
|
+
return buildReviewPromptFromDiff(
|
|
567
|
+
ctx,
|
|
568
|
+
reviewDiff.mode,
|
|
569
|
+
reviewDiff.diffText,
|
|
570
|
+
extraInstructions,
|
|
571
|
+
reviewDiff.emptyMessage ?? "No diff content found",
|
|
572
|
+
{ diffInstruction: reviewDiff.diffInstruction },
|
|
573
|
+
);
|
|
352
574
|
}
|
|
353
575
|
|
|
354
|
-
case
|
|
355
|
-
// Specific commit
|
|
576
|
+
case "commit": {
|
|
356
577
|
const commits = await getRecentCommits(this.api, 20);
|
|
357
578
|
if (commits.length === 0) {
|
|
358
579
|
ctx.ui.notify("No commits found", "error");
|
|
359
580
|
return undefined;
|
|
360
581
|
}
|
|
361
582
|
|
|
362
|
-
const
|
|
363
|
-
if (!
|
|
583
|
+
const selectedCommit = await ctx.ui.select("Select commit to review", commits);
|
|
584
|
+
if (!selectedCommit) return undefined;
|
|
364
585
|
|
|
365
|
-
|
|
366
|
-
const hash = selected.split(" ")[0];
|
|
586
|
+
const hash = selectedCommit.split(" ")[0];
|
|
367
587
|
|
|
368
588
|
let diffText: string;
|
|
369
589
|
try {
|
|
@@ -373,24 +593,17 @@ export class ReviewCommand implements CustomCommand {
|
|
|
373
593
|
return undefined;
|
|
374
594
|
}
|
|
375
595
|
|
|
376
|
-
|
|
377
|
-
ctx
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
return undefined;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, diffText, {
|
|
388
|
-
additionalInstructions: extraInstructions,
|
|
389
|
-
});
|
|
596
|
+
return buildReviewPromptFromDiff(
|
|
597
|
+
ctx,
|
|
598
|
+
`Reviewing commit \`${hash}\``,
|
|
599
|
+
diffText,
|
|
600
|
+
extraInstructions,
|
|
601
|
+
"Commit has no diff content",
|
|
602
|
+
{ filteredMessage: "No reviewable files in commit (all changes filtered out)" },
|
|
603
|
+
);
|
|
390
604
|
}
|
|
391
605
|
|
|
392
|
-
case
|
|
393
|
-
// Custom instructions with opportunistic current-diff context.
|
|
606
|
+
case "custom": {
|
|
394
607
|
const instructions = await ctx.ui.editor(
|
|
395
608
|
"Enter custom review instructions",
|
|
396
609
|
"Review the following:\n\n",
|
|
@@ -403,7 +616,6 @@ export class ReviewCommand implements CustomCommand {
|
|
|
403
616
|
|
|
404
617
|
if (reviewDiff?.diffText.trim()) {
|
|
405
618
|
const stats = parseDiff(reviewDiff.diffText);
|
|
406
|
-
// Even if all files filtered, include the custom instructions
|
|
407
619
|
return buildReviewPrompt(
|
|
408
620
|
`Custom review: ${instructions.split("\n")[0].slice(0, 60)}…`,
|
|
409
621
|
stats,
|
|
@@ -417,9 +629,6 @@ export class ReviewCommand implements CustomCommand {
|
|
|
417
629
|
|
|
418
630
|
return buildCustomReviewPrompt(instructions);
|
|
419
631
|
}
|
|
420
|
-
|
|
421
|
-
default:
|
|
422
|
-
return undefined;
|
|
423
632
|
}
|
|
424
633
|
}
|
|
425
634
|
}
|
|
@@ -9,6 +9,7 @@ import * as path from "node:path";
|
|
|
9
9
|
import { getPluginsLockfile, getPluginsNodeModules, getPluginsPackageJson, isEnoent } from "@oh-my-pi/pi-utils";
|
|
10
10
|
import { getConfigDirPaths } from "../../config";
|
|
11
11
|
import { installLegacyPiSpecifierShim } from "./legacy-pi-compat";
|
|
12
|
+
import { normalizePluginRuntimeConfig } from "./runtime-config";
|
|
12
13
|
import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types";
|
|
13
14
|
|
|
14
15
|
installLegacyPiSpecifierShim();
|
|
@@ -28,9 +29,9 @@ installLegacyPiSpecifierShim();
|
|
|
28
29
|
async function loadRuntimeConfig(home?: string): Promise<PluginRuntimeConfig> {
|
|
29
30
|
const lockPath = getPluginsLockfile(home);
|
|
30
31
|
try {
|
|
31
|
-
return await Bun.file(lockPath).json();
|
|
32
|
+
return normalizePluginRuntimeConfig(await Bun.file(lockPath).json());
|
|
32
33
|
} catch (err) {
|
|
33
|
-
if (isEnoent(err)) return {
|
|
34
|
+
if (isEnoent(err)) return normalizePluginRuntimeConfig({});
|
|
34
35
|
throw err;
|
|
35
36
|
}
|
|
36
37
|
}
|
|
@@ -15,6 +15,7 @@ import { type GitSource, parseGitUrl } from "./git-url";
|
|
|
15
15
|
import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "./legacy-pi-compat";
|
|
16
16
|
import { resolvePluginManifestEntries } from "./loader";
|
|
17
17
|
import { extractPackageName, parsePluginSpec } from "./parser";
|
|
18
|
+
import { normalizePluginRuntimeConfig } from "./runtime-config";
|
|
18
19
|
import type {
|
|
19
20
|
DoctorCheck,
|
|
20
21
|
DoctorOptions,
|
|
@@ -124,11 +125,11 @@ export class PluginManager {
|
|
|
124
125
|
async #loadRuntimeConfig(): Promise<PluginRuntimeConfig> {
|
|
125
126
|
const lockPath = getPluginsLockfile();
|
|
126
127
|
try {
|
|
127
|
-
return await Bun.file(lockPath).json();
|
|
128
|
+
return normalizePluginRuntimeConfig(await Bun.file(lockPath).json());
|
|
128
129
|
} catch (err) {
|
|
129
|
-
if (isEnoent(err)) return {
|
|
130
|
+
if (isEnoent(err)) return normalizePluginRuntimeConfig({});
|
|
130
131
|
logger.warn("Failed to load plugin runtime config", { path: lockPath, error: String(err) });
|
|
131
|
-
return {
|
|
132
|
+
return normalizePluginRuntimeConfig({});
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
@@ -192,8 +192,33 @@ export function parseMarketplaceCatalog(content: string, filePath: string): Mark
|
|
|
192
192
|
|
|
193
193
|
// ── fetchMarketplace ──────────────────────────────────────────────────
|
|
194
194
|
|
|
195
|
-
/**
|
|
196
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Catalog paths tried in priority order: omp-namespaced override first, then
|
|
197
|
+
* the Claude Code-compatible fallback so existing marketplaces keep loading.
|
|
198
|
+
*/
|
|
199
|
+
const CATALOG_RELATIVE_PATHS: readonly string[] = [
|
|
200
|
+
path.join(".omp-plugin", "marketplace.json"),
|
|
201
|
+
path.join(".claude-plugin", "marketplace.json"),
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
async function readMarketplaceCatalog(root: string): Promise<{ catalogPath: string; content: string }> {
|
|
205
|
+
const tried: string[] = [];
|
|
206
|
+
for (const rel of CATALOG_RELATIVE_PATHS) {
|
|
207
|
+
const catalogPath = path.join(root, rel);
|
|
208
|
+
tried.push(catalogPath);
|
|
209
|
+
try {
|
|
210
|
+
const content = await Bun.file(catalogPath).text();
|
|
211
|
+
return { catalogPath, content };
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (isEnoent(err)) continue;
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Marketplace catalog not found at ${tried.map(p => `"${p}"`).join(" or ")}. ` +
|
|
219
|
+
`Ensure the directory exists and contains one of: ${CATALOG_RELATIVE_PATHS.join(", ")}.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
197
222
|
|
|
198
223
|
/**
|
|
199
224
|
* Expand a `~/...` path to an absolute path using os.homedir().
|
|
@@ -220,21 +245,7 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis
|
|
|
220
245
|
|
|
221
246
|
if (type === "local") {
|
|
222
247
|
const resolved = path.resolve(expandHome(source));
|
|
223
|
-
const catalogPath =
|
|
224
|
-
|
|
225
|
-
let content: string;
|
|
226
|
-
try {
|
|
227
|
-
content = await Bun.file(catalogPath).text();
|
|
228
|
-
} catch (err) {
|
|
229
|
-
if (isEnoent(err)) {
|
|
230
|
-
throw new Error(
|
|
231
|
-
`Marketplace catalog not found at "${catalogPath}". ` +
|
|
232
|
-
`Ensure the directory exists and contains a .claude-plugin/marketplace.json file.`,
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
throw err;
|
|
236
|
-
}
|
|
237
|
-
|
|
248
|
+
const { catalogPath, content } = await readMarketplaceCatalog(resolved);
|
|
238
249
|
const catalog = parseMarketplaceCatalog(content, catalogPath);
|
|
239
250
|
return { catalog };
|
|
240
251
|
}
|
|
@@ -280,27 +291,14 @@ async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<Fetch
|
|
|
280
291
|
logger.debug(`[marketplace] cloning ${url} → ${tmpDir}`);
|
|
281
292
|
await git.clone(url, tmpDir);
|
|
282
293
|
|
|
283
|
-
const catalogPath = path.join(tmpDir, CATALOG_RELATIVE_PATH);
|
|
284
|
-
let content: string;
|
|
285
294
|
try {
|
|
286
|
-
content = await
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (isEnoent(err)) {
|
|
290
|
-
throw new Error(`Cloned repository has no marketplace catalog at ${CATALOG_RELATIVE_PATH}`);
|
|
291
|
-
}
|
|
292
|
-
throw err;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
let catalog: MarketplaceCatalog;
|
|
296
|
-
try {
|
|
297
|
-
catalog = parseMarketplaceCatalog(content, catalogPath);
|
|
295
|
+
const { catalogPath, content } = await readMarketplaceCatalog(tmpDir);
|
|
296
|
+
const catalog = parseMarketplaceCatalog(content, catalogPath);
|
|
297
|
+
return { catalog, clonePath: tmpDir };
|
|
298
298
|
} catch (err) {
|
|
299
299
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
300
|
-
throw err;
|
|
300
|
+
throw new Error(`Cloned repository ${url}: ${(err as Error).message}`, { cause: err });
|
|
301
301
|
}
|
|
302
|
-
|
|
303
|
-
return { catalog, clonePath: tmpDir };
|
|
304
302
|
}
|
|
305
303
|
|
|
306
304
|
/**
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PluginRuntimeConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Normalizes persisted plugin runtime config across legacy lockfile shapes. */
|
|
4
|
+
export function normalizePluginRuntimeConfig(config: Partial<PluginRuntimeConfig>): PluginRuntimeConfig {
|
|
5
|
+
return {
|
|
6
|
+
plugins: config.plugins ?? {},
|
|
7
|
+
settings: config.settings ?? {},
|
|
8
|
+
};
|
|
9
|
+
}
|