@cmodjs/core 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/docs/commands.md +31 -19
  2. package/docs/index.md +14 -4
  3. package/docs/install-steps.md +30 -4
  4. package/docs/jobs.md +25 -9
  5. package/docs/mod.md +103 -37
  6. package/docs/options.md +93 -0
  7. package/docs/permissions.md +88 -0
  8. package/docs/state.md +4 -31
  9. package/docs/testing.md +13 -2
  10. package/installer.d.ts +10 -0
  11. package/installer.d.ts.map +1 -0
  12. package/installer.js +8 -0
  13. package/jobs/context.d.ts +2 -2
  14. package/jobs/context.d.ts.map +1 -1
  15. package/jobs/context.js +5 -3
  16. package/jobs/permissions.js +1 -1
  17. package/jobs/program.d.ts.map +1 -1
  18. package/jobs/program.js +5 -1
  19. package/jobs/prompt.js +5 -5
  20. package/jobs/schedule.d.ts.map +1 -1
  21. package/jobs/schedule.js +2 -1
  22. package/jobs/slash-command.d.ts.map +1 -1
  23. package/jobs/slash-command.js +3 -2
  24. package/jobs/status-line.d.ts.map +1 -1
  25. package/jobs/status-line.js +3 -2
  26. package/jobs/tool.d.ts.map +1 -1
  27. package/jobs/tool.js +3 -2
  28. package/mod.d.ts +48 -10
  29. package/mod.d.ts.map +1 -1
  30. package/mod.js +3 -0
  31. package/options.d.ts +58 -0
  32. package/options.d.ts.map +1 -0
  33. package/options.js +66 -0
  34. package/package.json +1 -1
  35. package/records.d.ts +14 -1
  36. package/records.d.ts.map +1 -1
  37. package/records.js +106 -7
  38. package/register.d.ts +4 -2
  39. package/register.d.ts.map +1 -1
  40. package/register.js +8 -2
  41. package/runtime/claude.d.ts +5 -1
  42. package/runtime/claude.d.ts.map +1 -1
  43. package/runtime/grants.d.ts +28 -0
  44. package/runtime/grants.d.ts.map +1 -0
  45. package/runtime/grants.js +286 -0
  46. package/runtime/installer.d.ts +23 -0
  47. package/runtime/installer.d.ts.map +1 -0
  48. package/runtime/installer.js +113 -0
  49. package/runtime/lifecycle.d.ts +6 -3
  50. package/runtime/lifecycle.d.ts.map +1 -1
  51. package/runtime/lifecycle.js +220 -36
  52. package/runtime/metadata.d.ts +20 -0
  53. package/runtime/metadata.d.ts.map +1 -0
  54. package/runtime/metadata.js +143 -0
  55. package/runtime/options.d.ts +21 -0
  56. package/runtime/options.d.ts.map +1 -0
  57. package/runtime/options.js +100 -0
  58. package/runtime/state.d.ts +1 -0
  59. package/runtime/state.d.ts.map +1 -1
  60. package/runtime/state.js +5 -75
  61. package/src/installer.ts +17 -0
  62. package/src/jobs/context.ts +5 -3
  63. package/src/jobs/permissions.ts +1 -1
  64. package/src/jobs/program.ts +4 -1
  65. package/src/jobs/prompt.ts +5 -5
  66. package/src/jobs/schedule.ts +2 -1
  67. package/src/jobs/slash-command.ts +3 -2
  68. package/src/jobs/status-line.ts +3 -2
  69. package/src/jobs/tool.ts +3 -2
  70. package/src/mod.ts +46 -11
  71. package/src/options.ts +91 -0
  72. package/src/records.ts +117 -8
  73. package/src/register.ts +12 -3
  74. package/src/runtime/claude.ts +2 -0
  75. package/src/runtime/grants.ts +306 -0
  76. package/src/runtime/installer.ts +163 -0
  77. package/src/runtime/lifecycle.ts +220 -45
  78. package/src/runtime/metadata.ts +153 -0
  79. package/src/runtime/options.ts +102 -0
  80. package/src/runtime/state.ts +7 -70
  81. package/src/testing/fake-claude.ts +6 -1
  82. package/src/testing/fake-files.ts +9 -3
  83. package/src/testing.ts +40 -15
  84. package/src/utils/metadata.ts +242 -0
  85. package/testing/fake-claude.d.ts +4 -0
  86. package/testing/fake-claude.d.ts.map +1 -1
  87. package/testing/fake-claude.js +5 -1
  88. package/testing/fake-files.d.ts.map +1 -1
  89. package/testing/fake-files.js +9 -3
  90. package/testing.d.ts +6 -2
  91. package/testing.d.ts.map +1 -1
  92. package/testing.js +29 -13
  93. package/utils/metadata.d.ts +21 -0
  94. package/utils/metadata.d.ts.map +1 -0
  95. package/utils/metadata.js +250 -0
@@ -0,0 +1,153 @@
1
+ import type { FileMatch, Metadata } from '../mod.js'
2
+ import { formatOf, MetadataError, readMetadata, sidecarSuffix, writeMetadata, type MetadataFormat } from '../utils/metadata.js'
3
+ import { messageOf } from '../utils/text.js'
4
+ import { picomatch, resolve } from '../vendor.js'
5
+ import type { Claude } from './claude.js'
6
+
7
+ export type Places = {
8
+ readonly home: string | undefined
9
+ readonly configRoot: string | undefined
10
+ readonly projectRoot: () => string
11
+ }
12
+
13
+ export type ModFiles = {
14
+ find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
15
+ read(path: string): Promise<Metadata>
16
+ update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>
17
+ }
18
+
19
+ type Read = { readonly metadata: Metadata; readonly name?: string; readonly error?: string }
20
+
21
+ type Located = { readonly realPath: string; readonly format: MetadataFormat; readonly holder: string; readonly signature: string }
22
+
23
+ const skippedFolders: readonly string[] = ['.git', 'node_modules']
24
+
25
+ const ownKey = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/
26
+
27
+ export function resolvedPath(path: string, places: Places): string {
28
+ const configured = /^~\/\.claude(?=\/|$)/.exec(path)
29
+ 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)}`)
33
+ }
34
+ return path.startsWith('/') ? resolve(path) : resolve(places.projectRoot(), path)
35
+ }
36
+
37
+ export function createModFiles({ name, claude, places, checkWrite }: { readonly name: string; readonly claude: Claude; readonly places: Places; readonly checkWrite: (path: string) => Promise<void> }): ModFiles {
38
+ const prefix = `${name}.`
39
+ const cache = new Map<string, { readonly signature: string; readonly read: Read }>()
40
+ const logged = new Set<string>()
41
+
42
+ const own = (all: Metadata): Metadata => Object.freeze(Object.fromEntries(Object.entries(all).flatMap(([key, value]) => (key.startsWith(prefix) ? [[key.slice(prefix.length), value]] : []))))
43
+
44
+ const locate = async (path: string): Promise<Located> => {
45
+ const stat = await claude.fs.stat(path, { resolve: true })
46
+ if (stat.kind !== 'file') throw new Error(`${path} is not a file, so it holds no metadata.`)
47
+ const realPath = stat.realPath ?? path
48
+ const extensionless = !realPath.slice(realPath.lastIndexOf('/') + 1).includes('.')
49
+ const firstLine = extensionless ? (await claude.fs.read(realPath)).split('\n', 1)[0] : undefined
50
+ 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
53
+ return { realPath, format, holder, signature: `${stat.mtimeMs}:${stat.size}:${sidecar?.kind === 'file' ? `${sidecar.mtimeMs}:${sidecar.size}` : ''}` }
54
+ }
55
+
56
+ const textOf = async (holder: string): Promise<string> => ((await claude.fs.stat(holder).catch(() => undefined))?.kind === 'file' ? claude.fs.read(holder) : '')
57
+
58
+ const readEntry = async (path: string): Promise<Read> => {
59
+ const located = await locate(path)
60
+ const cached = cache.get(path)
61
+ if (cached?.signature === located.signature) return cached.read
62
+ let read: Read
63
+ try {
64
+ const { metadata, name: declared } = readMetadata(await textOf(located.holder), located.format)
65
+ read = { metadata, ...(declared === undefined ? {} : { name: declared }) }
66
+ } catch (error) {
67
+ if (!(error instanceof MetadataError)) throw error
68
+ const failure = `${located.holder} ${error.message}`
69
+ read = { metadata: {}, error: failure }
70
+ if (!logged.has(failure)) claude.ui.log(`${name}: ${failure}`)
71
+ logged.add(failure)
72
+ }
73
+ cache.set(path, { signature: located.signature, read })
74
+ return read
75
+ }
76
+
77
+ const matchesOf = async (pattern: string): Promise<string[]> => {
78
+ const absolute = resolvedPath(pattern, places)
79
+ const scanned = picomatch.scan(absolute)
80
+ if (!scanned.isGlob) return (await claude.fs.stat(absolute).catch(() => undefined))?.kind === 'file' ? [absolute] : []
81
+ const isMatch = picomatch(absolute, { dot: true })
82
+ const entersSkipped = skippedFolders.filter((folder) => scanned.glob.split('/').includes(folder))
83
+ const maxDepth = scanned.glob.includes('**') ? Number.POSITIVE_INFINITY : scanned.glob.split('/').length
84
+ const found: string[] = []
85
+ const visited = new Set<string>()
86
+ const walk = async (folder: string, depth: number): Promise<void> => {
87
+ const real = (await claude.fs.stat(folder, { resolve: true }).catch(() => undefined))?.realPath ?? folder
88
+ if (visited.has(real)) return
89
+ visited.add(real)
90
+ const entries = await claude.fs.list(folder).catch(() => [])
91
+ await Promise.all(
92
+ entries.map(async (entry) => {
93
+ const path = `${folder}/${entry.name}`
94
+ const kind = entry.isLink === true ? (await claude.fs.stat(path).catch(() => undefined))?.kind : entry.kind
95
+ if (kind === 'file' && isMatch(path)) found.push(path)
96
+ if (kind !== 'dir' || depth + 1 >= maxDepth) return
97
+ if (skippedFolders.includes(entry.name) && !entersSkipped.includes(entry.name)) return
98
+ await walk(path, depth + 1)
99
+ }),
100
+ )
101
+ }
102
+ const base = scanned.base === '' ? '/' : scanned.base
103
+ if ((await claude.fs.stat(base).catch(() => undefined))?.kind === 'dir') await walk(base, 0)
104
+ return found
105
+ }
106
+
107
+ return {
108
+ async find(glob) {
109
+ const patterns = typeof glob === 'string' ? [glob] : glob
110
+ const paths = [...new Set((await Promise.all(patterns.map(matchesOf))).flat())].sort()
111
+ const kept = paths.filter((path) => !(path.endsWith(sidecarSuffix) && paths.includes(path.slice(0, -sidecarSuffix.length))))
112
+ return Promise.all(
113
+ kept.map(async (path): Promise<FileMatch> => {
114
+ const read = await readEntry(path)
115
+ return { path, ...(read.name === undefined ? {} : { name: read.name }), metadata: own(read.metadata), ...(read.error === undefined ? {} : { error: read.error }) }
116
+ }),
117
+ )
118
+ },
119
+ async read(path) {
120
+ const read = await readEntry(resolvedPath(path, places))
121
+ if (read.error !== undefined) throw new Error(`${name}: ${read.error}`)
122
+ return own(read.metadata)
123
+ },
124
+ async update(path, change) {
125
+ const given = resolvedPath(path, places)
126
+ await checkWrite(given)
127
+ const located = await locate(given)
128
+ const text = await textOf(located.holder)
129
+ let all: Metadata
130
+ try {
131
+ all = readMetadata(text, located.format).metadata
132
+ } catch (error) {
133
+ throw new Error(`${name}: ${located.holder} ${messageOf(error)}`, { cause: error })
134
+ }
135
+ const edited: Record<string, string> = { ...own(all) }
136
+ change(edited)
137
+ for (const [key, value] of Object.entries(edited)) {
138
+ if (!ownKey.test(key)) throw new Error(`${name}: the metadata key "${key}" is not a name. Use letters, digits, "_", ".", and "-".`)
139
+ if (typeof value !== 'string') throw new Error(`${name}: the metadata key "${key}" holds ${JSON.stringify(value)}. Each value is text, and a list is one space-separated text.`)
140
+ if (value.includes('\n')) throw new Error(`${name}: the metadata key "${key}" holds a line break. Each value is one line of text.`)
141
+ }
142
+ const next: Record<string, string> = {}
143
+ for (const [key, value] of Object.entries(all)) {
144
+ if (!key.startsWith(prefix)) next[key] = value
145
+ else if (Object.hasOwn(edited, key.slice(prefix.length))) next[key] = edited[key.slice(prefix.length)] as string
146
+ }
147
+ for (const [key, value] of Object.entries(edited)) next[`${prefix}${key}`] = value
148
+ if (JSON.stringify(next) === JSON.stringify(all)) return
149
+ await claude.fs.write(located.holder, writeMetadata(text, located.format, next))
150
+ cache.delete(given)
151
+ },
152
+ }
153
+ }
@@ -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[]) => {