specline 1.4.0 → 2.0.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 (75) hide show
  1. package/README.md +132 -125
  2. package/adapters/claude/deploy.json +12 -0
  3. package/adapters/claude/hooks/hooks.json +12 -0
  4. package/adapters/claude/hooks.json +12 -0
  5. package/adapters/claude/orchestration.md +17 -0
  6. package/adapters/codex/agent.toml.hbs +7 -0
  7. package/adapters/codex/deploy.json +12 -0
  8. package/adapters/codex/hooks.json +12 -0
  9. package/adapters/codex/orchestration.md +18 -0
  10. package/adapters/cursor/deploy.json +12 -0
  11. package/adapters/cursor/hooks.json +9 -0
  12. package/adapters/cursor/orchestration.md +17 -0
  13. package/adapters/opencode/deploy.json +12 -0
  14. package/adapters/opencode/orchestration.md +18 -0
  15. package/adapters/opencode/plugin.js +10 -0
  16. package/cli.mjs +161 -558
  17. package/core/agents/specline-backend-dev.yaml +45 -0
  18. package/core/agents/specline-code-reviewer.yaml +67 -0
  19. package/core/agents/specline-config-dev.yaml +50 -0
  20. package/core/agents/specline-config-reviewer.yaml +70 -0
  21. package/core/agents/specline-explore-assistant.yaml +79 -0
  22. package/core/agents/specline-frontend-dev.yaml +45 -0
  23. package/core/agents/specline-spec-creator.yaml +58 -0
  24. package/core/agents/specline-spec-reviewer.yaml +58 -0
  25. package/core/agents/specline-test-runner.yaml +62 -0
  26. package/core/agents/specline-test-writer.yaml +67 -0
  27. package/core/bootstrap/using-specline.md +14 -0
  28. package/core/gates/pipeline-gate-checks/a1-covers-ref.sh +125 -0
  29. package/core/gates/pipeline-gate-checks/a2-a3-reverse.sh +171 -0
  30. package/core/gates/pipeline-gate-checks/c1-exception.sh +71 -0
  31. package/core/gates/pipeline-gate-checks/c2-vague.sh +60 -0
  32. package/core/gates/pipeline-gate-checks/common.sh +68 -0
  33. package/core/gates/pipeline-gate-checks/d1-cycle.sh +149 -0
  34. package/core/gates/pipeline-gate-checks/d3-type-file.sh +260 -0
  35. package/core/gates/pipeline-gate.sh +1456 -0
  36. package/core/hooks/session-start.sh +259 -0
  37. package/core/skills/specline-apply-change/SKILL.md +197 -0
  38. package/core/skills/specline-archive-change/SKILL.md +173 -0
  39. package/core/skills/specline-explore/SKILL.md +504 -0
  40. package/core/skills/specline-knowledge/SKILL.md +539 -0
  41. package/core/skills/specline-pipeline/SKILL.md +604 -0
  42. package/core/skills/specline-pipeline/references/error-recovery-details.md +49 -0
  43. package/core/skills/specline-pipeline/references/event-log-spec.md +59 -0
  44. package/core/skills/specline-pipeline/references/pipeline-state-schema.md +87 -0
  45. package/core/skills/specline-pipeline/templates/subagent-prompts.md +397 -0
  46. package/core/skills/specline-propose/SKILL.md +186 -0
  47. package/core/skills/specline-quickfix/SKILL.md +289 -0
  48. package/core/templates/AGENTS.md.hbs +5 -0
  49. package/core/templates/specline/config.yaml +15 -0
  50. package/lib/deploy-claude.mjs +80 -0
  51. package/lib/deploy-codex.mjs +77 -0
  52. package/lib/deploy-opencode.mjs +93 -0
  53. package/lib/deploy.mjs +668 -0
  54. package/lib/gate.mjs +103 -0
  55. package/lib/hash.mjs +13 -0
  56. package/lib/hook.mjs +105 -0
  57. package/lib/init.mjs +122 -0
  58. package/lib/lock.mjs +99 -0
  59. package/lib/merge.mjs +188 -0
  60. package/lib/paths.mjs +40 -0
  61. package/lib/platforms.mjs +74 -0
  62. package/lib/render-agents.mjs +88 -0
  63. package/lib/render.mjs +126 -0
  64. package/lib/sync.mjs +253 -0
  65. package/lib/tty-select.mjs +89 -0
  66. package/package.json +4 -1
  67. package/templates/.cursor/README.md +18 -0
  68. package/templates/.cursor/agents/specline-code-reviewer.md +18 -2
  69. package/templates/.cursor/agents/specline-spec-creator.md +51 -2
  70. package/templates/.cursor/agents/specline-test-runner.md +10 -1
  71. package/templates/.cursor/agents/specline-test-writer.md +58 -7
  72. package/templates/.cursor/hooks/specline-pipeline-gate-checks/a2-a3-reverse.sh +1 -1
  73. package/templates/.cursor/hooks/specline-pipeline-gate.sh +118 -0
  74. package/templates/.cursor/skills/specline-pipeline/SKILL.md +10 -4
  75. package/templates/.cursor/skills/specline-propose/SKILL.md +3 -3
package/lib/deploy.mjs ADDED
@@ -0,0 +1,668 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ copyFileSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ chmodSync,
9
+ } from 'fs';
10
+ import { join, dirname } from 'path';
11
+ import {
12
+ PACKAGE_ROOT,
13
+ TEMPLATES_DIR,
14
+ deployManifestPath,
15
+ PLATFORMS,
16
+ projectPlatformsPath,
17
+ } from './paths.mjs';
18
+ import { renderCursorAgent, renderClaudeAgent, renderCodexAgent } from './render-agents.mjs';
19
+ import { renderSkillForPlatform } from './render.mjs';
20
+ import { mergeOpencodeJson } from './merge.mjs';
21
+ import { computeFileHash, sha256 } from './hash.mjs';
22
+ import { readLockFile } from './lock.mjs';
23
+
24
+ /**
25
+ * @param {string} dir
26
+ * @param {string} [relBase]
27
+ */
28
+ function walkDir(dir, relBase = '') {
29
+ /** @type {{ rel: string, abs: string }[]} */
30
+ const out = [];
31
+ if (!existsSync(dir)) return out;
32
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
33
+ const abs = join(dir, entry.name);
34
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
35
+ if (entry.isDirectory()) {
36
+ out.push(...walkDir(abs, rel));
37
+ } else {
38
+ out.push({ rel, abs });
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** @type {Record<string, string[]>} */
45
+ export const PLATFORM_PATH_PREFIXES = {
46
+ cursor: ['.cursor/'],
47
+ claude: ['.claude/'],
48
+ codex: ['.codex/'],
49
+ opencode: ['.opencode/', 'specline/opencode-plugin/', 'opencode.json'],
50
+ };
51
+
52
+ const SHARED_PATH_PREFIXES = ['specline/bin/', 'specline/config.yaml'];
53
+
54
+ /**
55
+ * @param {string} relPath
56
+ * @param {string} platform
57
+ */
58
+ export function pathBelongsToPlatform(relPath, platform) {
59
+ const prefixes = PLATFORM_PATH_PREFIXES[platform] || [];
60
+ return prefixes.some((p) => {
61
+ const bare = p.replace(/\/$/, '');
62
+ return relPath === bare || relPath.startsWith(p);
63
+ });
64
+ }
65
+
66
+ /**
67
+ * @param {Map<string, unknown>} manifest
68
+ * @param {string[]} platforms
69
+ */
70
+ export function filterManifestByPlatforms(manifest, platforms) {
71
+ const filtered = new Map();
72
+ for (const [rel, entry] of manifest) {
73
+ const isShared = SHARED_PATH_PREFIXES.some(
74
+ (p) => rel === p || rel.startsWith(p),
75
+ );
76
+ if (isShared) {
77
+ filtered.set(rel, entry);
78
+ continue;
79
+ }
80
+ if (platforms.some((pl) => pathBelongsToPlatform(rel, pl))) {
81
+ filtered.set(rel, entry);
82
+ }
83
+ }
84
+ return filtered;
85
+ }
86
+
87
+ /**
88
+ * @param {string} packageRoot
89
+ * @param {boolean} withShellGuard
90
+ */
91
+ export function buildCursorHooksJsonContent(packageRoot, withShellGuard = false) {
92
+ const basePath = join(packageRoot, 'adapters', 'cursor', 'hooks.json');
93
+ const base = JSON.parse(readFileSync(basePath, 'utf-8'));
94
+ if (withShellGuard) {
95
+ base.hooks = base.hooks || {};
96
+ base.hooks.beforeShellExecution = [
97
+ {
98
+ command: '.cursor/hooks/specline-shell-guard.sh',
99
+ matcher: 'rm -rf|curl.*\\|.*bash|wget.*\\|.*sh|^sudo ',
100
+ failClosed: true,
101
+ },
102
+ ];
103
+ }
104
+ return JSON.stringify(base, null, 2) + '\n';
105
+ }
106
+
107
+ export function buildClaudeSettingsTemplate(packageRoot = PACKAGE_ROOT) {
108
+ const hooksPath = join(packageRoot, 'adapters', 'claude', 'hooks', 'hooks.json');
109
+ return readFileSync(hooksPath, 'utf-8');
110
+ }
111
+
112
+ /**
113
+ * @param {string} [packageRoot]
114
+ */
115
+ export function getSharedSpeclineManifest(packageRoot = PACKAGE_ROOT) {
116
+ /** @type {Map<string, { source?: string, transform?: string, content?: string }>} */
117
+ const manifest = new Map();
118
+
119
+ manifest.set('specline/bin/gate.sh', {
120
+ source: join(packageRoot, 'core', 'gates', 'pipeline-gate.sh'),
121
+ });
122
+
123
+ for (const { rel, abs } of walkDir(
124
+ join(packageRoot, 'core', 'gates', 'pipeline-gate-checks'),
125
+ 'specline/bin/gate-checks',
126
+ )) {
127
+ manifest.set(rel, { source: abs });
128
+ }
129
+
130
+ manifest.set('specline/config.yaml', {
131
+ source: join(packageRoot, 'core', 'templates', 'specline', 'config.yaml'),
132
+ });
133
+
134
+ return manifest;
135
+ }
136
+
137
+ /**
138
+ * @param {string} [packageRoot]
139
+ * @param {{ withShellGuard?: boolean, legacyPhase0?: boolean }} [options]
140
+ */
141
+ export function getCursorPlatformManifest(packageRoot = PACKAGE_ROOT, options = {}) {
142
+ const { withShellGuard = false, legacyPhase0 = false } = options;
143
+ if (legacyPhase0) return getCursorUpstreamManifestPhase0(packageRoot);
144
+
145
+ /** @type {Map<string, { source?: string, transform?: string, content?: string, platform?: string }>} */
146
+ const manifest = new Map();
147
+
148
+ for (const { rel, abs } of walkDir(join(packageRoot, 'core', 'skills'), '.cursor/skills')) {
149
+ if (rel.endsWith('.md')) {
150
+ manifest.set(rel, { source: abs, transform: 'skill-render', platform: 'cursor' });
151
+ } else {
152
+ manifest.set(rel, { source: abs });
153
+ }
154
+ }
155
+
156
+ const bootstrap = join(packageRoot, 'core', 'bootstrap', 'using-specline.md');
157
+ if (existsSync(bootstrap)) {
158
+ manifest.set('.cursor/skills/using-specline/SKILL.md', { source: bootstrap });
159
+ }
160
+
161
+ const agentsDir = join(packageRoot, 'core', 'agents');
162
+ if (existsSync(agentsDir)) {
163
+ for (const file of readdirSync(agentsDir).filter((f) => f.endsWith('.yaml'))) {
164
+ const rel = `.cursor/agents/${file.replace('.yaml', '.md')}`;
165
+ manifest.set(rel, { source: join(agentsDir, file), transform: 'cursor-agent-md' });
166
+ }
167
+ }
168
+
169
+ manifest.set('.cursor/hooks/specline-session-start.sh', {
170
+ source: join(packageRoot, 'core', 'hooks', 'session-start.sh'),
171
+ });
172
+
173
+ if (withShellGuard) {
174
+ manifest.set('.cursor/hooks/specline-shell-guard.sh', {
175
+ source: join(packageRoot, 'core', 'hooks', 'shell-guard.sh'),
176
+ });
177
+ }
178
+
179
+ manifest.set('.cursor/hooks.json', {
180
+ transform: 'cursor-hooks-json',
181
+ content: buildCursorHooksJsonContent(packageRoot, withShellGuard),
182
+ });
183
+
184
+ const cursorReadme = join(TEMPLATES_DIR, '.cursor', 'README.md');
185
+ if (existsSync(cursorReadme)) {
186
+ manifest.set('.cursor/README.md', { source: cursorReadme });
187
+ }
188
+
189
+ return manifest;
190
+ }
191
+
192
+ /**
193
+ * @param {string} [packageRoot]
194
+ */
195
+ export function getClaudeUpstreamManifest(packageRoot = PACKAGE_ROOT) {
196
+ /** @type {Map<string, { source?: string, transform?: string, content?: string, platform?: string }>} */
197
+ const manifest = new Map();
198
+
199
+ for (const { rel, abs } of walkDir(join(packageRoot, 'core', 'skills'), '.claude/skills')) {
200
+ if (rel.endsWith('.md')) {
201
+ manifest.set(rel, { source: abs, transform: 'skill-render', platform: 'claude' });
202
+ } else {
203
+ manifest.set(rel, { source: abs });
204
+ }
205
+ }
206
+
207
+ const bootstrap = join(packageRoot, 'core', 'bootstrap', 'using-specline.md');
208
+ if (existsSync(bootstrap)) {
209
+ manifest.set('.claude/skills/using-specline/SKILL.md', { source: bootstrap });
210
+ }
211
+
212
+ const agentsDir = join(packageRoot, 'core', 'agents');
213
+ if (existsSync(agentsDir)) {
214
+ for (const file of readdirSync(agentsDir).filter((f) => f.endsWith('.yaml'))) {
215
+ manifest.set(`.claude/agents/${file.replace('.yaml', '.md')}`, {
216
+ source: join(agentsDir, file),
217
+ transform: 'claude-md',
218
+ });
219
+ }
220
+ }
221
+
222
+ manifest.set('.claude/settings.json', {
223
+ transform: 'claude-settings-json',
224
+ content: buildClaudeSettingsTemplate(packageRoot),
225
+ });
226
+
227
+ return manifest;
228
+ }
229
+
230
+ /**
231
+ * @param {string} [packageRoot]
232
+ */
233
+ export function getCodexUpstreamManifest(packageRoot = PACKAGE_ROOT) {
234
+ /** @type {Map<string, { source?: string, transform?: string, content?: string, tomlTemplate?: string, platform?: string }>} */
235
+ const manifest = new Map();
236
+
237
+ for (const { rel, abs } of walkDir(join(packageRoot, 'core', 'skills'), '.codex/skills')) {
238
+ if (rel.endsWith('.md')) {
239
+ manifest.set(rel, { source: abs, transform: 'skill-render', platform: 'codex' });
240
+ } else {
241
+ manifest.set(rel, { source: abs });
242
+ }
243
+ }
244
+
245
+ const bootstrap = join(packageRoot, 'core', 'bootstrap', 'using-specline.md');
246
+ if (existsSync(bootstrap)) {
247
+ manifest.set('.codex/skills/using-specline/SKILL.md', { source: bootstrap });
248
+ }
249
+
250
+ const tomlTemplate = readFileSync(
251
+ join(packageRoot, 'adapters', 'codex', 'agent.toml.hbs'),
252
+ 'utf-8',
253
+ );
254
+
255
+ const agentsDir = join(packageRoot, 'core', 'agents');
256
+ if (existsSync(agentsDir)) {
257
+ for (const file of readdirSync(agentsDir).filter((f) => f.endsWith('.yaml'))) {
258
+ manifest.set(`.codex/agents/${file.replace('.yaml', '.toml')}`, {
259
+ source: join(agentsDir, file),
260
+ transform: 'codex-toml',
261
+ tomlTemplate,
262
+ });
263
+ }
264
+ }
265
+
266
+ manifest.set('.codex/hooks.json', {
267
+ source: join(packageRoot, 'adapters', 'codex', 'hooks.json'),
268
+ });
269
+
270
+ return manifest;
271
+ }
272
+
273
+ /**
274
+ * @param {string} [packageRoot]
275
+ */
276
+ export function getOpencodeUpstreamManifest(packageRoot = PACKAGE_ROOT) {
277
+ /** @type {Map<string, { source?: string, transform?: string, content?: string, platform?: string }>} */
278
+ const manifest = new Map();
279
+
280
+ for (const { rel, abs } of walkDir(join(packageRoot, 'core', 'skills'), '.opencode/skills')) {
281
+ if (rel.endsWith('.md')) {
282
+ manifest.set(rel, { source: abs, transform: 'skill-render', platform: 'opencode' });
283
+ } else {
284
+ manifest.set(rel, { source: abs });
285
+ }
286
+ }
287
+
288
+ const bootstrap = join(packageRoot, 'core', 'bootstrap', 'using-specline.md');
289
+ if (existsSync(bootstrap)) {
290
+ manifest.set('.opencode/skills/using-specline/SKILL.md', { source: bootstrap });
291
+ }
292
+
293
+ manifest.set('specline/opencode-plugin/plugin.js', {
294
+ source: join(packageRoot, 'adapters', 'opencode', 'plugin.js'),
295
+ });
296
+
297
+ manifest.set('opencode.json', {
298
+ transform: 'opencode-json',
299
+ content: mergeOpencodeJson(''),
300
+ });
301
+
302
+ return manifest;
303
+ }
304
+
305
+ /**
306
+ * @param {string[]} platforms
307
+ * @param {string} [packageRoot]
308
+ * @param {{ withShellGuard?: boolean, legacyPhase0?: boolean }} [options]
309
+ */
310
+ export function getCombinedUpstreamManifest(platforms, packageRoot = PACKAGE_ROOT, options = {}) {
311
+ /** @type {Map<string, { source?: string, transform?: string, content?: string, tomlTemplate?: string }>} */
312
+ const combined = new Map();
313
+
314
+ for (const [rel, entry] of getSharedSpeclineManifest(packageRoot)) {
315
+ combined.set(rel, entry);
316
+ }
317
+
318
+ if (platforms.includes('cursor')) {
319
+ for (const [rel, entry] of getCursorPlatformManifest(packageRoot, options)) {
320
+ combined.set(rel, entry);
321
+ }
322
+ }
323
+ if (platforms.includes('claude')) {
324
+ for (const [rel, entry] of getClaudeUpstreamManifest(packageRoot)) {
325
+ combined.set(rel, entry);
326
+ }
327
+ }
328
+ if (platforms.includes('codex')) {
329
+ for (const [rel, entry] of getCodexUpstreamManifest(packageRoot)) {
330
+ combined.set(rel, entry);
331
+ }
332
+ }
333
+ if (platforms.includes('opencode')) {
334
+ for (const [rel, entry] of getOpencodeUpstreamManifest(packageRoot)) {
335
+ combined.set(rel, entry);
336
+ }
337
+ }
338
+
339
+ return combined;
340
+ }
341
+
342
+ /** Backward-compatible alias: cursor-only full manifest including shared paths */
343
+ export function getCursorUpstreamManifest(packageRoot = PACKAGE_ROOT, options = {}) {
344
+ const manifest = getCursorPlatformManifest(packageRoot, options);
345
+ for (const [rel, entry] of getSharedSpeclineManifest(packageRoot)) {
346
+ if (!manifest.has(rel)) manifest.set(rel, entry);
347
+ }
348
+ return manifest;
349
+ }
350
+
351
+ /** Phase 0 完整 hooks(向后兼容测试) */
352
+ function getCursorUpstreamManifestPhase0(packageRoot) {
353
+ const manifest = new Map();
354
+ const phase0Hooks = join(TEMPLATES_DIR, '.cursor', 'hooks.json');
355
+
356
+ for (const { rel, abs } of walkDir(join(packageRoot, 'core', 'skills'), '.cursor/skills')) {
357
+ manifest.set(rel, { source: abs });
358
+ }
359
+ const agentsDir = join(packageRoot, 'core', 'agents');
360
+ if (existsSync(agentsDir)) {
361
+ for (const file of readdirSync(agentsDir).filter((f) => f.endsWith('.yaml'))) {
362
+ manifest.set(`.cursor/agents/${file.replace('.yaml', '.md')}`, {
363
+ source: join(agentsDir, file),
364
+ transform: 'cursor-agent-md',
365
+ });
366
+ }
367
+ }
368
+ for (const [src, dest] of [
369
+ ['core/hooks/session-start.sh', '.cursor/hooks/specline-session-start.sh'],
370
+ ['core/gates/pipeline-gate.sh', '.cursor/hooks/specline-pipeline-gate.sh'],
371
+ ]) {
372
+ manifest.set(dest, { source: join(packageRoot, src) });
373
+ }
374
+ for (const { rel, abs } of walkDir(
375
+ join(packageRoot, 'core', 'gates', 'pipeline-gate-checks'),
376
+ '.cursor/hooks/specline-pipeline-gate-checks',
377
+ )) {
378
+ manifest.set(rel, { source: abs });
379
+ }
380
+ for (const name of [
381
+ 'specline-phase-guard.sh',
382
+ 'specline-agent-guard.sh',
383
+ 'specline-reminder.sh',
384
+ 'specline-shell-guard.sh',
385
+ 'specline-auto-format.sh',
386
+ ]) {
387
+ const src = join(TEMPLATES_DIR, '.cursor', 'hooks', name);
388
+ if (existsSync(src)) manifest.set(`.cursor/hooks/${name}`, { source: src });
389
+ }
390
+ manifest.set('.cursor/hooks.json', { source: phase0Hooks });
391
+ for (const [rel, entry] of getSharedSpeclineManifest(packageRoot)) {
392
+ manifest.set(rel, entry);
393
+ }
394
+ const readme = join(TEMPLATES_DIR, '.cursor', 'README.md');
395
+ if (existsSync(readme)) manifest.set('.cursor/README.md', { source: readme });
396
+ return manifest;
397
+ }
398
+
399
+ function renderManifestEntry(entry) {
400
+ if (entry.transform === 'skill-render' && entry.platform) {
401
+ return renderSkillForPlatform(readFileSync(entry.source, 'utf-8'), entry.platform);
402
+ }
403
+ if (entry.transform === 'cursor-agent-md' || entry.transform === 'claude-md') {
404
+ const render = entry.transform === 'claude-md' ? renderClaudeAgent : renderCursorAgent;
405
+ return render(readFileSync(entry.source, 'utf-8'));
406
+ }
407
+ if (entry.transform === 'codex-toml') {
408
+ return renderCodexAgent(readFileSync(entry.source, 'utf-8'), entry.tomlTemplate);
409
+ }
410
+ if (entry.content) return entry.content;
411
+ if (entry.source && existsSync(entry.source)) {
412
+ return readFileSync(entry.source, 'utf-8');
413
+ }
414
+ return null;
415
+ }
416
+
417
+ export function hashManifestEntry(entry) {
418
+ if (entry.transform === 'skill-render' && entry.platform) {
419
+ return sha256(renderSkillForPlatform(readFileSync(entry.source, 'utf-8'), entry.platform));
420
+ }
421
+ if (entry.transform === 'cursor-agent-md') {
422
+ return sha256(renderCursorAgent(readFileSync(entry.source, 'utf-8')));
423
+ }
424
+ if (entry.transform === 'claude-md') {
425
+ return sha256(renderClaudeAgent(readFileSync(entry.source, 'utf-8')));
426
+ }
427
+ if (entry.transform === 'codex-toml') {
428
+ return sha256(renderCodexAgent(readFileSync(entry.source, 'utf-8'), entry.tomlTemplate));
429
+ }
430
+ if (entry.content) return sha256(entry.content);
431
+ if (entry.source && existsSync(entry.source)) return computeFileHash(entry.source);
432
+ return null;
433
+ }
434
+
435
+ export function buildLockDataFromManifest(manifest, version, platforms = ['cursor']) {
436
+ const files = new Map();
437
+ for (const [rel, entry] of manifest) {
438
+ const h = hashManifestEntry(entry);
439
+ if (h) files.set(rel, h);
440
+ }
441
+ return {
442
+ version,
443
+ synced_at: new Date().toISOString(),
444
+ schema: 2,
445
+ platforms: [...platforms],
446
+ files,
447
+ };
448
+ }
449
+
450
+ const PLATFORM_DIRS = {
451
+ cursor: [
452
+ '.cursor/agents',
453
+ '.cursor/skills',
454
+ '.cursor/hooks',
455
+ ],
456
+ claude: ['.claude/agents', '.claude/skills'],
457
+ codex: ['.codex/agents', '.codex/skills'],
458
+ opencode: ['.opencode/skills', 'specline/opencode-plugin'],
459
+ };
460
+
461
+ const SHARED_DIRS = [
462
+ 'specline/changes/archive',
463
+ 'specline/specs',
464
+ 'specline/bin',
465
+ 'specline/bin/gate-checks',
466
+ ];
467
+
468
+ /**
469
+ * @param {string} targetProjectDir
470
+ * @param {string[]} platforms
471
+ */
472
+ function ensurePlatformDirs(targetProjectDir, platforms) {
473
+ const dirs = new Set(SHARED_DIRS);
474
+ for (const pl of platforms) {
475
+ for (const d of PLATFORM_DIRS[pl] || []) dirs.add(d);
476
+ }
477
+ for (const dir of dirs) {
478
+ const full = join(targetProjectDir, dir);
479
+ if (!existsSync(full)) mkdirSync(full, { recursive: true });
480
+ }
481
+ }
482
+
483
+ /**
484
+ * @param {string} targetProjectDir
485
+ * @param {Map<string, object>} manifest
486
+ */
487
+ export function writeManifestToProject(targetProjectDir, manifest) {
488
+ for (const [rel, entry] of manifest) {
489
+ const dest = join(targetProjectDir, rel);
490
+ const destDir = dirname(dest);
491
+ if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
492
+
493
+ if (entry.transform === 'skill-render' && entry.platform) {
494
+ const rendered = renderSkillForPlatform(readFileSync(entry.source, 'utf-8'), entry.platform);
495
+ writeFileSync(dest, rendered, 'utf-8');
496
+ } else if (entry.transform === 'cursor-agent-md' || entry.transform === 'claude-md') {
497
+ const render = entry.transform === 'claude-md' ? renderClaudeAgent : renderCursorAgent;
498
+ writeFileSync(dest, render(readFileSync(entry.source, 'utf-8')), 'utf-8');
499
+ } else if (entry.transform === 'codex-toml') {
500
+ writeFileSync(
501
+ dest,
502
+ renderCodexAgent(readFileSync(entry.source, 'utf-8'), entry.tomlTemplate),
503
+ 'utf-8',
504
+ );
505
+ } else if (entry.content) {
506
+ writeFileSync(dest, entry.content, 'utf-8');
507
+ } else if (entry.source) {
508
+ copyFileSync(entry.source, dest);
509
+ if (rel.endsWith('.sh')) {
510
+ try {
511
+ chmodSync(dest, 0o755);
512
+ } catch {
513
+ /* Windows */
514
+ }
515
+ }
516
+ }
517
+ }
518
+ }
519
+
520
+ /**
521
+ * @param {string} targetProjectDir
522
+ * @param {string} [packageRoot]
523
+ * @param {{ withShellGuard?: boolean, platforms?: string[], legacyPhase0?: boolean }} [options]
524
+ */
525
+ export function deployCursor(targetProjectDir, packageRoot = PACKAGE_ROOT, options = {}) {
526
+ const platforms = options.platforms || ['cursor'];
527
+ ensurePlatformDirs(targetProjectDir, platforms.includes('cursor') ? ['cursor'] : []);
528
+ const manifest = getCursorUpstreamManifest(packageRoot, options);
529
+ writeManifestToProject(targetProjectDir, manifest);
530
+ return manifest;
531
+ }
532
+
533
+ /**
534
+ * @param {string} targetProjectDir
535
+ * @param {string[]} platforms
536
+ * @param {string} [packageRoot]
537
+ * @param {{ withShellGuard?: boolean }} [options]
538
+ */
539
+ export function deployPlatforms(targetProjectDir, platforms, packageRoot = PACKAGE_ROOT, options = {}) {
540
+ ensurePlatformDirs(targetProjectDir, platforms);
541
+ const manifest = getCombinedUpstreamManifest(platforms, packageRoot, options);
542
+ writeManifestToProject(targetProjectDir, manifest);
543
+ return manifest;
544
+ }
545
+
546
+ /** @deprecated use deployCursor */
547
+ export const deployCursorPhase0 = deployCursor;
548
+
549
+ export function loadDeployJson(platform, packageRoot = PACKAGE_ROOT) {
550
+ const path = deployManifestPath(platform);
551
+ if (!existsSync(path)) return null;
552
+ return JSON.parse(readFileSync(path, 'utf-8'));
553
+ }
554
+
555
+ export function countDeployedFiles(targetProjectDir) {
556
+ const counts = { skills: 0, agents: 0, hooks: 0 };
557
+ const tally = (sub, key) => {
558
+ const dir = join(targetProjectDir, sub);
559
+ if (existsSync(dir)) counts[key] += walkDir(dir).length;
560
+ };
561
+ for (const prefix of ['.cursor', '.claude', '.opencode']) {
562
+ tally(`${prefix}/skills`, 'skills');
563
+ tally(`${prefix}/agents`, 'agents');
564
+ }
565
+ tally('.codex/skills', 'skills');
566
+ tally('.codex/agents', 'agents');
567
+ tally('.cursor/hooks', 'hooks');
568
+ if (existsSync(join(targetProjectDir, '.codex', 'hooks.json'))) counts.hooks += 1;
569
+ if (existsSync(join(targetProjectDir, '.claude', 'settings.json'))) counts.hooks += 1;
570
+ return counts;
571
+ }
572
+
573
+ export function readUpstreamContent(relPath, manifest) {
574
+ const entry = manifest.get(relPath);
575
+ if (!entry) return null;
576
+ return renderManifestEntry(entry);
577
+ }
578
+
579
+ export function getUpstreamFileHash(relPath, packageRoot = PACKAGE_ROOT, options = {}) {
580
+ const platforms = options.platforms || ['cursor'];
581
+ const manifest = getCombinedUpstreamManifest(platforms, packageRoot, options);
582
+ const entry = manifest.get(relPath);
583
+ if (!entry) {
584
+ const legacy = join(TEMPLATES_DIR, relPath);
585
+ if (existsSync(legacy)) return computeFileHash(legacy);
586
+ return null;
587
+ }
588
+ return hashManifestEntry(entry);
589
+ }
590
+
591
+ export function buildUpstreamLockData(version, packageRoot = PACKAGE_ROOT, options = {}) {
592
+ const platforms = options.platforms || ['cursor'];
593
+ const manifest = getCombinedUpstreamManifest(platforms, packageRoot, options);
594
+ return buildLockDataFromManifest(manifest, version, platforms);
595
+ }
596
+
597
+ export function writePlatformsYaml(projectDir, platforms) {
598
+ const valid = platforms.filter((p) => PLATFORMS.includes(p));
599
+ const body = `platforms:\n${valid.map((p) => ` - ${p}`).join('\n')}\n`;
600
+ writeFileSync(join(projectDir, 'specline', 'platforms.yaml'), body, 'utf-8');
601
+ }
602
+
603
+ /**
604
+ * @param {string} projectDir
605
+ * @returns {string[]|null}
606
+ */
607
+ export function readPlatformsYaml(projectDir) {
608
+ const path = projectPlatformsPath(projectDir);
609
+ if (!existsSync(path)) return null;
610
+ const text = readFileSync(path, 'utf-8');
611
+ const platforms = [];
612
+ for (const line of text.split('\n')) {
613
+ const m = line.match(/^\s*-\s*(\w+)/);
614
+ if (m && PLATFORMS.includes(m[1])) platforms.push(m[1]);
615
+ }
616
+ return platforms.length ? platforms : null;
617
+ }
618
+
619
+ /**
620
+ * @param {string} projectDir
621
+ * @returns {string[]}
622
+ */
623
+ export function readProjectPlatforms(projectDir) {
624
+ const lock = readLockFile(projectDir);
625
+ if (lock?.platforms?.length) return lock.platforms;
626
+ const fromYaml = readPlatformsYaml(projectDir);
627
+ if (fromYaml?.length) return fromYaml;
628
+ if (existsSync(join(projectDir, '.cursor'))) return ['cursor'];
629
+ return ['cursor'];
630
+ }
631
+
632
+ /**
633
+ * @param {string} projectDir
634
+ * @param {string} [packageRoot]
635
+ */
636
+ export function mergeAgentsMd(projectDir, packageRoot = PACKAGE_ROOT) {
637
+ const bootstrapPath = join(packageRoot, 'core', 'bootstrap', 'using-specline.md');
638
+ const templatePath = join(packageRoot, 'core', 'templates', 'AGENTS.md.hbs');
639
+ if (!existsSync(bootstrapPath) || !existsSync(templatePath)) return;
640
+
641
+ const bootstrap = readFileSync(bootstrapPath, 'utf-8').trim();
642
+ const template = readFileSync(templatePath, 'utf-8').replace('{{bootstrap}}', bootstrap);
643
+ const agentsPath = join(projectDir, 'AGENTS.md');
644
+ const marker = '# Specline bootstrap';
645
+
646
+ if (!existsSync(agentsPath)) {
647
+ writeFileSync(agentsPath, template, 'utf-8');
648
+ return;
649
+ }
650
+
651
+ const existing = readFileSync(agentsPath, 'utf-8');
652
+ if (existing.includes(marker)) {
653
+ const sep = '\n---\n\n';
654
+ const idx = existing.indexOf(sep);
655
+ const userTail = idx >= 0 ? existing.slice(idx + sep.length) : '';
656
+ const merged = template + (userTail.trim() ? sep + userTail.trim() + '\n' : '\n');
657
+ writeFileSync(agentsPath, merged, 'utf-8');
658
+ return;
659
+ }
660
+
661
+ if (existing.includes('specline-pipeline') || existing.includes('Using Specline')) {
662
+ return;
663
+ }
664
+
665
+ writeFileSync(agentsPath, template + '\n\n---\n\n' + existing.trim() + '\n', 'utf-8');
666
+ }
667
+
668
+ export { PLATFORMS };