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.
- 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/opencode.json +3 -3
- package/content/.opencode/plugins/session-log.js +1 -1
- package/content/.opencode/skills/openspec-apply-change/SKILL.md +50 -33
- package/content/AGENTS.md +95 -141
- package/content/skills-lock.json +4 -0
- package/package.json +6 -1
- package/src/index.js +112 -191
- 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/{patch-agents-md.js → copy/agents.js} +41 -20
- 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} +27 -61
- 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 -163
- 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 -93
- 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
|
|
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('
|
|
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
|
|
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('
|
|
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('
|
|
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 {
|
|
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
|
}
|
|
@@ -1,27 +1,20 @@
|
|
|
1
1
|
import fse from 'fs-extra'
|
|
2
2
|
import path from 'path'
|
|
3
|
-
import { info, success } from '
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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 '
|
|
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
|
+
})
|