pantheon-opencode 1.3.3 → 1.3.5-beta.52.c75b04d
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 +919 -0
- package/README.md +157 -15
- package/bin/pantheon-init.mjs +177 -120
- package/opencode.json +3 -6
- package/package.json +8 -4
- package/scripts/build-agents-md.mjs +195 -0
- package/scripts/cost.mjs +17 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +189 -38
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-beta-version.mjs +80 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +6 -2
- package/src/pantheon/background-job-board.ts +24 -38
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/cost-command.ts +57 -18
- package/src/pantheon/delegation-compaction.ts +143 -16
- package/src/pantheon/delegation-finalize.ts +8 -1
- package/src/pantheon/delegation-notify.ts +19 -157
- package/src/pantheon/delegation.ts +330 -28
- package/src/pantheon/dispatch-guard.ts +9 -4
- package/src/pantheon/goal-loop.ts +7 -4
- package/src/pantheon/hashline/tool.ts +2 -1
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +79 -27
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +177 -50
- package/src/plugins/pantheon-hooks.ts +35 -22
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +3 -1
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* opencode.mjs — OpenCode platform installer
|
|
4
|
+
*
|
|
5
|
+
* Dual-version install (Phase 3): the generated config is V1-shaped and valid
|
|
6
|
+
* under BOTH OpenCode V1 (`opencode` 1.18.x) and V2 (`opencode2`
|
|
7
|
+
* v0.0.0-next-17444). V2 reads the same config locations and normalizes V1
|
|
8
|
+
* fields in memory — do NOT convert to native V2 format. Known V2 beta gaps
|
|
9
|
+
* handled here: top-level `subagent_depth` is silently ignored (migrated to
|
|
10
|
+
* `experimental.subagent_depth`), and the `instructions` config key is
|
|
11
|
+
* accepted-but-not-loaded (content consolidated into AGENTS.md, which both
|
|
12
|
+
* versions load). Pass --version v2 to pantheon-init for an informational
|
|
13
|
+
* label; state isolation is handled at runtime via OPENCODE_DB.
|
|
4
14
|
*/
|
|
5
15
|
|
|
6
|
-
import {
|
|
7
|
-
chmodSync,
|
|
8
|
-
existsSync,
|
|
9
|
-
mkdirSync,
|
|
10
|
-
readdirSync,
|
|
11
|
-
readFileSync,
|
|
12
|
-
rmSync,
|
|
13
|
-
writeFileSync,
|
|
14
|
-
} from 'node:fs'
|
|
16
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
15
17
|
import { homedir } from 'node:os'
|
|
16
18
|
import { basename, join, resolve } from 'node:path'
|
|
17
19
|
import {
|
|
@@ -29,7 +31,7 @@ import {
|
|
|
29
31
|
} from './cli-ui.mjs'
|
|
30
32
|
import { healthCheck } from './health-check.mjs'
|
|
31
33
|
import { detectVersion, runMigrations } from './migrate.mjs'
|
|
32
|
-
import { installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
|
|
34
|
+
import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
|
|
33
35
|
import {
|
|
34
36
|
collectSkillNames,
|
|
35
37
|
copyFiles,
|
|
@@ -89,7 +91,7 @@ function mergeMissing(target, source) {
|
|
|
89
91
|
* installed package.
|
|
90
92
|
*
|
|
91
93
|
* The packaged config may reference plugins via developer-machine absolute
|
|
92
|
-
* paths (e.g.
|
|
94
|
+
* paths (e.g. <dev>/pantheon/.../src/plugin.ts). Copying those verbatim into
|
|
93
95
|
* the user's config would break every install on any other machine. This
|
|
94
96
|
* rewrites the entry to the plugin file inside the INSTALLED package (derived
|
|
95
97
|
* from ROOT — never hardcoded), so both dev and global installs resolve to a
|
|
@@ -123,6 +125,97 @@ export function isGlobalConfigDir(target) {
|
|
|
123
125
|
return resolve(target) === xdgDir
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Derive where the TUI plugin must be COPIED for a given config directory.
|
|
130
|
+
*
|
|
131
|
+
* The TUI plugin's single source of truth is src/plugins/tui inside the
|
|
132
|
+
* package; the loader must NEVER be pointed at that in-package dir (a package
|
|
133
|
+
* reinstall/upgrade replaces the dir under a live registration and leaves
|
|
134
|
+
* diverging copies loading). Instead the installer always copies it into the
|
|
135
|
+
* config dir's plugins/ dir and registers THAT copy:
|
|
136
|
+
*
|
|
137
|
+
* <configDir>/plugins/pantheon-tui (dist/ + package.json + index.tsx)
|
|
138
|
+
*
|
|
139
|
+
* The loader reads the copied package.json `exports` map:
|
|
140
|
+
* exports["./tui"] → dist/tui.js (COMPILED Solid output)
|
|
141
|
+
* exports["./server"] → dist/server.js (no-op server() stub)
|
|
142
|
+
*
|
|
143
|
+
* @param {string} configDir - directory holding tui.json (target for global
|
|
144
|
+
* installs, <target>/.opencode for project installs)
|
|
145
|
+
* @returns {string} absolute path of the copied plugin directory
|
|
146
|
+
*/
|
|
147
|
+
export function resolveTuiCopyTarget(configDir) {
|
|
148
|
+
return join(configDir, 'plugins', 'pantheon-tui')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every tui.json location OpenCode reads: the two global config dirs
|
|
153
|
+
* (~/.opencode and $XDG_CONFIG_HOME/opencode) plus the target's own project
|
|
154
|
+
* config. Cleanup runs over ALL of them so a stale Pantheon TUI registration
|
|
155
|
+
* can never survive in a location the loader still reads.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} target - install target directory
|
|
158
|
+
* @returns {string[]} unique tui.json paths (deterministic order)
|
|
159
|
+
*/
|
|
160
|
+
export function tuiConfigLocations(target) {
|
|
161
|
+
const homeTui = join(homedir(), '.opencode', 'tui.json')
|
|
162
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
163
|
+
const xdgTui = join(xdgConfig, 'opencode', 'tui.json')
|
|
164
|
+
const projectTui = join(target, '.opencode', 'tui.json')
|
|
165
|
+
return [...new Set([homeTui, xdgTui, projectTui])]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Sync the Pantheon TUI plugin registration across EVERY tui.json location
|
|
170
|
+
* OpenCode reads.
|
|
171
|
+
*
|
|
172
|
+
* Contract (single source of truth):
|
|
173
|
+
* 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui
|
|
174
|
+
* (dist/* + package.json + index.tsx) so the registered reference is a
|
|
175
|
+
* directory that exists and carries the loader contract (exports →
|
|
176
|
+
* dist/tui.js, dist/server.js). The copy is idempotent: a second run is
|
|
177
|
+
* byte-identical.
|
|
178
|
+
* 2. Clean stale Pantheon TUI refs from ALL tui.json locations OpenCode
|
|
179
|
+
* loads — ~/.opencode, $XDG_CONFIG_HOME/opencode and
|
|
180
|
+
* <target>/.opencode — covering every old reference shape: dist file
|
|
181
|
+
* paths, in-package src/plugins/tui dirs, bare plugins/pantheon-tui
|
|
182
|
+
* copies, and npx paths. A stale ref left in the non-target location
|
|
183
|
+
* would still be loaded and break the TUI.
|
|
184
|
+
* 3. Register EXACTLY the copied directory in the selected target only —
|
|
185
|
+
* exactly ONE Pantheon TUI reference exists system-wide after install.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} target - install target directory
|
|
188
|
+
* @param {object} [opts]
|
|
189
|
+
* @param {boolean} [opts.isGlobal=false] - flat global layout vs .opencode/
|
|
190
|
+
* @param {boolean} [opts.dryRun=false]
|
|
191
|
+
* @returns {'created'|'skipped'} status for the target registration
|
|
192
|
+
*/
|
|
193
|
+
export function syncTuiRegistration(target, { isGlobal = false, dryRun = false } = {}) {
|
|
194
|
+
const configDir = isGlobal ? target : join(target, '.opencode')
|
|
195
|
+
const targetTuiConfigPath = join(configDir, 'tui.json')
|
|
196
|
+
|
|
197
|
+
// 1. Always copy the single source of truth into the target config's
|
|
198
|
+
// plugins/pantheon-tui. Registering this copied directory (never the
|
|
199
|
+
// in-package src/plugins/tui) keeps installs hermetic and idempotent:
|
|
200
|
+
// the ref points at a dir that exists and has dist/tui.js.
|
|
201
|
+
const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
|
|
202
|
+
const tuiCopyDir = resolveTuiCopyTarget(configDir)
|
|
203
|
+
if (existsSync(tuiSrcDir)) {
|
|
204
|
+
copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
|
|
208
|
+
// unregisterPlugin covers the bare relative id plus the extended stale
|
|
209
|
+
// patterns (dist files, in-package src/plugins/tui dir, bare
|
|
210
|
+
// plugins/pantheon-tui copies, npx paths).
|
|
211
|
+
for (const tuiConfigPath of tuiConfigLocations(target)) {
|
|
212
|
+
unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', { dryRun })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 3. Register the copied directory in the selected target only.
|
|
216
|
+
return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
|
|
217
|
+
}
|
|
218
|
+
|
|
126
219
|
export async function installOpenCode(
|
|
127
220
|
target,
|
|
128
221
|
dryRun = false,
|
|
@@ -138,6 +231,20 @@ export async function installOpenCode(
|
|
|
138
231
|
const componentSet = new Set(components)
|
|
139
232
|
const stats = summary.opencode
|
|
140
233
|
|
|
234
|
+
// V1/V2 dual-version awareness (Phase 3): the SAME config works under both
|
|
235
|
+
// OpenCode V1 (`opencode`) and V2 (`opencode2`, beta v0.0.0-next-17444).
|
|
236
|
+
// V2 normalizes V1 fields in memory (no rewrite) and isolates its state DB
|
|
237
|
+
// via OPENCODE_DB (~/.local/share/opencode/opencode-v2.db) with a distinct
|
|
238
|
+
// service port (49375 vs V1's 49374). The installer writes one shared
|
|
239
|
+
// config; the --version flag only labels the install for the operator.
|
|
240
|
+
const version = opts.version === 'v2' ? 'v2' : 'v1'
|
|
241
|
+
if (version === 'v2') {
|
|
242
|
+
info(
|
|
243
|
+
'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
|
|
244
|
+
'(~/.local/share/opencode/opencode-v2.db), service port 49375',
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
141
248
|
// Interactive mode handling
|
|
142
249
|
const stdinTTY = process.stdin.isTTY && process.stdout.isTTY
|
|
143
250
|
const forceInteractive = opts.interactive === true
|
|
@@ -250,8 +357,13 @@ export async function installOpenCode(
|
|
|
250
357
|
}
|
|
251
358
|
|
|
252
359
|
// -----------------------------------------------------------------------
|
|
253
|
-
// 2.5 Install instructions: AGENTS.md
|
|
360
|
+
// 2.5 Install instructions: AGENTS.md only (--components instructions)
|
|
254
361
|
// -----------------------------------------------------------------------
|
|
362
|
+
// AGENTS.md is the single instruction file both V1 and V2 load; the
|
|
363
|
+
// generated file embeds every src/instructions/*.instructions.md body, so
|
|
364
|
+
// the individual files are NOT copied anymore (nothing references
|
|
365
|
+
// <config>/instructions/ after the section-D merge fix — copying them would
|
|
366
|
+
// just leave stale duplicates on disk).
|
|
255
367
|
if (componentSet.has('instructions')) {
|
|
256
368
|
section('\uD83D\uDCCB Instructions')
|
|
257
369
|
// AGENTS.md
|
|
@@ -263,14 +375,6 @@ export async function installOpenCode(
|
|
|
263
375
|
if (status === 'created') stats.created++
|
|
264
376
|
else stats.skipped++
|
|
265
377
|
}
|
|
266
|
-
// instructions/ directory
|
|
267
|
-
const srcInstr = join(ROOT, 'src', 'instructions')
|
|
268
|
-
const dstInstr = join(target, 'instructions')
|
|
269
|
-
if (existsSync(srcInstr)) {
|
|
270
|
-
const instrResult = syncDir(srcInstr, dstInstr, dryRun, clean)
|
|
271
|
-
stats.created += instrResult.created
|
|
272
|
-
stats.skipped += instrResult.skipped
|
|
273
|
-
}
|
|
274
378
|
}
|
|
275
379
|
|
|
276
380
|
// -----------------------------------------------------------------------
|
|
@@ -327,19 +431,7 @@ export async function installOpenCode(
|
|
|
327
431
|
// -----------------------------------------------------------------------
|
|
328
432
|
// 2.9 Create/update tui.json with plugin registration
|
|
329
433
|
// -----------------------------------------------------------------------
|
|
330
|
-
const
|
|
331
|
-
? join(target, 'tui.json')
|
|
332
|
-
: join(target, '.opencode', 'tui.json')
|
|
333
|
-
// Hermetic TUI plugin registration: opencode's tui loader treats relative
|
|
334
|
-
// entries ("plugins/pantheon-tui") as npm/github package specs and fails
|
|
335
|
-
// with NpmInstallFailedError ("Repository not found"). Register the plugin
|
|
336
|
-
// by the ABSOLUTE path of the built dist inside the installed package
|
|
337
|
-
// (derived from ROOT — never hardcoded), mirroring the loader's
|
|
338
|
-
// absolute-path support (same pattern as the hooks plugin fix).
|
|
339
|
-
const tuiPluginRef = join(ROOT, 'src', 'plugins', 'tui', 'dist', 'tui.tsx')
|
|
340
|
-
// Clean up the old broken relative ref (and stale dist refs) on upgrade.
|
|
341
|
-
unregisterPlugin(targetTuiConfigPath, 'plugins/pantheon-tui', { dryRun })
|
|
342
|
-
const tuiStatus = registerPlugin(targetTuiConfigPath, tuiPluginRef, { dryRun })
|
|
434
|
+
const tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
|
|
343
435
|
if (tuiStatus === 'created') stats.created++
|
|
344
436
|
else stats.skipped++
|
|
345
437
|
|
|
@@ -590,19 +682,44 @@ export async function installOpenCode(
|
|
|
590
682
|
if (!config.default_agent && pantheonConfig.default_agent) {
|
|
591
683
|
config.default_agent = pantheonConfig.default_agent
|
|
592
684
|
}
|
|
685
|
+
// Merge top-level model/small_model from the repo config when the target
|
|
686
|
+
// config has no top-level `model` (fresh sandbox installs have none). The
|
|
687
|
+
// repo's opencode.json carries the default pair
|
|
688
|
+
// (opencode-go/deepseek-v4-flash); a user-set model is never overwritten.
|
|
689
|
+
if (!config.model && pantheonConfig.model) {
|
|
690
|
+
config.model = pantheonConfig.model
|
|
691
|
+
if (!config.small_model && pantheonConfig.small_model) {
|
|
692
|
+
config.small_model = pantheonConfig.small_model
|
|
693
|
+
}
|
|
694
|
+
}
|
|
593
695
|
// Merge subagent_depth HERE (the config merge always runs, including on a
|
|
594
696
|
// fresh install) instead of a file-existence-gated block, so run #1 already
|
|
595
697
|
// produces the final config — run #2 must be a byte-identical no-op (#19).
|
|
596
|
-
|
|
597
|
-
|
|
698
|
+
//
|
|
699
|
+
// V1/V2 compat (Phase 3): top-level `subagent_depth` is in V2's
|
|
700
|
+
// unsupportedTopLevel list and is SILENTLY IGNORED. The accepted form is
|
|
701
|
+
// `experimental.subagent_depth`. Migrate any existing top-level value into
|
|
702
|
+
// experimental (preserving the user's choice) and default fresh installs to
|
|
703
|
+
// experimental.subagent_depth = 2. V1 reads experimental.subagent_depth
|
|
704
|
+
// too, so the V1-shape config stays valid under both versions.
|
|
705
|
+
if (config.experimental === undefined || typeof config.experimental !== 'object') {
|
|
706
|
+
config.experimental = {}
|
|
707
|
+
}
|
|
708
|
+
if (config.subagent_depth !== undefined) {
|
|
709
|
+
if (config.experimental.subagent_depth === undefined) {
|
|
710
|
+
config.experimental.subagent_depth = config.subagent_depth
|
|
711
|
+
}
|
|
712
|
+
delete config.subagent_depth
|
|
713
|
+
} else if (config.experimental.subagent_depth === undefined) {
|
|
714
|
+
config.experimental.subagent_depth = 2
|
|
598
715
|
}
|
|
599
716
|
|
|
600
717
|
// --------------------------------------------------------------------
|
|
601
718
|
// B.5.1 Hermetic plugin resolution (packaging fix)
|
|
602
719
|
// --------------------------------------------------------------------
|
|
603
720
|
// The packaged opencode.json may reference plugins via developer-machine
|
|
604
|
-
// absolute paths (e.g.
|
|
605
|
-
//
|
|
721
|
+
// absolute paths (e.g. <dev>/pantheon/.../src/plugin.ts or
|
|
722
|
+
// <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
|
|
606
723
|
// verbatim into the user's config would break every global install on any
|
|
607
724
|
// other machine. resolveInstalledPlugin() rewrites each entry to the plugin
|
|
608
725
|
// file inside the INSTALLED package (derived from ROOT — never hardcoded),
|
|
@@ -624,6 +741,25 @@ export async function installOpenCode(
|
|
|
624
741
|
}
|
|
625
742
|
}
|
|
626
743
|
|
|
744
|
+
// The two Pantheon plugins MUST always be registered, on fresh installs AND
|
|
745
|
+
// upgrades. The packaged opencode.json carries no `plugin` key (dev-machine
|
|
746
|
+
// paths removed in 3e552cc), so config.plugin would otherwise only contain
|
|
747
|
+
// whatever the user had — a fresh install would register NOTHING and lose
|
|
748
|
+
// delegation tools, GoalLoop, cost command, hashline and vision. Register
|
|
749
|
+
// both unconditionally, replacing any stale entry for the same file
|
|
750
|
+
// (basename-dedup) so installs and upgrades are idempotent and hermetic
|
|
751
|
+
// while preserving any user plugins.
|
|
752
|
+
const ensurePantheonPlugin = (ref) => {
|
|
753
|
+
const resolved = resolveInstalledPlugin(ref)
|
|
754
|
+
const file = basename(resolved)
|
|
755
|
+
config.plugin = config.plugin.filter((p) => basename(p) !== file)
|
|
756
|
+
config.plugin.push(resolved)
|
|
757
|
+
}
|
|
758
|
+
// Root-level delegation plugin (PR #45): tools, GoalLoop, cost, hashline.
|
|
759
|
+
ensurePantheonPlugin('src/plugin.ts')
|
|
760
|
+
// Runtime hooks plugin: chat hooks, hook-runner, TUI wiring.
|
|
761
|
+
ensurePantheonPlugin('src/plugins/pantheon-hooks.ts')
|
|
762
|
+
|
|
627
763
|
if (!config.provider && pantheonConfig.provider) {
|
|
628
764
|
config.provider = deepClone(pantheonConfig.provider)
|
|
629
765
|
} else if (config.provider && pantheonConfig.provider) {
|
|
@@ -665,10 +801,25 @@ export async function installOpenCode(
|
|
|
665
801
|
// --------------------------------------------------------------------
|
|
666
802
|
// D. Merge instructions paths
|
|
667
803
|
// --------------------------------------------------------------------
|
|
668
|
-
|
|
804
|
+
// AGENTS.md is the single instruction file both V1 and V2 load (V1
|
|
805
|
+
// auto-discovers it, V2 loads it explicitly). The generated AGENTS.md
|
|
806
|
+
// (scripts/build-agents-md.mjs) already embeds every
|
|
807
|
+
// src/instructions/*.instructions.md body, so the `instructions/*.md`
|
|
808
|
+
// glob is no longer needed — keeping it would duplicate content under V1
|
|
809
|
+
// and is ignored under V2 anyway. Only ensure AGENTS.md is present.
|
|
810
|
+
const pantheonInstructions = ['AGENTS.md']
|
|
669
811
|
if (!config.instructions) {
|
|
670
812
|
config.instructions = [...pantheonInstructions]
|
|
671
813
|
} else {
|
|
814
|
+
// Strip stale per-file instruction globs from PREVIOUS installs
|
|
815
|
+
// (e.g. `instructions/*.instructions.md` / `src/instructions/*.md`).
|
|
816
|
+
// The merge is additive-only otherwise, so without this filter existing
|
|
817
|
+
// configs would keep loading instruction content twice under V1
|
|
818
|
+
// (generated AGENTS.md + individual files). User entries that are not
|
|
819
|
+
// *.instructions.md globs are preserved.
|
|
820
|
+
config.instructions = config.instructions.filter(
|
|
821
|
+
(instr) => typeof instr !== 'string' || !instr.endsWith('.instructions.md'),
|
|
822
|
+
)
|
|
672
823
|
for (const instr of pantheonInstructions) {
|
|
673
824
|
if (!config.instructions.includes(instr)) {
|
|
674
825
|
config.instructions.push(instr)
|
|
@@ -3,34 +3,34 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { execSync } from 'node:child_process'
|
|
6
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
7
7
|
import { join } from 'node:path'
|
|
8
8
|
import { warning } from './cli-ui.mjs'
|
|
9
9
|
import { writeIfChanged } from './shared.mjs'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* Copy a plugin's runtime payload (src/index.tsx → index.tsx, dist/*,
|
|
13
|
+
* package.json) from source to destination. Pure file copy — no npm install.
|
|
13
14
|
* @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
|
|
14
15
|
* @param {string} dstDir - Destination plugin directory
|
|
15
16
|
* @param {object} [options]
|
|
16
17
|
* @param {boolean} [options.dryRun=false] - If true, don't write files
|
|
17
|
-
* @
|
|
18
|
-
* @returns {{ created: number, skipped: number, errors: number }}
|
|
18
|
+
* @returns {{ created: number, skipped: number }}
|
|
19
19
|
*/
|
|
20
|
-
export function
|
|
21
|
-
const result = { created: 0, skipped: 0
|
|
20
|
+
export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
|
|
21
|
+
const result = { created: 0, skipped: 0 }
|
|
22
22
|
|
|
23
23
|
if (!dryRun) mkdirSync(dstDir, { recursive: true })
|
|
24
|
-
if (clean && existsSync(dstDir) && !dryRun) {
|
|
25
|
-
rmSync(dstDir, { recursive: true, force: true })
|
|
26
|
-
mkdirSync(dstDir, { recursive: true })
|
|
27
|
-
}
|
|
28
24
|
if (!dryRun) mkdirSync(join(dstDir, 'dist'), { recursive: true })
|
|
29
25
|
|
|
30
26
|
// Copy src/index.tsx -> index.tsx
|
|
31
27
|
const srcIdx = join(srcDir, 'src', 'index.tsx')
|
|
32
28
|
if (existsSync(srcIdx)) {
|
|
33
|
-
const idxStatus = writeIfChanged(
|
|
29
|
+
const idxStatus = writeIfChanged(
|
|
30
|
+
join(dstDir, 'index.tsx'),
|
|
31
|
+
readFileSync(srcIdx, 'utf8'),
|
|
32
|
+
dryRun,
|
|
33
|
+
)
|
|
34
34
|
if (idxStatus === 'created') result.created++
|
|
35
35
|
else result.skipped++
|
|
36
36
|
}
|
|
@@ -40,25 +40,53 @@ export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false }
|
|
|
40
40
|
if (existsSync(distSrc)) {
|
|
41
41
|
for (const f of readdirSync(distSrc)) {
|
|
42
42
|
const c = readFileSync(join(distSrc, f), 'utf8')
|
|
43
|
-
writeIfChanged(join(dstDir, 'dist', f), c, dryRun)
|
|
44
|
-
result.created++
|
|
43
|
+
const status = writeIfChanged(join(dstDir, 'dist', f), c, dryRun)
|
|
44
|
+
if (status === 'created') result.created++
|
|
45
|
+
else result.skipped++
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
// Copy package.json
|
|
49
50
|
const pkgSrc = join(srcDir, 'package.json')
|
|
50
51
|
if (existsSync(pkgSrc)) {
|
|
51
|
-
const pkgStatus = writeIfChanged(
|
|
52
|
+
const pkgStatus = writeIfChanged(
|
|
53
|
+
join(dstDir, 'package.json'),
|
|
54
|
+
readFileSync(pkgSrc, 'utf8'),
|
|
55
|
+
dryRun,
|
|
56
|
+
)
|
|
52
57
|
if (pkgStatus === 'created') result.created++
|
|
53
58
|
else result.skipped++
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Install a plugin from source to destination.
|
|
66
|
+
* @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
|
|
67
|
+
* @param {string} dstDir - Destination plugin directory
|
|
68
|
+
* @param {object} [options]
|
|
69
|
+
* @param {boolean} [options.dryRun=false] - If true, don't write files
|
|
70
|
+
* @param {boolean} [options.clean=false] - If true, remove existing before install
|
|
71
|
+
* @returns {{ created: number, skipped: number, errors: number }}
|
|
72
|
+
*/
|
|
73
|
+
export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false } = {}) {
|
|
74
|
+
const result = { created: 0, skipped: 0, errors: 0 }
|
|
75
|
+
|
|
76
|
+
if (clean && existsSync(dstDir) && !dryRun) {
|
|
77
|
+
rmSync(dstDir, { recursive: true, force: true })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun })
|
|
81
|
+
result.created += copyResult.created
|
|
82
|
+
result.skipped += copyResult.skipped
|
|
83
|
+
|
|
56
84
|
// Install plugin dependencies (@opentui/core, @opentui/solid, solid-js)
|
|
57
85
|
if (!dryRun) {
|
|
58
86
|
try {
|
|
59
87
|
execSync('npm install --omit=dev --no-audit', { cwd: dstDir, stdio: 'pipe' })
|
|
60
88
|
} catch (e) {
|
|
61
|
-
warning(
|
|
89
|
+
warning(`Failed to install TUI plugin dependencies: ${e.message}`)
|
|
62
90
|
result.errors++
|
|
63
91
|
}
|
|
64
92
|
}
|
|
@@ -77,7 +105,7 @@ export function installPluginDependencies(pluginDir, { dryRun = false } = {}) {
|
|
|
77
105
|
try {
|
|
78
106
|
execSync('npm install --omit=dev --no-audit', { cwd: pluginDir, stdio: 'pipe' })
|
|
79
107
|
} catch (e) {
|
|
80
|
-
warning(
|
|
108
|
+
warning(`Failed to install plugin dependencies: ${e.message}`)
|
|
81
109
|
}
|
|
82
110
|
}
|
|
83
111
|
|
|
@@ -108,6 +136,55 @@ export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
|
|
|
108
136
|
return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
|
|
109
137
|
}
|
|
110
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Suffix patterns that identify a Pantheon TUI registration as STALE. Every
|
|
141
|
+
* reference shape ever written by previous installers must be covered:
|
|
142
|
+
* - dist file refs (both the old in-package src/plugins/tui target and the
|
|
143
|
+
* copied plugins/pantheon-tui target, tsx and js eras)
|
|
144
|
+
* - the in-package source dir (src/plugins/tui) — a registration pointing
|
|
145
|
+
* into the installed package breaks on reinstall/upgrade (the dir is
|
|
146
|
+
* replaced under a live registration)
|
|
147
|
+
* - bare copied dirs (plugins/pantheon-tui) from any OLD install target —
|
|
148
|
+
* indistinguishable from the current copied dir without knowing the
|
|
149
|
+
* install target, so they are flagged too; syncTuiRegistration keeps the
|
|
150
|
+
* current ref via unregister-then-register
|
|
151
|
+
* - node_modules copies (npx cache installs of the vendored plugin)
|
|
152
|
+
*/
|
|
153
|
+
export const TUI_STALE_SUFFIXES = [
|
|
154
|
+
'/src/plugins/tui/dist/tui.tsx',
|
|
155
|
+
'/src/plugins/tui/dist/tui.js',
|
|
156
|
+
'/src/plugins/tui',
|
|
157
|
+
'/plugins/pantheon-tui/dist/tui.tsx',
|
|
158
|
+
'/plugins/pantheon-tui/dist/tui.js',
|
|
159
|
+
'/plugins/pantheon-tui',
|
|
160
|
+
'/node_modules/pantheon-tui',
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether a single tui.json plugin entry is a stale Pantheon TUI reference.
|
|
165
|
+
* @param {unknown} ref - tui.json plugin entry
|
|
166
|
+
* @returns {boolean}
|
|
167
|
+
*/
|
|
168
|
+
export function isStaleTuiRef(ref) {
|
|
169
|
+
if (typeof ref !== 'string') return false
|
|
170
|
+
if (ref === 'plugins/pantheon-tui') return true
|
|
171
|
+
const normalized = ref.replaceAll('\\', '/')
|
|
172
|
+
// npx spec strings ("npx pantheon-tui", "npx -y pantheon-tui",
|
|
173
|
+
// "npx:pantheon-tui") and npx cache paths end with /node_modules/pantheon-tui.
|
|
174
|
+
if (/^npx[\s:]/i.test(normalized) && normalized.includes('pantheon-tui')) return true
|
|
175
|
+
return TUI_STALE_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Pure filter: return the plugin entries of a tui.json plugin array that are
|
|
180
|
+
* stale Pantheon TUI references.
|
|
181
|
+
* @param {unknown[]} pluginArray - tui.json plugin array
|
|
182
|
+
* @returns {string[]}
|
|
183
|
+
*/
|
|
184
|
+
export function staleTuiRefs(pluginArray) {
|
|
185
|
+
return (Array.isArray(pluginArray) ? pluginArray : []).filter(isStaleTuiRef)
|
|
186
|
+
}
|
|
187
|
+
|
|
111
188
|
/**
|
|
112
189
|
* Unregister stale plugin references from tui.json.
|
|
113
190
|
* @param {string} tuiJsonPath - Path to tui.json
|
|
@@ -117,25 +194,32 @@ export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
|
|
|
117
194
|
* @returns {'created'|'skipped'}
|
|
118
195
|
*/
|
|
119
196
|
export function unregisterPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
|
|
197
|
+
if (!existsSync(tuiJsonPath)) {
|
|
198
|
+
// Nothing to clean — and never create an empty tui.json in a location
|
|
199
|
+
// OpenCode reads (the cross-location cleanup also touches global config
|
|
200
|
+
// dirs that may not exist yet; writing there would ENOENT).
|
|
201
|
+
return 'skipped'
|
|
202
|
+
}
|
|
120
203
|
let tuiConfig = { plugin: [] }
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
/* use default */
|
|
126
|
-
}
|
|
204
|
+
try {
|
|
205
|
+
tuiConfig = JSON.parse(readFileSync(tuiJsonPath, 'utf8'))
|
|
206
|
+
} catch {
|
|
207
|
+
/* use default */
|
|
127
208
|
}
|
|
128
209
|
if (!Array.isArray(tuiConfig.plugin)) {
|
|
129
210
|
tuiConfig.plugin = []
|
|
130
211
|
}
|
|
131
212
|
// Remove stale refs: the bare relative id (openCode < 1.19 wrote
|
|
132
213
|
// "plugins/pantheon-tui", which its tui loader misreads as an npm/github
|
|
133
|
-
// spec → NpmInstallFailedError)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
214
|
+
// spec → NpmInstallFailedError), old dist paths, in-package source dirs,
|
|
215
|
+
// bare copy dirs from previous install targets, and npx cache copies.
|
|
216
|
+
// Without removing the absolute paths, each init run accumulates another
|
|
217
|
+
// TUI plugin and OpenCode loads stale copies.
|
|
218
|
+
const exactStale = [pluginId, `${pluginId}/dist/tui.tsx`, `${pluginId}/dist/tui.js`]
|
|
219
|
+
tuiConfig.plugin = tuiConfig.plugin.filter((ref) => {
|
|
220
|
+
if (exactStale.includes(ref)) return false
|
|
221
|
+
return !isStaleTuiRef(ref)
|
|
222
|
+
})
|
|
139
223
|
const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
|
|
140
224
|
return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
|
|
141
225
|
}
|
package/scripts/install-mcp.mjs
CHANGED
|
@@ -37,7 +37,7 @@ const ROOT = join(__dirname, '..')
|
|
|
37
37
|
// venv python3 and the server script. ROOT alone is ambiguous: when this
|
|
38
38
|
// installer runs from a global npm install, `join(ROOT, '.venv/bin/python3')`
|
|
39
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.
|
|
40
|
+
// hardcoded path to a migrated home (e.g. <dev>/admin) fails with
|
|
41
41
|
// `posix_spawn ENOENT`. Resolution order:
|
|
42
42
|
// 1. Canonical user install: ~/.config/opencode/.venv + ~/.config/opencode/scripts/
|
|
43
43
|
// 2. Local checkout: <ROOT>/.venv + <ROOT>/<relPath>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/** Fail closed when validation artifacts contain callback URLs or credentials. */
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { basename, join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const callbackUrl = /https?:\/\/[^\s"'<>]+(?:callback|redirect|return|webhook)[^\s"'<>]*/iu
|
|
10
|
+
const bearer = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/u
|
|
11
|
+
const jwt = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/u
|
|
12
|
+
const apiSecret =
|
|
13
|
+
/\b(?:api[_-]?key|token|secret|password)\s*[=:]\s*["'](?!\$\{|process\.env|os\.getenv|env:|<redacted>|\[redacted\]|fixture)[^"']{12,}["']/iu
|
|
14
|
+
|
|
15
|
+
function isText(buffer) {
|
|
16
|
+
return !buffer.subarray(0, Math.min(buffer.length, 4096)).includes(0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function findings(text) {
|
|
20
|
+
const result = []
|
|
21
|
+
if (callbackUrl.test(text)) result.push('callback URL')
|
|
22
|
+
if (bearer.test(text) || jwt.test(text) || apiSecret.test(text)) result.push('credential/token')
|
|
23
|
+
return result
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scanFile(path) {
|
|
27
|
+
if (!existsSync(path) || !lstatSync(path).isFile()) return []
|
|
28
|
+
const content = readFileSync(path)
|
|
29
|
+
return isText(content) ? findings(content.toString('utf8')) : []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function scanTarball(path) {
|
|
33
|
+
const entries = execFileSync('tar', ['-tzf', path], { encoding: 'utf8' }).split('\n').filter(Boolean)
|
|
34
|
+
const result = []
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
const content = execFileSync('tar', ['-xOzf', path, entry])
|
|
37
|
+
if (isText(content)) result.push(...findings(content.toString('utf8')))
|
|
38
|
+
}
|
|
39
|
+
return [...new Set(result)]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function scanPath(path) {
|
|
43
|
+
if (path.endsWith('.tgz') || path.endsWith('.tar.gz')) return scanTarball(path)
|
|
44
|
+
if (!existsSync(path)) throw new Error(`path does not exist: ${path}`)
|
|
45
|
+
if (lstatSync(path).isDirectory()) {
|
|
46
|
+
return readdirSync(path, { withFileTypes: true }).flatMap((entry) =>
|
|
47
|
+
scanPath(join(path, entry.name)),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
return scanFile(path)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const paths = process.argv.slice(2)
|
|
54
|
+
if (paths.length === 0) {
|
|
55
|
+
console.error(`Usage: ${basename(process.argv[1])} <file|directory|tarball> [...]`)
|
|
56
|
+
process.exit(2)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const hits = [...new Set(paths.flatMap(scanPath))]
|
|
61
|
+
if (hits.length > 0) {
|
|
62
|
+
for (const hit of hits) console.error(`[REDACTION GATE] ${hit} detected`)
|
|
63
|
+
process.exit(1)
|
|
64
|
+
}
|
|
65
|
+
console.log('Redaction gate passed: no callback URLs or credentials detected.')
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(`[REDACTION GATE] failed to inspect input: ${error.message}`)
|
|
68
|
+
process.exit(2)
|
|
69
|
+
}
|