pantheon-opencode 1.2.0 → 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 (63) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +112 -15
  3. package/bin/pantheon-init.mjs +93 -0
  4. package/commands/pantheon-vision.md +67 -0
  5. package/opencode.json +14 -10
  6. package/package.json +20 -4
  7. package/scripts/_pantheon_paths.py +0 -2
  8. package/scripts/changelog-extract.mjs +112 -0
  9. package/scripts/doctor.mjs +307 -15
  10. package/scripts/hooks/audit-imports.sh +43 -0
  11. package/scripts/hooks/format-multi-language.sh +96 -0
  12. package/scripts/hooks/log-session-start.sh +24 -0
  13. package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
  14. package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
  15. package/scripts/hooks/run-type-check.sh +21 -0
  16. package/scripts/hooks/scan-secrets.sh +55 -0
  17. package/scripts/hooks/validate-post-conditions.sh +21 -0
  18. package/scripts/hooks/validate-talos-scope.sh +43 -0
  19. package/scripts/hooks/validate-tool-safety.sh +46 -0
  20. package/scripts/install/health-check.mjs +101 -1
  21. package/scripts/install/model-picker.mjs +118 -0
  22. package/scripts/install/opencode.mjs +155 -43
  23. package/scripts/install/plugin.mjs +4 -2
  24. package/scripts/install/venv.mjs +25 -2
  25. package/scripts/install-mcp.mjs +170 -37
  26. package/scripts/mcp_persistence_server.py +2 -1
  27. package/scripts/prune.mjs +359 -0
  28. package/scripts/secret-scan.mjs +88 -0
  29. package/scripts/uninstall.mjs +3 -3
  30. package/scripts/validate-routing.mjs +15 -0
  31. package/scripts/version-check.mjs +146 -0
  32. package/scripts/versioning.mjs +13 -14
  33. package/src/agents/hermes.md +2 -2
  34. package/src/agents/mnemosyne.md +1 -1
  35. package/src/agents/zeus.md +1 -1
  36. package/src/mcp/_pantheon_paths.py +0 -2
  37. package/src/mcp/install-mcp.mjs +6 -913
  38. package/src/mcp/mcp_persistence_server.py +2 -1
  39. package/src/mcp/pantheon_vision_server.py +495 -0
  40. package/src/mcp/requirements-mcp.txt +8 -5
  41. package/src/mcp/requirements-vision.txt +3 -0
  42. package/src/pantheon/auto-wake.ts +91 -0
  43. package/src/pantheon/background-job-board.ts +568 -0
  44. package/src/pantheon/file-persistence.ts +95 -0
  45. package/src/pantheon/presets.d.mts +108 -0
  46. package/src/pantheon/presets.mjs +554 -0
  47. package/src/pantheon/vision.ts +1437 -0
  48. package/src/plugin.ts +69 -25
  49. package/src/plugins/hook-runner.ts +186 -0
  50. package/src/plugins/pantheon-hooks.ts +153 -0
  51. package/src/plugins/tui/dist/tui.d.ts +1 -1
  52. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  53. package/src/plugins/tui/dist/tui.js +812 -223
  54. package/src/plugins/tui/dist/tui.js.map +1 -1
  55. package/src/plugins/tui/dist/tui.tsx +881 -121
  56. package/src/plugins/tui/package.json +3 -3
  57. package/src/plugins/tui/src/index.tsx +881 -121
  58. package/src/routing.yml +148 -0
  59. package/src/skills/context-compression/SKILL.md +1 -1
  60. package/src/skills/loop-engineering/SKILL.md +1 -1
  61. package/src/skills/orchestration-workflow/SKILL.md +11 -11
  62. package/scripts/scrub_secrets.py +0 -281
  63. 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 } 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
  // ---------------------------------------------------------------------------
@@ -171,19 +219,39 @@ const MCPS = {
171
219
  name: 'Pantheon Resources',
172
220
  description: 'Pantheon framework resources — agents, skills, routing, deepwork, memory bank',
173
221
  platforms: {
174
- opencode: { type: 'local', command: join(ROOT, '.venv/bin/python3'), args: [join(ROOT, 'scripts/mcp_resources_server.py')] },
175
- vscode: { type: 'stdio', command: join(ROOT, '.venv/bin/python3'), args: [join(ROOT, 'scripts/mcp_resources_server.py')] },
176
- cursor: { type: 'local', command: join(ROOT, '.venv/bin/python3'), args: [join(ROOT, 'scripts/mcp_resources_server.py')] },
177
- claude: { type: 'local', command: join(ROOT, '.venv/bin/python3'), args: [join(ROOT, 'scripts/mcp_resources_server.py')] },
178
- windsurf: { type: 'local', command: join(ROOT, '.venv/bin/python3'), args: [join(ROOT, 'scripts/mcp_resources_server.py')] },
222
+ opencode: {
223
+ type: 'local',
224
+ command: VENV_PYTHON,
225
+ args: [MCP_RESOURCES_SERVER],
226
+ },
227
+ vscode: {
228
+ type: 'stdio',
229
+ command: VENV_PYTHON,
230
+ args: [MCP_RESOURCES_SERVER],
231
+ },
232
+ cursor: {
233
+ type: 'local',
234
+ command: VENV_PYTHON,
235
+ args: [MCP_RESOURCES_SERVER],
236
+ },
237
+ claude: {
238
+ type: 'local',
239
+ command: VENV_PYTHON,
240
+ args: [MCP_RESOURCES_SERVER],
241
+ },
242
+ windsurf: {
243
+ type: 'local',
244
+ command: VENV_PYTHON,
245
+ args: [MCP_RESOURCES_SERVER],
246
+ },
179
247
  },
180
248
  env: [],
181
249
  validate: async () => {
182
- const scriptPath = join(ROOT, 'scripts', 'mcp_resources_server.py')
250
+ const scriptPath = MCP_RESOURCES_SERVER
183
251
  if (existsSync(scriptPath)) {
184
252
  return { ok: true, message: 'server script present' }
185
253
  }
186
- 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' }
187
255
  },
188
256
  },
189
257
  'pantheon-code-mode': {
@@ -191,19 +259,39 @@ const MCPS = {
191
259
  name: 'Pantheon Code Mode',
192
260
  description: 'Execute orchestration scripts from .pantheon/code-mode/ directory',
193
261
  platforms: {
194
- opencode: { type: 'local', command: join(ROOT, '.venv/bin/python3'), args: [join(ROOT, 'scripts/code_mode_server.py')] },
195
- vscode: { type: 'stdio', command: join(ROOT, '.venv/bin/python3'), args: [join(ROOT, 'scripts/code_mode_server.py')] },
196
- cursor: { type: 'local', command: join(ROOT, '.venv/bin/python3'), args: [join(ROOT, 'scripts/code_mode_server.py')] },
197
- claude: { type: 'local', command: join(ROOT, '.venv/bin/python3'), args: [join(ROOT, 'scripts/code_mode_server.py')] },
198
- windsurf: { type: 'local', command: join(ROOT, '.venv/bin/python3'), args: [join(ROOT, 'scripts/code_mode_server.py')] },
262
+ opencode: {
263
+ type: 'local',
264
+ command: VENV_PYTHON,
265
+ args: [CODE_MODE_SERVER],
266
+ },
267
+ vscode: {
268
+ type: 'stdio',
269
+ command: VENV_PYTHON,
270
+ args: [CODE_MODE_SERVER],
271
+ },
272
+ cursor: {
273
+ type: 'local',
274
+ command: VENV_PYTHON,
275
+ args: [CODE_MODE_SERVER],
276
+ },
277
+ claude: {
278
+ type: 'local',
279
+ command: VENV_PYTHON,
280
+ args: [CODE_MODE_SERVER],
281
+ },
282
+ windsurf: {
283
+ type: 'local',
284
+ command: VENV_PYTHON,
285
+ args: [CODE_MODE_SERVER],
286
+ },
199
287
  },
200
288
  env: [],
201
289
  validate: async () => {
202
- const scriptPath = join(ROOT, 'scripts', 'code_mode_server.py')
290
+ const scriptPath = CODE_MODE_SERVER
203
291
  if (existsSync(scriptPath)) {
204
292
  return { ok: true, message: 'server script present' }
205
293
  }
206
- 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' }
207
295
  },
208
296
  },
209
297
  'pantheon-memory': {
@@ -214,32 +302,32 @@ const MCPS = {
214
302
  platforms: {
215
303
  opencode: {
216
304
  type: 'local',
217
- command: join(ROOT, '.venv/bin/python3'),
218
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
305
+ command: VENV_PYTHON,
306
+ args: [MEMORY_MCP_SERVER],
219
307
  },
220
308
  claude: {
221
309
  type: 'local',
222
- command: join(ROOT, '.venv/bin/python3'),
223
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
310
+ command: VENV_PYTHON,
311
+ args: [MEMORY_MCP_SERVER],
224
312
  },
225
313
  cursor: {
226
314
  type: 'local',
227
- command: join(ROOT, '.venv/bin/python3'),
228
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
315
+ command: VENV_PYTHON,
316
+ args: [MEMORY_MCP_SERVER],
229
317
  },
230
318
  windsurf: {
231
319
  type: 'local',
232
- command: join(ROOT, '.venv/bin/python3'),
233
- args: [join(ROOT, 'scripts/memory_mcp_server.py')],
320
+ command: VENV_PYTHON,
321
+ args: [MEMORY_MCP_SERVER],
234
322
  },
235
323
  },
236
324
  env: [],
237
325
  validate: async () => {
238
- const scriptPath = join(ROOT, 'scripts', 'memory_mcp_server.py')
326
+ const scriptPath = MEMORY_MCP_SERVER
239
327
  if (existsSync(scriptPath)) {
240
328
  return { ok: true, message: 'server script present' }
241
329
  }
242
- 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' }
243
331
  },
244
332
  },
245
333
  'pantheon-persistence': {
@@ -250,32 +338,75 @@ const MCPS = {
250
338
  platforms: {
251
339
  opencode: {
252
340
  type: 'local',
253
- command: join(ROOT, '.venv/bin/python3'),
254
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
341
+ command: VENV_PYTHON,
342
+ args: [MCP_PERSISTENCE_SERVER],
255
343
  },
256
344
  claude: {
257
345
  type: 'local',
258
- command: join(ROOT, '.venv/bin/python3'),
259
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
346
+ command: VENV_PYTHON,
347
+ args: [MCP_PERSISTENCE_SERVER],
260
348
  },
261
349
  cursor: {
262
350
  type: 'local',
263
- command: join(ROOT, '.venv/bin/python3'),
264
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
351
+ command: VENV_PYTHON,
352
+ args: [MCP_PERSISTENCE_SERVER],
265
353
  },
266
354
  windsurf: {
267
355
  type: 'local',
268
- command: join(ROOT, '.venv/bin/python3'),
269
- args: [join(ROOT, 'scripts/mcp_persistence_server.py')],
356
+ command: VENV_PYTHON,
357
+ args: [MCP_PERSISTENCE_SERVER],
270
358
  },
271
359
  },
272
360
  env: [],
273
361
  validate: async () => {
274
- const scriptPath = join(ROOT, 'scripts', 'mcp_persistence_server.py')
362
+ const scriptPath = MCP_PERSISTENCE_SERVER
275
363
  if (existsSync(scriptPath)) {
276
364
  return { ok: true, message: 'server script present' }
277
365
  }
278
- 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' }
367
+ },
368
+ },
369
+ 'pantheon-vision': {
370
+ tier: 1,
371
+ name: 'Pantheon Vision',
372
+ description:
373
+ 'Image description, OCR, and structured analysis through the OpenCode vision gateway',
374
+ requirements: join(ROOT, 'src', 'mcp', 'requirements-vision.txt'),
375
+ platforms: {
376
+ opencode: {
377
+ type: 'local',
378
+ command: VENV_PYTHON,
379
+ args: [PANTHEON_VISION_SERVER],
380
+ },
381
+ vscode: {
382
+ type: 'stdio',
383
+ command: VENV_PYTHON,
384
+ args: [PANTHEON_VISION_SERVER],
385
+ },
386
+ claude: {
387
+ type: 'local',
388
+ command: VENV_PYTHON,
389
+ args: [PANTHEON_VISION_SERVER],
390
+ },
391
+ cursor: {
392
+ type: 'local',
393
+ command: VENV_PYTHON,
394
+ args: [PANTHEON_VISION_SERVER],
395
+ },
396
+ windsurf: {
397
+ type: 'local',
398
+ command: VENV_PYTHON,
399
+ args: [PANTHEON_VISION_SERVER],
400
+ },
401
+ },
402
+ env: [],
403
+ validate: async () => {
404
+ const sourcePath = PANTHEON_VISION_SERVER
405
+ const requirementsPath = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
406
+ if (existsSync(sourcePath) && existsSync(requirementsPath)) {
407
+ return { ok: true, message: 'canonical server source and isolated requirements present' }
408
+ }
409
+ return { ok: false, message: 'canonical vision source or requirements not found' }
279
410
  },
280
411
  },
281
412
  docker: {
@@ -790,7 +921,7 @@ function logValidation(mcpKey, result) {
790
921
  // Main
791
922
  // ---------------------------------------------------------------------------
792
923
 
793
- async function main() {
924
+ export async function main() {
794
925
  const args = parseArgs(process.argv)
795
926
 
796
927
  if (args.help) {
@@ -918,4 +1049,6 @@ async function main() {
918
1049
  process.exit(progress.errors.length > 0 ? 1 : 0)
919
1050
  }
920
1051
 
921
- main()
1052
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
1053
+ main()
1054
+ }
@@ -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
 
@@ -233,7 +234,7 @@ async def kv_store(
233
234
 
234
235
  # Opportunistic auto-purge: if namespace has >500 entries, purge expired
235
236
  _opportunistic_auto_purge(conn, namespace)
236
-
237
+
237
238
  return {"status": "stored", "namespace": namespace, "key": key}
238
239
 
239
240
 
@@ -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
+ }