@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.
- package/docs/commands.md +31 -19
- package/docs/index.md +14 -4
- package/docs/install-steps.md +30 -4
- package/docs/jobs.md +25 -9
- package/docs/mod.md +103 -37
- package/docs/options.md +93 -0
- package/docs/permissions.md +88 -0
- package/docs/state.md +4 -31
- package/docs/testing.md +13 -2
- package/installer.d.ts +10 -0
- package/installer.d.ts.map +1 -0
- package/installer.js +8 -0
- package/jobs/context.d.ts +2 -2
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +5 -3
- package/jobs/permissions.js +1 -1
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +5 -1
- package/jobs/prompt.js +5 -5
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +2 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +3 -2
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +3 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +3 -2
- package/mod.d.ts +48 -10
- package/mod.d.ts.map +1 -1
- package/mod.js +3 -0
- package/options.d.ts +58 -0
- package/options.d.ts.map +1 -0
- package/options.js +66 -0
- package/package.json +1 -1
- package/records.d.ts +14 -1
- package/records.d.ts.map +1 -1
- package/records.js +106 -7
- package/register.d.ts +4 -2
- package/register.d.ts.map +1 -1
- package/register.js +8 -2
- package/runtime/claude.d.ts +5 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/grants.d.ts +28 -0
- package/runtime/grants.d.ts.map +1 -0
- package/runtime/grants.js +286 -0
- package/runtime/installer.d.ts +23 -0
- package/runtime/installer.d.ts.map +1 -0
- package/runtime/installer.js +113 -0
- package/runtime/lifecycle.d.ts +6 -3
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +220 -36
- package/runtime/metadata.d.ts +20 -0
- package/runtime/metadata.d.ts.map +1 -0
- package/runtime/metadata.js +143 -0
- package/runtime/options.d.ts +21 -0
- package/runtime/options.d.ts.map +1 -0
- package/runtime/options.js +100 -0
- package/runtime/state.d.ts +1 -0
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +5 -75
- package/src/installer.ts +17 -0
- package/src/jobs/context.ts +5 -3
- package/src/jobs/permissions.ts +1 -1
- package/src/jobs/program.ts +4 -1
- package/src/jobs/prompt.ts +5 -5
- package/src/jobs/schedule.ts +2 -1
- package/src/jobs/slash-command.ts +3 -2
- package/src/jobs/status-line.ts +3 -2
- package/src/jobs/tool.ts +3 -2
- package/src/mod.ts +46 -11
- package/src/options.ts +91 -0
- package/src/records.ts +117 -8
- package/src/register.ts +12 -3
- package/src/runtime/claude.ts +2 -0
- package/src/runtime/grants.ts +306 -0
- package/src/runtime/installer.ts +163 -0
- package/src/runtime/lifecycle.ts +220 -45
- package/src/runtime/metadata.ts +153 -0
- package/src/runtime/options.ts +102 -0
- package/src/runtime/state.ts +7 -70
- package/src/testing/fake-claude.ts +6 -1
- package/src/testing/fake-files.ts +9 -3
- package/src/testing.ts +40 -15
- package/src/utils/metadata.ts +242 -0
- package/testing/fake-claude.d.ts +4 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +5 -1
- package/testing/fake-files.d.ts.map +1 -1
- package/testing/fake-files.js +9 -3
- package/testing.d.ts +6 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +29 -13
- package/utils/metadata.d.ts +21 -0
- package/utils/metadata.d.ts.map +1 -0
- 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
|
+
}
|
package/src/runtime/state.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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[]) => {
|