From 0ec2d96496ba6f9c5bf656e829d4d137d2a11b12 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 13 Jul 2026 10:20:56 +0200 Subject: [PATCH 01/16] feat(create-plugin): add prompt and hybrid migration shapes to the registry Migrations can now be script-only, prompt-only, or hybrid (script + prompt). Prompt-only migrations are skipped by the runner until the agentic runtime lands. Includes an unregistered example prompt documenting the authoring contract. --- .../src/codemods/migrations/manager.ts | 6 +- .../codemods/migrations/migrations.test.ts | 59 +++++++++++++++++-- .../src/codemods/migrations/migrations.ts | 26 +++++++- .../migrations/prompts/000-example.md | 48 +++++++++++++++ 4 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 packages/create-plugin/src/codemods/migrations/prompts/000-example.md diff --git a/packages/create-plugin/src/codemods/migrations/manager.ts b/packages/create-plugin/src/codemods/migrations/manager.ts index 18ed1786c6..9d21d53749 100644 --- a/packages/create-plugin/src/codemods/migrations/manager.ts +++ b/packages/create-plugin/src/codemods/migrations/manager.ts @@ -1,4 +1,4 @@ -import defaultMigrations, { Migration } from './migrations.js'; +import defaultMigrations, { isScriptMigration, Migration } from './migrations.js'; import { runCodemod } from '../runner.js'; import { gte, satisfies } from 'semver'; import { CURRENT_APP_VERSION } from '../../utils/utils.version.js'; @@ -34,6 +34,10 @@ export async function runMigrations(migrations: Migration[], options: RunMigrati // run migrations sequentially in version order where lowest version runs first for (const migration of migrations) { + if (!isScriptMigration(migration)) { + // prompt-only migrations require an AI agent to apply them (agentic runtime wiring lands separately) + continue; + } const context = await runCodemod(migration, options.codemodOptions); const shouldCommit = options.commitEachMigration && context.hasChanges(); diff --git a/packages/create-plugin/src/codemods/migrations/migrations.test.ts b/packages/create-plugin/src/codemods/migrations/migrations.test.ts index e4a6c9c3e0..ee80d7dcba 100644 --- a/packages/create-plugin/src/codemods/migrations/migrations.test.ts +++ b/packages/create-plugin/src/codemods/migrations/migrations.test.ts @@ -1,17 +1,64 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import defaultMigrations from './migrations.js'; +import defaultMigrations, { hasPromptStep, isScriptMigration, Migration } from './migrations.js'; describe('migrations json', () => { // As migration scripts are imported dynamically when update is run we assert the path is valid // Vitest 4 reimplemented its workers, which caused the previous dynamic import tests to fail. // This test now only asserts that the migration script source file exists. defaultMigrations.forEach((migration) => { - it(`should have a valid migration script path for ${migration.name}`, () => { - // import.meta.resolve() returns a file:// URL, convert to path - const filePath = fileURLToPath(migration.scriptPath); - const sourceFilePath = filePath.replace('.js', '.ts'); - expect(existsSync(sourceFilePath)).toBe(true); + it(`should have a script and/or a prompt for ${migration.name}`, () => { + expect(isScriptMigration(migration) || hasPromptStep(migration)).toBe(true); }); + + if (isScriptMigration(migration)) { + it(`should have a valid migration script path for ${migration.name}`, () => { + // import.meta.resolve() returns a file:// URL, convert to path + const filePath = fileURLToPath(migration.scriptPath); + const sourceFilePath = filePath.replace('.js', '.ts'); + expect(existsSync(sourceFilePath)).toBe(true); + }); + } + + if (hasPromptStep(migration)) { + it(`should have a valid prompt file for ${migration.name}`, () => { + // prompt files ship verbatim so there is no compiled extension to map back to source + expect(existsSync(fileURLToPath(migration.prompt))).toBe(true); + }); + } + }); +}); + +describe('migration type guards', () => { + const scriptOnly: Migration = { + name: 'script-only', + version: '1.0.0', + description: 'a migration with only a codemod script', + scriptPath: 'file:///virtual/scripts/001-script-only.js', + }; + const hybrid: Migration = { + name: 'hybrid', + version: '1.0.0', + description: 'a migration with a codemod script and a prompt', + scriptPath: 'file:///virtual/scripts/002-hybrid.js', + prompt: 'file:///virtual/prompts/002-hybrid.md', + }; + const promptOnly: Migration = { + name: 'prompt-only', + version: '1.0.0', + description: 'a migration with only a prompt', + prompt: 'file:///virtual/prompts/003-prompt-only.md', + }; + + it('isScriptMigration should return true only for migrations with a script', () => { + expect(isScriptMigration(scriptOnly)).toBe(true); + expect(isScriptMigration(hybrid)).toBe(true); + expect(isScriptMigration(promptOnly)).toBe(false); + }); + + it('hasPromptStep should return true only for migrations with a prompt', () => { + expect(hasPromptStep(scriptOnly)).toBe(false); + expect(hasPromptStep(hybrid)).toBe(true); + expect(hasPromptStep(promptOnly)).toBe(true); }); }); diff --git a/packages/create-plugin/src/codemods/migrations/migrations.ts b/packages/create-plugin/src/codemods/migrations/migrations.ts index f6353c6601..4dc8715d39 100644 --- a/packages/create-plugin/src/codemods/migrations/migrations.ts +++ b/packages/create-plugin/src/codemods/migrations/migrations.ts @@ -1,10 +1,32 @@ import { LEGACY_UPDATE_CUTOFF_VERSION } from '../../constants.js'; -import { Codemod } from '../types.js'; -export interface Migration extends Codemod { +interface MigrationBase { + name: string; + description: string; version: string; } +export interface ScriptMigration extends MigrationBase { + scriptPath: string; + // hybrid migrations pair a codemod script with agent instructions that finish the work + prompt?: string; +} + +export interface PromptOnlyMigration extends MigrationBase { + prompt: string; + scriptPath?: undefined; +} + +export type Migration = ScriptMigration | PromptOnlyMigration; + +export function isScriptMigration(migration: Migration): migration is ScriptMigration { + return typeof migration.scriptPath === 'string'; +} + +export function hasPromptStep(migration: Migration): migration is Migration & { prompt: string } { + return typeof migration.prompt === 'string'; +} + export default [ { name: '001-update-grafana-compose-extend', diff --git a/packages/create-plugin/src/codemods/migrations/prompts/000-example.md b/packages/create-plugin/src/codemods/migrations/prompts/000-example.md new file mode 100644 index 0000000000..f9bd48abde --- /dev/null +++ b/packages/create-plugin/src/codemods/migrations/prompts/000-example.md @@ -0,0 +1,48 @@ +# Example prompt migration + + + +## Context + +Explain what changed in create-plugin and why this project needs updating. +For hybrid migrations, describe what the codemod half already did and what is +deliberately left for this step. + +## Check first + +Describe how to tell whether there is anything to do. If the project is +already in the desired state, stop and report that nothing was needed. + +## Steps + +1. Concrete, ordered instructions referencing exact file paths. +2. Prefer "port X to Y" over "improve X" — no judgment calls without criteria. +3. If something cannot be migrated mechanically, say what to preserve and how + to flag it (for example a `TODO` comment) rather than silently dropping it. + +## Verify + +State the command(s) that prove the migration worked (for example the plugin's +build or test script) and limit fixes to breakage caused by this migration. + +## Out of scope + +- Do not modify anything under `.config/` (tool-managed). +- Do not upgrade unrelated dependencies or reformat untouched files. From 6fd381b156793e62459d7d6dba31e4f0a06b2f9a Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 13 Jul 2026 10:22:58 +0200 Subject: [PATCH 02/16] build(create-plugin): copy migration prompt files to dist Prompt .md files are resolved relative to migrations.js at runtime via import.meta.resolve, so they must ship verbatim alongside the compiled migration scripts. Verified with a build smoke: dist/codemods/migrations/prompts/000-example.md exists. --- rollup.config.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/rollup.config.ts b/rollup.config.ts index a78aec578a..473d95cff7 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -2,7 +2,7 @@ import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import { glob, GlobOptions } from 'glob'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { chmod, cp } from 'node:fs/promises'; import { join } from 'node:path'; import { inspect } from 'node:util'; @@ -142,6 +142,15 @@ function copyAssets(): Plugin { const distStyles = join(projectRoot, 'dist', 'server', 'styles'); await cp(srcStyles, distStyles, { recursive: true }); } + + if (pkg.name === '@grafana/create-plugin') { + // migration prompt .md files are resolved relative to migrations.js at runtime via import.meta.resolve + const srcPrompts = join(projectRoot, 'src', 'codemods', 'migrations', 'prompts'); + const distPrompts = join(projectRoot, 'dist', 'codemods', 'migrations', 'prompts'); + if (existsSync(srcPrompts)) { + await cp(srcPrompts, distPrompts, { recursive: true }); + } + } }, }; } From 79af011304fd3fa4e344de2a0d49cba40f161e7f Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 13 Jul 2026 10:24:25 +0200 Subject: [PATCH 03/16] feat(create-plugin): add agentic types and agent CLI definitions AGENT_DEFINITIONS is pure data describing how to detect and interactively invoke Claude Code and OpenAI Codex. Claude invocations pre-authorize the handoff write via --allowedTools so sessions cannot stall on a permission prompt. --- .../src/codemods/agentic/definitions.test.ts | 50 +++++++++++++++++++ .../src/codemods/agentic/definitions.ts | 33 ++++++++++++ .../src/codemods/agentic/types.ts | 49 ++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 packages/create-plugin/src/codemods/agentic/definitions.test.ts create mode 100644 packages/create-plugin/src/codemods/agentic/definitions.ts create mode 100644 packages/create-plugin/src/codemods/agentic/types.ts diff --git a/packages/create-plugin/src/codemods/agentic/definitions.test.ts b/packages/create-plugin/src/codemods/agentic/definitions.test.ts new file mode 100644 index 0000000000..ef966cda55 --- /dev/null +++ b/packages/create-plugin/src/codemods/agentic/definitions.test.ts @@ -0,0 +1,50 @@ +import { AGENT_DEFINITIONS } from './definitions.js'; +import { AgentInvocationContext } from './types.js'; + +const invocationContext: AgentInvocationContext = { + systemPrompt: 'system prompt contents', + userPrompt: 'user prompt contents', + workspaceRoot: '/virtual/workspace', + handoffDir: '/virtual/workspace/node_modules/.cache/grafana-create-plugin/migrate-runs/run-1', +}; + +function getDefinition(id: string) { + const definition = AGENT_DEFINITIONS.find((agentDefinition) => agentDefinition.id === id); + if (!definition) { + throw new Error(`No agent definition found for ${id}`); + } + return definition; +} + +describe('AGENT_DEFINITIONS', () => { + it('should define claude-code and codex in preference order', () => { + expect(AGENT_DEFINITIONS.map((agentDefinition) => agentDefinition.id)).toEqual(['claude-code', 'codex']); + }); + + AGENT_DEFINITIONS.forEach((definition) => { + describe(definition.id, () => { + it('should have at least one binary name to probe', () => { + expect(definition.binaryNames.length).toBeGreaterThan(0); + }); + + it('should embed both prompts in the interactive invocation', () => { + const invocation = definition.buildInteractive(invocationContext); + const serialisedArgs = invocation.args.join(' '); + expect(serialisedArgs).toContain(invocationContext.systemPrompt); + expect(serialisedArgs).toContain(invocationContext.userPrompt); + }); + }); + }); + + it('should pre-authorize the handoff write for claude-code', () => { + const invocation = getDefinition('claude-code').buildInteractive(invocationContext); + const allowedToolsIndex = invocation.args.indexOf('--allowedTools'); + expect(allowedToolsIndex).toBeGreaterThan(-1); + expect(invocation.args[allowedToolsIndex + 1]).toBe(`Write(${invocationContext.handoffDir}/**)`); + }); + + it('should pass the system prompt as developer instructions for codex', () => { + const invocation = getDefinition('codex').buildInteractive(invocationContext); + expect(invocation.args).toContain(`developer_instructions=${invocationContext.systemPrompt}`); + }); +}); diff --git a/packages/create-plugin/src/codemods/agentic/definitions.ts b/packages/create-plugin/src/codemods/agentic/definitions.ts new file mode 100644 index 0000000000..7d057b0c2e --- /dev/null +++ b/packages/create-plugin/src/codemods/agentic/definitions.ts @@ -0,0 +1,33 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { AgentDefinition } from './types.js'; + +// Pure data. Invocation args mirror the Nx agentic migrate implementation (nrwl/nx#35718, #35889) +// and must be verified against the currently shipping agent CLIs when changed. +export const AGENT_DEFINITIONS: AgentDefinition[] = [ + { + id: 'claude-code', + displayName: 'Claude Code', + binaryNames: ['claude'], + wellKnownPaths: [join(homedir(), '.claude', 'local', 'claude')], + buildInteractive: (invocationContext) => ({ + args: [ + '--system-prompt', + invocationContext.systemPrompt, + // pre-authorize the handoff write so the session cannot stall on a permission prompt + '--allowedTools', + `Write(${invocationContext.handoffDir}/**)`, + invocationContext.userPrompt, + ], + }), + }, + { + id: 'codex', + displayName: 'OpenAI Codex', + binaryNames: ['codex'], + wellKnownPaths: [], + buildInteractive: (invocationContext) => ({ + args: ['-c', `developer_instructions=${invocationContext.systemPrompt}`, invocationContext.userPrompt], + }), + }, +]; diff --git a/packages/create-plugin/src/codemods/agentic/types.ts b/packages/create-plugin/src/codemods/agentic/types.ts new file mode 100644 index 0000000000..caa3ca7968 --- /dev/null +++ b/packages/create-plugin/src/codemods/agentic/types.ts @@ -0,0 +1,49 @@ +export type AgentId = 'claude-code' | 'codex'; + +export interface AgentInvocationContext { + systemPrompt: string; + userPrompt: string; + workspaceRoot: string; + handoffDir: string; +} + +export interface AgentInvocation { + args: string[]; + env?: Record; +} + +export interface AgentDefinition { + id: AgentId; + displayName: string; + // candidate binary names probed on PATH, in order + binaryNames: string[]; + // absolute fallback locations checked for the execute bit when the binary is not on PATH + wellKnownPaths: string[]; + buildInteractive: (invocationContext: AgentInvocationContext) => AgentInvocation; +} + +export interface InstalledAgent { + definition: AgentDefinition; + binaryPath: string; +} + +export type AgenticResolution = + | { mode: 'inside-agent' } + | { mode: 'disabled'; reason: 'no-tty' | 'flag' | 'declined' | 'no-agents' } + | { mode: 'enabled'; agent: InstalledAgent }; + +export interface Handoff { + status: 'success' | 'failed'; + summary: string; +} + +export type AgentSessionResult = + | { outcome: 'handoff'; handoff: Handoff } + | { outcome: 'ambiguous-exit'; exitCode: number | null; signal: NodeJS.Signals | null } + | { outcome: 'user-aborted' }; + +export type PromptStepResult = + | { kind: 'applied'; summary: string } + | { kind: 'deferred' } + // the agent session ended without a valid handoff and the user chose to continue + | { kind: 'assumed-applied' }; From cf614ffbb656d883054977d8632bd29a17ccbde7 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 13 Jul 2026 10:26:19 +0200 Subject: [PATCH 04/16] feat(create-plugin): detect installed agent CLIs and inside-agent environments detectInstalledAgents probes each agent definition on the PATH via which, falling back to well-known install paths checked for the execute bit. isInsideAgent sniffs the environment variables agent CLIs set so update never spawns an agent from inside another agent. --- .../src/codemods/agentic/detect.test.ts | 95 +++++++++++++++++++ .../src/codemods/agentic/detect.ts | 54 +++++++++++ 2 files changed, 149 insertions(+) create mode 100644 packages/create-plugin/src/codemods/agentic/detect.test.ts create mode 100644 packages/create-plugin/src/codemods/agentic/detect.ts diff --git a/packages/create-plugin/src/codemods/agentic/detect.test.ts b/packages/create-plugin/src/codemods/agentic/detect.test.ts new file mode 100644 index 0000000000..f4e7040938 --- /dev/null +++ b/packages/create-plugin/src/codemods/agentic/detect.test.ts @@ -0,0 +1,95 @@ +import which from 'which'; +import { access } from 'node:fs/promises'; +import { detectInstalledAgents, isInsideAgent } from './detect.js'; +import { AgentDefinition } from './types.js'; + +vi.mock('which'); +vi.mock('node:fs/promises'); + +const whichMock = vi.mocked(which); +const accessMock = vi.mocked(access); + +function createDefinition(overrides: Partial): AgentDefinition { + return { + id: 'claude-code', + displayName: 'Claude Code', + binaryNames: ['claude'], + wellKnownPaths: [], + buildInteractive: () => ({ args: [] }), + ...overrides, + }; +} + +describe('detectInstalledAgents', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should detect an agent found on the PATH', async () => { + whichMock.mockResolvedValue('/usr/local/bin/claude'); + + const installed = await detectInstalledAgents([createDefinition({})]); + + expect(installed).toHaveLength(1); + expect(installed[0].binaryPath).toBe('/usr/local/bin/claude'); + expect(whichMock).toHaveBeenCalledWith('claude', { nothrow: true }); + }); + + it('should fall back to well-known paths when the binary is not on the PATH', async () => { + // @ts-expect-error - the nothrow overload returns string | null which the mocked union cannot infer + whichMock.mockResolvedValue(null); + accessMock.mockResolvedValue(undefined); + + const definition = createDefinition({ wellKnownPaths: ['/home/user/.claude/local/claude'] }); + const installed = await detectInstalledAgents([definition]); + + expect(installed).toHaveLength(1); + expect(installed[0].binaryPath).toBe('/home/user/.claude/local/claude'); + }); + + it('should return an empty list when nothing is installed', async () => { + // @ts-expect-error - the nothrow overload returns string | null which the mocked union cannot infer + whichMock.mockResolvedValue(null); + accessMock.mockRejectedValue(new Error('ENOENT')); + + const definition = createDefinition({ wellKnownPaths: ['/home/user/.claude/local/claude'] }); + const installed = await detectInstalledAgents([definition]); + + expect(installed).toEqual([]); + }); + + it('should preserve definition order even when probes resolve out of order', async () => { + whichMock.mockImplementation((binaryName) => { + if (binaryName === 'claude') { + return new Promise((resolve) => { + setTimeout(() => resolve('/usr/local/bin/claude'), 20); + }); + } + return Promise.resolve('/usr/local/bin/codex'); + }); + + const installed = await detectInstalledAgents([ + createDefinition({}), + createDefinition({ id: 'codex', displayName: 'OpenAI Codex', binaryNames: ['codex'] }), + ]); + + expect(installed.map((agent) => agent.definition.id)).toEqual(['claude-code', 'codex']); + }); +}); + +describe('isInsideAgent', () => { + it.each(['CLAUDECODE', 'CURSOR_TRACE_ID', 'OPENCODE', 'CODEX_THREAD_ID', 'GEMINI_CLI', 'VSCODE_AGENT', 'REPL_ID'])( + 'should return true when %s is set', + (envVar) => { + expect(isInsideAgent({ [envVar]: '1' })).toBe(true); + } + ); + + it('should return false when no agent environment variables are set', () => { + expect(isInsideAgent({ PATH: '/usr/bin', TERM: 'xterm-256color' })).toBe(false); + }); + + it('should return false when an agent environment variable is set but empty', () => { + expect(isInsideAgent({ CLAUDECODE: '' })).toBe(false); + }); +}); diff --git a/packages/create-plugin/src/codemods/agentic/detect.ts b/packages/create-plugin/src/codemods/agentic/detect.ts new file mode 100644 index 0000000000..4048cec115 --- /dev/null +++ b/packages/create-plugin/src/codemods/agentic/detect.ts @@ -0,0 +1,54 @@ +import which from 'which'; +import { constants } from 'node:fs'; +import { access } from 'node:fs/promises'; +import { AGENT_DEFINITIONS } from './definitions.js'; +import { AgentDefinition, InstalledAgent } from './types.js'; + +// Environment variables set by AI agent CLIs/IDEs when they run a shell command. +// Used to avoid spawning an agent from inside another agent. +const AGENT_ENV_VARS = [ + 'CLAUDECODE', + 'CURSOR_TRACE_ID', + 'OPENCODE', + 'CODEX_THREAD_ID', + 'GEMINI_CLI', + 'VSCODE_AGENT', + 'REPL_ID', +]; + +export async function detectInstalledAgents( + definitions: AgentDefinition[] = AGENT_DEFINITIONS +): Promise { + const probes = await Promise.all(definitions.map(detectAgent)); + return probes.filter((agent): agent is InstalledAgent => agent !== null); +} + +export function isInsideAgent(env: NodeJS.ProcessEnv = process.env): boolean { + return AGENT_ENV_VARS.some((envVar) => Boolean(env[envVar])); +} + +async function detectAgent(definition: AgentDefinition): Promise { + for (const binaryName of definition.binaryNames) { + const binaryPath = await which(binaryName, { nothrow: true }); + if (binaryPath) { + return { definition, binaryPath }; + } + } + + for (const wellKnownPath of definition.wellKnownPaths) { + if (await isExecutable(wellKnownPath)) { + return { definition, binaryPath: wellKnownPath }; + } + } + + return null; +} + +async function isExecutable(filePath: string): Promise { + try { + await access(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} From 50c5b699458e4c7da7359cb414b6ed494342901c Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 13 Jul 2026 10:27:48 +0200 Subject: [PATCH 05/16] feat(create-plugin): add agent handoff contract and run dir lifecycle The handoff JSON file is the completion signal for interactive agent sessions: ephemeral single-run IPC under node_modules/.cache, wiped at the start of every run, parsed with a strict valibot schema. --- .../src/codemods/agentic/handoff.test.ts | 102 ++++++++++++++++++ .../src/codemods/agentic/handoff.ts | 45 ++++++++ 2 files changed, 147 insertions(+) create mode 100644 packages/create-plugin/src/codemods/agentic/handoff.test.ts create mode 100644 packages/create-plugin/src/codemods/agentic/handoff.ts diff --git a/packages/create-plugin/src/codemods/agentic/handoff.test.ts b/packages/create-plugin/src/codemods/agentic/handoff.test.ts new file mode 100644 index 0000000000..9a0fe20ef3 --- /dev/null +++ b/packages/create-plugin/src/codemods/agentic/handoff.test.ts @@ -0,0 +1,102 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createRunId, + ensureRunDir, + getHandoffPath, + getRunDir, + MIGRATE_RUNS_DIR, + parseHandoff, + wipeMigrateRuns, +} from './handoff.js'; + +describe('run dir lifecycle', () => { + let basePath: string; + + beforeEach(() => { + basePath = mkdtempSync(join(tmpdir(), 'cp-handoff-test-')); + }); + + afterEach(() => { + rmSync(basePath, { recursive: true, force: true }); + }); + + it('should build run dirs under the migrate-runs cache dir', () => { + expect(getRunDir(basePath, 'run-1')).toBe(join(basePath, MIGRATE_RUNS_DIR, 'run-1')); + }); + + it('should build handoff paths from the migration name', () => { + const runDir = getRunDir(basePath, 'run-1'); + expect(getHandoffPath(runDir, '013-example')).toBe(join(runDir, '013-example.json')); + }); + + it('should create run dirs and tolerate them already existing', () => { + const runDir = getRunDir(basePath, 'run-1'); + ensureRunDir(runDir); + expect(existsSync(runDir)).toBe(true); + expect(() => ensureRunDir(runDir)).not.toThrow(); + }); + + it('should wipe all previous runs', () => { + const runDir = getRunDir(basePath, 'run-1'); + ensureRunDir(runDir); + writeFileSync(getHandoffPath(runDir, '013-example'), '{}'); + + wipeMigrateRuns(basePath); + + expect(existsSync(join(basePath, MIGRATE_RUNS_DIR))).toBe(false); + }); + + it('should not throw when wiping a base path with no runs', () => { + expect(() => wipeMigrateRuns(basePath)).not.toThrow(); + }); +}); + +describe('createRunId', () => { + it('should create ids that are safe to use as directory names', () => { + expect(createRunId()).toMatch(/^[a-z0-9-]+$/); + }); + + it('should create unique ids', () => { + expect(createRunId()).not.toBe(createRunId()); + }); +}); + +describe('parseHandoff', () => { + it('should parse a success handoff', () => { + expect(parseHandoff('{"status":"success","summary":"done"}')).toEqual({ status: 'success', summary: 'done' }); + }); + + it('should parse a failed handoff', () => { + expect(parseHandoff('{"status":"failed","summary":"could not migrate"}')).toEqual({ + status: 'failed', + summary: 'could not migrate', + }); + }); + + it('should drop unknown keys', () => { + expect(parseHandoff('{"status":"success","summary":"done","extras":{"foo":1}}')).toEqual({ + status: 'success', + summary: 'done', + }); + }); + + it('should return undefined for malformed JSON', () => { + expect(parseHandoff('{"status": "succ')).toBeUndefined(); + }); + + it('should return undefined for unexpected status values', () => { + expect(parseHandoff('{"status":"maybe","summary":"done"}')).toBeUndefined(); + }); + + it('should return undefined when summary is missing', () => { + expect(parseHandoff('{"status":"success"}')).toBeUndefined(); + }); + + it('should not be vulnerable to prototype pollution', () => { + const handoff = parseHandoff('{"status":"success","summary":"done","__proto__":{"polluted":true}}'); + expect(handoff).toEqual({ status: 'success', summary: 'done' }); + expect('polluted' in {}).toBe(false); + }); +}); diff --git a/packages/create-plugin/src/codemods/agentic/handoff.ts b/packages/create-plugin/src/codemods/agentic/handoff.ts new file mode 100644 index 0000000000..058ab9e6a9 --- /dev/null +++ b/packages/create-plugin/src/codemods/agentic/handoff.ts @@ -0,0 +1,45 @@ +import { randomBytes } from 'node:crypto'; +import { mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import * as v from 'valibot'; +import { Handoff } from './types.js'; + +// The handoff file is ephemeral single-run IPC between the spawned agent and create-plugin: +// the agent writes it as its final action, the runner polls and consumes it immediately. +// It lives under node_modules so it is gitignored in every scaffold and never needs to persist. +export const MIGRATE_RUNS_DIR = join('node_modules', '.cache', 'grafana-create-plugin', 'migrate-runs'); + +const handoffSchema = v.object({ + status: v.picklist(['success', 'failed']), + summary: v.string(), +}); + +export function createRunId(): string { + return `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`; +} + +export function getRunDir(basePath: string, runId: string): string { + return join(basePath, MIGRATE_RUNS_DIR, runId); +} + +export function getHandoffPath(runDir: string, migrationName: string): string { + return join(runDir, `${migrationName}.json`); +} + +// re-run before every agent session; a package manager install may have pruned the cache dir +export function ensureRunDir(runDir: string): void { + mkdirSync(runDir, { recursive: true }); +} + +export function wipeMigrateRuns(basePath: string): void { + rmSync(join(basePath, MIGRATE_RUNS_DIR), { recursive: true, force: true }); +} + +export function parseHandoff(raw: string): Handoff | undefined { + try { + const parsed = v.parse(handoffSchema, JSON.parse(raw)); + return { status: parsed.status, summary: parsed.summary }; + } catch { + return undefined; + } +} From 088d6553b7ea5ee58ce96545a998ee26e4411aa2 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 13 Jul 2026 10:29:44 +0200 Subject: [PATCH 06/16] feat(create-plugin): add agentic prompt builders XML-sectioned system and user prompts with escaping of all file-derived content, a git-or-file-list hybrid change context (capped at 50 entries), an inside-agent directive block for deferred steps, and the manual next-steps list. --- .../src/codemods/agentic/prompts.test.ts | 186 ++++++++++++++++++ .../src/codemods/agentic/prompts.ts | 159 +++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 packages/create-plugin/src/codemods/agentic/prompts.test.ts create mode 100644 packages/create-plugin/src/codemods/agentic/prompts.ts diff --git a/packages/create-plugin/src/codemods/agentic/prompts.test.ts b/packages/create-plugin/src/codemods/agentic/prompts.test.ts new file mode 100644 index 0000000000..b2f357ee78 --- /dev/null +++ b/packages/create-plugin/src/codemods/agentic/prompts.test.ts @@ -0,0 +1,186 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + buildDirectiveBlock, + buildHybridUserPrompt, + buildNextStepsList, + buildPromptOnlyUserPrompt, + buildSystemPrompt, + escapeXmlBody, + getPromptPath, + loadPromptInstructions, +} from './prompts.js'; + +const migrationMeta = { + name: '013-example', + version: '8.0.0', + description: 'Port custom webpack overrides to rspack.', +}; + +const systemPromptOptions = { + workspaceRoot: '/virtual/workspace', + packageManagerName: 'npm', + packageManagerVersion: '11.0.0', + installCmd: 'npm install --silent', + execCmd: 'npx -y', + handoffPath: '/virtual/workspace/node_modules/.cache/grafana-create-plugin/migrate-runs/run-1/013-example.json', +}; + +describe('escapeXmlBody', () => { + it('should escape ampersands and angle brackets', () => { + expect(escapeXmlBody('use