pi-better-subagents 0.1.30 → 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.
@@ -0,0 +1,711 @@
1
+ /**
2
+ * `/agents` list, show/inspect, create, reload, and import-codex.
3
+ *
4
+ * Lifecycle registers `registerAgentCommands`. This module does not edit the
5
+ * extension entry and does not launch children. Import and ambiguous choices
6
+ * write nothing unless the required confirmation is actually collected.
7
+ * Personal scope is the default. Project scope is explicit and trusted-only.
8
+ */
9
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from "node:fs";
10
+ import { isAbsolute, resolve } from "node:path";
11
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import { presentCatalog, presentCatalogEntry, type LaunchEnricher, type OperationView } from "./agent-inspection.ts";
13
+ import {
14
+ CATALOG_SCHEMA_VERSION,
15
+ DiagnosticCodes,
16
+ EFFORT_LEVELS,
17
+ parseDefinition,
18
+ type AgentDefinition,
19
+ type Diagnostic,
20
+ type InstructionMode,
21
+ type PortablePreferences,
22
+ type ThinkingLevel,
23
+ } from "./catalog-schema.ts";
24
+ import {
25
+ createDefinition,
26
+ defaultUserRoot,
27
+ loadCatalog,
28
+ projectCatalogPaths,
29
+ refreshCatalog,
30
+ saveDefinition,
31
+ userCatalogPaths,
32
+ type CatalogSnapshot,
33
+ } from "./catalog-store.ts";
34
+ import { listCatalog } from "./catalog-resolver.ts";
35
+ import {
36
+ CODEX_FORMAT,
37
+ PI_VS_CODEX_PRECEDENCE,
38
+ agentIdFromDisplayName,
39
+ buildImportedAgent,
40
+ codexSourceRef,
41
+ parseCodexSource,
42
+ renderReimportPreview,
43
+ suggestBaseRoles,
44
+ type CodexDocument,
45
+ } from "./codex-import.ts";
46
+ import { resolveRoleAssignment } from "./role-assignment.ts";
47
+
48
+ const MODEL_PATTERN = /^[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._+-]*$/;
49
+ const TIER_PATTERN = /^[a-z][a-z0-9-]{0,32}$/;
50
+ const KNOWN_FLAGS = new Set(["role", "scope", "name", "id", "mode", "description", "instructions", "model", "effort", "tier", "help"]);
51
+ const USAGE = [
52
+ "/agents list",
53
+ "/agents show <id> (inspect is the same command)",
54
+ "/agents create [--role <role-id>] [--name <name>] [--scope user|project] [--mode add|replace] [--instructions <text>] [--model <provider/model>] [--effort <level>] [--tier <label>]",
55
+ "/agents reload",
56
+ "/agents import-codex <file.toml> [--scope user|project] [--role <role-id>]",
57
+ "Create and import write to the personal catalog unless --scope project is set.",
58
+ "Import confirms one base role and replace mode. It does not scan .codex/agents.",
59
+ ].join("\n");
60
+
61
+ export interface AgentCommandDeps {
62
+ userRoot?: string;
63
+ bundledRoot?: string;
64
+ projectConfigDirName?: string;
65
+ now?: () => string;
66
+ /** Resolution unit injects availability, actual model/effort, and launchability. */
67
+ enrich?: LaunchEnricher;
68
+ /**
69
+ * Live slash-command context, before list/inspect/create/reload/import.
70
+ * Lifecycle refreshes the foreground model and settings here. Direct
71
+ * `executeAgentsCommand` callers do not have that context.
72
+ */
73
+ propagateCommandContext?: (ctx: ExtensionCommandContext) => void;
74
+ }
75
+
76
+ export interface AgentCommandUi {
77
+ select(title: string, options: string[]): Promise<string | undefined>;
78
+ confirm(title: string, message: string): Promise<boolean>;
79
+ input(title: string, placeholder?: string): Promise<string | undefined>;
80
+ editor(title: string, prefill?: string): Promise<string | undefined>;
81
+ notify(message: string, type?: "info" | "warning" | "error"): void;
82
+ setWidget?(key: string, content: string[] | undefined): void;
83
+ }
84
+
85
+ export interface AgentCommandHost {
86
+ cwd: string;
87
+ hasUI: boolean;
88
+ mode?: "tui" | "rpc" | "json" | "print";
89
+ isProjectTrusted(): boolean;
90
+ ui: AgentCommandUi;
91
+ }
92
+
93
+ export interface AgentCommandResult {
94
+ ok: boolean;
95
+ status: "ok" | "clarification-needed" | "error";
96
+ command: string;
97
+ wrote: boolean;
98
+ path?: string;
99
+ scope?: "user" | "project";
100
+ message: string;
101
+ diagnostics: Diagnostic[];
102
+ data?: unknown;
103
+ }
104
+
105
+ interface CatalogLocation {
106
+ cwd: string;
107
+ userRoot: string;
108
+ projectTrusted: boolean;
109
+ projectConfigDirName?: string;
110
+ bundledRoot?: string;
111
+ }
112
+
113
+ interface ParsedArgs {
114
+ positionals: string[];
115
+ flags: Map<string, string[]>;
116
+ unknown: string[];
117
+ missing: string[];
118
+ }
119
+
120
+ export function registerAgentCommands(pi: Pick<ExtensionAPI, "registerCommand">, deps: AgentCommandDeps = {}): void {
121
+ pi.registerCommand("agents", {
122
+ description: "List, inspect, create, reload, or import Codex agent definitions",
123
+ getArgumentCompletions(prefix: string) {
124
+ const commands = ["list", "inspect", "show", "create", "reload", "import-codex", "help"];
125
+ const first = prefix.trim().split(/\s+/)[0] ?? "";
126
+ if (first !== prefix.trim() && first.length > 0) return null;
127
+ const items = commands.filter((command) => command.startsWith(first)).map((value) => ({ value, label: value }));
128
+ return items.length > 0 ? items : null;
129
+ },
130
+ async handler(args: string, ctx: ExtensionCommandContext) {
131
+ // Every invocation, including help and a dismissed picker. The
132
+ // context argument is the current session, not the one captured
133
+ // when the command was registered.
134
+ deps.propagateCommandContext?.(ctx);
135
+ return executeAgentsCommand(args, {
136
+ cwd: ctx.cwd,
137
+ hasUI: ctx.hasUI,
138
+ mode: ctx.mode,
139
+ isProjectTrusted: () => ctx.isProjectTrusted(),
140
+ ui: ctx.ui,
141
+ }, deps);
142
+ },
143
+ });
144
+ }
145
+
146
+ export async function executeAgentsCommand(args: string, host: AgentCommandHost, deps: AgentCommandDeps = {}): Promise<AgentCommandResult> {
147
+ const parsed = parseCommandArgs(args);
148
+ if (parsed.unknown.length > 0 || parsed.missing.length > 0) {
149
+ const detail = [
150
+ ...parsed.unknown.map((flag) => `Unknown flag --${flag}.`),
151
+ ...parsed.missing.map((flag) => `--${flag} needs a value.`),
152
+ ].join(" ");
153
+ return finish(host, result("error", parsed.positionals[0] ?? "agents", false, `${detail}\n${USAGE}`));
154
+ }
155
+ if (parsed.flags.has("help") || parsed.positionals[0] === "help" || parsed.positionals.length === 0) {
156
+ return finish(host, result("ok", "help", false, USAGE));
157
+ }
158
+ const command = parsed.positionals[0]!;
159
+ const loc = location(host, deps);
160
+ if (command === "list") return finish(host, await runList(parsed, loc, deps));
161
+ if (command === "show" || command === "inspect") return finish(host, await runShow(command, parsed, host, loc, deps));
162
+ if (command === "reload") return finish(host, await runReload(parsed, loc, deps));
163
+ if (command === "create") return finish(host, await runCreate(parsed, host, loc, deps));
164
+ if (command === "import-codex") return finish(host, await runImport(parsed, host, loc, deps));
165
+ return finish(host, result("error", command, false, `Unknown /agents command ${JSON.stringify(command)}.\n${USAGE}`));
166
+ }
167
+
168
+ export function parseCommandArgs(input: string): ParsedArgs {
169
+ const positionals: string[] = [];
170
+ const flags = new Map<string, string[]>();
171
+ const unknown: string[] = [];
172
+ const missing: string[] = [];
173
+ const tokens = tokenize(input);
174
+ for (let index = 0; index < tokens.length; index += 1) {
175
+ const token = tokens[index]!;
176
+ if (token === "--") {
177
+ positionals.push(...tokens.slice(index + 1));
178
+ break;
179
+ }
180
+ if (!token.startsWith("--")) {
181
+ positionals.push(token);
182
+ continue;
183
+ }
184
+ const body = token.slice(2);
185
+ const eq = body.indexOf("=");
186
+ const name = eq === -1 ? body : body.slice(0, eq);
187
+ if (!KNOWN_FLAGS.has(name)) {
188
+ unknown.push(name);
189
+ continue;
190
+ }
191
+ const value = eq === -1 ? tokens[index + 1] : body.slice(eq + 1);
192
+ if (eq === -1 && (value === undefined || value.startsWith("--"))) {
193
+ missing.push(name);
194
+ continue;
195
+ }
196
+ if (eq === -1) index += 1;
197
+ flags.set(name, [...(flags.get(name) ?? []), value ?? ""]);
198
+ }
199
+ return { positionals, flags, unknown, missing };
200
+ }
201
+
202
+ async function runList(parsed: ParsedArgs, loc: CatalogLocation, deps: AgentCommandDeps): Promise<AgentCommandResult> {
203
+ if (parsed.positionals.length > 1) return result("error", "list", false, "list does not take extra arguments. Use /agents show <id> to inspect one definition.");
204
+ const listed = presentCatalog(readCatalog(loc), deps.enrich);
205
+ return {
206
+ ...result("ok", "list", false, `Listed from a fresh catalog read. File edits show up on the next list or launch; /agents reload is not required for that.\n${listed.text}`),
207
+ diagnostics: listed.diagnostics,
208
+ data: listed,
209
+ };
210
+ }
211
+
212
+ async function runReload(parsed: ParsedArgs, loc: CatalogLocation, deps: AgentCommandDeps): Promise<AgentCommandResult> {
213
+ if (parsed.positionals.length > 1) return result("error", "reload", false, "reload does not take extra arguments.");
214
+ const listed = presentCatalog(refreshCatalog(loc), deps.enrich);
215
+ return {
216
+ ...result("ok", "reload", false, `Reloaded revision ${listed.revision} for inspection. The next launch reads the files again on its own; this command is not a prerequisite.\n${listed.text}`),
217
+ diagnostics: listed.diagnostics,
218
+ data: listed,
219
+ };
220
+ }
221
+
222
+ async function runShow(
223
+ command: string,
224
+ parsed: ParsedArgs,
225
+ host: AgentCommandHost,
226
+ loc: CatalogLocation,
227
+ deps: AgentCommandDeps,
228
+ ): Promise<AgentCommandResult> {
229
+ if (parsed.positionals.length > 2) return result("error", command, false, `${command} takes one id. Display names and filenames are not ids.`);
230
+ let id = parsed.positionals[1];
231
+ if (!id) {
232
+ if (!host.hasUI) {
233
+ return clarification(command, `${command} needs an id. UI is unavailable, so none was selected. Run /agents list and pass the id. Nothing was written.`);
234
+ }
235
+ const ids = listCatalog(readCatalog(loc)).map((entry) => entry.id);
236
+ if (ids.length === 0) return result("error", command, false, "The catalog has no definitions to inspect.");
237
+ const selected = await host.ui.select("Inspect a role or agent", ids);
238
+ if (!selected) return clarification(command, "Inspect was dismissed. Nothing was written.");
239
+ id = selected;
240
+ }
241
+ const view = presentCatalogEntry(readCatalog(loc), id, deps.enrich);
242
+ return {
243
+ ok: view.found,
244
+ status: view.found ? "ok" : "error",
245
+ command,
246
+ wrote: false,
247
+ message: view.found ? view.text : `${view.text}\nNothing was written.`,
248
+ diagnostics: viewDiagnostics(view),
249
+ data: view,
250
+ };
251
+ }
252
+
253
+ async function runCreate(
254
+ parsed: ParsedArgs,
255
+ host: AgentCommandHost,
256
+ loc: CatalogLocation,
257
+ deps: AgentCommandDeps,
258
+ ): Promise<AgentCommandResult> {
259
+ if (parsed.positionals.length > 1) {
260
+ return result("error", "create", false, "create takes flags, not a bare role name. Use --role <role-id>. Nothing was written.");
261
+ }
262
+ const scope = readScope(parsed.flags);
263
+ if (scope.error) return result("error", "create", false, `${scope.error} Nothing was written.`);
264
+ if (scope.value === "project" && !loc.projectTrusted) return untrusted("create");
265
+ const snapshot = readCatalog(loc);
266
+ const roles = catalogRoles(snapshot);
267
+ let roleIds = [...(parsed.flags.get("role") ?? [])];
268
+ if (roleIds.length > 1) {
269
+ const decision = await resolveRoleAssignment(roleIds.map((roleId) => ({ roleId })), {
270
+ hasUI: host.hasUI,
271
+ select: host.hasUI ? (title, options) => host.ui.select(title, options) : undefined,
272
+ });
273
+ if (decision.status !== "resolved" || decision.jobs.length !== 1) {
274
+ return clarification("create", `${decision.message} A named agent was not created. Split does not write two agents or an agent with multiple parents.`);
275
+ }
276
+ roleIds = [decision.jobs[0]!.roleId];
277
+ }
278
+ let roleId = roleIds[0];
279
+ if (!roleId) {
280
+ if (!host.hasUI) {
281
+ return clarification("create", `create needs --role <role-id>. UI is unavailable, so no role was chosen. Roles: ${roles.map((role) => role.id).join(", ") || "none"}. Nothing was written.`);
282
+ }
283
+ if (roles.length === 0) return result("error", "create", false, "No roles are available to use as a base. Nothing was written.");
284
+ const selected = await host.ui.select("Choose one base role", roles.map(roleOption));
285
+ if (!selected) return clarification("create", "Role choice was dismissed. Nothing was written.");
286
+ roleId = selected.split(" ")[0];
287
+ }
288
+ if (!roleId || !roles.some((role) => role.id === roleId)) {
289
+ return result("error", "create", false, `Unknown role ${roleId ?? ""}. Nothing was written. Use /agents list and pass one role id.`);
290
+ }
291
+ let name = parsed.flags.get("name")?.[0];
292
+ if (!name?.trim()) {
293
+ if (!host.hasUI) return clarification("create", "create needs --name. UI is unavailable, so no name was chosen. Nothing was written.");
294
+ const entered = await host.ui.input("Agent display name", "Payments Developer");
295
+ if (!entered?.trim()) return clarification("create", "Name was dismissed. Nothing was written.");
296
+ name = entered.trim();
297
+ } else {
298
+ name = name.trim();
299
+ }
300
+ const explicitId = parsed.flags.get("id")?.[0];
301
+ const id = explicitId ?? agentIdFromDisplayName(name);
302
+ if (!id || !/^agent\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(id)) {
303
+ return result("error", "create", false, `Id ${JSON.stringify(explicitId ?? name)} is not agent.<slug>. Nothing was written.`);
304
+ }
305
+ const modeFlag = parsed.flags.get("mode")?.[0] ?? "add";
306
+ if (modeFlag !== "add" && modeFlag !== "replace") {
307
+ return result("error", "create", false, "mode must be add or replace. Nothing was written.");
308
+ }
309
+ const mode: InstructionMode = modeFlag;
310
+ let instructions = parsed.flags.get("instructions")?.[0];
311
+ if (!parsed.flags.has("instructions")) {
312
+ if (host.hasUI) {
313
+ const edited = await host.ui.editor(mode === "replace" ? "Replacement instructions" : "Instructions to add", "");
314
+ if (edited === undefined) return clarification("create", "Instruction editor was dismissed. Nothing was written.");
315
+ instructions = edited;
316
+ } else {
317
+ instructions = "";
318
+ }
319
+ }
320
+ if (mode === "replace" && instructions!.trim() === "") {
321
+ return clarification("create", "replace mode needs nonempty instruction text. Model and effort still inherit unless you set overrides. Nothing was written.");
322
+ }
323
+ const overrides: PortablePreferences = {};
324
+ const overrideError = readOverrides(parsed.flags, overrides);
325
+ if (overrideError) return result("error", "create", false, `${overrideError} Nothing was written.`);
326
+ const description = parsed.flags.get("description")?.[0];
327
+ const definition: AgentDefinition = {
328
+ schema: CATALOG_SCHEMA_VERSION,
329
+ kind: "agent",
330
+ id,
331
+ name,
332
+ description: description?.trim() || undefined,
333
+ roleId,
334
+ instructionMode: mode,
335
+ overrides,
336
+ executionRestrictions: [],
337
+ provenance: { origin: "local" },
338
+ body: instructions ?? "",
339
+ };
340
+ const saved = createDefinition({ ...loc, definition, scope: scope.value, replace: false });
341
+ if (!saved.ok || !saved.path) {
342
+ return { ...result("error", "create", false, saved.diagnostics.map((item) => item.message).join("\n") || "Create did not write a file."), diagnostics: saved.diagnostics };
343
+ }
344
+ const view = presentCatalogEntry(readCatalog(loc), id, deps.enrich);
345
+ return {
346
+ ok: true,
347
+ status: "ok",
348
+ command: "create",
349
+ wrote: true,
350
+ path: saved.path,
351
+ scope: scope.value,
352
+ message: `Created ${id} in the ${scopeLabel(scope.value)} catalog at ${saved.path}. Only the role reference and explicit overrides were stored.\n${view.text}`,
353
+ diagnostics: [...saved.diagnostics, ...viewDiagnostics(view)],
354
+ data: view,
355
+ };
356
+ }
357
+
358
+ async function runImport(
359
+ parsed: ParsedArgs,
360
+ host: AgentCommandHost,
361
+ loc: CatalogLocation,
362
+ deps: AgentCommandDeps,
363
+ ): Promise<AgentCommandResult> {
364
+ if (parsed.positionals.length > 2) {
365
+ return result("error", "import-codex", false, "import-codex takes one file. It does not scan a directory. Nothing was imported.");
366
+ }
367
+ const scope = readScope(parsed.flags);
368
+ if (scope.error) return result("error", "import-codex", false, `${scope.error} Nothing was imported.`);
369
+ if (scope.value === "project" && !loc.projectTrusted) return untrusted("import-codex");
370
+ let filePath = parsed.positionals[1];
371
+ if (!filePath) {
372
+ if (!host.hasUI) {
373
+ return clarification("import-codex", "import-codex needs a TOML file path. UI is unavailable, so no path was chosen and .codex/agents was not scanned. Example: /agents import-codex .codex/agents/reviewer.toml. Nothing was imported.");
374
+ }
375
+ const entered = await host.ui.input("Codex TOML file", ".codex/agents/name.toml");
376
+ if (!entered?.trim()) return clarification("import-codex", "Import path was dismissed. Nothing was imported.");
377
+ filePath = entered.trim();
378
+ }
379
+ const resolved = isAbsolute(filePath) ? filePath : resolve(loc.cwd, filePath);
380
+ let fileStats;
381
+ try {
382
+ fileStats = statSync(resolved);
383
+ } catch (error) {
384
+ return result("error", "import-codex", false, `Cannot read ${resolved}: ${error instanceof Error ? error.message : String(error)}. Nothing was imported.`);
385
+ }
386
+ if (fileStats.isDirectory()) {
387
+ return result("error", "import-codex", false, `${resolved} is a directory. import-codex does not scan directories, including .codex/agents. Pass one TOML file. Nothing was imported.`);
388
+ }
389
+ let source: string;
390
+ try {
391
+ source = readFileSync(resolved, "utf8");
392
+ } catch (error) {
393
+ return result("error", "import-codex", false, `Cannot read ${resolved}: ${error instanceof Error ? error.message : String(error)}. Nothing was imported.`);
394
+ }
395
+ const parsedSource = parseCodexSource(source, resolved);
396
+ if (!parsedSource.ok) {
397
+ return { ...result("error", "import-codex", false, parsedSource.diagnostics.map((item) => item.message).join("\n")), diagnostics: parsedSource.diagnostics };
398
+ }
399
+ const document = parsedSource.document;
400
+ const snapshot = readCatalog(loc);
401
+ const roles = catalogRoles(snapshot);
402
+ if (roles.length === 0) return result("error", "import-codex", false, "No roles are available to use as the one base role. Nothing was imported.");
403
+ const suggestion = suggestBaseRoles(`${document.name}\n${document.description}\n${document.developerInstructions}`, roles);
404
+ const requestedRoles = [...(parsed.flags.get("role") ?? [])];
405
+ if (requestedRoles.length > 1) {
406
+ const decision = await resolveRoleAssignment(requestedRoles.map((roleId) => ({ roleId })), {
407
+ hasUI: host.hasUI,
408
+ select: host.hasUI ? (title, options) => host.ui.select(title, options) : undefined,
409
+ });
410
+ if (decision.status !== "resolved" || decision.jobs.length !== 1) {
411
+ return clarification("import-codex", `${decision.message} Import did not write an agent with multiple parents.`);
412
+ }
413
+ requestedRoles.splice(0, requestedRoles.length, decision.jobs[0]!.roleId);
414
+ }
415
+ let roleId = requestedRoles[0];
416
+ if (roleId && !roles.some((role) => role.id === roleId)) {
417
+ return result("error", "import-codex", false, `Unknown role ${roleId}. Nothing was imported.`);
418
+ }
419
+ const sourceRef = codexSourceRef(resolved);
420
+ const existingLookup = matchExisting(listScopeAgents(loc, scope.value).agents, document.proposedId, sourceRef);
421
+ if (existingLookup.conflict) return clarification("import-codex", `${existingLookup.conflict} Nothing was imported.`);
422
+ if (!roleId) {
423
+ if (!host.hasUI) {
424
+ return clarification("import-codex", headlessImportMessage(document, suggestion, roles, existingLookup.match?.definition, sourceRef, scope.value, deps));
425
+ }
426
+ const ordered = [...roles].sort((left, right) => left.id.localeCompare(right.id));
427
+ if (suggestion.unique) {
428
+ const index = ordered.findIndex((role) => role.id === suggestion.unique);
429
+ if (index > 0) ordered.unshift(...ordered.splice(index, 1));
430
+ }
431
+ const title = suggestion.unique
432
+ ? `Confirm one base role. Suggested ${suggestion.unique}. Imported instructions use replace mode.`
433
+ : suggestion.ambiguous.length > 0
434
+ ? `Choose one base role. ${suggestion.ambiguous.join(" and ")} tied, and an agent cannot have both.`
435
+ : "Choose one base role. None was suggested from the Codex name.";
436
+ const selected = await host.ui.select(title, ordered.map(roleOption));
437
+ if (!selected) return clarification("import-codex", "Role confirmation was dismissed. Nothing was imported.");
438
+ roleId = selected.split(" ")[0];
439
+ if (!roleId || !roles.some((role) => role.id === roleId)) {
440
+ return result("error", "import-codex", false, `Unknown role choice ${JSON.stringify(selected)}. Nothing was imported.`);
441
+ }
442
+ }
443
+ if (!host.hasUI) {
444
+ return clarification("import-codex", headlessImportMessage(document, { ...suggestion, unique: roleId, ambiguous: [] }, roles, existingLookup.match?.definition, sourceRef, scope.value, deps, roleId));
445
+ }
446
+ if (!roleId) return result("error", "import-codex", false, "No role was confirmed. Nothing was imported.");
447
+ const confirmedMode = await host.ui.confirm(
448
+ "Import instructions in replace mode?",
449
+ [
450
+ `Imported developer_instructions replace the ${roleId} instruction text.`,
451
+ "Model, effort, and tier still inherit unless you explicitly save Codex values as overrides.",
452
+ `Destination: ${scopeLabel(scope.value)} catalog.`,
453
+ `Proposed id: ${existingLookup.match?.definition.id ?? document.proposedId}.`,
454
+ `${PI_VS_CODEX_PRECEDENCE.codex} ${PI_VS_CODEX_PRECEDENCE.pi}`,
455
+ "The native file is an independent copy. Later edits to the Codex source do not change it.",
456
+ ].join("\n"),
457
+ );
458
+ if (!confirmedMode) {
459
+ return clarification("import-codex", "Import cancelled. Codex instructions are only imported in replace mode, and that was not confirmed. Nothing was written.");
460
+ }
461
+ let saveOverrides = false;
462
+ if (document.storableModel || document.storableEffort) {
463
+ const choice = await host.ui.select("Codex model and effort are not copied into overrides unless you say so", [
464
+ "Inherit model and effort from the selected role",
465
+ "Save supported Codex model and effort as explicit overrides",
466
+ ]);
467
+ if (!choice) return clarification("import-codex", "Override confirmation was dismissed. Inherited role values were not flattened into overrides. Nothing was imported.");
468
+ saveOverrides = choice.startsWith("Save ");
469
+ }
470
+ const id = existingLookup.match?.definition.id ?? document.proposedId;
471
+ const built = buildImportedAgent(document, {
472
+ id,
473
+ roleId,
474
+ saveOverrides,
475
+ importedAt: deps.now?.() ?? new Date().toISOString(),
476
+ sourceRef,
477
+ });
478
+ if (!built.ok || !built.definition) {
479
+ return { ...result("error", "import-codex", false, built.diagnostics.map((item) => item.message).join("\n") || "The native copy could not be built. Nothing was imported."), diagnostics: built.diagnostics };
480
+ }
481
+ if (existingLookup.match) {
482
+ const preview = renderReimportPreview(existingLookup.match.definition, built.definition);
483
+ const confirmed = await host.ui.confirm(`Replace ${id}?`, preview.text);
484
+ if (!confirmed) {
485
+ return clarification("import-codex", `${preview.text}\nReplacement was not confirmed. The existing definition was left unchanged.`);
486
+ }
487
+ }
488
+ const saved = existingLookup.match
489
+ ? saveDefinition({ ...loc, definition: built.definition, scope: scope.value, replace: true })
490
+ : createDefinition({ ...loc, definition: built.definition, scope: scope.value, replace: false });
491
+ if (!saved.ok || !saved.path) {
492
+ return { ...result("error", "import-codex", false, saved.diagnostics.map((item) => item.message).join("\n") || "Import did not write a file."), diagnostics: [...parsedSource.diagnostics, ...built.diagnostics, ...saved.diagnostics] };
493
+ }
494
+ const view = presentCatalogEntry(readCatalog(loc), built.definition.id, deps.enrich);
495
+ const launchNote = view.catalogLaunchable
496
+ ? "Catalog validation passed. Definition validity is not launchability."
497
+ : "The file was saved and is not launchable.";
498
+ return {
499
+ ok: true,
500
+ status: "ok",
501
+ command: "import-codex",
502
+ wrote: true,
503
+ path: saved.path,
504
+ scope: scope.value,
505
+ message: `Imported ${built.definition.id} into the ${scopeLabel(scope.value)} catalog at ${saved.path}. ${launchNote}\n${PI_VS_CODEX_PRECEDENCE.codex} ${PI_VS_CODEX_PRECEDENCE.pi}\n${view.text}`,
506
+ diagnostics: [...parsedSource.diagnostics, ...built.diagnostics, ...saved.diagnostics, ...viewDiagnostics(view)],
507
+ data: { view, replaced: Boolean(existingLookup.match) },
508
+ };
509
+ }
510
+
511
+ function headlessImportMessage(
512
+ document: CodexDocument,
513
+ suggestion: { unique?: string; ambiguous: string[] },
514
+ roles: { id: string; name: string }[],
515
+ existing: AgentDefinition | undefined,
516
+ sourceRef: string,
517
+ scope: "user" | "project",
518
+ deps: AgentCommandDeps,
519
+ confirmedRole?: string,
520
+ ): string {
521
+ const roleLine = confirmedRole
522
+ ? `Role ${confirmedRole} was passed but replacement mode is not confirmed.`
523
+ : suggestion.unique
524
+ ? `Suggested role: ${suggestion.unique}. It is not confirmed.`
525
+ : suggestion.ambiguous.length > 0
526
+ ? `Role suggestion ties ${suggestion.ambiguous.join(" and ")}. Choose one; an agent cannot have both.`
527
+ : `No role was suggested. Choose one of: ${roles.map((role) => role.id).join(", ")}.`;
528
+ const lines = [
529
+ "import-codex needs confirmation of one base role and replace mode.",
530
+ `UI is unavailable (${"print/json or hasUI=false"}). Nothing was written.`,
531
+ roleLine,
532
+ "Instruction mode would be replace. Imported developer_instructions would replace role instructions, not append to them.",
533
+ "Unspecified model and effort would inherit from the role. They are not flattened into overrides unless that is confirmed.",
534
+ `Destination would be the ${scopeLabel(scope)} catalog.`,
535
+ `Proposed id: ${existing?.id ?? document.proposedId}.`,
536
+ `developer_instructions: ${JSON.stringify(document.developerInstructions)}`,
537
+ `${PI_VS_CODEX_PRECEDENCE.codex} ${PI_VS_CODEX_PRECEDENCE.pi}`,
538
+ `Source ${sourceRef} would be copied, not watched.`,
539
+ ];
540
+ const roleForPreview = confirmedRole ?? suggestion.unique;
541
+ if (existing && roleForPreview) {
542
+ const built = buildImportedAgent(document, {
543
+ id: existing.id,
544
+ roleId: roleForPreview,
545
+ saveOverrides: false,
546
+ importedAt: deps.now?.() ?? "unconfirmed",
547
+ sourceRef,
548
+ });
549
+ if (built.definition) lines.push(renderReimportPreview(existing, built.definition).text);
550
+ } else if (existing) {
551
+ lines.push(`instructions currently: ${JSON.stringify(existing.body)}`);
552
+ lines.push(`role currently: ${existing.roleId}`);
553
+ lines.push(`imported developer_instructions: ${JSON.stringify(document.developerInstructions)}`);
554
+ lines.push("The replacement role is not confirmed, so the native file was not changed.");
555
+ }
556
+ return lines.join("\n");
557
+ }
558
+
559
+ function matchExisting(
560
+ agents: { path: string; definition: AgentDefinition }[],
561
+ proposedId: string,
562
+ sourceRef: string,
563
+ ): { match?: { path: string; definition: AgentDefinition }; conflict?: string } {
564
+ const hits = agents.filter((agent) => agent.definition.id === proposedId || (agent.definition.provenance?.format === CODEX_FORMAT && agent.definition.provenance.sourceRef === sourceRef));
565
+ if (hits.length > 1) {
566
+ return { conflict: `This import matches ${hits.length} files (${hits.map((hit) => `${hit.definition.id} at ${hit.path}`).join("; ")}). Keep one file. No merge and no write were done.` };
567
+ }
568
+ return { match: hits[0] };
569
+ }
570
+
571
+ function listScopeAgents(loc: CatalogLocation, scope: "user" | "project"): { agents: { path: string; definition: AgentDefinition }[]; diagnostics: Diagnostic[] } {
572
+ const diagnostics: Diagnostic[] = [];
573
+ if (scope === "project" && !loc.projectTrusted) return { agents: [], diagnostics };
574
+ const directory = scope === "project"
575
+ ? projectCatalogPaths(loc.cwd, loc.projectConfigDirName).agents
576
+ : userCatalogPaths(loc.userRoot).agents;
577
+ if (!existsSync(directory)) return { agents: [], diagnostics };
578
+ let directoryStats;
579
+ try {
580
+ directoryStats = lstatSync(directory);
581
+ } catch (error) {
582
+ diagnostics.push(diagnostic(DiagnosticCodes.ioError, error instanceof Error ? error.message : String(error)));
583
+ return { agents: [], diagnostics };
584
+ }
585
+ if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) return { agents: [], diagnostics };
586
+ const agents: { path: string; definition: AgentDefinition }[] = [];
587
+ for (const name of readdirSync(directory).sort()) {
588
+ if (name.startsWith(".") || !name.endsWith(".md")) continue;
589
+ const path = resolve(directory, name);
590
+ let fileStats;
591
+ try {
592
+ fileStats = lstatSync(path);
593
+ } catch {
594
+ continue;
595
+ }
596
+ if (fileStats.isSymbolicLink() || !fileStats.isFile()) continue;
597
+ let parsed;
598
+ try {
599
+ parsed = parseDefinition(readFileSync(path, "utf8"), path);
600
+ } catch {
601
+ continue;
602
+ }
603
+ if (parsed.definition?.kind === "agent") agents.push({ path, definition: parsed.definition });
604
+ }
605
+ return { agents, diagnostics };
606
+ }
607
+
608
+ function readOverrides(flags: Map<string, string[]>, overrides: PortablePreferences): string | undefined {
609
+ if (flags.has("model")) {
610
+ const model = flags.get("model")![0]!;
611
+ if (!MODEL_PATTERN.test(model)) return `model must be provider/model, got ${JSON.stringify(model)}. No provider was guessed.`;
612
+ overrides.model = model;
613
+ }
614
+ if (flags.has("effort")) {
615
+ const effort = flags.get("effort")![0]!;
616
+ if (!(EFFORT_LEVELS as readonly string[]).includes(effort)) return `effort must be one of ${EFFORT_LEVELS.join(", ")}, got ${JSON.stringify(effort)}.`;
617
+ overrides.effort = effort as ThinkingLevel;
618
+ }
619
+ if (flags.has("tier")) {
620
+ const tier = flags.get("tier")![0]!;
621
+ if (!TIER_PATTERN.test(tier)) return `tier must be a short lowercase label, got ${JSON.stringify(tier)}.`;
622
+ overrides.tier = tier;
623
+ }
624
+ return undefined;
625
+ }
626
+
627
+ function catalogRoles(snapshot: CatalogSnapshot): { id: string; name: string }[] {
628
+ return listCatalog(snapshot)
629
+ .filter((entry) => entry.kind === "role" && entry.name)
630
+ .map((entry) => ({ id: entry.id, name: entry.name! }));
631
+ }
632
+
633
+ function roleOption(role: { id: string; name: string }): string {
634
+ return `${role.id} (${role.name})`;
635
+ }
636
+
637
+ function readScope(flags: Map<string, string[]>): { value: "user" | "project"; error?: string } {
638
+ const values = flags.get("scope") ?? [];
639
+ if (values.length === 0) return { value: "user" };
640
+ if (values.length > 1) return { value: "user", error: "Pass --scope once. Use user or project." };
641
+ if (values[0] === "user" || values[0] === "personal") return { value: "user" };
642
+ if (values[0] === "project") return { value: "project" };
643
+ return { value: "user", error: `Unknown scope ${JSON.stringify(values[0])}. Use user (the default) or project.` };
644
+ }
645
+
646
+ function scopeLabel(scope: "user" | "project"): string {
647
+ return scope === "project" ? "project" : "personal";
648
+ }
649
+
650
+ function location(host: AgentCommandHost, deps: AgentCommandDeps): CatalogLocation {
651
+ return {
652
+ cwd: host.cwd,
653
+ userRoot: deps.userRoot ?? defaultUserRoot(),
654
+ projectTrusted: host.isProjectTrusted(),
655
+ projectConfigDirName: deps.projectConfigDirName,
656
+ bundledRoot: deps.bundledRoot,
657
+ };
658
+ }
659
+
660
+ function readCatalog(loc: CatalogLocation): CatalogSnapshot {
661
+ return loadCatalog(loc);
662
+ }
663
+
664
+ function untrusted(command: string): AgentCommandResult {
665
+ return result("error", command, false, "Refusing to write the project catalog because the project is not trusted. Omit --scope to save personally, or trust the project and pass --scope project. Nothing was written.");
666
+ }
667
+
668
+ function clarification(command: string, message: string, diagnostics: Diagnostic[] = []): AgentCommandResult {
669
+ return {
670
+ ok: false,
671
+ status: "clarification-needed",
672
+ command,
673
+ wrote: false,
674
+ message,
675
+ diagnostics: diagnostics.length > 0 ? diagnostics : [diagnostic("clarification-needed", message)],
676
+ };
677
+ }
678
+
679
+ function result(status: AgentCommandResult["status"], command: string, wrote: boolean, message: string): AgentCommandResult {
680
+ return { ok: status === "ok", status, command, wrote, message, diagnostics: status === "error" ? [diagnostic("agents-command", message.split("\n")[0] ?? message)] : [] };
681
+ }
682
+
683
+ function viewDiagnostics(view: OperationView): Diagnostic[] {
684
+ return view.validation.map((item) => diagnostic(item.code, item.message, item.blocking, item.path, item.field));
685
+ }
686
+
687
+ function diagnostic(code: string, message: string, blocking = true, path?: string, field?: string): Diagnostic {
688
+ return { severity: blocking ? "error" : "warning", code, message, path, field, blocking, structural: false };
689
+ }
690
+
691
+ function finish(host: AgentCommandHost, commandResult: AgentCommandResult): AgentCommandResult {
692
+ if (!host.hasUI) return commandResult;
693
+ const level = commandResult.status === "error" ? "error" : commandResult.status === "clarification-needed" ? "warning" : "info";
694
+ try {
695
+ host.ui.notify(commandResult.message, level);
696
+ } catch {
697
+ /* Notification is not a write. */
698
+ }
699
+ try {
700
+ host.ui.setWidget?.("agents-catalog", commandResult.message.split("\n").slice(0, 40));
701
+ } catch {
702
+ /* Widget rendering is TUI-only. */
703
+ }
704
+ return commandResult;
705
+ }
706
+
707
+ function tokenize(input: string): string[] {
708
+ const tokens: string[] = [];
709
+ for (const match of input.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) tokens.push(match[1] ?? match[2] ?? match[3] ?? "");
710
+ return tokens;
711
+ }