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.
- package/.eslintcache +1 -1
- package/.turbo/turbo-eslint-check.log +1 -1
- package/.turbo/turbo-prettier-check.log +1 -1
- package/.turbo/turbo-test.log +7 -7
- package/.turbo/turbo-ts-check.log +1 -1
- package/dist/cli.js +37 -34
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +28 -25
- package/dist/mcp.js.map +4 -4
- package/package.json +3 -3
- package/src/commands/doctor/doctor.ts +119 -1
- package/src/commands/env-clear/env-clear.ts +1 -1
- package/src/commands/env-list/env-list.ts +1 -1
- package/src/commands/env-load/env-load.ts +1 -1
- package/src/commands/env-status/env-status.ts +1 -1
- package/src/commands/gh-merge-dev/gh-merge-dev.ts +1 -1
- package/src/commands/gh-release-deliver/gh-release-deliver.ts +1 -1
- package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +1 -1
- package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +1 -1
- package/src/commands/gh-release-list/gh-release-list.ts +1 -1
- package/src/commands/init/init.ts +3 -3
- package/src/commands/release-create/release-create.ts +1 -1
- package/src/commands/release-create-batch/release-create-batch.ts +1 -1
- package/src/commands/version/version.ts +1 -1
- package/src/commands/worktrees-add/index.ts +2 -1
- package/src/commands/worktrees-add/worktrees-add.ts +52 -11
- package/src/commands/worktrees-list/worktrees-list.ts +1 -1
- package/src/commands/worktrees-open/index.ts +1 -0
- package/src/commands/worktrees-open/worktrees-open.ts +197 -0
- package/src/commands/worktrees-remove/worktrees-remove.ts +50 -3
- package/src/commands/worktrees-sync/worktrees-sync.ts +69 -5
- package/src/entry/cli.ts +34 -5
- package/src/integrations/clickup/.gitkeep +0 -0
- package/src/integrations/cmux/index.ts +1 -0
- package/src/integrations/cmux/list-workspace-titles.ts +42 -0
- package/src/integrations/cursor/add-folders-to-workspace.ts +84 -0
- package/src/integrations/cursor/index.ts +4 -0
- package/src/integrations/cursor/reconcile-workspace-folders.ts +90 -0
- package/src/integrations/cursor/remove-folders-from-workspace.ts +93 -0
- package/src/integrations/cursor/resolve-workspace-path.ts +13 -0
- package/src/integrations/doppler/doppler-project.ts +2 -2
- package/src/lib/__tests__/infra-kit-config.test.ts +64 -14
- package/src/lib/git-utils/git-utils.ts +27 -8
- package/src/lib/infra-kit-config/index.ts +2 -0
- package/src/lib/infra-kit-config/infra-kit-config.ts +143 -0
- package/src/mcp/tools/index.ts +2 -0
- package/tsconfig.json +3 -2
- package/tsconfig.tsbuildinfo +1 -1
- 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.
|
|
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.
|
|
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": "
|
|
48
|
+
"typescript": "catalog:"
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import
|
|
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')
|
|
@@ -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'
|
|
@@ -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'
|
|
@@ -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 +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?:
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
.
|
|
355
|
+
.enum(CURSOR_MODES)
|
|
315
356
|
.optional()
|
|
316
357
|
.describe(
|
|
317
|
-
'
|
|
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()
|
|
@@ -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
|
+
}
|