opencode-onboard 0.4.3 → 0.4.5

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 (36) hide show
  1. package/README.md +41 -40
  2. package/content/.agents/agents/devops-manager.md +123 -123
  3. package/content/.agents/skills/ob-default/SKILL.md +25 -21
  4. package/content/.agents/skills/ob-generic-guardrails/SKILL.md +36 -32
  5. package/content/.agents/skills/ob-global/SKILL.md +92 -84
  6. package/content/.agents/skills/ob-pullrequest-az/SKILL.md +168 -160
  7. package/content/.agents/skills/ob-pullrequest-gh/SKILL.md +140 -136
  8. package/content/.opencode/commands/create-engineer.md +109 -0
  9. package/content/.opencode/plugins/session-log.js +523 -519
  10. package/content/AGENTS.md +32 -21
  11. package/package.json +1 -1
  12. package/src/commands/wizard.js +124 -113
  13. package/src/presets/browser.json +22 -18
  14. package/src/presets/optimization.json +27 -22
  15. package/src/steps/browser/browser.test.js +115 -81
  16. package/src/steps/browser/index.js +62 -54
  17. package/src/steps/clean/index.js +108 -107
  18. package/src/steps/metadata/index.js +63 -62
  19. package/src/steps/models/format.js +61 -60
  20. package/src/steps/models/write.test.js +117 -117
  21. package/src/steps/openspec/ensemble.test.js +79 -79
  22. package/src/steps/openspec/index.js +121 -32
  23. package/src/steps/openspec/index.test.js +63 -0
  24. package/src/steps/optimization/caveman.js +34 -29
  25. package/src/steps/optimization/codegraph.js +103 -0
  26. package/src/steps/optimization/codegraph.test.js +104 -0
  27. package/src/steps/optimization/global.js +88 -64
  28. package/src/steps/optimization/global.test.js +99 -0
  29. package/src/steps/optimization/index.js +109 -101
  30. package/src/steps/optimization/optimization.test.js +101 -93
  31. package/src/steps/optimization/quota.js +84 -84
  32. package/src/steps/source/source.test.js +124 -124
  33. package/src/utils/__tests__/copy.test.js +117 -117
  34. package/src/utils/exec-spinner.js +47 -47
  35. package/src/utils/exec.js +134 -131
  36. package/src/utils/terminal.js +6 -0
@@ -0,0 +1,103 @@
1
+ import { execa } from 'execa'
2
+ import fse from 'fs-extra'
3
+ import path from 'node:path'
4
+ import { header, success, warn, error, loading } from '../../utils/exec.js'
5
+
6
+ /**
7
+ * After codegraph install, it may create an `opencode.jsonc` at the project root.
8
+ * This project uses `.opencode/opencode.json` instead. Merge any MCP config from
9
+ * the rogue file into the correct location and remove it.
10
+ */
11
+ export async function fixCodegraphConfig() {
12
+ const cwd = process.cwd()
13
+ const rogueFile = path.join(cwd, 'opencode.jsonc')
14
+ const correctFile = path.join(cwd, '.opencode', 'opencode.json')
15
+
16
+ if (!await fse.pathExists(rogueFile)) return
17
+
18
+ let rogueContent
19
+ try {
20
+ const raw = await fse.readFile(rogueFile, 'utf-8')
21
+ // Strip JSONC comments (single-line // and block /* */) before parsing
22
+ const stripped = raw
23
+ .replace(/\/\/.*$/gm, '')
24
+ .replace(/\/\*[\s\S]*?\*\//g, '')
25
+ rogueContent = JSON.parse(stripped)
26
+ } catch {
27
+ warn('Could not parse opencode.jsonc, removing it')
28
+ await fse.remove(rogueFile)
29
+ return
30
+ }
31
+
32
+ let correctContent = {}
33
+ if (await fse.pathExists(correctFile)) {
34
+ try {
35
+ correctContent = await fse.readJson(correctFile)
36
+ } catch {
37
+ correctContent = {}
38
+ }
39
+ }
40
+
41
+ // Merge mcpServers from rogue into correct config
42
+ if (rogueContent.mcpServers || rogueContent.mcp) {
43
+ const mcpServers = rogueContent.mcpServers || rogueContent.mcp
44
+ correctContent.mcpServers = { ...(correctContent.mcpServers || {}), ...mcpServers }
45
+ }
46
+
47
+ await fse.ensureDir(path.dirname(correctFile))
48
+ await fse.writeJson(correctFile, correctContent, { spaces: 2 })
49
+ await fse.remove(rogueFile)
50
+ warn('Migrated codegraph config from opencode.jsonc → .opencode/opencode.json (removed opencode.jsonc)')
51
+ }
52
+
53
+ export async function installCodegraph(options = {}) {
54
+ if (!options.skipHeader) header('Installing codegraph')
55
+
56
+ const location = options.installScope === 'global' ? 'global' : 'local'
57
+
58
+ loading(`configuring codegraph for opencode (${location})...`)
59
+
60
+ try {
61
+ const installResult = await execa(
62
+ 'npx',
63
+ ['@colbymchenry/codegraph', 'install', '--target=opencode', `--location=${location}`, '--yes'],
64
+ {
65
+ cwd: process.cwd(),
66
+ reject: false,
67
+ stdio: 'pipe',
68
+ }
69
+ )
70
+
71
+ if (installResult.exitCode !== 0) {
72
+ warn('codegraph install exited with non-zero code')
73
+ return { optedIn: true, installed: false }
74
+ }
75
+
76
+ await fixCodegraphConfig()
77
+ success(`codegraph configured for opencode (${location})`)
78
+ } catch (err) {
79
+ error(`Failed to install codegraph: ${err.message}`)
80
+ return { optedIn: true, installed: false }
81
+ }
82
+
83
+ loading('initializing codegraph project index...')
84
+
85
+ try {
86
+ const initResult = await execa('codegraph', ['init'], {
87
+ cwd: process.cwd(),
88
+ reject: false,
89
+ stdio: 'pipe',
90
+ })
91
+
92
+ if (initResult.exitCode !== 0) {
93
+ warn('codegraph init exited with non-zero code')
94
+ return { optedIn: true, installed: false }
95
+ }
96
+ success('codegraph project index initialized')
97
+ } catch (err) {
98
+ error(`Failed to initialize codegraph: ${err.message}`)
99
+ return { optedIn: true, installed: false }
100
+ }
101
+
102
+ return { optedIn: true, installed: true }
103
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+ import fse from 'fs-extra'
6
+
7
+ vi.mock('execa', () => ({ execa: vi.fn() }))
8
+ vi.mock('../../utils/exec.js', () => ({
9
+ header: vi.fn(),
10
+ success: vi.fn(),
11
+ warn: vi.fn(),
12
+ error: vi.fn(),
13
+ loading: vi.fn(),
14
+ }))
15
+
16
+ import { warn } from '../../utils/exec.js'
17
+ import { fixCodegraphConfig } from './codegraph.js'
18
+
19
+ describe('fixCodegraphConfig()', () => {
20
+ let tmpDir
21
+
22
+ beforeEach(() => {
23
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-'))
24
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpDir)
25
+ })
26
+
27
+ afterEach(() => {
28
+ fs.rmSync(tmpDir, { recursive: true, force: true })
29
+ vi.restoreAllMocks()
30
+ })
31
+
32
+ it('does nothing when opencode.jsonc does not exist', async () => {
33
+ await fixCodegraphConfig()
34
+ // No error, no file created
35
+ expect(fs.existsSync(path.join(tmpDir, '.opencode', 'opencode.json'))).toBe(false)
36
+ })
37
+
38
+ it('merges mcpServers from opencode.jsonc into .opencode/opencode.json', async () => {
39
+ const rogueContent = {
40
+ mcpServers: {
41
+ codegraph: { command: 'codegraph', args: ['mcp'] }
42
+ }
43
+ }
44
+ fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), JSON.stringify(rogueContent))
45
+
46
+ const opencodeDir = path.join(tmpDir, '.opencode')
47
+ fs.mkdirSync(opencodeDir, { recursive: true })
48
+ fs.writeFileSync(path.join(opencodeDir, 'opencode.json'), JSON.stringify({
49
+ "$schema": "https://opencode.ai/config.json",
50
+ "plugin": ["opencode-plugin-openspec@latest"]
51
+ }))
52
+
53
+ await fixCodegraphConfig()
54
+
55
+ expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
56
+ const result = await fse.readJson(path.join(opencodeDir, 'opencode.json'))
57
+ expect(result.mcpServers.codegraph).toEqual({ command: 'codegraph', args: ['mcp'] })
58
+ expect(result.plugin).toEqual(["opencode-plugin-openspec@latest"])
59
+ })
60
+
61
+ it('handles JSONC with comments', async () => {
62
+ const rogueRaw = `{
63
+ // This is a comment
64
+ "mcpServers": {
65
+ "codegraph": { "command": "codegraph", "args": ["mcp"] }
66
+ }
67
+ }`
68
+ fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), rogueRaw)
69
+
70
+ const opencodeDir = path.join(tmpDir, '.opencode')
71
+ fs.mkdirSync(opencodeDir, { recursive: true })
72
+ fs.writeFileSync(path.join(opencodeDir, 'opencode.json'), '{}')
73
+
74
+ await fixCodegraphConfig()
75
+
76
+ expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
77
+ const result = await fse.readJson(path.join(opencodeDir, 'opencode.json'))
78
+ expect(result.mcpServers.codegraph.command).toBe('codegraph')
79
+ })
80
+
81
+ it('removes unparseable opencode.jsonc and warns', async () => {
82
+ fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), 'not valid json {{{')
83
+
84
+ await fixCodegraphConfig()
85
+
86
+ expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
87
+ expect(warn).toHaveBeenCalledWith('Could not parse opencode.jsonc, removing it')
88
+ })
89
+
90
+ it('creates .opencode/opencode.json if it does not exist', async () => {
91
+ const rogueContent = {
92
+ mcpServers: {
93
+ codegraph: { command: 'codegraph', args: ['mcp'] }
94
+ }
95
+ }
96
+ fs.writeFileSync(path.join(tmpDir, 'opencode.jsonc'), JSON.stringify(rogueContent))
97
+
98
+ await fixCodegraphConfig()
99
+
100
+ const result = await fse.readJson(path.join(tmpDir, '.opencode', 'opencode.json'))
101
+ expect(result.mcpServers.codegraph.command).toBe('codegraph')
102
+ expect(fs.existsSync(path.join(tmpDir, 'opencode.jsonc'))).toBe(false)
103
+ })
104
+ })
@@ -1,64 +1,88 @@
1
- import fse from 'fs-extra'
2
- import path from 'node:path'
3
- import { info, success, warn } from '../../utils/exec.js'
4
-
5
- const SOURCE_START = '<!-- OB-SOURCE-ROOTS-START -->'
6
- const SOURCE_END = '<!-- OB-SOURCE-ROOTS-END -->'
7
- const RTK_START = '<!-- OB-RTK-START -->'
8
- const RTK_END = '<!-- OB-RTK-END -->'
9
- const CAVEMAN_START = '<!-- OB-CAVEMAN-START -->'
10
- const CAVEMAN_END = '<!-- OB-CAVEMAN-END -->'
11
-
12
- function relRoot(cwd, abs) {
13
- const rel = path.relative(cwd, abs).replace(/\\/g, '/')
14
- return rel || '.'
15
- }
16
-
17
- function buildSourceRootsSection(sourceRoots, cwd) {
18
- const roots = Array.isArray(sourceRoots) && sourceRoots.length > 0 ? sourceRoots : [cwd]
19
- const bullets = roots.map(r => `- \`${relRoot(cwd, r)}\``).join('\n')
20
- const multiRepo = roots.length > 1
21
- ? '\nEach root is an independent git repository. For branch/commit/push workflows, run git operations per repository. There is no single shared git history across all roots.\n'
22
- : ''
23
-
24
- return `Read and analyze code ONLY from these roots:\n\n${bullets}\n${multiRepo}`
25
- }
26
-
27
- function buildRtkSection(rtkEnabled) {
28
- if (!rtkEnabled) return 'RTK was not selected during onboarding. Do not assume `rtk` exists.'
29
- return `## RTK, MANDATORY\n\nUse \`rtk\` for ALL CLI commands. Never run commands directly.\n\n- \`rtk git\` NOT \`git\`\n- \`rtk gh\` NOT \`gh\`\n- \`rtk az\` NOT \`az\`\n- \`rtk openspec\` NOT \`openspec\`\n\nIf \`rtk\` is not available, report blocker and stop CLI execution.`
30
- }
31
-
32
- function buildCavemanSection(cavemanEnabled) {
33
- if (!cavemanEnabled) return 'Caveman was not selected during onboarding. Use normal concise style.'
34
- return `## Caveman\n\ncaveman mode. Active now. Every response. No revert unless user asks \"stop caveman\" or \"normal mode\".`
35
- }
36
-
37
- function replaceBetween(content, start, end, replacement) {
38
- if (!content.includes(start) || !content.includes(end)) return content
39
- const pattern = new RegExp(`${start}[\\s\\S]*?${end}`)
40
- return content.replace(pattern, `${start}\n${replacement.trim()}\n${end}`)
41
- }
42
-
43
- export async function configureObGlobal(ctx = {}, tokenOpt = {}) {
44
- const cwd = process.cwd()
45
- const skillPath = path.join(cwd, '.agents', 'skills', 'ob-global', 'SKILL.md')
46
-
47
- if (!await fse.pathExists(skillPath)) {
48
- warn('ob-global skill not found, skipping dynamic configuration')
49
- return { configured: false }
50
- }
51
-
52
- const sourceRootsSection = buildSourceRootsSection(ctx.sourceRoots, cwd)
53
- const rtkSection = buildRtkSection(!!tokenOpt?.rtk?.optedIn)
54
- const cavemanSection = buildCavemanSection(!!tokenOpt?.caveman?.optedIn)
55
-
56
- let content = await fse.readFile(skillPath, 'utf-8')
57
- content = replaceBetween(content, SOURCE_START, SOURCE_END, sourceRootsSection)
58
- content = replaceBetween(content, RTK_START, RTK_END, rtkSection)
59
- content = replaceBetween(content, CAVEMAN_START, CAVEMAN_END, cavemanSection)
60
- await fse.writeFile(skillPath, `${content.replace(/\s*$/, '')}\n`, 'utf-8')
61
- info('Configured ob-global from onboarding selections')
62
- success('ob-global skill updated')
63
- return { configured: true, path: skillPath }
64
- }
1
+ import fse from 'fs-extra'
2
+ import path from 'node:path'
3
+ import { info, success, warn } from '../../utils/exec.js'
4
+
5
+ const SOURCE_START = '<!-- OB-SOURCE-ROOTS-START -->'
6
+ const SOURCE_END = '<!-- OB-SOURCE-ROOTS-END -->'
7
+ const RTK_START = '<!-- OB-RTK-START -->'
8
+ const RTK_END = '<!-- OB-RTK-END -->'
9
+ const CAVEMAN_START = '<!-- OB-CAVEMAN-START -->'
10
+ const CAVEMAN_END = '<!-- OB-CAVEMAN-END -->'
11
+
12
+ const CODEGRAPH_START = '<!-- OB-CODEGRAPH-START -->'
13
+ const CODEGRAPH_END = '<!-- OB-CODEGRAPH-END -->'
14
+
15
+ function relRoot(cwd, abs) {
16
+ const rel = path.relative(cwd, abs).replace(/\\/g, '/')
17
+ return rel || '.'
18
+ }
19
+
20
+ function buildSourceRootsSection(sourceRoots, cwd) {
21
+ const roots = Array.isArray(sourceRoots) && sourceRoots.length > 0 ? sourceRoots : [cwd]
22
+ const bullets = roots.map(r => `- \`${relRoot(cwd, r)}\``).join('\n')
23
+ const multiRepo = roots.length > 1
24
+ ? '\nEach root is an independent git repository. For branch/commit/push workflows, run git operations per repository. There is no single shared git history across all roots.\n'
25
+ : ''
26
+
27
+ return `Read and analyze code ONLY from these roots:\n\n${bullets}\n${multiRepo}`
28
+ }
29
+
30
+ function buildRtkSection(rtkEnabled) {
31
+ if (!rtkEnabled) return 'RTK was not selected during onboarding. Do not assume `rtk` exists.'
32
+ return `## RTK, MANDATORY\n\nUse \`rtk\` for ALL CLI commands. Never run commands directly.\n\n- \`rtk git\` NOT \`git\`\n- \`rtk gh\` NOT \`gh\`\n- \`rtk az\` NOT \`az\`\n- \`rtk openspec\` NOT \`openspec\`\n\nIf \`rtk\` is not available, report blocker and stop CLI execution.`
33
+ }
34
+
35
+ function buildCavemanSection(cavemanEnabled) {
36
+ if (!cavemanEnabled) return 'Caveman was not selected during onboarding. Use normal concise style.'
37
+ return `## Caveman\n\ncaveman mode. Active now. Every response. No revert unless user asks "stop caveman" or "normal mode".`
38
+ }
39
+
40
+ function buildCodegraphSection(codegraphEnabled) {
41
+ if (!codegraphEnabled) return 'Codegraph was not selected during onboarding. Use standard grep/glob/read for code exploration.'
42
+ return `## CodeGraph
43
+
44
+ This project has CodeGraph initialized (\`.codegraph/\` exists). Use it for all code exploration.
45
+
46
+ **NEVER call \`codegraph_explore\` or \`codegraph_context\` directly in the main session** — these return large source payloads that fill context. Instead, ALWAYS spawn an Explore sub-agent for exploration questions ("how does X work?", "where is Y implemented?").
47
+
48
+ When spawning Explore agents, include in the prompt:
49
+ > This project has CodeGraph initialized. Use \`codegraph_explore\` as your PRIMARY tool. Do NOT re-read files that codegraph_explore already returned. Only fall back to grep/glob/read for files listed under "Additional relevant files".
50
+
51
+ **The main session may only use these lightweight tools directly** (targeted lookups before edits):
52
+ - \`codegraph_search\` find symbols by name
53
+ - \`codegraph_callers\` / \`codegraph_callees\` — trace call flow
54
+ - \`codegraph_impact\` check what's affected before editing
55
+ - \`codegraph_node\` — get a single symbol's details`
56
+ }
57
+
58
+
59
+ function replaceBetween(content, start, end, replacement) {
60
+ if (!content.includes(start) || !content.includes(end)) return content
61
+ const pattern = new RegExp(`${start}[\\s\\S]*?${end}`)
62
+ return content.replace(pattern, `${start}\n${replacement.trim()}\n${end}`)
63
+ }
64
+
65
+ export async function configureObGlobal(ctx = {}, tokenOpt = {}) {
66
+ const cwd = process.cwd()
67
+ const skillPath = path.join(cwd, '.agents', 'skills', 'ob-global', 'SKILL.md')
68
+
69
+ if (!await fse.pathExists(skillPath)) {
70
+ warn('ob-global skill not found, skipping dynamic configuration')
71
+ return { configured: false }
72
+ }
73
+
74
+ const sourceRootsSection = buildSourceRootsSection(ctx.sourceRoots, cwd)
75
+ const rtkSection = buildRtkSection(!!tokenOpt?.rtk?.optedIn)
76
+ const cavemanSection = buildCavemanSection(!!tokenOpt?.caveman?.optedIn)
77
+ const codegraphSection = buildCodegraphSection(!!tokenOpt?.codegraph?.optedIn)
78
+
79
+ let content = await fse.readFile(skillPath, 'utf-8')
80
+ content = replaceBetween(content, SOURCE_START, SOURCE_END, sourceRootsSection)
81
+ content = replaceBetween(content, RTK_START, RTK_END, rtkSection)
82
+ content = replaceBetween(content, CAVEMAN_START, CAVEMAN_END, cavemanSection)
83
+ content = replaceBetween(content, CODEGRAPH_START, CODEGRAPH_END, codegraphSection)
84
+ await fse.writeFile(skillPath, `${content.replace(/\s*$/, '')}\n`, 'utf-8')
85
+ info('Configured ob-global from onboarding selections')
86
+ success('ob-global skill updated')
87
+ return { configured: true, path: skillPath }
88
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+
6
+ vi.mock('../../utils/exec.js', () => ({
7
+ info: vi.fn(),
8
+ success: vi.fn(),
9
+ warn: vi.fn(),
10
+ }))
11
+
12
+ import { configureObGlobal } from './global.js'
13
+
14
+ const SKILL_TEMPLATE = `## Token Optimization Rules
15
+
16
+ <!-- OB-SOURCE-ROOTS-START -->
17
+ placeholder
18
+ <!-- OB-SOURCE-ROOTS-END -->
19
+
20
+ <!-- OB-RTK-START -->
21
+ placeholder
22
+ <!-- OB-RTK-END -->
23
+
24
+ <!-- OB-CAVEMAN-START -->
25
+ placeholder
26
+ <!-- OB-CAVEMAN-END -->
27
+
28
+ <!-- OB-CODEGRAPH-START -->
29
+ placeholder
30
+ <!-- OB-CODEGRAPH-END -->
31
+ `
32
+
33
+ describe('configureObGlobal()', () => {
34
+ let tmpDir
35
+ let skillPath
36
+
37
+ beforeEach(() => {
38
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ob-global-test-'))
39
+ const skillDir = path.join(tmpDir, '.agents', 'skills', 'ob-global')
40
+ fs.mkdirSync(skillDir, { recursive: true })
41
+ skillPath = path.join(skillDir, 'SKILL.md')
42
+ fs.writeFileSync(skillPath, SKILL_TEMPLATE, 'utf-8')
43
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpDir)
44
+ })
45
+
46
+ afterEach(() => {
47
+ fs.rmSync(tmpDir, { recursive: true, force: true })
48
+ vi.restoreAllMocks()
49
+ })
50
+
51
+ it('returns configured:false when ob-global skill is missing', async () => {
52
+ fs.rmSync(skillPath)
53
+ const result = await configureObGlobal()
54
+ expect(result).toEqual({ configured: false })
55
+ })
56
+
57
+ it('injects source roots section', async () => {
58
+ await configureObGlobal({ sourceRoots: [tmpDir] }, {})
59
+ const content = fs.readFileSync(skillPath, 'utf-8')
60
+ expect(content).toContain('Read and analyze code ONLY from these roots')
61
+ })
62
+
63
+ it('injects RTK section when rtk is opted in', async () => {
64
+ await configureObGlobal({}, { rtk: { optedIn: true } })
65
+ const content = fs.readFileSync(skillPath, 'utf-8')
66
+ expect(content).toContain('RTK, MANDATORY')
67
+ })
68
+
69
+ it('injects RTK not-selected note when rtk is not opted in', async () => {
70
+ await configureObGlobal({}, { rtk: { optedIn: false } })
71
+ const content = fs.readFileSync(skillPath, 'utf-8')
72
+ expect(content).toContain('RTK was not selected during onboarding')
73
+ })
74
+
75
+ it('injects caveman section when caveman is opted in', async () => {
76
+ await configureObGlobal({}, { caveman: { optedIn: true } })
77
+ const content = fs.readFileSync(skillPath, 'utf-8')
78
+ expect(content).toContain('caveman mode')
79
+ })
80
+
81
+ it('injects codegraph section when codegraph is opted in', async () => {
82
+ await configureObGlobal({}, { codegraph: { optedIn: true } })
83
+ const content = fs.readFileSync(skillPath, 'utf-8')
84
+ expect(content).toContain('CodeGraph')
85
+ expect(content).toContain('codegraph_explore')
86
+ })
87
+
88
+ it('injects codegraph not-selected note when codegraph is not opted in', async () => {
89
+ await configureObGlobal({}, { codegraph: { optedIn: false } })
90
+ const content = fs.readFileSync(skillPath, 'utf-8')
91
+ expect(content).toContain('Codegraph was not selected during onboarding')
92
+ })
93
+
94
+ it('returns configured:true with skill path on success', async () => {
95
+ const result = await configureObGlobal({}, {})
96
+ expect(result.configured).toBe(true)
97
+ expect(result.path).toBe(skillPath)
98
+ })
99
+ })