ikie-cli 0.1.23 → 0.1.25

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/dist/agent.js CHANGED
@@ -937,6 +937,9 @@ changes they didn't ask for.
937
937
  files/scripts) for a specific kind of task. The skills installed on this machine are listed
938
938
  under "Available Skills" below by name + description. When a task matches one, call
939
939
  \`use_skill\` with its exact name FIRST to pull in the full instructions, then follow them.
940
+ **Skill scripts are for your internal use only.** If a skill includes Python/bash scripts,
941
+ run them yourself with the bash tool and use the output. Never paste the raw commands or
942
+ tell the user to run them manually. Apply the skill's knowledge directly to the user's task.
940
943
  - \`install_skill\`: Install a skill from a git URL (GitHub, GitLab, Bitbucket) or local path.
941
944
  Skills are instruction packs that give the agent specialized expertise. Example:
942
945
  \`install_skill(source: "https://github.com/user/repo.git")\`.
@@ -945,6 +948,27 @@ changes they didn't ask for.
945
948
  - \`ask_user\`: Ask the user a clarifying question when you need more info to proceed.
946
949
  The user's answer is returned as the tool result. Use sparingly — only when genuinely
947
950
  unsure. Don't ask for confirmation on safe operations.
951
+
952
+ ## MCP (Model Context Protocol) System
953
+ - \`mcp_list\`: List all installed MCP servers and their available tools. MCPs extend ikie
954
+ with specialized capabilities like GitHub API, database access, browser automation, etc.
955
+ - \`mcp_install\`: Install a new MCP server from npm, git URL, or local path.
956
+ - \`mcp_start\`: Start an MCP server to make its tools available for use.
957
+ - \`mcp_stop\`: Stop a running MCP server.
958
+ - \`mcp_call\`: Call a tool from a running MCP. Use \`mcp_list\` first to see available tools.
959
+ - \`mcp_uninstall\`: Remove an installed MCP (built-in MCPs cannot be uninstalled).
960
+
961
+ **Built-in MCPs:**
962
+ - **filesystem**: Enhanced file operations (read multiple files, directory trees)
963
+ - **github**: GitHub API operations (search repos, manage issues, read files from repos)
964
+ - **database**: Database operations for SQLite and PostgreSQL
965
+ - **puppeteer**: Browser automation and web scraping
966
+
967
+ **When to use MCPs:** When you need specialized functionality beyond basic tools. For example:
968
+ - Use GitHub MCP to interact with repositories, issues, pull requests
969
+ - Use database MCP to query and manage databases
970
+ - Use puppeteer MCP for complex web interactions
971
+ - Install custom MCPs for domain-specific needs (Slack, Jira, AWS, etc.)
948
972
  `,
949
973
  ];
950
974
  if (skillsCatalog) {
@@ -0,0 +1,85 @@
1
+ export interface MCPTool {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: string;
6
+ properties: Record<string, any>;
7
+ required?: string[];
8
+ };
9
+ }
10
+ export interface MCPServer {
11
+ name: string;
12
+ command: string;
13
+ args: string[];
14
+ env?: Record<string, string>;
15
+ description: string;
16
+ enabled: boolean;
17
+ autoStart: boolean;
18
+ tools?: MCPTool[];
19
+ }
20
+ export interface MCPRegistry {
21
+ servers: Record<string, MCPServer>;
22
+ builtIn: string[];
23
+ }
24
+ export declare class MCPManager {
25
+ private registry;
26
+ private processes;
27
+ private messageHandlers;
28
+ constructor();
29
+ private ensureMCPDir;
30
+ private loadRegistry;
31
+ private saveRegistry;
32
+ private initializeBuiltInServers;
33
+ installMCP(config: {
34
+ name: string;
35
+ source: string;
36
+ description?: string;
37
+ env?: Record<string, string>;
38
+ autoStart?: boolean;
39
+ }): Promise<{
40
+ success: boolean;
41
+ error?: string;
42
+ server?: MCPServer;
43
+ }>;
44
+ uninstallMCP(name: string): {
45
+ success: boolean;
46
+ error?: string;
47
+ };
48
+ startMCP(name: string): Promise<{
49
+ success: boolean;
50
+ error?: string;
51
+ tools?: MCPTool[];
52
+ }>;
53
+ stopMCP(name: string): {
54
+ success: boolean;
55
+ error?: string;
56
+ };
57
+ private initializeMCP;
58
+ private requestMCPTools;
59
+ private sendMCPMessage;
60
+ private handleMCPMessage;
61
+ callMCPTool(serverName: string, toolName: string, args: Record<string, any>): Promise<{
62
+ success: boolean;
63
+ result?: any;
64
+ error?: string;
65
+ }>;
66
+ getAllTools(): Array<{
67
+ server: string;
68
+ tool: MCPTool;
69
+ }>;
70
+ listMCPs(): Array<{
71
+ name: string;
72
+ description: string;
73
+ enabled: boolean;
74
+ running: boolean;
75
+ builtIn: boolean;
76
+ tools?: MCPTool[];
77
+ }>;
78
+ setMCPEnabled(name: string, enabled: boolean): {
79
+ success: boolean;
80
+ error?: string;
81
+ };
82
+ startAutoStartMCPs(): Promise<void>;
83
+ stopAllMCPs(): void;
84
+ }
85
+ export declare function getMCPManager(): MCPManager;
@@ -0,0 +1,336 @@
1
+ import { spawn } from 'child_process';
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { HOME_DIR } from './config.js';
5
+ const MCP_REGISTRY_FILE = join(HOME_DIR, 'mcp-registry.json');
6
+ const MCP_DIR = join(HOME_DIR, 'mcps');
7
+ export class MCPManager {
8
+ registry;
9
+ processes = new Map();
10
+ messageHandlers = new Map();
11
+ constructor() {
12
+ this.ensureMCPDir();
13
+ this.registry = this.loadRegistry();
14
+ this.initializeBuiltInServers();
15
+ }
16
+ ensureMCPDir() {
17
+ if (!existsSync(MCP_DIR)) {
18
+ mkdirSync(MCP_DIR, { recursive: true });
19
+ }
20
+ }
21
+ loadRegistry() {
22
+ if (existsSync(MCP_REGISTRY_FILE)) {
23
+ try {
24
+ return JSON.parse(readFileSync(MCP_REGISTRY_FILE, 'utf-8'));
25
+ }
26
+ catch {
27
+ return { servers: {}, builtIn: [] };
28
+ }
29
+ }
30
+ return { servers: {}, builtIn: [] };
31
+ }
32
+ saveRegistry() {
33
+ writeFileSync(MCP_REGISTRY_FILE, JSON.stringify(this.registry, null, 2));
34
+ }
35
+ initializeBuiltInServers() {
36
+ const builtIn = {
37
+ filesystem: {
38
+ name: 'filesystem',
39
+ command: 'node',
40
+ args: [join(MCP_DIR, 'filesystem-server.js')],
41
+ description: 'File system operations MCP',
42
+ enabled: true,
43
+ autoStart: true,
44
+ },
45
+ github: {
46
+ name: 'github',
47
+ command: 'node',
48
+ args: [join(MCP_DIR, 'github-server.js')],
49
+ env: { GITHUB_TOKEN: process.env.GITHUB_TOKEN || '' },
50
+ description: 'GitHub API operations MCP',
51
+ enabled: false,
52
+ autoStart: false,
53
+ },
54
+ database: {
55
+ name: 'database',
56
+ command: 'node',
57
+ args: [join(MCP_DIR, 'database-server.js')],
58
+ description: 'Database operations MCP (SQLite, PostgreSQL)',
59
+ enabled: false,
60
+ autoStart: false,
61
+ },
62
+ puppeteer: {
63
+ name: 'puppeteer',
64
+ command: 'node',
65
+ args: [join(MCP_DIR, 'puppeteer-server.js')],
66
+ description: 'Browser automation MCP',
67
+ enabled: false,
68
+ autoStart: false,
69
+ },
70
+ };
71
+ for (const [name, server] of Object.entries(builtIn)) {
72
+ if (!this.registry.servers[name]) {
73
+ this.registry.servers[name] = server;
74
+ if (!this.registry.builtIn.includes(name)) {
75
+ this.registry.builtIn.push(name);
76
+ }
77
+ }
78
+ }
79
+ this.saveRegistry();
80
+ }
81
+ async installMCP(config) {
82
+ try {
83
+ if (this.registry.servers[config.name]) {
84
+ return { success: false, error: `MCP '${config.name}' already installed` };
85
+ }
86
+ let command;
87
+ let args;
88
+ if (config.source.startsWith('npm:')) {
89
+ const pkg = config.source.slice(4);
90
+ const { execSync } = await import('child_process');
91
+ execSync(`npm install -g ${pkg}`, { cwd: MCP_DIR });
92
+ command = pkg;
93
+ args = [];
94
+ }
95
+ else if (config.source.startsWith('http://') || config.source.startsWith('https://')) {
96
+ const { execSync } = await import('child_process');
97
+ const repoName = config.source.split('/').pop()?.replace('.git', '') || config.name;
98
+ const repoPath = join(MCP_DIR, repoName);
99
+ execSync(`git clone ${config.source} ${repoPath}`, { cwd: MCP_DIR });
100
+ execSync('npm install', { cwd: repoPath });
101
+ command = 'node';
102
+ args = [join(repoPath, 'index.js')];
103
+ }
104
+ else {
105
+ command = 'node';
106
+ args = [config.source];
107
+ }
108
+ const server = {
109
+ name: config.name,
110
+ command,
111
+ args,
112
+ env: config.env,
113
+ description: config.description || 'Custom MCP server',
114
+ enabled: true,
115
+ autoStart: config.autoStart ?? false,
116
+ };
117
+ this.registry.servers[config.name] = server;
118
+ this.saveRegistry();
119
+ return { success: true, server };
120
+ }
121
+ catch (error) {
122
+ return {
123
+ success: false,
124
+ error: error instanceof Error ? error.message : String(error),
125
+ };
126
+ }
127
+ }
128
+ uninstallMCP(name) {
129
+ if (!this.registry.servers[name]) {
130
+ return { success: false, error: `MCP '${name}' not found` };
131
+ }
132
+ if (this.registry.builtIn.includes(name)) {
133
+ return { success: false, error: `Cannot uninstall built-in MCP '${name}'` };
134
+ }
135
+ if (this.processes.has(name)) {
136
+ this.stopMCP(name);
137
+ }
138
+ delete this.registry.servers[name];
139
+ this.saveRegistry();
140
+ return { success: true };
141
+ }
142
+ async startMCP(name) {
143
+ const server = this.registry.servers[name];
144
+ if (!server) {
145
+ return { success: false, error: `MCP '${name}' not found` };
146
+ }
147
+ if (!server.enabled) {
148
+ return { success: false, error: `MCP '${name}' is disabled` };
149
+ }
150
+ if (this.processes.has(name)) {
151
+ return { success: false, error: `MCP '${name}' is already running` };
152
+ }
153
+ try {
154
+ const child = spawn(server.command, server.args, {
155
+ env: { ...process.env, ...server.env },
156
+ stdio: ['pipe', 'pipe', 'pipe'],
157
+ });
158
+ const mcpProcess = {
159
+ server,
160
+ process: child,
161
+ tools: [],
162
+ ready: false,
163
+ };
164
+ this.processes.set(name, mcpProcess);
165
+ child.stdout?.on('data', (data) => {
166
+ this.handleMCPMessage(name, data.toString());
167
+ });
168
+ child.stderr?.on('data', (data) => {
169
+ console.error(`MCP ${name} error:`, data.toString());
170
+ });
171
+ child.on('exit', (code) => {
172
+ console.log(`MCP ${name} exited with code ${code}`);
173
+ this.processes.delete(name);
174
+ });
175
+ await this.initializeMCP(name);
176
+ return { success: true, tools: mcpProcess.tools };
177
+ }
178
+ catch (error) {
179
+ return {
180
+ success: false,
181
+ error: error instanceof Error ? error.message : String(error),
182
+ };
183
+ }
184
+ }
185
+ stopMCP(name) {
186
+ const mcpProcess = this.processes.get(name);
187
+ if (!mcpProcess) {
188
+ return { success: false, error: `MCP '${name}' is not running` };
189
+ }
190
+ mcpProcess.process.kill();
191
+ this.processes.delete(name);
192
+ return { success: true };
193
+ }
194
+ async initializeMCP(name) {
195
+ const mcpProcess = this.processes.get(name);
196
+ if (!mcpProcess)
197
+ return;
198
+ this.sendMCPMessage(name, {
199
+ jsonrpc: '2.0',
200
+ id: 1,
201
+ method: 'initialize',
202
+ params: {
203
+ protocolVersion: '2024-11-05',
204
+ capabilities: {
205
+ roots: { listChanged: true },
206
+ },
207
+ clientInfo: {
208
+ name: 'ikie-cli',
209
+ version: '0.1.0',
210
+ },
211
+ },
212
+ });
213
+ await this.requestMCPTools(name);
214
+ }
215
+ async requestMCPTools(name) {
216
+ this.sendMCPMessage(name, {
217
+ jsonrpc: '2.0',
218
+ id: 2,
219
+ method: 'tools/list',
220
+ });
221
+ }
222
+ sendMCPMessage(name, message) {
223
+ const mcpProcess = this.processes.get(name);
224
+ if (!mcpProcess)
225
+ return;
226
+ const json = JSON.stringify(message) + '\n';
227
+ mcpProcess.process.stdin?.write(json);
228
+ }
229
+ handleMCPMessage(name, data) {
230
+ const lines = data.split('\n').filter(Boolean);
231
+ for (const line of lines) {
232
+ try {
233
+ const message = JSON.parse(line);
234
+ if (message.result?.tools) {
235
+ const mcpProcess = this.processes.get(name);
236
+ if (mcpProcess) {
237
+ mcpProcess.tools = message.result.tools;
238
+ mcpProcess.ready = true;
239
+ }
240
+ }
241
+ if (message.id && this.messageHandlers.has(`${name}-${message.id}`)) {
242
+ const handler = this.messageHandlers.get(`${name}-${message.id}`);
243
+ handler?.(message);
244
+ this.messageHandlers.delete(`${name}-${message.id}`);
245
+ }
246
+ }
247
+ catch (error) {
248
+ console.error(`Failed to parse MCP message from ${name}:`, error);
249
+ }
250
+ }
251
+ }
252
+ async callMCPTool(serverName, toolName, args) {
253
+ const mcpProcess = this.processes.get(serverName);
254
+ if (!mcpProcess) {
255
+ return { success: false, error: `MCP '${serverName}' is not running` };
256
+ }
257
+ if (!mcpProcess.ready) {
258
+ return { success: false, error: `MCP '${serverName}' is not ready` };
259
+ }
260
+ return new Promise((resolve) => {
261
+ const id = Date.now();
262
+ const handlerKey = `${serverName}-${id}`;
263
+ const timeout = setTimeout(() => {
264
+ this.messageHandlers.delete(handlerKey);
265
+ resolve({ success: false, error: 'MCP tool call timeout' });
266
+ }, 30000);
267
+ this.messageHandlers.set(handlerKey, (message) => {
268
+ clearTimeout(timeout);
269
+ if (message.error) {
270
+ resolve({ success: false, error: message.error.message });
271
+ }
272
+ else {
273
+ resolve({ success: true, result: message.result });
274
+ }
275
+ });
276
+ this.sendMCPMessage(serverName, {
277
+ jsonrpc: '2.0',
278
+ id,
279
+ method: 'tools/call',
280
+ params: {
281
+ name: toolName,
282
+ arguments: args,
283
+ },
284
+ });
285
+ });
286
+ }
287
+ getAllTools() {
288
+ const tools = [];
289
+ for (const [name, mcpProcess] of this.processes) {
290
+ if (mcpProcess.ready) {
291
+ for (const tool of mcpProcess.tools) {
292
+ tools.push({ server: name, tool });
293
+ }
294
+ }
295
+ }
296
+ return tools;
297
+ }
298
+ listMCPs() {
299
+ return Object.entries(this.registry.servers).map(([name, server]) => ({
300
+ name,
301
+ description: server.description,
302
+ enabled: server.enabled,
303
+ running: this.processes.has(name),
304
+ builtIn: this.registry.builtIn.includes(name),
305
+ tools: this.processes.get(name)?.tools,
306
+ }));
307
+ }
308
+ setMCPEnabled(name, enabled) {
309
+ const server = this.registry.servers[name];
310
+ if (!server) {
311
+ return { success: false, error: `MCP '${name}' not found` };
312
+ }
313
+ server.enabled = enabled;
314
+ this.saveRegistry();
315
+ return { success: true };
316
+ }
317
+ async startAutoStartMCPs() {
318
+ for (const [name, server] of Object.entries(this.registry.servers)) {
319
+ if (server.enabled && server.autoStart) {
320
+ await this.startMCP(name);
321
+ }
322
+ }
323
+ }
324
+ stopAllMCPs() {
325
+ for (const name of this.processes.keys()) {
326
+ this.stopMCP(name);
327
+ }
328
+ }
329
+ }
330
+ let mcpManager = null;
331
+ export function getMCPManager() {
332
+ if (!mcpManager) {
333
+ mcpManager = new MCPManager();
334
+ }
335
+ return mcpManager;
336
+ }
package/dist/skills.js CHANGED
@@ -179,7 +179,10 @@ export function renderSkill(skill) {
179
179
  parts.push('---');
180
180
  parts.push('Follow these instructions for the current task. Use your normal tools to read any ' +
181
181
  'bundled files or run any bundled scripts referenced above (resolve their paths against ' +
182
- 'the skill directory).');
182
+ 'the skill directory). ' +
183
+ 'IMPORTANT: do NOT show the user raw python3 / bash commands or tell them to run scripts manually. ' +
184
+ 'If a skill includes scripts, run them yourself with the bash tool and present the results. ' +
185
+ 'Use the skill\'s knowledge directly to do the work.');
183
186
  return parts.join('\n\n');
184
187
  }
185
188
  // ─── Install / remove ─────────────────────────────────────────────────────────
@@ -301,7 +304,8 @@ export async function installSkill(source, opts = {}) {
301
304
  }
302
305
  if (existsSync(target))
303
306
  rmSync(target, { recursive: true, force: true });
304
- cpSync(dir, target, { recursive: true });
307
+ // Dereference symlinks so bundled files don't point back to a temp clone dir.
308
+ cpSync(dir, target, { recursive: true, dereference: true });
305
309
  // Never carry version-control metadata into the skill store.
306
310
  rmSync(join(target, '.git'), { recursive: true, force: true });
307
311
  installed.push(name);
package/dist/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { exec, spawn } from 'child_process';
2
2
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from 'fs';
3
- import { dirname, join, resolve } from 'path';
3
+ import { dirname, join, relative, resolve } from 'path';
4
4
  import { promisify } from 'util';
5
5
  import { glob } from 'glob';
6
6
  const execAsync = promisify(exec);
@@ -328,15 +328,102 @@ export const TOOL_DEFS = [
328
328
  },
329
329
  },
330
330
  },
331
+ {
332
+ type: 'function',
333
+ function: {
334
+ name: 'mcp_install',
335
+ description: 'Install a new MCP (Model Context Protocol) server to extend capabilities. MCPs provide specialized tools like GitHub API, database access, browser automation, etc.',
336
+ parameters: {
337
+ type: 'object',
338
+ properties: {
339
+ name: { type: 'string', description: 'Unique name for this MCP' },
340
+ source: { type: 'string', description: 'Source: npm package (npm:package-name), git URL, or local path' },
341
+ description: { type: 'string', description: 'Description of what this MCP does' },
342
+ autoStart: { type: 'boolean', description: 'Start automatically on ikie launch (default: false)' },
343
+ },
344
+ required: ['name', 'source'],
345
+ },
346
+ },
347
+ },
348
+ {
349
+ type: 'function',
350
+ function: {
351
+ name: 'mcp_list',
352
+ description: 'List all installed MCP servers and their available tools.',
353
+ parameters: {
354
+ type: 'object',
355
+ properties: {},
356
+ required: [],
357
+ },
358
+ },
359
+ },
360
+ {
361
+ type: 'function',
362
+ function: {
363
+ name: 'mcp_start',
364
+ description: 'Start an installed MCP server to make its tools available.',
365
+ parameters: {
366
+ type: 'object',
367
+ properties: {
368
+ name: { type: 'string', description: 'Name of the MCP server to start' },
369
+ },
370
+ required: ['name'],
371
+ },
372
+ },
373
+ },
374
+ {
375
+ type: 'function',
376
+ function: {
377
+ name: 'mcp_stop',
378
+ description: 'Stop a running MCP server.',
379
+ parameters: {
380
+ type: 'object',
381
+ properties: {
382
+ name: { type: 'string', description: 'Name of the MCP server to stop' },
383
+ },
384
+ required: ['name'],
385
+ },
386
+ },
387
+ },
388
+ {
389
+ type: 'function',
390
+ function: {
391
+ name: 'mcp_call',
392
+ description: 'Call a tool from a running MCP server. Use mcp_list first to see available tools and their parameters.',
393
+ parameters: {
394
+ type: 'object',
395
+ properties: {
396
+ server: { type: 'string', description: 'MCP server name' },
397
+ tool: { type: 'string', description: 'Tool name from the MCP server' },
398
+ arguments: { type: 'object', description: 'Arguments to pass to the tool' },
399
+ },
400
+ required: ['server', 'tool', 'arguments'],
401
+ },
402
+ },
403
+ },
404
+ {
405
+ type: 'function',
406
+ function: {
407
+ name: 'mcp_uninstall',
408
+ description: 'Uninstall an MCP server (cannot uninstall built-in MCPs).',
409
+ parameters: {
410
+ type: 'object',
411
+ properties: {
412
+ name: { type: 'string', description: 'Name of the MCP server to uninstall' },
413
+ },
414
+ required: ['name'],
415
+ },
416
+ },
417
+ },
331
418
  ];
332
419
  // ─── Safe tools (auto-approved) ───────────────────────────────────────────────
333
420
  // spawn_agent is "safe" at the dispatch layer — the tools the sub-agent itself
334
421
  // runs go through their own approval inside the sub-agent loop.
335
- export const SAFE_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'grep', 'memory_write', 'spawn_agent', 'ask_user', 'fetch_url', 'web_search', 'git_status', 'git_diff', 'git_log', 'git_branch', 'use_skill', 'install_skill', 'remove_skill']);
422
+ export const SAFE_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'grep', 'memory_write', 'spawn_agent', 'ask_user', 'fetch_url', 'web_search', 'git_status', 'git_diff', 'git_log', 'git_branch', 'use_skill', 'install_skill', 'remove_skill', 'mcp_list', 'mcp_start', 'mcp_stop', 'mcp_call']);
336
423
  // Tools available in PLAN mode — read-only exploration plus delegation/questions.
337
424
  // Everything that mutates the filesystem or runs commands (write_file, edit_file,
338
425
  // bash, memory_write) is intentionally excluded so plan mode can only research.
339
- export const PLAN_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'grep', 'spawn_agent', 'ask_user', 'fetch_url', 'web_search', 'git_status', 'git_diff', 'git_log', 'git_branch', 'use_skill', 'install_skill', 'remove_skill']);
426
+ export const PLAN_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'grep', 'spawn_agent', 'ask_user', 'fetch_url', 'web_search', 'git_status', 'git_diff', 'git_log', 'git_branch', 'use_skill', 'install_skill', 'remove_skill', 'mcp_list', 'mcp_call']);
340
427
  // Paths that may contain secrets, credentials, or system configuration.
341
428
  // Reading these requires explicit user permission even though read_file is normally safe.
342
429
  const RESTRICTED_PATTERNS = [
@@ -424,14 +511,84 @@ export function formatToolArgs(name, input) {
424
511
  return JSON.stringify(input).slice(0, 80);
425
512
  }
426
513
  }
514
+ // ─── Security Validation Functions ───────────────────────────────────────────
515
+ /**
516
+ * Validates that a path is safe and within allowed boundaries
517
+ */
518
+ function validatePathSafety(userPath) {
519
+ try {
520
+ const resolved = resolve(userPath);
521
+ const cwd = process.cwd();
522
+ const rel = relative(cwd, resolved);
523
+ if (rel.startsWith('..') || resolve(rel) !== rel) {
524
+ return { safe: false, resolved, error: 'Path traversal detected' };
525
+ }
526
+ if (!resolved.startsWith(cwd)) {
527
+ return { safe: false, resolved, error: 'Path outside working directory' };
528
+ }
529
+ return { safe: true, resolved };
530
+ }
531
+ catch (e) {
532
+ return { safe: false, resolved: '', error: `Invalid path: ${e}` };
533
+ }
534
+ }
535
+ /**
536
+ * Validates bash command for safety
537
+ */
538
+ function validateBashCommand(command) {
539
+ const trimmed = command.trim();
540
+ const dangerousPatterns = [
541
+ { pattern: /[;&|`](?!.*\bin\b.*[\"'])/g, desc: 'shell metacharacters' },
542
+ { pattern: /\$\(/g, desc: 'command substitution' },
543
+ { pattern: />\s*\/(?:dev|etc|proc|sys)\b/g, desc: 'system file access' },
544
+ ];
545
+ for (const { pattern, desc } of dangerousPatterns) {
546
+ if (pattern.test(trimmed)) {
547
+ return { safe: false, error: `Potentially dangerous: ${desc}` };
548
+ }
549
+ }
550
+ return { safe: true };
551
+ }
552
+ /**
553
+ * Sanitizes git branch names
554
+ */
555
+ function sanitizeGitBranchName(name) {
556
+ const trimmed = name.trim();
557
+ const dangerous = /[~^:\\@{}]|\.\.|\/{2,}|^[\/\.\-]|[\/\.]$/;
558
+ if (dangerous.test(trimmed)) {
559
+ return { safe: false, sanitized: '', error: 'Invalid branch name characters' };
560
+ }
561
+ if (trimmed.length === 0 || trimmed.length > 255) {
562
+ return { safe: false, sanitized: '', error: 'Branch name length must be 1-255 characters' };
563
+ }
564
+ return { safe: true, sanitized: trimmed };
565
+ }
566
+ /**
567
+ * Sanitizes error messages to prevent information leakage
568
+ */
569
+ function sanitizeError(error) {
570
+ if (error instanceof Error) {
571
+ return error.message.split('\n')[0];
572
+ }
573
+ return String(error).split('\n')[0];
574
+ }
427
575
  // ─── Executors ────────────────────────────────────────────────────────────────
428
576
  function readFile(input) {
429
577
  if (!input.path || input.path === 'undefined')
430
578
  return 'Error: path is required for read_file';
431
- const abs = resolve(input.path);
579
+ const pathCheck = validatePathSafety(input.path);
580
+ if (!pathCheck.safe) {
581
+ return `Error: ${pathCheck.error}`;
582
+ }
583
+ const abs = pathCheck.resolved;
432
584
  if (!existsSync(abs))
433
585
  return `Error: File not found: ${input.path}`;
434
586
  try {
587
+ const stats = statSync(abs);
588
+ const maxSize = 10 * 1024 * 1024;
589
+ if (stats.size > maxSize) {
590
+ return `Error: File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB). Maximum: 10MB`;
591
+ }
435
592
  const content = readFileSync(abs, 'utf-8');
436
593
  const lines = content.split('\n');
437
594
  const start = (input.start_line ?? 1) - 1;
@@ -439,7 +596,7 @@ function readFile(input) {
439
596
  return lines.slice(start, end).map((l, i) => `${String(i + start + 1).padStart(4)} │ ${l}`).join('\n');
440
597
  }
441
598
  catch (e) {
442
- return `Error: ${e}`;
599
+ return `Error: ${sanitizeError(e)}`;
443
600
  }
444
601
  }
445
602
  function writeFile(input) {
@@ -447,22 +604,35 @@ function writeFile(input) {
447
604
  return 'Error: path is required for write_file';
448
605
  if (input.content == null)
449
606
  return 'Error: content is required for write_file';
450
- const abs = resolve(input.path);
607
+ const pathCheck = validatePathSafety(input.path);
608
+ if (!pathCheck.safe) {
609
+ return `Error: ${pathCheck.error}`;
610
+ }
611
+ const abs = pathCheck.resolved;
612
+ const maxSize = 5 * 1024 * 1024;
613
+ const contentSize = Buffer.byteLength(input.content, 'utf-8');
614
+ if (contentSize > maxSize) {
615
+ return `Error: Content too large (${(contentSize / 1024 / 1024).toFixed(2)}MB). Maximum: 5MB`;
616
+ }
451
617
  const dir = dirname(abs);
452
- if (!existsSync(dir))
453
- mkdirSync(dir, { recursive: true });
454
618
  try {
619
+ if (!existsSync(dir))
620
+ mkdirSync(dir, { recursive: true });
455
621
  writeFileSync(abs, input.content);
456
622
  return `Written ${input.content.split('\n').length} lines to ${input.path}`;
457
623
  }
458
624
  catch (e) {
459
- return `Error: ${e}`;
625
+ return `Error: ${sanitizeError(e)}`;
460
626
  }
461
627
  }
462
628
  function editFile(input) {
463
629
  if (!input.path || input.path === 'undefined')
464
630
  return 'Error: path is required for edit_file';
465
- const abs = resolve(input.path);
631
+ const pathCheck = validatePathSafety(input.path);
632
+ if (!pathCheck.safe) {
633
+ return `Error: ${pathCheck.error}`;
634
+ }
635
+ const abs = pathCheck.resolved;
466
636
  if (!existsSync(abs))
467
637
  return `Error: File not found: ${input.path}`;
468
638
  try {
@@ -476,13 +646,23 @@ function editFile(input) {
476
646
  return `Edited ${input.path} — replaced 1 occurrence`;
477
647
  }
478
648
  catch (e) {
479
- return `Error: ${e}`;
649
+ return `Error: ${sanitizeError(e)}`;
480
650
  }
481
651
  }
482
652
  async function bash(input) {
483
- const cwd = input.cwd ? resolve(input.cwd) : process.cwd();
653
+ let cwd = process.cwd();
654
+ if (input.cwd) {
655
+ const cwdCheck = validatePathSafety(input.cwd);
656
+ if (!cwdCheck.safe) {
657
+ return `Error: ${cwdCheck.error}`;
658
+ }
659
+ cwd = cwdCheck.resolved;
660
+ }
484
661
  const command = input.command.trim();
485
- // Background commands (ending with &) — detach properly so they survive
662
+ const validation = validateBashCommand(command);
663
+ if (!validation.safe) {
664
+ return `Error: ${validation.error}. Command blocked for security.`;
665
+ }
486
666
  if (command.endsWith('&')) {
487
667
  const bgCmd = command.slice(0, -1).trim();
488
668
  try {
@@ -500,13 +680,15 @@ async function bash(input) {
500
680
  return `Started background process (PID: ${child.pid})`;
501
681
  }
502
682
  catch (err) {
503
- return `Error starting background process: ${err}`;
683
+ return `Error starting background process: ${sanitizeError(err)}`;
504
684
  }
505
685
  }
686
+ const maxTimeout = 60000;
687
+ const timeout = Math.min(input.timeout_ms ?? 30000, maxTimeout);
506
688
  try {
507
689
  const { stdout, stderr } = await execAsync(command, {
508
690
  cwd,
509
- timeout: input.timeout_ms ?? 30000,
691
+ timeout,
510
692
  maxBuffer: 2 * 1024 * 1024,
511
693
  });
512
694
  const parts = [];
@@ -524,12 +706,15 @@ async function bash(input) {
524
706
  if (e.stderr?.trim())
525
707
  parts.push(`[stderr]\n${e.stderr.trim()}`);
526
708
  if (!parts.length)
527
- parts.push(e.message ?? 'Command failed');
709
+ parts.push(sanitizeError(e.message ?? 'Command failed'));
528
710
  return `Exit ${e.code ?? 1}\n${parts.join('\n')}`;
529
711
  }
530
712
  }
531
713
  function listDir(input) {
532
- const root = resolve(input.path ?? '.');
714
+ const check = validatePathSafety(input.path ?? '.');
715
+ if (!check.safe)
716
+ return `Error: ${check.error}`;
717
+ const root = check.resolved;
533
718
  if (!existsSync(root))
534
719
  return `Error: Not found: ${input.path}`;
535
720
  const maxDepth = input.max_depth ?? 3;
@@ -582,6 +767,8 @@ async function grepFiles(input) {
582
767
  const flags = input.ignore_case ? 'i' : '';
583
768
  let re;
584
769
  try {
770
+ if (input.pattern.length > 200)
771
+ return 'Error: Regex pattern too long (max 200 characters)';
585
772
  re = new RegExp(input.pattern, flags);
586
773
  }
587
774
  catch {
@@ -680,8 +867,7 @@ async function memoryWrite(input) {
680
867
  return `Saved to ${scope} memory.`;
681
868
  }
682
869
  catch (err) {
683
- const msg = err instanceof Error ? err.message : String(err);
684
- return `Error saving to ${scope} memory: ${msg}`;
870
+ return `Error saving to ${scope} memory: ${sanitizeError(err)}`;
685
871
  }
686
872
  }
687
873
  // ─── Git ──────────────────────────────────────────────────────────────────────
@@ -693,7 +879,7 @@ async function gitStatus(input) {
693
879
  }
694
880
  catch (err) {
695
881
  const e = err;
696
- return `Error: ${e.stderr?.trim() ?? e.message ?? err}`;
882
+ return `Error: ${sanitizeError(e.stderr ?? e.message ?? err)}`;
697
883
  }
698
884
  }
699
885
  async function gitDiff(input) {
@@ -706,7 +892,7 @@ async function gitDiff(input) {
706
892
  }
707
893
  catch (err) {
708
894
  const e = err;
709
- return `Error: ${e.stderr?.trim() ?? e.message ?? err}`;
895
+ return `Error: ${sanitizeError(e.stderr ?? e.message ?? err)}`;
710
896
  }
711
897
  }
712
898
  async function gitLog(input) {
@@ -717,7 +903,7 @@ async function gitLog(input) {
717
903
  }
718
904
  catch (err) {
719
905
  const e = err;
720
- return `Error: ${e.stderr?.trim() ?? e.message ?? err}`;
906
+ return `Error: ${sanitizeError(e.stderr ?? e.message ?? err)}`;
721
907
  }
722
908
  }
723
909
  async function gitCommit(input) {
@@ -740,7 +926,7 @@ async function gitCommit(input) {
740
926
  parts.push(e.stdout.trim());
741
927
  if (e.stderr?.trim())
742
928
  parts.push(e.stderr.trim());
743
- return `Error: ${parts.join('\n') || (e.message ?? String(err))}`;
929
+ return `Error: ${parts.join('\n') || sanitizeError(e)}`;
744
930
  }
745
931
  }
746
932
  async function gitBranch(input) {
@@ -750,16 +936,21 @@ async function gitBranch(input) {
750
936
  const { stdout } = await execAsync('git branch -a', { cwd });
751
937
  return stdout.trim() || '(no branches)';
752
938
  }
939
+ const validation = sanitizeGitBranchName(input.name);
940
+ if (!validation.safe) {
941
+ return `Error: ${validation.error}`;
942
+ }
943
+ const safeName = validation.sanitized;
753
944
  if (input.checkout) {
754
- const { stdout, stderr } = await execAsync(`git checkout -b "${input.name}"`, { cwd });
755
- return (stdout + stderr).trim() || `Switched to new branch '${input.name}'`;
945
+ const { stdout, stderr } = await execAsync(`git checkout -b ${JSON.stringify(safeName)}`, { cwd });
946
+ return (stdout + stderr).trim() || `Switched to new branch '${safeName}'`;
756
947
  }
757
- await execAsync(`git branch "${input.name}"`, { cwd });
758
- return `Created branch '${input.name}'`;
948
+ await execAsync(`git branch ${JSON.stringify(safeName)}`, { cwd });
949
+ return `Created branch '${safeName}'`;
759
950
  }
760
951
  catch (err) {
761
952
  const e = err;
762
- return `Error: ${e.stderr?.trim() ?? e.message ?? err}`;
953
+ return `Error: ${sanitizeError(e.stderr ?? e.message ?? err)}`;
763
954
  }
764
955
  }
765
956
  // ─── Web ──────────────────────────────────────────────────────────────────────
@@ -823,7 +1014,7 @@ async function fetchUrl(input) {
823
1014
  const e = err;
824
1015
  if (e.name === 'AbortError')
825
1016
  return `Error: request to ${url} timed out after 15s`;
826
- return `Error fetching ${url}: ${e.message ?? err}`;
1017
+ return `Error fetching ${url}: ${sanitizeError(e)}`;
827
1018
  }
828
1019
  finally {
829
1020
  clearTimeout(timer);
@@ -834,7 +1025,6 @@ async function webSearch(input) {
834
1025
  if (!query || query === 'undefined')
835
1026
  return 'Error: query is required for web_search';
836
1027
  const count = Math.min(Math.max(input.count ?? 5, 1), 10);
837
- // Load account key at call time (same pattern as memoryWrite's dynamic import).
838
1028
  const { loadConfig, getApiKey, IKIE_API_BASE } = await import('./config.js');
839
1029
  const apiKey = getApiKey(loadConfig());
840
1030
  if (!apiKey) {
@@ -855,8 +1045,7 @@ async function webSearch(input) {
855
1045
  return formatSearchResults(query, data.results ?? []);
856
1046
  }
857
1047
  catch (err) {
858
- const e = err;
859
- return `Error during web_search: ${e.message ?? err}`;
1048
+ return `Error during web_search: ${sanitizeError(err)}`;
860
1049
  }
861
1050
  }
862
1051
  function formatSearchResults(query, results) {
@@ -901,7 +1090,7 @@ async function installSkill(input) {
901
1090
  return lines.length ? lines.join('\n') : 'Nothing to install.';
902
1091
  }
903
1092
  catch (err) {
904
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
1093
+ return `Error: ${sanitizeError(err)}`;
905
1094
  }
906
1095
  }
907
1096
  async function removeSkill(input) {
@@ -918,6 +1107,81 @@ async function removeSkill(input) {
918
1107
  const removed = doRemove(name);
919
1108
  return removed ? `Removed skill "${name}".` : `No skill named "${name}" found.`;
920
1109
  }
1110
+ // ─── MCP Functions ────────────────────────────────────────────────────────────
1111
+ async function mcpInstall(input) {
1112
+ const { getMCPManager } = await import('./mcp-manager.js');
1113
+ const manager = getMCPManager();
1114
+ const result = await manager.installMCP({
1115
+ name: input.name,
1116
+ source: input.source,
1117
+ description: input.description,
1118
+ autoStart: input.autoStart,
1119
+ });
1120
+ if (!result.success) {
1121
+ return `Error installing MCP: ${result.error}`;
1122
+ }
1123
+ return `✓ Installed MCP "${input.name}".\nRun mcp_start to activate it.`;
1124
+ }
1125
+ async function mcpList() {
1126
+ const { getMCPManager } = await import('./mcp-manager.js');
1127
+ const manager = getMCPManager();
1128
+ const mcps = manager.listMCPs();
1129
+ if (!mcps.length) {
1130
+ return 'No MCPs installed. Use mcp_install to add MCP servers.';
1131
+ }
1132
+ const lines = ['Available MCPs:\n'];
1133
+ for (const mcp of mcps) {
1134
+ const status = mcp.running ? '🟢 Running' : mcp.enabled ? '⚪ Stopped' : '⚫ Disabled';
1135
+ const builtIn = mcp.builtIn ? ' [Built-in]' : '';
1136
+ lines.push(`${status} ${mcp.name}${builtIn}`);
1137
+ lines.push(` ${mcp.description}`);
1138
+ if (mcp.running && mcp.tools) {
1139
+ lines.push(` Tools: ${mcp.tools.map(t => t.name).join(', ')}`);
1140
+ }
1141
+ lines.push('');
1142
+ }
1143
+ return lines.join('\n');
1144
+ }
1145
+ async function mcpStart(input) {
1146
+ const { getMCPManager } = await import('./mcp-manager.js');
1147
+ const manager = getMCPManager();
1148
+ const result = await manager.startMCP(input.name);
1149
+ if (!result.success) {
1150
+ return `Error starting MCP: ${result.error}`;
1151
+ }
1152
+ const toolCount = result.tools?.length || 0;
1153
+ const toolNames = result.tools?.map(t => t.name).join(', ') || 'none';
1154
+ return `✓ Started MCP "${input.name}".\nAvailable tools (${toolCount}): ${toolNames}`;
1155
+ }
1156
+ async function mcpStop(input) {
1157
+ const { getMCPManager } = await import('./mcp-manager.js');
1158
+ const manager = getMCPManager();
1159
+ const result = manager.stopMCP(input.name);
1160
+ if (!result.success) {
1161
+ return `Error stopping MCP: ${result.error}`;
1162
+ }
1163
+ return `✓ Stopped MCP "${input.name}".`;
1164
+ }
1165
+ async function mcpCall(input) {
1166
+ const { getMCPManager } = await import('./mcp-manager.js');
1167
+ const manager = getMCPManager();
1168
+ const result = await manager.callMCPTool(input.server, input.tool, input.arguments);
1169
+ if (!result.success) {
1170
+ return `Error calling MCP tool: ${result.error}`;
1171
+ }
1172
+ return typeof result.result === 'string'
1173
+ ? result.result
1174
+ : JSON.stringify(result.result, null, 2);
1175
+ }
1176
+ async function mcpUninstall(input) {
1177
+ const { getMCPManager } = await import('./mcp-manager.js');
1178
+ const manager = getMCPManager();
1179
+ const result = manager.uninstallMCP(input.name);
1180
+ if (!result.success) {
1181
+ return `Error uninstalling MCP: ${result.error}`;
1182
+ }
1183
+ return `✓ Uninstalled MCP "${input.name}".`;
1184
+ }
921
1185
  // ─── Dispatcher ───────────────────────────────────────────────────────────────
922
1186
  export async function executeTool(name, input) {
923
1187
  switch (name) {
@@ -940,6 +1204,12 @@ export async function executeTool(name, input) {
940
1204
  case 'use_skill': return useSkill(input);
941
1205
  case 'install_skill': return installSkill(input);
942
1206
  case 'remove_skill': return removeSkill(input);
1207
+ case 'mcp_install': return mcpInstall(input);
1208
+ case 'mcp_list': return mcpList();
1209
+ case 'mcp_start': return mcpStart(input);
1210
+ case 'mcp_stop': return mcpStop(input);
1211
+ case 'mcp_call': return mcpCall(input);
1212
+ case 'mcp_uninstall': return mcpUninstall(input);
943
1213
  default: return `Unknown tool: ${name}`;
944
1214
  }
945
1215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikie-cli",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Agentic coding CLI — your terminal AI pair programmer",
5
5
  "type": "module",
6
6
  "bin": {