claude-cook 1.10.1

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