@ikunin/sprintpilot 1.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.
Files changed (86) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +330 -0
  3. package/_Sprintpilot/.secrets-allowlist +26 -0
  4. package/_Sprintpilot/Sprintpilot.md +216 -0
  5. package/_Sprintpilot/lib/runtime/args.js +77 -0
  6. package/_Sprintpilot/lib/runtime/git.js +24 -0
  7. package/_Sprintpilot/lib/runtime/http.js +96 -0
  8. package/_Sprintpilot/lib/runtime/log.js +30 -0
  9. package/_Sprintpilot/lib/runtime/secrets.js +151 -0
  10. package/_Sprintpilot/lib/runtime/spawn.js +68 -0
  11. package/_Sprintpilot/lib/runtime/text.js +26 -0
  12. package/_Sprintpilot/lib/runtime/yaml-lite.js +160 -0
  13. package/_Sprintpilot/manifest.yaml +26 -0
  14. package/_Sprintpilot/modules/autopilot/config.yaml +20 -0
  15. package/_Sprintpilot/modules/git/branching-and-pr-strategy.md +101 -0
  16. package/_Sprintpilot/modules/git/config.yaml +83 -0
  17. package/_Sprintpilot/modules/git/templates/commit-patch.txt +1 -0
  18. package/_Sprintpilot/modules/git/templates/commit-story.txt +1 -0
  19. package/_Sprintpilot/modules/git/templates/pr-body.md +20 -0
  20. package/_Sprintpilot/modules/ma/config.yaml +9 -0
  21. package/_Sprintpilot/scripts/create-pr.js +284 -0
  22. package/_Sprintpilot/scripts/detect-platform.js +64 -0
  23. package/_Sprintpilot/scripts/health-check.js +98 -0
  24. package/_Sprintpilot/scripts/lint-changed.js +249 -0
  25. package/_Sprintpilot/scripts/lock.js +195 -0
  26. package/_Sprintpilot/scripts/sanitize-branch.js +107 -0
  27. package/_Sprintpilot/scripts/stage-and-commit.js +190 -0
  28. package/_Sprintpilot/scripts/sync-status.js +141 -0
  29. package/_Sprintpilot/skills/sprint-autopilot-off/SKILL.md +6 -0
  30. package/_Sprintpilot/skills/sprint-autopilot-off/workflow.md +154 -0
  31. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +6 -0
  32. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +1119 -0
  33. package/_Sprintpilot/skills/sprintpilot-assess/SKILL.md +6 -0
  34. package/_Sprintpilot/skills/sprintpilot-assess/agents/debt-classifier.md +64 -0
  35. package/_Sprintpilot/skills/sprintpilot-assess/agents/dependency-auditor.md +57 -0
  36. package/_Sprintpilot/skills/sprintpilot-assess/agents/migration-analyzer.md +62 -0
  37. package/_Sprintpilot/skills/sprintpilot-assess/workflow.md +114 -0
  38. package/_Sprintpilot/skills/sprintpilot-code-review/SKILL.md +6 -0
  39. package/_Sprintpilot/skills/sprintpilot-code-review/agents/acceptance-auditor.md +51 -0
  40. package/_Sprintpilot/skills/sprintpilot-code-review/agents/blind-hunter.md +39 -0
  41. package/_Sprintpilot/skills/sprintpilot-code-review/agents/edge-case-hunter.md +46 -0
  42. package/_Sprintpilot/skills/sprintpilot-code-review/workflow.md +111 -0
  43. package/_Sprintpilot/skills/sprintpilot-codebase-map/SKILL.md +6 -0
  44. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +129 -0
  45. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +135 -0
  46. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +138 -0
  47. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +143 -0
  48. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +133 -0
  49. package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +120 -0
  50. package/_Sprintpilot/skills/sprintpilot-migrate/SKILL.md +6 -0
  51. package/_Sprintpilot/skills/sprintpilot-migrate/agents/dependency-analyzer.md +51 -0
  52. package/_Sprintpilot/skills/sprintpilot-migrate/agents/risk-assessor.md +55 -0
  53. package/_Sprintpilot/skills/sprintpilot-migrate/agents/stack-mapper.md +49 -0
  54. package/_Sprintpilot/skills/sprintpilot-migrate/agents/test-parity-analyzer.md +49 -0
  55. package/_Sprintpilot/skills/sprintpilot-migrate/resources/coexistence-patterns.md +59 -0
  56. package/_Sprintpilot/skills/sprintpilot-migrate/resources/strategies.md +43 -0
  57. package/_Sprintpilot/skills/sprintpilot-migrate/templates/component-card.md +11 -0
  58. package/_Sprintpilot/skills/sprintpilot-migrate/templates/migration-epics.md +35 -0
  59. package/_Sprintpilot/skills/sprintpilot-migrate/templates/migration-plan.md +66 -0
  60. package/_Sprintpilot/skills/sprintpilot-migrate/workflow.md +235 -0
  61. package/_Sprintpilot/skills/sprintpilot-party-mode/SKILL.md +6 -0
  62. package/_Sprintpilot/skills/sprintpilot-party-mode/workflow.md +138 -0
  63. package/_Sprintpilot/skills/sprintpilot-research/SKILL.md +6 -0
  64. package/_Sprintpilot/skills/sprintpilot-research/workflow.md +128 -0
  65. package/_Sprintpilot/skills/sprintpilot-reverse-architect/SKILL.md +6 -0
  66. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/component-mapper.md +53 -0
  67. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/data-flow-tracer.md +54 -0
  68. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/pattern-extractor.md +67 -0
  69. package/_Sprintpilot/skills/sprintpilot-reverse-architect/workflow.md +119 -0
  70. package/_Sprintpilot/skills/sprintpilot-update/SKILL.md +6 -0
  71. package/_Sprintpilot/skills/sprintpilot-update/workflow.md +46 -0
  72. package/_Sprintpilot/templates/agent-rules.md +43 -0
  73. package/bin/sprintpilot.js +95 -0
  74. package/lib/commands/check-update.js +54 -0
  75. package/lib/commands/install.js +876 -0
  76. package/lib/commands/uninstall.js +218 -0
  77. package/lib/core/bmad-config.js +113 -0
  78. package/lib/core/file-ops.js +90 -0
  79. package/lib/core/gitignore.js +54 -0
  80. package/lib/core/markers.js +126 -0
  81. package/lib/core/tool-registry.js +73 -0
  82. package/lib/core/update-check.js +39 -0
  83. package/lib/core/v1-detect.js +86 -0
  84. package/lib/prompts.js +82 -0
  85. package/lib/substitute.js +39 -0
  86. package/package.json +49 -0
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { execFile } = require('node:child_process');
5
+ const { promisify } = require('node:util');
6
+ const fs = require('fs-extra');
7
+ const pc = require('picocolors');
8
+
9
+ const { ALL_TOOLS, getToolDir, getSystemPromptFile, getSystemPromptMode } = require('../core/tool-registry');
10
+ const { stripBlock, hasBlock, writeAtomic } = require('../core/markers');
11
+ const {
12
+ V1_ADDON_DIR_NAME,
13
+ V1_SKILL_NAMES,
14
+ detectV1Installation,
15
+ } = require('../core/v1-detect');
16
+
17
+ const execFileAsync = promisify(execFile);
18
+ const ADDON_DIR = path.resolve(__dirname, '..', '..', '_Sprintpilot');
19
+
20
+ async function listSkills() {
21
+ const entries = await fs.readdir(path.join(ADDON_DIR, 'skills'), { withFileTypes: true });
22
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
23
+ }
24
+
25
+ async function removeSystemPrompt(tool, projectRoot) {
26
+ const mode = getSystemPromptMode(tool);
27
+ const promptFileRel = getSystemPromptFile(tool);
28
+
29
+ if (mode === 'claude-code') {
30
+ const agentsFile = path.join(projectRoot, 'AGENTS.md');
31
+ if (await fs.pathExists(agentsFile)) {
32
+ const content = await fs.readFile(agentsFile, 'utf8');
33
+ if (hasBlock(content)) {
34
+ const stripped = stripBlock(content);
35
+ if (!stripped.trim()) {
36
+ await fs.remove(agentsFile);
37
+ console.log(`${tool}: removed AGENTS.md (was Sprintpilot-only)`);
38
+ } else {
39
+ await writeAtomic(agentsFile, stripped);
40
+ console.log(`${tool}: removed Sprintpilot section from AGENTS.md`);
41
+ }
42
+ }
43
+ }
44
+
45
+ const claudeFile = path.join(projectRoot, 'CLAUDE.md');
46
+ if (await fs.pathExists(claudeFile)) {
47
+ const content = await fs.readFile(claudeFile, 'utf8');
48
+ if (content.includes('@AGENTS.md')) {
49
+ const newContent = content.split(/\r?\n/).filter((l) => !l.includes('@AGENTS.md')).join('\n');
50
+ if (!newContent.trim()) {
51
+ await fs.remove(claudeFile);
52
+ console.log(`${tool}: removed CLAUDE.md (was Sprintpilot-only)`);
53
+ } else {
54
+ await writeAtomic(claudeFile, newContent.endsWith('\n') ? newContent : `${newContent}\n`);
55
+ console.log(`${tool}: removed @AGENTS.md from CLAUDE.md`);
56
+ }
57
+ }
58
+ }
59
+ return;
60
+ }
61
+
62
+ if (!promptFileRel) return;
63
+ const promptFile = path.join(projectRoot, promptFileRel);
64
+
65
+ if (mode === 'own-file') {
66
+ if (await fs.pathExists(promptFile)) {
67
+ await fs.remove(promptFile);
68
+ console.log(`${tool}: removed ${promptFileRel}`);
69
+ }
70
+ return;
71
+ }
72
+
73
+ if (mode === 'append') {
74
+ if (!(await fs.pathExists(promptFile))) return;
75
+ const content = await fs.readFile(promptFile, 'utf8');
76
+ if (!hasBlock(content)) return;
77
+ const stripped = stripBlock(content);
78
+ if (!stripped.trim()) {
79
+ await fs.remove(promptFile);
80
+ console.log(`${tool}: removed ${promptFileRel} (was Sprintpilot-only)`);
81
+ } else {
82
+ await writeAtomic(promptFile, stripped);
83
+ console.log(`${tool}: removed Sprintpilot section from ${promptFileRel}`);
84
+ }
85
+ }
86
+ }
87
+
88
+ async function cleanupWorktrees(projectRoot, force) {
89
+ const worktreesDir = path.join(projectRoot, '.worktrees');
90
+ if (!(await fs.pathExists(worktreesDir))) return;
91
+
92
+ console.log('');
93
+ console.log('Cleaning worktrees...');
94
+ const entries = await fs.readdir(worktreesDir, { withFileTypes: true });
95
+ let skipped = 0;
96
+
97
+ for (const entry of entries) {
98
+ if (!entry.isDirectory()) continue;
99
+ const wt = path.join(worktreesDir, entry.name);
100
+
101
+ let dirty = false;
102
+ try {
103
+ const { stdout } = await execFileAsync('git', ['-C', wt, 'status', '--porcelain']);
104
+ dirty = stdout.trim().length > 0;
105
+ } catch {
106
+ dirty = false;
107
+ }
108
+
109
+ if (dirty && !force) {
110
+ console.log(` SKIPPED: ${entry.name} (has uncommitted changes — use --force to override)`);
111
+ skipped++;
112
+ continue;
113
+ }
114
+
115
+ let removed = false;
116
+ try {
117
+ await execFileAsync('git', ['worktree', 'remove', '--force', wt]);
118
+ removed = true;
119
+ } catch {
120
+ // fallback
121
+ }
122
+ if (!removed) {
123
+ await fs.remove(wt);
124
+ }
125
+ console.log(` Removed: ${entry.name}`);
126
+ }
127
+
128
+ try {
129
+ await execFileAsync('git', ['-C', projectRoot, 'worktree', 'prune']);
130
+ } catch {
131
+ // ignore
132
+ }
133
+
134
+ if (skipped > 0) {
135
+ console.log('');
136
+ console.log(pc.yellow(`WARNING: ${skipped} worktree(s) skipped due to uncommitted changes.`));
137
+ console.log(pc.yellow('Remove manually after saving your work.'));
138
+ }
139
+ }
140
+
141
+ async function runUninstall(options = {}) {
142
+ const projectRoot = process.env.BMAD_PROJECT_ROOT || process.cwd();
143
+ const force = !!options.force;
144
+
145
+ console.log('=== Sprintpilot Uninstaller ===');
146
+ console.log('');
147
+
148
+ const skills = await listSkills();
149
+ const skillsToRemove = Array.from(new Set([...skills, ...V1_SKILL_NAMES]));
150
+ let totalRemoved = 0;
151
+
152
+ for (const tool of ALL_TOOLS) {
153
+ const toolDir = getToolDir(tool);
154
+ const skillsDir = path.join(projectRoot, toolDir, 'skills');
155
+ if (!(await fs.pathExists(skillsDir))) continue;
156
+
157
+ let removed = 0;
158
+ for (const skillName of skillsToRemove) {
159
+ const target = path.join(skillsDir, skillName);
160
+ if (await fs.pathExists(target)) {
161
+ await fs.remove(target);
162
+ removed++;
163
+ }
164
+ }
165
+
166
+ if (removed > 0) {
167
+ console.log(`${tool}: removed ${removed} skills from ${toolDir}/skills/`);
168
+ totalRemoved += removed;
169
+ }
170
+
171
+ const backupDir = path.join(projectRoot, toolDir, '.addon-backups');
172
+ if (await fs.pathExists(backupDir)) {
173
+ await fs.remove(backupDir);
174
+ console.log(`${tool}: removed backup directory`);
175
+ }
176
+
177
+ await removeSystemPrompt(tool, projectRoot);
178
+ }
179
+
180
+ if (totalRemoved === 0) {
181
+ console.log('No Sprintpilot skills found in any tool directory.');
182
+ }
183
+
184
+ await cleanupWorktrees(projectRoot, force);
185
+
186
+ const targetAddonDir = path.join(projectRoot, '_Sprintpilot');
187
+ if (await fs.pathExists(targetAddonDir)) {
188
+ await fs.remove(targetAddonDir);
189
+ console.log('');
190
+ console.log('Removed _Sprintpilot/');
191
+ }
192
+
193
+ // Remove the v1 directory only when it carries a v1 signature (manifest
194
+ // name or v1-named skill dirs). An unrelated user directory that happens
195
+ // to be named `_bmad-addons/` must not be silently nuked.
196
+ const v1 = await detectV1Installation(projectRoot);
197
+ if (v1) {
198
+ await fs.remove(v1.v1Dir);
199
+ console.log(`Removed legacy ${V1_ADDON_DIR_NAME}/ (v1 artifact)`);
200
+ } else {
201
+ const legacyAddonDir = path.join(projectRoot, V1_ADDON_DIR_NAME);
202
+ if (await fs.pathExists(legacyAddonDir)) {
203
+ console.log(pc.yellow(`Skipped ${V1_ADDON_DIR_NAME}/ — no v1 signature found (not a Sprintpilot v1 artifact).`));
204
+ }
205
+ }
206
+
207
+ const lockFile = path.join(projectRoot, '.autopilot.lock');
208
+ if (await fs.pathExists(lockFile)) {
209
+ await fs.remove(lockFile);
210
+ console.log('');
211
+ console.log('Removed .autopilot.lock');
212
+ }
213
+
214
+ console.log('');
215
+ console.log(pc.green(`Sprintpilot uninstalled (${totalRemoved} skills removed). BMad Method skills are unaffected.`));
216
+ }
217
+
218
+ module.exports = { runUninstall };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const fs = require('fs-extra');
5
+ const yaml = require('js-yaml');
6
+
7
+ async function readYaml(filePath) {
8
+ let raw;
9
+ try {
10
+ raw = await fs.readFile(filePath, 'utf8');
11
+ } catch {
12
+ // Missing/unreadable: return null silently — callers treat this as "no
13
+ // config here", which is a legitimate state.
14
+ return null;
15
+ }
16
+ try {
17
+ return yaml.load(raw);
18
+ } catch (e) {
19
+ // Distinguish "file absent" from "file malformed" — a silent fallback in
20
+ // the malformed case caused the installer to use the default
21
+ // output_folder instead of the user's configured value.
22
+ // eslint-disable-next-line no-console
23
+ console.warn(`WARN: failed to parse YAML at ${filePath}: ${e.message}`);
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function stripProjectRootPrefix(value) {
29
+ if (typeof value !== 'string') return value;
30
+ return value.replace(/^\{project-root\}\/?/, '');
31
+ }
32
+
33
+ async function verifyBmadInstalled(projectRoot) {
34
+ const manifest = path.join(projectRoot, '_bmad', '_config', 'manifest.yaml');
35
+ if (!(await fs.pathExists(manifest))) return null;
36
+ const data = await readYaml(manifest);
37
+ return data || {};
38
+ }
39
+
40
+ // BMad Method has used two manifest shapes:
41
+ // - flat: { version: "6.3.0" }
42
+ // - nested: { bmad: { version: "6.2.0" } }
43
+ // The shipping v6 installer writes the nested form; some older installs
44
+ // and our own unit-test fixture use the flat form. Try nested first,
45
+ // then fall back to flat so either layout resolves to a real version.
46
+ function extractBmadVersion(data) {
47
+ if (!data) return null;
48
+ if (data.bmad && data.bmad.version) return data.bmad.version;
49
+ if (data.version) return data.version;
50
+ return null;
51
+ }
52
+
53
+ async function readBmadVersion(projectRoot) {
54
+ const data = await verifyBmadInstalled(projectRoot);
55
+ return extractBmadVersion(data);
56
+ }
57
+
58
+ async function readOutputFolder(projectRoot) {
59
+ const bmadDir = path.join(projectRoot, '_bmad');
60
+ if (!(await fs.pathExists(bmadDir))) return '_bmad-output';
61
+
62
+ const modulePriority = ['bmm', 'core', 'bmb', 'cis'];
63
+ const seen = new Set();
64
+ const ordered = [];
65
+
66
+ for (const mod of modulePriority) {
67
+ const candidate = path.join(bmadDir, mod, 'config.yaml');
68
+ ordered.push(candidate);
69
+ seen.add(candidate);
70
+ }
71
+
72
+ try {
73
+ const entries = await fs.readdir(bmadDir, { withFileTypes: true });
74
+ // Sort by name for deterministic precedence across filesystems; otherwise
75
+ // two users on different OSes could resolve different output_folder
76
+ // values when multiple non-priority module configs are present.
77
+ entries.sort((a, b) => a.name.localeCompare(b.name));
78
+ for (const entry of entries) {
79
+ if (!entry.isDirectory()) continue;
80
+ const cfg = path.join(bmadDir, entry.name, 'config.yaml');
81
+ if (!seen.has(cfg)) {
82
+ ordered.push(cfg);
83
+ seen.add(cfg);
84
+ }
85
+ }
86
+ } catch {
87
+ // ignore
88
+ }
89
+
90
+ for (const cfgPath of ordered) {
91
+ if (!(await fs.pathExists(cfgPath))) continue;
92
+ const cfg = await readYaml(cfgPath);
93
+ if (cfg && typeof cfg.output_folder === 'string' && cfg.output_folder.trim()) {
94
+ return stripProjectRootPrefix(cfg.output_folder.trim());
95
+ }
96
+ }
97
+
98
+ return '_bmad-output';
99
+ }
100
+
101
+ async function readAddonManifestVersion(manifestPath) {
102
+ const data = await readYaml(manifestPath);
103
+ return (data && data.addon && data.addon.version) || null;
104
+ }
105
+
106
+ module.exports = {
107
+ readYaml,
108
+ verifyBmadInstalled,
109
+ readBmadVersion,
110
+ extractBmadVersion,
111
+ readOutputFolder,
112
+ readAddonManifestVersion,
113
+ };
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const fs = require('fs-extra');
5
+ const { isTextFile, renderString } = require('../substitute');
6
+
7
+ async function walkFiles(dir) {
8
+ const out = [];
9
+ async function walk(current) {
10
+ const entries = await fs.readdir(current, { withFileTypes: true });
11
+ for (const entry of entries) {
12
+ const full = path.join(current, entry.name);
13
+ if (entry.isDirectory()) {
14
+ await walk(full);
15
+ } else if (entry.isFile()) {
16
+ out.push(full);
17
+ }
18
+ }
19
+ }
20
+ await walk(dir);
21
+ return out;
22
+ }
23
+
24
+ async function copyDirWithSubstitution(src, dest, ctx, { dryRun = false } = {}) {
25
+ if (dryRun) return;
26
+
27
+ await fs.ensureDir(dest);
28
+ const files = await walkFiles(src);
29
+
30
+ for (const file of files) {
31
+ const rel = path.relative(src, file);
32
+ const target = path.join(dest, rel);
33
+ await fs.ensureDir(path.dirname(target));
34
+
35
+ if (ctx && isTextFile(file)) {
36
+ const raw = await fs.readFile(file, 'utf8');
37
+ const rendered = renderString(raw, ctx);
38
+ await fs.writeFile(target, rendered, 'utf8');
39
+ try {
40
+ const mode = (await fs.stat(file)).mode;
41
+ await fs.chmod(target, mode);
42
+ } catch {
43
+ // chmod best-effort
44
+ }
45
+ } else {
46
+ await fs.copy(file, target, { overwrite: true, dereference: false, preserveTimestamps: false });
47
+ }
48
+ }
49
+ }
50
+
51
+ async function copyFileWithSubstitution(src, dest, ctx) {
52
+ await fs.ensureDir(path.dirname(dest));
53
+ if (ctx && isTextFile(src)) {
54
+ const raw = await fs.readFile(src, 'utf8');
55
+ const rendered = renderString(raw, ctx);
56
+ await fs.writeFile(dest, rendered, 'utf8');
57
+ } else {
58
+ await fs.copy(src, dest, { overwrite: true });
59
+ }
60
+ }
61
+
62
+ async function backupSkill(target, backupDir, timestamp) {
63
+ const name = path.basename(target);
64
+ const backup = path.join(backupDir, `${name}.${timestamp}`);
65
+ await fs.ensureDir(backupDir);
66
+ await fs.copy(target, backup);
67
+ return backup;
68
+ }
69
+
70
+ async function pruneBackups(backupDir, skillName, max = 3) {
71
+ if (!(await fs.pathExists(backupDir))) return;
72
+ const prefix = `${skillName}.`;
73
+ const entries = await fs.readdir(backupDir);
74
+ const matches = entries
75
+ .filter((e) => e.startsWith(prefix))
76
+ .sort();
77
+ if (matches.length <= max) return;
78
+ const toRemove = matches.slice(0, matches.length - max);
79
+ for (const name of toRemove) {
80
+ await fs.remove(path.join(backupDir, name));
81
+ }
82
+ }
83
+
84
+ module.exports = {
85
+ walkFiles,
86
+ copyDirWithSubstitution,
87
+ copyFileWithSubstitution,
88
+ backupSkill,
89
+ pruneBackups,
90
+ };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const fs = require('fs-extra');
5
+
6
+ async function resolveIgnoreFile(projectRoot) {
7
+ const gitignore = path.join(projectRoot, '.gitignore');
8
+ if (await fs.pathExists(gitignore)) return { path: gitignore, created: false };
9
+
10
+ const exclude = path.join(projectRoot, '.git', 'info', 'exclude');
11
+ if (await fs.pathExists(exclude)) {
12
+ try {
13
+ const stat = await fs.stat(exclude);
14
+ if (stat.size > 0) {
15
+ return { path: exclude, created: false, usedExclude: true };
16
+ }
17
+ } catch {
18
+ // fall through
19
+ }
20
+ }
21
+
22
+ return { path: gitignore, created: true };
23
+ }
24
+
25
+ async function addIgnoreEntry(ignoreFile, entry, { dryRun = false } = {}) {
26
+ const exists = await fs.pathExists(ignoreFile);
27
+ let content = '';
28
+ if (exists) {
29
+ content = await fs.readFile(ignoreFile, 'utf8');
30
+ const lines = content.split(/\r?\n/);
31
+ if (lines.some((l) => l.trim() === entry.trim())) {
32
+ return { added: false, created: false };
33
+ }
34
+ }
35
+
36
+ if (dryRun) {
37
+ return { added: true, created: !exists, dryRun: true };
38
+ }
39
+
40
+ if (!exists) {
41
+ await fs.ensureDir(path.dirname(ignoreFile));
42
+ await fs.writeFile(ignoreFile, `${entry}\n`, 'utf8');
43
+ return { added: true, created: true };
44
+ }
45
+
46
+ const needsNewline = content.length > 0 && !content.endsWith('\n');
47
+ await fs.appendFile(ignoreFile, `${needsNewline ? '\n' : ''}${entry}\n`, 'utf8');
48
+ return { added: true, created: false };
49
+ }
50
+
51
+ module.exports = {
52
+ resolveIgnoreFile,
53
+ addIgnoreEntry,
54
+ };
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const crypto = require('node:crypto');
5
+ const fs = require('fs-extra');
6
+
7
+ const BEGIN = '<!-- BEGIN:sprintpilot-rules -->';
8
+ const END = '<!-- END:sprintpilot-rules -->';
9
+
10
+ // Legacy markers from bmad-autopilot-addon v1. We never write these, but
11
+ // uninstall / v1 eviction must recognize and strip them.
12
+ const LEGACY_BEGIN = '<!-- BEGIN:bmad-workflow-rules -->';
13
+ const LEGACY_END = '<!-- END:bmad-workflow-rules -->';
14
+
15
+ function escRegex(s) {
16
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
+ }
18
+
19
+ // Match BEGIN / END only at the start of a line (optionally preceded by
20
+ // whitespace) so plain-text mentions of the marker string inside code
21
+ // blocks or documentation don't get mistaken for real delimiters.
22
+ const BEGIN_RE = new RegExp(`^[ \\t]*${escRegex(BEGIN)}[ \\t]*$`, 'm');
23
+ const END_RE_GLOBAL = new RegExp(`^[ \\t]*${escRegex(END)}[ \\t]*$`, 'gm');
24
+ const LEGACY_BEGIN_RE = new RegExp(`^[ \\t]*${escRegex(LEGACY_BEGIN)}[ \\t]*$`, 'm');
25
+ const LEGACY_END_RE_GLOBAL = new RegExp(`^[ \\t]*${escRegex(LEGACY_END)}[ \\t]*$`, 'gm');
26
+
27
+ // Find a BEGIN..END span where both markers sit on their own line. Uses the
28
+ // FIRST line-anchored BEGIN and the FIRST line-anchored END after it.
29
+ //
30
+ // Earlier revisions used the LAST END to aggressively collapse nested /
31
+ // duplicate marker pairs from buggy prior installs. That was unsafe: if a
32
+ // document contained two independent, well-formed blocks with unrelated
33
+ // user content between them, a single strip would delete everything from
34
+ // the first BEGIN through the last END — including the user content.
35
+ // First-END is safe: it strips exactly one block. Duplicate cleanup is
36
+ // still handled because stripBlock iterates until no blocks remain.
37
+ function findSpan(text, beginRe, endReGlobal) {
38
+ const beginMatch = text.match(beginRe);
39
+ if (!beginMatch) return null;
40
+ const start = beginMatch.index;
41
+
42
+ for (const m of text.matchAll(endReGlobal)) {
43
+ if (m.index >= start) return { start, end: m.index + m[0].length };
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function findBlock(text) {
49
+ return findSpan(text, BEGIN_RE, END_RE_GLOBAL);
50
+ }
51
+
52
+ function findLegacyBlock(text) {
53
+ return findSpan(text, LEGACY_BEGIN_RE, LEGACY_END_RE_GLOBAL);
54
+ }
55
+
56
+ function stripSpan(text, block) {
57
+ const before = text.slice(0, block.start).replace(/\s+$/, '');
58
+ const after = text.slice(block.end).replace(/^\s+/, '');
59
+ if (before && after) return `${before}\n\n${after}\n`;
60
+ if (before) return `${before}\n`;
61
+ return after ? after : '';
62
+ }
63
+
64
+ // Strip ALL matching blocks — not just the first — so duplicate/nested
65
+ // blocks from buggy prior installs collapse cleanly. Iterating with
66
+ // first-END semantics preserves any unrelated content between blocks.
67
+ function stripBlock(text) {
68
+ let result = text;
69
+ while (true) {
70
+ const block = findBlock(result);
71
+ if (!block) return result;
72
+ result = stripSpan(result, block);
73
+ }
74
+ }
75
+
76
+ function stripLegacyBlock(text) {
77
+ let result = text;
78
+ while (true) {
79
+ const block = findLegacyBlock(result);
80
+ if (!block) return result;
81
+ result = stripSpan(result, block);
82
+ }
83
+ }
84
+
85
+ function upsertBlock(existingText, block) {
86
+ const baseline = stripBlock(existingText || '').replace(/\s+$/, '');
87
+ const trimmedBlock = block.replace(/^\s+|\s+$/g, '');
88
+ if (!baseline) return `${trimmedBlock}\n`;
89
+ return `${baseline}\n\n${trimmedBlock}\n`;
90
+ }
91
+
92
+ function hasBlock(text) {
93
+ return findBlock(text) !== null;
94
+ }
95
+
96
+ function hasLegacyBlock(text) {
97
+ return findLegacyBlock(text) !== null;
98
+ }
99
+
100
+ async function writeAtomic(filePath, content) {
101
+ await fs.ensureDir(path.dirname(filePath));
102
+ const suffix = crypto.randomBytes(4).toString('hex');
103
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.${suffix}.tmp`;
104
+ try {
105
+ await fs.writeFile(tmp, content, 'utf8');
106
+ await fs.move(tmp, filePath, { overwrite: true });
107
+ } catch (e) {
108
+ try { await fs.remove(tmp); } catch { /* best effort */ }
109
+ throw e;
110
+ }
111
+ }
112
+
113
+ module.exports = {
114
+ BEGIN,
115
+ END,
116
+ LEGACY_BEGIN,
117
+ LEGACY_END,
118
+ findBlock,
119
+ findLegacyBlock,
120
+ stripBlock,
121
+ stripLegacyBlock,
122
+ upsertBlock,
123
+ hasBlock,
124
+ hasLegacyBlock,
125
+ writeAtomic,
126
+ };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const TOOL_DIRS = {
4
+ 'claude-code': '.claude',
5
+ 'cursor': '.cursor',
6
+ 'windsurf': '.windsurf',
7
+ 'cline': '.cline',
8
+ 'roo': '.roo',
9
+ 'trae': '.trae',
10
+ 'kiro': '.kiro',
11
+ 'github-copilot': '.github/copilot',
12
+ 'gemini-cli': '.gemini',
13
+ };
14
+
15
+ const SYSTEM_PROMPT_FILES = {
16
+ 'claude-code': 'AGENTS.md',
17
+ 'cursor': '.cursor/rules/bmad.md',
18
+ 'windsurf': '.windsurfrules',
19
+ 'cline': '.clinerules',
20
+ 'roo': '.roo/rules/bmad.md',
21
+ 'gemini-cli': 'GEMINI.md',
22
+ 'github-copilot': '.github/copilot-instructions.md',
23
+ 'kiro': '.kiro/rules/bmad.md',
24
+ 'trae': '.trae/rules/bmad.md',
25
+ };
26
+
27
+ const SYSTEM_PROMPT_MODES = {
28
+ 'claude-code': 'claude-code',
29
+ 'cursor': 'own-file',
30
+ 'roo': 'own-file',
31
+ 'kiro': 'own-file',
32
+ 'trae': 'own-file',
33
+ 'windsurf': 'append',
34
+ 'cline': 'append',
35
+ 'gemini-cli': 'append',
36
+ 'github-copilot': 'append',
37
+ };
38
+
39
+ const ALL_TOOLS = [
40
+ 'claude-code',
41
+ 'cursor',
42
+ 'windsurf',
43
+ 'gemini-cli',
44
+ 'cline',
45
+ 'roo',
46
+ 'trae',
47
+ 'kiro',
48
+ 'github-copilot',
49
+ ];
50
+
51
+ function getToolDir(tool) {
52
+ return TOOL_DIRS[tool] || '';
53
+ }
54
+
55
+ function getSystemPromptFile(tool) {
56
+ return SYSTEM_PROMPT_FILES[tool] || '';
57
+ }
58
+
59
+ function getSystemPromptMode(tool) {
60
+ return SYSTEM_PROMPT_MODES[tool] || '';
61
+ }
62
+
63
+ function isKnownTool(tool) {
64
+ return Object.prototype.hasOwnProperty.call(TOOL_DIRS, tool);
65
+ }
66
+
67
+ module.exports = {
68
+ ALL_TOOLS,
69
+ getToolDir,
70
+ getSystemPromptFile,
71
+ getSystemPromptMode,
72
+ isKnownTool,
73
+ };