opencode-onboard 0.3.3 → 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.
- package/README.md +266 -214
- package/content/.agents/agents/basic-engineer.md +30 -0
- package/content/.agents/agents/devops-manager.md +38 -29
- package/content/.agents/session-log.json +41 -0
- package/content/.agents/skills/ob-default/SKILL.md +21 -0
- package/content/.agents/skills/ob-generic-guardrails/SKILL.md +32 -0
- package/content/.agents/skills/ob-global/SKILL.md +49 -0
- package/content/.agents/skills/ob-pullrequest-az/SKILL.md +11 -21
- package/content/.agents/skills/ob-pullrequest-gh/SKILL.md +14 -24
- package/content/.agents/skills/ob-userstory-az/SKILL.md +8 -14
- package/content/.agents/skills/ob-userstory-gh/SKILL.md +6 -14
- package/content/.opencode/commands/opsx-apply.md +50 -33
- package/content/.opencode/plugins/session-log.js +1 -1
- package/content/.opencode/skills/openspec-apply-change/SKILL.md +50 -33
- package/content/AGENTS.md +94 -144
- package/content/skills-lock.json +4 -0
- package/package.json +6 -1
- package/src/index.js +13 -47
- package/src/presets/browser.json +18 -0
- package/src/presets/clean.json +21 -0
- package/src/presets/models.json +33 -0
- package/src/presets/optimization.json +22 -0
- package/src/presets/platforms.json +29 -2
- package/src/presets/quota.json +14 -0
- package/src/presets/source.json +17 -0
- package/src/steps/browser/browser.test.js +81 -0
- package/src/steps/{install-browser.js → browser/index.js} +12 -15
- package/src/steps/{__tests__/clean-ai-files.test.js → clean/clean.test.js} +28 -13
- package/src/steps/{clean-ai-files.js → clean/index.js} +32 -30
- package/src/steps/copy/agents.js +106 -0
- package/src/steps/{__tests__/copy-content.test.js → copy/copy.test.js} +10 -1
- package/src/steps/copy/index.js +33 -0
- package/src/steps/copy/skills.js +55 -0
- package/src/steps/{write-onboard-config.js → metadata/index.js} +3 -3
- package/src/steps/metadata/metadata.test.js +96 -0
- package/src/steps/models/format.js +60 -0
- package/src/steps/models/format.test.js +74 -0
- package/src/steps/models/index.js +52 -0
- package/src/steps/models/write.js +54 -0
- package/src/steps/models/write.test.js +119 -0
- package/src/steps/{init-openspec.js → openspec/ensemble.js} +20 -57
- package/src/steps/openspec/ensemble.test.js +79 -0
- package/src/steps/openspec/index.js +32 -0
- package/src/steps/optimization/caveman-guidance.js +11 -0
- package/src/steps/{install-caveman.js → optimization/caveman.js} +5 -19
- package/src/steps/optimization/global.js +64 -0
- package/src/steps/optimization/index.js +101 -0
- package/src/steps/{__tests__/token-optimization.test.js → optimization/optimization.test.js} +19 -24
- package/src/steps/{install-quota.js → optimization/quota.js} +12 -10
- package/src/steps/platform/index.js +81 -0
- package/src/steps/platform/platform.test.js +129 -0
- package/src/steps/{choose-source-scope.js → source/index.js} +11 -17
- package/src/steps/source/source.test.js +89 -0
- package/src/utils/__tests__/copy.test.js +12 -5
- package/src/utils/copy.js +4 -24
- package/src/utils/exec-spinner.js +47 -0
- package/src/utils/exec.js +120 -162
- package/src/utils/models-cache.js +25 -68
- package/src/utils/models-pricing.js +42 -0
- package/src/utils/models-pricing.test.js +94 -0
- package/content/.agents/agents/back-engineer.md +0 -87
- package/content/.agents/agents/front-engineer.md +0 -86
- package/content/.agents/agents/infra-engineer.md +0 -85
- package/content/.agents/agents/quality-engineer.md +0 -86
- package/content/.agents/agents/security-auditor.md +0 -86
- package/src/steps/__tests__/check-env.test.js +0 -70
- package/src/steps/__tests__/check-platform.test.js +0 -104
- package/src/steps/__tests__/check-rtk.test.js +0 -38
- package/src/steps/__tests__/choose-platform.test.js +0 -38
- package/src/steps/check-env.js +0 -26
- package/src/steps/check-platform.js +0 -80
- package/src/steps/check-rtk.js +0 -38
- package/src/steps/choose-models.js +0 -165
- package/src/steps/choose-platform.js +0 -22
- package/src/steps/choose-skills-provider.js +0 -79
- package/src/steps/copy-content.js +0 -89
- package/src/steps/enable-caveman-guidance.js +0 -78
- package/src/steps/patch-agents-md.js +0 -153
- package/src/steps/token-optimization.js +0 -59
|
@@ -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 '
|
|
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 '
|
|
5
|
+
import { header, success, warn } from '../../utils/exec.js'
|
|
6
6
|
|
|
7
7
|
const require = createRequire(import.meta.url)
|
|
8
|
-
const { version: onboardVersion } = require('
|
|
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
|
|
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
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { success } from '../../utils/exec.js'
|
|
4
|
+
|
|
5
|
+
export async function writeModelToAgent(agentFile, modelId) {
|
|
6
|
+
const content = await fse.readFile(agentFile, 'utf-8');
|
|
7
|
+
const updated = content.replace(
|
|
8
|
+
/^(---\n[\s\S]*?)\n---/m,
|
|
9
|
+
`$1\nmodel: ${modelId}\n---`
|
|
10
|
+
);
|
|
11
|
+
await fse.writeFile(agentFile, updated, 'utf-8');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function writeModelsToConfigs({ planModel, buildModel, fastModel, agentsDir, preset }) {
|
|
15
|
+
for (const name of preset.roles.build.agents) {
|
|
16
|
+
const file = path.join(agentsDir, `${name}.md`);
|
|
17
|
+
if (await fse.pathExists(file)) {
|
|
18
|
+
await writeModelToAgent(file, buildModel);
|
|
19
|
+
success(`${name} → ${buildModel}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const name of preset.roles.fast.agents) {
|
|
24
|
+
const file = path.join(agentsDir, `${name}.md`);
|
|
25
|
+
if (await fse.pathExists(file)) {
|
|
26
|
+
await writeModelToAgent(file, fastModel);
|
|
27
|
+
success(`${name} → ${fastModel}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const opencodeJsonPath = path.join(process.cwd(), '.opencode', 'opencode.json');
|
|
32
|
+
if (await fse.pathExists(opencodeJsonPath)) {
|
|
33
|
+
const config = await fse.readJson(opencodeJsonPath);
|
|
34
|
+
config.model = buildModel;
|
|
35
|
+
await fse.writeJson(opencodeJsonPath, config, { spaces: 2 });
|
|
36
|
+
success(`default model -> ${buildModel} (written to .opencode/opencode.json)`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ensembleJsonPath = path.join(process.cwd(), '.opencode', 'ensemble.json');
|
|
40
|
+
if (await fse.pathExists(ensembleJsonPath)) {
|
|
41
|
+
const ensemble = await fse.readJson(ensembleJsonPath);
|
|
42
|
+
delete ensemble.defaultModel;
|
|
43
|
+
ensemble.modelsByAgent = {
|
|
44
|
+
...ensemble.modelsByAgent,
|
|
45
|
+
plan: planModel,
|
|
46
|
+
build: buildModel,
|
|
47
|
+
explore: fastModel,
|
|
48
|
+
};
|
|
49
|
+
await fse.writeJson(ensembleJsonPath, ensemble, { spaces: 2 });
|
|
50
|
+
success(`plan model -> ${planModel} (written to .opencode/ensemble.json)`);
|
|
51
|
+
success(`build model -> ${buildModel} (written to .opencode/ensemble.json)`);
|
|
52
|
+
success(`fast model -> ${fastModel} (written to .opencode/ensemble.json)`);
|
|
53
|
+
}
|
|
54
|
+
}
|