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