@cmodjs/core 0.1.14 → 0.2.1

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 (96) hide show
  1. package/docs/commands.md +32 -21
  2. package/docs/dependencies.md +8 -0
  3. package/docs/index.md +12 -4
  4. package/docs/install-steps.md +36 -4
  5. package/docs/jobs.md +25 -9
  6. package/docs/mod.md +103 -37
  7. package/docs/options.md +93 -0
  8. package/docs/permissions.md +88 -0
  9. package/docs/state.md +4 -31
  10. package/docs/testing.md +13 -2
  11. package/installer.d.ts +10 -0
  12. package/installer.d.ts.map +1 -0
  13. package/installer.js +8 -0
  14. package/jobs/context.d.ts +2 -2
  15. package/jobs/context.d.ts.map +1 -1
  16. package/jobs/context.js +5 -3
  17. package/jobs/permissions.js +1 -1
  18. package/jobs/program.d.ts.map +1 -1
  19. package/jobs/program.js +5 -1
  20. package/jobs/prompt.js +5 -5
  21. package/jobs/schedule.d.ts.map +1 -1
  22. package/jobs/schedule.js +2 -1
  23. package/jobs/slash-command.d.ts.map +1 -1
  24. package/jobs/slash-command.js +3 -2
  25. package/jobs/status-line.d.ts.map +1 -1
  26. package/jobs/status-line.js +3 -2
  27. package/jobs/tool.d.ts.map +1 -1
  28. package/jobs/tool.js +3 -2
  29. package/mod.d.ts +48 -10
  30. package/mod.d.ts.map +1 -1
  31. package/mod.js +3 -0
  32. package/options.d.ts +58 -0
  33. package/options.d.ts.map +1 -0
  34. package/options.js +66 -0
  35. package/package.json +1 -1
  36. package/records.d.ts +16 -1
  37. package/records.d.ts.map +1 -1
  38. package/records.js +119 -7
  39. package/register.d.ts +4 -2
  40. package/register.d.ts.map +1 -1
  41. package/register.js +8 -2
  42. package/runtime/claude.d.ts +5 -1
  43. package/runtime/claude.d.ts.map +1 -1
  44. package/runtime/grants.d.ts +28 -0
  45. package/runtime/grants.d.ts.map +1 -0
  46. package/runtime/grants.js +286 -0
  47. package/runtime/installer.d.ts +24 -0
  48. package/runtime/installer.d.ts.map +1 -0
  49. package/runtime/installer.js +118 -0
  50. package/runtime/lifecycle.d.ts +6 -3
  51. package/runtime/lifecycle.d.ts.map +1 -1
  52. package/runtime/lifecycle.js +226 -36
  53. package/runtime/metadata.d.ts +20 -0
  54. package/runtime/metadata.d.ts.map +1 -0
  55. package/runtime/metadata.js +143 -0
  56. package/runtime/options.d.ts +21 -0
  57. package/runtime/options.d.ts.map +1 -0
  58. package/runtime/options.js +100 -0
  59. package/runtime/state.d.ts +1 -0
  60. package/runtime/state.d.ts.map +1 -1
  61. package/runtime/state.js +5 -75
  62. package/src/installer.ts +17 -0
  63. package/src/jobs/context.ts +5 -3
  64. package/src/jobs/permissions.ts +1 -1
  65. package/src/jobs/program.ts +4 -1
  66. package/src/jobs/prompt.ts +5 -5
  67. package/src/jobs/schedule.ts +2 -1
  68. package/src/jobs/slash-command.ts +3 -2
  69. package/src/jobs/status-line.ts +3 -2
  70. package/src/jobs/tool.ts +3 -2
  71. package/src/mod.ts +46 -11
  72. package/src/options.ts +91 -0
  73. package/src/records.ts +131 -8
  74. package/src/register.ts +12 -3
  75. package/src/runtime/claude.ts +2 -0
  76. package/src/runtime/grants.ts +306 -0
  77. package/src/runtime/installer.ts +169 -0
  78. package/src/runtime/lifecycle.ts +227 -45
  79. package/src/runtime/metadata.ts +153 -0
  80. package/src/runtime/options.ts +102 -0
  81. package/src/runtime/state.ts +7 -70
  82. package/src/testing/fake-claude.ts +6 -1
  83. package/src/testing/fake-files.ts +9 -3
  84. package/src/testing.ts +40 -15
  85. package/src/utils/metadata.ts +242 -0
  86. package/testing/fake-claude.d.ts +4 -0
  87. package/testing/fake-claude.d.ts.map +1 -1
  88. package/testing/fake-claude.js +5 -1
  89. package/testing/fake-files.d.ts.map +1 -1
  90. package/testing/fake-files.js +9 -3
  91. package/testing.d.ts +6 -2
  92. package/testing.d.ts.map +1 -1
  93. package/testing.js +29 -13
  94. package/utils/metadata.d.ts +21 -0
  95. package/utils/metadata.d.ts.map +1 -0
  96. package/utils/metadata.js +250 -0
@@ -0,0 +1,102 @@
1
+ import type { PluginOptions } from 'claude-code'
2
+ import { fitsOption, isUnset, type Option, type Options } from '../options.js'
3
+ import { configFolders } from '../records.js'
4
+ import { listed, messageOf } from '../utils/text.js'
5
+ import type { Claude } from './claude.js'
6
+
7
+ export class MissingOptions extends Error {
8
+ readonly keys: readonly string[]
9
+ readonly titles: readonly string[]
10
+
11
+ constructor(keys: readonly string[], titles: readonly string[]) {
12
+ super(`it needs ${listed(titles)}`)
13
+ this.keys = keys
14
+ this.titles = titles
15
+ }
16
+ }
17
+
18
+ export type ModOptions = {
19
+ readonly values: Readonly<Record<string, unknown>>
20
+ readonly missing: readonly string[]
21
+ load(root: string): Promise<void>
22
+ }
23
+
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
+ let values: Readonly<Record<string, unknown>> = merged(declared, fromClaude, {}, new Set())
26
+ let locked: Set<string> | undefined
27
+ let loadedRoot: string | undefined
28
+
29
+ const readLocked = async () => {
30
+ if (Object.keys(declared).length === 0) return new Set<string>()
31
+ const rows = await claude.config.list().catch((error: unknown) => {
32
+ claude.ui.log(`${name} could not read /config, so a project's options.json may override a value your organization set: ${messageOf(error)}`, { to: 'debug' })
33
+ return []
34
+ })
35
+ return new Set(rows.filter((row) => row.isLocked && row.key.startsWith(`${name}.`)).map((row) => row.key.slice(name.length + 1)))
36
+ }
37
+
38
+ const readProject = async (root: string): Promise<Record<string, unknown>> => {
39
+ const env = { HOME: await claude.env.home(), CLAUDE_CONFIG_DIR: await claude.env.configHome() }
40
+ const project = configFolders(env, name, root).find(({ tier }) => tier === 'project')
41
+ if (project === undefined) return {}
42
+ const path = `${project.folder}/options.json`
43
+ if (!(await claude.fs.exists(path))) return {}
44
+ const ignore = (reason: string) => claude.ui.log(`${path} ${reason}`)
45
+ let file: unknown
46
+ try {
47
+ file = JSON.parse(await claude.fs.read(path))
48
+ } catch (error) {
49
+ ignore(`is not JSON (${messageOf(error)}). Fix the file; ${name} uses its other options until then.`)
50
+ return {}
51
+ }
52
+ if (typeof file !== 'object' || file === null || Array.isArray(file)) {
53
+ ignore('is not a JSON object. Write one, such as { "branch": "main" }.')
54
+ return {}
55
+ }
56
+ const kept: Record<string, unknown> = {}
57
+ for (const [key, value] of Object.entries(file)) {
58
+ const option = declared[key]
59
+ 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.`)
61
+ else {
62
+ const misfit = fitsOption(option, value)
63
+ if (misfit === undefined) kept[key] = value
64
+ else ignore(`sets ${key} to ${JSON.stringify(value)}, and ${option.title} ${misfit}.`)
65
+ }
66
+ }
67
+ return kept
68
+ }
69
+
70
+ return {
71
+ get values() {
72
+ return values
73
+ },
74
+ get missing() {
75
+ return Object.entries(declared)
76
+ .filter(([key, option]) => option.default === undefined && isUnset(values[key]))
77
+ .map(([key]) => key)
78
+ },
79
+ async load(root) {
80
+ if (Object.keys(declared).length === 0) return
81
+ locked ??= await readLocked()
82
+ const project = await readProject(root)
83
+ const isFirst = loadedRoot === undefined
84
+ loadedRoot = root
85
+ const next = merged(declared, fromClaude, project, locked)
86
+ if (!isFirst && JSON.stringify(next) === JSON.stringify(values)) return
87
+ values = next
88
+ if (!isFirst) changed()
89
+ },
90
+ }
91
+ }
92
+
93
+ function merged(declared: Options, fromClaude: PluginOptions, project: Readonly<Record<string, unknown>>, locked: ReadonlySet<string>): Readonly<Record<string, unknown>> {
94
+ return Object.freeze(Object.fromEntries(Object.entries(declared).map(([key, option]) => [key, valueOf(key, option, fromClaude[key], project, locked)])))
95
+ }
96
+
97
+ function valueOf(key: string, option: Option, own: unknown, project: Readonly<Record<string, unknown>>, locked: ReadonlySet<string>): unknown {
98
+ if (locked.has(key)) return own
99
+ if (Object.hasOwn(project, key)) return project[key]
100
+ 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 : '')
102
+ }
@@ -1,5 +1,4 @@
1
1
  import type { ClassicHookInputs } from 'claude-code'
2
- import { configFolder, configFolders } from '../records.js'
3
2
  import { messageOf } from '../utils/text.js'
4
3
  import type { Claude } from './claude.js'
5
4
 
@@ -21,6 +20,7 @@ export type StateOptions = {
21
20
  export type ModState<State extends object> = {
22
21
  readonly state: State
23
22
  readonly root: string
23
+ changed(): void
24
24
  load(): Promise<void>
25
25
  moveTo(root: string): Promise<void>
26
26
  switchSession(session: string, source: ClassicHookInputs['SessionStart']['source']): Promise<void>
@@ -28,8 +28,6 @@ export type ModState<State extends object> = {
28
28
 
29
29
  const lifetimes: readonly Lifetime[] = ['memory', 'session', 'project', 'global']
30
30
 
31
- const savedLifetimes: readonly SavedLifetime[] = ['session', 'project', 'global']
32
-
33
31
  const keptPerValue = 20
34
32
 
35
33
  const listeners = new WeakMap<object, Set<() => void>>()
@@ -47,7 +45,6 @@ export function createState<State extends object>({ name, initial, session, root
47
45
  }
48
46
  const declared = declaredGroups(name, initial)
49
47
  const current = copied(declared)
50
- let defaults = declared
51
48
  let projectRoot = root
52
49
  let sessionId = session
53
50
  let turn: Promise<unknown> = Promise.resolve()
@@ -72,66 +69,14 @@ export function createState<State extends object>({ name, initial, session, root
72
69
  for (const oldest of owners.slice(0, -keptPerValue)) await claude.store.delete(oldest)
73
70
  }
74
71
 
75
- const fileValues = (path: string, tier: 'system' | 'project', text: string, systemPath: string) => {
76
- const values: [SavedLifetime, string, unknown][] = []
77
- const ignore = (reason: string) => claude.ui.log(`${path} ${reason}`)
78
- let file: unknown
79
- try {
80
- file = JSON.parse(text)
81
- } catch (error) {
82
- ignore(`is not JSON (${messageOf(error)}). Fix the file, then run /reload-plugins.`)
83
- return values
84
- }
85
- if (!isPlainObject(file)) {
86
- ignore('is not a JSON object. Write one, such as { "global": { "key": "value" } }.')
87
- return values
88
- }
89
- for (const [group, entries] of Object.entries(file)) {
90
- if (!isLifetime(group)) {
91
- ignore(`sets ${group}, which ${name} does not declare. Remove it, or use one of: ${savedLifetimes.join(', ')}.`)
92
- continue
93
- }
94
- if (group === 'memory') {
95
- ignore('sets memory, and memory values are never saved, so a file cannot set them. Remove it.')
96
- continue
97
- }
98
- if (!isPlainObject(entries)) {
99
- ignore(`sets ${group} to ${typeOf(entries)}. Write an object of values, such as { "${group}": { "key": "value" } }.`)
100
- continue
101
- }
102
- for (const [key, value] of Object.entries(entries)) {
103
- const choices = Object.keys(declared[group])
104
- const wanted = typeOf(declared[group][key])
105
- if (!Object.hasOwn(declared[group], key)) ignore(`sets ${group}.${key}, which ${name} does not declare. ${choices.length === 0 ? 'Remove it.' : `Remove it, or use one of: ${choices.join(', ')}.`}`)
106
- else if (tier === 'project' && group === 'global') ignore(`sets ${group}.${key}, and one repository cannot change a value for every project. Move it to ${systemPath}.`)
107
- else if (typeOf(value) !== wanted) ignore(`sets ${group}.${key} to ${typeOf(value)}, and ${name} keeps ${wanted} there. Write ${wanted}, or remove it.`)
108
- else values.push([group, key, frozen(name, `${group}.${key}`, value)])
109
- }
110
- }
111
- return values
112
- }
113
-
114
- const readDefaults = async (nextRoot: string): Promise<Groups> => {
115
- const env = { HOME: await claude.env.home(), CLAUDE_CONFIG_DIR: await claude.env.configHome() }
116
- const systemPath = `${configFolder(env, name)}/state.json`
117
- const next = copied(declared)
118
- for (const { tier, folder } of configFolders(env, name, nextRoot)) {
119
- const path = `${folder}/state.json`
120
- if (!(await claude.fs.exists(path))) continue
121
- for (const [lifetime, key, value] of fileValues(path, tier, await claude.fs.read(path), systemPath)) next[lifetime][key] = value
122
- }
123
- return next
124
- }
125
-
126
- const loadGroups = async (only: readonly Lifetime[], next: { readonly root: string; readonly session: string; readonly defaults: Groups }) => {
72
+ const loadGroups = async (only: readonly Lifetime[], next: { readonly root: string; readonly session: string }) => {
127
73
  const loaded: [Lifetime, string, unknown][] = []
128
74
  for (const lifetime of only) {
129
75
  for (const key of Object.keys(declared[lifetime])) loaded.push([lifetime, key, lifetime === 'memory' ? undefined : await read(lifetime, key, lifetime === 'project' ? next.root : next.session)])
130
76
  }
131
77
  projectRoot = next.root
132
78
  sessionId = next.session
133
- defaults = next.defaults
134
- for (const [lifetime, key, saved] of loaded) current[lifetime][key] = saved === undefined ? defaults[lifetime][key] : frozen(name, `${lifetime}.${key}`, saved)
79
+ for (const [lifetime, key, saved] of loaded) current[lifetime][key] = saved === undefined ? declared[lifetime][key] : frozen(name, `${lifetime}.${key}`, saved)
135
80
  changed()
136
81
  }
137
82
 
@@ -167,14 +112,14 @@ export function createState<State extends object>({ name, initial, session, root
167
112
  get root() {
168
113
  return projectRoot
169
114
  },
170
- load: () => inTurn(async () => loadGroups(lifetimes, { root: projectRoot, session: sessionId, defaults: await readDefaults(projectRoot) })),
171
- moveTo: (nextRoot) =>
172
- inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId, defaults: await readDefaults(nextRoot) }))),
115
+ changed,
116
+ load: () => inTurn(() => loadGroups(lifetimes, { root: projectRoot, session: sessionId })),
117
+ moveTo: (nextRoot) => inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId }))),
173
118
  switchSession: (nextSession, source) =>
174
119
  inTurn(async () => {
175
120
  if (nextSession === sessionId) return
176
121
  if (source === 'fork') await copySession(nextSession)
177
- return loadGroups(['memory', 'session'], { root: projectRoot, session: nextSession, defaults })
122
+ return loadGroups(['memory', 'session'], { root: projectRoot, session: nextSession })
178
123
  }),
179
124
  }
180
125
  }
@@ -197,14 +142,6 @@ function isLifetime(group: string): group is Lifetime {
197
142
  return (lifetimes as readonly string[]).includes(group)
198
143
  }
199
144
 
200
- function typeOf(value: unknown): string {
201
- if (value === null) return 'null'
202
- if (Array.isArray(value)) return 'an array'
203
- if (typeof value === 'object') return 'an object'
204
- if (value === undefined) return 'nothing'
205
- return `a ${typeof value}`
206
- }
207
-
208
145
  function frozen(name: string, path: string, value: unknown): unknown {
209
146
  const isArray = Array.isArray(value)
210
147
  if (!isArray && !isPlainObject(value)) return value
@@ -12,6 +12,7 @@ export type Fakes = {
12
12
  fs: { read?: Claude['fs']['read']; write?: Claude['fs']['write']; list?: Claude['fs']['list']; exists?: Claude['fs']['exists']; stat?: Claude['fs']['stat'] }
13
13
  http: { fetch?: Claude['http']['fetch'] }
14
14
  settings: { read?: Claude['settings']['read'] }
15
+ config: { list?: Claude['config']['list']; set?: Claude['config']['set'] }
15
16
  ui: { ask?: Claude['ui']['ask']; open?: Claude['ui']['open']; scroll?: Claude['ui']['scroll'] }
16
17
  session: { messages?: Claude['session']['messages']; append?: Claude['session']['append'] }
17
18
  prompt: { submit?: Claude['prompt']['submit'] }
@@ -48,7 +49,7 @@ export type FakeClaude = {
48
49
 
49
50
  export function fakeClaude(plugin: { readonly name: string; readonly root: string }): FakeClaude {
50
51
  const calls: TestCall[] = []
51
- const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, ui: {}, session: {}, prompt: {}, model: {}, agent: {}, clock: {}, cmod: {}, tool: {} }
52
+ const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, config: {}, ui: {}, session: {}, prompt: {}, model: {}, agent: {}, clock: {}, cmod: {}, tool: {} }
52
53
  const shown: Shown = { toasts: [], notes: [], prompts: [], logs: [], debug: [], statuses: [], openPanes: new Set(), commands: [], tools: [], schedules: [] }
53
54
  const unplacedPanes = new Set<string>()
54
55
  let focusedPane: string | undefined
@@ -148,6 +149,10 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
148
149
  },
149
150
  http: { fetch: rejected('http.fetch', () => fakes.http.fetch) },
150
151
  settings: { read: rejected('settings.read', () => fakes.settings.read) },
152
+ config: {
153
+ list: rejected('config.list', () => fakes.config.list ?? (async () => [])),
154
+ set: rejected('config.set', () => fakes.config.set ?? (async ({ value }) => ({ value }))) as Claude['config']['set'],
155
+ },
151
156
  agent: {
152
157
  list: rejected('agent.list', () => fakes.agent.list),
153
158
  spawn: rejected('agent.spawn', () => fakes.agent.spawn),
@@ -19,6 +19,9 @@ export function fakeFiles(files: Readonly<Record<string, string>>, links: Readon
19
19
  return below(path).length > 0 ? 'dir' : undefined
20
20
  }
21
21
  const sizeOf = (path: string) => new TextEncoder().encode(contents.get(path) ?? '').length
22
+ const modified = new Map<string, number>()
23
+ let writes = 0
24
+ const mtimeOf = (path: string) => modified.get(path) ?? 0
22
25
 
23
26
  return {
24
27
  read: async (path) => {
@@ -27,7 +30,10 @@ export function fakeFiles(files: Readonly<Record<string, string>>, links: Readon
27
30
  return text
28
31
  },
29
32
  write: async (path, text) => {
30
- contents.set(realPathOf(path), text)
33
+ const realPath = realPathOf(path)
34
+ contents.set(realPath, text)
35
+ writes += 1
36
+ modified.set(realPath, writes)
31
37
  },
32
38
  list: async (path) => {
33
39
  if (path === undefined) throw new Error('fs.list() lists the working folder, which the fake files do not know. Name the folder, such as fs.list(mod.cwd).')
@@ -37,7 +43,7 @@ export function fakeFiles(files: Readonly<Record<string, string>>, links: Readon
37
43
  return [...names].map((name) => {
38
44
  const entry = resolve(folder, name)
39
45
  if (Object.hasOwn(links, entry)) return { name, kind: 'other', size: 0, mtimeMs: 0, isLink: true }
40
- return { name, kind: contents.has(entry) ? 'file' : 'dir', size: sizeOf(entry), mtimeMs: 0, isLink: false }
46
+ return { name, kind: contents.has(entry) ? 'file' : 'dir', size: sizeOf(entry), mtimeMs: mtimeOf(entry), isLink: false }
41
47
  })
42
48
  },
43
49
  exists: async (path) => kindOf(realPathOf(path)) !== undefined,
@@ -47,7 +53,7 @@ export function fakeFiles(files: Readonly<Record<string, string>>, links: Readon
47
53
  const isLink = Object.hasOwn(links, resolve('/', path))
48
54
  if (kind === undefined && isLink) return { kind: 'other', size: 0, mtimeMs: 0, isLink }
49
55
  if (kind === undefined) throw new Error(`ENOENT: no such file or directory, stat '${path}'`)
50
- return { kind, size: sizeOf(realPath), mtimeMs: 0, isLink, ...(options?.resolve === true ? { realPath } : {}) }
56
+ return { kind, size: sizeOf(realPath), mtimeMs: mtimeOf(realPath), isLink, ...(options?.resolve === true ? { realPath } : {}) }
51
57
  },
52
58
  }
53
59
  }
package/src/testing.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  EventResult,
8
8
  Frozen,
9
9
  PaneOpenArgs,
10
+ PluginOptions,
10
11
  PressedLink,
11
12
  RenderChildren,
12
13
  RenderComponent,
@@ -18,26 +19,32 @@ import type {
18
19
  } from 'claude-code'
19
20
  import type { Reply } from './jobs/slash-command.js'
20
21
  import type { ModDefinition, ModEvent } from './mod.js'
21
- import { storeFolder } from './records.js'
22
+ import type { Options, OptionValues } from './options.js'
23
+ import { readSteps, storeFolder, type Steps } from './records.js'
22
24
  import type { Claude } from './runtime/claude.js'
23
25
  import { answerCall, notInstalled } from './runtime/dependencies.js'
24
26
  import type { RoutedEvent } from './runtime/hooks.js'
25
27
  import { toolInputOf } from './runtime/tool-calls.js'
26
28
  import { createLifecycle } from './runtime/lifecycle.js'
29
+ import { MissingOptions } from './runtime/options.js'
27
30
  import { fakeClaude, type Fakes, type Shown, type TestCall } from './testing/fake-claude.js'
28
31
  import { elements, findElement, rowsOf } from './testing/fake-elements.js'
29
32
  import { fakeFiles } from './testing/fake-files.js'
33
+ import { listed } from './utils/text.js'
30
34
  import type { Slot, SlotProps } from './ui/slots.js'
31
35
 
32
36
  export type { Fakes, Shown, TestCall } from './testing/fake-claude.js'
33
37
 
34
- export type TestOptions<State extends object> = {
38
+ export type TestOptions<State extends object, Declared extends Options = Options> = {
35
39
  readonly state?: { readonly [Lifetime in keyof State]?: Partial<State[Lifetime]> }
40
+ readonly options?: Partial<OptionValues<Declared>>
41
+ readonly permissions?: readonly string[]
36
42
  readonly scope?: 'user' | 'project'
37
43
  readonly projectRoot?: string
38
44
  readonly cwd?: string
39
45
  readonly dependencies?: { readonly [Name in keyof CmodDependencies]?: CmodDependencies[Name] }
40
46
  readonly files?: Readonly<Record<string, string>>
47
+ readonly links?: Readonly<Record<string, string>>
41
48
  }
42
49
 
43
50
  type FilledField = 'session_id' | 'transcript_path' | 'cwd' | 'hook_event_name' | 'tool_use_id'
@@ -71,7 +78,7 @@ const presentation: CommandPresentation = { isFullscreen: false, columns: defaul
71
78
 
72
79
  const toolUseEvents: readonly string[] = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'] satisfies readonly ModEvent[]
73
80
 
74
- export function testMod<State extends object>(definition: ModDefinition<State>, options: TestOptions<State> = {}): TestedMod<State> {
81
+ export function testMod<State extends object, Declared extends Options = Options>(definition: ModDefinition<State, string, Declared>, options: TestOptions<State, Declared> = {}): TestedMod<State> {
75
82
  installJsx()
76
83
  const name = definition.name
77
84
  const isProjectPlugin = options.scope === 'project'
@@ -93,25 +100,34 @@ export function testMod<State extends object>(definition: ModDefinition<State>,
93
100
  if (fake.fakes.agent.list !== undefined || firedAgents.size === 0) return listAgents()
94
101
  return [...firedAgents].map(([id, type]) => ({ id, type, description: '', status: 'running' as const }))
95
102
  }
96
- Object.assign(fake.fakes.fs, fakeFiles(options.files ?? {}))
103
+ Object.assign(fake.fakes.fs, fakeFiles(options.files ?? {}, options.links))
97
104
  const starting = options.state as Record<string, object> | undefined
98
105
  const declared = Object.entries(definition.state ?? {}) as [string, object][]
99
106
  const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }])) as State
100
- const lifecycle = createLifecycle<State>({ ...definition, state })
107
+ const lifecycle = createLifecycle<State, Declared>({ ...definition, state }, () => true, (options.options ?? {}) as PluginOptions, false)
101
108
  let started: Promise<void> | undefined
102
109
 
103
110
  const start = async () => {
104
- started ??= lifecycle.start(fake.claude, async (claude) => ({
105
- name,
106
- root,
107
- version: '0.0.0',
108
- store: storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() }),
109
- isInstalled: true,
110
- shouldRecord: false,
111
- keys: {},
112
- }))
111
+ started ??= lifecycle.start(fake.claude, async (claude) => {
112
+ const steps = await modSteps()
113
+ return {
114
+ name,
115
+ root,
116
+ version: '0.0.0',
117
+ store: storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() }),
118
+ isInstalled: true,
119
+ shouldRecord: false,
120
+ steps,
121
+ granted: options.permissions ?? steps.permissions ?? [],
122
+ }
123
+ })
113
124
  await started
114
- if (lifecycle.phase !== 'active') throw lifecycle.failure ?? new Error(`${name} did not start: the lifecycle is ${lifecycle.phase}.`)
125
+ const { failure } = lifecycle
126
+ if (failure instanceof MissingOptions) {
127
+ const given = failure.keys.map((key) => `${key}: …`).join(', ')
128
+ throw new Error(`${name} needs ${listed(failure.titles)}, which a person sets in /config. Give ${failure.keys.length === 1 ? 'it' : 'them'} to the tested mod: testMod(mod, { options: { ${given} } }).`)
129
+ }
130
+ if (lifecycle.phase !== 'active') throw failure ?? new Error(`${name} did not start: the lifecycle is ${lifecycle.phase}.`)
115
131
  }
116
132
 
117
133
  const route = async <N extends RoutedEvent>(event: N, input: unknown, below: unknown): Promise<EventResult<N>> => {
@@ -282,6 +298,15 @@ function replyOf({ text, context }: CommandRunResult): Reply {
282
298
  return { ...(text === undefined ? {} : { text }), ...(context === undefined ? {} : { context: context.join('\n') }) }
283
299
  }
284
300
 
301
+ async function modSteps(): Promise<Steps> {
302
+ const manifest = '../../../package.json'
303
+ const loaded: unknown = await import(manifest, { with: { type: 'json' } }).then(
304
+ (module: { default: unknown }) => module.default,
305
+ () => undefined,
306
+ )
307
+ return readSteps(loaded) ?? {}
308
+ }
309
+
285
310
  function installJsx(): void {
286
311
  const scope = globalThis as unknown as Record<string, unknown>
287
312
  scope['h'] ??= (tag: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => {
@@ -0,0 +1,242 @@
1
+ export type MetadataFormat = { readonly kind: 'frontmatter' } | { readonly kind: 'comment'; readonly prefix: string } | { readonly kind: 'sidecar' }
2
+
3
+ export type ReadMetadata = {
4
+ readonly metadata: Readonly<Record<string, string>>
5
+ readonly name?: string
6
+ }
7
+
8
+ export class MetadataError extends Error {
9
+ readonly line: number
10
+
11
+ constructor(line: number, message: string) {
12
+ super(`line ${line}: ${message}`)
13
+ this.line = line
14
+ }
15
+ }
16
+
17
+ const markdown: readonly string[] = ['md', 'markdown', 'mdx']
18
+ const hashComments: readonly string[] = ['sh', 'bash', 'zsh', 'fish', 'py', 'rb', 'pl', 'r', 'yaml', 'yml', 'toml', 'ps1', 'nu', 'tcl', 'mk', 'conf', 'cfg', 'env']
19
+ const slashComments: readonly string[] = ['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs', 'go', 'rs', 'swift', 'kt', 'kts', 'java', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'scala', 'dart', 'zig', 'jsonc', 'json5']
20
+ const dashComments: readonly string[] = ['lua', 'sql', 'hs', 'elm']
21
+ const hashCommentNames: readonly string[] = ['Makefile', 'Dockerfile', 'Brewfile', 'Gemfile', 'Rakefile', 'Justfile', 'justfile']
22
+
23
+ export const sidecarSuffix = '.meta'
24
+
25
+ export function formatOf(path: string, firstLine: string | undefined): MetadataFormat {
26
+ const name = path.slice(path.lastIndexOf('/') + 1)
27
+ const dot = name.lastIndexOf('.')
28
+ const extension = dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
29
+ if (markdown.includes(extension)) return { kind: 'frontmatter' }
30
+ if (hashComments.includes(extension) || hashCommentNames.includes(name)) return { kind: 'comment', prefix: '#' }
31
+ if (slashComments.includes(extension)) return { kind: 'comment', prefix: '//' }
32
+ if (dashComments.includes(extension)) return { kind: 'comment', prefix: '--' }
33
+ if (extension === '' && firstLine?.startsWith('#!') === true) return { kind: 'comment', prefix: '#' }
34
+ return { kind: 'sidecar' }
35
+ }
36
+
37
+ type Region = { readonly start: number; readonly end: number; readonly lines: readonly string[] }
38
+
39
+ type Entry = { readonly key: string; readonly value: string }
40
+
41
+ type Block = { readonly start: number; readonly end: number; readonly entries: readonly Entry[] }
42
+
43
+ export function readMetadata(text: string, format: MetadataFormat): ReadMetadata {
44
+ const lines = text.split('\n')
45
+ if (format.kind === 'comment') {
46
+ const region = commentRegion(lines, format.prefix)
47
+ return { metadata: region === undefined ? {} : asRecord(mapEntries(region.lines, region.start + 2, 0)) }
48
+ }
49
+ const region = format.kind === 'frontmatter' ? frontmatterRegion(lines) : { start: -1, end: lines.length, lines }
50
+ if (region === undefined) return { metadata: {} }
51
+ const block = metadataBlock(region)
52
+ const name = format.kind === 'frontmatter' ? topLevelScalar(region, 'name') : undefined
53
+ return { metadata: block === undefined ? {} : asRecord(block.entries), ...(name === undefined ? {} : { name }) }
54
+ }
55
+
56
+ export function writeMetadata(text: string, format: MetadataFormat, metadata: Readonly<Record<string, string>>): string {
57
+ const lines = text.split('\n')
58
+ const entries = Object.entries(metadata)
59
+ if (format.kind === 'comment') {
60
+ const { prefix } = format
61
+ const block = entries.length === 0 ? [] : [`${prefix} /// metadata`, ...entries.map(([key, value]) => `${prefix} ${scalar(key)}: ${scalar(value)}`), `${prefix} ///`]
62
+ const region = commentRegion(lines, prefix)
63
+ if (region !== undefined) return [...lines.slice(0, region.start), ...block, ...lines.slice(region.end + 1)].join('\n')
64
+ if (block.length === 0) return text
65
+ const at = lines[0]?.startsWith('#!') === true ? 1 : 0
66
+ return [...lines.slice(0, at), ...block, ...lines.slice(at)].join('\n')
67
+ }
68
+ const map = entries.length === 0 ? [] : ['metadata:', ...entries.map(([key, value]) => ` ${scalar(key)}: ${scalar(value)}`)]
69
+ if (format.kind === 'sidecar') {
70
+ const block = metadataBlock({ start: -1, end: lines.length, lines })
71
+ const rest = block === undefined ? lines : [...lines.slice(0, block.start), ...lines.slice(block.end)]
72
+ const kept = rest.join('\n').trim()
73
+ return [...(kept === '' ? [] : [kept]), ...map].join('\n') + (map.length === 0 && kept === '' ? '' : '\n')
74
+ }
75
+ const region = frontmatterRegion(lines)
76
+ if (region === undefined) return map.length === 0 ? text : ['---', ...map, '---', ...lines].join('\n')
77
+ const block = metadataBlock(region)
78
+ const inner = block === undefined ? [...region.lines, ...map] : [...lines.slice(region.start + 1, block.start), ...map, ...lines.slice(block.end, region.end)]
79
+ if (inner.every((line) => line.trim() === '')) return lines.slice(region.end + 1).join('\n')
80
+ return [...lines.slice(0, region.start + 1), ...inner, ...lines.slice(region.end)].join('\n')
81
+ }
82
+
83
+ function frontmatterRegion(lines: readonly string[]): Region | undefined {
84
+ if (lines[0]?.replace(/^\uFEFF/, '').trimEnd() !== '---') return undefined
85
+ const end = lines.findIndex((line, index) => index > 0 && line.trimEnd() === '---')
86
+ if (end === -1) throw new MetadataError(1, 'the frontmatter starts with --- and never ends. Close it with a --- line.')
87
+ return { start: 0, end, lines: lines.slice(1, end) }
88
+ }
89
+
90
+ function commentRegion(lines: readonly string[], prefix: string): Region | undefined {
91
+ const start = lines.findIndex((line) => line.trimEnd() === `${prefix} /// metadata`)
92
+ if (start === -1) return undefined
93
+ const content: string[] = []
94
+ for (let index = start + 1; index < lines.length; index += 1) {
95
+ const line = (lines[index] ?? '').trimEnd()
96
+ if (line === `${prefix} ///`) return { start, end: index, lines: content }
97
+ if (line !== prefix && !line.startsWith(`${prefix} `)) break
98
+ content.push(line.slice(prefix.length + 1))
99
+ }
100
+ throw new MetadataError(start + 1, `the metadata block never ends. Close it with a "${prefix} ///" line.`)
101
+ }
102
+
103
+ function metadataBlock(region: Region): Block | undefined {
104
+ const at = region.lines.findIndex((line) => /^metadata\s*:/.test(line))
105
+ if (at === -1) return undefined
106
+ const lineNumber = region.start + 2 + at
107
+ const rest = withoutComment((region.lines[at] ?? '').replace(/^metadata\s*:/, '')).trim()
108
+ const start = region.start + 1 + at
109
+ if (rest.startsWith('{')) return { start, end: start + 1, entries: flowEntries(rest, lineNumber) }
110
+ if (rest !== '') throw new MetadataError(lineNumber, 'metadata holds a single value. Write a map of names to text, such as metadata: { mymod.key: value }.')
111
+ let index = at + 1
112
+ while (index < region.lines.length && (/^\s/.test(region.lines[index] ?? '') || (region.lines[index] ?? '').trim() === '')) index += 1
113
+ while (index > at + 1 && (region.lines[index - 1] ?? '').trim() === '') index -= 1
114
+ const children = region.lines.slice(at + 1, index)
115
+ return { start, end: region.start + 1 + index, entries: mapEntries(children, lineNumber + 1, indentOf(children)) }
116
+ }
117
+
118
+ function indentOf(lines: readonly string[]): number {
119
+ const first = lines.find((line) => line.trim() !== '' && !line.trim().startsWith('#'))
120
+ return first === undefined ? 0 : (/^\s*/.exec(first)?.[0].length ?? 0)
121
+ }
122
+
123
+ function mapEntries(lines: readonly string[], firstLine: number, indent: number): Entry[] {
124
+ const entries: Entry[] = []
125
+ lines.forEach((raw, index) => {
126
+ const lineNumber = firstLine + index
127
+ const line = withoutComment(raw)
128
+ if (line.trim() === '') return
129
+ const depth = /^\s*/.exec(line)?.[0].length ?? 0
130
+ if (depth !== indent) throw new MetadataError(lineNumber, 'a metadata value holds a list or a map. Each value is one line of text, and a list is one space-separated text.')
131
+ const body = line.trim()
132
+ if (body.startsWith('- ')) throw new MetadataError(lineNumber, 'metadata holds a list. Write a map of names to text, and a list as one space-separated text.')
133
+ entries.push(entryOf(body, lineNumber))
134
+ })
135
+ return entries
136
+ }
137
+
138
+ function flowEntries(text: string, lineNumber: number): Entry[] {
139
+ if (!text.endsWith('}')) throw new MetadataError(lineNumber, 'the metadata map starts with { and does not end on the same line. Close it with }, or write one entry per line.')
140
+ const body = text.slice(1, -1).trim()
141
+ if (body === '') return []
142
+ return splitOutsideQuotes(body, ',', lineNumber)
143
+ .map((part) => part.trim())
144
+ .filter((part) => part !== '')
145
+ .map((part) => entryOf(part, lineNumber))
146
+ }
147
+
148
+ function entryOf(text: string, lineNumber: number): Entry {
149
+ const [keyText, ...rest] = splitOutsideQuotes(text, ':', lineNumber, true)
150
+ if (keyText === undefined || rest.length === 0) throw new MetadataError(lineNumber, `"${text}" has no ":". Write each entry as name: value.`)
151
+ const valueText = rest.join(':').trim()
152
+ if (valueText.startsWith('{') || valueText.startsWith('[') || valueText === '|' || valueText === '>') throw new MetadataError(lineNumber, `${keyText.trim()} holds a list or a map. Each value is one line of text, and a list is one space-separated text.`)
153
+ return { key: unquoted(keyText.trim(), lineNumber), value: unquoted(valueText, lineNumber) }
154
+ }
155
+
156
+ function splitOutsideQuotes(text: string, separator: string, lineNumber: number, needsSpace = false): string[] {
157
+ const parts: string[] = []
158
+ let quote: string | undefined
159
+ let current = ''
160
+ for (let index = 0; index < text.length; index += 1) {
161
+ const char = text[index] as string
162
+ if (quote !== undefined) {
163
+ current += char
164
+ if (char === '\\' && quote === '"') {
165
+ current += text[index + 1] ?? ''
166
+ index += 1
167
+ } else if (char === quote) quote = undefined
168
+ continue
169
+ }
170
+ const before = current.trimEnd().at(-1)
171
+ if ((char === '"' || char === "'") && (before === undefined || before === ':')) quote = char
172
+ const next = text[index + 1]
173
+ if (char === separator && (!needsSpace || next === undefined || next === ' ' || next === '\t')) {
174
+ parts.push(current)
175
+ current = ''
176
+ if (needsSpace) {
177
+ parts.push(text.slice(index + 1))
178
+ return parts
179
+ }
180
+ continue
181
+ }
182
+ current += char
183
+ }
184
+ if (quote !== undefined) throw new MetadataError(lineNumber, `a ${quote === '"' ? 'double' : 'single'} quote never closes.`)
185
+ parts.push(current)
186
+ return parts
187
+ }
188
+
189
+ function withoutComment(line: string): string {
190
+ let quote: string | undefined
191
+ for (let index = 0; index < line.length; index += 1) {
192
+ const char = line[index]
193
+ if (quote !== undefined) {
194
+ if (char === '\\' && quote === '"') index += 1
195
+ else if (char === quote) quote = undefined
196
+ continue
197
+ }
198
+ if (char === '"' || char === "'") quote = char
199
+ if (char === '#' && (index === 0 || /\s/.test(line[index - 1] ?? ''))) return line.slice(0, index)
200
+ }
201
+ return line
202
+ }
203
+
204
+ function unquoted(text: string, lineNumber: number): string {
205
+ if (text.startsWith('"')) {
206
+ if (!text.endsWith('"') || text.length < 2) throw new MetadataError(lineNumber, `${text} opens a double quote that never closes.`)
207
+ try {
208
+ return JSON.parse(text) as string
209
+ } catch {
210
+ throw new MetadataError(lineNumber, `${text} is not a double-quoted text. Escape a " inside it as \\".`)
211
+ }
212
+ }
213
+ if (text.startsWith("'")) {
214
+ if (!text.endsWith("'") || text.length < 2) throw new MetadataError(lineNumber, `${text} opens a single quote that never closes.`)
215
+ return text.slice(1, -1).replaceAll("''", "'")
216
+ }
217
+ return text
218
+ }
219
+
220
+ function topLevelScalar(region: Region, key: string): string | undefined {
221
+ const at = region.lines.findIndex((line) => line.startsWith(`${key}:`))
222
+ if (at === -1) return undefined
223
+ const text = withoutComment((region.lines[at] ?? '').slice(key.length + 1)).trim()
224
+ if (text === '') return undefined
225
+ try {
226
+ return unquoted(text, region.start + 2 + at)
227
+ } catch {
228
+ return undefined
229
+ }
230
+ }
231
+
232
+ function asRecord(entries: readonly Entry[]): Readonly<Record<string, string>> {
233
+ return Object.freeze(Object.fromEntries(entries.map(({ key, value }) => [key, value])))
234
+ }
235
+
236
+ const plain = /^[A-Za-z0-9_./+~$%^()=\\][^#]*$/
237
+ const special = /^(true|false|yes|no|on|off|null|~|[-+]?(\d[\d_]*)?\.?\d+([eE][-+]?\d+)?|0x[0-9a-fA-F]+|\.inf|\.nan)$/i
238
+
239
+ function scalar(text: string): string {
240
+ const isPlain = plain.test(text) && text === text.trim() && !text.includes(': ') && !text.endsWith(':') && !text.includes(' #') && !special.test(text) && !/["',{}[\]]/.test(text)
241
+ return isPlain ? text : JSON.stringify(text)
242
+ }