opencode-onboard 0.3.3 → 0.4.1

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 (79) hide show
  1. package/README.md +266 -214
  2. package/content/.agents/agents/basic-engineer.md +30 -0
  3. package/content/.agents/agents/devops-manager.md +38 -29
  4. package/content/.agents/session-log.json +41 -0
  5. package/content/.agents/skills/ob-default/SKILL.md +21 -0
  6. package/content/.agents/skills/ob-generic-guardrails/SKILL.md +32 -0
  7. package/content/.agents/skills/ob-global/SKILL.md +49 -0
  8. package/content/.agents/skills/ob-pullrequest-az/SKILL.md +11 -21
  9. package/content/.agents/skills/ob-pullrequest-gh/SKILL.md +14 -24
  10. package/content/.agents/skills/ob-userstory-az/SKILL.md +8 -14
  11. package/content/.agents/skills/ob-userstory-gh/SKILL.md +6 -14
  12. package/content/.opencode/commands/opsx-apply.md +50 -33
  13. package/content/.opencode/plugins/session-log.js +1 -1
  14. package/content/.opencode/skills/openspec-apply-change/SKILL.md +50 -33
  15. package/content/AGENTS.md +94 -144
  16. package/content/skills-lock.json +4 -0
  17. package/package.json +6 -1
  18. package/src/index.js +13 -47
  19. package/src/presets/browser.json +18 -0
  20. package/src/presets/clean.json +21 -0
  21. package/src/presets/models.json +33 -0
  22. package/src/presets/optimization.json +22 -0
  23. package/src/presets/platforms.json +29 -2
  24. package/src/presets/quota.json +14 -0
  25. package/src/presets/source.json +17 -0
  26. package/src/steps/browser/browser.test.js +81 -0
  27. package/src/steps/{install-browser.js → browser/index.js} +12 -15
  28. package/src/steps/{__tests__/clean-ai-files.test.js → clean/clean.test.js} +28 -13
  29. package/src/steps/{clean-ai-files.js → clean/index.js} +32 -30
  30. package/src/steps/copy/agents.js +106 -0
  31. package/src/steps/{__tests__/copy-content.test.js → copy/copy.test.js} +10 -1
  32. package/src/steps/copy/index.js +33 -0
  33. package/src/steps/copy/skills.js +55 -0
  34. package/src/steps/{write-onboard-config.js → metadata/index.js} +3 -3
  35. package/src/steps/metadata/metadata.test.js +96 -0
  36. package/src/steps/models/format.js +60 -0
  37. package/src/steps/models/format.test.js +74 -0
  38. package/src/steps/models/index.js +52 -0
  39. package/src/steps/models/write.js +54 -0
  40. package/src/steps/models/write.test.js +119 -0
  41. package/src/steps/{init-openspec.js → openspec/ensemble.js} +20 -57
  42. package/src/steps/openspec/ensemble.test.js +79 -0
  43. package/src/steps/openspec/index.js +32 -0
  44. package/src/steps/optimization/caveman-guidance.js +11 -0
  45. package/src/steps/{install-caveman.js → optimization/caveman.js} +5 -19
  46. package/src/steps/optimization/global.js +64 -0
  47. package/src/steps/optimization/index.js +101 -0
  48. package/src/steps/{__tests__/token-optimization.test.js → optimization/optimization.test.js} +19 -24
  49. package/src/steps/{install-quota.js → optimization/quota.js} +12 -10
  50. package/src/steps/platform/index.js +81 -0
  51. package/src/steps/platform/platform.test.js +129 -0
  52. package/src/steps/{choose-source-scope.js → source/index.js} +11 -17
  53. package/src/steps/source/source.test.js +89 -0
  54. package/src/utils/__tests__/copy.test.js +12 -5
  55. package/src/utils/copy.js +4 -24
  56. package/src/utils/exec-spinner.js +47 -0
  57. package/src/utils/exec.js +120 -162
  58. package/src/utils/models-cache.js +25 -68
  59. package/src/utils/models-pricing.js +42 -0
  60. package/src/utils/models-pricing.test.js +94 -0
  61. package/content/.agents/agents/back-engineer.md +0 -87
  62. package/content/.agents/agents/front-engineer.md +0 -86
  63. package/content/.agents/agents/infra-engineer.md +0 -85
  64. package/content/.agents/agents/quality-engineer.md +0 -86
  65. package/content/.agents/agents/security-auditor.md +0 -86
  66. package/src/steps/__tests__/check-env.test.js +0 -70
  67. package/src/steps/__tests__/check-platform.test.js +0 -104
  68. package/src/steps/__tests__/check-rtk.test.js +0 -38
  69. package/src/steps/__tests__/choose-platform.test.js +0 -38
  70. package/src/steps/check-env.js +0 -26
  71. package/src/steps/check-platform.js +0 -80
  72. package/src/steps/check-rtk.js +0 -38
  73. package/src/steps/choose-models.js +0 -165
  74. package/src/steps/choose-platform.js +0 -22
  75. package/src/steps/choose-skills-provider.js +0 -79
  76. package/src/steps/copy-content.js +0 -89
  77. package/src/steps/enable-caveman-guidance.js +0 -78
  78. package/src/steps/patch-agents-md.js +0 -153
  79. package/src/steps/token-optimization.js +0 -59
package/src/index.js CHANGED
@@ -3,23 +3,15 @@ import chalk from 'chalk'
3
3
  import fse from 'fs-extra'
4
4
  import { createRequire } from 'node:module'
5
5
  import path from 'node:path'
6
- import { checkEnv } from './steps/check-env.js'
7
- import { checkPlatform } from './steps/check-platform.js'
8
- import { checkRtk } from './steps/check-rtk.js'
9
- import { chooseModels } from './steps/choose-models.js'
10
- import { choosePlatform } from './steps/choose-platform.js'
11
- import { chooseSkillsProvider } from './steps/choose-skills-provider.js'
12
- import { chooseSourceScope } from './steps/choose-source-scope.js'
13
- import { cleanAiFiles } from './steps/clean-ai-files.js'
14
- import { copyContentStep } from './steps/copy-content.js'
15
- import { enableCavemanGuidance } from './steps/enable-caveman-guidance.js'
16
- import { initOpenspec } from './steps/init-openspec.js'
17
- import { installBrowser } from './steps/install-browser.js'
18
- import { installCaveman } from './steps/install-caveman.js'
19
- import { installQuota } from './steps/install-quota.js'
20
- import { patchAgentsMd } from './steps/patch-agents-md.js'
21
- import { tokenOptimizationStep } from './steps/token-optimization.js'
22
- import { writeOnboardConfig } from './steps/write-onboard-config.js'
6
+ import { installBrowser } from './steps/browser/index.js'
7
+ import { cleanAiFiles } from './steps/clean/index.js'
8
+ import { copyContentStep } from './steps/copy/index.js'
9
+ import { chooseModels } from './steps/models/index.js'
10
+ import { initOpenspec } from './steps/openspec/index.js'
11
+ import { tokenOptimizationStep } from './steps/optimization/index.js'
12
+ import { choosePlatform } from './steps/platform/index.js'
13
+ import { chooseSourceScope } from './steps/source/index.js'
14
+ import { writeOnboardConfig } from './steps/metadata/index.js'
23
15
 
24
16
  function printHelp(version) {
25
17
  console.log(`opencode-onboard v${version}`)
@@ -33,12 +25,8 @@ function printHelp(version) {
33
25
  console.log(' platform Run platform selection step')
34
26
  console.log(' copy Run content copy step')
35
27
  console.log(' openspec Run OpenSpec initialization step')
36
- console.log(' skills Run skills install step')
37
28
  console.log(' models Run models selection step')
38
29
  console.log(' optimization Run token optimization tools step')
39
- console.log(' quota Run opencode-quota installer step')
40
- console.log(' rtk Run rtk check step')
41
- console.log(' caveman Run caveman install + guidance steps')
42
30
  console.log(' browser Run opencode-browser installer step')
43
31
  console.log(' metadata Write onboarding metadata step')
44
32
  console.log()
@@ -78,29 +66,15 @@ async function runSingleCommand(command) {
78
66
  },
79
67
  copy: async () => {
80
68
  await copyContentStep(resolvedPlatform, ctx)
81
- await patchAgentsMd(ctx)
82
69
  },
83
70
  openspec: async () => {
84
71
  await initOpenspec()
85
72
  },
86
- skills: async () => {
87
- await chooseSkillsProvider()
88
- },
89
73
  models: async () => {
90
74
  await chooseModels()
91
75
  },
92
76
  optimization: async () => {
93
- await tokenOptimizationStep({ skillsProvider: savedWizard?.additionalSkillsProvider })
94
- },
95
- quota: async () => {
96
- await installQuota()
97
- },
98
- rtk: async () => {
99
- await checkRtk()
100
- },
101
- caveman: async () => {
102
- const caveman = await installCaveman({ skillsProvider: savedWizard?.additionalSkillsProvider })
103
- await enableCavemanGuidance(caveman)
77
+ await tokenOptimizationStep({ ctx })
104
78
  },
105
79
  browser: async () => {
106
80
  await installBrowser()
@@ -109,7 +83,7 @@ async function runSingleCommand(command) {
109
83
  await writeOnboardConfig({
110
84
  ...ctx,
111
85
  platform: resolvedPlatform,
112
- additionalSkillsProvider: savedWizard?.additionalSkillsProvider ?? 'none',
86
+ additionalSkillsProvider: 'npx-skills',
113
87
  planModel: savedWizard?.models?.plan ?? null,
114
88
  buildModel: savedWizard?.models?.build ?? null,
115
89
  fastModel: savedWizard?.models?.fast ?? null,
@@ -186,28 +160,20 @@ if (process.stdin.isTTY) {
186
160
  }
187
161
 
188
162
  try {
189
- await checkEnv()
190
-
191
163
  const scope = await chooseSourceScope()
192
164
 
193
165
  const preserve = await cleanAiFiles()
194
166
  const ctx = { ...preserve, ...scope }
195
167
 
196
168
  const platform = await choosePlatform()
197
-
198
- await checkPlatform(platform)
199
169
 
200
170
  await copyContentStep(platform, ctx)
201
171
 
202
- await patchAgentsMd(ctx)
203
-
204
172
  await initOpenspec()
205
173
 
206
- const skillsSelection = await chooseSkillsProvider()
207
-
208
174
  const selectedModels = await chooseModels()
209
175
 
210
- const tokenOpt = await tokenOptimizationStep({ skillsProvider: skillsSelection.additionalSkillsProvider })
176
+ const tokenOpt = await tokenOptimizationStep({ ctx })
211
177
  const { rtk, quota, caveman, cavemanGuidance } = tokenOpt
212
178
 
213
179
  await installBrowser()
@@ -215,7 +181,7 @@ try {
215
181
  await writeOnboardConfig({
216
182
  ...ctx,
217
183
  platform,
218
- ...skillsSelection,
184
+ additionalSkillsProvider: 'npx-skills',
219
185
  ...selectedModels,
220
186
  optionalTools: { rtk, quota, caveman },
221
187
  cavemanGuidance,
@@ -0,0 +1,18 @@
1
+ {
2
+ "installer": {
3
+ "command": "npx",
4
+ "args": ["@different-ai/opencode-browser", "install"]
5
+ },
6
+ "output": {
7
+ "showAfter": "To load the extension",
8
+ "hideAfter": "Press Enter when"
9
+ },
10
+ "autoAnswers": [
11
+ { "trigger": "Press Enter when", "response": "" },
12
+ { "trigger": "Choose config location", "response": "2" },
13
+ { "trigger": "Add plugin automatically?", "response": "y" },
14
+ { "trigger": "Create one?", "response": "y" },
15
+ { "trigger": "Add browser-automation skill", "response": "n" },
16
+ { "trigger": "Check broker", "response": "n" }
17
+ ]
18
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "preserve": ["DESIGN.md", "ARCHITECTURE.md", "openspec"],
3
+ "detectFiles": [
4
+ "AGENTS.md",
5
+ "CLAUDE.md",
6
+ "ARCHITECTURE.md",
7
+ "DESIGN.md",
8
+ ".cursorrules",
9
+ ".clinerules",
10
+ ".windsurfrules",
11
+ ".github/copilot-instructions.md",
12
+ "copilot-instructions.md",
13
+ ".aider.conf.yml",
14
+ ".aider",
15
+ ".opencode",
16
+ ".agents"
17
+ ],
18
+ "directoryTargets": [".opencode", ".agents"],
19
+ "preserveSubfolders": ["skills"],
20
+ "selectionMessage": "Select AI config files to remove:"
21
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "costTiers": [
3
+ { "max": 1, "label": "[$]" },
4
+ { "max": 10, "label": "[$$]" },
5
+ { "label": "[$$$]" }
6
+ ],
7
+ "roles": {
8
+ "plan": {
9
+ "prompt": "Plan model:",
10
+ "info": [
11
+ "PLAN model: used by the main agent to read issues, write proposals, coordinate the team.",
12
+ "This model needs to be strong. Use Claude Sonnet/Opus, GPT-4o, o3, or equivalent.",
13
+ "A weak model here will silently skip steps and break the workflow."
14
+ ]
15
+ },
16
+ "build": {
17
+ "prompt": "Build model:",
18
+ "info": [
19
+ "BUILD model: used by front-engineer, back-engineer, infra-engineer, quality-engineer, security-auditor.",
20
+ "Needs to be capable for implementation work. Claude Sonnet, GPT-4o, or equivalent."
21
+ ],
22
+ "agents": ["front-engineer", "back-engineer", "infra-engineer", "quality-engineer", "security-auditor"]
23
+ },
24
+ "fast": {
25
+ "prompt": "Fast model:",
26
+ "info": [
27
+ "FAST model: used by devops-manager for reading issues and classifying PR comments.",
28
+ "Something fast and cheap is fine here, no heavy reasoning needed."
29
+ ],
30
+ "agents": ["devops-manager"]
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "message": "Enable tools:",
3
+ "info": "Choose which optimization tools to enable (recommended: all).",
4
+ "timeoutMs": 30000,
5
+ "choices": [
6
+ {
7
+ "name": "RTK check (recommended)",
8
+ "value": "rtk",
9
+ "checked": true
10
+ },
11
+ {
12
+ "name": "opencode-quota plugin (recommended)",
13
+ "value": "quota",
14
+ "checked": true
15
+ },
16
+ {
17
+ "name": "caveman concise mode (recommended)",
18
+ "value": "caveman",
19
+ "checked": true
20
+ }
21
+ ]
22
+ }
@@ -1,10 +1,37 @@
1
1
  [
2
2
  {
3
3
  "value": "github",
4
- "name": "GitHub"
4
+ "name": "GitHub",
5
+ "cli": {
6
+ "command": "gh",
7
+ "displayName": "GitHub CLI (gh)",
8
+ "installUrl": "https://cli.github.com",
9
+ "authCheck": {
10
+ "args": ["auth", "status"],
11
+ "notAuthenticatedMessage": "GitHub CLI not authenticated. Run:",
12
+ "commands": ["gh auth login"]
13
+ }
14
+ }
5
15
  },
6
16
  {
7
17
  "value": "azure",
8
- "name": "Azure DevOps"
18
+ "name": "Azure DevOps",
19
+ "cli": {
20
+ "command": "az",
21
+ "displayName": "Azure CLI (az)",
22
+ "installUrl": "https://learn.microsoft.com/en-us/cli/azure/install-azure-cli",
23
+ "extensionCheck": {
24
+ "args": ["extension", "list", "--query", "[?name=='azure-devops']", "-o", "tsv"],
25
+ "expectedOutput": "azure-devops",
26
+ "missingMessage": "azure-devops extension not found. Run:",
27
+ "errorMessage": "Could not check azure-devops extension. Run:",
28
+ "commands": [
29
+ "az extension add --name azure-devops",
30
+ "az config set extension.dynamic_install_allow_preview=true",
31
+ "az login",
32
+ "az devops login --organization https://dev.azure.com/<your-org>"
33
+ ]
34
+ }
35
+ }
9
36
  }
10
37
  ]
@@ -0,0 +1,14 @@
1
+ {
2
+ "plugin": "@slkiser/opencode-quota@latest",
3
+ "prompt": {
4
+ "message": "Install opencode-quota with recommended defaults?",
5
+ "default": true,
6
+ "timeoutMs": 20000
7
+ },
8
+ "defaults": {
9
+ "enabledProviders": "auto",
10
+ "formatStyle": "singleWindow",
11
+ "percentDisplayMode": "used",
12
+ "showSessionTokens": true
13
+ }
14
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "message": "Source code location:",
3
+ "default": "current",
4
+ "choices": [
5
+ {
6
+ "name": "Current folder (default)",
7
+ "value": "current",
8
+ "description": "Use this repository only"
9
+ },
10
+ {
11
+ "name": "Select folders in parent (../)",
12
+ "value": "parent",
13
+ "description": "Use when this repo only contains agent config"
14
+ }
15
+ ],
16
+ "parentSelectionMessage": "Select source folders from parent directory:"
17
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ header: vi.fn(),
5
+ info: vi.fn(),
6
+ success: vi.fn(),
7
+ warn: vi.fn(),
8
+ error: vi.fn(),
9
+ }))
10
+
11
+ vi.mock('fs-extra', () => ({
12
+ default: {
13
+ readJson: vi.fn().mockResolvedValue({
14
+ installer: { command: 'npx', args: ['@different-ai/opencode-browser', 'install'] },
15
+ output: { showAfter: '===', hideAfter: '===' },
16
+ autoAnswers: [
17
+ { trigger: 'Install', response: 'y' },
18
+ ],
19
+ }),
20
+ },
21
+ }))
22
+
23
+ vi.mock('execa', () => ({
24
+ execa: vi.fn(),
25
+ }))
26
+
27
+ import fse from 'fs-extra'
28
+ import { installBrowser } from './index.js'
29
+
30
+ describe('installBrowser()', () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks()
33
+ })
34
+
35
+ it('calls installer command from preset', async () => {
36
+ const { execa } = await import('execa')
37
+ const mockChild = {
38
+ stdout: { on: vi.fn() },
39
+ stderr: { on: vi.fn() },
40
+ stdin: { write: vi.fn() },
41
+ then: (cb) => cb({ exitCode: 0 }),
42
+ }
43
+ execa.mockReturnValue(mockChild)
44
+
45
+ await installBrowser()
46
+
47
+ expect(execa).toHaveBeenCalledWith('npx', expect.arrayContaining('@different-ai/opencode-browser'), expect.any(Object))
48
+ })
49
+
50
+ it('logs success when exit code is 0', async () => {
51
+ const { execa } = await import('execa')
52
+ const mockChild = {
53
+ stdout: { on: vi.fn() },
54
+ stderr: { on: vi.fn() },
55
+ stdin: { write: vi.fn() },
56
+ then: (cb) => cb({ exitCode: 0 }),
57
+ }
58
+ execa.mockReturnValue(mockChild)
59
+ const { success } = await import('../../utils/exec.js')
60
+
61
+ await installBrowser()
62
+
63
+ expect(success).toHaveBeenCalledWith('opencode-browser installed')
64
+ })
65
+
66
+ it('logs warning when exit code is non-zero', async () => {
67
+ const { execa } = await import('execa')
68
+ const mockChild = {
69
+ stdout: { on: vi.fn() },
70
+ stderr: { on: vi.fn() },
71
+ stdin: { write: vi.fn() },
72
+ then: (cb) => cb({ exitCode: 1 }),
73
+ }
74
+ execa.mockReturnValue(mockChild)
75
+ const { warn } = await import('../../utils/exec.js')
76
+
77
+ await installBrowser()
78
+
79
+ expect(warn).toHaveBeenCalledWith('opencode-browser install exited with non-zero code')
80
+ })
81
+ })
@@ -1,35 +1,32 @@
1
1
  import { execa } from 'execa'
2
- import { header, info, success, warn, error } from '../utils/exec.js'
2
+ import fse from 'fs-extra'
3
+ import { header, info, success, warn, error } from '../../utils/exec.js'
3
4
  import os from 'os'
5
+ import path from 'path'
6
+ import { fileURLToPath } from 'url'
4
7
 
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
- ]
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const BROWSER_PRESET_PATH = path.resolve(__dirname, '../../presets/browser.json')
10
+ const browserPreset = await fse.readJson(BROWSER_PRESET_PATH)
13
11
 
14
12
  export async function installBrowser() {
15
- header('Step 11, Installing opencode-browser')
13
+ header('Step 9, Installing opencode-browser')
16
14
 
17
15
  try {
18
- const child = execa('npx', ['@different-ai/opencode-browser', 'install'], {
16
+ const child = execa(browserPreset.installer.command, browserPreset.installer.args, {
19
17
  cwd: os.homedir(),
20
18
  stdio: ['pipe', 'pipe', 'pipe'],
21
19
  reject: false,
22
20
  })
23
21
 
24
- const pendingTriggers = [...AUTO_ANSWERS]
22
+ const pendingTriggers = [...browserPreset.autoAnswers]
25
23
  let show = false
26
24
 
27
25
  child.stdout.on('data', (chunk) => {
28
26
  const text = chunk.toString()
29
27
 
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
28
+ if (text.includes(browserPreset.output.showAfter)) show = true
29
+ if (text.includes(browserPreset.output.hideAfter)) show = false
33
30
 
34
31
  if (show) process.stdout.write(chunk)
35
32
 
@@ -3,6 +3,10 @@ import fse from 'fs-extra'
3
3
  import os from 'os'
4
4
  import path from 'path'
5
5
 
6
+ vi.mock('@inquirer/prompts', () => ({
7
+ checkbox: vi.fn(),
8
+ }))
9
+
6
10
  vi.mock('../../utils/exec.js', () => ({
7
11
  header: vi.fn(),
8
12
  success: vi.fn(),
@@ -11,7 +15,8 @@ vi.mock('../../utils/exec.js', () => ({
11
15
  prompt: vi.fn(),
12
16
  }))
13
17
 
14
- import { success, warn } from '../../utils/exec.js'
18
+ import { success } from '../../utils/exec.js'
19
+ import { checkbox } from '@inquirer/prompts'
15
20
 
16
21
  describe('cleanAiFiles()', () => {
17
22
  let tmpDir
@@ -31,24 +36,22 @@ describe('cleanAiFiles()', () => {
31
36
  })
32
37
 
33
38
  it('prints success when no AI files are found', async () => {
34
- const { cleanAiFiles } = await import('../clean-ai-files.js')
35
-
36
- // Simulate immediate Enter key
37
- const stdinPush = () => process.stdin.emit('data', '\n')
38
- setTimeout(stdinPush, 10)
39
+ const { cleanAiFiles } = await import('./index.js')
39
40
 
40
41
  await cleanAiFiles()
41
42
 
42
43
  expect(success).toHaveBeenCalledWith('No existing AI config files to remove')
43
44
  })
44
45
 
45
- it('removes found AI files after Enter', async () => {
46
+ it('removes selected AI files', async () => {
46
47
  await fse.writeFile(path.join(tmpDir, 'AGENTS.md'), '# agents')
47
48
  await fse.writeFile(path.join(tmpDir, 'CLAUDE.md'), '# claude')
49
+ checkbox.mockResolvedValue([
50
+ path.join(tmpDir, 'AGENTS.md'),
51
+ path.join(tmpDir, 'CLAUDE.md'),
52
+ ])
48
53
 
49
- const { cleanAiFiles } = await import('../clean-ai-files.js')
50
-
51
- setTimeout(() => process.stdin.emit('data', '\n'), 10)
54
+ const { cleanAiFiles } = await import('./index.js')
52
55
 
53
56
  await cleanAiFiles()
54
57
 
@@ -57,16 +60,28 @@ describe('cleanAiFiles()', () => {
57
60
  expect(success).toHaveBeenCalledWith('Removed existing AI config files')
58
61
  })
59
62
 
63
+ it('keeps unselected AI files', async () => {
64
+ await fse.writeFile(path.join(tmpDir, 'AGENTS.md'), '# agents')
65
+ await fse.writeFile(path.join(tmpDir, 'CLAUDE.md'), '# claude')
66
+ checkbox.mockResolvedValue([path.join(tmpDir, 'AGENTS.md')])
67
+
68
+ const { cleanAiFiles } = await import('./index.js')
69
+
70
+ await cleanAiFiles()
71
+
72
+ expect(await fse.pathExists(path.join(tmpDir, 'AGENTS.md'))).toBe(false)
73
+ expect(await fse.pathExists(path.join(tmpDir, 'CLAUDE.md'))).toBe(true)
74
+ })
75
+
60
76
  it('removes .agents sub-entries but preserves .agents/skills', async () => {
61
77
  const agentsDir = path.join(tmpDir, '.agents')
62
78
  await fse.ensureDir(path.join(agentsDir, 'agents'))
63
79
  await fse.ensureDir(path.join(agentsDir, 'skills', 'my-skill'))
64
80
  await fse.writeFile(path.join(agentsDir, 'agents', 'front-engineer.md'), 'agent')
65
81
  await fse.writeFile(path.join(agentsDir, 'skills', 'my-skill', 'SKILL.md'), 'skill')
82
+ checkbox.mockResolvedValue([path.join(agentsDir, 'agents')])
66
83
 
67
- const { cleanAiFiles } = await import('../clean-ai-files.js')
68
-
69
- setTimeout(() => process.stdin.emit('data', '\n'), 10)
84
+ const { cleanAiFiles } = await import('./index.js')
70
85
 
71
86
  await cleanAiFiles()
72
87
 
@@ -1,43 +1,34 @@
1
+ import { checkbox } from '@inquirer/prompts'
1
2
  import fse from 'fs-extra'
2
3
  import path from 'path'
3
- import { findAiFiles } from '../utils/copy.js'
4
- import { header, info, success, warn } from '../utils/exec.js'
4
+ import { fileURLToPath } from 'url'
5
+ import { findAiFiles } from '../../utils/copy.js'
6
+ import { header, info, success, warn } from '../../utils/exec.js'
5
7
 
6
- // Files/dirs that are valuable pre-existing work, never removed
7
- const PRESERVE = ['DESIGN.md', 'ARCHITECTURE.md', 'openspec']
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const CLEAN_PRESET_PATH = path.resolve(__dirname, '../../presets/clean.json')
10
+ const cleanPreset = await fse.readJson(CLEAN_PRESET_PATH)
8
11
 
9
- /**
10
- * Enumerate immediate children of a directory.
11
- * Skips 'skills' to preserve user-installed skills.
12
- */
13
- async function childrenExcludingSkills(dir) {
12
+ async function childrenExcludingPreserved(dir) {
14
13
  const results = []
15
14
  if (!await fse.pathExists(dir)) return results
16
15
  const entries = await fse.readdir(dir)
17
16
  for (const entry of entries) {
18
- if (entry === 'skills') continue
17
+ if (cleanPreset.preserveSubfolders.includes(entry)) continue
19
18
  results.push(path.join(dir, entry))
20
19
  }
21
20
  return results
22
21
  }
23
22
 
24
- /**
25
- * Returns true if the file exists and has real content (not empty, not a prompt template).
26
- * Prompt templates contain a specific marker written by the onboard CLI.
27
- */
28
23
  async function isPopulated(filePath) {
29
24
  if (!await fse.pathExists(filePath)) return false
30
25
  const content = await fse.readFile(filePath, 'utf-8')
31
26
  const trimmed = content.trim()
32
27
  if (!trimmed) return false
33
- // DESIGN.md and ARCHITECTURE.md shipped as prompt templates contain this marker
34
28
  if (trimmed.startsWith('<!-- onboard-prompt')) return false
35
29
  return true
36
30
  }
37
31
 
38
- /**
39
- * Returns true if openspec/ exists and has at least one change or archive entry.
40
- */
41
32
  async function hasOpenspecHistory(cwd) {
42
33
  const changesDir = path.join(cwd, 'openspec', 'changes')
43
34
  const archiveDir = path.join(cwd, 'openspec', 'archive')
@@ -53,11 +44,9 @@ async function hasOpenspecHistory(cwd) {
53
44
  }
54
45
 
55
46
  export async function cleanAiFiles() {
56
- header('Step 3, Existing AI config files')
47
+ header('Step 2, Existing AI config files')
57
48
 
58
49
  const cwd = process.cwd()
59
-
60
- // Detect what should be preserved before touching anything
61
50
  const ctx = {
62
51
  hasDesign: await isPopulated(path.join(cwd, 'DESIGN.md')),
63
52
  hasArchitecture: await isPopulated(path.join(cwd, 'ARCHITECTURE.md')),
@@ -68,22 +57,19 @@ export async function cleanAiFiles() {
68
57
  if (ctx.hasArchitecture) info('ARCHITECTURE.md exists and is populated, keeping it')
69
58
  if (ctx.hasOpenspec) info('openspec/ history exists, keeping it')
70
59
 
71
- // Build the list of files to remove
72
- const flatFiles = await findAiFiles(cwd)
73
- const dirTargets = ['.opencode', '.agents']
60
+ const flatFiles = await findAiFiles(cwd, cleanPreset.detectFiles)
61
+ const dirTargets = cleanPreset.directoryTargets
74
62
  const dirEntries = []
75
63
  for (const dirName of dirTargets) {
76
64
  const dirPath = path.join(cwd, dirName)
77
- const children = await childrenExcludingSkills(dirPath)
65
+ const children = await childrenExcludingPreserved(dirPath)
78
66
  dirEntries.push(...children)
79
67
  }
80
68
 
81
- // Remove directory targets themselves from flat list (handled via children)
82
- // Also remove any preserved entries
83
69
  const filteredFlat = flatFiles.filter(f => {
84
70
  const rel = path.relative(cwd, f)
85
71
  if (dirTargets.includes(rel)) return false
86
- if (PRESERVE.some(p => rel === p || rel.startsWith(p + path.sep))) return false
72
+ if (cleanPreset.preserve.some(p => rel === p || rel.startsWith(p + path.sep))) return false
87
73
  return true
88
74
  })
89
75
 
@@ -94,8 +80,24 @@ export async function cleanAiFiles() {
94
80
  return ctx
95
81
  }
96
82
 
97
- warn('Removing existing AI config files:')
98
- for (const f of allToRemove) {
83
+ const choices = allToRemove.map(f => ({
84
+ name: path.relative(cwd, f).replace(/\\/g, '/'),
85
+ value: f,
86
+ checked: true,
87
+ }))
88
+
89
+ const selected = await checkbox({
90
+ message: cleanPreset.selectionMessage,
91
+ choices,
92
+ })
93
+
94
+ if (!selected || selected.length === 0) {
95
+ success('No AI config files selected for removal')
96
+ return ctx
97
+ }
98
+
99
+ warn('Removing selected AI config files:')
100
+ for (const f of selected) {
99
101
  info(' ' + f.replace(cwd + path.sep, ''))
100
102
  await fse.remove(f)
101
103
  }