@heysalad/cheri-cli 0.9.0 → 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.
@@ -0,0 +1,157 @@
1
+ // File diff tracking — tracks changes made by the agent for review and rollback
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { createTwoFilesPatch, structuredPatch } from "diff";
5
+ import { homedir } from "os";
6
+
7
+ const SNAPSHOTS_DIR = join(homedir(), ".cheri", "snapshots");
8
+
9
+ // In-memory change log for current session
10
+ let sessionChanges = [];
11
+
12
+ /**
13
+ * Take a snapshot of a file before modification
14
+ */
15
+ export function snapshotFile(filePath) {
16
+ const absPath = filePath.startsWith("/") ? filePath : join(process.cwd(), filePath);
17
+ let content = "";
18
+ let existed = false;
19
+
20
+ if (existsSync(absPath)) {
21
+ try {
22
+ content = readFileSync(absPath, "utf-8");
23
+ existed = true;
24
+ } catch {
25
+ content = "";
26
+ }
27
+ }
28
+
29
+ return { path: absPath, content, existed, timestamp: Date.now() };
30
+ }
31
+
32
+ /**
33
+ * Record a file change (call after modification)
34
+ */
35
+ export function recordChange(snapshot, newContent, operation = "edit") {
36
+ const change = {
37
+ path: snapshot.path,
38
+ operation,
39
+ before: snapshot.content,
40
+ after: newContent,
41
+ existed: snapshot.existed,
42
+ timestamp: Date.now(),
43
+ };
44
+
45
+ sessionChanges.push(change);
46
+
47
+ // Persist snapshot for rollback
48
+ try {
49
+ if (!existsSync(SNAPSHOTS_DIR)) mkdirSync(SNAPSHOTS_DIR, { recursive: true });
50
+ const snapshotFile = join(SNAPSHOTS_DIR, `${Date.now()}-${sanitizeFilename(snapshot.path)}`);
51
+ writeFileSync(snapshotFile, JSON.stringify(change), "utf-8");
52
+ } catch {}
53
+
54
+ return change;
55
+ }
56
+
57
+ /**
58
+ * Generate a unified diff for a change
59
+ */
60
+ export function generateDiff(change) {
61
+ return createTwoFilesPatch(
62
+ change.path,
63
+ change.path,
64
+ change.before,
65
+ change.after,
66
+ "before",
67
+ "after"
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Get summary of changes in current session
73
+ */
74
+ export function getSessionChanges() {
75
+ return sessionChanges.map(c => ({
76
+ path: c.path,
77
+ operation: c.operation,
78
+ linesAdded: countLines(c.after) - countLines(c.before),
79
+ timestamp: c.timestamp,
80
+ }));
81
+ }
82
+
83
+ /**
84
+ * Get detailed diff for a specific change
85
+ */
86
+ export function getChangeDiff(index) {
87
+ if (index < 0 || index >= sessionChanges.length) return null;
88
+ return generateDiff(sessionChanges[index]);
89
+ }
90
+
91
+ /**
92
+ * Rollback a specific change
93
+ */
94
+ export function rollbackChange(index) {
95
+ if (index < 0 || index >= sessionChanges.length) {
96
+ return { error: "Invalid change index" };
97
+ }
98
+
99
+ const change = sessionChanges[index];
100
+
101
+ try {
102
+ if (!change.existed) {
103
+ // File was created — delete it
104
+ const { unlinkSync } = require("fs");
105
+ unlinkSync(change.path);
106
+ } else {
107
+ // Restore original content
108
+ const dir = dirname(change.path);
109
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
110
+ writeFileSync(change.path, change.before, "utf-8");
111
+ }
112
+
113
+ // Remove from session changes
114
+ sessionChanges.splice(index, 1);
115
+ return { success: true, path: change.path };
116
+ } catch (err) {
117
+ return { error: err.message };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Rollback all changes in reverse order
123
+ */
124
+ export function rollbackAll() {
125
+ const results = [];
126
+ for (let i = sessionChanges.length - 1; i >= 0; i--) {
127
+ results.push(rollbackChange(i));
128
+ }
129
+ return results;
130
+ }
131
+
132
+ /**
133
+ * Clear session changes (without rolling back)
134
+ */
135
+ export function clearSessionChanges() {
136
+ sessionChanges = [];
137
+ }
138
+
139
+ /**
140
+ * Get a structured diff with hunks
141
+ */
142
+ export function getStructuredDiff(change) {
143
+ return structuredPatch(
144
+ change.path, change.path,
145
+ change.before, change.after,
146
+ "before", "after"
147
+ );
148
+ }
149
+
150
+ function countLines(text) {
151
+ if (!text) return 0;
152
+ return text.split("\n").length;
153
+ }
154
+
155
+ function sanitizeFilename(path) {
156
+ return path.replace(/[^a-zA-Z0-9.-]/g, "_").slice(-60);
157
+ }
package/src/lib/logger.js CHANGED
@@ -1,4 +1,17 @@
1
1
  import chalk from "chalk";
2
+ import { readFileSync } from "fs";
3
+ import { fileURLToPath } from "url";
4
+ import { dirname, join } from "path";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ let _version;
8
+ function getVersion() {
9
+ if (!_version) {
10
+ try { _version = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8")).version; }
11
+ catch { _version = "0.0.0"; }
12
+ }
13
+ return _version;
14
+ }
2
15
 
3
16
  export const log = {
4
17
  info(msg) {
@@ -36,7 +49,8 @@ export const log = {
36
49
  console.log(chalk.dim(prefix) + " " + item);
37
50
  });
38
51
  },
39
- banner(version = "0.1.0") {
52
+ banner(version) {
53
+ if (!version) version = getVersion();
40
54
  console.log();
41
55
  console.log(` ${chalk.red("🍒")} ${chalk.red.bold("Cheri")}`);
42
56
  console.log(` ${chalk.dim("AI-powered cloud IDE by HeySalad")}`);
@@ -0,0 +1,62 @@
1
+ // Rich markdown rendering for terminal output
2
+ import { Marked } from "marked";
3
+ import { markedTerminal } from "marked-terminal";
4
+ import chalk from "chalk";
5
+
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
+ /**
23
+ * Render markdown text to terminal-formatted string
24
+ */
25
+ export function renderMarkdown(text) {
26
+ if (!text) return "";
27
+ try {
28
+ return marked.parse(text).trim();
29
+ } catch {
30
+ return text;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Stream-friendly markdown rendering for partial text
36
+ * Returns the text as-is for streaming (full render at end)
37
+ */
38
+ export function streamText(text) {
39
+ return text;
40
+ }
41
+
42
+ /**
43
+ * Render a code block with language label
44
+ */
45
+ export function renderCodeBlock(code, language = "") {
46
+ const header = language ? chalk.dim(` ${language}`) + "\n" : "";
47
+ const lines = code.split("\n").map(l => chalk.cyan(" " + l)).join("\n");
48
+ return header + lines;
49
+ }
50
+
51
+ /**
52
+ * Render a diff with color coding
53
+ */
54
+ export function renderDiff(diffText) {
55
+ return diffText.split("\n").map(line => {
56
+ if (line.startsWith("+") && !line.startsWith("+++")) return chalk.green(line);
57
+ if (line.startsWith("-") && !line.startsWith("---")) return chalk.red(line);
58
+ if (line.startsWith("@@")) return chalk.cyan(line);
59
+ if (line.startsWith("diff")) return chalk.bold(line);
60
+ return chalk.dim(line);
61
+ }).join("\n");
62
+ }
@@ -0,0 +1,239 @@
1
+ // MCP (Model Context Protocol) client
2
+ // Connects to MCP servers via stdio transport, discovers tools, and calls them
3
+ import { spawn } from "child_process";
4
+ import { log } from "../logger.js";
5
+
6
+ const JSONRPC_VERSION = "2.0";
7
+ let requestId = 0;
8
+
9
+ /**
10
+ * MCP Server connection
11
+ */
12
+ export class McpServer {
13
+ constructor(name, command, args = [], env = {}) {
14
+ this.name = name;
15
+ this.command = command;
16
+ this.args = args;
17
+ this.env = { ...process.env, ...env };
18
+ this.process = null;
19
+ this.tools = [];
20
+ this.resources = [];
21
+ this.pendingRequests = new Map();
22
+ this._buffer = "";
23
+ }
24
+
25
+ async connect() {
26
+ return new Promise((resolve, reject) => {
27
+ this.process = spawn(this.command, this.args, {
28
+ env: this.env,
29
+ stdio: ["pipe", "pipe", "pipe"],
30
+ });
31
+
32
+ this.process.stdout.on("data", (data) => {
33
+ this._buffer += data.toString();
34
+ this._processBuffer();
35
+ });
36
+
37
+ this.process.stderr.on("data", (data) => {
38
+ log.dim(`[mcp:${this.name}] ${data.toString().trim()}`);
39
+ });
40
+
41
+ this.process.on("error", (err) => {
42
+ reject(new Error(`MCP server ${this.name} failed to start: ${err.message}`));
43
+ });
44
+
45
+ this.process.on("close", (code) => {
46
+ for (const [, { reject: r }] of this.pendingRequests) {
47
+ r(new Error(`MCP server ${this.name} exited with code ${code}`));
48
+ }
49
+ this.pendingRequests.clear();
50
+ });
51
+
52
+ // Initialize the connection
53
+ setTimeout(async () => {
54
+ try {
55
+ await this._initialize();
56
+ resolve();
57
+ } catch (err) {
58
+ reject(err);
59
+ }
60
+ }, 500);
61
+ });
62
+ }
63
+
64
+ async _initialize() {
65
+ const result = await this._request("initialize", {
66
+ protocolVersion: "2024-11-05",
67
+ capabilities: {},
68
+ clientInfo: { name: "cheri", version: "0.10.0" },
69
+ });
70
+
71
+ // Send initialized notification
72
+ this._notify("notifications/initialized", {});
73
+
74
+ // Discover tools
75
+ try {
76
+ const toolsResult = await this._request("tools/list", {});
77
+ this.tools = toolsResult.tools || [];
78
+ } catch {
79
+ this.tools = [];
80
+ }
81
+
82
+ // Discover resources
83
+ try {
84
+ const resourcesResult = await this._request("resources/list", {});
85
+ this.resources = resourcesResult.resources || [];
86
+ } catch {
87
+ this.resources = [];
88
+ }
89
+
90
+ return result;
91
+ }
92
+
93
+ async callTool(name, args = {}) {
94
+ return this._request("tools/call", { name, arguments: args });
95
+ }
96
+
97
+ async readResource(uri) {
98
+ return this._request("resources/read", { uri });
99
+ }
100
+
101
+ _request(method, params) {
102
+ return new Promise((resolve, reject) => {
103
+ const id = ++requestId;
104
+ const timeout = setTimeout(() => {
105
+ this.pendingRequests.delete(id);
106
+ reject(new Error(`MCP request ${method} timed out`));
107
+ }, 30000);
108
+
109
+ this.pendingRequests.set(id, {
110
+ resolve: (result) => { clearTimeout(timeout); resolve(result); },
111
+ reject: (err) => { clearTimeout(timeout); reject(err); },
112
+ });
113
+
114
+ const message = JSON.stringify({
115
+ jsonrpc: JSONRPC_VERSION,
116
+ id,
117
+ method,
118
+ params,
119
+ });
120
+
121
+ this.process.stdin.write(message + "\n");
122
+ });
123
+ }
124
+
125
+ _notify(method, params) {
126
+ const message = JSON.stringify({
127
+ jsonrpc: JSONRPC_VERSION,
128
+ method,
129
+ params,
130
+ });
131
+ this.process.stdin.write(message + "\n");
132
+ }
133
+
134
+ _processBuffer() {
135
+ const lines = this._buffer.split("\n");
136
+ this._buffer = lines.pop() || "";
137
+
138
+ for (const line of lines) {
139
+ if (!line.trim()) continue;
140
+ try {
141
+ const msg = JSON.parse(line);
142
+ if (msg.id && this.pendingRequests.has(msg.id)) {
143
+ const { resolve, reject } = this.pendingRequests.get(msg.id);
144
+ this.pendingRequests.delete(msg.id);
145
+ if (msg.error) {
146
+ reject(new Error(msg.error.message || "MCP error"));
147
+ } else {
148
+ resolve(msg.result);
149
+ }
150
+ }
151
+ } catch {}
152
+ }
153
+ }
154
+
155
+ getToolDefinitions() {
156
+ return this.tools.map(t => ({
157
+ name: `mcp_${this.name}_${t.name}`,
158
+ description: `[MCP:${this.name}] ${t.description || t.name}`,
159
+ parameters: t.inputSchema || { type: "object", properties: {} },
160
+ mcpServer: this.name,
161
+ mcpToolName: t.name,
162
+ }));
163
+ }
164
+
165
+ disconnect() {
166
+ if (this.process) {
167
+ try { this.process.kill(); } catch {}
168
+ this.process = null;
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * MCP Manager — manages multiple MCP server connections
175
+ */
176
+ export class McpManager {
177
+ constructor() {
178
+ this.servers = new Map();
179
+ }
180
+
181
+ async addServer(name, config) {
182
+ const server = new McpServer(
183
+ name,
184
+ config.command,
185
+ config.args || [],
186
+ config.env || {}
187
+ );
188
+
189
+ try {
190
+ await server.connect();
191
+ this.servers.set(name, server);
192
+ log.success(`MCP server '${name}' connected (${server.tools.length} tools)`);
193
+ return server;
194
+ } catch (err) {
195
+ log.warn(`MCP server '${name}' failed: ${err.message}`);
196
+ return null;
197
+ }
198
+ }
199
+
200
+ getAllToolDefinitions() {
201
+ const tools = [];
202
+ for (const server of this.servers.values()) {
203
+ tools.push(...server.getToolDefinitions());
204
+ }
205
+ return tools;
206
+ }
207
+
208
+ async callTool(fullName, args) {
209
+ // Parse mcp_{server}_{tool} format
210
+ const match = fullName.match(/^mcp_([^_]+)_(.+)$/);
211
+ if (!match) return { error: `Invalid MCP tool name: ${fullName}` };
212
+
213
+ const [, serverName, toolName] = match;
214
+ const server = this.servers.get(serverName);
215
+ if (!server) return { error: `MCP server '${serverName}' not connected` };
216
+
217
+ try {
218
+ const result = await server.callTool(toolName, args);
219
+ // Extract text content from MCP response
220
+ if (result.content) {
221
+ return { result: result.content.map(c => c.text || JSON.stringify(c)).join("\n") };
222
+ }
223
+ return result;
224
+ } catch (err) {
225
+ return { error: err.message };
226
+ }
227
+ }
228
+
229
+ isMcpTool(name) {
230
+ return name.startsWith("mcp_");
231
+ }
232
+
233
+ disconnectAll() {
234
+ for (const server of this.servers.values()) {
235
+ server.disconnect();
236
+ }
237
+ this.servers.clear();
238
+ }
239
+ }
@@ -0,0 +1,153 @@
1
+ // Multi-agent orchestration — spawn child agents for subtasks
2
+ import { log } from "./logger.js";
3
+
4
+ /**
5
+ * SubAgent — a lightweight agent that runs a focused subtask
6
+ * with its own message history but shares the parent's tools and API
7
+ */
8
+ export class SubAgent {
9
+ constructor(name, systemPrompt, options = {}) {
10
+ this.name = name;
11
+ this.systemPrompt = systemPrompt;
12
+ this.maxIterations = options.maxIterations || 10;
13
+ this.tools = options.tools || [];
14
+ this.messages = [{ role: "system", content: systemPrompt }];
15
+ this.status = "idle"; // idle, running, done, error
16
+ this.result = null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Agent orchestrator — manages parent + child agents
22
+ */
23
+ export class AgentOrchestrator {
24
+ constructor(chatFn, executeTool) {
25
+ this.chatFn = chatFn; // async function(messages, tools) => response
26
+ this.executeTool = executeTool; // async function(name, args) => result
27
+ this.agents = new Map();
28
+ this.parentId = "main";
29
+ }
30
+
31
+ /**
32
+ * Spawn a child agent for a subtask
33
+ */
34
+ createAgent(name, task, options = {}) {
35
+ const systemPrompt = `You are a sub-agent named "${name}" focused on a specific task.
36
+ Your task: ${task}
37
+
38
+ Complete this task using the available tools, then provide a concise summary of what you did and the results.
39
+ Be focused and efficient. Do not ask questions — make reasonable decisions.
40
+ Current working directory: ${process.cwd()}`;
41
+
42
+ const agent = new SubAgent(name, systemPrompt, {
43
+ maxIterations: options.maxIterations || 8,
44
+ tools: options.tools,
45
+ });
46
+
47
+ this.agents.set(name, agent);
48
+ return agent;
49
+ }
50
+
51
+ /**
52
+ * Run a child agent to completion
53
+ */
54
+ async runAgent(name, userMessage, allTools, parseSSEStream) {
55
+ const agent = this.agents.get(name);
56
+ if (!agent) throw new Error(`Agent '${name}' not found`);
57
+
58
+ agent.status = "running";
59
+ agent.messages.push({ role: "user", content: userMessage });
60
+
61
+ log.dim(` [${name}] Starting subtask...`);
62
+
63
+ try {
64
+ for (let i = 0; i < agent.maxIterations; i++) {
65
+ const response = await this.chatFn(agent.messages, allTools);
66
+
67
+ let fullText = "";
68
+ const toolCalls = {};
69
+
70
+ for await (const chunk of parseSSEStream(response)) {
71
+ const delta = chunk.choices?.[0]?.delta;
72
+ const finishReason = chunk.choices?.[0]?.finish_reason;
73
+
74
+ if (delta?.content) fullText += delta.content;
75
+
76
+ if (delta?.tool_calls) {
77
+ for (const tc of delta.tool_calls) {
78
+ const idx = tc.index;
79
+ if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
80
+ if (tc.id) toolCalls[idx].id = tc.id;
81
+ if (tc.function?.name) toolCalls[idx].name = tc.function.name;
82
+ if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
83
+ }
84
+ }
85
+
86
+ if (finishReason) break;
87
+ }
88
+
89
+ const toolCallList = Object.values(toolCalls);
90
+
91
+ if (toolCallList.length === 0) {
92
+ agent.messages.push({ role: "assistant", content: fullText });
93
+ agent.status = "done";
94
+ agent.result = fullText;
95
+ log.dim(` [${name}] Completed.`);
96
+ return fullText;
97
+ }
98
+
99
+ const assistantMsg = { role: "assistant", content: fullText || null };
100
+ assistantMsg.tool_calls = toolCallList.map(tc => ({
101
+ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments },
102
+ }));
103
+ agent.messages.push(assistantMsg);
104
+
105
+ // Execute tools (in parallel)
106
+ const toolPromises = toolCallList.map(async (tc) => {
107
+ let input = {};
108
+ try { input = JSON.parse(tc.arguments); } catch {}
109
+ log.dim(` [${name}] → ${tc.name}`);
110
+ const result = await this.executeTool(tc.name, input);
111
+ return { id: tc.id, result };
112
+ });
113
+
114
+ const toolResults = await Promise.all(toolPromises);
115
+
116
+ for (const { id, result } of toolResults) {
117
+ const resultStr = JSON.stringify(result);
118
+ const truncated = resultStr.length > 6000 ? resultStr.slice(0, 6000) + "...(truncated)" : resultStr;
119
+ agent.messages.push({ role: "tool", tool_call_id: id, content: truncated });
120
+ }
121
+ }
122
+
123
+ agent.status = "done";
124
+ agent.result = "Reached maximum iterations";
125
+ return agent.result;
126
+ } catch (err) {
127
+ agent.status = "error";
128
+ agent.result = err.message;
129
+ log.warn(` [${name}] Error: ${err.message}`);
130
+ return `Error: ${err.message}`;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Run multiple agents in parallel
136
+ */
137
+ async runParallel(tasks, allTools, parseSSEStream) {
138
+ const promises = tasks.map(({ name, task }) => {
139
+ this.createAgent(name, task);
140
+ return this.runAgent(name, task, allTools, parseSSEStream);
141
+ });
142
+
143
+ return Promise.all(promises);
144
+ }
145
+
146
+ getAgentStatus() {
147
+ const statuses = {};
148
+ for (const [name, agent] of this.agents) {
149
+ statuses[name] = { status: agent.status, result: agent.result?.slice(0, 200) };
150
+ }
151
+ return statuses;
152
+ }
153
+ }