opencode-magi 0.1.0

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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +159 -0
  3. package/dist/commands.js +18 -0
  4. package/dist/config/load.js +62 -0
  5. package/dist/config/output.js +16 -0
  6. package/dist/config/resolve.js +113 -0
  7. package/dist/config/validate.js +567 -0
  8. package/dist/github/commands.js +398 -0
  9. package/dist/github/retry.js +44 -0
  10. package/dist/index.js +536 -0
  11. package/dist/orchestrator/abort.js +9 -0
  12. package/dist/orchestrator/ci.js +568 -0
  13. package/dist/orchestrator/findings.js +66 -0
  14. package/dist/orchestrator/majority.js +48 -0
  15. package/dist/orchestrator/merge.js +836 -0
  16. package/dist/orchestrator/model.js +202 -0
  17. package/dist/orchestrator/pool.js +15 -0
  18. package/dist/orchestrator/report.js +168 -0
  19. package/dist/orchestrator/review.js +790 -0
  20. package/dist/orchestrator/run-manager.js +1663 -0
  21. package/dist/orchestrator/safety.js +44 -0
  22. package/dist/permissions/common.json +24 -0
  23. package/dist/permissions/editor.json +7 -0
  24. package/dist/prompts/compose.js +298 -0
  25. package/dist/prompts/contracts.js +189 -0
  26. package/dist/prompts/output.js +260 -0
  27. package/dist/prompts/templates/ci-classification-after-edit.md +16 -0
  28. package/dist/prompts/templates/ci-classification.md +9 -0
  29. package/dist/prompts/templates/close-reconsideration.md +6 -0
  30. package/dist/prompts/templates/edit.md +9 -0
  31. package/dist/prompts/templates/finding-validation.md +7 -0
  32. package/dist/prompts/templates/rereview-close-reconsideration.md +6 -0
  33. package/dist/prompts/templates/rereview.md +16 -0
  34. package/dist/prompts/templates/review.md +7 -0
  35. package/dist/types.js +1 -0
  36. package/package.json +69 -0
  37. package/schema.json +200 -0
package/dist/index.js ADDED
@@ -0,0 +1,536 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { exec as nodeExec } from "node:child_process";
3
+ import { readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { MAGI_COMMANDS } from "./commands";
8
+ import { loadConfig, mergeMagiConfig } from "./config/load";
9
+ import { outputBaseDirs } from "./config/output";
10
+ import { resolveRepository } from "./config/resolve";
11
+ import { validateConfig } from "./config/validate";
12
+ import { withGitHubApiRetry } from "./github/retry";
13
+ import { mapPool } from "./orchestrator/pool";
14
+ import { MagiRunManager } from "./orchestrator/run-manager";
15
+ const execAsync = promisify(nodeExec);
16
+ const GLOBAL_CONFIG_PATH = join(homedir(), ".config", "opencode", "magi.json");
17
+ const PROJECT_CONFIG_PATH = join(".opencode", "magi.json");
18
+ function createExec(defaultCwd) {
19
+ return async (command, options) => {
20
+ const { stdout } = await execAsync(command, {
21
+ cwd: options?.cwd ?? defaultCwd,
22
+ env: { ...process.env, ...options?.env },
23
+ maxBuffer: 1024 * 1024 * 20,
24
+ signal: options?.signal,
25
+ });
26
+ return stdout;
27
+ };
28
+ }
29
+ function responseData(result) {
30
+ if (!result || typeof result !== "object")
31
+ return result;
32
+ return result.data ?? result;
33
+ }
34
+ function extractModelCatalog(result) {
35
+ const data = responseData(result);
36
+ if (!data || typeof data !== "object")
37
+ return undefined;
38
+ const providers = data.providers ??
39
+ data.all;
40
+ if (!Array.isArray(providers))
41
+ return undefined;
42
+ const catalog = {};
43
+ for (const provider of providers) {
44
+ if (!provider || typeof provider !== "object")
45
+ continue;
46
+ const id = provider.id;
47
+ const models = provider.models;
48
+ if (typeof id !== "string" || !models || typeof models !== "object")
49
+ continue;
50
+ catalog[id] = Object.keys(models);
51
+ }
52
+ return catalog;
53
+ }
54
+ function parsePrToken(value) {
55
+ const trimmed = value.trim();
56
+ const pullUrl = trimmed.match(/(?:^|\/)pull\/(\d+)(?:[/?#].*)?$/);
57
+ const raw = pullUrl?.[1] ?? trimmed.replace(/^#/, "");
58
+ const pr = Number.parseInt(raw, 10);
59
+ if (!Number.isInteger(pr) || pr <= 0 || String(pr) !== raw) {
60
+ throw new Error("Specify one or more PR numbers or PR URLs.");
61
+ }
62
+ return pr;
63
+ }
64
+ export function parsePrs(value) {
65
+ const prs = value
66
+ .split(/[\s,]+/)
67
+ .filter(Boolean)
68
+ .map(parsePrToken);
69
+ if (!prs.length)
70
+ throw new Error("Specify one or more PR numbers or PR URLs.");
71
+ return prs;
72
+ }
73
+ export function parseRunArguments(value, dryRun = false) {
74
+ const tokens = value.split(/[\s,]+/).filter(Boolean);
75
+ const prTokens = tokens.filter((token) => {
76
+ if (token === "--dry-run") {
77
+ dryRun = true;
78
+ return false;
79
+ }
80
+ return true;
81
+ });
82
+ return { dryRun, prs: parsePrs(prTokens.join(" ")) };
83
+ }
84
+ function parseOptionalPr(value) {
85
+ if (!value?.trim())
86
+ return undefined;
87
+ return parsePrToken(value);
88
+ }
89
+ function clearFlag(value) {
90
+ return typeof value === "boolean" ? value : undefined;
91
+ }
92
+ function clearToolFlag(value) {
93
+ if (value === true || value === "true")
94
+ return true;
95
+ if (value === "false")
96
+ return false;
97
+ return undefined;
98
+ }
99
+ function hasBlankSelector(args) {
100
+ return !args.runId?.trim() && !args.pr?.trim();
101
+ }
102
+ function hasDefaultedFalseClearFlags(args) {
103
+ return (hasBlankSelector(args) &&
104
+ args.branch === "false" &&
105
+ args.output === "false" &&
106
+ args.session === "false" &&
107
+ args.worktree === "false");
108
+ }
109
+ function parseQuestionAnswers(value) {
110
+ const trimmed = value.trim();
111
+ if (!trimmed)
112
+ throw new Error("Specify at least one answer.");
113
+ try {
114
+ const parsed = JSON.parse(trimmed);
115
+ if (Array.isArray(parsed) &&
116
+ parsed.length &&
117
+ parsed.every((item) => typeof item === "string")) {
118
+ return parsed;
119
+ }
120
+ }
121
+ catch {
122
+ // Plain text answers are accepted below.
123
+ }
124
+ return [trimmed];
125
+ }
126
+ function prMarkdownLink(repository, pr) {
127
+ const host = repository.github.host || "github.com";
128
+ const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/pull/${pr}`;
129
+ return `[#${pr}](${url})`;
130
+ }
131
+ function isPlainObject(value) {
132
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
133
+ }
134
+ async function readConfigFile(path, target) {
135
+ try {
136
+ const config = JSON.parse(await readFile(path, "utf8"));
137
+ if (!isPlainObject(config)) {
138
+ return {
139
+ error: `${target} config must be a JSON object: ${path}`,
140
+ exists: true,
141
+ path,
142
+ target,
143
+ };
144
+ }
145
+ return { config, exists: true, path, target };
146
+ }
147
+ catch (error) {
148
+ if (error.code === "ENOENT") {
149
+ return { exists: false, path, target };
150
+ }
151
+ return {
152
+ error: `${target} config is invalid JSON at ${path}: ${error.message}`,
153
+ exists: true,
154
+ path,
155
+ target,
156
+ };
157
+ }
158
+ }
159
+ function formatConfigStatus(status) {
160
+ if (!status.exists)
161
+ return `- ${status.target}: missing (${status.path})`;
162
+ if (status.error)
163
+ return `- ${status.target}: invalid (${status.path})`;
164
+ return `- ${status.target}: found (${status.path})`;
165
+ }
166
+ export async function validateMagiConfigFiles(directory, options = {}) {
167
+ const projectPath = join(directory, PROJECT_CONFIG_PATH);
168
+ const statuses = await Promise.all([
169
+ readConfigFile(GLOBAL_CONFIG_PATH, "global"),
170
+ readConfigFile(projectPath, "project"),
171
+ ]);
172
+ const existing = statuses.filter((status) => status.exists);
173
+ const hasProjectConfig = statuses.some((status) => status.target === "project" && status.exists);
174
+ const errors = statuses
175
+ .map((status) => status.error)
176
+ .filter((error) => Boolean(error));
177
+ const warnings = [];
178
+ let loadedFrom = "none";
179
+ if (!existing.length) {
180
+ errors.push(`No Magi config found. Expected ${GLOBAL_CONFIG_PATH} or ${projectPath}.`);
181
+ }
182
+ if (existing.length && !errors.length) {
183
+ const merged = existing.reduce((config, status) => mergeMagiConfig(config, status.config ?? {}), {});
184
+ const mergedConfig = merged;
185
+ const validation = await validateConfig(mergedConfig, {
186
+ checkAuth: options.checkAuth ?? true,
187
+ directory,
188
+ exec: options.exec
189
+ ? withGitHubApiRetry(options.exec, mergedConfig.github?.apiRetryAttempts ?? 3)
190
+ : undefined,
191
+ modelCatalog: options.modelCatalog,
192
+ requireGithub: hasProjectConfig && Boolean(mergedConfig.agents?.reviewers),
193
+ });
194
+ loadedFrom = existing.map((status) => status.path).join(", ");
195
+ errors.push(...validation.errors);
196
+ warnings.push(...validation.warnings);
197
+ }
198
+ return [
199
+ `Magi config validation: ${errors.length ? "failed" : "passed"}`,
200
+ "",
201
+ "Config files:",
202
+ ...statuses.map(formatConfigStatus),
203
+ "",
204
+ "Effective config:",
205
+ `- loaded from: ${loadedFrom}`,
206
+ `- auth checks: ${(options.checkAuth ?? true) ? "enabled" : "disabled"}`,
207
+ "",
208
+ "Errors:",
209
+ ...(errors.length ? errors.map((error) => `- ${error}`) : ["- None"]),
210
+ "",
211
+ "Warnings:",
212
+ ...(warnings.length
213
+ ? warnings.map((warning) => `- ${warning}`)
214
+ : ["- None"]),
215
+ ].join("\n");
216
+ }
217
+ export const MagiPlugin = async ({ client, directory }) => {
218
+ const exec = createExec(directory);
219
+ const modelClient = client;
220
+ const catalogClient = client;
221
+ let modelCatalogPromise;
222
+ const sessionOptions = new Map();
223
+ const runManager = new MagiRunManager({
224
+ client: modelClient,
225
+ directory,
226
+ exec,
227
+ setSessionOptions: (sessionId, options) => {
228
+ if (Object.keys(options).length)
229
+ sessionOptions.set(sessionId, options);
230
+ },
231
+ });
232
+ async function configuredOutputDir() {
233
+ return loadConfig(directory)
234
+ .then((loaded) => outputBaseDirs(directory, loaded.config))
235
+ .catch(() => undefined);
236
+ }
237
+ async function modelCatalog() {
238
+ modelCatalogPromise ??= catalogClient.config
239
+ ?.providers({ query: { directory } })
240
+ .then(extractModelCatalog)
241
+ .catch(() => catalogClient.provider
242
+ ?.list({ query: { directory } })
243
+ .then(extractModelCatalog));
244
+ return modelCatalogPromise;
245
+ }
246
+ return {
247
+ "chat.params": async (input, output) => {
248
+ const options = sessionOptions.get(input.sessionID);
249
+ if (options)
250
+ Object.assign(output.options, options);
251
+ },
252
+ "permission.ask": async (input, output) => {
253
+ const permissionInput = input;
254
+ const permissionOutput = output;
255
+ const sessionId = permissionInput.sessionID ?? permissionInput.sessionId;
256
+ if (typeof sessionId === "string" && runManager.hasSession(sessionId)) {
257
+ permissionOutput.status = permissionOutput.status ?? "ask";
258
+ }
259
+ },
260
+ event: async (input) => {
261
+ await runManager.handleEvent(input);
262
+ },
263
+ config: async (config) => {
264
+ config.command = { ...config.command, ...MAGI_COMMANDS };
265
+ },
266
+ tool: {
267
+ magi_merge: tool({
268
+ description: "Start background Magi merge runs for one or more GitHub pull requests with configured Magi agents.",
269
+ args: {
270
+ prs: tool.schema.string(),
271
+ dryRun: tool.schema.boolean().optional(),
272
+ },
273
+ async execute(args, context) {
274
+ const parsed = parseRunArguments(args.prs, args.dryRun ?? false);
275
+ const loaded = await loadConfig(directory);
276
+ const retryingExec = withGitHubApiRetry(exec, loaded.config.github?.apiRetryAttempts ?? 3);
277
+ const validation = await validateConfig(loaded.config, {
278
+ checkAuth: true,
279
+ directory,
280
+ exec: retryingExec,
281
+ modelCatalog: await modelCatalog(),
282
+ requireEditor: true,
283
+ });
284
+ if (!validation.ok)
285
+ return JSON.stringify(validation, null, 2);
286
+ const repository = resolveRepository(loaded.config);
287
+ const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startMerge({
288
+ config: loaded.config,
289
+ dryRun: parsed.dryRun,
290
+ repository,
291
+ pr,
292
+ parentSessionId: context.sessionID,
293
+ signal: context.abort,
294
+ }), { signal: context.abort });
295
+ return states
296
+ .map((state) => `Started reviewing ${prMarkdownLink(repository, state.pr)}.`)
297
+ .join("\n");
298
+ },
299
+ }),
300
+ magi_review: tool({
301
+ description: "Start background Magi review runs for one or more GitHub pull requests and post the reviews.",
302
+ args: {
303
+ prs: tool.schema.string(),
304
+ dryRun: tool.schema.boolean().optional(),
305
+ },
306
+ async execute(args, context) {
307
+ const parsed = parseRunArguments(args.prs, args.dryRun ?? false);
308
+ const loaded = await loadConfig(directory);
309
+ const retryingExec = withGitHubApiRetry(exec, loaded.config.github?.apiRetryAttempts ?? 3);
310
+ const validation = await validateConfig(loaded.config, {
311
+ checkAuth: true,
312
+ directory,
313
+ exec: retryingExec,
314
+ modelCatalog: await modelCatalog(),
315
+ });
316
+ if (!validation.ok)
317
+ return JSON.stringify(validation, null, 2);
318
+ const repository = resolveRepository(loaded.config);
319
+ const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startReview({
320
+ config: loaded.config,
321
+ dryRun: parsed.dryRun,
322
+ repository,
323
+ pr,
324
+ parentSessionId: context.sessionID,
325
+ signal: context.abort,
326
+ }), { signal: context.abort });
327
+ return states
328
+ .map((state) => `Started reviewing ${prMarkdownLink(repository, state.pr)}.`)
329
+ .join("\n");
330
+ },
331
+ }),
332
+ magi_status: tool({
333
+ description: "Show Magi background run status. Optionally filter by runId or PR and wait for completion.",
334
+ args: {
335
+ runId: tool.schema.string().optional(),
336
+ pr: tool.schema.string().optional(),
337
+ block: tool.schema.boolean().optional(),
338
+ timeoutSeconds: tool.schema.number().optional(),
339
+ verbose: tool.schema.boolean().optional(),
340
+ },
341
+ async execute(args) {
342
+ const states = await runManager.status({
343
+ block: args.block,
344
+ outputDir: await configuredOutputDir(),
345
+ pr: parseOptionalPr(args.pr),
346
+ runId: args.runId,
347
+ timeoutMs: args.timeoutSeconds == null
348
+ ? undefined
349
+ : args.timeoutSeconds * 1_000,
350
+ });
351
+ return runManager.formatStatesWithReports(states, {
352
+ verbose: args.verbose ?? false,
353
+ });
354
+ },
355
+ }),
356
+ magi_output: tool({
357
+ description: "Show artifacts and details for a Magi background run by runId or PR, optionally for a single reviewer.",
358
+ args: {
359
+ runId: tool.schema.string().optional(),
360
+ pr: tool.schema.string().optional(),
361
+ reviewer: tool.schema.string().optional(),
362
+ },
363
+ async execute(args) {
364
+ if (!args.runId && !args.pr)
365
+ return "Specify runId or pr.";
366
+ const outputDir = await configuredOutputDir();
367
+ if (outputDir)
368
+ await runManager.status({ outputDir });
369
+ return runManager.output({
370
+ outputDir,
371
+ pr: parseOptionalPr(args.pr),
372
+ reviewer: args.reviewer,
373
+ runId: args.runId,
374
+ });
375
+ },
376
+ }),
377
+ magi_cancel: tool({
378
+ description: "Cancel a Magi background run by runId or PR.",
379
+ args: {
380
+ runId: tool.schema.string().optional(),
381
+ pr: tool.schema.string().optional(),
382
+ },
383
+ async execute(args) {
384
+ if (!args.runId && !args.pr)
385
+ return "Specify runId or pr.";
386
+ const outputDir = await configuredOutputDir();
387
+ if (outputDir)
388
+ await runManager.status({ outputDir });
389
+ const pr = parseOptionalPr(args.pr);
390
+ const state = await runManager.cancel({
391
+ outputDir,
392
+ pr,
393
+ runId: args.runId,
394
+ });
395
+ if (!state) {
396
+ return args.runId
397
+ ? `Magi run not found: ${args.runId}`
398
+ : `Magi run not found for PR #${pr}`;
399
+ }
400
+ return runManager.formatStates([state]);
401
+ },
402
+ }),
403
+ magi_clear: tool({
404
+ description: "Clear all inactive Magi runs by deleting configured sessions, worktrees, branches, and output artifacts.",
405
+ args: {
406
+ runId: tool.schema.string().optional(),
407
+ pr: tool.schema.string().optional(),
408
+ branch: tool.schema.enum(["true", "false"]).optional(),
409
+ output: tool.schema.enum(["true", "false"]).optional(),
410
+ session: tool.schema.enum(["true", "false"]).optional(),
411
+ worktree: tool.schema.enum(["true", "false"]).optional(),
412
+ },
413
+ async execute(args) {
414
+ const loaded = await loadConfig(directory).catch(() => undefined);
415
+ const clear = loaded?.config.clear;
416
+ const useConfiguredDefaults = hasDefaultedFalseClearFlags(args);
417
+ const options = {
418
+ branch: (useConfiguredDefaults
419
+ ? undefined
420
+ : clearToolFlag(args.branch)) ?? clearFlag(clear?.branch),
421
+ output: (useConfiguredDefaults
422
+ ? undefined
423
+ : clearToolFlag(args.output)) ?? clearFlag(clear?.output),
424
+ session: (useConfiguredDefaults
425
+ ? undefined
426
+ : clearToolFlag(args.session)) ?? clearFlag(clear?.session),
427
+ worktree: (useConfiguredDefaults
428
+ ? undefined
429
+ : clearToolFlag(args.worktree)) ?? clearFlag(clear?.worktree),
430
+ };
431
+ return runManager.clear({
432
+ options,
433
+ outputDir: loaded
434
+ ? outputBaseDirs(directory, loaded.config)
435
+ : undefined,
436
+ pr: parseOptionalPr(args.pr),
437
+ runId: args.runId,
438
+ });
439
+ },
440
+ }),
441
+ magi_permission_reply: tool({
442
+ description: "Reply to a pending Magi child-agent permission request by runId or PR.",
443
+ args: {
444
+ runId: tool.schema.string().optional(),
445
+ pr: tool.schema.string().optional(),
446
+ agent: tool.schema.string().optional(),
447
+ reviewer: tool.schema.string().optional(),
448
+ requestId: tool.schema.string().optional(),
449
+ reply: tool.schema.string(),
450
+ },
451
+ async execute(args) {
452
+ if (!args.runId && !args.pr)
453
+ return "Specify runId or pr.";
454
+ if (!["always", "once", "reject"].includes(args.reply)) {
455
+ return "reply must be once, always, or reject.";
456
+ }
457
+ const outputDir = await configuredOutputDir();
458
+ if (outputDir)
459
+ await runManager.status({ outputDir });
460
+ return runManager.replyPermission({
461
+ agent: args.agent ?? args.reviewer,
462
+ outputDir,
463
+ pr: parseOptionalPr(args.pr),
464
+ reply: args.reply,
465
+ requestId: args.requestId,
466
+ runId: args.runId,
467
+ });
468
+ },
469
+ }),
470
+ magi_question_reply: tool({
471
+ description: "Reply to a pending Magi child-agent question request by runId or PR.",
472
+ args: {
473
+ runId: tool.schema.string().optional(),
474
+ pr: tool.schema.string().optional(),
475
+ agent: tool.schema.string().optional(),
476
+ reviewer: tool.schema.string().optional(),
477
+ requestId: tool.schema.string().optional(),
478
+ answers: tool.schema.string(),
479
+ },
480
+ async execute(args) {
481
+ if (!args.runId && !args.pr)
482
+ return "Specify runId or pr.";
483
+ const outputDir = await configuredOutputDir();
484
+ if (outputDir)
485
+ await runManager.status({ outputDir });
486
+ return runManager.replyQuestion({
487
+ agent: args.agent ?? args.reviewer,
488
+ answers: parseQuestionAnswers(args.answers),
489
+ outputDir,
490
+ pr: parseOptionalPr(args.pr),
491
+ requestId: args.requestId,
492
+ runId: args.runId,
493
+ });
494
+ },
495
+ }),
496
+ magi_question_reject: tool({
497
+ description: "Reject a pending Magi child-agent question request by runId or PR.",
498
+ args: {
499
+ runId: tool.schema.string().optional(),
500
+ pr: tool.schema.string().optional(),
501
+ agent: tool.schema.string().optional(),
502
+ reviewer: tool.schema.string().optional(),
503
+ requestId: tool.schema.string().optional(),
504
+ },
505
+ async execute(args) {
506
+ if (!args.runId && !args.pr)
507
+ return "Specify runId or pr.";
508
+ const outputDir = await configuredOutputDir();
509
+ if (outputDir)
510
+ await runManager.status({ outputDir });
511
+ return runManager.rejectQuestion({
512
+ agent: args.agent ?? args.reviewer,
513
+ outputDir,
514
+ pr: parseOptionalPr(args.pr),
515
+ requestId: args.requestId,
516
+ runId: args.runId,
517
+ });
518
+ },
519
+ }),
520
+ magi_validate: tool({
521
+ description: "Validate global and project Magi config presence, merged settings, reviewer rules, model IDs, and GitHub authentication.",
522
+ args: {
523
+ checkAuth: tool.schema.boolean().optional(),
524
+ },
525
+ async execute(args) {
526
+ return validateMagiConfigFiles(directory, {
527
+ checkAuth: args.checkAuth ?? true,
528
+ exec,
529
+ modelCatalog: await modelCatalog(),
530
+ });
531
+ },
532
+ }),
533
+ },
534
+ };
535
+ };
536
+ export default MagiPlugin;
@@ -0,0 +1,9 @@
1
+ export function throwIfAborted(signal) {
2
+ signal?.throwIfAborted();
3
+ }
4
+ export function withAbortSignal(exec, signal) {
5
+ return async (command, options) => {
6
+ throwIfAborted(signal);
7
+ return exec(command, { ...options, signal: options?.signal ?? signal });
8
+ };
9
+ }