@grafana/create-plugin 7.12.3 → 7.14.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 (93) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/codemods/additions/additions.js +20 -7
  3. package/dist/codemods/additions/prompts/docs-content.md +119 -0
  4. package/dist/codemods/additions/prompts/example-prompt.md +56 -0
  5. package/dist/codemods/additions/scripts/docs-content.js +30 -0
  6. package/dist/codemods/additions/scripts/docs.js +250 -0
  7. package/dist/codemods/additions/scripts/experimental-app-sdk.js +39 -33
  8. package/dist/codemods/agentic/definitions.js +31 -0
  9. package/dist/codemods/agentic/detect.js +57 -0
  10. package/dist/codemods/agentic/handoff.js +35 -0
  11. package/dist/codemods/agentic/index.js +143 -0
  12. package/dist/codemods/agentic/prompts.js +115 -0
  13. package/dist/codemods/agentic/resolve.js +120 -0
  14. package/dist/codemods/agentic/runner.js +79 -0
  15. package/dist/codemods/agentic/verify.js +55 -0
  16. package/dist/codemods/migrations/migrations.js +12 -0
  17. package/dist/codemods/migrations/scripts/017-jest-30.js +32 -0
  18. package/dist/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.js +115 -0
  19. package/dist/codemods/utils.agents.js +64 -0
  20. package/dist/codemods/utils.js +51 -8
  21. package/dist/codemods/utils.workflows.js +94 -0
  22. package/dist/commands/add.command.js +74 -12
  23. package/dist/utils/utils.cli.js +7 -2
  24. package/dist/utils/utils.console.js +10 -1
  25. package/dist/utils/utils.git.js +10 -1
  26. package/dist/utils/utils.handlebars.js +9 -2
  27. package/dist/utils/utils.packageManager.js +2 -2
  28. package/dist/utils/utils.templates.js +38 -24
  29. package/package.json +1 -1
  30. package/src/codemods/AGENTS.md +25 -0
  31. package/src/codemods/additions/additions.test.ts +69 -4
  32. package/src/codemods/additions/additions.ts +50 -8
  33. package/src/codemods/additions/prompts/docs-content.md +119 -0
  34. package/src/codemods/additions/prompts/example-prompt.md +56 -0
  35. package/src/codemods/additions/scripts/docs-content.test.ts +94 -0
  36. package/src/codemods/additions/scripts/docs-content.ts +32 -0
  37. package/src/codemods/additions/scripts/docs.test.ts +1037 -0
  38. package/src/codemods/additions/scripts/docs.ts +353 -0
  39. package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +40 -80
  40. package/src/codemods/additions/scripts/experimental-app-sdk.ts +42 -34
  41. package/src/codemods/agentic/definitions.test.ts +67 -0
  42. package/src/codemods/agentic/definitions.ts +33 -0
  43. package/src/codemods/agentic/detect.test.ts +136 -0
  44. package/src/codemods/agentic/detect.ts +75 -0
  45. package/src/codemods/agentic/handoff.test.ts +102 -0
  46. package/src/codemods/agentic/handoff.ts +45 -0
  47. package/src/codemods/agentic/index.test.ts +342 -0
  48. package/src/codemods/agentic/index.ts +221 -0
  49. package/src/codemods/agentic/prompts.test.ts +233 -0
  50. package/src/codemods/agentic/prompts.ts +166 -0
  51. package/src/codemods/agentic/resolve.test.ts +220 -0
  52. package/src/codemods/agentic/resolve.ts +165 -0
  53. package/src/codemods/agentic/runner.test.ts +146 -0
  54. package/src/codemods/agentic/runner.ts +103 -0
  55. package/src/codemods/agentic/types.ts +50 -0
  56. package/src/codemods/agentic/verify.test.ts +106 -0
  57. package/src/codemods/agentic/verify.ts +93 -0
  58. package/src/codemods/migrations/migrations.ts +14 -0
  59. package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
  60. package/src/codemods/migrations/scripts/017-jest-30.test.ts +72 -0
  61. package/src/codemods/migrations/scripts/017-jest-30.ts +39 -0
  62. package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.test.ts +174 -0
  63. package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.ts +141 -0
  64. package/src/codemods/utils.agents.test.ts +140 -0
  65. package/src/codemods/utils.agents.ts +81 -0
  66. package/src/codemods/utils.test.ts +131 -1
  67. package/src/codemods/utils.ts +79 -8
  68. package/src/codemods/utils.workflows.test.ts +153 -0
  69. package/src/codemods/utils.workflows.ts +140 -0
  70. package/src/commands/add.command.ts +92 -9
  71. package/src/utils/utils.cli.ts +5 -0
  72. package/src/utils/utils.git.test.ts +57 -0
  73. package/src/utils/utils.git.ts +25 -0
  74. package/src/utils/utils.handlebars.ts +9 -0
  75. package/src/utils/utils.packageManager.test.ts +23 -0
  76. package/src/utils/utils.packageManager.ts +3 -2
  77. package/src/utils/utils.templates.ts +50 -25
  78. package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
  79. package/templates/common/.config/webpack/webpack.config.ts +4 -5
  80. package/templates/common/_package.json +9 -8
  81. package/templates/docs/common/workflows/validate-docs.yml +52 -0
  82. package/templates/docs/panel/docs/AGENTS.md +107 -0
  83. package/templates/docs/panel/docs/CLAUDE.md +1 -0
  84. package/templates/docs/panel/docs/README.md +20 -0
  85. package/templates/docs/panel/docs/data-formats.md +27 -0
  86. package/templates/docs/panel/docs/examples.md +35 -0
  87. package/templates/docs/panel/docs/index.md +26 -0
  88. package/templates/docs/panel/docs/options.md +53 -0
  89. package/templates/docs/panel/docs/troubleshooting.md +35 -0
  90. package/templates/github/workflows/bundle-stats.yml +1 -1
  91. package/templates/github/workflows/ci.yml +7 -7
  92. package/templates/github/workflows/is-compatible.yml +3 -3
  93. package/templates/github/workflows/release.yml +1 -1
@@ -0,0 +1,342 @@
1
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { Addition, PromptOnlyAddition } from '../additions/additions.js';
6
+ import { Context } from '../context.js';
7
+ import { selectPrompt } from '../../utils/utils.console.js';
8
+ import { AGENT_RUNS_DIR } from './handoff.js';
9
+ import {
10
+ ABORT_CHOICE,
11
+ buildAdditionTodosWarning,
12
+ CONTINUE_CHOICE,
13
+ prepareAgenticAddition,
14
+ runAgenticStep,
15
+ } from './index.js';
16
+ import { resolveAgenticMode } from './resolve.js';
17
+ import { runAgentSession } from './runner.js';
18
+ import { AgentSessionResult, InstalledAgent } from './types.js';
19
+
20
+ vi.mock('./resolve.js');
21
+ vi.mock('./runner.js');
22
+ vi.mock('../../utils/utils.console.js', () => ({
23
+ output: {
24
+ log: vi.fn(),
25
+ warning: vi.fn(),
26
+ logSingleLine: vi.fn(),
27
+ statusList: vi.fn(),
28
+ },
29
+ confirmPrompt: vi.fn(),
30
+ selectPrompt: vi.fn(),
31
+ }));
32
+ vi.mock('../../utils/utils.packageManager.js', () => ({
33
+ getPackageManagerWithFallback: () => ({ packageManagerName: 'npm', packageManagerVersion: '11.0.0' }),
34
+ getPackageManagerSilentInstallCmd: () => 'npm install --silent',
35
+ getPackageManagerExecCmd: () => 'npx -y',
36
+ }));
37
+
38
+ const resolveMock = vi.mocked(resolveAgenticMode);
39
+ const runAgentSessionMock = vi.mocked(runAgentSession);
40
+ const selectPromptMock = vi.mocked(selectPrompt);
41
+
42
+ const fakeAgent: InstalledAgent = {
43
+ definition: {
44
+ id: 'claude-code',
45
+ displayName: 'Claude Code',
46
+ binaryNames: ['claude'],
47
+ buildInteractive: (invocationContext) => ({
48
+ args: ['--sys', invocationContext.systemPrompt, invocationContext.userPrompt],
49
+ env: { FAKE_AGENT: '1' },
50
+ }),
51
+ },
52
+ binaryPath: '/usr/local/bin/claude',
53
+ };
54
+
55
+ function handoffResult(status: 'success' | 'failed', summary: string): AgentSessionResult {
56
+ return { outcome: 'handoff', handoff: { status, summary } };
57
+ }
58
+
59
+ let basePath: string;
60
+ let promptOnly: PromptOnlyAddition;
61
+
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ basePath = mkdtempSync(join(tmpdir(), 'cp-agentic-test-'));
65
+ const promptPath = join(basePath, 'rspack-overrides.md');
66
+ writeFileSync(promptPath, '# Port things');
67
+ promptOnly = {
68
+ name: 'rspack-overrides',
69
+ description: 'Port custom things.',
70
+ prompt: pathToFileURL(promptPath).href,
71
+ };
72
+ });
73
+
74
+ afterEach(() => {
75
+ rmSync(basePath, { recursive: true, force: true });
76
+ });
77
+
78
+ describe('prepareAgenticAddition', () => {
79
+ const scriptOnly: Addition = {
80
+ name: 'externalize-jsx-runtime',
81
+ description: 'No prompt here.',
82
+ scriptPath: 'file:///virtual/externalize-jsx-runtime.js',
83
+ };
84
+
85
+ it('should return undefined for an addition with no prompt', async () => {
86
+ const resolution = await prepareAgenticAddition(scriptOnly, undefined);
87
+
88
+ expect(resolution).toBeUndefined();
89
+ expect(resolveMock).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it('should return the resolution when an agent is enabled', async () => {
93
+ resolveMock.mockResolvedValue({ mode: 'enabled', agent: fakeAgent });
94
+
95
+ await expect(prepareAgenticAddition(promptOnly, undefined)).resolves.toEqual({
96
+ mode: 'enabled',
97
+ agent: fakeAgent,
98
+ });
99
+ });
100
+
101
+ it.each([
102
+ ['opted-out via flag', { mode: 'opted-out', reason: 'flag' } as const],
103
+ ['opted-out via decline', { mode: 'opted-out', reason: 'declined' } as const],
104
+ ['inside an agent', { mode: 'inside-agent' } as const],
105
+ ])('should return the resolution when %s', async (_label, resolution) => {
106
+ resolveMock.mockResolvedValue(resolution);
107
+
108
+ await expect(prepareAgenticAddition(promptOnly, undefined)).resolves.toEqual(resolution);
109
+ });
110
+
111
+ it('should throw when no agent is installed', async () => {
112
+ resolveMock.mockResolvedValue({ mode: 'unavailable', reason: 'no-agents' });
113
+
114
+ await expect(prepareAgenticAddition(promptOnly, undefined)).rejects.toThrow(/needs an AI agent/);
115
+ });
116
+
117
+ it('should throw when there is no interactive terminal', async () => {
118
+ resolveMock.mockResolvedValue({ mode: 'unavailable', reason: 'no-tty' });
119
+
120
+ await expect(prepareAgenticAddition(promptOnly, undefined)).rejects.toThrow(/interactive terminal/);
121
+ });
122
+
123
+ it('should name every way forward when no agent is available', async () => {
124
+ resolveMock.mockResolvedValue({ mode: 'unavailable', reason: 'no-agents' });
125
+
126
+ await expect(prepareAgenticAddition(promptOnly, undefined)).rejects.toThrow(/--agent=<path>/);
127
+ await expect(prepareAgenticAddition(promptOnly, undefined)).rejects.toThrow(/--no-agent/);
128
+ await expect(prepareAgenticAddition(promptOnly, undefined)).rejects.toThrow(/rspack-overrides\.md/);
129
+ });
130
+
131
+ it('should pass the agent flag through to the resolver', async () => {
132
+ resolveMock.mockResolvedValue({ mode: 'enabled', agent: fakeAgent });
133
+
134
+ await prepareAgenticAddition(promptOnly, '/opt/homebrew/bin/claude');
135
+
136
+ expect(resolveMock).toHaveBeenCalledWith({ agentFlag: '/opt/homebrew/bin/claude' });
137
+ });
138
+ });
139
+
140
+ describe('runAgenticStep', () => {
141
+ function runStep(overrides: Partial<Parameters<typeof runAgenticStep>[0]> = {}) {
142
+ return runAgenticStep({
143
+ addition: promptOnly,
144
+ agent: fakeAgent,
145
+ basePath,
146
+ treeWasDirty: false,
147
+ ...overrides,
148
+ });
149
+ }
150
+
151
+ function createContextWithChanges() {
152
+ const context = new Context(basePath);
153
+ context.addFile('rspack.config.ts', 'export default {};');
154
+ return context;
155
+ }
156
+
157
+ it('should report the handoff summary when the agent succeeds', async () => {
158
+ runAgentSessionMock.mockResolvedValue(handoffResult('success', 'Ported the overrides.'));
159
+
160
+ await expect(runStep()).resolves.toEqual({ kind: 'applied', summary: 'Ported the overrides.' });
161
+ });
162
+
163
+ it('should throw when the agent hands off a failure', async () => {
164
+ runAgentSessionMock.mockResolvedValue(handoffResult('failed', 'Could not parse the config.'));
165
+
166
+ await expect(runStep()).rejects.toThrow(/Could not parse the config./);
167
+ });
168
+
169
+ it('should throw when the user aborts the session', async () => {
170
+ runAgentSessionMock.mockResolvedValue({ outcome: 'user-aborted' });
171
+
172
+ await expect(runStep()).rejects.toThrow(/aborted/);
173
+ });
174
+
175
+ it('should spawn the agent with its own invocation', async () => {
176
+ runAgentSessionMock.mockResolvedValue(handoffResult('success', 'done'));
177
+
178
+ await runStep();
179
+
180
+ expect(runAgentSessionMock).toHaveBeenCalledWith(
181
+ expect.objectContaining({
182
+ binaryPath: '/usr/local/bin/claude',
183
+ cwd: basePath,
184
+ env: { FAKE_AGENT: '1' },
185
+ })
186
+ );
187
+ });
188
+
189
+ it('should create the run directory under the agent runs cache', async () => {
190
+ runAgentSessionMock.mockResolvedValue(handoffResult('success', 'done'));
191
+
192
+ await runStep();
193
+
194
+ expect(existsSync(join(basePath, AGENT_RUNS_DIR))).toBe(true);
195
+ const handoffPath = runAgentSessionMock.mock.calls[0][0].handoffPath;
196
+ expect(handoffPath).toContain(AGENT_RUNS_DIR);
197
+ expect(handoffPath).toContain('rspack-overrides.json');
198
+ });
199
+
200
+ describe('prompt content', () => {
201
+ beforeEach(() => {
202
+ runAgentSessionMock.mockResolvedValue(handoffResult('success', 'done'));
203
+ });
204
+
205
+ function getUserPrompt() {
206
+ return runAgentSessionMock.mock.calls[0][0].args[2];
207
+ }
208
+
209
+ it('should omit the codemod changes section when there is no codemod half', async () => {
210
+ await runStep();
211
+
212
+ expect(getUserPrompt()).not.toContain('<codemod_changes>');
213
+ });
214
+
215
+ it('should point the agent at the git diff when the tree started clean', async () => {
216
+ await runStep({ context: createContextWithChanges(), treeWasDirty: false });
217
+
218
+ expect(getUserPrompt()).toContain('<codemod_changes>');
219
+ expect(getUserPrompt()).toContain('git diff');
220
+ expect(getUserPrompt()).not.toContain('<files_changed>');
221
+ });
222
+
223
+ it('should embed the changed file list when --force let a dirty tree through', async () => {
224
+ await runStep({ context: createContextWithChanges(), treeWasDirty: true });
225
+
226
+ expect(getUserPrompt()).toContain('<files_changed>');
227
+ expect(getUserPrompt()).toContain('rspack.config.ts');
228
+ });
229
+
230
+ it('should embed the instructions file contents', async () => {
231
+ await runStep();
232
+
233
+ expect(getUserPrompt()).toContain('# Port things');
234
+ });
235
+ });
236
+
237
+ async function getRejectionMessage(promise: Promise<unknown>): Promise<string> {
238
+ try {
239
+ await promise;
240
+ } catch (error) {
241
+ return error instanceof Error ? error.message : String(error);
242
+ }
243
+ throw new Error('Expected the agent step to throw');
244
+ }
245
+
246
+ describe('when a hybrid agent step does not finish', () => {
247
+ const unfinishedSessions = [
248
+ ['the agent hands off a failure', () => handoffResult('failed', 'Could not parse the config.')],
249
+ ['the user aborts the session', () => ({ outcome: 'user-aborted' as const })],
250
+ ] as const;
251
+
252
+ unfinishedSessions.forEach(([scenario, createResult]) => {
253
+ it(`should explain how to recover the automated changes when ${scenario}`, async () => {
254
+ runAgentSessionMock.mockResolvedValue(createResult());
255
+
256
+ const message = await getRejectionMessage(runStep({ context: createContextWithChanges() }));
257
+
258
+ expect(message).toMatch(/automated part of rspack-overrides has already changed your working tree/);
259
+ expect(message).toContain('git diff');
260
+ expect(message).toMatch(/rspack-overrides\.md/);
261
+ expect(message).toContain('git restore . && git clean -fd');
262
+ });
263
+ });
264
+
265
+ it('should explain recovery when the user aborts after an ambiguous exit', async () => {
266
+ runAgentSessionMock.mockResolvedValue({ outcome: 'ambiguous-exit', exitCode: 1, signal: null });
267
+ selectPromptMock.mockResolvedValue(ABORT_CHOICE);
268
+
269
+ await expect(runStep({ context: createContextWithChanges() })).rejects.toThrow(
270
+ /already changed your working tree/
271
+ );
272
+ });
273
+
274
+ it('should not suggest discarding changes when --force let a dirty tree through', async () => {
275
+ runAgentSessionMock.mockResolvedValue({ outcome: 'user-aborted' });
276
+
277
+ const message = await getRejectionMessage(runStep({ context: createContextWithChanges(), treeWasDirty: true }));
278
+
279
+ expect(message).toContain('git diff');
280
+ expect(message).not.toContain('git clean');
281
+ });
282
+
283
+ it('should add no recovery note for a prompt-only addition', async () => {
284
+ runAgentSessionMock.mockResolvedValue({ outcome: 'user-aborted' });
285
+
286
+ const message = await getRejectionMessage(runStep());
287
+
288
+ expect(message).not.toContain('working tree');
289
+ });
290
+ });
291
+
292
+ describe('ambiguous exit', () => {
293
+ beforeEach(() => {
294
+ runAgentSessionMock.mockResolvedValue({ outcome: 'ambiguous-exit', exitCode: 1, signal: null });
295
+ });
296
+
297
+ it('should assume the step was applied when the user chooses to continue', async () => {
298
+ selectPromptMock.mockResolvedValue(CONTINUE_CHOICE);
299
+
300
+ await expect(runStep()).resolves.toEqual({ kind: 'assumed-applied' });
301
+ });
302
+
303
+ it('should throw when the user chooses to abort', async () => {
304
+ selectPromptMock.mockResolvedValue(ABORT_CHOICE);
305
+
306
+ await expect(runStep()).rejects.toThrow(/aborted/);
307
+ });
308
+
309
+ it('should treat a cancelled prompt as an abort', async () => {
310
+ selectPromptMock.mockRejectedValue(new Error(''));
311
+
312
+ await expect(runStep()).rejects.toThrow(/aborted/);
313
+ });
314
+ });
315
+ });
316
+
317
+ describe('buildAdditionTodosWarning', () => {
318
+ it('should return nothing when the addition left no markers', () => {
319
+ expect(buildAdditionTodosWarning('rspack', [])).toBeUndefined();
320
+ });
321
+
322
+ it('should list each marker with its location', () => {
323
+ const warning = buildAdditionTodosWarning('rspack', [
324
+ { file: 'rspack.config.ts', line: 12, text: '// TODO(rspack): port CorsWorkerPlugin' },
325
+ { file: 'build/utils.ts', line: 3, text: '// TODO(rspack): swc plugin ABI mismatch' },
326
+ ]);
327
+
328
+ expect(warning).toEqual({
329
+ title: 'rspack left 2 items that need your attention.',
330
+ body: [
331
+ 'rspack.config.ts:12 // TODO(rspack): port CorsWorkerPlugin',
332
+ 'build/utils.ts:3 // TODO(rspack): swc plugin ABI mismatch',
333
+ ],
334
+ });
335
+ });
336
+
337
+ it('should use the singular for one marker', () => {
338
+ const warning = buildAdditionTodosWarning('rspack', [{ file: 'a.ts', line: 1, text: '// TODO(rspack): x' }]);
339
+
340
+ expect(warning?.title).toBe('rspack left 1 item that needs your attention.');
341
+ });
342
+ });
@@ -0,0 +1,221 @@
1
+ import { output, selectPrompt } from '../../utils/utils.console.js';
2
+ import {
3
+ getPackageManagerExecCmd,
4
+ getPackageManagerSilentInstallCmd,
5
+ getPackageManagerWithFallback,
6
+ } from '../../utils/utils.packageManager.js';
7
+ import { Addition, hasPromptStep } from '../additions/additions.js';
8
+ import { Context } from '../context.js';
9
+ import { createRunId, ensureRunDir, getHandoffPath, getRunDir, wipeAgentRuns } from './handoff.js';
10
+ import {
11
+ buildHybridUserPrompt,
12
+ buildPromptOnlyUserPrompt,
13
+ buildSystemPrompt,
14
+ CodemodChanges,
15
+ getPromptPath,
16
+ loadPromptInstructions,
17
+ } from './prompts.js';
18
+ import { resolveAgenticMode } from './resolve.js';
19
+ import { runAgentSession } from './runner.js';
20
+ import { AgenticResolution, AgenticStepResult, AgentSessionResult, InstalledAgent } from './types.js';
21
+ import type { AdditionTodo } from '../../utils/utils.git.js';
22
+
23
+ export const ABORT_CHOICE = 'Abort the addition';
24
+ export const CONTINUE_CHOICE = 'Treat the agent step as completed';
25
+
26
+ export interface AgenticStepOptions {
27
+ addition: Addition & { prompt: string };
28
+ agent: InstalledAgent;
29
+ // present for hybrid additions, absent for prompt-only
30
+ context?: Context;
31
+ basePath: string;
32
+ // true when --force let a dirty tree through, so the working diff is not ours alone
33
+ treeWasDirty: boolean;
34
+ }
35
+
36
+ // Resolves the agent before any codemod work runs, so a missing agent can never leave an addition
37
+ // half-applied. Returns undefined when the addition needs no agent, and throws when it needs one that
38
+ // is not available.
39
+ export async function prepareAgenticAddition(
40
+ addition: Addition,
41
+ agentFlag: string | boolean | undefined
42
+ ): Promise<AgenticResolution | undefined> {
43
+ if (!hasPromptStep(addition)) {
44
+ return undefined;
45
+ }
46
+
47
+ const resolution = await resolveAgenticMode({ agentFlag });
48
+ if (resolution.mode === 'unavailable') {
49
+ throw new Error(buildUnavailableMessage(addition, resolution.reason));
50
+ }
51
+
52
+ return resolution;
53
+ }
54
+
55
+ export async function runAgenticStep(options: AgenticStepOptions): Promise<AgenticStepResult> {
56
+ const { addition, agent, context, basePath, treeWasDirty } = options;
57
+ const instructionsPath = getPromptPath(addition.prompt);
58
+
59
+ // a unique dir per run means a stale handoff from an earlier run can never be read back
60
+ wipeAgentRuns(basePath);
61
+ const runDir = getRunDir(basePath, createRunId());
62
+ ensureRunDir(runDir);
63
+ const handoffPath = getHandoffPath(runDir, addition.name);
64
+
65
+ const { packageManagerName, packageManagerVersion } = getPackageManagerWithFallback();
66
+ const systemPrompt = buildSystemPrompt({
67
+ workspaceRoot: basePath,
68
+ packageManagerName,
69
+ packageManagerVersion,
70
+ installCmd: getPackageManagerSilentInstallCmd(packageManagerName, packageManagerVersion),
71
+ execCmd: getPackageManagerExecCmd(packageManagerName, packageManagerVersion),
72
+ handoffPath,
73
+ });
74
+
75
+ const userPromptOptions = {
76
+ addition: { name: addition.name, description: addition.description },
77
+ instructions: loadPromptInstructions(addition.prompt),
78
+ instructionsPath,
79
+ handoffPath,
80
+ };
81
+ const userPrompt = context
82
+ ? buildHybridUserPrompt({ ...userPromptOptions, codemodChanges: getCodemodChanges(context, treeWasDirty) })
83
+ : buildPromptOnlyUserPrompt(userPromptOptions);
84
+
85
+ output.log({
86
+ title: `Handing ${addition.name} to ${agent.definition.displayName} (${agent.binaryPath}).`,
87
+ body: [
88
+ 'The session is interactive: you can watch and redirect the agent.',
89
+ 'It ends automatically once the agent reports completion.',
90
+ ],
91
+ });
92
+
93
+ const invocation = agent.definition.buildInteractive({
94
+ systemPrompt,
95
+ userPrompt,
96
+ workspaceRoot: basePath,
97
+ handoffDir: runDir,
98
+ });
99
+
100
+ const sessionResult = await runAgentSession({
101
+ binaryPath: agent.binaryPath,
102
+ args: invocation.args,
103
+ env: invocation.env,
104
+ cwd: basePath,
105
+ handoffPath,
106
+ });
107
+
108
+ // a hybrid codemod has already written its changes, so an unfinished agent step leaves them behind
109
+ const withRecoveryNote = (message: string) =>
110
+ context ? [message, '', ...buildHybridRecoveryLines(addition, treeWasDirty)].join('\n') : message;
111
+
112
+ if (sessionResult.outcome === 'handoff') {
113
+ if (sessionResult.handoff.status === 'failed') {
114
+ throw new Error(
115
+ withRecoveryNote(`The agent reported failure for ${addition.name}: ${sessionResult.handoff.summary}`)
116
+ );
117
+ }
118
+ return { kind: 'applied', summary: sessionResult.handoff.summary };
119
+ }
120
+
121
+ if (sessionResult.outcome === 'user-aborted') {
122
+ throw new Error(withRecoveryNote(`The agent session for ${addition.name} was aborted.`));
123
+ }
124
+
125
+ return handleAmbiguousExit(addition.name, sessionResult, withRecoveryNote);
126
+ }
127
+
128
+ function buildHybridRecoveryLines(addition: Addition & { prompt: string }, treeWasDirty: boolean): string[] {
129
+ const lines = [
130
+ `The automated part of ${addition.name} has already changed your working tree.`,
131
+ `Review the changes with git diff, then apply the instructions in ${getPromptPath(addition.prompt)} yourself.`,
132
+ ];
133
+ // only safe to suggest when the tree was clean before the run, otherwise it discards the user's own work too
134
+ if (!treeWasDirty) {
135
+ lines.push('To undo the automated changes instead, run git restore . && git clean -fd');
136
+ }
137
+ return lines;
138
+ }
139
+
140
+ function buildUnavailableMessage(addition: Addition & { prompt: string }, reason: 'no-tty' | 'no-agents'): string {
141
+ const cause =
142
+ reason === 'no-tty' ? 'this is not an interactive terminal' : 'no supported agent CLI was found on your PATH';
143
+
144
+ return [
145
+ `${addition.name} needs an AI agent to apply its instructions, and ${cause}.`,
146
+ '',
147
+ 'Ways forward:',
148
+ ' - install a supported agent CLI and run this again',
149
+ ' - pass --agent=<path> if an agent is installed outside your PATH',
150
+ ` - pass --no-agent to skip the agent, then apply ${getPromptPath(addition.prompt)} by hand`,
151
+ ].join('\n');
152
+ }
153
+
154
+ async function handleAmbiguousExit(
155
+ additionName: string,
156
+ sessionResult: Extract<AgentSessionResult, { outcome: 'ambiguous-exit' }>,
157
+ withRecoveryNote: (message: string) => string
158
+ ): Promise<AgenticStepResult> {
159
+ output.warning({
160
+ title: `The agent session for ${additionName} ended without reporting a result.`,
161
+ body: [
162
+ `Exit code: ${sessionResult.exitCode ?? 'none'}, signal: ${sessionResult.signal ?? 'none'}.`,
163
+ 'Inspect the working tree before deciding how to proceed.',
164
+ ],
165
+ });
166
+
167
+ const choice = await safeSelect('How should the addition proceed?', [ABORT_CHOICE, CONTINUE_CHOICE]);
168
+ if (choice === CONTINUE_CHOICE) {
169
+ return { kind: 'assumed-applied' };
170
+ }
171
+
172
+ throw new Error(
173
+ withRecoveryNote(`The addition was aborted after the agent session for ${additionName} ended unexpectedly.`)
174
+ );
175
+ }
176
+
177
+ function getCodemodChanges(context: Context, treeWasDirty: boolean): CodemodChanges {
178
+ // add requires a clean tree, so after the codemod flushes the whole working tree diff is ours.
179
+ // --force can bypass that check, and then the diff includes changes we did not make.
180
+ if (!treeWasDirty) {
181
+ return { kind: 'git-diff' };
182
+ }
183
+
184
+ const files = Object.entries(context.listChanges()).map(([path, change]) => ({
185
+ path,
186
+ changeType: change.changeType,
187
+ }));
188
+ return { kind: 'file-list', files };
189
+ }
190
+
191
+ async function safeSelect(message: string, choices: string[]): Promise<string> {
192
+ try {
193
+ return await selectPrompt(message, choices);
194
+ } catch {
195
+ // enquirer rejects when the prompt is cancelled (ctrl+c) — treat as an abort
196
+ return ABORT_CHOICE;
197
+ }
198
+ }
199
+
200
+ export interface AdditionTodosWarning {
201
+ title: string;
202
+ body: string[];
203
+ }
204
+
205
+ // the handoff summary is the agent's own account. the markers are read from the tree, so anything
206
+ // the agent could not apply is reported even when its summary leaves it out
207
+ export function buildAdditionTodosWarning(
208
+ additionName: string,
209
+ todos: AdditionTodo[]
210
+ ): AdditionTodosWarning | undefined {
211
+ if (todos.length === 0) {
212
+ return undefined;
213
+ }
214
+
215
+ const itemsNeed = todos.length === 1 ? 'item that needs' : 'items that need';
216
+
217
+ return {
218
+ title: `${additionName} left ${todos.length} ${itemsNeed} your attention.`,
219
+ body: todos.map(({ file, line, text }) => `${file}:${line} ${text}`),
220
+ };
221
+ }