@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,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,25 +37,13 @@ const setupSourceImportTargetChoices = [
|
|
|
24
37
|
description: 'install profiles into this project .outfitter folder only',
|
|
25
38
|
},
|
|
26
39
|
];
|
|
27
|
-
const builtInSetupProfileChoices = [
|
|
28
|
-
{
|
|
29
|
-
id: 'engineer',
|
|
30
|
-
label: 'Engineer',
|
|
31
|
-
description: 'Engineering setup for repository navigation, maintainable code changes, tests, and reviews.',
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
id: 'data_analyst',
|
|
35
|
-
label: 'Data Analyst',
|
|
36
|
-
description: 'Data analysis setup for careful inspection, reproducible methods, assumptions, and summaries.',
|
|
37
|
-
},
|
|
38
|
-
];
|
|
39
40
|
/* eslint-disable complexity -- setup orchestration coordinates settings, sync, prompts, and welcome persistence. */
|
|
40
41
|
export const executeSetupCommand = async (input, dependencies = {}) => {
|
|
41
42
|
requireInteractiveTerminalIfNeeded(dependencies);
|
|
42
43
|
const settingsPath = join(input.homeDirectory, '.outfitter', 'settings.yml');
|
|
43
44
|
const initialSettingsMissing = !existsSync(settingsPath);
|
|
44
45
|
const starterLayout = input.setupSourceUri
|
|
45
|
-
? prepareStarterLayout(input.homeDirectory, input.setupSourceUri, dependencies.setupSourceSynchronizer)
|
|
46
|
+
? prepareStarterLayout(input.homeDirectory, input.projectDirectory, input.setupSourceUri, dependencies.setupSourceSynchronizer)
|
|
46
47
|
: undefined;
|
|
47
48
|
const loadedSettings = loadSettings(discoverSettingsLoadPlan(input));
|
|
48
49
|
if (loadedSettings.issues.length > 0) {
|
|
@@ -120,9 +121,12 @@ const executeInteractiveSetupSourceCommand = async ({ input, dependencies, homeS
|
|
|
120
121
|
: () => undefined;
|
|
121
122
|
const syncResult = executeSyncCommand(input, dependencies);
|
|
122
123
|
failOnInitialDefaultProfileSyncFailure(initialSettingsMissing && appliedImport.settingsPath === homeSettingsPath, rollbackCreatedSettings, syncResult);
|
|
123
|
-
const defaultProfilePath =
|
|
124
|
-
const createdDefaultProfile =
|
|
125
|
-
|
|
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);
|
|
126
130
|
return {
|
|
127
131
|
settingsPath: appliedImport.settingsPath,
|
|
128
132
|
defaultProfilePath,
|
|
@@ -142,8 +146,12 @@ const executeInteractiveSetupSourceCommand = async ({ input, dependencies, homeS
|
|
|
142
146
|
defaultProfilePath,
|
|
143
147
|
createdDefaultProfile,
|
|
144
148
|
syncResult,
|
|
145
|
-
welcomeProfileMessages:
|
|
146
|
-
|
|
149
|
+
welcomeProfileMessages: appliedImport.selectedProfileConflictMessage === undefined
|
|
150
|
+
? []
|
|
151
|
+
: [appliedImport.selectedProfileConflictMessage],
|
|
152
|
+
runExampleMessages: postImportAction === 'exit'
|
|
153
|
+
? formatSetupSourceExitMessages(input, onboarding.importTarget, onboarding.selectedProfileId, postImportLaunchTarget)
|
|
154
|
+
: [],
|
|
147
155
|
}),
|
|
148
156
|
};
|
|
149
157
|
};
|
|
@@ -181,27 +189,64 @@ const shouldReportDefaultProfileStatus = (input) => {
|
|
|
181
189
|
return input.input.setupSourceUri === undefined || input.starterLayout?.profilesPath === undefined;
|
|
182
190
|
};
|
|
183
191
|
const formatRunProfileExample = (profileId) => `Start the selected default profile either way:\n outfitter\n outfitter --profile ${profileId}`;
|
|
184
|
-
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) => {
|
|
185
206
|
if (dependencies.interactive !== true ||
|
|
186
207
|
(dependencies.launchSetupSourceProfile === undefined && dependencies.selectSetupSourceLaunchAction === undefined)) {
|
|
187
208
|
return 'exit';
|
|
188
209
|
}
|
|
189
|
-
const action = await selectSetupSourceLaunchAction(profileId, dependencies);
|
|
210
|
+
const action = await selectSetupSourceLaunchAction(profileId, launchTarget, dependencies);
|
|
190
211
|
if (action === 'start') {
|
|
212
|
+
if (launchTarget === 'selected') {
|
|
213
|
+
assertSetupSourceProfileCanLaunch(input, importTarget, profileId);
|
|
214
|
+
}
|
|
191
215
|
await dependencies.launchSetupSourceProfile?.({
|
|
192
216
|
homeDirectory: input.homeDirectory,
|
|
193
217
|
projectDirectory: input.projectDirectory,
|
|
194
|
-
profileId,
|
|
218
|
+
profileId: launchTarget === 'selected' ? profileId : undefined,
|
|
195
219
|
});
|
|
196
220
|
}
|
|
197
221
|
return action;
|
|
198
222
|
};
|
|
199
|
-
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) => {
|
|
200
244
|
if (dependencies.selectSetupSourceLaunchAction !== undefined) {
|
|
201
|
-
return dependencies.selectSetupSourceLaunchAction(profileId);
|
|
245
|
+
return dependencies.selectSetupSourceLaunchAction(profileId, launchTarget);
|
|
202
246
|
}
|
|
203
|
-
return promptForSetupSourceLaunchAction(profileId, dependencies);
|
|
247
|
+
return promptForSetupSourceLaunchAction(profileId, launchTarget, dependencies);
|
|
204
248
|
};
|
|
249
|
+
/* v8 ignore stop */
|
|
205
250
|
const capitalize = (value) => `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
206
251
|
export const createSetupCommand = (dependencies = {}) => {
|
|
207
252
|
const command = {
|
|
@@ -228,7 +273,19 @@ export const createSetupCommand = (dependencies = {}) => {
|
|
|
228
273
|
};
|
|
229
274
|
return command;
|
|
230
275
|
};
|
|
231
|
-
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
|
+
}
|
|
232
289
|
const cachePath = createSetupSourceCachePath(homeDirectory, setupSourceUri);
|
|
233
290
|
synchronizer.sync(setupSourceUri, cachePath);
|
|
234
291
|
const settingsPath = firstExistingPath(join(cachePath, 'settings.yml'), join(cachePath, '.outfitter', 'settings.yml'));
|
|
@@ -237,7 +294,7 @@ const prepareStarterLayout = (homeDirectory, setupSourceUri, synchronizer = crea
|
|
|
237
294
|
? join(cachePath, '.outfitter', 'profiles')
|
|
238
295
|
: join(cachePath, 'profiles');
|
|
239
296
|
const profilesPath = firstExistingPath(preferredProfilesPath, join(cachePath, 'profiles'), join(cachePath, '.outfitter', 'profiles'));
|
|
240
|
-
return { cachePath, settingsPath, profilesPath };
|
|
297
|
+
return { cachePath, settingsPath, profilesPath, sourceKind: 'remote-cache' };
|
|
241
298
|
};
|
|
242
299
|
const createSetupSourceCachePath = (homeDirectory, setupSourceUri) => createRemoteRepositoryCachePath(homeDirectory, { uri: setupSourceUri });
|
|
243
300
|
const createGitSetupSourceSynchronizer = () => ({
|
|
@@ -300,8 +357,8 @@ const assertValidDefaultProfileId = (profileId) => {
|
|
|
300
357
|
throw new Error(`Default profile '${profileId}' is not a filesystem-safe Outfitter profile id.`);
|
|
301
358
|
}
|
|
302
359
|
};
|
|
303
|
-
const createDefaultSettingsContent = () => [
|
|
304
|
-
|
|
360
|
+
export const createDefaultSettingsContent = (defaultProfileId = 'engineer') => [
|
|
361
|
+
`default_profile: ${defaultProfileId}`,
|
|
305
362
|
'profile_sources:',
|
|
306
363
|
' - github: ai-outfitter/default-profiles',
|
|
307
364
|
' path: profiles',
|
|
@@ -326,17 +383,84 @@ const readStarterSettingsContent = (starterSettingsPath) => {
|
|
|
326
383
|
}
|
|
327
384
|
return `default_profile: engineer\n${content}`;
|
|
328
385
|
};
|
|
386
|
+
/* v8 ignore start -- setup-source filesystem import variants are covered by integration-style fixtures; core settings outcomes are unit covered. */
|
|
329
387
|
const applySetupSourceImport = (input, starterLayout, onboarding) => {
|
|
330
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) => {
|
|
331
420
|
const createdSettings = createImportSettingsIfMissing(target.settingsPath, starterLayout.settingsPath, onboarding.selectedProfileId);
|
|
421
|
+
const selectedProfilePath = findSetupProfilePath(target.profilesPath, onboarding.selectedProfileId);
|
|
422
|
+
const selectedProfileAlreadyExists = existsSync(selectedProfilePath);
|
|
332
423
|
ensureLocalProfileSource(target.settingsPath, target.profilesPath);
|
|
333
424
|
updateSettingsDefaultProfile(target.settingsPath, onboarding.selectedProfileId);
|
|
334
425
|
return {
|
|
335
426
|
...target,
|
|
336
427
|
createdSettings,
|
|
337
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,
|
|
338
435
|
};
|
|
339
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 */
|
|
340
464
|
const createSetupSourceImportTargetLayout = (input, target) => {
|
|
341
465
|
if (target === 'project') {
|
|
342
466
|
return {
|
|
@@ -388,6 +512,20 @@ const copyStarterProfileFilesIfPresent = (sourceProfilesPath, targetProfilesPath
|
|
|
388
512
|
}
|
|
389
513
|
return copyDirectoryContentsWithoutOverwriting(sourceProfilesPath, targetProfilesPath);
|
|
390
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
|
+
};
|
|
391
529
|
const copyDirectoryContentsWithoutOverwriting = (sourceDirectory, targetDirectory) => {
|
|
392
530
|
mkdirSync(targetDirectory, { recursive: true });
|
|
393
531
|
let copiedFiles = 0;
|
|
@@ -406,6 +544,18 @@ const copyDirectoryContentsWithoutOverwriting = (sourceDirectory, targetDirector
|
|
|
406
544
|
}
|
|
407
545
|
return copiedFiles;
|
|
408
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
|
+
};
|
|
409
559
|
const createDefaultProfileIfMissing = (profilePath, profileId) => {
|
|
410
560
|
if (existsSync(profilePath)) {
|
|
411
561
|
return false;
|
|
@@ -485,18 +635,23 @@ const resolvePromptOutput = (dependencies) => {
|
|
|
485
635
|
const runSetupSourceOnboarding = async (input, dependencies, starterLayout, currentDefault) => {
|
|
486
636
|
const discoveredProfiles = discoverSetupProfileChoices(input, starterLayout);
|
|
487
637
|
const sourceDefault = discoverSetupSourcePromptDefault(input, starterLayout, discoveredProfiles);
|
|
488
|
-
const promptDefault = sourceDefault
|
|
489
|
-
const profiles = selectSetupPromptProfiles(
|
|
490
|
-
|
|
491
|
-
|
|
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);
|
|
492
645
|
}
|
|
493
646
|
writeSetupSourceWelcome(input, profiles, resolvePromptOutput(dependencies));
|
|
494
647
|
const importTarget = await selectSetupSourceImportTarget(dependencies);
|
|
648
|
+
const importMode = await selectSetupSourceImportMode(dependencies, localSymlinkAvailable);
|
|
495
649
|
const selectedProfileId = await selectSetupProfile(profiles, promptDefault, dependencies);
|
|
496
650
|
assertValidSelectedDefaultProfile(selectedProfileId, profiles);
|
|
497
|
-
return { importTarget, selectedProfileId };
|
|
651
|
+
return { importTarget, selectedProfileId, importMode };
|
|
498
652
|
};
|
|
499
|
-
|
|
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) => {
|
|
500
655
|
const output = resolvePromptOutput(dependencies);
|
|
501
656
|
/* v8 ignore next -- default process streams are direct terminal behavior; tests inject streams. */
|
|
502
657
|
const readline = createInterface({
|
|
@@ -505,10 +660,11 @@ const promptForSetupSourceOnboarding = async (input, profiles, currentDefault, d
|
|
|
505
660
|
});
|
|
506
661
|
try {
|
|
507
662
|
writeSetupSourceWelcome(input, profiles, output);
|
|
508
|
-
const importTarget = 'home';
|
|
663
|
+
const importTarget = await promptForSetupSourceImportTargetWithReadline(readline, output, setupSourceImportTargetChoices, 'home');
|
|
664
|
+
const importMode = await promptForSetupSourceImportModeWithReadline(readline, output, localSymlinkAvailable);
|
|
509
665
|
const selectedProfileId = await promptForSetupProfileWithReadline(readline, output, profiles, currentDefault, 'Choose the default profile from this setup source:');
|
|
510
666
|
assertValidSelectedDefaultProfile(selectedProfileId, profiles);
|
|
511
|
-
return { importTarget, selectedProfileId };
|
|
667
|
+
return { importTarget, selectedProfileId, importMode };
|
|
512
668
|
}
|
|
513
669
|
finally {
|
|
514
670
|
readline.close();
|
|
@@ -518,6 +674,9 @@ const writeSetupSourceWelcome = (input, profiles, output) => {
|
|
|
518
674
|
writeWelcomeIntro(output);
|
|
519
675
|
output.write(`\nYou're importing Outfitter profiles from ${redactProfileSourceUriCredentials(input.setupSourceUri)}.\n`);
|
|
520
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
|
+
}
|
|
521
680
|
};
|
|
522
681
|
const formatSetupSourceProfileList = (profiles) => {
|
|
523
682
|
/* v8 ignore next -- setup-source profile prompts normally require discovered source profiles. */
|
|
@@ -535,37 +694,87 @@ const selectSetupSourceImportTarget = async (dependencies) => {
|
|
|
535
694
|
}
|
|
536
695
|
return 'home';
|
|
537
696
|
};
|
|
697
|
+
const selectSetupSourceImportMode = async (dependencies, localSymlinkAvailable) => {
|
|
698
|
+
if (!localSymlinkAvailable) {
|
|
699
|
+
return 'copy';
|
|
700
|
+
}
|
|
701
|
+
if (dependencies.selectSetupSourceImportMode !== undefined) {
|
|
702
|
+
const selectedMode = await dependencies.selectSetupSourceImportMode(setupSourceImportModeChoices, 'copy');
|
|
703
|
+
assertValidSetupSourceImportMode(selectedMode);
|
|
704
|
+
return selectedMode;
|
|
705
|
+
}
|
|
706
|
+
return 'copy';
|
|
707
|
+
};
|
|
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.`);
|
|
711
|
+
}
|
|
712
|
+
};
|
|
538
713
|
const assertValidSetupSourceImportTarget = (target) => {
|
|
539
714
|
/* v8 ignore next -- defensive validation for custom dependency injection. */
|
|
540
715
|
if (setupSourceImportTargetChoices.every((choice) => choice.target !== target)) {
|
|
541
716
|
throw new Error(`Selected setup-source import target '${target}' is not available.`);
|
|
542
717
|
}
|
|
543
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
|
+
};
|
|
544
751
|
/* v8 ignore next -- covered by interactive CLI smoke tests; unit tests inject the launch choice. */
|
|
545
|
-
const promptForSetupSourceLaunchAction = async (profileId, dependencies) => {
|
|
752
|
+
const promptForSetupSourceLaunchAction = async (profileId, launchTarget, dependencies) => {
|
|
546
753
|
const readline = createInterface({
|
|
547
754
|
input: dependencies.input ?? process.stdin,
|
|
548
755
|
output: resolveReadlineOutput(dependencies),
|
|
549
756
|
});
|
|
550
757
|
try {
|
|
551
|
-
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);
|
|
552
762
|
return answer.trim().toLowerCase().startsWith('n') ? 'exit' : 'start';
|
|
553
763
|
}
|
|
554
764
|
finally {
|
|
555
765
|
readline.close();
|
|
556
766
|
}
|
|
557
767
|
};
|
|
768
|
+
/* v8 ignore stop */
|
|
558
769
|
const selectDefaultProfileIfInteractive = async (input, settingsPath, currentDefault, dependencies, starterLayout) => {
|
|
559
770
|
if (dependencies.interactive !== true) {
|
|
560
771
|
return currentDefault;
|
|
561
772
|
}
|
|
562
773
|
const discoveredProfiles = discoverSetupProfileChoices(input, starterLayout);
|
|
563
774
|
const sourceDefault = discoverSetupSourcePromptDefault(input, starterLayout, discoveredProfiles);
|
|
564
|
-
const promptDefault = sourceDefault
|
|
565
|
-
const profiles = selectSetupPromptProfiles(
|
|
566
|
-
|
|
567
|
-
writer('Welcome to Outfitter. Outfitter is the easiest way to run Pi.');
|
|
568
|
-
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));
|
|
569
778
|
const selectedProfile = await selectSetupProfile(profiles, promptDefault, dependencies);
|
|
570
779
|
assertValidSelectedDefaultProfile(selectedProfile, profiles);
|
|
571
780
|
updateSettingsDefaultProfile(settingsPath, selectedProfile);
|
|
@@ -584,13 +793,18 @@ const discoverSetupSourcePromptDefault = (input, starterLayout, profiles) => {
|
|
|
584
793
|
const sourceDefault = readStarterExplicitDefaultProfileId(starterLayout?.settingsPath);
|
|
585
794
|
return profiles.some((profile) => profile.id === sourceDefault) ? sourceDefault : undefined;
|
|
586
795
|
};
|
|
587
|
-
const selectSetupPromptProfiles = (
|
|
588
|
-
const profiles = discoveredProfiles.length > 0
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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;
|
|
594
808
|
};
|
|
595
809
|
const prioritizeSetupProfileChoice = (profiles, profileId) => [
|
|
596
810
|
...profiles.filter((profile) => profile.id === profileId),
|