pantheon-opencode 1.2.1 → 1.2.2

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 (36) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +12 -13
  3. package/bin/pantheon-init.mjs +14 -0
  4. package/opencode.json +1 -1
  5. package/package.json +13 -4
  6. package/scripts/changelog-extract.mjs +112 -0
  7. package/scripts/doctor.mjs +303 -13
  8. package/scripts/hooks/audit-imports.sh +43 -0
  9. package/scripts/hooks/format-multi-language.sh +96 -0
  10. package/scripts/hooks/log-session-start.sh +24 -0
  11. package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
  12. package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
  13. package/scripts/hooks/run-type-check.sh +21 -0
  14. package/scripts/hooks/scan-secrets.sh +55 -0
  15. package/scripts/hooks/validate-post-conditions.sh +21 -0
  16. package/scripts/hooks/validate-talos-scope.sh +43 -0
  17. package/scripts/hooks/validate-tool-safety.sh +46 -0
  18. package/scripts/install/opencode.mjs +58 -29
  19. package/scripts/install/plugin.mjs +4 -2
  20. package/scripts/install-mcp.mjs +104 -56
  21. package/scripts/mcp_persistence_server.py +1 -0
  22. package/scripts/prune.mjs +359 -0
  23. package/scripts/version-check.mjs +146 -0
  24. package/scripts/versioning.mjs +13 -14
  25. package/src/mcp/requirements-mcp.txt +8 -5
  26. package/src/mcp/requirements-vision.txt +3 -3
  27. package/src/plugins/hook-runner.ts +186 -0
  28. package/src/plugins/pantheon-hooks.ts +153 -0
  29. package/src/plugins/tui/dist/tui.d.ts +8 -0
  30. package/src/plugins/tui/dist/tui.d.ts.map +1 -0
  31. package/src/plugins/tui/dist/tui.js +1198 -0
  32. package/src/plugins/tui/dist/tui.js.map +1 -0
  33. package/src/plugins/tui/dist/tui.tsx +1204 -0
  34. package/src/plugins/tui/package.json +2 -2
  35. package/src/plugins/tui/src/index.tsx +15 -0
  36. package/src/mcp/requirements-mcp-core.txt +0 -5
@@ -23,13 +23,61 @@
23
23
  import { execSync } from 'node:child_process'
24
24
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
25
25
  import { homedir } from 'node:os'
26
- import { dirname, join, resolve } from 'node:path'
26
+ import { basename, dirname, join, resolve } from 'node:path'
27
27
  import { createInterface } from 'node:readline'
28
28
  import { fileURLToPath } from 'node:url'
29
29
 
30
30
  const __dirname = dirname(fileURLToPath(import.meta.url))
31
31
  const ROOT = join(__dirname, '..')
32
32
 
33
+ // ---------------------------------------------------------------------------
34
+ // Hermetic path resolution (P2 — ENOENT-on-spawn elimination)
35
+ //
36
+ // Pantheon MCP commands MUST be generated with ABSOLUTE paths for both the
37
+ // venv python3 and the server script. ROOT alone is ambiguous: when this
38
+ // installer runs from a global npm install, `join(ROOT, '.venv/bin/python3')`
39
+ // points at a venv that does not exist there, and a stale config with a
40
+ // hardcoded path to a migrated home (e.g. /home/admin) fails with
41
+ // `posix_spawn ENOENT`. Resolution order:
42
+ // 1. Canonical user install: ~/.config/opencode/.venv + ~/.config/opencode/scripts/
43
+ // 2. Local checkout: <ROOT>/.venv + <ROOT>/<relPath>
44
+ // 3. Fallback: PATH python3 / ROOT path, with a warning
45
+ // ---------------------------------------------------------------------------
46
+ const CONFIG_OPENCODE = join(homedir(), '.config', 'opencode')
47
+
48
+ function resolveVenvPython() {
49
+ const candidates = [
50
+ join(CONFIG_OPENCODE, '.venv', 'bin', 'python3'),
51
+ join(ROOT, '.venv', 'bin', 'python3'),
52
+ ]
53
+ for (const p of candidates) {
54
+ if (existsSync(p)) return p
55
+ }
56
+ console.warn(
57
+ ` ⚠️ Pantheon venv python3 not found (checked ${candidates.join(', ')}). Using PATH 'python3'.`,
58
+ )
59
+ return 'python3'
60
+ }
61
+
62
+ function resolveServerScript(relPath) {
63
+ const base = basename(relPath)
64
+ const candidates = [join(CONFIG_OPENCODE, 'scripts', base), join(ROOT, relPath)]
65
+ for (const p of candidates) {
66
+ if (existsSync(p)) return p
67
+ }
68
+ console.warn(
69
+ ` ⚠️ Pantheon MCP server script not found (checked ${candidates.join(', ')}). Using ${join(ROOT, relPath)}.`,
70
+ )
71
+ return join(ROOT, relPath)
72
+ }
73
+
74
+ const VENV_PYTHON = resolveVenvPython()
75
+ const MCP_RESOURCES_SERVER = resolveServerScript('scripts/mcp_resources_server.py')
76
+ const CODE_MODE_SERVER = resolveServerScript('scripts/code_mode_server.py')
77
+ const MEMORY_MCP_SERVER = resolveServerScript('scripts/memory_mcp_server.py')
78
+ const MCP_PERSISTENCE_SERVER = resolveServerScript('scripts/mcp_persistence_server.py')
79
+ const PANTHEON_VISION_SERVER = resolveServerScript('src/mcp/pantheon_vision_server.py')
80
+
33
81
  // ---------------------------------------------------------------------------
34
82
  // MCP Definitions
35
83
  // ---------------------------------------------------------------------------
@@ -173,37 +221,37 @@ const MCPS = {
173
221
  platforms: {
174
222
  opencode: {
175
223
  type: 'local',
176
- command: join(ROOT, '.venv/bin/python3'),
177
- args: [join(ROOT, 'scripts/mcp_resources_server.py')],
224
+ command: VENV_PYTHON,
225
+ args: [MCP_RESOURCES_SERVER],
178
226
  },
179
227
  vscode: {
180
228
  type: 'stdio',
181
- command: join(ROOT, '.venv/bin/python3'),
182
- args: [join(ROOT, 'scripts/mcp_resources_server.py')],
229
+ command: VENV_PYTHON,
230
+ args: [MCP_RESOURCES_SERVER],
183
231
  },
184
232
  cursor: {
185
233
  type: 'local',
186
- command: join(ROOT, '.venv/bin/python3'),
187
- args: [join(ROOT, 'scripts/mcp_resources_server.py')],
234
+ command: VENV_PYTHON,
235
+ args: [MCP_RESOURCES_SERVER],
188
236
  },
189
237
  claude: {
190
238
  type: 'local',
191
- command: join(ROOT, '.venv/bin/python3'),
192
- args: [join(ROOT, 'scripts/mcp_resources_server.py')],
239
+ command: VENV_PYTHON,
240
+ args: [MCP_RESOURCES_SERVER],
193
241
  },
194
242
  windsurf: {
195
243
  type: 'local',
196
- command: join(ROOT, '.venv/bin/python3'),
197
- args: [join(ROOT, 'scripts/mcp_resources_server.py')],
244
+ command: VENV_PYTHON,
245
+ args: [MCP_RESOURCES_SERVER],
198
246
  },
199
247
  },
200
248
  env: [],
201
249
  validate: async () => {
202
- const scriptPath = join(ROOT, 'scripts', 'mcp_resources_server.py')
250
+ const scriptPath = MCP_RESOURCES_SERVER
203
251
  if (existsSync(scriptPath)) {
204
252
  return { ok: true, message: 'server script present' }
205
253
  }
206
- return { ok: false, message: 'scripts/mcp_resources_server.py not found' }
254
+ return { ok: false, message: 'mcp_resources_server.py not found at resolved path' }
207
255
  },
208
256
  },
209
257
  'pantheon-code-mode': {
@@ -213,37 +261,37 @@ const MCPS = {
213
261
  platforms: {
214
262
  opencode: {
215
263
  type: 'local',
216
- command: join(ROOT, '.venv/bin/python3'),
217
- args: [join(ROOT, 'scripts/code_mode_server.py')],
264
+ command: VENV_PYTHON,
265
+ args: [CODE_MODE_SERVER],
218
266
  },
219
267
  vscode: {
220
268
  type: 'stdio',
221
- command: join(ROOT, '.venv/bin/python3'),
222
- args: [join(ROOT, 'scripts/code_mode_server.py')],
269
+ command: VENV_PYTHON,
270
+ args: [CODE_MODE_SERVER],
223
271
  },
224
272
  cursor: {
225
273
  type: 'local',
226
- command: join(ROOT, '.venv/bin/python3'),
227
- args: [join(ROOT, 'scripts/code_mode_server.py')],
274
+ command: VENV_PYTHON,
275
+ args: [CODE_MODE_SERVER],
228
276
  },
229
277
  claude: {
230
278
  type: 'local',
231
- command: join(ROOT, '.venv/bin/python3'),
232
- args: [join(ROOT, 'scripts/code_mode_server.py')],
279
+ command: VENV_PYTHON,
280
+ args: [CODE_MODE_SERVER],
233
281
  },
234
282
  windsurf: {
235
283
  type: 'local',
236
- command: join(ROOT, '.venv/bin/python3'),
237
- args: [join(ROOT, 'scripts/code_mode_server.py')],
284
+ command: VENV_PYTHON,
285
+ args: [CODE_MODE_SERVER],
238
286
  },
239
287
  },
240
288
  env: [],
241
289
  validate: async () => {
242
- const scriptPath = join(ROOT, 'scripts', 'code_mode_server.py')
290
+ const scriptPath = CODE_MODE_SERVER
243
291
  if (existsSync(scriptPath)) {
244
292
  return { ok: true, message: 'server script present' }
245
293
  }
246
- return { ok: false, message: 'scripts/code_mode_server.py not found' }
294
+ return { ok: false, message: 'code_mode_server.py not found at resolved path' }
247
295
  },
248
296
  },
249
297
  'pantheon-memory': {
@@ -254,32 +302,32 @@ const MCPS = {
254
302
  platforms: {
255
303
  opencode: {
256
304
  type: 'local',
257
- command: join(ROOT, '.venv/bin/python3'),
258
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
305
+ command: VENV_PYTHON,
306
+ args: [MEMORY_MCP_SERVER],
259
307
  },
260
308
  claude: {
261
309
  type: 'local',
262
- command: join(ROOT, '.venv/bin/python3'),
263
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
310
+ command: VENV_PYTHON,
311
+ args: [MEMORY_MCP_SERVER],
264
312
  },
265
313
  cursor: {
266
314
  type: 'local',
267
- command: join(ROOT, '.venv/bin/python3'),
268
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
315
+ command: VENV_PYTHON,
316
+ args: [MEMORY_MCP_SERVER],
269
317
  },
270
318
  windsurf: {
271
319
  type: 'local',
272
- command: join(ROOT, '.venv/bin/python3'),
273
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
320
+ command: VENV_PYTHON,
321
+ args: [MEMORY_MCP_SERVER],
274
322
  },
275
323
  },
276
324
  env: [],
277
325
  validate: async () => {
278
- const scriptPath = join(ROOT, 'scripts', 'memory_mcp_server.py')
326
+ const scriptPath = MEMORY_MCP_SERVER
279
327
  if (existsSync(scriptPath)) {
280
328
  return { ok: true, message: 'server script present' }
281
329
  }
282
- return { ok: false, message: 'scripts/memory_mcp_server.py not found' }
330
+ return { ok: false, message: 'memory_mcp_server.py not found at resolved path' }
283
331
  },
284
332
  },
285
333
  'pantheon-persistence': {
@@ -290,32 +338,32 @@ const MCPS = {
290
338
  platforms: {
291
339
  opencode: {
292
340
  type: 'local',
293
- command: join(ROOT, '.venv/bin/python3'),
294
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
341
+ command: VENV_PYTHON,
342
+ args: [MCP_PERSISTENCE_SERVER],
295
343
  },
296
344
  claude: {
297
345
  type: 'local',
298
- command: join(ROOT, '.venv/bin/python3'),
299
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
346
+ command: VENV_PYTHON,
347
+ args: [MCP_PERSISTENCE_SERVER],
300
348
  },
301
349
  cursor: {
302
350
  type: 'local',
303
- command: join(ROOT, '.venv/bin/python3'),
304
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
351
+ command: VENV_PYTHON,
352
+ args: [MCP_PERSISTENCE_SERVER],
305
353
  },
306
354
  windsurf: {
307
355
  type: 'local',
308
- command: join(ROOT, '.venv/bin/python3'),
309
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
356
+ command: VENV_PYTHON,
357
+ args: [MCP_PERSISTENCE_SERVER],
310
358
  },
311
359
  },
312
360
  env: [],
313
361
  validate: async () => {
314
- const scriptPath = join(ROOT, 'scripts', 'mcp_persistence_server.py')
362
+ const scriptPath = MCP_PERSISTENCE_SERVER
315
363
  if (existsSync(scriptPath)) {
316
364
  return { ok: true, message: 'server script present' }
317
365
  }
318
- return { ok: false, message: 'scripts/mcp_persistence_server.py not found' }
366
+ return { ok: false, message: 'mcp_persistence_server.py not found at resolved path' }
319
367
  },
320
368
  },
321
369
  'pantheon-vision': {
@@ -327,33 +375,33 @@ const MCPS = {
327
375
  platforms: {
328
376
  opencode: {
329
377
  type: 'local',
330
- command: join(ROOT, '.venv/bin/python3'),
331
- args: [join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')],
378
+ command: VENV_PYTHON,
379
+ args: [PANTHEON_VISION_SERVER],
332
380
  },
333
381
  vscode: {
334
382
  type: 'stdio',
335
- command: join(ROOT, '.venv/bin/python3'),
336
- args: [join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')],
383
+ command: VENV_PYTHON,
384
+ args: [PANTHEON_VISION_SERVER],
337
385
  },
338
386
  claude: {
339
387
  type: 'local',
340
- command: join(ROOT, '.venv/bin/python3'),
341
- args: [join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')],
388
+ command: VENV_PYTHON,
389
+ args: [PANTHEON_VISION_SERVER],
342
390
  },
343
391
  cursor: {
344
392
  type: 'local',
345
- command: join(ROOT, '.venv/bin/python3'),
346
- args: [join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')],
393
+ command: VENV_PYTHON,
394
+ args: [PANTHEON_VISION_SERVER],
347
395
  },
348
396
  windsurf: {
349
397
  type: 'local',
350
- command: join(ROOT, '.venv/bin/python3'),
351
- args: [join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')],
398
+ command: VENV_PYTHON,
399
+ args: [PANTHEON_VISION_SERVER],
352
400
  },
353
401
  },
354
402
  env: [],
355
403
  validate: async () => {
356
- const sourcePath = join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py')
404
+ const sourcePath = PANTHEON_VISION_SERVER
357
405
  const requirementsPath = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
358
406
  if (existsSync(sourcePath) && existsSync(requirementsPath)) {
359
407
  return { ok: true, message: 'canonical server source and isolated requirements present' }
@@ -14,6 +14,7 @@ import argparse
14
14
  import re
15
15
  import sqlite3
16
16
  import sys
17
+ import uuid
17
18
  from datetime import datetime, timedelta, timezone
18
19
  from pathlib import Path
19
20
 
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * prune.mjs — Pantheon legacy cleanup CLI (issue #22)
4
+ *
5
+ * Finds and (with explicit flags) removes legacy artifacts left by previous
6
+ * Pantheon versions — old global installs, stale config backups, dead
7
+ * legacy config directories — following the XDG Base Directory spec.
8
+ *
9
+ * SAFETY: prune NEVER touches the active config, the active venv, the package
10
+ * repo, or the installed package. It only:
11
+ * - lists old global `pantheon` installs (suggests `npm uninstall -g pantheon`)
12
+ * - removes stale `opencode.json.bak*` backups (age > threshold) with --apply
13
+ * - lists legacy directories whose opencode.json references dead/relative
14
+ * paths (removal requires --apply --remove-dirs as explicit confirmation)
15
+ *
16
+ * Usage:
17
+ * node scripts/prune.mjs # dry-run (default): list only
18
+ * node scripts/prune.mjs --apply # remove stale backups
19
+ * node scripts/prune.mjs --apply --remove-dirs # also remove legacy dirs
20
+ * node scripts/prune.mjs --target <dir> # override config dir (tests)
21
+ * node scripts/prune.mjs --age 30 # backup age threshold (days)
22
+ * node scripts/prune.mjs --help
23
+ *
24
+ * Exit codes:
25
+ * 0 = nothing to clean (or everything cleaned)
26
+ * 1 = findings remain (dry-run listed items, or items kept)
27
+ * 2 = usage error
28
+ */
29
+
30
+ import { execSync } from 'node:child_process'
31
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
32
+ import { homedir } from 'node:os'
33
+ import { dirname, join, resolve } from 'node:path'
34
+ import { fileURLToPath } from 'node:url'
35
+
36
+ const __dirname = dirname(fileURLToPath(import.meta.url))
37
+ const ROOT = join(__dirname, '..')
38
+ const DEFAULT_BACKUP_AGE_DAYS = 30
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // XDG config resolution — parity with _pantheon_paths.py and bin/pantheon-init
42
+ // PANTHEON_HOME → $XDG_CONFIG_HOME/opencode → ~/.config/opencode
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export function resolveConfigDir(target) {
46
+ if (target) return resolve(target)
47
+ if (process.env.PANTHEON_HOME) return resolve(process.env.PANTHEON_HOME)
48
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
49
+ return join(base, 'opencode')
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Finding model
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * @typedef {{ type: string, target: string, detail: string, action: 'suggest'|'remove'|'list'|'keep' }} Finding
58
+ */
59
+
60
+ /** @type {Finding[]} */
61
+ const findings = []
62
+
63
+ function add(type, target, detail, action) {
64
+ findings.push({ type, target, detail, action })
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // (a) Old global `pantheon` install (private package, never published)
69
+ // → suggestion only: NEVER remove node_modules directly (npm-managed).
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export function findLegacyGlobalInstalls() {
73
+ const nodeModulesDirs = new Set()
74
+ const knownLegacy = join(homedir(), '.npm-global', 'lib', 'node_modules')
75
+ if (existsSync(knownLegacy)) nodeModulesDirs.add(knownLegacy)
76
+ try {
77
+ const npmPrefix = execSync('npm prefix -g', { encoding: 'utf8' }).trim()
78
+ if (npmPrefix) nodeModulesDirs.add(join(npmPrefix, 'lib', 'node_modules'))
79
+ } catch {
80
+ // npm unavailable — fall back to the known legacy location only
81
+ }
82
+
83
+ for (const dir of nodeModulesDirs) {
84
+ // `pantheon` (legacy name) — NOT `pantheon-opencode` (current package)
85
+ const pkgDir = join(dir, 'pantheon')
86
+ const pkgJsonPath = join(pkgDir, 'package.json')
87
+ if (!existsSync(pkgJsonPath)) continue
88
+ try {
89
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
90
+ if (pkg.name === 'pantheon' && pkg.private === true) {
91
+ add(
92
+ 'global-install',
93
+ pkgDir,
94
+ `legacy private package pantheon@${pkg.version || '?'} — bin 'pantheon' conflicts with pantheon-opencode`,
95
+ 'suggest',
96
+ )
97
+ }
98
+ } catch {
99
+ // unreadable package.json — skip
100
+ }
101
+ }
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // (b) Stale opencode.json.bak* backups (age > threshold) in the config dir
106
+ // → removable with --apply. Fresh backups are listed as kept.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export function findStaleBackups(configDir, maxAgeDays = DEFAULT_BACKUP_AGE_DAYS) {
110
+ if (!existsSync(configDir)) return
111
+ const cutoffMs = Date.now() - maxAgeDays * 86_400_000
112
+ for (const name of readdirSync(configDir)) {
113
+ if (!name.startsWith('opencode.json.bak')) continue
114
+ const full = join(configDir, name)
115
+ let st
116
+ try {
117
+ st = statSync(full)
118
+ } catch {
119
+ continue
120
+ }
121
+ const ageDays = Math.floor((Date.now() - st.mtimeMs) / 86_400_000)
122
+ if (st.mtimeMs < cutoffMs) {
123
+ add('backup', full, `age ~${ageDays}d > ${maxAgeDays}d threshold`, 'remove')
124
+ } else {
125
+ add('backup-fresh', full, `age ~${ageDays}d ≤ ${maxAgeDays}d — kept`, 'keep')
126
+ }
127
+ }
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // (c) Legacy directories with dead config (relative/nonexistent paths)
132
+ // → listed only; removal needs --apply --remove-dirs (explicit confirm).
133
+ // ---------------------------------------------------------------------------
134
+
135
+ const LEGACY_DIR_NAMES = ['pantheon-legacy', 'legacy', 'old', 'backup', 'backups']
136
+
137
+ /** Collect opencode.json candidates inside a dir (depth ≤ 2). */
138
+ function collectConfigCandidates(dir) {
139
+ const candidates = []
140
+ const walk = (d, depth) => {
141
+ if (depth > 2) return
142
+ let entries
143
+ try {
144
+ entries = readdirSync(d, { withFileTypes: true })
145
+ } catch {
146
+ return
147
+ }
148
+ for (const entry of entries) {
149
+ const full = join(d, entry.name)
150
+ if (entry.isDirectory()) walk(full, depth + 1)
151
+ else if (entry.isFile() && entry.name === 'opencode.json') candidates.push(full)
152
+ }
153
+ }
154
+ walk(dir, 0)
155
+ return candidates
156
+ }
157
+
158
+ /** True when an opencode.json references relative or non-existent paths. */
159
+ export function configHasDeadPaths(configPath) {
160
+ let cfg
161
+ try {
162
+ cfg = JSON.parse(readFileSync(configPath, 'utf8'))
163
+ } catch {
164
+ return true // unparseable config in a legacy dir counts as dead
165
+ }
166
+
167
+ const check = (value, where) => {
168
+ if (typeof value !== 'string' || value === '') return
169
+ if (value.startsWith('file://')) return
170
+ if (!value.startsWith('/')) return true // relative path in a config
171
+ if (!existsSync(value)) return true // absolute but missing
172
+ return false
173
+ }
174
+
175
+ // agent sources
176
+ for (const agent of Object.values(cfg.agent || {})) {
177
+ if (agent && typeof agent === 'object' && check(agent.source, 'agent.source')) return true
178
+ }
179
+ // instructions
180
+ for (const instr of cfg.instructions || []) {
181
+ if (check(instr, 'instructions')) return true
182
+ }
183
+ // mcp cwd + command[0] (when not a bare command name like python3)
184
+ for (const [key, mcp] of Object.entries(cfg.mcp || {})) {
185
+ if (!mcp || typeof mcp !== 'object') continue
186
+ if (mcp.cwd && check(mcp.cwd, `mcp.${key}.cwd`)) return true
187
+ const cmd = Array.isArray(mcp.command) ? mcp.command[0] : undefined
188
+ if (cmd && typeof cmd === 'string' && cmd.includes('/') && check(cmd, `mcp.${key}.command`)) {
189
+ return true
190
+ }
191
+ }
192
+ return false
193
+ }
194
+
195
+ export function findLegacyDirs(configDir) {
196
+ if (!existsSync(configDir)) return
197
+ for (const entry of readdirSync(configDir, { withFileTypes: true })) {
198
+ if (!entry.isDirectory() || !LEGACY_DIR_NAMES.includes(entry.name)) continue
199
+ const dir = join(configDir, entry.name)
200
+ const deadConfigs = collectConfigCandidates(dir).filter(configHasDeadPaths)
201
+ if (deadConfigs.length > 0) {
202
+ add(
203
+ 'legacy-dir',
204
+ dir,
205
+ `dead config(s): ${deadConfigs.map((c) => c.replace(dir + '/', '')).join(', ')}`,
206
+ 'list',
207
+ )
208
+ }
209
+ }
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Extra: legacy `~/.opencode` install dir (pre-XDG location)
214
+ // → listed only, manual review.
215
+ // ---------------------------------------------------------------------------
216
+
217
+ export function findLegacyInstallDir(configDir) {
218
+ const legacyDir = join(homedir(), '.opencode')
219
+ if (legacyDir === configDir) return
220
+ if (!existsSync(join(legacyDir, 'opencode.json'))) return
221
+ add(
222
+ 'legacy-install',
223
+ legacyDir,
224
+ 'pre-XDG install location — review manually; not removed automatically',
225
+ 'list',
226
+ )
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Actions
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function format(f) {
234
+ const icon =
235
+ f.action === 'remove' ? '🗑️' : f.action === 'suggest' ? '💡' : f.action === 'keep' ? '✅' : '🔎'
236
+ return `${icon} [${f.type}] ${f.target} — ${f.detail}`
237
+ }
238
+
239
+ export function runPrune({ configDir, maxAgeDays, apply, removeDirs }) {
240
+ findLegacyGlobalInstalls()
241
+ findStaleBackups(configDir, maxAgeDays)
242
+ findLegacyDirs(configDir)
243
+ findLegacyInstallDir(configDir)
244
+
245
+ console.log(`Pantheon prune — config dir: ${configDir}`)
246
+ console.log(` mode: ${apply ? 'APPLY' : 'DRY-RUN'}${removeDirs ? ' + REMOVE-DIRS' : ''}`)
247
+ console.log('')
248
+
249
+ if (findings.length === 0) {
250
+ console.log(' ✅ Nothing to clean.')
251
+ return { removed: 0, remain: 0 }
252
+ }
253
+
254
+ let removed = 0
255
+ let remain = 0
256
+ for (const f of findings) {
257
+ console.log(` ${format(f)}`)
258
+ if (f.action === 'remove' && apply) {
259
+ try {
260
+ rmSync(f.target, { force: true })
261
+ console.log(` → removed`)
262
+ removed++
263
+ continue
264
+ } catch (err) {
265
+ console.log(` → ⚠️ failed: ${err.message}`)
266
+ remain++
267
+ continue
268
+ }
269
+ }
270
+ if (f.action === 'remove' && !apply) {
271
+ // dry-run: count as "would remove"
272
+ removed++
273
+ continue
274
+ }
275
+ if (f.action === 'list' && apply && removeDirs) {
276
+ try {
277
+ rmSync(f.target, { recursive: true, force: true })
278
+ console.log(` → removed (--remove-dirs)`)
279
+ removed++
280
+ continue
281
+ } catch (err) {
282
+ console.log(` → ⚠️ failed: ${err.message}`)
283
+ remain++
284
+ continue
285
+ }
286
+ }
287
+ if (f.action === 'list' && apply && !removeDirs) {
288
+ console.log(' → kept: directory removal requires --apply --remove-dirs')
289
+ remain++
290
+ } else if (f.action === 'suggest') {
291
+ console.log(' → action: npm uninstall -g pantheon (not run automatically)')
292
+ remain++
293
+ } else if (f.action === 'keep' || f.action === 'list') {
294
+ remain++
295
+ }
296
+ }
297
+
298
+ console.log('')
299
+ const hint = apply ? '' : ' — run with --apply to remove stale backups'
300
+ console.log(
301
+ ` ${apply ? 'Removed' : 'Would remove'}: ${removed} item(s); remaining: ${remain}${hint}`,
302
+ )
303
+ return { removed, remain }
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // CLI
308
+ // ---------------------------------------------------------------------------
309
+
310
+ function printUsage() {
311
+ console.log(`Pantheon prune — legacy cleanup (issue #22)
312
+
313
+ Usage:
314
+ node scripts/prune.mjs [options]
315
+
316
+ Options:
317
+ --apply Remove stale backups (default: dry-run, list only)
318
+ --remove-dirs With --apply, also remove legacy directories
319
+ (explicit confirmation; default: list only)
320
+ --target <dir> Override the config directory (XDG-aware by default)
321
+ --age <days> Backup age threshold in days (default: ${DEFAULT_BACKUP_AGE_DAYS})
322
+ --help Show this help
323
+
324
+ Safety:
325
+ Never removes: active opencode.json, active .venv, package repo, or the
326
+ installed pantheon-opencode package. Old global 'pantheon' installs are
327
+ only suggested for manual 'npm uninstall -g pantheon'.
328
+
329
+ Exit codes: 0 = clean, 1 = findings remain, 2 = usage error`)
330
+ }
331
+
332
+ function main() {
333
+ const args = process.argv.slice(2)
334
+ if (args.includes('--help') || args.includes('-h')) {
335
+ printUsage()
336
+ process.exit(0)
337
+ }
338
+
339
+ const apply = args.includes('--apply') || args.includes('-y')
340
+ const removeDirs = args.includes('--remove-dirs')
341
+ const targetIdx = args.indexOf('--target')
342
+ const target = targetIdx >= 0 ? args[targetIdx + 1] : undefined
343
+ const ageIdx = args.indexOf('--age')
344
+ const age = ageIdx >= 0 ? Number(args[ageIdx + 1]) : DEFAULT_BACKUP_AGE_DAYS
345
+ if (!Number.isFinite(age) || age < 0) {
346
+ console.error('❌ --age must be a non-negative number of days')
347
+ process.exit(2)
348
+ }
349
+
350
+ const configDir = resolveConfigDir(target)
351
+ const result = runPrune({ configDir, maxAgeDays: age, apply, removeDirs })
352
+
353
+ // 0 = clean/cleaned; 1 = findings remain; 2 = usage error (handled above)
354
+ process.exit(result.remain > 0 ? 1 : 0)
355
+ }
356
+
357
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
358
+ main()
359
+ }