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/gate.mjs ADDED
@@ -0,0 +1,103 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { spawnSync } from 'child_process';
4
+ import { PACKAGE_ROOT } from './paths.mjs';
5
+
6
+ const GATE_PHASES = new Set([
7
+ 'new', 'list', 'artifacts', 'spec', 'semantic', 'build', 'lint',
8
+ 'test-unit', 'test-integration', 'test-e2e', 'detect-modules', 'bind', 'archive', 'status',
9
+ ]);
10
+
11
+ /**
12
+ * @param {string} projectDir
13
+ */
14
+ export function resolveGateScript(projectDir) {
15
+ const projectGate = join(projectDir, 'specline', 'bin', 'gate.sh');
16
+ if (existsSync(projectGate)) return projectGate;
17
+ const packaged = join(PACKAGE_ROOT, 'core', 'gates', 'pipeline-gate.sh');
18
+ const legacy = join(projectDir, '.cursor', 'hooks', 'specline-pipeline-gate.sh');
19
+ if (projectDir === PACKAGE_ROOT && existsSync(packaged)) return packaged;
20
+ if (existsSync(legacy)) return legacy;
21
+ return packaged;
22
+ }
23
+
24
+ /**
25
+ * @param {string} phase
26
+ * @param {{ change?: string, projectDir?: string, execute?: boolean, json?: boolean, extraArgs?: string[] }} opts
27
+ */
28
+ export function runGate(phase, opts = {}) {
29
+ const projectDir = opts.projectDir || process.cwd();
30
+ const script = resolveGateScript(projectDir);
31
+ const args = [script, phase];
32
+ if (opts.change) args.push('--change', opts.change);
33
+ if (opts.execute) args.push('--execute');
34
+ if (opts.extraArgs?.length) args.push(...opts.extraArgs);
35
+
36
+ const result = spawnSync('bash', args, {
37
+ cwd: projectDir,
38
+ encoding: 'utf-8',
39
+ env: { ...process.env, SPECLINE_PROJECT_ROOT: projectDir },
40
+ });
41
+
42
+ if (opts.json && phase === 'list') {
43
+ try {
44
+ result.jsonOutput = JSON.parse((result.stdout || '').trim());
45
+ } catch {
46
+ result.jsonOutput = (result.stdout || '').trim().split('\n').filter(Boolean);
47
+ }
48
+ }
49
+
50
+ return result;
51
+ }
52
+
53
+ /**
54
+ * CLI entry point for `specline gate <subcommand> [--change <name>] [--json]`
55
+ * @param {string[]} argv - remaining args after "gate"
56
+ * @param {string} [cwd]
57
+ * @returns {number} exit code
58
+ */
59
+ export function cliGate(argv, cwd) {
60
+ const projectDir = cwd || process.cwd();
61
+ const subcommand = argv[0];
62
+
63
+ if (!subcommand || subcommand === '--help') {
64
+ const phases = [...GATE_PHASES].join(', ');
65
+ process.stdout.write(`Usage: specline gate <subcommand> [--change <name>] [--json]\n\nSubcommands: ${phases}\n`);
66
+ return subcommand ? 0 : 1;
67
+ }
68
+
69
+ if (!isGatePhase(subcommand)) {
70
+ process.stderr.write(`Unknown gate subcommand: ${subcommand}\nAvailable: ${[...GATE_PHASES].join(', ')}\n`);
71
+ return 1;
72
+ }
73
+
74
+ let change = null;
75
+ let json = false;
76
+ for (let i = 1; i < argv.length; i++) {
77
+ if (argv[i] === '--change' && argv[i + 1]) {
78
+ change = argv[++i];
79
+ } else if (argv[i] === '--json') {
80
+ json = true;
81
+ }
82
+ }
83
+
84
+ const result = runGate(subcommand, { change, projectDir, json });
85
+
86
+ if (json && subcommand === 'list') {
87
+ const output = result.jsonOutput
88
+ ? JSON.stringify(result.jsonOutput, null, 2)
89
+ : (result.stdout || '');
90
+ process.stdout.write(output + '\n');
91
+ } else {
92
+ if (result.stdout) process.stdout.write(result.stdout);
93
+ if (result.stderr) process.stderr.write(result.stderr);
94
+ }
95
+
96
+ return result.status ?? 1;
97
+ }
98
+
99
+ export function isGatePhase(phase) {
100
+ return GATE_PHASES.has(phase);
101
+ }
102
+
103
+ export { GATE_PHASES };
package/lib/hash.mjs ADDED
@@ -0,0 +1,13 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFileSync } from 'fs';
3
+
4
+ /** @param {string|Buffer} content */
5
+ export function sha256(content) {
6
+ const hash = createHash('sha256').update(content).digest('hex');
7
+ return `sha256:${hash}`;
8
+ }
9
+
10
+ /** @param {string} filePath */
11
+ export function computeFileHash(filePath) {
12
+ return sha256(readFileSync(filePath));
13
+ }
package/lib/hook.mjs ADDED
@@ -0,0 +1,105 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { spawnSync } from 'child_process';
4
+ import { PACKAGE_ROOT } from './paths.mjs';
5
+
6
+ /**
7
+ * @param {string} projectDir
8
+ */
9
+ export function resolveSessionStartScript(projectDir) {
10
+ const deployed = join(projectDir, '.cursor', 'hooks', 'specline-session-start.sh');
11
+ if (existsSync(deployed)) return deployed;
12
+ return join(PACKAGE_ROOT, 'core', 'hooks', 'session-start.sh');
13
+ }
14
+
15
+ /**
16
+ * @param {object} cursorJson parsed stdout from session-start.sh
17
+ * @param {string} platform
18
+ */
19
+ export function formatHookOutput(cursorJson, platform) {
20
+ const ctx = cursorJson.additional_context;
21
+ if (platform === 'cursor' || platform === 'opencode') {
22
+ if (ctx) return JSON.stringify({ additional_context: ctx });
23
+ return JSON.stringify(cursorJson);
24
+ }
25
+ if (platform === 'claude' || platform === 'codex') {
26
+ const additionalContext = ctx || '';
27
+ return JSON.stringify({
28
+ hookSpecificOutput: {
29
+ hookEventName: 'SessionStart',
30
+ additionalContext,
31
+ },
32
+ });
33
+ }
34
+ return JSON.stringify(cursorJson);
35
+ }
36
+
37
+ /**
38
+ * @param {{ platform?: string, projectDir?: string, input?: string }} opts
39
+ */
40
+ export function runSessionStartHook(opts = {}) {
41
+ const platform = opts.platform || 'cursor';
42
+ const projectDir = opts.projectDir || process.cwd();
43
+ const script = resolveSessionStartScript(projectDir);
44
+ const input = opts.input ?? JSON.stringify({ session_id: 'cli', is_background_agent: false });
45
+
46
+ const result = spawnSync('bash', [script], {
47
+ cwd: projectDir,
48
+ input,
49
+ encoding: 'utf-8',
50
+ env: { ...process.env, SPECLINE_PROJECT_ROOT: projectDir },
51
+ });
52
+
53
+ let parsed = {};
54
+ try {
55
+ parsed = JSON.parse((result.stdout || '').trim() || '{}');
56
+ } catch {
57
+ parsed = {};
58
+ }
59
+
60
+ const formatted = formatHookOutput(parsed, platform);
61
+ return { ...result, formatted, parsed };
62
+ }
63
+
64
+ /**
65
+ * CLI entry point for `specline hook session-start [--platform <p>]`
66
+ * @param {string[]} argv - remaining args after "hook session-start"
67
+ * @param {string} [cwd]
68
+ * @returns {number} exit code
69
+ */
70
+ export function cliHookSessionStart(argv, cwd) {
71
+ const projectDir = cwd || process.cwd();
72
+ let platform = 'cursor';
73
+
74
+ for (let i = 0; i < argv.length; i++) {
75
+ if (argv[i] === '--platform' && argv[i + 1]) {
76
+ platform = argv[++i];
77
+ }
78
+ }
79
+
80
+ const { formatted, status } = runSessionStartHook({ platform, projectDir });
81
+ process.stdout.write(formatted + '\n');
82
+ return status ?? 0;
83
+ }
84
+
85
+ /**
86
+ * CLI entry point for `specline hook <subcommand> [opts]`
87
+ * @param {string[]} argv - remaining args after "hook"
88
+ * @param {string} [cwd]
89
+ * @returns {number} exit code
90
+ */
91
+ export function cliHook(argv, cwd) {
92
+ const subcommand = argv[0];
93
+
94
+ if (!subcommand || subcommand === '--help') {
95
+ process.stdout.write('Usage: specline hook <subcommand> [options]\n\nSubcommands: session-start\n');
96
+ return subcommand ? 0 : 1;
97
+ }
98
+
99
+ if (subcommand === 'session-start') {
100
+ return cliHookSessionStart(argv.slice(1), cwd);
101
+ }
102
+
103
+ process.stderr.write(`Unknown hook subcommand: ${subcommand}\nAvailable: session-start\n`);
104
+ return 1;
105
+ }
package/lib/init.mjs ADDED
@@ -0,0 +1,122 @@
1
+ import { existsSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import {
4
+ deployPlatforms,
5
+ buildUpstreamLockData,
6
+ readPlatformsYaml,
7
+ writePlatformsYaml,
8
+ countDeployedFiles,
9
+ PLATFORMS,
10
+ } from './deploy.mjs';
11
+ import { writeLockFile } from './lock.mjs';
12
+ import { PACKAGE_ROOT } from './paths.mjs';
13
+ import { selectPlatforms } from './tty-select.mjs';
14
+
15
+ /**
16
+ * @param {string} raw
17
+ * @returns {string[]}
18
+ */
19
+ export function parsePlatformList(raw) {
20
+ if (!raw || raw === 'none') return [];
21
+ if (raw === 'all') return [...PLATFORMS];
22
+ return raw
23
+ .split(',')
24
+ .map((p) => p.trim().toLowerCase())
25
+ .filter((p) => PLATFORMS.includes(p));
26
+ }
27
+
28
+ /**
29
+ * @param {boolean} isTTY
30
+ * @param {string|undefined} platformArg
31
+ */
32
+ export async function resolvePlatforms(isTTY, platformArg) {
33
+ if (platformArg !== undefined) {
34
+ return parsePlatformList(platformArg);
35
+ }
36
+ return selectPlatforms();
37
+ }
38
+
39
+ /**
40
+ * @param {object} opts
41
+ * @param {string} opts.target
42
+ * @param {string[]} opts.platforms
43
+ * @param {boolean} opts.withShellGuard
44
+ * @param {string} opts.version
45
+ * @param {boolean} [opts.force]
46
+ */
47
+ export function runInit(opts) {
48
+ const { target, platforms, withShellGuard, version, force } = opts;
49
+
50
+ const speclineDir = join(target, 'specline');
51
+ const alreadyInitialized = existsSync(join(speclineDir, '.specline-lock.yaml'));
52
+
53
+ if (alreadyInitialized && !force && platforms.length > 0) {
54
+ return runAppendPlatforms({ target, platforms, withShellGuard, version });
55
+ }
56
+
57
+ if (platforms.length === 0) {
58
+ for (const dir of ['specline/changes/archive', 'specline/specs', 'specline/bin']) {
59
+ const full = join(target, dir);
60
+ if (!existsSync(full)) mkdirSync(full, { recursive: true });
61
+ }
62
+ writePlatformsYaml(target, []);
63
+
64
+ const lockData = buildUpstreamLockData(version, PACKAGE_ROOT, { platforms: [] });
65
+ writeLockFile(target, lockData);
66
+
67
+ return { skills: 0, agents: 0, hooks: 0, platforms: [], appended: [] };
68
+ }
69
+
70
+ deployPlatforms(target, platforms, PACKAGE_ROOT, { withShellGuard, platforms });
71
+ writePlatformsYaml(target, platforms);
72
+
73
+ const lockData = buildUpstreamLockData(version, PACKAGE_ROOT, {
74
+ withShellGuard,
75
+ platforms,
76
+ });
77
+ writeLockFile(target, lockData);
78
+
79
+ return { ...countDeployedFiles(target), platforms, appended: [] };
80
+ }
81
+
82
+ /**
83
+ * Append new platforms without resetting existing configuration.
84
+ * @param {object} opts
85
+ * @param {string} opts.target
86
+ * @param {string[]} opts.platforms - requested platforms
87
+ * @param {boolean} opts.withShellGuard
88
+ * @param {string} opts.version
89
+ */
90
+ function runAppendPlatforms(opts) {
91
+ const { target, platforms: requested, withShellGuard, version } = opts;
92
+
93
+ const existingPlatforms = readPlatformsYaml(target) || [];
94
+ const newPlatforms = requested.filter((p) => !existingPlatforms.includes(p));
95
+
96
+ if (newPlatforms.length === 0) {
97
+ return {
98
+ skills: 0,
99
+ agents: 0,
100
+ hooks: 0,
101
+ platforms: existingPlatforms,
102
+ appended: [],
103
+ };
104
+ }
105
+
106
+ deployPlatforms(target, newPlatforms, PACKAGE_ROOT, { withShellGuard, platforms: newPlatforms });
107
+
108
+ const allPlatforms = [...existingPlatforms, ...newPlatforms];
109
+ writePlatformsYaml(target, allPlatforms);
110
+
111
+ const lockData = buildUpstreamLockData(version, PACKAGE_ROOT, {
112
+ withShellGuard,
113
+ platforms: allPlatforms,
114
+ });
115
+ writeLockFile(target, lockData);
116
+
117
+ return {
118
+ ...countDeployedFiles(target),
119
+ platforms: allPlatforms,
120
+ appended: newPlatforms,
121
+ };
122
+ }
package/lib/lock.mjs ADDED
@@ -0,0 +1,99 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { computeFileHash, sha256 } from './hash.mjs';
4
+
5
+ /**
6
+ * @typedef {Object} LockData
7
+ * @property {string} version
8
+ * @property {string} synced_at
9
+ * @property {number} [schema]
10
+ * @property {string[]} [platforms]
11
+ * @property {Map<string, string>} files
12
+ */
13
+
14
+ /** @param {string} projectDir */
15
+ export function lockFilePath(projectDir) {
16
+ return join(projectDir, 'specline', '.specline-lock.yaml');
17
+ }
18
+
19
+ /** @param {string} projectDir @returns {LockData|null} */
20
+ export function readLockFile(projectDir) {
21
+ const lockPath = lockFilePath(projectDir);
22
+ if (!existsSync(lockPath)) return null;
23
+
24
+ const lines = readFileSync(lockPath, 'utf-8').split('\n');
25
+ /** @type {LockData} */
26
+ const result = { version: '', synced_at: '', files: new Map() };
27
+ let inFiles = false;
28
+
29
+ for (const line of lines) {
30
+ const trimmed = line.trim();
31
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
32
+
33
+ if (trimmed.startsWith('version:')) {
34
+ result.version = trimmed.slice('version:'.length).trim().replace(/^"(.*)"$/, '$1');
35
+ } else if (trimmed.startsWith('synced_at:')) {
36
+ result.synced_at = trimmed.slice('synced_at:'.length).trim().replace(/^"(.*)"$/, '$1');
37
+ } else if (trimmed.startsWith('schema:')) {
38
+ result.schema = Number(trimmed.slice('schema:'.length).trim());
39
+ } else if (trimmed.startsWith('platforms:')) {
40
+ const raw = trimmed.slice('platforms:'.length).trim();
41
+ if (raw.startsWith('[')) {
42
+ result.platforms = raw
43
+ .slice(1, -1)
44
+ .split(',')
45
+ .map((p) => p.trim().replace(/^"(.*)"$/, '$1'))
46
+ .filter(Boolean);
47
+ }
48
+ } else if (trimmed === 'files:') {
49
+ inFiles = true;
50
+ } else if (inFiles && trimmed.includes(':')) {
51
+ const colonIdx = trimmed.indexOf(':');
52
+ const key = trimmed.slice(0, colonIdx).trim();
53
+ const value = trimmed.slice(colonIdx + 1).trim();
54
+ result.files.set(key, value);
55
+ }
56
+ }
57
+
58
+ return result;
59
+ }
60
+
61
+ /** @param {string} projectDir @param {LockData} lockData */
62
+ export function writeLockFile(projectDir, lockData) {
63
+ const lockDir = join(projectDir, 'specline');
64
+ if (!existsSync(lockDir)) {
65
+ mkdirSync(lockDir, { recursive: true });
66
+ }
67
+ const lockPath = lockFilePath(projectDir);
68
+ const lines = [
69
+ '# Specline Lock File — 自动生成,请勿手动编辑',
70
+ `version: "${lockData.version}"`,
71
+ `synced_at: "${lockData.synced_at}"`,
72
+ ];
73
+ if (lockData.schema != null) {
74
+ lines.push(`schema: ${lockData.schema}`);
75
+ }
76
+ if (lockData.platforms?.length) {
77
+ lines.push(`platforms: [${lockData.platforms.map((p) => `"${p}"`).join(', ')}]`);
78
+ }
79
+ lines.push('files:');
80
+ for (const [key, value] of lockData.files) {
81
+ lines.push(` ${key}: ${value}`);
82
+ }
83
+ writeFileSync(lockPath, lines.join('\n') + '\n', 'utf-8');
84
+ }
85
+
86
+ /** @returns {boolean} */
87
+ export function isV1Lock(lockData) {
88
+ return lockData != null && lockData.schema == null && !lockData.platforms?.length;
89
+ }
90
+
91
+ /** @param {LockData} lockData @param {string} packageVersion @param {string[]} [platforms] */
92
+ export function migrateV1ToV2(lockData, packageVersion, platforms = ['cursor']) {
93
+ lockData.schema = 2;
94
+ lockData.version = packageVersion;
95
+ lockData.platforms = [...platforms];
96
+ return lockData;
97
+ }
98
+
99
+ export { computeFileHash, sha256 };
package/lib/merge.mjs ADDED
@@ -0,0 +1,188 @@
1
+ import { copyFileSync } from 'fs';
2
+
3
+ /**
4
+ * hooks.json 语义合并:清理 specline 官方条目,注入模板最新 hook
5
+ */
6
+ export function mergeHooksJson(existingContent, templateContent, warn = () => {}) {
7
+ let existingObj;
8
+ let templateObj;
9
+ try {
10
+ existingObj = JSON.parse(existingContent);
11
+ } catch {
12
+ warn('hooks.json 解析失败,将使用模板完整替换');
13
+ return templateContent;
14
+ }
15
+ try {
16
+ templateObj = JSON.parse(templateContent);
17
+ } catch {
18
+ warn('模板 hooks.json 解析失败,保留现有文件');
19
+ return existingContent;
20
+ }
21
+
22
+ if (!existingObj.hooks) existingObj.hooks = {};
23
+
24
+ for (const eventName of Object.keys(existingObj.hooks)) {
25
+ existingObj.hooks[eventName] = existingObj.hooks[eventName].filter(
26
+ (entry) => !(entry.command || '').includes('specline-') && !(entry.command || '').includes('specline '),
27
+ );
28
+ }
29
+
30
+ for (const eventName of Object.keys(templateObj.hooks || {})) {
31
+ if (!existingObj.hooks[eventName]) existingObj.hooks[eventName] = [];
32
+ existingObj.hooks[eventName] = [
33
+ ...templateObj.hooks[eventName],
34
+ ...existingObj.hooks[eventName],
35
+ ];
36
+ }
37
+ return JSON.stringify(existingObj, null, 2) + '\n';
38
+ }
39
+
40
+ export function countCustomHooks(hooksObj) {
41
+ let count = 0;
42
+ for (const eventName of Object.keys(hooksObj.hooks || {})) {
43
+ for (const entry of hooksObj.hooks[eventName] || []) {
44
+ const cmd = entry.command || '';
45
+ if (!cmd.includes('specline')) count++;
46
+ }
47
+ }
48
+ return count;
49
+ }
50
+
51
+ /** Claude Code settings.json — merge hooks 段 */
52
+ export function mergeClaudeSettings(existingContent, templateContent, warn = () => {}) {
53
+ let existingObj = {};
54
+ let templateObj;
55
+ try {
56
+ if (existingContent.trim()) existingObj = JSON.parse(existingContent);
57
+ } catch {
58
+ warn('settings.json 解析失败,将使用模板 hooks 段');
59
+ existingObj = {};
60
+ }
61
+ try {
62
+ templateObj = JSON.parse(templateContent);
63
+ } catch {
64
+ return existingContent;
65
+ }
66
+ const tmplHooks = templateObj.hooks || templateObj;
67
+ if (!existingObj.hooks) existingObj.hooks = {};
68
+ for (const [eventName, entries] of Object.entries(tmplHooks)) {
69
+ if (!existingObj.hooks[eventName]) existingObj.hooks[eventName] = [];
70
+ existingObj.hooks[eventName] = existingObj.hooks[eventName].filter(
71
+ (e) => !JSON.stringify(e).includes('specline'),
72
+ );
73
+ existingObj.hooks[eventName] = [...entries, ...existingObj.hooks[eventName]];
74
+ }
75
+ return JSON.stringify(existingObj, null, 2) + '\n';
76
+ }
77
+
78
+ /** opencode.json plugin 数组 merge */
79
+ export function mergeOpencodeJson(existingContent, pluginPath = './specline/opencode-plugin') {
80
+ let base = {};
81
+ if (existingContent?.trim()) {
82
+ try {
83
+ base = JSON.parse(existingContent);
84
+ } catch {
85
+ base = {};
86
+ }
87
+ }
88
+ const key = base.plugin != null ? 'plugin' : 'plugins';
89
+ const current = Array.isArray(base[key]) ? base[key] : [];
90
+ const set = new Set(current);
91
+ set.add(pluginPath);
92
+ base[key] = [...set];
93
+ return JSON.stringify(base, null, 2) + '\n';
94
+ }
95
+
96
+ function parseYamlSections(content) {
97
+ const lines = content.split('\n');
98
+ const sections = [];
99
+ let currentComments = [];
100
+ let currentKey = null;
101
+ let currentBodyLines = [];
102
+ let inBody = false;
103
+
104
+ function flushSection() {
105
+ if (currentComments.length > 0 || currentBodyLines.length > 0 || currentKey) {
106
+ const bodyStr = currentBodyLines.join('\n');
107
+ const bodyTrimmed = bodyStr.trim();
108
+ const onlyKeyDeclaration = currentKey !== null &&
109
+ currentBodyLines.length === 1 &&
110
+ bodyTrimmed.match(/^\w[\w_-]*\s*:\s*$/) !== null;
111
+ const isEmpty = bodyTrimmed === '' || bodyTrimmed.startsWith('#') || onlyKeyDeclaration;
112
+ sections.push({ key: currentKey, headerComments: [...currentComments], body: bodyStr, isEmpty });
113
+ }
114
+ currentComments = [];
115
+ currentKey = null;
116
+ currentBodyLines = [];
117
+ inBody = false;
118
+ }
119
+
120
+ for (const line of lines) {
121
+ const trimmed = line.trim();
122
+ if (trimmed === '') {
123
+ if (inBody) currentBodyLines.push(line);
124
+ continue;
125
+ }
126
+ if (trimmed.startsWith('#')) {
127
+ if (inBody) currentBodyLines.push(line);
128
+ else currentComments.push(line);
129
+ continue;
130
+ }
131
+ const topKeyMatch = line.match(/^(\w[\w_-]*)\s*:(.*)/);
132
+ if (topKeyMatch && !line.startsWith(' ') && !line.startsWith('\t')) {
133
+ flushSection();
134
+ currentKey = topKeyMatch[1];
135
+ currentBodyLines = [line];
136
+ inBody = true;
137
+ continue;
138
+ }
139
+ if (inBody) currentBodyLines.push(line);
140
+ }
141
+ flushSection();
142
+ return sections;
143
+ }
144
+
145
+ function findSection(sections, key) {
146
+ return sections.find((s) => s.key === key) || null;
147
+ }
148
+
149
+ export function mergeConfigYaml(existingContent, templateContent) {
150
+ const existingSections = parseYamlSections(existingContent);
151
+ const templateSections = parseYamlSections(templateContent);
152
+ const resultLines = [];
153
+
154
+ for (const tmplSec of templateSections) {
155
+ const existSec = findSection(existingSections, tmplSec.key);
156
+ if (existSec) {
157
+ if (!existSec.isEmpty && existSec.body.trim() !== tmplSec.body.trim()) {
158
+ resultLines.push(...tmplSec.headerComments);
159
+ resultLines.push(existSec.body);
160
+ } else {
161
+ resultLines.push(...tmplSec.headerComments);
162
+ resultLines.push(tmplSec.body);
163
+ }
164
+ resultLines.push('');
165
+ } else if (tmplSec.key !== null) {
166
+ resultLines.push('# 🆕 新增配置段 (specline sync)');
167
+ resultLines.push(...tmplSec.headerComments);
168
+ resultLines.push(tmplSec.body);
169
+ resultLines.push('');
170
+ }
171
+ }
172
+
173
+ for (const existSec of existingSections) {
174
+ if (existSec.key === null) continue;
175
+ if (!findSection(templateSections, existSec.key)) {
176
+ resultLines.push(...existSec.headerComments);
177
+ resultLines.push(existSec.body);
178
+ resultLines.push('');
179
+ }
180
+ }
181
+ return resultLines.join('\n');
182
+ }
183
+
184
+ export function backupBeforeOverwrite(destPath) {
185
+ const backupPath = destPath + '.orig';
186
+ copyFileSync(destPath, backupPath);
187
+ return backupPath;
188
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,40 @@
1
+ import { join, dirname } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+
6
+ /** npm 包根目录 */
7
+ export const PACKAGE_ROOT = join(__dirname, '..');
8
+
9
+ export const CORE_DIR = join(PACKAGE_ROOT, 'core');
10
+ export const ADAPTERS_DIR = join(PACKAGE_ROOT, 'adapters');
11
+ export const TEMPLATES_DIR = join(PACKAGE_ROOT, 'templates');
12
+
13
+ export const CORE_SKILLS = join(CORE_DIR, 'skills');
14
+ export const CORE_AGENTS = join(CORE_DIR, 'agents');
15
+ export const CORE_GATES = join(CORE_DIR, 'gates');
16
+ export const CORE_HOOKS = join(CORE_DIR, 'hooks');
17
+ export const CORE_BOOTSTRAP = join(CORE_DIR, 'bootstrap');
18
+ export const CORE_TEMPLATES = join(CORE_DIR, 'templates');
19
+
20
+ /** @param {string} platform */
21
+ export function adapterDir(platform) {
22
+ return join(ADAPTERS_DIR, platform);
23
+ }
24
+
25
+ /** @param {string} platform */
26
+ export function deployManifestPath(platform) {
27
+ return join(adapterDir(platform), 'deploy.json');
28
+ }
29
+
30
+ /** @param {string} projectDir */
31
+ export function projectSpeclineDir(projectDir) {
32
+ return join(projectDir, 'specline');
33
+ }
34
+
35
+ /** @param {string} projectDir */
36
+ export function projectPlatformsPath(projectDir) {
37
+ return join(projectSpeclineDir(projectDir), 'platforms.yaml');
38
+ }
39
+
40
+ export const PLATFORMS = ['cursor', 'claude', 'codex', 'opencode'];