@alia-codea/cli 1.0.0 → 2.0.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/dist/index.js +1362 -491
- package/package.json +5 -5
- package/src/app.tsx +281 -0
- package/src/commands/auth.ts +149 -20
- package/src/commands/repl.ts +11 -299
- package/src/commands/run.ts +103 -126
- package/src/commands/sessions.ts +5 -6
- package/src/components/ApprovalPrompt.tsx +60 -0
- package/src/components/Header.tsx +39 -0
- package/src/components/InputBar.tsx +36 -0
- package/src/components/MessageList.tsx +81 -0
- package/src/components/ThinkingIndicator.tsx +28 -0
- package/src/components/ToolCallCard.tsx +68 -0
- package/src/index.ts +31 -11
- package/src/tools/executor.ts +140 -14
- package/src/tools/patch.ts +167 -0
- package/src/utils/api.ts +22 -3
- package/src/utils/approval.ts +31 -0
- package/src/utils/context.ts +65 -4
- package/src/utils/conversation.ts +141 -0
- package/dist/api-X2G5QROW.js +0 -10
- package/dist/chunk-SVPL4GNV.js +0 -230
- package/dist/index.d.ts +0 -1
- package/src/utils/ui.ts +0 -153
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { ToolExecution } from '../utils/conversation.js';
|
|
4
|
+
|
|
5
|
+
interface ApprovalPromptProps {
|
|
6
|
+
execution: ToolExecution;
|
|
7
|
+
onResolve: (approved: boolean) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formatArgs(tool: string, args: Record<string, any>): string {
|
|
11
|
+
switch (tool) {
|
|
12
|
+
case 'write_file':
|
|
13
|
+
return `Write to ${args.path}`;
|
|
14
|
+
case 'edit_file':
|
|
15
|
+
return `Edit ${args.path}`;
|
|
16
|
+
case 'apply_patch':
|
|
17
|
+
return `Apply patch`;
|
|
18
|
+
case 'run_command':
|
|
19
|
+
return `Run: ${args.command}`;
|
|
20
|
+
default:
|
|
21
|
+
return `${tool}: ${JSON.stringify(args).slice(0, 80)}`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ApprovalPrompt({ execution, onResolve }: ApprovalPromptProps) {
|
|
26
|
+
useInput((input, key) => {
|
|
27
|
+
if (input === 'y' || input === 'Y') {
|
|
28
|
+
onResolve(true);
|
|
29
|
+
} else if (input === 'n' || input === 'N' || key.escape) {
|
|
30
|
+
onResolve(false);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const description = formatArgs(execution.tool, execution.args);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Box flexDirection="column" paddingLeft={2} paddingY={0}>
|
|
38
|
+
<Box gap={1}>
|
|
39
|
+
<Text color="yellow">{'⚠'}</Text>
|
|
40
|
+
<Text bold>{description}</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
{execution.tool === 'run_command' && execution.args.command && (
|
|
43
|
+
<Box paddingLeft={2}>
|
|
44
|
+
<Text color="gray">$ {execution.args.command}</Text>
|
|
45
|
+
</Box>
|
|
46
|
+
)}
|
|
47
|
+
{execution.tool === 'write_file' && execution.args.content && (
|
|
48
|
+
<Box paddingLeft={2}>
|
|
49
|
+
<Text color="gray">{execution.args.content.split('\n').length} lines</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
)}
|
|
52
|
+
<Box paddingLeft={2} gap={1}>
|
|
53
|
+
<Text color="green">[y]</Text>
|
|
54
|
+
<Text>approve</Text>
|
|
55
|
+
<Text color="red">[n]</Text>
|
|
56
|
+
<Text>deny</Text>
|
|
57
|
+
</Box>
|
|
58
|
+
</Box>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { ApprovalMode } from '../utils/approval.js';
|
|
4
|
+
|
|
5
|
+
interface HeaderProps {
|
|
6
|
+
cwd: string;
|
|
7
|
+
model: string;
|
|
8
|
+
approvalMode: ApprovalMode;
|
|
9
|
+
contextPercent: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function shortenPath(p: string): string {
|
|
13
|
+
const home = process.env.HOME || '';
|
|
14
|
+
if (home && p.startsWith(home)) {
|
|
15
|
+
return '~' + p.slice(home.length);
|
|
16
|
+
}
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MODE_COLORS: Record<ApprovalMode, string> = {
|
|
21
|
+
'suggest': 'yellow',
|
|
22
|
+
'auto-edit': 'cyan',
|
|
23
|
+
'full-auto': 'green',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function Header({ cwd, model, approvalMode, contextPercent }: HeaderProps) {
|
|
27
|
+
return (
|
|
28
|
+
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
|
|
29
|
+
<Text color="cyan">{shortenPath(cwd)}</Text>
|
|
30
|
+
<Box gap={1}>
|
|
31
|
+
<Text color="magenta">{model}</Text>
|
|
32
|
+
<Text color="gray">|</Text>
|
|
33
|
+
<Text color={MODE_COLORS[approvalMode]}>{approvalMode}</Text>
|
|
34
|
+
<Text color="gray">|</Text>
|
|
35
|
+
<Text color={contextPercent < 20 ? 'red' : 'gray'}>{contextPercent}% left</Text>
|
|
36
|
+
</Box>
|
|
37
|
+
</Box>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { ThinkingIndicator } from './ThinkingIndicator.js';
|
|
5
|
+
|
|
6
|
+
interface InputBarProps {
|
|
7
|
+
onSubmit: (value: string) => void;
|
|
8
|
+
isProcessing: boolean;
|
|
9
|
+
thinkingLabel?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function InputBar({ onSubmit, isProcessing, thinkingLabel }: InputBarProps) {
|
|
13
|
+
const [value, setValue] = useState('');
|
|
14
|
+
|
|
15
|
+
const handleSubmit = (text: string) => {
|
|
16
|
+
const trimmed = text.trim();
|
|
17
|
+
if (!trimmed) return;
|
|
18
|
+
setValue('');
|
|
19
|
+
onSubmit(trimmed);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
if (isProcessing) {
|
|
23
|
+
return (
|
|
24
|
+
<Box paddingX={1}>
|
|
25
|
+
<ThinkingIndicator label={thinkingLabel || 'Thinking'} />
|
|
26
|
+
</Box>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Box paddingX={1}>
|
|
32
|
+
<Text color="cyan">{'❯ '}</Text>
|
|
33
|
+
<TextInput value={value} onChange={setValue} onSubmit={handleSubmit} />
|
|
34
|
+
</Box>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Marked } from 'marked';
|
|
4
|
+
import { markedTerminal } from 'marked-terminal';
|
|
5
|
+
import { ToolCallCard } from './ToolCallCard.js';
|
|
6
|
+
import { ToolExecution } from '../utils/conversation.js';
|
|
7
|
+
|
|
8
|
+
export interface DisplayMessage {
|
|
9
|
+
id: string;
|
|
10
|
+
type: 'user' | 'assistant' | 'tool' | 'info';
|
|
11
|
+
content: string;
|
|
12
|
+
toolExecution?: ToolExecution;
|
|
13
|
+
streaming?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface MessageListProps {
|
|
17
|
+
messages: DisplayMessage[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const marked = new Marked(markedTerminal() as any);
|
|
21
|
+
|
|
22
|
+
function renderMarkdown(text: string): string {
|
|
23
|
+
try {
|
|
24
|
+
const rendered = marked.parse(text);
|
|
25
|
+
if (typeof rendered === 'string') {
|
|
26
|
+
return rendered.trimEnd();
|
|
27
|
+
}
|
|
28
|
+
return text;
|
|
29
|
+
} catch {
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function MessageBlock({ message }: { message: DisplayMessage }) {
|
|
35
|
+
switch (message.type) {
|
|
36
|
+
case 'user':
|
|
37
|
+
return (
|
|
38
|
+
<Box paddingLeft={1} paddingY={0}>
|
|
39
|
+
<Text color="cyan">{'❯ '}</Text>
|
|
40
|
+
<Text>{message.content}</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
case 'assistant':
|
|
45
|
+
return (
|
|
46
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
47
|
+
<Box>
|
|
48
|
+
<Text color="magenta">{'✦ '}</Text>
|
|
49
|
+
<Text>{message.streaming ? message.content : renderMarkdown(message.content)}</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
</Box>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
case 'tool':
|
|
55
|
+
if (message.toolExecution) {
|
|
56
|
+
return <ToolCallCard execution={message.toolExecution} />;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
|
|
60
|
+
case 'info':
|
|
61
|
+
return (
|
|
62
|
+
<Box paddingLeft={1}>
|
|
63
|
+
<Text color="blue">{'ℹ '}</Text>
|
|
64
|
+
<Text color="gray">{message.content}</Text>
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
default:
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function MessageList({ messages }: MessageListProps) {
|
|
74
|
+
return (
|
|
75
|
+
<Box flexDirection="column" flexGrow={1} gap={0}>
|
|
76
|
+
{messages.map((msg) => (
|
|
77
|
+
<MessageBlock key={msg.id} message={msg} />
|
|
78
|
+
))}
|
|
79
|
+
</Box>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
|
|
5
|
+
interface ThinkingIndicatorProps {
|
|
6
|
+
label?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ThinkingIndicator({ label = 'Thinking' }: ThinkingIndicatorProps) {
|
|
10
|
+
const [elapsed, setElapsed] = useState(0);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const interval = setInterval(() => {
|
|
14
|
+
setElapsed((e) => e + 1);
|
|
15
|
+
}, 1000);
|
|
16
|
+
return () => clearInterval(interval);
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Box gap={1}>
|
|
21
|
+
<Text color="cyan">
|
|
22
|
+
<Spinner type="dots" />
|
|
23
|
+
</Text>
|
|
24
|
+
<Text bold>{label}</Text>
|
|
25
|
+
<Text color="gray">({elapsed}s)</Text>
|
|
26
|
+
</Box>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { ToolExecution } from '../utils/conversation.js';
|
|
4
|
+
|
|
5
|
+
interface ToolCallCardProps {
|
|
6
|
+
execution: ToolExecution;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatArgs(tool: string, args: Record<string, any>): string {
|
|
10
|
+
switch (tool) {
|
|
11
|
+
case 'read_file':
|
|
12
|
+
case 'write_file':
|
|
13
|
+
case 'edit_file':
|
|
14
|
+
return args.path || '';
|
|
15
|
+
case 'apply_patch':
|
|
16
|
+
return 'applying patch...';
|
|
17
|
+
case 'list_files':
|
|
18
|
+
return args.path || '.';
|
|
19
|
+
case 'search_files':
|
|
20
|
+
return `"${args.pattern}" in ${args.path || '.'}`;
|
|
21
|
+
case 'run_command':
|
|
22
|
+
return args.command || '';
|
|
23
|
+
default:
|
|
24
|
+
return JSON.stringify(args).slice(0, 60);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ToolCallCard({ execution }: ToolCallCardProps) {
|
|
29
|
+
const { tool, args, result, success, approved } = execution;
|
|
30
|
+
const argStr = formatArgs(tool, args);
|
|
31
|
+
const isDone = result !== undefined;
|
|
32
|
+
|
|
33
|
+
if (approved === false) {
|
|
34
|
+
return (
|
|
35
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
36
|
+
<Box gap={1}>
|
|
37
|
+
<Text color="red">{'✗'}</Text>
|
|
38
|
+
<Text bold color="gray" strikethrough>{tool}</Text>
|
|
39
|
+
<Text color="gray">{argStr}</Text>
|
|
40
|
+
</Box>
|
|
41
|
+
<Box paddingLeft={2}>
|
|
42
|
+
<Text color="yellow" dimColor>Declined by user</Text>
|
|
43
|
+
</Box>
|
|
44
|
+
</Box>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
50
|
+
<Box gap={1}>
|
|
51
|
+
{isDone ? (
|
|
52
|
+
<Text color={success ? 'green' : 'red'}>{success ? '✓' : '✗'}</Text>
|
|
53
|
+
) : (
|
|
54
|
+
<Text color="cyan">{'→'}</Text>
|
|
55
|
+
)}
|
|
56
|
+
<Text bold>{tool}</Text>
|
|
57
|
+
<Text color="gray">{argStr}</Text>
|
|
58
|
+
</Box>
|
|
59
|
+
{isDone && result && (
|
|
60
|
+
<Box paddingLeft={2}>
|
|
61
|
+
<Text color="gray" wrap="truncate-end">
|
|
62
|
+
{result.slice(0, 120).replace(/\n/g, ' ')}{result.length > 120 ? '...' : ''}
|
|
63
|
+
</Text>
|
|
64
|
+
</Box>
|
|
65
|
+
)}
|
|
66
|
+
</Box>
|
|
67
|
+
);
|
|
68
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,15 +8,14 @@ import { login } from './commands/auth.js';
|
|
|
8
8
|
import { listSessions, resumeSession } from './commands/sessions.js';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
|
|
11
|
-
const VERSION = '
|
|
11
|
+
const VERSION = '2.0.0';
|
|
12
12
|
|
|
13
13
|
const program = new Command();
|
|
14
14
|
|
|
15
|
-
// ASCII art banner
|
|
16
15
|
const banner = `
|
|
17
16
|
${chalk.cyan(' ____ _ ')}
|
|
18
17
|
${chalk.cyan(' / ___|___ __| | ___ __ _ ')}
|
|
19
|
-
${chalk.cyan(' | | / _ \\ / _
|
|
18
|
+
${chalk.cyan(' | | / _ \\ / _` |/ _ \\/ _` |')}
|
|
20
19
|
${chalk.cyan(' | |__| (_) | (_| | __/ (_| |')}
|
|
21
20
|
${chalk.cyan(' \\____\\___/ \\__,_|\\___|\\__,_|')}
|
|
22
21
|
${chalk.gray(' AI Coding Assistant by Alia')}
|
|
@@ -26,23 +25,30 @@ program
|
|
|
26
25
|
.name('codea')
|
|
27
26
|
.description('Codea CLI - AI coding assistant for your terminal')
|
|
28
27
|
.version(VERSION)
|
|
29
|
-
.hook('preAction', () => {
|
|
30
|
-
// Check for API key before running commands (except login)
|
|
28
|
+
.hook('preAction', async () => {
|
|
31
29
|
const command = program.args[0];
|
|
32
|
-
if (command
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
if (command === 'login' || command === 'help') return;
|
|
31
|
+
|
|
32
|
+
if (!config.get('apiKey')) {
|
|
33
|
+
console.log(banner);
|
|
34
|
+
console.log(chalk.yellow('No API key found. Let\'s get you logged in.\n'));
|
|
35
|
+
const success = await login();
|
|
36
|
+
if (!success) {
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
console.log();
|
|
35
40
|
}
|
|
36
41
|
});
|
|
37
42
|
|
|
38
|
-
// Default command - start REPL
|
|
43
|
+
// Default command - start REPL with Ink TUI
|
|
39
44
|
program
|
|
40
45
|
.command('chat', { isDefault: true })
|
|
41
46
|
.description('Start an interactive chat session')
|
|
42
47
|
.option('-m, --model <model>', 'Model to use (codea, codea-pro, codea-thinking)', 'alia-v1-codea')
|
|
48
|
+
.option('-a, --approval-mode <mode>', 'Approval mode: suggest, auto-edit, full-auto', 'suggest')
|
|
43
49
|
.option('--no-context', 'Disable automatic codebase context')
|
|
50
|
+
.option('--no-instructions', 'Disable CODEA.md project instructions')
|
|
44
51
|
.action(async (options) => {
|
|
45
|
-
console.log(banner);
|
|
46
52
|
await startRepl(options);
|
|
47
53
|
});
|
|
48
54
|
|
|
@@ -52,12 +58,26 @@ program
|
|
|
52
58
|
.alias('r')
|
|
53
59
|
.description('Run a single prompt and exit')
|
|
54
60
|
.option('-m, --model <model>', 'Model to use', 'alia-v1-codea')
|
|
55
|
-
.option('-y, --yes', 'Auto-approve all
|
|
61
|
+
.option('-y, --yes', 'Auto-approve all actions (full-auto mode)')
|
|
62
|
+
.option('-a, --approval-mode <mode>', 'Approval mode: suggest, auto-edit, full-auto', 'suggest')
|
|
63
|
+
.option('-q, --quiet', 'Suppress UI, output only response text')
|
|
64
|
+
.option('--json', 'Output structured JSON')
|
|
56
65
|
.option('--no-context', 'Disable automatic codebase context')
|
|
57
66
|
.action(async (prompt, options) => {
|
|
58
67
|
await runPrompt(prompt, options);
|
|
59
68
|
});
|
|
60
69
|
|
|
70
|
+
// Exec command - shorthand for run --json --quiet --yes
|
|
71
|
+
program
|
|
72
|
+
.command('exec <prompt>')
|
|
73
|
+
.alias('x')
|
|
74
|
+
.description('Execute a prompt in full-auto mode with JSON output')
|
|
75
|
+
.option('-m, --model <model>', 'Model to use', 'alia-v1-codea')
|
|
76
|
+
.option('--no-context', 'Disable automatic codebase context')
|
|
77
|
+
.action(async (prompt, options) => {
|
|
78
|
+
await runPrompt(prompt, { ...options, yes: true, quiet: false, json: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
61
81
|
// Login/configure
|
|
62
82
|
program
|
|
63
83
|
.command('login')
|
package/src/tools/executor.ts
CHANGED
|
@@ -2,8 +2,8 @@ import * as fs from 'fs/promises';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { exec } from 'child_process';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
|
-
import { glob } from 'fs/promises';
|
|
6
5
|
import chalk from 'chalk';
|
|
6
|
+
import { applyPatch } from './patch.js';
|
|
7
7
|
|
|
8
8
|
const execAsync = promisify(exec);
|
|
9
9
|
|
|
@@ -21,10 +21,12 @@ export async function executeTool(name: string, args: Record<string, any>): Prom
|
|
|
21
21
|
return await writeFile(args.path, args.content);
|
|
22
22
|
case 'edit_file':
|
|
23
23
|
return await editFile(args.path, args.old_text, args.new_text);
|
|
24
|
+
case 'apply_patch':
|
|
25
|
+
return await applyPatchTool(args.patch);
|
|
24
26
|
case 'list_files':
|
|
25
27
|
return await listFiles(args.path, args.recursive);
|
|
26
28
|
case 'search_files':
|
|
27
|
-
return await searchFiles(args.pattern, args.path, args.file_pattern);
|
|
29
|
+
return await searchFiles(args.pattern, args.path, args.file_pattern, args.context_lines, args.max_results);
|
|
28
30
|
case 'run_command':
|
|
29
31
|
return await runCommand(args.command, args.cwd);
|
|
30
32
|
default:
|
|
@@ -55,14 +57,46 @@ async function editFile(filePath: string, oldText: string, newText: string): Pro
|
|
|
55
57
|
const absolutePath = path.resolve(process.cwd(), filePath);
|
|
56
58
|
const content = await fs.readFile(absolutePath, 'utf-8');
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
// Try exact match first
|
|
61
|
+
if (content.includes(oldText)) {
|
|
62
|
+
const newContent = content.replace(oldText, newText);
|
|
63
|
+
await fs.writeFile(absolutePath, newContent, 'utf-8');
|
|
64
|
+
return { success: true, result: `File edited: ${filePath}` };
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
// Try whitespace-normalized match
|
|
68
|
+
const normalizedOld = oldText.replace(/\s+/g, ' ').trim();
|
|
69
|
+
const lines = content.split('\n');
|
|
70
|
+
let matchStart = -1;
|
|
71
|
+
let matchEnd = -1;
|
|
64
72
|
|
|
65
|
-
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
for (let j = i; j < lines.length; j++) {
|
|
75
|
+
const block = lines.slice(i, j + 1).join('\n');
|
|
76
|
+
if (block.replace(/\s+/g, ' ').trim() === normalizedOld) {
|
|
77
|
+
matchStart = i;
|
|
78
|
+
matchEnd = j;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (matchStart >= 0) break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (matchStart >= 0) {
|
|
86
|
+
const newLines = [...lines.slice(0, matchStart), ...newText.split('\n'), ...lines.slice(matchEnd + 1)];
|
|
87
|
+
await fs.writeFile(absolutePath, newLines.join('\n'), 'utf-8');
|
|
88
|
+
return { success: true, result: `File edited (fuzzy match): ${filePath}` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { success: false, result: `Text not found in file: "${oldText.slice(0, 50)}..."` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function applyPatchTool(patchText: string): Promise<ToolResult> {
|
|
95
|
+
const result = await applyPatch(patchText, process.cwd());
|
|
96
|
+
const summary = result.results
|
|
97
|
+
.map((r) => `${r.success ? '✓' : '✗'} ${r.file}: ${r.message}`)
|
|
98
|
+
.join('\n');
|
|
99
|
+
return { success: result.success, result: summary };
|
|
66
100
|
}
|
|
67
101
|
|
|
68
102
|
async function listFiles(dirPath: string = '.', recursive: boolean = false): Promise<ToolResult> {
|
|
@@ -98,10 +132,71 @@ async function listFiles(dirPath: string = '.', recursive: boolean = false): Pro
|
|
|
98
132
|
}
|
|
99
133
|
}
|
|
100
134
|
|
|
101
|
-
async function searchFiles(
|
|
135
|
+
async function searchFiles(
|
|
136
|
+
pattern: string,
|
|
137
|
+
dirPath: string = '.',
|
|
138
|
+
filePattern?: string,
|
|
139
|
+
contextLines: number = 2,
|
|
140
|
+
maxResults: number = 50
|
|
141
|
+
): Promise<ToolResult> {
|
|
102
142
|
const absolutePath = path.resolve(process.cwd(), dirPath);
|
|
143
|
+
|
|
144
|
+
// Try ripgrep first
|
|
145
|
+
try {
|
|
146
|
+
const rgArgs = [
|
|
147
|
+
'--json',
|
|
148
|
+
'-C', String(contextLines),
|
|
149
|
+
'-m', String(maxResults),
|
|
150
|
+
'--no-heading',
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
if (filePattern) {
|
|
154
|
+
rgArgs.push('-g', filePattern);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
rgArgs.push('--', pattern, absolutePath);
|
|
158
|
+
|
|
159
|
+
const { stdout } = await execAsync(`rg ${rgArgs.map(a => `'${a}'`).join(' ')}`, {
|
|
160
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
161
|
+
timeout: 30000,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Parse rg --json output
|
|
165
|
+
const results: string[] = [];
|
|
166
|
+
const lines = stdout.trim().split('\n');
|
|
167
|
+
|
|
168
|
+
for (const line of lines) {
|
|
169
|
+
try {
|
|
170
|
+
const data = JSON.parse(line);
|
|
171
|
+
if (data.type === 'match') {
|
|
172
|
+
const relPath = path.relative(absolutePath, data.data.path.text);
|
|
173
|
+
const lineNum = data.data.line_number;
|
|
174
|
+
const text = data.data.lines.text.trimEnd();
|
|
175
|
+
results.push(`${relPath}:${lineNum}: ${text}`);
|
|
176
|
+
} else if (data.type === 'context') {
|
|
177
|
+
const relPath = path.relative(absolutePath, data.data.path.text);
|
|
178
|
+
const lineNum = data.data.line_number;
|
|
179
|
+
const text = data.data.lines.text.trimEnd();
|
|
180
|
+
results.push(`${relPath}:${lineNum} ${text}`);
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Skip malformed JSON lines
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (results.length === 0) {
|
|
188
|
+
return { success: true, result: 'No matches found.' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { success: true, result: results.join('\n') };
|
|
192
|
+
} catch {
|
|
193
|
+
// ripgrep not available or failed, use built-in
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Built-in fallback
|
|
103
197
|
const regex = new RegExp(pattern, 'gi');
|
|
104
|
-
const results: string
|
|
198
|
+
const results: Array<{ file: string; line: number; text: string; isMatch: boolean }> = [];
|
|
199
|
+
const fileMatchCounts = new Map<string, number>();
|
|
105
200
|
|
|
106
201
|
async function searchDir(dir: string) {
|
|
107
202
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
@@ -109,7 +204,6 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
|
|
|
109
204
|
for (const entry of entries) {
|
|
110
205
|
const fullPath = path.join(dir, entry.name);
|
|
111
206
|
|
|
112
|
-
// Skip common ignored directories
|
|
113
207
|
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist') {
|
|
114
208
|
continue;
|
|
115
209
|
}
|
|
@@ -117,7 +211,6 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
|
|
|
117
211
|
if (entry.isDirectory()) {
|
|
118
212
|
await searchDir(fullPath);
|
|
119
213
|
} else {
|
|
120
|
-
// Check file pattern
|
|
121
214
|
if (filePattern) {
|
|
122
215
|
const ext = path.extname(entry.name);
|
|
123
216
|
const patternExt = filePattern.replace('*', '');
|
|
@@ -128,14 +221,38 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
|
|
|
128
221
|
|
|
129
222
|
try {
|
|
130
223
|
const content = await fs.readFile(fullPath, 'utf-8');
|
|
224
|
+
|
|
225
|
+
// Skip binary files
|
|
226
|
+
if (content.includes('\0')) continue;
|
|
227
|
+
|
|
131
228
|
const lines = content.split('\n');
|
|
229
|
+
const matchIndices: number[] = [];
|
|
132
230
|
|
|
133
231
|
lines.forEach((line, index) => {
|
|
232
|
+
regex.lastIndex = 0;
|
|
134
233
|
if (regex.test(line)) {
|
|
135
|
-
|
|
136
|
-
results.push(`${relativePath}:${index + 1}: ${line.trim()}`);
|
|
234
|
+
matchIndices.push(index);
|
|
137
235
|
}
|
|
138
236
|
});
|
|
237
|
+
|
|
238
|
+
if (matchIndices.length > 0) {
|
|
239
|
+
const relativePath = path.relative(absolutePath, fullPath);
|
|
240
|
+
fileMatchCounts.set(relativePath, matchIndices.length);
|
|
241
|
+
|
|
242
|
+
for (const idx of matchIndices) {
|
|
243
|
+
const start = Math.max(0, idx - contextLines);
|
|
244
|
+
const end = Math.min(lines.length - 1, idx + contextLines);
|
|
245
|
+
|
|
246
|
+
for (let i = start; i <= end; i++) {
|
|
247
|
+
results.push({
|
|
248
|
+
file: relativePath,
|
|
249
|
+
line: i + 1,
|
|
250
|
+
text: lines[i].trimEnd(),
|
|
251
|
+
isMatch: i === idx,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
139
256
|
} catch {
|
|
140
257
|
// Skip unreadable files
|
|
141
258
|
}
|
|
@@ -149,7 +266,15 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
|
|
|
149
266
|
return { success: true, result: 'No matches found.' };
|
|
150
267
|
}
|
|
151
268
|
|
|
152
|
-
|
|
269
|
+
const formatted = results
|
|
270
|
+
.slice(0, maxResults * (1 + contextLines * 2))
|
|
271
|
+
.map((r) => `${r.file}:${r.line}${r.isMatch ? ':' : ' '} ${r.text}`)
|
|
272
|
+
.join('\n');
|
|
273
|
+
|
|
274
|
+
const totalMatches = Array.from(fileMatchCounts.values()).reduce((a, b) => a + b, 0);
|
|
275
|
+
const footer = `\n(${totalMatches} matches in ${fileMatchCounts.size} files)`;
|
|
276
|
+
|
|
277
|
+
return { success: true, result: formatted + footer };
|
|
153
278
|
}
|
|
154
279
|
|
|
155
280
|
async function runCommand(command: string, cwd?: string): Promise<ToolResult> {
|
|
@@ -177,6 +302,7 @@ export function formatToolCall(name: string, args: Record<string, any>): string
|
|
|
177
302
|
read_file: 'Reading file',
|
|
178
303
|
write_file: 'Writing file',
|
|
179
304
|
edit_file: 'Editing file',
|
|
305
|
+
apply_patch: 'Applying patch',
|
|
180
306
|
list_files: 'Listing files',
|
|
181
307
|
search_files: 'Searching files',
|
|
182
308
|
run_command: 'Running command'
|