learnship 1.9.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 (171) hide show
  1. package/.claude-plugin/plugin.json +26 -0
  2. package/.cursor-plugin/plugin.json +26 -0
  3. package/LICENSE +21 -0
  4. package/README.md +791 -0
  5. package/SKILL.md +86 -0
  6. package/agents/debugger.md +102 -0
  7. package/agents/executor.md +115 -0
  8. package/agents/learnship-debugger.md +146 -0
  9. package/agents/learnship-executor.md +155 -0
  10. package/agents/learnship-phase-researcher.md +128 -0
  11. package/agents/learnship-plan-checker.md +119 -0
  12. package/agents/learnship-planner.md +146 -0
  13. package/agents/learnship-verifier.md +157 -0
  14. package/agents/planner.md +109 -0
  15. package/agents/researcher.md +80 -0
  16. package/agents/verifier.md +114 -0
  17. package/bin/install.js +1242 -0
  18. package/bin/learnship.js +56 -0
  19. package/commands/learnship/add-phase.md +22 -0
  20. package/commands/learnship/add-tests.md +24 -0
  21. package/commands/learnship/add-todo.md +21 -0
  22. package/commands/learnship/audit-milestone.md +21 -0
  23. package/commands/learnship/check-todos.md +22 -0
  24. package/commands/learnship/cleanup.md +22 -0
  25. package/commands/learnship/complete-milestone.md +22 -0
  26. package/commands/learnship/debug.md +27 -0
  27. package/commands/learnship/decision-log.md +22 -0
  28. package/commands/learnship/diagnose-issues.md +23 -0
  29. package/commands/learnship/discovery-phase.md +24 -0
  30. package/commands/learnship/discuss-milestone.md +23 -0
  31. package/commands/learnship/discuss-phase.md +23 -0
  32. package/commands/learnship/execute-phase.md +27 -0
  33. package/commands/learnship/execute-plan.md +26 -0
  34. package/commands/learnship/health.md +20 -0
  35. package/commands/learnship/help.md +19 -0
  36. package/commands/learnship/insert-phase.md +22 -0
  37. package/commands/learnship/knowledge-base.md +21 -0
  38. package/commands/learnship/list-phase-assumptions.md +21 -0
  39. package/commands/learnship/ls.md +20 -0
  40. package/commands/learnship/map-codebase.md +23 -0
  41. package/commands/learnship/milestone-retrospective.md +21 -0
  42. package/commands/learnship/new-milestone.md +23 -0
  43. package/commands/learnship/new-project.md +24 -0
  44. package/commands/learnship/next.md +22 -0
  45. package/commands/learnship/pause-work.md +21 -0
  46. package/commands/learnship/plan-milestone-gaps.md +22 -0
  47. package/commands/learnship/plan-phase.md +24 -0
  48. package/commands/learnship/progress.md +20 -0
  49. package/commands/learnship/quick.md +27 -0
  50. package/commands/learnship/reapply-patches.md +21 -0
  51. package/commands/learnship/release.md +21 -0
  52. package/commands/learnship/remove-phase.md +23 -0
  53. package/commands/learnship/research-phase.md +23 -0
  54. package/commands/learnship/resume-work.md +21 -0
  55. package/commands/learnship/set-profile.md +21 -0
  56. package/commands/learnship/settings.md +21 -0
  57. package/commands/learnship/transition.md +21 -0
  58. package/commands/learnship/update.md +21 -0
  59. package/commands/learnship/validate-phase.md +22 -0
  60. package/commands/learnship/verify-work.md +23 -0
  61. package/cursor-rules/learnship.mdc +60 -0
  62. package/gemini-extension.json +10 -0
  63. package/hooks/hooks-claude.json +15 -0
  64. package/hooks/hooks-cursor.json +10 -0
  65. package/hooks/session-start +43 -0
  66. package/install.sh +254 -0
  67. package/learnship/references/design-commands.md +119 -0
  68. package/learnship/references/git-integration.md +249 -0
  69. package/learnship/references/learning-design.md +142 -0
  70. package/learnship/references/model-profiles.md +90 -0
  71. package/learnship/references/planning-config.md +184 -0
  72. package/learnship/references/questioning.md +162 -0
  73. package/learnship/references/ui-brand.md +160 -0
  74. package/learnship/references/verification-patterns.md +608 -0
  75. package/learnship/templates/agents.md +166 -0
  76. package/learnship/templates/context.md +72 -0
  77. package/learnship/templates/plan.md +202 -0
  78. package/learnship/templates/project.md +184 -0
  79. package/learnship/templates/requirements.md +231 -0
  80. package/learnship/templates/state.md +176 -0
  81. package/learnship/templates/uat.md +80 -0
  82. package/learnship/workflows/add-phase.md +84 -0
  83. package/learnship/workflows/add-tests.md +191 -0
  84. package/learnship/workflows/add-todo.md +108 -0
  85. package/learnship/workflows/audit-milestone.md +178 -0
  86. package/learnship/workflows/check-todos.md +138 -0
  87. package/learnship/workflows/cleanup.md +107 -0
  88. package/learnship/workflows/complete-milestone.md +191 -0
  89. package/learnship/workflows/debug.md +245 -0
  90. package/learnship/workflows/decision-log.md +131 -0
  91. package/learnship/workflows/diagnose-issues.md +145 -0
  92. package/learnship/workflows/discovery-phase.md +183 -0
  93. package/learnship/workflows/discuss-milestone.md +136 -0
  94. package/learnship/workflows/discuss-phase.md +244 -0
  95. package/learnship/workflows/execute-phase.md +345 -0
  96. package/learnship/workflows/execute-plan.md +149 -0
  97. package/learnship/workflows/health.md +171 -0
  98. package/learnship/workflows/help.md +153 -0
  99. package/learnship/workflows/insert-phase.md +106 -0
  100. package/learnship/workflows/knowledge-base.md +168 -0
  101. package/learnship/workflows/list-phase-assumptions.md +129 -0
  102. package/learnship/workflows/ls.md +145 -0
  103. package/learnship/workflows/map-codebase.md +142 -0
  104. package/learnship/workflows/milestone-retrospective.md +178 -0
  105. package/learnship/workflows/new-milestone.md +200 -0
  106. package/learnship/workflows/new-project.md +340 -0
  107. package/learnship/workflows/next.md +100 -0
  108. package/learnship/workflows/pause-work.md +122 -0
  109. package/learnship/workflows/plan-milestone-gaps.md +160 -0
  110. package/learnship/workflows/plan-phase.md +288 -0
  111. package/learnship/workflows/progress.md +118 -0
  112. package/learnship/workflows/quick.md +256 -0
  113. package/learnship/workflows/reapply-patches.md +130 -0
  114. package/learnship/workflows/release.md +217 -0
  115. package/learnship/workflows/remove-phase.md +128 -0
  116. package/learnship/workflows/research-phase.md +137 -0
  117. package/learnship/workflows/resume-work.md +162 -0
  118. package/learnship/workflows/set-profile.md +78 -0
  119. package/learnship/workflows/settings.md +204 -0
  120. package/learnship/workflows/sync-upstream-skills.md +269 -0
  121. package/learnship/workflows/transition.md +165 -0
  122. package/learnship/workflows/update.md +166 -0
  123. package/learnship/workflows/validate-phase.md +174 -0
  124. package/learnship/workflows/verify-work.md +264 -0
  125. package/package.json +62 -0
  126. package/references/design-commands.md +119 -0
  127. package/references/git-integration.md +249 -0
  128. package/references/learning-design.md +142 -0
  129. package/references/model-profiles.md +90 -0
  130. package/references/planning-config.md +184 -0
  131. package/references/questioning.md +162 -0
  132. package/references/ui-brand.md +160 -0
  133. package/references/verification-patterns.md +608 -0
  134. package/skills/agentic-learning/SKILL.md +373 -0
  135. package/skills/agentic-learning/references/either-or-format.md +161 -0
  136. package/skills/agentic-learning/references/learning-science.md +190 -0
  137. package/skills/agentic-learning/references/struggle-ladder.md +140 -0
  138. package/skills/impeccable/SKILL.md +125 -0
  139. package/skills/impeccable/adapt/SKILL.md +199 -0
  140. package/skills/impeccable/animate/SKILL.md +190 -0
  141. package/skills/impeccable/audit/SKILL.md +129 -0
  142. package/skills/impeccable/bolder/SKILL.md +132 -0
  143. package/skills/impeccable/clarify/SKILL.md +180 -0
  144. package/skills/impeccable/colorize/SKILL.md +158 -0
  145. package/skills/impeccable/critique/SKILL.md +118 -0
  146. package/skills/impeccable/delight/SKILL.md +317 -0
  147. package/skills/impeccable/distill/SKILL.md +137 -0
  148. package/skills/impeccable/extract/SKILL.md +95 -0
  149. package/skills/impeccable/frontend-design/SKILL.md +127 -0
  150. package/skills/impeccable/frontend-design/reference/color-and-contrast.md +132 -0
  151. package/skills/impeccable/frontend-design/reference/interaction-design.md +123 -0
  152. package/skills/impeccable/frontend-design/reference/motion-design.md +99 -0
  153. package/skills/impeccable/frontend-design/reference/responsive-design.md +114 -0
  154. package/skills/impeccable/frontend-design/reference/spatial-design.md +100 -0
  155. package/skills/impeccable/frontend-design/reference/typography.md +131 -0
  156. package/skills/impeccable/frontend-design/reference/ux-writing.md +107 -0
  157. package/skills/impeccable/harden/SKILL.md +358 -0
  158. package/skills/impeccable/normalize/SKILL.md +67 -0
  159. package/skills/impeccable/onboard/SKILL.md +243 -0
  160. package/skills/impeccable/optimize/SKILL.md +269 -0
  161. package/skills/impeccable/polish/SKILL.md +202 -0
  162. package/skills/impeccable/quieter/SKILL.md +118 -0
  163. package/skills/impeccable/teach-impeccable/SKILL.md +69 -0
  164. package/templates/agents.md +166 -0
  165. package/templates/config.json +22 -0
  166. package/templates/context.md +72 -0
  167. package/templates/plan.md +202 -0
  168. package/templates/project.md +184 -0
  169. package/templates/requirements.md +231 -0
  170. package/templates/state.md +176 -0
  171. package/templates/uat.md +80 -0
package/bin/install.js ADDED
@@ -0,0 +1,1242 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * learnship multi-platform installer
5
+ *
6
+ * Installs learnship workflows, agents, and commands for:
7
+ * --windsurf ~/.codeium/windsurf/workflows/ (Windsurf — same as install.sh)
8
+ * --claude ~/.claude/ (Claude Code)
9
+ * --opencode ~/.config/opencode/ (OpenCode)
10
+ * --gemini ~/.gemini/ (Gemini CLI)
11
+ * --codex ~/.codex/ (Codex CLI / OpenAI Codex)
12
+ * --all All platforms
13
+ *
14
+ * Usage:
15
+ * npx learnship Interactive install
16
+ * npx learnship --claude --global Claude Code, global
17
+ * npx learnship --all --global All platforms, global
18
+ * npx learnship --claude --global --uninstall Remove Claude Code files
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const os = require('os');
26
+ const readline = require('readline');
27
+
28
+ const pkg = require('../package.json');
29
+
30
+ // Codex config.toml marker — used to identify learnship-managed section
31
+ const LEARNSHIP_CODEX_MARKER = '# learnship Agent Configuration — managed by learnship installer';
32
+
33
+ // Per-agent Codex sandbox modes (read-only for checkers, workspace-write for executors)
34
+ const CODEX_AGENT_SANDBOX = {
35
+ 'learnship-executor': 'workspace-write',
36
+ 'learnship-planner': 'workspace-write',
37
+ 'learnship-phase-researcher': 'workspace-write',
38
+ 'learnship-verifier': 'workspace-write',
39
+ 'learnship-debugger': 'workspace-write',
40
+ 'learnship-plan-checker': 'read-only',
41
+ };
42
+
43
+ // ─── Colors ────────────────────────────────────────────────────────────────
44
+ const cyan = '\x1b[36m';
45
+ const purple = '\x1b[38;5;135m';
46
+ const green = '\x1b[32m';
47
+ const yellow = '\x1b[33m';
48
+ const dim = '\x1b[2m';
49
+ const reset = '\x1b[0m';
50
+
51
+ // ─── Argument parsing ──────────────────────────────────────────────────────
52
+ const args = process.argv.slice(2);
53
+ const hasWindsurf = args.includes('--windsurf');
54
+ const hasClaude = args.includes('--claude');
55
+ const hasOpencode = args.includes('--opencode');
56
+ const hasGemini = args.includes('--gemini');
57
+ const hasCodex = args.includes('--codex');
58
+ const hasAll = args.includes('--all');
59
+ const hasGlobal = args.includes('--global') || args.includes('-g');
60
+ const hasLocal = args.includes('--local') || args.includes('-l');
61
+ const hasUninstall = args.includes('--uninstall') || args.includes('-u');
62
+ const hasHelp = args.includes('--help') || args.includes('-h');
63
+
64
+ let selectedPlatforms = [];
65
+ if (hasAll) {
66
+ selectedPlatforms = ['windsurf', 'claude', 'opencode', 'gemini', 'codex'];
67
+ } else {
68
+ if (hasWindsurf) selectedPlatforms.push('windsurf');
69
+ if (hasClaude) selectedPlatforms.push('claude');
70
+ if (hasOpencode) selectedPlatforms.push('opencode');
71
+ if (hasGemini) selectedPlatforms.push('gemini');
72
+ if (hasCodex) selectedPlatforms.push('codex');
73
+ }
74
+
75
+ // ─── Banner ────────────────────────────────────────────────────────────────
76
+ const banner = `
77
+ ${purple} ██╗ ███████╗ █████╗ ██████╗ ███╗ ██╗███████╗██╗ ██╗██╗██████╗
78
+ ██║ ██╔════╝██╔══██╗██╔══██╗████╗ ██║██╔════╝██║ ██║██║██╔══██╗
79
+ ██║ █████╗ ███████║██████╔╝██╔██╗ ██║███████╗███████║██║██████╔╝
80
+ ██║ ██╔══╝ ██╔══██║██╔══██╗██║╚██╗██║╚════██║██╔══██║██║██╔═══╝
81
+ ███████╗███████╗██║ ██║██║ ██║██║ ╚████║███████║██║ ██║██║██║
82
+ ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝╚═╝╚═╝${reset}
83
+
84
+ ${dim}Learn as you build. Build with intent.${reset}
85
+ ${dim}v${pkg.version} · Windsurf · Claude Code · OpenCode · Gemini CLI · Codex CLI${reset}
86
+ `;
87
+
88
+ // ─── Help text ─────────────────────────────────────────────────────────────
89
+ const helpText = `
90
+ ${yellow}Usage:${reset} npx learnship [platform] [scope] [options]
91
+
92
+ ${yellow}Platforms:${reset}
93
+ ${cyan}--windsurf${reset} Windsurf (same as install.sh)
94
+ ${cyan}--claude${reset} Claude Code (~/.claude/)
95
+ ${cyan}--opencode${reset} OpenCode (~/.config/opencode/)
96
+ ${cyan}--gemini${reset} Gemini CLI (~/.gemini/)
97
+ ${cyan}--codex${reset} Codex CLI (~/.codex/)
98
+ ${cyan}--all${reset} All platforms
99
+
100
+ ${yellow}Scope:${reset}
101
+ ${cyan}-g, --global${reset} Install to global config directory (recommended)
102
+ ${cyan}-l, --local${reset} Install to current project directory
103
+
104
+ ${yellow}Options:${reset}
105
+ ${cyan}-u, --uninstall${reset} Remove learnship files
106
+ ${cyan}-h, --help${reset} Show this help
107
+
108
+ ${yellow}Examples:${reset}
109
+ ${dim}# Interactive install (prompts for platform and scope)${reset}
110
+ npx learnship
111
+
112
+ ${dim}# Install for Claude Code globally${reset}
113
+ npx learnship --claude --global
114
+
115
+ ${dim}# Install for all platforms globally${reset}
116
+ npx learnship --all --global
117
+
118
+ ${dim}# Install for Gemini CLI globally${reset}
119
+ npx learnship --gemini --global
120
+
121
+ ${dim}# Install for Codex globally${reset}
122
+ npx learnship --codex --global
123
+
124
+ ${dim}# Uninstall from OpenCode${reset}
125
+ npx learnship --opencode --global --uninstall
126
+
127
+ ${dim}# Install to current project only${reset}
128
+ npx learnship --claude --local
129
+ `;
130
+
131
+ // ─── Path helpers ──────────────────────────────────────────────────────────
132
+ function expandTilde(p) {
133
+ if (p && p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
134
+ return p;
135
+ }
136
+
137
+ function getDirName(platform) {
138
+ if (platform === 'opencode') return '.opencode';
139
+ if (platform === 'gemini') return '.gemini';
140
+ if (platform === 'codex') return '.codex';
141
+ if (platform === 'windsurf') return '.windsurf';
142
+ return '.claude';
143
+ }
144
+
145
+ function getGlobalDir(platform) {
146
+ switch (platform) {
147
+ case 'opencode': {
148
+ if (process.env.OPENCODE_CONFIG_DIR) return expandTilde(process.env.OPENCODE_CONFIG_DIR);
149
+ if (process.env.XDG_CONFIG_HOME) return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
150
+ return path.join(os.homedir(), '.config', 'opencode');
151
+ }
152
+ case 'gemini': {
153
+ if (process.env.GEMINI_CONFIG_DIR) return expandTilde(process.env.GEMINI_CONFIG_DIR);
154
+ return path.join(os.homedir(), '.gemini');
155
+ }
156
+ case 'codex': {
157
+ if (process.env.CODEX_HOME) return expandTilde(process.env.CODEX_HOME);
158
+ return path.join(os.homedir(), '.codex');
159
+ }
160
+ case 'windsurf': {
161
+ return path.join(os.homedir(), '.codeium', 'windsurf');
162
+ }
163
+ default: { // claude
164
+ if (process.env.CLAUDE_CONFIG_DIR) return expandTilde(process.env.CLAUDE_CONFIG_DIR);
165
+ return path.join(os.homedir(), '.claude');
166
+ }
167
+ }
168
+ }
169
+
170
+ function getPlatformLabel(platform) {
171
+ const labels = {
172
+ windsurf: 'Windsurf', claude: 'Claude Code',
173
+ opencode: 'OpenCode', gemini: 'Gemini CLI', codex: 'Codex CLI',
174
+ };
175
+ return labels[platform] || platform;
176
+ }
177
+
178
+ // ─── File helpers ──────────────────────────────────────────────────────────
179
+ function readSettings(p) {
180
+ if (!fs.existsSync(p)) return {};
181
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; }
182
+ }
183
+ function writeSettings(p, obj) {
184
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
185
+ }
186
+
187
+ // ─── Path helpers (extended) ───────────────────────────────────────────────
188
+
189
+ /**
190
+ * Convert an absolute pathPrefix to $HOME-relative form for bash code blocks.
191
+ * Keeps $HOME as a shell variable so paths remain portable across machines.
192
+ */
193
+ function toHomePrefix(pathPrefix) {
194
+ const home = os.homedir().replace(/\\/g, '/');
195
+ const normalized = pathPrefix.replace(/\\/g, '/');
196
+ if (normalized.startsWith(home)) return '$HOME' + normalized.slice(home.length);
197
+ return normalized;
198
+ }
199
+
200
+ // ─── Frontmatter conversion ────────────────────────────────────────────────
201
+
202
+ // Color name → hex for OpenCode (color names not supported)
203
+ const colorNameToHex = {
204
+ cyan: '#00FFFF', red: '#FF0000', green: '#00FF00', blue: '#0000FF',
205
+ yellow: '#FFFF00', magenta: '#FF00FF', orange: '#FFA500', purple: '#800080',
206
+ pink: '#FFC0CB', white: '#FFFFFF', black: '#000000', gray: '#808080', grey: '#808080',
207
+ };
208
+
209
+ /** Convert Claude Code tool name → OpenCode tool name */
210
+ function toOpencodeToolName(t) {
211
+ const map = { AskUserQuestion: 'question', SlashCommand: 'skill', TodoWrite: 'todowrite',
212
+ WebFetch: 'webfetch', WebSearch: 'websearch' };
213
+ return map[t] || t.toLowerCase();
214
+ }
215
+
216
+ /** Convert Claude Code tool name → Gemini CLI tool name (snake_case) */
217
+ function toGeminiToolName(t) {
218
+ if (t.startsWith('mcp__') || t === 'Task') return null; // auto-discovered
219
+ const map = { Read: 'read_file', Write: 'write_file', Edit: 'replace',
220
+ Bash: 'run_shell_command', Glob: 'glob', Grep: 'search_file_content',
221
+ WebSearch: 'google_web_search', WebFetch: 'web_fetch',
222
+ TodoWrite: 'write_todos', AskUserQuestion: 'ask_user' };
223
+ return map[t] || t.toLowerCase();
224
+ }
225
+
226
+ /** Parse YAML frontmatter from a .md file. Returns { frontmatter, body }. */
227
+ function parseFrontmatter(content) {
228
+ if (!content.startsWith('---')) return { frontmatter: null, body: content };
229
+ const end = content.indexOf('---', 3);
230
+ if (end === -1) return { frontmatter: null, body: content };
231
+ return { frontmatter: content.substring(3, end).trim(), body: content.substring(end + 3) };
232
+ }
233
+
234
+ function getFrontmatterField(fm, field) {
235
+ const m = fm.match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
236
+ return m ? m[1].trim().replace(/^['"]|['"]$/g, '') : null;
237
+ }
238
+
239
+ /**
240
+ * Convert Claude Code command/agent .md → OpenCode format
241
+ * - allowed-tools array → tools object (tool: true)
242
+ * - name: field removed (OpenCode uses filename for commands)
243
+ * - color: name → hex (OpenCode requires hex)
244
+ * - /learnship:cmd → /learnship-cmd
245
+ * - subagent_type="general-purpose" → subagent_type="general"
246
+ * - ~/.claude/ → ~/.config/opencode/
247
+ */
248
+ function convertToOpencode(content) {
249
+ let c = content
250
+ .replace(/\/learnship:/g, '/learnship-')
251
+ .replace(/~\/\.claude\//g, '~/.config/opencode/')
252
+ .replace(/\$HOME\/\.claude\//g, '$HOME/.config/opencode/')
253
+ .replace(/\bAskUserQuestion\b/g, 'question')
254
+ .replace(/\bSlashCommand\b/g, 'skill')
255
+ .replace(/\bTodoWrite\b/g, 'todowrite')
256
+ .replace(/subagent_type="general-purpose"/g, 'subagent_type="general"');
257
+
258
+ const { frontmatter, body } = parseFrontmatter(c);
259
+ if (!frontmatter) return c;
260
+
261
+ const lines = frontmatter.split('\n');
262
+ const newLines = [];
263
+ let inTools = false;
264
+ const tools = [];
265
+
266
+ for (const line of lines) {
267
+ const t = line.trim();
268
+ if (t.startsWith('name:')) continue; // OpenCode uses filename for command name
269
+ if (t.startsWith('allowed-tools:')) { inTools = true; continue; }
270
+ // Handle inline tools: field (comma-separated string, e.g. agents use 'tools: Read, Write')
271
+ if (t.startsWith('tools:')) {
272
+ const toolsValue = t.substring(6).trim();
273
+ if (toolsValue) {
274
+ // Inline comma-separated: tools: Read, Write, Bash
275
+ for (const tool of toolsValue.split(',').map(s => s.trim()).filter(Boolean)) {
276
+ tools.push(tool);
277
+ }
278
+ } else {
279
+ // YAML array follows
280
+ inTools = true;
281
+ }
282
+ continue;
283
+ }
284
+ // Convert color names to hex
285
+ if (t.startsWith('color:')) {
286
+ const colorVal = t.substring(6).trim().toLowerCase();
287
+ const hex = colorNameToHex[colorVal];
288
+ if (hex) { newLines.push(`color: "${hex}"`); }
289
+ else if (colorVal.startsWith('#')) { newLines.push(line); }
290
+ // skip unknown color names entirely
291
+ continue;
292
+ }
293
+ if (inTools) {
294
+ if (t.startsWith('- ')) { tools.push(t.slice(2).trim()); continue; }
295
+ if (t && !t.startsWith('-')) inTools = false;
296
+ }
297
+ if (!inTools) newLines.push(line);
298
+ }
299
+ if (tools.length > 0) {
300
+ newLines.push('tools:');
301
+ for (const tool of tools) newLines.push(` ${toOpencodeToolName(tool)}: true`);
302
+ }
303
+ return `---\n${newLines.join('\n').trim()}\n---${body}`;
304
+ }
305
+
306
+ /**
307
+ * Convert Claude Code command .md → Gemini CLI .toml
308
+ * Gemini uses TOML: description = "..." and prompt = "..."
309
+ */
310
+ function convertToGeminiToml(content) {
311
+ const { frontmatter, body } = parseFrontmatter(content);
312
+ // Strip <sub> tags — terminals can't render them
313
+ const cleanBody = (body || content).replace(/<sub>(.*?)<\/sub>/g, '*($1)*').trim();
314
+ let desc = '';
315
+ if (frontmatter) {
316
+ const d = getFrontmatterField(frontmatter, 'description');
317
+ if (d) desc = d;
318
+ }
319
+ let toml = '';
320
+ if (desc) toml += `description = ${JSON.stringify(desc)}\n`;
321
+ // Escape ${VAR} → $VAR (Gemini treats ${word} as template variables)
322
+ const escapedBody = cleanBody.replace(/\$\{(\w+)\}/g, '$$$1');
323
+ toml += `prompt = ${JSON.stringify(escapedBody)}\n`;
324
+ return toml;
325
+ }
326
+
327
+ /**
328
+ * Convert Claude Code command .md → Codex skill SKILL.md
329
+ * - /learnship:cmd → $learnship-cmd
330
+ * - $ARGUMENTS → {{LEARNSHIP_ARGS}}
331
+ * - AskUserQuestion → request_user_input
332
+ * - Adds full <codex_skill_adapter> header (matching GSD's detail level)
333
+ */
334
+ function convertToCodexSkill(content, skillName) {
335
+ let c = content
336
+ .replace(/\/learnship:([a-z0-9-]+)/gi, '$learnship-$1')
337
+ .replace(/\$ARGUMENTS\b/g, '{{LEARNSHIP_ARGS}}')
338
+ .replace(/\/learnship-help\b/g, '$learnship-help');
339
+
340
+ const { frontmatter, body } = parseFrontmatter(c);
341
+ let description = `Run learnship workflow ${skillName}.`;
342
+ if (frontmatter) {
343
+ const d = getFrontmatterField(frontmatter, 'description');
344
+ if (d) description = d.replace(/\s+/g, ' ').trim();
345
+ }
346
+ const shortDesc = description.length > 180 ? description.slice(0, 177) + '...' : description;
347
+ const invocation = `$${skillName}`;
348
+
349
+ const adapter = `<codex_skill_adapter>
350
+ ## A. Skill Invocation
351
+ - This skill is invoked by mentioning \`${invocation}\`.
352
+ - Treat all user text after \`${invocation}\` as \`{{LEARNSHIP_ARGS}}\`.
353
+ - If no arguments are present, treat \`{{LEARNSHIP_ARGS}}\` as empty.
354
+
355
+ ## B. AskUserQuestion → request_user_input Mapping
356
+ learnship workflows use \`AskUserQuestion\` (Claude Code syntax). Translate to Codex \`request_user_input\`:
357
+
358
+ Parameter mapping:
359
+ - \`header\` → \`header\`
360
+ - \`question\` → \`question\`
361
+ - Options formatted as \`"Label" — description\` → \`{label: "Label", description: "description"}\`
362
+ - Generate \`id\` from header: lowercase, replace spaces with underscores
363
+
364
+ Multi-select workaround:
365
+ - Codex has no \`multiSelect\`. Use sequential single-selects, or present a numbered freeform list.
366
+
367
+ Execute mode fallback:
368
+ - When \`request_user_input\` is rejected, present a plain-text numbered list and pick a reasonable default.
369
+
370
+ ## C. Task() → spawn_agent Mapping
371
+ learnship workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools:
372
+
373
+ Direct mapping:
374
+ - \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
375
+ - \`Task(model="...")\` → omit (Codex uses per-role config)
376
+ - \`fork_context: false\` by default — learnship agents load their own context via \`<files_to_read>\` blocks
377
+
378
+ Parallel fan-out:
379
+ - Spawn multiple agents → collect agent IDs → \`wait(ids)\` for all to complete
380
+
381
+ Result parsing:
382
+ - Look for structured markers: \`CHECKPOINT\`, \`PLAN COMPLETE\`, \`SUMMARY\`, etc.
383
+ - \`close_agent(id)\` after collecting results from each agent
384
+ </codex_skill_adapter>`;
385
+
386
+ return `---\nname: ${JSON.stringify(skillName)}\ndescription: ${JSON.stringify(description)}\nmetadata:\n short-description: ${JSON.stringify(shortDesc)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
387
+ }
388
+
389
+ /**
390
+ * Convert Claude Code agent .md to Codex agent format.
391
+ * Adds <codex_agent_role> header, cleans frontmatter (removes tools/color).
392
+ */
393
+ function convertClaudeAgentToCodexAgent(content) {
394
+ // Apply base Codex markdown conversions first
395
+ let c = content
396
+ .replace(/\/learnship:([a-z0-9-]+)/gi, '$learnship-$1')
397
+ .replace(/\$ARGUMENTS\b/g, '{{LEARNSHIP_ARGS}}');
398
+
399
+ const { frontmatter, body } = parseFrontmatter(c);
400
+ if (!frontmatter) return c;
401
+
402
+ const name = getFrontmatterField(frontmatter, 'name') || 'unknown';
403
+ const description = (getFrontmatterField(frontmatter, 'description') || '').replace(/\s+/g, ' ').trim();
404
+ const tools = getFrontmatterField(frontmatter, 'tools') || '';
405
+
406
+ const roleHeader = `<codex_agent_role>\nrole: ${name}\ntools: ${tools}\npurpose: ${description}\n</codex_agent_role>`;
407
+ const cleanFrontmatter = `---\nname: ${JSON.stringify(name)}\ndescription: ${JSON.stringify(description)}\n---`;
408
+
409
+ return `${cleanFrontmatter}\n\n${roleHeader}\n${body}`;
410
+ }
411
+
412
+ /**
413
+ * Convert agent .md for Gemini CLI
414
+ * - allowed-tools array → tools YAML array with snake_case names
415
+ * - color: removed (causes validation error in Gemini)
416
+ * - Task: excluded (agents auto-registered as tools in Gemini)
417
+ * - ${VAR} → $VAR in body (Gemini template engine would misparse ${WORD})
418
+ */
419
+ function convertAgentForGemini(content) {
420
+ if (!content.startsWith('---')) return content;
421
+ const end = content.indexOf('---', 3);
422
+ if (end === -1) return content;
423
+ const frontmatter = content.substring(3, end).trim();
424
+ const body = content.substring(end + 3);
425
+
426
+ const lines = frontmatter.split('\n');
427
+ const newLines = [];
428
+ let inTools = false;
429
+ const tools = [];
430
+
431
+ for (const line of lines) {
432
+ const t = line.trim();
433
+ if (t.startsWith('color:')) continue; // Gemini rejects color field
434
+ if (t.startsWith('allowed-tools:')) { inTools = true; continue; }
435
+ // Handle inline tools: field (comma-separated, used by agent frontmatter)
436
+ if (t.startsWith('tools:')) {
437
+ const toolsValue = t.substring(6).trim();
438
+ if (toolsValue) {
439
+ // Inline: tools: Read, Write, Bash
440
+ for (const tool of toolsValue.split(',').map(s => s.trim()).filter(Boolean)) {
441
+ const mapped = toGeminiToolName(tool);
442
+ if (mapped) tools.push(mapped);
443
+ }
444
+ } else {
445
+ // YAML array follows
446
+ inTools = true;
447
+ }
448
+ continue;
449
+ }
450
+ if (inTools) {
451
+ if (t.startsWith('- ')) {
452
+ const mapped = toGeminiToolName(t.slice(2).trim());
453
+ if (mapped) tools.push(mapped);
454
+ continue;
455
+ }
456
+ if (t && !t.startsWith('-')) inTools = false;
457
+ }
458
+ if (!inTools) newLines.push(line);
459
+ }
460
+ if (tools.length > 0) {
461
+ newLines.push('tools:');
462
+ for (const tool of tools) newLines.push(` - ${tool}`);
463
+ }
464
+ const escapedBody = body.replace(/\$\{(\w+)\}/g, '$$$1');
465
+ return `---\n${newLines.join('\n').trim()}\n---${escapedBody}`;
466
+ }
467
+
468
+ // ─── File copy helpers ─────────────────────────────────────────────────────
469
+
470
+ /** Verify a directory exists and has files */
471
+ function verifyInstalled(dirPath, description) {
472
+ if (!fs.existsSync(dirPath)) {
473
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: directory not created`);
474
+ return false;
475
+ }
476
+ try {
477
+ if (fs.readdirSync(dirPath).length === 0) {
478
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: directory is empty`);
479
+ return false;
480
+ }
481
+ } catch (e) {
482
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: ${e.message}`);
483
+ return false;
484
+ }
485
+ return true;
486
+ }
487
+
488
+ /** Recursively copy dir, replacing path references in .md files */
489
+ function copyDir(srcDir, destDir, pathPrefix, platform) {
490
+ if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true });
491
+ fs.mkdirSync(destDir, { recursive: true });
492
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
493
+ const src = path.join(srcDir, entry.name);
494
+ const dest = path.join(destDir, entry.name);
495
+ if (entry.isDirectory()) {
496
+ copyDir(src, dest, pathPrefix, platform);
497
+ } else if (entry.name.endsWith('.md')) {
498
+ let c = fs.readFileSync(src, 'utf8');
499
+ c = replacePaths(c, pathPrefix, platform);
500
+ if (platform === 'opencode') c = convertToOpencode(c);
501
+ // gemini agents converted separately; body ${VAR} escaping done there
502
+ fs.writeFileSync(dest, c);
503
+ } else {
504
+ fs.copyFileSync(src, dest);
505
+ }
506
+ }
507
+ }
508
+
509
+ function replacePaths(content, pathPrefix, platform) {
510
+ const dirName = getDirName(platform);
511
+ let c = content
512
+ // Source files use ~/.claude/ and $HOME/.claude/ as canonical paths
513
+ .replace(/~\/\.claude\//g, pathPrefix)
514
+ .replace(/\$HOME\/\.claude\//g, toHomePrefix(pathPrefix))
515
+ // Local ./.claude/ refs → ./<dirName>/
516
+ .replace(/\.\/.claude\//g, `./${dirName}/`);
517
+ // Also replace platform-specific dir refs that may appear in source
518
+ if (platform === 'opencode') {
519
+ c = c.replace(/~\/\.opencode\//g, pathPrefix);
520
+ } else if (platform === 'gemini') {
521
+ c = c.replace(/~\/\.gemini\//g, pathPrefix);
522
+ } else if (platform === 'codex') {
523
+ c = c.replace(/~\/\.codex\//g, pathPrefix);
524
+ }
525
+ return c;
526
+ }
527
+
528
+ /** Install Claude Code / Windsurf commands (commands/learnship/ → target/commands/learnship/) */
529
+ function installClaudeCommands(srcDir, targetDir, pathPrefix) {
530
+ const destDir = path.join(targetDir, 'commands', 'learnship');
531
+ if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true });
532
+ fs.mkdirSync(destDir, { recursive: true });
533
+ let count = 0;
534
+ for (const f of fs.readdirSync(srcDir)) {
535
+ if (!f.endsWith('.md')) continue;
536
+ let c = fs.readFileSync(path.join(srcDir, f), 'utf8');
537
+ c = replacePaths(c, pathPrefix, 'claude');
538
+ fs.writeFileSync(path.join(destDir, f), c);
539
+ count++;
540
+ }
541
+ return count;
542
+ }
543
+
544
+ /** Install Claude Code native plugin skills (plugins/learnship/skills/) */
545
+ function installClaudePlugins(skillsSrc, targetDir) {
546
+ const pluginDir = path.join(targetDir, 'plugins', 'learnship');
547
+ const pluginSkillsDir = path.join(pluginDir, 'skills');
548
+ const pluginMetaDir = path.join(pluginDir, '.claude-plugin');
549
+
550
+ // Clean install
551
+ if (fs.existsSync(pluginDir)) fs.rmSync(pluginDir, { recursive: true });
552
+ fs.mkdirSync(pluginSkillsDir, { recursive: true });
553
+ fs.mkdirSync(pluginMetaDir, { recursive: true });
554
+
555
+ // Write plugin manifest
556
+ const manifest = {
557
+ name: 'learnship',
558
+ description: 'Learnship skills — agentic-learning partner and impeccable design system',
559
+ author: { name: 'favio-vazquez' },
560
+ };
561
+ fs.writeFileSync(
562
+ path.join(pluginMetaDir, 'plugin.json'),
563
+ JSON.stringify(manifest, null, 2) + '\n'
564
+ );
565
+
566
+ let count = 0;
567
+
568
+ for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
569
+ if (!entry.isDirectory()) continue;
570
+ const skillName = entry.name;
571
+ const srcPath = path.join(skillsSrc, skillName);
572
+
573
+ if (!fs.existsSync(path.join(srcPath, 'SKILL.md'))) continue;
574
+
575
+ const dest = path.join(pluginSkillsDir, skillName);
576
+
577
+ if (skillName === 'impeccable') {
578
+ // impeccable: copy root SKILL.md (rewriting sibling paths → references/ paths),
579
+ // then copy each sub-skill dir into references/
580
+ fs.mkdirSync(dest, { recursive: true });
581
+ let skillMdContent = fs.readFileSync(path.join(srcPath, 'SKILL.md'), 'utf8');
582
+ // Rewrite repo-relative sibling links (e.g. adapt/SKILL.md) to post-install references/ paths
583
+ skillMdContent = skillMdContent.replace(/\]\((?!references\/)([^/)][^)]*\/SKILL\.md)\)/g, '](references/$1)');
584
+ fs.writeFileSync(path.join(dest, 'SKILL.md'), skillMdContent);
585
+ const refsDest = path.join(dest, 'references');
586
+ fs.mkdirSync(refsDest, { recursive: true });
587
+ for (const sub of fs.readdirSync(srcPath, { withFileTypes: true })) {
588
+ if (!sub.isDirectory()) continue;
589
+ const subSrc = path.join(srcPath, sub.name);
590
+ const subDest = path.join(refsDest, sub.name);
591
+ if (fs.existsSync(path.join(subSrc, 'SKILL.md'))) {
592
+ copyDir(subSrc, subDest, '', 'claude');
593
+ }
594
+ }
595
+ count++;
596
+ } else {
597
+ // agentic-learning and any future top-level skills — copy verbatim
598
+ copyDir(srcPath, dest, '', 'claude');
599
+ count++;
600
+ }
601
+ }
602
+
603
+ return count;
604
+ }
605
+
606
+ /** Install OpenCode commands (flat: learnship-*.md) */
607
+ function installOpencodeCommands(srcDir, targetDir, pathPrefix) {
608
+ const destDir = path.join(targetDir, 'command');
609
+ fs.mkdirSync(destDir, { recursive: true });
610
+ // Remove old learnship-*.md
611
+ if (fs.existsSync(destDir)) {
612
+ for (const f of fs.readdirSync(destDir)) {
613
+ if (f.startsWith('learnship-') && f.endsWith('.md')) fs.unlinkSync(path.join(destDir, f));
614
+ }
615
+ }
616
+ let count = 0;
617
+ for (const f of fs.readdirSync(srcDir)) {
618
+ if (!f.endsWith('.md')) continue;
619
+ const baseName = f.replace('.md', '');
620
+ const destName = `learnship-${baseName}.md`;
621
+ let c = fs.readFileSync(path.join(srcDir, f), 'utf8');
622
+ c = replacePaths(c, pathPrefix, 'opencode');
623
+ c = convertToOpencode(c);
624
+ fs.writeFileSync(path.join(destDir, destName), c);
625
+ count++;
626
+ }
627
+ return count;
628
+ }
629
+
630
+ /** Install Gemini CLI commands (commands/learnship/*.toml) */
631
+ function installGeminiCommands(srcDir, targetDir, pathPrefix) {
632
+ const destDir = path.join(targetDir, 'commands', 'learnship');
633
+ if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true });
634
+ fs.mkdirSync(destDir, { recursive: true });
635
+ let count = 0;
636
+ for (const f of fs.readdirSync(srcDir)) {
637
+ if (!f.endsWith('.md')) continue;
638
+ let c = fs.readFileSync(path.join(srcDir, f), 'utf8');
639
+ c = replacePaths(c, pathPrefix, 'gemini');
640
+ const toml = convertToGeminiToml(c);
641
+ const destName = f.replace('.md', '.toml');
642
+ fs.writeFileSync(path.join(destDir, destName), toml);
643
+ count++;
644
+ }
645
+ return count;
646
+ }
647
+
648
+ /* Install Codex skills (skills/learnship-NAME/SKILL.md) */
649
+ function installCodexSkills(srcDir, targetDir, pathPrefix) {
650
+ const skillsDir = path.join(targetDir, 'skills');
651
+ fs.mkdirSync(skillsDir, { recursive: true });
652
+ // Remove old learnship-* skill dirs
653
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
654
+ if (entry.isDirectory() && entry.name.startsWith('learnship-')) {
655
+ fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
656
+ }
657
+ }
658
+ let count = 0;
659
+ for (const f of fs.readdirSync(srcDir)) {
660
+ if (!f.endsWith('.md')) continue;
661
+ const baseName = f.replace('.md', '');
662
+ const skillName = `learnship-${baseName}`;
663
+ const skillDir = path.join(skillsDir, skillName);
664
+ fs.mkdirSync(skillDir, { recursive: true });
665
+ let c = fs.readFileSync(path.join(srcDir, f), 'utf8');
666
+ c = replacePaths(c, pathPrefix, 'codex');
667
+ c = convertToCodexSkill(c, skillName);
668
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), c);
669
+ count++;
670
+ }
671
+ return count;
672
+ }
673
+
674
+ /**
675
+ * Generate the learnship config block for Codex config.toml.
676
+ */
677
+ function generateCodexConfigBlock(agents) {
678
+ const lines = [
679
+ LEARNSHIP_CODEX_MARKER,
680
+ '[features]',
681
+ 'multi_agent = true',
682
+ 'default_mode_request_user_input = true',
683
+ '',
684
+ '[agents]',
685
+ 'max_threads = 4',
686
+ 'max_depth = 2',
687
+ '',
688
+ ];
689
+ for (const { name, description } of agents) {
690
+ lines.push(`[agents.${name}]`);
691
+ lines.push(`description = ${JSON.stringify(description)}`);
692
+ lines.push(`config_file = "agents/${name}.toml"`);
693
+ lines.push('');
694
+ }
695
+ return lines.join('\n');
696
+ }
697
+
698
+ /**
699
+ * Strip learnship sections from Codex config.toml content.
700
+ * Returns cleaned content, or null if file would be empty after stripping.
701
+ */
702
+ function stripLearnshipFromCodexConfig(content) {
703
+ const markerIndex = content.indexOf(LEARNSHIP_CODEX_MARKER);
704
+ if (markerIndex !== -1) {
705
+ let before = content.substring(0, markerIndex).trimEnd();
706
+ before = before.replace(/^multi_agent\s*=\s*true\s*\n?/m, '');
707
+ before = before.replace(/^default_mode_request_user_input\s*=\s*true\s*\n?/m, '');
708
+ before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
709
+ before = before.replace(/\n{3,}/g, '\n\n').trim();
710
+ if (!before) return null;
711
+ return before + '\n';
712
+ }
713
+ // No marker — clean any learnship-injected keys that may have leaked
714
+ let cleaned = content;
715
+ cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*\n?/m, '');
716
+ cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*\n?/m, '');
717
+ cleaned = cleaned.replace(/^\[agents\.learnship-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, '');
718
+ cleaned = cleaned.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
719
+ cleaned = cleaned.replace(/^\[agents\]\s*\n(?=\[|$)/m, '');
720
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
721
+ if (!cleaned) return null;
722
+ return cleaned + '\n';
723
+ }
724
+
725
+ /**
726
+ * Merge learnship config block into existing or new config.toml.
727
+ * Three cases: new file, existing with learnship marker, existing without marker.
728
+ */
729
+ function mergeCodexConfig(configPath, learnshipBlock) {
730
+ // Case 1: No config.toml — create fresh
731
+ if (!fs.existsSync(configPath)) {
732
+ fs.writeFileSync(configPath, learnshipBlock + '\n');
733
+ return;
734
+ }
735
+ const existing = fs.readFileSync(configPath, 'utf8');
736
+ const markerIndex = existing.indexOf(LEARNSHIP_CODEX_MARKER);
737
+
738
+ // Case 2: Has learnship marker — truncate and re-append
739
+ if (markerIndex !== -1) {
740
+ let before = existing.substring(0, markerIndex).trimEnd();
741
+ if (before) {
742
+ before = before.replace(/^\[agents\.learnship-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, '');
743
+ before = before.replace(/^\[agents\]\n(?:(?!\[)[^\n]*\n?)*/m, '');
744
+ before = before.replace(/\n{3,}/g, '\n\n').trimEnd();
745
+ const hasFeatures = /^\[features\]\s*$/m.test(before);
746
+ if (hasFeatures) {
747
+ if (!before.includes('multi_agent')) before = before.replace(/^\[features\]\s*$/m, '[features]\nmulti_agent = true');
748
+ if (!before.includes('default_mode_request_user_input')) before = before.replace(/^\[features\].*$/m, '$&\ndefault_mode_request_user_input = true');
749
+ const block = LEARNSHIP_CODEX_MARKER + '\n' + learnshipBlock.substring(learnshipBlock.indexOf('[agents]'));
750
+ fs.writeFileSync(configPath, before + '\n\n' + block + '\n');
751
+ } else {
752
+ fs.writeFileSync(configPath, before + '\n\n' + learnshipBlock + '\n');
753
+ }
754
+ } else {
755
+ fs.writeFileSync(configPath, learnshipBlock + '\n');
756
+ }
757
+ return;
758
+ }
759
+
760
+ // Case 3: No marker — inject features if needed, append agents
761
+ let content = existing;
762
+ const featuresRegex = /^\[features\]\s*$/m;
763
+ const hasFeatures = featuresRegex.test(content);
764
+ if (hasFeatures) {
765
+ if (!content.includes('multi_agent')) content = content.replace(featuresRegex, '[features]\nmulti_agent = true');
766
+ if (!content.includes('default_mode_request_user_input')) content = content.replace(/^\[features\].*$/m, '$&\ndefault_mode_request_user_input = true');
767
+ const agentsBlock = learnshipBlock.substring(learnshipBlock.indexOf('[agents]'));
768
+ content = content.trimEnd() + '\n\n' + LEARNSHIP_CODEX_MARKER + '\n' + agentsBlock + '\n';
769
+ } else {
770
+ content = content.trimEnd() + '\n\n' + learnshipBlock + '\n';
771
+ }
772
+ fs.writeFileSync(configPath, content);
773
+ }
774
+
775
+ /** Install Codex agent .toml files and update config.toml */
776
+ function installCodexAgents(agentsSrcDir, targetDir, pathPrefix) {
777
+ const agentsDir = path.join(targetDir, 'agents');
778
+ fs.mkdirSync(agentsDir, { recursive: true });
779
+ // Remove stale learnship agent .toml files
780
+ for (const f of fs.readdirSync(agentsDir)) {
781
+ if (f.startsWith('learnship-') && f.endsWith('.toml')) fs.unlinkSync(path.join(agentsDir, f));
782
+ }
783
+ const agents = [];
784
+ for (const f of fs.readdirSync(agentsSrcDir)) {
785
+ if (!f.startsWith('learnship-') || !f.endsWith('.md')) continue;
786
+ let content = fs.readFileSync(path.join(agentsSrcDir, f), 'utf8');
787
+ // Replace ~/.claude/ paths before generating TOML
788
+ content = content.replace(/~\/\.claude\//g, pathPrefix);
789
+ content = content.replace(/\$HOME\/\.claude\//g, toHomePrefix(pathPrefix));
790
+ // Convert to Codex agent format
791
+ content = convertClaudeAgentToCodexAgent(content);
792
+ const { frontmatter, body } = parseFrontmatter(content);
793
+ const name = frontmatter ? (getFrontmatterField(frontmatter, 'name') || f.replace('.md','')) : f.replace('.md','');
794
+ const description = frontmatter ? (getFrontmatterField(frontmatter, 'description') || '').replace(/\s+/g,' ').trim() : '';
795
+ agents.push({ name, description });
796
+ const sandboxMode = CODEX_AGENT_SANDBOX[name] || 'workspace-write';
797
+ const tomlContent = `sandbox_mode = "${sandboxMode}"\ndeveloper_instructions = """\n${body.trim()}\n"""\n`;
798
+ fs.writeFileSync(path.join(agentsDir, `${name}.toml`), tomlContent);
799
+ }
800
+ const learnshipBlock = generateCodexConfigBlock(agents);
801
+ mergeCodexConfig(path.join(targetDir, 'config.toml'), learnshipBlock);
802
+ return agents.length;
803
+ }
804
+
805
+ /** Install agent .md files for a platform (not Codex — handled by installCodexAgents) */
806
+ function installAgents(agentsSrcDir, targetDir, pathPrefix, platform) {
807
+ const destDir = path.join(targetDir, 'agents');
808
+ fs.mkdirSync(destDir, { recursive: true });
809
+ // Remove stale learnship agent .md files before re-installing
810
+ for (const f of fs.readdirSync(destDir)) {
811
+ if (f.startsWith('learnship-') && f.endsWith('.md')) fs.unlinkSync(path.join(destDir, f));
812
+ }
813
+ let count = 0;
814
+ for (const f of fs.readdirSync(agentsSrcDir)) {
815
+ if (!f.startsWith('learnship-') || !f.endsWith('.md')) continue;
816
+ let c = fs.readFileSync(path.join(agentsSrcDir, f), 'utf8');
817
+ c = replacePaths(c, pathPrefix, platform);
818
+ if (platform === 'gemini') c = convertAgentForGemini(c);
819
+ else if (platform === 'opencode') c = convertToOpencode(c);
820
+ fs.writeFileSync(path.join(destDir, f), c);
821
+ count++;
822
+ }
823
+ return count;
824
+ }
825
+
826
+ /**
827
+ * Parse JSONC (JSON with Comments) by stripping comments and trailing commas.
828
+ * OpenCode supports JSONC so users may have // comments in opencode.json.
829
+ */
830
+ function parseJsonc(content) {
831
+ if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1); // strip BOM
832
+ let result = '';
833
+ let inString = false;
834
+ let i = 0;
835
+ while (i < content.length) {
836
+ const char = content[i];
837
+ const next = content[i + 1];
838
+ if (inString) {
839
+ result += char;
840
+ if (char === '\\' && i + 1 < content.length) { result += next; i += 2; continue; }
841
+ if (char === '"') inString = false;
842
+ i++;
843
+ } else {
844
+ if (char === '"') { inString = true; result += char; i++; }
845
+ else if (char === '/' && next === '/') { while (i < content.length && content[i] !== '\n') i++; }
846
+ else if (char === '/' && next === '*') {
847
+ i += 2;
848
+ while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) i++;
849
+ i += 2;
850
+ } else { result += char; i++; }
851
+ }
852
+ }
853
+ result = result.replace(/,(\s*[}\]])/g, '$1'); // remove trailing commas
854
+ return JSON.parse(result);
855
+ }
856
+
857
+ /** Configure OpenCode permissions to allow reading learnship reference docs */
858
+ function configureOpencodePermissions(targetDir, learnshipDir) {
859
+ const configPath = path.join(targetDir, 'opencode.json');
860
+ let config = {};
861
+ if (fs.existsSync(configPath)) {
862
+ try { config = parseJsonc(fs.readFileSync(configPath, 'utf8')); }
863
+ catch (e) {
864
+ console.log(` ${yellow}⚠${reset} Could not parse opencode.json — skipping permission config`);
865
+ console.log(` ${dim}Reason: ${e.message}${reset}`);
866
+ console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
867
+ return;
868
+ }
869
+ }
870
+ const defaultDir = path.join(os.homedir(), '.config', 'opencode');
871
+ const learnshipPath = targetDir === defaultDir
872
+ ? '~/.config/opencode/learnship/*'
873
+ : `${learnshipDir.replace(/\\/g, '/')}/*`;
874
+ if (!config.permission) config.permission = {};
875
+ if (!config.permission.read) config.permission.read = {};
876
+ if (!config.permission.external_directory) config.permission.external_directory = {};
877
+ if (config.permission.read[learnshipPath] === 'allow' &&
878
+ config.permission.external_directory[learnshipPath] === 'allow') return; // already set
879
+ config.permission.read[learnshipPath] = 'allow';
880
+ config.permission.external_directory[learnshipPath] = 'allow';
881
+ writeSettings(configPath, config);
882
+ console.log(` ${green}✓${reset} Configured read permissions in opencode.json`);
883
+ }
884
+
885
+ /**
886
+ * Scan installed files for leaked ~/.claude paths in non-Claude platforms.
887
+ * GSD pattern: warn if any .md/.toml file still contains the source platform path.
888
+ */
889
+ function scanForLeakedPaths(targetDir, platform) {
890
+ if (platform === 'claude' || platform === 'windsurf') return;
891
+ const leaks = [];
892
+ function scan(dir) {
893
+ if (!fs.existsSync(dir)) return;
894
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
895
+ const full = path.join(dir, entry.name);
896
+ if (entry.isDirectory()) { scan(full); continue; }
897
+ if (!entry.name.endsWith('.md') && !entry.name.endsWith('.toml')) continue;
898
+ if (entry.name === 'CHANGELOG.md') continue;
899
+ const content = fs.readFileSync(full, 'utf8');
900
+ const matches = content.match(/(?:~|\$HOME)\/\.claude\b/g);
901
+ if (matches) leaks.push({ file: full.replace(targetDir + '/', ''), count: matches.length });
902
+ }
903
+ }
904
+ scan(targetDir);
905
+ if (leaks.length > 0) {
906
+ const total = leaks.reduce((s, l) => s + l.count, 0);
907
+ console.warn(`\n ${yellow}⚠${reset} Found ${total} unreplaced .claude path(s) in ${leaks.length} file(s):`);
908
+ for (const leak of leaks.slice(0, 5)) console.warn(` ${dim}${leak.file}${reset} (${leak.count})`);
909
+ if (leaks.length > 5) console.warn(` ${dim}... and ${leaks.length - 5} more${reset}`);
910
+ console.warn(` ${dim}These paths may not resolve correctly for ${getPlatformLabel(platform)}.${reset}`);
911
+ }
912
+ }
913
+
914
+ // ─── Main install function ─────────────────────────────────────────────────
915
+ function install(platform, isGlobal) {
916
+ const src = path.join(__dirname, '..');
917
+ const targetDir = isGlobal ? getGlobalDir(platform) : path.join(process.cwd(), getDirName(platform));
918
+ const pathPrefix = `${targetDir.replace(/\\/g, '/')}/learnship/`;
919
+ const label = getPlatformLabel(platform);
920
+ const locationLabel = targetDir.replace(os.homedir(), '~');
921
+
922
+ console.log(`\n Installing for ${cyan}${label}${reset} → ${cyan}${locationLabel}${reset}\n`);
923
+
924
+ fs.mkdirSync(targetDir, { recursive: true });
925
+
926
+ const learnshipSrc = path.join(src, 'learnship');
927
+ const commandsSrc = path.join(src, 'commands', 'learnship');
928
+ const agentsSrc = path.join(src, 'agents');
929
+ const skillsSrc = path.join(src, '.windsurf', 'skills');
930
+ const failures = [];
931
+
932
+ // 1. Install learnship/ payload (workflows, references, templates)
933
+ const learnshipDest = path.join(targetDir, 'learnship');
934
+ copyDir(learnshipSrc, learnshipDest, pathPrefix, platform);
935
+ if (verifyInstalled(learnshipDest, 'learnship/')) {
936
+ console.log(` ${green}✓${reset} Installed learnship/ (workflows, references, templates)`);
937
+ } else { failures.push('learnship/'); }
938
+
939
+ // 2. Write VERSION file into learnship/ dir
940
+ fs.writeFileSync(path.join(learnshipDest, 'VERSION'), pkg.version);
941
+ console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`);
942
+
943
+ // 2b. Install skills
944
+ // Windsurf: native skill support — copy to targetDir/skills/ (i.e. .windsurf/skills/)
945
+ // Others: copy to learnship/skills/ so the AI loads them as context files
946
+ if (fs.existsSync(skillsSrc)) {
947
+ const skillsDest = platform === 'windsurf'
948
+ ? path.join(targetDir, 'skills')
949
+ : path.join(learnshipDest, 'skills');
950
+ fs.mkdirSync(skillsDest, { recursive: true });
951
+ let skillCount = 0;
952
+ for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
953
+ if (!entry.isDirectory()) continue;
954
+ copyDir(path.join(skillsSrc, entry.name), path.join(skillsDest, entry.name), pathPrefix, platform);
955
+ skillCount++;
956
+ }
957
+ if (skillCount > 0) {
958
+ const loc = platform === 'windsurf' ? 'skills/' : 'learnship/skills/';
959
+ console.log(` ${green}✓${reset} Installed ${skillCount} skills to ${loc}`);
960
+ }
961
+ }
962
+
963
+ // 3. Install commands (platform-specific format)
964
+ if (platform === 'windsurf') {
965
+ const wfDest = path.join(targetDir, 'workflows');
966
+ fs.mkdirSync(wfDest, { recursive: true });
967
+ let count = 0;
968
+ for (const f of fs.readdirSync(path.join(learnshipSrc, 'workflows'))) {
969
+ if (!f.endsWith('.md')) continue;
970
+ fs.copyFileSync(path.join(learnshipSrc, 'workflows', f), path.join(wfDest, f));
971
+ count++;
972
+ }
973
+ // Copy templates/ and references/ so @./templates/ and @./references/ resolve in workflows
974
+ for (const subdir of ['templates', 'references']) {
975
+ const srcSub = path.join(learnshipSrc, subdir);
976
+ const destSub = path.join(wfDest, subdir);
977
+ if (fs.existsSync(srcSub)) {
978
+ copyDir(srcSub, destSub, pathPrefix, platform);
979
+ }
980
+ }
981
+ console.log(` ${green}✓${reset} Installed ${count} workflows to workflows/`);
982
+ } else if (platform === 'claude') {
983
+ const count = installClaudeCommands(commandsSrc, targetDir, pathPrefix);
984
+ if (verifyInstalled(path.join(targetDir, 'commands', 'learnship'), 'commands/learnship/')) {
985
+ console.log(` ${green}✓${reset} Installed ${count} commands to commands/learnship/`);
986
+ } else { failures.push('commands/learnship/'); }
987
+ const aCount = installAgents(agentsSrc, targetDir, pathPrefix, 'claude');
988
+ if (aCount > 0) console.log(` ${green}✓${reset} Installed ${aCount} agents to agents/`);
989
+ else failures.push('agents/');
990
+ const pCount = installClaudePlugins(skillsSrc, targetDir);
991
+ if (pCount > 0) {
992
+ console.log(` ${green}✓${reset} Installed ${pCount} skills to plugins/learnship/skills/`);
993
+ } else {
994
+ failures.push('plugins/learnship/skills/');
995
+ }
996
+ } else if (platform === 'opencode') {
997
+ const count = installOpencodeCommands(commandsSrc, targetDir, pathPrefix);
998
+ console.log(` ${green}✓${reset} Installed ${count} commands to command/ (flat)`);
999
+ const aCount = installAgents(agentsSrc, targetDir, pathPrefix, 'opencode');
1000
+ if (aCount > 0) console.log(` ${green}✓${reset} Installed ${aCount} agents to agents/`);
1001
+ configureOpencodePermissions(targetDir, learnshipDest);
1002
+ } else if (platform === 'gemini') {
1003
+ const count = installGeminiCommands(commandsSrc, targetDir, pathPrefix);
1004
+ if (verifyInstalled(path.join(targetDir, 'commands', 'learnship'), 'commands/learnship/')) {
1005
+ console.log(` ${green}✓${reset} Installed ${count} commands to commands/learnship/ (TOML)`);
1006
+ } else { failures.push('commands/learnship/'); }
1007
+ const aCount = installAgents(agentsSrc, targetDir, pathPrefix, 'gemini');
1008
+ if (aCount > 0) console.log(` ${green}✓${reset} Installed ${aCount} agents to agents/`);
1009
+ // Gemini requires experimental agents enabled
1010
+ const settingsPath = path.join(targetDir, 'settings.json');
1011
+ const settings = readSettings(settingsPath);
1012
+ if (!settings.experimental) settings.experimental = {};
1013
+ if (!settings.experimental.enableAgents) {
1014
+ settings.experimental.enableAgents = true;
1015
+ writeSettings(settingsPath, settings);
1016
+ console.log(` ${green}✓${reset} Enabled experimental.enableAgents in settings.json`);
1017
+ }
1018
+ } else if (platform === 'codex') {
1019
+ const count = installCodexSkills(commandsSrc, targetDir, pathPrefix);
1020
+ console.log(` ${green}✓${reset} Installed ${count} skills to skills/`);
1021
+ const aCount = installCodexAgents(agentsSrc, targetDir, pathPrefix);
1022
+ console.log(` ${green}✓${reset} Installed ${aCount} agents + config.toml (sandbox modes: read-only for checkers)`);
1023
+ }
1024
+
1025
+ if (failures.length > 0) {
1026
+ console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
1027
+ process.exit(1);
1028
+ }
1029
+
1030
+ // 4. Scan for leaked .claude paths
1031
+ scanForLeakedPaths(targetDir, platform);
1032
+
1033
+ // 5. Post-install tips
1034
+ const firstCmd = platform === 'windsurf' ? '/ls' :
1035
+ platform === 'claude' ? '/learnship:ls' :
1036
+ platform === 'opencode' ? '/learnship-ls' :
1037
+ platform === 'gemini' ? '/learnship:ls' : '$learnship-ls';
1038
+ console.log(`\n ${green}Done!${reset} Open a project in ${label} and run ${cyan}${firstCmd}${reset}.`);
1039
+ console.log(` ${dim}First time? Run ${cyan}${platform === 'windsurf' ? '/new-project' : platform === 'claude' ? '/learnship:new-project' : platform === 'opencode' ? '/learnship-new-project' : platform === 'gemini' ? '/learnship:new-project' : '$learnship-new-project'}${reset}${dim} to initialize your project and create AGENTS.md.${reset}`);
1040
+ if (platform !== 'windsurf') {
1041
+ console.log(` ${dim}Enable parallel subagents: add ${cyan}"parallelization": true${reset}${dim} to .planning/config.json${reset}`);
1042
+ }
1043
+ }
1044
+
1045
+ // ─── Uninstall function ────────────────────────────────────────────────────
1046
+ function uninstall(platform, isGlobal) {
1047
+ const targetDir = isGlobal ? getGlobalDir(platform) : path.join(process.cwd(), getDirName(platform));
1048
+ const label = getPlatformLabel(platform);
1049
+ const locationLabel = targetDir.replace(os.homedir(), '~');
1050
+ console.log(`\n Uninstalling learnship from ${cyan}${label}${reset} at ${cyan}${locationLabel}${reset}\n`);
1051
+
1052
+ if (!fs.existsSync(targetDir)) {
1053
+ console.log(` ${yellow}⚠${reset} Directory not found — nothing to uninstall.`);
1054
+ return;
1055
+ }
1056
+
1057
+ let removed = 0;
1058
+
1059
+ // 1. Remove learnship/ payload
1060
+ const learnshipDir = path.join(targetDir, 'learnship');
1061
+ if (fs.existsSync(learnshipDir)) {
1062
+ fs.rmSync(learnshipDir, { recursive: true });
1063
+ console.log(` ${green}✓${reset} Removed learnship/`);
1064
+ removed++;
1065
+ }
1066
+
1067
+ // 2. Remove platform-specific command files
1068
+ if (platform === 'claude' || platform === 'windsurf') {
1069
+ const commandsDir = path.join(targetDir, 'commands', 'learnship');
1070
+ if (fs.existsSync(commandsDir)) { fs.rmSync(commandsDir, { recursive: true }); removed++; console.log(` ${green}✓${reset} Removed commands/learnship/`); }
1071
+ }
1072
+ if (platform === 'claude') {
1073
+ const pluginDir = path.join(targetDir, 'plugins', 'learnship');
1074
+ if (fs.existsSync(pluginDir)) {
1075
+ fs.rmSync(pluginDir, { recursive: true });
1076
+ removed++;
1077
+ console.log(` ${green}✓${reset} Removed plugins/learnship/`);
1078
+ }
1079
+ }
1080
+ if (platform === 'opencode') {
1081
+ const commandDir = path.join(targetDir, 'command');
1082
+ if (fs.existsSync(commandDir)) {
1083
+ let n = 0;
1084
+ for (const f of fs.readdirSync(commandDir)) {
1085
+ if (f.startsWith('learnship-') && f.endsWith('.md')) { fs.unlinkSync(path.join(commandDir, f)); n++; }
1086
+ }
1087
+ if (n > 0) { removed++; console.log(` ${green}✓${reset} Removed ${n} learnship-*.md from command/`); }
1088
+ }
1089
+ // Clean opencode.json permissions
1090
+ const ocConfig = path.join(targetDir, 'opencode.json');
1091
+ if (fs.existsSync(ocConfig)) {
1092
+ try {
1093
+ const cfg = JSON.parse(fs.readFileSync(ocConfig, 'utf8'));
1094
+ let modified = false;
1095
+ if (cfg.permission) {
1096
+ for (const permType of ['read', 'external_directory']) {
1097
+ if (cfg.permission[permType]) {
1098
+ for (const key of Object.keys(cfg.permission[permType])) {
1099
+ if (key.includes('learnship')) { delete cfg.permission[permType][key]; modified = true; }
1100
+ }
1101
+ if (Object.keys(cfg.permission[permType]).length === 0) delete cfg.permission[permType];
1102
+ }
1103
+ }
1104
+ if (Object.keys(cfg.permission).length === 0) delete cfg.permission;
1105
+ }
1106
+ if (modified) { fs.writeFileSync(ocConfig, JSON.stringify(cfg, null, 2) + '\n'); removed++; console.log(` ${green}✓${reset} Removed learnship permissions from opencode.json`); }
1107
+ } catch { /* ignore */ }
1108
+ }
1109
+ }
1110
+ if (platform === 'gemini') {
1111
+ const commandsDir = path.join(targetDir, 'commands', 'learnship');
1112
+ if (fs.existsSync(commandsDir)) { fs.rmSync(commandsDir, { recursive: true }); removed++; console.log(` ${green}✓${reset} Removed commands/learnship/`); }
1113
+ }
1114
+ if (platform === 'codex') {
1115
+ // Remove skill directories
1116
+ const skillsDir = path.join(targetDir, 'skills');
1117
+ if (fs.existsSync(skillsDir)) {
1118
+ let n = 0;
1119
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
1120
+ if (entry.isDirectory() && entry.name.startsWith('learnship-')) {
1121
+ fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); n++;
1122
+ }
1123
+ }
1124
+ if (n > 0) { removed++; console.log(` ${green}✓${reset} Removed ${n} learnship skill directories`); }
1125
+ }
1126
+ // Remove agent .toml files
1127
+ const agentsDir2 = path.join(targetDir, 'agents');
1128
+ if (fs.existsSync(agentsDir2)) {
1129
+ let n = 0;
1130
+ for (const f of fs.readdirSync(agentsDir2)) {
1131
+ if (f.startsWith('learnship-') && f.endsWith('.toml')) { fs.unlinkSync(path.join(agentsDir2, f)); n++; }
1132
+ }
1133
+ if (n > 0) { removed++; console.log(` ${green}✓${reset} Removed ${n} agent .toml configs`); }
1134
+ }
1135
+ // Clean config.toml
1136
+ const configPath = path.join(targetDir, 'config.toml');
1137
+ if (fs.existsSync(configPath)) {
1138
+ const content = fs.readFileSync(configPath, 'utf8');
1139
+ const cleaned = stripLearnshipFromCodexConfig(content);
1140
+ if (cleaned === null) {
1141
+ fs.unlinkSync(configPath); removed++;
1142
+ console.log(` ${green}✓${reset} Removed config.toml (was learnship-only)`);
1143
+ } else if (cleaned !== content) {
1144
+ fs.writeFileSync(configPath, cleaned); removed++;
1145
+ console.log(` ${green}✓${reset} Cleaned learnship sections from config.toml`);
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ // 3. Remove learnship agent .md files
1151
+ const agentsDir = path.join(targetDir, 'agents');
1152
+ if (fs.existsSync(agentsDir)) {
1153
+ let n = 0;
1154
+ for (const f of fs.readdirSync(agentsDir)) {
1155
+ if (f.startsWith('learnship-') && f.endsWith('.md')) { fs.unlinkSync(path.join(agentsDir, f)); n++; }
1156
+ }
1157
+ if (n > 0) { removed++; console.log(` ${green}✓${reset} Removed ${n} learnship agent files`); }
1158
+ }
1159
+
1160
+ if (removed === 0) console.log(` ${yellow}⚠${reset} No learnship files found.`);
1161
+ else console.log(`\n ${green}Done!${reset} learnship uninstalled from ${label}. Your other files and settings were preserved.`);
1162
+ }
1163
+
1164
+ // ─── Interactive prompt ────────────────────────────────────────────────────
1165
+ async function promptUser() {
1166
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1167
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
1168
+
1169
+ console.log(` ${yellow}Select platform:${reset}`);
1170
+ console.log(` 1) Claude Code (/learnship:ls)`);
1171
+ console.log(` 2) OpenCode (/learnship-ls)`);
1172
+ console.log(` 3) Gemini CLI (/learnship:ls)`);
1173
+ console.log(` 4) Codex CLI ($learnship-ls)`);
1174
+ console.log(` 5) Windsurf (/ls — same as install.sh)`);
1175
+ console.log(` 6) All platforms`);
1176
+
1177
+ const platformChoice = await ask('\n Platform [1-6]: ');
1178
+ const platformMap = { '1': ['claude'], '2': ['opencode'], '3': ['gemini'], '4': ['codex'], '5': ['windsurf'], '6': ['claude','opencode','gemini','codex','windsurf'] };
1179
+ const platforms = platformMap[platformChoice.trim()] || ['claude'];
1180
+
1181
+ console.log(`\n ${yellow}Install scope:${reset}`);
1182
+ console.log(` 1) Global (recommended) — available in all projects`);
1183
+ console.log(` 2) Local — current project only`);
1184
+ const scopeChoice = await ask('\n Scope [1-2]: ');
1185
+ const isGlobal = scopeChoice.trim() !== '2';
1186
+
1187
+ rl.close();
1188
+ return { platforms, isGlobal };
1189
+ }
1190
+
1191
+ // ─── Entry point ──────────────────────────────────────────────────────────
1192
+ async function main() {
1193
+ console.log(banner);
1194
+
1195
+ if (hasHelp) { console.log(helpText); process.exit(0); }
1196
+
1197
+ let platforms = selectedPlatforms;
1198
+ let isGlobal = hasGlobal || !hasLocal;
1199
+
1200
+ if (platforms.length === 0 && !hasUninstall) {
1201
+ // Interactive
1202
+ const result = await promptUser();
1203
+ platforms = result.platforms;
1204
+ isGlobal = result.isGlobal;
1205
+ } else if (platforms.length === 0 && hasUninstall) {
1206
+ console.error(` ${yellow}Error:${reset} Specify a platform to uninstall from. Example: npx learnship --claude --global --uninstall`);
1207
+ process.exit(1);
1208
+ }
1209
+
1210
+ console.log('');
1211
+ for (const platform of platforms) {
1212
+ if (hasUninstall) uninstall(platform, isGlobal);
1213
+ else install(platform, isGlobal);
1214
+ }
1215
+ }
1216
+
1217
+ if (!process.env.LEARNSHIP_TEST_MODE) {
1218
+ main().catch(err => {
1219
+ console.error(` Error: ${err.message}`);
1220
+ process.exit(1);
1221
+ });
1222
+ }
1223
+
1224
+ // Test-only exports — allow unit testing without running main install logic
1225
+ if (process.env.LEARNSHIP_TEST_MODE) {
1226
+ module.exports = {
1227
+ convertToOpencode,
1228
+ convertToGeminiToml,
1229
+ convertToCodexSkill,
1230
+ convertClaudeAgentToCodexAgent,
1231
+ convertAgentForGemini,
1232
+ generateCodexConfigBlock,
1233
+ stripLearnshipFromCodexConfig,
1234
+ mergeCodexConfig,
1235
+ installCodexAgents,
1236
+ parseJsonc,
1237
+ replacePaths,
1238
+ toHomePrefix,
1239
+ LEARNSHIP_CODEX_MARKER,
1240
+ CODEX_AGENT_SANDBOX,
1241
+ };
1242
+ }