opencode-onboard 0.3.3 → 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.
Files changed (87) hide show
  1. package/README.md +278 -214
  2. package/content/.agents/agents/basic-engineer.md +30 -0
  3. package/content/.agents/agents/devops-manager.md +38 -29
  4. package/content/.agents/session-log.json +41 -0
  5. package/content/.agents/skills/ob-default/SKILL.md +21 -0
  6. package/content/.agents/skills/ob-generic-guardrails/SKILL.md +32 -0
  7. package/content/.agents/skills/ob-global/SKILL.md +49 -0
  8. package/content/.agents/skills/ob-pullrequest-az/SKILL.md +11 -21
  9. package/content/.agents/skills/ob-pullrequest-gh/SKILL.md +14 -24
  10. package/content/.agents/skills/ob-userstory-az/SKILL.md +8 -14
  11. package/content/.agents/skills/ob-userstory-gh/SKILL.md +6 -14
  12. package/content/.opencode/commands/init.md +8 -0
  13. package/content/.opencode/commands/main.md +17 -0
  14. package/content/.opencode/commands/opsx-apply.md +50 -33
  15. package/content/.opencode/commands/plan.md +37 -0
  16. package/content/.opencode/plugins/session-log.js +1 -1
  17. package/content/.opencode/skills/openspec-apply-change/SKILL.md +50 -33
  18. package/content/AGENTS.md +94 -144
  19. package/content/skills-lock.json +4 -0
  20. package/package.json +6 -1
  21. package/src/commands/join.js +43 -0
  22. package/src/commands/shared.js +12 -0
  23. package/src/commands/shared.test.js +56 -0
  24. package/src/commands/single.js +64 -0
  25. package/src/commands/wizard.js +99 -0
  26. package/src/index.js +25 -202
  27. package/src/presets/browser.json +18 -0
  28. package/src/presets/clean.json +21 -0
  29. package/src/presets/models.json +33 -0
  30. package/src/presets/optimization.json +22 -0
  31. package/src/presets/platforms.json +29 -2
  32. package/src/presets/quota.json +14 -0
  33. package/src/presets/source.json +17 -0
  34. package/src/steps/browser/browser.test.js +81 -0
  35. package/src/steps/{install-browser.js → browser/index.js} +12 -15
  36. package/src/steps/{__tests__/clean-ai-files.test.js → clean/clean.test.js} +28 -13
  37. package/src/steps/{clean-ai-files.js → clean/index.js} +32 -30
  38. package/src/steps/copy/agents.js +106 -0
  39. package/src/steps/{__tests__/copy-content.test.js → copy/copy.test.js} +10 -1
  40. package/src/steps/copy/index.js +33 -0
  41. package/src/steps/copy/skills.js +55 -0
  42. package/src/steps/{write-onboard-config.js → metadata/index.js} +3 -3
  43. package/src/steps/metadata/metadata.test.js +99 -0
  44. package/src/steps/models/format.js +60 -0
  45. package/src/steps/models/format.test.js +75 -0
  46. package/src/steps/models/index.js +52 -0
  47. package/src/steps/models/write.js +54 -0
  48. package/src/steps/models/write.test.js +117 -0
  49. package/src/steps/{init-openspec.js → openspec/ensemble.js} +20 -57
  50. package/src/steps/openspec/ensemble.test.js +79 -0
  51. package/src/steps/openspec/index.js +32 -0
  52. package/src/steps/optimization/caveman-guidance.js +11 -0
  53. package/src/steps/{install-caveman.js → optimization/caveman.js} +5 -19
  54. package/src/steps/optimization/global.js +64 -0
  55. package/src/steps/optimization/index.js +101 -0
  56. package/src/steps/{__tests__/token-optimization.test.js → optimization/optimization.test.js} +37 -22
  57. package/src/steps/{install-quota.js → optimization/quota.js} +12 -10
  58. package/src/steps/platform/index.js +81 -0
  59. package/src/steps/platform/platform.test.js +129 -0
  60. package/src/steps/{choose-source-scope.js → source/index.js} +11 -17
  61. package/src/steps/source/source.test.js +91 -0
  62. package/src/utils/__tests__/copy.test.js +12 -5
  63. package/src/utils/copy.js +4 -24
  64. package/src/utils/exec-spinner.js +47 -0
  65. package/src/utils/exec.js +120 -162
  66. package/src/utils/models-cache.js +25 -68
  67. package/src/utils/models-pricing.js +42 -0
  68. package/src/utils/models-pricing.test.js +93 -0
  69. package/content/.agents/agents/back-engineer.md +0 -87
  70. package/content/.agents/agents/front-engineer.md +0 -86
  71. package/content/.agents/agents/infra-engineer.md +0 -85
  72. package/content/.agents/agents/quality-engineer.md +0 -86
  73. package/content/.agents/agents/security-auditor.md +0 -86
  74. package/src/steps/__tests__/check-env.test.js +0 -70
  75. package/src/steps/__tests__/check-platform.test.js +0 -104
  76. package/src/steps/__tests__/check-rtk.test.js +0 -38
  77. package/src/steps/__tests__/choose-platform.test.js +0 -38
  78. package/src/steps/check-env.js +0 -26
  79. package/src/steps/check-platform.js +0 -80
  80. package/src/steps/check-rtk.js +0 -38
  81. package/src/steps/choose-models.js +0 -165
  82. package/src/steps/choose-platform.js +0 -22
  83. package/src/steps/choose-skills-provider.js +0 -79
  84. package/src/steps/copy-content.js +0 -89
  85. package/src/steps/enable-caveman-guidance.js +0 -78
  86. package/src/steps/patch-agents-md.js +0 -153
  87. package/src/steps/token-optimization.js +0 -59
@@ -1,43 +1,34 @@
1
+ import { checkbox } from '@inquirer/prompts'
1
2
  import fse from 'fs-extra'
2
3
  import path from 'path'
3
- import { findAiFiles } from '../utils/copy.js'
4
- import { header, info, success, warn } from '../utils/exec.js'
4
+ import { fileURLToPath } from 'url'
5
+ import { findAiFiles } from '../../utils/copy.js'
6
+ import { header, info, success, warn } from '../../utils/exec.js'
5
7
 
6
- // Files/dirs that are valuable pre-existing work, never removed
7
- const PRESERVE = ['DESIGN.md', 'ARCHITECTURE.md', 'openspec']
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const CLEAN_PRESET_PATH = path.resolve(__dirname, '../../presets/clean.json')
10
+ const cleanPreset = await fse.readJson(CLEAN_PRESET_PATH)
8
11
 
9
- /**
10
- * Enumerate immediate children of a directory.
11
- * Skips 'skills' to preserve user-installed skills.
12
- */
13
- async function childrenExcludingSkills(dir) {
12
+ async function childrenExcludingPreserved(dir) {
14
13
  const results = []
15
14
  if (!await fse.pathExists(dir)) return results
16
15
  const entries = await fse.readdir(dir)
17
16
  for (const entry of entries) {
18
- if (entry === 'skills') continue
17
+ if (cleanPreset.preserveSubfolders.includes(entry)) continue
19
18
  results.push(path.join(dir, entry))
20
19
  }
21
20
  return results
22
21
  }
23
22
 
24
- /**
25
- * Returns true if the file exists and has real content (not empty, not a prompt template).
26
- * Prompt templates contain a specific marker written by the onboard CLI.
27
- */
28
23
  async function isPopulated(filePath) {
29
24
  if (!await fse.pathExists(filePath)) return false
30
25
  const content = await fse.readFile(filePath, 'utf-8')
31
26
  const trimmed = content.trim()
32
27
  if (!trimmed) return false
33
- // DESIGN.md and ARCHITECTURE.md shipped as prompt templates contain this marker
34
28
  if (trimmed.startsWith('<!-- onboard-prompt')) return false
35
29
  return true
36
30
  }
37
31
 
38
- /**
39
- * Returns true if openspec/ exists and has at least one change or archive entry.
40
- */
41
32
  async function hasOpenspecHistory(cwd) {
42
33
  const changesDir = path.join(cwd, 'openspec', 'changes')
43
34
  const archiveDir = path.join(cwd, 'openspec', 'archive')
@@ -53,11 +44,9 @@ async function hasOpenspecHistory(cwd) {
53
44
  }
54
45
 
55
46
  export async function cleanAiFiles() {
56
- header('Step 3, Existing AI config files')
47
+ header('Step 2, Existing AI config files')
57
48
 
58
49
  const cwd = process.cwd()
59
-
60
- // Detect what should be preserved before touching anything
61
50
  const ctx = {
62
51
  hasDesign: await isPopulated(path.join(cwd, 'DESIGN.md')),
63
52
  hasArchitecture: await isPopulated(path.join(cwd, 'ARCHITECTURE.md')),
@@ -68,22 +57,19 @@ export async function cleanAiFiles() {
68
57
  if (ctx.hasArchitecture) info('ARCHITECTURE.md exists and is populated, keeping it')
69
58
  if (ctx.hasOpenspec) info('openspec/ history exists, keeping it')
70
59
 
71
- // Build the list of files to remove
72
- const flatFiles = await findAiFiles(cwd)
73
- const dirTargets = ['.opencode', '.agents']
60
+ const flatFiles = await findAiFiles(cwd, cleanPreset.detectFiles)
61
+ const dirTargets = cleanPreset.directoryTargets
74
62
  const dirEntries = []
75
63
  for (const dirName of dirTargets) {
76
64
  const dirPath = path.join(cwd, dirName)
77
- const children = await childrenExcludingSkills(dirPath)
65
+ const children = await childrenExcludingPreserved(dirPath)
78
66
  dirEntries.push(...children)
79
67
  }
80
68
 
81
- // Remove directory targets themselves from flat list (handled via children)
82
- // Also remove any preserved entries
83
69
  const filteredFlat = flatFiles.filter(f => {
84
70
  const rel = path.relative(cwd, f)
85
71
  if (dirTargets.includes(rel)) return false
86
- if (PRESERVE.some(p => rel === p || rel.startsWith(p + path.sep))) return false
72
+ if (cleanPreset.preserve.some(p => rel === p || rel.startsWith(p + path.sep))) return false
87
73
  return true
88
74
  })
89
75
 
@@ -94,8 +80,24 @@ export async function cleanAiFiles() {
94
80
  return ctx
95
81
  }
96
82
 
97
- warn('Removing existing AI config files:')
98
- for (const f of allToRemove) {
83
+ const choices = allToRemove.map(f => ({
84
+ name: path.relative(cwd, f).replace(/\\/g, '/'),
85
+ value: f,
86
+ checked: true,
87
+ }))
88
+
89
+ const selected = await checkbox({
90
+ message: cleanPreset.selectionMessage,
91
+ choices,
92
+ })
93
+
94
+ if (!selected || selected.length === 0) {
95
+ success('No AI config files selected for removal')
96
+ return ctx
97
+ }
98
+
99
+ warn('Removing selected AI config files:')
100
+ for (const f of selected) {
99
101
  info(' ' + f.replace(cwd + path.sep, ''))
100
102
  await fse.remove(f)
101
103
  }
@@ -0,0 +1,106 @@
1
+ import fse from 'fs-extra'
2
+ import path from 'path'
3
+ import { info, success } from '../../utils/exec.js'
4
+
5
+ const STEP1_HEADING = '### Step 1, Archive project history into OpenSpec'
6
+ const STEP2_HEADING = '### Step 2, Generate DESIGN.md'
7
+ const STEP3_HEADING = '### Step 3, Generate ARCHITECTURE.md'
8
+
9
+ const STEP1_CONFIRM_LINE = '- Project history archived in openspec'
10
+ const STEP2_CONFIRM_LINE = '- DESIGN.md generated'
11
+ const STEP3_CONFIRM_LINE = '- ARCHITECTURE.md generated'
12
+
13
+ function removeStepBlock(content, heading) {
14
+ const lines = content.split('\n')
15
+ const start = lines.findIndex(l => l.trim() === heading.trim())
16
+ if (start === -1) return content
17
+
18
+ let end = -1
19
+ for (let i = start + 1; i < lines.length; i++) {
20
+ if (lines[i].trim() === '---') { end = i; break }
21
+ }
22
+
23
+ if (end === -1) return content
24
+
25
+ const removeFrom = start > 0 && lines[start - 1].trim() === '' ? start - 1 : start
26
+ lines.splice(removeFrom, end - removeFrom + 1)
27
+ return lines.join('\n')
28
+ }
29
+
30
+ function removeConfirmLine(content, line) {
31
+ return content.split('\n').filter(l => l.trim() !== line.trim()).join('\n')
32
+ }
33
+
34
+ function renumberSteps(content) {
35
+ let counter = 0
36
+ return content.replace(/^### Step \d+,/gm, () => `### Step ${++counter},`)
37
+ }
38
+
39
+ const PLATFORM_SKILLS_START = '<!-- OB-PLATFORM-SKILLS-START -->'
40
+ const PLATFORM_SKILLS_END = '<!-- OB-PLATFORM-SKILLS-END -->'
41
+
42
+ function buildPlatformSkillsSection(platform) {
43
+ if (platform === 'azure') {
44
+ return [
45
+ '- Selected platform: `azure` (from onboarding platform step).',
46
+ '- Load Azure DevOps skills: `ob-userstory-az`, `ob-pullrequest-az`.',
47
+ '- Use URL-based platform inference only if onboarding metadata is missing or ambiguous.',
48
+ ].join('\n')
49
+ }
50
+
51
+ return [
52
+ '- Selected platform: `github` (from onboarding platform step).',
53
+ '- Load GitHub skills: `ob-userstory-gh`, `ob-pullrequest-gh`.',
54
+ '- Use URL-based platform inference only if onboarding metadata is missing or ambiguous.',
55
+ ].join('\n')
56
+ }
57
+
58
+ function replaceBetween(content, start, end, replacement) {
59
+ if (!content.includes(start) || !content.includes(end)) return content
60
+ const pattern = new RegExp(`${start}[\\s\\S]*?${end}`)
61
+ return content.replace(pattern, `${start}\n${replacement.trim()}\n${end}`)
62
+ }
63
+
64
+ export async function patchAgentsMd(ctx) {
65
+ const agentsMdPath = path.join(process.cwd(), 'AGENTS.md')
66
+ if (!await fse.pathExists(agentsMdPath)) return
67
+
68
+ let content = await fse.readFile(agentsMdPath, 'utf-8')
69
+ const patches = []
70
+
71
+ if (ctx.hasOpenspec) {
72
+ content = removeStepBlock(content, STEP1_HEADING)
73
+ content = removeConfirmLine(content, STEP1_CONFIRM_LINE)
74
+ patches.push('Step 1 (openspec history) removed, openspec/ already exists')
75
+ }
76
+
77
+ if (ctx.hasDesign) {
78
+ content = removeStepBlock(content, STEP2_HEADING)
79
+ content = removeConfirmLine(content, STEP2_CONFIRM_LINE)
80
+ patches.push('Step 2 (DESIGN.md) removed, DESIGN.md already exists')
81
+ }
82
+
83
+ if (ctx.hasArchitecture) {
84
+ content = removeStepBlock(content, STEP3_HEADING)
85
+ content = removeConfirmLine(content, STEP3_CONFIRM_LINE)
86
+ patches.push('Step 3 (ARCHITECTURE.md) removed, ARCHITECTURE.md already exists')
87
+ }
88
+
89
+ if (patches.length > 0) {
90
+ content = renumberSteps(content)
91
+ await fse.writeFile(agentsMdPath, content, 'utf-8')
92
+ for (const msg of patches) info(msg)
93
+ success('AGENTS.md patched for existing project state')
94
+ }
95
+ }
96
+
97
+ export async function patchDevopsManagerMd(platform) {
98
+ const devopsPath = path.join(process.cwd(), '.agents', 'agents', 'devops-manager.md')
99
+ if (!await fse.pathExists(devopsPath)) return
100
+
101
+ const resolved = platform === 'azure' ? 'azure' : 'github'
102
+ let content = await fse.readFile(devopsPath, 'utf-8')
103
+ content = replaceBetween(content, PLATFORM_SKILLS_START, PLATFORM_SKILLS_END, buildPlatformSkillsSection(resolved))
104
+ await fse.writeFile(devopsPath, `${content.replace(/\s*$/, '')}\n`, 'utf-8')
105
+ success(`devops-manager.md patched for platform: ${resolved}`)
106
+ }
@@ -10,9 +10,18 @@ vi.mock('../../utils/copy.js', () => ({
10
10
  copyContent: vi.fn(),
11
11
  }))
12
12
 
13
+ vi.mock('./agents.js', () => ({
14
+ patchAgentsMd: vi.fn(),
15
+ patchDevopsManagerMd: vi.fn(),
16
+ }))
17
+
18
+ vi.mock('./skills.js', () => ({
19
+ installSkills: vi.fn(),
20
+ }))
21
+
13
22
  import { copyContent } from '../../utils/copy.js'
14
23
  import { success, error } from '../../utils/exec.js'
15
- import { copyContentStep } from '../copy-content.js'
24
+ import { copyContentStep } from './index.js'
16
25
 
17
26
  describe('copyContentStep()', () => {
18
27
  const originalExit = process.exit
@@ -0,0 +1,33 @@
1
+ import fse from 'fs-extra'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import { copyContent } from '../../utils/copy.js'
5
+ import { error, header, success } from '../../utils/exec.js'
6
+ import { patchAgentsMd, patchDevopsManagerMd } from './agents.js'
7
+ import { installSkills } from './skills.js'
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
10
+ const CONTENT_DIR = path.resolve(__dirname, '../../../content')
11
+
12
+ export async function copyContentStep(platform, ctx = {}) {
13
+ header('Step 5, Copying opencode-onboard files')
14
+
15
+ const dest = process.cwd()
16
+
17
+ try {
18
+ await copyContent(CONTENT_DIR, dest, platform, ctx)
19
+ const rootsFile = path.join(dest, '.agents', 'source-roots.json')
20
+ await fse.ensureDir(path.dirname(rootsFile))
21
+ await fse.writeJson(rootsFile, {
22
+ mode: ctx.sourceMode || 'current',
23
+ roots: ctx.sourceRoots || [dest],
24
+ }, { spaces: 2 })
25
+ await patchDevopsManagerMd(platform)
26
+ await patchAgentsMd(ctx)
27
+ await installSkills()
28
+ success('Files copied to project root')
29
+ } catch (err) {
30
+ error(`Failed to copy content: ${err.message}`)
31
+ process.exit(1)
32
+ }
33
+ }
@@ -0,0 +1,55 @@
1
+ import { execa } from 'execa'
2
+ import fse from 'fs-extra'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+ import { info, success, warn } from '../../utils/exec.js'
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
+ const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../../../content/.agents/skills')
9
+ const CONTENT_SKILLS_LOCK = path.resolve(__dirname, '../../../content/skills-lock.json')
10
+
11
+ async function installObSkills() {
12
+ const destSkillsDir = path.join(process.cwd(), '.agents', 'skills')
13
+ await fse.ensureDir(destSkillsDir)
14
+
15
+ const skills = await fse.readdir(CONTENT_SKILLS_DIR)
16
+ for (const skill of skills) {
17
+ const src = path.join(CONTENT_SKILLS_DIR, skill)
18
+ const dest = path.join(destSkillsDir, skill)
19
+ const stat = await fse.stat(src)
20
+ if (!stat.isDirectory()) continue
21
+ if (await fse.pathExists(dest)) {
22
+ info(`${skill} already exists, skipping`)
23
+ continue
24
+ }
25
+ await fse.copy(src, dest)
26
+ success(`Installed skill: ${skill}`)
27
+ }
28
+ }
29
+
30
+ export async function installSkills() {
31
+ info('Installing built-in ob-skills...')
32
+ await installObSkills()
33
+ console.log()
34
+
35
+ if (await fse.pathExists(CONTENT_SKILLS_LOCK)) {
36
+ const destLock = path.join(process.cwd(), 'skills-lock.json')
37
+ if (await fse.pathExists(destLock)) {
38
+ info('skills-lock.json already exists, skipping')
39
+ } else {
40
+ await fse.copy(CONTENT_SKILLS_LOCK, destLock)
41
+ success('Installed skills-lock.json')
42
+ }
43
+ }
44
+
45
+ info('Installing npx skills from skills-lock.json...')
46
+ try {
47
+ await execa('npx', ['skills'], {
48
+ cwd: process.cwd(),
49
+ stdio: 'inherit',
50
+ reject: false,
51
+ })
52
+ } catch (err) {
53
+ warn(`npx skills failed: ${err.message}`)
54
+ }
55
+ }
@@ -2,10 +2,10 @@ import { execa } from 'execa'
2
2
  import fse from 'fs-extra'
3
3
  import path from 'path'
4
4
  import { createRequire } from 'node:module'
5
- import { header, success, warn } from '../utils/exec.js'
5
+ import { header, success, warn } from '../../utils/exec.js'
6
6
 
7
7
  const require = createRequire(import.meta.url)
8
- const { version: onboardVersion } = require('../../package.json')
8
+ const { version: onboardVersion } = require('../../../package.json')
9
9
 
10
10
  async function detectOpencodeVersion() {
11
11
  try {
@@ -19,7 +19,7 @@ async function detectOpencodeVersion() {
19
19
  }
20
20
 
21
21
  export async function writeOnboardConfig(data) {
22
- header('Step 12, Writing onboarding metadata')
22
+ header('Step 10, Writing onboarding metadata')
23
23
 
24
24
  const opencodeVersion = await detectOpencodeVersion()
25
25
  const target = path.join(process.cwd(), '.opencode', 'opencode-onboard.json')
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+
6
+ vi.mock('../../utils/exec.js', () => ({
7
+ header: vi.fn(),
8
+ success: vi.fn(),
9
+ warn: vi.fn(),
10
+ }))
11
+
12
+ vi.mock('execa', () => ({
13
+ execa: vi.fn(),
14
+ }))
15
+
16
+ vi.mock('fs-extra', () => ({
17
+ default: {
18
+ ensureDir: vi.fn().mockResolvedValue(undefined),
19
+ writeJson: vi.fn().mockResolvedValue(undefined),
20
+ },
21
+ }))
22
+
23
+ import { execa } from 'execa'
24
+ import fse from 'fs-extra'
25
+ import { writeOnboardConfig } from './index.js'
26
+
27
+ describe('writeOnboardConfig()', () => {
28
+ let tmpDir, originalCwd
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks()
32
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'metadata-test-'))
33
+ originalCwd = process.cwd()
34
+ process.chdir(tmpDir)
35
+ })
36
+
37
+ afterEach(() => {
38
+ process.chdir(originalCwd)
39
+ fs.rmSync(tmpDir, { recursive: true, force: true })
40
+ })
41
+
42
+ it('writes JSON file with all wizard selections', async () => {
43
+ execa.mockResolvedValue({ exitCode: 0, stdout: '1.2.3', stderr: '' })
44
+
45
+ await writeOnboardConfig({
46
+ platform: 'github',
47
+ sourceMode: 'current',
48
+ sourceRoots: ['/test/path'],
49
+ hasDesign: true,
50
+ hasArchitecture: false,
51
+ hasOpenspec: true,
52
+ additionalSkillsProvider: 'npx-skills',
53
+ planModel: 'plan-model',
54
+ buildModel: 'build-model',
55
+ fastModel: 'fast-model',
56
+ optionalTools: ['rtk'],
57
+ cavemanGuidance: true,
58
+ })
59
+
60
+ expect(fse.ensureDir).toHaveBeenCalled()
61
+ expect(fse.writeJson).toHaveBeenCalled()
62
+ const call = fse.writeJson.mock.calls[0]
63
+ const payload = call[1]
64
+ expect(payload.schema).toBe(1)
65
+ expect(payload.wizard.platform).toBe('github')
66
+ expect(payload.wizard.models.build).toBe('build-model')
67
+ expect(payload.wizard.optionalTools).toEqual(['rtk'])
68
+ })
69
+
70
+ it('detects opencode version from CLI', async () => {
71
+ execa.mockResolvedValue({ exitCode: 0, stdout: '2.0.0', stderr: '' })
72
+
73
+ await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
74
+
75
+ const call = fse.writeJson.mock.calls[0]
76
+ const payload = call[1]
77
+ expect(payload.opencodeVersion).toBe('2.0.0')
78
+ })
79
+
80
+ it('handles missing opencode gracefully', async () => {
81
+ execa.mockResolvedValue({ exitCode: 1, stdout: '', stderr: '' })
82
+
83
+ await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
84
+
85
+ const call = fse.writeJson.mock.calls[0]
86
+ const payload = call[1]
87
+ expect(payload.opencodeVersion).toBe(null)
88
+ })
89
+
90
+ it('includes note field', async () => {
91
+ execa.mockResolvedValue({ exitCode: 0, stdout: '1', stderr: '' })
92
+
93
+ await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
94
+
95
+ const call = fse.writeJson.mock.calls[0]
96
+ const payload = call[1]
97
+ expect(payload.note).toContain('Informational file only')
98
+ })
99
+ })
@@ -0,0 +1,60 @@
1
+ import { search } from '@inquirer/prompts'
2
+ import fse from 'fs-extra'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const MODELS_PRESET_PATH = path.resolve(__dirname, '../../presets/models.json');
8
+ const modelsPreset = await fse.readJson(MODELS_PRESET_PATH);
9
+
10
+ function costTier(input) {
11
+ if (input === undefined || input === null) return '';
12
+ const tier = modelsPreset.costTiers.find(t => t.max === undefined || input <= t.max);
13
+ return tier ? ` ${tier.label}` : '';
14
+ }
15
+
16
+ function costTierDisplay(cost, canonicalCost) {
17
+ return costTier(canonicalCost !== undefined ? canonicalCost : cost);
18
+ }
19
+
20
+ function formatPrice(price) {
21
+ if (price === undefined || price === null) return '?';
22
+ if (price === 0) return '$0 (subscription)';
23
+ return `$${price}/M`;
24
+ }
25
+
26
+ export function buildDisplayModels(rawModels) {
27
+ return rawModels.map(m => {
28
+ const priceStr = formatPrice(m.cost);
29
+ const canonicalNote = m.canonicalCost !== undefined
30
+ ? ` · official price: ${formatPrice(m.canonicalCost)}/M`
31
+ : '';
32
+ return {
33
+ ...m,
34
+ label: `${m.name}${costTierDisplay(m.cost, m.canonicalCost)}, ${m.id}`,
35
+ description: `${priceStr}${canonicalNote} · context: ${m.context ? (m.context / 1000) + 'k' : '?'}`,
36
+ };
37
+ });
38
+ }
39
+
40
+ export async function pickModel(message, models) {
41
+ return await search({
42
+ message,
43
+ source: (input) => {
44
+ const q = (input || '').toLowerCase();
45
+ const filtered = q
46
+ ? models.filter(m =>
47
+ m.label.toLowerCase().includes(q) ||
48
+ m.id.toLowerCase().includes(q)
49
+ )
50
+ : models;
51
+ return filtered.slice(0, 50).map(m => ({
52
+ name: m.label,
53
+ value: m.id,
54
+ description: m.description,
55
+ }));
56
+ },
57
+ });
58
+ }
59
+
60
+ export { modelsPreset };
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildDisplayModels } from './format.js'
3
+
4
+ describe('buildDisplayModels()', () => {
5
+ it('adds cost tier label for cheap models', () => {
6
+ const raw = [{ id: 'anthropic/claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', cost: 3, context: 200000 }]
7
+
8
+ const result = buildDisplayModels(raw)
9
+
10
+ expect(result[0].label).toContain('[$$]')
11
+ })
12
+
13
+ it('adds cost tier label for mid-range models', () => {
14
+ const raw = [{ id: 'anthropic/claude-3-opus-20240229', name: 'Claude 3 Opus', cost: 15, context: 200000 }]
15
+
16
+ const result = buildDisplayModels(raw)
17
+
18
+ expect(result[0].label).toContain('[$$$]')
19
+ })
20
+
21
+ it('adds cost tier label for expensive models', () => {
22
+ const raw = [{ id: 'openai/gpt-4-turbo', name: 'GPT-4 Turbo', cost: 30, context: 128000 }]
23
+
24
+ const result = buildDisplayModels(raw)
25
+
26
+ expect(result[0].label).toContain('[$$$]')
27
+ })
28
+
29
+ it('shows canonical cost note when provider cost differs', () => {
30
+ const raw = [{ id: 'github-copilot/claude-3-5-sonnet', name: 'Claude 3.5 Sonnet', cost: 0, canonicalCost: 3, context: 200000 }]
31
+
32
+ const result = buildDisplayModels(raw)
33
+
34
+ expect(result[0].description).toContain('official price: $3/M')
35
+ })
36
+
37
+ it('formats context in thousands', () => {
38
+ const raw = [{ id: 'test/model', name: 'Test', cost: 1, context: 128000 }]
39
+
40
+ const result = buildDisplayModels(raw)
41
+
42
+ expect(result[0].description).toContain('context: 128k')
43
+ })
44
+
45
+ it('handles missing cost as ?', () => {
46
+ const raw = [{ id: 'test/model', name: 'Test', context: 1000 }]
47
+
48
+ const result = buildDisplayModels(raw)
49
+
50
+ expect(result[0].description).toContain('?')
51
+ expect(result[0].label).not.toContain('[')
52
+ })
53
+
54
+ it('handles $0 subscription pricing', () => {
55
+ const raw = [{ id: 'test/model', name: 'Test', cost: 0, context: 1000 }]
56
+
57
+ const result = buildDisplayModels(raw)
58
+
59
+ expect(result[0].description).toContain('$0 (subscription)')
60
+ })
61
+
62
+ it('preserves input order (sorting is done upstream by parseModels)', () => {
63
+ const raw = [
64
+ { id: 'expensive/model', name: 'Expensive', cost: 100, context: 1000 },
65
+ { id: 'cheap/model', name: 'Cheap', cost: 1, context: 1000 },
66
+ { id: 'mid/model', name: 'Mid', cost: 10, context: 1000 },
67
+ ]
68
+
69
+ const result = buildDisplayModels(raw)
70
+
71
+ expect(result[0].id).toBe('expensive/model')
72
+ expect(result[1].id).toBe('cheap/model')
73
+ expect(result[2].id).toBe('mid/model')
74
+ })
75
+ })
@@ -0,0 +1,52 @@
1
+ import path from 'path'
2
+ import { header, info, success, warn } from '../../utils/exec.js'
3
+ import { fetchModels } from '../../utils/models-cache.js'
4
+ import { buildDisplayModels, modelsPreset, pickModel } from './format.js'
5
+ import { writeModelsToConfigs } from './write.js'
6
+
7
+ export async function chooseModels() {
8
+ header('Step 7, Choose models');
9
+
10
+ info('Fetching available models from models.dev...');
11
+ const { models: rawModels, source } = await fetchModels();
12
+
13
+ if (!rawModels) {
14
+ warn('Could not fetch models (offline and no cache). Skipping model selection.');
15
+ warn('Set models later in .agents/agents/<name>.md and .opencode/opencode.json');
16
+ return;
17
+ }
18
+
19
+ if (source === 'stale-cache') {
20
+ warn('Network unavailable, using cached model list (may be outdated).');
21
+ } else if (source === 'cache') {
22
+ info('Using cached model list (refreshes weekly).');
23
+ }
24
+
25
+ const models = buildDisplayModels(rawModels);
26
+ success(`${models.length} models available`);
27
+ console.log();
28
+ info('Cost indicators: [$] cheap [$$] mid [$$$] expensive');
29
+ info('Type to search. Change selections later in .agents/agents/ and .opencode/opencode.json');
30
+ console.log();
31
+
32
+ for (const line of modelsPreset.roles.plan.info) info(line);
33
+ const planModel = await pickModel(modelsPreset.roles.plan.prompt, models);
34
+ console.log();
35
+
36
+ for (const line of modelsPreset.roles.build.info) info(line);
37
+ const buildModel = await pickModel(modelsPreset.roles.build.prompt, models);
38
+ console.log();
39
+
40
+ for (const line of modelsPreset.roles.fast.info) info(line);
41
+ const fastModel = await pickModel(modelsPreset.roles.fast.prompt, models);
42
+ console.log();
43
+
44
+ const agentsDir = path.join(process.cwd(), '.agents', 'agents');
45
+ await writeModelsToConfigs({ planModel, buildModel, fastModel, agentsDir, preset: modelsPreset });
46
+
47
+ console.log();
48
+ warn('Make sure you have API access to the selected models.');
49
+ warn('Change them anytime in .agents/agents/<name>.md and .opencode/opencode.json');
50
+
51
+ return { planModel, buildModel, fastModel };
52
+ }