peaks-cli 1.0.7 → 1.0.9

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/bin/peaks.js CHANGED
File without changes
@@ -4,18 +4,29 @@ import { createWorkflowRouterPlan, isSoloMode, isWorkflowMode } from '../../serv
4
4
  import { createAutonomousWorkflowPlan } from '../../services/workflow/workflow-autonomous-service.js';
5
5
  import { createRecommendationPlan } from '../../services/recommendations/recommendation-service.js';
6
6
  import { createRefactorDryRun } from '../../services/refactor/refactor-service.js';
7
- import { getCurrentWorkspaceConfig, readConfig } from '../../services/config/config-service.js';
7
+ import { ensureWorkspaceConfigForCurrentPath, getCurrentWorkspaceConfig, readConfig } from '../../services/config/config-service.js';
8
8
  import { validateChangeIdOrThrow } from '../../shared/change-id.js';
9
9
  import { getEconomyAwareExecutionModelId } from '../../services/config/model-routing.js';
10
10
  import { getLocalArtifactPath } from '../../services/artifacts/workspace-service.js';
11
11
  import { fail, ok } from '../../shared/result.js';
12
12
  import { addJsonOption, failUnsupportedNonDryRun, getErrorMessage, isRecommendationWorkflow, printResult } from '../cli-helpers.js';
13
- function getWorkspaceContext() {
13
+ function getCurrentWorkspaceContext() {
14
14
  const workspace = getCurrentWorkspaceConfig();
15
15
  if (!workspace)
16
16
  return {};
17
17
  return { workspace, artifactWorkspacePath: getLocalArtifactPath(workspace) };
18
18
  }
19
+ function getWorkflowWorkspaceContext() {
20
+ try {
21
+ const workspace = ensureWorkspaceConfigForCurrentPath() ?? getCurrentWorkspaceConfig();
22
+ if (!workspace)
23
+ return {};
24
+ return { workspace, artifactWorkspacePath: getLocalArtifactPath(workspace) };
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
19
30
  function parseMaxWorkers(io, command, value, asJson) {
20
31
  const maxWorkers = Number(value);
21
32
  if (!Number.isInteger(maxWorkers) || maxWorkers < 1) {
@@ -54,7 +65,7 @@ function runTechPlan(io, options) {
54
65
  }
55
66
  try {
56
67
  validatePlanningInput(options.changeId, options.goal);
57
- const workspaceContext = getWorkspaceContext();
68
+ const workspaceContext = getCurrentWorkspaceContext();
58
69
  const plan = createTechPlan({
59
70
  changeId: options.changeId,
60
71
  goal: options.goal,
@@ -71,7 +82,7 @@ function runTechPlan(io, options) {
71
82
  }
72
83
  function runTechStatus(io, options) {
73
84
  try {
74
- const workspaceContext = getWorkspaceContext();
85
+ const workspaceContext = getCurrentWorkspaceContext();
75
86
  printResult(io, ok('tech.status', getTechStatus({ changeId: options.changeId, ...workspaceContext })), options.json);
76
87
  }
77
88
  catch (error) {
@@ -97,7 +108,7 @@ function runWorkflowRoute(io, options) {
97
108
  return;
98
109
  try {
99
110
  validatePlanningInput(options.changeId, options.goal);
100
- const workspaceContext = getWorkspaceContext();
111
+ const workspaceContext = getWorkflowWorkspaceContext();
101
112
  const plan = createWorkflowRouterPlan({
102
113
  changeId: options.changeId,
103
114
  goal: options.goal,
@@ -133,7 +144,7 @@ function runAutonomousWorkflow(io, options) {
133
144
  return;
134
145
  try {
135
146
  validatePlanningInput(options.changeId, options.goal);
136
- const workspaceContext = getWorkspaceContext();
147
+ const workspaceContext = getWorkflowWorkspaceContext();
137
148
  const plan = createAutonomousWorkflowPlan({
138
149
  changeId: options.changeId,
139
150
  goal: options.goal,
@@ -166,7 +177,7 @@ function runSwarmPlan(io, options) {
166
177
  return;
167
178
  try {
168
179
  validatePlanningInput(options.changeId, options.goal);
169
- const workspaceContext = getWorkspaceContext();
180
+ const workspaceContext = getWorkflowWorkspaceContext();
170
181
  const config = readConfig();
171
182
  const plan = createRdSwarmPlan({
172
183
  skill: 'rd',
@@ -28,4 +28,8 @@ export declare function addWorkspace(workspace: WorkspaceConfig, layer?: ConfigL
28
28
  export declare function removeWorkspace(workspaceId: string, layer?: ConfigLayer): boolean;
29
29
  export declare function setCurrentWorkspace(workspaceId: string, layer?: ConfigLayer): boolean;
30
30
  export declare function getCurrentWorkspaceConfig(): WorkspaceConfig | null;
31
+ export declare function getWorkspaceConfigForPath(path?: string): WorkspaceConfig | null;
32
+ export declare function ensureWorkspaceConfigForPath(path?: string): WorkspaceConfig | null;
33
+ export declare function getWorkspaceConfigForCurrentPath(): WorkspaceConfig | null;
34
+ export declare function ensureWorkspaceConfigForCurrentPath(): WorkspaceConfig | null;
31
35
  export type { TokenRef, WorkspaceConfig, PeaksConfig, ConfigLayer };
@@ -1,7 +1,9 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
- import { dirname, isAbsolute, relative, resolve } from 'node:path';
1
+ import { closeSync, constants, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
3
4
  import { homedir } from 'node:os';
4
5
  import { DEFAULT_CONFIG } from './config-types.js';
6
+ import { stablePath } from '../../shared/path-utils.js';
5
7
  function getUserConfigPath() {
6
8
  return resolve(homedir(), '.peaks', 'config.json');
7
9
  }
@@ -14,7 +16,13 @@ function isSafeProjectConfigMarker(projectRoot) {
14
16
  const markerPath = resolve(peaksPath, 'config.json');
15
17
  try {
16
18
  const projectRootReal = realpathSync(projectRoot);
19
+ const peaksStats = lstatSync(peaksPath);
17
20
  const peaksReal = realpathSync(peaksPath);
21
+ const markerStats = lstatSync(markerPath);
22
+ if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink())
23
+ return false;
24
+ if (!markerStats.isFile() || markerStats.isSymbolicLink() || markerStats.nlink !== 1)
25
+ return false;
18
26
  const markerReal = realpathSync(markerPath);
19
27
  if (!isInsidePath(peaksReal, projectRootReal))
20
28
  return false;
@@ -26,10 +34,25 @@ function isSafeProjectConfigMarker(projectRoot) {
26
34
  return false;
27
35
  }
28
36
  }
37
+ function normalizeBoundaryPath(path) {
38
+ const resolved = resolve(path);
39
+ let realPath = resolved;
40
+ try {
41
+ realPath = existsSync(resolved) ? realpathSync.native(resolved) : resolved;
42
+ }
43
+ catch {
44
+ realPath = resolved;
45
+ }
46
+ return process.platform === 'win32' || process.platform === 'darwin' ? realPath.toLowerCase() : realPath;
47
+ }
48
+ function getHomeBoundaryPaths() {
49
+ return new Set([homedir(), process.env.HOME, process.env.USERPROFILE].filter((path) => typeof path === 'string' && path.length > 0).map(normalizeBoundaryPath));
50
+ }
29
51
  function findProjectRoot(startPath) {
52
+ const homeBoundaryPaths = getHomeBoundaryPaths();
30
53
  let current = resolve(startPath);
31
54
  let parent = dirname(current);
32
- while (current !== parent) {
55
+ while (current !== parent && !homeBoundaryPaths.has(normalizeBoundaryPath(current))) {
33
56
  if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
34
57
  return current;
35
58
  }
@@ -40,10 +63,10 @@ function findProjectRoot(startPath) {
40
63
  }
41
64
  export function resolveProjectRootForConfig(startPath) {
42
65
  const start = resolve(startPath);
43
- const homePath = resolve(homedir());
66
+ const homeBoundaryPaths = getHomeBoundaryPaths();
44
67
  let current = start;
45
68
  let parent = dirname(current);
46
- while (current !== parent && current !== homePath) {
69
+ while (current !== parent && !homeBoundaryPaths.has(normalizeBoundaryPath(current))) {
47
70
  if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
48
71
  return current;
49
72
  }
@@ -87,6 +110,9 @@ function validateProjectBootstrapConfigPath(projectRootPath, peaksPath, configPa
87
110
  if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
88
111
  throw new Error('Project config path must stay inside the project root');
89
112
  }
113
+ if (markerStats.nlink !== 1) {
114
+ throw new Error('Config path must not be hardlinked');
115
+ }
90
116
  const markerReal = realpathSync(configPath);
91
117
  if (!isInsidePath(markerReal, projectRootReal) || !isInsidePath(markerReal, peaksReal)) {
92
118
  throw new Error('Project config path must stay inside the project root');
@@ -102,26 +128,184 @@ function validateProjectBootstrapConfigPathForWrite(projectRoot, configPath) {
102
128
  const projectRootPath = resolve(projectRoot);
103
129
  validateProjectBootstrapConfigPath(projectRootPath, resolve(projectRootPath, '.peaks'), configPath);
104
130
  }
105
- function readJsonFile(path) {
131
+ function validateUserConfigPathForWrite(configPath) {
132
+ const userRoot = resolve(homedir());
133
+ const peaksPath = resolve(userRoot, '.peaks');
134
+ const userRootReal = realpathSync(userRoot);
135
+ const peaksStats = lstatSync(peaksPath);
136
+ const peaksReal = realpathSync(peaksPath);
137
+ if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(userRootReal, '.peaks')) {
138
+ throw new Error('User config path must stay inside the user root');
139
+ }
140
+ try {
141
+ const markerStats = lstatSync(configPath);
142
+ if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
143
+ throw new Error('User config path must stay inside the user root');
144
+ }
145
+ if (markerStats.nlink !== 1) {
146
+ throw new Error('Config path must not be hardlinked');
147
+ }
148
+ const markerReal = realpathSync(configPath);
149
+ if (!isInsidePath(markerReal, userRootReal) || !isInsidePath(markerReal, peaksReal)) {
150
+ throw new Error('User config path must stay inside the user root');
151
+ }
152
+ }
153
+ catch (error) {
154
+ if (error.code !== 'ENOENT') {
155
+ throw error;
156
+ }
157
+ }
158
+ }
159
+ function validateArtifactWorkspaceRoot(artifactRoot, workspaceRoot) {
160
+ const artifactStats = lstatSync(artifactRoot);
161
+ if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
162
+ throw new Error('Artifact workspace marker must stay inside the artifact workspace');
163
+ }
164
+ const artifactRootReal = realpathSync(artifactRoot);
165
+ const workspaceRootReal = realpathSync(workspaceRoot);
166
+ if (isInsidePath(artifactRootReal, workspaceRootReal)) {
167
+ throw new Error('Artifact workspace must stay outside the project root');
168
+ }
169
+ }
170
+ function validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath) {
171
+ const artifactStats = lstatSync(artifactRoot);
172
+ if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
173
+ throw new Error('Artifact workspace marker must stay inside the artifact workspace');
174
+ }
175
+ const artifactRootReal = realpathSync(artifactRoot);
176
+ const peaksStats = lstatSync(peaksPath);
177
+ const peaksReal = realpathSync(peaksPath);
178
+ if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(artifactRootReal, '.peaks')) {
179
+ throw new Error('Artifact workspace marker must stay inside the artifact workspace');
180
+ }
181
+ try {
182
+ const markerStats = lstatSync(markerPath);
183
+ if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
184
+ throw new Error('Artifact workspace marker must stay inside the artifact workspace');
185
+ }
186
+ if (markerStats.nlink !== 1) {
187
+ throw new Error('Config path must not be hardlinked');
188
+ }
189
+ const markerReal = realpathSync(markerPath);
190
+ if (!isInsidePath(markerReal, artifactRootReal) || !isInsidePath(markerReal, peaksReal)) {
191
+ throw new Error('Artifact workspace marker must stay inside the artifact workspace');
192
+ }
193
+ }
194
+ catch (error) {
195
+ if (error.code !== 'ENOENT') {
196
+ throw error;
197
+ }
198
+ }
199
+ }
200
+ function validateOpenConfigFile(fd, tempPath, errorMessage) {
201
+ const fdStats = fstatSync(fd);
202
+ const pathStats = lstatSync(tempPath);
203
+ if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
204
+ throw new Error(errorMessage);
205
+ }
206
+ if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
207
+ throw new Error('Config path must not be hardlinked');
208
+ }
209
+ }
210
+ function getSafeTempOpenFlags() {
211
+ const baseFlags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
212
+ return typeof constants.O_NOFOLLOW === 'number' ? baseFlags | constants.O_NOFOLLOW : baseFlags;
213
+ }
214
+ function getSafeReadOpenFlags() {
215
+ return typeof constants.O_NOFOLLOW === 'number' ? constants.O_RDONLY | constants.O_NOFOLLOW : constants.O_RDONLY;
216
+ }
217
+ function readConfigFileSafely(configPath, errorMessage) {
218
+ const fd = openSync(configPath, getSafeReadOpenFlags());
219
+ try {
220
+ validateOpenConfigFile(fd, configPath, errorMessage);
221
+ return readFileSync(fd, 'utf-8');
222
+ }
223
+ finally {
224
+ closeSync(fd);
225
+ }
226
+ }
227
+ function writeConfigFileSafely(configPath, content, validateBeforeWrite, errorMessage) {
228
+ validateBeforeWrite();
229
+ const tempPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
230
+ let fd = openSync(tempPath, getSafeTempOpenFlags(), 0o600);
231
+ let renamed = false;
232
+ let closeError;
233
+ try {
234
+ validateOpenConfigFile(fd, tempPath, errorMessage);
235
+ fchmodSync(fd, 0o600);
236
+ writeFileSync(fd, content, 'utf-8');
237
+ const writeFd = fd;
238
+ fd = null;
239
+ closeSync(writeFd);
240
+ validateBeforeWrite();
241
+ const readFd = openSync(tempPath, getSafeReadOpenFlags());
242
+ try {
243
+ validateOpenConfigFile(readFd, tempPath, errorMessage);
244
+ }
245
+ finally {
246
+ closeSync(readFd);
247
+ }
248
+ renameSync(tempPath, configPath);
249
+ renamed = true;
250
+ }
251
+ finally {
252
+ if (fd !== null) {
253
+ try {
254
+ closeSync(fd);
255
+ }
256
+ catch (error) {
257
+ closeError = error;
258
+ }
259
+ }
260
+ try {
261
+ if (!renamed && existsSync(tempPath)) {
262
+ unlinkSync(tempPath);
263
+ }
264
+ }
265
+ finally {
266
+ if (closeError) {
267
+ throw closeError;
268
+ }
269
+ }
270
+ }
271
+ }
272
+ function writeProjectConfigFile(projectRoot, configPath, content) {
273
+ writeConfigFileSafely(configPath, content, () => validateProjectBootstrapConfigPathForWrite(projectRoot, configPath), 'Project config path must stay inside the project root');
274
+ }
275
+ function writeUserConfigFile(configPath, content) {
276
+ writeConfigFileSafely(configPath, content, () => validateUserConfigPathForWrite(configPath), 'User config path must stay inside the user root');
277
+ }
278
+ function readJsonFile(path, validateBeforeRead, errorMessage = 'Config path must stay inside the config root') {
106
279
  if (!path || !existsSync(path))
107
280
  return null;
281
+ validateBeforeRead?.();
282
+ const content = readConfigFileSafely(path, errorMessage);
108
283
  try {
109
- return JSON.parse(readFileSync(path, 'utf-8'));
284
+ return JSON.parse(content);
110
285
  }
111
286
  catch {
112
287
  return null;
113
288
  }
114
289
  }
115
- function readExistingJsonFile(path, errorMessage) {
290
+ function readExistingJsonFile(path, errorMessage, validateBeforeRead) {
116
291
  if (!existsSync(path))
117
292
  return null;
293
+ validateBeforeRead?.();
118
294
  try {
119
- return JSON.parse(readFileSync(path, 'utf-8'));
295
+ return JSON.parse(readConfigFileSafely(path, errorMessage));
120
296
  }
121
297
  catch {
122
298
  throw new Error(errorMessage);
123
299
  }
124
300
  }
301
+ function readUserJsonFile() {
302
+ const userPath = getUserConfigPath();
303
+ return readJsonFile(userPath, () => validateUserConfigPathForWrite(userPath), 'User config path must stay inside the user root');
304
+ }
305
+ function readProjectJsonFile(projectRoot) {
306
+ const projectPath = getProjectConfigPath(projectRoot);
307
+ return readJsonFile(projectPath, projectRoot && projectPath ? () => validateProjectBootstrapConfigPathForWrite(projectRoot, projectPath) : undefined, 'Project config path must stay inside the project root');
308
+ }
125
309
  function ensureDir(dirPath) {
126
310
  if (!existsSync(dirPath)) {
127
311
  mkdirSync(dirPath, { recursive: true });
@@ -284,6 +468,10 @@ function isRecord(value) {
284
468
  function isSafeConfigSegment(value) {
285
469
  return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !value.includes('..') && !value.endsWith('.');
286
470
  }
471
+ function toSafeConfigSegment(value) {
472
+ const normalized = value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').replace(/\.+$/g, '');
473
+ return isSafeConfigSegment(normalized) ? normalized : 'workspace';
474
+ }
287
475
  function toArtifactRemoteRepoConfig(value) {
288
476
  if (!isRecord(value) || (value.provider !== 'github' && value.provider !== 'gitlab') || typeof value.owner !== 'string' || typeof value.name !== 'string') {
289
477
  return null;
@@ -327,6 +515,15 @@ function toWorkspaceConfig(value) {
327
515
  function toWorkspaceConfigs(value) {
328
516
  return Array.isArray(value) ? value.map(toWorkspaceConfig).filter((workspace) => workspace !== null) : [];
329
517
  }
518
+ function mergeWorkspaceConfigs(userWorkspaces, projectWorkspaces) {
519
+ const merged = new Map(userWorkspaces.map((workspace) => [workspace.workspaceId, workspace]));
520
+ for (const workspace of projectWorkspaces) {
521
+ if (!merged.has(workspace.workspaceId)) {
522
+ merged.set(workspace.workspaceId, workspace);
523
+ }
524
+ }
525
+ return [...merged.values()];
526
+ }
330
527
  function toProviderModelConfig(value) {
331
528
  if (!isRecord(value))
332
529
  return {};
@@ -380,12 +577,13 @@ function toProxyConfig(value) {
380
577
  return null;
381
578
  return typeof value.httpProxy === 'string' && isValidProxyUrl(value.httpProxy) ? { httpProxy: value.httpProxy } : null;
382
579
  }
383
- function getProjectWritePath() {
384
- const projectPath = getProjectConfigPath(findProjectRoot(process.cwd()));
385
- if (!projectPath) {
580
+ function getProjectWriteTarget() {
581
+ const projectRoot = findProjectRoot(process.cwd());
582
+ const configPath = getProjectConfigPath(projectRoot);
583
+ if (!projectRoot || !configPath) {
386
584
  throw new Error('Project config not found');
387
585
  }
388
- return projectPath;
586
+ return { projectRoot, configPath };
389
587
  }
390
588
  export function containsSensitiveConfigValue(value) {
391
589
  if (Array.isArray(value)) {
@@ -432,14 +630,14 @@ function createMiniMaxProviderStatus(config) {
432
630
  };
433
631
  }
434
632
  export function getMiniMaxProviderConfig() {
435
- return toMiniMaxProviderConfig(readJsonFile(getUserConfigPath())?.providers?.minimax);
633
+ return toMiniMaxProviderConfig(readUserJsonFile()?.providers?.minimax);
436
634
  }
437
635
  export function getMiniMaxProviderStatus() {
438
636
  return createMiniMaxProviderStatus(getMiniMaxProviderConfig());
439
637
  }
440
638
  export function setMiniMaxProviderConfig(input) {
441
639
  validateMiniMaxBaseUrl(input.baseUrl);
442
- const userConfig = readJsonFile(getUserConfigPath()) ?? {};
640
+ const userConfig = readUserJsonFile() ?? {};
443
641
  const existingProviders = toModelProviderConfig(userConfig.providers);
444
642
  const providers = {
445
643
  ...existingProviders,
@@ -485,24 +683,23 @@ function toPeaksConfig(value) {
485
683
  export function bootstrapProjectLanguageConfig(projectRoot, language) {
486
684
  const inferredLanguage = inferHumanLanguage(language);
487
685
  const projectPath = getProjectBootstrapConfigPath(projectRoot);
488
- const existing = readExistingJsonFile(projectPath, 'Project config must contain valid JSON') ?? {};
686
+ const existing = readExistingJsonFile(projectPath, 'Project config must contain valid JSON', () => validateProjectBootstrapConfigPathForWrite(projectRoot, projectPath)) ?? {};
489
687
  if (typeof existing.language === 'string' && existing.language.trim().length > 0) {
490
688
  return;
491
689
  }
492
- validateProjectBootstrapConfigPathForWrite(projectRoot, projectPath);
493
- writeFileSync(projectPath, JSON.stringify({ ...existing, language: inferredLanguage }, null, 2), 'utf-8');
690
+ writeProjectConfigFile(projectRoot, projectPath, JSON.stringify({ ...existing, language: inferredLanguage }, null, 2));
494
691
  }
495
692
  export function readConfig(projectRoot) {
496
693
  const detectedRoot = projectRoot ?? findProjectRoot(process.cwd());
497
- const userPath = getUserConfigPath();
498
- const projectPath = getProjectConfigPath(detectedRoot);
499
- const userConfig = toPeaksConfig(readJsonFile(userPath));
500
- const projectConfig = removeProjectSensitiveConfig(toPeaksConfig(readJsonFile(projectPath)));
501
- const { proxy: projectProxy, ...projectConfigWithoutProxy } = projectConfig;
694
+ const userConfig = toPeaksConfig(readUserJsonFile());
695
+ const projectConfig = removeProjectSensitiveConfig(toPeaksConfig(readProjectJsonFile(detectedRoot)));
696
+ const { proxy: projectProxy, workspaces: projectWorkspaces, ...projectConfigWithoutProxy } = projectConfig;
697
+ const userWorkspaces = userConfig.workspaces ?? [];
502
698
  return {
503
699
  ...DEFAULT_CONFIG,
504
700
  ...userConfig,
505
- ...projectConfigWithoutProxy
701
+ ...projectConfigWithoutProxy,
702
+ workspaces: mergeWorkspaceConfigs(userWorkspaces, projectWorkspaces ?? [])
506
703
  };
507
704
  }
508
705
  export function writeConfig(partial, layer = 'user') {
@@ -515,27 +712,33 @@ export function writeConfig(partial, layer = 'user') {
515
712
  validateProviderConfig(partial);
516
713
  validateProxyConfig(partial);
517
714
  if (layer === 'project') {
518
- const projectPath = getProjectWritePath();
519
- ensureDir(dirname(projectPath));
520
- const existing = readJsonFile(projectPath) ?? {};
715
+ const { projectRoot, configPath } = getProjectWriteTarget();
716
+ ensureDir(dirname(configPath));
717
+ const existing = readJsonFile(configPath, () => validateProjectBootstrapConfigPathForWrite(projectRoot, configPath)) ?? {};
521
718
  const merged = { ...existing, ...partial };
522
- writeFileSync(projectPath, JSON.stringify(merged, null, 2), 'utf-8');
719
+ writeProjectConfigFile(projectRoot, configPath, JSON.stringify(merged, null, 2));
523
720
  return;
524
721
  }
525
722
  const userPath = getUserConfigPath();
526
723
  ensureDir(dirname(userPath));
527
- const userPathDir = dirname(userPath);
528
- ensureDir(userPathDir);
529
- const existing = readJsonFile(userPath) ?? {};
724
+ const existing = readJsonFile(userPath, () => validateUserConfigPathForWrite(userPath)) ?? {};
530
725
  const merged = { ...existing, ...partial };
531
- writeFileSync(userPath, JSON.stringify(merged, null, 2), 'utf-8');
726
+ writeUserConfigFile(userPath, JSON.stringify(merged, null, 2));
532
727
  }
533
728
  export function getConfig(options = {}) {
534
729
  const projectRoot = findProjectRoot(process.cwd());
535
- const userConfig = readJsonFile(getUserConfigPath()) ?? {};
536
- const projectConfig = removeProjectSensitiveConfig(readJsonFile(getProjectConfigPath(projectRoot)) ?? {});
537
- const { proxy: projectProxy, ...projectConfigWithoutProxy } = projectConfig;
538
- const source = options.layer === 'user' ? userConfig : options.layer === 'project' ? projectConfig : { ...userConfig, ...projectConfigWithoutProxy };
730
+ const userConfig = readUserJsonFile() ?? {};
731
+ const projectConfig = removeProjectSensitiveConfig(readProjectJsonFile(projectRoot) ?? {});
732
+ const { proxy: projectProxy, workspaces: projectWorkspaces, ...projectConfigWithoutProxy } = projectConfig;
733
+ const source = options.layer === 'user'
734
+ ? userConfig
735
+ : options.layer === 'project'
736
+ ? projectConfig
737
+ : {
738
+ ...userConfig,
739
+ ...projectConfigWithoutProxy,
740
+ workspaces: mergeWorkspaceConfigs(toWorkspaceConfigs(userConfig.workspaces), toWorkspaceConfigs(projectWorkspaces))
741
+ };
539
742
  const config = isRecord(source) ? { ...source, ...(source.tokens !== undefined ? { tokens: toTokenConfig(source.tokens) } : {}) } : source;
540
743
  if (options.key !== undefined) {
541
744
  return getNestedValue(config, options.key);
@@ -564,12 +767,21 @@ export function setConfig(options) {
564
767
  }
565
768
  }
566
769
  validateProxyUrl(getProxyUrlCandidate(options.key, options.value));
567
- const targetPath = layer === 'project' ? getProjectWritePath() : getUserConfigPath();
770
+ const projectTarget = layer === 'project' ? getProjectWriteTarget() : null;
771
+ const targetPath = projectTarget?.configPath ?? getUserConfigPath();
568
772
  ensureDir(dirname(targetPath));
569
- const existing = readJsonFile(targetPath) ?? {};
773
+ const existing = projectTarget
774
+ ? readJsonFile(targetPath, () => validateProjectBootstrapConfigPathForWrite(projectTarget.projectRoot, targetPath)) ?? {}
775
+ : readJsonFile(targetPath, () => validateUserConfigPathForWrite(targetPath)) ?? {};
570
776
  const updated = { ...existing };
571
777
  setNestedValue(updated, options.key, options.value);
572
- writeFileSync(targetPath, JSON.stringify(updated, null, 2), 'utf-8');
778
+ const content = JSON.stringify(updated, null, 2);
779
+ if (projectTarget) {
780
+ writeProjectConfigFile(projectTarget.projectRoot, targetPath, content);
781
+ }
782
+ else {
783
+ writeUserConfigFile(targetPath, content);
784
+ }
573
785
  }
574
786
  export function getWorkspaceConfig(workspaceId, projectRoot) {
575
787
  const config = readConfig(projectRoot ?? findProjectRoot(process.cwd()));
@@ -626,3 +838,77 @@ export function getCurrentWorkspaceConfig() {
626
838
  return null;
627
839
  return getWorkspaceConfig(config.currentWorkspace);
628
840
  }
841
+ export function getWorkspaceConfigForPath(path = process.cwd()) {
842
+ const config = readConfig(findProjectRoot(path));
843
+ return findWorkspaceForPath(config.workspaces, path);
844
+ }
845
+ function createWorkspaceId(projectRoot, existingIds) {
846
+ const base = toSafeConfigSegment(basename(projectRoot));
847
+ if (!existingIds.has(base))
848
+ return base;
849
+ let suffix = 2;
850
+ while (existingIds.has(`${base}-${suffix}`)) {
851
+ suffix += 1;
852
+ }
853
+ return `${base}-${suffix}`;
854
+ }
855
+ function findWorkspaceForPath(workspaces, path) {
856
+ const targetPath = stablePath(path);
857
+ const matches = workspaces.flatMap((workspace) => {
858
+ if (!isAbsolute(workspace.rootPath) || !existsSync(workspace.rootPath))
859
+ return [];
860
+ const rootPath = stablePath(workspace.rootPath);
861
+ return isInsidePath(targetPath, rootPath) ? [{ workspace, rootPath }] : [];
862
+ });
863
+ if (matches.length === 0)
864
+ return null;
865
+ return matches.reduce((best, match) => match.rootPath.length > best.rootPath.length ? match : best).workspace;
866
+ }
867
+ function getWorkspaceArtifactRoot(workspace) {
868
+ return workspace.artifactStorage?.localPath ? resolve(workspace.artifactStorage.localPath) : resolve(homedir(), '.peaks', 'workspaces', workspace.workspaceId, 'artifacts');
869
+ }
870
+ function ensureArtifactWorkspaceMarker(workspace) {
871
+ const artifactRoot = getWorkspaceArtifactRoot(workspace);
872
+ const peaksPath = resolve(artifactRoot, '.peaks');
873
+ const markerPath = resolve(peaksPath, 'config.json');
874
+ ensureDir(artifactRoot);
875
+ validateArtifactWorkspaceRoot(artifactRoot, workspace.rootPath);
876
+ ensureDir(peaksPath);
877
+ validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath);
878
+ if (!existsSync(markerPath)) {
879
+ writeConfigFileSafely(markerPath, '{}\n', () => validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath), 'Artifact workspace marker must stay inside the artifact workspace');
880
+ }
881
+ }
882
+ export function ensureWorkspaceConfigForPath(path = process.cwd()) {
883
+ const projectRoot = resolveProjectRootForConfig(path);
884
+ if (!isAbsolute(projectRoot) || !existsSync(projectRoot))
885
+ return null;
886
+ const config = readLayerConfig('user');
887
+ const existingWorkspace = findWorkspaceForPath(config.workspaces, path);
888
+ if (existingWorkspace) {
889
+ ensureArtifactWorkspaceMarker(existingWorkspace);
890
+ if (!config.currentWorkspace) {
891
+ writeConfig({ currentWorkspace: existingWorkspace.workspaceId }, 'user');
892
+ }
893
+ return existingWorkspace;
894
+ }
895
+ const existingIds = new Set(config.workspaces.map((workspace) => workspace.workspaceId));
896
+ const workspaceId = createWorkspaceId(projectRoot, existingIds);
897
+ const workspace = {
898
+ workspaceId,
899
+ name: basename(projectRoot) || 'Workspace',
900
+ rootPath: stablePath(projectRoot),
901
+ artifactStorage: { mode: 'local', localPath: resolve(homedir(), '.peaks', 'workspaces', workspaceId, 'artifacts') },
902
+ installedCapabilityIds: []
903
+ };
904
+ ensureArtifactWorkspaceMarker(workspace);
905
+ const updatedWorkspaces = [...config.workspaces, workspace];
906
+ writeConfig({ workspaces: updatedWorkspaces, ...(!config.currentWorkspace ? { currentWorkspace: workspace.workspaceId } : {}) }, 'user');
907
+ return workspace;
908
+ }
909
+ export function getWorkspaceConfigForCurrentPath() {
910
+ return getWorkspaceConfigForPath(process.cwd());
911
+ }
912
+ export function ensureWorkspaceConfigForCurrentPath() {
913
+ return ensureWorkspaceConfigForPath(process.cwd());
914
+ }
@@ -1,5 +1,6 @@
1
+ import { CLI_VERSION } from '../../shared/version.js';
1
2
  export const DEFAULT_CONFIG = {
2
- version: '0.1.0',
3
+ version: CLI_VERSION,
3
4
  currentWorkspace: null,
4
5
  workspaces: [],
5
6
  language: 'en',
@@ -1,5 +1,5 @@
1
1
  import { type Platform } from './platform.js';
2
- export declare const SEP: "/" | "\\";
2
+ export declare const SEP: "\\" | "/";
3
3
  export declare function normalizePath(p: string): string;
4
4
  export declare function pathsEqual(a: string, b: string): boolean;
5
5
  export declare function localPath(p: string, targetPlatform?: Platform): string;
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.0.7";
1
+ export declare const CLI_VERSION = "1.0.9";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.0.7";
1
+ export const CLI_VERSION = "1.0.9";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Peaks CLI and short skill family for Claude Code automation.",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { closeSync, constants, copyFileSync, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { closeSync, constants, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, realpathSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { createHash, randomUUID } from 'node:crypto';
3
4
  import { homedir } from 'node:os';
4
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
7
 
7
8
  function getPathStats(path) {
@@ -16,31 +17,163 @@ function isBrokenSymlink(stats, targetPath) {
16
17
  return stats.isSymbolicLink() && !existsSync(targetPath);
17
18
  }
18
19
 
20
+ function validateManagedMarkerPath(markerPath) {
21
+ const markerStats = getPathStats(markerPath);
22
+ if (!markerStats) return;
23
+ if (markerStats.isSymbolicLink()) {
24
+ throw new Error('Peaks managed marker path must not be a symlink');
25
+ }
26
+ if (!markerStats.isFile()) {
27
+ throw new Error('Peaks managed marker path must be a file');
28
+ }
29
+ if (markerStats.nlink !== 1) {
30
+ throw new Error('Peaks managed marker path must not be hardlinked');
31
+ }
32
+ }
33
+
34
+ function validateOpenFile(fd, path, errorMessage) {
35
+ const fdStats = fstatSync(fd);
36
+ const pathStats = lstatSync(path);
37
+ if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
38
+ throw new Error(errorMessage);
39
+ }
40
+ if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
41
+ throw new Error(`${errorMessage}: hardlinked file`);
42
+ }
43
+ }
44
+
45
+ function createFileIdentity(path) {
46
+ const stats = lstatSync(path);
47
+ if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) {
48
+ return null;
49
+ }
50
+ return { dev: stats.dev, ino: stats.ino };
51
+ }
52
+
53
+ function isSameFileIdentity(path, identity) {
54
+ if (identity === null) return false;
55
+ const stats = getPathStats(path);
56
+ return Boolean(stats?.isFile() && !stats.isSymbolicLink() && stats.nlink === 1 && stats.dev === identity.dev && stats.ino === identity.ino);
57
+ }
58
+
59
+ function getSafeReadOpenFlags() {
60
+ return typeof constants.O_NOFOLLOW === 'number' ? constants.O_RDONLY | constants.O_NOFOLLOW : constants.O_RDONLY;
61
+ }
62
+
63
+ function readFileSafely(path, errorMessage) {
64
+ const fd = openSync(path, getSafeReadOpenFlags());
65
+ try {
66
+ validateOpenFile(fd, path, errorMessage);
67
+ return readFileSync(fd, 'utf8');
68
+ } finally {
69
+ closeSync(fd);
70
+ }
71
+ }
72
+
19
73
  function getManagedTarget(targetPath) {
20
74
  const markerPath = `${targetPath}.peaks-managed`;
75
+ validateManagedMarkerPath(markerPath);
21
76
  if (!existsSync(markerPath)) {
22
77
  return null;
23
78
  }
24
- return readFileSync(markerPath, 'utf8').trim();
79
+ return readFileSafely(markerPath, 'Peaks managed marker path changed during read').trim();
25
80
  }
26
81
 
27
82
  function markManagedPeaksLink(targetPath, sourcePath) {
28
83
  const markerPath = `${targetPath}.peaks-managed`;
29
- writeFileSync(markerPath, `${sourcePath}\n`, 'utf8');
84
+ validateManagedMarkerPath(markerPath);
85
+ writeFileAtomically(markerPath, `${sourcePath}\n`, 'Peaks managed marker path changed during write', () => validateManagedMarkerPath(markerPath));
86
+ }
87
+
88
+ function readPackageSourceFile(path) {
89
+ const stats = lstatSync(path);
90
+ if (!stats.isFile() || stats.isSymbolicLink()) {
91
+ throw new Error('Peaks package source path must be a file');
92
+ }
93
+ return readFileSync(path, 'utf8');
94
+ }
95
+
96
+ function hashContent(content) {
97
+ return createHash('sha256').update(content).digest('hex');
98
+ }
99
+
100
+ function hashFileContent(path) {
101
+ return hashContent(readFileSafely(path, 'Peaks managed file path changed during read'));
102
+ }
103
+
104
+ function createManagedOutputStyleMarker(sourcePath, outputStyleName) {
105
+ const content = readPackageSourceFile(sourcePath);
106
+ return `${JSON.stringify({ version: 1, kind: 'output-style', outputStyleName, sourcePath, contentSha256: hashContent(content) })}\n`;
107
+ }
108
+
109
+ function parseManagedOutputStyleMarker(managedTarget) {
110
+ if (managedTarget === null) return null;
111
+ try {
112
+ const marker = JSON.parse(managedTarget);
113
+ if (marker?.version !== 1 || marker?.kind !== 'output-style' || typeof marker.outputStyleName !== 'string' || typeof marker.sourcePath !== 'string' || typeof marker.contentSha256 !== 'string') {
114
+ return null;
115
+ }
116
+ return marker;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function isTrustedOutputStyleSource(marker, sourcePath, outputStyleName) {
123
+ return marker.outputStyleName === outputStyleName && resolve(marker.sourcePath) === resolve(sourcePath) && basename(resolve(marker.sourcePath)) === outputStyleName;
124
+ }
125
+
126
+ function getManagedPeaksOutputStyleIdentity(managedTarget, targetPath, sourcePath, outputStyleName) {
127
+ const marker = parseManagedOutputStyleMarker(managedTarget);
128
+ const sourceHash = hashContent(readPackageSourceFile(sourcePath));
129
+ if (marker === null || !isTrustedOutputStyleSource(marker, sourcePath, outputStyleName) || !existsSync(targetPath) || hashFileContent(targetPath) !== sourceHash || marker.contentSha256 !== sourceHash) {
130
+ return null;
131
+ }
132
+ return createFileIdentity(targetPath);
133
+ }
134
+
135
+ function validateInstallRoot(targetRoot, label) {
136
+ const rootStats = lstatSync(targetRoot);
137
+ if (rootStats.isSymbolicLink()) {
138
+ throw new Error(`${label} install root must not be a symlink`);
139
+ }
140
+ if (!rootStats.isDirectory()) {
141
+ throw new Error(`${label} install root must be a directory`);
142
+ }
143
+ return rootStats;
30
144
  }
31
145
 
32
- function isManagedPeaksOutputStyle(managedTarget, outputStyleName) {
33
- if (managedTarget === null) return false;
34
- return managedTarget.replaceAll('\\', '/').endsWith(`/output-styles/${outputStyleName}`);
146
+ function createInstallRootValidator(targetRoot, label) {
147
+ const expectedStats = validateInstallRoot(targetRoot, label);
148
+ return () => {
149
+ const rootStats = validateInstallRoot(targetRoot, label);
150
+ if (rootStats.dev !== expectedStats.dev || rootStats.ino !== expectedStats.ino) {
151
+ throw new Error(`${label} install root changed during write`);
152
+ }
153
+ };
35
154
  }
36
155
 
37
156
  function createInstallResult() {
38
157
  return { installed: [], skipped: [] };
39
158
  }
40
159
 
41
- const PROJECT_CONFIG_DEFAULTS = {
42
- version: '0.1.0',
43
- currentWorkspace: null,
160
+ function resolvePackageRoot(options = {}) {
161
+ return resolve(options.packageRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..'));
162
+ }
163
+
164
+ function readPackageVersion(packageRoot = resolvePackageRoot()) {
165
+ const packageJson = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
166
+ if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
167
+ throw new Error('package.json version must be a non-empty string');
168
+ }
169
+
170
+ return packageJson.version;
171
+ }
172
+
173
+ function createConfigDefaults(packageRoot) {
174
+ return {
175
+ version: readPackageVersion(packageRoot),
176
+ currentWorkspace: null,
44
177
  workspaces: [],
45
178
  language: 'en',
46
179
  model: 'sonnet',
@@ -52,8 +185,9 @@ const PROJECT_CONFIG_DEFAULTS = {
52
185
  model: 'minimax-2.7'
53
186
  }
54
187
  },
55
- proxy: {}
56
- };
188
+ proxy: {}
189
+ };
190
+ }
57
191
 
58
192
  function createConfigResult(overrides = {}) {
59
193
  return { created: false, updated: false, skipped: false, ...overrides };
@@ -89,7 +223,7 @@ function readConfigFile(configPath, label) {
89
223
  }
90
224
 
91
225
  try {
92
- const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
226
+ const parsed = JSON.parse(readFileSafely(configPath, `${label} config path changed during read`));
93
227
  if (!isPlainObject(parsed)) {
94
228
  throw new Error(`${label} config must contain a JSON object`);
95
229
  }
@@ -117,6 +251,9 @@ function validateConfigPath(root, peaksRoot, configPath, label) {
117
251
  throw new Error(`${label} config path must be a file`);
118
252
  }
119
253
  if (configStats) {
254
+ if (configStats.nlink !== 1) {
255
+ throw new Error(`${label} config path must not be hardlinked`);
256
+ }
120
257
  const configReal = realpathSync(configPath);
121
258
  if (!isInsidePath(configReal, rootReal) || !isInsidePath(configReal, peaksReal)) {
122
259
  throw new Error(`${label} config path must stay inside the ${label.toLowerCase()} root`);
@@ -132,41 +269,93 @@ function validateUserConfigPaths(userRoot, peaksRoot, configPath) {
132
269
  validateConfigPath(userRoot, peaksRoot, configPath, 'User');
133
270
  }
134
271
 
135
- function validateOpenConfigFile(fd, configPath, label) {
136
- const fdStats = fstatSync(fd);
137
- const pathStats = lstatSync(configPath);
138
- if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
139
- throw new Error(`${label} config path changed during write`);
140
- }
141
- if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
142
- throw new Error(`${label} config path must not be hardlinked`);
143
- }
272
+ function getSafeTempOpenFlags() {
273
+ const baseFlags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
274
+ return typeof constants.O_NOFOLLOW === 'number' ? baseFlags | constants.O_NOFOLLOW : baseFlags;
144
275
  }
145
276
 
146
- function writeConfigFile(configPath, content, label, validateBeforeWrite) {
277
+ function writeFileExclusively(path, content, errorMessage, validateBeforeWrite) {
147
278
  validateBeforeWrite();
148
- if (typeof constants.O_NOFOLLOW !== 'number') {
149
- throw new Error('Safe config writes require O_NOFOLLOW support');
279
+ let fd = openSync(path, getSafeTempOpenFlags(), 0o600);
280
+ let closeError = null;
281
+ let identity = null;
282
+ try {
283
+ validateOpenFile(fd, path, errorMessage);
284
+ validateBeforeWrite();
285
+ validateOpenFile(fd, path, errorMessage);
286
+ identity = createFileIdentity(path);
287
+ if (identity === null) {
288
+ throw new Error(errorMessage);
289
+ }
290
+ fchmodSync(fd, 0o600);
291
+ writeFileSync(fd, content, 'utf8');
292
+ const writeFd = fd;
293
+ fd = null;
294
+ closeSync(writeFd);
295
+ return identity;
296
+ } finally {
297
+ if (fd !== null) {
298
+ try {
299
+ closeSync(fd);
300
+ } catch (error) {
301
+ closeError = error;
302
+ }
303
+ }
304
+ if (closeError) {
305
+ throw closeError;
306
+ }
150
307
  }
308
+ }
309
+
310
+ function writeFileAtomically(configPath, content, errorMessage, validateBeforeWrite) {
311
+ validateBeforeWrite();
151
312
 
152
- const fd = openSync(configPath, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
313
+ const tempPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
314
+ let fd = openSync(tempPath, getSafeTempOpenFlags(), 0o600);
315
+ let renamed = false;
316
+ let closeError = null;
153
317
  try {
154
- validateBeforeWrite();
155
- validateOpenConfigFile(fd, configPath, label);
318
+ validateOpenFile(fd, tempPath, errorMessage);
156
319
  fchmodSync(fd, 0o600);
157
- ftruncateSync(fd, 0);
158
320
  writeFileSync(fd, content, 'utf8');
321
+ const writeFd = fd;
322
+ fd = null;
323
+ closeSync(writeFd);
324
+ validateBeforeWrite();
325
+ const readFd = openSync(tempPath, getSafeReadOpenFlags());
326
+ try {
327
+ validateOpenFile(readFd, tempPath, errorMessage);
328
+ } finally {
329
+ closeSync(readFd);
330
+ }
331
+ renameSync(tempPath, configPath);
332
+ renamed = true;
159
333
  } finally {
160
- closeSync(fd);
334
+ if (fd !== null) {
335
+ try {
336
+ closeSync(fd);
337
+ } catch (error) {
338
+ closeError = error;
339
+ }
340
+ }
341
+ try {
342
+ if (!renamed && existsSync(tempPath)) {
343
+ unlinkSync(tempPath);
344
+ }
345
+ } finally {
346
+ if (closeError) {
347
+ throw closeError;
348
+ }
349
+ }
161
350
  }
162
351
  }
163
352
 
164
353
  function writeProjectConfig(projectRoot, peaksRoot, configPath, content) {
165
- writeConfigFile(configPath, content, 'Project', () => validateProjectConfigPaths(projectRoot, peaksRoot, configPath));
354
+ writeFileAtomically(configPath, content, 'Project config path changed during write', () => validateProjectConfigPaths(projectRoot, peaksRoot, configPath));
166
355
  }
167
356
 
168
357
  function writeUserConfig(userRoot, peaksRoot, configPath, content) {
169
- writeConfigFile(configPath, content, 'User', () => validateUserConfigPaths(userRoot, peaksRoot, configPath));
358
+ writeFileAtomically(configPath, content, 'User config path changed during write', () => validateUserConfigPaths(userRoot, peaksRoot, configPath));
170
359
  }
171
360
 
172
361
  function resolveProjectRoot(options) {
@@ -174,9 +363,9 @@ function resolveProjectRoot(options) {
174
363
  return projectRoot ? resolve(projectRoot) : null;
175
364
  }
176
365
 
177
- function writeMergedConfig(configPath, label, writeConfig) {
366
+ function writeMergedConfig(configPath, label, defaults, writeConfig) {
178
367
  const existing = readConfigFile(configPath, label);
179
- const next = existing === null ? PROJECT_CONFIG_DEFAULTS : mergeMissingConfigValues(existing, PROJECT_CONFIG_DEFAULTS);
368
+ const next = { ...(existing === null ? defaults : mergeMissingConfigValues(existing, defaults)), version: defaults.version };
180
369
  const currentJson = existing === null ? null : `${JSON.stringify(existing, null, 2)}\n`;
181
370
  const nextJson = `${JSON.stringify(next, null, 2)}\n`;
182
371
 
@@ -205,7 +394,7 @@ export function installUserConfig(options = {}) {
205
394
  }
206
395
  validateUserConfigPaths(userRoot, peaksRoot, configPath);
207
396
 
208
- return writeMergedConfig(configPath, 'User', (content) => writeUserConfig(userRoot, peaksRoot, configPath, content));
397
+ return writeMergedConfig(configPath, 'User', createConfigDefaults(options.packageRoot), (content) => writeUserConfig(userRoot, peaksRoot, configPath, content));
209
398
  }
210
399
 
211
400
  export function installProjectConfig(options = {}) {
@@ -229,11 +418,11 @@ export function installProjectConfig(options = {}) {
229
418
  }
230
419
  validateProjectConfigPaths(projectRoot, peaksRoot, configPath);
231
420
 
232
- return writeMergedConfig(configPath, 'Project', (content) => writeProjectConfig(projectRoot, peaksRoot, configPath, content));
421
+ return writeMergedConfig(configPath, 'Project', createConfigDefaults(options.packageRoot), (content) => writeProjectConfig(projectRoot, peaksRoot, configPath, content));
233
422
  }
234
423
 
235
424
  export function installBundledSkills(options = {}) {
236
- const packageRoot = resolve(options.packageRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..'));
425
+ const packageRoot = resolvePackageRoot(options);
237
426
  const skillsRoot = join(packageRoot, 'skills');
238
427
  const targetRoot = resolve(options.targetRoot ?? process.env.PEAKS_CLAUDE_SKILLS_DIR ?? join(homedir(), '.claude', 'skills'));
239
428
 
@@ -244,6 +433,7 @@ export function installBundledSkills(options = {}) {
244
433
  const installed = [];
245
434
  const skipped = [];
246
435
  mkdirSync(targetRoot, { recursive: true });
436
+ const validateSkillsRoot = createInstallRootValidator(targetRoot, 'Peaks skills');
247
437
 
248
438
  for (const skillName of readdirSync(skillsRoot)) {
249
439
  const sourcePath = join(skillsRoot, skillName);
@@ -257,12 +447,15 @@ export function installBundledSkills(options = {}) {
257
447
  const current = getPathStats(targetPath);
258
448
  if (current) {
259
449
  const managedTarget = getManagedTarget(targetPath);
260
- if (current.isSymbolicLink() && readlinkSync(targetPath) === sourcePath) {
450
+ const linkTarget = current.isSymbolicLink() ? readlinkSync(targetPath) : null;
451
+ if (linkTarget === sourcePath) {
261
452
  installed.push(skillName);
262
453
  continue;
263
454
  }
264
- if (isBrokenSymlink(current, targetPath) && managedTarget === readlinkSync(targetPath)) {
455
+ if ((current.isSymbolicLink() || isBrokenSymlink(current, targetPath)) && managedTarget === linkTarget) {
456
+ validateSkillsRoot();
265
457
  unlinkSync(targetPath);
458
+ validateSkillsRoot();
266
459
  unlinkSync(`${targetPath}.peaks-managed`);
267
460
  } else {
268
461
  skipped.push(skillName);
@@ -270,8 +463,19 @@ export function installBundledSkills(options = {}) {
270
463
  }
271
464
  }
272
465
 
466
+ validateSkillsRoot();
273
467
  symlinkSync(sourcePath, targetPath, process.platform === 'win32' ? 'junction' : 'dir');
274
- markManagedPeaksLink(targetPath, sourcePath);
468
+ try {
469
+ validateSkillsRoot();
470
+ markManagedPeaksLink(targetPath, sourcePath);
471
+ } catch (error) {
472
+ validateSkillsRoot();
473
+ const created = getPathStats(targetPath);
474
+ if (created?.isSymbolicLink() && readlinkSync(targetPath) === sourcePath) {
475
+ unlinkSync(targetPath);
476
+ }
477
+ throw error;
478
+ }
275
479
  installed.push(skillName);
276
480
  }
277
481
 
@@ -279,7 +483,7 @@ export function installBundledSkills(options = {}) {
279
483
  }
280
484
 
281
485
  export function installBundledOutputStyles(options = {}) {
282
- const packageRoot = resolve(options.packageRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..'));
486
+ const packageRoot = resolvePackageRoot(options);
283
487
  const outputStylesRoot = join(packageRoot, 'output-styles');
284
488
  const targetRoot = resolve(options.targetRoot ?? process.env.PEAKS_CLAUDE_OUTPUT_STYLES_DIR ?? join(homedir(), '.claude', 'output-styles'));
285
489
 
@@ -290,6 +494,7 @@ export function installBundledOutputStyles(options = {}) {
290
494
  const installed = [];
291
495
  const skipped = [];
292
496
  mkdirSync(targetRoot, { recursive: true });
497
+ const validateOutputStylesRoot = createInstallRootValidator(targetRoot, 'Peaks output styles');
293
498
 
294
499
  for (const outputStyleName of readdirSync(outputStylesRoot)) {
295
500
  const sourcePath = join(outputStylesRoot, outputStyleName);
@@ -302,8 +507,14 @@ export function installBundledOutputStyles(options = {}) {
302
507
  const current = getPathStats(targetPath);
303
508
  if (current) {
304
509
  const managedTarget = getManagedTarget(targetPath);
305
- if (isManagedPeaksOutputStyle(managedTarget, outputStyleName)) {
510
+ const managedTargetIdentity = getManagedPeaksOutputStyleIdentity(managedTarget, targetPath, sourcePath, outputStyleName);
511
+ if (isSameFileIdentity(targetPath, managedTargetIdentity)) {
512
+ validateOutputStylesRoot();
513
+ if (!isSameFileIdentity(targetPath, managedTargetIdentity)) {
514
+ throw new Error('Peaks output style path changed during unlink');
515
+ }
306
516
  unlinkSync(targetPath);
517
+ validateOutputStylesRoot();
307
518
  unlinkSync(`${targetPath}.peaks-managed`);
308
519
  } else {
309
520
  skipped.push(outputStyleName);
@@ -311,8 +522,25 @@ export function installBundledOutputStyles(options = {}) {
311
522
  }
312
523
  }
313
524
 
314
- copyFileSync(sourcePath, targetPath);
315
- markManagedPeaksLink(targetPath, sourcePath);
525
+ const markerPath = `${targetPath}.peaks-managed`;
526
+ validateManagedMarkerPath(markerPath);
527
+ if (!current && existsSync(markerPath)) {
528
+ validateOutputStylesRoot();
529
+ unlinkSync(markerPath);
530
+ }
531
+ const createdTargetIdentity = writeFileExclusively(targetPath, readPackageSourceFile(sourcePath), 'Peaks output style path changed during write', validateOutputStylesRoot);
532
+ try {
533
+ writeFileExclusively(markerPath, createManagedOutputStyleMarker(sourcePath, outputStyleName), 'Peaks managed marker path changed during write', () => {
534
+ validateOutputStylesRoot();
535
+ validateManagedMarkerPath(markerPath);
536
+ });
537
+ } catch (error) {
538
+ validateOutputStylesRoot();
539
+ if (isSameFileIdentity(targetPath, createdTargetIdentity)) {
540
+ unlinkSync(targetPath);
541
+ }
542
+ throw error;
543
+ }
316
544
  installed.push(outputStyleName);
317
545
  }
318
546