@lipter7/blueprint 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.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +626 -0
  3. package/agents/bp-codebase-mapper.md +761 -0
  4. package/agents/bp-debugger.md +1198 -0
  5. package/agents/bp-executor.md +403 -0
  6. package/agents/bp-integration-checker.md +423 -0
  7. package/agents/bp-phase-researcher.md +469 -0
  8. package/agents/bp-plan-checker.md +622 -0
  9. package/agents/bp-planner.md +1157 -0
  10. package/agents/bp-project-researcher.md +618 -0
  11. package/agents/bp-research-synthesizer.md +236 -0
  12. package/agents/bp-roadmapper.md +605 -0
  13. package/agents/bp-verifier.md +523 -0
  14. package/bin/install.js +1754 -0
  15. package/blueprint/bin/blueprint-tools.js +4597 -0
  16. package/blueprint/bin/blueprint-tools.test.js +2033 -0
  17. package/blueprint/references/checkpoints.md +775 -0
  18. package/blueprint/references/continuation-format.md +249 -0
  19. package/blueprint/references/decimal-phase-calculation.md +65 -0
  20. package/blueprint/references/git-integration.md +248 -0
  21. package/blueprint/references/git-planning-commit.md +38 -0
  22. package/blueprint/references/model-profile-resolution.md +32 -0
  23. package/blueprint/references/model-profiles.md +73 -0
  24. package/blueprint/references/phase-argument-parsing.md +61 -0
  25. package/blueprint/references/planning-config.md +194 -0
  26. package/blueprint/references/questioning.md +141 -0
  27. package/blueprint/references/tdd.md +263 -0
  28. package/blueprint/references/ui-brand.md +160 -0
  29. package/blueprint/references/verification-patterns.md +612 -0
  30. package/blueprint/templates/DEBUG.md +159 -0
  31. package/blueprint/templates/UAT.md +247 -0
  32. package/blueprint/templates/codebase/architecture.md +255 -0
  33. package/blueprint/templates/codebase/concerns.md +310 -0
  34. package/blueprint/templates/codebase/conventions.md +307 -0
  35. package/blueprint/templates/codebase/integrations.md +280 -0
  36. package/blueprint/templates/codebase/stack.md +186 -0
  37. package/blueprint/templates/codebase/structure.md +285 -0
  38. package/blueprint/templates/codebase/testing.md +480 -0
  39. package/blueprint/templates/config.json +35 -0
  40. package/blueprint/templates/context.md +283 -0
  41. package/blueprint/templates/continue-here.md +78 -0
  42. package/blueprint/templates/debug-subagent-prompt.md +91 -0
  43. package/blueprint/templates/discovery.md +146 -0
  44. package/blueprint/templates/milestone-archive.md +123 -0
  45. package/blueprint/templates/milestone.md +115 -0
  46. package/blueprint/templates/phase-prompt.md +567 -0
  47. package/blueprint/templates/planner-subagent-prompt.md +117 -0
  48. package/blueprint/templates/project.md +184 -0
  49. package/blueprint/templates/requirements.md +231 -0
  50. package/blueprint/templates/research-project/ARCHITECTURE.md +204 -0
  51. package/blueprint/templates/research-project/FEATURES.md +147 -0
  52. package/blueprint/templates/research-project/PITFALLS.md +200 -0
  53. package/blueprint/templates/research-project/STACK.md +120 -0
  54. package/blueprint/templates/research-project/SUMMARY.md +170 -0
  55. package/blueprint/templates/research.md +552 -0
  56. package/blueprint/templates/roadmap.md +202 -0
  57. package/blueprint/templates/state.md +176 -0
  58. package/blueprint/templates/summary-complex.md +59 -0
  59. package/blueprint/templates/summary-minimal.md +41 -0
  60. package/blueprint/templates/summary-standard.md +48 -0
  61. package/blueprint/templates/summary.md +246 -0
  62. package/blueprint/templates/user-setup.md +311 -0
  63. package/blueprint/templates/verification-report.md +322 -0
  64. package/blueprint/workflows/add-phase.md +111 -0
  65. package/blueprint/workflows/add-todo.md +157 -0
  66. package/blueprint/workflows/audit-milestone.md +241 -0
  67. package/blueprint/workflows/check-todos.md +176 -0
  68. package/blueprint/workflows/complete-milestone.md +644 -0
  69. package/blueprint/workflows/diagnose-issues.md +219 -0
  70. package/blueprint/workflows/discovery-phase.md +289 -0
  71. package/blueprint/workflows/discuss-phase.md +408 -0
  72. package/blueprint/workflows/execute-phase.md +338 -0
  73. package/blueprint/workflows/execute-plan.md +437 -0
  74. package/blueprint/workflows/help.md +470 -0
  75. package/blueprint/workflows/insert-phase.md +129 -0
  76. package/blueprint/workflows/list-phase-assumptions.md +178 -0
  77. package/blueprint/workflows/map-codebase.md +327 -0
  78. package/blueprint/workflows/new-milestone.md +373 -0
  79. package/blueprint/workflows/new-project.md +958 -0
  80. package/blueprint/workflows/pause-work.md +122 -0
  81. package/blueprint/workflows/plan-milestone-gaps.md +256 -0
  82. package/blueprint/workflows/plan-phase.md +376 -0
  83. package/blueprint/workflows/progress.md +385 -0
  84. package/blueprint/workflows/quick.md +230 -0
  85. package/blueprint/workflows/remove-phase.md +154 -0
  86. package/blueprint/workflows/research-phase.md +74 -0
  87. package/blueprint/workflows/resume-project.md +306 -0
  88. package/blueprint/workflows/set-profile.md +80 -0
  89. package/blueprint/workflows/settings.md +145 -0
  90. package/blueprint/workflows/transition.md +493 -0
  91. package/blueprint/workflows/update.md +212 -0
  92. package/blueprint/workflows/verify-phase.md +226 -0
  93. package/blueprint/workflows/verify-work.md +570 -0
  94. package/commands/bp/add-phase.md +39 -0
  95. package/commands/bp/add-todo.md +42 -0
  96. package/commands/bp/audit-milestone.md +42 -0
  97. package/commands/bp/check-todos.md +41 -0
  98. package/commands/bp/complete-milestone.md +136 -0
  99. package/commands/bp/debug.md +162 -0
  100. package/commands/bp/discuss-phase.md +86 -0
  101. package/commands/bp/execute-phase.md +42 -0
  102. package/commands/bp/help.md +22 -0
  103. package/commands/bp/insert-phase.md +33 -0
  104. package/commands/bp/join-discord.md +18 -0
  105. package/commands/bp/list-phase-assumptions.md +50 -0
  106. package/commands/bp/map-codebase.md +71 -0
  107. package/commands/bp/new-milestone.md +51 -0
  108. package/commands/bp/new-project.md +42 -0
  109. package/commands/bp/pause-work.md +35 -0
  110. package/commands/bp/plan-milestone-gaps.md +40 -0
  111. package/commands/bp/plan-phase.md +44 -0
  112. package/commands/bp/progress.md +24 -0
  113. package/commands/bp/quick.md +38 -0
  114. package/commands/bp/reapply-patches.md +110 -0
  115. package/commands/bp/remove-phase.md +32 -0
  116. package/commands/bp/research-phase.md +187 -0
  117. package/commands/bp/resume-work.md +40 -0
  118. package/commands/bp/set-profile.md +34 -0
  119. package/commands/bp/settings.md +36 -0
  120. package/commands/bp/update.md +37 -0
  121. package/commands/bp/verify-work.md +39 -0
  122. package/hooks/dist/bp-check-update.js +62 -0
  123. package/hooks/dist/bp-statusline.js +91 -0
  124. package/package.json +48 -0
  125. package/scripts/build-hooks.js +42 -0
package/bin/install.js ADDED
@@ -0,0 +1,1754 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+ const crypto = require('crypto');
8
+
9
+ // Colors
10
+ const cyan = '\x1b[36m';
11
+ const green = '\x1b[32m';
12
+ const yellow = '\x1b[33m';
13
+ const dim = '\x1b[2m';
14
+ const reset = '\x1b[0m';
15
+
16
+ // Get version from package.json
17
+ const pkg = require('../package.json');
18
+
19
+ // Parse args
20
+ const args = process.argv.slice(2);
21
+ const hasGlobal = args.includes('--global') || args.includes('-g');
22
+ const hasLocal = args.includes('--local') || args.includes('-l');
23
+ const hasOpencode = args.includes('--opencode');
24
+ const hasClaude = args.includes('--claude');
25
+ const hasGemini = args.includes('--gemini');
26
+ const hasBoth = args.includes('--both'); // Legacy flag, keeps working
27
+ const hasAll = args.includes('--all');
28
+ const hasUninstall = args.includes('--uninstall') || args.includes('-u');
29
+
30
+ // Runtime selection - can be set by flags or interactive prompt
31
+ let selectedRuntimes = [];
32
+ if (hasAll) {
33
+ selectedRuntimes = ['claude', 'opencode', 'gemini'];
34
+ } else if (hasBoth) {
35
+ selectedRuntimes = ['claude', 'opencode'];
36
+ } else {
37
+ if (hasOpencode) selectedRuntimes.push('opencode');
38
+ if (hasClaude) selectedRuntimes.push('claude');
39
+ if (hasGemini) selectedRuntimes.push('gemini');
40
+ }
41
+
42
+ // Helper to get directory name for a runtime (used for local/project installs)
43
+ function getDirName(runtime) {
44
+ if (runtime === 'opencode') return '.opencode';
45
+ if (runtime === 'gemini') return '.gemini';
46
+ return '.claude';
47
+ }
48
+
49
+ /**
50
+ * Get the global config directory for OpenCode
51
+ * OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
52
+ * Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
53
+ */
54
+ function getOpencodeGlobalDir() {
55
+ // 1. Explicit OPENCODE_CONFIG_DIR env var
56
+ if (process.env.OPENCODE_CONFIG_DIR) {
57
+ return expandTilde(process.env.OPENCODE_CONFIG_DIR);
58
+ }
59
+
60
+ // 2. OPENCODE_CONFIG env var (use its directory)
61
+ if (process.env.OPENCODE_CONFIG) {
62
+ return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
63
+ }
64
+
65
+ // 3. XDG_CONFIG_HOME/opencode
66
+ if (process.env.XDG_CONFIG_HOME) {
67
+ return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
68
+ }
69
+
70
+ // 4. Default: ~/.config/opencode (XDG default)
71
+ return path.join(os.homedir(), '.config', 'opencode');
72
+ }
73
+
74
+ /**
75
+ * Get the global config directory for a runtime
76
+ * @param {string} runtime - 'claude', 'opencode', or 'gemini'
77
+ * @param {string|null} explicitDir - Explicit directory from --config-dir flag
78
+ */
79
+ function getGlobalDir(runtime, explicitDir = null) {
80
+ if (runtime === 'opencode') {
81
+ // For OpenCode, --config-dir overrides env vars
82
+ if (explicitDir) {
83
+ return expandTilde(explicitDir);
84
+ }
85
+ return getOpencodeGlobalDir();
86
+ }
87
+
88
+ if (runtime === 'gemini') {
89
+ // Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
90
+ if (explicitDir) {
91
+ return expandTilde(explicitDir);
92
+ }
93
+ if (process.env.GEMINI_CONFIG_DIR) {
94
+ return expandTilde(process.env.GEMINI_CONFIG_DIR);
95
+ }
96
+ return path.join(os.homedir(), '.gemini');
97
+ }
98
+
99
+ // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
100
+ if (explicitDir) {
101
+ return expandTilde(explicitDir);
102
+ }
103
+ if (process.env.CLAUDE_CONFIG_DIR) {
104
+ return expandTilde(process.env.CLAUDE_CONFIG_DIR);
105
+ }
106
+ return path.join(os.homedir(), '.claude');
107
+ }
108
+
109
+ const banner = '\n' +
110
+ cyan + ' ██████╗ ██╗ ██╗ ██╗███████╗██████╗ ██████╗ ██╗███╗ ██╗████████╗\n' +
111
+ ' ██╔══██╗██║ ██║ ██║██╔════╝██╔══██╗██╔══██╗██║████╗ ██║╚══██╔══╝\n' +
112
+ ' ██████╔╝██║ ██║ ██║█████╗ ██████╔╝██████╔╝██║██╔██╗ ██║ ██║\n' +
113
+ ' ██╔══██╗██║ ██║ ██║██╔══╝ ██╔═══╝ ██╔══██╗██║██║╚██╗██║ ██║\n' +
114
+ ' ██████╔╝███████╗╚██████╔╝███████╗██║ ██║ ██║██║██║ ╚████║ ██║\n' +
115
+ ' ╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═╝' + reset + '\n' +
116
+ '\n' +
117
+ ' Blueprint ' + dim + 'v' + pkg.version + reset + '\n' +
118
+ ' A meta-prompting, context engineering and spec-driven\n' +
119
+ ' development system for Claude Code, OpenCode, and Gemini by TÂCHES.\n';
120
+
121
+ // Parse --config-dir argument
122
+ function parseConfigDirArg() {
123
+ const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c');
124
+ if (configDirIndex !== -1) {
125
+ const nextArg = args[configDirIndex + 1];
126
+ // Error if --config-dir is provided without a value or next arg is another flag
127
+ if (!nextArg || nextArg.startsWith('-')) {
128
+ console.error(` ${yellow}--config-dir requires a path argument${reset}`);
129
+ process.exit(1);
130
+ }
131
+ return nextArg;
132
+ }
133
+ // Also handle --config-dir=value format
134
+ const configDirArg = args.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c='));
135
+ if (configDirArg) {
136
+ const value = configDirArg.split('=')[1];
137
+ if (!value) {
138
+ console.error(` ${yellow}--config-dir requires a non-empty path${reset}`);
139
+ process.exit(1);
140
+ }
141
+ return value;
142
+ }
143
+ return null;
144
+ }
145
+ const explicitConfigDir = parseConfigDirArg();
146
+ const hasHelp = args.includes('--help') || args.includes('-h');
147
+ const forceStatusline = args.includes('--force-statusline');
148
+
149
+ console.log(banner);
150
+
151
+ // Show help if requested
152
+ if (hasHelp) {
153
+ console.log(` ${yellow}Usage:${reset} npx @lipter7/blueprint [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall Blueprint (remove all Blueprint files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx @lipter7/blueprint\n\n ${dim}# Install for Claude Code globally${reset}\n npx @lipter7/blueprint --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx @lipter7/blueprint --gemini --global\n\n ${dim}# Install for all runtimes globally${reset}\n npx @lipter7/blueprint --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx @lipter7/blueprint --claude --global --config-dir ~/.claude-bc\n\n ${dim}# Install to current project only${reset}\n npx @lipter7/blueprint --claude --local\n\n ${dim}# Uninstall Blueprint from Claude Code globally${reset}\n npx @lipter7/blueprint --claude --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR environment variables.\n`);
154
+ process.exit(0);
155
+ }
156
+
157
+ /**
158
+ * Expand ~ to home directory (shell doesn't expand in env vars passed to node)
159
+ */
160
+ function expandTilde(filePath) {
161
+ if (filePath && filePath.startsWith('~/')) {
162
+ return path.join(os.homedir(), filePath.slice(2));
163
+ }
164
+ return filePath;
165
+ }
166
+
167
+ /**
168
+ * Build a hook command path using forward slashes for cross-platform compatibility.
169
+ * On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path.
170
+ */
171
+ function buildHookCommand(configDir, hookName) {
172
+ // Use forward slashes for Node.js compatibility on all platforms
173
+ const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
174
+ return `node "${hooksPath}"`;
175
+ }
176
+
177
+ /**
178
+ * Read and parse settings.json, returning empty object if it doesn't exist
179
+ */
180
+ function readSettings(settingsPath) {
181
+ if (fs.existsSync(settingsPath)) {
182
+ try {
183
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
184
+ } catch (e) {
185
+ return {};
186
+ }
187
+ }
188
+ return {};
189
+ }
190
+
191
+ /**
192
+ * Write settings.json with proper formatting
193
+ */
194
+ function writeSettings(settingsPath, settings) {
195
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
196
+ }
197
+
198
+ // Cache for attribution settings (populated once per runtime during install)
199
+ const attributionCache = new Map();
200
+
201
+ /**
202
+ * Get commit attribution setting for a runtime
203
+ * @param {string} runtime - 'claude', 'opencode', or 'gemini'
204
+ * @returns {null|undefined|string} null = remove, undefined = keep default, string = custom
205
+ */
206
+ function getCommitAttribution(runtime) {
207
+ // Return cached value if available
208
+ if (attributionCache.has(runtime)) {
209
+ return attributionCache.get(runtime);
210
+ }
211
+
212
+ let result;
213
+
214
+ if (runtime === 'opencode') {
215
+ const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json'));
216
+ result = config.disable_ai_attribution === true ? null : undefined;
217
+ } else if (runtime === 'gemini') {
218
+ // Gemini: check gemini settings.json for attribution config
219
+ const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
220
+ if (!settings.attribution || settings.attribution.commit === undefined) {
221
+ result = undefined;
222
+ } else if (settings.attribution.commit === '') {
223
+ result = null;
224
+ } else {
225
+ result = settings.attribution.commit;
226
+ }
227
+ } else {
228
+ // Claude Code
229
+ const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
230
+ if (!settings.attribution || settings.attribution.commit === undefined) {
231
+ result = undefined;
232
+ } else if (settings.attribution.commit === '') {
233
+ result = null;
234
+ } else {
235
+ result = settings.attribution.commit;
236
+ }
237
+ }
238
+
239
+ // Cache and return
240
+ attributionCache.set(runtime, result);
241
+ return result;
242
+ }
243
+
244
+ /**
245
+ * Process Co-Authored-By lines based on attribution setting
246
+ * @param {string} content - File content to process
247
+ * @param {null|undefined|string} attribution - null=remove, undefined=keep, string=replace
248
+ * @returns {string} Processed content
249
+ */
250
+ function processAttribution(content, attribution) {
251
+ if (attribution === null) {
252
+ // Remove Co-Authored-By lines and the preceding blank line
253
+ return content.replace(/(\r?\n){2}Co-Authored-By:.*$/gim, '');
254
+ }
255
+ if (attribution === undefined) {
256
+ return content;
257
+ }
258
+ // Replace with custom attribution (escape $ to prevent backreference injection)
259
+ const safeAttribution = attribution.replace(/\$/g, '$$$$');
260
+ return content.replace(/Co-Authored-By:.*$/gim, `Co-Authored-By: ${safeAttribution}`);
261
+ }
262
+
263
+ /**
264
+ * Convert Claude Code frontmatter to opencode format
265
+ * - Converts 'allowed-tools:' array to 'permission:' object
266
+ * @param {string} content - Markdown file content with YAML frontmatter
267
+ * @returns {string} - Content with converted frontmatter
268
+ */
269
+ // Color name to hex mapping for opencode compatibility
270
+ const colorNameToHex = {
271
+ cyan: '#00FFFF',
272
+ red: '#FF0000',
273
+ green: '#00FF00',
274
+ blue: '#0000FF',
275
+ yellow: '#FFFF00',
276
+ magenta: '#FF00FF',
277
+ orange: '#FFA500',
278
+ purple: '#800080',
279
+ pink: '#FFC0CB',
280
+ white: '#FFFFFF',
281
+ black: '#000000',
282
+ gray: '#808080',
283
+ grey: '#808080',
284
+ };
285
+
286
+ // Tool name mapping from Claude Code to OpenCode
287
+ // OpenCode uses lowercase tool names; special mappings for renamed tools
288
+ const claudeToOpencodeTools = {
289
+ AskUserQuestion: 'question',
290
+ SlashCommand: 'skill',
291
+ TodoWrite: 'todowrite',
292
+ WebFetch: 'webfetch',
293
+ WebSearch: 'websearch', // Plugin/MCP - keep for compatibility
294
+ };
295
+
296
+ // Tool name mapping from Claude Code to Gemini CLI
297
+ // Gemini CLI uses snake_case built-in tool names
298
+ const claudeToGeminiTools = {
299
+ Read: 'read_file',
300
+ Write: 'write_file',
301
+ Edit: 'replace',
302
+ Bash: 'run_shell_command',
303
+ Glob: 'glob',
304
+ Grep: 'search_file_content',
305
+ WebSearch: 'google_web_search',
306
+ WebFetch: 'web_fetch',
307
+ TodoWrite: 'write_todos',
308
+ AskUserQuestion: 'ask_user',
309
+ };
310
+
311
+ /**
312
+ * Convert a Claude Code tool name to OpenCode format
313
+ * - Applies special mappings (AskUserQuestion -> question, etc.)
314
+ * - Converts to lowercase (except MCP tools which keep their format)
315
+ */
316
+ function convertToolName(claudeTool) {
317
+ // Check for special mapping first
318
+ if (claudeToOpencodeTools[claudeTool]) {
319
+ return claudeToOpencodeTools[claudeTool];
320
+ }
321
+ // MCP tools (mcp__*) keep their format
322
+ if (claudeTool.startsWith('mcp__')) {
323
+ return claudeTool;
324
+ }
325
+ // Default: convert to lowercase
326
+ return claudeTool.toLowerCase();
327
+ }
328
+
329
+ /**
330
+ * Convert a Claude Code tool name to Gemini CLI format
331
+ * - Applies Claude→Gemini mapping (Read→read_file, Bash→run_shell_command, etc.)
332
+ * - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Gemini
333
+ * - Filters out Task — agents are auto-registered as tools in Gemini
334
+ * @returns {string|null} Gemini tool name, or null if tool should be excluded
335
+ */
336
+ function convertGeminiToolName(claudeTool) {
337
+ // MCP tools: exclude — auto-discovered from mcpServers config at runtime
338
+ if (claudeTool.startsWith('mcp__')) {
339
+ return null;
340
+ }
341
+ // Task: exclude — agents are auto-registered as callable tools
342
+ if (claudeTool === 'Task') {
343
+ return null;
344
+ }
345
+ // Check for explicit mapping
346
+ if (claudeToGeminiTools[claudeTool]) {
347
+ return claudeToGeminiTools[claudeTool];
348
+ }
349
+ // Default: lowercase
350
+ return claudeTool.toLowerCase();
351
+ }
352
+
353
+ /**
354
+ * Strip HTML <sub> tags for Gemini CLI output
355
+ * Terminals don't support subscript — Gemini renders these as raw HTML.
356
+ * Converts <sub>text</sub> to italic *(text)* for readable terminal output.
357
+ */
358
+ function stripSubTags(content) {
359
+ return content.replace(/<sub>(.*?)<\/sub>/g, '*($1)*');
360
+ }
361
+
362
+ /**
363
+ * Convert Claude Code agent frontmatter to Gemini CLI format
364
+ * Gemini agents use .md files with YAML frontmatter, same as Claude,
365
+ * but with different field names and formats:
366
+ * - tools: must be a YAML array (not comma-separated string)
367
+ * - tool names: must use Gemini built-in names (read_file, not Read)
368
+ * - color: must be removed (causes validation error)
369
+ * - mcp__* tools: must be excluded (auto-discovered at runtime)
370
+ */
371
+ function convertClaudeToGeminiAgent(content) {
372
+ if (!content.startsWith('---')) return content;
373
+
374
+ const endIndex = content.indexOf('---', 3);
375
+ if (endIndex === -1) return content;
376
+
377
+ const frontmatter = content.substring(3, endIndex).trim();
378
+ const body = content.substring(endIndex + 3);
379
+
380
+ const lines = frontmatter.split('\n');
381
+ const newLines = [];
382
+ let inAllowedTools = false;
383
+ const tools = [];
384
+
385
+ for (const line of lines) {
386
+ const trimmed = line.trim();
387
+
388
+ // Convert allowed-tools YAML array to tools list
389
+ if (trimmed.startsWith('allowed-tools:')) {
390
+ inAllowedTools = true;
391
+ continue;
392
+ }
393
+
394
+ // Handle inline tools: field (comma-separated string)
395
+ if (trimmed.startsWith('tools:')) {
396
+ const toolsValue = trimmed.substring(6).trim();
397
+ if (toolsValue) {
398
+ const parsed = toolsValue.split(',').map(t => t.trim()).filter(t => t);
399
+ for (const t of parsed) {
400
+ const mapped = convertGeminiToolName(t);
401
+ if (mapped) tools.push(mapped);
402
+ }
403
+ } else {
404
+ // tools: with no value means YAML array follows
405
+ inAllowedTools = true;
406
+ }
407
+ continue;
408
+ }
409
+
410
+ // Strip color field (not supported by Gemini CLI, causes validation error)
411
+ if (trimmed.startsWith('color:')) continue;
412
+
413
+ // Collect allowed-tools/tools array items
414
+ if (inAllowedTools) {
415
+ if (trimmed.startsWith('- ')) {
416
+ const mapped = convertGeminiToolName(trimmed.substring(2).trim());
417
+ if (mapped) tools.push(mapped);
418
+ continue;
419
+ } else if (trimmed && !trimmed.startsWith('-')) {
420
+ inAllowedTools = false;
421
+ }
422
+ }
423
+
424
+ if (!inAllowedTools) {
425
+ newLines.push(line);
426
+ }
427
+ }
428
+
429
+ // Add tools as YAML array (Gemini requires array format)
430
+ if (tools.length > 0) {
431
+ newLines.push('tools:');
432
+ for (const tool of tools) {
433
+ newLines.push(` - ${tool}`);
434
+ }
435
+ }
436
+
437
+ const newFrontmatter = newLines.join('\n').trim();
438
+ return `---\n${newFrontmatter}\n---${stripSubTags(body)}`;
439
+ }
440
+
441
+ function convertClaudeToOpencodeFrontmatter(content) {
442
+ // Replace tool name references in content (applies to all files)
443
+ let convertedContent = content;
444
+ convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
445
+ convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
446
+ convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
447
+ // Replace /bp:command with /bp-command for opencode (flat command structure)
448
+ convertedContent = convertedContent.replace(/\/bp:/g, '/bp-');
449
+ // Replace ~/.claude with ~/.config/opencode (OpenCode's correct config location)
450
+ convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/opencode');
451
+
452
+ // Check if content has frontmatter
453
+ if (!convertedContent.startsWith('---')) {
454
+ return convertedContent;
455
+ }
456
+
457
+ // Find the end of frontmatter
458
+ const endIndex = convertedContent.indexOf('---', 3);
459
+ if (endIndex === -1) {
460
+ return convertedContent;
461
+ }
462
+
463
+ const frontmatter = convertedContent.substring(3, endIndex).trim();
464
+ const body = convertedContent.substring(endIndex + 3);
465
+
466
+ // Parse frontmatter line by line (simple YAML parsing)
467
+ const lines = frontmatter.split('\n');
468
+ const newLines = [];
469
+ let inAllowedTools = false;
470
+ const allowedTools = [];
471
+
472
+ for (const line of lines) {
473
+ const trimmed = line.trim();
474
+
475
+ // Detect start of allowed-tools array
476
+ if (trimmed.startsWith('allowed-tools:')) {
477
+ inAllowedTools = true;
478
+ continue;
479
+ }
480
+
481
+ // Detect inline tools: field (comma-separated string)
482
+ if (trimmed.startsWith('tools:')) {
483
+ const toolsValue = trimmed.substring(6).trim();
484
+ if (toolsValue) {
485
+ // Parse comma-separated tools
486
+ const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t);
487
+ allowedTools.push(...tools);
488
+ }
489
+ continue;
490
+ }
491
+
492
+ // Remove name: field - opencode uses filename for command name
493
+ if (trimmed.startsWith('name:')) {
494
+ continue;
495
+ }
496
+
497
+ // Convert color names to hex for opencode
498
+ if (trimmed.startsWith('color:')) {
499
+ const colorValue = trimmed.substring(6).trim().toLowerCase();
500
+ const hexColor = colorNameToHex[colorValue];
501
+ if (hexColor) {
502
+ newLines.push(`color: "${hexColor}"`);
503
+ } else if (colorValue.startsWith('#')) {
504
+ // Validate hex color format (#RGB or #RRGGBB)
505
+ if (/^#[0-9a-f]{3}$|^#[0-9a-f]{6}$/i.test(colorValue)) {
506
+ // Already hex and valid, keep as is
507
+ newLines.push(line);
508
+ }
509
+ // Skip invalid hex colors
510
+ }
511
+ // Skip unknown color names
512
+ continue;
513
+ }
514
+
515
+ // Collect allowed-tools items
516
+ if (inAllowedTools) {
517
+ if (trimmed.startsWith('- ')) {
518
+ allowedTools.push(trimmed.substring(2).trim());
519
+ continue;
520
+ } else if (trimmed && !trimmed.startsWith('-')) {
521
+ // End of array, new field started
522
+ inAllowedTools = false;
523
+ }
524
+ }
525
+
526
+ // Keep other fields (including name: which opencode ignores)
527
+ if (!inAllowedTools) {
528
+ newLines.push(line);
529
+ }
530
+ }
531
+
532
+ // Add tools object if we had allowed-tools or tools
533
+ if (allowedTools.length > 0) {
534
+ newLines.push('tools:');
535
+ for (const tool of allowedTools) {
536
+ newLines.push(` ${convertToolName(tool)}: true`);
537
+ }
538
+ }
539
+
540
+ // Rebuild frontmatter (body already has tool names converted)
541
+ const newFrontmatter = newLines.join('\n').trim();
542
+ return `---\n${newFrontmatter}\n---${body}`;
543
+ }
544
+
545
+ /**
546
+ * Convert Claude Code markdown command to Gemini TOML format
547
+ * @param {string} content - Markdown file content with YAML frontmatter
548
+ * @returns {string} - TOML content
549
+ */
550
+ function convertClaudeToGeminiToml(content) {
551
+ // Check if content has frontmatter
552
+ if (!content.startsWith('---')) {
553
+ return `prompt = ${JSON.stringify(content)}\n`;
554
+ }
555
+
556
+ const endIndex = content.indexOf('---', 3);
557
+ if (endIndex === -1) {
558
+ return `prompt = ${JSON.stringify(content)}\n`;
559
+ }
560
+
561
+ const frontmatter = content.substring(3, endIndex).trim();
562
+ const body = content.substring(endIndex + 3).trim();
563
+
564
+ // Extract description from frontmatter
565
+ let description = '';
566
+ const lines = frontmatter.split('\n');
567
+ for (const line of lines) {
568
+ const trimmed = line.trim();
569
+ if (trimmed.startsWith('description:')) {
570
+ description = trimmed.substring(12).trim();
571
+ break;
572
+ }
573
+ }
574
+
575
+ // Construct TOML
576
+ let toml = '';
577
+ if (description) {
578
+ toml += `description = ${JSON.stringify(description)}\n`;
579
+ }
580
+
581
+ toml += `prompt = ${JSON.stringify(body)}\n`;
582
+
583
+ return toml;
584
+ }
585
+
586
+ /**
587
+ * Copy commands to a flat structure for OpenCode
588
+ * OpenCode expects: command/bp-help.md (invoked as /bp-help)
589
+ * Source structure: commands/bp/help.md
590
+ *
591
+ * @param {string} srcDir - Source directory (e.g., commands/bp/)
592
+ * @param {string} destDir - Destination directory (e.g., command/)
593
+ * @param {string} prefix - Prefix for filenames (e.g., 'bp')
594
+ * @param {string} pathPrefix - Path prefix for file references
595
+ * @param {string} runtime - Target runtime ('claude' or 'opencode')
596
+ */
597
+ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
598
+ if (!fs.existsSync(srcDir)) {
599
+ return;
600
+ }
601
+
602
+ // Remove old bp-*.md files before copying new ones
603
+ if (fs.existsSync(destDir)) {
604
+ for (const file of fs.readdirSync(destDir)) {
605
+ if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
606
+ fs.unlinkSync(path.join(destDir, file));
607
+ }
608
+ }
609
+ } else {
610
+ fs.mkdirSync(destDir, { recursive: true });
611
+ }
612
+
613
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
614
+
615
+ for (const entry of entries) {
616
+ const srcPath = path.join(srcDir, entry.name);
617
+
618
+ if (entry.isDirectory()) {
619
+ // Recurse into subdirectories, adding to prefix
620
+ // e.g., commands/bp/debug/start.md -> command/bp-debug-start.md
621
+ copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
622
+ } else if (entry.name.endsWith('.md')) {
623
+ // Flatten: help.md -> bp-help.md
624
+ const baseName = entry.name.replace('.md', '');
625
+ const destName = `${prefix}-${baseName}.md`;
626
+ const destPath = path.join(destDir, destName);
627
+
628
+ let content = fs.readFileSync(srcPath, 'utf8');
629
+ const claudeDirRegex = /~\/\.claude\//g;
630
+ const opencodeDirRegex = /~\/\.opencode\//g;
631
+ content = content.replace(claudeDirRegex, pathPrefix);
632
+ content = content.replace(opencodeDirRegex, pathPrefix);
633
+ content = processAttribution(content, getCommitAttribution(runtime));
634
+ content = convertClaudeToOpencodeFrontmatter(content);
635
+
636
+ fs.writeFileSync(destPath, content);
637
+ }
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Recursively copy directory, replacing paths in .md files
643
+ * Deletes existing destDir first to remove orphaned files from previous versions
644
+ * @param {string} srcDir - Source directory
645
+ * @param {string} destDir - Destination directory
646
+ * @param {string} pathPrefix - Path prefix for file references
647
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini')
648
+ */
649
+ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) {
650
+ const isOpencode = runtime === 'opencode';
651
+ const dirName = getDirName(runtime);
652
+
653
+ // Clean install: remove existing destination to prevent orphaned files
654
+ if (fs.existsSync(destDir)) {
655
+ fs.rmSync(destDir, { recursive: true });
656
+ }
657
+ fs.mkdirSync(destDir, { recursive: true });
658
+
659
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
660
+
661
+ for (const entry of entries) {
662
+ const srcPath = path.join(srcDir, entry.name);
663
+ const destPath = path.join(destDir, entry.name);
664
+
665
+ if (entry.isDirectory()) {
666
+ copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime);
667
+ } else if (entry.name.endsWith('.md')) {
668
+ // Always replace ~/.claude/ as it is the source of truth in the repo
669
+ let content = fs.readFileSync(srcPath, 'utf8');
670
+ const claudeDirRegex = /~\/\.claude\//g;
671
+ content = content.replace(claudeDirRegex, pathPrefix);
672
+ content = processAttribution(content, getCommitAttribution(runtime));
673
+
674
+ // Convert frontmatter for opencode compatibility
675
+ if (isOpencode) {
676
+ content = convertClaudeToOpencodeFrontmatter(content);
677
+ fs.writeFileSync(destPath, content);
678
+ } else if (runtime === 'gemini') {
679
+ // Convert to TOML for Gemini (strip <sub> tags — terminals can't render subscript)
680
+ content = stripSubTags(content);
681
+ const tomlContent = convertClaudeToGeminiToml(content);
682
+ // Replace extension with .toml
683
+ const tomlPath = destPath.replace(/\.md$/, '.toml');
684
+ fs.writeFileSync(tomlPath, tomlContent);
685
+ } else {
686
+ fs.writeFileSync(destPath, content);
687
+ }
688
+ } else {
689
+ fs.copyFileSync(srcPath, destPath);
690
+ }
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Clean up orphaned files from previous Blueprint versions
696
+ */
697
+ function cleanupOrphanedFiles(configDir) {
698
+ const orphanedFiles = [
699
+ // Old GSD artifacts (clean up if user upgrades from GSD to Blueprint)
700
+ 'hooks/gsd-notify.sh', // GSD hook removed in v1.6.x
701
+ 'hooks/gsd-statusline.js', // GSD hook renamed to bp-statusline.js
702
+ 'hooks/gsd-check-update.js', // GSD hook renamed to bp-check-update.js
703
+ 'hooks/gsd-check-update.sh', // GSD hook renamed to bp-check-update.sh
704
+ // Blueprint orphaned files
705
+ 'hooks/bp-notify.sh', // Removed in v1.6.x
706
+ 'hooks/statusline.js', // Renamed to bp-statusline.js in v1.9.0
707
+ ];
708
+
709
+ for (const relPath of orphanedFiles) {
710
+ const fullPath = path.join(configDir, relPath);
711
+ if (fs.existsSync(fullPath)) {
712
+ fs.unlinkSync(fullPath);
713
+ console.log(` ${green}✓${reset} Removed orphaned ${relPath}`);
714
+ }
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Clean up orphaned hook registrations from settings.json
720
+ */
721
+ function cleanupOrphanedHooks(settings) {
722
+ const orphanedHookPatterns = [
723
+ // Old GSD artifacts (clean up if user upgrades from GSD to Blueprint)
724
+ 'gsd-notify.sh', // GSD hook
725
+ 'gsd-statusline.js', // GSD hook
726
+ 'gsd-check-update.js', // GSD hook
727
+ 'gsd-check-update.sh', // GSD hook
728
+ 'gsd-intel-index.js', // GSD hook
729
+ 'gsd-intel-session.js', // GSD hook
730
+ 'gsd-intel-prune.js', // GSD hook
731
+ // Blueprint orphaned hooks
732
+ 'bp-notify.sh', // Removed in v1.6.x
733
+ 'hooks/statusline.js', // Renamed to bp-statusline.js in v1.9.0
734
+ 'bp-intel-index.js', // Removed in v1.9.2
735
+ 'bp-intel-session.js', // Removed in v1.9.2
736
+ 'bp-intel-prune.js', // Removed in v1.9.2
737
+ ];
738
+
739
+ let cleanedHooks = false;
740
+
741
+ // Check all hook event types (Stop, SessionStart, etc.)
742
+ if (settings.hooks) {
743
+ for (const eventType of Object.keys(settings.hooks)) {
744
+ const hookEntries = settings.hooks[eventType];
745
+ if (Array.isArray(hookEntries)) {
746
+ // Filter out entries that contain orphaned hooks
747
+ const filtered = hookEntries.filter(entry => {
748
+ if (entry.hooks && Array.isArray(entry.hooks)) {
749
+ // Check if any hook in this entry matches orphaned patterns
750
+ const hasOrphaned = entry.hooks.some(h =>
751
+ h.command && orphanedHookPatterns.some(pattern => h.command.includes(pattern))
752
+ );
753
+ if (hasOrphaned) {
754
+ cleanedHooks = true;
755
+ return false; // Remove this entry
756
+ }
757
+ }
758
+ return true; // Keep this entry
759
+ });
760
+ settings.hooks[eventType] = filtered;
761
+ }
762
+ }
763
+ }
764
+
765
+ if (cleanedHooks) {
766
+ console.log(` ${green}✓${reset} Removed orphaned hook registrations`);
767
+ }
768
+
769
+ // Fix #330: Update statusLine if it points to old statusline.js path
770
+ if (settings.statusLine && settings.statusLine.command &&
771
+ settings.statusLine.command.includes('statusline.js') &&
772
+ !settings.statusLine.command.includes('bp-statusline.js')) {
773
+ // Replace old path with new path
774
+ settings.statusLine.command = settings.statusLine.command.replace(
775
+ /statusline\.js/,
776
+ 'bp-statusline.js'
777
+ );
778
+ console.log(` ${green}✓${reset} Updated statusline path (statusline.js → bp-statusline.js)`);
779
+ }
780
+
781
+ return settings;
782
+ }
783
+
784
+ /**
785
+ * Uninstall Blueprint from the specified directory for a specific runtime
786
+ * Removes only Blueprint-specific files/directories, preserves user content
787
+ * @param {boolean} isGlobal - Whether to uninstall from global or local
788
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini')
789
+ */
790
+ function uninstall(isGlobal, runtime = 'claude') {
791
+ const isOpencode = runtime === 'opencode';
792
+ const dirName = getDirName(runtime);
793
+
794
+ // Get the target directory based on runtime and install type
795
+ const targetDir = isGlobal
796
+ ? getGlobalDir(runtime, explicitConfigDir)
797
+ : path.join(process.cwd(), dirName);
798
+
799
+ const locationLabel = isGlobal
800
+ ? targetDir.replace(os.homedir(), '~')
801
+ : targetDir.replace(process.cwd(), '.');
802
+
803
+ let runtimeLabel = 'Claude Code';
804
+ if (runtime === 'opencode') runtimeLabel = 'OpenCode';
805
+ if (runtime === 'gemini') runtimeLabel = 'Gemini';
806
+
807
+ console.log(` Uninstalling Blueprint from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
808
+
809
+ // Check if target directory exists
810
+ if (!fs.existsSync(targetDir)) {
811
+ console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
812
+ console.log(` Nothing to uninstall.\n`);
813
+ return;
814
+ }
815
+
816
+ let removedCount = 0;
817
+
818
+ // 1. Remove Blueprint commands directory
819
+ if (isOpencode) {
820
+ // OpenCode: remove command/bp-*.md files
821
+ const commandDir = path.join(targetDir, 'command');
822
+ if (fs.existsSync(commandDir)) {
823
+ const files = fs.readdirSync(commandDir);
824
+ for (const file of files) {
825
+ if (file.startsWith('bp-') && file.endsWith('.md')) {
826
+ fs.unlinkSync(path.join(commandDir, file));
827
+ removedCount++;
828
+ }
829
+ }
830
+ console.log(` ${green}✓${reset} Removed Blueprint commands from command/`);
831
+ }
832
+ } else {
833
+ // Claude Code & Gemini: remove commands/bp/ directory
834
+ const bpCommandsDir = path.join(targetDir, 'commands', 'bp');
835
+ if (fs.existsSync(bpCommandsDir)) {
836
+ fs.rmSync(bpCommandsDir, { recursive: true });
837
+ removedCount++;
838
+ console.log(` ${green}✓${reset} Removed commands/bp/`);
839
+ }
840
+ }
841
+
842
+ // 2. Remove blueprint directory
843
+ const bpDir = path.join(targetDir, 'blueprint');
844
+ if (fs.existsSync(bpDir)) {
845
+ fs.rmSync(bpDir, { recursive: true });
846
+ removedCount++;
847
+ console.log(` ${green}✓${reset} Removed blueprint/`);
848
+ }
849
+
850
+ // 3. Remove Blueprint agents (bp-*.md files only)
851
+ const agentsDir = path.join(targetDir, 'agents');
852
+ if (fs.existsSync(agentsDir)) {
853
+ const files = fs.readdirSync(agentsDir);
854
+ let agentCount = 0;
855
+ for (const file of files) {
856
+ if (file.startsWith('bp-') && file.endsWith('.md')) {
857
+ fs.unlinkSync(path.join(agentsDir, file));
858
+ agentCount++;
859
+ }
860
+ }
861
+ if (agentCount > 0) {
862
+ removedCount++;
863
+ console.log(` ${green}✓${reset} Removed ${agentCount} Blueprint agents`);
864
+ }
865
+ }
866
+
867
+ // 4. Remove Blueprint hooks
868
+ const hooksDir = path.join(targetDir, 'hooks');
869
+ if (fs.existsSync(hooksDir)) {
870
+ const bpHooks = ['bp-statusline.js', 'bp-check-update.js', 'bp-check-update.sh'];
871
+ let hookCount = 0;
872
+ for (const hook of bpHooks) {
873
+ const hookPath = path.join(hooksDir, hook);
874
+ if (fs.existsSync(hookPath)) {
875
+ fs.unlinkSync(hookPath);
876
+ hookCount++;
877
+ }
878
+ }
879
+ if (hookCount > 0) {
880
+ removedCount++;
881
+ console.log(` ${green}✓${reset} Removed ${hookCount} Blueprint hooks`);
882
+ }
883
+ }
884
+
885
+ // 5. Clean up settings.json (remove Blueprint hooks and statusline)
886
+ const settingsPath = path.join(targetDir, 'settings.json');
887
+ if (fs.existsSync(settingsPath)) {
888
+ let settings = readSettings(settingsPath);
889
+ let settingsModified = false;
890
+
891
+ // Remove Blueprint statusline if it references our hook
892
+ if (settings.statusLine && settings.statusLine.command &&
893
+ settings.statusLine.command.includes('bp-statusline')) {
894
+ delete settings.statusLine;
895
+ settingsModified = true;
896
+ console.log(` ${green}✓${reset} Removed Blueprint statusline from settings`);
897
+ }
898
+
899
+ // Remove Blueprint hooks from SessionStart
900
+ if (settings.hooks && settings.hooks.SessionStart) {
901
+ const before = settings.hooks.SessionStart.length;
902
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry => {
903
+ if (entry.hooks && Array.isArray(entry.hooks)) {
904
+ // Filter out Blueprint hooks
905
+ const hasBpHook = entry.hooks.some(h =>
906
+ h.command && (h.command.includes('bp-check-update') || h.command.includes('bp-statusline'))
907
+ );
908
+ return !hasBpHook;
909
+ }
910
+ return true;
911
+ });
912
+ if (settings.hooks.SessionStart.length < before) {
913
+ settingsModified = true;
914
+ console.log(` ${green}✓${reset} Removed Blueprint hooks from settings`);
915
+ }
916
+ // Clean up empty array
917
+ if (settings.hooks.SessionStart.length === 0) {
918
+ delete settings.hooks.SessionStart;
919
+ }
920
+ // Clean up empty hooks object
921
+ if (Object.keys(settings.hooks).length === 0) {
922
+ delete settings.hooks;
923
+ }
924
+ }
925
+
926
+ if (settingsModified) {
927
+ writeSettings(settingsPath, settings);
928
+ removedCount++;
929
+ }
930
+ }
931
+
932
+ // 6. For OpenCode, clean up permissions from opencode.json
933
+ if (isOpencode) {
934
+ const opencodeConfigDir = getOpencodeGlobalDir();
935
+ const configPath = path.join(opencodeConfigDir, 'opencode.json');
936
+ if (fs.existsSync(configPath)) {
937
+ try {
938
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
939
+ let modified = false;
940
+
941
+ // Remove Blueprint permission entries
942
+ if (config.permission) {
943
+ for (const permType of ['read', 'external_directory']) {
944
+ if (config.permission[permType]) {
945
+ const keys = Object.keys(config.permission[permType]);
946
+ for (const key of keys) {
947
+ if (key.includes('blueprint')) {
948
+ delete config.permission[permType][key];
949
+ modified = true;
950
+ }
951
+ }
952
+ // Clean up empty objects
953
+ if (Object.keys(config.permission[permType]).length === 0) {
954
+ delete config.permission[permType];
955
+ }
956
+ }
957
+ }
958
+ if (Object.keys(config.permission).length === 0) {
959
+ delete config.permission;
960
+ }
961
+ }
962
+
963
+ if (modified) {
964
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
965
+ removedCount++;
966
+ console.log(` ${green}✓${reset} Removed Blueprint permissions from opencode.json`);
967
+ }
968
+ } catch (e) {
969
+ // Ignore JSON parse errors
970
+ }
971
+ }
972
+ }
973
+
974
+ if (removedCount === 0) {
975
+ console.log(` ${yellow}⚠${reset} No Blueprint files found to remove.`);
976
+ }
977
+
978
+ console.log(`
979
+ ${green}Done!${reset} Blueprint has been uninstalled from ${runtimeLabel}.
980
+ Your other files and settings have been preserved.
981
+ `);
982
+ }
983
+
984
+ /**
985
+ * Parse JSONC (JSON with Comments) by stripping comments and trailing commas.
986
+ * OpenCode supports JSONC format via jsonc-parser, so users may have comments.
987
+ * This is a lightweight inline parser to avoid adding dependencies.
988
+ */
989
+ function parseJsonc(content) {
990
+ // Strip BOM if present
991
+ if (content.charCodeAt(0) === 0xFEFF) {
992
+ content = content.slice(1);
993
+ }
994
+
995
+ // Remove single-line and block comments while preserving strings
996
+ let result = '';
997
+ let inString = false;
998
+ let i = 0;
999
+ while (i < content.length) {
1000
+ const char = content[i];
1001
+ const next = content[i + 1];
1002
+
1003
+ if (inString) {
1004
+ result += char;
1005
+ // Handle escape sequences
1006
+ if (char === '\\' && i + 1 < content.length) {
1007
+ result += next;
1008
+ i += 2;
1009
+ continue;
1010
+ }
1011
+ if (char === '"') {
1012
+ inString = false;
1013
+ }
1014
+ i++;
1015
+ } else {
1016
+ if (char === '"') {
1017
+ inString = true;
1018
+ result += char;
1019
+ i++;
1020
+ } else if (char === '/' && next === '/') {
1021
+ // Skip single-line comment until end of line
1022
+ while (i < content.length && content[i] !== '\n') {
1023
+ i++;
1024
+ }
1025
+ } else if (char === '/' && next === '*') {
1026
+ // Skip block comment
1027
+ i += 2;
1028
+ while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) {
1029
+ i++;
1030
+ }
1031
+ i += 2; // Skip closing */
1032
+ } else {
1033
+ result += char;
1034
+ i++;
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ // Remove trailing commas before } or ]
1040
+ result = result.replace(/,(\s*[}\]])/g, '$1');
1041
+
1042
+ return JSON.parse(result);
1043
+ }
1044
+
1045
+ /**
1046
+ * Configure OpenCode permissions to allow reading Blueprint reference docs
1047
+ * This prevents permission prompts when Blueprint accesses the blueprint directory
1048
+ */
1049
+ function configureOpencodePermissions() {
1050
+ // OpenCode config file is at ~/.config/opencode/opencode.json
1051
+ const opencodeConfigDir = getOpencodeGlobalDir();
1052
+ const configPath = path.join(opencodeConfigDir, 'opencode.json');
1053
+
1054
+ // Ensure config directory exists
1055
+ fs.mkdirSync(opencodeConfigDir, { recursive: true });
1056
+
1057
+ // Read existing config or create empty object
1058
+ let config = {};
1059
+ if (fs.existsSync(configPath)) {
1060
+ try {
1061
+ const content = fs.readFileSync(configPath, 'utf8');
1062
+ config = parseJsonc(content);
1063
+ } catch (e) {
1064
+ // Cannot parse - DO NOT overwrite user's config
1065
+ console.log(` ${yellow}⚠${reset} Could not parse opencode.json - skipping permission config`);
1066
+ console.log(` ${dim}Reason: ${e.message}${reset}`);
1067
+ console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
1068
+ return;
1069
+ }
1070
+ }
1071
+
1072
+ // Ensure permission structure exists
1073
+ if (!config.permission) {
1074
+ config.permission = {};
1075
+ }
1076
+
1077
+ // Build the Blueprint path using the actual config directory
1078
+ // Use ~ shorthand if it's in the default location, otherwise use full path
1079
+ const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
1080
+ const bpPath = opencodeConfigDir === defaultConfigDir
1081
+ ? '~/.config/opencode/blueprint/*'
1082
+ : `${opencodeConfigDir.replace(/\\/g, '/')}/blueprint/*`;
1083
+
1084
+ let modified = false;
1085
+
1086
+ // Configure read permission
1087
+ if (!config.permission.read || typeof config.permission.read !== 'object') {
1088
+ config.permission.read = {};
1089
+ }
1090
+ if (config.permission.read[bpPath] !== 'allow') {
1091
+ config.permission.read[bpPath] = 'allow';
1092
+ modified = true;
1093
+ }
1094
+
1095
+ // Configure external_directory permission (the safety guard for paths outside project)
1096
+ if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
1097
+ config.permission.external_directory = {};
1098
+ }
1099
+ if (config.permission.external_directory[bpPath] !== 'allow') {
1100
+ config.permission.external_directory[bpPath] = 'allow';
1101
+ modified = true;
1102
+ }
1103
+
1104
+ if (!modified) {
1105
+ return; // Already configured
1106
+ }
1107
+
1108
+ // Write config back
1109
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1110
+ console.log(` ${green}✓${reset} Configured read permission for Blueprint docs`);
1111
+ }
1112
+
1113
+ /**
1114
+ * Verify a directory exists and contains files
1115
+ */
1116
+ function verifyInstalled(dirPath, description) {
1117
+ if (!fs.existsSync(dirPath)) {
1118
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: directory not created`);
1119
+ return false;
1120
+ }
1121
+ try {
1122
+ const entries = fs.readdirSync(dirPath);
1123
+ if (entries.length === 0) {
1124
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: directory is empty`);
1125
+ return false;
1126
+ }
1127
+ } catch (e) {
1128
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: ${e.message}`);
1129
+ return false;
1130
+ }
1131
+ return true;
1132
+ }
1133
+
1134
+ /**
1135
+ * Verify a file exists
1136
+ */
1137
+ function verifyFileInstalled(filePath, description) {
1138
+ if (!fs.existsSync(filePath)) {
1139
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: file not created`);
1140
+ return false;
1141
+ }
1142
+ return true;
1143
+ }
1144
+
1145
+ /**
1146
+ * Install to the specified directory for a specific runtime
1147
+ * @param {boolean} isGlobal - Whether to install globally or locally
1148
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini')
1149
+ */
1150
+
1151
+ // ──────────────────────────────────────────────────────
1152
+ // Local Patch Persistence
1153
+ // ──────────────────────────────────────────────────────
1154
+
1155
+ const PATCHES_DIR_NAME = 'bp-local-patches';
1156
+ const MANIFEST_NAME = 'bp-file-manifest.json';
1157
+
1158
+ /**
1159
+ * Compute SHA256 hash of file contents
1160
+ */
1161
+ function fileHash(filePath) {
1162
+ const content = fs.readFileSync(filePath);
1163
+ return crypto.createHash('sha256').update(content).digest('hex');
1164
+ }
1165
+
1166
+ /**
1167
+ * Recursively collect all files in dir with their hashes
1168
+ */
1169
+ function generateManifest(dir, baseDir) {
1170
+ if (!baseDir) baseDir = dir;
1171
+ const manifest = {};
1172
+ if (!fs.existsSync(dir)) return manifest;
1173
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1174
+ for (const entry of entries) {
1175
+ const fullPath = path.join(dir, entry.name);
1176
+ const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
1177
+ if (entry.isDirectory()) {
1178
+ Object.assign(manifest, generateManifest(fullPath, baseDir));
1179
+ } else {
1180
+ manifest[relPath] = fileHash(fullPath);
1181
+ }
1182
+ }
1183
+ return manifest;
1184
+ }
1185
+
1186
+ /**
1187
+ * Write file manifest after installation for future modification detection
1188
+ */
1189
+ function writeManifest(configDir) {
1190
+ const bpDir = path.join(configDir, 'blueprint');
1191
+ const commandsDir = path.join(configDir, 'commands', 'bp');
1192
+ const agentsDir = path.join(configDir, 'agents');
1193
+ const manifest = { version: pkg.version, timestamp: new Date().toISOString(), files: {} };
1194
+
1195
+ const bpHashes = generateManifest(bpDir);
1196
+ for (const [rel, hash] of Object.entries(bpHashes)) {
1197
+ manifest.files['blueprint/' + rel] = hash;
1198
+ }
1199
+ if (fs.existsSync(commandsDir)) {
1200
+ const cmdHashes = generateManifest(commandsDir);
1201
+ for (const [rel, hash] of Object.entries(cmdHashes)) {
1202
+ manifest.files['commands/bp/' + rel] = hash;
1203
+ }
1204
+ }
1205
+ if (fs.existsSync(agentsDir)) {
1206
+ for (const file of fs.readdirSync(agentsDir)) {
1207
+ if (file.startsWith('bp-') && file.endsWith('.md')) {
1208
+ manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
1214
+ return manifest;
1215
+ }
1216
+
1217
+ /**
1218
+ * Detect user-modified Blueprint files by comparing against install manifest.
1219
+ * Backs up modified files to bp-local-patches/ for reapply after update.
1220
+ */
1221
+ function saveLocalPatches(configDir) {
1222
+ const manifestPath = path.join(configDir, MANIFEST_NAME);
1223
+ if (!fs.existsSync(manifestPath)) return [];
1224
+
1225
+ let manifest;
1226
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; }
1227
+
1228
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1229
+ const modified = [];
1230
+
1231
+ for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
1232
+ const fullPath = path.join(configDir, relPath);
1233
+ if (!fs.existsSync(fullPath)) continue;
1234
+ const currentHash = fileHash(fullPath);
1235
+ if (currentHash !== originalHash) {
1236
+ const backupPath = path.join(patchesDir, relPath);
1237
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
1238
+ fs.copyFileSync(fullPath, backupPath);
1239
+ modified.push(relPath);
1240
+ }
1241
+ }
1242
+
1243
+ if (modified.length > 0) {
1244
+ const meta = {
1245
+ backed_up_at: new Date().toISOString(),
1246
+ from_version: manifest.version,
1247
+ files: modified
1248
+ };
1249
+ fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
1250
+ console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified Blueprint file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
1251
+ for (const f of modified) {
1252
+ console.log(' ' + dim + f + reset);
1253
+ }
1254
+ }
1255
+ return modified;
1256
+ }
1257
+
1258
+ /**
1259
+ * After install, report backed-up patches for user to reapply.
1260
+ */
1261
+ function reportLocalPatches(configDir) {
1262
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1263
+ const metaPath = path.join(patchesDir, 'backup-meta.json');
1264
+ if (!fs.existsSync(metaPath)) return [];
1265
+
1266
+ let meta;
1267
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
1268
+
1269
+ if (meta.files && meta.files.length > 0) {
1270
+ console.log('');
1271
+ console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
1272
+ for (const f of meta.files) {
1273
+ console.log(' ' + cyan + f + reset);
1274
+ }
1275
+ console.log('');
1276
+ console.log(' Your modifications are saved in ' + cyan + PATCHES_DIR_NAME + '/' + reset);
1277
+ console.log(' Run ' + cyan + '/bp:reapply-patches' + reset + ' to merge them into the new version.');
1278
+ console.log(' Or manually compare and merge the files.');
1279
+ console.log('');
1280
+ }
1281
+ return meta.files || [];
1282
+ }
1283
+
1284
+ function install(isGlobal, runtime = 'claude') {
1285
+ const isOpencode = runtime === 'opencode';
1286
+ const isGemini = runtime === 'gemini';
1287
+ const dirName = getDirName(runtime);
1288
+ const src = path.join(__dirname, '..');
1289
+
1290
+ // Get the target directory based on runtime and install type
1291
+ const targetDir = isGlobal
1292
+ ? getGlobalDir(runtime, explicitConfigDir)
1293
+ : path.join(process.cwd(), dirName);
1294
+
1295
+ const locationLabel = isGlobal
1296
+ ? targetDir.replace(os.homedir(), '~')
1297
+ : targetDir.replace(process.cwd(), '.');
1298
+
1299
+ // Path prefix for file references in markdown content
1300
+ // For global installs: use full path
1301
+ // For local installs: use relative
1302
+ const pathPrefix = isGlobal
1303
+ ? `${targetDir.replace(/\\/g, '/')}/`
1304
+ : `./${dirName}/`;
1305
+
1306
+ let runtimeLabel = 'Claude Code';
1307
+ if (isOpencode) runtimeLabel = 'OpenCode';
1308
+ if (isGemini) runtimeLabel = 'Gemini';
1309
+
1310
+ console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
1311
+
1312
+ // Track installation failures
1313
+ const failures = [];
1314
+
1315
+ // Save any locally modified Blueprint files before they get wiped
1316
+ saveLocalPatches(targetDir);
1317
+
1318
+ // Clean up orphaned files from previous versions
1319
+ cleanupOrphanedFiles(targetDir);
1320
+
1321
+ // OpenCode uses 'command/' (singular) with flat structure
1322
+ // Claude Code & Gemini use 'commands/' (plural) with nested structure
1323
+ if (isOpencode) {
1324
+ // OpenCode: flat structure in command/ directory
1325
+ const commandDir = path.join(targetDir, 'command');
1326
+ fs.mkdirSync(commandDir, { recursive: true });
1327
+
1328
+ // Copy commands/bp/*.md as command/bp-*.md (flatten structure)
1329
+ const bpSrc = path.join(src, 'commands', 'bp');
1330
+ copyFlattenedCommands(bpSrc, commandDir, 'bp', pathPrefix, runtime);
1331
+ if (verifyInstalled(commandDir, 'command/bp-*')) {
1332
+ const count = fs.readdirSync(commandDir).filter(f => f.startsWith('bp-')).length;
1333
+ console.log(` ${green}✓${reset} Installed ${count} commands to command/`);
1334
+ } else {
1335
+ failures.push('command/bp-*');
1336
+ }
1337
+ } else {
1338
+ // Claude Code & Gemini: nested structure in commands/ directory
1339
+ const commandsDir = path.join(targetDir, 'commands');
1340
+ fs.mkdirSync(commandsDir, { recursive: true });
1341
+
1342
+ const bpSrc = path.join(src, 'commands', 'bp');
1343
+ const bpDest = path.join(commandsDir, 'bp');
1344
+ copyWithPathReplacement(bpSrc, bpDest, pathPrefix, runtime);
1345
+ if (verifyInstalled(bpDest, 'commands/bp')) {
1346
+ console.log(` ${green}✓${reset} Installed commands/bp`);
1347
+ } else {
1348
+ failures.push('commands/bp');
1349
+ }
1350
+ }
1351
+
1352
+ // Copy blueprint skill with path replacement
1353
+ const skillSrc = path.join(src, 'blueprint');
1354
+ const skillDest = path.join(targetDir, 'blueprint');
1355
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime);
1356
+ if (verifyInstalled(skillDest, 'blueprint')) {
1357
+ console.log(` ${green}✓${reset} Installed blueprint`);
1358
+ } else {
1359
+ failures.push('blueprint');
1360
+ }
1361
+
1362
+ // Copy agents to agents directory
1363
+ const agentsSrc = path.join(src, 'agents');
1364
+ if (fs.existsSync(agentsSrc)) {
1365
+ const agentsDest = path.join(targetDir, 'agents');
1366
+ fs.mkdirSync(agentsDest, { recursive: true });
1367
+
1368
+ // Remove old Blueprint agents (bp-*.md) before copying new ones
1369
+ if (fs.existsSync(agentsDest)) {
1370
+ for (const file of fs.readdirSync(agentsDest)) {
1371
+ if (file.startsWith('bp-') && file.endsWith('.md')) {
1372
+ fs.unlinkSync(path.join(agentsDest, file));
1373
+ }
1374
+ }
1375
+ }
1376
+
1377
+ // Copy new agents
1378
+ const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
1379
+ for (const entry of agentEntries) {
1380
+ if (entry.isFile() && entry.name.endsWith('.md')) {
1381
+ let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
1382
+ // Always replace ~/.claude/ as it is the source of truth in the repo
1383
+ const dirRegex = /~\/\.claude\//g;
1384
+ content = content.replace(dirRegex, pathPrefix);
1385
+ content = processAttribution(content, getCommitAttribution(runtime));
1386
+ // Convert frontmatter for runtime compatibility
1387
+ if (isOpencode) {
1388
+ content = convertClaudeToOpencodeFrontmatter(content);
1389
+ } else if (isGemini) {
1390
+ content = convertClaudeToGeminiAgent(content);
1391
+ }
1392
+ fs.writeFileSync(path.join(agentsDest, entry.name), content);
1393
+ }
1394
+ }
1395
+ if (verifyInstalled(agentsDest, 'agents')) {
1396
+ console.log(` ${green}✓${reset} Installed agents`);
1397
+ } else {
1398
+ failures.push('agents');
1399
+ }
1400
+ }
1401
+
1402
+ // Copy CHANGELOG.md
1403
+ const changelogSrc = path.join(src, 'CHANGELOG.md');
1404
+ const changelogDest = path.join(targetDir, 'blueprint', 'CHANGELOG.md');
1405
+ if (fs.existsSync(changelogSrc)) {
1406
+ fs.copyFileSync(changelogSrc, changelogDest);
1407
+ if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
1408
+ console.log(` ${green}✓${reset} Installed CHANGELOG.md`);
1409
+ } else {
1410
+ failures.push('CHANGELOG.md');
1411
+ }
1412
+ }
1413
+
1414
+ // Write VERSION file
1415
+ const versionDest = path.join(targetDir, 'blueprint', 'VERSION');
1416
+ fs.writeFileSync(versionDest, pkg.version);
1417
+ if (verifyFileInstalled(versionDest, 'VERSION')) {
1418
+ console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`);
1419
+ } else {
1420
+ failures.push('VERSION');
1421
+ }
1422
+
1423
+ // Copy hooks from dist/ (bundled with dependencies)
1424
+ const hooksSrc = path.join(src, 'hooks', 'dist');
1425
+ if (fs.existsSync(hooksSrc)) {
1426
+ const hooksDest = path.join(targetDir, 'hooks');
1427
+ fs.mkdirSync(hooksDest, { recursive: true });
1428
+ const hookEntries = fs.readdirSync(hooksSrc);
1429
+ for (const entry of hookEntries) {
1430
+ const srcFile = path.join(hooksSrc, entry);
1431
+ if (fs.statSync(srcFile).isFile()) {
1432
+ const destFile = path.join(hooksDest, entry);
1433
+ fs.copyFileSync(srcFile, destFile);
1434
+ }
1435
+ }
1436
+ if (verifyInstalled(hooksDest, 'hooks')) {
1437
+ console.log(` ${green}✓${reset} Installed hooks (bundled)`);
1438
+ } else {
1439
+ failures.push('hooks');
1440
+ }
1441
+ }
1442
+
1443
+ if (failures.length > 0) {
1444
+ console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
1445
+ process.exit(1);
1446
+ }
1447
+
1448
+ // Configure statusline and hooks in settings.json
1449
+ // Gemini shares same hook system as Claude Code for now
1450
+ const settingsPath = path.join(targetDir, 'settings.json');
1451
+ const settings = cleanupOrphanedHooks(readSettings(settingsPath));
1452
+ const statuslineCommand = isGlobal
1453
+ ? buildHookCommand(targetDir, 'bp-statusline.js')
1454
+ : 'node ' + dirName + '/hooks/bp-statusline.js';
1455
+ const updateCheckCommand = isGlobal
1456
+ ? buildHookCommand(targetDir, 'bp-check-update.js')
1457
+ : 'node ' + dirName + '/hooks/bp-check-update.js';
1458
+
1459
+ // Enable experimental agents for Gemini CLI (required for custom sub-agents)
1460
+ if (isGemini) {
1461
+ if (!settings.experimental) {
1462
+ settings.experimental = {};
1463
+ }
1464
+ if (!settings.experimental.enableAgents) {
1465
+ settings.experimental.enableAgents = true;
1466
+ console.log(` ${green}✓${reset} Enabled experimental agents`);
1467
+ }
1468
+ }
1469
+
1470
+ // Configure SessionStart hook for update checking (skip for opencode)
1471
+ if (!isOpencode) {
1472
+ if (!settings.hooks) {
1473
+ settings.hooks = {};
1474
+ }
1475
+ if (!settings.hooks.SessionStart) {
1476
+ settings.hooks.SessionStart = [];
1477
+ }
1478
+
1479
+ const hasBpUpdateHook = settings.hooks.SessionStart.some(entry =>
1480
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('bp-check-update'))
1481
+ );
1482
+
1483
+ if (!hasBpUpdateHook) {
1484
+ settings.hooks.SessionStart.push({
1485
+ hooks: [
1486
+ {
1487
+ type: 'command',
1488
+ command: updateCheckCommand
1489
+ }
1490
+ ]
1491
+ });
1492
+ console.log(` ${green}✓${reset} Configured update check hook`);
1493
+ }
1494
+ }
1495
+
1496
+ // Write file manifest for future modification detection
1497
+ writeManifest(targetDir);
1498
+ console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
1499
+
1500
+ // Report any backed-up local patches
1501
+ reportLocalPatches(targetDir);
1502
+
1503
+ return { settingsPath, settings, statuslineCommand, runtime };
1504
+ }
1505
+
1506
+ /**
1507
+ * Apply statusline config, then print completion message
1508
+ */
1509
+ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude') {
1510
+ const isOpencode = runtime === 'opencode';
1511
+
1512
+ if (shouldInstallStatusline && !isOpencode) {
1513
+ settings.statusLine = {
1514
+ type: 'command',
1515
+ command: statuslineCommand
1516
+ };
1517
+ console.log(` ${green}✓${reset} Configured statusline`);
1518
+ }
1519
+
1520
+ // Always write settings
1521
+ writeSettings(settingsPath, settings);
1522
+
1523
+ // Configure OpenCode permissions
1524
+ if (isOpencode) {
1525
+ configureOpencodePermissions();
1526
+ }
1527
+
1528
+ let program = 'Claude Code';
1529
+ if (runtime === 'opencode') program = 'OpenCode';
1530
+ if (runtime === 'gemini') program = 'Gemini';
1531
+
1532
+ const command = isOpencode ? '/bp-help' : '/bp:help';
1533
+ console.log(`
1534
+ ${green}Done!${reset} Launch ${program} and run ${cyan}${command}${reset}.
1535
+
1536
+ ${cyan}Join the community:${reset} https://discord.gg/5JJgD5svVS
1537
+ `);
1538
+ }
1539
+
1540
+ /**
1541
+ * Handle statusline configuration with optional prompt
1542
+ */
1543
+ function handleStatusline(settings, isInteractive, callback) {
1544
+ const hasExisting = settings.statusLine != null;
1545
+
1546
+ if (!hasExisting) {
1547
+ callback(true);
1548
+ return;
1549
+ }
1550
+
1551
+ if (forceStatusline) {
1552
+ callback(true);
1553
+ return;
1554
+ }
1555
+
1556
+ if (!isInteractive) {
1557
+ console.log(` ${yellow}⚠${reset} Skipping statusline (already configured)`);
1558
+ console.log(` Use ${cyan}--force-statusline${reset} to replace\n`);
1559
+ callback(false);
1560
+ return;
1561
+ }
1562
+
1563
+ const existingCmd = settings.statusLine.command || settings.statusLine.url || '(custom)';
1564
+
1565
+ const rl = readline.createInterface({
1566
+ input: process.stdin,
1567
+ output: process.stdout
1568
+ });
1569
+
1570
+ console.log(`
1571
+ ${yellow}⚠${reset} Existing statusline detected\n
1572
+ Your current statusline:
1573
+ ${dim}command: ${existingCmd}${reset}
1574
+
1575
+ Blueprint includes a statusline showing:
1576
+ • Model name
1577
+ • Current task (from todo list)
1578
+ • Context window usage (color-coded)
1579
+
1580
+ ${cyan}1${reset}) Keep existing
1581
+ ${cyan}2${reset}) Replace with Blueprint statusline
1582
+ `);
1583
+
1584
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
1585
+ rl.close();
1586
+ const choice = answer.trim() || '1';
1587
+ callback(choice === '2');
1588
+ });
1589
+ }
1590
+
1591
+ /**
1592
+ * Prompt for runtime selection
1593
+ */
1594
+ function promptRuntime(callback) {
1595
+ const rl = readline.createInterface({
1596
+ input: process.stdin,
1597
+ output: process.stdout
1598
+ });
1599
+
1600
+ let answered = false;
1601
+
1602
+ rl.on('close', () => {
1603
+ if (!answered) {
1604
+ answered = true;
1605
+ console.log(`\n ${yellow}Installation cancelled${reset}\n`);
1606
+ process.exit(0);
1607
+ }
1608
+ });
1609
+
1610
+ console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
1611
+ ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models
1612
+ ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset}
1613
+ ${cyan}4${reset}) All
1614
+ `);
1615
+
1616
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
1617
+ answered = true;
1618
+ rl.close();
1619
+ const choice = answer.trim() || '1';
1620
+ if (choice === '4') {
1621
+ callback(['claude', 'opencode', 'gemini']);
1622
+ } else if (choice === '3') {
1623
+ callback(['gemini']);
1624
+ } else if (choice === '2') {
1625
+ callback(['opencode']);
1626
+ } else {
1627
+ callback(['claude']);
1628
+ }
1629
+ });
1630
+ }
1631
+
1632
+ /**
1633
+ * Prompt for install location
1634
+ */
1635
+ function promptLocation(runtimes) {
1636
+ if (!process.stdin.isTTY) {
1637
+ console.log(` ${yellow}Non-interactive terminal detected, defaulting to global install${reset}\n`);
1638
+ installAllRuntimes(runtimes, true, false);
1639
+ return;
1640
+ }
1641
+
1642
+ const rl = readline.createInterface({
1643
+ input: process.stdin,
1644
+ output: process.stdout
1645
+ });
1646
+
1647
+ let answered = false;
1648
+
1649
+ rl.on('close', () => {
1650
+ if (!answered) {
1651
+ answered = true;
1652
+ console.log(`\n ${yellow}Installation cancelled${reset}\n`);
1653
+ process.exit(0);
1654
+ }
1655
+ });
1656
+
1657
+ const pathExamples = runtimes.map(r => {
1658
+ const globalPath = getGlobalDir(r, explicitConfigDir);
1659
+ return globalPath.replace(os.homedir(), '~');
1660
+ }).join(', ');
1661
+
1662
+ const localExamples = runtimes.map(r => `./${getDirName(r)}`).join(', ');
1663
+
1664
+ console.log(` ${yellow}Where would you like to install?${reset}\n\n ${cyan}1${reset}) Global ${dim}(${pathExamples})${reset} - available in all projects
1665
+ ${cyan}2${reset}) Local ${dim}(${localExamples})${reset} - this project only
1666
+ `);
1667
+
1668
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
1669
+ answered = true;
1670
+ rl.close();
1671
+ const choice = answer.trim() || '1';
1672
+ const isGlobal = choice !== '2';
1673
+ installAllRuntimes(runtimes, isGlobal, true);
1674
+ });
1675
+ }
1676
+
1677
+ /**
1678
+ * Install Blueprint for all selected runtimes
1679
+ */
1680
+ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
1681
+ const results = [];
1682
+
1683
+ for (const runtime of runtimes) {
1684
+ const result = install(isGlobal, runtime);
1685
+ results.push(result);
1686
+ }
1687
+
1688
+ // Handle statusline for Claude & Gemini (OpenCode uses themes)
1689
+ const claudeResult = results.find(r => r.runtime === 'claude');
1690
+ const geminiResult = results.find(r => r.runtime === 'gemini');
1691
+
1692
+ // Logic: if both are present, ask once if interactive? Or ask for each?
1693
+ // Simpler: Ask once and apply to both if applicable.
1694
+
1695
+ if (claudeResult || geminiResult) {
1696
+ // Use whichever settings exist to check for existing statusline
1697
+ const primaryResult = claudeResult || geminiResult;
1698
+
1699
+ handleStatusline(primaryResult.settings, isInteractive, (shouldInstallStatusline) => {
1700
+ if (claudeResult) {
1701
+ finishInstall(claudeResult.settingsPath, claudeResult.settings, claudeResult.statuslineCommand, shouldInstallStatusline, 'claude');
1702
+ }
1703
+ if (geminiResult) {
1704
+ finishInstall(geminiResult.settingsPath, geminiResult.settings, geminiResult.statuslineCommand, shouldInstallStatusline, 'gemini');
1705
+ }
1706
+
1707
+ const opencodeResult = results.find(r => r.runtime === 'opencode');
1708
+ if (opencodeResult) {
1709
+ finishInstall(opencodeResult.settingsPath, opencodeResult.settings, opencodeResult.statuslineCommand, false, 'opencode');
1710
+ }
1711
+ });
1712
+ } else {
1713
+ // Only OpenCode
1714
+ const opencodeResult = results[0];
1715
+ finishInstall(opencodeResult.settingsPath, opencodeResult.settings, opencodeResult.statuslineCommand, false, 'opencode');
1716
+ }
1717
+ }
1718
+
1719
+ // Main logic
1720
+ if (hasGlobal && hasLocal) {
1721
+ console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
1722
+ process.exit(1);
1723
+ } else if (explicitConfigDir && hasLocal) {
1724
+ console.error(` ${yellow}Cannot use --config-dir with --local${reset}`);
1725
+ process.exit(1);
1726
+ } else if (hasUninstall) {
1727
+ if (!hasGlobal && !hasLocal) {
1728
+ console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
1729
+ process.exit(1);
1730
+ }
1731
+ const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude'];
1732
+ for (const runtime of runtimes) {
1733
+ uninstall(hasGlobal, runtime);
1734
+ }
1735
+ } else if (selectedRuntimes.length > 0) {
1736
+ if (!hasGlobal && !hasLocal) {
1737
+ promptLocation(selectedRuntimes);
1738
+ } else {
1739
+ installAllRuntimes(selectedRuntimes, hasGlobal, false);
1740
+ }
1741
+ } else if (hasGlobal || hasLocal) {
1742
+ // Default to Claude if no runtime specified but location is
1743
+ installAllRuntimes(['claude'], hasGlobal, false);
1744
+ } else {
1745
+ // Interactive
1746
+ if (!process.stdin.isTTY) {
1747
+ console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
1748
+ installAllRuntimes(['claude'], true, false);
1749
+ } else {
1750
+ promptRuntime((runtimes) => {
1751
+ promptLocation(runtimes);
1752
+ });
1753
+ }
1754
+ }