@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.
- package/README.md +133 -0
- package/dist/kit-server.d.ts +8 -0
- package/dist/kit-server.js +21573 -0
- package/dist/tools/agent.d.ts +9 -0
- package/dist/tools/agent.js +188 -0
- package/dist/tools/config.d.ts +25 -0
- package/dist/tools/config.js +60 -0
- package/dist/tools/core.d.ts +10 -0
- package/dist/tools/core.js +76 -0
- package/dist/tools/gemini.d.ts +9 -0
- package/dist/tools/gemini.js +111 -0
- package/dist/tools/integration.d.ts +6 -0
- package/dist/tools/integration.js +310 -0
- package/dist/tools/orchestration.d.ts +9 -0
- package/dist/tools/orchestration.js +168 -0
- package/dist/tools/security.d.ts +41 -0
- package/dist/tools/security.js +131 -0
- package/dist/utils.d.ts +10 -0
- package/dist/utils.js +26 -0
- package/dist/workflows.d.ts +2 -0
- package/dist/workflows.js +268 -0
- package/package.json +32 -0
|
@@ -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
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -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;
|