pi-agents-switch 0.2.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/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "pi-agents-switch",
3
+ "version": "0.2.0",
4
+ "description": "Tab to switch primary agents in Pi — like OpenCode's agent switching. Each agent gets an isolated profile with its own AGENTS.md, extensions, skills, and settings.",
5
+ "type": "module",
6
+ "pi": {
7
+ "extensions": [
8
+ "./index.ts"
9
+ ]
10
+ },
11
+ "dependencies": {
12
+ "@earendil-works/pi-coding-agent": "*",
13
+ "@earendil-works/pi-tui": "*"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^25.9.3",
17
+ "typescript": "^6.0.3"
18
+ },
19
+ "files": [
20
+ "index.ts",
21
+ "types.ts",
22
+ "frontmatter.ts",
23
+ "profile-manager.ts",
24
+ "README.md"
25
+ ]
26
+ }
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Profile manager for pi-agents-switch.
3
+ *
4
+ * Each agent is defined by a single AGENTS.md file with YAML frontmatter.
5
+ * No more separate agents.json — metadata lives in the frontmatter,
6
+ * the system prompt lives in the markdown body.
7
+ *
8
+ * Profile dir layout:
9
+ * ~/.pi/agents/<name>/ ← user-level agent
10
+ * ├── AGENTS.md ← YAML frontmatter + system prompt
11
+ * ├── extensions/ ← agent-specific extensions
12
+ * ├── skills/ ← agent-specific skills
13
+ * └── prompts/ ← agent-specific prompt templates
14
+ *
15
+ * <cwd>/.pi/agents/<name>/ ← project-level agent (overrides user)
16
+ * └── AGENTS.md
17
+ *
18
+ * Fallback chain for agent config:
19
+ * 1. Agent's own ~/.pi/agents/<name>/AGENTS.md
20
+ * 2. Project's <cwd>/.pi/agents/<name>/AGENTS.md
21
+ * 3. PI defaults (from running pi environment)
22
+ *
23
+ * Last-write-wins: when the same item appears in both tools and
24
+ * excluded_tools (or extensions/excluded_extensions, skills/excluded_skills),
25
+ * whichever YAML key was declared LAST in the file wins.
26
+ * `"*"` in any exclude list removes everything except items explicitly added.
27
+ */
28
+
29
+ import {
30
+ existsSync,
31
+ mkdirSync,
32
+ readFileSync,
33
+ writeFileSync,
34
+ readdirSync,
35
+ statSync,
36
+ rmSync,
37
+ } from "node:fs";
38
+ import { join } from "node:path";
39
+ import { homedir } from "node:os";
40
+ import { parseFrontmatter, serializeFrontmatter } from "./frontmatter";
41
+ import type {
42
+ AgentDiskProfile,
43
+ AgentFrontmatter,
44
+ AgentsConfig,
45
+ FrontmatterResult,
46
+ ResolvedAgentConfig,
47
+ } from "./types";
48
+
49
+ const NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
50
+ const MAX_NAME_LENGTH = 32;
51
+
52
+ /**
53
+ * Information about the running PI agent, used for inheritance.
54
+ * Passed from the extension at resolution time.
55
+ */
56
+ export interface PIState {
57
+ /** Active tool names in PI */
58
+ tools: string[];
59
+ /** Active extension names in PI */
60
+ extensions: string[];
61
+ /** Active skill names in PI */
62
+ skills: string[];
63
+ }
64
+
65
+ export class ProfileManager {
66
+ readonly userAgentsDir: string;
67
+ readonly configPath: string;
68
+ readonly piAgentDir: string;
69
+
70
+ constructor(private cwd?: string) {
71
+ const piRoot = join(homedir(), ".pi");
72
+ this.userAgentsDir = join(piRoot, "agents");
73
+ this.configPath = join(piRoot, "agent", "agents-switch.json");
74
+ this.piAgentDir = join(piRoot, "agent");
75
+ }
76
+
77
+ /** Get the project-level agents directory (if cwd is known) */
78
+ get projectAgentsDir(): string | undefined {
79
+ return this.cwd ? join(this.cwd, ".pi", "agents") : undefined;
80
+ }
81
+
82
+ agentPath(name: string): string {
83
+ return join(this.userAgentsDir, name);
84
+ }
85
+
86
+ // ─── Config read/write (simplified) ───────────────────
87
+
88
+ loadConfig(): AgentsConfig {
89
+ if (!existsSync(this.configPath)) {
90
+ return { version: 1 };
91
+ }
92
+
93
+ try {
94
+ const raw = readFileSync(this.configPath, "utf8");
95
+ const cfg = JSON.parse(raw) as AgentsConfig;
96
+ if (cfg.version !== 1) throw new Error("Unsupported config version");
97
+ return cfg;
98
+ } catch (err) {
99
+ const corruptPath = this.configPath + ".corrupted." + Date.now();
100
+ try {
101
+ const raw = readFileSync(this.configPath, "utf8");
102
+ writeFileSync(corruptPath, raw);
103
+ console.error(
104
+ `[agents-switch] Config file corrupted, backed up to ${corruptPath}. Starting with defaults.`,
105
+ );
106
+ } catch {
107
+ console.error(
108
+ `[agents-switch] Config file unreadable. Starting with defaults.`,
109
+ );
110
+ }
111
+ return { version: 1 };
112
+ }
113
+ }
114
+
115
+ private ensureConfigDir(): void {
116
+ const dir = join(homedir(), ".pi", "agent");
117
+ if (!existsSync(dir)) {
118
+ mkdirSync(dir, { recursive: true });
119
+ }
120
+ }
121
+
122
+ saveConfig(config: AgentsConfig): void {
123
+ this.ensureConfigDir();
124
+ writeFileSync(this.configPath, JSON.stringify(config, null, 2) + "\n");
125
+ }
126
+
127
+ // ─── Agent discovery ─────────────────────────────────
128
+
129
+ /** List agent directories on disk (user + project). */
130
+ list(): AgentDiskProfile[] {
131
+ const results: AgentDiskProfile[] = [];
132
+
133
+ // User-level agents
134
+ if (existsSync(this.userAgentsDir)) {
135
+ for (const entry of readdirSync(this.userAgentsDir)) {
136
+ const path = join(this.userAgentsDir, entry);
137
+ try {
138
+ if (!statSync(path).isDirectory()) continue;
139
+ } catch {
140
+ continue;
141
+ }
142
+ results.push(this.buildDiskProfile(entry, path, "user"));
143
+ }
144
+ }
145
+
146
+ // Project-level agents
147
+ const projectDir = this.projectAgentsDir;
148
+ if (projectDir && existsSync(projectDir)) {
149
+ for (const entry of readdirSync(projectDir)) {
150
+ const path = join(projectDir, entry);
151
+ try {
152
+ if (!statSync(path).isDirectory()) continue;
153
+ } catch {
154
+ continue;
155
+ }
156
+ // Don't duplicate — project overrides user
157
+ const existing = results.find((r) => r.name === entry);
158
+ if (existing) {
159
+ existing.path = path;
160
+ existing.hasAgentsMd = existsSync(join(path, "AGENTS.md"));
161
+ existing.hasExtensions = existsSync(join(path, "extensions"));
162
+ existing.hasSkills = existsSync(join(path, "skills"));
163
+ existing.source = "project";
164
+ } else {
165
+ results.push(this.buildDiskProfile(entry, path, "project"));
166
+ }
167
+ }
168
+ }
169
+
170
+ return results.sort((a, b) => a.name.localeCompare(b.name));
171
+ }
172
+
173
+ private buildDiskProfile(
174
+ name: string,
175
+ path: string,
176
+ source: "user" | "project",
177
+ ): AgentDiskProfile {
178
+ return {
179
+ name,
180
+ path,
181
+ hasAgentsMd: existsSync(join(path, "AGENTS.md")),
182
+ hasExtensions: existsSync(join(path, "extensions")),
183
+ hasSkills: existsSync(join(path, "skills")),
184
+ source,
185
+ };
186
+ }
187
+
188
+ /** Get agent names from config (legacy) and from disk. */
189
+ getAgentNames(): string[] {
190
+ const agents = this.list();
191
+ return agents.map((a) => a.name).sort();
192
+ }
193
+
194
+ /** Check if an agent exists on disk. */
195
+ exists(name: string): boolean {
196
+ if (existsSync(this.agentPath(name))) return true;
197
+ const projectDir = this.projectAgentsDir;
198
+ if (projectDir && existsSync(join(projectDir, name))) return true;
199
+ return false;
200
+ }
201
+
202
+ // ─── Agent resolution (fallback chain) ───────────────
203
+
204
+ /**
205
+ * Resolve an agent's configuration through the fallback chain:
206
+ * 1. Agent's own AGENTS.md
207
+ * 2. Project AGENTS.md (if cwd is known)
208
+ * 3. PI defaults
209
+ *
210
+ * Returns undefined if the agent doesn't exist anywhere.
211
+ */
212
+ resolveAgent(
213
+ name: string,
214
+ piState?: PIState,
215
+ cwd?: string,
216
+ ): ResolvedAgentConfig | undefined {
217
+ const effectiveCwd = cwd ?? this.cwd;
218
+
219
+ // Try agent-level first
220
+ const agentResult = this.readAgentFrontmatter(name);
221
+ // Try project-level
222
+ let projectResult: FrontmatterResult | undefined;
223
+ if (effectiveCwd) {
224
+ const projectPath = join(
225
+ effectiveCwd,
226
+ ".pi",
227
+ "agents",
228
+ name,
229
+ "AGENTS.md",
230
+ );
231
+ if (existsSync(projectPath)) {
232
+ projectResult = this.readFrontmatterFrom(projectPath);
233
+ }
234
+ }
235
+
236
+ // If neither exists, agent doesn't exist
237
+ if (!agentResult && !projectResult) return undefined;
238
+
239
+ // Merge: project overrides agent
240
+ const fm: AgentFrontmatter = {
241
+ ...(agentResult?.frontmatter ?? {}),
242
+ ...(projectResult?.frontmatter ?? {}),
243
+ };
244
+
245
+ // Merge keyOrder: user first, project second (project wins in lastIndexOf)
246
+ const keyOrder = [
247
+ ...(agentResult?.keyOrder ?? []),
248
+ ...(projectResult?.keyOrder ?? []),
249
+ ];
250
+
251
+ // Build resolved config
252
+ return this.buildResolvedConfig(
253
+ name,
254
+ fm,
255
+ keyOrder,
256
+ piState,
257
+ agentResult ? this.agentPath(name) : undefined,
258
+ projectResult ? join(effectiveCwd!, ".pi", "agents", name) : undefined,
259
+ );
260
+ }
261
+
262
+ /**
263
+ * Read the frontmatter from an agent's AGENTS.md.
264
+ */
265
+ private readAgentFrontmatter(name: string): FrontmatterResult | undefined {
266
+ const path = join(this.agentPath(name), "AGENTS.md");
267
+ return this.readFrontmatterFrom(path);
268
+ }
269
+
270
+ /**
271
+ * Read frontmatter from a specific AGENTS.md file path.
272
+ * Returns undefined if the file doesn't exist or can't be parsed.
273
+ */
274
+ private readFrontmatterFrom(mdPath: string): FrontmatterResult | undefined {
275
+ if (!existsSync(mdPath)) return undefined;
276
+ try {
277
+ const content = readFileSync(mdPath, "utf8");
278
+ return parseFrontmatter(content);
279
+ } catch {
280
+ return undefined;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Read the full AGENTS.md content (including system prompt body).
286
+ * Follows the fallback chain: agent → project → undefined.
287
+ */
288
+ readAgentsMd(name: string, cwd?: string): string | undefined {
289
+ const effectiveCwd = cwd ?? this.cwd;
290
+
291
+ // Try agent level
292
+ const agentPath = join(this.agentPath(name), "AGENTS.md");
293
+ if (existsSync(agentPath)) {
294
+ try {
295
+ return readFileSync(agentPath, "utf8");
296
+ } catch {
297
+ // fall through
298
+ }
299
+ }
300
+
301
+ // Try project level
302
+ if (effectiveCwd) {
303
+ const projectPath = join(
304
+ effectiveCwd,
305
+ ".pi",
306
+ "agents",
307
+ name,
308
+ "AGENTS.md",
309
+ );
310
+ if (existsSync(projectPath)) {
311
+ try {
312
+ return readFileSync(projectPath, "utf8");
313
+ } catch {
314
+ // fall through
315
+ }
316
+ }
317
+ }
318
+
319
+ return undefined;
320
+ }
321
+
322
+ /**
323
+ * Get only the system prompt body (no frontmatter) for an agent.
324
+ */
325
+ getSystemPrompt(name: string, cwd?: string): string | undefined {
326
+ const md = this.readAgentsMd(name, cwd);
327
+ if (!md) return undefined;
328
+ return parseFrontmatter(md).body;
329
+ }
330
+
331
+ // ─── Resolution helpers ──────────────────────────────
332
+
333
+ /**
334
+ * Build a ResolvedAgentConfig from parsed frontmatter + PI state.
335
+ */
336
+ private buildResolvedConfig(
337
+ name: string,
338
+ fm: AgentFrontmatter,
339
+ keyOrder: string[],
340
+ piState?: PIState,
341
+ agentPath?: string,
342
+ projectPath?: string,
343
+ ): ResolvedAgentConfig {
344
+ const pi = piState ?? { tools: [], extensions: [], skills: [] };
345
+
346
+ // Apply inheritance: start with PI's → respect last-write-wins
347
+ const tools = this.resolveList(
348
+ pi.tools,
349
+ fm.excluded_tools,
350
+ fm.tools,
351
+ "tools",
352
+ "excluded_tools",
353
+ keyOrder,
354
+ );
355
+
356
+ // Extensions: prefer excluded_extensions, fall back to deprecated noextensions
357
+ const extExclude = fm.excluded_extensions ?? fm.noextensions;
358
+ const extExcludeKey =
359
+ fm.excluded_extensions !== undefined
360
+ ? "excluded_extensions"
361
+ : fm.noextensions !== undefined
362
+ ? "noextensions"
363
+ : undefined;
364
+ const extensionPaths = extExcludeKey
365
+ ? this.resolveList(
366
+ pi.extensions,
367
+ extExclude,
368
+ fm.extensions,
369
+ "extensions",
370
+ extExcludeKey,
371
+ keyOrder,
372
+ )
373
+ : [...pi.extensions, ...(fm.extensions ?? [])];
374
+
375
+ // Skills: prefer excluded_skills, fall back to deprecated noskills
376
+ const skillExclude = fm.excluded_skills ?? fm.noskills;
377
+ const skillExcludeKey =
378
+ fm.excluded_skills !== undefined
379
+ ? "excluded_skills"
380
+ : fm.noskills !== undefined
381
+ ? "noskills"
382
+ : undefined;
383
+ const skillPaths = skillExcludeKey
384
+ ? this.resolveList(
385
+ pi.skills,
386
+ skillExclude,
387
+ fm.skills,
388
+ "skills",
389
+ skillExcludeKey,
390
+ keyOrder,
391
+ )
392
+ : [...pi.skills, ...(fm.skills ?? [])];
393
+
394
+ // Add agent-specific folders
395
+ const agentExtensions: string[] = [];
396
+ const agentSkills: string[] = [];
397
+
398
+ const effectiveAgentDir = projectPath ?? agentPath;
399
+ if (effectiveAgentDir) {
400
+ const extDir = join(effectiveAgentDir, "extensions");
401
+ if (existsSync(extDir)) {
402
+ agentExtensions.push(extDir);
403
+ }
404
+ const skillDir = join(effectiveAgentDir, "skills");
405
+ if (existsSync(skillDir)) {
406
+ agentSkills.push(skillDir);
407
+ }
408
+ }
409
+
410
+ const sourcePath = projectPath ?? agentPath ?? "PI (default)";
411
+
412
+ const modelStr =
413
+ fm.provider && fm.model ? `${fm.provider}/${fm.model}` : undefined;
414
+
415
+ return {
416
+ name,
417
+ label: fm.name ?? name,
418
+ description: fm.description ?? "",
419
+ model: modelStr,
420
+ thinkingLevel: fm.thinkingLevel,
421
+ temperature: fm.temperature,
422
+ topP: fm.topP,
423
+ systemPrompt: "", // filled in later by caller
424
+ tools,
425
+ extensionPaths: [...extensionPaths, ...agentExtensions],
426
+ skillPaths: [...skillPaths, ...agentSkills],
427
+ sourcePath,
428
+ exists: true,
429
+ };
430
+ }
431
+
432
+ /**
433
+ * Resolve a list with inheritance, wildcard support, and last-write-wins.
434
+ *
435
+ * - `"*"` in the remove list removes ALL items from the base.
436
+ * - When the same item is in both add and remove, whichever YAML key
437
+ * was declared LAST wins (determined by `keyOrder`).
438
+ *
439
+ * @param base Items inherited from PI
440
+ * @param remove Items to remove (supports "*" wildcard)
441
+ * @param add Items to add
442
+ * @param addKey The YAML key name for the add list (e.g. "tools")
443
+ * @param removeKey The YAML key name for the remove list (e.g. "excluded_tools")
444
+ * @param keyOrder Ordered list of all YAML keys as they appear in the file
445
+ */
446
+ private resolveList(
447
+ base: string[],
448
+ remove: string[] | undefined,
449
+ add: string[] | undefined,
450
+ addKey: string,
451
+ removeKey: string,
452
+ keyOrder: string[],
453
+ ): string[] {
454
+ const removeSet = new Set(remove ?? []);
455
+ const addSet = new Set(add ?? []);
456
+ const wildcardRemove = removeSet.has("*");
457
+
458
+ // Last-write-wins: which key was declared later?
459
+ const addIdx = keyOrder.lastIndexOf(addKey);
460
+ const removeIdx = keyOrder.lastIndexOf(removeKey);
461
+ const addWins = addIdx >= 0 && (removeIdx < 0 || addIdx > removeIdx);
462
+
463
+ const result = base.filter((item) => {
464
+ const inRemove = removeSet.has(item);
465
+ const inAdd = addSet.has(item);
466
+
467
+ if (wildcardRemove) {
468
+ // "*" wildcard — removes everything unless explicitly added
469
+ if (inAdd) {
470
+ // Item is in both — last-write-wins decides
471
+ return addWins;
472
+ }
473
+ return false; // Not in add → wildcard removes it
474
+ }
475
+
476
+ if (inRemove) {
477
+ if (inAdd) {
478
+ // Item in both — last-write-wins decides
479
+ return addWins;
480
+ }
481
+ return false; // Only in remove
482
+ }
483
+
484
+ return true; // Not in remove → keep from PI base
485
+ });
486
+
487
+ // Add items from addSet that aren't already present
488
+ for (const item of addSet) {
489
+ if (!result.includes(item)) {
490
+ result.push(item);
491
+ }
492
+ }
493
+
494
+ return result;
495
+ }
496
+
497
+ // ─── Agent creation / deletion ───────────────────────
498
+
499
+ validateName(name: string): void {
500
+ if (!name || name.length > MAX_NAME_LENGTH || !NAME_REGEX.test(name)) {
501
+ throw new Error(
502
+ `Invalid agent name "${name}". Must match ${NAME_REGEX} and be ≤ ${MAX_NAME_LENGTH} chars.`,
503
+ );
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Create a new agent profile directory with an AGENTS.md scaffold.
509
+ */
510
+ create(
511
+ name: string,
512
+ frontmatter: AgentFrontmatter,
513
+ systemPrompt?: string,
514
+ ): void {
515
+ this.validateName(name);
516
+ const path = this.agentPath(name);
517
+ if (existsSync(path)) {
518
+ throw new Error(`Agent "${name}" already exists at ${path}`);
519
+ }
520
+
521
+ // Build directory structure
522
+ try {
523
+ mkdirSync(path, { recursive: true });
524
+ mkdirSync(join(path, "extensions"));
525
+ mkdirSync(join(path, "skills"));
526
+ mkdirSync(join(path, "prompts"));
527
+ } catch (err) {
528
+ try {
529
+ rmSync(path, { recursive: true, force: true });
530
+ } catch {
531
+ // Best effort cleanup
532
+ }
533
+ throw new Error(
534
+ `Failed to create agent directory for "${name}": ${(err as Error).message}`,
535
+ );
536
+ }
537
+
538
+ // Write AGENTS.md with frontmatter + body
539
+ const fmWithDefaults: AgentFrontmatter = {
540
+ name: frontmatter.name ?? name,
541
+ description: frontmatter.description ?? "Custom agent profile",
542
+ ...frontmatter,
543
+ };
544
+
545
+ const defaultBody =
546
+ systemPrompt ??
547
+ `# ${fmWithDefaults.name}
548
+
549
+ ${fmWithDefaults.description}
550
+
551
+ You are the **${fmWithDefaults.name}** agent. Your role and specific instructions go here.
552
+ Edit this file to customize your behavior.
553
+
554
+ `;
555
+
556
+ const content = serializeFrontmatter(fmWithDefaults) + "\n" + defaultBody;
557
+ writeFileSync(join(path, "AGENTS.md"), content);
558
+ }
559
+
560
+ /**
561
+ * Delete an agent profile directory.
562
+ */
563
+ delete(name: string): void {
564
+ const path = this.agentPath(name);
565
+ if (!existsSync(path)) {
566
+ throw new Error(`Agent "${name}" does not exist`);
567
+ }
568
+
569
+ rmSync(path, { recursive: true, force: true });
570
+
571
+ const config = this.loadConfig();
572
+ if (config.active === name) {
573
+ delete config.active;
574
+ }
575
+ this.saveConfig(config);
576
+ }
577
+
578
+ // ─── Active agent ────────────────────────────────────
579
+
580
+ /**
581
+ * Get the currently active agent name.
582
+ * Falls back to "PI" if nothing is set or the configured agent no longer exists.
583
+ */
584
+ getActive(config: AgentsConfig): string {
585
+ if (config.active && this.exists(config.active)) {
586
+ return config.active;
587
+ }
588
+ return "PI";
589
+ }
590
+ }