@hanhnd/agent-kit 1.0.0

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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Integration Tools - Bitbucket, Jira
3
+ * Tools: kit_get_bitbucket_pr, kit_jira_get_ticket
4
+ */
5
+ import { z } from 'zod';
6
+ import { sanitize } from './security.js';
7
+ // Zod schema for Bitbucket PR REST API response
8
+ const BitbucketPrSchema = z.object({
9
+ id: z.number(),
10
+ title: z.string(),
11
+ description: z.string().nullable().optional(),
12
+ state: z.enum(['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED']),
13
+ author: z.object({ display_name: z.string(), nickname: z.string() }),
14
+ source: z.object({ branch: z.object({ name: z.string() }) }),
15
+ destination: z.object({ branch: z.object({ name: z.string() }) }),
16
+ });
17
+ // MEDIUM 2: Jira ticket schema for runtime validation
18
+ // ADF (Atlassian Document Format) can have many nested content types
19
+ // We use a more permissive schema that accepts any ADF structure
20
+ const AdfContentSchema = z
21
+ .object({
22
+ type: z.string().optional(),
23
+ content: z.array(z.unknown()).optional(),
24
+ text: z.string().optional(),
25
+ })
26
+ .passthrough();
27
+ const JiraFieldsSchema = z.object({
28
+ summary: z.string(),
29
+ status: z.object({ name: z.string() }).optional(),
30
+ priority: z.object({ name: z.string() }).optional(),
31
+ assignee: z.object({ displayName: z.string() }).nullable().optional(),
32
+ reporter: z.object({ displayName: z.string() }).nullable().optional(),
33
+ issuetype: z.object({ name: z.string() }).optional(),
34
+ // Handle both plain string and ADF (Atlassian Document Format) structures
35
+ description: z
36
+ .union([
37
+ z.string(),
38
+ z
39
+ .object({
40
+ type: z.string().optional(),
41
+ version: z.number().optional(),
42
+ content: z.array(AdfContentSchema).optional(),
43
+ })
44
+ .passthrough(), // Accept any additional ADF fields
45
+ ])
46
+ .nullable()
47
+ .optional(),
48
+ labels: z.array(z.string()).optional(),
49
+ });
50
+ const JiraTicketSchema = z.object({
51
+ errorMessages: z.array(z.string()).optional(),
52
+ fields: JiraFieldsSchema,
53
+ });
54
+ /**
55
+ * Extract plain text from ADF (Atlassian Document Format) content
56
+ * Recursively walks the content tree to find text nodes
57
+ */
58
+ function extractAdfText(adf) {
59
+ if (!adf || typeof adf !== 'object')
60
+ return 'No description';
61
+ const content = adf.content;
62
+ if (!content || !Array.isArray(content))
63
+ return 'No description';
64
+ const textParts = [];
65
+ function extractText(nodes) {
66
+ for (const node of nodes) {
67
+ if (typeof node.text === 'string') {
68
+ textParts.push(node.text);
69
+ }
70
+ if (Array.isArray(node.content)) {
71
+ extractText(node.content);
72
+ }
73
+ }
74
+ }
75
+ extractText(content);
76
+ return textParts.join(' ') || 'No description';
77
+ }
78
+ function buildJiraBasicAuth() {
79
+ const email = process.env.ATLASSIAN_USER_EMAIL;
80
+ const token = process.env.ATLASSIAN_API_TOKEN;
81
+ if (!email || !token) {
82
+ throw new Error('Missing ATLASSIAN_USER_EMAIL or ATLASSIAN_API_TOKEN');
83
+ }
84
+ return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
85
+ }
86
+ function buildBitbucketBasicAuth() {
87
+ const email = process.env.BITBUCKET_USER_EMAIL;
88
+ const token = process.env.BITBUCKET_API_TOKEN;
89
+ if (!email || !token) {
90
+ throw new Error('Missing BITBUCKET_USER_EMAIL or BITBUCKET_API_TOKEN');
91
+ }
92
+ return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
93
+ }
94
+ async function callAtlassianRestApi(url) {
95
+ const auth = buildJiraBasicAuth();
96
+ const resp = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' } });
97
+ if (resp.status === 401) {
98
+ throw new Error('❌ Auth failed: check ATLASSIAN_USER_EMAIL and ATLASSIAN_API_TOKEN');
99
+ }
100
+ if (resp.status === 404) {
101
+ throw new Error(`❌ Not found: ${url}`);
102
+ }
103
+ if (!resp.ok) {
104
+ throw new Error(`❌ API error ${resp.status}: ${await resp.text()}`);
105
+ }
106
+ return await resp.json();
107
+ }
108
+ async function callBitbucketRestApi(url) {
109
+ const auth = buildBitbucketBasicAuth();
110
+ const resp = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' } });
111
+ if (resp.status === 401) {
112
+ throw new Error('❌ Auth failed: check BITBUCKET_USER_EMAIL and BITBUCKET_API_TOKEN');
113
+ }
114
+ if (resp.status === 404) {
115
+ throw new Error(`❌ Not found: ${url}`);
116
+ }
117
+ if (!resp.ok) {
118
+ throw new Error(`❌ API error ${resp.status}: ${await resp.text()}`);
119
+ }
120
+ return await resp.json();
121
+ }
122
+ async function callBitbucketDiffApi(url) {
123
+ const auth = buildBitbucketBasicAuth();
124
+ const resp = await fetch(url, { headers: { Authorization: auth, Accept: 'text/plain' } });
125
+ if (resp.status === 401) {
126
+ throw new Error('❌ Auth failed: check BITBUCKET_USER_EMAIL and BITBUCKET_API_TOKEN');
127
+ }
128
+ if (resp.status === 404) {
129
+ throw new Error(`❌ Not found: ${url}`);
130
+ }
131
+ if (!resp.ok) {
132
+ throw new Error(`❌ API error ${resp.status}: ${await resp.text()}`);
133
+ }
134
+ return await resp.text();
135
+ }
136
+ export function registerIntegrationTools(server) {
137
+ // TOOL: GET BITBUCKET PR
138
+ server.tool('kit_get_bitbucket_pr', 'Get Bitbucket PR details and optionally the diff. Accepts a full PR URL or a numeric PR ID with workspace + repoSlug.', {
139
+ input: z.string().describe('Bitbucket PR URL or numeric PR ID'),
140
+ workspace: z
141
+ .string()
142
+ .optional()
143
+ .describe('Bitbucket workspace slug (required for numeric ID if BITBUCKET_DEFAULT_WORKSPACE not set)'),
144
+ repoSlug: z.string().optional().describe('Bitbucket repo slug (required for numeric ID)'),
145
+ includeDiff: z
146
+ .boolean()
147
+ .optional()
148
+ .default(true)
149
+ .describe('Include unified diff in response'),
150
+ }, async ({ input, workspace, repoSlug, includeDiff }) => {
151
+ try {
152
+ const bbEmail = process.env.BITBUCKET_USER_EMAIL;
153
+ const bbToken = process.env.BITBUCKET_API_TOKEN;
154
+ if (!bbEmail || !bbToken) {
155
+ return {
156
+ content: [
157
+ {
158
+ type: 'text',
159
+ text: `❌ Missing BITBUCKET_USER_EMAIL or BITBUCKET_API_TOKEN. Create an API token at id.atlassian.com/manage-profile/security/api-tokens.`,
160
+ },
161
+ ],
162
+ };
163
+ }
164
+ let ws;
165
+ let repo;
166
+ let prId;
167
+ // Try URL parse
168
+ const urlMatch = input.match(/bitbucket\.org\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)/);
169
+ if (urlMatch) {
170
+ ws = urlMatch[1];
171
+ repo = urlMatch[2];
172
+ prId = parseInt(urlMatch[3], 10);
173
+ }
174
+ else if (input.match(/^\d+$/)) {
175
+ prId = parseInt(input, 10);
176
+ ws = workspace || process.env.BITBUCKET_DEFAULT_WORKSPACE;
177
+ repo = repoSlug;
178
+ }
179
+ if (!ws) {
180
+ return {
181
+ content: [
182
+ {
183
+ type: 'text',
184
+ text: `❌ workspace is required. Pass it as a parameter or set BITBUCKET_DEFAULT_WORKSPACE in your MCP env config.`,
185
+ },
186
+ ],
187
+ };
188
+ }
189
+ if (!repo || !prId) {
190
+ return {
191
+ content: [
192
+ {
193
+ type: 'text',
194
+ text: `❌ Could not parse PR URL. Expected: bitbucket.org/{ws}/{repo}/pull-requests/{id}`,
195
+ },
196
+ ],
197
+ };
198
+ }
199
+ const safeWs = sanitize(ws);
200
+ const safeRepo = sanitize(repo);
201
+ const prUrl = `https://api.bitbucket.org/2.0/repositories/${safeWs}/${safeRepo}/pullrequests/${prId}`;
202
+ const jsonData = await callBitbucketRestApi(prUrl);
203
+ const parseResult = BitbucketPrSchema.safeParse(jsonData);
204
+ if (!parseResult.success) {
205
+ throw new Error(`Failed to parse PR response: ${parseResult.error.message}`);
206
+ }
207
+ const pr = parseResult.data;
208
+ let output = `## PR #${pr.id}: ${pr.title}
209
+ **State:** ${pr.state} **Author:** ${pr.author.display_name}
210
+ **Branch:** ${pr.source.branch.name} → ${pr.destination.branch.name}
211
+
212
+ ### Description
213
+ ${pr.description || 'No description'}`;
214
+ if (includeDiff) {
215
+ const diffUrl = `https://api.bitbucket.org/2.0/repositories/${safeWs}/${safeRepo}/pullrequests/${prId}/diff`;
216
+ const diff = await callBitbucketDiffApi(diffUrl);
217
+ const truncated = diff.slice(0, 8000) +
218
+ (diff.length > 8000 ? `\n... (truncated — ${diff.length - 8000} chars omitted)` : '');
219
+ output += `\n\n### Diff\n\`\`\`diff\n${truncated}\n\`\`\``;
220
+ }
221
+ return { content: [{ type: 'text', text: output }] };
222
+ }
223
+ catch (error) {
224
+ const errorMsg = error instanceof Error ? error.message : String(error);
225
+ return { content: [{ type: 'text', text: errorMsg }] };
226
+ }
227
+ });
228
+ // TOOL: JIRA GET TICKET
229
+ server.tool('kit_jira_get_ticket', 'Get ticket details from Jira using the Atlassian REST API', {
230
+ ticketId: z.string().describe('Jira ticket ID (e.g., PROJ-123)'),
231
+ }, async ({ ticketId }) => {
232
+ try {
233
+ const cloudId = process.env.ATLASSIAN_CLOUD_ID;
234
+ const userEmail = process.env.ATLASSIAN_USER_EMAIL;
235
+ const apiToken = process.env.ATLASSIAN_API_TOKEN;
236
+ if (!cloudId || !userEmail || !apiToken) {
237
+ const missing = [
238
+ !cloudId && 'ATLASSIAN_CLOUD_ID',
239
+ !userEmail && 'ATLASSIAN_USER_EMAIL',
240
+ !apiToken && 'ATLASSIAN_API_TOKEN',
241
+ ]
242
+ .filter(Boolean)
243
+ .join(', ');
244
+ return {
245
+ content: [
246
+ {
247
+ type: 'text',
248
+ text: `❌ Missing ${missing}. Create an API Token at id.atlassian.com/manage-profile/security/api-tokens.`,
249
+ },
250
+ ],
251
+ };
252
+ }
253
+ const safeTicketId = ticketId.match(/^[A-Z]+-\d+$/)?.[0];
254
+ if (!safeTicketId) {
255
+ return {
256
+ content: [
257
+ {
258
+ type: 'text',
259
+ text: `❌ Invalid ticket ID format: ${ticketId}\n\nExpected format: PROJ-123`,
260
+ },
261
+ ],
262
+ };
263
+ }
264
+ const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeTicketId}`;
265
+ const jsonData = await callAtlassianRestApi(url);
266
+ const parseResult = JiraTicketSchema.safeParse(jsonData);
267
+ if (!parseResult.success) {
268
+ return {
269
+ content: [
270
+ {
271
+ type: 'text',
272
+ text: `❌ Invalid Jira response format: ${parseResult.error.message}`,
273
+ },
274
+ ],
275
+ };
276
+ }
277
+ const ticket = parseResult.data;
278
+ if (ticket.errorMessages && ticket.errorMessages.length > 0) {
279
+ return {
280
+ content: [
281
+ {
282
+ type: 'text',
283
+ text: `❌ Ticket not found: ${ticketId}\n\n${ticket.errorMessages.join('\n')}`,
284
+ },
285
+ ],
286
+ };
287
+ }
288
+ const output = `## 🎫 ${ticketId}: ${ticket.fields.summary}
289
+
290
+ **Status:** ${ticket.fields.status?.name || 'Unknown'}
291
+ **Priority:** ${ticket.fields.priority?.name || 'None'}
292
+ **Assignee:** ${ticket.fields.assignee?.displayName || 'Unassigned'}
293
+ **Reporter:** ${ticket.fields.reporter?.displayName || 'Unknown'}
294
+ **Type:** ${ticket.fields.issuetype?.name || 'Unknown'}
295
+
296
+ ### Description
297
+ ${typeof ticket.fields.description === 'string'
298
+ ? ticket.fields.description
299
+ : extractAdfText(ticket.fields.description)}
300
+
301
+ ### Labels
302
+ ${ticket.fields.labels?.join(', ') || 'None'}`;
303
+ return { content: [{ type: 'text', text: output }] };
304
+ }
305
+ catch (error) {
306
+ const errorMsg = error instanceof Error ? error.message : String(error);
307
+ return { content: [{ type: 'text', text: `Error: ${errorMsg}` }] };
308
+ }
309
+ });
310
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Core Tools - Project context, handoff, and artifact management
3
+ * Extracted from kit-server.ts for better modularity
4
+ */
5
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ /**
7
+ * Register core tools with MCP server
8
+ */
9
+ export declare function registrerOrchestrationTools(server: McpServer): void;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Core Tools - Project context, handoff, and artifact management
3
+ * Extracted from kit-server.ts for better modularity
4
+ */
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { z } from 'zod';
8
+ import { getExtensionRoot, getWorkspaceRoot } from '../utils.js';
9
+ import { WORKFLOWS } from '../workflows.js';
10
+ /**
11
+ * Resolve lifecycle configuration (Project > Extension)
12
+ */
13
+ function getWorkflows() {
14
+ return WORKFLOWS;
15
+ }
16
+ /**
17
+ * Register core tools with MCP server
18
+ */
19
+ export function registrerOrchestrationTools(server) {
20
+ // ═══════════════════════════════════════════════════════════════
21
+ // TOOL: GET NEXT PHASE
22
+ // Persists the rolling summary and returns transition instructions
23
+ // ═══════════════════════════════════════════════════════════════
24
+ server.tool('kit_get_next_phase', 'Persists the rolling summary and returns transition instructions. currentPhase format: "workflow:phase" (e.g., "code:ANALYSIS")', {
25
+ summary: z.object({
26
+ original_goal: z.string(),
27
+ completed_tasks: z.array(z.string()),
28
+ pending_tasks: z.array(z.string()),
29
+ current_context_brief: z.string(),
30
+ }),
31
+ transitionTo: z
32
+ .string()
33
+ .optional()
34
+ .describe('Force transition to a specific phase (e.g., "INGESTION")'),
35
+ currentPhase: z.string().describe('The current workflow and phase (e.g., "code:ANALYSIS")'),
36
+ }, async ({ summary, transitionTo, currentPhase }) => {
37
+ try {
38
+ const [workflowId, phaseId] = currentPhase.split(':');
39
+ if (!workflowId) {
40
+ return {
41
+ content: [
42
+ {
43
+ type: 'text',
44
+ text: '❌ Invalid currentPhase format. Use "workflowId" or "workflow:phase".',
45
+ },
46
+ ],
47
+ };
48
+ }
49
+ const handoffDir = path.join(getWorkspaceRoot(), '.claude-kit', 'handoffs', 'active');
50
+ const summaryPath = path.join(handoffDir, `${workflowId}-summary.md`);
51
+ // 1. Ensure directory exists
52
+ if (!fs.existsSync(handoffDir)) {
53
+ fs.mkdirSync(handoffDir, { recursive: true });
54
+ }
55
+ // 2. Format Markdown
56
+ const markdown = `
57
+ # 📝 Rolling Summary: ${workflowId.toUpperCase()} Workflow
58
+
59
+ ## 🎯 Original Goal
60
+ ${summary.original_goal}
61
+
62
+ ## ✅ Completed Tasks
63
+ ${summary.completed_tasks.map((t) => `- ${t}`).join('\n')}
64
+
65
+ ## ⏳ Pending Tasks
66
+ ${summary.pending_tasks.map((t) => `- ${t}`).join('\n')}
67
+
68
+ ## 🧠 Current Context Brief
69
+ ${summary.current_context_brief}
70
+
71
+ ---
72
+ *Updated: ${new Date().toLocaleString()}*
73
+ `;
74
+ // 3. Persist summary
75
+ fs.writeFileSync(summaryPath, markdown, 'utf8');
76
+ // 4. Resolve next state
77
+ const workflows = getWorkflows();
78
+ let nextPhaseId = null;
79
+ if (workflows[workflowId]) {
80
+ const workflow = workflows[workflowId];
81
+ const currentState = phaseId ? workflow.states[phaseId] : null;
82
+ // Handle bootstrap case: if phaseId is missing, fallback to initial
83
+ if (!currentState) {
84
+ nextPhaseId = transitionTo || workflow.initial;
85
+ }
86
+ else {
87
+ nextPhaseId = transitionTo || currentState?.next || null;
88
+ }
89
+ if (nextPhaseId && !workflow.states[nextPhaseId]) {
90
+ nextPhaseId = null;
91
+ }
92
+ }
93
+ // 5. Persist machine-readable state
94
+ const statePath = path.join(handoffDir, `${workflowId}-state.json`);
95
+ const stateData = {
96
+ workflowId,
97
+ currentPhase: phaseId || 'START',
98
+ nextPhase: nextPhaseId,
99
+ summary,
100
+ updatedAt: new Date().toISOString(),
101
+ };
102
+ fs.writeFileSync(statePath, JSON.stringify(stateData, null, 2), 'utf8');
103
+ // 6. Return transition instructions
104
+ if (!workflows[workflowId]) {
105
+ return {
106
+ content: [
107
+ {
108
+ type: 'text',
109
+ text: `✅ Summary persisted to ${summaryPath}.\n⚠️ Warning: Workflow "${workflowId}" not found in lifecycle.json.`,
110
+ },
111
+ ],
112
+ };
113
+ }
114
+ const workflow = workflows[workflowId];
115
+ if (!nextPhaseId || !workflow.states[nextPhaseId]) {
116
+ return {
117
+ content: [
118
+ {
119
+ type: 'text',
120
+ text: `✅ Summary persisted. Workflow: ${workflowId}, Phase: ${phaseId || 'START'}. No next phase defined.`,
121
+ },
122
+ ],
123
+ };
124
+ }
125
+ const currentState = phaseId ? workflow.states[phaseId] : null;
126
+ const nextState = workflow.states[nextPhaseId];
127
+ const extensionRoot = getExtensionRoot();
128
+ // Resolve agent content only if it changes
129
+ const currentAgent = currentState?.agent || workflow.agent;
130
+ const nextAgent = nextState.agent || workflow.agent;
131
+ let agentContent = '[UNCHANGED]';
132
+ if (nextAgent && nextAgent !== currentAgent) {
133
+ const agentPath = path.join(extensionRoot, 'agents', nextAgent + '.md');
134
+ if (fs.existsSync(agentPath)) {
135
+ agentContent = fs.readFileSync(agentPath, 'utf8');
136
+ }
137
+ else {
138
+ agentContent = `⚠️ Warning: Agent persona file not found at ${agentPath}`;
139
+ }
140
+ }
141
+ else if (nextAgent && !currentState) {
142
+ // First phase transition (bootstrap)
143
+ const agentPath = path.join(extensionRoot, 'agents', nextAgent + '.md');
144
+ if (fs.existsSync(agentPath)) {
145
+ agentContent = fs.readFileSync(agentPath, 'utf8');
146
+ }
147
+ }
148
+ const nextInstructions = `${nextState.instructions}\n\n**PHASE_STOP:** You MUST STOP immediately after completing the instructions above. Do not speculate, do not perform any "extra" work, and do not initiate the next phase. Summarize your progress and call 'kit_get_next_phase' to transition, or WAIT for user feedback if you need to ask_user or a gate is present.`;
149
+ return {
150
+ content: [
151
+ {
152
+ type: 'text',
153
+ text: `PHASE_TRANSITION:
154
+ Next Phase: ${workflowId}:${nextPhaseId}
155
+ Agent Content: ${agentContent}
156
+ Skills: ${JSON.stringify(nextState.skills || [])}
157
+ Instructions: ${nextInstructions}`,
158
+ },
159
+ ],
160
+ };
161
+ }
162
+ catch (error) {
163
+ return {
164
+ content: [{ type: 'text', text: `Error updating summary/transition: ${error}` }],
165
+ };
166
+ }
167
+ });
168
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Security Helpers - Prevent Command Injection
3
+ * Exported utilities for safe command execution
4
+ */
5
+ /**
6
+ * Sanitize string for safe use with execFileSync
7
+ * Only removes dangerous shell operators - safe chars like !?#* are allowed
8
+ * since execFileSync doesn't invoke a shell and handles args safely
9
+ *
10
+ * NOTE: Flag injection is NOT handled here because:
11
+ * 1. execFileSync uses arg arrays, not shell parsing
12
+ * 2. Adding -- prefix would corrupt content (e.g., commit messages)
13
+ * 3. Callers should use '--' separator when needed for specific commands
14
+ */
15
+ export declare function sanitize(input: string): string;
16
+ /**
17
+ * Validate file path to prevent path traversal attacks
18
+ * Uses stricter path.sep check to prevent prefix matching flaws
19
+ * (e.g., /.geminit-kit/handoffs/app should not match /.geminit-kit/handoffs/app-secret)
20
+ */
21
+ export declare function validatePath(filePath: string, baseDir?: string): string;
22
+ /**
23
+ * Safe git command execution using execFileSync
24
+ * Includes stderr in error message for better debugging
25
+ *
26
+ * @param timeout Default from GEMINI_KIT_GIT_TIMEOUT env var or 30s
27
+ */
28
+ export declare function safeGit(args: string[], options?: {
29
+ timeout?: number;
30
+ cwd?: string;
31
+ }): string;
32
+ /**
33
+ * Check if a command exists (cross-platform)
34
+ * Uses 'where' on Windows, 'which' on macOS/Linux
35
+ */
36
+ export declare function commandExists(cmd: string): boolean;
37
+ /**
38
+ * Async file finder - non-blocking for large repos
39
+ * Uses queue-based approach to prevent stack overflow on deep directories
40
+ */
41
+ export declare function findFilesAsync(dir: string, extensions: string[], maxFiles: number, excludeDirs?: string[]): Promise<string[]>;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Security Helpers - Prevent Command Injection
3
+ * Exported utilities for safe command execution
4
+ */
5
+ import { execFileSync } from 'child_process';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { getWorkspaceRoot } from '../utils.js';
9
+ /**
10
+ * Sanitize string for safe use with execFileSync
11
+ * Only removes dangerous shell operators - safe chars like !?#* are allowed
12
+ * since execFileSync doesn't invoke a shell and handles args safely
13
+ *
14
+ * NOTE: Flag injection is NOT handled here because:
15
+ * 1. execFileSync uses arg arrays, not shell parsing
16
+ * 2. Adding -- prefix would corrupt content (e.g., commit messages)
17
+ * 3. Callers should use '--' separator when needed for specific commands
18
+ */
19
+ export function sanitize(input) {
20
+ // Only remove truly dangerous shell operators
21
+ // Keep: ! ? # * ( ) [ ] { } - for valid content like "Fix bug!" or "- TODO item"
22
+ return String(input)
23
+ .replace(/[;&|`$<>\\]/g, '')
24
+ .trim()
25
+ .slice(0, 500); // Limit length
26
+ }
27
+ /**
28
+ * Validate file path to prevent path traversal attacks
29
+ * Uses stricter path.sep check to prevent prefix matching flaws
30
+ * (e.g., /.geminit-kit/handoffs/app should not match /.geminit-kit/handoffs/app-secret)
31
+ */
32
+ export function validatePath(filePath, baseDir = process.cwd()) {
33
+ const resolved = path.resolve(baseDir, filePath);
34
+ const root = path.resolve(baseDir);
35
+ // Stricter: exact match OR starts with root + separator
36
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
37
+ throw new Error(`Path traversal detected: ${filePath}`);
38
+ }
39
+ return resolved;
40
+ }
41
+ /**
42
+ * Extract stderr from error for better logging
43
+ */
44
+ function extractStderr(error) {
45
+ if (error && typeof error === 'object' && 'stderr' in error) {
46
+ return String(error.stderr);
47
+ }
48
+ return '';
49
+ }
50
+ // Configurable timeouts via environment variables
51
+ const GIT_TIMEOUT = parseInt(process.env.GEMINI_KIT_GIT_TIMEOUT || '30000', 10);
52
+ /**
53
+ * Safe git command execution using execFileSync
54
+ * Includes stderr in error message for better debugging
55
+ *
56
+ * @param timeout Default from GEMINI_KIT_GIT_TIMEOUT env var or 30s
57
+ */
58
+ export function safeGit(args, options) {
59
+ try {
60
+ return execFileSync('git', args, {
61
+ encoding: 'utf8',
62
+ timeout: options?.timeout || GIT_TIMEOUT,
63
+ cwd: options?.cwd || getWorkspaceRoot(),
64
+ maxBuffer: 10 * 1024 * 1024, // 10MB
65
+ });
66
+ }
67
+ catch (error) {
68
+ const stderr = extractStderr(error);
69
+ const baseMsg = error instanceof Error ? error.message : String(error);
70
+ throw new Error(`Git command failed: ${baseMsg}${stderr ? `\nDetails: ${stderr}` : ''}`);
71
+ }
72
+ }
73
+ /**
74
+ * Check if a command exists (cross-platform)
75
+ * Uses 'where' on Windows, 'which' on macOS/Linux
76
+ */
77
+ export function commandExists(cmd) {
78
+ try {
79
+ const checkCmd = process.platform === 'win32' ? 'where' : 'which';
80
+ execFileSync(checkCmd, [cmd], {
81
+ encoding: 'utf8',
82
+ cwd: getWorkspaceRoot(),
83
+ timeout: 5000,
84
+ stdio: 'ignore',
85
+ });
86
+ return true;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * Async file finder - non-blocking for large repos
94
+ * Uses queue-based approach to prevent stack overflow on deep directories
95
+ */
96
+ export async function findFilesAsync(dir, extensions, maxFiles, excludeDirs = ['node_modules', '.git', 'dist', 'build', 'coverage']) {
97
+ const results = [];
98
+ // Use queue instead of recursion to prevent stack overflow
99
+ const queue = [
100
+ { fullPath: dir, relativePath: '' },
101
+ ];
102
+ while (queue.length > 0 && results.length < maxFiles) {
103
+ const current = queue.shift();
104
+ let entries;
105
+ try {
106
+ entries = await fs.promises.readdir(current.fullPath, { withFileTypes: true });
107
+ }
108
+ catch {
109
+ continue; // Skip directories we can't read
110
+ }
111
+ for (const entry of entries) {
112
+ if (results.length >= maxFiles)
113
+ break;
114
+ const entryFullPath = path.join(current.fullPath, entry.name);
115
+ const entryRelPath = current.relativePath
116
+ ? path.join(current.relativePath, entry.name)
117
+ : entry.name;
118
+ if (entry.isDirectory()) {
119
+ if (!excludeDirs.includes(entry.name)) {
120
+ queue.push({ fullPath: entryFullPath, relativePath: entryRelPath });
121
+ }
122
+ }
123
+ else if (entry.isFile()) {
124
+ if (extensions.some((ext) => entry.name.endsWith(ext))) {
125
+ results.push(entryRelPath);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return results;
131
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Utility functions for agent-kit
3
+ */
4
+ export declare function getWorkspaceRoot(): string;
5
+ /**
6
+ * Resolve path to plugin root.
7
+ * When installed as a Claude Code plugin, CLAUDE_PLUGIN_ROOT is set automatically.
8
+ * Falls back to resolving from the bundled file location for local development.
9
+ */
10
+ export declare function getExtensionRoot(): string;