opencode-onboard 0.0.5 → 0.1.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.
Files changed (32) hide show
  1. package/README.md +44 -33
  2. package/content/{.opencode → .agents}/agents/.bootstrap/AGENTS.template.md +7 -7
  3. package/content/{.opencode → .agents}/agents/back-engineer.md +18 -17
  4. package/content/{.opencode → .agents}/agents/devops-manager.md +22 -29
  5. package/content/{.opencode → .agents}/agents/front-engineer.md +18 -18
  6. package/content/{.opencode → .agents}/agents/infra-engineer.md +19 -18
  7. package/content/{.opencode → .agents}/agents/quality-engineer.md +17 -18
  8. package/content/{.opencode → .agents}/agents/security-auditor.md +19 -20
  9. package/content/.opencode/package-lock.json +3 -3
  10. package/content/AGENTS.md +1 -1
  11. package/package.json +1 -1
  12. package/src/index.js +105 -67
  13. package/src/steps/__tests__/clean-ai-files.test.js +44 -30
  14. package/src/steps/check-platform.js +2 -2
  15. package/src/steps/check-rtk.js +1 -1
  16. package/src/steps/choose-models.js +141 -0
  17. package/src/steps/choose-skills-provider.js +51 -32
  18. package/src/steps/clean-ai-files.js +9 -9
  19. package/src/steps/copy-content.js +1 -1
  20. package/src/steps/install-browser.js +19 -27
  21. package/src/utils/__tests__/copy.test.js +0 -22
  22. package/src/utils/__tests__/exec.test.js +6 -4
  23. package/src/utils/copy.js +1 -1
  24. package/src/utils/exec.js +161 -84
  25. package/src/utils/models-cache.js +101 -0
  26. package/content/.opencode/agents/.bootstrap/CUSTOM-AGENT.template.md +0 -24
  27. package/content/.opencode/commands/.gitkeep +0 -0
  28. package/src/presets/skills-providers.json +0 -14
  29. package/src/steps/__tests__/choose-team.test.js +0 -105
  30. /package/content/{.opencode → .agents}/skills/browser-automation/SKILL.md +0 -0
  31. /package/content/{.opencode → .agents}/skills/ob-userstory-az/SKILL.md +0 -0
  32. /package/content/{.opencode → .agents}/skills/ob-userstory-gh/SKILL.md +0 -0
@@ -1,9 +1,18 @@
1
1
  import { execa } from 'execa'
2
- import { error, header, success, warn } from '../utils/exec.js'
2
+ import { header, info, success, warn, error } from '../utils/exec.js'
3
3
  import os from 'os'
4
4
 
5
+ const AUTO_ANSWERS = [
6
+ { trigger: 'Press Enter when', response: '' },
7
+ { trigger: 'Choose config location', response: '2' },
8
+ { trigger: 'Add plugin automatically?', response: 'y' },
9
+ { trigger: 'Create one?', response: 'y' },
10
+ { trigger: 'Add browser-automation skill', response: 'n' },
11
+ { trigger: 'Check broker', response: 'n' },
12
+ ]
13
+
5
14
  export async function installBrowser() {
6
- header('Step 7, Installing opencode-browser')
15
+ header('Step 10, Installing opencode-browser')
7
16
 
8
17
  try {
9
18
  const child = execa('npx', ['@different-ai/opencode-browser', 'install'], {
@@ -12,37 +21,18 @@ export async function installBrowser() {
12
21
  reject: false,
13
22
  })
14
23
 
15
- const AUTO_ANSWERS = [
16
- { trigger: 'Choose config location', response: '2' }, // global config
17
- { trigger: 'Create one?', response: 'y' },
18
- { trigger: 'Add browser-automation skill', response: 'n' },
19
- { trigger: 'Check broker', response: 'n' },
20
- ]
21
-
22
- let pendingTriggers = [...AUTO_ANSWERS]
23
- let showOutput = true // show output until after step 3 user interaction
24
- let waitingForUser = false
24
+ const pendingTriggers = [...AUTO_ANSWERS]
25
+ let show = false
25
26
 
26
27
  child.stdout.on('data', (chunk) => {
27
28
  const text = chunk.toString()
28
29
 
29
- if (showOutput) {
30
- process.stdout.write(chunk)
31
- }
30
+ // Show only the load/pin instructions, hide everything else
31
+ if (text.includes('To load the extension')) show = true
32
+ if (text.includes('Press Enter when')) show = false
32
33
 
33
- // Step 3 — let user press Enter, then suppress remaining output
34
- if (text.includes('Press Enter when') && !waitingForUser) {
35
- waitingForUser = true
36
- process.stdin.resume()
37
- process.stdin.once('data', () => {
38
- child.stdin.write('\n')
39
- process.stdin.pause()
40
- showOutput = false // suppress steps 4-9 output
41
- })
42
- return
43
- }
34
+ if (show) process.stdout.write(chunk)
44
35
 
45
- // Auto-answer remaining prompts
46
36
  for (let i = 0; i < pendingTriggers.length; i++) {
47
37
  if (text.includes(pendingTriggers[i].trigger)) {
48
38
  child.stdin.write(pendingTriggers[i].response + '\n')
@@ -52,6 +42,8 @@ export async function installBrowser() {
52
42
  }
53
43
  })
54
44
 
45
+ child.stderr.on('data', (chunk) => process.stderr.write(chunk))
46
+
55
47
  const result = await child
56
48
 
57
49
  if (result.exitCode === 0) {
@@ -76,28 +76,6 @@ describe('copy utils', () => {
76
76
  expect(await fse.pathExists(path.join(dest, 'AGENTS.md'))).toBe(true)
77
77
  })
78
78
 
79
- it('excludes azure files when platform is github', async () => {
80
- await fse.ensureDir(path.join(src, 'skills', 'ob-userstory-az'))
81
- await fse.writeFile(path.join(src, 'skills', 'ob-userstory-az', 'SKILL.md'), 'azure skill')
82
- await fse.writeFile(path.join(src, 'agent-az.md'), 'azure agent')
83
-
84
- await copyContent(src, dest, 'github')
85
-
86
- expect(await fse.pathExists(path.join(dest, 'agent-az.md'))).toBe(false)
87
- expect(await fse.pathExists(path.join(dest, 'skills', 'ob-userstory-az', 'SKILL.md'))).toBe(false)
88
- })
89
-
90
- it('excludes github files when platform is azure', async () => {
91
- await fse.ensureDir(path.join(src, 'skills', 'ob-userstory-gh'))
92
- await fse.writeFile(path.join(src, 'skills', 'ob-userstory-gh', 'SKILL.md'), 'gh skill')
93
- await fse.writeFile(path.join(src, 'agent-gh.md'), 'gh agent')
94
-
95
- await copyContent(src, dest, 'azure')
96
-
97
- expect(await fse.pathExists(path.join(dest, 'agent-gh.md'))).toBe(false)
98
- expect(await fse.pathExists(path.join(dest, 'skills', 'ob-userstory-gh', 'SKILL.md'))).toBe(false)
99
- })
100
-
101
79
  it('always excludes .bootstrap folder', async () => {
102
80
  await fse.ensureDir(path.join(src, '.bootstrap'))
103
81
  await fse.writeFile(path.join(src, '.bootstrap', 'secret.md'), 'internal')
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3
3
  // Mock chalk to return the string as-is (no ANSI codes in tests)
4
4
  vi.mock('chalk', () => ({
5
5
  default: {
6
- bold: { cyan: (s) => s },
6
+ bold: { hex: () => (s) => s },
7
7
  green: (s) => s,
8
8
  yellow: (s) => s,
9
9
  red: (s) => s,
@@ -14,7 +14,7 @@ vi.mock('chalk', () => ({
14
14
 
15
15
  // Mock ora spinner
16
16
  vi.mock('ora', () => ({
17
- default: () => ({ start: () => ({ succeed: vi.fn(), fail: vi.fn() }) }),
17
+ default: () => ({ start: () => ({ succeed: vi.fn(), fail: vi.fn(), stop: vi.fn() }) }),
18
18
  }))
19
19
 
20
20
  // Mock execa
@@ -72,9 +72,11 @@ describe('exec utils', () => {
72
72
  })
73
73
 
74
74
  describe('console helpers', () => {
75
- it('header() calls console.log', () => {
75
+ it('header() clears screen and writes output', () => {
76
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => {})
77
+ vi.spyOn(console, 'clear').mockImplementation(() => {})
76
78
  header('Test Header')
77
- expect(console.log).toHaveBeenCalled()
79
+ expect(process.stdout.write).toHaveBeenCalled()
78
80
  })
79
81
 
80
82
  it('success() calls console.log with text', () => {
package/src/utils/copy.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fse from 'fs-extra'
2
2
  import path from 'path'
3
3
 
4
- // Folders never copied (internal bootstrap tooling)
4
+ // Folders never copied (skills handled separately by chooseSkillsProvider, .bootstrap is internal tooling)
5
5
  const ALWAYS_EXCLUDE = ['.bootstrap', 'skills']
6
6
 
7
7
  /**
package/src/utils/exec.js CHANGED
@@ -1,84 +1,161 @@
1
- import chalk from 'chalk'
2
- import { execa } from 'execa'
3
- import ora from 'ora'
4
-
5
- /**
6
- * Run a shell command with a spinner.
7
- * Returns { success, stdout, stderr }
8
- */
9
- export async function run(command, args = [], { label, cwd = process.cwd() } = {}) {
10
- const spinner = ora(label ?? `${command} ${args.join(' ')}`).start()
11
- try {
12
- const result = await execa(command, args, { cwd, reject: false })
13
- if (result.exitCode === 0) {
14
- spinner.succeed()
15
- } else {
16
- spinner.fail()
17
- }
18
- return { success: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr }
19
- } catch (err) {
20
- spinner.fail()
21
- return { success: false, stdout: '', stderr: err.message }
22
- }
23
- }
24
-
25
- /**
26
- * Check if a command is available on PATH.
27
- * Returns true/false.
28
- */
29
- export async function commandExists(command) {
30
- try {
31
- const result = await execa(command, ['--version'], { reject: false })
32
- return result.exitCode === 0
33
- } catch {
34
- return false
35
- }
36
- }
37
-
38
- /**
39
- * Print a section header.
40
- */
41
- export function header(text) {
42
- console.log()
43
- console.log(chalk.bold.cyan(`━━ ${text}`))
44
- console.log()
45
- }
46
-
47
- /**
48
- * Print a success line.
49
- */
50
- export function success(text) {
51
- console.log(chalk.green('✓ ') + text)
52
- }
53
-
54
- /**
55
- * Print a warning line.
56
- */
57
- export function warn(text) {
58
- console.log(chalk.yellow('⚠ ') + text)
59
- }
60
-
61
- /**
62
- * Print an error line.
63
- */
64
- export function error(text) {
65
- console.log(chalk.red('✗ ') + text)
66
- }
67
-
68
- /**
69
- * Print an info line.
70
- */
71
- export function info(text) {
72
- console.log(chalk.dim(' ' + text))
73
- }
74
-
75
- /**
76
- * Print a code block.
77
- */
78
- export function code(lines) {
79
- console.log()
80
- for (const line of lines) {
81
- console.log(chalk.bgGray.white(' ' + line + ' '))
82
- }
83
- console.log()
84
- }
1
+ import chalk from 'chalk'
2
+ import { execa } from 'execa'
3
+ import ora from 'ora'
4
+
5
+ // ── Screen / step state ──────────────────────────────────────────────────────
6
+
7
+ const previousSteps = [] // up to 2 completed steps, each is an array of lines
8
+ let currentStepLines = [] // lines accumulated in the current step
9
+ let stepSpinner = null // ora spinner shown while step is working
10
+
11
+ function appendLine(line) {
12
+ currentStepLines.push(line)
13
+ }
14
+
15
+ function stopSpinner() {
16
+ if (stepSpinner) {
17
+ stepSpinner.stop()
18
+ stepSpinner = null
19
+ }
20
+ }
21
+
22
+ function redraw() {
23
+ if (process.stdout.isTTY) console.clear()
24
+
25
+ // Show up to 2 previous steps dimmed
26
+ for (const stepLines of previousSteps) {
27
+ for (const line of stepLines) {
28
+ process.stdout.write(chalk.dim(line) + '\n')
29
+ }
30
+ process.stdout.write('\n')
31
+ }
32
+
33
+ // Current step output
34
+ for (const line of currentStepLines) {
35
+ process.stdout.write(line + '\n')
36
+ }
37
+ }
38
+
39
+ // ── Public API ───────────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Run a shell command with a spinner.
43
+ * Returns { success, stdout, stderr }
44
+ */
45
+ export async function run(command, args = [], { label, cwd = process.cwd() } = {}) {
46
+ const spinner = ora(label ?? `${command} ${args.join(' ')}`).start()
47
+ try {
48
+ const result = await execa(command, args, { cwd, reject: false })
49
+ if (result.exitCode === 0) {
50
+ spinner.succeed()
51
+ } else {
52
+ spinner.fail()
53
+ }
54
+ return { success: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr }
55
+ } catch (err) {
56
+ spinner.fail()
57
+ return { success: false, stdout: '', stderr: err.message }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Check if a command is available on PATH.
63
+ * Returns true/false.
64
+ */
65
+ export async function commandExists(command) {
66
+ try {
67
+ const result = await execa(command, ['--version'], { reject: false })
68
+ return result.exitCode === 0
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Print a section header, clears screen, shows previous step dimmed, starts new step.
76
+ */
77
+ export function header(text) {
78
+ // Rotate buffers, keep last 2 completed steps
79
+ previousSteps.push(currentStepLines)
80
+ if (previousSteps.length > 2) previousSteps.shift()
81
+ currentStepLines = []
82
+
83
+ const line1 = ''
84
+ const line2 = chalk.bold.hex('#fe3d57')(`━━ ${text}`)
85
+ const line3 = ''
86
+
87
+ appendLine(line1)
88
+ appendLine(line2)
89
+ appendLine(line3)
90
+
91
+ redraw()
92
+
93
+ // Start a spinner while the step is working
94
+ stepSpinner = ora({ text: chalk.dim('working...'), color: 'red' }).start()
95
+ }
96
+
97
+ /**
98
+ * Print a success line.
99
+ */
100
+ export function success(text) {
101
+ stopSpinner()
102
+ const line = chalk.green('✓ ') + text
103
+ appendLine(line)
104
+ console.log(line)
105
+ }
106
+
107
+ /**
108
+ * Print a warning line.
109
+ */
110
+ export function warn(text) {
111
+ stopSpinner()
112
+ const line = chalk.yellow('⚠ ') + text
113
+ appendLine(line)
114
+ console.log(line)
115
+ }
116
+
117
+ /**
118
+ * Print an error line.
119
+ */
120
+ export function error(text) {
121
+ stopSpinner()
122
+ const line = chalk.red('✗ ') + text
123
+ appendLine(line)
124
+ console.log(line)
125
+ }
126
+
127
+ /**
128
+ * Print an info line.
129
+ */
130
+ export function info(text) {
131
+ stopSpinner()
132
+ const line = chalk.dim(' ' + text)
133
+ appendLine(line)
134
+ console.log(line)
135
+ }
136
+
137
+ /**
138
+ * Print an action prompt line (white bold, requires user interaction).
139
+ */
140
+ export function prompt(text) {
141
+ stopSpinner()
142
+ const line = chalk.bold(' ' + text)
143
+ appendLine(line)
144
+ console.log(line)
145
+ }
146
+
147
+ /**
148
+ * Print a code block.
149
+ */
150
+ export function code(lines) {
151
+ stopSpinner()
152
+ appendLine('')
153
+ console.log()
154
+ for (const line of lines) {
155
+ const formatted = chalk.bgGray.white(' ' + line + ' ')
156
+ appendLine(formatted)
157
+ console.log(formatted)
158
+ }
159
+ appendLine('')
160
+ console.log()
161
+ }
@@ -0,0 +1,101 @@
1
+ import fse from 'fs-extra'
2
+ import os from 'os'
3
+ import path from 'path'
4
+
5
+ const CACHE_DIR = path.join(os.homedir(), '.config', 'opencode-onboard')
6
+ const CACHE_FILE = path.join(CACHE_DIR, 'models-cache.json')
7
+ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
8
+ const MODELS_URL = 'https://models.dev/api.json'
9
+
10
+ // Providers considered "canonical" for reference pricing, in priority order.
11
+ // When a model's own provider has no cost (e.g. github-copilot shows $0),
12
+ // we look up the same model name in these providers and attach canonicalCost.
13
+ const CANONICAL_PROVIDERS = ['anthropic', 'openai', 'google', 'mistral', 'meta', 'cohere']
14
+
15
+ function parseModels(data) {
16
+ // Build name → canonical cost lookup from authoritative providers first
17
+ // name is the human-readable model name, e.g. "Claude Opus 4.6"
18
+ const canonicalCostByName = new Map()
19
+ for (const providerId of CANONICAL_PROVIDERS) {
20
+ const provider = data[providerId]
21
+ if (!provider?.models) continue
22
+ for (const model of Object.values(provider.models)) {
23
+ if (!model.tool_call) continue
24
+ const name = model.name
25
+ if (name && model.cost?.input !== undefined && !canonicalCostByName.has(name)) {
26
+ canonicalCostByName.set(name, model.cost.input)
27
+ }
28
+ }
29
+ }
30
+
31
+ const models = []
32
+ for (const [providerId, provider] of Object.entries(data)) {
33
+ if (!provider.models) continue
34
+ for (const [modelId, model] of Object.entries(provider.models)) {
35
+ if (!model.tool_call) continue
36
+ const name = model.name || modelId
37
+ const cost = model.cost?.input
38
+ const canonicalCost = canonicalCostByName.get(name)
39
+ models.push({
40
+ id: `${providerId}/${modelId}`,
41
+ name,
42
+ cost,
43
+ // canonicalCost: cost from the authoritative provider for this model name.
44
+ // Defined when cost !== canonicalCost (different provider, reseller, or $0 subscription).
45
+ canonicalCost: canonicalCost !== undefined && canonicalCost !== cost ? canonicalCost : undefined,
46
+ context: model.limit?.context,
47
+ })
48
+ }
49
+ }
50
+ models.sort((a, b) => (a.cost ?? Infinity) - (b.cost ?? Infinity))
51
+ return models
52
+ }
53
+
54
+ async function loadCache() {
55
+ try {
56
+ if (!await fse.pathExists(CACHE_FILE)) return null
57
+ const cache = await fse.readJson(CACHE_FILE)
58
+ if (!cache.timestamp || !cache.models) return null
59
+ const age = Date.now() - cache.timestamp
60
+ if (age > CACHE_TTL_MS) return null // expired
61
+ return cache.models
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ async function saveCache(models) {
68
+ try {
69
+ await fse.ensureDir(CACHE_DIR)
70
+ await fse.writeJson(CACHE_FILE, { timestamp: Date.now(), models })
71
+ } catch {
72
+ // cache write failure is non-fatal
73
+ }
74
+ }
75
+
76
+ export async function fetchModels() {
77
+ // 1. Try cache first (fresh)
78
+ const cached = await loadCache()
79
+ if (cached) return { models: cached, source: 'cache' }
80
+
81
+ // 2. Try network
82
+ try {
83
+ const res = await fetch(MODELS_URL, { signal: AbortSignal.timeout(8000) })
84
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
85
+ const data = await res.json()
86
+ const models = parseModels(data)
87
+ await saveCache(models)
88
+ return { models, source: 'network' }
89
+ } catch {
90
+ // 3. Network failed, fall back to stale cache if available
91
+ try {
92
+ if (await fse.pathExists(CACHE_FILE)) {
93
+ const cache = await fse.readJson(CACHE_FILE)
94
+ if (cache.models?.length) return { models: cache.models, source: 'stale-cache' }
95
+ }
96
+ } catch {
97
+ // ignore
98
+ }
99
+ return { models: null, source: 'unavailable' }
100
+ }
101
+ }
@@ -1,24 +0,0 @@
1
- # Backend Agent
2
-
3
- > {{description_short}} - spawned by orchestrator via opencode-ensemble
4
-
5
- ```
6
- name: {{name}}
7
- mode: subagent
8
- model: {{build|explore}}
9
- description: |
10
- {{description_long}}
11
- tools:
12
- read: {{true|false}}
13
- write: {{true|false}}
14
- execute: {{true|false}}
15
- network: {{true|false}}
16
- ```
17
-
18
- ## RTK - MANDATORY
19
-
20
- Use `rtk` for ALL CLI commands:
21
- {{rtk_commands}}
22
-
23
- {{rest_of_content}}
24
-
File without changes
@@ -1,14 +0,0 @@
1
- [
2
- {
3
- "label": "ob-skills (default, ships with opencode-onboard)",
4
- "value": "ob-skills",
5
- "package": "opencode-onboard",
6
- "description": "Default skill pack: GitHub and Azure DevOps user stories, pull requests, OpenSpec workflows."
7
- },
8
- {
9
- "label": "None, I will add skills manually",
10
- "value": "none",
11
- "package": null,
12
- "description": "Skip skill installation. Add skills to .opencode/skills/ manually."
13
- }
14
- ]
@@ -1,105 +0,0 @@
1
- import fse from 'fs-extra'
2
- import os from 'os'
3
- import path from 'path'
4
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
-
6
- vi.mock('../../utils/exec.js', () => ({
7
- header: vi.fn(),
8
- success: vi.fn(),
9
- info: vi.fn(),
10
- }))
11
-
12
- vi.mock('@inquirer/prompts', () => ({
13
- checkbox: vi.fn(),
14
- input: vi.fn(),
15
- }))
16
-
17
- import { checkbox, input } from '@inquirer/prompts'
18
- import { info } from '../../utils/exec.js'
19
-
20
- describe('chooseTeam()', () => {
21
- let tmpDir
22
- let originalCwd
23
-
24
- beforeEach(async () => {
25
- tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'ob-team-test-'))
26
- originalCwd = process.cwd()
27
- process.chdir(tmpDir)
28
- vi.clearAllMocks()
29
- })
30
-
31
- afterEach(async () => {
32
- process.chdir(originalCwd)
33
- await fse.remove(tmpDir)
34
- vi.resetModules()
35
- })
36
-
37
- it('returns empty array and skips when no agents selected', async () => {
38
- checkbox.mockResolvedValue([])
39
- // single empty input to exit the custom loop
40
- input.mockResolvedValue('')
41
-
42
- // Dynamic import so process.cwd() is captured at call time
43
- const { chooseTeam } = await import('../choose-team.js')
44
- const result = await chooseTeam()
45
-
46
- expect(result).toEqual([])
47
- expect(info).toHaveBeenCalledWith('No agents selected, skipping team setup.')
48
- })
49
-
50
- it('creates agent files for selected preset agents', async () => {
51
- checkbox.mockResolvedValue(['frontend', 'backend'])
52
- input.mockResolvedValue('') // no custom agents
53
-
54
- const { chooseTeam } = await import('../choose-team.js')
55
- const result = await chooseTeam()
56
-
57
- expect(result).toEqual(['frontend', 'backend'])
58
-
59
- const frontendPath = path.join(tmpDir, '.opencode', 'agents', 'frontend.md')
60
- const backendPath = path.join(tmpDir, '.opencode', 'agents', 'backend.md')
61
- expect(await fse.pathExists(frontendPath)).toBe(true)
62
- expect(await fse.pathExists(backendPath)).toBe(true)
63
- })
64
-
65
- it('creates agent file for custom agent name', async () => {
66
- checkbox.mockResolvedValue([])
67
- input.mockResolvedValueOnce('devops').mockResolvedValueOnce('') // one custom, then stop
68
-
69
- const { chooseTeam } = await import('../choose-team.js')
70
- const result = await chooseTeam()
71
-
72
- expect(result).toContain('devops')
73
- const devopsPath = path.join(tmpDir, '.opencode', 'agents', 'devops.md')
74
- expect(await fse.pathExists(devopsPath)).toBe(true)
75
- })
76
-
77
- it('normalises custom agent name (lowercase, spaces to dashes)', async () => {
78
- checkbox.mockResolvedValue([])
79
- input.mockResolvedValueOnce('My Agent').mockResolvedValueOnce('')
80
-
81
- const { chooseTeam } = await import('../choose-team.js')
82
- const result = await chooseTeam()
83
-
84
- expect(result).toContain('my-agent')
85
- const agentPath = path.join(tmpDir, '.opencode', 'agents', 'my-agent.md')
86
- expect(await fse.pathExists(agentPath)).toBe(true)
87
- })
88
-
89
- it('skips agent file creation if it already exists', async () => {
90
- checkbox.mockResolvedValue(['frontend'])
91
- input.mockResolvedValue('')
92
-
93
- const agentsDir = path.join(tmpDir, '.opencode', 'agents')
94
- await fse.ensureDir(agentsDir)
95
- await fse.writeFile(path.join(agentsDir, 'frontend.md'), 'existing content')
96
-
97
- const { chooseTeam } = await import('../choose-team.js')
98
- await chooseTeam()
99
-
100
- // File should still have original content (not overwritten)
101
- const content = await fse.readFile(path.join(agentsDir, 'frontend.md'), 'utf-8')
102
- expect(content).toBe('existing content')
103
- expect(info).toHaveBeenCalledWith('frontend.md already exists, skipping')
104
- })
105
- })