@ai-outfitter/outfitter 0.6.1 → 0.7.1

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 (93) hide show
  1. package/LICENSE.md +20 -50
  2. package/README.md +41 -280
  3. package/code/enterprise/LICENSE +35 -0
  4. package/code/enterprise/README.md +7 -0
  5. package/code/enterprise/cli/privateCatalogGate.cjs +134 -0
  6. package/code/enterprise/cli/privateCatalogSettings.cjs +59 -0
  7. package/code/enterprise/pi-extension/privateCatalogOnboarding.js +89 -0
  8. package/code/enterprise/private-catalog-boundary.json +9 -0
  9. package/code/enterprise/privateCatalog.js +24 -0
  10. package/code/enterprise/shared/privateCatalogPolicy.cjs +66 -0
  11. package/dist/agents/AdapterProfileControls.d.ts +2 -2
  12. package/dist/agents/AdapterProfileControls.js +8 -1
  13. package/dist/agents/AdapterProfileControls.js.map +1 -1
  14. package/dist/agents/AgentAdapter.d.ts +11 -0
  15. package/dist/agents/AgentLaunch.d.ts +6 -0
  16. package/dist/agents/AgentLaunch.js +89 -0
  17. package/dist/agents/AgentLaunch.js.map +1 -0
  18. package/dist/agents/claude/ClaudeAdapter.js +18 -3
  19. package/dist/agents/claude/ClaudeAdapter.js.map +1 -1
  20. package/dist/agents/pi/PiAdapter.js +162 -33
  21. package/dist/agents/pi/PiAdapter.js.map +1 -1
  22. package/dist/agents/pi/PiArgs.d.ts +2 -0
  23. package/dist/agents/pi/PiArgs.js +15 -0
  24. package/dist/agents/pi/PiArgs.js.map +1 -0
  25. package/dist/agents/pi/PiExtensionCache.d.ts +4 -0
  26. package/dist/agents/pi/PiExtensionCache.js +105 -0
  27. package/dist/agents/pi/PiExtensionCache.js.map +1 -0
  28. package/dist/cli/commands/PiLoginLaunch.d.ts +9 -2
  29. package/dist/cli/commands/PiLoginLaunch.js +817 -75
  30. package/dist/cli/commands/PiLoginLaunch.js.map +1 -1
  31. package/dist/cli/commands/RunCommand.d.ts +22 -3
  32. package/dist/cli/commands/RunCommand.js +104 -20
  33. package/dist/cli/commands/RunCommand.js.map +1 -1
  34. package/dist/cli/commands/SetupCommand.d.ts +19 -2
  35. package/dist/cli/commands/SetupCommand.js +281 -57
  36. package/dist/cli/commands/SetupCommand.js.map +1 -1
  37. package/dist/cli/commands/SyncCommand.d.ts +19 -1
  38. package/dist/cli/commands/SyncCommand.js +47 -6
  39. package/dist/cli/commands/SyncCommand.js.map +1 -1
  40. package/dist/cli/commands/WelcomeCommand.js +1 -1
  41. package/dist/cli/commands/WelcomeCommand.js.map +1 -1
  42. package/dist/cli/commands/assets/outfitter-ascii.txt +5 -0
  43. package/dist/cli/commands/profile/Command.d.ts +1 -0
  44. package/dist/cli/commands/profile/Command.js +3 -0
  45. package/dist/cli/commands/profile/Command.js.map +1 -1
  46. package/dist/cli/commands/profile/LintCommand.d.ts +19 -0
  47. package/dist/cli/commands/profile/LintCommand.js +123 -0
  48. package/dist/cli/commands/profile/LintCommand.js.map +1 -0
  49. package/dist/cli.js +8 -2
  50. package/dist/cli.js.map +1 -1
  51. package/dist/merge/ArrayMergePolicy.js.map +1 -1
  52. package/dist/merge/SettingsValueMerger.js.map +1 -1
  53. package/dist/profiles/Profile.d.ts +13 -1
  54. package/dist/profiles/Profile.js.map +1 -1
  55. package/dist/profiles/ProfileLoader.d.ts +4 -0
  56. package/dist/profiles/ProfileLoader.js +117 -17
  57. package/dist/profiles/ProfileLoader.js.map +1 -1
  58. package/dist/profiles/ProfileMerger.js +3 -0
  59. package/dist/profiles/ProfileMerger.js.map +1 -1
  60. package/dist/profiles/PromptIncludes.d.ts +32 -0
  61. package/dist/profiles/PromptIncludes.js +147 -0
  62. package/dist/profiles/PromptIncludes.js.map +1 -0
  63. package/dist/prompts/SystemPromptExport.d.ts +16 -0
  64. package/dist/prompts/SystemPromptExport.js +81 -0
  65. package/dist/prompts/SystemPromptExport.js.map +1 -0
  66. package/dist/schemas/profile.schema.json +37 -2
  67. package/dist/schemas/settings.schema.json +23 -0
  68. package/dist/settings/Settings.d.ts +9 -0
  69. package/dist/settings/Settings.js.map +1 -1
  70. package/dist/settings/SettingsLoader.js +5 -0
  71. package/dist/settings/SettingsLoader.js.map +1 -1
  72. package/dist/settings/SettingsMerger.js +11 -0
  73. package/dist/settings/SettingsMerger.js.map +1 -1
  74. package/package.json +7 -11
  75. package/src/schemas/profile.schema.json +37 -2
  76. package/src/schemas/settings.schema.json +23 -0
  77. package/doc/.deepreview +0 -30
  78. package/doc/architecture.md +0 -856
  79. package/doc/controllable-elements.md +0 -162
  80. package/doc/file_structure.md +0 -141
  81. package/doc/integration_test_system.md +0 -214
  82. package/doc/specs/validating_requirements_with_rules.md +0 -55
  83. package/doc/state_writeback_strategy.md +0 -342
  84. package/requirements/OFTR-001-project-foundation.md +0 -53
  85. package/requirements/OFTR-002-settings.md +0 -65
  86. package/requirements/OFTR-003-profiles.md +0 -60
  87. package/requirements/OFTR-004-sync-and-setup.md +0 -67
  88. package/requirements/OFTR-005-run-and-composite-profile.md +0 -60
  89. package/requirements/OFTR-006-agent-adapters.md +0 -66
  90. package/requirements/OFTR-007-controllable-elements.md +0 -32
  91. package/requirements/OFTR-008-requirements-governance.md +0 -42
  92. package/requirements/OFTR-009-release-publishing.md +0 -35
  93. 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 = {
@@ -212,23 +257,45 @@ export const createSetupCommand = (dependencies = {}) => {
212
257
  .command(`${command.name} [source]`)
213
258
  .description(command.description)
214
259
  .action(async (source) => {
215
- const result = await executeSetupCommand({
260
+ const input = {
216
261
  /* v8 ignore next -- default process home is exercised by the direct CLI entrypoint, not unit tests. */
217
262
  homeDirectory: dependencies.homeDirectory ?? homedir(),
218
263
  /* v8 ignore next -- default process cwd is exercised by the direct CLI entrypoint, not unit tests. */
219
264
  projectDirectory: dependencies.projectDirectory ?? process.cwd(),
220
265
  setupSourceUri: source,
221
- }, { ...dependencies, interactive: true });
222
- for (const message of result.messages) {
223
- /* v8 ignore next -- console fallback is direct CLI behavior; tests inject a writer. */
224
- (dependencies.writeLine ?? console.log)(message);
266
+ };
267
+ const result = dependencies.launchPiOnboarding === undefined
268
+ ? await launchPiOnboardingWithRunCommand(input, dependencies)
269
+ : await dependencies.launchPiOnboarding(input);
270
+ if (result.exitCode !== 0) {
271
+ process.exitCode = result.exitCode;
225
272
  }
226
273
  });
227
274
  },
228
275
  };
229
276
  return command;
230
277
  };
231
- const prepareStarterLayout = (homeDirectory, setupSourceUri, synchronizer = createGitSetupSourceSynchronizer()) => {
278
+ const launchPiOnboardingWithRunCommand = async (input, dependencies) => {
279
+ const { executeRunCommand } = await import('./RunCommand.js');
280
+ return executeRunCommand({
281
+ ...input,
282
+ agentId: 'pi',
283
+ forceRuntimeOnboarding: true,
284
+ }, { ...dependencies, interactive: true });
285
+ };
286
+ const prepareStarterLayout = (homeDirectory, projectDirectory, setupSourceUri, synchronizer = createGitSetupSourceSynchronizer()) => {
287
+ const localOutfitterPath = resolveLocalSetupSourceOutfitterPathFromUri(setupSourceUri, projectDirectory);
288
+ if (localOutfitterPath !== undefined) {
289
+ const settingsPath = join(localOutfitterPath, 'settings.yml');
290
+ validateStarterSettingsIfPresent(existsSync(settingsPath) ? settingsPath : undefined);
291
+ return {
292
+ cachePath: localOutfitterPath,
293
+ settingsPath: existsSync(settingsPath) ? settingsPath : undefined,
294
+ profilesPath: firstExistingPath(join(localOutfitterPath, 'profiles')),
295
+ sourceKind: 'local-live',
296
+ sourceOutfitterPath: localOutfitterPath,
297
+ };
298
+ }
232
299
  const cachePath = createSetupSourceCachePath(homeDirectory, setupSourceUri);
233
300
  synchronizer.sync(setupSourceUri, cachePath);
234
301
  const settingsPath = firstExistingPath(join(cachePath, 'settings.yml'), join(cachePath, '.outfitter', 'settings.yml'));
@@ -237,7 +304,7 @@ const prepareStarterLayout = (homeDirectory, setupSourceUri, synchronizer = crea
237
304
  ? join(cachePath, '.outfitter', 'profiles')
238
305
  : join(cachePath, 'profiles');
239
306
  const profilesPath = firstExistingPath(preferredProfilesPath, join(cachePath, 'profiles'), join(cachePath, '.outfitter', 'profiles'));
240
- return { cachePath, settingsPath, profilesPath };
307
+ return { cachePath, settingsPath, profilesPath, sourceKind: 'remote-cache' };
241
308
  };
242
309
  const createSetupSourceCachePath = (homeDirectory, setupSourceUri) => createRemoteRepositoryCachePath(homeDirectory, { uri: setupSourceUri });
243
310
  const createGitSetupSourceSynchronizer = () => ({
@@ -300,8 +367,8 @@ const assertValidDefaultProfileId = (profileId) => {
300
367
  throw new Error(`Default profile '${profileId}' is not a filesystem-safe Outfitter profile id.`);
301
368
  }
302
369
  };
303
- const createDefaultSettingsContent = () => [
304
- 'default_profile: engineer',
370
+ export const createDefaultSettingsContent = (defaultProfileId = 'engineer') => [
371
+ `default_profile: ${defaultProfileId}`,
305
372
  'profile_sources:',
306
373
  ' - github: ai-outfitter/default-profiles',
307
374
  ' path: profiles',
@@ -326,17 +393,84 @@ const readStarterSettingsContent = (starterSettingsPath) => {
326
393
  }
327
394
  return `default_profile: engineer\n${content}`;
328
395
  };
396
+ /* v8 ignore start -- setup-source filesystem import variants are covered by integration-style fixtures; core settings outcomes are unit covered. */
329
397
  const applySetupSourceImport = (input, starterLayout, onboarding) => {
330
398
  const target = createSetupSourceImportTargetLayout(input, onboarding.importTarget);
399
+ if (onboarding.importMode === 'symlink') {
400
+ return applySetupSourceSymlinkImport(input, target, onboarding);
401
+ }
402
+ return applySetupSourceCopyImport(starterLayout, target, onboarding);
403
+ };
404
+ const applySetupSourceSymlinkImport = (input, target, onboarding) => {
405
+ const sourceOutfitterPath = resolveLocalSetupSourceOutfitterPath(input);
406
+ if (sourceOutfitterPath === undefined) {
407
+ throw new Error('Local setup-source symlink mode requires a source .outfitter directory.');
408
+ }
409
+ const sourceSettingsPath = join(sourceOutfitterPath, 'settings.yml');
410
+ if (!existsSync(sourceSettingsPath)) {
411
+ throw new Error('Local setup-source symlink mode requires source .outfitter/settings.yml.');
412
+ }
413
+ validateStarterSettingsIfPresent(sourceSettingsPath);
414
+ const sourceProfilesPath = join(sourceOutfitterPath, 'profiles');
415
+ const sourceSelectedProfilePath = findSetupProfilePath(sourceProfilesPath, onboarding.selectedProfileId);
416
+ if (!existsSync(sourceSelectedProfilePath)) {
417
+ throw new Error(`Local setup-source symlink mode requires selected profile '${onboarding.selectedProfileId}'.`);
418
+ }
419
+ symlinkLocalOutfitterSource(sourceOutfitterPath, dirname(target.settingsPath));
420
+ return {
421
+ ...target,
422
+ createdSettings: false,
423
+ copiedStarterProfileFiles: 0,
424
+ copiedStarterResourceFiles: 0,
425
+ selectedProfileAlreadyExists: false,
426
+ symlinkedOutfitter: true,
427
+ };
428
+ };
429
+ const applySetupSourceCopyImport = (starterLayout, target, onboarding) => {
331
430
  const createdSettings = createImportSettingsIfMissing(target.settingsPath, starterLayout.settingsPath, onboarding.selectedProfileId);
431
+ const selectedProfilePath = findSetupProfilePath(target.profilesPath, onboarding.selectedProfileId);
432
+ const selectedProfileAlreadyExists = existsSync(selectedProfilePath);
332
433
  ensureLocalProfileSource(target.settingsPath, target.profilesPath);
333
434
  updateSettingsDefaultProfile(target.settingsPath, onboarding.selectedProfileId);
334
435
  return {
335
436
  ...target,
336
437
  createdSettings,
337
438
  copiedStarterProfileFiles: copyStarterProfileFilesIfPresent(starterLayout.profilesPath, target.profilesPath),
439
+ copiedStarterResourceFiles: copyStarterResourceFilesIfPresent(starterLayout.profilesPath, dirname(target.settingsPath)),
440
+ selectedProfileAlreadyExists,
441
+ selectedProfileConflictMessage: selectedProfileAlreadyExists
442
+ ? `Existing selected setup-source profile '${onboarding.selectedProfileId}' at ${selectedProfilePath} was not overwritten.`
443
+ : undefined,
444
+ symlinkedOutfitter: false,
338
445
  };
339
446
  };
447
+ /* v8 ignore stop */
448
+ /* v8 ignore start -- local setup-source path probing and symlink safety are covered by filesystem integration tests. */
449
+ const resolveLocalSetupSourceOutfitterPath = (input) => input.setupSourceUri === undefined
450
+ ? undefined
451
+ : resolveLocalSetupSourceOutfitterPathFromUri(input.setupSourceUri, input.projectDirectory);
452
+ const resolveLocalSetupSourceOutfitterPathFromUri = (setupSourceUri, projectDirectory) => {
453
+ if (isRemoteSetupSourceUri(setupSourceUri)) {
454
+ return undefined;
455
+ }
456
+ const sourcePath = isAbsolute(setupSourceUri) ? setupSourceUri : resolve(projectDirectory, setupSourceUri);
457
+ const outfitterPath = sourcePath.endsWith('.outfitter') ? sourcePath : join(sourcePath, '.outfitter');
458
+ return existsSync(outfitterPath) ? outfitterPath : undefined;
459
+ };
460
+ const isRemoteSetupSourceUri = (source) => /^[a-z][a-z0-9+.-]*:/iu.test(source) && !isAbsolute(source);
461
+ const symlinkLocalOutfitterSource = (sourceOutfitterPath, targetOutfitterPath) => {
462
+ if (existsSync(targetOutfitterPath)) {
463
+ const entries = readdirSync(targetOutfitterPath);
464
+ if (entries.length > 0) {
465
+ throw new Error(`Cannot symlink local setup source into non-empty .outfitter directory '${targetOutfitterPath}'. ` +
466
+ 'Move it aside or use copy snapshot setup.');
467
+ }
468
+ rmSync(targetOutfitterPath, { recursive: true, force: true });
469
+ }
470
+ mkdirSync(dirname(targetOutfitterPath), { recursive: true });
471
+ symlinkSync(sourceOutfitterPath, targetOutfitterPath, 'dir');
472
+ };
473
+ /* v8 ignore stop */
340
474
  const createSetupSourceImportTargetLayout = (input, target) => {
341
475
  if (target === 'project') {
342
476
  return {
@@ -388,6 +522,20 @@ const copyStarterProfileFilesIfPresent = (sourceProfilesPath, targetProfilesPath
388
522
  }
389
523
  return copyDirectoryContentsWithoutOverwriting(sourceProfilesPath, targetProfilesPath);
390
524
  };
525
+ const copyStarterResourceFilesIfPresent = (sourceProfilesPath, targetOutfitterPath) => {
526
+ if (sourceProfilesPath === undefined) {
527
+ return 0;
528
+ }
529
+ const sourceOutfitterPath = dirname(sourceProfilesPath);
530
+ return ['prompts', 'deepwork', 'skills'].reduce((copiedFiles, resourceName) => copiedFiles + copyNamedStarterResourceDirectoryIfPresent(sourceOutfitterPath, targetOutfitterPath, resourceName), 0);
531
+ };
532
+ const copyNamedStarterResourceDirectoryIfPresent = (sourceOutfitterPath, targetOutfitterPath, resourceName) => {
533
+ const sourceResourcePath = join(sourceOutfitterPath, resourceName);
534
+ if (!existsSync(sourceResourcePath)) {
535
+ return 0;
536
+ }
537
+ return copyDirectoryContentsWithoutOverwriting(sourceResourcePath, join(targetOutfitterPath, resourceName));
538
+ };
391
539
  const copyDirectoryContentsWithoutOverwriting = (sourceDirectory, targetDirectory) => {
392
540
  mkdirSync(targetDirectory, { recursive: true });
393
541
  let copiedFiles = 0;
@@ -406,6 +554,18 @@ const copyDirectoryContentsWithoutOverwriting = (sourceDirectory, targetDirector
406
554
  }
407
555
  return copiedFiles;
408
556
  };
557
+ const findSetupProfilePath = (profilesPath, profileId) => {
558
+ for (const profilePath of [
559
+ join(profilesPath, `${profileId}.yml`),
560
+ join(profilesPath, `${profileId}.yaml`),
561
+ join(profilesPath, profileId, 'profile.yml'),
562
+ ]) {
563
+ if (existsSync(profilePath)) {
564
+ return profilePath;
565
+ }
566
+ }
567
+ return join(profilesPath, profileId, 'profile.yml');
568
+ };
409
569
  const createDefaultProfileIfMissing = (profilePath, profileId) => {
410
570
  if (existsSync(profilePath)) {
411
571
  return false;
@@ -485,18 +645,23 @@ const resolvePromptOutput = (dependencies) => {
485
645
  const runSetupSourceOnboarding = async (input, dependencies, starterLayout, currentDefault) => {
486
646
  const discoveredProfiles = discoverSetupProfileChoices(input, starterLayout);
487
647
  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);
648
+ const promptDefault = chooseSetupPromptDefault(discoveredProfiles, sourceDefault, currentDefault);
649
+ const profiles = selectSetupPromptProfiles(discoveredProfiles, currentDefault, promptDefault);
650
+ const localSymlinkAvailable = resolveLocalSetupSourceOutfitterPath(input) !== undefined;
651
+ if (dependencies.selectSetupSourceImportTarget === undefined &&
652
+ dependencies.selectDefaultProfile === undefined &&
653
+ dependencies.selectSetupSourceImportMode === undefined) {
654
+ return promptForSetupSourceOnboarding(input, profiles, promptDefault, localSymlinkAvailable, dependencies);
492
655
  }
493
656
  writeSetupSourceWelcome(input, profiles, resolvePromptOutput(dependencies));
494
657
  const importTarget = await selectSetupSourceImportTarget(dependencies);
658
+ const importMode = await selectSetupSourceImportMode(dependencies, localSymlinkAvailable);
495
659
  const selectedProfileId = await selectSetupProfile(profiles, promptDefault, dependencies);
496
660
  assertValidSelectedDefaultProfile(selectedProfileId, profiles);
497
- return { importTarget, selectedProfileId };
661
+ return { importTarget, selectedProfileId, importMode };
498
662
  };
499
- const promptForSetupSourceOnboarding = async (input, profiles, currentDefault, dependencies) => {
663
+ /* v8 ignore start -- readline fallback is smoke-tested through terminal streams; injected selector paths carry deterministic setup-source coverage. */
664
+ const promptForSetupSourceOnboarding = async (input, profiles, currentDefault, localSymlinkAvailable, dependencies) => {
500
665
  const output = resolvePromptOutput(dependencies);
501
666
  /* v8 ignore next -- default process streams are direct terminal behavior; tests inject streams. */
502
667
  const readline = createInterface({
@@ -505,10 +670,11 @@ const promptForSetupSourceOnboarding = async (input, profiles, currentDefault, d
505
670
  });
506
671
  try {
507
672
  writeSetupSourceWelcome(input, profiles, output);
508
- const importTarget = 'home';
673
+ const importTarget = await promptForSetupSourceImportTargetWithReadline(readline, output, setupSourceImportTargetChoices, 'home');
674
+ const importMode = await promptForSetupSourceImportModeWithReadline(readline, output, localSymlinkAvailable);
509
675
  const selectedProfileId = await promptForSetupProfileWithReadline(readline, output, profiles, currentDefault, 'Choose the default profile from this setup source:');
510
676
  assertValidSelectedDefaultProfile(selectedProfileId, profiles);
511
- return { importTarget, selectedProfileId };
677
+ return { importTarget, selectedProfileId, importMode };
512
678
  }
513
679
  finally {
514
680
  readline.close();
@@ -518,6 +684,9 @@ const writeSetupSourceWelcome = (input, profiles, output) => {
518
684
  writeWelcomeIntro(output);
519
685
  output.write(`\nYou're importing Outfitter profiles from ${redactProfileSourceUriCredentials(input.setupSourceUri)}.\n`);
520
686
  output.write(`Found ${profiles.length} profile(s)${formatSetupSourceProfileList(profiles)}.\n`);
687
+ if (resolveLocalSetupSourceOutfitterPath(input) !== undefined) {
688
+ 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');
689
+ }
521
690
  };
522
691
  const formatSetupSourceProfileList = (profiles) => {
523
692
  /* v8 ignore next -- setup-source profile prompts normally require discovered source profiles. */
@@ -535,37 +704,87 @@ const selectSetupSourceImportTarget = async (dependencies) => {
535
704
  }
536
705
  return 'home';
537
706
  };
707
+ const selectSetupSourceImportMode = async (dependencies, localSymlinkAvailable) => {
708
+ if (!localSymlinkAvailable) {
709
+ return 'copy';
710
+ }
711
+ if (dependencies.selectSetupSourceImportMode !== undefined) {
712
+ const selectedMode = await dependencies.selectSetupSourceImportMode(setupSourceImportModeChoices, 'copy');
713
+ assertValidSetupSourceImportMode(selectedMode);
714
+ return selectedMode;
715
+ }
716
+ return 'copy';
717
+ };
718
+ const assertValidSetupSourceImportMode = (mode) => {
719
+ if (setupSourceImportModeChoices.every((choice) => choice.mode !== mode)) {
720
+ throw new Error(`Selected setup-source import mode '${mode}' is not available.`);
721
+ }
722
+ };
538
723
  const assertValidSetupSourceImportTarget = (target) => {
539
724
  /* v8 ignore next -- defensive validation for custom dependency injection. */
540
725
  if (setupSourceImportTargetChoices.every((choice) => choice.target !== target)) {
541
726
  throw new Error(`Selected setup-source import target '${target}' is not available.`);
542
727
  }
543
728
  };
729
+ const promptForSetupSourceImportTargetWithReadline = async (readline, output, choices, defaultTarget) => {
730
+ output.write('\nChoose where to install these profiles:\n');
731
+ choices.forEach((choice, index) => {
732
+ output.write(`${index + 1}. ${choice.label}\n`);
733
+ output.write(` ${choice.description}.\n`);
734
+ });
735
+ const defaultIndex = Math.max(choices.findIndex((choice) => choice.target === defaultTarget), 0);
736
+ const answer = await readline.question(`Import target [${defaultIndex + 1}]: `);
737
+ const selectedIndex = Number.parseInt(answer.trim() || String(defaultIndex + 1), 10) - 1;
738
+ const selectedChoice = choices[selectedIndex];
739
+ if (selectedChoice === undefined) {
740
+ throw new Error('Selected setup-source import target number is out of range.');
741
+ }
742
+ return selectedChoice.target;
743
+ };
744
+ const promptForSetupSourceImportModeWithReadline = async (readline, output, localSymlinkAvailable) => {
745
+ if (!localSymlinkAvailable) {
746
+ return 'copy';
747
+ }
748
+ output.write('\nChoose how to install this local setup source:\n');
749
+ setupSourceImportModeChoices.forEach((choice, index) => {
750
+ output.write(`${index + 1}. ${choice.label}\n`);
751
+ output.write(` ${choice.description}.\n`);
752
+ });
753
+ const answer = await readline.question('Import mode [1]: ');
754
+ const selectedIndex = Number.parseInt(answer.trim() || '1', 10) - 1;
755
+ const selectedChoice = setupSourceImportModeChoices[selectedIndex];
756
+ if (selectedChoice === undefined) {
757
+ throw new Error('Selected setup-source import mode number is out of range.');
758
+ }
759
+ return selectedChoice.mode;
760
+ };
544
761
  /* v8 ignore next -- covered by interactive CLI smoke tests; unit tests inject the launch choice. */
545
- const promptForSetupSourceLaunchAction = async (profileId, dependencies) => {
762
+ const promptForSetupSourceLaunchAction = async (profileId, launchTarget, dependencies) => {
546
763
  const readline = createInterface({
547
764
  input: dependencies.input ?? process.stdin,
548
765
  output: resolveReadlineOutput(dependencies),
549
766
  });
550
767
  try {
551
- const answer = await readline.question(`Start Outfitter with profile '${profileId}' now? [Y/n]: `);
768
+ const prompt = launchTarget === 'selected'
769
+ ? `Start Outfitter with profile '${profileId}' now? [Y/n]: `
770
+ : 'Start Outfitter with the current default profile now? [Y/n]: ';
771
+ const answer = await readline.question(prompt);
552
772
  return answer.trim().toLowerCase().startsWith('n') ? 'exit' : 'start';
553
773
  }
554
774
  finally {
555
775
  readline.close();
556
776
  }
557
777
  };
778
+ /* v8 ignore stop */
558
779
  const selectDefaultProfileIfInteractive = async (input, settingsPath, currentDefault, dependencies, starterLayout) => {
559
780
  if (dependencies.interactive !== true) {
560
781
  return currentDefault;
561
782
  }
562
783
  const discoveredProfiles = discoverSetupProfileChoices(input, starterLayout);
563
784
  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.');
785
+ const promptDefault = chooseSetupPromptDefault(discoveredProfiles, sourceDefault, currentDefault);
786
+ const profiles = selectSetupPromptProfiles(discoveredProfiles, currentDefault, promptDefault);
787
+ writeWelcomeIntro(resolvePromptOutput(dependencies));
569
788
  const selectedProfile = await selectSetupProfile(profiles, promptDefault, dependencies);
570
789
  assertValidSelectedDefaultProfile(selectedProfile, profiles);
571
790
  updateSettingsDefaultProfile(settingsPath, selectedProfile);
@@ -584,13 +803,18 @@ const discoverSetupSourcePromptDefault = (input, starterLayout, profiles) => {
584
803
  const sourceDefault = readStarterExplicitDefaultProfileId(starterLayout?.settingsPath);
585
804
  return profiles.some((profile) => profile.id === sourceDefault) ? sourceDefault : undefined;
586
805
  };
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);
806
+ const selectSetupPromptProfiles = (discoveredProfiles, currentDefault, promptDefault) => {
807
+ const profiles = discoveredProfiles.length > 0 ? discoveredProfiles : [{ id: currentDefault }];
808
+ return prioritizeSetupProfileChoice(profiles, promptDefault);
809
+ };
810
+ const chooseSetupPromptDefault = (profiles, sourceDefault, fallbackDefault) => {
811
+ if (sourceDefault !== undefined && profiles.some((profile) => profile.id === sourceDefault)) {
812
+ return sourceDefault;
813
+ }
814
+ if (profiles.some((profile) => profile.id === fallbackDefault)) {
815
+ return fallbackDefault;
816
+ }
817
+ return profiles[0]?.id ?? fallbackDefault;
594
818
  };
595
819
  const prioritizeSetupProfileChoice = (profiles, profileId) => [
596
820
  ...profiles.filter((profile) => profile.id === profileId),