@cmodjs/core 0.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. package/docs/commands.md +61 -10
  2. package/docs/dependencies.md +16 -14
  3. package/docs/hooks.md +64 -29
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +13 -9
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +227 -175
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +311 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +68 -23
  13. package/docs/ui.md +116 -76
  14. package/jobs/check.d.ts.map +1 -1
  15. package/jobs/check.js +5 -2
  16. package/jobs/context.d.ts +7 -8
  17. package/jobs/context.d.ts.map +1 -1
  18. package/jobs/context.js +23 -73
  19. package/jobs/permissions/decide-permission.d.ts +5 -3
  20. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  21. package/jobs/permissions/decide-permission.js +6 -4
  22. package/jobs/permissions/find-project-scope.d.ts +1 -1
  23. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  24. package/jobs/permissions/find-project-scope.js +8 -11
  25. package/jobs/permissions/match-target.d.ts +2 -2
  26. package/jobs/permissions/match-target.d.ts.map +1 -1
  27. package/jobs/permissions.d.ts +1 -1
  28. package/jobs/permissions.d.ts.map +1 -1
  29. package/jobs/permissions.js +6 -4
  30. package/jobs/program.d.ts.map +1 -1
  31. package/jobs/program.js +14 -4
  32. package/jobs/prompt.d.ts +4 -2
  33. package/jobs/prompt.d.ts.map +1 -1
  34. package/jobs/prompt.js +16 -15
  35. package/jobs/schedule.d.ts +2 -2
  36. package/jobs/schedule.d.ts.map +1 -1
  37. package/jobs/schedule.js +9 -9
  38. package/jobs/slash-command.d.ts +4 -4
  39. package/jobs/slash-command.d.ts.map +1 -1
  40. package/jobs/slash-command.js +14 -9
  41. package/jobs/status-line.d.ts +1 -11
  42. package/jobs/status-line.d.ts.map +1 -1
  43. package/jobs/status-line.js +8 -12
  44. package/jobs/tool.d.ts +2 -2
  45. package/jobs/tool.d.ts.map +1 -1
  46. package/jobs/tool.js +15 -9
  47. package/mod.d.ts +178 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +127 -3
  50. package/options.d.ts +2 -2
  51. package/options.js +19 -19
  52. package/package.json +3 -3
  53. package/records.d.ts +13 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +33 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +34 -5
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +68 -0
  61. package/runtime/before.d.ts +32 -0
  62. package/runtime/before.d.ts.map +1 -0
  63. package/runtime/before.js +214 -0
  64. package/runtime/bound.d.ts +4 -0
  65. package/runtime/bound.d.ts.map +1 -0
  66. package/runtime/bound.js +9 -0
  67. package/runtime/claude.d.ts +4 -2
  68. package/runtime/claude.d.ts.map +1 -1
  69. package/runtime/conversations.d.ts +17 -0
  70. package/runtime/conversations.d.ts.map +1 -0
  71. package/runtime/conversations.js +563 -0
  72. package/runtime/deadline.d.ts +0 -1
  73. package/runtime/deadline.d.ts.map +1 -1
  74. package/runtime/deadline.js +1 -1
  75. package/runtime/dependencies.d.ts +2 -0
  76. package/runtime/dependencies.d.ts.map +1 -1
  77. package/runtime/dependencies.js +10 -1
  78. package/runtime/engine.d.ts +13 -0
  79. package/runtime/engine.d.ts.map +1 -0
  80. package/runtime/engine.js +10 -0
  81. package/runtime/grants.d.ts +4 -0
  82. package/runtime/grants.d.ts.map +1 -1
  83. package/runtime/grants.js +68 -31
  84. package/runtime/hooks.d.ts +5 -4
  85. package/runtime/hooks.d.ts.map +1 -1
  86. package/runtime/hooks.js +29 -9
  87. package/runtime/installer.d.ts +1 -1
  88. package/runtime/installer.d.ts.map +1 -1
  89. package/runtime/installer.js +17 -14
  90. package/runtime/lifecycle.d.ts +3 -1
  91. package/runtime/lifecycle.d.ts.map +1 -1
  92. package/runtime/lifecycle.js +414 -212
  93. package/runtime/metadata.d.ts +3 -1
  94. package/runtime/metadata.d.ts.map +1 -1
  95. package/runtime/metadata.js +25 -14
  96. package/runtime/options.d.ts +3 -1
  97. package/runtime/options.d.ts.map +1 -1
  98. package/runtime/options.js +4 -4
  99. package/runtime/programs.d.ts +8 -0
  100. package/runtime/programs.d.ts.map +1 -1
  101. package/runtime/programs.js +39 -1
  102. package/runtime/router.d.ts +2 -1
  103. package/runtime/router.d.ts.map +1 -1
  104. package/runtime/router.js +11 -3
  105. package/runtime/sessions.d.ts +58 -0
  106. package/runtime/sessions.d.ts.map +1 -0
  107. package/runtime/sessions.js +433 -0
  108. package/runtime/state.d.ts +6 -2
  109. package/runtime/state.d.ts.map +1 -1
  110. package/runtime/state.js +46 -13
  111. package/runtime/toaster.d.ts +24 -0
  112. package/runtime/toaster.d.ts.map +1 -0
  113. package/runtime/toaster.js +286 -0
  114. package/runtime/ui.d.ts +6 -17
  115. package/runtime/ui.d.ts.map +1 -1
  116. package/runtime/ui.js +62 -118
  117. package/session/context/context.d.ts +12 -0
  118. package/session/context/context.d.ts.map +1 -0
  119. package/session/context/context.js +2 -0
  120. package/session/context/turn/message/document.d.ts +7 -0
  121. package/session/context/turn/message/document.d.ts.map +1 -0
  122. package/session/context/turn/message/document.js +1 -0
  123. package/session/context/turn/message/message.d.ts +55 -0
  124. package/session/context/turn/message/message.d.ts.map +1 -0
  125. package/session/context/turn/message/message.js +178 -0
  126. package/session/context/turn/message/note.d.ts +8 -0
  127. package/session/context/turn/message/note.d.ts.map +1 -0
  128. package/session/context/turn/message/note.js +1 -0
  129. package/session/context/turn/message/notice.d.ts +6 -0
  130. package/session/context/turn/message/notice.d.ts.map +1 -0
  131. package/session/context/turn/message/notice.js +1 -0
  132. package/session/context/turn/message/prompt.d.ts +8 -0
  133. package/session/context/turn/message/prompt.d.ts.map +1 -0
  134. package/session/context/turn/message/prompt.js +1 -0
  135. package/session/context/turn/message/reply.d.ts +6 -0
  136. package/session/context/turn/message/reply.d.ts.map +1 -0
  137. package/session/context/turn/message/reply.js +1 -0
  138. package/session/context/turn/message/sender.d.ts +14 -0
  139. package/session/context/turn/message/sender.d.ts.map +1 -0
  140. package/session/context/turn/message/sender.js +30 -0
  141. package/session/context/turn/message/skill.d.ts +7 -0
  142. package/session/context/turn/message/skill.d.ts.map +1 -0
  143. package/session/context/turn/message/skill.js +1 -0
  144. package/session/context/turn/message/tool-call.d.ts +9 -0
  145. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  146. package/session/context/turn/message/tool-call.js +1 -0
  147. package/session/context/turn/message/tool-result.d.ts +8 -0
  148. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  149. package/session/context/turn/message/tool-result.js +1 -0
  150. package/session/context/turn/turn.d.ts +13 -0
  151. package/session/context/turn/turn.d.ts.map +1 -0
  152. package/session/context/turn/turn.js +2 -0
  153. package/session/conversation.d.ts +73 -0
  154. package/session/conversation.d.ts.map +1 -0
  155. package/session/conversation.js +146 -0
  156. package/session/goal/goal.d.ts +11 -0
  157. package/session/goal/goal.d.ts.map +1 -0
  158. package/session/goal/goal.js +1 -0
  159. package/session/goal/task.d.ts +16 -0
  160. package/session/goal/task.d.ts.map +1 -0
  161. package/session/goal/task.js +3 -0
  162. package/session/session.d.ts +158 -0
  163. package/session/session.d.ts.map +1 -0
  164. package/session/session.js +2 -0
  165. package/src/jobs/check.ts +7 -4
  166. package/src/jobs/context.ts +24 -82
  167. package/src/jobs/permissions/decide-permission.ts +10 -8
  168. package/src/jobs/permissions/find-project-scope.ts +8 -14
  169. package/src/jobs/permissions/match-target.ts +3 -3
  170. package/src/jobs/permissions.ts +8 -6
  171. package/src/jobs/program.ts +13 -4
  172. package/src/jobs/prompt.ts +21 -24
  173. package/src/jobs/schedule.ts +14 -14
  174. package/src/jobs/slash-command.ts +20 -14
  175. package/src/jobs/status-line.ts +9 -21
  176. package/src/jobs/tool.ts +17 -11
  177. package/src/mod.ts +252 -97
  178. package/src/options.ts +22 -22
  179. package/src/records.ts +40 -23
  180. package/src/register.ts +34 -5
  181. package/src/runtime/announce.ts +76 -0
  182. package/src/runtime/before.ts +200 -0
  183. package/src/runtime/bound.ts +12 -0
  184. package/src/runtime/claude.ts +7 -2
  185. package/src/runtime/conversations.ts +579 -0
  186. package/src/runtime/deadline.ts +2 -2
  187. package/src/runtime/dependencies.ts +13 -1
  188. package/src/runtime/engine.ts +23 -0
  189. package/src/runtime/grants.ts +69 -29
  190. package/src/runtime/hooks.ts +39 -14
  191. package/src/runtime/installer.ts +16 -14
  192. package/src/runtime/lifecycle.ts +413 -201
  193. package/src/runtime/metadata.ts +37 -14
  194. package/src/runtime/options.ts +19 -4
  195. package/src/runtime/programs.ts +40 -1
  196. package/src/runtime/router.ts +11 -4
  197. package/src/runtime/sessions.ts +447 -0
  198. package/src/runtime/state.ts +50 -14
  199. package/src/runtime/toaster.ts +334 -0
  200. package/src/runtime/ui.ts +65 -143
  201. package/src/session/context/context.ts +14 -0
  202. package/src/session/context/turn/message/document.ts +8 -0
  203. package/src/session/context/turn/message/message.ts +203 -0
  204. package/src/session/context/turn/message/note.ts +9 -0
  205. package/src/session/context/turn/message/notice.ts +7 -0
  206. package/src/session/context/turn/message/prompt.ts +9 -0
  207. package/src/session/context/turn/message/reply.ts +7 -0
  208. package/src/session/context/turn/message/sender.ts +38 -0
  209. package/src/session/context/turn/message/skill.ts +8 -0
  210. package/src/session/context/turn/message/tool-call.ts +10 -0
  211. package/src/session/context/turn/message/tool-result.ts +9 -0
  212. package/src/session/context/turn/turn.ts +15 -0
  213. package/src/session/conversation.ts +194 -0
  214. package/src/session/goal/goal.ts +10 -0
  215. package/src/session/goal/task.ts +19 -0
  216. package/src/session/session.ts +141 -0
  217. package/src/testing/fake-claude.ts +25 -17
  218. package/src/testing/fake-elements.ts +3 -2
  219. package/src/testing/fake-files.ts +1 -1
  220. package/src/testing/fake-sessions.ts +277 -0
  221. package/src/testing.ts +138 -35
  222. package/src/ui/components.ts +3 -3
  223. package/src/ui/elements.ts +22 -8
  224. package/src/ui/markdown.ts +22 -26
  225. package/src/ui/places.ts +49 -0
  226. package/src/utils/call-effects.ts +5 -5
  227. package/src/utils/changelog.ts +32 -0
  228. package/src/utils/disposable.ts +3 -0
  229. package/src/utils/matcher.ts +37 -0
  230. package/src/utils/metadata.ts +17 -15
  231. package/src/utils/parse-shell.ts +9 -8
  232. package/src/utils/paths.ts +21 -4
  233. package/src/utils/text.ts +4 -0
  234. package/testing/fake-claude.d.ts +5 -1
  235. package/testing/fake-claude.d.ts.map +1 -1
  236. package/testing/fake-claude.js +21 -12
  237. package/testing/fake-elements.js +4 -2
  238. package/testing/fake-files.js +1 -1
  239. package/testing/fake-sessions.d.ts +81 -0
  240. package/testing/fake-sessions.d.ts.map +1 -0
  241. package/testing/fake-sessions.js +249 -0
  242. package/testing.d.ts +34 -13
  243. package/testing.d.ts.map +1 -1
  244. package/testing.js +115 -23
  245. package/ui/components.d.ts +2 -3
  246. package/ui/components.d.ts.map +1 -1
  247. package/ui/components.js +3 -3
  248. package/ui/elements.d.ts.map +1 -1
  249. package/ui/elements.js +19 -9
  250. package/ui/markdown.d.ts +35 -33
  251. package/ui/markdown.d.ts.map +1 -1
  252. package/ui/markdown.js +14 -16
  253. package/ui/places.d.ts +43 -0
  254. package/ui/places.d.ts.map +1 -0
  255. package/ui/places.js +31 -0
  256. package/utils/call-effects.d.ts +2 -1
  257. package/utils/call-effects.d.ts.map +1 -1
  258. package/utils/call-effects.js +3 -3
  259. package/utils/changelog.d.ts +9 -0
  260. package/utils/changelog.d.ts.map +1 -0
  261. package/utils/changelog.js +29 -0
  262. package/utils/disposable.d.ts +4 -0
  263. package/utils/disposable.d.ts.map +1 -0
  264. package/utils/disposable.js +1 -0
  265. package/utils/matcher.d.ts +13 -0
  266. package/utils/matcher.d.ts.map +1 -0
  267. package/utils/matcher.js +32 -0
  268. package/utils/metadata.d.ts +3 -3
  269. package/utils/metadata.js +18 -14
  270. package/utils/parse-shell.js +9 -7
  271. package/utils/paths.d.ts +4 -1
  272. package/utils/paths.d.ts.map +1 -1
  273. package/utils/paths.js +19 -4
  274. package/utils/text.d.ts +1 -0
  275. package/utils/text.d.ts.map +1 -1
  276. package/utils/text.js +3 -0
  277. package/src/ui/slots.ts +0 -36
  278. package/ui/slots.d.ts +0 -208
  279. package/ui/slots.d.ts.map +0 -1
  280. package/ui/slots.js +0 -19
@@ -1,8 +1,10 @@
1
1
  import type { FileMatch, Metadata } from '../mod.js'
2
2
  import { formatOf, MetadataError, readMetadata, sidecarSuffix, writeMetadata, type MetadataFormat } from '../utils/metadata.js'
3
+ import { expandHome, homePrefix, ifMissing, isMissing } from '../utils/paths.js'
3
4
  import { messageOf } from '../utils/text.js'
4
5
  import { picomatch, resolve } from '../vendor.js'
5
6
  import type { Claude } from './claude.js'
7
+ import type { Failure } from './toaster.js'
6
8
 
7
9
  export type Places = {
8
10
  readonly home: string | undefined
@@ -27,14 +29,27 @@ const ownKey = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/
27
29
  export function resolvedPath(path: string, places: Places): string {
28
30
  const configured = /^~\/\.claude(?=\/|$)/.exec(path)
29
31
  if (configured !== null && places.configRoot !== undefined) return resolve(`${places.configRoot}${path.slice(configured[0].length)}`)
30
- if (path === '~' || path.startsWith('~/')) {
31
- if (places.home === undefined) throw new Error(`${path} starts with ~, and HOME is not set. Start Claude Code with HOME set.`)
32
- return resolve(`${places.home}${path.slice(1)}`)
32
+ const prefix = homePrefix.exec(path)?.[0]
33
+ if (prefix !== undefined) {
34
+ if (places.home === undefined) throw new Error(`${path} starts with ${prefix}, and HOME is not set. Start Claude Code with HOME set.`)
35
+ return resolve(expandHome(path, places.home, places.configRoot))
33
36
  }
34
37
  return path.startsWith('/') ? resolve(path) : resolve(places.projectRoot(), path)
35
38
  }
36
39
 
37
- export function createModFiles({ name, claude, places, checkWrite }: { readonly name: string; readonly claude: Claude; readonly places: Places; readonly checkWrite: (path: string) => Promise<void> }): ModFiles {
40
+ export function createModFiles({
41
+ name,
42
+ claude,
43
+ places,
44
+ checkWrite,
45
+ report,
46
+ }: {
47
+ readonly name: string
48
+ readonly claude: Claude
49
+ readonly places: Places
50
+ readonly checkWrite: (path: string) => Promise<void>
51
+ readonly report: (failure: Failure) => void
52
+ }): ModFiles {
38
53
  const prefix = `${name}.`
39
54
  const cache = new Map<string, { readonly signature: string; readonly read: Read }>()
40
55
  const logged = new Set<string>()
@@ -48,12 +63,20 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
48
63
  const extensionless = !realPath.slice(realPath.lastIndexOf('/') + 1).includes('.')
49
64
  const firstLine = extensionless ? (await claude.fs.read(realPath)).split('\n', 1)[0] : undefined
50
65
  const format = formatOf(realPath, firstLine)
51
- const holder = format.kind === 'sidecar' ? `${realPath}${sidecarSuffix}` : realPath
52
- const sidecar = format.kind === 'sidecar' ? await claude.fs.stat(holder).catch(() => undefined) : undefined
66
+ const holder = format.type === 'sidecar' ? `${realPath}${sidecarSuffix}` : realPath
67
+ const sidecar = format.type === 'sidecar' ? await claude.fs.stat(holder).catch(ifMissing(undefined)) : undefined
53
68
  return { realPath, format, holder, signature: `${stat.mtimeMs}:${stat.size}:${sidecar?.kind === 'file' ? `${sidecar.mtimeMs}:${sidecar.size}` : ''}` }
54
69
  }
55
70
 
56
- const textOf = async (holder: string): Promise<string> => ((await claude.fs.stat(holder).catch(() => undefined))?.kind === 'file' ? claude.fs.read(holder) : '')
71
+ const textOf = async (holder: string): Promise<string> => ((await claude.fs.stat(holder).catch(ifMissing(undefined)))?.kind === 'file' ? claude.fs.read(holder) : '')
72
+
73
+ const skipped = <Value>(path: string, value: Value) => (error: unknown): Value => {
74
+ if (isMissing(error)) return value
75
+ const reason = `${name}: fs.find skipped ${path}: ${messageOf(error)}`
76
+ if (!logged.has(reason)) claude.ui.log(reason, { to: 'debug' })
77
+ logged.add(reason)
78
+ return value
79
+ }
57
80
 
58
81
  const readEntry = async (path: string): Promise<Read> => {
59
82
  const located = await locate(path)
@@ -67,7 +90,7 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
67
90
  if (!(error instanceof MetadataError)) throw error
68
91
  const failure = `${located.holder} ${error.message}`
69
92
  read = { metadata: {}, error: failure }
70
- if (!logged.has(failure)) claude.ui.log(`${name}: ${failure}`)
93
+ if (!logged.has(failure)) report({ id: `metadata:${located.holder}`, message: `Skips the metadata of ${located.holder}`, description: failure })
71
94
  logged.add(failure)
72
95
  }
73
96
  cache.set(path, { signature: located.signature, read })
@@ -77,21 +100,21 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
77
100
  const matchesOf = async (pattern: string): Promise<string[]> => {
78
101
  const absolute = resolvedPath(pattern, places)
79
102
  const scanned = picomatch.scan(absolute)
80
- if (!scanned.isGlob) return (await claude.fs.stat(absolute).catch(() => undefined))?.kind === 'file' ? [absolute] : []
103
+ if (!scanned.isGlob) return (await claude.fs.stat(absolute).catch(skipped(absolute, undefined)))?.kind === 'file' ? [absolute] : []
81
104
  const isMatch = picomatch(absolute, { dot: true })
82
105
  const entersSkipped = skippedFolders.filter((folder) => scanned.glob.split('/').includes(folder))
83
106
  const maxDepth = scanned.glob.includes('**') ? Number.POSITIVE_INFINITY : scanned.glob.split('/').length
84
107
  const found: string[] = []
85
108
  const visited = new Set<string>()
86
109
  const walk = async (folder: string, depth: number): Promise<void> => {
87
- const real = (await claude.fs.stat(folder, { resolve: true }).catch(() => undefined))?.realPath ?? folder
110
+ const real = (await claude.fs.stat(folder, { resolve: true }).catch(skipped(folder, undefined)))?.realPath ?? folder
88
111
  if (visited.has(real)) return
89
112
  visited.add(real)
90
- const entries = await claude.fs.list(folder).catch(() => [])
113
+ const entries = await claude.fs.list(folder).catch(skipped(folder, []))
91
114
  await Promise.all(
92
115
  entries.map(async (entry) => {
93
116
  const path = `${folder}/${entry.name}`
94
- const kind = entry.isLink === true ? (await claude.fs.stat(path).catch(() => undefined))?.kind : entry.kind
117
+ const kind = entry.isLink === true ? (await claude.fs.stat(path).catch(skipped(path, undefined)))?.kind : entry.kind
95
118
  if (kind === 'file' && isMatch(path)) found.push(path)
96
119
  if (kind !== 'dir' || depth + 1 >= maxDepth) return
97
120
  if (skippedFolders.includes(entry.name) && !entersSkipped.includes(entry.name)) return
@@ -100,7 +123,7 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
100
123
  )
101
124
  }
102
125
  const base = scanned.base === '' ? '/' : scanned.base
103
- if ((await claude.fs.stat(base).catch(() => undefined))?.kind === 'dir') await walk(base, 0)
126
+ if ((await claude.fs.stat(base).catch(skipped(base, undefined)))?.kind === 'dir') await walk(base, 0)
104
127
  return found
105
128
  }
106
129
 
@@ -111,7 +134,7 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
111
134
  const kept = paths.filter((path) => !(path.endsWith(sidecarSuffix) && paths.includes(path.slice(0, -sidecarSuffix.length))))
112
135
  return Promise.all(
113
136
  kept.map(async (path): Promise<FileMatch> => {
114
- const read = await readEntry(path)
137
+ const read = await readEntry(path).catch((error: unknown): Read => ({ metadata: {}, error: `${path} ${messageOf(error)}` }))
115
138
  return { path, ...(read.name === undefined ? {} : { name: read.name }), metadata: own(read.metadata), ...(read.error === undefined ? {} : { error: read.error }) }
116
139
  }),
117
140
  )
@@ -3,6 +3,7 @@ import { fitsOption, isUnset, type Option, type Options } from '../options.js'
3
3
  import { configFolders } from '../records.js'
4
4
  import { listed, messageOf } from '../utils/text.js'
5
5
  import type { Claude } from './claude.js'
6
+ import type { Failure } from './toaster.js'
6
7
 
7
8
  export class MissingOptions extends Error {
8
9
  readonly keys: readonly string[]
@@ -21,7 +22,21 @@ export type ModOptions = {
21
22
  load(root: string): Promise<void>
22
23
  }
23
24
 
24
- export function createOptions({ name, declared, fromClaude, claude, changed }: { readonly name: string; readonly declared: Options; readonly fromClaude: PluginOptions; readonly claude: Claude; readonly changed: () => void }): ModOptions {
25
+ export function createOptions({
26
+ name,
27
+ declared,
28
+ fromClaude,
29
+ claude,
30
+ report,
31
+ changed,
32
+ }: {
33
+ readonly name: string
34
+ readonly declared: Options
35
+ readonly fromClaude: PluginOptions
36
+ readonly claude: Claude
37
+ readonly report: (failure: Failure) => void
38
+ readonly changed: () => void
39
+ }): ModOptions {
25
40
  let values: Readonly<Record<string, unknown>> = merged(declared, fromClaude, {}, new Set())
26
41
  let locked: Set<string> | undefined
27
42
  let loadedRoot: string | undefined
@@ -41,7 +56,7 @@ export function createOptions({ name, declared, fromClaude, claude, changed }: {
41
56
  if (project === undefined) return {}
42
57
  const path = `${project.folder}/options.json`
43
58
  if (!(await claude.fs.exists(path))) return {}
44
- const ignore = (reason: string) => claude.ui.log(`${path} ${reason}`)
59
+ const ignore = (reason: string) => report({ id: `options:${path} ${reason}`, message: `Ignores part of ${path}`, description: `${path} ${reason}` })
45
60
  let file: unknown
46
61
  try {
47
62
  file = JSON.parse(await claude.fs.read(path))
@@ -57,7 +72,7 @@ export function createOptions({ name, declared, fromClaude, claude, changed }: {
57
72
  for (const [key, value] of Object.entries(file)) {
58
73
  const option = declared[key]
59
74
  if (option === undefined) ignore(`sets ${key}, which ${name} does not declare. ${Object.keys(declared).length === 0 ? 'Remove it.' : `Remove it, or use one of: ${Object.keys(declared).join(', ')}.`}`)
60
- else if (option.kind === 'secret') ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it, and set it in /config.`)
75
+ else if (option.type === 'secret') ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it, and set it in /config.`)
61
76
  else {
62
77
  const misfit = fitsOption(option, value)
63
78
  if (misfit === undefined) kept[key] = value
@@ -98,5 +113,5 @@ function valueOf(key: string, option: Option, own: unknown, project: Readonly<Re
98
113
  if (locked.has(key)) return own
99
114
  if (Object.hasOwn(project, key)) return project[key]
100
115
  if (!isUnset(own) && fitsOption(option, own) === undefined) return Array.isArray(own) ? Object.freeze([...own]) : own
101
- return option.default ?? (option.kind === 'list' ? Object.freeze([]) : option.kind === 'number' ? undefined : '')
116
+ return option.default ?? (option.type === 'list' ? Object.freeze([]) : option.type === 'number' ? undefined : '')
102
117
  }
@@ -1,8 +1,47 @@
1
+ import type { HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
1
2
  import { storeFolder } from '../records.js'
3
+ import { ifMissing } from '../utils/paths.js'
4
+ import { formatExit } from '../utils/text.js'
2
5
  import type { Claude } from './claude.js'
3
6
 
4
7
  export type Programs = { readonly claude: Claude; refresh(): Promise<void> }
5
8
 
9
+ export function lastLineOf(text: string): string | undefined {
10
+ const trimmed = text.trim()
11
+ return trimmed === '' ? undefined : trimmed.slice(trimmed.lastIndexOf('\n') + 1).trim()
12
+ }
13
+
14
+ export function cmodError(stderr: string, command: string | undefined): string {
15
+ const line = lastLineOf(stderr) ?? ''
16
+ const prefix = `cmod ${command}: `
17
+ return line.startsWith(prefix) ? line.slice(prefix.length) : line
18
+ }
19
+
20
+ export async function readLines(stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>, onLine: (text: string) => void): Promise<{ code: number | null; lastError: string }> {
21
+ let pending = ''
22
+ let lastError = ''
23
+ let piece = await stream.next()
24
+ while (piece.done !== true) {
25
+ if (piece.value.stream === 'stderr') {
26
+ lastError = lastLineOf(piece.value.text) ?? lastError
27
+ } else {
28
+ const lines = `${pending}${piece.value.text}`.split('\n')
29
+ pending = lines.pop() ?? ''
30
+ for (const text of lines) onLine(text)
31
+ }
32
+ piece = await stream.next()
33
+ }
34
+ if (pending !== '') onLine(pending)
35
+ return { code: piece.value.code, lastError }
36
+ }
37
+
38
+ export async function runCmod(claude: Claude, args: readonly string[], session: string): Promise<string> {
39
+ const lines: string[] = []
40
+ const { code, lastError } = await readLines(claude.process.spawn({ argv: ['cmod', ...args], env: { CLAUDE_CODE_SESSION_ID: session } }), (line) => void lines.push(line))
41
+ if (code !== 0) throw new Error(cmodError(lastError, args[0]) || `cmod ${args.slice(0, 2).join(' ')} ${formatExit(code, '')}.`)
42
+ return lines.join('\n')
43
+ }
44
+
6
45
  export function locatingPrograms(claude: Claude): Programs {
7
46
  let folder: string | undefined
8
47
  let names: ReadonlySet<string> = new Set()
@@ -21,7 +60,7 @@ export function locatingPrograms(claude: Claude): Programs {
21
60
  async refresh() {
22
61
  const [home, dataHome] = await Promise.all([claude.env.home(), claude.env.dataHome()])
23
62
  const programs = `${storeFolder({ HOME: home, XDG_DATA_HOME: dataHome })}/programs`
24
- names = new Set((await claude.fs.list(programs).catch(() => [])).map((entry) => entry.name))
63
+ names = new Set((await claude.fs.list(programs).catch(ifMissing([]))).map((entry) => entry.name))
25
64
  folder = programs
26
65
  },
27
66
  }
@@ -1,4 +1,5 @@
1
1
  import type { Args, EventResult, Frozen } from 'claude-code'
2
+ import type { Disposable } from '../mod.js'
2
3
  import type { RoutedEvent } from './hooks.js'
3
4
 
4
5
  export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<EventResult<N>>
@@ -6,7 +7,7 @@ export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<
6
7
  export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: RouterNext<N>) => EventResult<N> | Promise<EventResult<N>>
7
8
 
8
9
  export type Router = {
9
- add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): void
10
+ add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): Disposable
10
11
  has(event: RoutedEvent): boolean
11
12
  dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
12
13
  }
@@ -18,9 +19,15 @@ export function createRouter(): Router {
18
19
 
19
20
  return {
20
21
  add(event, hook) {
21
- const chain = hooks.get(event) ?? []
22
- chain.push(hook as unknown as ErasedHook)
23
- hooks.set(event, chain)
22
+ const erased = hook as unknown as ErasedHook
23
+ hooks.set(event, [...(hooks.get(event) ?? []), erased])
24
+ return {
25
+ dispose() {
26
+ const chain = (hooks.get(event) ?? []).filter((each) => each !== erased)
27
+ if (chain.length === 0) hooks.delete(event)
28
+ else hooks.set(event, chain)
29
+ },
30
+ }
24
31
  },
25
32
  has: (event) => hooks.has(event),
26
33
  dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {