infra-kit 0.1.111 → 0.1.114

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 (99) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-0d1515ce-4ebe-4ce8-a865-2897f68de168.jsonl +1 -0
  3. package/.omc/state/agent-replay-127837d3-a000-4656-9453-2324ab353723.jsonl +5 -0
  4. package/.omc/state/agent-replay-15c409af-24fa-4026-88d3-a82dd1e68b87.jsonl +11 -0
  5. package/.omc/state/agent-replay-74afa912-1443-4011-b4f2-db9f055cdd9f.jsonl +3 -0
  6. package/.omc/state/agent-replay-8dd0ad2f-3a90-4158-b9de-ec8e19a7c983.jsonl +3 -0
  7. package/.omc/state/agent-replay-f99d8dad-81d1-4987-8183-62edf0843d0c.jsonl +2 -0
  8. package/.omc/state/idle-notif-cooldown.json +1 -1
  9. package/.omc/state/sessions/0d1515ce-4ebe-4ce8-a865-2897f68de168/pre-tool-advisory-throttle.json +10 -0
  10. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/mission-state.json +61 -0
  11. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/pre-tool-advisory-throttle.json +22 -0
  12. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/skill-active-state.json +15 -0
  13. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/subagent-tracking-state.json +26 -0
  14. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/last-tool-error-state.json +7 -0
  15. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/mission-state.json +89 -0
  16. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/pre-tool-advisory-throttle.json +34 -0
  17. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/ralph-state.json +13 -0
  18. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/skill-active-state.json +15 -0
  19. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/subagent-tracking-state.json +44 -0
  20. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/ultrawork-state.json +11 -0
  21. package/.omc/state/sessions/560ea519-574a-4713-b134-5bf6cfaf3c33/pre-tool-advisory-throttle.json +18 -0
  22. package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/pre-tool-advisory-throttle.json +18 -0
  23. package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/skill-active-state.json +15 -0
  24. package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/subagent-tracking-state.json +17 -0
  25. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +14 -0
  26. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ralph-state.json +13 -0
  27. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/subagent-tracking-state.json +17 -0
  28. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ultrawork-state.json +11 -0
  29. package/.omc/state/sessions/f99d8dad-81d1-4987-8183-62edf0843d0c/pre-tool-advisory-throttle.json +14 -0
  30. package/.omc/state/sessions/f99d8dad-81d1-4987-8183-62edf0843d0c/ralph-state.json +13 -0
  31. package/.omc/state/sessions/f99d8dad-81d1-4987-8183-62edf0843d0c/ultrawork-state.json +11 -0
  32. package/.turbo/turbo-build.log +3 -4
  33. package/.turbo/turbo-eslint-check.log +1 -2
  34. package/.turbo/turbo-prettier-check.log +1 -2
  35. package/.turbo/turbo-prettier-fix.log +1 -2
  36. package/.turbo/turbo-test.log +25 -18
  37. package/.turbo/turbo-ts-check.log +1 -2
  38. package/dist/cli.js +62 -54
  39. package/dist/cli.js.map +4 -4
  40. package/dist/mcp.js +36 -36
  41. package/dist/mcp.js.map +4 -4
  42. package/package.json +1 -1
  43. package/src/.omc/state/agent-replay-8dd0ad2f-3a90-4158-b9de-ec8e19a7c983.jsonl +11 -0
  44. package/src/.omc/state/idle-notif-cooldown.json +3 -0
  45. package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/mission-state.json +79 -0
  46. package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +22 -0
  47. package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ralph-state.json +13 -0
  48. package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/subagent-tracking-state.json +26 -0
  49. package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ultrawork-state.json +11 -0
  50. package/src/.omc/state/sessions/f99d8dad-81d1-4987-8183-62edf0843d0c/pre-tool-advisory-throttle.json +14 -0
  51. package/src/commands/doctor/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +14 -0
  52. package/src/commands/doctor/__tests__/check-ide-installed.test.ts +142 -0
  53. package/src/commands/doctor/doctor.ts +104 -1
  54. package/src/commands/gh-release-deliver/gh-release-deliver.ts +32 -1
  55. package/src/commands/init/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +18 -0
  56. package/src/commands/init/__tests__/migrate-config.test.ts +132 -1
  57. package/src/commands/init/init.ts +19 -6
  58. package/src/commands/init/migrate-config.ts +104 -0
  59. package/src/commands/worktrees-add/index.ts +1 -2
  60. package/src/commands/worktrees-add/worktrees-add.ts +24 -60
  61. package/src/commands/worktrees-open/worktrees-open.ts +55 -68
  62. package/src/commands/worktrees-reload/__tests__/reload-cmux.test.ts +119 -0
  63. package/src/commands/worktrees-reload/index.ts +1 -0
  64. package/src/commands/worktrees-reload/worktrees-reload.ts +229 -0
  65. package/src/commands/worktrees-remove/worktrees-remove.ts +2 -44
  66. package/src/commands/worktrees-sync/worktrees-sync.ts +2 -44
  67. package/src/entry/cli.ts +32 -11
  68. package/src/integrations/cursor/.omc/state/agent-replay-f99d8dad-81d1-4987-8183-62edf0843d0c.jsonl +2 -0
  69. package/src/integrations/cursor/.omc/state/idle-notif-cooldown.json +3 -0
  70. package/src/integrations/cursor/.omc/state/sessions/f99d8dad-81d1-4987-8183-62edf0843d0c/pre-tool-advisory-throttle.json +18 -0
  71. package/src/integrations/cursor/.omc/state/sessions/f99d8dad-81d1-4987-8183-62edf0843d0c/subagent-tracking-state.json +17 -0
  72. package/src/integrations/cursor/index.ts +2 -0
  73. package/src/integrations/cursor/launch-cursor.ts +12 -0
  74. package/src/integrations/cursor/open-cursor-workspace.ts +69 -0
  75. package/src/integrations/ide/__tests__/ide-facade.test.ts +250 -0
  76. package/src/integrations/ide/add-ide-worktree-folders.ts +77 -0
  77. package/src/integrations/ide/index.ts +6 -0
  78. package/src/integrations/ide/open-ide-workspace.ts +51 -0
  79. package/src/integrations/ide/provider-label.ts +24 -0
  80. package/src/integrations/ide/remove-ide-worktree-folders.ts +80 -0
  81. package/src/integrations/ide/types.ts +32 -0
  82. package/src/integrations/zed/__tests__/open-zed-workspace.test.ts +85 -0
  83. package/src/integrations/zed/add-folders-to-zed-workspace.ts +30 -0
  84. package/src/integrations/zed/index.ts +2 -0
  85. package/src/integrations/zed/open-zed-workspace.ts +57 -0
  86. package/src/lib/assert-never/assert-never.ts +15 -0
  87. package/src/lib/assert-never/index.ts +1 -0
  88. package/src/lib/git-utils/__tests__/git-primitives.test.ts +122 -1
  89. package/src/lib/git-utils/git-utils.ts +35 -0
  90. package/src/lib/git-utils/index.ts +2 -0
  91. package/src/lib/infra-kit-config/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +18 -0
  92. package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +172 -9
  93. package/src/lib/infra-kit-config/index.ts +2 -1
  94. package/src/lib/infra-kit-config/infra-kit-config.ts +80 -22
  95. package/src/lib/package-config/.omc/state/sessions/dde22a92-a0e9-4948-905d-25c9a812ab35/last-tool-error-state.json +7 -0
  96. package/src/lib/package-config/.omc/state/sessions/dde22a92-a0e9-4948-905d-25c9a812ab35/pre-tool-advisory-throttle.json +10 -0
  97. package/src/lib/worktrees/remove-worktrees.ts +15 -0
  98. package/src/mcp/tools/index.ts +2 -0
  99. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,30 @@
1
+ import { $ } from 'zx'
2
+
3
+ interface AddFoldersToZedWorkspaceArgs {
4
+ folderPaths: string[]
5
+ }
6
+
7
+ interface AddFoldersToZedWorkspaceResult {
8
+ added: string[]
9
+ }
10
+
11
+ /**
12
+ * Adds folders to Zed's current workspace via `zed --add <path>` per folder.
13
+ * This is the only legitimate use of `--add` (it targets the most-recent
14
+ * workspace, so it is reserved for the incremental add-only path right after
15
+ * `worktrees-add` creates new worktrees — never for the full cold-start open).
16
+ *
17
+ * Zed has no folder-remove CLI and no skip-if-present concept, so every folder
18
+ * is reported as added (no `skipped` set, unlike the Cursor `.code-workspace`).
19
+ */
20
+ export const addFoldersToZedWorkspace = async (
21
+ args: AddFoldersToZedWorkspaceArgs,
22
+ ): Promise<AddFoldersToZedWorkspaceResult> => {
23
+ const { folderPaths } = args
24
+
25
+ for (const folderPath of folderPaths) {
26
+ await $`zed --add ${folderPath}`
27
+ }
28
+
29
+ return { added: folderPaths }
30
+ }
@@ -0,0 +1,2 @@
1
+ export { addFoldersToZedWorkspace } from './add-folders-to-zed-workspace'
2
+ export { openZedWorkspace } from './open-zed-workspace'
@@ -0,0 +1,57 @@
1
+ import { $ } from 'zx'
2
+
3
+ import { logger } from 'src/lib/logger'
4
+
5
+ interface OpenZedWorkspaceArgs {
6
+ projectRoot: string
7
+ worktreeDir: string
8
+ currentBranches: string[]
9
+ /**
10
+ * When true, skip launching Zed if there are no release worktrees. Lets
11
+ * `worktrees-reload` avoid popping a bare Zed window while keeping the
12
+ * cold-start `worktrees-open` behaviour (launch unconditionally) intact.
13
+ */
14
+ skipRelaunchWhenEmpty: boolean
15
+ }
16
+
17
+ interface OpenZedWorkspaceOutcome {
18
+ ran: boolean
19
+ added: number
20
+ removed: number
21
+ }
22
+
23
+ /**
24
+ * Opens a single Zed workspace containing the project root plus every release
25
+ * worktree, via one `zed <root> <wt...>` invocation (Zed realizes a multi-folder
26
+ * workspace from multiple path arguments). Shared by `worktrees-open` (cold-start
27
+ * restore) and `worktrees-reload` (relaunch).
28
+ *
29
+ * Zed has no portable workspace file and no folder-remove CLI, so there is
30
+ * nothing to reconcile: `added` is the number of worktrees opened and `removed`
31
+ * is always 0. All failures are swallowed into a warning — opening Zed is
32
+ * best-effort, mirroring the Cursor integration.
33
+ */
34
+ export const openZedWorkspace = async (args: OpenZedWorkspaceArgs): Promise<OpenZedWorkspaceOutcome> => {
35
+ const { projectRoot, worktreeDir, currentBranches, skipRelaunchWhenEmpty } = args
36
+
37
+ if (skipRelaunchWhenEmpty && currentBranches.length === 0) {
38
+ return { ran: false, added: 0, removed: 0 }
39
+ }
40
+
41
+ const paths = [
42
+ projectRoot,
43
+ ...currentBranches.map((branch) => {
44
+ return `${worktreeDir}/${branch}`
45
+ }),
46
+ ]
47
+
48
+ try {
49
+ await $`zed ${paths}`
50
+
51
+ return { ran: true, added: currentBranches.length, removed: 0 }
52
+ } catch (error) {
53
+ logger.warn({ error }, '⚠️ Failed to open Zed workspace')
54
+
55
+ return { ran: false, added: 0, removed: 0 }
56
+ }
57
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Exhaustiveness guard for discriminated unions. Place in the `default` branch of
3
+ * a `switch` over a union: once every case is handled the argument narrows to
4
+ * `never`, so adding a new variant later turns this into a compile error.
5
+ *
6
+ * @example
7
+ * switch (provider) {
8
+ * case 'cursor': return openCursor()
9
+ * case 'zed': return openZed()
10
+ * default: return assertNever(provider) // compile error if a provider is unhandled
11
+ * }
12
+ */
13
+ export const assertNever = (value: never): never => {
14
+ throw new Error(`Unexpected value: ${JSON.stringify(value)}`)
15
+ }
@@ -0,0 +1 @@
1
+ export { assertNever } from './assert-never'
@@ -1,6 +1,12 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
- import { getCurrentBranch, isInsideLinkedWorktree, isWorkingTreeClean } from 'src/lib/git-utils'
3
+ import {
4
+ deleteLocalBranch,
5
+ deleteRemoteBranch,
6
+ getCurrentBranch,
7
+ isInsideLinkedWorktree,
8
+ isWorkingTreeClean,
9
+ } from 'src/lib/git-utils'
4
10
 
5
11
  // Controllable per-command git output. The mocked `$` dispatches on the command
6
12
  // text so a single test can make `--absolute-git-dir` and `--git-common-dir`
@@ -12,6 +18,12 @@ const git = vi.hoisted(() => {
12
18
  status: '',
13
19
  absoluteGitDir: '/repo/.git',
14
20
  commonDir: '.git',
21
+ branchList: '',
22
+ lsRemote: '',
23
+ lsRemoteThrows: false,
24
+ localDeleteThrows: false,
25
+ remoteDeleteThrows: false,
26
+ calls: [] as string[],
15
27
  }
16
28
  })
17
29
 
@@ -19,11 +31,29 @@ vi.mock('zx', () => {
19
31
  const run = (strings: TemplateStringsArray): Promise<{ stdout: string }> => {
20
32
  const command = strings.join('')
21
33
 
34
+ git.calls.push(command)
35
+
22
36
  if (command.includes('--show-toplevel')) return Promise.resolve({ stdout: `${git.toplevel}\n` })
23
37
  if (command.includes('--abbrev-ref HEAD')) return Promise.resolve({ stdout: `${git.branch}\n` })
24
38
  if (command.includes('status --porcelain')) return Promise.resolve({ stdout: git.status })
25
39
  if (command.includes('--absolute-git-dir')) return Promise.resolve({ stdout: `${git.absoluteGitDir}\n` })
26
40
  if (command.includes('--git-common-dir')) return Promise.resolve({ stdout: `${git.commonDir}\n` })
41
+ if (command.includes('branch --list')) return Promise.resolve({ stdout: git.branchList })
42
+ if (command.includes('branch -D')) {
43
+ return git.localDeleteThrows
44
+ ? Promise.reject(new Error('branch checked out in another worktree'))
45
+ : Promise.resolve({ stdout: '' })
46
+ }
47
+ if (command.includes('ls-remote --heads')) {
48
+ return git.lsRemoteThrows
49
+ ? Promise.reject(new Error('could not read from remote repository'))
50
+ : Promise.resolve({ stdout: git.lsRemote })
51
+ }
52
+ if (command.includes('push origin --delete')) {
53
+ return git.remoteDeleteThrows
54
+ ? Promise.reject(new Error('remote ref does not exist'))
55
+ : Promise.resolve({ stdout: '' })
56
+ }
27
57
 
28
58
  return Promise.resolve({ stdout: '' })
29
59
  }
@@ -94,3 +124,94 @@ describe('isInsideLinkedWorktree', () => {
94
124
  await expect(isInsideLinkedWorktree()).resolves.toBe(true)
95
125
  })
96
126
  })
127
+
128
+ const ranLocalDelete = (): boolean => {
129
+ return git.calls.some((c) => {
130
+ return c.includes('branch -D')
131
+ })
132
+ }
133
+ const ranRemoteDelete = (): boolean => {
134
+ return git.calls.some((c) => {
135
+ return c.includes('push origin --delete')
136
+ })
137
+ }
138
+
139
+ describe('deleteLocalBranch', () => {
140
+ beforeEach(() => {
141
+ git.branch = 'dev'
142
+ git.branchList = ''
143
+ git.localDeleteThrows = false
144
+ git.calls = []
145
+ })
146
+
147
+ it('force-deletes the branch when it exists and is not current', async () => {
148
+ git.branchList = ' release/v1.2.3\n'
149
+
150
+ await deleteLocalBranch('release/v1.2.3')
151
+
152
+ expect(ranLocalDelete()).toBe(true)
153
+ })
154
+
155
+ it('is a no-op when the branch does not exist locally', async () => {
156
+ git.branchList = ''
157
+
158
+ await deleteLocalBranch('release/v1.2.3')
159
+
160
+ expect(ranLocalDelete()).toBe(false)
161
+ })
162
+
163
+ it('is a no-op when the branch is the current checkout', async () => {
164
+ git.branch = 'release/v1.2.3'
165
+ git.branchList = '* release/v1.2.3\n'
166
+
167
+ await deleteLocalBranch('release/v1.2.3')
168
+
169
+ expect(ranLocalDelete()).toBe(false)
170
+ })
171
+
172
+ it('propagates when the delete itself fails (e.g. checked out in another worktree)', async () => {
173
+ git.branchList = ' release/v1.2.3\n'
174
+ git.localDeleteThrows = true
175
+
176
+ await expect(deleteLocalBranch('release/v1.2.3')).rejects.toThrow()
177
+ })
178
+ })
179
+
180
+ describe('deleteRemoteBranch', () => {
181
+ beforeEach(() => {
182
+ git.lsRemote = ''
183
+ git.lsRemoteThrows = false
184
+ git.remoteDeleteThrows = false
185
+ git.calls = []
186
+ })
187
+
188
+ it('deletes the remote branch when ls-remote finds it', async () => {
189
+ git.lsRemote = 'abc123\trefs/heads/release/v1.2.3\n'
190
+
191
+ await deleteRemoteBranch('release/v1.2.3')
192
+
193
+ expect(ranRemoteDelete()).toBe(true)
194
+ })
195
+
196
+ it('is a no-op when the branch does not exist on the remote', async () => {
197
+ git.lsRemote = ''
198
+
199
+ await deleteRemoteBranch('release/v1.2.3')
200
+
201
+ expect(ranRemoteDelete()).toBe(false)
202
+ })
203
+
204
+ it('propagates a network/auth failure instead of treating it as "branch absent"', async () => {
205
+ git.lsRemoteThrows = true
206
+
207
+ await expect(deleteRemoteBranch('release/v1.2.3')).rejects.toThrow()
208
+ expect(ranRemoteDelete()).toBe(false)
209
+ })
210
+
211
+ it('propagates when the remote delete itself fails', async () => {
212
+ git.lsRemote = 'abc123\trefs/heads/release/v1.2.3\n'
213
+ git.remoteDeleteThrows = true
214
+
215
+ await expect(deleteRemoteBranch('release/v1.2.3')).rejects.toThrow()
216
+ })
217
+ })
@@ -142,3 +142,38 @@ export const getRepoName = async (): Promise<string> => {
142
142
 
143
143
  return path.basename(projectRoot)
144
144
  }
145
+
146
+ /**
147
+ * Delete a local branch if it exists and is not the current checkout.
148
+ *
149
+ * Idempotent: a no-op when the branch is absent (`git branch --list` prints
150
+ * nothing). Uses force `-D` because a delivered release branch was
151
+ * squash-merged — its tip is unreachable from the base, so `-d` would refuse
152
+ * with "not fully merged". The delete itself still rejects if the branch is
153
+ * checked out in another worktree; callers decide how to handle that.
154
+ */
155
+ export const deleteLocalBranch = async (branch: string): Promise<void> => {
156
+ const listed = await $`git branch --list ${branch}`
157
+
158
+ if (listed.stdout.trim().length === 0) return
159
+
160
+ if ((await getCurrentBranch()) === branch) return
161
+
162
+ await $`git branch -D ${branch}`
163
+ }
164
+
165
+ /**
166
+ * Delete a branch on the `origin` remote if it exists.
167
+ *
168
+ * Idempotent: a no-op when the branch is absent on the remote. Existence is
169
+ * probed with `git ls-remote --heads` (empty stdout = absent) rather than
170
+ * `--exit-code`, so a genuine network/auth failure rejects and propagates to
171
+ * the caller instead of being silently misread as "branch absent".
172
+ */
173
+ export const deleteRemoteBranch = async (branch: string): Promise<void> => {
174
+ const refs = await $`git ls-remote --heads origin ${branch}`
175
+
176
+ if (refs.stdout.trim().length === 0) return
177
+
178
+ await $`git push origin --delete ${branch}`
179
+ }
@@ -1,4 +1,6 @@
1
1
  export {
2
+ deleteLocalBranch,
3
+ deleteRemoteBranch,
2
4
  getCurrentBranch,
3
5
  getCurrentWorktrees,
4
6
  getProjectRoot,
@@ -0,0 +1,18 @@
1
+ {
2
+ "version": 1,
3
+ "entries": {
4
+ "79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
5
+ "last_emitted_at_ms": 1782040911131,
6
+ "message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
7
+ },
8
+ "445ed27a3872b681d98190bae61ccb84954e1bc4e140df5370be958dee776b3a": {
9
+ "last_emitted_at_ms": 1782040633210,
10
+ "message": "Verify changes work after editing. Test functionality before marking complete."
11
+ },
12
+ "466399dafa2d20f60587180bad0a07358eb7f2bce724df0ff682f038ad33f5ad": {
13
+ "last_emitted_at_ms": 1782040627481,
14
+ "message": "Read multiple files in parallel when possible for faster analysis."
15
+ }
16
+ },
17
+ "updated_at": "2026-06-21T11:21:51.131Z"
18
+ }
@@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
6
  // Import AFTER the mock is declared so the module picks up the mocked dep.
7
7
  import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
8
8
 
9
- import { getInfraKitConfig, resetInfraKitConfigCache } from '../infra-kit-config'
9
+ import { getInfraKitConfig, resetInfraKitConfigCache, resolveConfiguredIdes } from '../infra-kit-config'
10
+ import type { InfraKitConfig } from '../infra-kit-config'
10
11
 
11
12
  vi.mock('src/lib/git-utils', () => {
12
13
  return {
@@ -76,24 +77,143 @@ describe('getInfraKitConfig', () => {
76
77
  JSON.stringify({
77
78
  environments: ['dev'],
78
79
  envManagement: { provider: 'doppler', config: { name: 'p' } },
79
- ide: { provider: 'cursor', config: { mode: 'workspace', workspaceConfigPath: './ws.code-workspace' } },
80
+ ide: { provider: 'cursor', config: { workspaceConfigPath: './ws.code-workspace' } },
80
81
  taskManager: { provider: 'jira', config: { baseUrl: 'https://example.atlassian.net', projectId: 123 } },
81
82
  }),
82
83
  )
83
84
 
84
85
  const cfg = await getInfraKitConfig()
85
86
 
86
- expect(cfg.ide?.provider).toBe('cursor')
87
+ const ide = resolveConfiguredIdes(cfg)[0]
87
88
 
88
- if (cfg.ide?.provider === 'cursor') {
89
- expect(cfg.ide.config.mode).toBe('workspace')
90
- expect(cfg.ide.config.workspaceConfigPath).toBe('./ws.code-workspace')
89
+ if (!ide) {
90
+ throw new Error('expected one configured ide')
91
+ }
92
+
93
+ expect(ide.provider).toBe('cursor')
94
+
95
+ if (ide.provider === 'cursor') {
96
+ expect(ide.config.workspaceConfigPath).toBe('./ws.code-workspace')
91
97
  }
92
98
 
93
99
  expect(cfg.taskManager?.provider).toBe('jira')
94
100
  })
95
101
  })
96
102
 
103
+ it('accepts a zed ide provider with no workspaceConfigPath', async () => {
104
+ await withTmpRepo(async (tmp) => {
105
+ fs.writeFileSync(
106
+ path.join(tmp, 'infra-kit.json'),
107
+ JSON.stringify({
108
+ environments: ['dev'],
109
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
110
+ ide: { provider: 'zed', config: {} },
111
+ }),
112
+ )
113
+
114
+ const cfg = await getInfraKitConfig()
115
+
116
+ expect(resolveConfiguredIdes(cfg)[0]?.provider).toBe('zed')
117
+ })
118
+ })
119
+
120
+ it('strips a legacy "mode" key (backward compat)', async () => {
121
+ await withTmpRepo(async (tmp) => {
122
+ fs.writeFileSync(
123
+ path.join(tmp, 'infra-kit.json'),
124
+ JSON.stringify({
125
+ environments: ['dev'],
126
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
127
+ ide: { provider: 'zed', config: { mode: 'windows' } },
128
+ }),
129
+ )
130
+
131
+ const cfg = await getInfraKitConfig()
132
+
133
+ const ide = resolveConfiguredIdes(cfg)[0]
134
+
135
+ if (!ide) {
136
+ throw new Error('expected one configured ide')
137
+ }
138
+
139
+ expect(ide.provider).toBe('zed')
140
+ // The now-removed `mode` field is silently stripped, not rejected.
141
+ expect(ide.config).not.toHaveProperty('mode')
142
+ })
143
+ })
144
+
145
+ it('accepts an array of IDE providers (multi-editor)', async () => {
146
+ await withTmpRepo(async (tmp) => {
147
+ fs.writeFileSync(
148
+ path.join(tmp, 'infra-kit.json'),
149
+ JSON.stringify({
150
+ environments: ['dev'],
151
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
152
+ ide: [
153
+ { provider: 'cursor', config: { workspaceConfigPath: './ws.code-workspace' } },
154
+ { provider: 'zed', config: {} },
155
+ ],
156
+ }),
157
+ )
158
+
159
+ const cfg = await getInfraKitConfig()
160
+
161
+ expect(
162
+ resolveConfiguredIdes(cfg).map((ide) => {
163
+ return ide.provider
164
+ }),
165
+ ).toEqual(['cursor', 'zed'])
166
+ })
167
+ })
168
+
169
+ it('rejects an array with a duplicate provider at parse time', async () => {
170
+ await withTmpRepo(async (tmp) => {
171
+ fs.writeFileSync(
172
+ path.join(tmp, 'infra-kit.json'),
173
+ JSON.stringify({
174
+ environments: ['dev'],
175
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
176
+ ide: [
177
+ { provider: 'cursor', config: { workspaceConfigPath: './a.code-workspace' } },
178
+ { provider: 'cursor', config: { workspaceConfigPath: './b.code-workspace' } },
179
+ ],
180
+ }),
181
+ )
182
+
183
+ await expect(getInfraKitConfig()).rejects.toThrow(/each IDE provider may appear at most once/)
184
+ })
185
+ })
186
+
187
+ it('rejects an empty IDE array', async () => {
188
+ await withTmpRepo(async (tmp) => {
189
+ fs.writeFileSync(
190
+ path.join(tmp, 'infra-kit.json'),
191
+ JSON.stringify({
192
+ environments: ['dev'],
193
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
194
+ ide: [],
195
+ }),
196
+ )
197
+
198
+ await expect(getInfraKitConfig()).rejects.toThrow(/Invalid infra-kit\.json/)
199
+ })
200
+ })
201
+
202
+ it('rejects an unknown ide provider', async () => {
203
+ await withTmpRepo(async (tmp) => {
204
+ fs.writeFileSync(
205
+ path.join(tmp, 'infra-kit.json'),
206
+ JSON.stringify({
207
+ environments: ['dev'],
208
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
209
+ ide: { provider: 'vscode', config: {} },
210
+ }),
211
+ )
212
+
213
+ await expect(getInfraKitConfig()).rejects.toThrow(/Invalid infra-kit\.json/)
214
+ })
215
+ })
216
+
97
217
  it('accepts a worktrees prompt-defaults block', async () => {
98
218
  await withTmpRepo(async (tmp) => {
99
219
  fs.writeFileSync(
@@ -154,18 +274,21 @@ describe('getInfraKitConfig', () => {
154
274
  })
155
275
  })
156
276
 
157
- it('rejects ide.cursor mode=workspace without workspaceConfigPath', async () => {
277
+ it('rejects cursor without workspaceConfigPath', async () => {
158
278
  await withTmpRepo(async (tmp) => {
159
279
  fs.writeFileSync(
160
280
  path.join(tmp, 'infra-kit.json'),
161
281
  JSON.stringify({
162
282
  environments: ['dev'],
163
283
  envManagement: { provider: 'doppler', config: { name: 'p' } },
164
- ide: { provider: 'cursor', config: { mode: 'workspace' } },
284
+ ide: { provider: 'cursor', config: {} },
165
285
  }),
166
286
  )
167
287
 
168
- await expect(getInfraKitConfig()).rejects.toThrow(/workspaceConfigPath/)
288
+ // Cursor requires workspaceConfigPath; with the single|array union the
289
+ // inner message collapses to the union-level "Invalid input → at ide",
290
+ // but the config is still rejected (which is what matters).
291
+ await expect(getInfraKitConfig()).rejects.toThrow(/Invalid infra-kit\.json/)
169
292
  })
170
293
  })
171
294
 
@@ -268,3 +391,43 @@ describe('getInfraKitConfig', () => {
268
391
  })
269
392
  })
270
393
  })
394
+
395
+ describe('resolveConfiguredIdes', () => {
396
+ const base = {
397
+ environments: ['dev'],
398
+ envManagement: { provider: 'doppler', config: { name: 'p' } },
399
+ } as InfraKitConfig
400
+
401
+ it('wraps a single ide object in an array', () => {
402
+ const cfg = {
403
+ ...base,
404
+ ide: { provider: 'cursor', config: { workspaceConfigPath: 'ws' } },
405
+ } as InfraKitConfig
406
+
407
+ expect(
408
+ resolveConfiguredIdes(cfg).map((ide) => {
409
+ return ide.provider
410
+ }),
411
+ ).toEqual(['cursor'])
412
+ })
413
+
414
+ it('returns an ide array as-is', () => {
415
+ const cfg = {
416
+ ...base,
417
+ ide: [
418
+ { provider: 'cursor', config: { workspaceConfigPath: 'ws' } },
419
+ { provider: 'zed', config: {} },
420
+ ],
421
+ } as InfraKitConfig
422
+
423
+ expect(
424
+ resolveConfiguredIdes(cfg).map((ide) => {
425
+ return ide.provider
426
+ }),
427
+ ).toEqual(['cursor', 'zed'])
428
+ })
429
+
430
+ it('returns an empty array when ide is unset', () => {
431
+ expect(resolveConfiguredIdes(base)).toEqual([])
432
+ })
433
+ })
@@ -4,5 +4,6 @@ export {
4
4
  infraKitConfigSchema,
5
5
  infraKitOverrideConfigSchema,
6
6
  resetInfraKitConfigCache,
7
+ resolveConfiguredIdes,
7
8
  } from './infra-kit-config'
8
- export type { InfraKitConfig, InfraKitConfigPaths } from './infra-kit-config'
9
+ export type { ConfiguredIde, InfraKitConfig, InfraKitConfigPaths } from './infra-kit-config'