#!/usr/bin/env bun // Deterministic offline scaffold for a new AIDLC plugin repository. import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { type PluginValidationFinding, validatePluginName, } from "./aidlc-plugin-validate.ts"; const USAGE = "Usage: bun /aidlc-plugin-create.ts [targetDir] [--json]"; export interface PluginCreateResult { valid: boolean; errors: PluginValidationFinding[]; warnings: PluginValidationFinding[]; targetDir: string; files: string[]; } type ScaffoldFile = { path: string; content: string; }; function displayPath(root: string, file: string): string { return relative(root, file).split(sep).join("/") || "."; } function titleCase(name: string): string { return name .split("-") .filter(Boolean) .map((part) => `${part[0].toUpperCase()}${part.slice(1)}`) .join(" "); } function manifest(name: string): string { return `${JSON.stringify( { name, version: "0.1.0", description: "TODO: Describe what this plugin adds to AI-DLC.", author: { name: "TODO: Your name or organization" }, dependencies: ["core"], aidlc: { contributes: { stages: "stages/", agents: "agents/", scopes: "scopes/", }, }, }, null, 2, )}\n`; } function stage(name: string): string { const slug = `${name}-example-stage`; const scopeName = `${name}-example`; const agent = `${name}-example-agent`; const artifact = `${name}-example-output`; return `--- slug: ${slug} name: ${titleCase(name)} Example plugin: ${name} phase: construction execution: CONDITIONAL condition: Execute when the ${name} plugin example is selected for the current workflow. lead_agent: ${agent} support_agents: [] mode: inline produces: - ${artifact} consumes: [] requires_stage: [] sensors: [] scopes: - ${scopeName} inputs: Existing project context relevant to this plugin outputs: ${artifact}.md (under this stage's record dir, engine-resolved) --- # ${titleCase(name)} Example ## Steps 1. Read the current project context and any declared consumed artifacts. 2. Produce \`${artifact}.md\` in the engine-resolved record directory. 3. Report completion through the normal stage protocol. `; } function scope(name: string): string { const scopeName = `${name}-example`; return `--- name: ${scopeName} plugin: ${name} depth: Standard # Flow form is also legal: keywords: [${name}, ${name} example] keywords: - ${name} - ${name} example description: Run the ${name} example stage skeleton: off runner: true --- # ${scopeName} scope Use this scope while developing the plugin's example stage. Add or remove stage membership through each stage file's \`scopes:\` list. `; } function agent(name: string): string { const agentName = `${name}-example-agent`; return `--- name: ${agentName} display_name: ${titleCase(name)} Example Agent plugin: ${name} description: > Example specialist for the ${name} plugin scaffold. disallowedTools: Task model: sonnet --- **IMPORTANT: Do NOT use the Task tool. You operate as a delegated agent and must not spawn sub-agents.** # ${titleCase(name)} Example Agent Guide the \`${name}-example\` stage, keep outputs concise, and preserve traceability to the stage's declared inputs and artifacts. `; } function testsReadme(): string { return `# Plugin tests Keep plugin tests and fixtures under this directory. Do not place them under \`tools/\`: composition copies \`tools/\` recursively into every install, so test payloads there become shipped runtime files (issue #876). Inside the AIDLC repository, use \`tests/harness/plugin-kit.ts\` for reusable validation, build, compose, and optional live-harness helpers. External plugin repositories can invoke the shipped validate/build/test tools directly from CI. `; } function readme(name: string): string { return `# ${name} An AIDLC plugin scaffold. Replace the placeholders and example content with your plugin's stages, scopes, agents, contributions, sensors, knowledge, and runtime tools. ## Authoring flow 1. **Create:** \`bun /aidlc-plugin-create.ts ${name}\` 2. **Author:** edit \`stages/\`, \`scopes/\`, \`agents/\`, and the manifest. 3. **Validate:** \`bun /aidlc-plugin-validate.ts .\` 4. **Build:** \`bun /aidlc-plugin-build.ts . claude\` 5. **Test:** \`bun /aidlc-plugin-test.ts . --install --harness claude\` 6. **Publish:** build every supported harness projection, tag releases with SemVer, and publish the generated host plugin directories. The build output defaults to \`dist//\`. Publish those outputs from a git repository with a \`marketplace.json\` so host-native plugin stores can discover them. See the AIDLC plugin authoring guide for marketplace metadata and host installation examples. \`hooks/compose.ts\` is intentionally absent from this authored root. Plugin build injects the current bundled compose hook into each host projection. `; } export function scaffoldFiles(name: string): ScaffoldFile[] { const stageName = `${name}-example-stage`; const scopeName = `${name}-example`; return [ { path: join(".aidlc-plugin", "plugin.json"), content: manifest(name), }, { path: join("stages", "construction", `${stageName}.md`), content: stage(name), }, { path: join("scopes", `${scopeName}.md`), content: scope(name), }, { path: join("agents", `${name}-example-agent.md`), content: agent(name), }, { path: join("tests", "README.md"), content: testsReadme() }, { path: "README.md", content: readme(name) }, ].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0, ); } function targetFindings( name: string, targetDir: string, ): PluginValidationFinding[] { const findings = validatePluginName(name, basename(targetDir)); let targetLstat: ReturnType | null = null; try { targetLstat = lstatSync(targetDir); } catch { return findings; } if (targetLstat.isSymbolicLink()) { return [ ...findings, { file: basename(targetDir), rule: "create-target", message: `target path is a symlink: ${targetDir}`, fix: "Choose a real missing or empty directory path.", }, ]; } let entries: string[]; try { if (!targetLstat.isDirectory()) { return [ ...findings, { file: basename(targetDir), rule: "create-target", message: `target path is not a directory: ${targetDir}`, fix: "Choose a missing or empty directory for the plugin scaffold.", }, ]; } entries = readdirSync(targetDir).sort(); } catch (error) { return [ ...findings, { file: ".", rule: "create-target", message: `target directory could not be inspected: ${error instanceof Error ? error.message : String(error)}`, fix: "Choose a readable missing or empty directory.", }, ]; } if (entries.length > 0) { findings.push({ file: ".", rule: "create-target", message: `target directory is not empty: ${entries.join(", ")}`, fix: "Choose a missing or empty directory; CREATE never overwrites existing files.", }); } return findings; } export function createPluginScaffold( name: string, targetDir: string, ): PluginCreateResult { const target = resolve(targetDir); const errors = targetFindings(name, target); if (errors.length > 0) { return { valid: false, errors, warnings: [], targetDir: target, files: [], }; } const files = scaffoldFiles(name); const parent = dirname(target); let staging = ""; let removedEmptyTarget = false; try { mkdirSync(parent, { recursive: true }); staging = mkdtempSync( join(parent, `.${basename(target)}.aidlc-create-`), ); for (const file of files) { const destination = join(staging, file.path); mkdirSync(dirname(destination), { recursive: true }); writeFileSync(destination, file.content, { encoding: "utf-8", flag: "wx", }); } if (existsSync(target)) { const current = lstatSync(target); if ( current.isSymbolicLink() || !current.isDirectory() || readdirSync(target).length > 0 ) { throw new Error( "target changed after validation; CREATE will not overwrite it", ); } rmSync(target, { recursive: true }); removedEmptyTarget = true; } renameSync(staging, target); staging = ""; } catch (error) { if (staging) rmSync(staging, { recursive: true, force: true }); if (removedEmptyTarget && !existsSync(target)) { mkdirSync(target, { recursive: true }); } return { valid: false, errors: [ { file: ".", rule: "create-write", message: `plugin scaffold could not be written: ${error instanceof Error ? error.message : String(error)}`, fix: "Correct the target permissions or race and retry; no partial scaffold was kept.", }, ], warnings: [], targetDir: target, files: [], }; } return { valid: true, errors: [], warnings: [], targetDir: target, files: files.map((file) => displayPath(target, join(target, file.path))), }; } function formatResult(name: string, result: PluginCreateResult): string { if (!result.valid) { const lines = [ "Plugin create: FAILED", `Name: ${name}`, `Target: ${result.targetDir}`, ]; for (const finding of result.errors) { lines.push( `ERROR ${finding.file} [${finding.rule}]: ${finding.message}`, ` Fix: ${finding.fix}`, ); } return `${lines.join("\n")}\n`; } return `${[ "Plugin create: COMPLETE", `Name: ${name}`, `Target: ${result.targetDir}`, `Files (${result.files.length}): ${result.files.join(", ")}`, ].join("\n")}\n`; } export function pluginCreateJson(result: PluginCreateResult): object { return { valid: result.valid, errors: result.errors, warnings: result.warnings, targetDir: result.targetDir, files: result.files, }; } export function main(argv: string[]): number { if (argv.includes("--help") || argv.includes("-h")) { process.stdout.write(`${USAGE}\n`); return 0; } const jsonArgs = argv.filter((arg) => arg === "--json"); const positional = argv.filter((arg) => arg !== "--json"); if ( (positional.length !== 1 && positional.length !== 2) || jsonArgs.length > 1 || argv.some((arg) => arg.startsWith("-") && arg !== "--json") ) { process.stderr.write(`${USAGE}\n`); return 2; } const [name, targetArg] = positional; const targetDir = targetArg ? resolve(targetArg) : resolve(process.cwd(), name); const result = createPluginScaffold(name, targetDir); if (jsonArgs.length === 1) { process.stdout.write(`${JSON.stringify(pluginCreateJson(result))}\n`); } else { const output = formatResult(name, result); (result.valid ? process.stdout : process.stderr).write(output); } return result.valid ? 0 : 1; } if (import.meta.main) { process.exitCode = main(process.argv.slice(2)); }