diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index e7787fcf2a..7362cf8efa 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -11,4 +11,14 @@ export default [ description: 'Externalizes the react JSX runtime to help migrate plugins to React 19', scriptPath: import.meta.resolve('./scripts/externalize-jsx-runtime.js'), }, + { + name: 'datasource-docs', + description: 'Scaffolds multi-page documentation for a Grafana datasource plugin', + scriptPath: import.meta.resolve('./scripts/datasource-docs/index.js'), + }, + { + name: 'panel-docs', + description: 'Scaffolds multi-page documentation for a Grafana panel plugin', + scriptPath: import.meta.resolve('./scripts/panel-docs/index.js'), + }, ] satisfies Codemod[]; diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts new file mode 100644 index 0000000000..c7e5e489f7 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.test.ts @@ -0,0 +1,416 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Context } from '../../../context.js'; +import { + assertAgentLoop, + assertPluginType, + type ConditionalFilePredicate, + setupDocsScaffolding, + sourceContainsVariableSupport, + sourceIsSqlDatasource, +} from './setup.js'; + +// capture the real existsSync before mocking so we can delegate to it in beforeEach +const { existsSync: realExistsSync } = await vi.importActual('node:fs'); + +vi.mock('node:fs', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + existsSync: vi.fn().mockImplementation(mod.existsSync), + }; +}); + +interface MakeContextOptions { + pluginJson?: Record; + basePath?: string; +} + +function makeContext(opts: MakeContextOptions = {}): Context { + const { pluginJson = { type: 'datasource', name: 'My Plugin' }, basePath = '/virtual' } = opts; + const context = new Context(basePath); + context.addFile('src/plugin.json', JSON.stringify(pluginJson)); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); + return context; +} + +describe('_docs-shared/setup', () => { + const tempDirs: string[] = []; + + function makeTempPluginDir(srcFiles: Record = {}): string { + const dir = mkdtempSync(join(tmpdir(), 'docs-shared-test-')); + tempDirs.push(dir); + if (Object.keys(srcFiles).length > 0) { + mkdirSync(join(dir, 'src'), { recursive: true }); + for (const [relPath, content] of Object.entries(srcFiles)) { + const target = join(dir, 'src', relPath); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, content); + } + } + return dir; + } + + // build a synthetic templateBaseUrl folder with the given file contents + function makeTemplateBaseUrl(files: Record): URL { + const dir = mkdtempSync(join(tmpdir(), 'docs-templates-')); + tempDirs.push(dir); + mkdirSync(join(dir, 'docs'), { recursive: true }); + mkdirSync(join(dir, 'templates', 'workflows'), { recursive: true }); + // The shared module reads workflows/validate-docs.yml relative to its own location - + // we can't override that from the outside, so this helper is only for /docs templates. + for (const [relPath, content] of Object.entries(files)) { + const target = join(dir, 'docs', relPath); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, content); + } + return pathToFileURL(`${dir}/`); + } + + beforeEach(() => { + vi.mocked(existsSync).mockImplementation(realExistsSync); + }); + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + describe('setupDocsScaffolding', () => { + function call( + context: Context, + overrides: { + templates?: Record; + conditionalFiles?: Record; + docsPath?: string; + } = {} + ): void { + const templates = overrides.templates ?? { 'index.md': '# {{pluginName}}\n' }; + setupDocsScaffolding({ + context, + docsPath: overrides.docsPath ?? 'docs', + templateBaseUrl: makeTemplateBaseUrl(templates), + codemodName: 'test-docs', + conditionalFiles: overrides.conditionalFiles, + }); + } + + describe('early exit', () => { + it('throws if docs directory already exists on disk', () => { + vi.mocked(existsSync).mockReturnValueOnce(true); + const context = makeContext(); + expect(() => call(context)).toThrow("A directory already exists at 'docs'"); + }); + }); + + describe('plugin.json step', () => { + it('adds docsPath to src/plugin.json', () => { + const context = makeContext(); + call(context); + const parsed = JSON.parse(context.getFile('src/plugin.json') ?? '{}'); + expect(parsed.docsPath).toBe('docs'); + }); + + it('throws if src/plugin.json is missing', () => { + const context = new Context('/virtual'); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + expect(() => call(context)).toThrow('Cannot find src/plugin.json'); + }); + + it('skips docsPath update when already set to a different value', () => { + const context = makeContext({ + pluginJson: { type: 'datasource', name: 'My Plugin', docsPath: 'custom-docs' }, + }); + call(context); + const parsed = JSON.parse(context.getFile('src/plugin.json') ?? '{}'); + expect(parsed.docsPath).toBe('custom-docs'); + }); + + it('uses a custom docsPath when specified', () => { + const context = makeContext(); + call(context, { docsPath: 'my-docs' }); + expect(context.doesFileExist('my-docs/index.md')).toBe(true); + }); + }); + + describe('devDependency and scripts', () => { + it('adds @grafana/plugin-docs-cli to devDependencies', () => { + const context = makeContext(); + call(context); + const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); + expect(pkg.devDependencies?.['@grafana/plugin-docs-cli']).toBe('^0.0.10'); + }); + + it('adds docs:serve and docs:validate scripts', () => { + const context = makeContext(); + call(context); + const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); + expect(pkg.scripts?.['docs:serve']).toBe('plugin-docs-cli serve --port 3001 --reload'); + expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate --strict'); + }); + + it('skips docs:serve if already present', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); + context.addFile( + 'package.json', + JSON.stringify({ scripts: { 'docs:serve': 'custom-command' }, devDependencies: {} }) + ); + context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n'); + call(context); + const pkg = JSON.parse(context.getFile('package.json') ?? '{}'); + expect(pkg.scripts?.['docs:serve']).toBe('custom-command'); + expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate --strict'); + }); + }); + + describe('template copy', () => { + it('copies every template file under docs/', () => { + const context = makeContext(); + call(context, { + templates: { + 'index.md': '# Overview\n', + 'configuration.md': '## Configure\n', + 'nested/deep.md': 'nested\n', + }, + }); + expect(context.doesFileExist('docs/index.md')).toBe(true); + expect(context.doesFileExist('docs/configuration.md')).toBe(true); + expect(context.doesFileExist('docs/nested/deep.md')).toBe(true); + }); + + it('interpolates {{pluginName}} in template content', () => { + const context = makeContext(); + call(context, { templates: { 'index.md': '# {{pluginName}}\n' } }); + expect(context.getFile('docs/index.md')).toContain('My Plugin'); + expect(context.getFile('docs/index.md')).not.toContain('{{pluginName}}'); + }); + + it('skips a file already present in the context', () => { + const context = makeContext(); + context.addFile('docs/index.md', '# Existing\n'); + call(context, { templates: { 'index.md': '# Replacement\n' } }); + expect(context.getFile('docs/index.md')).toBe('# Existing\n'); + }); + + it('honors a conditional file predicate when it returns true', () => { + const context = makeContext({ pluginJson: { type: 'datasource', name: 'X', annotations: true } }); + call(context, { + templates: { 'annotations.md': '# {{pluginName}}\n' }, + conditionalFiles: { 'annotations.md': ({ pluginJson }) => pluginJson.annotations === true }, + }); + expect(context.doesFileExist('docs/annotations.md')).toBe(true); + }); + + it('honors a conditional file predicate when it returns false', () => { + const context = makeContext(); + call(context, { + templates: { 'annotations.md': '# {{pluginName}}\n' }, + conditionalFiles: { 'annotations.md': ({ pluginJson }) => pluginJson.annotations === true }, + }); + expect(context.doesFileExist('docs/annotations.md')).toBe(false); + }); + }); + + describe('validate-docs workflow', () => { + it('creates .github/workflows/validate-docs.yml', () => { + const context = makeContext(); + call(context); + expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true); + expect(context.getFile('.github/workflows/validate-docs.yml')).toContain('plugin-docs-cli validate --strict'); + }); + + it('overwrites an existing .github/workflows/validate-docs.yml', () => { + const context = makeContext(); + context.addFile('.github/workflows/validate-docs.yml', 'old content'); + call(context); + expect(context.getFile('.github/workflows/validate-docs.yml')).not.toBe('old content'); + }); + }); + + describe('release.yml build-plugin bump', () => { + it('bumps build-plugin ref in release.yml', () => { + const context = makeContext(); + call(context); + const content = context.getFile('.github/workflows/release.yml') ?? ''; + expect(content).toContain('grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step'); + }); + + it('handles multiple build-plugin refs', () => { + const context = makeContext(); + context.updateFile( + '.github/workflows/release.yml', + 'uses: grafana/plugin-actions/build-plugin@v1.0.0\nuses: grafana/plugin-actions/build-plugin@v2.0.0\n' + ); + call(context); + expect(context.getFile('.github/workflows/release.yml')).toBe( + 'uses: grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step\nuses: grafana/plugin-actions/build-plugin@eriksundell/plugin-docs-build-step\n' + ); + }); + + it('skips when release.yml has no build-plugin reference', () => { + const context = makeContext(); + context.updateFile('.github/workflows/release.yml', 'name: Release\n'); + call(context); + expect(context.getFile('.github/workflows/release.yml')).toBe('name: Release\n'); + }); + + it('skips when release.yml does not exist', () => { + const context = new Context('/virtual'); + context.addFile('src/plugin.json', JSON.stringify({ type: 'datasource', name: 'My Plugin' })); + context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} })); + expect(() => call(context)).not.toThrow(); + }); + }); + }); + + describe('assertAgentLoop', () => { + it('throws a friendly message when loop is undefined', () => { + expect(() => assertAgentLoop(undefined)).toThrow( + /Missing required flag: --agent-loop[\s\S]*--agent-loop=claude[\s\S]*--agent-loop=none/ + ); + }); + + it.each(['claude', 'codex', 'cursor', 'none'] as const)('accepts %s as a valid loop', (loop) => { + expect(() => assertAgentLoop(loop)).not.toThrow(); + }); + }); + + describe('assertPluginType', () => { + it('returns the parsed plugin.json when the type matches', () => { + const context = makeContext({ pluginJson: { type: 'datasource', name: 'X' } }); + const parsed = assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' }); + expect(parsed.name).toBe('X'); + }); + + it('throws when the type does not match', () => { + const context = makeContext({ pluginJson: { type: 'panel', name: 'X' } }); + expect(() => assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' })).toThrow( + /only works on 'datasource' plugins.*type is 'panel'/ + ); + }); + + it('points the user at the sibling codemod in the error message', () => { + const context = makeContext({ pluginJson: { type: 'panel', name: 'X' } }); + expect(() => assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' })).toThrow( + /create-plugin add panel-docs/ + ); + }); + + it('throws when plugin.json is missing', () => { + const context = new Context('/virtual'); + expect(() => assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' })).toThrow( + 'Cannot find src/plugin.json' + ); + }); + + it('throws when type is unset', () => { + const context = makeContext({ pluginJson: { name: 'X' } }); + expect(() => assertPluginType(context, { expectedType: 'datasource', codemodName: 'datasource-docs' })).toThrow( + /type is 'unset'/ + ); + }); + }); + + describe('sourceContainsVariableSupport', () => { + it.each([ + ['metricFindQuery', 'export function metricFindQuery(q: string) { return []; }\n'], + [ + 'CustomVariableSupport', + 'import { CustomVariableSupport } from "@grafana/data"; class V extends CustomVariableSupport {}\n', + ], + [ + 'StandardVariableSupport', + 'import { StandardVariableSupport } from "@grafana/data"; class V extends StandardVariableSupport {}\n', + ], + [ + 'DataSourceVariableSupport', + 'import { DataSourceVariableSupport } from "@grafana/data"; class V extends DataSourceVariableSupport {}\n', + ], + ])('returns true when src contains %s', (_, source) => { + const basePath = makeTempPluginDir({ 'datasource.ts': source }); + expect(sourceContainsVariableSupport(basePath)).toBe(true); + }); + + it('finds tokens in nested directories', () => { + const basePath = makeTempPluginDir({ + 'nested/deep/queries.ts': 'export const x = (ds: any) => ds.metricFindQuery("foo");\n', + }); + expect(sourceContainsVariableSupport(basePath)).toBe(true); + }); + + it('returns false when no token is found', () => { + const basePath = makeTempPluginDir({ 'datasource.ts': 'export function query(q: string) { return []; }\n' }); + expect(sourceContainsVariableSupport(basePath)).toBe(false); + }); + + it('returns false when src directory does not exist', () => { + const basePath = makeTempPluginDir(); + expect(sourceContainsVariableSupport(basePath)).toBe(false); + }); + }); + + describe('sourceIsSqlDatasource', () => { + function writeFile(basePath: string, relPath: string, content: string): void { + const target = join(basePath, relPath); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, content); + } + + it.each([ + ['Go sqlds versioned import', 'datasource.go', 'import "github.com/grafana/sqlds/v3"\n'], + ['Go sqlds unversioned import', 'datasource.go', 'import "github.com/grafana/sqlds"\n'], + ['TS @grafana/sql double quotes', 'datasource.ts', 'import { SqlQueryEditorLazy } from "@grafana/sql";\n'], + ['TS @grafana/sql single quotes', 'datasource.ts', "import { SQLQuery } from '@grafana/sql';\n"], + ])('returns true for %s in src/', (_, file, content) => { + const basePath = makeTempPluginDir({ [file]: content }); + expect(sourceIsSqlDatasource(basePath)).toBe(true); + }); + + it('walks pkg/ in addition to src/', () => { + const basePath = mkdtempSync(join(tmpdir(), 'docs-shared-test-')); + tempDirs.push(basePath); + writeFile(basePath, 'pkg/plugin/datasource.go', 'import "github.com/grafana/sqlds/v3"\n'); + expect(sourceIsSqlDatasource(basePath)).toBe(true); + }); + + it('walks nested directories under src/', () => { + const basePath = makeTempPluginDir({ + 'backend/handler.go': 'import (\n "github.com/grafana/sqlds"\n)\n', + }); + expect(sourceIsSqlDatasource(basePath)).toBe(true); + }); + + it('returns false when sqlds is mentioned only in a comment-like string', () => { + const basePath = makeTempPluginDir({ + 'datasource.go': '// the sqlds library github.com/grafana/sqlds is not used here\nfunc main() {}\n', + }); + expect(sourceIsSqlDatasource(basePath)).toBe(false); + }); + + it('returns false when only the Go stdlib database/sql is imported', () => { + const basePath = makeTempPluginDir({ + 'datasource.go': 'import "database/sql"\n', + }); + expect(sourceIsSqlDatasource(basePath)).toBe(false); + }); + + it('returns false when no SQL signal is present', () => { + const basePath = makeTempPluginDir({ 'datasource.ts': 'export class DataSource {}\n' }); + expect(sourceIsSqlDatasource(basePath)).toBe(false); + }); + + it('returns false when neither src/ nor pkg/ exists', () => { + const basePath = makeTempPluginDir(); + expect(sourceIsSqlDatasource(basePath)).toBe(false); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.ts b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.ts new file mode 100644 index 0000000000..e593d5b20c --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/setup.ts @@ -0,0 +1,521 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Context } from '../../../context.js'; +import { additionsDebug, addDependenciesToPackageJson } from '../../../utils.js'; + +// TODO: replace with stable tag once plugin-actions PR #219 merges +const REQUIRED_BUILD_PLUGIN_REF = 'eriksundell/plugin-docs-build-step'; + +export interface PluginJsonInclude { + type?: string; + name?: string; + path?: string; + [key: string]: unknown; +} + +export interface PluginJson { + type?: string; + name?: string; + docsPath?: string; + annotations?: boolean; + alerting?: boolean; + backend?: boolean; + includes?: PluginJsonInclude[]; + [key: string]: unknown; +} + +export type ConditionalFilePredicate = (ctx: { pluginJson: PluginJson; basePath: string }) => boolean; + +// supported agent loops. `none` disables all agent-related scaffolding. +export type AgentLoop = 'claude' | 'codex' | 'cursor' | 'none'; + +// maps each non-none loop to its conventional skills directory. +const LOOP_SKILL_TARGET: Record, string> = { + claude: '.claude/skills', + codex: '.agents/skills', + cursor: '.cursor/skills', +}; + +// canonical prefixes under the codemod's agent/ template tree. Both get +// rewritten at scaffold time: +// - SKILLS_TEMPLATE_PREFIX (`.config/AGENTS/skills/`) -> the loop-specific +// skills directory (`.claude/skills/`, `.agents/skills/`, `.cursor/skills/`) +// - DOCS_TEMPLATE_PREFIX (`docs/`) -> the user's chosen `/`. Without +// this rewrite, a non-default docsPath (e.g. `docs2`) ends up with the +// agent's docs/AGENTS.md scaffolded into a stray `docs/` folder while the +// actual pages live in `docs2/`. +const SKILLS_TEMPLATE_PREFIX = '.config/AGENTS/skills/'; +const DOCS_TEMPLATE_PREFIX = 'docs/'; + +// computes the destination path for an agent template file given the chosen +// loop and the user's docsPath. Two prefixes are rewritten; anything else +// passes through unchanged. +function targetPathForLoop( + relPath: string, + agentLoop: Exclude, + docsPath: string +): string | undefined { + if (relPath.startsWith(SKILLS_TEMPLATE_PREFIX)) { + return `${LOOP_SKILL_TARGET[agentLoop]}/${relPath.slice(SKILLS_TEMPLATE_PREFIX.length)}`; + } + if (relPath.startsWith(DOCS_TEMPLATE_PREFIX)) { + return `${docsPath}/${relPath.slice(DOCS_TEMPLATE_PREFIX.length)}`; + } + return relPath; +} + +export interface DocsSetupOptions { + context: Context; + docsPath: string; + templateBaseUrl: URL; + codemodName: string; + conditionalFiles?: Record; + /** + * Which AI agent loop to scaffold support for. Controls whether docs/AGENTS.md + * and the per-loop skills are written. + * + * Defaults to `none` if omitted - in which case NO agent files are written + * (including `docs/AGENTS.md` and any skills). + * + * The `agent/` template subtree maps to the target plugin like this: + * agent/docs/AGENTS.md -> /AGENTS.md + * agent/.config/AGENTS/skills//SKILL.md -> //SKILL.md + */ + agentLoop?: AgentLoop; +} + +export function setupDocsScaffolding(opts: DocsSetupOptions): Context { + const { context, docsPath, templateBaseUrl, codemodName, conditionalFiles = {}, agentLoop = 'none' } = opts; + + // step 1: early exit if the docs directory already exists on disk + if (existsSync(join(context.basePath, docsPath))) { + throw new Error( + `A directory already exists at '${docsPath}'. Re-run with a different path:\n create-plugin add ${codemodName} --docsPath ` + ); + } + + // step 2: set docsPath in src/plugin.json + const pluginJsonContent = context.getFile('src/plugin.json'); + if (pluginJsonContent === undefined) { + throw new Error('Cannot find src/plugin.json. Run this command from the plugin root directory.'); + } + + let pluginJson: PluginJson; + try { + pluginJson = JSON.parse(pluginJsonContent); + } catch (e) { + throw new Error(`Cannot parse src/plugin.json: ${e}`); + } + + const existingDocsPath = pluginJson.docsPath; + if (existingDocsPath !== undefined && existingDocsPath !== docsPath) { + additionsDebug(`src/plugin.json already has docsPath set to '${existingDocsPath}', skipping update`); + } else { + context.updateFile('src/plugin.json', JSON.stringify({ ...pluginJson, docsPath }, null, 2)); + } + + const pluginName = pluginJson.name ?? 'my-plugin'; + + // step 3: add @grafana/plugin-docs-cli as a devDependency + addDependenciesToPackageJson(context, {}, { '@grafana/plugin-docs-cli': '^0.0.10' }); + + // step 4: add docs:serve and docs:validate npm scripts + addDocsScripts(context); + + // step 5: copy template files to docs folder (includes README.md) + copyDocsTemplates(context, templateBaseUrl, docsPath, pluginName, pluginJson, conditionalFiles); + + // step 6: append the AI-workflow section to the docs README when an agent loop is selected + if (agentLoop !== 'none') { + appendAgentSuffixToReadme(context, docsPath, pluginName); + } + + // step 7: copy validate-docs workflow from the shared templates folder (same dir as this file) + upsertFile(context, '.github/workflows/validate-docs.yml', readSharedTemplate('workflows/validate-docs.yml')); + + // step 8: bump build-plugin version in release.yml + bumpBuildPluginVersion(context); + + // step 9: optionally scaffold AI authoring assistance (AGENTS.md, skills) + let agentAssistanceAdded = false; + if (agentLoop !== 'none') { + agentAssistanceAdded = copyAgentTemplates(context, templateBaseUrl, pluginName, agentLoop, docsPath); + if (agentAssistanceAdded) { + appendMultiPageDocsSectionToInstructions(context, docsPath); + } + } + + // step 9: print next-steps summary + const readmePresent = existsSync(join(context.basePath, 'README.md')); + printNextSteps({ docsPath, agentAssistanceAdded, readmePresent, agentLoop }); + + return context; +} + +function printNextSteps(opts: { + docsPath: string; + agentAssistanceAdded: boolean; + readmePresent: boolean; + agentLoop: AgentLoop; +}): void { + const { docsPath, agentAssistanceAdded, readmePresent, agentLoop } = opts; + const lines = ['', 'Next steps:']; + // the `agentLoop !== 'none'` check narrows the type for LOOP_SKILL_TARGET below. + // At runtime `agentAssistanceAdded` already implies a non-none loop. + if (agentAssistanceAdded && agentLoop !== 'none') { + const readmeMention = readmePresent ? ' (and mine your README for content)' : ''; + lines.push( + ` - Run the \`/bootstrap-plugin-docs\` skill to generate docs for your current features${readmeMention}` + ); + lines.push(` - Skills are available under ${LOOP_SKILL_TARGET[agentLoop]}/`); + } else { + lines.push(` - Fill in the stub docs under ${docsPath}/ with your plugin's actual content`); + } + lines.push(' - Run `npm run docs:serve` to preview the docs locally'); + lines.push(' - Run `npm run docs:validate` to check for issues before pushing'); + lines.push(''); + console.log(lines.join('\n')); +} + +// throws a helpful error message when the user omits `--agent-loop`. Use this +// at the top of each codemod's entrypoint, before calling setupDocsScaffolding. +// +// Note: this is a manual check rather than a valibot schema constraint because +// valibot's `v.object` raises a generic "Invalid key: Expected X but received +// undefined" error for missing required fields that can't be customized at the +// field-schema level. Pairs with `agentLoop: v.optional(v.union(...))` in each +// codemod's schema (no default, just optional). +export function assertAgentLoop(loop: AgentLoop | undefined): asserts loop is AgentLoop { + if (loop !== undefined) { + return; + } + throw new Error( + [ + 'Missing required flag: --agent-loop', + '', + "This codemod can ship a set of AI skills that help author plugin docs and keep them aligned with Grafana's documentation standards.", + '', + 'Pick how you want the skills wired up:', + ' --agent-loop=claude install skills under .claude/skills/ (Claude Code)', + ' --agent-loop=codex install skills under .agents/skills/ (OpenAI Codex)', + ' --agent-loop=cursor install skills under .cursor/skills/ (Cursor)', + ' --agent-loop=none skip the AI skills entirely (just scaffold the docs files)', + ].join('\n') + ); +} + +// parses src/plugin.json from the context and verifies its `type` matches the +// expected value. Throws a helpful error otherwise. Returns the parsed object +// so callers don't have to reparse. +export function assertPluginType( + context: Context, + opts: { expectedType: 'datasource' | 'panel'; codemodName: string } +): PluginJson { + const raw = context.getFile('src/plugin.json'); + if (raw === undefined) { + throw new Error('Cannot find src/plugin.json. Run this command from the plugin root directory.'); + } + let parsed: PluginJson; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error(`Cannot parse src/plugin.json: ${e}`); + } + if (parsed.type !== opts.expectedType) { + const otherCommand = opts.expectedType === 'datasource' ? 'panel-docs' : 'datasource-docs'; + throw new Error( + `create-plugin add ${opts.codemodName} only works on '${opts.expectedType}' plugins, but this plugin's type is '${parsed.type ?? 'unset'}'. Try create-plugin add ${otherCommand} if this is the other plugin type.` + ); + } + return parsed; +} + +const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']; +const VARIABLE_SUPPORT_RE = + /\b(?:CustomVariableSupport|StandardVariableSupport|DataSourceVariableSupport|metricFindQuery)\b/; + +// scans the plugin's src/ tree for any of the four Grafana variable-support +// hooks (CustomVariableSupport, StandardVariableSupport, DataSourceVariableSupport +// or metricFindQuery). Returns true on the first match. Returns false if src/ +// doesn't exist. +export function sourceContainsVariableSupport(basePath: string): boolean { + const srcDir = join(basePath, 'src'); + if (!existsSync(srcDir)) { + return false; + } + return walkForMatch(srcDir); +} + +function walkForMatch(dir: string): boolean { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') { + continue; + } + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (walkForMatch(fullPath)) { + return true; + } + continue; + } + if (!SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + continue; + } + const content = readFileSync(fullPath, 'utf-8'); + if (VARIABLE_SUPPORT_RE.test(content)) { + return true; + } + } + return false; +} + +const SQL_SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.go']; +const SQL_PATTERNS = [ + // Go import of sqlds (any major version, with or without /vN suffix) + /["']github\.com\/grafana\/sqlds(\/v\d+)?["']/, + // TS/JS import of the shared SQL frontend library + /from\s+["']@grafana\/sql["']/, +]; + +// detects whether the plugin source uses one of the shared SQL libraries +// (`github.com/grafana/sqlds` on the backend or `@grafana/sql` on the +// frontend). Walks both `src/` and `pkg/` since Go backend code is sometimes +// kept under `pkg/`. Returns true on the first match. +export function sourceIsSqlDatasource(basePath: string): boolean { + for (const dir of ['src', 'pkg']) { + const root = join(basePath, dir); + if (!existsSync(root)) { + continue; + } + if (walkForSqlMatch(root)) { + return true; + } + } + return false; +} + +function walkForSqlMatch(dir: string): boolean { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') { + continue; + } + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (walkForSqlMatch(fullPath)) { + return true; + } + continue; + } + if (!SQL_SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + continue; + } + const content = readFileSync(fullPath, 'utf-8'); + if (SQL_PATTERNS.some((re) => re.test(content))) { + return true; + } + } + return false; +} + +function copyDocsTemplates( + context: Context, + templateBaseUrl: URL, + docsPath: string, + pluginName: string, + pluginJson: PluginJson, + conditionalFiles: Record +): void { + const docsTemplateDir = fileURLToPath(new URL('./docs', templateBaseUrl)); + if (!existsSync(docsTemplateDir)) { + return; + } + for (const filePath of listFilesRecursively(docsTemplateDir)) { + const relativePath = filePath.slice(docsTemplateDir.length + 1); + const targetPath = `${docsPath}/${relativePath}`; + const predicate = conditionalFiles[basename(filePath)]; + if (predicate && !predicate({ pluginJson, basePath: context.basePath })) { + additionsDebug(`${targetPath} skipped: plugin does not meet the conditions for this file`); + continue; + } + if (!context.doesFileExist(targetPath)) { + const content = readFileSync(filePath, 'utf-8').replaceAll('{{pluginName}}', pluginName); + context.addFile(targetPath, content); + } else { + additionsDebug(`${targetPath} already exists, skipping`); + } + } +} + +function listFilesRecursively(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = join(dir, entry.name); + return entry.isDirectory() ? listFilesRecursively(fullPath) : [fullPath]; + }); +} + +function upsertFile(context: Context, path: string, content: string): void { + if (context.doesFileExist(path)) { + context.updateFile(path, content); + } else { + context.addFile(path, content); + } +} + +function readSharedTemplate(relativePath: string): string { + const templatePath = fileURLToPath(new URL(`./templates/${relativePath}`, import.meta.url)); + return readFileSync(templatePath, 'utf-8'); +} + +// appends the shared AI-workflow suffix to the docs README. No-op if the +// README is missing from Context (e.g. a codemod chose not to scaffold one) +// or if the suffix is already present. +function appendAgentSuffixToReadme(context: Context, docsPath: string, pluginName: string): void { + const readmePath = `${docsPath}/README.md`; + const existing = context.getFile(readmePath); + if (existing === undefined) { + additionsDebug(`${readmePath} not found in context; skipping agent-workflow suffix`); + return; + } + const suffix = readSharedTemplate('README-suffix.md').replaceAll('{{pluginName}}', pluginName); + if (existing.includes('AI authoring assistance')) { + additionsDebug(`${readmePath} already contains the AI authoring section, skipping`); + return; + } + const trailingNewline = existing.endsWith('\n') ? '' : '\n'; + context.updateFile(readmePath, `${existing}${trailingNewline}${suffix}`); +} + +function bumpBuildPluginVersion(context: Context): void { + const releaseYmlContent = context.getFile('.github/workflows/release.yml'); + if (!releaseYmlContent) { + additionsDebug('no .github/workflows/release.yml found, skipping build-plugin version bump'); + return; + } + const updated = releaseYmlContent.replace( + /(grafana\/plugin-actions\/build-plugin@)[^\s'"]+/g, + `$1${REQUIRED_BUILD_PLUGIN_REF}` + ); + if (updated === releaseYmlContent) { + additionsDebug('no grafana/plugin-actions/build-plugin reference found in release.yml, skipping'); + return; + } + context.updateFile('.github/workflows/release.yml', updated); +} + +// scaffolds AI authoring assistance: docs/AGENTS.md plus the four skills under +// the loop-specific skills path (.claude/skills/, .agents/skills/ or +// .cursor/skills/). Skill files are stored under .config/AGENTS/skills/ in the +// codemod's internal template tree and get re-routed to the loop's +// conventional path at scaffold time. +// +// Reads from TWO template directories: +// 1. The codemod-specific `/agent/` (plugin-type-specific +// skills like bootstrap-plugin-docs, plus any per-codemod overrides). +// 2. The shared `_docs-shared/templates/agent/` (the generic AGENTS.md and +// the type-agnostic skills: write-plugin-docs, review-plugin-docs, +// validate-plugin-docs). +// +// Codemod-specific files win when both directories contain the same path, +// since we iterate the codemod-specific dir first and refuse to overwrite +// existing Context entries. +// +// returns true if at least one file was written. existing files are never +// overwritten - the user may have customized them. +function copyAgentTemplates( + context: Context, + templateBaseUrl: URL, + pluginName: string, + agentLoop: Exclude, + docsPath: string +): boolean { + const codemodAgentDir = fileURLToPath(new URL('./agent', templateBaseUrl)); + const sharedAgentDir = fileURLToPath(new URL('./templates/agent', import.meta.url)); + let wroteSomething = false; + for (const agentTemplateDir of [codemodAgentDir, sharedAgentDir]) { + if (!existsSync(agentTemplateDir)) { + continue; + } + for (const filePath of listFilesRecursively(agentTemplateDir)) { + const relPath = filePath.slice(agentTemplateDir.length + 1); + const targetPath = targetPathForLoop(relPath, agentLoop, docsPath); + if (targetPath === undefined) { + continue; + } + if (context.doesFileExist(targetPath)) { + additionsDebug(`${targetPath} already exists, skipping`); + continue; + } + const content = readFileSync(filePath, 'utf-8').replaceAll('{{pluginName}}', pluginName); + context.addFile(targetPath, content); + wroteSomething = true; + } + } + return wroteSomething; +} + +const MULTI_PAGE_DOCS_MARKER = '## Multi-page docs'; + +// appends a "Multi-page docs" section to the plugin's +// .config/AGENTS/instructions.md so agents working on src/ remember to keep +// docs in sync. idempotent - if the section is already there, does nothing. +function appendMultiPageDocsSectionToInstructions(context: Context, docsPath: string): void { + const targetPath = '.config/AGENTS/instructions.md'; + const existing = context.getFile(targetPath); + if (existing === undefined) { + additionsDebug(`${targetPath} not found; skipping multi-page docs section append`); + return; + } + if (existing.includes(MULTI_PAGE_DOCS_MARKER)) { + additionsDebug(`${targetPath} already contains a Multi-page docs section, skipping`); + return; + } + const trailingNewline = existing.endsWith('\n') ? '' : '\n'; + const section = [ + MULTI_PAGE_DOCS_MARKER, + '', + `This plugin uses multi-page docs under \`${docsPath}/\`. **Always update those pages when features change in \`src/\` (added, changed or removed).** Conventions, the feature-change checklist and the four authoring skills are in [\`${docsPath}/AGENTS.md\`](./${docsPath}/AGENTS.md).`, + '', + ].join('\n'); + context.updateFile(targetPath, `${existing}${trailingNewline}\n${section}`); +} + +function addDocsScripts(context: Context): void { + const packageJsonContent = context.getFile('package.json'); + if (!packageJsonContent) { + return; + } + const packageJson = JSON.parse(packageJsonContent) as Record; + const scripts = (packageJson['scripts'] ?? {}) as Record; + let changed = false; + + if (!scripts['docs:serve']) { + scripts['docs:serve'] = 'plugin-docs-cli serve --port 3001 --reload'; + changed = true; + } else { + additionsDebug('docs:serve already exists in package.json scripts, skipping'); + } + + if (!scripts['docs:validate']) { + scripts['docs:validate'] = 'plugin-docs-cli validate --strict'; + changed = true; + } else { + additionsDebug('docs:validate already exists in package.json scripts, skipping'); + } + + if (changed) { + context.updateFile('package.json', JSON.stringify({ ...packageJson, scripts }, null, 2)); + } +} diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/README-suffix.md b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/README-suffix.md new file mode 100644 index 0000000000..3bec88c1c1 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/README-suffix.md @@ -0,0 +1,47 @@ +## AI authoring assistance + +You scaffolded this plugin with AI agent skills. Four skills live under your agent loop's skills folder (`.claude/skills/`, `.agents/skills/` or `.cursor/skills/`, depending on the loop you picked): + +| Skill | Purpose | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bootstrap-plugin-docs` | One-shot, plugin-wide. Mines README and source files into the scaffolded stubs, detects soft features and proposes additional pages. Run this first. | +| `write-plugin-docs` | Per-page. Fills a stub page or updates an existing one. Reads the source files implied by the page title and section briefs. | +| `review-plugin-docs` | Reviews docs files for frontmatter compliance, style rules, section-brief cleanup and factual alignment with source. | +| `validate-plugin-docs` | Runs `npm run docs:validate --json`, applies category-based fixes, iterates up to 3 times. | + +The authoring conventions the skills enforce are documented in [`AGENTS.md`](./AGENTS.md). + +### Recommended workflow + +**Once, when starting:** + +``` +/bootstrap-plugin-docs +``` + +This walks through every scaffolded stub plus any README content and fills them in. Greenfield plugins (no README content) work too — the skill leans on source-code analysis and prompts you for non-source-backed context. + +**When adding a new feature later:** + +1. Code the feature first (source files, `plugin.json` edits). +2. If the feature warrants a new doc page (e.g. you added RBAC roles and want to document them): + ``` + /write-plugin-docs /.md + ``` + The skill catalogs conventional filenames in its step 4 — check there for the right name. If there is no conventional fit, pick a `kebab-case` filename and use it. +3. Update existing pages whose scope changed (e.g. you added a config field, so `configuration.md` needs an update): + ``` + /write-plugin-docs /configuration.md + ``` +4. Review the diff: + ``` + /review-plugin-docs + ``` +5. Validate before pushing: + ``` + /validate-plugin-docs + ``` + +**When updating an existing page:** + +Run `/write-plugin-docs ` directly. The skill re-reads the source files implied by the page title and updates content to match. diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/review-plugin-docs/SKILL.md b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/review-plugin-docs/SKILL.md new file mode 100644 index 0000000000..8e3ffe07d5 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/review-plugin-docs/SKILL.md @@ -0,0 +1,78 @@ +--- +name: review-plugin-docs +description: Plugin-specific review of docs files against frontmatter requirements, section-brief cleanup, style rules, and factual alignment with source files. Reports findings without auto-editing. Use before merging changes that touch docs/**. +--- + +# Review Plugin Docs + +> **Path conventions:** Throughout this skill, `/` refers to the docs folder configured in `src/plugin.json` via the `docsPath` field (default: `docs`). Read that value before following any step that touches a path inside the docs folder. + +## Usage + +``` +/review-plugin-docs [page-path] +``` + +Run from the plugin root. Without a page path, reviews every doc page changed in the current git diff. With a page path, reviews just that file. + +This skill is plugin-specific and intentionally narrow. For a heavyweight pass (link validation, Vale style linting, multi-agent technical review) use a dedicated docs review tool. + +## Steps + +1. Determine the target file set: + + ```bash + if [ -n "$TARGET" ]; then + FILES="$TARGET" + else + FILES=$(git diff --name-only HEAD / 2>/dev/null | grep '\.md$') + if [ -z "$FILES" ]; then + FILES=$(git diff --name-only --cached / 2>/dev/null | grep '\.md$') + fi + fi + ``` + + If still empty, ask the user which page to review. + +2. Read `/AGENTS.md` for the style rules and frontmatter schema. Keep them in context for the rest of the review. + +3. For each target file: + + **Frontmatter checks:** + - `title`, `description`, `sidebar_position` are all present. + - `description` is a single concise sentence (not a paragraph). + + **Section-brief cleanup:** + - No `` or `` markers remain. If any are found, flag them and propose stripping them. + + **Style rule check** (against the 13 rules in `/AGENTS.md`): + - Present tense, not future "will". + - Active voice. + - Second person "you", not first person "we/our". + - No filler adjectives (easy, simple, just, obviously). + - No exclamation marks in body text. + - Bold for UI elements; code formatting for commands/paths/values. + - Descriptive link text. + - "refer to" not "see" for links. + - Sentence case in headings. + - No `--` (double hyphens) - use `-` or `—`. + + **Factual alignment:** + - For each documented field, type or behaviour, locate it in `src/` via grep or path inspection. The page title and section briefs suggest where to look (the bootstrap-plugin-docs skill's page catalog lists conventional source-to-page mappings for the plugin type). Flag anything invented. + + **Forbidden content:** + - No MDX or React components. + - No references to one-click installation. + - No internal-only URLs (anything on `*.grafana-ops.net`, `*.staging.*` etc.). + - No invented example data unless clearly labelled as illustrative. + +4. Report findings in this format, grouped by file: + + ``` + [/configuration.md] + - Line 12: STYLE - "users will see" should be "users see" (present tense). + - Line 34: FACTUAL - documented field `region` not found in src/components/ConfigEditor.tsx. Verify or remove. + - Line 88: BRIEF - leftover section-brief block. Strip lines 86-90. + ``` + +5. Do not auto-edit. Surface every finding in chat. If the user says "apply them", proceed page by page. After applying, run `npm run docs:validate -- --json` and report any remaining issues. diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/validate-plugin-docs/SKILL.md b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/validate-plugin-docs/SKILL.md new file mode 100644 index 0000000000..f7eb4d2fef --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/validate-plugin-docs/SKILL.md @@ -0,0 +1,70 @@ +--- +name: validate-plugin-docs +description: Runs `npm run docs:validate --json` and applies category-based auto-fixes to plugin docs files, iterating up to 3 times. Use after authoring or editing docs to clean up structural validation errors before pushing. +--- + +# Validate Plugin Docs + +> **Path conventions:** Throughout this skill, `/` refers to the docs folder configured in `src/plugin.json` via the `docsPath` field (default: `docs`). Read that value before following any step that touches a path inside the docs folder. + +## Usage + +``` +/validate-plugin-docs +``` + +Run from the plugin root. Loops validate → fix → validate until clean or stuck. + +## Steps + +1. Run the validator with JSON output: + + ```bash + npm run docs:validate -- --json + ``` + + The output is a single JSON document of shape: + + ```json + { + "valid": false, + "diagnostics": [ + { + "severity": "error", + "rule": "frontmatter-required-fields", + "file": "/x.md", + "message": "...", + "line": 3 + } + ] + } + ``` + +2. If `valid` is `true`, report success and exit. + +3. Group diagnostics by `rule`. Apply fixes per category: + + | Rule category | Fix the agent attempts | + | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | + | `frontmatter-*` | Add missing required field, correct type, remove unknown field. Required fields are `title`, `description`, `sidebar_position`. | + | `has-markdown-files` / `*-index` / `*-naming` | Create missing required page from the same scaffold conventions used by `create-plugin add datasource-docs`. Rename misplaced files. | + | `no-spaces-in-names` / `valid-file-naming` | Rename to kebab-case. | + | `no-raw-html` / `no-script-tags` | Remove offending HTML except the `` blocks (which the parser ignores). | + | `image-refs-relative` / `internal-links-relative` | Fix path. | + | `internal-links-resolve` | Suggest closest existing page; ask the user before changing semantics. | + | `referenced-images-exist` / `no-orphaned-images` | Flag for the user. Do not generate images. Do not delete referenced images. | + | `max-image-size` / `max-total-images-size` / `max-data-uri-size` | Flag for the user. Suggest re-encoding to PNG or splitting into multiple pages. Do not auto-resize. | + | `manifest-*` | Manifest is auto-generated downstream. Surface as a bug; do not patch. | + +4. After applying a round of fixes, re-run `npm run docs:validate -- --json`. + +5. Repeat steps 2-4 up to **3 iterations total**. If validation is still failing after 3 rounds, stop and report: + - Which diagnostics remain unfixed. + - Why the heuristic could not fix them. + - What the user should do next. + +6. When clean, run a final sanity check: + ```bash + npm run docs:validate + ``` + This runs in strict mode; the exit code is what CI checks. A zero exit here means the docs ship. diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/write-plugin-docs/SKILL.md b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/write-plugin-docs/SKILL.md new file mode 100644 index 0000000000..4100a38571 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/.config/AGENTS/skills/write-plugin-docs/SKILL.md @@ -0,0 +1,79 @@ +--- +name: write-plugin-docs +description: Authors or updates a single plugin docs page. Consults the source files implied by the page's section briefs and title to keep content grounded in current code, and applies the project's style rules from `/AGENTS.md`. Use when filling a stub page, adding a new page, or updating docs after a code change. +--- + +# Write Plugin Docs + +> **Path conventions:** Throughout this skill, `/` refers to the docs folder configured in `src/plugin.json` via the `docsPath` field (default: `docs`). Read that value before following any step that touches a path inside the docs folder. + +## Usage + +``` +/write-plugin-docs +``` + +Run from the plugin root. The page path can be an existing doc page (e.g. `/query-editor.md`) or a topic the user wants documented for the first time (e.g. "IAM user setup"). + +Read `/AGENTS.md` before writing. It defines the frontmatter schema, voice rules, Section-brief protocol and the common page patterns catalog. + +## Branch A: filling or updating an existing page + +1. Read the target page. Identify every ` ... ` block. Each sits under a section heading and scopes that section. + +2. Identify the source files that back the page. The page title and the section-brief text imply the right source - the bootstrap-plugin-docs skill's page catalog lists conventional source-to-page mappings for the plugin type. Use grep, file listing and the import graph to find them. + +3. Read those source files. Only document fields, behaviours and types visible in the files you read. Do not invent. + +4. Read 1-2 sibling pages in the same `/` folder for tone and structure consistency. + +5. Fill each section in place. For each section: + - Use the brief as scope guidance. + - Apply the style rules from `/AGENTS.md`. + - Use real code examples drawn from source, not invented. + - For numbered-step procedures, write a clean ordered list with one action per step. + - For example queries, use the `dashboard.json` `targets` JSON shape, not free prose. + +6. Strip every `section-brief` block after the corresponding section is written. The block format is exactly: + + ```html + + ... + + ``` + +7. Validate: + + ```bash + npm run docs:validate -- --json + ``` + + Fix any errors reported. Re-run until clean. + +8. Optional visual check: + ```bash + npm run docs:serve + ``` + +## Branch B: creating a brand-new page + +1. Check the page catalog in the `bootstrap-plugin-docs` skill. If the requested topic matches a catalogued pattern, use that filename and scope. + +2. **Check whether the new topic is tightly coupled to an existing page.** If so, restructure as a folder rather than creating a flat sibling. For example, if you are creating `service-account-setup.md` and `/configuration.md` already exists with overlapping scope, restructure both into: + - `/configuration/index.md` (the existing configuration content moves here) + - `/configuration/service-account-setup.md` (the new page) + + Confirm the restructure with the user before moving the existing file. Refer to "Group closely-coupled pages into folders" in `/AGENTS.md` for the full convention. + +3. If no catalogued pattern matches and no existing page warrants grouping, propose: + - A `kebab-case` filename under `/`. + - A `sidebar_position` value placing the page where it belongs in nav order. + + Confirm both with the user before creating the file. + +4. Create the file with the frontmatter schema from `/AGENTS.md`: + - `title`, `description`, `sidebar_position` are required. + +5. Write the content. For non-source-backed pages (external setup, prerequisites, conceptual overviews), use the user's context plus authoritative external documentation. Always link to upstream documentation for steps in third-party systems rather than mirroring them in full. For source-backed pages, follow the read-source step from Branch A before writing. + +6. Validate as in Branch A step 7. diff --git a/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/docs/AGENTS.md b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/docs/AGENTS.md new file mode 100644 index 0000000000..87031e104a --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/_docs-shared/templates/agent/docs/AGENTS.md @@ -0,0 +1,142 @@ +--- +name: plugin docs authoring guide +description: Guides how AI agents author, maintain, review and validate the multi-page docs for {{pluginName}} +--- + +# Plugin docs authoring + +> **Path conventions:** This file lives at the path configured in `src/plugin.json` via the `docsPath` field (default: `docs`). Throughout this file and the bundled skills, `/` refers to that same folder. Substitute the actual value wherever you see it. + +This folder contains the multi-page documentation for the **{{pluginName}}** plugin. Pages here are rendered by `@grafana/plugin-docs-parser` and published to `grafana.com/grafana/plugins//docs/`. + +If you are filling in stub pages, maintaining docs alongside code changes or adding new pages, read this file first. Four skills, scaffolded under your agent loop's skills folder (`.claude/skills/`, `.agents/skills/` or `.cursor/skills/`), cover the common workflows. + +## Keeping docs in sync with source + +**Whenever you add, change or remove a feature in `src/`, update the matching pages in this folder in the same change:** + +- Added feature → extend the relevant page (new configuration field, new query field, new panel option etc.). +- Changed feature → update the page text and any tables so they match the current behaviour. +- Removed feature → delete the section or page and fix any cross-references that pointed to it. + +Use `/write-plugin-docs ` for routine per-feature updates. The full skill catalog is in the [Skills](#skills) section below. + +## Frontmatter schema + +Every page starts with a YAML frontmatter block: + +```yaml +--- +title: Configuration # required - the H1 / nav label +description: Learn how to ... # required - short page meta, used for SEO and sidebar previews +sidebar_position: 2 # required - controls ordering in the docs nav (lower = earlier) +deprecated: false # optional - hides the page from nav when true +--- +``` + +## Filesystem conventions + +- One Markdown file per page. Filename in `kebab-case`. +- Nested folders become nested URLs (Docusaurus-style). `/setup/iam.md` becomes `...//setup/iam`. +- No MDX, no React components, no `