@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/utils.ts ADDED
@@ -0,0 +1,253 @@
1
+ import {
2
+ CONFIG_DIR_NAME,
3
+ getAgentDir,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ } from '@earendil-works/pi-coding-agent';
7
+ import { getConfigValue } from '@homer0/pi-config';
8
+ import { execFile } from 'node:child_process';
9
+ import { existsSync } from 'node:fs';
10
+ import { createRequire } from 'node:module';
11
+ import { homedir } from 'node:os';
12
+ import { extname, isAbsolute, join, resolve } from 'node:path';
13
+ import { promisify } from 'node:util';
14
+ import { z } from 'zod';
15
+ import { profilesConfigSchema } from './schemas.js';
16
+ import type { ProfileLibOptions } from './types.js';
17
+
18
+ /**
19
+ * Resolves a path relative to the Pi agent directory.
20
+ *
21
+ * @param segments The segments of the path, relative to the agent directory.
22
+ * @returns The absolute path.
23
+ */
24
+ export const getAgentPath = (...segments: string[]): string =>
25
+ join(getAgentDir(), ...segments);
26
+
27
+ /**
28
+ * Resolves the absolute path to the jiti binary. In a pnpm workspace, the binary is
29
+ * always at `<workspace-root>/node_modules/.bin/jiti`, derived by splitting the resolved
30
+ * jiti package path on `/node_modules/`. Falls back to `npx jiti` when the package cannot
31
+ * be resolved.
32
+ *
33
+ * @returns The command that runs jiti.
34
+ */
35
+ export const resolveJitiBin = (): string => {
36
+ try {
37
+ const requireFromHere = createRequire(import.meta.url);
38
+ const pkgPath = requireFromHere.resolve('jiti/package.json');
39
+ const workspaceRoot = pkgPath.split('/node_modules/')[0];
40
+ if (workspaceRoot) return join(workspaceRoot, 'node_modules/.bin/jiti');
41
+ // pnpm detected but workspace root could not be derived — use pnpm exec as a safe fallback
42
+ if (pkgPath.includes('.pnpm')) return 'pnpm exec jiti';
43
+ } catch {
44
+ // fall through to npx fallback
45
+ }
46
+ return 'npx jiti';
47
+ };
48
+
49
+ /**
50
+ * The resolved jiti binary for this workspace. Computed once at module load so callers
51
+ * can use it directly in shell commands or spawn arguments.
52
+ */
53
+ export const JITI_BIN = resolveJitiBin();
54
+
55
+ /**
56
+ * Returns the error message string from any thrown value. Prettifies zod validation
57
+ * errors instead of surfacing their raw JSON `.message`.
58
+ *
59
+ * @param error The thrown value.
60
+ * @returns The message of the error, or the value as a string if it's not an error.
61
+ */
62
+ export const ensureErrorMessage = (error: unknown): string => {
63
+ if (error instanceof z.ZodError) {
64
+ return error.issues.map((issue) => issue.message).join('; ');
65
+ }
66
+ return error instanceof Error ? error.message : String(error);
67
+ };
68
+
69
+ /** Options for {@link showAnnouncementWidget}. */
70
+ type AnnouncementWidgetOptions = {
71
+ /** The Pi extension API, to listen for the user input. */
72
+ pi: ExtensionAPI;
73
+ /** The context of the session. */
74
+ ctx: ExtensionContext;
75
+ /** The key that identifies the widget. */
76
+ key: string;
77
+ /** The label of the announcement. */
78
+ label: string;
79
+ /** The value of the announcement. */
80
+ value: string;
81
+ };
82
+
83
+ /**
84
+ * Shows a labelled announcement widget above the editor and removes it on the first user
85
+ * input. Intended for session-start status messages that should not persist or appear in
86
+ * LLM context.
87
+ *
88
+ * @param options What to show, and where.
89
+ */
90
+ export const showAnnouncementWidget = (options: AnnouncementWidgetOptions): void => {
91
+ const { pi, ctx, key, label, value } = options;
92
+ ctx.ui.setWidget(key, (_tui, theme) => ({
93
+ render: () => [
94
+ theme.fg('customMessageLabel', `${label}: `) + theme.fg('customMessageText', value),
95
+ ],
96
+ invalidate: () => {},
97
+ dispose: () => {},
98
+ }));
99
+
100
+ pi.on('input', (_event, inputCtx) => {
101
+ inputCtx.ui.setWidget(key, undefined);
102
+ });
103
+ };
104
+
105
+ /**
106
+ * Creates a function that outputs a message for one subsystem. When there is a UI, it
107
+ * notifies through it; otherwise, it logs to stdout, with the subsystem as the prefix.
108
+ *
109
+ * @param subsystem The name that identifies the messages.
110
+ * @returns The function that outputs the messages.
111
+ */
112
+ export const createOutputMessage =
113
+ (subsystem: string) =>
114
+ (ctx: ExtensionContext, message: string): void => {
115
+ if (ctx.hasUI) {
116
+ ctx.ui.notify(message, 'info');
117
+ return;
118
+ }
119
+
120
+ // eslint-disable-next-line no-console -- Non-interactive sessions still need visible extension output.
121
+ console.info(`[${subsystem}] ${message}`);
122
+ };
123
+
124
+ const CONFIG_PATH = 'pi.profiles';
125
+ const HOME_DIR = homedir();
126
+ const REPO_DIR = join(HOME_DIR, CONFIG_DIR_NAME);
127
+ const AGENT_DIR = getAgentDir();
128
+ const execFileAsync = promisify(execFile);
129
+
130
+ /**
131
+ * Reports a non-fatal profile-resolution issue without an active UI session.
132
+ *
133
+ * @param message The description of the issue.
134
+ * @param error The error behind the issue, if there is one.
135
+ */
136
+ export const warnProfilesIssue = (message: string, error?: unknown): void => {
137
+ if (typeof error === 'undefined') {
138
+ // eslint-disable-next-line no-console -- Profile resolution issues should be visible without an active Pi session.
139
+ console.warn(`[pi-profiles] ${message}`);
140
+ } else {
141
+ // eslint-disable-next-line no-console -- Profile resolution issues should be visible without an active Pi session.
142
+ console.warn(`[pi-profiles] ${message}`, error);
143
+ }
144
+ };
145
+
146
+ /**
147
+ * Resolves a script path against the cwd, the agent directory, and the Pi directory, in
148
+ * that order. An absolute path is only checked for existence.
149
+ *
150
+ * @param scriptPath The path of the script.
151
+ * @param cwd The directory to resolve relative paths against first.
152
+ * @returns The path of the script, or `undefined` if it doesn't exist anywhere.
153
+ */
154
+ export const resolveScriptPath = (
155
+ scriptPath: string,
156
+ cwd: string,
157
+ ): string | undefined => {
158
+ if (isAbsolute(scriptPath)) return existsSync(scriptPath) ? scriptPath : undefined;
159
+ for (const base of [cwd, AGENT_DIR, REPO_DIR]) {
160
+ const candidate = resolve(base, scriptPath);
161
+ if (existsSync(candidate)) return candidate;
162
+ }
163
+ return undefined;
164
+ };
165
+
166
+ /**
167
+ * Runs a script from the Pi directory: `.sh` with bash, `.ts` with jiti, and anything
168
+ * else with node. If it fails, it warns and counts as `false`.
169
+ *
170
+ * @param scriptPath The absolute path of the script.
171
+ * @returns Whether the script printed `true`.
172
+ */
173
+ export const runScript = async (scriptPath: string): Promise<boolean> => {
174
+ const ext = extname(scriptPath);
175
+ let args: string[];
176
+ if (ext === '.sh') {
177
+ args = [scriptPath];
178
+ } else if (ext === '.ts') {
179
+ args = ['-c', `${JITI_BIN} "${scriptPath}"`];
180
+ } else {
181
+ args = ['-c', `node "${scriptPath}"`];
182
+ }
183
+ try {
184
+ const { stdout } = await execFileAsync('bash', args, { cwd: REPO_DIR });
185
+ return stdout.trim() === 'true';
186
+ } catch (error) {
187
+ warnProfilesIssue(`Script failed: ${scriptPath}`, error);
188
+ return false;
189
+ }
190
+ };
191
+
192
+ /**
193
+ * Runs the `enabledIf` scripts and collects the profiles whose script printed `true`.
194
+ * Entries set to `false`, and scripts that can't be found, are skipped.
195
+ *
196
+ * @param enabledIf The `enabledIf` entries, from profile name to script path.
197
+ * @param cwd The directory to resolve relative script paths against first.
198
+ * @returns The names of the profiles that passed.
199
+ */
200
+ export const evaluateEnabledIf = async (
201
+ enabledIf: Record<string, false | string>,
202
+ cwd: string,
203
+ ): Promise<string[]> => {
204
+ const results: string[] = [];
205
+ for (const [name, scriptPath] of Object.entries(enabledIf)) {
206
+ if (scriptPath && scriptPath !== 'false') {
207
+ const resolved = resolveScriptPath(scriptPath, cwd);
208
+ if (typeof resolved === 'undefined') {
209
+ warnProfilesIssue(
210
+ `Could not resolve enabledIf script for "${name}": ${scriptPath}`,
211
+ );
212
+ } else {
213
+ const passed = await runScript(resolved);
214
+ if (passed) results.push(name);
215
+ }
216
+ }
217
+ }
218
+ return results;
219
+ };
220
+
221
+ /**
222
+ * Resolves the names of the enabled profiles, from the `enabled` list and the `enabledIf`
223
+ * scripts of the `pi.profiles` config. If the config is invalid, it reports it through
224
+ * `onError` and no profile is enabled.
225
+ *
226
+ * This is the single source of truth for which profiles are active: the extension and any
227
+ * other extension that needs to know them call this same function.
228
+ *
229
+ * @param options The directory to resolve from, and where to report non-fatal issues.
230
+ * @returns The names of the enabled profiles, without duplicates.
231
+ */
232
+ export const resolveEnabledProfiles = async (
233
+ options: ProfileLibOptions = {},
234
+ ): Promise<string[]> => {
235
+ const cwd = options.cwd ?? process.cwd();
236
+ const onError = options.onError ?? ((message: string) => warnProfilesIssue(message));
237
+
238
+ try {
239
+ const config = await getConfigValue(CONFIG_PATH, {
240
+ cwd,
241
+ schema: profilesConfigSchema,
242
+ });
243
+ if (!config) return [];
244
+ const { enabled, enabledIf } = config;
245
+ const conditional = await evaluateEnabledIf(enabledIf, cwd);
246
+ return Array.from(new Set([...enabled, ...conditional]));
247
+ } catch (error) {
248
+ onError(
249
+ `Invalid ${CONFIG_PATH} config; profiles skipped. ${ensureErrorMessage(error)}`,
250
+ );
251
+ return [];
252
+ }
253
+ };