SLOPSHOPPER

prompt-enhancer

An Enhance button above the prompt: Haiku rewrites your draft to name the skills that fit, the questions Claude should ask first and what done looks like. It…

newbandcommandmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-enhancer
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /prompt-enhancer ⎿ prompt-enhancer: off (/prompt-enhancer on turns it on) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods-kit

Eight small Claude Code mods, free to use under the MIT licence. A mod is a plugin made of function hooks: Claude Code calls it at every step (a tool call, a slash command, a redraw), and it can answer, change or watch that step.

Each mod has its own on/off switch, /<mod> on, /<mod> off and /<mod> status, kept across sessions. secret-guard starts on; the other seven start off, so installing one changes nothing until you turn it on. See Switching a mod on and off.

ModWhat it doesCommand
secret-guardReads every .env and .env.* from your session's folder up to the drive root when a session starts and hides their secret values (keys named like PASSWORD, SECRET, TOKEN, API_KEY) in every tool result before Claude reads it, along with anything that looks like a secret on its own: KEY=VALUE under such a name, the password in postgres://user:REDACTED@host, private keys, JWTs, AWS keys, Kubernetes Secret data. A secret reaches Claude as ‹hidden: DB_PASSWORD›. It tells Claude, in every conversation, to use $DB_PASSWORD instead of printing the value, and it refuses the few commands that would print a whole env file or a decrypted secret./secret-guard lists the protected files and key names; /secret-guard off / on
plan-meterA one-line band above the prompt that says how far your plan is: plan ▸ Ship the export · phases 1/3 · steps 3/6 (50%) · now: API · Claude's tasks 2/5. It reads your plan file (and the phase files it links to) in many formats, and Claude's own task list. It updates when a file changes./plan-meter on / off / status; /plan-meter opens a pane with the details; /plan-meter docs/roadmap.md picks a file
done-gateWhen Claude marks a task done while code it changed has not been tested since, it tells Claude, in the tool result Claude reads, and you, in a toast. A band shows the last test run: done-gate ▸ tests ✔ passed 4 min ago · 2 files changed since. It warns; it never blocks./done-gate on / off; /done-gate status lists the changed files and the last test command
context-meterA band above the prompt with what fills the context window, by category and in /context's colours (context ▸ 90k of 1M · 9% · compacts at 987k), and a countdown to when the prompt cache expires, which turns from green through amber to red: cache ▸ 41:07 left (1h TTL, assumed: subscription). A Compact button (or c while the band has focus) runs the same compaction as /compact./context-meter on / off / status; the button
idea-shelfPark an idea while Claude works, without interrupting it: /idea try the bulk endpoint later keeps it on this project's shelf, across sessions. The band counts them (ideas ▸ 3 ideas parked [Shelf]); the shelf lists them with Send, Edit and Delete. Send puts the idea in the prompt box, or sends it when the box is empty and Claude is idle. A parked idea never reaches the model until you send it./idea <text>; /idea opens the shelf; /idea-shelf on / off / status
session-monitorSee your other Claude Code sessions from this one: other sessions ▸ 1 waiting · 2 working · 1 done [List], waiting first, in the colour for "needs you". Sessions in this same folder get their own line: also here ▸ "Fix the export" · working. Waiting means a permission dialog or a question is open./session-monitor on / off / status; /session-monitor list opens the list
prompt-enhancerAn Enhance button above the prompt: Haiku rewrites your draft so it names the skills that fit, lists what Claude should ask you first, and says what done looks like. The rewrite replaces the draft; nothing is sent until you press Enter or Send./prompt-enhancer on / off / status; the buttons
assumption-checkAfter a turn that edited files or ran commands, Haiku lists the choices Claude made that you did not state (assumptions ▸ 6 to check [Review]): a date format, a default, a library. Mark each Right or Wrong, add a note, and Send corrections puts one prompt in the box that says what to change and what to keep./assumption-check on / off / status; /assumption-check review opens the list

Tested on Claude Code 2.1.296 on Windows 11 (secret-guard, plan-meter, done-gate and context-meter first on 2.1.291 to 2.1.295). Mods are an early-access feature, so the API can change between versions; if a mod stops loading after an update, check claude plugin validate on its folder.

Try one without installing anything

git clone https://github.com/vumichien/claude-code-mods-kit.git
claude --plugin-dir claude-code-mods-kit/plugins/plan-meter

--plugin-dir loads the mod for that session only. Repeat the flag to load more than one.

Install

claude plugin marketplace add vumichien/claude-code-mods-kit
claude plugin install secret-guard@chien-mods
claude plugin install plan-meter@chien-mods
claude plugin install done-gate@chien-mods
claude plugin install context-meter@chien-mods
claude plugin install idea-shelf@chien-mods
claude plugin install session-monitor@chien-mods
claude plugin install prompt-enhancer@chien-mods
claude plugin install assumption-check@chien-mods

Start a new session afterwards. To remove one: claude plugin uninstall plan-meter@chien-mods.

Switching a mod on and off

Every mod answers /<mod> on, /<mod> off and /<mod> status (a bare /<mod> is status). The switch is kept in the mod's own store, so it holds in every later session, in every folder, until you change it.

  • Off means off. Every hook passes its event on unchanged: no band, no pane, no file written, no model call, no note to Claude, no timer. The mod still registers its command, so that you can turn it on.
  • secret-guard starts on, because a guard that has to be remembered protects nothing. Off, the status line says secret-guard OFF: secrets not hidden, and its note to Claude and the hiding both stop.
  • The other seven start off. Turn on the ones you want: /plan-meter on, /idea-shelf on, and so on.
  • claude plugin disable <mod>@chien-mods still unloads a mod entirely, and each one's tests include one that feeds every hooked event to the mod while it is off and finds each one passed on unchanged.

Up to version 0.2 of plan-meter, done-gate and context-meter, on and off only showed or hid the band for the session; now they switch the whole mod, and the choice stays.

Options

Every option has a default, so all eight mods work without any. plan-meter, done-gate and context-meter share one: band, on (default) or off. With off, a mod that is switched on keeps working (the plan is read, done-gate still tells Claude, context-meter still measures) but draws no band. To change one, use /plugin configure <name>@chien-mods inside Claude Code, pass --config key=value to claude plugin install, or pipe a JSON object to claude plugin configure <name>@chien-mods --values-stdin. With --plugin-dir, put them in a settings file: --settings '{"pluginConfigs":{"done-gate":{"options":{"testCommands":"make ci"}}}}'.

secret-guard

  • mode: value (default) hides secrets in results and refuses the commands listed below. command instead refuses any call whose command or path names a protected env file, except to load it (source .env, --env-file .env), without reading values or changing results; it is simpler, but it blocks harmless commands and misses reads that don't name the file.
  • secretFiles (default .env, .env.*, !*.example): comma-separated globs of the env files to read. A file name is looked for in the session's folder and every folder above it, up to the drive root. A path is read where it points: ~/vault//.env (from your home folder; goes up to 8 folders deep and skips node_modules, .git, .venv, venv and __pycache__), an absolute path, or one relative to the session's folder. !glob leaves files out. Each file must be in .env format.
  • secretKeys: comma-separated key names to treat as secrets on top of the built-in rule. The rule: a key is a secret when a part of its name (split at _, -, . and camelCase) is PASS, PASSWD, PASSWORD, PW, PWD, SECRET, TOKEN, KEY, DSN, CREDENTIAL or PRIVATE, or ends with one of the first seven (APIKEY, DBPASS). So DB_PASSWORD, apiKey and AWS_SECRET_ACCESS_KEY are secrets; DB_NAME, DB_HOST, ACCOUNT_ID, MAX_TOKENS, TOKENIZER_PATH and the shell's own PWD are not.
  • identifierKeys: comma-separated keys that match the rule but hold names, not secrets (KMS_KEY_ID, SSH_KEY_NAME). They are never hidden.

Values shorter than 8 characters are never hidden (except the password in a URL such as postgres://user:REDACTED@host, which is always hidden), so PW=1 or TOKEN_TTL=60 does not mask every 1 or 60. The values of non-secret keys are never hidden at all, so database names, hosts, users and account ids stay readable.

In value mode it refuses these commands, each time naming a way to do the same without printing the secret: cat, type, Get-Content, less, more, head, tail or bat of a protected file (cat .env | cut -d= -f1 is allowed, it prints names); a bare env, printenv, set or Get-ChildItem env:; and, unless the output goes to a file or a variable (> out.json, VALUE=$(...)), aws ssm ... --with-decryption, aws secretsmanager get-secret-value and kubectl get secret ... -o yaml|json. It never refuses loading a file: source .env, . .env, set -a, --env-file .env.

Every conversation starts with a short # secret-guard note to Claude: the protected files and key names (names only), what ‹hidden: NAME› means, and the convention (reference $NAME, let scripts load the env file, never print a secret, report only whether a command worked). So you no longer need to remind each session. The note is rebuilt after a compaction or /clear. Messages Claude sends to another agent or session (SendMessage) are scrubbed like tool results. So is what Claude Code attaches to a message on its own, which never passes through a tool call: a file Claude read, attached again after a compaction; a file changed on disk; a file you @-mention; a settings hook's output.

plan-meter

  • plan: comma-separated paths, relative to the project, tried in order; the first one that matches a file wins. A * in any part matches anything, and among several matches the most recently changed file wins. Default: plans/*/plan.md, PLAN.md, plan.md, TODO.md, TASKS.md, ROADMAP.md, todo.txt, TODO.org.
  • refreshSeconds (default 15, at least 5): how often the plan is read again, so an edit you make in your own editor shows up too. Edits Claude makes show up at once.

The band shows only when there is a plan or a task list. The pane draws in the terminal and the desktop app; under claude -p, /plan-meter answers with the band's line. (The command was /plan up to 0.1.0; Claude Code 2.1.294 has a built-in /plan, which refused the name.)

context-meter

  • cacheTtl: auto (default), 5m or 1h. A mod cannot read the cache lifetime Claude Code asks for, so auto follows Claude Code's defaults: one hour on a Claude subscription (the session reports rate-limit windows), five minutes with an API key or a cloud provider. Set it when you know better: you set promptCacheTtl or ENABLE_PROMPT_CACHING_1H, or you are drawing on usage credits, where Claude Code drops to five minutes. The band always says which lifetime it assumed and why.
  • breakdown: summary (default) estimates the categories locally and sends nothing. full counts them with the token-count API after every turn, as /context does: more exact, one request per tool and memory file.

Every request of the main conversation that hits the cache resets its timer, so the clock restarts at each model request, from the moment it was sent, not only when a turn ends. While Claude works the band says the cache is being kept warm; the countdown runs between turns, from the last request. A subagent's requests have caches of their own and are left out. After a compaction it starts again with the next message. The button is hidden while a turn runs and before the conversation's first reply, when Claude Code refuses a compaction ("Not enough messages to compact"); if a compaction is refused or a hook vetoes it, the band says why, and so does a toast. If you set CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, which can only bring auto-compaction earlier, the header shows that point and marks it as your setting (compacts at 500k (your 50% setting)), since the breakdown Claude Code returns may still give its default; that one environment variable is all context-meter reads. In the desktop app and the IDE extensions, which run Claude Code through its SDK, Claude Code cannot compact between turns yet, so there the button runs /compact as if you had typed it. Below the bar, every category and the free part get a coloured entry with their tokens and share of the window, wrapped onto as many rows as they need. Compacting is a model call: the band costs no tokens, the button does. With little room above the prompt the band keeps two rows, the fill and the cache clock.

done-gate

  • testCommands: comma-separated commands that run your tests, added to the usual runners. Example: ./scripts/check.sh, make ci. Each one counts when it is the command itself, with or without arguments, so check does not match git checkout.
  • ignore (default .md,.mdx,.markdown,.txt,.rst,.adoc,.org): file endings whose changes need no test run.

The usual runners it knows: pytest, python -m pytest or unittest, npm/pnpm/yarn/bun test or run test, vitest, jest, mocha, go test, cargo test, cargo nextest, mvn test or verify, gradle test, dotnet test, rspec, phpunit, mix test, swift test, ctest, make test or check, tox, nox, deno test, claude plugin test. A runner counts when it is a command in the line, after &&, ; or cd api && and behind FOO=1, npx, uv run or poetry run; a runner named inside an argument (echo pytest, git commit -m "fix pytest") does not.

idea-shelf, session-monitor, prompt-enhancer and assumption-check have no options.

The four newer mods: what each reads, sends and costs

Each paragraph ends with the mod's calls: line from claude plugin validate: every call it makes into Claude Code. Costs are what the author's checks measured on Claude Code 2.1.296 with Haiku 5.5, at Anthropic's list prices; on a Claude subscription the same calls count against your usage instead.

idea-shelf. /idea <text> runs at once, even while Claude works, and does not interrupt the turn. The shelf is one list per project (the session's root folder), at most 200 ideas of 2,000 characters, in the mod's store on your machine. It sends nothing to the model. The line /idea <text> leaves in the conversation would carry the idea to the model, so idea-shelf rewrites that line before it is stored: the model and the transcript read /idea (an idea, kept on the shelf); the shelf keeps the text. In the author's check, a word typed after /idea was absent from the messages the model reads before and after. An idea reaches the model only when you send it, and then it arrives as a prompt "from the idea-shelf plugin". The shelf's text box is in its pane, so it draws in the terminal and the desktop app; elsewhere /idea <text> still parks. Cost: none. calls: $.clock.now, $.command.register, $.prompt.fill, $.prompt.read, $.prompt.submit, $.session.root, $.state.get, $.state.set, $.store.get, $.store.set, $.ui.open, $.ui.resolve

session-monitor. Each session that has it on writes one small file, ~/.cache/claude-mods/session-monitor/<session id>.json: the session id, its folder, its state (working, waiting or done), when that state began, when the file was written, and a title, which is the title Claude Code gave the session, else the folder's name. No prompt or command text. The file is rewritten at every change of state and every 15 seconds, and each session reads the others' files on the same beat; that is the mod's only reach beyond its own session. Working runs from a prompt to the end of the turn; waiting, while a permission dialog is open or Claude's question waits for you (in auto mode the classifier answers most permission checks, so waiting is brief there); done, otherwise. A closing session marks its file ended; a file not rewritten for 90 seconds (a killed session) is skipped. Mods cannot delete files, so the files stay, one per session, about 240 bytes each: delete the folder whenever you like. It sends nothing to the model. Cost: none. calls: $.clock.every, $.clock.now, $.command.register, $.env.get (HOME, USERPROFILE), $.fs.list, $.fs.read, $.fs.write, $.session.id, $.session.root, $.state.get, $.state.set, $.store.get, $.store.set, $.ui.open, $.ui.resolve

prompt-enhancer. Nothing happens until you press Enhance (e after Ctrl+X, Tab gives the band the keyboard). Then it sends Haiku one request holding your draft, the name and the first 100 characters of the description of each skill and command your session has (built-in commands left out), and the first 40 lines of the project's CLAUDE.md. It never reads .env files. It sends nothing when the draft is empty, longer than 4,000 characters, or holds secret-guard's ‹hidden: …› placeholder or a value shaped like a key (a private key, sk-…, AKIA…, ghp_…, xox?-…, a JWT); the band says why. A rewrite arrives in a few seconds and replaces the draft. If you edited the draft meanwhile, your edit wins and the rewrite is dropped. After 20 seconds it gives up and the draft stays. It never submits: Enter or Send (n) does. A command cannot read the prompt box, so Enhance is a button only. Cost: one press with about 150 skills and commands in the session took 6,615 input and 661 output tokens, about $0.001; the band and /prompt-enhancer status show each press's tokens. calls: $.command.list, $.command.register, $.fs.read (CLAUDE.md), $.model.complete, $.prompt.fill, $.prompt.read, $.prompt.submit, $.session.root, $.state.get, $.state.set, $.store.get, $.store.set, $.ui.resolve

assumption-check. It counts the edits and commands of each turn. When a turn that edited a file or ran a command ends (not one that only read, nor one you interrupted), it sends Haiku one request: that turn's messages, from your last prompt on, each tool call's input cut to 1,500 characters, the whole at most 30,000. These are messages the model already read, after secret-guard (if on) hid what it hides. It asks for at most 8 assumptions as JSON, and asks once more if the answer does not parse. The check starts after the turn has ended, so the turn never waits for it. The list lives for the session only. Send corrections puts a prompt in the box ("You assumed: … That is wrong: <your note>", and the ones you marked right, to keep); pressed again, it sends that prompt, but never while Claude is working. In the author's check, Claude wrote a date parser that read 3/4/2026 day first; the list named that choice first; marked wrong with the note "our users are in the US", the correction changed the two date formats and nothing else. It lists what it finds; it does not prove the list complete. Cost: a median of 1,176 input and 1,339 output tokens per checked turn over 13 checks, about $0.0008; /assumption-check status shows the last one. calls: $.clock.after, $.command.register, $.model.complete, $.prompt.fill, $.prompt.read, $.prompt.submit, $.session.messages, $.state.get, $.state.set, $.store.get, $.store.set, $.ui.open, $.ui.resolve

Plan formats plan-meter reads

plan-meter does not ask you to write your plan its way. It recognises these, alone or mixed in one file:

FormatExampleWhat it counts
Checklist- [x] write the schemaOne step per item. Bullets -, *, +, 1., 1). Marks: x or X done; / or ~ in progress (Obsidian); - cancelled, left out of the count; a space, >, <, ! or ? still to do.
Status table`\Phase \Name \Status \ with a row \2 \API \🚧 In progress \`One phase per row. The status column is the one headed Status, State, Progress, Done or Done?, Trạng thái, Tình trạng, ステータス or 状態. The name comes from a Name, Title, Task, Item, What, Deliverable, Tên or Công việc column, else Phase, Step, Milestone, Stage or Giai đoạn, else the first cell with words in it (so a Phase column holding only 2 is skipped).
Headings as phases## Phase 2: API (in progress), ## Step 3 ✅Used when the file has no status table. A heading named Phase, Step, Stage, Milestone, Sprint, Part or Task with a number (also Giai đoạn 1, Bước 2, フェーズ1) is a phase. Its status comes from a mark in it, a status at its end ((done), [WIP], — done, : in progress), the checklist beneath it (all ticked is done, some ticked is in progress), or the phase file it links to. Any other heading counts only when it ends with a status alone: ## Setup (done).
Linked phase filesPhase 1Links in the plan (in text, tables or headings) to Markdown files whose name starts with phase, step, stage, milestone, sprint, part or task and goes on with a number or a dash: phase-01-schema.md, phase1.md, steps.md, part_2.md, but not department.md. Relative to the plan's folder, up to 30; a #section part is ignored, and two links to one file count it once. Their checklists add to the steps. A phase whose status the plan leaves blank or unknown takes the file's status from its frontmatter or status line, else from its checklist; a status the plan states, such as Pending, wins. A plan with no phases of its own takes one phase per linked file.
YAML frontmattertitle: Ship the export / status: in_progressThe plan's title and its own status.
Status lineStatus: Draft, phase 3 next, Status: …, Status (2026-10-07): …The plan's own status, shown as written when nothing in the file can be counted.
org-mode* TODO write the schema, ** DONE testsOne step per headline. DONE done; DOING, IN-PROGRESS, STARTED, WAITING, HOLD in progress; CANCELLED left out; TODO, NEXT to do.
todo.txtx 2026-10-01 call the bankA file named todo.txt (or *.todo.txt): one step per line, x at the start is done.

Status words it understands in tables, headings, status lines and frontmatter, in English, Vietnamese and Japanese. When a cell holds several, the first one wins, so Done (review pending) is done and Not started is to do.

  • Done: done, complete, completed, finished, shipped, merged, closed, resolved, delivered, passed, xong, hoàn thành, 完了, ✅ ✔ ☑ ✓ [x]
  • In progress: in progress (also in-progress, in_progress), WIP, doing, ongoing, active, started, running, in review, reviewing, blocked, đang, đang làm, 進行中, 🚧 🔄 ⏳ ▶
  • To do: not started, not done, not yet, incomplete, unfinished, todo, to do, pending, planned, backlog, queued, open, chưa, chưa làm, chưa xong, 未着手, ⬜ ☐ [ ]
  • Dropped, left out of the count: cancelled, canceled, dropped, won't do, won't fix, wontfix, skipped, obsolete, abandoned, n/a, hủy, bỏ qua, 中止, 🚫

The title is the frontmatter title:, else the first # heading (a leading Plan: is dropped). Anything inside a fenced code block is skipped.

Claude's own tasks. When Claude keeps a task list (its TodoWrite, TaskCreate and TaskUpdate tools), the band adds Claude's tasks done/total, and the

Source 4 files
hooks/register.tsx 107 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { TIMEOUT_MS, buildPrompt, costLine, refusal, rewriteOf } from './enhance'
5import { STORE_KEY, storedSwitch, switchText, switchWord } from './toggle'
6
7// The switch as this session read it (toggle.ts). Off, the band is not drawn and nothing reads the draft or calls
8// the model. The mod starts off; `/prompt-enhancer on` turns it on for every session.
9const enabled = atom({ plugin: 'prompt-enhancer', key: 'enabled' } as const, false)
10const isBusy = atom({ plugin: 'prompt-enhancer', key: 'isBusy' } as const, false)
11const note = atom({ plugin: 'prompt-enhancer', key: 'note' } as const, null as string | null)
12const lastCost = atom({ plugin: 'prompt-enhancer', key: 'lastCost' } as const, null as string | null)
13
14// Enhance: one Haiku call over the draft, the session's skills and commands, and the top of CLAUDE.md. The rewrite
15// replaces the draft in the prompt box and is never submitted. A command cannot read the draft (running one empties
16// the box first), so this is a button only.
17async function enhance($: any): Promise<void> {
18  if (await read($, isBusy)) return
19  const draft = (await $.prompt.read()).text
20  const refused = refusal(draft)
21  if (refused !== undefined) {
22    await update($, note, () => refused)
23    return
24  }
25  await update($, isBusy, () => true)
26  await update($, note, () => 'Haiku is rewriting the draft…')
27  try {
28    const commands = await $.command.list().catch(() => [])
29    const root = await $.session.root()
30    const claudeMd = await $.fs.read(`${root}/CLAUDE.md`).catch(() => '')
31    const answer = await $.model.complete({ model: 'haiku', prompt: buildPrompt(draft, commands, typeof claudeMd === 'string' ? claudeMd : ''), maxTokens: 1200, timeoutMs: TIMEOUT_MS })
32    if (!answer.isAnswered) {
33      const why = answer.reason === 'aborted' ? `no answer within ${TIMEOUT_MS / 1000} s` : answer.reason === 'empty-reply' ? 'the model answered nothing' : `the model call failed (${answer.status ?? 'no response'})`
34      await update($, note, () => `${why}; the draft is unchanged`)
35      return
36    }
37    const cost = costLine(answer.usage)
38    await update($, lastCost, () => cost)
39    // A draft edited while Haiku worked is the author's newer word: it is kept, not overwritten.
40    if ((await $.prompt.read()).text !== draft) {
41      await update($, note, () => `the draft changed while Haiku worked, so it was kept; press Enhance again · ${cost}`)
42      return
43    }
44    const filled = await $.prompt.fill({ text: rewriteOf(answer.text), mode: 'replace' })
45    await update($, note, () => (filled.isFilled ? `rewritten · ${cost}. Edit it, then Send or Enter` : `the prompt box could not take the rewrite now · ${cost}`))
46  } finally {
47    await update($, isBusy, () => false)
48  }
49}
50
51// Send: the draft as it stands goes as the author's own prompt, and the box is emptied.
52async function send($: any): Promise<void> {
53  const draft = (await $.prompt.read()).text
54  if (draft.trim() === '') {
55    await update($, note, () => 'nothing to send')
56    return
57  }
58  await $.prompt.fill({ text: '', mode: 'replace' })
59  await $.prompt.submit({ text: draft, asUser: true })
60  await update($, note, () => null)
61}
62
63export const register: Register = on => {
64  on('session.start', async ($, e, next) => {
65    // Registered even when the mod is off, so that it can be turned on.
66    await $.command
67      .register({ name: 'prompt-enhancer', description: 'Switch the Enhance band on or off, or show what the last rewrite cost', argumentHint: '[on|off|status]', immediate: true })
68      .catch(() => undefined)
69    const isOn = storedSwitch(await $.store.get(STORE_KEY).catch(() => undefined), false)
70    await update($, enabled, () => isOn)
71    return next(e)
72  })
73
74  on('command.run', { command: 'prompt-enhancer' }, async ($, e) => {
75    const word = switchWord(e.args)
76    if (word === undefined) return { text: 'use /prompt-enhancer on, /prompt-enhancer off or /prompt-enhancer status' }
77    if (word === 'status') {
78      if (!(await read($, enabled))) return { text: switchText('prompt-enhancer', false) }
79      const cost = await read($, lastCost)
80      return { text: `${switchText('prompt-enhancer', true)} · ${cost === null ? 'no rewrite yet this session' : `last rewrite: ${cost}`}` }
81    }
82    const isOn = word === 'on'
83    await $.store.set(STORE_KEY, isOn)
84    await update($, enabled, () => isOn)
85    return { text: switchText('prompt-enhancer', isOn) }
86  })
87
88  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
89    const below = await next(e)
90    if (!(await read($, enabled)) || e.props.hasSurvey) return below
91    const { Box, Button, Text } = $.ui.resolve(e)
92    const busy = await read($, isBusy)
93    const said = await read($, note)
94    return (
95      <Box flexDirection="column">
96        <Box>
97          <Text dimColor>{'prompt ▸ '}</Text>
98          <Button key="enhance" label={busy ? 'Enhancing…' : 'Enhance'} hotkey="e" onPress={() => enhance($)} />
99          <Button key="send" label="Send" hotkey="n" onPress={() => send($)} />
100          {said !== null && <Text dimColor wrap="truncate">{` ${said}`}</Text>}
101        </Box>
102        {below}
103      </Box>
104    )
105  })
106}
107
hooks/enhance.ts 68 lines
1// prompt-enhancer's pure parts: when a draft may go to the model, what the model is asked, and how its answer
2// and its cost are read back.
3
4export const MAX_DRAFT = 4000
5export const TIMEOUT_MS = 20_000
6export const CLAUDE_MD_LINES = 40
7const DESCRIPTION_CHARS = 100
8
9// A draft holding a secret never leaves the session: secret-guard's placeholder, or a value shaped like a key.
10const SECRET_SHAPES: readonly RegExp[] = [
11  /‹hidden:/,
12  /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
13  /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/,
14  /\bAKIA[0-9A-Z]{16}\b/,
15  /\bgh[pousr]_[A-Za-z0-9]{36,}/,
16  /\bxox[abprs]-[A-Za-z0-9-]{10,}/,
17  /\beyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/,
18]
19
20// Why a draft is not sent to the model, or undefined when it may be.
21export function refusal(draft: string): string | undefined {
22  if (draft.trim() === '') return 'nothing to enhance: type a draft first'
23  if (draft.length > MAX_DRAFT) return `too long to enhance: ${draft.length} characters, at most ${MAX_DRAFT}`
24  if (SECRET_SHAPES.some(shape => shape.test(draft))) return 'not sent: the draft holds a secret or a hidden-value marker'
25  return undefined
26}
27
28export type Command = { name: string; description: string; source: string }
29
30// The ask: the draft, the skills and commands this session has (built-in ones left out), and the top of the
31// project's CLAUDE.md, so the rewrite can name what fits and what the project asks for.
32export function buildPrompt(draft: string, commands: readonly Command[], claudeMd: string): string {
33  const list = commands
34    .filter(c => c.source !== 'builtin')
35    .map(c => `/${c.name}: ${c.description.replace(/\s+/g, ' ').slice(0, DESCRIPTION_CHARS)}`)
36    .join('\n')
37  const head = claudeMd.split('\n').slice(0, CLAUDE_MD_LINES).join('\n')
38  return `Rewrite the draft prompt below so Claude Code can act on it well. Name the skills or commands from the list that fit (at most 3), list the decisions Claude should ask about before starting, and say what done looks like. Keep the author's intent and language; do not invent requirements. Reply with the rewritten prompt only.
39
40<commands>
41${list}
42</commands>
43
44<project-instructions>
45${head}
46</project-instructions>
47
48<draft>
49${draft}
50</draft>`
51}
52
53// The rewrite as it goes into the prompt box: trimmed, and out of a code fence if the model put it in one.
54export function rewriteOf(reply: string): string {
55  const text = reply.trim()
56  const fenced = /^```[\w-]*\n([\s\S]*?)\n```$/.exec(text)
57  return (fenced ? fenced[1]! : text).trim()
58}
59
60export type Usage = { input_tokens: number; output_tokens: number }
61
62const thousands = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
63
64// What one press cost, in the tokens the model call reports; the README converts them at list prices.
65export function costLine(usage: Usage): string {
66  return `${thousands(usage.input_tokens)} in · ${thousands(usage.output_tokens)} out tokens (haiku)`
67}
68
hooks/toggle.ts 24 lines
1// The mod's on/off switch, `/<mod> on|off|status`. The value is kept in $.store under STORE_KEY: one store per
2// plugin, shared by every session and folder, so the switch holds across restarts. A session reads it as it
3// starts and when its own command changes it; a session already open elsewhere picks a change up when it next
4// starts. Pure helpers only: `claude plugin validate` follows `$` only within the file that uses it.
5export const STORE_KEY = 'enabled'
6
7export type SwitchWord = 'on' | 'off' | 'status'
8
9// The command's argument as a switch word: '' and 'status' ask, 'on' and 'off' set; anything else is the mod's own.
10export function switchWord(args: string | undefined): SwitchWord | undefined {
11  const word = (args ?? '').trim().toLowerCase()
12  if (word === '' || word === 'status') return 'status'
13  return word === 'on' || word === 'off' ? word : undefined
14}
15
16// The stored value, or the mod's default when it was never set (or holds something else).
17export function storedSwitch(value: unknown, byDefault: boolean): boolean {
18  return typeof value === 'boolean' ? value : byDefault
19}
20
21export function switchText(mod: string, isOn: boolean): string {
22  return isOn ? `on (/${mod} off turns it off)` : `off (/${mod} on turns it on)`
23}
24
types/index.d.ts 11 lines
1// What one press cost, as the band shows it.
2export type CostLine = string
3
4declare module 'claude-code' {
5  interface PluginState {
6    // enabled: the mod's on/off switch as this session read it from the store, or as /prompt-enhancer on|off set it.
7    // isBusy: a rewrite is on its way. note: what the last press did. lastCost: the tokens the last rewrite took.
8    'prompt-enhancer': { enabled: boolean; isBusy: boolean; note: string | null; lastCost: CostLine | null }
9  }
10}
11