pantheon-opencode 1.4.2 → 1.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.pantheon/code-mode/.gitkeep +0 -0
  2. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +8 -0
  3. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +8 -0
  4. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +8 -0
  5. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +8 -0
  6. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +8 -0
  7. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +8 -0
  8. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +8 -0
  9. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +8 -0
  10. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +8 -0
  11. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +8 -0
  12. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +8 -0
  13. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +8 -0
  14. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +11 -0
  15. package/.pantheon/code-mode/README.md +93 -0
  16. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  17. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  18. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  19. package/.pantheon/code-mode/compress-inline.py +450 -0
  20. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  21. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  22. package/.pantheon/code-mode/eval-run.py +197 -0
  23. package/.pantheon/code-mode/eval-static.py +321 -0
  24. package/.pantheon/code-mode/example-sync.sh +5 -0
  25. package/.pantheon/code-mode/session-end-save.py +186 -0
  26. package/.pantheon/code-mode/session-end-save.sh +18 -0
  27. package/.pantheon/code-mode/test-allowed.sh +5 -0
  28. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  29. package/.pantheon/code-mode/test-env.sh +7 -0
  30. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  31. package/.pantheon/code-mode/test-timeout.sh +6 -0
  32. package/README.md +158 -1189
  33. package/README.pt-BR.md +222 -0
  34. package/bin/pantheon-init.mjs +30 -11
  35. package/opencode.json +1 -0
  36. package/package.json +14 -3
  37. package/plugin.json +15 -0
  38. package/scripts/_pantheon_paths.py +48 -7
  39. package/scripts/ci-validate-yaml.py +7 -2
  40. package/scripts/code_mode_server.py +33 -2
  41. package/scripts/doctor.mjs +135 -127
  42. package/scripts/generate-preset-docs.mjs +127 -66
  43. package/scripts/generate-prompts.sh +2 -2
  44. package/scripts/install/cli-ui.mjs +8 -1
  45. package/scripts/install/config-migration.mjs +401 -0
  46. package/scripts/install/health-check.mjs +3 -3
  47. package/scripts/install/model-picker.mjs +72 -23
  48. package/scripts/install/opencode-version.mjs +47 -0
  49. package/scripts/install/opencode.mjs +261 -88
  50. package/scripts/install/plugin.mjs +112 -42
  51. package/scripts/install/shared.mjs +13 -158
  52. package/scripts/install-mcp.mjs +44 -232
  53. package/scripts/mcp_persistence_server.py +344 -15
  54. package/scripts/mcp_resources_server.py +144 -40
  55. package/scripts/memory_mcp_server.py +2 -2
  56. package/scripts/recover-zenodo-deposition.mjs +146 -0
  57. package/scripts/sync-tui.mjs +32 -7
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +591 -0
  59. package/scripts/uninstall.mjs +298 -954
  60. package/scripts/validate-zenodo-release.mjs +154 -0
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/mcp/_pantheon_paths.py +48 -7
  64. package/src/mcp/code_mode_server.py +33 -2
  65. package/src/mcp/eval_store.py +1 -1
  66. package/src/mcp/mcp_persistence_server.py +344 -15
  67. package/src/mcp/mcp_resources_server.py +144 -40
  68. package/src/mcp/pantheon_vision_server.py +1 -1
  69. package/src/mcp/toon_codec.py +491 -0
  70. package/src/pantheon/background-job-board.ts +67 -1
  71. package/src/pantheon/cost-command.ts +6 -2
  72. package/src/pantheon/delegate-manager.ts +384 -0
  73. package/src/pantheon/delegate-task-adapter.ts +444 -0
  74. package/src/pantheon/delegation-notify.ts +35 -20
  75. package/src/pantheon/model-command.ts +86 -32
  76. package/src/pantheon/token-opt.ts +555 -0
  77. package/src/pantheon/toon-codec.ts +481 -0
  78. package/src/pantheon/tui-version-check.ts +1 -3
  79. package/src/pantheon/v2-bridge.ts +129 -0
  80. package/src/pantheon/v2-events.ts +131 -0
  81. package/src/pantheon/v2-hooks.ts +287 -0
  82. package/src/pantheon/v2-tool-definitions.ts +331 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +836 -0
  85. package/src/plugin.ts +57 -10
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +22 -16
  98. package/src/skills/README.md +0 -2
@@ -1,32 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- /**
4
- * uninstall.mjs — Multi-platform Pantheon agent uninstaller
5
- *
6
- * Removes Pantheon agent files and config from a project directory.
7
- * The reverse of the installer.
8
- *
9
- * Usage:
10
- * node scripts/uninstall.mjs auto-detect, cwd
11
- * node scripts/uninstall.mjs --target /path/to/project auto-detect, target
12
- * node scripts/uninstall.mjs --platforms opencode,claude specific platforms
13
- * node scripts/uninstall.mjs --target /path --platforms all all platforms
14
- * node scripts/uninstall.mjs --dry-run preview without deleting
15
- * node scripts/uninstall.mjs --force skip confirmation
16
- * node scripts/uninstall.mjs --help show this help
17
- */
18
-
19
- import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
20
- import { join } from 'node:path'
3
+ /** Remove only files and registrations owned by Pantheon for OpenCode. */
4
+ import { lstatSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { basename, join, relative, resolve } from 'node:path'
21
7
  import { createInterface } from 'node:readline'
22
8
  import { isatty } from 'node:tty'
23
- import { detectPlatforms, resolveTarget } from './install/shared.mjs'
24
9
 
25
- // ---------------------------------------------------------------------------
26
- // Constants
27
- // ---------------------------------------------------------------------------
28
-
29
- const PANTHEON_AGENT_NAMES = [
10
+ const AGENT_NAMES = new Set([
30
11
  'zeus',
31
12
  'athena',
32
13
  'apollo',
@@ -36,1020 +17,383 @@ const PANTHEON_AGENT_NAMES = [
36
17
  'themis',
37
18
  'prometheus',
38
19
  'hephaestus',
39
-
40
20
  'nyx',
41
21
  'gaia',
42
22
  'iris',
43
23
  'mnemosyne',
44
24
  'talos',
45
- ]
46
-
47
- const PLATFORM_LABELS = {
48
- opencode: 'OpenCode',
49
- claude: 'Claude Code',
50
- cursor: 'Cursor',
51
- windsurf: 'Windsurf',
52
- copilot: 'VS Code / Copilot',
53
- continue: 'Continue.dev',
54
- cline: 'Cline',
55
- }
56
-
57
- const ALL_PLATFORMS = Object.keys(PLATFORM_LABELS)
58
-
59
- // ---------------------------------------------------------------------------
60
- // Summary tracker
61
- // ---------------------------------------------------------------------------
62
-
63
- const stats = {
64
- opencode: { removed: 0, skipped: 0, errors: 0 },
65
- claude: { removed: 0, skipped: 0, errors: 0 },
66
- cursor: { removed: 0, skipped: 0, errors: 0 },
67
- windsurf: { removed: 0, skipped: 0, errors: 0 },
68
- copilot: { removed: 0, skipped: 0, errors: 0 },
69
- continue: { removed: 0, skipped: 0, errors: 0 },
70
- cline: { removed: 0, skipped: 0, errors: 0 },
71
- }
72
-
73
- // ---------------------------------------------------------------------------
74
- // Help
75
- // ---------------------------------------------------------------------------
25
+ ])
26
+ const OWNERSHIP_MARKER =
27
+ /(?:pantheon-opencode|pantheon[- ]agent system|managed[- ]by[: ]+pantheon|pantheon-(?:resources|memory|persistence|code-mode|tui|hooks))/i
28
+ const stats = { removed: 0, skipped: 0, errors: 0 }
76
29
 
77
30
  function showHelp() {
78
31
  console.log(`
79
- uninstall.mjs — Multi-platform Pantheon agent uninstaller
80
32
 
81
33
  Usage:
82
- node scripts/uninstall.mjs auto-detect, cwd
83
- node scripts/uninstall.mjs --target /path/to/project auto-detect, target
84
- node scripts/uninstall.mjs --platforms opencode,claude specific platforms, cwd
85
- node scripts/uninstall.mjs --target /path --platforms all all platforms
86
- node scripts/uninstall.mjs --dry-run preview without deleting
87
- node scripts/uninstall.mjs --force skip confirmation prompt
88
- node scripts/uninstall.mjs --help show this help
89
-
90
- Flags:
91
- --target /path Project to uninstall from (default: cwd)
92
- --platforms list Comma-separated platforms (default: auto-detect or all)
93
- --dry-run Preview what would be removed without deleting
94
- --force Skip confirmation prompt
95
- --help Show this help
34
+ node scripts/uninstall.mjs [--project|--global] [--target /path] [--dry-run] [--force]
96
35
 
97
- Platforms:
98
- opencode → .opencode/agents/ + opencode.json Pantheon entries
99
- claude → .claude/agents/ + CLAUDE.md (if Pantheon content)
100
- cursor → .cursor/rules/*.mdc (only Pantheon rules)
101
- windsurf → .windsurf/rules/ + .windsurf/workflows/ (if empty)
102
- copilot → .github/agents/ + .vscode/settings.json Pantheon keys
103
- continue → .continue/rules/ + check .continue/config.yaml
104
- cline → .clinerules/ (only agent rules, not commands/skills)
105
- all → uninstall every platform
106
-
107
- When --platforms is omitted, auto-detects which platforms are configured.
108
- If none detected, all platforms are uninstalled.
36
+ Target may be a project directory (containing .opencode), or a flat global
37
+ OpenCode directory (~/.config/opencode or ~/.opencode). Symlinks are rejected.
109
38
  `)
110
39
  }
111
40
 
112
- // ---------------------------------------------------------------------------
113
- // CLI args
114
- // ---------------------------------------------------------------------------
115
-
116
41
  function parseArgs(argv) {
117
- const args = {
118
- target: null,
119
- platforms: null,
120
- dryRun: false,
121
- force: false,
122
- help: false,
123
- }
124
-
125
- for (let i = 2; i < argv.length; i++) {
126
- switch (argv[i]) {
127
- case '--target':
128
- args.target = argv[++i]
129
- break
130
- case '--platforms':
131
- args.platforms = argv[++i].split(',').map((s) => s.trim().toLowerCase())
132
- break
133
- case '--dry-run':
134
- args.dryRun = true
135
- break
136
- case '--force':
137
- args.force = true
138
- break
139
- case '--help':
140
- args.help = true
141
- break
142
- default:
143
- console.warn(`⚠️ Unknown option: ${argv[i]}`)
144
- break
145
- }
146
- }
147
-
148
- if (!args.target) {
149
- args.target = process.cwd()
150
- }
151
- args.target = resolveTarget(args.target)
152
-
42
+ const args = { target: process.cwd(), dryRun: false, force: false, help: false, scope: 'project' }
43
+ let explicitTarget = false
44
+ for (let index = 2; index < argv.length; index += 1) {
45
+ const option = argv[index]
46
+ if (option === '--target') {
47
+ args.target = argv[++index]
48
+ explicitTarget = true
49
+ } else if (option === '--project') {
50
+ if (args.scope === 'global') throw new Error('--project and --global are mutually exclusive')
51
+ args.scope = 'project'
52
+ } else if (option === '--global') {
53
+ if (args.scope === 'project' && argv.slice(2, index).includes('--project'))
54
+ throw new Error('--project and --global are mutually exclusive')
55
+ args.scope = 'global'
56
+ } else if (option === '--dry-run') args.dryRun = true
57
+ else if (option === '--force') args.force = true
58
+ else if (option === '--help') args.help = true
59
+ else if (option === '--platforms')
60
+ throw new Error('--platforms was removed; this uninstaller supports OpenCode only')
61
+ else throw new Error(`Unknown option: ${option}`)
62
+ }
63
+ if (args.scope === 'global' && !explicitTarget) {
64
+ args.target = resolveGlobalTarget()
65
+ }
66
+ args.target = resolve(args.target)
153
67
  return args
154
68
  }
155
69
 
156
- // ---------------------------------------------------------------------------
157
- // Confirmation prompt
158
- // ---------------------------------------------------------------------------
70
+ /**
71
+ * Resolve the isolated global OpenCode directory without consulting cwd.
72
+ *
73
+ * PANTHEON_HOME is intentionally a directory override (not a parent), which
74
+ * makes sandboxed installs safe and keeps uninstall symmetric with the MCP
75
+ * path resolver. XDG remains the normal user-wide fallback.
76
+ *
77
+ * @param {NodeJS.ProcessEnv} [env] environment to inspect
78
+ * @param {string} [home] home directory fallback
79
+ * @returns {string} absolute global configuration directory
80
+ */
81
+ function resolveGlobalTarget(env = process.env, home = homedir()) {
82
+ const pantheonHome = env.PANTHEON_HOME?.trim()
83
+ if (pantheonHome) return resolve(pantheonHome)
84
+ const xdg = env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
85
+ return resolve(join(xdg, 'opencode'))
86
+ }
159
87
 
160
- async function confirmPrompt(message) {
161
- if (!isatty(process.stdin.fd)) {
162
- // Non-interactive — skip prompt (caller must use --force if desired)
163
- return true
88
+ function lstat(path) {
89
+ try {
90
+ return lstatSync(path)
91
+ } catch {
92
+ return null
164
93
  }
165
- const rl = createInterface({ input: process.stdin, output: process.stdout })
166
- return new Promise((resolve) => {
167
- rl.question(message, (answer) => {
168
- rl.close()
169
- resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes')
170
- })
171
- })
172
94
  }
173
95
 
174
- // ---------------------------------------------------------------------------
175
- // Safety checks
176
- // ---------------------------------------------------------------------------
96
+ function isSymlink(path) {
97
+ return lstat(path)?.isSymbolicLink() === true
98
+ }
177
99
 
178
- /** Check if a filename matches a Pantheon agent file pattern. */
179
- function isPantheonAgentFile(filename) {
180
- const basename = filename.replace(/\.(md|mdc)$/, '')
181
- return PANTHEON_AGENT_NAMES.includes(basename)
100
+ function isInside(path, root) {
101
+ const child = relative(resolve(root), resolve(path))
102
+ return child === '' || (!child.startsWith('..') && !child.includes(`${pathSeparator()}..`))
182
103
  }
183
104
 
184
- /** Check if a string is a Pantheon skill directory name. */
185
- function _isPantheonSkillEntry(dirname) {
186
- // Skill directories are dynamically fetched from ROOT/skills/
187
- // We use a dynamic check via readdirSync when needed, but for safety
188
- // we check against a known set of patterns.
189
- return /^[a-z-]+$/.test(dirname) && dirname.length > 1
105
+ function pathSeparator() {
106
+ return '/'
190
107
  }
191
108
 
192
- /** Check if a file contains Pantheon content. */
193
- function fileContainsPantheonContent(filePath) {
109
+ function isPantheonContent(path) {
110
+ const info = lstat(path)
111
+ if (!info?.isFile() || info.isSymbolicLink()) return false
194
112
  try {
195
- if (!existsSync(filePath)) return false
196
- const content = readFileSync(filePath, 'utf8')
197
- return (
198
- content.includes('Pantheon Agent System') ||
199
- content.includes('# Pantheon Agent System') ||
200
- content.includes('Pantheon multi-agent framework')
201
- )
113
+ return OWNERSHIP_MARKER.test(readFileSync(path, 'utf8'))
202
114
  } catch {
203
115
  return false
204
116
  }
205
117
  }
206
118
 
207
- // ---------------------------------------------------------------------------
208
- // Platform uninstallers
209
- // ---------------------------------------------------------------------------
210
-
211
- /**
212
- * OpenCode — remove .opencode/agents/, restore opencode.json
213
- */
214
- function uninstallOpenCode(target, dryRun) {
215
- const s = stats.opencode
216
-
217
- // 1. Remove .opencode/agents/ (entire directory — Pantheon-created)
218
- const agentsDir = join(target, '.opencode', 'agents')
219
- if (existsSync(agentsDir)) {
220
- if (!dryRun) {
221
- try {
222
- rmSync(agentsDir, { recursive: true, force: true })
223
- s.removed++
224
- } catch (err) {
225
- console.error(` ⚠️ Failed to remove ${agentsDir}: ${err.message}`)
226
- s.errors++
227
- }
228
- } else {
229
- const count = readdirSync(agentsDir).length
230
- s.removed++
231
- console.log(` ~ Would remove .opencode/agents/ (${count} files)`)
232
- }
233
- } else {
234
- s.skipped++
235
- }
236
-
237
- // 2. Remove .opencode/skills/ (entire directory — Pantheon-created)
238
- const skillsDir = join(target, '.opencode', 'skills')
239
- if (existsSync(skillsDir)) {
240
- if (!dryRun) {
241
- try {
242
- rmSync(skillsDir, { recursive: true, force: true })
243
- s.removed++
244
- } catch (err) {
245
- console.error(` ⚠️ Failed to remove ${skillsDir}: ${err.message}`)
246
- s.errors++
247
- }
248
- } else {
249
- s.removed++
250
- console.log(' ~ Would remove .opencode/skills/')
251
- }
252
- } else {
253
- s.skipped++
254
- }
255
-
256
- // 3. Remove .opencode/commands/ (entire directory — Pantheon-created)
257
- const cmdsDir = join(target, '.opencode', 'commands')
258
- if (existsSync(cmdsDir)) {
259
- if (!dryRun) {
260
- try {
261
- rmSync(cmdsDir, { recursive: true, force: true })
262
- s.removed++
263
- } catch (err) {
264
- console.error(` ⚠️ Failed to remove ${cmdsDir}: ${err.message}`)
265
- s.errors++
266
- }
267
- } else {
268
- s.removed++
269
- console.log(' ~ Would remove .opencode/commands/')
270
- }
271
- } else {
272
- s.skipped++
273
- }
274
-
275
- // 4. Remove .opencode/plugins/ (Pantheon TUI plugin)
276
- const pluginsDir = join(target, '.opencode', 'plugins')
277
- if (existsSync(pluginsDir)) {
278
- let isPluginEmpty = true
279
- try {
280
- isPluginEmpty = readdirSync(pluginsDir).length === 0
281
- } catch {
282
- /* ignore */
283
- }
284
- if (!dryRun) {
285
- try {
286
- rmSync(pluginsDir, { recursive: true, force: true })
287
- s.removed++
288
- } catch (err) {
289
- console.error(` ⚠️ Failed to remove ${pluginsDir}: ${err.message}`)
290
- s.errors++
291
- }
292
- } else if (!isPluginEmpty) {
293
- s.removed++
294
- console.log(' ~ Would remove .opencode/plugins/ (Pantheon TUI plugin)')
295
- } else {
296
- s.skipped++
297
- }
298
- } else {
299
- s.skipped++
300
- }
301
-
302
- // 5. Remove .opencode/package.json and .opencode/tsconfig.json (Pantheon-created)
303
- for (const f of ['package.json', 'tsconfig.json']) {
304
- const fp = join(target, '.opencode', f)
305
- if (existsSync(fp)) {
306
- if (!dryRun) {
307
- try {
308
- rmSync(fp, { force: true })
309
- s.removed++
310
- } catch (err) {
311
- console.error(` ⚠️ Failed to remove ${fp}: ${err.message}`)
312
- s.errors++
313
- }
314
- } else {
315
- s.removed++
316
- console.log(` ~ Would remove .opencode/${f}`)
317
- }
318
- } else {
319
- s.skipped++
320
- }
119
+ function isPantheonRouting(path) {
120
+ const info = lstat(path)
121
+ if (!info?.isFile() || info.isSymbolicLink()) return false
122
+ try {
123
+ // routing.yml is also a legitimate user configuration. Unlike generated
124
+ // agents, its contents can mention Pantheon without being Pantheon-owned,
125
+ // so only an explicit ownership marker permits removal.
126
+ return /(?:^|\n)\s*(?:#|<!--)\s*(?:managed-by:\s*)?pantheon-opencode\b/i.test(
127
+ readFileSync(path, 'utf8'),
128
+ )
129
+ } catch {
130
+ return false
321
131
  }
132
+ }
322
133
 
323
- // 6. Clean up empty .opencode/ directory
324
- const opencodeDir = join(target, '.opencode')
325
- if (existsSync(opencodeDir)) {
326
- let isEmpty = true
327
- try {
328
- isEmpty = readdirSync(opencodeDir).length === 0
329
- } catch {
330
- /* ignore */
331
- }
332
- if (isEmpty) {
333
- if (!dryRun) {
334
- try {
335
- rmSync(opencodeDir, { recursive: true, force: true })
336
- s.removed++
337
- } catch (_err) {
338
- /* ignore */
339
- }
340
- } else {
341
- s.removed++
342
- console.log(' ~ Would remove empty .opencode/ directory')
343
- }
344
- }
134
+ function removePath(path, dryRun, root, label = path) {
135
+ if (!isInside(path, root) || isSymlink(path)) {
136
+ stats.skipped += 1
137
+ return false
345
138
  }
346
-
347
- // 7. Restore opencode.json — remove Pantheon agent config entries
348
- const configPath = join(target, 'opencode.json')
349
- if (existsSync(configPath)) {
350
- try {
351
- const raw = readFileSync(configPath, 'utf8')
352
- const config = JSON.parse(raw)
353
- let changed = false
354
-
355
- // Remove Pantheon-managed agent entries
356
- if (config.agent && typeof config.agent === 'object') {
357
- for (const [name, cfg] of Object.entries(config.agent)) {
358
- const source = cfg?.source || ''
359
- if (
360
- source.startsWith('.opencode/agents/') ||
361
- source.startsWith('agents/') ||
362
- (PANTHEON_AGENT_NAMES.includes(name) && source === '')
363
- ) {
364
- delete config.agent[name]
365
- changed = true
366
- }
367
- }
368
- if (Object.keys(config.agent).length === 0) {
369
- delete config.agent
370
- }
371
- }
372
-
373
- // Remove Pantheon-specific instructions
374
- if (Array.isArray(config.instructions)) {
375
- const filtered = config.instructions.filter(
376
- (i) => i !== 'AGENTS.md' && i !== 'instructions/*.instructions.md',
377
- )
378
- if (filtered.length !== config.instructions.length) {
379
- changed = true
380
- }
381
- if (filtered.length === 0) {
382
- delete config.instructions
383
- } else {
384
- config.instructions = filtered
385
- }
386
- }
387
-
388
- // Remove Pantheon plugin entry (stub — reserved for future plugin cleanup)
389
- if (Array.isArray(config.plugin) && config.plugin.length === 0) {
390
- delete config.plugin
391
- }
392
-
393
- // Remove default_agent if it points to zeus
394
- if (config.default_agent === 'zeus') {
395
- delete config.default_agent
396
- changed = true
397
- }
398
-
399
- if (changed) {
400
- if (!dryRun) {
401
- writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
402
- s.removed++
403
- } else {
404
- s.removed++
405
- console.log(' ~ Would restore opencode.json (remove Pantheon agent config entries)')
406
- }
407
- } else {
408
- s.skipped++
409
- }
410
- } catch (err) {
411
- console.error(` ⚠️ Failed to process opencode.json: ${err.message}`)
412
- s.errors++
413
- }
414
- } else {
415
- s.skipped++
139
+ const info = lstat(path)
140
+ if (!info) {
141
+ stats.skipped += 1
142
+ return false
416
143
  }
417
-
418
- // 8. Remove Pantheon-installed instructions/ directory
419
- const instrDir = join(target, 'instructions')
420
- if (existsSync(instrDir)) {
421
- // Only remove if it was Pantheon-installed (heuristic: contains .instructions.md files)
144
+ if (dryRun) console.log(` ~ Would remove ${label}`)
145
+ else {
422
146
  try {
423
- const entries = readdirSync(instrDir)
424
- const hasInstructions = entries.some((e) => e.endsWith('.instructions.md'))
425
- if (hasInstructions) {
426
- if (!dryRun) {
427
- rmSync(instrDir, { recursive: true, force: true })
428
- s.removed++
429
- } else {
430
- s.removed++
431
- console.log(' ~ Would remove instructions/ (Pantheon-installed)')
432
- }
433
- } else {
434
- s.skipped++
435
- }
436
- } catch {
437
- s.skipped++
147
+ rmSync(path, { recursive: info.isDirectory(), force: false })
148
+ } catch (error) {
149
+ console.error(` ⚠️ Failed to remove ${path}: ${error.message}`)
150
+ stats.errors += 1
151
+ return false
438
152
  }
439
- } else {
440
- s.skipped++
441
153
  }
154
+ stats.removed += 1
155
+ return true
156
+ }
442
157
 
443
- // 9. Remove Pantheon-installed prompts/ directory
444
- const promptsDir = join(target, 'prompts')
445
- if (existsSync(promptsDir)) {
446
- try {
447
- const entries = readdirSync(promptsDir)
448
- const hasPromptMds = entries.some((e) => e.endsWith('.prompt.md'))
449
- if (hasPromptMds) {
450
- if (!dryRun) {
451
- rmSync(promptsDir, { recursive: true, force: true })
452
- s.removed++
453
- } else {
454
- s.removed++
455
- console.log(' ~ Would remove prompts/ (Pantheon-installed)')
456
- }
457
- } else {
458
- s.skipped++
459
- }
460
- } catch {
461
- s.skipped++
462
- }
463
- } else {
464
- s.skipped++
158
+ function removeMatchingEntries(directory, predicate, dryRun, root) {
159
+ const info = lstat(directory)
160
+ if (!info || info.isSymbolicLink() || !info.isDirectory()) {
161
+ stats.skipped += 1
162
+ return
465
163
  }
466
-
467
- // 10. Remove AGENTS.md if it has Pantheon content
468
- const agentsMd = join(target, 'AGENTS.md')
469
- if (fileContainsPantheonContent(agentsMd)) {
470
- if (!dryRun) {
471
- try {
472
- rmSync(agentsMd, { force: true })
473
- s.removed++
474
- } catch (err) {
475
- console.error(` ⚠️ Failed to remove AGENTS.md: ${err.message}`)
476
- s.errors++
477
- }
478
- } else {
479
- s.removed++
480
- console.log(' ~ Would remove AGENTS.md (Pantheon content)')
164
+ for (const entry of readdirSync(directory)) {
165
+ const path = join(directory, entry)
166
+ const child = lstat(path)
167
+ if (!child || child.isSymbolicLink()) {
168
+ stats.skipped += 1
169
+ continue
481
170
  }
482
- } else {
483
- s.skipped++
171
+ if (predicate(path, entry)) removePath(path, dryRun, root, path)
172
+ else if (child.isDirectory()) removeMatchingEntries(path, predicate, dryRun, root)
484
173
  }
174
+ const remaining = lstat(directory)
175
+ if (remaining?.isDirectory() && readdirSync(directory).length === 0)
176
+ removePath(directory, dryRun, root, directory)
485
177
  }
486
178
 
487
- /**
488
- * Claude Code — remove .claude/agents/, remove CLAUDE.md if Pantheon content
489
- */
490
- function uninstallClaude(target, dryRun) {
491
- const s = stats.claude
492
-
493
- // 1. Remove .claude/agents/
494
- const agentsDir = join(target, '.claude', 'agents')
495
- if (existsSync(agentsDir)) {
496
- if (!dryRun) {
497
- try {
498
- rmSync(agentsDir, { recursive: true, force: true })
499
- s.removed++
500
- } catch (err) {
501
- console.error(` ⚠️ Failed to remove ${agentsDir}: ${err.message}`)
502
- s.errors++
503
- }
504
- } else {
505
- const count = existsSync(agentsDir) ? readdirSync(agentsDir).length : 0
506
- s.removed++
507
- console.log(` ~ Would remove .claude/agents/ (${count} files)`)
508
- }
509
- } else {
510
- s.skipped++
511
- }
512
-
513
- // 2. Remove .claude/settings.json Pantheon section (or entire file if Pantheon-only)
514
- const settingsPath = join(target, '.claude', 'settings.json')
515
- if (existsSync(settingsPath)) {
516
- try {
517
- const raw = readFileSync(settingsPath, 'utf8')
518
- const settings = JSON.parse(raw)
519
- // The installer creates settings with only permissions.allow
520
- // If the file only has permissions, it's Pantheon-created
521
- if (settings.permissions && Object.keys(settings).length === 1 && !dryRun) {
522
- rmSync(settingsPath, { force: true })
523
- s.removed++
524
- } else if (settings.permissions && !dryRun) {
525
- // User had other settings — just remove the permissions key
526
- delete settings.permissions
527
- writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
528
- s.removed++
529
- } else if (settings.permissions) {
530
- s.removed++
531
- console.log(' ~ Would remove Pantheon permissions from .claude/settings.json')
532
- } else {
533
- s.skipped++
534
- }
535
- } catch {
536
- s.skipped++
537
- }
538
- } else {
539
- s.skipped++
179
+ function readJson(path) {
180
+ const info = lstat(path)
181
+ if (!info || info.isSymbolicLink() || !info.isFile()) return null
182
+ try {
183
+ return JSON.parse(readFileSync(path, 'utf8'))
184
+ } catch {
185
+ return null
540
186
  }
187
+ }
541
188
 
542
- // 3. Remove CLAUDE.md if it has Pantheon content
543
- const claudeMdPath = join(target, 'CLAUDE.md')
544
- if (fileContainsPantheonContent(claudeMdPath)) {
545
- if (!dryRun) {
546
- try {
547
- rmSync(claudeMdPath, { force: true })
548
- s.removed++
549
- } catch (err) {
550
- console.error(` ⚠️ Failed to remove CLAUDE.md: ${err.message}`)
551
- s.errors++
552
- }
553
- } else {
554
- s.removed++
555
- console.log(' ~ Would remove CLAUDE.md (Pantheon content)')
556
- }
557
- } else {
558
- s.skipped++
559
- }
189
+ function ownedReference(value) {
190
+ return typeof value === 'string' && /pantheon(?:[-/]|$)/i.test(value)
191
+ }
560
192
 
561
- // 4. Remove .claude/ (if empty after cleanup)
562
- const claudeDir = join(target, '.claude')
563
- if (existsSync(claudeDir)) {
564
- try {
565
- const entries = readdirSync(claudeDir)
566
- if (entries.length === 0) {
567
- if (!dryRun) {
568
- rmSync(claudeDir, { recursive: true, force: true })
569
- }
570
- // No need to count this — it's a cleanup step
571
- }
572
- } catch {
573
- /* ignore */
574
- }
193
+ function ownedPluginEntry(value) {
194
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
195
+ return ['managed-by', 'managedBy', 'owner'].some(
196
+ (key) => typeof value[key] === 'string' && /^pantheon(?:-|$)/i.test(value[key]),
197
+ )
575
198
  }
576
- // Note: AGENTS.md is handled by opencode uninstaller
199
+ if (typeof value !== 'string') return false
200
+ const normalized = value.replaceAll('\\', '/')
201
+ return (
202
+ /(?:^|\/)src\/plugin\.ts$/i.test(normalized) ||
203
+ /(?:^|\/)src\/plugins\/pantheon-hooks\.ts$/i.test(normalized) ||
204
+ /^(?:plugins\/)?pantheon-(?:hooks|plugin|tui)$/i.test(normalized) ||
205
+ /^pantheon-opencode$/i.test(normalized)
206
+ )
577
207
  }
578
208
 
579
- /**
580
- * Cursor — remove .cursor/rules/*.mdc (only Pantheon rules)
581
- */
582
- function uninstallCursor(target, dryRun) {
583
- const s = stats.cursor
209
+ function ownedMcpEntry(name, value) {
210
+ if (typeof name === 'string' && /^pantheon(?:-|$)/i.test(name)) return true
211
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
212
+ return ['managed-by', 'managedBy', 'owner'].some(
213
+ (key) => typeof value[key] === 'string' && /^pantheon(?:-|$)/i.test(value[key]),
214
+ )
215
+ }
584
216
 
585
- const rulesDir = join(target, '.cursor', 'rules')
586
- if (!existsSync(rulesDir)) {
587
- s.skipped++
217
+ function cleanOpenCodeConfig(target, dryRun, ownedRoot = target) {
218
+ const configPath = join(target, 'opencode.json')
219
+ const config = readJson(configPath)
220
+ if (!config) {
221
+ stats.skipped += 1
588
222
  return
589
223
  }
590
-
591
- try {
592
- const entries = readdirSync(rulesDir)
593
- const pantheonFiles = entries.filter((f) => f.endsWith('.mdc') && isPantheonAgentFile(f))
594
-
595
- if (pantheonFiles.length === 0) {
596
- s.skipped++
597
- return
598
- }
599
-
600
- for (const f of pantheonFiles) {
601
- const fp = join(rulesDir, f)
602
- if (!dryRun) {
603
- try {
604
- rmSync(fp, { force: true })
605
- s.removed++
606
- } catch (err) {
607
- console.error(` ⚠️ Failed to remove ${fp}: ${err.message}`)
608
- s.errors++
609
- }
610
- } else {
611
- s.removed++
612
- console.log(` ~ Would remove .cursor/rules/${f}`)
224
+ let changed = false
225
+ const removedAgents = new Set()
226
+ if (config.agent && typeof config.agent === 'object' && !Array.isArray(config.agent)) {
227
+ for (const [name, value] of Object.entries(config.agent)) {
228
+ const source = value && typeof value === 'object' ? value.source : undefined
229
+ const sourcePath = typeof source === 'string' ? resolve(target, source) : ''
230
+ if (
231
+ typeof source === 'string' &&
232
+ isInside(sourcePath, ownedRoot) &&
233
+ isPantheonContent(sourcePath)
234
+ ) {
235
+ delete config.agent[name]
236
+ removedAgents.add(name)
237
+ changed = true
613
238
  }
614
239
  }
615
-
616
- // Remove rules/ directory if empty
617
- if (!dryRun && readdirSync(rulesDir).length === 0) {
618
- try {
619
- rmSync(rulesDir, { recursive: true, force: true })
620
- } catch {
621
- /* ignore */
622
- }
240
+ if (!Object.keys(config.agent).length) {
241
+ delete config.agent
242
+ changed = true
623
243
  }
624
- } catch (err) {
625
- console.error(` ⚠️ Failed to read ${rulesDir}: ${err.message}`)
626
- s.errors++
627
244
  }
628
-
629
- // Note: AGENTS.md is handled by opencode uninstaller
630
- }
631
-
632
- /**
633
- * Windsurf — remove .windsurf/rules/, remove .windsurf/workflows/ if empty
634
- */
635
- function uninstallWindsurf(target, dryRun) {
636
- const s = stats.windsurf
637
-
638
- // 1. Remove .windsurf/rules/
639
- const rulesDir = join(target, '.windsurf', 'rules')
640
- if (existsSync(rulesDir)) {
641
- if (!dryRun) {
642
- try {
643
- rmSync(rulesDir, { recursive: true, force: true })
644
- s.removed++
645
- } catch (err) {
646
- console.error(` ⚠️ Failed to remove ${rulesDir}: ${err.message}`)
647
- s.errors++
648
- }
649
- } else {
650
- const count = readdirSync(rulesDir).length
651
- s.removed++
652
- console.log(` ~ Would remove .windsurf/rules/ (${count} files)`)
653
- }
654
- } else {
655
- s.skipped++
656
- }
657
-
658
- // 2. Remove workflows that match Pantheon workflow names
659
- const wfDir = join(target, '.windsurf', 'workflows')
660
- if (existsSync(wfDir)) {
661
- try {
662
- const entries = readdirSync(wfDir)
663
- const pantheonWorkflows = entries.filter(
664
- (f) =>
665
- f.endsWith('.md') &&
666
- [
667
- 'orchestrate.md',
668
- 'code-review.md',
669
- 'audit.md',
670
- 'cancel.md',
671
- 'deepwork.md',
672
- 'focus.md',
673
- 'forge.md',
674
- 'metamorphosis.md',
675
- 'mirrordeps.md',
676
- 'optimize.md',
677
- 'pantheon-status.md',
678
- 'pantheon.md',
679
- 'ping.md',
680
- 'praxis.md',
681
- 'reflect.md',
682
- 'sketch.md',
683
- 'stop-continuation.md',
684
- 'subtask.md',
685
- ].includes(f),
245
+ if (Array.isArray(config.instructions)) {
246
+ const instructions = config.instructions.filter((entry) => {
247
+ if (typeof entry !== 'string') return true
248
+ const path = resolve(target, entry)
249
+ return !(isInside(path, ownedRoot) && isPantheonContent(path))
250
+ })
251
+ changed ||= instructions.length !== config.instructions.length
252
+ if (instructions.length) config.instructions = instructions
253
+ else delete config.instructions
254
+ }
255
+ for (const key of ['mcp', 'plugin', 'plugins']) {
256
+ if (Array.isArray(config[key])) {
257
+ const kept = config[key].filter((entry) =>
258
+ key === 'mcp'
259
+ ? !ownedMcpEntry(typeof entry === 'string' ? entry : '', entry)
260
+ : !ownedPluginEntry(entry),
686
261
  )
687
-
688
- for (const f of pantheonWorkflows) {
689
- const fp = join(wfDir, f)
690
- if (!dryRun) {
691
- try {
692
- rmSync(fp, { force: true })
693
- s.removed++
694
- } catch (err) {
695
- console.error(` ⚠️ Failed to remove ${fp}: ${err.message}`)
696
- s.errors++
697
- }
698
- } else {
699
- s.removed++
700
- console.log(` ~ Would remove .windsurf/workflows/${f}`)
701
- }
702
- }
703
-
704
- // Remove workflows/ directory if empty
705
- if (!dryRun && existsSync(wfDir) && readdirSync(wfDir).length === 0) {
706
- try {
707
- rmSync(wfDir, { recursive: true, force: true })
708
- } catch {
709
- /* ignore */
710
- }
711
- }
712
- } catch (err) {
713
- console.error(` ⚠️ Failed to read ${wfDir}: ${err.message}`)
714
- s.errors++
715
- }
716
- } else {
717
- s.skipped++
718
- }
719
-
720
- // Note: AGENTS.md is handled by opencode uninstaller
721
- }
722
-
723
- /**
724
- * VS Code / Copilot — remove .github/agents/, restore .vscode/settings.json
725
- */
726
- function uninstallCopilot(target, dryRun) {
727
- const s = stats.copilot
728
-
729
- // 1. Remove .github/agents/ (entire directory — Pantheon-created)
730
- const agentsDir = join(target, '.github', 'agents')
731
- if (existsSync(agentsDir)) {
732
- if (!dryRun) {
733
- try {
734
- rmSync(agentsDir, { recursive: true, force: true })
735
- s.removed++
736
- } catch (err) {
737
- console.error(` ⚠️ Failed to remove ${agentsDir}: ${err.message}`)
738
- s.errors++
739
- }
740
- } else {
741
- const count = existsSync(agentsDir) ? readdirSync(agentsDir).length : 0
742
- s.removed++
743
- console.log(` ~ Would remove .github/agents/ (${count} files)`)
744
- }
745
- } else {
746
- s.skipped++
747
- }
748
-
749
- // 2. Restore .vscode/settings.json — remove Pantheon-added keys
750
- const settingsPath = join(target, '.vscode', 'settings.json')
751
- if (existsSync(settingsPath)) {
752
- try {
753
- const raw = readFileSync(settingsPath, 'utf8')
754
- const settings = JSON.parse(raw)
755
- let changed = false
756
-
757
- // Remove the Pantheon-specific settings that the installer added
758
- const pantheonKeys = ['chat.subagents.allowInvocationsFromSubagents', 'chat.plugins.enabled']
759
-
760
- for (const key of pantheonKeys) {
761
- if (key in settings) {
762
- delete settings[key]
262
+ changed ||= kept.length !== config[key].length
263
+ config[key] = kept
264
+ } else if (config[key] && typeof config[key] === 'object') {
265
+ for (const [name, value] of Object.entries(config[key])) {
266
+ if (
267
+ key === 'mcp'
268
+ ? ownedMcpEntry(name, value)
269
+ : ownedPluginEntry(name) || ownedPluginEntry(value)
270
+ ) {
271
+ delete config[key][name]
763
272
  changed = true
764
273
  }
765
274
  }
766
-
767
- if (changed) {
768
- if (!dryRun) {
769
- writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
770
- s.removed++
771
- } else {
772
- s.removed++
773
- console.log(' ~ Would restore .vscode/settings.json (remove Pantheon keys)')
774
- }
775
- } else {
776
- s.skipped++
275
+ if (!Object.keys(config[key]).length) {
276
+ delete config[key]
277
+ changed = true
777
278
  }
778
- } catch (err) {
779
- console.error(` ⚠️ Failed to process .vscode/settings.json: ${err.message}`)
780
- s.errors++
781
279
  }
782
- } else {
783
- s.skipped++
784
280
  }
785
-
786
- // Note: AGENTS.md is handled by opencode uninstaller
787
- }
788
-
789
- /**
790
- * Continue.dev — remove .continue/rules/, check .continue/config.yaml
791
- */
792
- function uninstallContinue(target, dryRun) {
793
- const s = stats.continue
794
-
795
- // 1. Remove .continue/rules/ (entire directory — Pantheon-created)
796
- const rulesDir = join(target, '.continue', 'rules')
797
- if (existsSync(rulesDir)) {
798
- if (!dryRun) {
799
- try {
800
- rmSync(rulesDir, { recursive: true, force: true })
801
- s.removed++
802
- } catch (err) {
803
- console.error(` ⚠️ Failed to remove ${rulesDir}: ${err.message}`)
804
- s.errors++
805
- }
806
- } else {
807
- const count = readdirSync(rulesDir).length
808
- s.removed++
809
- console.log(` ~ Would remove .continue/rules/ (${count} files)`)
810
- }
811
- } else {
812
- s.skipped++
281
+ if (removedAgents.has(config.default_agent)) {
282
+ delete config.default_agent
283
+ changed = true
813
284
  }
814
-
815
- // 2. Check .continue/config.yaml for Pantheon references
816
- const configPath = join(target, '.continue', 'config.yaml')
817
- if (existsSync(configPath)) {
818
- try {
819
- const content = readFileSync(configPath, 'utf8')
820
- if (
821
- content.includes('Pantheon') ||
822
- content.includes('pantheon') ||
823
- /rules\/\w+\.md/.test(content)
824
- ) {
825
- if (!dryRun) {
826
- // Note: We don't auto-edit yaml (too risky with formatting),
827
- // but we inform the user
828
- console.log(
829
- ' ℹ️ .continue/config.yaml references Pantheon rules — manual cleanup may be needed',
830
- )
831
- } else {
832
- console.log(' ~ Would check .continue/config.yaml for Pantheon references')
833
- }
834
- s.removed++
835
- } else {
836
- s.skipped++
837
- }
838
- } catch {
839
- s.skipped++
840
- }
841
- } else {
842
- s.skipped++
843
- }
844
-
845
- // 3. Remove .continue/.opencode/ if present (from sync)
846
- const opencodeDir = join(target, '.continue', '.opencode')
847
- if (existsSync(opencodeDir)) {
848
- if (!dryRun) {
849
- try {
850
- rmSync(opencodeDir, { recursive: true, force: true })
851
- s.removed++
852
- } catch (_err) {
853
- /* ignore */
854
- }
855
- } else {
856
- s.removed++
857
- console.log(' ~ Would remove .continue/.opencode/ shared config')
858
- }
285
+ if (!changed) {
286
+ stats.skipped += 1
287
+ return
859
288
  }
289
+ if (dryRun) console.log(' ~ Would update opencode.json (remove Pantheon-owned entries)')
290
+ else writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
291
+ stats.removed += 1
860
292
  }
861
293
 
862
- /**
863
- * Cline — remove .clinerules/ (only agent rules, not commands/skills if shared)
864
- */
865
- function uninstallCline(target, dryRun) {
866
- const s = stats.cline
294
+ function configDirFor(target) {
295
+ const name = basename(target)
296
+ const looksFlat = lstat(join(target, 'agents'))?.isDirectory() === true
297
+ return name === '.opencode' || name === 'opencode' || looksFlat
298
+ ? target
299
+ : join(target, '.opencode')
300
+ }
867
301
 
868
- const clineDir = join(target, '.clinerules')
869
- if (!existsSync(clineDir)) {
870
- s.skipped++
302
+ function uninstallOpenCode(target, dryRun) {
303
+ const root = resolve(target)
304
+ if (isSymlink(root)) throw new Error(`Target must not be a symlink: ${root}`)
305
+ const configDir = configDirFor(root)
306
+ const configInfo = lstat(configDir)
307
+ if (configInfo?.isSymbolicLink()) {
308
+ stats.skipped += 1
871
309
  return
872
310
  }
873
-
874
- try {
875
- const entries = readdirSync(clineDir)
876
- const agentFiles = entries.filter(
877
- (f) => statSync(join(clineDir, f)).isFile() && isPantheonAgentFile(f),
878
- )
879
-
880
- if (agentFiles.length === 0) {
881
- s.skipped++
882
- return
883
- }
884
-
885
- for (const f of agentFiles) {
886
- const fp = join(clineDir, f)
887
- if (!dryRun) {
888
- try {
889
- rmSync(fp, { force: true })
890
- s.removed++
891
- } catch (err) {
892
- console.error(` ⚠️ Failed to remove ${fp}: ${err.message}`)
893
- s.errors++
894
- }
895
- } else {
896
- s.removed++
897
- console.log(` ~ Would remove .clinerules/${f}`)
898
- }
899
- }
900
-
901
- // Remove .clinerules/ directory if empty (no shared commands/skills remain)
902
- if (!dryRun && readdirSync(clineDir).length === 0) {
903
- try {
904
- rmSync(clineDir, { recursive: true, force: true })
905
- } catch {
906
- /* ignore */
907
- }
908
- }
909
- } catch (err) {
910
- console.error(` ⚠️ Failed to read ${clineDir}: ${err.message}`)
911
- s.errors++
912
- }
311
+ const ownedRoot = root
312
+ cleanOpenCodeConfig(root, dryRun, ownedRoot)
313
+ removeMatchingEntries(
314
+ join(configDir, 'agents'),
315
+ (_path, name) => AGENT_NAMES.has(name.replace(/\.md$/, '')) && isPantheonContent(_path),
316
+ dryRun,
317
+ ownedRoot,
318
+ )
319
+ for (const directory of ['skills', 'commands', 'plugins'])
320
+ removeMatchingEntries(join(configDir, directory), isPantheonContent, dryRun, ownedRoot)
321
+ for (const file of ['package.json', 'tsconfig.json', 'routing.yml']) {
322
+ const path = join(configDir, file)
323
+ if (file === 'routing.yml' ? isPantheonRouting(path) : isPantheonContent(path))
324
+ removePath(path, dryRun, ownedRoot, path)
325
+ }
326
+ const tui = join(configDir, 'tui.json')
327
+ cleanTuiConfig(tui, dryRun, ownedRoot)
328
+ const agentsMd = join(root, 'AGENTS.md')
329
+ if (isPantheonContent(agentsMd)) removePath(agentsMd, dryRun, ownedRoot, agentsMd)
330
+ if (configInfo?.isDirectory() && readdirSync(configDir).length === 0)
331
+ removePath(configDir, dryRun, ownedRoot, configDir)
913
332
  }
914
333
 
915
- // ---------------------------------------------------------------------------
916
- // Summary
917
- // ---------------------------------------------------------------------------
918
-
919
- function printUninstallSummary(target, platforms) {
920
- console.log('')
921
- console.log('='.repeat(60))
922
- console.log('🗑️ Uninstall Summary')
923
- console.log(` Target: ${target}`)
924
- console.log('='.repeat(60))
925
-
926
- let totalRemoved = 0
927
- let totalSkipped = 0
928
- let totalErrors = 0
929
-
930
- for (const platform of platforms) {
931
- const label = PLATFORM_LABELS[platform] ?? platform
932
- const pStats = stats[platform]
933
- totalRemoved += pStats.removed
934
- totalSkipped += pStats.skipped
935
- totalErrors += pStats.errors
936
-
937
- const status = pStats.errors > 0 ? '⚠️' : '✅'
938
- console.log(
939
- ` ${status} ${label}: ${pStats.removed} removed, ${pStats.skipped} skipped${pStats.errors > 0 ? `, ${pStats.errors} errors` : ''}`,
940
- )
334
+ function cleanTuiConfig(path, dryRun, root) {
335
+ const config = readJson(path)
336
+ if (!config || !Array.isArray(config.plugin)) {
337
+ stats.skipped += 1
338
+ return
941
339
  }
942
-
943
- console.log('-'.repeat(60))
944
- console.log(
945
- ` Total: ${totalRemoved} items removed, ${totalSkipped} items skipped, ${totalErrors} errors`,
340
+ const plugin = config.plugin.filter(
341
+ (entry) => !(ownedReference(entry) && isInside(resolve(dirname(path), entry), root)),
946
342
  )
947
-
948
- if (totalErrors > 0) {
949
- console.log(' ⚠️ Some platforms had errors — review warnings above.')
950
- }
951
-
952
- if (totalRemoved === 0 && totalSkipped > 0) {
953
- console.log(' ℹ️ No Pantheon files found for the selected platforms.')
343
+ if (plugin.length === config.plugin.length) {
344
+ stats.skipped += 1
345
+ return
954
346
  }
347
+ config.plugin = plugin
348
+ if (dryRun) console.log(' ~ Would update tui.json (remove Pantheon-owned plugin references)')
349
+ else writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
350
+ stats.removed += 1
351
+ }
955
352
 
956
- console.log('')
957
- if (totalRemoved > 0) {
958
- console.log(' 📖 To verify, check the target directory for remaining Pantheon files.')
959
- console.log(` 📚 Reinstall: npx pantheon-opencode init --project (in ${target})`)
960
- }
961
- console.log('')
353
+ function dirname(path) {
354
+ return path.slice(0, path.lastIndexOf('/'))
962
355
  }
963
356
 
964
- // ---------------------------------------------------------------------------
965
- // Main
966
- // ---------------------------------------------------------------------------
357
+ async function confirmPrompt(message) {
358
+ if (!isatty(process.stdin.fd)) return true
359
+ const readline = createInterface({ input: process.stdin, output: process.stdout })
360
+ return new Promise((answer) =>
361
+ readline.question(message, (value) => {
362
+ readline.close()
363
+ answer(['y', 'yes'].includes(value.trim().toLowerCase()))
364
+ }),
365
+ )
366
+ }
967
367
 
968
368
  async function main() {
969
369
  const args = parseArgs(process.argv)
370
+ if (args.help) return showHelp()
371
+ const info = lstat(args.target)
372
+ if (!info?.isDirectory() || info.isSymbolicLink())
373
+ throw new Error(`Target directory does not exist or is a symlink: ${args.target}`)
374
+ if (
375
+ !args.dryRun &&
376
+ !args.force &&
377
+ !(await confirmPrompt(`⚠️ Remove Pantheon OpenCode artifacts from ${args.target}? [y/N] `))
378
+ )
379
+ return console.log('❌ Uninstall cancelled.')
380
+ console.log(`${args.dryRun ? '🔍 Dry-run' : '🗑️ Uninstall'}: OpenCode/Pantheon only`)
381
+ uninstallOpenCode(args.target, args.dryRun)
382
+ console.log(`✅ ${stats.removed} removed, ${stats.skipped} skipped, ${stats.errors} errors`)
383
+ if (stats.errors) process.exitCode = 1
384
+ }
970
385
 
971
- if (args.help) {
972
- showHelp()
973
- process.exit(0)
974
- }
975
-
976
- const target = args.target
977
-
978
- if (!existsSync(target)) {
979
- console.error(`❌ Target directory does not exist: ${target}`)
980
- process.exit(1)
981
- }
982
-
983
- let platforms = args.platforms
984
-
985
- if (platforms?.includes('all')) {
986
- platforms = [...ALL_PLATFORMS]
987
- } else if (!platforms) {
988
- const detected = detectPlatforms(target)
989
- if (detected.length === 0) {
990
- console.log(`🔍 No Pantheon platform config detected in ${target}`)
991
- console.log(' Uninstalling all platforms.\n')
992
- platforms = [...ALL_PLATFORMS]
993
- } else {
994
- console.log(`🔍 Detected platforms in ${target}: ${detected.join(', ')}\n`)
995
- platforms = detected
996
- }
997
- }
998
-
999
- console.log(args.dryRun ? '🔍 Dry-run mode — no files will be deleted\n' : '')
1000
- console.log(`📦 Pantheon Uninstaller`)
1001
- console.log(` Target: ${target}`)
1002
- console.log(` Platforms: ${platforms.join(', ')}\n`)
1003
-
1004
- // Confirmation (skip in dry-run mode, unless --force)
1005
- if (!args.dryRun && !args.force) {
1006
- const ok = await confirmPrompt(
1007
- `⚠️ This will remove Pantheon agent files from ${target}\n Proceed? [y/N] `,
1008
- )
1009
- if (!ok) {
1010
- console.log('❌ Uninstall cancelled.')
1011
- process.exit(0)
1012
- }
1013
- console.log('')
1014
- }
1015
-
1016
- for (const platform of platforms) {
1017
- const label = PLATFORM_LABELS[platform] ?? platform
1018
- console.log(`🗑️ ${label}`)
1019
-
1020
- switch (platform) {
1021
- case 'opencode':
1022
- uninstallOpenCode(target, args.dryRun)
1023
- break
1024
- case 'claude':
1025
- uninstallClaude(target, args.dryRun)
1026
- break
1027
- case 'cursor':
1028
- uninstallCursor(target, args.dryRun)
1029
- break
1030
- case 'windsurf':
1031
- uninstallWindsurf(target, args.dryRun)
1032
- break
1033
- case 'copilot':
1034
- uninstallCopilot(target, args.dryRun)
1035
- break
1036
- case 'continue':
1037
- uninstallContinue(target, args.dryRun)
1038
- break
1039
- case 'cline':
1040
- uninstallCline(target, args.dryRun)
1041
- break
1042
- default:
1043
- console.warn(` ⚠️ Unknown platform: ${platform} — skipping`)
1044
- break
1045
- }
1046
- }
1047
-
1048
- printUninstallSummary(target, platforms)
1049
- process.exit(0)
386
+ export {
387
+ cleanOpenCodeConfig,
388
+ isPantheonContent,
389
+ main,
390
+ parseArgs,
391
+ resolveGlobalTarget,
392
+ uninstallOpenCode,
1050
393
  }
1051
394
 
1052
- main().catch((err) => {
1053
- console.error(`❌ Uninstall failed: ${err.message}`)
1054
- process.exit(1)
1055
- })
395
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname))
396
+ main().catch((error) => {
397
+ console.error(`❌ Uninstall failed: ${error.message}`)
398
+ process.exit(1)
399
+ })