@cmodjs/core 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/mod.md ADDED
@@ -0,0 +1,217 @@
1
+ # Define a mod
2
+
3
+ `defineMod` describes a mod: its name, its state, the methods it offers other mods, and the `setup` function that attaches everything to Claude Code. `connect` hands the definition to Claude Code.
4
+
5
+ ## defineMod
6
+
7
+ ```ts
8
+ defineMod<State, Name>(definition: ModDefinition<State, Name>): ModDefinition<State, Name>
9
+ ```
10
+
11
+ ```ts
12
+ type ModDefinition<State, Name> = {
13
+ readonly name: Name
14
+ readonly state?: State
15
+ readonly api?: { … }
16
+ setup(mod: Mod<State>): void | Promise<void>
17
+ }
18
+ ```
19
+
20
+ - `name` is the mod's name. Use the `name` in `.claude-plugin/plugin.json`. Claude Mod Manager (CMod) keys the saved state and the `state.json` files by this name, and the data folder and the Skill overrides by the plugin's name, so the two must match. `defineMod` throws `defineMod: the mod needs a name, such as the name in .claude-plugin/plugin.json.` for a blank name.
21
+ - `state` holds the starting values, grouped by how long they last. [state.md](state.md) explains the groups.
22
+ - `api` holds the methods other mods call. [dependencies.md](dependencies.md) explains it.
23
+ - `setup` runs once when the mod starts. It adds hooks, panes, slot renders, and jobs. It may be `async`.
24
+
25
+ ```tsx
26
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
27
+
28
+ export const greeter = defineMod({
29
+ name: 'greeter',
30
+ state: { session: { isGreeted: false } },
31
+
32
+ setup(mod) {
33
+ mod.on('UserPromptSubmit', () => {
34
+ if (mod.state.session.isGreeted) return
35
+ mod.state.session.isGreeted = true
36
+ mod.ui.toast(`Hello from ${mod.name}`)
37
+ })
38
+ },
39
+ })
40
+ ```
41
+
42
+ ## When setup runs
43
+
44
+ Claude Code loads the mod at the start of each session. CMod then checks that the mod is set up. A mod with an install step that has not run waits for the person's consent and the install first ([install-steps.md](install-steps.md)). Then CMod loads the saved state, runs `setup`, and marks as open each of the mod's panes that Claude Code already shows.
45
+
46
+ The first time a version starts, CMod shows the toast `<name> is ready` and logs `<name> added <what setup added>`, such as `the my-mod pane, a render of AbovePrompt and a hook on UserPromptSubmit`.
47
+
48
+ When a step fails, the mod does not start. Its progress line above the prompt names the failure and the fix:
49
+
50
+ - `<mod>: state.<group> is not a lifetime. …` or `<mod>: state.<group> is not an object of values. …` when the declared state is not valid ([state.md](state.md)).
51
+ - `its state did not load: <error>` when reading the saved values or a `state.json` fails.
52
+ - `its setup function threw: <error>` when `setup` throws or rejects.
53
+ - `its open panes did not load: <error>` when Claude Code cannot list the open panes.
54
+
55
+ Each of these says `Fix it, then run /reload-plugins.`
56
+
57
+ ## Connect the mod to Claude Code
58
+
59
+ `hooks/hooks.json` names `hooks/register.ts` as the plugin's hooks module. `register.ts` calls `connect` and does nothing else:
60
+
61
+ ```ts
62
+ import type { Register } from 'claude-code'
63
+ import { connect } from '../node_modules/@cmodjs/core/connect.js'
64
+ import { greeter } from '../src/mod.js'
65
+
66
+ export const register: Register = (on) => {
67
+ connect(on, greeter)
68
+ }
69
+ ```
70
+
71
+ ```ts
72
+ connect<State>(on: On, definition: ModDefinition<State>): void
73
+ ```
74
+
75
+ `connect` registers one handler per Claude Code event the mod can use, and routes each event to the mod. Keep `register.ts` this small. Claude Code checks a hooks module before it loads it, and refuses some shapes, such as a `$` passed to a function in another module.
76
+
77
+ ## What `mod` can call
78
+
79
+ `setup` gets `mod`, of type `Mod<State>`. Every hook, render, and job gets the same `mod`.
80
+
81
+ ```ts
82
+ type Mod<State> = {
83
+ readonly name: string
84
+ readonly state: Readonly<State>
85
+ readonly dataFolder: string
86
+ readonly projectRoot: string
87
+ readonly cwd: string
88
+ on(event, hook): void
89
+ use(part): Handle
90
+ readonly ui: { pane, render, toast, progress, ask }
91
+ readonly process: { run, spawn }
92
+ readonly fs: { read, write, list }
93
+ readonly http: { fetch }
94
+ readonly settings: { read }
95
+ readonly dependencies: CmodDependencies
96
+ }
97
+ ```
98
+
99
+ | Member | What it is | Docs |
100
+ | --- | --- | --- |
101
+ | `name` | The mod's name from `defineMod`. | |
102
+ | `state` | The mod's values. Assign to a key to change it. | [state.md](state.md) |
103
+ | `dataFolder` | The mod's own folder in the CMod store: `$XDG_DATA_HOME/cmod/data/<plugin name>`, or `~/.local/share/cmod/data/<plugin name>` when `XDG_DATA_HOME` is not set. The install step gets the same folder as `CMOD_DATA`. CMod deletes it when the mod is removed. | [install-steps.md](install-steps.md) |
104
+ | `projectRoot` | The project the session works in. It follows `/cd`. | |
105
+ | `cwd` | The session's working folder. It follows a `cd` in a Bash or PowerShell call, and `/cd`. | |
106
+ | `on` | Adds a hook on a Claude Code event. | [hooks.md](hooks.md) |
107
+ | `use` | Adds a job, such as a slash command or a tool, and returns its handle. | [jobs.md](jobs.md) |
108
+ | `ui` | Panes, slot renders, toasts, progress lines, and questions. | [ui.md](ui.md) |
109
+ | `process` | Runs a program. | below |
110
+ | `fs` | Reads and writes files. | below |
111
+ | `http` | Fetches a URL. | below |
112
+ | `settings` | Reads Claude Code's settings. | below |
113
+ | `dependencies` | Calls the methods of other mods. | [dependencies.md](dependencies.md) |
114
+
115
+ ### mod.process
116
+
117
+ ```ts
118
+ mod.process.run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>
119
+ mod.process.spawn(argv: readonly string[], init?: { cwd?, env?, input? }): HookStream<ProcessSpawnChunk, ProcessSpawnResult>
120
+ ```
121
+
122
+ Both run a program without a shell. `argv[0]` is the program and the rest are its arguments.
123
+
124
+ - `init.cwd` is the program's folder, absolute or relative to the session's working folder. Without it the program runs in the session's working folder.
125
+ - `init.env` sets variables over Claude Code's own environment.
126
+ - `run` takes `stdin`, and `spawn` takes `input`: text written to standard input, which then closes.
127
+ - `run` takes `timeoutMs`: Claude Code kills the program after it, 30 seconds by default and 10 minutes at most. `longestMs`, exported from `mod.js`, is those 10 minutes in milliseconds, `600000`.
128
+ - `run` resolves `{ exitCode, stdout, stderr, isStdoutTruncated, isStderrTruncated }` once the program exits, whatever its exit code. It keeps the first 4 MiB of each stream. A program ended by a signal has `exitCode` 1.
129
+ - `run` rejects when the program cannot start, and when it is still running at `timeoutMs`. It reads the whole output, so a background process the program leaves writing holds the call until the timeout.
130
+ - `spawn` streams `{ stream: 'stdout' | 'stderr', text }` pieces as the program writes them. Its `result` resolves `{ code, signal }` once the stream is read to its end. Its first pull rejects when the program cannot start.
131
+ - The `for await` loop over a `spawn` is the program's life. Leaving the loop, or calling `return()` on the stream, kills the program, and so does unloading the mod. Returning from the hook does not.
132
+
133
+ Inside a job, CMod adds the job's deadline to each call ([jobs.md](jobs.md)).
134
+
135
+ ```ts
136
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
137
+
138
+ export const branchNote = defineMod({
139
+ name: 'branch-note',
140
+ setup(mod) {
141
+ mod.on('SessionStart', async () => {
142
+ const { exitCode, stdout } = await mod.process.run(['git', 'branch', '--show-current'], { cwd: mod.projectRoot })
143
+ if (exitCode !== 0) return
144
+ return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `The git branch is ${stdout.trim()}.` } }
145
+ })
146
+ },
147
+ })
148
+ ```
149
+
150
+ ### mod.fs
151
+
152
+ ```ts
153
+ mod.fs.read(path: string): Promise<string>
154
+ mod.fs.write(path: string, text: string): Promise<void>
155
+ mod.fs.list(path?: string): Promise<FsEntry[]>
156
+ ```
157
+
158
+ - A relative path is under the session's working folder. An absolute path is used as given. Text is UTF-8.
159
+ - `read` rejects a missing file and a file over 4 MiB.
160
+ - `write` writes the whole file and creates its folders.
161
+ - `list` lists a folder, the working folder without a path. Each entry is `{ name, kind, size, mtimeMs, isLink }`, and `kind` is `'file'`, `'dir'`, or `'other'`. A symbolic link is `'other'` with `isLink` true.
162
+
163
+ A file the mod ships is under the plugin's folder. `mod` has no member that names that folder, so a job of your own reads it from `claude.plugin.root` ([jobs.md](jobs.md#the-claude-members)). A file the mod keeps for itself belongs in `mod.dataFolder`.
164
+
165
+ ### mod.http
166
+
167
+ ```ts
168
+ mod.http.fetch(url: string, init?: HttpInit): Promise<HttpResponse>
169
+ ```
170
+
171
+ - `init` takes `method` (`GET` by default), `headers`, `body` as text, and `socketPath`, the absolute path of a Unix socket the request goes over.
172
+ - The response is `{ status, ok, headers, text }`. `ok` is true for a 2xx status. Header names are lower case.
173
+
174
+ ### mod.settings
175
+
176
+ ```ts
177
+ mod.settings.read(args?: { source?: 'user' | 'project' | 'local' | 'flag' | 'policy' }): Promise<Settings>
178
+ ```
179
+
180
+ Without a `source` it answers the settings Claude Code runs under, every source merged. With a `source` it answers that one source: `user` is `~/.claude/settings.json`, `project` is `.claude/settings.json`, and `local` is `.claude/settings.local.json`. The answer is keyed as a `settings.json` is, such as `permissions`, `env`, and `enabledPlugins`.
181
+
182
+ ```ts
183
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
184
+
185
+ export const pluginCount = defineMod({
186
+ name: 'plugin-count',
187
+ setup(mod) {
188
+ mod.on('SessionStart', async () => {
189
+ const enabled = (await mod.settings.read({ source: 'user' }))['enabledPlugins'] as Record<string, unknown> | undefined
190
+ mod.ui.toast(`${Object.keys(enabled ?? {}).length} plugins are enabled`)
191
+ })
192
+ },
193
+ })
194
+ ```
195
+
196
+ ## messageOf
197
+
198
+ ```ts
199
+ messageOf(error: unknown): string
200
+ ```
201
+
202
+ Returns an `Error`'s `message`, or the value as text. Use it in a `catch`, where the error is `unknown`.
203
+
204
+ ```ts
205
+ import { defineMod, messageOf } from '../node_modules/@cmodjs/core/mod.js'
206
+
207
+ export const notes = defineMod({
208
+ name: 'notes',
209
+ setup(mod) {
210
+ mod.on('Stop', async () => {
211
+ await mod.fs.write(`${mod.dataFolder}/last-stop.txt`, new Date().toISOString()).catch((error: unknown) => {
212
+ mod.ui.toast(`notes could not save: ${messageOf(error)}`)
213
+ })
214
+ })
215
+ },
216
+ })
217
+ ```
package/docs/state.md ADDED
@@ -0,0 +1,140 @@
1
+ # State
2
+
3
+ `mod.state` holds the mod's values. Claude Mod Manager (CMod) saves them, redraws the mod's panes and slot renders when one changes, and lets a person change the starting values without touching the mod's code.
4
+
5
+ ## Declare the state
6
+
7
+ `defineMod`'s `state` groups the starting values by how long they last:
8
+
9
+ | Group | How long a value lasts | Saved |
10
+ | --- | --- | --- |
11
+ | `memory` | Until `/clear`, `--resume`, `/branch`, or a reload of Claude Code or its plugins. A compaction keeps it. | No |
12
+ | `session` | This conversation. It comes back on `--resume`, starts over on `/clear`, and `/branch` copies it. | Yes |
13
+ | `project` | This project, in every conversation. | Yes |
14
+ | `global` | Every project. | Yes |
15
+
16
+ CMod keeps each `session` value for the 20 sessions that wrote it last, and each `project` value for the 20 projects that wrote it last. A session or project past those 20 starts that value over from its starting value.
17
+
18
+ Put values the mod works out again on every prompt, such as a git status, in `memory`.
19
+
20
+ Declare the state in its own file with its type, as `cmod new` does:
21
+
22
+ ```ts
23
+ export type TasksState = {
24
+ memory: { changedFiles: readonly string[] }
25
+ session: { prompts: number }
26
+ project: { isFrozen: boolean }
27
+ global: { defaultCommitPolicy: 'ask' | 'never' }
28
+ }
29
+
30
+ export const initialState: TasksState = {
31
+ memory: { changedFiles: [] },
32
+ session: { prompts: 0 },
33
+ project: { isFrozen: false },
34
+ global: { defaultCommitPolicy: 'ask' },
35
+ }
36
+ ```
37
+
38
+ Then pass it to `defineMod`:
39
+
40
+ ```ts
41
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
42
+ import { initialState } from './state.js'
43
+
44
+ export const tasks = defineMod({
45
+ name: 'tasks',
46
+ state: initialState,
47
+ setup(mod) {
48
+ mod.on('UserPromptSubmit', () => {
49
+ mod.state.session.prompts += 1
50
+ })
51
+ },
52
+ })
53
+ ```
54
+
55
+ A group other than `memory`, `session`, `project`, and `global` throws `<mod>: state.<group> is not a lifetime.` when the mod starts. A group that is not an object of values throws too.
56
+
57
+ ## Change a value
58
+
59
+ Assign to a key. CMod saves the new value and redraws the mod.
60
+
61
+ - A key the state does not declare throws `<mod>: mod.state.<group>.<key> is not declared. Add it to state.<group> in defineMod with its starting value.`
62
+ - An object or an array in the state is frozen. Changing it in place throws `<mod>: mod.state.<path> cannot change in place.` Assign a new value instead:
63
+
64
+ ```ts
65
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
66
+
67
+ export const reads = defineMod({
68
+ name: 'reads',
69
+ state: { memory: { readFiles: [] as readonly string[] } },
70
+ setup(mod) {
71
+ mod.on('PostToolUse', (input) => {
72
+ mod.state.memory.readFiles = [...mod.state.memory.readFiles, ...input.files.read]
73
+ })
74
+ },
75
+ })
76
+ ```
77
+
78
+ - Keep only JSON data in a saved group. A value CMod cannot save logs `<mod> could not keep state.<group>.<key>: <error>. Keep only JSON data in mod.state.`
79
+ - CMod saves values one at a time in the order they were assigned.
80
+
81
+ ## Where the values come from
82
+
83
+ When the mod starts, each value is, in order of precedence:
84
+
85
+ 1. The value the mod saved for this session, this project, or globally.
86
+ 2. The value in the project's `state.json`.
87
+ 3. The value in the person's own `state.json`.
88
+ 4. The starting value in `defineMod`.
89
+
90
+ After a `/cd` to another project, CMod loads that project's `project` values and reads its `state.json` again.
91
+
92
+ ## Let a person change the starting values
93
+
94
+ Each mod has two config folders, outside its code:
95
+
96
+ ```text
97
+ ~/.claude/cmods/<mod>/ the person's own, in every project
98
+ <project>/.claude/cmods/<mod>/ the team's, committed with the repository
99
+ ```
100
+
101
+ `CLAUDE_CONFIG_DIR` moves `~/.claude`. Removing the mod keeps both folders.
102
+
103
+ A `state.json` in either folder lists only the values to change:
104
+
105
+ ```json
106
+ { "global": { "defaultCommitPolicy": "never" }, "session": { "prompts": 10 } }
107
+ ```
108
+
109
+ - A `session` value is what each new conversation starts with. A `project` value is what each project starts with.
110
+ - A file in the project folder wins over the same value in the person's folder.
111
+ - A project `state.json` cannot set `global` values, because one repository cannot change a value for every project.
112
+ - A file cannot set `memory` values.
113
+
114
+ CMod ignores each entry it cannot use and logs one line naming the file, the entry, and the fix:
115
+
116
+ - a file that is not JSON, or not a JSON object
117
+ - a group the mod does not declare
118
+ - a key the mod does not declare
119
+ - a value whose type differs from the starting value's
120
+ - a `global` value in a project file
121
+ - a `memory` value
122
+
123
+ ## Let a person replace a Skill's text
124
+
125
+ A person replaces the text of a Skill the mod ships by putting a file at the same path in a config folder:
126
+
127
+ ```text
128
+ ~/.claude/cmods/<mod>/skills/<skill>/SKILL.md
129
+ <project>/.claude/cmods/<mod>/skills/<skill>/SKILL.md
130
+ ```
131
+
132
+ - Claude reads that file in place of the mod's from the next time the Skill loads, and updates of the mod keep it.
133
+ - The project file wins over the person's file.
134
+ - CMod drops the file's frontmatter, so the mod's own name and description stay.
135
+ - Only a Skill the mod ships in `skills/<skill>/` is replaced, and only when it loads as `<mod>:<skill>`.
136
+ - Deleting the file brings the mod's text back.
137
+
138
+ ## Test state
139
+
140
+ `testMod(mod, { state: { session: { prompts: 3 } } })` starts the tested mod with those values over the declared ones. `tested.state` reads the values after a test step. [testing.md](testing.md) covers both.
@@ -0,0 +1,263 @@
1
+ # Testing
2
+
3
+ `testMod` runs a mod against a fake Claude Code in `bun test`, so a test drives the mod the way a person and Claude do and reads what they would see. It needs no running Claude Code.
4
+
5
+ ```ts
6
+ testMod<State>(definition: ModDefinition<State>, options?: TestOptions<State>): TestedMod<State>
7
+ ```
8
+
9
+ ```ts
10
+ import { expect, test } from 'bun:test'
11
+ import { testMod } from '../node_modules/@cmodjs/core/testing.js'
12
+ import { todo } from '../src/mod.js'
13
+
14
+ test('/todo adds the item to the project list', async () => {
15
+ const tested = testMod(todo)
16
+
17
+ expect(await tested.type('/todo buy milk')).toEqual({ text: 'Added: buy milk', context: 'The to-do list is now: buy milk' })
18
+ expect(tested.state.project.items).toEqual(['buy milk'])
19
+ })
20
+ ```
21
+
22
+ Assert what a person or Claude sees: `tested.lines(...)`, `tested.shown`, `tested.state`, and what `tested.fire(...)`, `tested.type(...)`, and `tested.callTool(...)` return.
23
+
24
+ ## TestOptions
25
+
26
+ ```ts
27
+ type TestOptions<State> = {
28
+ readonly state?: { [Lifetime in keyof State]?: Partial<State[Lifetime]> }
29
+ readonly scope?: 'user' | 'project'
30
+ readonly projectRoot?: string
31
+ readonly cwd?: string
32
+ readonly dependencies?: { [Name in keyof CmodDependencies]?: CmodDependencies[Name] }
33
+ readonly files?: Readonly<Record<string, string>>
34
+ }
35
+ ```
36
+
37
+ | Option | What it sets | Default |
38
+ | --- | --- | --- |
39
+ | `state` | Values over the declared starting state | The declared state |
40
+ | `scope` | `'project'` makes the mod a project plugin, in `<projectRoot>/.claude/skills/<name>` | `'user'` |
41
+ | `projectRoot` | `mod.projectRoot` | `/test/plugins/<name>`, or `/test/project` for a project plugin |
42
+ | `cwd` | `mod.cwd` | `projectRoot` |
43
+ | `dependencies` | The methods other mods answer with | none: every call fails with `<name> is not installed. Run cmod install <name>.` |
44
+ | `files` | The fake file system, absolute path to text | no files |
45
+
46
+ A project plugin finds its repository through a `.git` entry at `projectRoot`, so a test of one puts it in `files`, such as `files: { '/test/project/.git': '' }`. Without it the project plugin has no repository, and its rules match calls in every folder, as a user mod's do.
47
+
48
+ The fake session's id is `test-session`, its model is `test-model`, and its home is `/test/home`. So `mod.dataFolder` is `/test/home/.local/share/cmod/data/<name>`. The mod is installed and draws on a terminal 80 columns wide.
49
+
50
+ ## TestedMod
51
+
52
+ ```ts
53
+ type TestedMod<State> = {
54
+ start(): Promise<void>
55
+ fire(event, input, below?): Promise<EventResult>
56
+ settle(): Promise<void>
57
+ lines(paneId: string): Promise<string[]>
58
+ lines(slot, props, requestId?): Promise<string[]>
59
+ type(line: string): Promise<Reply>
60
+ callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
61
+ press(paneId: string, key: string): Promise<void>
62
+ moveTo(projectRoot: string, cwd?: string): Promise<void>
63
+ readonly state: Readonly<State>
64
+ readonly calls: readonly TestCall[]
65
+ readonly fakes: Fakes
66
+ readonly shown: Shown
67
+ }
68
+ ```
69
+
70
+ ### start
71
+
72
+ Starts the mod: loads its state, runs `setup`, and adds its parts. `fire`, `lines`, `type`, `callTool`, `press`, and `moveTo` start the mod first, so a test calls `start` only before it reads `state` or `shown` with nothing else to run. It rejects with the reason when the mod does not start.
73
+
74
+ ### fire
75
+
76
+ ```ts
77
+ fire<E extends ModEvent>(event: E, input: TestInput<E>, below?): Promise<EventResult<`classic.${E}`>>
78
+ fire<N extends RoutedEvent>(event: N, input: Args<N>, below?): Promise<EventResult<N>>
79
+ ```
80
+
81
+ - `fire('UserPromptSubmit', { prompt: 'hello' })` runs the mod's hooks on a `ModEvent`. `TestInput<E>` is the event's input without the fields `fire` fills: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, and a new `tool_use_id` for each tool event.
82
+ - For a tool event, `files` is worked out from the call and the test's `files`, as in Claude Code.
83
+ - `below` is what the hooks beneath the mod answered, `{}` by default. Pass it to test how the mod's answer combines with another, such as a `deny` beneath. When nothing in the mod answers, `fire` returns `below` as it is.
84
+ - It returns the combined answer in Claude Code's own field names: `additionalContext` is a list, a `PreToolUse` decision is `allow: true`, or the key `ask` or `deny` with the reason as its value, and `decision: 'block'` is `block`.
85
+ - `fire('tool.check', { tool: 'Edit', input: { … }, tool_use_id: 'toolu_1' }, { decision: 'allow' })` asks the mod's permission rules about any call. A name with a dot fires that Claude Code hook-module event as it is.
86
+
87
+ ```ts
88
+ import { expect, test } from 'bun:test'
89
+ import { testMod } from '../node_modules/@cmodjs/core/testing.js'
90
+ import { guard } from '../src/mod.js'
91
+
92
+ test('a force push is denied with the reason', async () => {
93
+ const tested = testMod(guard)
94
+
95
+ const answer = await tested.fire('tool.check', { tool: 'Bash', input: { command: 'git push --force' }, tool_use_id: 'toolu_1' }, { decision: 'allow' })
96
+
97
+ expect(answer).toEqual({ decision: 'deny', reason: 'Force pushes rewrite shared history.' })
98
+ })
99
+ ```
100
+
101
+ ### Test a prompt with when
102
+
103
+ A `prompt` with `when` runs on the hook-module event `prompt.submit`, not on `UserPromptSubmit`, so `fire('UserPromptSubmit', …)` never reaches it. It adds its text to the `context` field of the event it passes down, and `fire` returns `below`, so the answer does not show the text either. Read it with a spy part used after the prompt, which sees the event the prompt passes down:
104
+
105
+ - The `prompt.submit` input is `{ text, wait, origin }`. A prompt the person typed has `wait: false` and `origin: { kind: 'composer' }`.
106
+ - `context` is a list of strings, each `# <name>\n<text>`. It is `undefined` when no prompt added anything.
107
+
108
+ ```ts
109
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
110
+ import { prompt } from '../node_modules/@cmodjs/core/jobs/prompt.js'
111
+
112
+ export const release = defineMod({
113
+ name: 'release',
114
+ setup(mod) {
115
+ mod.use(prompt({ name: 'release-steps', prompt: 'Run bun run release, then push the tag.', when: /\brelease\b/i }))
116
+ },
117
+ })
118
+ ```
119
+
120
+ ```ts
121
+ import { expect, test } from 'bun:test'
122
+ import { defineMod, type Part } from '../node_modules/@cmodjs/core/mod.js'
123
+ import { testMod } from '../node_modules/@cmodjs/core/testing.js'
124
+ import { release } from '../src/mod.js'
125
+
126
+ function contextBelow(passedOn: unknown[]): Part<void> {
127
+ return ({ on }) => {
128
+ on('prompt.submit', async (e, next) => {
129
+ passedOn.push(e.context)
130
+ return next(e)
131
+ })
132
+ }
133
+ }
134
+
135
+ test('a prompt about a release gets the release steps, and another prompt gets nothing', async () => {
136
+ const passedOn: unknown[] = []
137
+ const tested = testMod(
138
+ defineMod({
139
+ ...release,
140
+ setup(mod) {
141
+ release.setup(mod)
142
+ mod.use(contextBelow(passedOn))
143
+ },
144
+ }),
145
+ )
146
+
147
+ await tested.fire('prompt.submit', { text: 'Cut a release', wait: false, origin: { kind: 'composer' } }, { text: '' })
148
+ await tested.fire('prompt.submit', { text: 'Fix the bug', wait: false, origin: { kind: 'composer' } }, { text: '' })
149
+
150
+ expect(passedOn).toEqual([['# release-steps\nRun bun run release, then push the tag.'], undefined])
151
+ })
152
+ ```
153
+
154
+ ### settle
155
+
156
+ Waits until the work a hook started without awaiting it has run, such as a `void` call in a `Stop` hook. It returns while a fake the test holds open has not answered.
157
+
158
+ ### lines
159
+
160
+ - `lines(paneId)` draws an open pane and returns its rows of text. It rejects a pane that is not open: open it first with `tested.type('/<command>')` or `pane.open()` in the mod. The render gets the props `title`, `isFocused`, `bodyColumns`, and `placement: 'dock'`, and no `scroll` or `view`, so a render that reads those two gets `undefined` in a test.
161
+ - `lines(slot, props, requestId?)` draws a slot render with `props` and returns its rows. `Default` draws a plain listing of the props it gets, so a test sees what the render changed. Draw `slots.AssistantMessage` with a reply's `text` to test a markdown slot render. Draws that pass one `requestId` grow one streamed reply.
162
+
163
+ ```ts
164
+ import { expect, test } from 'bun:test'
165
+ import type { RenderPropsOf } from 'claude-code'
166
+ import { testMod } from '../node_modules/@cmodjs/core/testing.js'
167
+ import { slots } from '../node_modules/@cmodjs/core/ui/slots.js'
168
+ import { hints } from '../src/mod.js'
169
+
170
+ const band: RenderPropsOf['AbovePrompt'] = { hasSurvey: false, isWorking: false, maxRows: 12, bodyColumns: 75, scroll: { offset: 0, bodyRows: 12 }, view: {} }
171
+
172
+ test('the band shows the prompt count on its last line', async () => {
173
+ const tested = testMod(hints)
174
+ await tested.fire('UserPromptSubmit', { prompt: 'hello' })
175
+
176
+ expect((await tested.lines(slots.AbovePrompt, band)).at(-1)).toBe('Prompts: 1')
177
+ })
178
+ ```
179
+
180
+ ### type
181
+
182
+ `type('/<command> <arguments>')` runs a slash command as the person types it and returns its reply, `{ text?, context? }`. It rejects a line that does not start with `/`, and a command the mod does not add.
183
+
184
+ ### callTool
185
+
186
+ `callTool(name, input)` runs the mod's permission rules on a call of its own tool, then the tool. `name` is the name given to `tool`, without `mcp__<plugin>__`. It returns `{ result }`, or `{ deny }` when a rule or the input check denies the call. It rejects a call a rule would ask the person about, and a tool the mod does not add.
187
+
188
+ ### press
189
+
190
+ `press(paneId, key)` presses the `Button` of an open pane whose `key` or `label` is `key`, and awaits its `onPress`.
191
+
192
+ ### moveTo
193
+
194
+ `moveTo(projectRoot, cwd?)` moves the session. A new `projectRoot` runs a `/cd` there, so the mod loads that project's state. A `cwd` then runs a Bash `cd` there. The mod's `CwdChanged` hooks run for each move.
195
+
196
+ ### state
197
+
198
+ The mod's current values. Reading it before the mod starts throws.
199
+
200
+ ### calls
201
+
202
+ Every call the mod made to the fake Claude Code, in order, as `{ call, args }`, such as `{ call: 'process.run', args: [['git', 'status'], { cwd: '/work' }] }` and `{ call: 'cmod.call', args: [{ to, method, input }] }`.
203
+
204
+ ### fakes
205
+
206
+ ```ts
207
+ type Fakes = {
208
+ process: { run?, spawn? }
209
+ fs: { read?, write?, list?, exists?, stat? }
210
+ http: { fetch? }
211
+ settings: { read? }
212
+ ui: { ask?, open? }
213
+ agent: { list? }
214
+ clock: { after?, every? }
215
+ cmod: { call? }
216
+ }
217
+ ```
218
+
219
+ Set a fake to answer a call:
220
+
221
+ - A call with no fake throws `<call>(<args>) has no fake answer. Set fakes.<call> on the tested mod.` So set `fakes.process.run` before a test runs a program, and `fakes.ui.ask` before the mod asks a question.
222
+ - A `program` job is the exception: it catches the `process.spawn` error and retries after 1, 2, 4, 8 and 16 seconds on real timers, so its `ready()` waits past `bun test`'s 5-second timeout. Set `fakes.process.spawn` before a test starts a `program` job.
223
+ - `files` already fills the `fs` fakes: a write is read back, and `list` lists the given files. The fake file system resolves a relative path against `/`, not against `mod.cwd`, so a mod under test reads `${mod.projectRoot}/<file>` rather than a relative path. `fs.list()` with no path rejects.
224
+ - `ui.open` places every pane by default. A fake that answers `{ isPlaced: false }` holds a pane back.
225
+ - `clock.after(ms, fn)` and `clock.every(ms, fn)` each return `{ cancel() }`, and so must a fake of either. `clock.after` runs a real timer by default. `clock.every` never fires unless a fake keeps `fn` for the test to call.
226
+
227
+ ```ts
228
+ import { expect, test } from 'bun:test'
229
+ import { testMod } from '../node_modules/@cmodjs/core/testing.js'
230
+ import { committer } from '../src/mod.js'
231
+
232
+ test('a Commit answer commits', async () => {
233
+ const tested = testMod(committer)
234
+ tested.fakes.ui.ask = async () => 'Commit'
235
+ tested.fakes.process.run = async () => ({ exitCode: 0, stdout: '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false })
236
+
237
+ await tested.fire('Stop', { stop_hook_active: false })
238
+
239
+ expect(tested.calls.filter(({ call }) => call === 'process.run').map(({ args }) => args[0])).toEqual([['git', 'commit', '-am', 'wip']])
240
+ })
241
+ ```
242
+
243
+ ### shown
244
+
245
+ ```ts
246
+ type Shown = {
247
+ readonly toasts: string[]
248
+ readonly logs: string[]
249
+ readonly debug: string[]
250
+ readonly statuses: (string | undefined)[]
251
+ readonly openPanes: Set<string>
252
+ readonly commands: string[]
253
+ readonly tools: string[]
254
+ }
255
+ ```
256
+
257
+ What the person would see: the toasts, the log lines, the debug log lines, each status line text, the open panes, and the slash commands and tools the mod added.
258
+
259
+ `testing.js` exports the types `TestOptions`, `TestInput`, `TestedMod`, `Fakes`, `Shown`, and `TestCall`, so a test helper can take them.
260
+
261
+ ## Run the tests
262
+
263
+ Run `bun test` in the mod's folder. `cmod check` runs the test files the same way, all but those that import `claude-code/testing`. A test file that imports `claude-code/testing` is for Claude Code's own test kit, and `cmod check` runs those with `claude plugin test` instead. `testMod` tests import neither.