pantheon-opencode 1.1.1-beta.4 → 1.1.1-beta.5

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.
@@ -27,10 +27,13 @@ function printUsage() {
27
27
  console.log(' npx pantheon-opencode init # Install globally');
28
28
  console.log(' npx pantheon-opencode init --project # Install in project');
29
29
  console.log(' npx pantheon-opencode init --dry-run # Preview only');
30
- console.log(' npx pantheon-opencode init --no-mcp # Skip MCP + venv');
31
- console.log(' npx pantheon-opencode init --force # Overwrite + recreate venv');
32
- console.log(' npx pantheon-opencode init --doctor # Run health check after install');
33
- console.log(' npx pantheon-opencode --help # Show this help');
30
+ console.log(' npx pantheon-opencode init --no-mcp # Skip MCP + venv');
31
+ console.log(' npx pantheon-opencode init --force # Overwrite + recreate venv');
32
+ console.log(' npx pantheon-opencode init --doctor # Run health check after install');
33
+ console.log(' npx pantheon-opencode init --interactive # Force interactive TUI mode');
34
+ console.log(' npx pantheon-opencode init --headless # Force non-interactive mode');
35
+ console.log(' npx pantheon-opencode init -y # Skip confirmations, use defaults');
36
+ console.log(' npx pantheon-opencode --help # Show this help');
34
37
  }
35
38
 
36
39
  async function main() {
@@ -45,6 +48,9 @@ async function main() {
45
48
  const skipMCP = args.includes('--no-mcp');
46
49
  const forceReinstall = args.includes('--force');
47
50
  const runDoctor = args.includes('--doctor');
51
+ const forceInteractive = args.includes('--interactive');
52
+ const forceHeadless = args.includes('--headless');
53
+ const autoYes = args.includes('--yes') || args.includes('-y');
48
54
 
49
55
  const components = ['agents', 'skills', 'instructions', 'commands', 'plugins'];
50
56
  if (!skipMCP) components.push('runtime');
@@ -56,13 +62,20 @@ async function main() {
56
62
  version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || '?';
57
63
  } catch { /* use default */ }
58
64
 
59
- console.log(`Pantheon OpenCode v${version} — ${isDryRun ? 'DRY RUN' : 'Installing...'}`);
60
65
  console.log('');
66
+ if (!forceInteractive || isDryRun) {
67
+ console.log(`Pantheon OpenCode v${version} — ${isDryRun ? 'DRY RUN' : 'Installing...'}`);
68
+ console.log('');
69
+ }
61
70
 
62
71
  try {
63
72
  const { installOpenCode } = await import('../scripts/install/opencode.mjs');
64
73
  const target = isProject ? process.cwd() : undefined;
65
- installOpenCode(target, isDryRun, forceReinstall, components);
74
+ await installOpenCode(target, isDryRun, forceReinstall, components, {
75
+ interactive: forceInteractive,
76
+ headless: forceHeadless,
77
+ yes: autoYes,
78
+ });
66
79
  } catch (err) {
67
80
  console.error(`❌ Installation failed: ${err.message}\n Run with --no-mcp to skip Python dependencies:\n npx pantheon-opencode init --no-mcp\n Or retry with --force to recreate the venv:\n npx pantheon-opencode init --force`);
68
81
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.1.1-beta.4",
3
+ "version": "1.1.1-beta.5",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { emitKeypressEvents } from 'node:readline'
4
+ import { colors, icons } from './cli-ui.mjs'
5
+
6
+ const cursorHide = () => process.stdout.write('\x1b[?25l')
7
+ const cursorShow = () => process.stdout.write('\x1b[?25h')
8
+ const cursorUp = (n) => process.stdout.write(`\x1b[${n}A`)
9
+ const clearDown = () => process.stdout.write('\x1b[J')
10
+
11
+ function setupStdin() {
12
+ if (!process.stdin.isTTY) return false
13
+ if (!process.stdout.isTTY) return false
14
+ emitKeypressEvents(process.stdin)
15
+ try {
16
+ process.stdin.setRawMode(true)
17
+ } catch {
18
+ return false
19
+ }
20
+ process.stdin.resume()
21
+ return true
22
+ }
23
+
24
+ function teardownStdin() {
25
+ try {
26
+ process.stdin.setRawMode(false)
27
+ process.stdin.removeAllListeners('keypress')
28
+ process.stdin.pause()
29
+ } catch {
30
+ /* safe to ignore */
31
+ }
32
+ }
33
+
34
+ export async function promptMultiSelect(options, { title = '', selected = new Set(), pageSize = 6 } = {}) {
35
+ if (options.length === 0) return new Set()
36
+
37
+ const preSelected = selected instanceof Set ? selected : new Set(selected)
38
+ const items = options.map((o) => ({
39
+ name: o.name,
40
+ value: o.value,
41
+ description: o.description || '',
42
+ checked: o.checked || preSelected.has(o.value),
43
+ }))
44
+
45
+ let cursor = 0
46
+ let scroll = 0
47
+ const maxVisible = Math.min(pageSize, options.length)
48
+ let previousLineCount = 0
49
+
50
+ const isTTY = setupStdin()
51
+ if (!isTTY) {
52
+ return new Set(items.filter((i) => i.checked).map((i) => i.value))
53
+ }
54
+ cursorHide()
55
+
56
+ function render() {
57
+ if (cursor < scroll) scroll = cursor
58
+ if (cursor >= scroll + maxVisible) scroll = cursor - maxVisible + 1
59
+
60
+ const visible = items.slice(scroll, scroll + maxVisible)
61
+ const lines = []
62
+
63
+ if (title) lines.push(colors.bold(title))
64
+
65
+ const hasMoreAbove = scroll > 0
66
+ const hasMoreBelow = scroll + maxVisible < options.length
67
+
68
+ if (hasMoreAbove) lines.push(colors.dim(' ...'))
69
+
70
+ for (let i = 0; i < visible.length; i++) {
71
+ const item = visible[i]
72
+ const idx = scroll + i
73
+ const active = idx === cursor
74
+ const box = item.checked ? '[x]' : '[ ]'
75
+ const ptr = active ? `${colors.cyan('>')} ` : ' '
76
+ const name = active ? colors.cyan(item.name) : item.name
77
+ const desc = item.description ? colors.dim(` — ${item.description}`) : ''
78
+ lines.push(`${ptr}${colors.bold(box)} ${name}${desc}`)
79
+ }
80
+
81
+ if (hasMoreBelow) lines.push(colors.dim(' ...'))
82
+
83
+ const count = items.filter((i) => i.checked).length
84
+ const total = items.length
85
+ const noun = count === 1 ? 'selected' : 'selected'
86
+ lines.push(colors.dim(`(${count}/${total} ${noun}) — ${colors.cyan('↑')}${colors.dim('/')}${colors.cyan('↓')} navigate · ${colors.cyan('Space')}${colors.dim(' toggle · ')}${colors.cyan('Enter')}${colors.dim(' confirm')}`))
87
+
88
+ const lineCount = lines.length
89
+ if (previousLineCount > 0) cursorUp(previousLineCount)
90
+ clearDown()
91
+ process.stdout.write(lines.join('\n') + '\n')
92
+ previousLineCount = lineCount
93
+ }
94
+
95
+ return new Promise((resolve, reject) => {
96
+ let settled = false
97
+
98
+ function finalize(result) {
99
+ if (settled) return
100
+ settled = true
101
+ cursorShow()
102
+ teardownStdin()
103
+ if (previousLineCount > 0) cursorUp(previousLineCount)
104
+ clearDown()
105
+ resolve(result)
106
+ }
107
+
108
+ function cancel() {
109
+ if (settled) return
110
+ settled = true
111
+ cursorShow()
112
+ teardownStdin()
113
+ if (previousLineCount > 0) cursorUp(previousLineCount)
114
+ clearDown()
115
+ reject(new Error('Canceled'))
116
+ }
117
+
118
+ function handler(str, key) {
119
+ if (!key) return
120
+ if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
121
+ cancel()
122
+ return
123
+ }
124
+ if (key.name === 'return' || key.name === 'enter') {
125
+ const result = new Set(items.filter((i) => i.checked).map((i) => i.value))
126
+ finalize(result)
127
+ return
128
+ }
129
+ if (key.name === 'up') {
130
+ cursor = (cursor - 1 + items.length) % items.length
131
+ render()
132
+ return
133
+ }
134
+ if (key.name === 'down') {
135
+ cursor = (cursor + 1) % items.length
136
+ render()
137
+ return
138
+ }
139
+ if (key.name === 'space') {
140
+ items[cursor].checked = !items[cursor].checked
141
+ render()
142
+ return
143
+ }
144
+ }
145
+
146
+ process.stdin.on('keypress', handler)
147
+ render()
148
+ })
149
+ }
150
+
151
+ export async function promptConfirm(message, { default: def = true } = {}) {
152
+ const label = def ? 'Y/n' : 'y/N'
153
+ const isTTY = setupStdin()
154
+ if (!isTTY) return def
155
+
156
+ cursorHide()
157
+ process.stdout.write(`${colors.bold('?')} ${message} ${colors.cyan(label)} `)
158
+
159
+ return new Promise((resolve, reject) => {
160
+ let settled = false
161
+
162
+ function finalize(result) {
163
+ if (settled) return
164
+ settled = true
165
+ cursorShow()
166
+ teardownStdin()
167
+ process.stdout.write(`${result ? 'Yes' : 'No'}\n`)
168
+ resolve(result)
169
+ }
170
+
171
+ function cancel() {
172
+ if (settled) return
173
+ settled = true
174
+ cursorShow()
175
+ teardownStdin()
176
+ process.stdout.write('\n')
177
+ reject(new Error('Canceled'))
178
+ }
179
+
180
+ function handler(str, key) {
181
+ if (!key) return
182
+ if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
183
+ cancel()
184
+ return
185
+ }
186
+ if (key.name === 'return' || key.name === 'enter') {
187
+ finalize(def)
188
+ return
189
+ }
190
+ if (str) {
191
+ const ch = str.toLowerCase()
192
+ if (ch === 'y' || ch === 'yes') {
193
+ finalize(true)
194
+ } else if (ch === 'n' || ch === 'no') {
195
+ finalize(false)
196
+ }
197
+ }
198
+ }
199
+
200
+ process.stdin.on('keypress', handler)
201
+ })
202
+ }
203
+
204
+ export async function promptList(options, { title = '' } = {}) {
205
+ if (options.length === 0) throw new Error('No options provided')
206
+ if (options.length === 1) return options[0].value
207
+
208
+ const items = options.map((o) => ({
209
+ name: o.name,
210
+ value: o.value,
211
+ description: o.description || '',
212
+ }))
213
+
214
+ let cursor = 0
215
+ const pageSize = Math.min(8, options.length)
216
+ let scroll = 0
217
+ const maxVisible = Math.min(pageSize, options.length)
218
+ let previousLineCount = 0
219
+
220
+ const isTTY = setupStdin()
221
+ if (!isTTY) return items[0].value
222
+ cursorHide()
223
+
224
+ function render() {
225
+ if (cursor < scroll) scroll = cursor
226
+ if (cursor >= scroll + maxVisible) scroll = cursor - maxVisible + 1
227
+
228
+ const visible = items.slice(scroll, scroll + maxVisible)
229
+ const lines = []
230
+
231
+ if (title) lines.push(colors.bold(title))
232
+
233
+ const hasMoreAbove = scroll > 0
234
+ const hasMoreBelow = scroll + maxVisible < options.length
235
+
236
+ if (hasMoreAbove) lines.push(colors.dim(' ...'))
237
+
238
+ for (let i = 0; i < visible.length; i++) {
239
+ const item = visible[i]
240
+ const idx = scroll + i
241
+ const active = idx === cursor
242
+ const ptr = active ? `${colors.cyan('>')} ` : ' '
243
+ const name = active ? colors.cyan(item.name) : item.name
244
+ const desc = item.description ? colors.dim(` — ${item.description}`) : ''
245
+ lines.push(`${ptr}${colors.bold('◉')} ${name}${desc}`)
246
+ }
247
+
248
+ if (hasMoreBelow) lines.push(colors.dim(' ...'))
249
+
250
+ lines.push(colors.dim(`(${colors.cyan('↑')}${colors.dim('/')}${colors.cyan('↓')} navigate · ${colors.cyan('Enter')}${colors.dim(' select')}`))
251
+
252
+ const lineCount = lines.length
253
+ if (previousLineCount > 0) cursorUp(previousLineCount)
254
+ clearDown()
255
+ process.stdout.write(lines.join('\n') + '\n')
256
+ previousLineCount = lineCount
257
+ }
258
+
259
+ return new Promise((resolve, reject) => {
260
+ let settled = false
261
+
262
+ function finalize(result) {
263
+ if (settled) return
264
+ settled = true
265
+ cursorShow()
266
+ teardownStdin()
267
+ if (previousLineCount > 0) cursorUp(previousLineCount)
268
+ clearDown()
269
+ resolve(result)
270
+ }
271
+
272
+ function cancel() {
273
+ if (settled) return
274
+ settled = true
275
+ cursorShow()
276
+ teardownStdin()
277
+ if (previousLineCount > 0) cursorUp(previousLineCount)
278
+ clearDown()
279
+ reject(new Error('Canceled'))
280
+ }
281
+
282
+ function handler(str, key) {
283
+ if (!key) return
284
+ if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
285
+ cancel()
286
+ return
287
+ }
288
+ if (key.name === 'return' || key.name === 'enter') {
289
+ finalize(items[cursor].value)
290
+ return
291
+ }
292
+ if (key.name === 'up') {
293
+ cursor = (cursor - 1 + items.length) % items.length
294
+ render()
295
+ return
296
+ }
297
+ if (key.name === 'down') {
298
+ cursor = (cursor + 1) % items.length
299
+ render()
300
+ return
301
+ }
302
+ }
303
+
304
+ process.stdin.on('keypress', handler)
305
+ render()
306
+ })
307
+ }
308
+
309
+ export async function runInteractiveInstall({ components = [], defaultComponents = [] } = {}) {
310
+ const defaultSet = new Set(
311
+ defaultComponents.length > 0 ? defaultComponents : components,
312
+ )
313
+
314
+ const componentOptions = [
315
+ {
316
+ name: 'agents',
317
+ value: 'agents',
318
+ description: `Agent configurations and routing (${14} agents)`,
319
+ },
320
+ {
321
+ name: 'skills',
322
+ value: 'skills',
323
+ description: `Reusable skill workflows (${21} skills)`,
324
+ },
325
+ {
326
+ name: 'instructions',
327
+ value: 'instructions',
328
+ description: 'AGENTS.md and instructional files',
329
+ },
330
+ {
331
+ name: 'prompts',
332
+ value: 'prompts',
333
+ description: 'Prompt templates for agents',
334
+ },
335
+ {
336
+ name: 'commands',
337
+ value: 'commands',
338
+ description: 'OpenCode command shortcuts',
339
+ },
340
+ {
341
+ name: 'plugins',
342
+ value: 'plugins',
343
+ description: 'TUI sidebar plugin',
344
+ },
345
+ {
346
+ name: 'runtime',
347
+ value: 'runtime',
348
+ description: 'Python venv + MCP server scripts',
349
+ },
350
+ ]
351
+
352
+ process.stdout.write('\n')
353
+ process.stdout.write(`${colors.bold(colors.cyan('📦 OpenCode Installer'))}\n\n`)
354
+
355
+ const selectedComponents = await promptMultiSelect(componentOptions, {
356
+ title: 'Select components to install:',
357
+ selected: defaultSet,
358
+ pageSize: 7,
359
+ })
360
+
361
+ process.stdout.write('\n')
362
+
363
+ if (selectedComponents.size === 0) {
364
+ process.stdout.write(`${colors.yellow('No components selected — using defaults\n')}`)
365
+ defaultSet.forEach((v) => selectedComponents.add(v))
366
+ }
367
+
368
+ const targetOptions = [
369
+ {
370
+ name: 'Global directory',
371
+ value: 'global',
372
+ description: '~/.config/opencode (shared across projects)',
373
+ },
374
+ {
375
+ name: 'Current project',
376
+ value: 'project',
377
+ description: './.opencode (scoped to this project)',
378
+ },
379
+ ]
380
+
381
+ const target = await promptList(targetOptions, {
382
+ title: 'Install location:',
383
+ })
384
+
385
+ process.stdout.write('\n')
386
+
387
+ const confirmed = await promptConfirm(
388
+ `Install ${selectedComponents.size} component(s) in ${target === 'global' ? 'global config' : 'current project'}?`,
389
+ { default: true },
390
+ )
391
+
392
+ process.stdout.write('\n')
393
+
394
+ return { components: selectedComponents, target, confirmed }
395
+ }
@@ -8,7 +8,7 @@ import { homedir } from 'node:os'
8
8
  import { join, resolve } from 'node:path'
9
9
  import { healthCheck } from './health-check.mjs'
10
10
  import { detectVersion, runMigrations } from './migrate.mjs'
11
- import { configure, section, step, success, warning, error, info, bullet, spinner, printSummary } from './cli-ui.mjs'
11
+ import { colors, configure, section, step, success, warning, error, info, bullet, spinner, printSummary } from './cli-ui.mjs'
12
12
  import {
13
13
  collectSkillNames,
14
14
  copyFiles,
@@ -24,6 +24,8 @@ import { setupVenv } from './venv.mjs'
24
24
  import { installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
25
25
  import { resolveComponents } from './component.mjs'
26
26
 
27
+ const COMPONENT_NAMES = ['agents', 'skills', 'instructions', 'prompts', 'commands', 'plugins', 'runtime']
28
+
27
29
  function deepClone(value) {
28
30
  return JSON.parse(JSON.stringify(value))
29
31
  }
@@ -72,11 +74,12 @@ function isGlobalConfigDir(target) {
72
74
  return resolve(target) === xdgDir
73
75
  }
74
76
 
75
- export function installOpenCode(
77
+ export async function installOpenCode(
76
78
  target,
77
79
  dryRun = false,
78
80
  clean = false,
79
81
  components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
82
+ opts = {},
80
83
  ) {
81
84
  // Default target if not provided (global ~/.config/opencode)
82
85
  if (!target) {
@@ -86,6 +89,35 @@ export function installOpenCode(
86
89
  const componentSet = new Set(components)
87
90
  const stats = summary.opencode
88
91
 
92
+ // Interactive mode handling
93
+ const stdinTTY = process.stdin.isTTY && process.stdout.isTTY
94
+ const forceInteractive = opts.interactive === true
95
+ const forceHeadless = opts.headless === true
96
+ const autoYes = opts.yes === true
97
+ const interactive = forceInteractive || (stdinTTY && !forceHeadless)
98
+
99
+ if (interactive && !dryRun) {
100
+ const { runInteractiveInstall } = await import('./interactive.mjs')
101
+ const result = await runInteractiveInstall({
102
+ components,
103
+ defaultComponents: COMPONENT_NAMES,
104
+ })
105
+
106
+ if (!result.confirmed && !autoYes) {
107
+ info('Installation canceled.')
108
+ return stats
109
+ }
110
+
111
+ // Map target selection to actual path
112
+ const newComponents = [...result.components]
113
+ componentSet.clear()
114
+ for (const c of newComponents) componentSet.add(c)
115
+
116
+ if (result.target === 'project') {
117
+ target = process.cwd()
118
+ }
119
+ }
120
+
89
121
  // Determine layout based on install scope.
90
122
  // Global config dir (~/.opencode or ~/.config/opencode) uses a flat layout:
91
123
  // agents/ skills/ commands/ plugins/
@@ -679,5 +711,25 @@ export function installOpenCode(
679
711
  }
680
712
  }
681
713
 
682
- printSummary(target, ['opencode'], stats)
714
+ if (interactive && !dryRun) {
715
+ const created = stats.created
716
+ const skipped = stats.skipped
717
+ const errors = stats.errors
718
+
719
+ process.stdout.write('\n')
720
+ process.stdout.write(` ${colors.bold(colors.green('Installation complete'))}\n`)
721
+ process.stdout.write(` ${colors.dim('\u2500'.repeat(Math.min(process.stdout.columns || 60, 60)))}\n`)
722
+ process.stdout.write(` ${colors.green('\u2713')} ${created} component(s) installed\n`)
723
+ if (skipped > 0) process.stdout.write(` ${colors.dim('\u2014')} ${skipped} already up-to-date\n`)
724
+ if (errors > 0) process.stdout.write(` ${colors.yellow('\u26a0')} ${errors} error(s) encountered\n`)
725
+ process.stdout.write('\n')
726
+ process.stdout.write(` ${colors.bold('Next steps:')}\n`)
727
+ process.stdout.write(` ${colors.dim('\u2022')} Configure agents in opencode.json\n`)
728
+ process.stdout.write(` ${colors.dim('\u2022')} Add MCP servers in mcp.json\n`)
729
+ process.stdout.write(` ${colors.dim('\u2022')} Run 'opencode doctor' to verify\n`)
730
+ process.stdout.write(` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`)
731
+ process.stdout.write('\n')
732
+ } else {
733
+ printSummary(target, ['opencode'], stats)
734
+ }
683
735
  }