infra-kit 0.1.110 → 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 (96) 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/agent-replay-d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc.jsonl +10 -0
  7. package/.omc/state/idle-notif-cooldown.json +1 -1
  8. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/mission-state.json +61 -0
  9. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/pre-tool-advisory-throttle.json +22 -0
  10. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/skill-active-state.json +15 -0
  11. package/.omc/state/sessions/127837d3-a000-4656-9453-2324ab353723/subagent-tracking-state.json +26 -0
  12. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/last-tool-error-state.json +7 -0
  13. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/mission-state.json +89 -0
  14. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/pre-tool-advisory-throttle.json +34 -0
  15. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/ralph-state.json +13 -0
  16. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/skill-active-state.json +15 -0
  17. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/subagent-tracking-state.json +44 -0
  18. package/.omc/state/sessions/15c409af-24fa-4026-88d3-a82dd1e68b87/ultrawork-state.json +11 -0
  19. package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/pre-tool-advisory-throttle.json +18 -0
  20. package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/skill-active-state.json +15 -0
  21. package/.omc/state/sessions/74afa912-1443-4011-b4f2-db9f055cdd9f/subagent-tracking-state.json +17 -0
  22. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +14 -0
  23. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ralph-state.json +13 -0
  24. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/subagent-tracking-state.json +17 -0
  25. package/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/ultrawork-state.json +11 -0
  26. package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/last-tool-error-state.json +7 -0
  27. package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/mission-state.json +103 -0
  28. package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/pre-tool-advisory-throttle.json +30 -0
  29. package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/ralph-state.json +13 -0
  30. package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/subagent-tracking-state.json +62 -0
  31. package/.omc/state/sessions/d5aae73c-340d-4aa2-9fe0-1bf2d79b69dc/ultrawork-state.json +11 -0
  32. package/.turbo/turbo-build.log +6 -6
  33. package/.turbo/turbo-eslint-check.log +1 -1
  34. package/.turbo/turbo-prettier-check.log +1 -1
  35. package/.turbo/turbo-prettier-fix.log +1 -2
  36. package/.turbo/turbo-test.log +79 -70
  37. package/.turbo/turbo-ts-check.log +1 -1
  38. package/dist/cli.js +64 -56
  39. package/dist/cli.js.map +4 -4
  40. package/dist/mcp.js +39 -39
  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/commands/doctor/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +14 -0
  51. package/src/commands/doctor/__tests__/check-ide-installed.test.ts +142 -0
  52. package/src/commands/doctor/doctor.ts +98 -1
  53. package/src/commands/gh-release-deliver/gh-release-deliver.ts +32 -1
  54. package/src/commands/init/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +18 -0
  55. package/src/commands/init/__tests__/migrate-config.test.ts +132 -1
  56. package/src/commands/init/init.ts +19 -6
  57. package/src/commands/init/migrate-config.ts +104 -0
  58. package/src/commands/release-create/release-create.ts +5 -13
  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 +35 -25
  68. package/src/integrations/cursor/index.ts +1 -0
  69. package/src/integrations/cursor/open-cursor-workspace.ts +70 -0
  70. package/src/integrations/ide/__tests__/ide-facade.test.ts +242 -0
  71. package/src/integrations/ide/add-ide-worktree-folders.ts +79 -0
  72. package/src/integrations/ide/index.ts +6 -0
  73. package/src/integrations/ide/open-ide-workspace.ts +51 -0
  74. package/src/integrations/ide/provider-label.ts +24 -0
  75. package/src/integrations/ide/remove-ide-worktree-folders.ts +80 -0
  76. package/src/integrations/ide/types.ts +32 -0
  77. package/src/integrations/zed/__tests__/open-zed-workspace.test.ts +85 -0
  78. package/src/integrations/zed/add-folders-to-zed-workspace.ts +30 -0
  79. package/src/integrations/zed/index.ts +2 -0
  80. package/src/integrations/zed/open-zed-workspace.ts +57 -0
  81. package/src/lib/assert-never/assert-never.ts +15 -0
  82. package/src/lib/assert-never/index.ts +1 -0
  83. package/src/lib/git-utils/__tests__/git-primitives.test.ts +122 -1
  84. package/src/lib/git-utils/git-utils.ts +35 -0
  85. package/src/lib/git-utils/index.ts +2 -0
  86. package/src/lib/infra-kit-config/.omc/state/sessions/8dd0ad2f-3a90-4158-b9de-ec8e19a7c983/pre-tool-advisory-throttle.json +18 -0
  87. package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +172 -9
  88. package/src/lib/infra-kit-config/index.ts +2 -1
  89. package/src/lib/infra-kit-config/infra-kit-config.ts +80 -22
  90. package/src/lib/package-config/.omc/state/sessions/dde22a92-a0e9-4948-905d-25c9a812ab35/last-tool-error-state.json +7 -0
  91. package/src/lib/package-config/.omc/state/sessions/dde22a92-a0e9-4948-905d-25c9a812ab35/pre-tool-advisory-throttle.json +10 -0
  92. package/src/lib/version-utils/__tests__/next-version.test.ts +84 -0
  93. package/src/lib/version-utils/index.ts +1 -0
  94. package/src/lib/version-utils/next-version.ts +56 -15
  95. package/src/mcp/tools/index.ts +2 -0
  96. package/tsconfig.tsbuildinfo +1 -1
@@ -6,7 +6,7 @@ import { logger } from 'src/lib/logger'
6
6
  import { removeManagedBlock, upsertManagedBlock } from 'src/lib/managed-block'
7
7
 
8
8
  import { writeAgentFiles } from './agent-files'
9
- import { migrateLegacyConfig } from './migrate-config'
9
+ import { migrateLegacyConfig, normalizeLegacyIdeStructures } from './migrate-config'
10
10
 
11
11
  export const MARKER_START = '# -- infra-kit:begin --'
12
12
  export const MARKER_END = '# -- infra-kit:end --'
@@ -36,18 +36,27 @@ const USER_GLOBAL_CONFIG_EXAMPLE = `// infra-kit user-global config — ~/.infra
36
36
  {
37
37
  // "ide": {
38
38
  // "provider": "cursor",
39
- // "config": { "mode": "workspace", "workspaceConfigPath": "/path/to/your.code-workspace" }
39
+ // "config": { "workspaceConfigPath": "/path/to/your.code-workspace" }
40
40
  // },
41
+ // // Or, for Zed (no workspace file — one window with all worktrees via "zed <root> <wt...>"):
42
+ // "ide": { "provider": "zed", "config": {} },
43
+ // // Or drive BOTH editors at once with an array (at most one entry per provider):
44
+ // "ide": [
45
+ // { "provider": "cursor", "config": { "workspaceConfigPath": "/path/to/your.code-workspace" } },
46
+ // { "provider": "zed", "config": {} }
47
+ // ],
41
48
  // "worktrees": { "openInGithubDesktop": false, "openInCmux": true }
42
49
  }
43
50
  `
44
51
 
45
52
  /**
46
53
  * Append infra-kit shell functions to .zshrc, migrate any legacy
47
- * `infra-kit.yml` config layers to JSON, and seed the user-global config at
48
- * ~/.infra-kit/config.json on first run. Idempotent: a subsequent run replaces
49
- * the existing zshrc block in place, has nothing left to migrate, and leaves
50
- * the user-global config untouched.
54
+ * `infra-kit.yml` config layers to JSON, normalize existing JSON configs from
55
+ * the old IDE structure to the new one (strip the removed `ide.config.mode`),
56
+ * and seed the user-global config at ~/.infra-kit/config.json on first run.
57
+ * Idempotent: a subsequent run replaces the existing zshrc block in place, has
58
+ * nothing left to migrate or normalize, and leaves the user-global config
59
+ * untouched.
51
60
  *
52
61
  * @example
53
62
  * // CLI: `infra-kit init` (or via the `pnpm dx-init` alias)
@@ -79,6 +88,10 @@ export const init = async (): Promise<void> => {
79
88
  // migrated config.json is not re-seeded as an empty stub.
80
89
  await migrateLegacyConfig()
81
90
 
91
+ // Migrate existing JSON configs from the old IDE structure to the new one
92
+ // (strip the removed `ide.config.mode` field). No-op for already-clean configs.
93
+ await normalizeLegacyIdeStructures()
94
+
82
95
  seedUserGlobalConfig()
83
96
 
84
97
  // Best-effort, non-fatal, repo-gated: keep the agent-instruction files in sync
@@ -144,3 +144,107 @@ export const migrateLegacyConfig = async (): Promise<void> => {
144
144
  resetInfraKitConfigCache()
145
145
  }
146
146
  }
147
+
148
+ /**
149
+ * Surgically strip the removed `ide.config.mode` field from a parsed config
150
+ * object (single-provider or array form). The windows-removal made `mode` a dead
151
+ * key — the loader already ignores it, so this only matters for keeping the
152
+ * on-disk file clean. Returns `changed: false` (and the input untouched) when
153
+ * there is no `mode` to remove, so callers can skip rewriting clean files.
154
+ *
155
+ * Only `mode` is removed — every other key is preserved verbatim (no full-schema
156
+ * re-validation), so a config that is otherwise invalid or carries forward-compat
157
+ * keys is never altered beyond the dead field.
158
+ */
159
+ const stripLegacyIdeMode = (parsed: Record<string, unknown>): { changed: boolean; result: Record<string, unknown> } => {
160
+ const ide = parsed.ide
161
+
162
+ if (ide === null || typeof ide !== 'object') {
163
+ return { changed: false, result: parsed }
164
+ }
165
+
166
+ let changed = false
167
+
168
+ const stripEntry = (entry: unknown): unknown => {
169
+ if (entry === null || typeof entry !== 'object' || !('config' in entry)) {
170
+ return entry
171
+ }
172
+
173
+ const config = (entry as { config: unknown }).config
174
+
175
+ if (config === null || typeof config !== 'object' || !('mode' in config)) {
176
+ return entry
177
+ }
178
+
179
+ changed = true
180
+
181
+ const restConfig = Object.fromEntries(
182
+ Object.entries(config as Record<string, unknown>).filter(([key]) => {
183
+ return key !== 'mode'
184
+ }),
185
+ )
186
+
187
+ return { ...(entry as Record<string, unknown>), config: restConfig }
188
+ }
189
+
190
+ const nextIde = Array.isArray(ide) ? ide.map(stripEntry) : stripEntry(ide)
191
+
192
+ if (!changed) {
193
+ return { changed: false, result: parsed }
194
+ }
195
+
196
+ return { changed: true, result: { ...parsed, ide: nextIde } }
197
+ }
198
+
199
+ /**
200
+ * Normalize any existing `infra-kit.json` config layers (project, user-global,
201
+ * user-project) from the old IDE structure to the new one by removing the
202
+ * removed `ide.config.mode` field. Run by `infra-kit init` after the YAML→JSON
203
+ * migration. Best-effort and non-fatal per layer; only rewrites a file when its
204
+ * `ide` config actually carries the dead key, so clean configs are left byte-for-
205
+ * byte untouched (idempotent). Resets the config cache when anything changed.
206
+ *
207
+ * @example
208
+ * await normalizeLegacyIdeStructures()
209
+ * // ✓ Normalized ide config in infra-kit.json (removed legacy "mode")
210
+ * // (no output when no config carries a legacy "mode")
211
+ */
212
+ export const normalizeLegacyIdeStructures = async (): Promise<void> => {
213
+ let paths: Awaited<ReturnType<typeof getInfraKitConfigPaths>>
214
+
215
+ try {
216
+ paths = await getInfraKitConfigPaths()
217
+ } catch {
218
+ return
219
+ }
220
+
221
+ const jsonPaths = [paths.main, paths.userGlobal, paths.userProject]
222
+
223
+ let normalized = 0
224
+
225
+ for (const jsonPath of jsonPaths) {
226
+ if (!(await fileExists(jsonPath))) continue
227
+
228
+ try {
229
+ const raw = await fs.readFile(jsonPath, 'utf-8')
230
+
231
+ if (raw.trim() === '') continue
232
+
233
+ const parsed = JSON.parse(raw) as Record<string, unknown>
234
+ const { changed, result } = stripLegacyIdeMode(parsed)
235
+
236
+ if (!changed) continue
237
+
238
+ await fs.writeFile(jsonPath, `${JSON.stringify(result, null, 2)}\n`, 'utf-8')
239
+
240
+ logger.info(`✓ Normalized ide config in ${tildify(jsonPath)} (removed legacy "mode")`)
241
+ normalized++
242
+ } catch (err) {
243
+ logger.info(`⚠ Skipped normalizing ${tildify(jsonPath)} — ${(err as Error).message}`)
244
+ }
245
+ }
246
+
247
+ if (normalized > 0) {
248
+ resetInfraKitConfigCache()
249
+ }
250
+ }
@@ -15,6 +15,7 @@ import type { ReleaseCreationResult, ReleaseType } from 'src/lib/release-utils'
15
15
  import {
16
16
  NoPriorVersionsError,
17
17
  computeNextVersion,
18
+ formatReleaseSpec,
18
19
  hasNextToken,
19
20
  loadExistingVersions,
20
21
  parseVersion,
@@ -171,20 +172,11 @@ const formatReleaseSummary = (entry: ReleaseEntry): string => {
171
172
  }
172
173
 
173
174
  const echoReleases = (entries: ReleaseEntry[]): void => {
175
+ // Every entry — versions, "next" (resolved to a concrete version), and named
176
+ // releases alike — echoes through the single --release flag. formatReleaseSpec
177
+ // produces a spec that parseReleaseSpec parses back into the same entry.
174
178
  for (const entry of entries) {
175
- if (entry.id.kind === 'name') {
176
- // Named releases echo the --name form; description is not part of the
177
- // flag and is set later via release-desc-edit / interactively.
178
- commandEcho.addOption('--name', entry.id.name)
179
-
180
- continue
181
- }
182
-
183
- const spec = entry.description
184
- ? `${entry.id.raw}:${entry.type}:${entry.description}`
185
- : `${entry.id.raw}:${entry.type}`
186
-
187
- commandEcho.addOption('--release', spec)
179
+ commandEcho.addOption('--release', formatReleaseSpec(entry))
188
180
  }
189
181
  }
190
182
 
@@ -1,2 +1 @@
1
- export { CURSOR_MODES, worktreesAdd, worktreesAddMcpTool } from './worktrees-add'
2
- export type { CursorMode } from './worktrees-add'
1
+ export { worktreesAdd, worktreesAddMcpTool } from './worktrees-add'
@@ -1,7 +1,6 @@
1
1
  /* eslint-disable sonarjs/cognitive-complexity */
2
2
  import checkbox from '@inquirer/checkbox'
3
3
  import confirm from '@inquirer/confirm'
4
- import select from '@inquirer/select'
5
4
  import process from 'node:process'
6
5
  import { z } from 'zod'
7
6
  import { $ } from 'zx'
@@ -12,14 +11,15 @@ import {
12
11
  listCmuxWorkspaceTitles,
13
12
  openCmuxWorkspaceWithLayout,
14
13
  } from 'src/integrations/cmux'
15
- import { addFoldersToCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
16
14
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
15
+ import { IDE_MODES, addIdeWorktreeFolders } from 'src/integrations/ide'
16
+ import type { IdeMode } from 'src/integrations/ide'
17
17
  import { commandEcho } from 'src/lib/command-echo'
18
18
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
19
19
  import { OperationError } from 'src/lib/errors/operation-error'
20
20
  import { assertManagementContext } from 'src/lib/git-guard'
21
21
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
22
- import { getInfraKitConfig } from 'src/lib/infra-kit-config'
22
+ import { getInfraKitConfig, resolveConfiguredIdes } from 'src/lib/infra-kit-config'
23
23
  import { logger } from 'src/lib/logger'
24
24
  import { formatBranchName, isReleaseBranch, parseReleaseRef } from 'src/lib/release-id'
25
25
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions, releaseBranchLabels } from 'src/lib/release-utils'
@@ -31,13 +31,12 @@ import type { RequiredConfirmedOptionArg } from 'src/types'
31
31
  const FEATURE_DIR = 'feature'
32
32
  const RELEASE_DIR = 'release'
33
33
 
34
- export const CURSOR_MODES = ['workspace', 'windows', 'none'] as const
35
- export type CursorMode = (typeof CURSOR_MODES)[number]
36
-
37
34
  interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
38
35
  all?: boolean
39
36
  versions?: string
40
- cursor?: CursorMode
37
+ ide?: IdeMode
38
+ /** @deprecated Alias for `ide`, kept for back-compat. Ignored when `ide` is set. */
39
+ cursor?: IdeMode
41
40
  githubDesktop?: boolean
42
41
  cmux?: boolean
43
42
  }
@@ -47,7 +46,9 @@ interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
47
46
  * Creates worktrees for active release branches and removes unused ones
48
47
  */
49
48
  export const worktreesAdd = async (options: WorktreeManagementArgs) => {
50
- const { confirmedCommand, all, versions, cursor, githubDesktop, cmux } = options
49
+ const { confirmedCommand, all, versions, githubDesktop, cmux } = options
50
+ // `cursor` is the deprecated alias for `ide`; `ide` wins when both are present.
51
+ const ide = options.ide ?? options.cursor
51
52
 
52
53
  commandEcho.start('worktrees-add')
53
54
 
@@ -136,34 +137,13 @@ export const worktreesAdd = async (options: WorktreeManagementArgs) => {
136
137
  }
137
138
 
138
139
  const config = await getInfraKitConfig()
139
- const cursorConfig = config.ide?.provider === 'cursor' ? config.ide.config : undefined
140
-
141
- const cursorMode: CursorMode =
142
- cursor ??
143
- cursorConfig?.mode ??
144
- (await select<CursorMode>({
145
- message: 'Cursor mode for created worktrees?',
146
- default: 'workspace',
147
- choices: [
148
- {
149
- name: 'Add to workspace file',
150
- value: 'workspace',
151
- description: 'Append each worktree as a folder in ide.config.workspaceConfigPath, then open the workspace',
152
- },
153
- {
154
- name: 'Open separate windows',
155
- value: 'windows',
156
- description: 'Open each created worktree in its own Cursor window',
157
- },
158
- { name: 'Skip', value: 'none', description: 'Do not open Cursor' },
159
- ],
160
- }))
161
-
162
- if (typeof cursor === 'undefined' && !cursorConfig?.mode) {
163
- commandEcho.setInteractive()
164
- }
165
140
 
166
- commandEcho.addOption('--cursor', cursorMode)
141
+ // One attach style: every configured editor gets the worktrees added to its
142
+ // workspace. Per-run skip is `--ide none` / `--no-ide`; no editor configured
143
+ // means there's nothing to open.
144
+ const ideMode: IdeMode = ide ?? (resolveConfiguredIdes(config).length > 0 ? 'workspace' : 'none')
145
+
146
+ commandEcho.addOption('--ide', ideMode)
167
147
 
168
148
  const openInGithubDesktop =
169
149
  githubDesktop ??
@@ -202,28 +182,8 @@ export const worktreesAdd = async (options: WorktreeManagementArgs) => {
202
182
 
203
183
  logResults(createdWorktrees)
204
184
 
205
- if (cursorMode === 'workspace') {
206
- if (!cursorConfig?.workspaceConfigPath) {
207
- logger.warn('⚠️ Skipping Cursor: ide.config.workspaceConfigPath is not set in infra-kit config')
208
- } else {
209
- const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
210
-
211
- const folderPaths = createdWorktrees.map((branch) => {
212
- return `${worktreeDir}/${branch}`
213
- })
214
-
215
- const { added, skipped } = await addFoldersToCursorWorkspace({ workspacePath, folderPaths })
216
-
217
- const skippedSuffix = skipped.length > 0 ? ` (${skipped.length} already present)` : ''
218
-
219
- logger.info(`✅ Added ${added.length} folder(s) to ${workspacePath}${skippedSuffix}`)
220
-
221
- await $`cursor ${workspacePath}`
222
- }
223
- } else if (cursorMode === 'windows') {
224
- for (const branch of createdWorktrees) {
225
- await $`cursor ${worktreeDir}/${branch}`
226
- }
185
+ if (ideMode === 'workspace') {
186
+ await addIdeWorktreeFolders({ projectRoot, worktreeDir, branches: createdWorktrees })
227
187
  }
228
188
 
229
189
  if (openInGithubDesktop) {
@@ -369,12 +329,16 @@ export const worktreesAddMcpTool = defineMcpTool({
369
329
  .describe(
370
330
  'Comma-separated release versions or names to target (e.g. "1.2.5, 1.2.6" or "checkout-redesign, 1.2.5"). Either "versions" or all=true must be provided for MCP calls. Overrides "all" when set.',
371
331
  ),
372
- cursor: z
373
- .enum(CURSOR_MODES)
332
+ ide: z
333
+ .enum(IDE_MODES)
374
334
  .optional()
375
335
  .describe(
376
- 'Cursor open mode for created worktrees. "workspace" appends each worktree as a folder to "ide.config.workspaceConfigPath" in infra-kit config and opens the workspace. "windows" opens each worktree in its own Cursor window. "none" skips Cursor. Resolution order: this flag → "ide.config.mode" from infra-kit config interactive prompt (CLI) / "none" (MCP, no TTY).',
336
+ 'Editor open mode for created worktrees, applied to all configured editors (Cursor and/or Zed, per the "ide" config). "workspace" (the only attach style) adds each worktree to every configured editor workspace and opens it. "none" skips the editor. Resolution order: this flag → "workspace" when at least one "ide" is configured "none" otherwise.',
377
337
  ),
338
+ cursor: z
339
+ .enum(IDE_MODES)
340
+ .optional()
341
+ .describe('Deprecated alias for "ide". Prefer "ide". Ignored when "ide" is provided.'),
378
342
  githubDesktop: z
379
343
  .boolean()
380
344
  .optional()
@@ -1,5 +1,4 @@
1
1
  import { z } from 'zod'
2
- import { $ } from 'zx'
3
2
 
4
3
  import {
5
4
  buildCmuxWorkspaceTitle,
@@ -7,26 +6,27 @@ import {
7
6
  listCmuxWorkspaceTitles,
8
7
  openCmuxWorkspaceWithLayout,
9
8
  } from 'src/integrations/cmux'
10
- import { reconcileCursorWorkspaceFolders, resolveCursorWorkspacePath } from 'src/integrations/cursor'
9
+ import { ideProviderLabel, openIdeWorkspace } from 'src/integrations/ide'
10
+ import type { IdeProvider } from 'src/integrations/ide'
11
11
  import { commandEcho } from 'src/lib/command-echo'
12
12
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
13
13
  import { OperationError } from 'src/lib/errors/operation-error'
14
14
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
15
- import { getInfraKitConfig } from 'src/lib/infra-kit-config'
16
15
  import { logger } from 'src/lib/logger'
17
16
  import { defineMcpTool, textContent } from 'src/types'
18
17
 
19
18
  interface WorktreesOpenResult {
20
19
  openedCmux: string[]
21
20
  skippedCmux: string[]
22
- cursorFoldersAdded: number
23
- cursorFoldersRemoved: number
21
+ ideProviders: IdeProvider[]
22
+ ideFoldersAdded: number
23
+ ideFoldersRemoved: number
24
24
  }
25
25
 
26
26
  /**
27
- * Cold-start restore command: reconciles `Main.code-workspace` against the set
28
- * of release worktrees on disk, opens Cursor against it, and ensures one cmux
29
- * workspace exists per worktree. Idempotent and additive — never removes
27
+ * Cold-start restore command: reconciles the configured editor workspace against
28
+ * the set of release worktrees on disk, opens the editor against it, and ensures
29
+ * one cmux workspace exists per worktree. Idempotent and additive — never removes
30
30
  * worktrees, never recreates running cmux workspaces.
31
31
  */
32
32
  export const worktreesOpen = async () => {
@@ -37,17 +37,38 @@ export const worktreesOpen = async () => {
37
37
  const worktreeDir = `${projectRoot}${WORKTREES_DIR_SUFFIX}`
38
38
  const currentBranches = await getCurrentWorktrees('release')
39
39
 
40
- const cursorOutcome = await openCursor({ projectRoot, worktreeDir, currentBranches })
40
+ const ideOutcomes = await openIdeWorkspace({
41
+ projectRoot,
42
+ worktreeDir,
43
+ currentBranches,
44
+ skipRelaunchWhenEmpty: false,
45
+ })
41
46
  const cmuxOutcome = await openCmux({ worktreeDir, currentBranches })
42
47
 
43
48
  const result: WorktreesOpenResult = {
44
49
  openedCmux: cmuxOutcome.opened,
45
50
  skippedCmux: cmuxOutcome.skipped,
46
- cursorFoldersAdded: cursorOutcome.added,
47
- cursorFoldersRemoved: cursorOutcome.removed,
51
+ ideProviders: ideOutcomes
52
+ .filter((outcome) => {
53
+ return outcome.ran
54
+ })
55
+ .map((outcome) => {
56
+ return outcome.provider
57
+ }),
58
+ ideFoldersAdded: ideOutcomes.reduce((sum, outcome) => {
59
+ return sum + outcome.added
60
+ }, 0),
61
+ ideFoldersRemoved: ideOutcomes.reduce((sum, outcome) => {
62
+ return sum + outcome.removed
63
+ }, 0),
48
64
  }
49
65
 
50
- logResults(result, { cursorRan: cursorOutcome.ran, cmuxRan: cmuxOutcome.ran })
66
+ logResults(result, {
67
+ ideRan: ideOutcomes.some((outcome) => {
68
+ return outcome.ran
69
+ }),
70
+ cmuxRan: cmuxOutcome.ran,
71
+ })
51
72
 
52
73
  commandEcho.print()
53
74
 
@@ -64,49 +85,6 @@ export const worktreesOpen = async () => {
64
85
  }
65
86
  }
66
87
 
67
- interface OpenCursorArgs {
68
- projectRoot: string
69
- worktreeDir: string
70
- currentBranches: string[]
71
- }
72
-
73
- interface OpenCursorOutcome {
74
- ran: boolean
75
- added: number
76
- removed: number
77
- }
78
-
79
- const openCursor = async (args: OpenCursorArgs): Promise<OpenCursorOutcome> => {
80
- const { projectRoot, worktreeDir, currentBranches } = args
81
-
82
- const config = await getInfraKitConfig()
83
- const cursorConfig = config.ide?.provider === 'cursor' ? config.ide.config : undefined
84
-
85
- if (!cursorConfig || cursorConfig.mode !== 'workspace' || !cursorConfig.workspaceConfigPath) {
86
- logger.warn('⚠️ Skipping Cursor: ide.provider must be "cursor", mode "workspace", and workspaceConfigPath set.')
87
-
88
- return { ran: false, added: 0, removed: 0 }
89
- }
90
-
91
- const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
92
-
93
- try {
94
- const { added, removed } = await reconcileCursorWorkspaceFolders({
95
- workspacePath,
96
- worktreeDir,
97
- currentBranches,
98
- })
99
-
100
- await $`cursor ${workspacePath}`
101
-
102
- return { ran: true, added: added.length, removed: removed.length }
103
- } catch (error) {
104
- logger.warn({ error }, `⚠️ Failed to reconcile/open Cursor workspace at ${workspacePath}`)
105
-
106
- return { ran: false, added: 0, removed: 0 }
107
- }
108
- }
109
-
110
88
  interface OpenCmuxArgs {
111
89
  worktreeDir: string
112
90
  currentBranches: string[]
@@ -153,18 +131,24 @@ export const openCmux = async (args: OpenCmuxArgs): Promise<OpenCmuxOutcome> =>
153
131
  }
154
132
 
155
133
  interface LogResultsContext {
156
- cursorRan: boolean
134
+ ideRan: boolean
157
135
  cmuxRan: boolean
158
136
  }
159
137
 
160
138
  const logResults = (result: WorktreesOpenResult, context: LogResultsContext): void => {
161
- if (context.cursorRan) {
162
- if (result.cursorFoldersAdded > 0) {
163
- logger.info(`✅ Added ${result.cursorFoldersAdded} folder(s) to Cursor workspace`)
139
+ if (context.ideRan) {
140
+ const ideLabels = result.ideProviders
141
+ .map((provider) => {
142
+ return ideProviderLabel(provider)
143
+ })
144
+ .join(', ')
145
+
146
+ if (result.ideFoldersAdded > 0) {
147
+ logger.info(`✅ Added ${result.ideFoldersAdded} folder(s) to ${ideLabels} workspace(s)`)
164
148
  }
165
149
 
166
- if (result.cursorFoldersRemoved > 0) {
167
- logger.info(`🧹 Removed ${result.cursorFoldersRemoved} dangling folder(s) from Cursor workspace`)
150
+ if (result.ideFoldersRemoved > 0) {
151
+ logger.info(`🧹 Removed ${result.ideFoldersRemoved} dangling folder(s) from ${ideLabels} workspace(s)`)
168
152
  }
169
153
  }
170
154
 
@@ -180,11 +164,11 @@ const logResults = (result: WorktreesOpenResult, context: LogResultsContext): vo
180
164
  }
181
165
 
182
166
  if (
183
- !context.cursorRan &&
167
+ !context.ideRan &&
184
168
  result.openedCmux.length === 0 &&
185
169
  result.skippedCmux.length === 0 &&
186
- result.cursorFoldersAdded === 0 &&
187
- result.cursorFoldersRemoved === 0
170
+ result.ideFoldersAdded === 0 &&
171
+ result.ideFoldersRemoved === 0
188
172
  ) {
189
173
  logger.info('ℹ️ Nothing to open')
190
174
  }
@@ -194,15 +178,18 @@ const logResults = (result: WorktreesOpenResult, context: LogResultsContext): vo
194
178
  export const worktreesOpenMcpTool = defineMcpTool({
195
179
  name: 'worktrees-open',
196
180
  description:
197
- 'Open Cursor against the configured workspace file and ensure a cmux workspace exists for each existing release worktree. Idempotent and additive — never removes worktrees, never recreates running cmux workspaces. Use after a cold start (Cursor + cmux closed). For stale-worktree cleanup, use worktrees-sync.',
181
+ 'Open every configured editor (Cursor and/or Zed) against its workspace and ensure a cmux workspace exists for each existing release worktree. Idempotent and additive — never removes worktrees, never recreates running cmux workspaces. Use after a cold start (editor + cmux closed). For stale-worktree cleanup, use worktrees-sync.',
198
182
  inputSchema: {},
199
183
  outputSchema: {
200
184
  openedCmux: z.array(z.string()).describe('Titles of cmux workspaces opened during this run'),
201
185
  skippedCmux: z.array(z.string()).describe('Titles of cmux workspaces that were already open'),
202
- cursorFoldersAdded: z.number().describe('Number of worktree folders added to the Cursor workspace file'),
203
- cursorFoldersRemoved: z
186
+ ideProviders: z
187
+ .array(z.string())
188
+ .describe('Configured IDE providers that were opened (cursor | zed); empty if none configured'),
189
+ ideFoldersAdded: z.number().describe('Total worktree folders added across all configured editor workspaces'),
190
+ ideFoldersRemoved: z
204
191
  .number()
205
- .describe('Number of dangling worktree folders removed from the Cursor workspace file'),
192
+ .describe('Total dangling worktree folders removed across all configured editor workspaces'),
206
193
  },
207
194
  handler: worktreesOpen,
208
195
  })
@@ -0,0 +1,119 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { closeCmuxWorkspaceByTitle, listCmuxWorkspaceTitles, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
4
+ import { getRepoName } from 'src/lib/git-utils'
5
+
6
+ import { closeCmux, reopenCmux } from '../worktrees-reload'
7
+
8
+ vi.mock('src/integrations/cmux', async (importActual) => {
9
+ const actual = await importActual<typeof import('src/integrations/cmux')>()
10
+
11
+ return {
12
+ ...actual,
13
+ listCmuxWorkspaceTitles: vi.fn(),
14
+ closeCmuxWorkspaceByTitle: vi.fn(),
15
+ openCmuxWorkspaceWithLayout: vi.fn(),
16
+ }
17
+ })
18
+
19
+ vi.mock('src/lib/git-utils', () => {
20
+ return {
21
+ getRepoName: vi.fn(),
22
+ getProjectRoot: vi.fn(),
23
+ getCurrentWorktrees: vi.fn(),
24
+ }
25
+ })
26
+
27
+ const REPO = 'hulyo-monorepo'
28
+ const WORKTREE_DIR = '/repos/project-worktrees'
29
+ const BRANCHES = ['release/v1.48.0', 'release/checkout-redesign']
30
+
31
+ const titleFor = {
32
+ versioned: `${REPO} 1.48.0`,
33
+ named: `${REPO} checkout-redesign`,
34
+ }
35
+
36
+ describe('closeCmux', () => {
37
+ beforeEach(() => {
38
+ vi.clearAllMocks()
39
+ vi.mocked(getRepoName).mockResolvedValue(REPO)
40
+ vi.mocked(closeCmuxWorkspaceByTitle).mockResolvedValue(undefined)
41
+ })
42
+
43
+ it('closes only the branches whose workspace is open in the snapshot', async () => {
44
+ vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set([titleFor.versioned]))
45
+
46
+ const closed = await closeCmux({ currentBranches: BRANCHES, repoName: REPO })
47
+
48
+ expect(closed).toEqual([titleFor.versioned])
49
+ expect(closeCmuxWorkspaceByTitle).toHaveBeenCalledTimes(1)
50
+ expect(closeCmuxWorkspaceByTitle).toHaveBeenCalledWith(titleFor.versioned)
51
+ })
52
+
53
+ it('closes every open branch when all are present in the snapshot', async () => {
54
+ vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set([titleFor.versioned, titleFor.named]))
55
+
56
+ const closed = await closeCmux({ currentBranches: BRANCHES, repoName: REPO })
57
+
58
+ expect(closed).toEqual([titleFor.versioned, titleFor.named])
59
+ expect(closeCmuxWorkspaceByTitle).toHaveBeenCalledTimes(2)
60
+ })
61
+
62
+ it('closes nothing when none are open', async () => {
63
+ vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set())
64
+
65
+ const closed = await closeCmux({ currentBranches: BRANCHES, repoName: REPO })
66
+
67
+ expect(closed).toEqual([])
68
+ expect(closeCmuxWorkspaceByTitle).not.toHaveBeenCalled()
69
+ })
70
+
71
+ it('returns [] and skips the snapshot when there are no worktrees', async () => {
72
+ const closed = await closeCmux({ currentBranches: [], repoName: REPO })
73
+
74
+ expect(closed).toEqual([])
75
+ expect(listCmuxWorkspaceTitles).not.toHaveBeenCalled()
76
+ expect(closeCmuxWorkspaceByTitle).not.toHaveBeenCalled()
77
+ })
78
+ })
79
+
80
+ describe('reopenCmux', () => {
81
+ beforeEach(() => {
82
+ vi.clearAllMocks()
83
+ vi.mocked(getRepoName).mockResolvedValue(REPO)
84
+ vi.mocked(openCmuxWorkspaceWithLayout).mockResolvedValue(undefined)
85
+ })
86
+
87
+ it('force-opens a workspace for every branch (no dedup)', async () => {
88
+ const result = await reopenCmux({ worktreeDir: WORKTREE_DIR, currentBranches: BRANCHES, repoName: REPO })
89
+
90
+ expect(result.opened).toEqual([titleFor.versioned, titleFor.named])
91
+ expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledTimes(2)
92
+ expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledWith({
93
+ cwd: `${WORKTREE_DIR}/release/v1.48.0`,
94
+ title: titleFor.versioned,
95
+ })
96
+ expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledWith({
97
+ cwd: `${WORKTREE_DIR}/release/checkout-redesign`,
98
+ title: titleFor.named,
99
+ })
100
+ })
101
+
102
+ it('continues past a per-branch failure and reopens the rest', async () => {
103
+ vi.mocked(openCmuxWorkspaceWithLayout)
104
+ .mockRejectedValueOnce(new Error('cmux boom'))
105
+ .mockResolvedValueOnce(undefined)
106
+
107
+ const result = await reopenCmux({ worktreeDir: WORKTREE_DIR, currentBranches: BRANCHES, repoName: REPO })
108
+
109
+ expect(result.opened).toEqual([titleFor.named])
110
+ expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledTimes(2)
111
+ })
112
+
113
+ it('opens nothing when there are no worktrees', async () => {
114
+ const result = await reopenCmux({ worktreeDir: WORKTREE_DIR, currentBranches: [], repoName: REPO })
115
+
116
+ expect(result.opened).toEqual([])
117
+ expect(openCmuxWorkspaceWithLayout).not.toHaveBeenCalled()
118
+ })
119
+ })
@@ -0,0 +1 @@
1
+ export { worktreesReload, worktreesReloadMcpTool } from './worktrees-reload'