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/README.md +186 -0
- package/frontmatter.ts +172 -0
- package/index.ts +760 -0
- package/package.json +26 -0
- package/profile-manager.ts +590 -0
- package/types.ts +151 -0
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
|
+
}
|