specline 1.3.4 → 2.0.0
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/README.md +132 -125
- package/adapters/claude/deploy.json +12 -0
- package/adapters/claude/hooks/hooks.json +12 -0
- package/adapters/claude/hooks.json +12 -0
- package/adapters/claude/orchestration.md +17 -0
- package/adapters/codex/agent.toml.hbs +7 -0
- package/adapters/codex/deploy.json +12 -0
- package/adapters/codex/hooks.json +12 -0
- package/adapters/codex/orchestration.md +18 -0
- package/adapters/cursor/deploy.json +12 -0
- package/adapters/cursor/hooks.json +9 -0
- package/adapters/cursor/orchestration.md +17 -0
- package/adapters/opencode/deploy.json +12 -0
- package/adapters/opencode/orchestration.md +18 -0
- package/adapters/opencode/plugin.js +10 -0
- package/cli.mjs +161 -558
- package/core/agents/specline-backend-dev.yaml +45 -0
- package/core/agents/specline-code-reviewer.yaml +67 -0
- package/core/agents/specline-config-dev.yaml +50 -0
- package/core/agents/specline-config-reviewer.yaml +70 -0
- package/core/agents/specline-explore-assistant.yaml +79 -0
- package/core/agents/specline-frontend-dev.yaml +45 -0
- package/core/agents/specline-spec-creator.yaml +58 -0
- package/core/agents/specline-spec-reviewer.yaml +58 -0
- package/core/agents/specline-test-runner.yaml +62 -0
- package/core/agents/specline-test-writer.yaml +67 -0
- package/core/bootstrap/using-specline.md +14 -0
- package/core/gates/pipeline-gate-checks/a1-covers-ref.sh +125 -0
- package/core/gates/pipeline-gate-checks/a2-a3-reverse.sh +171 -0
- package/core/gates/pipeline-gate-checks/c1-exception.sh +71 -0
- package/core/gates/pipeline-gate-checks/c2-vague.sh +60 -0
- package/core/gates/pipeline-gate-checks/common.sh +68 -0
- package/core/gates/pipeline-gate-checks/d1-cycle.sh +149 -0
- package/core/gates/pipeline-gate-checks/d3-type-file.sh +260 -0
- package/core/gates/pipeline-gate.sh +1456 -0
- package/core/hooks/session-start.sh +259 -0
- package/core/skills/specline-apply-change/SKILL.md +197 -0
- package/core/skills/specline-archive-change/SKILL.md +173 -0
- package/core/skills/specline-explore/SKILL.md +504 -0
- package/core/skills/specline-knowledge/SKILL.md +539 -0
- package/core/skills/specline-pipeline/SKILL.md +604 -0
- package/core/skills/specline-pipeline/references/error-recovery-details.md +49 -0
- package/core/skills/specline-pipeline/references/event-log-spec.md +59 -0
- package/core/skills/specline-pipeline/references/pipeline-state-schema.md +87 -0
- package/core/skills/specline-pipeline/templates/subagent-prompts.md +397 -0
- package/core/skills/specline-propose/SKILL.md +186 -0
- package/core/skills/specline-quickfix/SKILL.md +289 -0
- package/core/templates/AGENTS.md.hbs +5 -0
- package/core/templates/specline/config.yaml +15 -0
- package/lib/deploy-claude.mjs +80 -0
- package/lib/deploy-codex.mjs +77 -0
- package/lib/deploy-opencode.mjs +93 -0
- package/lib/deploy.mjs +668 -0
- package/lib/gate.mjs +103 -0
- package/lib/hash.mjs +13 -0
- package/lib/hook.mjs +105 -0
- package/lib/init.mjs +122 -0
- package/lib/lock.mjs +99 -0
- package/lib/merge.mjs +184 -0
- package/lib/paths.mjs +40 -0
- package/lib/platforms.mjs +74 -0
- package/lib/render-agents.mjs +88 -0
- package/lib/render.mjs +126 -0
- package/lib/sync.mjs +253 -0
- package/lib/tty-select.mjs +89 -0
- package/package.json +4 -1
- package/templates/.cursor/README.md +18 -0
- package/templates/.cursor/agents/specline-code-reviewer.md +63 -4
- package/templates/.cursor/agents/specline-spec-creator.md +120 -1
- package/templates/.cursor/agents/specline-spec-reviewer.md +21 -2
- package/templates/.cursor/agents/specline-test-runner.md +10 -1
- package/templates/.cursor/agents/specline-test-writer.md +58 -7
- package/templates/.cursor/hooks/specline-pipeline-gate-checks/a2-a3-reverse.sh +1 -1
- package/templates/.cursor/hooks/specline-pipeline-gate.sh +118 -0
- package/templates/.cursor/skills/specline-apply-change/SKILL.md +26 -0
- package/templates/.cursor/skills/specline-archive-change/SKILL.md +24 -0
- package/templates/.cursor/skills/specline-explore/SKILL.md +17 -0
- package/templates/.cursor/skills/specline-knowledge/SKILL.md +539 -0
- package/templates/.cursor/skills/specline-pipeline/SKILL.md +102 -3
- package/templates/.cursor/skills/specline-pipeline/templates/subagent-prompts.md +32 -0
- package/templates/.cursor/skills/specline-propose/SKILL.md +34 -3
- package/templates/.cursor/skills/specline-quickfix/SKILL.md +26 -0
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 };
|