pantheon-opencode 1.6.0-beta.11 → 1.6.0-beta.13
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.global.md +3 -3
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +39 -0
- package/README.md +85 -31
- package/README.pt-BR.md +7 -4
- package/bin/pantheon-init.mjs +278 -102
- package/package.json +5 -3
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +77 -13
- package/scripts/check-plugin-v2-coverage.mjs +7 -7
- package/scripts/doctor.mjs +242 -19
- package/scripts/install/backup.mjs +191 -18
- package/scripts/install/cli-ui.mjs +73 -0
- package/scripts/install/file-inventory.mjs +555 -0
- package/scripts/install/interactive.mjs +69 -391
- package/scripts/install/migrate.mjs +45 -38
- package/scripts/install/model-picker.mjs +63 -22
- package/scripts/install/opencode-version.mjs +9 -1
- package/scripts/install/opencode.mjs +279 -79
- package/scripts/install/plan-artifact.mjs +517 -0
- package/scripts/install/plan.mjs +165 -3
- package/scripts/install/plugin.mjs +118 -14
- package/scripts/install/prompt-result.mjs +2 -0
- package/scripts/install/shared.mjs +188 -65
- package/scripts/install/state.mjs +63 -8
- package/scripts/install/venv.mjs +15 -6
- package/scripts/install/verify.mjs +128 -0
- package/scripts/install/vision-prompt.mjs +2 -0
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/manifest-inventory.mjs +49 -3
- package/scripts/node-engine-policy.mjs +85 -0
- package/scripts/package-evidence.mjs +6 -0
- package/scripts/postinstall.mjs +26 -26
- package/scripts/reconcile.mjs +325 -0
- package/scripts/sync-tui.mjs +5 -1
- package/scripts/uninstall.mjs +258 -34
- package/scripts/update.mjs +164 -0
- package/scripts/validate-node-engine-policy.mjs +13 -0
- package/scripts/version-check.mjs +34 -1
- package/scripts/versioning.mjs +9 -1
- package/skills-lock.json +3 -3
- package/src/agents/mnemosyne.md +7 -2
- package/src/instructions/memory-protocol.instructions.md +2 -2
- package/src/instructions/zeus-memory-operations.instructions.md +4 -3
- package/src/pantheon/v2-unsupported.mjs +2 -3
- package/src/plugins/tui/dist/tui-v2.js +2 -2
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +3 -3
- package/src/plugins/tui/package.json +2 -2
- package/src/skills/artifact-management/SKILL.md +5 -1
- package/src/skills/memory-bank/SKILL.md +5 -1
- package/src/skills/worktrees/SKILL.md +30 -11
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { createHash } from 'node:crypto'
|
|
20
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync
|
|
20
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
|
|
21
21
|
import { homedir } from 'node:os'
|
|
22
22
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
23
23
|
import { listV2UnsupportedFeatures } from '../../src/pantheon/v2-unsupported.mjs'
|
|
24
24
|
import { copyAgentPrompts, writeAgentsMdManaged } from '../build-agents-md.mjs'
|
|
25
|
-
import {
|
|
25
|
+
import { backupWarning, createBackupSet, previewBackupSet } from './backup.mjs'
|
|
26
26
|
import {
|
|
27
27
|
bullet,
|
|
28
28
|
colors,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
error,
|
|
31
31
|
info,
|
|
32
32
|
printSummary,
|
|
33
|
+
printWorktreeGuidance,
|
|
33
34
|
section,
|
|
34
35
|
spinner,
|
|
35
36
|
step,
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
migrateMcpEntrypointPaths,
|
|
43
44
|
migrateV1toV2,
|
|
44
45
|
} from './config-migration.mjs'
|
|
46
|
+
import { componentForInstalledPath, updateFileInventory } from './file-inventory.mjs'
|
|
45
47
|
import { healthCheck } from './health-check.mjs'
|
|
46
48
|
import { detectVersion, runMigrations } from './migrate.mjs'
|
|
47
49
|
import { resolveOpenCodeVersion } from './opencode-version.mjs'
|
|
@@ -49,14 +51,19 @@ import {
|
|
|
49
51
|
ACTIONS,
|
|
50
52
|
createRecorder,
|
|
51
53
|
displayPath,
|
|
54
|
+
fingerprintOutput,
|
|
52
55
|
formatChangePlan,
|
|
53
56
|
isRecording,
|
|
54
57
|
planStatsMismatch,
|
|
55
58
|
recordChange,
|
|
59
|
+
recordRemovalTree,
|
|
60
|
+
removeReviewedTree,
|
|
56
61
|
snapshot,
|
|
57
62
|
summarizeChanges,
|
|
58
63
|
} from './plan.mjs'
|
|
64
|
+
import { findPlanDrift } from './plan-artifact.mjs'
|
|
59
65
|
import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
|
|
66
|
+
import { PROMPT_CANCELED } from './prompt-result.mjs'
|
|
60
67
|
import {
|
|
61
68
|
checkRuntimePrerequisites,
|
|
62
69
|
collectSkillNames,
|
|
@@ -73,6 +80,7 @@ import { createInitialState, hasState, readState, setComponentState, writeState
|
|
|
73
80
|
import { strings } from './strings.mjs'
|
|
74
81
|
|
|
75
82
|
import { setupVenv, venvPythonPath } from './venv.mjs'
|
|
83
|
+
import { assertNoSymlinkAncestors, assertPathWithin, assertSafeRemoval } from './verify.mjs'
|
|
76
84
|
|
|
77
85
|
const COMPONENT_NAMES = [
|
|
78
86
|
'agents',
|
|
@@ -786,7 +794,14 @@ export function tuiConfigLocations(target) {
|
|
|
786
794
|
*/
|
|
787
795
|
export function syncTuiRegistration(
|
|
788
796
|
target,
|
|
789
|
-
{
|
|
797
|
+
{
|
|
798
|
+
isGlobal = false,
|
|
799
|
+
dryRun = false,
|
|
800
|
+
version = 'v1',
|
|
801
|
+
recorder = null,
|
|
802
|
+
reviewedRemovals = null,
|
|
803
|
+
reviewedSnapshots = null,
|
|
804
|
+
} = {},
|
|
790
805
|
) {
|
|
791
806
|
const configDir = isGlobal ? target : join(target, '.opencode')
|
|
792
807
|
const targetTuiConfigPath = join(configDir, 'tui.json')
|
|
@@ -796,7 +811,13 @@ export function syncTuiRegistration(
|
|
|
796
811
|
const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
|
|
797
812
|
const tuiCopyDir = resolveTuiCopyTarget(configDir)
|
|
798
813
|
if (existsSync(tuiSrcDir)) {
|
|
799
|
-
copyPluginFiles(tuiSrcDir, tuiCopyDir, {
|
|
814
|
+
copyPluginFiles(tuiSrcDir, tuiCopyDir, {
|
|
815
|
+
dryRun,
|
|
816
|
+
version,
|
|
817
|
+
recorder,
|
|
818
|
+
reviewedRemovals,
|
|
819
|
+
reviewedSnapshots,
|
|
820
|
+
})
|
|
800
821
|
}
|
|
801
822
|
|
|
802
823
|
// 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
|
|
@@ -830,7 +851,7 @@ export function syncTuiRegistration(
|
|
|
830
851
|
*/
|
|
831
852
|
export function setupRuntimePhase(
|
|
832
853
|
target,
|
|
833
|
-
{ dryRun, clean, isGlobal, recorder = null },
|
|
854
|
+
{ dryRun, clean, isGlobal, recorder = null, reviewedRemovals = null, reviewedSnapshots = null },
|
|
834
855
|
stats,
|
|
835
856
|
deps = {},
|
|
836
857
|
) {
|
|
@@ -839,7 +860,13 @@ export function setupRuntimePhase(
|
|
|
839
860
|
const check = deps.healthCheck ?? healthCheck
|
|
840
861
|
try {
|
|
841
862
|
const venvSpinner = spinner(S.settingUpRuntime)
|
|
842
|
-
setup(target, {
|
|
863
|
+
setup(target, {
|
|
864
|
+
dryRun,
|
|
865
|
+
force: clean,
|
|
866
|
+
recorder,
|
|
867
|
+
reviewedRemovals,
|
|
868
|
+
reviewedSnapshots,
|
|
869
|
+
})
|
|
843
870
|
venvSpinner(true)
|
|
844
871
|
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
845
872
|
const health = check(runtimeTarget, { dryRun, pythonTarget: target })
|
|
@@ -976,50 +1003,12 @@ function detectSnapshotDrift(snapshots) {
|
|
|
976
1003
|
return changed
|
|
977
1004
|
}
|
|
978
1005
|
|
|
979
|
-
/**
|
|
980
|
-
* Remove lingering atomic-write temp siblings (`<file>.tmp-<pid>`) left behind
|
|
981
|
-
* when a writer aborted mid-write. Best-effort; skips the bulk dirs so a failure
|
|
982
|
-
* path stays cheap.
|
|
983
|
-
*/
|
|
984
|
-
/**
|
|
985
|
-
* Remove lingering atomic-write temp siblings (`<file>.tmp-<pid>`) left when a
|
|
986
|
-
* writer aborted mid-write. Only files matching the installer's own temp
|
|
987
|
-
* pattern are removed, and the bulk/foreign dirs are skipped so a failure path
|
|
988
|
-
* stays cheap and never touches unrelated files.
|
|
989
|
-
*/
|
|
990
|
-
function cleanupTempSiblings(root) {
|
|
991
|
-
if (!root) return
|
|
992
|
-
const suffix = `.tmp-${process.pid}`
|
|
993
|
-
const skip = new Set(['.git', '.pantheon', 'node_modules', '.venv'])
|
|
994
|
-
const walk = (dir) => {
|
|
995
|
-
let entries
|
|
996
|
-
try {
|
|
997
|
-
entries = readdirSync(dir, { withFileTypes: true })
|
|
998
|
-
} catch {
|
|
999
|
-
return
|
|
1000
|
-
}
|
|
1001
|
-
for (const entry of entries) {
|
|
1002
|
-
if (entry.isDirectory()) {
|
|
1003
|
-
if (skip.has(entry.name)) continue
|
|
1004
|
-
walk(join(dir, entry.name))
|
|
1005
|
-
} else if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
1006
|
-
try {
|
|
1007
|
-
rmSync(join(dir, entry.name), { force: true })
|
|
1008
|
-
} catch {
|
|
1009
|
-
// best-effort cleanup
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
walk(root)
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
1006
|
/**
|
|
1018
1007
|
* Phase 5 failure semantics. `installPass` is the whole installer; this thin
|
|
1019
1008
|
* wrapper reports a mid-apply failure HONESTLY: it aborts the remaining writes
|
|
1020
|
-
* (the throw),
|
|
1021
|
-
* PATHS already applied (never contents), prints the recovery
|
|
1022
|
-
* re-throws so the CLI exits non-zero. There is NO automatic rollback — the
|
|
1009
|
+
* (the throw), relies on each writer to clean up only its own temporary file,
|
|
1010
|
+
* reports the PATHS already applied (never contents), prints the recovery
|
|
1011
|
+
* path, and re-throws so the CLI exits non-zero. There is NO automatic rollback — the
|
|
1023
1012
|
* exact backup set + `pantheon-opencode restore` is the recovery.
|
|
1024
1013
|
*
|
|
1025
1014
|
* Progress is tracked in `_lastRun` (set only by a non-dry pass, so the nested
|
|
@@ -1053,8 +1042,6 @@ export async function installOpenCode(
|
|
|
1053
1042
|
for (const p of paths) warning(` ${p}`)
|
|
1054
1043
|
}
|
|
1055
1044
|
warning(run.backupDir ? S.restoreRecoveryHint(run.backupDir) : S.restoreNoBackup)
|
|
1056
|
-
// Sweep only on the failure path (never a full walk on a clean apply).
|
|
1057
|
-
cleanupTempSiblings(run.target)
|
|
1058
1045
|
}
|
|
1059
1046
|
throw err
|
|
1060
1047
|
}
|
|
@@ -1079,8 +1066,14 @@ async function installPass(
|
|
|
1079
1066
|
const statsBefore = { created: stats.created, skipped: stats.skipped }
|
|
1080
1067
|
// Number of planned changes, filled by a dry plan pass (used by the gate).
|
|
1081
1068
|
let plannedChanges = 0
|
|
1069
|
+
let planFiles = []
|
|
1070
|
+
let removalPaths = []
|
|
1071
|
+
const planTimestamp = opts.planTimestamp ?? new Date().toISOString()
|
|
1072
|
+
let projectedState = null
|
|
1082
1073
|
// Phase 4: planned-target snapshots from the plan pass, used for drift checks.
|
|
1083
1074
|
let plannedSnapshots = null
|
|
1075
|
+
let reviewedRemovals = null
|
|
1076
|
+
let reviewedSnapshots = null
|
|
1084
1077
|
const S = strings()
|
|
1085
1078
|
|
|
1086
1079
|
// Beta 11 (Setup phase 1): a dry pass records every writer's intended change
|
|
@@ -1117,7 +1110,16 @@ async function installPass(
|
|
|
1117
1110
|
// the V1 branch writes .ts file paths that a 2.x host drops from the config.
|
|
1118
1111
|
// `opts` is forwarded as the resolver's options bag so a caller (and CI) can
|
|
1119
1112
|
// pin env/probe instead of spawning a real host binary.
|
|
1120
|
-
const version = resolveOpenCodeVersion(opts.version ?? 'auto',
|
|
1113
|
+
const version = resolveOpenCodeVersion(opts.version ?? 'auto', {
|
|
1114
|
+
...opts,
|
|
1115
|
+
skipProbe: dryRun,
|
|
1116
|
+
})
|
|
1117
|
+
const versionAssumed =
|
|
1118
|
+
dryRun &&
|
|
1119
|
+
(opts.version ?? 'auto') === 'auto' &&
|
|
1120
|
+
process.env.OPENCODE_VERSION !== 'v1' &&
|
|
1121
|
+
process.env.OPENCODE_VERSION !== 'v2' &&
|
|
1122
|
+
!/(?:^|[\\/])opencode2(?:\.exe)?$/i.test(process.env.OPENCODE_BIN ?? '')
|
|
1121
1123
|
if (version === 'v2') {
|
|
1122
1124
|
info(
|
|
1123
1125
|
'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
|
|
@@ -1151,8 +1153,15 @@ async function installPass(
|
|
|
1151
1153
|
const result = await runInteractiveInstall({
|
|
1152
1154
|
components,
|
|
1153
1155
|
defaultComponents: COMPONENT_NAMES,
|
|
1156
|
+
prompts: opts.deps?.prompts,
|
|
1157
|
+
isTTY: opts.deps?.isTTY,
|
|
1154
1158
|
})
|
|
1155
1159
|
|
|
1160
|
+
if (result.canceled) {
|
|
1161
|
+
info('Installation canceled.')
|
|
1162
|
+
return { ...stats, applied: false, reason: 'canceled' }
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1156
1165
|
if (!result.confirmed && !autoYes) {
|
|
1157
1166
|
info('Installation canceled.')
|
|
1158
1167
|
return { ...stats, applied: false, reason: 'canceled' }
|
|
@@ -1202,7 +1211,7 @@ async function installPass(
|
|
|
1202
1211
|
// active config directory is the project root and its agent loader scans
|
|
1203
1212
|
// <config-dir>/{agent,agents}/**, so V2 project agents must be at ./agents.
|
|
1204
1213
|
// Global installs are flat for both versions.
|
|
1205
|
-
const isGlobal = isGlobalConfigDir(target)
|
|
1214
|
+
const isGlobal = opts.global ?? isGlobalConfigDir(target)
|
|
1206
1215
|
const _subDir = isGlobal ? '' : '.opencode'
|
|
1207
1216
|
const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
|
|
1208
1217
|
|
|
@@ -1229,6 +1238,7 @@ async function installPass(
|
|
|
1229
1238
|
// plan for the review / decline preview.
|
|
1230
1239
|
planned = await installOpenCode(target, true, clean, [...componentSet], {
|
|
1231
1240
|
...opts,
|
|
1241
|
+
planTimestamp,
|
|
1232
1242
|
planOnly: true,
|
|
1233
1243
|
quiet: autoYes,
|
|
1234
1244
|
quietPlan: autoYes,
|
|
@@ -1240,6 +1250,22 @@ async function installPass(
|
|
|
1240
1250
|
}
|
|
1241
1251
|
plannedSnapshots = planned?.snapshots ?? null
|
|
1242
1252
|
|
|
1253
|
+
if (opts.expectedPlan) {
|
|
1254
|
+
const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
1255
|
+
const drift = findPlanDrift(opts.expectedPlan, {
|
|
1256
|
+
target,
|
|
1257
|
+
packageRoot: ROOT,
|
|
1258
|
+
packageVersion,
|
|
1259
|
+
files: planned?.files ?? [],
|
|
1260
|
+
snapshots: planned?.snapshots ?? [],
|
|
1261
|
+
})
|
|
1262
|
+
if (drift.length > 0) {
|
|
1263
|
+
warning('Reviewed install plan drifted; no files were written.')
|
|
1264
|
+
for (const item of drift) warning(` ${item}`)
|
|
1265
|
+
return { ...stats, applied: false, reason: 'drift', drift }
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1243
1269
|
if (!autoYes) {
|
|
1244
1270
|
const injected = opts.deps?.promptReview
|
|
1245
1271
|
const canPrompt = typeof injected === 'function' || interactive
|
|
@@ -1248,7 +1274,13 @@ async function installPass(
|
|
|
1248
1274
|
const ask = typeof injected === 'function' ? injected : promptReview
|
|
1249
1275
|
const accepted = await ask(`Apply these ${planned?.planned ?? 0} changes?`, {
|
|
1250
1276
|
default: false,
|
|
1277
|
+
prompts: opts.deps?.prompts,
|
|
1278
|
+
isTTY: opts.deps?.isTTY,
|
|
1251
1279
|
})
|
|
1280
|
+
if (accepted === PROMPT_CANCELED) {
|
|
1281
|
+
info(S.nothingApplied)
|
|
1282
|
+
return { ...stats, applied: false, reason: 'canceled' }
|
|
1283
|
+
}
|
|
1252
1284
|
if (!accepted) {
|
|
1253
1285
|
info(S.nothingApplied)
|
|
1254
1286
|
return { ...stats, applied: false, reason: 'declined' }
|
|
@@ -1275,6 +1307,10 @@ async function installPass(
|
|
|
1275
1307
|
drift: drifted.map((p) => displayPath(target, p)),
|
|
1276
1308
|
}
|
|
1277
1309
|
}
|
|
1310
|
+
reviewedRemovals = new Set((planned?.removals ?? []).map((filePath) => resolve(filePath)))
|
|
1311
|
+
reviewedSnapshots = new Map(
|
|
1312
|
+
(plannedSnapshots ?? []).map((before) => [resolve(before.path), before]),
|
|
1313
|
+
)
|
|
1278
1314
|
}
|
|
1279
1315
|
|
|
1280
1316
|
// -----------------------------------------------------------------------
|
|
@@ -1284,25 +1320,57 @@ async function installPass(
|
|
|
1284
1320
|
// out-of-target global files are recorded in the manifest as
|
|
1285
1321
|
// `notRevertible`. A dry run records the planned backup-manifest path only.
|
|
1286
1322
|
// -----------------------------------------------------------------------
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1323
|
+
if (componentSet.has('agents')) {
|
|
1324
|
+
const agentTarget = join(target, agentPrefix)
|
|
1325
|
+
assertPathWithin(target, agentTarget)
|
|
1326
|
+
assertNoSymlinkAncestors(agentTarget)
|
|
1327
|
+
}
|
|
1328
|
+
if (componentSet.has('skills')) {
|
|
1329
|
+
const skillsTarget = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
|
|
1330
|
+
assertPathWithin(target, skillsTarget)
|
|
1331
|
+
assertNoSymlinkAncestors(skillsTarget)
|
|
1332
|
+
}
|
|
1333
|
+
const backupPlan = collectInstallBackupEntries(target, componentSet, version, isGlobal)
|
|
1334
|
+
const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
1291
1335
|
if (dryRun) {
|
|
1336
|
+
const preview = previewBackupSet({
|
|
1337
|
+
configDir: target,
|
|
1338
|
+
entries: backupPlan.entries,
|
|
1339
|
+
notRevertible: backupPlan.notRevertible,
|
|
1340
|
+
packageVersion: pkgVersion,
|
|
1341
|
+
clock: () => new Date(planTimestamp),
|
|
1342
|
+
})
|
|
1292
1343
|
if (isRecording(recorder)) {
|
|
1344
|
+
for (const file of preview.files) {
|
|
1345
|
+
recordChange(
|
|
1346
|
+
{
|
|
1347
|
+
path: file.path,
|
|
1348
|
+
action: ACTIONS.CREATE,
|
|
1349
|
+
bytes: file.bytes,
|
|
1350
|
+
outputFingerprint: file.outputFingerprint,
|
|
1351
|
+
scored: false,
|
|
1352
|
+
},
|
|
1353
|
+
recorder,
|
|
1354
|
+
)
|
|
1355
|
+
}
|
|
1293
1356
|
recordChange(
|
|
1294
|
-
{
|
|
1357
|
+
{
|
|
1358
|
+
path: preview.manifestPath,
|
|
1359
|
+
action: ACTIONS.CREATE,
|
|
1360
|
+
bytes: preview.manifestBytes,
|
|
1361
|
+
outputFingerprint: preview.manifestFingerprint,
|
|
1362
|
+
scored: false,
|
|
1363
|
+
},
|
|
1295
1364
|
recorder,
|
|
1296
1365
|
)
|
|
1297
1366
|
}
|
|
1298
1367
|
} else {
|
|
1299
|
-
const backupPlan = collectInstallBackupEntries(target, componentSet, version, isGlobal)
|
|
1300
|
-
const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
1301
1368
|
const { dir: backupDir } = createBackupSet({
|
|
1302
1369
|
configDir: target,
|
|
1303
1370
|
entries: backupPlan.entries,
|
|
1304
1371
|
notRevertible: backupPlan.notRevertible,
|
|
1305
1372
|
packageVersion: pkgVersion,
|
|
1373
|
+
clock: () => new Date(planTimestamp),
|
|
1306
1374
|
})
|
|
1307
1375
|
if (_lastRun && _lastRun.target === target) _lastRun.backupDir = backupDir
|
|
1308
1376
|
// Secret-safe: the path only. The exact wording is fixed (once per apply).
|
|
@@ -1325,11 +1393,23 @@ async function installPass(
|
|
|
1325
1393
|
} else {
|
|
1326
1394
|
const agentStep = step(S.installingAgents)
|
|
1327
1395
|
const dstDir = join(target, agentPrefix)
|
|
1328
|
-
|
|
1329
|
-
|
|
1396
|
+
assertPathWithin(target, dstDir)
|
|
1397
|
+
assertNoSymlinkAncestors(dstDir)
|
|
1398
|
+
if (!dryRun) {
|
|
1399
|
+
mkdirSync(dstDir, { recursive: true })
|
|
1400
|
+
assertNoSymlinkAncestors(dstDir)
|
|
1401
|
+
}
|
|
1402
|
+
if (clean && existsSync(dstDir)) {
|
|
1403
|
+
assertNoSymlinkAncestors(dstDir)
|
|
1330
1404
|
const existing = readdirSync(dstDir)
|
|
1331
1405
|
for (const f of existing) {
|
|
1332
|
-
|
|
1406
|
+
const agentPath = join(dstDir, f)
|
|
1407
|
+
assertSafeRemoval(dstDir, agentPath)
|
|
1408
|
+
if (dryRun) {
|
|
1409
|
+
recordRemovalTree(agentPath, recorder)
|
|
1410
|
+
} else {
|
|
1411
|
+
removeReviewedTree(agentPath, reviewedRemovals, reviewedSnapshots)
|
|
1412
|
+
}
|
|
1333
1413
|
}
|
|
1334
1414
|
}
|
|
1335
1415
|
const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun, false, recorder)
|
|
@@ -1363,10 +1443,19 @@ async function installPass(
|
|
|
1363
1443
|
if (skillNames.length > 0) {
|
|
1364
1444
|
const skillStep = step(`Installing ${skillNames.length} skills`)
|
|
1365
1445
|
const dstSkillsDir = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
|
|
1366
|
-
|
|
1446
|
+
assertPathWithin(target, dstSkillsDir)
|
|
1447
|
+
assertNoSymlinkAncestors(dstSkillsDir)
|
|
1448
|
+
if (clean && existsSync(dstSkillsDir)) {
|
|
1449
|
+
assertNoSymlinkAncestors(dstSkillsDir)
|
|
1367
1450
|
const existing = readdirSync(dstSkillsDir)
|
|
1368
1451
|
for (const s of existing) {
|
|
1369
|
-
|
|
1452
|
+
const skillPath = join(dstSkillsDir, s)
|
|
1453
|
+
assertSafeRemoval(dstSkillsDir, skillPath)
|
|
1454
|
+
if (dryRun) {
|
|
1455
|
+
recordRemovalTree(skillPath, recorder)
|
|
1456
|
+
} else {
|
|
1457
|
+
removeReviewedTree(skillPath, reviewedRemovals, reviewedSnapshots)
|
|
1458
|
+
}
|
|
1370
1459
|
}
|
|
1371
1460
|
}
|
|
1372
1461
|
const installSubDir = isGlobal ? '' : '.opencode'
|
|
@@ -1376,6 +1465,9 @@ async function installPass(
|
|
|
1376
1465
|
dryRun,
|
|
1377
1466
|
installSubDir,
|
|
1378
1467
|
recorder,
|
|
1468
|
+
reviewedRemovals,
|
|
1469
|
+
reviewedSnapshots,
|
|
1470
|
+
clean,
|
|
1379
1471
|
)
|
|
1380
1472
|
stats.created += sCreated
|
|
1381
1473
|
stats.skipped += sSkipped
|
|
@@ -1434,7 +1526,16 @@ async function installPass(
|
|
|
1434
1526
|
const srcPrompts = join(ROOT, 'prompts')
|
|
1435
1527
|
const dstPrompts = join(target, 'prompts')
|
|
1436
1528
|
if (existsSync(srcPrompts)) {
|
|
1437
|
-
const promptsResult = syncDir(
|
|
1529
|
+
const promptsResult = syncDir(
|
|
1530
|
+
srcPrompts,
|
|
1531
|
+
dstPrompts,
|
|
1532
|
+
dryRun,
|
|
1533
|
+
clean,
|
|
1534
|
+
null,
|
|
1535
|
+
recorder,
|
|
1536
|
+
reviewedRemovals,
|
|
1537
|
+
reviewedSnapshots,
|
|
1538
|
+
)
|
|
1438
1539
|
stats.created += promptsResult.created
|
|
1439
1540
|
stats.skipped += promptsResult.skipped
|
|
1440
1541
|
}
|
|
@@ -1452,7 +1553,16 @@ async function installPass(
|
|
|
1452
1553
|
const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
|
|
1453
1554
|
const dstCmds = join(target, commandDir)
|
|
1454
1555
|
if (existsSync(srcCmds)) {
|
|
1455
|
-
const cmdResult = syncDir(
|
|
1556
|
+
const cmdResult = syncDir(
|
|
1557
|
+
srcCmds,
|
|
1558
|
+
dstCmds,
|
|
1559
|
+
dryRun,
|
|
1560
|
+
clean,
|
|
1561
|
+
(f) => f.endsWith('.md'),
|
|
1562
|
+
recorder,
|
|
1563
|
+
reviewedRemovals,
|
|
1564
|
+
reviewedSnapshots,
|
|
1565
|
+
)
|
|
1456
1566
|
stats.created += cmdResult.created
|
|
1457
1567
|
stats.skipped += cmdResult.skipped
|
|
1458
1568
|
}
|
|
@@ -1480,6 +1590,8 @@ async function installPass(
|
|
|
1480
1590
|
clean,
|
|
1481
1591
|
version,
|
|
1482
1592
|
recorder,
|
|
1593
|
+
reviewedRemovals,
|
|
1594
|
+
reviewedSnapshots,
|
|
1483
1595
|
})
|
|
1484
1596
|
stats.created += pluginStats.created
|
|
1485
1597
|
stats.skipped += pluginStats.skipped
|
|
@@ -1492,7 +1604,14 @@ async function installPass(
|
|
|
1492
1604
|
if (componentSet.has('plugins')) {
|
|
1493
1605
|
let tuiStatus
|
|
1494
1606
|
try {
|
|
1495
|
-
tuiStatus = syncTuiRegistration(target, {
|
|
1607
|
+
tuiStatus = syncTuiRegistration(target, {
|
|
1608
|
+
isGlobal,
|
|
1609
|
+
dryRun,
|
|
1610
|
+
version,
|
|
1611
|
+
recorder,
|
|
1612
|
+
reviewedRemovals,
|
|
1613
|
+
reviewedSnapshots,
|
|
1614
|
+
})
|
|
1496
1615
|
} catch (cause) {
|
|
1497
1616
|
error(`TUI plugin setup failed: ${cause.message}`)
|
|
1498
1617
|
stats.errors++
|
|
@@ -1581,6 +1700,8 @@ async function installPass(
|
|
|
1581
1700
|
clean,
|
|
1582
1701
|
(f) => f !== 'manifest.json',
|
|
1583
1702
|
recorder,
|
|
1703
|
+
reviewedRemovals,
|
|
1704
|
+
reviewedSnapshots,
|
|
1584
1705
|
)
|
|
1585
1706
|
stats.created += cmResult.created
|
|
1586
1707
|
stats.skipped += cmResult.skipped
|
|
@@ -1625,7 +1746,11 @@ async function installPass(
|
|
|
1625
1746
|
// -----------------------------------------------------------------------
|
|
1626
1747
|
let runtimeHealthy = !componentSet.has('runtime')
|
|
1627
1748
|
if (componentSet.has('runtime')) {
|
|
1628
|
-
runtimeHealthy = setupRuntimePhase(
|
|
1749
|
+
runtimeHealthy = setupRuntimePhase(
|
|
1750
|
+
target,
|
|
1751
|
+
{ dryRun, clean, isGlobal, recorder, reviewedRemovals, reviewedSnapshots },
|
|
1752
|
+
stats,
|
|
1753
|
+
)
|
|
1629
1754
|
}
|
|
1630
1755
|
|
|
1631
1756
|
// -----------------------------------------------------------------------
|
|
@@ -2095,7 +2220,12 @@ async function installPass(
|
|
|
2095
2220
|
if (componentSet.has('runtime')) {
|
|
2096
2221
|
const currentVersion = detectVersion(target)
|
|
2097
2222
|
if (currentVersion) {
|
|
2098
|
-
const migration = runMigrations(target, currentVersion, {
|
|
2223
|
+
const migration = runMigrations(target, currentVersion, {
|
|
2224
|
+
dryRun,
|
|
2225
|
+
recorder,
|
|
2226
|
+
updatedAt: planTimestamp,
|
|
2227
|
+
})
|
|
2228
|
+
if (migration.plannedState) projectedState = migration.plannedState
|
|
2099
2229
|
if (migration.applied > 0) {
|
|
2100
2230
|
success(`Applied ${migration.applied} migration(s)`)
|
|
2101
2231
|
for (const msg of migration.messages) {
|
|
@@ -2105,29 +2235,66 @@ async function installPass(
|
|
|
2105
2235
|
}
|
|
2106
2236
|
}
|
|
2107
2237
|
|
|
2238
|
+
if (opts.preset && dryRun) {
|
|
2239
|
+
const { writeActivePreset } = await import('./model-picker.mjs')
|
|
2240
|
+
writeActivePreset(target, opts.preset, {
|
|
2241
|
+
dryRun: true,
|
|
2242
|
+
source: 'cli',
|
|
2243
|
+
updatedAt: planTimestamp,
|
|
2244
|
+
recorder,
|
|
2245
|
+
logger: { log() {} },
|
|
2246
|
+
})
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2108
2249
|
// -----------------------------------------------------------------------
|
|
2109
2250
|
// 3.75 Preview summary (Beta 11 Setup phase 1): print the REAL per-file
|
|
2110
2251
|
// change set this same pass would apply. Paths + action + byte size
|
|
2111
2252
|
// ONLY — never file contents or hashes. Nothing was written.
|
|
2112
2253
|
// -----------------------------------------------------------------------
|
|
2113
2254
|
if (preview) {
|
|
2114
|
-
//
|
|
2115
|
-
//
|
|
2116
|
-
|
|
2255
|
+
// Project the terminal state write using the plan's stable timestamp so
|
|
2256
|
+
// saved-plan apply can validate its exact output fingerprint.
|
|
2257
|
+
const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
2258
|
+
let state = projectedState ?? readState(target)
|
|
2259
|
+
if (!state) state = createInitialState(packageVersion, planTimestamp)
|
|
2260
|
+
state.pantheon_version = packageVersion
|
|
2261
|
+
state.updated_at = planTimestamp
|
|
2262
|
+
state.schema_version = 2
|
|
2263
|
+
for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
|
|
2264
|
+
if (!componentSet.has(component)) continue
|
|
2265
|
+
const componentStatus = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
|
|
2266
|
+
const componentVersion = state.components?.[component]?.version ?? 1
|
|
2267
|
+
setComponentState(state, component, componentVersion, componentStatus)
|
|
2268
|
+
}
|
|
2269
|
+
const stateContent = `${JSON.stringify(state, null, 2)}\n`
|
|
2117
2270
|
recordChange(
|
|
2118
2271
|
{
|
|
2119
2272
|
path: join(target, '.pantheon', 'install-state.json'),
|
|
2120
2273
|
action: hasState(target) ? ACTIONS.UPDATE : ACTIONS.CREATE,
|
|
2121
|
-
bytes:
|
|
2274
|
+
bytes: Buffer.byteLength(stateContent, 'utf8'),
|
|
2275
|
+
outputFingerprint: fingerprintOutput(stateContent),
|
|
2122
2276
|
scored: false,
|
|
2123
2277
|
},
|
|
2124
2278
|
recorder,
|
|
2125
2279
|
)
|
|
2126
2280
|
const plan = summarizeChanges(preview.entries)
|
|
2281
|
+
planFiles = plan.files
|
|
2127
2282
|
plannedChanges = plan.totals.create + plan.totals.update + plan.totals.remove
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2283
|
+
removalPaths = [
|
|
2284
|
+
...new Set(
|
|
2285
|
+
preview.entries
|
|
2286
|
+
.filter((entry) => entry.action === ACTIONS.REMOVE)
|
|
2287
|
+
.map((entry) => resolve(entry.path)),
|
|
2288
|
+
),
|
|
2289
|
+
].sort()
|
|
2290
|
+
// Snapshot both net changes and every raw removal target. A later write can
|
|
2291
|
+
// make a removal path look unchanged in the net plan, but it still needs
|
|
2292
|
+
// review before the clean operation deletes and recreates it.
|
|
2293
|
+
const snapshotPaths = new Set([...plan.files.map((file) => file.path), ...removalPaths])
|
|
2294
|
+
plannedSnapshots = [...snapshotPaths].map((filePath) => ({
|
|
2295
|
+
path: filePath,
|
|
2296
|
+
...snapshot(filePath),
|
|
2297
|
+
}))
|
|
2131
2298
|
const statsDelta = {
|
|
2132
2299
|
created: stats.created - statsBefore.created,
|
|
2133
2300
|
skipped: stats.skipped - statsBefore.skipped,
|
|
@@ -2148,6 +2315,13 @@ async function installPass(
|
|
|
2148
2315
|
// it — a real apply is not a dry run.
|
|
2149
2316
|
if (!opts.quietPlan) {
|
|
2150
2317
|
console.log('')
|
|
2318
|
+
if (removalPaths.length > 0) {
|
|
2319
|
+
console.log(tag(`Removal review — ${removalPaths.length} path(s):`))
|
|
2320
|
+
for (const filePath of removalPaths) {
|
|
2321
|
+
console.log(tag(` remove ${displayPath(target, filePath)}`))
|
|
2322
|
+
}
|
|
2323
|
+
console.log('')
|
|
2324
|
+
}
|
|
2151
2325
|
for (const line of formatChangePlan(target, plan).split('\n')) {
|
|
2152
2326
|
console.log(tag(line))
|
|
2153
2327
|
}
|
|
@@ -2194,6 +2368,7 @@ async function installPass(
|
|
|
2194
2368
|
` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
|
|
2195
2369
|
)
|
|
2196
2370
|
process.stdout.write('\n')
|
|
2371
|
+
printWorktreeGuidance({ projectRoot: process.cwd(), interactive: true })
|
|
2197
2372
|
} else if (!opts.planOnly) {
|
|
2198
2373
|
printSummary(target, ['opencode'], stats)
|
|
2199
2374
|
}
|
|
@@ -2211,7 +2386,7 @@ async function installPass(
|
|
|
2211
2386
|
|
|
2212
2387
|
if (opts.preset && !dryRun) {
|
|
2213
2388
|
const { writeActivePreset } = await import('./model-picker.mjs')
|
|
2214
|
-
writeActivePreset(target, opts.preset, { source: 'cli' })
|
|
2389
|
+
writeActivePreset(target, opts.preset, { source: 'cli', updatedAt: planTimestamp, recorder })
|
|
2215
2390
|
}
|
|
2216
2391
|
|
|
2217
2392
|
// -----------------------------------------------------------------------
|
|
@@ -2242,19 +2417,39 @@ async function installPass(
|
|
|
2242
2417
|
// to `doctor` forever. Unselected components retain their previous state.
|
|
2243
2418
|
if (!dryRun) {
|
|
2244
2419
|
const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
2245
|
-
let state = readState(target)
|
|
2420
|
+
let state = projectedState ?? readState(target)
|
|
2246
2421
|
if (!state && hasState(target)) {
|
|
2247
2422
|
throw new Error('Installation state exists but cannot be read; refusing to replace it')
|
|
2248
2423
|
}
|
|
2249
|
-
state ??= createInitialState(packageVersion)
|
|
2424
|
+
state ??= createInitialState(packageVersion, planTimestamp)
|
|
2250
2425
|
state.pantheon_version = packageVersion
|
|
2426
|
+
state.updated_at = planTimestamp
|
|
2251
2427
|
for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
|
|
2252
2428
|
if (!componentSet.has(component)) continue
|
|
2253
2429
|
const status = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
|
|
2254
2430
|
const componentVersion = state.components?.[component]?.version ?? 1
|
|
2255
2431
|
setComponentState(state, component, componentVersion, status)
|
|
2256
2432
|
}
|
|
2257
|
-
|
|
2433
|
+
const verifiedFiles = summarizeChanges(ownRecorder?.entries ?? []).files.flatMap((file) => {
|
|
2434
|
+
if (!['create', 'update', 'unchanged'].includes(file.action) || !file.outputFingerprint)
|
|
2435
|
+
return []
|
|
2436
|
+
const owner = componentForInstalledPath(target, file.path, { isGlobal, version })
|
|
2437
|
+
if (!owner) return []
|
|
2438
|
+
return [
|
|
2439
|
+
{
|
|
2440
|
+
path: file.path,
|
|
2441
|
+
sha256: file.outputFingerprint,
|
|
2442
|
+
component: owner.component,
|
|
2443
|
+
layout: owner.layout,
|
|
2444
|
+
action: file.action,
|
|
2445
|
+
},
|
|
2446
|
+
]
|
|
2447
|
+
})
|
|
2448
|
+
updateFileInventory(state, target, {
|
|
2449
|
+
selectedComponents: [...componentSet],
|
|
2450
|
+
verifiedFiles,
|
|
2451
|
+
})
|
|
2452
|
+
writeState(target, state, { updatedAt: planTimestamp })
|
|
2258
2453
|
}
|
|
2259
2454
|
|
|
2260
2455
|
// Beta 11 Setup phase 3: report whether this call APPLIED, so the caller can
|
|
@@ -2267,6 +2462,11 @@ async function installPass(
|
|
|
2267
2462
|
reason: 'plan',
|
|
2268
2463
|
planned: plannedChanges,
|
|
2269
2464
|
snapshots: plannedSnapshots,
|
|
2465
|
+
files: planFiles,
|
|
2466
|
+
removals: removalPaths,
|
|
2467
|
+
target,
|
|
2468
|
+
openCodeVersion: version,
|
|
2469
|
+
versionAssumed,
|
|
2270
2470
|
}
|
|
2271
2471
|
: { ...stats, applied: true, reason: 'applied' }
|
|
2272
2472
|
}
|