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,242 @@
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
+ resolveCursorWorkspacePath: vi.fn((value: string) => {
37
+ return `/abs/${value}`
38
+ }),
39
+ }
40
+ })
41
+
42
+ vi.mock('src/integrations/cursor', () => {
43
+ return cursor
44
+ })
45
+
46
+ const zed = vi.hoisted(() => {
47
+ return {
48
+ openZedWorkspace: vi.fn(),
49
+ addFoldersToZedWorkspace: vi.fn(),
50
+ }
51
+ })
52
+
53
+ vi.mock('src/integrations/zed', () => {
54
+ return zed
55
+ })
56
+
57
+ const cursorEntry = { provider: 'cursor', config: { workspaceConfigPath: 'ws' } }
58
+ const zedEntry = { provider: 'zed', config: {} }
59
+ const cursorConfig = { ide: cursorEntry }
60
+ const zedConfig = { ide: zedEntry }
61
+ const bothConfig = { ide: [cursorEntry, zedEntry] }
62
+ const unconfigured = { ide: undefined }
63
+
64
+ const baseOpenArgs = {
65
+ projectRoot: '/repo',
66
+ worktreeDir: '/repo.worktrees',
67
+ currentBranches: ['release/v1.0.0'],
68
+ skipRelaunchWhenEmpty: false,
69
+ }
70
+
71
+ beforeEach(() => {
72
+ vi.clearAllMocks()
73
+ cursor.resolveCursorWorkspacePath.mockImplementation((value: string) => {
74
+ return `/abs/${value}`
75
+ })
76
+ })
77
+
78
+ describe('openIdeWorkspace', () => {
79
+ it('routes to Cursor and tags the provider (passing the cursor config down)', async () => {
80
+ config.value = cursorConfig
81
+ cursor.openCursorWorkspace.mockResolvedValue({ ran: true, added: 1, removed: 0 })
82
+
83
+ const outcomes = await openIdeWorkspace(baseOpenArgs)
84
+
85
+ expect(cursor.openCursorWorkspace).toHaveBeenCalledWith({
86
+ ...baseOpenArgs,
87
+ cursorConfig: { workspaceConfigPath: 'ws' },
88
+ })
89
+ expect(zed.openZedWorkspace).not.toHaveBeenCalled()
90
+ expect(outcomes).toEqual([{ ran: true, added: 1, removed: 0, provider: 'cursor' }])
91
+ })
92
+
93
+ it('routes to Zed and tags the provider', async () => {
94
+ config.value = zedConfig
95
+ zed.openZedWorkspace.mockResolvedValue({ ran: true, added: 2, removed: 0 })
96
+
97
+ const outcomes = await openIdeWorkspace(baseOpenArgs)
98
+
99
+ expect(zed.openZedWorkspace).toHaveBeenCalledWith(baseOpenArgs)
100
+ expect(cursor.openCursorWorkspace).not.toHaveBeenCalled()
101
+ expect(outcomes).toEqual([{ ran: true, added: 2, removed: 0, provider: 'zed' }])
102
+ })
103
+
104
+ it('opens BOTH editors when both are configured (one outcome per provider)', async () => {
105
+ config.value = bothConfig
106
+ cursor.openCursorWorkspace.mockResolvedValue({ ran: true, added: 1, removed: 0 })
107
+ zed.openZedWorkspace.mockResolvedValue({ ran: true, added: 2, removed: 0 })
108
+
109
+ const outcomes = await openIdeWorkspace(baseOpenArgs)
110
+
111
+ expect(cursor.openCursorWorkspace).toHaveBeenCalledTimes(1)
112
+ expect(zed.openZedWorkspace).toHaveBeenCalledTimes(1)
113
+ expect(outcomes).toEqual([
114
+ { ran: true, added: 1, removed: 0, provider: 'cursor' },
115
+ { ran: true, added: 2, removed: 0, provider: 'zed' },
116
+ ])
117
+ })
118
+
119
+ it('returns an empty array when no IDE is configured', async () => {
120
+ config.value = unconfigured
121
+
122
+ const outcomes = await openIdeWorkspace(baseOpenArgs)
123
+
124
+ expect(cursor.openCursorWorkspace).not.toHaveBeenCalled()
125
+ expect(zed.openZedWorkspace).not.toHaveBeenCalled()
126
+ expect(outcomes).toEqual([])
127
+ })
128
+ })
129
+
130
+ describe('addIdeWorktreeFolders', () => {
131
+ const args = { projectRoot: '/repo', worktreeDir: '/repo.worktrees', branches: ['release/v1.0.0'] }
132
+
133
+ it('adds to the Cursor workspace and reports skipped', async () => {
134
+ config.value = cursorConfig
135
+ cursor.addFoldersToCursorWorkspace.mockResolvedValue({ added: ['a'], skipped: ['b'] })
136
+
137
+ const outcomes = await addIdeWorktreeFolders(args)
138
+
139
+ expect(cursor.resolveCursorWorkspacePath).toHaveBeenCalledWith('ws', '/repo')
140
+ expect(cursor.addFoldersToCursorWorkspace).toHaveBeenCalled()
141
+ expect(outcomes).toEqual([{ ran: true, provider: 'cursor', added: 1, skipped: 1 }])
142
+ })
143
+
144
+ it('skips Cursor when workspaceConfigPath is missing', async () => {
145
+ config.value = { ide: { provider: 'cursor', config: {} } }
146
+
147
+ const outcomes = await addIdeWorktreeFolders(args)
148
+
149
+ expect(cursor.addFoldersToCursorWorkspace).not.toHaveBeenCalled()
150
+ expect(outcomes).toEqual([{ ran: false, provider: 'cursor', added: 0, skipped: 0 }])
151
+ })
152
+
153
+ it('adds to Zed via zed --add (skipped always 0)', async () => {
154
+ config.value = zedConfig
155
+ zed.addFoldersToZedWorkspace.mockResolvedValue({ added: ['a', 'b'] })
156
+
157
+ const outcomes = await addIdeWorktreeFolders(args)
158
+
159
+ expect(zed.addFoldersToZedWorkspace).toHaveBeenCalled()
160
+ expect(outcomes).toEqual([{ ran: true, provider: 'zed', added: 2, skipped: 0 }])
161
+ })
162
+
163
+ it('adds to BOTH editors when both are configured', async () => {
164
+ config.value = bothConfig
165
+ cursor.addFoldersToCursorWorkspace.mockResolvedValue({ added: ['a'], skipped: [] })
166
+ zed.addFoldersToZedWorkspace.mockResolvedValue({ added: ['a', 'b'] })
167
+
168
+ const outcomes = await addIdeWorktreeFolders(args)
169
+
170
+ expect(outcomes).toEqual([
171
+ { ran: true, provider: 'cursor', added: 1, skipped: 0 },
172
+ { ran: true, provider: 'zed', added: 2, skipped: 0 },
173
+ ])
174
+ })
175
+
176
+ it('returns an empty array when no IDE is configured', async () => {
177
+ config.value = unconfigured
178
+
179
+ const outcomes = await addIdeWorktreeFolders(args)
180
+
181
+ expect(outcomes).toEqual([])
182
+ })
183
+ })
184
+
185
+ describe('removeIdeWorktreeFolders', () => {
186
+ const args = { projectRoot: '/repo', worktreeDir: '/repo.worktrees', branches: ['release/v1.0.0'] }
187
+
188
+ it('removes from the Cursor workspace', async () => {
189
+ config.value = cursorConfig
190
+ cursor.removeFoldersFromCursorWorkspace.mockResolvedValue({ removed: ['x'], notFound: [] })
191
+
192
+ const outcomes = await removeIdeWorktreeFolders(args)
193
+
194
+ expect(cursor.removeFoldersFromCursorWorkspace).toHaveBeenCalled()
195
+ expect(outcomes).toEqual([{ provider: 'cursor', supported: true, removed: ['x'] }])
196
+ })
197
+
198
+ it('is a no-op for Cursor when workspaceConfigPath is missing', async () => {
199
+ config.value = { ide: { provider: 'cursor', config: {} } }
200
+
201
+ const outcomes = await removeIdeWorktreeFolders(args)
202
+
203
+ expect(cursor.removeFoldersFromCursorWorkspace).not.toHaveBeenCalled()
204
+ expect(outcomes).toEqual([{ provider: 'cursor', supported: true, removed: [] }])
205
+ })
206
+
207
+ it('reports unsupported for Zed without throwing', async () => {
208
+ config.value = zedConfig
209
+
210
+ const outcomes = await removeIdeWorktreeFolders(args)
211
+
212
+ expect(outcomes).toEqual([{ provider: 'zed', supported: false, removed: [] }])
213
+ })
214
+
215
+ it('removes from Cursor AND reports Zed unsupported when both are configured', async () => {
216
+ config.value = bothConfig
217
+ cursor.removeFoldersFromCursorWorkspace.mockResolvedValue({ removed: ['x'], notFound: [] })
218
+
219
+ const outcomes = await removeIdeWorktreeFolders(args)
220
+
221
+ expect(outcomes).toEqual([
222
+ { provider: 'cursor', supported: true, removed: ['x'] },
223
+ { provider: 'zed', supported: false, removed: [] },
224
+ ])
225
+ })
226
+
227
+ it('short-circuits to an empty array when there are no branches (no config read needed)', async () => {
228
+ config.value = zedConfig
229
+
230
+ const outcomes = await removeIdeWorktreeFolders({ ...args, branches: [] })
231
+
232
+ expect(outcomes).toEqual([])
233
+ })
234
+
235
+ it('returns an empty array when no IDE is configured', async () => {
236
+ config.value = unconfigured
237
+
238
+ const outcomes = await removeIdeWorktreeFolders(args)
239
+
240
+ expect(outcomes).toEqual([])
241
+ })
242
+ })
@@ -0,0 +1,79 @@
1
+ import { $ } from 'zx'
2
+
3
+ import { addFoldersToCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
4
+ import { addFoldersToZedWorkspace } from 'src/integrations/zed'
5
+ import { assertNever } from 'src/lib/assert-never'
6
+ import { getInfraKitConfig, resolveConfiguredIdes } from 'src/lib/infra-kit-config'
7
+ import { logger } from 'src/lib/logger'
8
+
9
+ import type { AddIdeWorktreeFoldersOutcome } from './types'
10
+
11
+ interface AddIdeWorktreeFoldersArgs {
12
+ projectRoot: string
13
+ worktreeDir: string
14
+ branches: string[]
15
+ }
16
+
17
+ /**
18
+ * Workspace-mode add (used by `worktrees-add`): append the given worktree folders
19
+ * to every configured editor's workspace and open it. Returns one outcome per
20
+ * configured provider (empty array when no IDE is configured), iterating
21
+ * sequentially.
22
+ *
23
+ * Cursor appends to the `.code-workspace` `folders` array (reporting `skipped`
24
+ * for already-present entries) then runs `cursor <workspace>`. Zed has no
25
+ * workspace file, so it `zed --add`s each folder (`skipped` is always 0).
26
+ */
27
+ export const addIdeWorktreeFolders = async (
28
+ args: AddIdeWorktreeFoldersArgs,
29
+ ): Promise<AddIdeWorktreeFoldersOutcome[]> => {
30
+ const { projectRoot, worktreeDir, branches } = args
31
+
32
+ const config = await getInfraKitConfig()
33
+ const ides = resolveConfiguredIdes(config)
34
+
35
+ const folderPaths = branches.map((branch) => {
36
+ return `${worktreeDir}/${branch}`
37
+ })
38
+
39
+ const outcomes: AddIdeWorktreeFoldersOutcome[] = []
40
+
41
+ for (const ide of ides) {
42
+ switch (ide.provider) {
43
+ case 'cursor': {
44
+ if (!ide.config.workspaceConfigPath) {
45
+ logger.warn('⚠️ Skipping Cursor: ide.config.workspaceConfigPath is not set in infra-kit config')
46
+
47
+ outcomes.push({ ran: false, provider: 'cursor', added: 0, skipped: 0 })
48
+ break
49
+ }
50
+
51
+ const workspacePath = resolveCursorWorkspacePath(ide.config.workspaceConfigPath, projectRoot)
52
+
53
+ const { added, skipped } = await addFoldersToCursorWorkspace({ workspacePath, folderPaths })
54
+
55
+ const skippedSuffix = skipped.length > 0 ? ` (${skipped.length} already present)` : ''
56
+
57
+ logger.info(`✅ Added ${added.length} folder(s) to ${workspacePath}${skippedSuffix}`)
58
+
59
+ await $`cursor ${workspacePath}`
60
+
61
+ outcomes.push({ ran: true, provider: 'cursor', added: added.length, skipped: skipped.length })
62
+ break
63
+ }
64
+ case 'zed': {
65
+ const { added } = await addFoldersToZedWorkspace({ folderPaths })
66
+
67
+ logger.info(`✅ Added ${added.length} folder(s) to Zed workspace`)
68
+
69
+ outcomes.push({ ran: true, provider: 'zed', added: added.length, skipped: 0 })
70
+ break
71
+ }
72
+ default: {
73
+ assertNever(ide)
74
+ }
75
+ }
76
+ }
77
+
78
+ return outcomes
79
+ }
@@ -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
+ })
@@ -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'