opencode-onboard 0.2.7 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-onboard",
3
- "version": "0.2.7",
3
+ "version": "0.2.13",
4
4
  "description": "Prepare any brownfield codebase for AI agent workflows using OpenCode, OpenSpec, and ensemble orchestration.",
5
5
  "keywords": [
6
6
  "opencode",
package/src/index.js CHANGED
@@ -1,26 +1,173 @@
1
1
  #!/usr/bin/env node
2
- import chalk from 'chalk'
3
- import { createRequire } from 'node:module'
4
- import { checkEnv } from './steps/check-env.js'
5
- import { checkPlatform } from './steps/check-platform.js'
6
- import { checkRtk } from './steps/check-rtk.js'
2
+ import chalk from 'chalk'
3
+ import { createRequire } from 'node:module'
4
+ import path from 'node:path'
5
+ import fse from 'fs-extra'
6
+ import { checkEnv } from './steps/check-env.js'
7
+ import { checkPlatform } from './steps/check-platform.js'
8
+ import { checkRtk } from './steps/check-rtk.js'
7
9
  import { chooseModels } from './steps/choose-models.js'
8
10
  import { choosePlatform } from './steps/choose-platform.js'
9
11
  import { chooseSourceScope } from './steps/choose-source-scope.js'
10
12
  import { chooseSkillsProvider } from './steps/choose-skills-provider.js'
11
13
  import { cleanAiFiles } from './steps/clean-ai-files.js'
12
14
  import { copyContentStep } from './steps/copy-content.js'
13
- import { initOpenspec } from './steps/init-openspec.js'
15
+ import { initOpenspec } from './steps/init-openspec.js'
14
16
  import { patchAgentsMd } from './steps/patch-agents-md.js'
17
+ import { installQuota } from './steps/install-quota.js'
18
+ import { installCaveman } from './steps/install-caveman.js'
19
+ import { enableCavemanGuidance } from './steps/enable-caveman-guidance.js'
15
20
  import { installBrowser } from './steps/install-browser.js'
16
21
  import { writeOnboardConfig } from './steps/write-onboard-config.js'
17
-
18
- if (process.stdout.isTTY) console.clear()
19
- console.log()
20
- const require = createRequire(import.meta.url)
21
- const { version } = require('../package.json')
22
- const logo = chalk.hex('#fe3d57')
23
- const bannerLines = [
22
+ import { loading } from './utils/exec.js'
23
+ import { tokenOptimizationStep } from './steps/token-optimization.js'
24
+
25
+ function printHelp(version) {
26
+ console.log(`opencode-onboard v${version}`)
27
+ console.log()
28
+ console.log('Usage:')
29
+ console.log(' npx opencode-onboard Run full onboarding wizard')
30
+ console.log(' npx opencode-onboard <command> Run a single step command')
31
+ console.log()
32
+ console.log('Commands:')
33
+ console.log(' clean Run AI files cleanup step')
34
+ console.log(' platform Run platform selection step')
35
+ console.log(' copy Run content copy step')
36
+ console.log(' openspec Run OpenSpec initialization step')
37
+ console.log(' skills Run skills install step')
38
+ console.log(' models Run models selection step')
39
+ console.log(' optimization Run token optimization tools step')
40
+ console.log(' quota Run opencode-quota installer step')
41
+ console.log(' rtk Run rtk check step')
42
+ console.log(' caveman Run caveman install + guidance steps')
43
+ console.log(' browser Run opencode-browser installer step')
44
+ console.log(' metadata Write onboarding metadata step')
45
+ console.log()
46
+ console.log('Options:')
47
+ console.log(' -h, --help Show this help message')
48
+ }
49
+
50
+ async function readOnboardConfig() {
51
+ const cfgPath = path.join(process.cwd(), '.opencode', 'opencode-onboard.json')
52
+ if (!await fse.pathExists(cfgPath)) return null
53
+ try {
54
+ return await fse.readJson(cfgPath)
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ async function runSingleCommand(command) {
61
+ const saved = await readOnboardConfig()
62
+ const savedWizard = saved?.wizard ?? {}
63
+ const ctx = {
64
+ hasDesign: !!savedWizard?.preserved?.design,
65
+ hasArchitecture: !!savedWizard?.preserved?.architecture,
66
+ hasOpenspec: !!savedWizard?.preserved?.openspec,
67
+ sourceMode: savedWizard?.sourceMode ?? 'current',
68
+ sourceRoots: Array.isArray(savedWizard?.sourceRoots) ? savedWizard.sourceRoots : [],
69
+ }
70
+ const platform = savedWizard?.platform
71
+ const resolvedPlatform = platform === 'azure' || platform === 'github' ? platform : 'github'
72
+
73
+ if (command === 'clean') {
74
+ await cleanAiFiles()
75
+ return true
76
+ }
77
+
78
+ if (command === 'platform') {
79
+ await choosePlatform()
80
+ return true
81
+ }
82
+
83
+ if (command === 'copy') {
84
+ await copyContentStep(resolvedPlatform, ctx)
85
+ await patchAgentsMd(ctx)
86
+ return true
87
+ }
88
+
89
+ if (command === 'openspec') {
90
+ await initOpenspec()
91
+ return true
92
+ }
93
+
94
+ if (command === 'skills') {
95
+ await chooseSkillsProvider()
96
+ return true
97
+ }
98
+
99
+ if (command === 'models') {
100
+ await chooseModels()
101
+ return true
102
+ }
103
+
104
+ if (command === 'optimization') {
105
+ await tokenOptimizationStep({ skillsProvider: savedWizard?.additionalSkillsProvider })
106
+ return true
107
+ }
108
+
109
+ if (command === 'quota') {
110
+ await installQuota()
111
+ return true
112
+ }
113
+
114
+ if (command === 'rtk') {
115
+ await checkRtk()
116
+ return true
117
+ }
118
+
119
+ if (command === 'caveman') {
120
+ const caveman = await installCaveman({ skillsProvider: savedWizard?.additionalSkillsProvider })
121
+ await enableCavemanGuidance(caveman)
122
+ return true
123
+ }
124
+
125
+ if (command === 'browser') {
126
+ await installBrowser()
127
+ return true
128
+ }
129
+
130
+ if (command === 'metadata') {
131
+ await writeOnboardConfig({
132
+ ...ctx,
133
+ platform: resolvedPlatform,
134
+ additionalSkillsProvider: savedWizard?.additionalSkillsProvider ?? 'none',
135
+ planModel: savedWizard?.models?.plan ?? null,
136
+ buildModel: savedWizard?.models?.build ?? null,
137
+ fastModel: savedWizard?.models?.fast ?? null,
138
+ optionalTools: savedWizard?.optionalTools ?? null,
139
+ cavemanGuidance: savedWizard?.cavemanGuidance ?? null,
140
+ })
141
+ return true
142
+ }
143
+
144
+ return false
145
+ }
146
+
147
+ if (process.stdout.isTTY) console.clear()
148
+ console.log()
149
+ const require = createRequire(import.meta.url)
150
+ const { version } = require('../package.json')
151
+ const args = process.argv.slice(2)
152
+
153
+ if (args.includes('-h') || args.includes('--help')) {
154
+ printHelp(version)
155
+ process.exit(0)
156
+ }
157
+
158
+ if (args.length > 0) {
159
+ const ok = await runSingleCommand(args[0])
160
+ if (!ok) {
161
+ console.log(chalk.red(`Unknown command: ${args[0]}`))
162
+ console.log()
163
+ printHelp(version)
164
+ process.exit(1)
165
+ }
166
+ process.exit(0)
167
+ }
168
+
169
+ const logo = chalk.hex('#fe3d57')
170
+ const bannerLines = [
24
171
  logo(' '),
25
172
  logo(' ▒▒▒▒▒▒▒▒▒▒▒▒▒ '),
26
173
  logo(' ▒▒▓ ▓▒▓ '),
@@ -58,42 +205,55 @@ if (process.stdin.isTTY) {
58
205
  }
59
206
 
60
207
  try {
61
- // 1. Check Node + pnpm
62
- await checkEnv()
63
-
208
+ // 1. Check Node + pnpm
209
+ await checkEnv()
210
+ loading('preparing next step...')
211
+
64
212
  // 2. Choose source code scope for init analysis
65
213
  const scope = await chooseSourceScope()
214
+ loading('preparing next step...')
66
215
 
67
216
  // 3. Clean existing AI config files, detect preserved state
68
217
  const preserve = await cleanAiFiles()
69
218
  const ctx = { ...preserve, ...scope }
219
+ loading('preparing next step...')
70
220
 
71
221
  // 4. Choose platform
72
222
  const platform = await choosePlatform()
223
+ loading('preparing next step...')
73
224
 
74
225
  // 5. Check platform CLI (az or gh)
75
226
  await checkPlatform(platform)
227
+ loading('preparing next step...')
76
228
 
77
229
  // 6. Copy content
78
230
  await copyContentStep(platform, ctx)
231
+ loading('preparing next step...')
79
232
 
80
233
  // 6b. Patch AGENTS.md to skip steps for already-existing files
81
234
  await patchAgentsMd(ctx)
235
+ loading('preparing next step...')
82
236
 
83
237
  // 7. Init OpenSpec
84
238
  await initOpenspec()
239
+ loading('preparing next step...')
85
240
 
86
241
  // 8. Install skills
87
242
  const skillsSelection = await chooseSkillsProvider()
243
+ loading('preparing next step...')
88
244
 
89
245
  // 9. Choose models
90
246
  const selectedModels = await chooseModels()
247
+ loading('preparing next step...')
91
248
 
92
- // 10. Check RTK
93
- await checkRtk()
249
+ // 10. Token optimization tools
250
+ const tokenOpt = await tokenOptimizationStep({ skillsProvider: skillsSelection.additionalSkillsProvider })
251
+ const { rtk, quota, caveman, cavemanGuidance } = tokenOpt
252
+ loading('preparing next step...')
94
253
 
95
254
  // 11. Install opencode-browser
96
255
  await installBrowser()
256
+ loading('preparing next step...')
97
257
 
98
258
  // 12. Write onboarding metadata
99
259
  await writeOnboardConfig({
@@ -101,6 +261,8 @@ try {
101
261
  platform,
102
262
  ...skillsSelection,
103
263
  ...selectedModels,
264
+ optionalTools: { rtk, quota, caveman },
265
+ cavemanGuidance,
104
266
  })
105
267
 
106
268
  // Done
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
3
  vi.mock('../../utils/exec.js', () => ({
4
4
  commandExists: vi.fn(),
5
5
  header: vi.fn(),
6
+ loading: vi.fn(),
6
7
  success: vi.fn(),
7
8
  warn: vi.fn(),
8
9
  info: vi.fn(),
@@ -20,7 +21,7 @@ describe('checkRtk()', () => {
20
21
  it('prints success when rtk is available', async () => {
21
22
  commandExists.mockResolvedValue(true)
22
23
 
23
- await checkRtk()
24
+ await checkRtk({ skipPrompt: true })
24
25
 
25
26
  expect(success).toHaveBeenCalledWith('rtk is available')
26
27
  expect(warn).not.toHaveBeenCalled()
@@ -29,7 +30,7 @@ describe('checkRtk()', () => {
29
30
  it('prints warning with install instructions when rtk is not found', async () => {
30
31
  commandExists.mockResolvedValue(false)
31
32
 
32
- await checkRtk()
33
+ await checkRtk({ skipPrompt: true })
33
34
 
34
35
  expect(warn).toHaveBeenCalledWith('rtk not found on PATH.')
35
36
  expect(success).not.toHaveBeenCalled()
@@ -39,7 +39,7 @@ describe('cleanAiFiles()', () => {
39
39
 
40
40
  await cleanAiFiles()
41
41
 
42
- expect(success).toHaveBeenCalledWith('No existing AI config files found')
42
+ expect(success).toHaveBeenCalledWith('No existing AI config files to remove')
43
43
  })
44
44
 
45
45
  it('removes found AI files after Enter', async () => {
@@ -34,7 +34,8 @@ describe('copyContentStep()', () => {
34
34
  expect(copyContent).toHaveBeenCalledWith(
35
35
  expect.stringContaining('content'),
36
36
  process.cwd(),
37
- 'github'
37
+ 'github',
38
+ {}
38
39
  )
39
40
  expect(success).toHaveBeenCalledWith('Files copied to project root')
40
41
  })
@@ -47,7 +48,8 @@ describe('copyContentStep()', () => {
47
48
  expect(copyContent).toHaveBeenCalledWith(
48
49
  expect.stringContaining('content'),
49
50
  process.cwd(),
50
- 'azure'
51
+ 'azure',
52
+ {}
51
53
  )
52
54
  })
53
55
 
@@ -0,0 +1,78 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ vi.mock('@inquirer/prompts', () => ({
4
+ checkbox: vi.fn(),
5
+ }))
6
+
7
+ vi.mock('../../utils/exec.js', () => ({
8
+ header: vi.fn(),
9
+ info: vi.fn(),
10
+ loading: vi.fn(),
11
+ success: vi.fn(),
12
+ warn: vi.fn(),
13
+ }))
14
+
15
+ vi.mock('../check-rtk.js', () => ({
16
+ checkRtk: vi.fn(),
17
+ }))
18
+
19
+ vi.mock('../install-quota.js', () => ({
20
+ installQuota: vi.fn(),
21
+ }))
22
+
23
+ vi.mock('../install-caveman.js', () => ({
24
+ installCaveman: vi.fn(),
25
+ }))
26
+
27
+ vi.mock('../enable-caveman-guidance.js', () => ({
28
+ enableCavemanGuidance: vi.fn(),
29
+ }))
30
+
31
+ import { checkbox } from '@inquirer/prompts'
32
+ import { warn } from '../../utils/exec.js'
33
+ import { checkRtk } from '../check-rtk.js'
34
+ import { installQuota } from '../install-quota.js'
35
+ import { installCaveman } from '../install-caveman.js'
36
+ import { enableCavemanGuidance } from '../enable-caveman-guidance.js'
37
+ import { tokenOptimizationStep } from '../token-optimization.js'
38
+
39
+ describe('tokenOptimizationStep()', () => {
40
+ beforeEach(() => {
41
+ vi.clearAllMocks()
42
+ })
43
+
44
+ it('runs all optimizations by default selection', async () => {
45
+ checkbox.mockResolvedValue(['rtk', 'quota', 'caveman'])
46
+ checkRtk.mockResolvedValue({ optedIn: true, checked: true, available: true })
47
+ installQuota.mockResolvedValue({ optedIn: true, installed: true })
48
+ installCaveman.mockResolvedValue({ optedIn: true, installed: true })
49
+ enableCavemanGuidance.mockResolvedValue({ enabled: true })
50
+
51
+ const result = await tokenOptimizationStep()
52
+
53
+ expect(checkRtk).toHaveBeenCalledWith({ skipHeader: true, skipPrompt: true })
54
+ expect(installQuota).toHaveBeenCalledWith({ skipHeader: true, skipPrompt: true })
55
+ expect(installCaveman).toHaveBeenCalledWith({ skipHeader: true, skipPrompt: true })
56
+ expect(enableCavemanGuidance).toHaveBeenCalledWith({ optedIn: true, installed: true })
57
+ expect(result.rtk.available).toBe(true)
58
+ expect(result.quota.installed).toBe(true)
59
+ expect(result.caveman.installed).toBe(true)
60
+ expect(result.cavemanGuidance.enabled).toBe(true)
61
+ })
62
+
63
+ it('skips all tools when nothing is selected', async () => {
64
+ checkbox.mockResolvedValue([])
65
+
66
+ const result = await tokenOptimizationStep()
67
+
68
+ expect(checkRtk).not.toHaveBeenCalled()
69
+ expect(installQuota).not.toHaveBeenCalled()
70
+ expect(installCaveman).not.toHaveBeenCalled()
71
+ expect(enableCavemanGuidance).not.toHaveBeenCalled()
72
+ expect(warn).toHaveBeenCalledWith('No token optimization tools selected')
73
+ expect(result.rtk.optedIn).toBe(false)
74
+ expect(result.quota.optedIn).toBe(false)
75
+ expect(result.caveman.optedIn).toBe(false)
76
+ expect(result.cavemanGuidance.enabled).toBe(false)
77
+ })
78
+ })
@@ -1,13 +1,30 @@
1
- import { code, commandExists, header, info, success, warn } from '../utils/exec.js'
1
+ import { confirm } from '@inquirer/prompts'
2
+ import { code, commandExists, header, info, loading, success, warn } from '../utils/exec.js'
2
3
 
3
- export async function checkRtk() {
4
- header('Step 10, Checking rtk')
4
+ export async function checkRtk(options = {}) {
5
+ if (!options.skipHeader) header('Checking rtk')
6
+
7
+ let shouldCheck = true
8
+ if (!options.skipPrompt) {
9
+ info('Recommended: install and verify rtk for safer agent CLI command execution.')
10
+ shouldCheck = await confirm({
11
+ message: 'Check rtk now?',
12
+ default: true,
13
+ })
14
+ }
15
+
16
+ if (!shouldCheck) {
17
+ warn('Skipped rtk check (you can install it later)')
18
+ return { optedIn: false, checked: false, available: false }
19
+ }
20
+
21
+ loading('checking rtk...')
5
22
 
6
23
  const available = await commandExists('rtk')
7
24
 
8
25
  if (available) {
9
26
  success('rtk is available')
10
- return
27
+ return { optedIn: true, checked: true, available: true }
11
28
  }
12
29
 
13
30
  warn('rtk not found on PATH.')
@@ -17,4 +34,5 @@ export async function checkRtk() {
17
34
  console.log()
18
35
  info('After installing, verify with:')
19
36
  code(['rtk --version'])
37
+ return { optedIn: true, checked: true, available: false }
20
38
  }
@@ -65,7 +65,7 @@ export async function chooseSourceScope() {
65
65
  choices: parentFolders.map(d => ({
66
66
  name: `../${d.name}`,
67
67
  value: d.abs,
68
- checked: false,
68
+ checked: true,
69
69
  })),
70
70
  required: true,
71
71
  })
@@ -0,0 +1,93 @@
1
+ import path from 'node:path'
2
+ import fse from 'fs-extra'
3
+ import { info, success, warn } from '../utils/exec.js'
4
+
5
+ const MARKER_START = '<!-- CAVEMAN-GUIDANCE-START -->'
6
+ const MARKER_END = '<!-- CAVEMAN-GUIDANCE-END -->'
7
+
8
+ const AGENTS_BLOCK = `${MARKER_START}
9
+ ## Caveman Mode (Optional)
10
+
11
+ If caveman is installed, prefer concise responses by default to reduce token usage while keeping full technical accuracy.
12
+
13
+ Treat any of these as a direct trigger to enable concise style:
14
+ - "talk like caveman"
15
+ - "caveman mode"
16
+ - "less tokens please"
17
+
18
+ When enabled:
19
+ - Keep explanations short and direct
20
+ - Prioritize exact commands, paths, and outcomes
21
+ - Avoid filler, greetings, and repeated context
22
+
23
+ The user can disable it with phrases like: "normal mode" or "stop caveman".
24
+ ${MARKER_END}`
25
+
26
+ const OPSX_BLOCK = `${MARKER_START}
27
+ ## Caveman Output Style
28
+
29
+ If the user requests "talk like caveman", "caveman mode", or "less tokens please", keep all status updates concise:
30
+ - Report only key actions, blockers, and next commands
31
+ - Keep completion updates brief and factual
32
+ - Preserve technical precision; compress wording only
33
+ ${MARKER_END}`
34
+
35
+ async function appendBlockIfMissing(filePath, block) {
36
+ if (!await fse.pathExists(filePath)) return { ok: false, reason: 'missing-file' }
37
+
38
+ const current = await fse.readFile(filePath, 'utf-8')
39
+ if (current.includes(MARKER_START)) return { ok: true, changed: false }
40
+
41
+ const next = `${current.replace(/\s*$/, '')}\n\n${block}\n`
42
+ await fse.writeFile(filePath, next, 'utf-8')
43
+ return { ok: true, changed: true }
44
+ }
45
+
46
+ export async function enableCavemanGuidance(cavemanResult) {
47
+ if (!cavemanResult?.installed) {
48
+ info('Caveman guidance skipped (caveman not installed)')
49
+ return { enabled: false }
50
+ }
51
+
52
+ const targets = [
53
+ { rel: 'AGENTS.md', block: AGENTS_BLOCK },
54
+ ]
55
+
56
+ const commandsDir = path.join(process.cwd(), '.opencode', 'commands')
57
+ if (await fse.pathExists(commandsDir)) {
58
+ const entries = await fse.readdir(commandsDir)
59
+ for (const name of entries) {
60
+ if (!/^opsx-.*\.md$/i.test(name)) continue
61
+ targets.push({ rel: path.join('.opencode', 'commands', name), block: OPSX_BLOCK })
62
+ }
63
+ }
64
+
65
+ const skillsDir = path.join(process.cwd(), '.opencode', 'skills')
66
+ if (await fse.pathExists(skillsDir)) {
67
+ const skillEntries = await fse.readdir(skillsDir)
68
+ for (const dirName of skillEntries) {
69
+ if (!/^openspec-/i.test(dirName)) continue
70
+ targets.push({ rel: path.join('.opencode', 'skills', dirName, 'SKILL.md'), block: OPSX_BLOCK })
71
+ }
72
+ }
73
+
74
+ let changedCount = 0
75
+ for (const target of targets) {
76
+ const abs = path.join(process.cwd(), target.rel)
77
+ try {
78
+ const res = await appendBlockIfMissing(abs, target.block)
79
+ if (!res.ok) {
80
+ warn(`Could not apply caveman guidance to ${target.rel} (${res.reason})`)
81
+ } else if (res.changed) {
82
+ changedCount++
83
+ }
84
+ } catch (err) {
85
+ warn(`Could not apply caveman guidance to ${target.rel}: ${err.message}`)
86
+ }
87
+ }
88
+
89
+ if (changedCount > 0) success('Caveman guidance enabled in AGENTS/OpenSpec prompts')
90
+ else info('Caveman guidance already present')
91
+
92
+ return { enabled: true, patchedFiles: changedCount }
93
+ }
@@ -0,0 +1,43 @@
1
+ import { execa } from 'execa'
2
+ import fse from 'fs-extra'
3
+ import path from 'node:path'
4
+ import { header, success, warn, error, loading, info } from '../utils/exec.js'
5
+
6
+ const SKILLS_LOCK_CANDIDATES = [
7
+ 'skills-lock.json',
8
+ '.skills-lock.json',
9
+ '.skills/skills-lock.json',
10
+ ]
11
+
12
+ export async function installCaveman(options = {}) {
13
+ if (!options.skipHeader) header('Installing caveman')
14
+
15
+ loading('installing caveman...')
16
+
17
+ try {
18
+ info('Installing caveman via npx skills')
19
+ const result = await execa('npx', ['skills', 'add', 'JuliusBrussee/caveman/caveman', '-a', 'opencode', '--yes'], {
20
+ reject: false,
21
+ timeout: 600000,
22
+ stdio: 'pipe',
23
+ })
24
+
25
+ if (result.exitCode === 0) {
26
+ if (options.skillsProvider !== 'npx-skills') {
27
+ for (const rel of SKILLS_LOCK_CANDIDATES) {
28
+ const abs = path.join(process.cwd(), rel)
29
+ if (await fse.pathExists(abs)) await fse.remove(abs)
30
+ }
31
+ }
32
+ success('caveman installed')
33
+ return { optedIn: true, installed: true }
34
+ } else {
35
+ if (result.stderr?.trim()) warn(result.stderr.trim().split('\n').slice(-3).join('\n'))
36
+ warn('caveman install exited with non-zero code')
37
+ return { optedIn: true, installed: false }
38
+ }
39
+ } catch (err) {
40
+ error(`Failed to install caveman: ${err.message}`)
41
+ return { optedIn: true, installed: false }
42
+ }
43
+ }
@@ -0,0 +1,82 @@
1
+ import { confirm } from '@inquirer/prompts'
2
+ import fse from 'fs-extra'
3
+ import path from 'node:path'
4
+ import { header, success, warn, error, loading, info } from '../utils/exec.js'
5
+
6
+ const PLUGIN = '@slkiser/opencode-quota'
7
+
8
+ function ensurePlugin(config) {
9
+ if (!Array.isArray(config.plugin)) config.plugin = []
10
+ if (!config.plugin.includes(PLUGIN)) config.plugin.push(PLUGIN)
11
+ }
12
+
13
+ function addIfMissing(target, key, value) {
14
+ if (!(key in target)) target[key] = value
15
+ }
16
+
17
+ export async function installQuota(options = {}) {
18
+ if (!options.skipHeader) header('Installing opencode-quota')
19
+
20
+ let shouldInstall = true
21
+ if (!options.skipPrompt && process.stdin.isTTY) {
22
+ const timeoutMs = 20000
23
+ const choice = await Promise.race([
24
+ confirm({
25
+ message: 'Install opencode-quota with recommended defaults?',
26
+ default: true,
27
+ }),
28
+ new Promise(resolve => setTimeout(() => resolve(true), timeoutMs)),
29
+ ])
30
+ shouldInstall = choice !== false
31
+ }
32
+
33
+ if (!shouldInstall) {
34
+ warn('Skipped opencode-quota installation')
35
+ return { optedIn: false, installed: false }
36
+ }
37
+
38
+ loading('configuring opencode-quota...')
39
+
40
+ try {
41
+ const opencodeDir = path.join(process.cwd(), '.opencode')
42
+ const opencodePath = path.join(opencodeDir, 'opencode.json')
43
+ const tuiPath = path.join(opencodeDir, 'tui.json')
44
+ const quotaDir = path.join(opencodeDir, 'opencode-quota')
45
+ const quotaPath = path.join(quotaDir, 'quota-toast.json')
46
+
47
+ const opencode = await fse.pathExists(opencodePath)
48
+ ? await fse.readJson(opencodePath)
49
+ : { $schema: 'https://opencode.ai/config.json' }
50
+
51
+ const tui = await fse.pathExists(tuiPath)
52
+ ? await fse.readJson(tuiPath)
53
+ : { $schema: 'https://opencode.ai/tui.json' }
54
+
55
+ ensurePlugin(opencode)
56
+ ensurePlugin(tui)
57
+
58
+ await fse.ensureDir(opencodeDir)
59
+ await fse.writeJson(opencodePath, opencode, { spaces: 2 })
60
+ await fse.writeJson(tuiPath, tui, { spaces: 2 })
61
+
62
+ const quotaConfig = await fse.pathExists(quotaPath)
63
+ ? await fse.readJson(quotaPath)
64
+ : {}
65
+
66
+ // Keep installer semantics append-only: add defaults only when missing.
67
+ addIfMissing(quotaConfig, 'enabledProviders', 'auto')
68
+ addIfMissing(quotaConfig, 'formatStyle', 'singleWindow')
69
+ addIfMissing(quotaConfig, 'percentDisplayMode', 'used')
70
+ addIfMissing(quotaConfig, 'showSessionTokens', true)
71
+
72
+ await fse.ensureDir(quotaDir)
73
+ await fse.writeJson(quotaPath, quotaConfig, { spaces: 2 })
74
+
75
+ success('opencode-quota configured (manual setup)')
76
+ info('Restart OpenCode and run /quota to verify')
77
+ return { optedIn: true, installed: true }
78
+ } catch (err) {
79
+ error(`Failed to configure opencode-quota: ${err.message}`)
80
+ return { optedIn: true, installed: false }
81
+ }
82
+ }