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.
Files changed (46) hide show
  1. package/README.md +215 -0
  2. package/content/.agents/agents/.bootstrap/AGENTS.template.md +234 -0
  3. package/content/.agents/agents/back-engineer.md +74 -0
  4. package/content/.agents/agents/devops-manager.md +108 -0
  5. package/content/.agents/agents/front-engineer.md +73 -0
  6. package/content/.agents/agents/infra-engineer.md +74 -0
  7. package/content/.agents/agents/quality-engineer.md +74 -0
  8. package/content/.agents/agents/security-auditor.md +84 -0
  9. package/content/.agents/skills/browser-automation/SKILL.md +63 -0
  10. package/content/{.opencode → .agents}/skills/ob-userstory-az/SKILL.md +6 -6
  11. package/content/{.opencode → .agents}/skills/ob-userstory-gh/SKILL.md +3 -3
  12. package/content/.opencode/package-lock.json +3 -3
  13. package/content/AGENTS.md +13 -13
  14. package/content/DESIGN.md +1 -1
  15. package/package.json +18 -1
  16. package/src/index.js +97 -1
  17. package/src/presets/platforms.json +10 -0
  18. package/src/steps/__tests__/check-env.test.js +70 -0
  19. package/src/steps/__tests__/check-platform.test.js +104 -0
  20. package/src/steps/__tests__/check-rtk.test.js +37 -0
  21. package/src/steps/__tests__/choose-platform.test.js +38 -0
  22. package/src/steps/__tests__/clean-ai-files.test.js +76 -0
  23. package/src/steps/__tests__/copy-content.test.js +62 -0
  24. package/src/steps/check-env.js +26 -0
  25. package/src/steps/check-platform.js +80 -0
  26. package/src/steps/check-rtk.js +20 -0
  27. package/src/steps/choose-models.js +141 -0
  28. package/src/steps/choose-platform.js +22 -0
  29. package/src/steps/choose-skills-provider.js +75 -0
  30. package/src/steps/clean-ai-files.js +51 -0
  31. package/src/steps/copy-content.js +21 -0
  32. package/src/steps/init-openspec.js +22 -0
  33. package/src/steps/install-browser.js +57 -0
  34. package/src/utils/__tests__/copy.test.js +110 -0
  35. package/src/utils/__tests__/exec.test.js +108 -0
  36. package/src/utils/copy.js +54 -0
  37. package/src/utils/exec.js +161 -0
  38. package/src/utils/models-cache.js +101 -0
  39. package/content/.opencode/agents/.bootstrap/AGENTS.template.md +0 -230
  40. package/content/.opencode/agents/.bootstrap/CUSTOM-AGENT.template.md +0 -24
  41. package/content/.opencode/agents/ob-pullrequest-creator-az.md +0 -332
  42. package/content/.opencode/agents/ob-pullrequest-creator-gh.md +0 -177
  43. package/content/.opencode/agents/ob-pullrequest-observer-az.md +0 -248
  44. package/content/.opencode/agents/ob-pullrequest-observer-gh.md +0 -197
  45. package/content/.opencode/agents/qa.md +0 -137
  46. package/content/.opencode/commands/.gitkeep +0 -0
@@ -0,0 +1,75 @@
1
+ import { select } from '@inquirer/prompts'
2
+ import fse from 'fs-extra'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+ import { execa } from 'execa'
6
+ import { header, info, success, warn } from '../utils/exec.js'
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../../content/.agents/skills')
10
+
11
+ async function installObSkills() {
12
+ const destSkillsDir = path.join(process.cwd(), '.agents', 'skills')
13
+ await fse.ensureDir(destSkillsDir)
14
+
15
+ const skills = await fse.readdir(CONTENT_SKILLS_DIR)
16
+ for (const skill of skills) {
17
+ const src = path.join(CONTENT_SKILLS_DIR, skill)
18
+ const dest = path.join(destSkillsDir, skill)
19
+ const stat = await fse.stat(src)
20
+ if (!stat.isDirectory()) continue
21
+ if (await fse.pathExists(dest)) {
22
+ info(`${skill} already exists, skipping`)
23
+ continue
24
+ }
25
+ await fse.copy(src, dest)
26
+ success(`Installed skill: ${skill}`)
27
+ }
28
+ }
29
+
30
+ export async function chooseSkillsProvider() {
31
+ header('Step 7, Installing skills')
32
+
33
+ // ob-skills are always installed — mandatory
34
+ info('Installing built-in ob-skills...')
35
+ await installObSkills()
36
+ console.log()
37
+
38
+ info('Skills provide platform and tech-specific knowledge to your agents.')
39
+ info('Agents detect and load skills automatically — you never need to specify them.')
40
+ info('You can add more skills on top of the built-in ones.')
41
+ console.log()
42
+
43
+ const selected = await select({
44
+ message: 'Add additional skills from:',
45
+ choices: [
46
+ {
47
+ name: 'npx skills (vercel-labs/skills)',
48
+ value: 'npx-skills',
49
+ description: 'Install skills from the vercel-labs community skills registry',
50
+ },
51
+ {
52
+ name: 'None — built-in skills are enough',
53
+ value: 'none',
54
+ },
55
+ ],
56
+ })
57
+
58
+ if (selected === 'none') {
59
+ return
60
+ }
61
+
62
+ if (selected === 'npx-skills') {
63
+ info('Running npx skills...')
64
+ console.log()
65
+ try {
66
+ await execa('npx', ['skills'], {
67
+ cwd: process.cwd(),
68
+ stdio: 'inherit',
69
+ reject: false,
70
+ })
71
+ } catch (err) {
72
+ warn(`npx skills failed: ${err.message}`)
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,51 @@
1
+ import fse from 'fs-extra'
2
+ import path from 'path'
3
+ import { findAiFiles } from '../utils/copy.js'
4
+ import { header, info, prompt, success, warn } from '../utils/exec.js'
5
+
6
+ export async function cleanAiFiles() {
7
+ header('Step 2, Existing AI config files')
8
+
9
+ const cwd = process.cwd()
10
+ const found = await findAiFiles(cwd)
11
+
12
+ // Also find .agents contents except skills/ (preserve user skills)
13
+ const agentsDir = path.join(cwd, '.agents')
14
+ const agentsEntries = []
15
+ if (await fse.pathExists(agentsDir)) {
16
+ const entries = await fse.readdir(agentsDir)
17
+ for (const entry of entries) {
18
+ if (entry !== 'skills') {
19
+ agentsEntries.push(path.join(agentsDir, entry))
20
+ }
21
+ }
22
+ }
23
+
24
+ const allFiles = [...found, ...agentsEntries]
25
+
26
+ if (allFiles.length === 0) {
27
+ success('No existing AI config files found')
28
+ return
29
+ }
30
+
31
+ warn('Found the following AI config files:')
32
+ for (const f of allFiles) {
33
+ info(f.replace(cwd, '.'))
34
+ }
35
+ console.log()
36
+ prompt('Press Enter to remove them all (your .agents/skills/ will be kept)')
37
+ console.log()
38
+
39
+ await new Promise(resolve => {
40
+ process.stdin.resume()
41
+ process.stdin.once('data', () => {
42
+ process.stdin.pause()
43
+ resolve()
44
+ })
45
+ })
46
+
47
+ for (const f of allFiles) {
48
+ await fse.remove(f)
49
+ }
50
+ success('Removed existing AI config files')
51
+ }
@@ -0,0 +1,21 @@
1
+ import path from 'path'
2
+ import { fileURLToPath } from 'url'
3
+ import { copyContent } from '../utils/copy.js'
4
+ import { error, header, success } from '../utils/exec.js'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
+ const CONTENT_DIR = path.resolve(__dirname, '../../content')
8
+
9
+ export async function copyContentStep(platform) {
10
+ header('Step 5, Copying opencode-onboard files')
11
+
12
+ const dest = process.cwd()
13
+
14
+ try {
15
+ await copyContent(CONTENT_DIR, dest, platform)
16
+ success('Files copied to project root')
17
+ } catch (err) {
18
+ error(`Failed to copy content: ${err.message}`)
19
+ process.exit(1)
20
+ }
21
+ }
@@ -0,0 +1,22 @@
1
+ import { execa } from 'execa'
2
+ import { error, header, success, warn } from '../utils/exec.js'
3
+
4
+ export async function initOpenspec() {
5
+ header('Step 6, Initializing OpenSpec')
6
+
7
+ try {
8
+ const result = await execa('npx', ['@fission-ai/openspec', 'init', '--tools', 'opencode', '--force'], {
9
+ cwd: process.cwd(),
10
+ stdio: 'pipe',
11
+ reject: false,
12
+ })
13
+
14
+ if (result.exitCode === 0) {
15
+ success('OpenSpec initialized')
16
+ } else {
17
+ warn('OpenSpec init exited with non-zero code, check output above')
18
+ }
19
+ } catch (err) {
20
+ error(`Failed to run openspec init: ${err.message}`)
21
+ }
22
+ }
@@ -0,0 +1,57 @@
1
+ import { execa } from 'execa'
2
+ import { header, info, success, warn, error } from '../utils/exec.js'
3
+ import os from 'os'
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
+
14
+ export async function installBrowser() {
15
+ header('Step 10, Installing opencode-browser')
16
+
17
+ try {
18
+ const child = execa('npx', ['@different-ai/opencode-browser', 'install'], {
19
+ cwd: os.homedir(),
20
+ stdio: ['pipe', 'pipe', 'pipe'],
21
+ reject: false,
22
+ })
23
+
24
+ const pendingTriggers = [...AUTO_ANSWERS]
25
+ let show = false
26
+
27
+ child.stdout.on('data', (chunk) => {
28
+ const text = chunk.toString()
29
+
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
33
+
34
+ if (show) process.stdout.write(chunk)
35
+
36
+ for (let i = 0; i < pendingTriggers.length; i++) {
37
+ if (text.includes(pendingTriggers[i].trigger)) {
38
+ child.stdin.write(pendingTriggers[i].response + '\n')
39
+ pendingTriggers.splice(i, 1)
40
+ break
41
+ }
42
+ }
43
+ })
44
+
45
+ child.stderr.on('data', (chunk) => process.stderr.write(chunk))
46
+
47
+ const result = await child
48
+
49
+ if (result.exitCode === 0) {
50
+ success('opencode-browser installed')
51
+ } else {
52
+ warn('opencode-browser install exited with non-zero code')
53
+ }
54
+ } catch (err) {
55
+ error(`Failed to install opencode-browser: ${err.message}`)
56
+ }
57
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import path from 'path'
3
+ import os from 'os'
4
+ import fse from 'fs-extra'
5
+
6
+ // Use real fs-extra for file system tests (temp dirs)
7
+ import { copyContent, findAiFiles } from '../copy.js'
8
+
9
+ const tmpDir = () => fse.mkdtempSync(path.join(os.tmpdir(), 'ob-test-'))
10
+
11
+ describe('copy utils', () => {
12
+ describe('findAiFiles()', () => {
13
+ let dir
14
+
15
+ beforeEach(() => {
16
+ dir = tmpDir()
17
+ })
18
+
19
+ afterEach(async () => {
20
+ await fse.remove(dir)
21
+ })
22
+
23
+ it('returns empty array when no AI files exist', async () => {
24
+ const found = await findAiFiles(dir)
25
+ expect(found).toEqual([])
26
+ })
27
+
28
+ it('detects AGENTS.md', async () => {
29
+ await fse.writeFile(path.join(dir, 'AGENTS.md'), '# agents')
30
+ const found = await findAiFiles(dir)
31
+ expect(found).toHaveLength(1)
32
+ expect(found[0]).toContain('AGENTS.md')
33
+ })
34
+
35
+ it('detects CLAUDE.md', async () => {
36
+ await fse.writeFile(path.join(dir, 'CLAUDE.md'), '# claude')
37
+ const found = await findAiFiles(dir)
38
+ expect(found).toHaveLength(1)
39
+ expect(found[0]).toContain('CLAUDE.md')
40
+ })
41
+
42
+ it('detects multiple AI files at once', async () => {
43
+ await fse.writeFile(path.join(dir, 'AGENTS.md'), '')
44
+ await fse.writeFile(path.join(dir, '.cursorrules'), '')
45
+ await fse.writeFile(path.join(dir, '.clinerules'), '')
46
+ const found = await findAiFiles(dir)
47
+ expect(found).toHaveLength(3)
48
+ })
49
+
50
+ it('detects nested copilot-instructions.md', async () => {
51
+ const ghDir = path.join(dir, '.github')
52
+ await fse.ensureDir(ghDir)
53
+ await fse.writeFile(path.join(ghDir, 'copilot-instructions.md'), '')
54
+ const found = await findAiFiles(dir)
55
+ expect(found).toHaveLength(1)
56
+ expect(found[0]).toContain('copilot-instructions.md')
57
+ })
58
+ })
59
+
60
+ describe('copyContent()', () => {
61
+ let src, dest
62
+
63
+ beforeEach(async () => {
64
+ src = tmpDir()
65
+ dest = tmpDir()
66
+ })
67
+
68
+ afterEach(async () => {
69
+ await fse.remove(src)
70
+ await fse.remove(dest)
71
+ })
72
+
73
+ it('copies files that match neither platform exclusion', async () => {
74
+ await fse.writeFile(path.join(src, 'AGENTS.md'), '# agents')
75
+ await copyContent(src, dest, 'github')
76
+ expect(await fse.pathExists(path.join(dest, 'AGENTS.md'))).toBe(true)
77
+ })
78
+
79
+ it('always excludes .bootstrap folder', async () => {
80
+ await fse.ensureDir(path.join(src, '.bootstrap'))
81
+ await fse.writeFile(path.join(src, '.bootstrap', 'secret.md'), 'internal')
82
+
83
+ await copyContent(src, dest, 'github')
84
+
85
+ expect(await fse.pathExists(path.join(dest, '.bootstrap', 'secret.md'))).toBe(false)
86
+ })
87
+
88
+ it('does not overwrite existing files', async () => {
89
+ await fse.writeFile(path.join(src, 'AGENTS.md'), 'new content')
90
+ await fse.writeFile(path.join(dest, 'AGENTS.md'), 'original content')
91
+
92
+ await copyContent(src, dest, 'github')
93
+
94
+ const content = await fse.readFile(path.join(dest, 'AGENTS.md'), 'utf-8')
95
+ expect(content).toBe('original content')
96
+ })
97
+
98
+ it('copies github-specific files when platform is github', async () => {
99
+ await fse.writeFile(path.join(src, 'agent-gh.md'), 'github agent')
100
+ await copyContent(src, dest, 'github')
101
+ expect(await fse.pathExists(path.join(dest, 'agent-gh.md'))).toBe(true)
102
+ })
103
+
104
+ it('copies azure-specific files when platform is azure', async () => {
105
+ await fse.writeFile(path.join(src, 'agent-az.md'), 'azure agent')
106
+ await copyContent(src, dest, 'azure')
107
+ expect(await fse.pathExists(path.join(dest, 'agent-az.md'))).toBe(true)
108
+ })
109
+ })
110
+ })
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+
3
+ // Mock chalk to return the string as-is (no ANSI codes in tests)
4
+ vi.mock('chalk', () => ({
5
+ default: {
6
+ bold: { hex: () => (s) => s },
7
+ green: (s) => s,
8
+ yellow: (s) => s,
9
+ red: (s) => s,
10
+ dim: (s) => s,
11
+ bgGray: { white: (s) => s },
12
+ },
13
+ }))
14
+
15
+ // Mock ora spinner
16
+ vi.mock('ora', () => ({
17
+ default: () => ({ start: () => ({ succeed: vi.fn(), fail: vi.fn(), stop: vi.fn() }) }),
18
+ }))
19
+
20
+ // Mock execa
21
+ vi.mock('execa', () => ({
22
+ execa: vi.fn(),
23
+ }))
24
+
25
+ import { execa } from 'execa'
26
+ import { run, commandExists, header, success, warn, error, info, code } from '../exec.js'
27
+
28
+ describe('exec utils', () => {
29
+ beforeEach(() => {
30
+ vi.spyOn(console, 'log').mockImplementation(() => {})
31
+ })
32
+ afterEach(() => {
33
+ vi.restoreAllMocks()
34
+ })
35
+
36
+ describe('run()', () => {
37
+ it('returns success=true when exitCode is 0', async () => {
38
+ execa.mockResolvedValue({ exitCode: 0, stdout: 'ok', stderr: '' })
39
+ const result = await run('node', ['--version'])
40
+ expect(result).toEqual({ success: true, stdout: 'ok', stderr: '' })
41
+ })
42
+
43
+ it('returns success=false when exitCode is non-zero', async () => {
44
+ execa.mockResolvedValue({ exitCode: 1, stdout: '', stderr: 'error' })
45
+ const result = await run('node', ['--bad-flag'])
46
+ expect(result).toEqual({ success: false, stdout: '', stderr: 'error' })
47
+ })
48
+
49
+ it('returns success=false when execa throws', async () => {
50
+ execa.mockRejectedValue(new Error('spawn ENOENT'))
51
+ const result = await run('nonexistent-command', [])
52
+ expect(result.success).toBe(false)
53
+ expect(result.stderr).toBe('spawn ENOENT')
54
+ })
55
+ })
56
+
57
+ describe('commandExists()', () => {
58
+ it('returns true when command exits with code 0', async () => {
59
+ execa.mockResolvedValue({ exitCode: 0 })
60
+ expect(await commandExists('node')).toBe(true)
61
+ })
62
+
63
+ it('returns false when command exits with non-zero code', async () => {
64
+ execa.mockResolvedValue({ exitCode: 1 })
65
+ expect(await commandExists('badcmd')).toBe(false)
66
+ })
67
+
68
+ it('returns false when execa throws (command not found)', async () => {
69
+ execa.mockRejectedValue(new Error('spawn ENOENT'))
70
+ expect(await commandExists('no-such-binary')).toBe(false)
71
+ })
72
+ })
73
+
74
+ describe('console helpers', () => {
75
+ it('header() clears screen and writes output', () => {
76
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => {})
77
+ vi.spyOn(console, 'clear').mockImplementation(() => {})
78
+ header('Test Header')
79
+ expect(process.stdout.write).toHaveBeenCalled()
80
+ })
81
+
82
+ it('success() calls console.log with text', () => {
83
+ success('all good')
84
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('all good'))
85
+ })
86
+
87
+ it('warn() calls console.log with text', () => {
88
+ warn('watch out')
89
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('watch out'))
90
+ })
91
+
92
+ it('error() calls console.log with text', () => {
93
+ error('something broke')
94
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('something broke'))
95
+ })
96
+
97
+ it('info() calls console.log with text', () => {
98
+ info('just info')
99
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('just info'))
100
+ })
101
+
102
+ it('code() calls console.log for each line', () => {
103
+ code(['line one', 'line two'])
104
+ // 2 lines + 2 blank lines surrounding them
105
+ expect(console.log).toHaveBeenCalledTimes(4)
106
+ })
107
+ })
108
+ })
@@ -0,0 +1,54 @@
1
+ import fse from 'fs-extra'
2
+ import path from 'path'
3
+
4
+ // Folders never copied (skills handled separately by chooseSkillsProvider, .bootstrap is internal tooling)
5
+ const ALWAYS_EXCLUDE = ['.bootstrap', 'skills']
6
+
7
+ /**
8
+ * Copy content/ directory to destination, excluding skills (handled separately by chooseSkillsProvider)
9
+ * and internal bootstrap tooling.
10
+ * @param {string} contentDir - absolute path to content/
11
+ * @param {string} destDir - absolute path to destination (project root)
12
+ * @param {'azure'|'github'} platform
13
+ */
14
+ export async function copyContent(contentDir, destDir, platform) {
15
+ await fse.copy(contentDir, destDir, {
16
+ overwrite: false,
17
+ filter: (src) => {
18
+ const rel = path.relative(contentDir, src)
19
+ const parts = rel.split(path.sep)
20
+ return !parts.some(part =>
21
+ ALWAYS_EXCLUDE.some(pattern => part.includes(pattern))
22
+ )
23
+ },
24
+ })
25
+ }
26
+
27
+ /**
28
+ * Scan a directory for known AI config files.
29
+ * Returns array of absolute paths found.
30
+ */
31
+ const AI_FILES = [
32
+ 'AGENTS.md',
33
+ 'CLAUDE.md',
34
+ 'ARCHITECTURE.md',
35
+ 'DESIGN.md',
36
+ '.cursorrules',
37
+ '.clinerules',
38
+ '.windsurfrules',
39
+ '.github/copilot-instructions.md',
40
+ 'copilot-instructions.md',
41
+ '.aider.conf.yml',
42
+ '.aider',
43
+ ]
44
+
45
+ export async function findAiFiles(dir) {
46
+ const found = []
47
+ for (const file of AI_FILES) {
48
+ const fullPath = path.join(dir, file)
49
+ if (await fse.pathExists(fullPath)) {
50
+ found.push(fullPath)
51
+ }
52
+ }
53
+ return found
54
+ }
@@ -0,0 +1,161 @@
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
+ 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
+ }