@mohammadhprp/system-prompt 0.11.2 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/framework/agents/researcher.md +1 -1
  2. package/framework/mcps/github-mcp/README.md +1 -1
  3. package/framework/mcps/github-mcp/configs/.env.example +1 -1
  4. package/framework/plugins/ponytail/README.md +1 -1
  5. package/framework/plugins/ponytail/capabilities.md +1 -1
  6. package/framework/references/standards/pull-requests.md +1 -1
  7. package/framework/skills/README.md +3 -3
  8. package/framework/skills/diagram-design/SKILL.md +1 -1
  9. package/framework/skills/effective-html/SKILL.md +63 -0
  10. package/framework/skills/effective-html/examples.md +19 -0
  11. package/framework/skills/effective-html/references/charts-and-data.md +32 -0
  12. package/framework/skills/effective-html/references/creative-direction.md +48 -0
  13. package/framework/skills/effective-html/references/design-artifact.md +78 -0
  14. package/framework/skills/effective-html/references/diagrams.md +68 -0
  15. package/framework/skills/effective-html/references/documents-and-presentations.md +28 -0
  16. package/framework/skills/effective-html/references/html-diagram.md +43 -0
  17. package/framework/skills/effective-html/references/html-plan.md +40 -0
  18. package/framework/skills/effective-html/references/html-prototype.md +97 -0
  19. package/framework/skills/effective-html/references/html-wireframe.md +81 -0
  20. package/framework/skills/effective-html/references/html.md +72 -0
  21. package/framework/skills/effective-html/references/interfaces.md +17 -0
  22. package/framework/skills/great-interface/SKILL.md +43 -0
  23. package/framework/skills/great-interface/references/animations.md +205 -0
  24. package/framework/skills/great-interface/references/better-accessibility.md +106 -0
  25. package/framework/skills/great-interface/references/better-colors.md +100 -0
  26. package/framework/skills/great-interface/references/better-interface.md +132 -0
  27. package/framework/skills/great-interface/references/better-layout.md +76 -0
  28. package/framework/skills/great-interface/references/better-typography.md +157 -0
  29. package/framework/skills/great-interface/references/better-ui.md +107 -0
  30. package/framework/skills/great-interface/references/better-writing.md +109 -0
  31. package/framework/skills/great-interface/references/choosing-fonts.md +64 -0
  32. package/framework/skills/great-interface/references/color-formats.md +90 -0
  33. package/framework/skills/great-interface/references/color-usage.md +118 -0
  34. package/framework/skills/great-interface/references/contrast.md +79 -0
  35. package/framework/skills/great-interface/references/css-cheat-sheet.md +65 -0
  36. package/framework/skills/great-interface/references/details-and-accessibility.md +119 -0
  37. package/framework/skills/great-interface/references/enter-exit.md +147 -0
  38. package/framework/skills/great-interface/references/explain-interface.md +126 -0
  39. package/framework/skills/great-interface/references/find-the-effect.md +94 -0
  40. package/framework/skills/great-interface/references/focus-and-keyboard.md +131 -0
  41. package/framework/skills/great-interface/references/forms.md +84 -0
  42. package/framework/skills/great-interface/references/from-an-image.md +55 -0
  43. package/framework/skills/great-interface/references/grouping-and-alignment.md +123 -0
  44. package/framework/skills/great-interface/references/hit-areas.md +94 -0
  45. package/framework/skills/great-interface/references/icon-transitions.md +102 -0
  46. package/framework/skills/great-interface/references/icons.md +110 -0
  47. package/framework/skills/great-interface/references/interface-review.md +148 -0
  48. package/framework/skills/great-interface/references/motion-and-zoom.md +79 -0
  49. package/framework/skills/great-interface/references/no-browser.md +73 -0
  50. package/framework/skills/great-interface/references/palette-generation.md +104 -0
  51. package/framework/skills/great-interface/references/palette-structure.md +76 -0
  52. package/framework/skills/great-interface/references/performance.md +88 -0
  53. package/framework/skills/great-interface/references/picker.md +76 -0
  54. package/framework/skills/great-interface/references/read-the-system.md +178 -0
  55. package/framework/skills/great-interface/references/removed-signals.md +38 -0
  56. package/framework/skills/great-interface/references/review-format.md +46 -0
  57. package/framework/skills/great-interface/references/scope-resolution.md +88 -0
  58. package/framework/skills/great-interface/references/screen-readers.md +101 -0
  59. package/framework/skills/great-interface/references/semantics-and-aria.md +84 -0
  60. package/framework/skills/great-interface/references/spacing-and-adaptivity.md +159 -0
  61. package/framework/skills/great-interface/references/spacing-and-sizing.md +121 -0
  62. package/framework/skills/great-interface/references/surfaces.md +219 -0
  63. package/framework/skills/great-interface/references/token-naming.md +97 -0
  64. package/framework/skills/great-interface/references/variable-fonts-and-opentype.md +105 -0
  65. package/framework/skills/great-interface/references/variant.md +104 -0
  66. package/framework/skills/great-interface/references/wrapping-and-punctuation.md +55 -0
  67. package/framework/skills/taste/SKILL.md +47 -0
  68. package/framework/skills/taste/examples.md +73 -0
  69. package/framework/skills/taste/references/brandkit.md +793 -0
  70. package/framework/skills/taste/references/design-taste-frontend-v1.md +221 -0
  71. package/framework/skills/taste/references/design-taste-frontend.md +1201 -0
  72. package/framework/skills/taste/references/full-output-enforcement.md +44 -0
  73. package/framework/skills/taste/references/gpt-taste.md +69 -0
  74. package/framework/skills/taste/references/high-end-visual-design.md +93 -0
  75. package/framework/skills/taste/references/image-to-code.md +1223 -0
  76. package/framework/skills/taste/references/imagegen-frontend-mobile.md +1460 -0
  77. package/framework/skills/taste/references/imagegen-frontend-web.md +982 -0
  78. package/framework/skills/taste/references/industrial-brutalist-ui.md +87 -0
  79. package/framework/skills/taste/references/minimalist-ui.md +80 -0
  80. package/framework/skills/taste/references/redesign-existing-projects.md +173 -0
  81. package/framework/skills/taste/references/stitch-design-taste-design.md +121 -0
  82. package/framework/skills/taste/references/stitch-design-taste.md +179 -0
  83. package/package.json +1 -1
  84. package/src/agent-configs.js +6 -2
  85. package/src/catalog.js +3 -2
  86. package/src/cli.js +82 -2
  87. package/src/doctor.js +84 -0
  88. package/src/installer.js +337 -154
  89. package/framework/skills/brand-guidelines/LICENSE.txt +0 -202
  90. package/framework/skills/brand-guidelines/SKILL.md +0 -73
  91. package/framework/skills/lavish/SKILL.md +0 -67
  92. package/framework/skills/lavish/examples.md +0 -31
package/src/installer.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { fileURLToPath } from 'node:url';
2
- import { dirname, resolve } from 'node:path';
3
- import { mkdir, copyFile, writeFile, readdir, stat, readFile, rm } from 'node:fs/promises';
2
+ import { dirname, relative, resolve, isAbsolute } from 'node:path';
3
+ import { mkdir, writeFile, readdir, stat, lstat, readFile, rm } from 'node:fs/promises';
4
+ import { createHash } from 'node:crypto';
4
5
 
5
6
  import { categories } from './catalog.js';
6
7
  import { loadMcpConfigs, generateOpenCodeConfig, generateTuiConfig } from './agent-configs.js';
@@ -97,46 +98,134 @@ export async function getPackageVersion() {
97
98
  export async function loadLockFile(absTarget) {
98
99
  try {
99
100
  const content = await readFile(resolve(absTarget, 'system-prompt-lock.json'), 'utf-8');
100
- return JSON.parse(content);
101
- } catch {
102
- return null;
101
+ const lock = JSON.parse(content);
102
+ validateLock(lock);
103
+ return lock;
104
+ } catch (error) {
105
+ if (error.code === 'ENOENT') return null;
106
+ if (error instanceof SyntaxError || error.message?.startsWith('Invalid system-prompt lock')) {
107
+ throw new Error(`${error.message}. Remove or repair system-prompt-lock.json before reinstalling.`);
108
+ }
109
+ throw error;
103
110
  }
104
111
  }
105
112
 
106
- function resolveSource(subpath) {
107
- return resolve(packageRoot, subpath);
113
+ function validateLock(lock) {
114
+ if (!lock || typeof lock !== 'object' || Array.isArray(lock)) {
115
+ throw new Error('Invalid system-prompt lock: expected an object');
116
+ }
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');
119
+ }
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')) {
123
+ throw new Error(`Invalid system-prompt lock: invalid ${category} selection`);
124
+ }
125
+ 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`);
128
+ }
129
+ }
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');
133
+ }
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');
137
+ }
138
+ }
139
+ }
108
140
  }
109
141
 
110
- function targetSubdir(sourceDir) {
111
- return sourceDir.replace(/^framework\//, '');
142
+ function hash(content) {
143
+ return createHash('sha256').update(content).digest('hex');
112
144
  }
113
145
 
114
- async function copyDir(src, dest) {
115
- await mkdir(dest, { recursive: true });
146
+ function isInside(parent, child) {
147
+ const relativePath = relative(parent, child);
148
+ return relativePath === '' || (!relativePath.startsWith('../') && relativePath !== '..' && !isAbsolute(relativePath));
149
+ }
150
+
151
+ async function canWrite(destFile, relativePath, oldLock, force) {
152
+ if (force) return true;
153
+ try {
154
+ const existing = await readFile(destFile);
155
+ const previousHash = oldLock?.managedFiles?.[relativePath];
156
+ return Boolean(previousHash && previousHash === hash(existing));
157
+ } catch (error) {
158
+ if (error.code === 'ENOENT') return true;
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ async function writeManagedFile(destFile, content, relativePath, options) {
164
+ await assertSafeDestination(options.targetDir, destFile);
165
+ if (!options.allowExistingMerge && !(await canWrite(destFile, relativePath, options.oldLock, options.force))) {
166
+ console.warn(` ⚠ Preserving existing file: ${relativePath}`);
167
+ return false;
168
+ }
169
+ if (!options.dryRun) await mkdir(dirname(destFile), { recursive: true });
170
+ if (!options.dryRun) await writeFile(destFile, content);
171
+ options.managedFiles[relativePath] = hash(content);
172
+ return true;
173
+ }
174
+
175
+ async function copyDir(src, dest, relativeDir, options) {
176
+ if (!options.dryRun) await mkdir(dest, { recursive: true });
116
177
  const entries = await readdir(src, { withFileTypes: true });
117
178
 
118
179
  for (const entry of entries) {
119
180
  if (entry.name === '.DS_Store') continue;
120
181
  const srcPath = resolve(src, entry.name);
121
182
  const destPath = resolve(dest, entry.name);
183
+ const relativePath = `${relativeDir}/${entry.name}`;
122
184
 
123
185
  if (entry.isDirectory()) {
124
- await copyDir(srcPath, destPath);
186
+ await copyDir(srcPath, destPath, relativePath, options);
125
187
  } else if (entry.isFile()) {
126
- await mkdir(dirname(destPath), { recursive: true });
127
- await copyFile(srcPath, destPath);
188
+ const content = await readFile(srcPath);
189
+ await writeManagedFile(destPath, content, relativePath, options);
128
190
  }
129
191
  }
130
192
  }
131
193
 
132
- async function isRemoved(category, id) {
133
- const catConfig = categories[category];
134
- if (!catConfig) return false;
135
- const item = catConfig.items.find(i => i.id === id);
136
- return item?.removed === true;
194
+ function sourceMissing(error) {
195
+ return error?.code === 'ENOENT';
196
+ }
197
+
198
+ function assertSafePath(targetDir, path) {
199
+ if (!isInside(targetDir, path)) {
200
+ throw new Error(`Refusing to access path outside installation directory: ${path}`);
201
+ }
202
+ }
203
+
204
+ async function assertSafeDestination(targetDir, path) {
205
+ assertSafePath(targetDir, path);
206
+ try {
207
+ if ((await lstat(targetDir)).isSymbolicLink()) {
208
+ throw new Error(`Refusing to install through symlink target: ${targetDir}`);
209
+ }
210
+ } catch (error) {
211
+ if (error.code !== 'ENOENT') throw error;
212
+ }
213
+ const relativePath = relative(targetDir, path);
214
+ let current = targetDir;
215
+ for (const part of relativePath.split('/').filter(Boolean)) {
216
+ current = resolve(current, part);
217
+ try {
218
+ if ((await lstat(current)).isSymbolicLink()) {
219
+ throw new Error(`Refusing to access symlink inside installation directory: ${current}`);
220
+ }
221
+ } catch (error) {
222
+ if (error.code === 'ENOENT') break;
223
+ throw error;
224
+ }
225
+ }
137
226
  }
138
227
 
139
- async function copySelectedDirs(targetDir, category, selectedIds) {
228
+ async function copySelectedDirs(targetDir, category, selectedIds, options) {
140
229
  const catConfig = categories[category];
141
230
  if (!catConfig || !selectedIds?.length) return;
142
231
 
@@ -148,81 +237,49 @@ async function copySelectedDirs(targetDir, category, selectedIds) {
148
237
 
149
238
  const srcPath = resolveSource(`${catConfig.sourceDir}/${id}`);
150
239
  const destPath = resolve(destParent, id);
240
+ await assertSafeDestination(targetDir, destPath);
151
241
 
152
242
  try {
153
243
  await stat(srcPath);
154
- await copyDir(srcPath, destPath);
155
- } catch {
156
- console.warn(` ⚠ Source not found: ${catConfig.sourceDir}/${id}`);
244
+ await copyDir(srcPath, destPath, `${relativeDir}/${id}`, options);
245
+ } catch (error) {
246
+ if (sourceMissing(error)) {
247
+ console.warn(` ⚠ Source not found: ${catConfig.sourceDir}/${id}`);
248
+ continue;
249
+ }
250
+ throw error;
157
251
  }
158
252
  }
159
253
  }
160
254
 
161
- async function copySelectedFiles(targetDir, category, selectedIds, skipIfExists = false) {
255
+ async function copySelectedFiles(targetDir, category, selectedIds, options) {
162
256
  const catConfig = categories[category];
163
257
  if (!catConfig || !selectedIds?.length) return;
164
258
 
165
259
  const relativeDir = targetSubdir(catConfig.sourceDir);
166
260
  const destParent = resolve(targetDir, relativeDir);
167
- await mkdir(destParent, { recursive: true });
261
+ if (!options.dryRun) await mkdir(destParent, { recursive: true });
168
262
 
169
263
  for (const id of selectedIds) {
170
264
  if (await isRemoved(category, id)) continue;
171
265
 
172
266
  const srcFile = resolveSource(`${catConfig.sourceDir}/${id}.md`);
173
267
  const destFile = resolve(destParent, `${id}.md`);
268
+ await assertSafeDestination(targetDir, destFile);
174
269
  try {
175
- await stat(srcFile);
176
- if (skipIfExists) {
177
- try {
178
- await stat(destFile);
179
- continue;
180
- } catch {
181
- // dest doesn't exist — proceed to copy
182
- }
183
- }
184
- await copyFile(srcFile, destFile);
185
- } catch {
186
- console.warn(` ⚠ Source not found: ${catConfig.sourceDir}/${id}.md`);
187
- }
188
- }
189
- }
190
-
191
- function parseEnv(content) {
192
- const vars = new Map();
193
- for (const line of content.split('\n')) {
194
- const trimmed = line.trim();
195
- if (!trimmed || trimmed.startsWith('#')) continue;
196
- const eqIndex = trimmed.indexOf('=');
197
- if (eqIndex === -1) {
198
- vars.set(trimmed, '');
199
- } else {
200
- const key = trimmed.slice(0, eqIndex).trim();
201
- const value = trimmed.slice(eqIndex + 1).trim();
202
- if (key) vars.set(key, value);
203
- }
204
- }
205
- return vars;
206
- }
207
-
208
- async function collectMcpEnvExamples(mcpIds) {
209
- const combined = new Map();
210
- for (const id of mcpIds) {
211
- try {
212
- const examplePath = resolveSource(`framework/mcps/${id}/configs/.env.example`);
213
- const content = await readFile(examplePath, 'utf-8');
214
- const parsed = parseEnv(content);
215
- for (const [key, value] of parsed) {
216
- if (!combined.has(key)) combined.set(key, value);
270
+ const content = await readFile(srcFile);
271
+ await writeManagedFile(destFile, content, `${relativeDir}/${id}.md`, options);
272
+ } catch (error) {
273
+ if (sourceMissing(error)) {
274
+ console.warn(` ⚠ Source not found: ${catConfig.sourceDir}/${id}.md`);
275
+ continue;
217
276
  }
218
- } catch {
219
- // Silently skip MCPs without .env.example
277
+ throw error;
220
278
  }
221
279
  }
222
- return combined;
223
280
  }
224
281
 
225
- async function deleteSelectedItems(absTarget, category, ids) {
282
+ async function deleteSelectedItems(absTarget, category, ids, oldLock, force, dryRun) {
226
283
  const catConfig = categories[category];
227
284
  if (!catConfig || !ids?.length) return;
228
285
 
@@ -230,139 +287,265 @@ async function deleteSelectedItems(absTarget, category, ids) {
230
287
  if (!FILE_BASED.has(category) && category !== 'skills' && category !== 'styles') return;
231
288
 
232
289
  const relativeDir = targetSubdir(catConfig.sourceDir);
233
- const destParent = resolve(absTarget, relativeDir);
234
-
235
290
  for (const id of ids) {
236
- const destPath = FILE_BASED.has(category)
237
- ? resolve(destParent, `${id}.md`)
238
- : resolve(destParent, id);
239
- try {
240
- await rm(destPath, { recursive: true, force: true });
241
- } catch {
242
- // File may not exist — ignore
291
+ const relativePath = FILE_BASED.has(category)
292
+ ? `${relativeDir}/${id}.md`
293
+ : `${relativeDir}/${id}`;
294
+ const destPath = resolve(absTarget, relativePath);
295
+ await assertSafeDestination(absTarget, destPath);
296
+ if (!force && oldLock) {
297
+ const managedEntries = Object.entries(oldLock.managedFiles || {})
298
+ .filter(([path]) => path === relativePath || path.startsWith(`${relativePath}/`));
299
+ if (managedEntries.length === 0) {
300
+ console.warn(` ⚠ Preserving unmanaged item: ${relativePath}`);
301
+ continue;
302
+ }
303
+ let modified = false;
304
+ for (const [path, checksum] of managedEntries) {
305
+ await assertSafeDestination(absTarget, resolve(absTarget, path));
306
+ try {
307
+ if (hash(await readFile(resolve(absTarget, path))) !== checksum) modified = true;
308
+ } catch (error) {
309
+ if (!sourceMissing(error)) throw error;
310
+ }
311
+ }
312
+ if (modified) {
313
+ console.warn(` ⚠ Preserving modified item: ${relativePath}`);
314
+ continue;
315
+ }
316
+ if (!FILE_BASED.has(category)) {
317
+ if (!dryRun) {
318
+ for (const [path] of managedEntries) await rm(resolve(absTarget, path), { force: true });
319
+ }
320
+ continue;
321
+ }
243
322
  }
323
+ if (!dryRun) await rm(destPath, { recursive: true, force: true });
244
324
  }
245
325
  }
246
326
 
247
- async function writeMergedEnv(absTarget, examples) {
248
- if (examples.size === 0) return;
249
-
250
- const envPath = resolve(absTarget, '.env');
251
- const existing = new Map();
252
-
327
+ async function mergeJsonFile(destFile, generated, relativePath, options, previousGenerated = generated) {
328
+ let existing = {};
253
329
  try {
254
- const existingContent = await readFile(envPath, 'utf-8');
255
- const parsed = parseEnv(existingContent);
256
- for (const [key, value] of parsed) existing.set(key, value);
257
- } catch {
258
- // File doesn't exist yet
330
+ existing = JSON.parse(await readFile(destFile, 'utf-8'));
331
+ } catch (error) {
332
+ if (error.code !== 'ENOENT') {
333
+ if (error instanceof SyntaxError) throw new Error(`Cannot merge invalid JSON file: ${relativePath}`);
334
+ throw error;
335
+ }
259
336
  }
260
-
261
- const merged = new Map(existing);
262
- for (const [key, value] of examples) {
263
- if (!merged.has(key)) merged.set(key, value);
337
+ const merged = { ...existing, ...generated };
338
+ if (Array.isArray(existing.instructions) && Array.isArray(generated.instructions)) {
339
+ const previous = new Set(previousGenerated.instructions || []);
340
+ merged.instructions = [...new Set([
341
+ ...existing.instructions.filter(item => !previous.has(item)),
342
+ ...generated.instructions,
343
+ ])];
264
344
  }
265
-
266
- if (merged.size === 0) return;
267
-
268
- const lines = [];
269
- for (const [key, value] of merged) {
270
- lines.push(`${key}=${value}`);
345
+ if (Array.isArray(existing.plugin)) {
346
+ const previous = new Set(previousGenerated.plugin || []);
347
+ const plugins = [...new Set([
348
+ ...existing.plugin.filter(item => !previous.has(item)),
349
+ ...(generated.plugin || []),
350
+ ])];
351
+ if (plugins.length) merged.plugin = plugins;
352
+ else delete merged.plugin;
353
+ }
354
+ for (const key of ['mcp', 'references']) {
355
+ if (existing[key] && typeof existing[key] === 'object') {
356
+ const previous = previousGenerated[key] || {};
357
+ const preserved = Object.fromEntries(Object.entries(existing[key]).filter(([name]) => !(name in previous)));
358
+ const values = { ...preserved, ...(generated[key] || {}) };
359
+ if (Object.keys(values).length) merged[key] = values;
360
+ else delete merged[key];
361
+ }
271
362
  }
272
- lines.push('');
363
+ return writeManagedFile(destFile, Buffer.from(JSON.stringify(merged, null, 4)), relativePath, {
364
+ ...options,
365
+ allowExistingMerge: !options.oldLock,
366
+ });
367
+ }
273
368
 
274
- await writeFile(envPath, lines.join('\n'));
369
+ async function mergeGitignore(destFile, options) {
370
+ let existing = '';
371
+ try {
372
+ existing = await readFile(destFile, 'utf-8');
373
+ } catch (error) {
374
+ if (error.code !== 'ENOENT') throw error;
375
+ }
376
+ const lines = new Set(existing.split('\n').filter(Boolean));
377
+ for (const line of OPENCODE_GITIGNORE.split('\n').filter(Boolean)) lines.add(line);
378
+ return writeManagedFile(destFile, Buffer.from(`${[...lines].join('\n')}\n`), '.gitignore', {
379
+ ...options,
380
+ allowExistingMerge: !options.oldLock,
381
+ });
275
382
  }
276
383
 
277
- function generateAgentsMd({ selections }) {
278
- return AGENTS_MD;
384
+ export function validateSelections(selections) {
385
+ if (!selections || typeof selections !== 'object' || Array.isArray(selections)) {
386
+ throw new Error('Selections must be an object');
387
+ }
388
+ for (const [category, ids] of Object.entries(selections)) {
389
+ const config = categories[category];
390
+ if (!config || !Array.isArray(ids)) throw new Error(`Unknown or invalid category: ${category}`);
391
+ const knownIds = new Set(config.items.map(item => item.id));
392
+ if (ids.some(id => !knownIds.has(id))) throw new Error(`Unknown ${category} item selected`);
393
+ }
394
+ return selections;
279
395
  }
280
396
 
281
- export async function install({ targetDir, agentType, selections, includeAgentsMd = true, writeAgentsMd, oldSelections }) {
397
+ export async function install({ targetDir, agentType, selections, includeAgentsMd = true, writeAgentsMd, oldSelections, oldLock, force = false, dryRun = false }) {
398
+ validateSelections(selections);
399
+ if (oldLock) validateLock(oldLock);
282
400
  writeAgentsMd = writeAgentsMd ?? includeAgentsMd;
283
401
  const absTarget = resolve(process.cwd(), targetDir);
284
- await mkdir(absTarget, { recursive: true });
402
+ if (!dryRun) await mkdir(absTarget, { recursive: true });
403
+ const options = {
404
+ targetDir: absTarget,
405
+ oldLock,
406
+ force,
407
+ dryRun,
408
+ managedFiles: { ...(oldLock?.managedFiles || {}) },
409
+ };
285
410
 
286
- // Delete items that are no longer selected
411
+ // Delete items that are no longer selected.
287
412
  if (oldSelections) {
288
413
  for (const cat of Object.keys(oldSelections)) {
289
414
  const oldIds = new Set(oldSelections[cat] || []);
290
415
  const newIds = new Set(selections[cat] || []);
291
416
  const removedIds = [...oldIds].filter(id => !newIds.has(id));
292
- if (removedIds.length > 0) {
293
- await deleteSelectedItems(absTarget, cat, removedIds);
294
- }
417
+ await deleteSelectedItems(absTarget, cat, removedIds, oldLock, force, dryRun);
295
418
  }
296
419
  }
297
420
 
298
421
  const tasks = [];
299
-
300
- if (selections.skills?.length) {
301
- tasks.push(copySelectedDirs(absTarget, 'skills', selections.skills));
302
- }
303
- if (selections.agents?.length) {
304
- tasks.push(copySelectedFiles(absTarget, 'agents', selections.agents));
305
- }
306
- if (selections.commands?.length) {
307
- tasks.push(copySelectedFiles(absTarget, 'commands', selections.commands));
308
- }
309
- if (selections.styles?.length) {
310
- tasks.push(copySelectedDirs(absTarget, 'styles', selections.styles));
311
- }
312
- if (selections.modes?.length) {
313
- tasks.push(copySelectedFiles(absTarget, 'modes', selections.modes));
314
- }
315
- if (selections.memory?.length) {
316
- tasks.push(copySelectedFiles(absTarget, 'memory', selections.memory, true));
422
+ for (const category of ['skills', 'styles']) {
423
+ if (selections[category]?.length) tasks.push(copySelectedDirs(absTarget, category, selections[category], options));
317
424
  }
318
- if (selections.standards?.length) {
319
- tasks.push(copySelectedFiles(absTarget, 'standards', selections.standards));
425
+ for (const category of ['agents', 'commands', 'modes', 'memory', 'standards', 'templates']) {
426
+ if (selections[category]?.length) tasks.push(copySelectedFiles(absTarget, category, selections[category], options));
320
427
  }
321
- if (selections.templates?.length) {
322
- tasks.push(copySelectedFiles(absTarget, 'templates', selections.templates));
323
- }
324
-
325
428
  await Promise.all(tasks);
326
429
 
327
430
  if (writeAgentsMd) {
328
- const agentsContent = generateAgentsMd({ selections });
329
- await writeFile(resolve(absTarget, 'AGENTS.md'), agentsContent);
431
+ await writeManagedFile(resolve(absTarget, 'AGENTS.md'), Buffer.from(AGENTS_MD), 'AGENTS.md', options);
330
432
  }
331
433
 
332
434
  if (agentType === 'opencode') {
333
435
  let mcpEntries = {};
334
- if (selections.mcps?.length) {
335
- mcpEntries = await loadMcpConfigs(selections.mcps);
336
- }
337
- const configJson = generateOpenCodeConfig({
338
- selections,
339
- mcpEntries,
340
- includeAgentsMd: includeAgentsMd,
341
- });
342
- if (configJson) {
343
- await writeFile(resolve(absTarget, 'opencode.json'), configJson);
436
+ if (selections.mcps?.length) mcpEntries = await loadMcpConfigs(selections.mcps);
437
+ let previousOpenCodeConfig;
438
+ let previousTuiConfig;
439
+ if (oldLock) {
440
+ let previousMcpEntries = {};
441
+ if (oldLock.selections.mcps?.length) previousMcpEntries = await loadMcpConfigs(oldLock.selections.mcps);
442
+ previousOpenCodeConfig = JSON.parse(generateOpenCodeConfig({
443
+ selections: oldLock.selections,
444
+ mcpEntries: previousMcpEntries,
445
+ includeAgentsMd: oldLock.includeAgentsMd ?? true,
446
+ }));
447
+ previousTuiConfig = JSON.parse(generateTuiConfig({ selections: oldLock.selections }));
344
448
  }
345
- await writeFile(resolve(absTarget, 'tui.json'), generateTuiConfig({ selections }));
346
- await writeFile(resolve(absTarget, '.gitignore'), OPENCODE_GITIGNORE);
449
+ const configJson = generateOpenCodeConfig({ selections, mcpEntries, includeAgentsMd });
450
+ 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);
452
+ await mergeGitignore(resolve(absTarget, '.gitignore'), options);
347
453
  }
348
454
 
349
455
  if (selections.mcps?.length) {
350
456
  const envExamples = await collectMcpEnvExamples(selections.mcps);
351
- await writeMergedEnv(absTarget, envExamples);
457
+ await writeMergedEnv(absTarget, envExamples, options);
352
458
  }
353
459
 
460
+ if (dryRun) return absTarget;
354
461
  const version = await getPackageVersion();
355
462
  const lockData = {
356
463
  version,
357
464
  agentType,
358
465
  targetDir,
359
466
  installedAt: new Date().toISOString(),
360
- selections: Object.fromEntries(
361
- Object.entries(selections).map(([k, v]) => [k, [...v]])
362
- ),
467
+ selections: Object.fromEntries(Object.entries(selections).map(([k, v]) => [k, [...v]])),
363
468
  includeAgentsMd,
469
+ managedFiles: options.managedFiles,
364
470
  };
365
471
  await writeFile(resolve(absTarget, 'system-prompt-lock.json'), JSON.stringify(lockData, null, 2));
366
-
367
472
  return absTarget;
368
473
  }
474
+
475
+ function resolveSource(subpath) {
476
+ return resolve(packageRoot, subpath);
477
+ }
478
+
479
+ function targetSubdir(sourceDir) {
480
+ return sourceDir.replace(/^framework\//, '');
481
+ }
482
+
483
+ async function isRemoved(category, id) {
484
+ const catConfig = categories[category];
485
+ if (!catConfig) return false;
486
+ const item = catConfig.items.find(i => i.id === id);
487
+ return item?.removed === true;
488
+ }
489
+
490
+ function parseEnv(content) {
491
+ const vars = new Map();
492
+ for (const line of content.split('\n')) {
493
+ const trimmed = line.trim();
494
+ if (!trimmed || trimmed.startsWith('#')) continue;
495
+ const eqIndex = trimmed.indexOf('=');
496
+ if (eqIndex === -1) {
497
+ vars.set(trimmed, '');
498
+ } else {
499
+ const key = trimmed.slice(0, eqIndex).trim();
500
+ const value = trimmed.slice(eqIndex + 1).trim();
501
+ if (key) vars.set(key, value);
502
+ }
503
+ }
504
+ return vars;
505
+ }
506
+
507
+ async function collectMcpEnvExamples(mcpIds) {
508
+ const combined = new Map();
509
+ for (const id of mcpIds) {
510
+ try {
511
+ const examplePath = resolveSource(`framework/mcps/${id}/configs/.env.example`);
512
+ const content = await readFile(examplePath, 'utf-8');
513
+ const parsed = parseEnv(content);
514
+ for (const [key, value] of parsed) {
515
+ if (!combined.has(key)) combined.set(key, value);
516
+ }
517
+ } catch (error) {
518
+ if (error.code !== 'ENOENT') throw error;
519
+ // MCPs may not provide an environment example.
520
+ }
521
+ }
522
+ return combined;
523
+ }
524
+
525
+ async function writeMergedEnv(absTarget, examples, options) {
526
+ if (examples.size === 0) return;
527
+
528
+ const envPath = resolve(absTarget, '.env');
529
+ await assertSafeDestination(options.targetDir, envPath);
530
+ const existing = new Map();
531
+ let existingContent = '';
532
+
533
+ try {
534
+ existingContent = await readFile(envPath, 'utf-8');
535
+ const parsed = parseEnv(existingContent);
536
+ for (const [key, value] of parsed) existing.set(key, value);
537
+ } catch (error) {
538
+ if (error.code !== 'ENOENT') throw error;
539
+ }
540
+
541
+ const additions = [];
542
+ for (const [key, value] of examples) {
543
+ if (!existing.has(key)) additions.push(`${key}=${value}`);
544
+ }
545
+
546
+ if (additions.length === 0) return;
547
+ const separator = existingContent && !existingContent.endsWith('\n') ? '\n' : '';
548
+ const content = Buffer.from(`${existingContent}${separator}${additions.join('\n')}\n`);
549
+ if (!options.dryRun) await writeFile(envPath, content);
550
+ options.managedFiles['.env'] = hash(content);
551
+ }