opencode-onboard 0.5.1 → 0.5.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/package.json
CHANGED
|
@@ -24,8 +24,15 @@ export async function writeOnboardConfig(data) {
|
|
|
24
24
|
const opencodeVersion = await detectOpencodeVersion()
|
|
25
25
|
const cwd = data.cwd ?? process.cwd()
|
|
26
26
|
const target = path.join(cwd, '.opencode', 'opencode-onboard.json')
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const selectedModels = Object.fromEntries(
|
|
28
|
+
Object.entries({
|
|
29
|
+
plan: data.planModel,
|
|
30
|
+
build: data.buildModel,
|
|
31
|
+
fast: data.fastModel,
|
|
32
|
+
}).filter(([, value]) => value)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const payload = {
|
|
29
36
|
schema: 1,
|
|
30
37
|
generatedAt: new Date().toISOString(),
|
|
31
38
|
onboardVersion,
|
|
@@ -39,17 +46,13 @@ export async function writeOnboardConfig(data) {
|
|
|
39
46
|
design: !!data.hasDesign,
|
|
40
47
|
architecture: !!data.hasArchitecture,
|
|
41
48
|
openspec: !!data.hasOpenspec,
|
|
42
|
-
},
|
|
43
|
-
openspec: data.openspec,
|
|
44
|
-
additionalSkillsProvider: data.additionalSkillsProvider,
|
|
45
|
-
models: {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
},
|
|
50
|
-
optionalTools: data.optionalTools ?? null,
|
|
51
|
-
cavemanGuidance: data.cavemanGuidance ?? null,
|
|
52
|
-
},
|
|
49
|
+
},
|
|
50
|
+
openspec: data.openspec,
|
|
51
|
+
additionalSkillsProvider: data.additionalSkillsProvider,
|
|
52
|
+
...(Object.keys(selectedModels).length > 0 ? { models: selectedModels } : {}),
|
|
53
|
+
optionalTools: data.optionalTools ?? null,
|
|
54
|
+
cavemanGuidance: data.cavemanGuidance ?? null,
|
|
55
|
+
},
|
|
53
56
|
note: 'Informational file only. Editing this file does not change runtime behavior.',
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -104,4 +104,14 @@ describe('writeOnboardConfig()', () => {
|
|
|
104
104
|
const payload = call[1]
|
|
105
105
|
expect(payload.wizard.platform).toBe('none')
|
|
106
106
|
})
|
|
107
|
+
|
|
108
|
+
it('omits model metadata when no model is selected', async () => {
|
|
109
|
+
execa.mockResolvedValue({ exitCode: 0, stdout: '1', stderr: '' })
|
|
110
|
+
|
|
111
|
+
await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [], cwd: tmpDir })
|
|
112
|
+
|
|
113
|
+
const call = fse.writeJson.mock.calls[0]
|
|
114
|
+
const payload = call[1]
|
|
115
|
+
expect(payload.wizard.models).toBeUndefined()
|
|
116
|
+
})
|
|
107
117
|
})
|
|
@@ -23,8 +23,8 @@ function formatPrice(price) {
|
|
|
23
23
|
return `$${price}/M`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export function buildDisplayModels(rawModels) {
|
|
27
|
-
return rawModels.map(m => {
|
|
26
|
+
export function buildDisplayModels(rawModels) {
|
|
27
|
+
return rawModels.map(m => {
|
|
28
28
|
const priceStr = formatPrice(m.cost);
|
|
29
29
|
const canonicalNote = m.canonicalCost !== undefined
|
|
30
30
|
? ` · official price: ${formatPrice(m.canonicalCost)}/M`
|
|
@@ -35,27 +35,37 @@ export function buildDisplayModels(rawModels) {
|
|
|
35
35
|
label: `${m.name}${costTierDisplay(m.cost, m.canonicalCost)}, ${m.id}`,
|
|
36
36
|
description: `${priceStr}${canonicalNote} · context: ${context}`,
|
|
37
37
|
};
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildModelChoices(input, models) {
|
|
42
|
+
const q = (input || '').toLowerCase();
|
|
43
|
+
const filtered = q
|
|
44
|
+
? models.filter(m =>
|
|
45
|
+
m.label.toLowerCase().includes(q) ||
|
|
46
|
+
m.id.toLowerCase().includes(q)
|
|
47
|
+
)
|
|
48
|
+
: models;
|
|
49
|
+
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
name: 'None',
|
|
53
|
+
value: null,
|
|
54
|
+
description: 'Leave this model unset',
|
|
55
|
+
},
|
|
56
|
+
...filtered.slice(0, 50).map(m => ({
|
|
57
|
+
name: m.label,
|
|
58
|
+
value: m.id,
|
|
59
|
+
description: m.description,
|
|
60
|
+
})),
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function pickModel(message, models) {
|
|
65
|
+
return search({
|
|
66
|
+
message,
|
|
67
|
+
source: input => buildModelChoices(input, models),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
60
70
|
|
|
61
71
|
export { modelsPreset };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildDisplayModels } from './format.js'
|
|
2
|
+
import { buildDisplayModels, buildModelChoices } from './format.js'
|
|
3
3
|
|
|
4
4
|
describe('buildDisplayModels()', () => {
|
|
5
5
|
it('adds cost tier label for cheap models', () => {
|
|
@@ -72,4 +72,30 @@ describe('buildDisplayModels()', () => {
|
|
|
72
72
|
expect(result[1].id).toBe('cheap/model')
|
|
73
73
|
expect(result[2].id).toBe('mid/model')
|
|
74
74
|
})
|
|
75
|
-
|
|
75
|
+
|
|
76
|
+
it('keeps None as the first choice with no search', () => {
|
|
77
|
+
const models = buildDisplayModels([
|
|
78
|
+
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', cost: 2, context: 128000 },
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
const result = buildModelChoices('', models)
|
|
82
|
+
|
|
83
|
+
expect(result[0]).toEqual({
|
|
84
|
+
name: 'None',
|
|
85
|
+
value: null,
|
|
86
|
+
description: 'Leave this model unset',
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('keeps None as the first choice during search', () => {
|
|
91
|
+
const models = buildDisplayModels([
|
|
92
|
+
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', cost: 2, context: 128000 },
|
|
93
|
+
{ id: 'anthropic/claude-3-5-sonnet', name: 'Claude 3.5 Sonnet', cost: 3, context: 200000 },
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
const result = buildModelChoices('claude', models)
|
|
97
|
+
|
|
98
|
+
expect(result[0].name).toBe('None')
|
|
99
|
+
expect(result[1].value).toBe('anthropic/claude-3-5-sonnet')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -2,12 +2,20 @@ import fse from 'fs-extra'
|
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { success } from '../../utils/exec.js'
|
|
4
4
|
|
|
5
|
+
function updateFrontmatterModel(content, modelId) {
|
|
6
|
+
return content.replace(
|
|
7
|
+
/^(---\n)([\s\S]*?)(\n---)/m,
|
|
8
|
+
(_, start, body, end) => {
|
|
9
|
+
const withoutModel = body.replace(/^model:\s.*\n?/m, '').replace(/\n+$/, '')
|
|
10
|
+
const nextBody = modelId ? `${withoutModel}\nmodel: ${modelId}` : withoutModel
|
|
11
|
+
return `${start}${nextBody}${end}`
|
|
12
|
+
}
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
export async function writeModelToAgent(agentFile, modelId) {
|
|
6
17
|
const content = await fse.readFile(agentFile, 'utf-8');
|
|
7
|
-
const updated = content
|
|
8
|
-
/^(---\n[\s\S]*?)\n---/m,
|
|
9
|
-
`$1\nmodel: ${modelId}\n---`
|
|
10
|
-
);
|
|
18
|
+
const updated = updateFrontmatterModel(content, modelId)
|
|
11
19
|
await fse.writeFile(agentFile, updated, 'utf-8');
|
|
12
20
|
}
|
|
13
21
|
|
|
@@ -29,7 +37,7 @@ export async function writeModelsToConfigs({ planModel, buildModel, fastModel, a
|
|
|
29
37
|
const file = path.join(agentsDir, `${name}.md`);
|
|
30
38
|
if (await fse.pathExists(file)) {
|
|
31
39
|
await writeModelToAgent(file, buildModel);
|
|
32
|
-
success(`${name} → ${buildModel}`);
|
|
40
|
+
if (buildModel) success(`${name} → ${buildModel}`);
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -37,31 +45,37 @@ export async function writeModelsToConfigs({ planModel, buildModel, fastModel, a
|
|
|
37
45
|
const file = path.join(agentsDir, `${name}.md`);
|
|
38
46
|
if (await fse.pathExists(file)) {
|
|
39
47
|
await writeModelToAgent(file, fastModel);
|
|
40
|
-
success(`${name} → ${fastModel}`);
|
|
48
|
+
if (fastModel) success(`${name} → ${fastModel}`);
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
const opencodeJsonPath = path.join(cwd, '.opencode', 'opencode.json');
|
|
45
53
|
if (await fse.pathExists(opencodeJsonPath)) {
|
|
46
54
|
const config = await fse.readJson(opencodeJsonPath);
|
|
47
|
-
config.model = buildModel;
|
|
55
|
+
if (buildModel) config.model = buildModel;
|
|
56
|
+
else delete config.model;
|
|
48
57
|
await fse.writeJson(opencodeJsonPath, config, { spaces: 2 });
|
|
49
|
-
success(`default model -> ${buildModel} (written to .opencode/opencode.json)`);
|
|
58
|
+
if (buildModel) success(`default model -> ${buildModel} (written to .opencode/opencode.json)`);
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
const ensembleJsonPath = path.join(cwd, '.opencode', 'ensemble.json');
|
|
53
62
|
if (await fse.pathExists(ensembleJsonPath)) {
|
|
54
63
|
const ensemble = await fse.readJson(ensembleJsonPath);
|
|
55
64
|
delete ensemble.defaultModel;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
const modelsByAgent = { ...ensemble.modelsByAgent }
|
|
66
|
+
if (planModel) modelsByAgent.plan = planModel
|
|
67
|
+
else delete modelsByAgent.plan
|
|
68
|
+
if (buildModel) modelsByAgent.build = buildModel
|
|
69
|
+
else delete modelsByAgent.build
|
|
70
|
+
if (fastModel) modelsByAgent.explore = fastModel
|
|
71
|
+
else delete modelsByAgent.explore
|
|
72
|
+
|
|
73
|
+
if (Object.keys(modelsByAgent).length > 0) ensemble.modelsByAgent = modelsByAgent
|
|
74
|
+
else delete ensemble.modelsByAgent
|
|
75
|
+
|
|
62
76
|
await fse.writeJson(ensembleJsonPath, ensemble, { spaces: 2 });
|
|
63
|
-
success(`plan model -> ${planModel} (written to .opencode/ensemble.json)`);
|
|
64
|
-
success(`build model -> ${buildModel} (written to .opencode/ensemble.json)`);
|
|
65
|
-
success(`fast model -> ${fastModel} (written to .opencode/ensemble.json)`);
|
|
77
|
+
if (planModel) success(`plan model -> ${planModel} (written to .opencode/ensemble.json)`);
|
|
78
|
+
if (buildModel) success(`build model -> ${buildModel} (written to .opencode/ensemble.json)`);
|
|
79
|
+
if (fastModel) success(`fast model -> ${fastModel} (written to .opencode/ensemble.json)`);
|
|
66
80
|
}
|
|
67
81
|
}
|
|
@@ -38,7 +38,7 @@ description: A test agent
|
|
|
38
38
|
expect(updated).toContain('model: anthropic/claude-3-sonnet')
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
it('preserves existing frontmatter fields', async () => {
|
|
41
|
+
it('preserves existing frontmatter fields', async () => {
|
|
42
42
|
const filePath = path.join(tmpDir, 'test-agent.md')
|
|
43
43
|
const original = `---
|
|
44
44
|
name: Test Agent
|
|
@@ -54,10 +54,28 @@ custom_field: custom_value
|
|
|
54
54
|
const updated = fs.readFileSync(filePath, 'utf-8')
|
|
55
55
|
expect(updated).toContain('name: Test Agent')
|
|
56
56
|
expect(updated).toContain('description: A test agent')
|
|
57
|
-
expect(updated).toContain('custom_field: custom_value')
|
|
58
|
-
expect(updated).toContain('model: test/model')
|
|
59
|
-
})
|
|
60
|
-
|
|
57
|
+
expect(updated).toContain('custom_field: custom_value')
|
|
58
|
+
expect(updated).toContain('model: test/model')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('removes model field when no model is selected', async () => {
|
|
62
|
+
const filePath = path.join(tmpDir, 'test-agent.md')
|
|
63
|
+
const original = `---
|
|
64
|
+
name: Test Agent
|
|
65
|
+
model: existing/model
|
|
66
|
+
description: A test agent
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
# Test Agent`
|
|
70
|
+
fs.writeFileSync(filePath, original, 'utf-8')
|
|
71
|
+
|
|
72
|
+
await writeModelToAgent(filePath, null)
|
|
73
|
+
|
|
74
|
+
const updated = fs.readFileSync(filePath, 'utf-8')
|
|
75
|
+
expect(updated).not.toContain('model:')
|
|
76
|
+
expect(updated).toContain('description: A test agent')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
61
79
|
|
|
62
80
|
describe('writeModelsToConfigs()', () => {
|
|
63
81
|
let tmpDir, agentsDir, opencodeJsonPath
|
|
@@ -115,4 +133,27 @@ describe('writeModelsToConfigs()', () => {
|
|
|
115
133
|
|
|
116
134
|
expect(success).toHaveBeenCalled()
|
|
117
135
|
})
|
|
136
|
+
|
|
137
|
+
it('removes model config entries when None is selected', async () => {
|
|
138
|
+
const agentFile = path.join(agentsDir, 'basic-engineer.md')
|
|
139
|
+
const ensembleJsonPath = path.join(tmpDir, '.opencode', 'ensemble.json')
|
|
140
|
+
|
|
141
|
+
fs.writeFileSync(agentFile, '---\nname: Basic\nmodel: existing/model\n---', 'utf-8')
|
|
142
|
+
fs.writeFileSync(opencodeJsonPath, JSON.stringify({ model: 'existing/model', theme: 'dark' }, null, 2), 'utf-8')
|
|
143
|
+
fs.writeFileSync(ensembleJsonPath, JSON.stringify({ modelsByAgent: { plan: 'a', build: 'b', explore: 'c', keep: 'yes' } }, null, 2), 'utf-8')
|
|
144
|
+
|
|
145
|
+
await writeModelsToConfigs({
|
|
146
|
+
planModel: null,
|
|
147
|
+
buildModel: null,
|
|
148
|
+
fastModel: null,
|
|
149
|
+
agentsDir,
|
|
150
|
+
cwd: tmpDir,
|
|
151
|
+
preset: { roles: { build: { agents: ['basic-engineer'] }, fast: { agents: [] } } },
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
expect(fs.readFileSync(agentFile, 'utf-8')).not.toContain('model:')
|
|
155
|
+
expect(JSON.parse(fs.readFileSync(opencodeJsonPath, 'utf-8'))).toEqual({ theme: 'dark' })
|
|
156
|
+
expect(JSON.parse(fs.readFileSync(ensembleJsonPath, 'utf-8'))).toEqual({ modelsByAgent: { keep: 'yes' } })
|
|
157
|
+
expect(success).not.toHaveBeenCalled()
|
|
158
|
+
})
|
|
118
159
|
})
|