Push, pop and list prompt drafts in files under ~/.claude/prompt-stack, from a key-opened menu

Personal dotfiles. Managed with GNU Stow + mise. Repo layout mirrors ~/ — stow symlinks files into place.
.
├── .config/ # XDG configs: nvim, helix, fish, git, zellij, ghostty, atuin, direnv, nix, sketchybar, worktrunk
├── .claude/ # Claude Code project-local overrides (settings.local.json)
├── .zshrc, .zshrc_* # Modular zsh config (aliases, git, go, mise, fish, ...)
├── .tmux.conf
├── .wezterm.lua
├── .aerospace.toml
├── .mise.toml # mise tasks (incl. unified `stow`)
├── .mcp.json # global MCP server config
├── .stow-local-ignore # exclusions for repo-root stow package
│
├── harness/ # Per-harness packages (separate stow targets)
│ ├── claude/ # → ~/.claude (settings.json, hooks, agents, commands, scripts, skills, CLAUDE.md, RTK.md, agent-settings)
│ ├── plugins/ # Plugin source dirs (wm, self-improvement); NOT stowed directly
│ └── scripts/ # sync-marketplace.sh — regenerates /.claude-plugin/marketplace.json + /plugins/ symlinks
│
├── ansible/ # Package installation playbook (run in-place)
├── scripts/ # Misc shell scripts (not stowed)
├── hammerspoon/ # macOS automation Lua
├── raycast/ # Raycast extensions
├── alacritty/, vscode/ # legacy app configs
└── .notes/ # WM notes (gitignored runtime)
| Source | Target | Contents |
|---|---|---|
| repo root | ~ | shell, .config/*, .tmux.conf, etc. (gated by .stow-local-ignore) |
harness/claude | ~/.claude | Claude Code user config + plugin marketplace |
Prerequisites: nix, mise, stow, ansible.
# 1. Nix-darwin (Mac system packages)
nix run nix-darwin -- switch --flake .config/nix-darwin
# 2. Other packages via ansible
ansible-playbook ansible/install_packages.yaml
# 3. Symlink everything into ~ (repo root + harness/claude)
mise run stow
To remove all symlinks:
mise run unstow
Two registries, both in harness/claude/settings.json (stowed to ~/.claude/settings.json):
extraKnownMarketplaces — where plugins come from. This repo's own plugins ship via the local-plugins directory marketplace (source.path = repo root, so Claude reads /.claude-plugin/marketplace.json directly). External plugins point at GitHub repos.enabledPlugins — which plugins are on. Each entry is "<name>@<marketplace>": true|false.// harness/claude/settings.json
"extraKnownMarketplaces": {
"ponytail": { "source": { "source": "github", "repo": "DietrichGebert/ponytail" } }
},
"enabledPlugins": {
"ponytail@ponytail": true, // external plugin
"wm@local-plugins": true // local plugin from this repo
}
Then mise run stow (to symlink the edited settings) and /reload-plugins in Claude Code — or do both steps interactively with /plugin. After enabling, the first launch caches the plugin under ~/.claude/plugins/cache/.
Local plugins are disabled by default in committed settings (
wm@local-plugins: false) — flip totrueto turn one on.
Each plugin directory under harness/plugins/<name>/ is the plugin root — markdown only (agents, commands, hooks, skills), no claude/ wrapper, no build step. harness/scripts/sync-marketplace.sh (run via mise run harness:plugins:sync) regenerates /.claude-plugin/marketplace.json with one entry per plugin, source: ./harness/plugins/<name> pointing directly at the dir — no symlink layer. Edit the source, re-sync, then /reload-plugins.
hooks/register.tsx 147 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { MenuView, StackEntry } from '../types'
5
6const MENU_TRIGGER = '\u{E000}'
7const PREVIEW_LENGTH = 60
8const LIST_LIMIT = 9
9
10const draft = atom({ plugin: 'prompt-stack', key: 'draft' } as const, null)
11const view = atom({ plugin: 'prompt-stack', key: 'view' } as const, 'menu')
12const entries = atom({ plugin: 'prompt-stack', key: 'entries' } as const, [])
13
14type MenuKey = 'push' | 'pop' | 'list'
15
16const MENU: readonly { key: MenuKey; hotkey: string; label: string }[] = [
17 { key: 'push', hotkey: 'u', label: 'push: save the draft on the stack' },
18 { key: 'pop', hotkey: 'o', label: 'pop: restore the newest draft' },
19 { key: 'list', hotkey: 'l', label: 'list: pick a saved draft' },
20]
21
22const firstLine = (text: string): string =>
23 (text.split('\n').find(line => line.trim() !== '') ?? '').slice(0, PREVIEW_LENGTH)
24
25const boxWith = (text: string) => ({ text, cursor: text.length })
26
27export const register: Register = on => {
28 on('session.start', async ($, e, next) => {
29 await update($, draft, () => null)
30 return next(e)
31 })
32
33 on('prompt.edit', async ($, e, next) => {
34 const pending = await read($, draft)
35 const isTrigger = e.inputText.includes(MENU_TRIGGER)
36
37 if (pending === null) {
38 if (!isTrigger) return next(e)
39 await update($, draft, () => e.text)
40 await update($, view, (): MenuView => 'menu')
41 return boxWith('')
42 }
43
44 const closeMenu = async (text: string) => {
45 await update($, draft, () => null)
46 return boxWith(text)
47 }
48
49 if (isTrigger) return closeMenu(pending)
50
51 const stackDir = async () => `${await $.env.get('HOME')}/.claude/prompt-stack`
52
53 const loadEntries = async (): Promise<StackEntry[]> => {
54 const dir = await stackDir()
55 if (!(await $.fs.exists(dir))) return []
56 const names = (await $.fs.list(dir))
57 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.md'))
58 .map(entry => entry.name)
59 .sort()
60 .reverse()
61 .slice(0, LIST_LIMIT)
62 return Promise.all(
63 names.map(async name => {
64 const file = `${dir}/${name}`
65 return { file, preview: firstLine(await $.fs.read(file)) }
66 }),
67 )
68 }
69
70 const restoreEntry = async (file: string) => {
71 const saved = await $.fs.read(file)
72 await $.process.run(['rm', '-f', file])
73 return closeMenu(pending === '' ? saved : `${saved}\n\n${pending}`)
74 }
75
76 if ((await read($, view)) === 'list') {
77 const picked = (await read($, entries))[Number(e.inputText) - 1]
78 return picked === undefined ? closeMenu(pending + e.inputText) : restoreEntry(picked.file)
79 }
80
81 const runMenu: Record<MenuKey, () => Promise<{ text: string; cursor: number }>> = {
82 push: async () => {
83 if (pending.trim() === '') {
84 $.ui.toast('prompt-stack: the draft is empty, nothing to push')
85 return closeMenu('')
86 }
87 await $.fs.write(`${await stackDir()}/${await $.clock.now()}.md`, pending)
88 $.ui.toast('prompt-stack: draft pushed')
89 return closeMenu('')
90 },
91 pop: async () => {
92 const [newest] = await loadEntries()
93 if (newest === undefined) {
94 $.ui.toast('prompt-stack: the stack is empty')
95 return closeMenu(pending)
96 }
97 return restoreEntry(newest.file)
98 },
99 list: async () => {
100 const loaded = await loadEntries()
101 await update($, entries, () => loaded)
102 await update($, view, (): MenuView => 'list')
103 return boxWith('')
104 },
105 }
106
107 const item = MENU.find(row => row.hotkey === e.inputText)
108 return item === undefined ? closeMenu(pending + e.inputText) : runMenu[item.key]()
109 })
110
111 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
112 const pending = await read($, draft)
113 if (pending === null) return next(e)
114
115 const { Box, Text } = $.ui.resolve(e)
116 const cancelHint = <Text dimColor>any other key: close and restore the draft</Text>
117
118 if ((await read($, view)) === 'list') {
119 const list = await read($, entries)
120 return (
121 <Box flexDirection="column">
122 {list.length === 0 && <Text dimColor>The stack is empty.</Text>}
123 {list.map((entry, index) => (
124 <Text>
125 {index + 1}: {entry.preview || '(blank draft)'}
126 </Text>
127 ))}
128 {cancelHint}
129 </Box>
130 )
131 }
132
133 const preview = firstLine(pending)
134 return (
135 <Box flexDirection="column">
136 <Text dimColor>Draft: {preview === '' ? '(empty)' : preview}</Text>
137 {MENU.map(row => (
138 <Text>
139 {row.hotkey} {row.label}
140 </Text>
141 ))}
142 {cancelHint}
143 </Box>
144 )
145 })
146}
147types/index.d.ts 13 lines1export type StackEntry = { file: string; preview: string }
2export type MenuView = 'menu' | 'list'
3
4declare module 'claude-code' {
5 interface PluginState {
6 'prompt-stack': {
7 draft: string | null
8 view: MenuView
9 entries: StackEntry[]
10 }
11 }
12}
13