@bretwardjames/ghp-mcp 0.1.4 → 0.1.5

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @bretwardjames/ghp-mcp@0.1.3 build /home/bretwardjames/IdeaProjects/ghp/packages/mcp
2
+ > @bretwardjames/ghp-mcp@0.1.5 build /home/bretwardjames/IdeaProjects/ghp/packages/mcp
3
3
  > tsup src/index.ts --format esm --dts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -7,8 +7,8 @@ CLI Using tsconfig: tsconfig.json
7
7
  CLI tsup v8.5.1
8
8
  CLI Target: es2022
9
9
  ESM Build start
10
- ESM dist/index.js 52.39 KB
11
- ESM ⚡️ Build success in 54ms
10
+ ESM dist/index.js 55.56 KB
11
+ ESM ⚡️ Build success in 42ms
12
12
  DTS Build start
13
- DTS ⚡️ Build success in 1637ms
13
+ DTS ⚡️ Build success in 2277ms
14
14
  DTS dist/index.d.ts 20.00 B
package/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # @bretwardjames/ghp-mcp
2
2
 
3
+ ## 0.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - QA Checkpoint 2026-02-02
8
+
9
+ ## @bretwardjames/ghp-core
10
+
11
+ ### Security
12
+
13
+ - Fix command injection vulnerabilities by using `spawn()` with array arguments instead of `exec()` with string interpolation
14
+ - Add `shell-utils` module with `shellEscape()`, `validateNumericInput()`, `validateSafeString()`, `validateUrl()`
15
+
16
+ ### Error Handling
17
+
18
+ - Add `GitError` class that captures command, stderr, exitCode, and cwd for debugging
19
+ - Remove silent catch blocks from git-utils functions - errors now propagate properly
20
+
21
+ ### New Features
22
+
23
+ - Add retry logic for transient GitHub API failures (`withRetry`, `isTransientError`, `calculateBackoffDelay`)
24
+ - Add configurable hook failure behavior (`OnFailureBehavior`: 'fail-fast' | 'continue')
25
+ - Support per-event hook settings via `eventDefaults` in event-hooks.json
26
+
27
+ ### Bug Fixes
28
+
29
+ - Fix repository field in GraphQL queries to return full `owner/repo` format
30
+
31
+ ## @bretwardjames/ghp-cli
32
+
33
+ ### New Features
34
+
35
+ - Add centralized exit utility with cleanup handler support (`registerCleanupHandler`, `exit`)
36
+ - Add validation module for enum flags, mutual exclusion, and numeric bounds
37
+
38
+ ### Bug Fixes
39
+
40
+ - Fix `deepMergeObjects` to recursively merge nested config at all depths
41
+ - Fix type safety issues - replace `any` types with `SortableFieldValue` in sorting logic
42
+ - Fix `planCommand` parameter type from `any` to `Command | PlanOptions`
43
+
44
+ ### Documentation
45
+
46
+ - Document all hook events (pre-pr, pr-creating) and template variables
47
+ - Update create-pr command to mention committing ragtime branch context
48
+
49
+ ### Test Coverage
50
+
51
+ - Add 25 tests for CLI commands (start, add-issue)
52
+ - Add 21 tests for exit utility
53
+ - Add 34 tests for config operations
54
+ - Add 33 tests for validation module
55
+
56
+ ## @bretwardjames/ghp-mcp
57
+
58
+ ### Security
59
+
60
+ - Fix command injection in worktree operations
61
+
62
+ ### Test Coverage
63
+
64
+ - Add 9 tests for tool registry
65
+
66
+ - Updated dependencies
67
+ - Updated dependencies [fbe1b3c]
68
+ - @bretwardjames/ghp-core@0.6.0
69
+
3
70
  ## 0.1.4
4
71
 
5
72
  ### Patch Changes
package/README.md CHANGED
@@ -65,13 +65,14 @@ The server uses the same GitHub authentication as the CLI. Run `ghp auth` to aut
65
65
 
66
66
  | Tool | Description |
67
67
  |------|-------------|
68
- | `work` | View items assigned to you |
68
+ | `get_my_work` | View items assigned to you |
69
69
  | `get_project_board` | View project board/items (with optional status/assignee filters) |
70
70
  | `create_issue` | Create a new issue and add to project |
71
71
  | `update_issue` | Update an issue's title and/or body |
72
72
  | `move` | Move an issue to a different status |
73
73
  | `done` | Mark an issue as done |
74
74
  | `start` | Start working on an issue |
75
+ | `create_worktree` | Create a worktree for parallel development |
75
76
  | `assign` | Assign users to an issue |
76
77
  | `comment` | Add a comment to an issue |
77
78
  | `set_field` | Set a field value on an issue |
@@ -80,7 +81,7 @@ The server uses the same GitHub authentication as the CLI. Run `ghp auth` to aut
80
81
 
81
82
  ```
82
83
  AI: "Show me my current work items"
83
- → Uses the `work` tool
84
+ → Uses the `get_my_work` tool
84
85
 
85
86
  AI: "Create a bug report for the login timeout issue"
86
87
  → Uses `create_issue` with appropriate title/body
package/dist/index.js CHANGED
@@ -120,7 +120,7 @@ function createTokenProvider() {
120
120
  import { existsSync, readFileSync } from "fs";
121
121
  import { homedir } from "os";
122
122
  import { join } from "path";
123
- import { execSync as execSync2 } from "child_process";
123
+ import { execSync } from "child_process";
124
124
 
125
125
  // src/tools/work.ts
126
126
  var work_exports = {};
@@ -671,6 +671,11 @@ __export(start_exports, {
671
671
  register: () => register5
672
672
  });
673
673
  import * as z5 from "zod";
674
+ import {
675
+ getCurrentBranch,
676
+ executeHooksForEvent,
677
+ hasHooksForEvent
678
+ } from "@bretwardjames/ghp-core";
674
679
  var meta5 = {
675
680
  name: "start_work",
676
681
  category: "action"
@@ -771,16 +776,7 @@ WARNING: This issue is blocked by: ${openBlockers.map((b) => `#${b.number} (${b.
771
776
  statusField.fieldId,
772
777
  inProgressOption.id
773
778
  );
774
- if (success) {
775
- return {
776
- content: [
777
- {
778
- type: "text",
779
- text: `Started work on issue #${issue} "${item.title}" - status set to "${inProgressOption.name}".${blockingWarning}`
780
- }
781
- ]
782
- };
783
- } else {
779
+ if (!success) {
784
780
  return {
785
781
  content: [
786
782
  {
@@ -791,6 +787,43 @@ WARNING: This issue is blocked by: ${openBlockers.map((b) => `#${b.number} (${b.
791
787
  isError: true
792
788
  };
793
789
  }
790
+ let hookInfo = "";
791
+ if (hasHooksForEvent("issue-started")) {
792
+ const branch = await getCurrentBranch() || "";
793
+ const payload = {
794
+ repo: `${repo.owner}/${repo.name}`,
795
+ issue: {
796
+ number: issue,
797
+ title: item.title,
798
+ body: "",
799
+ // Body not available from ProjectItem
800
+ url: `https://github.com/${repo.owner}/${repo.name}/issues/${issue}`
801
+ },
802
+ branch
803
+ };
804
+ const hooksConfig = loadHooksConfig();
805
+ const hookResults = await executeHooksForEvent("issue-started", payload, {
806
+ onFailure: hooksConfig.onFailure
807
+ });
808
+ const successCount = hookResults.filter((r) => r.success).length;
809
+ const failCount = hookResults.length - successCount;
810
+ if (hookResults.length > 0) {
811
+ hookInfo = `
812
+
813
+ Hooks: ${successCount} succeeded`;
814
+ if (failCount > 0) {
815
+ hookInfo += `, ${failCount} failed`;
816
+ }
817
+ }
818
+ }
819
+ return {
820
+ content: [
821
+ {
822
+ type: "text",
823
+ text: `Started work on issue #${issue} "${item.title}" - status set to "${inProgressOption.name}".${blockingWarning}${hookInfo}`
824
+ }
825
+ ]
826
+ };
794
827
  } catch (error) {
795
828
  return {
796
829
  content: [
@@ -813,6 +846,10 @@ __export(add_issue_exports, {
813
846
  register: () => register6
814
847
  });
815
848
  import * as z6 from "zod";
849
+ import {
850
+ executeHooksForEvent as executeHooksForEvent2,
851
+ hasHooksForEvent as hasHooksForEvent2
852
+ } from "@bretwardjames/ghp-core";
816
853
  var meta6 = {
817
854
  name: "create_issue",
818
855
  category: "action"
@@ -910,6 +947,31 @@ Warning: Status "${status}" not found. Valid options: ${validStatuses}`;
910
947
  }
911
948
  }
912
949
  }
950
+ if (hasHooksForEvent2("issue-created")) {
951
+ const payload = {
952
+ repo: `${repo.owner}/${repo.name}`,
953
+ issue: {
954
+ number: result.number,
955
+ title,
956
+ body: body || "",
957
+ url: issueUrl
958
+ }
959
+ };
960
+ const hooksConfig = loadHooksConfig();
961
+ const hookResults = await executeHooksForEvent2("issue-created", payload, {
962
+ onFailure: hooksConfig.onFailure
963
+ });
964
+ const successCount = hookResults.filter((r) => r.success).length;
965
+ const failCount = hookResults.length - successCount;
966
+ if (hookResults.length > 0) {
967
+ message += `
968
+
969
+ Hooks: ${successCount} succeeded`;
970
+ if (failCount > 0) {
971
+ message += `, ${failCount} failed`;
972
+ }
973
+ }
974
+ }
913
975
  return {
914
976
  content: [
915
977
  {
@@ -1348,7 +1410,8 @@ __export(worktree_exports, {
1348
1410
  register: () => register11
1349
1411
  });
1350
1412
  import * as z11 from "zod";
1351
- import { execSync } from "child_process";
1413
+ import { spawnSync } from "child_process";
1414
+ import { validateNumericInput } from "@bretwardjames/ghp-core";
1352
1415
  var meta11 = {
1353
1416
  name: "create_worktree",
1354
1417
  category: "action"
@@ -1404,18 +1467,23 @@ function register11(server, context) {
1404
1467
  };
1405
1468
  }
1406
1469
  try {
1407
- let cmd = `ghp start ${issue} --parallel -fd --force`;
1470
+ const safeIssue = validateNumericInput(issue, "issue");
1471
+ const args = ["start", String(safeIssue), "--parallel", "-fd", "--force"];
1408
1472
  if (worktreePath) {
1409
- cmd += ` --worktree-path "${worktreePath}"`;
1473
+ args.push("--worktree-path", worktreePath);
1410
1474
  }
1411
1475
  if (spawnSubagent) {
1412
- cmd += " --spawn-subagent";
1476
+ args.push("--spawn-subagent");
1413
1477
  }
1414
- const output = execSync(cmd, {
1478
+ const result = spawnSync("ghp", args, {
1415
1479
  encoding: "utf-8",
1416
1480
  cwd: process.cwd(),
1417
1481
  env: process.env
1418
1482
  });
1483
+ if (result.status !== 0) {
1484
+ throw new Error(result.stderr || `ghp start failed with exit code ${result.status}`);
1485
+ }
1486
+ const output = result.stdout;
1419
1487
  const directive = spawnSubagent ? parseSpawnDirective(output) : null;
1420
1488
  const response = {
1421
1489
  content: [
@@ -1476,7 +1544,7 @@ var DEFAULT_MCP_CONFIG = {
1476
1544
  };
1477
1545
  function getRepoRoot() {
1478
1546
  try {
1479
- return execSync2("git rev-parse --show-toplevel", {
1547
+ return execSync("git rev-parse --show-toplevel", {
1480
1548
  encoding: "utf-8",
1481
1549
  stdio: ["pipe", "pipe", "pipe"]
1482
1550
  }).trim();
@@ -1528,6 +1596,23 @@ function loadMcpConfig() {
1528
1596
  }
1529
1597
  return result;
1530
1598
  }
1599
+ function loadHooksConfig() {
1600
+ const userConfigPath = join(homedir(), ".config", "ghp-cli", "config.json");
1601
+ const userConfig = loadConfigFile(userConfigPath);
1602
+ const repoRoot = getRepoRoot();
1603
+ const workspaceConfigPath = repoRoot ? join(repoRoot, ".ghp", "config.json") : null;
1604
+ const workspaceConfig = workspaceConfigPath ? loadConfigFile(workspaceConfigPath) : null;
1605
+ const result = { onFailure: "fail-fast" };
1606
+ const userHooks = userConfig?.hooks;
1607
+ if (userHooks?.onFailure === "fail-fast" || userHooks?.onFailure === "continue") {
1608
+ result.onFailure = userHooks.onFailure;
1609
+ }
1610
+ const workspaceHooks = workspaceConfig?.hooks;
1611
+ if (workspaceHooks?.onFailure === "fail-fast" || workspaceHooks?.onFailure === "continue") {
1612
+ result.onFailure = workspaceHooks.onFailure;
1613
+ }
1614
+ return result;
1615
+ }
1531
1616
  function isToolEnabled(tool, config) {
1532
1617
  const toolsConfig = config.tools || DEFAULT_MCP_CONFIG.tools;
1533
1618
  const disabledTools = new Set(config.disabledTools || []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bretwardjames/ghp-mcp",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "MCP server for ghp (GitHub Projects)",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -29,12 +29,13 @@
29
29
  "dependencies": {
30
30
  "@modelcontextprotocol/sdk": "^1.0.0",
31
31
  "zod": "^3.22.0",
32
- "@bretwardjames/ghp-core": "0.5.0"
32
+ "@bretwardjames/ghp-core": "0.6.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^20.10.0",
36
36
  "tsup": "^8.0.0",
37
- "typescript": "^5.3.2"
37
+ "typescript": "^5.3.2",
38
+ "vitest": "^3.0.5"
38
39
  },
39
40
  "engines": {
40
41
  "node": ">=18"
@@ -42,6 +43,8 @@
42
43
  "scripts": {
43
44
  "build": "tsup src/index.ts --format esm --dts",
44
45
  "dev": "tsup src/index.ts --format esm --dts --watch",
45
- "clean": "rm -rf dist"
46
+ "clean": "rm -rf dist",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest"
46
49
  }
47
50
  }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Tests for the tool registry
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+
7
+ // Mock fs
8
+ vi.mock('fs', () => ({
9
+ existsSync: vi.fn(() => false),
10
+ readFileSync: vi.fn(),
11
+ }));
12
+
13
+ // Mock os
14
+ vi.mock('os', () => ({
15
+ homedir: vi.fn(() => '/home/testuser'),
16
+ }));
17
+
18
+ // Mock child_process
19
+ vi.mock('child_process', () => ({
20
+ execSync: vi.fn(() => '/test/repo'),
21
+ exec: vi.fn(),
22
+ }));
23
+
24
+ // Mock util for promisify
25
+ vi.mock('util', async () => {
26
+ const actual = await vi.importActual<typeof import('util')>('util');
27
+ return {
28
+ ...actual,
29
+ promisify: vi.fn(() => vi.fn()),
30
+ };
31
+ });
32
+
33
+ // Mock @bretwardjames/ghp-core
34
+ vi.mock('@bretwardjames/ghp-core', () => ({
35
+ getCurrentBranch: vi.fn(),
36
+ executeHooksForEvent: vi.fn(() => []),
37
+ hasHooksForEvent: vi.fn(() => false),
38
+ branchExists: vi.fn(),
39
+ createBranch: vi.fn(),
40
+ checkoutBranch: vi.fn(),
41
+ generateBranchName: vi.fn(),
42
+ createWorktree: vi.fn(),
43
+ removeWorktree: vi.fn(),
44
+ listWorktrees: vi.fn(),
45
+ }));
46
+
47
+ // Import after mocks
48
+ import { loadMcpConfig, getToolList, registerEnabledTools } from './tool-registry.js';
49
+ import { existsSync, readFileSync } from 'fs';
50
+ import { execSync } from 'child_process';
51
+
52
+ describe('tool-registry', () => {
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ });
56
+
57
+ describe('loadMcpConfig', () => {
58
+ it('should return default config when no config files exist', () => {
59
+ vi.mocked(existsSync).mockReturnValue(false);
60
+
61
+ const config = loadMcpConfig();
62
+
63
+ expect(config).toEqual({
64
+ tools: {
65
+ read: true,
66
+ action: true,
67
+ },
68
+ disabledTools: [],
69
+ });
70
+ });
71
+
72
+ it('should merge user config with defaults', () => {
73
+ vi.mocked(existsSync).mockImplementation((path) => {
74
+ return String(path).includes('.config/ghp-cli');
75
+ });
76
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
77
+ mcp: {
78
+ tools: { read: false },
79
+ disabledTools: ['create_issue'],
80
+ },
81
+ }));
82
+
83
+ const config = loadMcpConfig();
84
+
85
+ expect(config.tools?.read).toBe(false);
86
+ expect(config.tools?.action).toBe(true); // from defaults
87
+ expect(config.disabledTools).toContain('create_issue');
88
+ });
89
+
90
+ it('should prefer workspace config over user config', () => {
91
+ vi.mocked(existsSync).mockReturnValue(true);
92
+ vi.mocked(readFileSync).mockImplementation((path) => {
93
+ if (String(path).includes('.ghp/config.json')) {
94
+ return JSON.stringify({
95
+ mcp: { tools: { action: false } },
96
+ });
97
+ }
98
+ return JSON.stringify({
99
+ mcp: { tools: { action: true } },
100
+ });
101
+ });
102
+
103
+ const config = loadMcpConfig();
104
+
105
+ expect(config.tools?.action).toBe(false); // workspace wins
106
+ });
107
+
108
+ it('should handle JSON with comments', () => {
109
+ vi.mocked(existsSync).mockImplementation((path) => {
110
+ return String(path).includes('.config/ghp-cli');
111
+ });
112
+ vi.mocked(readFileSync).mockReturnValue(`{
113
+ // This is a comment
114
+ "mcp": {
115
+ /* Block comment */
116
+ "tools": { "read": false }
117
+ }
118
+ }`);
119
+
120
+ const config = loadMcpConfig();
121
+
122
+ expect(config.tools?.read).toBe(false);
123
+ });
124
+
125
+ it('should handle missing git repo gracefully', () => {
126
+ vi.mocked(execSync).mockImplementation(() => {
127
+ throw new Error('Not a git repo');
128
+ });
129
+ vi.mocked(existsSync).mockReturnValue(false);
130
+
131
+ const config = loadMcpConfig();
132
+
133
+ // Should still return defaults
134
+ expect(config.tools?.read).toBe(true);
135
+ expect(config.tools?.action).toBe(true);
136
+ });
137
+ });
138
+
139
+ describe('getToolList', () => {
140
+ it('should return all registered tools with their categories', () => {
141
+ const tools = getToolList();
142
+
143
+ // Should have multiple tools
144
+ expect(tools.length).toBeGreaterThan(0);
145
+
146
+ // Each tool should have name and category
147
+ for (const tool of tools) {
148
+ expect(tool).toHaveProperty('name');
149
+ expect(tool).toHaveProperty('category');
150
+ expect(['read', 'action']).toContain(tool.category);
151
+ }
152
+
153
+ // Check some expected tools exist
154
+ const toolNames = tools.map(t => t.name);
155
+ expect(toolNames).toContain('create_issue');
156
+ expect(toolNames).toContain('get_my_work'); // actual tool name
157
+ });
158
+ });
159
+
160
+ describe('registerEnabledTools', () => {
161
+ it('should register tools based on config', () => {
162
+ const mockServer = {
163
+ registerTool: vi.fn(),
164
+ };
165
+ const mockContext = {
166
+ ensureAuthenticated: vi.fn(),
167
+ getRepo: vi.fn(),
168
+ api: {},
169
+ };
170
+
171
+ // All categories enabled
172
+ registerEnabledTools(mockServer as any, mockContext as any, {
173
+ tools: { read: true, action: true },
174
+ disabledTools: [],
175
+ });
176
+
177
+ // Should have registered multiple tools
178
+ expect(mockServer.registerTool).toHaveBeenCalled();
179
+ });
180
+
181
+ it('should skip disabled categories', () => {
182
+ const mockServer = {
183
+ registerTool: vi.fn(),
184
+ };
185
+ const mockContext = {
186
+ ensureAuthenticated: vi.fn(),
187
+ getRepo: vi.fn(),
188
+ api: {},
189
+ };
190
+
191
+ // Only read category enabled
192
+ registerEnabledTools(mockServer as any, mockContext as any, {
193
+ tools: { read: true, action: false },
194
+ disabledTools: [],
195
+ });
196
+
197
+ // Get the names of registered tools
198
+ const registeredNames = mockServer.registerTool.mock.calls.map(
199
+ (call) => call[0]
200
+ );
201
+
202
+ // Should only have read tools (list_work_items, view_plan)
203
+ // No action tools (create_issue, move_issue, etc.)
204
+ expect(registeredNames).not.toContain('create_issue');
205
+ expect(registeredNames).not.toContain('move_issue');
206
+ });
207
+
208
+ it('should skip specifically disabled tools', () => {
209
+ const mockServer = {
210
+ registerTool: vi.fn(),
211
+ };
212
+ const mockContext = {
213
+ ensureAuthenticated: vi.fn(),
214
+ getRepo: vi.fn(),
215
+ api: {},
216
+ };
217
+
218
+ // All categories enabled but specific tool disabled
219
+ registerEnabledTools(mockServer as any, mockContext as any, {
220
+ tools: { read: true, action: true },
221
+ disabledTools: ['create_issue'],
222
+ });
223
+
224
+ const registeredNames = mockServer.registerTool.mock.calls.map(
225
+ (call) => call[0]
226
+ );
227
+
228
+ expect(registeredNames).not.toContain('create_issue');
229
+ // Other action tools should still be registered
230
+ expect(registeredNames).toContain('move_issue');
231
+ });
232
+ });
233
+ });
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { ServerContext } from './server.js';
3
3
  import type { ToolCategory, McpConfig, McpToolsConfig } from './types.js';
4
+ import type { OnFailureBehavior } from '@bretwardjames/ghp-core';
4
5
  import { existsSync, readFileSync } from 'fs';
5
6
  import { homedir } from 'os';
6
7
  import { join } from 'path';
@@ -142,6 +143,45 @@ export function loadMcpConfig(): McpConfig {
142
143
  return result;
143
144
  }
144
145
 
146
+ /**
147
+ * Hooks configuration for onFailure behavior
148
+ */
149
+ export interface HooksConfig {
150
+ onFailure: OnFailureBehavior;
151
+ }
152
+
153
+ /**
154
+ * Load hooks configuration from user and workspace config files.
155
+ * Workspace config takes precedence over user config.
156
+ */
157
+ export function loadHooksConfig(): HooksConfig {
158
+ // User config: ~/.config/ghp-cli/config.json
159
+ const userConfigPath = join(homedir(), '.config', 'ghp-cli', 'config.json');
160
+ const userConfig = loadConfigFile(userConfigPath);
161
+
162
+ // Workspace config: <repo-root>/.ghp/config.json
163
+ const repoRoot = getRepoRoot();
164
+ const workspaceConfigPath = repoRoot ? join(repoRoot, '.ghp', 'config.json') : null;
165
+ const workspaceConfig = workspaceConfigPath ? loadConfigFile(workspaceConfigPath) : null;
166
+
167
+ // Default
168
+ const result: HooksConfig = { onFailure: 'fail-fast' };
169
+
170
+ // Apply user config
171
+ const userHooks = userConfig?.hooks as { onFailure?: string } | undefined;
172
+ if (userHooks?.onFailure === 'fail-fast' || userHooks?.onFailure === 'continue') {
173
+ result.onFailure = userHooks.onFailure;
174
+ }
175
+
176
+ // Apply workspace config (takes precedence)
177
+ const workspaceHooks = workspaceConfig?.hooks as { onFailure?: string } | undefined;
178
+ if (workspaceHooks?.onFailure === 'fail-fast' || workspaceHooks?.onFailure === 'continue') {
179
+ result.onFailure = workspaceHooks.onFailure;
180
+ }
181
+
182
+ return result;
183
+ }
184
+
145
185
  /**
146
186
  * Get list of all tool names and categories
147
187
  */
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import * as z from 'zod';
3
3
  import type { ServerContext } from '../server.js';
4
4
  import type { ToolMeta } from '../types.js';
5
+ import { loadHooksConfig } from '../tool-registry.js';
5
6
  import {
6
7
  executeHooksForEvent,
7
8
  hasHooksForEvent,
@@ -132,7 +133,10 @@ export function register(server: McpServer, context: ServerContext): void {
132
133
  },
133
134
  };
134
135
 
135
- const hookResults = await executeHooksForEvent('issue-created', payload);
136
+ const hooksConfig = loadHooksConfig();
137
+ const hookResults = await executeHooksForEvent('issue-created', payload, {
138
+ onFailure: hooksConfig.onFailure,
139
+ });
136
140
  const successCount = hookResults.filter(r => r.success).length;
137
141
  const failCount = hookResults.length - successCount;
138
142
 
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import * as z from 'zod';
3
3
  import type { ServerContext } from '../server.js';
4
4
  import type { ToolMeta } from '../types.js';
5
+ import { loadHooksConfig } from '../tool-registry.js';
5
6
  import {
6
7
  getCurrentBranch,
7
8
  executeHooksForEvent,
@@ -162,7 +163,10 @@ export function register(server: McpServer, context: ServerContext): void {
162
163
  branch,
163
164
  };
164
165
 
165
- const hookResults = await executeHooksForEvent('issue-started', payload);
166
+ const hooksConfig = loadHooksConfig();
167
+ const hookResults = await executeHooksForEvent('issue-started', payload, {
168
+ onFailure: hooksConfig.onFailure,
169
+ });
166
170
  const successCount = hookResults.filter(r => r.success).length;
167
171
  const failCount = hookResults.length - successCount;
168
172
 
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import * as z from 'zod';
3
- import { execSync } from 'child_process';
3
+ import { spawnSync } from 'child_process';
4
+ import { validateNumericInput } from '@bretwardjames/ghp-core';
4
5
  import type { ServerContext } from '../server.js';
5
6
  import type { ToolMeta } from '../types.js';
6
7
 
@@ -102,22 +103,32 @@ export function register(server: McpServer, context: ServerContext): void {
102
103
  }
103
104
 
104
105
  try {
105
- // Build the CLI command
106
- let cmd = `ghp start ${issue} --parallel -fd --force`;
106
+ // Validate issue number to prevent injection
107
+ const safeIssue = validateNumericInput(issue, 'issue');
108
+
109
+ // Build CLI args as array to prevent command injection
110
+ // Using spawnSync with array args avoids shell entirely
111
+ const args = ['start', String(safeIssue), '--parallel', '-fd', '--force'];
107
112
  if (worktreePath) {
108
- cmd += ` --worktree-path "${worktreePath}"`;
113
+ args.push('--worktree-path', worktreePath);
109
114
  }
110
115
  if (spawnSubagent) {
111
- cmd += ' --spawn-subagent';
116
+ args.push('--spawn-subagent');
112
117
  }
113
118
 
114
119
  // Execute the CLI command
115
- const output = execSync(cmd, {
120
+ const result = spawnSync('ghp', args, {
116
121
  encoding: 'utf-8',
117
122
  cwd: process.cwd(),
118
123
  env: process.env,
119
124
  });
120
125
 
126
+ if (result.status !== 0) {
127
+ throw new Error(result.stderr || `ghp start failed with exit code ${result.status}`);
128
+ }
129
+
130
+ const output = result.stdout;
131
+
121
132
  // Parse the spawn directive if present
122
133
  const directive = spawnSubagent ? parseSpawnDirective(output) : null;
123
134
 
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['src/**/*.test.ts'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ include: ['src/**/*.ts'],
11
+ exclude: ['src/**/*.test.ts', 'src/index.ts'],
12
+ },
13
+ },
14
+ });