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.
- package/README.md +278 -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/init.md +8 -0
- package/content/.opencode/commands/main.md +17 -0
- package/content/.opencode/commands/opsx-apply.md +50 -33
- package/content/.opencode/commands/plan.md +37 -0
- 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/commands/join.js +43 -0
- package/src/commands/shared.js +12 -0
- package/src/commands/shared.test.js +56 -0
- package/src/commands/single.js +64 -0
- package/src/commands/wizard.js +99 -0
- package/src/index.js +25 -202
- 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 +99 -0
- package/src/steps/models/format.js +60 -0
- package/src/steps/models/format.test.js +75 -0
- package/src/steps/models/index.js +52 -0
- package/src/steps/models/write.js +54 -0
- package/src/steps/models/write.test.js +117 -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} +37 -22
- 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 +91 -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 +93 -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
|
@@ -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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
7
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
72
|
-
const
|
|
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
|
|
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 (
|
|
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
|
-
|
|
98
|
-
|
|
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 '
|
|
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,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
|
+
}
|