@homer0/pi-profiles 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/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { profilesConfigSchema, type ProfilesConfig } from './schemas.js';
2
+ export type { ProfileLibOptions } from './types.js';
3
+ export { resolveEnabledProfiles } from './utils.js';
@@ -0,0 +1,323 @@
1
+ import {
2
+ DefaultPackageManager,
3
+ SettingsManager,
4
+ getAgentDir,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ type ExtensionHandler,
8
+ type SessionStartEvent,
9
+ } from '@earendil-works/pi-coding-agent';
10
+ import { existsSync, statSync } from 'node:fs';
11
+ import { readFile } from 'node:fs/promises';
12
+ import { dirname, isAbsolute, join } from 'node:path';
13
+ import { profilePackagesSchema, type PackageEntry } from './schemas.js';
14
+ import type {
15
+ LoadProfileExtensionsOptions,
16
+ PackageResourcePaths,
17
+ ProfileLibOptions,
18
+ ProfileResources,
19
+ ResolveProfilePackageResourcesOptions,
20
+ } from './types.js';
21
+ import {
22
+ ensureErrorMessage,
23
+ getAgentPath,
24
+ resolveEnabledProfiles,
25
+ resolveScriptPath,
26
+ runScript,
27
+ warnProfilesIssue,
28
+ } from './utils.js';
29
+
30
+ const AGENT_DIR = getAgentDir();
31
+ const PROFILES_DIR = getAgentPath('profiles');
32
+ const PACKAGES_FILENAME = 'packages.json';
33
+ const AGENTS_FILENAME = 'AGENTS.md';
34
+ const SKILLS_DIRNAME = 'skills';
35
+
36
+ /** Factory function signature exported by a pi extension module. */
37
+ type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
38
+
39
+ /**
40
+ * Checks if a path is an existing directory.
41
+ *
42
+ * @param path The path to check.
43
+ * @returns Whether it's a directory.
44
+ */
45
+ const isDirectory = (path: string): boolean => {
46
+ try {
47
+ return statSync(path).isDirectory();
48
+ } catch {
49
+ return false;
50
+ }
51
+ };
52
+
53
+ /**
54
+ * Resolves a single package entry to a path, evaluating its `enabledIf` condition if it
55
+ * has one.
56
+ *
57
+ * @param entry The entry from the profile's `packages.json`.
58
+ * @param profileDir The directory of the profile, to resolve the script against.
59
+ * @returns The path of the package, or `undefined` if it's turned off.
60
+ */
61
+ const resolvePackageEntry = async (
62
+ entry: PackageEntry,
63
+ profileDir: string,
64
+ ): Promise<string | undefined> => {
65
+ if (typeof entry === 'string') return entry;
66
+ const { path: pkgPath, enabledIf } = entry;
67
+ if (enabledIf === false || enabledIf === 'false') return undefined;
68
+ const resolved = resolveScriptPath(enabledIf, profileDir);
69
+ if (typeof resolved === 'undefined') {
70
+ warnProfilesIssue(
71
+ `Could not resolve package enabledIf script for "${pkgPath}": ${enabledIf}`,
72
+ );
73
+ return undefined;
74
+ }
75
+ return (await runScript(resolved)) ? pkgPath : undefined;
76
+ };
77
+
78
+ /**
79
+ * Reads the packages a profile declares, evaluating the `enabledIf` condition of each. If
80
+ * the file is invalid, it reports it through `onError` and the profile has no packages.
81
+ *
82
+ * @param packagesPath The path of the profile's `packages.json`.
83
+ * @param onError Where to report non-fatal issues.
84
+ * @returns The paths of the packages, as declared.
85
+ */
86
+ const readProfilePackages = async (
87
+ packagesPath: string,
88
+ onError: (message: string) => void,
89
+ ): Promise<string[]> => {
90
+ if (!existsSync(packagesPath)) return [];
91
+ const profileDir = dirname(packagesPath);
92
+
93
+ try {
94
+ const parsed = JSON.parse(await readFile(packagesPath, 'utf8')) as unknown;
95
+ const { packages } = profilePackagesSchema.parse(parsed);
96
+ const results: string[] = [];
97
+ for (const entry of packages) {
98
+ const pkgPath = await resolvePackageEntry(entry, profileDir);
99
+ if (typeof pkgPath !== 'undefined') results.push(pkgPath);
100
+ }
101
+ return results;
102
+ } catch (error) {
103
+ onError(
104
+ `Invalid profile packages file ${packagesPath}; skipped. ${ensureErrorMessage(error)}`,
105
+ );
106
+ return [];
107
+ }
108
+ };
109
+
110
+ /**
111
+ * Discovers what every enabled profile exposes: its `skills` directory, its `AGENTS.md`
112
+ * context file, and its declared packages. Whatever can't be read is reported through
113
+ * `onError` and skipped.
114
+ *
115
+ * @param options The directory to resolve from, and where to report non-fatal issues.
116
+ * @returns The names of the profiles and what they expose.
117
+ */
118
+ export const discoverProfileResources = async (
119
+ options: ProfileLibOptions = {},
120
+ ): Promise<ProfileResources> => {
121
+ const onError = options.onError ?? ((message: string) => warnProfilesIssue(message));
122
+ const names = await resolveEnabledProfiles(options);
123
+ const resources: ProfileResources = {
124
+ names,
125
+ skillPaths: [],
126
+ agentsFiles: [],
127
+ packages: [],
128
+ };
129
+
130
+ if (names.length === 0) return resources;
131
+
132
+ for (const name of names) {
133
+ const profileDir = join(PROFILES_DIR, name);
134
+ const skillsPath = join(profileDir, SKILLS_DIRNAME);
135
+ const agentsPath = join(profileDir, AGENTS_FILENAME);
136
+ const packagesPath = join(profileDir, PACKAGES_FILENAME);
137
+
138
+ if (isDirectory(skillsPath)) resources.skillPaths.push(skillsPath);
139
+
140
+ if (existsSync(agentsPath)) {
141
+ try {
142
+ resources.agentsFiles.push({
143
+ path: agentsPath,
144
+ content: await readFile(agentsPath, 'utf8'),
145
+ });
146
+ } catch (error) {
147
+ onError(
148
+ `Could not read profile context ${agentsPath}; skipped. ${ensureErrorMessage(error)}`,
149
+ );
150
+ }
151
+ }
152
+
153
+ const profilePackages = await readProfilePackages(packagesPath, onError);
154
+ resources.packages.push(
155
+ ...profilePackages.map((pkg) => (isAbsolute(pkg) ? pkg : join(profileDir, pkg))),
156
+ );
157
+ }
158
+
159
+ return resources;
160
+ };
161
+
162
+ /**
163
+ * One queued `session_start` handler registration, captured by {@link wrapPiForReplay} so
164
+ * it can be replayed after its factory returns.
165
+ */
166
+ type QueuedSessionStartReplay = () => Promise<void>;
167
+
168
+ /** Options for {@link wrapPiForReplay}. */
169
+ type WrapPiForReplayOptions = {
170
+ /** The Pi extension API to wrap. */
171
+ pi: ExtensionAPI;
172
+ /** The in-flight `session_start` event. */
173
+ sessionStartEvent: SessionStartEvent;
174
+ /** The context of the in-flight `session_start`. */
175
+ ctx: ExtensionContext;
176
+ };
177
+
178
+ /** Return value of {@link wrapPiForReplay}: the wrapped `pi` plus its replay queue. */
179
+ type WrapPiForReplayResult = { pi: ExtensionAPI; queue: QueuedSessionStartReplay[] };
180
+
181
+ /**
182
+ * Wraps `pi` for one dynamically-loaded factory call so any `pi.on('session_start', ...)`
183
+ * it makes still registers normally, but is also queued for replay. Pi snapshots
184
+ * registered handlers before dispatching an event, so a handler registered by a factory
185
+ * invoked from inside an in-flight `session_start` handler would otherwise never run for
186
+ * the event that triggered this load.
187
+ *
188
+ * @param options The API to wrap, and the event and context to replay.
189
+ * @returns The wrapped `pi`, and the replay queue in registration order.
190
+ */
191
+ const wrapPiForReplay = ({
192
+ pi,
193
+ sessionStartEvent,
194
+ ctx,
195
+ }: WrapPiForReplayOptions): WrapPiForReplayResult => {
196
+ const queue: QueuedSessionStartReplay[] = [];
197
+ // `ExtensionAPI.on` is a large overload union; a single forwarding implementation can't
198
+ // match every overload, so the event/handler pair is cast through `never` at the
199
+ // forwarding call and the finished function is cast once to the real overloaded type.
200
+ const on = ((eventType: string, handler: ExtensionHandler<SessionStartEvent>) => {
201
+ const unsubscribe = pi.on(eventType as never, handler as never);
202
+ if (eventType === 'session_start') {
203
+ queue.push(async () => {
204
+ await handler(sessionStartEvent, ctx);
205
+ });
206
+ }
207
+ return unsubscribe;
208
+ }) as ExtensionAPI['on'];
209
+
210
+ return { pi: { ...pi, on }, queue };
211
+ };
212
+
213
+ /**
214
+ * Imports and runs the extension factories of the profile packages. It's meant to be
215
+ * called from a `session_start` handler: the `session_start` handlers each factory
216
+ * registers are replayed right after, with the same event, because Pi's own dispatch for
217
+ * it has already taken its snapshot of the handlers. A handler that fails is reported and
218
+ * doesn't stop the others.
219
+ *
220
+ * @param pi The Pi extension API to give to the factories.
221
+ * @param options The packages to load, and the event and context to replay.
222
+ */
223
+ export const loadProfileExtensions = async (
224
+ pi: ExtensionAPI,
225
+ options: LoadProfileExtensionsOptions,
226
+ ): Promise<void> => {
227
+ const { packages, sessionStartEvent, ctx } = options;
228
+ if (packages.length === 0) return;
229
+ const cwd = options.cwd ?? process.cwd();
230
+ const onError = options.onError ?? ((message: string) => warnProfilesIssue(message));
231
+
232
+ try {
233
+ // Resolve the pi.extensions entry points declared by each profile package.
234
+ const settingsManager = SettingsManager.create(cwd, AGENT_DIR);
235
+ const packageManager = new DefaultPackageManager({
236
+ cwd,
237
+ agentDir: AGENT_DIR,
238
+ settingsManager,
239
+ });
240
+ const resolved = await packageManager.resolveExtensionSources(packages, {
241
+ temporary: true,
242
+ });
243
+
244
+ // Import each extension file and call its factory with a replay-wrapped pi API, then
245
+ // replay any session_start registrations it made, in registration order, with
246
+ // per-handler error isolation matching pi's own emit() behavior.
247
+ for (const ext of resolved.extensions) {
248
+ // Guard against the directory-path fallback that resolveExtensionSources emits
249
+ // when a package has no pi.extensions manifest and no index.ts/index.js.
250
+ if (ext.enabled && (ext.path.endsWith('.ts') || ext.path.endsWith('.js'))) {
251
+ try {
252
+ const imported = await import(ext.path);
253
+ const factory = imported?.default;
254
+ if (typeof factory === 'function') {
255
+ const { pi: replayPi, queue } = wrapPiForReplay({
256
+ pi,
257
+ sessionStartEvent,
258
+ ctx,
259
+ });
260
+ await (factory as ExtensionFactory)(replayPi);
261
+ for (const replay of queue) {
262
+ try {
263
+ await replay();
264
+ } catch (error) {
265
+ onError(
266
+ `Profile extension ${ext.path} session_start handler failed. ${ensureErrorMessage(error)}`,
267
+ );
268
+ }
269
+ }
270
+ }
271
+ } catch (error) {
272
+ onError(
273
+ `Could not load profile extension ${ext.path}; skipped. ${ensureErrorMessage(error)}`,
274
+ );
275
+ }
276
+ }
277
+ }
278
+ } catch (error) {
279
+ onError(`Could not load profile extensions; skipped. ${ensureErrorMessage(error)}`);
280
+ }
281
+ };
282
+
283
+ /**
284
+ * Resolves the skills, prompt templates and themes the profile packages expose. If they
285
+ * can't be resolved, it reports it through `onError` and there are none.
286
+ *
287
+ * @param options The packages to resolve, the directory to resolve from, and where to
288
+ * report non-fatal issues.
289
+ * @returns The paths of the enabled resources, by kind.
290
+ */
291
+ export const resolveProfilePackageResources = async (
292
+ options: ResolveProfilePackageResourcesOptions,
293
+ ): Promise<PackageResourcePaths> => {
294
+ const { packages } = options;
295
+ if (packages.length === 0) {
296
+ return { skillPaths: [], promptPaths: [], themePaths: [] };
297
+ }
298
+ const cwd = options.cwd ?? process.cwd();
299
+ const onError = options.onError ?? ((message: string) => warnProfilesIssue(message));
300
+
301
+ try {
302
+ const settingsManager = SettingsManager.create(cwd, AGENT_DIR);
303
+ const packageManager = new DefaultPackageManager({
304
+ cwd,
305
+ agentDir: AGENT_DIR,
306
+ settingsManager,
307
+ });
308
+ const resolved = await packageManager.resolveExtensionSources(packages, {
309
+ temporary: true,
310
+ });
311
+ const enabledPaths = (resources: typeof resolved.skills): string[] =>
312
+ resources.filter((resource) => resource.enabled).map((resource) => resource.path);
313
+
314
+ return {
315
+ skillPaths: enabledPaths(resolved.skills),
316
+ promptPaths: enabledPaths(resolved.prompts),
317
+ themePaths: enabledPaths(resolved.themes),
318
+ };
319
+ } catch (error) {
320
+ onError(`Could not load profile packages; skipped. ${ensureErrorMessage(error)}`);
321
+ return { skillPaths: [], promptPaths: [], themePaths: [] };
322
+ }
323
+ };
package/src/schemas.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Runtime schema for `pi.profiles` in the Pi config. */
4
+ export const profilesConfigSchema = z.object({
5
+ /** The names of the profiles that are always enabled. */
6
+ enabled: z.array(z.string().min(1)).default([]),
7
+ /**
8
+ * The conditional profiles: each key is a profile name, and each value is the path of a
9
+ * script that prints `true` when the profile should be enabled, or `false` to turn the
10
+ * entry off.
11
+ */
12
+ enabledIf: z
13
+ .record(z.string().min(1), z.union([z.literal(false), z.string().min(1)]))
14
+ .default({}),
15
+ });
16
+
17
+ /** The resolved `pi.profiles` config. */
18
+ export type ProfilesConfig = z.infer<typeof profilesConfigSchema>;
19
+
20
+ /**
21
+ * Runtime schema for one entry in a profile's `packages.json`: a path, or an object with
22
+ * the path and a condition.
23
+ */
24
+ export const packageEntrySchema = z.union([
25
+ z.string().min(1),
26
+ z.object({
27
+ /** The path of the package, absolute or relative to the profile directory. */
28
+ path: z.string().min(1),
29
+ /**
30
+ * The path of a script that prints `true` when the package should be loaded, or
31
+ * `false` to turn the entry off.
32
+ */
33
+ enabledIf: z.union([z.literal(false), z.string().min(1)]),
34
+ }),
35
+ ]);
36
+
37
+ /** One entry in a profile's `packages.json`. */
38
+ export type PackageEntry = z.infer<typeof packageEntrySchema>;
39
+
40
+ /** Runtime schema for a profile's `packages.json` file. */
41
+ export const profilePackagesSchema = z.object({
42
+ /** The packages the profile loads. */
43
+ packages: z.array(packageEntrySchema).default([]),
44
+ });
package/src/types.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ SessionStartEvent,
5
+ } from '@earendil-works/pi-coding-agent';
6
+ import type { ProfilesConfig } from './schemas.js';
7
+
8
+ /**
9
+ * Options shared by every profile-resolution/discovery function in this lib. Callers with
10
+ * an active Pi session (e.g. the `profiles` extension) pass their own `onError` to
11
+ * surface non-fatal issues through the session UI; other callers fall back to a
12
+ * `console.warn`-based reporter.
13
+ */
14
+ export type ProfileLibOptions = {
15
+ /**
16
+ * Working directory used to resolve config files and `enabledIf` scripts.
17
+ *
18
+ * @default process.cwd()
19
+ */
20
+ cwd?: string;
21
+ /** Called with a human-readable message when resolution hits a non-fatal issue. */
22
+ onError?: (message: string) => void;
23
+ };
24
+
25
+ /** Resources discovered across every currently-enabled profile. */
26
+ export type ProfileResources = {
27
+ /** The names of the enabled profiles. */
28
+ names: string[];
29
+ /** The `skills` directories of the enabled profiles that have one. */
30
+ skillPaths: string[];
31
+ /** The `AGENTS.md` files of the enabled profiles, with their content. */
32
+ agentsFiles: Array<{ path: string; content: string }>;
33
+ /** The absolute paths of the packages the enabled profiles declare. */
34
+ packages: string[];
35
+ };
36
+
37
+ /** Resource paths exposed by a profile's declared packages. */
38
+ export type PackageResourcePaths = {
39
+ /** The paths of the skills. */
40
+ skillPaths: string[];
41
+ /** The paths of the prompt templates. */
42
+ promptPaths: string[];
43
+ /** The paths of the themes. */
44
+ themePaths: string[];
45
+ };
46
+
47
+ /** Options for {@link loadProfileExtensions}. */
48
+ export type LoadProfileExtensionsOptions = ProfileLibOptions & {
49
+ /**
50
+ * Resolved package paths (from {@link ProfileResources.packages}) to load extensions
51
+ * from.
52
+ */
53
+ packages: string[];
54
+ /**
55
+ * The in-flight `session_start` event, replayed for each dynamically loaded extension's
56
+ * own `session_start` handler(s). Required because pi snapshots registered handlers
57
+ * before dispatching an event, so a handler registered here (after that snapshot, since
58
+ * this function itself runs from inside a `session_start` handler) would otherwise
59
+ * never run for the very event that triggered this load.
60
+ */
61
+ sessionStartEvent: SessionStartEvent;
62
+ /** Extension context for the in-flight `session_start`, passed to replayed handlers. */
63
+ ctx: ExtensionContext;
64
+ };
65
+
66
+ /** Options for {@link resolveProfilePackageResources}. */
67
+ export type ResolveProfilePackageResourcesOptions = ProfileLibOptions & {
68
+ /**
69
+ * Resolved package paths (from {@link ProfileResources.packages}) to resolve resources
70
+ * from.
71
+ */
72
+ packages: string[];
73
+ };
74
+
75
+ /** Mutable state shared by the entry and the lazily loaded handlers. */
76
+ export type ProfilesState = {
77
+ /** Names of the profiles discovered for the current session. */
78
+ sessionProfiles: string[];
79
+ };
80
+
81
+ /**
82
+ * What the lazy handlers receive from the entry.
83
+ *
84
+ * This module is imported eagerly at startup: it must not import anything heavy.
85
+ */
86
+ export type ProfilesRuntime = {
87
+ /** The Pi extension API. */
88
+ pi: ExtensionAPI;
89
+ /** The state shared by the handlers. */
90
+ state: ProfilesState;
91
+ };
92
+
93
+ declare module '@homer0/pi-config' {
94
+ interface PiConfig {
95
+ profiles?: ProfilesConfig;
96
+ }
97
+ }