@ai-outfitter/outfitter 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE.md +20 -50
  2. package/README.md +36 -247
  3. package/code/enterprise/LICENSE +35 -0
  4. package/code/enterprise/README.md +5 -0
  5. package/dist/agents/AdapterProfileControls.d.ts +2 -2
  6. package/dist/agents/AdapterProfileControls.js +8 -1
  7. package/dist/agents/AdapterProfileControls.js.map +1 -1
  8. package/dist/agents/AgentAdapter.d.ts +10 -0
  9. package/dist/agents/AgentLaunch.d.ts +6 -0
  10. package/dist/agents/AgentLaunch.js +89 -0
  11. package/dist/agents/AgentLaunch.js.map +1 -0
  12. package/dist/agents/claude/ClaudeAdapter.js +18 -3
  13. package/dist/agents/claude/ClaudeAdapter.js.map +1 -1
  14. package/dist/agents/pi/PiAdapter.js +159 -15
  15. package/dist/agents/pi/PiAdapter.js.map +1 -1
  16. package/dist/cli/commands/FirstRunWelcomeProfile.js +8 -5
  17. package/dist/cli/commands/FirstRunWelcomeProfile.js.map +1 -1
  18. package/dist/cli/commands/PiLoginLaunch.d.ts +8 -2
  19. package/dist/cli/commands/PiLoginLaunch.js +739 -30
  20. package/dist/cli/commands/PiLoginLaunch.js.map +1 -1
  21. package/dist/cli/commands/RunCommand.d.ts +20 -3
  22. package/dist/cli/commands/RunCommand.js +102 -20
  23. package/dist/cli/commands/RunCommand.js.map +1 -1
  24. package/dist/cli/commands/SetupCommand.d.ts +12 -2
  25. package/dist/cli/commands/SetupCommand.js +267 -70
  26. package/dist/cli/commands/SetupCommand.js.map +1 -1
  27. package/dist/cli/commands/SyncCommand.d.ts +8 -1
  28. package/dist/cli/commands/SyncCommand.js +2 -1
  29. package/dist/cli/commands/SyncCommand.js.map +1 -1
  30. package/dist/cli/commands/WelcomeCommand.d.ts +2 -1
  31. package/dist/cli/commands/WelcomeCommand.js +77 -70
  32. package/dist/cli/commands/WelcomeCommand.js.map +1 -1
  33. package/dist/cli/commands/assets/outfitter-ascii.txt +5 -0
  34. package/dist/cli/commands/profile/Command.d.ts +1 -0
  35. package/dist/cli/commands/profile/Command.js +3 -0
  36. package/dist/cli/commands/profile/Command.js.map +1 -1
  37. package/dist/cli/commands/profile/LintCommand.d.ts +19 -0
  38. package/dist/cli/commands/profile/LintCommand.js +123 -0
  39. package/dist/cli/commands/profile/LintCommand.js.map +1 -0
  40. package/dist/cli.js +8 -2
  41. package/dist/cli.js.map +1 -1
  42. package/dist/compositeProfile/StatePersistence.js +3 -0
  43. package/dist/compositeProfile/StatePersistence.js.map +1 -1
  44. package/dist/merge/ArrayMergePolicy.js.map +1 -1
  45. package/dist/merge/SettingsValueMerger.js.map +1 -1
  46. package/dist/profiles/Profile.d.ts +14 -1
  47. package/dist/profiles/Profile.js.map +1 -1
  48. package/dist/profiles/ProfileLoader.d.ts +4 -0
  49. package/dist/profiles/ProfileLoader.js +118 -17
  50. package/dist/profiles/ProfileLoader.js.map +1 -1
  51. package/dist/profiles/ProfileMerger.js +3 -0
  52. package/dist/profiles/ProfileMerger.js.map +1 -1
  53. package/dist/profiles/PromptIncludes.d.ts +32 -0
  54. package/dist/profiles/PromptIncludes.js +147 -0
  55. package/dist/profiles/PromptIncludes.js.map +1 -0
  56. package/dist/prompts/SystemPromptExport.d.ts +16 -0
  57. package/dist/prompts/SystemPromptExport.js +81 -0
  58. package/dist/prompts/SystemPromptExport.js.map +1 -0
  59. package/dist/schemas/profile.schema.json +38 -2
  60. package/dist/schemas/settings.schema.json +12 -0
  61. package/dist/settings/Settings.d.ts +5 -0
  62. package/dist/settings/Settings.js.map +1 -1
  63. package/dist/settings/SettingsLoader.js +3 -0
  64. package/dist/settings/SettingsLoader.js.map +1 -1
  65. package/dist/settings/SettingsMerger.js +8 -0
  66. package/dist/settings/SettingsMerger.js.map +1 -1
  67. package/package.json +23 -11
  68. package/skills/outfitter/SKILL.md +68 -0
  69. package/src/schemas/profile.schema.json +38 -2
  70. package/src/schemas/settings.schema.json +12 -0
  71. package/doc/.deepreview +0 -30
  72. package/doc/architecture.md +0 -855
  73. package/doc/controllable-elements.md +0 -162
  74. package/doc/file_structure.md +0 -133
  75. package/doc/integration_test_system.md +0 -214
  76. package/doc/specs/validating_requirements_with_rules.md +0 -55
  77. package/doc/state_writeback_strategy.md +0 -334
  78. package/requirements/OFTR-001-project-foundation.md +0 -53
  79. package/requirements/OFTR-002-settings.md +0 -65
  80. package/requirements/OFTR-003-profiles.md +0 -59
  81. package/requirements/OFTR-004-sync-and-setup.md +0 -67
  82. package/requirements/OFTR-005-run-and-composite-profile.md +0 -60
  83. package/requirements/OFTR-006-agent-adapters.md +0 -66
  84. package/requirements/OFTR-007-controllable-elements.md +0 -32
  85. package/requirements/OFTR-008-requirements-governance.md +0 -42
  86. package/requirements/OFTR-009-release-publishing.md +0 -34
  87. package/requirements/OFTR-010-onboarding-welcome.md +0 -39
@@ -1,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 = join(appliedImport.profilesPath, onboarding.selectedProfileId, 'profile.yml');
116
- const createdDefaultProfile = createDefaultProfileIfMissing(defaultProfilePath, onboarding.selectedProfileId);
117
- 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);
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
- 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
+ : [],
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 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) => {
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 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) => {
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
- 'default_profile: engineer',
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 ?? currentDefault;
481
- const profiles = selectSetupPromptProfiles(input, discoveredProfiles, currentDefault, sourceDefault);
482
- if (dependencies.selectSetupSourceImportTarget === undefined && dependencies.selectDefaultProfile === undefined) {
483
- 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);
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
- 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) => {
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
- /* v8 ignore next -- mixed dependency injection path; the full readline path prompts with one shared readline. */
529
- return promptForSetupSourceImportTarget(dependencies);
695
+ return 'home';
530
696
  };
531
- /* v8 ignore next -- covered by the shared readline setup-source onboarding prompt in normal CLI usage. */
532
- const promptForSetupSourceImportTarget = async (dependencies) => {
533
- const output = resolvePromptOutput(dependencies);
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
- finally {
543
- readline.close();
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 promptForSetupSourceImportTargetWithReadline = async (readline, output) => {
547
- output.write('\nChoose where to install these profiles:\n');
548
- setupSourceImportTargetChoices.forEach((choice, index) => {
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 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);
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 ?? currentDefault;
587
- const profiles = selectSetupPromptProfiles(input, discoveredProfiles, currentDefault, sourceDefault);
588
- const writer = dependencies.writeLine ?? console.log;
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 = (input, discoveredProfiles, currentDefault, sourceDefault) => {
610
- const profiles = discoveredProfiles.length > 0 ||
611
- input.setupSourceUri !== undefined ||
612
- builtInSetupProfileChoices.every((profile) => profile.id !== currentDefault)
613
- ? discoveredProfiles
614
- : builtInSetupProfileChoices;
615
- 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;
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}]: `);