get-shit-done-cc 1.9.3 → 1.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/install.js CHANGED
@@ -15,6 +15,29 @@ const reset = '\x1b[0m';
15
15
  // Get version from package.json
16
16
  const pkg = require('../package.json');
17
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 hasBoth = args.includes('--both');
25
+
26
+ // Runtime selection - can be set by flags or interactive prompt
27
+ let selectedRuntimes = [];
28
+ if (hasBoth) {
29
+ selectedRuntimes = ['claude', 'opencode'];
30
+ } else if (hasOpencode) {
31
+ selectedRuntimes = ['opencode'];
32
+ } else if (hasClaude) {
33
+ selectedRuntimes = ['claude'];
34
+ }
35
+
36
+ // Helper to get directory name for a runtime
37
+ function getDirName(runtime) {
38
+ return runtime === 'opencode' ? '.opencode' : '.claude';
39
+ }
40
+
18
41
  const banner = `
19
42
  ${cyan} ██████╗ ███████╗██████╗
20
43
  ██╔════╝ ██╔════╝██╔══██╗
@@ -25,14 +48,9 @@ ${cyan} ██████╗ ███████╗██████╗
25
48
 
26
49
  Get Shit Done ${dim}v${pkg.version}${reset}
27
50
  A meta-prompting, context engineering and spec-driven
28
- development system for Claude Code by TÂCHES.
51
+ development system for Claude Code (and opencode) by TÂCHES.
29
52
  `;
30
53
 
31
- // Parse args
32
- const args = process.argv.slice(2);
33
- const hasGlobal = args.includes('--global') || args.includes('-g');
34
- const hasLocal = args.includes('--local') || args.includes('-l');
35
-
36
54
  // Parse --config-dir argument
37
55
  function parseConfigDirArg() {
38
56
  const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c');
@@ -68,24 +86,33 @@ if (hasHelp) {
68
86
  console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]
69
87
 
70
88
  ${yellow}Options:${reset}
71
- ${cyan}-g, --global${reset} Install globally (to Claude config directory)
72
- ${cyan}-l, --local${reset} Install locally (to ./.claude in current directory)
73
- ${cyan}-c, --config-dir <path>${reset} Specify custom Claude config directory
89
+ ${cyan}-g, --global${reset} Install globally (to config directory)
90
+ ${cyan}-l, --local${reset} Install locally (to current directory)
91
+ ${cyan}--claude${reset} Install for Claude Code only
92
+ ${cyan}--opencode${reset} Install for OpenCode only
93
+ ${cyan}--both${reset} Install for both Claude Code and OpenCode
94
+ ${cyan}-c, --config-dir <path>${reset} Specify custom config directory
74
95
  ${cyan}-h, --help${reset} Show this help message
75
96
  ${cyan}--force-statusline${reset} Replace existing statusline config
76
97
 
77
98
  ${yellow}Examples:${reset}
78
- ${dim}# Install to default ~/.claude directory${reset}
79
- npx get-shit-done-cc --global
99
+ ${dim}# Interactive install (prompts for runtime and location)${reset}
100
+ npx get-shit-done-cc
101
+
102
+ ${dim}# Install for Claude Code globally${reset}
103
+ npx get-shit-done-cc --claude --global
104
+
105
+ ${dim}# Install for OpenCode globally${reset}
106
+ npx get-shit-done-cc --opencode --global
80
107
 
81
- ${dim}# Install to custom config directory (for multiple Claude accounts)${reset}
82
- npx get-shit-done-cc --global --config-dir ~/.claude-bc
108
+ ${dim}# Install for both runtimes globally${reset}
109
+ npx get-shit-done-cc --both --global
83
110
 
84
- ${dim}# Using environment variable${reset}
85
- CLAUDE_CONFIG_DIR=~/.claude-bc npx get-shit-done-cc --global
111
+ ${dim}# Install to custom config directory${reset}
112
+ npx get-shit-done-cc --claude --global --config-dir ~/.claude-bc
86
113
 
87
114
  ${dim}# Install to current project only${reset}
88
- npx get-shit-done-cc --local
115
+ npx get-shit-done-cc --claude --local
89
116
 
90
117
  ${yellow}Notes:${reset}
91
118
  The --config-dir option is useful when you have multiple Claude Code
@@ -106,7 +133,17 @@ function expandTilde(filePath) {
106
133
  }
107
134
 
108
135
  /**
109
- * Read and parse settings.json, returning empty object if doesn't exist
136
+ * Build a hook command path using forward slashes for cross-platform compatibility.
137
+ * On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path.
138
+ */
139
+ function buildHookCommand(claudeDir, hookName) {
140
+ // Use forward slashes for Node.js compatibility on all platforms
141
+ const hooksPath = claudeDir.replace(/\\/g, '/') + '/hooks/' + hookName;
142
+ return `node "${hooksPath}"`;
143
+ }
144
+
145
+ /**
146
+ * Read and parse settings.json, returning empty object if it doesn't exist
110
147
  */
111
148
  function readSettings(settingsPath) {
112
149
  if (fs.existsSync(settingsPath)) {
@@ -126,11 +163,169 @@ function writeSettings(settingsPath, settings) {
126
163
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
127
164
  }
128
165
 
166
+ /**
167
+ * Convert Claude Code frontmatter to opencode format
168
+ * - Converts 'allowed-tools:' array to 'permission:' object
169
+ * @param {string} content - Markdown file content with YAML frontmatter
170
+ * @returns {string} - Content with converted frontmatter
171
+ */
172
+ // Color name to hex mapping for opencode compatibility
173
+ const colorNameToHex = {
174
+ cyan: '#00FFFF',
175
+ red: '#FF0000',
176
+ green: '#00FF00',
177
+ blue: '#0000FF',
178
+ yellow: '#FFFF00',
179
+ magenta: '#FF00FF',
180
+ orange: '#FFA500',
181
+ purple: '#800080',
182
+ pink: '#FFC0CB',
183
+ white: '#FFFFFF',
184
+ black: '#000000',
185
+ gray: '#808080',
186
+ grey: '#808080',
187
+ };
188
+
189
+ // Tool name mapping from Claude Code to OpenCode
190
+ // OpenCode uses lowercase tool names; special mappings for renamed tools
191
+ const claudeToOpencodeTools = {
192
+ AskUserQuestion: 'question',
193
+ SlashCommand: 'skill',
194
+ TodoWrite: 'todowrite',
195
+ WebFetch: 'webfetch',
196
+ WebSearch: 'websearch', // Plugin/MCP - keep for compatibility
197
+ };
198
+
199
+ /**
200
+ * Convert a Claude Code tool name to OpenCode format
201
+ * - Applies special mappings (AskUserQuestion -> question, etc.)
202
+ * - Converts to lowercase (except MCP tools which keep their format)
203
+ */
204
+ function convertToolName(claudeTool) {
205
+ // Check for special mapping first
206
+ if (claudeToOpencodeTools[claudeTool]) {
207
+ return claudeToOpencodeTools[claudeTool];
208
+ }
209
+ // MCP tools (mcp__*) keep their format
210
+ if (claudeTool.startsWith('mcp__')) {
211
+ return claudeTool;
212
+ }
213
+ // Default: convert to lowercase
214
+ return claudeTool.toLowerCase();
215
+ }
216
+
217
+ function convertClaudeToOpencodeFrontmatter(content) {
218
+ // Replace tool name references in content (applies to all files)
219
+ let convertedContent = content;
220
+ convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
221
+ convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
222
+ convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
223
+ // Replace /gsd:command with /gsd/command for opencode
224
+ convertedContent = convertedContent.replace(/\/gsd:/g, '/gsd/');
225
+ // Replace ~/.claude with ~/.opencode
226
+ convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.opencode');
227
+
228
+ // Check if content has frontmatter
229
+ if (!convertedContent.startsWith('---')) {
230
+ return convertedContent;
231
+ }
232
+
233
+ // Find the end of frontmatter
234
+ const endIndex = convertedContent.indexOf('---', 3);
235
+ if (endIndex === -1) {
236
+ return convertedContent;
237
+ }
238
+
239
+ const frontmatter = convertedContent.substring(3, endIndex).trim();
240
+ const body = convertedContent.substring(endIndex + 3);
241
+
242
+ // Parse frontmatter line by line (simple YAML parsing)
243
+ const lines = frontmatter.split('\n');
244
+ const newLines = [];
245
+ let inAllowedTools = false;
246
+ const allowedTools = [];
247
+
248
+ for (const line of lines) {
249
+ const trimmed = line.trim();
250
+
251
+ // Detect start of allowed-tools array
252
+ if (trimmed.startsWith('allowed-tools:')) {
253
+ inAllowedTools = true;
254
+ continue;
255
+ }
256
+
257
+ // Detect inline tools: field (comma-separated string)
258
+ if (trimmed.startsWith('tools:')) {
259
+ const toolsValue = trimmed.substring(6).trim();
260
+ if (toolsValue) {
261
+ // Parse comma-separated tools
262
+ const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t);
263
+ allowedTools.push(...tools);
264
+ }
265
+ continue;
266
+ }
267
+
268
+ // Remove name: field - opencode uses filename for command name
269
+ if (trimmed.startsWith('name:')) {
270
+ continue;
271
+ }
272
+
273
+ // Convert color names to hex for opencode
274
+ if (trimmed.startsWith('color:')) {
275
+ const colorValue = trimmed.substring(6).trim().toLowerCase();
276
+ const hexColor = colorNameToHex[colorValue];
277
+ if (hexColor) {
278
+ newLines.push(`color: "${hexColor}"`);
279
+ } else if (colorValue.startsWith('#')) {
280
+ // Already hex, keep as is
281
+ newLines.push(line);
282
+ }
283
+ // Skip unknown color names
284
+ continue;
285
+ }
286
+
287
+ // Collect allowed-tools items
288
+ if (inAllowedTools) {
289
+ if (trimmed.startsWith('- ')) {
290
+ allowedTools.push(trimmed.substring(2).trim());
291
+ continue;
292
+ } else if (trimmed && !trimmed.startsWith('-')) {
293
+ // End of array, new field started
294
+ inAllowedTools = false;
295
+ }
296
+ }
297
+
298
+ // Keep other fields (including name: which opencode ignores)
299
+ if (!inAllowedTools) {
300
+ newLines.push(line);
301
+ }
302
+ }
303
+
304
+ // Add tools object if we had allowed-tools or tools
305
+ if (allowedTools.length > 0) {
306
+ newLines.push('tools:');
307
+ for (const tool of allowedTools) {
308
+ newLines.push(` ${convertToolName(tool)}: true`);
309
+ }
310
+ }
311
+
312
+ // Rebuild frontmatter (body already has tool names converted)
313
+ const newFrontmatter = newLines.join('\n').trim();
314
+ return `---\n${newFrontmatter}\n---${body}`;
315
+ }
316
+
129
317
  /**
130
318
  * Recursively copy directory, replacing paths in .md files
131
319
  * Deletes existing destDir first to remove orphaned files from previous versions
320
+ * @param {string} srcDir - Source directory
321
+ * @param {string} destDir - Destination directory
322
+ * @param {string} pathPrefix - Path prefix for file references
323
+ * @param {string} runtime - Target runtime ('claude' or 'opencode')
132
324
  */
133
- function copyWithPathReplacement(srcDir, destDir, pathPrefix) {
325
+ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) {
326
+ const isOpencode = runtime === 'opencode';
327
+ const dirName = getDirName(runtime);
328
+
134
329
  // Clean install: remove existing destination to prevent orphaned files
135
330
  if (fs.existsSync(destDir)) {
136
331
  fs.rmSync(destDir, { recursive: true });
@@ -144,11 +339,16 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix) {
144
339
  const destPath = path.join(destDir, entry.name);
145
340
 
146
341
  if (entry.isDirectory()) {
147
- copyWithPathReplacement(srcPath, destPath, pathPrefix);
342
+ copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime);
148
343
  } else if (entry.name.endsWith('.md')) {
149
- // Replace ~/.claude/ with the appropriate prefix in markdown files
344
+ // Replace ~/.claude/ with the appropriate prefix in Markdown files
150
345
  let content = fs.readFileSync(srcPath, 'utf8');
151
- content = content.replace(/~\/\.claude\//g, pathPrefix);
346
+ const claudeDirRegex = new RegExp(`~/${dirName.replace('.', '\\.')}/`, 'g');
347
+ content = content.replace(claudeDirRegex, pathPrefix);
348
+ // Convert frontmatter for opencode compatibility
349
+ if (isOpencode) {
350
+ content = convertClaudeToOpencodeFrontmatter(content);
351
+ }
152
352
  fs.writeFileSync(destPath, content);
153
353
  } else {
154
354
  fs.copyFileSync(srcPath, destPath);
@@ -219,6 +419,59 @@ function cleanupOrphanedHooks(settings) {
219
419
  return settings;
220
420
  }
221
421
 
422
+ /**
423
+ * Configure OpenCode permissions to allow reading GSD reference docs
424
+ * This prevents permission prompts when GSD accesses ~/.opencode/get-shit-done/
425
+ */
426
+ function configureOpencodePermissions() {
427
+ const configPath = path.join(os.homedir(), '.opencode.json');
428
+
429
+ // Read existing config or create empty object
430
+ let config = {};
431
+ if (fs.existsSync(configPath)) {
432
+ try {
433
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
434
+ } catch (e) {
435
+ // Invalid JSON - start fresh but warn user
436
+ console.log(` ${yellow}⚠${reset} ~/.opencode.json had invalid JSON, recreating`);
437
+ }
438
+ }
439
+
440
+ // Ensure permission structure exists
441
+ if (!config.permission) {
442
+ config.permission = {};
443
+ }
444
+
445
+ const gsdPath = '~/.opencode/get-shit-done/*';
446
+ let modified = false;
447
+
448
+ // Configure read permission
449
+ if (!config.permission.read || typeof config.permission.read !== 'object') {
450
+ config.permission.read = {};
451
+ }
452
+ if (config.permission.read[gsdPath] !== 'allow') {
453
+ config.permission.read[gsdPath] = 'allow';
454
+ modified = true;
455
+ }
456
+
457
+ // Configure external_directory permission (the safety guard for paths outside project)
458
+ if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
459
+ config.permission.external_directory = {};
460
+ }
461
+ if (config.permission.external_directory[gsdPath] !== 'allow') {
462
+ config.permission.external_directory[gsdPath] = 'allow';
463
+ modified = true;
464
+ }
465
+
466
+ if (!modified) {
467
+ return; // Already configured
468
+ }
469
+
470
+ // Write config back
471
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
472
+ console.log(` ${green}✓${reset} Configured read permission for GSD docs`);
473
+ }
474
+
222
475
  /**
223
476
  * Verify a directory exists and contains files
224
477
  */
@@ -252,43 +505,49 @@ function verifyFileInstalled(filePath, description) {
252
505
  }
253
506
 
254
507
  /**
255
- * Install to the specified directory
508
+ * Install to the specified directory for a specific runtime
509
+ * @param {boolean} isGlobal - Whether to install globally or locally
510
+ * @param {string} runtime - Target runtime ('claude' or 'opencode')
256
511
  */
257
- function install(isGlobal) {
512
+ function install(isGlobal, runtime = 'claude') {
513
+ const isOpencode = runtime === 'opencode';
514
+ const dirName = getDirName(runtime);
258
515
  const src = path.join(__dirname, '..');
259
- // Priority: explicit --config-dir arg > CLAUDE_CONFIG_DIR env var > default ~/.claude
516
+
517
+ // Priority: explicit --config-dir arg > CLAUDE_CONFIG_DIR env var > default dir
260
518
  const configDir = expandTilde(explicitConfigDir) || expandTilde(process.env.CLAUDE_CONFIG_DIR);
261
- const defaultGlobalDir = configDir || path.join(os.homedir(), '.claude');
262
- const claudeDir = isGlobal
519
+ const defaultGlobalDir = configDir || path.join(os.homedir(), dirName);
520
+ const targetDir = isGlobal
263
521
  ? defaultGlobalDir
264
- : path.join(process.cwd(), '.claude');
522
+ : path.join(process.cwd(), dirName);
265
523
 
266
524
  const locationLabel = isGlobal
267
- ? claudeDir.replace(os.homedir(), '~')
268
- : claudeDir.replace(process.cwd(), '.');
525
+ ? targetDir.replace(os.homedir(), '~')
526
+ : targetDir.replace(process.cwd(), '.');
269
527
 
270
528
  // Path prefix for file references
271
529
  // Use actual path when CLAUDE_CONFIG_DIR is set, otherwise use ~ shorthand
272
530
  const pathPrefix = isGlobal
273
- ? (configDir ? `${claudeDir}/` : '~/.claude/')
274
- : './.claude/';
531
+ ? (configDir ? `${targetDir}/` : `~/${dirName}/`)
532
+ : `./${dirName}/`;
275
533
 
276
- console.log(` Installing to ${cyan}${locationLabel}${reset}\n`);
534
+ const runtimeLabel = isOpencode ? 'OpenCode' : 'Claude Code';
535
+ console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
277
536
 
278
537
  // Track installation failures
279
538
  const failures = [];
280
539
 
281
540
  // Clean up orphaned files from previous versions
282
- cleanupOrphanedFiles(claudeDir);
541
+ cleanupOrphanedFiles(targetDir);
283
542
 
284
543
  // Create commands directory
285
- const commandsDir = path.join(claudeDir, 'commands');
544
+ const commandsDir = path.join(targetDir, 'commands');
286
545
  fs.mkdirSync(commandsDir, { recursive: true });
287
546
 
288
547
  // Copy commands/gsd with path replacement
289
548
  const gsdSrc = path.join(src, 'commands', 'gsd');
290
549
  const gsdDest = path.join(commandsDir, 'gsd');
291
- copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix);
550
+ copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime);
292
551
  if (verifyInstalled(gsdDest, 'commands/gsd')) {
293
552
  console.log(` ${green}✓${reset} Installed commands/gsd`);
294
553
  } else {
@@ -297,19 +556,19 @@ function install(isGlobal) {
297
556
 
298
557
  // Copy get-shit-done skill with path replacement
299
558
  const skillSrc = path.join(src, 'get-shit-done');
300
- const skillDest = path.join(claudeDir, 'get-shit-done');
301
- copyWithPathReplacement(skillSrc, skillDest, pathPrefix);
559
+ const skillDest = path.join(targetDir, 'get-shit-done');
560
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime);
302
561
  if (verifyInstalled(skillDest, 'get-shit-done')) {
303
562
  console.log(` ${green}✓${reset} Installed get-shit-done`);
304
563
  } else {
305
564
  failures.push('get-shit-done');
306
565
  }
307
566
 
308
- // Copy agents to ~/.claude/agents (subagents must be at root level)
567
+ // Copy agents to agents directory (subagents must be at root level)
309
568
  // Only delete gsd-*.md files to preserve user's custom agents
310
569
  const agentsSrc = path.join(src, 'agents');
311
570
  if (fs.existsSync(agentsSrc)) {
312
- const agentsDest = path.join(claudeDir, 'agents');
571
+ const agentsDest = path.join(targetDir, 'agents');
313
572
  fs.mkdirSync(agentsDest, { recursive: true });
314
573
 
315
574
  // Remove old GSD agents (gsd-*.md) before copying new ones
@@ -326,7 +585,12 @@ function install(isGlobal) {
326
585
  for (const entry of agentEntries) {
327
586
  if (entry.isFile() && entry.name.endsWith('.md')) {
328
587
  let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
329
- content = content.replace(/~\/\.claude\//g, pathPrefix);
588
+ const dirRegex = new RegExp(`~/${dirName.replace('.', '\\.')}/`, 'g');
589
+ content = content.replace(dirRegex, pathPrefix);
590
+ // Convert frontmatter for opencode compatibility
591
+ if (isOpencode) {
592
+ content = convertClaudeToOpencodeFrontmatter(content);
593
+ }
330
594
  fs.writeFileSync(path.join(agentsDest, entry.name), content);
331
595
  }
332
596
  }
@@ -339,7 +603,7 @@ function install(isGlobal) {
339
603
 
340
604
  // Copy CHANGELOG.md
341
605
  const changelogSrc = path.join(src, 'CHANGELOG.md');
342
- const changelogDest = path.join(claudeDir, 'get-shit-done', 'CHANGELOG.md');
606
+ const changelogDest = path.join(targetDir, 'get-shit-done', 'CHANGELOG.md');
343
607
  if (fs.existsSync(changelogSrc)) {
344
608
  fs.copyFileSync(changelogSrc, changelogDest);
345
609
  if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
@@ -350,7 +614,7 @@ function install(isGlobal) {
350
614
  }
351
615
 
352
616
  // Write VERSION file for whats-new command
353
- const versionDest = path.join(claudeDir, 'get-shit-done', 'VERSION');
617
+ const versionDest = path.join(targetDir, 'get-shit-done', 'VERSION');
354
618
  fs.writeFileSync(versionDest, pkg.version);
355
619
  if (verifyFileInstalled(versionDest, 'VERSION')) {
356
620
  console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`);
@@ -361,7 +625,7 @@ function install(isGlobal) {
361
625
  // Copy hooks from dist/ (bundled with dependencies)
362
626
  const hooksSrc = path.join(src, 'hooks', 'dist');
363
627
  if (fs.existsSync(hooksSrc)) {
364
- const hooksDest = path.join(claudeDir, 'hooks');
628
+ const hooksDest = path.join(targetDir, 'hooks');
365
629
  fs.mkdirSync(hooksDest, { recursive: true });
366
630
  const hookEntries = fs.readdirSync(hooksSrc);
367
631
  for (const entry of hookEntries) {
@@ -387,48 +651,57 @@ function install(isGlobal) {
387
651
  }
388
652
 
389
653
  // Configure statusline and hooks in settings.json
390
- const settingsPath = path.join(claudeDir, 'settings.json');
654
+ const settingsPath = path.join(targetDir, 'settings.json');
391
655
  const settings = cleanupOrphanedHooks(readSettings(settingsPath));
392
656
  const statuslineCommand = isGlobal
393
- ? 'node "$HOME/.claude/hooks/gsd-statusline.js"'
394
- : 'node .claude/hooks/gsd-statusline.js';
657
+ ? buildHookCommand(targetDir, 'gsd-statusline.js')
658
+ : 'node ' + dirName + '/hooks/gsd-statusline.js';
395
659
  const updateCheckCommand = isGlobal
396
- ? 'node "$HOME/.claude/hooks/gsd-check-update.js"'
397
- : 'node .claude/hooks/gsd-check-update.js';
660
+ ? buildHookCommand(targetDir, 'gsd-check-update.js')
661
+ : 'node ' + dirName + '/hooks/gsd-check-update.js';
398
662
 
399
- // Configure SessionStart hook for update checking
400
- if (!settings.hooks) {
401
- settings.hooks = {};
402
- }
403
- if (!settings.hooks.SessionStart) {
404
- settings.hooks.SessionStart = [];
405
- }
406
-
407
- // Check if GSD update hook already exists
408
- const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
409
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-check-update'))
410
- );
663
+ // Configure SessionStart hook for update checking (skip for opencode - different hook system)
664
+ if (!isOpencode) {
665
+ if (!settings.hooks) {
666
+ settings.hooks = {};
667
+ }
668
+ if (!settings.hooks.SessionStart) {
669
+ settings.hooks.SessionStart = [];
670
+ }
411
671
 
412
- if (!hasGsdUpdateHook) {
413
- settings.hooks.SessionStart.push({
414
- hooks: [
415
- {
416
- type: 'command',
417
- command: updateCheckCommand
418
- }
419
- ]
420
- });
421
- console.log(` ${green}✓${reset} Configured update check hook`);
672
+ // Check if GSD update hook already exists
673
+ const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
674
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-check-update'))
675
+ );
676
+
677
+ if (!hasGsdUpdateHook) {
678
+ settings.hooks.SessionStart.push({
679
+ hooks: [
680
+ {
681
+ type: 'command',
682
+ command: updateCheckCommand
683
+ }
684
+ ]
685
+ });
686
+ console.log(` ${green}✓${reset} Configured update check hook`);
687
+ }
422
688
  }
423
689
 
424
- return { settingsPath, settings, statuslineCommand };
690
+ return { settingsPath, settings, statuslineCommand, runtime };
425
691
  }
426
692
 
427
693
  /**
428
694
  * Apply statusline config, then print completion message
695
+ * @param {string} settingsPath - Path to settings.json
696
+ * @param {object} settings - Settings object
697
+ * @param {string} statuslineCommand - Statusline command
698
+ * @param {boolean} shouldInstallStatusline - Whether to install statusline
699
+ * @param {string} runtime - Target runtime ('claude' or 'opencode')
429
700
  */
430
- function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline) {
431
- if (shouldInstallStatusline) {
701
+ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude') {
702
+ const isOpencode = runtime === 'opencode';
703
+
704
+ if (shouldInstallStatusline && !isOpencode) {
432
705
  settings.statusLine = {
433
706
  type: 'command',
434
707
  command: statuslineCommand
@@ -439,8 +712,15 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
439
712
  // Always write settings (hooks were already configured in install())
440
713
  writeSettings(settingsPath, settings);
441
714
 
715
+ // Configure OpenCode permissions if needed
716
+ if (isOpencode) {
717
+ configureOpencodePermissions();
718
+ }
719
+
720
+ const program = isOpencode ? 'OpenCode' : 'Claude Code';
721
+ const command = isOpencode ? '/gsd/help' : '/gsd:help';
442
722
  console.log(`
443
- ${green}Done!${reset} Launch Claude Code and run ${cyan}/gsd:help${reset}.
723
+ ${green}Done!${reset} Launch ${program} and run ${cyan}${command}${reset}.
444
724
  `);
445
725
  }
446
726
 
@@ -500,18 +780,57 @@ function handleStatusline(settings, isInteractive, callback) {
500
780
  });
501
781
  }
502
782
 
783
+ /**
784
+ * Prompt for runtime selection (Claude Code / OpenCode / Both)
785
+ * @param {function} callback - Called with array of selected runtimes
786
+ */
787
+ function promptRuntime(callback) {
788
+ const rl = readline.createInterface({
789
+ input: process.stdin,
790
+ output: process.stdout
791
+ });
792
+
793
+ let answered = false;
794
+
795
+ rl.on('close', () => {
796
+ if (!answered) {
797
+ answered = true;
798
+ console.log(`\n ${yellow}Installation cancelled${reset}\n`);
799
+ process.exit(0);
800
+ }
801
+ });
802
+
803
+ console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}
804
+
805
+ ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
806
+ ${cyan}2${reset}) OpenCode ${dim}(~/.opencode)${reset} - open source, free models
807
+ ${cyan}3${reset}) Both
808
+ `);
809
+
810
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
811
+ answered = true;
812
+ rl.close();
813
+ const choice = answer.trim() || '1';
814
+ if (choice === '3') {
815
+ callback(['claude', 'opencode']);
816
+ } else if (choice === '2') {
817
+ callback(['opencode']);
818
+ } else {
819
+ callback(['claude']);
820
+ }
821
+ });
822
+ }
823
+
503
824
  /**
504
825
  * Prompt for install location
826
+ * @param {string[]} runtimes - Array of runtimes to install for
505
827
  */
506
- function promptLocation() {
828
+ function promptLocation(runtimes) {
507
829
  // Check if stdin is a TTY - if not, fall back to global install
508
830
  // This handles npx execution in environments like WSL2 where stdin may not be properly connected
509
831
  if (!process.stdin.isTTY) {
510
832
  console.log(` ${yellow}Non-interactive terminal detected, defaulting to global install${reset}\n`);
511
- const { settingsPath, settings, statuslineCommand } = install(true);
512
- handleStatusline(settings, false, (shouldInstallStatusline) => {
513
- finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline);
514
- });
833
+ installAllRuntimes(runtimes, true, false);
515
834
  return;
516
835
  }
517
836
 
@@ -523,26 +842,29 @@ function promptLocation() {
523
842
  // Track whether we've processed the answer to prevent double-execution
524
843
  let answered = false;
525
844
 
526
- // Handle readline close event to detect premature stdin closure
845
+ // Handle readline close event (Ctrl+C, Escape, etc.) - cancel installation
527
846
  rl.on('close', () => {
528
847
  if (!answered) {
529
848
  answered = true;
530
- console.log(`\n ${yellow}Input stream closed, defaulting to global install${reset}\n`);
531
- const { settingsPath, settings, statuslineCommand } = install(true);
532
- handleStatusline(settings, false, (shouldInstallStatusline) => {
533
- finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline);
534
- });
849
+ console.log(`\n ${yellow}Installation cancelled${reset}\n`);
850
+ process.exit(0);
535
851
  }
536
852
  });
537
853
 
854
+ // Show paths for selected runtimes
538
855
  const configDir = expandTilde(explicitConfigDir) || expandTilde(process.env.CLAUDE_CONFIG_DIR);
539
- const globalPath = configDir || path.join(os.homedir(), '.claude');
540
- const globalLabel = globalPath.replace(os.homedir(), '~');
856
+ const pathExamples = runtimes.map(r => {
857
+ const dir = getDirName(r);
858
+ const globalPath = configDir || path.join(os.homedir(), dir);
859
+ return globalPath.replace(os.homedir(), '~');
860
+ }).join(', ');
861
+
862
+ const localExamples = runtimes.map(r => `./${getDirName(r)}`).join(', ');
541
863
 
542
864
  console.log(` ${yellow}Where would you like to install?${reset}
543
865
 
544
- ${cyan}1${reset}) Global ${dim}(${globalLabel})${reset} - available in all projects
545
- ${cyan}2${reset}) Local ${dim}(./.claude)${reset} - this project only
866
+ ${cyan}1${reset}) Global ${dim}(${pathExamples})${reset} - available in all projects
867
+ ${cyan}2${reset}) Local ${dim}(${localExamples})${reset} - this project only
546
868
  `);
547
869
 
548
870
  rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
@@ -550,14 +872,44 @@ function promptLocation() {
550
872
  rl.close();
551
873
  const choice = answer.trim() || '1';
552
874
  const isGlobal = choice !== '2';
553
- const { settingsPath, settings, statuslineCommand } = install(isGlobal);
554
- // Interactive mode - prompt for optional features
555
- handleStatusline(settings, true, (shouldInstallStatusline) => {
556
- finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline);
557
- });
875
+ installAllRuntimes(runtimes, isGlobal, true);
558
876
  });
559
877
  }
560
878
 
879
+ /**
880
+ * Install GSD for all selected runtimes
881
+ * @param {string[]} runtimes - Array of runtimes to install for
882
+ * @param {boolean} isGlobal - Whether to install globally
883
+ * @param {boolean} isInteractive - Whether running interactively
884
+ */
885
+ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
886
+ const results = [];
887
+
888
+ for (const runtime of runtimes) {
889
+ const result = install(isGlobal, runtime);
890
+ results.push(result);
891
+ }
892
+
893
+ // Handle statusline for Claude Code only (OpenCode uses themes)
894
+ const claudeResult = results.find(r => r.runtime === 'claude');
895
+
896
+ if (claudeResult) {
897
+ handleStatusline(claudeResult.settings, isInteractive, (shouldInstallStatusline) => {
898
+ finishInstall(claudeResult.settingsPath, claudeResult.settings, claudeResult.statuslineCommand, shouldInstallStatusline, 'claude');
899
+
900
+ // Finish OpenCode install if present
901
+ const opencodeResult = results.find(r => r.runtime === 'opencode');
902
+ if (opencodeResult) {
903
+ finishInstall(opencodeResult.settingsPath, opencodeResult.settings, opencodeResult.statuslineCommand, false, 'opencode');
904
+ }
905
+ });
906
+ } else {
907
+ // Only OpenCode
908
+ const opencodeResult = results[0];
909
+ finishInstall(opencodeResult.settingsPath, opencodeResult.settings, opencodeResult.statuslineCommand, false, 'opencode');
910
+ }
911
+ }
912
+
561
913
  // Main
562
914
  if (hasGlobal && hasLocal) {
563
915
  console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
@@ -565,18 +917,26 @@ if (hasGlobal && hasLocal) {
565
917
  } else if (explicitConfigDir && hasLocal) {
566
918
  console.error(` ${yellow}Cannot use --config-dir with --local${reset}`);
567
919
  process.exit(1);
568
- } else if (hasGlobal) {
569
- const { settingsPath, settings, statuslineCommand } = install(true);
570
- // Non-interactive - respect flags
571
- handleStatusline(settings, false, (shouldInstallStatusline) => {
572
- finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline);
573
- });
574
- } else if (hasLocal) {
575
- const { settingsPath, settings, statuslineCommand } = install(false);
576
- // Non-interactive - respect flags
577
- handleStatusline(settings, false, (shouldInstallStatusline) => {
578
- finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline);
579
- });
920
+ } else if (selectedRuntimes.length > 0) {
921
+ // Non-interactive: runtime specified via flags
922
+ if (!hasGlobal && !hasLocal) {
923
+ // Need location but runtime is specified - prompt for location only
924
+ promptLocation(selectedRuntimes);
925
+ } else {
926
+ // Both runtime and location specified via flags
927
+ installAllRuntimes(selectedRuntimes, hasGlobal, false);
928
+ }
929
+ } else if (hasGlobal || hasLocal) {
930
+ // Location specified but no runtime - default to Claude Code
931
+ installAllRuntimes(['claude'], hasGlobal, false);
580
932
  } else {
581
- promptLocation();
933
+ // Fully interactive: prompt for runtime, then location
934
+ if (!process.stdin.isTTY) {
935
+ console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
936
+ installAllRuntimes(['claude'], true, false);
937
+ } else {
938
+ promptRuntime((runtimes) => {
939
+ promptLocation(runtimes);
940
+ });
941
+ }
582
942
  }