@cmodjs/core 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/docs/commands.md +14 -14
- package/docs/dependencies.md +1 -1
- package/docs/hooks.md +69 -13
- package/docs/index.md +7 -5
- package/docs/install-steps.md +19 -19
- package/docs/jobs.md +22 -21
- package/docs/mod.md +44 -12
- package/docs/state.md +8 -8
- package/docs/testing.md +25 -9
- package/docs/ui.md +33 -9
- package/jobs/check.js +2 -2
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +2 -0
- package/jobs/permissions/decide-permission.js +2 -2
- package/jobs/permissions/find-project-scope.d.ts +1 -1
- package/jobs/permissions/find-project-scope.d.ts.map +1 -1
- package/jobs/permissions/find-project-scope.js +9 -9
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions/match-target.js +4 -3
- package/mod.d.ts +15 -5
- package/mod.d.ts.map +1 -1
- package/package.json +2 -1
- package/path.d.ts +2 -0
- package/path.d.ts.map +1 -0
- package/path.js +1 -0
- package/records.js +3 -3
- package/register.d.ts.map +1 -1
- package/register.js +8 -2
- package/runtime/claude.d.ts +4 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/hooks.d.ts +10 -2
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +56 -18
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +17 -7
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +38 -8
- package/shell.d.ts +2 -0
- package/shell.d.ts.map +1 -0
- package/shell.js +1 -0
- package/src/jobs/check.ts +2 -2
- package/src/jobs/context.ts +2 -0
- package/src/jobs/permissions/decide-permission.ts +2 -2
- package/src/jobs/permissions/find-project-scope.ts +11 -10
- package/src/jobs/permissions/match-target.ts +4 -3
- package/src/mod.ts +22 -3
- package/src/path.ts +1 -0
- package/src/records.ts +3 -3
- package/src/register.ts +8 -2
- package/src/runtime/claude.ts +11 -1
- package/src/runtime/hooks.ts +62 -23
- package/src/runtime/lifecycle.ts +16 -9
- package/src/runtime/ui.ts +35 -8
- package/src/shell.ts +1 -0
- package/src/testing/fake-claude.ts +14 -5
- package/src/testing/fake-elements.ts +5 -7
- package/src/testing/fake-files.ts +5 -5
- package/src/testing.ts +83 -11
- package/src/ui/define-pane.ts +5 -1
- package/src/utils/call-effects.ts +12 -11
- package/src/utils/parse-shell.ts +28 -36
- package/src/utils/paths.ts +4 -19
- package/src/vendor.ts +1 -0
- package/testing/fake-claude.d.ts +5 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +13 -3
- package/testing/fake-elements.d.ts +5 -8
- package/testing/fake-elements.d.ts.map +1 -1
- package/testing/fake-elements.js +6 -6
- package/testing/fake-files.js +5 -5
- package/testing.d.ts +4 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +65 -9
- package/ui/define-pane.d.ts +5 -1
- package/ui/define-pane.d.ts.map +1 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +12 -11
- package/utils/parse-shell.d.ts +5 -9
- package/utils/parse-shell.d.ts.map +1 -1
- package/utils/parse-shell.js +5 -10
- package/utils/paths.d.ts +0 -3
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +3 -18
- package/vendor.d.ts +1 -0
- package/vendor.d.ts.map +1 -1
- package/vendor.js +209 -12
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { FileSystem } from '../../utils/paths.js'
|
|
2
|
+
import { basename, dirname, resolve } from '../../vendor.js'
|
|
2
3
|
|
|
3
4
|
export type ProjectScope = { workTreeOf: (path: string) => Promise<string | undefined> }
|
|
4
5
|
|
|
5
6
|
export type Workspace = { projectRoot: string; cwd: string; home: string; fs: FileSystem; scope: ProjectScope | undefined }
|
|
6
7
|
|
|
7
8
|
export async function findProjectScope(pluginRoot: string, home: string, fs: FileSystem): Promise<ProjectScope | undefined> {
|
|
8
|
-
const skills =
|
|
9
|
-
const claude =
|
|
10
|
-
const root =
|
|
11
|
-
if (
|
|
9
|
+
const skills = dirname(pluginRoot)
|
|
10
|
+
const claude = dirname(skills)
|
|
11
|
+
const root = dirname(claude)
|
|
12
|
+
if (basename(skills) !== 'skills' || basename(claude) !== '.claude' || root === resolve('/', home)) return undefined
|
|
12
13
|
const repositories = new Map<string, string>()
|
|
13
14
|
const repositoryOf = async (workTree: string) => {
|
|
14
15
|
const known = repositories.get(workTree)
|
|
@@ -21,7 +22,7 @@ export async function findProjectScope(pluginRoot: string, home: string, fs: Fil
|
|
|
21
22
|
if (project === undefined) return undefined
|
|
22
23
|
return {
|
|
23
24
|
async workTreeOf(path) {
|
|
24
|
-
for (let folder = path; ; folder =
|
|
25
|
+
for (let folder = path; ; folder = dirname(folder)) {
|
|
25
26
|
const repository = await repositoryOf(folder)
|
|
26
27
|
if (repository !== undefined) return repository === project ? folder : undefined
|
|
27
28
|
if (folder === '/') return undefined
|
|
@@ -31,16 +32,16 @@ export async function findProjectScope(pluginRoot: string, home: string, fs: Fil
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
async function readRepository(workTree: string, fs: FileSystem): Promise<string | undefined> {
|
|
34
|
-
const gitPath =
|
|
35
|
+
const gitPath = resolve(workTree, '.git')
|
|
35
36
|
const git = await fs.stat(gitPath).catch(() => undefined)
|
|
36
37
|
if (git === undefined) return undefined
|
|
37
38
|
if (git.kind === 'dir') return git.realPath ?? gitPath
|
|
38
39
|
try {
|
|
39
40
|
const gitDirectory = /^gitdir:\s*(.+)$/m.exec(await fs.read(gitPath))?.[1]?.trim()
|
|
40
41
|
if (gitDirectory === undefined) throw new Error(`${gitPath} has no gitdir line.`)
|
|
41
|
-
const linkedDirectory =
|
|
42
|
-
const commonDirectory = await fs.read(
|
|
43
|
-
(text) =>
|
|
42
|
+
const linkedDirectory = resolve(workTree, gitDirectory)
|
|
43
|
+
const commonDirectory = await fs.read(resolve(linkedDirectory, 'commondir')).then(
|
|
44
|
+
(text) => resolve(linkedDirectory, text.trim()),
|
|
44
45
|
() => linkedDirectory,
|
|
45
46
|
)
|
|
46
47
|
return (await fs.stat(commonDirectory)).realPath ?? commonDirectory
|
|
@@ -2,7 +2,8 @@ import { matchesGlob } from '../../utils/matches-glob.js'
|
|
|
2
2
|
import { parseShell } from '../../utils/parse-shell.js'
|
|
3
3
|
import { callBaseOf, type CallBase, type CallEffects, type CommandCall, type FetchCall, type FileAccess, type FileCall, type ToolCall, type ToolUse } from '../../utils/call-effects.js'
|
|
4
4
|
import type { Workspace } from './find-project-scope.js'
|
|
5
|
-
import {
|
|
5
|
+
import { realPathOf, relativePath } from '../../utils/paths.js'
|
|
6
|
+
import { basename } from '../../vendor.js'
|
|
6
7
|
|
|
7
8
|
export type TargetCalls = { command: CommandCall; read: FileCall; write: FileCall; fetch: FetchCall; subagent: CallBase; tool: CallBase }
|
|
8
9
|
export type TargetKey = keyof TargetCalls
|
|
@@ -56,11 +57,11 @@ export async function matchTarget(target: Target, use: ToolUse, effects: CallEff
|
|
|
56
57
|
function commandPattern(pattern: string): [string, ...string[]] {
|
|
57
58
|
const command = parseShell(pattern).commands[0]
|
|
58
59
|
if (command === undefined) throw new Error(`The command pattern '${pattern}' names no program.`)
|
|
59
|
-
return command
|
|
60
|
+
return command.argv
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
function commandMatches(pattern: [string, ...string[]], argv: [string, ...string[]]): boolean {
|
|
63
|
-
if (
|
|
64
|
+
if (basename(argv[0]) !== basename(pattern[0])) return false
|
|
64
65
|
const wanted = splitArguments(pattern.slice(1))
|
|
65
66
|
const actual = splitArguments(argv.slice(1))
|
|
66
67
|
let next = 0
|
package/src/mod.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AgentSpawnArgs,
|
|
3
|
+
AgentSpawnResult,
|
|
2
4
|
AskOptions,
|
|
3
5
|
ClassicHookInputs,
|
|
4
6
|
CmodDependencies,
|
|
5
7
|
FsEntry,
|
|
8
|
+
FsStat,
|
|
9
|
+
FsStatOptions,
|
|
6
10
|
HookStream,
|
|
7
11
|
HttpInit,
|
|
8
12
|
HttpResponse,
|
|
13
|
+
PaneOpenArgs,
|
|
9
14
|
PermissionRequestDecision,
|
|
10
15
|
ProcessRunInit,
|
|
11
16
|
ProcessRunResult,
|
|
@@ -15,6 +20,8 @@ import type {
|
|
|
15
20
|
RenderElement,
|
|
16
21
|
Settings,
|
|
17
22
|
SettingsReadArgs,
|
|
23
|
+
UiScrollArgs,
|
|
24
|
+
UiScrollResult,
|
|
18
25
|
} from 'claude-code'
|
|
19
26
|
import type { Claude } from './runtime/claude.js'
|
|
20
27
|
import type { RoutedEvent } from './runtime/hooks.js'
|
|
@@ -30,9 +37,9 @@ export type ProgressStep = {
|
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
export type PaneHandle = {
|
|
33
|
-
open(): Promise<void>
|
|
40
|
+
open(options?: Readonly<Pick<PaneOpenArgs, 'focus'>>): Promise<void>
|
|
34
41
|
close(): Promise<void>
|
|
35
|
-
toggle(): Promise<void>
|
|
42
|
+
toggle(options?: Readonly<Pick<PaneOpenArgs, 'focus'>>): Promise<void>
|
|
36
43
|
readonly isOpen: boolean
|
|
37
44
|
}
|
|
38
45
|
|
|
@@ -59,6 +66,7 @@ export type ModEvent =
|
|
|
59
66
|
| 'Stop'
|
|
60
67
|
| 'StopFailure'
|
|
61
68
|
| 'CwdChanged'
|
|
69
|
+
| 'FileChanged'
|
|
62
70
|
|
|
63
71
|
export type HookAnswer = {
|
|
64
72
|
continue?: boolean
|
|
@@ -104,7 +112,9 @@ type HookInputs = Omit<ClassicHookInputs, 'PreToolUse' | 'PostToolUse' | 'PostTo
|
|
|
104
112
|
CwdChanged: Omit<ClassicHookInputs['CwdChanged'], 'transcript_path'>
|
|
105
113
|
}
|
|
106
114
|
|
|
107
|
-
export type
|
|
115
|
+
export type HookInput<E extends ModEvent> = HookInputs[E]
|
|
116
|
+
|
|
117
|
+
export type ModHook<E extends ModEvent> = (input: HookInput<E>) => HookAnswer | void | Promise<HookAnswer | void>
|
|
108
118
|
|
|
109
119
|
export type Mod<State extends object = Record<never, never>> = {
|
|
110
120
|
readonly name: string
|
|
@@ -118,6 +128,7 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
118
128
|
toast(text: string): void
|
|
119
129
|
progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
|
|
120
130
|
ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
|
|
131
|
+
scroll(args: UiScrollArgs): Promise<UiScrollResult>
|
|
121
132
|
}
|
|
122
133
|
readonly process: {
|
|
123
134
|
run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>
|
|
@@ -127,6 +138,8 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
127
138
|
read(path: string): Promise<string>
|
|
128
139
|
write(path: string, text: string): Promise<void>
|
|
129
140
|
list(path?: string): Promise<FsEntry[]>
|
|
141
|
+
exists(path: string): Promise<boolean>
|
|
142
|
+
stat(path: string, options?: FsStatOptions): Promise<FsStat>
|
|
130
143
|
}
|
|
131
144
|
readonly http: {
|
|
132
145
|
fetch(url: string, init?: HttpInit): Promise<HttpResponse>
|
|
@@ -134,6 +147,12 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
134
147
|
readonly settings: {
|
|
135
148
|
read(args?: SettingsReadArgs): Promise<Settings>
|
|
136
149
|
}
|
|
150
|
+
readonly session: {
|
|
151
|
+
messages: Claude['session']['messages']
|
|
152
|
+
}
|
|
153
|
+
readonly agent: {
|
|
154
|
+
spawn(args: AgentSpawnArgs): Promise<AgentSpawnResult>
|
|
155
|
+
}
|
|
137
156
|
readonly projectRoot: string
|
|
138
157
|
readonly cwd: string
|
|
139
158
|
readonly dependencies: CmodDependencies
|
package/src/path.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve } from './vendor.js'
|
package/src/records.ts
CHANGED
|
@@ -28,7 +28,7 @@ export function storeFolder(env: Record<string, string | undefined>): string {
|
|
|
28
28
|
const dataHome = env['XDG_DATA_HOME']
|
|
29
29
|
const home = env['HOME']
|
|
30
30
|
if (dataHome) return `${dataHome}/cmod`
|
|
31
|
-
if (!home) throw new Error('Neither XDG_DATA_HOME nor HOME is set, so the
|
|
31
|
+
if (!home) throw new Error('Neither XDG_DATA_HOME nor HOME is set, so the cmod store has no folder. Set HOME.')
|
|
32
32
|
return `${home}/.local/share/cmod`
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -81,7 +81,7 @@ function parseRecord(text: string, path: string): InstallRecord {
|
|
|
81
81
|
} catch (error) {
|
|
82
82
|
throw new Error(`${path} is not JSON (${messageOf(error)}). ${fix}`)
|
|
83
83
|
}
|
|
84
|
-
if (!isObject(value)) throw new Error(`${path} is not a
|
|
84
|
+
if (!isObject(value)) throw new Error(`${path} is not a cmod install record. ${fix}`)
|
|
85
85
|
for (const key of ['name', 'version', 'root', 'installedAt', 'scriptsSha256'] as const) {
|
|
86
86
|
if (typeof value[key] !== 'string') throw new Error(`${path} has no "${key}" text. ${fix}`)
|
|
87
87
|
}
|
|
@@ -117,7 +117,7 @@ function readCommand(steps: Record<string, unknown>, key: 'install' | 'uninstall
|
|
|
117
117
|
if (typeof command !== 'string' || command.trim() === '') throw new Error(`package.json "cmod.${key}" must be a command, such as "./setup/${key}.sh".`)
|
|
118
118
|
if (/[\n\t]/.test(command)) throw new Error(`package.json "cmod.${key}" holds a line break or a tab. Write one command, such as "./setup/${key}.sh".`)
|
|
119
119
|
const atRoot = scriptPaths(command).find((path) => folderOf(path) === '.')
|
|
120
|
-
if (atRoot !== undefined) throw new Error(`package.json "cmod.${key}" names ${atRoot}, a script at the plugin root. Move it into a folder, such as ./setup/${key}.sh:
|
|
120
|
+
if (atRoot !== undefined) throw new Error(`package.json "cmod.${key}" names ${atRoot}, a script at the plugin root. Move it into a folder, such as ./setup/${key}.sh: cmod asks consent for the whole folder of each script.`)
|
|
121
121
|
return { [key]: command }
|
|
122
122
|
}
|
|
123
123
|
|
package/src/register.ts
CHANGED
|
@@ -21,6 +21,7 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
|
|
|
21
21
|
open: (pane) => $.ui.open(pane),
|
|
22
22
|
close: (pane) => $.ui.close(pane),
|
|
23
23
|
panes: () => $.ui.panes(),
|
|
24
|
+
scroll: (args) => $.ui.scroll(args),
|
|
24
25
|
resolve: (render) => $.ui.resolve(render),
|
|
25
26
|
},
|
|
26
27
|
process: {
|
|
@@ -54,10 +55,14 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
|
|
|
54
55
|
model: () => $.session.model(),
|
|
55
56
|
usage: () => $.session.usage(),
|
|
56
57
|
surfaces: () => $.session.surfaces(),
|
|
58
|
+
messages: ((args?: Parameters<Claude['session']['messages']>[0]) => (args === undefined ? $.session.messages() : $.session.messages(args))) as Claude['session']['messages'],
|
|
57
59
|
},
|
|
58
60
|
command: { register: (command) => $.command.register(command) },
|
|
59
61
|
tool: { register: (tool) => $.tool.register(tool) },
|
|
60
|
-
agent: {
|
|
62
|
+
agent: {
|
|
63
|
+
list: () => $.agent.list(),
|
|
64
|
+
spawn: (args) => $.agent.spawn(args),
|
|
65
|
+
},
|
|
61
66
|
env: {
|
|
62
67
|
home: () => $.env.get('HOME'),
|
|
63
68
|
dataHome: () => $.env.get('XDG_DATA_HOME'),
|
|
@@ -91,6 +96,7 @@ export function registerMod<State extends object>(addHook: On, definition: ModDe
|
|
|
91
96
|
addHook('classic.PreCompact', routeToMod)
|
|
92
97
|
addHook('classic.Stop', routeToMod)
|
|
93
98
|
addHook('classic.StopFailure', routeToMod)
|
|
99
|
+
addHook('classic.FileChanged', routeToMod)
|
|
94
100
|
addHook('tool.call', routeToMod)
|
|
95
101
|
addHook('prompt.submit', routeToMod)
|
|
96
102
|
addHook('prompt.context', routeToMod)
|
|
@@ -99,6 +105,7 @@ export function registerMod<State extends object>(addHook: On, definition: ModDe
|
|
|
99
105
|
addHook('skill.prompt', routeToMod)
|
|
100
106
|
addHook('ui.render', routeToMod)
|
|
101
107
|
addHook('ui.press', routeToMod)
|
|
108
|
+
addHook('ui.scroll', routeToMod)
|
|
102
109
|
addHook('ui.close', routeToMod)
|
|
103
110
|
addHook('cmod.call', routeToMod)
|
|
104
111
|
}
|
|
@@ -106,6 +113,5 @@ export function registerMod<State extends object>(addHook: On, definition: ModDe
|
|
|
106
113
|
export function registerPermissionCheck(addHook: On): void {
|
|
107
114
|
checksPermissions = true
|
|
108
115
|
addHook('tool.check', routeToMod)
|
|
109
|
-
addHook('classic.PreToolUse', routeToMod)
|
|
110
116
|
addHook('classic.PermissionRequest', routeToMod)
|
|
111
117
|
}
|
package/src/runtime/claude.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AgentInfo,
|
|
3
|
+
AgentSpawnArgs,
|
|
4
|
+
AgentSpawnResult,
|
|
3
5
|
Args,
|
|
6
|
+
EngineInterface,
|
|
4
7
|
AskOptions,
|
|
5
8
|
CommandSpec,
|
|
6
9
|
ElementTable,
|
|
@@ -29,6 +32,8 @@ import type {
|
|
|
29
32
|
UiLogOptions,
|
|
30
33
|
UiOpenResult,
|
|
31
34
|
UiPane,
|
|
35
|
+
UiScrollArgs,
|
|
36
|
+
UiScrollResult,
|
|
32
37
|
} from 'claude-code'
|
|
33
38
|
|
|
34
39
|
export type Claude = {
|
|
@@ -43,6 +48,7 @@ export type Claude = {
|
|
|
43
48
|
open(pane: PaneOpenArgs): Promise<UiOpenResult>
|
|
44
49
|
close(pane: PaneCloseArgs): Promise<void>
|
|
45
50
|
panes(): Promise<readonly UiPane[]>
|
|
51
|
+
scroll(args: UiScrollArgs): Promise<UiScrollResult>
|
|
46
52
|
resolve(e: ResolveInput): ElementTable<RenderSurface>
|
|
47
53
|
}
|
|
48
54
|
readonly process: {
|
|
@@ -76,10 +82,14 @@ export type Claude = {
|
|
|
76
82
|
model(): Promise<string>
|
|
77
83
|
usage(): Promise<SessionUsage>
|
|
78
84
|
surfaces(): Promise<readonly RenderSurface[]>
|
|
85
|
+
messages: EngineInterface['session']['messages']
|
|
79
86
|
}
|
|
80
87
|
readonly command: { register(command: CommandSpec): Promise<{ command: string }> }
|
|
81
88
|
readonly tool: { register(tool: ToolSpec): Promise<{ tool: string }> }
|
|
82
|
-
readonly agent: {
|
|
89
|
+
readonly agent: {
|
|
90
|
+
list(): Promise<AgentInfo[]>
|
|
91
|
+
spawn(args: AgentSpawnArgs): Promise<AgentSpawnResult>
|
|
92
|
+
}
|
|
83
93
|
readonly env: {
|
|
84
94
|
home(): Promise<string | undefined>
|
|
85
95
|
dataHome(): Promise<string | undefined>
|
package/src/runtime/hooks.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import type { Args, ClassicHookInputs, Frozen } from 'claude-code'
|
|
2
|
-
import type { HookAnswer, ModEvent, ModHook } from '../mod.js'
|
|
1
|
+
import type { Args, ClassicHookInputs, EventResult, Frozen } from 'claude-code'
|
|
2
|
+
import type { HookAnswer, HookInput, ModEvent, ModHook } from '../mod.js'
|
|
3
3
|
import { configFolders } from '../records.js'
|
|
4
4
|
import type { Claude } from './claude.js'
|
|
5
5
|
import type { RoutedHook } from './router.js'
|
|
6
|
-
import { toolInputOf, type ToolCalls } from './tool-calls.js'
|
|
6
|
+
import { reservedKeys, toolInputOf, type ToolCalls } from './tool-calls.js'
|
|
7
7
|
import { callEffects, type FileAccess, type ToolUse } from '../utils/call-effects.js'
|
|
8
8
|
import { dynamicPattern } from '../utils/parse-shell.js'
|
|
9
9
|
import { messageOf } from '../utils/text.js'
|
|
10
10
|
|
|
11
11
|
export type RoutedEvent =
|
|
12
|
-
| `classic.${ModEvent}`
|
|
12
|
+
| `classic.${Exclude<ModEvent, 'PreToolUse'>}`
|
|
13
13
|
| 'tool.check'
|
|
14
14
|
| 'tool.call'
|
|
15
15
|
| 'prompt.submit'
|
|
@@ -19,16 +19,15 @@ export type RoutedEvent =
|
|
|
19
19
|
| 'skill.prompt'
|
|
20
20
|
| 'ui.render'
|
|
21
21
|
| 'ui.press'
|
|
22
|
+
| 'ui.scroll'
|
|
22
23
|
| 'ui.close'
|
|
23
24
|
| 'cmod.call'
|
|
24
25
|
|
|
25
|
-
export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.
|
|
26
|
+
export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
|
|
26
27
|
|
|
27
28
|
type ClassicFields = Record<string, unknown>
|
|
28
29
|
|
|
29
|
-
type
|
|
30
|
-
|
|
31
|
-
type CallFiles = PreToolUseInput['files']
|
|
30
|
+
type CallFiles = HookInput<'PreToolUse'>['files']
|
|
32
31
|
|
|
33
32
|
const flags: readonly string[] = ['suppressOriginalPrompt', 'reloadSkills', 'retry']
|
|
34
33
|
|
|
@@ -50,18 +49,18 @@ const readFields: Record<ModEvent, readonly string[]> = {
|
|
|
50
49
|
Stop: ['additionalContext'],
|
|
51
50
|
StopFailure: [],
|
|
52
51
|
CwdChanged: [],
|
|
52
|
+
FileChanged: [],
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function classicHook<E extends ModEvent
|
|
55
|
+
export function classicHook<E extends Exclude<ModEvent, 'PreToolUse'>>(name: string, event: E, hook: ModHook<E>, claude: Claude, calls: ToolCalls): RoutedHook<RoutedEvent> {
|
|
56
56
|
const inputOf = async (e: unknown): Promise<unknown> => {
|
|
57
|
-
if (event === 'PreToolUse') return preToolUseInput(name, e as Frozen<Args<'classic.PreToolUse'>>, claude, calls.agentOf)
|
|
58
57
|
if (event !== 'PostToolUse' && event !== 'PostToolUseFailure') return e
|
|
59
58
|
const input = e as ClassicHookInputs['PostToolUse' | 'PostToolUseFailure']
|
|
60
59
|
const cwd = calls.cwdOf(input.tool_use_id) ?? input.cwd
|
|
61
60
|
return { ...input, files: await callFiles(name, claude, { tool: input.tool_name, input: input.tool_input }, cwd) }
|
|
62
61
|
}
|
|
63
62
|
const resultOf = async (e: unknown): Promise<ClassicFields | undefined> => {
|
|
64
|
-
const answer = await hook((await inputOf(e)) as
|
|
63
|
+
const answer = await hook((await inputOf(e)) as HookInput<E>)
|
|
65
64
|
if (answer === undefined) return undefined
|
|
66
65
|
if (answer.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
|
|
67
66
|
return classicResult(event, answer)
|
|
@@ -69,17 +68,65 @@ export function classicHook<E extends ModEvent>(name: string, event: E, hook: Mo
|
|
|
69
68
|
const routed = async (e: unknown, next: (e: unknown) => Promise<unknown>) => {
|
|
70
69
|
const ours = await resultOf(e).catch((error: unknown): ClassicFields | undefined => {
|
|
71
70
|
const reason = `${name}: the ${event} hook failed: ${messageOf(error)}`
|
|
72
|
-
if (event === 'PreToolUse') return { deny: reason }
|
|
73
71
|
if (event === 'PermissionRequest') return { decision: { behavior: 'deny', message: reason } }
|
|
74
72
|
claude.ui.log(reason)
|
|
75
73
|
return undefined
|
|
76
74
|
})
|
|
77
75
|
if (ours === undefined) return next(e)
|
|
78
|
-
return mergeClassic(
|
|
76
|
+
return mergeClassic((await next(e)) as ClassicFields, ours)
|
|
79
77
|
}
|
|
80
78
|
return routed as unknown as RoutedHook<RoutedEvent>
|
|
81
79
|
}
|
|
82
80
|
|
|
81
|
+
type HeldDecision = { readonly decision: 'allow' | 'ask'; readonly reason?: string }
|
|
82
|
+
|
|
83
|
+
export type HeldDecisions = Map<string, HeldDecision>
|
|
84
|
+
|
|
85
|
+
export function preToolUseHook(name: string, hook: ModHook<'PreToolUse'>, claude: Claude, calls: ToolCalls, held: HeldDecisions | undefined): RoutedHook<'tool.call'> {
|
|
86
|
+
return async (e, next) => {
|
|
87
|
+
let ours: ClassicFields
|
|
88
|
+
try {
|
|
89
|
+
const answer = await hook(await preToolUseInput(name, e, claude, calls.agentOf))
|
|
90
|
+
if (answer?.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
|
|
91
|
+
ours = answer === undefined ? {} : classicResult('PreToolUse', answer)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return { deny: `${name}: the PreToolUse hook failed: ${messageOf(error)}` }
|
|
94
|
+
}
|
|
95
|
+
if (typeof ours['deny'] === 'string') return { deny: ours['deny'] }
|
|
96
|
+
const decided = heldDecisionOf(ours)
|
|
97
|
+
if (decided !== undefined && held === undefined) {
|
|
98
|
+
return { deny: `${name}: the PreToolUse hook answered permissionDecision "${decided.decision}", so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.` }
|
|
99
|
+
}
|
|
100
|
+
const updated = ours['updatedInput'] as Record<string, unknown> | undefined
|
|
101
|
+
const reserved = Object.fromEntries(Object.entries(e).filter(([key]) => reservedKeys.includes(key)))
|
|
102
|
+
const earlier = held?.get(e.tool_use_id)
|
|
103
|
+
if (decided !== undefined) held?.set(e.tool_use_id, earlier !== undefined && strictness.indexOf(earlier.decision) >= strictness.indexOf(decided.decision) ? earlier : decided)
|
|
104
|
+
let result: EventResult<'tool.call'>
|
|
105
|
+
try {
|
|
106
|
+
result = await next(updated === undefined ? e : ({ ...reserved, ...updated } as Frozen<Args<'tool.call'>>))
|
|
107
|
+
} finally {
|
|
108
|
+
if (decided !== undefined) held?.delete(e.tool_use_id)
|
|
109
|
+
}
|
|
110
|
+
const context = ours['additionalContext'] as string[] | undefined
|
|
111
|
+
if (context === undefined || result.deny !== undefined) return result
|
|
112
|
+
return { ...result, context: [...(result.context ?? []), ...context] }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function heldDecisionHook(held: HeldDecisions): RoutedHook<'tool.check'> {
|
|
117
|
+
return async (e, next) => {
|
|
118
|
+
const below = await next(e)
|
|
119
|
+
const ours = e.tool_use_id === undefined ? undefined : held.get(e.tool_use_id)
|
|
120
|
+
return ours === undefined || below.decision === 'deny' ? below : ours
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function heldDecisionOf(ours: ClassicFields): HeldDecision | undefined {
|
|
125
|
+
if (ours['allow'] !== undefined) return { decision: 'allow' }
|
|
126
|
+
if (typeof ours['ask'] !== 'string') return undefined
|
|
127
|
+
return ours['ask'] === '' ? { decision: 'ask' } : { decision: 'ask', reason: ours['ask'] }
|
|
128
|
+
}
|
|
129
|
+
|
|
83
130
|
const frontmatter = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/
|
|
84
131
|
|
|
85
132
|
const baseDirectoryLine = /^Base directory for this skill: [^\n]*\n\n/
|
|
@@ -101,7 +148,7 @@ export function userSkillHook(claude: Claude): RoutedHook<'skill.prompt'> {
|
|
|
101
148
|
}
|
|
102
149
|
}
|
|
103
150
|
|
|
104
|
-
async function preToolUseInput(name: string, envelope: Frozen<Args<'
|
|
151
|
+
async function preToolUseInput(name: string, envelope: Frozen<Args<'tool.call'>>, claude: Claude, agentOf: ToolCalls['agentOf']): Promise<HookInput<'PreToolUse'>> {
|
|
105
152
|
const { tool, tool_use_id } = envelope
|
|
106
153
|
const [session_id, cwd, { agentId, agentType }] = await Promise.all([claude.session.id(), claude.session.cwd(), agentOf(tool_use_id)])
|
|
107
154
|
const tool_input = toolInputOf(envelope)
|
|
@@ -161,17 +208,9 @@ function classicResult(event: ModEvent, answer: HookAnswer): ClassicFields {
|
|
|
161
208
|
|
|
162
209
|
export const strictness = ['allow', 'ask', 'deny'] as const
|
|
163
210
|
|
|
164
|
-
function
|
|
165
|
-
return strictness.findLastIndex((decision) => result[decision] !== undefined)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function mergeClassic(event: ModEvent, below: ClassicFields, ours: ClassicFields): ClassicFields {
|
|
211
|
+
function mergeClassic(below: ClassicFields, ours: ClassicFields): ClassicFields {
|
|
169
212
|
const merged: ClassicFields = { ...below, ...ours }
|
|
170
213
|
const context = [...((below['additionalContext'] as string[] | undefined) ?? []), ...((ours['additionalContext'] as string[] | undefined) ?? [])]
|
|
171
214
|
if (context.length > 0) merged['additionalContext'] = context
|
|
172
|
-
if (event !== 'PreToolUse') return merged
|
|
173
|
-
const strictest = strictness[Math.max(strictnessOf(below), strictnessOf(ours))]
|
|
174
|
-
for (const decision of strictness) delete merged[decision]
|
|
175
|
-
if (strictest !== undefined) merged[strictest] = ours[strictest] ?? below[strictest]
|
|
176
215
|
return merged
|
|
177
216
|
}
|
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { Args, EventResult, Frozen, HookBudget, HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
|
|
2
|
-
import type { Mod, ModDefinition, ModEvent, ModHook } from '../mod.js'
|
|
2
|
+
import type { HookInput, Mod, ModDefinition, ModEvent, ModHook } from '../mod.js'
|
|
3
3
|
import { dataFolder, isAtLeast, parseEvent, readRecord, readSteps, scriptsSha256, storeFolder, type ReadFile, type RunnerEvent } from '../records.js'
|
|
4
4
|
import { relativePath } from '../utils/paths.js'
|
|
5
5
|
import { formatExit, listed, messageOf } from '../utils/text.js'
|
|
6
6
|
import type { Claude } from './claude.js'
|
|
7
7
|
import { beforeDeadline } from './deadline.js'
|
|
8
8
|
import { answerCall, dependencyCalls, notInstalled } from './dependencies.js'
|
|
9
|
-
import { classicHook, permissionEvents, userSkillHook, type RoutedEvent } from './hooks.js'
|
|
9
|
+
import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook, type HeldDecisions, type RoutedEvent } from './hooks.js'
|
|
10
10
|
import { createRouter, type Router, type RouterNext } from './router.js'
|
|
11
11
|
import { createState } from './state.js'
|
|
12
12
|
import { toolCalls } from './tool-calls.js'
|
|
@@ -172,7 +172,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
172
172
|
|
|
173
173
|
const record = () => {
|
|
174
174
|
recording ??= recordThroughCmod()
|
|
175
|
-
.catch((error: unknown) => claude().ui.log(`${definition.name} has no
|
|
175
|
+
.catch((error: unknown) => claude().ui.log(`${definition.name} has no cmod record yet: ${messageOf(error)}`, { to: 'debug' }))
|
|
176
176
|
.finally(() => {
|
|
177
177
|
recording = undefined
|
|
178
178
|
})
|
|
@@ -227,7 +227,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
227
227
|
if (plugin === undefined) return
|
|
228
228
|
if (plugin.name === cmodPluginName) return bootstrap(plugin.root)
|
|
229
229
|
phase = 'installing'
|
|
230
|
-
if ((await cmodVersion(claude())) === undefined) return
|
|
230
|
+
if ((await cmodVersion(claude())) === undefined) return waitForcmod()
|
|
231
231
|
endLine()
|
|
232
232
|
const progress = showLine()
|
|
233
233
|
let outcome: RunnerEvent | undefined
|
|
@@ -243,9 +243,9 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
243
243
|
fail(reason, `Fix the cause, then run: cmod install ${definition.name}`)
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
const
|
|
246
|
+
const waitForcmod = () => {
|
|
247
247
|
phase = 'waiting'
|
|
248
|
-
showLine().wait('Waiting for
|
|
248
|
+
showLine().wait('Waiting for cmod')
|
|
249
249
|
let check: Promise<void> | undefined
|
|
250
250
|
const stop = () => {
|
|
251
251
|
timer.cancel()
|
|
@@ -264,7 +264,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
264
264
|
report(error)
|
|
265
265
|
})
|
|
266
266
|
})
|
|
267
|
-
const longWait = claude().clock.after(cmodWaitMs, () => showLine().wait("Still waiting for
|
|
267
|
+
const longWait = claude().clock.after(cmodWaitMs, () => showLine().wait("Still waiting for cmod to download cmod. See cmod's own line."))
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
const bootstrap = async (root: string) => {
|
|
@@ -362,13 +362,15 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
362
362
|
let staleCwd: string | undefined
|
|
363
363
|
const area = createUi<State>({ name: definition.name, claude, router, progress, announce, mod: () => mod })
|
|
364
364
|
const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, changed: area.changed })
|
|
365
|
+
const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
|
|
365
366
|
const mod: Mod<State> = {
|
|
366
367
|
name: definition.name,
|
|
367
368
|
state: modState.state,
|
|
368
369
|
dataFolder: runtime.dataFolder,
|
|
369
370
|
on(event, hook) {
|
|
370
371
|
if (!hookEvents.includes(event)) hookEvents.push(event)
|
|
371
|
-
router.add(
|
|
372
|
+
if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, hook as ModHook<'PreToolUse'>, claude, agents, held))
|
|
373
|
+
else router.add(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, classicHook(definition.name, event as Exclude<ModEvent, 'PreToolUse'>, hook as ModHook<Exclude<ModEvent, 'PreToolUse'>>, claude, agents))
|
|
372
374
|
},
|
|
373
375
|
use: (job) => job({ mod, claude, on: (event, hook) => router.add(event, hook), announce, reserveName, toolCalls: agents }),
|
|
374
376
|
ui: area.ui,
|
|
@@ -380,9 +382,13 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
380
382
|
read: (path) => claude.fs.read(path),
|
|
381
383
|
write: (path, text) => claude.fs.write(path, text),
|
|
382
384
|
list: (path) => claude.fs.list(path),
|
|
385
|
+
exists: (path) => claude.fs.exists(path),
|
|
386
|
+
stat: (path, options) => claude.fs.stat(path, options),
|
|
383
387
|
},
|
|
384
388
|
http: { fetch: (url, init) => claude.http.fetch(url, init) },
|
|
385
389
|
settings: { read: (args) => claude.settings.read(args) },
|
|
390
|
+
session: { messages: claude.session.messages },
|
|
391
|
+
agent: { spawn: (args) => claude.agent.spawn(args) },
|
|
386
392
|
get projectRoot() {
|
|
387
393
|
return modState.root
|
|
388
394
|
},
|
|
@@ -408,7 +414,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
408
414
|
loadedCwd = nextCwd
|
|
409
415
|
if (!hasMovedRoot) area.changed()
|
|
410
416
|
if (nextCwd === oldCwd) return
|
|
411
|
-
const moved:
|
|
417
|
+
const moved: HookInput<'CwdChanged'> = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
|
|
412
418
|
await router.dispatch('classic.CwdChanged', moved as Frozen<Args<'classic.CwdChanged'>>, async () => ({}))
|
|
413
419
|
} catch (error) {
|
|
414
420
|
claude.ui.log(`${definition.name} keeps the state of ${modState.root} until the next prompt or folder move: ${messageOf(error)}`)
|
|
@@ -452,6 +458,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
452
458
|
if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
|
|
453
459
|
throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
|
|
454
460
|
}
|
|
461
|
+
if (held !== undefined && hookEvents.includes('PreToolUse')) router.add('tool.check', heldDecisionHook(held))
|
|
455
462
|
await failsAs('its open panes did not load', () => area.restorePanes())
|
|
456
463
|
if (hookEvents.length > 0) added.push(`${hookEvents.length === 1 ? 'a hook' : 'hooks'} on ${listed(hookEvents)}`)
|
|
457
464
|
return { mod, added }
|
package/src/runtime/ui.ts
CHANGED
|
@@ -224,6 +224,7 @@ export type UiArea<State extends object> = {
|
|
|
224
224
|
|
|
225
225
|
export function createUi<State extends object>({ name, claude, router, progress, announce, mod }: UiOptions<State>): UiArea<State> {
|
|
226
226
|
const panes = new Map<string, Pane<State>>()
|
|
227
|
+
const paneLogs = new Map<string, (failure: string) => void>()
|
|
227
228
|
const openPanes = new Set<string>()
|
|
228
229
|
const requestedSizes = new Map<string, PaneSize>()
|
|
229
230
|
const renders = new Set<string>()
|
|
@@ -291,9 +292,10 @@ export function createUi<State extends object>({ name, claude, router, progress,
|
|
|
291
292
|
})
|
|
292
293
|
}
|
|
293
294
|
|
|
294
|
-
const openAt = (pane: Pane<State>, size: PaneSize) => {
|
|
295
|
+
const openAt = (pane: Pane<State>, size: PaneSize, focus?: true) => {
|
|
295
296
|
requestedSizes.set(pane.id, size)
|
|
296
|
-
|
|
297
|
+
const { id, title, closeOnEscape, holdToasts } = pane
|
|
298
|
+
return claude.ui.open({ id, title, ...size, ...(closeOnEscape === true ? { closeOnEscape } : {}), ...(holdToasts === true ? { holdToasts } : {}), ...(focus === true ? { focus } : {}) })
|
|
297
299
|
}
|
|
298
300
|
|
|
299
301
|
const resize = async (pane: Pane<State>) => {
|
|
@@ -315,34 +317,58 @@ export function createUi<State extends object>({ name, claude, router, progress,
|
|
|
315
317
|
if (pane === undefined || e.component !== 'Pane') return next(e)
|
|
316
318
|
openPanes.add(pane.id)
|
|
317
319
|
const table = claude.ui.resolve(e)
|
|
318
|
-
|
|
320
|
+
try {
|
|
321
|
+
return drawWith(table, () => pane.render(mod(), e.props), markdownIn(table))
|
|
322
|
+
} catch (error) {
|
|
323
|
+
paneLogs.get(pane.id)?.(`threw: ${messageOf(error)}`)
|
|
324
|
+
return drawWith(table, () => Text({ color: 'error', children: `The ${pane.title} pane could not draw: ${messageOf(error)}` }))
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
router.add('ui.scroll', async (e, next) => {
|
|
328
|
+
const pane = panes.get(e.requestId)
|
|
329
|
+
const moved = await next(e)
|
|
330
|
+
if (pane?.onScroll === undefined || e.component !== 'Pane' || moved.deny !== undefined) return moved
|
|
331
|
+
await handle(pane, 'onScroll', () => pane.onScroll?.(mod(), e))
|
|
332
|
+
return moved
|
|
319
333
|
})
|
|
320
|
-
router.add('ui.close', (e, next) => {
|
|
334
|
+
router.add('ui.close', async (e, next) => {
|
|
321
335
|
openPanes.delete(e.id)
|
|
322
|
-
|
|
336
|
+
const closed = await next(e)
|
|
337
|
+
const pane = panes.get(e.id)
|
|
338
|
+
if (pane?.onClose !== undefined) await handle(pane, 'onClose', () => pane.onClose?.(mod(), e))
|
|
339
|
+
return closed
|
|
323
340
|
})
|
|
324
341
|
}
|
|
325
342
|
|
|
343
|
+
const handle = async (pane: Pane<State>, handler: string, run: () => unknown) => {
|
|
344
|
+
try {
|
|
345
|
+
await run()
|
|
346
|
+
} catch (error) {
|
|
347
|
+
claude.ui.log(`${name}: the ${pane.title} pane's ${handler} threw: ${messageOf(error)}`)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
326
351
|
return {
|
|
327
352
|
ui: {
|
|
328
353
|
pane(pane) {
|
|
329
354
|
if (panes.has(pane.id)) throw new Error(`${name}: the pane "${pane.id}" is already added. Give each pane its own id.`)
|
|
330
355
|
panes.set(pane.id, pane)
|
|
356
|
+
paneLogs.set(pane.id, logOnce(`${pane.title} pane`))
|
|
331
357
|
announce(`the ${pane.title} pane`)
|
|
332
358
|
route()
|
|
333
359
|
const handle: PaneHandle = {
|
|
334
360
|
get isOpen() {
|
|
335
361
|
return openPanes.has(pane.id)
|
|
336
362
|
},
|
|
337
|
-
async open() {
|
|
338
|
-
const { isPlaced } = await openAt(pane, paneSize(pane, mod().state))
|
|
363
|
+
async open(options) {
|
|
364
|
+
const { isPlaced } = await openAt(pane, paneSize(pane, mod().state), options?.focus)
|
|
339
365
|
if (isPlaced) openPanes.add(pane.id)
|
|
340
366
|
},
|
|
341
367
|
async close() {
|
|
342
368
|
await claude.ui.close({ id: pane.id })
|
|
343
369
|
openPanes.delete(pane.id)
|
|
344
370
|
},
|
|
345
|
-
toggle: () => (openPanes.has(pane.id) ? handle.close() : handle.open()),
|
|
371
|
+
toggle: (options) => (openPanes.has(pane.id) ? handle.close() : handle.open(options)),
|
|
346
372
|
}
|
|
347
373
|
return handle
|
|
348
374
|
},
|
|
@@ -386,6 +412,7 @@ export function createUi<State extends object>({ name, claude, router, progress,
|
|
|
386
412
|
}
|
|
387
413
|
},
|
|
388
414
|
ask: (question, options) => claude.ui.ask(question, options),
|
|
415
|
+
scroll: (args) => claude.ui.scroll(args),
|
|
389
416
|
},
|
|
390
417
|
changed() {
|
|
391
418
|
if (panes.size === 0 && renders.size === 0) return
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { parseShell, type ParsedShell, type ShellCommand } from './utils/parse-shell.js'
|