Scaffold, validate, and publish new Claude Code plugins with the canonical plugin contract — now including governed Claude Code mods (function hooks), ADR +…

Scaffold, validate, and publish new Claude Code plugins with proper structure, MCP tool wiring, AND the canonical plugin contract (ADR + smoke + Compatibility + namespace coordination).
/plugin marketplace add ruvnet/ruflo
/plugin install ruflo-plugin-creator@ruflo
embeddings_embed, namespace passed to agentdb_hierarchical-* / agentdb_pattern-*)/create-plugin -- Interactively scaffold a new Claude Code plugincreate-plugin -- Generate plugin structure with skills, commands, agents, ADR-0001, smoke test, and contract sectionsvalidate-plugin -- Validate plugin format and catch issues before publishingcreate-mod -- Scaffold a Claude Code mod (function hooks) from templates/mod/ (scripts/scaffold-mod.mjs <name> <dir>; the manifest is plugin.json.tmpl so validators do not read it as a nested plugin): hybrid hooks.json with a classic fallback that stands down while the module runs, a host adapter over literal $ calls, userConfig, engine-kit tests, tsconfig. Validated with claude plugin validate and claude plugin test (ruflo ADR-404)@claude-flow/cli v3.6 major+minor.bash plugins/ruflo-plugin-creator/scripts/smoke.sh is the contract.Every plugin scaffolded by this plugin inherits the same shape every other plugin in the ruflo family adopted via its own ADR-0001:
plugins/<name>/
├── .claude-plugin/plugin.json # version, keywords, mcp keyword
├── skills/<skill>/SKILL.md # name + description + allowed-tools (no wildcards)
├── commands/<command>.md # name + description + dispatch logic
├── agents/<agent>.md # name + description + model
├── docs/adrs/0001-<name>-contract.md # ADR (Proposed) — pinning, namespace, smoke scope
├── scripts/smoke.sh # Structural contract, ≥8 checks
└── README.md # Compatibility + Namespace coordination + Verification + ADR
Lessons learned from sibling-ADR fixes — the scaffolder warns about these:
| Bug | Real tool / pattern |
|---|---|
embeddings_embed referenced as a tool | Use embeddings_generate (the _embed name does not exist) |
namespace arg passed to agentdb_hierarchical-* | Use tier (working/episodic/semantic), or use memory_* for namespaced reads/writes |
namespace arg passed to agentdb_pattern-* | Don't pass it — ReasoningBank routes; fallback writes to pattern reserved |
pattern and patterns confused | They are different reserved namespaces |
| Hard-coded "19 AgentDB controllers" | Defer to agentdb_controllers runtime; real count varies (~15 MCP tools, 29 controller names) |
bash plugins/ruflo-plugin-creator/scripts/smoke.sh
# Expected: "11 passed, 0 failed"
ruflo-agentdb — namespace convention owner; agentdb_controllers runtime is the canonical controller listruflo-cost-tracker, ruflo-market-data, ruflo-migrations, ruflo-observability — each fixed namespace-routing bugs the scaffolder now warns aboutruflo-knowledge-graph, ruflo-market-data — each fixed embeddings_embed references the scaffolder now warns aboutFunction-hook mod (ADR-445, pattern of ruflo-agentdb), loaded from hooks/hooks.json → hooks/register.ts. This plugin's own tools only search the plugin store, so there is no guard and no userConfig: the mod is a local command and a status file.
/creator-mod answers locally with no model call, read-only. Verbs: status; reserved <plugin-dir> lists the command and skill names a plugin already uses (a mod command must not reuse one); check <plugin-dir> summarises its manifest, hooks.json modules and userConfig options. Paths with .. are refused..claude-flow/creator-mod/status.json ({version, updatedMs, checked, reserved, lastTarget})./create-mod scaffolds hooks/status.ts, which writes .claude-flow/<name>-mod/status.json with the fields the console's Mods section reads: version: 1, summary, modVersion, guard, calls (a number), blocked, lastDenied (only once the mod has refused something), plus updatedMs/startedMs. The scaffold's tests/status.test.ts asserts that shape; the steps are in skills/create-mod/SKILL.md.claude plugin validate plugins/ruflo-plugin-creator, claude plugin test plugins/ruflo-plugin-creator, bash plugins/ruflo-plugin-creator/scripts/smoke.sh. The runner sweeps in templates/mod/tests (the scaffold's own test, which only passes against a scaffolded mod), so expect that one file to fail from the plugin root. node plugins/ruflo-plugin-creator/scripts/test-scaffold-mod.mjs scaffolds a mod from the template and holds it to claude plugin validate and claude plugin test.hooks/register.ts 50 lines1import type { Hook, Register } from 'claude-code'
2
3import { answer } from './command'
4import { newStats, STATUS_PATH, statusText, type Stats } from './status'
5
6type Dollar = Parameters<Hook<'session.start'>>[0]
7
8/** Everything one session of the mod keeps: its counters and the project root. */
9type Session = { readonly stats: Stats; root?: string }
10
11async function flush($: Dollar, s: Session): Promise<void> {
12 if (s.root === undefined) return
13 try {
14 await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, await $.clock.now()))
15 } catch {
16 /* the status file is a courtesy */
17 }
18}
19
20/**
21 * The plugin creator as a mod (ADR-445): `/creator-mod` (status, reserved names, a contract check, all read-only and local) and a status file
22 * the console reads. No guard: this plugin's tools only search the plugin store. No network, no process.
23 */
24export const register: Register = on => {
25 const s: Session = { stats: newStats() }
26
27 on('session.start', async ($, e, next) => {
28 const result = await next(e)
29 try {
30 s.root = (await $.session.root()) as string | undefined
31 } catch {
32 /* no project root: the mod still answers, it just writes no status file */
33 }
34 try {
35 await $.command.register({ name: 'creator-mod', description: 'Plugin creator mod: status, reserved <dir>, check <dir>' })
36 } catch {
37 /* a name taken by another plugin must not stop the mod */
38 }
39 await flush($, s)
40 return result
41 })
42
43 /** `/creator-mod` (the plugin's `/create-plugin` is a prompt command, which no hook can answer). */
44 on('command.run', { command: 'creator-mod' }, async ($, e) => {
45 const text = await answer(typeof e.args === 'string' ? e.args : '', { stats: s.stats, read: p => $.fs.read(p), list: p => $.fs.list(p) })
46 await flush($, s)
47 return { text }
48 })
49}
50hooks/command.ts 73 lines1import type { Stats } from './status'
2
3/** `/creator-mod` is answered locally, read-only and takes no model turn. */
4export type CommandDeps = {
5 readonly stats: Stats
6 readonly read: (path: string) => Promise<string>
7 readonly list: (path: string) => Promise<readonly { readonly name: string; readonly kind: string }[]>
8}
9
10const HELP = ['/creator-mod status', '/creator-mod reserved <plugin-dir>', '/creator-mod check <plugin-dir>'].join('\n')
11
12/** A plugin directory the user typed: relative or absolute, never a parent traversal. */
13export const safeDir = (arg: string): string | undefined => {
14 const dir = arg.trim().replace(/\/+$/, '')
15 return dir === '' || dir.includes('\0') || dir.split('/').includes('..') ? undefined : dir
16}
17
18/** Names the plugin's markdown commands and skill directories already hold: a mod command may not reuse one. */
19async function reservedIn(dir: string, deps: CommandDeps): Promise<string[]> {
20 const names: string[] = []
21 for (const [sub, want] of [['commands', 'file'], ['skills', 'dir']] as const) {
22 try {
23 for (const e of await deps.list(`${dir}/${sub}`)) {
24 if (e.kind === want || e.kind === 'directory') names.push(e.name.replace(/\.md$/, ''))
25 }
26 } catch {
27 /* no such folder */
28 }
29 }
30 return [...new Set(names)].sort()
31}
32
33const readJson = async (deps: CommandDeps, path: string): Promise<Record<string, unknown> | undefined> => {
34 try {
35 const v: unknown = JSON.parse(await deps.read(path))
36 return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined
37 } catch {
38 return undefined
39 }
40}
41
42export async function answer(args: string, deps: CommandDeps): Promise<string> {
43 const [verb = '', ...rest] = args.trim().split(/\s+/)
44 const { stats } = deps
45
46 if (verb === '' || verb === 'help') return HELP
47 if (verb === 'status') return `checks run ${stats.checked} · reserved lookups ${stats.reserved}${stats.lastTarget ? ` · last ${stats.lastTarget}` : ''}`
48
49 if (verb !== 'reserved' && verb !== 'check') return `Unknown: ${verb}\n${HELP}`
50 const dir = safeDir(rest.join(' '))
51 if (dir === undefined) return `usage: /creator-mod ${verb} <plugin-dir> (no .. segments)`
52 stats.lastTarget = dir
53
54 if (verb === 'reserved') {
55 stats.reserved++
56 const names = await reservedIn(dir, deps)
57 return names.length ? `Names ${dir} already uses (a mod command must differ): ${names.join(', ')}` : `No commands or skills found under ${dir}.`
58 }
59
60 stats.checked++
61 const manifest = await readJson(deps, `${dir}/.claude-plugin/plugin.json`)
62 if (!manifest) return `${dir}: no readable .claude-plugin/plugin.json.`
63 const hooks = await readJson(deps, `${dir}/hooks/hooks.json`)
64 const modules = Array.isArray(hooks?.modules) ? (hooks.modules as unknown[]).length : 0
65 const config = typeof manifest.userConfig === 'object' && manifest.userConfig !== null ? Object.keys(manifest.userConfig).length : 0
66 const name = typeof manifest.name === 'string' ? manifest.name : '?'
67 return [
68 `${name} ${typeof manifest.version === 'string' ? manifest.version : '(no version)'}`,
69 `hooks.json ${hooks ? 'present' : 'absent'} · mod modules ${modules} · userConfig options ${config}`,
70 `reserved names: ${(await reservedIn(dir, deps)).join(', ') || 'none'}`,
71 ].join('\n')
72}
73hooks/status.ts 10 lines1/** Counters the mod keeps for the session and writes to `.claude-flow/creator-mod/status.json`. */
2export type Stats = { checked: number; reserved: number; lastTarget?: string }
3
4export const newStats = (): Stats => ({ checked: 0, reserved: 0 })
5
6export const STATUS_PATH = '.claude-flow/creator-mod/status.json'
7
8/** The file's text; `version` lets a reader refuse a shape it does not know. */
9export const statusText = (stats: Stats, nowMs: number): string => `${JSON.stringify({ version: 1, updatedMs: nowMs, ...stats }, null, 2)}\n`
10