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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-onboard",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Prepare any codebase for AI agent workflows using OpenCode, OpenSpec, and ensemble orchestration.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -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
- const payload = {
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
- plan: data.planModel,
47
- build: data.buildModel,
48
- fast: data.fastModel,
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 pickModel(message, models) {
42
- return search({
43
- message,
44
- source: (input) => {
45
- const q = (input || '').toLowerCase();
46
- const filtered = q
47
- ? models.filter(m =>
48
- m.label.toLowerCase().includes(q) ||
49
- m.id.toLowerCase().includes(q)
50
- )
51
- : models;
52
- return filtered.slice(0, 50).map(m => ({
53
- name: m.label,
54
- value: m.id,
55
- description: m.description,
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.replace(
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
- ensemble.modelsByAgent = {
57
- ...ensemble.modelsByAgent,
58
- plan: planModel,
59
- build: buildModel,
60
- explore: fastModel,
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
  })