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,69 @@
1
+ import { logger } from 'src/lib/logger'
2
+
3
+ import { launchCursor } from './launch-cursor'
4
+ import { reconcileCursorWorkspaceFolders } from './reconcile-workspace-folders'
5
+ import { resolveCursorWorkspacePath } from './resolve-workspace-path'
6
+
7
+ interface OpenCursorWorkspaceArgs {
8
+ projectRoot: string
9
+ worktreeDir: string
10
+ currentBranches: string[]
11
+ /**
12
+ * When true, skip the `cursor <workspace>` relaunch if there are no release
13
+ * worktrees (folder reconcile still runs to strip dangling entries). Lets
14
+ * `worktrees-reload` avoid popping an empty Cursor window while keeping the
15
+ * cold-start `worktrees-open` behaviour (relaunch unconditionally) intact.
16
+ */
17
+ skipRelaunchWhenEmpty: boolean
18
+ /**
19
+ * The Cursor entry's config, selected by the facade from the (possibly
20
+ * multi-IDE) infra-kit config. Passed in rather than re-read here so this
21
+ * opener acts on the exact entry the caller chose — essential once `ide` can
22
+ * be an array of providers.
23
+ */
24
+ cursorConfig: { workspaceConfigPath: string }
25
+ }
26
+
27
+ interface OpenCursorWorkspaceOutcome {
28
+ ran: boolean
29
+ added: number
30
+ removed: number
31
+ }
32
+
33
+ /**
34
+ * Reconciles the configured Cursor `.code-workspace` `folders` array against the
35
+ * set of release worktrees on disk, then launches Cursor against it. Shared by
36
+ * `worktrees-open` (cold-start restore) and `worktrees-reload` (close + reopen).
37
+ *
38
+ * No-ops (returns `ran: false`) when the Cursor entry has no `workspaceConfigPath`.
39
+ * All failures are swallowed into a warning — opening Cursor is best-effort.
40
+ */
41
+ export const openCursorWorkspace = async (args: OpenCursorWorkspaceArgs): Promise<OpenCursorWorkspaceOutcome> => {
42
+ const { projectRoot, worktreeDir, currentBranches, skipRelaunchWhenEmpty, cursorConfig } = args
43
+
44
+ if (!cursorConfig.workspaceConfigPath) {
45
+ logger.warn('⚠️ Skipping Cursor: workspaceConfigPath is not set.')
46
+
47
+ return { ran: false, added: 0, removed: 0 }
48
+ }
49
+
50
+ const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
51
+
52
+ try {
53
+ const { added, removed } = await reconcileCursorWorkspaceFolders({
54
+ workspacePath,
55
+ worktreeDir,
56
+ currentBranches,
57
+ })
58
+
59
+ if (!(skipRelaunchWhenEmpty && currentBranches.length === 0)) {
60
+ await launchCursor(workspacePath)
61
+ }
62
+
63
+ return { ran: true, added: added.length, removed: removed.length }
64
+ } catch (error) {
65
+ logger.warn({ error }, `⚠️ Failed to reconcile/open Cursor workspace at ${workspacePath}`)
66
+
67
+ return { ran: false, added: 0, removed: 0 }
68
+ }
69
+ }
@@ -0,0 +1,250 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { addIdeWorktreeFolders } from '../add-ide-worktree-folders'
4
+ import { openIdeWorkspace } from '../open-ide-workspace'
5
+ import { removeIdeWorktreeFolders } from '../remove-ide-worktree-folders'
6
+
7
+ const config = vi.hoisted(() => {
8
+ return { value: {} as { ide?: unknown } }
9
+ })
10
+
11
+ vi.mock('src/lib/infra-kit-config', () => {
12
+ return {
13
+ getInfraKitConfig: vi.fn(() => {
14
+ return Promise.resolve(config.value)
15
+ }),
16
+ // Mirror the real normalizer (single → [ide], array → as-is, undefined → []).
17
+ resolveConfiguredIdes: vi.fn((cfg: { ide?: unknown }) => {
18
+ const ide = cfg.ide
19
+
20
+ if (!ide) return []
21
+
22
+ return Array.isArray(ide) ? ide : [ide]
23
+ }),
24
+ }
25
+ })
26
+
27
+ vi.mock('src/lib/logger', () => {
28
+ return { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }
29
+ })
30
+
31
+ const cursor = vi.hoisted(() => {
32
+ return {
33
+ openCursorWorkspace: vi.fn(),
34
+ addFoldersToCursorWorkspace: vi.fn(),
35
+ removeFoldersFromCursorWorkspace: vi.fn(),
36
+ launchCursor: vi.fn(),
37
+ resolveCursorWorkspacePath: vi.fn((value: string) => {
38
+ return `/abs/${value}`
39
+ }),
40
+ }
41
+ })
42
+
43
+ vi.mock('src/integrations/cursor', () => {
44
+ return cursor
45
+ })
46
+
47
+ // Defense-in-depth: never let a test shell out to a real editor. Even if an
48
+ // editor spawn leaks back into a layer this suite exercises directly, `$` is a
49
+ // no-op here so `cursor <path>` can never open a real window during the run.
50
+ vi.mock('zx', () => {
51
+ return { $: vi.fn() }
52
+ })
53
+
54
+ const zed = vi.hoisted(() => {
55
+ return {
56
+ openZedWorkspace: vi.fn(),
57
+ addFoldersToZedWorkspace: vi.fn(),
58
+ }
59
+ })
60
+
61
+ vi.mock('src/integrations/zed', () => {
62
+ return zed
63
+ })
64
+
65
+ const cursorEntry = { provider: 'cursor', config: { workspaceConfigPath: 'ws' } }
66
+ const zedEntry = { provider: 'zed', config: {} }
67
+ const cursorConfig = { ide: cursorEntry }
68
+ const zedConfig = { ide: zedEntry }
69
+ const bothConfig = { ide: [cursorEntry, zedEntry] }
70
+ const unconfigured = { ide: undefined }
71
+
72
+ const baseOpenArgs = {
73
+ projectRoot: '/repo',
74
+ worktreeDir: '/repo.worktrees',
75
+ currentBranches: ['release/v1.0.0'],
76
+ skipRelaunchWhenEmpty: false,
77
+ }
78
+
79
+ beforeEach(() => {
80
+ vi.clearAllMocks()
81
+ cursor.resolveCursorWorkspacePath.mockImplementation((value: string) => {
82
+ return `/abs/${value}`
83
+ })
84
+ })
85
+
86
+ describe('openIdeWorkspace', () => {
87
+ it('routes to Cursor and tags the provider (passing the cursor config down)', async () => {
88
+ config.value = cursorConfig
89
+ cursor.openCursorWorkspace.mockResolvedValue({ ran: true, added: 1, removed: 0 })
90
+
91
+ const outcomes = await openIdeWorkspace(baseOpenArgs)
92
+
93
+ expect(cursor.openCursorWorkspace).toHaveBeenCalledWith({
94
+ ...baseOpenArgs,
95
+ cursorConfig: { workspaceConfigPath: 'ws' },
96
+ })
97
+ expect(zed.openZedWorkspace).not.toHaveBeenCalled()
98
+ expect(outcomes).toEqual([{ ran: true, added: 1, removed: 0, provider: 'cursor' }])
99
+ })
100
+
101
+ it('routes to Zed and tags the provider', async () => {
102
+ config.value = zedConfig
103
+ zed.openZedWorkspace.mockResolvedValue({ ran: true, added: 2, removed: 0 })
104
+
105
+ const outcomes = await openIdeWorkspace(baseOpenArgs)
106
+
107
+ expect(zed.openZedWorkspace).toHaveBeenCalledWith(baseOpenArgs)
108
+ expect(cursor.openCursorWorkspace).not.toHaveBeenCalled()
109
+ expect(outcomes).toEqual([{ ran: true, added: 2, removed: 0, provider: 'zed' }])
110
+ })
111
+
112
+ it('opens BOTH editors when both are configured (one outcome per provider)', async () => {
113
+ config.value = bothConfig
114
+ cursor.openCursorWorkspace.mockResolvedValue({ ran: true, added: 1, removed: 0 })
115
+ zed.openZedWorkspace.mockResolvedValue({ ran: true, added: 2, removed: 0 })
116
+
117
+ const outcomes = await openIdeWorkspace(baseOpenArgs)
118
+
119
+ expect(cursor.openCursorWorkspace).toHaveBeenCalledTimes(1)
120
+ expect(zed.openZedWorkspace).toHaveBeenCalledTimes(1)
121
+ expect(outcomes).toEqual([
122
+ { ran: true, added: 1, removed: 0, provider: 'cursor' },
123
+ { ran: true, added: 2, removed: 0, provider: 'zed' },
124
+ ])
125
+ })
126
+
127
+ it('returns an empty array when no IDE is configured', async () => {
128
+ config.value = unconfigured
129
+
130
+ const outcomes = await openIdeWorkspace(baseOpenArgs)
131
+
132
+ expect(cursor.openCursorWorkspace).not.toHaveBeenCalled()
133
+ expect(zed.openZedWorkspace).not.toHaveBeenCalled()
134
+ expect(outcomes).toEqual([])
135
+ })
136
+ })
137
+
138
+ describe('addIdeWorktreeFolders', () => {
139
+ const args = { projectRoot: '/repo', worktreeDir: '/repo.worktrees', branches: ['release/v1.0.0'] }
140
+
141
+ it('adds to the Cursor workspace and reports skipped', async () => {
142
+ config.value = cursorConfig
143
+ cursor.addFoldersToCursorWorkspace.mockResolvedValue({ added: ['a'], skipped: ['b'] })
144
+
145
+ const outcomes = await addIdeWorktreeFolders(args)
146
+
147
+ expect(cursor.resolveCursorWorkspacePath).toHaveBeenCalledWith('ws', '/repo')
148
+ expect(cursor.addFoldersToCursorWorkspace).toHaveBeenCalled()
149
+ expect(outcomes).toEqual([{ ran: true, provider: 'cursor', added: 1, skipped: 1 }])
150
+ })
151
+
152
+ it('skips Cursor when workspaceConfigPath is missing', async () => {
153
+ config.value = { ide: { provider: 'cursor', config: {} } }
154
+
155
+ const outcomes = await addIdeWorktreeFolders(args)
156
+
157
+ expect(cursor.addFoldersToCursorWorkspace).not.toHaveBeenCalled()
158
+ expect(outcomes).toEqual([{ ran: false, provider: 'cursor', added: 0, skipped: 0 }])
159
+ })
160
+
161
+ it('adds to Zed via zed --add (skipped always 0)', async () => {
162
+ config.value = zedConfig
163
+ zed.addFoldersToZedWorkspace.mockResolvedValue({ added: ['a', 'b'] })
164
+
165
+ const outcomes = await addIdeWorktreeFolders(args)
166
+
167
+ expect(zed.addFoldersToZedWorkspace).toHaveBeenCalled()
168
+ expect(outcomes).toEqual([{ ran: true, provider: 'zed', added: 2, skipped: 0 }])
169
+ })
170
+
171
+ it('adds to BOTH editors when both are configured', async () => {
172
+ config.value = bothConfig
173
+ cursor.addFoldersToCursorWorkspace.mockResolvedValue({ added: ['a'], skipped: [] })
174
+ zed.addFoldersToZedWorkspace.mockResolvedValue({ added: ['a', 'b'] })
175
+
176
+ const outcomes = await addIdeWorktreeFolders(args)
177
+
178
+ expect(outcomes).toEqual([
179
+ { ran: true, provider: 'cursor', added: 1, skipped: 0 },
180
+ { ran: true, provider: 'zed', added: 2, skipped: 0 },
181
+ ])
182
+ })
183
+
184
+ it('returns an empty array when no IDE is configured', async () => {
185
+ config.value = unconfigured
186
+
187
+ const outcomes = await addIdeWorktreeFolders(args)
188
+
189
+ expect(outcomes).toEqual([])
190
+ })
191
+ })
192
+
193
+ describe('removeIdeWorktreeFolders', () => {
194
+ const args = { projectRoot: '/repo', worktreeDir: '/repo.worktrees', branches: ['release/v1.0.0'] }
195
+
196
+ it('removes from the Cursor workspace', async () => {
197
+ config.value = cursorConfig
198
+ cursor.removeFoldersFromCursorWorkspace.mockResolvedValue({ removed: ['x'], notFound: [] })
199
+
200
+ const outcomes = await removeIdeWorktreeFolders(args)
201
+
202
+ expect(cursor.removeFoldersFromCursorWorkspace).toHaveBeenCalled()
203
+ expect(outcomes).toEqual([{ provider: 'cursor', supported: true, removed: ['x'] }])
204
+ })
205
+
206
+ it('is a no-op for Cursor when workspaceConfigPath is missing', async () => {
207
+ config.value = { ide: { provider: 'cursor', config: {} } }
208
+
209
+ const outcomes = await removeIdeWorktreeFolders(args)
210
+
211
+ expect(cursor.removeFoldersFromCursorWorkspace).not.toHaveBeenCalled()
212
+ expect(outcomes).toEqual([{ provider: 'cursor', supported: true, removed: [] }])
213
+ })
214
+
215
+ it('reports unsupported for Zed without throwing', async () => {
216
+ config.value = zedConfig
217
+
218
+ const outcomes = await removeIdeWorktreeFolders(args)
219
+
220
+ expect(outcomes).toEqual([{ provider: 'zed', supported: false, removed: [] }])
221
+ })
222
+
223
+ it('removes from Cursor AND reports Zed unsupported when both are configured', async () => {
224
+ config.value = bothConfig
225
+ cursor.removeFoldersFromCursorWorkspace.mockResolvedValue({ removed: ['x'], notFound: [] })
226
+
227
+ const outcomes = await removeIdeWorktreeFolders(args)
228
+
229
+ expect(outcomes).toEqual([
230
+ { provider: 'cursor', supported: true, removed: ['x'] },
231
+ { provider: 'zed', supported: false, removed: [] },
232
+ ])
233
+ })
234
+
235
+ it('short-circuits to an empty array when there are no branches (no config read needed)', async () => {
236
+ config.value = zedConfig
237
+
238
+ const outcomes = await removeIdeWorktreeFolders({ ...args, branches: [] })
239
+
240
+ expect(outcomes).toEqual([])
241
+ })
242
+
243
+ it('returns an empty array when no IDE is configured', async () => {
244
+ config.value = unconfigured
245
+
246
+ const outcomes = await removeIdeWorktreeFolders(args)
247
+
248
+ expect(outcomes).toEqual([])
249
+ })
250
+ })
@@ -0,0 +1,77 @@
1
+ import { addFoldersToCursorWorkspace, launchCursor, resolveCursorWorkspacePath } from 'src/integrations/cursor'
2
+ import { addFoldersToZedWorkspace } from 'src/integrations/zed'
3
+ import { assertNever } from 'src/lib/assert-never'
4
+ import { getInfraKitConfig, resolveConfiguredIdes } from 'src/lib/infra-kit-config'
5
+ import { logger } from 'src/lib/logger'
6
+
7
+ import type { AddIdeWorktreeFoldersOutcome } from './types'
8
+
9
+ interface AddIdeWorktreeFoldersArgs {
10
+ projectRoot: string
11
+ worktreeDir: string
12
+ branches: string[]
13
+ }
14
+
15
+ /**
16
+ * Workspace-mode add (used by `worktrees-add`): append the given worktree folders
17
+ * to every configured editor's workspace and open it. Returns one outcome per
18
+ * configured provider (empty array when no IDE is configured), iterating
19
+ * sequentially.
20
+ *
21
+ * Cursor appends to the `.code-workspace` `folders` array (reporting `skipped`
22
+ * for already-present entries) then runs `cursor <workspace>`. Zed has no
23
+ * workspace file, so it `zed --add`s each folder (`skipped` is always 0).
24
+ */
25
+ export const addIdeWorktreeFolders = async (
26
+ args: AddIdeWorktreeFoldersArgs,
27
+ ): Promise<AddIdeWorktreeFoldersOutcome[]> => {
28
+ const { projectRoot, worktreeDir, branches } = args
29
+
30
+ const config = await getInfraKitConfig()
31
+ const ides = resolveConfiguredIdes(config)
32
+
33
+ const folderPaths = branches.map((branch) => {
34
+ return `${worktreeDir}/${branch}`
35
+ })
36
+
37
+ const outcomes: AddIdeWorktreeFoldersOutcome[] = []
38
+
39
+ for (const ide of ides) {
40
+ switch (ide.provider) {
41
+ case 'cursor': {
42
+ if (!ide.config.workspaceConfigPath) {
43
+ logger.warn('⚠️ Skipping Cursor: ide.config.workspaceConfigPath is not set in infra-kit config')
44
+
45
+ outcomes.push({ ran: false, provider: 'cursor', added: 0, skipped: 0 })
46
+ break
47
+ }
48
+
49
+ const workspacePath = resolveCursorWorkspacePath(ide.config.workspaceConfigPath, projectRoot)
50
+
51
+ const { added, skipped } = await addFoldersToCursorWorkspace({ workspacePath, folderPaths })
52
+
53
+ const skippedSuffix = skipped.length > 0 ? ` (${skipped.length} already present)` : ''
54
+
55
+ logger.info(`✅ Added ${added.length} folder(s) to ${workspacePath}${skippedSuffix}`)
56
+
57
+ await launchCursor(workspacePath)
58
+
59
+ outcomes.push({ ran: true, provider: 'cursor', added: added.length, skipped: skipped.length })
60
+ break
61
+ }
62
+ case 'zed': {
63
+ const { added } = await addFoldersToZedWorkspace({ folderPaths })
64
+
65
+ logger.info(`✅ Added ${added.length} folder(s) to Zed workspace`)
66
+
67
+ outcomes.push({ ran: true, provider: 'zed', added: added.length, skipped: 0 })
68
+ break
69
+ }
70
+ default: {
71
+ assertNever(ide)
72
+ }
73
+ }
74
+ }
75
+
76
+ return outcomes
77
+ }
@@ -0,0 +1,6 @@
1
+ export { addIdeWorktreeFolders } from './add-ide-worktree-folders'
2
+ export { openIdeWorkspace } from './open-ide-workspace'
3
+ export { ideProviderLabel } from './provider-label'
4
+ export { removeIdeWorktreeFolders } from './remove-ide-worktree-folders'
5
+ export { IDE_MODES } from './types'
6
+ export type { IdeMode, IdeProvider } from './types'
@@ -0,0 +1,51 @@
1
+ import { openCursorWorkspace } from 'src/integrations/cursor'
2
+ import { openZedWorkspace } from 'src/integrations/zed'
3
+ import { assertNever } from 'src/lib/assert-never'
4
+ import { getInfraKitConfig, resolveConfiguredIdes } from 'src/lib/infra-kit-config'
5
+
6
+ import type { OpenIdeWorkspaceOutcome } from './types'
7
+
8
+ interface OpenIdeWorkspaceArgs {
9
+ projectRoot: string
10
+ worktreeDir: string
11
+ currentBranches: string[]
12
+ skipRelaunchWhenEmpty: boolean
13
+ }
14
+
15
+ /**
16
+ * Provider-agnostic entry point for the cold-start / reload open: for every
17
+ * configured editor, reconciles (or, for Zed, simply assembles) the workspace
18
+ * against the release worktrees on disk and launches it. Returns one outcome per
19
+ * configured provider (empty array when no IDE is configured). Iterates
20
+ * sequentially — `worktrees-reload` already wraps this call in an outer
21
+ * `Promise.all` with cmux, so a `Promise.all` here would compound editor-spawn
22
+ * concurrency. Best-effort — every provider swallows failures into a warning.
23
+ */
24
+ export const openIdeWorkspace = async (args: OpenIdeWorkspaceArgs): Promise<OpenIdeWorkspaceOutcome[]> => {
25
+ const config = await getInfraKitConfig()
26
+ const ides = resolveConfiguredIdes(config)
27
+
28
+ const outcomes: OpenIdeWorkspaceOutcome[] = []
29
+
30
+ for (const ide of ides) {
31
+ switch (ide.provider) {
32
+ case 'cursor': {
33
+ const outcome = await openCursorWorkspace({ ...args, cursorConfig: ide.config })
34
+
35
+ outcomes.push({ ...outcome, provider: 'cursor' })
36
+ break
37
+ }
38
+ case 'zed': {
39
+ const outcome = await openZedWorkspace(args)
40
+
41
+ outcomes.push({ ...outcome, provider: 'zed' })
42
+ break
43
+ }
44
+ default: {
45
+ assertNever(ide)
46
+ }
47
+ }
48
+ }
49
+
50
+ return outcomes
51
+ }
@@ -0,0 +1,24 @@
1
+ import { assertNever } from 'src/lib/assert-never'
2
+
3
+ import type { IdeProvider } from './types'
4
+
5
+ /**
6
+ * Human-facing display name for an IDE provider, used in log lines. Falls back to
7
+ * a generic "IDE" when no provider is configured.
8
+ */
9
+ export const ideProviderLabel = (provider: IdeProvider | undefined): string => {
10
+ switch (provider) {
11
+ case 'cursor': {
12
+ return 'Cursor'
13
+ }
14
+ case 'zed': {
15
+ return 'Zed'
16
+ }
17
+ case undefined: {
18
+ return 'IDE'
19
+ }
20
+ default: {
21
+ return assertNever(provider)
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,80 @@
1
+ import { removeFoldersFromCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
2
+ import { assertNever } from 'src/lib/assert-never'
3
+ import { getInfraKitConfig, resolveConfiguredIdes } from 'src/lib/infra-kit-config'
4
+ import { logger } from 'src/lib/logger'
5
+
6
+ import type { RemoveIdeWorktreeFoldersOutcome } from './types'
7
+
8
+ interface RemoveIdeWorktreeFoldersArgs {
9
+ projectRoot: string
10
+ worktreeDir: string
11
+ branches: string[]
12
+ }
13
+
14
+ /**
15
+ * Strip removed worktrees from every configured editor's workspace (used by
16
+ * `worktrees-remove` and `worktrees-sync`). Returns one outcome per configured
17
+ * provider (empty array when no IDE is configured or no worktrees were removed),
18
+ * iterating sequentially.
19
+ *
20
+ * Cursor edits the `.code-workspace` `folders` array. Zed has no folder-remove
21
+ * CLI, so it is a structural no-op (`supported: false`) with an info-level
22
+ * message — never a warning, because nothing failed.
23
+ */
24
+ export const removeIdeWorktreeFolders = async (
25
+ args: RemoveIdeWorktreeFoldersArgs,
26
+ ): Promise<RemoveIdeWorktreeFoldersOutcome[]> => {
27
+ const { projectRoot, worktreeDir, branches } = args
28
+
29
+ if (branches.length === 0) {
30
+ return []
31
+ }
32
+
33
+ const config = await getInfraKitConfig()
34
+ const ides = resolveConfiguredIdes(config)
35
+
36
+ const folderPaths = branches.map((branch) => {
37
+ return `${worktreeDir}/${branch}`
38
+ })
39
+
40
+ const outcomes: RemoveIdeWorktreeFoldersOutcome[] = []
41
+
42
+ for (const ide of ides) {
43
+ switch (ide.provider) {
44
+ case 'cursor': {
45
+ if (!ide.config.workspaceConfigPath) {
46
+ outcomes.push({ provider: 'cursor', supported: true, removed: [] })
47
+ break
48
+ }
49
+
50
+ const workspacePath = resolveCursorWorkspacePath(ide.config.workspaceConfigPath, projectRoot)
51
+
52
+ try {
53
+ const { removed } = await removeFoldersFromCursorWorkspace({ workspacePath, folderPaths })
54
+
55
+ if (removed.length > 0) {
56
+ logger.info(`✅ Removed ${removed.length} folder(s) from ${workspacePath}`)
57
+ }
58
+
59
+ outcomes.push({ provider: 'cursor', supported: true, removed })
60
+ } catch (error) {
61
+ logger.warn({ error }, `⚠️ Failed to update Cursor workspace at ${workspacePath}`)
62
+
63
+ outcomes.push({ provider: 'cursor', supported: true, removed: [] })
64
+ }
65
+ break
66
+ }
67
+ case 'zed': {
68
+ logger.info('ℹ️ Zed has no folder-remove CLI; close removed worktree folders in Zed manually if needed.')
69
+
70
+ outcomes.push({ provider: 'zed', supported: false, removed: [] })
71
+ break
72
+ }
73
+ default: {
74
+ assertNever(ide)
75
+ }
76
+ }
77
+ }
78
+
79
+ return outcomes
80
+ }
@@ -0,0 +1,32 @@
1
+ export type IdeProvider = 'cursor' | 'zed'
2
+
3
+ /**
4
+ * CLI-level open modes for `worktrees-add` (`--ide`). `workspace` adds each
5
+ * created worktree to the configured editor's workspace and opens it (the only
6
+ * attach style); `none` means "don't open any editor".
7
+ */
8
+ export const IDE_MODES = ['workspace', 'none'] as const
9
+ export type IdeMode = (typeof IDE_MODES)[number]
10
+
11
+ // Each facade returns one outcome PER configured provider (an array), so every
12
+ // outcome is tagged with its `provider`.
13
+ export interface OpenIdeWorkspaceOutcome {
14
+ ran: boolean
15
+ provider: IdeProvider
16
+ added: number
17
+ removed: number
18
+ }
19
+
20
+ export interface AddIdeWorktreeFoldersOutcome {
21
+ ran: boolean
22
+ provider: IdeProvider
23
+ added: number
24
+ skipped: number
25
+ }
26
+
27
+ export interface RemoveIdeWorktreeFoldersOutcome {
28
+ provider: IdeProvider
29
+ /** False when the configured provider has no folder-remove capability (Zed). */
30
+ supported: boolean
31
+ removed: string[]
32
+ }
@@ -0,0 +1,85 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { openZedWorkspace } from '../open-zed-workspace'
4
+
5
+ const zx = vi.hoisted(() => {
6
+ return { calls: [] as unknown[][], shouldThrow: false }
7
+ })
8
+
9
+ vi.mock('zx', () => {
10
+ return {
11
+ $: vi.fn((_strings: TemplateStringsArray, ...values: unknown[]) => {
12
+ zx.calls.push(values)
13
+
14
+ if (zx.shouldThrow) {
15
+ return Promise.reject(new Error('zed failed to launch'))
16
+ }
17
+
18
+ return Promise.resolve({ stdout: '' })
19
+ }),
20
+ }
21
+ })
22
+
23
+ vi.mock('src/lib/logger', () => {
24
+ return {
25
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
26
+ }
27
+ })
28
+
29
+ describe('openZedWorkspace', () => {
30
+ beforeEach(() => {
31
+ zx.calls = []
32
+ zx.shouldThrow = false
33
+ })
34
+
35
+ it('opens a single workspace with the project root plus every worktree path', async () => {
36
+ const outcome = await openZedWorkspace({
37
+ projectRoot: '/repo',
38
+ worktreeDir: '/repo.worktrees',
39
+ currentBranches: ['release/v1.0.0', 'release/v1.1.0'],
40
+ skipRelaunchWhenEmpty: false,
41
+ })
42
+
43
+ expect(zx.calls).toHaveLength(1)
44
+ expect(zx.calls[0]?.[0]).toEqual(['/repo', '/repo.worktrees/release/v1.0.0', '/repo.worktrees/release/v1.1.0'])
45
+ expect(outcome).toEqual({ ran: true, added: 2, removed: 0 })
46
+ })
47
+
48
+ it('still opens the bare project root when there are worktrees but relaunch is not skipped', async () => {
49
+ const outcome = await openZedWorkspace({
50
+ projectRoot: '/repo',
51
+ worktreeDir: '/repo.worktrees',
52
+ currentBranches: [],
53
+ skipRelaunchWhenEmpty: false,
54
+ })
55
+
56
+ expect(zx.calls).toHaveLength(1)
57
+ expect(zx.calls[0]?.[0]).toEqual(['/repo'])
58
+ expect(outcome).toEqual({ ran: true, added: 0, removed: 0 })
59
+ })
60
+
61
+ it('skips launching when skipRelaunchWhenEmpty is set and there are no worktrees', async () => {
62
+ const outcome = await openZedWorkspace({
63
+ projectRoot: '/repo',
64
+ worktreeDir: '/repo.worktrees',
65
+ currentBranches: [],
66
+ skipRelaunchWhenEmpty: true,
67
+ })
68
+
69
+ expect(zx.calls).toHaveLength(0)
70
+ expect(outcome).toEqual({ ran: false, added: 0, removed: 0 })
71
+ })
72
+
73
+ it('swallows a launch failure into a best-effort warning', async () => {
74
+ zx.shouldThrow = true
75
+
76
+ const outcome = await openZedWorkspace({
77
+ projectRoot: '/repo',
78
+ worktreeDir: '/repo.worktrees',
79
+ currentBranches: ['release/v1.0.0'],
80
+ skipRelaunchWhenEmpty: false,
81
+ })
82
+
83
+ expect(outcome).toEqual({ ran: false, added: 0, removed: 0 })
84
+ })
85
+ })