infra-kit 0.1.94 → 0.1.97

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 (49) hide show
  1. package/.eslintcache +1 -1
  2. package/.turbo/turbo-eslint-check.log +1 -1
  3. package/.turbo/turbo-prettier-check.log +1 -1
  4. package/.turbo/turbo-test.log +7 -7
  5. package/.turbo/turbo-ts-check.log +1 -1
  6. package/dist/cli.js +37 -34
  7. package/dist/cli.js.map +4 -4
  8. package/dist/mcp.js +28 -25
  9. package/dist/mcp.js.map +4 -4
  10. package/package.json +3 -3
  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-open/index.ts +1 -0
  29. package/src/commands/worktrees-open/worktrees-open.ts +197 -0
  30. package/src/commands/worktrees-remove/worktrees-remove.ts +50 -3
  31. package/src/commands/worktrees-sync/worktrees-sync.ts +69 -5
  32. package/src/entry/cli.ts +34 -5
  33. package/src/integrations/clickup/.gitkeep +0 -0
  34. package/src/integrations/cmux/index.ts +1 -0
  35. package/src/integrations/cmux/list-workspace-titles.ts +42 -0
  36. package/src/integrations/cursor/add-folders-to-workspace.ts +84 -0
  37. package/src/integrations/cursor/index.ts +4 -0
  38. package/src/integrations/cursor/reconcile-workspace-folders.ts +90 -0
  39. package/src/integrations/cursor/remove-folders-from-workspace.ts +93 -0
  40. package/src/integrations/cursor/resolve-workspace-path.ts +13 -0
  41. package/src/integrations/doppler/doppler-project.ts +2 -2
  42. package/src/lib/__tests__/infra-kit-config.test.ts +64 -14
  43. package/src/lib/git-utils/git-utils.ts +27 -8
  44. package/src/lib/infra-kit-config/index.ts +2 -0
  45. package/src/lib/infra-kit-config/infra-kit-config.ts +143 -0
  46. package/src/mcp/tools/index.ts +2 -0
  47. package/tsconfig.json +3 -2
  48. package/tsconfig.tsbuildinfo +1 -1
  49. 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.97",
5
5
  "description": "infra-kit",
6
6
  "main": "dist/cli.js",
7
7
  "module": "dist/cli.js",
@@ -37,7 +37,7 @@
37
37
  "commander": "^14.0.3",
38
38
  "pino": "^10.3.1",
39
39
  "pino-pretty": "^13.1.3",
40
- "yaml": "^2.8.3",
40
+ "yaml": "^2.8.4",
41
41
  "zod": "^3.25.76",
42
42
  "zx": "^8.8.5"
43
43
  },
@@ -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'
@@ -0,0 +1 @@
1
+ export { worktreesOpen, worktreesOpenMcpTool } from './worktrees-open'
@@ -0,0 +1,197 @@
1
+ import { z } from 'zod/v4'
2
+ import { $ } from 'zx'
3
+
4
+ import { buildCmuxWorkspaceTitle, listCmuxWorkspaceTitles, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
5
+ import { reconcileCursorWorkspaceFolders, resolveCursorWorkspacePath } from 'src/integrations/cursor'
6
+ import { commandEcho } from 'src/lib/command-echo'
7
+ import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
8
+ import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
9
+ import { getInfraKitConfig } from 'src/lib/infra-kit-config'
10
+ import { logger } from 'src/lib/logger'
11
+ import type { ToolsExecutionResult } from 'src/types'
12
+
13
+ interface WorktreesOpenResult {
14
+ openedCmux: string[]
15
+ skippedCmux: string[]
16
+ cursorFoldersAdded: number
17
+ cursorFoldersRemoved: number
18
+ }
19
+
20
+ /**
21
+ * Cold-start restore command: reconciles `Main.code-workspace` against the set
22
+ * of release worktrees on disk, opens Cursor against it, and ensures one cmux
23
+ * workspace exists per worktree. Idempotent and additive — never removes
24
+ * worktrees, never recreates running cmux workspaces.
25
+ */
26
+ export const worktreesOpen = async (): Promise<ToolsExecutionResult> => {
27
+ commandEcho.start('worktrees-open')
28
+
29
+ try {
30
+ const projectRoot = await getProjectRoot()
31
+ const worktreeDir = `${projectRoot}${WORKTREES_DIR_SUFFIX}`
32
+ const currentBranches = await getCurrentWorktrees('release')
33
+
34
+ const cursorOutcome = await openCursor({ projectRoot, worktreeDir, currentBranches })
35
+ const cmuxOutcome = await openCmux({ worktreeDir, currentBranches })
36
+
37
+ const result: WorktreesOpenResult = {
38
+ openedCmux: cmuxOutcome.opened,
39
+ skippedCmux: cmuxOutcome.skipped,
40
+ cursorFoldersAdded: cursorOutcome.added,
41
+ cursorFoldersRemoved: cursorOutcome.removed,
42
+ }
43
+
44
+ logResults(result, { cursorRan: cursorOutcome.ran, cmuxRan: cmuxOutcome.ran })
45
+
46
+ commandEcho.print()
47
+
48
+ return {
49
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
50
+ structuredContent: { ...result },
51
+ }
52
+ } catch (error) {
53
+ logger.error({ error }, '❌ Error opening worktrees')
54
+ throw error
55
+ }
56
+ }
57
+
58
+ interface OpenCursorArgs {
59
+ projectRoot: string
60
+ worktreeDir: string
61
+ currentBranches: string[]
62
+ }
63
+
64
+ interface OpenCursorOutcome {
65
+ ran: boolean
66
+ added: number
67
+ removed: number
68
+ }
69
+
70
+ const openCursor = async (args: OpenCursorArgs): Promise<OpenCursorOutcome> => {
71
+ const { projectRoot, worktreeDir, currentBranches } = args
72
+
73
+ const config = await getInfraKitConfig()
74
+ const cursorConfig = config.ide?.provider === 'cursor' ? config.ide.config : undefined
75
+
76
+ if (!cursorConfig || cursorConfig.mode !== 'workspace' || !cursorConfig.workspaceConfigPath) {
77
+ logger.warn('⚠️ Skipping Cursor: ide.provider must be "cursor", mode "workspace", and workspaceConfigPath set.')
78
+
79
+ return { ran: false, added: 0, removed: 0 }
80
+ }
81
+
82
+ const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
83
+
84
+ try {
85
+ const { added, removed } = await reconcileCursorWorkspaceFolders({
86
+ workspacePath,
87
+ worktreeDir,
88
+ currentBranches,
89
+ })
90
+
91
+ await $`cursor ${workspacePath}`
92
+
93
+ return { ran: true, added: added.length, removed: removed.length }
94
+ } catch (error) {
95
+ logger.warn({ error }, `⚠️ Failed to reconcile/open Cursor workspace at ${workspacePath}`)
96
+
97
+ return { ran: false, added: 0, removed: 0 }
98
+ }
99
+ }
100
+
101
+ interface OpenCmuxArgs {
102
+ worktreeDir: string
103
+ currentBranches: string[]
104
+ }
105
+
106
+ interface OpenCmuxOutcome {
107
+ ran: boolean
108
+ opened: string[]
109
+ skipped: string[]
110
+ }
111
+
112
+ const openCmux = async (args: OpenCmuxArgs): Promise<OpenCmuxOutcome> => {
113
+ const { worktreeDir, currentBranches } = args
114
+
115
+ if (currentBranches.length === 0) {
116
+ return { ran: true, opened: [], skipped: [] }
117
+ }
118
+
119
+ const repoName = await getRepoName()
120
+ const existingTitles = await listCmuxWorkspaceTitles()
121
+
122
+ const opened: string[] = []
123
+ const skipped: string[] = []
124
+
125
+ for (const branch of currentBranches) {
126
+ const title = buildCmuxWorkspaceTitle({ repoName, branch })
127
+
128
+ if (existingTitles.has(title)) {
129
+ skipped.push(title)
130
+ continue
131
+ }
132
+
133
+ try {
134
+ await openCmuxWorkspaceWithLayout({ cwd: `${worktreeDir}/${branch}`, title })
135
+ opened.push(title)
136
+ } catch (error) {
137
+ logger.warn({ error, title }, `⚠️ Failed to open cmux workspace for ${branch}`)
138
+ }
139
+ }
140
+
141
+ return { ran: true, opened, skipped }
142
+ }
143
+
144
+ interface LogResultsContext {
145
+ cursorRan: boolean
146
+ cmuxRan: boolean
147
+ }
148
+
149
+ const logResults = (result: WorktreesOpenResult, context: LogResultsContext): void => {
150
+ if (context.cursorRan) {
151
+ if (result.cursorFoldersAdded > 0) {
152
+ logger.info(`✅ Added ${result.cursorFoldersAdded} folder(s) to Cursor workspace`)
153
+ }
154
+
155
+ if (result.cursorFoldersRemoved > 0) {
156
+ logger.info(`🧹 Removed ${result.cursorFoldersRemoved} dangling folder(s) from Cursor workspace`)
157
+ }
158
+ }
159
+
160
+ if (result.openedCmux.length > 0) {
161
+ logger.info('✅ Opened cmux workspaces:')
162
+ for (const title of result.openedCmux) {
163
+ logger.info(title)
164
+ }
165
+ }
166
+
167
+ if (result.skippedCmux.length > 0) {
168
+ logger.info(`ℹ️ Skipped ${result.skippedCmux.length} cmux workspace(s) already open`)
169
+ }
170
+
171
+ if (
172
+ !context.cursorRan &&
173
+ result.openedCmux.length === 0 &&
174
+ result.skippedCmux.length === 0 &&
175
+ result.cursorFoldersAdded === 0 &&
176
+ result.cursorFoldersRemoved === 0
177
+ ) {
178
+ logger.info('ℹ️ Nothing to open')
179
+ }
180
+ }
181
+
182
+ // MCP Tool Registration
183
+ export const worktreesOpenMcpTool = {
184
+ name: 'worktrees-open',
185
+ description:
186
+ '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.',
187
+ inputSchema: {},
188
+ outputSchema: {
189
+ openedCmux: z.array(z.string()).describe('Titles of cmux workspaces opened during this run'),
190
+ skippedCmux: z.array(z.string()).describe('Titles of cmux workspaces that were already open'),
191
+ cursorFoldersAdded: z.number().describe('Number of worktree folders added to the Cursor workspace file'),
192
+ cursorFoldersRemoved: z
193
+ .number()
194
+ .describe('Number of dangling worktree folders removed from the Cursor workspace file'),
195
+ },
196
+ handler: worktreesOpen,
197
+ }