@cmodjs/core 0.1.7 → 0.1.9
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 +2 -2
- package/docs/commands.md +14 -14
- package/docs/dependencies.md +1 -1
- package/docs/hooks.md +69 -13
- package/docs/index.md +7 -5
- package/docs/install-steps.md +19 -19
- package/docs/jobs.md +22 -21
- package/docs/mod.md +44 -12
- package/docs/state.md +8 -8
- package/docs/testing.md +25 -9
- package/docs/ui.md +33 -9
- package/jobs/check.js +2 -2
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +2 -0
- package/jobs/permissions/decide-permission.js +2 -2
- package/jobs/permissions/find-project-scope.d.ts +1 -1
- package/jobs/permissions/find-project-scope.d.ts.map +1 -1
- package/jobs/permissions/find-project-scope.js +9 -9
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions/match-target.js +4 -3
- package/mod.d.ts +15 -5
- package/mod.d.ts.map +1 -1
- package/package.json +2 -1
- package/path.d.ts +2 -0
- package/path.d.ts.map +1 -0
- package/path.js +1 -0
- package/records.js +3 -3
- package/register.d.ts.map +1 -1
- package/register.js +8 -2
- package/runtime/claude.d.ts +4 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/hooks.d.ts +10 -2
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +56 -18
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +17 -7
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +38 -8
- package/shell.d.ts +2 -0
- package/shell.d.ts.map +1 -0
- package/shell.js +1 -0
- package/src/jobs/check.ts +2 -2
- package/src/jobs/context.ts +2 -0
- package/src/jobs/permissions/decide-permission.ts +2 -2
- package/src/jobs/permissions/find-project-scope.ts +11 -10
- package/src/jobs/permissions/match-target.ts +4 -3
- package/src/mod.ts +22 -3
- package/src/path.ts +1 -0
- package/src/records.ts +3 -3
- package/src/register.ts +8 -2
- package/src/runtime/claude.ts +11 -1
- package/src/runtime/hooks.ts +62 -23
- package/src/runtime/lifecycle.ts +16 -9
- package/src/runtime/ui.ts +35 -8
- package/src/shell.ts +1 -0
- package/src/testing/fake-claude.ts +14 -5
- package/src/testing/fake-elements.ts +5 -7
- package/src/testing/fake-files.ts +5 -5
- package/src/testing.ts +83 -11
- package/src/ui/define-pane.ts +5 -1
- package/src/utils/call-effects.ts +12 -11
- package/src/utils/parse-shell.ts +28 -36
- package/src/utils/paths.ts +4 -19
- package/src/vendor.ts +1 -0
- package/testing/fake-claude.d.ts +5 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +13 -3
- package/testing/fake-elements.d.ts +5 -8
- package/testing/fake-elements.d.ts.map +1 -1
- package/testing/fake-elements.js +6 -6
- package/testing/fake-files.js +5 -5
- package/testing.d.ts +4 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +65 -9
- package/ui/define-pane.d.ts +5 -1
- package/ui/define-pane.d.ts.map +1 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +12 -11
- package/utils/parse-shell.d.ts +5 -9
- package/utils/parse-shell.d.ts.map +1 -1
- package/utils/parse-shell.js +5 -10
- package/utils/paths.d.ts +0 -3
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +3 -18
- package/vendor.d.ts +1 -0
- package/vendor.d.ts.map +1 -1
- package/vendor.js +209 -12
package/docs/mod.md
CHANGED
|
@@ -17,7 +17,7 @@ type ModDefinition<State, Name> = {
|
|
|
17
17
|
}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
- `name` is the mod's name. Use the `name` in `.claude-plugin/plugin.json`. Claude Mod Manager (
|
|
20
|
+
- `name` is the mod's name. Use the `name` in `.claude-plugin/plugin.json`. Claude Mod Manager (cmod) keys the saved state and the `state.json` files by this name, and the data folder and the Skill overrides by the plugin's name, so the two must match. `defineMod` throws `defineMod: the mod needs a name, such as the name in .claude-plugin/plugin.json.` for a blank name.
|
|
21
21
|
- `state` holds the starting values, grouped by how long they last. [state.md](state.md) explains the groups.
|
|
22
22
|
- `api` holds the methods other mods call. [dependencies.md](dependencies.md) explains it.
|
|
23
23
|
- `setup` runs once when the mod starts. It adds hooks, panes, slot renders, and jobs. It may be `async`.
|
|
@@ -41,9 +41,9 @@ export const greeter = defineMod({
|
|
|
41
41
|
|
|
42
42
|
## When setup runs
|
|
43
43
|
|
|
44
|
-
Claude Code loads the mod at the start of each session.
|
|
44
|
+
Claude Code loads the mod at the start of each session. cmod then checks that the mod is set up. A mod with an install step that has not run waits for the person's consent and the install first ([install-steps.md](install-steps.md)). Then cmod loads the saved state, runs `setup`, and marks as open each of the mod's panes that Claude Code already shows.
|
|
45
45
|
|
|
46
|
-
The first time a version starts,
|
|
46
|
+
The first time a version starts, cmod shows the toast `<name> is ready` and logs `<name> added <what setup added>`, such as `the my-mod pane, a render of AbovePrompt and a hook on UserPromptSubmit`.
|
|
47
47
|
|
|
48
48
|
When a step fails, the mod does not start. Its progress line above the prompt names the failure and the fix:
|
|
49
49
|
|
|
@@ -51,7 +51,7 @@ When a step fails, the mod does not start. Its progress line above the prompt na
|
|
|
51
51
|
- `its state did not load: <error>` when reading the saved values or a `state.json` fails.
|
|
52
52
|
- `its setup function threw: <error>` when `setup` throws or rejects.
|
|
53
53
|
- `its open panes did not load: <error>` when Claude Code cannot list the open panes.
|
|
54
|
-
- `it decides permissions on <events>, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod` when `setup` adds a hook on `
|
|
54
|
+
- `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.
|
|
55
55
|
|
|
56
56
|
Each of these says `Fix it, then run /reload-plugins.`
|
|
57
57
|
|
|
@@ -74,9 +74,9 @@ registerMod<State>(addHook: On, definition: ModDefinition<State>): void
|
|
|
74
74
|
registerPermissionCheck(addHook: On): void
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
`registerMod` registers one handler per Claude Code event the mod can use, and routes each event to the mod. It leaves out Claude Code's permission check, the events `tool.check
|
|
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`.
|
|
78
78
|
|
|
79
|
-
A mod that decides permissions calls `registerPermissionCheck` after `registerMod`. That covers a hook
|
|
79
|
+
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
80
|
|
|
81
81
|
```ts
|
|
82
82
|
export function register(addHook: On): void {
|
|
@@ -102,11 +102,13 @@ type Mod<State> = {
|
|
|
102
102
|
readonly cwd: string
|
|
103
103
|
on(event, hook): void
|
|
104
104
|
use(job): Handle
|
|
105
|
-
readonly ui: { pane, render, toast, progress, ask }
|
|
105
|
+
readonly ui: { pane, render, toast, progress, ask, scroll }
|
|
106
106
|
readonly process: { run, spawn }
|
|
107
|
-
readonly fs: { read, write, list }
|
|
107
|
+
readonly fs: { read, write, list, exists, stat }
|
|
108
108
|
readonly http: { fetch }
|
|
109
109
|
readonly settings: { read }
|
|
110
|
+
readonly session: { messages }
|
|
111
|
+
readonly agent: { spawn }
|
|
110
112
|
readonly dependencies: CmodDependencies
|
|
111
113
|
}
|
|
112
114
|
```
|
|
@@ -115,16 +117,18 @@ type Mod<State> = {
|
|
|
115
117
|
| --- | --- | --- |
|
|
116
118
|
| `name` | The mod's name from `defineMod`. | |
|
|
117
119
|
| `state` | The mod's values. Assign to a key to change it. | [state.md](state.md) |
|
|
118
|
-
| `dataFolder` | The mod's own folder in the
|
|
120
|
+
| `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) |
|
|
119
121
|
| `projectRoot` | The project the session works in. It follows `/cd`. | |
|
|
120
122
|
| `cwd` | The session's working folder. It follows a `cd` in a Bash or PowerShell call, and `/cd`. | |
|
|
121
123
|
| `on` | Adds a hook on a Claude Code event. | [hooks.md](hooks.md) |
|
|
122
124
|
| `use` | Adds a job, such as a slash command or a tool, and returns its handle. | [jobs.md](jobs.md) |
|
|
123
|
-
| `ui` | Panes, slot renders, toasts, progress lines, and
|
|
125
|
+
| `ui` | Panes, slot renders, toasts, progress lines, questions, and scrolling. | [ui.md](ui.md) |
|
|
124
126
|
| `process` | Runs a program. | below |
|
|
125
|
-
| `fs` | Reads and
|
|
127
|
+
| `fs` | Reads, writes, and looks up files. | below |
|
|
126
128
|
| `http` | Fetches a URL. | below |
|
|
127
129
|
| `settings` | Reads Claude Code's settings. | below |
|
|
130
|
+
| `session` | Reads the conversation, or a subagent's. | below |
|
|
131
|
+
| `agent` | Starts a subagent. | below |
|
|
128
132
|
| `dependencies` | Calls the methods of other mods. | [dependencies.md](dependencies.md) |
|
|
129
133
|
|
|
130
134
|
### mod.process
|
|
@@ -145,7 +149,7 @@ Both run a program without a shell. `argv[0]` is the program and the rest are it
|
|
|
145
149
|
- `spawn` streams `{ stream: 'stdout' | 'stderr', text }` pieces as the program writes them. Its `result` resolves `{ code, signal }` once the stream is read to its end. Its first pull rejects when the program cannot start.
|
|
146
150
|
- The `for await` loop over a `spawn` is the program's life. Leaving the loop, or calling `return()` on the stream, kills the program, and so does unloading the mod. Returning from the hook does not.
|
|
147
151
|
|
|
148
|
-
Inside a job,
|
|
152
|
+
Inside a job, cmod adds the job's deadline to each call ([jobs.md](jobs.md)).
|
|
149
153
|
|
|
150
154
|
```ts
|
|
151
155
|
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
@@ -168,12 +172,16 @@ export const branchNote = defineMod({
|
|
|
168
172
|
mod.fs.read(path: string): Promise<string>
|
|
169
173
|
mod.fs.write(path: string, text: string): Promise<void>
|
|
170
174
|
mod.fs.list(path?: string): Promise<FsEntry[]>
|
|
175
|
+
mod.fs.exists(path: string): Promise<boolean>
|
|
176
|
+
mod.fs.stat(path: string, options?: { resolve: boolean }): Promise<FsStat>
|
|
171
177
|
```
|
|
172
178
|
|
|
173
179
|
- A relative path is under the session's working folder. An absolute path is used as given. Text is UTF-8.
|
|
174
180
|
- `read` rejects a missing file and a file over 4 MiB.
|
|
175
181
|
- `write` writes the whole file and creates its folders.
|
|
176
182
|
- `list` lists a folder, the working folder without a path. Each entry is `{ name, kind, size, mtimeMs, isLink }`, and `kind` is `'file'`, `'dir'`, or `'other'`. A symbolic link is `'other'` with `isLink` true.
|
|
183
|
+
- `exists` answers whether the path leads to a file or a folder.
|
|
184
|
+
- `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.
|
|
177
185
|
|
|
178
186
|
A file the mod ships is under the plugin's folder. `mod` has no member that names that folder, so a job of your own reads it from `claude.plugin.root` ([jobs.md](jobs.md#the-claude-members)). A file the mod keeps for itself belongs in `mod.dataFolder`.
|
|
179
187
|
|
|
@@ -208,6 +216,30 @@ export const pluginCount = defineMod({
|
|
|
208
216
|
})
|
|
209
217
|
```
|
|
210
218
|
|
|
219
|
+
### mod.session
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
mod.session.messages(): Promise<SessionMessage[]>
|
|
223
|
+
mod.session.messages(args: { agentId?: string; as?: 'api' }): Promise<…>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`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. `{ as: 'api' }` reads it in the Messages API form, `{ role, content }` with the content blocks whole.
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
mod.on('SubagentStop', async ({ agent_id }) => {
|
|
230
|
+
const read = await mod.session.messages({ agentId: agent_id })
|
|
231
|
+
if (Array.isArray(read)) mod.ui.toast(read.at(-1)?.text ?? '')
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### mod.agent
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
mod.agent.spawn(args: { prompt: string; description?; subagentType?; model?; name?; cwd? }): Promise<AgentSpawnResult>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`spawn` starts a subagent in the background, the way the Agent tool starts one, and resolves `{ agentId, model }` once it started, or `{ deny }` when a hook refused it. It does not wait for the subagent to finish. `subagentType` names an agent type, such as `explorer`. `mod.session.messages({ agentId })` reads the subagent's conversation so far.
|
|
242
|
+
|
|
211
243
|
## messageOf
|
|
212
244
|
|
|
213
245
|
```ts
|
package/docs/state.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# State
|
|
2
2
|
|
|
3
|
-
`mod.state` holds the mod's values. Claude Mod Manager (
|
|
3
|
+
`mod.state` holds the mod's values. Claude Mod Manager (cmod) saves them, redraws the mod's panes and slot renders when one changes, and lets a person change the starting values without touching the mod's code.
|
|
4
4
|
|
|
5
5
|
## Declare the state
|
|
6
6
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| `project` | This project, in every conversation. | Yes |
|
|
14
14
|
| `global` | Every project. | Yes |
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
cmod keeps each `session` value for the 20 sessions that wrote it last, and each `project` value for the 20 projects that wrote it last. A session or project past those 20 starts that value over from its starting value.
|
|
17
17
|
|
|
18
18
|
Put values the mod works out again on every prompt, such as a git status, in `memory`.
|
|
19
19
|
|
|
@@ -56,7 +56,7 @@ A group other than `memory`, `session`, `project`, and `global` throws `<mod>: s
|
|
|
56
56
|
|
|
57
57
|
## Change a value
|
|
58
58
|
|
|
59
|
-
Assign to a key.
|
|
59
|
+
Assign to a key. cmod saves the new value and redraws the mod.
|
|
60
60
|
|
|
61
61
|
- A key the state does not declare throws `<mod>: mod.state.<group>.<key> is not declared. Add it to state.<group> in defineMod with its starting value.`
|
|
62
62
|
- An object or an array in the state is frozen. Changing it in place throws `<mod>: mod.state.<path> cannot change in place.` Assign a new value instead:
|
|
@@ -75,8 +75,8 @@ export const reads = defineMod({
|
|
|
75
75
|
})
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
- Keep only JSON data in a saved group. A value
|
|
79
|
-
-
|
|
78
|
+
- Keep only JSON data in a saved group. A value cmod cannot save logs `<mod> could not keep state.<group>.<key>: <error>. Keep only JSON data in mod.state.`
|
|
79
|
+
- cmod saves values one at a time in the order they were assigned.
|
|
80
80
|
|
|
81
81
|
## Where the values come from
|
|
82
82
|
|
|
@@ -87,7 +87,7 @@ When the mod starts, each value is, in order of precedence:
|
|
|
87
87
|
3. The value in the person's own `state.json`.
|
|
88
88
|
4. The starting value in `defineMod`.
|
|
89
89
|
|
|
90
|
-
After a `/cd` to another project,
|
|
90
|
+
After a `/cd` to another project, cmod loads that project's `project` values and reads its `state.json` again.
|
|
91
91
|
|
|
92
92
|
## Let a person change the starting values
|
|
93
93
|
|
|
@@ -111,7 +111,7 @@ A `state.json` in either folder lists only the values to change:
|
|
|
111
111
|
- A project `state.json` cannot set `global` values, because one repository cannot change a value for every project.
|
|
112
112
|
- A file cannot set `memory` values.
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
cmod ignores each entry it cannot use and logs one line naming the file, the entry, and the fix:
|
|
115
115
|
|
|
116
116
|
- a file that is not JSON, or not a JSON object
|
|
117
117
|
- a group the mod does not declare
|
|
@@ -131,7 +131,7 @@ A person replaces the text of a Skill the mod ships by putting a file at the sam
|
|
|
131
131
|
|
|
132
132
|
- Claude reads that file in place of the mod's from the next time the Skill loads, and updates of the mod keep it.
|
|
133
133
|
- The project file wins over the person's file.
|
|
134
|
-
-
|
|
134
|
+
- cmod drops the file's frontmatter, so the mod's own name and description stay.
|
|
135
135
|
- Only a Skill the mod ships in `skills/<skill>/` is replaced, and only when it loads as `<mod>:<skill>`.
|
|
136
136
|
- Deleting the file brings the mod's text back.
|
|
137
137
|
|
package/docs/testing.md
CHANGED
|
@@ -58,7 +58,9 @@ type TestedMod<State> = {
|
|
|
58
58
|
lines(slot, props, requestId?): Promise<string[]>
|
|
59
59
|
type(line: string): Promise<Reply>
|
|
60
60
|
callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
|
|
61
|
-
press(paneId: string, key: string): Promise<void>
|
|
61
|
+
press(paneId: string, key: string, link?: string): Promise<void>
|
|
62
|
+
input(paneId: string, key: string, text: string, kind?: 'submit' | 'change'): Promise<void>
|
|
63
|
+
select(paneId: string, key: string, value: string): Promise<void>
|
|
62
64
|
moveTo(projectRoot: string, cwd?: string): Promise<void>
|
|
63
65
|
readonly state: Readonly<State>
|
|
64
66
|
readonly calls: readonly TestCall[]
|
|
@@ -69,7 +71,7 @@ type TestedMod<State> = {
|
|
|
69
71
|
|
|
70
72
|
### start
|
|
71
73
|
|
|
72
|
-
Starts the mod: loads its state, runs `setup`, and adds its hooks, panes, and jobs. `fire`, `lines`, `type`, `callTool`, `press`, and `moveTo` start the mod first, so a test calls `start` only before it reads `state` or `shown` with nothing else to run. It rejects with the reason when the mod does not start.
|
|
74
|
+
Starts the mod: loads its state, runs `setup`, and adds its hooks, panes, and jobs. `fire`, `lines`, `type`, `callTool`, `press`, `input`, `select`, and `moveTo` start the mod first, so a test calls `start` only before it reads `state` or `shown` with nothing else to run. It rejects with the reason when the mod does not start.
|
|
73
75
|
|
|
74
76
|
### fire
|
|
75
77
|
|
|
@@ -80,9 +82,10 @@ fire<N extends RoutedEvent>(event: N, input: Args<N>, below?): Promise<EventResu
|
|
|
80
82
|
|
|
81
83
|
- `fire('UserPromptSubmit', { prompt: 'hello' })` runs the mod's hooks on a `ModEvent`. `TestInput<E>` is the event's input without the fields `fire` fills: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, and a new `tool_use_id` for each tool event.
|
|
82
84
|
- For a tool event, `files` is worked out from the call and the test's `files`, as in Claude Code.
|
|
83
|
-
- `
|
|
85
|
+
- `fire('PreToolUse', …)` runs the call's `tool.call`, and its `tool.check` inside it, as Claude Code does. `testMod` runs a mod as if `register.ts` calls `registerPermissionCheck`, so it returns `allow: true` or `ask` with the reason when the mod's hooks or `permissions` job decided the permission. A `PreToolUse` with `agent_id` and `agent_type`, such as `fire('PreToolUse', { tool_name: 'Read', tool_input: { file_path: 'a.ts' }, agent_id: 'agent-7', agent_type: 'explorer' })`, runs as that subagent's call. It returns `updatedInput` when the hooks rewrote the input, `additionalContext` as a list, and `deny` when a hook or Claude Code refused the call. Its `below` is `{ deny }` to test a call Claude Code refuses, and Claude Code runs the call otherwise.
|
|
86
|
+
- On every other event, `below` is what the hooks beneath the mod answered. Pass it to test how the mod's answer combines with another. When nothing in the mod answers, `fire` returns `below` as it is.
|
|
84
87
|
- Left out, `below` is `{}`, except on `prompt.submit`, where it is what Claude Code answers: the prompt that entered, `{ text, context, origin }`, with the `context` the mod's hooks added.
|
|
85
|
-
- It returns the combined answer in Claude Code's own field names: `additionalContext` is a list,
|
|
88
|
+
- It returns the combined answer in Claude Code's own field names: `additionalContext` is a list, and `decision: 'block'` is `block`.
|
|
86
89
|
- `fire('tool.check', { tool: 'Edit', input: { … }, tool_use_id: 'toolu_1' }, { decision: 'allow' })` asks the mod's permission rules about any call. A name with a dot fires that Claude Code hook-module event as it is.
|
|
87
90
|
|
|
88
91
|
```ts
|
|
@@ -141,7 +144,7 @@ Waits until the work a hook started without awaiting it has run, such as a `void
|
|
|
141
144
|
|
|
142
145
|
### lines
|
|
143
146
|
|
|
144
|
-
- `lines(paneId)` draws an open pane and returns its rows of text. It rejects a pane that is not open: open it first with `tested.type('/<command>')` or `pane.open()` in the mod. The render gets the props `title`, `isFocused:
|
|
147
|
+
- `lines(paneId)` draws an open pane and returns its rows of text. It rejects a pane that is not open: open it first with `tested.type('/<command>')` or `pane.open()` in the mod. The render gets the props `title`, `isFocused`, true once an `open` or `toggle` with `{ focus: true }` opened it, `bodyColumns`, `placement: 'dock'`, `scroll: { offset: 0, bodyRows: 24 }`, and `view: {}`, the main conversation's view.
|
|
145
148
|
- `lines(slot, props, requestId?)` draws a slot render with `props` and returns its rows. `Default` draws a plain listing of the props it gets, so a test sees what the render changed. Draw `slots.AssistantMessage` with a reply's `text` to test a markdown slot render. Draws that pass one `requestId` grow one streamed reply.
|
|
146
149
|
|
|
147
150
|
```ts
|
|
@@ -169,9 +172,20 @@ test('the band shows the prompt count on its last line', async () => {
|
|
|
169
172
|
|
|
170
173
|
`callTool(name, input)` runs the mod's permission rules on a call of its own tool, then the tool. `name` is the name given to `tool`, without `mcp__<plugin>__`. It returns `{ result }`, or `{ deny }` when a rule or the input check denies the call. It rejects a call a rule would ask the person about, and a tool the mod does not add.
|
|
171
174
|
|
|
172
|
-
### press
|
|
175
|
+
### press, input, and select
|
|
173
176
|
|
|
174
|
-
`press
|
|
177
|
+
`press`, `input`, and `select` act on an element of an open pane as the person does, and await its handler. Each passes the handler the event Claude Code passes, with `component: 'Pane'`, `requestId` the pane's id, and `surface: 'terminal'`. Each rejects when the pane does not draw the element: `The pane "<pane>" of <mod> draws no <Element> with the key "<key>".`
|
|
178
|
+
|
|
179
|
+
- `press(paneId, key)` presses the `Button` whose `key` or `label` is `key`, and runs its `onPress`.
|
|
180
|
+
- `press(paneId, key, link)` presses the link `link` in the `Markdown` whose `key` is `key`, and runs its `onLinkPress`.
|
|
181
|
+
- `input(paneId, key, text)` types `text` into the `Input` whose `key` is `key` and presses Enter, which runs its `onSubmit`. With `kind` `'change'` it runs `onInput` instead, as one edit does.
|
|
182
|
+
- `select(paneId, key, value)` picks the option `value` of the `Select` whose `key` is `key`, and runs its `onSelect`. It rejects an option the `Select` does not have.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
await tested.input('history', 'search', 'deploy')
|
|
186
|
+
await tested.select('history', 'agent', 'explorer')
|
|
187
|
+
expect(await tested.lines('history')).toContain('deploy by explorer')
|
|
188
|
+
```
|
|
175
189
|
|
|
176
190
|
### moveTo
|
|
177
191
|
|
|
@@ -193,8 +207,9 @@ type Fakes = {
|
|
|
193
207
|
fs: { read?, write?, list?, exists?, stat? }
|
|
194
208
|
http: { fetch? }
|
|
195
209
|
settings: { read? }
|
|
196
|
-
ui: { ask?, open? }
|
|
197
|
-
|
|
210
|
+
ui: { ask?, open?, scroll? }
|
|
211
|
+
session: { messages? }
|
|
212
|
+
agent: { list?, spawn? }
|
|
198
213
|
clock: { after?, every? }
|
|
199
214
|
cmod: { call? }
|
|
200
215
|
}
|
|
@@ -206,6 +221,7 @@ Set a fake to answer a call:
|
|
|
206
221
|
- A `program` job is the exception: it catches the `process.spawn` error and retries after 1, 2, 4, 8 and 16 seconds on real timers, so its `ready()` waits past `bun test`'s 5-second timeout. Set `fakes.process.spawn` before a test starts a `program` job.
|
|
207
222
|
- `files` already fills the `fs` fakes: a write is read back, and `list` lists the given files. The fake file system resolves a relative path against `/`, not against `mod.cwd`, so a mod under test reads `${mod.projectRoot}/<file>` rather than a relative path. `fs.list()` with no path rejects.
|
|
208
223
|
- `ui.open` places every pane by default. A fake that answers `{ isPlaced: false }` holds a pane back.
|
|
224
|
+
- `ui.scroll` answers `{}` by default, as when the window moved. A fake that answers `{ deny }` refuses the scroll.
|
|
209
225
|
- `clock.after(ms, fn)` and `clock.every(ms, fn)` each return `{ cancel() }`, and so must a fake of either. `clock.after` runs a real timer by default. `clock.every` never fires unless a fake keeps `fn` for the test to call.
|
|
210
226
|
|
|
211
227
|
```ts
|
package/docs/ui.md
CHANGED
|
@@ -8,9 +8,10 @@ mod.ui.render<S extends Slot>(slot: S, Component: (props: SlotProps<S>) => Rende
|
|
|
8
8
|
mod.ui.toast(text: string): void
|
|
9
9
|
mod.ui.progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
|
|
10
10
|
mod.ui.ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
|
|
11
|
+
mod.ui.scroll(args: UiScrollArgs): Promise<UiScrollResult>
|
|
11
12
|
```
|
|
12
13
|
|
|
13
|
-
Claude Mod Manager (
|
|
14
|
+
Claude Mod Manager (cmod) draws the mod again whenever a value in `mod.state` changes. A render reads `mod.state` and returns what to show. It never draws by hand.
|
|
14
15
|
|
|
15
16
|
## Elements
|
|
16
17
|
|
|
@@ -46,7 +47,7 @@ export function Count({ label, count }: { readonly label: string; readonly count
|
|
|
46
47
|
|
|
47
48
|
An element called outside a render throws `<Element> was called outside a render. Use it inside a pane's render or a slot's component.` So build elements inside a pane's `render` or a slot's component, never at module load or in a hook.
|
|
48
49
|
|
|
49
|
-
`drawWith(table, draw, markdown?)` is the function
|
|
50
|
+
`drawWith(table, draw, markdown?)` is the function cmod draws a render with. A mod never calls it.
|
|
50
51
|
|
|
51
52
|
## Panes
|
|
52
53
|
|
|
@@ -62,14 +63,23 @@ type Pane<State> = {
|
|
|
62
63
|
readonly title: string
|
|
63
64
|
readonly columns?: number | ((state: State) => number | undefined)
|
|
64
65
|
readonly rows?: number | ((state: State) => number | undefined)
|
|
66
|
+
readonly closeOnEscape?: true
|
|
67
|
+
readonly holdToasts?: true
|
|
65
68
|
render(mod: Mod<State>, props: Frozen<RenderPropsOf['Pane']>): RenderElement
|
|
69
|
+
onScroll?(mod: Mod<State>, e: Frozen<UiScrollInput>): void | Promise<void>
|
|
70
|
+
onClose?(mod: Mod<State>, e: Frozen<PaneCloseInput>): void | Promise<void>
|
|
66
71
|
}
|
|
67
72
|
```
|
|
68
73
|
|
|
69
74
|
- `id` is 1 to 64 letters, digits, `_`, or `-`. Another id throws `definePane: "<id>" is not a pane id.`
|
|
70
75
|
- `title` labels the pane's tab while more than one pane is open.
|
|
71
|
-
- `columns` and `rows` ask for a size. A number must be a whole number above 0, or `definePane` throws. A function reads the state and returns the size, or `undefined` for Claude Code's default. When the state changes the size,
|
|
76
|
+
- `columns` and `rows` ask for a size. A number must be a whole number above 0, or `definePane` throws. A function reads the state and returns the size, or `undefined` for Claude Code's default. When the state changes the size, cmod resizes an open pane. A function that returns a bad size keeps an open pane at its size and logs the pane's title to the debug log. `open`, and `toggle` on a closed pane, reject with that size instead.
|
|
77
|
+
- `closeOnEscape` closes the pane when the person presses Escape while it holds the keyboard, as their own close does. Left out, Escape gives the keyboard back to the prompt and the pane stays.
|
|
78
|
+
- `holdToasts` holds every toast back while the pane is shown, the toasts of other plugins and of Claude Code included. Use it for a pane the person answers and closes, never for one that stays open.
|
|
72
79
|
- `render` draws the pane's body. `props` holds `title`, `isFocused`, `bodyColumns`, `placement` (`'dock'` or `'inline'`), `scroll`, the body's window over a taller drawing, and `view`, which transcript is on screen beside the pane.
|
|
80
|
+
- `onScroll` runs after this pane's window moved, by the person's wheel or keys or by `mod.ui.scroll`. `e` holds the new `offset`, the move's `by`, `bodyRows`, `contentRows`, and `origin`. Another pane's scroll and the band's never reach it, and a scroll Claude Code refused does not either.
|
|
81
|
+
- `onClose` runs after the pane closed, by the person or by `close()`. `e.origin.kind` is `'person'` or `'plugin'`.
|
|
82
|
+
- A handler that throws logs `<mod>: the <title> pane's <handler> threw: <error>`.
|
|
73
83
|
|
|
74
84
|
Put each pane in its own file in `src/panes/`.
|
|
75
85
|
|
|
@@ -98,21 +108,23 @@ export const notesPane = definePane<NotesState>({
|
|
|
98
108
|
|
|
99
109
|
```ts
|
|
100
110
|
type PaneHandle = {
|
|
101
|
-
open(): Promise<void>
|
|
111
|
+
open(options?: { readonly focus?: true }): Promise<void>
|
|
102
112
|
close(): Promise<void>
|
|
103
|
-
toggle(): Promise<void>
|
|
113
|
+
toggle(options?: { readonly focus?: true }): Promise<void>
|
|
104
114
|
readonly isOpen: boolean
|
|
105
115
|
}
|
|
106
116
|
```
|
|
107
117
|
|
|
108
118
|
- `open` asks Claude Code to show the pane. `isOpen` turns true once Claude Code places it. Claude Code may hold a pane back, and then `isOpen` stays false.
|
|
109
|
-
- `
|
|
119
|
+
- `{ focus: true }` asks Claude Code to give the pane the keyboard as it opens. Claude Code gives it only while the prompt is empty and holds the keyboard, so typed text, a dialog, or another pane the person is using keeps it. cmod sends `focus` only on that one open, never when it resizes the pane.
|
|
120
|
+
- `mod.ui.pane` only adds the pane. cmod opens it only when the mod calls `open` or `toggle`, and Claude Code shows a pane only once the plugin has opened it. So give the person a way in, such as a slash command whose `reply` calls `pane.toggle()`.
|
|
110
121
|
- A pane opened from what the person did, such as a slash command they typed or a `Button` they pressed, shows at any terminal width. A pane opened from anything else, such as `setup`, a timer, or `SessionStart`, shows only on a terminal 144 columns wide, or 110 for a pane the person opened before. Below that it waits undrawn until the person opens it or the terminal widens.
|
|
111
122
|
- `close` closes it. The person closes a pane too, and `isOpen` follows.
|
|
112
123
|
- `toggle` closes an open pane and opens a closed one.
|
|
113
124
|
- When the mod starts, `isOpen` is true for each of its panes Claude Code already shows.
|
|
114
125
|
- Adding two panes with one id throws `<mod>: the pane "<id>" is already added. Give each pane its own id.`
|
|
115
|
-
-
|
|
126
|
+
- A `render` that throws draws `The <title> pane could not draw: <error>` in the pane, and cmod logs `<mod>: the <title> pane render threw: <error>` once.
|
|
127
|
+
- The line cmod logs when the mod first starts names the pane by its `title`, as in `the Notes pane`.
|
|
116
128
|
|
|
117
129
|
```tsx
|
|
118
130
|
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
@@ -126,7 +138,7 @@ export const notes = defineMod({
|
|
|
126
138
|
state: initialState,
|
|
127
139
|
setup(mod) {
|
|
128
140
|
const pane = mod.ui.pane(notesPane)
|
|
129
|
-
mod.use(slashCommand({ name: 'notes', description: 'Show or hide the notes', reply: () => pane.toggle() }))
|
|
141
|
+
mod.use(slashCommand({ name: 'notes', description: 'Show or hide the notes', reply: () => pane.toggle({ focus: true }) }))
|
|
130
142
|
},
|
|
131
143
|
})
|
|
132
144
|
```
|
|
@@ -157,7 +169,7 @@ export const notes = defineMod({
|
|
|
157
169
|
- `Default` draws what Claude Code, and the renders of other mods, would draw. Pass it props to change what it draws, such as `<Default hint="…" />`. Return `<Default />` to change nothing.
|
|
158
170
|
- A render adds to what it wraps. In `AbovePrompt`, drawing `<Default />` and a line under it keeps the lines of Claude Code and other mods.
|
|
159
171
|
- A render must draw the same for the same props. One that calls `Default` a different number of times on a second draw falls back to Claude Code's drawing.
|
|
160
|
-
- A render that throws falls back to Claude Code's drawing.
|
|
172
|
+
- A render that throws falls back to Claude Code's drawing. cmod logs `<mod>: the <Slot> render threw, so Claude Code draws its own: <error>` once.
|
|
161
173
|
- A mod renders each slot once. A second `mod.ui.render` of one slot throws `<mod>: a render of <Slot> is already added. Render each slot once.` For a markdown slot the error names it as `a render of markdown <Kind>`, such as `a render of markdown CodeBlock`.
|
|
162
174
|
- `slots.ToolUse` changes a call's own row. A group of calls shows the `slots.ToolGroup` row, which Claude Code builds from the stored message, so a mod that hides a tool's input also sets `isExpanded` on `slots.ToolGroup` to unfold the group into `ToolUse` rows.
|
|
163
175
|
- No slot covers the permission dialog.
|
|
@@ -290,6 +302,18 @@ export const committer = defineMod({
|
|
|
290
302
|
})
|
|
291
303
|
```
|
|
292
304
|
|
|
305
|
+
## Scrolling
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
mod.ui.scroll(args: { to: 'start' | 'end' | { key: string } | { requestId: string }; in?: string; block?: 'start' | 'center' | 'end' | 'nearest' }): Promise<UiScrollResult>
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
`scroll` brings something the mod drew into view. `{ key }` is an element the mod drew with that `key`. `'start'` and `'end'` are the top and bottom of the pane whose id `in` names, and `'end'` keeps up with rows the mod adds until the person scrolls. `{ requestId }` is a row Claude Code drew, such as a message of the transcript, which scrolls only while the mod answers the person's own press or key. `block` places the target in the window, `'nearest'` by default. It resolves `{}` once the window moved, or `{ deny }` with the reason it did not.
|
|
312
|
+
|
|
313
|
+
```tsx
|
|
314
|
+
<Button label="Latest" onPress={() => mod.ui.scroll({ to: 'end', in: 'log' })} />
|
|
315
|
+
```
|
|
316
|
+
|
|
293
317
|
## Test the UI
|
|
294
318
|
|
|
295
319
|
`tested.lines(paneId)` reads an open pane as rows of text. `tested.lines(slot, props)` draws a slot render. `tested.press(paneId, key)` presses a button. `tested.shown.toasts` lists the toasts. [testing.md](testing.md) covers each.
|
package/jobs/check.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { longestMs } from '../runtime/deadline.js';
|
|
2
2
|
import { callEffects } from '../utils/call-effects.js';
|
|
3
|
-
import {
|
|
3
|
+
import { dirname } from '../vendor.js';
|
|
4
4
|
import { listed, messageOf } from '../utils/text.js';
|
|
5
5
|
import { afterCall, modOf, targetWords } from './context.js';
|
|
6
6
|
import { matchTarget, targetOf } from './permissions/match-target.js';
|
|
@@ -55,6 +55,6 @@ async function triggeredRun(after, run, use, workspace) {
|
|
|
55
55
|
existing.push(path);
|
|
56
56
|
const last = existing.at(-1);
|
|
57
57
|
if (last !== undefined)
|
|
58
|
-
return { command: [...run, ...existing], folder:
|
|
58
|
+
return { command: [...run, ...existing], folder: dirname(last) };
|
|
59
59
|
return commandFolder === undefined ? undefined : { command: [...run], folder: commandFolder };
|
|
60
60
|
}
|
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,OAAO,EAAE,MAAM,0BAA0B,CAAA;AACjE,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,CAOhF;AAaD,wBAAgB,SAAS,CAAC,KAAK,SAAS,MAAM,EAC5C,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,EACtB,YAAY,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,EAChF,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,MAAM,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,
|
|
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,OAAO,EAAE,MAAM,0BAA0B,CAAA;AACjE,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,CAOhF;AAaD,wBAAgB,SAAS,CAAC,KAAK,SAAS,MAAM,EAC5C,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,EACtB,YAAY,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,EAChF,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,MAAM,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,CA0BvL;AAwBD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CASlD"}
|
package/jobs/context.js
CHANGED
|
@@ -60,6 +60,8 @@ export function modWithin({ mod, claude }, deadline, workTree = async () => unde
|
|
|
60
60
|
read: (path) => within('mod.fs.read', mod.fs.read(path)),
|
|
61
61
|
write: (path, text) => within('mod.fs.write', mod.fs.write(path, text)),
|
|
62
62
|
list: (path) => within('mod.fs.list', mod.fs.list(path)),
|
|
63
|
+
exists: (path) => within('mod.fs.exists', mod.fs.exists(path)),
|
|
64
|
+
stat: (path, options) => within('mod.fs.stat', mod.fs.stat(path, options)),
|
|
63
65
|
},
|
|
64
66
|
http: { fetch: (url, init) => within('mod.http.fetch', mod.http.fetch(url, init)) },
|
|
65
67
|
dependencies: dependencyCalls(claude, within),
|
|
@@ -2,7 +2,7 @@ import { strictness } from '../../runtime/hooks.js';
|
|
|
2
2
|
import { messageOf } from '../../utils/text.js';
|
|
3
3
|
import { callBaseOf, callEffects } from '../../utils/call-effects.js';
|
|
4
4
|
import { folderInScope, matchTarget, targetOf } from './match-target.js';
|
|
5
|
-
import {
|
|
5
|
+
import { resolve } from '../../vendor.js';
|
|
6
6
|
const wordBoundary = '\\s;&|()<>\'"`=';
|
|
7
7
|
export function stricterVerdict(first, second) {
|
|
8
8
|
return strictness.indexOf(second.decision) > strictness.indexOf(first.decision) ? second : first;
|
|
@@ -63,7 +63,7 @@ async function unparsedMatches(target, use, effects, workspace) {
|
|
|
63
63
|
const folder = named.length === 0 ? undefined : await folderInScope(shell, workspace);
|
|
64
64
|
if (folder === undefined)
|
|
65
65
|
return [];
|
|
66
|
-
return named.map((segment) => ({ call: { ...base, path:
|
|
66
|
+
return named.map((segment) => ({ call: { ...base, path: resolve(folder, segment) }, access: undefined, folder }));
|
|
67
67
|
}
|
|
68
68
|
function containsWord(line, word) {
|
|
69
69
|
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"find-project-scope.d.ts","sourceRoot":"","sources":["../../src/jobs/permissions/find-project-scope.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"find-project-scope.d.ts","sourceRoot":"","sources":["../../src/jobs/permissions/find-project-scope.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AAGtD,MAAM,MAAM,YAAY,GAAG;IAAE,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,CAAA;AAExF,MAAM,MAAM,SAAS,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,YAAY,GAAG,SAAS,CAAA;CAAE,CAAA;AAE3H,wBAAsB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAwB1H"}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { basename, dirname, resolve } from '../../vendor.js';
|
|
2
2
|
export async function findProjectScope(pluginRoot, home, fs) {
|
|
3
|
-
const skills =
|
|
4
|
-
const claude =
|
|
5
|
-
const root =
|
|
6
|
-
if (
|
|
3
|
+
const skills = dirname(pluginRoot);
|
|
4
|
+
const claude = dirname(skills);
|
|
5
|
+
const root = dirname(claude);
|
|
6
|
+
if (basename(skills) !== 'skills' || basename(claude) !== '.claude' || root === resolve('/', home))
|
|
7
7
|
return undefined;
|
|
8
8
|
const repositories = new Map();
|
|
9
9
|
const repositoryOf = async (workTree) => {
|
|
@@ -20,7 +20,7 @@ export async function findProjectScope(pluginRoot, home, fs) {
|
|
|
20
20
|
return undefined;
|
|
21
21
|
return {
|
|
22
22
|
async workTreeOf(path) {
|
|
23
|
-
for (let folder = path;; folder =
|
|
23
|
+
for (let folder = path;; folder = dirname(folder)) {
|
|
24
24
|
const repository = await repositoryOf(folder);
|
|
25
25
|
if (repository !== undefined)
|
|
26
26
|
return repository === project ? folder : undefined;
|
|
@@ -31,7 +31,7 @@ export async function findProjectScope(pluginRoot, home, fs) {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
async function readRepository(workTree, fs) {
|
|
34
|
-
const gitPath =
|
|
34
|
+
const gitPath = resolve(workTree, '.git');
|
|
35
35
|
const git = await fs.stat(gitPath).catch(() => undefined);
|
|
36
36
|
if (git === undefined)
|
|
37
37
|
return undefined;
|
|
@@ -41,8 +41,8 @@ async function readRepository(workTree, fs) {
|
|
|
41
41
|
const gitDirectory = /^gitdir:\s*(.+)$/m.exec(await fs.read(gitPath))?.[1]?.trim();
|
|
42
42
|
if (gitDirectory === undefined)
|
|
43
43
|
throw new Error(`${gitPath} has no gitdir line.`);
|
|
44
|
-
const linkedDirectory =
|
|
45
|
-
const commonDirectory = await fs.read(
|
|
44
|
+
const linkedDirectory = resolve(workTree, gitDirectory);
|
|
45
|
+
const commonDirectory = await fs.read(resolve(linkedDirectory, 'commondir')).then((text) => resolve(linkedDirectory, text.trim()), () => linkedDirectory);
|
|
46
46
|
return (await fs.stat(commonDirectory)).realPath ?? commonDirectory;
|
|
47
47
|
}
|
|
48
48
|
catch {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"match-target.d.ts","sourceRoot":"","sources":["../../src/jobs/permissions/match-target.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,KAAK,QAAQ,EAAE,KAAK,QAAQ,EAAE,KAAK,OAAO,EAAE,MAAM,6BAA6B,CAAA;AACxL,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"match-target.d.ts","sourceRoot":"","sources":["../../src/jobs/permissions/match-target.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,KAAK,QAAQ,EAAE,KAAK,QAAQ,EAAE,KAAK,OAAO,EAAE,MAAM,6BAA6B,CAAA;AACxL,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAA;AAIxD,MAAM,MAAM,WAAW,GAAG;IAAE,OAAO,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAA;AACzI,MAAM,MAAM,SAAS,GAAG,MAAM,WAAW,CAAA;AACzC,MAAM,MAAM,QAAQ,CAAC,GAAG,SAAS,SAAS,IAAI,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,CAAA;AAC9H,MAAM,MAAM,MAAM,GAAG;KAAG,GAAG,IAAI,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC;CAAE,CAAC,SAAS,CAAC,CAAA;AACrE,MAAM,MAAM,WAAW,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAI5F,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAQ/E;AAED,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA6BlI;AA4BD,wBAAsB,aAAa,CAAC,KAAK,EAAE,WAAW,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,EAAE,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAI/H"}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { matchesGlob } from '../../utils/matches-glob.js';
|
|
2
2
|
import { parseShell } from '../../utils/parse-shell.js';
|
|
3
3
|
import { callBaseOf } from '../../utils/call-effects.js';
|
|
4
|
-
import {
|
|
4
|
+
import { realPathOf, relativePath } from '../../utils/paths.js';
|
|
5
|
+
import { basename } from '../../vendor.js';
|
|
5
6
|
const targetKeys = ['command', 'read', 'write', 'fetch', 'subagent', 'tool'];
|
|
6
7
|
export function targetOf(target) {
|
|
7
8
|
const keys = targetKeys.filter((key) => key in target);
|
|
@@ -51,10 +52,10 @@ function commandPattern(pattern) {
|
|
|
51
52
|
const command = parseShell(pattern).commands[0];
|
|
52
53
|
if (command === undefined)
|
|
53
54
|
throw new Error(`The command pattern '${pattern}' names no program.`);
|
|
54
|
-
return command;
|
|
55
|
+
return command.argv;
|
|
55
56
|
}
|
|
56
57
|
function commandMatches(pattern, argv) {
|
|
57
|
-
if (
|
|
58
|
+
if (basename(argv[0]) !== basename(pattern[0]))
|
|
58
59
|
return false;
|
|
59
60
|
const wanted = splitArguments(pattern.slice(1));
|
|
60
61
|
const actual = splitArguments(argv.slice(1));
|
package/mod.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AskOptions, ClassicHookInputs, CmodDependencies, FsEntry, HookStream, HttpInit, HttpResponse, PermissionRequestDecision, ProcessRunInit, ProcessRunResult, ProcessSpawnChunk, ProcessSpawnRequest, ProcessSpawnResult, RenderElement, Settings, SettingsReadArgs } from 'claude-code';
|
|
1
|
+
import type { AgentSpawnArgs, AgentSpawnResult, AskOptions, ClassicHookInputs, CmodDependencies, FsEntry, FsStat, FsStatOptions, HookStream, HttpInit, HttpResponse, PaneOpenArgs, PermissionRequestDecision, ProcessRunInit, ProcessRunResult, ProcessSpawnChunk, ProcessSpawnRequest, ProcessSpawnResult, RenderElement, Settings, SettingsReadArgs, UiScrollArgs, UiScrollResult } from 'claude-code';
|
|
2
2
|
import type { Claude } from './runtime/claude.js';
|
|
3
3
|
import type { RoutedEvent } from './runtime/hooks.js';
|
|
4
4
|
import type { RoutedHook } from './runtime/router.js';
|
|
@@ -11,16 +11,16 @@ export type ProgressStep = {
|
|
|
11
11
|
readonly label?: string;
|
|
12
12
|
};
|
|
13
13
|
export type PaneHandle = {
|
|
14
|
-
open(): Promise<void>;
|
|
14
|
+
open(options?: Readonly<Pick<PaneOpenArgs, 'focus'>>): Promise<void>;
|
|
15
15
|
close(): Promise<void>;
|
|
16
|
-
toggle(): Promise<void>;
|
|
16
|
+
toggle(options?: Readonly<Pick<PaneOpenArgs, 'focus'>>): Promise<void>;
|
|
17
17
|
readonly isOpen: boolean;
|
|
18
18
|
};
|
|
19
19
|
export type { Claude, RoutedEvent, RoutedHook, ToolCalls };
|
|
20
20
|
export { longestMs } from './runtime/deadline.js';
|
|
21
21
|
export { notInstalled } from './runtime/dependencies.js';
|
|
22
22
|
export { messageOf } from './utils/text.js';
|
|
23
|
-
export type ModEvent = 'SessionStart' | 'SessionEnd' | 'UserPromptSubmit' | 'InstructionsLoaded' | 'PreToolUse' | 'PermissionRequest' | 'PermissionDenied' | 'PostToolUse' | 'PostToolUseFailure' | 'PostToolBatch' | 'SubagentStart' | 'SubagentStop' | 'Notification' | 'PreCompact' | 'Stop' | 'StopFailure' | 'CwdChanged';
|
|
23
|
+
export type ModEvent = 'SessionStart' | 'SessionEnd' | 'UserPromptSubmit' | 'InstructionsLoaded' | 'PreToolUse' | 'PermissionRequest' | 'PermissionDenied' | 'PostToolUse' | 'PostToolUseFailure' | 'PostToolBatch' | 'SubagentStart' | 'SubagentStop' | 'Notification' | 'PreCompact' | 'Stop' | 'StopFailure' | 'CwdChanged' | 'FileChanged';
|
|
24
24
|
export type HookAnswer = {
|
|
25
25
|
continue?: boolean;
|
|
26
26
|
stopReason?: string;
|
|
@@ -69,7 +69,8 @@ type HookInputs = Omit<ClassicHookInputs, 'PreToolUse' | 'PostToolUse' | 'PostTo
|
|
|
69
69
|
};
|
|
70
70
|
CwdChanged: Omit<ClassicHookInputs['CwdChanged'], 'transcript_path'>;
|
|
71
71
|
};
|
|
72
|
-
export type
|
|
72
|
+
export type HookInput<E extends ModEvent> = HookInputs[E];
|
|
73
|
+
export type ModHook<E extends ModEvent> = (input: HookInput<E>) => HookAnswer | void | Promise<HookAnswer | void>;
|
|
73
74
|
export type Mod<State extends object = Record<never, never>> = {
|
|
74
75
|
readonly name: string;
|
|
75
76
|
readonly state: Readonly<State>;
|
|
@@ -82,6 +83,7 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
82
83
|
toast(text: string): void;
|
|
83
84
|
progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>;
|
|
84
85
|
ask(question: string, options?: readonly string[] | AskOptions): Promise<string>;
|
|
86
|
+
scroll(args: UiScrollArgs): Promise<UiScrollResult>;
|
|
85
87
|
};
|
|
86
88
|
readonly process: {
|
|
87
89
|
run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>;
|
|
@@ -91,6 +93,8 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
91
93
|
read(path: string): Promise<string>;
|
|
92
94
|
write(path: string, text: string): Promise<void>;
|
|
93
95
|
list(path?: string): Promise<FsEntry[]>;
|
|
96
|
+
exists(path: string): Promise<boolean>;
|
|
97
|
+
stat(path: string, options?: FsStatOptions): Promise<FsStat>;
|
|
94
98
|
};
|
|
95
99
|
readonly http: {
|
|
96
100
|
fetch(url: string, init?: HttpInit): Promise<HttpResponse>;
|
|
@@ -98,6 +102,12 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
98
102
|
readonly settings: {
|
|
99
103
|
read(args?: SettingsReadArgs): Promise<Settings>;
|
|
100
104
|
};
|
|
105
|
+
readonly session: {
|
|
106
|
+
messages: Claude['session']['messages'];
|
|
107
|
+
};
|
|
108
|
+
readonly agent: {
|
|
109
|
+
spawn(args: AgentSpawnArgs): Promise<AgentSpawnResult>;
|
|
110
|
+
};
|
|
101
111
|
readonly projectRoot: string;
|
|
102
112
|
readonly cwd: string;
|
|
103
113
|
readonly dependencies: CmodDependencies;
|