infra-kit 0.1.93 → 0.1.95

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 (45) hide show
  1. package/.eslintcache +1 -1
  2. package/.turbo/turbo-eslint-check.log +4 -5
  3. package/.turbo/turbo-prettier-check.log +7 -5
  4. package/.turbo/turbo-test.log +82 -18
  5. package/.turbo/turbo-ts-check.log +10 -5
  6. package/dist/cli.js +37 -34
  7. package/dist/cli.js.map +4 -4
  8. package/dist/mcp.js +30 -27
  9. package/dist/mcp.js.map +4 -4
  10. package/package.json +2 -2
  11. package/src/commands/doctor/doctor.ts +119 -1
  12. package/src/commands/env-clear/env-clear.ts +1 -1
  13. package/src/commands/env-list/env-list.ts +1 -1
  14. package/src/commands/env-load/env-load.ts +1 -1
  15. package/src/commands/env-status/env-status.ts +1 -1
  16. package/src/commands/gh-merge-dev/gh-merge-dev.ts +1 -1
  17. package/src/commands/gh-release-deliver/gh-release-deliver.ts +1 -1
  18. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +1 -1
  19. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +1 -1
  20. package/src/commands/gh-release-list/gh-release-list.ts +1 -1
  21. package/src/commands/init/init.ts +3 -3
  22. package/src/commands/release-create/release-create.ts +1 -1
  23. package/src/commands/release-create-batch/release-create-batch.ts +1 -1
  24. package/src/commands/version/version.ts +1 -1
  25. package/src/commands/worktrees-add/index.ts +2 -1
  26. package/src/commands/worktrees-add/worktrees-add.ts +55 -14
  27. package/src/commands/worktrees-list/worktrees-list.ts +1 -1
  28. package/src/commands/worktrees-remove/worktrees-remove.ts +68 -4
  29. package/src/commands/worktrees-sync/worktrees-sync.ts +69 -5
  30. package/src/entry/cli.ts +25 -4
  31. package/src/integrations/clickup/.gitkeep +0 -0
  32. package/src/integrations/cmux/close-workspace-by-title.ts +51 -0
  33. package/src/integrations/cmux/index.ts +2 -0
  34. package/src/integrations/cmux/workspace-title.ts +17 -0
  35. package/src/integrations/cursor/add-folders-to-workspace.ts +84 -0
  36. package/src/integrations/cursor/index.ts +3 -0
  37. package/src/integrations/cursor/remove-folders-from-workspace.ts +93 -0
  38. package/src/integrations/cursor/resolve-workspace-path.ts +13 -0
  39. package/src/integrations/doppler/doppler-project.ts +2 -2
  40. package/src/lib/__tests__/infra-kit-config.test.ts +64 -14
  41. package/src/lib/infra-kit-config/index.ts +2 -0
  42. package/src/lib/infra-kit-config/infra-kit-config.ts +143 -0
  43. package/tsconfig.json +3 -2
  44. package/tsconfig.tsbuildinfo +1 -1
  45. package/src/lib/infra-kit-config.ts +0 -69
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infra-kit",
3
3
  "type": "module",
4
- "version": "0.1.93",
4
+ "version": "0.1.95",
5
5
  "description": "infra-kit",
6
6
  "main": "dist/cli.js",
7
7
  "module": "dist/cli.js",
@@ -45,6 +45,6 @@
45
45
  "@pkg/eslint-config": "workspace:*",
46
46
  "@pkg/vitest-config": "workspace:*",
47
47
  "esbuild": "^0.28.0",
48
- "typescript": "^5.9.3"
48
+ "typescript": "catalog:"
49
49
  }
50
50
  }
@@ -1,9 +1,17 @@
1
- import { z } from 'zod'
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { z } from 'zod/v4'
2
5
  import { $ } from 'zx'
3
6
 
7
+ import { MARKER_END, MARKER_START, buildShellBlock } from 'src/commands/init/init'
8
+ import { getProjectRoot } from 'src/lib/git-utils/git-utils'
9
+ import { getInfraKitConfig, resetInfraKitConfigCache } from 'src/lib/infra-kit-config'
4
10
  import { logger } from 'src/lib/logger'
5
11
  import type { ToolsExecutionResult } from 'src/types'
6
12
 
13
+ const LOCAL_CONFIG_FILE = 'infra-kit.local.yml'
14
+
7
15
  interface CheckResult {
8
16
  name: string
9
17
  status: 'pass' | 'fail'
@@ -25,6 +33,112 @@ const checkCommand = async (
25
33
  }
26
34
  }
27
35
 
36
+ const checkZshrcInitialized = (): CheckResult => {
37
+ const name = 'zshrc init block'
38
+ const zshrcPath = path.join(os.homedir(), '.zshrc')
39
+
40
+ if (!fs.existsSync(zshrcPath)) {
41
+ return { name, status: 'fail', message: '~/.zshrc not found. Run: infra-kit init' }
42
+ }
43
+
44
+ const content = fs.readFileSync(zshrcPath, 'utf-8')
45
+ const startIdx = content.indexOf(MARKER_START)
46
+ const endIdx = content.indexOf(MARKER_END)
47
+
48
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
49
+ return {
50
+ name,
51
+ status: 'fail',
52
+ message: 'infra-kit shell block missing from ~/.zshrc. Run: infra-kit init',
53
+ }
54
+ }
55
+
56
+ const installedBlock = content.slice(startIdx, endIdx + MARKER_END.length).trim()
57
+ const expectedBlock = buildShellBlock().trim()
58
+
59
+ if (installedBlock !== expectedBlock) {
60
+ return {
61
+ name,
62
+ status: 'fail',
63
+ message: 'infra-kit shell block in ~/.zshrc is out of date. Run: infra-kit init',
64
+ }
65
+ }
66
+
67
+ return { name, status: 'pass', message: 'infra-kit shell block in ~/.zshrc is up to date' }
68
+ }
69
+
70
+ const checkPnpmWorkspaceVirtualStore = async (): Promise<CheckResult> => {
71
+ const name = 'pnpm enableGlobalVirtualStore'
72
+
73
+ try {
74
+ const root = await getProjectRoot()
75
+ const yamlPath = path.join(root, 'pnpm-workspace.yaml')
76
+
77
+ if (!fs.existsSync(yamlPath)) {
78
+ return { name, status: 'fail', message: `pnpm-workspace.yaml not found at ${yamlPath}` }
79
+ }
80
+
81
+ const content = fs.readFileSync(yamlPath, 'utf-8')
82
+ // eslint-disable-next-line sonarjs/slow-regex
83
+ const enabled = /^\s*enableGlobalVirtualStore\s*:\s*true\s*$/m.test(content)
84
+
85
+ if (!enabled) {
86
+ return {
87
+ name,
88
+ status: 'fail',
89
+ message: 'enableGlobalVirtualStore: true is missing in pnpm-workspace.yaml',
90
+ }
91
+ }
92
+
93
+ return { name, status: 'pass', message: 'enableGlobalVirtualStore: true is set' }
94
+ } catch (err) {
95
+ return {
96
+ name,
97
+ status: 'fail',
98
+ message: `Failed to read pnpm-workspace.yaml: ${(err as Error).message}`,
99
+ }
100
+ }
101
+ }
102
+
103
+ const checkInfraKitConfigValid = async (): Promise<CheckResult> => {
104
+ const name = 'infra-kit config valid'
105
+
106
+ try {
107
+ resetInfraKitConfigCache()
108
+ await getInfraKitConfig()
109
+
110
+ return {
111
+ name,
112
+ status: 'pass',
113
+ message: 'infra-kit.yml is valid (infra-kit.local.yml overrides applied if present)',
114
+ }
115
+ } catch (err) {
116
+ return { name, status: 'fail', message: (err as Error).message }
117
+ }
118
+ }
119
+
120
+ const checkLocalConfigGitignored = async (): Promise<CheckResult> => {
121
+ const name = 'infra-kit.local.yml gitignored'
122
+
123
+ try {
124
+ const root = await getProjectRoot()
125
+
126
+ await $({ cwd: root, nothrow: true })`git check-ignore -q ${LOCAL_CONFIG_FILE}`.then((result) => {
127
+ if (result.exitCode !== 0) {
128
+ throw new Error('not ignored')
129
+ }
130
+ })
131
+
132
+ return { name, status: 'pass', message: `${LOCAL_CONFIG_FILE} is covered by .gitignore` }
133
+ } catch {
134
+ return {
135
+ name,
136
+ status: 'fail',
137
+ message: `${LOCAL_CONFIG_FILE} is not gitignored. Add "${LOCAL_CONFIG_FILE}" to .gitignore.`,
138
+ }
139
+ }
140
+ }
141
+
28
142
  /**
29
143
  * Check installation and authentication status of gh, doppler, and aws CLIs
30
144
  */
@@ -67,6 +181,10 @@ export const doctor = async (): Promise<ToolsExecutionResult> => {
67
181
  // 'AWS CLI is authenticated',
68
182
  // 'AWS CLI is not authenticated. Run: aws configure (or aws sso login)',
69
183
  // ),
184
+ Promise.resolve(checkZshrcInitialized()),
185
+ checkPnpmWorkspaceVirtualStore(),
186
+ checkInfraKitConfigValid(),
187
+ checkLocalConfigGitignored(),
70
188
  ])
71
189
 
72
190
  logger.info('Doctor check results:\n')
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import process from 'node:process'
4
- import { z } from 'zod'
4
+ import { z } from 'zod/v4'
5
5
 
6
6
  import {
7
7
  ENV_CLEAR_FILE,
@@ -1,4 +1,4 @@
1
- import { z } from 'zod'
1
+ import { z } from 'zod/v4'
2
2
 
3
3
  import { getDopplerProject } from 'src/integrations/doppler/doppler-project'
4
4
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
@@ -3,7 +3,7 @@ import { Buffer } from 'node:buffer'
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import process from 'node:process'
6
- import { z } from 'zod'
6
+ import { z } from 'zod/v4'
7
7
  import { $ } from 'zx'
8
8
 
9
9
  import { validateDopplerCliAndAuth } from 'src/integrations/doppler'
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import process from 'node:process'
3
- import { z } from 'zod'
3
+ import { z } from 'zod/v4'
4
4
 
5
5
  import { validateDopplerCliAndAuth } from 'src/integrations/doppler'
6
6
  import {
@@ -2,7 +2,7 @@
2
2
  import checkbox from '@inquirer/checkbox'
3
3
  import confirm from '@inquirer/confirm'
4
4
  import process from 'node:process'
5
- import { z } from 'zod'
5
+ import { z } from 'zod/v4'
6
6
  import { $ } from 'zx'
7
7
 
8
8
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
@@ -1,7 +1,7 @@
1
1
  import confirm from '@inquirer/confirm'
2
2
  import select from '@inquirer/select'
3
3
  import process from 'node:process'
4
- import { z } from 'zod'
4
+ import { z } from 'zod/v4'
5
5
  import { $ } from 'zx'
6
6
 
7
7
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
@@ -1,6 +1,6 @@
1
1
  import select from '@inquirer/select'
2
2
  import process from 'node:process'
3
- import { z } from 'zod'
3
+ import { z } from 'zod/v4'
4
4
  import { $ } from 'zx'
5
5
 
6
6
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
@@ -4,7 +4,7 @@ import fs from 'node:fs/promises'
4
4
  import { resolve } from 'node:path'
5
5
  import process from 'node:process'
6
6
  import yaml from 'yaml'
7
- import { z } from 'zod'
7
+ import { z } from 'zod/v4'
8
8
  import { $ } from 'zx'
9
9
 
10
10
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
@@ -1,4 +1,4 @@
1
- import { z } from 'zod'
1
+ import { z } from 'zod/v4'
2
2
 
3
3
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
4
4
  import { logger } from 'src/lib/logger'
@@ -4,8 +4,8 @@ import path from 'node:path'
4
4
 
5
5
  import { logger } from 'src/lib/logger'
6
6
 
7
- const MARKER_START = '# -- infra-kit:begin --'
8
- const MARKER_END = '# -- infra-kit:end --'
7
+ export const MARKER_START = '# -- infra-kit:begin --'
8
+ export const MARKER_END = '# -- infra-kit:end --'
9
9
 
10
10
  const LEGACY_PAIRED: [start: string, end: string][] = [['# region infra-kit', '# endregion infra-kit']]
11
11
  const LEGACY_SINGLE = '# infra-kit shell functions'
@@ -94,7 +94,7 @@ const removeExistingBlock = (content: string): string => {
94
94
  return before + (remaining ? `\n${remaining}` : '')
95
95
  }
96
96
 
97
- const buildShellBlock = (): string => {
97
+ export const buildShellBlock = (): string => {
98
98
  const runCmd = 'pnpm exec infra-kit'
99
99
 
100
100
  return [
@@ -1,7 +1,7 @@
1
1
  import confirm from '@inquirer/confirm'
2
2
  import select from '@inquirer/select'
3
3
  import process from 'node:process'
4
- import { z } from 'zod'
4
+ import { z } from 'zod/v4'
5
5
  import { $, question } from 'zx'
6
6
 
7
7
  import { loadJiraConfig } from 'src/integrations/jira'
@@ -1,7 +1,7 @@
1
1
  import confirm from '@inquirer/confirm'
2
2
  import select from '@inquirer/select'
3
3
  import process from 'node:process'
4
- import { z } from 'zod'
4
+ import { z } from 'zod/v4'
5
5
  import { question } from 'zx'
6
6
 
7
7
  import { loadJiraConfig } from 'src/integrations/jira'
@@ -1,4 +1,4 @@
1
- import { z } from 'zod'
1
+ import { z } from 'zod/v4'
2
2
 
3
3
  import { logger } from 'src/lib/logger'
4
4
  import type { ToolsExecutionResult } from 'src/types'
@@ -1 +1,2 @@
1
- export { worktreesAdd, worktreesAddMcpTool } from './worktrees-add'
1
+ export { CURSOR_MODES, worktreesAdd, worktreesAddMcpTool } from './worktrees-add'
2
+ export type { CursorMode } from './worktrees-add'
@@ -1,15 +1,18 @@
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'
4
5
  import process from 'node:process'
5
- import { z } from 'zod'
6
+ import { z } from 'zod/v4'
6
7
  import { $ } from 'zx'
7
8
 
8
- import { openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
9
+ import { buildCmuxWorkspaceTitle, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
10
+ import { addFoldersToCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
9
11
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
10
12
  import { commandEcho } from 'src/lib/command-echo'
11
13
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
12
14
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
15
+ import { getInfraKitConfig } from 'src/lib/infra-kit-config'
13
16
  import { logger } from 'src/lib/logger'
14
17
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
15
18
  import type { ReleaseType } from 'src/lib/release-utils'
@@ -20,10 +23,13 @@ const FEATURE_DIR = 'feature'
20
23
  const RELEASE_DIR = 'release'
21
24
  const RELEASE_BRANCH_PREFIX = 'release/v'
22
25
 
26
+ export const CURSOR_MODES = ['workspace', 'windows', 'none'] as const
27
+ export type CursorMode = (typeof CURSOR_MODES)[number]
28
+
23
29
  interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
24
30
  all: boolean
25
31
  versions?: string
26
- cursor?: boolean
32
+ cursor?: CursorMode
27
33
  githubDesktop?: boolean
28
34
  cmux?: boolean
29
35
  }
@@ -124,17 +130,31 @@ export const worktreesAdd = async (options: WorktreeManagementArgs): Promise<Too
124
130
  commandEcho.addOption('--yes', true)
125
131
  }
126
132
 
127
- const openInCursor = cursor ?? (await confirm({ message: 'Open created worktrees in Cursor?' }))
133
+ const cursorMode: CursorMode =
134
+ cursor ??
135
+ (await select<CursorMode>({
136
+ message: 'Cursor mode for created worktrees?',
137
+ default: 'workspace',
138
+ choices: [
139
+ {
140
+ name: 'Add to workspace file',
141
+ value: 'workspace',
142
+ description: 'Append each worktree as a folder in ide.config.workspaceConfigPath, then open the workspace',
143
+ },
144
+ {
145
+ name: 'Open separate windows',
146
+ value: 'windows',
147
+ description: 'Open each created worktree in its own Cursor window',
148
+ },
149
+ { name: 'Skip', value: 'none', description: 'Do not open Cursor' },
150
+ ],
151
+ }))
128
152
 
129
153
  if (typeof cursor === 'undefined') {
130
154
  commandEcho.setInteractive()
131
155
  }
132
156
 
133
- if (openInCursor) {
134
- commandEcho.addOption('--cursor', true)
135
- } else {
136
- commandEcho.addOption('--no-cursor', true)
137
- }
157
+ commandEcho.addOption('--cursor', cursorMode)
138
158
 
139
159
  const openInGithubDesktop =
140
160
  githubDesktop ?? (await confirm({ message: 'Open created worktrees in GitHub Desktop?' }))
@@ -170,7 +190,28 @@ export const worktreesAdd = async (options: WorktreeManagementArgs): Promise<Too
170
190
 
171
191
  logResults(createdWorktrees)
172
192
 
173
- if (openInCursor) {
193
+ if (cursorMode === 'workspace') {
194
+ const config = await getInfraKitConfig()
195
+ const cursorConfig = config.ide?.provider === 'cursor' ? config.ide.config : undefined
196
+
197
+ if (!cursorConfig?.workspaceConfigPath) {
198
+ logger.warn('⚠️ Skipping Cursor: ide.config.workspaceConfigPath is not set in infra-kit.yml')
199
+ } else {
200
+ const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
201
+
202
+ const folderPaths = createdWorktrees.map((branch) => {
203
+ return `${worktreeDir}/${branch}`
204
+ })
205
+
206
+ const { added, skipped } = await addFoldersToCursorWorkspace({ workspacePath, folderPaths })
207
+
208
+ const skippedSuffix = skipped.length > 0 ? ` (${skipped.length} already present)` : ''
209
+
210
+ logger.info(`✅ Added ${added.length} folder(s) to ${workspacePath}${skippedSuffix}`)
211
+
212
+ await $`cursor ${workspacePath}`
213
+ }
214
+ } else if (cursorMode === 'windows') {
174
215
  for (const branch of createdWorktrees) {
175
216
  await $`cursor ${worktreeDir}/${branch}`
176
217
  }
@@ -187,11 +228,11 @@ export const worktreesAdd = async (options: WorktreeManagementArgs): Promise<Too
187
228
  const repoName = await getRepoName()
188
229
 
189
230
  for (const branch of createdWorktrees) {
190
- const version = branch.replace('release/', '')
231
+ const title = buildCmuxWorkspaceTitle({ repoName, branch })
191
232
 
192
233
  await openCmuxWorkspaceWithLayout({
193
234
  cwd: `${worktreeDir}/${branch}`,
194
- title: `${repoName} ${version}`,
235
+ title,
195
236
  })
196
237
  }
197
238
  }
@@ -311,10 +352,10 @@ export const worktreesAddMcpTool = {
311
352
  'Comma-separated release versions to target (e.g. "1.2.5, 1.2.6"). Either "versions" or all=true must be provided for MCP calls. Overrides "all" when set.',
312
353
  ),
313
354
  cursor: z
314
- .boolean()
355
+ .enum(CURSOR_MODES)
315
356
  .optional()
316
357
  .describe(
317
- 'Open each created worktree in Cursor. Defaults to false in MCP mode (the follow-up prompt is not shown).',
358
+ 'Cursor open mode for created worktrees. "workspace" (default behavior when set interactively) appends each worktree as a folder to "ide.config.workspaceConfigPath" in infra-kit.yml and opens the workspace. "windows" opens each worktree in its own Cursor window. "none" skips Cursor. Defaults to "none" in MCP mode (the follow-up prompt is not shown).',
318
359
  ),
319
360
  githubDesktop: z
320
361
  .boolean()
@@ -1,4 +1,4 @@
1
- import { z } from 'zod'
1
+ import { z } from 'zod/v4'
2
2
 
3
3
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
4
4
  import { getCurrentWorktrees } from 'src/lib/git-utils'
@@ -1,13 +1,16 @@
1
1
  import checkbox from '@inquirer/checkbox'
2
2
  import confirm from '@inquirer/confirm'
3
3
  import process from 'node:process'
4
- import { z } from 'zod'
4
+ import { z } from 'zod/v4'
5
5
  import { $ } from 'zx'
6
6
 
7
+ import { buildCmuxWorkspaceTitle, closeCmuxWorkspaceByTitle } from 'src/integrations/cmux'
8
+ import { removeFoldersFromCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
7
9
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
8
10
  import { commandEcho } from 'src/lib/command-echo'
9
11
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
10
- import { getCurrentWorktrees, getProjectRoot } from 'src/lib/git-utils'
12
+ import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
13
+ import { getInfraKitConfig } from 'src/lib/infra-kit-config'
11
14
  import { logger } from 'src/lib/logger'
12
15
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
13
16
  import type { ReleaseType } from 'src/lib/release-utils'
@@ -107,7 +110,15 @@ export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<
107
110
  commandEcho.addOption('--yes', true)
108
111
  }
109
112
 
110
- const removedWorktrees = await removeWorktrees(selectedReleaseBranches, worktreeDir)
113
+ const repoName = await getRepoName()
114
+
115
+ const removedWorktrees = await removeWorktrees({
116
+ branches: selectedReleaseBranches,
117
+ worktreeDir,
118
+ repoName,
119
+ })
120
+
121
+ await syncCursorWorkspaceOnRemove({ removedWorktrees, worktreeDir, projectRoot })
111
122
 
112
123
  logResults(removedWorktrees)
113
124
 
@@ -133,14 +144,26 @@ export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<
133
144
  }
134
145
  }
135
146
 
147
+ interface RemoveWorktreesArgs {
148
+ branches: string[]
149
+ worktreeDir: string
150
+ repoName: string
151
+ }
152
+
136
153
  /**
137
154
  * Remove worktrees for the specified branches and whole folder
138
155
  */
139
- const removeWorktrees = async (branches: string[], worktreeDir: string): Promise<string[]> => {
156
+ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> => {
157
+ const { branches, worktreeDir, repoName } = args
158
+
140
159
  const results = await Promise.allSettled(
141
160
  branches.map(async (branch) => {
142
161
  const worktreePath = `${worktreeDir}/${branch}`
143
162
 
163
+ const title = buildCmuxWorkspaceTitle({ repoName, branch })
164
+
165
+ await closeCmuxWorkspaceByTitle(title)
166
+
144
167
  await $`git worktree remove ${worktreePath}`
145
168
 
146
169
  return branch
@@ -170,6 +193,47 @@ const removeWorktrees = async (branches: string[], worktreeDir: string): Promise
170
193
  return removed
171
194
  }
172
195
 
196
+ interface SyncCursorWorkspaceOnRemoveArgs {
197
+ removedWorktrees: string[]
198
+ worktreeDir: string
199
+ projectRoot: string
200
+ }
201
+
202
+ /**
203
+ * Strip removed worktrees from the configured Cursor workspace's `folders` array.
204
+ * No-op if Cursor isn't configured, mode isn't "workspace", or no worktrees were removed.
205
+ */
206
+ const syncCursorWorkspaceOnRemove = async (args: SyncCursorWorkspaceOnRemoveArgs): Promise<void> => {
207
+ const { removedWorktrees, worktreeDir, projectRoot } = args
208
+
209
+ if (removedWorktrees.length === 0) {
210
+ return
211
+ }
212
+
213
+ const config = await getInfraKitConfig()
214
+ const cursorConfig = config.ide?.provider === 'cursor' ? config.ide.config : undefined
215
+
216
+ if (!cursorConfig || cursorConfig.mode !== 'workspace' || !cursorConfig.workspaceConfigPath) {
217
+ return
218
+ }
219
+
220
+ const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
221
+
222
+ const folderPaths = removedWorktrees.map((branch) => {
223
+ return `${worktreeDir}/${branch}`
224
+ })
225
+
226
+ try {
227
+ const { removed } = await removeFoldersFromCursorWorkspace({ workspacePath, folderPaths })
228
+
229
+ if (removed.length > 0) {
230
+ logger.info(`✅ Removed ${removed.length} folder(s) from ${workspacePath}`)
231
+ }
232
+ } catch (error) {
233
+ logger.warn({ error }, `⚠️ Failed to update Cursor workspace at ${workspacePath}`)
234
+ }
235
+ }
236
+
173
237
  /**
174
238
  * Log the results of worktree management
175
239
  */
@@ -1,12 +1,15 @@
1
1
  import confirm from '@inquirer/confirm'
2
2
  import process from 'node:process'
3
- import { z } from 'zod'
3
+ import { z } from 'zod/v4'
4
4
  import { $ } from 'zx'
5
5
 
6
+ import { buildCmuxWorkspaceTitle, closeCmuxWorkspaceByTitle } from 'src/integrations/cmux'
7
+ import { removeFoldersFromCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
6
8
  import { getReleasePRs } from 'src/integrations/gh'
7
9
  import { commandEcho } from 'src/lib/command-echo'
8
10
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
9
- import { getCurrentWorktrees, getProjectRoot } from 'src/lib/git-utils'
11
+ import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
12
+ import { getInfraKitConfig } from 'src/lib/infra-kit-config'
10
13
  import { logger } from 'src/lib/logger'
11
14
  import type { RequiredConfirmedOptionArg, ToolsExecutionResult } from 'src/types'
12
15
 
@@ -59,7 +62,15 @@ export const worktreesSync = async (options: WorktreeSyncArgs): Promise<ToolsExe
59
62
  currentWorktrees,
60
63
  })
61
64
 
62
- const removedWorktrees = await removeWorktrees(branchesToRemove, worktreeDir)
65
+ const repoName = await getRepoName()
66
+
67
+ const removedWorktrees = await removeWorktrees({
68
+ branches: branchesToRemove,
69
+ worktreeDir,
70
+ repoName,
71
+ })
72
+
73
+ await syncCursorWorkspaceOnRemove({ removedWorktrees, worktreeDir, projectRoot })
63
74
 
64
75
  logResults(removedWorktrees)
65
76
 
@@ -107,16 +118,28 @@ const categorizeWorktrees = (args: CategorizeWorktreesArgs): { branchesToRemove:
107
118
  return { branchesToRemove }
108
119
  }
109
120
 
121
+ interface RemoveWorktreesArgs {
122
+ branches: string[]
123
+ worktreeDir: string
124
+ repoName: string
125
+ }
126
+
110
127
  /**
111
- * Remove worktrees for the specified branches
128
+ * Remove worktrees for the specified branches and close their cmux workspaces
112
129
  */
113
- const removeWorktrees = async (branches: string[], worktreeDir: string): Promise<string[]> => {
130
+ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> => {
131
+ const { branches, worktreeDir, repoName } = args
132
+
114
133
  const removed: string[] = []
115
134
 
116
135
  for (const branch of branches) {
117
136
  try {
118
137
  const worktreePath = `${worktreeDir}/${branch}`
119
138
 
139
+ const title = buildCmuxWorkspaceTitle({ repoName, branch })
140
+
141
+ await closeCmuxWorkspaceByTitle(title)
142
+
120
143
  await $`git worktree remove ${worktreePath}`
121
144
  removed.push(branch)
122
145
  } catch (error) {
@@ -127,6 +150,47 @@ const removeWorktrees = async (branches: string[], worktreeDir: string): Promise
127
150
  return removed
128
151
  }
129
152
 
153
+ interface SyncCursorWorkspaceOnRemoveArgs {
154
+ removedWorktrees: string[]
155
+ worktreeDir: string
156
+ projectRoot: string
157
+ }
158
+
159
+ /**
160
+ * Strip removed worktrees from the configured Cursor workspace's `folders` array.
161
+ * No-op if Cursor isn't configured, mode isn't "workspace", or no worktrees were removed.
162
+ */
163
+ const syncCursorWorkspaceOnRemove = async (args: SyncCursorWorkspaceOnRemoveArgs): Promise<void> => {
164
+ const { removedWorktrees, worktreeDir, projectRoot } = args
165
+
166
+ if (removedWorktrees.length === 0) {
167
+ return
168
+ }
169
+
170
+ const config = await getInfraKitConfig()
171
+ const cursorConfig = config.ide?.provider === 'cursor' ? config.ide.config : undefined
172
+
173
+ if (!cursorConfig || cursorConfig.mode !== 'workspace' || !cursorConfig.workspaceConfigPath) {
174
+ return
175
+ }
176
+
177
+ const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
178
+
179
+ const folderPaths = removedWorktrees.map((branch) => {
180
+ return `${worktreeDir}/${branch}`
181
+ })
182
+
183
+ try {
184
+ const { removed: removedEntries } = await removeFoldersFromCursorWorkspace({ workspacePath, folderPaths })
185
+
186
+ if (removedEntries.length > 0) {
187
+ logger.info(`✅ Removed ${removedEntries.length} folder(s) from ${workspacePath}`)
188
+ }
189
+ } catch (error) {
190
+ logger.warn({ error }, `⚠️ Failed to update Cursor workspace at ${workspacePath}`)
191
+ }
192
+ }
193
+
130
194
  /**
131
195
  * Log the results of worktree management
132
196
  */