Agentic engineering kit: seven skills (context engineering, harness engineering, skill development, agent development, evals, sandboxing, fewer faster tests)…

An agent reads CLAUDE.md once, at the start, and has usually stopped using it by the moment a rule applies. So it writes session notes into CLAUDE.md, where they become orders. It hands a worker a note template and gets the template back, copied into every prompt. It ships a skill that nothing ever loads, and calls a screen fixed without looking at the screen. Each of these happened here, more than once. A longer CLAUDE.md does not help: the rule is there, just not in front of the agent when it acts.
This plugin puts the rule there. A hook watches for the moments those mistakes happen and gives the agent a short block (300 to 630 characters) right then: an edit to CLAUDE.md, a brief handed to a worker, a new skill, a hook, a test or an eval. Each block names one skill. The skill holds the depth and loads only when its moment comes.
| skill | what it holds | named at |
|---|---|---|
| /agentic-engineering:context-engineering | what an agent's window holds, when, and in what words: any text an agent reads | an edit to CLAUDE.md, a skill, an agent or a prompt; a brief to a worker |
| /agentic-engineering:harness-engineering | everything around an agent except the model, in six parts: instructions (what reaches it before it acts: channels, files, hooks), checks (denies, lints, tests, baseline lints), tools (CLI commands), verification (how it tests its own result), state (what is kept across sessions and compactions) and maintenance (measuring pieces and removing the ones that no longer help); what a harness is on its front page, the published sources and their evidence (references/field.md), a hook template (assets/hook.py) | every start; wiring a hook or a setting |
| /agentic-engineering:skill-development | whether it should be a skill at all, then designing, delivering, proving and retiring one | a new SKILL.md, agent or command; a plugin listing |
| /agentic-engineering:agent-development | an agent file: which kind (subagent, fork, teammate, the session itself with --agent), its frontmatter, its body, memory, MCP servers, and how Claude Code runs it (background, nesting, limits, resuming) | no block yet: one comes with its eval case (plugins/EVALS.md) |
| /agentic-engineering:evals | which eval or test answers which question: unit, contract, claude -p, tmux, sandbox rehearsal, eval cases | writing a test or an eval; starting Claude from a script or tmux |
| /agentic-engineering:sandboxing | trying a plugin where it can't reach the real setup: the level (container, macOS VM), what data goes in and how (snapshot, read-only), reading back what it changed | no block yet: one comes with its eval case (plugins/EVALS.md) |
| /agentic-engineering:fewer-faster-tests | a project's test suite under an agent, on any stack: how long checking one change takes, which tests can fail for a reason and which cannot, what is missing as well as what is extra, whether the agent can run the app and see the result, whether a change runs only its affected tests; options with their trade-offs, the person chooses | no block yet: one comes with its eval case (plugins/EVALS.md) |
Skills link each other inside this plugin, where they always ship together; a skill from outside the plugin or workspace may not be installed, so none is named. When two skills need one fact, it lives in one of them and the other links it.
agentic-engineering:agentic-engineer (agents/agentic-engineer.md) is a subagent to hand agentic-engineering work to; @agent-agentic-engineering:agentic-engineer calls it by name. It starts with context-engineering and skill-development loaded whole (skills: in its frontmatter) and can load the other two through the Skill tool. The skills are its instructions; its body holds one step, the check before it reports agent text done: the checklist in context-engineering § Before a text is done, then a review by a subagent that has not seen the conversation, with a fixed brief (tasks walked through the links, each Claude Code claim against the docs, facts stated twice, steps the reader cannot run, metaphors). The blocks still reach it, because the plugin's hooks fire inside subagents too.
Three commands in hooks/new.tsx. /new-plugin makes your own plugin: one folder, tracked with git, for the skills and agents you want in every project. /new-agent and /new-skill make a new agent or skill file, by default in that plugin.
/new-plugin my-tools (or /new-plugin --name my-tools --folder ~/code/my-tools, or a form)
├─ writes ~/my-tools-claude/: .claude-plugin/plugin.json, .claude-plugin/marketplace.json (a marketplace of
│ this one plugin, source "./"), skills/, agents/, README.md (templates/plugin-README.md), .gitignore,
│ AGENTS.md (templates/plugin-AGENTS.md: where things go, and /plugin-authoring and
│ /agentic-engineering:harness-engineering for advanced customization), CLAUDE.md (@AGENTS.md: Claude Code
│ reads CLAUDE.md, not AGENTS.md)
├─ runs git init · claude plugin marketplace add ~/my-tools-claude · claude plugin install my-tools@my-tools --scope user
├─ keeps {name, folder} in this plugin's store, then runs /reload-plugins
└─ you see created your plugin my-tools at ~/my-tools-claude: /new-skill and /new-agent write there now
claude plugin update says so on 2.1.294): an edit there loads at /reload-plugins or the next session.claude plugin step stops it with that command's last line, and no plugin is kept.marketplace.json is written. Nothing else in the folder is written over./my-tools:release-notes)./new-agent and /new-skill make a new agent or skill file, three ways:
/new-agent --name code-reviewer --description "Reviews diffs for bugs"
├─ writes .claude/agents/code-reviewer.md from agent-development's _template.md:
│ name and description set, every other field commented out, the body a placeholder
├─ you see agentic-engineering: created .claude/agents/code-reviewer.md
└─ your next prompt carries one line of context for the model, which you don't see, saying what was created
/new-agent a reviewer for pull requests
└─ runs /agentic-engineering:agent-development a reviewer for pull requests for you:
Claude starts at once, the whole skill loaded (/new-skill: …:skill-development)
/new-agent
└─ a pane opens, the cursor in Name
✻ New agent
Writes .claude/agents/<name>.md from the agent template.
Name: code-reviewer
Description: Reviews diffs for bugs, after each change
[ Create file ] [ Design with Claude ]
Tab next field · Enter create · Esc cancel
Create file (or Enter in a field) writes it, as the flags do; Design with Claude runs the skill
command with the description and "(name: …)", or "a new agent" when both are empty
--name and --description each take the words up to the next flag; quotes keep words together; --name=code-reviewer works too. --description alone is refused: a name is needed.Code Reviewer and Code_Reviewer become code-reviewer. A bad name is refused (in the pane, shown there, the pane staying open). An empty description becomes a TODO line._template.md, read from the plugin at each run. Its links point inside the skill, so in the copy each becomes the skill's command and the file's path in it. tests/test_new_templates.py checks a template still has what the command replaces: one single-line name: and description:, and links that resolve..claude/: skills/<name>/SKILL.md, agents/<name>.md), then /reload-plugins runs so it loads now; the form's Where row, or --where project, writes under the session's working directory instead. Without one, under the working directory: a .claude/agents/ that did not exist when the session started is not watched, and the new agent loads in the next session. A plugin whose folder no longer holds its plugin.json is ignored. Design with Claude and free text add where the file goes to the words: (put it in my plugin my-tools: <folder>/agents/<name>.md).claude -p, the SDK): the flags and free text work the same; with nothing after the command, the usage.Where the pane sits is Claude Code's choice, not the plugin's (the pane's placement): in the fullscreen renderer from 110 columns it is dock, a side panel beside the transcript on a grey background Claude Code draws, and the form draws its own border; in the classic renderer (/tui default, or CLAUDE_CODE_NO_FLICKER=0) it is inline, above the prompt in Claude Code's own frame. Colors are theme keys (claude, subtle, error), so the form follows the user's theme.
The skill loads because its slash command runs, not because Claude chooses it: the module calls $.command.run after the command has answered ($.clock.after), since Claude Code refuses command.run from inside a command.run hook.
A Claude Mod is a plugin's TypeScript hooks module, run inside Claude Code: "modules" in hooks/hooks.json, beside the Python hooks. Its API is in Claude Code's built-in skill, /plugin-authoring. Its hooks (on('command.run', …)) call Claude Code through $. A skill could not do this: a skill's text always goes to the model, and a hook that blocks a skill's expansion shows "UserPromptExpansion operation blocked by hook" and drops its additionalContext.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1; without it the commands are missing and the rest of the plugin works as before. /new-agent missing from the / menu means it did not load: claude plugin validate plugins/agentic-engineering lists the module and the hooks the loader sees.new-agent, new-skill or new-plugin wins: Claude Code refuses the plugin's command at session start./new-plugin on 2.1.294. The mods API is early access.From the repo root, in order:
claude plugin validate plugins/agentic-engineering
npx -y -p typescript@5 tsc -p plugins/agentic-engineering/tsconfig.json
cd plugins/agentic-engineering && CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .
tsc reads the API from plugins/agentic-engineering/.claude/types/ (git-ignored): /plugin-types writes it, or copy ~/.claude/plugins/marketplaces/claude-code-plugins/mods/types/claude-code.d.ts there.claude plugin test reads its own arguments: nothing may come before plugin test. With the kit's cc-shim in front of claude, which adds a flag, prefix CC_SYSPROMPT_FILE=/dev/null.tests/new.test.ts runs on cut-down templates (a mod's test has no disk); uv run pytest -q checks the real ones.mdmap moved into the vault plugin on 2026-09-30: ak vault map <dir> (ak mdmap still answers, hidden), source plugins/vault/src/vault/map.py. It reads a [[name]] through the vault's own link engine, so there is no resolver copy here any more.
Each block is one plain-text file in prompts/, printed whole by a hook. It arrives once per agent (the main session and each worker), and again after a compaction.
| block | arrives |
|---|---|
session.txt | at every start (new, resume, clear, compact, fork) and in each worker (hooks/session.py) |
instruction-files.txt | before a Write or Edit of CLAUDE.md, AGENTS.md, CLAUDE.local.md, .claude/rules/* |
agent-text.txt | before an edit of an existing SKILL.md, a skill's references/, agents/*.md, commands/*.md, a plugin's prompts/* |
skills.txt | before writing a new SKILL.md, agent or command; an edit of .claude-plugin/*.json |
hooks.txt | before an edit of hooks.json, a hook script, .claude/settings*.json |
evals.txt | before an edit in a plugin's tests/ or evals/; a claude -p, claude plugin eval, env … claude -p or tmux command |
brief.txt | before an Agent, Task or Workflow call (not a Workflow resume) |
Every block except session.txt comes from hooks/on_edit.py on PreToolUse. Bash reaches it through one handler per if rule in hooks/hooks.json (claude *, env *claude *, tmux *), so every other command starts no process. The script re-reads the command, since the rule is best effort.
Who gets blocks. A person's session and every worker it starts. A program's session (claude -p, the SDK, CI) gets the start line and nothing else: no block on an edit. An eval opts in with EVAL_AK_CAPTURE=1 (AK_CAPTURE=1 anywhere else). claude -p --bare skips every hook.
After the edit. When a CLAUDE.md, AGENTS.md or CLAUDE.local.md has been written, a check tells the model what it found: over 200 lines, an AGENTS.md Claude does not read, a link that does not resolve. The person sees <path> edited (+n −m) (hooks/after_edit.py).
Not seen. An edit made through Bash (sed -i, a heredoc). A script that launches claude. A tool description.
claude --plugin-dir plugins/agentic-engineering
Run it as a person's session, not -p. Then:
claude -p on something. The evals block arrives before the run.Headless, the way the evals run it. Unset CLAUDECODE when you start it from inside Claude; --setting-sources local keeps your other plugins out of the run:
EVAL_AK_CAPTURE=1 claude -p "add a line to CLAUDE.md" --plugin-dir plugins/agentic-engineering \ --allowedTools Edit --setting-sources local
Each block leaves a record in the transcript of the agent that got it: ~/.claude/projects/<project>/<session>.jsonl, and a worker's under <session>/subagents/. Grep it for the block's tag, for example [agentic-engineering: evals].
hook_additional_context.In the kit workspace, ak plugin log agentic-engineering shows the same for one session, and ak plugin inspect agentic-engineering counts how often each hook fired and what it cost.
evals/<case>/ holds prompt.md (the request and its settings), scaffold.sh (the repo the agent works in) and graders/ (one check per file; the score is the share that pass). Every case is born from a session that went wrong and keeps its story: the session, the person's words, what it guards (plugins/EVALS.md). It runs red on the release before the change that makes it pass.
claude plugin eval plugins/agentic-engineering --scaffold --allow-tools Write Edit \ --model sonnet --runs 3 --trust-plugin --no-publish
EVAL_AK_CAPTURE=1. An eval runs as claude -p, a program's session, which gets no block without it, and claude plugin eval passes only EVAL_* variables to a case.CLAUDE_CODE_DISABLE_CLAUDE_MDS=1, so each case copies its scaffold's CLAUDE.md into append_system_prompt. Keep the two in sync.claude-md-is-a-prompt also carries memory's start text, rendered from this checkout by evals/memory_line.py --write. Its scaffold fails a run whose copy is stale.--allow-tools: on a machine where ~/.docker/bin holds symlinks, the harness refuses any run that grants it. A case that needs Bash (claude -p, tmux) runs through evals/run.py instead.uv run pytest -q runs the hooks the way Claude Code runs them.
hooks/new.tsx 410 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// What each command writes, relative to the session's working directory (in the person's own plugin, the same path
4// without `.claude/`, under its folder); the template it starts from, in this plugin; and the skill that designs one
5// with Claude.
6const KINDS = {
7 'new-agent': { noun: 'agent', title: 'New agent', path: (name: string) => `.claude/agents/${name}.md`, when: 'when Claude should hand work to it', template: 'skills/agent-development/_template.md', skill: 'agentic-engineering:agent-development', example: 'code-reviewer', sample: 'Reviews diffs for bugs, after each change' },
8 'new-skill': { noun: 'skill', title: 'New skill', path: (name: string) => `.claude/skills/${name}/SKILL.md`, when: 'when Claude should use it', template: 'skills/skill-development/_template.md', skill: 'agentic-engineering:skill-development', example: 'release-notes', sample: 'Writes the notes for a version' },
9} as const
10type Command = keyof typeof KINDS
11const isCommand = (id: string): id is Command => id in KINDS
12// Every surface but mobile draws Input, which the form needs.
13const drawsInput = <E extends { surface: string }>(e: E): e is E & { surface: 'terminal' | 'desktop' | 'vscode' } => e.surface !== 'mobile'
14
15// The person's own plugin, made by /new-plugin: its name and its folder (absolute). Kept in the plugin's store, so
16// every session knows it.
17type Home = { name: string; folder: string }
18// Where /new-agent and /new-skill write: into that plugin, or under the session's working directory.
19type Where = 'plugin' | 'project'
20// What a form holds while its pane is open.
21type Form = { name: string; what: string; where: Where; error: string }
22type PluginForm = { name: string; folder: string; error: string; status: string }
23// The module's state: whether a user is at the session, the session's working directory, the user's home folder,
24// their plugin, each command's form, how many times a form has opened, and the notes the model gets with the
25// user's next prompt.
26type State = { isInteractive: boolean; cwd: string; userHome: string; home?: Home; forms: Record<Command, Form>; plugin: PluginForm; opens: number; notes: string[] }
27// The fields' keys, new at each open: Claude Code keeps what was typed in a field, by its key, until the hook
28// draws a different value, and an emptied form draws the same empty value, so an old key would show the old text.
29const field = (state: State, name: 'name' | 'what' | 'where' | 'folder') => `${name}-${state.opens}`
30const empty = (where: Where = 'project'): Form => ({ name: '', what: '', where, error: '' })
31const emptyPlugin = (): PluginForm => ({ name: '', folder: '', error: '', status: '' })
32const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/
33
34// The skill's template made into the new file: its name and description set, and its links, relative to the
35// skill's folder and so dead in the copy, turned into the skill's command and the file's path in the skill.
36function fill(source: string, command: Command, name: string, description: string) {
37 const { noun, when, skill } = KINDS[command]
38 const text = description.trim() || `TODO: what this ${noun} does, and ${when}.`
39 return source
40 .replace(/^name: .*$/m, () => `name: ${name}`)
41 .replace(/^description: .*$/m, () => `description: >-\n ${text}`)
42 .replace(/\[([^\]]+)\]\(\.\/([^)]+)\)/g, (_, label: string, file: string) =>
43 file === 'SKILL.md' ? `${label} (/${skill})` : `${label} (/${skill}, ${file})`)
44}
45
46// `Code_Reviewer` and `Code Reviewer` → `code-reviewer`.
47const normalize = (name: string) => name.trim().toLowerCase().replace(/[\s_]+/g, '-')
48// `/Users/ana/ana-claude` → `~/ana-claude`, in what the user reads.
49const tilde = (state: State, path: string) =>
50 state.userHome && path.startsWith(`${state.userHome}/`) ? `~${path.slice(state.userHome.length)}` : path
51// What the user typed as a folder, made absolute: `~/x` under their home folder, a relative path under the
52// session's working directory.
53function expand(state: State, typed: string) {
54 const path = typed.length > 1 ? typed.replace(/[\\/]+$/, '') : typed
55 if (path === '~' || path.startsWith('~/')) return `${state.userHome}${path.slice(1)}`
56 return /^([a-zA-Z]:)?[\\/]/.test(path) ? path : `${state.cwd}/${path}`
57}
58
59// The file a command writes: under the working directory, or in the plugin.
60function target(command: Command, name: string, home?: Home) {
61 const file = KINDS[command].path(name)
62 return home ? `${home.folder}/${file.slice('.claude/'.length)}` : file
63}
64
65// The user's plugin, when /new-plugin made one and its folder still holds it.
66async function loadHome($: EngineInterface): Promise<Home | undefined> {
67 try {
68 const home = (await $.store.get('home')) as Home | undefined
69 return home && (await $.fs.exists(`${home.folder}/.claude-plugin/plugin.json`)) ? home : undefined
70 } catch {
71 return undefined
72 }
73}
74
75// Plugins reload once the command has answered: from inside a command.run hook, command.run would wait on the turn
76// the hook holds. A reload restarts only the modules that changed, so this one keeps its state.
77function reload($: EngineInterface) {
78 $.clock.after(0, () => {
79 void $.command.run({ command: 'reload-plugins' }).catch(() => $.ui.log('run /reload-plugins to load it now'))
80 })
81}
82
83// Writes the file, never over an existing one. `context` is the note for the model.
84async function write($: EngineInterface, state: State, command: Command, name: string, description: string, where: Where) {
85 const { template, skill } = KINDS[command]
86 if (!name) return { error: 'a name is needed' }
87 if (!NAME.test(name)) return { error: `bad name '${name}': use lowercase letters, digits and hyphens` }
88 const home = where === 'plugin' ? state.home : undefined
89 const file = target(command, name, home)
90 if (await $.fs.exists(file)) return { error: `already exists, left untouched: ${tilde(state, file)}` }
91 await $.fs.write(file, fill(await $.fs.read(`${$.plugin.root}/${template}`), command, name, description))
92 const created = `The user ran /${command}: it created ${file} from /${skill}'s template (name and description set, every other field commented out, the body a placeholder).`
93 if (!home) return { text: `created ${file}`, context: created }
94 reload($)
95 return {
96 text: `created ${tilde(state, file)}, in your plugin: ${home.name}:${name}`,
97 context: `${created} It is in the user's own plugin, ${home.name} (${home.folder}), so Claude Code knows it as ${home.name}:${name}; the command reloaded the plugins.`,
98 }
99}
100
101// /new-plugin: the user's own plugin, in a folder they pick. The folder is also a one-plugin marketplace, so Claude
102// Code reads the plugin from it (an edit there loads at /reload-plugins), installed for every session, and kept as
103// where /new-agent and /new-skill write. A folder that already holds this plugin (a clone, a run that stopped
104// half-way) is added and installed, nothing in it written over.
105async function createPlugin($: EngineInterface, state: State, typed: string, typedFolder: string) {
106 const name = normalize(typed)
107 if (!name) return { error: 'a name is needed' }
108 if (!NAME.test(name)) return { error: `bad name '${name}': use lowercase letters, digits and hyphens` }
109 const folder = expand(state, typedFolder.trim() || `~/${name}-claude`)
110 const shown = tilde(state, folder)
111 const manifest = `${folder}/.claude-plugin/plugin.json`
112 const isAdopted = await $.fs.exists(manifest)
113 if (isAdopted) {
114 let found: string | undefined
115 try {
116 found = (JSON.parse(await $.fs.read(manifest)) as { name?: string }).name
117 } catch {
118 return { error: `cannot read ${tilde(state, manifest)}: fix it, or pick another folder` }
119 }
120 if (found !== name) return { error: `${shown} holds the plugin '${found}': name it ${found}, or pick another folder` }
121 } else if ((await $.fs.exists(folder)) && (await $.fs.list(folder)).length) {
122 return { error: `not empty, left untouched: ${shown}` }
123 }
124 const settings = await $.settings.read()
125 const marketplaces = (settings.extraKnownMarketplaces ?? {}) as Record<string, { source?: { path?: string } }>
126 const enabled = Object.keys((settings.enabledPlugins ?? {}) as Record<string, unknown>)
127 const known = marketplaces[name]
128 if (known && known.source?.path !== folder) return { error: `a marketplace named ${name} already comes from elsewhere: pick another name` }
129 const other = enabled.find(id => id.startsWith(`${name}@`) && id !== `${name}@${name}`)
130 if (other) return { error: `a plugin named ${name} is already installed (${other}): pick another name` }
131
132 const files = await scaffold($, name)
133 for (const [path, text] of Object.entries(files)) {
134 if (isAdopted && (path !== '.claude-plugin/marketplace.json' || (await $.fs.exists(`${folder}/${path}`)))) continue
135 await $.fs.write(`${folder}/${path}`, text)
136 }
137 if (!isAdopted) await $.process.run(['git', 'init', '-q'], { cwd: folder }).catch(() => undefined)
138 const steps = [
139 ...(known ? [] : [['claude', 'plugin', 'marketplace', 'add', folder]]),
140 ...(enabled.includes(`${name}@${name}`) ? [] : [['claude', 'plugin', 'install', `${name}@${name}`, '--scope', 'user']]),
141 ]
142 for (const argv of steps) {
143 const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs: 120_000 })
144 const said = (stderr.trim() || stdout.trim()).split('\n').pop() ?? ''
145 if (exitCode !== 0) return { error: `${argv.slice(0, 4).join(' ')} failed: ${said}` }
146 }
147 state.home = { name, folder }
148 await $.store.set('home', state.home)
149 reload($)
150 return {
151 text: `${isAdopted ? 'added' : 'created'} your plugin ${name} at ${shown}: /new-skill and /new-agent write there now`,
152 context: `The user ran /new-plugin: their own plugin, ${name}, is at ${folder} (a git repo: .claude-plugin/plugin.json and marketplace.json, skills/, agents/, README.md, AGENTS.md), added as the marketplace ${name} and installed as ${name}@${name} for every session. Claude Code reads it from that folder: an edit there loads at /reload-plugins, which the command ran. /new-skill and /new-agent now write into it unless the user picks this project.`,
153 }
154}
155
156// The files of a new plugin, by path in its folder: the manifest, the folder as a one-plugin marketplace, the
157// README and AGENTS.md from this plugin's templates, a CLAUDE.md that imports AGENTS.md (Claude Code reads
158// CLAUDE.md, not AGENTS.md), and the two folders the commands write into.
159async function scaffold($: EngineInterface, name: string): Promise<Record<string, string>> {
160 const description = `${name}'s own skills and agents, in every session.`
161 const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`
162 const template = async (file: string) => (await $.fs.read(`${$.plugin.root}/templates/${file}`)).replaceAll('{{name}}', name)
163 return {
164 '.claude-plugin/plugin.json': json({ name, version: '0.1.0', description }),
165 '.claude-plugin/marketplace.json': json({ name, owner: { name }, plugins: [{ name, source: './', description }] }),
166 'README.md': await template('plugin-README.md'),
167 'AGENTS.md': await template('plugin-AGENTS.md'),
168 'CLAUDE.md': '@AGENTS.md\n',
169 '.gitignore': '.DS_Store\n',
170 'skills/.gitkeep': '',
171 'agents/.gitkeep': '',
172 }
173}
174
175// `--name code-reviewer --description Reviews diffs` (or `--name=…`): each flag takes the words up to the next
176// flag; quotes keep words together. Words before any flag are free text.
177type Flag = 'name' | 'description' | 'where' | 'folder'
178function parse(args: string) {
179 const words: Record<Flag | 'text', string[]> = { name: [], description: [], where: [], folder: [], text: [] }
180 let into: Flag | 'text' = 'text'
181 let flagged = false
182 for (const token of args.match(/"[^"]*"|'[^']*'|\S+/g) ?? []) {
183 const flag = /^--(name|description|where|folder)(?:=(.+))?$/.exec(token)
184 if (flag) {
185 into = flag[1] as Flag
186 flagged = true
187 if (flag[2]) words[into].push(flag[2])
188 continue
189 }
190 words[into].push(token)
191 }
192 const join = (key: Flag | 'text') => words[key].map(w => w.replace(/^(["'])(.*)\1$/, '$2')).join(' ').trim()
193 return { flagged, name: join('name'), description: join('description'), where: join('where'), folder: join('folder'), text: join('text') }
194}
195
196// `--name` (and `--description`): the file is written at once and the model gets a note. Free text: Claude is
197// asked, the skill for designing one loaded. Nothing, in a session a user is at: the form opens in a pane.
198// `--where plugin|project` picks where; without it, the user's plugin when they have one.
199async function run($: EngineInterface, state: State, command: Command, args: string) {
200 state.home = await loadHome($)
201 const { flagged, name, description, where, text } = parse(args)
202 const place = where || (state.home ? 'plugin' : 'project')
203 if (place !== 'plugin' && place !== 'project') return { text: `--where takes plugin or project, not '${place}'` }
204 if (place === 'plugin' && !state.home) return { text: 'no plugin of yours yet: /new-plugin makes one' }
205 if (flagged) {
206 if (!name) return { text: `--name is needed: /${command} --name <name> --description <what it does>` }
207 const result = await write($, state, command, normalize(name), description, place)
208 return 'error' in result ? { text: result.error } : { text: result.text, context: [result.context] }
209 }
210 if (text) return ask($, state, command, text, place)
211 if (!state.isInteractive) return { text: `usage: /${command} --name <name> --description <what it does>, or /${command} <what it should do>` }
212 state.forms[command] = empty(place)
213 state.opens += 1
214 // rows: what the form needs inline, footer included (left out, a third of the screen, which cuts it).
215 await $.ui.open({ id: command, title: KINDS[command].title, focus: true, closeOnEscape: true, rows: state.home ? 13 : 12 })
216 return {}
217}
218
219// `/new-plugin my-tools` or `--name my-tools --folder ~/code/my-tools`: the plugin is made at once. Nothing, in a session a
220// user is at: the form opens in a pane.
221async function runPlugin($: EngineInterface, state: State, args: string) {
222 const { name, folder, text } = parse(args)
223 const typed = name || text
224 if (typed) {
225 const result = await createPlugin($, state, typed, folder)
226 return 'error' in result ? { text: result.error } : { text: result.text, context: [result.context] }
227 }
228 if (!state.isInteractive) return { text: 'usage: /new-plugin <name>, or /new-plugin --name <name> --folder <folder>' }
229 state.plugin = emptyPlugin()
230 state.opens += 1
231 await $.ui.open({ id: 'new-plugin', title: 'New plugin', focus: true, closeOnEscape: true, rows: 12 })
232 return {}
233}
234
235// Claude is asked to design it: the skill's own command, run with the words as if the user typed it, so the whole
236// skill loads. Run after the hook returns: from inside a command.run hook, command.run would wait on the turn the
237// hook holds, and Claude Code refuses it. Into the plugin, the words say where the file goes.
238function ask($: EngineInterface, state: State, command: Command, words: string, where: Where) {
239 const { skill } = KINDS[command]
240 const home = where === 'plugin' ? state.home : undefined
241 const into = home ? ` (put it in my plugin ${home.name}: ${target(command, '<name>', home)})` : ''
242 $.clock.after(0, () => { void $.command.run({ command: skill, args: `${words}${into}` }).catch(() => undefined) })
243 return { text: `asking Claude, with /${skill}` }
244}
245
246async function create($: EngineInterface, state: State, command: Command) {
247 const form = state.forms[command]
248 const result = await write($, state, command, normalize(form.name), form.what, form.where)
249 if ('error' in result) {
250 form.error = result.error ?? ''
251 $.ui.invalidate('ui.render')
252 return
253 }
254 state.notes.push(result.context)
255 await $.ui.close({ id: command })
256 $.ui.log(result.text)
257}
258
259async function createFromForm($: EngineInterface, state: State) {
260 const form = state.plugin
261 form.error = ''
262 form.status = 'creating…'
263 $.ui.invalidate('ui.render')
264 const result = await createPlugin($, state, form.name, form.folder)
265 form.status = ''
266 if ('error' in result) {
267 form.error = result.error ?? ''
268 $.ui.invalidate('ui.render')
269 return
270 }
271 state.notes.push(result.context)
272 await $.ui.close({ id: 'new-plugin' })
273 $.ui.log(result.text)
274}
275
276// Design with Claude: the pane closes and Claude is asked, with what the user typed.
277async function design($: EngineInterface, state: State, command: Command) {
278 const form = state.forms[command]
279 const named = form.name.trim() ? `(name: ${normalize(form.name)})` : ''
280 const words = [form.what.trim(), named].filter(Boolean).join(' ')
281 await $.ui.close({ id: command })
282 ask($, state, command, words || `a new ${KINDS[command].noun}`, form.where)
283}
284
285export const register: Register = on => {
286 const state: State = { isInteractive: false, cwd: '', userHome: '', forms: { 'new-agent': empty(), 'new-skill': empty() }, plugin: emptyPlugin(), opens: 0, notes: [] }
287
288 on('session.start', async ($, e, next) => {
289 const started = await next(e)
290 state.isInteractive = e.isInteractive
291 state.cwd = e.cwd
292 state.userHome = ((await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? '').replace(/[\\/]$/, '')
293 // immediate: typed mid-turn, it runs at once instead of waiting for the turn to end
294 await $.command.register({
295 name: 'new-agent',
296 description: 'Create an agent file, in your plugin or this project, or design the agent with Claude',
297 argumentHint: '[--name <name> --description <text> --where plugin|project | what it should do]',
298 immediate: true,
299 })
300 await $.command.register({
301 name: 'new-skill',
302 description: 'Create a SKILL.md, in your plugin or this project, or design the skill with Claude',
303 argumentHint: '[--name <name> --description <text> --where plugin|project | what it should do]',
304 immediate: true,
305 })
306 await $.command.register({
307 name: 'new-plugin',
308 description: 'Make your own plugin in a folder you pick: /new-skill and /new-agent then write into it',
309 argumentHint: '[<name> | --name <name> --folder <folder>]',
310 immediate: true,
311 })
312 return started
313 })
314
315 // The hook IS the command: no next(e), so no model turn happens for it.
316 on('command.run', { command: 'new-agent' }, ($, e) => run($, state, 'new-agent', e.args))
317 on('command.run', { command: 'new-skill' }, ($, e) => run($, state, 'new-skill', e.args))
318 on('command.run', { command: 'new-plugin' }, ($, e) => runPlugin($, state, e.args))
319
320 // The forms, drawn as Claude Code draws its own dialogs: a bold title in the accent color, dim help, the keys in
321 // a dim footer, and docked a rounded border. Theme keys, so it follows the user's theme.
322 on('ui.render', { component: 'Pane' }, ($, e, next) => {
323 const id = e.requestId
324 if (!(isCommand(id) || id === 'new-plugin') || !drawsInput(e)) return next(e)
325 const { Box, Text, Input, Button, Select } = $.ui.resolve(e)
326 const primary = { variant: 'primary' } // on 2.1.292, not yet in the typed props
327 // Docked (fullscreen), the pane is a grey column with no frame: our border marks the form. Inline (the classic
328 // renderer, above the prompt), Claude Code frames the pane itself, so a border of ours would be a box in a box.
329 const frame = e.props.placement === 'dock' ? { borderStyle: 'round', borderColor: 'claude', paddingX: 1 } : {}
330 const footer = <Text color="subtle">{e.props.placement === 'dock' ? ' ' : ''}Tab next field · Enter create · Esc cancel</Text>
331
332 if (id === 'new-plugin') {
333 const form = state.plugin
334 const name = normalize(form.name) || '<name>'
335 return (
336 <Box flexDirection="column">
337 <Box flexDirection="column" {...frame}>
338 <Text bold color="claude">✻ New plugin</Text>
339 <Text color="subtle">Your own skills and agents, in a folder you pick: a git repo, on in every session.</Text>
340 <Box marginTop={1} flexDirection="column">
341 <Input key={field(state, 'name')} label="Name" placeholder="your-name" value={form.name} autoFocus submitLabel="create"
342 onInput={value => { form.name = value; $.ui.invalidate('ui.render') }}
343 onSubmit={value => { form.name = value; void createFromForm($, state) }} />
344 <Input key={field(state, 'folder')} label="Folder" placeholder={`~/${name}-claude`} value={form.folder} submitLabel="create"
345 onInput={value => { form.folder = value }}
346 onSubmit={value => { form.folder = value; void createFromForm($, state) }} />
347 </Box>
348 <Text color="subtle">Its skills run as /{name}:<skill>: a short name reads best.</Text>
349 {form.error ? <Text color="error">✗ {form.error}</Text> : null}
350 {form.status ? <Text color="subtle">{form.status}</Text> : null}
351 <Box marginTop={1} flexDirection="row" gap={2}>
352 <Button key="create" {...primary} onPress={() => { void createFromForm($, state) }}>Create plugin</Button>
353 </Box>
354 </Box>
355 {footer}
356 </Box>
357 )
358 }
359
360 const { noun, title, example, sample, skill } = KINDS[id]
361 const form = state.forms[id]
362 const home = state.home
363 const file = target(id, '<name>', form.where === 'plugin' ? home : undefined)
364 // Enter in either field creates the file: Enter empties the field it is pressed in, so it cannot move to
365 // the next one; Tab does.
366 return (
367 <Box flexDirection="column">
368 <Box flexDirection="column" {...frame}>
369 <Text bold color="claude">✻ {title}</Text>
370 <Text color="subtle">Writes {tilde(state, file)} from the {noun} template.</Text>
371 <Box marginTop={1} flexDirection="column">
372 {home ? (
373 <Select key={field(state, 'where')} label="Where" value={form.where}
374 options={[{ value: 'plugin', label: `${home.name} (${tilde(state, home.folder)})` }, { value: 'project', label: 'this project (.claude/)' }]}
375 onSelect={value => { form.where = value === 'project' ? 'project' : 'plugin'; $.ui.invalidate('ui.render') }} />
376 ) : null}
377 <Input key={field(state, 'name')} label="Name" placeholder={example} value={form.name} autoFocus submitLabel="create"
378 onInput={value => { form.name = value }}
379 onSubmit={value => { form.name = value; void create($, state, id) }} />
380 <Input key={field(state, 'what')} label="Description" placeholder={sample} value={form.what} submitLabel="create"
381 onInput={value => { form.what = value }}
382 onSubmit={value => { form.what = value; void create($, state, id) }} />
383 </Box>
384 {form.error ? <Text color="error">✗ {form.error}</Text> : null}
385 <Box marginTop={1} flexDirection="row" gap={2}>
386 <Button key="create" {...primary} onPress={() => { void create($, state, id) }}>Create file</Button>
387 <Button key="design" onPress={() => { void design($, state, id) }}>Design with Claude</Button>
388 </Box>
389 <Text color="subtle">Design with Claude asks Claude, with /{skill} loaded and your words.</Text>
390 </Box>
391 {footer}
392 </Box>
393 )
394 })
395
396 on('ui.close', async ($, e, next) => {
397 const closed = await next(e)
398 if (isCommand(e.id)) state.forms[e.id] = empty()
399 if (e.id === 'new-plugin') state.plugin = emptyPlugin()
400 return closed
401 })
402
403 on('prompt.submit', ($, e, next) => {
404 if (!state.notes.length) return next(e)
405 const context = [...(e.context ?? []), ...state.notes]
406 state.notes = []
407 return next({ ...e, context })
408 })
409}
410