@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/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homer0/pi-config",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Config management building block for Pi",
|
|
6
|
+
"author": "Leonardo Apiwan (@homer0) <me@homer0.com>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi",
|
|
11
|
+
"pi-extension",
|
|
12
|
+
"config"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/homer0/packages.git",
|
|
17
|
+
"directory": "packages/public/pi-config"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"types": "./src/index.ts",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist/",
|
|
26
|
+
"src/",
|
|
27
|
+
"!src/**/*.test.ts"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./src/index.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24"
|
|
38
|
+
},
|
|
39
|
+
"pi": {
|
|
40
|
+
"extensions": [
|
|
41
|
+
"./dist/extensions/config.js"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@homer0/object-utils": "^6.0.7",
|
|
46
|
+
"yaml": "2.9.1",
|
|
47
|
+
"zod": "4.6.7"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@earendil-works/pi-coding-agent": ">1.0.0",
|
|
51
|
+
"@earendil-works/pi-tui": ">1.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@earendil-works/pi-coding-agent": "catalog:",
|
|
55
|
+
"@earendil-works/pi-tui": "catalog:",
|
|
56
|
+
"@homer0/oxlint-config": "workspace:*",
|
|
57
|
+
"@homer0/tsconfig": "workspace:*",
|
|
58
|
+
"@types/node": "catalog:",
|
|
59
|
+
"@vitest/coverage-v8": "catalog:",
|
|
60
|
+
"tsdown": "catalog:",
|
|
61
|
+
"oxlint": "catalog:",
|
|
62
|
+
"typescript": "catalog:",
|
|
63
|
+
"vitest": "catalog:"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"lint": "oxlint .",
|
|
67
|
+
"lint:all": "oxlint .",
|
|
68
|
+
"lint:file": "oxlint $@",
|
|
69
|
+
"test": "vitest run $@",
|
|
70
|
+
"types:check": "tsc --noEmit",
|
|
71
|
+
"build": "tsdown",
|
|
72
|
+
"prepublishOnly": "pnpm run build"
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* eslint-disable max-classes-per-file -- The config errors live together. */
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/** The error for the config files that couldn't be read or parsed. */
|
|
5
|
+
export class ConfigReadError extends Error {
|
|
6
|
+
/** The path of the config file that failed. */
|
|
7
|
+
readonly filePath: string;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param message The description of what failed.
|
|
11
|
+
* @param filePath The path of the config file that failed.
|
|
12
|
+
* @param options The error options, to preserve the original `cause`.
|
|
13
|
+
*/
|
|
14
|
+
constructor(message: string, filePath: string, options?: ErrorOptions) {
|
|
15
|
+
super(message, options);
|
|
16
|
+
this.filePath = filePath;
|
|
17
|
+
this.name = 'ConfigReadError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The error thrown when the resolved config doesn't match the schema that was provided. */
|
|
22
|
+
export class ConfigValidationError extends Error {
|
|
23
|
+
/** The Zod issues that explain why the value didn't match the schema. */
|
|
24
|
+
readonly issues: z.core.$ZodIssue[];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param message The description of what failed.
|
|
28
|
+
* @param issues The Zod issues that explain why the value didn't match the schema.
|
|
29
|
+
*/
|
|
30
|
+
constructor(message: string, issues: z.core.$ZodIssue[]) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.issues = issues;
|
|
33
|
+
this.name = 'ConfigValidationError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { formatValuePreview, getQueryLabel, getUsedSourceCount } from '../format.js';
|
|
5
|
+
import {
|
|
6
|
+
getConfigParamsSchema,
|
|
7
|
+
setConfigParamsSchema,
|
|
8
|
+
type GetConfigParams,
|
|
9
|
+
type SetConfigParams,
|
|
10
|
+
} from '../schemas.js';
|
|
11
|
+
import type { ConfigQuery, SetConfigDetails } from '../types.js';
|
|
12
|
+
|
|
13
|
+
/** Module shape of the lazily loaded handlers. */
|
|
14
|
+
type HandlersModule = typeof import('../handlers.js');
|
|
15
|
+
|
|
16
|
+
/** The promise of the handlers module, shared by every call once it's been requested. */
|
|
17
|
+
let handlersModule: Promise<HandlersModule> | undefined;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Lazy-loads all the handlers through a single shared `import()`. Tools can be called in
|
|
21
|
+
* parallel by the model, and jiti evaluates lazily loaded modules asynchronously, handing
|
|
22
|
+
* partial exports to any `import()` of a module (or of a dependency shared by several
|
|
23
|
+
* handlers) that is still evaluating. Loading everything through one memoized promise
|
|
24
|
+
* makes overlapping calls wait for the same evaluation. A failed load is not cached, so
|
|
25
|
+
* the next call retries.
|
|
26
|
+
*
|
|
27
|
+
* @returns The module with all the handlers.
|
|
28
|
+
*/
|
|
29
|
+
const loadHandlers = (): Promise<HandlersModule> => {
|
|
30
|
+
handlersModule ??= import('../handlers.js').catch((error: unknown) => {
|
|
31
|
+
handlersModule = undefined;
|
|
32
|
+
throw error;
|
|
33
|
+
});
|
|
34
|
+
return handlersModule;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Tool definition for reading resolved Pi Core config. */
|
|
38
|
+
const getConfigTool: Parameters<ExtensionAPI['registerTool']>[0] = {
|
|
39
|
+
name: 'get_config',
|
|
40
|
+
label: 'Get Config',
|
|
41
|
+
description:
|
|
42
|
+
'Read resolved Pi Core config from config.json and config.local.json for local extensions, tools, and skills that explicitly use this convention.',
|
|
43
|
+
promptSnippet:
|
|
44
|
+
'Use get_config only when a tool, skill, or extension explicitly calls for Pi Core config.',
|
|
45
|
+
promptGuidelines: [
|
|
46
|
+
'Call get_config only when a tool, skill, or extension explicitly calls for Pi Core config.',
|
|
47
|
+
'Do not infer or guess config support from package names or behavior.',
|
|
48
|
+
'Prefer the narrowest lookup: path first, then pi or named skill/tool/extension, then namespace, and full config only when necessary.',
|
|
49
|
+
"Use scope: 'pi' for general Pi/session/agent settings, for example { scope: 'pi' }.",
|
|
50
|
+
"Use scope: 'skill' with name for local skill preferences, for example { scope: 'skill', name: 'commit' }.",
|
|
51
|
+
"Use scope: 'tool' with name for local tool defaults, for example { scope: 'tool', name: 'bash' }.",
|
|
52
|
+
"Use scope: 'extension' with name for local extension settings, for example { scope: 'extension', name: 'config' }.",
|
|
53
|
+
'Do not expose config values to the user unless necessary for the answer or requested by the user.',
|
|
54
|
+
'Treat missing config as absence of preference, not as an error.',
|
|
55
|
+
'Ask the user before relying on unclear or conflicting config.',
|
|
56
|
+
],
|
|
57
|
+
parameters: z.toJSONSchema(getConfigParamsSchema),
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Runs the tool: it lazy-loads the handler and delegates the work to it.
|
|
61
|
+
*
|
|
62
|
+
* @param _toolCallId The id of the tool call. It's not used.
|
|
63
|
+
* @param rawParams The parameters the model sent.
|
|
64
|
+
* @param signal The signal to abort the lookup.
|
|
65
|
+
* @param _onUpdate The callback for partial results. It's not used.
|
|
66
|
+
* @param ctx The context of the session, to know the project directory.
|
|
67
|
+
* @returns The result of the tool.
|
|
68
|
+
*/
|
|
69
|
+
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
|
|
70
|
+
const { handleGetConfig } = await loadHandlers();
|
|
71
|
+
return handleGetConfig({ params: rawParams as GetConfigParams, signal, ctx });
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Renders the call of the tool in the TUI.
|
|
76
|
+
*
|
|
77
|
+
* @param args The parameters of the call.
|
|
78
|
+
* @param theme The theme to color the text with.
|
|
79
|
+
* @returns The text to show: the label of the query and what kind of lookup it is.
|
|
80
|
+
*/
|
|
81
|
+
renderCall(args, theme) {
|
|
82
|
+
const query = args as ConfigQuery;
|
|
83
|
+
const label = getQueryLabel(query);
|
|
84
|
+
let details = 'full config';
|
|
85
|
+
if (query.path) details = 'path lookup';
|
|
86
|
+
else if (query.scope && query.name) details = `${query.scope} scope`;
|
|
87
|
+
else if (query.scope) details = `${query.scope} namespace`;
|
|
88
|
+
return new Text(
|
|
89
|
+
`${theme.fg('toolTitle', theme.bold('get_config '))}${theme.fg(
|
|
90
|
+
'accent',
|
|
91
|
+
label,
|
|
92
|
+
)}\n${theme.fg('dim', ` ${details}`)}`,
|
|
93
|
+
0,
|
|
94
|
+
0,
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Renders the result of the tool in the TUI.
|
|
100
|
+
*
|
|
101
|
+
* @param result The result of the tool.
|
|
102
|
+
* @param options The render options, to know if the result is partial.
|
|
103
|
+
* @param theme The theme to color the text with.
|
|
104
|
+
* @returns The text to show: a preview of the value and how many files it came from, or
|
|
105
|
+
* the reason why there's no value.
|
|
106
|
+
*/
|
|
107
|
+
renderResult(result, options, theme) {
|
|
108
|
+
const details = result.details as {
|
|
109
|
+
status?: string;
|
|
110
|
+
value?: unknown;
|
|
111
|
+
message?: string;
|
|
112
|
+
sources?: unknown;
|
|
113
|
+
};
|
|
114
|
+
const prefix = options.isPartial ? `${theme.fg('muted', '… ')} ` : '';
|
|
115
|
+
if (details.status === 'found') {
|
|
116
|
+
const usedSourceCount = getUsedSourceCount(details.sources);
|
|
117
|
+
const sourceText =
|
|
118
|
+
usedSourceCount === 1 ? '1 source' : `${usedSourceCount} sources`;
|
|
119
|
+
return new Text(
|
|
120
|
+
`${prefix}${theme.fg('success', '✓')} ${theme.fg(
|
|
121
|
+
'muted',
|
|
122
|
+
sourceText,
|
|
123
|
+
)} ${formatValuePreview(details.value)}`,
|
|
124
|
+
0,
|
|
125
|
+
0,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return new Text(
|
|
129
|
+
`${prefix}${theme.fg(
|
|
130
|
+
details.status === 'not_found' ? 'warning' : 'error',
|
|
131
|
+
details.message ?? 'Config lookup failed',
|
|
132
|
+
)}`,
|
|
133
|
+
0,
|
|
134
|
+
0,
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Tool definition for setting an in-memory session config override. */
|
|
140
|
+
const setConfigTool: Parameters<ExtensionAPI['registerTool']>[0] = {
|
|
141
|
+
name: 'set_config',
|
|
142
|
+
label: 'Set Config',
|
|
143
|
+
description:
|
|
144
|
+
'Override one Pi Core config value for the rest of this session. In-memory only: never written to config.json/config.local.json/config.yaml/config.local.yaml, and lost when the session ends.',
|
|
145
|
+
promptSnippet:
|
|
146
|
+
'Only call set_config when the user explicitly asks to override a config value for this session.',
|
|
147
|
+
promptGuidelines: [
|
|
148
|
+
'Only call set_config when the user explicitly asks to override a specific config value for this session.',
|
|
149
|
+
'Never call set_config speculatively, to work around an error, or to "fix" a config problem the user did not ask you to change.',
|
|
150
|
+
'set_config only affects this session/process and is never persisted to any config file.',
|
|
151
|
+
'set_config can only set a value; it cannot remove a file-based value or restore file-only behavior for one path. Tell the user to use /config to clear the whole session override.',
|
|
152
|
+
],
|
|
153
|
+
parameters: z.toJSONSchema(setConfigParamsSchema),
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Runs the tool: it lazy-loads the handler and delegates the work to it.
|
|
157
|
+
*
|
|
158
|
+
* @param _toolCallId The id of the tool call. It's not used.
|
|
159
|
+
* @param rawParams The parameters the model sent.
|
|
160
|
+
* @returns The result of the tool.
|
|
161
|
+
*/
|
|
162
|
+
async execute(_toolCallId, rawParams) {
|
|
163
|
+
const { handleSetConfig } = await loadHandlers();
|
|
164
|
+
return handleSetConfig({ params: rawParams as SetConfigParams });
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Renders the call of the tool in the TUI.
|
|
169
|
+
*
|
|
170
|
+
* @param args The parameters of the call.
|
|
171
|
+
* @param theme The theme to color the text with.
|
|
172
|
+
* @returns The text to show: the path that is being set.
|
|
173
|
+
*/
|
|
174
|
+
renderCall(args, theme) {
|
|
175
|
+
const params = args as Partial<SetConfigParams>;
|
|
176
|
+
return new Text(
|
|
177
|
+
`${theme.fg('toolTitle', theme.bold('set_config '))}${theme.fg('accent', params.path ?? '')}`,
|
|
178
|
+
0,
|
|
179
|
+
0,
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Renders the result of the tool in the TUI.
|
|
185
|
+
*
|
|
186
|
+
* @param result The result of the tool.
|
|
187
|
+
* @param options The render options, to know if the result is partial.
|
|
188
|
+
* @param theme The theme to color the text with.
|
|
189
|
+
* @returns The text to show: the path and the value that were set, or the reason why it
|
|
190
|
+
* failed.
|
|
191
|
+
*/
|
|
192
|
+
renderResult(result, options, theme) {
|
|
193
|
+
const details = result.details as SetConfigDetails | undefined;
|
|
194
|
+
const prefix = options.isPartial ? `${theme.fg('muted', '… ')} ` : '';
|
|
195
|
+
if (!details || details.status !== 'applied') {
|
|
196
|
+
return new Text(
|
|
197
|
+
`${prefix}${theme.fg('error', details?.message ?? 'set_config failed')}`,
|
|
198
|
+
0,
|
|
199
|
+
0,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return new Text(
|
|
203
|
+
`${prefix}${theme.fg('success', '✓ ')}${theme.fg('accent', details.path)} = ${JSON.stringify(details.value)}`,
|
|
204
|
+
0,
|
|
205
|
+
0,
|
|
206
|
+
);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Registers config extension tools and the `/config`/`/config-reset` commands with Pi.
|
|
212
|
+
* The definitions are light; the tools' `execute` and the commands lazy-load their
|
|
213
|
+
* implementation, through the shared handlers loader.
|
|
214
|
+
*
|
|
215
|
+
* @param pi The Pi extension API.
|
|
216
|
+
*/
|
|
217
|
+
const config = (pi: ExtensionAPI): void => {
|
|
218
|
+
pi.registerTool(getConfigTool);
|
|
219
|
+
pi.registerTool(setConfigTool);
|
|
220
|
+
|
|
221
|
+
pi.registerCommand('config', {
|
|
222
|
+
description: 'View the effective Pi Core config and edit the session override',
|
|
223
|
+
handler: async (_args, ctx) => {
|
|
224
|
+
const { handleConfig } = await loadHandlers();
|
|
225
|
+
await handleConfig({ ctx });
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
pi.registerCommand('config-reset', {
|
|
230
|
+
description: 'Clear the session config override',
|
|
231
|
+
handler: async (_args, ctx) => {
|
|
232
|
+
const { handleConfigReset } = await loadHandlers();
|
|
233
|
+
handleConfigReset({ ctx });
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export default config;
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ConfigQuery } from './types.js';
|
|
2
|
+
|
|
3
|
+
/** The number of spaces used to indent the JSON output. */
|
|
4
|
+
const JSON_INDENT = 2;
|
|
5
|
+
/** The max number of characters of a value preview. */
|
|
6
|
+
const SUMMARY_LIMIT = 120;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Formats config values for model-facing tool output.
|
|
10
|
+
*
|
|
11
|
+
* @param value The value to format.
|
|
12
|
+
* @returns The value as it is if it's a string, or as indented JSON for anything else.
|
|
13
|
+
*/
|
|
14
|
+
export const formatValue = (value: unknown): string => {
|
|
15
|
+
if (typeof value === 'string') return value;
|
|
16
|
+
return JSON.stringify(value, null, JSON_INDENT);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Formats a compact single-line value preview for TUI rendering.
|
|
21
|
+
*
|
|
22
|
+
* @param value The value to preview.
|
|
23
|
+
* @returns The formatted value in a single line, cut with an ellipsis if it's too long.
|
|
24
|
+
*/
|
|
25
|
+
export const formatValuePreview = (value: unknown): string => {
|
|
26
|
+
const preview = formatValue(value).replaceAll('\n', ' ');
|
|
27
|
+
if (preview.length <= SUMMARY_LIMIT) return preview;
|
|
28
|
+
return `${preview.slice(0, SUMMARY_LIMIT - 1)}…`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Builds a compact label for displaying get_config calls.
|
|
33
|
+
*
|
|
34
|
+
* @param query The query of the call.
|
|
35
|
+
* @returns The path if there's one; `scope:name` for a named integration; the scope for a
|
|
36
|
+
* namespace; or `all` for the whole config.
|
|
37
|
+
*/
|
|
38
|
+
export const getQueryLabel = (query: ConfigQuery): string => {
|
|
39
|
+
if (query.path) return query.path;
|
|
40
|
+
if (query.scope && query.name) return `${query.scope}:${query.name}`;
|
|
41
|
+
if (query.scope) return query.scope;
|
|
42
|
+
return 'all';
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Counts config source files that exist and contributed to a lookup.
|
|
47
|
+
*
|
|
48
|
+
* @param sources The `sources` of a query result. It comes from the details of a tool
|
|
49
|
+
* result, so it can't be trusted to have the right shape.
|
|
50
|
+
* @returns The number of sources that were used, or `0` if `sources` is not a list.
|
|
51
|
+
*/
|
|
52
|
+
export const getUsedSourceCount = (sources: unknown): number => {
|
|
53
|
+
if (!Array.isArray(sources)) return 0;
|
|
54
|
+
return sources.filter(
|
|
55
|
+
(source) =>
|
|
56
|
+
source && typeof source === 'object' && 'used' in source && source.used === true,
|
|
57
|
+
).length;
|
|
58
|
+
};
|
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { set } from '@homer0/object-utils';
|
|
3
|
+
import { ConfigReadError } from './errors.js';
|
|
4
|
+
import { formatValue, getQueryLabel } from './format.js';
|
|
5
|
+
import type { GetConfigParams, SetConfigParams } from './schemas.js';
|
|
6
|
+
import type { ConfigQuery, PiCoreConfig, SetConfigDetails } from './types.js';
|
|
7
|
+
import {
|
|
8
|
+
clearSessionConfigOverride,
|
|
9
|
+
EMPTY_PI_CORE_CONFIG,
|
|
10
|
+
getSessionConfigOverride,
|
|
11
|
+
queryConfig,
|
|
12
|
+
setSessionConfigOverride,
|
|
13
|
+
} from './utils.js';
|
|
14
|
+
import { showConfigWindow } from './window.js';
|
|
15
|
+
|
|
16
|
+
/** The text that replaces a value that looks like a secret. */
|
|
17
|
+
const REDACTED_VALUE = '[REDACTED]';
|
|
18
|
+
/** The pattern of the keys that look like they would contain secrets. */
|
|
19
|
+
const SENSITIVE_KEY_PATTERN =
|
|
20
|
+
/(?:secret|password|passwd|token|apiKey|api[_-]?key|privateKey|private[_-]?key|accessKey|access[_-]?key|key$)/iu;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Checks whether a config path segment is likely to contain secret material.
|
|
24
|
+
*
|
|
25
|
+
* @param key The segment of the path.
|
|
26
|
+
* @returns Whether it looks like a secret.
|
|
27
|
+
*/
|
|
28
|
+
const isSensitiveKey = (key: string): boolean => SENSITIVE_KEY_PATTERN.test(key);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Redacts model-facing config values while preserving raw access through the Node API.
|
|
32
|
+
*
|
|
33
|
+
* @param value The value to redact.
|
|
34
|
+
* @param path The segments of the path the value is at. If any of them looks like a
|
|
35
|
+
* secret, the whole value is redacted.
|
|
36
|
+
* @returns The value, and whether something was redacted.
|
|
37
|
+
*/
|
|
38
|
+
const redactValue = (
|
|
39
|
+
value: unknown,
|
|
40
|
+
path: string[],
|
|
41
|
+
): { value: unknown; redacted: boolean } => {
|
|
42
|
+
if (path.some(isSensitiveKey)) return { value: REDACTED_VALUE, redacted: true };
|
|
43
|
+
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
let redacted = false;
|
|
46
|
+
const items = value.map((item, index) => {
|
|
47
|
+
const result = redactValue(item, path.concat(String(index)));
|
|
48
|
+
redacted ||= result.redacted;
|
|
49
|
+
return result.value;
|
|
50
|
+
});
|
|
51
|
+
return { value: items, redacted };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!value || typeof value !== 'object') return { value, redacted: false };
|
|
55
|
+
|
|
56
|
+
let redacted = false;
|
|
57
|
+
const entries = Object.entries(value).map(([key, childValue]) => {
|
|
58
|
+
const result = redactValue(childValue, path.concat(key));
|
|
59
|
+
redacted ||= result.redacted;
|
|
60
|
+
return [key, result.value] as const;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { value: Object.fromEntries(entries), redacted };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Gets the segments of the query path, when the lookup targets a specific path.
|
|
68
|
+
*
|
|
69
|
+
* @param query The query of the lookup.
|
|
70
|
+
* @returns The segments, or an empty list if the query doesn't have a path.
|
|
71
|
+
*/
|
|
72
|
+
const getQueryPathSegments = (query: ConfigQuery): string[] =>
|
|
73
|
+
query.path ? query.path.split('.').filter(Boolean) : [];
|
|
74
|
+
|
|
75
|
+
/** Options for {@link handleGetConfig}. */
|
|
76
|
+
type HandleGetConfigOptions = {
|
|
77
|
+
/** The parameters the model sent. */
|
|
78
|
+
params: GetConfigParams;
|
|
79
|
+
/** The signal to abort the lookup. */
|
|
80
|
+
signal: AbortSignal | undefined;
|
|
81
|
+
/** The context of the session, to know the project directory. */
|
|
82
|
+
ctx: { cwd: string };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Executes the `get_config` tool: reads, redacts and formats the requested config.
|
|
87
|
+
*
|
|
88
|
+
* @param options The parameters of the call and the context it was made in.
|
|
89
|
+
* @returns The result of the tool, with the text for the model and the details for the
|
|
90
|
+
* TUI. Failures are reported in the result, they don't throw.
|
|
91
|
+
*/
|
|
92
|
+
export const handleGetConfig = async ({
|
|
93
|
+
params,
|
|
94
|
+
signal,
|
|
95
|
+
ctx,
|
|
96
|
+
}: HandleGetConfigOptions) => {
|
|
97
|
+
if (signal?.aborted) {
|
|
98
|
+
return {
|
|
99
|
+
content: [{ type: 'text' as const, text: 'Config lookup cancelled.' }],
|
|
100
|
+
details: {
|
|
101
|
+
status: 'error',
|
|
102
|
+
query: params,
|
|
103
|
+
redacted: false,
|
|
104
|
+
sources: [],
|
|
105
|
+
message: 'Config lookup cancelled.',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const result = await queryConfig(params, { cwd: ctx.cwd });
|
|
112
|
+
const redacted =
|
|
113
|
+
result.status === 'found'
|
|
114
|
+
? redactValue(result.value, getQueryPathSegments(params))
|
|
115
|
+
: { value: result.value, redacted: false };
|
|
116
|
+
const text =
|
|
117
|
+
result.status === 'found'
|
|
118
|
+
? `Config value for ${getQueryLabel(params)}:\n${formatValue(redacted.value)}`
|
|
119
|
+
: (result.message ?? result.status);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: 'text' as const, text }],
|
|
123
|
+
details: {
|
|
124
|
+
...result,
|
|
125
|
+
value: redacted.value,
|
|
126
|
+
redacted: redacted.redacted,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: 'text' as const, text: message }],
|
|
133
|
+
details: {
|
|
134
|
+
status: 'error',
|
|
135
|
+
query: params,
|
|
136
|
+
redacted: false,
|
|
137
|
+
sources: [],
|
|
138
|
+
message,
|
|
139
|
+
filePath: error instanceof ConfigReadError ? error.filePath : undefined,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Options for {@link handleSetConfig}. */
|
|
146
|
+
type HandleSetConfigOptions = {
|
|
147
|
+
/** The parameters the model sent. */
|
|
148
|
+
params: SetConfigParams;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Executes the `set_config` tool: applies an in-memory session config override.
|
|
153
|
+
*
|
|
154
|
+
* @param options The parameters of the call.
|
|
155
|
+
* @returns The result of the tool, with the text for the model and the details for the
|
|
156
|
+
* TUI. Failures are reported in the result, they don't throw.
|
|
157
|
+
*/
|
|
158
|
+
export const handleSetConfig = ({ params }: HandleSetConfigOptions) => {
|
|
159
|
+
const path = params.path.trim();
|
|
160
|
+
|
|
161
|
+
if (!path) {
|
|
162
|
+
const message = 'set_config requires a non-empty path.';
|
|
163
|
+
return {
|
|
164
|
+
content: [{ type: 'text' as const, text: message }],
|
|
165
|
+
details: { status: 'error', path, message } satisfies SetConfigDetails,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const current = structuredClone(getSessionConfigOverride() ?? EMPTY_PI_CORE_CONFIG);
|
|
170
|
+
const updated = set<PiCoreConfig>({ target: current, path, value: params.value });
|
|
171
|
+
|
|
172
|
+
if (typeof updated === 'undefined') {
|
|
173
|
+
const message = `Could not set config path: ${path}`;
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: 'text' as const, text: message }],
|
|
176
|
+
details: { status: 'error', path, message } satisfies SetConfigDetails,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
setSessionConfigOverride(updated);
|
|
181
|
+
const text = `Session override set: ${path} = ${JSON.stringify(params.value)}`;
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: 'text' as const, text }],
|
|
184
|
+
details: {
|
|
185
|
+
status: 'applied',
|
|
186
|
+
path,
|
|
187
|
+
value: params.value,
|
|
188
|
+
} satisfies SetConfigDetails,
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** Options for {@link handleConfig}. */
|
|
193
|
+
type HandleConfigOptions = {
|
|
194
|
+
/** The context of the command. */
|
|
195
|
+
ctx: ExtensionCommandContext;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Handles `/config`: opens the config window.
|
|
200
|
+
*
|
|
201
|
+
* @param options The context of the command.
|
|
202
|
+
*/
|
|
203
|
+
export const handleConfig = async ({ ctx }: HandleConfigOptions): Promise<void> => {
|
|
204
|
+
await showConfigWindow(ctx);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Options for {@link handleConfigReset}. */
|
|
208
|
+
type HandleConfigResetOptions = {
|
|
209
|
+
/** The context of the command. */
|
|
210
|
+
ctx: ExtensionCommandContext;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Handles `/config-reset`: clears the session config override, and tells the user if
|
|
215
|
+
* there was one.
|
|
216
|
+
*
|
|
217
|
+
* @param options The context of the command.
|
|
218
|
+
*/
|
|
219
|
+
export const handleConfigReset = ({ ctx }: HandleConfigResetOptions): void => {
|
|
220
|
+
const hadOverride = clearSessionConfigOverride();
|
|
221
|
+
ctx.ui.notify(
|
|
222
|
+
hadOverride ? 'Session config override cleared.' : 'No session override was set.',
|
|
223
|
+
'info',
|
|
224
|
+
);
|
|
225
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { getConfig, getConfigValue, queryConfig } from './utils.js';
|
|
2
|
+
export type {
|
|
3
|
+
ConfigNamespace,
|
|
4
|
+
ConfigPath,
|
|
5
|
+
ConfigPathValue,
|
|
6
|
+
ConfigQuery,
|
|
7
|
+
ConfigQueryScope,
|
|
8
|
+
ConfigSchema,
|
|
9
|
+
ConfigSource,
|
|
10
|
+
ExtensionsConfig,
|
|
11
|
+
GetConfigOptions,
|
|
12
|
+
PiConfig,
|
|
13
|
+
PiCoreConfig,
|
|
14
|
+
QueryConfigOptions,
|
|
15
|
+
QueryConfigResult,
|
|
16
|
+
ReadConfigResult,
|
|
17
|
+
SkillsConfig,
|
|
18
|
+
ToolsConfig,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
export { ConfigValidationError } from './errors.js';
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** Model-facing schema for the get_config tool. */
|
|
4
|
+
export const getConfigParamsSchema = z.object({
|
|
5
|
+
path: z
|
|
6
|
+
.string()
|
|
7
|
+
.optional()
|
|
8
|
+
.describe('Dot-path to read, for example skills.commit.runChecks.'),
|
|
9
|
+
scope: z
|
|
10
|
+
.enum(['all', 'pi', 'skills', 'tools', 'extensions', 'skill', 'tool', 'extension'])
|
|
11
|
+
.optional()
|
|
12
|
+
.describe(
|
|
13
|
+
'Config scope to read. Use pi for general Pi settings, or skill/tool/extension with name.',
|
|
14
|
+
),
|
|
15
|
+
name: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('Name for skill, tool, or extension scoped lookups.'),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** The parameters the model sends to the `get_config` tool. */
|
|
22
|
+
export type GetConfigParams = z.infer<typeof getConfigParamsSchema>;
|
|
23
|
+
|
|
24
|
+
/** Model-facing schema for the set_config tool. */
|
|
25
|
+
export const setConfigParamsSchema = z.object({
|
|
26
|
+
path: z
|
|
27
|
+
.string()
|
|
28
|
+
.describe(
|
|
29
|
+
'Dot-path to override for the rest of this session, for example extensions.notifyMe.priority.',
|
|
30
|
+
),
|
|
31
|
+
value: z
|
|
32
|
+
.unknown()
|
|
33
|
+
.describe(
|
|
34
|
+
'Value to set at path. Only this path changes; the rest of the config is unaffected.',
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** The parameters the model sends to the `set_config` tool. */
|
|
39
|
+
export type SetConfigParams = z.infer<typeof setConfigParamsSchema>;
|