opencode-onboard 0.0.1 → 0.1.0
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 +215 -0
- package/content/.agents/agents/.bootstrap/AGENTS.template.md +234 -0
- package/content/.agents/agents/back-engineer.md +74 -0
- package/content/.agents/agents/devops-manager.md +108 -0
- package/content/.agents/agents/front-engineer.md +73 -0
- package/content/.agents/agents/infra-engineer.md +74 -0
- package/content/.agents/agents/quality-engineer.md +74 -0
- package/content/.agents/agents/security-auditor.md +84 -0
- package/content/.agents/skills/browser-automation/SKILL.md +63 -0
- package/content/{.opencode → .agents}/skills/ob-userstory-az/SKILL.md +6 -6
- package/content/{.opencode → .agents}/skills/ob-userstory-gh/SKILL.md +3 -3
- package/content/.opencode/package-lock.json +3 -3
- package/content/AGENTS.md +13 -13
- package/content/DESIGN.md +1 -1
- package/package.json +18 -1
- package/src/index.js +97 -1
- package/src/presets/platforms.json +10 -0
- package/src/steps/__tests__/check-env.test.js +70 -0
- package/src/steps/__tests__/check-platform.test.js +104 -0
- package/src/steps/__tests__/check-rtk.test.js +37 -0
- package/src/steps/__tests__/choose-platform.test.js +38 -0
- package/src/steps/__tests__/clean-ai-files.test.js +76 -0
- package/src/steps/__tests__/copy-content.test.js +62 -0
- package/src/steps/check-env.js +26 -0
- package/src/steps/check-platform.js +80 -0
- package/src/steps/check-rtk.js +20 -0
- package/src/steps/choose-models.js +141 -0
- package/src/steps/choose-platform.js +22 -0
- package/src/steps/choose-skills-provider.js +75 -0
- package/src/steps/clean-ai-files.js +51 -0
- package/src/steps/copy-content.js +21 -0
- package/src/steps/init-openspec.js +22 -0
- package/src/steps/install-browser.js +57 -0
- package/src/utils/__tests__/copy.test.js +110 -0
- package/src/utils/__tests__/exec.test.js +108 -0
- package/src/utils/copy.js +54 -0
- package/src/utils/exec.js +161 -0
- package/src/utils/models-cache.js +101 -0
- package/content/.opencode/agents/.bootstrap/AGENTS.template.md +0 -230
- package/content/.opencode/agents/.bootstrap/CUSTOM-AGENT.template.md +0 -24
- package/content/.opencode/agents/ob-pullrequest-creator-az.md +0 -332
- package/content/.opencode/agents/ob-pullrequest-creator-gh.md +0 -177
- package/content/.opencode/agents/ob-pullrequest-observer-az.md +0 -248
- package/content/.opencode/agents/ob-pullrequest-observer-gh.md +0 -197
- package/content/.opencode/agents/qa.md +0 -137
- package/content/.opencode/commands/.gitkeep +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('../../utils/exec.js', () => ({
|
|
4
|
+
commandExists: vi.fn(),
|
|
5
|
+
header: vi.fn(),
|
|
6
|
+
success: vi.fn(),
|
|
7
|
+
error: vi.fn(),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
// Mock execa used directly in check-env.js
|
|
11
|
+
vi.mock('execa', () => ({ execa: vi.fn() }))
|
|
12
|
+
|
|
13
|
+
import { commandExists, error, success } from '../../utils/exec.js'
|
|
14
|
+
import { checkEnv } from '../check-env.js'
|
|
15
|
+
|
|
16
|
+
describe('checkEnv()', () => {
|
|
17
|
+
const originalVersion = process.version
|
|
18
|
+
const originalExit = process.exit
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
process.exit = vi.fn()
|
|
22
|
+
vi.clearAllMocks()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
Object.defineProperty(process, 'version', { value: originalVersion, writable: true })
|
|
27
|
+
process.exit = originalExit
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('exits with error when Node version is < 18', async () => {
|
|
31
|
+
Object.defineProperty(process, 'version', { value: 'v16.0.0', writable: true })
|
|
32
|
+
commandExists.mockResolvedValue(true)
|
|
33
|
+
|
|
34
|
+
await checkEnv()
|
|
35
|
+
|
|
36
|
+
expect(error).toHaveBeenCalledWith(expect.stringContaining('v16.0.0'))
|
|
37
|
+
expect(process.exit).toHaveBeenCalledWith(1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('succeeds when Node version is >= 18 and pnpm is available', async () => {
|
|
41
|
+
Object.defineProperty(process, 'version', { value: 'v20.0.0', writable: true })
|
|
42
|
+
commandExists.mockImplementation(async (cmd) => cmd === 'pnpm')
|
|
43
|
+
|
|
44
|
+
await checkEnv()
|
|
45
|
+
|
|
46
|
+
expect(process.exit).not.toHaveBeenCalled()
|
|
47
|
+
expect(success).toHaveBeenCalledWith(expect.stringContaining('v20.0.0'))
|
|
48
|
+
expect(success).toHaveBeenCalledWith('pnpm available')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('succeeds when Node version is >= 18 and only npm is available', async () => {
|
|
52
|
+
Object.defineProperty(process, 'version', { value: 'v18.0.0', writable: true })
|
|
53
|
+
commandExists.mockImplementation(async (cmd) => cmd === 'npm')
|
|
54
|
+
|
|
55
|
+
await checkEnv()
|
|
56
|
+
|
|
57
|
+
expect(process.exit).not.toHaveBeenCalled()
|
|
58
|
+
expect(success).toHaveBeenCalledWith('npm available')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('exits when neither npm nor pnpm available', async () => {
|
|
62
|
+
Object.defineProperty(process, 'version', { value: 'v20.0.0', writable: true })
|
|
63
|
+
commandExists.mockResolvedValue(false)
|
|
64
|
+
|
|
65
|
+
await checkEnv()
|
|
66
|
+
|
|
67
|
+
expect(error).toHaveBeenCalledWith(expect.stringContaining('Neither npm nor pnpm'))
|
|
68
|
+
expect(process.exit).toHaveBeenCalledWith(1)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('../../utils/exec.js', () => ({
|
|
4
|
+
commandExists: vi.fn(),
|
|
5
|
+
header: vi.fn(),
|
|
6
|
+
success: vi.fn(),
|
|
7
|
+
warn: vi.fn(),
|
|
8
|
+
info: vi.fn(),
|
|
9
|
+
code: vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
vi.mock('execa', () => ({
|
|
13
|
+
execa: vi.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
import { execa } from 'execa'
|
|
17
|
+
import { commandExists, success, warn } from '../../utils/exec.js'
|
|
18
|
+
import { checkPlatform } from '../check-platform.js'
|
|
19
|
+
|
|
20
|
+
describe('checkPlatform()', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('github path', () => {
|
|
26
|
+
it('prints success when gh is installed and authenticated', async () => {
|
|
27
|
+
commandExists.mockResolvedValue(true)
|
|
28
|
+
execa.mockResolvedValue({ exitCode: 0, stdout: '' })
|
|
29
|
+
|
|
30
|
+
await checkPlatform('github')
|
|
31
|
+
|
|
32
|
+
expect(success).toHaveBeenCalledWith('GitHub CLI (gh) available')
|
|
33
|
+
expect(success).toHaveBeenCalledWith('GitHub CLI authenticated')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('warns when gh is installed but not authenticated', async () => {
|
|
37
|
+
commandExists.mockResolvedValue(true)
|
|
38
|
+
execa.mockResolvedValue({ exitCode: 1, stdout: '' })
|
|
39
|
+
|
|
40
|
+
await checkPlatform('github')
|
|
41
|
+
|
|
42
|
+
expect(success).toHaveBeenCalledWith('GitHub CLI (gh) available')
|
|
43
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('not authenticated'))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('warns when gh is not installed', async () => {
|
|
47
|
+
commandExists.mockResolvedValue(false)
|
|
48
|
+
|
|
49
|
+
await checkPlatform('github')
|
|
50
|
+
|
|
51
|
+
expect(warn).toHaveBeenCalledWith('GitHub CLI (gh) not found.')
|
|
52
|
+
expect(success).not.toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('warns when gh auth status check throws', async () => {
|
|
56
|
+
commandExists.mockResolvedValue(true)
|
|
57
|
+
execa.mockRejectedValue(new Error('spawn error'))
|
|
58
|
+
|
|
59
|
+
await checkPlatform('github')
|
|
60
|
+
|
|
61
|
+
expect(warn).toHaveBeenCalledWith('Could not check gh auth status.')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('azure path', () => {
|
|
66
|
+
it('prints success when az is installed and azure-devops extension present', async () => {
|
|
67
|
+
commandExists.mockResolvedValue(true)
|
|
68
|
+
execa.mockResolvedValue({ exitCode: 0, stdout: 'azure-devops\tsome info' })
|
|
69
|
+
|
|
70
|
+
await checkPlatform('azure')
|
|
71
|
+
|
|
72
|
+
expect(success).toHaveBeenCalledWith('Azure CLI (az) available')
|
|
73
|
+
expect(success).toHaveBeenCalledWith('azure-devops extension installed')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('warns when az is installed but azure-devops extension is missing', async () => {
|
|
77
|
+
commandExists.mockResolvedValue(true)
|
|
78
|
+
execa.mockResolvedValue({ exitCode: 0, stdout: '' })
|
|
79
|
+
|
|
80
|
+
await checkPlatform('azure')
|
|
81
|
+
|
|
82
|
+
expect(success).toHaveBeenCalledWith('Azure CLI (az) available')
|
|
83
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('azure-devops extension not found'))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('warns when az is not installed', async () => {
|
|
87
|
+
commandExists.mockResolvedValue(false)
|
|
88
|
+
|
|
89
|
+
await checkPlatform('azure')
|
|
90
|
+
|
|
91
|
+
expect(warn).toHaveBeenCalledWith('Azure CLI (az) not found.')
|
|
92
|
+
expect(success).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('warns when extension check throws', async () => {
|
|
96
|
+
commandExists.mockResolvedValue(true)
|
|
97
|
+
execa.mockRejectedValue(new Error('spawn error'))
|
|
98
|
+
|
|
99
|
+
await checkPlatform('azure')
|
|
100
|
+
|
|
101
|
+
expect(warn).toHaveBeenCalledWith('Could not check azure-devops extension. Run:')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('../../utils/exec.js', () => ({
|
|
4
|
+
commandExists: vi.fn(),
|
|
5
|
+
header: vi.fn(),
|
|
6
|
+
success: vi.fn(),
|
|
7
|
+
warn: vi.fn(),
|
|
8
|
+
info: vi.fn(),
|
|
9
|
+
code: vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
import { commandExists, success, warn } from '../../utils/exec.js'
|
|
13
|
+
import { checkRtk } from '../check-rtk.js'
|
|
14
|
+
|
|
15
|
+
describe('checkRtk()', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('prints success when rtk is available', async () => {
|
|
21
|
+
commandExists.mockResolvedValue(true)
|
|
22
|
+
|
|
23
|
+
await checkRtk()
|
|
24
|
+
|
|
25
|
+
expect(success).toHaveBeenCalledWith('rtk is available')
|
|
26
|
+
expect(warn).not.toHaveBeenCalled()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('prints warning with install instructions when rtk is not found', async () => {
|
|
30
|
+
commandExists.mockResolvedValue(false)
|
|
31
|
+
|
|
32
|
+
await checkRtk()
|
|
33
|
+
|
|
34
|
+
expect(warn).toHaveBeenCalledWith('rtk not found on PATH.')
|
|
35
|
+
expect(success).not.toHaveBeenCalled()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('../../utils/exec.js', () => ({
|
|
4
|
+
header: vi.fn(),
|
|
5
|
+
success: vi.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
vi.mock('@inquirer/prompts', () => ({
|
|
9
|
+
select: vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
import { select } from '@inquirer/prompts'
|
|
13
|
+
import { success } from '../../utils/exec.js'
|
|
14
|
+
import { choosePlatform } from '../choose-platform.js'
|
|
15
|
+
|
|
16
|
+
describe('choosePlatform()', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns "github" when user selects GitHub', async () => {
|
|
22
|
+
select.mockResolvedValue('github')
|
|
23
|
+
|
|
24
|
+
const result = await choosePlatform()
|
|
25
|
+
|
|
26
|
+
expect(result).toBe('github')
|
|
27
|
+
expect(success).toHaveBeenCalledWith('Platform: GitHub')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns "azure" when user selects Azure DevOps', async () => {
|
|
31
|
+
select.mockResolvedValue('azure')
|
|
32
|
+
|
|
33
|
+
const result = await choosePlatform()
|
|
34
|
+
|
|
35
|
+
expect(result).toBe('azure')
|
|
36
|
+
expect(success).toHaveBeenCalledWith('Platform: Azure DevOps')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
|
|
6
|
+
vi.mock('../../utils/exec.js', () => ({
|
|
7
|
+
header: vi.fn(),
|
|
8
|
+
success: vi.fn(),
|
|
9
|
+
warn: vi.fn(),
|
|
10
|
+
info: vi.fn(),
|
|
11
|
+
prompt: vi.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
import { success, warn } from '../../utils/exec.js'
|
|
15
|
+
|
|
16
|
+
describe('cleanAiFiles()', () => {
|
|
17
|
+
let tmpDir
|
|
18
|
+
let originalCwd
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'ob-clean-test-'))
|
|
22
|
+
originalCwd = process.cwd()
|
|
23
|
+
process.chdir(tmpDir)
|
|
24
|
+
vi.clearAllMocks()
|
|
25
|
+
vi.resetModules()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
process.chdir(originalCwd)
|
|
30
|
+
await fse.remove(tmpDir)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('prints success when no AI files are found', async () => {
|
|
34
|
+
const { cleanAiFiles } = await import('../clean-ai-files.js')
|
|
35
|
+
|
|
36
|
+
// Simulate immediate Enter key
|
|
37
|
+
const stdinPush = () => process.stdin.emit('data', '\n')
|
|
38
|
+
setTimeout(stdinPush, 10)
|
|
39
|
+
|
|
40
|
+
await cleanAiFiles()
|
|
41
|
+
|
|
42
|
+
expect(success).toHaveBeenCalledWith('No existing AI config files found')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('removes found AI files after Enter', async () => {
|
|
46
|
+
await fse.writeFile(path.join(tmpDir, 'AGENTS.md'), '# agents')
|
|
47
|
+
await fse.writeFile(path.join(tmpDir, 'CLAUDE.md'), '# claude')
|
|
48
|
+
|
|
49
|
+
const { cleanAiFiles } = await import('../clean-ai-files.js')
|
|
50
|
+
|
|
51
|
+
setTimeout(() => process.stdin.emit('data', '\n'), 10)
|
|
52
|
+
|
|
53
|
+
await cleanAiFiles()
|
|
54
|
+
|
|
55
|
+
expect(await fse.pathExists(path.join(tmpDir, 'AGENTS.md'))).toBe(false)
|
|
56
|
+
expect(await fse.pathExists(path.join(tmpDir, 'CLAUDE.md'))).toBe(false)
|
|
57
|
+
expect(success).toHaveBeenCalledWith('Removed existing AI config files')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('removes .agents sub-entries but preserves .agents/skills', async () => {
|
|
61
|
+
const agentsDir = path.join(tmpDir, '.agents')
|
|
62
|
+
await fse.ensureDir(path.join(agentsDir, 'agents'))
|
|
63
|
+
await fse.ensureDir(path.join(agentsDir, 'skills', 'my-skill'))
|
|
64
|
+
await fse.writeFile(path.join(agentsDir, 'agents', 'front-engineer.md'), 'agent')
|
|
65
|
+
await fse.writeFile(path.join(agentsDir, 'skills', 'my-skill', 'SKILL.md'), 'skill')
|
|
66
|
+
|
|
67
|
+
const { cleanAiFiles } = await import('../clean-ai-files.js')
|
|
68
|
+
|
|
69
|
+
setTimeout(() => process.stdin.emit('data', '\n'), 10)
|
|
70
|
+
|
|
71
|
+
await cleanAiFiles()
|
|
72
|
+
|
|
73
|
+
expect(await fse.pathExists(path.join(agentsDir, 'agents'))).toBe(false)
|
|
74
|
+
expect(await fse.pathExists(path.join(agentsDir, 'skills', 'my-skill', 'SKILL.md'))).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('../../utils/exec.js', () => ({
|
|
4
|
+
header: vi.fn(),
|
|
5
|
+
success: vi.fn(),
|
|
6
|
+
error: vi.fn(),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
vi.mock('../../utils/copy.js', () => ({
|
|
10
|
+
copyContent: vi.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
import { copyContent } from '../../utils/copy.js'
|
|
14
|
+
import { success, error } from '../../utils/exec.js'
|
|
15
|
+
import { copyContentStep } from '../copy-content.js'
|
|
16
|
+
|
|
17
|
+
describe('copyContentStep()', () => {
|
|
18
|
+
const originalExit = process.exit
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
process.exit = vi.fn()
|
|
22
|
+
vi.clearAllMocks()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
process.exit = originalExit
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('calls copyContent with the correct platform and prints success', async () => {
|
|
30
|
+
copyContent.mockResolvedValue(undefined)
|
|
31
|
+
|
|
32
|
+
await copyContentStep('github')
|
|
33
|
+
|
|
34
|
+
expect(copyContent).toHaveBeenCalledWith(
|
|
35
|
+
expect.stringContaining('content'),
|
|
36
|
+
process.cwd(),
|
|
37
|
+
'github'
|
|
38
|
+
)
|
|
39
|
+
expect(success).toHaveBeenCalledWith('Files copied to project root')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('calls copyContent with azure platform', async () => {
|
|
43
|
+
copyContent.mockResolvedValue(undefined)
|
|
44
|
+
|
|
45
|
+
await copyContentStep('azure')
|
|
46
|
+
|
|
47
|
+
expect(copyContent).toHaveBeenCalledWith(
|
|
48
|
+
expect.stringContaining('content'),
|
|
49
|
+
process.cwd(),
|
|
50
|
+
'azure'
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('calls process.exit(1) when copyContent throws', async () => {
|
|
55
|
+
copyContent.mockRejectedValue(new Error('disk full'))
|
|
56
|
+
|
|
57
|
+
await copyContentStep('github')
|
|
58
|
+
|
|
59
|
+
expect(error).toHaveBeenCalledWith(expect.stringContaining('disk full'))
|
|
60
|
+
expect(process.exit).toHaveBeenCalledWith(1)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { commandExists, error, header, success } from '../utils/exec.js'
|
|
2
|
+
|
|
3
|
+
export async function checkEnv() {
|
|
4
|
+
header('Step 1, Checking environment')
|
|
5
|
+
|
|
6
|
+
// Node version
|
|
7
|
+
const nodeVersion = process.version
|
|
8
|
+
const major = parseInt(nodeVersion.slice(1).split('.')[0], 10)
|
|
9
|
+
if (major < 18) {
|
|
10
|
+
error(`Node.js ${nodeVersion} detected. Version 18+ is required.`)
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
success(`Node.js ${nodeVersion}`)
|
|
14
|
+
|
|
15
|
+
// npm or pnpm
|
|
16
|
+
const hasPnpm = await commandExists('pnpm')
|
|
17
|
+
const hasNpm = await commandExists('npm')
|
|
18
|
+
|
|
19
|
+
if (!hasPnpm && !hasNpm) {
|
|
20
|
+
error('Neither npm nor pnpm found. Please install Node.js from https://nodejs.org')
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (hasPnpm) success('pnpm available')
|
|
25
|
+
else success('npm available')
|
|
26
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { code, commandExists, header, info, success, warn } from '../utils/exec.js'
|
|
3
|
+
|
|
4
|
+
export async function checkPlatform(platform) {
|
|
5
|
+
if (platform === 'azure') {
|
|
6
|
+
await checkAzure()
|
|
7
|
+
} else {
|
|
8
|
+
await checkGithub()
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function checkAzure() {
|
|
13
|
+
header('Step 4, Checking Azure DevOps CLI')
|
|
14
|
+
|
|
15
|
+
// Check az is installed
|
|
16
|
+
const hasAz = await commandExists('az')
|
|
17
|
+
if (!hasAz) {
|
|
18
|
+
warn('Azure CLI (az) not found.')
|
|
19
|
+
info('Install from: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli')
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
success('Azure CLI (az) available')
|
|
23
|
+
|
|
24
|
+
// Check az devops extension
|
|
25
|
+
try {
|
|
26
|
+
const result = await execa('az', ['extension', 'list', '--query', "[?name=='azure-devops']", '-o', 'tsv'], {
|
|
27
|
+
reject: false,
|
|
28
|
+
})
|
|
29
|
+
const hasExtension = result.stdout && result.stdout.includes('azure-devops')
|
|
30
|
+
|
|
31
|
+
if (hasExtension) {
|
|
32
|
+
success('azure-devops extension installed')
|
|
33
|
+
} else {
|
|
34
|
+
warn('azure-devops extension not found. Run:')
|
|
35
|
+
code([
|
|
36
|
+
'az extension add --name azure-devops',
|
|
37
|
+
'az config set extension.dynamic_install_allow_preview=true',
|
|
38
|
+
'az login',
|
|
39
|
+
'az devops login --organization https://dev.azure.com/<your-org>',
|
|
40
|
+
])
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
warn('Could not check azure-devops extension. Run:')
|
|
44
|
+
code([
|
|
45
|
+
'az extension add --name azure-devops',
|
|
46
|
+
'az config set extension.dynamic_install_allow_preview=true',
|
|
47
|
+
'az login',
|
|
48
|
+
'az devops login --organization https://dev.azure.com/<your-org>',
|
|
49
|
+
])
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function checkGithub() {
|
|
54
|
+
header('Step 4, Checking GitHub CLI')
|
|
55
|
+
|
|
56
|
+
const hasGh = await commandExists('gh')
|
|
57
|
+
|
|
58
|
+
if (hasGh) {
|
|
59
|
+
success('GitHub CLI (gh) available')
|
|
60
|
+
|
|
61
|
+
// Check auth status
|
|
62
|
+
try {
|
|
63
|
+
const result = await execa('gh', ['auth', 'status'], { reject: false })
|
|
64
|
+
if (result.exitCode === 0) {
|
|
65
|
+
success('GitHub CLI authenticated')
|
|
66
|
+
} else {
|
|
67
|
+
warn('GitHub CLI not authenticated. Run:')
|
|
68
|
+
code(['gh auth login'])
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
warn('Could not check gh auth status.')
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
warn('GitHub CLI (gh) not found.')
|
|
75
|
+
info('Install from: https://cli.github.com')
|
|
76
|
+
console.log()
|
|
77
|
+
info('After installing, authenticate with:')
|
|
78
|
+
code(['gh auth login'])
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { code, commandExists, header, info, success, warn } from '../utils/exec.js'
|
|
2
|
+
|
|
3
|
+
export async function checkRtk() {
|
|
4
|
+
header('Step 9, Checking rtk')
|
|
5
|
+
|
|
6
|
+
const available = await commandExists('rtk')
|
|
7
|
+
|
|
8
|
+
if (available) {
|
|
9
|
+
success('rtk is available')
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
warn('rtk not found on PATH.')
|
|
14
|
+
console.log()
|
|
15
|
+
info('rtk is required for agents to run CLI commands safely.')
|
|
16
|
+
info('Install it from: https://github.com/rtk-ai/rtk#pre-built-binaries')
|
|
17
|
+
console.log()
|
|
18
|
+
info('After installing, verify with:')
|
|
19
|
+
code(['rtk --version'])
|
|
20
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { search } from '@inquirer/prompts'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { header, info, success, warn } from '../utils/exec.js'
|
|
5
|
+
import { fetchModels } from '../utils/models-cache.js'
|
|
6
|
+
|
|
7
|
+
const COST_TIER = (input) => {
|
|
8
|
+
if (input === undefined || input === null) return ''
|
|
9
|
+
if (input < 1) return ' [$]'
|
|
10
|
+
if (input <= 10) return ' [$$]'
|
|
11
|
+
return ' [$$$]'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Use canonical cost for the tier badge so all providers of the same model
|
|
15
|
+
// show the same tier (e.g. github-copilot $0 subscription shows [$$] not [$])
|
|
16
|
+
const COST_TIER_DISPLAY = (cost, canonicalCost) =>
|
|
17
|
+
COST_TIER(canonicalCost !== undefined ? canonicalCost : cost)
|
|
18
|
+
|
|
19
|
+
function formatPrice(price) {
|
|
20
|
+
if (price === undefined || price === null) return '?'
|
|
21
|
+
if (price === 0) return '$0 (subscription)'
|
|
22
|
+
return `$${price}/M`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildDisplayModels(rawModels) {
|
|
26
|
+
return rawModels.map(m => {
|
|
27
|
+
const priceStr = formatPrice(m.cost)
|
|
28
|
+
const canonicalNote = m.canonicalCost !== undefined
|
|
29
|
+
? ` · official price: ${formatPrice(m.canonicalCost)}/M`
|
|
30
|
+
: ''
|
|
31
|
+
return {
|
|
32
|
+
...m,
|
|
33
|
+
label: `${m.name}${COST_TIER_DISPLAY(m.cost, m.canonicalCost)} — ${m.id}`,
|
|
34
|
+
description: `${priceStr}${canonicalNote} · context: ${m.context ? (m.context / 1000) + 'k' : '?'}`,
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function pickModel(message, models) {
|
|
40
|
+
return await search({
|
|
41
|
+
message,
|
|
42
|
+
source: (input) => {
|
|
43
|
+
const q = (input || '').toLowerCase()
|
|
44
|
+
const filtered = q
|
|
45
|
+
? models.filter(m =>
|
|
46
|
+
m.label.toLowerCase().includes(q) ||
|
|
47
|
+
m.id.toLowerCase().includes(q)
|
|
48
|
+
)
|
|
49
|
+
: models
|
|
50
|
+
return filtered.slice(0, 50).map(m => ({
|
|
51
|
+
name: m.label,
|
|
52
|
+
value: m.id,
|
|
53
|
+
description: m.description,
|
|
54
|
+
}))
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function writeModelToAgent(agentFile, modelId) {
|
|
60
|
+
const content = await fse.readFile(agentFile, 'utf-8')
|
|
61
|
+
const updated = content.replace(
|
|
62
|
+
/^(---\n[\s\S]*?)\n---/m,
|
|
63
|
+
`$1\nmodel: ${modelId}\n---`
|
|
64
|
+
)
|
|
65
|
+
await fse.writeFile(agentFile, updated, 'utf-8')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function chooseModels() {
|
|
69
|
+
header('Step 8, Choose models')
|
|
70
|
+
|
|
71
|
+
info('Fetching available models from models.dev...')
|
|
72
|
+
const { models: rawModels, source } = await fetchModels()
|
|
73
|
+
|
|
74
|
+
if (!rawModels) {
|
|
75
|
+
warn('Could not fetch models (offline and no cache). Skipping model selection.')
|
|
76
|
+
warn('Set models later in .agents/agents/<name>.md and .opencode/opencode.json')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (source === 'stale-cache') {
|
|
81
|
+
warn('Network unavailable — using cached model list (may be outdated).')
|
|
82
|
+
} else if (source === 'cache') {
|
|
83
|
+
info('Using cached model list (refreshes weekly).')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const models = buildDisplayModels(rawModels)
|
|
87
|
+
success(`${models.length} models available`)
|
|
88
|
+
console.log()
|
|
89
|
+
info('Cost indicators: [$] cheap [$$] mid [$$$] expensive')
|
|
90
|
+
info('Type to search. Change selections later in .agents/agents/ and .opencode/opencode.json')
|
|
91
|
+
console.log()
|
|
92
|
+
|
|
93
|
+
// Plan model
|
|
94
|
+
info('PLAN model — used by the main agent for proposals, specs, architecture decisions.')
|
|
95
|
+
info('Pick something capable with strong reasoning.')
|
|
96
|
+
const planModel = await pickModel('Plan model:', models)
|
|
97
|
+
console.log()
|
|
98
|
+
|
|
99
|
+
// Build model
|
|
100
|
+
info('BUILD model — used by front-engineer, back-engineer, infra-engineer, quality-engineer, security-auditor.')
|
|
101
|
+
info('Pick something capable for implementation work.')
|
|
102
|
+
const buildModel = await pickModel('Build model:', models)
|
|
103
|
+
console.log()
|
|
104
|
+
|
|
105
|
+
// Fast model
|
|
106
|
+
info('FAST model — used by devops-manager for reading issues, classifying PR comments.')
|
|
107
|
+
info('Pick something fast and cheap — no heavy reasoning needed.')
|
|
108
|
+
const fastModel = await pickModel('Fast model:', models)
|
|
109
|
+
console.log()
|
|
110
|
+
|
|
111
|
+
// Write build model to builder agents
|
|
112
|
+
const buildAgents = ['front-engineer', 'back-engineer', 'infra-engineer', 'quality-engineer', 'security-auditor']
|
|
113
|
+
const agentsDir = path.join(process.cwd(), '.agents', 'agents')
|
|
114
|
+
for (const name of buildAgents) {
|
|
115
|
+
const file = path.join(agentsDir, `${name}.md`)
|
|
116
|
+
if (await fse.pathExists(file)) {
|
|
117
|
+
await writeModelToAgent(file, buildModel)
|
|
118
|
+
success(`${name} → ${buildModel}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Write fast model to devops-manager
|
|
123
|
+
const devopsFile = path.join(agentsDir, 'devops-manager.md')
|
|
124
|
+
if (await fse.pathExists(devopsFile)) {
|
|
125
|
+
await writeModelToAgent(devopsFile, fastModel)
|
|
126
|
+
success(`devops-manager → ${fastModel}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Write plan model to opencode.json
|
|
130
|
+
const opencodeJsonPath = path.join(process.cwd(), '.opencode', 'opencode.json')
|
|
131
|
+
if (await fse.pathExists(opencodeJsonPath)) {
|
|
132
|
+
const config = await fse.readJson(opencodeJsonPath)
|
|
133
|
+
config.model = planModel
|
|
134
|
+
await fse.writeJson(opencodeJsonPath, config, { spaces: 2 })
|
|
135
|
+
success(`plan model → ${planModel} (written to .opencode/opencode.json)`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log()
|
|
139
|
+
warn('Make sure you have API access to the selected models.')
|
|
140
|
+
warn('Change them anytime in .agents/agents/<name>.md and .opencode/opencode.json')
|
|
141
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { header, success } from '../utils/exec.js'
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const PLATFORMS_PRESET_PATH = path.resolve(__dirname, '../presets/platforms.json')
|
|
9
|
+
|
|
10
|
+
const platformsPreset = await fse.readJson(PLATFORMS_PRESET_PATH)
|
|
11
|
+
|
|
12
|
+
export async function choosePlatform() {
|
|
13
|
+
header('Step 3, Version control platform')
|
|
14
|
+
|
|
15
|
+
const platform = await select({
|
|
16
|
+
message: 'Which platform are you using?',
|
|
17
|
+
choices: platformsPreset.map(p => ({ name: p.name, value: p.value })),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
success(`Platform: ${platform === 'github' ? 'GitHub' : 'Azure DevOps'}`)
|
|
21
|
+
return platform
|
|
22
|
+
}
|