SLOPSHOPPER

agentic-engineering

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

panecommandpromptprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agentic-engineering
│ ┃ New agent ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ✻ New agent ⏺ Read(src/auth.ts) │ ┃ │ Writes .claude/agents/<name>.md from the ⎿ Read 6 lines │ ┃ │ agent template. ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Name: code-reviewer ⏎ create ⏺ Bash(bun test) │ ┃ │ Description: Reviews diffs for bugs, after ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ [ Create file ] [ Design with Claude ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Design with Claude asks Claude, with │ ┃ │ /agentic-engineering:agent-development ✻ Worked for 42s · done 4:20 PM │ ┃ │ loaded and your words. │ ┃ ╰─────────────────────────────────────────── › /new-agent │ ┃ Tab next field · Enter create · Esc cancel │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · New agent
╭─────────────────────────────────────────────────────────── │ ✻ New agent │ Writes .claude/agents/<name>.md from the agent template. │ │ Name: code-reviewer ⏎ create │ Description: Reviews diffs for bugs, after each change ⏎ c │ │ [ Create file ] [ Design with Claude ] │ Design with Claude asks Claude, with │ /agentic-engineering:agent-development loaded and your │ words. ╰─────────────────────────────────────────────────────────── Tab next field · Enter create · Esc cancel
Pane · New skill
╭──────────────────────────────────────────────────────────╮ │ ✻ New skill │ │ Writes .claude/skills/<name>/SKILL.md from the skill │ │ template. │ │ │ │ Name: release-notes ⏎ create │ │ Description: Writes the notes for a version ⏎ create │ │ │ │ [ Create file ] [ Design with Claude ] │ │ Design with Claude asks Claude, with │ │ /agentic-engineering:skill-development loaded and your │ │ words. │ ╰──────────────────────────────────────────────────────────╯ Tab next field · Enter create · Esc cancel
Pane · New plugin
╭──────────────────────────────────────────────────────────╮ │ ✻ New plugin │ │ Your own skills and agents, in a folder you pick: a git │ │ repo, on in every session. │ │ │ │ Name: your-name ⏎ create │ │ Folder: ~/<name>-claude ⏎ create │ │ Its skills run as /<name>:<skill>: a short name reads │ │ best. │ │ │ │ [ Create plugin ] │ ╰──────────────────────────────────────────────────────────╯ Tab next field · Enter create · Esc cancel
README

agentic-engineering

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.

The seven skills

skillwhat it holdsnamed at
/agentic-engineering:context-engineeringwhat an agent's window holds, when, and in what words: any text an agent readsan edit to CLAUDE.md, a skill, an agent or a prompt; a brief to a worker
/agentic-engineering:harness-engineeringeverything 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-developmentwhether it should be a skill at all, then designing, delivering, proving and retiring onea new SKILL.md, agent or command; a plugin listing
/agentic-engineering:agent-developmentan 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:evalswhich eval or test answers which question: unit, contract, claude -p, tmux, sandbox rehearsal, eval caseswriting a test or an eval; starting Claude from a script or tmux
/agentic-engineering:sandboxingtrying 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 changedno block yet: one comes with its eval case (plugins/EVALS.md)
/agentic-engineering:fewer-faster-testsa 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 choosesno 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.

The agent

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.

/new-plugin, /new-agent and /new-skill

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
  • Live: Claude Code reads a plugin from a directory marketplace's folder, not from a copy (claude plugin update says so on 2.1.294): an edit there loads at /reload-plugins or the next session.
  • Refused: a folder with other files in it, a name another plugin or marketplace has, a bad name. A failing claude plugin step stops it with that command's last line, and no plugin is kept.
  • A folder that already holds the plugin (a clone on a new machine, a run that stopped half-way): it is added and installed, and only a missing marketplace.json is written. Nothing else in the folder is written over.
  • Names: Claude Code puts the plugin's name before each skill and agent in it (/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
  • Flags: --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.
  • Name: lowercase letters, digits and hyphens; 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.
  • The template is the skill's own _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.
  • Never overwrites: an existing file is left untouched, and the command says so.
  • Where: with a plugin of yours, in it (the same path without .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).
  • Headless (claude -p, the SDK): the flags and free text work the same; with nothing after the command, the usage.
  • Runs the moment you type it, even while the agent is mid-turn.

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.

It is a Claude Mod

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.

  • The module loads only with 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.
  • A skill or command of yours named new-agent, new-skill or new-plugin wins: Claude Code refuses the plugin's command at session start.
  • Tested on Claude Code 2.1.292; /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: what an agent can reach

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.

The seven blocks, and when each arrives

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.

blockarrives
session.txtat every start (new, resume, clear, compact, fork) and in each worker (hooks/session.py)
instruction-files.txtbefore a Write or Edit of CLAUDE.md, AGENTS.md, CLAUDE.local.md, .claude/rules/*
agent-text.txtbefore an edit of an existing SKILL.md, a skill's references/, agents/*.md, commands/*.md, a plugin's prompts/*
skills.txtbefore writing a new SKILL.md, agent or command; an edit of .claude-plugin/*.json
hooks.txtbefore an edit of hooks.json, a hook script, .claude/settings*.json
evals.txtbefore an edit in a plugin's tests/ or evals/; a claude -p, claude plugin eval, env … claude -p or tmux command
brief.txtbefore 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.

Try it

claude --plugin-dir plugins/agentic-engineering

Run it as a person's session, not -p. Then:

  • Ask it to add a line to a CLAUDE.md. The instruction-files block arrives once. Ask a subagent to do the same, and it gets the block too.
  • Ask it to run 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

See it land

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].

  • A block given as context is an attachment of type hook_additional_context.
  • A skill's real loads are Skill tool calls in the transcripts, not its line in the listing.

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

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

  • Each case sets 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.
  • Eval runs set 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.
  • Bash is left out of --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.

Source 1 files
hooks/new.tsx 410 lines
1import 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}:&lt;skill&gt;: 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