bunosh 0.4.1 → 0.4.6

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,575 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import {
4
+ CallToolRequestSchema,
5
+ ErrorCode,
6
+ ListToolsRequestSchema,
7
+ McpError,
8
+ } from '@modelcontextprotocol/sdk/types.js';
9
+ import { processCommands } from './program.js';
10
+
11
+ // Global state for managing conversations and pending questions
12
+ const conversations = new Map();
13
+
14
+ /**
15
+ * Convert BunoshCommand objects to MCP tools
16
+ * @param {Array<BunoshCommand>} parsedCommands - Array of parsed BunoshCommand objects
17
+ * @returns {Object} MCP tools object
18
+ */
19
+ function createMcpTools(parsedCommands) {
20
+ const tools = {};
21
+
22
+ parsedCommands.forEach((command) => {
23
+ // Create input schema for the tool
24
+ const properties = {};
25
+ const required = [];
26
+
27
+ // Add positional arguments
28
+ Object.entries(command.args).forEach(([argName, defaultValue]) => {
29
+ properties[argName] = {
30
+ type: 'string',
31
+ description: `Argument: ${argName}`,
32
+ };
33
+ if (defaultValue === undefined) {
34
+ required.push(argName);
35
+ }
36
+ });
37
+
38
+ // Add options
39
+ Object.entries(command.opts).forEach(([optName, defaultValue]) => {
40
+ properties[optName] = {
41
+ type: typeof defaultValue === 'boolean' ? 'boolean' : 'string',
42
+ description: `Option: --${optName}`,
43
+ };
44
+ // Don't make options required as they have defaults
45
+ });
46
+
47
+ tools[command.fullName] = {
48
+ name: command.fullName,
49
+ description: command.comment || `Bunosh command: ${command.fullName}`,
50
+ inputSchema: {
51
+ type: 'object',
52
+ properties,
53
+ required: required.length > 0 ? required : undefined,
54
+ },
55
+ };
56
+ });
57
+
58
+ // Add the answer tool for handling interactive questions
59
+ tools.answer = {
60
+ name: 'answer',
61
+ description: 'Provide an answer to a question asked by a Bunosh command',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ conversationId: {
66
+ type: 'string',
67
+ description: 'The ID of the conversation to answer',
68
+ },
69
+ questionId: {
70
+ type: 'string',
71
+ description: 'The ID of the question to answer',
72
+ },
73
+ answer: {
74
+ oneOf: [
75
+ { type: 'string' },
76
+ { type: 'boolean' },
77
+ { type: 'array', items: { type: 'string' } }
78
+ ],
79
+ description: 'The answer to provide - can be string, boolean, or array of strings for multiple choice',
80
+ }
81
+ },
82
+ required: ['conversationId', 'questionId', 'answer'],
83
+ },
84
+ };
85
+
86
+ return tools;
87
+ }
88
+
89
+ /**
90
+ * Create a conversation state for tracking multi-question interactions
91
+ * @param {string} conversationId - Unique conversation identifier
92
+ * @param {BunoshCommand} command - The command being executed
93
+ * @param {Object} args - Command arguments
94
+ * @returns {Object} Conversation state object
95
+ */
96
+ function createConversation(conversationId, command, args) {
97
+ const conversation = {
98
+ id: conversationId,
99
+ command,
100
+ args,
101
+ questions: [], // List of questions asked in this conversation
102
+ answers: {}, // Map of questionId to answer
103
+ currentQuestion: null,
104
+ output: [],
105
+ isComplete: false,
106
+ result: null,
107
+ startTime: Date.now()
108
+ };
109
+
110
+ conversations.set(conversationId, conversation);
111
+ return conversation;
112
+ }
113
+
114
+ /**
115
+ * Create an interactive ask function that works with conversation state
116
+ * @param {Object} conversation - The conversation state
117
+ * @returns {Function} Interactive ask function
118
+ */
119
+ function createConversationAskFunction(conversation) {
120
+ return async (question, defaultValueOrOptions = {}, options = {}) => {
121
+ // Check if we already have an answer for this exact question text
122
+ // Look for ANY question with the same text that has an answer
123
+ // Important: Use 'in' operator to check if key exists, even if the value is falsy (like false)
124
+ const existingQuestionWithAnswer = conversation.questions.find(q => q.message === question && q.id in conversation.answers);
125
+
126
+ if (existingQuestionWithAnswer) {
127
+ return conversation.answers[existingQuestionWithAnswer.id];
128
+ }
129
+
130
+ // Generate a unique question ID
131
+ const questionId = `q_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
132
+
133
+ // Parse the question options to understand what type of answer is expected
134
+ let questionOptions = {};
135
+ let expectedType = 'string';
136
+
137
+ // Smart parameter detection (copied from original ask function)
138
+ if (defaultValueOrOptions !== null && typeof defaultValueOrOptions !== 'object') {
139
+ questionOptions.default = defaultValueOrOptions;
140
+ questionOptions = { ...questionOptions, ...options };
141
+
142
+ if (typeof defaultValueOrOptions === 'boolean') {
143
+ expectedType = 'boolean';
144
+ }
145
+ } else if (Array.isArray(defaultValueOrOptions)) {
146
+ questionOptions.choices = defaultValueOrOptions;
147
+ questionOptions = { ...questionOptions, ...options };
148
+ expectedType = 'choices';
149
+ } else {
150
+ questionOptions = { ...defaultValueOrOptions, ...options };
151
+ if (questionOptions.type === 'confirm') {
152
+ expectedType = 'boolean';
153
+ } else if (questionOptions.choices) {
154
+ expectedType = 'choices';
155
+ }
156
+ }
157
+
158
+ // Store the question data
159
+ const questionData = {
160
+ id: questionId,
161
+ conversationId: conversation.id,
162
+ message: question,
163
+ options: questionOptions,
164
+ expectedType,
165
+ choices: questionOptions.choices || null,
166
+ multiple: questionOptions.multiple || false,
167
+ timestamp: Date.now()
168
+ };
169
+
170
+ // Add question to conversation (only if it's not already there)
171
+ // Check if this exact question already exists
172
+ const existingQuestion = conversation.questions.find(q => q.message === question);
173
+ if (!existingQuestion) {
174
+ conversation.questions.push(questionData);
175
+ conversation.currentQuestion = questionData;
176
+ } else {
177
+ conversation.currentQuestion = existingQuestion;
178
+ }
179
+
180
+ // Throw a special error to signal the MCP server that a question needs to be answered
181
+ const questionError = new Error(`INTERACTIVE_QUESTION:${JSON.stringify(questionData)}`);
182
+ questionError.code = 'INTERACTIVE_QUESTION';
183
+ throw questionError;
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Execute a Bunosh command with conversation-based multi-question support
189
+ * @param {BunoshCommand} command - Parsed BunoshCommand object
190
+ * @param {Object} args - Arguments from MCP tool call
191
+ * @param {string} [conversationId] - ID for resuming an existing conversation
192
+ * @returns {Promise<string>} Command output with task information
193
+ */
194
+ async function executeBunoshCommand(command, args, conversationId = null) {
195
+ // Import task functions to access executed tasks
196
+ const { tasksExecuted } = await import('./task.js');
197
+
198
+ // Check if we're resuming an existing conversation
199
+ if (conversationId && conversations.has(conversationId)) {
200
+ const conversation = conversations.get(conversationId);
201
+
202
+ // Re-execute the command with the same conversation state
203
+ // The ask function will return pre-provided answers for already-answered questions
204
+ return executeCommandWithConversation(conversation);
205
+ }
206
+
207
+ // Create new conversation
208
+ const newConversationId = conversationId || `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
209
+ const conversation = createConversation(newConversationId, command, args);
210
+
211
+ return executeCommandWithConversation(conversation);
212
+ }
213
+
214
+ /**
215
+ * Execute a command within a conversation context
216
+ * @param {Object} conversation - The conversation state
217
+ * @returns {Promise<string>} Command output or next question
218
+ */
219
+ async function executeCommandWithConversation(conversation) {
220
+ // Import task functions to access executed tasks
221
+ const { tasksExecuted } = await import('./task.js');
222
+
223
+ // Capture stdout/stderr
224
+ const originalConsoleLog = console.log;
225
+ const originalConsoleError = console.error;
226
+ const output = [];
227
+
228
+ console.log = (...consoleArgs) => {
229
+ const message = consoleArgs.join(' ');
230
+ output.push(message);
231
+ conversation.output.push(message);
232
+ };
233
+
234
+ console.error = (...consoleArgs) => {
235
+ const message = `Error: ${consoleArgs.join(' ')}`;
236
+ output.push(message);
237
+ conversation.output.push(message);
238
+ };
239
+
240
+ try {
241
+ // Set up conversation-based ask function
242
+ globalThis._mcpAskFunction = createConversationAskFunction(conversation);
243
+
244
+ // Build arguments array using the parsed command information
245
+ const cmdArgs = [];
246
+ const optionsObject = {};
247
+
248
+ // Add positional arguments
249
+ Object.keys(conversation.command.args).forEach((argName) => {
250
+ if (conversation.args[argName] !== undefined) {
251
+ cmdArgs.push(conversation.args[argName]);
252
+ } else {
253
+ // Use default value from parsed command
254
+ cmdArgs.push(conversation.command.args[argName]);
255
+ }
256
+ });
257
+
258
+ // Handle options object
259
+ const optionKeys = Object.keys(conversation.command.opts);
260
+ if (optionKeys.length > 0) {
261
+ optionKeys.forEach((optName) => {
262
+ if (conversation.args[optName] !== undefined) {
263
+ optionsObject[optName] = conversation.args[optName];
264
+ } else {
265
+ // Use default value from parsed command
266
+ optionsObject[optName] = conversation.command.opts[optName];
267
+ }
268
+ });
269
+ cmdArgs.push(optionsObject);
270
+ }
271
+
272
+ // Execute the command
273
+ const result = await conversation.command.function(...cmdArgs);
274
+
275
+ // Mark conversation as complete
276
+ conversation.isComplete = true;
277
+ conversation.result = result;
278
+
279
+ // Restore console and cleanup
280
+ console.log = originalConsoleLog;
281
+ console.error = originalConsoleError;
282
+ delete globalThis._mcpAskFunction;
283
+
284
+ // Get tasks executed during this command
285
+ const executedTasks = tasksExecuted.filter(task =>
286
+ task.startTime && task.startTime >= conversation.startTime
287
+ );
288
+
289
+ // Format response with task information
290
+ const response = {
291
+ output: output.join('\n'),
292
+ result: result,
293
+ tasks: executedTasks.map(task => ({
294
+ name: task.name,
295
+ status: task.status,
296
+ duration: task.duration,
297
+ output: task.result?.output || null
298
+ }))
299
+ };
300
+
301
+ // Add direct output if result is a string
302
+ if (result && typeof result === 'string') {
303
+ response.output += (response.output ? '\n' : '') + result;
304
+ }
305
+
306
+ // Clean up completed conversation
307
+ conversations.delete(conversation.id);
308
+
309
+ // If no tasks were tracked, still return the output
310
+ if (executedTasks.length === 0) {
311
+ return response.output || 'Command executed successfully';
312
+ }
313
+
314
+ // Return JSON response with task information
315
+ return JSON.stringify(response, null, 2);
316
+ } catch (error) {
317
+ // Check if this is an interactive question
318
+ if (error.code === 'INTERACTIVE_QUESTION') {
319
+ // Extract question data from error message
320
+ const questionData = JSON.parse(error.message.split('INTERACTIVE_QUESTION:')[1]);
321
+
322
+ // Format the question for the AI to answer
323
+ let questionPrompt = `❓ **Question:** ${questionData.message}\n\n`;
324
+
325
+ if (questionData.expectedType === 'boolean') {
326
+ questionPrompt += `Please provide a boolean answer (true/false).`;
327
+ } else if (questionData.expectedType === 'choices') {
328
+ if (questionData.multiple) {
329
+ questionPrompt += `Please select one or more choices from: ${questionData.choices.join(', ')}\n`;
330
+ questionPrompt += `Provide the answer as an array of strings, e.g., ["option1", "option2"]`;
331
+ } else {
332
+ questionPrompt += `Please choose one option from: ${questionData.choices.join(', ')}\n`;
333
+ questionPrompt += `Provide the answer as a string (the exact choice).`;
334
+ }
335
+ } else {
336
+ questionPrompt += `Please provide a string answer.`;
337
+ if (questionData.options.default !== undefined) {
338
+ questionPrompt += ` Default value: ${questionData.options.default}`;
339
+ }
340
+ }
341
+
342
+ questionPrompt += `\n\nUse the \`answer\` tool to respond with:\n`;
343
+ questionPrompt += `- conversationId: "${questionData.conversationId}"\n`;
344
+ questionPrompt += `- questionId: "${questionData.id}"\n`;
345
+ questionPrompt += `- answer: <your answer>`;
346
+
347
+ // Restore console and cleanup for now
348
+ console.log = originalConsoleLog;
349
+ console.error = originalConsoleError;
350
+ delete globalThis._mcpAskFunction;
351
+
352
+ // Return special response that prompts the AI to use the answer tool
353
+ return JSON.stringify({
354
+ interactive: true,
355
+ conversationId: questionData.conversationId,
356
+ questionId: questionData.id,
357
+ question: questionData.message,
358
+ prompt: questionPrompt,
359
+ expectedType: questionData.expectedType,
360
+ choices: questionData.choices,
361
+ multiple: questionData.multiple,
362
+ default: questionData.options.default,
363
+ progress: `Question ${conversation.questions.length} of ${conversation.questions.length + 1}`
364
+ }, null, 2);
365
+ }
366
+
367
+ // Handle other errors
368
+ conversation.error = error;
369
+
370
+ // Get tasks executed before the error
371
+ const executedTasks = tasksExecuted.filter(task =>
372
+ task.startTime && task.startTime >= conversation.startTime
373
+ );
374
+
375
+ // Format error response with task information
376
+ const errorResponse = {
377
+ output: output.join('\n'),
378
+ error: error.message,
379
+ tasks: executedTasks.map(task => ({
380
+ name: task.name,
381
+ status: task.status,
382
+ duration: task.duration,
383
+ output: task.result?.output || null
384
+ }))
385
+ };
386
+
387
+ // Clean up conversation state
388
+ conversations.delete(conversation.id);
389
+
390
+ throw new Error(`Command execution failed: ${error.message}\n${JSON.stringify(errorResponse, null, 2)}`);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Handle the answer tool for responding to interactive questions
396
+ * @param {Object} args - Tool arguments containing conversationId, questionId, and answer
397
+ * @returns {Object} MCP response
398
+ */
399
+ function handleAnswerTool(args) {
400
+ const { conversationId, questionId, answer } = args;
401
+
402
+ if (!conversationId || !questionId) {
403
+ throw new McpError(
404
+ ErrorCode.InvalidParams,
405
+ 'Missing required parameters: conversationId and questionId'
406
+ );
407
+ }
408
+
409
+ const conversation = conversations.get(conversationId);
410
+ if (!conversation) {
411
+ throw new McpError(
412
+ ErrorCode.InvalidParams,
413
+ `No conversation found with ID: ${conversationId}`
414
+ );
415
+ }
416
+
417
+ // Validate the answer based on question expectations
418
+ let validatedAnswer = answer;
419
+ const question = conversation.questions.find(q => q.id === questionId);
420
+
421
+ if (!question) {
422
+ throw new McpError(
423
+ ErrorCode.InvalidParams,
424
+ `No question found with ID: ${questionId} in conversation: ${conversationId}`
425
+ );
426
+ }
427
+
428
+ if (question.choices && Array.isArray(answer)) {
429
+ // For multiple choice questions, validate that all choices are valid
430
+ const invalidChoices = answer.filter(choice => !question.choices.includes(choice));
431
+ if (invalidChoices.length > 0) {
432
+ throw new McpError(
433
+ ErrorCode.InvalidParams,
434
+ `Invalid choices: ${invalidChoices.join(', ')}. Valid choices are: ${question.choices.join(', ')}`
435
+ );
436
+ }
437
+ }
438
+
439
+ // Store the answer
440
+ conversation.answers[questionId] = validatedAnswer;
441
+
442
+ return {
443
+ content: [
444
+ {
445
+ type: 'text',
446
+ text: `Answer received for question: ${question.message}`,
447
+ },
448
+ ],
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Continue a conversation after receiving an answer
454
+ * @param {string} conversationId - The conversation ID to continue
455
+ * @returns {Promise<string>} Command output or next question
456
+ */
457
+ async function continueConversation(conversationId) {
458
+ const conversation = conversations.get(conversationId);
459
+ if (!conversation) {
460
+ throw new Error(`No conversation found with ID: ${conversationId}`);
461
+ }
462
+
463
+ // Execute the command again, but this time it will use pre-provided answers
464
+ return executeCommandWithConversation(conversation);
465
+ }
466
+
467
+ /**
468
+ * Create MCP server from Bunosh commands
469
+ * @param {Object} commands - Commands object from Bunoshfile
470
+ * @param {Object} sources - Sources object containing comments and metadata
471
+ * @returns {Server} Configured MCP server
472
+ */
473
+ export function createMcpServer(commands, sources) {
474
+ const server = new Server(
475
+ {
476
+ name: 'bunosh',
477
+ version: '0.1.5',
478
+ },
479
+ {
480
+ capabilities: {
481
+ tools: {},
482
+ },
483
+ }
484
+ );
485
+
486
+ // Process commands using the existing logic from program.js
487
+ const parsedCommands = processCommands(commands, sources);
488
+ const tools = createMcpTools(parsedCommands);
489
+
490
+ // Create a map for quick command lookup
491
+ const commandMap = new Map(parsedCommands.map(cmd => [cmd.fullName, cmd]));
492
+
493
+ // List tools handler
494
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
495
+ return {
496
+ tools: Object.values(tools),
497
+ };
498
+ });
499
+
500
+ // Call tool handler
501
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
502
+ const { name, arguments: args } = request.params;
503
+
504
+ // Handle the answer tool separately
505
+ if (name === 'answer') {
506
+ const result = handleAnswerTool(args);
507
+
508
+ // After providing an answer, try to continue the conversation
509
+ const { conversationId } = args;
510
+ if (conversationId) {
511
+ try {
512
+ const continuationResult = await continueConversation(conversationId);
513
+ return {
514
+ content: [
515
+ result.content[0], // The "Answer received" message
516
+ {
517
+ type: 'text',
518
+ text: continuationResult,
519
+ },
520
+ ],
521
+ };
522
+ } catch (error) {
523
+ // If continuation fails, just return the answer confirmation
524
+ return result;
525
+ }
526
+ }
527
+
528
+ return result;
529
+ }
530
+
531
+ const command = commandMap.get(name);
532
+ if (!command) {
533
+ throw new McpError(
534
+ ErrorCode.MethodNotFound,
535
+ `Unknown command: ${name}`
536
+ );
537
+ }
538
+
539
+ try {
540
+ // Check if this is a continuation of a previous conversation
541
+ const conversationId = args.conversationId;
542
+
543
+ // Execute the Bunosh command with provided arguments
544
+ const result = await executeBunoshCommand(command, args, conversationId);
545
+
546
+ return {
547
+ content: [
548
+ {
549
+ type: 'text',
550
+ text: result,
551
+ },
552
+ ],
553
+ };
554
+ } catch (error) {
555
+ throw new McpError(
556
+ ErrorCode.InternalError,
557
+ `Error executing command ${name}: ${error.message}`
558
+ );
559
+ }
560
+ });
561
+
562
+ return server;
563
+ }
564
+
565
+ /**
566
+ * Start MCP server with stdio transport
567
+ * @param {Server} server - MCP server instance
568
+ */
569
+ export async function startMcpServer(server) {
570
+ const transport = new StdioServerTransport();
571
+ await server.connect(transport);
572
+
573
+ // Server is now running and listening for MCP protocol messages
574
+ // No need to return anything as it will handle communication via stdio
575
+ }
package/src/printer.js CHANGED
@@ -93,7 +93,7 @@ export class Printer {
93
93
 
94
94
  // Add task prefix for parallel tasks on output lines
95
95
  const prefix = this.taskId ? getTaskPrefix(this.taskId) : '';
96
- const prefixedLine = prefix ? `${chalk.gray.dim(prefix)} ${line}` : line;
96
+ const prefixedLine = prefix ? `${prefix} ${line}` : line;
97
97
 
98
98
  const formattedLine = this.formatter.formatOutput(prefixedLine, isError);
99
99
  if (formattedLine) {