@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.
- package/CHANGELOG.md +31 -0
- package/dist/codemods/additions/additions.js +20 -7
- package/dist/codemods/additions/prompts/docs-content.md +119 -0
- package/dist/codemods/additions/prompts/example-prompt.md +56 -0
- package/dist/codemods/additions/scripts/docs-content.js +30 -0
- package/dist/codemods/additions/scripts/docs.js +250 -0
- package/dist/codemods/additions/scripts/experimental-app-sdk.js +39 -33
- package/dist/codemods/agentic/definitions.js +31 -0
- package/dist/codemods/agentic/detect.js +57 -0
- package/dist/codemods/agentic/handoff.js +35 -0
- package/dist/codemods/agentic/index.js +143 -0
- package/dist/codemods/agentic/prompts.js +115 -0
- package/dist/codemods/agentic/resolve.js +120 -0
- package/dist/codemods/agentic/runner.js +79 -0
- package/dist/codemods/agentic/verify.js +55 -0
- package/dist/codemods/migrations/migrations.js +12 -0
- package/dist/codemods/migrations/scripts/017-jest-30.js +32 -0
- package/dist/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.js +115 -0
- package/dist/codemods/utils.agents.js +64 -0
- package/dist/codemods/utils.js +51 -8
- package/dist/codemods/utils.workflows.js +94 -0
- package/dist/commands/add.command.js +74 -12
- package/dist/utils/utils.cli.js +7 -2
- package/dist/utils/utils.console.js +10 -1
- package/dist/utils/utils.git.js +10 -1
- package/dist/utils/utils.handlebars.js +9 -2
- package/dist/utils/utils.packageManager.js +2 -2
- package/dist/utils/utils.templates.js +38 -24
- package/package.json +1 -1
- package/src/codemods/AGENTS.md +25 -0
- package/src/codemods/additions/additions.test.ts +69 -4
- package/src/codemods/additions/additions.ts +50 -8
- package/src/codemods/additions/prompts/docs-content.md +119 -0
- package/src/codemods/additions/prompts/example-prompt.md +56 -0
- package/src/codemods/additions/scripts/docs-content.test.ts +94 -0
- package/src/codemods/additions/scripts/docs-content.ts +32 -0
- package/src/codemods/additions/scripts/docs.test.ts +1037 -0
- package/src/codemods/additions/scripts/docs.ts +353 -0
- package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +40 -80
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +42 -34
- package/src/codemods/agentic/definitions.test.ts +67 -0
- package/src/codemods/agentic/definitions.ts +33 -0
- package/src/codemods/agentic/detect.test.ts +136 -0
- package/src/codemods/agentic/detect.ts +75 -0
- package/src/codemods/agentic/handoff.test.ts +102 -0
- package/src/codemods/agentic/handoff.ts +45 -0
- package/src/codemods/agentic/index.test.ts +342 -0
- package/src/codemods/agentic/index.ts +221 -0
- package/src/codemods/agentic/prompts.test.ts +233 -0
- package/src/codemods/agentic/prompts.ts +166 -0
- package/src/codemods/agentic/resolve.test.ts +220 -0
- package/src/codemods/agentic/resolve.ts +165 -0
- package/src/codemods/agentic/runner.test.ts +146 -0
- package/src/codemods/agentic/runner.ts +103 -0
- package/src/codemods/agentic/types.ts +50 -0
- package/src/codemods/agentic/verify.test.ts +106 -0
- package/src/codemods/agentic/verify.ts +93 -0
- package/src/codemods/migrations/migrations.ts +14 -0
- package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
- package/src/codemods/migrations/scripts/017-jest-30.test.ts +72 -0
- package/src/codemods/migrations/scripts/017-jest-30.ts +39 -0
- package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.test.ts +174 -0
- package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.ts +141 -0
- package/src/codemods/utils.agents.test.ts +140 -0
- package/src/codemods/utils.agents.ts +81 -0
- package/src/codemods/utils.test.ts +131 -1
- package/src/codemods/utils.ts +79 -8
- package/src/codemods/utils.workflows.test.ts +153 -0
- package/src/codemods/utils.workflows.ts +140 -0
- package/src/commands/add.command.ts +92 -9
- package/src/utils/utils.cli.ts +5 -0
- package/src/utils/utils.git.test.ts +57 -0
- package/src/utils/utils.git.ts +25 -0
- package/src/utils/utils.handlebars.ts +9 -0
- package/src/utils/utils.packageManager.test.ts +23 -0
- package/src/utils/utils.packageManager.ts +3 -2
- package/src/utils/utils.templates.ts +50 -25
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
- package/templates/common/.config/webpack/webpack.config.ts +4 -5
- package/templates/common/_package.json +9 -8
- package/templates/docs/common/workflows/validate-docs.yml +52 -0
- package/templates/docs/panel/docs/AGENTS.md +107 -0
- package/templates/docs/panel/docs/CLAUDE.md +1 -0
- package/templates/docs/panel/docs/README.md +20 -0
- package/templates/docs/panel/docs/data-formats.md +27 -0
- package/templates/docs/panel/docs/examples.md +35 -0
- package/templates/docs/panel/docs/index.md +26 -0
- package/templates/docs/panel/docs/options.md +53 -0
- package/templates/docs/panel/docs/troubleshooting.md +35 -0
- package/templates/github/workflows/bundle-stats.yml +1 -1
- package/templates/github/workflows/ci.yml +7 -7
- package/templates/github/workflows/is-compatible.yml +3 -3
- 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 = [
|
|
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
|
-
|
|
49
|
-
|
|
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,
|
|
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
|
-
|
|
125
|
-
|
|
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,
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
+
}
|