@heysalad/cheri-cli 0.7.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heysalad/cheri-cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Cheri CLI - AI-powered cloud IDE by HeySalad. Like Claude Code, but for cloud workspaces.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,151 +1,72 @@
1
1
  import { apiClient } from "../lib/api-client.js";
2
2
  import { log } from "../lib/logger.js";
3
+ import { getToolDefinitions, executeTool as executeLocalTool, requiresConfirmation as toolRequiresConfirmation } from "../lib/tools/index.js";
4
+ import { generateSessionId, saveSession, loadSession, listSessions } from "../lib/sessions/index.js";
5
+ import { compactMessages, shouldCompact, estimateMessagesTokens } from "../lib/context.js";
6
+ import { loadHooks, runHooks } from "../lib/hooks/index.js";
7
+ import { loadPlugins, getSkillContext, getSlashCommand } from "../lib/plugins/index.js";
8
+ import { checkPermission, loadPermissions } from "../lib/permissions.js";
9
+ import { getMemoryContext } from "../lib/memory.js";
3
10
  import chalk from "chalk";
11
+ import readline from "readline";
4
12
 
5
- const SYSTEM_PROMPT = `You are Cheri Agent, an AI assistant for the Cheri cloud IDE platform. You help users manage cloud workspaces, memory, configuration, and their account. Use the provided tools to get real data — never guess or fabricate information. Be concise. After performing actions, briefly summarize what happened and the result.`;
13
+ const SYSTEM_PROMPT = `You are Cheri, an AI coding assistant by HeySalad. You are a powerful agentic coding tool that can read, write, edit, and search code, execute shell commands, and manage cloud workspaces.
6
14
 
7
- const TOOLS = [
8
- {
9
- type: "function",
10
- function: {
11
- name: "get_account_info",
12
- description: "Get the current user's account information",
13
- parameters: { type: "object", properties: {}, required: [] },
14
- },
15
- },
16
- {
17
- type: "function",
18
- function: {
19
- name: "list_workspaces",
20
- description: "List all cloud workspaces for the current user",
21
- parameters: { type: "object", properties: {}, required: [] },
22
- },
23
- },
24
- {
25
- type: "function",
26
- function: {
27
- name: "create_workspace",
28
- description: "Launch a new cloud workspace for a GitHub repository",
29
- parameters: {
30
- type: "object",
31
- properties: { repo: { type: "string", description: "GitHub repo in owner/name format" } },
32
- required: ["repo"],
33
- },
34
- },
35
- },
36
- {
37
- type: "function",
38
- function: {
39
- name: "stop_workspace",
40
- description: "Stop and delete a running workspace",
41
- parameters: {
42
- type: "object",
43
- properties: { id: { type: "string", description: "Workspace ID to stop" } },
44
- required: ["id"],
45
- },
46
- },
47
- },
48
- {
49
- type: "function",
50
- function: {
51
- name: "get_workspace_status",
52
- description: "Get the status of a specific workspace",
53
- parameters: {
54
- type: "object",
55
- properties: { id: { type: "string", description: "Workspace ID" } },
56
- required: ["id"],
57
- },
58
- },
59
- },
60
- {
61
- type: "function",
62
- function: {
63
- name: "get_memory",
64
- description: "Retrieve all stored memory entries",
65
- parameters: { type: "object", properties: {}, required: [] },
66
- },
67
- },
68
- {
69
- type: "function",
70
- function: {
71
- name: "add_memory",
72
- description: "Add a new memory entry for the user",
73
- parameters: {
74
- type: "object",
75
- properties: {
76
- content: { type: "string", description: "Memory content to store" },
77
- category: { type: "string", description: "Optional category (defaults to 'general')" },
78
- },
79
- required: ["content"],
80
- },
81
- },
82
- },
83
- {
84
- type: "function",
85
- function: {
86
- name: "clear_memory",
87
- description: "Clear all stored memory entries",
88
- parameters: { type: "object", properties: {}, required: [] },
89
- },
90
- },
91
- {
92
- type: "function",
93
- function: {
94
- name: "get_usage",
95
- description: "Get the user's API usage and rate limit statistics",
96
- parameters: { type: "object", properties: {}, required: [] },
97
- },
98
- },
99
- {
15
+ You have two categories of tools:
16
+ 1. LOCAL CODING TOOLS — read_file, write_file, edit_file, run_command, search_files, search_content, list_directory. Use these to work directly with the user's local codebase.
17
+ 2. CLOUD PLATFORM TOOLS — get_account_info, list_workspaces, create_workspace, stop_workspace, get_workspace_status, get_memory, add_memory, clear_memory, get_usage, get_config, set_config. Use these to manage the Cheri cloud platform.
18
+
19
+ Guidelines:
20
+ - Read files before editing them. Understand existing code before making changes.
21
+ - Use search_files and search_content to explore unfamiliar codebases.
22
+ - Use list_directory to understand project structure.
23
+ - When writing code, match existing style and patterns.
24
+ - For shell commands, prefer specific commands over broad ones.
25
+ - Be concise. Show what you did and the result.
26
+ - Never guess file contents — always read first.
27
+ - Current working directory: ${process.cwd()}`;
28
+
29
+ // Cloud platform tools
30
+ const CLOUD_TOOLS = [
31
+ { name: "get_account_info", description: "Get the current user's account information", parameters: { type: "object", properties: {}, required: [] } },
32
+ { name: "list_workspaces", description: "List all cloud workspaces for the current user", parameters: { type: "object", properties: {}, required: [] } },
33
+ { name: "create_workspace", description: "Launch a new cloud workspace for a GitHub repository", parameters: { type: "object", properties: { repo: { type: "string", description: "GitHub repo in owner/name format" } }, required: ["repo"] } },
34
+ { name: "stop_workspace", description: "Stop and delete a running workspace", parameters: { type: "object", properties: { id: { type: "string", description: "Workspace ID to stop" } }, required: ["id"] } },
35
+ { name: "get_workspace_status", description: "Get the status of a specific workspace", parameters: { type: "object", properties: { id: { type: "string", description: "Workspace ID" } }, required: ["id"] } },
36
+ { name: "get_memory", description: "Retrieve all stored memory entries", parameters: { type: "object", properties: {}, required: [] } },
37
+ { name: "add_memory", description: "Add a new memory entry for the user", parameters: { type: "object", properties: { content: { type: "string", description: "Memory content to store" }, category: { type: "string", description: "Optional category (defaults to 'general')" } }, required: ["content"] } },
38
+ { name: "clear_memory", description: "Clear all stored memory entries", parameters: { type: "object", properties: {}, required: [] } },
39
+ { name: "get_usage", description: "Get the user's API usage and rate limit statistics", parameters: { type: "object", properties: {}, required: [] } },
40
+ { name: "get_config", description: "Get a configuration value by key (dot notation supported)", parameters: { type: "object", properties: { key: { type: "string", description: "Config key, e.g. 'ai.provider'" } }, required: ["key"] } },
41
+ { name: "set_config", description: "Set a configuration value", parameters: { type: "object", properties: { key: { type: "string", description: "Config key" }, value: { type: "string", description: "Value to set" } }, required: ["key", "value"] } },
42
+ ];
43
+
44
+ const CLOUD_TOOL_NAMES = new Set(CLOUD_TOOLS.map((t) => t.name));
45
+
46
+ function buildTools() {
47
+ const localDefs = getToolDefinitions().map((t) => ({
100
48
  type: "function",
101
- function: {
102
- name: "get_config",
103
- description: "Get a configuration value by key (dot notation supported)",
104
- parameters: {
105
- type: "object",
106
- properties: { key: { type: "string", description: "Config key, e.g. 'ai.provider'" } },
107
- required: ["key"],
108
- },
109
- },
110
- },
111
- {
49
+ function: { name: t.name, description: t.description, parameters: t.parameters },
50
+ }));
51
+ const cloudDefs = CLOUD_TOOLS.map((t) => ({
112
52
  type: "function",
113
- function: {
114
- name: "set_config",
115
- description: "Set a configuration value",
116
- parameters: {
117
- type: "object",
118
- properties: {
119
- key: { type: "string", description: "Config key" },
120
- value: { type: "string", description: "Value to set" },
121
- },
122
- required: ["key", "value"],
123
- },
124
- },
125
- },
126
- ];
53
+ function: { name: t.name, description: t.description, parameters: t.parameters },
54
+ }));
55
+ return [...localDefs, ...cloudDefs];
56
+ }
127
57
 
128
- async function executeTool(name, args) {
58
+ async function executeCloudTool(name, args) {
129
59
  try {
130
60
  switch (name) {
131
- case "get_account_info":
132
- return await apiClient.getMe();
133
- case "list_workspaces":
134
- return await apiClient.listWorkspaces();
135
- case "create_workspace":
136
- return await apiClient.createWorkspace(args.repo);
137
- case "stop_workspace":
138
- return await apiClient.deleteWorkspace(args.id);
139
- case "get_workspace_status":
140
- return await apiClient.getWorkspaceStatus(args.id);
141
- case "get_memory":
142
- return await apiClient.getMemory();
143
- case "add_memory":
144
- return await apiClient.addMemory(args.content, args.category);
145
- case "clear_memory":
146
- return await apiClient.clearMemory();
147
- case "get_usage":
148
- return await apiClient.getUsage();
61
+ case "get_account_info": return await apiClient.getMe();
62
+ case "list_workspaces": return await apiClient.listWorkspaces();
63
+ case "create_workspace": return await apiClient.createWorkspace(args.repo);
64
+ case "stop_workspace": return await apiClient.deleteWorkspace(args.id);
65
+ case "get_workspace_status": return await apiClient.getWorkspaceStatus(args.id);
66
+ case "get_memory": return await apiClient.getMemory();
67
+ case "add_memory": return await apiClient.addMemory(args.content, args.category);
68
+ case "clear_memory": return await apiClient.clearMemory();
69
+ case "get_usage": return await apiClient.getUsage();
149
70
  case "get_config": {
150
71
  const { getConfigValue } = await import("../lib/config-store.js");
151
72
  return { key: args.key, value: getConfigValue(args.key) };
@@ -155,15 +76,55 @@ async function executeTool(name, args) {
155
76
  setConfigValue(args.key, args.value);
156
77
  return { key: args.key, value: args.value, status: "updated" };
157
78
  }
158
- default:
159
- return { error: `Unknown tool: ${name}` };
79
+ default: return { error: `Unknown cloud tool: ${name}` };
160
80
  }
161
81
  } catch (err) {
162
82
  return { error: err.message };
163
83
  }
164
84
  }
165
85
 
166
- // Parse SSE stream from the cloud proxy
86
+ async function confirmAction(toolName, input) {
87
+ const desc = toolName === "run_command" ? input.command : JSON.stringify(input);
88
+ return new Promise((resolve) => {
89
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
90
+ rl.question(chalk.yellow(` Allow ${chalk.cyan(toolName)}: ${chalk.dim(truncate(desc, 60))}? [Y/n] `), (answer) => {
91
+ rl.close();
92
+ resolve(answer.trim().toLowerCase() !== "n");
93
+ });
94
+ });
95
+ }
96
+
97
+ async function executeTool(name, args) {
98
+ // Check permission rules
99
+ const permission = checkPermission(name, args);
100
+ if (permission === "deny") {
101
+ return { error: `Tool ${name} is denied by permission rules` };
102
+ }
103
+
104
+ // Run PreToolUse hooks
105
+ const hookResult = await runHooks("PreToolUse", { toolName: name, input: args });
106
+ if (!hookResult.allowed) {
107
+ return { error: hookResult.reason || `Blocked by hook` };
108
+ }
109
+
110
+ // Cloud tools
111
+ if (CLOUD_TOOL_NAMES.has(name)) {
112
+ const result = await executeCloudTool(name, args);
113
+ await runHooks("PostToolUse", { toolName: name, input: args, result });
114
+ return result;
115
+ }
116
+
117
+ // Local tools — check if confirmation needed
118
+ if (permission === "ask" || (permission !== "allow" && toolRequiresConfirmation(name))) {
119
+ const allowed = await confirmAction(name, args);
120
+ if (!allowed) return { error: "User denied execution" };
121
+ }
122
+
123
+ const result = await executeLocalTool(name, args);
124
+ await runHooks("PostToolUse", { toolName: name, input: args, result });
125
+ return result;
126
+ }
127
+
167
128
  async function* parseSSEStream(response) {
168
129
  const reader = response.body.getReader();
169
130
  const decoder = new TextDecoder();
@@ -182,9 +143,7 @@ async function* parseSSEStream(response) {
182
143
  if (line.startsWith("data: ")) {
183
144
  const data = line.slice(6).trim();
184
145
  if (data === "[DONE]") return;
185
- try {
186
- yield JSON.parse(data);
187
- } catch {}
146
+ try { yield JSON.parse(data); } catch {}
188
147
  }
189
148
  }
190
149
  }
@@ -193,16 +152,108 @@ async function* parseSSEStream(response) {
193
152
  }
194
153
  }
195
154
 
196
- export async function runAgent(userRequest) {
197
- const messages = [
198
- { role: "system", content: SYSTEM_PROMPT },
199
- { role: "user", content: userRequest },
200
- ];
155
+ // Active session state
156
+ let currentSession = null;
157
+
158
+ export async function runAgent(userRequest, options = {}) {
159
+ // Initialize systems
160
+ loadHooks();
161
+ loadPermissions();
162
+ const plugins = loadPlugins();
163
+ const memoryContext = getMemoryContext();
164
+ const skillContext = getSkillContext(plugins.skills, userRequest);
201
165
 
202
- const MAX_ITERATIONS = 10;
166
+ // Check for slash commands
167
+ if (userRequest.startsWith("/")) {
168
+ const cmdName = userRequest.slice(1).split(/\s+/)[0];
169
+ const cmdArgs = userRequest.slice(1 + cmdName.length).trim();
170
+
171
+ // Built-in slash commands
172
+ if (cmdName === "sessions" || cmdName === "history") {
173
+ const sessions = listSessions();
174
+ if (sessions.length === 0) { log.info("No saved sessions."); return; }
175
+ log.brand("Sessions");
176
+ sessions.slice(0, 10).forEach((s) => {
177
+ console.log(` ${chalk.dim(s.id.slice(0, 12))} ${s.title} ${chalk.dim(`(${s.messageCount} msgs)`)}`);
178
+ });
179
+ return;
180
+ }
181
+ if (cmdName === "resume") {
182
+ const sessions = listSessions();
183
+ if (sessions.length === 0) { log.info("No sessions to resume."); return; }
184
+ const target = cmdArgs ? sessions.find((s) => s.id.includes(cmdArgs)) : sessions[0];
185
+ if (!target) { log.error("Session not found."); return; }
186
+ const data = loadSession(target.id);
187
+ if (data) {
188
+ currentSession = { id: target.id, messages: data.messages, title: data.title };
189
+ log.success(`Resumed session: ${target.title}`);
190
+ log.dim(`${data.messages.length} messages loaded`);
191
+ }
192
+ return;
193
+ }
194
+ if (cmdName === "compact") {
195
+ if (!currentSession) { log.info("No active session to compact."); return; }
196
+ const before = estimateMessagesTokens(currentSession.messages);
197
+ const { messages } = compactMessages(currentSession.messages, 50000);
198
+ currentSession.messages = messages;
199
+ const after = estimateMessagesTokens(messages);
200
+ log.success(`Compacted: ~${Math.round(before / 1000)}k → ~${Math.round(after / 1000)}k tokens`);
201
+ return;
202
+ }
203
+ if (cmdName === "new") {
204
+ currentSession = null;
205
+ log.success("Started new session.");
206
+ return;
207
+ }
208
+
209
+ // Plugin slash commands
210
+ const cmd = getSlashCommand(plugins.commands, cmdName);
211
+ if (cmd) {
212
+ log.info(`Running /${cmdName}`);
213
+ userRequest = cmd.content.replace(/\$ARGUMENTS/g, cmdArgs).replace(/\$1/g, cmdArgs);
214
+ }
215
+ }
216
+
217
+ // Run SessionStart hooks on first message
218
+ if (!currentSession) {
219
+ await runHooks("SessionStart", { cwd: process.cwd() });
220
+ }
221
+
222
+ // Build or continue session
223
+ if (!currentSession) {
224
+ currentSession = {
225
+ id: generateSessionId(),
226
+ messages: [{ role: "system", content: SYSTEM_PROMPT + memoryContext + skillContext }],
227
+ title: userRequest.slice(0, 60),
228
+ createdAt: Date.now(),
229
+ };
230
+ } else {
231
+ // Inject fresh skill context if relevant
232
+ if (skillContext) {
233
+ const sysMsg = currentSession.messages.find((m) => m.role === "system");
234
+ if (sysMsg && !sysMsg.content.includes(skillContext)) {
235
+ sysMsg.content += skillContext;
236
+ }
237
+ }
238
+ }
239
+
240
+ currentSession.messages.push({ role: "user", content: userRequest });
241
+ currentSession.updatedAt = Date.now();
242
+
243
+ // Auto-compact if conversation is too long
244
+ if (shouldCompact(currentSession.messages)) {
245
+ const { messages, compacted } = compactMessages(currentSession.messages);
246
+ if (compacted) {
247
+ currentSession.messages = messages;
248
+ log.dim("Context auto-compacted to fit window.");
249
+ }
250
+ }
251
+
252
+ const ALL_TOOLS = buildTools();
253
+ const MAX_ITERATIONS = 15;
203
254
 
204
255
  for (let i = 0; i < MAX_ITERATIONS; i++) {
205
- const response = await apiClient.chatStream(messages, TOOLS);
256
+ const response = await apiClient.chatStream(currentSession.messages, ALL_TOOLS);
206
257
 
207
258
  let fullText = "";
208
259
  const toolCalls = {};
@@ -219,9 +270,7 @@ export async function runAgent(userRequest) {
219
270
  if (delta?.tool_calls) {
220
271
  for (const tc of delta.tool_calls) {
221
272
  const idx = tc.index;
222
- if (!toolCalls[idx]) {
223
- toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
224
- }
273
+ if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
225
274
  if (tc.id) toolCalls[idx].id = tc.id;
226
275
  if (tc.function?.name) toolCalls[idx].name = tc.function.name;
227
276
  if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
@@ -233,29 +282,29 @@ export async function runAgent(userRequest) {
233
282
 
234
283
  const toolCallList = Object.values(toolCalls);
235
284
 
236
- // No tool calls — final text response, done
237
285
  if (toolCallList.length === 0) {
238
286
  if (fullText) process.stdout.write("\n");
287
+ // Save assistant response to session
288
+ currentSession.messages.push({ role: "assistant", content: fullText });
289
+ saveSession(currentSession.id, currentSession);
239
290
  return;
240
291
  }
241
292
 
242
293
  if (fullText) process.stdout.write("\n");
243
294
 
244
- // Build assistant message with tool calls
245
295
  const assistantMsg = { role: "assistant", content: fullText || null };
246
296
  assistantMsg.tool_calls = toolCallList.map((tc) => ({
247
- id: tc.id,
248
- type: "function",
249
- function: { name: tc.name, arguments: tc.arguments },
297
+ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments },
250
298
  }));
251
- messages.push(assistantMsg);
299
+ currentSession.messages.push(assistantMsg);
252
300
 
253
- // Execute each tool and add results
254
301
  for (const tc of toolCallList) {
255
302
  let input = {};
256
303
  try { input = JSON.parse(tc.arguments); } catch {}
257
304
 
258
- log.info(`Calling ${chalk.cyan(tc.name)}${Object.keys(input).length ? chalk.dim(" " + JSON.stringify(input)) : ""}`);
305
+ const isLocal = !CLOUD_TOOL_NAMES.has(tc.name);
306
+ const prefix = isLocal ? chalk.magenta("local") : chalk.blue("cloud");
307
+ log.info(`${prefix} ${chalk.cyan(tc.name)}${Object.keys(input).length ? chalk.dim(" " + truncate(JSON.stringify(input), 80)) : ""}`);
259
308
 
260
309
  const result = await executeTool(tc.name, input);
261
310
 
@@ -265,28 +314,32 @@ export async function runAgent(userRequest) {
265
314
  log.success(tc.name);
266
315
  }
267
316
 
268
- messages.push({
269
- role: "tool",
270
- tool_call_id: tc.id,
271
- content: JSON.stringify(result),
272
- });
317
+ const resultStr = JSON.stringify(result);
318
+ const truncatedResult = resultStr.length > 8000 ? resultStr.slice(0, 8000) + "...(truncated)" : resultStr;
319
+
320
+ currentSession.messages.push({ role: "tool", tool_call_id: tc.id, content: truncatedResult });
273
321
  }
322
+
323
+ // Save session after each tool round
324
+ saveSession(currentSession.id, currentSession);
274
325
  }
275
326
 
276
- log.warn("Agent reached maximum iterations (10). Stopping.");
327
+ log.warn("Agent reached maximum iterations (15). Stopping.");
328
+ await runHooks("Stop", { reason: "max_iterations" });
329
+ }
330
+
331
+ function truncate(str, max) {
332
+ return str.length > max ? str.slice(0, max) + "..." : str;
277
333
  }
278
334
 
279
335
  export function registerAgentCommand(program) {
280
336
  program
281
337
  .command("agent")
282
338
  .argument("<request...>")
283
- .description("AI agent — natural language command interface")
339
+ .description("AI coding agent — natural language command interface")
284
340
  .action(async (requestParts) => {
285
341
  const request = requestParts.join(" ");
286
- try {
287
- await runAgent(request);
288
- } catch (err) {
289
- log.error(err.message);
290
- }
342
+ try { await runAgent(request); }
343
+ catch (err) { log.error(err.message); }
291
344
  });
292
345
  }
@@ -0,0 +1,56 @@
1
+ // Simple token estimation (~4 chars per token for English)
2
+ function estimateTokens(text) {
3
+ return Math.ceil((text || "").length / 4);
4
+ }
5
+
6
+ export function estimateMessagesTokens(messages) {
7
+ return messages.reduce((sum, m) => {
8
+ const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content || "");
9
+ return sum + estimateTokens(content) + 4; // 4 tokens overhead per message
10
+ }, 0);
11
+ }
12
+
13
+ // Compress conversation by summarizing old tool results and trimming history
14
+ export function compactMessages(messages, maxTokens = 100000) {
15
+ const currentTokens = estimateMessagesTokens(messages);
16
+ if (currentTokens <= maxTokens) return { messages, compacted: false };
17
+
18
+ const compacted = [];
19
+ const systemMsg = messages.find((m) => m.role === "system");
20
+ if (systemMsg) compacted.push(systemMsg);
21
+
22
+ // Keep recent messages (last 20), compress older ones
23
+ const nonSystem = messages.filter((m) => m.role !== "system");
24
+ const keepRecent = 20;
25
+ const old = nonSystem.slice(0, -keepRecent);
26
+ const recent = nonSystem.slice(-keepRecent);
27
+
28
+ if (old.length > 0) {
29
+ // Build a summary of old messages
30
+ const userMessages = old.filter((m) => m.role === "user" && typeof m.content === "string");
31
+ const toolResults = old.filter((m) => m.role === "tool");
32
+
33
+ let summary = "[Conversation history compacted]\n";
34
+ summary += `Previous turns: ${old.length} messages\n`;
35
+
36
+ if (userMessages.length > 0) {
37
+ summary += "Topics discussed:\n";
38
+ userMessages.forEach((m) => {
39
+ summary += `- ${m.content.slice(0, 100)}\n`;
40
+ });
41
+ }
42
+
43
+ summary += `Tool calls made: ${toolResults.length}\n`;
44
+
45
+ compacted.push({ role: "user", content: summary });
46
+ compacted.push({ role: "assistant", content: "Understood. I have context from the previous conversation. Let's continue." });
47
+ }
48
+
49
+ compacted.push(...recent);
50
+ return { messages: compacted, compacted: true };
51
+ }
52
+
53
+ // Check if we should auto-compact
54
+ export function shouldCompact(messages, threshold = 80000) {
55
+ return estimateMessagesTokens(messages) > threshold;
56
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { execSync } from "child_process";
4
+ import { log } from "../logger.js";
5
+
6
+ // Hook events: PreToolUse, PostToolUse, Stop, SessionStart, SessionEnd
7
+ // Hooks are loaded from .cheri/hooks.json in the project directory
8
+
9
+ const HOOKS_FILE = ".cheri/hooks.json";
10
+
11
+ let loadedHooks = null;
12
+
13
+ export function loadHooks(projectDir = process.cwd()) {
14
+ const hooksPath = join(projectDir, HOOKS_FILE);
15
+ if (!existsSync(hooksPath)) {
16
+ loadedHooks = {};
17
+ return loadedHooks;
18
+ }
19
+ try {
20
+ loadedHooks = JSON.parse(readFileSync(hooksPath, "utf-8"));
21
+ } catch {
22
+ loadedHooks = {};
23
+ }
24
+ return loadedHooks;
25
+ }
26
+
27
+ export function getHooks(event) {
28
+ if (!loadedHooks) loadHooks();
29
+ return loadedHooks[event] || [];
30
+ }
31
+
32
+ // Run hooks for an event. Returns { allowed: bool, reason?: string }
33
+ export async function runHooks(event, context = {}) {
34
+ const hooks = getHooks(event);
35
+ if (hooks.length === 0) return { allowed: true };
36
+
37
+ for (const hook of hooks) {
38
+ // Check matcher
39
+ if (hook.matcher && context.toolName) {
40
+ const matchers = hook.matcher.split("|");
41
+ if (!matchers.some((m) => context.toolName.match(new RegExp(m)))) {
42
+ continue;
43
+ }
44
+ }
45
+
46
+ for (const h of hook.hooks || [hook]) {
47
+ try {
48
+ if (h.type === "command") {
49
+ const input = JSON.stringify(context);
50
+ const timeout = (h.timeout || 10) * 1000;
51
+ const result = execSync(h.command, {
52
+ input,
53
+ encoding: "utf-8",
54
+ timeout,
55
+ stdio: ["pipe", "pipe", "pipe"],
56
+ cwd: process.cwd(),
57
+ });
58
+
59
+ try {
60
+ const output = JSON.parse(result.trim());
61
+ if (output.decision === "block") {
62
+ return { allowed: false, reason: output.reason || "Blocked by hook" };
63
+ }
64
+ } catch {
65
+ // Non-JSON output is fine, treat as allow
66
+ }
67
+ }
68
+ } catch (err) {
69
+ if (err.status === 2) {
70
+ // Exit code 2 = block
71
+ return { allowed: false, reason: err.stderr || "Blocked by hook" };
72
+ }
73
+ // Exit code 1 = warn but allow
74
+ if (err.status === 1 && err.stderr) {
75
+ log.warn(`Hook: ${err.stderr.trim()}`);
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ return { allowed: true };
82
+ }
@@ -0,0 +1,73 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+
5
+ // Memory scopes:
6
+ // - user: ~/.cheri/memory.json (global, follows user)
7
+ // - project: .cheri/memory.json (per-project, in git)
8
+ // - local: .cheri/memory.local.json (per-project, gitignored)
9
+
10
+ const USER_MEMORY_PATH = join(homedir(), ".cheri", "memory.json");
11
+ const PROJECT_MEMORY_FILE = ".cheri/memory.json";
12
+ const LOCAL_MEMORY_FILE = ".cheri/memory.local.json";
13
+
14
+ function loadMemoryFile(filePath) {
15
+ if (!existsSync(filePath)) return [];
16
+ try {
17
+ return JSON.parse(readFileSync(filePath, "utf-8"));
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ function saveMemoryFile(filePath, entries) {
24
+ const dir = join(filePath, "..");
25
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
26
+ writeFileSync(filePath, JSON.stringify(entries, null, 2), "utf-8");
27
+ }
28
+
29
+ export function getMemory(scope = "all", projectDir = process.cwd()) {
30
+ const memories = {};
31
+
32
+ if (scope === "all" || scope === "user") {
33
+ memories.user = loadMemoryFile(USER_MEMORY_PATH);
34
+ }
35
+ if (scope === "all" || scope === "project") {
36
+ memories.project = loadMemoryFile(join(projectDir, PROJECT_MEMORY_FILE));
37
+ }
38
+ if (scope === "all" || scope === "local") {
39
+ memories.local = loadMemoryFile(join(projectDir, LOCAL_MEMORY_FILE));
40
+ }
41
+
42
+ return memories;
43
+ }
44
+
45
+ export function addMemory(content, scope = "project", category = "general", projectDir = process.cwd()) {
46
+ const filePath = scope === "user" ? USER_MEMORY_PATH :
47
+ scope === "local" ? join(projectDir, LOCAL_MEMORY_FILE) :
48
+ join(projectDir, PROJECT_MEMORY_FILE);
49
+
50
+ const entries = loadMemoryFile(filePath);
51
+ entries.push({ content, category, scope, createdAt: new Date().toISOString() });
52
+ saveMemoryFile(filePath, entries);
53
+ return entries.length;
54
+ }
55
+
56
+ export function clearMemory(scope = "all", projectDir = process.cwd()) {
57
+ if (scope === "all" || scope === "user") saveMemoryFile(USER_MEMORY_PATH, []);
58
+ if (scope === "all" || scope === "project") saveMemoryFile(join(projectDir, PROJECT_MEMORY_FILE), []);
59
+ if (scope === "all" || scope === "local") saveMemoryFile(join(projectDir, LOCAL_MEMORY_FILE), []);
60
+ }
61
+
62
+ // Build memory context string for system prompt
63
+ export function getMemoryContext(projectDir = process.cwd()) {
64
+ const all = getMemory("all", projectDir);
65
+ const entries = [...(all.user || []), ...(all.project || []), ...(all.local || [])];
66
+ if (entries.length === 0) return "";
67
+
68
+ let context = "\n\n## Memory\nThe following are remembered preferences and context:\n";
69
+ for (const entry of entries) {
70
+ context += `- [${entry.scope}/${entry.category}] ${entry.content}\n`;
71
+ }
72
+ return context;
73
+ }
@@ -0,0 +1,69 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ // Permission rules loaded from .cheri/permissions.json
5
+ // Format: { "allow": ["read_file", "list_directory"], "ask": ["run_command", "write_file"], "deny": ["clear_memory"] }
6
+ // Bash patterns: "run_command(npm:*)" allows npm commands
7
+
8
+ const PERMISSIONS_FILE = ".cheri/permissions.json";
9
+ let rules = null;
10
+
11
+ export function loadPermissions(projectDir = process.cwd()) {
12
+ const filePath = join(projectDir, PERMISSIONS_FILE);
13
+ if (!existsSync(filePath)) {
14
+ rules = { allow: [], ask: [], deny: [] };
15
+ return rules;
16
+ }
17
+ try {
18
+ rules = JSON.parse(readFileSync(filePath, "utf-8"));
19
+ } catch {
20
+ rules = { allow: [], ask: [], deny: [] };
21
+ }
22
+ return rules;
23
+ }
24
+
25
+ export function checkPermission(toolName, input = {}) {
26
+ if (!rules) loadPermissions();
27
+
28
+ // Check deny first
29
+ for (const pattern of rules.deny || []) {
30
+ if (matchesPattern(toolName, input, pattern)) {
31
+ return "deny";
32
+ }
33
+ }
34
+
35
+ // Check allow
36
+ for (const pattern of rules.allow || []) {
37
+ if (matchesPattern(toolName, input, pattern)) {
38
+ return "allow";
39
+ }
40
+ }
41
+
42
+ // Check ask
43
+ for (const pattern of rules.ask || []) {
44
+ if (matchesPattern(toolName, input, pattern)) {
45
+ return "ask";
46
+ }
47
+ }
48
+
49
+ // Default: allow for read-only tools, ask for others
50
+ const readOnlyTools = ["read_file", "list_directory", "search_files", "search_content", "get_account_info", "list_workspaces", "get_memory", "get_usage", "get_config", "get_workspace_status"];
51
+ return readOnlyTools.includes(toolName) ? "allow" : "ask";
52
+ }
53
+
54
+ function matchesPattern(toolName, input, pattern) {
55
+ // Simple match: "read_file"
56
+ if (pattern === toolName) return true;
57
+
58
+ // Pattern match: "run_command(npm:*)"
59
+ const match = pattern.match(/^(\w+)\((.+)\)$/);
60
+ if (match && match[1] === toolName) {
61
+ const argPattern = match[2];
62
+ const command = input.command || "";
63
+ // Convert glob to regex
64
+ const regex = new RegExp("^" + argPattern.replace(/\*/g, ".*").replace(/:/g, "\\s*") + "$");
65
+ return regex.test(command);
66
+ }
67
+
68
+ return false;
69
+ }
@@ -0,0 +1,138 @@
1
+ import { existsSync, readFileSync, readdirSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { log } from "../logger.js";
4
+
5
+ const PLUGIN_DIR = ".cheri";
6
+ const SKILLS_DIR = "skills";
7
+ const COMMANDS_DIR = "commands";
8
+ const AGENTS_DIR = "agents";
9
+
10
+ // Load all plugins from .cheri/ directory
11
+ export function loadPlugins(projectDir = process.cwd()) {
12
+ const cheriDir = join(projectDir, PLUGIN_DIR);
13
+ if (!existsSync(cheriDir)) return { skills: [], commands: [], agents: [] };
14
+
15
+ return {
16
+ skills: loadSkills(cheriDir),
17
+ commands: loadCommands(cheriDir),
18
+ agents: loadAgents(cheriDir),
19
+ };
20
+ }
21
+
22
+ // Load skills from .cheri/skills/
23
+ function loadSkills(cheriDir) {
24
+ const skillsDir = join(cheriDir, SKILLS_DIR);
25
+ if (!existsSync(skillsDir)) return [];
26
+
27
+ const skills = [];
28
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
29
+ if (entry.isDirectory()) {
30
+ const skillFile = join(skillsDir, entry.name, "SKILL.md");
31
+ if (existsSync(skillFile)) {
32
+ const parsed = parseMarkdownWithFrontmatter(readFileSync(skillFile, "utf-8"));
33
+ skills.push({
34
+ name: parsed.frontmatter.name || entry.name,
35
+ description: parsed.frontmatter.description || "",
36
+ trigger: parsed.frontmatter.trigger || "",
37
+ content: parsed.body,
38
+ dir: join(skillsDir, entry.name),
39
+ });
40
+ }
41
+ }
42
+ }
43
+ return skills;
44
+ }
45
+
46
+ // Load slash commands from .cheri/commands/
47
+ function loadCommands(cheriDir) {
48
+ const commandsDir = join(cheriDir, COMMANDS_DIR);
49
+ if (!existsSync(commandsDir)) return [];
50
+
51
+ const commands = [];
52
+ for (const file of readdirSync(commandsDir)) {
53
+ if (file.endsWith(".md")) {
54
+ const content = readFileSync(join(commandsDir, file), "utf-8");
55
+ const parsed = parseMarkdownWithFrontmatter(content);
56
+ commands.push({
57
+ name: file.replace(".md", ""),
58
+ description: parsed.frontmatter.description || "",
59
+ allowedTools: parsed.frontmatter["allowed-tools"] || "",
60
+ content: parsed.body,
61
+ });
62
+ }
63
+ }
64
+ return commands;
65
+ }
66
+
67
+ // Load agent definitions from .cheri/agents/
68
+ function loadAgents(cheriDir) {
69
+ const agentsDir = join(cheriDir, AGENTS_DIR);
70
+ if (!existsSync(agentsDir)) return [];
71
+
72
+ const agents = [];
73
+ for (const file of readdirSync(agentsDir)) {
74
+ if (file.endsWith(".md")) {
75
+ const content = readFileSync(join(agentsDir, file), "utf-8");
76
+ const parsed = parseMarkdownWithFrontmatter(content);
77
+ agents.push({
78
+ name: parsed.frontmatter.name || file.replace(".md", ""),
79
+ description: parsed.frontmatter.description || "",
80
+ model: parsed.frontmatter.model || "inherit",
81
+ tools: parsed.frontmatter.tools || [],
82
+ systemPrompt: parsed.body,
83
+ });
84
+ }
85
+ }
86
+ return agents;
87
+ }
88
+
89
+ // Parse markdown with YAML frontmatter (--- delimited)
90
+ function parseMarkdownWithFrontmatter(content) {
91
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
92
+ if (!match) return { frontmatter: {}, body: content };
93
+
94
+ const frontmatter = {};
95
+ for (const line of match[1].split("\n")) {
96
+ const colonIdx = line.indexOf(":");
97
+ if (colonIdx > 0) {
98
+ const key = line.slice(0, colonIdx).trim();
99
+ let value = line.slice(colonIdx + 1).trim();
100
+ // Parse arrays
101
+ if (value.startsWith("[") && value.endsWith("]")) {
102
+ try { value = JSON.parse(value); } catch {}
103
+ }
104
+ frontmatter[key] = value;
105
+ }
106
+ }
107
+
108
+ return { frontmatter, body: match[2].trim() };
109
+ }
110
+
111
+ // Get extra system prompt context from skills that match the user's request
112
+ export function getSkillContext(skills, userMessage) {
113
+ const matched = [];
114
+ const lowerMsg = userMessage.toLowerCase();
115
+
116
+ for (const skill of skills) {
117
+ // Match by trigger keyword or description
118
+ const triggers = skill.trigger ? skill.trigger.split(",").map((t) => t.trim().toLowerCase()) : [];
119
+ const descWords = skill.description.toLowerCase().split(/\s+/);
120
+ const nameMatch = lowerMsg.includes(skill.name.toLowerCase());
121
+ const triggerMatch = triggers.some((t) => lowerMsg.includes(t));
122
+ const descMatch = descWords.filter((w) => w.length > 4).some((w) => lowerMsg.includes(w));
123
+
124
+ if (nameMatch || triggerMatch || descMatch) {
125
+ matched.push(skill);
126
+ }
127
+ }
128
+
129
+ if (matched.length === 0) return "";
130
+
131
+ return "\n\n## Active Skills\n" +
132
+ matched.map((s) => `### ${s.name}\n${s.content}`).join("\n\n");
133
+ }
134
+
135
+ // Execute a slash command
136
+ export function getSlashCommand(commands, name) {
137
+ return commands.find((c) => c.name === name);
138
+ }
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+
5
+ const SESSIONS_DIR = join(homedir(), ".cheri", "sessions");
6
+
7
+ function ensureDir() {
8
+ if (!existsSync(SESSIONS_DIR)) {
9
+ mkdirSync(SESSIONS_DIR, { recursive: true });
10
+ }
11
+ }
12
+
13
+ export function generateSessionId() {
14
+ return `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
15
+ }
16
+
17
+ export function saveSession(sessionId, data) {
18
+ ensureDir();
19
+ const filePath = join(SESSIONS_DIR, `${sessionId}.json`);
20
+ writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
21
+ }
22
+
23
+ export function loadSession(sessionId) {
24
+ const filePath = join(SESSIONS_DIR, `${sessionId}.json`);
25
+ if (!existsSync(filePath)) return null;
26
+ return JSON.parse(readFileSync(filePath, "utf-8"));
27
+ }
28
+
29
+ export function listSessions() {
30
+ ensureDir();
31
+ const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
32
+ return files.map((f) => {
33
+ try {
34
+ const data = JSON.parse(readFileSync(join(SESSIONS_DIR, f), "utf-8"));
35
+ return {
36
+ id: f.replace(".json", ""),
37
+ title: data.title || data.messages?.[1]?.content?.slice(0, 60) || "Untitled",
38
+ messageCount: data.messages?.length || 0,
39
+ createdAt: data.createdAt,
40
+ updatedAt: data.updatedAt,
41
+ };
42
+ } catch {
43
+ return { id: f.replace(".json", ""), title: "Corrupted", messageCount: 0 };
44
+ }
45
+ }).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
46
+ }
47
+
48
+ export function deleteSession(sessionId) {
49
+ const filePath = join(SESSIONS_DIR, `${sessionId}.json`);
50
+ if (existsSync(filePath)) {
51
+ const { unlinkSync } = require("fs");
52
+ unlinkSync(filePath);
53
+ return true;
54
+ }
55
+ return false;
56
+ }
@@ -0,0 +1,34 @@
1
+ import { execSync } from "child_process";
2
+
3
+ export const runCommand = {
4
+ name: "run_command",
5
+ description: "Execute a shell command and return its output. Use for running builds, tests, git commands, installing packages, etc.",
6
+ parameters: {
7
+ type: "object",
8
+ properties: {
9
+ command: { type: "string", description: "The shell command to execute" },
10
+ cwd: { type: "string", description: "Working directory (optional, defaults to current directory)" },
11
+ },
12
+ required: ["command"],
13
+ },
14
+ requiresConfirmation: true,
15
+ handler: async ({ command, cwd }) => {
16
+ try {
17
+ const output = execSync(command, {
18
+ cwd: cwd || process.cwd(),
19
+ encoding: "utf-8",
20
+ timeout: 120_000,
21
+ maxBuffer: 1024 * 1024 * 10,
22
+ stdio: ["pipe", "pipe", "pipe"],
23
+ });
24
+ return { command, exitCode: 0, stdout: output, stderr: "" };
25
+ } catch (err) {
26
+ return {
27
+ command,
28
+ exitCode: err.status ?? 1,
29
+ stdout: err.stdout || "",
30
+ stderr: err.stderr || err.message,
31
+ };
32
+ }
33
+ },
34
+ };
@@ -0,0 +1,72 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { dirname, resolve } from "path";
3
+
4
+ export const readFile = {
5
+ name: "read_file",
6
+ description: "Read the contents of a file at the given path. Returns the file contents as a string.",
7
+ parameters: {
8
+ type: "object",
9
+ properties: {
10
+ path: { type: "string", description: "Absolute or relative file path to read" },
11
+ },
12
+ required: ["path"],
13
+ },
14
+ handler: async ({ path }) => {
15
+ const resolved = resolve(path);
16
+ if (!existsSync(resolved)) {
17
+ return { error: `File not found: ${resolved}` };
18
+ }
19
+ const content = readFileSync(resolved, "utf-8");
20
+ return { path: resolved, content, lines: content.split("\n").length };
21
+ },
22
+ };
23
+
24
+ export const writeFile = {
25
+ name: "write_file",
26
+ description: "Create or overwrite a file with the given content. Creates parent directories if needed.",
27
+ parameters: {
28
+ type: "object",
29
+ properties: {
30
+ path: { type: "string", description: "Absolute or relative file path to write" },
31
+ content: { type: "string", description: "The content to write to the file" },
32
+ },
33
+ required: ["path", "content"],
34
+ },
35
+ handler: async ({ path, content }) => {
36
+ const resolved = resolve(path);
37
+ const dir = dirname(resolved);
38
+ if (!existsSync(dir)) {
39
+ mkdirSync(dir, { recursive: true });
40
+ }
41
+ writeFileSync(resolved, content, "utf-8");
42
+ return { path: resolved, bytesWritten: Buffer.byteLength(content, "utf-8") };
43
+ },
44
+ };
45
+
46
+ export const editFile = {
47
+ name: "edit_file",
48
+ description: "Edit a file by replacing an exact string match with new content. The old_string must match exactly (including whitespace and indentation).",
49
+ parameters: {
50
+ type: "object",
51
+ properties: {
52
+ path: { type: "string", description: "Absolute or relative file path to edit" },
53
+ old_string: { type: "string", description: "The exact string to find and replace" },
54
+ new_string: { type: "string", description: "The replacement string" },
55
+ },
56
+ required: ["path", "old_string", "new_string"],
57
+ },
58
+ handler: async ({ path, old_string, new_string }) => {
59
+ const resolved = resolve(path);
60
+ if (!existsSync(resolved)) {
61
+ return { error: `File not found: ${resolved}` };
62
+ }
63
+ const content = readFileSync(resolved, "utf-8");
64
+ if (!content.includes(old_string)) {
65
+ return { error: "old_string not found in file. Make sure it matches exactly, including whitespace." };
66
+ }
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
+ },
72
+ };
@@ -0,0 +1,32 @@
1
+ import { readFile, writeFile, editFile } from "./file-tools.js";
2
+ import { runCommand } from "./command-tools.js";
3
+ import { searchFiles, searchContent, listDirectory } from "./search-tools.js";
4
+
5
+ const tools = [readFile, writeFile, editFile, runCommand, searchFiles, searchContent, listDirectory];
6
+
7
+ const toolMap = new Map(tools.map((t) => [t.name, t]));
8
+
9
+ export function getToolDefinitions() {
10
+ return tools.map(({ name, description, parameters }) => ({ name, description, parameters }));
11
+ }
12
+
13
+ export function getTool(name) {
14
+ return toolMap.get(name);
15
+ }
16
+
17
+ export async function executeTool(name, input) {
18
+ const tool = toolMap.get(name);
19
+ if (!tool) {
20
+ return { error: `Unknown tool: ${name}` };
21
+ }
22
+ try {
23
+ return await tool.handler(input);
24
+ } catch (err) {
25
+ return { error: err.message };
26
+ }
27
+ }
28
+
29
+ export function requiresConfirmation(name) {
30
+ const tool = toolMap.get(name);
31
+ return tool?.requiresConfirmation === true;
32
+ }
@@ -0,0 +1,95 @@
1
+ import { readdirSync, statSync, existsSync } from "fs";
2
+ import { resolve, join } from "path";
3
+ import { execSync } from "child_process";
4
+
5
+ export const searchFiles = {
6
+ name: "search_files",
7
+ description: "Search for files matching a glob/name pattern. Returns matching file paths.",
8
+ parameters: {
9
+ type: "object",
10
+ properties: {
11
+ pattern: { type: "string", description: "File name pattern to search for (e.g., '*.js', 'config*', 'test')" },
12
+ path: { type: "string", description: "Directory to search in (defaults to current directory)" },
13
+ },
14
+ required: ["pattern"],
15
+ },
16
+ handler: async ({ pattern, path }) => {
17
+ const dir = resolve(path || ".");
18
+ try {
19
+ const result = execSync(`find ${JSON.stringify(dir)} -name ${JSON.stringify(pattern)} -not -path '*/node_modules/*' -not -path '*/.git/*' 2>/dev/null | head -50`, {
20
+ encoding: "utf-8",
21
+ timeout: 10_000,
22
+ });
23
+ const files = result.trim().split("\n").filter(Boolean);
24
+ return { pattern, searchPath: dir, matches: files, count: files.length };
25
+ } catch {
26
+ return { pattern, searchPath: dir, matches: [], count: 0 };
27
+ }
28
+ },
29
+ };
30
+
31
+ export const searchContent = {
32
+ name: "search_content",
33
+ description: "Search for a text pattern inside files (like grep). Returns matching lines with file paths and line numbers.",
34
+ parameters: {
35
+ type: "object",
36
+ properties: {
37
+ pattern: { type: "string", description: "Text or regex pattern to search for" },
38
+ path: { type: "string", description: "Directory to search in (defaults to current directory)" },
39
+ include: { type: "string", description: "File glob to filter (e.g., '*.js')" },
40
+ },
41
+ required: ["pattern"],
42
+ },
43
+ handler: async ({ pattern, path, include }) => {
44
+ const dir = resolve(path || ".");
45
+ try {
46
+ let cmd = `grep -rn --include='${include || "*"}' ${JSON.stringify(pattern)} ${JSON.stringify(dir)} 2>/dev/null | head -50`;
47
+ const result = execSync(cmd, { encoding: "utf-8", timeout: 10_000 });
48
+ const lines = result.trim().split("\n").filter(Boolean);
49
+ const matches = lines.map((line) => {
50
+ const match = line.match(/^(.+?):(\d+):(.*)$/);
51
+ if (match) return { file: match[1], line: parseInt(match[2]), content: match[3].trim() };
52
+ return { raw: line };
53
+ });
54
+ return { pattern, searchPath: dir, matches, count: matches.length };
55
+ } catch {
56
+ return { pattern, searchPath: dir, matches: [], count: 0 };
57
+ }
58
+ },
59
+ };
60
+
61
+ export const listDirectory = {
62
+ name: "list_directory",
63
+ description: "List files and directories at the given path. Shows names, types, and sizes.",
64
+ parameters: {
65
+ type: "object",
66
+ properties: {
67
+ path: { type: "string", description: "Directory path to list (defaults to current directory)" },
68
+ },
69
+ required: [],
70
+ },
71
+ handler: async ({ path }) => {
72
+ const dir = resolve(path || ".");
73
+ if (!existsSync(dir)) {
74
+ return { error: `Directory not found: ${dir}` };
75
+ }
76
+ try {
77
+ const entries = readdirSync(dir).map((name) => {
78
+ try {
79
+ const fullPath = join(dir, name);
80
+ const stat = statSync(fullPath);
81
+ return {
82
+ name,
83
+ type: stat.isDirectory() ? "directory" : "file",
84
+ size: stat.isDirectory() ? undefined : stat.size,
85
+ };
86
+ } catch {
87
+ return { name, type: "unknown" };
88
+ }
89
+ });
90
+ return { path: dir, entries, count: entries.length };
91
+ } catch (err) {
92
+ return { error: err.message };
93
+ }
94
+ },
95
+ };
package/src/repl.js CHANGED
@@ -54,6 +54,11 @@ function showHelp() {
54
54
  }
55
55
 
56
56
  async function dispatch(input) {
57
+ // Strip "cheri " prefix if user types it inside the REPL
58
+ if (input.startsWith("cheri ")) {
59
+ input = input.slice(6);
60
+ }
61
+
57
62
  const parts = input.split(/\s+/);
58
63
  const cmd = parts[0];
59
64
  const sub = parts[1];
@@ -179,9 +184,16 @@ async function dispatch(input) {
179
184
  return;
180
185
  }
181
186
 
182
- default:
187
+ default: {
183
188
  // Treat any unrecognized input as an agent request
189
+ const { getConfigValue } = await import("./lib/config-store.js");
190
+ const token = getConfigValue("token");
191
+ if (!token) {
192
+ log.warn("Not logged in. Type " + chalk.cyan("login") + " first to authenticate.");
193
+ return;
194
+ }
184
195
  await runAgent(input);
196
+ }
185
197
  }
186
198
  }
187
199