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.
- package/.claude/settings.local.json +32 -0
- package/README.md +1464 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +61 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/loader.d.ts +34 -0
- package/dist/commands/loader.d.ts.map +1 -0
- package/dist/commands/loader.js +192 -0
- package/dist/commands/loader.js.map +1 -0
- package/dist/config/manager.d.ts +21 -0
- package/dist/config/manager.d.ts.map +1 -0
- package/dist/config/manager.js +203 -0
- package/dist/config/manager.js.map +1 -0
- package/dist/conversation/chat.d.ts +50 -0
- package/dist/conversation/chat.d.ts.map +1 -0
- package/dist/conversation/chat.js +1145 -0
- package/dist/conversation/chat.js.map +1 -0
- package/dist/conversation/history.d.ts +24 -0
- package/dist/conversation/history.d.ts.map +1 -0
- package/dist/conversation/history.js +103 -0
- package/dist/conversation/history.js.map +1 -0
- package/dist/grok/client.d.ts +86 -0
- package/dist/grok/client.d.ts.map +1 -0
- package/dist/grok/client.js +106 -0
- package/dist/grok/client.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/manager.d.ts +26 -0
- package/dist/permissions/manager.d.ts.map +1 -0
- package/dist/permissions/manager.js +170 -0
- package/dist/permissions/manager.js.map +1 -0
- package/dist/tools/bash.d.ts +8 -0
- package/dist/tools/bash.d.ts.map +1 -0
- package/dist/tools/bash.js +102 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/edit.d.ts +9 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +61 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/glob.d.ts +7 -0
- package/dist/tools/glob.d.ts.map +1 -0
- package/dist/tools/glob.js +38 -0
- package/dist/tools/glob.js.map +1 -0
- package/dist/tools/grep.d.ts +8 -0
- package/dist/tools/grep.d.ts.map +1 -0
- package/dist/tools/grep.js +78 -0
- package/dist/tools/grep.js.map +1 -0
- package/dist/tools/read.d.ts +8 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +96 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/registry.d.ts +42 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +230 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/webfetch.d.ts +10 -0
- package/dist/tools/webfetch.d.ts.map +1 -0
- package/dist/tools/webfetch.js +108 -0
- package/dist/tools/webfetch.js.map +1 -0
- package/dist/tools/websearch.d.ts +7 -0
- package/dist/tools/websearch.d.ts.map +1 -0
- package/dist/tools/websearch.js +180 -0
- package/dist/tools/websearch.js.map +1 -0
- package/dist/tools/write.d.ts +7 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +80 -0
- package/dist/tools/write.js.map +1 -0
- package/dist/utils/security.d.ts +36 -0
- package/dist/utils/security.d.ts.map +1 -0
- package/dist/utils/security.js +227 -0
- package/dist/utils/security.js.map +1 -0
- package/dist/utils/ui.d.ts +49 -0
- package/dist/utils/ui.d.ts.map +1 -0
- package/dist/utils/ui.js +302 -0
- package/dist/utils/ui.js.map +1 -0
- package/package.json +45 -0
- package/src/cli.ts +68 -0
- package/src/commands/loader.ts +244 -0
- package/src/config/manager.ts +239 -0
- package/src/conversation/chat.ts +1294 -0
- package/src/conversation/history.ts +131 -0
- package/src/grok/client.ts +192 -0
- package/src/index.ts +8 -0
- package/src/permissions/manager.ts +208 -0
- package/src/tools/bash.ts +119 -0
- package/src/tools/edit.ts +73 -0
- package/src/tools/glob.ts +49 -0
- package/src/tools/grep.ts +96 -0
- package/src/tools/read.ts +116 -0
- package/src/tools/registry.ts +248 -0
- package/src/tools/webfetch.ts +127 -0
- package/src/tools/websearch.ts +219 -0
- package/src/tools/write.ts +94 -0
- package/src/utils/security.ts +259 -0
- package/src/utils/ui.ts +382 -0
- 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
|
+
}
|