infra-kit 0.1.111 → 0.1.113

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 (85) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-127837d3-a000-4656-9453-2324ab353723.jsonl +5 -0
  3. package/.omc/state/agent-replay-15c409af-24fa-4026-88d3-a82dd1e68b87.jsonl +11 -0
  4. package/.omc/state/agent-replay-74afa912-1443-4011-b4f2-db9f055cdd9f.jsonl +3 -0
  5. package/.omc/state/agent-replay-8dd0ad2f-3a90-4158-b9de-ec8e19a7c983.jsonl +3 -0
  6. package/.omc/state/idle-notif-cooldown.json +1 -1
  7. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/mission-state.json +61 -0
  8. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/pre-tool-advisory-throttle.json +22 -0
  9. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/skill-active-state.json +15 -0
  10. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/subagent-tracking-state.json +26 -0
  11. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/last-tool-error-state.json +7 -0
  12. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/mission-state.json +89 -0
  13. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/pre-tool-advisory-throttle.json +34 -0
  14. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/ralph-state.json +13 -0
  15. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/skill-active-state.json +15 -0
  16. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/subagent-tracking-state.json +44 -0
  17. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/ultrawork-state.json +11 -0
  18. package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/pre-tool-advisory-throttle.json +18 -0
  19. package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/skill-active-state.json +15 -0
  20. package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/subagent-tracking-state.json +17 -0
  21. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +14 -0
  22. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ralph-state.json +13 -0
  23. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/subagent-tracking-state.json +17 -0
  24. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ultrawork-state.json +11 -0
  25. package/.turbo/turbo-build.log +3 -4
  26. package/.turbo/turbo-eslint-check.log +1 -2
  27. package/.turbo/turbo-prettier-check.log +1 -2
  28. package/.turbo/turbo-prettier-fix.log +1 -2
  29. package/.turbo/turbo-test.log +25 -18
  30. package/.turbo/turbo-ts-check.log +1 -2
  31. package/dist/cli.js +64 -56
  32. package/dist/cli.js.map +4 -4
  33. package/dist/mcp.js +39 -39
  34. package/dist/mcp.js.map +4 -4
  35. package/package.json +1 -1
  36. package/src/.omc/state/agent-replay-8dd0ad2f-3a90-4158-b9de-ec8e19a7c983.jsonl +11 -0
  37. package/src/.omc/state/idle-notif-cooldown.json +3 -0
  38. package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/mission-state.json +79 -0
  39. package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +22 -0
  40. package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ralph-state.json +13 -0
  41. package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/subagent-tracking-state.json +26 -0
  42. package/src/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ultrawork-state.json +11 -0
  43. package/src/commands/doctor/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +14 -0
  44. package/src/commands/doctor/__tests__/check-ide-installed.test.ts +142 -0
  45. package/src/commands/doctor/doctor.ts +98 -1
  46. package/src/commands/gh-release-deliver/gh-release-deliver.ts +32 -1
  47. package/src/commands/init/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +18 -0
  48. package/src/commands/init/__tests__/migrate-config.test.ts +132 -1
  49. package/src/commands/init/init.ts +19 -6
  50. package/src/commands/init/migrate-config.ts +104 -0
  51. package/src/commands/worktrees-add/index.ts +1 -2
  52. package/src/commands/worktrees-add/worktrees-add.ts +24 -60
  53. package/src/commands/worktrees-open/worktrees-open.ts +55 -68
  54. package/src/commands/worktrees-reload/__tests__/reload-cmux.test.ts +119 -0
  55. package/src/commands/worktrees-reload/index.ts +1 -0
  56. package/src/commands/worktrees-reload/worktrees-reload.ts +229 -0
  57. package/src/commands/worktrees-remove/worktrees-remove.ts +2 -44
  58. package/src/commands/worktrees-sync/worktrees-sync.ts +2 -44
  59. package/src/entry/cli.ts +32 -11
  60. package/src/integrations/cursor/index.ts +1 -0
  61. package/src/integrations/cursor/open-cursor-workspace.ts +70 -0
  62. package/src/integrations/ide/__tests__/ide-facade.test.ts +242 -0
  63. package/src/integrations/ide/add-ide-worktree-folders.ts +79 -0
  64. package/src/integrations/ide/index.ts +6 -0
  65. package/src/integrations/ide/open-ide-workspace.ts +51 -0
  66. package/src/integrations/ide/provider-label.ts +24 -0
  67. package/src/integrations/ide/remove-ide-worktree-folders.ts +80 -0
  68. package/src/integrations/ide/types.ts +32 -0
  69. package/src/integrations/zed/__tests__/open-zed-workspace.test.ts +85 -0
  70. package/src/integrations/zed/add-folders-to-zed-workspace.ts +30 -0
  71. package/src/integrations/zed/index.ts +2 -0
  72. package/src/integrations/zed/open-zed-workspace.ts +57 -0
  73. package/src/lib/assert-never/assert-never.ts +15 -0
  74. package/src/lib/assert-never/index.ts +1 -0
  75. package/src/lib/git-utils/__tests__/git-primitives.test.ts +122 -1
  76. package/src/lib/git-utils/git-utils.ts +35 -0
  77. package/src/lib/git-utils/index.ts +2 -0
  78. package/src/lib/infra-kit-config/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +18 -0
  79. package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +172 -9
  80. package/src/lib/infra-kit-config/index.ts +2 -1
  81. package/src/lib/infra-kit-config/infra-kit-config.ts +80 -22
  82. package/src/lib/package-config/.omc/state/sessions/dde22a92-a0e9-4948-905d-25c9a812ab35/last-tool-error-state.json +7 -0
  83. package/src/lib/package-config/.omc/state/sessions/dde22a92-a0e9-4948-905d-25c9a812ab35/pre-tool-advisory-throttle.json +10 -0
  84. package/src/mcp/tools/index.ts +2 -0
  85. package/tsconfig.tsbuildinfo +1 -1
@@ -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'
@@ -22,27 +22,36 @@ const dopplerEnvManagementSchema = z.object({
22
22
  const envManagementSchema = z.discriminatedUnion('provider', [dopplerEnvManagementSchema])
23
23
 
24
24
  // ide
25
- const cursorIdeConfigSchema = z
26
- .object({
27
- mode: z.enum(['workspace', 'windows']).default('workspace'),
28
- workspaceConfigPath: z.string().min(1).optional(),
29
- })
30
- .refine(
31
- (v) => {
32
- return v.mode !== 'workspace' || !!v.workspaceConfigPath
33
- },
34
- {
35
- message: 'workspaceConfigPath is required when mode is "workspace"',
36
- path: ['workspaceConfigPath'],
37
- },
38
- )
25
+ // There is one attach style: each worktree is added to the configured editor's
26
+ // workspace and opened (no per-window mode). Cursor needs a `.code-workspace`
27
+ // path to reconcile its `folders` array against.
28
+ const cursorIdeConfigSchema = z.object({
29
+ workspaceConfigPath: z.string().min(1),
30
+ })
39
31
 
40
32
  const cursorIdeSchema = z.object({
41
33
  provider: z.literal('cursor'),
42
34
  config: cursorIdeConfigSchema,
43
35
  })
44
36
 
45
- const ideSchema = z.discriminatedUnion('provider', [cursorIdeSchema])
37
+ // Zed has no portable workspace file (no `.code-workspace`) and no folder-remove
38
+ // CLI: a multi-worktree workspace is realized by a single `zed <root> <wt...>`
39
+ // invocation. So `config` carries no settings — there's no path to point at.
40
+ const zedIdeConfigSchema = z.object({})
41
+
42
+ const zedIdeSchema = z.object({
43
+ provider: z.literal('zed'),
44
+ config: zedIdeConfigSchema,
45
+ })
46
+
47
+ const ideSchema = z.discriminatedUnion('provider', [cursorIdeSchema, zedIdeSchema])
48
+
49
+ // `ide` accepts a single provider (back-compat) OR an array to drive multiple
50
+ // editors at once (e.g. Cursor + Zed). Normalized to an array everywhere via
51
+ // `resolveConfiguredIdes`. Uniqueness-by-provider is enforced at parse time by a
52
+ // `.superRefine` on the full config schema (see below) — not here, so the message
53
+ // survives `z.union` error aggregation.
54
+ const idesSchema = z.union([ideSchema, z.array(ideSchema).min(1)])
46
55
 
47
56
  // taskManager
48
57
  const jiraTaskManagerSchema = z.object({
@@ -61,18 +70,67 @@ const worktreesConfigSchema = z.object({
61
70
  openInCmux: z.boolean().optional(),
62
71
  })
63
72
 
64
- export const infraKitConfigSchema = z.object({
73
+ // Base object shape, kept separate so `.partial()` (which only works on a plain
74
+ // ZodObject, not the `.superRefine`-wrapped full schema) can derive the override
75
+ // schema from it.
76
+ const infraKitConfigObject = z.object({
65
77
  environments: z.array(z.string().min(1)).min(1),
66
78
  envManagement: envManagementSchema,
67
- ide: ideSchema.optional(),
79
+ ide: idesSchema.optional(),
68
80
  taskManager: taskManagerSchema.optional(),
69
81
  worktrees: worktreesConfigSchema.optional(),
70
82
  })
71
83
 
72
- export const infraKitOverrideConfigSchema = infraKitConfigSchema.partial()
84
+ // Full schema = base object + a parse-time uniqueness check on the `ide` array.
85
+ // This runs inside the *merged* `safeParse` in getInfraKitConfig, so it's the
86
+ // gate for the final config. (The override layers use the `.partial()` form
87
+ // below, which drops this object-level refinement — acceptable, the merged
88
+ // parse is authoritative.)
89
+ export const infraKitConfigSchema = infraKitConfigObject.superRefine((cfg, ctx) => {
90
+ if (!Array.isArray(cfg.ide)) return
91
+
92
+ const seen = new Set<string>()
93
+
94
+ for (const entry of cfg.ide) {
95
+ if (seen.has(entry.provider)) {
96
+ ctx.addIssue({
97
+ code: 'custom',
98
+ message: 'each IDE provider may appear at most once',
99
+ path: ['ide'],
100
+ })
101
+
102
+ return
103
+ }
104
+
105
+ seen.add(entry.provider)
106
+ }
107
+ })
108
+
109
+ export const infraKitOverrideConfigSchema = infraKitConfigObject.partial()
73
110
 
74
111
  export type InfraKitConfig = z.infer<typeof infraKitConfigSchema>
75
112
 
113
+ /** A single resolved IDE entry (`{ provider, config }`). */
114
+ export type ConfiguredIde = z.infer<typeof ideSchema>
115
+
116
+ /**
117
+ * Normalize the `ide` config (single object, array, or unset) into a flat list.
118
+ * Validation-free: assumes already-parsed input (uniqueness is enforced by the
119
+ * schema). The one source of truth for "which editors are configured."
120
+ *
121
+ * @example
122
+ * resolveConfiguredIdes({ ide: { provider: 'cursor', config: {...} } }) // => [cursor]
123
+ * resolveConfiguredIdes({ ide: [cursor, zed] }) // => [cursor, zed]
124
+ * resolveConfiguredIdes({}) // => []
125
+ */
126
+ export const resolveConfiguredIdes = (config: InfraKitConfig): ConfiguredIde[] => {
127
+ const ide = config.ide
128
+
129
+ if (!ide) return []
130
+
131
+ return Array.isArray(ide) ? ide : [ide]
132
+ }
133
+
76
134
  export interface InfraKitConfigPaths {
77
135
  /** Committed project config (required). */
78
136
  main: string
@@ -131,9 +189,9 @@ export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> =>
131
189
  *
132
190
  * @example
133
191
  * // infra-kit.json: { "environments": ["dev"], "envManagement": { "provider": "doppler", "config": { "name": "p" } } }
134
- * // ~/.infra-kit/config.json: { "ide": { "provider": "cursor", "config": { "mode": "windows" } } }
192
+ * // ~/.infra-kit/config.json: { "ide": { "provider": "cursor", "config": { "workspaceConfigPath": "./ws.code-workspace" } } }
135
193
  * const cfg = await getInfraKitConfig()
136
- * // => { environments: ['dev'], envManagement: {...}, ide: { provider: 'cursor', config: { mode: 'windows' } } }
194
+ * // => { environments: ['dev'], envManagement: {...}, ide: { provider: 'cursor', config: { workspaceConfigPath: './ws.code-workspace' } } }
137
195
  */
138
196
  export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
139
197
  const paths = await getInfraKitConfigPaths()
@@ -281,9 +339,9 @@ interface ConfigLayer {
281
339
  * // => null
282
340
  *
283
341
  * @example
284
- * // /home/me/.infra-kit/config.json: '{ "ide": { "provider": "cursor", "config": { "mode": "windows" } } }'
342
+ * // /home/me/.infra-kit/config.json: '{ "ide": { "provider": "cursor", "config": { "workspaceConfigPath": "./ws.code-workspace" } } }'
285
343
  * await loadLayer({ label: '~/.infra-kit/config.json', path: '/home/me/.infra-kit/config.json', required: false })
286
- * // => { ide: { provider: 'cursor', config: { mode: 'windows' } } }
344
+ * // => { ide: { provider: 'cursor', config: { workspaceConfigPath: './ws.code-workspace' } } }
287
345
  */
288
346
  const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> | null> => {
289
347
  const raw = await readIfExists(layer.path)