@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.
Files changed (81) hide show
  1. package/LICENSE.md +20 -50
  2. package/README.md +36 -280
  3. package/code/enterprise/LICENSE +35 -0
  4. package/code/enterprise/README.md +5 -0
  5. package/dist/agents/AdapterProfileControls.d.ts +2 -2
  6. package/dist/agents/AdapterProfileControls.js +8 -1
  7. package/dist/agents/AdapterProfileControls.js.map +1 -1
  8. package/dist/agents/AgentAdapter.d.ts +10 -0
  9. package/dist/agents/AgentLaunch.d.ts +6 -0
  10. package/dist/agents/AgentLaunch.js +89 -0
  11. package/dist/agents/AgentLaunch.js.map +1 -0
  12. package/dist/agents/claude/ClaudeAdapter.js +18 -3
  13. package/dist/agents/claude/ClaudeAdapter.js.map +1 -1
  14. package/dist/agents/pi/PiAdapter.js +154 -14
  15. package/dist/agents/pi/PiAdapter.js.map +1 -1
  16. package/dist/cli/commands/PiLoginLaunch.d.ts +8 -2
  17. package/dist/cli/commands/PiLoginLaunch.js +726 -75
  18. package/dist/cli/commands/PiLoginLaunch.js.map +1 -1
  19. package/dist/cli/commands/RunCommand.d.ts +20 -3
  20. package/dist/cli/commands/RunCommand.js +102 -20
  21. package/dist/cli/commands/RunCommand.js.map +1 -1
  22. package/dist/cli/commands/SetupCommand.d.ts +11 -2
  23. package/dist/cli/commands/SetupCommand.js +266 -52
  24. package/dist/cli/commands/SetupCommand.js.map +1 -1
  25. package/dist/cli/commands/SyncCommand.d.ts +8 -1
  26. package/dist/cli/commands/SyncCommand.js +2 -1
  27. package/dist/cli/commands/SyncCommand.js.map +1 -1
  28. package/dist/cli/commands/WelcomeCommand.js +1 -1
  29. package/dist/cli/commands/WelcomeCommand.js.map +1 -1
  30. package/dist/cli/commands/assets/outfitter-ascii.txt +5 -0
  31. package/dist/cli/commands/profile/Command.d.ts +1 -0
  32. package/dist/cli/commands/profile/Command.js +3 -0
  33. package/dist/cli/commands/profile/Command.js.map +1 -1
  34. package/dist/cli/commands/profile/LintCommand.d.ts +19 -0
  35. package/dist/cli/commands/profile/LintCommand.js +123 -0
  36. package/dist/cli/commands/profile/LintCommand.js.map +1 -0
  37. package/dist/cli.js +8 -2
  38. package/dist/cli.js.map +1 -1
  39. package/dist/merge/ArrayMergePolicy.js.map +1 -1
  40. package/dist/merge/SettingsValueMerger.js.map +1 -1
  41. package/dist/profiles/Profile.d.ts +13 -1
  42. package/dist/profiles/Profile.js.map +1 -1
  43. package/dist/profiles/ProfileLoader.d.ts +4 -0
  44. package/dist/profiles/ProfileLoader.js +117 -17
  45. package/dist/profiles/ProfileLoader.js.map +1 -1
  46. package/dist/profiles/ProfileMerger.js +3 -0
  47. package/dist/profiles/ProfileMerger.js.map +1 -1
  48. package/dist/profiles/PromptIncludes.d.ts +32 -0
  49. package/dist/profiles/PromptIncludes.js +147 -0
  50. package/dist/profiles/PromptIncludes.js.map +1 -0
  51. package/dist/prompts/SystemPromptExport.d.ts +16 -0
  52. package/dist/prompts/SystemPromptExport.js +81 -0
  53. package/dist/prompts/SystemPromptExport.js.map +1 -0
  54. package/dist/schemas/profile.schema.json +37 -2
  55. package/dist/schemas/settings.schema.json +12 -0
  56. package/dist/settings/Settings.d.ts +5 -0
  57. package/dist/settings/Settings.js.map +1 -1
  58. package/dist/settings/SettingsLoader.js +3 -0
  59. package/dist/settings/SettingsLoader.js.map +1 -1
  60. package/dist/settings/SettingsMerger.js +8 -0
  61. package/dist/settings/SettingsMerger.js.map +1 -1
  62. package/package.json +8 -11
  63. package/src/schemas/profile.schema.json +37 -2
  64. package/src/schemas/settings.schema.json +12 -0
  65. package/doc/.deepreview +0 -30
  66. package/doc/architecture.md +0 -856
  67. package/doc/controllable-elements.md +0 -162
  68. package/doc/file_structure.md +0 -141
  69. package/doc/integration_test_system.md +0 -214
  70. package/doc/specs/validating_requirements_with_rules.md +0 -55
  71. package/doc/state_writeback_strategy.md +0 -342
  72. package/requirements/OFTR-001-project-foundation.md +0 -53
  73. package/requirements/OFTR-002-settings.md +0 -65
  74. package/requirements/OFTR-003-profiles.md +0 -60
  75. package/requirements/OFTR-004-sync-and-setup.md +0 -67
  76. package/requirements/OFTR-005-run-and-composite-profile.md +0 -60
  77. package/requirements/OFTR-006-agent-adapters.md +0 -66
  78. package/requirements/OFTR-007-controllable-elements.md +0 -32
  79. package/requirements/OFTR-008-requirements-governance.md +0 -42
  80. package/requirements/OFTR-009-release-publishing.md +0 -35
  81. 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 = join(appliedImport.profilesPath, onboarding.selectedProfileId, 'profile.yml');
124
- const createdDefaultProfile = createDefaultProfileIfMissing(defaultProfilePath, onboarding.selectedProfileId);
125
- const postImportAction = await runSetupSourcePostImportAction(input, dependencies, onboarding.selectedProfileId);
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
- runExampleMessages: postImportAction === 'exit' ? [formatRunProfileExample(onboarding.selectedProfileId)] : [],
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 runSetupSourcePostImportAction = async (input, dependencies, profileId) => {
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 selectSetupSourceLaunchAction = async (profileId, dependencies) => {
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
- 'default_profile: engineer',
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 ?? currentDefault;
489
- const profiles = selectSetupPromptProfiles(input, discoveredProfiles, currentDefault, sourceDefault);
490
- if (dependencies.selectSetupSourceImportTarget === undefined && dependencies.selectDefaultProfile === undefined) {
491
- return promptForSetupSourceOnboarding(input, profiles, promptDefault, dependencies);
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
- const promptForSetupSourceOnboarding = async (input, profiles, currentDefault, dependencies) => {
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 answer = await readline.question(`Start Outfitter with profile '${profileId}' now? [Y/n]: `);
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 ?? currentDefault;
565
- const profiles = selectSetupPromptProfiles(input, discoveredProfiles, currentDefault, sourceDefault);
566
- const writer = dependencies.writeLine ?? console.log;
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 = (input, discoveredProfiles, currentDefault, sourceDefault) => {
588
- const profiles = discoveredProfiles.length > 0 ||
589
- input.setupSourceUri !== undefined ||
590
- builtInSetupProfileChoices.every((profile) => profile.id !== currentDefault)
591
- ? discoveredProfiles
592
- : builtInSetupProfileChoices;
593
- return sourceDefault === undefined ? profiles : prioritizeSetupProfileChoice(profiles, sourceDefault);
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),