SLOPSHOPPER

prompt-stack

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

newbandtoastpromptprocess
★ 2v0.2.0Apache-2.0updated 2026-10-06popoffvg/dotfiles/harness/plugins/prompt-stack
A shopper browsing a rack in a slop shop
README

dotfiles

Personal dotfiles. Managed with GNU Stow + mise. Repo layout mirrors ~/ — stow symlinks files into place.

Structure

.
├── .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)

Stow targets

SourceTargetContents
repo root~shell, .config/*, .tmux.conf, etc. (gated by .stow-local-ignore)
harness/claude~/.claudeClaude Code user config + plugin marketplace

Install

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

Claude Code plugins

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.

Install a plugin

// 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 to true to turn one on.

Author a local plugin (in this repo)

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.

Source 2 files
hooks/register.tsx 147 lines
1import { 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}
147
types/index.d.ts 13 lines
1export 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