infra-kit 0.1.99 → 0.1.100

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 (47) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-d367c3be-9c2a-48e7-bcea-b45861af568c.jsonl +2 -0
  3. package/.omc/state/agent-replay-f2846d8f-974c-486c-b16f-4bdaa28ca45f.jsonl +1 -0
  4. package/.omc/state/last-tool-error.json +7 -0
  5. package/.omc/state/subagent-tracking.json +7 -0
  6. package/.turbo/turbo-eslint-check.log +1 -4
  7. package/.turbo/turbo-eslint-fix.log +1 -0
  8. package/.turbo/turbo-prettier-check.log +1 -4
  9. package/.turbo/turbo-prettier-fix.log +1 -10
  10. package/.turbo/turbo-test.log +5 -11
  11. package/.turbo/turbo-ts-check.log +1 -4
  12. package/dist/cli.js +45 -36
  13. package/dist/cli.js.map +4 -4
  14. package/dist/mcp.js +42 -34
  15. package/dist/mcp.js.map +4 -4
  16. package/package.json +11 -11
  17. package/src/commands/doctor/doctor.ts +62 -12
  18. package/src/commands/env-clear/env-clear.ts +5 -10
  19. package/src/commands/env-list/env-list.ts +5 -10
  20. package/src/commands/env-load/env-load.ts +5 -10
  21. package/src/commands/env-status/env-status.ts +5 -10
  22. package/src/commands/gh-merge-dev/gh-merge-dev.ts +17 -18
  23. package/src/commands/gh-release-deliver/gh-release-deliver.ts +290 -89
  24. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +15 -14
  25. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +30 -23
  26. package/src/commands/gh-release-list/gh-release-list.ts +5 -10
  27. package/src/commands/release-create/release-create.ts +22 -18
  28. package/src/commands/release-desc-edit/index.ts +1 -0
  29. package/src/commands/release-desc-edit/release-desc-edit.ts +207 -0
  30. package/src/commands/version/version.ts +5 -10
  31. package/src/commands/worktrees-add/worktrees-add.ts +18 -14
  32. package/src/commands/worktrees-list/worktrees-list.ts +6 -11
  33. package/src/commands/worktrees-open/worktrees-open.ts +10 -6
  34. package/src/commands/worktrees-remove/worktrees-remove.ts +18 -14
  35. package/src/commands/worktrees-sync/worktrees-sync.ts +17 -12
  36. package/src/entry/cli.ts +16 -0
  37. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +21 -0
  38. package/src/integrations/gh/gh-release-prs/index.ts +1 -1
  39. package/src/integrations/gh/index.ts +1 -1
  40. package/src/integrations/jira/api.ts +8 -17
  41. package/src/integrations/jira/index.ts +2 -0
  42. package/src/lib/errors/__tests__/operation-error.test.ts +62 -0
  43. package/src/lib/errors/format-zx-error.ts +54 -0
  44. package/src/lib/errors/operation-error.ts +80 -0
  45. package/src/mcp/tools/index.ts +2 -0
  46. package/src/types.ts +56 -2
  47. package/tsconfig.tsbuildinfo +1 -1
@@ -9,16 +9,18 @@ import { removeFoldersFromCursorWorkspace, resolveCursorWorkspacePath } from 'sr
9
9
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
10
10
  import { commandEcho } from 'src/lib/command-echo'
11
11
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
12
+ import { OperationError } from 'src/lib/errors/operation-error'
12
13
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
13
14
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
14
15
  import { logger } from 'src/lib/logger'
15
16
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
16
17
  import type { ReleaseType } from 'src/lib/release-utils'
17
- import type { RequiredConfirmedOptionArg, ToolsExecutionResult } from 'src/types'
18
+ import { defineMcpTool, textContent } from 'src/types'
19
+ import type { RequiredConfirmedOptionArg } from 'src/types'
18
20
 
19
21
  // Constants
20
22
  interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
21
- all: boolean
23
+ all?: boolean
22
24
  versions?: string
23
25
  }
24
26
 
@@ -26,7 +28,7 @@ interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
26
28
  * Manage git worktrees for release branches
27
29
  * Creates worktrees for active release branches and removes unused ones
28
30
  */
29
- export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<ToolsExecutionResult> => {
31
+ export const worktreesRemove = async (options: WorktreeManagementArgs) => {
30
32
  const { confirmedCommand, all, versions } = options
31
33
 
32
34
  commandEcho.start('worktrees-remove')
@@ -40,7 +42,7 @@ export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<
40
42
  commandEcho.print()
41
43
 
42
44
  return {
43
- content: [{ type: 'text', text: JSON.stringify({ removedWorktrees: [], count: 0 }, null, 2) }],
45
+ content: textContent(JSON.stringify({ removedWorktrees: [], count: 0 }, null, 2)),
44
46
  structuredContent: { removedWorktrees: [], count: 0 },
45
47
  }
46
48
  }
@@ -131,17 +133,15 @@ export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<
131
133
  }
132
134
 
133
135
  return {
134
- content: [
135
- {
136
- type: 'text',
137
- text: JSON.stringify(structuredContent, null, 2),
138
- },
139
- ],
136
+ content: textContent(JSON.stringify(structuredContent, null, 2)),
140
137
  structuredContent,
141
138
  }
142
139
  } catch (error) {
143
140
  logger.error({ error }, '❌ Error managing worktrees')
144
- throw error
141
+ throw new OperationError(error, {
142
+ operation: 'remove worktrees',
143
+ remediation: "check 'git worktree list' for the path; uncommitted changes block removal",
144
+ })
145
145
  }
146
146
  }
147
147
 
@@ -179,8 +179,12 @@ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> =>
179
179
  removed.push(result.value)
180
180
  } else {
181
181
  const branch = branches[index]
182
+ const err = new OperationError(result.reason, {
183
+ operation: `remove worktree for ${branch}`,
184
+ remediation: "check 'git worktree list' for the path; uncommitted changes block removal",
185
+ })
182
186
 
183
- logger.error({ error: result.reason }, `❌ Failed to remove worktree for ${branch}`)
187
+ logger.error({ error: result.reason, msg: err.message })
184
188
  }
185
189
  }
186
190
 
@@ -252,7 +256,7 @@ const logResults = (removed: string[]): void => {
252
256
  }
253
257
 
254
258
  // MCP Tool Registration
255
- export const worktreesRemoveMcpTool = {
259
+ export const worktreesRemoveMcpTool = defineMcpTool({
256
260
  name: 'worktrees-remove',
257
261
  description:
258
262
  'Remove local git worktrees for release branches. When everything is removed, also runs "git worktree prune" and deletes the worktrees directory. When invoked via MCP, pass either "versions" (comma-separated) or all=true — the branch picker is unreachable without a TTY, and the CLI confirmation is auto-skipped for MCP calls, so the caller is responsible for gating.',
@@ -275,4 +279,4 @@ export const worktreesRemoveMcpTool = {
275
279
  count: z.number().describe('Number of git worktrees removed'),
276
280
  },
277
281
  handler: worktreesRemove,
278
- }
282
+ })
@@ -8,10 +8,12 @@ import { removeFoldersFromCursorWorkspace, resolveCursorWorkspacePath } from 'sr
8
8
  import { getReleasePRs } from 'src/integrations/gh'
9
9
  import { commandEcho } from 'src/lib/command-echo'
10
10
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
11
+ import { OperationError } from 'src/lib/errors/operation-error'
11
12
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
12
13
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
13
14
  import { logger } from 'src/lib/logger'
14
- import type { RequiredConfirmedOptionArg, ToolsExecutionResult } from 'src/types'
15
+ import { defineMcpTool, textContent } from 'src/types'
16
+ import type { RequiredConfirmedOptionArg } from 'src/types'
15
17
 
16
18
  // Constants
17
19
  const RELEASE_BRANCH_PREFIX = 'release/v'
@@ -23,7 +25,7 @@ interface WorktreeSyncArgs extends RequiredConfirmedOptionArg {}
23
25
  *
24
26
  * Creates worktrees for active release branches and removes unused ones
25
27
  */
26
- export const worktreesSync = async (options: WorktreeSyncArgs): Promise<ToolsExecutionResult> => {
28
+ export const worktreesSync = async (options: WorktreeSyncArgs) => {
27
29
  const { confirmedCommand } = options
28
30
 
29
31
  commandEcho.start('worktrees-sync')
@@ -82,17 +84,15 @@ export const worktreesSync = async (options: WorktreeSyncArgs): Promise<ToolsExe
82
84
  }
83
85
 
84
86
  return {
85
- content: [
86
- {
87
- type: 'text',
88
- text: JSON.stringify(structuredContent, null, 2),
89
- },
90
- ],
87
+ content: textContent(JSON.stringify(structuredContent, null, 2)),
91
88
  structuredContent,
92
89
  }
93
90
  } catch (error) {
94
91
  logger.error({ error }, '❌ Error managing worktrees')
95
- throw error
92
+ throw new OperationError(error, {
93
+ operation: 'sync worktrees with remote',
94
+ remediation: "ensure 'gh auth status' is ok and you can reach origin",
95
+ })
96
96
  }
97
97
  }
98
98
 
@@ -143,7 +143,12 @@ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> =>
143
143
  await $`git worktree remove ${worktreePath}`
144
144
  removed.push(branch)
145
145
  } catch (error) {
146
- logger.error({ error, branch }, `❌ Failed to remove worktree for ${branch}`)
146
+ const err = new OperationError(error, {
147
+ operation: `remove stale worktree for ${branch}`,
148
+ remediation: 'inspect the worktree dir manually; rerun with the branch checked out elsewhere',
149
+ })
150
+
151
+ logger.error({ error, branch, msg: err.message })
147
152
  }
148
153
  }
149
154
 
@@ -207,7 +212,7 @@ const logResults = (removed: string[]): void => {
207
212
  }
208
213
 
209
214
  // MCP Tool Registration
210
- export const worktreesSyncMcpTool = {
215
+ export const worktreesSyncMcpTool = defineMcpTool({
211
216
  name: 'worktrees-sync',
212
217
  description:
213
218
  'Remove worktrees whose release PR is no longer open (stale cleanup). Only removes — never creates; use worktrees-add to create worktrees for new releases. The CLI confirmation is auto-skipped for MCP calls, so the caller is responsible for gating.',
@@ -217,4 +222,4 @@ export const worktreesSyncMcpTool = {
217
222
  count: z.number().describe('Number of worktrees removed during sync'),
218
223
  },
219
224
  handler: worktreesSync,
220
- }
225
+ })
package/src/entry/cli.ts CHANGED
@@ -15,6 +15,7 @@ import { ghReleaseDeploySelected } from 'src/commands/gh-release-deploy-selected
15
15
  import { ghReleaseList } from 'src/commands/gh-release-list'
16
16
  import { init } from 'src/commands/init'
17
17
  import { releaseCreate } from 'src/commands/release-create'
18
+ import { releaseDescEdit } from 'src/commands/release-desc-edit'
18
19
  import { version } from 'src/commands/version'
19
20
  import { CURSOR_MODES, worktreesAdd } from 'src/commands/worktrees-add'
20
21
  import type { CursorMode } from 'src/commands/worktrees-add'
@@ -102,6 +103,20 @@ program
102
103
  })
103
104
  })
104
105
 
106
+ program
107
+ .command('release-desc-edit')
108
+ .description("Edit a release's description in Jira and in the matching GitHub PR body")
109
+ .option('-v, --version <version>', 'Release version, e.g. 1.2.5')
110
+ .option('-d, --description <description>', 'New description (use "" to clear)')
111
+ .option('-y, --yes', 'Skip confirmation prompt')
112
+ .action(async (options) => {
113
+ await releaseDescEdit({
114
+ version: options.version,
115
+ description: options.description,
116
+ confirmedCommand: options.yes,
117
+ })
118
+ })
119
+
105
120
  program
106
121
  .command('release-deploy-all')
107
122
  .description('Deploy any release branch to any environment')
@@ -263,6 +278,7 @@ if (process.argv.length <= 2) {
263
278
  'merge-dev',
264
279
  'release-list',
265
280
  'release-create',
281
+ 'release-desc-edit',
266
282
  'release-deploy-all',
267
283
  'release-deploy-selected',
268
284
  'release-deliver',
@@ -109,6 +109,27 @@ export const getReleasePRsWithInfo = async (): Promise<ReleasePRInfo[]> => {
109
109
  }
110
110
  }
111
111
 
112
+ interface UpdateReleasePRBodyArgs {
113
+ branch: string
114
+ body: string
115
+ }
116
+
117
+ /**
118
+ * Update the body of an open release PR identified by its head branch.
119
+ */
120
+ export const updateReleasePRBody = async (args: UpdateReleasePRBodyArgs): Promise<void> => {
121
+ const { branch, body } = args
122
+
123
+ try {
124
+ $.quiet = true
125
+ await $`gh pr edit ${branch} --body ${body}`
126
+ $.quiet = false
127
+ } catch (error: unknown) {
128
+ logger.error({ error, branch }, `Error updating release PR body for ${branch}`)
129
+ throw error
130
+ }
131
+ }
132
+
112
133
  interface CreateReleaseBranchArgs {
113
134
  version: string
114
135
  jiraVersionUrl: string
@@ -1,2 +1,2 @@
1
- export { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo } from './gh-release-prs'
1
+ export { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo, updateReleasePRBody } from './gh-release-prs'
2
2
  export type { ReleasePRInfo } from './gh-release-prs'
@@ -1,3 +1,3 @@
1
1
  export { validateGitHubCliAndAuth } from './gh-cli-auth'
2
- export { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo } from './gh-release-prs'
2
+ export { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo, updateReleasePRBody } from './gh-release-prs'
3
3
  export type { ReleasePRInfo } from './gh-release-prs'
@@ -145,7 +145,7 @@ export const getProjectVersions = async (config: JiraConfig): Promise<JiraVersio
145
145
  * @param config - Jira configuration
146
146
  * @returns JiraVersion if found, null otherwise
147
147
  */
148
- const findVersionByName = async (versionName: string, config: JiraConfig): Promise<JiraVersion | null> => {
148
+ export const findVersionByName = async (versionName: string, config: JiraConfig): Promise<JiraVersion | null> => {
149
149
  try {
150
150
  const versions = await getProjectVersions(config)
151
151
  const version = versions.find((v) => {
@@ -166,29 +166,20 @@ const findVersionByName = async (versionName: string, config: JiraConfig): Promi
166
166
  * @param config - Jira configuration
167
167
  * @returns Result containing updated version or error
168
168
  */
169
- const updateJiraVersion = async (
169
+ export const updateJiraVersion = async (
170
170
  params: UpdateJiraVersionParams,
171
171
  config: JiraConfig,
172
172
  ): Promise<UpdateJiraVersionResult> => {
173
173
  try {
174
174
  const { baseUrl, token, email } = config
175
175
 
176
- // Prepare request body - only include fields that are provided
177
- const requestBody: Record<string, any> = {
178
- released: params.released ?? true,
179
- archived: params.archived ?? false,
180
- }
181
-
182
- // Add releaseDate if provided, otherwise use current date when releasing
183
- if (params.releaseDate) {
184
- requestBody.releaseDate = params.releaseDate
185
- } else if (params.released !== false) {
186
- requestBody.releaseDate = new Date().toISOString().split('T')[0] // YYYY-MM-DD
187
- }
176
+ // Only include fields the caller explicitly passed.
177
+ const requestBody: Record<string, any> = {}
188
178
 
189
- if (params.description !== undefined) {
190
- requestBody.description = params.description
191
- }
179
+ if (params.released !== undefined) requestBody.released = params.released
180
+ if (params.archived !== undefined) requestBody.archived = params.archived
181
+ if (params.releaseDate !== undefined) requestBody.releaseDate = params.releaseDate
182
+ if (params.description !== undefined) requestBody.description = params.description
192
183
 
193
184
  const url = `${baseUrl}/rest/api/3/version/${params.versionId}`
194
185
  const credentials = btoa(`${email}:${token}`)
@@ -1,9 +1,11 @@
1
1
  export {
2
2
  createJiraVersion,
3
3
  deliverJiraRelease,
4
+ findVersionByName,
4
5
  getProjectVersions,
5
6
  loadJiraConfig,
6
7
  loadJiraConfigOptional,
8
+ updateJiraVersion,
7
9
  } from './api.js'
8
10
  export type {
9
11
  CreateJiraVersionParams,
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { OperationError } from '../operation-error'
4
+
5
+ describe('operationError', () => {
6
+ it('formats a minimal message from operation alone', () => {
7
+ const err = new OperationError(new Error('boom'), { operation: 'do the thing' })
8
+
9
+ expect(err).toBeInstanceOf(OperationError)
10
+ expect(err.message).toBe('failed to do the thing')
11
+ expect(err.cause).toBeInstanceOf(Error)
12
+ expect(err.operation).toBe('do the thing')
13
+ })
14
+
15
+ it('appends a remediation hint when provided', () => {
16
+ const err = new OperationError(new Error('boom'), {
17
+ operation: 'do the thing',
18
+ remediation: 'check the docs',
19
+ })
20
+
21
+ expect(err.message).toBe('failed to do the thing — try: check the docs')
22
+ expect(err.remediation).toBe('check the docs')
23
+ })
24
+
25
+ it('extracts a stderr excerpt from a zx ProcessOutput-shaped cause', () => {
26
+ const zxLike = { stderr: 'fatal: not a git repository\n(more lines...)' }
27
+ const err = new OperationError(zxLike, { operation: 'run git' })
28
+
29
+ expect(err.message).toContain('stderr: fatal: not a git repository')
30
+ })
31
+
32
+ it('truncates oversized stderr excerpts', () => {
33
+ const huge = 'X'.repeat(5000)
34
+ const err = new OperationError({ stderr: huge }, { operation: 'run git' })
35
+
36
+ expect(err.message.length).toBeLessThan(300)
37
+ })
38
+
39
+ it('prefers an explicit stderrExcerpt over the cause stderr', () => {
40
+ const err = new OperationError(
41
+ { stderr: 'raw zx output' },
42
+ { operation: 'run git', stderrExcerpt: 'curated excerpt' },
43
+ )
44
+
45
+ expect(err.message).toContain('stderr: curated excerpt')
46
+ expect(err.message).not.toContain('raw zx output')
47
+ })
48
+
49
+ it('preserves the original cause', () => {
50
+ const cause = new Error('underlying')
51
+ const err = new OperationError(cause, { operation: 'do thing' })
52
+
53
+ expect(err.cause).toBe(cause)
54
+ })
55
+
56
+ it('handles non-Error causes', () => {
57
+ const err = new OperationError('a string', { operation: 'do thing' })
58
+
59
+ expect(err.message).toBe('failed to do thing')
60
+ expect(err.cause).toBe('a string')
61
+ })
62
+ })
@@ -0,0 +1,54 @@
1
+ const STDERR_EXCERPT_MAX_BYTES = 500
2
+ const STDOUT_EXCERPT_MAX_BYTES = 200
3
+
4
+ export interface ZxErrorFields {
5
+ exitCode?: number | null
6
+ stderr?: string
7
+ stdout?: string
8
+ message?: string
9
+ name?: string
10
+ }
11
+
12
+ const readTrimmedString = (value: unknown, max: number): string | undefined => {
13
+ if (typeof value !== 'string' || value.length === 0) return undefined
14
+
15
+ return value.slice(-max).trim()
16
+ }
17
+
18
+ /**
19
+ * Extract loggable fields from an error for `logger.error({ err: formatZxError(e) }, ...)`.
20
+ *
21
+ * pino's default `err` serializer handles `Error` subclasses but renders zx's
22
+ * `ProcessOutput` as `{}` because its informative fields (`stderr`, `stdout`,
23
+ * `exitCode`) are non-enumerable / on the prototype. This helper duck-types
24
+ * those fields so subprocess failures surface in logs instead of vanishing.
25
+ */
26
+ export const formatZxError = (error: unknown): ZxErrorFields => {
27
+ if (error === null || typeof error !== 'object') {
28
+ return { message: String(error) }
29
+ }
30
+
31
+ const rec = error as Record<string, unknown>
32
+ const fields: ZxErrorFields = {}
33
+
34
+ if (error instanceof Error) {
35
+ fields.name = error.name
36
+ fields.message = error.message
37
+ } else if (typeof rec.message === 'string') {
38
+ fields.message = rec.message
39
+ }
40
+
41
+ const exitCode = rec.exitCode
42
+
43
+ if (typeof exitCode === 'number' || exitCode === null) fields.exitCode = exitCode
44
+
45
+ const stderr = readTrimmedString(rec.stderr, STDERR_EXCERPT_MAX_BYTES)
46
+
47
+ if (stderr) fields.stderr = stderr
48
+
49
+ const stdout = readTrimmedString(rec.stdout, STDOUT_EXCERPT_MAX_BYTES)
50
+
51
+ if (stdout) fields.stdout = stdout
52
+
53
+ return fields
54
+ }
@@ -0,0 +1,80 @@
1
+ const STDERR_EXCERPT_MAX_BYTES = 200
2
+
3
+ export interface OperationErrorContext {
4
+ operation: string
5
+ remediation?: string
6
+ stderrExcerpt?: string
7
+ }
8
+
9
+ /**
10
+ * Duck-typed read of zx's `ProcessOutput.stderr` (and similar shapes) without
11
+ * importing zx types just for an `instanceof` check.
12
+ *
13
+ * @example
14
+ * extractStderr(new Error('x')) // undefined
15
+ * extractStderr({ stderr: 'fatal: ...' }) // 'fatal: ...'
16
+ * extractStderr({ stderr: '' }) // undefined (empty treated as missing)
17
+ */
18
+ const extractStderr = (cause: unknown): string | undefined => {
19
+ if (cause === null || typeof cause !== 'object') return undefined
20
+ const stderr = (cause as { stderr?: unknown }).stderr
21
+
22
+ return typeof stderr === 'string' && stderr.length > 0 ? stderr : undefined
23
+ }
24
+
25
+ /**
26
+ * Compose the human-and-agent-readable message body for an `OperationError`:
27
+ * `"failed to <operation> [— stderr: <excerpt>] [— try: <remediation>]"`.
28
+ * `stderrExcerpt` overrides anything duck-typed off `cause`; both are trimmed
29
+ * and capped at {@link STDERR_EXCERPT_MAX_BYTES} so a runaway subprocess can't
30
+ * blow up the message.
31
+ */
32
+ const buildMessage = (cause: unknown, ctx: OperationErrorContext): string => {
33
+ const stderr = ctx.stderrExcerpt ?? extractStderr(cause)
34
+ const parts = [`failed to ${ctx.operation}`]
35
+
36
+ if (stderr) parts.push(`stderr: ${stderr.slice(0, STDERR_EXCERPT_MAX_BYTES).trim()}`)
37
+ if (ctx.remediation) parts.push(`try: ${ctx.remediation}`)
38
+
39
+ return parts.join(' — ')
40
+ }
41
+
42
+ /**
43
+ * Error type for any handler-level failure that should surface to the caller
44
+ * (CLI user or MCP-connected agent) with a remediation hint. Wraps an
45
+ * underlying cause and renders a single-line, structured message so logs and
46
+ * agent tool-result text stay scannable.
47
+ *
48
+ * Pattern modeled on the exemplary Doppler errors in
49
+ * `src/integrations/doppler/doppler-cli-auth.ts`.
50
+ *
51
+ * @example
52
+ * // wrap a zx subprocess failure
53
+ * try {
54
+ * await $`git worktree add ${path} ${branch}`
55
+ * } catch (err) {
56
+ * throw new OperationError(err, {
57
+ * operation: `git worktree add for ${branch}`,
58
+ * remediation: 'check the branch name and that the parent dir is writable',
59
+ * })
60
+ * }
61
+ *
62
+ * @example
63
+ * // validation failure with no underlying cause
64
+ * throw new OperationError(undefined, {
65
+ * operation: 'launch deploy-all workflow',
66
+ * remediation: `pass one of: ${environments.join(', ')}`,
67
+ * stderrExcerpt: `invalid environment: ${selectedEnv}`,
68
+ * })
69
+ */
70
+ export class OperationError extends Error {
71
+ readonly operation: string
72
+ readonly remediation?: string
73
+
74
+ constructor(cause: unknown, ctx: OperationErrorContext) {
75
+ super(buildMessage(cause, ctx), { cause })
76
+ this.name = 'OperationError'
77
+ this.operation = ctx.operation
78
+ this.remediation = ctx.remediation
79
+ }
80
+ }
@@ -10,6 +10,7 @@ import { ghReleaseDeployAllMcpTool } from 'src/commands/gh-release-deploy-all'
10
10
  import { ghReleaseDeploySelectedMcpTool } from 'src/commands/gh-release-deploy-selected'
11
11
  import { ghReleaseListMcpTool } from 'src/commands/gh-release-list'
12
12
  import { releaseCreateMcpTool } from 'src/commands/release-create'
13
+ import { releaseDescEditMcpTool } from 'src/commands/release-desc-edit'
13
14
  import { versionMcpTool } from 'src/commands/version'
14
15
  import { worktreesAddMcpTool } from 'src/commands/worktrees-add'
15
16
  import { worktreesListMcpTool } from 'src/commands/worktrees-list'
@@ -25,6 +26,7 @@ const tools = [
25
26
  envClearMcpTool,
26
27
  ghMergeDevMcpTool,
27
28
  releaseCreateMcpTool,
29
+ releaseDescEditMcpTool,
28
30
  ghReleaseDeliverMcpTool,
29
31
  ghReleaseDeployAllMcpTool,
30
32
  ghReleaseDeploySelectedMcpTool,
package/src/types.ts CHANGED
@@ -1,12 +1,66 @@
1
- export interface ToolsExecutionResult {
1
+ import type { z } from 'zod/v4'
2
+
3
+ export interface ToolsExecutionResult<TStructured = Record<string, unknown>> {
2
4
  [x: string]: unknown
3
5
  content: {
4
6
  type: 'text'
5
7
  text: string
6
8
  }[]
7
- structuredContent?: { [x: string]: unknown }
9
+ structuredContent?: TStructured
8
10
  }
9
11
 
10
12
  export interface RequiredConfirmedOptionArg {
11
13
  confirmedCommand: boolean
12
14
  }
15
+
16
+ export interface McpTool<TIn extends z.ZodRawShape = z.ZodRawShape, TOut extends z.ZodRawShape = z.ZodRawShape> {
17
+ name: string
18
+ description: string
19
+ inputSchema: TIn
20
+ outputSchema: TOut
21
+ handler: (
22
+ params: z.infer<z.ZodObject<TIn>> & RequiredConfirmedOptionArg,
23
+ ) => Promise<ToolsExecutionResult<z.infer<z.ZodObject<TOut>>>>
24
+ }
25
+
26
+ /**
27
+ * Build the dual-channel content array shared by every MCP tool. Narrows the
28
+ * literal `type: 'text'` so handlers can use inferred return types without TS
29
+ * widening `type` to `string` — which would otherwise break assignability
30
+ * against the MCP SDK's content union.
31
+ *
32
+ * @example
33
+ * return {
34
+ * content: textContent(JSON.stringify(structuredContent, null, 2)),
35
+ * structuredContent,
36
+ * }
37
+ */
38
+ export const textContent = (text: string): ToolsExecutionResult['content'] => {
39
+ return [{ type: 'text', text }]
40
+ }
41
+
42
+ /**
43
+ * Factory that ties the handler's return type to the declared `outputSchema`
44
+ * so `structuredContent` is checked against the schema at compile time. If a
45
+ * handler accidentally drops or renames a field, TS errors at the registration
46
+ * site rather than at runtime in an MCP client.
47
+ *
48
+ * @example
49
+ * export const envLoadMcpTool = defineMcpTool({
50
+ * name: 'env-load',
51
+ * description: '...',
52
+ * inputSchema: { config: z.string() },
53
+ * outputSchema: {
54
+ * filePath: z.string(),
55
+ * variableCount: z.number(),
56
+ * project: z.string(),
57
+ * config: z.string(),
58
+ * },
59
+ * handler: envLoad,
60
+ * })
61
+ */
62
+ export const defineMcpTool = <TIn extends z.ZodRawShape, TOut extends z.ZodRawShape>(
63
+ tool: McpTool<TIn, TOut>,
64
+ ): McpTool<TIn, TOut> => {
65
+ return tool
66
+ }