@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.
- package/docs/commands.md +32 -21
- package/docs/dependencies.md +8 -0
- package/docs/index.md +12 -4
- package/docs/install-steps.md +36 -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 +16 -1
- package/records.d.ts.map +1 -1
- package/records.js +119 -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 +24 -0
- package/runtime/installer.d.ts.map +1 -0
- package/runtime/installer.js +118 -0
- package/runtime/lifecycle.d.ts +6 -3
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +226 -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 +131 -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 +169 -0
- package/src/runtime/lifecycle.ts +227 -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,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[]) => {
|
|
@@ -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
|
+
}
|