Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docusaurus/docs/how-to-guides/updating-a-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ keywords:
---

import UpdateNPM from '@shared/createplugin-update.md';
import UpdateNPMAgent from '@shared/createplugin-update-agent.md';
import UpdateNPMCommit from '@shared/createplugin-update-commit.md';
import UpdateNPMForce from '@shared/createplugin-update-force.md';

Expand Down Expand Up @@ -50,6 +51,18 @@ The force flag can be used to bypass all safety checks related to uncommitted ch

<UpdateNPMForce />

#### `--agent`

Some migrations include instructions for an AI coding agent instead of, or in addition to, an automated code transform. The agent flag controls whether an agent CLI installed on your machine runs those instructions. Pass `--agent=<id>` to use a specific agent, `--agent` to use whichever supported agent is installed, or `--no-agent` to skip agent steps entirely. Supported agents: `claude-code`, `codex`.

<UpdateNPMAgent />

:::note

Always pass the flag with an equals sign (`--agent=claude-code`) and after the `update` command. `create-plugin --agent update` is parsed as a value assignment and runs the wrong command.

:::

### What happens when you update

The update command applies a series of changes, known as migrations, to your plugin to align it with the latest `create-plugin` standards.
Expand All @@ -64,10 +77,26 @@ As each migration runs, its name and description are output to the terminal, alo

If you pass the `--commit` flag, after each migration finishes it adds a Git commit to the current branch with the name of the migration.

### AI-assisted migrations

Some changes can't be applied reliably by an automated code transform, for example porting user-authored configuration or refactoring plugin source code. Migrations for such changes ship natural-language instructions that an AI coding agent can apply, either on their own (prompt migrations) or as a follow-up to an automated transform (hybrid migrations).

When the update includes such a migration and a supported agent CLI is installed, `create-plugin` asks whether to run it (or use the [`--agent`](#--agent) flag to decide up front). If you opt in:

- The agent runs in an interactive session: you can watch it work and redirect it at any point.
- Each agent step ends automatically when the agent reports its result. If the update runs without `--commit`, per-migration commits are enabled so each agent step sees an isolated diff; pass `--no-commit` to prevent this.
- If an agent reports failure or its session ends unexpectedly, the update stops (or asks you how to proceed) without recording the update as complete.

If you decline, pass `--no-agent`, or have no supported agent installed, the automated portions of the update still run. The instructions of any skipped agent steps are printed at the end of the update as manual next steps — the update is not blocked by them.

If you run `create-plugin update` from inside an AI agent session (for example, asking Claude Code to update your plugin), `create-plugin` never starts a second agent. Instead it prints the deferred instructions in a machine-readable block for the agent driving your session to complete.

## Automate updates via CI

To make it even easier to keep your plugin up to date, use the provided GitHub workflow that runs the update command and automatically opens a PR if there are any changes. Follow [these steps](/set-up/set-up-github#the-create-plugin-update-workflow) to enable it in your repository.

In CI and other non-interactive environments, AI-assisted migrations are always skipped: automated transforms run as normal, and the instructions of any skipped agent steps are printed to the job log as manual next steps. Passing `--agent` in CI prints a warning and has no other effect.

## Automate dependency updates

`create-plugin` will only update dependencies if they are required for other changes to function correctly. Besides running the update command regularly, use [dependabot](https://docs.github.com/en/code-security/getting-started/dependabot-quickstart-guide) or [renovatebot](https://docs.renovatebot.com/) to keep all dependencies up to date.
Expand Down
7 changes: 7 additions & 0 deletions docusaurus/docs/reference/cli-commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ sidebar_position: 40

import ScaffoldNPM from '@shared/createplugin-scaffold.md';
import UpdateNPM from '@shared/createplugin-update.md';
import UpdateAgentNPM from '@shared/createplugin-update-agent.md';
import UpdateForceNPM from '@shared/createplugin-update-force.md';

# CLI commands
Expand Down Expand Up @@ -107,3 +108,9 @@ By default, the `update` command will stop if there are any uncommitted changes

<UpdateForceNPM />

### update --agent

Some migrations ship AI-agent instructions for changes an automated code transform can't apply. Use `--agent=<id>` to run them with a specific installed agent CLI (`claude-code` or `codex`), bare `--agent` to use whichever supported agent is installed, or `--no-agent` to always skip them. Without the flag, `update` asks when such a migration is queued and a supported agent is installed. Skipped agent steps are printed as manual next steps. See [AI-assisted migrations](/how-to-guides/updating-a-plugin#ai-assisted-migrations) for details.

<UpdateAgentNPM />

3 changes: 3 additions & 0 deletions docusaurus/docs/shared/createplugin-update-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```shell npm2yarn
npx @grafana/create-plugin@latest update --agent=claude-code
```
20 changes: 20 additions & 0 deletions packages/create-plugin/src/codemods/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

This guide provides specific instructions for working with migrations and additions in the create-plugin package.

## Migration shapes

A migration registry entry in @./migrations/migrations.ts takes one of three shapes:

- **Script migration** — `scriptPath` only. A codemod applies the change automatically.
- **Prompt migration** — `prompt` only. A Markdown instructions file that an installed AI agent (or the user, manually) applies. Use when the change touches user-authored code or configuration that a codemod cannot transform reliably.
- **Hybrid migration** — `scriptPath` and `prompt`. The codemod applies the deterministic part first; the agent finishes the parts that depend on user code.

Prefer a script migration whenever the change is mechanically expressible. Reach for a prompt only when judgment over user-authored code is unavoidable.

## Agent Behavior

- Refer to current migrations and additions typescript files found in @./additions/scripts and @./migrations/scripts
Expand All @@ -23,3 +33,13 @@ This guide provides specific instructions for working with migrations and additi
- `NNN-migration-title.ts` - main migration logic
- `NNN-migration-title.test.ts` - migration logic tests
- Each migration should export a default function named "migrate"

## Prompt authoring rules

Prompt files live under @./migrations/prompts as `NNN-migration-title.md` (same `NNN` as the script for hybrid migrations) and are registered with `prompt: import.meta.resolve('./prompts/NNN-migration-title.md')`. See @./migrations/prompts/000-example.md for a template. Rules:

- **Standalone**: when no agent is available the file is surfaced to the user as a manual next step, so it must read as complete manual instructions with no agent-specific context.
- **Idempotency check first**: deferred prompt migrations are version-stamped past, so the instructions may be followed on a project where the work is already done. Start with how to detect that nothing needs doing.
- **No handoff or completion mechanics**: the agent handoff contract is injected by the system prompt at run time and does not exist on the manual path.
- **Bounded scope**: include a "Verify" section (what command proves it worked) and an "Out of scope" section (at minimum: never modify `.config/`).
- The registry test in @./migrations/migrations.test.ts asserts every registered `prompt` file exists; prompt files ship verbatim to dist via the root rollup config.
50 changes: 50 additions & 0 deletions packages/create-plugin/src/codemods/agentic/definitions.test.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
});
29 changes: 29 additions & 0 deletions packages/create-plugin/src/codemods/agentic/definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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'],
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'],
buildInteractive: (invocationContext) => ({
args: ['-c', `developer_instructions=${invocationContext.systemPrompt}`, invocationContext.userPrompt],
}),
},
];
77 changes: 77 additions & 0 deletions packages/create-plugin/src/codemods/agentic/detect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import which from 'which';
import { detectInstalledAgents, isInsideAgent } from './detect.js';
import { AgentDefinition } from './types.js';

vi.mock('which');

const whichMock = vi.mocked(which);

function createDefinition(overrides: Partial<AgentDefinition>): AgentDefinition {
return {
id: 'claude-code',
displayName: 'Claude Code',
binaryNames: ['claude'],
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 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);

const installed = await detectInstalledAgents([createDefinition({})]);

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);
});
});
37 changes: 37 additions & 0 deletions packages/create-plugin/src/codemods/agentic/detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import which from 'which';
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<InstalledAgent[]> {
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<InstalledAgent | null> {
for (const binaryName of definition.binaryNames) {
const binaryPath = await which(binaryName, { nothrow: true });
if (binaryPath) {
return { definition, binaryPath };
}
}

return null;
}
Loading
Loading