@mohammadhprp/system-prompt 0.12.6 → 0.13.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/src/doctor.js CHANGED
@@ -3,7 +3,8 @@ import { access, readFile } from 'node:fs/promises';
3
3
  import { createHash } from 'node:crypto';
4
4
 
5
5
  import { categories } from './catalog.js';
6
- import { loadLockFile } from './installer.js';
6
+ import { loadLockFile, lockToSelections } from './installer.js';
7
+ import { itemRelativePath } from './item-layout.js';
7
8
 
8
9
  async function exists(path) {
9
10
  try {
@@ -14,6 +15,16 @@ async function exists(path) {
14
15
  }
15
16
  }
16
17
 
18
+ async function checkManagedFile(absTarget, relativePath, expectedHash, issues) {
19
+ try {
20
+ const actual = createHash('sha256').update(await readFile(resolve(absTarget, relativePath))).digest('hex');
21
+ if (actual !== expectedHash) issues.push(`Modified managed file: ${relativePath}`);
22
+ } catch (error) {
23
+ if (error.code === 'ENOENT') issues.push(`Missing managed file: ${relativePath}`);
24
+ else throw error;
25
+ }
26
+ }
27
+
17
28
  export async function inspectInstallation(targetDir) {
18
29
  const absTarget = resolve(process.cwd(), targetDir);
19
30
  const issues = [];
@@ -31,24 +42,24 @@ export async function inspectInstallation(targetDir) {
31
42
  return { targetDir: absTarget, issues };
32
43
  }
33
44
 
34
- for (const [path, expected] of Object.entries(lock.managedFiles || {})) {
35
- try {
36
- const actual = createHash('sha256').update(await readFile(resolve(absTarget, path))).digest('hex');
37
- if (actual !== expected) issues.push(`Modified managed file: ${path}`);
38
- } catch (error) {
39
- if (error.code === 'ENOENT') issues.push(`Missing managed file: ${path}`);
40
- else throw error;
45
+ for (const [path, entry] of Object.entries(lock.generated || {})) {
46
+ await checkManagedFile(absTarget, path, entry.computedHash, issues);
47
+ }
48
+
49
+ for (const [category, entries] of Object.entries(lock)) {
50
+ if (!categories[category]) continue;
51
+ for (const entry of Object.values(entries)) {
52
+ for (const [path, expected] of Object.entries(entry.files || {})) {
53
+ await checkManagedFile(absTarget, path, expected, issues);
54
+ }
41
55
  }
42
56
  }
43
57
 
44
- for (const [category, ids] of Object.entries(lock.selections)) {
58
+ for (const [category, ids] of Object.entries(lockToSelections(lock))) {
45
59
  const config = categories[category];
46
60
  for (const id of ids) {
47
61
  const item = config.items.find(entry => entry.id === id);
48
- const relativePath = ['agents', 'commands', 'memory', 'modes', 'standards', 'templates'].includes(category)
49
- ? `${config.sourceDir.replace(/^framework\//, '')}/${id}.md`
50
- : `${config.sourceDir.replace(/^framework\//, '')}/${id}`;
51
- if (!item || !(await exists(resolve(absTarget, relativePath)))) {
62
+ if (!item || !(await exists(resolve(absTarget, itemRelativePath(category, id))))) {
52
63
  issues.push(`Missing installed ${category} item: ${id}`);
53
64
  }
54
65
  }
package/src/installer.js CHANGED
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto';
5
5
 
6
6
  import { categories } from './catalog.js';
7
7
  import { loadMcpConfigs, generateOpenCodeConfig, generateTuiConfig } from './agent-configs.js';
8
+ import { LOCK_CATEGORIES, isFileBased, isCopyable, itemSourcePath, itemRelativePath, targetSubdir } from './item-layout.js';
8
9
 
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  const packageRoot = resolve(__dirname, '..');
@@ -86,6 +87,38 @@ package-lock.json
86
87
  bun.lock
87
88
  `;
88
89
 
90
+ export const LOCK_VERSION = 1;
91
+
92
+ export function lockToSelections(lock) {
93
+ const selections = {};
94
+ if (!lock) return selections;
95
+ for (const category of LOCK_CATEGORIES) {
96
+ const ids = Object.keys(lock[category] || {});
97
+ if (ids.length) selections[category] = ids;
98
+ }
99
+ return selections;
100
+ }
101
+
102
+ function buildComputedHash(files, fallbackId) {
103
+ const keys = Object.keys(files).sort();
104
+ if (keys.length === 0) return hash(fallbackId);
105
+ if (keys.length === 1) return files[keys[0]];
106
+ return hash(keys.map(path => `${path}:${files[path]}`).join('\n'));
107
+ }
108
+
109
+ function getExpectedHash(oldLock, relativePath) {
110
+ const generated = oldLock?.generated?.[relativePath]?.computedHash;
111
+ if (generated) return generated;
112
+ for (const category of LOCK_CATEGORIES) {
113
+ const entries = oldLock?.[category];
114
+ if (!entries) continue;
115
+ for (const entry of Object.values(entries)) {
116
+ const value = entry?.files?.[relativePath];
117
+ if (value) return value;
118
+ }
119
+ }
120
+ return undefined;
121
+ }
89
122
  export async function getPackageVersion() {
90
123
  try {
91
124
  const pkg = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf-8'));
@@ -114,26 +147,63 @@ function validateLock(lock) {
114
147
  if (!lock || typeof lock !== 'object' || Array.isArray(lock)) {
115
148
  throw new Error('Invalid system-prompt lock: expected an object');
116
149
  }
117
- if (!lock.selections || typeof lock.selections !== 'object' || Array.isArray(lock.selections)) {
118
- throw new Error('Invalid system-prompt lock: selections must be an object');
150
+ if (lock.selections !== undefined || lock.managedFiles !== undefined) {
151
+ throw new Error('Invalid system-prompt lock: legacy lock format no longer supported');
119
152
  }
120
- for (const [category, ids] of Object.entries(lock.selections)) {
121
- const config = categories[category];
122
- if (!config || !Array.isArray(ids) || ids.some(id => typeof id !== 'string')) {
153
+ if (lock.version !== LOCK_VERSION) {
154
+ throw new Error('Invalid system-prompt lock: unsupported version');
155
+ }
156
+ if (typeof lock.agentType !== 'string' || typeof lock.installedAt !== 'string') {
157
+ throw new Error('Invalid system-prompt lock: missing installation metadata');
158
+ }
159
+ if (typeof lock.includeAgentsMd !== 'boolean') {
160
+ throw new Error('Invalid system-prompt lock: includeAgentsMd must be a boolean');
161
+ }
162
+ const allowed = new Set(['version', 'agentType', 'installedAt', 'includeAgentsMd', 'generated', ...LOCK_CATEGORIES]);
163
+ for (const key of Object.keys(lock)) {
164
+ if (!allowed.has(key)) {
165
+ throw new Error(`Invalid system-prompt lock: unknown key ${key}`);
166
+ }
167
+ }
168
+ for (const category of LOCK_CATEGORIES) {
169
+ const entries = lock[category];
170
+ if (entries === undefined) continue;
171
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries)) {
123
172
  throw new Error(`Invalid system-prompt lock: invalid ${category} selection`);
124
173
  }
174
+ const config = categories[category];
125
175
  const knownIds = new Set(config.items.map(item => item.id));
126
- if (ids.some(id => !knownIds.has(id))) {
127
- throw new Error(`Invalid system-prompt lock: unknown ${category} item`);
176
+ for (const [id, entry] of Object.entries(entries)) {
177
+ if (!knownIds.has(id)) {
178
+ throw new Error(`Invalid system-prompt lock: unknown ${category} item`);
179
+ }
180
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
181
+ throw new Error(`Invalid system-prompt lock: invalid ${category} entry`);
182
+ }
183
+ if (typeof entry.source !== 'string' || typeof entry.sourceType !== 'string' || typeof entry.itemPath !== 'string') {
184
+ throw new Error(`Invalid system-prompt lock: invalid ${category} entry source`);
185
+ }
186
+ if (!/^[a-f0-9]{64}$/.test(entry.computedHash || '')) {
187
+ throw new Error(`Invalid system-prompt lock: invalid ${category} entry hash`);
188
+ }
189
+ if (!entry.files || typeof entry.files !== 'object' || Array.isArray(entry.files)) {
190
+ throw new Error(`Invalid system-prompt lock: invalid ${category} entry files`);
191
+ }
192
+ for (const [path, checksum] of Object.entries(entry.files)) {
193
+ if (path.startsWith('/') || path.split('/').includes('..') || !/^[a-f0-9]{64}$/.test(checksum)) {
194
+ throw new Error('Invalid system-prompt lock: unsafe managed file entry');
195
+ }
196
+ }
128
197
  }
129
198
  }
130
- if (lock.managedFiles !== undefined) {
131
- if (!lock.managedFiles || typeof lock.managedFiles !== 'object' || Array.isArray(lock.managedFiles)) {
132
- throw new Error('Invalid system-prompt lock: managedFiles must be an object');
199
+ const generated = lock.generated;
200
+ if (generated !== undefined) {
201
+ if (!generated || typeof generated !== 'object' || Array.isArray(generated)) {
202
+ throw new Error('Invalid system-prompt lock: generated must be an object');
133
203
  }
134
- for (const [path, checksum] of Object.entries(lock.managedFiles)) {
135
- if (path.startsWith('/') || path.split('/').includes('..') || !/^[a-f0-9]{64}$/.test(checksum)) {
136
- throw new Error('Invalid system-prompt lock: unsafe managed file entry');
204
+ for (const [path, entry] of Object.entries(generated)) {
205
+ if (path.startsWith('/') || path.split('/').includes('..') || !/^[a-f0-9]{64}$/.test(entry?.computedHash || '')) {
206
+ throw new Error('Invalid system-prompt lock: unsafe generated file entry');
137
207
  }
138
208
  }
139
209
  }
@@ -152,7 +222,7 @@ async function canWrite(destFile, relativePath, oldLock, force) {
152
222
  if (force) return true;
153
223
  try {
154
224
  const existing = await readFile(destFile);
155
- const previousHash = oldLock?.managedFiles?.[relativePath];
225
+ const previousHash = getExpectedHash(oldLock, relativePath);
156
226
  return Boolean(previousHash && previousHash === hash(existing));
157
227
  } catch (error) {
158
228
  if (error.code === 'ENOENT') return true;
@@ -160,7 +230,7 @@ async function canWrite(destFile, relativePath, oldLock, force) {
160
230
  }
161
231
  }
162
232
 
163
- async function writeManagedFile(destFile, content, relativePath, options) {
233
+ async function writeManagedFile(destFile, content, relativePath, options, owner) {
164
234
  await assertSafeDestination(options.targetDir, destFile);
165
235
  if (!options.allowExistingMerge && !(await canWrite(destFile, relativePath, options.oldLock, options.force))) {
166
236
  console.warn(` ⚠ Preserving existing file: ${relativePath}`);
@@ -169,10 +239,11 @@ async function writeManagedFile(destFile, content, relativePath, options) {
169
239
  if (!options.dryRun) await mkdir(dirname(destFile), { recursive: true });
170
240
  if (!options.dryRun) await writeFile(destFile, content);
171
241
  options.managedFiles[relativePath] = hash(content);
242
+ if (owner) options.fileOwners[relativePath] = owner;
172
243
  return true;
173
244
  }
174
245
 
175
- async function copyDir(src, dest, relativeDir, options) {
246
+ async function copyDir(src, dest, relativeDir, options, owner) {
176
247
  if (!options.dryRun) await mkdir(dest, { recursive: true });
177
248
  const entries = await readdir(src, { withFileTypes: true });
178
249
 
@@ -183,10 +254,10 @@ async function copyDir(src, dest, relativeDir, options) {
183
254
  const relativePath = `${relativeDir}/${entry.name}`;
184
255
 
185
256
  if (entry.isDirectory()) {
186
- await copyDir(srcPath, destPath, relativePath, options);
257
+ await copyDir(srcPath, destPath, relativePath, options, owner);
187
258
  } else if (entry.isFile()) {
188
259
  const content = await readFile(srcPath);
189
- await writeManagedFile(destPath, content, relativePath, options);
260
+ await writeManagedFile(destPath, content, relativePath, options, owner);
190
261
  }
191
262
  }
192
263
  }
@@ -229,22 +300,22 @@ async function copySelectedDirs(targetDir, category, selectedIds, options) {
229
300
  const catConfig = categories[category];
230
301
  if (!catConfig || !selectedIds?.length) return;
231
302
 
232
- const relativeDir = targetSubdir(catConfig.sourceDir);
233
- const destParent = resolve(targetDir, relativeDir);
303
+ const destParent = resolve(targetDir, targetSubdir(catConfig.sourceDir));
234
304
 
235
305
  for (const id of selectedIds) {
236
306
  if (await isRemoved(category, id)) continue;
237
307
 
238
- const srcPath = resolveSource(`${catConfig.sourceDir}/${id}`);
308
+ const source = itemSourcePath(category, id);
309
+ const srcPath = resolveSource(source);
239
310
  const destPath = resolve(destParent, id);
240
311
  await assertSafeDestination(targetDir, destPath);
241
312
 
242
313
  try {
243
314
  await stat(srcPath);
244
- await copyDir(srcPath, destPath, `${relativeDir}/${id}`, options);
315
+ await copyDir(srcPath, destPath, itemRelativePath(category, id), options, { category, id });
245
316
  } catch (error) {
246
317
  if (sourceMissing(error)) {
247
- console.warn(` ⚠ Source not found: ${catConfig.sourceDir}/${id}`);
318
+ console.warn(` ⚠ Source not found: ${source}`);
248
319
  continue;
249
320
  }
250
321
  throw error;
@@ -256,22 +327,22 @@ async function copySelectedFiles(targetDir, category, selectedIds, options) {
256
327
  const catConfig = categories[category];
257
328
  if (!catConfig || !selectedIds?.length) return;
258
329
 
259
- const relativeDir = targetSubdir(catConfig.sourceDir);
260
- const destParent = resolve(targetDir, relativeDir);
330
+ const destParent = resolve(targetDir, targetSubdir(catConfig.sourceDir));
261
331
  if (!options.dryRun) await mkdir(destParent, { recursive: true });
262
332
 
263
333
  for (const id of selectedIds) {
264
334
  if (await isRemoved(category, id)) continue;
265
335
 
266
- const srcFile = resolveSource(`${catConfig.sourceDir}/${id}.md`);
267
- const destFile = resolve(destParent, `${id}.md`);
336
+ const source = itemSourcePath(category, id);
337
+ const relativePath = itemRelativePath(category, id);
338
+ const destFile = resolve(targetDir, relativePath);
268
339
  await assertSafeDestination(targetDir, destFile);
269
340
  try {
270
- const content = await readFile(srcFile);
271
- await writeManagedFile(destFile, content, `${relativeDir}/${id}.md`, options);
341
+ const content = await readFile(resolveSource(source));
342
+ await writeManagedFile(destFile, content, relativePath, options, { category, id });
272
343
  } catch (error) {
273
344
  if (sourceMissing(error)) {
274
- console.warn(` ⚠ Source not found: ${catConfig.sourceDir}/${id}.md`);
345
+ console.warn(` ⚠ Source not found: ${source}`);
275
346
  continue;
276
347
  }
277
348
  throw error;
@@ -280,22 +351,14 @@ async function copySelectedFiles(targetDir, category, selectedIds, options) {
280
351
  }
281
352
 
282
353
  async function deleteSelectedItems(absTarget, category, ids, oldLock, force, dryRun) {
283
- const catConfig = categories[category];
284
- if (!catConfig || !ids?.length) return;
354
+ if (!categories[category] || !ids?.length || !isCopyable(category)) return;
285
355
 
286
- const FILE_BASED = new Set(['agents', 'commands', 'memory', 'modes', 'standards', 'templates']);
287
- if (!FILE_BASED.has(category) && category !== 'skills' && category !== 'styles') return;
288
-
289
- const relativeDir = targetSubdir(catConfig.sourceDir);
290
356
  for (const id of ids) {
291
- const relativePath = FILE_BASED.has(category)
292
- ? `${relativeDir}/${id}.md`
293
- : `${relativeDir}/${id}`;
357
+ const relativePath = itemRelativePath(category, id);
294
358
  const destPath = resolve(absTarget, relativePath);
295
359
  await assertSafeDestination(absTarget, destPath);
296
360
  if (!force && oldLock) {
297
- const managedEntries = Object.entries(oldLock.managedFiles || {})
298
- .filter(([path]) => path === relativePath || path.startsWith(`${relativePath}/`));
361
+ const managedEntries = Object.entries(oldLock?.[category]?.[id]?.files || {});
299
362
  if (managedEntries.length === 0) {
300
363
  console.warn(` ⚠ Preserving unmanaged item: ${relativePath}`);
301
364
  continue;
@@ -313,7 +376,7 @@ async function deleteSelectedItems(absTarget, category, ids, oldLock, force, dry
313
376
  console.warn(` ⚠ Preserving modified item: ${relativePath}`);
314
377
  continue;
315
378
  }
316
- if (!FILE_BASED.has(category)) {
379
+ if (!isFileBased(category)) {
317
380
  if (!dryRun) {
318
381
  for (const [path] of managedEntries) await rm(resolve(absTarget, path), { force: true });
319
382
  }
@@ -363,7 +426,7 @@ async function mergeJsonFile(destFile, generated, relativePath, options, previou
363
426
  return writeManagedFile(destFile, Buffer.from(JSON.stringify(merged, null, 4)), relativePath, {
364
427
  ...options,
365
428
  allowExistingMerge: !options.oldLock,
366
- });
429
+ }, { generated: true });
367
430
  }
368
431
 
369
432
  async function mergeGitignore(destFile, options) {
@@ -378,7 +441,7 @@ async function mergeGitignore(destFile, options) {
378
441
  return writeManagedFile(destFile, Buffer.from(`${[...lines].join('\n')}\n`), '.gitignore', {
379
442
  ...options,
380
443
  allowExistingMerge: !options.oldLock,
381
- });
444
+ }, { generated: true });
382
445
  }
383
446
 
384
447
  export function validateSelections(selections) {
@@ -394,7 +457,7 @@ export function validateSelections(selections) {
394
457
  return selections;
395
458
  }
396
459
 
397
- export async function install({ targetDir, agentType, selections, includeAgentsMd = true, writeAgentsMd, oldSelections, oldLock, force = false, dryRun = false }) {
460
+ export async function install({ targetDir, agentType, selections, includeAgentsMd = true, writeAgentsMd, oldSelections, oldLock, force = false, dryRun = false, tuiPreferences }) {
398
461
  validateSelections(selections);
399
462
  if (oldLock) validateLock(oldLock);
400
463
  writeAgentsMd = writeAgentsMd ?? includeAgentsMd;
@@ -405,8 +468,27 @@ export async function install({ targetDir, agentType, selections, includeAgentsM
405
468
  oldLock,
406
469
  force,
407
470
  dryRun,
408
- managedFiles: { ...(oldLock?.managedFiles || {}) },
471
+ managedFiles: {},
472
+ fileOwners: {},
409
473
  };
474
+ if (oldLock) {
475
+ for (const category of LOCK_CATEGORIES) {
476
+ for (const [id, entry] of Object.entries(oldLock[category] || {})) {
477
+ for (const [path, checksum] of Object.entries(entry.files || {})) {
478
+ if (!(path in options.managedFiles)) {
479
+ options.managedFiles[path] = checksum;
480
+ options.fileOwners[path] = { category, id };
481
+ }
482
+ }
483
+ }
484
+ }
485
+ for (const [path, entry] of Object.entries(oldLock.generated || {})) {
486
+ if (!(path in options.managedFiles)) {
487
+ options.managedFiles[path] = entry.computedHash;
488
+ options.fileOwners[path] = { generated: true };
489
+ }
490
+ }
491
+ }
410
492
 
411
493
  // Delete items that are no longer selected.
412
494
  if (oldSelections) {
@@ -419,16 +501,16 @@ export async function install({ targetDir, agentType, selections, includeAgentsM
419
501
  }
420
502
 
421
503
  const tasks = [];
422
- for (const category of ['skills', 'styles']) {
423
- if (selections[category]?.length) tasks.push(copySelectedDirs(absTarget, category, selections[category], options));
424
- }
425
- for (const category of ['agents', 'commands', 'modes', 'memory', 'standards', 'templates']) {
426
- if (selections[category]?.length) tasks.push(copySelectedFiles(absTarget, category, selections[category], options));
504
+ for (const category of LOCK_CATEGORIES) {
505
+ if (!isCopyable(category) || !selections[category]?.length) continue;
506
+ tasks.push(isFileBased(category)
507
+ ? copySelectedFiles(absTarget, category, selections[category], options)
508
+ : copySelectedDirs(absTarget, category, selections[category], options));
427
509
  }
428
510
  await Promise.all(tasks);
429
511
 
430
512
  if (writeAgentsMd) {
431
- await writeManagedFile(resolve(absTarget, 'AGENTS.md'), Buffer.from(AGENTS_MD), 'AGENTS.md', options);
513
+ await writeManagedFile(resolve(absTarget, 'AGENTS.md'), Buffer.from(AGENTS_MD), 'AGENTS.md', options, { generated: true });
432
514
  }
433
515
 
434
516
  if (agentType === 'opencode') {
@@ -437,18 +519,19 @@ export async function install({ targetDir, agentType, selections, includeAgentsM
437
519
  let previousOpenCodeConfig;
438
520
  let previousTuiConfig;
439
521
  if (oldLock) {
522
+ const oldSelections = lockToSelections(oldLock);
440
523
  let previousMcpEntries = {};
441
- if (oldLock.selections.mcps?.length) previousMcpEntries = await loadMcpConfigs(oldLock.selections.mcps);
524
+ if (oldSelections.mcps?.length) previousMcpEntries = await loadMcpConfigs(oldSelections.mcps);
442
525
  previousOpenCodeConfig = JSON.parse(generateOpenCodeConfig({
443
- selections: oldLock.selections,
526
+ selections: oldSelections,
444
527
  mcpEntries: previousMcpEntries,
445
528
  includeAgentsMd: oldLock.includeAgentsMd ?? true,
446
529
  }));
447
- previousTuiConfig = JSON.parse(generateTuiConfig({ selections: oldLock.selections }));
530
+ previousTuiConfig = JSON.parse(generateTuiConfig({ selections: oldSelections, preferences: tuiPreferences }));
448
531
  }
449
532
  const configJson = generateOpenCodeConfig({ selections, mcpEntries, includeAgentsMd });
450
533
  await mergeJsonFile(resolve(absTarget, 'opencode.json'), JSON.parse(configJson), 'opencode.json', options, previousOpenCodeConfig);
451
- await mergeJsonFile(resolve(absTarget, 'tui.json'), JSON.parse(generateTuiConfig({ selections })), 'tui.json', options, previousTuiConfig);
534
+ await mergeJsonFile(resolve(absTarget, 'tui.json'), JSON.parse(generateTuiConfig({ selections, preferences: tuiPreferences })), 'tui.json', options, previousTuiConfig);
452
535
  await mergeGitignore(resolve(absTarget, '.gitignore'), options);
453
536
  }
454
537
 
@@ -458,16 +541,38 @@ export async function install({ targetDir, agentType, selections, includeAgentsM
458
541
  }
459
542
 
460
543
  if (dryRun) return absTarget;
461
- const version = await getPackageVersion();
544
+ const pkgVersion = await getPackageVersion();
545
+ const source = `system-prompt@${pkgVersion}`;
462
546
  const lockData = {
463
- version,
547
+ version: LOCK_VERSION,
464
548
  agentType,
465
- targetDir,
466
549
  installedAt: new Date().toISOString(),
467
- selections: Object.fromEntries(Object.entries(selections).map(([k, v]) => [k, [...v]])),
468
550
  includeAgentsMd,
469
- managedFiles: options.managedFiles,
470
551
  };
552
+ for (const category of LOCK_CATEGORIES) {
553
+ const entries = {};
554
+ for (const id of selections[category] || []) {
555
+ if (await isRemoved(category, id)) continue;
556
+ const files = {};
557
+ for (const [path, checksum] of Object.entries(options.managedFiles)) {
558
+ const owner = options.fileOwners[path];
559
+ if (owner?.category === category && owner?.id === id) files[path] = checksum;
560
+ }
561
+ entries[id] = {
562
+ source,
563
+ sourceType: 'bundled',
564
+ itemPath: itemSourcePath(category, id),
565
+ computedHash: buildComputedHash(files, id),
566
+ files,
567
+ };
568
+ }
569
+ lockData[category] = entries;
570
+ }
571
+ const generated = {};
572
+ for (const [path, checksum] of Object.entries(options.managedFiles)) {
573
+ if (options.fileOwners[path]?.generated) generated[path] = { computedHash: checksum };
574
+ }
575
+ lockData.generated = generated;
471
576
  await writeFile(resolve(absTarget, 'system-prompt-lock.json'), JSON.stringify(lockData, null, 2));
472
577
  return absTarget;
473
578
  }
@@ -476,10 +581,6 @@ function resolveSource(subpath) {
476
581
  return resolve(packageRoot, subpath);
477
582
  }
478
583
 
479
- function targetSubdir(sourceDir) {
480
- return sourceDir.replace(/^framework\//, '');
481
- }
482
-
483
584
  async function isRemoved(category, id) {
484
585
  const catConfig = categories[category];
485
586
  if (!catConfig) return false;
@@ -548,4 +649,5 @@ async function writeMergedEnv(absTarget, examples, options) {
548
649
  const content = Buffer.from(`${existingContent}${separator}${additions.join('\n')}\n`);
549
650
  if (!options.dryRun) await writeFile(envPath, content);
550
651
  options.managedFiles['.env'] = hash(content);
652
+ options.fileOwners['.env'] = { generated: true };
551
653
  }
@@ -0,0 +1,26 @@
1
+ import { categories } from './catalog.js';
2
+
3
+ export const LOCK_CATEGORIES = Object.keys(categories);
4
+
5
+ export function isFileBased(category) {
6
+ return categories[category]?.itemLayout === 'file';
7
+ }
8
+
9
+ export function isCopyable(category) {
10
+ const config = categories[category];
11
+ return Boolean(config?.itemLayout) && config.copyItems !== false;
12
+ }
13
+
14
+ export function targetSubdir(sourceDir) {
15
+ return sourceDir.replace(/^framework\//, '');
16
+ }
17
+
18
+ export function itemSourcePath(category, id) {
19
+ const config = categories[category];
20
+ return isFileBased(category) ? `${config.sourceDir}/${id}.md` : `${config.sourceDir}/${id}`;
21
+ }
22
+
23
+ export function itemRelativePath(category, id) {
24
+ const dir = targetSubdir(categories[category].sourceDir);
25
+ return isFileBased(category) ? `${dir}/${id}.md` : `${dir}/${id}`;
26
+ }