@cmodjs/core 0.1.2 → 0.1.4
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/README.md +6 -2
- package/docs/commands.md +175 -0
- package/docs/dependencies.md +102 -0
- package/docs/hooks.md +156 -0
- package/docs/index.md +101 -0
- package/docs/install-steps.md +151 -0
- package/docs/jobs.md +450 -0
- package/docs/mod.md +217 -0
- package/docs/state.md +140 -0
- package/docs/testing.md +247 -0
- package/docs/ui.md +295 -0
- package/mod.d.ts +1 -0
- package/mod.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/mod.ts +1 -0
- package/src/testing.ts +12 -4
- package/testing.d.ts.map +1 -1
- package/testing.js +10 -3
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.
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
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. 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
|
+
- Left out, `below` is `{}`, except on `prompt.submit`, where it is what Claude Code answers: the prompt that entered, `{ text, context, origin }`, with the `context` the mod's hooks added.
|
|
85
|
+
- 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`.
|
|
86
|
+
- `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.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { expect, test } from 'bun:test'
|
|
90
|
+
import { testMod } from '../node_modules/@cmodjs/core/testing.js'
|
|
91
|
+
import { guard } from '../src/mod.js'
|
|
92
|
+
|
|
93
|
+
test('a force push is denied with the reason', async () => {
|
|
94
|
+
const tested = testMod(guard)
|
|
95
|
+
|
|
96
|
+
const answer = await tested.fire('tool.check', { tool: 'Bash', input: { command: 'git push --force' }, tool_use_id: 'toolu_1' }, { decision: 'allow' })
|
|
97
|
+
|
|
98
|
+
expect(answer).toEqual({ decision: 'deny', reason: 'Force pushes rewrite shared history.' })
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Test a prompt with when
|
|
103
|
+
|
|
104
|
+
A `prompt` with `when` runs on the hook-module event `prompt.submit`, not on `UserPromptSubmit`, so `fire('UserPromptSubmit', …)` never reaches it. `fire('prompt.submit', …)` returns the prompt that entered, with the text the prompt added in its `context`:
|
|
105
|
+
|
|
106
|
+
- The `prompt.submit` input is `{ text, wait, origin }`. A prompt the person typed has `wait: false` and `origin: { kind: 'composer' }`.
|
|
107
|
+
- `context` is a list of strings, each `# <name>\n<text>`. The answer has no `context` when no prompt added anything.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
111
|
+
import { prompt } from '../node_modules/@cmodjs/core/jobs/prompt.js'
|
|
112
|
+
|
|
113
|
+
export const release = defineMod({
|
|
114
|
+
name: 'release',
|
|
115
|
+
setup(mod) {
|
|
116
|
+
mod.use(prompt({ name: 'release-steps', prompt: 'Run bun run release, then push the tag.', when: /\brelease\b/i }))
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { expect, test } from 'bun:test'
|
|
123
|
+
import { testMod } from '../node_modules/@cmodjs/core/testing.js'
|
|
124
|
+
import { release } from '../src/mod.js'
|
|
125
|
+
|
|
126
|
+
test('a prompt about a release gets the release steps, and another prompt gets nothing', async () => {
|
|
127
|
+
const tested = testMod(release)
|
|
128
|
+
const typed = { wait: false, origin: { kind: 'composer' } } as const
|
|
129
|
+
|
|
130
|
+
const aboutRelease = await tested.fire('prompt.submit', { text: 'Cut a release', ...typed })
|
|
131
|
+
const aboutBug = await tested.fire('prompt.submit', { text: 'Fix the bug', ...typed })
|
|
132
|
+
|
|
133
|
+
expect(aboutRelease).toEqual({ text: 'Cut a release', context: ['# release-steps\nRun bun run release, then push the tag.'], origin: typed.origin })
|
|
134
|
+
expect(aboutBug).toEqual({ text: 'Fix the bug', origin: typed.origin })
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### settle
|
|
139
|
+
|
|
140
|
+
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.
|
|
141
|
+
|
|
142
|
+
### lines
|
|
143
|
+
|
|
144
|
+
- `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: false`, `bodyColumns`, `placement: 'dock'`, `scroll: { offset: 0, bodyRows: 24 }`, and `view: {}`, the main conversation's view.
|
|
145
|
+
- `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.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { expect, test } from 'bun:test'
|
|
149
|
+
import type { RenderPropsOf } from 'claude-code'
|
|
150
|
+
import { testMod } from '../node_modules/@cmodjs/core/testing.js'
|
|
151
|
+
import { slots } from '../node_modules/@cmodjs/core/ui/slots.js'
|
|
152
|
+
import { hints } from '../src/mod.js'
|
|
153
|
+
|
|
154
|
+
const band: RenderPropsOf['AbovePrompt'] = { hasSurvey: false, isWorking: false, maxRows: 12, bodyColumns: 75, scroll: { offset: 0, bodyRows: 12 }, view: {} }
|
|
155
|
+
|
|
156
|
+
test('the band shows the prompt count on its last line', async () => {
|
|
157
|
+
const tested = testMod(hints)
|
|
158
|
+
await tested.fire('UserPromptSubmit', { prompt: 'hello' })
|
|
159
|
+
|
|
160
|
+
expect((await tested.lines(slots.AbovePrompt, band)).at(-1)).toBe('Prompts: 1')
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### type
|
|
165
|
+
|
|
166
|
+
`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.
|
|
167
|
+
|
|
168
|
+
### callTool
|
|
169
|
+
|
|
170
|
+
`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.
|
|
171
|
+
|
|
172
|
+
### press
|
|
173
|
+
|
|
174
|
+
`press(paneId, key)` presses the `Button` of an open pane whose `key` or `label` is `key`, and awaits its `onPress`.
|
|
175
|
+
|
|
176
|
+
### moveTo
|
|
177
|
+
|
|
178
|
+
`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.
|
|
179
|
+
|
|
180
|
+
### state
|
|
181
|
+
|
|
182
|
+
The mod's current values. Reading it before the mod starts throws.
|
|
183
|
+
|
|
184
|
+
### calls
|
|
185
|
+
|
|
186
|
+
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 }] }`.
|
|
187
|
+
|
|
188
|
+
### fakes
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
type Fakes = {
|
|
192
|
+
process: { run?, spawn? }
|
|
193
|
+
fs: { read?, write?, list?, exists?, stat? }
|
|
194
|
+
http: { fetch? }
|
|
195
|
+
settings: { read? }
|
|
196
|
+
ui: { ask?, open? }
|
|
197
|
+
agent: { list? }
|
|
198
|
+
clock: { after?, every? }
|
|
199
|
+
cmod: { call? }
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Set a fake to answer a call:
|
|
204
|
+
|
|
205
|
+
- 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.
|
|
206
|
+
- 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.
|
|
207
|
+
- `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.
|
|
208
|
+
- `ui.open` places every pane by default. A fake that answers `{ isPlaced: false }` holds a pane back.
|
|
209
|
+
- `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.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import { expect, test } from 'bun:test'
|
|
213
|
+
import { testMod } from '../node_modules/@cmodjs/core/testing.js'
|
|
214
|
+
import { committer } from '../src/mod.js'
|
|
215
|
+
|
|
216
|
+
test('a Commit answer commits', async () => {
|
|
217
|
+
const tested = testMod(committer)
|
|
218
|
+
tested.fakes.ui.ask = async () => 'Commit'
|
|
219
|
+
tested.fakes.process.run = async () => ({ exitCode: 0, stdout: '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false })
|
|
220
|
+
|
|
221
|
+
await tested.fire('Stop', { stop_hook_active: false })
|
|
222
|
+
|
|
223
|
+
expect(tested.calls.filter(({ call }) => call === 'process.run').map(({ args }) => args[0])).toEqual([['git', 'commit', '-am', 'wip']])
|
|
224
|
+
})
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### shown
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
type Shown = {
|
|
231
|
+
readonly toasts: string[]
|
|
232
|
+
readonly logs: string[]
|
|
233
|
+
readonly debug: string[]
|
|
234
|
+
readonly statuses: (string | undefined)[]
|
|
235
|
+
readonly openPanes: Set<string>
|
|
236
|
+
readonly commands: string[]
|
|
237
|
+
readonly tools: string[]
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
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.
|
|
242
|
+
|
|
243
|
+
`testing.js` exports the types `TestOptions`, `TestInput`, `TestedMod`, `Fakes`, `Shown`, and `TestCall`, so a test helper can take them.
|
|
244
|
+
|
|
245
|
+
## Run the tests
|
|
246
|
+
|
|
247
|
+
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.
|