@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.
- package/LICENSE.md +20 -50
- package/README.md +41 -280
- package/code/enterprise/LICENSE +35 -0
- package/code/enterprise/README.md +7 -0
- package/code/enterprise/cli/privateCatalogGate.cjs +134 -0
- package/code/enterprise/cli/privateCatalogSettings.cjs +59 -0
- package/code/enterprise/pi-extension/privateCatalogOnboarding.js +89 -0
- package/code/enterprise/private-catalog-boundary.json +9 -0
- package/code/enterprise/privateCatalog.js +24 -0
- package/code/enterprise/shared/privateCatalogPolicy.cjs +66 -0
- package/dist/agents/AdapterProfileControls.d.ts +2 -2
- package/dist/agents/AdapterProfileControls.js +8 -1
- package/dist/agents/AdapterProfileControls.js.map +1 -1
- package/dist/agents/AgentAdapter.d.ts +11 -0
- package/dist/agents/AgentLaunch.d.ts +6 -0
- package/dist/agents/AgentLaunch.js +89 -0
- package/dist/agents/AgentLaunch.js.map +1 -0
- package/dist/agents/claude/ClaudeAdapter.js +18 -3
- package/dist/agents/claude/ClaudeAdapter.js.map +1 -1
- package/dist/agents/pi/PiAdapter.js +162 -33
- package/dist/agents/pi/PiAdapter.js.map +1 -1
- package/dist/agents/pi/PiArgs.d.ts +2 -0
- package/dist/agents/pi/PiArgs.js +15 -0
- package/dist/agents/pi/PiArgs.js.map +1 -0
- package/dist/agents/pi/PiExtensionCache.d.ts +4 -0
- package/dist/agents/pi/PiExtensionCache.js +105 -0
- package/dist/agents/pi/PiExtensionCache.js.map +1 -0
- package/dist/cli/commands/PiLoginLaunch.d.ts +9 -2
- package/dist/cli/commands/PiLoginLaunch.js +817 -75
- package/dist/cli/commands/PiLoginLaunch.js.map +1 -1
- package/dist/cli/commands/RunCommand.d.ts +22 -3
- package/dist/cli/commands/RunCommand.js +104 -20
- package/dist/cli/commands/RunCommand.js.map +1 -1
- package/dist/cli/commands/SetupCommand.d.ts +19 -2
- package/dist/cli/commands/SetupCommand.js +281 -57
- package/dist/cli/commands/SetupCommand.js.map +1 -1
- package/dist/cli/commands/SyncCommand.d.ts +19 -1
- package/dist/cli/commands/SyncCommand.js +47 -6
- package/dist/cli/commands/SyncCommand.js.map +1 -1
- package/dist/cli/commands/WelcomeCommand.js +1 -1
- package/dist/cli/commands/WelcomeCommand.js.map +1 -1
- package/dist/cli/commands/assets/outfitter-ascii.txt +5 -0
- package/dist/cli/commands/profile/Command.d.ts +1 -0
- package/dist/cli/commands/profile/Command.js +3 -0
- package/dist/cli/commands/profile/Command.js.map +1 -1
- package/dist/cli/commands/profile/LintCommand.d.ts +19 -0
- package/dist/cli/commands/profile/LintCommand.js +123 -0
- package/dist/cli/commands/profile/LintCommand.js.map +1 -0
- package/dist/cli.js +8 -2
- package/dist/cli.js.map +1 -1
- package/dist/merge/ArrayMergePolicy.js.map +1 -1
- package/dist/merge/SettingsValueMerger.js.map +1 -1
- package/dist/profiles/Profile.d.ts +13 -1
- package/dist/profiles/Profile.js.map +1 -1
- package/dist/profiles/ProfileLoader.d.ts +4 -0
- package/dist/profiles/ProfileLoader.js +117 -17
- package/dist/profiles/ProfileLoader.js.map +1 -1
- package/dist/profiles/ProfileMerger.js +3 -0
- package/dist/profiles/ProfileMerger.js.map +1 -1
- package/dist/profiles/PromptIncludes.d.ts +32 -0
- package/dist/profiles/PromptIncludes.js +147 -0
- package/dist/profiles/PromptIncludes.js.map +1 -0
- package/dist/prompts/SystemPromptExport.d.ts +16 -0
- package/dist/prompts/SystemPromptExport.js +81 -0
- package/dist/prompts/SystemPromptExport.js.map +1 -0
- package/dist/schemas/profile.schema.json +37 -2
- package/dist/schemas/settings.schema.json +23 -0
- package/dist/settings/Settings.d.ts +9 -0
- package/dist/settings/Settings.js.map +1 -1
- package/dist/settings/SettingsLoader.js +5 -0
- package/dist/settings/SettingsLoader.js.map +1 -1
- package/dist/settings/SettingsMerger.js +11 -0
- package/dist/settings/SettingsMerger.js.map +1 -1
- package/package.json +7 -11
- package/src/schemas/profile.schema.json +37 -2
- package/src/schemas/settings.schema.json +23 -0
- package/doc/.deepreview +0 -30
- package/doc/architecture.md +0 -856
- package/doc/controllable-elements.md +0 -162
- package/doc/file_structure.md +0 -141
- package/doc/integration_test_system.md +0 -214
- package/doc/specs/validating_requirements_with_rules.md +0 -55
- package/doc/state_writeback_strategy.md +0 -342
- package/requirements/OFTR-001-project-foundation.md +0 -53
- package/requirements/OFTR-002-settings.md +0 -65
- package/requirements/OFTR-003-profiles.md +0 -60
- package/requirements/OFTR-004-sync-and-setup.md +0 -67
- package/requirements/OFTR-005-run-and-composite-profile.md +0 -60
- package/requirements/OFTR-006-agent-adapters.md +0 -66
- package/requirements/OFTR-007-controllable-elements.md +0 -32
- package/requirements/OFTR-008-requirements-governance.md +0 -42
- package/requirements/OFTR-009-release-publishing.md +0 -35
- package/requirements/OFTR-010-onboarding-welcome.md +0 -48
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
2
|
// Provides the command object for first-run Outfitter setup.
|
|
3
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { createInterface } from 'node:readline/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
7
7
|
import spawn from 'cross-spawn';
|
|
8
8
|
import { parse, stringify } from 'yaml';
|
|
9
9
|
import { createProfileSourceCachePath, createRemoteRepositoryCachePath, normalizeGitUri, redactProfileSourceUriCredentials, resolveRemoteRepositorySubpath, } from '../../profiles/ProfileCache.js';
|
|
10
10
|
import { isValidProfileId, loadLocalProfileSource } from '../../profiles/ProfileLoader.js';
|
|
11
|
+
import { resolveProfile } from '../../profiles/ProfileMerger.js';
|
|
11
12
|
import { createSettingsLoadPlan, discoverSettingsLoadPlan, loadSettings, loadSettingsFiles, loadSettingsWithCachedRemoteSettings, } from '../../settings/SettingsLoader.js';
|
|
12
13
|
import { persistFirstRunWelcomeProfile, updateSettingsDefaultProfile, } from './FirstRunWelcomeProfile.js';
|
|
13
14
|
import { executeSyncCommand } from './SyncCommand.js';
|
|
14
15
|
import { executeWelcomeCommand, writeWelcomeIntro } from './WelcomeCommand.js';
|
|
16
|
+
const setupSourceImportModeChoices = [
|
|
17
|
+
{
|
|
18
|
+
mode: 'copy',
|
|
19
|
+
label: 'Copy snapshot',
|
|
20
|
+
description: 'copy profiles into the selected .outfitter folder; safest for normal use',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
mode: 'symlink',
|
|
24
|
+
label: 'Symlink for development',
|
|
25
|
+
description: 'link the selected .outfitter folder to the local source so shared profile edits apply immediately',
|
|
26
|
+
},
|
|
27
|
+
];
|
|
15
28
|
const setupSourceImportTargetChoices = [
|
|
16
29
|
{
|
|
17
30
|
target: 'home',
|
|
@@ -24,25 +37,13 @@ const setupSourceImportTargetChoices = [
|
|
|
24
37
|
description: 'install profiles into this project .outfitter folder only',
|
|
25
38
|
},
|
|
26
39
|
];
|
|
27
|
-
const builtInSetupProfileChoices = [
|
|
28
|
-
{
|
|
29
|
-
id: 'engineer',
|
|
30
|
-
label: 'Engineer',
|
|
31
|
-
description: 'Engineering setup for repository navigation, maintainable code changes, tests, and reviews.',
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
id: 'data_analyst',
|
|
35
|
-
label: 'Data Analyst',
|
|
36
|
-
description: 'Data analysis setup for careful inspection, reproducible methods, assumptions, and summaries.',
|
|
37
|
-
},
|
|
38
|
-
];
|
|
39
40
|
/* eslint-disable complexity -- setup orchestration coordinates settings, sync, prompts, and welcome persistence. */
|
|
40
41
|
export const executeSetupCommand = async (input, dependencies = {}) => {
|
|
41
42
|
requireInteractiveTerminalIfNeeded(dependencies);
|
|
42
43
|
const settingsPath = join(input.homeDirectory, '.outfitter', 'settings.yml');
|
|
43
44
|
const initialSettingsMissing = !existsSync(settingsPath);
|
|
44
45
|
const starterLayout = input.setupSourceUri
|
|
45
|
-
? prepareStarterLayout(input.homeDirectory, input.setupSourceUri, dependencies.setupSourceSynchronizer)
|
|
46
|
+
? prepareStarterLayout(input.homeDirectory, input.projectDirectory, input.setupSourceUri, dependencies.setupSourceSynchronizer)
|
|
46
47
|
: undefined;
|
|
47
48
|
const loadedSettings = loadSettings(discoverSettingsLoadPlan(input));
|
|
48
49
|
if (loadedSettings.issues.length > 0) {
|
|
@@ -120,9 +121,12 @@ const executeInteractiveSetupSourceCommand = async ({ input, dependencies, homeS
|
|
|
120
121
|
: () => undefined;
|
|
121
122
|
const syncResult = executeSyncCommand(input, dependencies);
|
|
122
123
|
failOnInitialDefaultProfileSyncFailure(initialSettingsMissing && appliedImport.settingsPath === homeSettingsPath, rollbackCreatedSettings, syncResult);
|
|
123
|
-
const defaultProfilePath =
|
|
124
|
-
const createdDefaultProfile =
|
|
125
|
-
|
|
124
|
+
const defaultProfilePath = findSetupProfilePath(appliedImport.profilesPath, onboarding.selectedProfileId);
|
|
125
|
+
const createdDefaultProfile = appliedImport.symlinkedOutfitter
|
|
126
|
+
? false
|
|
127
|
+
: createDefaultProfileIfMissing(defaultProfilePath, onboarding.selectedProfileId);
|
|
128
|
+
const postImportLaunchTarget = appliedImport.selectedProfileAlreadyExists ? 'default' : 'selected';
|
|
129
|
+
const postImportAction = await runSetupSourcePostImportAction(input, dependencies, onboarding.importTarget, onboarding.selectedProfileId, postImportLaunchTarget);
|
|
126
130
|
return {
|
|
127
131
|
settingsPath: appliedImport.settingsPath,
|
|
128
132
|
defaultProfilePath,
|
|
@@ -142,8 +146,12 @@ const executeInteractiveSetupSourceCommand = async ({ input, dependencies, homeS
|
|
|
142
146
|
defaultProfilePath,
|
|
143
147
|
createdDefaultProfile,
|
|
144
148
|
syncResult,
|
|
145
|
-
welcomeProfileMessages:
|
|
146
|
-
|
|
149
|
+
welcomeProfileMessages: appliedImport.selectedProfileConflictMessage === undefined
|
|
150
|
+
? []
|
|
151
|
+
: [appliedImport.selectedProfileConflictMessage],
|
|
152
|
+
runExampleMessages: postImportAction === 'exit'
|
|
153
|
+
? formatSetupSourceExitMessages(input, onboarding.importTarget, onboarding.selectedProfileId, postImportLaunchTarget)
|
|
154
|
+
: [],
|
|
147
155
|
}),
|
|
148
156
|
};
|
|
149
157
|
};
|
|
@@ -181,27 +189,64 @@ const shouldReportDefaultProfileStatus = (input) => {
|
|
|
181
189
|
return input.input.setupSourceUri === undefined || input.starterLayout?.profilesPath === undefined;
|
|
182
190
|
};
|
|
183
191
|
const formatRunProfileExample = (profileId) => `Start the selected default profile either way:\n outfitter\n outfitter --profile ${profileId}`;
|
|
184
|
-
const
|
|
192
|
+
const formatRunDefaultProfileExample = () => `Start the current default profile:\n outfitter`;
|
|
193
|
+
/* v8 ignore start -- setup-source launch visibility fallbacks are exercised through integration-style CLI flows; unit tests cover the primary imported-profile outcomes. */
|
|
194
|
+
const formatSetupSourceExitMessages = (input, importTarget, profileId, launchTarget) => {
|
|
195
|
+
if (launchTarget === 'default') {
|
|
196
|
+
return [formatRunDefaultProfileExample()];
|
|
197
|
+
}
|
|
198
|
+
if (importTarget !== 'home' || canResolveProfileForLaunch(input, profileId)) {
|
|
199
|
+
return [formatRunProfileExample(profileId)];
|
|
200
|
+
}
|
|
201
|
+
return [formatHiddenHomeImportMessage(profileId)];
|
|
202
|
+
};
|
|
203
|
+
const formatHiddenHomeImportMessage = (profileId) => `Imported profile '${profileId}' into user home, but this project overrides profile_sources and does not expose ~/.outfitter/profiles. ` +
|
|
204
|
+
'Import into the current project, add ~/.outfitter/profiles to project profile_sources, or run from a directory without project Outfitter settings.';
|
|
205
|
+
const runSetupSourcePostImportAction = async (input, dependencies, importTarget, profileId, launchTarget) => {
|
|
185
206
|
if (dependencies.interactive !== true ||
|
|
186
207
|
(dependencies.launchSetupSourceProfile === undefined && dependencies.selectSetupSourceLaunchAction === undefined)) {
|
|
187
208
|
return 'exit';
|
|
188
209
|
}
|
|
189
|
-
const action = await selectSetupSourceLaunchAction(profileId, dependencies);
|
|
210
|
+
const action = await selectSetupSourceLaunchAction(profileId, launchTarget, dependencies);
|
|
190
211
|
if (action === 'start') {
|
|
212
|
+
if (launchTarget === 'selected') {
|
|
213
|
+
assertSetupSourceProfileCanLaunch(input, importTarget, profileId);
|
|
214
|
+
}
|
|
191
215
|
await dependencies.launchSetupSourceProfile?.({
|
|
192
216
|
homeDirectory: input.homeDirectory,
|
|
193
217
|
projectDirectory: input.projectDirectory,
|
|
194
|
-
profileId,
|
|
218
|
+
profileId: launchTarget === 'selected' ? profileId : undefined,
|
|
195
219
|
});
|
|
196
220
|
}
|
|
197
221
|
return action;
|
|
198
222
|
};
|
|
199
|
-
const
|
|
223
|
+
const assertSetupSourceProfileCanLaunch = (input, importTarget, profileId) => {
|
|
224
|
+
if (importTarget !== 'home' || canResolveProfileForLaunch(input, profileId)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
throw new Error(formatHiddenHomeImportMessage(profileId));
|
|
228
|
+
};
|
|
229
|
+
const canResolveProfileForLaunch = (input, profileId) => {
|
|
230
|
+
const loadedSettings = loadSettingsWithCachedRemoteSettings(input);
|
|
231
|
+
if (loadedSettings.issues.length > 0) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
const profiles = loadedSettings.settings.profileSources.flatMap((source) => loadLocalProfileSource({
|
|
235
|
+
path: materializeSetupProfileSource(input.homeDirectory, source),
|
|
236
|
+
only: source.only,
|
|
237
|
+
except: source.except,
|
|
238
|
+
}).profiles);
|
|
239
|
+
const resolution = resolveProfile({ profiles, profileId });
|
|
240
|
+
const selectedProfile = resolution.profileStack.find((profile) => profile.id === profileId);
|
|
241
|
+
return resolution.profile !== undefined && resolution.issues.length === 0 && selectedProfile?.template !== true;
|
|
242
|
+
};
|
|
243
|
+
const selectSetupSourceLaunchAction = async (profileId, launchTarget, dependencies) => {
|
|
200
244
|
if (dependencies.selectSetupSourceLaunchAction !== undefined) {
|
|
201
|
-
return dependencies.selectSetupSourceLaunchAction(profileId);
|
|
245
|
+
return dependencies.selectSetupSourceLaunchAction(profileId, launchTarget);
|
|
202
246
|
}
|
|
203
|
-
return promptForSetupSourceLaunchAction(profileId, dependencies);
|
|
247
|
+
return promptForSetupSourceLaunchAction(profileId, launchTarget, dependencies);
|
|
204
248
|
};
|
|
249
|
+
/* v8 ignore stop */
|
|
205
250
|
const capitalize = (value) => `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
206
251
|
export const createSetupCommand = (dependencies = {}) => {
|
|
207
252
|
const command = {
|
|
@@ -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
|
|
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
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
489
|
-
const profiles = selectSetupPromptProfiles(
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
565
|
-
const profiles = selectSetupPromptProfiles(
|
|
566
|
-
|
|
567
|
-
writer('Welcome to Outfitter. Outfitter is the easiest way to run Pi.');
|
|
568
|
-
writer('Outfitter manages full pi configurations for you, so you can use different profiles in different situations.');
|
|
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 = (
|
|
588
|
-
const profiles = discoveredProfiles.length > 0
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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),
|