@ai-outfitter/outfitter 0.7.2 → 0.8.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.
Files changed (61) hide show
  1. package/README.md +4 -2
  2. package/code/pi-extension/src/outfitter-extension.js +720 -0
  3. package/dist/agents/AgentAdapter.d.ts +2 -0
  4. package/dist/agents/AgentLaunch.js +5 -0
  5. package/dist/agents/AgentLaunch.js.map +1 -1
  6. package/dist/agents/OutfitterDocs.d.ts +2 -0
  7. package/dist/agents/OutfitterDocs.js +38 -0
  8. package/dist/agents/OutfitterDocs.js.map +1 -0
  9. package/dist/agents/pi/PiAdapter.js +15 -14
  10. package/dist/agents/pi/PiAdapter.js.map +1 -1
  11. package/dist/agents/pi/PiSkillSources.d.ts +8 -0
  12. package/dist/agents/pi/PiSkillSources.js +69 -0
  13. package/dist/agents/pi/PiSkillSources.js.map +1 -0
  14. package/dist/cli/OutfitterCli.js +7 -2
  15. package/dist/cli/OutfitterCli.js.map +1 -1
  16. package/dist/cli/commands/PiLoginLaunch.js +25 -728
  17. package/dist/cli/commands/PiLoginLaunch.js.map +1 -1
  18. package/dist/cli/commands/RunCommand.d.ts +4 -17
  19. package/dist/cli/commands/RunCommand.js +10 -146
  20. package/dist/cli/commands/RunCommand.js.map +1 -1
  21. package/dist/cli/commands/SetupCommand.d.ts +4 -63
  22. package/dist/cli/commands/SetupCommand.js +13 -673
  23. package/dist/cli/commands/SetupCommand.js.map +1 -1
  24. package/dist/cli/commands/run/RunLaunchSummary.d.ts +2 -0
  25. package/dist/cli/commands/run/RunLaunchSummary.js +35 -0
  26. package/dist/cli/commands/run/RunLaunchSummary.js.map +1 -0
  27. package/dist/cli/commands/run/RunProfileResolution.d.ts +37 -0
  28. package/dist/cli/commands/run/RunProfileResolution.js +115 -0
  29. package/dist/cli/commands/run/RunProfileResolution.js.map +1 -0
  30. package/dist/cli/commands/setup/SetupPrompts.d.ts +14 -0
  31. package/dist/cli/commands/setup/SetupPrompts.js +296 -0
  32. package/dist/cli/commands/setup/SetupPrompts.js.map +1 -0
  33. package/dist/cli/commands/setup/SetupSourceImport.d.ts +5 -0
  34. package/dist/cli/commands/setup/SetupSourceImport.js +177 -0
  35. package/dist/cli/commands/setup/SetupSourceImport.js.map +1 -0
  36. package/dist/cli/commands/setup/SetupSourceLaunch.d.ts +4 -0
  37. package/dist/cli/commands/setup/SetupSourceLaunch.js +65 -0
  38. package/dist/cli/commands/setup/SetupSourceLaunch.js.map +1 -0
  39. package/dist/cli/commands/setup/SetupStarterSource.d.ts +21 -0
  40. package/dist/cli/commands/setup/SetupStarterSource.js +133 -0
  41. package/dist/cli/commands/setup/SetupStarterSource.js.map +1 -0
  42. package/dist/cli/commands/setup/SetupTypes.d.ts +91 -0
  43. package/dist/cli/commands/setup/SetupTypes.js +26 -0
  44. package/dist/cli/commands/setup/SetupTypes.js.map +1 -0
  45. package/doc/architecture/state_writeback_strategy.md +350 -0
  46. package/doc/documentation/README.md +21 -0
  47. package/doc/documentation/cli.md +74 -0
  48. package/doc/documentation/concepts.md +54 -0
  49. package/doc/documentation/first-time-cli-agent-users.md +137 -0
  50. package/doc/documentation/getting-started.md +49 -0
  51. package/doc/documentation/iterating-on-profiles.md +109 -0
  52. package/doc/documentation/profile-repository.md +111 -0
  53. package/doc/documentation/profiles.md +183 -0
  54. package/doc/documentation/state.md +204 -0
  55. package/doc/documentation/support-matrix.md +46 -0
  56. package/doc/documentation/switching-to-outfitter.md +130 -0
  57. package/doc/documentation/usecases/engineering.md +114 -0
  58. package/doc/documentation/usecases/organization-profile-catalog.md +140 -0
  59. package/doc/documentation/usecases/persona-reviews.md +173 -0
  60. package/doc/philosophy.md +25 -0
  61. package/package.json +3 -2
@@ -0,0 +1,720 @@
1
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+
3
+ const OUTFITTER_PLAN_TOOLS = ["read", "grep", "find", "ls"];
4
+ const OUTFITTER_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
5
+ // The Outfitter CLI replaces each quoted __OUTFITTER_*__ placeholder below with
6
+ // a JSON-encoded launch-time value when it writes this extension into the Pi
7
+ // agent directory. Pi never loads this file directly from the repository or the
8
+ // npm package.
9
+ const OUTFITTER_HOME = "__OUTFITTER_HOME__";
10
+ const OUTFITTER_PROJECT = "__OUTFITTER_PROJECT__";
11
+ const OUTFITTER_DEFAULT_PROFILES_PATH = "__OUTFITTER_DEFAULT_PROFILES_PATH__";
12
+ const OUTFITTER_SETUP_SOURCE_URI = "__OUTFITTER_SETUP_SOURCE_URI__";
13
+ const OUTFITTER_AUTO_OPEN = "__OUTFITTER_AUTO_OPEN__";
14
+ const OUTFITTER_DEFAULT_SETTINGS_TEMPLATE = "__OUTFITTER_DEFAULT_SETTINGS_TEMPLATE__";
15
+ const OUTFITTER_STARTUP_ASCII_ART = "__OUTFITTER_STARTUP_ASCII_ART__";
16
+ const OUTFITTER_ASCII_ART = "__OUTFITTER_ASCII_ART__";
17
+ const OUTFITTER_ASCII_GRADIENT = ["success", "accent", "text", "muted", "dim"];
18
+ const OUTFITTER_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$/u;
19
+ const loadPrivateCatalogOnboarding = () => import("./pi-extension/privateCatalogOnboarding.js");
20
+
21
+ export default function outfitter(pi) {
22
+ let mode = "build";
23
+ let buildModeTools;
24
+ let loginSubmitted = false;
25
+
26
+ const updateModeStatus = (ctx) => {
27
+ const color = mode === "plan" ? "warning" : "muted";
28
+ ctx.ui.setStatus("outfitter-mode", ctx.ui.theme.fg(color, "mode: " + mode));
29
+ };
30
+
31
+ const enterPlanMode = (ctx) => {
32
+ if (mode !== "plan") {
33
+ buildModeTools = pi.getActiveTools();
34
+ }
35
+ mode = "plan";
36
+ const availableTools = new Set(pi.getAllTools().map((tool) => tool.name));
37
+ const planTools = OUTFITTER_PLAN_TOOLS.filter((toolName) => availableTools.has(toolName));
38
+ pi.setActiveTools(planTools.length > 0 ? planTools : OUTFITTER_PLAN_TOOLS);
39
+ updateModeStatus(ctx);
40
+ ctx.ui.notify("Outfitter mode: plan (read-only tools; Shift+Tab to switch back)", "info");
41
+ };
42
+
43
+ const enterBuildMode = (ctx) => {
44
+ mode = "build";
45
+ pi.setActiveTools(buildModeTools ?? OUTFITTER_DEFAULT_TOOLS);
46
+ buildModeTools = undefined;
47
+ updateModeStatus(ctx);
48
+ ctx.ui.notify("Outfitter mode: build (normal tools; Shift+Tab for plan mode)", "info");
49
+ };
50
+
51
+ const cycleOutfitterMode = (ctx) => {
52
+ if (mode === "plan") {
53
+ enterBuildMode(ctx);
54
+ return;
55
+ }
56
+
57
+ enterPlanMode(ctx);
58
+ };
59
+
60
+ pi.on("project_trust", async (event) => {
61
+ if (!OUTFITTER_AUTO_OPEN || event.cwd !== OUTFITTER_PROJECT) {
62
+ return { trusted: "undecided" };
63
+ }
64
+
65
+ return { trusted: "yes", remember: true };
66
+ });
67
+
68
+ const exportRuntimeSystemPrompt = async (ctx) => {
69
+ const outputPath = typeof process === "undefined" ? undefined : process.env.OUTFITTER_SYSTEM_PROMPT_EXPORT_PATH;
70
+ if (!outputPath || typeof ctx.getSystemPrompt !== "function") return;
71
+
72
+ const systemPrompt = ctx.getSystemPrompt();
73
+ if (typeof systemPrompt !== "string") return;
74
+
75
+ const [{ mkdirSync, writeFileSync }, { dirname }] = await Promise.all([import("node:fs"), import("node:path")]);
76
+ mkdirSync(dirname(outputPath), { recursive: true });
77
+ writeFileSync(
78
+ outputPath,
79
+ [
80
+ "<!-- Generated by Outfitter from Pi runtime ctx.getSystemPrompt(). Safe to review or git-ignore. Do not edit by hand. -->",
81
+ "# Generated Pi runtime system prompt",
82
+ "",
83
+ systemPrompt,
84
+ "",
85
+ ].join("\n"),
86
+ );
87
+ };
88
+
89
+ const submitSlashCommand = async (ctx, command, notification) => {
90
+ if (ctx.mode !== "tui") return false;
91
+ ctx.ui.setEditorText(command);
92
+ if (notification !== undefined) ctx.ui.notify(notification, "info");
93
+ await ctx.ui.custom((tui, _theme, _keybindings, done) => {
94
+ setTimeout(() => {
95
+ tui.focusedComponent?.handleInput?.("\r");
96
+ done(true);
97
+ }, 25);
98
+
99
+ return {
100
+ render: () => [],
101
+ invalidate: () => undefined,
102
+ };
103
+ }, { overlay: true, overlayOptions: { nonCapturing: true, visible: () => false } });
104
+ return true;
105
+ };
106
+
107
+ const getAvailableModelCount = async (ctx) => {
108
+ if (ctx.modelRegistry === undefined || typeof ctx.modelRegistry.getAvailable !== "function") {
109
+ return ctx.model === undefined ? 0 : 1;
110
+ }
111
+
112
+ try {
113
+ const available = await ctx.modelRegistry.getAvailable();
114
+ return Array.isArray(available) ? available.length : 0;
115
+ } catch {
116
+ return ctx.model === undefined ? 0 : 1;
117
+ }
118
+ };
119
+
120
+ const confirmModelProviderConnection = async (ctx) => {
121
+ if (typeof ctx.ui.custom !== "function") return true;
122
+ const selected = await selectDescribedOption(
123
+ ctx,
124
+ [
125
+ "Pi does not have a model provider connected yet.",
126
+ "Connect one now so Outfitter can use Pi.",
127
+ "Credentials stay inside Pi.",
128
+ ],
129
+ [{ value: "connect", label: "Connect a model provider" }],
130
+ "connect",
131
+ );
132
+ return selected === "connect";
133
+ };
134
+
135
+ const openLoginIfNoModels = async (ctx) => {
136
+ if (loginSubmitted || ctx.mode !== "tui") return;
137
+ const availableModelCount = await getAvailableModelCount(ctx);
138
+ if (availableModelCount > 0) return;
139
+ if (!(await confirmModelProviderConnection(ctx))) return;
140
+ loginSubmitted = await submitSlashCommand(ctx, "/login");
141
+ };
142
+
143
+ const createQuestionUi = (ctx) => ({
144
+ async selectSetupMode() {
145
+ const options = [
146
+ "Use the default Outfitter profile catalog",
147
+ "Create your own profile",
148
+ "Provide a different catalog to import",
149
+ ];
150
+ const selected = await ctx.ui.select("How would you like to set up Outfitter?", options);
151
+ if (selected === undefined) return undefined;
152
+ return options.indexOf(selected) === 1 ? "create" : options.indexOf(selected) === 2 ? "catalog" : "default";
153
+ },
154
+ async selectInstallTarget(paths) {
155
+ const items = [
156
+ {
157
+ value: "home",
158
+ label: "Home folder (~/.outfitter)",
159
+ description: "These profiles will be available anywhere you start outfitter.",
160
+ },
161
+ {
162
+ value: "project",
163
+ label: "Current project directory (.outfitter)",
164
+ description: "These profiles will only be available in the current project directory and will compose the profiles of the same name in the home folder.",
165
+ },
166
+ ];
167
+ const title = ["Where should Outfitter install these settings?"];
168
+ const selected = typeof ctx.ui.custom === "function"
169
+ ? await selectDescribedOption(ctx, title, items, "home")
170
+ : await ctx.ui.select(title.join("\n"), items.map((item) => item.label));
171
+ if (selected === undefined) return undefined;
172
+ const selectedValue = items.some((item) => item.value === selected)
173
+ ? selected
174
+ : items.find((item) => item.label === selected)?.value;
175
+ return selectedValue === "project"
176
+ ? { id: "project", settingsPath: paths.projectSettingsPath, profilesPath: paths.projectProfilesPath }
177
+ : { id: "home", settingsPath: paths.homeSettingsPath, profilesPath: paths.homeProfilesPath };
178
+ },
179
+ async selectProfile(profiles, currentDefault) {
180
+ const items = profiles.map((profile) => ({
181
+ value: profile.id,
182
+ label: formatProfileLabel(profile, currentDefault),
183
+ description: profile.description,
184
+ }));
185
+ const title = [
186
+ "Outfitter profile setup",
187
+ "",
188
+ "Choose the default profile from the selected catalog for future 'outfitter' launches.",
189
+ "The current Pi process keeps the profile it started with; this setting applies on the next launch.",
190
+ ];
191
+ const initialProfileId = currentDefault ?? (profiles.some((profile) => profile.id === "founder") ? "founder" : profiles[0]?.id);
192
+ const selectedId = typeof ctx.ui.custom === "function"
193
+ ? await selectDescribedOption(ctx, title, items, initialProfileId)
194
+ : await ctx.ui.select(title.join("\n"), items.map((item) => item.label));
195
+ if (selectedId === undefined) return undefined;
196
+ return profiles.find((profile) => profile.id === selectedId) ?? profiles[items.findIndex((item) => item.label === selectedId)];
197
+ },
198
+ async input(message, defaultValue) {
199
+ if (typeof ctx.ui.input === "function") {
200
+ return ctx.ui.input(message, defaultValue === undefined ? undefined : { defaultValue });
201
+ }
202
+ const suffix = defaultValue === undefined ? "" : " [" + defaultValue + "]";
203
+ const selected = await ctx.ui.select(message + suffix, [defaultValue ?? ""]);
204
+ return selected;
205
+ },
206
+ async confirmPrivateCatalog(repository) {
207
+ const { confirmPrivateCatalog } = await loadPrivateCatalogOnboarding();
208
+ return confirmPrivateCatalog(ctx, selectDescribedOption, repository);
209
+ },
210
+ notify: (message, type = "info") => ctx.ui.notify(message, type),
211
+ });
212
+
213
+ const runOutfitterOnboarding = async (ctx) => {
214
+ if (!ctx.hasUI) return;
215
+ const [{ mkdirSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync }, { dirname, join }] =
216
+ await Promise.all([import("node:fs"), import("node:path")]);
217
+ const paths = createOutfitterPaths(join);
218
+ const questionUi = createQuestionUi(ctx);
219
+
220
+ if (OUTFITTER_SETUP_SOURCE_URI !== undefined) {
221
+ await runProvidedSourceOnboarding({ mkdirSync, writeFileSync, dirname }, paths, questionUi, OUTFITTER_SETUP_SOURCE_URI);
222
+ await openLoginIfNoModels(ctx);
223
+ return;
224
+ }
225
+
226
+ const setupMode = await questionUi.selectSetupMode();
227
+
228
+ if (setupMode === undefined) {
229
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
230
+ await openLoginIfNoModels(ctx);
231
+ return;
232
+ }
233
+
234
+ if (setupMode === "catalog") {
235
+ await runRemoteSettingsOnboarding({ existsSync, mkdirSync, readFileSync, writeFileSync, dirname }, paths, questionUi);
236
+ await openLoginIfNoModels(ctx);
237
+ return;
238
+ }
239
+
240
+ if (setupMode === "create") {
241
+ await runCreateProfileOnboarding({ existsSync, mkdirSync, readFileSync, writeFileSync, dirname, join }, paths, questionUi);
242
+ await openLoginIfNoModels(ctx);
243
+ return;
244
+ }
245
+
246
+ await runDefaultCatalogOnboarding(
247
+ { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, dirname, join },
248
+ paths,
249
+ questionUi,
250
+ );
251
+ await openLoginIfNoModels(ctx);
252
+ };
253
+
254
+ pi.registerCommand("outfitter", {
255
+ description: "Configure Outfitter profile onboarding",
256
+ handler: async (_args, ctx) => {
257
+ await runOutfitterOnboarding(ctx);
258
+ },
259
+ });
260
+
261
+ pi.registerCommand("mode", {
262
+ description: "Toggle Outfitter build/plan mode",
263
+ handler: async (_args, ctx) => {
264
+ if (ctx.mode !== "tui") return;
265
+ cycleOutfitterMode(ctx);
266
+ },
267
+ });
268
+
269
+ pi.on("session_start", async (event, ctx) => {
270
+ await exportRuntimeSystemPrompt(ctx);
271
+ if (ctx.mode !== "tui") return;
272
+ ctx.ui.setHeader((_tui, theme) => {
273
+ const lines = createStartupHeaderLines(theme, event.reason === "startup" && OUTFITTER_AUTO_OPEN);
274
+ return {
275
+ render: () => lines,
276
+ invalidate: () => undefined,
277
+ };
278
+ });
279
+ updateModeStatus(ctx);
280
+ ctx.ui.onTerminalInput((data) => {
281
+ if (!matchesKey(data, "shift+tab")) return undefined;
282
+ cycleOutfitterMode(ctx);
283
+ return { consume: true };
284
+ });
285
+
286
+ if (event.reason === "startup" && OUTFITTER_AUTO_OPEN) {
287
+ await submitSlashCommand(ctx, "/outfitter");
288
+ return;
289
+ }
290
+
291
+ await openLoginIfNoModels(ctx);
292
+ });
293
+
294
+ pi.on("tool_call", async (event) => {
295
+ if (mode !== "plan" || event.toolName !== "bash") return;
296
+
297
+ return {
298
+ block: true,
299
+ reason: "Outfitter plan mode blocks Bash commands. Press Shift+Tab to return to build mode. Command: " + String(event.input?.command ?? ""),
300
+ };
301
+ });
302
+
303
+ pi.on("context", async (event) => {
304
+ const messages = event.messages.filter((message) => message.customType !== "outfitter-mode-context");
305
+
306
+ if (mode !== "plan") {
307
+ return { messages };
308
+ }
309
+
310
+ return {
311
+ messages: [
312
+ ...messages,
313
+ {
314
+ role: "custom",
315
+ customType: "outfitter-mode-context",
316
+ content:
317
+ "[OUTFITTER PLAN MODE ACTIVE]\n" +
318
+ "You are in read-only planning mode. Inspect files and explain the implementation plan, but do not modify files, run Bash commands, or claim changes are done. Ask before leaving planning mode.",
319
+ display: false,
320
+ },
321
+ ],
322
+ };
323
+ });
324
+ }
325
+
326
+ const createStartupHeaderLines = (theme, firstRun) => {
327
+ const brandLine = theme.bold(theme.fg("accent", "Outfitter")) + theme.fg("dim", " + pi");
328
+ const commandHelp = theme.fg("muted", "/ commands · ! bash · shift+tab mode · ctrl+shift+t thinking · ctrl+o more");
329
+ const lines = [];
330
+
331
+ if (OUTFITTER_STARTUP_ASCII_ART) {
332
+ lines.push(
333
+ ...OUTFITTER_ASCII_ART.split("\n").map((line, index) => theme.fg(OUTFITTER_ASCII_GRADIENT[index] ?? "accent", line)),
334
+ "",
335
+ );
336
+ }
337
+
338
+ lines.push(brandLine, commandHelp);
339
+
340
+ if (firstRun) {
341
+ lines.push(
342
+ "",
343
+ theme.fg("dim", "Outfitter turns Pi into a configured working environment:"),
344
+ theme.fg("dim", "• profiles define model, tools, prompts, skills, and extensions"),
345
+ theme.fg("dim", "• settings can live in your home folder or this project"),
346
+ theme.fg("dim", "• catalogs let teams share setups through GitHub"),
347
+ );
348
+ return lines;
349
+ }
350
+
351
+ lines.push(
352
+ "",
353
+ theme.fg(
354
+ "dim",
355
+ "Outfitter + Pi can explain its own features and look up its docs. Ask it how to use or extend Pi or outfitter profiles.",
356
+ ),
357
+ );
358
+ return lines;
359
+ };
360
+
361
+ const createOutfitterPaths = (join) => ({
362
+ homeSettingsPath: join(OUTFITTER_HOME, ".outfitter", "settings.yml"),
363
+ homeProfilesPath: join(OUTFITTER_HOME, ".outfitter", "profiles"),
364
+ projectSettingsPath: join(OUTFITTER_PROJECT, ".outfitter", "settings.yml"),
365
+ projectProfilesPath: join(OUTFITTER_PROJECT, ".outfitter", "profiles"),
366
+ defaultProfilesPath: OUTFITTER_DEFAULT_PROFILES_PATH,
367
+ });
368
+
369
+ const runDefaultCatalogOnboarding = async (fs, paths, questionUi) => {
370
+ const currentDefault = readCurrentDefaultProfile(paths.homeSettingsPath, fs.existsSync, fs.readFileSync);
371
+ const profiles = discoverProfileChoices(fs, paths, currentDefault);
372
+ if (profiles.length === 0) {
373
+ questionUi.notify(
374
+ "No profiles were found in the default Outfitter profile catalog. Fix the catalog sync or provide a different catalog.",
375
+ "error",
376
+ );
377
+ return;
378
+ }
379
+
380
+ const selectedProfile = await questionUi.selectProfile(profiles, currentDefault);
381
+ if (selectedProfile === undefined) {
382
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
383
+ return;
384
+ }
385
+
386
+ if (!OUTFITTER_PROFILE_ID_PATTERN.test(selectedProfile.id)) {
387
+ questionUi.notify("Selected profile id is not filesystem-safe; no settings were changed.", "error");
388
+ return;
389
+ }
390
+
391
+ const installTarget = await questionUi.selectInstallTarget(paths);
392
+ if (installTarget === undefined) {
393
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
394
+ return;
395
+ }
396
+
397
+ fs.mkdirSync(fs.dirname(installTarget.settingsPath), { recursive: true });
398
+ fs.mkdirSync(installTarget.profilesPath, { recursive: true });
399
+ const settingsExisted = fs.existsSync(installTarget.settingsPath);
400
+ if (settingsExisted) {
401
+ updateExistingSettingsDefaultProfile(installTarget.settingsPath, selectedProfile.id, fs.readFileSync, fs.writeFileSync);
402
+ } else {
403
+ fs.writeFileSync(installTarget.settingsPath, createDefaultSettingsContent(selectedProfile.id));
404
+ }
405
+
406
+ questionUi.notify(
407
+ [
408
+ "Outfitter saved default profile '" + selectedProfile.id + "' to " + installTarget.settingsPath + ".",
409
+ "Profile choices were loaded from the default Outfitter profile catalog, not generated locally.",
410
+ "It applies on the next 'outfitter' launch; restart Outfitter to load the selected profile.",
411
+ ].join("\n"),
412
+ "info",
413
+ );
414
+ };
415
+
416
+ const runCreateProfileOnboarding = async (fs, paths, questionUi) => {
417
+ const profileId = normalizeInputValue(await questionUi.input("Profile id", "my_profile"));
418
+ if (!profileId || !OUTFITTER_PROFILE_ID_PATTERN.test(profileId)) {
419
+ questionUi.notify("Profile id is not filesystem-safe; no settings were changed.", "error");
420
+ return;
421
+ }
422
+ const label = normalizeInputValue(await questionUi.input("Profile label", profileId));
423
+ const installTarget = await questionUi.selectInstallTarget(paths);
424
+ if (installTarget === undefined) {
425
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
426
+ return;
427
+ }
428
+
429
+ fs.mkdirSync(fs.dirname(installTarget.settingsPath), { recursive: true });
430
+ if (fs.existsSync(installTarget.settingsPath)) {
431
+ updateExistingSettingsDefaultProfile(installTarget.settingsPath, profileId, fs.readFileSync, fs.writeFileSync);
432
+ } else {
433
+ fs.writeFileSync(installTarget.settingsPath, createLocalProfileSettingsContent(profileId));
434
+ }
435
+
436
+ const profilePath = fs.join(installTarget.profilesPath, profileId, "profile.yml");
437
+ if (!fs.existsSync(profilePath)) {
438
+ fs.mkdirSync(fs.dirname(profilePath), { recursive: true });
439
+ fs.writeFileSync(profilePath, createUserProfileContent(profileId, label));
440
+ }
441
+
442
+ questionUi.notify(
443
+ [
444
+ "Outfitter created profile '" + profileId + "' at " + profilePath + ".",
445
+ "Outfitter saved settings to " + installTarget.settingsPath + ".",
446
+ "It applies on the next 'outfitter' launch; restart Outfitter to load the selected profile.",
447
+ ].join("\n"),
448
+ "info",
449
+ );
450
+ };
451
+
452
+ const runRemoteSettingsOnboarding = async (fs, paths, questionUi) => {
453
+ const github = normalizeInputValue(await questionUi.input("GitHub catalog repo (owner/repo)", "my_account/outfitter_config"));
454
+ const ref = normalizeInputValue(await questionUi.input("Catalog ref", "main")) || "main";
455
+ const settingsPath = normalizeInputValue(await questionUi.input("Catalog settings path", "settings.yml")) || "settings.yml";
456
+ if (!github || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(github)) {
457
+ questionUi.notify("Catalog repo must use owner/repo syntax; no settings were changed.", "error");
458
+ return;
459
+ }
460
+ if (settingsPath.startsWith("/") || settingsPath.includes("..")) {
461
+ questionUi.notify("Catalog settings path must stay inside the repository; no settings were changed.", "error");
462
+ return;
463
+ }
464
+
465
+ const privateCatalogOnboarding = await loadPrivateCatalogOnboarding();
466
+ const privateCatalogsAlreadyEnabled = privateCatalogOnboarding.readPrivateProfileCatalogsEnabled(fs, paths.homeSettingsPath);
467
+ let privateCatalogAccepted = false;
468
+ if (!privateCatalogsAlreadyEnabled && await privateCatalogOnboarding.classifyGitHubRepositoryVisibility(github) === "private") {
469
+ const accepted = await questionUi.confirmPrivateCatalog(github);
470
+ if (!accepted) {
471
+ questionUi.notify("Private catalog setup was cancelled; no settings were changed.", "warning");
472
+ return;
473
+ }
474
+
475
+ privateCatalogAccepted = true;
476
+ }
477
+
478
+ const installTarget = await questionUi.selectInstallTarget(paths);
479
+ if (installTarget === undefined) {
480
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
481
+ return;
482
+ }
483
+
484
+ if (privateCatalogAccepted) {
485
+ privateCatalogOnboarding.writePrivateProfileCatalogsEnabled(fs, paths.homeSettingsPath);
486
+ }
487
+
488
+ const privateCatalogsEnabled = privateCatalogsAlreadyEnabled || privateCatalogAccepted;
489
+ fs.mkdirSync(fs.dirname(installTarget.settingsPath), { recursive: true });
490
+ fs.writeFileSync(installTarget.settingsPath, createRemoteSettingsContent(github, ref, settingsPath, privateCatalogsEnabled && installTarget.settingsPath === paths.homeSettingsPath));
491
+ questionUi.notify(
492
+ privateCatalogAccepted
493
+ ? "Outfitter enabled private profile catalogs in ~/.outfitter/settings.yml and saved this catalog."
494
+ : [
495
+ "Outfitter saved remote settings catalog to " + installTarget.settingsPath + ".",
496
+ "Run 'outfitter sync' or restart Outfitter after the catalog is reachable.",
497
+ ].join("\n"),
498
+ "info",
499
+ );
500
+ };
501
+
502
+ const runProvidedSourceOnboarding = async (fs, paths, questionUi, sourceUri) => {
503
+ const installTarget = await questionUi.selectInstallTarget(paths);
504
+ if (installTarget === undefined) {
505
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
506
+ return;
507
+ }
508
+
509
+ fs.mkdirSync(fs.dirname(installTarget.settingsPath), { recursive: true });
510
+ fs.writeFileSync(installTarget.settingsPath, createProvidedSourceSettingsContent(sourceUri));
511
+ questionUi.notify(
512
+ [
513
+ "Outfitter saved setup source to " + installTarget.settingsPath + ".",
514
+ "Run 'outfitter sync' or restart Outfitter after the source is reachable.",
515
+ ].join("\n"),
516
+ "info",
517
+ );
518
+ };
519
+
520
+ const normalizeInputValue = (value) => typeof value === "string" ? value.trim() : undefined;
521
+
522
+ const createProvidedSourceSettingsContent = (sourceUri) =>
523
+ ["remote_settings:", " - uri: " + JSON.stringify(sourceUri), " path: settings.yml", ""].join("\n");
524
+
525
+ const readCurrentDefaultProfile = (settingsPath, existsSync, readFileSync) => {
526
+ if (!existsSync(settingsPath)) return undefined;
527
+ const match = /^default_profile:\s*([^\n#]+)/mu.exec(readFileSync(settingsPath, "utf8"));
528
+ return match?.[1]?.trim().replace(/^['"]|['"]$/gu, "");
529
+ };
530
+
531
+ const discoverProfileChoices = (fs, paths, currentDefault) => {
532
+ const discovered = new Map();
533
+ const addProfile = (profile) => {
534
+ if (!profile?.id || !OUTFITTER_PROFILE_ID_PATTERN.test(profile.id)) return;
535
+ const existing = discovered.get(profile.id);
536
+ discovered.set(profile.id, {
537
+ id: profile.id,
538
+ label: profile.label ?? existing?.label,
539
+ description: profile.description ?? existing?.description,
540
+ });
541
+ };
542
+
543
+ for (const profile of readProfilesFromSource(fs, paths.defaultProfilesPath)) addProfile(profile);
544
+
545
+ return [...discovered.values()].sort((left, right) => compareProfiles(left, right, currentDefault));
546
+ };
547
+
548
+ const readProfilesFromSource = (fs, sourcePath) => {
549
+ if (!sourcePath || !fs.existsSync(sourcePath)) return [];
550
+ let entries;
551
+ try {
552
+ entries = fs.readdirSync(sourcePath).sort();
553
+ } catch {
554
+ return [];
555
+ }
556
+
557
+ return entries.flatMap((entryName) => {
558
+ const entryPath = fs.join(sourcePath, entryName);
559
+ let entryStat;
560
+ try {
561
+ entryStat = fs.statSync(entryPath);
562
+ } catch {
563
+ return [];
564
+ }
565
+
566
+ if (entryStat.isDirectory()) {
567
+ const profilePath = fs.join(entryPath, "profile.yml");
568
+ return fs.existsSync(profilePath) ? [readProfileYaml(fs.readFileSync(profilePath, "utf8"), entryName)] : [];
569
+ }
570
+
571
+ if (!entryStat.isFile() || !/\.ya?ml$/u.test(entryName) || entryName === "profile.yml") return [];
572
+ return [readProfileYaml(fs.readFileSync(entryPath, "utf8"), entryName.replace(/\.ya?ml$/u, ""))];
573
+ }).filter((profile) => profile.template !== true);
574
+ };
575
+
576
+ const readProfileYaml = (content, fallbackId) => ({
577
+ id: readYamlString(content, "id") ?? fallbackId,
578
+ label: readYamlString(content, "label"),
579
+ description: readYamlString(content, "description"),
580
+ template: readYamlString(content, "template") === "true",
581
+ });
582
+
583
+ const readYamlString = (content, key) => {
584
+ const match = new RegExp("^" + key + ":\\s*([^\\n#]+)", "mu").exec(content);
585
+ return match?.[1]?.trim().replace(/^['"]|['"]$/gu, "");
586
+ };
587
+
588
+ const compareProfiles = (left, right, currentDefault) => {
589
+ if (currentDefault !== undefined) {
590
+ if (left.id === currentDefault) return -1;
591
+ if (right.id === currentDefault) return 1;
592
+ }
593
+ if (left.id === "founder") return -1;
594
+ if (right.id === "founder") return 1;
595
+ return left.id.localeCompare(right.id);
596
+ };
597
+
598
+ const selectDescribedOption = (ctx, titleLines, items, initialValue) =>
599
+ ctx.ui.custom((tui, theme, _keybindings, done) => {
600
+ let selectedIndex = Math.max(0, items.findIndex((item) => item.value === initialValue));
601
+ const labelWidth = Math.max(...items.map((item) => item.label.length));
602
+ let cachedWidth;
603
+ let cachedLines;
604
+
605
+ const finish = (value) => done(value);
606
+ const refresh = () => {
607
+ cachedWidth = undefined;
608
+ cachedLines = undefined;
609
+ tui.requestRender?.();
610
+ };
611
+ const move = (delta) => {
612
+ selectedIndex = Math.max(0, Math.min(items.length - 1, selectedIndex + delta));
613
+ refresh();
614
+ };
615
+
616
+ const render = (width) => {
617
+ const maxWidth = typeof width === "number" && width > 0 ? width : 120;
618
+ if (cachedLines && cachedWidth === maxWidth) return cachedLines;
619
+
620
+ const lines = [];
621
+ const add = (line) => lines.push(visibleWidth(line) > maxWidth ? truncateToWidth(line, maxWidth) : line);
622
+ const addWrapped = (line, widthForWrap = maxWidth, prefix = "") => {
623
+ for (const wrappedLine of wrapTextWithAnsi(line, Math.max(1, widthForWrap))) {
624
+ add(prefix + wrappedLine);
625
+ }
626
+ };
627
+ const renderSelectedItem = (prefix, label, description) => {
628
+ const baseLine = prefix + label;
629
+ if (!description) {
630
+ add(baseLine);
631
+ return;
632
+ }
633
+
634
+ const inlineDescriptionWidth = maxWidth - visibleWidth(baseLine) - 2;
635
+ const descriptionText = theme.fg("muted", description);
636
+ if (inlineDescriptionWidth >= 30) {
637
+ const [firstLine = "", ...remainingLines] = wrapTextWithAnsi(descriptionText, inlineDescriptionWidth);
638
+ add(baseLine + " " + firstLine);
639
+ const continuationPrefix = " ".repeat(Math.min(maxWidth, visibleWidth(baseLine) + 2));
640
+ for (const line of remainingLines) add(continuationPrefix + line);
641
+ return;
642
+ }
643
+
644
+ add(baseLine);
645
+ addWrapped(descriptionText, maxWidth - 2, " ");
646
+ };
647
+
648
+ add(theme.fg("accent", "─".repeat(maxWidth)));
649
+ titleLines.forEach((line, index) => addWrapped(index === 0 ? theme.fg("text", " " + line) : theme.fg("dim", " " + line)));
650
+ lines.push("");
651
+
652
+ items.forEach((item, index) => {
653
+ const selected = index === selectedIndex;
654
+ const prefix = selected ? theme.fg("accent", "→ ") : " ";
655
+ const paddedLabel = item.label.padEnd(labelWidth);
656
+ const label = selected ? theme.fg("accent", paddedLabel) : paddedLabel;
657
+ renderSelectedItem(prefix, label, selected ? item.description : undefined);
658
+ });
659
+
660
+ lines.push("");
661
+ add(theme.fg("dim", "↑↓ navigate enter select escape/ctrl+c cancel"));
662
+ add(theme.fg("accent", "─".repeat(maxWidth)));
663
+
664
+ cachedWidth = maxWidth;
665
+ cachedLines = lines;
666
+ return lines;
667
+ };
668
+
669
+ return {
670
+ outfitterOptions: items.map((item) => item.label),
671
+ render,
672
+ invalidate: refresh,
673
+ handleInput: (data) => {
674
+ if (matchesKey(data, Key.up)) move(-1);
675
+ else if (matchesKey(data, Key.down)) move(1);
676
+ else if (matchesKey(data, Key.enter)) finish(items[selectedIndex]?.value);
677
+ else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) finish(undefined);
678
+ },
679
+ };
680
+ });
681
+
682
+ const formatProfileLabel = (profile, currentDefault) => {
683
+ const current = profile.id === currentDefault ? " (current)" : "";
684
+ const recommended = currentDefault === undefined && profile.id === "founder" ? " (Recommended)" : "";
685
+ const label = profile.label ? " — " + profile.label : "";
686
+ return profile.id + label + current + recommended;
687
+ };
688
+
689
+ const createDefaultSettingsContent = (profileId) =>
690
+ OUTFITTER_DEFAULT_SETTINGS_TEMPLATE.replace("__OUTFITTER_PROFILE_ID__", profileId);
691
+
692
+ const createLocalProfileSettingsContent = (profileId) =>
693
+ ["default_profile: " + profileId, "profile_sources:", " - path: ./profiles", ""].join("\n");
694
+
695
+ const createRemoteSettingsContent = (github, ref, path, privateCatalogsEnabled = false) =>
696
+ [
697
+ ...(privateCatalogsEnabled ? ["enterprise:", " private_profile_catalogs: true"] : []),
698
+ "remote_settings:",
699
+ " - github: " + github,
700
+ " ref: " + ref,
701
+ " path: " + path,
702
+ "",
703
+ ].join("\n");
704
+
705
+ const updateExistingSettingsDefaultProfile = (settingsPath, profileId, readFileSync, writeFileSync) => {
706
+ const content = readFileSync(settingsPath, "utf8");
707
+ const nextContent = /^default_profile:.*$/mu.test(content)
708
+ ? content.replace(/^default_profile:.*$/gmu, "default_profile: " + profileId)
709
+ : content.replace(/\s*$/u, "\n") + "default_profile: " + profileId + "\n";
710
+ writeFileSync(settingsPath, nextContent);
711
+ };
712
+
713
+ const createUserProfileContent = (profileId, label) =>
714
+ [
715
+ "id: " + profileId,
716
+ "label: " + (label || profileId),
717
+ "description: User-created Outfitter profile.",
718
+ "controls: {}",
719
+ "",
720
+ ].join("\n");