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.
- package/AGENTS.md +9 -0
- package/README.md +12 -13
- package/bin/pantheon-init.mjs +14 -0
- package/opencode.json +1 -1
- package/package.json +13 -4
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +303 -13
- 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/opencode.mjs +58 -29
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install-mcp.mjs +104 -56
- package/scripts/mcp_persistence_server.py +1 -0
- package/scripts/prune.mjs +359 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -3
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +8 -0
- package/src/plugins/tui/dist/tui.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui.js +1198 -0
- package/src/plugins/tui/dist/tui.js.map +1 -0
- package/src/plugins/tui/dist/tui.tsx +1204 -0
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +15 -0
- 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, 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:
|
|
177
|
-
args: [
|
|
224
|
+
command: VENV_PYTHON,
|
|
225
|
+
args: [MCP_RESOURCES_SERVER],
|
|
178
226
|
},
|
|
179
227
|
vscode: {
|
|
180
228
|
type: 'stdio',
|
|
181
|
-
command:
|
|
182
|
-
args: [
|
|
229
|
+
command: VENV_PYTHON,
|
|
230
|
+
args: [MCP_RESOURCES_SERVER],
|
|
183
231
|
},
|
|
184
232
|
cursor: {
|
|
185
233
|
type: 'local',
|
|
186
|
-
command:
|
|
187
|
-
args: [
|
|
234
|
+
command: VENV_PYTHON,
|
|
235
|
+
args: [MCP_RESOURCES_SERVER],
|
|
188
236
|
},
|
|
189
237
|
claude: {
|
|
190
238
|
type: 'local',
|
|
191
|
-
command:
|
|
192
|
-
args: [
|
|
239
|
+
command: VENV_PYTHON,
|
|
240
|
+
args: [MCP_RESOURCES_SERVER],
|
|
193
241
|
},
|
|
194
242
|
windsurf: {
|
|
195
243
|
type: 'local',
|
|
196
|
-
command:
|
|
197
|
-
args: [
|
|
244
|
+
command: VENV_PYTHON,
|
|
245
|
+
args: [MCP_RESOURCES_SERVER],
|
|
198
246
|
},
|
|
199
247
|
},
|
|
200
248
|
env: [],
|
|
201
249
|
validate: async () => {
|
|
202
|
-
const scriptPath =
|
|
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: '
|
|
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:
|
|
217
|
-
args: [
|
|
264
|
+
command: VENV_PYTHON,
|
|
265
|
+
args: [CODE_MODE_SERVER],
|
|
218
266
|
},
|
|
219
267
|
vscode: {
|
|
220
268
|
type: 'stdio',
|
|
221
|
-
command:
|
|
222
|
-
args: [
|
|
269
|
+
command: VENV_PYTHON,
|
|
270
|
+
args: [CODE_MODE_SERVER],
|
|
223
271
|
},
|
|
224
272
|
cursor: {
|
|
225
273
|
type: 'local',
|
|
226
|
-
command:
|
|
227
|
-
args: [
|
|
274
|
+
command: VENV_PYTHON,
|
|
275
|
+
args: [CODE_MODE_SERVER],
|
|
228
276
|
},
|
|
229
277
|
claude: {
|
|
230
278
|
type: 'local',
|
|
231
|
-
command:
|
|
232
|
-
args: [
|
|
279
|
+
command: VENV_PYTHON,
|
|
280
|
+
args: [CODE_MODE_SERVER],
|
|
233
281
|
},
|
|
234
282
|
windsurf: {
|
|
235
283
|
type: 'local',
|
|
236
|
-
command:
|
|
237
|
-
args: [
|
|
284
|
+
command: VENV_PYTHON,
|
|
285
|
+
args: [CODE_MODE_SERVER],
|
|
238
286
|
},
|
|
239
287
|
},
|
|
240
288
|
env: [],
|
|
241
289
|
validate: async () => {
|
|
242
|
-
const scriptPath =
|
|
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: '
|
|
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:
|
|
258
|
-
args: [
|
|
305
|
+
command: VENV_PYTHON,
|
|
306
|
+
args: [MEMORY_MCP_SERVER],
|
|
259
307
|
},
|
|
260
308
|
claude: {
|
|
261
309
|
type: 'local',
|
|
262
|
-
command:
|
|
263
|
-
args: [
|
|
310
|
+
command: VENV_PYTHON,
|
|
311
|
+
args: [MEMORY_MCP_SERVER],
|
|
264
312
|
},
|
|
265
313
|
cursor: {
|
|
266
314
|
type: 'local',
|
|
267
|
-
command:
|
|
268
|
-
args: [
|
|
315
|
+
command: VENV_PYTHON,
|
|
316
|
+
args: [MEMORY_MCP_SERVER],
|
|
269
317
|
},
|
|
270
318
|
windsurf: {
|
|
271
319
|
type: 'local',
|
|
272
|
-
command:
|
|
273
|
-
args: [
|
|
320
|
+
command: VENV_PYTHON,
|
|
321
|
+
args: [MEMORY_MCP_SERVER],
|
|
274
322
|
},
|
|
275
323
|
},
|
|
276
324
|
env: [],
|
|
277
325
|
validate: async () => {
|
|
278
|
-
const scriptPath =
|
|
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: '
|
|
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:
|
|
294
|
-
args: [
|
|
341
|
+
command: VENV_PYTHON,
|
|
342
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
295
343
|
},
|
|
296
344
|
claude: {
|
|
297
345
|
type: 'local',
|
|
298
|
-
command:
|
|
299
|
-
args: [
|
|
346
|
+
command: VENV_PYTHON,
|
|
347
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
300
348
|
},
|
|
301
349
|
cursor: {
|
|
302
350
|
type: 'local',
|
|
303
|
-
command:
|
|
304
|
-
args: [
|
|
351
|
+
command: VENV_PYTHON,
|
|
352
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
305
353
|
},
|
|
306
354
|
windsurf: {
|
|
307
355
|
type: 'local',
|
|
308
|
-
command:
|
|
309
|
-
args: [
|
|
356
|
+
command: VENV_PYTHON,
|
|
357
|
+
args: [MCP_PERSISTENCE_SERVER],
|
|
310
358
|
},
|
|
311
359
|
},
|
|
312
360
|
env: [],
|
|
313
361
|
validate: async () => {
|
|
314
|
-
const scriptPath =
|
|
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: '
|
|
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:
|
|
331
|
-
args: [
|
|
378
|
+
command: VENV_PYTHON,
|
|
379
|
+
args: [PANTHEON_VISION_SERVER],
|
|
332
380
|
},
|
|
333
381
|
vscode: {
|
|
334
382
|
type: 'stdio',
|
|
335
|
-
command:
|
|
336
|
-
args: [
|
|
383
|
+
command: VENV_PYTHON,
|
|
384
|
+
args: [PANTHEON_VISION_SERVER],
|
|
337
385
|
},
|
|
338
386
|
claude: {
|
|
339
387
|
type: 'local',
|
|
340
|
-
command:
|
|
341
|
-
args: [
|
|
388
|
+
command: VENV_PYTHON,
|
|
389
|
+
args: [PANTHEON_VISION_SERVER],
|
|
342
390
|
},
|
|
343
391
|
cursor: {
|
|
344
392
|
type: 'local',
|
|
345
|
-
command:
|
|
346
|
-
args: [
|
|
393
|
+
command: VENV_PYTHON,
|
|
394
|
+
args: [PANTHEON_VISION_SERVER],
|
|
347
395
|
},
|
|
348
396
|
windsurf: {
|
|
349
397
|
type: 'local',
|
|
350
|
-
command:
|
|
351
|
-
args: [
|
|
398
|
+
command: VENV_PYTHON,
|
|
399
|
+
args: [PANTHEON_VISION_SERVER],
|
|
352
400
|
},
|
|
353
401
|
},
|
|
354
402
|
env: [],
|
|
355
403
|
validate: async () => {
|
|
356
|
-
const sourcePath =
|
|
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' }
|
|
@@ -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
|
+
}
|