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.
- package/LICENSE +21 -0
- package/README.md +303 -289
- package/content/.agents/agents/basic-engineer.md +4 -2
- package/content/.agents/skills/ob-global/SKILL.md +35 -0
- package/content/.opencode/commands/init.md +8 -0
- package/content/.opencode/commands/main.md +17 -0
- package/content/.opencode/commands/opsx-apply.md +131 -70
- package/content/.opencode/commands/plan.md +37 -0
- package/content/.opencode/skills/openspec-apply-change/SKILL.md +86 -64
- package/content/AGENTS.md +48 -22
- package/package.json +1 -1
- package/src/commands/join.js +43 -0
- package/src/commands/shared.js +12 -0
- package/src/commands/shared.test.js +56 -0
- package/src/commands/single.js +66 -0
- package/src/commands/wizard.js +113 -0
- package/src/index.js +25 -168
- package/src/presets/source.json +7 -1
- package/src/steps/browser/browser.test.js +1 -1
- package/src/steps/copy/agents.js +28 -0
- package/src/steps/copy/copy.test.js +1 -0
- package/src/steps/copy/index.js +2 -1
- package/src/steps/metadata/index.js +1 -0
- package/src/steps/metadata/metadata.test.js +8 -5
- package/src/steps/models/format.test.js +8 -7
- package/src/steps/models/write.test.js +11 -13
- package/src/steps/openspec/ensemble.js +30 -7
- package/src/steps/optimization/optimization.test.js +20 -0
- package/src/steps/platform/platform.test.js +1 -1
- package/src/steps/source/index.js +48 -0
- package/src/steps/source/source.test.js +38 -3
- package/src/utils/models-pricing.test.js +0 -1
package/src/steps/copy/agents.js
CHANGED
|
@@ -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
|
package/src/steps/copy/index.js
CHANGED
|
@@ -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[
|
|
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[
|
|
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[
|
|
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[
|
|
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('
|
|
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('
|
|
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('
|
|
71
|
-
expect(result[1].id).toBe('
|
|
72
|
-
expect(result[2].id).toBe('
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
90
|
-
Call team_results to read
|
|
91
|
-
|
|
92
|
-
|
|
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
|
})
|