opencode-onboard 0.4.1 → 0.4.3

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.
@@ -61,6 +61,34 @@ function replaceBetween(content, start, end, replacement) {
61
61
  return content.replace(pattern, `${start}\n${replacement.trim()}\n${end}`)
62
62
  }
63
63
 
64
+ const CONCURRENCY_PLACEHOLDER = '{{MAX_CONCURRENT_AGENTS}}'
65
+ const DEFAULT_MAX_CONCURRENT_AGENTS = 4
66
+
67
+ export async function patchConcurrency(ctx) {
68
+ const maxAgents = String(ctx.maxConcurrentAgents ?? DEFAULT_MAX_CONCURRENT_AGENTS)
69
+ const cwd = process.cwd()
70
+
71
+ const filesToPatch = [
72
+ 'AGENTS.md',
73
+ path.join('.opencode', 'commands', 'opsx-apply.md'),
74
+ path.join('.opencode', 'skills', 'openspec-apply-change', 'SKILL.md'),
75
+ ]
76
+
77
+ let patched = 0
78
+ for (const rel of filesToPatch) {
79
+ const abs = path.join(cwd, rel)
80
+ if (!await fse.pathExists(abs)) continue
81
+ const content = await fse.readFile(abs, 'utf-8')
82
+ if (!content.includes(CONCURRENCY_PLACEHOLDER)) continue
83
+ await fse.writeFile(abs, content.replaceAll(CONCURRENCY_PLACEHOLDER, maxAgents), 'utf-8')
84
+ patched++
85
+ }
86
+
87
+ if (patched > 0) {
88
+ success(`Concurrency limit set to ${maxAgents} agents in ${patched} file(s)`)
89
+ }
90
+ }
91
+
64
92
  export async function patchAgentsMd(ctx) {
65
93
  const agentsMdPath = path.join(process.cwd(), 'AGENTS.md')
66
94
  if (!await fse.pathExists(agentsMdPath)) return
@@ -12,6 +12,7 @@ vi.mock('../../utils/copy.js', () => ({
12
12
 
13
13
  vi.mock('./agents.js', () => ({
14
14
  patchAgentsMd: vi.fn(),
15
+ patchConcurrency: vi.fn(),
15
16
  patchDevopsManagerMd: vi.fn(),
16
17
  }))
17
18
 
@@ -3,7 +3,7 @@ import path from 'path'
3
3
  import { fileURLToPath } from 'url'
4
4
  import { copyContent } from '../../utils/copy.js'
5
5
  import { error, header, success } from '../../utils/exec.js'
6
- import { patchAgentsMd, patchDevopsManagerMd } from './agents.js'
6
+ import { patchAgentsMd, patchConcurrency, patchDevopsManagerMd } from './agents.js'
7
7
  import { installSkills } from './skills.js'
8
8
 
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -24,6 +24,7 @@ export async function copyContentStep(platform, ctx = {}) {
24
24
  }, { spaces: 2 })
25
25
  await patchDevopsManagerMd(platform)
26
26
  await patchAgentsMd(ctx)
27
+ await patchConcurrency(ctx)
27
28
  await installSkills()
28
29
  success('Files copied to project root')
29
30
  } catch (err) {
@@ -33,6 +33,7 @@ export async function writeOnboardConfig(data) {
33
33
  platform: data.platform,
34
34
  sourceMode: data.sourceMode,
35
35
  sourceRoots: data.sourceRoots,
36
+ maxConcurrentAgents: data.maxConcurrentAgents ?? 4,
36
37
  preserved: {
37
38
  design: !!data.hasDesign,
38
39
  architecture: !!data.hasArchitecture,
@@ -25,14 +25,17 @@ import fse from 'fs-extra'
25
25
  import { writeOnboardConfig } from './index.js'
26
26
 
27
27
  describe('writeOnboardConfig()', () => {
28
- let tmpDir
28
+ let tmpDir, originalCwd
29
29
 
30
30
  beforeEach(() => {
31
+ vi.clearAllMocks()
31
32
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'metadata-test-'))
33
+ originalCwd = process.cwd()
32
34
  process.chdir(tmpDir)
33
35
  })
34
36
 
35
37
  afterEach(() => {
38
+ process.chdir(originalCwd)
36
39
  fs.rmSync(tmpDir, { recursive: true, force: true })
37
40
  })
38
41
 
@@ -57,7 +60,7 @@ describe('writeOnboardConfig()', () => {
57
60
  expect(fse.ensureDir).toHaveBeenCalled()
58
61
  expect(fse.writeJson).toHaveBeenCalled()
59
62
  const call = fse.writeJson.mock.calls[0]
60
- const payload = call[0]
63
+ const payload = call[1]
61
64
  expect(payload.schema).toBe(1)
62
65
  expect(payload.wizard.platform).toBe('github')
63
66
  expect(payload.wizard.models.build).toBe('build-model')
@@ -70,7 +73,7 @@ describe('writeOnboardConfig()', () => {
70
73
  await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
71
74
 
72
75
  const call = fse.writeJson.mock.calls[0]
73
- const payload = call[0]
76
+ const payload = call[1]
74
77
  expect(payload.opencodeVersion).toBe('2.0.0')
75
78
  })
76
79
 
@@ -80,7 +83,7 @@ describe('writeOnboardConfig()', () => {
80
83
  await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
81
84
 
82
85
  const call = fse.writeJson.mock.calls[0]
83
- const payload = call[0]
86
+ const payload = call[1]
84
87
  expect(payload.opencodeVersion).toBe(null)
85
88
  })
86
89
 
@@ -90,7 +93,7 @@ describe('writeOnboardConfig()', () => {
90
93
  await writeOnboardConfig({ platform: 'github', sourceMode: 'current', sourceRoots: [] })
91
94
 
92
95
  const call = fse.writeJson.mock.calls[0]
93
- const payload = call[0]
96
+ const payload = call[1]
94
97
  expect(payload.note).toContain('Informational file only')
95
98
  })
96
99
  })
@@ -7,7 +7,7 @@ describe('buildDisplayModels()', () => {
7
7
 
8
8
  const result = buildDisplayModels(raw)
9
9
 
10
- expect(result[0].label).toContain('[$]')
10
+ expect(result[0].label).toContain('[$$]')
11
11
  })
12
12
 
13
13
  it('adds cost tier label for mid-range models', () => {
@@ -15,7 +15,7 @@ describe('buildDisplayModels()', () => {
15
15
 
16
16
  const result = buildDisplayModels(raw)
17
17
 
18
- expect(result[0].label).toContain('[$$]')
18
+ expect(result[0].label).toContain('[$$$]')
19
19
  })
20
20
 
21
21
  it('adds cost tier label for expensive models', () => {
@@ -47,7 +47,8 @@ describe('buildDisplayModels()', () => {
47
47
 
48
48
  const result = buildDisplayModels(raw)
49
49
 
50
- expect(result[0].description).toContain('cost: ?')
50
+ expect(result[0].description).toContain('?')
51
+ expect(result[0].label).not.toContain('[')
51
52
  })
52
53
 
53
54
  it('handles $0 subscription pricing', () => {
@@ -58,7 +59,7 @@ describe('buildDisplayModels()', () => {
58
59
  expect(result[0].description).toContain('$0 (subscription)')
59
60
  })
60
61
 
61
- it('sorts models by cost ascending', () => {
62
+ it('preserves input order (sorting is done upstream by parseModels)', () => {
62
63
  const raw = [
63
64
  { id: 'expensive/model', name: 'Expensive', cost: 100, context: 1000 },
64
65
  { id: 'cheap/model', name: 'Cheap', cost: 1, context: 1000 },
@@ -67,8 +68,8 @@ describe('buildDisplayModels()', () => {
67
68
 
68
69
  const result = buildDisplayModels(raw)
69
70
 
70
- expect(result[0].id).toBe('cheap/model')
71
- expect(result[1].id).toBe('mid/model')
72
- expect(result[2].id).toBe('expensive/model')
71
+ expect(result[0].id).toBe('expensive/model')
72
+ expect(result[1].id).toBe('cheap/model')
73
+ expect(result[2].id).toBe('mid/model')
73
74
  })
74
75
  })
@@ -7,16 +7,6 @@ vi.mock('../../utils/exec.js', () => ({
7
7
  success: vi.fn(),
8
8
  }))
9
9
 
10
- vi.mock('fs-extra', () => ({
11
- default: {
12
- readFile: vi.fn(),
13
- writeFile: vi.fn(),
14
- writeJson: vi.fn(),
15
- pathExists: vi.fn(),
16
- },
17
- }))
18
-
19
- import fse from 'fs-extra'
20
10
  import { success } from '../../utils/exec.js'
21
11
  import { writeModelToAgent, writeModelsToConfigs } from './write.js'
22
12
 
@@ -24,6 +14,7 @@ describe('writeModelToAgent()', () => {
24
14
  let tmpDir
25
15
 
26
16
  beforeEach(() => {
17
+ vi.clearAllMocks()
27
18
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-write-test-'))
28
19
  })
29
20
 
@@ -69,18 +60,22 @@ custom_field: custom_value
69
60
  })
70
61
 
71
62
  describe('writeModelsToConfigs()', () => {
72
- let tmpDir, agentsDir, opencodeJsonPath, ensembleJsonPath
63
+ let tmpDir, agentsDir, opencodeJsonPath, ensembleJsonPath, originalCwd
73
64
 
74
65
  beforeEach(() => {
66
+ vi.clearAllMocks()
67
+ originalCwd = process.cwd()
75
68
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-config-test-'))
76
69
  agentsDir = path.join(tmpDir, '.agents', 'agents')
77
70
  fs.mkdirSync(agentsDir, { recursive: true })
78
71
  opencodeJsonPath = path.join(tmpDir, '.opencode', 'opencode.json')
79
72
  ensembleJsonPath = path.join(tmpDir, '.opencode', 'ensemble.json')
80
73
  fs.mkdirSync(path.dirname(opencodeJsonPath), { recursive: true })
74
+ process.chdir(tmpDir)
81
75
  })
82
76
 
83
77
  afterEach(() => {
78
+ process.chdir(originalCwd)
84
79
  fs.rmSync(tmpDir, { recursive: true, force: true })
85
80
  })
86
81
 
@@ -106,12 +101,15 @@ describe('writeModelsToConfigs()', () => {
106
101
  })
107
102
 
108
103
  it('reports success when writing configs', async () => {
104
+ const agentFile = path.join(agentsDir, 'back-engineer.md')
105
+ fs.writeFileSync(agentFile, '---\nname: Back\n---', 'utf-8')
106
+
109
107
  await writeModelsToConfigs({
110
108
  planModel: 'plan-model',
111
109
  buildModel: 'build-model',
112
110
  fastModel: 'fast-model',
113
- agentsDir: '/nonexistent',
114
- preset: { roles: { build: { agents: [] }, fast: { agents: [] } } },
111
+ agentsDir,
112
+ preset: { roles: { build: { agents: ['back-engineer'] }, fast: { agents: [] } } },
115
113
  })
116
114
 
117
115
  expect(success).toHaveBeenCalled()
@@ -36,7 +36,14 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
36
36
  DO NOT call team_claim yourself, only agents claim tasks.
37
37
  DO NOT proceed to 6d until team_tasks_add succeeds.
38
38
 
39
- **Step 6d.** Discover relevant skills, then spawn specialists.
39
+ **Step 6d.** Discover relevant skills, then spawn specialists with an INITIAL BATCH of tasks.
40
+
41
+ **ROLLING BATCH MODEL:**
42
+ Agents do NOT receive all their tasks upfront. Instead:
43
+ - Assign each agent an initial batch of up to 3 unblocked tasks.
44
+ - When an agent completes its batch and messages back, the lead assigns the next batch of up to 3 unassigned tasks from the board that match the agent's domain.
45
+ - Repeat until no pending tasks remain on the board.
46
+ - Only shut down an agent when the board has no more tasks for its domain.
40
47
 
41
48
  Before spawning, scan \`.agents/skills/\` and read each \`SKILL.md\` description line.
42
49
  Match skills to agents by domain:
@@ -49,11 +56,11 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
49
56
 
50
57
  The spawn prompt must contain exactly:
51
58
  1. Their name and role on this team
52
- 2. Which tasks are theirs, include the LITERAL task IDs (e.g. "task-abc123") AND the task content for each. Copy them verbatim from the IDs returned by team_tasks_add. Do NOT paraphrase or omit IDs.
59
+ 2. Their initial batch of tasks (up to 3): include the LITERAL task IDs (e.g. "task-abc123") AND the task content. Copy them verbatim from the IDs returned by team_tasks_add. Do NOT paraphrase or omit IDs.
53
60
  3. Key context they need (summarized from context files, do NOT tell them to read files themselves)
54
61
  4. The 6 OpenCode tools they have available (these are OpenCode tools, NOT shell commands, call them directly as tools, never via bash):
55
62
  team_claim, team_tasks_complete, team_tasks_list, team_tasks_add, team_message, team_broadcast
56
- 5. How to proceed: for EACH task ID listed above, call team_claim tool with that exact task_id before starting it, call team_tasks_complete tool with that task_id after finishing it, then move to the next task. When all tasks are done or blocked, call team_message to notify lead with results or blockers.
63
+ 5. How to proceed: for EACH task ID listed, call team_claim tool with that exact task_id before starting it, call team_tasks_complete tool with that task_id after finishing it, then move to the next task. When all listed tasks are done, message lead with results. Lead may assign more tasks, do NOT shut down until lead confirms no more tasks.
57
64
  6. Which skills to load: list the skill names and paths they MUST read before implementing. Example: "Before starting, read \`.agents/skills/next-best-practices/SKILL.md\` and follow its rules for all Next.js code."
58
65
 
59
66
  Keep spawn prompts under 600 tokens. Do not describe team internals or how ensemble works.
@@ -86,12 +93,24 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
86
93
  - \`team_view member:"<name>"\` for a teammate live session
87
94
  - \`team_results from:"<name>"\` for full teammate report text
88
95
 
89
- **Step 6f.** When a teammate messages back, you receive a ping only, the full content is NOT in the notification.
90
- Call team_results to read the full message and mark it read. Then for each teammate: team_shutdown -> team_merge.
91
- If team_merge blocks ("overlapping local changes"), commit or stash your local changes first, then retry.
92
- Fix any other blockers reported.
96
+ **Step 6f.** When a teammate messages back (rolling re-assignment loop):
97
+ 1. Call \`team_results from:"<name>"\` to read full message.
98
+ 2. Call \`team_tasks_list\` to check remaining pending/unassigned tasks on the board.
99
+ 3. **If there are more unassigned tasks matching this agent's domain:**
100
+ - Pick up to 3 unassigned, unblocked tasks for this agent's domain.
101
+ - Send them via \`team_message to:"<name>" text:"Next tasks: [task-<id1>] <desc>, [task-<id2>] <desc>. Claim each with team_claim before starting."\`
102
+ - Do NOT shut down the agent. Go back to waiting (step 6e).
103
+ 4. **If no more tasks for this agent:**
104
+ - \`team_shutdown member:"<name>"\`
105
+ - \`team_merge member:"<name>"\`
106
+ - If team_merge blocks on local changes: \`git stash\`, retry merge, \`git stash pop\`.
107
+ 5. **If ALL agents are shut down and tasks remain unassigned** (new domain, dependencies unblocked):
108
+ - Spawn new agents for the remaining tasks (back to step 6d).
109
+ 6. **If ALL tasks are done:** proceed to step 7.
93
110
  If a teammate reports rate-limit/quota/token exhaustion, immediately shutdown that teammate and respawn with an available model.
94
111
 
112
+ **ZERO PENDING TASKS GUARANTEE:** Before proceeding to step 7, call \`team_tasks_list\` and verify EVERY task is either \`done\` or \`blocked\`. If any task is \`pending\` and unassigned, assign it to an agent or spawn a new one. Never leave pending tasks orphaned.
113
+
95
114
  7. **Quality check**
96
115
 
97
116
  Spawn quality engineer with worktree:false (read-only, no file edits):
@@ -123,13 +142,17 @@ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
123
142
  - NEVER call team_spawn before team_tasks_add, tasks must exist before agents are spawned
124
143
  - NEVER poll team_results or team_status in a loop, wait for teammates to message you
125
144
  - NEVER call team_claim or team_tasks_complete as lead, only agents call these tools
145
+ - NEVER leave pending tasks orphaned, always verify board is empty before proceeding to step 7
126
146
  - ALWAYS pass the LITERAL task IDs returned by team_tasks_add into each agent's spawn prompt, copy the exact IDs, never paraphrase
147
+ - ALWAYS assign initial batch of up to 3 tasks per agent; re-assign next batch (up to 3) via team_message when agent reports done
148
+ - ALWAYS call team_tasks_list after each agent reports done to check for remaining unassigned tasks
127
149
  - ALWAYS repeat the same literal task IDs in the team_message start trigger, never send a generic "claim your first task" without the actual IDs
128
150
  - NEVER send a start message that omits task IDs; if a task ID is missing from the start message, the agent cannot claim
129
151
  - NEVER edit files between team_spawn and team_merge, team_merge blocks on overlapping local changes
130
152
  - ALWAYS add every task to the board with team_tasks_add before spawning
131
153
  - ALWAYS spawn agents sequentially (wait for each team_spawn result before the next), then send start messages to all of them together
132
154
  - ALWAYS instruct agents to call team_claim before each task and team_tasks_complete after
155
+ - ALWAYS shut down + merge agents only when no more tasks remain for their domain
133
156
  - If teammates are stuck, use team_message to resend tasks, then wait, never implement directly
134
157
  - Mark tasks complete in openspec AFTER specialists finish, not before
135
158
  - Pause on errors, blockers, or unclear requirements. Do not guess
@@ -20,6 +20,21 @@ vi.mock('./caveman.js', () => ({ installCaveman: vi.fn() }))
20
20
  vi.mock('./caveman-guidance.js', () => ({ enableCavemanGuidance: vi.fn() }))
21
21
  vi.mock('./global.js', () => ({ configureObGlobal: vi.fn() }))
22
22
 
23
+ vi.mock('fs-extra', () => ({
24
+ default: {
25
+ readJson: vi.fn().mockResolvedValue({
26
+ info: 'Token optimization info',
27
+ message: 'Select tools',
28
+ timeoutMs: 5000,
29
+ choices: [
30
+ { value: 'rtk', checked: false },
31
+ { value: 'quota', checked: false },
32
+ { value: 'caveman', checked: false },
33
+ ],
34
+ }),
35
+ },
36
+ }))
37
+
23
38
  import { checkbox } from '@inquirer/prompts'
24
39
  import { commandExists, warn } from '../../utils/exec.js'
25
40
  import { installQuota } from './quota.js'
@@ -34,6 +49,9 @@ describe('tokenOptimizationStep()', () => {
34
49
  })
35
50
 
36
51
  it('runs all optimizations by default selection', async () => {
52
+ const originalIsTTY = process.stdin.isTTY
53
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true })
54
+
37
55
  checkbox.mockResolvedValue(['rtk', 'quota', 'caveman'])
38
56
  commandExists.mockResolvedValue(true)
39
57
  installQuota.mockResolvedValue({ optedIn: true, installed: true })
@@ -43,6 +61,8 @@ describe('tokenOptimizationStep()', () => {
43
61
 
44
62
  const result = await tokenOptimizationStep()
45
63
 
64
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true })
65
+
46
66
  expect(commandExists).toHaveBeenCalledWith('rtk')
47
67
  expect(installQuota).toHaveBeenCalledWith({ skipHeader: true, skipPrompt: true })
48
68
  expect(installCaveman).toHaveBeenCalledWith({ skipHeader: true, skipPrompt: true })
@@ -54,7 +54,7 @@ describe('choosePlatform()', () => {
54
54
  await checkPlatform('github')
55
55
 
56
56
  expect(success).toHaveBeenCalledWith('GitHub CLI (gh) available')
57
- expect(success).toHaveBeenCalledWith('GitHub CLI authenticated')
57
+ expect(success).toHaveBeenCalledWith('GitHub CLI (gh) authenticated')
58
58
  })
59
59
 
60
60
  it('warns when gh is installed but not authenticated', async () => {
@@ -8,6 +8,26 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
8
  const SOURCE_PRESET_PATH = path.resolve(__dirname, '../../presets/source.json')
9
9
  const sourcePreset = await fse.readJson(SOURCE_PRESET_PATH)
10
10
 
11
+ async function listChildFolders(cwd) {
12
+ const entries = await fse.readdir(cwd)
13
+ const dirs = []
14
+
15
+ for (const name of entries) {
16
+ if (name.startsWith('.')) continue
17
+ const abs = path.join(cwd, name)
18
+ try {
19
+ const stat = await fse.stat(abs)
20
+ if (!stat.isDirectory()) continue
21
+ dirs.push({ name, abs })
22
+ } catch {
23
+ // ignore invalid entries
24
+ }
25
+ }
26
+
27
+ dirs.sort((a, b) => a.name.localeCompare(b.name))
28
+ return dirs
29
+ }
30
+
11
31
  async function listParentFolders(cwd) {
12
32
  const parent = path.resolve(cwd, '..')
13
33
  const entries = await fse.readdir(parent)
@@ -47,6 +67,34 @@ export async function chooseSourceScope() {
47
67
  return { sourceMode: 'current', sourceRoots: [cwd] }
48
68
  }
49
69
 
70
+ if (mode === 'children') {
71
+ const childFolders = await listChildFolders(cwd)
72
+ if (childFolders.length === 0) {
73
+ warn('No child folders found in current directory. Falling back to current folder.')
74
+ success(`Source scope: ${cwd}`)
75
+ return { sourceMode: 'current', sourceRoots: [cwd] }
76
+ }
77
+
78
+ const selected = await checkbox({
79
+ message: sourcePreset.childrenSelectionMessage,
80
+ choices: childFolders.map(d => ({
81
+ name: `./${d.name}`,
82
+ value: d.abs,
83
+ checked: true,
84
+ })),
85
+ required: true,
86
+ })
87
+
88
+ if (!selected || selected.length === 0) {
89
+ warn('No folders selected. Falling back to current folder.')
90
+ success(`Source scope: ${cwd}`)
91
+ return { sourceMode: 'current', sourceRoots: [cwd] }
92
+ }
93
+
94
+ success(`Source scope: ${selected.map(p => path.basename(p)).join(', ')}`)
95
+ return { sourceMode: 'children-selected', sourceRoots: selected }
96
+ }
97
+
50
98
  const parentFolders = await listParentFolders(cwd)
51
99
  if (parentFolders.length === 0) {
52
100
  warn('No sibling folders found in parent directory. Falling back to current folder.')
@@ -23,11 +23,13 @@ vi.mock('fs-extra', () => ({
23
23
  choices: [
24
24
  { name: 'Current folder', value: 'current' },
25
25
  { name: 'Parent folder', value: 'parent' },
26
+ { name: 'Child folders', value: 'children' },
26
27
  ],
27
28
  parentSelectionMessage: 'Select sibling folders',
29
+ childrenSelectionMessage: 'Select child folders',
28
30
  }),
29
31
  readdir: vi.fn(),
30
- stat: vi.fn(),
32
+ stat: vi.fn().mockResolvedValue({ isDirectory: () => true }),
31
33
  },
32
34
  }))
33
35
 
@@ -36,15 +38,17 @@ import fse from 'fs-extra'
36
38
  import { chooseSourceScope } from './index.js'
37
39
 
38
40
  describe('chooseSourceScope()', () => {
39
- let tmpDir
41
+ let tmpDir, originalCwd
40
42
 
41
43
  beforeEach(() => {
42
44
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'source-test-'))
45
+ originalCwd = process.cwd()
43
46
  process.chdir(tmpDir)
44
47
  vi.clearAllMocks()
45
48
  })
46
49
 
47
50
  afterEach(() => {
51
+ process.chdir(originalCwd)
48
52
  fs.rmSync(tmpDir, { recursive: true, force: true })
49
53
  })
50
54
 
@@ -61,7 +65,7 @@ describe('chooseSourceScope()', () => {
61
65
  select.mockResolvedValue('parent')
62
66
  const parentDir = path.dirname(tmpDir)
63
67
  const siblingDir = path.join(parentDir, 'sibling-project')
64
- fs.mkdirSync(siblingDir)
68
+ fs.mkdirSync(siblingDir, { recursive: true })
65
69
  fse.readdir.mockResolvedValue(['sibling-project'])
66
70
 
67
71
  await chooseSourceScope()
@@ -86,4 +90,35 @@ describe('chooseSourceScope()', () => {
86
90
 
87
91
  expect(result.sourceMode).toBe('current')
88
92
  })
93
+
94
+ it('lists child folders when user selects children mode', async () => {
95
+ select.mockResolvedValue('children')
96
+ fse.readdir.mockResolvedValue(['packages', 'apps'])
97
+ checkbox.mockResolvedValue([path.join(tmpDir, 'packages')])
98
+
99
+ const result = await chooseSourceScope()
100
+
101
+ expect(checkbox).toHaveBeenCalled()
102
+ expect(result.sourceMode).toBe('children-selected')
103
+ expect(result.sourceRoots).toContain(path.join(tmpDir, 'packages'))
104
+ })
105
+
106
+ it('falls back to current when no child folders found', async () => {
107
+ select.mockResolvedValue('children')
108
+ fse.readdir.mockResolvedValue([])
109
+
110
+ const result = await chooseSourceScope()
111
+
112
+ expect(result.sourceMode).toBe('current')
113
+ })
114
+
115
+ it('falls back to current when no child folders selected', async () => {
116
+ select.mockResolvedValue('children')
117
+ fse.readdir.mockResolvedValue(['packages'])
118
+ checkbox.mockResolvedValue([])
119
+
120
+ const result = await chooseSourceScope()
121
+
122
+ expect(result.sourceMode).toBe('current')
123
+ })
89
124
  })
@@ -75,7 +75,6 @@ describe('parseModels()', () => {
75
75
  const result = parseModels(data)
76
76
 
77
77
  expect(result[0].cost).toBeUndefined()
78
- expect(result[0].description).toContain('cost: ?')
79
78
  })
80
79
 
81
80
  it('extracts context limit', () => {