Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "whalincode",
"private": true,
"version": "1.0.0",
"workspaces": [
"packages/*"
],
"scripts": {
"start": "bun run --cwd packages/server start",
"dev:cli": "bun run --watch packages/cli/src/index.tsx",
"dev:server": "bun run --hot packages/server/src/index.ts",
"format": "prettier . --write",
"format:check": "prettier . --check"
"format:check": "prettier . --check",
"typecheck": "bun run typecheck:cli && bun run typecheck:server && bun run typecheck:database && bun run typecheck:shared",
"typecheck:cli": "tsc --noEmit -p packages/cli/tsconfig.json",
"typecheck:server": "tsc --noEmit -p packages/server/tsconfig.json",
"typecheck:database": "tsc --noEmit -p packages/database/tsconfig.json",
"typecheck:shared": "tsc --noEmit -p packages/shared/tsconfig.json"
},
"devDependencies": {
"@types/node": "^25.9.1",
Expand Down
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
".": "./src/index.ts"
},
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun run --hot src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun",
"postinstall": "bun run --cwd ../database db:generate"
Expand Down
39 changes: 37 additions & 2 deletions packages/server/src/tools/bash.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
import { platform } from 'os';
import { tool } from 'ai';
import { z } from 'zod';

const MAX_OUTPUT = 20_000;
const DEFAULT_TIMEOUT = 30_000;

/**
* Resolve the shell invocation for the current platform so commands run
* consistently across Windows, macOS and Linux.
*
* - On Windows we prefer PowerShell (available by default on modern Windows)
* and fall back to cmd.exe when it isn't present.
* - On Unix-like systems we use the user's $SHELL, falling back to /bin/sh
* which is guaranteed to exist.
*/
function resolveShell(command: string): string[] {
if (platform() === 'win32') {
const comspec = process.env.ComSpec ?? 'cmd.exe';
// /d skips AutoRun, /s + /c preserves quoting, /c runs and exits.
return [comspec, '/d', '/s', '/c', command];
}

const shell = process.env.SHELL ?? '/bin/sh';
return [shell, '-c', command];
}

export function createBashTool(cwd: string) {
return tool({
description:
'Execute a shell command in the project. Use this for running tests, builds, git operations, package installs, and any other shell commands.',
'Execute a shell command in the project. Use this for running tests, builds, git operations, package installs, and any other shell commands. Runs on the platform-native shell (PowerShell/cmd on Windows, sh on Unix).',
inputSchema: z.object({
command: z.string().describe('The shell command to execute'),
timeout: z
Expand All @@ -18,18 +39,23 @@ export function createBashTool(cwd: string) {
),
}),
execute: async ({ command, timeout }) => {
let timedOut = false;

try {
const proc = Bun.spawn(['bash', '-c', command], {
const proc = Bun.spawn(resolveShell(command), {
cwd,
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, TERM: 'dumb' },
});

const timer = setTimeout(() => {
timedOut = true;
proc.kill();
}, timeout);

const response = new Response(proc.stdout);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove unused Response wrapper.

response at Line 57 is never read; lines 60-61 already construct fresh Response objects over the same streams for the actual .text() calls. This is dead code, likely a leftover from refactoring.

🧹 Proposed fix
-                const response = new Response(proc.stdout);
-
                 const [stdout, stderr] = await Promise.all([
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = new Response(proc.stdout);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/tools/bash.ts` at line 57, The bash tool has a dead
`response` variable in the `bash.ts` flow: `new Response(proc.stdout)` is
created but never used, since the later `.text()` calls already instantiate
fresh `Response` objects from the same streams. Remove the unused `response`
creation in the `bash`-related logic and keep the existing stream reads intact
so the control flow remains unchanged.


const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
Expand All @@ -43,6 +69,15 @@ export function createBashTool(cwd: string) {
? s.slice(0, MAX_OUTPUT) + `\n... (truncated, ${s.length} total chars)`
: s;

if (timedOut) {
return {
stdout: truncate(stdout),
stderr: truncate(stderr),
exitCode,
error: `Command timed out after ${timeout}ms and was terminated.`,
};
}

return {
stdout: truncate(stdout),
stderr: truncate(stderr),
Expand Down
196 changes: 150 additions & 46 deletions packages/server/src/tools/grep.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,111 @@
import { resolve, relative } from 'path';
import { resolve, relative, join } from 'path';
import { readdir } from 'fs/promises';
import { tool } from 'ai';
import { z } from 'zod';

const MAX_MATCHES = 50;

const SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', '.DS_Store']);

const BINARY_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.bmp',
'.ico',
'.webp',
'.svg',
'.woff',
'.woff2',
'.ttf',
'.eot',
'.otf',
'.zip',
'.tar',
'.gz',
'.bz2',
'.7z',
'.rar',
'.exe',
'.dll',
'.so',
'.dylib',
'.bin',
'.pdf',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.mp3',
'.mp4',
'.avi',
'.mov',
'.wav',
'.lock',
]);

function isBinaryPath(filePath: string): boolean {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
return BINARY_EXTENSIONS.has(ext);
}

function isHiddenPath(filePath: string): boolean {
return filePath
.split(/[\\/]/)
.some((segment) => segment.startsWith('.') && segment !== '.' && segment !== '..');
}

/**
* Recursively walk a directory, pruning skip/hidden directories during
* traversal so we never descend into node_modules (etc.). Returns file paths
* relative to `root`. An optional glob matcher filters files by pattern.
*/
async function walkFiles(
root: string,
matcher: Bun.Glob | null,
dir = root,
results: string[] = [],
): Promise<string[]> {
let entries;

try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return results;
}

for (const entry of entries) {
const name = entry.name;

if (entry.isDirectory()) {
// Prune excluded and hidden directories up front so we never
// traverse into them (e.g. node_modules, .git).
if (SKIP_DIRS.has(name) || name.startsWith('.')) {
continue;
}
await walkFiles(root, matcher, join(dir, name), results);
continue;
}

if (!entry.isFile()) {
continue;
}

const relPath = relative(root, join(dir, name));

if (matcher && !matcher.match(relPath)) {
continue;
}

if (!isHiddenPath(relPath) && !isBinaryPath(relPath)) {
results.push(relPath);
}
}

return results;
}

export function createGrepTool(cwd: string) {
return tool({
description:
Expand All @@ -29,69 +131,71 @@ export function createGrepTool(cwd: string) {
}

try {
const args = [
'-rn',
'--color=never',
'--exclude-dir=node_modules',
'--exclude-dir=git',
'-E',
];

if (include) {
args.push(`--include=${include}`);
}
const regex = new RegExp(pattern);

args.push(pattern, resolved);
// Scan all files in the target directory, pruning
// node_modules and hidden directories during traversal.
const matcher = include ? new Bun.Glob(include) : null;
const filePaths = await walkFiles(resolved, matcher);

const proc = Bun.spawn(['grep', ...args], {
stdout: 'pipe',
stderr: 'pipe',
cwd,
});
filePaths.sort();

const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const matches: { file: string; line: number; content: string }[] = [];
let truncated = false;
let scanedMatches = 0;

await proc.exited;
for (const filePath of filePaths) {
if (truncated) break;

// grep exits with 1 when no matches found - not an error
if (proc.exitCode !== 0 && proc.exitCode !== 1) {
return { error: `grep failed: ${stderr.trim()}` };
}
const absolutePath = join(resolved, filePath);
let text: string;

if (!stdout.trim()) {
return { matches: [], message: 'No matches found' };
}
try {
const file = Bun.file(absolutePath);
text = await file.text();
} catch {
// Skip files that can't be read
continue;
}

const lines = stdout.trim().split('\n');
const matches: { file: string; line: number; content: string }[] = [];
let truncated = false;
const lines = text.split('\n');

for (const line of lines) {
if (matches.length >= MAX_MATCHES) {
truncated = true;
break;
}
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i]!)) {
scanedMatches++;

// grep output format; /absolute/path:linenum:content
const match = line.match(/^(.+?):(\d+):(.*)$/);
if (match) {
matches.push({
file: relative(cwd, match[1]!),
line: parseInt(match[2]!, 10),
content: match[3]!,
});
if (matches.length < MAX_MATCHES) {
matches.push({
file: relative(cwd, absolutePath),
line: i + 1,
content: lines[i]!.trimEnd(),
});
} else {
truncated = true;
break;
}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (matches.length === 0) {
return { matches: [], message: 'No matches found' };
}

return {
matches,
...(truncated ? { truncated: true, totalMatches: lines.length } : {}),
...(truncated ? { truncated: true, scanedMatches } : {}),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

// Check if it's an invalid regex
if (error instanceof SyntaxError) {
return { error: `Invalid regex pattern: ${message}` };
}

return {
error: `Failed to execute command: ${message}`,
error: `Failed to search files: ${message}`,
};
}
},
Expand Down