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