@howlil/ez-agents 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +845 -0
  3. package/README.zh-CN.md +702 -0
  4. package/agents/ez-codebase-mapper.md +770 -0
  5. package/agents/ez-debugger.md +1255 -0
  6. package/agents/ez-executor.md +487 -0
  7. package/agents/ez-integration-checker.md +443 -0
  8. package/agents/ez-nyquist-auditor.md +176 -0
  9. package/agents/ez-phase-researcher.md +553 -0
  10. package/agents/ez-plan-checker.md +706 -0
  11. package/agents/ez-planner.md +1307 -0
  12. package/agents/ez-project-researcher.md +629 -0
  13. package/agents/ez-research-synthesizer.md +247 -0
  14. package/agents/ez-roadmapper.md +650 -0
  15. package/agents/ez-ui-auditor.md +441 -0
  16. package/agents/ez-ui-checker.md +302 -0
  17. package/agents/ez-ui-researcher.md +355 -0
  18. package/agents/ez-verifier.md +579 -0
  19. package/bin/install.js +2862 -0
  20. package/bin/update.js +214 -0
  21. package/commands/ez/add-phase.md +43 -0
  22. package/commands/ez/add-tests.md +41 -0
  23. package/commands/ez/add-todo.md +47 -0
  24. package/commands/ez/audit-milestone.md +36 -0
  25. package/commands/ez/autonomous.md +41 -0
  26. package/commands/ez/check-todos.md +45 -0
  27. package/commands/ez/cleanup.md +18 -0
  28. package/commands/ez/complete-milestone.md +136 -0
  29. package/commands/ez/debug.md +168 -0
  30. package/commands/ez/discuss-phase.md +90 -0
  31. package/commands/ez/execute-phase.md +41 -0
  32. package/commands/ez/health.md +22 -0
  33. package/commands/ez/help.md +22 -0
  34. package/commands/ez/insert-phase.md +32 -0
  35. package/commands/ez/join-discord.md +18 -0
  36. package/commands/ez/list-phase-assumptions.md +46 -0
  37. package/commands/ez/map-codebase.md +71 -0
  38. package/commands/ez/new-milestone.md +44 -0
  39. package/commands/ez/new-project.md +42 -0
  40. package/commands/ez/pause-work.md +38 -0
  41. package/commands/ez/plan-milestone-gaps.md +34 -0
  42. package/commands/ez/plan-phase.md +45 -0
  43. package/commands/ez/progress.md +24 -0
  44. package/commands/ez/quick.md +45 -0
  45. package/commands/ez/reapply-patches.md +124 -0
  46. package/commands/ez/remove-phase.md +31 -0
  47. package/commands/ez/research-phase.md +190 -0
  48. package/commands/ez/resume-work.md +40 -0
  49. package/commands/ez/set-profile.md +34 -0
  50. package/commands/ez/settings.md +36 -0
  51. package/commands/ez/stats.md +18 -0
  52. package/commands/ez/ui-phase.md +34 -0
  53. package/commands/ez/ui-review.md +32 -0
  54. package/commands/ez/update.md +37 -0
  55. package/commands/ez/validate-phase.md +35 -0
  56. package/commands/ez/verify-work.md +38 -0
  57. package/get-shit-done/bin/ez-tools.cjs +598 -0
  58. package/get-shit-done/bin/lib/assistant-adapter.cjs +205 -0
  59. package/get-shit-done/bin/lib/audit-exec.cjs +150 -0
  60. package/get-shit-done/bin/lib/auth.cjs +175 -0
  61. package/get-shit-done/bin/lib/circuit-breaker.cjs +118 -0
  62. package/get-shit-done/bin/lib/commands.cjs +666 -0
  63. package/get-shit-done/bin/lib/config.cjs +183 -0
  64. package/get-shit-done/bin/lib/core.cjs +495 -0
  65. package/get-shit-done/bin/lib/file-lock.cjs +236 -0
  66. package/get-shit-done/bin/lib/frontmatter.cjs +299 -0
  67. package/get-shit-done/bin/lib/fs-utils.cjs +153 -0
  68. package/get-shit-done/bin/lib/git-utils.cjs +203 -0
  69. package/get-shit-done/bin/lib/health-check.cjs +163 -0
  70. package/get-shit-done/bin/lib/index.cjs +113 -0
  71. package/get-shit-done/bin/lib/init.cjs +710 -0
  72. package/get-shit-done/bin/lib/logger.cjs +117 -0
  73. package/get-shit-done/bin/lib/milestone.cjs +241 -0
  74. package/get-shit-done/bin/lib/model-provider.cjs +146 -0
  75. package/get-shit-done/bin/lib/phase.cjs +908 -0
  76. package/get-shit-done/bin/lib/retry.cjs +119 -0
  77. package/get-shit-done/bin/lib/roadmap.cjs +305 -0
  78. package/get-shit-done/bin/lib/safe-exec.cjs +128 -0
  79. package/get-shit-done/bin/lib/safe-path.cjs +130 -0
  80. package/get-shit-done/bin/lib/state.cjs +721 -0
  81. package/get-shit-done/bin/lib/temp-file.cjs +239 -0
  82. package/get-shit-done/bin/lib/template.cjs +222 -0
  83. package/get-shit-done/bin/lib/test-file-lock.cjs +112 -0
  84. package/get-shit-done/bin/lib/test-graceful.cjs +93 -0
  85. package/get-shit-done/bin/lib/test-logger.cjs +60 -0
  86. package/get-shit-done/bin/lib/test-safe-exec.cjs +38 -0
  87. package/get-shit-done/bin/lib/test-safe-path.cjs +33 -0
  88. package/get-shit-done/bin/lib/test-temp-file.cjs +125 -0
  89. package/get-shit-done/bin/lib/timeout-exec.cjs +62 -0
  90. package/get-shit-done/bin/lib/verify.cjs +820 -0
  91. package/get-shit-done/references/checkpoints.md +776 -0
  92. package/get-shit-done/references/continuation-format.md +249 -0
  93. package/get-shit-done/references/decimal-phase-calculation.md +65 -0
  94. package/get-shit-done/references/git-integration.md +248 -0
  95. package/get-shit-done/references/git-planning-commit.md +38 -0
  96. package/get-shit-done/references/model-profile-resolution.md +34 -0
  97. package/get-shit-done/references/model-profiles.md +93 -0
  98. package/get-shit-done/references/phase-argument-parsing.md +61 -0
  99. package/get-shit-done/references/planning-config.md +200 -0
  100. package/get-shit-done/references/questioning.md +162 -0
  101. package/get-shit-done/references/tdd.md +263 -0
  102. package/get-shit-done/references/ui-brand.md +160 -0
  103. package/get-shit-done/references/verification-patterns.md +612 -0
  104. package/get-shit-done/templates/DEBUG.md +164 -0
  105. package/get-shit-done/templates/UAT.md +247 -0
  106. package/get-shit-done/templates/UI-SPEC.md +100 -0
  107. package/get-shit-done/templates/VALIDATION.md +76 -0
  108. package/get-shit-done/templates/codebase/architecture.md +255 -0
  109. package/get-shit-done/templates/codebase/concerns.md +310 -0
  110. package/get-shit-done/templates/codebase/conventions.md +307 -0
  111. package/get-shit-done/templates/codebase/integrations.md +280 -0
  112. package/get-shit-done/templates/codebase/stack.md +186 -0
  113. package/get-shit-done/templates/codebase/structure.md +285 -0
  114. package/get-shit-done/templates/codebase/testing.md +480 -0
  115. package/get-shit-done/templates/config.json +37 -0
  116. package/get-shit-done/templates/context.md +352 -0
  117. package/get-shit-done/templates/continue-here.md +78 -0
  118. package/get-shit-done/templates/copilot-instructions.md +7 -0
  119. package/get-shit-done/templates/debug-subagent-prompt.md +91 -0
  120. package/get-shit-done/templates/discovery.md +146 -0
  121. package/get-shit-done/templates/milestone-archive.md +123 -0
  122. package/get-shit-done/templates/milestone.md +115 -0
  123. package/get-shit-done/templates/phase-prompt.md +610 -0
  124. package/get-shit-done/templates/planner-subagent-prompt.md +117 -0
  125. package/get-shit-done/templates/project.md +184 -0
  126. package/get-shit-done/templates/requirements.md +231 -0
  127. package/get-shit-done/templates/research-project/ARCHITECTURE.md +204 -0
  128. package/get-shit-done/templates/research-project/FEATURES.md +147 -0
  129. package/get-shit-done/templates/research-project/PITFALLS.md +200 -0
  130. package/get-shit-done/templates/research-project/STACK.md +120 -0
  131. package/get-shit-done/templates/research-project/SUMMARY.md +170 -0
  132. package/get-shit-done/templates/research.md +552 -0
  133. package/get-shit-done/templates/retrospective.md +54 -0
  134. package/get-shit-done/templates/roadmap.md +202 -0
  135. package/get-shit-done/templates/state.md +176 -0
  136. package/get-shit-done/templates/summary-complex.md +59 -0
  137. package/get-shit-done/templates/summary-minimal.md +41 -0
  138. package/get-shit-done/templates/summary-standard.md +48 -0
  139. package/get-shit-done/templates/summary.md +248 -0
  140. package/get-shit-done/templates/user-setup.md +311 -0
  141. package/get-shit-done/templates/verification-report.md +322 -0
  142. package/get-shit-done/workflows/add-phase.md +112 -0
  143. package/get-shit-done/workflows/add-tests.md +351 -0
  144. package/get-shit-done/workflows/add-todo.md +158 -0
  145. package/get-shit-done/workflows/audit-milestone.md +332 -0
  146. package/get-shit-done/workflows/autonomous.md +743 -0
  147. package/get-shit-done/workflows/check-todos.md +177 -0
  148. package/get-shit-done/workflows/cleanup.md +152 -0
  149. package/get-shit-done/workflows/complete-milestone.md +766 -0
  150. package/get-shit-done/workflows/diagnose-issues.md +219 -0
  151. package/get-shit-done/workflows/discovery-phase.md +289 -0
  152. package/get-shit-done/workflows/discuss-phase.md +762 -0
  153. package/get-shit-done/workflows/execute-phase.md +468 -0
  154. package/get-shit-done/workflows/execute-plan.md +483 -0
  155. package/get-shit-done/workflows/health.md +159 -0
  156. package/get-shit-done/workflows/help.md +492 -0
  157. package/get-shit-done/workflows/insert-phase.md +130 -0
  158. package/get-shit-done/workflows/list-phase-assumptions.md +178 -0
  159. package/get-shit-done/workflows/map-codebase.md +316 -0
  160. package/get-shit-done/workflows/new-milestone.md +384 -0
  161. package/get-shit-done/workflows/new-project.md +1111 -0
  162. package/get-shit-done/workflows/node-repair.md +92 -0
  163. package/get-shit-done/workflows/pause-work.md +122 -0
  164. package/get-shit-done/workflows/plan-milestone-gaps.md +274 -0
  165. package/get-shit-done/workflows/plan-phase.md +651 -0
  166. package/get-shit-done/workflows/progress.md +382 -0
  167. package/get-shit-done/workflows/quick.md +610 -0
  168. package/get-shit-done/workflows/remove-phase.md +155 -0
  169. package/get-shit-done/workflows/research-phase.md +74 -0
  170. package/get-shit-done/workflows/resume-project.md +307 -0
  171. package/get-shit-done/workflows/set-profile.md +81 -0
  172. package/get-shit-done/workflows/settings.md +242 -0
  173. package/get-shit-done/workflows/stats.md +57 -0
  174. package/get-shit-done/workflows/transition.md +544 -0
  175. package/get-shit-done/workflows/ui-phase.md +290 -0
  176. package/get-shit-done/workflows/ui-review.md +157 -0
  177. package/get-shit-done/workflows/update.md +320 -0
  178. package/get-shit-done/workflows/validate-phase.md +167 -0
  179. package/get-shit-done/workflows/verify-phase.md +243 -0
  180. package/get-shit-done/workflows/verify-work.md +584 -0
  181. package/package.json +55 -0
  182. package/scripts/build-hooks.js +43 -0
  183. package/scripts/run-tests.cjs +29 -0
package/bin/install.js ADDED
@@ -0,0 +1,2862 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+ const crypto = require('crypto');
8
+
9
+ // Colors
10
+ const cyan = '\x1b[36m';
11
+ const green = '\x1b[32m';
12
+ const yellow = '\x1b[33m';
13
+ const dim = '\x1b[2m';
14
+ const reset = '\x1b[0m';
15
+
16
+ // Codex config.toml constants
17
+ const EZ_CODEX_MARKER = '# EZ Agents Agent Configuration \u2014 managed by ez-agents installer';
18
+
19
+ // Copilot instructions marker constants
20
+ const EZ_COPILOT_INSTRUCTIONS_MARKER = '<!-- EZ Agents Configuration \u2014 managed by ez-agents installer -->';
21
+ const EZ_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /EZ Agents Configuration -->';
22
+
23
+ const CODEX_AGENT_SANDBOX = {
24
+ 'ez-executor': 'workspace-write',
25
+ 'ez-planner': 'workspace-write',
26
+ 'ez-phase-researcher': 'workspace-write',
27
+ 'ez-project-researcher': 'workspace-write',
28
+ 'ez-research-synthesizer': 'workspace-write',
29
+ 'ez-verifier': 'workspace-write',
30
+ 'ez-codebase-mapper': 'workspace-write',
31
+ 'ez-roadmapper': 'workspace-write',
32
+ 'ez-debugger': 'workspace-write',
33
+ 'ez-plan-checker': 'read-only',
34
+ 'ez-integration-checker': 'read-only',
35
+ };
36
+
37
+ // Copilot tool name mapping — Claude Code tools to GitHub Copilot tools
38
+ // Tool mapping applies ONLY to agents, NOT to skills (per CONTEXT.md decision)
39
+ const claudeToCopilotTools = {
40
+ Read: 'read',
41
+ Write: 'edit',
42
+ Edit: 'edit',
43
+ Bash: 'execute',
44
+ Grep: 'search',
45
+ Glob: 'search',
46
+ Task: 'agent',
47
+ WebSearch: 'web',
48
+ WebFetch: 'web',
49
+ TodoWrite: 'todo',
50
+ AskUserQuestion: 'ask_user',
51
+ SlashCommand: 'skill',
52
+ };
53
+
54
+ // Get version from package.json
55
+ const pkg = require('../package.json');
56
+
57
+ // Parse args
58
+ const args = process.argv.slice(2);
59
+ const hasGlobal = args.includes('--global') || args.includes('-g');
60
+ const hasLocal = args.includes('--local') || args.includes('-l');
61
+ const hasOpencode = args.includes('--opencode');
62
+ const hasClaude = args.includes('--claude');
63
+ const hasGemini = args.includes('--gemini');
64
+ const hasCodex = args.includes('--codex');
65
+ const hasCopilot = args.includes('--copilot');
66
+ const hasBoth = args.includes('--both'); // Legacy flag, keeps working
67
+ const hasAll = args.includes('--all');
68
+ const hasUninstall = args.includes('--uninstall') || args.includes('-u');
69
+
70
+ // Runtime selection - can be set by flags or interactive prompt
71
+ let selectedRuntimes = [];
72
+ if (hasAll) {
73
+ selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot'];
74
+ } else if (hasBoth) {
75
+ selectedRuntimes = ['claude', 'opencode'];
76
+ } else {
77
+ if (hasOpencode) selectedRuntimes.push('opencode');
78
+ if (hasClaude) selectedRuntimes.push('claude');
79
+ if (hasGemini) selectedRuntimes.push('gemini');
80
+ if (hasCodex) selectedRuntimes.push('codex');
81
+ if (hasCopilot) selectedRuntimes.push('copilot');
82
+ }
83
+
84
+ // WSL + Windows Node.js detection
85
+ // When Windows-native Node runs on WSL, os.homedir() and path.join() produce
86
+ // backslash paths that don't resolve correctly on the Linux filesystem.
87
+ if (process.platform === 'win32') {
88
+ let isWSL = false;
89
+ try {
90
+ if (process.env.WSL_DISTRO_NAME) {
91
+ isWSL = true;
92
+ } else if (fs.existsSync('/proc/version')) {
93
+ const procVersion = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
94
+ if (procVersion.includes('microsoft') || procVersion.includes('wsl')) {
95
+ isWSL = true;
96
+ }
97
+ }
98
+ } catch {
99
+ // Ignore read errors — not WSL
100
+ }
101
+
102
+ if (isWSL) {
103
+ console.error(`
104
+ ${yellow}⚠ Detected WSL with Windows-native Node.js.${reset}
105
+
106
+ This causes path resolution issues that prevent correct installation.
107
+ Please install a Linux-native Node.js inside WSL:
108
+
109
+ curl -fsSL https://fnm.vercel.app/install | bash
110
+ fnm install --lts
111
+
112
+ Then re-run: npx ez-agents
113
+ `);
114
+ process.exit(1);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Convert a pathPrefix (which uses absolute paths for global installs) to a
120
+ * $HOME-relative form for replacing $HOME/.claude/ references in bash code blocks.
121
+ * Preserves $HOME as a shell variable so paths remain portable across machines.
122
+ */
123
+ function toHomePrefix(pathPrefix) {
124
+ const home = os.homedir().replace(/\\/g, '/');
125
+ const normalized = pathPrefix.replace(/\\/g, '/');
126
+ if (normalized.startsWith(home)) {
127
+ return '$HOME' + normalized.slice(home.length);
128
+ }
129
+ // Convert tilde-based paths to $HOME-based paths for bash code blocks
130
+ if (normalized.startsWith('~/')) {
131
+ return '$HOME' + normalized.slice(1);
132
+ }
133
+ // For relative paths or paths not under $HOME, return as-is
134
+ return normalized;
135
+ }
136
+
137
+ // Helper to get directory name for a runtime (used for local/project installs)
138
+ function getDirName(runtime) {
139
+ if (runtime === 'copilot') return '.github';
140
+ if (runtime === 'opencode') return '.opencode';
141
+ if (runtime === 'gemini') return '.gemini';
142
+ if (runtime === 'codex') return '.codex';
143
+ return '.claude';
144
+ }
145
+
146
+ /**
147
+ * Get the config directory path relative to home directory for a runtime
148
+ * Used for templating hooks that use path.join(homeDir, '<configDir>', ...)
149
+ * @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
150
+ * @param {boolean} isGlobal - Whether this is a global install
151
+ */
152
+ function getConfigDirFromHome(runtime, isGlobal) {
153
+ if (!isGlobal) {
154
+ // Local installs use the same dir name pattern
155
+ return `'${getDirName(runtime)}'`;
156
+ }
157
+ // Global installs - OpenCode uses XDG path structure
158
+ if (runtime === 'copilot') return "'.copilot'";
159
+ if (runtime === 'opencode') {
160
+ // OpenCode: ~/.config/opencode -> '.config', 'opencode'
161
+ // Return as comma-separated for path.join() replacement
162
+ return "'.config', 'opencode'";
163
+ }
164
+ if (runtime === 'gemini') return "'.gemini'";
165
+ if (runtime === 'codex') return "'.codex'";
166
+ return "'.claude'";
167
+ }
168
+
169
+ /**
170
+ * Get the global config directory for OpenCode
171
+ * OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
172
+ * Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
173
+ */
174
+ function getOpencodeGlobalDir() {
175
+ // 1. Explicit OPENCODE_CONFIG_DIR env var
176
+ if (process.env.OPENCODE_CONFIG_DIR) {
177
+ return expandTilde(process.env.OPENCODE_CONFIG_DIR);
178
+ }
179
+
180
+ // 2. OPENCODE_CONFIG env var (use its directory)
181
+ if (process.env.OPENCODE_CONFIG) {
182
+ return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
183
+ }
184
+
185
+ // 3. XDG_CONFIG_HOME/opencode
186
+ if (process.env.XDG_CONFIG_HOME) {
187
+ return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
188
+ }
189
+
190
+ // 4. Default: ~/.config/opencode (XDG default)
191
+ return path.join(os.homedir(), '.config', 'opencode');
192
+ }
193
+
194
+ /**
195
+ * Get the global config directory for a runtime
196
+ * @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
197
+ * @param {string|null} explicitDir - Explicit directory from --config-dir flag
198
+ */
199
+ function getGlobalDir(runtime, explicitDir = null) {
200
+ if (runtime === 'opencode') {
201
+ // For OpenCode, --config-dir overrides env vars
202
+ if (explicitDir) {
203
+ return expandTilde(explicitDir);
204
+ }
205
+ return getOpencodeGlobalDir();
206
+ }
207
+
208
+ if (runtime === 'gemini') {
209
+ // Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
210
+ if (explicitDir) {
211
+ return expandTilde(explicitDir);
212
+ }
213
+ if (process.env.GEMINI_CONFIG_DIR) {
214
+ return expandTilde(process.env.GEMINI_CONFIG_DIR);
215
+ }
216
+ return path.join(os.homedir(), '.gemini');
217
+ }
218
+
219
+ if (runtime === 'codex') {
220
+ // Codex: --config-dir > CODEX_HOME > ~/.codex
221
+ if (explicitDir) {
222
+ return expandTilde(explicitDir);
223
+ }
224
+ if (process.env.CODEX_HOME) {
225
+ return expandTilde(process.env.CODEX_HOME);
226
+ }
227
+ return path.join(os.homedir(), '.codex');
228
+ }
229
+
230
+ if (runtime === 'copilot') {
231
+ // Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
232
+ if (explicitDir) {
233
+ return expandTilde(explicitDir);
234
+ }
235
+ if (process.env.COPILOT_CONFIG_DIR) {
236
+ return expandTilde(process.env.COPILOT_CONFIG_DIR);
237
+ }
238
+ return path.join(os.homedir(), '.copilot');
239
+ }
240
+
241
+ // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
242
+ if (explicitDir) {
243
+ return expandTilde(explicitDir);
244
+ }
245
+ if (process.env.CLAUDE_CONFIG_DIR) {
246
+ return expandTilde(process.env.CLAUDE_CONFIG_DIR);
247
+ }
248
+ return path.join(os.homedir(), '.claude');
249
+ }
250
+
251
+ const banner = '\n' +
252
+ cyan + ' ██████╗ ███████╗██████╗\n' +
253
+ ' ██╔════╝ ██╔════╝██╔══██╗\n' +
254
+ ' ██║ ███╗███████╗██║ ██║\n' +
255
+ ' ██║ ██║╚════██║██║ ██║\n' +
256
+ ' ╚██████╔╝███████║██████╔╝\n' +
257
+ ' ╚═════╝ ╚══════╝╚═════╝' + reset + '\n' +
258
+ '\n' +
259
+ ' EZ Agents ' + dim + 'v' + pkg.version + reset + '\n' +
260
+ ' ' + dim + 'Multi-Model Edition' + reset + '\n' +
261
+ ' A meta-prompting, context engineering and spec-driven\n' +
262
+ ' development system for Claude Code, OpenCode, Gemini, Codex, Copilot,\n' +
263
+ ' with multi-model support (Qwen, Kimi, OpenAI, Anthropic).\n' +
264
+ ' Original by TÂCHES | Multi-Model Fork by @howlil.\n';
265
+
266
+ // Parse --config-dir argument
267
+ function parseConfigDirArg() {
268
+ const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c');
269
+ if (configDirIndex !== -1) {
270
+ const nextArg = args[configDirIndex + 1];
271
+ // Error if --config-dir is provided without a value or next arg is another flag
272
+ if (!nextArg || nextArg.startsWith('-')) {
273
+ console.error(` ${yellow}--config-dir requires a path argument${reset}`);
274
+ process.exit(1);
275
+ }
276
+ return nextArg;
277
+ }
278
+ // Also handle --config-dir=value format
279
+ const configDirArg = args.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c='));
280
+ if (configDirArg) {
281
+ const value = configDirArg.split('=')[1];
282
+ if (!value) {
283
+ console.error(` ${yellow}--config-dir requires a non-empty path${reset}`);
284
+ process.exit(1);
285
+ }
286
+ return value;
287
+ }
288
+ return null;
289
+ }
290
+ const explicitConfigDir = parseConfigDirArg();
291
+ const hasHelp = args.includes('--help') || args.includes('-h');
292
+ const forceStatusline = args.includes('--force-statusline');
293
+
294
+ console.log(banner);
295
+
296
+ if (hasUninstall) {
297
+ console.log(' Mode: Uninstall\n');
298
+ }
299
+
300
+ // Show help if requested
301
+ if (hasHelp) {
302
+ console.log(` ${yellow}Usage:${reset} npx ez-agents [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}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall EZ Agents (remove all EZ Agents 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 ez-agents\n\n ${dim}# Install for Claude Code globally${reset}\n npx ez-agents --claude --global\n\n ${dim}# Install for all runtimes globally${reset}\n npx ez-agents --all --global\n\n ${dim}# Uninstall EZ Agents globally${reset}\n npx ez-agents --all --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 / CODEX_HOME / COPILOT_CONFIG_DIR environment variables.\n`);
303
+ process.exit(0);
304
+ }
305
+
306
+ /**
307
+ * Expand ~ to home directory (shell doesn't expand in env vars passed to node)
308
+ */
309
+ function expandTilde(filePath) {
310
+ if (filePath && filePath.startsWith('~/')) {
311
+ return path.join(os.homedir(), filePath.slice(2));
312
+ }
313
+ return filePath;
314
+ }
315
+
316
+ /**
317
+ * Build a hook command path using forward slashes for cross-platform compatibility.
318
+ * On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path.
319
+ */
320
+ function buildHookCommand(configDir, hookName) {
321
+ // Use forward slashes for Node.js compatibility on all platforms
322
+ const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
323
+ return `node "${hooksPath}"`;
324
+ }
325
+
326
+ /**
327
+ * Read and parse settings.json, returning empty object if it doesn't exist
328
+ */
329
+ function readSettings(settingsPath) {
330
+ if (fs.existsSync(settingsPath)) {
331
+ try {
332
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
333
+ } catch (e) {
334
+ return {};
335
+ }
336
+ }
337
+ return {};
338
+ }
339
+
340
+ /**
341
+ * Write settings.json with proper formatting
342
+ */
343
+ function writeSettings(settingsPath, settings) {
344
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
345
+ }
346
+
347
+ // Cache for attribution settings (populated once per runtime during install)
348
+ const attributionCache = new Map();
349
+
350
+ /**
351
+ * Get commit attribution setting for a runtime
352
+ * @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
353
+ * @returns {null|undefined|string} null = remove, undefined = keep default, string = custom
354
+ */
355
+ function getCommitAttribution(runtime) {
356
+ // Return cached value if available
357
+ if (attributionCache.has(runtime)) {
358
+ return attributionCache.get(runtime);
359
+ }
360
+
361
+ let result;
362
+
363
+ if (runtime === 'opencode') {
364
+ const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json'));
365
+ result = config.disable_ai_attribution === true ? null : undefined;
366
+ } else if (runtime === 'gemini') {
367
+ // Gemini: check gemini settings.json for attribution config
368
+ const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
369
+ if (!settings.attribution || settings.attribution.commit === undefined) {
370
+ result = undefined;
371
+ } else if (settings.attribution.commit === '') {
372
+ result = null;
373
+ } else {
374
+ result = settings.attribution.commit;
375
+ }
376
+ } else if (runtime === 'claude') {
377
+ // Claude Code
378
+ const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
379
+ if (!settings.attribution || settings.attribution.commit === undefined) {
380
+ result = undefined;
381
+ } else if (settings.attribution.commit === '') {
382
+ result = null;
383
+ } else {
384
+ result = settings.attribution.commit;
385
+ }
386
+ } else {
387
+ // Codex and Copilot currently have no attribution setting equivalent
388
+ result = undefined;
389
+ }
390
+
391
+ // Cache and return
392
+ attributionCache.set(runtime, result);
393
+ return result;
394
+ }
395
+
396
+ /**
397
+ * Process Co-Authored-By lines based on attribution setting
398
+ * @param {string} content - File content to process
399
+ * @param {null|undefined|string} attribution - null=remove, undefined=keep, string=replace
400
+ * @returns {string} Processed content
401
+ */
402
+ function processAttribution(content, attribution) {
403
+ if (attribution === null) {
404
+ // Remove Co-Authored-By lines and the preceding blank line
405
+ return content.replace(/(\r?\n){2}Co-Authored-By:.*$/gim, '');
406
+ }
407
+ if (attribution === undefined) {
408
+ return content;
409
+ }
410
+ // Replace with custom attribution (escape $ to prevent backreference injection)
411
+ const safeAttribution = attribution.replace(/\$/g, '$$$$');
412
+ return content.replace(/Co-Authored-By:.*$/gim, `Co-Authored-By: ${safeAttribution}`);
413
+ }
414
+
415
+ /**
416
+ * Convert Claude Code frontmatter to opencode format
417
+ * - Converts 'allowed-tools:' array to 'permission:' object
418
+ * @param {string} content - Markdown file content with YAML frontmatter
419
+ * @returns {string} - Content with converted frontmatter
420
+ */
421
+ // Color name to hex mapping for opencode compatibility
422
+ const colorNameToHex = {
423
+ cyan: '#00FFFF',
424
+ red: '#FF0000',
425
+ green: '#00FF00',
426
+ blue: '#0000FF',
427
+ yellow: '#FFFF00',
428
+ magenta: '#FF00FF',
429
+ orange: '#FFA500',
430
+ purple: '#800080',
431
+ pink: '#FFC0CB',
432
+ white: '#FFFFFF',
433
+ black: '#000000',
434
+ gray: '#808080',
435
+ grey: '#808080',
436
+ };
437
+
438
+ // Tool name mapping from Claude Code to OpenCode
439
+ // OpenCode uses lowercase tool names; special mappings for renamed tools
440
+ const claudeToOpencodeTools = {
441
+ AskUserQuestion: 'question',
442
+ SlashCommand: 'skill',
443
+ TodoWrite: 'todowrite',
444
+ WebFetch: 'webfetch',
445
+ WebSearch: 'websearch', // Plugin/MCP - keep for compatibility
446
+ };
447
+
448
+ // Tool name mapping from Claude Code to Gemini CLI
449
+ // Gemini CLI uses snake_case built-in tool names
450
+ const claudeToGeminiTools = {
451
+ Read: 'read_file',
452
+ Write: 'write_file',
453
+ Edit: 'replace',
454
+ Bash: 'run_shell_command',
455
+ Glob: 'glob',
456
+ Grep: 'search_file_content',
457
+ WebSearch: 'google_web_search',
458
+ WebFetch: 'web_fetch',
459
+ TodoWrite: 'write_todos',
460
+ AskUserQuestion: 'ask_user',
461
+ };
462
+
463
+ /**
464
+ * Convert a Claude Code tool name to OpenCode format
465
+ * - Applies special mappings (AskUserQuestion -> question, etc.)
466
+ * - Converts to lowercase (except MCP tools which keep their format)
467
+ */
468
+ function convertToolName(claudeTool) {
469
+ // Check for special mapping first
470
+ if (claudeToOpencodeTools[claudeTool]) {
471
+ return claudeToOpencodeTools[claudeTool];
472
+ }
473
+ // MCP tools (mcp__*) keep their format
474
+ if (claudeTool.startsWith('mcp__')) {
475
+ return claudeTool;
476
+ }
477
+ // Default: convert to lowercase
478
+ return claudeTool.toLowerCase();
479
+ }
480
+
481
+ /**
482
+ * Convert a Claude Code tool name to Gemini CLI format
483
+ * - Applies Claude→Gemini mapping (Read→read_file, Bash→run_shell_command, etc.)
484
+ * - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Gemini
485
+ * - Filters out Task — agents are auto-registered as tools in Gemini
486
+ * @returns {string|null} Gemini tool name, or null if tool should be excluded
487
+ */
488
+ function convertGeminiToolName(claudeTool) {
489
+ // MCP tools: exclude — auto-discovered from mcpServers config at runtime
490
+ if (claudeTool.startsWith('mcp__')) {
491
+ return null;
492
+ }
493
+ // Task: exclude — agents are auto-registered as callable tools
494
+ if (claudeTool === 'Task') {
495
+ return null;
496
+ }
497
+ // Check for explicit mapping
498
+ if (claudeToGeminiTools[claudeTool]) {
499
+ return claudeToGeminiTools[claudeTool];
500
+ }
501
+ // Default: lowercase
502
+ return claudeTool.toLowerCase();
503
+ }
504
+
505
+ /**
506
+ * Convert a Claude Code tool name to GitHub Copilot format.
507
+ * - Applies explicit mapping from claudeToCopilotTools
508
+ * - Handles mcp__context7__* prefix → io.github.upstash/context7/*
509
+ * - Falls back to lowercase for unknown tools
510
+ */
511
+ function convertCopilotToolName(claudeTool) {
512
+ // mcp__context7__* wildcard → io.github.upstash/context7/*
513
+ if (claudeTool.startsWith('mcp__context7__')) {
514
+ return 'io.github.upstash/context7/' + claudeTool.slice('mcp__context7__'.length);
515
+ }
516
+ // Check explicit mapping
517
+ if (claudeToCopilotTools[claudeTool]) {
518
+ return claudeToCopilotTools[claudeTool];
519
+ }
520
+ // Default: lowercase
521
+ return claudeTool.toLowerCase();
522
+ }
523
+
524
+ /**
525
+ * Apply Copilot-specific content conversion — CONV-06 (paths) + CONV-07 (command names).
526
+ * Path mappings depend on install mode:
527
+ * Global: ~/.claude/ → ~/.copilot/, ./.claude/ → ./.github/
528
+ * Local: ~/.claude/ → ./.github/, ./.claude/ → ./.github/
529
+ * Applied to ALL Copilot content (skills, agents, engine files).
530
+ * @param {string} content - Source content to convert
531
+ * @param {boolean} [isGlobal=false] - Whether this is a global install
532
+ */
533
+ function convertClaudeToCopilotContent(content, isGlobal = false) {
534
+ let c = content;
535
+ // CONV-06: Path replacement — most specific first to avoid substring matches
536
+ if (isGlobal) {
537
+ c = c.replace(/\$HOME\/\.claude\//g, '$HOME/.copilot/');
538
+ c = c.replace(/~\/\.claude\//g, '~/.copilot/');
539
+ } else {
540
+ c = c.replace(/\$HOME\/\.claude\//g, '.github/');
541
+ c = c.replace(/~\/\.claude\//g, '.github/');
542
+ }
543
+ c = c.replace(/\.\/\.claude\//g, './.github/');
544
+ c = c.replace(/\.claude\//g, '.github/');
545
+ // CONV-07: Command name conversion (all ez: references → ez-)
546
+ c = c.replace(/ez:/g, 'ez-');
547
+ return c;
548
+ }
549
+
550
+ /**
551
+ * Convert a Claude command (.md) to a Copilot skill (SKILL.md).
552
+ * Transforms frontmatter only — body passes through with CONV-06/07 applied.
553
+ * Skills keep original tool names (no mapping) per CONTEXT.md decision.
554
+ */
555
+ function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false) {
556
+ const converted = convertClaudeToCopilotContent(content, isGlobal);
557
+ const { frontmatter, body } = extractFrontmatterAndBody(converted);
558
+ if (!frontmatter) return converted;
559
+
560
+ const description = extractFrontmatterField(frontmatter, 'description') || '';
561
+ const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
562
+ const agent = extractFrontmatterField(frontmatter, 'agent');
563
+
564
+ // CONV-02: Extract allowed-tools YAML multiline list → comma-separated string
565
+ const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
566
+ let toolsLine = '';
567
+ if (toolsMatch) {
568
+ const tools = toolsMatch[1].match(/^\s+-\s+(.+)/gm);
569
+ if (tools) {
570
+ toolsLine = tools.map(t => t.replace(/^\s+-\s+/, '').trim()).join(', ');
571
+ }
572
+ }
573
+
574
+ // Reconstruct frontmatter in Copilot format
575
+ let fm = `---\nname: ${skillName}\ndescription: ${description}\n`;
576
+ if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
577
+ if (agent) fm += `agent: ${agent}\n`;
578
+ if (toolsLine) fm += `allowed-tools: ${toolsLine}\n`;
579
+ fm += '---';
580
+
581
+ return `${fm}\n${body}`;
582
+ }
583
+
584
+ /**
585
+ * Convert a Claude agent (.md) to a Copilot agent (.agent.md).
586
+ * Applies tool mapping + deduplication, formats tools as JSON array.
587
+ * CONV-04: JSON array format. CONV-05: Tool name mapping.
588
+ */
589
+ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
590
+ const converted = convertClaudeToCopilotContent(content, isGlobal);
591
+ const { frontmatter, body } = extractFrontmatterAndBody(converted);
592
+ if (!frontmatter) return converted;
593
+
594
+ const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
595
+ const description = extractFrontmatterField(frontmatter, 'description') || '';
596
+ const color = extractFrontmatterField(frontmatter, 'color');
597
+ const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || '';
598
+
599
+ // CONV-04 + CONV-05: Map tools, deduplicate, format as JSON array
600
+ const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
601
+ const mappedTools = claudeTools.map(t => convertCopilotToolName(t));
602
+ const uniqueTools = [...new Set(mappedTools)];
603
+ const toolsArray = uniqueTools.length > 0
604
+ ? "['" + uniqueTools.join("', '") + "']"
605
+ : '[]';
606
+
607
+ // Reconstruct frontmatter in Copilot format
608
+ let fm = `---\nname: ${name}\ndescription: ${description}\ntools: ${toolsArray}\n`;
609
+ if (color) fm += `color: ${color}\n`;
610
+ fm += '---';
611
+
612
+ return `${fm}\n${body}`;
613
+ }
614
+
615
+ function toSingleLine(value) {
616
+ return value.replace(/\s+/g, ' ').trim();
617
+ }
618
+
619
+ function yamlQuote(value) {
620
+ return JSON.stringify(value);
621
+ }
622
+
623
+ function extractFrontmatterAndBody(content) {
624
+ if (!content.startsWith('---')) {
625
+ return { frontmatter: null, body: content };
626
+ }
627
+
628
+ const endIndex = content.indexOf('---', 3);
629
+ if (endIndex === -1) {
630
+ return { frontmatter: null, body: content };
631
+ }
632
+
633
+ return {
634
+ frontmatter: content.substring(3, endIndex).trim(),
635
+ body: content.substring(endIndex + 3),
636
+ };
637
+ }
638
+
639
+ function extractFrontmatterField(frontmatter, fieldName) {
640
+ const regex = new RegExp(`^${fieldName}:\\s*(.+)$`, 'm');
641
+ const match = frontmatter.match(regex);
642
+ if (!match) return null;
643
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
644
+ }
645
+
646
+ function convertSlashCommandsToCodexSkillMentions(content) {
647
+ let converted = content.replace(/\/ez:([a-z0-9-]+)/gi, (_, commandName) => {
648
+ return `$ez-${String(commandName).toLowerCase()}`;
649
+ });
650
+ converted = converted.replace(/\/ez-help\b/g, '$ez-help');
651
+ return converted;
652
+ }
653
+
654
+ function convertClaudeToCodexMarkdown(content) {
655
+ let converted = convertSlashCommandsToCodexSkillMentions(content);
656
+ converted = converted.replace(/\$ARGUMENTS\b/g, '{{EZ_ARGS}}');
657
+ return converted;
658
+ }
659
+
660
+ function getCodexSkillAdapterHeader(skillName) {
661
+ const invocation = `$${skillName}`;
662
+ return `<codex_skill_adapter>
663
+ ## A. Skill Invocation
664
+ - This skill is invoked by mentioning \`${invocation}\`.
665
+ - Treat all user text after \`${invocation}\` as \`{{EZ_ARGS}}\`.
666
+ - If no arguments are present, treat \`{{EZ_ARGS}}\` as empty.
667
+
668
+ ## B. AskUserQuestion → request_user_input Mapping
669
+ EZ Agents workflows use \`AskUserQuestion\` (Claude Code syntax). Translate to Codex \`request_user_input\`:
670
+
671
+ Parameter mapping:
672
+ - \`header\` → \`header\`
673
+ - \`question\` → \`question\`
674
+ - Options formatted as \`"Label" — description\` → \`{label: "Label", description: "description"}\`
675
+ - Generate \`id\` from header: lowercase, replace spaces with underscores
676
+
677
+ Batched calls:
678
+ - \`AskUserQuestion([q1, q2])\` → single \`request_user_input\` with multiple entries in \`questions[]\`
679
+
680
+ Multi-select workaround:
681
+ - Codex has no \`multiSelect\`. Use sequential single-selects, or present a numbered freeform list asking the user to enter comma-separated numbers.
682
+
683
+ Execute mode fallback:
684
+ - When \`request_user_input\` is rejected (Execute mode), present a plain-text numbered list and pick a reasonable default.
685
+
686
+ ## C. Task() → spawn_agent Mapping
687
+ EZ Agents workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools:
688
+
689
+ Direct mapping:
690
+ - \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
691
+ - \`Task(model="...")\` → omit (Codex uses per-role config, not inline model selection)
692
+ - \`fork_context: false\` by default — EZ Agents agents load their own context via \`<files_to_read>\` blocks
693
+
694
+ Parallel fan-out:
695
+ - Spawn multiple agents → collect agent IDs → \`wait(ids)\` for all to complete
696
+
697
+ Result parsing:
698
+ - Look for structured markers in agent output: \`CHECKPOINT\`, \`PLAN COMPLETE\`, \`SUMMARY\`, etc.
699
+ - \`close_agent(id)\` after collecting results from each agent
700
+ </codex_skill_adapter>`;
701
+ }
702
+
703
+ function convertClaudeCommandToCodexSkill(content, skillName) {
704
+ const converted = convertClaudeToCodexMarkdown(content);
705
+ const { frontmatter, body } = extractFrontmatterAndBody(converted);
706
+ let description = `Run EZ Agents workflow ${skillName}.`;
707
+ if (frontmatter) {
708
+ const maybeDescription = extractFrontmatterField(frontmatter, 'description');
709
+ if (maybeDescription) {
710
+ description = maybeDescription;
711
+ }
712
+ }
713
+ description = toSingleLine(description);
714
+ const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
715
+ const adapter = getCodexSkillAdapterHeader(skillName);
716
+
717
+ return `---\nname: ${yamlQuote(skillName)}\ndescription: ${yamlQuote(description)}\nmetadata:\n short-description: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
718
+ }
719
+
720
+ /**
721
+ * Convert Claude Code agent markdown to Codex agent format.
722
+ * Applies base markdown conversions, then adds a <codex_agent_role> header
723
+ * and cleans up frontmatter (removes tools/color fields).
724
+ */
725
+ function convertClaudeAgentToCodexAgent(content) {
726
+ let converted = convertClaudeToCodexMarkdown(content);
727
+
728
+ const { frontmatter, body } = extractFrontmatterAndBody(converted);
729
+ if (!frontmatter) return converted;
730
+
731
+ const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
732
+ const description = extractFrontmatterField(frontmatter, 'description') || '';
733
+ const tools = extractFrontmatterField(frontmatter, 'tools') || '';
734
+
735
+ const roleHeader = `<codex_agent_role>
736
+ role: ${name}
737
+ tools: ${tools}
738
+ purpose: ${toSingleLine(description)}
739
+ </codex_agent_role>`;
740
+
741
+ const cleanFrontmatter = `---\nname: ${yamlQuote(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
742
+
743
+ return `${cleanFrontmatter}\n\n${roleHeader}\n${body}`;
744
+ }
745
+
746
+ /**
747
+ * Generate a per-agent .toml config file for Codex.
748
+ * Sets sandbox_mode and developer_instructions from the agent markdown body.
749
+ */
750
+ function generateCodexAgentToml(agentName, agentContent) {
751
+ const sandboxMode = CODEX_AGENT_SANDBOX[agentName] || 'read-only';
752
+ const { body } = extractFrontmatterAndBody(agentContent);
753
+ const instructions = body.trim();
754
+
755
+ const lines = [
756
+ `sandbox_mode = "${sandboxMode}"`,
757
+ // Agent prompts contain raw backslashes in regexes and shell snippets.
758
+ // TOML literal multiline strings preserve them without escape parsing.
759
+ `developer_instructions = '''`,
760
+ instructions,
761
+ `'''`,
762
+ ];
763
+ return lines.join('\n') + '\n';
764
+ }
765
+
766
+ /**
767
+ * Generate the EZ Agents config block for Codex config.toml.
768
+ * @param {Array<{name: string, description: string}>} agents
769
+ */
770
+ function generateCodexConfigBlock(agents) {
771
+ const lines = [
772
+ EZ_CODEX_MARKER,
773
+ '',
774
+ ];
775
+
776
+ for (const { name, description } of agents) {
777
+ lines.push(`[agents.${name}]`);
778
+ lines.push(`description = ${JSON.stringify(description)}`);
779
+ lines.push(`config_file = "agents/${name}.toml"`);
780
+ lines.push('');
781
+ }
782
+
783
+ return lines.join('\n');
784
+ }
785
+
786
+ /**
787
+ * Strip EZ Agents sections from Codex config.toml content.
788
+ * Returns cleaned content, or null if file would be empty.
789
+ */
790
+ function stripGsdFromCodexConfig(content) {
791
+ const markerIndex = content.indexOf(EZ_CODEX_MARKER);
792
+
793
+ if (markerIndex !== -1) {
794
+ // Has EZ Agents marker — remove everything from marker to EOF
795
+ let before = content.substring(0, markerIndex).trimEnd();
796
+ // Also strip EZ Agents-injected feature keys above the marker (Case 3 inject)
797
+ before = before.replace(/^multi_agent\s*=\s*true\s*\n?/m, '');
798
+ before = before.replace(/^default_mode_request_user_input\s*=\s*true\s*\n?/m, '');
799
+ before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
800
+ before = before.replace(/\n{3,}/g, '\n\n').trim();
801
+ if (!before) return null;
802
+ return before + '\n';
803
+ }
804
+
805
+ // No marker but may have EZ Agents-injected feature keys
806
+ let cleaned = content;
807
+ cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*\n?/m, '');
808
+ cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*\n?/m, '');
809
+
810
+ // Remove [agents.ez-*] sections (from header to next section or EOF)
811
+ cleaned = cleaned.replace(/^\[agents\.ez-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, '');
812
+
813
+ // Remove [features] section if now empty (only header, no keys before next section)
814
+ cleaned = cleaned.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
815
+
816
+ // Remove [agents] section if now empty
817
+ cleaned = cleaned.replace(/^\[agents\]\s*\n(?=\[|$)/m, '');
818
+
819
+ // Clean up excessive blank lines
820
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
821
+
822
+ if (!cleaned) return null;
823
+ return cleaned + '\n';
824
+ }
825
+
826
+ /**
827
+ * Merge EZ Agents config block into an existing or new config.toml.
828
+ * Three cases: new file, existing with EZ Agents marker, existing without marker.
829
+ */
830
+ function mergeCodexConfig(configPath, gsdBlock) {
831
+ // Case 1: No config.toml — create fresh
832
+ if (!fs.existsSync(configPath)) {
833
+ fs.writeFileSync(configPath, gsdBlock + '\n');
834
+ return;
835
+ }
836
+
837
+ const existing = fs.readFileSync(configPath, 'utf8');
838
+ const markerIndex = existing.indexOf(EZ_CODEX_MARKER);
839
+
840
+ // Case 2: Has EZ Agents marker — truncate and re-append
841
+ if (markerIndex !== -1) {
842
+ let before = existing.substring(0, markerIndex).trimEnd();
843
+ if (before) {
844
+ // Strip any EZ Agents-managed sections that leaked above the marker from previous installs
845
+ before = before.replace(/^\[agents\.ez-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, '');
846
+ before = before.replace(/^\[agents\]\n(?:(?!\[)[^\n]*\n?)*/m, '');
847
+ before = before.replace(/\n{3,}/g, '\n\n').trimEnd();
848
+
849
+ fs.writeFileSync(configPath, before + '\n\n' + gsdBlock + '\n');
850
+ } else {
851
+ fs.writeFileSync(configPath, gsdBlock + '\n');
852
+ }
853
+ return;
854
+ }
855
+
856
+ // Case 3: No marker — append EZ Agents block
857
+ let content = existing;
858
+ content = content.trimEnd() + '\n\n' + gsdBlock + '\n';
859
+
860
+ fs.writeFileSync(configPath, content);
861
+ }
862
+
863
+ /**
864
+ * Merge EZ Agents instructions into copilot-instructions.md.
865
+ * Three cases: new file, existing with markers, existing without markers.
866
+ * @param {string} filePath - Full path to copilot-instructions.md
867
+ * @param {string} gsdContent - Template content (without markers)
868
+ */
869
+ function mergeCopilotInstructions(filePath, gsdContent) {
870
+ const gsdBlock = EZ_COPILOT_INSTRUCTIONS_MARKER + '\n' +
871
+ gsdContent.trim() + '\n' +
872
+ EZ_COPILOT_INSTRUCTIONS_CLOSE_MARKER;
873
+
874
+ // Case 1: No file — create fresh
875
+ if (!fs.existsSync(filePath)) {
876
+ fs.writeFileSync(filePath, gsdBlock + '\n');
877
+ return;
878
+ }
879
+
880
+ const existing = fs.readFileSync(filePath, 'utf8');
881
+ const openIndex = existing.indexOf(EZ_COPILOT_INSTRUCTIONS_MARKER);
882
+ const closeIndex = existing.indexOf(EZ_COPILOT_INSTRUCTIONS_CLOSE_MARKER);
883
+
884
+ // Case 2: Has EZ Agents markers — replace between markers
885
+ if (openIndex !== -1 && closeIndex !== -1) {
886
+ const before = existing.substring(0, openIndex).trimEnd();
887
+ const after = existing.substring(closeIndex + EZ_COPILOT_INSTRUCTIONS_CLOSE_MARKER.length).trimStart();
888
+ let newContent = '';
889
+ if (before) newContent += before + '\n\n';
890
+ newContent += gsdBlock;
891
+ if (after) newContent += '\n\n' + after;
892
+ newContent += '\n';
893
+ fs.writeFileSync(filePath, newContent);
894
+ return;
895
+ }
896
+
897
+ // Case 3: No markers — append at end
898
+ const content = existing.trimEnd() + '\n\n' + gsdBlock + '\n';
899
+ fs.writeFileSync(filePath, content);
900
+ }
901
+
902
+ /**
903
+ * Strip EZ Agents section from copilot-instructions.md content.
904
+ * Returns cleaned content, or null if file should be deleted (was GSD-only).
905
+ * @param {string} content - File content
906
+ * @returns {string|null} - Cleaned content or null if empty
907
+ */
908
+ function stripGsdFromCopilotInstructions(content) {
909
+ const openIndex = content.indexOf(EZ_COPILOT_INSTRUCTIONS_MARKER);
910
+ const closeIndex = content.indexOf(EZ_COPILOT_INSTRUCTIONS_CLOSE_MARKER);
911
+
912
+ if (openIndex !== -1 && closeIndex !== -1) {
913
+ const before = content.substring(0, openIndex).trimEnd();
914
+ const after = content.substring(closeIndex + EZ_COPILOT_INSTRUCTIONS_CLOSE_MARKER.length).trimStart();
915
+ const cleaned = (before + (before && after ? '\n\n' : '') + after).trim();
916
+ if (!cleaned) return null;
917
+ return cleaned + '\n';
918
+ }
919
+
920
+ // No markers found — nothing to strip
921
+ return content;
922
+ }
923
+
924
+ /**
925
+ * Generate config.toml and per-agent .toml files for Codex.
926
+ * Reads agent .md files from source, extracts metadata, writes .toml configs.
927
+ */
928
+ function installCodexConfig(targetDir, agentsSrc) {
929
+ const configPath = path.join(targetDir, 'config.toml');
930
+ const agentsTomlDir = path.join(targetDir, 'agents');
931
+ fs.mkdirSync(agentsTomlDir, { recursive: true });
932
+
933
+ const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
934
+ const agents = [];
935
+
936
+ // Compute the Codex pathPrefix for replacing .claude paths
937
+ // Use tilde-based path to avoid baking absolute paths into templates
938
+ const codexPathPrefix = `${targetDir.replace(/\\/g, '/').replace(os.homedir().replace(/\\/g, '/'), '~')}/`;
939
+
940
+ for (const file of agentEntries) {
941
+ let content = fs.readFileSync(path.join(agentsSrc, file), 'utf8');
942
+ // Replace .claude paths before generating TOML (source files use ~/.claude and $HOME/.claude)
943
+ content = content.replace(/~\/\.claude\//g, codexPathPrefix);
944
+ content = content.replace(/\$HOME\/\.claude\//g, toHomePrefix(codexPathPrefix));
945
+ const { frontmatter } = extractFrontmatterAndBody(content);
946
+ const name = extractFrontmatterField(frontmatter, 'name') || file.replace('.md', '');
947
+ const description = extractFrontmatterField(frontmatter, 'description') || '';
948
+
949
+ agents.push({ name, description: toSingleLine(description) });
950
+
951
+ const tomlContent = generateCodexAgentToml(name, content);
952
+ fs.writeFileSync(path.join(agentsTomlDir, `${name}.toml`), tomlContent);
953
+ }
954
+
955
+ const gsdBlock = generateCodexConfigBlock(agents);
956
+ mergeCodexConfig(configPath, gsdBlock);
957
+
958
+ return agents.length;
959
+ }
960
+
961
+ /**
962
+ * Strip HTML <sub> tags for Gemini CLI output
963
+ * Terminals don't support subscript — Gemini renders these as raw HTML.
964
+ * Converts <sub>text</sub> to italic *(text)* for readable terminal output.
965
+ */
966
+ function stripSubTags(content) {
967
+ return content.replace(/<sub>(.*?)<\/sub>/g, '*($1)*');
968
+ }
969
+
970
+ /**
971
+ * Convert Claude Code agent frontmatter to Gemini CLI format
972
+ * Gemini agents use .md files with YAML frontmatter, same as Claude,
973
+ * but with different field names and formats:
974
+ * - tools: must be a YAML array (not comma-separated string)
975
+ * - tool names: must use Gemini built-in names (read_file, not Read)
976
+ * - color: must be removed (causes validation error)
977
+ * - skills: must be removed (causes validation error)
978
+ * - mcp__* tools: must be excluded (auto-discovered at runtime)
979
+ */
980
+ function convertClaudeToGeminiAgent(content) {
981
+ if (!content.startsWith('---')) return content;
982
+
983
+ const endIndex = content.indexOf('---', 3);
984
+ if (endIndex === -1) return content;
985
+
986
+ const frontmatter = content.substring(3, endIndex).trim();
987
+ const body = content.substring(endIndex + 3);
988
+
989
+ const lines = frontmatter.split('\n');
990
+ const newLines = [];
991
+ let inAllowedTools = false;
992
+ let inSkippedArrayField = false;
993
+ const tools = [];
994
+
995
+ for (const line of lines) {
996
+ const trimmed = line.trim();
997
+
998
+ if (inSkippedArrayField) {
999
+ if (!trimmed || trimmed.startsWith('- ')) {
1000
+ continue;
1001
+ }
1002
+ inSkippedArrayField = false;
1003
+ }
1004
+
1005
+ // Convert allowed-tools YAML array to tools list
1006
+ if (trimmed.startsWith('allowed-tools:')) {
1007
+ inAllowedTools = true;
1008
+ continue;
1009
+ }
1010
+
1011
+ // Handle inline tools: field (comma-separated string)
1012
+ if (trimmed.startsWith('tools:')) {
1013
+ const toolsValue = trimmed.substring(6).trim();
1014
+ if (toolsValue) {
1015
+ const parsed = toolsValue.split(',').map(t => t.trim()).filter(t => t);
1016
+ for (const t of parsed) {
1017
+ const mapped = convertGeminiToolName(t);
1018
+ if (mapped) tools.push(mapped);
1019
+ }
1020
+ } else {
1021
+ // tools: with no value means YAML array follows
1022
+ inAllowedTools = true;
1023
+ }
1024
+ continue;
1025
+ }
1026
+
1027
+ // Strip color field (not supported by Gemini CLI, causes validation error)
1028
+ if (trimmed.startsWith('color:')) continue;
1029
+
1030
+ // Strip skills field (not supported by Gemini CLI, causes validation error)
1031
+ if (trimmed.startsWith('skills:')) {
1032
+ inSkippedArrayField = true;
1033
+ continue;
1034
+ }
1035
+
1036
+ // Collect allowed-tools/tools array items
1037
+ if (inAllowedTools) {
1038
+ if (trimmed.startsWith('- ')) {
1039
+ const mapped = convertGeminiToolName(trimmed.substring(2).trim());
1040
+ if (mapped) tools.push(mapped);
1041
+ continue;
1042
+ } else if (trimmed && !trimmed.startsWith('-')) {
1043
+ inAllowedTools = false;
1044
+ }
1045
+ }
1046
+
1047
+ if (!inAllowedTools) {
1048
+ newLines.push(line);
1049
+ }
1050
+ }
1051
+
1052
+ // Add tools as YAML array (Gemini requires array format)
1053
+ if (tools.length > 0) {
1054
+ newLines.push('tools:');
1055
+ for (const tool of tools) {
1056
+ newLines.push(` - ${tool}`);
1057
+ }
1058
+ }
1059
+
1060
+ const newFrontmatter = newLines.join('\n').trim();
1061
+
1062
+ // Escape ${VAR} patterns in agent body for Gemini CLI compatibility.
1063
+ // Gemini's templateString() treats all ${word} patterns as template variables
1064
+ // and throws "Template validation failed: Missing required input parameters"
1065
+ // when they can't be resolved. EZ Agents agents use ${PHASE}, ${PLAN}, etc. as
1066
+ // shell variables in bash code blocks — convert to $VAR (no braces) which
1067
+ // is equivalent bash and invisible to Gemini's /\$\{(\w+)\}/g regex.
1068
+ const escapedBody = body.replace(/\$\{(\w+)\}/g, '$$$1');
1069
+
1070
+ return `---\n${newFrontmatter}\n---${stripSubTags(escapedBody)}`;
1071
+ }
1072
+
1073
+ function convertClaudeToOpencodeFrontmatter(content) {
1074
+ // Replace tool name references in content (applies to all files)
1075
+ let convertedContent = content;
1076
+ convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
1077
+ convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
1078
+ convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
1079
+ // Replace /ez:command with /ez-command for opencode (flat command structure)
1080
+ convertedContent = convertedContent.replace(/\/ez:/g, '/ez-');
1081
+ // Replace ~/.claude and $HOME/.claude with OpenCode's config location
1082
+ convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/opencode');
1083
+ convertedContent = convertedContent.replace(/\$HOME\/\.claude\b/g, '$HOME/.config/opencode');
1084
+ // Replace general-purpose subagent type with OpenCode's equivalent "general"
1085
+ convertedContent = convertedContent.replace(/subagent_type="general-purpose"/g, 'subagent_type="general"');
1086
+
1087
+ // Check if content has frontmatter
1088
+ if (!convertedContent.startsWith('---')) {
1089
+ return convertedContent;
1090
+ }
1091
+
1092
+ // Find the end of frontmatter
1093
+ const endIndex = convertedContent.indexOf('---', 3);
1094
+ if (endIndex === -1) {
1095
+ return convertedContent;
1096
+ }
1097
+
1098
+ const frontmatter = convertedContent.substring(3, endIndex).trim();
1099
+ const body = convertedContent.substring(endIndex + 3);
1100
+
1101
+ // Parse frontmatter line by line (simple YAML parsing)
1102
+ const lines = frontmatter.split('\n');
1103
+ const newLines = [];
1104
+ let inAllowedTools = false;
1105
+ const allowedTools = [];
1106
+
1107
+ for (const line of lines) {
1108
+ const trimmed = line.trim();
1109
+
1110
+ // Detect start of allowed-tools array
1111
+ if (trimmed.startsWith('allowed-tools:')) {
1112
+ inAllowedTools = true;
1113
+ continue;
1114
+ }
1115
+
1116
+ // Detect inline tools: field (comma-separated string)
1117
+ if (trimmed.startsWith('tools:')) {
1118
+ const toolsValue = trimmed.substring(6).trim();
1119
+ if (toolsValue) {
1120
+ // Parse comma-separated tools
1121
+ const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t);
1122
+ allowedTools.push(...tools);
1123
+ }
1124
+ continue;
1125
+ }
1126
+
1127
+ // Remove name: field - opencode uses filename for command name
1128
+ if (trimmed.startsWith('name:')) {
1129
+ continue;
1130
+ }
1131
+
1132
+ // Convert color names to hex for opencode
1133
+ if (trimmed.startsWith('color:')) {
1134
+ const colorValue = trimmed.substring(6).trim().toLowerCase();
1135
+ const hexColor = colorNameToHex[colorValue];
1136
+ if (hexColor) {
1137
+ newLines.push(`color: "${hexColor}"`);
1138
+ } else if (colorValue.startsWith('#')) {
1139
+ // Validate hex color format (#RGB or #RRGGBB)
1140
+ if (/^#[0-9a-f]{3}$|^#[0-9a-f]{6}$/i.test(colorValue)) {
1141
+ // Already hex and valid, keep as is
1142
+ newLines.push(line);
1143
+ }
1144
+ // Skip invalid hex colors
1145
+ }
1146
+ // Skip unknown color names
1147
+ continue;
1148
+ }
1149
+
1150
+ // Collect allowed-tools items
1151
+ if (inAllowedTools) {
1152
+ if (trimmed.startsWith('- ')) {
1153
+ allowedTools.push(trimmed.substring(2).trim());
1154
+ continue;
1155
+ } else if (trimmed && !trimmed.startsWith('-')) {
1156
+ // End of array, new field started
1157
+ inAllowedTools = false;
1158
+ }
1159
+ }
1160
+
1161
+ // Keep other fields (including name: which opencode ignores)
1162
+ if (!inAllowedTools) {
1163
+ newLines.push(line);
1164
+ }
1165
+ }
1166
+
1167
+ // Add tools object if we had allowed-tools or tools
1168
+ if (allowedTools.length > 0) {
1169
+ newLines.push('tools:');
1170
+ for (const tool of allowedTools) {
1171
+ newLines.push(` ${convertToolName(tool)}: true`);
1172
+ }
1173
+ }
1174
+
1175
+ // Rebuild frontmatter (body already has tool names converted)
1176
+ const newFrontmatter = newLines.join('\n').trim();
1177
+ return `---\n${newFrontmatter}\n---${body}`;
1178
+ }
1179
+
1180
+ /**
1181
+ * Convert Claude Code markdown command to Gemini TOML format
1182
+ * @param {string} content - Markdown file content with YAML frontmatter
1183
+ * @returns {string} - TOML content
1184
+ */
1185
+ function convertClaudeToGeminiToml(content) {
1186
+ // Check if content has frontmatter
1187
+ if (!content.startsWith('---')) {
1188
+ return `prompt = ${JSON.stringify(content)}\n`;
1189
+ }
1190
+
1191
+ const endIndex = content.indexOf('---', 3);
1192
+ if (endIndex === -1) {
1193
+ return `prompt = ${JSON.stringify(content)}\n`;
1194
+ }
1195
+
1196
+ const frontmatter = content.substring(3, endIndex).trim();
1197
+ const body = content.substring(endIndex + 3).trim();
1198
+
1199
+ // Extract description from frontmatter
1200
+ let description = '';
1201
+ const lines = frontmatter.split('\n');
1202
+ for (const line of lines) {
1203
+ const trimmed = line.trim();
1204
+ if (trimmed.startsWith('description:')) {
1205
+ description = trimmed.substring(12).trim();
1206
+ break;
1207
+ }
1208
+ }
1209
+
1210
+ // Construct TOML
1211
+ let toml = '';
1212
+ if (description) {
1213
+ toml += `description = ${JSON.stringify(description)}\n`;
1214
+ }
1215
+
1216
+ toml += `prompt = ${JSON.stringify(body)}\n`;
1217
+
1218
+ return toml;
1219
+ }
1220
+
1221
+ /**
1222
+ * Copy commands to a flat structure for OpenCode
1223
+ * OpenCode expects: command/gsd-help.md (invoked as /gsd-help)
1224
+ * Source structure: commands/gsd/help.md
1225
+ *
1226
+ * @param {string} srcDir - Source directory (e.g., commands/gsd/)
1227
+ * @param {string} destDir - Destination directory (e.g., command/)
1228
+ * @param {string} prefix - Prefix for filenames (e.g., 'gsd')
1229
+ * @param {string} pathPrefix - Path prefix for file references
1230
+ * @param {string} runtime - Target runtime ('claude' or 'opencode')
1231
+ */
1232
+ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
1233
+ if (!fs.existsSync(srcDir)) {
1234
+ return;
1235
+ }
1236
+
1237
+ // Remove old gsd-*.md files before copying new ones
1238
+ if (fs.existsSync(destDir)) {
1239
+ for (const file of fs.readdirSync(destDir)) {
1240
+ if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
1241
+ fs.unlinkSync(path.join(destDir, file));
1242
+ }
1243
+ }
1244
+ } else {
1245
+ fs.mkdirSync(destDir, { recursive: true });
1246
+ }
1247
+
1248
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
1249
+
1250
+ for (const entry of entries) {
1251
+ const srcPath = path.join(srcDir, entry.name);
1252
+
1253
+ if (entry.isDirectory()) {
1254
+ // Recurse into subdirectories, adding to prefix
1255
+ // e.g., commands/gsd/debug/start.md -> command/gsd-debug-start.md
1256
+ copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
1257
+ } else if (entry.name.endsWith('.md')) {
1258
+ // Flatten: help.md -> gsd-help.md
1259
+ const baseName = entry.name.replace('.md', '');
1260
+ const destName = `${prefix}-${baseName}.md`;
1261
+ const destPath = path.join(destDir, destName);
1262
+
1263
+ let content = fs.readFileSync(srcPath, 'utf8');
1264
+ const globalClaudeRegex = /~\/\.claude\//g;
1265
+ const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
1266
+ const localClaudeRegex = /\.\/\.claude\//g;
1267
+ const opencodeDirRegex = /~\/\.opencode\//g;
1268
+ content = content.replace(globalClaudeRegex, pathPrefix);
1269
+ content = content.replace(globalClaudeHomeRegex, toHomePrefix(pathPrefix));
1270
+ content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
1271
+ content = content.replace(opencodeDirRegex, pathPrefix);
1272
+ content = processAttribution(content, getCommitAttribution(runtime));
1273
+ content = convertClaudeToOpencodeFrontmatter(content);
1274
+
1275
+ fs.writeFileSync(destPath, content);
1276
+ }
1277
+ }
1278
+ }
1279
+
1280
+ function listCodexSkillNames(skillsDir, prefix = 'gsd-') {
1281
+ if (!fs.existsSync(skillsDir)) return [];
1282
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
1283
+ return entries
1284
+ .filter(entry => entry.isDirectory() && entry.name.startsWith(prefix))
1285
+ .filter(entry => fs.existsSync(path.join(skillsDir, entry.name, 'SKILL.md')))
1286
+ .map(entry => entry.name)
1287
+ .sort();
1288
+ }
1289
+
1290
+ function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) {
1291
+ if (!fs.existsSync(srcDir)) {
1292
+ return;
1293
+ }
1294
+
1295
+ fs.mkdirSync(skillsDir, { recursive: true });
1296
+
1297
+ // Remove previous GSD Codex skills to avoid stale command skills.
1298
+ const existing = fs.readdirSync(skillsDir, { withFileTypes: true });
1299
+ for (const entry of existing) {
1300
+ if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
1301
+ fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
1302
+ }
1303
+ }
1304
+
1305
+ function recurse(currentSrcDir, currentPrefix) {
1306
+ const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true });
1307
+
1308
+ for (const entry of entries) {
1309
+ const srcPath = path.join(currentSrcDir, entry.name);
1310
+ if (entry.isDirectory()) {
1311
+ recurse(srcPath, `${currentPrefix}-${entry.name}`);
1312
+ continue;
1313
+ }
1314
+
1315
+ if (!entry.name.endsWith('.md')) {
1316
+ continue;
1317
+ }
1318
+
1319
+ const baseName = entry.name.replace('.md', '');
1320
+ const skillName = `${currentPrefix}-${baseName}`;
1321
+ const skillDir = path.join(skillsDir, skillName);
1322
+ fs.mkdirSync(skillDir, { recursive: true });
1323
+
1324
+ let content = fs.readFileSync(srcPath, 'utf8');
1325
+ const globalClaudeRegex = /~\/\.claude\//g;
1326
+ const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
1327
+ const localClaudeRegex = /\.\/\.claude\//g;
1328
+ const codexDirRegex = /~\/\.codex\//g;
1329
+ content = content.replace(globalClaudeRegex, pathPrefix);
1330
+ content = content.replace(globalClaudeHomeRegex, toHomePrefix(pathPrefix));
1331
+ content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
1332
+ content = content.replace(codexDirRegex, pathPrefix);
1333
+ content = processAttribution(content, getCommitAttribution(runtime));
1334
+ content = convertClaudeCommandToCodexSkill(content, skillName);
1335
+
1336
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
1337
+ }
1338
+ }
1339
+
1340
+ recurse(srcDir, prefix);
1341
+ }
1342
+
1343
+ /**
1344
+ * Copy Claude commands as Copilot skills — one folder per skill with SKILL.md.
1345
+ * Applies CONV-01 (structure), CONV-02 (allowed-tools), CONV-06 (paths), CONV-07 (command names).
1346
+ */
1347
+ function copyCommandsAsCopilotSkills(srcDir, skillsDir, prefix, isGlobal = false) {
1348
+ if (!fs.existsSync(srcDir)) {
1349
+ return;
1350
+ }
1351
+
1352
+ fs.mkdirSync(skillsDir, { recursive: true });
1353
+
1354
+ // Remove previous GSD Copilot skills
1355
+ const existing = fs.readdirSync(skillsDir, { withFileTypes: true });
1356
+ for (const entry of existing) {
1357
+ if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
1358
+ fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
1359
+ }
1360
+ }
1361
+
1362
+ function recurse(currentSrcDir, currentPrefix) {
1363
+ const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true });
1364
+
1365
+ for (const entry of entries) {
1366
+ const srcPath = path.join(currentSrcDir, entry.name);
1367
+ if (entry.isDirectory()) {
1368
+ recurse(srcPath, `${currentPrefix}-${entry.name}`);
1369
+ continue;
1370
+ }
1371
+
1372
+ if (!entry.name.endsWith('.md')) {
1373
+ continue;
1374
+ }
1375
+
1376
+ const baseName = entry.name.replace('.md', '');
1377
+ const skillName = `${currentPrefix}-${baseName}`;
1378
+ const skillDir = path.join(skillsDir, skillName);
1379
+ fs.mkdirSync(skillDir, { recursive: true });
1380
+
1381
+ let content = fs.readFileSync(srcPath, 'utf8');
1382
+ content = convertClaudeCommandToCopilotSkill(content, skillName, isGlobal);
1383
+ content = processAttribution(content, getCommitAttribution('copilot'));
1384
+
1385
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
1386
+ }
1387
+ }
1388
+
1389
+ recurse(srcDir, prefix);
1390
+ }
1391
+
1392
+ /**
1393
+ * Recursively copy directory, replacing paths in .md files
1394
+ * Deletes existing destDir first to remove orphaned files from previous versions
1395
+ * @param {string} srcDir - Source directory
1396
+ * @param {string} destDir - Destination directory
1397
+ * @param {string} pathPrefix - Path prefix for file references
1398
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex')
1399
+ */
1400
+ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false) {
1401
+ const isOpencode = runtime === 'opencode';
1402
+ const isCodex = runtime === 'codex';
1403
+ const isCopilot = runtime === 'copilot';
1404
+ const dirName = getDirName(runtime);
1405
+
1406
+ // Clean install: remove existing destination to prevent orphaned files
1407
+ if (fs.existsSync(destDir)) {
1408
+ fs.rmSync(destDir, { recursive: true });
1409
+ }
1410
+ fs.mkdirSync(destDir, { recursive: true });
1411
+
1412
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
1413
+
1414
+ for (const entry of entries) {
1415
+ const srcPath = path.join(srcDir, entry.name);
1416
+ const destPath = path.join(destDir, entry.name);
1417
+
1418
+ if (entry.isDirectory()) {
1419
+ copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal);
1420
+ } else if (entry.name.endsWith('.md')) {
1421
+ // Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths
1422
+ // Skip generic replacement for Copilot — convertClaudeToCopilotContent handles all paths
1423
+ let content = fs.readFileSync(srcPath, 'utf8');
1424
+ if (!isCopilot) {
1425
+ const globalClaudeRegex = /~\/\.claude\//g;
1426
+ const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
1427
+ const localClaudeRegex = /\.\/\.claude\//g;
1428
+ content = content.replace(globalClaudeRegex, pathPrefix);
1429
+ content = content.replace(globalClaudeHomeRegex, toHomePrefix(pathPrefix));
1430
+ content = content.replace(localClaudeRegex, `./${dirName}/`);
1431
+ }
1432
+ content = processAttribution(content, getCommitAttribution(runtime));
1433
+
1434
+ // Convert frontmatter for opencode compatibility
1435
+ if (isOpencode) {
1436
+ content = convertClaudeToOpencodeFrontmatter(content);
1437
+ fs.writeFileSync(destPath, content);
1438
+ } else if (runtime === 'gemini') {
1439
+ if (isCommand) {
1440
+ // Convert to TOML for Gemini (strip <sub> tags — terminals can't render subscript)
1441
+ content = stripSubTags(content);
1442
+ const tomlContent = convertClaudeToGeminiToml(content);
1443
+ // Replace extension with .toml
1444
+ const tomlPath = destPath.replace(/\.md$/, '.toml');
1445
+ fs.writeFileSync(tomlPath, tomlContent);
1446
+ } else {
1447
+ fs.writeFileSync(destPath, content);
1448
+ }
1449
+ } else if (isCodex) {
1450
+ content = convertClaudeToCodexMarkdown(content);
1451
+ fs.writeFileSync(destPath, content);
1452
+ } else if (isCopilot) {
1453
+ content = convertClaudeToCopilotContent(content, isGlobal);
1454
+ content = processAttribution(content, getCommitAttribution(runtime));
1455
+ fs.writeFileSync(destPath, content);
1456
+ } else {
1457
+ fs.writeFileSync(destPath, content);
1458
+ }
1459
+ } else if (isCopilot && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
1460
+ // Copilot: also transform .cjs/.js files for CONV-06 and CONV-07
1461
+ let content = fs.readFileSync(srcPath, 'utf8');
1462
+ content = convertClaudeToCopilotContent(content, isGlobal);
1463
+ fs.writeFileSync(destPath, content);
1464
+ } else {
1465
+ fs.copyFileSync(srcPath, destPath);
1466
+ }
1467
+ }
1468
+ }
1469
+
1470
+ /**
1471
+ * Clean up orphaned files from previous GSD versions
1472
+ */
1473
+ function cleanupOrphanedFiles(configDir) {
1474
+ const orphanedFiles = [
1475
+ 'hooks/gsd-notify.sh', // Removed in v1.6.x
1476
+ 'hooks/statusline.js', // Renamed to gsd-statusline.js in v1.9.0
1477
+ ];
1478
+
1479
+ for (const relPath of orphanedFiles) {
1480
+ const fullPath = path.join(configDir, relPath);
1481
+ if (fs.existsSync(fullPath)) {
1482
+ fs.unlinkSync(fullPath);
1483
+ console.log(` ${green}✓${reset} Removed orphaned ${relPath}`);
1484
+ }
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * Clean up orphaned hook registrations from settings.json
1490
+ */
1491
+ function cleanupOrphanedHooks(settings) {
1492
+ const orphanedHookPatterns = [
1493
+ 'gsd-notify.sh', // Removed in v1.6.x
1494
+ 'hooks/statusline.js', // Renamed to gsd-statusline.js in v1.9.0
1495
+ 'gsd-intel-index.js', // Removed in v1.9.2
1496
+ 'gsd-intel-session.js', // Removed in v1.9.2
1497
+ 'gsd-intel-prune.js', // Removed in v1.9.2
1498
+ ];
1499
+
1500
+ let cleanedHooks = false;
1501
+
1502
+ // Check all hook event types (Stop, SessionStart, etc.)
1503
+ if (settings.hooks) {
1504
+ for (const eventType of Object.keys(settings.hooks)) {
1505
+ const hookEntries = settings.hooks[eventType];
1506
+ if (Array.isArray(hookEntries)) {
1507
+ // Filter out entries that contain orphaned hooks
1508
+ const filtered = hookEntries.filter(entry => {
1509
+ if (entry.hooks && Array.isArray(entry.hooks)) {
1510
+ // Check if any hook in this entry matches orphaned patterns
1511
+ const hasOrphaned = entry.hooks.some(h =>
1512
+ h.command && orphanedHookPatterns.some(pattern => h.command.includes(pattern))
1513
+ );
1514
+ if (hasOrphaned) {
1515
+ cleanedHooks = true;
1516
+ return false; // Remove this entry
1517
+ }
1518
+ }
1519
+ return true; // Keep this entry
1520
+ });
1521
+ settings.hooks[eventType] = filtered;
1522
+ }
1523
+ }
1524
+ }
1525
+
1526
+ if (cleanedHooks) {
1527
+ console.log(` ${green}✓${reset} Removed orphaned hook registrations`);
1528
+ }
1529
+
1530
+ // Fix #330: Update statusLine if it points to old GSD statusline.js path
1531
+ // Only match the specific old GSD path pattern (hooks/statusline.js),
1532
+ // not third-party statusline scripts that happen to contain 'statusline.js'
1533
+ if (settings.statusLine && settings.statusLine.command &&
1534
+ /hooks[\/\\]statusline\.js/.test(settings.statusLine.command)) {
1535
+ settings.statusLine.command = settings.statusLine.command.replace(
1536
+ /hooks([\/\\])statusline\.js/,
1537
+ 'hooks$1gsd-statusline.js'
1538
+ );
1539
+ console.log(` ${green}✓${reset} Updated statusline path (hooks/statusline.js → hooks/gsd-statusline.js)`);
1540
+ }
1541
+
1542
+ return settings;
1543
+ }
1544
+
1545
+ /**
1546
+ * Uninstall EZ Agents from the specified directory for a specific runtime
1547
+ * Removes only EZ Agents-specific files/directories, preserves user content
1548
+ * @param {boolean} isGlobal - Whether to uninstall from global or local
1549
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex', 'copilot')
1550
+ */
1551
+ function uninstall(isGlobal, runtime = 'claude') {
1552
+ const isOpencode = runtime === 'opencode';
1553
+ const isCodex = runtime === 'codex';
1554
+ const isCopilot = runtime === 'copilot';
1555
+ const dirName = getDirName(runtime);
1556
+
1557
+ // Get the target directory based on runtime and install type
1558
+ const targetDir = isGlobal
1559
+ ? getGlobalDir(runtime, explicitConfigDir)
1560
+ : path.join(process.cwd(), dirName);
1561
+
1562
+ const locationLabel = isGlobal
1563
+ ? targetDir.replace(os.homedir(), '~')
1564
+ : targetDir.replace(process.cwd(), '.');
1565
+
1566
+ let runtimeLabel = 'Claude Code';
1567
+ if (runtime === 'opencode') runtimeLabel = 'OpenCode';
1568
+ if (runtime === 'gemini') runtimeLabel = 'Gemini';
1569
+ if (runtime === 'codex') runtimeLabel = 'Codex';
1570
+ if (runtime === 'copilot') runtimeLabel = 'Copilot';
1571
+
1572
+ console.log(` Uninstalling EZ Agents from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
1573
+
1574
+ // Check if target directory exists
1575
+ if (!fs.existsSync(targetDir)) {
1576
+ console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
1577
+ console.log(` Nothing to uninstall.\n`);
1578
+ return;
1579
+ }
1580
+
1581
+ let removedCount = 0;
1582
+
1583
+ // 1. Remove GSD commands/skills
1584
+ if (isOpencode) {
1585
+ // OpenCode: remove command/gsd-*.md files
1586
+ const commandDir = path.join(targetDir, 'command');
1587
+ if (fs.existsSync(commandDir)) {
1588
+ const files = fs.readdirSync(commandDir);
1589
+ for (const file of files) {
1590
+ if (file.startsWith('gsd-') && file.endsWith('.md')) {
1591
+ fs.unlinkSync(path.join(commandDir, file));
1592
+ removedCount++;
1593
+ }
1594
+ }
1595
+ console.log(` ${green}✓${reset} Removed EZ Agents commands from command/`);
1596
+ }
1597
+ } else if (isCodex) {
1598
+ // Codex: remove skills/gsd-*/SKILL.md skill directories
1599
+ const skillsDir = path.join(targetDir, 'skills');
1600
+ if (fs.existsSync(skillsDir)) {
1601
+ let skillCount = 0;
1602
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
1603
+ for (const entry of entries) {
1604
+ if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
1605
+ fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
1606
+ skillCount++;
1607
+ }
1608
+ }
1609
+ if (skillCount > 0) {
1610
+ removedCount++;
1611
+ console.log(` ${green}✓${reset} Removed ${skillCount} Codex skills`);
1612
+ }
1613
+ }
1614
+
1615
+ // Codex: remove GSD agent .toml config files
1616
+ const codexAgentsDir = path.join(targetDir, 'agents');
1617
+ if (fs.existsSync(codexAgentsDir)) {
1618
+ const tomlFiles = fs.readdirSync(codexAgentsDir);
1619
+ let tomlCount = 0;
1620
+ for (const file of tomlFiles) {
1621
+ if (file.startsWith('gsd-') && file.endsWith('.toml')) {
1622
+ fs.unlinkSync(path.join(codexAgentsDir, file));
1623
+ tomlCount++;
1624
+ }
1625
+ }
1626
+ if (tomlCount > 0) {
1627
+ removedCount++;
1628
+ console.log(` ${green}✓${reset} Removed ${tomlCount} agent .toml configs`);
1629
+ }
1630
+ }
1631
+
1632
+ // Codex: clean GSD sections from config.toml
1633
+ const configPath = path.join(targetDir, 'config.toml');
1634
+ if (fs.existsSync(configPath)) {
1635
+ const content = fs.readFileSync(configPath, 'utf8');
1636
+ const cleaned = stripGsdFromCodexConfig(content);
1637
+ if (cleaned === null) {
1638
+ // File is empty after stripping — delete it
1639
+ fs.unlinkSync(configPath);
1640
+ removedCount++;
1641
+ console.log(` ${green}✓${reset} Removed config.toml (was EZ Agents-only)`);
1642
+ } else if (cleaned !== content) {
1643
+ fs.writeFileSync(configPath, cleaned);
1644
+ removedCount++;
1645
+ console.log(` ${green}✓${reset} Cleaned EZ Agents sections from config.toml`);
1646
+ }
1647
+ }
1648
+ } else if (isCopilot) {
1649
+ // Copilot: remove skills/gsd-*/ directories (same layout as Codex skills)
1650
+ const skillsDir = path.join(targetDir, 'skills');
1651
+ if (fs.existsSync(skillsDir)) {
1652
+ let skillCount = 0;
1653
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
1654
+ for (const entry of entries) {
1655
+ if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
1656
+ fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
1657
+ skillCount++;
1658
+ }
1659
+ }
1660
+ if (skillCount > 0) {
1661
+ removedCount++;
1662
+ console.log(` ${green}✓${reset} Removed ${skillCount} Copilot skills`);
1663
+ }
1664
+ }
1665
+
1666
+ // Copilot: clean GSD section from copilot-instructions.md
1667
+ const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
1668
+ if (fs.existsSync(instructionsPath)) {
1669
+ const content = fs.readFileSync(instructionsPath, 'utf8');
1670
+ const cleaned = stripGsdFromCopilotInstructions(content);
1671
+ if (cleaned === null) {
1672
+ fs.unlinkSync(instructionsPath);
1673
+ removedCount++;
1674
+ console.log(` ${green}✓${reset} Removed copilot-instructions.md (was EZ Agents-only)`);
1675
+ } else if (cleaned !== content) {
1676
+ fs.writeFileSync(instructionsPath, cleaned);
1677
+ removedCount++;
1678
+ console.log(` ${green}✓${reset} Cleaned EZ Agents section from copilot-instructions.md`);
1679
+ }
1680
+ }
1681
+ } else {
1682
+ const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
1683
+ if (fs.existsSync(gsdCommandsDir)) {
1684
+ fs.rmSync(gsdCommandsDir, { recursive: true });
1685
+ removedCount++;
1686
+ console.log(` ${green}✓${reset} Removed commands/gsd/`);
1687
+ }
1688
+ }
1689
+
1690
+ // 2. Remove get-shit-done directory
1691
+ const gsdDir = path.join(targetDir, 'get-shit-done');
1692
+ if (fs.existsSync(gsdDir)) {
1693
+ fs.rmSync(gsdDir, { recursive: true });
1694
+ removedCount++;
1695
+ console.log(` ${green}✓${reset} Removed get-shit-done/`);
1696
+ }
1697
+
1698
+ // 3. Remove EZ Agents agents (gsd-*.md files only)
1699
+ const agentsDir = path.join(targetDir, 'agents');
1700
+ if (fs.existsSync(agentsDir)) {
1701
+ const files = fs.readdirSync(agentsDir);
1702
+ let agentCount = 0;
1703
+ for (const file of files) {
1704
+ if (file.startsWith('gsd-') && file.endsWith('.md')) {
1705
+ fs.unlinkSync(path.join(agentsDir, file));
1706
+ agentCount++;
1707
+ }
1708
+ }
1709
+ if (agentCount > 0) {
1710
+ removedCount++;
1711
+ console.log(` ${green}✓${reset} Removed ${agentCount} EZ Agents agents`);
1712
+ }
1713
+ }
1714
+
1715
+ // 4. Remove GSD hooks
1716
+ const hooksDir = path.join(targetDir, 'hooks');
1717
+ if (fs.existsSync(hooksDir)) {
1718
+ const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js'];
1719
+ let hookCount = 0;
1720
+ for (const hook of gsdHooks) {
1721
+ const hookPath = path.join(hooksDir, hook);
1722
+ if (fs.existsSync(hookPath)) {
1723
+ fs.unlinkSync(hookPath);
1724
+ hookCount++;
1725
+ }
1726
+ }
1727
+ if (hookCount > 0) {
1728
+ removedCount++;
1729
+ console.log(` ${green}✓${reset} Removed ${hookCount} GSD hooks`);
1730
+ }
1731
+ }
1732
+
1733
+ // 5. Remove GSD package.json (CommonJS mode marker)
1734
+ const pkgJsonPath = path.join(targetDir, 'package.json');
1735
+ if (fs.existsSync(pkgJsonPath)) {
1736
+ try {
1737
+ const content = fs.readFileSync(pkgJsonPath, 'utf8').trim();
1738
+ // Only remove if it's our minimal CommonJS marker
1739
+ if (content === '{"type":"commonjs"}') {
1740
+ fs.unlinkSync(pkgJsonPath);
1741
+ removedCount++;
1742
+ console.log(` ${green}✓${reset} Removed GSD package.json`);
1743
+ }
1744
+ } catch (e) {
1745
+ // Ignore read errors
1746
+ }
1747
+ }
1748
+
1749
+ // 6. Clean up settings.json (remove GSD hooks and statusline)
1750
+ const settingsPath = path.join(targetDir, 'settings.json');
1751
+ if (fs.existsSync(settingsPath)) {
1752
+ let settings = readSettings(settingsPath);
1753
+ let settingsModified = false;
1754
+
1755
+ // Remove GSD statusline if it references our hook
1756
+ if (settings.statusLine && settings.statusLine.command &&
1757
+ settings.statusLine.command.includes('gsd-statusline')) {
1758
+ delete settings.statusLine;
1759
+ settingsModified = true;
1760
+ console.log(` ${green}✓${reset} Removed GSD statusline from settings`);
1761
+ }
1762
+
1763
+ // Remove GSD hooks from SessionStart
1764
+ if (settings.hooks && settings.hooks.SessionStart) {
1765
+ const before = settings.hooks.SessionStart.length;
1766
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry => {
1767
+ if (entry.hooks && Array.isArray(entry.hooks)) {
1768
+ // Filter out GSD hooks
1769
+ const hasGsdHook = entry.hooks.some(h =>
1770
+ h.command && (h.command.includes('gsd-check-update') || h.command.includes('gsd-statusline'))
1771
+ );
1772
+ return !hasGsdHook;
1773
+ }
1774
+ return true;
1775
+ });
1776
+ if (settings.hooks.SessionStart.length < before) {
1777
+ settingsModified = true;
1778
+ console.log(` ${green}✓${reset} Removed GSD hooks from settings`);
1779
+ }
1780
+ // Clean up empty array
1781
+ if (settings.hooks.SessionStart.length === 0) {
1782
+ delete settings.hooks.SessionStart;
1783
+ }
1784
+ }
1785
+
1786
+ // Remove GSD hooks from PostToolUse and AfterTool (Gemini uses AfterTool)
1787
+ for (const eventName of ['PostToolUse', 'AfterTool']) {
1788
+ if (settings.hooks && settings.hooks[eventName]) {
1789
+ const before = settings.hooks[eventName].length;
1790
+ settings.hooks[eventName] = settings.hooks[eventName].filter(entry => {
1791
+ if (entry.hooks && Array.isArray(entry.hooks)) {
1792
+ const hasGsdHook = entry.hooks.some(h =>
1793
+ h.command && h.command.includes('gsd-context-monitor')
1794
+ );
1795
+ return !hasGsdHook;
1796
+ }
1797
+ return true;
1798
+ });
1799
+ if (settings.hooks[eventName].length < before) {
1800
+ settingsModified = true;
1801
+ console.log(` ${green}✓${reset} Removed context monitor hook from settings`);
1802
+ }
1803
+ if (settings.hooks[eventName].length === 0) {
1804
+ delete settings.hooks[eventName];
1805
+ }
1806
+ }
1807
+ }
1808
+
1809
+ // Clean up empty hooks object
1810
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
1811
+ delete settings.hooks;
1812
+ }
1813
+
1814
+ if (settingsModified) {
1815
+ writeSettings(settingsPath, settings);
1816
+ removedCount++;
1817
+ }
1818
+ }
1819
+
1820
+ // 6. For OpenCode, clean up permissions from opencode.json
1821
+ if (isOpencode) {
1822
+ // For local uninstalls, clean up ./.opencode/opencode.json
1823
+ // For global uninstalls, clean up ~/.config/opencode/opencode.json
1824
+ const opencodeConfigDir = isGlobal
1825
+ ? getOpencodeGlobalDir()
1826
+ : path.join(process.cwd(), '.opencode');
1827
+ const configPath = path.join(opencodeConfigDir, 'opencode.json');
1828
+ if (fs.existsSync(configPath)) {
1829
+ try {
1830
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1831
+ let modified = false;
1832
+
1833
+ // Remove GSD permission entries
1834
+ if (config.permission) {
1835
+ for (const permType of ['read', 'external_directory']) {
1836
+ if (config.permission[permType]) {
1837
+ const keys = Object.keys(config.permission[permType]);
1838
+ for (const key of keys) {
1839
+ if (key.includes('get-shit-done')) {
1840
+ delete config.permission[permType][key];
1841
+ modified = true;
1842
+ }
1843
+ }
1844
+ // Clean up empty objects
1845
+ if (Object.keys(config.permission[permType]).length === 0) {
1846
+ delete config.permission[permType];
1847
+ }
1848
+ }
1849
+ }
1850
+ if (Object.keys(config.permission).length === 0) {
1851
+ delete config.permission;
1852
+ }
1853
+ }
1854
+
1855
+ if (modified) {
1856
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1857
+ removedCount++;
1858
+ console.log(` ${green}✓${reset} Removed GSD permissions from opencode.json`);
1859
+ }
1860
+ } catch (e) {
1861
+ // Ignore JSON parse errors
1862
+ }
1863
+ }
1864
+ }
1865
+
1866
+ if (removedCount === 0) {
1867
+ console.log(` ${yellow}⚠${reset} No EZ Agents files found to remove.`);
1868
+ }
1869
+
1870
+ console.log(`
1871
+ ${green}Done!${reset} EZ Agents has been uninstalled from ${runtimeLabel}.
1872
+ Your other files and settings have been preserved.
1873
+ `);
1874
+ }
1875
+
1876
+ /**
1877
+ * Parse JSONC (JSON with Comments) by stripping comments and trailing commas.
1878
+ * OpenCode supports JSONC format via jsonc-parser, so users may have comments.
1879
+ * This is a lightweight inline parser to avoid adding dependencies.
1880
+ */
1881
+ function parseJsonc(content) {
1882
+ // Strip BOM if present
1883
+ if (content.charCodeAt(0) === 0xFEFF) {
1884
+ content = content.slice(1);
1885
+ }
1886
+
1887
+ // Remove single-line and block comments while preserving strings
1888
+ let result = '';
1889
+ let inString = false;
1890
+ let i = 0;
1891
+ while (i < content.length) {
1892
+ const char = content[i];
1893
+ const next = content[i + 1];
1894
+
1895
+ if (inString) {
1896
+ result += char;
1897
+ // Handle escape sequences
1898
+ if (char === '\\' && i + 1 < content.length) {
1899
+ result += next;
1900
+ i += 2;
1901
+ continue;
1902
+ }
1903
+ if (char === '"') {
1904
+ inString = false;
1905
+ }
1906
+ i++;
1907
+ } else {
1908
+ if (char === '"') {
1909
+ inString = true;
1910
+ result += char;
1911
+ i++;
1912
+ } else if (char === '/' && next === '/') {
1913
+ // Skip single-line comment until end of line
1914
+ while (i < content.length && content[i] !== '\n') {
1915
+ i++;
1916
+ }
1917
+ } else if (char === '/' && next === '*') {
1918
+ // Skip block comment
1919
+ i += 2;
1920
+ while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) {
1921
+ i++;
1922
+ }
1923
+ i += 2; // Skip closing */
1924
+ } else {
1925
+ result += char;
1926
+ i++;
1927
+ }
1928
+ }
1929
+ }
1930
+
1931
+ // Remove trailing commas before } or ]
1932
+ result = result.replace(/,(\s*[}\]])/g, '$1');
1933
+
1934
+ return JSON.parse(result);
1935
+ }
1936
+
1937
+ /**
1938
+ * Configure OpenCode permissions to allow reading GSD reference docs
1939
+ * This prevents permission prompts when GSD accesses the get-shit-done directory
1940
+ * @param {boolean} isGlobal - Whether this is a global or local install
1941
+ */
1942
+ function configureOpencodePermissions(isGlobal = true) {
1943
+ // For local installs, use ./.opencode/opencode.json
1944
+ // For global installs, use ~/.config/opencode/opencode.json
1945
+ const opencodeConfigDir = isGlobal
1946
+ ? getOpencodeGlobalDir()
1947
+ : path.join(process.cwd(), '.opencode');
1948
+ const configPath = path.join(opencodeConfigDir, 'opencode.json');
1949
+
1950
+ // Ensure config directory exists
1951
+ fs.mkdirSync(opencodeConfigDir, { recursive: true });
1952
+
1953
+ // Read existing config or create empty object
1954
+ let config = {};
1955
+ if (fs.existsSync(configPath)) {
1956
+ try {
1957
+ const content = fs.readFileSync(configPath, 'utf8');
1958
+ config = parseJsonc(content);
1959
+ } catch (e) {
1960
+ // Cannot parse - DO NOT overwrite user's config
1961
+ console.log(` ${yellow}⚠${reset} Could not parse opencode.json - skipping permission config`);
1962
+ console.log(` ${dim}Reason: ${e.message}${reset}`);
1963
+ console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
1964
+ return;
1965
+ }
1966
+ }
1967
+
1968
+ // Ensure permission structure exists
1969
+ if (!config.permission) {
1970
+ config.permission = {};
1971
+ }
1972
+
1973
+ // Build the GSD path using the actual config directory
1974
+ // Use ~ shorthand if it's in the default location, otherwise use full path
1975
+ const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
1976
+ const gsdPath = opencodeConfigDir === defaultConfigDir
1977
+ ? '~/.config/opencode/get-shit-done/*'
1978
+ : `${opencodeConfigDir.replace(/\\/g, '/')}/get-shit-done/*`;
1979
+
1980
+ let modified = false;
1981
+
1982
+ // Configure read permission
1983
+ if (!config.permission.read || typeof config.permission.read !== 'object') {
1984
+ config.permission.read = {};
1985
+ }
1986
+ if (config.permission.read[gsdPath] !== 'allow') {
1987
+ config.permission.read[gsdPath] = 'allow';
1988
+ modified = true;
1989
+ }
1990
+
1991
+ // Configure external_directory permission (the safety guard for paths outside project)
1992
+ if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
1993
+ config.permission.external_directory = {};
1994
+ }
1995
+ if (config.permission.external_directory[gsdPath] !== 'allow') {
1996
+ config.permission.external_directory[gsdPath] = 'allow';
1997
+ modified = true;
1998
+ }
1999
+
2000
+ if (!modified) {
2001
+ return; // Already configured
2002
+ }
2003
+
2004
+ // Write config back
2005
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
2006
+ console.log(` ${green}✓${reset} Configured read permission for EZ Agents docs`);
2007
+ }
2008
+
2009
+ /**
2010
+ * Verify a directory exists and contains files
2011
+ */
2012
+ function verifyInstalled(dirPath, description) {
2013
+ if (!fs.existsSync(dirPath)) {
2014
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: directory not created`);
2015
+ return false;
2016
+ }
2017
+ try {
2018
+ const entries = fs.readdirSync(dirPath);
2019
+ if (entries.length === 0) {
2020
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: directory is empty`);
2021
+ return false;
2022
+ }
2023
+ } catch (e) {
2024
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: ${e.message}`);
2025
+ return false;
2026
+ }
2027
+ return true;
2028
+ }
2029
+
2030
+ /**
2031
+ * Verify a file exists
2032
+ */
2033
+ function verifyFileInstalled(filePath, description) {
2034
+ if (!fs.existsSync(filePath)) {
2035
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: file not created`);
2036
+ return false;
2037
+ }
2038
+ return true;
2039
+ }
2040
+
2041
+ /**
2042
+ * Install to the specified directory for a specific runtime
2043
+ * @param {boolean} isGlobal - Whether to install globally or locally
2044
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex')
2045
+ */
2046
+
2047
+ // ──────────────────────────────────────────────────────
2048
+ // Local Patch Persistence
2049
+ // ──────────────────────────────────────────────────────
2050
+
2051
+ const PATCHES_DIR_NAME = 'gsd-local-patches';
2052
+ const MANIFEST_NAME = 'gsd-file-manifest.json';
2053
+
2054
+ /**
2055
+ * Compute SHA256 hash of file contents
2056
+ */
2057
+ function fileHash(filePath) {
2058
+ const content = fs.readFileSync(filePath);
2059
+ return crypto.createHash('sha256').update(content).digest('hex');
2060
+ }
2061
+
2062
+ /**
2063
+ * Recursively collect all files in dir with their hashes
2064
+ */
2065
+ function generateManifest(dir, baseDir) {
2066
+ if (!baseDir) baseDir = dir;
2067
+ const manifest = {};
2068
+ if (!fs.existsSync(dir)) return manifest;
2069
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2070
+ for (const entry of entries) {
2071
+ const fullPath = path.join(dir, entry.name);
2072
+ const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
2073
+ if (entry.isDirectory()) {
2074
+ Object.assign(manifest, generateManifest(fullPath, baseDir));
2075
+ } else {
2076
+ manifest[relPath] = fileHash(fullPath);
2077
+ }
2078
+ }
2079
+ return manifest;
2080
+ }
2081
+
2082
+ /**
2083
+ * Write file manifest after installation for future modification detection
2084
+ */
2085
+ function writeManifest(configDir, runtime = 'claude') {
2086
+ const isOpencode = runtime === 'opencode';
2087
+ const isCodex = runtime === 'codex';
2088
+ const isCopilot = runtime === 'copilot';
2089
+ const gsdDir = path.join(configDir, 'get-shit-done');
2090
+ const commandsDir = path.join(configDir, 'commands', 'gsd');
2091
+ const opencodeCommandDir = path.join(configDir, 'command');
2092
+ const codexSkillsDir = path.join(configDir, 'skills');
2093
+ const agentsDir = path.join(configDir, 'agents');
2094
+ const manifest = { version: pkg.version, timestamp: new Date().toISOString(), files: {} };
2095
+
2096
+ const gsdHashes = generateManifest(gsdDir);
2097
+ for (const [rel, hash] of Object.entries(gsdHashes)) {
2098
+ manifest.files['get-shit-done/' + rel] = hash;
2099
+ }
2100
+ if (!isOpencode && !isCodex && !isCopilot && fs.existsSync(commandsDir)) {
2101
+ const cmdHashes = generateManifest(commandsDir);
2102
+ for (const [rel, hash] of Object.entries(cmdHashes)) {
2103
+ manifest.files['commands/gsd/' + rel] = hash;
2104
+ }
2105
+ }
2106
+ if (isOpencode && fs.existsSync(opencodeCommandDir)) {
2107
+ for (const file of fs.readdirSync(opencodeCommandDir)) {
2108
+ if (file.startsWith('gsd-') && file.endsWith('.md')) {
2109
+ manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
2110
+ }
2111
+ }
2112
+ }
2113
+ if ((isCodex || isCopilot) && fs.existsSync(codexSkillsDir)) {
2114
+ for (const skillName of listCodexSkillNames(codexSkillsDir)) {
2115
+ const skillRoot = path.join(codexSkillsDir, skillName);
2116
+ const skillHashes = generateManifest(skillRoot);
2117
+ for (const [rel, hash] of Object.entries(skillHashes)) {
2118
+ manifest.files[`skills/${skillName}/${rel}`] = hash;
2119
+ }
2120
+ }
2121
+ }
2122
+ if (fs.existsSync(agentsDir)) {
2123
+ for (const file of fs.readdirSync(agentsDir)) {
2124
+ if (file.startsWith('gsd-') && file.endsWith('.md')) {
2125
+ manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
2126
+ }
2127
+ }
2128
+ }
2129
+
2130
+ fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
2131
+ return manifest;
2132
+ }
2133
+
2134
+ /**
2135
+ * Detect user-modified EZ Agents files by comparing against install manifest.
2136
+ * Backs up modified files to gsd-local-patches/ for reapply after update.
2137
+ */
2138
+ function saveLocalPatches(configDir) {
2139
+ const manifestPath = path.join(configDir, MANIFEST_NAME);
2140
+ if (!fs.existsSync(manifestPath)) return [];
2141
+
2142
+ let manifest;
2143
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; }
2144
+
2145
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
2146
+ const modified = [];
2147
+
2148
+ for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
2149
+ const fullPath = path.join(configDir, relPath);
2150
+ if (!fs.existsSync(fullPath)) continue;
2151
+ const currentHash = fileHash(fullPath);
2152
+ if (currentHash !== originalHash) {
2153
+ const backupPath = path.join(patchesDir, relPath);
2154
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
2155
+ fs.copyFileSync(fullPath, backupPath);
2156
+ modified.push(relPath);
2157
+ }
2158
+ }
2159
+
2160
+ if (modified.length > 0) {
2161
+ const meta = {
2162
+ backed_up_at: new Date().toISOString(),
2163
+ from_version: manifest.version,
2164
+ files: modified
2165
+ };
2166
+ fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
2167
+ console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified GSD file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
2168
+ for (const f of modified) {
2169
+ console.log(' ' + dim + f + reset);
2170
+ }
2171
+ }
2172
+ return modified;
2173
+ }
2174
+
2175
+ /**
2176
+ * After install, report backed-up patches for user to reapply.
2177
+ */
2178
+ function reportLocalPatches(configDir, runtime = 'claude') {
2179
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
2180
+ const metaPath = path.join(patchesDir, 'backup-meta.json');
2181
+ if (!fs.existsSync(metaPath)) return [];
2182
+
2183
+ let meta;
2184
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
2185
+
2186
+ if (meta.files && meta.files.length > 0) {
2187
+ const reapplyCommand = (runtime === 'opencode' || runtime === 'copilot')
2188
+ ? '/ez-reapply-patches'
2189
+ : runtime === 'codex'
2190
+ ? '$ez-reapply-patches'
2191
+ : '/ez:reapply-patches';
2192
+ console.log('');
2193
+ console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
2194
+ for (const f of meta.files) {
2195
+ console.log(' ' + cyan + f + reset);
2196
+ }
2197
+ console.log('');
2198
+ console.log(' Your modifications are saved in ' + cyan + PATCHES_DIR_NAME + '/' + reset);
2199
+ console.log(' Run ' + cyan + reapplyCommand + reset + ' to merge them into the new version.');
2200
+ console.log(' Or manually compare and merge the files.');
2201
+ console.log('');
2202
+ }
2203
+ return meta.files || [];
2204
+ }
2205
+
2206
+ function install(isGlobal, runtime = 'claude') {
2207
+ const isOpencode = runtime === 'opencode';
2208
+ const isGemini = runtime === 'gemini';
2209
+ const isCodex = runtime === 'codex';
2210
+ const isCopilot = runtime === 'copilot';
2211
+ const dirName = getDirName(runtime);
2212
+ const src = path.join(__dirname, '..');
2213
+
2214
+ // Get the target directory based on runtime and install type
2215
+ const targetDir = isGlobal
2216
+ ? getGlobalDir(runtime, explicitConfigDir)
2217
+ : path.join(process.cwd(), dirName);
2218
+
2219
+ const locationLabel = isGlobal
2220
+ ? targetDir.replace(os.homedir(), '~')
2221
+ : targetDir.replace(process.cwd(), '.');
2222
+
2223
+ // Path prefix for file references in markdown content
2224
+ // For global installs: use tilde-based path (~/.claude/) to avoid baking
2225
+ // absolute paths (containing OS username) into templates
2226
+ // For local installs: use relative
2227
+ const pathPrefix = isGlobal
2228
+ ? `${targetDir.replace(/\\/g, '/').replace(os.homedir().replace(/\\/g, '/'), '~')}/`
2229
+ : `./${dirName}/`;
2230
+
2231
+ let runtimeLabel = 'Claude Code';
2232
+ if (isOpencode) runtimeLabel = 'OpenCode';
2233
+ if (isGemini) runtimeLabel = 'Gemini';
2234
+ if (isCodex) runtimeLabel = 'Codex';
2235
+ if (isCopilot) runtimeLabel = 'Copilot';
2236
+
2237
+ console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
2238
+
2239
+ // Track installation failures
2240
+ const failures = [];
2241
+
2242
+ // Save any locally modified EZ Agents files before they get wiped
2243
+ saveLocalPatches(targetDir);
2244
+
2245
+ // Clean up orphaned files from previous versions
2246
+ cleanupOrphanedFiles(targetDir);
2247
+
2248
+ // OpenCode uses command/ (flat), Codex uses skills/, Claude/Gemini use commands/gsd/
2249
+ if (isOpencode) {
2250
+ // OpenCode: flat structure in command/ directory
2251
+ const commandDir = path.join(targetDir, 'command');
2252
+ fs.mkdirSync(commandDir, { recursive: true });
2253
+
2254
+ // Copy commands/gsd/*.md as command/gsd-*.md (flatten structure)
2255
+ const gsdSrc = path.join(src, 'commands', 'gsd');
2256
+ copyFlattenedCommands(gsdSrc, commandDir, 'gsd', pathPrefix, runtime);
2257
+ if (verifyInstalled(commandDir, 'command/gsd-*')) {
2258
+ const count = fs.readdirSync(commandDir).filter(f => f.startsWith('gsd-')).length;
2259
+ console.log(` ${green}✓${reset} Installed ${count} commands to command/`);
2260
+ } else {
2261
+ failures.push('command/gsd-*');
2262
+ }
2263
+ } else if (isCodex) {
2264
+ const skillsDir = path.join(targetDir, 'skills');
2265
+ const gsdSrc = path.join(src, 'commands', 'gsd');
2266
+ copyCommandsAsCodexSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime);
2267
+ const installedSkillNames = listCodexSkillNames(skillsDir);
2268
+ if (installedSkillNames.length > 0) {
2269
+ console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`);
2270
+ } else {
2271
+ failures.push('skills/gsd-*');
2272
+ }
2273
+ } else if (isCopilot) {
2274
+ const skillsDir = path.join(targetDir, 'skills');
2275
+ const gsdSrc = path.join(src, 'commands', 'gsd');
2276
+ copyCommandsAsCopilotSkills(gsdSrc, skillsDir, 'gsd', isGlobal);
2277
+ if (fs.existsSync(skillsDir)) {
2278
+ const count = fs.readdirSync(skillsDir, { withFileTypes: true })
2279
+ .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
2280
+ if (count > 0) {
2281
+ console.log(` ${green}✓${reset} Installed ${count} skills to skills/`);
2282
+ } else {
2283
+ failures.push('skills/gsd-*');
2284
+ }
2285
+ } else {
2286
+ failures.push('skills/gsd-*');
2287
+ }
2288
+ } else {
2289
+ // Claude Code & Gemini: nested structure in commands/ directory
2290
+ const commandsDir = path.join(targetDir, 'commands');
2291
+ fs.mkdirSync(commandsDir, { recursive: true });
2292
+
2293
+ const gsdSrc = path.join(src, 'commands', 'gsd');
2294
+ const gsdDest = path.join(commandsDir, 'gsd');
2295
+ copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal);
2296
+ if (verifyInstalled(gsdDest, 'commands/gsd')) {
2297
+ console.log(` ${green}✓${reset} Installed commands/gsd`);
2298
+ } else {
2299
+ failures.push('commands/gsd');
2300
+ }
2301
+ }
2302
+
2303
+ // Copy bin/update.js for ez-agents-update command
2304
+ const updateSrc = path.join(src, 'bin', 'update.js');
2305
+ if (fs.existsSync(updateSrc)) {
2306
+ const updateDest = path.join(targetDir, 'bin', 'update.js');
2307
+ fs.mkdirSync(path.dirname(updateDest), { recursive: true });
2308
+ fs.copyFileSync(updateSrc, updateDest);
2309
+ console.log(` ${green}✓${reset} Installed ez-agents-update command`);
2310
+ }
2311
+
2312
+ // Copy get-shit-done skill with path replacement
2313
+ const skillSrc = path.join(src, 'get-shit-done');
2314
+ const skillDest = path.join(targetDir, 'get-shit-done');
2315
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal);
2316
+ if (verifyInstalled(skillDest, 'get-shit-done')) {
2317
+ console.log(` ${green}✓${reset} Installed get-shit-done`);
2318
+ } else {
2319
+ failures.push('get-shit-done');
2320
+ }
2321
+
2322
+ // Copy agents to agents directory
2323
+ const agentsSrc = path.join(src, 'agents');
2324
+ if (fs.existsSync(agentsSrc)) {
2325
+ const agentsDest = path.join(targetDir, 'agents');
2326
+ fs.mkdirSync(agentsDest, { recursive: true });
2327
+
2328
+ // Remove old EZ Agents agents (gsd-*.md) before copying new ones
2329
+ if (fs.existsSync(agentsDest)) {
2330
+ for (const file of fs.readdirSync(agentsDest)) {
2331
+ if (file.startsWith('gsd-') && file.endsWith('.md')) {
2332
+ fs.unlinkSync(path.join(agentsDest, file));
2333
+ }
2334
+ }
2335
+ }
2336
+
2337
+ // Copy new agents
2338
+ const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
2339
+ for (const entry of agentEntries) {
2340
+ if (entry.isFile() && entry.name.endsWith('.md')) {
2341
+ let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
2342
+ // Replace ~/.claude/ and $HOME/.claude/ as they are the source of truth in the repo
2343
+ const dirRegex = /~\/\.claude\//g;
2344
+ const homeDirRegex = /\$HOME\/\.claude\//g;
2345
+ if (!isCopilot) {
2346
+ content = content.replace(dirRegex, pathPrefix);
2347
+ content = content.replace(homeDirRegex, toHomePrefix(pathPrefix));
2348
+ }
2349
+ content = processAttribution(content, getCommitAttribution(runtime));
2350
+ // Convert frontmatter for runtime compatibility
2351
+ if (isOpencode) {
2352
+ content = convertClaudeToOpencodeFrontmatter(content);
2353
+ } else if (isGemini) {
2354
+ content = convertClaudeToGeminiAgent(content);
2355
+ } else if (isCodex) {
2356
+ content = convertClaudeAgentToCodexAgent(content);
2357
+ } else if (isCopilot) {
2358
+ content = convertClaudeAgentToCopilotAgent(content, isGlobal);
2359
+ }
2360
+ const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name;
2361
+ fs.writeFileSync(path.join(agentsDest, destName), content);
2362
+ }
2363
+ }
2364
+ if (verifyInstalled(agentsDest, 'agents')) {
2365
+ console.log(` ${green}✓${reset} Installed agents`);
2366
+ } else {
2367
+ failures.push('agents');
2368
+ }
2369
+ }
2370
+
2371
+ // Copy CHANGELOG.md
2372
+ const changelogSrc = path.join(src, 'CHANGELOG.md');
2373
+ const changelogDest = path.join(targetDir, 'get-shit-done', 'CHANGELOG.md');
2374
+ if (fs.existsSync(changelogSrc)) {
2375
+ fs.copyFileSync(changelogSrc, changelogDest);
2376
+ if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
2377
+ console.log(` ${green}✓${reset} Installed CHANGELOG.md`);
2378
+ } else {
2379
+ failures.push('CHANGELOG.md');
2380
+ }
2381
+ }
2382
+
2383
+ // Write VERSION file
2384
+ const versionDest = path.join(targetDir, 'get-shit-done', 'VERSION');
2385
+ fs.writeFileSync(versionDest, pkg.version);
2386
+ if (verifyFileInstalled(versionDest, 'VERSION')) {
2387
+ console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`);
2388
+ } else {
2389
+ failures.push('VERSION');
2390
+ }
2391
+
2392
+ if (!isCodex && !isCopilot) {
2393
+ // Write package.json to force CommonJS mode for GSD scripts
2394
+ // Prevents "require is not defined" errors when project has "type": "module"
2395
+ // Node.js walks up looking for package.json - this stops inheritance from project
2396
+ const pkgJsonDest = path.join(targetDir, 'package.json');
2397
+ fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
2398
+ console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
2399
+
2400
+ // Copy hooks from dist/ (bundled with dependencies)
2401
+ // Template paths for the target runtime (replaces '.claude' with correct config dir)
2402
+ const hooksSrc = path.join(src, 'hooks', 'dist');
2403
+ if (fs.existsSync(hooksSrc)) {
2404
+ const hooksDest = path.join(targetDir, 'hooks');
2405
+ fs.mkdirSync(hooksDest, { recursive: true });
2406
+ const hookEntries = fs.readdirSync(hooksSrc);
2407
+ const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
2408
+ for (const entry of hookEntries) {
2409
+ const srcFile = path.join(hooksSrc, entry);
2410
+ if (fs.statSync(srcFile).isFile()) {
2411
+ const destFile = path.join(hooksDest, entry);
2412
+ // Template .js files to replace '.claude' with runtime-specific config dir
2413
+ if (entry.endsWith('.js')) {
2414
+ let content = fs.readFileSync(srcFile, 'utf8');
2415
+ content = content.replace(/'\.claude'/g, configDirReplacement);
2416
+ fs.writeFileSync(destFile, content);
2417
+ } else {
2418
+ fs.copyFileSync(srcFile, destFile);
2419
+ }
2420
+ }
2421
+ }
2422
+ if (verifyInstalled(hooksDest, 'hooks')) {
2423
+ console.log(` ${green}✓${reset} Installed hooks (bundled)`);
2424
+ } else {
2425
+ failures.push('hooks');
2426
+ }
2427
+ }
2428
+ }
2429
+
2430
+ if (failures.length > 0) {
2431
+ console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
2432
+ process.exit(1);
2433
+ }
2434
+
2435
+ // Write file manifest for future modification detection
2436
+ writeManifest(targetDir, runtime);
2437
+ console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
2438
+
2439
+ // Report any backed-up local patches
2440
+ reportLocalPatches(targetDir, runtime);
2441
+
2442
+ // Verify no leaked .claude paths in non-Claude runtimes
2443
+ if (runtime !== 'claude') {
2444
+ const leakedPaths = [];
2445
+ function scanForLeakedPaths(dir) {
2446
+ if (!fs.existsSync(dir)) return;
2447
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2448
+ for (const entry of entries) {
2449
+ const fullPath = path.join(dir, entry.name);
2450
+ if (entry.isDirectory()) {
2451
+ scanForLeakedPaths(fullPath);
2452
+ } else if ((entry.name.endsWith('.md') || entry.name.endsWith('.toml')) && entry.name !== 'CHANGELOG.md') {
2453
+ const content = fs.readFileSync(fullPath, 'utf8');
2454
+ const matches = content.match(/(?:~|\$HOME)\/\.claude\b/g);
2455
+ if (matches) {
2456
+ leakedPaths.push({ file: fullPath.replace(targetDir + '/', ''), count: matches.length });
2457
+ }
2458
+ }
2459
+ }
2460
+ }
2461
+ scanForLeakedPaths(targetDir);
2462
+ if (leakedPaths.length > 0) {
2463
+ const totalLeaks = leakedPaths.reduce((sum, l) => sum + l.count, 0);
2464
+ console.warn(`\n ${yellow}⚠${reset} Found ${totalLeaks} unreplaced .claude path reference(s) in ${leakedPaths.length} file(s):`);
2465
+ for (const leak of leakedPaths.slice(0, 5)) {
2466
+ console.warn(` ${dim}${leak.file}${reset} (${leak.count})`);
2467
+ }
2468
+ if (leakedPaths.length > 5) {
2469
+ console.warn(` ${dim}... and ${leakedPaths.length - 5} more file(s)${reset}`);
2470
+ }
2471
+ console.warn(` ${dim}These paths may not resolve correctly for ${runtimeLabel}.${reset}`);
2472
+ }
2473
+ }
2474
+
2475
+ if (isCodex) {
2476
+ // Generate Codex config.toml and per-agent .toml files
2477
+ const agentCount = installCodexConfig(targetDir, agentsSrc);
2478
+ console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`);
2479
+ console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`);
2480
+ return { settingsPath: null, settings: null, statuslineCommand: null, runtime };
2481
+ }
2482
+
2483
+ if (isCopilot) {
2484
+ // Generate copilot-instructions.md
2485
+ const templatePath = path.join(targetDir, 'get-shit-done', 'templates', 'copilot-instructions.md');
2486
+ const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
2487
+ if (fs.existsSync(templatePath)) {
2488
+ const template = fs.readFileSync(templatePath, 'utf8');
2489
+ mergeCopilotInstructions(instructionsPath, template);
2490
+ console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
2491
+ }
2492
+ // Copilot: no settings.json, no hooks, no statusline (like Codex)
2493
+ return { settingsPath: null, settings: null, statuslineCommand: null, runtime };
2494
+ }
2495
+
2496
+ // Configure statusline and hooks in settings.json
2497
+ // Gemini uses AfterTool instead of PostToolUse for post-tool hooks
2498
+ const postToolEvent = runtime === 'gemini' ? 'AfterTool' : 'PostToolUse';
2499
+ const settingsPath = path.join(targetDir, 'settings.json');
2500
+ const settings = cleanupOrphanedHooks(readSettings(settingsPath));
2501
+ const statuslineCommand = isGlobal
2502
+ ? buildHookCommand(targetDir, 'gsd-statusline.js')
2503
+ : 'node ' + dirName + '/hooks/gsd-statusline.js';
2504
+ const updateCheckCommand = isGlobal
2505
+ ? buildHookCommand(targetDir, 'gsd-check-update.js')
2506
+ : 'node ' + dirName + '/hooks/gsd-check-update.js';
2507
+ const contextMonitorCommand = isGlobal
2508
+ ? buildHookCommand(targetDir, 'gsd-context-monitor.js')
2509
+ : 'node ' + dirName + '/hooks/gsd-context-monitor.js';
2510
+
2511
+ // Enable experimental agents for Gemini CLI (required for custom sub-agents)
2512
+ if (isGemini) {
2513
+ if (!settings.experimental) {
2514
+ settings.experimental = {};
2515
+ }
2516
+ if (!settings.experimental.enableAgents) {
2517
+ settings.experimental.enableAgents = true;
2518
+ console.log(` ${green}✓${reset} Enabled experimental agents`);
2519
+ }
2520
+ }
2521
+
2522
+ // Configure SessionStart hook for update checking (skip for opencode)
2523
+ if (!isOpencode) {
2524
+ if (!settings.hooks) {
2525
+ settings.hooks = {};
2526
+ }
2527
+ if (!settings.hooks.SessionStart) {
2528
+ settings.hooks.SessionStart = [];
2529
+ }
2530
+
2531
+ const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
2532
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-check-update'))
2533
+ );
2534
+
2535
+ if (!hasGsdUpdateHook) {
2536
+ settings.hooks.SessionStart.push({
2537
+ hooks: [
2538
+ {
2539
+ type: 'command',
2540
+ command: updateCheckCommand
2541
+ }
2542
+ ]
2543
+ });
2544
+ console.log(` ${green}✓${reset} Configured update check hook`);
2545
+ }
2546
+
2547
+ // Configure post-tool hook for context window monitoring
2548
+ if (!settings.hooks[postToolEvent]) {
2549
+ settings.hooks[postToolEvent] = [];
2550
+ }
2551
+
2552
+ const hasContextMonitorHook = settings.hooks[postToolEvent].some(entry =>
2553
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
2554
+ );
2555
+
2556
+ if (!hasContextMonitorHook) {
2557
+ settings.hooks[postToolEvent].push({
2558
+ hooks: [
2559
+ {
2560
+ type: 'command',
2561
+ command: contextMonitorCommand
2562
+ }
2563
+ ]
2564
+ });
2565
+ console.log(` ${green}✓${reset} Configured context window monitor hook`);
2566
+ }
2567
+ }
2568
+
2569
+ return { settingsPath, settings, statuslineCommand, runtime };
2570
+ }
2571
+
2572
+ /**
2573
+ * Apply statusline config, then print completion message
2574
+ */
2575
+ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true) {
2576
+ const isOpencode = runtime === 'opencode';
2577
+ const isCodex = runtime === 'codex';
2578
+ const isCopilot = runtime === 'copilot';
2579
+
2580
+ if (shouldInstallStatusline && !isOpencode && !isCodex && !isCopilot) {
2581
+ settings.statusLine = {
2582
+ type: 'command',
2583
+ command: statuslineCommand
2584
+ };
2585
+ console.log(` ${green}✓${reset} Configured statusline`);
2586
+ }
2587
+
2588
+ // Write settings when runtime supports settings.json
2589
+ if (!isCodex && !isCopilot) {
2590
+ writeSettings(settingsPath, settings);
2591
+ }
2592
+
2593
+ // Configure OpenCode permissions
2594
+ if (isOpencode) {
2595
+ configureOpencodePermissions(isGlobal);
2596
+ }
2597
+
2598
+ let program = 'Claude Code';
2599
+ if (runtime === 'opencode') program = 'OpenCode';
2600
+ if (runtime === 'gemini') program = 'Gemini';
2601
+ if (runtime === 'codex') program = 'Codex';
2602
+ if (runtime === 'copilot') program = 'Copilot';
2603
+
2604
+ let command = '/ez:new-project';
2605
+ if (runtime === 'opencode') command = '/ez-new-project';
2606
+ if (runtime === 'codex') command = '$ez-new-project';
2607
+ if (runtime === 'copilot') command = '/ez-new-project';
2608
+ console.log(`
2609
+ ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
2610
+
2611
+ ${cyan}Join the community:${reset} https://discord.gg/gsd
2612
+ `);
2613
+ }
2614
+
2615
+ /**
2616
+ * Handle statusline configuration with optional prompt
2617
+ */
2618
+ function handleStatusline(settings, isInteractive, callback) {
2619
+ const hasExisting = settings.statusLine != null;
2620
+
2621
+ if (!hasExisting) {
2622
+ callback(true);
2623
+ return;
2624
+ }
2625
+
2626
+ if (forceStatusline) {
2627
+ callback(true);
2628
+ return;
2629
+ }
2630
+
2631
+ if (!isInteractive) {
2632
+ console.log(` ${yellow}⚠${reset} Skipping statusline (already configured)`);
2633
+ console.log(` Use ${cyan}--force-statusline${reset} to replace\n`);
2634
+ callback(false);
2635
+ return;
2636
+ }
2637
+
2638
+ const existingCmd = settings.statusLine.command || settings.statusLine.url || '(custom)';
2639
+
2640
+ const rl = readline.createInterface({
2641
+ input: process.stdin,
2642
+ output: process.stdout
2643
+ });
2644
+
2645
+ console.log(`
2646
+ ${yellow}⚠${reset} Existing statusline detected\n
2647
+ Your current statusline:
2648
+ ${dim}command: ${existingCmd}${reset}
2649
+
2650
+ EZ Agents includes a statusline showing:
2651
+ • Model name
2652
+ • Current task (from todo list)
2653
+ • Context window usage (color-coded)
2654
+
2655
+ ${cyan}1${reset}) Keep existing
2656
+ ${cyan}2${reset}) Replace with EZ Agents statusline
2657
+ `);
2658
+
2659
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
2660
+ rl.close();
2661
+ const choice = answer.trim() || '1';
2662
+ callback(choice === '2');
2663
+ });
2664
+ }
2665
+
2666
+ /**
2667
+ * Prompt for runtime selection
2668
+ */
2669
+ function promptRuntime(callback) {
2670
+ const rl = readline.createInterface({
2671
+ input: process.stdin,
2672
+ output: process.stdout
2673
+ });
2674
+
2675
+ let answered = false;
2676
+
2677
+ rl.on('close', () => {
2678
+ if (!answered) {
2679
+ answered = true;
2680
+ console.log(`\n ${yellow}Installation cancelled${reset}\n`);
2681
+ process.exit(0);
2682
+ }
2683
+ });
2684
+
2685
+ console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
2686
+ ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models
2687
+ ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset}
2688
+ ${cyan}4${reset}) Codex ${dim}(~/.codex)${reset}
2689
+ ${cyan}5${reset}) Copilot ${dim}(~/.copilot)${reset}
2690
+ ${cyan}6${reset}) All
2691
+ `);
2692
+
2693
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
2694
+ answered = true;
2695
+ rl.close();
2696
+ const choice = answer.trim() || '1';
2697
+ if (choice === '6') {
2698
+ callback(['claude', 'opencode', 'gemini', 'codex', 'copilot']);
2699
+ } else if (choice === '5') {
2700
+ callback(['copilot']);
2701
+ } else if (choice === '4') {
2702
+ callback(['codex']);
2703
+ } else if (choice === '3') {
2704
+ callback(['gemini']);
2705
+ } else if (choice === '2') {
2706
+ callback(['opencode']);
2707
+ } else {
2708
+ callback(['claude']);
2709
+ }
2710
+ });
2711
+ }
2712
+
2713
+ /**
2714
+ * Prompt for install location
2715
+ */
2716
+ function promptLocation(runtimes) {
2717
+ if (!process.stdin.isTTY) {
2718
+ console.log(` ${yellow}Non-interactive terminal detected, defaulting to global install${reset}\n`);
2719
+ installAllRuntimes(runtimes, true, false);
2720
+ return;
2721
+ }
2722
+
2723
+ const rl = readline.createInterface({
2724
+ input: process.stdin,
2725
+ output: process.stdout
2726
+ });
2727
+
2728
+ let answered = false;
2729
+
2730
+ rl.on('close', () => {
2731
+ if (!answered) {
2732
+ answered = true;
2733
+ console.log(`\n ${yellow}Installation cancelled${reset}\n`);
2734
+ process.exit(0);
2735
+ }
2736
+ });
2737
+
2738
+ const pathExamples = runtimes.map(r => {
2739
+ const globalPath = getGlobalDir(r, explicitConfigDir);
2740
+ return globalPath.replace(os.homedir(), '~');
2741
+ }).join(', ');
2742
+
2743
+ const localExamples = runtimes.map(r => `./${getDirName(r)}`).join(', ');
2744
+
2745
+ console.log(` ${yellow}Where would you like to install?${reset}\n\n ${cyan}1${reset}) Global ${dim}(${pathExamples})${reset} - available in all projects
2746
+ ${cyan}2${reset}) Local ${dim}(${localExamples})${reset} - this project only
2747
+ `);
2748
+
2749
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
2750
+ answered = true;
2751
+ rl.close();
2752
+ const choice = answer.trim() || '1';
2753
+ const isGlobal = choice !== '2';
2754
+ installAllRuntimes(runtimes, isGlobal, true);
2755
+ });
2756
+ }
2757
+
2758
+ /**
2759
+ * Install EZ Agents for all selected runtimes
2760
+ */
2761
+ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
2762
+ const results = [];
2763
+
2764
+ for (const runtime of runtimes) {
2765
+ const result = install(isGlobal, runtime);
2766
+ results.push(result);
2767
+ }
2768
+
2769
+ const statuslineRuntimes = ['claude', 'gemini'];
2770
+ const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
2771
+
2772
+ const finalize = (shouldInstallStatusline) => {
2773
+ for (const result of results) {
2774
+ const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
2775
+ finishInstall(
2776
+ result.settingsPath,
2777
+ result.settings,
2778
+ result.statuslineCommand,
2779
+ useStatusline,
2780
+ result.runtime,
2781
+ isGlobal
2782
+ );
2783
+ }
2784
+ };
2785
+
2786
+ if (primaryStatuslineResult) {
2787
+ handleStatusline(primaryStatuslineResult.settings, isInteractive, finalize);
2788
+ } else {
2789
+ finalize(false);
2790
+ }
2791
+ }
2792
+
2793
+ // Test-only exports — skip main logic when loaded as a module for testing
2794
+ if (process.env.GSD_TEST_MODE) {
2795
+ module.exports = {
2796
+ getCodexSkillAdapterHeader,
2797
+ convertClaudeToGeminiAgent,
2798
+ convertClaudeAgentToCodexAgent,
2799
+ generateCodexAgentToml,
2800
+ generateCodexConfigBlock,
2801
+ stripGsdFromCodexConfig,
2802
+ mergeCodexConfig,
2803
+ installCodexConfig,
2804
+ convertClaudeCommandToCodexSkill,
2805
+ EZ_CODEX_MARKER,
2806
+ CODEX_AGENT_SANDBOX,
2807
+ getDirName,
2808
+ getGlobalDir,
2809
+ getConfigDirFromHome,
2810
+ claudeToCopilotTools,
2811
+ convertCopilotToolName,
2812
+ convertClaudeToCopilotContent,
2813
+ convertClaudeCommandToCopilotSkill,
2814
+ convertClaudeAgentToCopilotAgent,
2815
+ copyCommandsAsCopilotSkills,
2816
+ EZ_COPILOT_INSTRUCTIONS_MARKER,
2817
+ EZ_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
2818
+ mergeCopilotInstructions,
2819
+ stripGsdFromCopilotInstructions,
2820
+ writeManifest,
2821
+ reportLocalPatches,
2822
+ };
2823
+ } else {
2824
+
2825
+ // Main logic
2826
+ if (hasGlobal && hasLocal) {
2827
+ console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
2828
+ process.exit(1);
2829
+ } else if (explicitConfigDir && hasLocal) {
2830
+ console.error(` ${yellow}Cannot use --config-dir with --local${reset}`);
2831
+ process.exit(1);
2832
+ } else if (hasUninstall) {
2833
+ if (!hasGlobal && !hasLocal) {
2834
+ console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
2835
+ process.exit(1);
2836
+ }
2837
+ const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude'];
2838
+ for (const runtime of runtimes) {
2839
+ uninstall(hasGlobal, runtime);
2840
+ }
2841
+ } else if (selectedRuntimes.length > 0) {
2842
+ if (!hasGlobal && !hasLocal) {
2843
+ promptLocation(selectedRuntimes);
2844
+ } else {
2845
+ installAllRuntimes(selectedRuntimes, hasGlobal, false);
2846
+ }
2847
+ } else if (hasGlobal || hasLocal) {
2848
+ // Default to Claude if no runtime specified but location is
2849
+ installAllRuntimes(['claude'], hasGlobal, false);
2850
+ } else {
2851
+ // Interactive
2852
+ if (!process.stdin.isTTY) {
2853
+ console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
2854
+ installAllRuntimes(['claude'], true, false);
2855
+ } else {
2856
+ promptRuntime((runtimes) => {
2857
+ promptLocation(runtimes);
2858
+ });
2859
+ }
2860
+ }
2861
+
2862
+ } // end of else block for GSD_TEST_MODE