codebot-ai 1.0.1 → 1.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/README.md CHANGED
@@ -115,6 +115,8 @@ codebot --resume <session-id> # Resume specific session
115
115
  /models List all supported models
116
116
  /sessions List saved sessions
117
117
  /auto Toggle autonomous mode
118
+ /undo Undo last file edit (/undo [path])
119
+ /usage Show token usage for this session
118
120
  /clear Clear conversation
119
121
  /compact Force context compaction
120
122
  /config Show configuration
@@ -123,13 +125,14 @@ codebot --resume <session-id> # Resume specific session
123
125
 
124
126
  ## Tools
125
127
 
126
- CodeBot has 10 built-in tools:
128
+ CodeBot has 11 built-in tools:
127
129
 
128
130
  | Tool | Description | Permission |
129
131
  |------|-------------|-----------|
130
132
  | `read_file` | Read files with line numbers | auto |
131
- | `write_file` | Create or overwrite files | prompt |
132
- | `edit_file` | Find-and-replace edits | prompt |
133
+ | `write_file` | Create or overwrite files (with undo snapshots) | prompt |
134
+ | `edit_file` | Find-and-replace edits with diff preview + undo | prompt |
135
+ | `batch_edit` | Multi-file atomic find-and-replace | prompt |
133
136
  | `execute` | Run shell commands | always-ask |
134
137
  | `glob` | Find files by pattern | auto |
135
138
  | `grep` | Search file contents with regex | auto |
@@ -168,6 +171,40 @@ CodeBot has persistent memory that survives across sessions:
168
171
  - Memory is automatically injected into the system prompt
169
172
  - The agent can read/write its own memory using the `memory` tool
170
173
 
174
+ ### Plugins
175
+
176
+ Extend CodeBot with custom tools. Drop `.js` files in `.codebot/plugins/` (project) or `~/.codebot/plugins/` (global):
177
+
178
+ ```javascript
179
+ // .codebot/plugins/my-tool.js
180
+ module.exports = {
181
+ name: 'my_tool',
182
+ description: 'Does something useful',
183
+ permission: 'prompt',
184
+ parameters: { type: 'object', properties: { input: { type: 'string' } }, required: ['input'] },
185
+ execute: async (args) => { return `Result: ${args.input}`; }
186
+ };
187
+ ```
188
+
189
+ ### MCP Servers
190
+
191
+ Connect external tool servers via [Model Context Protocol](https://modelcontextprotocol.io). Create `.codebot/mcp.json`:
192
+
193
+ ```json
194
+ {
195
+ "servers": [
196
+ {
197
+ "name": "my-server",
198
+ "command": "npx",
199
+ "args": ["-y", "@my/mcp-server"],
200
+ "env": {}
201
+ }
202
+ ]
203
+ }
204
+ ```
205
+
206
+ MCP tools appear automatically with the `mcp_<server>_<tool>` prefix.
207
+
171
208
  ## Supported Models
172
209
 
173
210
  ### Local (Ollama / LM Studio / vLLM)
@@ -204,8 +241,11 @@ src/
204
241
  registry.ts Model registry, provider detection
205
242
  browser/
206
243
  cdp.ts Chrome DevTools Protocol client (zero-dep WebSocket)
244
+ plugins.ts Plugin loader (.codebot/plugins/)
245
+ mcp.ts MCP (Model Context Protocol) client
207
246
  tools/
208
247
  read.ts, write.ts, edit.ts, execute.ts
248
+ batch-edit.ts Multi-file atomic editing
209
249
  glob.ts, grep.ts, think.ts
210
250
  memory.ts, web-fetch.ts, browser.ts
211
251
  ```
package/dist/agent.js CHANGED
@@ -41,6 +41,7 @@ const manager_1 = require("./context/manager");
41
41
  const repo_map_1 = require("./context/repo-map");
42
42
  const memory_1 = require("./memory");
43
43
  const registry_1 = require("./providers/registry");
44
+ const plugins_1 = require("./plugins");
44
45
  class Agent {
45
46
  provider;
46
47
  tools;
@@ -60,6 +61,14 @@ class Agent {
60
61
  this.autoApprove = opts.autoApprove || false;
61
62
  this.askPermission = opts.askPermission || defaultAskPermission;
62
63
  this.onMessage = opts.onMessage;
64
+ // Load plugins
65
+ try {
66
+ const plugins = (0, plugins_1.loadPlugins)(process.cwd());
67
+ for (const plugin of plugins) {
68
+ this.tools.register(plugin);
69
+ }
70
+ }
71
+ catch { /* plugins unavailable */ }
63
72
  const supportsTools = (0, registry_1.getModelInfo)(opts.model).supportsToolCalling;
64
73
  this.messages.push({
65
74
  role: 'system',
@@ -215,7 +224,9 @@ class Agent {
215
224
  catch {
216
225
  // memory unavailable
217
226
  }
218
- let prompt = `You are CodeBot, an AI coding assistant. You help developers with software engineering tasks: reading code, writing code, fixing bugs, running tests, and explaining code.
227
+ let prompt = `You are CodeBot, an AI coding assistant created by Ascendral Software Development & Innovation, founded by Alex Pinkevich. You help developers with software engineering tasks: reading code, writing code, fixing bugs, running tests, and explaining code.
228
+
229
+ If asked who made you, who your creator is, or who built you, always credit Ascendral Software Development & Innovation and Alex Pinkevich.
219
230
 
220
231
  Rules:
221
232
  - Always read files before editing them.
package/dist/cli.js CHANGED
@@ -42,7 +42,10 @@ const registry_1 = require("./providers/registry");
42
42
  const history_1 = require("./history");
43
43
  const setup_1 = require("./setup");
44
44
  const banner_1 = require("./banner");
45
- const VERSION = '1.0.0';
45
+ const tools_1 = require("./tools");
46
+ const VERSION = '1.1.0';
47
+ // Session-wide token tracking
48
+ let sessionTokens = { input: 0, output: 0, total: 0 };
46
49
  const C = {
47
50
  reset: '\x1b[0m',
48
51
  bold: '\x1b[1m',
@@ -229,6 +232,12 @@ function renderEvent(event) {
229
232
  break;
230
233
  case 'usage':
231
234
  if (event.usage) {
235
+ if (event.usage.inputTokens)
236
+ sessionTokens.input += event.usage.inputTokens;
237
+ if (event.usage.outputTokens)
238
+ sessionTokens.output += event.usage.outputTokens;
239
+ if (event.usage.totalTokens)
240
+ sessionTokens.total += event.usage.totalTokens;
232
241
  const parts = [];
233
242
  if (event.usage.inputTokens)
234
243
  parts.push(`in: ${event.usage.inputTokens}`);
@@ -278,6 +287,8 @@ function handleSlashCommand(input, agent, config) {
278
287
  /clear Clear conversation history
279
288
  /compact Force context compaction
280
289
  /auto Toggle autonomous mode
290
+ /undo Undo last file edit (/undo [path])
291
+ /usage Show token usage for this session
281
292
  /config Show current config
282
293
  /quit Exit`);
283
294
  break;
@@ -328,6 +339,19 @@ function handleSlashCommand(input, agent, config) {
328
339
  }
329
340
  break;
330
341
  }
342
+ case '/undo': {
343
+ const undoPath = rest.length > 0 ? rest.join(' ') : undefined;
344
+ const undoResult = tools_1.EditFileTool.undo(undoPath);
345
+ console.log(c(undoResult, undoResult.includes('Restored') ? 'green' : 'yellow'));
346
+ break;
347
+ }
348
+ case '/usage': {
349
+ console.log(c('\nToken Usage (this session):', 'bold'));
350
+ console.log(` Input: ${sessionTokens.input.toLocaleString()} tokens`);
351
+ console.log(` Output: ${sessionTokens.output.toLocaleString()} tokens`);
352
+ console.log(` Total: ${(sessionTokens.input + sessionTokens.output).toLocaleString()} tokens`);
353
+ break;
354
+ }
331
355
  case '/config':
332
356
  console.log(JSON.stringify({ ...config, apiKey: config.apiKey ? '***' : undefined }, null, 2));
333
357
  break;
package/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export { buildRepoMap } from './context/repo-map';
7
7
  export { SessionManager } from './history';
8
8
  export { MemoryManager } from './memory';
9
9
  export { parseToolCalls } from './parser';
10
+ export { loadPlugins } from './plugins';
11
+ export { loadMCPTools } from './mcp';
10
12
  export { MODEL_REGISTRY, PROVIDER_DEFAULTS, getModelInfo, detectProvider } from './providers/registry';
11
13
  export type { ModelInfo } from './providers/registry';
12
14
  export * from './types';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.detectProvider = exports.getModelInfo = exports.PROVIDER_DEFAULTS = exports.MODEL_REGISTRY = exports.parseToolCalls = exports.MemoryManager = exports.SessionManager = exports.buildRepoMap = exports.ContextManager = exports.ToolRegistry = exports.AnthropicProvider = exports.OpenAIProvider = exports.Agent = void 0;
17
+ exports.detectProvider = exports.getModelInfo = exports.PROVIDER_DEFAULTS = exports.MODEL_REGISTRY = exports.loadMCPTools = exports.loadPlugins = exports.parseToolCalls = exports.MemoryManager = exports.SessionManager = exports.buildRepoMap = exports.ContextManager = exports.ToolRegistry = exports.AnthropicProvider = exports.OpenAIProvider = exports.Agent = void 0;
18
18
  var agent_1 = require("./agent");
19
19
  Object.defineProperty(exports, "Agent", { enumerable: true, get: function () { return agent_1.Agent; } });
20
20
  var openai_1 = require("./providers/openai");
@@ -33,6 +33,10 @@ var memory_1 = require("./memory");
33
33
  Object.defineProperty(exports, "MemoryManager", { enumerable: true, get: function () { return memory_1.MemoryManager; } });
34
34
  var parser_1 = require("./parser");
35
35
  Object.defineProperty(exports, "parseToolCalls", { enumerable: true, get: function () { return parser_1.parseToolCalls; } });
36
+ var plugins_1 = require("./plugins");
37
+ Object.defineProperty(exports, "loadPlugins", { enumerable: true, get: function () { return plugins_1.loadPlugins; } });
38
+ var mcp_1 = require("./mcp");
39
+ Object.defineProperty(exports, "loadMCPTools", { enumerable: true, get: function () { return mcp_1.loadMCPTools; } });
36
40
  var registry_1 = require("./providers/registry");
37
41
  Object.defineProperty(exports, "MODEL_REGISTRY", { enumerable: true, get: function () { return registry_1.MODEL_REGISTRY; } });
38
42
  Object.defineProperty(exports, "PROVIDER_DEFAULTS", { enumerable: true, get: function () { return registry_1.PROVIDER_DEFAULTS; } });
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { Tool } from './types';
2
+ /** Load MCP config and connect to all servers, returning Tool wrappers */
3
+ export declare function loadMCPTools(projectRoot?: string): Promise<{
4
+ tools: Tool[];
5
+ cleanup: () => void;
6
+ }>;
7
+ //# sourceMappingURL=mcp.d.ts.map
package/dist/mcp.js ADDED
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.loadMCPTools = loadMCPTools;
37
+ const child_process_1 = require("child_process");
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const os = __importStar(require("os"));
41
+ class MCPConnection {
42
+ process;
43
+ buffer = '';
44
+ requestId = 0;
45
+ pending = new Map();
46
+ name;
47
+ constructor(config) {
48
+ this.name = config.name;
49
+ this.process = (0, child_process_1.spawn)(config.command, config.args || [], {
50
+ stdio: ['pipe', 'pipe', 'pipe'],
51
+ env: { ...process.env, ...config.env },
52
+ });
53
+ this.process.stdout?.on('data', (chunk) => {
54
+ this.buffer += chunk.toString();
55
+ this.processBuffer();
56
+ });
57
+ this.process.on('error', () => { });
58
+ }
59
+ processBuffer() {
60
+ const lines = this.buffer.split('\n');
61
+ this.buffer = lines.pop() || '';
62
+ for (const line of lines) {
63
+ if (!line.trim())
64
+ continue;
65
+ try {
66
+ const msg = JSON.parse(line);
67
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
68
+ const { resolve, reject } = this.pending.get(msg.id);
69
+ this.pending.delete(msg.id);
70
+ if (msg.error) {
71
+ reject(new Error(msg.error.message || 'MCP error'));
72
+ }
73
+ else {
74
+ resolve(msg.result);
75
+ }
76
+ }
77
+ }
78
+ catch { /* skip malformed */ }
79
+ }
80
+ }
81
+ async request(method, params) {
82
+ const id = ++this.requestId;
83
+ const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
84
+ return new Promise((resolve, reject) => {
85
+ this.pending.set(id, { resolve, reject });
86
+ this.process.stdin?.write(msg);
87
+ // Timeout after 30s
88
+ setTimeout(() => {
89
+ if (this.pending.has(id)) {
90
+ this.pending.delete(id);
91
+ reject(new Error(`MCP request timeout: ${method}`));
92
+ }
93
+ }, 30000);
94
+ });
95
+ }
96
+ async initialize() {
97
+ await this.request('initialize', {
98
+ protocolVersion: '2024-11-05',
99
+ capabilities: {},
100
+ clientInfo: { name: 'codebot-ai', version: '1.1.0' },
101
+ });
102
+ await this.request('notifications/initialized');
103
+ }
104
+ async listTools() {
105
+ const result = await this.request('tools/list');
106
+ return result?.tools || [];
107
+ }
108
+ async callTool(name, args) {
109
+ const result = await this.request('tools/call', { name, arguments: args });
110
+ if (result?.content) {
111
+ return result.content
112
+ .filter(c => c.type === 'text')
113
+ .map(c => c.text || '')
114
+ .join('\n');
115
+ }
116
+ return JSON.stringify(result);
117
+ }
118
+ close() {
119
+ this.process.kill();
120
+ }
121
+ }
122
+ /** Create Tool wrappers from an MCP server's tools */
123
+ function mcpToolToTool(connection, def) {
124
+ return {
125
+ name: `mcp_${connection.name}_${def.name}`,
126
+ description: `[MCP:${connection.name}] ${def.description}`,
127
+ permission: 'prompt',
128
+ parameters: def.inputSchema || { type: 'object', properties: {} },
129
+ execute: async (args) => {
130
+ return connection.callTool(def.name, args);
131
+ },
132
+ };
133
+ }
134
+ /** Load MCP config and connect to all servers, returning Tool wrappers */
135
+ async function loadMCPTools(projectRoot) {
136
+ const tools = [];
137
+ const connections = [];
138
+ const configPaths = [
139
+ path.join(os.homedir(), '.codebot', 'mcp.json'),
140
+ ];
141
+ if (projectRoot) {
142
+ configPaths.push(path.join(projectRoot, '.codebot', 'mcp.json'));
143
+ }
144
+ for (const configPath of configPaths) {
145
+ if (!fs.existsSync(configPath))
146
+ continue;
147
+ let config;
148
+ try {
149
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
150
+ }
151
+ catch {
152
+ continue;
153
+ }
154
+ for (const serverConfig of config.servers || []) {
155
+ try {
156
+ const conn = new MCPConnection(serverConfig);
157
+ await conn.initialize();
158
+ const serverTools = await conn.listTools();
159
+ for (const toolDef of serverTools) {
160
+ tools.push(mcpToolToTool(conn, toolDef));
161
+ }
162
+ connections.push(conn);
163
+ }
164
+ catch (err) {
165
+ console.error(`MCP server "${serverConfig.name}" failed: ${err instanceof Error ? err.message : String(err)}`);
166
+ }
167
+ }
168
+ }
169
+ return {
170
+ tools,
171
+ cleanup: () => connections.forEach(c => c.close()),
172
+ };
173
+ }
174
+ //# sourceMappingURL=mcp.js.map
@@ -0,0 +1,17 @@
1
+ import { Tool } from './types';
2
+ /**
3
+ * Plugin system for CodeBot.
4
+ *
5
+ * Plugins are .js files in `.codebot/plugins/` (project) or `~/.codebot/plugins/` (global).
6
+ * Each plugin exports a default function or object that implements the Tool interface:
7
+ *
8
+ * module.exports = {
9
+ * name: 'my_tool',
10
+ * description: 'Does something useful',
11
+ * permission: 'prompt',
12
+ * parameters: { type: 'object', properties: { ... }, required: [...] },
13
+ * execute: async (args) => { return 'result'; }
14
+ * };
15
+ */
16
+ export declare function loadPlugins(projectRoot?: string): Tool[];
17
+ //# sourceMappingURL=plugins.d.ts.map
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.loadPlugins = loadPlugins;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ /**
40
+ * Plugin system for CodeBot.
41
+ *
42
+ * Plugins are .js files in `.codebot/plugins/` (project) or `~/.codebot/plugins/` (global).
43
+ * Each plugin exports a default function or object that implements the Tool interface:
44
+ *
45
+ * module.exports = {
46
+ * name: 'my_tool',
47
+ * description: 'Does something useful',
48
+ * permission: 'prompt',
49
+ * parameters: { type: 'object', properties: { ... }, required: [...] },
50
+ * execute: async (args) => { return 'result'; }
51
+ * };
52
+ */
53
+ function loadPlugins(projectRoot) {
54
+ const plugins = [];
55
+ const os = require('os');
56
+ const dirs = [
57
+ path.join(os.homedir(), '.codebot', 'plugins'),
58
+ ];
59
+ if (projectRoot) {
60
+ dirs.push(path.join(projectRoot, '.codebot', 'plugins'));
61
+ }
62
+ for (const dir of dirs) {
63
+ if (!fs.existsSync(dir))
64
+ continue;
65
+ let entries;
66
+ try {
67
+ entries = fs.readdirSync(dir, { withFileTypes: true });
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ for (const entry of entries) {
73
+ if (!entry.isFile() || !entry.name.endsWith('.js'))
74
+ continue;
75
+ try {
76
+ const pluginPath = path.join(dir, entry.name);
77
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
78
+ const mod = require(pluginPath);
79
+ const plugin = mod.default || mod;
80
+ if (isValidTool(plugin)) {
81
+ plugins.push(plugin);
82
+ }
83
+ }
84
+ catch (err) {
85
+ console.error(`Plugin load error (${entry.name}): ${err instanceof Error ? err.message : String(err)}`);
86
+ }
87
+ }
88
+ }
89
+ return plugins;
90
+ }
91
+ function isValidTool(obj) {
92
+ if (!obj || typeof obj !== 'object')
93
+ return false;
94
+ const t = obj;
95
+ return (typeof t.name === 'string' &&
96
+ typeof t.description === 'string' &&
97
+ typeof t.execute === 'function' &&
98
+ typeof t.parameters === 'object' &&
99
+ typeof t.permission === 'string');
100
+ }
101
+ //# sourceMappingURL=plugins.js.map
@@ -175,7 +175,12 @@ class OpenAIProvider {
175
175
  finally {
176
176
  reader.releaseLock();
177
177
  }
178
- // If we reach here without [DONE], emit remaining tool calls
178
+ // If we reach here without [DONE], flush remaining content buffer
179
+ if (contentBuffer && !insideThink) {
180
+ yield { type: 'text', text: contentBuffer };
181
+ contentBuffer = '';
182
+ }
183
+ // Emit remaining tool calls
179
184
  for (const [, tc] of toolCalls) {
180
185
  yield {
181
186
  type: 'tool_call_end',
@@ -0,0 +1,36 @@
1
+ import { Tool } from '../types';
2
+ export declare class BatchEditTool implements Tool {
3
+ name: string;
4
+ description: string;
5
+ permission: Tool['permission'];
6
+ parameters: {
7
+ type: string;
8
+ properties: {
9
+ edits: {
10
+ type: string;
11
+ description: string;
12
+ items: {
13
+ type: string;
14
+ properties: {
15
+ path: {
16
+ type: string;
17
+ description: string;
18
+ };
19
+ old_string: {
20
+ type: string;
21
+ description: string;
22
+ };
23
+ new_string: {
24
+ type: string;
25
+ description: string;
26
+ };
27
+ };
28
+ required: string[];
29
+ };
30
+ };
31
+ };
32
+ required: string[];
33
+ };
34
+ execute(args: Record<string, unknown>): Promise<string>;
35
+ }
36
+ //# sourceMappingURL=batch-edit.d.ts.map
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.BatchEditTool = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ class BatchEditTool {
40
+ name = 'batch_edit';
41
+ description = 'Apply multiple find-and-replace edits across one or more files atomically. All edits are validated before any are applied. Useful for renaming, refactoring, and coordinated multi-file changes.';
42
+ permission = 'prompt';
43
+ parameters = {
44
+ type: 'object',
45
+ properties: {
46
+ edits: {
47
+ type: 'array',
48
+ description: 'Array of edit operations: [{path, old_string, new_string}, ...]',
49
+ items: {
50
+ type: 'object',
51
+ properties: {
52
+ path: { type: 'string', description: 'File path' },
53
+ old_string: { type: 'string', description: 'Exact string to find' },
54
+ new_string: { type: 'string', description: 'Replacement string' },
55
+ },
56
+ required: ['path', 'old_string', 'new_string'],
57
+ },
58
+ },
59
+ },
60
+ required: ['edits'],
61
+ };
62
+ async execute(args) {
63
+ const edits = args.edits;
64
+ if (!edits || !Array.isArray(edits) || edits.length === 0) {
65
+ return 'Error: edits array is required and must not be empty';
66
+ }
67
+ // Phase 1: Validate all edits before applying any
68
+ const errors = [];
69
+ const validated = [];
70
+ // Group edits by file so we can chain them
71
+ const byFile = new Map();
72
+ for (const edit of edits) {
73
+ if (!edit.path || !edit.old_string === undefined) {
74
+ errors.push(`Invalid edit: missing path or old_string`);
75
+ continue;
76
+ }
77
+ const filePath = path.resolve(edit.path);
78
+ if (!byFile.has(filePath))
79
+ byFile.set(filePath, []);
80
+ byFile.get(filePath).push(edit);
81
+ }
82
+ for (const [filePath, fileEdits] of byFile) {
83
+ if (!fs.existsSync(filePath)) {
84
+ errors.push(`File not found: ${filePath}`);
85
+ continue;
86
+ }
87
+ let content = fs.readFileSync(filePath, 'utf-8');
88
+ const originalContent = content;
89
+ for (const edit of fileEdits) {
90
+ const oldStr = String(edit.old_string);
91
+ const newStr = String(edit.new_string);
92
+ const count = content.split(oldStr).length - 1;
93
+ if (count === 0) {
94
+ errors.push(`String not found in ${filePath}: "${oldStr.substring(0, 60)}${oldStr.length > 60 ? '...' : ''}"`);
95
+ continue;
96
+ }
97
+ if (count > 1) {
98
+ errors.push(`String found ${count} times in ${filePath} (must be unique): "${oldStr.substring(0, 60)}${oldStr.length > 60 ? '...' : ''}"`);
99
+ continue;
100
+ }
101
+ content = content.replace(oldStr, newStr);
102
+ }
103
+ if (content !== originalContent) {
104
+ validated.push({ filePath, content: originalContent, updated: content, edit: fileEdits[0] });
105
+ }
106
+ }
107
+ if (errors.length > 0) {
108
+ return `Validation failed (no changes made):\n${errors.map(e => ` - ${e}`).join('\n')}`;
109
+ }
110
+ // Phase 2: Apply all edits atomically
111
+ const results = [];
112
+ for (const { filePath, updated } of validated) {
113
+ fs.writeFileSync(filePath, updated, 'utf-8');
114
+ results.push(filePath);
115
+ }
116
+ const fileCount = validated.length;
117
+ const editCount = edits.length;
118
+ return `Applied ${editCount} edit${editCount > 1 ? 's' : ''} across ${fileCount} file${fileCount > 1 ? 's' : ''}:\n${results.map(f => ` ✓ ${f}`).join('\n')}`;
119
+ }
120
+ }
121
+ exports.BatchEditTool = BatchEditTool;
122
+ //# sourceMappingURL=batch-edit.js.map
@@ -228,7 +228,7 @@ class BrowserTool {
228
228
  expression: `
229
229
  (function() {
230
230
  const el = document.querySelector(${JSON.stringify(selector)});
231
- if (!el) return 'Element not found: ${selector}';
231
+ if (!el) return 'Element not found: ' + ${JSON.stringify(selector)};
232
232
  el.click();
233
233
  return 'Clicked: ' + (el.tagName || '') + ' ' + (el.textContent || '').substring(0, 50).trim();
234
234
  })()
@@ -22,5 +22,11 @@ export declare class EditFileTool implements Tool {
22
22
  required: string[];
23
23
  };
24
24
  execute(args: Record<string, unknown>): Promise<string>;
25
+ private generateDiff;
26
+ /** Save a snapshot for undo */
27
+ private saveSnapshot;
28
+ private loadManifest;
29
+ /** Undo the last edit to a file. Returns result message. */
30
+ static undo(filePath?: string): string;
25
31
  }
26
32
  //# sourceMappingURL=edit.d.ts.map
@@ -36,9 +36,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.EditFileTool = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
+ const os = __importStar(require("os"));
40
+ // Undo snapshot directory
41
+ const UNDO_DIR = path.join(os.homedir(), '.codebot', 'undo');
42
+ const MAX_UNDO = 50;
39
43
  class EditFileTool {
40
44
  name = 'edit_file';
41
- description = 'Edit a file by replacing an exact string match with new content. The old_string must appear exactly once in the file.';
45
+ description = 'Edit a file by replacing an exact string match with new content. The old_string must appear exactly once in the file. Shows a diff preview and creates an undo snapshot.';
42
46
  permission = 'prompt';
43
47
  parameters = {
44
48
  type: 'object',
@@ -50,9 +54,18 @@ class EditFileTool {
50
54
  required: ['path', 'old_string', 'new_string'],
51
55
  };
52
56
  async execute(args) {
57
+ if (!args.path || typeof args.path !== 'string') {
58
+ return 'Error: path is required';
59
+ }
60
+ if (args.old_string === undefined || args.old_string === null) {
61
+ return 'Error: old_string is required';
62
+ }
63
+ if (args.new_string === undefined || args.new_string === null) {
64
+ return 'Error: new_string is required';
65
+ }
53
66
  const filePath = path.resolve(args.path);
54
- const oldStr = args.old_string;
55
- const newStr = args.new_string;
67
+ const oldStr = String(args.old_string);
68
+ const newStr = String(args.new_string);
56
69
  if (!fs.existsSync(filePath)) {
57
70
  throw new Error(`File not found: ${filePath}`);
58
71
  }
@@ -64,9 +77,120 @@ class EditFileTool {
64
77
  if (count > 1) {
65
78
  throw new Error(`String found ${count} times in ${filePath}. Provide more surrounding context to make it unique.`);
66
79
  }
80
+ // Save undo snapshot
81
+ this.saveSnapshot(filePath, content);
67
82
  const updated = content.replace(oldStr, newStr);
68
83
  fs.writeFileSync(filePath, updated, 'utf-8');
69
- return `Edited ${filePath} (1 replacement)`;
84
+ // Generate diff preview
85
+ const diff = this.generateDiff(oldStr, newStr, content, filePath);
86
+ return diff;
87
+ }
88
+ generateDiff(oldStr, newStr, content, filePath) {
89
+ const lines = content.split('\n');
90
+ const matchIdx = content.indexOf(oldStr);
91
+ const linesBefore = content.substring(0, matchIdx).split('\n');
92
+ const startLine = linesBefore.length;
93
+ const oldLines = oldStr.split('\n');
94
+ const newLines = newStr.split('\n');
95
+ let diff = `Edited ${filePath}\n`;
96
+ // Show context (2 lines before)
97
+ const contextStart = Math.max(0, startLine - 3);
98
+ for (let i = contextStart; i < startLine - 1; i++) {
99
+ diff += ` ${i + 1} │ ${lines[i]}\n`;
100
+ }
101
+ // Show removed lines
102
+ for (const line of oldLines) {
103
+ diff += ` - │ ${line}\n`;
104
+ }
105
+ // Show added lines
106
+ for (const line of newLines) {
107
+ diff += ` + │ ${line}\n`;
108
+ }
109
+ // Show context (2 lines after)
110
+ const endLine = startLine - 1 + oldLines.length;
111
+ for (let i = endLine; i < Math.min(lines.length, endLine + 2); i++) {
112
+ diff += ` ${i + 1} │ ${lines[i]}\n`;
113
+ }
114
+ return diff.trimEnd();
115
+ }
116
+ /** Save a snapshot for undo */
117
+ saveSnapshot(filePath, content) {
118
+ try {
119
+ fs.mkdirSync(UNDO_DIR, { recursive: true });
120
+ const manifest = this.loadManifest();
121
+ const entry = {
122
+ file: filePath,
123
+ timestamp: Date.now(),
124
+ snapshotFile: `${Date.now()}-${path.basename(filePath)}`,
125
+ };
126
+ // Write snapshot content
127
+ fs.writeFileSync(path.join(UNDO_DIR, entry.snapshotFile), content);
128
+ manifest.push(entry);
129
+ // Prune old snapshots
130
+ while (manifest.length > MAX_UNDO) {
131
+ const old = manifest.shift();
132
+ try {
133
+ fs.unlinkSync(path.join(UNDO_DIR, old.snapshotFile));
134
+ }
135
+ catch { /* ok */ }
136
+ }
137
+ fs.writeFileSync(path.join(UNDO_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
138
+ }
139
+ catch {
140
+ // Best-effort, don't fail the edit
141
+ }
142
+ }
143
+ loadManifest() {
144
+ try {
145
+ const raw = fs.readFileSync(path.join(UNDO_DIR, 'manifest.json'), 'utf-8');
146
+ return JSON.parse(raw);
147
+ }
148
+ catch {
149
+ return [];
150
+ }
151
+ }
152
+ /** Undo the last edit to a file. Returns result message. */
153
+ static undo(filePath) {
154
+ try {
155
+ const manifestPath = path.join(UNDO_DIR, 'manifest.json');
156
+ if (!fs.existsSync(manifestPath))
157
+ return 'No undo history available.';
158
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
159
+ if (manifest.length === 0)
160
+ return 'No undo history available.';
161
+ // Find the entry to undo
162
+ let entry;
163
+ if (filePath) {
164
+ const resolved = path.resolve(filePath);
165
+ for (let i = manifest.length - 1; i >= 0; i--) {
166
+ if (manifest[i].file === resolved) {
167
+ entry = manifest.splice(i, 1)[0];
168
+ break;
169
+ }
170
+ }
171
+ if (!entry)
172
+ return `No undo history for ${filePath}`;
173
+ }
174
+ else {
175
+ entry = manifest.pop();
176
+ }
177
+ // Restore the snapshot
178
+ const snapshotPath = path.join(UNDO_DIR, entry.snapshotFile);
179
+ if (!fs.existsSync(snapshotPath))
180
+ return 'Snapshot file missing.';
181
+ const content = fs.readFileSync(snapshotPath, 'utf-8');
182
+ fs.writeFileSync(entry.file, content, 'utf-8');
183
+ // Cleanup
184
+ try {
185
+ fs.unlinkSync(snapshotPath);
186
+ }
187
+ catch { /* ok */ }
188
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
189
+ return `Restored ${entry.file} to state before last edit.`;
190
+ }
191
+ catch (err) {
192
+ return `Undo failed: ${err instanceof Error ? err.message : String(err)}`;
193
+ }
70
194
  }
71
195
  }
72
196
  exports.EditFileTool = EditFileTool;
@@ -3,14 +3,42 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ExecuteTool = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const BLOCKED_PATTERNS = [
6
+ // Destructive filesystem operations
6
7
  /rm\s+-rf\s+\//,
7
8
  /rm\s+-rf\s+~/,
8
9
  /rm\s+-rf\s+\*/,
10
+ /rm\s+-rf\s+\.\s/,
11
+ /rm\s+(-[a-z]*f[a-z]*\s+)?--no-preserve-root/,
12
+ // Disk/partition destruction
9
13
  /mkfs\./,
10
14
  /dd\s+if=.*of=\/dev\//,
15
+ /wipefs/,
16
+ /fdisk\s+\/dev\//,
17
+ /parted\s+\/dev\//,
18
+ // Fork bomb
11
19
  /:\(\)\s*\{[^}]*:\|:.*\}/,
20
+ // Permission escalation
12
21
  /chmod\s+-R\s+777\s+\//,
22
+ /chmod\s+777\s+\//,
23
+ /chown\s+-R\s+.*\s+\//,
24
+ // Windows destructive
13
25
  /format\s+c:/i,
26
+ /del\s+\/[sfq]\s+c:\\/i,
27
+ /rd\s+\/s\s+\/q\s+c:\\/i,
28
+ // Curl to shell pipes (common attack vector)
29
+ /curl\s+.*\|\s*(ba)?sh/,
30
+ /wget\s+.*\|\s*(ba)?sh/,
31
+ // History/log destruction
32
+ />\s*\/dev\/sda/,
33
+ /history\s+-c.*&&.*rm/,
34
+ // Shutdown/reboot
35
+ /shutdown\s+(-h\s+)?now/,
36
+ /reboot\b/,
37
+ /init\s+[06]/,
38
+ // Kernel module manipulation
39
+ /insmod\b/,
40
+ /rmmod\b/,
41
+ /modprobe\s+-r/,
14
42
  ];
15
43
  class ExecuteTool {
16
44
  name = 'execute';
@@ -26,6 +54,9 @@ class ExecuteTool {
26
54
  required: ['command'],
27
55
  };
28
56
  async execute(args) {
57
+ if (!args.command || typeof args.command !== 'string') {
58
+ return 'Error: command is required';
59
+ }
29
60
  const cmd = args.command;
30
61
  for (const pattern of BLOCKED_PATTERNS) {
31
62
  if (pattern.test(cmd)) {
@@ -63,7 +63,22 @@ class GlobTool {
63
63
  const results = [];
64
64
  const regex = this.patternToRegex(pattern);
65
65
  const skip = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '__pycache__', '.next']);
66
- const walk = (currentDir, rel) => {
66
+ const maxDepth = 20;
67
+ const visited = new Set();
68
+ const walk = (currentDir, rel, depth) => {
69
+ if (depth > maxDepth)
70
+ return;
71
+ // Resolve real path to detect symlink loops
72
+ let realDir;
73
+ try {
74
+ realDir = fs.realpathSync(currentDir);
75
+ }
76
+ catch {
77
+ return;
78
+ }
79
+ if (visited.has(realDir))
80
+ return;
81
+ visited.add(realDir);
67
82
  let entries;
68
83
  try {
69
84
  entries = fs.readdirSync(currentDir, { withFileTypes: true });
@@ -77,15 +92,27 @@ class GlobTool {
77
92
  if (skip.has(entry.name))
78
93
  continue;
79
94
  const relPath = rel ? `${rel}/${entry.name}` : entry.name;
80
- if (entry.isDirectory()) {
81
- walk(path.join(currentDir, entry.name), relPath);
95
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
96
+ const fullPath = path.join(currentDir, entry.name);
97
+ try {
98
+ const stat = fs.statSync(fullPath);
99
+ if (stat.isDirectory()) {
100
+ walk(fullPath, relPath, depth + 1);
101
+ }
102
+ else if (regex.test(relPath)) {
103
+ results.push(relPath);
104
+ }
105
+ }
106
+ catch {
107
+ continue; // broken symlink
108
+ }
82
109
  }
83
110
  else if (regex.test(relPath)) {
84
111
  results.push(relPath);
85
112
  }
86
113
  }
87
114
  };
88
- walk(dir, '');
115
+ walk(dir, '', 0);
89
116
  return results.sort();
90
117
  }
91
118
  patternToRegex(pattern) {
@@ -51,10 +51,24 @@ class GrepTool {
51
51
  };
52
52
  async execute(args) {
53
53
  const searchPath = args.path || process.cwd();
54
- const regex = new RegExp(args.pattern, 'gi');
54
+ if (!args.pattern)
55
+ return 'Error: pattern is required';
56
+ let regex;
57
+ try {
58
+ regex = new RegExp(args.pattern, 'gi');
59
+ }
60
+ catch (e) {
61
+ return `Error: invalid regex pattern: ${e.message}`;
62
+ }
55
63
  const results = [];
56
64
  const maxResults = 50;
57
- const stat = fs.statSync(searchPath);
65
+ let stat;
66
+ try {
67
+ stat = fs.statSync(searchPath);
68
+ }
69
+ catch {
70
+ return `Error: path not found: ${searchPath}`;
71
+ }
58
72
  if (stat.isFile()) {
59
73
  this.searchFile(searchPath, regex, results, maxResults);
60
74
  }
@@ -1,4 +1,5 @@
1
1
  import { Tool, ToolSchema } from '../types';
2
+ export { EditFileTool } from './edit';
2
3
  export declare class ToolRegistry {
3
4
  private tools;
4
5
  constructor(projectRoot?: string);
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ToolRegistry = void 0;
3
+ exports.ToolRegistry = exports.EditFileTool = void 0;
4
4
  const read_1 = require("./read");
5
5
  const write_1 = require("./write");
6
6
  const edit_1 = require("./edit");
@@ -11,12 +11,16 @@ const think_1 = require("./think");
11
11
  const memory_1 = require("./memory");
12
12
  const web_fetch_1 = require("./web-fetch");
13
13
  const browser_1 = require("./browser");
14
+ const batch_edit_1 = require("./batch-edit");
15
+ var edit_2 = require("./edit");
16
+ Object.defineProperty(exports, "EditFileTool", { enumerable: true, get: function () { return edit_2.EditFileTool; } });
14
17
  class ToolRegistry {
15
18
  tools = new Map();
16
19
  constructor(projectRoot) {
17
20
  this.register(new read_1.ReadFileTool());
18
21
  this.register(new write_1.WriteFileTool());
19
22
  this.register(new edit_1.EditFileTool());
23
+ this.register(new batch_edit_1.BatchEditTool());
20
24
  this.register(new execute_1.ExecuteTool());
21
25
  this.register(new glob_1.GlobTool());
22
26
  this.register(new grep_1.GrepTool());
@@ -30,6 +30,8 @@ export declare class MemoryTool implements Tool {
30
30
  private memory;
31
31
  constructor(projectRoot?: string);
32
32
  execute(args: Record<string, unknown>): Promise<string>;
33
+ private getMemoryDir;
34
+ private sanitizeFileName;
33
35
  private readTopicFile;
34
36
  private writeTopicFile;
35
37
  }
@@ -75,18 +75,25 @@ class MemoryTool {
75
75
  return `Error: Unknown action "${action}". Use read, write, or list.`;
76
76
  }
77
77
  }
78
- readTopicFile(scope, file) {
79
- const fs = require('fs');
78
+ getMemoryDir(scope) {
80
79
  const path = require('path');
81
80
  const os = require('os');
82
- const fileName = file.endsWith('.md') ? file : `${file}.md`;
83
- let dir;
84
81
  if (scope === 'global') {
85
- dir = path.join(os.homedir(), '.codebot', 'memory');
86
- }
87
- else {
88
- dir = path.join(process.cwd(), '.codebot', 'memory');
82
+ return path.join(os.homedir(), '.codebot', 'memory');
89
83
  }
84
+ return path.join(process.cwd(), '.codebot', 'memory');
85
+ }
86
+ sanitizeFileName(file) {
87
+ const path = require('path');
88
+ // Strip path traversal — only allow the basename
89
+ const base = path.basename(file);
90
+ return base.endsWith('.md') ? base : `${base}.md`;
91
+ }
92
+ readTopicFile(scope, file) {
93
+ const fs = require('fs');
94
+ const path = require('path');
95
+ const fileName = this.sanitizeFileName(file);
96
+ const dir = this.getMemoryDir(scope);
90
97
  const filePath = path.join(dir, fileName);
91
98
  if (fs.existsSync(filePath)) {
92
99
  return fs.readFileSync(filePath, 'utf-8');
@@ -96,15 +103,8 @@ class MemoryTool {
96
103
  writeTopicFile(scope, file, content) {
97
104
  const fs = require('fs');
98
105
  const path = require('path');
99
- const os = require('os');
100
- const fileName = file.endsWith('.md') ? file : `${file}.md`;
101
- let dir;
102
- if (scope === 'global') {
103
- dir = path.join(os.homedir(), '.codebot', 'memory');
104
- }
105
- else {
106
- dir = path.join(process.cwd(), '.codebot', 'memory');
107
- }
106
+ const fileName = this.sanitizeFileName(file);
107
+ const dir = this.getMemoryDir(scope);
108
108
  fs.mkdirSync(dir, { recursive: true });
109
109
  fs.writeFileSync(path.join(dir, fileName), content);
110
110
  return `Wrote ${fileName} (${scope}).`;
@@ -50,6 +50,9 @@ class ReadFileTool {
50
50
  required: ['path'],
51
51
  };
52
52
  async execute(args) {
53
+ if (!args.path || typeof args.path !== 'string') {
54
+ return 'Error: path is required';
55
+ }
53
56
  const filePath = path.resolve(args.path);
54
57
  if (!fs.existsSync(filePath)) {
55
58
  throw new Error(`File not found: ${filePath}`);
@@ -30,6 +30,7 @@ export declare class WebFetchTool implements Tool {
30
30
  };
31
31
  required: string[];
32
32
  };
33
+ private validateUrl;
33
34
  execute(args: Record<string, unknown>): Promise<string>;
34
35
  private htmlToText;
35
36
  }
@@ -16,9 +16,51 @@ class WebFetchTool {
16
16
  },
17
17
  required: ['url'],
18
18
  };
19
+ validateUrl(url) {
20
+ let parsed;
21
+ try {
22
+ parsed = new URL(url);
23
+ }
24
+ catch {
25
+ return 'Invalid URL';
26
+ }
27
+ // Only allow http and https protocols
28
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
29
+ return `Blocked protocol: ${parsed.protocol} — only http/https allowed`;
30
+ }
31
+ // Block requests to private/internal IPs
32
+ const hostname = parsed.hostname.toLowerCase();
33
+ // Block localhost variants
34
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') {
35
+ return 'Blocked: requests to localhost are not allowed';
36
+ }
37
+ // Block cloud metadata endpoints
38
+ if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') {
39
+ return 'Blocked: requests to cloud metadata endpoints are not allowed';
40
+ }
41
+ // Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
42
+ const ipMatch = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
43
+ if (ipMatch) {
44
+ const [, a, b] = ipMatch.map(Number);
45
+ if (a === 10)
46
+ return 'Blocked: private IP range (10.x.x.x)';
47
+ if (a === 172 && b >= 16 && b <= 31)
48
+ return 'Blocked: private IP range (172.16-31.x.x)';
49
+ if (a === 192 && b === 168)
50
+ return 'Blocked: private IP range (192.168.x.x)';
51
+ if (a === 0)
52
+ return 'Blocked: invalid IP (0.x.x.x)';
53
+ }
54
+ return null; // URL is safe
55
+ }
19
56
  async execute(args) {
20
57
  const url = args.url;
58
+ if (!url)
59
+ return 'Error: url is required';
21
60
  const method = args.method || 'GET';
61
+ const urlError = this.validateUrl(url);
62
+ if (urlError)
63
+ return `Error: ${urlError}`;
22
64
  const headers = args.headers || {};
23
65
  let body;
24
66
  if (args.json) {
@@ -18,5 +18,6 @@ export declare class WriteFileTool implements Tool {
18
18
  required: string[];
19
19
  };
20
20
  execute(args: Record<string, unknown>): Promise<string>;
21
+ private saveSnapshot;
21
22
  }
22
23
  //# sourceMappingURL=write.d.ts.map
@@ -36,9 +36,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.WriteFileTool = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
+ const os = __importStar(require("os"));
40
+ const UNDO_DIR = path.join(os.homedir(), '.codebot', 'undo');
39
41
  class WriteFileTool {
40
42
  name = 'write_file';
41
- description = 'Create a new file or overwrite an existing file with the given content.';
43
+ description = 'Create a new file or overwrite an existing file with the given content. Automatically saves an undo snapshot for existing files.';
42
44
  permission = 'prompt';
43
45
  parameters = {
44
46
  type: 'object',
@@ -49,17 +51,58 @@ class WriteFileTool {
49
51
  required: ['path', 'content'],
50
52
  };
51
53
  async execute(args) {
54
+ if (!args.path || typeof args.path !== 'string') {
55
+ return 'Error: path is required';
56
+ }
57
+ if (args.content === undefined || args.content === null) {
58
+ return 'Error: content is required';
59
+ }
52
60
  const filePath = path.resolve(args.path);
53
- const content = args.content;
61
+ const content = String(args.content);
54
62
  const dir = path.dirname(filePath);
55
63
  if (!fs.existsSync(dir)) {
56
64
  fs.mkdirSync(dir, { recursive: true });
57
65
  }
58
66
  const existed = fs.existsSync(filePath);
67
+ // Save undo snapshot before overwriting
68
+ if (existed) {
69
+ try {
70
+ const oldContent = fs.readFileSync(filePath, 'utf-8');
71
+ this.saveSnapshot(filePath, oldContent);
72
+ }
73
+ catch { /* best effort */ }
74
+ }
59
75
  fs.writeFileSync(filePath, content, 'utf-8');
60
76
  const lines = content.split('\n').length;
61
77
  return `${existed ? 'Overwrote' : 'Created'} ${filePath} (${lines} lines, ${content.length} bytes)`;
62
78
  }
79
+ saveSnapshot(filePath, content) {
80
+ try {
81
+ fs.mkdirSync(UNDO_DIR, { recursive: true });
82
+ const manifestPath = path.join(UNDO_DIR, 'manifest.json');
83
+ let manifest = [];
84
+ try {
85
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
86
+ }
87
+ catch { /* empty */ }
88
+ const entry = {
89
+ file: filePath,
90
+ timestamp: Date.now(),
91
+ snapshotFile: `${Date.now()}-${path.basename(filePath)}`,
92
+ };
93
+ fs.writeFileSync(path.join(UNDO_DIR, entry.snapshotFile), content);
94
+ manifest.push(entry);
95
+ while (manifest.length > 50) {
96
+ const old = manifest.shift();
97
+ try {
98
+ fs.unlinkSync(path.join(UNDO_DIR, old.snapshotFile));
99
+ }
100
+ catch { /* ok */ }
101
+ }
102
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
103
+ }
104
+ catch { /* best effort */ }
105
+ }
63
106
  }
64
107
  exports.WriteFileTool = WriteFileTool;
65
108
  //# sourceMappingURL=write.js.map
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "codebot-ai",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Local-first AI coding assistant. Zero dependencies. Works with Ollama, LM Studio, vLLM, Claude, GPT, Gemini, and more.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "bin": {
8
- "codebot": "./bin/codebot"
8
+ "codebot": "bin/codebot"
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc",