@cmodjs/core 0.1.2 → 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/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 +452 -0
- package/docs/mod.md +217 -0
- package/docs/state.md +140 -0
- package/docs/testing.md +263 -0
- package/docs/ui.md +295 -0
- package/package.json +3 -2
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Install steps
|
|
2
|
+
|
|
3
|
+
A mod that changes the machine, such as by adding a shell alias or downloading a program, names its install and uninstall steps in its `package.json`. The cmod program of Claude Mod Manager (CMod) runs them with the person's consent, and runs the uninstall step after the person removes the mod. The library never runs them.
|
|
4
|
+
|
|
5
|
+
## The cmod key
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"cmod": {
|
|
10
|
+
"install": "./setup/install.sh",
|
|
11
|
+
"uninstall": "./setup/uninstall.sh",
|
|
12
|
+
"program": "hello"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Each key is optional.
|
|
18
|
+
|
|
19
|
+
- `install` is one shell command that sets the mod up.
|
|
20
|
+
- `uninstall` is one shell command that undoes it.
|
|
21
|
+
- `program` is the name of a program CMod downloads from the mod's GitHub release into `~/.local/bin`. [Ship a program](#ship-a-program) explains it.
|
|
22
|
+
|
|
23
|
+
CMod refuses a `cmod` key that breaks these rules, with the fix in the message:
|
|
24
|
+
|
|
25
|
+
- `cmod` is an object.
|
|
26
|
+
- `install` and `uninstall` are each one command on one line, with no tab.
|
|
27
|
+
- Each names at least one script file inside the mod, and no script at the mod's root. CMod asks consent for the whole folder of each script, so put the scripts in a folder, such as `setup/`.
|
|
28
|
+
- A symbolic link in a script folder points at a file.
|
|
29
|
+
- `program` is a command name: letters, digits, `.`, `_`, and `-`, starting with a letter or digit.
|
|
30
|
+
- `.claude-plugin/plugin.json` has a `version`.
|
|
31
|
+
|
|
32
|
+
## Consent
|
|
33
|
+
|
|
34
|
+
Before a step runs for the first time, CMod shows the person the commands and asks to run them. CMod hashes the commands and every file in each script folder, and remembers the hash the person approved. A change to any of those files asks again.
|
|
35
|
+
|
|
36
|
+
- In Claude Code, the mod asks `<mod> runs <install> to install, and <uninstall> when you remove it. Run it now?` with `Install` and `Not now`. A progress line above the prompt shows the install.
|
|
37
|
+
- In a session with no screen, such as `claude -p`, the mod waits and logs `<mod> waits for consent to run <install>. Run cmod install <mod> in a terminal.`
|
|
38
|
+
- In a terminal, `cmod install`, `cmod link`, `cmod try`, and `cmod update` print the commands and ask `Run them? [y/N]`. `--yes` approves without asking.
|
|
39
|
+
- `Not now` leaves the mod off and logs `<mod> is not installed. Run cmod install <mod> to install it.`
|
|
40
|
+
|
|
41
|
+
The mod starts only once its install step has run ([mod.md](mod.md)).
|
|
42
|
+
|
|
43
|
+
## How a step runs
|
|
44
|
+
|
|
45
|
+
CMod runs a step with `sh -c "<command>"` in the mod's folder. So `./setup/install.sh` runs the script itself, and the script must be executable: `chmod +x setup/*.sh`.
|
|
46
|
+
|
|
47
|
+
The step gets these environment variables:
|
|
48
|
+
|
|
49
|
+
| Variable | Value |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `CMOD_PLUGIN_ROOT` | The mod's folder |
|
|
52
|
+
| `CMOD_DATA` | The mod's data folder, which CMod creates first. It is the folder `mod.dataFolder` names. |
|
|
53
|
+
| `CMOD_VERSION` | The mod's version from `plugin.json` |
|
|
54
|
+
| `PATH` | The person's `PATH`, with the cmod program's own folder first, so the step can run `cmod download` |
|
|
55
|
+
|
|
56
|
+
- A line `progress <done> <total> <label>` on standard output moves the progress bar.
|
|
57
|
+
- Every other line goes to the log.
|
|
58
|
+
- The last line on standard error names the failure when the step fails.
|
|
59
|
+
|
|
60
|
+
## When the install step runs again
|
|
61
|
+
|
|
62
|
+
The install step runs again on every new version of the mod and whenever its scripts change. An unchanged mod runs nothing. So the install step must work over an existing install.
|
|
63
|
+
|
|
64
|
+
## When the install step fails
|
|
65
|
+
|
|
66
|
+
- When the first install of a mod fails or is stopped, CMod runs the uninstall step to undo it. So the uninstall step must work on a partial install.
|
|
67
|
+
- When the install step of an upgrade fails or is stopped, CMod keeps the version it set up before.
|
|
68
|
+
- The failure shows on the mod's progress line with `Fix the cause, then run: cmod install <mod>`.
|
|
69
|
+
|
|
70
|
+
## The uninstall step
|
|
71
|
+
|
|
72
|
+
When the install step succeeds, CMod saves a copy of every script folder the uninstall step names. It runs the uninstall step from that copy, so it runs after the mod's own folder is gone.
|
|
73
|
+
|
|
74
|
+
- After the person removes the mod with `/plugin uninstall`, the CMod plugin runs the uninstall step at the next session start or prompt.
|
|
75
|
+
- `cmod remove <mod>` uninstalls the mod and runs the step at once.
|
|
76
|
+
- In the copy, `CMOD_PLUGIN_ROOT` is the copy's folder, and `CMOD_DATA` and `CMOD_VERSION` are the same as at install.
|
|
77
|
+
- After the step, CMod deletes the mod's install record, its program, its data folder, and the approvals of its scripts. It keeps the mod's config folders, because the files there are the person's.
|
|
78
|
+
- A failed uninstall step keeps the record. `cmod teardown <mod>` runs it again.
|
|
79
|
+
|
|
80
|
+
## Example
|
|
81
|
+
|
|
82
|
+
`setup/install.sh` works over an existing install. It writes the alias file again, and adds the line to `.zshrc` only when it is missing:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
#!/bin/sh
|
|
86
|
+
set -e
|
|
87
|
+
echo 'progress 0 1 Writing the alias'
|
|
88
|
+
printf "alias gs='git status'\n" > "$CMOD_DATA/aliases.sh"
|
|
89
|
+
grep -qF "$CMOD_DATA/aliases.sh" "$HOME/.zshrc" 2>/dev/null || printf '. "%s"\n' "$CMOD_DATA/aliases.sh" >> "$HOME/.zshrc"
|
|
90
|
+
echo 'progress 1 1 Wrote the alias'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`setup/uninstall.sh` works on a partial install. It removes the line only from a `.zshrc` that exists, and writes the file in place, so a `.zshrc` that is a symbolic link stays one:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
#!/bin/sh
|
|
97
|
+
set -e
|
|
98
|
+
[ -f "$HOME/.zshrc" ] || exit 0
|
|
99
|
+
kept=$(mktemp)
|
|
100
|
+
grep -vF "$CMOD_DATA/aliases.sh" "$HOME/.zshrc" > "$kept" || true
|
|
101
|
+
cat "$kept" > "$HOME/.zshrc"
|
|
102
|
+
rm "$kept"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
CMod deletes `$CMOD_DATA` itself after the uninstall step.
|
|
106
|
+
|
|
107
|
+
## Download a program in an install step
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
cmod download <program> <machine> <url> <sha256> [<machine> <url> <sha256> …]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
An install script runs `cmod download` to fetch a program built by someone else. It picks the download for this machine, checks its SHA-256, unpacks a `.tar.gz` or a `.zip` or takes the file as it is, and moves the file named `<program>` to `$CMOD_DATA/bin/<program>`. A download whose SHA-256 differs installs nothing. Machines are `darwin-arm64`, `darwin-x64`, `linux-arm64`, and `linux-x64`.
|
|
114
|
+
|
|
115
|
+
`$CMOD_DATA/bin/<program>` is `${mod.dataFolder}/bin/<program>` in mod code. That folder is not on `PATH`, so mod code names the program by that full path, such as `` mod.process.run([`${mod.dataFolder}/bin/mermaid-ascii`, '--help']) ``, and so does the `command` of a `program` job.
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
cmod download mermaid-ascii \
|
|
119
|
+
darwin-arm64 https://example.com/mermaid-ascii_Darwin_arm64.tar.gz <sha256> \
|
|
120
|
+
linux-x64 https://example.com/mermaid-ascii_Linux_x86_64.tar.gz <sha256>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Ship a program
|
|
124
|
+
|
|
125
|
+
A mod that builds its own command-line program keeps its source in `cli/`, and CMod builds it, releases it, and puts it on the person's `PATH`.
|
|
126
|
+
|
|
127
|
+
1. `cli/package.json` declares the build:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"name": "hello",
|
|
132
|
+
"bin": { "hello": "bin/hello" },
|
|
133
|
+
"cmod": { "build": "bun run build", "output": "dist" }
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The program's name is the one `bin` command, or else `name`. A Rust program declares the same keys under `[package.metadata.cmod]` in `cli/Cargo.toml`.
|
|
138
|
+
|
|
139
|
+
2. The build writes one file per machine into `output`, each named `<program>-<os>-<arch>`, such as `hello-darwin-arm64`.
|
|
140
|
+
|
|
141
|
+
3. The mod's own `package.json` names the program: `"cmod": { "program": "hello" }`. `.claude-plugin/plugin.json` names the GitHub `repository`.
|
|
142
|
+
|
|
143
|
+
Then:
|
|
144
|
+
|
|
145
|
+
- `cmod link` builds the program and links this machine's build to `~/.local/bin/<program>`.
|
|
146
|
+
- `cmod publish` builds every machine's file and attaches each to the GitHub release `v<version>`, with a `SHA256SUMS` file. It refuses a `cli/` program that the `package.json` `program` key does not name.
|
|
147
|
+
- On install, CMod downloads `<repository>/releases/download/v<version>/<program>-<machine>`, checks it against `SHA256SUMS`, runs `<program> --version`, and links it to `~/.local/bin/<program>`.
|
|
148
|
+
- CMod refuses to replace a `~/.local/bin/<program>` it did not make, and a program `PATH` already finds somewhere else.
|
|
149
|
+
- `cmod unlink` and the uninstall remove the program.
|
|
150
|
+
|
|
151
|
+
The CMod repository ships the `cmod` program this way. Its `package.json` holds `"cmod": { "program": "cmod" }`, and its `cli/package.json` holds `"cmod": { "build": "bun run build", "output": "dist" }`.
|
package/docs/jobs.md
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
# Jobs
|
|
2
|
+
|
|
3
|
+
A job is a ready-made part of a mod: a slash command, a tool, permission rules, a check, a prompt, a status line, or a background program. Add each in `setup` with `mod.use`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
mod.use<Handle>(part: Part<Handle, State>): Handle
|
|
7
|
+
|
|
8
|
+
type Part<Handle, State> = (context: PartContext<State>) => Handle
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Each job function, such as `slashCommand(options)`, returns a `Part`. `mod.use` runs the part and returns its handle. A job that has nothing to hand back returns `void`.
|
|
12
|
+
|
|
13
|
+
A job's bad options throw at one of two times. `check`, `permissions`, and `prompt` check their options when the job function is called. `slashCommand`, `tool`, `statusLine`, and `program` check theirs inside the part, at `mod.use`, so the error surfaces as `its setup function threw: …`.
|
|
14
|
+
|
|
15
|
+
| Job | Import | Adds |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `slashCommand` | `jobs/slash-command.js` | A slash command the person types |
|
|
18
|
+
| `tool` | `jobs/tool.js` | A tool Claude calls |
|
|
19
|
+
| `permissions` | `jobs/permissions.js` | Rules that deny a call, or ask the person first |
|
|
20
|
+
| `check` | `jobs/check.js` | A command that runs after matching calls, and reports a failure to Claude |
|
|
21
|
+
| `prompt` | `jobs/prompt.js` | Text Claude reads: once, before matching prompts, or after matching calls |
|
|
22
|
+
| `statusLine` | `jobs/status-line.js` | A status line |
|
|
23
|
+
| `program` | `jobs/program.js` | A background program the mod talks to over HTTP |
|
|
24
|
+
|
|
25
|
+
Each job announces itself. The first time a version starts, Claude Mod Manager (CMod) logs what the mod added, such as `tasks added /tasks, the open_ticket tool and 2 permission rules.`
|
|
26
|
+
|
|
27
|
+
## Deadlines
|
|
28
|
+
|
|
29
|
+
Inside a job, the `mod` a function receives fails each call that runs past the job's deadline, with `<call> passed the <n> s deadline of <job>`, such as `mod.fs.read passed the 30 s deadline of tool`.
|
|
30
|
+
|
|
31
|
+
| Job and function | Deadline of each `mod.fs`, `mod.http`, `mod.dependencies`, and `mod.process.spawn` call | `mod.process.run` |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `slashCommand`'s `reply` | 30 s | 30 s, or `timeoutMs` up to 10 minutes |
|
|
34
|
+
| `tool`'s `execute` | 30 s | 30 s, or `timeoutMs` up to 10 minutes |
|
|
35
|
+
| `prompt`'s `prompt` | 5 s | 5 s, or `timeoutMs` up to 10 minutes |
|
|
36
|
+
| `statusLine`'s `text` | 2 s | 2 s at most |
|
|
37
|
+
| `permissions`' `when` | 2 s | 2 s at most |
|
|
38
|
+
| `check`'s `run` | none: `run` is the check's only call | `timeoutMs`, 60 s by default, 10 minutes at most |
|
|
39
|
+
|
|
40
|
+
Outside a job, in a hook or a render, a `mod.dependencies` call fails after 30 s, and the other calls have only Claude Code's own limits ([mod.md](mod.md)).
|
|
41
|
+
|
|
42
|
+
## slashCommand
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
slashCommand<State>(options: {
|
|
46
|
+
readonly name: string
|
|
47
|
+
readonly description: string
|
|
48
|
+
readonly argumentHint?: string
|
|
49
|
+
readonly immediate?: true
|
|
50
|
+
readonly reply: (input: { args: string; positionals: string[] }, mod: Mod<State>) => Reply | void | Promise<Reply | void>
|
|
51
|
+
}): Part<void, State>
|
|
52
|
+
|
|
53
|
+
type Reply = string | { text?: string; context?: string } | undefined
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `name` is the command without the slash: 1 to 64 letters, digits, `_`, or `-`. The person types `/<name>`. Another name throws `<mod>: "<name>" is not a slash command name.`
|
|
57
|
+
- `description` is the line the typeahead and `/help` show.
|
|
58
|
+
- `argumentHint` is drawn dim after the name, such as `[path]`.
|
|
59
|
+
- `immediate: true` runs the command at once while Claude is still working. Without it, the command waits for the turn to end.
|
|
60
|
+
- `reply` gets `args` and `positionals`. `args` is everything after the name as Claude Code passes it, and `''` for a bare `/<name>`. CMod hands it on unchanged. `positionals` is that text split into words the way a shell splits them, so extra spaces never reach it. `tested.type('/todo milk')` drops the spaces after the name.
|
|
61
|
+
- A `string` reply, or `text`, shows as the command's output. `context` is text Claude reads after the output, and the person never sees. `undefined` shows nothing.
|
|
62
|
+
- A `reply` that throws shows `/<name> failed: <error>`.
|
|
63
|
+
- Two commands with one name in a mod throw `<mod>: the slash command /<name> is already added. Give each slashCommand its own name.`
|
|
64
|
+
- When Claude Code refuses to add the command, CMod logs `<mod>: /<name> is not added: <error>`.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
68
|
+
import { slashCommand } from '../node_modules/@cmodjs/core/jobs/slash-command.js'
|
|
69
|
+
|
|
70
|
+
export const todo = defineMod({
|
|
71
|
+
name: 'todo',
|
|
72
|
+
state: { project: { items: [] as readonly string[] } },
|
|
73
|
+
setup(mod) {
|
|
74
|
+
mod.use(
|
|
75
|
+
slashCommand({
|
|
76
|
+
name: 'todo',
|
|
77
|
+
description: 'Add an item to the project to-do list',
|
|
78
|
+
argumentHint: '<item>',
|
|
79
|
+
reply: ({ args }, mod) => {
|
|
80
|
+
mod.state.project.items = [...mod.state.project.items, args]
|
|
81
|
+
return { text: `Added: ${args}`, context: `The to-do list is now: ${mod.state.project.items.join(', ')}` }
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## tool
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
tool<Schema, State>(options: {
|
|
93
|
+
readonly name: string
|
|
94
|
+
readonly description: string
|
|
95
|
+
readonly inputSchema?: Schema
|
|
96
|
+
readonly execute: (input: FromSchema<Schema>, mod: Mod<State>) => unknown
|
|
97
|
+
}): Part<{ readonly name: string }, State>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- `name` is 1 to 64 letters, digits, `_`, or `-`. Claude sees the tool as `mcp__<plugin name>__<name>`, and the handle's `name` is that full name.
|
|
101
|
+
- `description` tells Claude what the tool does.
|
|
102
|
+
- `inputSchema` is a JSON Schema with `type: 'object'`. It types `execute`'s `input`, so write it inline. CMod checks each call's input against it, draft 2020-12, and denies an input that does not fit with the list of its errors. Its properties cannot be named `tool`, `tool_use_id`, `consent`, or `agentId`, which Claude Code keeps for itself.
|
|
103
|
+
- `execute` returns the result Claude reads. A `string` and an array of content blocks go to Claude as they are. Any other value goes as JSON. `undefined` returns nothing.
|
|
104
|
+
- An input that does not fit the schema is denied with one line per error under a heading, such as `The open_ticket tool input is not valid:` followed by `- #: Instance does not have required property "title".` The text after `- ` is the location in the input, `#` for its root, and the validator's message.
|
|
105
|
+
- An `execute` that throws denies the call with `The <name> tool failed: <error>`.
|
|
106
|
+
- Two tools with one name in a mod throw.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
110
|
+
import { tool } from '../node_modules/@cmodjs/core/jobs/tool.js'
|
|
111
|
+
|
|
112
|
+
export const tickets = defineMod({
|
|
113
|
+
name: 'tickets',
|
|
114
|
+
state: { project: { opened: [] as readonly string[] } },
|
|
115
|
+
setup(mod) {
|
|
116
|
+
mod.use(
|
|
117
|
+
tool({
|
|
118
|
+
name: 'open_ticket',
|
|
119
|
+
description: 'Open a ticket with a title',
|
|
120
|
+
inputSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] },
|
|
121
|
+
execute: (input, mod) => {
|
|
122
|
+
mod.state.project.opened = [...mod.state.project.opened, input.title]
|
|
123
|
+
return `Opened "${input.title}"`
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Targets: which calls a rule, check, or prompt matches
|
|
132
|
+
|
|
133
|
+
`permissions`, `check`, and `prompt` name the calls they act on with targets. A target names exactly one key, with a pattern or a list of patterns. A target that names none or two throws an error that starts `A rule names exactly one of command, read, write, fetch, subagent, tool`.
|
|
134
|
+
|
|
135
|
+
| Target | Matches |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `{ command: 'git push --force' }` | A Bash command. [Command patterns](#command-patterns) explains how it matches. |
|
|
138
|
+
| `{ read: 'src/**/*.ts' }` | A `Read` of a matching file, a `Grep` whose `path` matches (the working folder when it names none), a `Glob` whose `pattern`, joined to its `path`, matches, and a Bash command that reads a matching file |
|
|
139
|
+
| `{ write: '.env*' }` | A call that changes a matching file: `Edit`, `Write`, `NotebookEdit`, and a Bash command that writes the file |
|
|
140
|
+
| `{ fetch: 'https://api.example.com/**' }` | A `WebFetch` of a matching URL, and a Bash command that fetches it. The glob runs over the whole URL, so `**` crosses `/` and `*` stops at it: `https://example.com/**` matches every page of that host, `https://*.example.com/**` every page of its subdomains, and `https://example.com/*` only the pages one level down. A bare host, such as `example.com`, matches nothing. |
|
|
141
|
+
| `{ subagent: 'explorer' }` | An `Agent` call that starts a matching subagent type. A call that names none starts `general-purpose`. |
|
|
142
|
+
| `{ tool: 'mcp__tickets__*' }` | A call of a matching tool name |
|
|
143
|
+
|
|
144
|
+
- A `read`, `write`, `fetch`, `subagent`, or `tool` pattern is a glob, matched without regard to case.
|
|
145
|
+
- A `read` or `write` pattern is relative to the project root. A pattern that starts with `~/` is relative to the home folder, and one that starts with `/` is absolute. A pattern that starts with `**` matches an absolute path too. A path that leads through a symbolic link also matches by where it lands.
|
|
146
|
+
- A mod installed for the person matches calls in every folder. A project plugin, made with `cmod new --project`, matches only calls inside its own repository's work trees, linked worktrees included, and its relative patterns start at the root of the work tree the call is in.
|
|
147
|
+
|
|
148
|
+
### Command patterns
|
|
149
|
+
|
|
150
|
+
A `command` pattern is not a glob. CMod splits it into words the way it splits a Bash command, and matches each command of the call:
|
|
151
|
+
|
|
152
|
+
- `'*'` alone matches every shell command. A `*` inside a pattern is a plain character.
|
|
153
|
+
- The first word is the program, matched by its name, so `/bin/rm` counts as `rm`.
|
|
154
|
+
- Every other word that does not start with `-` must appear in the command in the pattern's order, and other words may sit between them: `git push` matches `git push origin main`.
|
|
155
|
+
- Every flag, a word that starts with `-`, must appear somewhere in the command, in any order. `--force` also matches `--force=yes`.
|
|
156
|
+
- CMod splits a cluster of short flags into single flags, in the pattern and in the command alike, so `rm -rf` matches `rm -rf`, `rm -fr`, and `rm -r -f`. A letter that takes a value keeps the rest of the cluster as its value. These programs keep their flags as written: `find`, `java`, `javac`, `go`, `gcc`, `g++`, `clang`, `clang++`, `swift`, `swiftc`, `xcodebuild`, `xcrun`, `ffmpeg`, `ffprobe`, `openssl`, `plutil`, `defaults`, `security`, and `codesign`.
|
|
157
|
+
- Words and flags match exactly, letter case included.
|
|
158
|
+
- Each command of a pipeline or a `&&` chain counts, and so does the command a wrapper runs: `sudo rm -rf /` holds the commands `sudo` and `rm -rf /`. The wrappers are `sudo`, `env`, `doas`, `timeout`, `flock`, `stdbuf`, `watch`, `nohup`, `nice`, `time`, `exec`, `command`, and `xargs`.
|
|
159
|
+
|
|
160
|
+
## permissions
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
permissions<State>(rules: { deny?: Rule<State>[]; ask?: Rule<State>[] }): Part<void, State>
|
|
164
|
+
|
|
165
|
+
type Rule<State> = Target & {
|
|
166
|
+
when?: (call: <the call of the target's key>, mod: Mod<State>) => boolean | Promise<boolean>
|
|
167
|
+
reason?: string
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
- Before each call, CMod tries every `deny` rule, then every `ask` rule. The first rule that matches decides, and its `reason` goes with the decision. No match leaves the call to Claude Code.
|
|
172
|
+
- The strictest answer wins: a mod's `ask` or `deny` beats Claude Code's `allow`, and Claude Code's `deny` beats a mod's `ask`.
|
|
173
|
+
- `when` narrows a rule. The rule matches only when `when` resolves true. A `when` that throws makes the rule match, and the reason adds `Its when check failed: <error>`.
|
|
174
|
+
- When CMod cannot read the call or the session, it denies the call with the reason.
|
|
175
|
+
- For a PowerShell command, or a Bash command CMod cannot fully parse, a `command` rule matches when every word of its pattern appears in the command line. A `read` or `write` rule matches when the command line holds the last segment of its pattern, after the last `/`, and only when that segment holds none of `*?[]{}`. So `{ write: 'Domain.md' }` matches such a command that names `Domain.md`, and `{ write: '**/.env*' }` never matches one.
|
|
176
|
+
- `permissions({})` throws `permissions: give it a deny or an ask rule, or remove it from setup.`
|
|
177
|
+
|
|
178
|
+
`when` gets the call, typed by the target's key. `jobs/permissions.js` exports each type:
|
|
179
|
+
|
|
180
|
+
| Key | Call type | Fields beyond `tool`, `agentId`, and `agentType` |
|
|
181
|
+
| --- | --- | --- |
|
|
182
|
+
| `command` | `CommandCall` | `commands`, each `[program, ...arguments]`, and `isFullyParsed` |
|
|
183
|
+
| `read`, `write` | `FileCall` | `path`, and for an `Edit` or a `Write`, `content`, the new text, and `previousContent`, the text before |
|
|
184
|
+
| `fetch` | `FetchCall` | `url` |
|
|
185
|
+
| `subagent`, `tool` | `ToolCall` | none |
|
|
186
|
+
|
|
187
|
+
`agentId` and `agentType` name the subagent that makes the call. In the main conversation `agentId` is absent, and `agentType` is the agent the session started as, when it started as one. `jobs/permissions.js` also exports `Target`, `Rule`, and `PermissionRules`.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
191
|
+
import { permissions } from '../node_modules/@cmodjs/core/jobs/permissions.js'
|
|
192
|
+
|
|
193
|
+
export const guard = defineMod({
|
|
194
|
+
name: 'guard',
|
|
195
|
+
state: { project: { isFrozen: false } },
|
|
196
|
+
setup(mod) {
|
|
197
|
+
mod.use(
|
|
198
|
+
permissions({
|
|
199
|
+
deny: [
|
|
200
|
+
{ command: 'git push --force', reason: 'Force pushes rewrite shared history.' },
|
|
201
|
+
{ command: 'git push', when: (_call, mod) => mod.state.project.isFrozen, reason: 'The branch is frozen.' },
|
|
202
|
+
{ write: '**/.env*', reason: 'Edit .env files by hand.' },
|
|
203
|
+
],
|
|
204
|
+
ask: [{ write: 'Domain.md', when: (call) => call.content?.includes('Avoid') === true, reason: 'Domain.md changes need a review.' }],
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## check
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
check<State>(options: { readonly after: Target | Target[]; readonly run: readonly string[]; readonly timeoutMs?: number }): Part<void, State>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- After each call that matches `after` and succeeds, CMod runs `run`. A call that was denied or failed runs nothing.
|
|
218
|
+
- For a `read` or `write` target, CMod adds the matched files that still exist to the end of `run`, as absolute paths. For any other target it runs `run` as it is.
|
|
219
|
+
- A mod installed for the person passes no folder, so Claude Code runs the command in the session's working folder. A project plugin runs it at the root of the work tree that holds the last matched file, or the matched command's folder.
|
|
220
|
+
- A command that exits with another code than 0 adds `<command> exited with <code>:` and its output to the call's result, so Claude reads the failure right after its own call.
|
|
221
|
+
- `timeoutMs` is 60 seconds by default and 10 minutes at most.
|
|
222
|
+
- An empty `after`, an empty `run`, or a `timeoutMs` out of range throws, naming the check and the fix.
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
226
|
+
import { check } from '../node_modules/@cmodjs/core/jobs/check.js'
|
|
227
|
+
|
|
228
|
+
export const lint = defineMod({
|
|
229
|
+
name: 'lint',
|
|
230
|
+
setup(mod) {
|
|
231
|
+
mod.use(check({ after: { write: '**/*.ts' }, run: ['bun', 'x', 'oxlint'] }))
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## prompt
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
prompt<State>(options: {
|
|
240
|
+
name: string
|
|
241
|
+
prompt: string | ((input: { userPrompt?: string; call?: ToolCall }, mod: Mod<State>) => string | undefined | Promise<string | undefined>)
|
|
242
|
+
when?: RegExp | ((userPrompt: string) => boolean)
|
|
243
|
+
after?: Target | Target[]
|
|
244
|
+
}): Part<void, State>
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`prompt` adds text that Claude reads. `when` and `after` pick when:
|
|
248
|
+
|
|
249
|
+
| Options | When Claude reads the text |
|
|
250
|
+
| --- | --- |
|
|
251
|
+
| neither | Once per conversation, as a block of context named `name`. A block of that name already in the context wins. |
|
|
252
|
+
| `when` | With each prompt the person sends whose text matches the `RegExp`, or for which the function returns true. `input.userPrompt` is the prompt. |
|
|
253
|
+
| `after` | After each call that matches a target and succeeds, with the call's result. `input.call` is the matched call. |
|
|
254
|
+
|
|
255
|
+
- With `when` or `after`, the text goes to Claude under the heading `# <name>`.
|
|
256
|
+
- A `prompt` function that returns `undefined` or `''` adds nothing. One that throws adds nothing, and the error goes to Claude Code's debug log.
|
|
257
|
+
- `name` cannot be blank. `when` and `after` together throw: add a second `prompt` for the other trigger. Two prompts with one name in a mod throw.
|
|
258
|
+
- A `when` prompt runs on `prompt.submit` and adds its text to the event it passes down, so a test reads it with a spy part. [testing.md](testing.md#test-a-prompt-with-when) shows how.
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
262
|
+
import { prompt } from '../node_modules/@cmodjs/core/jobs/prompt.js'
|
|
263
|
+
|
|
264
|
+
export const conventions = defineMod({
|
|
265
|
+
name: 'conventions',
|
|
266
|
+
setup(mod) {
|
|
267
|
+
mod.use(prompt({ name: 'Commit messages', prompt: 'Write commit messages in the imperative mood.' }))
|
|
268
|
+
mod.use(prompt({ name: 'Migrations', when: /\bmigrat/i, prompt: 'Every migration has a down step.' }))
|
|
269
|
+
mod.use(prompt({ name: 'Schema', after: { write: 'db/schema.sql' }, prompt: async (_input, mod) => `The schema is now:\n${await mod.fs.read(`${mod.projectRoot}/db/schema.sql`)}` }))
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## statusLine
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
statusLine<State>(options: {
|
|
278
|
+
readonly text: (usage: Usage, mod: Mod<State>) => string | undefined | Promise<string | undefined>
|
|
279
|
+
readonly interval?: number
|
|
280
|
+
}): Part<void, State>
|
|
281
|
+
|
|
282
|
+
type Usage = { model: string; context: { tokens?: number; window: number; percent?: number }; cost?: { usd: number } }
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
- `text` returns the status line's text, or `undefined` to clear it.
|
|
286
|
+
- CMod calls `text` when the mod starts, every `interval` milliseconds (10000 by default), and whenever Claude Code measures the session. It sends the text only when it changed.
|
|
287
|
+
- A `text` that throws keeps the last text, and the error goes to Claude Code's debug log.
|
|
288
|
+
- A mod has one status line. A second `statusLine` throws: join the texts in one.
|
|
289
|
+
- An `interval` below 1 throws.
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
293
|
+
import { statusLine } from '../node_modules/@cmodjs/core/jobs/status-line.js'
|
|
294
|
+
|
|
295
|
+
export const usage = defineMod({
|
|
296
|
+
name: 'usage',
|
|
297
|
+
setup(mod) {
|
|
298
|
+
mod.use(statusLine({ text: ({ model, context }) => `${model} ${context.percent ?? 0}%` }))
|
|
299
|
+
},
|
|
300
|
+
})
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## program
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
program(options: { readonly command: readonly string[]; readonly environment?: Record<string, string> }): Part<Program>
|
|
307
|
+
|
|
308
|
+
type Program = {
|
|
309
|
+
readonly state: 'stopped' | 'starting' | 'running' | 'backoff' | 'fatal'
|
|
310
|
+
ready(): Promise<{ url: string; socketPath?: string }>
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
`program` runs a background program, such as a preview server, and hands the mod its address.
|
|
315
|
+
|
|
316
|
+
- `command` is the program on `PATH` and its arguments. `environment` sets variables for it. An empty `command` throws.
|
|
317
|
+
- The program's first line on standard output must be its address: `127.0.0.1:<port>`, or `unix:<absolute socket path>`. `ready()` then resolves `{ url: 'http://127.0.0.1:<port>' }`, or `{ url: 'http://localhost', socketPath }`. Pass both to `mod.http.fetch`.
|
|
318
|
+
- In an interactive session the program starts with the mod. Otherwise it starts at the first `ready()`.
|
|
319
|
+
- When the program exits, CMod starts it again after 1, 2, 4, 8, and 16 seconds. After 5 failed tries, or a first line that is not an address, the state is `fatal`, `ready()` rejects, and CMod logs `<mod>: the <program> program stopped: <reason>`.
|
|
320
|
+
- A program that ships with the mod gets onto `PATH` through the `program` key of the mod's `package.json` ([install-steps.md](install-steps.md)). A program an install step fetched with `cmod download` is not on `PATH`, so `command` names it by its full path, `${mod.dataFolder}/bin/<program>`.
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
324
|
+
import { program } from '../node_modules/@cmodjs/core/jobs/program.js'
|
|
325
|
+
import { slashCommand } from '../node_modules/@cmodjs/core/jobs/slash-command.js'
|
|
326
|
+
|
|
327
|
+
export const preview = defineMod({
|
|
328
|
+
name: 'preview',
|
|
329
|
+
setup(mod) {
|
|
330
|
+
const server = mod.use(program({ command: ['preview-server', '--port', '0'] }))
|
|
331
|
+
mod.use(
|
|
332
|
+
slashCommand({
|
|
333
|
+
name: 'preview-status',
|
|
334
|
+
description: 'Ask the preview server for its status',
|
|
335
|
+
reply: async (_input, mod) => {
|
|
336
|
+
const { url, socketPath } = await server.ready()
|
|
337
|
+
const response = await mod.http.fetch(`${url}/status`, socketPath === undefined ? {} : { socketPath })
|
|
338
|
+
return response.text
|
|
339
|
+
},
|
|
340
|
+
}),
|
|
341
|
+
)
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Write a job of your own
|
|
347
|
+
|
|
348
|
+
A `Part` is a function. `mod.use` calls it with a `PartContext` and returns what it returns:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
type PartContext<State> = {
|
|
352
|
+
readonly mod: Mod<State>
|
|
353
|
+
readonly claude: Claude
|
|
354
|
+
on<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): void
|
|
355
|
+
announce(feature: string): void
|
|
356
|
+
reserveName(kind: string, name: string, taken: string): void
|
|
357
|
+
readonly toolCalls: ToolCalls
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
- `claude` reaches Claude Code itself. [The claude members](#the-claude-members) lists each one.
|
|
362
|
+
- `claude.store` is where CMod saves `mod.state`, under keys that start with `<mod>.`, such as `<mod>.<key>`. A job of your own never writes those keys, or it changes the mod's state behind CMod's back.
|
|
363
|
+
- `on` adds a Claude Code hook-module handler, `(e, next) => …`, on a `classic.<ModEvent>` event or on `tool.check`, `tool.call`, `prompt.submit`, `prompt.context`, `command.run`, `session.measure`, `skill.prompt`, `ui.render`, `ui.press`, `ui.close`, or `cmod.call`. The handler calls `next(e)` to pass the event on.
|
|
364
|
+
- `announce` adds a phrase to the list CMod logs when the mod first starts.
|
|
365
|
+
- `reserveName` throws `taken` when the mod already reserved that `kind` and `name`. A job calls it to refuse a duplicate.
|
|
366
|
+
- `toolCalls.agentOf(toolUseId)` resolves `{ agentId?, agentType? }`, the subagent behind a call.
|
|
367
|
+
- `toolCalls.cwdOf(toolUseId)` returns the working folder at the call's `PreToolUse`, or `undefined` once its `PostToolUse` or `PostToolUseFailure` has run. CMod keeps the folders of the last 100 calls.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
import { defineMod, type Part } from '../node_modules/@cmodjs/core/mod.js'
|
|
371
|
+
|
|
372
|
+
const clock: Part<{ now(): Promise<number> }> = ({ claude, announce }) => {
|
|
373
|
+
announce('a clock')
|
|
374
|
+
return { now: () => claude.clock.now() }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export const timer = defineMod({
|
|
378
|
+
name: 'timer',
|
|
379
|
+
setup(mod) {
|
|
380
|
+
const started = mod.use(clock)
|
|
381
|
+
mod.on('Stop', async () => {
|
|
382
|
+
mod.ui.toast(`Turn ended at ${new Date(await started.now()).toISOString()}`)
|
|
383
|
+
})
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### The claude members
|
|
389
|
+
|
|
390
|
+
`mod.js` does not export the types `Claude`, `RoutedEvent`, `RoutedHook`, or `ToolCalls`. A job of your own types `claude` through `PartContext`, such as `PartContext['claude']`, or lets `Part` infer it.
|
|
391
|
+
|
|
392
|
+
Each member calls the member of the same name in Claude Code's hooks API, `$`, with the same arguments. The `env` members read `$.env.get` instead.
|
|
393
|
+
|
|
394
|
+
| Member | What it does |
|
|
395
|
+
| --- | --- |
|
|
396
|
+
| `plugin.name` | The plugin's name. |
|
|
397
|
+
| `plugin.root` | The plugin's own folder, absolute, where the files the mod ships live. |
|
|
398
|
+
| `ui.toast(text, options?)` | Shows `text` for a few seconds, 4000 ms unless `options.timeoutMs` says otherwise. |
|
|
399
|
+
| `ui.status(text)` | Pins `text` as the plugin's one status line under the prompt. `undefined` removes it. |
|
|
400
|
+
| `ui.log(text, options?)` | Adds a dim line to the transcript that Claude does not read, or with `{ to: 'debug' }` a line to the debug log only. |
|
|
401
|
+
| `ui.notice(toolUseId, text)` | Shows `text` as a line under the open permission dialog of that call. `undefined` removes it. |
|
|
402
|
+
| `ui.invalidate(event)` | Draws `ui.render` again, or drops the cached answers of `prompt.context` and the other cached events. |
|
|
403
|
+
| `ui.ask(question, options?)` | Asks the person in Claude Code's question dialog, and resolves the label they chose. It rejects when they dismiss it, and in a `-p` run. |
|
|
404
|
+
| `ui.open(pane)` | Opens a pane, `{ id, title?, focus?, closeOnEscape?, holdToasts?, rows?, columns? }`, and resolves `{ isPlaced }`. |
|
|
405
|
+
| `ui.close(pane)` | Closes the open pane with that `{ id }`. |
|
|
406
|
+
| `ui.panes()` | The plugin's open panes: each one's id, title, and whether it is shown, holds the keyboard, and is placed. |
|
|
407
|
+
| `ui.resolve(e)` | The element table of the surface a `ui.render` event draws on. |
|
|
408
|
+
| `process.run(argv, init?)` | Runs a program to its end. `mod.process.run` is built on it. |
|
|
409
|
+
| `process.spawn(request)` | Starts a program and streams its output. `mod.process.spawn` is built on it. |
|
|
410
|
+
| `fs.read`, `fs.write`, `fs.list` | What `mod.fs` calls. A relative path is under the session's working folder. |
|
|
411
|
+
| `fs.exists(path)` | Resolves whether the path exists. |
|
|
412
|
+
| `fs.stat(path, options?)` | Resolves `{ kind, size, mtimeMs, isLink }`, and `realPath` with `{ resolve: true }`. It rejects for a missing path. |
|
|
413
|
+
| `http.fetch(url, init?)` | What `mod.http.fetch` calls. |
|
|
414
|
+
| `settings.read(args?)` | What `mod.settings.read` calls. |
|
|
415
|
+
| `store.get`, `store.set`, `store.delete`, `store.keys` | The plugin's key-value store, where CMod also keeps `mod.state`. |
|
|
416
|
+
| `clock.now()` | Resolves milliseconds since the epoch. |
|
|
417
|
+
| `clock.after(ms, fn)` | Calls `fn` once after `ms` milliseconds, and returns `{ cancel() }`. |
|
|
418
|
+
| `clock.every(ms, fn)` | Calls `fn` every `ms` milliseconds, at least 1, and returns `{ cancel() }`. |
|
|
419
|
+
| `session.id()` | The session's id, the transcript file's name. |
|
|
420
|
+
| `session.root()` | The session's project root. A shell `cd` does not move it. |
|
|
421
|
+
| `session.cwd()` | The folder the session runs in. |
|
|
422
|
+
| `session.model()` | The main conversation's model, as `/model` shows it. |
|
|
423
|
+
| `session.usage()` | When the session began, the context window's fill, the rate-limit windows, and the cost, as the status line has them. |
|
|
424
|
+
| `session.surfaces()` | Every surface the session draws on, `terminal` first. Empty in a plain `-p` run. |
|
|
425
|
+
| `command.register(spec)` | Adds a slash command. `slashCommand` is built on it. |
|
|
426
|
+
| `tool.register(spec)` | Adds a tool. `tool` is built on it. |
|
|
427
|
+
| `agent.list()` | The session's subagents and teammates so far. |
|
|
428
|
+
| `env.home()` | The `HOME` variable, or `undefined` when it is unset. |
|
|
429
|
+
| `env.dataHome()` | The `XDG_DATA_HOME` variable, or `undefined`. |
|
|
430
|
+
| `env.configHome()` | The `CLAUDE_CONFIG_DIR` variable, or `undefined`. |
|
|
431
|
+
| `cmod.call({ to, method, input })` | Calls `method` of the mod named `to`. `mod.dependencies` is built on it. |
|
|
432
|
+
|
|
433
|
+
### The building blocks
|
|
434
|
+
|
|
435
|
+
The built-in jobs are written from these building blocks, which a job of your own may import:
|
|
436
|
+
|
|
437
|
+
| Import | Export | What it does |
|
|
438
|
+
| --- | --- | --- |
|
|
439
|
+
| `jobs/part-context.js` | `afterCall(part, contextAfter, failed)` | Runs `contextAfter` after each call that succeeds, and adds the lines it returns to the call's result. |
|
|
440
|
+
| `jobs/part-context.js` | `modWithin(part, deadline, workTree?)` | A `mod` whose calls fail at `deadline`, `{ ms, job?, longestMs? }`. |
|
|
441
|
+
| `jobs/part-context.js` | `modOf(part, { call?, folder }, workspace, deadline)` | `modWithin`, with `mod.process` calls run in the call's work tree. |
|
|
442
|
+
| `jobs/part-context.js` | `workspaceReader(part)` | A function that reads the session's `projectRoot`, `cwd`, `home`, file access, and project scope. |
|
|
443
|
+
| `jobs/part-context.js` | `targetWords(target)` | A target in words, such as `edits to src/**`. |
|
|
444
|
+
| `jobs/permissions/match-target.js` | `targetOf`, `matchTarget`, `folderInScope`, and the types `Target`, `TargetKey`, `TargetOf`, `TargetCalls`, `MatchedCall` | Checks a target and matches it against a call. |
|
|
445
|
+
| `jobs/permissions/decide-permission.js` | `decidePermission`, `stricterVerdict`, and the types `Rule`, `PermissionRules`, `Decision`, `Verdict` | Decides a call against permission rules. |
|
|
446
|
+
| `jobs/permissions/find-project-scope.js` | `findProjectScope`, and the types `ProjectScope`, `Workspace` | Finds the repository a project plugin belongs to. |
|
|
447
|
+
|
|
448
|
+
`matchTarget` and `folderInScope` take a `CallEffects`, the files, URLs, and commands of one call. Only the internal `utils/call-effects.js` makes one, and no export does. So a job of your own reaches permission matching through the `permissions` and `check` jobs, not through `matchTarget`.
|
|
449
|
+
|
|
450
|
+
## Test a job
|
|
451
|
+
|
|
452
|
+
`tested.type('/todo milk')` runs a slash command. `tested.callTool('open_ticket', { title: 'Crash' })` runs the permission rules, then the tool. `tested.fire('tool.check', …)` asks the permission rules about any call. `tested.fire('prompt.submit', …)` with a spy part tests a `when` prompt. [testing.md](testing.md) covers each.
|