@ai-outfitter/outfitter 0.6.1 → 0.7.1

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 (93) hide show
  1. package/LICENSE.md +20 -50
  2. package/README.md +41 -280
  3. package/code/enterprise/LICENSE +35 -0
  4. package/code/enterprise/README.md +7 -0
  5. package/code/enterprise/cli/privateCatalogGate.cjs +134 -0
  6. package/code/enterprise/cli/privateCatalogSettings.cjs +59 -0
  7. package/code/enterprise/pi-extension/privateCatalogOnboarding.js +89 -0
  8. package/code/enterprise/private-catalog-boundary.json +9 -0
  9. package/code/enterprise/privateCatalog.js +24 -0
  10. package/code/enterprise/shared/privateCatalogPolicy.cjs +66 -0
  11. package/dist/agents/AdapterProfileControls.d.ts +2 -2
  12. package/dist/agents/AdapterProfileControls.js +8 -1
  13. package/dist/agents/AdapterProfileControls.js.map +1 -1
  14. package/dist/agents/AgentAdapter.d.ts +11 -0
  15. package/dist/agents/AgentLaunch.d.ts +6 -0
  16. package/dist/agents/AgentLaunch.js +89 -0
  17. package/dist/agents/AgentLaunch.js.map +1 -0
  18. package/dist/agents/claude/ClaudeAdapter.js +18 -3
  19. package/dist/agents/claude/ClaudeAdapter.js.map +1 -1
  20. package/dist/agents/pi/PiAdapter.js +162 -33
  21. package/dist/agents/pi/PiAdapter.js.map +1 -1
  22. package/dist/agents/pi/PiArgs.d.ts +2 -0
  23. package/dist/agents/pi/PiArgs.js +15 -0
  24. package/dist/agents/pi/PiArgs.js.map +1 -0
  25. package/dist/agents/pi/PiExtensionCache.d.ts +4 -0
  26. package/dist/agents/pi/PiExtensionCache.js +105 -0
  27. package/dist/agents/pi/PiExtensionCache.js.map +1 -0
  28. package/dist/cli/commands/PiLoginLaunch.d.ts +9 -2
  29. package/dist/cli/commands/PiLoginLaunch.js +817 -75
  30. package/dist/cli/commands/PiLoginLaunch.js.map +1 -1
  31. package/dist/cli/commands/RunCommand.d.ts +22 -3
  32. package/dist/cli/commands/RunCommand.js +104 -20
  33. package/dist/cli/commands/RunCommand.js.map +1 -1
  34. package/dist/cli/commands/SetupCommand.d.ts +19 -2
  35. package/dist/cli/commands/SetupCommand.js +281 -57
  36. package/dist/cli/commands/SetupCommand.js.map +1 -1
  37. package/dist/cli/commands/SyncCommand.d.ts +19 -1
  38. package/dist/cli/commands/SyncCommand.js +47 -6
  39. package/dist/cli/commands/SyncCommand.js.map +1 -1
  40. package/dist/cli/commands/WelcomeCommand.js +1 -1
  41. package/dist/cli/commands/WelcomeCommand.js.map +1 -1
  42. package/dist/cli/commands/assets/outfitter-ascii.txt +5 -0
  43. package/dist/cli/commands/profile/Command.d.ts +1 -0
  44. package/dist/cli/commands/profile/Command.js +3 -0
  45. package/dist/cli/commands/profile/Command.js.map +1 -1
  46. package/dist/cli/commands/profile/LintCommand.d.ts +19 -0
  47. package/dist/cli/commands/profile/LintCommand.js +123 -0
  48. package/dist/cli/commands/profile/LintCommand.js.map +1 -0
  49. package/dist/cli.js +8 -2
  50. package/dist/cli.js.map +1 -1
  51. package/dist/merge/ArrayMergePolicy.js.map +1 -1
  52. package/dist/merge/SettingsValueMerger.js.map +1 -1
  53. package/dist/profiles/Profile.d.ts +13 -1
  54. package/dist/profiles/Profile.js.map +1 -1
  55. package/dist/profiles/ProfileLoader.d.ts +4 -0
  56. package/dist/profiles/ProfileLoader.js +117 -17
  57. package/dist/profiles/ProfileLoader.js.map +1 -1
  58. package/dist/profiles/ProfileMerger.js +3 -0
  59. package/dist/profiles/ProfileMerger.js.map +1 -1
  60. package/dist/profiles/PromptIncludes.d.ts +32 -0
  61. package/dist/profiles/PromptIncludes.js +147 -0
  62. package/dist/profiles/PromptIncludes.js.map +1 -0
  63. package/dist/prompts/SystemPromptExport.d.ts +16 -0
  64. package/dist/prompts/SystemPromptExport.js +81 -0
  65. package/dist/prompts/SystemPromptExport.js.map +1 -0
  66. package/dist/schemas/profile.schema.json +37 -2
  67. package/dist/schemas/settings.schema.json +23 -0
  68. package/dist/settings/Settings.d.ts +9 -0
  69. package/dist/settings/Settings.js.map +1 -1
  70. package/dist/settings/SettingsLoader.js +5 -0
  71. package/dist/settings/SettingsLoader.js.map +1 -1
  72. package/dist/settings/SettingsMerger.js +11 -0
  73. package/dist/settings/SettingsMerger.js.map +1 -1
  74. package/package.json +7 -11
  75. package/src/schemas/profile.schema.json +37 -2
  76. package/src/schemas/settings.schema.json +23 -0
  77. package/doc/.deepreview +0 -30
  78. package/doc/architecture.md +0 -856
  79. package/doc/controllable-elements.md +0 -162
  80. package/doc/file_structure.md +0 -141
  81. package/doc/integration_test_system.md +0 -214
  82. package/doc/specs/validating_requirements_with_rules.md +0 -55
  83. package/doc/state_writeback_strategy.md +0 -342
  84. package/requirements/OFTR-001-project-foundation.md +0 -53
  85. package/requirements/OFTR-002-settings.md +0 -65
  86. package/requirements/OFTR-003-profiles.md +0 -60
  87. package/requirements/OFTR-004-sync-and-setup.md +0 -67
  88. package/requirements/OFTR-005-run-and-composite-profile.md +0 -60
  89. package/requirements/OFTR-006-agent-adapters.md +0 -66
  90. package/requirements/OFTR-007-controllable-elements.md +0 -32
  91. package/requirements/OFTR-008-requirements-governance.md +0 -42
  92. package/requirements/OFTR-009-release-publishing.md +0 -35
  93. package/requirements/OFTR-010-onboarding-welcome.md +0 -48
@@ -1,73 +1,205 @@
1
- // Adds first-run Pi login startup behavior without handling credentials in Outfitter.
1
+ /* eslint-disable max-lines, complexity */
2
+ // Prepares Pi launch-time bootstrap extensions for Outfitter UX, login, and setup handoffs.
2
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- const outfitterSkillMessage = 'No profile set up. Outfitter will open `/outfitter` automatically so you can configure a profile.';
5
- const manualLoginMessage = 'Pi does not appear to be logged in yet. After Pi starts, run `/login` and choose a subscription such as Codex or provide an API key from another model provider.';
6
- const automaticLoginMessage = 'Pi does not appear to be logged in yet. Outfitter will open `/login` automatically after Pi starts.';
7
- const nonInteractivePiLaunchFlags = new Set(['--print', '-p', '--mode', '--export', '--list-models']);
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createDefaultSettingsContent as createSetupDefaultSettingsContent } from './SetupCommand.js';
7
+ const runtimeLoginMessage = 'Outfitter will ask Pi to open `/login` automatically if Pi reports no available models after startup.';
8
+ const nonInteractivePiLaunchFlags = new Set(['--print', '-p', '--export', '--list-models']);
9
+ const nonInteractivePiModes = new Set(['json', 'print', 'rpc']);
8
10
  export const preparePiLoginLaunchPlan = (input) => {
9
11
  if (input.adapterId !== 'pi') {
10
12
  return input.launchPlan;
11
13
  }
12
14
  // Load the Outfitter runtime extension for every interactive pi session. It brands the
13
- // startup header today and is the home for future Outfitter↔pi integration. The header
14
- // text is compiled into pi, so a launch-time extension is the only repo-local override.
15
- // Non-interactive launches (--print, --export, …) keep pi untouched.
15
+ // startup header, owns Outfitter-specific shortcuts, and registers native /outfitter
16
+ // onboarding after pi has started. Non-interactive launches (--print, --export, …) keep
17
+ // pi untouched and must not prompt, auto-submit commands, or mutate user settings.
16
18
  let launchPlan = input.launchPlan;
17
- if (!isNonInteractivePiLaunch(input.launchPlan.args)) {
18
- launchPlan = addExtension(launchPlan, 'outfitter-extension.js', piOutfitterExtensionContent);
19
- }
20
- if (!hasConfiguredPiLoginState(input.homeDirectory)) {
21
- if (shouldAutoOpenPiLogin(input.setupResult, input.launchPlan.args)) {
22
- writePiLoginMessage(input.writeLine, automaticLoginMessage);
23
- return addExtension(launchPlan, 'prefill-login-extension.js', piLoginPrefillExtensionContent);
24
- }
25
- writePiLoginMessage(input.writeLine, manualLoginMessage);
19
+ const piConfigDirectory = input.launchPlan.env.PI_CODING_AGENT_DIR ?? join(input.homeDirectory, '.pi', 'agent');
20
+ const interactiveLaunch = !isNonInteractivePiLaunch(input.launchPlan.args);
21
+ if (!interactiveLaunch) {
26
22
  return launchPlan;
27
23
  }
28
- if (shouldAutoOpenOutfitterSkill(input.setupResult, input.launchPlan.args)) {
29
- writePiLoginMessage(input.writeLine, outfitterSkillMessage);
30
- return addExtension(launchPlan, 'prefill-outfitter-extension.js', piOutfitterPrefillExtensionContent);
24
+ if (input.runtimeOnboarding?.autoOpenOutfitter === true) {
25
+ writeQuietPiStartupSettings(piConfigDirectory);
26
+ launchPlan = addFirstRunBootstrapModelIfNeeded(launchPlan);
27
+ }
28
+ writePiOutfitterEnterpriseSupportFiles(piConfigDirectory);
29
+ launchPlan = addExtension(launchPlan, piConfigDirectory, 'outfitter-extension.js', createPiOutfitterExtensionContent({
30
+ autoOpenOutfitter: input.runtimeOnboarding?.autoOpenOutfitter === true,
31
+ defaultProfilesPath: input.runtimeOnboarding?.defaultProfilesPath,
32
+ homeDirectory: input.homeDirectory,
33
+ projectDirectory: resolve(input.runtimeOnboarding?.projectDirectory ?? process.cwd()),
34
+ setupSourceUri: input.runtimeOnboarding?.setupSourceUri,
35
+ startupAsciiArt: input.startupAsciiArt ?? true,
36
+ }));
37
+ if (!hasConfiguredPiLoginState(piConfigDirectory)) {
38
+ writePiLaunchMessage(input.writeLine, runtimeLoginMessage);
31
39
  }
32
40
  return launchPlan;
33
41
  };
34
- const addExtension = (launchPlan, fileName, content) => {
35
- const extensionPath = join(launchPlan.env.PI_CODING_AGENT_DIR, 'outfitter', fileName);
42
+ const addFirstRunBootstrapModelIfNeeded = (launchPlan) => {
43
+ if (hasPiModelArg(launchPlan.args)) {
44
+ return launchPlan;
45
+ }
46
+ return { ...launchPlan, args: ['--model', 'google/gemini-3.1-pro-preview', ...launchPlan.args] };
47
+ };
48
+ /* v8 ignore next -- arg parser supports both spellings; integration smoke covers explicit --model passthrough. */
49
+ const hasPiModelArg = (args) => args.some((arg) => arg === '--model' || arg.startsWith('--model='));
50
+ const writeQuietPiStartupSettings = (piConfigDirectory) => {
51
+ const settingsPath = join(piConfigDirectory, 'settings.json');
52
+ mkdirSync(dirname(settingsPath), { recursive: true });
53
+ let settings = {};
54
+ if (existsSync(settingsPath)) {
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(settingsPath, 'utf8'));
57
+ /* v8 ignore next -- defensive against hand-edited Pi settings with non-object JSON. */
58
+ settings =
59
+ parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
60
+ ? parsed
61
+ : {};
62
+ }
63
+ catch {
64
+ settings = {};
65
+ }
66
+ }
67
+ writeFileSync(settingsPath, `${JSON.stringify({ ...settings, quietStartup: true }, null, 2)}\n`);
68
+ };
69
+ const addExtension = (launchPlan, piConfigDirectory, fileName, content) => {
70
+ const extensionPath = join(piConfigDirectory, 'outfitter', fileName);
36
71
  mkdirSync(dirname(extensionPath), { recursive: true });
37
72
  writeFileSync(extensionPath, content);
38
- return { ...launchPlan, args: ['--extension', extensionPath, ...launchPlan.args] };
73
+ return {
74
+ ...launchPlan,
75
+ args: ['--extension', extensionPath, ...launchPlan.args],
76
+ env: { ...launchPlan.env, PI_CODING_AGENT_DIR: piConfigDirectory },
77
+ };
39
78
  };
40
- // The general Outfitter pi extension. Currently brands the startup header with an
41
- // Outfitter + pi line; extend its session_start handler for further integration.
42
- const piOutfitterExtensionContent = `export default function outfitter(pi) {
43
- pi.on("session_start", (_event, ctx) => {
44
- if (ctx.mode !== "tui") return;
45
- ctx.ui.setHeader((_tui, theme) => {
46
- const lines = [
47
- theme.bold(theme.fg("accent", "Outfitter")) + theme.fg("dim", " + pi"),
48
- theme.fg("muted", "/ commands · ! bash · ctrl+o more"),
49
- "",
50
- theme.fg(
51
- "dim",
52
- "Outfitter + Pi can explain its own features and look up its docs. Ask it how to use or extend Pi or outfitter profiles.",
53
- ),
54
- ];
55
- return {
56
- render: () => lines,
57
- invalidate: () => undefined,
58
- };
59
- });
79
+ const writePiOutfitterEnterpriseSupportFiles = (piConfigDirectory) => {
80
+ const extensionDirectory = join(piConfigDirectory, 'outfitter');
81
+ const supportFiles = [
82
+ ['pi-extension/privateCatalogOnboarding.js', 'pi-extension/privateCatalogOnboarding.js'],
83
+ ['shared/privateCatalogPolicy.cjs', 'shared/privateCatalogPolicy.cjs'],
84
+ ];
85
+ for (const [from, to] of supportFiles) {
86
+ const destination = join(extensionDirectory, to);
87
+ mkdirSync(dirname(destination), { recursive: true });
88
+ writeFileSync(destination, readEnterpriseSupportFile(from));
89
+ }
90
+ };
91
+ const readEnterpriseSupportFile = (relativePath) => {
92
+ const sourcePath = fileURLToPath(new URL(`../../../../enterprise/${relativePath}`, import.meta.url));
93
+ const packagePath = fileURLToPath(new URL(`../../../code/enterprise/${relativePath}`, import.meta.url));
94
+ /* v8 ignore else -- packaged npm layout is exercised after build, not unit tests. */
95
+ if (existsSync(sourcePath)) {
96
+ return readFileSync(sourcePath, 'utf8');
97
+ }
98
+ /* v8 ignore next -- packaged npm layout is exercised after build, not unit tests. */
99
+ if (existsSync(packagePath)) {
100
+ return readFileSync(packagePath, 'utf8');
101
+ }
102
+ /* v8 ignore next -- defensive packaging assertion for missing enterprise support files. */
103
+ throw new Error(`Outfitter enterprise support file '${relativePath}' was not found.`);
104
+ };
105
+ // The general Outfitter pi extension. It brands the startup header, owns
106
+ // Outfitter-specific interactive shortcuts, registers native /outfitter, and
107
+ // keeps credential entry delegated to Pi's native /login command.
108
+ const createPiOutfitterExtensionContent = (input) => {
109
+ const defaultSettingsTemplate = createSetupDefaultSettingsContent('__OUTFITTER_PROFILE_ID__');
110
+ const startupAsciiArt = readFileSync(new URL('./assets/outfitter-ascii.txt', import.meta.url), 'utf8').trimEnd();
111
+ return String.raw `import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
112
+
113
+ const OUTFITTER_PLAN_TOOLS = ["read", "grep", "find", "ls"];
114
+ const OUTFITTER_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
115
+ const OUTFITTER_HOME = ${JSON.stringify(input.homeDirectory)};
116
+ const OUTFITTER_PROJECT = ${JSON.stringify(input.projectDirectory)};
117
+ const OUTFITTER_DEFAULT_PROFILES_PATH = ${JSON.stringify(input.defaultProfilesPath)};
118
+ const OUTFITTER_SETUP_SOURCE_URI = ${JSON.stringify(input.setupSourceUri)};
119
+ const OUTFITTER_AUTO_OPEN = ${JSON.stringify(input.autoOpenOutfitter)};
120
+ const OUTFITTER_DEFAULT_SETTINGS_TEMPLATE = ${JSON.stringify(defaultSettingsTemplate)};
121
+ const OUTFITTER_STARTUP_ASCII_ART = ${JSON.stringify(input.startupAsciiArt)};
122
+ const OUTFITTER_ASCII_ART = ${JSON.stringify(startupAsciiArt)};
123
+ const OUTFITTER_ASCII_GRADIENT = ["success", "accent", "text", "muted", "dim"];
124
+ const OUTFITTER_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$/u;
125
+ const loadPrivateCatalogOnboarding = () => import("./pi-extension/privateCatalogOnboarding.js");
126
+
127
+ export default function outfitter(pi) {
128
+ let mode = "build";
129
+ let buildModeTools;
130
+ let loginSubmitted = false;
131
+
132
+ const updateModeStatus = (ctx) => {
133
+ const color = mode === "plan" ? "warning" : "muted";
134
+ ctx.ui.setStatus("outfitter-mode", ctx.ui.theme.fg(color, "mode: " + mode));
135
+ };
136
+
137
+ const enterPlanMode = (ctx) => {
138
+ if (mode !== "plan") {
139
+ buildModeTools = pi.getActiveTools();
140
+ }
141
+ mode = "plan";
142
+ const availableTools = new Set(pi.getAllTools().map((tool) => tool.name));
143
+ const planTools = OUTFITTER_PLAN_TOOLS.filter((toolName) => availableTools.has(toolName));
144
+ pi.setActiveTools(planTools.length > 0 ? planTools : OUTFITTER_PLAN_TOOLS);
145
+ updateModeStatus(ctx);
146
+ ctx.ui.notify("Outfitter mode: plan (read-only tools; Shift+Tab to switch back)", "info");
147
+ };
148
+
149
+ const enterBuildMode = (ctx) => {
150
+ mode = "build";
151
+ pi.setActiveTools(buildModeTools ?? OUTFITTER_DEFAULT_TOOLS);
152
+ buildModeTools = undefined;
153
+ updateModeStatus(ctx);
154
+ ctx.ui.notify("Outfitter mode: build (normal tools; Shift+Tab for plan mode)", "info");
155
+ };
156
+
157
+ const cycleOutfitterMode = (ctx) => {
158
+ if (mode === "plan") {
159
+ enterBuildMode(ctx);
160
+ return;
161
+ }
162
+
163
+ enterPlanMode(ctx);
164
+ };
165
+
166
+ pi.on("project_trust", async (event) => {
167
+ if (!OUTFITTER_AUTO_OPEN || event.cwd !== OUTFITTER_PROJECT) {
168
+ return { trusted: "undecided" };
169
+ }
170
+
171
+ return { trusted: "yes", remember: true };
60
172
  });
61
- }
62
- `;
63
- const piOutfitterPrefillExtensionContent = `export default function outfitterSkillPrefill(pi) {
64
- pi.on("session_start", async (_event, ctx) => {
65
- ctx.ui.setEditorText("/outfitter");
66
- ctx.ui.notify("Outfitter is opening /outfitter to help you set up your profile.", "info");
173
+
174
+ const exportRuntimeSystemPrompt = async (ctx) => {
175
+ const outputPath = typeof process === "undefined" ? undefined : process.env.OUTFITTER_SYSTEM_PROMPT_EXPORT_PATH;
176
+ if (!outputPath || typeof ctx.getSystemPrompt !== "function") return;
177
+
178
+ const systemPrompt = ctx.getSystemPrompt();
179
+ if (typeof systemPrompt !== "string") return;
180
+
181
+ const [{ mkdirSync, writeFileSync }, { dirname }] = await Promise.all([import("node:fs"), import("node:path")]);
182
+ mkdirSync(dirname(outputPath), { recursive: true });
183
+ writeFileSync(
184
+ outputPath,
185
+ [
186
+ "<!-- Generated by Outfitter from Pi runtime ctx.getSystemPrompt(). Safe to review or git-ignore. Do not edit by hand. -->",
187
+ "# Generated Pi runtime system prompt",
188
+ "",
189
+ systemPrompt,
190
+ "",
191
+ ].join("\n"),
192
+ );
193
+ };
194
+
195
+ const submitSlashCommand = async (ctx, command, notification) => {
196
+ if (ctx.mode !== "tui") return false;
197
+ ctx.ui.setEditorText(command);
198
+ if (notification !== undefined) ctx.ui.notify(notification, "info");
67
199
  await ctx.ui.custom((tui, _theme, _keybindings, done) => {
68
200
  setTimeout(() => {
69
- tui.focusedComponent?.handleInput?.("\\r");
70
- done();
201
+ tui.focusedComponent?.handleInput?.("\r");
202
+ done(true);
71
203
  }, 25);
72
204
 
73
205
  return {
@@ -75,45 +207,655 @@ const piOutfitterPrefillExtensionContent = `export default function outfitterSki
75
207
  invalidate: () => undefined,
76
208
  };
77
209
  }, { overlay: true, overlayOptions: { nonCapturing: true, visible: () => false } });
210
+ return true;
211
+ };
212
+
213
+ const getAvailableModelCount = async (ctx) => {
214
+ if (ctx.modelRegistry === undefined || typeof ctx.modelRegistry.getAvailable !== "function") {
215
+ return ctx.model === undefined ? 0 : 1;
216
+ }
217
+
218
+ try {
219
+ const available = await ctx.modelRegistry.getAvailable();
220
+ return Array.isArray(available) ? available.length : 0;
221
+ } catch {
222
+ return ctx.model === undefined ? 0 : 1;
223
+ }
224
+ };
225
+
226
+ const confirmModelProviderConnection = async (ctx) => {
227
+ if (typeof ctx.ui.custom !== "function") return true;
228
+ const selected = await selectDescribedOption(
229
+ ctx,
230
+ [
231
+ "Pi does not have a model provider connected yet.",
232
+ "Connect one now so Outfitter can use Pi.",
233
+ "Credentials stay inside Pi.",
234
+ ],
235
+ [{ value: "connect", label: "Connect a model provider" }],
236
+ "connect",
237
+ );
238
+ return selected === "connect";
239
+ };
240
+
241
+ const openLoginIfNoModels = async (ctx) => {
242
+ if (loginSubmitted || ctx.mode !== "tui") return;
243
+ const availableModelCount = await getAvailableModelCount(ctx);
244
+ if (availableModelCount > 0) return;
245
+ if (!(await confirmModelProviderConnection(ctx))) return;
246
+ loginSubmitted = await submitSlashCommand(ctx, "/login");
247
+ };
248
+
249
+ const createQuestionUi = (ctx) => ({
250
+ async selectSetupMode() {
251
+ const options = [
252
+ "Use the default Outfitter profile catalog",
253
+ "Create your own profile",
254
+ "Provide a different catalog to import",
255
+ ];
256
+ const selected = await ctx.ui.select("How would you like to set up Outfitter?", options);
257
+ if (selected === undefined) return undefined;
258
+ return options.indexOf(selected) === 1 ? "create" : options.indexOf(selected) === 2 ? "catalog" : "default";
259
+ },
260
+ async selectInstallTarget(paths) {
261
+ const items = [
262
+ {
263
+ value: "home",
264
+ label: "Home folder (~/.outfitter)",
265
+ description: "These profiles will be available anywhere you start outfitter.",
266
+ },
267
+ {
268
+ value: "project",
269
+ label: "Current project directory (.outfitter)",
270
+ 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.",
271
+ },
272
+ ];
273
+ const title = ["Where should Outfitter install these settings?"];
274
+ const selected = typeof ctx.ui.custom === "function"
275
+ ? await selectDescribedOption(ctx, title, items, "home")
276
+ : await ctx.ui.select(title.join("\n"), items.map((item) => item.label));
277
+ if (selected === undefined) return undefined;
278
+ const selectedValue = items.some((item) => item.value === selected)
279
+ ? selected
280
+ : items.find((item) => item.label === selected)?.value;
281
+ return selectedValue === "project"
282
+ ? { id: "project", settingsPath: paths.projectSettingsPath, profilesPath: paths.projectProfilesPath }
283
+ : { id: "home", settingsPath: paths.homeSettingsPath, profilesPath: paths.homeProfilesPath };
284
+ },
285
+ async selectProfile(profiles, currentDefault) {
286
+ const items = profiles.map((profile) => ({
287
+ value: profile.id,
288
+ label: formatProfileLabel(profile, currentDefault),
289
+ description: profile.description,
290
+ }));
291
+ const title = [
292
+ "Outfitter profile setup",
293
+ "",
294
+ "Choose the default profile from the selected catalog for future 'outfitter' launches.",
295
+ "The current Pi process keeps the profile it started with; this setting applies on the next launch.",
296
+ ];
297
+ const initialProfileId = currentDefault ?? (profiles.some((profile) => profile.id === "founder") ? "founder" : profiles[0]?.id);
298
+ const selectedId = typeof ctx.ui.custom === "function"
299
+ ? await selectDescribedOption(ctx, title, items, initialProfileId)
300
+ : await ctx.ui.select(title.join("\n"), items.map((item) => item.label));
301
+ if (selectedId === undefined) return undefined;
302
+ return profiles.find((profile) => profile.id === selectedId) ?? profiles[items.findIndex((item) => item.label === selectedId)];
303
+ },
304
+ async input(message, defaultValue) {
305
+ if (typeof ctx.ui.input === "function") {
306
+ return ctx.ui.input(message, defaultValue === undefined ? undefined : { defaultValue });
307
+ }
308
+ const suffix = defaultValue === undefined ? "" : " [" + defaultValue + "]";
309
+ const selected = await ctx.ui.select(message + suffix, [defaultValue ?? ""]);
310
+ return selected;
311
+ },
312
+ async confirmPrivateCatalog(repository) {
313
+ const { confirmPrivateCatalog } = await loadPrivateCatalogOnboarding();
314
+ return confirmPrivateCatalog(ctx, selectDescribedOption, repository);
315
+ },
316
+ notify: (message, type = "info") => ctx.ui.notify(message, type),
78
317
  });
79
- }
80
- `;
81
- const piLoginPrefillExtensionContent = `export default function outfitterLoginPrefill(pi) {
82
- pi.on("session_start", async (_event, ctx) => {
83
- ctx.ui.setEditorText("/login");
84
- ctx.ui.notify("Outfitter is opening /login so you can choose a provider.", "info");
85
- await ctx.ui.custom((tui, _theme, _keybindings, done) => {
86
- setTimeout(() => {
87
- tui.focusedComponent?.handleInput?.("\\r");
88
- done();
89
- }, 25);
90
318
 
319
+ const runOutfitterOnboarding = async (ctx) => {
320
+ if (!ctx.hasUI) return;
321
+ const [{ mkdirSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync }, { dirname, join }] =
322
+ await Promise.all([import("node:fs"), import("node:path")]);
323
+ const paths = createOutfitterPaths(join);
324
+ const questionUi = createQuestionUi(ctx);
325
+
326
+ if (OUTFITTER_SETUP_SOURCE_URI !== undefined) {
327
+ await runProvidedSourceOnboarding({ mkdirSync, writeFileSync, dirname }, paths, questionUi, OUTFITTER_SETUP_SOURCE_URI);
328
+ await openLoginIfNoModels(ctx);
329
+ return;
330
+ }
331
+
332
+ const setupMode = await questionUi.selectSetupMode();
333
+
334
+ if (setupMode === undefined) {
335
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
336
+ await openLoginIfNoModels(ctx);
337
+ return;
338
+ }
339
+
340
+ if (setupMode === "catalog") {
341
+ await runRemoteSettingsOnboarding({ existsSync, mkdirSync, readFileSync, writeFileSync, dirname }, paths, questionUi);
342
+ await openLoginIfNoModels(ctx);
343
+ return;
344
+ }
345
+
346
+ if (setupMode === "create") {
347
+ await runCreateProfileOnboarding({ existsSync, mkdirSync, readFileSync, writeFileSync, dirname, join }, paths, questionUi);
348
+ await openLoginIfNoModels(ctx);
349
+ return;
350
+ }
351
+
352
+ await runDefaultCatalogOnboarding(
353
+ { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, dirname, join },
354
+ paths,
355
+ questionUi,
356
+ );
357
+ await openLoginIfNoModels(ctx);
358
+ };
359
+
360
+ pi.registerCommand("outfitter", {
361
+ description: "Configure Outfitter profile onboarding",
362
+ handler: async (_args, ctx) => {
363
+ await runOutfitterOnboarding(ctx);
364
+ },
365
+ });
366
+
367
+ pi.registerCommand("mode", {
368
+ description: "Toggle Outfitter build/plan mode",
369
+ handler: async (_args, ctx) => {
370
+ if (ctx.mode !== "tui") return;
371
+ cycleOutfitterMode(ctx);
372
+ },
373
+ });
374
+
375
+ pi.on("session_start", async (event, ctx) => {
376
+ await exportRuntimeSystemPrompt(ctx);
377
+ if (ctx.mode !== "tui") return;
378
+ ctx.ui.setHeader((_tui, theme) => {
379
+ const lines = createStartupHeaderLines(theme, event.reason === "startup" && OUTFITTER_AUTO_OPEN);
91
380
  return {
92
- render: () => [],
381
+ render: () => lines,
93
382
  invalidate: () => undefined,
94
383
  };
95
- }, { overlay: true, overlayOptions: { nonCapturing: true, visible: () => false } });
384
+ });
385
+ updateModeStatus(ctx);
386
+ ctx.ui.onTerminalInput((data) => {
387
+ if (!matchesKey(data, "shift+tab")) return undefined;
388
+ cycleOutfitterMode(ctx);
389
+ return { consume: true };
390
+ });
391
+
392
+ if (event.reason === "startup" && OUTFITTER_AUTO_OPEN) {
393
+ await submitSlashCommand(ctx, "/outfitter");
394
+ return;
395
+ }
396
+
397
+ await openLoginIfNoModels(ctx);
398
+ });
399
+
400
+ pi.on("tool_call", async (event) => {
401
+ if (mode !== "plan" || event.toolName !== "bash") return;
402
+
403
+ return {
404
+ block: true,
405
+ reason: "Outfitter plan mode blocks Bash commands. Press Shift+Tab to return to build mode. Command: " + String(event.input?.command ?? ""),
406
+ };
407
+ });
408
+
409
+ pi.on("context", async (event) => {
410
+ const messages = event.messages.filter((message) => message.customType !== "outfitter-mode-context");
411
+
412
+ if (mode !== "plan") {
413
+ return { messages };
414
+ }
415
+
416
+ return {
417
+ messages: [
418
+ ...messages,
419
+ {
420
+ role: "custom",
421
+ customType: "outfitter-mode-context",
422
+ content:
423
+ "[OUTFITTER PLAN MODE ACTIVE]\n" +
424
+ "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.",
425
+ display: false,
426
+ },
427
+ ],
428
+ };
96
429
  });
97
430
  }
431
+
432
+ const createStartupHeaderLines = (theme, firstRun) => {
433
+ const brandLine = theme.bold(theme.fg("accent", "Outfitter")) + theme.fg("dim", " + pi");
434
+ const commandHelp = theme.fg("muted", "/ commands · ! bash · shift+tab mode · ctrl+shift+t thinking · ctrl+o more");
435
+ const lines = [];
436
+
437
+ if (OUTFITTER_STARTUP_ASCII_ART) {
438
+ lines.push(
439
+ ...OUTFITTER_ASCII_ART.split("\n").map((line, index) => theme.fg(OUTFITTER_ASCII_GRADIENT[index] ?? "accent", line)),
440
+ "",
441
+ );
442
+ }
443
+
444
+ lines.push(brandLine, commandHelp);
445
+
446
+ if (firstRun) {
447
+ lines.push(
448
+ "",
449
+ theme.fg("dim", "Outfitter turns Pi into a configured working environment:"),
450
+ theme.fg("dim", "• profiles define model, tools, prompts, skills, and extensions"),
451
+ theme.fg("dim", "• settings can live in your home folder or this project"),
452
+ theme.fg("dim", "• catalogs let teams share setups through GitHub"),
453
+ );
454
+ return lines;
455
+ }
456
+
457
+ lines.push(
458
+ "",
459
+ theme.fg(
460
+ "dim",
461
+ "Outfitter + Pi can explain its own features and look up its docs. Ask it how to use or extend Pi or outfitter profiles.",
462
+ ),
463
+ );
464
+ return lines;
465
+ };
466
+
467
+ const createOutfitterPaths = (join) => ({
468
+ homeSettingsPath: join(OUTFITTER_HOME, ".outfitter", "settings.yml"),
469
+ homeProfilesPath: join(OUTFITTER_HOME, ".outfitter", "profiles"),
470
+ projectSettingsPath: join(OUTFITTER_PROJECT, ".outfitter", "settings.yml"),
471
+ projectProfilesPath: join(OUTFITTER_PROJECT, ".outfitter", "profiles"),
472
+ defaultProfilesPath: OUTFITTER_DEFAULT_PROFILES_PATH,
473
+ });
474
+
475
+ const runDefaultCatalogOnboarding = async (fs, paths, questionUi) => {
476
+ const currentDefault = readCurrentDefaultProfile(paths.homeSettingsPath, fs.existsSync, fs.readFileSync);
477
+ const profiles = discoverProfileChoices(fs, paths, currentDefault);
478
+ if (profiles.length === 0) {
479
+ questionUi.notify(
480
+ "No profiles were found in the default Outfitter profile catalog. Fix the catalog sync or provide a different catalog.",
481
+ "error",
482
+ );
483
+ return;
484
+ }
485
+
486
+ const selectedProfile = await questionUi.selectProfile(profiles, currentDefault);
487
+ if (selectedProfile === undefined) {
488
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
489
+ return;
490
+ }
491
+
492
+ if (!OUTFITTER_PROFILE_ID_PATTERN.test(selectedProfile.id)) {
493
+ questionUi.notify("Selected profile id is not filesystem-safe; no settings were changed.", "error");
494
+ return;
495
+ }
496
+
497
+ const installTarget = await questionUi.selectInstallTarget(paths);
498
+ if (installTarget === undefined) {
499
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
500
+ return;
501
+ }
502
+
503
+ fs.mkdirSync(fs.dirname(installTarget.settingsPath), { recursive: true });
504
+ const settingsExisted = fs.existsSync(installTarget.settingsPath);
505
+ if (settingsExisted) {
506
+ updateExistingSettingsDefaultProfile(installTarget.settingsPath, selectedProfile.id, fs.readFileSync, fs.writeFileSync);
507
+ } else {
508
+ fs.writeFileSync(installTarget.settingsPath, createDefaultSettingsContent(selectedProfile.id));
509
+ }
510
+
511
+ questionUi.notify(
512
+ [
513
+ "Outfitter saved default profile '" + selectedProfile.id + "' to " + installTarget.settingsPath + ".",
514
+ "Profile choices were loaded from the default Outfitter profile catalog, not generated locally.",
515
+ "It applies on the next 'outfitter' launch; restart Outfitter to load the selected profile.",
516
+ ].join("\n"),
517
+ "info",
518
+ );
519
+ };
520
+
521
+ const runCreateProfileOnboarding = async (fs, paths, questionUi) => {
522
+ const profileId = normalizeInputValue(await questionUi.input("Profile id", "my_profile"));
523
+ if (!profileId || !OUTFITTER_PROFILE_ID_PATTERN.test(profileId)) {
524
+ questionUi.notify("Profile id is not filesystem-safe; no settings were changed.", "error");
525
+ return;
526
+ }
527
+ const label = normalizeInputValue(await questionUi.input("Profile label", profileId));
528
+ const installTarget = await questionUi.selectInstallTarget(paths);
529
+ if (installTarget === undefined) {
530
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
531
+ return;
532
+ }
533
+
534
+ fs.mkdirSync(fs.dirname(installTarget.settingsPath), { recursive: true });
535
+ if (fs.existsSync(installTarget.settingsPath)) {
536
+ updateExistingSettingsDefaultProfile(installTarget.settingsPath, profileId, fs.readFileSync, fs.writeFileSync);
537
+ } else {
538
+ fs.writeFileSync(installTarget.settingsPath, createLocalProfileSettingsContent(profileId));
539
+ }
540
+
541
+ const profilePath = fs.join(installTarget.profilesPath, profileId, "profile.yml");
542
+ if (!fs.existsSync(profilePath)) {
543
+ fs.mkdirSync(fs.dirname(profilePath), { recursive: true });
544
+ fs.writeFileSync(profilePath, createUserProfileContent(profileId, label));
545
+ }
546
+
547
+ questionUi.notify(
548
+ [
549
+ "Outfitter created profile '" + profileId + "' at " + profilePath + ".",
550
+ "Outfitter saved settings to " + installTarget.settingsPath + ".",
551
+ "It applies on the next 'outfitter' launch; restart Outfitter to load the selected profile.",
552
+ ].join("\n"),
553
+ "info",
554
+ );
555
+ };
556
+
557
+ const runRemoteSettingsOnboarding = async (fs, paths, questionUi) => {
558
+ const github = normalizeInputValue(await questionUi.input("GitHub catalog repo (owner/repo)", "my_account/outfitter_config"));
559
+ const ref = normalizeInputValue(await questionUi.input("Catalog ref", "main")) || "main";
560
+ const settingsPath = normalizeInputValue(await questionUi.input("Catalog settings path", "settings.yml")) || "settings.yml";
561
+ if (!github || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(github)) {
562
+ questionUi.notify("Catalog repo must use owner/repo syntax; no settings were changed.", "error");
563
+ return;
564
+ }
565
+ if (settingsPath.startsWith("/") || settingsPath.includes("..")) {
566
+ questionUi.notify("Catalog settings path must stay inside the repository; no settings were changed.", "error");
567
+ return;
568
+ }
569
+
570
+ const privateCatalogOnboarding = await loadPrivateCatalogOnboarding();
571
+ const privateCatalogsAlreadyEnabled = privateCatalogOnboarding.readPrivateProfileCatalogsEnabled(fs, paths.homeSettingsPath);
572
+ let privateCatalogAccepted = false;
573
+ if (!privateCatalogsAlreadyEnabled && await privateCatalogOnboarding.classifyGitHubRepositoryVisibility(github) === "private") {
574
+ const accepted = await questionUi.confirmPrivateCatalog(github);
575
+ if (!accepted) {
576
+ questionUi.notify("Private catalog setup was cancelled; no settings were changed.", "warning");
577
+ return;
578
+ }
579
+
580
+ privateCatalogAccepted = true;
581
+ }
582
+
583
+ const installTarget = await questionUi.selectInstallTarget(paths);
584
+ if (installTarget === undefined) {
585
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
586
+ return;
587
+ }
588
+
589
+ if (privateCatalogAccepted) {
590
+ privateCatalogOnboarding.writePrivateProfileCatalogsEnabled(fs, paths.homeSettingsPath);
591
+ }
592
+
593
+ const privateCatalogsEnabled = privateCatalogsAlreadyEnabled || privateCatalogAccepted;
594
+ fs.mkdirSync(fs.dirname(installTarget.settingsPath), { recursive: true });
595
+ fs.writeFileSync(installTarget.settingsPath, createRemoteSettingsContent(github, ref, settingsPath, privateCatalogsEnabled && installTarget.settingsPath === paths.homeSettingsPath));
596
+ questionUi.notify(
597
+ privateCatalogAccepted
598
+ ? "Outfitter enabled private profile catalogs in ~/.outfitter/settings.yml and saved this catalog."
599
+ : [
600
+ "Outfitter saved remote settings catalog to " + installTarget.settingsPath + ".",
601
+ "Run 'outfitter sync' or restart Outfitter after the catalog is reachable.",
602
+ ].join("\n"),
603
+ "info",
604
+ );
605
+ };
606
+
607
+ const runProvidedSourceOnboarding = async (fs, paths, questionUi, sourceUri) => {
608
+ const installTarget = await questionUi.selectInstallTarget(paths);
609
+ if (installTarget === undefined) {
610
+ questionUi.notify("Outfitter setup cancelled; no settings were changed.", "warning");
611
+ return;
612
+ }
613
+
614
+ fs.mkdirSync(fs.dirname(installTarget.settingsPath), { recursive: true });
615
+ fs.writeFileSync(installTarget.settingsPath, createProvidedSourceSettingsContent(sourceUri));
616
+ questionUi.notify(
617
+ [
618
+ "Outfitter saved setup source to " + installTarget.settingsPath + ".",
619
+ "Run 'outfitter sync' or restart Outfitter after the source is reachable.",
620
+ ].join("\n"),
621
+ "info",
622
+ );
623
+ };
624
+
625
+ const normalizeInputValue = (value) => typeof value === "string" ? value.trim() : undefined;
626
+
627
+ const createProvidedSourceSettingsContent = (sourceUri) =>
628
+ ["remote_settings:", " - uri: " + JSON.stringify(sourceUri), " path: settings.yml", ""].join("\n");
629
+
630
+ const readCurrentDefaultProfile = (settingsPath, existsSync, readFileSync) => {
631
+ if (!existsSync(settingsPath)) return undefined;
632
+ const match = /^default_profile:\s*([^\n#]+)/mu.exec(readFileSync(settingsPath, "utf8"));
633
+ return match?.[1]?.trim().replace(/^['"]|['"]$/gu, "");
634
+ };
635
+
636
+ const discoverProfileChoices = (fs, paths, currentDefault) => {
637
+ const discovered = new Map();
638
+ const addProfile = (profile) => {
639
+ if (!profile?.id || !OUTFITTER_PROFILE_ID_PATTERN.test(profile.id)) return;
640
+ const existing = discovered.get(profile.id);
641
+ discovered.set(profile.id, {
642
+ id: profile.id,
643
+ label: profile.label ?? existing?.label,
644
+ description: profile.description ?? existing?.description,
645
+ });
646
+ };
647
+
648
+ for (const profile of readProfilesFromSource(fs, paths.defaultProfilesPath)) addProfile(profile);
649
+
650
+ return [...discovered.values()].sort((left, right) => compareProfiles(left, right, currentDefault));
651
+ };
652
+
653
+ const readProfilesFromSource = (fs, sourcePath) => {
654
+ if (!sourcePath || !fs.existsSync(sourcePath)) return [];
655
+ let entries;
656
+ try {
657
+ entries = fs.readdirSync(sourcePath).sort();
658
+ } catch {
659
+ return [];
660
+ }
661
+
662
+ return entries.flatMap((entryName) => {
663
+ const entryPath = fs.join(sourcePath, entryName);
664
+ let entryStat;
665
+ try {
666
+ entryStat = fs.statSync(entryPath);
667
+ } catch {
668
+ return [];
669
+ }
670
+
671
+ if (entryStat.isDirectory()) {
672
+ const profilePath = fs.join(entryPath, "profile.yml");
673
+ return fs.existsSync(profilePath) ? [readProfileYaml(fs.readFileSync(profilePath, "utf8"), entryName)] : [];
674
+ }
675
+
676
+ if (!entryStat.isFile() || !/\.ya?ml$/u.test(entryName) || entryName === "profile.yml") return [];
677
+ return [readProfileYaml(fs.readFileSync(entryPath, "utf8"), entryName.replace(/\.ya?ml$/u, ""))];
678
+ }).filter((profile) => profile.template !== true);
679
+ };
680
+
681
+ const readProfileYaml = (content, fallbackId) => ({
682
+ id: readYamlString(content, "id") ?? fallbackId,
683
+ label: readYamlString(content, "label"),
684
+ description: readYamlString(content, "description"),
685
+ template: readYamlString(content, "template") === "true",
686
+ });
687
+
688
+ const readYamlString = (content, key) => {
689
+ const match = new RegExp("^" + key + ":\\s*([^\\n#]+)", "mu").exec(content);
690
+ return match?.[1]?.trim().replace(/^['"]|['"]$/gu, "");
691
+ };
692
+
693
+ const compareProfiles = (left, right, currentDefault) => {
694
+ if (currentDefault !== undefined) {
695
+ if (left.id === currentDefault) return -1;
696
+ if (right.id === currentDefault) return 1;
697
+ }
698
+ if (left.id === "founder") return -1;
699
+ if (right.id === "founder") return 1;
700
+ return left.id.localeCompare(right.id);
701
+ };
702
+
703
+ const selectDescribedOption = (ctx, titleLines, items, initialValue) =>
704
+ ctx.ui.custom((tui, theme, _keybindings, done) => {
705
+ let selectedIndex = Math.max(0, items.findIndex((item) => item.value === initialValue));
706
+ const labelWidth = Math.max(...items.map((item) => item.label.length));
707
+ let cachedWidth;
708
+ let cachedLines;
709
+
710
+ const finish = (value) => done(value);
711
+ const refresh = () => {
712
+ cachedWidth = undefined;
713
+ cachedLines = undefined;
714
+ tui.requestRender?.();
715
+ };
716
+ const move = (delta) => {
717
+ selectedIndex = Math.max(0, Math.min(items.length - 1, selectedIndex + delta));
718
+ refresh();
719
+ };
720
+
721
+ const render = (width) => {
722
+ const maxWidth = typeof width === "number" && width > 0 ? width : 120;
723
+ if (cachedLines && cachedWidth === maxWidth) return cachedLines;
724
+
725
+ const lines = [];
726
+ const add = (line) => lines.push(visibleWidth(line) > maxWidth ? truncateToWidth(line, maxWidth) : line);
727
+ const addWrapped = (line, widthForWrap = maxWidth, prefix = "") => {
728
+ for (const wrappedLine of wrapTextWithAnsi(line, Math.max(1, widthForWrap))) {
729
+ add(prefix + wrappedLine);
730
+ }
731
+ };
732
+ const renderSelectedItem = (prefix, label, description) => {
733
+ const baseLine = prefix + label;
734
+ if (!description) {
735
+ add(baseLine);
736
+ return;
737
+ }
738
+
739
+ const inlineDescriptionWidth = maxWidth - visibleWidth(baseLine) - 2;
740
+ const descriptionText = theme.fg("muted", description);
741
+ if (inlineDescriptionWidth >= 30) {
742
+ const [firstLine = "", ...remainingLines] = wrapTextWithAnsi(descriptionText, inlineDescriptionWidth);
743
+ add(baseLine + " " + firstLine);
744
+ const continuationPrefix = " ".repeat(Math.min(maxWidth, visibleWidth(baseLine) + 2));
745
+ for (const line of remainingLines) add(continuationPrefix + line);
746
+ return;
747
+ }
748
+
749
+ add(baseLine);
750
+ addWrapped(descriptionText, maxWidth - 2, " ");
751
+ };
752
+
753
+ add(theme.fg("accent", "─".repeat(maxWidth)));
754
+ titleLines.forEach((line, index) => addWrapped(index === 0 ? theme.fg("text", " " + line) : theme.fg("dim", " " + line)));
755
+ lines.push("");
756
+
757
+ items.forEach((item, index) => {
758
+ const selected = index === selectedIndex;
759
+ const prefix = selected ? theme.fg("accent", "→ ") : " ";
760
+ const paddedLabel = item.label.padEnd(labelWidth);
761
+ const label = selected ? theme.fg("accent", paddedLabel) : paddedLabel;
762
+ renderSelectedItem(prefix, label, selected ? item.description : undefined);
763
+ });
764
+
765
+ lines.push("");
766
+ add(theme.fg("dim", "↑↓ navigate enter select escape/ctrl+c cancel"));
767
+ add(theme.fg("accent", "─".repeat(maxWidth)));
768
+
769
+ cachedWidth = maxWidth;
770
+ cachedLines = lines;
771
+ return lines;
772
+ };
773
+
774
+ return {
775
+ outfitterOptions: items.map((item) => item.label),
776
+ render,
777
+ invalidate: refresh,
778
+ handleInput: (data) => {
779
+ if (matchesKey(data, Key.up)) move(-1);
780
+ else if (matchesKey(data, Key.down)) move(1);
781
+ else if (matchesKey(data, Key.enter)) finish(items[selectedIndex]?.value);
782
+ else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) finish(undefined);
783
+ },
784
+ };
785
+ });
786
+
787
+ const formatProfileLabel = (profile, currentDefault) => {
788
+ const current = profile.id === currentDefault ? " (current)" : "";
789
+ const recommended = currentDefault === undefined && profile.id === "founder" ? " (Recommended)" : "";
790
+ const label = profile.label ? " — " + profile.label : "";
791
+ return profile.id + label + current + recommended;
792
+ };
793
+
794
+ const createDefaultSettingsContent = (profileId) =>
795
+ OUTFITTER_DEFAULT_SETTINGS_TEMPLATE.replace("__OUTFITTER_PROFILE_ID__", profileId);
796
+
797
+ const createLocalProfileSettingsContent = (profileId) =>
798
+ ["default_profile: " + profileId, "profile_sources:", " - path: ./profiles", ""].join("\n");
799
+
800
+ const createRemoteSettingsContent = (github, ref, path, privateCatalogsEnabled = false) =>
801
+ [
802
+ ...(privateCatalogsEnabled ? ["enterprise:", " private_profile_catalogs: true"] : []),
803
+ "remote_settings:",
804
+ " - github: " + github,
805
+ " ref: " + ref,
806
+ " path: " + path,
807
+ "",
808
+ ].join("\n");
809
+
810
+ const updateExistingSettingsDefaultProfile = (settingsPath, profileId, readFileSync, writeFileSync) => {
811
+ const content = readFileSync(settingsPath, "utf8");
812
+ const nextContent = /^default_profile:.*$/mu.test(content)
813
+ ? content.replace(/^default_profile:.*$/gmu, "default_profile: " + profileId)
814
+ : content.replace(/\s*$/u, "\n") + "default_profile: " + profileId + "\n";
815
+ writeFileSync(settingsPath, nextContent);
816
+ };
817
+
818
+ const createUserProfileContent = (profileId, label) =>
819
+ [
820
+ "id: " + profileId,
821
+ "label: " + (label || profileId),
822
+ "description: User-created Outfitter profile.",
823
+ "controls: {}",
824
+ "",
825
+ ].join("\n");
98
826
  `;
99
- const writePiLoginMessage = (writeLine, message) => {
100
- /* v8 ignore next -- console fallback is direct CLI behavior; tests inject a writer for login messages. */
827
+ };
828
+ const writePiLaunchMessage = (writeLine, message) => {
829
+ /* v8 ignore next -- console fallback is direct CLI behavior; tests inject a writer for launch messages. */
101
830
  (writeLine ?? console.log)(message);
102
831
  };
103
- const shouldAutoOpenOutfitterSkill = (setupResult, args) => setupResult?.welcomeResult?.answered === false && !isNonInteractivePiLaunch(args);
104
- const shouldAutoOpenPiLogin = (setupResult, args) => setupResult?.welcomeResult !== undefined && !isNonInteractivePiLaunch(args);
105
- const isNonInteractivePiLaunch = (args) => args.some((arg) => nonInteractivePiLaunchFlags.has(arg));
106
- const hasConfiguredPiLoginState = (homeDirectory) => hasConfiguredPiStateFile(homeDirectory, 'auth.json') || hasConfiguredPiStateFile(homeDirectory, 'models.json');
107
- const hasConfiguredPiStateFile = (homeDirectory, fileName) => {
108
- const statePath = join(homeDirectory, '.pi', 'agent', fileName);
832
+ export const isNonInteractivePiLaunch = (args) => args.some((arg, index) => {
833
+ if (nonInteractivePiLaunchFlags.has(arg)) {
834
+ return true;
835
+ }
836
+ if (arg === '--mode') {
837
+ return nonInteractivePiModes.has(args[index + 1] ?? '');
838
+ }
839
+ if (arg.startsWith('--mode=')) {
840
+ return nonInteractivePiModes.has(arg.slice('--mode='.length));
841
+ }
842
+ return false;
843
+ });
844
+ const hasConfiguredPiLoginState = (piConfigDirectory) => hasConfiguredPiStateFile(piConfigDirectory, 'auth.json') ||
845
+ hasConfiguredPiStateFile(piConfigDirectory, 'models.json');
846
+ const hasConfiguredPiStateFile = (piConfigDirectory, fileName) => {
847
+ const statePath = join(piConfigDirectory, fileName);
109
848
  if (!existsSync(statePath)) {
110
849
  return false;
111
850
  }
112
851
  try {
113
852
  return hasConfiguredPiStateEntries(JSON.parse(readFileSync(statePath, 'utf8')));
114
853
  }
115
- catch {
116
- return false;
854
+ catch (error) {
855
+ if (error instanceof SyntaxError) {
856
+ return false;
857
+ }
858
+ throw new Error(`Could not read pi login state file '${statePath}': ${String(error)}`, { cause: error });
117
859
  }
118
860
  };
119
861
  const hasConfiguredPiStateEntries = (value) => {