@cmodjs/core 0.1.14 → 0.2.1
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/commands.md +32 -21
- package/docs/dependencies.md +8 -0
- package/docs/index.md +12 -4
- package/docs/install-steps.md +36 -4
- package/docs/jobs.md +25 -9
- package/docs/mod.md +103 -37
- package/docs/options.md +93 -0
- package/docs/permissions.md +88 -0
- package/docs/state.md +4 -31
- package/docs/testing.md +13 -2
- package/installer.d.ts +10 -0
- package/installer.d.ts.map +1 -0
- package/installer.js +8 -0
- package/jobs/context.d.ts +2 -2
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +5 -3
- package/jobs/permissions.js +1 -1
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +5 -1
- package/jobs/prompt.js +5 -5
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +2 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +3 -2
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +3 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +3 -2
- package/mod.d.ts +48 -10
- package/mod.d.ts.map +1 -1
- package/mod.js +3 -0
- package/options.d.ts +58 -0
- package/options.d.ts.map +1 -0
- package/options.js +66 -0
- package/package.json +1 -1
- package/records.d.ts +16 -1
- package/records.d.ts.map +1 -1
- package/records.js +119 -7
- package/register.d.ts +4 -2
- package/register.d.ts.map +1 -1
- package/register.js +8 -2
- package/runtime/claude.d.ts +5 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/grants.d.ts +28 -0
- package/runtime/grants.d.ts.map +1 -0
- package/runtime/grants.js +286 -0
- package/runtime/installer.d.ts +24 -0
- package/runtime/installer.d.ts.map +1 -0
- package/runtime/installer.js +118 -0
- package/runtime/lifecycle.d.ts +6 -3
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +226 -36
- package/runtime/metadata.d.ts +20 -0
- package/runtime/metadata.d.ts.map +1 -0
- package/runtime/metadata.js +143 -0
- package/runtime/options.d.ts +21 -0
- package/runtime/options.d.ts.map +1 -0
- package/runtime/options.js +100 -0
- package/runtime/state.d.ts +1 -0
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +5 -75
- package/src/installer.ts +17 -0
- package/src/jobs/context.ts +5 -3
- package/src/jobs/permissions.ts +1 -1
- package/src/jobs/program.ts +4 -1
- package/src/jobs/prompt.ts +5 -5
- package/src/jobs/schedule.ts +2 -1
- package/src/jobs/slash-command.ts +3 -2
- package/src/jobs/status-line.ts +3 -2
- package/src/jobs/tool.ts +3 -2
- package/src/mod.ts +46 -11
- package/src/options.ts +91 -0
- package/src/records.ts +131 -8
- package/src/register.ts +12 -3
- package/src/runtime/claude.ts +2 -0
- package/src/runtime/grants.ts +306 -0
- package/src/runtime/installer.ts +169 -0
- package/src/runtime/lifecycle.ts +227 -45
- package/src/runtime/metadata.ts +153 -0
- package/src/runtime/options.ts +102 -0
- package/src/runtime/state.ts +7 -70
- package/src/testing/fake-claude.ts +6 -1
- package/src/testing/fake-files.ts +9 -3
- package/src/testing.ts +40 -15
- package/src/utils/metadata.ts +242 -0
- package/testing/fake-claude.d.ts +4 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +5 -1
- package/testing/fake-files.d.ts.map +1 -1
- package/testing/fake-files.js +9 -3
- package/testing.d.ts +6 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +29 -13
- package/utils/metadata.d.ts +21 -0
- package/utils/metadata.d.ts.map +1 -0
- package/utils/metadata.js +250 -0
package/docs/mod.md
CHANGED
|
@@ -5,21 +5,25 @@
|
|
|
5
5
|
## defineMod
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
|
-
defineMod<State, Name>(definition: ModDefinition<State, Name>): ModDefinition<State, Name>
|
|
8
|
+
defineMod<State, Name, Options>(definition: ModDefinition<State, Name, Options>): ModDefinition<State, Name, Options>
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
|
-
type ModDefinition<State, Name> = {
|
|
12
|
+
type ModDefinition<State, Name, Options> = {
|
|
13
13
|
readonly name: Name
|
|
14
14
|
readonly state?: State
|
|
15
|
+
readonly options?: Options
|
|
15
16
|
readonly api?: { … }
|
|
16
|
-
|
|
17
|
+
readonly installer?: readonly Step<State>[]
|
|
18
|
+
setup(mod: Mod<State, Options>): void | Promise<void>
|
|
17
19
|
}
|
|
18
20
|
```
|
|
19
21
|
|
|
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
|
|
22
|
+
- `name` is the mod's name. Use the `name` in `.claude-plugin/plugin.json`. Claude Mod Manager (cmod) keys the saved state and the options 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
23
|
- `state` holds the starting values, grouped by how long they last. [state.md](state.md) explains the groups.
|
|
24
|
+
- `options` declares what a person sets, such as a token, and types `mod.options`. [options.md](options.md) explains them.
|
|
22
25
|
- `api` holds the methods other mods call. [dependencies.md](dependencies.md) explains it.
|
|
26
|
+
- `installer` lists the steps the person finishes in Claude Code after the permissions and the options, such as signing in. [install-steps.md](install-steps.md#steps-in-claude-code) explains them.
|
|
23
27
|
- `setup` runs once when the mod starts. It adds hooks, panes, slot renders, and jobs. It may be `async`.
|
|
24
28
|
|
|
25
29
|
```tsx
|
|
@@ -41,14 +45,15 @@ export const greeter = defineMod({
|
|
|
41
45
|
|
|
42
46
|
## When setup runs
|
|
43
47
|
|
|
44
|
-
Claude Code loads the mod at the start of each session. cmod then checks that the mod is set up. A mod
|
|
48
|
+
Claude Code loads the mod at the start of each session. cmod then checks that the mod is set up. A mod the person has not accepted yet opens the installer pane, `Install <name>`, which lists its permissions and the changes it makes to the computer, and waits for Accept ([install-steps.md](install-steps.md)). An option with no default and no value is asked on the next page of the same pane. Then cmod loads the saved state, runs `setup`, marks as open each of the mod's panes that Claude Code already shows, and opens the pane again for each `installer` step the mod declares that is not done.
|
|
45
49
|
|
|
46
50
|
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
51
|
|
|
48
52
|
When a step fails, the mod does not start. Its progress line above the prompt names the failure and the fix:
|
|
49
53
|
|
|
50
54
|
- `<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
|
|
55
|
+
- `its state did not load: <error>` when reading the saved values fails.
|
|
56
|
+
- `it needs <titles>` when the person chose Not now on an option with no default, or no one can answer, as in `claude -p` ([options.md](options.md)). This one says `Set it in /config.` instead, and Claude Code reloads the mod once the value is set.
|
|
52
57
|
- `its setup function threw: <error>` when `setup` throws or rejects.
|
|
53
58
|
- `its open panes did not load: <error>` when Claude Code cannot list the open panes.
|
|
54
59
|
- `it decides permissions on <events>, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod` when `setup` adds a hook on `PermissionRequest`, or a job that decides permissions, and `register.ts` does not register the permission check.
|
|
@@ -60,43 +65,45 @@ Each of these says `Fix it, then run /reload-plugins.`
|
|
|
60
65
|
`hooks/hooks.json` names `hooks/register.ts` as the plugin's hooks module. `register.ts` calls `registerMod` and does nothing else:
|
|
61
66
|
|
|
62
67
|
```ts
|
|
63
|
-
import type { On } from 'claude-code'
|
|
68
|
+
import type { On, PluginOptions } from 'claude-code'
|
|
64
69
|
import { registerMod } from '../node_modules/@cmodjs/core/register.js'
|
|
65
70
|
import { greeter } from '../src/mod.js'
|
|
66
71
|
|
|
67
|
-
export function register(addHook: On): void {
|
|
68
|
-
registerMod(addHook, greeter)
|
|
72
|
+
export function register(addHook: On, options: PluginOptions): void {
|
|
73
|
+
registerMod(addHook, greeter, options)
|
|
69
74
|
}
|
|
70
75
|
```
|
|
71
76
|
|
|
72
77
|
```ts
|
|
73
|
-
registerMod<State>(addHook: On, definition: ModDefinition<State
|
|
78
|
+
registerMod<State, Options>(addHook: On, definition: ModDefinition<State, string, Options>, options: PluginOptions): void
|
|
74
79
|
registerPermissionCheck(addHook: On): void
|
|
80
|
+
registeredMod(): ModDefinition | undefined
|
|
75
81
|
```
|
|
76
82
|
|
|
77
|
-
`registerMod` registers one handler per Claude Code event the mod can use, and routes each event to the mod. A `PreToolUse` hook runs on `tool.call`, which `registerMod` registers ([hooks.md](hooks.md)). It leaves out Claude Code's permission check, the events `tool.check` and `PermissionRequest`.
|
|
83
|
+
`registerMod` registers one handler per Claude Code event the mod can use, and routes each event to the mod. `options` is what Claude Code passes `register`: the values of the mod's options ([options.md](options.md)). `registeredMod` returns the definition `registerMod` got, for `cmod check` to read the mod's options without starting it. A `PreToolUse` hook runs on `tool.call`, which `registerMod` registers ([hooks.md](hooks.md)). It leaves out Claude Code's permission check, the events `tool.check` and `PermissionRequest`.
|
|
78
84
|
|
|
79
85
|
A mod that decides permissions calls `registerPermissionCheck` after `registerMod`. That covers a `PreToolUse` hook that answers `permissionDecision: 'allow'` or `'ask'`, a hook on `PermissionRequest`, the `permissions` job, and a job of your own on `tool.check`:
|
|
80
86
|
|
|
81
87
|
```ts
|
|
82
|
-
export function register(addHook: On): void {
|
|
83
|
-
registerMod(addHook, guard)
|
|
88
|
+
export function register(addHook: On, options: PluginOptions): void {
|
|
89
|
+
registerMod(addHook, guard, options)
|
|
84
90
|
registerPermissionCheck(addHook)
|
|
85
91
|
}
|
|
86
92
|
```
|
|
87
93
|
|
|
88
|
-
A mod without
|
|
94
|
+
Such a mod also declares `"approve": true` in its `package.json` `"cmod".permissions`, so the person grants it at install. `cmod check` adds the line when it is missing, and without the grant cmod drops every allow or ask the mod answers, so Claude Code decides those calls itself ([permissions.md](permissions.md)). A mod without `registerPermissionCheck` has no part in Claude Code's permission check, so the plugin directory reads it as one that never answers a permission. A mod that decides permissions without it does not start, and its progress line names the line to add.
|
|
89
95
|
|
|
90
96
|
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. The plugin directory reads the bundled module too, and flags it when the name of `register`'s first parameter is declared anywhere else in the bundle. `@cmodjs/core` declares `on` as the method `mod.on`, so name that parameter `addHook`. Declare `register` as a function, as here: `cmod publish` bundles the hooks module into one file, and Claude Code refuses a bundled `register` that is not one.
|
|
91
97
|
|
|
92
98
|
## What `mod` can call
|
|
93
99
|
|
|
94
|
-
`setup` gets `mod`, of type `Mod<State>`. Every hook, render, and job gets the same `mod`.
|
|
100
|
+
`setup` gets `mod`, of type `Mod<State, Options>`. Every hook, render, and job gets the same `mod`.
|
|
95
101
|
|
|
96
102
|
```ts
|
|
97
|
-
type Mod<State> = {
|
|
103
|
+
type Mod<State, Options> = {
|
|
98
104
|
readonly name: string
|
|
99
105
|
readonly state: Readonly<State>
|
|
106
|
+
readonly options: OptionValues<Options>
|
|
100
107
|
readonly dataFolder: string
|
|
101
108
|
readonly projectRoot: string
|
|
102
109
|
readonly cwd: string
|
|
@@ -105,13 +112,16 @@ type Mod<State> = {
|
|
|
105
112
|
every(ms, hook): Timer
|
|
106
113
|
readonly ui: { pane, render, toast, progress, ask, scroll }
|
|
107
114
|
readonly process: { run, spawn }
|
|
108
|
-
readonly fs: { read, write, list, exists, stat }
|
|
115
|
+
readonly fs: { read, write, list, exists, stat, find }
|
|
116
|
+
readonly metadata: { read, update }
|
|
109
117
|
readonly http: { fetch }
|
|
110
|
-
readonly settings: {
|
|
118
|
+
readonly settings: { page, open }
|
|
111
119
|
readonly session: { messages, append, submit }
|
|
112
120
|
readonly agent: { spawn }
|
|
113
121
|
readonly model: { complete }
|
|
122
|
+
readonly permissions: { has }
|
|
114
123
|
readonly dependencies: CmodDependencies
|
|
124
|
+
readonly claude: ModClaude
|
|
115
125
|
}
|
|
116
126
|
```
|
|
117
127
|
|
|
@@ -119,6 +129,7 @@ type Mod<State> = {
|
|
|
119
129
|
| --- | --- | --- |
|
|
120
130
|
| `name` | The mod's name from `defineMod`. | |
|
|
121
131
|
| `state` | The mod's values. Assign to a key to change it. | [state.md](state.md) |
|
|
132
|
+
| `options` | The values a person set for the mod's options, typed from their declaration. Read-only. | [options.md](options.md) |
|
|
122
133
|
| `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) |
|
|
123
134
|
| `projectRoot` | The project the session works in. It follows `/cd`. | |
|
|
124
135
|
| `cwd` | The session's working folder. It follows a `cd` in a Bash or PowerShell call, and `/cd`. | |
|
|
@@ -127,13 +138,16 @@ type Mod<State> = {
|
|
|
127
138
|
| `every` | Runs a function every so many milliseconds while the session runs. | below |
|
|
128
139
|
| `ui` | Panes, slot renders, toasts, progress lines, questions, and scrolling. | [ui.md](ui.md) |
|
|
129
140
|
| `process` | Runs a program. | below |
|
|
130
|
-
| `fs` | Reads, writes,
|
|
141
|
+
| `fs` | Reads, writes, looks up, and finds files. | below |
|
|
142
|
+
| `metadata` | Reads and changes the mod's keys in one file's metadata. | below |
|
|
131
143
|
| `http` | Fetches a URL. | below |
|
|
132
|
-
| `settings` |
|
|
144
|
+
| `settings` | Adds the mod's own pages to `/mods`, and opens them. | below |
|
|
133
145
|
| `session` | Reads the conversation or a subagent's, adds a note Claude reads, and asks Claude for a turn. | below |
|
|
134
146
|
| `agent` | Starts a subagent. | below |
|
|
135
147
|
| `model` | Asks a model one question, outside the conversation. | below |
|
|
148
|
+
| `permissions` | Says whether the person has a permission turned on. `process`, `fs.write`, `metadata.update`, `http`, `session`, `agent`, `model`, and `claude` each check theirs. | [permissions.md](permissions.md) |
|
|
136
149
|
| `dependencies` | Calls the methods of other mods. | [dependencies.md](dependencies.md) |
|
|
150
|
+
| `claude` | Claude Code's own calls and events, for what `mod` has no member for. | [jobs.md](jobs.md#the-claude-members) |
|
|
137
151
|
|
|
138
152
|
### mod.every
|
|
139
153
|
|
|
@@ -195,6 +209,7 @@ mod.fs.write(path: string, text: string): Promise<void>
|
|
|
195
209
|
mod.fs.list(path?: string): Promise<FsEntry[]>
|
|
196
210
|
mod.fs.exists(path: string): Promise<boolean>
|
|
197
211
|
mod.fs.stat(path: string, options?: { resolve: boolean }): Promise<FsStat>
|
|
212
|
+
mod.fs.find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
|
|
198
213
|
```
|
|
199
214
|
|
|
200
215
|
- A relative path is under the session's working folder. An absolute path is used as given. Text is UTF-8.
|
|
@@ -204,7 +219,64 @@ mod.fs.stat(path: string, options?: { resolve: boolean }): Promise<FsStat>
|
|
|
204
219
|
- `exists` answers whether the path leads to a file or a folder.
|
|
205
220
|
- `stat` answers `{ kind, size, mtimeMs, isLink }` of what the path leads to, following a symbolic link, and rejects a missing path. With `{ resolve: true }` it also answers `realPath`: the absolute path, every symbolic link followed.
|
|
206
221
|
|
|
207
|
-
A file the mod ships is under the plugin's folder
|
|
222
|
+
A file the mod ships is under the plugin's folder, `mod.claude.plugin.root` ([jobs.md](jobs.md#the-claude-members)). A file the mod keeps for itself belongs in `mod.dataFolder`.
|
|
223
|
+
|
|
224
|
+
`find` answers every file a glob matches, each with its metadata, so a mod finds the Skills, agents, scripts, or notes it acts on in one call:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
type FileMatch = { readonly path: string; readonly name?: string; readonly metadata: Readonly<Record<string, string>>; readonly error?: string }
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- A glob starting with `~/.claude` starts at Claude Code's config folder, `CLAUDE_CONFIG_DIR` when it is set. One starting with `~/` starts at home, one starting with `/` is absolute, and any other starts at the project root. Several globs answer each file once.
|
|
231
|
+
- `path` is absolute. `name` is a Markdown file's frontmatter `name`. `metadata` holds the mod's own keys, bare, as `mod.metadata.read` answers them.
|
|
232
|
+
- `find` follows symbolic links into the folders they point at, once each. It skips `.git` and `node_modules` unless the glob names them.
|
|
233
|
+
- A file whose metadata cmod cannot read comes back with empty `metadata` and an `error` naming the line, and cmod logs it once.
|
|
234
|
+
- cmod keeps what it read of each file until the file's modified time or size changes.
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
const skills = await mod.fs.find(['~/.claude/skills/*/SKILL.md', '.claude/skills/*/SKILL.md'])
|
|
238
|
+
const planning = skills.filter((skill) => skill.metadata['modes']?.split(' ').includes('plan'))
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### mod.metadata
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
mod.metadata.read(path: string): Promise<Readonly<Record<string, string>>>
|
|
245
|
+
mod.metadata.update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Metadata is a file's own set of string keys, kept in the file, so it moves and is committed with the file. Each mod reads and writes its own keys only: the file stores them as `<mod>.<key>`, and the mod sees them bare. A value is one line of text, and a list is one space-separated string.
|
|
249
|
+
|
|
250
|
+
Where a file keeps its metadata depends on its kind:
|
|
251
|
+
|
|
252
|
+
```markdown
|
|
253
|
+
---
|
|
254
|
+
name: plan
|
|
255
|
+
metadata:
|
|
256
|
+
modes.modes: plan build
|
|
257
|
+
---
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```sh
|
|
261
|
+
#!/bin/sh
|
|
262
|
+
# /// metadata
|
|
263
|
+
# modes.modes: plan build
|
|
264
|
+
# ///
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
- A Markdown file keeps it in its frontmatter, under `metadata:`.
|
|
268
|
+
- A `.sh`, `.py`, `.ts`, `.js`, `.yaml`, or `.toml` file keeps it in a `/// metadata` comment block after any shebang, in the file's own comment, `#` or `//`, the way PEP 723 keeps a Python script's dependencies.
|
|
269
|
+
- Any other file keeps it in a sidecar, `<file>.meta`.
|
|
270
|
+
|
|
271
|
+
`update` hands `change` the mod's current keys, and writes what `change` leaves. It changes only the metadata block, keeps other mods' keys, writes nothing when nothing changed, and removes an empty block. It writes through a symbolic link to the file it points at. Writing a file outside the project and the mod's data folder needs the `files` permission, as `mod.fs.write` does ([permissions.md](permissions.md)). A key is letters, digits, `_`, `.`, and `-`, and `update` rejects any other key and a value with a line break.
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
await mod.metadata.update('.claude/skills/plan/SKILL.md', (metadata) => {
|
|
275
|
+
metadata['modes'] = 'plan build'
|
|
276
|
+
})
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`cmod check` reads the metadata of every file the mod ships, and fails one cmod cannot read.
|
|
208
280
|
|
|
209
281
|
### mod.http
|
|
210
282
|
|
|
@@ -218,35 +290,29 @@ mod.http.fetch(url: string, init?: HttpInit): Promise<HttpResponse>
|
|
|
218
290
|
### mod.settings
|
|
219
291
|
|
|
220
292
|
```ts
|
|
221
|
-
mod.settings.
|
|
293
|
+
mod.settings.page(page: Pane<State>): void
|
|
294
|
+
mod.settings.open(pageId?: string): Promise<void>
|
|
222
295
|
```
|
|
223
296
|
|
|
224
|
-
|
|
297
|
+
`/mods` shows each mod's options, permissions, and keys. `page` adds a page of the mod's own to its entry there, drawn as any pane is ([ui.md](ui.md)). `open` opens that page, or with no id opens the mod's entry in `/mods`. `open` rejects for an id `page` did not add.
|
|
225
298
|
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
export const pluginCount = defineMod({
|
|
230
|
-
name: 'plugin-count',
|
|
231
|
-
setup(mod) {
|
|
232
|
-
mod.on('SessionStart', async () => {
|
|
233
|
-
const enabled = (await mod.settings.read({ source: 'user' }))['enabledPlugins'] as Record<string, unknown> | undefined
|
|
234
|
-
mod.ui.toast(`${Object.keys(enabled ?? {}).length} plugins are enabled`)
|
|
235
|
-
})
|
|
236
|
-
},
|
|
237
|
-
})
|
|
299
|
+
```tsx
|
|
300
|
+
mod.settings.page(definePane({ id: 'runs', title: 'Runs', render: (current) => <Text>{current.state.session.lastRun}</Text> }))
|
|
301
|
+
mod.use(slashCommand({ name: 'ci-runs', description: 'Show the CI runs', reply: () => void mod.settings.open('runs') }))
|
|
238
302
|
```
|
|
239
303
|
|
|
304
|
+
Claude Code's own `settings.json` files are read with `mod.claude.settings.read`, such as `(await mod.claude.settings.read({ source: 'user' }))['enabledPlugins']` ([jobs.md](jobs.md#the-claude-members)).
|
|
305
|
+
|
|
240
306
|
### mod.session
|
|
241
307
|
|
|
242
308
|
```ts
|
|
243
309
|
mod.session.messages(): Promise<SessionMessage[]>
|
|
244
|
-
mod.session.messages(args: { agentId
|
|
310
|
+
mod.session.messages(args: { agentId: string }): Promise<SessionMessage[] | { deny: string }>
|
|
245
311
|
mod.session.append(text: string): Promise<void>
|
|
246
312
|
mod.session.submit(text: string): Promise<void>
|
|
247
313
|
```
|
|
248
314
|
|
|
249
|
-
`messages` reads the main conversation, one `{ role, text, toolUses, toolResults }` entry per message. A message has no id of its own, and each tool use carries its `tool_use_id`. `{ agentId }` reads that subagent's conversation instead, the id a `SubagentStart` or `SubagentStop` hook gets as `agent_id`, and answers `{ deny }` when the session cannot read it, so `Array.isArray` tells the two apart.
|
|
315
|
+
`messages` reads the main conversation, one `{ role, text, toolUses, toolResults }` entry per message. A message has no id of its own, and each tool use carries its `tool_use_id`. `{ agentId }` reads that subagent's conversation instead, the id a `SubagentStart` or `SubagentStop` hook gets as `agent_id`, and answers `{ deny }` when the session cannot read it, so `Array.isArray` tells the two apart. The Messages API form, `{ role, content }` with the content blocks whole, is `mod.claude.session.messages({ as: 'api' })`.
|
|
250
316
|
|
|
251
317
|
```ts
|
|
252
318
|
mod.on('SubagentStop', async ({ agent_id }) => {
|
package/docs/options.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Options
|
|
2
|
+
|
|
3
|
+
An option is a value a person sets for a mod and the mod reads, such as a token, a URL, or how often to check. The mod declares its options in `defineMod`, and `mod.options` holds their values, typed from the declaration. Claude Code stores them: it shows each one in `/config`, keeps a secret in the system keychain, and reloads the mod when one changes.
|
|
4
|
+
|
|
5
|
+
`mod.state` is the other kind of value: what the mod owns and changes as it runs ([state.md](state.md)).
|
|
6
|
+
|
|
7
|
+
## Declare the options
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
11
|
+
import { option } from '../node_modules/@cmodjs/core/options.js'
|
|
12
|
+
|
|
13
|
+
export const ciWatch = defineMod({
|
|
14
|
+
name: 'ci-watch',
|
|
15
|
+
options: {
|
|
16
|
+
githubToken: option.secret({ title: 'GitHub token', description: 'Reads your CI runs' }),
|
|
17
|
+
every: option.number({ title: 'Check every', description: 'Minutes between checks', default: 10, min: 1 }),
|
|
18
|
+
branch: option.text({ title: 'Branch', description: 'The branch to watch', default: 'main' }),
|
|
19
|
+
},
|
|
20
|
+
setup(mod) {
|
|
21
|
+
mod.ui.toast(`Watching ${mod.options.branch} every ${mod.options.every} minutes`)
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`mod.options.every` is a `number` and `mod.options.branch` a `string`, so `tsc` catches a typo or a wrong type.
|
|
27
|
+
|
|
28
|
+
| Helper | The value | Claude Code shows it as |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `option.text({ title, description, default? })` | `string` | a text field |
|
|
31
|
+
| `option.secret({ title, description })` | `string` | a masked field, kept in the keychain |
|
|
32
|
+
| `option.number({ title, description, default?, min?, max? })` | `number` | a number field |
|
|
33
|
+
| `option.toggle({ title, description, default? })` | `boolean`, `false` unless `default` says otherwise | a toggle |
|
|
34
|
+
| `option.choice(choices, { title, description, default? })` | one of `choices` | a picker |
|
|
35
|
+
| `option.folder({ title, description, default? })` | `string`, a folder path | a folder field |
|
|
36
|
+
| `option.file({ title, description, default? })` | `string`, a file path | a file field |
|
|
37
|
+
| `option.list({ title, description, default? })` | `readonly string[]` | a list of text |
|
|
38
|
+
|
|
39
|
+
- An option name is letters, digits, and `_`, starting with a letter, because Claude Code passes each option on as `CLAUDE_PLUGIN_OPTION_<NAME>`. `defineMod` throws for another name.
|
|
40
|
+
- `title` is the label `/config` shows. `description` is the help text under it.
|
|
41
|
+
- A secret has no default, so a token never ships in the mod.
|
|
42
|
+
|
|
43
|
+
## Where the values come from
|
|
44
|
+
|
|
45
|
+
Each value is, in order of precedence:
|
|
46
|
+
|
|
47
|
+
1. A value the person's organization set in managed settings. `/config` shows it locked.
|
|
48
|
+
2. The value in the project's `options.json`.
|
|
49
|
+
3. The value the person set in `/config`.
|
|
50
|
+
4. The `default` in the declaration.
|
|
51
|
+
|
|
52
|
+
An option with no default and no value is asked before `setup` runs, on a page of the installer pane: one field per option, each saved to `/config` as the person submits it. A secret is set in `/config` itself, where Claude Code keeps it in the keychain, and the page says so. When the person chooses Not now, or no one can answer, as in `claude -p`, the mod does not start: its progress line says `it needs <title>` and `Set it in /config.`, and Claude Code reloads the mod once the value is set.
|
|
53
|
+
|
|
54
|
+
`cmod install`, `cmod link`, and `cmod try` set options from the terminal with `--option key=value`, once per option ([commands.md](commands.md)).
|
|
55
|
+
|
|
56
|
+
`mod.options` follows the values while the session runs. Read an option where you use it, as in `mod.options.branch`, so a change applies at once. A change in `/config` reloads the mod, and a `/cd` reads the new project's `options.json`.
|
|
57
|
+
|
|
58
|
+
## Set options for a team
|
|
59
|
+
|
|
60
|
+
A project sets options for everyone who works in it with an `options.json` in its config folder, committed with the repository:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
<project>/.claude/cmods/<mod>/options.json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{ "branch": "release", "every": 5 }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
cmod ignores each entry it cannot use and logs one line naming the file, the entry, and the fix:
|
|
71
|
+
|
|
72
|
+
- a file that is not JSON, or not a JSON object
|
|
73
|
+
- an option the mod does not declare
|
|
74
|
+
- a secret, because the file is committed for everyone to read
|
|
75
|
+
- a value the option does not take, such as `0` for an option with `min: 1`
|
|
76
|
+
|
|
77
|
+
## plugin.json userConfig
|
|
78
|
+
|
|
79
|
+
Claude Code reads a plugin's options from `userConfig` in `.claude-plugin/plugin.json` before it loads the mod. `cmod check` writes `userConfig` from the options `defineMod` declares, so the declaration is the one place to change them. `cmod publish` refuses a `plugin.json` whose `userConfig` differs: run `cmod check`, then commit `.claude-plugin/plugin.json`.
|
|
80
|
+
|
|
81
|
+
`cmod check` never writes `required`, because Claude Code refuses to load a mod while a required option has no value. cmod asks for an option with no default instead.
|
|
82
|
+
|
|
83
|
+
`userConfigOf(options)`, exported from `options.js`, returns the `userConfig` for a declaration.
|
|
84
|
+
|
|
85
|
+
## Test options
|
|
86
|
+
|
|
87
|
+
`testMod(mod, { options: { githubToken: 'ghp_test' } })` starts the tested mod with those values, as Claude Code passes them. Each option left out gets its default. A test that leaves out an option with no default fails with `<mod> needs <title>, which a person sets in /config. Give it to the tested mod: testMod(mod, { options: { <key>: … } }).` `fakes.config.list` answers the `/config` rows, so a test can lock a value as managed settings do ([testing.md](testing.md)).
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const tested = testMod(ciWatch, { options: { githubToken: 'ghp_test', branch: 'develop' } })
|
|
91
|
+
await tested.start()
|
|
92
|
+
expect(tested.shown.toasts).toContain('Watching develop every 10 minutes')
|
|
93
|
+
```
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Permissions
|
|
2
|
+
|
|
3
|
+
A mod declares what it does beyond drawing and answering hooks: the hosts it connects to, the programs it runs, the files it changes outside its project, and whether it reads the conversation, adds text Claude reads, asks a model, starts agents, uses Claude's tools, changes Claude Code's settings, or approves tool calls. The person sees each permission as one sentence when they install the mod, turns each one on or off in `/mods`, and cmod checks every call and every answer against what they granted.
|
|
4
|
+
|
|
5
|
+
Permissions bind every mod built on `@cmodjs/core`, and the plugin directory reads them from `package.json`. They are a contract, not a sandbox: a Claude Code plugin can skip cmod and call Claude Code directly.
|
|
6
|
+
|
|
7
|
+
## Declare the permissions
|
|
8
|
+
|
|
9
|
+
They sit in the `package.json` `cmod` key, beside `install` and `keys`, because the person reads them before any of the mod's code runs:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
"cmod": {
|
|
13
|
+
"permissions": { "network": ["api.github.com"], "run": ["gh"], "prompt": true, "model": true }
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Permission | The person reads | cmod checks |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| `"network": ["api.github.com"]` | Connect to api.github.com | `mod.http.fetch` to that host. A path starting with `/` or `~/` names a socket, such as `"~/.docker/run/docker.sock"` |
|
|
20
|
+
| `"run": ["gh"]`, or `"run": "*"` | Run gh on your computer | `mod.process.run`, `mod.process.spawn`, and the `program` and `check` jobs. A program matches by its file name, so `"mermaid-ascii"` covers `${mod.dataFolder}/bin/mermaid-ascii` |
|
|
21
|
+
| `"files": ["~/.zshrc"]` | Change ~/.zshrc | `mod.fs.write` and `mod.metadata.update` outside the project and the mod's data folder. A folder covers every file below it |
|
|
22
|
+
| `"conversation": true` | Read this conversation | `mod.session.messages`, and reading Claude Code's transcript files |
|
|
23
|
+
| `"prompt": true` | Add text Claude reads and start turns | `mod.session.append`, `mod.session.submit`, the `prompt` and `schedule` jobs, and the hook answers listed below |
|
|
24
|
+
| `"model": true` | Ask a model, which uses your plan | `mod.model.complete` |
|
|
25
|
+
| `"agents": true` | Start agents | `mod.agent.spawn` |
|
|
26
|
+
| `"tools": true` | Use and change Claude's tool calls | `mod.claude.tool.call`, and a hook that changes a tool call's input |
|
|
27
|
+
| `"config": true` | Change your Claude Code settings | `mod.claude.config.set`, and writes to `.claude/settings.json`, `.claude/settings.local.json`, and `.mcp.json` |
|
|
28
|
+
| `"approve": true` | Approve Claude's tool calls for you | an allow or an ask from the mod's permission rules or hooks, and a `retry`. A deny needs no permission |
|
|
29
|
+
|
|
30
|
+
Reading files, drawing panes and slot renders, toasts, slash commands, tools of the mod's own, and hooks need no permission.
|
|
31
|
+
|
|
32
|
+
`cmod check` adds `"approve": true` to `package.json` when `hooks/register.ts` calls `registerPermissionCheck` and the line is missing, and says so.
|
|
33
|
+
|
|
34
|
+
## What the person sees
|
|
35
|
+
|
|
36
|
+
The install opens the installer pane in Claude Code and lists every permission in `permissions`, with the install step, the program, and the keys. An update asks again only when it adds a permission, and lists only the new ones. Removing the mod revokes everything it was granted.
|
|
37
|
+
|
|
38
|
+
`/mods <mod>` lists the same permissions as toggles. A toggle runs `cmod permission <mod> <name> [value] on|off`, and the mod sees the change before its next call, with no reload.
|
|
39
|
+
|
|
40
|
+
`cmod check` prints the sentences, so you read what the person will see:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
✔ At install the person grants: Connect to api.github.com; Run gh on your computer; Ask a model, which uses your plan
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Check a permission before a feature uses it
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
mod.permissions.has(name: 'network' | 'run' | 'files', value: string): boolean
|
|
50
|
+
mod.permissions.has(name: 'conversation' | 'prompt' | 'model' | 'agents' | 'tools' | 'config' | 'approve'): boolean
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`has` says whether the person has the permission turned on, so a mod hides a feature instead of failing it:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
if (mod.permissions.has('model')) {
|
|
57
|
+
const verdict = await mod.model.complete({ prompt: 'Is this reply a waste of time?' })
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## When a call has no grant
|
|
62
|
+
|
|
63
|
+
The call rejects, and the error names the fix:
|
|
64
|
+
|
|
65
|
+
- An undeclared call names the line to add: `ci-watch calls http.fetch(https://api.github.com/repos), which needs "permissions": { "network": ["api.github.com"] } in package.json "cmod".`
|
|
66
|
+
- A declared call the person turned off names where to turn it on: `ci-watch calls model.complete without your grant to "Ask a model, which uses your plan". Turn it on in /mods ci-watch.`
|
|
67
|
+
- A `program` job without its grant stops at once with the same line, with no restarts.
|
|
68
|
+
|
|
69
|
+
## When a hook answer has no grant
|
|
70
|
+
|
|
71
|
+
cmod checks the answer each hook returns, part by part. A part without its grant is dropped, and cmod logs once per permission which part it dropped and how to fix it:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
ci-watch answered additionalContext without your grant to "Add text Claude reads and start turns", so cmod dropped it. Turn it on in /mods ci-watch.
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Answer part | Permission |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `additionalContext`, `initialUserMessage`, `suppressOriginalPrompt`, `updatedToolOutput`, a `prompt` job's text | `prompt` |
|
|
80
|
+
| a Stop or PostToolUse block | `prompt` |
|
|
81
|
+
| `updatedInput`, and a changed tool input passed down the chain | `tools` |
|
|
82
|
+
| an allow or an ask, and `retry` | `approve` |
|
|
83
|
+
|
|
84
|
+
A deny needs no permission. A tool of the mod's own, `mcp__<mod>__*`, answers freely.
|
|
85
|
+
|
|
86
|
+
## Test permissions
|
|
87
|
+
|
|
88
|
+
`testMod` grants what the mod's `package.json` declares, so tests run as an installed mod does. An undeclared call fails the test with the line to add. `testMod(mod, { permissions: ['network:api.github.com'] })` grants exactly the list instead, so a test checks what happens with a permission turned off ([testing.md](testing.md)).
|
package/docs/state.md
CHANGED
|
@@ -80,16 +80,11 @@ export const reads = defineMod({
|
|
|
80
80
|
|
|
81
81
|
## Where the values come from
|
|
82
82
|
|
|
83
|
-
When the mod starts, each value is, in
|
|
83
|
+
When the mod starts, each value is the value the mod saved for this session, this project, or globally, or else the starting value in `defineMod`. After a `/cd` to another project, cmod loads that project's `project` values.
|
|
84
84
|
|
|
85
|
-
|
|
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`.
|
|
85
|
+
`mod.state` holds what the mod owns and changes as it runs. What a person sets, such as a token or a branch to watch, is an option ([options.md](options.md)).
|
|
89
86
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
## Let a person change the starting values
|
|
87
|
+
## Let a person replace a Skill's text
|
|
93
88
|
|
|
94
89
|
Each mod has two config folders, outside its code:
|
|
95
90
|
|
|
@@ -98,29 +93,7 @@ Each mod has two config folders, outside its code:
|
|
|
98
93
|
<project>/.claude/cmods/<mod>/ the team's, committed with the repository
|
|
99
94
|
```
|
|
100
95
|
|
|
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
|
|
96
|
+
`CLAUDE_CONFIG_DIR` moves `~/.claude`. Removing the mod keeps both folders. The project folder also holds the team's options, in `options.json` ([options.md](options.md)).
|
|
124
97
|
|
|
125
98
|
A person replaces the text of a Skill the mod ships by putting a file at the same path in a config folder:
|
|
126
99
|
|
package/docs/testing.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
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
4
|
|
|
5
5
|
```ts
|
|
6
|
-
testMod<State>(definition: ModDefinition<State>, options?: TestOptions<State>): TestedMod<State>
|
|
6
|
+
testMod<State, Options>(definition: ModDefinition<State, string, Options>, options?: TestOptions<State, Options>): TestedMod<State>
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
```ts
|
|
@@ -24,24 +24,30 @@ Assert what a person or Claude sees: `tested.lines(...)`, `tested.shown`, `teste
|
|
|
24
24
|
## TestOptions
|
|
25
25
|
|
|
26
26
|
```ts
|
|
27
|
-
type TestOptions<State> = {
|
|
27
|
+
type TestOptions<State, Options> = {
|
|
28
28
|
readonly state?: { [Lifetime in keyof State]?: Partial<State[Lifetime]> }
|
|
29
|
+
readonly options?: Partial<OptionValues<Options>>
|
|
30
|
+
readonly permissions?: readonly string[]
|
|
29
31
|
readonly scope?: 'user' | 'project'
|
|
30
32
|
readonly projectRoot?: string
|
|
31
33
|
readonly cwd?: string
|
|
32
34
|
readonly dependencies?: { [Name in keyof CmodDependencies]?: CmodDependencies[Name] }
|
|
33
35
|
readonly files?: Readonly<Record<string, string>>
|
|
36
|
+
readonly links?: Readonly<Record<string, string>>
|
|
34
37
|
}
|
|
35
38
|
```
|
|
36
39
|
|
|
37
40
|
| Option | What it sets | Default |
|
|
38
41
|
| --- | --- | --- |
|
|
39
42
|
| `state` | Values over the declared starting state | The declared state |
|
|
43
|
+
| `options` | The option values Claude Code passes, as a person set them ([options.md](options.md)) | none: each option gets its default |
|
|
44
|
+
| `permissions` | Exactly the permissions the person granted, such as `['network:api.github.com', 'model']` ([permissions.md](permissions.md)) | every permission the mod's `package.json` declares |
|
|
40
45
|
| `scope` | `'project'` makes the mod a project plugin, in `<projectRoot>/.claude/skills/<name>` | `'user'` |
|
|
41
46
|
| `projectRoot` | `mod.projectRoot` | `/test/plugins/<name>`, or `/test/project` for a project plugin |
|
|
42
47
|
| `cwd` | `mod.cwd` | `projectRoot` |
|
|
43
48
|
| `dependencies` | The methods other mods answer with | none: every call fails with `<name> is not installed. Run cmod install <name>.` |
|
|
44
49
|
| `files` | The fake file system, absolute path to text | no files |
|
|
50
|
+
| `links` | Symbolic links in the fake file system, absolute link path to its target | no links |
|
|
45
51
|
|
|
46
52
|
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
53
|
|
|
@@ -213,6 +219,7 @@ type Fakes = {
|
|
|
213
219
|
fs: { read?, write?, list?, exists?, stat? }
|
|
214
220
|
http: { fetch? }
|
|
215
221
|
settings: { read? }
|
|
222
|
+
config: { list?, set? }
|
|
216
223
|
ui: { ask?, open?, scroll? }
|
|
217
224
|
session: { messages?, append? }
|
|
218
225
|
prompt: { submit? }
|
|
@@ -234,7 +241,11 @@ Set a fake to answer a call:
|
|
|
234
241
|
- `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, so a test of `mod.every` keeps it and calls it once per tick.
|
|
235
242
|
- `session.append` and `prompt.submit` take every note and prompt by default, into `shown.notes` and `shown.prompts`. A fake that answers `{ deny }` or `{ drop }` refuses one.
|
|
236
243
|
- `model.complete` has no default: set it to the answer the model gives, such as `async () => ({ isAnswered: true, text: 'no', usage })`.
|
|
244
|
+
- `config.list` answers no `/config` rows by default, and `config.set` answers every change with `{ value }`. A `config.list` fake that answers a row with `isLocked: true` locks that option as managed settings do.
|
|
237
245
|
- `tool.call` answers `CronCreate`, `CronDelete`, and `CronList` by default, keeping the crons a `schedule` makes in `shown.schedules`. A call to any other tool needs a fake.
|
|
246
|
+
- `files` takes symbolic links too: `testMod(mod, { files, links: { '/work/skills': '/shared/skills' } })` makes `/work/skills` a link to `/shared/skills`, so a test of `mod.fs.find` or `mod.metadata.update` follows it as a real file system does.
|
|
247
|
+
- `testMod` runs no installer pane: an option with no default and no value fails `start()` with its fix, and the `installer` steps are not shown. Test a step's `render` and `isDone` by calling them with the started `mod`.
|
|
248
|
+
- The `committer` below runs `git`, so its `package.json` declares `"permissions": { "run": ["git"] }`. A gated call the mod does not declare fails before it reaches a fake ([permissions.md](permissions.md)).
|
|
238
249
|
|
|
239
250
|
```ts
|
|
240
251
|
import { expect, test } from 'bun:test'
|
package/installer.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Frozen, RenderElement, RenderPropsOf } from 'claude-code';
|
|
2
|
+
import type { Mod } from './mod.js';
|
|
3
|
+
export type Step<State extends object = Record<never, never>> = {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly title: string;
|
|
6
|
+
render(mod: Mod<State>, props: Frozen<RenderPropsOf['Pane']>): RenderElement;
|
|
7
|
+
isDone(mod: Mod<State>): boolean | Promise<boolean>;
|
|
8
|
+
};
|
|
9
|
+
export declare function defineStep<State extends object = Record<never, never>>(step: Step<State>): Step<State>;
|
|
10
|
+
//# sourceMappingURL=installer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["src/installer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AACvE,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;AAEnC,MAAM,MAAM,IAAI,CAAC,KAAK,SAAS,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI;IAC9D,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,GAAG,aAAa,CAAA;IAC5E,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC,GAAG,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CACpD,CAAA;AAID,wBAAgB,UAAU,CAAC,KAAK,SAAS,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAItG"}
|
package/installer.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const stepId = /^[A-Za-z0-9_-]{1,64}$/;
|
|
2
|
+
export function defineStep(step) {
|
|
3
|
+
if (!stepId.test(step.id))
|
|
4
|
+
throw new Error(`defineStep: "${step.id}" is not a step id. Use 1 to 64 letters, digits, "_", or "-".`);
|
|
5
|
+
if (step.title.trim() === '')
|
|
6
|
+
throw new Error(`defineStep: the step "${step.id}" needs a title, the line the installer shows above it.`);
|
|
7
|
+
return step;
|
|
8
|
+
}
|
package/jobs/context.d.ts
CHANGED
|
@@ -3,12 +3,12 @@ import { type Deadline } from '../runtime/deadline.js';
|
|
|
3
3
|
import type { ToolCall } from '../utils/call-effects.js';
|
|
4
4
|
import { type Workspace } from './permissions/find-project-scope.js';
|
|
5
5
|
import { type Target } from './permissions/match-target.js';
|
|
6
|
-
export declare function workspaceReader({ claude }: JobContext): () => Promise<Workspace>;
|
|
6
|
+
export declare function workspaceReader({ mod: { claude } }: JobContext): () => Promise<Workspace>;
|
|
7
7
|
export declare function afterCall<State extends object>(job: JobContext<State>, contextAfter: (call: ToolCall, workspace: Workspace) => Promise<readonly string[]>, failed: (error: unknown) => readonly string[]): void;
|
|
8
8
|
export declare function modOf<State extends object>(job: JobContext<State>, { call, folder }: {
|
|
9
9
|
readonly call?: ToolCall;
|
|
10
10
|
readonly folder: string;
|
|
11
11
|
}, workspace: Workspace, deadline: Deadline): Mod<State>;
|
|
12
|
-
export declare function modWithin<State extends object>({ mod
|
|
12
|
+
export declare function modWithin<State extends object>({ mod }: JobContext<State>, deadline: Deadline, workTree?: () => Promise<string | undefined>): Mod<State>;
|
|
13
13
|
export declare function targetWords(target: Target): string;
|
|
14
14
|
//# sourceMappingURL=context.d.ts.map
|
package/jobs/context.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/jobs/context.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,WAAW,CAAA;AAEhD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,wBAAwB,CAAA;AAItE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACxD,OAAO,EAAoB,KAAK,SAAS,EAAE,MAAM,qCAAqC,CAAA;AACtF,OAAO,EAAY,KAAK,MAAM,EAAE,MAAM,+BAA+B,CAAA;AAOrE,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,EAAE,UAAU,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/jobs/context.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,WAAW,CAAA;AAEhD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,wBAAwB,CAAA;AAItE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACxD,OAAO,EAAoB,KAAK,SAAS,EAAE,MAAM,qCAAqC,CAAA;AACtF,OAAO,EAAY,KAAK,MAAM,EAAE,MAAM,+BAA+B,CAAA;AAOrE,wBAAgB,eAAe,CAAC,EAAE,GAAG,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,CAOzF;AAaD,wBAAgB,SAAS,CAAC,KAAK,SAAS,MAAM,EAC5C,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,EACtB,YAAY,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,EAClF,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,SAAS,MAAM,EAAE,GAC5C,IAAI,CAUN;AAMD,wBAAgB,KAAK,CAAC,KAAK,SAAS,MAAM,EAAE,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE;IAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,CAEjM;AAED,wBAAgB,SAAS,CAAC,KAAK,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,GAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAyB,GAAG,GAAG,CAAC,KAAK,CAAC,CA4B/K;AAwBD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CASlD"}
|