grokcodecli 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.
Files changed (99) hide show
  1. package/.claude/settings.local.json +32 -0
  2. package/README.md +1464 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +61 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/commands/loader.d.ts +34 -0
  8. package/dist/commands/loader.d.ts.map +1 -0
  9. package/dist/commands/loader.js +192 -0
  10. package/dist/commands/loader.js.map +1 -0
  11. package/dist/config/manager.d.ts +21 -0
  12. package/dist/config/manager.d.ts.map +1 -0
  13. package/dist/config/manager.js +203 -0
  14. package/dist/config/manager.js.map +1 -0
  15. package/dist/conversation/chat.d.ts +50 -0
  16. package/dist/conversation/chat.d.ts.map +1 -0
  17. package/dist/conversation/chat.js +1145 -0
  18. package/dist/conversation/chat.js.map +1 -0
  19. package/dist/conversation/history.d.ts +24 -0
  20. package/dist/conversation/history.d.ts.map +1 -0
  21. package/dist/conversation/history.js +103 -0
  22. package/dist/conversation/history.js.map +1 -0
  23. package/dist/grok/client.d.ts +86 -0
  24. package/dist/grok/client.d.ts.map +1 -0
  25. package/dist/grok/client.js +106 -0
  26. package/dist/grok/client.js.map +1 -0
  27. package/dist/index.d.ts +7 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/permissions/manager.d.ts +26 -0
  32. package/dist/permissions/manager.d.ts.map +1 -0
  33. package/dist/permissions/manager.js +170 -0
  34. package/dist/permissions/manager.js.map +1 -0
  35. package/dist/tools/bash.d.ts +8 -0
  36. package/dist/tools/bash.d.ts.map +1 -0
  37. package/dist/tools/bash.js +102 -0
  38. package/dist/tools/bash.js.map +1 -0
  39. package/dist/tools/edit.d.ts +9 -0
  40. package/dist/tools/edit.d.ts.map +1 -0
  41. package/dist/tools/edit.js +61 -0
  42. package/dist/tools/edit.js.map +1 -0
  43. package/dist/tools/glob.d.ts +7 -0
  44. package/dist/tools/glob.d.ts.map +1 -0
  45. package/dist/tools/glob.js +38 -0
  46. package/dist/tools/glob.js.map +1 -0
  47. package/dist/tools/grep.d.ts +8 -0
  48. package/dist/tools/grep.d.ts.map +1 -0
  49. package/dist/tools/grep.js +78 -0
  50. package/dist/tools/grep.js.map +1 -0
  51. package/dist/tools/read.d.ts +8 -0
  52. package/dist/tools/read.d.ts.map +1 -0
  53. package/dist/tools/read.js +96 -0
  54. package/dist/tools/read.js.map +1 -0
  55. package/dist/tools/registry.d.ts +42 -0
  56. package/dist/tools/registry.d.ts.map +1 -0
  57. package/dist/tools/registry.js +230 -0
  58. package/dist/tools/registry.js.map +1 -0
  59. package/dist/tools/webfetch.d.ts +10 -0
  60. package/dist/tools/webfetch.d.ts.map +1 -0
  61. package/dist/tools/webfetch.js +108 -0
  62. package/dist/tools/webfetch.js.map +1 -0
  63. package/dist/tools/websearch.d.ts +7 -0
  64. package/dist/tools/websearch.d.ts.map +1 -0
  65. package/dist/tools/websearch.js +180 -0
  66. package/dist/tools/websearch.js.map +1 -0
  67. package/dist/tools/write.d.ts +7 -0
  68. package/dist/tools/write.d.ts.map +1 -0
  69. package/dist/tools/write.js +80 -0
  70. package/dist/tools/write.js.map +1 -0
  71. package/dist/utils/security.d.ts +36 -0
  72. package/dist/utils/security.d.ts.map +1 -0
  73. package/dist/utils/security.js +227 -0
  74. package/dist/utils/security.js.map +1 -0
  75. package/dist/utils/ui.d.ts +49 -0
  76. package/dist/utils/ui.d.ts.map +1 -0
  77. package/dist/utils/ui.js +302 -0
  78. package/dist/utils/ui.js.map +1 -0
  79. package/package.json +45 -0
  80. package/src/cli.ts +68 -0
  81. package/src/commands/loader.ts +244 -0
  82. package/src/config/manager.ts +239 -0
  83. package/src/conversation/chat.ts +1294 -0
  84. package/src/conversation/history.ts +131 -0
  85. package/src/grok/client.ts +192 -0
  86. package/src/index.ts +8 -0
  87. package/src/permissions/manager.ts +208 -0
  88. package/src/tools/bash.ts +119 -0
  89. package/src/tools/edit.ts +73 -0
  90. package/src/tools/glob.ts +49 -0
  91. package/src/tools/grep.ts +96 -0
  92. package/src/tools/read.ts +116 -0
  93. package/src/tools/registry.ts +248 -0
  94. package/src/tools/webfetch.ts +127 -0
  95. package/src/tools/websearch.ts +219 -0
  96. package/src/tools/write.ts +94 -0
  97. package/src/utils/security.ts +259 -0
  98. package/src/utils/ui.ts +382 -0
  99. package/tsconfig.json +22 -0
@@ -0,0 +1,131 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { GrokMessage } from '../grok/client.js';
5
+
6
+ export interface ConversationSession {
7
+ id: string;
8
+ title: string;
9
+ createdAt: string;
10
+ updatedAt: string;
11
+ workingDirectory: string;
12
+ messages: GrokMessage[];
13
+ }
14
+
15
+ export class HistoryManager {
16
+ private historyDir: string;
17
+
18
+ constructor() {
19
+ this.historyDir = path.join(os.homedir(), '.config', 'grokcodecli', 'history');
20
+ }
21
+
22
+ private async ensureDir(): Promise<void> {
23
+ await fs.mkdir(this.historyDir, { recursive: true });
24
+ }
25
+
26
+ private generateId(): string {
27
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
28
+ }
29
+
30
+ private getSessionPath(id: string): string {
31
+ return path.join(this.historyDir, `${id}.json`);
32
+ }
33
+
34
+ async createSession(workingDirectory: string): Promise<ConversationSession> {
35
+ await this.ensureDir();
36
+
37
+ const session: ConversationSession = {
38
+ id: this.generateId(),
39
+ title: 'New Conversation',
40
+ createdAt: new Date().toISOString(),
41
+ updatedAt: new Date().toISOString(),
42
+ workingDirectory,
43
+ messages: [],
44
+ };
45
+
46
+ await this.saveSession(session);
47
+ return session;
48
+ }
49
+
50
+ async saveSession(session: ConversationSession): Promise<void> {
51
+ await this.ensureDir();
52
+ session.updatedAt = new Date().toISOString();
53
+
54
+ // Generate title from first user message if not set
55
+ if (session.title === 'New Conversation' && session.messages.length > 1) {
56
+ const firstUserMsg = session.messages.find(m => m.role === 'user');
57
+ if (firstUserMsg) {
58
+ session.title = firstUserMsg.content.slice(0, 50) + (firstUserMsg.content.length > 50 ? '...' : '');
59
+ }
60
+ }
61
+
62
+ await fs.writeFile(
63
+ this.getSessionPath(session.id),
64
+ JSON.stringify(session, null, 2),
65
+ 'utf-8'
66
+ );
67
+ }
68
+
69
+ async loadSession(id: string): Promise<ConversationSession | null> {
70
+ try {
71
+ const content = await fs.readFile(this.getSessionPath(id), 'utf-8');
72
+ return JSON.parse(content) as ConversationSession;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ async listSessions(limit: number = 20): Promise<ConversationSession[]> {
79
+ await this.ensureDir();
80
+
81
+ try {
82
+ const files = await fs.readdir(this.historyDir);
83
+ const sessions: ConversationSession[] = [];
84
+
85
+ for (const file of files) {
86
+ if (!file.endsWith('.json')) continue;
87
+
88
+ try {
89
+ const content = await fs.readFile(path.join(this.historyDir, file), 'utf-8');
90
+ sessions.push(JSON.parse(content));
91
+ } catch {
92
+ // Skip invalid files
93
+ }
94
+ }
95
+
96
+ // Sort by updatedAt, newest first
97
+ sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
98
+
99
+ return sessions.slice(0, limit);
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ async deleteSession(id: string): Promise<boolean> {
106
+ try {
107
+ await fs.unlink(this.getSessionPath(id));
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ async getLastSession(): Promise<ConversationSession | null> {
115
+ const sessions = await this.listSessions(1);
116
+ return sessions.length > 0 ? sessions[0] : null;
117
+ }
118
+
119
+ async clearAll(): Promise<void> {
120
+ try {
121
+ const files = await fs.readdir(this.historyDir);
122
+ for (const file of files) {
123
+ if (file.endsWith('.json')) {
124
+ await fs.unlink(path.join(this.historyDir, file));
125
+ }
126
+ }
127
+ } catch {
128
+ // Ignore errors
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Grok API Client
3
+ *
4
+ * xAI's Grok API is OpenAI-compatible, so we use similar patterns.
5
+ * API Base: https://api.x.ai/v1
6
+ */
7
+
8
+ export interface GrokMessage {
9
+ role: 'system' | 'user' | 'assistant' | 'tool';
10
+ content: string;
11
+ tool_calls?: ToolCall[];
12
+ tool_call_id?: string;
13
+ }
14
+
15
+ export interface ToolCall {
16
+ id: string;
17
+ type: 'function';
18
+ function: {
19
+ name: string;
20
+ arguments: string;
21
+ };
22
+ }
23
+
24
+ export interface Tool {
25
+ type: 'function';
26
+ function: {
27
+ name: string;
28
+ description: string;
29
+ parameters: Record<string, unknown>;
30
+ };
31
+ }
32
+
33
+ export interface GrokCompletionRequest {
34
+ model: string;
35
+ messages: GrokMessage[];
36
+ tools?: Tool[];
37
+ tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
38
+ stream?: boolean;
39
+ temperature?: number;
40
+ max_tokens?: number;
41
+ }
42
+
43
+ export interface GrokCompletionResponse {
44
+ id: string;
45
+ object: string;
46
+ created: number;
47
+ model: string;
48
+ choices: {
49
+ index: number;
50
+ message: GrokMessage;
51
+ finish_reason: 'stop' | 'tool_calls' | 'length';
52
+ }[];
53
+ usage: {
54
+ prompt_tokens: number;
55
+ completion_tokens: number;
56
+ total_tokens: number;
57
+ };
58
+ }
59
+
60
+ export interface StreamChunk {
61
+ id: string;
62
+ object: string;
63
+ created: number;
64
+ model: string;
65
+ choices: {
66
+ index: number;
67
+ delta: Partial<GrokMessage>;
68
+ finish_reason: 'stop' | 'tool_calls' | 'length' | null;
69
+ }[];
70
+ }
71
+
72
+ export class GrokClient {
73
+ private apiKey: string;
74
+ private baseUrl: string = 'https://api.x.ai/v1';
75
+ public model: string;
76
+
77
+ constructor(apiKey: string, model: string = 'grok-4-0709') {
78
+ this.apiKey = apiKey;
79
+ this.model = model;
80
+ }
81
+
82
+ async chat(
83
+ messages: GrokMessage[],
84
+ tools?: Tool[],
85
+ options: { stream?: boolean; temperature?: number; maxTokens?: number } = {}
86
+ ): Promise<GrokCompletionResponse> {
87
+ const request: GrokCompletionRequest = {
88
+ model: this.model,
89
+ messages,
90
+ stream: false,
91
+ temperature: options.temperature ?? 0.7,
92
+ max_tokens: options.maxTokens ?? 16384,
93
+ };
94
+
95
+ if (tools && tools.length > 0) {
96
+ request.tools = tools;
97
+ request.tool_choice = 'auto';
98
+ }
99
+
100
+ const response = await fetch(`${this.baseUrl}/chat/completions`, {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ 'Authorization': `Bearer ${this.apiKey}`,
105
+ },
106
+ body: JSON.stringify(request),
107
+ });
108
+
109
+ if (!response.ok) {
110
+ const error = await response.text();
111
+ throw new Error(`Grok API error: ${response.status} - ${error}`);
112
+ }
113
+
114
+ return response.json() as Promise<GrokCompletionResponse>;
115
+ }
116
+
117
+ async *chatStream(
118
+ messages: GrokMessage[],
119
+ tools?: Tool[],
120
+ options: { temperature?: number; maxTokens?: number } = {}
121
+ ): AsyncGenerator<StreamChunk> {
122
+ const request: GrokCompletionRequest = {
123
+ model: this.model,
124
+ messages,
125
+ stream: true,
126
+ temperature: options.temperature ?? 0.7,
127
+ max_tokens: options.maxTokens ?? 16384,
128
+ };
129
+
130
+ if (tools && tools.length > 0) {
131
+ request.tools = tools;
132
+ request.tool_choice = 'auto';
133
+ }
134
+
135
+ const response = await fetch(`${this.baseUrl}/chat/completions`, {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ 'Authorization': `Bearer ${this.apiKey}`,
140
+ },
141
+ body: JSON.stringify(request),
142
+ });
143
+
144
+ if (!response.ok) {
145
+ const error = await response.text();
146
+ throw new Error(`Grok API error: ${response.status} - ${error}`);
147
+ }
148
+
149
+ const reader = response.body?.getReader();
150
+ if (!reader) throw new Error('No response body');
151
+
152
+ const decoder = new TextDecoder();
153
+ let buffer = '';
154
+
155
+ while (true) {
156
+ const { done, value } = await reader.read();
157
+ if (done) break;
158
+
159
+ buffer += decoder.decode(value, { stream: true });
160
+ const lines = buffer.split('\n');
161
+ buffer = lines.pop() || '';
162
+
163
+ for (const line of lines) {
164
+ const trimmed = line.trim();
165
+ if (!trimmed || trimmed === 'data: [DONE]') continue;
166
+ if (trimmed.startsWith('data: ')) {
167
+ try {
168
+ const chunk = JSON.parse(trimmed.slice(6)) as StreamChunk;
169
+ yield chunk;
170
+ } catch {
171
+ // Ignore parse errors for incomplete chunks
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ async listModels(): Promise<string[]> {
179
+ const response = await fetch(`${this.baseUrl}/models`, {
180
+ headers: {
181
+ 'Authorization': `Bearer ${this.apiKey}`,
182
+ },
183
+ });
184
+
185
+ if (!response.ok) {
186
+ throw new Error(`Failed to list models: ${response.status}`);
187
+ }
188
+
189
+ const data = await response.json() as { data: { id: string }[] };
190
+ return data.data.map((m) => m.id);
191
+ }
192
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // Grok Code CLI - Public API exports
2
+
3
+ export { GrokClient, type GrokMessage, type Tool, type ToolCall } from './grok/client.js';
4
+ export { GrokChat, type ChatOptions } from './conversation/chat.js';
5
+ export { HistoryManager, type ConversationSession } from './conversation/history.js';
6
+ export { ConfigManager } from './config/manager.js';
7
+ export { PermissionManager, type PermissionRequest, type ToolRiskLevel } from './permissions/manager.js';
8
+ export { allTools, executeTool, type ToolResult } from './tools/registry.js';
@@ -0,0 +1,208 @@
1
+ import * as readline from 'readline';
2
+ import chalk from 'chalk';
3
+
4
+ export type ToolRiskLevel = 'read' | 'write' | 'execute';
5
+
6
+ export interface PermissionRequest {
7
+ tool: string;
8
+ description: string;
9
+ riskLevel: ToolRiskLevel;
10
+ details?: Record<string, unknown>;
11
+ }
12
+
13
+ export interface PermissionConfig {
14
+ autoApprove: string[]; // Tools to auto-approve
15
+ alwaysDeny: string[]; // Tools to always deny
16
+ sessionApproved: Set<string>; // Approved for this session
17
+ }
18
+
19
+ const TOOL_RISK_LEVELS: Record<string, ToolRiskLevel> = {
20
+ Read: 'read',
21
+ Glob: 'read',
22
+ Grep: 'read',
23
+ Write: 'write',
24
+ Edit: 'write',
25
+ Bash: 'execute',
26
+ WebFetch: 'read',
27
+ };
28
+
29
+ const RISK_COLORS = {
30
+ read: chalk.green,
31
+ write: chalk.yellow,
32
+ execute: chalk.red,
33
+ };
34
+
35
+ const RISK_ICONS = {
36
+ read: '📖',
37
+ write: '✏️',
38
+ execute: '⚡',
39
+ };
40
+
41
+ export class PermissionManager {
42
+ private config: PermissionConfig;
43
+ private rl: readline.Interface | null = null;
44
+
45
+ constructor(autoApprove: string[] = []) {
46
+ this.config = {
47
+ autoApprove,
48
+ alwaysDeny: [],
49
+ sessionApproved: new Set(),
50
+ };
51
+ }
52
+
53
+ setReadlineInterface(rl: readline.Interface): void {
54
+ this.rl = rl;
55
+ }
56
+
57
+ async requestPermission(request: PermissionRequest): Promise<boolean> {
58
+ const { tool, description, riskLevel, details } = request;
59
+
60
+ // Check if always denied
61
+ if (this.config.alwaysDeny.includes(tool)) {
62
+ console.log(chalk.red(`\n⛔ Tool "${tool}" is blocked by configuration.\n`));
63
+ return false;
64
+ }
65
+
66
+ // Check if auto-approved
67
+ if (this.config.autoApprove.includes(tool) || this.config.autoApprove.includes('*')) {
68
+ return true;
69
+ }
70
+
71
+ // Check if approved for this session
72
+ const sessionKey = this.getSessionKey(tool, details);
73
+ if (this.config.sessionApproved.has(sessionKey)) {
74
+ return true;
75
+ }
76
+
77
+ // Read-only operations can be auto-approved with lower risk
78
+ if (riskLevel === 'read' && this.config.autoApprove.includes('read')) {
79
+ return true;
80
+ }
81
+
82
+ // Prompt user for permission
83
+ return this.promptUser(request);
84
+ }
85
+
86
+ private async promptUser(request: PermissionRequest): Promise<boolean> {
87
+ const { tool, description, riskLevel, details } = request;
88
+ const color = RISK_COLORS[riskLevel];
89
+ const icon = RISK_ICONS[riskLevel];
90
+
91
+ console.log(chalk.cyan('\n┌─────────────────────────────────────────────────────┐'));
92
+ console.log(chalk.cyan('│') + ` ${icon} ${color(`Permission Request: ${tool}`)}`.padEnd(60) + chalk.cyan('│'));
93
+ console.log(chalk.cyan('├─────────────────────────────────────────────────────┤'));
94
+ console.log(chalk.cyan('│') + ` ${description}`.padEnd(52) + chalk.cyan('│'));
95
+
96
+ // Show details
97
+ if (details) {
98
+ console.log(chalk.cyan('├─────────────────────────────────────────────────────┤'));
99
+ for (const [key, value] of Object.entries(details)) {
100
+ const valueStr = typeof value === 'string'
101
+ ? value.length > 45 ? value.slice(0, 42) + '...' : value
102
+ : String(value);
103
+ console.log(chalk.cyan('│') + chalk.gray(` ${key}: `) + valueStr.padEnd(42 - key.length) + chalk.cyan('│'));
104
+ }
105
+ }
106
+
107
+ console.log(chalk.cyan('└─────────────────────────────────────────────────────┘'));
108
+ console.log();
109
+ console.log(` ${chalk.green('[y]')} Yes, allow once`);
110
+ console.log(` ${chalk.green('[a]')} Allow for this session`);
111
+ console.log(` ${chalk.red('[n]')} No, deny`);
112
+ console.log(` ${chalk.red('[!]')} Deny and block for session`);
113
+ console.log();
114
+
115
+ const answer = await this.question(chalk.cyan('Choice [y/a/n/!]: '));
116
+ const choice = answer.toLowerCase().trim();
117
+
118
+ switch (choice) {
119
+ case 'y':
120
+ case 'yes':
121
+ return true;
122
+
123
+ case 'a':
124
+ case 'always':
125
+ this.config.sessionApproved.add(this.getSessionKey(tool, details));
126
+ console.log(chalk.green(`✓ "${tool}" approved for this session.\n`));
127
+ return true;
128
+
129
+ case 'n':
130
+ case 'no':
131
+ case '':
132
+ console.log(chalk.yellow('⊘ Denied.\n'));
133
+ return false;
134
+
135
+ case '!':
136
+ case 'block':
137
+ this.config.alwaysDeny.push(tool);
138
+ console.log(chalk.red(`⛔ "${tool}" blocked for this session.\n`));
139
+ return false;
140
+
141
+ default:
142
+ console.log(chalk.gray('Invalid choice, defaulting to deny.\n'));
143
+ return false;
144
+ }
145
+ }
146
+
147
+ private getSessionKey(tool: string, details?: Record<string, unknown>): string {
148
+ // For some tools, scope approval to specific paths/commands
149
+ if (tool === 'Bash' && details?.command) {
150
+ // Approve specific command patterns
151
+ const cmd = String(details.command);
152
+ if (cmd.startsWith('git ')) return `${tool}:git`;
153
+ if (cmd.startsWith('npm ')) return `${tool}:npm`;
154
+ if (cmd.startsWith('ls ') || cmd === 'ls') return `${tool}:ls`;
155
+ return tool;
156
+ }
157
+
158
+ if ((tool === 'Read' || tool === 'Write' || tool === 'Edit') && details?.file_path) {
159
+ // Could scope to directory
160
+ return tool;
161
+ }
162
+
163
+ return tool;
164
+ }
165
+
166
+ private question(prompt: string): Promise<string> {
167
+ return new Promise((resolve) => {
168
+ if (this.rl) {
169
+ this.rl.question(prompt, resolve);
170
+ } else {
171
+ // Fallback if no readline interface
172
+ const tempRl = readline.createInterface({
173
+ input: process.stdin,
174
+ output: process.stdout,
175
+ });
176
+ tempRl.question(prompt, (answer) => {
177
+ tempRl.close();
178
+ resolve(answer);
179
+ });
180
+ }
181
+ });
182
+ }
183
+
184
+ getToolRiskLevel(tool: string): ToolRiskLevel {
185
+ return TOOL_RISK_LEVELS[tool] || 'execute';
186
+ }
187
+
188
+ formatToolDetails(tool: string, params: Record<string, unknown>): string {
189
+ switch (tool) {
190
+ case 'Read':
191
+ return `Read file: ${params.file_path}`;
192
+ case 'Write':
193
+ return `Write to file: ${params.file_path}`;
194
+ case 'Edit':
195
+ return `Edit file: ${params.file_path}`;
196
+ case 'Bash':
197
+ return `Execute command: ${params.command}`;
198
+ case 'Glob':
199
+ return `Search for files: ${params.pattern}`;
200
+ case 'Grep':
201
+ return `Search in files: ${params.pattern}`;
202
+ case 'WebFetch':
203
+ return `Fetch URL: ${params.url}`;
204
+ default:
205
+ return `Execute ${tool}`;
206
+ }
207
+ }
208
+ }
@@ -0,0 +1,119 @@
1
+ import { spawn } from 'child_process';
2
+ import { ToolResult } from './registry.js';
3
+ import { validateCommand, sanitizeOutput } from '../utils/security.js';
4
+ import chalk from 'chalk';
5
+
6
+ export interface BashToolParams {
7
+ command: string;
8
+ timeout?: number;
9
+ cwd?: string;
10
+ }
11
+
12
+ // Maximum output size to prevent memory issues
13
+ const MAX_OUTPUT_SIZE = 1024 * 1024; // 1MB
14
+
15
+ export async function bashTool(params: BashToolParams): Promise<ToolResult> {
16
+ const timeout = params.timeout ?? 120000; // 2 minutes default
17
+
18
+ // Security validation
19
+ const security = validateCommand(params.command);
20
+ if (!security.allowed) {
21
+ return {
22
+ success: false,
23
+ output: '',
24
+ error: `Security: ${security.reason}${security.suggestion ? ` - ${security.suggestion}` : ''}`,
25
+ };
26
+ }
27
+
28
+ // Warn about risky commands but allow them
29
+ let warning = '';
30
+ if (security.severity === 'medium') {
31
+ warning = chalk.yellow(`⚠️ ${security.reason}\n`);
32
+ }
33
+
34
+ return new Promise((resolve) => {
35
+ let stdout = '';
36
+ let stderr = '';
37
+ let killed = false;
38
+ let outputTruncated = false;
39
+
40
+ const child = spawn('bash', ['-c', params.command], {
41
+ cwd: params.cwd || process.cwd(),
42
+ env: {
43
+ ...process.env,
44
+ // Prevent color codes from commands that might interfere
45
+ FORCE_COLOR: '0',
46
+ NO_COLOR: '1',
47
+ },
48
+ });
49
+
50
+ const timer = setTimeout(() => {
51
+ killed = true;
52
+ child.kill('SIGTERM');
53
+ // Force kill after 5 seconds if still running
54
+ setTimeout(() => {
55
+ try {
56
+ child.kill('SIGKILL');
57
+ } catch {
58
+ // Already dead
59
+ }
60
+ }, 5000);
61
+ }, timeout);
62
+
63
+ child.stdout.on('data', (data) => {
64
+ if (stdout.length < MAX_OUTPUT_SIZE) {
65
+ stdout += data.toString();
66
+ } else if (!outputTruncated) {
67
+ outputTruncated = true;
68
+ stdout += '\n... (output truncated)';
69
+ }
70
+ });
71
+
72
+ child.stderr.on('data', (data) => {
73
+ if (stderr.length < MAX_OUTPUT_SIZE) {
74
+ stderr += data.toString();
75
+ }
76
+ });
77
+
78
+ child.on('close', (code, signal) => {
79
+ clearTimeout(timer);
80
+
81
+ if (killed) {
82
+ resolve({
83
+ success: false,
84
+ output: sanitizeOutput(stdout),
85
+ error: `Command timed out after ${timeout / 1000}s and was terminated`,
86
+ });
87
+ return;
88
+ }
89
+
90
+ // Combine and sanitize output
91
+ let output = sanitizeOutput(stdout);
92
+ if (stderr && code !== 0) {
93
+ output += (output ? '\n' : '') + chalk.red('stderr: ') + sanitizeOutput(stderr);
94
+ }
95
+
96
+ if (code === 0) {
97
+ resolve({
98
+ success: true,
99
+ output: warning + (output.trim() || '(command completed with no output)'),
100
+ });
101
+ } else {
102
+ resolve({
103
+ success: false,
104
+ output: output.trim(),
105
+ error: sanitizeOutput(stderr.trim()) || `Command exited with code ${code}${signal ? ` (signal: ${signal})` : ''}`,
106
+ });
107
+ }
108
+ });
109
+
110
+ child.on('error', (error) => {
111
+ clearTimeout(timer);
112
+ resolve({
113
+ success: false,
114
+ output: '',
115
+ error: `Failed to execute command: ${error.message}`,
116
+ });
117
+ });
118
+ });
119
+ }