@cmodjs/core 0.1.14 → 0.2.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 +31 -19
- package/docs/index.md +12 -4
- package/docs/install-steps.md +30 -4
- package/docs/jobs.md +25 -9
- package/docs/mod.md +103 -37
- package/docs/options.md +93 -0
- package/docs/permissions.md +88 -0
- package/docs/state.md +4 -31
- package/docs/testing.md +13 -2
- package/installer.d.ts +10 -0
- package/installer.d.ts.map +1 -0
- package/installer.js +8 -0
- package/jobs/context.d.ts +2 -2
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +5 -3
- package/jobs/permissions.js +1 -1
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +5 -1
- package/jobs/prompt.js +5 -5
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +2 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +3 -2
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +3 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +3 -2
- package/mod.d.ts +48 -10
- package/mod.d.ts.map +1 -1
- package/mod.js +3 -0
- package/options.d.ts +58 -0
- package/options.d.ts.map +1 -0
- package/options.js +66 -0
- package/package.json +1 -1
- package/records.d.ts +14 -1
- package/records.d.ts.map +1 -1
- package/records.js +106 -7
- package/register.d.ts +4 -2
- package/register.d.ts.map +1 -1
- package/register.js +8 -2
- package/runtime/claude.d.ts +5 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/grants.d.ts +28 -0
- package/runtime/grants.d.ts.map +1 -0
- package/runtime/grants.js +286 -0
- package/runtime/installer.d.ts +23 -0
- package/runtime/installer.d.ts.map +1 -0
- package/runtime/installer.js +113 -0
- package/runtime/lifecycle.d.ts +6 -3
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +220 -36
- package/runtime/metadata.d.ts +20 -0
- package/runtime/metadata.d.ts.map +1 -0
- package/runtime/metadata.js +143 -0
- package/runtime/options.d.ts +21 -0
- package/runtime/options.d.ts.map +1 -0
- package/runtime/options.js +100 -0
- package/runtime/state.d.ts +1 -0
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +5 -75
- package/src/installer.ts +17 -0
- package/src/jobs/context.ts +5 -3
- package/src/jobs/permissions.ts +1 -1
- package/src/jobs/program.ts +4 -1
- package/src/jobs/prompt.ts +5 -5
- package/src/jobs/schedule.ts +2 -1
- package/src/jobs/slash-command.ts +3 -2
- package/src/jobs/status-line.ts +3 -2
- package/src/jobs/tool.ts +3 -2
- package/src/mod.ts +46 -11
- package/src/options.ts +91 -0
- package/src/records.ts +117 -8
- package/src/register.ts +12 -3
- package/src/runtime/claude.ts +2 -0
- package/src/runtime/grants.ts +306 -0
- package/src/runtime/installer.ts +163 -0
- package/src/runtime/lifecycle.ts +220 -45
- package/src/runtime/metadata.ts +153 -0
- package/src/runtime/options.ts +102 -0
- package/src/runtime/state.ts +7 -70
- package/src/testing/fake-claude.ts +6 -1
- package/src/testing/fake-files.ts +9 -3
- package/src/testing.ts +40 -15
- package/src/utils/metadata.ts +242 -0
- package/testing/fake-claude.d.ts +4 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +5 -1
- package/testing/fake-files.d.ts.map +1 -1
- package/testing/fake-files.js +9 -3
- package/testing.d.ts +6 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +29 -13
- package/utils/metadata.d.ts +21 -0
- package/utils/metadata.d.ts.map +1 -0
- package/utils/metadata.js +250 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Args, Frozen, RenderElement, RenderPropsOf } from 'claude-code'
|
|
2
|
+
import type { Step } from '../installer.js'
|
|
3
|
+
import type { Mod } from '../mod.js'
|
|
4
|
+
import type { Option } from '../options.js'
|
|
5
|
+
import { permissionWords } from '../records.js'
|
|
6
|
+
import { Box, Button, drawWith, Input, Text } from '../ui/elements.js'
|
|
7
|
+
import { messageOf } from '../utils/text.js'
|
|
8
|
+
import type { Claude } from './claude.js'
|
|
9
|
+
|
|
10
|
+
export const installerPaneId = 'cmod-installer'
|
|
11
|
+
|
|
12
|
+
export type Consent = {
|
|
13
|
+
readonly install: string
|
|
14
|
+
readonly uninstall: string
|
|
15
|
+
readonly keys: string
|
|
16
|
+
readonly permissions: readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Page = {
|
|
20
|
+
draw(props: Frozen<RenderPropsOf['Pane']>): RenderElement
|
|
21
|
+
cancel(): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Installer = {
|
|
25
|
+
draws(e: Frozen<Args<'ui.render'>>): boolean
|
|
26
|
+
draw(e: Frozen<Args<'ui.render'>>): RenderElement
|
|
27
|
+
closed(id: string): void
|
|
28
|
+
consent(consent: Consent): Promise<boolean>
|
|
29
|
+
options(missing: readonly (readonly [string, Option])[], save: (key: string, value: string) => Promise<string | undefined>): Promise<boolean>
|
|
30
|
+
step<State extends object>(mod: Mod<State>, step: Step<State>, index: number, total: number): Promise<boolean>
|
|
31
|
+
close(): Promise<void>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createInstaller(name: string, claude: () => Claude): Installer {
|
|
35
|
+
let page: Page | undefined
|
|
36
|
+
let isOpen = false
|
|
37
|
+
|
|
38
|
+
const show = async <Answer>(build: (answer: (value: Answer) => void) => Omit<Page, 'cancel'>, cancelled: Answer): Promise<Answer> => {
|
|
39
|
+
page?.cancel()
|
|
40
|
+
const answer = new Promise<Answer>((resolve) => {
|
|
41
|
+
const done = (value: Answer) => {
|
|
42
|
+
if (page === shown) page = undefined
|
|
43
|
+
resolve(value)
|
|
44
|
+
}
|
|
45
|
+
const shown: Page = { ...build(done), cancel: () => done(cancelled) }
|
|
46
|
+
page = shown
|
|
47
|
+
})
|
|
48
|
+
if (!isOpen) {
|
|
49
|
+
isOpen = true
|
|
50
|
+
await claude().ui.open({ id: installerPaneId, title: `Install ${name}`, focus: true, holdToasts: true, closeOnEscape: true })
|
|
51
|
+
}
|
|
52
|
+
claude().ui.invalidate('ui.render')
|
|
53
|
+
return answer
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const frame = (title: string, body: readonly RenderElement[], actions: readonly RenderElement[]) =>
|
|
57
|
+
Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [Text({ bold: true, children: title }), ...body, Box({ gap: 3, children: actions })] })
|
|
58
|
+
|
|
59
|
+
const bullet = (text: string) => Text({ children: [Text({ color: 'claude', children: '◆ ' }), text] })
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
draws: (e) => e.component === 'Pane' && e.requestId === installerPaneId,
|
|
63
|
+
draw(e) {
|
|
64
|
+
return drawWith(claude().ui.resolve(e), () => page?.draw(e.props as Frozen<RenderPropsOf['Pane']>) ?? Text({ dimColor: true, children: `${name} is installing.` }))
|
|
65
|
+
},
|
|
66
|
+
closed(id) {
|
|
67
|
+
if (id !== installerPaneId) return
|
|
68
|
+
isOpen = false
|
|
69
|
+
page?.cancel()
|
|
70
|
+
},
|
|
71
|
+
consent({ install, uninstall, keys, permissions }) {
|
|
72
|
+
const changes = [...(install === '' ? [] : [`Run ${install} now${uninstall === '' ? '' : `, and ${uninstall} when you remove it`}`]), ...(install === '' && uninstall !== '' ? [`Run ${uninstall} when you remove it`] : []), ...(keys === '' ? [] : [`Bind ${keys}`])]
|
|
73
|
+
return show<boolean>(
|
|
74
|
+
(answer) => ({
|
|
75
|
+
draw: () =>
|
|
76
|
+
frame(
|
|
77
|
+
`${name} wants to:`,
|
|
78
|
+
[
|
|
79
|
+
...(permissions.length === 0 ? [] : [Box({ flexDirection: 'column', children: permissions.map((item) => bullet(permissionWords(item))) })]),
|
|
80
|
+
...(changes.length === 0 ? [] : [Text({ children: permissions.length === 0 ? 'Change your computer:' : 'and change your computer:' }), Box({ flexDirection: 'column', children: changes.map(bullet) })]),
|
|
81
|
+
Text({ dimColor: true, children: 'You can turn each permission off later in /mods.' }),
|
|
82
|
+
],
|
|
83
|
+
[Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'accept', hotkey: 'a', label: 'Accept', onPress: () => answer(true) })],
|
|
84
|
+
),
|
|
85
|
+
}),
|
|
86
|
+
false,
|
|
87
|
+
)
|
|
88
|
+
},
|
|
89
|
+
options(missing, save) {
|
|
90
|
+
const saved = new Map<string, string>()
|
|
91
|
+
const failures = new Map<string, string>()
|
|
92
|
+
return show<boolean>(
|
|
93
|
+
(answer) => ({
|
|
94
|
+
draw: () =>
|
|
95
|
+
frame(
|
|
96
|
+
`${name} needs ${missing.length === 1 ? 'one setting' : `${missing.length} settings`}:`,
|
|
97
|
+
missing.map(([key, option]) => {
|
|
98
|
+
const label = Text({ children: [Text({ bold: true, children: option.title }), option.description === '' ? '' : Text({ dimColor: true, children: ` ${option.description}` })] })
|
|
99
|
+
if (option.kind === 'secret') return Box({ flexDirection: 'column', children: [label, Text({ dimColor: true, children: `Set it in /config, where Claude Code keeps it in your keychain, then press Next.` })] })
|
|
100
|
+
const failure = failures.get(key)
|
|
101
|
+
const field = Input({
|
|
102
|
+
key: `option:${key}`,
|
|
103
|
+
label: '› ',
|
|
104
|
+
placeholder: option.kind === 'choice' ? (option.choices ?? []).join(', ') : option.kind === 'toggle' ? 'true or false' : option.kind === 'list' ? 'comma-separated' : '',
|
|
105
|
+
value: saved.get(key) ?? '',
|
|
106
|
+
submitLabel: 'save',
|
|
107
|
+
onSubmit: (value: string) =>
|
|
108
|
+
void save(key, value)
|
|
109
|
+
.then((refused) => {
|
|
110
|
+
if (refused === undefined) {
|
|
111
|
+
saved.set(key, value)
|
|
112
|
+
failures.delete(key)
|
|
113
|
+
} else failures.set(key, refused)
|
|
114
|
+
})
|
|
115
|
+
.catch((error: unknown) => failures.set(key, messageOf(error)))
|
|
116
|
+
.finally(() => claude().ui.invalidate('ui.render')),
|
|
117
|
+
})
|
|
118
|
+
const status = failure !== undefined ? Text({ color: 'error', children: failure }) : saved.has(key) ? Text({ color: 'success', children: '✔ saved' }) : Text({ children: '' })
|
|
119
|
+
return Box({ flexDirection: 'column', children: [label, field, status] })
|
|
120
|
+
}),
|
|
121
|
+
[Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'next', hotkey: 'x', label: 'Next', onPress: () => answer(true) })],
|
|
122
|
+
),
|
|
123
|
+
}),
|
|
124
|
+
false,
|
|
125
|
+
)
|
|
126
|
+
},
|
|
127
|
+
step(mod, step, index, total) {
|
|
128
|
+
let notice: string | undefined
|
|
129
|
+
return show<boolean>(
|
|
130
|
+
(answer) => ({
|
|
131
|
+
draw: (props) => {
|
|
132
|
+
let body: RenderElement
|
|
133
|
+
try {
|
|
134
|
+
body = step.render(mod, props)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
body = Text({ color: 'error', children: `The ${step.title} step could not draw: ${messageOf(error)}` })
|
|
137
|
+
}
|
|
138
|
+
const next = async () => {
|
|
139
|
+
const isDone = await Promise.resolve(step.isDone(mod)).catch(() => false)
|
|
140
|
+
if (isDone) answer(true)
|
|
141
|
+
else {
|
|
142
|
+
notice = `Finish ${step.title} first.`
|
|
143
|
+
claude().ui.invalidate('ui.render')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return frame(
|
|
147
|
+
`${step.title} (${index} of ${total})`,
|
|
148
|
+
[body, ...(notice === undefined ? [] : [Text({ color: 'warning', children: notice })])],
|
|
149
|
+
[Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'next', hotkey: 'x', label: index === total ? 'Finish' : 'Next', onPress: () => void next() })],
|
|
150
|
+
)
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
false,
|
|
154
|
+
)
|
|
155
|
+
},
|
|
156
|
+
async close() {
|
|
157
|
+
page = undefined
|
|
158
|
+
if (!isOpen) return
|
|
159
|
+
isOpen = false
|
|
160
|
+
await claude().ui.close({ id: installerPaneId })
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import type { Args, EventResult, Frozen, HookBudget, HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
|
|
2
|
-
import type { HookInput, Mod, ModDefinition, ModEvent, ModHook } from '../mod.js'
|
|
3
|
-
import {
|
|
1
|
+
import type { Args, EventResult, Frozen, HookBudget, HookStream, PluginOptions, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
|
|
2
|
+
import type { HookInput, Mod, ModDefinition, ModEvent, ModHook, PaneHandle } from '../mod.js'
|
|
3
|
+
import type { Options, OptionValues } from '../options.js'
|
|
4
|
+
import { consentPath, dataFolder, finishStepsMethod, isAtLeast, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, scriptsSha256, settingsPagesMethod, storeFolder, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
|
|
4
5
|
import { relativePath } from '../utils/paths.js'
|
|
5
6
|
import { formatExit, listed, messageOf } from '../utils/text.js'
|
|
6
7
|
import type { Claude } from './claude.js'
|
|
7
8
|
import { beforeDeadline } from './deadline.js'
|
|
8
9
|
import { answerCall, dependencyCalls, notInstalled } from './dependencies.js'
|
|
9
10
|
import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook, type HeldDecisions, type RoutedEvent } from './hooks.js'
|
|
11
|
+
import { checkedAnswer, checkingGrants, checksAnswers, checkWrite, isCovered, itemOf, passedDown, type AnswerCheck, type Grants } from './grants.js'
|
|
12
|
+
import { createModFiles } from './metadata.js'
|
|
13
|
+
import { fitsOption } from '../options.js'
|
|
14
|
+
import { createInstaller } from './installer.js'
|
|
15
|
+
import { createOptions, MissingOptions } from './options.js'
|
|
10
16
|
import { createRouter, type Router, type RouterNext } from './router.js'
|
|
11
17
|
import { createState } from './state.js'
|
|
12
18
|
import { toolCalls } from './tool-calls.js'
|
|
@@ -19,7 +25,8 @@ export type Plugin = {
|
|
|
19
25
|
readonly store: string
|
|
20
26
|
readonly isInstalled: boolean
|
|
21
27
|
readonly shouldRecord: boolean
|
|
22
|
-
readonly
|
|
28
|
+
readonly steps: Steps
|
|
29
|
+
readonly granted: readonly string[]
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export type Phase = 'starting' | 'installing' | 'waiting' | 'declined' | 'failed' | 'ready' | 'active'
|
|
@@ -37,12 +44,15 @@ type ModRuntime = {
|
|
|
37
44
|
readonly router: Router
|
|
38
45
|
readonly progress: Progress
|
|
39
46
|
readonly dataFolder: string
|
|
40
|
-
readonly
|
|
47
|
+
readonly steps: Steps
|
|
48
|
+
readonly granted: ReadonlySet<string>
|
|
49
|
+
readonly refreshGrants: () => Promise<void>
|
|
41
50
|
readonly checksPermissions: () => boolean
|
|
51
|
+
readonly options: PluginOptions
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
type ActiveMod<State extends object> = {
|
|
45
|
-
readonly mod: Mod<State>
|
|
54
|
+
type ActiveMod<State extends object, Declared extends Options> = {
|
|
55
|
+
readonly mod: Mod<State, Declared>
|
|
46
56
|
readonly added: readonly string[]
|
|
47
57
|
}
|
|
48
58
|
|
|
@@ -76,12 +86,14 @@ export async function readPlugin(claude: Claude): Promise<Plugin> {
|
|
|
76
86
|
const manifest = (await readJson(read, `${root}/.claude-plugin/plugin.json`)) as { version?: unknown } | undefined
|
|
77
87
|
const version = typeof manifest?.version === 'string' ? manifest.version : undefined
|
|
78
88
|
const store = storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() })
|
|
89
|
+
const steps = readSteps(await readJson(read, `${root}/package.json`)) ?? {}
|
|
90
|
+
const declared = steps.permissions ?? []
|
|
79
91
|
if (name === cmodPluginName) {
|
|
80
92
|
const installed = await cmodVersion(claude)
|
|
81
|
-
return { name, root, version, store,
|
|
93
|
+
return { name, root, version, store, steps, granted: declared, isInstalled: version !== undefined && installed !== undefined && isAtLeast(installed, version), shouldRecord: false }
|
|
82
94
|
}
|
|
83
|
-
const
|
|
84
|
-
const plugin = { name, root, version, store,
|
|
95
|
+
const approved = parseConsent(await readJson(read, consentPath(store)), consentPath(store))[name] ?? []
|
|
96
|
+
const plugin = { name, root, version, store, steps, granted: declared.filter((item) => approved.includes(item)) }
|
|
85
97
|
const record = await readRecord(read, store, name)
|
|
86
98
|
if (Object.keys(steps).length === 0) return { ...plugin, isInstalled: true, shouldRecord: record?.version !== version }
|
|
87
99
|
const scripts = await scriptsSha256(steps, {
|
|
@@ -123,15 +135,23 @@ async function cmodVersion(claude: Claude): Promise<string | undefined> {
|
|
|
123
135
|
return result.stdout.trim().split(/\s+/).at(-1)
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
export function createLifecycle<State extends object
|
|
138
|
+
export function createLifecycle<State extends object, Declared extends Options = Options>(
|
|
139
|
+
definition: ModDefinition<State, string, Declared>,
|
|
140
|
+
checksPermissions: () => boolean = () => true,
|
|
141
|
+
options: PluginOptions = {},
|
|
142
|
+
asksPerson = true,
|
|
143
|
+
): Lifecycle<State> {
|
|
127
144
|
let router: Router = createRouter()
|
|
128
145
|
let phase: Phase = 'starting'
|
|
129
146
|
let runtime: Pick<ModRuntime, 'claude' | 'progress'> | undefined
|
|
130
147
|
let plugin: Plugin | undefined
|
|
148
|
+
const granted = new Set<string>()
|
|
131
149
|
let line: ProgressLine | undefined
|
|
132
150
|
let activation: Promise<void> | undefined
|
|
133
151
|
let mod: Mod<State> | undefined
|
|
134
152
|
let failure: unknown
|
|
153
|
+
let pluginOptions = options
|
|
154
|
+
const installer = createInstaller(definition.name, () => claude())
|
|
135
155
|
let shouldRecord = false
|
|
136
156
|
let recording: Promise<void> | undefined
|
|
137
157
|
let missedSessionStart: Frozen<Args<'classic.SessionStart'>> | undefined
|
|
@@ -139,7 +159,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
139
159
|
const startSettled = new Promise<void>((resolve) => (settleStart = resolve))
|
|
140
160
|
|
|
141
161
|
const claude = () => {
|
|
142
|
-
if (runtime === undefined) throw new Error(`${definition.name}: the lifecycle has not started. registerMod(addHook, mod) starts it at session.start.`)
|
|
162
|
+
if (runtime === undefined) throw new Error(`${definition.name}: the lifecycle has not started. registerMod(addHook, mod, options) starts it at session.start.`)
|
|
143
163
|
return runtime.claude
|
|
144
164
|
}
|
|
145
165
|
|
|
@@ -180,19 +200,65 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
180
200
|
})
|
|
181
201
|
}
|
|
182
202
|
|
|
183
|
-
const
|
|
203
|
+
const saveOption = async (key: string, text: string): Promise<string | undefined> => {
|
|
204
|
+
const option = (definition.options as Options | undefined)?.[key]
|
|
205
|
+
if (option === undefined || plugin === undefined) return `${definition.name} has no option ${key}.`
|
|
206
|
+
const value = option.kind === 'number' ? Number(text) : option.kind === 'toggle' ? text.trim() === 'true' : option.kind === 'list' ? text.split(',').map((item) => item.trim()).filter((item) => item !== '') : text
|
|
207
|
+
const misfit = option.kind === 'toggle' && !['true', 'false'].includes(text.trim()) ? 'takes true or false' : fitsOption(option, value)
|
|
208
|
+
if (misfit !== undefined) return `${option.title} ${misfit}.`
|
|
209
|
+
const saved = await claude().config.set({ key: `${plugin.name}.${key}`, value })
|
|
210
|
+
return saved.deny
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const askOptions = async (missing: MissingOptions): Promise<boolean> => {
|
|
214
|
+
if (!asksPerson || plugin === undefined || (await claude().session.surfaces()).length === 0) return false
|
|
215
|
+
const declared: Options = definition.options ?? {}
|
|
216
|
+
const asked = missing.keys.flatMap((key) => {
|
|
217
|
+
const option = declared[key]
|
|
218
|
+
return option === undefined ? [] : [[key, option] as const]
|
|
219
|
+
})
|
|
220
|
+
if (!(await installer.options(asked, saveOption))) return false
|
|
221
|
+
const prefix = `${plugin.name}.`
|
|
222
|
+
const rows = await claude().config.list()
|
|
223
|
+
pluginOptions = { ...pluginOptions, ...Object.fromEntries(rows.filter((row) => row.key.startsWith(prefix)).map((row) => [row.key.slice(prefix.length), row.value])) }
|
|
224
|
+
return true
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const pendingSteps = async (active: Mod<State>) => {
|
|
228
|
+
const pending: string[] = []
|
|
229
|
+
for (const step of definition.installer ?? []) if (!(await Promise.resolve(step.isDone(active)).catch(() => false))) pending.push(step.title)
|
|
230
|
+
return pending
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const runSteps = async (active: Mod<State>) => {
|
|
234
|
+
const steps = asksPerson ? (definition.installer ?? []) : []
|
|
235
|
+
for (const [index, step] of steps.entries()) {
|
|
236
|
+
if (await Promise.resolve(step.isDone(active)).catch(() => false)) continue
|
|
237
|
+
const isAnswered = (await claude().session.surfaces()).length > 0 && (await installer.step(active, step, index + 1, steps.length))
|
|
238
|
+
if (!isAnswered) {
|
|
239
|
+
claude().ui.log(`${definition.name} needs ${steps.length - index === 1 ? 'one more step' : `${steps.length - index} more steps`}, starting with ${step.title}. Run /mods ${definition.name} to finish.`)
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await installer.close()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const activate = async (): Promise<void> => {
|
|
184
247
|
if (runtime === undefined || plugin === undefined) return
|
|
185
248
|
const activeRouter = createRouter()
|
|
186
|
-
const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name),
|
|
187
|
-
|
|
249
|
+
const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), steps: plugin.steps, granted, refreshGrants, checksPermissions, options: pluginOptions }).catch(async (error: unknown) => {
|
|
250
|
+
if (error instanceof MissingOptions && (await askOptions(error))) return undefined
|
|
251
|
+
fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : 'Fix it, then run /reload-plugins.')
|
|
188
252
|
throw error
|
|
189
253
|
})
|
|
254
|
+
if (active === undefined) return activate()
|
|
190
255
|
const { added } = active
|
|
191
256
|
mod = active.mod
|
|
192
257
|
router = activeRouter
|
|
193
258
|
phase = 'active'
|
|
194
259
|
runtime.claude.ui.invalidate('ui.render')
|
|
195
260
|
endLine()
|
|
261
|
+
void runSteps(active.mod).catch((error: unknown) => claude().ui.log(`${definition.name}: its installer stopped: ${messageOf(error)}`))
|
|
196
262
|
const missed = missedSessionStart
|
|
197
263
|
missedSessionStart = undefined
|
|
198
264
|
const lateAnswer = missed === undefined ? {} : await activeRouter.dispatch('classic.SessionStart', missed, async () => ({})).catch((error: unknown) => {
|
|
@@ -217,13 +283,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
217
283
|
return
|
|
218
284
|
}
|
|
219
285
|
showLine().wait('Waiting for your answer')
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
...(event.uninstall === '' ? [] : [`runs ${event.uninstall} when you remove it`]),
|
|
223
|
-
...(event.keys === '' ? [] : [`binds ${event.keys}`]),
|
|
224
|
-
]
|
|
225
|
-
const answer = await claude().ui.ask(`${name} ${listed(actions)}. Install it now?`, { options: ['Install', 'Not now'], header: 'Install' })
|
|
226
|
-
if (answer === 'Install') return install(event.sha256)
|
|
286
|
+
if (await installer.consent(event)) return install(event.sha256)
|
|
287
|
+
await installer.close()
|
|
227
288
|
phase = 'declined'
|
|
228
289
|
endLine()
|
|
229
290
|
claude().ui.log(`${name} is not installed. Run cmod install ${name} to install it.`)
|
|
@@ -244,13 +305,16 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
244
305
|
if (event.kind === 'progress') progress.report(event)
|
|
245
306
|
if (event.kind === 'done' || event.kind === 'failed' || event.kind === 'needs-consent') outcome = event
|
|
246
307
|
})
|
|
247
|
-
if (outcome?.kind === 'done')
|
|
308
|
+
if (outcome?.kind === 'done') {
|
|
309
|
+
for (const item of plugin.steps.permissions ?? []) granted.add(item)
|
|
310
|
+
return finish()
|
|
311
|
+
}
|
|
248
312
|
if (outcome?.kind === 'needs-consent') return askConsent(outcome)
|
|
249
313
|
const reason = outcome?.kind === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`
|
|
250
314
|
fail(reason, `Fix the cause, then run: cmod install ${definition.name}`)
|
|
251
315
|
}
|
|
252
316
|
|
|
253
|
-
const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.
|
|
317
|
+
const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}))
|
|
254
318
|
|
|
255
319
|
const waitForcmod = (found: string | undefined) => {
|
|
256
320
|
phase = 'waiting'
|
|
@@ -279,7 +343,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
279
343
|
showLine().wait(
|
|
280
344
|
latest === undefined
|
|
281
345
|
? "Still waiting for cmod to download cmod. See cmod's own line."
|
|
282
|
-
: `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.
|
|
346
|
+
: `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.steps ?? {})} or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.`,
|
|
283
347
|
),
|
|
284
348
|
)
|
|
285
349
|
}
|
|
@@ -300,6 +364,46 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
300
364
|
if (phase !== 'failed') fail(messageOf(error), `Run cmod install ${definition.name} in a terminal to see the whole log.`)
|
|
301
365
|
}
|
|
302
366
|
|
|
367
|
+
let consentMs = 0
|
|
368
|
+
const consentStamp = async (path: string) => {
|
|
369
|
+
const stat = await claude().fs.stat(path).catch(() => undefined)
|
|
370
|
+
return stat?.kind === 'file' ? stat.mtimeMs : 0
|
|
371
|
+
}
|
|
372
|
+
const refreshGrants = async () => {
|
|
373
|
+
if (plugin === undefined || plugin.name === cmodPluginName) return
|
|
374
|
+
const path = consentPath(plugin.store)
|
|
375
|
+
const modifiedMs = await consentStamp(path)
|
|
376
|
+
if (modifiedMs === consentMs) return
|
|
377
|
+
consentMs = modifiedMs
|
|
378
|
+
const approved = modifiedMs === 0 ? [] : (parseConsent(JSON.parse(await claude().fs.read(path)), path)[plugin.name] ?? [])
|
|
379
|
+
granted.clear()
|
|
380
|
+
for (const item of plugin.steps.permissions ?? []) {
|
|
381
|
+
if (approved.includes(item)) granted.add(item)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const droppedParts = new Set<string>()
|
|
386
|
+
const answerCheck: AnswerCheck = {
|
|
387
|
+
get plugin() {
|
|
388
|
+
return plugin?.name ?? definition.name
|
|
389
|
+
},
|
|
390
|
+
isGranted: (item) => isCovered(granted, item, undefined),
|
|
391
|
+
dropped(item, part) {
|
|
392
|
+
if (droppedParts.has(item)) return
|
|
393
|
+
droppedParts.add(item)
|
|
394
|
+
const declared = plugin?.steps.permissions ?? []
|
|
395
|
+
const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`
|
|
396
|
+
claude().ui.log(`${definition.name} answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it. ${fix}`)
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const checkedRoute = async <N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> => {
|
|
401
|
+
let below: Promise<EventResult<N>> | undefined
|
|
402
|
+
const passOn: RouterNext<N> = (passed) => (below ??= next(passedDown(event, e, passed, answerCheck) as Frozen<Args<N>>))
|
|
403
|
+
const answer = await router.dispatch(event, e, passOn)
|
|
404
|
+
return (await checkedAnswer(event, e, answer, () => passOn(e), answerCheck)) as EventResult<N>
|
|
405
|
+
}
|
|
406
|
+
|
|
303
407
|
const whenActive = async () => {
|
|
304
408
|
if (phase === 'ready') activation ??= activate().catch(report)
|
|
305
409
|
if (activation !== undefined && phase !== 'active') await activation
|
|
@@ -334,6 +438,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
334
438
|
})
|
|
335
439
|
if (started === undefined) return
|
|
336
440
|
plugin = started
|
|
441
|
+
for (const item of started.granted) granted.add(item)
|
|
442
|
+
consentMs = await consentStamp(consentPath(started.store))
|
|
337
443
|
if (started.isInstalled) {
|
|
338
444
|
shouldRecord = started.shouldRecord
|
|
339
445
|
if (shouldRecord) record()
|
|
@@ -346,6 +452,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
346
452
|
}
|
|
347
453
|
},
|
|
348
454
|
async route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
455
|
+
if (event === 'ui.render' && installer.draws(e as Frozen<Args<'ui.render'>>)) return installer.draw(e as Frozen<Args<'ui.render'>>) as EventResult<N>
|
|
456
|
+
if (event === 'ui.close') installer.closed((e as Frozen<Args<'ui.close'>>).id)
|
|
349
457
|
if (event === 'classic.SessionStart') missedSessionStart = undefined
|
|
350
458
|
if (event === 'classic.SessionStart' && runtime === undefined) {
|
|
351
459
|
missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
|
|
@@ -358,6 +466,13 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
358
466
|
const isStopped = phase === 'declined' || phase === 'failed'
|
|
359
467
|
return { deny: isStopped ? notInstalled(definition.name) : `${definition.name} is installing. Try again when it's ready.` } as EventResult<N>
|
|
360
468
|
}
|
|
469
|
+
const call = e as Frozen<Args<'cmod.call'>>
|
|
470
|
+
if (event === 'cmod.call' && call.to === definition.name && mod !== undefined) {
|
|
471
|
+
if (call.method === pendingStepsMethod) return { value: await pendingSteps(mod) } as EventResult<N>
|
|
472
|
+
if (call.method === finishStepsMethod) return (await runSteps(mod), { value: null }) as EventResult<N>
|
|
473
|
+
}
|
|
474
|
+
if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart') await refreshGrants().catch((error: unknown) => claude().ui.log(`${definition.name} keeps its grants from before: ${messageOf(error)}`, { to: 'debug' }))
|
|
475
|
+
if (checksAnswers(event) && phase === 'active') return checkedRoute(event, e, next)
|
|
361
476
|
const answer = router.dispatch(event, e, next)
|
|
362
477
|
const render = e as Frozen<Args<'ui.render'>>
|
|
363
478
|
if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined || !runtime.progress.isShown) return answer as Promise<EventResult<N>>
|
|
@@ -366,24 +481,63 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
366
481
|
}
|
|
367
482
|
}
|
|
368
483
|
|
|
369
|
-
async function createMod<State extends object>(definition: ModDefinition<State>, runtime: ModRuntime): Promise<ActiveMod<State>> {
|
|
484
|
+
async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
|
|
370
485
|
const { router, progress } = runtime
|
|
371
|
-
const claude = checkingKeys(runtime.claude, definition.name, runtime.keys)
|
|
486
|
+
const claude = checkingKeys(runtime.claude, definition.name, runtime.steps.keys ?? {})
|
|
372
487
|
const added: string[] = []
|
|
373
488
|
const hookEvents: ModEvent[] = []
|
|
374
489
|
const announce = (feature: string) => {
|
|
375
490
|
added.push(feature)
|
|
376
491
|
}
|
|
377
|
-
const [session, root, startCwd] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd()])
|
|
492
|
+
const [session, root, startCwd, home, configHome] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd(), claude.env.home(), claude.env.configHome()])
|
|
493
|
+
const { granted } = runtime
|
|
494
|
+
const configRoot = configHome ?? (home === undefined ? undefined : `${home}/.claude`)
|
|
495
|
+
const grants: Grants = {
|
|
496
|
+
name: definition.name,
|
|
497
|
+
declared: runtime.steps.permissions ?? [],
|
|
498
|
+
granted: () => granted,
|
|
499
|
+
refresh: runtime.refreshGrants,
|
|
500
|
+
home,
|
|
501
|
+
configRoot,
|
|
502
|
+
projectRoot: () => modState.root,
|
|
503
|
+
freeFolders: () => [modState.root, runtime.dataFolder],
|
|
504
|
+
}
|
|
505
|
+
const checked = checkingGrants(claude, grants)
|
|
506
|
+
const files = createModFiles({
|
|
507
|
+
name: definition.name,
|
|
508
|
+
claude,
|
|
509
|
+
places: { home, configRoot, projectRoot: () => modState.root },
|
|
510
|
+
checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
|
|
511
|
+
})
|
|
512
|
+
const pages = new Map<string, { readonly title: string; readonly handle: PaneHandle }>()
|
|
513
|
+
const settingsPages: Mod<State>['settings'] = {
|
|
514
|
+
page(page) {
|
|
515
|
+
if (pages.has(page.id)) throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`)
|
|
516
|
+
pages.set(page.id, { title: page.title, handle: area.ui.pane(page) })
|
|
517
|
+
},
|
|
518
|
+
async open(pageId) {
|
|
519
|
+
if (pageId === undefined) {
|
|
520
|
+
await claude.cmod.call({ to: cmodPluginName, method: 'openSettings', input: { mod: definition.name } })
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
const page = pages.get(pageId)
|
|
524
|
+
if (page === undefined) throw new Error(`${definition.name} has no settings page "${pageId}". It has: ${[...pages.keys()].join(', ') || 'none'}.`)
|
|
525
|
+
await page.handle.open({ focus: true })
|
|
526
|
+
},
|
|
527
|
+
}
|
|
378
528
|
let cwd = startCwd
|
|
379
529
|
let loadedCwd = startCwd
|
|
380
530
|
let staleCwd: string | undefined
|
|
381
531
|
const area = createUi<State>({ name: definition.name, claude, router, progress, announce, mod: () => mod })
|
|
382
532
|
const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, changed: area.changed })
|
|
533
|
+
const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, changed: modState.changed })
|
|
383
534
|
const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
|
|
384
|
-
const mod: Mod<State> = {
|
|
535
|
+
const mod: Mod<State, Declared> = {
|
|
385
536
|
name: definition.name,
|
|
386
537
|
state: modState.state,
|
|
538
|
+
get options() {
|
|
539
|
+
return modOptions.values as OptionValues<Declared>
|
|
540
|
+
},
|
|
387
541
|
dataFolder: runtime.dataFolder,
|
|
388
542
|
on(event, hook, options) {
|
|
389
543
|
const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude)
|
|
@@ -391,7 +545,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
391
545
|
if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, bounded as ModHook<'PreToolUse'>, claude, agents, held))
|
|
392
546
|
else router.add(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, classicHook(definition.name, event as Exclude<ModEvent, 'PreToolUse'>, bounded as ModHook<Exclude<ModEvent, 'PreToolUse'>>, claude, agents))
|
|
393
547
|
},
|
|
394
|
-
use: (job) => job({ mod,
|
|
548
|
+
use: (job) => job({ mod, announce, reserveName, toolCalls: agents }),
|
|
395
549
|
every(ms, hook) {
|
|
396
550
|
let isRunning = false
|
|
397
551
|
return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
|
|
@@ -407,31 +561,40 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
407
561
|
},
|
|
408
562
|
ui: area.ui,
|
|
409
563
|
process: {
|
|
410
|
-
run: (argv, init) =>
|
|
411
|
-
spawn: (argv, init) =>
|
|
564
|
+
run: (argv, init) => checked.process.run(argv, init),
|
|
565
|
+
spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
|
|
412
566
|
},
|
|
413
567
|
fs: {
|
|
414
|
-
read: (path) =>
|
|
415
|
-
write: (path, text) =>
|
|
416
|
-
list: (path) =>
|
|
417
|
-
exists: (path) =>
|
|
418
|
-
stat: (path, options) =>
|
|
568
|
+
read: (path) => checked.fs.read(path),
|
|
569
|
+
write: (path, text) => checked.fs.write(path, text),
|
|
570
|
+
list: (path) => checked.fs.list(path),
|
|
571
|
+
exists: (path) => checked.fs.exists(path),
|
|
572
|
+
stat: (path, options) => checked.fs.stat(path, options),
|
|
573
|
+
find: (glob) => files.find(glob),
|
|
419
574
|
},
|
|
420
|
-
|
|
421
|
-
|
|
575
|
+
metadata: {
|
|
576
|
+
read: (path) => files.read(path),
|
|
577
|
+
update: (path, change) => files.update(path, change),
|
|
578
|
+
},
|
|
579
|
+
http: { fetch: (url, init) => checked.http.fetch(url, init) },
|
|
580
|
+
settings: settingsPages,
|
|
422
581
|
session: {
|
|
423
|
-
messages:
|
|
582
|
+
messages: ((args?: { readonly agentId: string }) => (args === undefined ? checked.session.messages() : checked.session.messages({ agentId: args.agentId }))) as Mod<State>['session']['messages'],
|
|
424
583
|
async append(text) {
|
|
425
|
-
const added = await
|
|
584
|
+
const added = await checked.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
|
|
426
585
|
if (added.deny !== undefined) throw new Error(`${definition.name}: Claude Code refused the note: ${added.deny}`)
|
|
427
586
|
},
|
|
428
587
|
async submit(text) {
|
|
429
|
-
const submitted = await
|
|
588
|
+
const submitted = await checked.prompt.submit({ text })
|
|
430
589
|
if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${definition.name}: Claude Code dropped the prompt: ${submitted.drop}`)
|
|
431
590
|
},
|
|
432
591
|
},
|
|
433
|
-
agent: { spawn: (args) =>
|
|
434
|
-
model: { complete: (
|
|
592
|
+
agent: { spawn: (args) => checked.agent.spawn(args) },
|
|
593
|
+
model: { complete: (completion, options) => checked.model.complete(completion, options) },
|
|
594
|
+
permissions: {
|
|
595
|
+
has: (name: string, value?: string) => isCovered(granted, itemOf(name, value), home),
|
|
596
|
+
},
|
|
597
|
+
claude: { ...checked, on: (event, hook) => router.add(event, hook) },
|
|
435
598
|
get projectRoot() {
|
|
436
599
|
return modState.root
|
|
437
600
|
},
|
|
@@ -454,6 +617,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
454
617
|
if (cwd === nextCwd) cwd = loadedCwd
|
|
455
618
|
throw error
|
|
456
619
|
})
|
|
620
|
+
if (hasMovedRoot) await modOptions.load(nextRoot)
|
|
457
621
|
loadedCwd = nextCwd
|
|
458
622
|
if (!hasMovedRoot) area.changed()
|
|
459
623
|
if (nextCwd === oldCwd) return
|
|
@@ -487,7 +651,15 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
487
651
|
})
|
|
488
652
|
router.add('skill.prompt', userSkillHook(claude))
|
|
489
653
|
const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input: never) => run(input, mod)]))
|
|
490
|
-
router.add('cmod.call', (e, next) =>
|
|
654
|
+
router.add('cmod.call', async (e, next) => {
|
|
655
|
+
if (e.to !== definition.name) return next(e)
|
|
656
|
+
if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
|
|
657
|
+
if (e.method === openPageMethod) {
|
|
658
|
+
await settingsPages.open(String(e.input))
|
|
659
|
+
return { value: null }
|
|
660
|
+
}
|
|
661
|
+
return answerCall(definition.name, api, e)
|
|
662
|
+
})
|
|
491
663
|
const agents = toolCalls(claude, router)
|
|
492
664
|
const names = new Set<string>()
|
|
493
665
|
const reserveName = (kind: string, name: string, taken: string) => {
|
|
@@ -496,6 +668,9 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
496
668
|
names.add(key)
|
|
497
669
|
}
|
|
498
670
|
await failsAs('its state did not load', () => modState.load())
|
|
671
|
+
await failsAs('its options did not load', () => modOptions.load(root))
|
|
672
|
+
const missing = modOptions.missing
|
|
673
|
+
if (missing.length > 0) throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key))
|
|
499
674
|
await failsAs('its setup function threw', () => definition.setup(mod))
|
|
500
675
|
const permissionHooks = permissionEvents.filter((event) => router.has(event))
|
|
501
676
|
if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
|