SLOPSHOPPER

cmod

Claude Mod Manager: fetches the cmod program for every mod, runs the uninstall step of each mod Claude Code removes, and carries calls from one mod to another.

new
★ 1v0.3.0MITupdated 2026-10-08heyJordanParker/cmod
A shopper browsing a rack in a slop shop
README

<img src=".claude-plugin/icon.png" alt="Claude Mod Manager" width="160">

<h1 align="center">Claude Mod Manager</h1>

Claude Mod Manager (cmod) installs and builds Claude Code mods. A mod is a Claude Code plugin built with the @cmodjs/core package. It can add hooks, panes, slash commands, tools, and permission rules, and it can run its own install step. cmod installs mods, runs their install and uninstall steps with your consent, and helps you build and publish your own.

Install a mod

In Claude Code, add the mod's marketplace and install the mod:

/plugin marketplace add <owner>/<repo>
/plugin install <mod>@<marketplace>

Claude Code installs the cmod plugin with the mod. cmod then fetches the cmod program and opens the mod's installer pane, and the mod runs its install step once you accept. The pane names each command the mod runs, each key it binds, and each permission it asks for, such as "connect to api.github.com" or "run gh on your computer". It then asks for any setting the mod needs, and walks you through any step only you can do, such as signing in. The mod can reach nothing else through cmod. A progress bar shows each step. A notice tells you when the mod is ready.

/mods lists every mod with the one-line description from its plugin.json, whether Claude Code has it turned off, its settings, its permissions, its keys, and its own pages. Turn a permission off there and the mod loses it before its next call. Turn off and Turn on run cmod disable and cmod enable, and Remove asks once, then runs cmod remove <mod>.

A mod keeps itself up to date. Its setup turns on Claude Code's automatic updates for the marketplace it came from, unless you already chose, and the install question says so. Turn them off under /plugin Marketplaces. An update that changes what the mod runs asks you again before it runs.

When you remove a mod with /plugin uninstall, cmod runs the mod's uninstall step at the next session start or prompt. If Claude Code quits before the uninstall step finishes, the next session runs it again. If the uninstall step fails, a notice names the saved step to fix and the command that runs it again, cmod teardown <mod>. The mod keeps running in a session that is already open until you run /reload-plugins there.

In a terminal, one command does the same, for a mod or any other Claude Code plugin:

cmod install <owner>/<repo>

A plugin that is not a mod installs through Claude Code alone, with no consent question. cmod update, cmod enable, cmod disable, and cmod remove work on any plugin the same way. The cmod plugin puts cmod in ~/.local/bin the first time it runs. A mod that ships a program, such as trace, runs it by its name in Claude Code even when your system has a command of the same name, and a mod built only for some machines says so before it runs anything. cmod list shows every plugin Claude Code has, and whether cmod set up each mod.

What the cmod plugin runs

The cmod plugin runs these programs, and sends no data anywhere:

  • setup/bootstrap.sh, the first time it runs. It runs the cmod launcher from the @cmodjs/cli package Claude Code installs with the plugin. The launcher downloads the cmod program for your machine from the GitHub release of this version at github.com/heyJordanParker/cmod, checks it against the release's SHA256SUMS, keeps it in ~/.local/share/cmod/bin/cmod/<version>/, and links ~/.local/share/cmod/programs/cmod and ~/.local/bin/cmod to it. It leaves a ~/.local/bin/cmod it did not make in place. That download is its only network request.
  • setup/path.sh, at each session start. It writes one line to the file Claude Code reads into every Bash command's environment, export PATH="~/.local/share/cmod/programs:$PATH", so Claude runs each program a mod installs by its name.
  • cmod teardown <mod> --events, when you remove a mod. At each session start and prompt, the plugin reads enabledPlugins from your Claude Code user settings. For each mod that left the list, it runs cmod teardown, which runs the uninstall step you approved when you installed the mod.
  • cmod permission <mod> <name> [value] on|off, when you turn a permission on or off in /mods, cmod option <mod> <key> <value>, when you save a setting there, and cmod enable, cmod disable, and cmod remove with a mod's name, when you turn it on or off or remove it there.

A setting you change in /mods goes through cmod option to Claude Code's /config, the same place Claude Code keeps it when you change it there. The cmod plugin never changes Claude Code's settings itself.

It writes files in two places only: the cmod program in ~/.local/share/cmod/ with its links at ~/.local/share/cmod/programs/cmod and ~/.local/bin/cmod, and the list of mods it has seen, which it keeps in Claude Code's own storage for the plugin.

Every mod built with cmod, the cmod plugin included, adds one hook on each Claude Code event a mod can use, and only a mod that decides permissions hooks Claude Code's permission check. So the cmod plugin hooks tool calls, prompts, and what Claude Code draws, and no permission check. On a tool call, the cmod plugin answers nothing of its own: it passes the event on unchanged. It never allows or denies a call, never rewrites a tool's input, and never changes a tool's output. It draws one line above the prompt while it downloads cmod, and it answers a call from one mod to another that names a mod you have not installed.

Change a mod

Your changes to a mod live in its config folders, outside the mod's code. Each mod has two:

~/.claude/cmods/<mod>/              yours, in every project
<project>/.claude/cmods/<mod>/      your team's, committed with the repository

A file in the project folder wins over the same file in yours. CLAUDE_CONFIG_DIR moves ~/.claude, and removing the mod keeps both folders.

A mod's options, such as a token or a branch to watch, are yours to set in /config. To set them for your whole team, commit an options.json in the project folder that lists only the options to change:

{ "branch": "release" }

The project's value wins over yours, and a value your organization set in managed settings wins over both. A secret never goes in the project file. The mod ignores an option it does not have and a value that does not fit, and logs one line naming the file, the option, and the fix. cmod reads the project's file when the mod starts, and again after a /cd.

To replace the text of a Skill a mod ships, put your own file at the same path in a config folder:

~/.claude/cmods/<mod>/skills/<skill>/SKILL.md

Claude reads your text in place of the mod's from the next time the Skill loads, and updates keep it. cmod drops the file's frontmatter, so the mod's own name and description stay. Delete the file to get the mod's text back.

Make a mod in 30 seconds

npm i -g @cmodjs/cli
cmod new my-mod
cd my-mod
cmod link

npm i -g @cmodjs/cli puts cmod on PATH, and bun add -g @cmodjs/cli does the same. bunx @cmodjs/cli new my-mod runs it once without installing. cmod new writes the mod and installs its packages. cmod link loads it in every new Claude Code session. The mod lives in src/:

src/
├── mod.tsx                     defineMod, and the mappings: the pane, the slot renders, the hooks
├── state.ts                    the starting state, grouped by how long it lasts
├── panes/
│   └── prompts.tsx             one definePane per file
└── components/
    └── prompt-count.tsx        what the pane and the slot render draw with

src/mod.tsx attaches each part to Claude Code with one line:

import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
import { Box } from '../node_modules/@cmodjs/core/ui/elements.js'
import { slots } from '../node_modules/@cmodjs/core/ui/slots.js'
import { PromptCount } from './components/prompt-count.js'
import { promptsPane } from './panes/prompts.js'
import { initialState } from './state.js'

export const myMod = defineMod({
  name: 'my-mod',
  state: initialState,

  setup(mod) {
    mod.ui.pane(promptsPane)
    mod.ui.render(slots.AbovePrompt, ({ hasSurvey, Default }) =>
      hasSurvey ? <Default /> : <Box flexDirection="column"><Default /><PromptCount count={mod.state.session.prompts} /></Box>)

    mod.on('UserPromptSubmit', () => {
      mod.state.session.prompts += 1
    })
  },
})

src/state.ts holds the starting state:

export type MyModState = { session: { prompts: number } }

export const initialState: MyModState = { session: { prompts: 0 } }

src/panes/prompts.tsx is the mod's pane:

export const promptsPane = definePane<MyModState>({
  id: 'my-mod',
  title: 'my-mod',
  render: (mod) => <PromptCount count={mod.state.session.prompts} />,
})

src/components/prompt-count.tsx is what the pane and the band above the prompt draw:

export function PromptCount({ count }: { readonly count: number }): RenderElement {
  return <Text>{`Prompts this session: ${count}`}</Text>
}

Start claude, and the mod counts every prompt. cmod check checks the layout, the imports, and the lint, validates the mod with Claude Code, type-checks it, and runs its tests. A fresh clone has no .claude-plugin/types/ yet, so cmod check has Claude Code write it before the type check. cmod publish releases the mod on GitHub, and prints the link that lists it in Anthropic's plugin directory.

Docs

The mod author docs live in core/docs/ and ship inside @cmodjs/core. So every mod's node_modules/@cmodjs/core/docs/ holds the docs of the version it installed. The .claude/CLAUDE.md that cmod new writes points coding agents there. A project plugin, made with cmod new --project, gets no .claude/CLAUDE.md.

  • index.md: what a mod is, and which file answers which question
  • mod.md: defineMod, setup, and what mod can call
  • state.md: state groups and Skill overrides
  • options.md: options a person sets, typed in code, stored by Claude Code
  • permissions.md: the hosts, programs, and files a mod may reach, granted by the person
  • hooks.md: mod.on, every event, and what a hook can answer
  • ui.md: panes, slots, markdown slots, elements, toasts, progress, and questions
  • jobs.md: slash commands, tools, permission rules, checks, prompts, schedules, status lines, and programs
  • dependencies.md: calling another mod
  • install-steps.md: install and uninstall steps, and shipping a program
  • testing.md: testMod
  • commands.md: every cmod command

What is in this repository

  • The root is the cmod plugin. It fetches the cmod program, runs the uninstall step of each mod Claude Code removes, and carries calls from one mod to another.
  • core/ is @cmodjs/core on npm, the library every mod imports, with its docs in core/docs/.
  • cli/ is @cmodjs/cli on npm, the cmod command, which runs the newest cmod program. It also holds the source of the cmod program, which each GitHub release carries for macOS and Linux.
  • CONTRIBUTING.md runs cmod from a checkout, runs the gate, and releases.

License

MIT. See LICENSE.

Source 4 files
hooks/register.ts 19 lines
1import type { On, PluginOptions } from 'claude-code'
2import { notInstalled } from '../node_modules/@cmodjs/core/mod.js'
3import { registerMod } from '../node_modules/@cmodjs/core/register.js'
4import { cmodPlugin } from '../src/mod.js'
5import type { Cmod } from '../types/index.js'
6
7export function register(addHook: On, options: PluginOptions): void {
8  addHook('engine.create', async (_$, eventInput, passOn) => {
9    const built = await passOn(eventInput)
10    const cmod: Cmod = {
11      async call(input) {
12        return { deny: notInstalled(input.to) }
13      },
14    }
15    return { ...built, cmod }
16  })
17  registerMod(addHook, cmodPlugin, options)
18}
19
src/mod.ts 71 lines
1import { slashCommand } from '../node_modules/@cmodjs/core/jobs/slash-command.js'
2import { defineMod, longestMs, messageOf } from '../node_modules/@cmodjs/core/mod.js'
3import { parseEvent } from '../node_modules/@cmodjs/core/records.js'
4import { modsMemory, modsPanel, type ModsState } from './mods.js'
5
6export type CmodPluginState = { readonly global: { installedPlugins: readonly string[] | null } } & ModsState
7
8const panels = new WeakMap<object, ReturnType<typeof modsPanel<CmodPluginState>>>()
9
10export const cmodPlugin = defineMod({
11  name: 'cmod',
12  state: { global: { installedPlugins: null } as CmodPluginState['global'], memory: modsMemory },
13
14  api: {
15    async openSettings(input: { readonly mod: string; readonly page?: string }, mod) {
16      const panel = panels.get(mod)
17      if (panel === undefined) throw new Error('cmod has not started its /mods panel yet.')
18      await panel.open(input.mod, input.page)
19      return null
20    },
21  },
22
23  setup(mod) {
24    const triedRemovals = new Set<string>()
25    const mods = modsPanel(mod)
26    panels.set(mod, mods)
27
28    mod.use(
29      slashCommand({
30        name: 'mods',
31        description: 'See your mods, turn them off or remove them, and change their options, permissions, keys, and pages',
32        argumentHint: '[mod] [page]',
33        immediate: true,
34        async reply({ positionals }) {
35          const [name, page] = positionals
36          await mods.open(name, page)
37        },
38      }),
39    )
40
41    const tearDown = async (name: string) => {
42      const { exitCode, stdout, stderr } = await mod.process.run(['cmod', 'teardown', name, '--events'], { timeoutMs: longestMs })
43      const outcome = parseEvent(stdout.trim().split('\n').at(-1) ?? '')
44      const isAnswered = outcome.kind === 'done' || outcome.kind === 'missing' || outcome.kind === 'failed'
45      if (isAnswered) mod.state.global.installedPlugins = (mod.state.global.installedPlugins ?? []).filter((installed) => installed !== name)
46      if (outcome.kind === 'done') mod.ui.toast(`${name} is uninstalled. A session that still runs it stops after /reload-plugins.`)
47      else if (outcome.kind === 'failed') mod.ui.toast(outcome.message)
48      else if (exitCode !== 0) mod.ui.toast(`cmod teardown ${name} exited ${exitCode}: ${stderr.trim().split('\n').at(-1)}`)
49    }
50
51    const watchRemovals = async () => {
52      const enabledPlugins = (await mod.claude.settings.read({ source: 'user' }))['enabledPlugins'] as Record<string, unknown> | undefined
53      const current = [...new Set(Object.keys(enabledPlugins ?? {}).map((key) => key.replace(/@[^@]*$/, '')))]
54      const previous = mod.state.global.installedPlugins
55      const removed = (previous ?? []).filter((name) => !current.includes(name))
56      const installed = [...current, ...removed]
57      if (previous === null || previous.length !== installed.length || !installed.every((name) => previous.includes(name))) mod.state.global.installedPlugins = installed
58      for (const name of removed) {
59        if (triedRemovals.has(name)) continue
60        triedRemovals.add(name)
61        void tearDown(name).catch((error: unknown) => {
62          mod.ui.toast(`cmod teardown ${name} did not run: ${messageOf(error)}`)
63        })
64      }
65    }
66
67    mod.on('SessionStart', watchRemovals)
68    mod.on('UserPromptSubmit', watchRemovals)
69  },
70})
71
src/mods.ts 247 lines
1import type { ConfigRow, RenderElement } from 'claude-code'
2import { longestMs, messageOf, type Mod } from '../node_modules/@cmodjs/core/mod.js'
3import { consentPath, finishStepsMethod, isObject, openPageMethod, parseConsent, pendingStepsMethod, permissionWords, readRecord, readSteps, settingsPagesMethod, storeFolder } from '../node_modules/@cmodjs/core/records.js'
4import { Tabs } from '../node_modules/@cmodjs/core/ui/components.js'
5import { definePane } from '../node_modules/@cmodjs/core/ui/define-pane.js'
6import { Box, Button, Input, Text } from '../node_modules/@cmodjs/core/ui/elements.js'
7
8export type ModsState = { readonly memory: { selected: string | null; tab: Tab; notice: string | null; removing: string | null; views: readonly ModView[] } }
9
10export const modsMemory: ModsState['memory'] = { selected: null, tab: 'options', notice: null, removing: null, views: [] }
11
12type Tab = 'options' | 'permissions' | 'keys' | 'pages'
13
14type Permission = { readonly item: string; readonly isOn: boolean }
15
16export type ModView = {
17  readonly name: string
18  readonly version: string
19  readonly description: string | undefined
20  readonly isOn: boolean
21  readonly permissions: readonly Permission[]
22  readonly keys: readonly (readonly [string, string])[]
23  readonly options: readonly ConfigRow[]
24  readonly pages: readonly { readonly id: string; readonly title: string }[]
25  readonly pendingSteps: readonly string[]
26}
27
28const tabs: readonly { readonly key: Tab; readonly label: string }[] = [
29  { key: 'options', label: 'Options' },
30  { key: 'permissions', label: 'Permissions' },
31  { key: 'keys', label: 'Keys' },
32  { key: 'pages', label: 'Pages' },
33]
34
35export function modsPanel<State extends ModsState>(mod: Mod<State>) {
36  const load = async (): Promise<readonly ModView[]> => {
37    const store = storeFolder({ HOME: await mod.claude.env.home(), XDG_DATA_HOME: await mod.claude.env.dataHome() })
38    const consent = (await mod.fs.exists(consentPath(store))) ? parseConsent(JSON.parse(await mod.fs.read(consentPath(store))), consentPath(store)) : {}
39    const rows = await mod.claude.config.list()
40    const enabledPlugins = (await mod.claude.settings.read())['enabledPlugins']
41    const turnedOff = Object.entries(isObject(enabledPlugins) ? enabledPlugins : {})
42      .filter(([, isEnabled]) => isEnabled === false)
43      .map(([id]) => id.replace(/@[^@]*$/, ''))
44    const names = (await mod.fs.exists(`${store}/records`)) ? (await mod.fs.list(`${store}/records`)).filter((entry) => entry.name.endsWith('.json')).map((entry) => entry.name.slice(0, -'.json'.length)) : []
45    const read = (path: string) => mod.fs.read(path).catch(() => undefined)
46    const records = (await Promise.all(names.map((name) => readRecord(read, store, name)))).filter((record) => record !== undefined)
47    const views = await Promise.all(
48      records.map(async (record): Promise<ModView> => {
49        const manifest: unknown = await mod.fs.read(`${record.root}/package.json`).then(JSON.parse, () => undefined)
50        const plugin: unknown = await mod.fs.read(`${record.root}/.claude-plugin/plugin.json`).then(JSON.parse, () => undefined)
51        const declared = (isObject(manifest) ? readSteps(manifest)?.permissions : undefined) ?? []
52        const granted = consent[record.name] ?? []
53        const ask = async <Value>(method: string): Promise<readonly Value[]> => {
54          const answer = await mod.claude.cmod.call({ to: record.name, method, input: null }).catch(() => undefined)
55          return isObject(answer) && Array.isArray(answer['value']) ? (answer['value'] as Value[]) : []
56        }
57        const pages = await ask<ModView['pages'][number]>(settingsPagesMethod)
58        const pendingSteps = await ask<string>(pendingStepsMethod)
59        return {
60          name: record.name,
61          version: record.version,
62          description: isObject(plugin) && typeof plugin['description'] === 'string' ? plugin['description'] : undefined,
63          isOn: !turnedOff.includes(record.name),
64          permissions: declared.map((item) => ({ item, isOn: granted.includes(item) })),
65          keys: Object.entries(record.keys).map(([key, command]) => [key, `/${command}`] as const),
66          options: rows.filter((row) => row.key.startsWith(`${record.name}.`)),
67          pages,
68          pendingSteps,
69        }
70      }),
71    )
72    return views.sort((left, right) => left.name.localeCompare(right.name))
73  }
74
75  const refresh = async () => {
76    try {
77      mod.state.memory.views = await load()
78    } catch (error) {
79      mod.state.memory.notice = `The mods did not load: ${messageOf(error)}`
80    }
81  }
82
83  const act = (task: () => Promise<string | undefined>) =>
84    void task()
85      .then((notice) => {
86        mod.state.memory.notice = notice ?? null
87        return refresh()
88      })
89      .catch((error: unknown) => void (mod.state.memory.notice = messageOf(error)))
90
91  const toggle = (name: string, { item, isOn }: Permission) =>
92    act(async () => {
93      const split = item.indexOf(':')
94      const words = split === -1 ? [item] : [item.slice(0, split), item.slice(split + 1)]
95      const { exitCode, stdout, stderr } = await mod.process.run(['cmod', 'permission', name, ...words, isOn ? 'off' : 'on'])
96      return exitCode === 0 ? stdout.trim() : stderr.trim()
97    })
98
99  const remove = (name: string) =>
100    act(async () => {
101      mod.state.memory.removing = null
102      mod.state.memory.notice = `Removing ${name}…`
103      const { exitCode, stderr } = await mod.process.run(['cmod', 'remove', name], { timeoutMs: longestMs })
104      if (exitCode === 0) return `Removed ${name}. This session stops running it after /reload-plugins.`
105      return `cmod remove ${name} exited ${exitCode}: ${stderr.trim().split('\n').at(-1) ?? ''}`
106    })
107
108  const turn = (name: string, isOn: boolean) =>
109    act(async () => {
110      const action = isOn ? 'enable' : 'disable'
111      const { exitCode, stderr } = await mod.process.run(['cmod', action, name])
112      if (exitCode === 0) return `Turned ${name} ${isOn ? 'on' : 'off'}. This session follows after /reload-plugins.`
113      return `cmod ${action} ${name} exited ${exitCode}: ${stderr.trim().split('\n').at(-1) ?? ''}`
114    })
115
116  const setOption = (name: string, row: ConfigRow, text: string) =>
117    act(async () => {
118      const value = text.split(',').map((item) => item.trim()).join(',')
119      const { exitCode, stderr } = await mod.process.run(['cmod', 'option', name, row.key.slice(name.length + 1), value])
120      if (exitCode === 0) return `Saved ${row.label}. ${name} reads it at its next start.`
121      return `cmod option ${name} exited ${exitCode}: ${stderr.trim().split('\n').at(-1) ?? ''}`
122    })
123
124  const drawTab = (view: ModView, tab: Tab): RenderElement => {
125    if (tab === 'options') {
126      if (view.options.length === 0) return Text({ dimColor: true, children: `${view.name} has no options.` })
127      return Box({
128        flexDirection: 'column',
129        gap: 1,
130        children: view.options.map((row) =>
131          Box({
132            flexDirection: 'column',
133            children: [
134              Text({ children: [Text({ bold: true, children: row.label }), row.isLocked ? Text({ dimColor: true, children: '  set by your organization' }) : ''] }),
135              row.isLocked ? Text({ children: String(row.value) }) : Input({ key: `option:${row.key}`, label: '› ', value: Array.isArray(row.value) ? row.value.join(', ') : String(row.value), submitLabel: 'save', onSubmit: (text: string) => setOption(view.name, row, text) }),
136            ],
137          }),
138        ),
139      })
140    }
141    if (tab === 'permissions') {
142      if (view.permissions.length === 0) return Text({ dimColor: true, children: `${view.name} asks for no permissions.` })
143      return Box({ flexDirection: 'column', children: view.permissions.map((permission) => Button({ plain: true, key: `permission:${permission.item}`, label: `${permission.isOn ? '[x]' : '[ ]'} ${permissionWords(permission.item)}`, onPress: () => toggle(view.name, permission) })) })
144    }
145    if (tab === 'keys') {
146      if (view.keys.length === 0) return Text({ dimColor: true, children: `${view.name} binds no keys.` })
147      return Box({ flexDirection: 'column', children: view.keys.map(([key, command]) => Text({ children: `${key.padEnd(12)}${command}` })) })
148    }
149    if (view.pages.length === 0) return Text({ dimColor: true, children: `${view.name} adds no pages.` })
150    return Box({ gap: 2, children: view.pages.map((page) => Button({ key: `page:${page.id}`, label: page.title, onPress: () => act(async () => (await mod.claude.cmod.call({ to: view.name, method: openPageMethod, input: page.id }), undefined)) })) })
151  }
152
153  const pane = definePane<State>({
154    id: 'mods',
155    title: 'Mods',
156    closeOnEscape: true,
157    render(current) {
158      const { selected, tab, notice, removing, views } = current.state.memory
159      const view = views.find((each) => each.name === selected) ?? views[0]
160      if (view === undefined) return Text({ dimColor: true, children: notice ?? 'No mod is set up yet. Run cmod install <owner/repo> to add one.' })
161      const list = Box({
162        flexDirection: 'column',
163        minWidth: 18,
164        children: views.map((each) => {
165          const label = `${each === view ? '▸' : ' '} ${each.name}${each.isOn ? '' : ' (off)'}`
166          return each === view ? Text({ bold: true, children: label }) : Button({ plain: true, key: `mod:${each.name}`, label, onPress: () => void Object.assign(current.state.memory, { selected: each.name, removing: null }) })
167        }),
168      })
169      const actions =
170        view.name === 'cmod'
171          ? []
172          : removing === view.name
173            ? [
174                Box({
175                  flexDirection: 'column',
176                  children: [
177                    Text({ color: 'warning', children: `Remove ${view.name}? Its uninstall step runs, and Claude Code deletes it.` }),
178                    Box({
179                      gap: 2,
180                      children: [
181                        Button({ key: 'keep', label: 'Keep', onPress: () => void (current.state.memory.removing = null) }),
182                        Button({ key: 'remove-confirmed', label: 'Remove', onPress: () => remove(view.name) }),
183                      ],
184                    }),
185                  ],
186                }),
187              ]
188            : [
189                Box({
190                  gap: 2,
191                  children: [
192                    Button({ key: 'turn', label: view.isOn ? 'Turn off' : 'Turn on', onPress: () => turn(view.name, !view.isOn) }),
193                    Button({ key: 'remove', label: 'Remove', onPress: () => void (current.state.memory.removing = view.name) }),
194                  ],
195                }),
196              ]
197      const details = Box({
198        flexDirection: 'column',
199        gap: 1,
200        children: [
201          Box({
202            flexDirection: 'column',
203            children: [
204              Text({ children: [Text({ bold: true, children: view.name }), Text({ dimColor: true, children: ` ${view.version}` })] }),
205              ...(view.description === undefined ? [] : [Text({ dimColor: true, children: view.description })]),
206              ...(view.isOn ? [] : [Text({ color: 'warning', children: 'Off: Claude Code does not load it.' })]),
207            ],
208          }),
209          ...actions,
210          ...(view.pendingSteps.length === 0
211            ? []
212            : [
213                Box({
214                  gap: 2,
215                  children: [
216                    Text({ color: 'warning', children: `Needs setup: ${view.pendingSteps.join(', ')}` }),
217                    Button({ key: 'finish', label: 'Finish setup', onPress: () => act(async () => (await mod.claude.cmod.call({ to: view.name, method: finishStepsMethod, input: null }), undefined)) }),
218                  ],
219                }),
220              ]),
221          Tabs({ tabs, selected: tab, onSelect: (key) => void (current.state.memory.tab = key as Tab), children: drawTab(view, tab) }),
222          ...(notice === null ? [] : [Text({ dimColor: true, children: notice })]),
223        ],
224      })
225      return Box({ gap: 2, children: [list, details] })
226    },
227  })
228
229  const handle = mod.ui.pane(pane)
230
231  return {
232    async open(name?: string, page?: string) {
233      await refresh()
234      if (name !== undefined) {
235        if (!mod.state.memory.views.some((view) => view.name === name)) throw new Error(`${name} is not a mod cmod set up. cmod list shows the mods.`)
236        mod.state.memory.selected = name
237        mod.state.memory.tab = 'options'
238      }
239      if (name !== undefined && page !== undefined) {
240        await mod.claude.cmod.call({ to: name, method: openPageMethod, input: page })
241        return
242      }
243      await handle.open({ focus: true })
244    },
245  }
246}
247
types/index.d.ts 11 lines
1export type Cmod = {
2  call(input: { to: string; method: string; input: unknown }): Promise<unknown>
3}
4
5declare module 'claude-code' {
6  interface EngineInterface {
7    cmod: Cmod
8  }
9  interface CmodDependencies {}
10}
11