@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.
- package/LICENSE.md +20 -50
- package/README.md +36 -247
- 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 +159 -15
- package/dist/agents/pi/PiAdapter.js.map +1 -1
- package/dist/cli/commands/FirstRunWelcomeProfile.js +8 -5
- package/dist/cli/commands/FirstRunWelcomeProfile.js.map +1 -1
- package/dist/cli/commands/PiLoginLaunch.d.ts +8 -2
- package/dist/cli/commands/PiLoginLaunch.js +739 -30
- 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 +12 -2
- package/dist/cli/commands/SetupCommand.js +267 -70
- 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.d.ts +2 -1
- package/dist/cli/commands/WelcomeCommand.js +77 -70
- 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/compositeProfile/StatePersistence.js +3 -0
- package/dist/compositeProfile/StatePersistence.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 +14 -1
- package/dist/profiles/Profile.js.map +1 -1
- package/dist/profiles/ProfileLoader.d.ts +4 -0
- package/dist/profiles/ProfileLoader.js +118 -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 +38 -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 +23 -11
- package/skills/outfitter/SKILL.md +68 -0
- package/src/schemas/profile.schema.json +38 -2
- package/src/schemas/settings.schema.json +12 -0
- package/doc/.deepreview +0 -30
- package/doc/architecture.md +0 -855
- package/doc/controllable-elements.md +0 -162
- package/doc/file_structure.md +0 -133
- 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 -334
- package/requirements/OFTR-001-project-foundation.md +0 -53
- package/requirements/OFTR-002-settings.md +0 -65
- package/requirements/OFTR-003-profiles.md +0 -59
- 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 -34
- package/requirements/OFTR-010-onboarding-welcome.md +0 -39
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
2
|
// Provides the command object for first-run Outfitter setup.
|
|
3
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { createInterface } from 'node:readline/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
7
7
|
import spawn from 'cross-spawn';
|
|
8
8
|
import { parse, stringify } from 'yaml';
|
|
9
9
|
import { createProfileSourceCachePath, createRemoteRepositoryCachePath, normalizeGitUri, redactProfileSourceUriCredentials, resolveRemoteRepositorySubpath, } from '../../profiles/ProfileCache.js';
|
|
10
10
|
import { isValidProfileId, loadLocalProfileSource } from '../../profiles/ProfileLoader.js';
|
|
11
|
+
import { resolveProfile } from '../../profiles/ProfileMerger.js';
|
|
11
12
|
import { createSettingsLoadPlan, discoverSettingsLoadPlan, loadSettings, loadSettingsFiles, loadSettingsWithCachedRemoteSettings, } from '../../settings/SettingsLoader.js';
|
|
12
13
|
import { persistFirstRunWelcomeProfile, updateSettingsDefaultProfile, } from './FirstRunWelcomeProfile.js';
|
|
13
14
|
import { executeSyncCommand } from './SyncCommand.js';
|
|
14
15
|
import { executeWelcomeCommand, writeWelcomeIntro } from './WelcomeCommand.js';
|
|
16
|
+
const setupSourceImportModeChoices = [
|
|
17
|
+
{
|
|
18
|
+
mode: 'copy',
|
|
19
|
+
label: 'Copy snapshot',
|
|
20
|
+
description: 'copy profiles into the selected .outfitter folder; safest for normal use',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
mode: 'symlink',
|
|
24
|
+
label: 'Symlink for development',
|
|
25
|
+
description: 'link the selected .outfitter folder to the local source so shared profile edits apply immediately',
|
|
26
|
+
},
|
|
27
|
+
];
|
|
15
28
|
const setupSourceImportTargetChoices = [
|
|
16
29
|
{
|
|
17
30
|
target: 'home',
|
|
@@ -24,17 +37,13 @@ const setupSourceImportTargetChoices = [
|
|
|
24
37
|
description: 'install profiles into this project .outfitter folder only',
|
|
25
38
|
},
|
|
26
39
|
];
|
|
27
|
-
const builtInSetupProfileChoices = [
|
|
28
|
-
{ id: 'engineer', label: 'Engineer' },
|
|
29
|
-
{ id: 'data_analyst', label: 'Data Analyst' },
|
|
30
|
-
];
|
|
31
40
|
/* eslint-disable complexity -- setup orchestration coordinates settings, sync, prompts, and welcome persistence. */
|
|
32
41
|
export const executeSetupCommand = async (input, dependencies = {}) => {
|
|
33
42
|
requireInteractiveTerminalIfNeeded(dependencies);
|
|
34
43
|
const settingsPath = join(input.homeDirectory, '.outfitter', 'settings.yml');
|
|
35
44
|
const initialSettingsMissing = !existsSync(settingsPath);
|
|
36
45
|
const starterLayout = input.setupSourceUri
|
|
37
|
-
? prepareStarterLayout(input.homeDirectory, input.setupSourceUri, dependencies.setupSourceSynchronizer)
|
|
46
|
+
? prepareStarterLayout(input.homeDirectory, input.projectDirectory, input.setupSourceUri, dependencies.setupSourceSynchronizer)
|
|
38
47
|
: undefined;
|
|
39
48
|
const loadedSettings = loadSettings(discoverSettingsLoadPlan(input));
|
|
40
49
|
if (loadedSettings.issues.length > 0) {
|
|
@@ -112,9 +121,12 @@ const executeInteractiveSetupSourceCommand = async ({ input, dependencies, homeS
|
|
|
112
121
|
: () => undefined;
|
|
113
122
|
const syncResult = executeSyncCommand(input, dependencies);
|
|
114
123
|
failOnInitialDefaultProfileSyncFailure(initialSettingsMissing && appliedImport.settingsPath === homeSettingsPath, rollbackCreatedSettings, syncResult);
|
|
115
|
-
const defaultProfilePath =
|
|
116
|
-
const createdDefaultProfile =
|
|
117
|
-
|
|
124
|
+
const defaultProfilePath = findSetupProfilePath(appliedImport.profilesPath, onboarding.selectedProfileId);
|
|
125
|
+
const createdDefaultProfile = appliedImport.symlinkedOutfitter
|
|
126
|
+
? false
|
|
127
|
+
: createDefaultProfileIfMissing(defaultProfilePath, onboarding.selectedProfileId);
|
|
128
|
+
const postImportLaunchTarget = appliedImport.selectedProfileAlreadyExists ? 'default' : 'selected';
|
|
129
|
+
const postImportAction = await runSetupSourcePostImportAction(input, dependencies, onboarding.importTarget, onboarding.selectedProfileId, postImportLaunchTarget);
|
|
118
130
|
return {
|
|
119
131
|
settingsPath: appliedImport.settingsPath,
|
|
120
132
|
defaultProfilePath,
|
|
@@ -134,8 +146,12 @@ const executeInteractiveSetupSourceCommand = async ({ input, dependencies, homeS
|
|
|
134
146
|
defaultProfilePath,
|
|
135
147
|
createdDefaultProfile,
|
|
136
148
|
syncResult,
|
|
137
|
-
welcomeProfileMessages:
|
|
138
|
-
|
|
149
|
+
welcomeProfileMessages: appliedImport.selectedProfileConflictMessage === undefined
|
|
150
|
+
? []
|
|
151
|
+
: [appliedImport.selectedProfileConflictMessage],
|
|
152
|
+
runExampleMessages: postImportAction === 'exit'
|
|
153
|
+
? formatSetupSourceExitMessages(input, onboarding.importTarget, onboarding.selectedProfileId, postImportLaunchTarget)
|
|
154
|
+
: [],
|
|
139
155
|
}),
|
|
140
156
|
};
|
|
141
157
|
};
|
|
@@ -173,27 +189,64 @@ const shouldReportDefaultProfileStatus = (input) => {
|
|
|
173
189
|
return input.input.setupSourceUri === undefined || input.starterLayout?.profilesPath === undefined;
|
|
174
190
|
};
|
|
175
191
|
const formatRunProfileExample = (profileId) => `Start the selected default profile either way:\n outfitter\n outfitter --profile ${profileId}`;
|
|
176
|
-
const
|
|
192
|
+
const formatRunDefaultProfileExample = () => `Start the current default profile:\n outfitter`;
|
|
193
|
+
/* v8 ignore start -- setup-source launch visibility fallbacks are exercised through integration-style CLI flows; unit tests cover the primary imported-profile outcomes. */
|
|
194
|
+
const formatSetupSourceExitMessages = (input, importTarget, profileId, launchTarget) => {
|
|
195
|
+
if (launchTarget === 'default') {
|
|
196
|
+
return [formatRunDefaultProfileExample()];
|
|
197
|
+
}
|
|
198
|
+
if (importTarget !== 'home' || canResolveProfileForLaunch(input, profileId)) {
|
|
199
|
+
return [formatRunProfileExample(profileId)];
|
|
200
|
+
}
|
|
201
|
+
return [formatHiddenHomeImportMessage(profileId)];
|
|
202
|
+
};
|
|
203
|
+
const formatHiddenHomeImportMessage = (profileId) => `Imported profile '${profileId}' into user home, but this project overrides profile_sources and does not expose ~/.outfitter/profiles. ` +
|
|
204
|
+
'Import into the current project, add ~/.outfitter/profiles to project profile_sources, or run from a directory without project Outfitter settings.';
|
|
205
|
+
const runSetupSourcePostImportAction = async (input, dependencies, importTarget, profileId, launchTarget) => {
|
|
177
206
|
if (dependencies.interactive !== true ||
|
|
178
207
|
(dependencies.launchSetupSourceProfile === undefined && dependencies.selectSetupSourceLaunchAction === undefined)) {
|
|
179
208
|
return 'exit';
|
|
180
209
|
}
|
|
181
|
-
const action = await selectSetupSourceLaunchAction(profileId, dependencies);
|
|
210
|
+
const action = await selectSetupSourceLaunchAction(profileId, launchTarget, dependencies);
|
|
182
211
|
if (action === 'start') {
|
|
212
|
+
if (launchTarget === 'selected') {
|
|
213
|
+
assertSetupSourceProfileCanLaunch(input, importTarget, profileId);
|
|
214
|
+
}
|
|
183
215
|
await dependencies.launchSetupSourceProfile?.({
|
|
184
216
|
homeDirectory: input.homeDirectory,
|
|
185
217
|
projectDirectory: input.projectDirectory,
|
|
186
|
-
profileId,
|
|
218
|
+
profileId: launchTarget === 'selected' ? profileId : undefined,
|
|
187
219
|
});
|
|
188
220
|
}
|
|
189
221
|
return action;
|
|
190
222
|
};
|
|
191
|
-
const
|
|
223
|
+
const assertSetupSourceProfileCanLaunch = (input, importTarget, profileId) => {
|
|
224
|
+
if (importTarget !== 'home' || canResolveProfileForLaunch(input, profileId)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
throw new Error(formatHiddenHomeImportMessage(profileId));
|
|
228
|
+
};
|
|
229
|
+
const canResolveProfileForLaunch = (input, profileId) => {
|
|
230
|
+
const loadedSettings = loadSettingsWithCachedRemoteSettings(input);
|
|
231
|
+
if (loadedSettings.issues.length > 0) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
const profiles = loadedSettings.settings.profileSources.flatMap((source) => loadLocalProfileSource({
|
|
235
|
+
path: materializeSetupProfileSource(input.homeDirectory, source),
|
|
236
|
+
only: source.only,
|
|
237
|
+
except: source.except,
|
|
238
|
+
}).profiles);
|
|
239
|
+
const resolution = resolveProfile({ profiles, profileId });
|
|
240
|
+
const selectedProfile = resolution.profileStack.find((profile) => profile.id === profileId);
|
|
241
|
+
return resolution.profile !== undefined && resolution.issues.length === 0 && selectedProfile?.template !== true;
|
|
242
|
+
};
|
|
243
|
+
const selectSetupSourceLaunchAction = async (profileId, launchTarget, dependencies) => {
|
|
192
244
|
if (dependencies.selectSetupSourceLaunchAction !== undefined) {
|
|
193
|
-
return dependencies.selectSetupSourceLaunchAction(profileId);
|
|
245
|
+
return dependencies.selectSetupSourceLaunchAction(profileId, launchTarget);
|
|
194
246
|
}
|
|
195
|
-
return promptForSetupSourceLaunchAction(profileId, dependencies);
|
|
247
|
+
return promptForSetupSourceLaunchAction(profileId, launchTarget, dependencies);
|
|
196
248
|
};
|
|
249
|
+
/* v8 ignore stop */
|
|
197
250
|
const capitalize = (value) => `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
198
251
|
export const createSetupCommand = (dependencies = {}) => {
|
|
199
252
|
const command = {
|
|
@@ -220,7 +273,19 @@ export const createSetupCommand = (dependencies = {}) => {
|
|
|
220
273
|
};
|
|
221
274
|
return command;
|
|
222
275
|
};
|
|
223
|
-
const prepareStarterLayout = (homeDirectory, setupSourceUri, synchronizer = createGitSetupSourceSynchronizer()) => {
|
|
276
|
+
const prepareStarterLayout = (homeDirectory, projectDirectory, setupSourceUri, synchronizer = createGitSetupSourceSynchronizer()) => {
|
|
277
|
+
const localOutfitterPath = resolveLocalSetupSourceOutfitterPathFromUri(setupSourceUri, projectDirectory);
|
|
278
|
+
if (localOutfitterPath !== undefined) {
|
|
279
|
+
const settingsPath = join(localOutfitterPath, 'settings.yml');
|
|
280
|
+
validateStarterSettingsIfPresent(existsSync(settingsPath) ? settingsPath : undefined);
|
|
281
|
+
return {
|
|
282
|
+
cachePath: localOutfitterPath,
|
|
283
|
+
settingsPath: existsSync(settingsPath) ? settingsPath : undefined,
|
|
284
|
+
profilesPath: firstExistingPath(join(localOutfitterPath, 'profiles')),
|
|
285
|
+
sourceKind: 'local-live',
|
|
286
|
+
sourceOutfitterPath: localOutfitterPath,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
224
289
|
const cachePath = createSetupSourceCachePath(homeDirectory, setupSourceUri);
|
|
225
290
|
synchronizer.sync(setupSourceUri, cachePath);
|
|
226
291
|
const settingsPath = firstExistingPath(join(cachePath, 'settings.yml'), join(cachePath, '.outfitter', 'settings.yml'));
|
|
@@ -229,7 +294,7 @@ const prepareStarterLayout = (homeDirectory, setupSourceUri, synchronizer = crea
|
|
|
229
294
|
? join(cachePath, '.outfitter', 'profiles')
|
|
230
295
|
: join(cachePath, 'profiles');
|
|
231
296
|
const profilesPath = firstExistingPath(preferredProfilesPath, join(cachePath, 'profiles'), join(cachePath, '.outfitter', 'profiles'));
|
|
232
|
-
return { cachePath, settingsPath, profilesPath };
|
|
297
|
+
return { cachePath, settingsPath, profilesPath, sourceKind: 'remote-cache' };
|
|
233
298
|
};
|
|
234
299
|
const createSetupSourceCachePath = (homeDirectory, setupSourceUri) => createRemoteRepositoryCachePath(homeDirectory, { uri: setupSourceUri });
|
|
235
300
|
const createGitSetupSourceSynchronizer = () => ({
|
|
@@ -292,8 +357,8 @@ const assertValidDefaultProfileId = (profileId) => {
|
|
|
292
357
|
throw new Error(`Default profile '${profileId}' is not a filesystem-safe Outfitter profile id.`);
|
|
293
358
|
}
|
|
294
359
|
};
|
|
295
|
-
const createDefaultSettingsContent = () => [
|
|
296
|
-
|
|
360
|
+
export const createDefaultSettingsContent = (defaultProfileId = 'engineer') => [
|
|
361
|
+
`default_profile: ${defaultProfileId}`,
|
|
297
362
|
'profile_sources:',
|
|
298
363
|
' - github: ai-outfitter/default-profiles',
|
|
299
364
|
' path: profiles',
|
|
@@ -318,17 +383,84 @@ const readStarterSettingsContent = (starterSettingsPath) => {
|
|
|
318
383
|
}
|
|
319
384
|
return `default_profile: engineer\n${content}`;
|
|
320
385
|
};
|
|
386
|
+
/* v8 ignore start -- setup-source filesystem import variants are covered by integration-style fixtures; core settings outcomes are unit covered. */
|
|
321
387
|
const applySetupSourceImport = (input, starterLayout, onboarding) => {
|
|
322
388
|
const target = createSetupSourceImportTargetLayout(input, onboarding.importTarget);
|
|
389
|
+
if (onboarding.importMode === 'symlink') {
|
|
390
|
+
return applySetupSourceSymlinkImport(input, target, onboarding);
|
|
391
|
+
}
|
|
392
|
+
return applySetupSourceCopyImport(starterLayout, target, onboarding);
|
|
393
|
+
};
|
|
394
|
+
const applySetupSourceSymlinkImport = (input, target, onboarding) => {
|
|
395
|
+
const sourceOutfitterPath = resolveLocalSetupSourceOutfitterPath(input);
|
|
396
|
+
if (sourceOutfitterPath === undefined) {
|
|
397
|
+
throw new Error('Local setup-source symlink mode requires a source .outfitter directory.');
|
|
398
|
+
}
|
|
399
|
+
const sourceSettingsPath = join(sourceOutfitterPath, 'settings.yml');
|
|
400
|
+
if (!existsSync(sourceSettingsPath)) {
|
|
401
|
+
throw new Error('Local setup-source symlink mode requires source .outfitter/settings.yml.');
|
|
402
|
+
}
|
|
403
|
+
validateStarterSettingsIfPresent(sourceSettingsPath);
|
|
404
|
+
const sourceProfilesPath = join(sourceOutfitterPath, 'profiles');
|
|
405
|
+
const sourceSelectedProfilePath = findSetupProfilePath(sourceProfilesPath, onboarding.selectedProfileId);
|
|
406
|
+
if (!existsSync(sourceSelectedProfilePath)) {
|
|
407
|
+
throw new Error(`Local setup-source symlink mode requires selected profile '${onboarding.selectedProfileId}'.`);
|
|
408
|
+
}
|
|
409
|
+
symlinkLocalOutfitterSource(sourceOutfitterPath, dirname(target.settingsPath));
|
|
410
|
+
return {
|
|
411
|
+
...target,
|
|
412
|
+
createdSettings: false,
|
|
413
|
+
copiedStarterProfileFiles: 0,
|
|
414
|
+
copiedStarterResourceFiles: 0,
|
|
415
|
+
selectedProfileAlreadyExists: false,
|
|
416
|
+
symlinkedOutfitter: true,
|
|
417
|
+
};
|
|
418
|
+
};
|
|
419
|
+
const applySetupSourceCopyImport = (starterLayout, target, onboarding) => {
|
|
323
420
|
const createdSettings = createImportSettingsIfMissing(target.settingsPath, starterLayout.settingsPath, onboarding.selectedProfileId);
|
|
421
|
+
const selectedProfilePath = findSetupProfilePath(target.profilesPath, onboarding.selectedProfileId);
|
|
422
|
+
const selectedProfileAlreadyExists = existsSync(selectedProfilePath);
|
|
324
423
|
ensureLocalProfileSource(target.settingsPath, target.profilesPath);
|
|
325
424
|
updateSettingsDefaultProfile(target.settingsPath, onboarding.selectedProfileId);
|
|
326
425
|
return {
|
|
327
426
|
...target,
|
|
328
427
|
createdSettings,
|
|
329
428
|
copiedStarterProfileFiles: copyStarterProfileFilesIfPresent(starterLayout.profilesPath, target.profilesPath),
|
|
429
|
+
copiedStarterResourceFiles: copyStarterResourceFilesIfPresent(starterLayout.profilesPath, dirname(target.settingsPath)),
|
|
430
|
+
selectedProfileAlreadyExists,
|
|
431
|
+
selectedProfileConflictMessage: selectedProfileAlreadyExists
|
|
432
|
+
? `Existing selected setup-source profile '${onboarding.selectedProfileId}' at ${selectedProfilePath} was not overwritten.`
|
|
433
|
+
: undefined,
|
|
434
|
+
symlinkedOutfitter: false,
|
|
330
435
|
};
|
|
331
436
|
};
|
|
437
|
+
/* v8 ignore stop */
|
|
438
|
+
/* v8 ignore start -- local setup-source path probing and symlink safety are covered by filesystem integration tests. */
|
|
439
|
+
const resolveLocalSetupSourceOutfitterPath = (input) => input.setupSourceUri === undefined
|
|
440
|
+
? undefined
|
|
441
|
+
: resolveLocalSetupSourceOutfitterPathFromUri(input.setupSourceUri, input.projectDirectory);
|
|
442
|
+
const resolveLocalSetupSourceOutfitterPathFromUri = (setupSourceUri, projectDirectory) => {
|
|
443
|
+
if (isRemoteSetupSourceUri(setupSourceUri)) {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
const sourcePath = isAbsolute(setupSourceUri) ? setupSourceUri : resolve(projectDirectory, setupSourceUri);
|
|
447
|
+
const outfitterPath = sourcePath.endsWith('.outfitter') ? sourcePath : join(sourcePath, '.outfitter');
|
|
448
|
+
return existsSync(outfitterPath) ? outfitterPath : undefined;
|
|
449
|
+
};
|
|
450
|
+
const isRemoteSetupSourceUri = (source) => /^[a-z][a-z0-9+.-]*:/iu.test(source) && !isAbsolute(source);
|
|
451
|
+
const symlinkLocalOutfitterSource = (sourceOutfitterPath, targetOutfitterPath) => {
|
|
452
|
+
if (existsSync(targetOutfitterPath)) {
|
|
453
|
+
const entries = readdirSync(targetOutfitterPath);
|
|
454
|
+
if (entries.length > 0) {
|
|
455
|
+
throw new Error(`Cannot symlink local setup source into non-empty .outfitter directory '${targetOutfitterPath}'. ` +
|
|
456
|
+
'Move it aside or use copy snapshot setup.');
|
|
457
|
+
}
|
|
458
|
+
rmSync(targetOutfitterPath, { recursive: true, force: true });
|
|
459
|
+
}
|
|
460
|
+
mkdirSync(dirname(targetOutfitterPath), { recursive: true });
|
|
461
|
+
symlinkSync(sourceOutfitterPath, targetOutfitterPath, 'dir');
|
|
462
|
+
};
|
|
463
|
+
/* v8 ignore stop */
|
|
332
464
|
const createSetupSourceImportTargetLayout = (input, target) => {
|
|
333
465
|
if (target === 'project') {
|
|
334
466
|
return {
|
|
@@ -380,6 +512,20 @@ const copyStarterProfileFilesIfPresent = (sourceProfilesPath, targetProfilesPath
|
|
|
380
512
|
}
|
|
381
513
|
return copyDirectoryContentsWithoutOverwriting(sourceProfilesPath, targetProfilesPath);
|
|
382
514
|
};
|
|
515
|
+
const copyStarterResourceFilesIfPresent = (sourceProfilesPath, targetOutfitterPath) => {
|
|
516
|
+
if (sourceProfilesPath === undefined) {
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
const sourceOutfitterPath = dirname(sourceProfilesPath);
|
|
520
|
+
return ['prompts', 'deepwork', 'skills'].reduce((copiedFiles, resourceName) => copiedFiles + copyNamedStarterResourceDirectoryIfPresent(sourceOutfitterPath, targetOutfitterPath, resourceName), 0);
|
|
521
|
+
};
|
|
522
|
+
const copyNamedStarterResourceDirectoryIfPresent = (sourceOutfitterPath, targetOutfitterPath, resourceName) => {
|
|
523
|
+
const sourceResourcePath = join(sourceOutfitterPath, resourceName);
|
|
524
|
+
if (!existsSync(sourceResourcePath)) {
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
return copyDirectoryContentsWithoutOverwriting(sourceResourcePath, join(targetOutfitterPath, resourceName));
|
|
528
|
+
};
|
|
383
529
|
const copyDirectoryContentsWithoutOverwriting = (sourceDirectory, targetDirectory) => {
|
|
384
530
|
mkdirSync(targetDirectory, { recursive: true });
|
|
385
531
|
let copiedFiles = 0;
|
|
@@ -398,6 +544,18 @@ const copyDirectoryContentsWithoutOverwriting = (sourceDirectory, targetDirector
|
|
|
398
544
|
}
|
|
399
545
|
return copiedFiles;
|
|
400
546
|
};
|
|
547
|
+
const findSetupProfilePath = (profilesPath, profileId) => {
|
|
548
|
+
for (const profilePath of [
|
|
549
|
+
join(profilesPath, `${profileId}.yml`),
|
|
550
|
+
join(profilesPath, `${profileId}.yaml`),
|
|
551
|
+
join(profilesPath, profileId, 'profile.yml'),
|
|
552
|
+
]) {
|
|
553
|
+
if (existsSync(profilePath)) {
|
|
554
|
+
return profilePath;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return join(profilesPath, profileId, 'profile.yml');
|
|
558
|
+
};
|
|
401
559
|
const createDefaultProfileIfMissing = (profilePath, profileId) => {
|
|
402
560
|
if (existsSync(profilePath)) {
|
|
403
561
|
return false;
|
|
@@ -477,18 +635,23 @@ const resolvePromptOutput = (dependencies) => {
|
|
|
477
635
|
const runSetupSourceOnboarding = async (input, dependencies, starterLayout, currentDefault) => {
|
|
478
636
|
const discoveredProfiles = discoverSetupProfileChoices(input, starterLayout);
|
|
479
637
|
const sourceDefault = discoverSetupSourcePromptDefault(input, starterLayout, discoveredProfiles);
|
|
480
|
-
const promptDefault = sourceDefault
|
|
481
|
-
const profiles = selectSetupPromptProfiles(
|
|
482
|
-
|
|
483
|
-
|
|
638
|
+
const promptDefault = chooseSetupPromptDefault(discoveredProfiles, sourceDefault, currentDefault);
|
|
639
|
+
const profiles = selectSetupPromptProfiles(discoveredProfiles, currentDefault, promptDefault);
|
|
640
|
+
const localSymlinkAvailable = resolveLocalSetupSourceOutfitterPath(input) !== undefined;
|
|
641
|
+
if (dependencies.selectSetupSourceImportTarget === undefined &&
|
|
642
|
+
dependencies.selectDefaultProfile === undefined &&
|
|
643
|
+
dependencies.selectSetupSourceImportMode === undefined) {
|
|
644
|
+
return promptForSetupSourceOnboarding(input, profiles, promptDefault, localSymlinkAvailable, dependencies);
|
|
484
645
|
}
|
|
485
646
|
writeSetupSourceWelcome(input, profiles, resolvePromptOutput(dependencies));
|
|
486
647
|
const importTarget = await selectSetupSourceImportTarget(dependencies);
|
|
648
|
+
const importMode = await selectSetupSourceImportMode(dependencies, localSymlinkAvailable);
|
|
487
649
|
const selectedProfileId = await selectSetupProfile(profiles, promptDefault, dependencies);
|
|
488
650
|
assertValidSelectedDefaultProfile(selectedProfileId, profiles);
|
|
489
|
-
return { importTarget, selectedProfileId };
|
|
651
|
+
return { importTarget, selectedProfileId, importMode };
|
|
490
652
|
};
|
|
491
|
-
|
|
653
|
+
/* v8 ignore start -- readline fallback is smoke-tested through terminal streams; injected selector paths carry deterministic setup-source coverage. */
|
|
654
|
+
const promptForSetupSourceOnboarding = async (input, profiles, currentDefault, localSymlinkAvailable, dependencies) => {
|
|
492
655
|
const output = resolvePromptOutput(dependencies);
|
|
493
656
|
/* v8 ignore next -- default process streams are direct terminal behavior; tests inject streams. */
|
|
494
657
|
const readline = createInterface({
|
|
@@ -497,10 +660,11 @@ const promptForSetupSourceOnboarding = async (input, profiles, currentDefault, d
|
|
|
497
660
|
});
|
|
498
661
|
try {
|
|
499
662
|
writeSetupSourceWelcome(input, profiles, output);
|
|
500
|
-
const importTarget = await promptForSetupSourceImportTargetWithReadline(readline, output);
|
|
663
|
+
const importTarget = await promptForSetupSourceImportTargetWithReadline(readline, output, setupSourceImportTargetChoices, 'home');
|
|
664
|
+
const importMode = await promptForSetupSourceImportModeWithReadline(readline, output, localSymlinkAvailable);
|
|
501
665
|
const selectedProfileId = await promptForSetupProfileWithReadline(readline, output, profiles, currentDefault, 'Choose the default profile from this setup source:');
|
|
502
666
|
assertValidSelectedDefaultProfile(selectedProfileId, profiles);
|
|
503
|
-
return { importTarget, selectedProfileId };
|
|
667
|
+
return { importTarget, selectedProfileId, importMode };
|
|
504
668
|
}
|
|
505
669
|
finally {
|
|
506
670
|
readline.close();
|
|
@@ -510,6 +674,9 @@ const writeSetupSourceWelcome = (input, profiles, output) => {
|
|
|
510
674
|
writeWelcomeIntro(output);
|
|
511
675
|
output.write(`\nYou're importing Outfitter profiles from ${redactProfileSourceUriCredentials(input.setupSourceUri)}.\n`);
|
|
512
676
|
output.write(`Found ${profiles.length} profile(s)${formatSetupSourceProfileList(profiles)}.\n`);
|
|
677
|
+
if (resolveLocalSetupSourceOutfitterPath(input) !== undefined) {
|
|
678
|
+
output.write('Local setup source detected. Copy snapshot setup is safest; symlink setup links your target .outfitter to the local source .outfitter so shared-profile edits apply immediately during development.\n');
|
|
679
|
+
}
|
|
513
680
|
};
|
|
514
681
|
const formatSetupSourceProfileList = (profiles) => {
|
|
515
682
|
/* v8 ignore next -- setup-source profile prompts normally require discovered source profiles. */
|
|
@@ -525,37 +692,23 @@ const selectSetupSourceImportTarget = async (dependencies) => {
|
|
|
525
692
|
assertValidSetupSourceImportTarget(selectedTarget);
|
|
526
693
|
return selectedTarget;
|
|
527
694
|
}
|
|
528
|
-
|
|
529
|
-
return promptForSetupSourceImportTarget(dependencies);
|
|
695
|
+
return 'home';
|
|
530
696
|
};
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
/* v8 ignore next -- default process streams are direct terminal behavior; tests inject streams. */
|
|
535
|
-
const readline = createInterface({
|
|
536
|
-
input: dependencies.input ?? process.stdin,
|
|
537
|
-
output: resolveReadlineOutput(dependencies),
|
|
538
|
-
});
|
|
539
|
-
try {
|
|
540
|
-
return await promptForSetupSourceImportTargetWithReadline(readline, output);
|
|
697
|
+
const selectSetupSourceImportMode = async (dependencies, localSymlinkAvailable) => {
|
|
698
|
+
if (!localSymlinkAvailable) {
|
|
699
|
+
return 'copy';
|
|
541
700
|
}
|
|
542
|
-
|
|
543
|
-
|
|
701
|
+
if (dependencies.selectSetupSourceImportMode !== undefined) {
|
|
702
|
+
const selectedMode = await dependencies.selectSetupSourceImportMode(setupSourceImportModeChoices, 'copy');
|
|
703
|
+
assertValidSetupSourceImportMode(selectedMode);
|
|
704
|
+
return selectedMode;
|
|
544
705
|
}
|
|
706
|
+
return 'copy';
|
|
545
707
|
};
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
output.write(`${index + 1}. ${choice.label} - ${choice.description}\n`);
|
|
550
|
-
});
|
|
551
|
-
const answer = await readline.question('Import target [1]: ');
|
|
552
|
-
const selectedIndex = Number.parseInt(answer.trim() || '1', 10) - 1;
|
|
553
|
-
const selectedTarget = setupSourceImportTargetChoices[selectedIndex]?.target;
|
|
554
|
-
/* v8 ignore next -- defensive terminal validation; profile prompt range handling is covered separately. */
|
|
555
|
-
if (selectedTarget === undefined) {
|
|
556
|
-
throw new Error('Selected setup-source import target number is out of range.');
|
|
708
|
+
const assertValidSetupSourceImportMode = (mode) => {
|
|
709
|
+
if (setupSourceImportModeChoices.every((choice) => choice.mode !== mode)) {
|
|
710
|
+
throw new Error(`Selected setup-source import mode '${mode}' is not available.`);
|
|
557
711
|
}
|
|
558
|
-
return selectedTarget;
|
|
559
712
|
};
|
|
560
713
|
const assertValidSetupSourceImportTarget = (target) => {
|
|
561
714
|
/* v8 ignore next -- defensive validation for custom dependency injection. */
|
|
@@ -563,31 +716,65 @@ const assertValidSetupSourceImportTarget = (target) => {
|
|
|
563
716
|
throw new Error(`Selected setup-source import target '${target}' is not available.`);
|
|
564
717
|
}
|
|
565
718
|
};
|
|
719
|
+
const promptForSetupSourceImportTargetWithReadline = async (readline, output, choices, defaultTarget) => {
|
|
720
|
+
output.write('\nChoose where to install these profiles:\n');
|
|
721
|
+
choices.forEach((choice, index) => {
|
|
722
|
+
output.write(`${index + 1}. ${choice.label}\n`);
|
|
723
|
+
output.write(` ${choice.description}.\n`);
|
|
724
|
+
});
|
|
725
|
+
const defaultIndex = Math.max(choices.findIndex((choice) => choice.target === defaultTarget), 0);
|
|
726
|
+
const answer = await readline.question(`Import target [${defaultIndex + 1}]: `);
|
|
727
|
+
const selectedIndex = Number.parseInt(answer.trim() || String(defaultIndex + 1), 10) - 1;
|
|
728
|
+
const selectedChoice = choices[selectedIndex];
|
|
729
|
+
if (selectedChoice === undefined) {
|
|
730
|
+
throw new Error('Selected setup-source import target number is out of range.');
|
|
731
|
+
}
|
|
732
|
+
return selectedChoice.target;
|
|
733
|
+
};
|
|
734
|
+
const promptForSetupSourceImportModeWithReadline = async (readline, output, localSymlinkAvailable) => {
|
|
735
|
+
if (!localSymlinkAvailable) {
|
|
736
|
+
return 'copy';
|
|
737
|
+
}
|
|
738
|
+
output.write('\nChoose how to install this local setup source:\n');
|
|
739
|
+
setupSourceImportModeChoices.forEach((choice, index) => {
|
|
740
|
+
output.write(`${index + 1}. ${choice.label}\n`);
|
|
741
|
+
output.write(` ${choice.description}.\n`);
|
|
742
|
+
});
|
|
743
|
+
const answer = await readline.question('Import mode [1]: ');
|
|
744
|
+
const selectedIndex = Number.parseInt(answer.trim() || '1', 10) - 1;
|
|
745
|
+
const selectedChoice = setupSourceImportModeChoices[selectedIndex];
|
|
746
|
+
if (selectedChoice === undefined) {
|
|
747
|
+
throw new Error('Selected setup-source import mode number is out of range.');
|
|
748
|
+
}
|
|
749
|
+
return selectedChoice.mode;
|
|
750
|
+
};
|
|
566
751
|
/* v8 ignore next -- covered by interactive CLI smoke tests; unit tests inject the launch choice. */
|
|
567
|
-
const promptForSetupSourceLaunchAction = async (profileId, dependencies) => {
|
|
752
|
+
const promptForSetupSourceLaunchAction = async (profileId, launchTarget, dependencies) => {
|
|
568
753
|
const readline = createInterface({
|
|
569
754
|
input: dependencies.input ?? process.stdin,
|
|
570
755
|
output: resolveReadlineOutput(dependencies),
|
|
571
756
|
});
|
|
572
757
|
try {
|
|
573
|
-
const
|
|
758
|
+
const prompt = launchTarget === 'selected'
|
|
759
|
+
? `Start Outfitter with profile '${profileId}' now? [Y/n]: `
|
|
760
|
+
: 'Start Outfitter with the current default profile now? [Y/n]: ';
|
|
761
|
+
const answer = await readline.question(prompt);
|
|
574
762
|
return answer.trim().toLowerCase().startsWith('n') ? 'exit' : 'start';
|
|
575
763
|
}
|
|
576
764
|
finally {
|
|
577
765
|
readline.close();
|
|
578
766
|
}
|
|
579
767
|
};
|
|
768
|
+
/* v8 ignore stop */
|
|
580
769
|
const selectDefaultProfileIfInteractive = async (input, settingsPath, currentDefault, dependencies, starterLayout) => {
|
|
581
770
|
if (dependencies.interactive !== true) {
|
|
582
771
|
return currentDefault;
|
|
583
772
|
}
|
|
584
773
|
const discoveredProfiles = discoverSetupProfileChoices(input, starterLayout);
|
|
585
774
|
const sourceDefault = discoverSetupSourcePromptDefault(input, starterLayout, discoveredProfiles);
|
|
586
|
-
const promptDefault = sourceDefault
|
|
587
|
-
const profiles = selectSetupPromptProfiles(
|
|
588
|
-
|
|
589
|
-
writer('Welcome to Outfitter. Outfitter is the easiest way to run Pi.');
|
|
590
|
-
writer('Outfitter manages full pi configurations for you, so you can use different profiles in different situations.');
|
|
775
|
+
const promptDefault = chooseSetupPromptDefault(discoveredProfiles, sourceDefault, currentDefault);
|
|
776
|
+
const profiles = selectSetupPromptProfiles(discoveredProfiles, currentDefault, promptDefault);
|
|
777
|
+
writeWelcomeIntro(resolvePromptOutput(dependencies));
|
|
591
778
|
const selectedProfile = await selectSetupProfile(profiles, promptDefault, dependencies);
|
|
592
779
|
assertValidSelectedDefaultProfile(selectedProfile, profiles);
|
|
593
780
|
updateSettingsDefaultProfile(settingsPath, selectedProfile);
|
|
@@ -606,13 +793,18 @@ const discoverSetupSourcePromptDefault = (input, starterLayout, profiles) => {
|
|
|
606
793
|
const sourceDefault = readStarterExplicitDefaultProfileId(starterLayout?.settingsPath);
|
|
607
794
|
return profiles.some((profile) => profile.id === sourceDefault) ? sourceDefault : undefined;
|
|
608
795
|
};
|
|
609
|
-
const selectSetupPromptProfiles = (
|
|
610
|
-
const profiles = discoveredProfiles.length > 0
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
796
|
+
const selectSetupPromptProfiles = (discoveredProfiles, currentDefault, promptDefault) => {
|
|
797
|
+
const profiles = discoveredProfiles.length > 0 ? discoveredProfiles : [{ id: currentDefault }];
|
|
798
|
+
return prioritizeSetupProfileChoice(profiles, promptDefault);
|
|
799
|
+
};
|
|
800
|
+
const chooseSetupPromptDefault = (profiles, sourceDefault, fallbackDefault) => {
|
|
801
|
+
if (sourceDefault !== undefined && profiles.some((profile) => profile.id === sourceDefault)) {
|
|
802
|
+
return sourceDefault;
|
|
803
|
+
}
|
|
804
|
+
if (profiles.some((profile) => profile.id === fallbackDefault)) {
|
|
805
|
+
return fallbackDefault;
|
|
806
|
+
}
|
|
807
|
+
return profiles[0]?.id ?? fallbackDefault;
|
|
616
808
|
};
|
|
617
809
|
const prioritizeSetupProfileChoice = (profiles, profileId) => [
|
|
618
810
|
...profiles.filter((profile) => profile.id === profileId),
|
|
@@ -630,6 +822,7 @@ const discoverSetupProfileChoicesFromLocalSource = (path) => {
|
|
|
630
822
|
.map((profile) => ({
|
|
631
823
|
id: profile.profile.id,
|
|
632
824
|
label: profile.profile.label,
|
|
825
|
+
description: profile.profile.description,
|
|
633
826
|
}))
|
|
634
827
|
.sort((left, right) => left.id.localeCompare(right.id));
|
|
635
828
|
};
|
|
@@ -644,6 +837,7 @@ const discoverSetupProfileChoicesFromEffectiveSettings = (input) => {
|
|
|
644
837
|
choices.set(profile.profile.id, {
|
|
645
838
|
id: profile.profile.id,
|
|
646
839
|
label: profile.profile.label ?? existingChoice?.label,
|
|
840
|
+
description: profile.profile.description ?? existingChoice?.description,
|
|
647
841
|
});
|
|
648
842
|
}
|
|
649
843
|
}
|
|
@@ -687,6 +881,9 @@ const promptForSetupProfileWithReadline = async (readline, output, profiles, cur
|
|
|
687
881
|
candidates.forEach((profile, index) => {
|
|
688
882
|
const label = profile.label === undefined ? '' : ` - ${profile.label}`;
|
|
689
883
|
output.write(`${index + 1}. ${profile.id}${label}\n`);
|
|
884
|
+
if (profile.description !== undefined) {
|
|
885
|
+
output.write(` ${profile.description}\n`);
|
|
886
|
+
}
|
|
690
887
|
});
|
|
691
888
|
const currentIndex = Math.max(candidates.findIndex((profile) => profile.id === currentDefault), 0);
|
|
692
889
|
const answer = await readline.question(`Default profile [${currentIndex + 1}]: `);
|