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.
- package/AGENTS.md +9 -0
- package/README.md +112 -15
- package/bin/pantheon-init.mjs +93 -0
- package/commands/pantheon-vision.md +67 -0
- package/opencode.json +14 -10
- package/package.json +20 -4
- package/scripts/_pantheon_paths.py +0 -2
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +307 -15
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +24 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +55 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +43 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/health-check.mjs +101 -1
- package/scripts/install/model-picker.mjs +118 -0
- package/scripts/install/opencode.mjs +155 -43
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install/venv.mjs +25 -2
- package/scripts/install-mcp.mjs +170 -37
- package/scripts/mcp_persistence_server.py +2 -1
- package/scripts/prune.mjs +359 -0
- package/scripts/secret-scan.mjs +88 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/validate-routing.mjs +15 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/hermes.md +2 -2
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +1 -1
- package/src/mcp/_pantheon_paths.py +0 -2
- package/src/mcp/install-mcp.mjs +6 -913
- package/src/mcp/mcp_persistence_server.py +2 -1
- package/src/mcp/pantheon_vision_server.py +495 -0
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -0
- package/src/pantheon/auto-wake.ts +91 -0
- package/src/pantheon/background-job-board.ts +568 -0
- package/src/pantheon/file-persistence.ts +95 -0
- package/src/pantheon/presets.d.mts +108 -0
- package/src/pantheon/presets.mjs +554 -0
- package/src/pantheon/vision.ts +1437 -0
- package/src/plugin.ts +69 -25
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +812 -223
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +881 -121
- package/src/plugins/tui/package.json +3 -3
- package/src/plugins/tui/src/index.tsx +881 -121
- package/src/routing.yml +148 -0
- package/src/skills/context-compression/SKILL.md +1 -1
- package/src/skills/loop-engineering/SKILL.md +1 -1
- package/src/skills/orchestration-workflow/SKILL.md +11 -11
- package/scripts/scrub_secrets.py +0 -281
- package/src/mcp/requirements-mcp-core.txt +0 -5
package/scripts/install-mcp.mjs
CHANGED
|
@@ -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: {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 =
|
|
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: '
|
|
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: {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 =
|
|
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: '
|
|
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:
|
|
218
|
-
args: [
|
|
305
|
+
command: VENV_PYTHON,
|
|
306
|
+
args: [MEMORY_MCP_SERVER],
|
|
219
307
|
},
|
|
220
308
|
claude: {
|
|
221
309
|
type: 'local',
|
|
222
|
-
command:
|
|
223
|
-
args: [
|
|
310
|
+
command: VENV_PYTHON,
|
|
311
|
+
args: [MEMORY_MCP_SERVER],
|
|
224
312
|
},
|
|
225
313
|
cursor: {
|
|
226
314
|
type: 'local',
|
|
227
|
-
command:
|
|
228
|
-
args: [
|
|
315
|
+
command: VENV_PYTHON,
|
|
316
|
+
args: [MEMORY_MCP_SERVER],
|
|
229
317
|
},
|
|
230
318
|
windsurf: {
|
|
231
319
|
type: 'local',
|
|
232
|
-
command:
|
|
233
|
-
args: [
|
|
320
|
+
command: VENV_PYTHON,
|
|
321
|
+
args: [MEMORY_MCP_SERVER],
|
|
234
322
|
},
|
|
235
323
|
},
|
|
236
324
|
env: [],
|
|
237
325
|
validate: async () => {
|
|
238
|
-
const scriptPath =
|
|
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: '
|
|
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:
|
|
254
|
-
args: [
|
|
341
|
+
command: VENV_PYTHON,
|
|
342
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
255
343
|
},
|
|
256
344
|
claude: {
|
|
257
345
|
type: 'local',
|
|
258
|
-
command:
|
|
259
|
-
args: [
|
|
346
|
+
command: VENV_PYTHON,
|
|
347
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
260
348
|
},
|
|
261
349
|
cursor: {
|
|
262
350
|
type: 'local',
|
|
263
|
-
command:
|
|
264
|
-
args: [
|
|
351
|
+
command: VENV_PYTHON,
|
|
352
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
265
353
|
},
|
|
266
354
|
windsurf: {
|
|
267
355
|
type: 'local',
|
|
268
|
-
command:
|
|
269
|
-
args: [
|
|
356
|
+
command: VENV_PYTHON,
|
|
357
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
270
358
|
},
|
|
271
359
|
},
|
|
272
360
|
env: [],
|
|
273
361
|
validate: async () => {
|
|
274
|
-
const scriptPath =
|
|
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: '
|
|
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
|
-
|
|
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
|
+
}
|