agentcord 0.1.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,515 @@
1
+ import {
2
+ EmbedBuilder,
3
+ ActionRowBuilder,
4
+ ButtonBuilder,
5
+ ButtonStyle,
6
+ StringSelectMenuBuilder,
7
+ type TextChannel,
8
+ type Message,
9
+ } from 'discord.js';
10
+ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
11
+ import { splitMessage, truncate, detectNumberedOptions, detectYesNoPrompt } from './utils.ts';
12
+ import type { ExpandableContent } from './types.ts';
13
+
14
+ // In-memory store for expandable content (with TTL cleanup)
15
+ const expandableStore = new Map<string, ExpandableContent>();
16
+ let expandCounter = 0;
17
+
18
+ // Clean up expired expandable content every 5 minutes
19
+ setInterval(() => {
20
+ const now = Date.now();
21
+ const TTL = 10 * 60 * 1000; // 10 minutes
22
+ for (const [key, val] of expandableStore) {
23
+ if (now - val.createdAt > TTL) expandableStore.delete(key);
24
+ }
25
+ }, 5 * 60 * 1000);
26
+
27
+ export function getExpandableContent(id: string): string | undefined {
28
+ return expandableStore.get(id)?.content;
29
+ }
30
+
31
+ function storeExpandable(content: string): string {
32
+ const id = `exp_${++expandCounter}`;
33
+ expandableStore.set(id, { content, createdAt: Date.now() });
34
+ return id;
35
+ }
36
+
37
+ function makeStopButton(sessionId: string): ActionRowBuilder<ButtonBuilder> {
38
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
39
+ new ButtonBuilder()
40
+ .setCustomId(`stop:${sessionId}`)
41
+ .setLabel('Stop')
42
+ .setStyle(ButtonStyle.Danger),
43
+ );
44
+ }
45
+
46
+ function makeCompletionButtons(sessionId: string): ActionRowBuilder<ButtonBuilder> {
47
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
48
+ new ButtonBuilder()
49
+ .setCustomId(`continue:${sessionId}`)
50
+ .setLabel('Continue')
51
+ .setStyle(ButtonStyle.Primary),
52
+ );
53
+ }
54
+
55
+ function makeOptionButtons(sessionId: string, options: string[]): ActionRowBuilder<ButtonBuilder>[] {
56
+ const rows: ActionRowBuilder<ButtonBuilder>[] = [];
57
+ const maxOptions = Math.min(options.length, 10);
58
+
59
+ for (let i = 0; i < maxOptions; i += 5) {
60
+ const row = new ActionRowBuilder<ButtonBuilder>();
61
+ const chunk = options.slice(i, i + 5);
62
+ for (let j = 0; j < chunk.length; j++) {
63
+ row.addComponents(
64
+ new ButtonBuilder()
65
+ .setCustomId(`option:${sessionId}:${i + j}`)
66
+ .setLabel(truncate(chunk[j], 80))
67
+ .setStyle(ButtonStyle.Secondary),
68
+ );
69
+ }
70
+ rows.push(row);
71
+ }
72
+
73
+ return rows;
74
+ }
75
+
76
+ function makeYesNoButtons(sessionId: string): ActionRowBuilder<ButtonBuilder> {
77
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
78
+ new ButtonBuilder()
79
+ .setCustomId(`confirm:${sessionId}:yes`)
80
+ .setLabel('Yes')
81
+ .setStyle(ButtonStyle.Success),
82
+ new ButtonBuilder()
83
+ .setCustomId(`confirm:${sessionId}:no`)
84
+ .setLabel('No')
85
+ .setStyle(ButtonStyle.Danger),
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Serialized message editor — ensures only one Discord API call is in-flight
91
+ * at a time, preventing duplicate messages from race conditions.
92
+ */
93
+ class MessageStreamer {
94
+ private channel: TextChannel;
95
+ private sessionId: string;
96
+ private currentMessage: Message | null = null;
97
+ private currentText = '';
98
+ private dirty = false;
99
+ private flushing = false;
100
+ private timer: ReturnType<typeof setTimeout> | null = null;
101
+ private readonly INTERVAL = 400; // ms between edits
102
+
103
+ constructor(channel: TextChannel, sessionId: string) {
104
+ this.channel = channel;
105
+ this.sessionId = sessionId;
106
+ }
107
+
108
+ append(text: string): void {
109
+ this.currentText += text;
110
+ this.dirty = true;
111
+ this.scheduleFlush();
112
+ }
113
+
114
+ private scheduleFlush(): void {
115
+ if (this.timer || this.flushing) return;
116
+ this.timer = setTimeout(() => {
117
+ this.timer = null;
118
+ this.flush();
119
+ }, this.INTERVAL);
120
+ }
121
+
122
+ private async flush(): Promise<void> {
123
+ if (this.flushing || !this.dirty) return;
124
+ this.flushing = true;
125
+
126
+ try {
127
+ // Snapshot what we need to send
128
+ const text = this.currentText;
129
+ this.dirty = false;
130
+
131
+ const chunks = splitMessage(text);
132
+ const lastChunk = chunks[chunks.length - 1];
133
+
134
+ // If text overflows into multiple chunks, finalize earlier ones
135
+ if (chunks.length > 1 && this.currentMessage) {
136
+ try {
137
+ await this.currentMessage.edit({ content: chunks[0], components: [] });
138
+ } catch { /* deleted */ }
139
+ this.currentMessage = null;
140
+
141
+ for (let i = 1; i < chunks.length - 1; i++) {
142
+ await this.channel.send(chunks[i]);
143
+ }
144
+ }
145
+
146
+ // Edit or create the live message with the last chunk
147
+ if (this.currentMessage) {
148
+ try {
149
+ await this.currentMessage.edit({
150
+ content: lastChunk,
151
+ components: [makeStopButton(this.sessionId)],
152
+ });
153
+ } catch { /* deleted */ }
154
+ } else {
155
+ this.currentMessage = await this.channel.send({
156
+ content: lastChunk,
157
+ components: [makeStopButton(this.sessionId)],
158
+ });
159
+ }
160
+ } finally {
161
+ this.flushing = false;
162
+ // If more text arrived while we were flushing, schedule again
163
+ if (this.dirty) {
164
+ this.scheduleFlush();
165
+ }
166
+ }
167
+ }
168
+
169
+ /** Flush remaining text and remove the stop button */
170
+ async finalize(): Promise<void> {
171
+ if (this.timer) {
172
+ clearTimeout(this.timer);
173
+ this.timer = null;
174
+ }
175
+
176
+ // Wait for any in-flight flush to finish
177
+ while (this.flushing) {
178
+ await new Promise(r => setTimeout(r, 50));
179
+ }
180
+
181
+ // Do a final flush if there's pending text
182
+ if (this.dirty) {
183
+ this.dirty = false;
184
+ const text = this.currentText;
185
+ const chunks = splitMessage(text);
186
+ const lastChunk = chunks[chunks.length - 1];
187
+
188
+ if (chunks.length > 1 && this.currentMessage) {
189
+ try {
190
+ await this.currentMessage.edit({ content: chunks[0], components: [] });
191
+ } catch { /* deleted */ }
192
+ this.currentMessage = null;
193
+ for (let i = 1; i < chunks.length - 1; i++) {
194
+ await this.channel.send(chunks[i]);
195
+ }
196
+ }
197
+
198
+ if (this.currentMessage) {
199
+ try {
200
+ await this.currentMessage.edit({ content: lastChunk, components: [] });
201
+ } catch { /* deleted */ }
202
+ } else if (lastChunk) {
203
+ this.currentMessage = await this.channel.send({ content: lastChunk });
204
+ }
205
+ } else if (this.currentMessage) {
206
+ // Just remove the stop button
207
+ try {
208
+ await this.currentMessage.edit({
209
+ content: this.currentMessage.content || '',
210
+ components: [],
211
+ });
212
+ } catch { /* deleted */ }
213
+ }
214
+
215
+ this.currentMessage = null;
216
+ this.currentText = '';
217
+ }
218
+
219
+ getText(): string {
220
+ return this.currentText;
221
+ }
222
+
223
+ destroy(): void {
224
+ if (this.timer) {
225
+ clearTimeout(this.timer);
226
+ this.timer = null;
227
+ }
228
+ }
229
+ }
230
+
231
+ // Tools that ask for user input — always shown regardless of verbose mode
232
+ const USER_FACING_TOOLS = new Set([
233
+ 'AskUserQuestion',
234
+ 'EnterPlanMode',
235
+ 'ExitPlanMode',
236
+ ]);
237
+
238
+ // Task management tools — rendered as a visual board
239
+ const TASK_TOOLS = new Set([
240
+ 'TaskCreate',
241
+ 'TaskUpdate',
242
+ 'TaskList',
243
+ 'TaskGet',
244
+ ]);
245
+
246
+ const STATUS_EMOJI: Record<string, string> = {
247
+ pending: '\u2B1C', // white square
248
+ in_progress: '\uD83D\uDD04', // arrows
249
+ completed: '\u2705', // check
250
+ deleted: '\uD83D\uDDD1\uFE0F', // wastebasket
251
+ };
252
+
253
+ function renderTaskToolEmbed(toolName: string, toolInput: string): EmbedBuilder | null {
254
+ try {
255
+ const data = JSON.parse(toolInput);
256
+
257
+ if (toolName === 'TaskCreate') {
258
+ const embed = new EmbedBuilder()
259
+ .setColor(0x3498db)
260
+ .setTitle('\uD83D\uDCCB New Task')
261
+ .setDescription(`**${data.subject || 'Untitled'}**`);
262
+ if (data.description) {
263
+ embed.addFields({ name: 'Details', value: truncate(data.description, 300) });
264
+ }
265
+ return embed;
266
+ }
267
+
268
+ if (toolName === 'TaskUpdate') {
269
+ const emoji = STATUS_EMOJI[data.status] || '\uD83D\uDCCB';
270
+ const parts: string[] = [];
271
+ if (data.status) parts.push(`${emoji} **${data.status}**`);
272
+ if (data.subject) parts.push(data.subject);
273
+ return new EmbedBuilder()
274
+ .setColor(data.status === 'completed' ? 0x2ecc71 : 0xf39c12)
275
+ .setTitle(`Task #${data.taskId || '?'} Updated`)
276
+ .setDescription(parts.join(' — ') || 'Updated');
277
+ }
278
+
279
+ return null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ function renderTaskListEmbed(resultText: string): EmbedBuilder | null {
286
+ if (!resultText.trim()) return null;
287
+
288
+ // Replace status keywords with emojis for visual clarity
289
+ let formatted = resultText;
290
+ for (const [status, emoji] of Object.entries(STATUS_EMOJI)) {
291
+ formatted = formatted.replaceAll(status, `${emoji} ${status}`);
292
+ }
293
+
294
+ return new EmbedBuilder()
295
+ .setColor(0x9b59b6)
296
+ .setTitle('\uD83D\uDCCB Task Board')
297
+ .setDescription(truncate(formatted, 4000));
298
+ }
299
+
300
+ export async function handleOutputStream(
301
+ stream: AsyncGenerator<SDKMessage>,
302
+ channel: TextChannel,
303
+ sessionId: string,
304
+ verbose = false,
305
+ ): Promise<void> {
306
+ const streamer = new MessageStreamer(channel, sessionId);
307
+ let currentToolName: string | null = null;
308
+ let currentToolInput = '';
309
+ let lastFinishedToolName: string | null = null;
310
+
311
+ // Show "typing..." indicator while the agent is working
312
+ channel.sendTyping().catch(() => {});
313
+ const typingInterval = setInterval(() => {
314
+ channel.sendTyping().catch(() => {});
315
+ }, 8000);
316
+
317
+ try {
318
+ for await (const message of stream) {
319
+ if (message.type === 'stream_event') {
320
+ const event = (message as any).event;
321
+
322
+ if (event?.type === 'content_block_start') {
323
+ if (event.content_block?.type === 'tool_use') {
324
+ await streamer.finalize();
325
+ currentToolName = event.content_block.name || 'tool';
326
+ currentToolInput = '';
327
+ }
328
+ }
329
+
330
+ if (event?.type === 'content_block_delta') {
331
+ if (event.delta?.type === 'text_delta' && event.delta.text) {
332
+ streamer.append(event.delta.text);
333
+ }
334
+ if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
335
+ currentToolInput += event.delta.partial_json;
336
+ }
337
+ }
338
+
339
+ if (event?.type === 'content_block_stop') {
340
+ if (currentToolName) {
341
+ const isUserFacing = USER_FACING_TOOLS.has(currentToolName);
342
+ const isTaskTool = TASK_TOOLS.has(currentToolName);
343
+ const showTool = verbose || isUserFacing || isTaskTool;
344
+
345
+ if (showTool) {
346
+ // Task tools get a special visual render
347
+ const taskEmbed = isTaskTool
348
+ ? renderTaskToolEmbed(currentToolName, currentToolInput)
349
+ : null;
350
+
351
+ if (taskEmbed) {
352
+ await channel.send({
353
+ embeds: [taskEmbed],
354
+ components: [makeStopButton(sessionId)],
355
+ });
356
+ } else if (!isTaskTool) {
357
+ // Regular tool or user-facing tool — show raw JSON
358
+ const toolInput = currentToolInput;
359
+ const displayInput = toolInput.length > 1000
360
+ ? truncate(toolInput, 1000)
361
+ : toolInput;
362
+
363
+ const embed = new EmbedBuilder()
364
+ .setColor(isUserFacing ? 0xf39c12 : 0x3498db)
365
+ .setTitle(isUserFacing
366
+ ? `Waiting for input: ${currentToolName}`
367
+ : `Tool: ${currentToolName}`)
368
+ .setDescription(`\`\`\`json\n${displayInput}\n\`\`\``);
369
+
370
+ const components: ActionRowBuilder<ButtonBuilder>[] = [makeStopButton(sessionId)];
371
+
372
+ if (toolInput.length > 1000) {
373
+ const contentId = storeExpandable(toolInput);
374
+ components.unshift(
375
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
376
+ new ButtonBuilder()
377
+ .setCustomId(`expand:${contentId}`)
378
+ .setLabel('Show Full Input')
379
+ .setStyle(ButtonStyle.Secondary),
380
+ ),
381
+ );
382
+ }
383
+
384
+ await channel.send({ embeds: [embed], components });
385
+ }
386
+ }
387
+
388
+ lastFinishedToolName = currentToolName;
389
+ currentToolName = null;
390
+ currentToolInput = '';
391
+ }
392
+ }
393
+ }
394
+
395
+ if (message.type === 'user') {
396
+ const showResult = verbose || (lastFinishedToolName !== null && TASK_TOOLS.has(lastFinishedToolName));
397
+ if (!showResult) continue;
398
+
399
+ await streamer.finalize();
400
+
401
+ const content = (message as any).message?.content;
402
+ let resultText = '';
403
+ if (Array.isArray(content)) {
404
+ for (const block of content) {
405
+ if (block.type === 'tool_result' && block.content) {
406
+ if (typeof block.content === 'string') {
407
+ resultText += block.content;
408
+ } else if (Array.isArray(block.content)) {
409
+ for (const sub of block.content) {
410
+ if (sub.type === 'text') resultText += sub.text;
411
+ }
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ if (resultText) {
418
+ // TaskList/TaskGet results get a visual board embed
419
+ const isTaskResult = lastFinishedToolName !== null && TASK_TOOLS.has(lastFinishedToolName);
420
+ if (isTaskResult && !verbose) {
421
+ const boardEmbed = renderTaskListEmbed(resultText);
422
+ if (boardEmbed) {
423
+ await channel.send({
424
+ embeds: [boardEmbed],
425
+ components: [makeStopButton(sessionId)],
426
+ });
427
+ }
428
+ } else {
429
+ const displayResult = resultText.length > 1000
430
+ ? truncate(resultText, 1000)
431
+ : resultText;
432
+
433
+ const embed = new EmbedBuilder()
434
+ .setColor(0x1abc9c)
435
+ .setTitle('Tool Result')
436
+ .setDescription(`\`\`\`\n${displayResult}\n\`\`\``);
437
+
438
+ const components: ActionRowBuilder<ButtonBuilder>[] = [makeStopButton(sessionId)];
439
+
440
+ if (resultText.length > 1000) {
441
+ const contentId = storeExpandable(resultText);
442
+ components.unshift(
443
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
444
+ new ButtonBuilder()
445
+ .setCustomId(`expand:${contentId}`)
446
+ .setLabel('Show Full Output')
447
+ .setStyle(ButtonStyle.Secondary),
448
+ ),
449
+ );
450
+ }
451
+
452
+ await channel.send({ embeds: [embed], components });
453
+ }
454
+ }
455
+ }
456
+
457
+ if (message.type === 'result') {
458
+ const lastText = streamer.getText();
459
+ await streamer.finalize();
460
+
461
+ const result = message as any;
462
+ const isSuccess = result.subtype === 'success';
463
+ const cost = result.total_cost_usd?.toFixed(4) || '0.0000';
464
+ const duration = result.duration_ms
465
+ ? `${(result.duration_ms / 1000).toFixed(1)}s`
466
+ : 'unknown';
467
+ const turns = result.num_turns || 0;
468
+
469
+ const embed = new EmbedBuilder()
470
+ .setColor(isSuccess ? 0x2ecc71 : 0xe74c3c)
471
+ .setTitle(isSuccess ? 'Completed' : 'Error')
472
+ .addFields(
473
+ { name: 'Cost', value: `$${cost}`, inline: true },
474
+ { name: 'Duration', value: duration, inline: true },
475
+ { name: 'Turns', value: `${turns}`, inline: true },
476
+ );
477
+
478
+ if (result.session_id) {
479
+ embed.setFooter({ text: `Session: ${result.session_id}` });
480
+ }
481
+
482
+ if (!isSuccess && result.errors?.length) {
483
+ embed.setDescription(result.errors.join('\n'));
484
+ }
485
+
486
+ const components: (ActionRowBuilder<ButtonBuilder> | ActionRowBuilder<StringSelectMenuBuilder>)[] = [];
487
+
488
+ const checkText = lastText || (result.result as string) || '';
489
+ const options = detectNumberedOptions(checkText);
490
+ if (options) {
491
+ components.push(...makeOptionButtons(sessionId, options));
492
+ } else if (detectYesNoPrompt(checkText)) {
493
+ components.push(makeYesNoButtons(sessionId));
494
+ }
495
+
496
+ components.push(makeCompletionButtons(sessionId));
497
+
498
+ await channel.send({ embeds: [embed], components });
499
+ }
500
+ }
501
+ } catch (err: unknown) {
502
+ await streamer.finalize();
503
+
504
+ if ((err as Error).name !== 'AbortError') {
505
+ const embed = new EmbedBuilder()
506
+ .setColor(0xe74c3c)
507
+ .setTitle('Error')
508
+ .setDescription(`\`\`\`\n${(err as Error).message}\n\`\`\``);
509
+ await channel.send({ embeds: [embed] });
510
+ }
511
+ } finally {
512
+ clearInterval(typingInterval);
513
+ streamer.destroy();
514
+ }
515
+ }
@@ -0,0 +1,33 @@
1
+ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ const DATA_DIR = join(process.cwd(), '.discord-friends');
6
+
7
+ export class Store<T> {
8
+ private filePath: string;
9
+
10
+ constructor(filename: string) {
11
+ this.filePath = join(DATA_DIR, filename);
12
+ }
13
+
14
+ async read(): Promise<T | null> {
15
+ try {
16
+ const data = await readFile(this.filePath, 'utf-8');
17
+ return JSON.parse(data) as T;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ async write(data: T): Promise<void> {
24
+ const dir = dirname(this.filePath);
25
+ if (!existsSync(dir)) {
26
+ await mkdir(dir, { recursive: true });
27
+ }
28
+
29
+ const tmpPath = this.filePath + '.tmp';
30
+ await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
31
+ await rename(tmpPath, this.filePath);
32
+ }
33
+ }
@@ -0,0 +1,165 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { Store } from './persistence.ts';
5
+ import type { Project, McpServer } from './types.ts';
6
+
7
+ const projectStore = new Store<Record<string, Project>>('projects.json');
8
+
9
+ let projects: Record<string, Project> = {};
10
+
11
+ export async function loadProjects(): Promise<void> {
12
+ projects = (await projectStore.read()) || {};
13
+ }
14
+
15
+ async function saveProjects(): Promise<void> {
16
+ await projectStore.write(projects);
17
+ }
18
+
19
+ export function getProject(name: string): Project | undefined {
20
+ return projects[name];
21
+ }
22
+
23
+ export function getAllProjects(): Record<string, Project> {
24
+ return { ...projects };
25
+ }
26
+
27
+ export function getOrCreateProject(name: string, directory: string, categoryId: string): Project {
28
+ if (!projects[name]) {
29
+ projects[name] = {
30
+ name,
31
+ directory,
32
+ categoryId,
33
+ skills: {},
34
+ mcpServers: [],
35
+ };
36
+ saveProjects();
37
+ }
38
+ return projects[name];
39
+ }
40
+
41
+ export function updateProjectCategory(name: string, categoryId: string, logChannelId?: string): void {
42
+ const project = projects[name];
43
+ if (project) {
44
+ project.categoryId = categoryId;
45
+ if (logChannelId) project.logChannelId = logChannelId;
46
+ saveProjects();
47
+ }
48
+ }
49
+
50
+ // Personality
51
+
52
+ export function setPersonality(projectName: string, prompt: string): boolean {
53
+ const project = projects[projectName];
54
+ if (!project) return false;
55
+ project.personality = prompt;
56
+ saveProjects();
57
+ return true;
58
+ }
59
+
60
+ export function getPersonality(projectName: string): string | undefined {
61
+ return projects[projectName]?.personality;
62
+ }
63
+
64
+ export function clearPersonality(projectName: string): boolean {
65
+ const project = projects[projectName];
66
+ if (!project) return false;
67
+ delete project.personality;
68
+ saveProjects();
69
+ return true;
70
+ }
71
+
72
+ // Skills
73
+
74
+ export function addSkill(projectName: string, name: string, prompt: string): boolean {
75
+ const project = projects[projectName];
76
+ if (!project) return false;
77
+ project.skills[name] = prompt;
78
+ saveProjects();
79
+ return true;
80
+ }
81
+
82
+ export function removeSkill(projectName: string, name: string): boolean {
83
+ const project = projects[projectName];
84
+ if (!project || !project.skills[name]) return false;
85
+ delete project.skills[name];
86
+ saveProjects();
87
+ return true;
88
+ }
89
+
90
+ export function getSkills(projectName: string): Record<string, string> {
91
+ return projects[projectName]?.skills || {};
92
+ }
93
+
94
+ export function executeSkill(projectName: string, skillName: string, input?: string): string | null {
95
+ const project = projects[projectName];
96
+ if (!project) return null;
97
+ const template = project.skills[skillName];
98
+ if (!template) return null;
99
+ return input ? template.replace(/\{input\}/g, input) : template.replace(/\{input\}/g, '');
100
+ }
101
+
102
+ // MCP Servers
103
+
104
+ export async function addMcpServer(projectDir: string, projectName: string, server: McpServer): Promise<boolean> {
105
+ const project = projects[projectName];
106
+ if (!project) return false;
107
+
108
+ // Update in-memory
109
+ const existing = project.mcpServers.findIndex(s => s.name === server.name);
110
+ if (existing >= 0) {
111
+ project.mcpServers[existing] = server;
112
+ } else {
113
+ project.mcpServers.push(server);
114
+ }
115
+ await saveProjects();
116
+
117
+ // Write to project's .mcp.json so Claude Code picks it up natively
118
+ await writeMcpJson(projectDir, project.mcpServers);
119
+ return true;
120
+ }
121
+
122
+ export async function removeMcpServer(projectDir: string, projectName: string, name: string): Promise<boolean> {
123
+ const project = projects[projectName];
124
+ if (!project) return false;
125
+
126
+ const idx = project.mcpServers.findIndex(s => s.name === name);
127
+ if (idx < 0) return false;
128
+
129
+ project.mcpServers.splice(idx, 1);
130
+ await saveProjects();
131
+ await writeMcpJson(projectDir, project.mcpServers);
132
+ return true;
133
+ }
134
+
135
+ export function listMcpServers(projectName: string): McpServer[] {
136
+ return projects[projectName]?.mcpServers || [];
137
+ }
138
+
139
+ async function writeMcpJson(projectDir: string, servers: McpServer[]): Promise<void> {
140
+ const mcpPath = join(projectDir, '.mcp.json');
141
+
142
+ // Read existing .mcp.json if it exists (preserve non-bot entries)
143
+ let mcpConfig: Record<string, unknown> = {};
144
+ try {
145
+ if (existsSync(mcpPath)) {
146
+ const existing = await readFile(mcpPath, 'utf-8');
147
+ mcpConfig = JSON.parse(existing);
148
+ }
149
+ } catch {
150
+ // Start fresh
151
+ }
152
+
153
+ // Build mcpServers section
154
+ const mcpServers: Record<string, { command: string; args?: string[]; env?: Record<string, string> }> = {};
155
+ for (const server of servers) {
156
+ mcpServers[server.name] = {
157
+ command: server.command,
158
+ ...(server.args?.length ? { args: server.args } : {}),
159
+ ...(server.env ? { env: server.env } : {}),
160
+ };
161
+ }
162
+
163
+ mcpConfig.mcpServers = mcpServers;
164
+ await writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
165
+ }