@homer0/pi-config 0.0.0-stage → 1.0.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/README.md +329 -2
- package/dist/extensions/config.js +2 -0
- package/dist/extensions/config.js.map +1 -0
- package/dist/format-EJuykEtc.js +3 -0
- package/dist/format-EJuykEtc.js.map +1 -0
- package/dist/handlers-B2Dm1nBF.js +2 -0
- package/dist/handlers-B2Dm1nBF.js.map +1 -0
- package/dist/index.js +1 -0
- package/dist/utils-DYlB6FdR.js +2 -0
- package/dist/utils-DYlB6FdR.js.map +1 -0
- package/package.json +72 -4
- package/src/errors.ts +35 -0
- package/src/extensions/config.ts +238 -0
- package/src/format.ts +58 -0
- package/src/handlers.ts +225 -0
- package/src/index.ts +20 -0
- package/src/schemas.ts +39 -0
- package/src/types.ts +240 -0
- package/src/utils.ts +583 -0
- package/src/window.ts +593 -0
package/src/utils.ts
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { CONFIG_DIR_NAME, getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { get, merge } from '@homer0/object-utils';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, extname, isAbsolute, join } from 'node:path';
|
|
6
|
+
import { parse as parseYaml } from 'yaml';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { ConfigReadError, ConfigValidationError } from './errors.js';
|
|
9
|
+
import type {
|
|
10
|
+
ConfigPath,
|
|
11
|
+
ConfigPathValue,
|
|
12
|
+
ConfigQuery,
|
|
13
|
+
ConfigSource,
|
|
14
|
+
ConfigSchema,
|
|
15
|
+
GetConfigOptions,
|
|
16
|
+
PiCoreConfig,
|
|
17
|
+
QueryConfigOptions,
|
|
18
|
+
QueryConfigResult,
|
|
19
|
+
ReadConfigResult,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolves a path relative to the Pi agent directory.
|
|
24
|
+
*
|
|
25
|
+
* @param segments The segments of the path, relative to the agent directory.
|
|
26
|
+
* @returns The absolute path.
|
|
27
|
+
*/
|
|
28
|
+
const getAgentPath = (...segments: string[]): string => join(getAgentDir(), ...segments);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns the error message string from any thrown value. Prettifies zod validation
|
|
32
|
+
* errors instead of surfacing their raw JSON `.message`.
|
|
33
|
+
*
|
|
34
|
+
* @param error The thrown value.
|
|
35
|
+
* @returns The message of the error, or the value as a string if it's not an error.
|
|
36
|
+
*/
|
|
37
|
+
const ensureErrorMessage = (error: unknown): string => {
|
|
38
|
+
if (error instanceof z.ZodError) {
|
|
39
|
+
return error.issues.map((issue) => issue.message).join('; ');
|
|
40
|
+
}
|
|
41
|
+
return error instanceof Error ? error.message : String(error);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The Pi directory, in the home of the user. */
|
|
45
|
+
const PI_DIR = join(homedir(), CONFIG_DIR_NAME);
|
|
46
|
+
/** The absolute path of Pi's agent directory, where the global config files live. */
|
|
47
|
+
const AGENT_DIR = getAgentDir();
|
|
48
|
+
/** The directory where the profiles are. */
|
|
49
|
+
const PROFILES_DIR = getAgentPath('profiles');
|
|
50
|
+
|
|
51
|
+
/** The prefix of the paths relative to the Pi directory. */
|
|
52
|
+
const PI_PREFIX = 'pi:';
|
|
53
|
+
/** The prefix of the paths relative to the agent directory. */
|
|
54
|
+
const AGENT_PREFIX = 'agent:';
|
|
55
|
+
/** The pattern of the paths relative to a profile; it captures the name and the path. */
|
|
56
|
+
const PROFILE_PATTERN = /^profile:([^:]+):(.+)$/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolves a pi-style path reference to an absolute path.
|
|
60
|
+
*
|
|
61
|
+
* Supported formats:
|
|
62
|
+
*
|
|
63
|
+
* - `pi:<path>` — relative to the Pi directory (`~/.pi/`)
|
|
64
|
+
* - `agent:<path>` — relative to the agent directory (`~/.pi/agent/`)
|
|
65
|
+
* - `profile:<name>:<path>` — relative to a profile directory
|
|
66
|
+
* (`~/.pi/agent/profiles/<name>/`)
|
|
67
|
+
* - Absolute path — used as-is
|
|
68
|
+
* - Anything else — resolved relative to `baseDir`
|
|
69
|
+
*
|
|
70
|
+
* @example <caption>Relative to the agent directory</caption>
|
|
71
|
+
* resolvePiPath('agent:shared/base.yaml', '/project');
|
|
72
|
+
*
|
|
73
|
+
* @example <caption>Relative to the file that references it</caption>
|
|
74
|
+
* resolvePiPath('../base.yaml', '/project/.pi');
|
|
75
|
+
*
|
|
76
|
+
* @param value The path reference.
|
|
77
|
+
* @param baseDir The directory to resolve the path from, when it doesn't have a prefix
|
|
78
|
+
* and it's not absolute.
|
|
79
|
+
* @returns The absolute path.
|
|
80
|
+
*/
|
|
81
|
+
const resolvePiPath = (value: string, baseDir: string): string => {
|
|
82
|
+
if (value.startsWith(PI_PREFIX)) return join(PI_DIR, value.slice(PI_PREFIX.length));
|
|
83
|
+
if (value.startsWith(AGENT_PREFIX))
|
|
84
|
+
return join(AGENT_DIR, value.slice(AGENT_PREFIX.length));
|
|
85
|
+
if (isAbsolute(value)) return value;
|
|
86
|
+
|
|
87
|
+
const profileMatch = PROFILE_PATTERN.exec(value);
|
|
88
|
+
if (profileMatch) {
|
|
89
|
+
const [, profileName, profilePath] = profileMatch;
|
|
90
|
+
return join(PROFILES_DIR, profileName!, profilePath!);
|
|
91
|
+
}
|
|
92
|
+
return join(baseDir, value);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The base config shape with all the top-level namespaces present, so consumers never see
|
|
97
|
+
* missing keys.
|
|
98
|
+
*/
|
|
99
|
+
const EMPTY_PI_CORE_CONFIG: PiCoreConfig = {
|
|
100
|
+
pi: {},
|
|
101
|
+
skills: {},
|
|
102
|
+
tools: {},
|
|
103
|
+
extensions: {},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Session-scoped config override, held on `globalThis` rather than as module state or
|
|
108
|
+
* passed through `ExtensionAPI`/`ctx`: Pi loads each extension with its own copy of the
|
|
109
|
+
* modules it imports, so module state would not be shared between extensions. One pi
|
|
110
|
+
* process is assumed to correspond to one session.
|
|
111
|
+
*
|
|
112
|
+
* This is `globalThis` typed with the property that holds the override. The assertion is
|
|
113
|
+
* the only place that needs to know about it.
|
|
114
|
+
*/
|
|
115
|
+
const sessionStore = globalThis as typeof globalThis & {
|
|
116
|
+
__piConfigSessionOverride?: PiCoreConfig;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Replaces the session-scoped config override wholesale, or clears it when `undefined`.
|
|
121
|
+
*
|
|
122
|
+
* @param value The new override, or `undefined` to clear it.
|
|
123
|
+
*/
|
|
124
|
+
const setSessionConfigOverride = (value: PiCoreConfig | undefined): void => {
|
|
125
|
+
sessionStore.__piConfigSessionOverride = value;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Gets the current session-scoped config override.
|
|
130
|
+
*
|
|
131
|
+
* @returns The override, or `undefined` if there's none.
|
|
132
|
+
*/
|
|
133
|
+
const getSessionConfigOverride = (): PiCoreConfig | undefined =>
|
|
134
|
+
sessionStore.__piConfigSessionOverride;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Clears the session-scoped config override.
|
|
138
|
+
*
|
|
139
|
+
* @returns Whether an override was actually set.
|
|
140
|
+
*/
|
|
141
|
+
const clearSessionConfigOverride = (): boolean => {
|
|
142
|
+
const hadOverride = typeof sessionStore.__piConfigSessionOverride !== 'undefined';
|
|
143
|
+
sessionStore.__piConfigSessionOverride = undefined;
|
|
144
|
+
return hadOverride;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The result of reading a config file: either its parsed value, or that it doesn't exist.
|
|
149
|
+
* When the file couldn't be read or parsed, and it's not just missing, the result also
|
|
150
|
+
* has the error.
|
|
151
|
+
*/
|
|
152
|
+
type ReadConfigFileResult =
|
|
153
|
+
| { exists: true; value: unknown }
|
|
154
|
+
| { exists: false; error?: ConfigReadError };
|
|
155
|
+
|
|
156
|
+
/** The formats a config file can have, used to describe errors. */
|
|
157
|
+
type ConfigFileFormat = 'JSON' | 'YAML';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Creates a read error with the file path as context, preserving the original cause.
|
|
161
|
+
*
|
|
162
|
+
* @param message The description of what failed.
|
|
163
|
+
* @param filePath The path of the config file that failed.
|
|
164
|
+
* @param error The original error.
|
|
165
|
+
* @returns The new error.
|
|
166
|
+
*/
|
|
167
|
+
const toReadError = (
|
|
168
|
+
message: string,
|
|
169
|
+
filePath: string,
|
|
170
|
+
error: unknown,
|
|
171
|
+
): ConfigReadError => new ConfigReadError(message, filePath, { cause: error });
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parses the content of a config file, based on the extension of the file.
|
|
175
|
+
*
|
|
176
|
+
* @param content The content of the file.
|
|
177
|
+
* @param filePath The path of the file, to know its format.
|
|
178
|
+
* @returns The parsed value.
|
|
179
|
+
* @throws If the content is not valid for the format of the file.
|
|
180
|
+
*/
|
|
181
|
+
const parseConfigFile = (content: string, filePath: string): unknown => {
|
|
182
|
+
const extension = extname(filePath);
|
|
183
|
+
if (extension === '.json') return JSON.parse(content);
|
|
184
|
+
return parseYaml(content);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Gets the display label for the format of a config file.
|
|
189
|
+
*
|
|
190
|
+
* @param filePath The path of the config file.
|
|
191
|
+
* @returns `JSON` for `.json` files, and `YAML` for everything else.
|
|
192
|
+
*/
|
|
193
|
+
const getConfigFileFormat = (filePath: string): ConfigFileFormat =>
|
|
194
|
+
extname(filePath) === '.json' ? 'JSON' : 'YAML';
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Logs a non-fatal config read issue, without interrupting the resolution chain.
|
|
198
|
+
*
|
|
199
|
+
* @param error The error to log.
|
|
200
|
+
*/
|
|
201
|
+
const logConfigReadError = (error: ConfigReadError): void => {
|
|
202
|
+
// eslint-disable-next-line no-console -- Non-fatal config read failures should be visible without breaking the config chain.
|
|
203
|
+
console.warn(`[pi-core config] ${error.message}`);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Reads and parses a config file, returning the missing or invalid files as non-fatal
|
|
208
|
+
* results.
|
|
209
|
+
*
|
|
210
|
+
* @param filePath The path of the config file.
|
|
211
|
+
* @returns The parsed value, or that the file doesn't exist; if it failed for another
|
|
212
|
+
* reason than being missing, the result has the error.
|
|
213
|
+
*/
|
|
214
|
+
const readConfigIfExists = async (filePath: string): Promise<ReadConfigFileResult> => {
|
|
215
|
+
let content: string;
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
content = await readFile(filePath, 'utf8');
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
221
|
+
return { exists: false };
|
|
222
|
+
return {
|
|
223
|
+
exists: false,
|
|
224
|
+
error: toReadError(`Failed to read config file ${filePath}`, filePath, error),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
return { exists: true, value: parseConfigFile(content, filePath) };
|
|
230
|
+
} catch (error) {
|
|
231
|
+
const format = getConfigFileFormat(filePath);
|
|
232
|
+
return {
|
|
233
|
+
exists: false,
|
|
234
|
+
error: toReadError(`Invalid ${format} in config file ${filePath}`, filePath, error),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reads a config file and recursively resolves its `extends` chain.
|
|
241
|
+
*
|
|
242
|
+
* The `extends` key is stripped from the returned value; the base config is deep-merged
|
|
243
|
+
* first and the current file's own keys win. Circular references are detected via
|
|
244
|
+
* `seen`.
|
|
245
|
+
*
|
|
246
|
+
* @param filePath The path of the config file.
|
|
247
|
+
* @param seen The files that are already part of the chain, to detect circular
|
|
248
|
+
* references.
|
|
249
|
+
* @returns The resolved value, or that the file doesn't exist. A base file that can't be
|
|
250
|
+
* read doesn't make the file fail: the error is logged and the file is used on its
|
|
251
|
+
* own.
|
|
252
|
+
*/
|
|
253
|
+
const readConfigResolvingExtends = async (
|
|
254
|
+
filePath: string,
|
|
255
|
+
seen = new Set<string>(),
|
|
256
|
+
): Promise<ReadConfigFileResult> => {
|
|
257
|
+
if (seen.has(filePath)) {
|
|
258
|
+
return {
|
|
259
|
+
exists: false,
|
|
260
|
+
error: toReadError(
|
|
261
|
+
`Circular extends detected for config file ${filePath}`,
|
|
262
|
+
filePath,
|
|
263
|
+
undefined,
|
|
264
|
+
),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const result = await readConfigIfExists(filePath);
|
|
269
|
+
if (!result.exists) return result;
|
|
270
|
+
|
|
271
|
+
const raw = result.value as Record<string, unknown>;
|
|
272
|
+
const extendsValue = raw['extends'];
|
|
273
|
+
if (typeof extendsValue !== 'string' || !extendsValue.trim()) {
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const nextSeen = new Set(seen);
|
|
278
|
+
nextSeen.add(filePath);
|
|
279
|
+
const extendsPath = resolvePiPath(extendsValue.trim(), dirname(filePath));
|
|
280
|
+
const baseResult = await readConfigResolvingExtends(extendsPath, nextSeen);
|
|
281
|
+
|
|
282
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- `extends` is stripped from the merged config; the value is intentionally discarded.
|
|
283
|
+
const { extends: _, ...currentWithoutExtends } = raw;
|
|
284
|
+
if (!baseResult.exists) {
|
|
285
|
+
if (baseResult.error) logConfigReadError(baseResult.error);
|
|
286
|
+
return { exists: true, value: currentWithoutExtends };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
exists: true,
|
|
291
|
+
value: merge({}, baseResult.value, currentWithoutExtends),
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/** The file extensions a config file can have. */
|
|
296
|
+
const CONFIG_EXTENSIONS = ['.json', '.yml', '.yaml'] as const;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Expands the basename of a config file into all the supported file extensions.
|
|
300
|
+
*
|
|
301
|
+
* @param directory The directory where the file would be.
|
|
302
|
+
* @param basename The name of the file, without the extension.
|
|
303
|
+
* @param scope Whether the directory is global or from a project.
|
|
304
|
+
* @param local Whether it's a `config.local` file.
|
|
305
|
+
* @returns One candidate for each supported extension.
|
|
306
|
+
*/
|
|
307
|
+
const getConfigFileVariants = (
|
|
308
|
+
directory: string,
|
|
309
|
+
basename: string,
|
|
310
|
+
scope: ConfigSource['scope'],
|
|
311
|
+
local: boolean,
|
|
312
|
+
): ConfigSource[] =>
|
|
313
|
+
CONFIG_EXTENSIONS.map((extension) => ({
|
|
314
|
+
path: join(directory, `${basename}${extension}`),
|
|
315
|
+
scope,
|
|
316
|
+
local,
|
|
317
|
+
exists: false,
|
|
318
|
+
used: false,
|
|
319
|
+
}));
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Gets the config file candidates, in merge order: global files first, then the project
|
|
323
|
+
* ones, with the `config.local` files after their `config` counterparts.
|
|
324
|
+
*
|
|
325
|
+
* @param cwd The project directory.
|
|
326
|
+
* @returns The candidates.
|
|
327
|
+
*/
|
|
328
|
+
const getConfigFilePaths = (cwd: string): ConfigSource[] => [
|
|
329
|
+
...getConfigFileVariants(AGENT_DIR, 'config', 'global', false),
|
|
330
|
+
...getConfigFileVariants(AGENT_DIR, 'config.local', 'global', true),
|
|
331
|
+
...getConfigFileVariants(join(cwd, CONFIG_DIR_NAME), 'config', 'project', false),
|
|
332
|
+
...getConfigFileVariants(join(cwd, CONFIG_DIR_NAME), 'config.local', 'project', true),
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Converts a query into a dot-path.
|
|
337
|
+
*
|
|
338
|
+
* @param query The query to convert. It has to be valid (see
|
|
339
|
+
* {@link validateConfigQuery}), which means that a `skill`, `tool`, or `extension`
|
|
340
|
+
* scope always has a name.
|
|
341
|
+
* @returns The dot-path, or `undefined` if the query is for the whole config.
|
|
342
|
+
*/
|
|
343
|
+
const getConfigPath = (query: ConfigQuery): string | undefined => {
|
|
344
|
+
if (query.path) return query.path;
|
|
345
|
+
if (!query.scope || query.scope === 'all') return undefined;
|
|
346
|
+
if (
|
|
347
|
+
query.scope === 'pi' ||
|
|
348
|
+
query.scope === 'skills' ||
|
|
349
|
+
query.scope === 'tools' ||
|
|
350
|
+
query.scope === 'extensions'
|
|
351
|
+
)
|
|
352
|
+
return query.scope;
|
|
353
|
+
if (query.scope === 'skill') return `skills.${query.name}`;
|
|
354
|
+
if (query.scope === 'tool') return `tools.${query.name}`;
|
|
355
|
+
return `extensions.${query.name}`;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Validates the combinations a model-facing config query can have.
|
|
360
|
+
*
|
|
361
|
+
* Returns a human-readable phrase because callers surface validation failures as tool
|
|
362
|
+
* result messages. There is no recovery branch needed beyond "show this to the
|
|
363
|
+
* model/user", so an Error object or machine-code enum would add handling noise without
|
|
364
|
+
* improving current behavior.
|
|
365
|
+
*
|
|
366
|
+
* @param query The query to validate.
|
|
367
|
+
* @returns The description of the problem, or `undefined` if the query is valid.
|
|
368
|
+
*/
|
|
369
|
+
const validateConfigQuery = (query: ConfigQuery): string | undefined => {
|
|
370
|
+
if (query.path && (query.scope || query.name))
|
|
371
|
+
return 'Use either path or scope/name, not both.';
|
|
372
|
+
|
|
373
|
+
if (
|
|
374
|
+
(query.scope === 'skill' || query.scope === 'tool' || query.scope === 'extension') &&
|
|
375
|
+
!query.name
|
|
376
|
+
)
|
|
377
|
+
return `scope '${query.scope}' requires name.`;
|
|
378
|
+
|
|
379
|
+
if (
|
|
380
|
+
query.name &&
|
|
381
|
+
query.scope !== 'skill' &&
|
|
382
|
+
query.scope !== 'tool' &&
|
|
383
|
+
query.scope !== 'extension'
|
|
384
|
+
)
|
|
385
|
+
return 'name is only valid with skill, tool, or extension scope.';
|
|
386
|
+
|
|
387
|
+
return undefined;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Validates a resolved config value with a Zod schema provided by the caller.
|
|
392
|
+
*
|
|
393
|
+
* @template TValue The type of the value the schema produces.
|
|
394
|
+
* @param value The value to validate.
|
|
395
|
+
* @param schema The schema to validate and parse the value with.
|
|
396
|
+
* @returns The value, as parsed by the schema.
|
|
397
|
+
* @throws If the value doesn't match the schema; the error is a
|
|
398
|
+
* {@link ConfigValidationError}.
|
|
399
|
+
*/
|
|
400
|
+
const validateConfigValue = <TValue>(
|
|
401
|
+
value: unknown,
|
|
402
|
+
schema: ConfigSchema<TValue>,
|
|
403
|
+
): TValue => {
|
|
404
|
+
const result = schema.safeParse(value);
|
|
405
|
+
if (result.success) return result.data;
|
|
406
|
+
|
|
407
|
+
throw new ConfigValidationError(
|
|
408
|
+
'Config value failed schema validation.',
|
|
409
|
+
result.error.issues,
|
|
410
|
+
);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Reads the global and the project config files, and merges them in order.
|
|
415
|
+
*
|
|
416
|
+
* @param cwd The project directory.
|
|
417
|
+
* @returns The resolved config, without the session override, and the status of each file
|
|
418
|
+
* that was checked.
|
|
419
|
+
*/
|
|
420
|
+
const readConfigFiles = async (cwd = process.cwd()): Promise<ReadConfigResult> => {
|
|
421
|
+
const sources = [];
|
|
422
|
+
const configs: unknown[] = [];
|
|
423
|
+
|
|
424
|
+
for (const source of getConfigFilePaths(cwd)) {
|
|
425
|
+
const result = await readConfigResolvingExtends(source.path);
|
|
426
|
+
if (!result.exists && result.error) logConfigReadError(result.error);
|
|
427
|
+
sources.push({ ...source, exists: result.exists, used: result.exists });
|
|
428
|
+
if (result.exists) configs.push(result.value);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
config: merge<PiCoreConfig>(EMPTY_PI_CORE_CONFIG, ...configs),
|
|
433
|
+
sources,
|
|
434
|
+
};
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Deep-merges the session override (if there's one) on top of a config resolved from
|
|
439
|
+
* files.
|
|
440
|
+
*
|
|
441
|
+
* @param config The config resolved from files.
|
|
442
|
+
* @returns The config with the override applied, or the same config if there's no
|
|
443
|
+
* override.
|
|
444
|
+
*/
|
|
445
|
+
const applySessionConfigOverride = (config: PiCoreConfig): PiCoreConfig => {
|
|
446
|
+
const override = getSessionConfigOverride();
|
|
447
|
+
return typeof override === 'undefined'
|
|
448
|
+
? config
|
|
449
|
+
: merge<PiCoreConfig>({}, config, override);
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Loads and returns the full resolved config: the files, merged in order, plus the
|
|
454
|
+
* session override.
|
|
455
|
+
*
|
|
456
|
+
* @example <caption>The full config</caption>
|
|
457
|
+
* const config = await getConfig();
|
|
458
|
+
*
|
|
459
|
+
* @example <caption>The config of another project</caption>
|
|
460
|
+
* const config = await getConfig({ cwd: '/path/to/project' });
|
|
461
|
+
*
|
|
462
|
+
* @template TValue The type of the value the schema produces; by default, the config
|
|
463
|
+
* itself.
|
|
464
|
+
* @param options The options to read the config.
|
|
465
|
+
* @returns The config, or the value the schema produced from it.
|
|
466
|
+
* @throws If a schema was provided and the config doesn't match; the error is a
|
|
467
|
+
* {@link ConfigValidationError}.
|
|
468
|
+
*/
|
|
469
|
+
const getConfig = async <TValue = PiCoreConfig>(
|
|
470
|
+
options: GetConfigOptions<TValue> = {},
|
|
471
|
+
): Promise<TValue> => {
|
|
472
|
+
const result = await readConfigFiles(options.cwd);
|
|
473
|
+
const config = applySessionConfigOverride(result.config);
|
|
474
|
+
return options.schema
|
|
475
|
+
? validateConfigValue(config, options.schema)
|
|
476
|
+
: (config as TValue);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Loads config and returns a single typed dot-path value.
|
|
481
|
+
*
|
|
482
|
+
* Use this from Node code when the caller already knows the exact config path it needs
|
|
483
|
+
* and wants declaration-merging based return type inference.
|
|
484
|
+
*
|
|
485
|
+
* @example <caption>A single value</caption>
|
|
486
|
+
* const runChecks = await getConfigValue('skills.commit.runChecks');
|
|
487
|
+
*
|
|
488
|
+
* @example <caption>Validating the value with a schema</caption>
|
|
489
|
+
* const commit = await getConfigValue('skills.commit', {
|
|
490
|
+
* schema: z.object({ runChecks: z.boolean().optional() }),
|
|
491
|
+
* });
|
|
492
|
+
*
|
|
493
|
+
* @template TPath The dot-path of the value.
|
|
494
|
+
* @template TValue The type of the value; by default, the one inferred from the path.
|
|
495
|
+
* @param path The dot-path of the value, like `skills.commit.runChecks`.
|
|
496
|
+
* @param options The options to read the config.
|
|
497
|
+
* @returns The value, or `undefined` if the path doesn't exist.
|
|
498
|
+
* @throws If a schema was provided and the value doesn't match; the error is a
|
|
499
|
+
* {@link ConfigValidationError}.
|
|
500
|
+
*/
|
|
501
|
+
const getConfigValue = async <
|
|
502
|
+
TPath extends ConfigPath | string,
|
|
503
|
+
TValue = ConfigPathValue<TPath>,
|
|
504
|
+
>(
|
|
505
|
+
path: TPath,
|
|
506
|
+
options: GetConfigOptions<TValue> = {},
|
|
507
|
+
): Promise<TValue | undefined> => {
|
|
508
|
+
const config = await getConfig({ cwd: options.cwd });
|
|
509
|
+
const value = get<ConfigPathValue<TPath>>(config, path);
|
|
510
|
+
if (value === undefined) return undefined;
|
|
511
|
+
return options.schema ? validateConfigValue(value, options.schema) : (value as TValue);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Loads config and applies the model-facing query format used by the get_config tool.
|
|
516
|
+
*
|
|
517
|
+
* This is intentionally less type-specific than getConfigValue because tool calls use
|
|
518
|
+
* runtime scope/name/path parameters and need structured status, source metadata, and
|
|
519
|
+
* validation messages.
|
|
520
|
+
*
|
|
521
|
+
* @example <caption>The config of a skill</caption>
|
|
522
|
+
* const result = await queryConfig({ scope: 'skill', name: 'commit' });
|
|
523
|
+
* if (result.status === 'found') {
|
|
524
|
+
* console.log(result.value);
|
|
525
|
+
* }
|
|
526
|
+
*
|
|
527
|
+
* @param query The query to run.
|
|
528
|
+
* @param options The options to read the config.
|
|
529
|
+
* @returns The result of the query. A query that is not valid, or a path that doesn't
|
|
530
|
+
* exist, are reported in the `status`, they don't throw.
|
|
531
|
+
*/
|
|
532
|
+
const queryConfig = async (
|
|
533
|
+
query: ConfigQuery,
|
|
534
|
+
options: QueryConfigOptions = {},
|
|
535
|
+
): Promise<QueryConfigResult> => {
|
|
536
|
+
const validationError = validateConfigQuery(query);
|
|
537
|
+
if (validationError) {
|
|
538
|
+
return {
|
|
539
|
+
status: 'error',
|
|
540
|
+
query,
|
|
541
|
+
redacted: false,
|
|
542
|
+
sources: [],
|
|
543
|
+
message: validationError,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const { config: fileConfig, sources } = await readConfigFiles(options.cwd);
|
|
548
|
+
const config = applySessionConfigOverride(fileConfig);
|
|
549
|
+
const path = getConfigPath(query);
|
|
550
|
+
const value = path ? get(config, path) : config;
|
|
551
|
+
|
|
552
|
+
if (value === undefined) {
|
|
553
|
+
return {
|
|
554
|
+
status: 'not_found',
|
|
555
|
+
query,
|
|
556
|
+
redacted: false,
|
|
557
|
+
sources,
|
|
558
|
+
message: `Config path not found: ${path}`,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
status: 'found',
|
|
564
|
+
query,
|
|
565
|
+
value,
|
|
566
|
+
redacted: false,
|
|
567
|
+
sources,
|
|
568
|
+
};
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
export {
|
|
572
|
+
clearSessionConfigOverride,
|
|
573
|
+
EMPTY_PI_CORE_CONFIG,
|
|
574
|
+
ensureErrorMessage,
|
|
575
|
+
getAgentPath,
|
|
576
|
+
getConfig,
|
|
577
|
+
getConfigValue,
|
|
578
|
+
getSessionConfigOverride,
|
|
579
|
+
queryConfig,
|
|
580
|
+
readConfigFiles,
|
|
581
|
+
resolvePiPath,
|
|
582
|
+
setSessionConfigOverride,
|
|
583
|
+
};
|