shoud-cli 1.0.11 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ const { parseShell } = require('./shellParser');
2
+
3
+ const Risk = Object.freeze({
4
+ READ: 0,
5
+ PROJECT_LOCAL: 1,
6
+ LOCAL_MODIFICATION: 2,
7
+ EXTERNAL: 3,
8
+ DESTRUCTIVE: 4,
9
+ });
10
+
11
+ const READ_BINARIES = new Set([
12
+ 'cat','ls','pwd','find','grep','rg','head','tail','wc','file','stat',
13
+ 'git','npm','pnpm','yarn','node','python','python3','which','echo','env','printenv',
14
+ ]);
15
+ const PROJECT_BINARIES = new Set([
16
+ 'eslint','tsc','vitest','jest','mocha','pytest','cargo','go','make','gradle','mvn',
17
+ ]);
18
+
19
+ const DESTRUCTIVE_PATTERNS = [
20
+ /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)?\//, // rm -rf /
21
+ /\bsudo\b/,
22
+ /\bchmod\s+777\b/,
23
+ /\bchown\s+-R\b/,
24
+ /\bgit\s+reset\s+--hard\b/,
25
+ /\bgit\s+clean\s+-[a-z]*f/,
26
+ /\bgit\s+push\s+--force\b/,
27
+ /\bgit\s+push\s+-f\b/,
28
+ /DROP\s+(TABLE|DATABASE)/i,
29
+ /TRUNCATE\s+TABLE/i,
30
+ /\bterraform\s+destroy\b/,
31
+ /\bkubectl\s+delete\b/,
32
+ /:\(\)\s*\{.*\};:/, // fork bomb
33
+ /\bmkfs\b/,
34
+ /\bdd\s+if=/,
35
+ />\s*\/dev\/sd[a-z]/,
36
+ ];
37
+
38
+ const EXTERNAL_PATTERNS = [
39
+ /\bcurl\b/, /\bwget\b/, /\bnc\b/, /\bssh\b/, /\bscp\b/,
40
+ /\bgit\s+(commit|push|pull|fetch|clone|remote)\b/,
41
+ /\bnpm\s+publish\b/, /\bdocker\b/, /\bkubectl\b/, /\bterraform\b/,
42
+ /\baws\b/, /\bgcloud\b/, /\baz\b/,
43
+ /\|\s*(sh|bash|zsh)\b/, // pipe to shell — very dangerous
44
+ ];
45
+
46
+ const LOCAL_MOD_PATTERNS = [
47
+ /\bnpm\s+(install|i|uninstall)\b/,
48
+ /\bpnpm\s+(add|install|remove)\b/,
49
+ /\byarn\s+(add|remove)\b/,
50
+ /\bgit\s+(add|rm|mv|checkout|switch|stash)\b/,
51
+ /\bmv\b/, /\bcp\b/, /\bmkdir\b/, /\btouch\b/,
52
+ ];
53
+
54
+ function classifyCommand(commandStr) {
55
+ const parsed = parseShell(commandStr);
56
+ let maxRisk = Risk.READ;
57
+ const reasons = [];
58
+
59
+ for (const seg of parsed.segments) {
60
+ const cmd = seg.command;
61
+ const first = (cmd.match(/^[\w./-]+/) || [''])[0];
62
+ const binary = first.split('/').pop();
63
+
64
+ // 1. Destructive
65
+ if (DESTRUCTIVE_PATTERNS.some(re => re.test(cmd))) {
66
+ maxRisk = Math.max(maxRisk, Risk.DESTRUCTIVE);
67
+ reasons.push(`destructive pattern: ${cmd.slice(0, 60)}`);
68
+ continue;
69
+ }
70
+
71
+ // 2. External
72
+ if (EXTERNAL_PATTERNS.some(re => re.test(cmd))) {
73
+ maxRisk = Math.max(maxRisk, Risk.EXTERNAL);
74
+ reasons.push(`external effect: ${cmd.slice(0, 60)}`);
75
+ continue;
76
+ }
77
+
78
+ // 3. Local modification
79
+ if (LOCAL_MOD_PATTERNS.some(re => re.test(cmd))) {
80
+ maxRisk = Math.max(maxRisk, Risk.LOCAL_MODIFICATION);
81
+ reasons.push(`local modification: ${cmd.slice(0, 60)}`);
82
+ continue;
83
+ }
84
+
85
+ // 4. Project-local tools
86
+ if (PROJECT_BINARIES.has(binary)) {
87
+ maxRisk = Math.max(maxRisk, Risk.PROJECT_LOCAL);
88
+ continue;
89
+ }
90
+
91
+ // 5. Read-only binaries
92
+ if (READ_BINARIES.has(binary)) {
93
+ maxRisk = Math.max(maxRisk, Risk.READ);
94
+ continue;
95
+ }
96
+
97
+ // 6. Unknown → treat as project-local at minimum
98
+ maxRisk = Math.max(maxRisk, Risk.PROJECT_LOCAL);
99
+ reasons.push(`unknown binary: ${binary}`);
100
+ }
101
+
102
+ // Elevate risk for structural hazards
103
+ if (parsed.hasSubshell) {
104
+ maxRisk = Math.max(maxRisk, Risk.LOCAL_MODIFICATION);
105
+ reasons.push('subshell detected');
106
+ }
107
+ if (parsed.hasRedirect) {
108
+ maxRisk = Math.max(maxRisk, Risk.LOCAL_MODIFICATION);
109
+ reasons.push('output redirection');
110
+ }
111
+ if (parsed.segments.length > 1) {
112
+ // Compound commands: take the max, already done, but flag
113
+ reasons.push(`compound command (${parsed.segments.length} segments)`);
114
+ }
115
+
116
+ return { risk: maxRisk, reasons, segments: parsed.segments.length };
117
+ }
118
+
119
+ const RiskLabel = {
120
+ [Risk.READ]: 'READ',
121
+ [Risk.PROJECT_LOCAL]: 'PROJECT',
122
+ [Risk.LOCAL_MODIFICATION]: 'MODIFY',
123
+ [Risk.EXTERNAL]: 'EXTERNAL',
124
+ [Risk.DESTRUCTIVE]: 'DESTRUCTIVE',
125
+ };
126
+
127
+ module.exports = { Risk, RiskLabel, classifyCommand };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Scans text for likely secrets and returns redacted version.
3
+ * Conservative — better to over-redact than leak.
4
+ */
5
+ const PATTERNS = [
6
+ // OpenAI / Anthropic / generic API keys
7
+ { re: /\b(sk-[A-Za-z0-9_-]{20,})\b/g, sub: 'sk-[REDACTED]' },
8
+ { re: /\b(sk-ant-[A-Za-z0-9_-]{20,})\b/g, sub: 'sk-ant-[REDACTED]' },
9
+ // AWS
10
+ { re: /\b(AKIA[0-9A-Z]{16})\b/g, sub: 'AKIA[REDACTED]' },
11
+ { re: /(aws_secret_access_key\s*[=:]\s*)([A-Za-z0-9+/=]{40})/gi, sub: '$1[REDACTED]' },
12
+ // GitHub
13
+ { re: /\b(gh[pousr]_[A-Za-z0-9]{36,})\b/g, sub: 'gh[REDACTED]' },
14
+ // JWTs (standalone)
15
+ { re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, sub: '[JWT REDACTED]' },
16
+ // Private keys
17
+ { re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, sub: '[PRIVATE KEY REDACTED]' },
18
+ // Generic key=value for suspicious names
19
+ { re: /((?:api[_-]?key|secret|token|password|passwd|pwd)\s*[=:]\s*)(["']?)([^\s"'#]{6,})\2/gi, sub: '$1$2[REDACTED]$2' },
20
+ // .env-style
21
+ { re: /^([A-Z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PWD)[A-Z0-9_]*=)(.+)$/gm, sub: '$1[REDACTED]' },
22
+ // DB URLs
23
+ { re: /(postgres|mysql|mongodb|redis):\/\/[^\s]+/gi, sub: '$1://[REDACTED]' },
24
+ ];
25
+
26
+ function redact(text) {
27
+ if (typeof text !== 'string') return text;
28
+ let out = text;
29
+ let redactions = 0;
30
+ for (const { re, sub } of PATTERNS) {
31
+ out = out.replace(re, (...args) => {
32
+ redactions++;
33
+ return typeof sub === 'function' ? sub(...args) : sub;
34
+ });
35
+ }
36
+ return { text: out, redactions };
37
+ }
38
+
39
+ function redactDeep(value) {
40
+ if (typeof value === 'string') return redact(value).text;
41
+ if (Array.isArray(value)) return value.map(redactDeep);
42
+ if (value && typeof value === 'object') {
43
+ const out = {};
44
+ for (const [k, v] of Object.entries(value)) {
45
+ // Redact by key name too
46
+ if (/key|secret|token|password|pwd/i.test(k) && typeof v === 'string') {
47
+ out[k] = '[REDACTED]';
48
+ } else {
49
+ out[k] = redactDeep(v);
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ return value;
55
+ }
56
+
57
+ module.exports = { redact, redactDeep };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Splits a shell command line into "segments" that would each be executed.
3
+ * Detects compound operators: && || ; | $(...) `...` > >> <
4
+ *
5
+ * Returns { segments: [{command, operator}], hasSubshell, hasRedirect }
6
+ */
7
+ function parseShell(input) {
8
+ const segments = [];
9
+ let current = '';
10
+ let depth = 0; // inside $( ) or ``
11
+ let quote = null; // ' or "
12
+ let hasSubshell = false;
13
+ let hasRedirect = false;
14
+
15
+ const push = (op) => {
16
+ const trimmed = current.trim();
17
+ if (trimmed) segments.push({ command: trimmed, operator: op });
18
+ current = '';
19
+ };
20
+
21
+ for (let i = 0; i < input.length; i++) {
22
+ const c = input[i];
23
+ const next = input[i + 1];
24
+
25
+ if (quote) {
26
+ current += c;
27
+ if (c === quote) quote = null;
28
+ continue;
29
+ }
30
+ if (c === '"' || c === "'") { quote = c; current += c; continue; }
31
+ if (c === '$' && next === '(') { depth++; hasSubshell = true; current += c; continue; }
32
+ if (c === '`') { hasSubshell = true; current += c; continue; }
33
+ if (c === ')' && depth > 0) { depth--; current += c; continue; }
34
+
35
+ if (depth === 0) {
36
+ if (c === '&' && next === '&') { push('&&'); i++; continue; }
37
+ if (c === '|' && next === '|') { push('||'); i++; continue; }
38
+ if (c === ';') { push(';'); continue; }
39
+ if (c === '|') { push('|'); continue; }
40
+ if (c === '>' || c === '<') {
41
+ hasRedirect = true;
42
+ // collect redirect target
43
+ let redir = c;
44
+ while (input[i + 1] === '>' || input[i + 1] === '&') { redir += input[i + 1]; i++; }
45
+ push(redir);
46
+ continue;
47
+ }
48
+ }
49
+ current += c;
50
+ }
51
+ push(null);
52
+
53
+ return { segments, hasSubshell, hasRedirect };
54
+ }
55
+
56
+ module.exports = { parseShell };
@@ -0,0 +1,81 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const diff = require('diff');
4
+ const { isIgnored, loadIgnore } = require('../project/ignore');
5
+
6
+ const MAX_READ_BYTES = 2 * 1024 * 1024;
7
+
8
+ function sanitizePath(projectRoot, inputPath) {
9
+ const resolved = path.resolve(projectRoot, inputPath);
10
+ const rel = path.relative(projectRoot, resolved);
11
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
12
+ throw new Error(`Path escapes project root: ${inputPath}`);
13
+ }
14
+ return resolved;
15
+ }
16
+
17
+ function readFile(projectRoot, filePath, { startLine, endLine } = {}) {
18
+ const full = sanitizePath(projectRoot, filePath);
19
+ const ig = loadIgnore(projectRoot);
20
+ if (isIgnored(projectRoot, full, ig)) {
21
+ throw new Error(`Path is .shoudignore'd: ${filePath}`);
22
+ }
23
+ const stat = fs.statSync(full);
24
+ if (!stat.isFile()) throw new Error('Not a file.');
25
+
26
+ if (startLine || endLine) {
27
+ const lines = fs.readFileSync(full, 'utf-8').split('\n');
28
+ const s = Math.max(0, (startLine || 1) - 1);
29
+ const e = Math.min(lines.length, endLine || lines.length);
30
+ return {
31
+ content: lines.slice(s, e).map((l, i) => `${s + i + 1}\t${l}`).join('\n'),
32
+ totalLines: lines.length,
33
+ from: s + 1,
34
+ to: e,
35
+ };
36
+ }
37
+
38
+ if (stat.size > MAX_READ_BYTES) {
39
+ const buf = Buffer.alloc(MAX_READ_BYTES);
40
+ const fd = fs.openSync(full, 'r');
41
+ fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
42
+ fs.closeSync(fd);
43
+ return { content: buf.toString('utf-8') + '\n... [truncated at 2MB]', truncated: true };
44
+ }
45
+ return { content: fs.readFileSync(full, 'utf-8') };
46
+ }
47
+
48
+ function writeFile(projectRoot, filePath, content) {
49
+ const full = sanitizePath(projectRoot, filePath);
50
+ const ig = loadIgnore(projectRoot);
51
+ if (isIgnored(projectRoot, full, ig)) {
52
+ throw new Error(`Path is .shoudignore'd: ${filePath}`);
53
+ }
54
+ fs.mkdirSync(path.dirname(full), { recursive: true });
55
+ fs.writeFileSync(full, content, 'utf-8');
56
+ return { ok: true, path: filePath, bytes: Buffer.byteLength(content) };
57
+ }
58
+
59
+ /**
60
+ * apply_patch: replace a specific old snippet with a new one.
61
+ * Fails if `oldText` is not found exactly once (safety).
62
+ */
63
+ function applyPatch(projectRoot, filePath, oldText, newText) {
64
+ const full = sanitizePath(projectRoot, filePath);
65
+ const ig = loadIgnore(projectRoot);
66
+ if (isIgnored(projectRoot, full, ig)) throw new Error(`Path is .shoudignore'd: ${filePath}`);
67
+ if (!fs.existsSync(full)) throw new Error(`File not found: ${filePath}`);
68
+
69
+ const original = fs.readFileSync(full, 'utf-8');
70
+ const occurrences = original.split(oldText).length - 1;
71
+ if (occurrences === 0) throw new Error('old_text not found in file.');
72
+ if (occurrences > 1) throw new Error(`old_text is ambiguous (${occurrences} matches). Provide more context.`);
73
+
74
+ const updated = original.replace(oldText, newText);
75
+ fs.writeFileSync(full, updated, 'utf-8');
76
+
77
+ const patch = diff.createPatch(filePath, original, updated, 'before', 'after');
78
+ return { ok: true, path: filePath, patch };
79
+ }
80
+
81
+ module.exports = { readFile, writeFile, applyPatch, sanitizePath };
@@ -0,0 +1,32 @@
1
+ const { execSync } = require('child_process');
2
+
3
+ function run(cwd, cmd) {
4
+ try { return { ok: true, out: execSync(cmd, { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim() }; }
5
+ catch (err) { return { ok: false, out: (err.stdout || '').toString().trim(), err: (err.stderr || '').toString().trim() }; }
6
+ }
7
+
8
+ function gitStatus(cwd) {
9
+ const s = run(cwd, 'git status --porcelain');
10
+ const branch = run(cwd, 'git rev-parse --abbrev-ref HEAD');
11
+ return {
12
+ branch: branch.ok ? branch.out : null,
13
+ dirty: s.ok && s.out.length > 0,
14
+ entries: s.ok ? s.out.split('\n').filter(Boolean).map(l => ({
15
+ status: l.slice(0, 2).trim(),
16
+ path: l.slice(3),
17
+ })) : [],
18
+ };
19
+ }
20
+
21
+ function gitDiff(cwd, { staged = false, path: p } = {}) {
22
+ const cmd = `git diff ${staged ? '--staged' : ''} ${p || ''}`.trim();
23
+ const r = run(cwd, cmd);
24
+ return { diff: r.out };
25
+ }
26
+
27
+ function gitLog(cwd, { limit = 10 } = {}) {
28
+ const r = run(cwd, `git log --oneline -n ${limit}`);
29
+ return { log: r.out };
30
+ }
31
+
32
+ module.exports = { gitStatus, gitDiff, gitLog };
@@ -1,171 +1,107 @@
1
- const { execSync } = require('child_process');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const os = require('os');
1
+ const { runShell } = require('./shell');
2
+ const { readFile, writeFile, applyPatch } = require('./files');
3
+ const { listDirectory, searchFiles, searchText } = require('./search');
4
+ const { gitStatus, gitDiff, gitLog } = require('./git');
5
+ const { projectInfo } = require('./project');
5
6
 
6
- const MAX_OUTPUT_SIZE = 1024 * 1024;
7
- const PROJECT_ROOT = process.cwd();
7
+ /**
8
+ * Tool registry. Each entry:
9
+ * { name, description, input_schema, handler(ctx, input) }
10
+ * `ctx` = { projectRoot, onOutput, timeout }
11
+ */
12
+ const TOOLS = {
13
+ execute_shell: {
14
+ description: 'Run a shell command in the project directory.',
15
+ input: { command: 'string', timeout_ms: 'optional number' },
16
+ async handler(ctx, input) {
17
+ const timeout = input.timeout_ms || ctx.timeout || 60000;
18
+ return await runShell(input.command, {
19
+ cwd: ctx.projectRoot,
20
+ timeout,
21
+ onOutput: ctx.onOutput,
22
+ });
23
+ },
24
+ },
8
25
 
9
- function getShell() {
10
- if (os.platform() === 'win32') {
11
- return 'powershell.exe';
12
- }
13
- return '/bin/bash';
14
- }
26
+ read_file: {
27
+ description: 'Read a file. Optionally specify a line range.',
28
+ input: { path: 'string', start_line: 'optional number', end_line: 'optional number' },
29
+ async handler(ctx, input) {
30
+ return readFile(ctx.projectRoot, input.path, {
31
+ startLine: input.start_line, endLine: input.end_line,
32
+ });
33
+ },
34
+ },
15
35
 
16
- function sanitizePath(inputPath) {
17
- const resolved = path.resolve(PROJECT_ROOT, inputPath);
18
- if (!resolved.startsWith(PROJECT_ROOT)) {
19
- throw new Error('Path is outside the project directory.');
20
- }
21
- return resolved;
22
- }
36
+ write_file: {
37
+ description: 'Write or overwrite a file with the given content.',
38
+ input: { path: 'string', content: 'string' },
39
+ async handler(ctx, input) { return writeFile(ctx.projectRoot, input.path, input.content); },
40
+ },
23
41
 
24
- /**
25
- * Execute a shell command and return plain text output.
26
- * On Windows, we use PowerShell with `Out-String` to get text.
27
- */
28
- function executeShell(commandString) {
29
- if (!commandString || commandString.trim().length === 0) {
30
- throw new Error('Empty command.');
31
- }
42
+ apply_patch: {
43
+ description: 'Replace a specific old snippet with new content in a file. Fails if old_text is not unique.',
44
+ input: { path: 'string', old_text: 'string', new_text: 'string' },
45
+ async handler(ctx, input) {
46
+ return applyPatch(ctx.projectRoot, input.path, input.old_text, input.new_text);
47
+ },
48
+ },
32
49
 
33
- let finalCommand = commandString;
34
- if (os.platform() === 'win32') {
35
- // Force PowerShell to output plain text
36
- // Escape double quotes in the command
37
- const escaped = commandString.replace(/"/g, '`"');
38
- // Wrap in a PowerShell script block and pipe to Out-String
39
- finalCommand = `& { ${escaped} } | Out-String -Width 4096`;
40
- }
50
+ list_directory: {
51
+ description: 'List files in a directory.',
52
+ input: { path: 'optional string' },
53
+ async handler(ctx, input) { return listDirectory(ctx.projectRoot, input.path || '.'); },
54
+ },
41
55
 
42
- try {
43
- const output = execSync(finalCommand, {
44
- cwd: PROJECT_ROOT,
45
- encoding: 'utf-8',
46
- shell: getShell(),
47
- timeout: 30000,
48
- maxBuffer: MAX_OUTPUT_SIZE,
49
- stdio: 'pipe',
50
- });
51
- const trimmed = output.trim();
52
- return trimmed || 'Command executed successfully with no output.';
53
- } catch (err) {
54
- // If the command fails, return the error message
55
- if (err.stderr) {
56
- return `Error: ${err.stderr.trim()}`;
57
- }
58
- if (err.stdout) {
59
- // Some commands output to stdout even on failure (e.g., `dir`)
60
- return err.stdout.trim();
61
- }
62
- throw new Error(`Command execution failed: ${err.message}`);
63
- }
64
- }
56
+ search_files: {
57
+ description: 'Find files matching a glob pattern.',
58
+ input: { pattern: 'string' },
59
+ async handler(ctx, input) { return searchFiles(ctx.projectRoot, input.pattern); },
60
+ },
65
61
 
66
- function readFile(filePath) {
67
- const safePath = sanitizePath(filePath);
68
- try {
69
- const stats = fs.statSync(safePath);
70
- if (!stats.isFile()) throw new Error('Path is not a file.');
71
- if (stats.size > MAX_OUTPUT_SIZE) {
72
- const buffer = Buffer.alloc(MAX_OUTPUT_SIZE);
73
- const fd = fs.openSync(safePath, 'r');
74
- fs.readSync(fd, buffer, 0, MAX_OUTPUT_SIZE, 0);
75
- fs.closeSync(fd);
76
- return buffer.toString('utf-8') + '\n... (file truncated)';
77
- }
78
- return fs.readFileSync(safePath, 'utf-8');
79
- } catch (err) {
80
- throw new Error(`Failed to read file: ${err.message}`);
81
- }
82
- }
62
+ search_text: {
63
+ description: 'Search file contents for a substring.',
64
+ input: { query: 'string', glob: 'optional string' },
65
+ async handler(ctx, input) { return searchText(ctx.projectRoot, input.query, { glob: input.glob }); },
66
+ },
83
67
 
84
- function writeFile(filePath, content) {
85
- const safePath = sanitizePath(filePath);
86
- try {
87
- const dir = path.dirname(safePath);
88
- if (!fs.existsSync(dir)) {
89
- fs.mkdirSync(dir, { recursive: true });
90
- }
91
- fs.writeFileSync(safePath, content, 'utf-8');
92
- return `File written successfully: ${filePath}`;
93
- } catch (err) {
94
- throw new Error(`Failed to write file: ${err.message}`);
95
- }
96
- }
68
+ git_status: {
69
+ description: 'Show git status.',
70
+ input: {},
71
+ async handler(ctx) { return gitStatus(ctx.projectRoot); },
72
+ },
97
73
 
98
- function executeTool(toolName, input) {
99
- try {
100
- switch (toolName) {
101
- case 'execute_shell':
102
- return executeShell(input.command);
103
- case 'read_file':
104
- return readFile(input.path);
105
- case 'write_file':
106
- return writeFile(input.path, input.content);
107
- default:
108
- throw new Error(`Tool "${toolName}" is not supported.`);
109
- }
110
- } catch (error) {
111
- return `Tool execution failed: ${error.message}`;
112
- }
113
- }
74
+ git_diff: {
75
+ description: 'Show git diff.',
76
+ input: { staged: 'optional boolean', path: 'optional string' },
77
+ async handler(ctx, input) { return gitDiff(ctx.projectRoot, input); },
78
+ },
114
79
 
115
- /**
116
- * Read an image/video file and return a data URL for Gemini.
117
- */
118
- function readImage(filePath) {
119
- const safePath = sanitizePath(filePath);
120
- try {
121
- const stats = fs.statSync(safePath);
122
- if (!stats.isFile()) throw new Error('Path is not a file.');
123
- const buffer = fs.readFileSync(safePath);
124
- const base64 = buffer.toString('base64');
125
- const mimeType = getMimeType(safePath); // you need to define this helper
126
- return `data:${mimeType};base64,${base64}`;
127
- } catch (err) {
128
- throw new Error(`Failed to read image: ${err.message}`);
129
- }
130
- }
80
+ git_log: {
81
+ description: 'Show recent git commits.',
82
+ input: { limit: 'optional number' },
83
+ async handler(ctx, input) { return gitLog(ctx.projectRoot, input); },
84
+ },
85
+
86
+ project_info: {
87
+ description: 'Describe the project (language, framework, package manager, verification commands).',
88
+ input: {},
89
+ async handler(ctx) { return projectInfo(ctx.projectRoot); },
90
+ },
91
+ };
131
92
 
132
- // Helper: determine MIME type from file extension
133
- function getMimeType(filePath) {
134
- const ext = path.extname(filePath).toLowerCase();
135
- const map = {
136
- '.jpg': 'image/jpeg',
137
- '.jpeg': 'image/jpeg',
138
- '.png': 'image/png',
139
- '.gif': 'image/gif',
140
- '.webp': 'image/webp',
141
- '.mp4': 'video/mp4',
142
- '.mov': 'video/quicktime',
143
- '.avi': 'video/x-msvideo',
144
- '.webm': 'video/webm',
145
- '.mkv': 'video/x-matroska',
146
- '.pdf': 'application/pdf',
147
- };
148
- return map[ext] || 'application/octet-stream';
93
+ function toolList() {
94
+ return Object.entries(TOOLS).map(([name, t]) => ({
95
+ name,
96
+ description: t.description,
97
+ input: t.input,
98
+ }));
149
99
  }
150
100
 
151
- // In executeTool, add:
152
- function executeTool(toolName, input) {
153
- try {
154
- switch (toolName) {
155
- case 'execute_shell':
156
- return executeShell(input.command);
157
- case 'read_file':
158
- return readFile(input.path);
159
- case 'write_file':
160
- return writeFile(input.path, input.content);
161
- case 'read_image': // new
162
- return readImage(input.path);
163
- default:
164
- throw new Error(`Tool "${toolName}" is not supported.`);
165
- }
166
- } catch (error) {
167
- return `Tool execution failed: ${error.message}`;
168
- }
101
+ async function executeTool(name, ctx, input) {
102
+ const tool = TOOLS[name];
103
+ if (!tool) throw new Error(`Unknown tool: ${name}`);
104
+ return await tool.handler(ctx, input);
169
105
  }
170
106
 
171
- module.exports = { executeTool };
107
+ module.exports = { TOOLS, toolList, executeTool };
@@ -0,0 +1,7 @@
1
+ const { discoverProject } = require('../project/discovery');
2
+
3
+ function projectInfo(root) {
4
+ return discoverProject(root);
5
+ }
6
+
7
+ module.exports = { projectInfo };
@@ -0,0 +1,49 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const fg = require('fast-glob');
4
+ const { loadIgnore } = require('../project/ignore');
5
+
6
+ async function listDirectory(projectRoot, dirPath = '.') {
7
+ const target = path.resolve(projectRoot, dirPath);
8
+ const entries = fs.readdirSync(target, { withFileTypes: true });
9
+ return entries.map(e => ({
10
+ name: e.name,
11
+ type: e.isDirectory() ? 'dir' : 'file',
12
+ path: path.relative(projectRoot, path.join(target, e.name)),
13
+ }));
14
+ }
15
+
16
+ async function searchFiles(projectRoot, pattern) {
17
+ const ig = loadIgnore(projectRoot);
18
+ const matches = await fg(pattern, {
19
+ cwd: projectRoot,
20
+ dot: false,
21
+ onlyFiles: true,
22
+ ignore: ['**/node_modules/**', '**/.git/**'],
23
+ });
24
+ return matches.filter(m => !ig.ignores(m)).slice(0, 500);
25
+ }
26
+
27
+ async function searchText(projectRoot, query, { glob = '**/*' } = {}) {
28
+ const ig = loadIgnore(projectRoot);
29
+ const files = await fg(glob, {
30
+ cwd: projectRoot, onlyFiles: true, dot: false,
31
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
32
+ });
33
+ const results = [];
34
+ const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
35
+ for (const f of files) {
36
+ if (ig.ignores(f)) continue;
37
+ let content;
38
+ try { content = fs.readFileSync(path.join(projectRoot, f), 'utf-8'); } catch { continue; }
39
+ if (content.length > 500_000) continue;
40
+ const lines = content.split('\n');
41
+ lines.forEach((line, i) => {
42
+ if (re.test(line)) results.push({ file: f, line: i + 1, text: line.trim().slice(0, 200) });
43
+ });
44
+ if (results.length >= 200) break;
45
+ }
46
+ return results.slice(0, 200);
47
+ }
48
+
49
+ module.exports = { listDirectory, searchFiles, searchText };