opencode-onboard 0.3.1 → 0.4.1

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 (79) hide show
  1. package/README.md +266 -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/opsx-apply.md +50 -33
  13. package/content/.opencode/opencode.json +3 -3
  14. package/content/.opencode/plugins/session-log.js +1 -1
  15. package/content/.opencode/skills/openspec-apply-change/SKILL.md +50 -33
  16. package/content/AGENTS.md +95 -141
  17. package/content/skills-lock.json +4 -0
  18. package/package.json +6 -1
  19. package/src/index.js +112 -191
  20. package/src/presets/browser.json +18 -0
  21. package/src/presets/clean.json +21 -0
  22. package/src/presets/models.json +33 -0
  23. package/src/presets/optimization.json +22 -0
  24. package/src/presets/platforms.json +29 -2
  25. package/src/presets/quota.json +14 -0
  26. package/src/presets/source.json +17 -0
  27. package/src/steps/browser/browser.test.js +81 -0
  28. package/src/steps/{install-browser.js → browser/index.js} +12 -15
  29. package/src/steps/{__tests__/clean-ai-files.test.js → clean/clean.test.js} +28 -13
  30. package/src/steps/{clean-ai-files.js → clean/index.js} +32 -30
  31. package/src/steps/{patch-agents-md.js → copy/agents.js} +41 -20
  32. package/src/steps/{__tests__/copy-content.test.js → copy/copy.test.js} +10 -1
  33. package/src/steps/copy/index.js +33 -0
  34. package/src/steps/copy/skills.js +55 -0
  35. package/src/steps/{write-onboard-config.js → metadata/index.js} +3 -3
  36. package/src/steps/metadata/metadata.test.js +96 -0
  37. package/src/steps/models/format.js +60 -0
  38. package/src/steps/models/format.test.js +74 -0
  39. package/src/steps/models/index.js +52 -0
  40. package/src/steps/models/write.js +54 -0
  41. package/src/steps/models/write.test.js +119 -0
  42. package/src/steps/{init-openspec.js → openspec/ensemble.js} +27 -61
  43. package/src/steps/openspec/ensemble.test.js +79 -0
  44. package/src/steps/openspec/index.js +32 -0
  45. package/src/steps/optimization/caveman-guidance.js +11 -0
  46. package/src/steps/{install-caveman.js → optimization/caveman.js} +5 -19
  47. package/src/steps/optimization/global.js +64 -0
  48. package/src/steps/optimization/index.js +101 -0
  49. package/src/steps/{__tests__/token-optimization.test.js → optimization/optimization.test.js} +19 -24
  50. package/src/steps/{install-quota.js → optimization/quota.js} +12 -10
  51. package/src/steps/platform/index.js +81 -0
  52. package/src/steps/platform/platform.test.js +129 -0
  53. package/src/steps/{choose-source-scope.js → source/index.js} +11 -17
  54. package/src/steps/source/source.test.js +89 -0
  55. package/src/utils/__tests__/copy.test.js +12 -5
  56. package/src/utils/copy.js +4 -24
  57. package/src/utils/exec-spinner.js +47 -0
  58. package/src/utils/exec.js +120 -162
  59. package/src/utils/models-cache.js +25 -68
  60. package/src/utils/models-pricing.js +42 -0
  61. package/src/utils/models-pricing.test.js +94 -0
  62. package/content/.agents/agents/back-engineer.md +0 -87
  63. package/content/.agents/agents/front-engineer.md +0 -86
  64. package/content/.agents/agents/infra-engineer.md +0 -85
  65. package/content/.agents/agents/quality-engineer.md +0 -86
  66. package/content/.agents/agents/security-auditor.md +0 -86
  67. package/src/steps/__tests__/check-env.test.js +0 -70
  68. package/src/steps/__tests__/check-platform.test.js +0 -104
  69. package/src/steps/__tests__/check-rtk.test.js +0 -38
  70. package/src/steps/__tests__/choose-platform.test.js +0 -38
  71. package/src/steps/check-env.js +0 -26
  72. package/src/steps/check-platform.js +0 -80
  73. package/src/steps/check-rtk.js +0 -38
  74. package/src/steps/choose-models.js +0 -163
  75. package/src/steps/choose-platform.js +0 -22
  76. package/src/steps/choose-skills-provider.js +0 -79
  77. package/src/steps/copy-content.js +0 -89
  78. package/src/steps/enable-caveman-guidance.js +0 -93
  79. package/src/steps/token-optimization.js +0 -59
@@ -3,6 +3,10 @@ import fse from 'fs-extra'
3
3
  import os from 'os'
4
4
  import path from 'path'
5
5
 
6
+ vi.mock('@inquirer/prompts', () => ({
7
+ checkbox: vi.fn(),
8
+ }))
9
+
6
10
  vi.mock('../../utils/exec.js', () => ({
7
11
  header: vi.fn(),
8
12
  success: vi.fn(),
@@ -11,7 +15,8 @@ vi.mock('../../utils/exec.js', () => ({
11
15
  prompt: vi.fn(),
12
16
  }))
13
17
 
14
- import { success, warn } from '../../utils/exec.js'
18
+ import { success } from '../../utils/exec.js'
19
+ import { checkbox } from '@inquirer/prompts'
15
20
 
16
21
  describe('cleanAiFiles()', () => {
17
22
  let tmpDir
@@ -31,24 +36,22 @@ describe('cleanAiFiles()', () => {
31
36
  })
32
37
 
33
38
  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
+ const { cleanAiFiles } = await import('./index.js')
39
40
 
40
41
  await cleanAiFiles()
41
42
 
42
43
  expect(success).toHaveBeenCalledWith('No existing AI config files to remove')
43
44
  })
44
45
 
45
- it('removes found AI files after Enter', async () => {
46
+ it('removes selected AI files', async () => {
46
47
  await fse.writeFile(path.join(tmpDir, 'AGENTS.md'), '# agents')
47
48
  await fse.writeFile(path.join(tmpDir, 'CLAUDE.md'), '# claude')
49
+ checkbox.mockResolvedValue([
50
+ path.join(tmpDir, 'AGENTS.md'),
51
+ path.join(tmpDir, 'CLAUDE.md'),
52
+ ])
48
53
 
49
- const { cleanAiFiles } = await import('../clean-ai-files.js')
50
-
51
- setTimeout(() => process.stdin.emit('data', '\n'), 10)
54
+ const { cleanAiFiles } = await import('./index.js')
52
55
 
53
56
  await cleanAiFiles()
54
57
 
@@ -57,16 +60,28 @@ describe('cleanAiFiles()', () => {
57
60
  expect(success).toHaveBeenCalledWith('Removed existing AI config files')
58
61
  })
59
62
 
63
+ it('keeps unselected AI files', async () => {
64
+ await fse.writeFile(path.join(tmpDir, 'AGENTS.md'), '# agents')
65
+ await fse.writeFile(path.join(tmpDir, 'CLAUDE.md'), '# claude')
66
+ checkbox.mockResolvedValue([path.join(tmpDir, 'AGENTS.md')])
67
+
68
+ const { cleanAiFiles } = await import('./index.js')
69
+
70
+ await cleanAiFiles()
71
+
72
+ expect(await fse.pathExists(path.join(tmpDir, 'AGENTS.md'))).toBe(false)
73
+ expect(await fse.pathExists(path.join(tmpDir, 'CLAUDE.md'))).toBe(true)
74
+ })
75
+
60
76
  it('removes .agents sub-entries but preserves .agents/skills', async () => {
61
77
  const agentsDir = path.join(tmpDir, '.agents')
62
78
  await fse.ensureDir(path.join(agentsDir, 'agents'))
63
79
  await fse.ensureDir(path.join(agentsDir, 'skills', 'my-skill'))
64
80
  await fse.writeFile(path.join(agentsDir, 'agents', 'front-engineer.md'), 'agent')
65
81
  await fse.writeFile(path.join(agentsDir, 'skills', 'my-skill', 'SKILL.md'), 'skill')
82
+ checkbox.mockResolvedValue([path.join(agentsDir, 'agents')])
66
83
 
67
- const { cleanAiFiles } = await import('../clean-ai-files.js')
68
-
69
- setTimeout(() => process.stdin.emit('data', '\n'), 10)
84
+ const { cleanAiFiles } = await import('./index.js')
70
85
 
71
86
  await cleanAiFiles()
72
87
 
@@ -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
  }
@@ -1,27 +1,20 @@
1
1
  import fse from 'fs-extra'
2
2
  import path from 'path'
3
- import { info, success } from '../utils/exec.js'
3
+ import { info, success } from '../../utils/exec.js'
4
4
 
5
- // Each block is identified by its heading line. We remove from the heading up to (and including) the next `---` separator.
6
5
  const STEP1_HEADING = '### Step 1, Archive project history into OpenSpec'
7
6
  const STEP2_HEADING = '### Step 2, Generate DESIGN.md'
8
7
  const STEP3_HEADING = '### Step 3, Generate ARCHITECTURE.md'
9
8
 
10
- // Confirm message lines that reference each step, removed when the step is skipped
11
9
  const STEP1_CONFIRM_LINE = '- Project history archived in openspec'
12
10
  const STEP2_CONFIRM_LINE = '- DESIGN.md generated'
13
11
  const STEP3_CONFIRM_LINE = '- ARCHITECTURE.md generated'
14
12
 
15
- /**
16
- * Remove a bootstrap step block from AGENTS.md content.
17
- * Removes from the step heading line up to and including the next `---` separator line.
18
- */
19
13
  function removeStepBlock(content, heading) {
20
14
  const lines = content.split('\n')
21
15
  const start = lines.findIndex(l => l.trim() === heading.trim())
22
16
  if (start === -1) return content
23
17
 
24
- // Find the next `---` separator after the heading
25
18
  let end = -1
26
19
  for (let i = start + 1; i < lines.length; i++) {
27
20
  if (lines[i].trim() === '---') { end = i; break }
@@ -29,27 +22,45 @@ function removeStepBlock(content, heading) {
29
22
 
30
23
  if (end === -1) return content
31
24
 
32
- // Remove the block including any blank line before the heading
33
25
  const removeFrom = start > 0 && lines[start - 1].trim() === '' ? start - 1 : start
34
26
  lines.splice(removeFrom, end - removeFrom + 1)
35
27
  return lines.join('\n')
36
28
  }
37
29
 
38
- /**
39
- * Remove a specific line from the confirm message block in AGENTS.md.
40
- */
41
30
  function removeConfirmLine(content, line) {
42
31
  return content.split('\n').filter(l => l.trim() !== line.trim()).join('\n')
43
32
  }
44
33
 
45
- /**
46
- * Renumber remaining bootstrap steps sequentially (Step 1, Step 2, ...).
47
- */
48
34
  function renumberSteps(content) {
49
35
  let counter = 0
50
36
  return content.replace(/^### Step \d+,/gm, () => `### Step ${++counter},`)
51
37
  }
52
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
+
53
64
  export async function patchAgentsMd(ctx) {
54
65
  const agentsMdPath = path.join(process.cwd(), 'AGENTS.md')
55
66
  if (!await fse.pathExists(agentsMdPath)) return
@@ -75,11 +86,21 @@ export async function patchAgentsMd(ctx) {
75
86
  patches.push('Step 3 (ARCHITECTURE.md) removed, ARCHITECTURE.md already exists')
76
87
  }
77
88
 
78
- if (patches.length === 0) return
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
+ }
79
96
 
80
- content = renumberSteps(content)
81
- await fse.writeFile(agentsMdPath, content, 'utf-8')
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
82
100
 
83
- for (const msg of patches) info(msg)
84
- success('AGENTS.md patched for existing project state')
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}`)
85
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,96 @@
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
29
+
30
+ beforeEach(() => {
31
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'metadata-test-'))
32
+ process.chdir(tmpDir)
33
+ })
34
+
35
+ afterEach(() => {
36
+ fs.rmSync(tmpDir, { recursive: true, force: true })
37
+ })
38
+
39
+ it('writes JSON file with all wizard selections', async () => {
40
+ execa.mockResolvedValue({ exitCode: 0, stdout: '1.2.3', stderr: '' })
41
+
42
+ await writeOnboardConfig({
43
+ platform: 'github',
44
+ sourceMode: 'current',
45
+ sourceRoots: ['/test/path'],
46
+ hasDesign: true,
47
+ hasArchitecture: false,
48
+ hasOpenspec: true,
49
+ additionalSkillsProvider: 'npx-skills',
50
+ planModel: 'plan-model',
51
+ buildModel: 'build-model',
52
+ fastModel: 'fast-model',
53
+ optionalTools: ['rtk'],
54
+ cavemanGuidance: true,
55
+ })
56
+
57
+ expect(fse.ensureDir).toHaveBeenCalled()
58
+ expect(fse.writeJson).toHaveBeenCalled()
59
+ const call = fse.writeJson.mock.calls[0]
60
+ const payload = call[0]
61
+ expect(payload.schema).toBe(1)
62
+ expect(payload.wizard.platform).toBe('github')
63
+ expect(payload.wizard.models.build).toBe('build-model')
64
+ expect(payload.wizard.optionalTools).toEqual(['rtk'])
65
+ })
66
+
67
+ it('detects opencode version from CLI', async () => {
68
+ execa.mockResolvedValue({ exitCode: 0, stdout: '2.0.0', stderr: '' })
69
+
70
+ await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
71
+
72
+ const call = fse.writeJson.mock.calls[0]
73
+ const payload = call[0]
74
+ expect(payload.opencodeVersion).toBe('2.0.0')
75
+ })
76
+
77
+ it('handles missing opencode gracefully', async () => {
78
+ execa.mockResolvedValue({ exitCode: 1, stdout: '', stderr: '' })
79
+
80
+ await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
81
+
82
+ const call = fse.writeJson.mock.calls[0]
83
+ const payload = call[0]
84
+ expect(payload.opencodeVersion).toBe(null)
85
+ })
86
+
87
+ it('includes note field', async () => {
88
+ execa.mockResolvedValue({ exitCode: 0, stdout: '1', stderr: '' })
89
+
90
+ await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
91
+
92
+ const call = fse.writeJson.mock.calls[0]
93
+ const payload = call[0]
94
+ expect(payload.note).toContain('Informational file only')
95
+ })
96
+ })
@@ -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,74 @@
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('cost: ?')
51
+ })
52
+
53
+ it('handles $0 subscription pricing', () => {
54
+ const raw = [{ id: 'test/model', name: 'Test', cost: 0, context: 1000 }]
55
+
56
+ const result = buildDisplayModels(raw)
57
+
58
+ expect(result[0].description).toContain('$0 (subscription)')
59
+ })
60
+
61
+ it('sorts models by cost ascending', () => {
62
+ const raw = [
63
+ { id: 'expensive/model', name: 'Expensive', cost: 100, context: 1000 },
64
+ { id: 'cheap/model', name: 'Cheap', cost: 1, context: 1000 },
65
+ { id: 'mid/model', name: 'Mid', cost: 10, context: 1000 },
66
+ ]
67
+
68
+ const result = buildDisplayModels(raw)
69
+
70
+ expect(result[0].id).toBe('cheap/model')
71
+ expect(result[1].id).toBe('mid/model')
72
+ expect(result[2].id).toBe('expensive/model')
73
+ })
74
+ })