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.
- package/README.md +44 -33
- package/content/{.opencode → .agents}/agents/.bootstrap/AGENTS.template.md +7 -7
- package/content/{.opencode → .agents}/agents/back-engineer.md +18 -17
- package/content/{.opencode → .agents}/agents/devops-manager.md +22 -29
- package/content/{.opencode → .agents}/agents/front-engineer.md +18 -18
- package/content/{.opencode → .agents}/agents/infra-engineer.md +19 -18
- package/content/{.opencode → .agents}/agents/quality-engineer.md +17 -18
- package/content/{.opencode → .agents}/agents/security-auditor.md +19 -20
- package/content/.opencode/package-lock.json +3 -3
- package/content/AGENTS.md +1 -1
- package/package.json +1 -1
- package/src/index.js +105 -67
- package/src/steps/__tests__/clean-ai-files.test.js +44 -30
- package/src/steps/check-platform.js +2 -2
- package/src/steps/check-rtk.js +1 -1
- package/src/steps/choose-models.js +141 -0
- package/src/steps/choose-skills-provider.js +51 -32
- package/src/steps/clean-ai-files.js +9 -9
- package/src/steps/copy-content.js +1 -1
- package/src/steps/install-browser.js +19 -27
- package/src/utils/__tests__/copy.test.js +0 -22
- package/src/utils/__tests__/exec.test.js +6 -4
- package/src/utils/copy.js +1 -1
- package/src/utils/exec.js +161 -84
- package/src/utils/models-cache.js +101 -0
- package/content/.opencode/agents/.bootstrap/CUSTOM-AGENT.template.md +0 -24
- package/content/.opencode/commands/.gitkeep +0 -0
- package/src/presets/skills-providers.json +0 -14
- package/src/steps/__tests__/choose-team.test.js +0 -105
- /package/content/{.opencode → .agents}/skills/browser-automation/SKILL.md +0 -0
- /package/content/{.opencode → .agents}/skills/ob-userstory-az/SKILL.md +0 -0
- /package/content/{.opencode → .agents}/skills/ob-userstory-gh/SKILL.md +0 -0
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { execa } from 'execa'
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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: {
|
|
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()
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|