SLOPSHOPPER

handily

Every handily mod in one install, and /handily to show which mods are installed and loaded.

newrowscommand
v0.3.0MITupdated 2026-10-09niksavis/handily/mods/handily
A shopper browsing a rack in a slop shop
README

handily

Mods for Claude Code: panes, bands and quiet rows that show your work, your tasks and your sessions. They work with any tracker (basicly, beads, br, beans, or your own through a CLI adapter), and they need no harness.

Mods show and ask. They never enforce. Rules that must hold for every agent belong in git hooks, not in a Claude Code mod.

Status: in development. No mod is released yet. See docs/design.md.

Mods (planned)

ModWhat you see
workitemsNothing. It is the provider that reads work items for the other mods
quiet-itemsOne short row in place of a raw tracker file write
task-paneClaude's task list in a sidebar. You can add and remove tasks
session-boardAll local Claude sessions: task, worktree, time worked, estimate
item-toastsA toast when a work item changes outside your session
agent-boardA pane with what each subagent of this session does
simple-viewOne concise row per Bash, Edit and Write call, with buttons to open and copy its output; /simple switches it
reply-viewLong replies fold after 12 lines, tables draw without box lines, and tables and code blocks get copy buttons; /replies switches it
handilyThe bundle of all the mods above. /handily shows which mods loaded

Install

Type this at the prompt of a Claude Code session in a terminal. It adds the marketplace and installs every mod. The handily plugin is a bundle: it lists each mod as a dependency, and Claude Code installs and enables them with it.

/plugin install handily --marketplace niksavis/handily

When the marketplace is already added, this line is enough:

/plugin install handily@handily

Each install line opens the details of the plugin. Select Install, then close the panel.

Then type /handily. It lists each mod with its version and whether it loaded, and it ends with one line such as handily: 8 of 8 mods loaded. When a mod is missing, the list gives the line that installs it.

To update, run this line in a shell, then restart Claude Code. claude plugin update takes one plugin, and the bundle install does not update a mod that is already installed, so the line updates the bundle and each mod in turn. A mod that came into the bundle after your install, such as agent-board, simple-view or reply-view, fails the update with Plugin "<name>" is not installed, and the line installs it by its name instead. It then stays until you uninstall it by name. The line is safe to run again.

claude plugin marketplace update handily; for p in handily workitems quiet-items task-pane session-board item-toasts agent-board simple-view reply-view; do claude plugin update "$p@handily" || claude plugin install "$p@handily"; done

An update keeps the folder of the old version in ~/.claude/plugins/cache/handily/. Claude Code loads only the version that claude plugin list shows.

Claude Code does not load the bundle when one of its mods is disabled. Then /handily is not available, and /plugin shows which mod to enable. claude plugin disable refuses to disable a mod while the bundle is enabled, so disable the bundle first.

To install one mod only, use its name, for example /plugin install quiet-items@handily. A mod that needs workitems installs it too. You can also open /plugin and pick the mods from the handily marketplace.

To remove the bundle, type the first line in Claude Code and the second line in a shell. The uninstall leaves the mods in place. claude plugin prune then removes each mod that Claude Code installed only for the bundle. A mod that you installed by its own name stays until you uninstall it by name.

/plugin uninstall handily@handily
claude plugin prune

The mods run in Claude Code only. Codex reads this repository's .agents/plugins/marketplace.json first, which lists no plugins, so Codex offers none of them. If you installed a handily mod in Codex before that file existed, remove it with codex plugin remove <name>@handily.

Requirements

  • The latest Claude Code. The mod API is early access and changes between releases, so the mods support only the newest version, and CI tests them on it. Update Claude Code before you update a mod.

Contributing

See CONTRIBUTING.md. Work is tracked in the repository's own tracker (.basicly/ledger/), managed by basicly.

License

MIT

Source 4 files
hooks/register.ts 95 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { drawReport } from './draw'
3import { bundledMods, manifestVersion, replyText, statusesOf } from './status'
4
5const COMMAND = 'handily'
6const NO_ARGUMENT_TEXT =
7  '/handily takes no argument; it lists the handily mods and whether each loaded.'
8
9type Ready = { root: string } | undefined
10
11async function readyOf($: EngineInterface, name: string): Promise<Ready> {
12  switch (name) {
13    case 'workitems':
14      return (await $.state.get({ plugin: 'workitems', key: 'ready' })).value
15    case 'quiet-items':
16      return (await $.state.get({ plugin: 'quiet-items', key: 'ready' })).value
17    case 'task-pane':
18      return (await $.state.get({ plugin: 'task-pane', key: 'ready' })).value
19    case 'session-board':
20      return (await $.state.get({ plugin: 'session-board', key: 'ready' })).value
21    case 'item-toasts':
22      return (await $.state.get({ plugin: 'item-toasts', key: 'ready' })).value
23    case 'agent-board':
24      return (await $.state.get({ plugin: 'agent-board', key: 'ready' })).value
25    case 'simple-view':
26      return (await $.state.get({ plugin: 'simple-view', key: 'ready' })).value
27    case 'reply-view':
28      return (await $.state.get({ plugin: 'reply-view', key: 'ready' })).value
29    default:
30      throw new Error(
31        `handily: plugin.json lists "${name}", but /handily has no ready value to read for it`,
32      )
33  }
34}
35
36function manifestPath(root: string): string {
37  return `${root}/.claude-plugin/plugin.json`
38}
39
40function shownUnderPluginName(pluginName: string, text: string): string {
41  return `${pluginName}: ${text}`
42}
43
44async function loadedVersions(
45  $: EngineInterface,
46  mods: readonly string[],
47): Promise<Map<string, string>> {
48  const versions = new Map<string, string>()
49  for (const name of mods) {
50    const ready = await readyOf($, name)
51    if (ready === undefined) continue
52    versions.set(name, manifestVersion(await $.fs.read(manifestPath(ready.root)), name))
53  }
54  return versions
55}
56
57export const register: Register = (on) => {
58  on('session.start', async ($, e, next) => {
59    await $.command.register({
60      name: COMMAND,
61      description: 'Show which handily mods are installed and loaded',
62    })
63    return next(e)
64  })
65
66  on('command.run', { command: COMMAND }, async ($, e) => {
67    if (e.args.trim() !== '') return { text: NO_ARGUMENT_TEXT }
68    const mods = bundledMods(await $.fs.read(manifestPath($.plugin.root)))
69    const versions = await loadedVersions($, mods)
70    const { enabledPlugins } = await $.settings.read()
71    const statuses = statusesOf(mods, versions, enabledPlugins)
72    const text = replyText(statuses)
73    await $.state.set(
74      { plugin: 'handily', key: 'reports', id: shownUnderPluginName($.plugin.name, text) },
75      statuses,
76    )
77    return { text }
78  })
79
80  on(
81    'ui.render',
82    { component: 'CommandOutput', props: { command: COMMAND } },
83    async ($, e, next) => {
84      if (e.props.isErrored) return next(e)
85      const { value: statuses } = await $.state.get({
86        plugin: 'handily',
87        key: 'reports',
88        id: e.props.text,
89      })
90      if (statuses === undefined) return next(e)
91      return drawReport($.ui.resolve(e), statuses)
92    },
93  )
94}
95
hooks/draw.ts 55 lines
1import type { Elements, RenderElement, RenderSurface, ThemeKey } from 'claude-code'
2import type { HandilyModState, HandilyModStatus } from '../types'
3import { summaryOf } from './status'
4
5type ReportElements = Pick<Elements[RenderSurface], 'Box' | 'Text'>
6
7const STATE_COLOR: Record<HandilyModState, ThemeKey> = {
8  loaded: 'success',
9  disabled: 'error',
10  'not installed': 'error',
11  'not loaded': 'warning',
12}
13
14function widest(texts: readonly string[]): number {
15  return Math.max(0, ...texts.map((text) => text.length))
16}
17
18export function drawReport(
19  { Box, Text }: ReportElements,
20  statuses: readonly HandilyModStatus[],
21): RenderElement {
22  const nameWidth = widest(statuses.map((status) => status.name))
23  const versionWidth = widest(statuses.map((status) => status.version ?? ''))
24  const rows = statuses.map((status) =>
25    Box({
26      key: status.name,
27      flexDirection: 'row',
28      gap: 2,
29      children: [
30        Box({
31          flexShrink: 0,
32          children: Text({ bold: true, children: status.name.padEnd(nameWidth) }),
33        }),
34        Box({
35          flexShrink: 0,
36          children: Text({ dimColor: true, children: (status.version ?? '').padEnd(versionWidth) }),
37        }),
38        Box({
39          flexShrink: 1,
40          children: Text({ color: STATE_COLOR[status.state], children: status.detail }),
41        }),
42      ],
43    }),
44  )
45  const isEveryModLoaded = statuses.every((status) => status.state === 'loaded')
46  const summary = Box({
47    key: 'summary',
48    children: Text({
49      color: isEveryModLoaded ? 'success' : 'warning',
50      children: summaryOf(statuses),
51    }),
52  })
53  return Box({ flexDirection: 'column', children: [...rows, summary] })
54}
55
hooks/status.ts 94 lines
1import type { HandilyModStatus } from '../types'
2
3const MARKETPLACE = 'handily'
4
5type Manifest = { version: unknown; dependencies: unknown }
6
7function manifestOf(manifestText: string): Manifest {
8  const manifest: unknown = JSON.parse(manifestText)
9  if (typeof manifest !== 'object' || manifest === null)
10    return { version: undefined, dependencies: undefined }
11  return {
12    version: 'version' in manifest ? manifest.version : undefined,
13    dependencies: 'dependencies' in manifest ? manifest.dependencies : undefined,
14  }
15}
16
17export function bundledMods(manifestText: string): string[] {
18  const { dependencies } = manifestOf(manifestText)
19  if (
20    !Array.isArray(dependencies) ||
21    dependencies.length === 0 ||
22    !dependencies.every((name) => typeof name === 'string')
23  ) {
24    throw new Error('handily: plugin.json "dependencies" must list the handily mods by name')
25  }
26  return dependencies
27}
28
29export function manifestVersion(manifestText: string, name: string): string {
30  const { version } = manifestOf(manifestText)
31  if (typeof version !== 'string' || version === '') {
32    throw new Error(`handily: the plugin.json of ${name} has no "version"`)
33  }
34  return version
35}
36
37function pluginId(name: string): string {
38  return `${name}@${MARKETPLACE}`
39}
40
41function enabledEntry(enabledPlugins: unknown, name: string): unknown {
42  if (typeof enabledPlugins !== 'object' || enabledPlugins === null) return undefined
43  return (enabledPlugins as Record<string, unknown>)[pluginId(name)]
44}
45
46function stateOf(
47  name: string,
48  version: string | undefined,
49  enabled: unknown,
50): Pick<HandilyModStatus, 'state' | 'detail'> {
51  if (version !== undefined) return { state: 'loaded', detail: 'loaded' }
52  if (enabled === false) {
53    return { state: 'disabled', detail: `disabled. Run /plugin enable ${pluginId(name)}` }
54  }
55  if (enabled === undefined) {
56    return {
57      state: 'not installed',
58      detail: `not installed. Run /plugin install ${pluginId(name)}`,
59    }
60  }
61  return {
62    state: 'not loaded',
63    detail: 'enabled, but it did not load. Run claude --debug to see why',
64  }
65}
66
67export function statusesOf(
68  mods: readonly string[],
69  loadedVersions: ReadonlyMap<string, string>,
70  enabledPlugins: unknown,
71): HandilyModStatus[] {
72  return mods.map((name) => {
73    const version = loadedVersions.get(name)
74    return {
75      name,
76      version: version ?? null,
77      ...stateOf(name, version, enabledEntry(enabledPlugins, name)),
78    }
79  })
80}
81
82export function summaryOf(statuses: readonly HandilyModStatus[]): string {
83  const loaded = statuses.filter((status) => status.state === 'loaded').length
84  return `handily: ${String(loaded)} of ${String(statuses.length)} mods loaded`
85}
86
87export function replyText(statuses: readonly HandilyModStatus[]): string {
88  const rows = statuses.map((status) => {
89    const version = status.version === null ? '' : ` ${status.version}`
90    return `- ${status.name}${version}: ${status.detail}`
91  })
92  return [...rows, '', summaryOf(statuses)].join('\n')
93}
94
types/index.d.ts 15 lines
1export type HandilyModState = 'loaded' | 'disabled' | 'not installed' | 'not loaded'
2
3export type HandilyModStatus = {
4  name: string
5  version: string | null
6  state: HandilyModState
7  detail: string
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    handily: { reports: StateFamily<readonly HandilyModStatus[]> }
13  }
14}
15