@xl0/pi-lovely-agents 0.1.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/extensions/lovely-agents/agent.ts +1374 -0
- package/extensions/lovely-agents/bash.ts +599 -0
- package/extensions/lovely-agents/child-session.ts +296 -0
- package/extensions/lovely-agents/config.ts +221 -0
- package/extensions/lovely-agents/coordinator.ts +506 -0
- package/extensions/lovely-agents/definitions.ts +380 -0
- package/extensions/lovely-agents/index.ts +400 -0
- package/extensions/lovely-agents/lifecycle.ts +251 -0
- package/extensions/lovely-agents/management.ts +638 -0
- package/extensions/lovely-agents/notifications.ts +220 -0
- package/extensions/lovely-agents/provider-limits.ts +13 -0
- package/extensions/lovely-agents/rendering.ts +90 -0
- package/extensions/lovely-agents/state.ts +1179 -0
- package/extensions/lovely-agents/task-panel.ts +192 -0
- package/extensions/lovely-agents/tools.ts +635 -0
- package/extensions/lovely-agents/updates.ts +45 -0
- package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
- package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
- package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
- package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
- package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
- package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
- package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
- package/package.json +68 -0
- package/skills/agent/SKILL.md +21 -0
- package/skills/agent-creator/SKILL.md +35 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join, resolve as resolvePath } from "node:path"
|
|
3
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
|
|
5
|
+
export type ConfigScope = "user" | "workspace"
|
|
6
|
+
export type ConfigPatch = Record<string, unknown>
|
|
7
|
+
export type ScopedConfigPatch = Record<ConfigScope, ConfigPatch>
|
|
8
|
+
export type ResolvedConfig<Config extends object> = { [Key in keyof Config]-?: NonNullable<Config[Key]> }
|
|
9
|
+
export type ConfigWarning = { key?: string; message: string }
|
|
10
|
+
export type ScopedConfigWarning = ConfigWarning & { scope: ConfigScope; path: string }
|
|
11
|
+
|
|
12
|
+
type StringValues = readonly [string, ...string[]]
|
|
13
|
+
type NumberValues = readonly [number, ...number[]]
|
|
14
|
+
|
|
15
|
+
export type VisibilityContext = {
|
|
16
|
+
get(key: string): unknown
|
|
17
|
+
getScoped(key: string, scope?: ConfigScope): unknown
|
|
18
|
+
scope: ConfigScope
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type FieldMeta = {
|
|
22
|
+
label?: string
|
|
23
|
+
description?: string
|
|
24
|
+
depth?: number
|
|
25
|
+
visibleWhen?: (ctx: VisibilityContext) => boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type BaseField = FieldMeta & {
|
|
29
|
+
kind: "enum" | "multiEnum" | "boolean" | "string" | "text" | "number"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type EnumConfigField<Values extends StringValues = StringValues> = BaseField & {
|
|
33
|
+
kind: "enum"
|
|
34
|
+
values: Values
|
|
35
|
+
valueDescriptions?: Partial<Record<Values[number], string>> & Record<string, string>
|
|
36
|
+
search?: boolean
|
|
37
|
+
default: Values[number]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type MultiEnumConfigField<Values extends StringValues = StringValues> = BaseField & {
|
|
41
|
+
kind: "multiEnum"
|
|
42
|
+
values: Values
|
|
43
|
+
valueDescriptions?: Partial<Record<Values[number], string>> & Record<string, string>
|
|
44
|
+
default: readonly Values[number][]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type BooleanConfigField = BaseField & {
|
|
48
|
+
kind: "boolean"
|
|
49
|
+
valueDescriptions?: Partial<Record<"on" | "off", string>>
|
|
50
|
+
default: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type StringConfigField = BaseField & {
|
|
54
|
+
kind: "string"
|
|
55
|
+
default: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type TextConfigField = BaseField & {
|
|
59
|
+
kind: "text"
|
|
60
|
+
default: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type BaseNumberConfigField = BaseField & {
|
|
64
|
+
kind: "number"
|
|
65
|
+
default: number
|
|
66
|
+
valueDescriptions?: Record<string, string>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type RangedNumberConfigField = BaseNumberConfigField & {
|
|
70
|
+
min?: number
|
|
71
|
+
max?: number
|
|
72
|
+
step?: number
|
|
73
|
+
values?: never
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ValuedNumberConfigField<Values extends NumberValues = NumberValues> = BaseNumberConfigField & {
|
|
77
|
+
values: Values
|
|
78
|
+
default: Values[number]
|
|
79
|
+
min?: never
|
|
80
|
+
max?: never
|
|
81
|
+
step?: never
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type NumberConfigField = RangedNumberConfigField | ValuedNumberConfigField
|
|
85
|
+
export type ConfigField =
|
|
86
|
+
| EnumConfigField
|
|
87
|
+
| MultiEnumConfigField
|
|
88
|
+
| BooleanConfigField
|
|
89
|
+
| StringConfigField
|
|
90
|
+
| TextConfigField
|
|
91
|
+
| NumberConfigField
|
|
92
|
+
export type ConfigSchema = Record<string, ConfigField>
|
|
93
|
+
export type ScopedConfigField = ConfigField & { key: string; label: string }
|
|
94
|
+
|
|
95
|
+
export type ConfigFromSchema<Schema extends ConfigSchema> = {
|
|
96
|
+
[Key in keyof Schema]: FieldValue<Schema[Key]>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type FieldValue<Field> = Field extends { kind: "enum"; values: infer Values extends readonly string[] }
|
|
100
|
+
? Values[number]
|
|
101
|
+
: Field extends { kind: "multiEnum"; values: infer Values extends readonly string[] }
|
|
102
|
+
? readonly Values[number][]
|
|
103
|
+
: Field extends { kind: "boolean" }
|
|
104
|
+
? boolean
|
|
105
|
+
: Field extends { kind: "string" | "text" }
|
|
106
|
+
? string
|
|
107
|
+
: Field extends { kind: "number"; values: infer Values extends readonly number[] }
|
|
108
|
+
? Values[number]
|
|
109
|
+
: Field extends { kind: "number" }
|
|
110
|
+
? number
|
|
111
|
+
: never
|
|
112
|
+
|
|
113
|
+
export type ScopedConfig<Config extends object> = {
|
|
114
|
+
fileName: string
|
|
115
|
+
scopes: readonly ConfigScope[]
|
|
116
|
+
fields: readonly ScopedConfigField[]
|
|
117
|
+
defaults: ResolvedConfig<Config>
|
|
118
|
+
cwd: string | undefined
|
|
119
|
+
value: ResolvedConfig<Config>
|
|
120
|
+
scoped: ScopedConfigPatch
|
|
121
|
+
warnings: ScopedConfigWarning[]
|
|
122
|
+
path(scope: ConfigScope, cwd?: string): string
|
|
123
|
+
resolve(scoped?: ScopedConfigPatch): ResolvedConfig<Config>
|
|
124
|
+
load(cwd: string): ScopedConfig<Config>
|
|
125
|
+
update<Key extends keyof Config & string>(scope: ConfigScope, key: Key, value: Config[Key] | undefined): ScopedConfig<Config>
|
|
126
|
+
resetScope(scope: ConfigScope): ScopedConfig<Config>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type EnumFieldOptions<Values extends StringValues> = Omit<EnumConfigField<Values>, "kind" | "values" | "default">
|
|
130
|
+
type MultiEnumFieldOptions<Values extends StringValues> = Omit<MultiEnumConfigField<Values>, "kind" | "values" | "default">
|
|
131
|
+
type BooleanFieldOptions = Omit<BooleanConfigField, "kind" | "default">
|
|
132
|
+
type StringFieldOptions = Omit<StringConfigField, "kind" | "default">
|
|
133
|
+
type TextFieldOptions = Omit<TextConfigField, "kind" | "default">
|
|
134
|
+
type RangedNumberOptions = Omit<RangedNumberConfigField, "kind" | "default">
|
|
135
|
+
type ValuedNumberOptions<Values extends NumberValues> = Omit<ValuedNumberConfigField<Values>, "kind" | "default">
|
|
136
|
+
|
|
137
|
+
function enumField<const Values extends StringValues>(
|
|
138
|
+
values: Values,
|
|
139
|
+
defaultValue: Values[number],
|
|
140
|
+
options: EnumFieldOptions<Values> = {}
|
|
141
|
+
): EnumConfigField<Values> {
|
|
142
|
+
if (values.length === 0) throw new Error("Enum field must have at least one value")
|
|
143
|
+
if (!values.includes(defaultValue)) throw new Error(`Enum field default must be one of: ${values.join(", ")}`)
|
|
144
|
+
return { kind: "enum", values, default: defaultValue, ...options }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function multiEnumField<const Values extends StringValues>(
|
|
148
|
+
values: Values,
|
|
149
|
+
defaultValue: readonly Values[number][],
|
|
150
|
+
options: MultiEnumFieldOptions<Values> = {}
|
|
151
|
+
): MultiEnumConfigField<Values> {
|
|
152
|
+
if (values.length === 0) throw new Error("Multi-enum field must have at least one value")
|
|
153
|
+
const warning = getMultiEnumWarning(values, defaultValue)
|
|
154
|
+
if (warning) throw new Error(`Multi-enum field default ${warning}`)
|
|
155
|
+
return { kind: "multiEnum", values, default: defaultValue, ...options }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getMultiEnumWarning(values: readonly string[], value: unknown): string | undefined {
|
|
159
|
+
if (!Array.isArray(value)) return "must be array"
|
|
160
|
+
for (const item of value) {
|
|
161
|
+
if (typeof item !== "string" || !values.includes(item)) return `items should be one of: ${values.join(", ")}`
|
|
162
|
+
}
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function booleanField(defaultValue: boolean, options: BooleanFieldOptions = {}): BooleanConfigField {
|
|
167
|
+
return { kind: "boolean", default: defaultValue, ...options }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function stringField(defaultValue: string, options: StringFieldOptions = {}): StringConfigField {
|
|
171
|
+
if (/[\r\n]/.test(defaultValue)) throw new Error("String field default must be single-line")
|
|
172
|
+
return { kind: "string", default: defaultValue, ...options }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function textField(defaultValue: string, options: TextFieldOptions = {}): TextConfigField {
|
|
176
|
+
return { kind: "text", default: defaultValue, ...options }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function numberField<const Values extends NumberValues>(
|
|
180
|
+
defaultValue: Values[number],
|
|
181
|
+
options: ValuedNumberOptions<Values>
|
|
182
|
+
): ValuedNumberConfigField<Values>
|
|
183
|
+
function numberField(defaultValue: number, options?: RangedNumberOptions): RangedNumberConfigField
|
|
184
|
+
function numberField(defaultValue: number, options: RangedNumberOptions | ValuedNumberOptions<NumberValues> = {}): NumberConfigField {
|
|
185
|
+
const rawRangeOptions = options as { min?: number; max?: number; step?: number }
|
|
186
|
+
if (
|
|
187
|
+
options.values !== undefined &&
|
|
188
|
+
(rawRangeOptions.min !== undefined || rawRangeOptions.max !== undefined || rawRangeOptions.step !== undefined)
|
|
189
|
+
) {
|
|
190
|
+
throw new Error("Number field cannot combine values with min, max, or step")
|
|
191
|
+
}
|
|
192
|
+
if (!Number.isFinite(defaultValue)) throw new Error("Number field default must be finite")
|
|
193
|
+
if (options.values !== undefined) {
|
|
194
|
+
if (options.values.length === 0) throw new Error("Number field values must have at least one value")
|
|
195
|
+
for (const value of options.values) {
|
|
196
|
+
if (!Number.isFinite(value)) throw new Error("Number field values must be finite")
|
|
197
|
+
}
|
|
198
|
+
if (!options.values.includes(defaultValue)) throw new Error(`Number field default must be one of: ${options.values.join(", ")}`)
|
|
199
|
+
} else {
|
|
200
|
+
if (options.min !== undefined && !Number.isFinite(options.min)) throw new Error("Number field min must be finite")
|
|
201
|
+
if (options.max !== undefined && !Number.isFinite(options.max)) throw new Error("Number field max must be finite")
|
|
202
|
+
if (options.step !== undefined && (!Number.isFinite(options.step) || options.step <= 0)) {
|
|
203
|
+
throw new Error("Number field step must be a positive finite number")
|
|
204
|
+
}
|
|
205
|
+
if (options.min !== undefined && options.max !== undefined && options.min > options.max) {
|
|
206
|
+
throw new Error("Number field min must be less than or equal to max")
|
|
207
|
+
}
|
|
208
|
+
if (options.min !== undefined && defaultValue < options.min) throw new Error(`Number field default must be at least ${options.min}`)
|
|
209
|
+
if (options.max !== undefined && defaultValue > options.max) throw new Error(`Number field default must be at most ${options.max}`)
|
|
210
|
+
}
|
|
211
|
+
return { kind: "number", default: defaultValue, ...options } as NumberConfigField
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const field = {
|
|
215
|
+
enum: enumField,
|
|
216
|
+
multiEnum: multiEnumField,
|
|
217
|
+
boolean: booleanField,
|
|
218
|
+
string: stringField,
|
|
219
|
+
text: textField,
|
|
220
|
+
number: numberField
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function defineScopedConfig<const Schema extends ConfigSchema>(options: {
|
|
224
|
+
fileName: string
|
|
225
|
+
scope?: ConfigScope
|
|
226
|
+
schema: Schema
|
|
227
|
+
}): ScopedConfig<ConfigFromSchema<Schema>> {
|
|
228
|
+
type Config = ConfigFromSchema<Schema>
|
|
229
|
+
if (!/^[A-Za-z0-9._-]+$/.test(options.fileName) || options.fileName === "." || options.fileName === "..") {
|
|
230
|
+
throw new Error(`Invalid config file name: ${options.fileName}`)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fields = Object.entries(options.schema).map(([key, field]) => ({ ...field, key, label: field.label ?? key }))
|
|
234
|
+
const defaults = Object.fromEntries(fields.map(field => [field.key, field.default]))
|
|
235
|
+
|
|
236
|
+
return new ScopedConfigImpl<Config>(
|
|
237
|
+
options.fileName,
|
|
238
|
+
options.scope === undefined ? ["user", "workspace"] : [options.scope],
|
|
239
|
+
fields,
|
|
240
|
+
defaults as ResolvedConfig<Config>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
class ScopedConfigImpl<Config extends object> implements ScopedConfig<Config> {
|
|
245
|
+
readonly #fieldByKey: Map<string, ScopedConfigField>
|
|
246
|
+
cwd: string | undefined
|
|
247
|
+
value: ResolvedConfig<Config>
|
|
248
|
+
scoped: ScopedConfigPatch = { user: {}, workspace: {} }
|
|
249
|
+
warnings: ScopedConfigWarning[] = []
|
|
250
|
+
|
|
251
|
+
constructor(
|
|
252
|
+
readonly fileName: string,
|
|
253
|
+
readonly scopes: readonly ConfigScope[],
|
|
254
|
+
readonly fields: readonly ScopedConfigField[],
|
|
255
|
+
readonly defaults: ResolvedConfig<Config>
|
|
256
|
+
) {
|
|
257
|
+
this.#fieldByKey = new Map(fields.map(field => [field.key, field]))
|
|
258
|
+
this.value = { ...defaults }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
path(scope: ConfigScope, cwd?: string): string {
|
|
262
|
+
return scope === "user" ? join(getAgentDir(), this.fileName) : resolvePath(cwd ?? this.currentCwd(), CONFIG_DIR_NAME, this.fileName)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
resolve(scoped: ScopedConfigPatch = this.scoped): ResolvedConfig<Config> {
|
|
266
|
+
const resolved: Record<string, unknown> = { ...this.defaults }
|
|
267
|
+
for (const scope of this.scopes) {
|
|
268
|
+
for (const field of this.fields) {
|
|
269
|
+
const value = scoped[scope][field.key]
|
|
270
|
+
if (value !== undefined && !getConfigValueWarning(field, value)) resolved[field.key] = value
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return resolved as ResolvedConfig<Config>
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
load(cwd: string): ScopedConfig<Config> {
|
|
277
|
+
const scoped: ScopedConfigPatch = { user: {}, workspace: {} }
|
|
278
|
+
const warnings: ScopedConfigWarning[] = []
|
|
279
|
+
for (const scope of this.scopes) {
|
|
280
|
+
const path = this.path(scope, cwd)
|
|
281
|
+
const result = readConfigFile(path)
|
|
282
|
+
scoped[scope] = result.config
|
|
283
|
+
if (result.warning) warnings.push({ message: result.warning, scope, path })
|
|
284
|
+
}
|
|
285
|
+
this.cwd = cwd
|
|
286
|
+
this.scoped = scoped
|
|
287
|
+
this.value = this.resolve(scoped)
|
|
288
|
+
this.warnings = warnings
|
|
289
|
+
for (const scope of this.scopes) {
|
|
290
|
+
for (const warning of getConfigWarnings(this.fields, scoped[scope])) {
|
|
291
|
+
this.warnings.push({ ...warning, scope, path: this.path(scope, cwd) })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return this
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
update<Key extends keyof Config & string>(scope: ConfigScope, key: Key, value: Config[Key] | undefined): ScopedConfig<Config> {
|
|
298
|
+
if (!this.scopes.includes(scope)) throw new Error(`Config scope is not active: ${scope}`)
|
|
299
|
+
const field = this.#fieldByKey.get(key)
|
|
300
|
+
if (!field) throw new Error(`Unknown config key: ${key}`)
|
|
301
|
+
if (value !== undefined) {
|
|
302
|
+
const warning = getConfigValueWarning(field, value)
|
|
303
|
+
if (warning) throw new Error(warning)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const cwd = this.currentCwd()
|
|
307
|
+
const configPath = this.path(scope)
|
|
308
|
+
// Patch existing file instead of writing only known schema keys.
|
|
309
|
+
// Unknown keys belong to newer app versions and must survive old versions.
|
|
310
|
+
const patch = readConfigFile(configPath).config
|
|
311
|
+
if (value === undefined) delete patch[key]
|
|
312
|
+
else patch[key] = value
|
|
313
|
+
writeConfigFile(configPath, patch)
|
|
314
|
+
return this.load(cwd)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
resetScope(scope: ConfigScope): ScopedConfig<Config> {
|
|
318
|
+
if (!this.scopes.includes(scope)) throw new Error(`Config scope is not active: ${scope}`)
|
|
319
|
+
const cwd = this.currentCwd()
|
|
320
|
+
const configPath = this.path(scope)
|
|
321
|
+
const patch = readConfigFile(configPath).config
|
|
322
|
+
for (const field of this.fields) delete patch[field.key]
|
|
323
|
+
writeConfigFile(configPath, patch)
|
|
324
|
+
return this.load(cwd)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private currentCwd(): string {
|
|
328
|
+
if (!this.cwd) throw new Error("Config must be loaded before this operation")
|
|
329
|
+
return this.cwd
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function readConfigFile(path: string): { config: ConfigPatch; warning?: string } {
|
|
334
|
+
if (!existsSync(path)) return { config: {} }
|
|
335
|
+
const source = readFileSync(path, "utf-8")
|
|
336
|
+
let value: unknown
|
|
337
|
+
try {
|
|
338
|
+
value = JSON.parse(source)
|
|
339
|
+
} catch (error) {
|
|
340
|
+
const message = error instanceof Error && error.message ? error.message : String(error)
|
|
341
|
+
return { config: {}, warning: `Invalid config: ${message}; file is ignored` }
|
|
342
|
+
}
|
|
343
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
344
|
+
return { config: {}, warning: "Invalid config: must be an object; file is ignored" }
|
|
345
|
+
}
|
|
346
|
+
const config: ConfigPatch = {}
|
|
347
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
348
|
+
if (fieldValue !== undefined) config[key] = fieldValue
|
|
349
|
+
}
|
|
350
|
+
return { config }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function writeConfigFile(path: string, config: ConfigPatch): void {
|
|
354
|
+
if (Object.keys(config).length === 0) {
|
|
355
|
+
rmSync(path, { force: true })
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
359
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8")
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function getConfigWarnings(fields: readonly ScopedConfigField[], config: object): ConfigWarning[] {
|
|
363
|
+
const warnings: ConfigWarning[] = []
|
|
364
|
+
for (const field of fields) {
|
|
365
|
+
const value = (config as Record<string, unknown>)[field.key]
|
|
366
|
+
if (value === undefined) continue
|
|
367
|
+
const message = getConfigValueWarning(field, value)
|
|
368
|
+
if (message) warnings.push({ key: field.key, message: `${message}; value is ignored while resolving` })
|
|
369
|
+
}
|
|
370
|
+
return warnings
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function getConfigValueWarning(field: ScopedConfigField, value: unknown): string | undefined {
|
|
374
|
+
switch (field.kind) {
|
|
375
|
+
case "enum":
|
|
376
|
+
if (typeof value !== "string") return `/${field.key} must be string`
|
|
377
|
+
if (!field.values.includes(value)) return `/${field.key} should be one of: ${field.values.join(", ")}`
|
|
378
|
+
return undefined
|
|
379
|
+
case "multiEnum": {
|
|
380
|
+
const warning = getMultiEnumWarning(field.values, value)
|
|
381
|
+
return warning ? `/${field.key} ${warning}` : undefined
|
|
382
|
+
}
|
|
383
|
+
case "boolean":
|
|
384
|
+
return typeof value === "boolean" ? undefined : `/${field.key} must be boolean`
|
|
385
|
+
case "string":
|
|
386
|
+
if (typeof value !== "string") return `/${field.key} must be string`
|
|
387
|
+
return /[\r\n]/.test(value) ? `/${field.key} must be single-line string` : undefined
|
|
388
|
+
case "text":
|
|
389
|
+
return typeof value === "string" ? undefined : `/${field.key} must be string`
|
|
390
|
+
case "number":
|
|
391
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return `/${field.key} must be number`
|
|
392
|
+
if (field.values !== undefined && !field.values.includes(value)) {
|
|
393
|
+
return `/${field.key} should be one of: ${field.values.join(", ")}`
|
|
394
|
+
}
|
|
395
|
+
if (field.min !== undefined && value < field.min) return `/${field.key} should be at least ${field.min}`
|
|
396
|
+
if (field.max !== undefined && value > field.max) return `/${field.key} should be at most ${field.max}`
|
|
397
|
+
return undefined
|
|
398
|
+
}
|
|
399
|
+
}
|