opencode-onboard 0.4.1 → 0.4.2

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/README.md CHANGED
@@ -90,6 +90,18 @@ OpenCode generates `ARCHITECTURE.md` and `DESIGN.md` from your actual codebase,
90
90
 
91
91
  ---
92
92
 
93
+ ## Commands
94
+
95
+ Custom slash commands are installed into `.opencode/commands/` and are available directly in OpenCode.
96
+
97
+ | Command | Description |
98
+ |---------|-------------|
99
+ | `/init` | Initialize the project: generate `ARCHITECTURE.md`, `DESIGN.md`, archive history, activate agent team |
100
+ | `/plan <url>` | Parse a user story URL and produce a plan — proposal, specs, and tasks. Stops before implementation. |
101
+ | `/main <task>` | Quick direct implementation — no OpenSpec, no ensemble, no PRs. Just do it. |
102
+
103
+ ---
104
+
93
105
  ## Agents and Skills
94
106
 
95
107
  opencode-onboard draws a hard line between two concepts:
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Initialize the project — runs the bootstrap sequence defined in AGENTS.md if not yet initialized.
3
+ ---
4
+
5
+ Check if `AGENTS.md` is in bootstrap mode (contains `<!-- AGENTS-TEMPLATE-START -->`).
6
+
7
+ - If yes: run the full initialization sequence defined in `AGENTS.md` now.
8
+ - If no: tell the user the project is already initialized.
@@ -0,0 +1,17 @@
1
+ ---
2
+ description: Quick direct implementation — no OpenSpec, no ensemble, no PRs. Just do it.
3
+ ---
4
+
5
+ Implement the task described after `/main` directly and immediately.
6
+
7
+ **Rules:**
8
+ - No OpenSpec artifacts (no proposal, no specs, no tasks.md)
9
+ - No ensemble team (no team_create, no team_spawn)
10
+ - No branches, no PRs
11
+ - Work directly in the current branch
12
+ - Keep changes minimal and focused on exactly what was asked
13
+ - Use Read/Glob/Grep to locate relevant files before editing
14
+ - After editing, run `pnpm run typecheck` to catch type errors; fix any that are caused by your changes
15
+ - Do NOT run lint or tests unless the user asks
16
+
17
+ **Input**: Everything after `/main` is the task. Execute it now.
@@ -0,0 +1,37 @@
1
+ ---
2
+ description: Parse a user story URL and produce a plan — proposal, specs, and tasks. Stops before implementation.
3
+ ---
4
+
5
+ Parse the work item at the URL provided after `/plan` and produce a full implementation plan.
6
+
7
+ **Input**: A GitHub Issue URL or Azure DevOps work item URL. Example: `/plan https://github.com/org/repo/issues/42`
8
+
9
+ **Steps:**
10
+
11
+ 1. **Load baseline**
12
+
13
+ Load `@ob-global` first.
14
+
15
+ 2. **Detect URL type and load matching skill**
16
+
17
+ - GitHub Issue URL → load `ob-userstory-gh` skill
18
+ - Azure DevOps URL → load `ob-userstory-az` skill
19
+
20
+ Follow the skill steps exactly: fetch the issue/work item via CLI and create an OpenSpec change.
21
+
22
+ 3. **Propose**
23
+
24
+ Run `/opsx-propose` to generate `proposal.md`, specs, and `tasks.md`.
25
+
26
+ 4. **Show the plan**
27
+
28
+ Display:
29
+ - Change name
30
+ - Total number of tasks
31
+ - Full task list summary
32
+
33
+ 5. **Stop**
34
+
35
+ Ask the user: "Ready to implement? Type `/opsx-apply` to start."
36
+
37
+ Do NOT proceed to implementation. Do NOT run `/opsx-apply` automatically.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-onboard",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Prepare any brownfield codebase for AI agent workflows using OpenCode, OpenSpec, and ensemble orchestration.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -0,0 +1,43 @@
1
+ import chalk from 'chalk'
2
+ import { header, info } from '../utils/exec.js'
3
+ import { installBrowser } from '../steps/browser/index.js'
4
+ import { checkRtk } from '../steps/optimization/index.js'
5
+ import { choosePlatform, checkPlatform } from '../steps/platform/index.js'
6
+ import { readOnboardConfig } from './shared.js'
7
+
8
+ export async function runJoin() {
9
+ const logo = chalk.hex('#fe3d57')
10
+ console.log()
11
+ console.log(logo(' 🤝 opencode-onboard join'))
12
+ console.log(chalk.dim(' New team member setup — checks & local installs only'))
13
+ console.log(chalk.dim(' This will NOT modify any project files.'))
14
+ console.log()
15
+
16
+ // Step 1: Platform CLI check
17
+ header('Step 1, Platform CLI check')
18
+ const saved = await readOnboardConfig()
19
+ const savedPlatform = saved?.wizard?.platform
20
+ if (savedPlatform) {
21
+ info(`Detected project platform: ${savedPlatform === 'github' ? 'GitHub' : 'Azure DevOps'}`)
22
+ await checkPlatform(savedPlatform)
23
+ } else {
24
+ const platform = await choosePlatform()
25
+ void platform // result not persisted in join mode
26
+ }
27
+
28
+ // Step 2: rtk check
29
+ header('Step 2, Checking rtk')
30
+ await checkRtk({ skipHeader: true })
31
+
32
+ // Step 3: Browser extension
33
+ await installBrowser()
34
+
35
+ console.log()
36
+ console.log(chalk.bold.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
37
+ console.log(chalk.bold.green(' Join setup complete!'))
38
+ console.log(chalk.bold.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
39
+ console.log()
40
+ console.log(' Your local environment is ready.')
41
+ console.log(' Open the project in OpenCode and start coding!')
42
+ console.log()
43
+ }
@@ -0,0 +1,12 @@
1
+ import fse from 'fs-extra'
2
+ import path from 'node:path'
3
+
4
+ export async function readOnboardConfig() {
5
+ const cfgPath = path.join(process.cwd(), '.opencode', 'opencode-onboard.json')
6
+ if (!await fse.pathExists(cfgPath)) return null
7
+ try {
8
+ return await fse.readJson(cfgPath)
9
+ } catch {
10
+ return null
11
+ }
12
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+ import { readOnboardConfig } from './shared.js'
6
+
7
+ describe('readOnboardConfig()', () => {
8
+ let tmpDir, originalCwd
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shared-test-'))
12
+ originalCwd = process.cwd()
13
+ process.chdir(tmpDir)
14
+ })
15
+
16
+ afterEach(() => {
17
+ process.chdir(originalCwd)
18
+ fs.rmSync(tmpDir, { recursive: true, force: true })
19
+ })
20
+
21
+ it('returns null when config file does not exist', async () => {
22
+ const result = await readOnboardConfig()
23
+
24
+ expect(result).toBeNull()
25
+ })
26
+
27
+ it('returns parsed config when file exists', async () => {
28
+ const configDir = path.join(tmpDir, '.opencode')
29
+ fs.mkdirSync(configDir)
30
+ fs.writeFileSync(
31
+ path.join(configDir, 'opencode-onboard.json'),
32
+ JSON.stringify({ schema: 1, wizard: { platform: 'github' } }),
33
+ 'utf-8'
34
+ )
35
+
36
+ const result = await readOnboardConfig()
37
+
38
+ expect(result).not.toBeNull()
39
+ expect(result.schema).toBe(1)
40
+ expect(result.wizard.platform).toBe('github')
41
+ })
42
+
43
+ it('returns null when file contains invalid JSON', async () => {
44
+ const configDir = path.join(tmpDir, '.opencode')
45
+ fs.mkdirSync(configDir)
46
+ fs.writeFileSync(
47
+ path.join(configDir, 'opencode-onboard.json'),
48
+ 'not valid json',
49
+ 'utf-8'
50
+ )
51
+
52
+ const result = await readOnboardConfig()
53
+
54
+ expect(result).toBeNull()
55
+ })
56
+ })
@@ -0,0 +1,64 @@
1
+ import { cleanAiFiles } from '../steps/clean/index.js'
2
+ import { copyContentStep } from '../steps/copy/index.js'
3
+ import { chooseModels } from '../steps/models/index.js'
4
+ import { initOpenspec } from '../steps/openspec/index.js'
5
+ import { tokenOptimizationStep } from '../steps/optimization/index.js'
6
+ import { choosePlatform } from '../steps/platform/index.js'
7
+ import { installBrowser } from '../steps/browser/index.js'
8
+ import { writeOnboardConfig } from '../steps/metadata/index.js'
9
+ import { readOnboardConfig } from './shared.js'
10
+
11
+ export async function runSingleCommand(command) {
12
+ const saved = await readOnboardConfig()
13
+ const savedWizard = saved?.wizard ?? {}
14
+ const ctx = {
15
+ hasDesign: !!savedWizard?.preserved?.design,
16
+ hasArchitecture: !!savedWizard?.preserved?.architecture,
17
+ hasOpenspec: !!savedWizard?.preserved?.openspec,
18
+ sourceMode: savedWizard?.sourceMode ?? 'current',
19
+ sourceRoots: Array.isArray(savedWizard?.sourceRoots) ? savedWizard.sourceRoots : [],
20
+ }
21
+ const platform = savedWizard?.platform
22
+ const resolvedPlatform = platform === 'azure' || platform === 'github' ? platform : 'github'
23
+
24
+ const handlers = {
25
+ clean: async () => {
26
+ await cleanAiFiles()
27
+ },
28
+ platform: async () => {
29
+ await choosePlatform()
30
+ },
31
+ copy: async () => {
32
+ await copyContentStep(resolvedPlatform, ctx)
33
+ },
34
+ openspec: async () => {
35
+ await initOpenspec()
36
+ },
37
+ models: async () => {
38
+ await chooseModels()
39
+ },
40
+ optimization: async () => {
41
+ await tokenOptimizationStep({ ctx })
42
+ },
43
+ browser: async () => {
44
+ await installBrowser()
45
+ },
46
+ metadata: async () => {
47
+ await writeOnboardConfig({
48
+ ...ctx,
49
+ platform: resolvedPlatform,
50
+ additionalSkillsProvider: 'npx-skills',
51
+ planModel: savedWizard?.models?.plan ?? null,
52
+ buildModel: savedWizard?.models?.build ?? null,
53
+ fastModel: savedWizard?.models?.fast ?? null,
54
+ optionalTools: savedWizard?.optionalTools ?? null,
55
+ cavemanGuidance: savedWizard?.cavemanGuidance ?? null,
56
+ })
57
+ },
58
+ }
59
+
60
+ const handler = handlers[command]
61
+ if (!handler) return false
62
+ await handler()
63
+ return true
64
+ }
@@ -0,0 +1,99 @@
1
+ import chalk from 'chalk'
2
+ import { chooseSourceScope } from '../steps/source/index.js'
3
+ import { cleanAiFiles } from '../steps/clean/index.js'
4
+ import { choosePlatform } from '../steps/platform/index.js'
5
+ import { copyContentStep } from '../steps/copy/index.js'
6
+ import { initOpenspec } from '../steps/openspec/index.js'
7
+ import { chooseModels } from '../steps/models/index.js'
8
+ import { tokenOptimizationStep } from '../steps/optimization/index.js'
9
+ import { installBrowser } from '../steps/browser/index.js'
10
+ import { writeOnboardConfig } from '../steps/metadata/index.js'
11
+
12
+ export async function runWizard(version) {
13
+ const logo = chalk.hex('#fe3d57')
14
+ const bannerLines = [
15
+ logo(' '),
16
+ logo(' ▒▒▒▒▒▒▒▒▒▒▒▒▒ '),
17
+ logo(' ▒▒▓ ▓▒▓ '),
18
+ logo(' ▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒ '),
19
+ logo(' ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓ '),
20
+ logo(' ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓ '),
21
+ logo(' ▓▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▓▓ '),
22
+ logo(' ▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓ '),
23
+ logo(' ▓▓▒▒▒▒▒▒░▒▒▒▒▒▒▒░▒▒▒▒▒▒▓▓ '),
24
+ logo(' ▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓ '),
25
+ logo(' ▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓ '),
26
+ logo(' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ '),
27
+ '',
28
+ chalk.bold(' 🧰 opencode-onboard') + chalk.dim(` v${version}`),
29
+ chalk.dim(' Prepare your codebase for AI agents'),
30
+ ]
31
+
32
+ for (const line of bannerLines) console.log(line)
33
+ console.log()
34
+ console.log(' This tool will set up your project with a team of AI agents,')
35
+ console.log(' install skills, select models, and configure OpenCode.')
36
+ console.log()
37
+
38
+ // Only wait for Enter in a real interactive TTY
39
+ if (process.stdin.isTTY) {
40
+ console.log(chalk.bold(' Press Enter to begin...'))
41
+ console.log()
42
+ await new Promise(resolve => {
43
+ process.stdin.resume()
44
+ process.stdin.once('data', () => {
45
+ process.stdin.pause()
46
+ resolve()
47
+ })
48
+ })
49
+ }
50
+
51
+ const scope = await chooseSourceScope()
52
+
53
+ const preserve = await cleanAiFiles()
54
+ const ctx = { ...preserve, ...scope }
55
+
56
+ const platform = await choosePlatform()
57
+
58
+ await copyContentStep(platform, ctx)
59
+
60
+ await initOpenspec()
61
+
62
+ const selectedModels = await chooseModels()
63
+
64
+ const tokenOpt = await tokenOptimizationStep({ ctx })
65
+ const { rtk, quota, caveman, cavemanGuidance } = tokenOpt
66
+
67
+ await installBrowser()
68
+
69
+ await writeOnboardConfig({
70
+ ...ctx,
71
+ platform,
72
+ additionalSkillsProvider: 'npx-skills',
73
+ ...selectedModels,
74
+ optionalTools: { rtk, quota, caveman },
75
+ cavemanGuidance,
76
+ })
77
+
78
+ const toGenerate = [
79
+ !ctx.hasDesign && 'DESIGN.md',
80
+ !ctx.hasArchitecture && 'ARCHITECTURE.md',
81
+ ].filter(Boolean)
82
+
83
+ console.log()
84
+ console.log(chalk.bold.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
85
+ console.log(chalk.bold.green(' Onboarding complete!'))
86
+ console.log(chalk.bold.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
87
+ console.log()
88
+ console.log(' Open this project in OpenCode and type:')
89
+ console.log(chalk.bold(' "init"'))
90
+ console.log()
91
+ if (toGenerate.length > 0) {
92
+ console.log(` OpenCode will generate ${toGenerate.join(' and ')}`)
93
+ console.log(' from your actual codebase, then activate the agent team.')
94
+ } else {
95
+ console.log(' OpenCode will activate the agent team.')
96
+ }
97
+ console.log(` Source scope: ${ctx.sourceMode === 'parent-selected' ? ctx.sourceRoots.map(p => `../${p.split(/[/\\]/).pop()}`).join(', ') : 'current folder'}`)
98
+ console.log()
99
+ }
package/src/index.js CHANGED
@@ -1,17 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import chalk from 'chalk'
3
- import fse from 'fs-extra'
4
3
  import { createRequire } from 'node:module'
5
- import path from 'node:path'
6
- import { installBrowser } from './steps/browser/index.js'
7
- import { cleanAiFiles } from './steps/clean/index.js'
8
- import { copyContentStep } from './steps/copy/index.js'
9
- import { chooseModels } from './steps/models/index.js'
10
- import { initOpenspec } from './steps/openspec/index.js'
11
- import { tokenOptimizationStep } from './steps/optimization/index.js'
12
- import { choosePlatform } from './steps/platform/index.js'
13
- import { chooseSourceScope } from './steps/source/index.js'
14
- import { writeOnboardConfig } from './steps/metadata/index.js'
4
+ import { runJoin } from './commands/join.js'
5
+ import { runSingleCommand } from './commands/single.js'
6
+ import { runWizard } from './commands/wizard.js'
15
7
 
16
8
  function printHelp(version) {
17
9
  console.log(`opencode-onboard v${version}`)
@@ -21,6 +13,7 @@ function printHelp(version) {
21
13
  console.log(' npx opencode-onboard <command> Run a single step command')
22
14
  console.log()
23
15
  console.log('Commands:')
16
+ console.log(' join New team member setup (checks & local installs only)')
24
17
  console.log(' clean Run AI files cleanup step')
25
18
  console.log(' platform Run platform selection step')
26
19
  console.log(' copy Run content copy step')
@@ -34,71 +27,6 @@ function printHelp(version) {
34
27
  console.log(' -h, --help Show this help message')
35
28
  }
36
29
 
37
- async function readOnboardConfig() {
38
- const cfgPath = path.join(process.cwd(), '.opencode', 'opencode-onboard.json')
39
- if (!await fse.pathExists(cfgPath)) return null
40
- try {
41
- return await fse.readJson(cfgPath)
42
- } catch {
43
- return null
44
- }
45
- }
46
-
47
- async function runSingleCommand(command) {
48
- const saved = await readOnboardConfig()
49
- const savedWizard = saved?.wizard ?? {}
50
- const ctx = {
51
- hasDesign: !!savedWizard?.preserved?.design,
52
- hasArchitecture: !!savedWizard?.preserved?.architecture,
53
- hasOpenspec: !!savedWizard?.preserved?.openspec,
54
- sourceMode: savedWizard?.sourceMode ?? 'current',
55
- sourceRoots: Array.isArray(savedWizard?.sourceRoots) ? savedWizard.sourceRoots : [],
56
- }
57
- const platform = savedWizard?.platform
58
- const resolvedPlatform = platform === 'azure' || platform === 'github' ? platform : 'github'
59
-
60
- const handlers = {
61
- clean: async () => {
62
- await cleanAiFiles()
63
- },
64
- platform: async () => {
65
- await choosePlatform()
66
- },
67
- copy: async () => {
68
- await copyContentStep(resolvedPlatform, ctx)
69
- },
70
- openspec: async () => {
71
- await initOpenspec()
72
- },
73
- models: async () => {
74
- await chooseModels()
75
- },
76
- optimization: async () => {
77
- await tokenOptimizationStep({ ctx })
78
- },
79
- browser: async () => {
80
- await installBrowser()
81
- },
82
- metadata: async () => {
83
- await writeOnboardConfig({
84
- ...ctx,
85
- platform: resolvedPlatform,
86
- additionalSkillsProvider: 'npx-skills',
87
- planModel: savedWizard?.models?.plan ?? null,
88
- buildModel: savedWizard?.models?.build ?? null,
89
- fastModel: savedWizard?.models?.fast ?? null,
90
- optionalTools: savedWizard?.optionalTools ?? null,
91
- cavemanGuidance: savedWizard?.cavemanGuidance ?? null,
92
- })
93
- },
94
- }
95
-
96
- const handler = handlers[command]
97
- if (!handler) return false
98
- await handler()
99
- return true
100
- }
101
-
102
30
  if (process.stdout.isTTY) console.clear()
103
31
  console.log()
104
32
  const require = createRequire(import.meta.url)
@@ -111,103 +39,32 @@ if (args.includes('-h') || args.includes('--help')) {
111
39
  }
112
40
 
113
41
  if (args.length > 0) {
114
- const ok = await runSingleCommand(args[0])
115
- if (!ok) {
116
- console.log(chalk.red(`Unknown command: ${args[0]}`))
117
- console.log()
118
- printHelp(version)
119
- process.exit(1)
42
+ try {
43
+ if (args[0] === 'join') {
44
+ await runJoin()
45
+ } else {
46
+ const ok = await runSingleCommand(args[0])
47
+ if (!ok) {
48
+ console.log(chalk.red(`Unknown command: ${args[0]}`))
49
+ console.log()
50
+ printHelp(version)
51
+ process.exit(1)
52
+ }
53
+ }
54
+ } catch (err) {
55
+ if (err.name === 'ExitPromptError') {
56
+ console.log()
57
+ console.log(chalk.yellow('Cancelled.'))
58
+ } else {
59
+ console.error(chalk.red('\nUnexpected error:'), err.message)
60
+ process.exit(1)
61
+ }
120
62
  }
121
63
  process.exit(0)
122
64
  }
123
65
 
124
- const logo = chalk.hex('#fe3d57')
125
- const bannerLines = [
126
- logo(' '),
127
- logo(' ▒▒▒▒▒▒▒▒▒▒▒▒▒ '),
128
- logo(' ▒▒▓ ▓▒▓ '),
129
- logo(' ▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒ '),
130
- logo(' ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓ '),
131
- logo(' ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓ '),
132
- logo(' ▓▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▓▓ '),
133
- logo(' ▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓ '),
134
- logo(' ▓▓▒▒▒▒▒▒░▒▒▒▒▒▒▒░▒▒▒▒▒▒▓▓ '),
135
- logo(' ▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓ '),
136
- logo(' ▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓ '),
137
- logo(' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ '),
138
- '',
139
- chalk.bold(' 🧰 opencode-onboard') + chalk.dim(` v${version}`),
140
- chalk.dim(' Prepare your codebase for AI agents'),
141
- ]
142
-
143
- for (const line of bannerLines) console.log(line)
144
- console.log()
145
- console.log(' This tool will set up your project with a team of AI agents,')
146
- console.log(' install skills, select models, and configure OpenCode.')
147
- console.log()
148
-
149
- // Only wait for Enter in a real interactive TTY
150
- if (process.stdin.isTTY) {
151
- console.log(chalk.bold(' Press Enter to begin...'))
152
- console.log()
153
- await new Promise(resolve => {
154
- process.stdin.resume()
155
- process.stdin.once('data', () => {
156
- process.stdin.pause()
157
- resolve()
158
- })
159
- })
160
- }
161
-
162
66
  try {
163
- const scope = await chooseSourceScope()
164
-
165
- const preserve = await cleanAiFiles()
166
- const ctx = { ...preserve, ...scope }
167
-
168
- const platform = await choosePlatform()
169
-
170
- await copyContentStep(platform, ctx)
171
-
172
- await initOpenspec()
173
-
174
- const selectedModels = await chooseModels()
175
-
176
- const tokenOpt = await tokenOptimizationStep({ ctx })
177
- const { rtk, quota, caveman, cavemanGuidance } = tokenOpt
178
-
179
- await installBrowser()
180
-
181
- await writeOnboardConfig({
182
- ...ctx,
183
- platform,
184
- additionalSkillsProvider: 'npx-skills',
185
- ...selectedModels,
186
- optionalTools: { rtk, quota, caveman },
187
- cavemanGuidance,
188
- })
189
-
190
- const toGenerate = [
191
- !ctx.hasDesign && 'DESIGN.md',
192
- !ctx.hasArchitecture && 'ARCHITECTURE.md',
193
- ].filter(Boolean)
194
-
195
- console.log()
196
- console.log(chalk.bold.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
197
- console.log(chalk.bold.green(' Onboarding complete!'))
198
- console.log(chalk.bold.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
199
- console.log()
200
- console.log(' Open this project in OpenCode and type:')
201
- console.log(chalk.bold(' "init"'))
202
- console.log()
203
- if (toGenerate.length > 0) {
204
- console.log(` OpenCode will generate ${toGenerate.join(' and ')}`)
205
- console.log(' from your actual codebase, then activate the agent team.')
206
- } else {
207
- console.log(' OpenCode will activate the agent team.')
208
- }
209
- console.log(` Source scope: ${ctx.sourceMode === 'parent-selected' ? ctx.sourceRoots.map(p => `../${p.split(/[/\\]/).pop()}`).join(', ') : 'current folder'}`)
210
- console.log()
67
+ await runWizard(version)
211
68
  } catch (err) {
212
69
  if (err.name === 'ExitPromptError') {
213
70
  console.log()
@@ -44,7 +44,7 @@ describe('installBrowser()', () => {
44
44
 
45
45
  await installBrowser()
46
46
 
47
- expect(execa).toHaveBeenCalledWith('npx', expect.arrayContaining('@different-ai/opencode-browser'), expect.any(Object))
47
+ expect(execa).toHaveBeenCalledWith('npx', expect.arrayContaining(['@different-ai/opencode-browser']), expect.any(Object))
48
48
  })
49
49
 
50
50
  it('logs success when exit code is 0', async () => {
@@ -25,14 +25,17 @@ import fse from 'fs-extra'
25
25
  import { writeOnboardConfig } from './index.js'
26
26
 
27
27
  describe('writeOnboardConfig()', () => {
28
- let tmpDir
28
+ let tmpDir, originalCwd
29
29
 
30
30
  beforeEach(() => {
31
+ vi.clearAllMocks()
31
32
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'metadata-test-'))
33
+ originalCwd = process.cwd()
32
34
  process.chdir(tmpDir)
33
35
  })
34
36
 
35
37
  afterEach(() => {
38
+ process.chdir(originalCwd)
36
39
  fs.rmSync(tmpDir, { recursive: true, force: true })
37
40
  })
38
41
 
@@ -57,7 +60,7 @@ describe('writeOnboardConfig()', () => {
57
60
  expect(fse.ensureDir).toHaveBeenCalled()
58
61
  expect(fse.writeJson).toHaveBeenCalled()
59
62
  const call = fse.writeJson.mock.calls[0]
60
- const payload = call[0]
63
+ const payload = call[1]
61
64
  expect(payload.schema).toBe(1)
62
65
  expect(payload.wizard.platform).toBe('github')
63
66
  expect(payload.wizard.models.build).toBe('build-model')
@@ -70,7 +73,7 @@ describe('writeOnboardConfig()', () => {
70
73
  await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
71
74
 
72
75
  const call = fse.writeJson.mock.calls[0]
73
- const payload = call[0]
76
+ const payload = call[1]
74
77
  expect(payload.opencodeVersion).toBe('2.0.0')
75
78
  })
76
79
 
@@ -80,7 +83,7 @@ describe('writeOnboardConfig()', () => {
80
83
  await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
81
84
 
82
85
  const call = fse.writeJson.mock.calls[0]
83
- const payload = call[0]
86
+ const payload = call[1]
84
87
  expect(payload.opencodeVersion).toBe(null)
85
88
  })
86
89
 
@@ -90,7 +93,7 @@ describe('writeOnboardConfig()', () => {
90
93
  await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
91
94
 
92
95
  const call = fse.writeJson.mock.calls[0]
93
- const payload = call[0]
96
+ const payload = call[1]
94
97
  expect(payload.note).toContain('Informational file only')
95
98
  })
96
99
  })
@@ -7,7 +7,7 @@ describe('buildDisplayModels()', () => {
7
7
 
8
8
  const result = buildDisplayModels(raw)
9
9
 
10
- expect(result[0].label).toContain('[$]')
10
+ expect(result[0].label).toContain('[$$]')
11
11
  })
12
12
 
13
13
  it('adds cost tier label for mid-range models', () => {
@@ -15,7 +15,7 @@ describe('buildDisplayModels()', () => {
15
15
 
16
16
  const result = buildDisplayModels(raw)
17
17
 
18
- expect(result[0].label).toContain('[$$]')
18
+ expect(result[0].label).toContain('[$$$]')
19
19
  })
20
20
 
21
21
  it('adds cost tier label for expensive models', () => {
@@ -47,7 +47,8 @@ describe('buildDisplayModels()', () => {
47
47
 
48
48
  const result = buildDisplayModels(raw)
49
49
 
50
- expect(result[0].description).toContain('cost: ?')
50
+ expect(result[0].description).toContain('?')
51
+ expect(result[0].label).not.toContain('[')
51
52
  })
52
53
 
53
54
  it('handles $0 subscription pricing', () => {
@@ -58,7 +59,7 @@ describe('buildDisplayModels()', () => {
58
59
  expect(result[0].description).toContain('$0 (subscription)')
59
60
  })
60
61
 
61
- it('sorts models by cost ascending', () => {
62
+ it('preserves input order (sorting is done upstream by parseModels)', () => {
62
63
  const raw = [
63
64
  { id: 'expensive/model', name: 'Expensive', cost: 100, context: 1000 },
64
65
  { id: 'cheap/model', name: 'Cheap', cost: 1, context: 1000 },
@@ -67,8 +68,8 @@ describe('buildDisplayModels()', () => {
67
68
 
68
69
  const result = buildDisplayModels(raw)
69
70
 
70
- expect(result[0].id).toBe('cheap/model')
71
- expect(result[1].id).toBe('mid/model')
72
- expect(result[2].id).toBe('expensive/model')
71
+ expect(result[0].id).toBe('expensive/model')
72
+ expect(result[1].id).toBe('cheap/model')
73
+ expect(result[2].id).toBe('mid/model')
73
74
  })
74
75
  })
@@ -7,16 +7,6 @@ vi.mock('../../utils/exec.js', () => ({
7
7
  success: vi.fn(),
8
8
  }))
9
9
 
10
- vi.mock('fs-extra', () => ({
11
- default: {
12
- readFile: vi.fn(),
13
- writeFile: vi.fn(),
14
- writeJson: vi.fn(),
15
- pathExists: vi.fn(),
16
- },
17
- }))
18
-
19
- import fse from 'fs-extra'
20
10
  import { success } from '../../utils/exec.js'
21
11
  import { writeModelToAgent, writeModelsToConfigs } from './write.js'
22
12
 
@@ -24,6 +14,7 @@ describe('writeModelToAgent()', () => {
24
14
  let tmpDir
25
15
 
26
16
  beforeEach(() => {
17
+ vi.clearAllMocks()
27
18
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-write-test-'))
28
19
  })
29
20
 
@@ -69,18 +60,22 @@ custom_field: custom_value
69
60
  })
70
61
 
71
62
  describe('writeModelsToConfigs()', () => {
72
- let tmpDir, agentsDir, opencodeJsonPath, ensembleJsonPath
63
+ let tmpDir, agentsDir, opencodeJsonPath, ensembleJsonPath, originalCwd
73
64
 
74
65
  beforeEach(() => {
66
+ vi.clearAllMocks()
67
+ originalCwd = process.cwd()
75
68
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-config-test-'))
76
69
  agentsDir = path.join(tmpDir, '.agents', 'agents')
77
70
  fs.mkdirSync(agentsDir, { recursive: true })
78
71
  opencodeJsonPath = path.join(tmpDir, '.opencode', 'opencode.json')
79
72
  ensembleJsonPath = path.join(tmpDir, '.opencode', 'ensemble.json')
80
73
  fs.mkdirSync(path.dirname(opencodeJsonPath), { recursive: true })
74
+ process.chdir(tmpDir)
81
75
  })
82
76
 
83
77
  afterEach(() => {
78
+ process.chdir(originalCwd)
84
79
  fs.rmSync(tmpDir, { recursive: true, force: true })
85
80
  })
86
81
 
@@ -106,12 +101,15 @@ describe('writeModelsToConfigs()', () => {
106
101
  })
107
102
 
108
103
  it('reports success when writing configs', async () => {
104
+ const agentFile = path.join(agentsDir, 'back-engineer.md')
105
+ fs.writeFileSync(agentFile, '---\nname: Back\n---', 'utf-8')
106
+
109
107
  await writeModelsToConfigs({
110
108
  planModel: 'plan-model',
111
109
  buildModel: 'build-model',
112
110
  fastModel: 'fast-model',
113
- agentsDir: '/nonexistent',
114
- preset: { roles: { build: { agents: [] }, fast: { agents: [] } } },
111
+ agentsDir,
112
+ preset: { roles: { build: { agents: ['back-engineer'] }, fast: { agents: [] } } },
115
113
  })
116
114
 
117
115
  expect(success).toHaveBeenCalled()
@@ -20,6 +20,21 @@ vi.mock('./caveman.js', () => ({ installCaveman: vi.fn() }))
20
20
  vi.mock('./caveman-guidance.js', () => ({ enableCavemanGuidance: vi.fn() }))
21
21
  vi.mock('./global.js', () => ({ configureObGlobal: vi.fn() }))
22
22
 
23
+ vi.mock('fs-extra', () => ({
24
+ default: {
25
+ readJson: vi.fn().mockResolvedValue({
26
+ info: 'Token optimization info',
27
+ message: 'Select tools',
28
+ timeoutMs: 5000,
29
+ choices: [
30
+ { value: 'rtk', checked: false },
31
+ { value: 'quota', checked: false },
32
+ { value: 'caveman', checked: false },
33
+ ],
34
+ }),
35
+ },
36
+ }))
37
+
23
38
  import { checkbox } from '@inquirer/prompts'
24
39
  import { commandExists, warn } from '../../utils/exec.js'
25
40
  import { installQuota } from './quota.js'
@@ -34,6 +49,9 @@ describe('tokenOptimizationStep()', () => {
34
49
  })
35
50
 
36
51
  it('runs all optimizations by default selection', async () => {
52
+ const originalIsTTY = process.stdin.isTTY
53
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true })
54
+
37
55
  checkbox.mockResolvedValue(['rtk', 'quota', 'caveman'])
38
56
  commandExists.mockResolvedValue(true)
39
57
  installQuota.mockResolvedValue({ optedIn: true, installed: true })
@@ -43,6 +61,8 @@ describe('tokenOptimizationStep()', () => {
43
61
 
44
62
  const result = await tokenOptimizationStep()
45
63
 
64
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true })
65
+
46
66
  expect(commandExists).toHaveBeenCalledWith('rtk')
47
67
  expect(installQuota).toHaveBeenCalledWith({ skipHeader: true, skipPrompt: true })
48
68
  expect(installCaveman).toHaveBeenCalledWith({ skipHeader: true, skipPrompt: true })
@@ -54,7 +54,7 @@ describe('choosePlatform()', () => {
54
54
  await checkPlatform('github')
55
55
 
56
56
  expect(success).toHaveBeenCalledWith('GitHub CLI (gh) available')
57
- expect(success).toHaveBeenCalledWith('GitHub CLI authenticated')
57
+ expect(success).toHaveBeenCalledWith('GitHub CLI (gh) authenticated')
58
58
  })
59
59
 
60
60
  it('warns when gh is installed but not authenticated', async () => {
@@ -27,7 +27,7 @@ vi.mock('fs-extra', () => ({
27
27
  parentSelectionMessage: 'Select sibling folders',
28
28
  }),
29
29
  readdir: vi.fn(),
30
- stat: vi.fn(),
30
+ stat: vi.fn().mockResolvedValue({ isDirectory: () => true }),
31
31
  },
32
32
  }))
33
33
 
@@ -36,15 +36,17 @@ import fse from 'fs-extra'
36
36
  import { chooseSourceScope } from './index.js'
37
37
 
38
38
  describe('chooseSourceScope()', () => {
39
- let tmpDir
39
+ let tmpDir, originalCwd
40
40
 
41
41
  beforeEach(() => {
42
42
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'source-test-'))
43
+ originalCwd = process.cwd()
43
44
  process.chdir(tmpDir)
44
45
  vi.clearAllMocks()
45
46
  })
46
47
 
47
48
  afterEach(() => {
49
+ process.chdir(originalCwd)
48
50
  fs.rmSync(tmpDir, { recursive: true, force: true })
49
51
  })
50
52
 
@@ -61,7 +63,7 @@ describe('chooseSourceScope()', () => {
61
63
  select.mockResolvedValue('parent')
62
64
  const parentDir = path.dirname(tmpDir)
63
65
  const siblingDir = path.join(parentDir, 'sibling-project')
64
- fs.mkdirSync(siblingDir)
66
+ fs.mkdirSync(siblingDir, { recursive: true })
65
67
  fse.readdir.mockResolvedValue(['sibling-project'])
66
68
 
67
69
  await chooseSourceScope()
@@ -75,7 +75,6 @@ describe('parseModels()', () => {
75
75
  const result = parseModels(data)
76
76
 
77
77
  expect(result[0].cost).toBeUndefined()
78
- expect(result[0].description).toContain('cost: ?')
79
78
  })
80
79
 
81
80
  it('extracts context limit', () => {