opencode-onboard 0.0.1 → 0.1.0

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 (46) hide show
  1. package/README.md +215 -0
  2. package/content/.agents/agents/.bootstrap/AGENTS.template.md +234 -0
  3. package/content/.agents/agents/back-engineer.md +74 -0
  4. package/content/.agents/agents/devops-manager.md +108 -0
  5. package/content/.agents/agents/front-engineer.md +73 -0
  6. package/content/.agents/agents/infra-engineer.md +74 -0
  7. package/content/.agents/agents/quality-engineer.md +74 -0
  8. package/content/.agents/agents/security-auditor.md +84 -0
  9. package/content/.agents/skills/browser-automation/SKILL.md +63 -0
  10. package/content/{.opencode → .agents}/skills/ob-userstory-az/SKILL.md +6 -6
  11. package/content/{.opencode → .agents}/skills/ob-userstory-gh/SKILL.md +3 -3
  12. package/content/.opencode/package-lock.json +3 -3
  13. package/content/AGENTS.md +13 -13
  14. package/content/DESIGN.md +1 -1
  15. package/package.json +18 -1
  16. package/src/index.js +97 -1
  17. package/src/presets/platforms.json +10 -0
  18. package/src/steps/__tests__/check-env.test.js +70 -0
  19. package/src/steps/__tests__/check-platform.test.js +104 -0
  20. package/src/steps/__tests__/check-rtk.test.js +37 -0
  21. package/src/steps/__tests__/choose-platform.test.js +38 -0
  22. package/src/steps/__tests__/clean-ai-files.test.js +76 -0
  23. package/src/steps/__tests__/copy-content.test.js +62 -0
  24. package/src/steps/check-env.js +26 -0
  25. package/src/steps/check-platform.js +80 -0
  26. package/src/steps/check-rtk.js +20 -0
  27. package/src/steps/choose-models.js +141 -0
  28. package/src/steps/choose-platform.js +22 -0
  29. package/src/steps/choose-skills-provider.js +75 -0
  30. package/src/steps/clean-ai-files.js +51 -0
  31. package/src/steps/copy-content.js +21 -0
  32. package/src/steps/init-openspec.js +22 -0
  33. package/src/steps/install-browser.js +57 -0
  34. package/src/utils/__tests__/copy.test.js +110 -0
  35. package/src/utils/__tests__/exec.test.js +108 -0
  36. package/src/utils/copy.js +54 -0
  37. package/src/utils/exec.js +161 -0
  38. package/src/utils/models-cache.js +101 -0
  39. package/content/.opencode/agents/.bootstrap/AGENTS.template.md +0 -230
  40. package/content/.opencode/agents/.bootstrap/CUSTOM-AGENT.template.md +0 -24
  41. package/content/.opencode/agents/ob-pullrequest-creator-az.md +0 -332
  42. package/content/.opencode/agents/ob-pullrequest-creator-gh.md +0 -177
  43. package/content/.opencode/agents/ob-pullrequest-observer-az.md +0 -248
  44. package/content/.opencode/agents/ob-pullrequest-observer-gh.md +0 -197
  45. package/content/.opencode/agents/qa.md +0 -137
  46. package/content/.opencode/commands/.gitkeep +0 -0
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ commandExists: vi.fn(),
5
+ header: vi.fn(),
6
+ success: vi.fn(),
7
+ error: vi.fn(),
8
+ }))
9
+
10
+ // Mock execa used directly in check-env.js
11
+ vi.mock('execa', () => ({ execa: vi.fn() }))
12
+
13
+ import { commandExists, error, success } from '../../utils/exec.js'
14
+ import { checkEnv } from '../check-env.js'
15
+
16
+ describe('checkEnv()', () => {
17
+ const originalVersion = process.version
18
+ const originalExit = process.exit
19
+
20
+ beforeEach(() => {
21
+ process.exit = vi.fn()
22
+ vi.clearAllMocks()
23
+ })
24
+
25
+ afterEach(() => {
26
+ Object.defineProperty(process, 'version', { value: originalVersion, writable: true })
27
+ process.exit = originalExit
28
+ })
29
+
30
+ it('exits with error when Node version is < 18', async () => {
31
+ Object.defineProperty(process, 'version', { value: 'v16.0.0', writable: true })
32
+ commandExists.mockResolvedValue(true)
33
+
34
+ await checkEnv()
35
+
36
+ expect(error).toHaveBeenCalledWith(expect.stringContaining('v16.0.0'))
37
+ expect(process.exit).toHaveBeenCalledWith(1)
38
+ })
39
+
40
+ it('succeeds when Node version is >= 18 and pnpm is available', async () => {
41
+ Object.defineProperty(process, 'version', { value: 'v20.0.0', writable: true })
42
+ commandExists.mockImplementation(async (cmd) => cmd === 'pnpm')
43
+
44
+ await checkEnv()
45
+
46
+ expect(process.exit).not.toHaveBeenCalled()
47
+ expect(success).toHaveBeenCalledWith(expect.stringContaining('v20.0.0'))
48
+ expect(success).toHaveBeenCalledWith('pnpm available')
49
+ })
50
+
51
+ it('succeeds when Node version is >= 18 and only npm is available', async () => {
52
+ Object.defineProperty(process, 'version', { value: 'v18.0.0', writable: true })
53
+ commandExists.mockImplementation(async (cmd) => cmd === 'npm')
54
+
55
+ await checkEnv()
56
+
57
+ expect(process.exit).not.toHaveBeenCalled()
58
+ expect(success).toHaveBeenCalledWith('npm available')
59
+ })
60
+
61
+ it('exits when neither npm nor pnpm available', async () => {
62
+ Object.defineProperty(process, 'version', { value: 'v20.0.0', writable: true })
63
+ commandExists.mockResolvedValue(false)
64
+
65
+ await checkEnv()
66
+
67
+ expect(error).toHaveBeenCalledWith(expect.stringContaining('Neither npm nor pnpm'))
68
+ expect(process.exit).toHaveBeenCalledWith(1)
69
+ })
70
+ })
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ commandExists: vi.fn(),
5
+ header: vi.fn(),
6
+ success: vi.fn(),
7
+ warn: vi.fn(),
8
+ info: vi.fn(),
9
+ code: vi.fn(),
10
+ }))
11
+
12
+ vi.mock('execa', () => ({
13
+ execa: vi.fn(),
14
+ }))
15
+
16
+ import { execa } from 'execa'
17
+ import { commandExists, success, warn } from '../../utils/exec.js'
18
+ import { checkPlatform } from '../check-platform.js'
19
+
20
+ describe('checkPlatform()', () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks()
23
+ })
24
+
25
+ describe('github path', () => {
26
+ it('prints success when gh is installed and authenticated', async () => {
27
+ commandExists.mockResolvedValue(true)
28
+ execa.mockResolvedValue({ exitCode: 0, stdout: '' })
29
+
30
+ await checkPlatform('github')
31
+
32
+ expect(success).toHaveBeenCalledWith('GitHub CLI (gh) available')
33
+ expect(success).toHaveBeenCalledWith('GitHub CLI authenticated')
34
+ })
35
+
36
+ it('warns when gh is installed but not authenticated', async () => {
37
+ commandExists.mockResolvedValue(true)
38
+ execa.mockResolvedValue({ exitCode: 1, stdout: '' })
39
+
40
+ await checkPlatform('github')
41
+
42
+ expect(success).toHaveBeenCalledWith('GitHub CLI (gh) available')
43
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('not authenticated'))
44
+ })
45
+
46
+ it('warns when gh is not installed', async () => {
47
+ commandExists.mockResolvedValue(false)
48
+
49
+ await checkPlatform('github')
50
+
51
+ expect(warn).toHaveBeenCalledWith('GitHub CLI (gh) not found.')
52
+ expect(success).not.toHaveBeenCalled()
53
+ })
54
+
55
+ it('warns when gh auth status check throws', async () => {
56
+ commandExists.mockResolvedValue(true)
57
+ execa.mockRejectedValue(new Error('spawn error'))
58
+
59
+ await checkPlatform('github')
60
+
61
+ expect(warn).toHaveBeenCalledWith('Could not check gh auth status.')
62
+ })
63
+ })
64
+
65
+ describe('azure path', () => {
66
+ it('prints success when az is installed and azure-devops extension present', async () => {
67
+ commandExists.mockResolvedValue(true)
68
+ execa.mockResolvedValue({ exitCode: 0, stdout: 'azure-devops\tsome info' })
69
+
70
+ await checkPlatform('azure')
71
+
72
+ expect(success).toHaveBeenCalledWith('Azure CLI (az) available')
73
+ expect(success).toHaveBeenCalledWith('azure-devops extension installed')
74
+ })
75
+
76
+ it('warns when az is installed but azure-devops extension is missing', async () => {
77
+ commandExists.mockResolvedValue(true)
78
+ execa.mockResolvedValue({ exitCode: 0, stdout: '' })
79
+
80
+ await checkPlatform('azure')
81
+
82
+ expect(success).toHaveBeenCalledWith('Azure CLI (az) available')
83
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('azure-devops extension not found'))
84
+ })
85
+
86
+ it('warns when az is not installed', async () => {
87
+ commandExists.mockResolvedValue(false)
88
+
89
+ await checkPlatform('azure')
90
+
91
+ expect(warn).toHaveBeenCalledWith('Azure CLI (az) not found.')
92
+ expect(success).not.toHaveBeenCalled()
93
+ })
94
+
95
+ it('warns when extension check throws', async () => {
96
+ commandExists.mockResolvedValue(true)
97
+ execa.mockRejectedValue(new Error('spawn error'))
98
+
99
+ await checkPlatform('azure')
100
+
101
+ expect(warn).toHaveBeenCalledWith('Could not check azure-devops extension. Run:')
102
+ })
103
+ })
104
+ })
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ commandExists: vi.fn(),
5
+ header: vi.fn(),
6
+ success: vi.fn(),
7
+ warn: vi.fn(),
8
+ info: vi.fn(),
9
+ code: vi.fn(),
10
+ }))
11
+
12
+ import { commandExists, success, warn } from '../../utils/exec.js'
13
+ import { checkRtk } from '../check-rtk.js'
14
+
15
+ describe('checkRtk()', () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks()
18
+ })
19
+
20
+ it('prints success when rtk is available', async () => {
21
+ commandExists.mockResolvedValue(true)
22
+
23
+ await checkRtk()
24
+
25
+ expect(success).toHaveBeenCalledWith('rtk is available')
26
+ expect(warn).not.toHaveBeenCalled()
27
+ })
28
+
29
+ it('prints warning with install instructions when rtk is not found', async () => {
30
+ commandExists.mockResolvedValue(false)
31
+
32
+ await checkRtk()
33
+
34
+ expect(warn).toHaveBeenCalledWith('rtk not found on PATH.')
35
+ expect(success).not.toHaveBeenCalled()
36
+ })
37
+ })
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ header: vi.fn(),
5
+ success: vi.fn(),
6
+ }))
7
+
8
+ vi.mock('@inquirer/prompts', () => ({
9
+ select: vi.fn(),
10
+ }))
11
+
12
+ import { select } from '@inquirer/prompts'
13
+ import { success } from '../../utils/exec.js'
14
+ import { choosePlatform } from '../choose-platform.js'
15
+
16
+ describe('choosePlatform()', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks()
19
+ })
20
+
21
+ it('returns "github" when user selects GitHub', async () => {
22
+ select.mockResolvedValue('github')
23
+
24
+ const result = await choosePlatform()
25
+
26
+ expect(result).toBe('github')
27
+ expect(success).toHaveBeenCalledWith('Platform: GitHub')
28
+ })
29
+
30
+ it('returns "azure" when user selects Azure DevOps', async () => {
31
+ select.mockResolvedValue('azure')
32
+
33
+ const result = await choosePlatform()
34
+
35
+ expect(result).toBe('azure')
36
+ expect(success).toHaveBeenCalledWith('Platform: Azure DevOps')
37
+ })
38
+ })
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import fse from 'fs-extra'
3
+ import os from 'os'
4
+ import path from 'path'
5
+
6
+ vi.mock('../../utils/exec.js', () => ({
7
+ header: vi.fn(),
8
+ success: vi.fn(),
9
+ warn: vi.fn(),
10
+ info: vi.fn(),
11
+ prompt: vi.fn(),
12
+ }))
13
+
14
+ import { success, warn } from '../../utils/exec.js'
15
+
16
+ describe('cleanAiFiles()', () => {
17
+ let tmpDir
18
+ let originalCwd
19
+
20
+ beforeEach(async () => {
21
+ tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'ob-clean-test-'))
22
+ originalCwd = process.cwd()
23
+ process.chdir(tmpDir)
24
+ vi.clearAllMocks()
25
+ vi.resetModules()
26
+ })
27
+
28
+ afterEach(async () => {
29
+ process.chdir(originalCwd)
30
+ await fse.remove(tmpDir)
31
+ })
32
+
33
+ it('prints success when no AI files are found', async () => {
34
+ const { cleanAiFiles } = await import('../clean-ai-files.js')
35
+
36
+ // Simulate immediate Enter key
37
+ const stdinPush = () => process.stdin.emit('data', '\n')
38
+ setTimeout(stdinPush, 10)
39
+
40
+ await cleanAiFiles()
41
+
42
+ expect(success).toHaveBeenCalledWith('No existing AI config files found')
43
+ })
44
+
45
+ it('removes found AI files after Enter', async () => {
46
+ await fse.writeFile(path.join(tmpDir, 'AGENTS.md'), '# agents')
47
+ await fse.writeFile(path.join(tmpDir, 'CLAUDE.md'), '# claude')
48
+
49
+ const { cleanAiFiles } = await import('../clean-ai-files.js')
50
+
51
+ setTimeout(() => process.stdin.emit('data', '\n'), 10)
52
+
53
+ await cleanAiFiles()
54
+
55
+ expect(await fse.pathExists(path.join(tmpDir, 'AGENTS.md'))).toBe(false)
56
+ expect(await fse.pathExists(path.join(tmpDir, 'CLAUDE.md'))).toBe(false)
57
+ expect(success).toHaveBeenCalledWith('Removed existing AI config files')
58
+ })
59
+
60
+ it('removes .agents sub-entries but preserves .agents/skills', async () => {
61
+ const agentsDir = path.join(tmpDir, '.agents')
62
+ await fse.ensureDir(path.join(agentsDir, 'agents'))
63
+ await fse.ensureDir(path.join(agentsDir, 'skills', 'my-skill'))
64
+ await fse.writeFile(path.join(agentsDir, 'agents', 'front-engineer.md'), 'agent')
65
+ await fse.writeFile(path.join(agentsDir, 'skills', 'my-skill', 'SKILL.md'), 'skill')
66
+
67
+ const { cleanAiFiles } = await import('../clean-ai-files.js')
68
+
69
+ setTimeout(() => process.stdin.emit('data', '\n'), 10)
70
+
71
+ await cleanAiFiles()
72
+
73
+ expect(await fse.pathExists(path.join(agentsDir, 'agents'))).toBe(false)
74
+ expect(await fse.pathExists(path.join(agentsDir, 'skills', 'my-skill', 'SKILL.md'))).toBe(true)
75
+ })
76
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ header: vi.fn(),
5
+ success: vi.fn(),
6
+ error: vi.fn(),
7
+ }))
8
+
9
+ vi.mock('../../utils/copy.js', () => ({
10
+ copyContent: vi.fn(),
11
+ }))
12
+
13
+ import { copyContent } from '../../utils/copy.js'
14
+ import { success, error } from '../../utils/exec.js'
15
+ import { copyContentStep } from '../copy-content.js'
16
+
17
+ describe('copyContentStep()', () => {
18
+ const originalExit = process.exit
19
+
20
+ beforeEach(() => {
21
+ process.exit = vi.fn()
22
+ vi.clearAllMocks()
23
+ })
24
+
25
+ afterEach(() => {
26
+ process.exit = originalExit
27
+ })
28
+
29
+ it('calls copyContent with the correct platform and prints success', async () => {
30
+ copyContent.mockResolvedValue(undefined)
31
+
32
+ await copyContentStep('github')
33
+
34
+ expect(copyContent).toHaveBeenCalledWith(
35
+ expect.stringContaining('content'),
36
+ process.cwd(),
37
+ 'github'
38
+ )
39
+ expect(success).toHaveBeenCalledWith('Files copied to project root')
40
+ })
41
+
42
+ it('calls copyContent with azure platform', async () => {
43
+ copyContent.mockResolvedValue(undefined)
44
+
45
+ await copyContentStep('azure')
46
+
47
+ expect(copyContent).toHaveBeenCalledWith(
48
+ expect.stringContaining('content'),
49
+ process.cwd(),
50
+ 'azure'
51
+ )
52
+ })
53
+
54
+ it('calls process.exit(1) when copyContent throws', async () => {
55
+ copyContent.mockRejectedValue(new Error('disk full'))
56
+
57
+ await copyContentStep('github')
58
+
59
+ expect(error).toHaveBeenCalledWith(expect.stringContaining('disk full'))
60
+ expect(process.exit).toHaveBeenCalledWith(1)
61
+ })
62
+ })
@@ -0,0 +1,26 @@
1
+ import { commandExists, error, header, success } from '../utils/exec.js'
2
+
3
+ export async function checkEnv() {
4
+ header('Step 1, Checking environment')
5
+
6
+ // Node version
7
+ const nodeVersion = process.version
8
+ const major = parseInt(nodeVersion.slice(1).split('.')[0], 10)
9
+ if (major < 18) {
10
+ error(`Node.js ${nodeVersion} detected. Version 18+ is required.`)
11
+ process.exit(1)
12
+ }
13
+ success(`Node.js ${nodeVersion}`)
14
+
15
+ // npm or pnpm
16
+ const hasPnpm = await commandExists('pnpm')
17
+ const hasNpm = await commandExists('npm')
18
+
19
+ if (!hasPnpm && !hasNpm) {
20
+ error('Neither npm nor pnpm found. Please install Node.js from https://nodejs.org')
21
+ process.exit(1)
22
+ }
23
+
24
+ if (hasPnpm) success('pnpm available')
25
+ else success('npm available')
26
+ }
@@ -0,0 +1,80 @@
1
+ import { execa } from 'execa'
2
+ import { code, commandExists, header, info, success, warn } from '../utils/exec.js'
3
+
4
+ export async function checkPlatform(platform) {
5
+ if (platform === 'azure') {
6
+ await checkAzure()
7
+ } else {
8
+ await checkGithub()
9
+ }
10
+ }
11
+
12
+ async function checkAzure() {
13
+ header('Step 4, Checking Azure DevOps CLI')
14
+
15
+ // Check az is installed
16
+ const hasAz = await commandExists('az')
17
+ if (!hasAz) {
18
+ warn('Azure CLI (az) not found.')
19
+ info('Install from: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli')
20
+ return
21
+ }
22
+ success('Azure CLI (az) available')
23
+
24
+ // Check az devops extension
25
+ try {
26
+ const result = await execa('az', ['extension', 'list', '--query', "[?name=='azure-devops']", '-o', 'tsv'], {
27
+ reject: false,
28
+ })
29
+ const hasExtension = result.stdout && result.stdout.includes('azure-devops')
30
+
31
+ if (hasExtension) {
32
+ success('azure-devops extension installed')
33
+ } else {
34
+ warn('azure-devops extension not found. Run:')
35
+ code([
36
+ 'az extension add --name azure-devops',
37
+ 'az config set extension.dynamic_install_allow_preview=true',
38
+ 'az login',
39
+ 'az devops login --organization https://dev.azure.com/<your-org>',
40
+ ])
41
+ }
42
+ } catch {
43
+ warn('Could not check azure-devops extension. Run:')
44
+ code([
45
+ 'az extension add --name azure-devops',
46
+ 'az config set extension.dynamic_install_allow_preview=true',
47
+ 'az login',
48
+ 'az devops login --organization https://dev.azure.com/<your-org>',
49
+ ])
50
+ }
51
+ }
52
+
53
+ async function checkGithub() {
54
+ header('Step 4, Checking GitHub CLI')
55
+
56
+ const hasGh = await commandExists('gh')
57
+
58
+ if (hasGh) {
59
+ success('GitHub CLI (gh) available')
60
+
61
+ // Check auth status
62
+ try {
63
+ const result = await execa('gh', ['auth', 'status'], { reject: false })
64
+ if (result.exitCode === 0) {
65
+ success('GitHub CLI authenticated')
66
+ } else {
67
+ warn('GitHub CLI not authenticated. Run:')
68
+ code(['gh auth login'])
69
+ }
70
+ } catch {
71
+ warn('Could not check gh auth status.')
72
+ }
73
+ } else {
74
+ warn('GitHub CLI (gh) not found.')
75
+ info('Install from: https://cli.github.com')
76
+ console.log()
77
+ info('After installing, authenticate with:')
78
+ code(['gh auth login'])
79
+ }
80
+ }
@@ -0,0 +1,20 @@
1
+ import { code, commandExists, header, info, success, warn } from '../utils/exec.js'
2
+
3
+ export async function checkRtk() {
4
+ header('Step 9, Checking rtk')
5
+
6
+ const available = await commandExists('rtk')
7
+
8
+ if (available) {
9
+ success('rtk is available')
10
+ return
11
+ }
12
+
13
+ warn('rtk not found on PATH.')
14
+ console.log()
15
+ info('rtk is required for agents to run CLI commands safely.')
16
+ info('Install it from: https://github.com/rtk-ai/rtk#pre-built-binaries')
17
+ console.log()
18
+ info('After installing, verify with:')
19
+ code(['rtk --version'])
20
+ }
@@ -0,0 +1,141 @@
1
+ import { search } from '@inquirer/prompts'
2
+ import fse from 'fs-extra'
3
+ import path from 'path'
4
+ import { header, info, success, warn } from '../utils/exec.js'
5
+ import { fetchModels } from '../utils/models-cache.js'
6
+
7
+ const COST_TIER = (input) => {
8
+ if (input === undefined || input === null) return ''
9
+ if (input < 1) return ' [$]'
10
+ if (input <= 10) return ' [$$]'
11
+ return ' [$$$]'
12
+ }
13
+
14
+ // Use canonical cost for the tier badge so all providers of the same model
15
+ // show the same tier (e.g. github-copilot $0 subscription shows [$$] not [$])
16
+ const COST_TIER_DISPLAY = (cost, canonicalCost) =>
17
+ COST_TIER(canonicalCost !== undefined ? canonicalCost : cost)
18
+
19
+ function formatPrice(price) {
20
+ if (price === undefined || price === null) return '?'
21
+ if (price === 0) return '$0 (subscription)'
22
+ return `$${price}/M`
23
+ }
24
+
25
+ function buildDisplayModels(rawModels) {
26
+ return rawModels.map(m => {
27
+ const priceStr = formatPrice(m.cost)
28
+ const canonicalNote = m.canonicalCost !== undefined
29
+ ? ` · official price: ${formatPrice(m.canonicalCost)}/M`
30
+ : ''
31
+ return {
32
+ ...m,
33
+ label: `${m.name}${COST_TIER_DISPLAY(m.cost, m.canonicalCost)} — ${m.id}`,
34
+ description: `${priceStr}${canonicalNote} · context: ${m.context ? (m.context / 1000) + 'k' : '?'}`,
35
+ }
36
+ })
37
+ }
38
+
39
+ async function pickModel(message, models) {
40
+ return await search({
41
+ message,
42
+ source: (input) => {
43
+ const q = (input || '').toLowerCase()
44
+ const filtered = q
45
+ ? models.filter(m =>
46
+ m.label.toLowerCase().includes(q) ||
47
+ m.id.toLowerCase().includes(q)
48
+ )
49
+ : models
50
+ return filtered.slice(0, 50).map(m => ({
51
+ name: m.label,
52
+ value: m.id,
53
+ description: m.description,
54
+ }))
55
+ },
56
+ })
57
+ }
58
+
59
+ async function writeModelToAgent(agentFile, modelId) {
60
+ const content = await fse.readFile(agentFile, 'utf-8')
61
+ const updated = content.replace(
62
+ /^(---\n[\s\S]*?)\n---/m,
63
+ `$1\nmodel: ${modelId}\n---`
64
+ )
65
+ await fse.writeFile(agentFile, updated, 'utf-8')
66
+ }
67
+
68
+ export async function chooseModels() {
69
+ header('Step 8, Choose models')
70
+
71
+ info('Fetching available models from models.dev...')
72
+ const { models: rawModels, source } = await fetchModels()
73
+
74
+ if (!rawModels) {
75
+ warn('Could not fetch models (offline and no cache). Skipping model selection.')
76
+ warn('Set models later in .agents/agents/<name>.md and .opencode/opencode.json')
77
+ return
78
+ }
79
+
80
+ if (source === 'stale-cache') {
81
+ warn('Network unavailable — using cached model list (may be outdated).')
82
+ } else if (source === 'cache') {
83
+ info('Using cached model list (refreshes weekly).')
84
+ }
85
+
86
+ const models = buildDisplayModels(rawModels)
87
+ success(`${models.length} models available`)
88
+ console.log()
89
+ info('Cost indicators: [$] cheap [$$] mid [$$$] expensive')
90
+ info('Type to search. Change selections later in .agents/agents/ and .opencode/opencode.json')
91
+ console.log()
92
+
93
+ // Plan model
94
+ info('PLAN model — used by the main agent for proposals, specs, architecture decisions.')
95
+ info('Pick something capable with strong reasoning.')
96
+ const planModel = await pickModel('Plan model:', models)
97
+ console.log()
98
+
99
+ // Build model
100
+ info('BUILD model — used by front-engineer, back-engineer, infra-engineer, quality-engineer, security-auditor.')
101
+ info('Pick something capable for implementation work.')
102
+ const buildModel = await pickModel('Build model:', models)
103
+ console.log()
104
+
105
+ // Fast model
106
+ info('FAST model — used by devops-manager for reading issues, classifying PR comments.')
107
+ info('Pick something fast and cheap — no heavy reasoning needed.')
108
+ const fastModel = await pickModel('Fast model:', models)
109
+ console.log()
110
+
111
+ // Write build model to builder agents
112
+ const buildAgents = ['front-engineer', 'back-engineer', 'infra-engineer', 'quality-engineer', 'security-auditor']
113
+ const agentsDir = path.join(process.cwd(), '.agents', 'agents')
114
+ for (const name of buildAgents) {
115
+ const file = path.join(agentsDir, `${name}.md`)
116
+ if (await fse.pathExists(file)) {
117
+ await writeModelToAgent(file, buildModel)
118
+ success(`${name} → ${buildModel}`)
119
+ }
120
+ }
121
+
122
+ // Write fast model to devops-manager
123
+ const devopsFile = path.join(agentsDir, 'devops-manager.md')
124
+ if (await fse.pathExists(devopsFile)) {
125
+ await writeModelToAgent(devopsFile, fastModel)
126
+ success(`devops-manager → ${fastModel}`)
127
+ }
128
+
129
+ // Write plan model to opencode.json
130
+ const opencodeJsonPath = path.join(process.cwd(), '.opencode', 'opencode.json')
131
+ if (await fse.pathExists(opencodeJsonPath)) {
132
+ const config = await fse.readJson(opencodeJsonPath)
133
+ config.model = planModel
134
+ await fse.writeJson(opencodeJsonPath, config, { spaces: 2 })
135
+ success(`plan model → ${planModel} (written to .opencode/opencode.json)`)
136
+ }
137
+
138
+ console.log()
139
+ warn('Make sure you have API access to the selected models.')
140
+ warn('Change them anytime in .agents/agents/<name>.md and .opencode/opencode.json')
141
+ }
@@ -0,0 +1,22 @@
1
+ import { select } from '@inquirer/prompts'
2
+ import fse from 'fs-extra'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+ import { header, success } from '../utils/exec.js'
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
+ const PLATFORMS_PRESET_PATH = path.resolve(__dirname, '../presets/platforms.json')
9
+
10
+ const platformsPreset = await fse.readJson(PLATFORMS_PRESET_PATH)
11
+
12
+ export async function choosePlatform() {
13
+ header('Step 3, Version control platform')
14
+
15
+ const platform = await select({
16
+ message: 'Which platform are you using?',
17
+ choices: platformsPreset.map(p => ({ name: p.name, value: p.value })),
18
+ })
19
+
20
+ success(`Platform: ${platform === 'github' ? 'GitHub' : 'Azure DevOps'}`)
21
+ return platform
22
+ }