@heysalad/cheri-cli 0.8.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.8.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,6 +1,12 @@
1
1
  import { apiClient } from "../lib/api-client.js";
2
2
  import { log } from "../lib/logger.js";
3
- import { getToolDefinitions, executeTool as executeLocalTool, requiresConfirmation } from "../lib/tools/index.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";
4
10
  import chalk from "chalk";
5
11
  import readline from "readline";
6
12
 
@@ -20,7 +26,7 @@ Guidelines:
20
26
  - Never guess file contents — always read first.
21
27
  - Current working directory: ${process.cwd()}`;
22
28
 
23
- // Cloud platform tools (executed via API)
29
+ // Cloud platform tools
24
30
  const CLOUD_TOOLS = [
25
31
  { name: "get_account_info", description: "Get the current user's account information", parameters: { type: "object", properties: {}, required: [] } },
26
32
  { name: "list_workspaces", description: "List all cloud workspaces for the current user", parameters: { type: "object", properties: {}, required: [] } },
@@ -37,7 +43,6 @@ const CLOUD_TOOLS = [
37
43
 
38
44
  const CLOUD_TOOL_NAMES = new Set(CLOUD_TOOLS.map((t) => t.name));
39
45
 
40
- // Build unified tool list in OpenAI function-calling format
41
46
  function buildTools() {
42
47
  const localDefs = getToolDefinitions().map((t) => ({
43
48
  type: "function",
@@ -50,7 +55,6 @@ function buildTools() {
50
55
  return [...localDefs, ...cloudDefs];
51
56
  }
52
57
 
53
- // Execute a cloud platform tool via the API
54
58
  async function executeCloudTool(name, args) {
55
59
  try {
56
60
  switch (name) {
@@ -79,36 +83,48 @@ async function executeCloudTool(name, args) {
79
83
  }
80
84
  }
81
85
 
82
- // Ask user for confirmation before destructive operations
83
86
  async function confirmAction(toolName, input) {
84
87
  const desc = toolName === "run_command" ? input.command : JSON.stringify(input);
85
88
  return new Promise((resolve) => {
86
89
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
87
- rl.question(chalk.yellow(` Allow ${chalk.cyan(toolName)}: ${chalk.dim(desc)}? [Y/n] `), (answer) => {
90
+ rl.question(chalk.yellow(` Allow ${chalk.cyan(toolName)}: ${chalk.dim(truncate(desc, 60))}? [Y/n] `), (answer) => {
88
91
  rl.close();
89
92
  resolve(answer.trim().toLowerCase() !== "n");
90
93
  });
91
94
  });
92
95
  }
93
96
 
94
- // Unified tool executor
95
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
96
111
  if (CLOUD_TOOL_NAMES.has(name)) {
97
- return executeCloudTool(name, args);
112
+ const result = await executeCloudTool(name, args);
113
+ await runHooks("PostToolUse", { toolName: name, input: args, result });
114
+ return result;
98
115
  }
99
116
 
100
- // Local tool — check if it needs confirmation
101
- if (requiresConfirmation(name)) {
117
+ // Local tools — check if confirmation needed
118
+ if (permission === "ask" || (permission !== "allow" && toolRequiresConfirmation(name))) {
102
119
  const allowed = await confirmAction(name, args);
103
- if (!allowed) {
104
- return { error: "User denied execution" };
105
- }
120
+ if (!allowed) return { error: "User denied execution" };
106
121
  }
107
122
 
108
- return executeLocalTool(name, args);
123
+ const result = await executeLocalTool(name, args);
124
+ await runHooks("PostToolUse", { toolName: name, input: args, result });
125
+ return result;
109
126
  }
110
127
 
111
- // Parse SSE stream from the cloud proxy
112
128
  async function* parseSSEStream(response) {
113
129
  const reader = response.body.getReader();
114
130
  const decoder = new TextDecoder();
@@ -127,9 +143,7 @@ async function* parseSSEStream(response) {
127
143
  if (line.startsWith("data: ")) {
128
144
  const data = line.slice(6).trim();
129
145
  if (data === "[DONE]") return;
130
- try {
131
- yield JSON.parse(data);
132
- } catch {}
146
+ try { yield JSON.parse(data); } catch {}
133
147
  }
134
148
  }
135
149
  }
@@ -138,18 +152,108 @@ async function* parseSSEStream(response) {
138
152
  }
139
153
  }
140
154
 
141
- export async function runAgent(userRequest) {
142
- const ALL_TOOLS = buildTools();
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);
165
+
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
+ }
143
208
 
144
- const messages = [
145
- { role: "system", content: SYSTEM_PROMPT },
146
- { role: "user", content: userRequest },
147
- ];
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
+ }
148
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();
149
253
  const MAX_ITERATIONS = 15;
150
254
 
151
255
  for (let i = 0; i < MAX_ITERATIONS; i++) {
152
- const response = await apiClient.chatStream(messages, ALL_TOOLS);
256
+ const response = await apiClient.chatStream(currentSession.messages, ALL_TOOLS);
153
257
 
154
258
  let fullText = "";
155
259
  const toolCalls = {};
@@ -166,9 +270,7 @@ export async function runAgent(userRequest) {
166
270
  if (delta?.tool_calls) {
167
271
  for (const tc of delta.tool_calls) {
168
272
  const idx = tc.index;
169
- if (!toolCalls[idx]) {
170
- toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
171
- }
273
+ if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
172
274
  if (tc.id) toolCalls[idx].id = tc.id;
173
275
  if (tc.function?.name) toolCalls[idx].name = tc.function.name;
174
276
  if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
@@ -180,24 +282,22 @@ export async function runAgent(userRequest) {
180
282
 
181
283
  const toolCallList = Object.values(toolCalls);
182
284
 
183
- // No tool calls — final text response, done
184
285
  if (toolCallList.length === 0) {
185
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);
186
290
  return;
187
291
  }
188
292
 
189
293
  if (fullText) process.stdout.write("\n");
190
294
 
191
- // Build assistant message with tool calls
192
295
  const assistantMsg = { role: "assistant", content: fullText || null };
193
296
  assistantMsg.tool_calls = toolCallList.map((tc) => ({
194
- id: tc.id,
195
- type: "function",
196
- function: { name: tc.name, arguments: tc.arguments },
297
+ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments },
197
298
  }));
198
- messages.push(assistantMsg);
299
+ currentSession.messages.push(assistantMsg);
199
300
 
200
- // Execute each tool and add results
201
301
  for (const tc of toolCallList) {
202
302
  let input = {};
203
303
  try { input = JSON.parse(tc.arguments); } catch {}
@@ -214,19 +314,18 @@ export async function runAgent(userRequest) {
214
314
  log.success(tc.name);
215
315
  }
216
316
 
217
- // Truncate large tool results to avoid blowing context
218
317
  const resultStr = JSON.stringify(result);
219
318
  const truncatedResult = resultStr.length > 8000 ? resultStr.slice(0, 8000) + "...(truncated)" : resultStr;
220
319
 
221
- messages.push({
222
- role: "tool",
223
- tool_call_id: tc.id,
224
- content: truncatedResult,
225
- });
320
+ currentSession.messages.push({ role: "tool", tool_call_id: tc.id, content: truncatedResult });
226
321
  }
322
+
323
+ // Save session after each tool round
324
+ saveSession(currentSession.id, currentSession);
227
325
  }
228
326
 
229
327
  log.warn("Agent reached maximum iterations (15). Stopping.");
328
+ await runHooks("Stop", { reason: "max_iterations" });
230
329
  }
231
330
 
232
331
  function truncate(str, max) {
@@ -240,10 +339,7 @@ export function registerAgentCommand(program) {
240
339
  .description("AI coding agent — natural language command interface")
241
340
  .action(async (requestParts) => {
242
341
  const request = requestParts.join(" ");
243
- try {
244
- await runAgent(request);
245
- } catch (err) {
246
- log.error(err.message);
247
- }
342
+ try { await runAgent(request); }
343
+ catch (err) { log.error(err.message); }
248
344
  });
249
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
+ }
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