@hanhnd/agent-kit 1.0.17 → 1.0.18

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 CHANGED
@@ -11,7 +11,8 @@
11
11
  | `/ak:brainstorm [idea]` | Strategic architectural analysis |
12
12
  | `/ak:plan [file or idea]` | Create an implementation blueprint |
13
13
  | `/ak:code [file or task]` | Implement from a plan |
14
- | `/ak:code-simplify` | Refactor modified code for readability |
14
+ | `/ak:code-simplify` | Simplify modified code for readability |
15
+ | `/ak:code-refactor` | Refactor modified code for readability |
15
16
  | `/ak:research [topic]` | Research a topic |
16
17
  | `/ak:review-pr [PR URL]` | Review a pull request |
17
18
  | `/ak:review` | Review uncommitted local changes |
@@ -68,7 +69,7 @@ You don't need to run the wiki manually. Five hooks keep it fed automatically:
68
69
 
69
70
  ```bash
70
71
  claude plugin marketplace add https://github.com/hanh-nd/agent-kit
71
- claude plugin install ak
72
+ claude plugin install agent-kit
72
73
  ```
73
74
 
74
75
  The plugin fetches from GitHub, registers the MCP server automatically, and makes all commands available immediately.
@@ -91,8 +92,8 @@ BITBUCKET_DEFAULT_WORKSPACE = your-default-workspace-slug
91
92
 
92
93
  To use multiple profiles, add `[work]`, `[personal]`, etc. sections and set `KIT_PROFILE=work` in your environment.
93
94
 
94
- To update: `claude plugin update ak`
95
- To uninstall: `claude plugin uninstall ak`
95
+ To update: `claude plugin update agent-kit`
96
+ To uninstall: `claude plugin uninstall agent-kit`
96
97
 
97
98
  ---
98
99
 
@@ -113,7 +114,7 @@ npm run build
113
114
 
114
115
  ```bash
115
116
  claude plugin marketplace add /absolute/path/to/agent-kit
116
- claude plugin install ak
117
+ claude plugin install agent-kit
117
118
  ```
118
119
 
119
120
  This registers the MCP server automatically (pointing to your local build). Do **not** manually add a `kit-agents` entry to `settings.json` — the plugin handles that.
@@ -147,7 +148,9 @@ BITBUCKET_DEFAULT_WORKSPACE = your-default-workspace-slug
147
148
  If you want to reuse the commands with [Gemini CLI](https://geminicli.com) (for `/ak:delegate` to Gemini), install it using:
148
149
 
149
150
  ```bash
150
- gemini extension install https://github.com/hanh-nd/agent-kit
151
+ git clone https://github.com/hanh-nd/agent-kit.git
152
+ cd agent-kit/plugins/agent-kit
153
+ gemini extension link .gemini
151
154
  ```
152
155
 
153
156
  ---
@@ -20997,6 +20997,12 @@ import * as path from "path";
20997
20997
  function getWorkspaceRoot() {
20998
20998
  return process.env.WORKSPACE_DIR || process.env.CLAUDE_WORKSPACE || process.env.GEMINI_WORKSPACE || process.env.INIT_CWD || process.env.PWD || process.cwd();
20999
20999
  }
21000
+ function mcpText(text) {
21001
+ return { content: [{ type: "text", text }] };
21002
+ }
21003
+ function mcpJson(obj) {
21004
+ return mcpText(JSON.stringify(obj, null, 2));
21005
+ }
21000
21006
 
21001
21007
  // src/tools/config.ts
21002
21008
  var FORBIDDEN_FILES = [
@@ -21060,13 +21066,9 @@ function registerCoreTools(server2) {
21060
21066
  }
21061
21067
  const filePath = path.join(handoffDir, filename);
21062
21068
  fs.writeFileSync(filePath, content, "utf8");
21063
- return {
21064
- content: [{ type: "text", text: `\u2705 Saved to: ${filePath}` }]
21065
- };
21069
+ return mcpText(`\u2705 Saved to: ${filePath}`);
21066
21070
  } catch (error2) {
21067
- return {
21068
- content: [{ type: "text", text: `Error saving handoff: ${error2}` }]
21069
- };
21071
+ return mcpText(`Error saving handoff: ${error2}`);
21070
21072
  }
21071
21073
  }
21072
21074
  );
@@ -21171,9 +21173,12 @@ function registerAgentTools(server2) {
21171
21173
  'Task message or path to a handoff file (e.g., ".agent-kit/handoffs/plans/plan-xyz.md")'
21172
21174
  )
21173
21175
  },
21174
- async ({ agent, task }) => {
21176
+ async ({ agent, task }, extra) => {
21177
+ const workspaceRoot = getWorkspaceRoot();
21178
+ let job;
21179
+ let logPath;
21180
+ let killChild;
21175
21181
  try {
21176
- const workspaceRoot = getWorkspaceRoot();
21177
21182
  let prompt = task;
21178
21183
  const resolvedTask = path3.resolve(workspaceRoot, task);
21179
21184
  if (fs2.existsSync(resolvedTask) && fs2.statSync(resolvedTask).isFile()) {
@@ -21181,9 +21186,7 @@ function registerAgentTools(server2) {
21181
21186
  validatePath(task, workspaceRoot);
21182
21187
  prompt = fs2.readFileSync(resolvedTask, "utf-8");
21183
21188
  } catch (err) {
21184
- return {
21185
- content: [{ type: "text", text: `Error reading task file: ${err}` }]
21186
- };
21189
+ return mcpText(`Error reading task file: ${err}`);
21187
21190
  }
21188
21191
  }
21189
21192
  let usedAgent = agent;
@@ -21195,107 +21198,77 @@ function registerAgentTools(server2) {
21195
21198
  fallbackReason = `${agent} CLI not installed, fell back to ${fallback}`;
21196
21199
  } else {
21197
21200
  const installHint = agent === "gemini" ? "Install Gemini CLI: https://github.com/google-gemini/gemini-cli" : "Install Claude CLI: npm install -g @anthropic-ai/claude-code";
21198
- return {
21199
- content: [
21200
- {
21201
- type: "text",
21202
- text: JSON.stringify(
21203
- {
21204
- agent,
21205
- status: "error",
21206
- output: "",
21207
- error: `Neither ${agent} nor the fallback agent CLI is installed. ${installHint}`
21208
- },
21209
- null,
21210
- 2
21211
- )
21212
- }
21213
- ]
21214
- };
21201
+ return mcpJson({
21202
+ agent,
21203
+ status: "error",
21204
+ output: "",
21205
+ error: `Neither ${agent} nor the fallback agent CLI is installed. ${installHint}`
21206
+ });
21215
21207
  }
21216
21208
  }
21217
21209
  const agentArgs = usedAgent === "gemini" ? ["-y", "-p", prompt] : ["--dangerously-skip-permissions", "-p", prompt];
21218
- const slug = taskSlug(task);
21219
- const { logPath, logStream } = initAgentLog(workspaceRoot, slug, usedAgent);
21210
+ const { logPath: lp, logStream } = initAgentLog(workspaceRoot, taskSlug(task), usedAgent);
21211
+ logPath = lp;
21220
21212
  const jobId = randomUUID();
21221
21213
  const child = spawn(usedAgent, agentArgs, {
21222
21214
  cwd: workspaceRoot,
21223
21215
  env: { ...process.env, GEMINI_WORKSPACE: workspaceRoot }
21224
21216
  });
21225
- const job = {
21226
- id: jobId,
21217
+ job = {
21227
21218
  agent: usedAgent,
21228
21219
  process: child,
21229
21220
  startedAt: /* @__PURE__ */ new Date(),
21230
21221
  chunks: [],
21231
21222
  logStream,
21232
- timeoutHandle: setTimeout(() => {
21233
- child.kill("SIGTERM");
21234
- }, AGENT_TIMEOUT)
21223
+ timeoutHandle: setTimeout(() => child.kill("SIGTERM"), AGENT_TIMEOUT)
21235
21224
  };
21236
21225
  jobRegistry.set(jobId, job);
21237
- try {
21238
- await new Promise((resolve3, reject) => {
21239
- child.stdout?.on("data", (data) => {
21240
- job.chunks.push(data.toString());
21241
- job.logStream.write(data);
21242
- });
21243
- child.stderr?.on("data", (data) => {
21244
- job.chunks.push(data.toString());
21245
- job.logStream.write(data);
21246
- });
21247
- child.on("close", (code) => {
21248
- if (code === 0) resolve3();
21249
- else reject(new Error(`Process exited with code ${code}`));
21250
- });
21251
- child.on("error", reject);
21226
+ killChild = () => {
21227
+ child.kill("SIGTERM");
21228
+ setTimeout(() => child.kill("SIGKILL"), 5e3).unref();
21229
+ };
21230
+ if (extra.signal.aborted) killChild();
21231
+ else extra.signal.addEventListener("abort", killChild);
21232
+ await new Promise((resolve3, reject) => {
21233
+ child.stdout?.on("data", (data) => {
21234
+ job.chunks.push(data.toString());
21235
+ job.logStream.write(data);
21252
21236
  });
21253
- const output = job.chunks.join("");
21254
- const safeOutput = sanitizeOutput(output);
21255
- return {
21256
- content: [
21257
- {
21258
- type: "text",
21259
- text: JSON.stringify(
21260
- {
21261
- agent: usedAgent,
21262
- status: fallbackReason ? "fallback" : "success",
21263
- log: logPath,
21264
- output: safeOutput,
21265
- ...fallbackReason && { fallback_reason: fallbackReason }
21266
- },
21267
- null,
21268
- 2
21269
- )
21270
- }
21271
- ]
21272
- };
21273
- } catch (error2) {
21274
- throw error2;
21275
- } finally {
21276
- job.logStream.end();
21277
- clearTimeout(job.timeoutHandle);
21278
- jobRegistry.delete(jobId);
21279
- }
21237
+ child.stderr?.on("data", (data) => {
21238
+ job.chunks.push(data.toString());
21239
+ job.logStream.write(data);
21240
+ });
21241
+ child.on("close", (code, signal) => {
21242
+ if (code === 0) resolve3();
21243
+ else
21244
+ reject(
21245
+ new Error(`Process exited with code ${code}${signal ? ` (signal: ${signal})` : ""}`)
21246
+ );
21247
+ });
21248
+ child.on("error", reject);
21249
+ });
21250
+ return mcpJson({
21251
+ agent: usedAgent,
21252
+ status: fallbackReason ? "fallback" : "success",
21253
+ log: logPath,
21254
+ output: sanitizeOutput(job.chunks.join("")),
21255
+ ...fallbackReason && { fallback_reason: fallbackReason }
21256
+ });
21280
21257
  } catch (error2) {
21258
+ if (extra.signal.aborted) {
21259
+ return mcpJson({
21260
+ agent,
21261
+ status: "cancelled",
21262
+ log: logPath,
21263
+ output: sanitizeOutput(job?.chunks.join("") ?? "")
21264
+ });
21265
+ }
21281
21266
  const msg = error2 instanceof Error ? error2.message : String(error2);
21282
- return {
21283
- content: [
21284
- {
21285
- type: "text",
21286
- text: JSON.stringify(
21287
- {
21288
- agent,
21289
- status: "error",
21290
- output: "",
21291
- error: msg
21292
- },
21293
- null,
21294
- 2
21295
- )
21296
- }
21297
- ]
21298
- };
21267
+ return mcpJson({ agent, status: "error", output: "", error: msg });
21268
+ } finally {
21269
+ if (killChild) extra.signal.removeEventListener("abort", killChild);
21270
+ job?.logStream.end();
21271
+ if (job) clearTimeout(job.timeoutHandle);
21299
21272
  }
21300
21273
  }
21301
21274
  );
@@ -21529,63 +21502,18 @@ var JiraTicketSchema = external_exports.object({
21529
21502
  errorMessages: external_exports.array(external_exports.string()).optional(),
21530
21503
  fields: JiraFieldsSchema
21531
21504
  });
21532
- function buildJiraBasicAuth() {
21533
- const email2 = getCredential("ATLASSIAN_USER_EMAIL");
21534
- const token = getCredential("ATLASSIAN_API_TOKEN");
21535
- if (!email2 || !token) {
21536
- throw new Error("Missing ATLASSIAN_USER_EMAIL or ATLASSIAN_API_TOKEN");
21537
- }
21505
+ function buildBasicAuth(emailVar, tokenVar) {
21506
+ const email2 = getCredential(emailVar);
21507
+ const token = getCredential(tokenVar);
21508
+ if (!email2 || !token) throw new Error(`Missing ${emailVar} or ${tokenVar}`);
21538
21509
  return "Basic " + Buffer.from(`${email2}:${token}`).toString("base64");
21539
21510
  }
21540
- function buildBitbucketBasicAuth() {
21541
- const email2 = getCredential("BITBUCKET_USER_EMAIL");
21542
- const token = getCredential("BITBUCKET_API_TOKEN");
21543
- if (!email2 || !token) {
21544
- throw new Error("Missing BITBUCKET_USER_EMAIL or BITBUCKET_API_TOKEN");
21545
- }
21546
- return "Basic " + Buffer.from(`${email2}:${token}`).toString("base64");
21547
- }
21548
- async function callAtlassianRestApi(url) {
21549
- const auth = buildJiraBasicAuth();
21550
- const resp = await fetch(url, { headers: { Authorization: auth, Accept: "application/json" } });
21551
- if (resp.status === 401) {
21552
- throw new Error("\u274C Auth failed: check ATLASSIAN_USER_EMAIL and ATLASSIAN_API_TOKEN");
21553
- }
21554
- if (resp.status === 404) {
21555
- throw new Error(`\u274C Not found: ${url}`);
21556
- }
21557
- if (!resp.ok) {
21558
- throw new Error(`\u274C API error ${resp.status}: ${await resp.text()}`);
21559
- }
21560
- return await resp.json();
21561
- }
21562
- async function callBitbucketRestApi(url) {
21563
- const auth = buildBitbucketBasicAuth();
21564
- const resp = await fetch(url, { headers: { Authorization: auth, Accept: "application/json" } });
21565
- if (resp.status === 401) {
21566
- throw new Error("\u274C Auth failed: check BITBUCKET_USER_EMAIL and BITBUCKET_API_TOKEN");
21567
- }
21568
- if (resp.status === 404) {
21569
- throw new Error(`\u274C Not found: ${url}`);
21570
- }
21571
- if (!resp.ok) {
21572
- throw new Error(`\u274C API error ${resp.status}: ${await resp.text()}`);
21573
- }
21574
- return await resp.json();
21575
- }
21576
- async function callBitbucketDiffApi(url) {
21577
- const auth = buildBitbucketBasicAuth();
21578
- const resp = await fetch(url, { headers: { Authorization: auth, Accept: "text/plain" } });
21579
- if (resp.status === 401) {
21580
- throw new Error("\u274C Auth failed: check BITBUCKET_USER_EMAIL and BITBUCKET_API_TOKEN");
21581
- }
21582
- if (resp.status === 404) {
21583
- throw new Error(`\u274C Not found: ${url}`);
21584
- }
21585
- if (!resp.ok) {
21586
- throw new Error(`\u274C API error ${resp.status}: ${await resp.text()}`);
21587
- }
21588
- return await resp.text();
21511
+ async function callRestApi(url, auth, accept = "application/json") {
21512
+ const resp = await fetch(url, { headers: { Authorization: auth, Accept: accept } });
21513
+ if (resp.status === 401) throw new Error(`\u274C Auth failed (401): ${url}`);
21514
+ if (resp.status === 404) throw new Error(`\u274C Not found: ${url}`);
21515
+ if (!resp.ok) throw new Error(`\u274C API error ${resp.status}: ${await resp.text()}`);
21516
+ return accept === "text/plain" ? resp.text() : resp.json();
21589
21517
  }
21590
21518
  function registerIntegrationTools(server2) {
21591
21519
  server2.tool(
@@ -21601,18 +21529,6 @@ function registerIntegrationTools(server2) {
21601
21529
  },
21602
21530
  async ({ input, workspace, repoSlug, includeDiff }) => {
21603
21531
  try {
21604
- const bbEmail = getCredential("BITBUCKET_USER_EMAIL");
21605
- const bbToken = getCredential("BITBUCKET_API_TOKEN");
21606
- if (!bbEmail || !bbToken) {
21607
- return {
21608
- content: [
21609
- {
21610
- type: "text",
21611
- text: `\u274C Missing BITBUCKET_USER_EMAIL or BITBUCKET_API_TOKEN. Set them in ~/.claude/agent-kit or as environment variables.`
21612
- }
21613
- ]
21614
- };
21615
- }
21616
21532
  let ws;
21617
21533
  let repo;
21618
21534
  let prId;
@@ -21627,29 +21543,20 @@ function registerIntegrationTools(server2) {
21627
21543
  repo = repoSlug;
21628
21544
  }
21629
21545
  if (!ws) {
21630
- return {
21631
- content: [
21632
- {
21633
- type: "text",
21634
- text: `\u274C workspace is required. Pass it as a parameter or set BITBUCKET_DEFAULT_WORKSPACE in your MCP env config.`
21635
- }
21636
- ]
21637
- };
21546
+ return mcpText(
21547
+ `\u274C workspace is required. Pass it as a parameter or set BITBUCKET_DEFAULT_WORKSPACE in your MCP env config.`
21548
+ );
21638
21549
  }
21639
21550
  if (!repo || !prId) {
21640
- return {
21641
- content: [
21642
- {
21643
- type: "text",
21644
- text: `\u274C Could not parse PR URL. Expected: bitbucket.org/{ws}/{repo}/pull-requests/{id}`
21645
- }
21646
- ]
21647
- };
21551
+ return mcpText(
21552
+ `\u274C Could not parse PR URL. Expected: bitbucket.org/{ws}/{repo}/pull-requests/{id}`
21553
+ );
21648
21554
  }
21649
21555
  const safeWs = sanitize(ws);
21650
21556
  const safeRepo = sanitize(repo);
21557
+ const auth = buildBasicAuth("BITBUCKET_USER_EMAIL", "BITBUCKET_API_TOKEN");
21651
21558
  const prUrl = `https://api.bitbucket.org/2.0/repositories/${safeWs}/${safeRepo}/pullrequests/${prId}`;
21652
- const jsonData = await callBitbucketRestApi(prUrl);
21559
+ const jsonData = await callRestApi(prUrl, auth);
21653
21560
  const parseResult = BitbucketPrSchema.safeParse(jsonData);
21654
21561
  if (!parseResult.success) {
21655
21562
  throw new Error(`Failed to parse PR response: ${parseResult.error.message}`);
@@ -21663,7 +21570,7 @@ function registerIntegrationTools(server2) {
21663
21570
  ${pr.description || "No description"}`;
21664
21571
  if (includeDiff) {
21665
21572
  const diffUrl = `https://api.bitbucket.org/2.0/repositories/${safeWs}/${safeRepo}/pullrequests/${prId}/diff`;
21666
- const diff = await callBitbucketDiffApi(diffUrl);
21573
+ const diff = await callRestApi(diffUrl, auth, "text/plain");
21667
21574
  const DIFF_FILE_THRESHOLD = 5e4;
21668
21575
  if (diff.length < DIFF_FILE_THRESHOLD) {
21669
21576
  output += `
@@ -21691,10 +21598,9 @@ ${diff}
21691
21598
  }
21692
21599
  }
21693
21600
  }
21694
- return { content: [{ type: "text", text: output }] };
21601
+ return mcpText(output);
21695
21602
  } catch (error2) {
21696
- const errorMsg = error2 instanceof Error ? error2.message : String(error2);
21697
- return { content: [{ type: "text", text: errorMsg }] };
21603
+ return mcpText(error2 instanceof Error ? error2.message : String(error2));
21698
21604
  }
21699
21605
  }
21700
21606
  );
@@ -21706,62 +21612,26 @@ ${diff}
21706
21612
  },
21707
21613
  async ({ ticketId }) => {
21708
21614
  try {
21709
- const cloudId = getCredential("ATLASSIAN_CLOUD_ID");
21710
- const userEmail = getCredential("ATLASSIAN_USER_EMAIL");
21711
- const apiToken = getCredential("ATLASSIAN_API_TOKEN");
21712
- if (!cloudId || !userEmail || !apiToken) {
21713
- const missing = [
21714
- !cloudId && "ATLASSIAN_CLOUD_ID",
21715
- !userEmail && "ATLASSIAN_USER_EMAIL",
21716
- !apiToken && "ATLASSIAN_API_TOKEN"
21717
- ].filter(Boolean).join(", ");
21718
- return {
21719
- content: [
21720
- {
21721
- type: "text",
21722
- text: `\u274C Missing ${missing}. Create an API Token at id.atlassian.com/manage-profile/security/api-tokens.`
21723
- }
21724
- ]
21725
- };
21726
- }
21727
21615
  const safeTicketId = ticketId.match(/^[A-Z]+-\d+$/)?.[0];
21728
21616
  if (!safeTicketId) {
21729
- return {
21730
- content: [
21731
- {
21732
- type: "text",
21733
- text: `\u274C Invalid ticket ID format: ${ticketId}
21617
+ return mcpText(`\u274C Invalid ticket ID format: ${ticketId}
21734
21618
 
21735
- Expected format: PROJ-123`
21736
- }
21737
- ]
21738
- };
21619
+ Expected format: PROJ-123`);
21739
21620
  }
21621
+ const cloudId = getCredential("ATLASSIAN_CLOUD_ID");
21622
+ if (!cloudId) throw new Error("Missing ATLASSIAN_CLOUD_ID");
21623
+ const auth = buildBasicAuth("ATLASSIAN_USER_EMAIL", "ATLASSIAN_API_TOKEN");
21740
21624
  const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeTicketId}`;
21741
- const jsonData = await callAtlassianRestApi(url);
21625
+ const jsonData = await callRestApi(url, auth);
21742
21626
  const parseResult = JiraTicketSchema.safeParse(jsonData);
21743
21627
  if (!parseResult.success) {
21744
- return {
21745
- content: [
21746
- {
21747
- type: "text",
21748
- text: `\u274C Invalid Jira response format: ${parseResult.error.message}`
21749
- }
21750
- ]
21751
- };
21628
+ return mcpText(`\u274C Invalid Jira response format: ${parseResult.error.message}`);
21752
21629
  }
21753
21630
  const ticket = parseResult.data;
21754
21631
  if (ticket.errorMessages && ticket.errorMessages.length > 0) {
21755
- return {
21756
- content: [
21757
- {
21758
- type: "text",
21759
- text: `\u274C Ticket not found: ${ticketId}
21632
+ return mcpText(`\u274C Ticket not found: ${ticketId}
21760
21633
 
21761
- ${ticket.errorMessages.join("\n")}`
21762
- }
21763
- ]
21764
- };
21634
+ ${ticket.errorMessages.join("\n")}`);
21765
21635
  }
21766
21636
  const output = `## \u{1F3AB} ${ticketId}: ${ticket.fields.summary}
21767
21637
 
@@ -21776,10 +21646,9 @@ ${typeof ticket.fields.description === "string" ? ticket.fields.description : ad
21776
21646
 
21777
21647
  ### Labels
21778
21648
  ${ticket.fields.labels?.join(", ") || "None"}`;
21779
- return { content: [{ type: "text", text: output }] };
21649
+ return mcpText(output);
21780
21650
  } catch (error2) {
21781
- const errorMsg = error2 instanceof Error ? error2.message : String(error2);
21782
- return { content: [{ type: "text", text: `Error: ${errorMsg}` }] };
21651
+ return mcpText(`Error: ${error2 instanceof Error ? error2.message : String(error2)}`);
21783
21652
  }
21784
21653
  }
21785
21654
  );
@@ -3,7 +3,4 @@
3
3
  * Supports handoff file injection and automatic fallback
4
4
  */
5
5
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
- /**
7
- * Register agent delegation tools with MCP server
8
- */
9
6
  export declare function registerAgentTools(server: McpServer): void;
@@ -7,14 +7,11 @@ import { randomUUID } from 'crypto';
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import { z } from 'zod';
10
- import { getWorkspaceRoot } from '../utils/utils.js';
10
+ import { getWorkspaceRoot, mcpJson, mcpText } from '../utils/utils.js';
11
11
  import { commandExists, sanitizeOutput, validatePath } from './security.js';
12
12
  // Configurable timeout via environment variable (default: 5 minutes)
13
13
  const AGENT_TIMEOUT = parseInt(process.env.KIT_AGENT_TIMEOUT || '300000', 10);
14
14
  const jobRegistry = new Map();
15
- /**
16
- * Generate a URL-safe slug from a task string (first ~6 words, max 48 chars)
17
- */
18
15
  function taskSlug(task) {
19
16
  return task
20
17
  .toLowerCase()
@@ -25,11 +22,6 @@ function taskSlug(task) {
25
22
  .join('-')
26
23
  .slice(0, 48);
27
24
  }
28
- /**
29
- * Initialize a live-streaming log file for an agent job.
30
- * Writes the header synchronously so the file exists immediately,
31
- * then opens an append-mode WriteStream for real-time chunk writing.
32
- */
33
25
  function initAgentLog(workspaceRoot, slug, agent) {
34
26
  const logsDir = path.join(workspaceRoot, '.agent-kit', 'logs', 'agents');
35
27
  fs.mkdirSync(logsDir, { recursive: true });
@@ -41,25 +33,19 @@ function initAgentLog(workspaceRoot, slug, agent) {
41
33
  logStream.on('error', () => { });
42
34
  return { logPath: path.relative(workspaceRoot, logFile), logStream };
43
35
  }
44
- /**
45
- * Register agent delegation tools with MCP server
46
- */
47
36
  export function registerAgentTools(server) {
48
- // ═══════════════════════════════════════════════════════════════
49
- // TOOL: TRIGGER AGENT
50
- // Delegates a task to an external agent CLI (gemini or claude).
51
- // Auto-detects if task is a file path and reads it as context.
52
- // Falls back to the alternate agent if requested one is missing.
53
- // ═══════════════════════════════════════════════════════════════
54
37
  server.tool('kit_trigger_agent', '!Important: Trigger only when user explicitly asks to delegate a task to an external agent CLI (gemini or claude). The task can be a direct message or a path to a handoff file (.agent-kit/handoffs/plans/plan-xyz.md). Falls back to the other agent CLI if the requested one is not installed.', {
55
38
  agent: z.enum(['gemini', 'claude']).describe('The agent CLI to invoke: "gemini" or "claude"'),
56
39
  task: z
57
40
  .string()
58
41
  .describe('Task message or path to a handoff file (e.g., ".agent-kit/handoffs/plans/plan-xyz.md")'),
59
- }, async ({ agent, task }) => {
42
+ }, async ({ agent, task }, extra) => {
43
+ const workspaceRoot = getWorkspaceRoot();
44
+ let job;
45
+ let logPath;
46
+ let killChild;
60
47
  try {
61
- const workspaceRoot = getWorkspaceRoot();
62
- // Detect if task is a file path — resolve and read if it exists
48
+ // Resolve task: read file if path, otherwise use as-is
63
49
  let prompt = task;
64
50
  const resolvedTask = path.resolve(workspaceRoot, task);
65
51
  if (fs.existsSync(resolvedTask) && fs.statSync(resolvedTask).isFile()) {
@@ -68,12 +54,10 @@ export function registerAgentTools(server) {
68
54
  prompt = fs.readFileSync(resolvedTask, 'utf-8');
69
55
  }
70
56
  catch (err) {
71
- return {
72
- content: [{ type: 'text', text: `Error reading task file: ${err}` }],
73
- };
57
+ return mcpText(`Error reading task file: ${err}`);
74
58
  }
75
59
  }
76
- // Determine which agent to use — fallback if requested not available
60
+ // Resolve agent with fallback
77
61
  let usedAgent = agent;
78
62
  let fallbackReason;
79
63
  if (!commandExists(agent)) {
@@ -86,105 +70,87 @@ export function registerAgentTools(server) {
86
70
  const installHint = agent === 'gemini'
87
71
  ? 'Install Gemini CLI: https://github.com/google-gemini/gemini-cli'
88
72
  : 'Install Claude CLI: npm install -g @anthropic-ai/claude-code';
89
- return {
90
- content: [
91
- {
92
- type: 'text',
93
- text: JSON.stringify({
94
- agent,
95
- status: 'error',
96
- output: '',
97
- error: `Neither ${agent} nor the fallback agent CLI is installed. ${installHint}`,
98
- }, null, 2),
99
- },
100
- ],
101
- };
73
+ return mcpJson({
74
+ agent,
75
+ status: 'error',
76
+ output: '',
77
+ error: `Neither ${agent} nor the fallback agent CLI is installed. ${installHint}`,
78
+ });
102
79
  }
103
80
  }
104
- // Execute the agent CLI with the prompt
105
81
  // Gemini: -y (--yolo) for auto-accept, -p for headless prompt
106
82
  // Claude: --dangerously-skip-permissions for auto-accept, -p/--print for headless
107
83
  const agentArgs = usedAgent === 'gemini'
108
84
  ? ['-y', '-p', prompt]
109
85
  : ['--dangerously-skip-permissions', '-p', prompt];
110
- const slug = taskSlug(task);
111
- const { logPath, logStream } = initAgentLog(workspaceRoot, slug, usedAgent);
86
+ const { logPath: lp, logStream } = initAgentLog(workspaceRoot, taskSlug(task), usedAgent);
87
+ logPath = lp;
112
88
  const jobId = randomUUID();
113
89
  const child = spawn(usedAgent, agentArgs, {
114
90
  cwd: workspaceRoot,
115
91
  env: { ...process.env, GEMINI_WORKSPACE: workspaceRoot },
116
92
  });
117
- const job = {
118
- id: jobId,
93
+ job = {
119
94
  agent: usedAgent,
120
95
  process: child,
121
96
  startedAt: new Date(),
122
97
  chunks: [],
123
98
  logStream,
124
- timeoutHandle: setTimeout(() => {
125
- child.kill('SIGTERM');
126
- }, AGENT_TIMEOUT),
99
+ timeoutHandle: setTimeout(() => child.kill('SIGTERM'), AGENT_TIMEOUT),
127
100
  };
128
101
  jobRegistry.set(jobId, job);
129
- try {
130
- await new Promise((resolve, reject) => {
131
- child.stdout?.on('data', (data) => {
132
- job.chunks.push(data.toString());
133
- job.logStream.write(data);
134
- });
135
- child.stderr?.on('data', (data) => {
136
- job.chunks.push(data.toString());
137
- job.logStream.write(data);
138
- });
139
- child.on('close', (code) => {
140
- if (code === 0)
141
- resolve();
142
- else
143
- reject(new Error(`Process exited with code ${code}`));
144
- });
145
- child.on('error', reject);
102
+ // Kill child if the MCP request is cancelled (e.g. user presses ESC)
103
+ killChild = () => {
104
+ child.kill('SIGTERM');
105
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref();
106
+ };
107
+ if (extra.signal.aborted)
108
+ killChild();
109
+ else
110
+ extra.signal.addEventListener('abort', killChild);
111
+ await new Promise((resolve, reject) => {
112
+ child.stdout?.on('data', (data) => {
113
+ job.chunks.push(data.toString());
114
+ job.logStream.write(data);
146
115
  });
147
- const output = job.chunks.join('');
148
- const safeOutput = sanitizeOutput(output);
149
- return {
150
- content: [
151
- {
152
- type: 'text',
153
- text: JSON.stringify({
154
- agent: usedAgent,
155
- status: fallbackReason ? 'fallback' : 'success',
156
- log: logPath,
157
- output: safeOutput,
158
- ...(fallbackReason && { fallback_reason: fallbackReason }),
159
- }, null, 2),
160
- },
161
- ],
162
- };
163
- }
164
- catch (error) {
165
- throw error;
166
- }
167
- finally {
168
- job.logStream.end();
169
- clearTimeout(job.timeoutHandle);
170
- jobRegistry.delete(jobId);
171
- }
116
+ child.stderr?.on('data', (data) => {
117
+ job.chunks.push(data.toString());
118
+ job.logStream.write(data);
119
+ });
120
+ child.on('close', (code, signal) => {
121
+ if (code === 0)
122
+ resolve();
123
+ else
124
+ reject(new Error(`Process exited with code ${code}${signal ? ` (signal: ${signal})` : ''}`));
125
+ });
126
+ child.on('error', reject);
127
+ });
128
+ return mcpJson({
129
+ agent: usedAgent,
130
+ status: fallbackReason ? 'fallback' : 'success',
131
+ log: logPath,
132
+ output: sanitizeOutput(job.chunks.join('')),
133
+ ...(fallbackReason && { fallback_reason: fallbackReason }),
134
+ });
172
135
  }
173
136
  catch (error) {
137
+ if (extra.signal.aborted) {
138
+ return mcpJson({
139
+ agent,
140
+ status: 'cancelled',
141
+ log: logPath,
142
+ output: sanitizeOutput(job?.chunks.join('') ?? ''),
143
+ });
144
+ }
174
145
  const msg = error instanceof Error ? error.message : String(error);
175
- return {
176
- content: [
177
- {
178
- type: 'text',
179
- text: JSON.stringify({
180
- agent,
181
- status: 'error',
182
- output: '',
183
- error: msg,
184
- }, null, 2),
185
- },
186
- ],
187
- };
146
+ return mcpJson({ agent, status: 'error', output: '', error: msg });
147
+ }
148
+ finally {
149
+ if (killChild)
150
+ extra.signal.removeEventListener('abort', killChild);
151
+ job?.logStream.end();
152
+ if (job)
153
+ clearTimeout(job.timeoutHandle);
188
154
  }
189
155
  });
190
156
  }
@@ -4,7 +4,7 @@
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
  import { z } from 'zod';
7
- import { getWorkspaceRoot } from '../utils/utils.js';
7
+ import { getWorkspaceRoot, mcpText } from '../utils/utils.js';
8
8
  import { DEFAULT_EXTENSIONS } from './config.js';
9
9
  /**
10
10
  * Register core tools with MCP server
@@ -32,14 +32,10 @@ export function registerCoreTools(server) {
32
32
  }
33
33
  const filePath = path.join(handoffDir, filename);
34
34
  fs.writeFileSync(filePath, content, 'utf8');
35
- return {
36
- content: [{ type: 'text', text: `✅ Saved to: ${filePath}` }],
37
- };
35
+ return mcpText(`✅ Saved to: ${filePath}`);
38
36
  }
39
37
  catch (error) {
40
- return {
41
- content: [{ type: 'text', text: `Error saving handoff: ${error}` }],
42
- };
38
+ return mcpText(`Error saving handoff: ${error}`);
43
39
  }
44
40
  });
45
41
  }
@@ -5,6 +5,7 @@
5
5
  import { writeFileSync } from 'fs';
6
6
  import { z } from 'zod';
7
7
  import { getCredential } from '../utils/credentials.js';
8
+ import { mcpText } from '../utils/utils.js';
8
9
  import { adfToMarkdown } from '../utils/parser.js';
9
10
  import { sanitize } from './security.js';
10
11
  // Zod schema for Bitbucket PR REST API response
@@ -54,63 +55,22 @@ const JiraTicketSchema = z.object({
54
55
  errorMessages: z.array(z.string()).optional(),
55
56
  fields: JiraFieldsSchema,
56
57
  });
57
- function buildJiraBasicAuth() {
58
- const email = getCredential('ATLASSIAN_USER_EMAIL');
59
- const token = getCredential('ATLASSIAN_API_TOKEN');
60
- if (!email || !token) {
61
- throw new Error('Missing ATLASSIAN_USER_EMAIL or ATLASSIAN_API_TOKEN');
62
- }
58
+ function buildBasicAuth(emailVar, tokenVar) {
59
+ const email = getCredential(emailVar);
60
+ const token = getCredential(tokenVar);
61
+ if (!email || !token)
62
+ throw new Error(`Missing ${emailVar} or ${tokenVar}`);
63
63
  return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
64
64
  }
65
- function buildBitbucketBasicAuth() {
66
- const email = getCredential('BITBUCKET_USER_EMAIL');
67
- const token = getCredential('BITBUCKET_API_TOKEN');
68
- if (!email || !token) {
69
- throw new Error('Missing BITBUCKET_USER_EMAIL or BITBUCKET_API_TOKEN');
70
- }
71
- return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
72
- }
73
- async function callAtlassianRestApi(url) {
74
- const auth = buildJiraBasicAuth();
75
- const resp = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' } });
76
- if (resp.status === 401) {
77
- throw new Error('❌ Auth failed: check ATLASSIAN_USER_EMAIL and ATLASSIAN_API_TOKEN');
78
- }
79
- if (resp.status === 404) {
80
- throw new Error(`❌ Not found: ${url}`);
81
- }
82
- if (!resp.ok) {
83
- throw new Error(`❌ API error ${resp.status}: ${await resp.text()}`);
84
- }
85
- return await resp.json();
86
- }
87
- async function callBitbucketRestApi(url) {
88
- const auth = buildBitbucketBasicAuth();
89
- const resp = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' } });
90
- if (resp.status === 401) {
91
- throw new Error('❌ Auth failed: check BITBUCKET_USER_EMAIL and BITBUCKET_API_TOKEN');
92
- }
93
- if (resp.status === 404) {
94
- throw new Error(`❌ Not found: ${url}`);
95
- }
96
- if (!resp.ok) {
97
- throw new Error(`❌ API error ${resp.status}: ${await resp.text()}`);
98
- }
99
- return await resp.json();
100
- }
101
- async function callBitbucketDiffApi(url) {
102
- const auth = buildBitbucketBasicAuth();
103
- const resp = await fetch(url, { headers: { Authorization: auth, Accept: 'text/plain' } });
104
- if (resp.status === 401) {
105
- throw new Error('❌ Auth failed: check BITBUCKET_USER_EMAIL and BITBUCKET_API_TOKEN');
106
- }
107
- if (resp.status === 404) {
65
+ async function callRestApi(url, auth, accept = 'application/json') {
66
+ const resp = await fetch(url, { headers: { Authorization: auth, Accept: accept } });
67
+ if (resp.status === 401)
68
+ throw new Error(`❌ Auth failed (401): ${url}`);
69
+ if (resp.status === 404)
108
70
  throw new Error(`❌ Not found: ${url}`);
109
- }
110
- if (!resp.ok) {
71
+ if (!resp.ok)
111
72
  throw new Error(`❌ API error ${resp.status}: ${await resp.text()}`);
112
- }
113
- return await resp.text();
73
+ return accept === 'text/plain' ? resp.text() : resp.json();
114
74
  }
115
75
  export function registerIntegrationTools(server) {
116
76
  // TOOL: GET BITBUCKET PR
@@ -128,22 +88,9 @@ export function registerIntegrationTools(server) {
128
88
  .describe('Include unified diff in response'),
129
89
  }, async ({ input, workspace, repoSlug, includeDiff }) => {
130
90
  try {
131
- const bbEmail = getCredential('BITBUCKET_USER_EMAIL');
132
- const bbToken = getCredential('BITBUCKET_API_TOKEN');
133
- if (!bbEmail || !bbToken) {
134
- return {
135
- content: [
136
- {
137
- type: 'text',
138
- text: `❌ Missing BITBUCKET_USER_EMAIL or BITBUCKET_API_TOKEN. Set them in ~/.claude/agent-kit or as environment variables.`,
139
- },
140
- ],
141
- };
142
- }
143
91
  let ws;
144
92
  let repo;
145
93
  let prId;
146
- // Try URL parse
147
94
  const urlMatch = input.match(/bitbucket\.org\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)/);
148
95
  if (urlMatch) {
149
96
  ws = urlMatch[1];
@@ -156,29 +103,16 @@ export function registerIntegrationTools(server) {
156
103
  repo = repoSlug;
157
104
  }
158
105
  if (!ws) {
159
- return {
160
- content: [
161
- {
162
- type: 'text',
163
- text: `❌ workspace is required. Pass it as a parameter or set BITBUCKET_DEFAULT_WORKSPACE in your MCP env config.`,
164
- },
165
- ],
166
- };
106
+ return mcpText(`❌ workspace is required. Pass it as a parameter or set BITBUCKET_DEFAULT_WORKSPACE in your MCP env config.`);
167
107
  }
168
108
  if (!repo || !prId) {
169
- return {
170
- content: [
171
- {
172
- type: 'text',
173
- text: `❌ Could not parse PR URL. Expected: bitbucket.org/{ws}/{repo}/pull-requests/{id}`,
174
- },
175
- ],
176
- };
109
+ return mcpText(`❌ Could not parse PR URL. Expected: bitbucket.org/{ws}/{repo}/pull-requests/{id}`);
177
110
  }
178
111
  const safeWs = sanitize(ws);
179
112
  const safeRepo = sanitize(repo);
113
+ const auth = buildBasicAuth('BITBUCKET_USER_EMAIL', 'BITBUCKET_API_TOKEN');
180
114
  const prUrl = `https://api.bitbucket.org/2.0/repositories/${safeWs}/${safeRepo}/pullrequests/${prId}`;
181
- const jsonData = await callBitbucketRestApi(prUrl);
115
+ const jsonData = await callRestApi(prUrl, auth);
182
116
  const parseResult = BitbucketPrSchema.safeParse(jsonData);
183
117
  if (!parseResult.success) {
184
118
  throw new Error(`Failed to parse PR response: ${parseResult.error.message}`);
@@ -192,7 +126,7 @@ export function registerIntegrationTools(server) {
192
126
  ${pr.description || 'No description'}`;
193
127
  if (includeDiff) {
194
128
  const diffUrl = `https://api.bitbucket.org/2.0/repositories/${safeWs}/${safeRepo}/pullrequests/${prId}/diff`;
195
- const diff = await callBitbucketDiffApi(diffUrl);
129
+ const diff = (await callRestApi(diffUrl, auth, 'text/plain'));
196
130
  const DIFF_FILE_THRESHOLD = 50_000;
197
131
  if (diff.length < DIFF_FILE_THRESHOLD) {
198
132
  output += `\n\n### Diff\n\`\`\`diff\n${diff}\n\`\`\``;
@@ -208,11 +142,10 @@ ${pr.description || 'No description'}`;
208
142
  }
209
143
  }
210
144
  }
211
- return { content: [{ type: 'text', text: output }] };
145
+ return mcpText(output);
212
146
  }
213
147
  catch (error) {
214
- const errorMsg = error instanceof Error ? error.message : String(error);
215
- return { content: [{ type: 'text', text: errorMsg }] };
148
+ return mcpText(error instanceof Error ? error.message : String(error));
216
149
  }
217
150
  });
218
151
  // TOOL: JIRA GET TICKET
@@ -220,60 +153,23 @@ ${pr.description || 'No description'}`;
220
153
  ticketId: z.string().describe('Jira ticket ID (e.g., PROJ-123)'),
221
154
  }, async ({ ticketId }) => {
222
155
  try {
223
- const cloudId = getCredential('ATLASSIAN_CLOUD_ID');
224
- const userEmail = getCredential('ATLASSIAN_USER_EMAIL');
225
- const apiToken = getCredential('ATLASSIAN_API_TOKEN');
226
- if (!cloudId || !userEmail || !apiToken) {
227
- const missing = [
228
- !cloudId && 'ATLASSIAN_CLOUD_ID',
229
- !userEmail && 'ATLASSIAN_USER_EMAIL',
230
- !apiToken && 'ATLASSIAN_API_TOKEN',
231
- ]
232
- .filter(Boolean)
233
- .join(', ');
234
- return {
235
- content: [
236
- {
237
- type: 'text',
238
- text: `❌ Missing ${missing}. Create an API Token at id.atlassian.com/manage-profile/security/api-tokens.`,
239
- },
240
- ],
241
- };
242
- }
243
156
  const safeTicketId = ticketId.match(/^[A-Z]+-\d+$/)?.[0];
244
157
  if (!safeTicketId) {
245
- return {
246
- content: [
247
- {
248
- type: 'text',
249
- text: `❌ Invalid ticket ID format: ${ticketId}\n\nExpected format: PROJ-123`,
250
- },
251
- ],
252
- };
158
+ return mcpText(`❌ Invalid ticket ID format: ${ticketId}\n\nExpected format: PROJ-123`);
253
159
  }
160
+ const cloudId = getCredential('ATLASSIAN_CLOUD_ID');
161
+ if (!cloudId)
162
+ throw new Error('Missing ATLASSIAN_CLOUD_ID');
163
+ const auth = buildBasicAuth('ATLASSIAN_USER_EMAIL', 'ATLASSIAN_API_TOKEN');
254
164
  const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeTicketId}`;
255
- const jsonData = await callAtlassianRestApi(url);
165
+ const jsonData = await callRestApi(url, auth);
256
166
  const parseResult = JiraTicketSchema.safeParse(jsonData);
257
167
  if (!parseResult.success) {
258
- return {
259
- content: [
260
- {
261
- type: 'text',
262
- text: `❌ Invalid Jira response format: ${parseResult.error.message}`,
263
- },
264
- ],
265
- };
168
+ return mcpText(`❌ Invalid Jira response format: ${parseResult.error.message}`);
266
169
  }
267
170
  const ticket = parseResult.data;
268
171
  if (ticket.errorMessages && ticket.errorMessages.length > 0) {
269
- return {
270
- content: [
271
- {
272
- type: 'text',
273
- text: `❌ Ticket not found: ${ticketId}\n\n${ticket.errorMessages.join('\n')}`,
274
- },
275
- ],
276
- };
172
+ return mcpText(`❌ Ticket not found: ${ticketId}\n\n${ticket.errorMessages.join('\n')}`);
277
173
  }
278
174
  const output = `## 🎫 ${ticketId}: ${ticket.fields.summary}
279
175
 
@@ -290,11 +186,10 @@ ${typeof ticket.fields.description === 'string'
290
186
 
291
187
  ### Labels
292
188
  ${ticket.fields.labels?.join(', ') || 'None'}`;
293
- return { content: [{ type: 'text', text: output }] };
189
+ return mcpText(output);
294
190
  }
295
191
  catch (error) {
296
- const errorMsg = error instanceof Error ? error.message : String(error);
297
- return { content: [{ type: 'text', text: `Error: ${errorMsg}` }] };
192
+ return mcpText(`Error: ${error instanceof Error ? error.message : String(error)}`);
298
193
  }
299
194
  });
300
195
  }
@@ -1 +1,10 @@
1
1
  export declare function getWorkspaceRoot(): string;
2
+ type McpTextResult = {
3
+ content: [{
4
+ type: 'text';
5
+ text: string;
6
+ }];
7
+ };
8
+ export declare function mcpText(text: string): McpTextResult;
9
+ export declare function mcpJson(obj: unknown): McpTextResult;
10
+ export {};
@@ -6,3 +6,9 @@ export function getWorkspaceRoot() {
6
6
  process.env.PWD ||
7
7
  process.cwd());
8
8
  }
9
+ export function mcpText(text) {
10
+ return { content: [{ type: 'text', text }] };
11
+ }
12
+ export function mcpJson(obj) {
13
+ return mcpText(JSON.stringify(obj, null, 2));
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanhnd/agent-kit",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "Super Engineer - Team of AI Agents for software development (Claude Code Plugin)",
5
5
  "type": "module",
6
6
  "main": "dist/kit-server.js",