@heysalad/cheri-cli 1.0.0 → 1.1.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/CHANGELOG.md +95 -0
- package/package.json +9 -3
- package/src/commands/agent.js +174 -98
- package/src/commands/login.js +30 -10
- package/src/commands/memory.js +2 -2
- package/src/commands/status.js +1 -1
- package/src/commands/usage.js +13 -9
- package/src/commands/workspace.js +1 -1
- package/src/lib/command-safety.js +7 -6
- package/src/lib/config-store.js +3 -13
- package/src/lib/diff-tracker.js +3 -4
- package/src/lib/logger.js +31 -12
- package/src/lib/markdown.js +39 -21
- package/src/lib/mcp/client.js +29 -10
- package/src/lib/providers/index.js +7 -2
- package/src/lib/sessions/index.js +1 -2
- package/src/lib/tools/file-tools.js +56 -23
- package/src/lib/tools/search-tools.js +1 -1
- package/src/lib/ui.js +554 -0
- package/src/repl.js +5 -0
package/src/lib/diff-tracker.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// File diff tracking — tracks changes made by the agent for review and rollback
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
|
|
3
3
|
import { join, dirname } from "path";
|
|
4
4
|
import { createTwoFilesPatch, structuredPatch } from "diff";
|
|
5
5
|
import { homedir } from "os";
|
|
@@ -47,8 +47,8 @@ export function recordChange(snapshot, newContent, operation = "edit") {
|
|
|
47
47
|
// Persist snapshot for rollback
|
|
48
48
|
try {
|
|
49
49
|
if (!existsSync(SNAPSHOTS_DIR)) mkdirSync(SNAPSHOTS_DIR, { recursive: true });
|
|
50
|
-
const
|
|
51
|
-
writeFileSync(
|
|
50
|
+
const snapshotPath = join(SNAPSHOTS_DIR, `${Date.now()}-${sanitizeFilename(snapshot.path)}`);
|
|
51
|
+
writeFileSync(snapshotPath, JSON.stringify(change), "utf-8");
|
|
52
52
|
} catch {}
|
|
53
53
|
|
|
54
54
|
return change;
|
|
@@ -101,7 +101,6 @@ export function rollbackChange(index) {
|
|
|
101
101
|
try {
|
|
102
102
|
if (!change.existed) {
|
|
103
103
|
// File was created — delete it
|
|
104
|
-
const { unlinkSync } = require("fs");
|
|
105
104
|
unlinkSync(change.path);
|
|
106
105
|
} else {
|
|
107
106
|
// Restore original content
|
package/src/lib/logger.js
CHANGED
|
@@ -13,51 +13,70 @@ function getVersion() {
|
|
|
13
13
|
return _version;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// Gradient colors
|
|
17
|
+
const gradients = {
|
|
18
|
+
cheri: (text) => chalk.hex('#FF6B6B')(text),
|
|
19
|
+
purple: (text) => chalk.hex('#A855F7')(text),
|
|
20
|
+
blue: (text) => chalk.hex('#3B82F6')(text),
|
|
21
|
+
green: (text) => chalk.hex('#10B981')(text),
|
|
22
|
+
yellow: (text) => chalk.hex('#F59E0B')(text),
|
|
23
|
+
cyan: (text) => chalk.hex('#06B6D4')(text),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Enhanced icons
|
|
27
|
+
const icons = {
|
|
28
|
+
info: 'ℹ',
|
|
29
|
+
success: '✓',
|
|
30
|
+
warning: '⚠',
|
|
31
|
+
error: '✗',
|
|
32
|
+
tip: '💡',
|
|
33
|
+
};
|
|
34
|
+
|
|
16
35
|
export const log = {
|
|
17
36
|
info(msg) {
|
|
18
|
-
console.log(
|
|
37
|
+
console.log(gradients.blue(`${icons.info} info`) + " " + chalk.dim(msg));
|
|
19
38
|
},
|
|
20
39
|
success(msg) {
|
|
21
|
-
console.log(
|
|
40
|
+
console.log(gradients.green(`${icons.success} done`) + " " + msg);
|
|
22
41
|
},
|
|
23
42
|
warn(msg) {
|
|
24
|
-
console.log(
|
|
43
|
+
console.log(gradients.yellow(`${icons.warning} warn`) + " " + msg);
|
|
25
44
|
},
|
|
26
45
|
error(msg) {
|
|
27
|
-
console.log(
|
|
46
|
+
console.log(gradients.cheri(`${icons.error} error`) + " " + chalk.red(msg));
|
|
28
47
|
},
|
|
29
48
|
dim(msg) {
|
|
30
49
|
console.log(chalk.dim(msg));
|
|
31
50
|
},
|
|
32
51
|
brand(msg) {
|
|
33
|
-
console.log(
|
|
52
|
+
console.log(gradients.cheri("🍒 cheri") + " " + chalk.bold(msg));
|
|
34
53
|
},
|
|
35
54
|
blank() {
|
|
36
55
|
console.log();
|
|
37
56
|
},
|
|
38
57
|
header(title) {
|
|
39
58
|
console.log();
|
|
40
|
-
console.log(chalk.bold(title));
|
|
41
|
-
console.log(
|
|
59
|
+
console.log(gradients.cyan("╭─") + " " + chalk.bold(title));
|
|
60
|
+
console.log(gradients.cyan("│"));
|
|
42
61
|
},
|
|
43
62
|
keyValue(key, value) {
|
|
44
|
-
console.log(` ${chalk.dim(key.padEnd(
|
|
63
|
+
console.log(` ${chalk.dim(key.padEnd(18))} ${gradients.cyan("→")} ${value}`);
|
|
45
64
|
},
|
|
46
65
|
tree(items) {
|
|
47
66
|
items.forEach((item, i) => {
|
|
48
67
|
const prefix = i === items.length - 1 ? " └─" : " ├─";
|
|
49
|
-
console.log(
|
|
68
|
+
console.log(gradients.cyan(prefix) + " " + item);
|
|
50
69
|
});
|
|
51
70
|
},
|
|
52
71
|
banner(version) {
|
|
53
72
|
if (!version) version = getVersion();
|
|
54
73
|
console.log();
|
|
55
|
-
console.log(` ${
|
|
74
|
+
console.log(` ${gradients.cheri("🍒")} ${chalk.red.bold("Cheri")}`);
|
|
56
75
|
console.log(` ${chalk.dim("AI-powered cloud IDE by HeySalad")}`);
|
|
57
|
-
console.log(` ${chalk.dim("v" + version)}`);
|
|
76
|
+
console.log(` ${gradients.cyan("✨")} ${chalk.dim("v" + version)}`);
|
|
58
77
|
console.log();
|
|
59
78
|
},
|
|
60
79
|
tip(msg) {
|
|
61
|
-
console.log(` ${
|
|
80
|
+
console.log(` ${gradients.yellow(`${icons.tip} tip`)} ${chalk.dim(msg)}`);
|
|
62
81
|
},
|
|
63
82
|
};
|
package/src/lib/markdown.js
CHANGED
|
@@ -1,32 +1,50 @@
|
|
|
1
1
|
// Rich markdown rendering for terminal output
|
|
2
|
-
import { Marked } from "marked";
|
|
3
|
-
import { markedTerminal } from "marked-terminal";
|
|
4
2
|
import chalk from "chalk";
|
|
5
3
|
|
|
6
|
-
const marked = new Marked(markedTerminal({
|
|
7
|
-
reflowText: true,
|
|
8
|
-
width: Math.min(process.stdout.columns || 100, 120),
|
|
9
|
-
showSectionPrefix: false,
|
|
10
|
-
tab: 2,
|
|
11
|
-
code: chalk.cyan,
|
|
12
|
-
codespan: chalk.cyan,
|
|
13
|
-
blockquote: chalk.dim.italic,
|
|
14
|
-
strong: chalk.bold,
|
|
15
|
-
em: chalk.italic,
|
|
16
|
-
del: chalk.strikethrough,
|
|
17
|
-
link: chalk.blue.underline,
|
|
18
|
-
href: chalk.blue.underline,
|
|
19
|
-
listitem: (text) => ` ${chalk.dim("•")} ${text}`,
|
|
20
|
-
}));
|
|
21
|
-
|
|
22
4
|
/**
|
|
23
|
-
*
|
|
5
|
+
* Simple markdown renderer for terminal
|
|
6
|
+
* Handles basic formatting without external renderer
|
|
24
7
|
*/
|
|
25
8
|
export function renderMarkdown(text) {
|
|
26
9
|
if (!text) return "";
|
|
10
|
+
|
|
27
11
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
12
|
+
let output = text;
|
|
13
|
+
|
|
14
|
+
// Code blocks ```code```
|
|
15
|
+
output = output.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
|
|
16
|
+
const header = lang ? chalk.dim(` ${lang}`) + "\n" : "";
|
|
17
|
+
const lines = code.split("\n").map(l => chalk.cyan(" " + l)).join("\n");
|
|
18
|
+
return "\n" + header + lines + "\n";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Inline code `code`
|
|
22
|
+
output = output.replace(/`([^`]+)`/g, (match, code) => chalk.cyan(code));
|
|
23
|
+
|
|
24
|
+
// Bold **text**
|
|
25
|
+
output = output.replace(/\*\*([^*]+)\*\*/g, (match, text) => chalk.bold(text));
|
|
26
|
+
|
|
27
|
+
// Italic *text*
|
|
28
|
+
output = output.replace(/\*([^*]+)\*/g, (match, text) => chalk.italic(text));
|
|
29
|
+
|
|
30
|
+
// Links [text](url)
|
|
31
|
+
output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) =>
|
|
32
|
+
chalk.blue.underline(text) + chalk.dim(` (${url})`)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Headers ### text
|
|
36
|
+
output = output.replace(/^(#{1,6})\s+(.+)$/gm, (match, hashes, text) => {
|
|
37
|
+
return chalk.bold.cyan(text);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// List items - text or * text
|
|
41
|
+
output = output.replace(/^[\-\*]\s+(.+)$/gm, (match, text) =>
|
|
42
|
+
` ${chalk.dim("•")} ${text}`
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return output.trim();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// Fallback to plain text if rendering fails
|
|
30
48
|
return text;
|
|
31
49
|
}
|
|
32
50
|
}
|
package/src/lib/mcp/client.js
CHANGED
|
@@ -118,7 +118,13 @@ export class McpServer {
|
|
|
118
118
|
params,
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
try {
|
|
122
|
+
this.process.stdin.write(message + "\n");
|
|
123
|
+
} catch (err) {
|
|
124
|
+
this.pendingRequests.delete(id);
|
|
125
|
+
clearTimeout(timeout);
|
|
126
|
+
reject(new Error(`Failed to write to MCP server '${this.name}': ${err.message}`));
|
|
127
|
+
}
|
|
122
128
|
});
|
|
123
129
|
}
|
|
124
130
|
|
|
@@ -128,7 +134,9 @@ export class McpServer {
|
|
|
128
134
|
method,
|
|
129
135
|
params,
|
|
130
136
|
});
|
|
131
|
-
|
|
137
|
+
try {
|
|
138
|
+
this.process.stdin.write(message + "\n");
|
|
139
|
+
} catch {}
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
_processBuffer() {
|
|
@@ -148,7 +156,9 @@ export class McpServer {
|
|
|
148
156
|
resolve(msg.result);
|
|
149
157
|
}
|
|
150
158
|
}
|
|
151
|
-
} catch {
|
|
159
|
+
} catch (err) {
|
|
160
|
+
log.dim(`[mcp:${this.name}] Failed to parse response: ${line.slice(0, 100)}`);
|
|
161
|
+
}
|
|
152
162
|
}
|
|
153
163
|
}
|
|
154
164
|
|
|
@@ -206,13 +216,22 @@ export class McpManager {
|
|
|
206
216
|
}
|
|
207
217
|
|
|
208
218
|
async callTool(fullName, args) {
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
// Find the server by checking registered tool names (avoids underscore ambiguity)
|
|
220
|
+
let server = null;
|
|
221
|
+
let toolName = null;
|
|
222
|
+
for (const s of this.servers.values()) {
|
|
223
|
+
const def = s.getToolDefinitions().find(t => t.name === fullName);
|
|
224
|
+
if (def) { server = s; toolName = def.mcpToolName; break; }
|
|
225
|
+
}
|
|
226
|
+
if (!server || !toolName) {
|
|
227
|
+
// Fallback: try regex parse
|
|
228
|
+
const match = fullName.match(/^mcp_([^_]+)_(.+)$/);
|
|
229
|
+
if (!match) return { error: `Invalid MCP tool name: ${fullName}` };
|
|
230
|
+
const [, serverName, tn] = match;
|
|
231
|
+
server = this.servers.get(serverName);
|
|
232
|
+
toolName = tn;
|
|
233
|
+
if (!server) return { error: `MCP server '${serverName}' not connected` };
|
|
234
|
+
}
|
|
216
235
|
|
|
217
236
|
try {
|
|
218
237
|
const result = await server.callTool(toolName, args);
|
|
@@ -210,8 +210,13 @@ class AnthropicProvider {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
await writer.write(encoder.encode("data: [DONE]\n\n"));
|
|
213
|
-
} catch
|
|
214
|
-
|
|
213
|
+
} catch (err) {
|
|
214
|
+
try {
|
|
215
|
+
const errChunk = { choices: [{ index: 0, delta: { content: `\n[Stream error: ${err.message || "unknown"}]` }, finish_reason: "stop" }] };
|
|
216
|
+
await writer.write(encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`));
|
|
217
|
+
} catch {}
|
|
218
|
+
} finally {
|
|
219
|
+
try { await writer.close(); } catch {}
|
|
215
220
|
}
|
|
216
221
|
})();
|
|
217
222
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "fs";
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
|
|
@@ -48,7 +48,6 @@ export function listSessions() {
|
|
|
48
48
|
export function deleteSession(sessionId) {
|
|
49
49
|
const filePath = join(SESSIONS_DIR, `${sessionId}.json`);
|
|
50
50
|
if (existsSync(filePath)) {
|
|
51
|
-
const { unlinkSync } = require("fs");
|
|
52
51
|
unlinkSync(filePath);
|
|
53
52
|
return true;
|
|
54
53
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "fs";
|
|
2
2
|
import { dirname, resolve } from "path";
|
|
3
3
|
|
|
4
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB limit
|
|
5
|
+
|
|
4
6
|
export const readFile = {
|
|
5
7
|
name: "read_file",
|
|
6
8
|
description: "Read the contents of a file at the given path. Returns the file contents as a string.",
|
|
@@ -12,12 +14,32 @@ export const readFile = {
|
|
|
12
14
|
required: ["path"],
|
|
13
15
|
},
|
|
14
16
|
handler: async ({ path }) => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
try {
|
|
18
|
+
const resolved = resolve(path);
|
|
19
|
+
if (!existsSync(resolved)) {
|
|
20
|
+
return { error: `File not found: ${resolved}` };
|
|
21
|
+
}
|
|
22
|
+
const stat = statSync(resolved);
|
|
23
|
+
if (stat.isDirectory()) {
|
|
24
|
+
return { error: `Path is a directory, not a file: ${resolved}` };
|
|
25
|
+
}
|
|
26
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
27
|
+
return { error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Max: 5MB` };
|
|
28
|
+
}
|
|
29
|
+
// Detect binary files by checking for null bytes in first 8KB
|
|
30
|
+
const buf = Buffer.alloc(Math.min(8192, stat.size));
|
|
31
|
+
const fd = await import("fs").then(fs => fs.openSync(resolved, "r"));
|
|
32
|
+
const { readSync, closeSync } = await import("fs");
|
|
33
|
+
readSync(fd, buf, 0, buf.length, 0);
|
|
34
|
+
closeSync(fd);
|
|
35
|
+
if (buf.includes(0)) {
|
|
36
|
+
return { error: `Binary file detected: ${resolved}. Cannot read binary files as text.` };
|
|
37
|
+
}
|
|
38
|
+
const content = readFileSync(resolved, "utf-8");
|
|
39
|
+
return { path: resolved, content, lines: content.split("\n").length };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { error: `Failed to read file: ${err.message}` };
|
|
18
42
|
}
|
|
19
|
-
const content = readFileSync(resolved, "utf-8");
|
|
20
|
-
return { path: resolved, content, lines: content.split("\n").length };
|
|
21
43
|
},
|
|
22
44
|
};
|
|
23
45
|
|
|
@@ -33,13 +55,17 @@ export const writeFile = {
|
|
|
33
55
|
required: ["path", "content"],
|
|
34
56
|
},
|
|
35
57
|
handler: async ({ path, content }) => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
try {
|
|
59
|
+
const resolved = resolve(path);
|
|
60
|
+
const dir = dirname(resolved);
|
|
61
|
+
if (!existsSync(dir)) {
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(resolved, content, "utf-8");
|
|
65
|
+
return { path: resolved, bytesWritten: Buffer.byteLength(content, "utf-8") };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { error: `Failed to write file: ${err.message}` };
|
|
40
68
|
}
|
|
41
|
-
writeFileSync(resolved, content, "utf-8");
|
|
42
|
-
return { path: resolved, bytesWritten: Buffer.byteLength(content, "utf-8") };
|
|
43
69
|
},
|
|
44
70
|
};
|
|
45
71
|
|
|
@@ -56,17 +82,24 @@ export const editFile = {
|
|
|
56
82
|
required: ["path", "old_string", "new_string"],
|
|
57
83
|
},
|
|
58
84
|
handler: async ({ path, old_string, new_string }) => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
85
|
+
try {
|
|
86
|
+
const resolved = resolve(path);
|
|
87
|
+
if (!existsSync(resolved)) {
|
|
88
|
+
return { error: `File not found: ${resolved}` };
|
|
89
|
+
}
|
|
90
|
+
const content = readFileSync(resolved, "utf-8");
|
|
91
|
+
if (!content.includes(old_string)) {
|
|
92
|
+
return { error: "old_string not found in file. Make sure it matches exactly, including whitespace." };
|
|
93
|
+
}
|
|
94
|
+
const count = content.split(old_string).length - 1;
|
|
95
|
+
if (count > 1) {
|
|
96
|
+
return { error: `old_string found ${count} times. It must be unique — add more surrounding context.` };
|
|
97
|
+
}
|
|
98
|
+
const newContent = content.replace(old_string, new_string);
|
|
99
|
+
writeFileSync(resolved, newContent, "utf-8");
|
|
100
|
+
return { path: resolved, replacements: 1 };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { error: `Failed to edit file: ${err.message}` };
|
|
66
103
|
}
|
|
67
|
-
const count = content.split(old_string).length - 1;
|
|
68
|
-
const newContent = content.replace(old_string, new_string);
|
|
69
|
-
writeFileSync(resolved, newContent, "utf-8");
|
|
70
|
-
return { path: resolved, replacements: 1, totalOccurrences: count };
|
|
71
104
|
},
|
|
72
105
|
};
|
|
@@ -43,7 +43,7 @@ export const searchContent = {
|
|
|
43
43
|
handler: async ({ pattern, path, include }) => {
|
|
44
44
|
const dir = resolve(path || ".");
|
|
45
45
|
try {
|
|
46
|
-
let cmd = `grep -rn --include
|
|
46
|
+
let cmd = `grep -rn --include=${JSON.stringify(include || "*")} ${JSON.stringify(pattern)} ${JSON.stringify(dir)} 2>/dev/null | head -50`;
|
|
47
47
|
const result = execSync(cmd, { encoding: "utf-8", timeout: 10_000 });
|
|
48
48
|
const lines = result.trim().split("\n").filter(Boolean);
|
|
49
49
|
const matches = lines.map((line) => {
|