@ai-outfitter/outfitter 0.6.1 → 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.
- package/LICENSE.md +20 -50
- package/README.md +36 -280
- package/code/enterprise/LICENSE +35 -0
- package/code/enterprise/README.md +5 -0
- package/dist/agents/AdapterProfileControls.d.ts +2 -2
- package/dist/agents/AdapterProfileControls.js +8 -1
- package/dist/agents/AdapterProfileControls.js.map +1 -1
- package/dist/agents/AgentAdapter.d.ts +10 -0
- package/dist/agents/AgentLaunch.d.ts +6 -0
- package/dist/agents/AgentLaunch.js +89 -0
- package/dist/agents/AgentLaunch.js.map +1 -0
- package/dist/agents/claude/ClaudeAdapter.js +18 -3
- package/dist/agents/claude/ClaudeAdapter.js.map +1 -1
- package/dist/agents/pi/PiAdapter.js +154 -14
- package/dist/agents/pi/PiAdapter.js.map +1 -1
- package/dist/cli/commands/PiLoginLaunch.d.ts +8 -2
- package/dist/cli/commands/PiLoginLaunch.js +726 -75
- package/dist/cli/commands/PiLoginLaunch.js.map +1 -1
- package/dist/cli/commands/RunCommand.d.ts +20 -3
- package/dist/cli/commands/RunCommand.js +102 -20
- package/dist/cli/commands/RunCommand.js.map +1 -1
- package/dist/cli/commands/SetupCommand.d.ts +11 -2
- package/dist/cli/commands/SetupCommand.js +266 -52
- package/dist/cli/commands/SetupCommand.js.map +1 -1
- package/dist/cli/commands/SyncCommand.d.ts +8 -1
- package/dist/cli/commands/SyncCommand.js +2 -1
- package/dist/cli/commands/SyncCommand.js.map +1 -1
- package/dist/cli/commands/WelcomeCommand.js +1 -1
- package/dist/cli/commands/WelcomeCommand.js.map +1 -1
- package/dist/cli/commands/assets/outfitter-ascii.txt +5 -0
- package/dist/cli/commands/profile/Command.d.ts +1 -0
- package/dist/cli/commands/profile/Command.js +3 -0
- package/dist/cli/commands/profile/Command.js.map +1 -1
- package/dist/cli/commands/profile/LintCommand.d.ts +19 -0
- package/dist/cli/commands/profile/LintCommand.js +123 -0
- package/dist/cli/commands/profile/LintCommand.js.map +1 -0
- package/dist/cli.js +8 -2
- package/dist/cli.js.map +1 -1
- package/dist/merge/ArrayMergePolicy.js.map +1 -1
- package/dist/merge/SettingsValueMerger.js.map +1 -1
- package/dist/profiles/Profile.d.ts +13 -1
- package/dist/profiles/Profile.js.map +1 -1
- package/dist/profiles/ProfileLoader.d.ts +4 -0
- package/dist/profiles/ProfileLoader.js +117 -17
- package/dist/profiles/ProfileLoader.js.map +1 -1
- package/dist/profiles/ProfileMerger.js +3 -0
- package/dist/profiles/ProfileMerger.js.map +1 -1
- package/dist/profiles/PromptIncludes.d.ts +32 -0
- package/dist/profiles/PromptIncludes.js +147 -0
- package/dist/profiles/PromptIncludes.js.map +1 -0
- package/dist/prompts/SystemPromptExport.d.ts +16 -0
- package/dist/prompts/SystemPromptExport.js +81 -0
- package/dist/prompts/SystemPromptExport.js.map +1 -0
- package/dist/schemas/profile.schema.json +37 -2
- package/dist/schemas/settings.schema.json +12 -0
- package/dist/settings/Settings.d.ts +5 -0
- package/dist/settings/Settings.js.map +1 -1
- package/dist/settings/SettingsLoader.js +3 -0
- package/dist/settings/SettingsLoader.js.map +1 -1
- package/dist/settings/SettingsMerger.js +8 -0
- package/dist/settings/SettingsMerger.js.map +1 -1
- package/package.json +8 -11
- package/src/schemas/profile.schema.json +37 -2
- package/src/schemas/settings.schema.json +12 -0
- package/doc/.deepreview +0 -30
- package/doc/architecture.md +0 -856
- package/doc/controllable-elements.md +0 -162
- package/doc/file_structure.md +0 -141
- package/doc/integration_test_system.md +0 -214
- package/doc/specs/validating_requirements_with_rules.md +0 -55
- package/doc/state_writeback_strategy.md +0 -342
- package/requirements/OFTR-001-project-foundation.md +0 -53
- package/requirements/OFTR-002-settings.md +0 -65
- package/requirements/OFTR-003-profiles.md +0 -60
- package/requirements/OFTR-004-sync-and-setup.md +0 -67
- package/requirements/OFTR-005-run-and-composite-profile.md +0 -60
- package/requirements/OFTR-006-agent-adapters.md +0 -66
- package/requirements/OFTR-007-controllable-elements.md +0 -32
- package/requirements/OFTR-008-requirements-governance.md +0 -42
- package/requirements/OFTR-009-release-publishing.md +0 -35
- package/requirements/OFTR-010-onboarding-welcome.md +0 -48
|
@@ -1,73 +1,174 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
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']);
|
|
8
9
|
export const preparePiLoginLaunchPlan = (input) => {
|
|
9
10
|
if (input.adapterId !== 'pi') {
|
|
10
11
|
return input.launchPlan;
|
|
11
12
|
}
|
|
12
13
|
// Load the Outfitter runtime extension for every interactive pi session. It brands the
|
|
13
|
-
// startup header
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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.
|
|
16
17
|
let launchPlan = input.launchPlan;
|
|
17
|
-
|
|
18
|
-
|
|
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);
|
|
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) {
|
|
26
21
|
return launchPlan;
|
|
27
22
|
}
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
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);
|
|
31
36
|
}
|
|
32
37
|
return launchPlan;
|
|
33
38
|
};
|
|
34
|
-
const
|
|
35
|
-
|
|
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] };
|
|
44
|
+
};
|
|
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);
|
|
36
68
|
mkdirSync(dirname(extensionPath), { recursive: true });
|
|
37
69
|
writeFileSync(extensionPath, content);
|
|
38
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
...launchPlan,
|
|
72
|
+
args: ['--extension', extensionPath, ...launchPlan.args],
|
|
73
|
+
env: { ...launchPlan.env, PI_CODING_AGENT_DIR: piConfigDirectory },
|
|
74
|
+
};
|
|
39
75
|
};
|
|
40
|
-
// The general Outfitter pi extension.
|
|
41
|
-
// Outfitter
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 };
|
|
60
141
|
});
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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");
|
|
67
168
|
await ctx.ui.custom((tui, _theme, _keybindings, done) => {
|
|
68
169
|
setTimeout(() => {
|
|
69
|
-
tui.focusedComponent?.handleInput?.("
|
|
70
|
-
done();
|
|
170
|
+
tui.focusedComponent?.handleInput?.("\r");
|
|
171
|
+
done(true);
|
|
71
172
|
}, 25);
|
|
72
173
|
|
|
73
174
|
return {
|
|
@@ -75,45 +176,595 @@ const piOutfitterPrefillExtensionContent = `export default function outfitterSki
|
|
|
75
176
|
invalidate: () => undefined,
|
|
76
177
|
};
|
|
77
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
|
+
},
|
|
78
331
|
});
|
|
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
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);
|
|
91
338
|
return {
|
|
92
|
-
render: () =>
|
|
339
|
+
render: () => lines,
|
|
93
340
|
invalidate: () => undefined,
|
|
94
341
|
};
|
|
95
|
-
}
|
|
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
|
+
};
|
|
96
387
|
});
|
|
97
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");
|
|
98
735
|
`;
|
|
99
|
-
|
|
100
|
-
|
|
736
|
+
};
|
|
737
|
+
const writePiLaunchMessage = (writeLine, message) => {
|
|
738
|
+
/* v8 ignore next -- console fallback is direct CLI behavior; tests inject a writer for launch messages. */
|
|
101
739
|
(writeLine ?? console.log)(message);
|
|
102
740
|
};
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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);
|
|
109
757
|
if (!existsSync(statePath)) {
|
|
110
758
|
return false;
|
|
111
759
|
}
|
|
112
760
|
try {
|
|
113
761
|
return hasConfiguredPiStateEntries(JSON.parse(readFileSync(statePath, 'utf8')));
|
|
114
762
|
}
|
|
115
|
-
catch {
|
|
116
|
-
|
|
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 });
|
|
117
768
|
}
|
|
118
769
|
};
|
|
119
770
|
const hasConfiguredPiStateEntries = (value) => {
|