@cmodjs/core 0.3.0 → 1.5.0
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 +61 -10
- package/docs/dependencies.md +16 -14
- package/docs/hooks.md +64 -29
- package/docs/index.md +29 -25
- package/docs/install-steps.md +13 -9
- package/docs/jobs.md +59 -111
- package/docs/mod.md +227 -175
- package/docs/options.md +2 -2
- package/docs/permissions.md +27 -16
- package/docs/session.md +311 -0
- package/docs/state.md +11 -7
- package/docs/testing.md +68 -23
- package/docs/ui.md +116 -76
- package/jobs/check.d.ts.map +1 -1
- package/jobs/check.js +5 -2
- package/jobs/context.d.ts +7 -8
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +23 -73
- package/jobs/permissions/decide-permission.d.ts +5 -3
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +6 -4
- 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 +8 -11
- package/jobs/permissions/match-target.d.ts +2 -2
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions.d.ts +1 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +6 -4
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +14 -4
- package/jobs/prompt.d.ts +4 -2
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +16 -15
- package/jobs/schedule.d.ts +2 -2
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +9 -9
- package/jobs/slash-command.d.ts +4 -4
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +14 -9
- package/jobs/status-line.d.ts +1 -11
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +8 -12
- package/jobs/tool.d.ts +2 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +15 -9
- package/mod.d.ts +178 -75
- package/mod.d.ts.map +1 -1
- package/mod.js +127 -3
- package/options.d.ts +2 -2
- package/options.js +19 -19
- package/package.json +3 -3
- package/records.d.ts +13 -8
- package/records.d.ts.map +1 -1
- package/records.js +33 -17
- package/register.d.ts.map +1 -1
- package/register.js +34 -5
- package/runtime/announce.d.ts +18 -0
- package/runtime/announce.d.ts.map +1 -0
- package/runtime/announce.js +68 -0
- package/runtime/before.d.ts +32 -0
- package/runtime/before.d.ts.map +1 -0
- package/runtime/before.js +214 -0
- package/runtime/bound.d.ts +4 -0
- package/runtime/bound.d.ts.map +1 -0
- package/runtime/bound.js +9 -0
- package/runtime/claude.d.ts +4 -2
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +17 -0
- package/runtime/conversations.d.ts.map +1 -0
- package/runtime/conversations.js +563 -0
- package/runtime/deadline.d.ts +0 -1
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +1 -1
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +10 -1
- package/runtime/engine.d.ts +13 -0
- package/runtime/engine.d.ts.map +1 -0
- package/runtime/engine.js +10 -0
- package/runtime/grants.d.ts +4 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +68 -31
- package/runtime/hooks.d.ts +5 -4
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +29 -9
- package/runtime/installer.d.ts +1 -1
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +17 -14
- package/runtime/lifecycle.d.ts +3 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +414 -212
- package/runtime/metadata.d.ts +3 -1
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +25 -14
- package/runtime/options.d.ts +3 -1
- package/runtime/options.d.ts.map +1 -1
- package/runtime/options.js +4 -4
- package/runtime/programs.d.ts +8 -0
- package/runtime/programs.d.ts.map +1 -1
- package/runtime/programs.js +39 -1
- package/runtime/router.d.ts +2 -1
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +11 -3
- package/runtime/sessions.d.ts +58 -0
- package/runtime/sessions.d.ts.map +1 -0
- package/runtime/sessions.js +433 -0
- package/runtime/state.d.ts +6 -2
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +46 -13
- package/runtime/toaster.d.ts +24 -0
- package/runtime/toaster.d.ts.map +1 -0
- package/runtime/toaster.js +286 -0
- package/runtime/ui.d.ts +6 -17
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +62 -118
- package/session/context/context.d.ts +12 -0
- package/session/context/context.d.ts.map +1 -0
- package/session/context/context.js +2 -0
- package/session/context/turn/message/document.d.ts +7 -0
- package/session/context/turn/message/document.d.ts.map +1 -0
- package/session/context/turn/message/document.js +1 -0
- package/session/context/turn/message/message.d.ts +55 -0
- package/session/context/turn/message/message.d.ts.map +1 -0
- package/session/context/turn/message/message.js +178 -0
- package/session/context/turn/message/note.d.ts +8 -0
- package/session/context/turn/message/note.d.ts.map +1 -0
- package/session/context/turn/message/note.js +1 -0
- package/session/context/turn/message/notice.d.ts +6 -0
- package/session/context/turn/message/notice.d.ts.map +1 -0
- package/session/context/turn/message/notice.js +1 -0
- package/session/context/turn/message/prompt.d.ts +8 -0
- package/session/context/turn/message/prompt.d.ts.map +1 -0
- package/session/context/turn/message/prompt.js +1 -0
- package/session/context/turn/message/reply.d.ts +6 -0
- package/session/context/turn/message/reply.d.ts.map +1 -0
- package/session/context/turn/message/reply.js +1 -0
- package/session/context/turn/message/sender.d.ts +14 -0
- package/session/context/turn/message/sender.d.ts.map +1 -0
- package/session/context/turn/message/sender.js +30 -0
- package/session/context/turn/message/skill.d.ts +7 -0
- package/session/context/turn/message/skill.d.ts.map +1 -0
- package/session/context/turn/message/skill.js +1 -0
- package/session/context/turn/message/tool-call.d.ts +9 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -0
- package/session/context/turn/message/tool-call.js +1 -0
- package/session/context/turn/message/tool-result.d.ts +8 -0
- package/session/context/turn/message/tool-result.d.ts.map +1 -0
- package/session/context/turn/message/tool-result.js +1 -0
- package/session/context/turn/turn.d.ts +13 -0
- package/session/context/turn/turn.d.ts.map +1 -0
- package/session/context/turn/turn.js +2 -0
- package/session/conversation.d.ts +73 -0
- package/session/conversation.d.ts.map +1 -0
- package/session/conversation.js +146 -0
- package/session/goal/goal.d.ts +11 -0
- package/session/goal/goal.d.ts.map +1 -0
- package/session/goal/goal.js +1 -0
- package/session/goal/task.d.ts +16 -0
- package/session/goal/task.d.ts.map +1 -0
- package/session/goal/task.js +3 -0
- package/session/session.d.ts +158 -0
- package/session/session.d.ts.map +1 -0
- package/session/session.js +2 -0
- package/src/jobs/check.ts +7 -4
- package/src/jobs/context.ts +24 -82
- package/src/jobs/permissions/decide-permission.ts +10 -8
- package/src/jobs/permissions/find-project-scope.ts +8 -14
- package/src/jobs/permissions/match-target.ts +3 -3
- package/src/jobs/permissions.ts +8 -6
- package/src/jobs/program.ts +13 -4
- package/src/jobs/prompt.ts +21 -24
- package/src/jobs/schedule.ts +14 -14
- package/src/jobs/slash-command.ts +20 -14
- package/src/jobs/status-line.ts +9 -21
- package/src/jobs/tool.ts +17 -11
- package/src/mod.ts +252 -97
- package/src/options.ts +22 -22
- package/src/records.ts +40 -23
- package/src/register.ts +34 -5
- package/src/runtime/announce.ts +76 -0
- package/src/runtime/before.ts +200 -0
- package/src/runtime/bound.ts +12 -0
- package/src/runtime/claude.ts +7 -2
- package/src/runtime/conversations.ts +579 -0
- package/src/runtime/deadline.ts +2 -2
- package/src/runtime/dependencies.ts +13 -1
- package/src/runtime/engine.ts +23 -0
- package/src/runtime/grants.ts +69 -29
- package/src/runtime/hooks.ts +39 -14
- package/src/runtime/installer.ts +16 -14
- package/src/runtime/lifecycle.ts +413 -201
- package/src/runtime/metadata.ts +37 -14
- package/src/runtime/options.ts +19 -4
- package/src/runtime/programs.ts +40 -1
- package/src/runtime/router.ts +11 -4
- package/src/runtime/sessions.ts +447 -0
- package/src/runtime/state.ts +50 -14
- package/src/runtime/toaster.ts +334 -0
- package/src/runtime/ui.ts +65 -143
- package/src/session/context/context.ts +14 -0
- package/src/session/context/turn/message/document.ts +8 -0
- package/src/session/context/turn/message/message.ts +203 -0
- package/src/session/context/turn/message/note.ts +9 -0
- package/src/session/context/turn/message/notice.ts +7 -0
- package/src/session/context/turn/message/prompt.ts +9 -0
- package/src/session/context/turn/message/reply.ts +7 -0
- package/src/session/context/turn/message/sender.ts +38 -0
- package/src/session/context/turn/message/skill.ts +8 -0
- package/src/session/context/turn/message/tool-call.ts +10 -0
- package/src/session/context/turn/message/tool-result.ts +9 -0
- package/src/session/context/turn/turn.ts +15 -0
- package/src/session/conversation.ts +194 -0
- package/src/session/goal/goal.ts +10 -0
- package/src/session/goal/task.ts +19 -0
- package/src/session/session.ts +141 -0
- package/src/testing/fake-claude.ts +25 -17
- package/src/testing/fake-elements.ts +3 -2
- package/src/testing/fake-files.ts +1 -1
- package/src/testing/fake-sessions.ts +277 -0
- package/src/testing.ts +138 -35
- package/src/ui/components.ts +3 -3
- package/src/ui/elements.ts +22 -8
- package/src/ui/markdown.ts +22 -26
- package/src/ui/places.ts +49 -0
- package/src/utils/call-effects.ts +5 -5
- package/src/utils/changelog.ts +32 -0
- package/src/utils/disposable.ts +3 -0
- package/src/utils/matcher.ts +37 -0
- package/src/utils/metadata.ts +17 -15
- package/src/utils/parse-shell.ts +9 -8
- package/src/utils/paths.ts +21 -4
- package/src/utils/text.ts +4 -0
- package/testing/fake-claude.d.ts +5 -1
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +21 -12
- package/testing/fake-elements.js +4 -2
- package/testing/fake-files.js +1 -1
- package/testing/fake-sessions.d.ts +81 -0
- package/testing/fake-sessions.d.ts.map +1 -0
- package/testing/fake-sessions.js +249 -0
- package/testing.d.ts +34 -13
- package/testing.d.ts.map +1 -1
- package/testing.js +115 -23
- package/ui/components.d.ts +2 -3
- package/ui/components.d.ts.map +1 -1
- package/ui/components.js +3 -3
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +19 -9
- package/ui/markdown.d.ts +35 -33
- package/ui/markdown.d.ts.map +1 -1
- package/ui/markdown.js +14 -16
- package/ui/places.d.ts +43 -0
- package/ui/places.d.ts.map +1 -0
- package/ui/places.js +31 -0
- package/utils/call-effects.d.ts +2 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +3 -3
- package/utils/changelog.d.ts +9 -0
- package/utils/changelog.d.ts.map +1 -0
- package/utils/changelog.js +29 -0
- package/utils/disposable.d.ts +4 -0
- package/utils/disposable.d.ts.map +1 -0
- package/utils/disposable.js +1 -0
- package/utils/matcher.d.ts +13 -0
- package/utils/matcher.d.ts.map +1 -0
- package/utils/matcher.js +32 -0
- package/utils/metadata.d.ts +3 -3
- package/utils/metadata.js +18 -14
- package/utils/parse-shell.js +9 -7
- package/utils/paths.d.ts +4 -1
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +19 -4
- package/utils/text.d.ts +1 -0
- package/utils/text.d.ts.map +1 -1
- package/utils/text.js +3 -0
- package/src/ui/slots.ts +0 -36
- package/ui/slots.d.ts +0 -208
- package/ui/slots.d.ts.map +0 -1
- package/ui/slots.js +0 -19
package/docs/mod.md
CHANGED
|
@@ -15,7 +15,7 @@ type ModDefinition<State, Name, Options> = {
|
|
|
15
15
|
readonly options?: Options
|
|
16
16
|
readonly api?: { … }
|
|
17
17
|
readonly installer?: readonly Step<State>[]
|
|
18
|
-
setup(mod: Mod<State, Options>):
|
|
18
|
+
setup(mod: Mod<State, Options>): unknown
|
|
19
19
|
}
|
|
20
20
|
```
|
|
21
21
|
|
|
@@ -24,17 +24,17 @@ type ModDefinition<State, Name, Options> = {
|
|
|
24
24
|
- `options` declares what a person sets, such as a token, and types `mod.options`. [options.md](options.md) explains them.
|
|
25
25
|
- `api` holds the methods other mods call. [dependencies.md](dependencies.md) explains it.
|
|
26
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.
|
|
27
|
-
- `setup` runs once when the mod starts. It adds hooks, panes,
|
|
27
|
+
- `setup` runs once when the mod starts. It adds hooks, panes, renders, and jobs. It may be `async`.
|
|
28
28
|
|
|
29
29
|
```tsx
|
|
30
|
-
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
30
|
+
import { defineMod, UserPromptSubmit } from '../node_modules/@cmodjs/core/mod.js'
|
|
31
31
|
|
|
32
32
|
export const greeter = defineMod({
|
|
33
33
|
name: 'greeter',
|
|
34
34
|
state: { session: { isGreeted: false } },
|
|
35
35
|
|
|
36
36
|
setup(mod) {
|
|
37
|
-
mod.on(
|
|
37
|
+
mod.on(UserPromptSubmit, () => {
|
|
38
38
|
if (mod.state.session.isGreeted) return
|
|
39
39
|
mod.state.session.isGreeted = true
|
|
40
40
|
mod.ui.toast(`Hello from ${mod.name}`)
|
|
@@ -47,18 +47,61 @@ export const greeter = defineMod({
|
|
|
47
47
|
|
|
48
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.
|
|
49
49
|
|
|
50
|
-
The first time a
|
|
50
|
+
The first time a mod starts on a machine, cmod shows the toast `Installed` under its name. Opened, it lists the mod's `plugin.json` `description`, what setup added, such as `Adds the my-mod pane, a render of AbovePrompt and a hook on UserPromptSubmit.`, what its install step set up, and each permission the person allowed, with a Settings button for `/mods <name>`. The first time a new version starts, the toast is `Updated to <version>`: the first three changes of that version's `CHANGELOG.md` section, the version the machine ran before, and a Changelog button ([Release notes](#release-notes)). A mod with no changelog lists what it adds now and no longer adds instead. A new session, `/reload-plugins`, and a version the machine already ran show no toast.
|
|
51
51
|
|
|
52
|
-
When a step fails, the mod
|
|
52
|
+
When a step fails, or the person declines the install, the mod stays stopped. Once another session sets it up, the mod starts at the next prompt of every session it stopped in, with no `/reload-plugins`.
|
|
53
|
+
|
|
54
|
+
When a step fails, the mod does not start. An error toast above the prompt names the failure, with the fix under it:
|
|
53
55
|
|
|
54
56
|
- `<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)).
|
|
55
57
|
- `its state did not load: <error>` when reading the saved values fails.
|
|
56
58
|
- `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.
|
|
57
59
|
- `its setup function threw: <error>` when `setup` throws or rejects.
|
|
58
60
|
- `its open panes did not load: <error>` when Claude Code cannot list the open panes.
|
|
61
|
+
- `the details of its session did not load: <error>` when Claude Code cannot name the session's id, model, or start time.
|
|
59
62
|
- `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
63
|
|
|
61
|
-
Each of these
|
|
64
|
+
Each of these has `Retry`, which starts the mod again, and `Report` and `Hide` ([ui.md](ui.md#toasts)).
|
|
65
|
+
|
|
66
|
+
## Rules
|
|
67
|
+
|
|
68
|
+
A mod's `rules/` folder holds rules in Claude Code's own rule format: one Markdown file per rule, with an optional `paths` list in its front matter. A rule without `paths` applies to every conversation, and a rule with `paths` applies when Claude reads a matching file.
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
my-mod/
|
|
72
|
+
└── rules/
|
|
73
|
+
├── tests.md <- applies to every conversation
|
|
74
|
+
└── sql.md <- front matter paths: ["**/*.sql"]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
While the mod is on and has the `prompt` permission, cmod links the folder into Claude Code's rules folder: a mod the person installed links at `<config>/rules/<mod>`, and a mod turned on for one project at `<project>/.claude/rules/<mod>`. A mod turned off for one project keeps its rules out of that project through `claudeMdExcludes` in the project's `.claude/settings.local.json`. A rule the person excluded with `claudeMdExcludes` stays out of the notes too. Turning the mod off, turning `prompt` off, and removing the mod remove the link, and an update links the new version's folder. cmod changes only the links and exclusions it made.
|
|
78
|
+
|
|
79
|
+
Claude Code reads rules without `paths` only as a session starts. So when cmod links a mod's rules into a running session, it adds them to the conversation as one note, and when it removes the link, one note says the mod's rules no longer apply. A rule taken out of the folder is withdrawn by a note too. When an update changes a rule without `paths`, one note gives its new text. Claude Code loads rules with `paths` itself, at the read that matches.
|
|
80
|
+
|
|
81
|
+
The `Installed` toast lists the rules, as in `Adds 2 rules: sql and tests.` `cmod check` fails a mod with a `rules/` folder whose `package.json` `"cmod"` does not ask for `"prompt": true`.
|
|
82
|
+
|
|
83
|
+
## Release notes
|
|
84
|
+
|
|
85
|
+
A mod's `CHANGELOG.md`, at its root, holds what changed in each version, written for the people who use it. It follows the Keep a Changelog format: an `## [Unreleased]` section for what is not released yet, then one `## [<version>] - <date>` section per release, newest first, with its entries under `### Added`, `### Changed`, `### Removed`, and `### Fixed`. A breaking change goes under Changed or Removed, and raises the major version.
|
|
86
|
+
|
|
87
|
+
```markdown
|
|
88
|
+
# Changelog
|
|
89
|
+
|
|
90
|
+
## [Unreleased]
|
|
91
|
+
|
|
92
|
+
## [1.4.0] - 2026-10-13
|
|
93
|
+
|
|
94
|
+
### Added
|
|
95
|
+
- /rollback undoes the last deploy, including its database migration.
|
|
96
|
+
|
|
97
|
+
### Removed
|
|
98
|
+
- /deploy-old. Use /deploy --legacy instead.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- `cmod publish` releases the version's section as the GitHub release's notes, and refuses a version with no section ([commands.md](commands.md#cmod-publish)). Every release ships the file.
|
|
102
|
+
- The Updated toast shows the first three changes of the new version's section, and its Changelog button opens `/mods <name> changelog`, the whole file with the installed version at the top of the view.
|
|
103
|
+
- A linked checkout whose version has no section yet shows its `[Unreleased]` section, so the notes show as soon as the version moves.
|
|
104
|
+
- `cmod new` writes the file with an `[Unreleased]` and a `[0.1.0]` section.
|
|
62
105
|
|
|
63
106
|
## Register the mod with Claude Code
|
|
64
107
|
|
|
@@ -91,89 +134,181 @@ export function register(addHook: On, options: PluginOptions): void {
|
|
|
91
134
|
}
|
|
92
135
|
```
|
|
93
136
|
|
|
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
|
|
137
|
+
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 error toast names the line to add.
|
|
95
138
|
|
|
96
139
|
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.
|
|
97
140
|
|
|
98
|
-
##
|
|
141
|
+
## mod: this mod
|
|
99
142
|
|
|
100
|
-
`setup` gets `mod`, of type `Mod<State, Options>`. Every hook, render, and job gets the same `mod`.
|
|
143
|
+
`setup` gets `mod`, of type `Mod<State, Options>`. Every hook, render, and job gets the same `mod`. `mod` holds what belongs to this mod: its name, its folders, its values, its grants, and what it adds to Claude Code.
|
|
101
144
|
|
|
102
145
|
```ts
|
|
103
146
|
type Mod<State, Options> = {
|
|
104
147
|
readonly name: string
|
|
148
|
+
readonly folder: string
|
|
149
|
+
readonly dataFolder: string
|
|
105
150
|
readonly state: Readonly<State>
|
|
106
151
|
readonly options: OptionValues<Options>
|
|
107
|
-
readonly
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
152
|
+
readonly permissions: Permissions
|
|
153
|
+
on(hook, listener, options?): Disposable
|
|
154
|
+
on(hook, matcher, listener, options?): Disposable
|
|
155
|
+
every(ms, listener): Disposable
|
|
156
|
+
after(ms, listener): Disposable
|
|
111
157
|
use(job): Handle
|
|
112
|
-
|
|
113
|
-
readonly ui: { pane, render, toast,
|
|
114
|
-
readonly process: { run, spawn }
|
|
115
|
-
readonly fs: { read, write, list, exists, stat, find }
|
|
116
|
-
readonly metadata: { read, update }
|
|
117
|
-
readonly http: { fetch }
|
|
158
|
+
log(text): void
|
|
159
|
+
readonly ui: { pane, render, toast, ask, copy, scroll, notice }
|
|
118
160
|
readonly settings: { page, open }
|
|
119
|
-
readonly session: { messages, append, submit }
|
|
120
|
-
readonly agent: { spawn }
|
|
121
|
-
readonly model: { complete }
|
|
122
|
-
readonly permissions: { has }
|
|
123
161
|
readonly dependencies: CmodDependencies
|
|
124
|
-
readonly claude: ModClaude
|
|
125
162
|
}
|
|
126
163
|
```
|
|
127
164
|
|
|
128
165
|
| Member | What it is | Docs |
|
|
129
166
|
| --- | --- | --- |
|
|
130
167
|
| `name` | The mod's name from `defineMod`. | |
|
|
168
|
+
| `folder` | The plugin's own folder, where the files the mod ships live. | |
|
|
169
|
+
| `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) |
|
|
131
170
|
| `state` | The mod's values. Assign to a key to change it. | [state.md](state.md) |
|
|
132
171
|
| `options` | The values a person set for the mod's options, typed from their declaration. Read-only. | [options.md](options.md) |
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `on` | Adds a hook on a Claude Code event. | [hooks.md](hooks.md) |
|
|
172
|
+
| `permissions` | Says whether the person granted each permission. | [permissions.md](permissions.md) |
|
|
173
|
+
| `on` | Listens to a Claude Code hook, such as `PreToolUse`. | [hooks.md](hooks.md) |
|
|
174
|
+
| `every` and `after` | Run a listener on a timer while the session runs. | below |
|
|
137
175
|
| `use` | Adds a job, such as a slash command or a tool, and returns its handle. | [jobs.md](jobs.md) |
|
|
138
|
-
| `
|
|
139
|
-
| `ui` | Panes,
|
|
140
|
-
| `process` | Runs a program. | below |
|
|
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 |
|
|
143
|
-
| `http` | Fetches a URL. | below |
|
|
176
|
+
| `log` | Writes a line, under the mod's name, to Claude Code's debug log. | below |
|
|
177
|
+
| `ui` | Panes, renders at a place Claude Code draws, toasts, questions, the clipboard, scrolling, and a line under a permission dialog. | [ui.md](ui.md) |
|
|
144
178
|
| `settings` | Adds the mod's own pages to `/mods`, and opens them. | below |
|
|
145
|
-
| `session` | Reads the conversation or a subagent's, adds a note Claude reads, and asks Claude for a turn. | below |
|
|
146
|
-
| `agent` | Starts a subagent. | below |
|
|
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) |
|
|
149
179
|
| `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) |
|
|
151
180
|
|
|
152
|
-
|
|
181
|
+
Everything that is Claude Code's or the computer's rather than the mod's is an import from `mod.js`, so a function in any file calls it without being handed `mod`:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { fs, http, metadata, model, process, sessions, settings } from '../node_modules/@cmodjs/core/mod.js'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
| Import | What it is | Docs |
|
|
188
|
+
| --- | --- | --- |
|
|
189
|
+
| `sessions` | The conversations: the one the mod runs in, every other one, and search across them. | [session.md](session.md) |
|
|
190
|
+
| `settings` | Claude Code's `settings.json` files: the person's, the project's, and the project's local one. | below |
|
|
191
|
+
| `model` | Asks a model one question, outside the conversation. | below |
|
|
192
|
+
| `fs` | Reads, writes, lists, and finds files. | below |
|
|
193
|
+
| `metadata` | Reads and changes the mod's keys in one file's metadata. | below |
|
|
194
|
+
| `process` | Runs a program. | below |
|
|
195
|
+
| `http` | Fetches a URL. | below |
|
|
196
|
+
|
|
197
|
+
Each import works once the mod has started, in `setup` and anything it adds. Each one checks the permission it needs, and rejects with the line to add or the grant to turn on when the person has not granted it ([permissions.md](permissions.md)).
|
|
198
|
+
|
|
199
|
+
### Disposable
|
|
200
|
+
|
|
201
|
+
`mod.on`, `mod.every`, `mod.after`, `mod.ui.render`, and a session's `on` each return a `Disposable`. `dispose()` takes back what the call added, so a mod turns a feature off and on while it runs. Everything a mod adds is taken back when the mod stops, so a mod that keeps a feature for the whole session ignores the `Disposable`.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
type Disposable = { dispose(): void }
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const watching = mod.on(PostToolUse, { tool_name: 'Edit' }, () => void (mod.state.session.edits += 1))
|
|
209
|
+
mod.use(slashCommand({ name: 'stop-counting', description: 'Stop counting edits', reply: () => watching.dispose() }))
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### mod.every and mod.after
|
|
153
213
|
|
|
154
214
|
```ts
|
|
155
|
-
mod.every(ms: number,
|
|
215
|
+
mod.every(ms: number, listener: () => unknown): Disposable
|
|
216
|
+
mod.after(ms: number, listener: () => unknown): Disposable
|
|
156
217
|
```
|
|
157
218
|
|
|
158
|
-
`every` runs `
|
|
219
|
+
`every` runs `listener` every `ms` milliseconds until the session ends or the mod disposes it. A tick that comes while the last run is still going is skipped, so a slow run never stacks up. `after` runs `listener` once, `ms` milliseconds from now, unless the mod disposes it first. A run that throws shows the error toast `The every <ms> ms timer failed`, or `The after <ms> ms timer failed`, and the next tick of an `every` runs again. `ms` is a whole number above 0 and at most `2147483647`, and any other `ms` stops `setup` with `<mod>: mod.every is <ms>. Give a whole number of milliseconds above 0 and at most 2147483647.`
|
|
159
220
|
|
|
160
221
|
A watch that reacts on its own is `every` plus `mod.state` plus a `prompt` with no trigger: `every` checks the outside world, writes what it found to `mod.state`, and the panes and the prompt follow the state ([jobs.md](jobs.md#prompt)).
|
|
161
222
|
|
|
162
223
|
```ts
|
|
163
224
|
mod.every(60_000, async () => {
|
|
164
|
-
const { stdout } = await
|
|
225
|
+
const { stdout } = await process.run(['gh', 'run', 'list', '--limit', '1', '--json', 'conclusion'])
|
|
165
226
|
mod.state.session.ci = JSON.parse(stdout)[0]?.conclusion ?? 'running'
|
|
166
227
|
})
|
|
167
228
|
```
|
|
168
229
|
|
|
169
|
-
### mod.
|
|
230
|
+
### mod.log
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
mod.log(text: string): void
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`log` writes `<mod>: <text>` to Claude Code's debug log, which `claude --debug` shows. It is for what helps the mod's author find a fault and the person never needs to see. Tell the person with a toast instead ([ui.md](ui.md#toasts)).
|
|
237
|
+
|
|
238
|
+
### mod.settings
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
mod.settings.page(page: Pane<State>): void
|
|
242
|
+
mod.settings.open(pageId?: string): Promise<void>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`/mods` shows each mod's description, options, permissions, keys, and changelog, and turns a mod off or removes it. `page` adds a page of the mod's own to its entry there, drawn as any pane is ([ui.md](ui.md)). The id `changelog` is taken by the Changelog page, so `page` refuses it. `open` opens that page, or with no id opens the mod's entry in `/mods`. `open` rejects for an id `page` did not add.
|
|
246
|
+
|
|
247
|
+
```tsx
|
|
248
|
+
mod.settings.page(definePane({ id: 'runs', title: 'Runs', render: (current) => <Text>{current.state.session.lastRun}</Text> }))
|
|
249
|
+
mod.use(slashCommand({ name: 'ci-runs', description: 'Show the CI runs', reply: () => void mod.settings.open('runs') }))
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## settings: Claude Code's settings files
|
|
170
253
|
|
|
171
254
|
```ts
|
|
172
|
-
|
|
173
|
-
|
|
255
|
+
settings.list(): Promise<Setting[]>
|
|
256
|
+
settings.user.read(): Promise<Settings>
|
|
257
|
+
settings.user.update(values: Readonly<Record<string, unknown>>): Promise<void>
|
|
258
|
+
settings.project.read(): Promise<Settings>
|
|
259
|
+
settings.project.update(values: Readonly<Record<string, unknown>>): Promise<void>
|
|
260
|
+
settings.local.read(): Promise<Settings>
|
|
261
|
+
settings.local.update(values: Readonly<Record<string, unknown>>): Promise<void>
|
|
174
262
|
```
|
|
175
263
|
|
|
176
|
-
|
|
264
|
+
`settings.list` answers every row of Claude Code's `/config` menu, its own and each plugin's, with the value each holds now:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
type Setting = { key: string; label: string; description?: string; kind: 'boolean' | 'choice' | 'text' | 'number'; value: boolean | string | number | readonly string[]; options?: readonly string[]; plugin?: string; isLocked: boolean }
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`plugin` names the plugin a row belongs to, and Claude Code's own rows have none. `isLocked` is true when a managed setting or the organization's policy holds the value.
|
|
271
|
+
|
|
272
|
+
`settings.user` is the person's own `~/.claude/settings.json`, `settings.project` is the project's `.claude/settings.json`, and `settings.local` is the project's `.claude/settings.local.json`.
|
|
273
|
+
|
|
274
|
+
- `read` answers the values in that one file, such as `(await settings.user.read())['enabledPlugins']`.
|
|
275
|
+
- `update` writes each top-level key of `values` into that file. `mcpServers` in `settings.project` goes to the project's `.mcp.json`, where Claude Code reads a project's servers.
|
|
276
|
+
- A key replaces its whole value, and `null` removes the key. Every other key, and the file's formatting, stays as it was.
|
|
277
|
+
- Each key needs its own `config` permission, such as `"config": ["cleanupPeriodDays"]` ([permissions.md](permissions.md)), and `update` rejects with the line to add for a key without one.
|
|
278
|
+
- Removing the mod takes out each value it set that still equals what it set, so a value the person changed since stays.
|
|
279
|
+
- `fs.write` to a Claude Code settings file rejects and names `update`, because a whole-file write loses the person's own edits and leaves the mod's values behind when it is removed.
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
await settings.user.update({ cleanupPeriodDays: 365 })
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## model: one question outside the conversation
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
model.complete(request: ModelCompleteRequest, options?: { signal?: AbortSignal }): Promise<ModelCompleteResult>
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`complete` asks a model one question through Claude Code's own login, outside the conversation, so the answer costs no context and the person sees no turn. The request names the `model`, such as `'haiku'`, and the `prompt`, and takes `system`, `maxTokens`, `effort`, and `timeoutMs`. `timeoutMs` stops a model that is slow or down, so the listener that waits on it still answers in time. `options.signal` stops the call when its `AbortController` aborts. It needs the `model` permission.
|
|
292
|
+
|
|
293
|
+
The call never rejects for what the model did. The result's `isAnswered` is true with the reply's `text`, or false with a `reason`, such as `'api-error'` or `'aborted'`.
|
|
294
|
+
|
|
295
|
+
Put a limit on the listener too, with `mod.on`'s `timeoutMs` ([hooks.md](hooks.md#mod-on)), which also covers the work around the call:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
mod.on(Stop, async ({ last_assistant_message }) => {
|
|
299
|
+
const verdict = await model.complete({ model: 'haiku', prompt: `Does this reply ask the person to do work Claude could do itself? Answer yes or no.\n\n${last_assistant_message}`, timeoutMs: 8000 })
|
|
300
|
+
if (verdict.isAnswered && verdict.text.trim().toLowerCase().startsWith('yes')) return { decision: 'block', reason: 'Do that work yourself, then reply.' }
|
|
301
|
+
}, { timeoutMs: 9000 })
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## process: run a program
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
process.run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>
|
|
308
|
+
process.spawn(argv: readonly string[], init?: { cwd?, env?, input? }): HookStream<ProcessSpawnChunk, ProcessSpawnResult>
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Both run a program without a shell. `argv[0]` is the program and the rest are its arguments. A bare name that cmod installed, such as `trace`, runs from cmod's programs folder ([install-steps.md](install-steps.md#run-a-program-by-its-name)), and any other name runs through Claude Code's `PATH`. Each program needs its `run` permission.
|
|
177
312
|
|
|
178
313
|
- `init.cwd` is the program's folder, absolute or relative to the session's working folder. Without it the program runs in the session's working folder.
|
|
179
314
|
- `init.env` sets variables over Claude Code's own environment.
|
|
@@ -182,44 +317,49 @@ Both run a program without a shell. `argv[0]` is the program and the rest are it
|
|
|
182
317
|
- `run` resolves `{ exitCode, stdout, stderr, isStdoutTruncated, isStderrTruncated }` once the program exits, whatever its exit code. It keeps the first 4 MiB of each stream. A program ended by a signal has `exitCode` 1.
|
|
183
318
|
- `run` rejects when the program cannot start, and when it is still running at `timeoutMs`. It reads the whole output, so a background process the program leaves writing holds the call until the timeout.
|
|
184
319
|
- `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.
|
|
185
|
-
- 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
|
|
320
|
+
- 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 listener does not.
|
|
186
321
|
|
|
187
|
-
|
|
322
|
+
A job's deadline bounds the whole callback that makes these calls ([jobs.md](jobs.md#deadlines)).
|
|
188
323
|
|
|
189
324
|
```ts
|
|
190
|
-
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
325
|
+
import { defineMod, process, SessionStart, sessions } from '../node_modules/@cmodjs/core/mod.js'
|
|
191
326
|
|
|
192
327
|
export const branchNote = defineMod({
|
|
193
328
|
name: 'branch-note',
|
|
194
329
|
setup(mod) {
|
|
195
|
-
mod.on(
|
|
196
|
-
const { exitCode, stdout } = await
|
|
330
|
+
mod.on(SessionStart, async () => {
|
|
331
|
+
const { exitCode, stdout } = await process.run(['git', 'branch', '--show-current'], { cwd: sessions.current.project })
|
|
197
332
|
if (exitCode !== 0) return
|
|
198
|
-
return { hookSpecificOutput: { hookEventName:
|
|
333
|
+
return { hookSpecificOutput: { hookEventName: SessionStart, additionalContext: `The git branch is ${stdout.trim()}.` } }
|
|
199
334
|
})
|
|
200
335
|
},
|
|
201
336
|
})
|
|
202
337
|
```
|
|
203
338
|
|
|
204
|
-
|
|
339
|
+
## fs: files
|
|
205
340
|
|
|
206
341
|
```ts
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
342
|
+
fs.read(path: string): Promise<string>
|
|
343
|
+
fs.write(path: string, text: string): Promise<void>
|
|
344
|
+
fs.list(path?: string): Promise<Entry[]>
|
|
345
|
+
fs.exists(path: string): Promise<boolean>
|
|
346
|
+
fs.stat(path: string, options?: { resolve: boolean }): Promise<Stat>
|
|
347
|
+
fs.find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
|
|
213
348
|
```
|
|
214
349
|
|
|
215
|
-
|
|
350
|
+
```ts
|
|
351
|
+
type Entry = { name: string; isFile: boolean; isFolder: boolean; isLink: boolean; size: number; modifiedAt: number }
|
|
352
|
+
type Stat = { isFile: boolean; isFolder: boolean; isLink: boolean; size: number; modifiedAt: number; realPath?: string }
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
- A relative path is under the session's working folder. An absolute path is used as given. `~/.claude` and `$CLAUDE_CONFIG_DIR` are Claude Code's config folder, and `~` and `$HOME` are the home folder. Text is UTF-8.
|
|
216
356
|
- `read` rejects a missing file and a file over 4 MiB.
|
|
217
|
-
- `write` writes the whole file and creates its folders.
|
|
218
|
-
- `list` lists a folder, the working folder without a path.
|
|
357
|
+
- `write` writes the whole file and creates its folders. Writing outside the project and the mod's data folder needs the `files` permission.
|
|
358
|
+
- `list` lists a folder, the working folder without a path. A symbolic link is neither a file nor a folder, with `isLink` true. `modifiedAt` is the time it last changed, in milliseconds since 1970.
|
|
219
359
|
- `exists` answers whether the path leads to a file or a folder.
|
|
220
|
-
- `stat` answers
|
|
360
|
+
- `stat` answers 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.
|
|
221
361
|
|
|
222
|
-
A file the mod ships is under
|
|
362
|
+
A file the mod ships is under `mod.folder`. A file the mod keeps for itself belongs in `mod.dataFolder`.
|
|
223
363
|
|
|
224
364
|
`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
365
|
|
|
@@ -227,27 +367,28 @@ A file the mod ships is under the plugin's folder, `mod.claude.plugin.root` ([jo
|
|
|
227
367
|
type FileMatch = { readonly path: string; readonly name?: string; readonly metadata: Readonly<Record<string, string>>; readonly error?: string }
|
|
228
368
|
```
|
|
229
369
|
|
|
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
|
|
231
|
-
- `path` is absolute. `name` is a Markdown file's frontmatter `name`. `metadata` holds the mod's own keys, bare, as `
|
|
370
|
+
- 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. Several globs answer each file once.
|
|
371
|
+
- `path` is absolute. `name` is a Markdown file's frontmatter `name`. `metadata` holds the mod's own keys, bare, as `metadata.read` answers them.
|
|
232
372
|
- `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.
|
|
373
|
+
- A file whose metadata cmod cannot read comes back with empty `metadata` and an `error` naming the line, and cmod logs it once. A file cmod cannot open comes back the same way, with an `error` naming the reason.
|
|
374
|
+
- A folder cmod cannot list, such as one it has no permission to read, is left out, and cmod writes `<mod>: fs.find skipped <folder>: <reason>` to Claude Code's debug log once.
|
|
234
375
|
- cmod keeps what it read of each file until the file's modified time or size changes.
|
|
235
376
|
|
|
236
377
|
```ts
|
|
237
|
-
const skills = await
|
|
378
|
+
const skills = await fs.find(['~/.claude/skills/*/SKILL.md', '.claude/skills/*/SKILL.md'])
|
|
238
379
|
const planning = skills.filter((skill) => skill.metadata['modes']?.split(' ').includes('plan'))
|
|
239
380
|
```
|
|
240
381
|
|
|
241
|
-
|
|
382
|
+
## metadata: a file's own keys
|
|
242
383
|
|
|
243
384
|
```ts
|
|
244
|
-
|
|
245
|
-
|
|
385
|
+
metadata.read(path: string): Promise<Readonly<Record<string, string>>>
|
|
386
|
+
metadata.update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>
|
|
246
387
|
```
|
|
247
388
|
|
|
248
389
|
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
390
|
|
|
250
|
-
Where a file keeps its metadata depends on its
|
|
391
|
+
Where a file keeps its metadata depends on its type:
|
|
251
392
|
|
|
252
393
|
```markdown
|
|
253
394
|
---
|
|
@@ -268,116 +409,27 @@ metadata:
|
|
|
268
409
|
- 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
410
|
- Any other file keeps it in a sidecar, `<file>.meta`.
|
|
270
411
|
|
|
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 `
|
|
412
|
+
`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 `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.
|
|
413
|
+
|
|
414
|
+
`read` rejects when cmod cannot open the file or read its metadata, with the reason or the line. `update` rejects the same way and writes nothing, so it never overwrites metadata it could not read.
|
|
272
415
|
|
|
273
416
|
```ts
|
|
274
|
-
await
|
|
275
|
-
|
|
417
|
+
await metadata.update('.claude/skills/plan/SKILL.md', (keys) => {
|
|
418
|
+
keys['modes'] = 'plan build'
|
|
276
419
|
})
|
|
277
420
|
```
|
|
278
421
|
|
|
279
422
|
`cmod check` reads the metadata of every file the mod ships, and fails one cmod cannot read.
|
|
280
423
|
|
|
281
|
-
|
|
424
|
+
## http: fetch a URL
|
|
282
425
|
|
|
283
426
|
```ts
|
|
284
|
-
|
|
427
|
+
http.fetch(url: string, init?: HttpInit): Promise<HttpResponse>
|
|
285
428
|
```
|
|
286
429
|
|
|
287
430
|
- `init` takes `method` (`GET` by default), `headers`, `body` as text, and `socketPath`, the absolute path of a Unix socket the request goes over.
|
|
288
431
|
- The response is `{ status, ok, headers, text }`. `ok` is true for a 2xx status. Header names are lower case.
|
|
289
|
-
|
|
290
|
-
### mod.settings
|
|
291
|
-
|
|
292
|
-
```ts
|
|
293
|
-
mod.settings.page(page: Pane<State>): void
|
|
294
|
-
mod.settings.open(pageId?: string): Promise<void>
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
`/mods` shows each mod's description, options, permissions, and keys, and turns a mod off or removes it. `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.
|
|
298
|
-
|
|
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') }))
|
|
302
|
-
```
|
|
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
|
-
|
|
306
|
-
### mod.session
|
|
307
|
-
|
|
308
|
-
```ts
|
|
309
|
-
mod.session.messages(): Promise<SessionMessage[]>
|
|
310
|
-
mod.session.messages(args: { agentId: string }): Promise<SessionMessage[] | { deny: string }>
|
|
311
|
-
mod.session.append(text: string): Promise<void>
|
|
312
|
-
mod.session.submit(text: string): Promise<void>
|
|
313
|
-
```
|
|
314
|
-
|
|
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' })`.
|
|
316
|
-
|
|
317
|
-
```ts
|
|
318
|
-
mod.on('SubagentStop', async ({ agent_id }) => {
|
|
319
|
-
const read = await mod.session.messages({ agentId: agent_id })
|
|
320
|
-
if (Array.isArray(read)) mod.ui.toast(read.at(-1)?.text ?? '')
|
|
321
|
-
})
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
`append` adds `text` to the conversation as a user message the person does not see, so Claude reads it on its next call to the model. It starts no turn. It rejects with `<mod>: Claude Code refused the note: <reason>` when a plugin's `session.append` handler refuses it.
|
|
325
|
-
|
|
326
|
-
`submit` sends `text` as the person's next prompt, so Claude answers it as a turn of its own. Claude Code holds it until the session is idle, so it never cuts into a turn, and shows it under `The <mod> plugin sent a message:`. It rejects with `<mod>: Claude Code dropped the prompt: <reason>` when a plugin drops it. A prompt that comes on a clock, such as every 10 minutes, belongs in a `schedule` job instead, which Claude Code shows as one `Running scheduled task` row ([jobs.md](jobs.md#schedule)).
|
|
327
|
-
|
|
328
|
-
```ts
|
|
329
|
-
mod.every(60_000, async () => {
|
|
330
|
-
const { stdout } = await mod.process.run(['gh', 'run', 'list', '--limit', '1', '--json', 'conclusion'])
|
|
331
|
-
if (JSON.parse(stdout)[0]?.conclusion !== 'failure') return
|
|
332
|
-
await mod.session.append('CI failed on the last push.')
|
|
333
|
-
await mod.session.submit('Find the cause of the CI failure and fix it.')
|
|
334
|
-
})
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
### mod.agent
|
|
338
|
-
|
|
339
|
-
```ts
|
|
340
|
-
mod.agent.spawn(args: { prompt: string; description?; subagentType?; model?; name?; cwd? }): Promise<AgentSpawnResult>
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
`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.
|
|
344
|
-
|
|
345
|
-
The subagent's answer arrives on the mod's `SubagentStop` hook once it finishes: the input's `agent_id` is the `agentId` that `spawn` resolved, and its `last_assistant_message` is the subagent's final reply. A hook may wait for it, as this one does:
|
|
346
|
-
|
|
347
|
-
```ts
|
|
348
|
-
const answers = new Map<string, (answer: string) => void>()
|
|
349
|
-
|
|
350
|
-
mod.on('SubagentStop', (input) => {
|
|
351
|
-
answers.get(input.agent_id)?.(input.last_assistant_message ?? '')
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
mod.on('UserPromptSubmit', async (input) => {
|
|
355
|
-
if (!input.prompt.startsWith('/summarize')) return
|
|
356
|
-
const spawned = await mod.agent.spawn({ prompt: 'Summarize README.md in one line.', subagentType: 'Explore' })
|
|
357
|
-
if (spawned.agentId === undefined) return
|
|
358
|
-
const summary = await new Promise<string>((resolve) => answers.set(spawned.agentId as string, resolve))
|
|
359
|
-
return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: summary } }
|
|
360
|
-
})
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
### mod.model
|
|
364
|
-
|
|
365
|
-
```ts
|
|
366
|
-
mod.model.complete(request: ModelCompleteRequest, options?: { signal?: AbortSignal }): Promise<ModelCompleteResult>
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
`complete` asks a model one question through Claude Code's own login, outside the conversation, so the answer costs no context and the person sees no turn. The request names the `model`, such as `'haiku'`, and the `prompt`, and takes `system`, `maxTokens`, `effort`, and `timeoutMs`. `timeoutMs` stops a model that is slow or down, so the hook that waits on it still answers in time. `options.signal` stops the call when its `AbortController` aborts.
|
|
370
|
-
|
|
371
|
-
The call never rejects for what the model did. The result's `isAnswered` is true with the reply's `text`, or false with a `reason`, such as `'api-error'` or `'aborted'`.
|
|
372
|
-
|
|
373
|
-
Put a limit on the hook too, with `mod.on`'s `timeoutMs` ([hooks.md](hooks.md#mod-on)), which also covers the work around the call:
|
|
374
|
-
|
|
375
|
-
```ts
|
|
376
|
-
mod.on('Stop', async ({ last_assistant_message }) => {
|
|
377
|
-
const verdict = await mod.model.complete({ model: 'haiku', prompt: `Does this reply ask the person to do work Claude could do itself? Answer yes or no.\n\n${last_assistant_message}`, timeoutMs: 8000 })
|
|
378
|
-
if (verdict.isAnswered && verdict.text.trim().toLowerCase().startsWith('yes')) return { decision: 'block', reason: 'Do that work yourself, then reply.' }
|
|
379
|
-
}, { timeoutMs: 9000 })
|
|
380
|
-
```
|
|
432
|
+
- Each host needs its `network` permission. A fetch to the address the mod's own `program` job answers on needs none, while that program runs ([jobs.md](jobs.md#program)).
|
|
381
433
|
|
|
382
434
|
## messageOf
|
|
383
435
|
|
|
@@ -388,14 +440,14 @@ messageOf(error: unknown): string
|
|
|
388
440
|
Returns an `Error`'s `message`, or the value as text. Use it in a `catch`, where the error is `unknown`.
|
|
389
441
|
|
|
390
442
|
```ts
|
|
391
|
-
import { defineMod, messageOf } from '../node_modules/@cmodjs/core/mod.js'
|
|
443
|
+
import { defineMod, fs, messageOf, Stop } from '../node_modules/@cmodjs/core/mod.js'
|
|
392
444
|
|
|
393
445
|
export const notes = defineMod({
|
|
394
446
|
name: 'notes',
|
|
395
447
|
setup(mod) {
|
|
396
|
-
mod.on(
|
|
397
|
-
await
|
|
398
|
-
mod.ui.toast(
|
|
448
|
+
mod.on(Stop, async () => {
|
|
449
|
+
await fs.write(`${mod.dataFolder}/last-stop.txt`, new Date().toISOString()).catch((error: unknown) => {
|
|
450
|
+
mod.ui.toast.error('Could not save the last stop', { description: messageOf(error) })
|
|
399
451
|
})
|
|
400
452
|
})
|
|
401
453
|
},
|
package/docs/options.md
CHANGED
|
@@ -49,7 +49,7 @@ Each value is, in order of precedence:
|
|
|
49
49
|
3. The value the person set in `/config`.
|
|
50
50
|
4. The `default` in the declaration.
|
|
51
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` through `cmod option` 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
|
|
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` through `cmod option` 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 error toast says `it needs <title>` and `Set it in /config.`, and Claude Code reloads the mod once the value is set.
|
|
53
53
|
|
|
54
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
55
|
|
|
@@ -89,5 +89,5 @@ Claude Code reads a plugin's options from `userConfig` in `.claude-plugin/plugin
|
|
|
89
89
|
```ts
|
|
90
90
|
const tested = testMod(ciWatch, { options: { githubToken: 'ghp_test', branch: 'develop' } })
|
|
91
91
|
await tested.start()
|
|
92
|
-
expect(tested.shown.toasts).
|
|
92
|
+
expect(tested.shown.toasts).toContainEqual({ type: 'default', message: 'Watching develop every 10 minutes' })
|
|
93
93
|
```
|