aryx-cli 1.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/bin/aryx +15 -0
- package/bin/aryx.cjs +15 -0
- package/package.json +42 -0
- package/src/components/CommandSuggestions.tsx +62 -0
- package/src/components/Header.tsx +77 -0
- package/src/components/HelpView.tsx +36 -0
- package/src/components/IAmAryx.tsx +51 -0
- package/src/components/Loader.tsx +57 -0
- package/src/components/LoginView.tsx +129 -0
- package/src/components/Message.tsx +65 -0
- package/src/components/ThemeSelector.tsx +92 -0
- package/src/components/UsageView.tsx +52 -0
- package/src/constants.ts +32 -0
- package/src/hooks/useChat.ts +306 -0
- package/src/index.tsx +18 -0
- package/src/screens/ChatScreen.tsx +277 -0
- package/src/screens/SetupScreen.tsx +85 -0
- package/src/services/ai.ts +187 -0
- package/src/services/auth.ts +100 -0
- package/src/services/config.ts +30 -0
- package/src/services/fileTools.ts +257 -0
- package/src/services/firestoreRest.ts +119 -0
- package/src/services/loginServer.ts +91 -0
- package/src/services/platform.ts +26 -0
- package/src/theme.ts +46 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import ThemeSelector from '../components/ThemeSelector.js';
|
|
4
|
+
import { theme, markSetupDone } from '../theme.js';
|
|
5
|
+
import { BRAND_NAME, VERSION } from '../constants.js';
|
|
6
|
+
|
|
7
|
+
const SECURITY_NOTES = [
|
|
8
|
+
{ title: `${BRAND_NAME} can make mistakes`, desc: `You should always review ${BRAND_NAME}'s responses, especially when running code.` },
|
|
9
|
+
{ title: `${BRAND_NAME} executes commands carefully, but responsibility is yours`, desc: 'You should always review and verify code before running it, especially if it modifies files or installs dependencies.' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const BANNER = [
|
|
13
|
+
' █████╗ ██████╗ ██╗ ██╗██╗ ██╗',
|
|
14
|
+
' ██╔══██╗██╔══██╗╚██╗ ██╔╝╚██╗██╔╝',
|
|
15
|
+
' ███████║██████╔╝ ╚████╔╝ ╚███╔╝ ',
|
|
16
|
+
' ██║ ██║██║ ██║ ██║ ██╔╝ ██╗',
|
|
17
|
+
' ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const BannerHeader: React.FC<{ showTagline?: boolean }> = ({ showTagline }) => (
|
|
21
|
+
<Box flexDirection="column" paddingX={1} paddingTop={1} marginBottom={1}>
|
|
22
|
+
<Text>
|
|
23
|
+
<Text color={theme.colors.primary}>Welcome to {BRAND_NAME} </Text>
|
|
24
|
+
<Text color="gray">v{VERSION}</Text>
|
|
25
|
+
</Text>
|
|
26
|
+
<Box flexDirection="column" marginTop={1}>
|
|
27
|
+
{BANNER.map((line, i) => (
|
|
28
|
+
<Text key={i} color={theme.colors.primary}>{line}</Text>
|
|
29
|
+
))}
|
|
30
|
+
</Box>
|
|
31
|
+
{showTagline && <Box paddingTop={1}><Text color="gray">Let's get started.</Text></Box>}
|
|
32
|
+
</Box>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const SetupScreen: React.FC<{ onComplete: () => void }> = ({ onComplete }) => {
|
|
36
|
+
const [phase, setPhase] = useState<'theme' | 'security'>('theme');
|
|
37
|
+
|
|
38
|
+
useInput((_, key) => {
|
|
39
|
+
if (phase === 'security' && key.return) {
|
|
40
|
+
markSetupDone();
|
|
41
|
+
onComplete();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Box
|
|
47
|
+
key={phase}
|
|
48
|
+
flexDirection="column">
|
|
49
|
+
<BannerHeader showTagline={phase === 'theme'} />
|
|
50
|
+
|
|
51
|
+
{phase === 'theme' ? (
|
|
52
|
+
<ThemeSelector
|
|
53
|
+
onClose={() => setPhase('security')}
|
|
54
|
+
isSetup />
|
|
55
|
+
) : (
|
|
56
|
+
<Box
|
|
57
|
+
flexDirection="column"
|
|
58
|
+
paddingX={2}
|
|
59
|
+
marginTop={1}>
|
|
60
|
+
<Text bold>Security notes:</Text>
|
|
61
|
+
|
|
62
|
+
<Box flexDirection="column" marginTop={1}>
|
|
63
|
+
{SECURITY_NOTES.map((note, i) => (
|
|
64
|
+
<Box key={i} marginTop={i > 0 ? 1 : 0}>
|
|
65
|
+
<Text>{i + 1}. </Text>
|
|
66
|
+
|
|
67
|
+
<Box flexDirection="column">
|
|
68
|
+
<Text bold>{note.title}</Text>
|
|
69
|
+
<Text color="gray">{note.desc}</Text>
|
|
70
|
+
</Box>
|
|
71
|
+
</Box>
|
|
72
|
+
))}
|
|
73
|
+
</Box>
|
|
74
|
+
|
|
75
|
+
<Box marginTop={1}>
|
|
76
|
+
<Text color={theme.colors.primary}>Press <Text bold>Enter</Text> to continue...</Text>
|
|
77
|
+
</Box>
|
|
78
|
+
</Box>
|
|
79
|
+
)}
|
|
80
|
+
</Box>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default SetupScreen;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { FILE_TOOLS, executeTool, formatDiff } from './fileTools.js';
|
|
4
|
+
import { BRAND_NAME_LOWER, OPENROUTER_API_KEY, OPENROUTER_API_KEY_BACKUP, OPENROUTER_MODEL } from '../constants.js';
|
|
5
|
+
|
|
6
|
+
const FILE_WRITE_TOOLS = new Set(['create_file', 'write_file', 'edit_file', 'append_file']);
|
|
7
|
+
|
|
8
|
+
export interface ChatCompletionRequest {
|
|
9
|
+
messages: { role: 'user' | 'assistant'; content: string }[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UsageData {
|
|
13
|
+
inputTokens: number;
|
|
14
|
+
outputTokens: number;
|
|
15
|
+
totalTokens: number;
|
|
16
|
+
cost: number;
|
|
17
|
+
model: string;
|
|
18
|
+
responseTimeMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ChatCompletionResponse {
|
|
22
|
+
content: string;
|
|
23
|
+
usage?: UsageData;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
27
|
+
|
|
28
|
+
const getConfig = () => {
|
|
29
|
+
return {
|
|
30
|
+
apiKey: OPENROUTER_API_KEY,
|
|
31
|
+
backupKey: OPENROUTER_API_KEY_BACKUP || undefined,
|
|
32
|
+
model: OPENROUTER_MODEL,
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type OpenRouterMessage = {
|
|
37
|
+
content?: string | Array<{ text?: string }>;
|
|
38
|
+
tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type OpenRouterResponse = {
|
|
42
|
+
error?: { message?: string };
|
|
43
|
+
choices?: Array<{ message?: OpenRouterMessage }>;
|
|
44
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number; cost?: number };
|
|
45
|
+
model?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const extractContent = (data: OpenRouterResponse): string => {
|
|
49
|
+
const content = data.choices?.[0]?.message?.content;
|
|
50
|
+
|
|
51
|
+
if (typeof content === 'string') return content.trim();
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(content)) {
|
|
54
|
+
return content.map((item) => item.text || '').join('').trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return '';
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const fetchOpenRouter = async (messages: object[], apiKey: string, model: string, backupKey?: string) => {
|
|
61
|
+
const t0 = Date.now();
|
|
62
|
+
|
|
63
|
+
const tryFetch = async (key: string) => {
|
|
64
|
+
const res = await fetch(OPENROUTER_URL, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: {
|
|
67
|
+
authorization: `Bearer ${key}`,
|
|
68
|
+
'content-type': 'application/json'
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({ model, messages, tools: FILE_TOOLS }),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const data = await res.json() as OpenRouterResponse;
|
|
74
|
+
return { res, data };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let { res, data } = await tryFetch(apiKey);
|
|
78
|
+
|
|
79
|
+
if (!res.ok && backupKey) {
|
|
80
|
+
({ res, data } = await tryFetch(backupKey));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
throw new Error(data.error?.message || `OpenRouter failed (${res.status})`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
message: data.choices?.[0]?.message,
|
|
89
|
+
usage: data.usage,
|
|
90
|
+
model: data.model,
|
|
91
|
+
responseTimeMs: Date.now() - t0
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const chatService = {
|
|
96
|
+
fetchResponse: async (request: ChatCompletionRequest): Promise<ChatCompletionResponse> => {
|
|
97
|
+
const { apiKey, backupKey, model } = getConfig();
|
|
98
|
+
|
|
99
|
+
const messages: object[] = [
|
|
100
|
+
{ role: 'system', content: `You are a helpful AI with file system access. CWD: ${process.cwd()}
|
|
101
|
+
|
|
102
|
+
## About the Creator
|
|
103
|
+
${BRAND_NAME_LOWER} CLI was created by Vasu Bhalodiya.
|
|
104
|
+
- Full Name: Vasu Bhalodiya
|
|
105
|
+
- Age: 20
|
|
106
|
+
- Location: Rajkot, Gujarat, India
|
|
107
|
+
- Role: Frontend Developer
|
|
108
|
+
- Company: Prolix Technikos
|
|
109
|
+
- Tech Stack: React.js, Next.js, JavaScript, TypeScript, TailwindCSS
|
|
110
|
+
- GitHub: https://github.com/vasubhalodiya
|
|
111
|
+
- LinkedIn: https://www.linkedin.com/in/vasubhalodiya
|
|
112
|
+
- Portfolio: https://www.vasubhalodiya.in
|
|
113
|
+
|
|
114
|
+
If anyone asks about "Vasu", "Vasu Bhalodiya", or the creator/developer of ${BRAND_NAME_LOWER}, provide the above information.` },
|
|
115
|
+
...request.messages,
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
// filePath -> content BEFORE any changes this session
|
|
119
|
+
const originals = new Map<string, string>();
|
|
120
|
+
let lastUsage: UsageData | undefined;
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < 10; i++) {
|
|
123
|
+
const {
|
|
124
|
+
message: msg,
|
|
125
|
+
usage,
|
|
126
|
+
model: respModel,
|
|
127
|
+
responseTimeMs
|
|
128
|
+
} = await fetchOpenRouter(messages, apiKey, model, backupKey);
|
|
129
|
+
|
|
130
|
+
if (!msg) throw new Error('Empty response');
|
|
131
|
+
messages.push(msg);
|
|
132
|
+
|
|
133
|
+
if (usage) {
|
|
134
|
+
lastUsage = {
|
|
135
|
+
inputTokens: usage.prompt_tokens ?? 0,
|
|
136
|
+
outputTokens: usage.completion_tokens ?? 0,
|
|
137
|
+
totalTokens: usage.total_tokens ?? 0,
|
|
138
|
+
cost: usage.cost ?? 0,
|
|
139
|
+
model: respModel ?? model,
|
|
140
|
+
responseTimeMs,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!msg.tool_calls?.length) {
|
|
145
|
+
// Build one diff per changed file
|
|
146
|
+
const diffs: string[] = [];
|
|
147
|
+
|
|
148
|
+
for (const [fp, oldContent] of originals) {
|
|
149
|
+
const newContent = existsSync(fp) ? readFileSync(fp, 'utf8') : '';
|
|
150
|
+
|
|
151
|
+
if (newContent !== oldContent) {
|
|
152
|
+
diffs.push(formatDiff(fp, oldContent, newContent));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const aiText = extractContent({ choices: [{ message: msg }] }) || 'Done.';
|
|
157
|
+
const prefix = diffs.join('\n\n');
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
content: prefix ? prefix + '\n\n' + aiText : aiText,
|
|
161
|
+
usage: lastUsage
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const tc of msg.tool_calls) {
|
|
166
|
+
const args = JSON.parse(tc.function.arguments || '{}');
|
|
167
|
+
|
|
168
|
+
// Snapshot original content before first write to this file
|
|
169
|
+
if (FILE_WRITE_TOOLS.has(tc.function.name) && args.file_path) {
|
|
170
|
+
const fp = resolve(process.cwd(), args.file_path);
|
|
171
|
+
if (!originals.has(fp)) {
|
|
172
|
+
originals.set(fp, existsSync(fp) ? readFileSync(fp, 'utf8') : '');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const output = executeTool(tc.function.name, args);
|
|
177
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: output });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
content: 'Max iterations reached.',
|
|
183
|
+
usage: lastUsage
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { readConfig, writeConfig } from './config.js';
|
|
5
|
+
import { FIREBASE_API_KEY } from '../constants.js';
|
|
6
|
+
|
|
7
|
+
const CFG = join(homedir(), '.aryx', 'config.json');
|
|
8
|
+
|
|
9
|
+
export type AuthData = {
|
|
10
|
+
uid: string;
|
|
11
|
+
email: string;
|
|
12
|
+
displayName: string;
|
|
13
|
+
photoURL: string;
|
|
14
|
+
idToken: string;
|
|
15
|
+
refreshToken: string;
|
|
16
|
+
idTokenExpiry: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const readAuth = (): AuthData | null => {
|
|
20
|
+
const cfg = readConfig();
|
|
21
|
+
const auth = cfg.auth as AuthData | undefined;
|
|
22
|
+
|
|
23
|
+
if (!auth?.uid) return null;
|
|
24
|
+
|
|
25
|
+
return auth;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const saveAuth = (data: AuthData): void => {
|
|
29
|
+
writeConfig({
|
|
30
|
+
...readConfig(),
|
|
31
|
+
auth: data
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const clearAuth = (): void => {
|
|
36
|
+
const cfg = readConfig();
|
|
37
|
+
delete cfg.auth;
|
|
38
|
+
|
|
39
|
+
writeConfig(cfg);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const deleteConfig = (): void => {
|
|
43
|
+
try {
|
|
44
|
+
if (existsSync(CFG)) {
|
|
45
|
+
unlinkSync(CFG);
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Ignore errors
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const isLoggedIn = (): boolean => {
|
|
53
|
+
return readAuth() !== null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const getValidToken = async (): Promise<string | null> => {
|
|
57
|
+
const auth = readAuth();
|
|
58
|
+
if (!auth) return null;
|
|
59
|
+
|
|
60
|
+
if (Date.now() < auth.idTokenExpiry) {
|
|
61
|
+
return auth.idToken;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// idToken expired — refresh via Firebase REST API
|
|
65
|
+
try {
|
|
66
|
+
const apiKey = FIREBASE_API_KEY;
|
|
67
|
+
|
|
68
|
+
const res = await fetch(
|
|
69
|
+
`https://securetoken.googleapis.com/v1/token?key=${apiKey}`,
|
|
70
|
+
{
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: {
|
|
73
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
74
|
+
},
|
|
75
|
+
body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(auth.refreshToken)}`,
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
clearAuth();
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const json = await res.json() as { id_token: string; refresh_token: string };
|
|
85
|
+
|
|
86
|
+
const updated: AuthData = {
|
|
87
|
+
...auth,
|
|
88
|
+
idToken: json.id_token,
|
|
89
|
+
refreshToken: json.refresh_token,
|
|
90
|
+
idTokenExpiry: Date.now() + 3_600_000,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
saveAuth(updated);
|
|
94
|
+
|
|
95
|
+
return updated.idToken;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), '.aryx');
|
|
6
|
+
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
/** Read the shared config file (~/.aryx/config.json). */
|
|
9
|
+
export const readConfig = (): Record<string, unknown> => {
|
|
10
|
+
try {
|
|
11
|
+
if (existsSync(CONFIG_PATH)) {
|
|
12
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
} catch {
|
|
15
|
+
/* corrupted / unreadable — treat as empty */
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Write the shared config file (~/.aryx/config.json). */
|
|
22
|
+
export const writeConfig = (data: Record<string, unknown>): void => {
|
|
23
|
+
try {
|
|
24
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
25
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(data));
|
|
26
|
+
} catch {
|
|
27
|
+
/* permission error — silently ignore */
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { diffLines } from 'diff';
|
|
5
|
+
import { theme, DIFF_COLORS } from '../theme.js';
|
|
6
|
+
|
|
7
|
+
type Args = Record<string, string>;
|
|
8
|
+
type Tool = { description: string; params: string[]; optional?: string[]; handler: (a: Args) => string };
|
|
9
|
+
|
|
10
|
+
// ─── Diff Engine ─────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const formatDiff = (fp: string, oldSrc: string, newSrc: string): string => {
|
|
13
|
+
const rel = path.relative(process.cwd(), fp).replace(/\\/g, '/');
|
|
14
|
+
const { add: addClr, rm: rmClr } = DIFF_COLORS[theme.diffTheme];
|
|
15
|
+
const useBg = theme.diffTheme <= 2;
|
|
16
|
+
const isNew = !oldSrc;
|
|
17
|
+
|
|
18
|
+
type Op = { t: 's' | 'a' | 'r'; l: string };
|
|
19
|
+
|
|
20
|
+
const ops: Op[] = diffLines(oldSrc || '', newSrc).flatMap(ch => {
|
|
21
|
+
const t = ch.added ? 'a' : ch.removed ? 'r' : 's';
|
|
22
|
+
const lines = ch.value.split('\n');
|
|
23
|
+
|
|
24
|
+
if (lines.at(-1) === '') lines.pop();
|
|
25
|
+
|
|
26
|
+
return lines.map(l => ({ t, l }) as Op);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const added = ops.filter(o => o.t === 'a').length;
|
|
30
|
+
const rem = ops.filter(o => o.t === 'r').length;
|
|
31
|
+
|
|
32
|
+
if (!added && !rem) return `No changes: ${rel}`;
|
|
33
|
+
|
|
34
|
+
// visible = changed ± 2 context lines
|
|
35
|
+
const vis = new Set<number>();
|
|
36
|
+
ops.forEach((o, i) => {
|
|
37
|
+
if (o.t !== 's') {
|
|
38
|
+
for (let c = Math.max(0, i - 2); c <= Math.min(ops.length - 1, i + 2); c++) {
|
|
39
|
+
vis.add(c);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const hdr = chalk.bold(`${isNew ? 'Create' : 'Update'}(${rel})`);
|
|
45
|
+
const sum = ` └─ Added ${added} lines${isNew ? '' : `, removed ${rem} lines`}`;
|
|
46
|
+
const body: string[] = [];
|
|
47
|
+
|
|
48
|
+
let ol = 0, nl = 0, hidden = false;
|
|
49
|
+
|
|
50
|
+
ops.forEach((o, i) => {
|
|
51
|
+
if (o.t === 's') {
|
|
52
|
+
ol++;
|
|
53
|
+
nl++;
|
|
54
|
+
} else if (o.t === 'r') {
|
|
55
|
+
ol++;
|
|
56
|
+
} else {
|
|
57
|
+
nl++;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!vis.has(i)) {
|
|
61
|
+
hidden = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (hidden) {
|
|
66
|
+
body.push(chalk.gray(' ...'));
|
|
67
|
+
hidden = false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ln = String(o.t === 'a' ? nl : ol).padStart(4);
|
|
71
|
+
|
|
72
|
+
if (o.t === 's') {
|
|
73
|
+
body.push(chalk.gray(` ${ln} ${o.l}`));
|
|
74
|
+
} else if (o.t === 'r') {
|
|
75
|
+
body.push(useBg ? chalk.bgHex(rmClr).black(`- ${ln} ${o.l}`) : chalk.hex(rmClr)(`- ${ln} ${o.l}`));
|
|
76
|
+
} else {
|
|
77
|
+
body.push(useBg ? chalk.bgHex(addClr).black(`+ ${ln} ${o.l}`) : chalk.hex(addClr)(`+ ${ln} ${o.l}`));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return [hdr, sum, '', ...body].join('\n');
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const r = (p: string) => path.resolve(process.cwd(), p || '.');
|
|
87
|
+
const mk = (fp: string) => fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
88
|
+
|
|
89
|
+
// ─── Add / remove tools here only ────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const TOOLS: Record<string, Tool> = {
|
|
92
|
+
|
|
93
|
+
create_file: {
|
|
94
|
+
description: 'Create a new file with content (fails if already exists)',
|
|
95
|
+
params: ['file_path', 'content'],
|
|
96
|
+
handler: (a) => {
|
|
97
|
+
const fp = r(a.file_path);
|
|
98
|
+
mk(fp);
|
|
99
|
+
|
|
100
|
+
if (fs.existsSync(fp)) return `Already exists: ${fp}`;
|
|
101
|
+
|
|
102
|
+
fs.writeFileSync(fp, a.content, 'utf8');
|
|
103
|
+
return `Created: ${fp}`;
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
read_file: {
|
|
108
|
+
description: 'Read and return the full contents of a file',
|
|
109
|
+
params: ['file_path'],
|
|
110
|
+
handler: (a) => {
|
|
111
|
+
return fs.readFileSync(r(a.file_path), 'utf8');
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
write_file: {
|
|
116
|
+
description: 'Overwrite a file with new content (creates if missing)',
|
|
117
|
+
params: ['file_path', 'content'],
|
|
118
|
+
handler: (a) => {
|
|
119
|
+
const fp = r(a.file_path);
|
|
120
|
+
mk(fp);
|
|
121
|
+
fs.writeFileSync(fp, a.content, 'utf8');
|
|
122
|
+
|
|
123
|
+
return `Written: ${fp}`;
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
append_file: {
|
|
128
|
+
description: 'Append content to the end of a file',
|
|
129
|
+
params: ['file_path', 'content'],
|
|
130
|
+
handler: (a) => {
|
|
131
|
+
const fp = r(a.file_path);
|
|
132
|
+
mk(fp);
|
|
133
|
+
fs.appendFileSync(fp, a.content, 'utf8');
|
|
134
|
+
|
|
135
|
+
return `Appended to: ${fp}`;
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
edit_file: {
|
|
140
|
+
description: 'Find old_text in a file and replace it with new_text',
|
|
141
|
+
params: ['file_path', 'old_text', 'new_text'],
|
|
142
|
+
handler: (a) => {
|
|
143
|
+
const fp = r(a.file_path);
|
|
144
|
+
const src = fs.readFileSync(fp, 'utf8');
|
|
145
|
+
|
|
146
|
+
if (!src.includes(a.old_text)) return `Text not found in: ${fp}`;
|
|
147
|
+
|
|
148
|
+
fs.writeFileSync(fp, src.replace(a.old_text, a.new_text), 'utf8');
|
|
149
|
+
return `Edited: ${fp}`;
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
delete_file: {
|
|
154
|
+
description: 'Delete a single file',
|
|
155
|
+
params: ['file_path'],
|
|
156
|
+
handler: (a) => {
|
|
157
|
+
fs.unlinkSync(r(a.file_path));
|
|
158
|
+
return `Deleted: ${a.file_path}`;
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
copy_file: {
|
|
163
|
+
description: 'Copy a file from src path to dest path',
|
|
164
|
+
params: ['src', 'dest'],
|
|
165
|
+
handler: (a) => {
|
|
166
|
+
const dest = r(a.dest);
|
|
167
|
+
mk(dest);
|
|
168
|
+
fs.copyFileSync(r(a.src), dest);
|
|
169
|
+
|
|
170
|
+
return `Copied → ${dest}`;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
move_file: {
|
|
175
|
+
description: 'Move or rename a file from src to dest',
|
|
176
|
+
params: ['src', 'dest'],
|
|
177
|
+
handler: (a) => {
|
|
178
|
+
const dest = r(a.dest);
|
|
179
|
+
mk(dest);
|
|
180
|
+
fs.renameSync(r(a.src), dest);
|
|
181
|
+
|
|
182
|
+
return `Moved → ${dest}`;
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
list_files: {
|
|
187
|
+
description: 'List files and folders in a directory',
|
|
188
|
+
params: [],
|
|
189
|
+
optional: ['dir_path'],
|
|
190
|
+
handler: (a) => {
|
|
191
|
+
const entries = fs.readdirSync(r(a.dir_path), { withFileTypes: true });
|
|
192
|
+
|
|
193
|
+
return entries
|
|
194
|
+
.map(e => `${e.isDirectory() ? '[dir] ' : '[file]'} ${e.name}`)
|
|
195
|
+
.join('\n') || '(empty)';
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
make_dir: {
|
|
200
|
+
description: 'Create a directory (and any missing parent directories)',
|
|
201
|
+
params: ['dir_path'],
|
|
202
|
+
handler: (a) => {
|
|
203
|
+
fs.mkdirSync(r(a.dir_path), { recursive: true });
|
|
204
|
+
return `Directory created: ${a.dir_path}`;
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
delete_dir: {
|
|
209
|
+
description: 'Recursively delete a directory and all its contents',
|
|
210
|
+
params: ['dir_path'],
|
|
211
|
+
handler: (a) => {
|
|
212
|
+
fs.rmSync(r(a.dir_path), { recursive: true, force: true });
|
|
213
|
+
return `Directory deleted: ${a.dir_path}`;
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
file_exists: {
|
|
218
|
+
description: 'Check whether a file or directory exists',
|
|
219
|
+
params: ['file_path'],
|
|
220
|
+
handler: (a) => {
|
|
221
|
+
return fs.existsSync(r(a.file_path))
|
|
222
|
+
? `Exists: ${a.file_path}`
|
|
223
|
+
: `Not found: ${a.file_path}`;
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
// Auto-build OpenRouter tool definitions from TOOLS map
|
|
232
|
+
export const FILE_TOOLS = Object.entries(TOOLS).map(([name, { description, params, optional = [] }]) => ({
|
|
233
|
+
type: 'function',
|
|
234
|
+
function: {
|
|
235
|
+
name,
|
|
236
|
+
description,
|
|
237
|
+
parameters: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: Object.fromEntries(
|
|
240
|
+
[...params, ...optional].map(k => [k, { type: 'string' }])
|
|
241
|
+
),
|
|
242
|
+
required: params,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
}));
|
|
246
|
+
|
|
247
|
+
export { formatDiff };
|
|
248
|
+
|
|
249
|
+
// Auto-dispatch to handler
|
|
250
|
+
export const executeTool = (name: string, args: Args): string => {
|
|
251
|
+
try {
|
|
252
|
+
return TOOLS[name]?.handler(args) ?? `Unknown tool: ${name}`;
|
|
253
|
+
} catch (e: any) {
|
|
254
|
+
return `Error: ${e.message}`;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|