infra-kit 0.1.94 → 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 (42) 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 +36 -34
  7. package/dist/cli.js.map +4 -4
  8. package/dist/mcp.js +28 -26
  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 +52 -11
  27. package/src/commands/worktrees-list/worktrees-list.ts +1 -1
  28. package/src/commands/worktrees-remove/worktrees-remove.ts +46 -1
  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/cursor/add-folders-to-workspace.ts +84 -0
  33. package/src/integrations/cursor/index.ts +3 -0
  34. package/src/integrations/cursor/remove-folders-from-workspace.ts +93 -0
  35. package/src/integrations/cursor/resolve-workspace-path.ts +13 -0
  36. package/src/integrations/doppler/doppler-project.ts +2 -2
  37. package/src/lib/__tests__/infra-kit-config.test.ts +64 -14
  38. package/src/lib/infra-kit-config/index.ts +2 -0
  39. package/src/lib/infra-kit-config/infra-kit-config.ts +143 -0
  40. package/tsconfig.json +3 -2
  41. package/tsconfig.tsbuildinfo +1 -1
  42. 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.94",
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
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
  }
@@ -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,14 +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
7
  import { buildCmuxWorkspaceTitle, closeCmuxWorkspaceByTitle } from 'src/integrations/cmux'
8
+ import { removeFoldersFromCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
8
9
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
9
10
  import { commandEcho } from 'src/lib/command-echo'
10
11
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
11
12
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
13
+ import { getInfraKitConfig } from 'src/lib/infra-kit-config'
12
14
  import { logger } from 'src/lib/logger'
13
15
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
14
16
  import type { ReleaseType } from 'src/lib/release-utils'
@@ -116,6 +118,8 @@ export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<
116
118
  repoName,
117
119
  })
118
120
 
121
+ await syncCursorWorkspaceOnRemove({ removedWorktrees, worktreeDir, projectRoot })
122
+
119
123
  logResults(removedWorktrees)
120
124
 
121
125
  commandEcho.print()
@@ -189,6 +193,47 @@ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> =>
189
193
  return removed
190
194
  }
191
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
+
192
237
  /**
193
238
  * Log the results of worktree management
194
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
  */
package/src/entry/cli.ts CHANGED
@@ -16,7 +16,8 @@ import { init } from 'src/commands/init'
16
16
  import { releaseCreate } from 'src/commands/release-create'
17
17
  import { releaseCreateBatch } from 'src/commands/release-create-batch'
18
18
  import { version } from 'src/commands/version'
19
- import { worktreesAdd } from 'src/commands/worktrees-add'
19
+ import { CURSOR_MODES, worktreesAdd } from 'src/commands/worktrees-add'
20
+ import type { CursorMode } from 'src/commands/worktrees-add'
20
21
  import { worktreesList } from 'src/commands/worktrees-list'
21
22
  import { worktreesRemove } from 'src/commands/worktrees-remove'
22
23
  import { worktreesSync } from 'src/commands/worktrees-sync'
@@ -24,6 +25,26 @@ import { logger } from 'src/lib/logger'
24
25
 
25
26
  const program = new Command()
26
27
 
28
+ const normalizeCursorMode = (value: unknown): CursorMode | undefined => {
29
+ if (typeof value === 'undefined') {
30
+ return undefined
31
+ }
32
+
33
+ if (value === true) {
34
+ return 'workspace'
35
+ }
36
+
37
+ if (value === false) {
38
+ return 'none'
39
+ }
40
+
41
+ if (typeof value === 'string' && (CURSOR_MODES as readonly string[]).includes(value)) {
42
+ return value as CursorMode
43
+ }
44
+
45
+ throw new Error(`Invalid --cursor value "${String(value)}". Expected one of: ${CURSOR_MODES.join(', ')}.`)
46
+ }
47
+
27
48
  const runProgram = async (argv?: string[]): Promise<void> => {
28
49
  try {
29
50
  if (argv) {
@@ -136,8 +157,8 @@ program
136
157
  .option('-y, --yes', 'Skip confirmation prompt')
137
158
  .option('-a, --all', 'Select all active release branches')
138
159
  .option('-v, --versions <versions>', 'Specify versions by comma, e.g. 1.2.5, 1.2.6')
139
- .option('-c, --cursor', 'Open created worktrees in Cursor')
140
- .option('--no-cursor', 'Skip Cursor prompt')
160
+ .option('-c, --cursor [mode]', 'Cursor mode for created worktrees: workspace (default) | windows | none')
161
+ .option('--no-cursor', 'Skip Cursor (alias for --cursor none)')
141
162
  .option('-g, --github-desktop', 'Open created worktrees in GitHub Desktop')
142
163
  .option('--no-github-desktop', 'Skip GitHub Desktop prompt')
143
164
  .option('-m, --cmux', 'Open created worktrees in cmux (3-pane layout)')
@@ -147,7 +168,7 @@ program
147
168
  confirmedCommand: options.yes,
148
169
  all: options.all,
149
170
  versions: options.versions,
150
- cursor: options.cursor,
171
+ cursor: normalizeCursorMode(options.cursor),
151
172
  githubDesktop: options.githubDesktop,
152
173
  cmux: options.cmux,
153
174
  })
File without changes