@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
@@ -2,15 +2,20 @@ import { fileURLToPath } from 'node:url';
2
2
  import { styleText } from 'node:util';
3
3
  import { parseDocument, stringify, YAMLMap, Scalar } from 'yaml';
4
4
  import type { Context, ContextMessage } from '../../context.js';
5
+ import type { TemplateData } from '../../../types.js';
5
6
  import { getBackendCmd } from '../../../commands/generate/print-success-message.js';
6
7
  import { output } from '../../../utils/utils.console.js';
7
- import { additionsDebug, renderTemplate } from '../../utils.js';
8
+ import { additionsDebug, getTemplateDataFromContext, renderTemplate } from '../../utils.js';
9
+ import { AGENT_INSTRUCTIONS_PATH, appendToAgentInstructions } from '../../utils.agents.js';
8
10
  import { addRequireToGoMod } from '../../utils.goMod.js';
9
- import { getTemplateData } from '../../../utils/utils.templates.js';
10
11
 
11
12
  // Grafana reads an app-sdk manifest from the plugin bundle, and registers its API server, only when
12
- // these toggles are enabled.
13
- const APP_SDK_FEATURE_TOGGLES = ['appplugins.loadAppManifest', 'appplugins.registerAPIServer'];
13
+ // these toggles are enabled. The router middleware lets the app's requests through the router.
14
+ const APP_SDK_FEATURE_TOGGLES = [
15
+ 'appplugins.loadAppManifest',
16
+ 'appplugins.registerAPIServer',
17
+ 'grafana.useRouterMiddleware',
18
+ ];
14
19
 
15
20
  // Files copied verbatim from templates/app-sdk, and whether they get the "scaffolded by create-plugin,
16
21
  // don't edit" header. Paths are relative to both the template folder and the plugin root.
@@ -45,15 +50,20 @@ export default function appSdk(context: Context): Context {
45
50
 
46
51
  const changesBefore = Object.keys(context.listChanges()).length;
47
52
 
48
- addTemplateFiles(context);
49
- referenceAgentInstructions(context);
53
+ const data = getTemplateDataFromContext(context);
54
+ if (!data.orgName) {
55
+ throw new Error('src/plugin.json has no info.author.name, which the app-sdk uses to name the Go and CUE modules.');
56
+ }
57
+
58
+ addTemplateFiles(context, data);
59
+ referenceAgentInstructions(context, data);
50
60
  addGenerateScript(context);
51
61
  addFeatureToggle(context);
52
- wireGoBackend(context);
62
+ wireGoBackend(context, data);
53
63
 
54
64
  // Only guide the user when we actually scaffolded something; a re-run should stay quiet.
55
65
  if (Object.keys(context.listChanges()).length > changesBefore) {
56
- context.setMessage(buildNextStepsMessage(hasGoBackend(context)));
66
+ context.setMessage(buildNextStepsMessage(hasGoBackend(context), data.packageManagerName));
57
67
  }
58
68
 
59
69
  return context;
@@ -98,14 +108,14 @@ function skip(title: string, body: string[] = []) {
98
108
  output.warning({ title: `Skipping app-sdk: ${title}`, body });
99
109
  }
100
110
 
101
- function addTemplateFiles(context: Context) {
111
+ function addTemplateFiles(context: Context, data: TemplateData) {
102
112
  for (const [file, includeWarning] of TEMPLATE_FILES) {
103
113
  if (context.doesFileExist(file)) {
104
114
  additionsDebug(`${file} already exists. Skipping.`);
105
115
  continue;
106
116
  }
107
117
 
108
- context.addFile(file, renderTemplate(templatePath(file), includeWarning));
118
+ context.addFile(file, renderAppSdkTemplate(file, data, includeWarning));
109
119
  }
110
120
  }
111
121
 
@@ -113,6 +123,10 @@ function templatePath(file: string): string {
113
123
  return fileURLToPath(new URL(`../../../../templates/app-sdk/${file}`, import.meta.url));
114
124
  }
115
125
 
126
+ function renderAppSdkTemplate(file: string, data: TemplateData, includeWarning = false): string {
127
+ return renderTemplate(templatePath(file), includeWarning, data);
128
+ }
129
+
116
130
  /**
117
131
  * Points the plugin's agent instructions at the app-sdk ones.
118
132
  *
@@ -120,26 +134,21 @@ function templatePath(file: string): string {
120
134
  * have an instructions.md that predates the flag, so add it here — without it, an agent reading the
121
135
  * plugin's instructions has no idea kinds or generated code exist.
122
136
  */
123
- function referenceAgentInstructions(context: Context) {
124
- const path = '.config/AGENTS/instructions.md';
125
- const instructions = context.getFile(path);
126
-
127
- if (!instructions) {
128
- additionsDebug(`Could not find ${path}. Skipping the app-sdk reference.`);
137
+ function referenceAgentInstructions(context: Context, data: TemplateData) {
138
+ if (!context.doesFileExist(AGENT_INSTRUCTIONS_PATH)) {
139
+ additionsDebug(`Could not find ${AGENT_INSTRUCTIONS_PATH}. Skipping the app-sdk reference.`);
129
140
  return;
130
141
  }
131
142
 
132
143
  if (!context.doesFileExist(APP_SDK_MD)) {
133
- context.addFile(APP_SDK_MD, renderTemplate(templatePath(APP_SDK_MD), false));
134
- }
135
-
136
- if (instructions.includes(APP_SDK_MD)) {
137
- return;
144
+ context.addFile(APP_SDK_MD, renderAppSdkTemplate(APP_SDK_MD, data));
138
145
  }
139
146
 
140
- context.updateFile(
141
- path,
142
- `${instructions.trimEnd()}\n- This plugin defines its API resources as **CUE kinds** under \`kinds/\`, with TypeScript and Go types generated from them. Read @./${APP_SDK_MD} before changing anything under \`kinds/\` or any generated directory. **Never hand-edit generated code.**\n`
147
+ appendToAgentInstructions(
148
+ context,
149
+ APP_SDK_MD,
150
+ `- This plugin defines its API resources as **CUE kinds** under \`kinds/\`, with TypeScript and Go types generated from them. Read @./${APP_SDK_MD} before changing anything under \`kinds/\` or any generated directory. **Never hand-edit generated code.**\n`,
151
+ '\n'
143
152
  );
144
153
  }
145
154
 
@@ -235,20 +244,20 @@ function addFeatureToggle(context: Context) {
235
244
  * Wires the generated kinds into the Go backend, for app plugins that have one. Go code generation
236
245
  * itself is enabled when kinds/config.cue is first scaffolded, in addTemplateFiles.
237
246
  */
238
- function wireGoBackend(context: Context) {
247
+ function wireGoBackend(context: Context, data: TemplateData) {
239
248
  if (!hasGoBackend(context)) {
240
249
  additionsDebug('No Go backend found. Skipping main.go wiring.');
241
250
  return;
242
251
  }
243
252
 
244
- addAppProvider(context);
245
- addGeneratedStubs(context);
253
+ addAppProvider(context, data);
254
+ addGeneratedStubs(context, data);
246
255
  wireMainGo(context);
247
256
  addGoModDependency(context);
248
257
  }
249
258
 
250
259
  // The go.mod version for grafana-app-sdk and its plugin/ submodule.
251
- const GRAFANA_APP_SDK_VERSION = 'v0.60.3';
260
+ const GRAFANA_APP_SDK_VERSION = 'v0.60.16';
252
261
 
253
262
  /**
254
263
  * Adds github.com/grafana/grafana-app-sdk and its plugin/ submodule to go.mod. The generated Go kind
@@ -272,7 +281,7 @@ function addGoModDependency(context: Context) {
272
281
  * Not overwritten on a re-run — like kinds/*.cue, it's meant to be edited as the plugin adds
273
282
  * validators, mutators, or more kinds.
274
283
  */
275
- function addAppProvider(context: Context) {
284
+ function addAppProvider(context: Context, data: TemplateData) {
276
285
  const path = 'pkg/provider/provider.go';
277
286
 
278
287
  if (context.doesFileExist(path)) {
@@ -280,7 +289,7 @@ function addAppProvider(context: Context) {
280
289
  return;
281
290
  }
282
291
 
283
- context.addFile(path, renderTemplate(templatePath(path), false));
292
+ context.addFile(path, renderAppSdkTemplate(path, data));
284
293
  }
285
294
 
286
295
  // Stub doc.go files under pkg/generated/. Both are real packages `generate:kinds` fills in; they
@@ -295,14 +304,14 @@ const GENERATED_STUB_PATHS = ['pkg/generated/example/v1alpha1/doc.go', 'pkg/gene
295
304
  * Scaffolds stub packages under pkg/generated/, so provider.go's imports resolve for `go mod tidy`
296
305
  * before `generate:kinds` has run for the first time.
297
306
  */
298
- function addGeneratedStubs(context: Context) {
307
+ function addGeneratedStubs(context: Context, data: TemplateData) {
299
308
  for (const path of GENERATED_STUB_PATHS) {
300
309
  if (context.doesFileExist(path)) {
301
310
  additionsDebug(`${path} already exists. Skipping.`);
302
311
  continue;
303
312
  }
304
313
 
305
- context.addFile(path, renderTemplate(templatePath(path), false));
314
+ context.addFile(path, renderAppSdkTemplate(path, data));
306
315
  }
307
316
  }
308
317
 
@@ -398,8 +407,7 @@ ${errorBody}\t}`
398
407
  }
399
408
 
400
409
  /** Builds the message telling the user what to run next. */
401
- function buildNextStepsMessage(hasGoBackend: boolean): ContextMessage {
402
- const { packageManagerName } = getTemplateData();
410
+ function buildNextStepsMessage(hasGoBackend: boolean, packageManagerName: string): ContextMessage {
403
411
  const versionBadge = styleText(['reset', 'inverse', 'bold', 'cyan'], ` grafana-app-sdk@${GRAFANA_APP_SDK_VERSION} `);
404
412
 
405
413
  const commands = output.bulletList([
@@ -0,0 +1,67 @@
1
+ import { AGENT_DEFINITIONS } from './definitions.js';
2
+ import { AgentInvocationContext } from './types.js';
3
+
4
+ const invocationContext: AgentInvocationContext = {
5
+ systemPrompt: 'system prompt contents',
6
+ userPrompt: 'user prompt contents',
7
+ workspaceRoot: '/virtual/workspace',
8
+ handoffDir: '/virtual/workspace/node_modules/.cache/grafana-create-plugin/agent-runs/run-1',
9
+ };
10
+
11
+ function getDefinition(id: string) {
12
+ const definition = AGENT_DEFINITIONS.find((agentDefinition) => agentDefinition.id === id);
13
+ if (!definition) {
14
+ throw new Error(`No agent definition found for ${id}`);
15
+ }
16
+ return definition;
17
+ }
18
+
19
+ describe('AGENT_DEFINITIONS', () => {
20
+ it('should define claude-code and codex in preference order', () => {
21
+ expect(AGENT_DEFINITIONS.map((agentDefinition) => agentDefinition.id)).toEqual(['claude-code', 'codex']);
22
+ });
23
+
24
+ AGENT_DEFINITIONS.forEach((definition) => {
25
+ describe(definition.id, () => {
26
+ it('should have at least one binary name to probe', () => {
27
+ expect(definition.binaryNames.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ it('should embed both prompts in the interactive invocation', () => {
31
+ const invocation = definition.buildInteractive(invocationContext);
32
+ const serialisedArgs = invocation.args.join(' ');
33
+ expect(serialisedArgs).toContain(invocationContext.systemPrompt);
34
+ expect(serialisedArgs).toContain(invocationContext.userPrompt);
35
+ });
36
+ });
37
+ });
38
+
39
+ it('should append the system prompt to the claude-code default rather than replace it', () => {
40
+ const invocation = getDefinition('claude-code').buildInteractive(invocationContext);
41
+ const appendIndex = invocation.args.indexOf('--append-system-prompt');
42
+
43
+ expect(appendIndex).toBeGreaterThan(-1);
44
+ expect(invocation.args[appendIndex + 1]).toBe(invocationContext.systemPrompt);
45
+ expect(invocation.args).not.toContain('--system-prompt');
46
+ });
47
+
48
+ it('should pre-authorize the handoff write for claude-code', () => {
49
+ const invocation = getDefinition('claude-code').buildInteractive(invocationContext);
50
+ const allowedToolsIndex = invocation.args.indexOf('--allowedTools');
51
+ expect(allowedToolsIndex).toBeGreaterThan(-1);
52
+ // claude-code only honours Edit(...) rules for file permission checks, and they cover Write too
53
+ expect(invocation.args[allowedToolsIndex + 1]).toBe(`Edit(${invocationContext.handoffDir}/**)`);
54
+ });
55
+
56
+ it('should end claude-code options before the user prompt', () => {
57
+ const invocation = getDefinition('claude-code').buildInteractive(invocationContext);
58
+
59
+ // --allowedTools takes a list, so without `--` the prompt is read as more rules and never sent
60
+ expect(invocation.args.slice(-2)).toEqual(['--', invocationContext.userPrompt]);
61
+ });
62
+
63
+ it('should pass the system prompt as developer instructions for codex', () => {
64
+ const invocation = getDefinition('codex').buildInteractive(invocationContext);
65
+ expect(invocation.args).toContain(`developer_instructions=${invocationContext.systemPrompt}`);
66
+ });
67
+ });
@@ -0,0 +1,33 @@
1
+ import { AgentDefinition } from './types.js';
2
+
3
+ // Pure data. Invocation args mirror the Nx agentic migrate implementation (nrwl/nx#35718, #35889)
4
+ // and must be verified against the currently shipping agent CLIs when changed.
5
+ export const AGENT_DEFINITIONS: AgentDefinition[] = [
6
+ {
7
+ id: 'claude-code',
8
+ displayName: 'Claude Code',
9
+ binaryNames: ['claude'],
10
+ buildInteractive: (invocationContext) => ({
11
+ args: [
12
+ // appended rather than replaced, so claude-code keeps its own tool and safety guidance
13
+ '--append-system-prompt',
14
+ invocationContext.systemPrompt,
15
+ // pre-authorize the handoff write so the session cannot stall on a permission prompt.
16
+ // claude-code matches file permissions against Edit(...) rules only; they cover Write too
17
+ '--allowedTools',
18
+ `Edit(${invocationContext.handoffDir}/**)`,
19
+ // --allowedTools takes a list, so end the options or the prompt is read as more rules
20
+ '--',
21
+ invocationContext.userPrompt,
22
+ ],
23
+ }),
24
+ },
25
+ {
26
+ id: 'codex',
27
+ displayName: 'OpenAI Codex',
28
+ binaryNames: ['codex'],
29
+ buildInteractive: (invocationContext) => ({
30
+ args: ['-c', `developer_instructions=${invocationContext.systemPrompt}`, invocationContext.userPrompt],
31
+ }),
32
+ },
33
+ ];
@@ -0,0 +1,136 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { constants } from 'node:fs';
4
+ import { access } from 'node:fs/promises';
5
+ import which from 'which';
6
+ import { detectInstalledAgents, isInsideAgent, resolveAgentFromPath } from './detect.js';
7
+ import { AgentDefinition } from './types.js';
8
+
9
+ vi.mock('which');
10
+ vi.mock('node:fs/promises');
11
+
12
+ const whichMock = vi.mocked(which);
13
+ const accessMock = vi.mocked(access);
14
+
15
+ function createDefinition(overrides: Partial<AgentDefinition>): AgentDefinition {
16
+ return {
17
+ id: 'claude-code',
18
+ displayName: 'Claude Code',
19
+ binaryNames: ['claude'],
20
+ buildInteractive: () => ({ args: [] }),
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe('detectInstalledAgents', () => {
26
+ beforeEach(() => {
27
+ vi.resetAllMocks();
28
+ });
29
+
30
+ it('should detect an agent found on the PATH', async () => {
31
+ whichMock.mockResolvedValue('/usr/local/bin/claude');
32
+
33
+ const installed = await detectInstalledAgents([createDefinition({})]);
34
+
35
+ expect(installed).toHaveLength(1);
36
+ expect(installed[0].binaryPath).toBe('/usr/local/bin/claude');
37
+ expect(whichMock).toHaveBeenCalledWith('claude', { nothrow: true });
38
+ });
39
+
40
+ it('should return an empty list when nothing is installed', async () => {
41
+ // @ts-expect-error - the nothrow overload returns string | null which the mocked union cannot infer
42
+ whichMock.mockResolvedValue(null);
43
+
44
+ const installed = await detectInstalledAgents([createDefinition({})]);
45
+
46
+ expect(installed).toEqual([]);
47
+ });
48
+
49
+ it('should preserve definition order even when probes resolve out of order', async () => {
50
+ whichMock.mockImplementation((binaryName) => {
51
+ if (binaryName === 'claude') {
52
+ return new Promise((resolve) => {
53
+ setTimeout(() => resolve('/usr/local/bin/claude'), 20);
54
+ });
55
+ }
56
+ return Promise.resolve('/usr/local/bin/codex');
57
+ });
58
+
59
+ const installed = await detectInstalledAgents([
60
+ createDefinition({}),
61
+ createDefinition({ id: 'codex', displayName: 'OpenAI Codex', binaryNames: ['codex'] }),
62
+ ]);
63
+
64
+ expect(installed.map((agent) => agent.definition.id)).toEqual(['claude-code', 'codex']);
65
+ });
66
+ });
67
+
68
+ describe('isInsideAgent', () => {
69
+ it.each(['CLAUDECODE', 'CURSOR_TRACE_ID', 'OPENCODE', 'CODEX_THREAD_ID', 'GEMINI_CLI', 'VSCODE_AGENT', 'REPL_ID'])(
70
+ 'should return true when %s is set',
71
+ (envVar) => {
72
+ expect(isInsideAgent({ [envVar]: '1' })).toBe(true);
73
+ }
74
+ );
75
+
76
+ it('should return false when no agent environment variables are set', () => {
77
+ expect(isInsideAgent({ PATH: '/usr/bin', TERM: 'xterm-256color' })).toBe(false);
78
+ });
79
+
80
+ it('should return false when an agent environment variable is set but empty', () => {
81
+ expect(isInsideAgent({ CLAUDECODE: '' })).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe('resolveAgentFromPath', () => {
86
+ beforeEach(() => {
87
+ vi.resetAllMocks();
88
+ accessMock.mockResolvedValue(undefined);
89
+ });
90
+
91
+ it('should resolve the definition from the binary name', async () => {
92
+ const definition = createDefinition({});
93
+
94
+ const agent = await resolveAgentFromPath('/opt/homebrew/bin/claude', [definition]);
95
+
96
+ expect(agent.definition).toBe(definition);
97
+ expect(agent.binaryPath).toBe('/opt/homebrew/bin/claude');
98
+ });
99
+
100
+ it('should expand a leading ~ to the home directory, since shells leave it alone after --agent=', async () => {
101
+ const agent = await resolveAgentFromPath('~/.claude/local/claude', [createDefinition({})]);
102
+
103
+ expect(agent.binaryPath).toBe(join(homedir(), '.claude/local/claude'));
104
+ expect(accessMock).toHaveBeenCalledWith(join(homedir(), '.claude/local/claude'), constants.X_OK);
105
+ });
106
+
107
+ it('should check the path is executable', async () => {
108
+ await resolveAgentFromPath('/opt/homebrew/bin/claude', [createDefinition({})]);
109
+
110
+ expect(accessMock).toHaveBeenCalledWith('/opt/homebrew/bin/claude', constants.X_OK);
111
+ });
112
+
113
+ it('should throw when the binary name matches no known agent', async () => {
114
+ await expect(resolveAgentFromPath('/usr/local/bin/mystery', [createDefinition({})])).rejects.toThrow(
115
+ /Could not tell which agent/
116
+ );
117
+ });
118
+
119
+ it('should name the expected binaries when the name matches nothing', async () => {
120
+ await expect(resolveAgentFromPath('/usr/local/bin/mystery', [createDefinition({})])).rejects.toThrow(/claude/);
121
+ });
122
+
123
+ it('should throw when the path is not executable', async () => {
124
+ accessMock.mockRejectedValue(new Error('EACCES'));
125
+
126
+ await expect(resolveAgentFromPath('/opt/homebrew/bin/claude', [createDefinition({})])).rejects.toThrow(
127
+ /not an executable file/
128
+ );
129
+ });
130
+
131
+ it('should not check executability when the name matches nothing', async () => {
132
+ await expect(resolveAgentFromPath('/usr/local/bin/mystery', [createDefinition({})])).rejects.toThrow();
133
+
134
+ expect(accessMock).not.toHaveBeenCalled();
135
+ });
136
+ });
@@ -0,0 +1,75 @@
1
+ import { constants } from 'node:fs';
2
+ import { access } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+ import which from 'which';
6
+ import { AGENT_DEFINITIONS } from './definitions.js';
7
+ import { AgentDefinition, InstalledAgent } from './types.js';
8
+
9
+ // Environment variables set by AI agent CLIs/IDEs when they run a shell command.
10
+ // Used to avoid spawning an agent from inside another agent.
11
+ const AGENT_ENV_VARS = [
12
+ 'CLAUDECODE',
13
+ 'CURSOR_TRACE_ID',
14
+ 'OPENCODE',
15
+ 'CODEX_THREAD_ID',
16
+ 'GEMINI_CLI',
17
+ 'VSCODE_AGENT',
18
+ 'REPL_ID',
19
+ ];
20
+
21
+ export async function detectInstalledAgents(
22
+ definitions: AgentDefinition[] = AGENT_DEFINITIONS
23
+ ): Promise<InstalledAgent[]> {
24
+ const probes = await Promise.all(definitions.map(detectAgent));
25
+ return probes.filter((agent): agent is InstalledAgent => agent !== null);
26
+ }
27
+
28
+ // escape hatch for agents installed outside PATH, e.g. claude at ~/.claude/local/claude. the binary
29
+ // name has to identify the agent because each one takes different invocation arguments
30
+ export async function resolveAgentFromPath(
31
+ agentPath: string,
32
+ definitions: AgentDefinition[] = AGENT_DEFINITIONS
33
+ ): Promise<InstalledAgent> {
34
+ const binaryPath = expandHomeDirectory(agentPath);
35
+ const binaryName = basename(binaryPath);
36
+ const definition = definitions.find((agentDefinition) => agentDefinition.binaryNames.includes(binaryName));
37
+
38
+ if (!definition) {
39
+ const expectedNames = definitions.flatMap((agentDefinition) => agentDefinition.binaryNames).join(', ');
40
+ throw new Error(
41
+ `Could not tell which agent "${binaryPath}" is. Expected the file to be named one of: ${expectedNames}.`
42
+ );
43
+ }
44
+
45
+ try {
46
+ await access(binaryPath, constants.X_OK);
47
+ } catch {
48
+ throw new Error(`The agent path "${binaryPath}" is not an executable file.`);
49
+ }
50
+
51
+ return { definition, binaryPath };
52
+ }
53
+
54
+ // shells only expand ~ at the start of a word, so `--agent=~/...` reaches us unexpanded
55
+ function expandHomeDirectory(agentPath: string): string {
56
+ if (!agentPath.startsWith('~/')) {
57
+ return agentPath;
58
+ }
59
+ return join(homedir(), agentPath.slice(2));
60
+ }
61
+
62
+ export function isInsideAgent(env: NodeJS.ProcessEnv = process.env): boolean {
63
+ return AGENT_ENV_VARS.some((envVar) => Boolean(env[envVar]));
64
+ }
65
+
66
+ async function detectAgent(definition: AgentDefinition): Promise<InstalledAgent | null> {
67
+ for (const binaryName of definition.binaryNames) {
68
+ const binaryPath = await which(binaryName, { nothrow: true });
69
+ if (binaryPath) {
70
+ return { definition, binaryPath };
71
+ }
72
+ }
73
+
74
+ return null;
75
+ }
@@ -0,0 +1,102 @@
1
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import {
5
+ createRunId,
6
+ ensureRunDir,
7
+ getHandoffPath,
8
+ getRunDir,
9
+ AGENT_RUNS_DIR,
10
+ parseHandoff,
11
+ wipeAgentRuns,
12
+ } from './handoff.js';
13
+
14
+ describe('run dir lifecycle', () => {
15
+ let basePath: string;
16
+
17
+ beforeEach(() => {
18
+ basePath = mkdtempSync(join(tmpdir(), 'cp-handoff-test-'));
19
+ });
20
+
21
+ afterEach(() => {
22
+ rmSync(basePath, { recursive: true, force: true });
23
+ });
24
+
25
+ it('should build run dirs under the agent-runs cache dir', () => {
26
+ expect(getRunDir(basePath, 'run-1')).toBe(join(basePath, AGENT_RUNS_DIR, 'run-1'));
27
+ });
28
+
29
+ it('should build handoff paths from the migration name', () => {
30
+ const runDir = getRunDir(basePath, 'run-1');
31
+ expect(getHandoffPath(runDir, '013-example')).toBe(join(runDir, '013-example.json'));
32
+ });
33
+
34
+ it('should create run dirs and tolerate them already existing', () => {
35
+ const runDir = getRunDir(basePath, 'run-1');
36
+ ensureRunDir(runDir);
37
+ expect(existsSync(runDir)).toBe(true);
38
+ expect(() => ensureRunDir(runDir)).not.toThrow();
39
+ });
40
+
41
+ it('should wipe all previous runs', () => {
42
+ const runDir = getRunDir(basePath, 'run-1');
43
+ ensureRunDir(runDir);
44
+ writeFileSync(getHandoffPath(runDir, '013-example'), '{}');
45
+
46
+ wipeAgentRuns(basePath);
47
+
48
+ expect(existsSync(join(basePath, AGENT_RUNS_DIR))).toBe(false);
49
+ });
50
+
51
+ it('should not throw when wiping a base path with no runs', () => {
52
+ expect(() => wipeAgentRuns(basePath)).not.toThrow();
53
+ });
54
+ });
55
+
56
+ describe('createRunId', () => {
57
+ it('should create ids that are safe to use as directory names', () => {
58
+ expect(createRunId()).toMatch(/^[a-z0-9-]+$/);
59
+ });
60
+
61
+ it('should create unique ids', () => {
62
+ expect(createRunId()).not.toBe(createRunId());
63
+ });
64
+ });
65
+
66
+ describe('parseHandoff', () => {
67
+ it('should parse a success handoff', () => {
68
+ expect(parseHandoff('{"status":"success","summary":"done"}')).toEqual({ status: 'success', summary: 'done' });
69
+ });
70
+
71
+ it('should parse a failed handoff', () => {
72
+ expect(parseHandoff('{"status":"failed","summary":"could not migrate"}')).toEqual({
73
+ status: 'failed',
74
+ summary: 'could not migrate',
75
+ });
76
+ });
77
+
78
+ it('should drop unknown keys', () => {
79
+ expect(parseHandoff('{"status":"success","summary":"done","extras":{"foo":1}}')).toEqual({
80
+ status: 'success',
81
+ summary: 'done',
82
+ });
83
+ });
84
+
85
+ it('should return undefined for malformed JSON', () => {
86
+ expect(parseHandoff('{"status": "succ')).toBeUndefined();
87
+ });
88
+
89
+ it('should return undefined for unexpected status values', () => {
90
+ expect(parseHandoff('{"status":"maybe","summary":"done"}')).toBeUndefined();
91
+ });
92
+
93
+ it('should return undefined when summary is missing', () => {
94
+ expect(parseHandoff('{"status":"success"}')).toBeUndefined();
95
+ });
96
+
97
+ it('should not be vulnerable to prototype pollution', () => {
98
+ const handoff = parseHandoff('{"status":"success","summary":"done","__proto__":{"polluted":true}}');
99
+ expect(handoff).toEqual({ status: 'success', summary: 'done' });
100
+ expect('polluted' in {}).toBe(false);
101
+ });
102
+ });
@@ -0,0 +1,45 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdirSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import * as v from 'valibot';
5
+ import { Handoff } from './types.js';
6
+
7
+ // The handoff file is ephemeral single-run IPC between the spawned agent and create-plugin:
8
+ // the agent writes it as its final action, the runner polls and consumes it immediately.
9
+ // It lives under node_modules so it is gitignored in every scaffold and never needs to persist.
10
+ export const AGENT_RUNS_DIR = join('node_modules', '.cache', 'grafana-create-plugin', 'agent-runs');
11
+
12
+ const handoffSchema = v.object({
13
+ status: v.picklist(['success', 'failed']),
14
+ summary: v.string(),
15
+ });
16
+
17
+ export function createRunId(): string {
18
+ return `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
19
+ }
20
+
21
+ export function getRunDir(basePath: string, runId: string): string {
22
+ return join(basePath, AGENT_RUNS_DIR, runId);
23
+ }
24
+
25
+ export function getHandoffPath(runDir: string, stepName: string): string {
26
+ return join(runDir, `${stepName}.json`);
27
+ }
28
+
29
+ // re-run before every agent session; a package manager install may have pruned the cache dir
30
+ export function ensureRunDir(runDir: string): void {
31
+ mkdirSync(runDir, { recursive: true });
32
+ }
33
+
34
+ export function wipeAgentRuns(basePath: string): void {
35
+ rmSync(join(basePath, AGENT_RUNS_DIR), { recursive: true, force: true });
36
+ }
37
+
38
+ export function parseHandoff(raw: string): Handoff | undefined {
39
+ try {
40
+ const parsed = v.parse(handoffSchema, JSON.parse(raw));
41
+ return { status: parsed.status, summary: parsed.summary };
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }