@cmodjs/core 0.2.3 → 1.0.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 +58 -10
- package/docs/dependencies.md +12 -12
- package/docs/hooks.md +41 -28
- package/docs/index.md +29 -25
- package/docs/install-steps.md +9 -7
- package/docs/jobs.md +59 -111
- package/docs/mod.md +200 -174
- package/docs/options.md +2 -2
- package/docs/permissions.md +27 -16
- package/docs/session.md +248 -0
- package/docs/state.md +11 -7
- package/docs/testing.md +66 -23
- package/docs/ui.md +108 -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 +8 -3
- 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 +154 -75
- package/mod.d.ts.map +1 -1
- package/mod.js +94 -3
- package/options.d.ts +2 -2
- package/options.js +19 -19
- package/package.json +3 -3
- package/records.d.ts +10 -8
- package/records.d.ts.map +1 -1
- package/records.js +28 -17
- package/register.d.ts.map +1 -1
- package/register.js +18 -6
- package/runtime/announce.d.ts +18 -0
- package/runtime/announce.d.ts.map +1 -0
- package/runtime/announce.js +66 -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 +3 -3
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +16 -0
- package/runtime/conversations.d.ts.map +1 -0
- package/runtime/conversations.js +291 -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 +9 -0
- package/runtime/engine.d.ts +17 -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 +63 -33
- package/runtime/hooks.d.ts +5 -4
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +10 -5
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +11 -9
- package/runtime/lifecycle.d.ts +3 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +317 -186
- package/runtime/metadata.d.ts +3 -1
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +21 -11
- 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 +36 -0
- package/runtime/sessions.d.ts.map +1 -0
- package/runtime/sessions.js +235 -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 +31 -0
- package/runtime/toaster.d.ts.map +1 -0
- package/runtime/toaster.js +176 -0
- package/runtime/ui.d.ts +6 -17
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +36 -112
- package/session/context/context.d.ts +10 -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 +54 -0
- package/session/context/turn/message/message.d.ts.map +1 -0
- package/session/context/turn/message/message.js +137 -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 +12 -0
- package/session/context/turn/turn.d.ts.map +1 -0
- package/session/context/turn/turn.js +2 -0
- package/session/conversation.d.ts +64 -0
- package/session/conversation.d.ts.map +1 -0
- package/session/conversation.js +123 -0
- package/session/goal/goal.d.ts +10 -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 +99 -0
- package/session/session.d.ts.map +1 -0
- package/session/session.js +1 -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 +7 -3
- 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 +209 -97
- package/src/options.ts +22 -22
- package/src/records.ts +35 -23
- package/src/register.ts +18 -6
- package/src/runtime/announce.ts +74 -0
- package/src/runtime/bound.ts +12 -0
- package/src/runtime/claude.ts +5 -3
- package/src/runtime/conversations.ts +302 -0
- package/src/runtime/deadline.ts +2 -2
- package/src/runtime/dependencies.ts +12 -0
- package/src/runtime/engine.ts +22 -0
- package/src/runtime/grants.ts +66 -31
- package/src/runtime/hooks.ts +19 -10
- package/src/runtime/installer.ts +10 -9
- package/src/runtime/lifecycle.ts +313 -181
- package/src/runtime/metadata.ts +33 -11
- 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 +242 -0
- package/src/runtime/state.ts +50 -14
- package/src/runtime/toaster.ts +218 -0
- package/src/runtime/ui.ts +42 -139
- package/src/session/context/context.ts +12 -0
- package/src/session/context/turn/message/document.ts +8 -0
- package/src/session/context/turn/message/message.ts +165 -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 +14 -0
- package/src/session/conversation.ts +169 -0
- package/src/session/goal/goal.ts +9 -0
- package/src/session/goal/task.ts +19 -0
- package/src/session/session.ts +94 -0
- package/src/testing/fake-claude.ts +23 -18
- package/src/testing/fake-elements.ts +3 -2
- package/src/testing/fake-files.ts +1 -1
- package/src/testing/fake-sessions.ts +203 -0
- package/src/testing.ts +112 -33
- 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 +3 -3
- package/src/utils/changelog.ts +32 -0
- package/src/utils/matcher.ts +37 -0
- package/src/utils/metadata.ts +17 -15
- package/src/utils/parse-shell.ts +24 -16
- package/src/utils/paths.ts +15 -1
- package/src/utils/text.ts +4 -0
- package/testing/fake-claude.d.ts +4 -2
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +18 -12
- package/testing/fake-elements.js +4 -2
- package/testing/fake-files.js +1 -1
- package/testing/fake-sessions.d.ts +74 -0
- package/testing/fake-sessions.d.ts.map +1 -0
- package/testing/fake-sessions.js +170 -0
- package/testing.d.ts +33 -13
- package/testing.d.ts.map +1 -1
- package/testing.js +92 -21
- 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 +1 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +2 -2
- package/utils/changelog.d.ts +9 -0
- package/utils/changelog.d.ts.map +1 -0
- package/utils/changelog.js +29 -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.d.ts +1 -0
- package/utils/parse-shell.d.ts.map +1 -1
- package/utils/parse-shell.js +22 -13
- package/utils/paths.d.ts +2 -0
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +13 -1
- 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/src/runtime/lifecycle.ts
CHANGED
|
@@ -1,23 +1,34 @@
|
|
|
1
|
-
import type { Args, EventResult, Frozen, HookBudget,
|
|
2
|
-
import type {
|
|
1
|
+
import type { Args, EventResult, Frozen, HookBudget, PluginOptions, RenderElement } from 'claude-code'
|
|
2
|
+
import type { Step } from '../installer.js'
|
|
3
|
+
import { hooks, PreToolUse, type Allowed, type Disposable, type Hook, type HookInput, type HookListener, type HookOptions, type Imports, type Mod, type ModDefinition, type PaneHandle, type SettingsFile, type Toast } from '../mod.js'
|
|
3
4
|
import type { Options, OptionValues } from '../options.js'
|
|
4
|
-
import { consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
|
|
5
|
-
import { relativePath } from '../utils/paths.js'
|
|
6
|
-
import { formatExit, listed, messageOf } from '../utils/text.js'
|
|
5
|
+
import { changelogPage, consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, recordPath, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
|
|
6
|
+
import { ifMissing, relativePath } from '../utils/paths.js'
|
|
7
|
+
import { firstLine, formatExit, listed, messageOf } from '../utils/text.js'
|
|
7
8
|
import type { Claude } from './claude.js'
|
|
8
9
|
import { beforeDeadline } from './deadline.js'
|
|
9
|
-
import {
|
|
10
|
+
import { announcedOf, announcementMs, announcementOf } from './announce.js'
|
|
11
|
+
import { bind } from './bound.js'
|
|
12
|
+
import { serveConversations } from './conversations.js'
|
|
13
|
+
import { answerCall, cmodPluginName, dependencyCalls, notInstalled, withBareRefusals } from './dependencies.js'
|
|
14
|
+
import { keepEngine } from './engine.js'
|
|
10
15
|
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 { locatingPrograms, type Programs } from './programs.js'
|
|
13
|
-
import { createModFiles } from './metadata.js'
|
|
16
|
+
import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
|
|
17
|
+
import { cmodError, lastLineOf, locatingPrograms, readLines, type Programs } from './programs.js'
|
|
18
|
+
import { createModFiles, resolvedPath, type Places } from './metadata.js'
|
|
14
19
|
import { fitsOption } from '../options.js'
|
|
15
20
|
import { createInstaller } from './installer.js'
|
|
16
21
|
import { createOptions, MissingOptions } from './options.js'
|
|
17
22
|
import { createRouter, type Router, type RouterNext } from './router.js'
|
|
23
|
+
import { conversationMethods, createSessionsClient } from './sessions.js'
|
|
24
|
+
import type { SessionChange } from '../session/conversation.js'
|
|
25
|
+
import { matches } from '../utils/matcher.js'
|
|
18
26
|
import { createState } from './state.js'
|
|
27
|
+
import { createToaster, showFailure, type Failure, type ShownToast, type Toaster } from './toaster.js'
|
|
19
28
|
import { toolCalls } from './tool-calls.js'
|
|
20
|
-
import {
|
|
29
|
+
import { createUi } from './ui.js'
|
|
30
|
+
import { Progress } from '../ui/components.js'
|
|
31
|
+
import { Box, Text } from '../ui/elements.js'
|
|
21
32
|
|
|
22
33
|
export type Plugin = {
|
|
23
34
|
readonly name: string
|
|
@@ -36,6 +47,7 @@ export type Lifecycle<State extends object> = {
|
|
|
36
47
|
readonly phase: Phase
|
|
37
48
|
readonly mod: Mod<State> | undefined
|
|
38
49
|
readonly failure: unknown
|
|
50
|
+
readonly toasts: readonly ShownToast[]
|
|
39
51
|
start(claude: Claude, read: (claude: Claude) => Promise<Plugin>): Promise<void>
|
|
40
52
|
route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
|
|
41
53
|
}
|
|
@@ -43,8 +55,9 @@ export type Lifecycle<State extends object> = {
|
|
|
43
55
|
type ModRuntime = {
|
|
44
56
|
readonly claude: Claude
|
|
45
57
|
readonly router: Router
|
|
46
|
-
readonly
|
|
58
|
+
readonly toaster: Toaster
|
|
47
59
|
readonly dataFolder: string
|
|
60
|
+
readonly store: string
|
|
48
61
|
readonly steps: Steps
|
|
49
62
|
readonly granted: ReadonlySet<string>
|
|
50
63
|
readonly refreshGrants: () => Promise<void>
|
|
@@ -55,33 +68,30 @@ type ModRuntime = {
|
|
|
55
68
|
type ActiveMod<State extends object, Declared extends Options> = {
|
|
56
69
|
readonly mod: Mod<State, Declared>
|
|
57
70
|
readonly added: readonly string[]
|
|
71
|
+
readonly imports: Imports
|
|
58
72
|
}
|
|
59
73
|
|
|
60
|
-
const cmodPluginName = 'cmod'
|
|
61
74
|
|
|
62
75
|
const announcedKey = 'cmod:announced'
|
|
63
76
|
|
|
77
|
+
const installId = 'cmod:install'
|
|
78
|
+
|
|
64
79
|
const cmodCheckMs = 1000
|
|
65
80
|
|
|
66
81
|
const cmodWaitMs = 60_000
|
|
67
82
|
|
|
68
|
-
const
|
|
83
|
+
const startHoldMs = (10_000 satisfies HookBudget['ms']) - 1_000
|
|
69
84
|
|
|
70
85
|
const dependencyCallMs = 30_000
|
|
71
86
|
|
|
72
87
|
const cannotStart = /failed to start: /
|
|
73
88
|
|
|
74
|
-
const missingPath = /(?:^|: )(?:ENOENT|ENOTDIR)\b/
|
|
75
|
-
|
|
76
89
|
const shellTools: readonly string[] = ['Bash', 'PowerShell']
|
|
77
90
|
|
|
78
91
|
export async function readPlugin(claude: Claude): Promise<Plugin> {
|
|
79
92
|
const { name, root } = claude.plugin
|
|
80
93
|
const read: ReadFile = async (path) => {
|
|
81
|
-
const stat = await claude.fs.stat(path).catch((
|
|
82
|
-
if (missingPath.test(messageOf(error))) return undefined
|
|
83
|
-
throw error
|
|
84
|
-
})
|
|
94
|
+
const stat = await claude.fs.stat(path).catch(ifMissing(undefined))
|
|
85
95
|
return stat?.kind === 'file' ? claude.fs.read(path) : undefined
|
|
86
96
|
}
|
|
87
97
|
const manifest = (await readJson(read, `${root}/.claude-plugin/plugin.json`)) as { version?: unknown } | undefined
|
|
@@ -144,13 +154,13 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
144
154
|
): Lifecycle<State> {
|
|
145
155
|
let router: Router = createRouter()
|
|
146
156
|
let phase: Phase = 'starting'
|
|
147
|
-
let runtime: Pick<ModRuntime, 'claude' | '
|
|
157
|
+
let runtime: Pick<ModRuntime, 'claude' | 'toaster'> | undefined
|
|
148
158
|
let programs: Programs | undefined
|
|
149
159
|
let plugin: Plugin | undefined
|
|
150
160
|
const granted = new Set<string>()
|
|
151
|
-
let line: ProgressLine | undefined
|
|
152
161
|
let activation: Promise<void> | undefined
|
|
153
162
|
let mod: Mod<State> | undefined
|
|
163
|
+
let imports: Imports | undefined
|
|
154
164
|
let failure: unknown
|
|
155
165
|
let pluginOptions = options
|
|
156
166
|
const installer = createInstaller(definition.name, () => claude())
|
|
@@ -165,20 +175,17 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
165
175
|
return runtime.claude
|
|
166
176
|
}
|
|
167
177
|
|
|
168
|
-
const
|
|
169
|
-
line ??= runtime?.progress.start(`Installing ${definition.name}`)
|
|
170
|
-
return line as ProgressLine
|
|
171
|
-
}
|
|
178
|
+
const installing = (description?: string | RenderElement) => runtime?.toaster.toast.loading('Installing', { id: installId, ...(description === undefined ? {} : { description }) })
|
|
172
179
|
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
line = undefined
|
|
180
|
+
const toastFailure = (shown: Failure) => {
|
|
181
|
+
if (runtime !== undefined) showFailure({ name: definition.name, ui: runtime.toaster, claude: runtime.claude }, shown)
|
|
176
182
|
}
|
|
177
183
|
|
|
178
|
-
const fail = (reason: string, fix: string) => {
|
|
184
|
+
const fail = (reason: string, fix: string, retry?: () => Promise<void>) => {
|
|
179
185
|
phase = 'failed'
|
|
180
186
|
failure ??= new Error(`${definition.name}: ${reason}${/[.!?]$/.test(reason) ? '' : '.'} ${fix}`)
|
|
181
|
-
|
|
187
|
+
const action = retry === undefined ? {} : { action: { label: 'Retry', onClick: () => ((failure = undefined), retry().catch(report)) } }
|
|
188
|
+
runtime?.toaster.toast.error(reason, { id: installId, description: fix, duration: Infinity, ...action })
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
const finish = async () => {
|
|
@@ -190,7 +197,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
190
197
|
const recordThroughCmod = async () => {
|
|
191
198
|
if (plugin === undefined || (await cmodVersion(claude())) === undefined) return
|
|
192
199
|
const { code, lastError } = await readLines(claude().process.spawn({ argv: ['cmod', 'setup', plugin.root, '--events'] }), (text) => {
|
|
193
|
-
if (parseEvent(text).
|
|
200
|
+
if (parseEvent(text).type === 'done') shouldRecord = false
|
|
194
201
|
})
|
|
195
202
|
if (shouldRecord) throw new Error(`cmod setup ${formatExit(code, lastError)}`)
|
|
196
203
|
}
|
|
@@ -206,11 +213,11 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
206
213
|
const saveOption = async (key: string, text: string): Promise<string | undefined> => {
|
|
207
214
|
const option = (definition.options as Options | undefined)?.[key]
|
|
208
215
|
if (option === undefined || plugin === undefined) return `${definition.name} has no option ${key}.`
|
|
209
|
-
const value = option.
|
|
210
|
-
const misfit = option.
|
|
216
|
+
const value = option.type === 'number' ? Number(text) : option.type === 'toggle' ? text.trim() === 'true' : option.type === 'list' ? text.split(',').map((item) => item.trim()).filter((item) => item !== '') : text
|
|
217
|
+
const misfit = option.type === 'toggle' && !['true', 'false'].includes(text.trim()) ? 'takes true or false' : fitsOption(option, value)
|
|
211
218
|
if (misfit !== undefined) return `${option.title} ${misfit}.`
|
|
212
|
-
const saved = await claude().
|
|
213
|
-
return saved.
|
|
219
|
+
const saved = await claude().process.run(['cmod', 'option', plugin.name, key, Array.isArray(value) ? value.join(',') : String(value)])
|
|
220
|
+
return saved.exitCode === 0 ? undefined : cmodError(saved.stderr, 'option') || `cmod option ${formatExit(saved.exitCode, '')}.`
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
const askOptions = async (missing: MissingOptions): Promise<boolean> => {
|
|
@@ -227,19 +234,33 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
227
234
|
return true
|
|
228
235
|
}
|
|
229
236
|
|
|
237
|
+
const isStepDone = async (active: Mod<State>, step: Step<State>) => {
|
|
238
|
+
try {
|
|
239
|
+
return await step.isDone(active)
|
|
240
|
+
} catch (error) {
|
|
241
|
+
toastFailure({ id: `step:${step.title}`, message: `The ${step.title} step's isDone failed`, error })
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
230
246
|
const pendingSteps = async (active: Mod<State>) => {
|
|
231
247
|
const pending: string[] = []
|
|
232
|
-
for (const step of definition.installer ?? []) if (!(await
|
|
248
|
+
for (const step of definition.installer ?? []) if (!(await isStepDone(active, step))) pending.push(step.title)
|
|
233
249
|
return pending
|
|
234
250
|
}
|
|
235
251
|
|
|
236
252
|
const runSteps = async (active: Mod<State>) => {
|
|
237
253
|
const steps = asksPerson ? (definition.installer ?? []) : []
|
|
238
254
|
for (const [index, step] of steps.entries()) {
|
|
239
|
-
if (await
|
|
255
|
+
if (await isStepDone(active, step)) continue
|
|
240
256
|
const isAnswered = (await claude().session.surfaces()).length > 0 && (await installer.step(active, step, index + 1, steps.length))
|
|
241
257
|
if (!isAnswered) {
|
|
242
|
-
|
|
258
|
+
runtime?.toaster.toast.warning(steps.length - index === 1 ? 'Needs one more step' : `Needs ${steps.length - index} more steps`, {
|
|
259
|
+
id: 'cmod:steps',
|
|
260
|
+
description: `Starting with ${step.title}. Finish them in /mods ${definition.name}.`,
|
|
261
|
+
duration: Infinity,
|
|
262
|
+
action: { label: 'Finish', onClick: () => active.settings.open() },
|
|
263
|
+
})
|
|
243
264
|
break
|
|
244
265
|
}
|
|
245
266
|
}
|
|
@@ -249,7 +270,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
249
270
|
const activate = async (): Promise<void> => {
|
|
250
271
|
if (runtime === undefined || plugin === undefined) return
|
|
251
272
|
const activeRouter = createRouter()
|
|
252
|
-
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) => {
|
|
273
|
+
const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), store: plugin.store, steps: plugin.steps, granted, refreshGrants, checksPermissions, options: pluginOptions }).catch(async (error: unknown) => {
|
|
253
274
|
if (error instanceof MissingOptions && (await askOptions(error))) return undefined
|
|
254
275
|
fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : 'Fix it, then run /reload-plugins.')
|
|
255
276
|
throw error
|
|
@@ -257,47 +278,58 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
257
278
|
if (active === undefined) return activate()
|
|
258
279
|
const { added } = active
|
|
259
280
|
mod = active.mod
|
|
281
|
+
imports = active.imports
|
|
260
282
|
router = activeRouter
|
|
261
283
|
phase = 'active'
|
|
262
284
|
runtime.claude.ui.invalidate('ui.render')
|
|
263
|
-
|
|
264
|
-
void runSteps(active.mod).catch((error: unknown) => claude().ui.log(`${definition.name}: its installer stopped: ${messageOf(error)}`))
|
|
285
|
+
void runSteps(active.mod).catch((error: unknown) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }))
|
|
265
286
|
const missed = missedSessionStart
|
|
266
287
|
missedSessionStart = undefined
|
|
267
288
|
const lateAnswer = missed === undefined ? {} : await activeRouter.dispatch('classic.SessionStart', missed, async () => ({})).catch((error: unknown) => {
|
|
268
|
-
|
|
289
|
+
toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error })
|
|
269
290
|
return {}
|
|
270
291
|
})
|
|
271
292
|
if (Object.keys(lateAnswer).length > 0) {
|
|
272
293
|
runtime.claude.ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read the answer of its SessionStart hooks.`, { to: 'debug' })
|
|
273
294
|
}
|
|
274
295
|
const version = plugin.version ?? ''
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
296
|
+
const announced = announcedOf(await runtime.claude.store.get(announcedKey))
|
|
297
|
+
if (announced?.version === version) {
|
|
298
|
+
runtime.toaster.toast.dismiss(installId)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
await runtime.claude.store.set(announcedKey, { version, added })
|
|
302
|
+
const { toaster } = runtime
|
|
303
|
+
void announcementOf(runtime.claude, plugin, granted, added, announced)
|
|
304
|
+
.then(({ message, ...options }) => toaster.toast.success(message, { id: installId, duration: announcementMs, ...options }))
|
|
305
|
+
.catch((error: unknown) => toastFailure({ id: installId, message: 'Could not say what it installed', error }))
|
|
279
306
|
}
|
|
280
307
|
|
|
281
308
|
const updatesToShow = async () => {
|
|
282
309
|
const [home, configHome] = await Promise.all([claude().env.home(), claude().env.configHome()])
|
|
283
310
|
const configRoot = configHome ?? `${home ?? ''}/.claude`
|
|
284
|
-
const known = await claude()
|
|
311
|
+
const known = await claude()
|
|
312
|
+
.fs.read(`${configRoot}/plugins/known_marketplaces.json`)
|
|
313
|
+
.then((text): unknown => JSON.parse(text))
|
|
314
|
+
.catch(ifMissing(undefined))
|
|
285
315
|
return updatesToTurnOn(marketplaceOf(claude().plugin.root, claude().plugin.name), await claude().settings.read({ source: 'user' }), known)
|
|
286
316
|
}
|
|
287
317
|
|
|
288
|
-
const askConsent = async (event: Extract<RunnerEvent, {
|
|
318
|
+
const askConsent = async (event: Extract<RunnerEvent, { type: 'needs-consent' }>) => {
|
|
289
319
|
const name = definition.name
|
|
290
320
|
if ((await claude().session.surfaces()).length === 0) {
|
|
291
|
-
showLine().wait(`Waiting for consent: run cmod install ${name}`)
|
|
292
321
|
claude().ui.log(`${name} waits for consent to run ${event.install}. Run cmod install ${name} in a terminal.`)
|
|
293
322
|
return
|
|
294
323
|
}
|
|
295
|
-
|
|
296
|
-
|
|
324
|
+
installing('Waiting for your answer')
|
|
325
|
+
const updates = await updatesToShow().catch((error: unknown) => {
|
|
326
|
+
toastFailure({ id: 'cmod:updates', message: 'Could not read whether its marketplace updates automatically', error })
|
|
327
|
+
return undefined
|
|
328
|
+
})
|
|
329
|
+
if (await installer.consent({ ...event, updates })) return install(event.sha256)
|
|
297
330
|
await installer.close()
|
|
298
331
|
phase = 'declined'
|
|
299
|
-
|
|
300
|
-
claude().ui.log(`${name} is not installed. Run cmod install ${name} to install it.`)
|
|
332
|
+
runtime?.toaster.toast('Not installed', { id: installId, description: `Run cmod install ${name} to install it.` })
|
|
301
333
|
}
|
|
302
334
|
|
|
303
335
|
const install = async (consent?: string): Promise<void> => {
|
|
@@ -306,29 +338,28 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
306
338
|
phase = 'installing'
|
|
307
339
|
const found = await cmodVersion(claude())
|
|
308
340
|
if (!fits(found)) return waitForcmod(found)
|
|
309
|
-
|
|
310
|
-
const progress = showLine()
|
|
341
|
+
installing()
|
|
311
342
|
let outcome: RunnerEvent | undefined
|
|
312
343
|
const argv = ['cmod', 'setup', plugin.root, '--events', ...(consent === undefined ? [] : ['--consent', consent])]
|
|
313
344
|
const { code, lastError } = await readLines(claude().process.spawn({ argv }), (text) => {
|
|
314
345
|
const event = parseEvent(text)
|
|
315
|
-
if (event.
|
|
316
|
-
if (event.
|
|
346
|
+
if (event.type === 'progress') installing(progressOf(event))
|
|
347
|
+
if (event.type === 'done' || event.type === 'failed' || event.type === 'needs-consent') outcome = event
|
|
317
348
|
})
|
|
318
|
-
if (outcome?.
|
|
349
|
+
if (outcome?.type === 'done') {
|
|
319
350
|
for (const item of plugin.steps.permissions ?? []) granted.add(item)
|
|
320
351
|
return finish()
|
|
321
352
|
}
|
|
322
|
-
if (outcome?.
|
|
323
|
-
const reason = outcome?.
|
|
324
|
-
fail(reason, `Fix the cause, then run
|
|
353
|
+
if (outcome?.type === 'needs-consent') return askConsent(outcome)
|
|
354
|
+
const reason = outcome?.type === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`
|
|
355
|
+
fail(reason, `Fix the cause, then retry, or run cmod install ${definition.name} in a terminal.`, install)
|
|
325
356
|
}
|
|
326
357
|
|
|
327
358
|
const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}))
|
|
328
359
|
|
|
329
360
|
const waitForcmod = (found: string | undefined) => {
|
|
330
361
|
phase = 'waiting'
|
|
331
|
-
|
|
362
|
+
installing('Waiting for cmod')
|
|
332
363
|
let latest = found
|
|
333
364
|
let check: Promise<void> | undefined
|
|
334
365
|
const stop = () => {
|
|
@@ -350,7 +381,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
350
381
|
})
|
|
351
382
|
})
|
|
352
383
|
const longWait = claude().clock.after(cmodWaitMs, () =>
|
|
353
|
-
|
|
384
|
+
installing(
|
|
354
385
|
latest === undefined
|
|
355
386
|
? "Still waiting for cmod to download cmod. See cmod's own line."
|
|
356
387
|
: `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.`,
|
|
@@ -359,14 +390,14 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
359
390
|
}
|
|
360
391
|
|
|
361
392
|
const bootstrap = async (root: string) => {
|
|
362
|
-
|
|
393
|
+
installing()
|
|
363
394
|
phase = 'installing'
|
|
364
395
|
const { code, lastError } = await readLines(claude().process.spawn({ argv: ['sh', '-c', './setup/bootstrap.sh'], cwd: root }), (text) => {
|
|
365
396
|
const event = parseEvent(text)
|
|
366
|
-
if (event.
|
|
397
|
+
if (event.type === 'progress') installing(progressOf(event))
|
|
367
398
|
})
|
|
368
399
|
if (code === 0) return finish()
|
|
369
|
-
fail(`bootstrap ${formatExit(code, lastError)}`, `Run ./setup/bootstrap.sh in ${root} to see the whole log
|
|
400
|
+
fail(`bootstrap ${formatExit(code, lastError)}`, `Run ./setup/bootstrap.sh in ${root} to see the whole log.`, () => bootstrap(root))
|
|
370
401
|
}
|
|
371
402
|
|
|
372
403
|
const report = (error: unknown) => {
|
|
@@ -375,14 +406,30 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
375
406
|
}
|
|
376
407
|
|
|
377
408
|
let consentMs = 0
|
|
378
|
-
const
|
|
379
|
-
const stat = await claude().fs.stat(path).catch((
|
|
409
|
+
const stampOf = async (path: string) => {
|
|
410
|
+
const stat = await claude().fs.stat(path).catch(ifMissing(undefined))
|
|
380
411
|
return stat?.kind === 'file' ? stat.mtimeMs : 0
|
|
381
412
|
}
|
|
413
|
+
let recordMs = 0
|
|
414
|
+
let readStarted: ((claude: Claude) => Promise<Plugin>) | undefined
|
|
415
|
+
const recover = async () => {
|
|
416
|
+
if (plugin === undefined || readStarted === undefined || (phase !== 'failed' && phase !== 'declined')) return
|
|
417
|
+
const modifiedMs = await stampOf(recordPath(plugin.store, plugin.name))
|
|
418
|
+
if (modifiedMs === recordMs) return
|
|
419
|
+
recordMs = modifiedMs
|
|
420
|
+
const current = await readStarted(claude())
|
|
421
|
+
if (!current.isInstalled) return
|
|
422
|
+
plugin = current
|
|
423
|
+
failure = undefined
|
|
424
|
+
activation = undefined
|
|
425
|
+
await refreshGrants()
|
|
426
|
+
await finish()
|
|
427
|
+
}
|
|
428
|
+
|
|
382
429
|
const refreshGrants = async () => {
|
|
383
430
|
if (plugin === undefined || plugin.name === cmodPluginName) return
|
|
384
431
|
const path = consentPath(plugin.store)
|
|
385
|
-
const modifiedMs = await
|
|
432
|
+
const modifiedMs = await stampOf(path)
|
|
386
433
|
if (modifiedMs === consentMs) return
|
|
387
434
|
consentMs = modifiedMs
|
|
388
435
|
const approved = modifiedMs === 0 ? [] : (parseConsent(JSON.parse(await claude().fs.read(path)), path)[plugin.name] ?? [])
|
|
@@ -403,7 +450,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
403
450
|
droppedParts.add(item)
|
|
404
451
|
const declared = plugin?.steps.permissions ?? []
|
|
405
452
|
const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`
|
|
406
|
-
|
|
453
|
+
runtime?.toaster.toast.warning(`Answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it`, { id: `dropped:${item}`, description: fix, duration: Infinity })
|
|
407
454
|
},
|
|
408
455
|
}
|
|
409
456
|
|
|
@@ -419,14 +466,16 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
419
466
|
if (activation !== undefined && phase !== 'active') await activation
|
|
420
467
|
}
|
|
421
468
|
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
void
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
469
|
+
const holdUntilStarted = (): Promise<void> =>
|
|
470
|
+
runtime === undefined
|
|
471
|
+
? startSettled.then(whenActive)
|
|
472
|
+
: new Promise<void>((resolve) => {
|
|
473
|
+
const limit = claude().clock.after(startHoldMs, resolve)
|
|
474
|
+
void startSettled.then(whenActive).then(() => {
|
|
475
|
+
limit.cancel()
|
|
476
|
+
resolve()
|
|
477
|
+
})
|
|
478
|
+
})
|
|
430
479
|
|
|
431
480
|
return {
|
|
432
481
|
get phase() {
|
|
@@ -438,10 +487,13 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
438
487
|
get failure() {
|
|
439
488
|
return failure
|
|
440
489
|
},
|
|
490
|
+
get toasts() {
|
|
491
|
+
return runtime?.toaster.shown ?? []
|
|
492
|
+
},
|
|
441
493
|
async start(claudeCalls, read) {
|
|
442
494
|
if (runtime !== undefined) return
|
|
443
495
|
programs = locatingPrograms(claudeCalls)
|
|
444
|
-
runtime = { claude: programs.claude,
|
|
496
|
+
runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name) }
|
|
445
497
|
try {
|
|
446
498
|
await programs.refresh()
|
|
447
499
|
const started = await read(claudeCalls).catch((error: unknown) => {
|
|
@@ -450,8 +502,10 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
450
502
|
})
|
|
451
503
|
if (started === undefined) return
|
|
452
504
|
plugin = started
|
|
505
|
+
readStarted = read
|
|
453
506
|
for (const item of started.granted) granted.add(item)
|
|
454
|
-
consentMs = await
|
|
507
|
+
consentMs = await stampOf(consentPath(started.store))
|
|
508
|
+
recordMs = await stampOf(recordPath(started.store, started.name))
|
|
455
509
|
if (started.isInstalled) {
|
|
456
510
|
shouldRecord = started.shouldRecord
|
|
457
511
|
if (shouldRecord) record()
|
|
@@ -464,6 +518,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
464
518
|
}
|
|
465
519
|
},
|
|
466
520
|
async route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
521
|
+
if (imports !== undefined) bind(imports)
|
|
467
522
|
if (event === 'ui.render' && installer.draws(e as Frozen<Args<'ui.render'>>)) return installer.draw(e as Frozen<Args<'ui.render'>>) as EventResult<N>
|
|
468
523
|
if (event === 'ui.close') installer.closed((e as Frozen<Args<'ui.close'>>).id)
|
|
469
524
|
if (event === 'classic.SessionStart') missedSessionStart = undefined
|
|
@@ -472,9 +527,11 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
472
527
|
return next(e)
|
|
473
528
|
}
|
|
474
529
|
if (shouldRecord && event === 'classic.UserPromptSubmit') record()
|
|
475
|
-
|
|
530
|
+
if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart') await recover().catch((error: unknown) => claude().ui.log(`${definition.name} stays stopped: ${messageOf(error)}`, { to: 'debug' }))
|
|
531
|
+
const isCallToThisMod = event === 'cmod.call' && (e as Frozen<Args<'cmod.call'>>).to === definition.name
|
|
532
|
+
await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive())
|
|
476
533
|
if (event === 'classic.SessionStart' && phase !== 'active') missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
|
|
477
|
-
if (
|
|
534
|
+
if (isCallToThisMod && phase !== 'active') {
|
|
478
535
|
const isStopped = phase === 'declined' || phase === 'failed'
|
|
479
536
|
return { deny: isStopped ? notInstalled(definition.name) : `${definition.name} is installing. Try again when it's ready.` } as EventResult<N>
|
|
480
537
|
}
|
|
@@ -487,17 +544,18 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
487
544
|
if (checksAnswers(event) && phase === 'active') return checkedRoute(event, e, next)
|
|
488
545
|
const answer = router.dispatch(event, e, next)
|
|
489
546
|
const render = e as Frozen<Args<'ui.render'>>
|
|
490
|
-
if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined
|
|
491
|
-
return runtime.
|
|
547
|
+
if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined) return answer
|
|
548
|
+
return runtime.toaster.draw((await answer) as EventResult<'ui.render'>, runtime.claude.ui.resolve(render), render.props) as EventResult<N>
|
|
492
549
|
},
|
|
493
550
|
}
|
|
494
551
|
}
|
|
495
552
|
|
|
496
553
|
async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
|
|
497
|
-
const { router,
|
|
498
|
-
const
|
|
554
|
+
const { router, toaster } = runtime
|
|
555
|
+
const report = (failure: Failure) => void showFailure({ name: definition.name, ui: toaster, claude: runtime.claude }, failure)
|
|
556
|
+
const claude = withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {}))
|
|
499
557
|
const added: string[] = []
|
|
500
|
-
const
|
|
558
|
+
const hooksAdded: Hook[] = []
|
|
501
559
|
const announce = (feature: string) => {
|
|
502
560
|
added.push(feature)
|
|
503
561
|
}
|
|
@@ -513,18 +571,22 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
513
571
|
configRoot,
|
|
514
572
|
projectRoot: () => modState.root,
|
|
515
573
|
freeFolders: () => [modState.root, runtime.dataFolder],
|
|
574
|
+
freeAddresses: () => programAddresses(mod),
|
|
516
575
|
}
|
|
517
576
|
const checked = checkingGrants(claude, grants)
|
|
577
|
+
const places: Places = { home, configRoot, projectRoot: () => modState.root }
|
|
518
578
|
const files = createModFiles({
|
|
519
579
|
name: definition.name,
|
|
520
580
|
claude,
|
|
521
|
-
places
|
|
581
|
+
places,
|
|
522
582
|
checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
|
|
583
|
+
report,
|
|
523
584
|
})
|
|
524
585
|
const pages = new Map<string, { readonly title: string; readonly handle: PaneHandle }>()
|
|
525
586
|
const settingsPages: Mod<State>['settings'] = {
|
|
526
587
|
page(page) {
|
|
527
588
|
if (pages.has(page.id)) throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`)
|
|
589
|
+
if (page.id === changelogPage) throw new Error(`${definition.name}: the settings page id "${changelogPage}" opens the mod's CHANGELOG.md in /mods. Give the page another id.`)
|
|
528
590
|
pages.set(page.id, { title: page.title, handle: area.ui.pane(page) })
|
|
529
591
|
},
|
|
530
592
|
async open(pageId) {
|
|
@@ -540,81 +602,141 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
540
602
|
let cwd = startCwd
|
|
541
603
|
let loadedCwd = startCwd
|
|
542
604
|
let staleCwd: string | undefined
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
605
|
+
let forkedFrom: string | undefined
|
|
606
|
+
let parentAsked: Promise<string | undefined> | undefined
|
|
607
|
+
let hasEarlierPrompts = true
|
|
608
|
+
const area = createUi<State>({ name: definition.name, claude, router, toaster, report, announce, mod: () => mod })
|
|
609
|
+
const askParent = async (): Promise<string | undefined> => {
|
|
610
|
+
if (definition.name === cmodPluginName) return undefined
|
|
611
|
+
try {
|
|
612
|
+
const parent = await claude.cmod.call({ to: cmodPluginName, method: 'forkParent', input: null })
|
|
613
|
+
return typeof parent === 'string' ? parent : undefined
|
|
614
|
+
} catch (error) {
|
|
615
|
+
claude.ui.log(`${definition.name} starts without the session values of the conversation it was forked from: ${messageOf(error)}`, { to: 'debug' })
|
|
616
|
+
return undefined
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const forkParent = () =>
|
|
620
|
+
(parentAsked ??= askParent().then((parent) => {
|
|
621
|
+
forkedFrom = parent
|
|
622
|
+
return parent
|
|
623
|
+
}))
|
|
624
|
+
const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
|
|
625
|
+
const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
|
|
546
626
|
const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
dataFolder: runtime.dataFolder,
|
|
554
|
-
on(event, hook, options) {
|
|
555
|
-
const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude)
|
|
556
|
-
if (!hookEvents.includes(event)) hookEvents.push(event)
|
|
557
|
-
if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, bounded as ModHook<'PreToolUse'>, claude, agents, held))
|
|
558
|
-
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))
|
|
559
|
-
},
|
|
560
|
-
use: (job) => job({ mod, announce, reserveName, toolCalls: agents }),
|
|
561
|
-
every(ms, hook) {
|
|
562
|
-
let isRunning = false
|
|
563
|
-
return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
|
|
564
|
-
if (isRunning) return
|
|
565
|
-
isRunning = true
|
|
566
|
-
void Promise.resolve()
|
|
567
|
-
.then(hook)
|
|
568
|
-
.catch((error: unknown) => claude.ui.log(`${definition.name}: the every ${ms} ms hook failed: ${messageOf(error)}`))
|
|
569
|
-
.finally(() => {
|
|
570
|
-
isRunning = false
|
|
571
|
-
})
|
|
572
|
-
})
|
|
573
|
-
},
|
|
574
|
-
ui: area.ui,
|
|
575
|
-
process: {
|
|
576
|
-
run: (argv, init) => checked.process.run(argv, init),
|
|
577
|
-
spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
|
|
627
|
+
const expand = (path: string) => (path === '~' || path.startsWith('~/') ? resolvedPath(path, places) : path)
|
|
628
|
+
const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
|
|
629
|
+
read: () => checked.settings.read({ source }),
|
|
630
|
+
async update(values) {
|
|
631
|
+
await checkSettings(grants, source, Object.keys(values))
|
|
632
|
+
await claude.cmod.call({ to: cmodPluginName, method: 'updateSettings', input: { mod: definition.name, source, project: modState.root, values } })
|
|
578
633
|
},
|
|
634
|
+
})
|
|
635
|
+
const client = createSessionsClient({ name: definition.name, claude: checked, report: (id, message, error) => report({ id, message, error }) })
|
|
636
|
+
let followed: Parameters<typeof client.follow>[0] | undefined
|
|
637
|
+
const followFolders = (project: string, folder: string) => {
|
|
638
|
+
if (followed === undefined) return
|
|
639
|
+
followed = { ...followed, project, cwd: folder }
|
|
640
|
+
client.follow(followed)
|
|
641
|
+
}
|
|
642
|
+
const imports: Imports = {
|
|
643
|
+
sessions: client.sessions,
|
|
644
|
+
settings: { user: settingsFile('user'), project: settingsFile('project'), local: settingsFile('local') },
|
|
645
|
+
model: { complete: (completion, options) => checked.model.complete(completion, options) },
|
|
579
646
|
fs: {
|
|
580
|
-
read: (path) => checked.fs.read(path),
|
|
581
|
-
write: (path, text) => checked.fs.write(path, text),
|
|
582
|
-
|
|
583
|
-
exists: (path) => checked.fs.exists(path),
|
|
584
|
-
stat: (path, options) => checked.fs.stat(path, options),
|
|
647
|
+
read: (path) => checked.fs.read(expand(path)),
|
|
648
|
+
write: (path, text) => checked.fs.write(expand(path), text),
|
|
649
|
+
exists: (path) => checked.fs.exists(expand(path)),
|
|
585
650
|
find: (glob) => files.find(glob),
|
|
651
|
+
list: async (path) => (await checked.fs.list(path === undefined ? undefined : expand(path))).map(({ name, kind, size, mtimeMs, isLink }) => ({ name, isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs })),
|
|
652
|
+
async stat(path, options) {
|
|
653
|
+
const { kind, size, mtimeMs, isLink, realPath } = await checked.fs.stat(expand(path), options)
|
|
654
|
+
return { isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs, ...(realPath === undefined ? {} : { realPath }) }
|
|
655
|
+
},
|
|
586
656
|
},
|
|
587
657
|
metadata: {
|
|
588
658
|
read: (path) => files.read(path),
|
|
589
659
|
update: (path, change) => files.update(path, change),
|
|
590
660
|
},
|
|
661
|
+
process: {
|
|
662
|
+
run: (argv, init) => checked.process.run(argv, init),
|
|
663
|
+
spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
|
|
664
|
+
},
|
|
591
665
|
http: { fetch: (url, init) => checked.http.fetch(url, init) },
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
666
|
+
}
|
|
667
|
+
const allowed = (permission: string): Allowed => ({ allows: (value) => isCovered(granted, itemOf(permission, value), home) })
|
|
668
|
+
const flag = (permission: string) => isCovered(granted, permission, home)
|
|
669
|
+
const timer = (ms: number, subject: string, start: (ms: number, run: () => void) => { cancel(): void }, listener: () => unknown): Disposable => {
|
|
670
|
+
let isRunning = false
|
|
671
|
+
const started = start(checkedMs(definition.name, `mod.${subject}`, ms), () => {
|
|
672
|
+
if (isRunning) return
|
|
673
|
+
isRunning = true
|
|
674
|
+
void Promise.resolve()
|
|
675
|
+
.then(() => {
|
|
676
|
+
bind(imports)
|
|
677
|
+
return listener()
|
|
678
|
+
})
|
|
679
|
+
.catch((error: unknown) => report({ id: `${subject}:${ms}`, message: `The ${subject} ${ms} ms timer failed`, error }))
|
|
680
|
+
.finally(() => {
|
|
681
|
+
isRunning = false
|
|
682
|
+
})
|
|
683
|
+
})
|
|
684
|
+
return { dispose: () => started.cancel() }
|
|
685
|
+
}
|
|
686
|
+
const mod: Mod<State, Declared> = {
|
|
687
|
+
name: definition.name,
|
|
688
|
+
folder: claude.plugin.root,
|
|
689
|
+
dataFolder: runtime.dataFolder,
|
|
690
|
+
state: modState.state,
|
|
691
|
+
get options() {
|
|
692
|
+
return modOptions.values as OptionValues<Declared>
|
|
603
693
|
},
|
|
604
|
-
agent: { spawn: (args) => checked.agent.spawn(args) },
|
|
605
|
-
model: { complete: (completion, options) => checked.model.complete(completion, options) },
|
|
606
694
|
permissions: {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
695
|
+
get conversation() {
|
|
696
|
+
return flag('conversation')
|
|
697
|
+
},
|
|
698
|
+
get prompt() {
|
|
699
|
+
return flag('prompt')
|
|
700
|
+
},
|
|
701
|
+
get model() {
|
|
702
|
+
return flag('model')
|
|
703
|
+
},
|
|
704
|
+
get tasks() {
|
|
705
|
+
return flag('tasks')
|
|
706
|
+
},
|
|
707
|
+
get tools() {
|
|
708
|
+
return flag('tools')
|
|
709
|
+
},
|
|
710
|
+
get approve() {
|
|
711
|
+
return flag('approve')
|
|
712
|
+
},
|
|
713
|
+
network: allowed('network'),
|
|
714
|
+
run: allowed('run'),
|
|
715
|
+
files: allowed('files'),
|
|
716
|
+
config: allowed('config'),
|
|
612
717
|
},
|
|
613
|
-
|
|
614
|
-
|
|
718
|
+
on(hook: Hook, matcherOrListener: unknown, listenerOrOptions?: unknown, maybeOptions?: HookOptions): Disposable {
|
|
719
|
+
const hasMatcher = typeof listenerOrOptions === 'function'
|
|
720
|
+
const listener = (hasMatcher ? listenerOrOptions : matcherOrListener) as HookListener<Hook>
|
|
721
|
+
const matcher = hasMatcher ? matcherOrListener : undefined
|
|
722
|
+
const options = (hasMatcher ? maybeOptions : listenerOrOptions) as HookOptions | undefined
|
|
723
|
+
if (!hooks.includes(hook)) throw new Error(`${definition.name}: mod.on takes one of Claude Code's hooks, such as PreToolUse, not ${String(hook)}.`)
|
|
724
|
+
const matched: HookListener<Hook> = matcher === undefined ? listener : (input) => (matches(input, matcher) ? listener(input) : undefined)
|
|
725
|
+
const bounded = options?.timeoutMs === undefined ? matched : timedHook(hook, matched, checkedMs(definition.name, `the ${hook} hook's timeoutMs`, options.timeoutMs), claude, toaster.toast)
|
|
726
|
+
if (!hooksAdded.includes(hook)) hooksAdded.push(hook)
|
|
727
|
+
if (hook === PreToolUse) return router.add('tool.call', preToolUseHook(definition.name, bounded as HookListener<typeof PreToolUse>, claude, agents, held))
|
|
728
|
+
const classic: Exclude<Hook, typeof PreToolUse> = hook
|
|
729
|
+
return router.add(`classic.${classic}`, classicHook(definition.name, classic, bounded as HookListener<typeof classic>, claude, agents, report))
|
|
615
730
|
},
|
|
731
|
+
every: (ms, listener) => timer(ms, 'every', claude.clock.every, listener),
|
|
732
|
+
after: (ms, listener) => timer(ms, 'after', claude.clock.after, listener),
|
|
733
|
+
use: (job) => job({ mod, announce, reserveName }),
|
|
734
|
+
log: (text) => claude.ui.log(`${definition.name}: ${text}`, { to: 'debug' }),
|
|
735
|
+
ui: { ...area.ui, notice: (callId, text) => claude.ui.notice(callId, text) },
|
|
736
|
+
settings: settingsPages,
|
|
616
737
|
dependencies: dependencyCalls(claude, (call, task) => beforeDeadline(claude, { ms: dependencyCallMs }, call, task)),
|
|
617
738
|
}
|
|
739
|
+
bind(imports)
|
|
618
740
|
const followSession = async (isAfterCd = false) => {
|
|
619
741
|
try {
|
|
620
742
|
const [nextRoot, reportedCwd] = await Promise.all([claude.session.root(), claude.session.cwd()])
|
|
@@ -625,8 +747,10 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
625
747
|
const nextCwd = staleCwd === undefined ? reportedCwd : nextRoot
|
|
626
748
|
if (!hasMovedRoot && nextCwd === oldCwd) return
|
|
627
749
|
cwd = nextCwd
|
|
750
|
+
followFolders(nextRoot, nextCwd)
|
|
628
751
|
await modState.moveTo(nextRoot).catch((error: unknown) => {
|
|
629
752
|
if (cwd === nextCwd) cwd = loadedCwd
|
|
753
|
+
followFolders(modState.root, cwd)
|
|
630
754
|
throw error
|
|
631
755
|
})
|
|
632
756
|
if (hasMovedRoot) await modOptions.load(nextRoot)
|
|
@@ -636,18 +760,33 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
636
760
|
const moved: HookInput<'CwdChanged'> = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
|
|
637
761
|
await router.dispatch('classic.CwdChanged', moved as Frozen<Args<'classic.CwdChanged'>>, async () => ({}))
|
|
638
762
|
} catch (error) {
|
|
639
|
-
|
|
763
|
+
report({ id: 'cmod:follow', message: `Keeps the state of ${modState.root} until the next prompt or folder move`, error })
|
|
640
764
|
}
|
|
641
765
|
}
|
|
766
|
+
const followCurrent = async (title: string | undefined) => {
|
|
767
|
+
const [id, model, usage] = await Promise.all([claude.session.id(), claude.session.model(), claude.session.usage()])
|
|
768
|
+
followed = { id, title: title ?? '', project: modState.root, cwd, model, startedAt: new Date(usage.startedAt).toISOString(), ...(forkedFrom === undefined ? {} : { forkedFrom }) }
|
|
769
|
+
client.follow(followed)
|
|
770
|
+
}
|
|
642
771
|
router.add('classic.SessionStart', async (e, next) => {
|
|
643
|
-
|
|
644
|
-
|
|
772
|
+
const previous = followed?.id
|
|
773
|
+
if (e.source === 'fork' && previous !== undefined && previous !== e.session_id) forkedFrom = previous
|
|
774
|
+
else if (e.session_id !== previous) {
|
|
775
|
+
forkedFrom = undefined
|
|
776
|
+
parentAsked = undefined
|
|
777
|
+
}
|
|
778
|
+
hasEarlierPrompts = e.source !== 'startup' && e.source !== 'clear'
|
|
779
|
+
await modState.switchSession(e.session_id, e.source, e.transcript_path).catch((error: unknown) => {
|
|
780
|
+
report({ id: 'cmod:session', message: `Kept its session values from before the ${e.source}`, error })
|
|
645
781
|
})
|
|
782
|
+
await followCurrent(e.session_title).catch((error: unknown) => report({ id: 'cmod:current', message: 'Kept the details of the session from before', error }))
|
|
646
783
|
return next(e)
|
|
647
784
|
})
|
|
648
785
|
router.add('classic.UserPromptSubmit', async (e, next) => {
|
|
649
786
|
staleCwd = undefined
|
|
650
787
|
await followSession()
|
|
788
|
+
const isPerson = e.source === undefined || e.source === 'user' || e.source === 'sdk'
|
|
789
|
+
await followCurrent(e.session_title || (isPerson && !hasEarlierPrompts && client.sessions.current.title === '' ? firstLine(e.prompt) : undefined)).catch((error: unknown) => report({ id: 'cmod:current', message: 'Kept the details of the session from before', error }))
|
|
651
790
|
return next(e)
|
|
652
791
|
})
|
|
653
792
|
for (const event of ['classic.PostToolUse', 'classic.PostToolUseFailure'] as const) {
|
|
@@ -663,44 +802,56 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
663
802
|
})
|
|
664
803
|
router.add('skill.prompt', userSkillHook(claude))
|
|
665
804
|
const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input: never) => run(input, mod)]))
|
|
805
|
+
const conversations = definition.name === cmodPluginName ? serveConversations({ claude, router, store: runtime.store, project: () => modState.root, report: (id, message, error) => report({ id, message, error }) }) : undefined
|
|
666
806
|
router.add('cmod.call', async (e, next) => {
|
|
667
807
|
if (e.to !== definition.name) return next(e)
|
|
808
|
+
const call = e as typeof e & { readonly caller?: string }
|
|
809
|
+
if (e.method === conversationMethods.changed && call.caller === cmodPluginName) {
|
|
810
|
+
if (!flag('conversation')) return { value: null }
|
|
811
|
+
const { session: changedSession, change } = e.input as { readonly session: string; readonly change: SessionChange }
|
|
812
|
+
client.changed(changedSession, change)
|
|
813
|
+
return { value: null }
|
|
814
|
+
}
|
|
668
815
|
if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
|
|
669
816
|
if (e.method === openPageMethod) {
|
|
670
817
|
await settingsPages.open(String(e.input))
|
|
671
818
|
return { value: null }
|
|
672
819
|
}
|
|
673
|
-
|
|
820
|
+
const served = await conversations?.answer(call)
|
|
821
|
+
return served ?? answerCall(definition.name, api, e)
|
|
674
822
|
})
|
|
675
823
|
const agents = toolCalls(claude, router)
|
|
824
|
+
keepEngine(mod, { claude: checked, router, toolCalls: agents, report })
|
|
676
825
|
const names = new Set<string>()
|
|
677
|
-
const reserveName = (
|
|
678
|
-
const key = `${
|
|
826
|
+
const reserveName = (type: string, name: string, taken: string): Disposable => {
|
|
827
|
+
const key = `${type}:${name}`
|
|
679
828
|
if (names.has(key)) throw new Error(taken)
|
|
680
829
|
names.add(key)
|
|
830
|
+
return { dispose: () => void names.delete(key) }
|
|
681
831
|
}
|
|
682
832
|
await failsAs('its state did not load', () => modState.load())
|
|
683
833
|
await failsAs('its options did not load', () => modOptions.load(root))
|
|
684
834
|
const missing = modOptions.missing
|
|
685
835
|
if (missing.length > 0) throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key))
|
|
836
|
+
await failsAs('the details of its session did not load', () => followCurrent(undefined))
|
|
686
837
|
await failsAs('its setup function threw', () => definition.setup(mod))
|
|
687
838
|
const permissionHooks = permissionEvents.filter((event) => router.has(event))
|
|
688
839
|
if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
|
|
689
840
|
throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
|
|
690
841
|
}
|
|
691
|
-
if (held !== undefined &&
|
|
842
|
+
if (held !== undefined && hooksAdded.includes(PreToolUse)) router.add('tool.check', heldDecisionHook(held))
|
|
692
843
|
await failsAs('its open panes did not load', () => area.restorePanes())
|
|
693
|
-
if (
|
|
694
|
-
return { mod, added }
|
|
844
|
+
if (hooksAdded.length > 0) added.push(`${hooksAdded.length === 1 ? 'a hook' : 'hooks'} on ${listed(hooksAdded)}`)
|
|
845
|
+
return { mod, added, imports }
|
|
695
846
|
}
|
|
696
847
|
|
|
697
|
-
function checkingKeys(claude: Claude,
|
|
848
|
+
function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string, string>>): Claude {
|
|
698
849
|
const bound = Object.entries(keys)
|
|
699
850
|
if (bound.length === 0) return claude
|
|
700
851
|
const register: Claude['command']['register'] = (command) => {
|
|
701
852
|
for (const [key, commandName] of bound) {
|
|
702
853
|
if (commandName === command.name && command.immediate !== true) {
|
|
703
|
-
|
|
854
|
+
toast.warning(`${key} runs /${command.name}, which waits for Claude's turn to end`, { id: `key:${key}`, description: `It adds a row to the conversation. Give /${command.name} immediate: true.`, duration: Infinity })
|
|
704
855
|
}
|
|
705
856
|
}
|
|
706
857
|
return claude.command.register(command)
|
|
@@ -715,11 +866,11 @@ function checkedMs(name: string, subject: string, ms: number): number {
|
|
|
715
866
|
return ms
|
|
716
867
|
}
|
|
717
868
|
|
|
718
|
-
function timedHook<
|
|
869
|
+
function timedHook<H extends Hook>(event: H, hook: HookListener<H>, ms: number, claude: Claude, toast: Toast): HookListener<H> {
|
|
719
870
|
return (input) =>
|
|
720
871
|
new Promise((resolve, reject) => {
|
|
721
872
|
const timer = claude.clock.after(ms, () => {
|
|
722
|
-
|
|
873
|
+
toast.warning(`The ${event} hook passed its ${ms / 1000} s timeout`, { id: `timeout:${event}` })
|
|
723
874
|
resolve(undefined)
|
|
724
875
|
})
|
|
725
876
|
Promise.resolve()
|
|
@@ -745,25 +896,6 @@ async function failsAs(subject: string, step: () => unknown): Promise<void> {
|
|
|
745
896
|
}
|
|
746
897
|
}
|
|
747
898
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
let lastError = ''
|
|
751
|
-
let piece = await stream.next()
|
|
752
|
-
while (piece.done !== true) {
|
|
753
|
-
if (piece.value.stream === 'stderr') {
|
|
754
|
-
lastError = lastLineOf(piece.value.text) ?? lastError
|
|
755
|
-
} else {
|
|
756
|
-
const lines = `${pending}${piece.value.text}`.split('\n')
|
|
757
|
-
pending = lines.pop() ?? ''
|
|
758
|
-
for (const text of lines) onLine(text)
|
|
759
|
-
}
|
|
760
|
-
piece = await stream.next()
|
|
761
|
-
}
|
|
762
|
-
if (pending !== '') onLine(pending)
|
|
763
|
-
return { code: piece.value.code, lastError }
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
function lastLineOf(text: string): string | undefined {
|
|
767
|
-
const trimmed = text.trim()
|
|
768
|
-
return trimmed === '' ? undefined : trimmed.slice(trimmed.lastIndexOf('\n') + 1).trim()
|
|
899
|
+
function progressOf({ done, total, label }: Extract<RunnerEvent, { type: 'progress' }>): RenderElement {
|
|
900
|
+
return Box({ gap: 2, children: [Progress({ value: total > 0 ? (done / total) * 100 : 0 }), Text({ dimColor: true, wrap: 'truncate-end', children: label })] })
|
|
769
901
|
}
|