pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11
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 +206 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +350 -0
- package/README.md +561 -52
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +70 -4
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-vision.md +61 -12
- package/package.json +24 -17
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +428 -56
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/{code_mode_server.py → code_mode.py} +3 -3
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +151 -1
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +16 -2
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +1175 -215
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +62 -14
- package/scripts/install/shared.mjs +190 -48
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +171 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/scripts/probe-context-rehydrate.mjs +8 -2
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +180 -26
- package/scripts/validate-routing.mjs +3 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +8 -8
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +7 -1
- package/src/agents/mnemosyne.md +18 -54
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -172
- package/src/instructions/agent-return-format.instructions.md +34 -17
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -110
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +20 -67
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
- package/src/instructions/zeus-memory-operations.instructions.md +30 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
- package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +403 -102
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +418 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1160 -115
- package/src/plugin-v2.ts +1158 -219
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5516 -1353
- package/src/plugins/tui/package.json +22 -12
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -21
- package/src/skills/README.md +3 -3
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +52 -35
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/opencode.json +0 -58
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
import {
|
|
7
|
-
copyFileSync,
|
|
8
|
-
cpSync,
|
|
9
7
|
existsSync,
|
|
8
|
+
lstatSync,
|
|
10
9
|
mkdirSync,
|
|
11
10
|
readdirSync,
|
|
12
11
|
readFileSync,
|
|
@@ -15,7 +14,7 @@ import {
|
|
|
15
14
|
statSync,
|
|
16
15
|
writeFileSync,
|
|
17
16
|
} from 'node:fs'
|
|
18
|
-
import {
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
19
18
|
import { fileURLToPath } from 'node:url'
|
|
20
19
|
|
|
21
20
|
const REQUIRED_NODE_MAJOR = 18
|
|
@@ -24,7 +23,9 @@ if (parseInt(process.versions.node.split('.')[0], 10) < REQUIRED_NODE_MAJOR) {
|
|
|
24
23
|
process.exit(1)
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
import
|
|
26
|
+
import { dump, load } from 'js-yaml'
|
|
27
|
+
|
|
28
|
+
import { ACTIONS, isRecording, recordChange } from './plan.mjs'
|
|
28
29
|
|
|
29
30
|
export const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
30
31
|
export const ROOT = join(__dirname, '..', '..')
|
|
@@ -140,33 +141,6 @@ export function resolveTarget(target) {
|
|
|
140
141
|
return join(process.cwd(), target)
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
export function createBackup(target) {
|
|
144
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
145
|
-
const backupDir = join(target, '..', `.pantheon-bak-${basename(target)}-${timestamp}`)
|
|
146
|
-
|
|
147
|
-
console.log(`\n 💾 Creating backup: ${backupDir}`)
|
|
148
|
-
mkdirSync(backupDir, { recursive: true })
|
|
149
|
-
|
|
150
|
-
// Copy only relevant Pantheon directories/files
|
|
151
|
-
const items = ['agents', 'skills', 'commands', 'instructions', 'opencode.json', 'tui.json']
|
|
152
|
-
let count = 0
|
|
153
|
-
for (const item of items) {
|
|
154
|
-
const src = join(target, item)
|
|
155
|
-
try {
|
|
156
|
-
cpSync(src, join(backupDir, item), {
|
|
157
|
-
recursive: true,
|
|
158
|
-
errorOnExist: false,
|
|
159
|
-
})
|
|
160
|
-
count++
|
|
161
|
-
} catch {
|
|
162
|
-
// File or directory doesn't exist in target — skip silently
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
console.log(` ✅ Backed up ${count} items to: ${backupDir}`)
|
|
167
|
-
return backupDir
|
|
168
|
-
}
|
|
169
|
-
|
|
170
144
|
export function detectAndReport(target) {
|
|
171
145
|
const found = detectPlatforms(target).length > 0
|
|
172
146
|
|
|
@@ -257,32 +231,161 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
|
|
|
257
231
|
return { created, skipped }
|
|
258
232
|
}
|
|
259
233
|
|
|
260
|
-
|
|
261
|
-
|
|
234
|
+
/**
|
|
235
|
+
* `lstat` that returns `null` instead of throwing for an absent path.
|
|
236
|
+
*
|
|
237
|
+
* Deliberately `lstat`, not `stat`/`existsSync`: both of those follow the link,
|
|
238
|
+
* so a DANGLING symlink reads as absent and the atomic rename below would then
|
|
239
|
+
* silently replace the link itself. `lstat` sees the link and lets the write
|
|
240
|
+
* guard refuse it.
|
|
241
|
+
*/
|
|
242
|
+
function lstatOrNull(filePath) {
|
|
243
|
+
try {
|
|
244
|
+
return lstatSync(filePath)
|
|
245
|
+
} catch {
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Read a path's raw text, or `null` when it cannot be read (absent, dangling
|
|
252
|
+
* symlink target, permission error). The caller decides what "unreadable"
|
|
253
|
+
* means; this never throws.
|
|
254
|
+
*/
|
|
255
|
+
function readFileOrNull(filePath) {
|
|
256
|
+
try {
|
|
257
|
+
return readFileSync(filePath, 'utf8')
|
|
258
|
+
} catch {
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Atomically create/overwrite a text file, skipping an unchanged write.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} filePath - destination path
|
|
267
|
+
* @param {string} content - full file content
|
|
268
|
+
* @param {boolean} dryRun - when true, compute the outcome without writing
|
|
269
|
+
* @param {object} [options]
|
|
270
|
+
* @param {string|null} [options.expectedContent] - the raw content the caller
|
|
271
|
+
* read this path from, if any. When supplied, the write proceeds only if the
|
|
272
|
+
* on-disk content STILL matches it — compared once up front AND again
|
|
273
|
+
* immediately before the rename; otherwise it returns `'conflict'` and leaves
|
|
274
|
+
* the file alone. This is a best-effort COMPARE-BEFORE-WRITE guard, NOT a
|
|
275
|
+
* filesystem CAS: the final re-read and the rename are still two syscalls, so
|
|
276
|
+
* a writer that lands between them is not prevented. What it does close is
|
|
277
|
+
* the window the installer actually races with — a config the caller parsed
|
|
278
|
+
* going stale during the same run — and it keeps the target consistent with
|
|
279
|
+
* the bytes that were checked.
|
|
280
|
+
* @param {(existing: string|null) => void} [options.beforeRecheck] - runs after
|
|
281
|
+
* the initial content check and before the last-moment re-read. Injection
|
|
282
|
+
* point so tests can simulate a concurrent writer landing in the window;
|
|
283
|
+
* production callers omit it (undefined is a no-op).
|
|
284
|
+
* @param {(existing: string|null) => void} [options.beforeSwap] - runs after the
|
|
285
|
+
* expected-content checks pass and immediately before the rename, for side
|
|
286
|
+
* effects that must not fire on a conflict.
|
|
287
|
+
* @param {(change: object) => void} [options.recorder] - optional change
|
|
288
|
+
* recorder. When supplied, the outcome is recorded (path + action + byte
|
|
289
|
+
* size only) so `--dry-run` can print the real per-file plan. Defaults to
|
|
290
|
+
* the module-level no-op singleton, so an uninstrumented caller is
|
|
291
|
+
* byte-identical to before.
|
|
292
|
+
* @param {boolean} [options.scored] - set `false` when this write is not part
|
|
293
|
+
* of the install summary (e.g. stale-ref cleanup), so it is shown in the plan
|
|
294
|
+
* but excluded from the consistency guard.
|
|
295
|
+
* @returns {'created'|'skipped'|'conflict'}
|
|
296
|
+
*/
|
|
297
|
+
export function writeIfChanged(filePath, content, dryRun, options = {}) {
|
|
298
|
+
const { expectedContent, beforeSwap, beforeRecheck, recorder, scored } = options
|
|
299
|
+
|
|
300
|
+
// Refuse to write through/over a symlink. A dangling symlink is invisible to
|
|
301
|
+
// existsSync, and rename-over-link would replace the link rather than its
|
|
302
|
+
// target — both are silent redirections the installer must not perform.
|
|
303
|
+
const stat = lstatOrNull(filePath)
|
|
304
|
+
if (stat?.isSymbolicLink()) {
|
|
305
|
+
throw new Error(`Refusing to write through symlink: ${filePath}`)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const existing = stat === null ? null : readFileOrNull(filePath)
|
|
309
|
+
const record = (outcome) => {
|
|
310
|
+
if (!isRecording(recorder)) return
|
|
311
|
+
const action =
|
|
312
|
+
outcome === 'conflict'
|
|
313
|
+
? ACTIONS.CONFLICT
|
|
314
|
+
: outcome === 'skipped'
|
|
315
|
+
? ACTIONS.UNCHANGED
|
|
316
|
+
: existing === null
|
|
317
|
+
? ACTIONS.CREATE
|
|
318
|
+
: ACTIONS.UPDATE
|
|
319
|
+
recordChange(
|
|
320
|
+
{ path: filePath, action, bytes: Buffer.byteLength(content, 'utf8'), scored },
|
|
321
|
+
recorder,
|
|
322
|
+
)
|
|
323
|
+
}
|
|
262
324
|
if (existing === content) {
|
|
325
|
+
record('skipped')
|
|
263
326
|
return 'skipped'
|
|
264
327
|
}
|
|
328
|
+
if (expectedContent !== undefined && existing !== expectedContent) {
|
|
329
|
+
// The file changed since the caller read it (or appeared/disappeared).
|
|
330
|
+
// Do not clobber the newer content; the caller decides how to react.
|
|
331
|
+
record('conflict')
|
|
332
|
+
return 'conflict'
|
|
333
|
+
}
|
|
265
334
|
if (!dryRun) {
|
|
335
|
+
// Seam for a concurrent writer to land between the read above and the
|
|
336
|
+
// re-read below. No production caller passes this.
|
|
337
|
+
if (typeof beforeRecheck === 'function') beforeRecheck(existing)
|
|
338
|
+
|
|
266
339
|
// Atomic write: write a temp sibling and rename over the target so a
|
|
267
340
|
// crash mid-write can never leave a truncated file behind (rename is
|
|
268
341
|
// atomic within the same directory).
|
|
269
342
|
const tmpPath = `${filePath}.tmp-${process.pid}`
|
|
270
343
|
writeFileSync(tmpPath, content, 'utf8')
|
|
344
|
+
|
|
345
|
+
// Last-moment re-read. The initial compare and this rename are separate
|
|
346
|
+
// syscalls, so a concurrent writer can land in between; re-check the
|
|
347
|
+
// expected bytes immediately before the swap. On a mismatch, drop the temp
|
|
348
|
+
// and leave the target byte-identical.
|
|
349
|
+
if (expectedContent !== undefined) {
|
|
350
|
+
const current = readFileOrNull(filePath)
|
|
351
|
+
if (current !== expectedContent) {
|
|
352
|
+
rmSync(tmpPath, { force: true })
|
|
353
|
+
record('conflict')
|
|
354
|
+
return 'conflict'
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (typeof beforeSwap === 'function') beforeSwap(existing)
|
|
271
359
|
renameSync(tmpPath, filePath)
|
|
272
360
|
}
|
|
361
|
+
record('created')
|
|
273
362
|
return 'created'
|
|
274
363
|
}
|
|
275
364
|
|
|
276
365
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
366
|
+
* Write a config file atomically, honoring the compare-before-write guard.
|
|
367
|
+
* This used to always keep a `<config>.bak` sidecar; the main `opencode.json`
|
|
368
|
+
* no longer does — the target-scoped exact backup set
|
|
369
|
+
* (scripts/install/backup.mjs) preserves it byte-for-byte and
|
|
370
|
+
* `pantheon-opencode restore` reverts it.
|
|
371
|
+
*
|
|
372
|
+
* A caller may still supply `beforeSwap` for a file the exact set does NOT
|
|
373
|
+
* cover: the GLOBAL `opencode-vision.json` lives outside the config dir, so
|
|
374
|
+
* vision-prompt passes a `beforeSwap` that keeps its `<file>.bak` recovery.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} filePath
|
|
377
|
+
* @param {string} content
|
|
378
|
+
* @param {boolean} dryRun
|
|
379
|
+
* @param {object} [options] - forwarded to {@link writeIfChanged}
|
|
280
380
|
*/
|
|
281
|
-
export function writeConfigWithBackup(filePath, content, dryRun) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
381
|
+
export function writeConfigWithBackup(filePath, content, dryRun, options = {}) {
|
|
382
|
+
return writeIfChanged(filePath, content, dryRun, {
|
|
383
|
+
expectedContent: options.expectedContent,
|
|
384
|
+
beforeRecheck: options.beforeRecheck,
|
|
385
|
+
beforeSwap: options.beforeSwap,
|
|
386
|
+
recorder: options.recorder,
|
|
387
|
+
scored: options.scored,
|
|
388
|
+
})
|
|
286
389
|
}
|
|
287
390
|
|
|
288
391
|
/**
|
|
@@ -326,10 +429,10 @@ export function collectSkillNames() {
|
|
|
326
429
|
* Returns { fm: object, body: string } or null if no frontmatter.
|
|
327
430
|
*/
|
|
328
431
|
export function parseFrontmatter(content) {
|
|
329
|
-
const match = content.match(/^---\r?\n([\s\S]*?)
|
|
432
|
+
const match = content.match(/^---\r?\n([\s\S]*?)(?:\r?\n)?---\r?\n?([\s\S]*)$/)
|
|
330
433
|
if (!match) return null
|
|
331
434
|
return {
|
|
332
|
-
fm:
|
|
435
|
+
fm: match[1].trim() ? (load(match[1]) ?? {}) : {},
|
|
333
436
|
body: match[2],
|
|
334
437
|
}
|
|
335
438
|
}
|
|
@@ -339,15 +442,15 @@ export function parseFrontmatter(content) {
|
|
|
339
442
|
* Uses long-line mode and avoids unnecessary quoting.
|
|
340
443
|
*/
|
|
341
444
|
export function serializeFm(fm) {
|
|
342
|
-
return
|
|
445
|
+
return dump(fm, {
|
|
343
446
|
lineWidth: -1,
|
|
344
|
-
|
|
447
|
+
quoteStyle: 'double',
|
|
345
448
|
forceQuotes: false,
|
|
346
449
|
noRefs: true,
|
|
347
450
|
})
|
|
348
451
|
}
|
|
349
452
|
|
|
350
|
-
export function installSkills(skills, target, dryRun, subDir = '.opencode') {
|
|
453
|
+
export function installSkills(skills, target, dryRun, subDir = '.opencode', recorder = null) {
|
|
351
454
|
const srcSkillsDir = join(ROOT, 'src', 'skills')
|
|
352
455
|
const dstSkillsDir = join(target, subDir, 'skills')
|
|
353
456
|
|
|
@@ -373,9 +476,29 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
|
|
|
373
476
|
const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf8') : null
|
|
374
477
|
if (existing === content) {
|
|
375
478
|
skipped++
|
|
479
|
+
if (isRecording(recorder)) {
|
|
480
|
+
recordChange(
|
|
481
|
+
{
|
|
482
|
+
path: dstPath,
|
|
483
|
+
action: ACTIONS.UNCHANGED,
|
|
484
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
485
|
+
},
|
|
486
|
+
recorder,
|
|
487
|
+
)
|
|
488
|
+
}
|
|
376
489
|
} else {
|
|
377
490
|
if (!dryRun) writeFileSync(dstPath, content, 'utf8')
|
|
378
491
|
created++
|
|
492
|
+
if (isRecording(recorder)) {
|
|
493
|
+
recordChange(
|
|
494
|
+
{
|
|
495
|
+
path: dstPath,
|
|
496
|
+
action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
|
|
497
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
498
|
+
},
|
|
499
|
+
recorder,
|
|
500
|
+
)
|
|
501
|
+
}
|
|
379
502
|
}
|
|
380
503
|
}
|
|
381
504
|
}
|
|
@@ -396,6 +519,9 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
|
|
|
396
519
|
console.log(` 🗑️ Removed stale skill: ${entry}`)
|
|
397
520
|
}
|
|
398
521
|
created++
|
|
522
|
+
// A stale removal increments `created` (a change), so it must also be
|
|
523
|
+
// recorded or the preview would under-report on a non-fresh target.
|
|
524
|
+
recordChange({ path: dstPath, action: ACTIONS.REMOVE, bytes: 0 }, recorder)
|
|
399
525
|
}
|
|
400
526
|
}
|
|
401
527
|
}
|
|
@@ -403,7 +529,7 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
|
|
|
403
529
|
return { created, skipped }
|
|
404
530
|
}
|
|
405
531
|
|
|
406
|
-
export function syncDir(src, dst, dryRun, clean = false, filter = null) {
|
|
532
|
+
export function syncDir(src, dst, dryRun, clean = false, filter = null, recorder = null) {
|
|
407
533
|
if (!existsSync(src)) return { created: 0, skipped: 0 }
|
|
408
534
|
let created = 0
|
|
409
535
|
let skipped = 0
|
|
@@ -422,7 +548,7 @@ export function syncDir(src, dst, dryRun, clean = false, filter = null) {
|
|
|
422
548
|
const srcPath = join(src, entry)
|
|
423
549
|
const dstPath = join(dst, entry)
|
|
424
550
|
if (statSync(srcPath).isDirectory()) {
|
|
425
|
-
const sub = syncDir(srcPath, dstPath, dryRun, false, filter)
|
|
551
|
+
const sub = syncDir(srcPath, dstPath, dryRun, false, filter, recorder)
|
|
426
552
|
created += sub.created
|
|
427
553
|
skipped += sub.skipped
|
|
428
554
|
} else {
|
|
@@ -430,9 +556,25 @@ export function syncDir(src, dst, dryRun, clean = false, filter = null) {
|
|
|
430
556
|
const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf8') : null
|
|
431
557
|
if (existing === content) {
|
|
432
558
|
skipped++
|
|
559
|
+
if (isRecording(recorder)) {
|
|
560
|
+
recordChange(
|
|
561
|
+
{ path: dstPath, action: ACTIONS.UNCHANGED, bytes: Buffer.byteLength(content, 'utf8') },
|
|
562
|
+
recorder,
|
|
563
|
+
)
|
|
564
|
+
}
|
|
433
565
|
} else {
|
|
434
566
|
if (!dryRun) writeFileSync(dstPath, content, 'utf8')
|
|
435
567
|
created++
|
|
568
|
+
if (isRecording(recorder)) {
|
|
569
|
+
recordChange(
|
|
570
|
+
{
|
|
571
|
+
path: dstPath,
|
|
572
|
+
action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
|
|
573
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
574
|
+
},
|
|
575
|
+
recorder,
|
|
576
|
+
)
|
|
577
|
+
}
|
|
436
578
|
}
|
|
437
579
|
}
|
|
438
580
|
}
|
|
@@ -47,6 +47,16 @@ const TABLE = {
|
|
|
47
47
|
prereqMissing:
|
|
48
48
|
'runtime prerequisites missing — install the tools above, or re-run with --no-mcp to install without Python MCP servers',
|
|
49
49
|
v2Migrating: 'Migrating config to V2 native format...',
|
|
50
|
+
nothingApplied: 'Nothing applied.',
|
|
51
|
+
planOnlyNoYes: 'Plan only — re-run with --yes to apply.',
|
|
52
|
+
driftHeader: 'State changed since preview — aborting before any write:',
|
|
53
|
+
driftAbort: 'No files were written. Re-run to re-review.',
|
|
54
|
+
applyFailed: (msg) => `❌ Apply failed: ${msg}`,
|
|
55
|
+
alreadyApplied: 'Paths already applied — no automatic rollback:',
|
|
56
|
+
restoreRecoveryHint: (dir) => `Recover with: pantheon-opencode restore (backup at ${dir})`,
|
|
57
|
+
restoreNoBackup:
|
|
58
|
+
'No backup was created (the failure occurred before the first write); the target is unchanged.',
|
|
59
|
+
dryRunNothingWritten: 'Preview only — no files were written.',
|
|
50
60
|
// summary (printSummary)
|
|
51
61
|
summaryInstalled: (target) => `OpenCode installed in ${target}`,
|
|
52
62
|
summaryWithErrors: (target) => `Installation finished WITH ERRORS in ${target}`,
|
|
@@ -95,6 +105,16 @@ const TABLE = {
|
|
|
95
105
|
prereqMissing:
|
|
96
106
|
'pré-requisitos do runtime ausentes — instale as ferramentas acima, ou rode de novo com --no-mcp para instalar sem os MCPs Python',
|
|
97
107
|
v2Migrating: 'Migrando config para o formato nativo V2...',
|
|
108
|
+
nothingApplied: 'Nada foi aplicado.',
|
|
109
|
+
planOnlyNoYes: 'Apenas o plano — rode de novo com --yes para aplicar.',
|
|
110
|
+
driftHeader: 'O estado mudou desde o preview — abortando antes de qualquer escrita:',
|
|
111
|
+
driftAbort: 'Nenhum arquivo foi escrito. Rode de novo para revisar.',
|
|
112
|
+
applyFailed: (msg) => `❌ Falha ao aplicar: ${msg}`,
|
|
113
|
+
alreadyApplied: 'Caminhos já aplicados — sem rollback automático:',
|
|
114
|
+
restoreRecoveryHint: (dir) => `Recupere com: pantheon-opencode restore (backup em ${dir})`,
|
|
115
|
+
restoreNoBackup:
|
|
116
|
+
'Nenhum backup foi criado (a falha ocorreu antes da primeira escrita); o alvo está intacto.',
|
|
117
|
+
dryRunNothingWritten: 'Somente prévia — nenhum arquivo foi escrito.',
|
|
98
118
|
// summary (printSummary)
|
|
99
119
|
summaryInstalled: (target) => `OpenCode instalado em ${target}`,
|
|
100
120
|
summaryWithErrors: (target) => `Instalação concluída COM ERROS em ${target}`,
|
package/scripts/install/venv.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import { existsSync, rmSync } from 'node:fs'
|
|
|
14
14
|
import { dirname, join } from 'node:path'
|
|
15
15
|
import { fileURLToPath } from 'node:url'
|
|
16
16
|
|
|
17
|
+
import { ACTIONS, recordChange } from './plan.mjs'
|
|
18
|
+
|
|
17
19
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
18
20
|
const ROOT = join(__dirname, '..', '..')
|
|
19
21
|
|
|
@@ -40,10 +42,13 @@ export function venvPythonPath(target) {
|
|
|
40
42
|
* Set up a Python virtual environment and install MCP dependencies.
|
|
41
43
|
*
|
|
42
44
|
* @param {string} target - Target installation directory (e.g. ~/.config/opencode)
|
|
43
|
-
* @param {{ dryRun?: boolean, skipInstall?: boolean, force?: boolean }} [options]
|
|
45
|
+
* @param {{ dryRun?: boolean, skipInstall?: boolean, force?: boolean, recorder?: (change: object) => void }} [options]
|
|
44
46
|
* @returns {{ venvPath: string, python: string }}
|
|
45
47
|
*/
|
|
46
|
-
export function setupVenv(
|
|
48
|
+
export function setupVenv(
|
|
49
|
+
target,
|
|
50
|
+
{ dryRun = false, skipInstall = false, force = false, recorder = null } = {},
|
|
51
|
+
) {
|
|
47
52
|
const venvPath = join(target, '.venv')
|
|
48
53
|
const pythonBin = venvPythonPath(target)
|
|
49
54
|
|
|
@@ -57,7 +62,8 @@ export function setupVenv(target, { dryRun = false, skipInstall = false, force =
|
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
// Step 1b: Create venv if not exists
|
|
60
|
-
|
|
65
|
+
const venvExists = existsSync(pythonBin)
|
|
66
|
+
if (!venvExists) {
|
|
61
67
|
if (!dryRun) {
|
|
62
68
|
console.log(' Creating .venv...')
|
|
63
69
|
const result = spawnSync('python3', ['-m', 'venv', venvPath], {
|
|
@@ -69,8 +75,12 @@ export function setupVenv(target, { dryRun = false, skipInstall = false, force =
|
|
|
69
75
|
}
|
|
70
76
|
}
|
|
71
77
|
console.log(' ✅ .venv created')
|
|
78
|
+
// A single synthetic entry for the venv directory — never enumerate its
|
|
79
|
+
// thousands of files. Unscored: the install summary does not count it.
|
|
80
|
+
recordChange({ path: venvPath, action: ACTIONS.CREATE, bytes: 0, scored: false }, recorder)
|
|
72
81
|
} else {
|
|
73
82
|
console.log(' ⏭️ .venv already exists')
|
|
83
|
+
recordChange({ path: venvPath, action: ACTIONS.UNCHANGED, bytes: 0, scored: false }, recorder)
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
// Step 2: Install MCP dependencies
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vision-prompt.mjs — optional vision step of `pantheon-opencode init`.
|
|
3
|
+
*
|
|
4
|
+
* After the model-preset wizard, an interactive TTY session is offered a
|
|
5
|
+
* three-way choice for the vision model:
|
|
6
|
+
* 1. automatic discovery (default, Enter) — writes nothing
|
|
7
|
+
* 2. pin a `provider/model` — merges `visionModel` into the
|
|
8
|
+
* GLOBAL vision config (~/.config/opencode/opencode-vision.json)
|
|
9
|
+
* 3. skip — writes nothing
|
|
10
|
+
*
|
|
11
|
+
* Non-interactive runs (no TTY, `--yes`, `--headless`, automated sandboxes)
|
|
12
|
+
* never reach this module: `installOpenCode` only calls it when
|
|
13
|
+
* `interactive && !dryRun && !autoYes`. Automatic discovery therefore stays
|
|
14
|
+
* the default in every non-interactive flow and no run blocks on stdin.
|
|
15
|
+
*
|
|
16
|
+
* The write mirrors the `/pantheon-vision` command: atomic, `.bak` backup,
|
|
17
|
+
* and a MERGE — existing keys are never overwritten.
|
|
18
|
+
*/
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { writeConfigWithBackup } from './shared.mjs'
|
|
23
|
+
|
|
24
|
+
const CONFIG_FILENAME = 'opencode-vision.json'
|
|
25
|
+
/** `provider/model` — exactly one slash, no surrounding whitespace. */
|
|
26
|
+
const MODEL_PATTERN = /^[^\s/]+\/[^\s/]+$/
|
|
27
|
+
const MAX_PIN_ATTEMPTS = 3
|
|
28
|
+
|
|
29
|
+
/** Global vision config path, honoring `XDG_CONFIG_HOME` (same rule as the plugin). */
|
|
30
|
+
export function globalVisionConfigPath(env = process.env) {
|
|
31
|
+
const home = env.HOME?.trim() || homedir()
|
|
32
|
+
const xdg = env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
|
|
33
|
+
return join(xdg, 'opencode', CONFIG_FILENAME)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when `value` looks like a `provider/model` id (one slash, no spaces). */
|
|
37
|
+
export function isValidVisionModel(value) {
|
|
38
|
+
return typeof value === 'string' && MODEL_PATTERN.test(value.trim())
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Merge `visionModel` into a vision config, preserving every other key.
|
|
43
|
+
* Defaults to the GLOBAL config; pass `configPath` to target another file
|
|
44
|
+
* (e.g. the project config `.opencode/opencode-vision.json`). Creates the
|
|
45
|
+
* parent directory when missing; never overwrites existing keys. A corrupted
|
|
46
|
+
* JSON file is left byte-identical (the caller decides what to tell the user)
|
|
47
|
+
* rather than clobbered.
|
|
48
|
+
*
|
|
49
|
+
* A concurrent writer that changes the file between the read and the atomic
|
|
50
|
+
* swap makes `writeConfigWithBackup` return `'conflict'`: the write is aborted,
|
|
51
|
+
* `wrote` is `false`, and the newer content is left untouched.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} [opts]
|
|
54
|
+
* @param {Record<string, string|undefined>} [opts.env]
|
|
55
|
+
* @param {boolean} [opts.dryRun]
|
|
56
|
+
* @param {string} [opts.configPath] - explicit target file (defaults to global)
|
|
57
|
+
* @param {(existing: string|null) => void} [opts.beforeRecheck] - test seam
|
|
58
|
+
* forwarded to `writeConfigWithBackup` to simulate a concurrent writer.
|
|
59
|
+
* @returns {{path: string, wrote: boolean, reason: 'created'|'skipped'|'conflict'|'corrupt'|'dry-run'}}
|
|
60
|
+
*/
|
|
61
|
+
export function mergeVisionModel(
|
|
62
|
+
model,
|
|
63
|
+
{ env = process.env, dryRun = false, beforeRecheck, configPath: target } = {},
|
|
64
|
+
) {
|
|
65
|
+
const configPath = target ?? globalVisionConfigPath(env)
|
|
66
|
+
let parsed = {}
|
|
67
|
+
let existingRaw
|
|
68
|
+
if (existsSync(configPath)) {
|
|
69
|
+
existingRaw = readFileSync(configPath, 'utf8')
|
|
70
|
+
try {
|
|
71
|
+
const data = JSON.parse(existingRaw)
|
|
72
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) parsed = data
|
|
73
|
+
} catch {
|
|
74
|
+
return { path: configPath, wrote: false, reason: 'corrupt' }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (dryRun) return { path: configPath, wrote: false, reason: 'dry-run' }
|
|
78
|
+
const next = { ...parsed, visionModel: model }
|
|
79
|
+
const content = `${JSON.stringify(next, null, 2)}\n`
|
|
80
|
+
mkdirSync(dirname(configPath), { recursive: true })
|
|
81
|
+
const status = writeConfigWithBackup(configPath, content, false, {
|
|
82
|
+
expectedContent: existingRaw,
|
|
83
|
+
beforeRecheck,
|
|
84
|
+
// This file is GLOBAL and lives outside the config dir, so the target-scoped
|
|
85
|
+
// exact backup set never captures it. Keep its own `<file>.bak` recovery.
|
|
86
|
+
beforeSwap: (existing) => {
|
|
87
|
+
if (existing === null) return
|
|
88
|
+
try {
|
|
89
|
+
copyFileSync(configPath, `${configPath}.bak`)
|
|
90
|
+
} catch {
|
|
91
|
+
// Best-effort: a backup failure must not block the write.
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
return { path: configPath, wrote: status === 'created', reason: status }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Ask the interactive vision question. `rl` may be injected (tests / an
|
|
100
|
+
* existing readline interface); otherwise a private one is created and closed.
|
|
101
|
+
* Returns `{action, visionModel?, wrote?, path?}` — never throws on bad input,
|
|
102
|
+
* and never writes for the default/skip paths.
|
|
103
|
+
*
|
|
104
|
+
* @param {{rl?: object, logger?: object, env?: Record<string, string|undefined>, dryRun?: boolean, beforeRecheck?: (existing: string|null) => void}} [opts]
|
|
105
|
+
*/
|
|
106
|
+
export async function runVisionPrompt({
|
|
107
|
+
rl,
|
|
108
|
+
logger = console,
|
|
109
|
+
env = process.env,
|
|
110
|
+
dryRun = false,
|
|
111
|
+
beforeRecheck,
|
|
112
|
+
} = {}) {
|
|
113
|
+
let iface = rl
|
|
114
|
+
let closeAfter = false
|
|
115
|
+
if (!iface) {
|
|
116
|
+
const { createInterface } = await import('node:readline/promises')
|
|
117
|
+
iface = createInterface({ input: process.stdin, output: process.stdout })
|
|
118
|
+
closeAfter = true
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
logger.log?.('')
|
|
122
|
+
logger.log?.('Vision — how should images be described?')
|
|
123
|
+
logger.log?.(
|
|
124
|
+
' 1) Automatic discovery (default) — pick a vision-capable model from the host catalog',
|
|
125
|
+
)
|
|
126
|
+
logger.log?.(' 2) Pin a provider/model (e.g. opencode-go/mimo-v2.5)')
|
|
127
|
+
logger.log?.(' 3) Skip')
|
|
128
|
+
const answer = (await iface.question('Choice [1]: ')).trim().toLowerCase()
|
|
129
|
+
|
|
130
|
+
if (answer === '' || answer === '1' || answer === 'auto' || answer === 'discovery') {
|
|
131
|
+
logger.log?.('Vision: automatic discovery (no config written).')
|
|
132
|
+
return { action: 'auto' }
|
|
133
|
+
}
|
|
134
|
+
if (answer === '3' || answer === 'skip' || answer === 'no') {
|
|
135
|
+
logger.log?.('Vision: skipped (no config written).')
|
|
136
|
+
return { action: 'skip' }
|
|
137
|
+
}
|
|
138
|
+
if (answer === '2' || answer === 'pin') {
|
|
139
|
+
for (let attempt = 0; attempt < MAX_PIN_ATTEMPTS; attempt++) {
|
|
140
|
+
const raw = (await iface.question('provider/model (e.g. opencode-go/mimo-v2.5): ')).trim()
|
|
141
|
+
if (!isValidVisionModel(raw)) {
|
|
142
|
+
logger.warn?.(`Invalid model "${raw}" — expected provider/model (one slash, no spaces).`)
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const result = mergeVisionModel(raw, { env, dryRun, beforeRecheck })
|
|
146
|
+
if (result.reason === 'corrupt') {
|
|
147
|
+
logger.warn?.(
|
|
148
|
+
`Vision config at ${result.path} is not valid JSON — leaving it untouched; fix it and re-run.`,
|
|
149
|
+
)
|
|
150
|
+
return { action: 'pin', visionModel: raw, wrote: false, path: result.path }
|
|
151
|
+
}
|
|
152
|
+
if (result.reason === 'conflict') {
|
|
153
|
+
logger.warn?.(
|
|
154
|
+
`Vision: not saved — config at ${result.path} was modified concurrently; re-run to retry.`,
|
|
155
|
+
)
|
|
156
|
+
} else {
|
|
157
|
+
logger.log?.(`Vision: pinned ${raw} in ${result.path}.`)
|
|
158
|
+
}
|
|
159
|
+
return { action: 'pin', visionModel: raw, wrote: result.wrote, path: result.path }
|
|
160
|
+
}
|
|
161
|
+
logger.warn?.('Vision: no valid model entered — skipping (no config written).')
|
|
162
|
+
return { action: 'skip' }
|
|
163
|
+
}
|
|
164
|
+
logger.warn?.(
|
|
165
|
+
`Unknown choice "${answer}" — defaulting to automatic discovery (no config written).`,
|
|
166
|
+
)
|
|
167
|
+
return { action: 'auto' }
|
|
168
|
+
} finally {
|
|
169
|
+
if (closeAfter) iface.close()
|
|
170
|
+
}
|
|
171
|
+
}
|