@heysalad/cheri-cli 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,95 @@
1
+ # Changelog
2
+
3
+ All notable changes to Cheri CLI will be documented in this file.
4
+
5
+ ## [1.1.0] - 2026-02-17
6
+
7
+ ### ✨ Added - Beautiful UI & Animations
8
+
9
+ #### Enhanced Visual Feedback
10
+ - **Thinking Animation**: Beautiful animated spinner while AI processes requests
11
+ - **Tool Execution Panels**: Rich, color-coded panels for each tool with:
12
+ - Tool-specific icons (📖 for reading, ✍️ for writing, ⚡ for executing)
13
+ - Real-time elapsed time tracking
14
+ - Success/failure animations with context
15
+ - Preview of results in success messages
16
+
17
+ #### New UI Components
18
+ - **Gradient Colors**: Beautiful gradient text for enhanced readability
19
+ - Cheri red (#FF6B6B), Purple (#A855F7), Blue (#3B82F6), Green (#10B981)
20
+ - Cyan (#06B6D4), Yellow (#F59E0B), Pink (#EC4899)
21
+ - **Custom Spinners**: Operation-specific animated spinners
22
+ - File operations: Reading, writing, editing, deleting
23
+ - Cloud operations: API calls, workspace management
24
+ - Search operations: File and content search
25
+ - Command execution: Shell command progress
26
+ - AI thinking: Multi-frame thinking animation
27
+
28
+ #### Enhanced Logging
29
+ - **Enhanced Icons**: Unicode icons for all status messages (✓ ✗ ⚠ ℹ 💡)
30
+ - **Box Drawing**: Beautiful borders with unicode box characters
31
+ - **Progress Bars**: Animated progress indicators with percentage
32
+ - **Stream Renderer**: Colored streaming text for AI responses
33
+ - **Tool Panels**: Live-updating panels for tool execution with timing
34
+
35
+ #### Developer Experience
36
+ - **Managed Spinners**: Automatic cleanup of all active spinners
37
+ - **Error Context**: Better error messages with truncated stack traces
38
+ - **Visual Separators**: Clear section dividers between operations
39
+ - **Status Messages**: Context-aware success/failure messages
40
+
41
+ ### 🔧 Improved
42
+
43
+ #### Agent Command
44
+ - Streaming responses now display in gradient cyan for better readability
45
+ - Tool executions show in animated panels with real-time feedback
46
+ - Visual separator between AI response and tool execution
47
+ - Thinking spinner appears while waiting for AI response
48
+ - Better error display with color-coded severity
49
+
50
+ #### Logger
51
+ - Enhanced all log methods with gradient colors
52
+ - Added icons to all status messages
53
+ - Improved visual hierarchy with better spacing
54
+ - Enhanced banner with animated logo
55
+
56
+ ### 📚 Files Added
57
+ - `src/lib/ui.js` - Complete UI system with animations and components
58
+
59
+ ### 🐛 Bug Fixes
60
+ - Fixed missing newlines after tool execution
61
+ - Better handling of long command outputs in spinners
62
+ - Improved truncation of long file paths in status messages
63
+
64
+ ---
65
+
66
+ ## [1.0.0] - 2026-02-16
67
+
68
+ ### Initial Release
69
+ - AI-powered coding agent with tool execution
70
+ - Cloud workspace management
71
+ - Session persistence and resume
72
+ - MCP server integration
73
+ - Multi-agent orchestration
74
+ - Sandbox command execution
75
+ - File operations (read, write, edit)
76
+ - Search capabilities
77
+ - Configuration management
78
+ - Memory system
79
+ - Plugin/skill support
80
+ - Approval workflows
81
+ - Diff tracking and rollback
82
+
83
+ ---
84
+
85
+ ## Future Roadmap
86
+
87
+ ### Planned Features
88
+ - Interactive mode with REPL
89
+ - Diff viewer with syntax highlighting
90
+ - File tree visualization
91
+ - Real-time collaboration indicators
92
+ - Custom theme support
93
+ - Notification sounds (optional)
94
+ - Performance metrics dashboard
95
+ - Export session transcripts with formatting
package/bin/cheri.js CHANGED
@@ -18,7 +18,7 @@ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-
18
18
 
19
19
  program
20
20
  .name("cheri")
21
- .description("Cheri CLI - AI-powered cloud IDE by HeySalad")
21
+ .description("Cheri CLI - AI-powered cloud IDE by HeySalad®")
22
22
  .version(pkg.version);
23
23
 
24
24
  registerLoginCommand(program);
package/package.json CHANGED
@@ -1,14 +1,20 @@
1
1
  {
2
2
  "name": "@heysalad/cheri-cli",
3
- "version": "1.0.0",
4
- "description": "Cheri CLI - AI-powered cloud IDE by HeySalad. Like Claude Code, but for cloud workspaces.",
3
+ "version": "1.1.1",
4
+ "description": "Cheri CLI - AI-powered cloud IDE by HeySalad®. Like Claude Code, but for cloud workspaces. Now with beautiful animations and enhanced UI!",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cheri": "./bin/cheri.js"
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
- "src/"
11
+ "src/**/*.js",
12
+ "!src/**/*.fixed.js",
13
+ "!src/**/*.backup.js",
14
+ "!src/**/*.test.js",
15
+ "!src/**/*.spec.js",
16
+ "CHANGELOG.md",
17
+ "README.md"
12
18
  ],
13
19
  "scripts": {
14
20
  "start": "node bin/cheri.js",
@@ -15,10 +15,11 @@ import { snapshotFile, recordChange, getSessionChanges, generateDiff, rollbackCh
15
15
  import { shouldApprove, promptApproval, getApprovalMode } from "../lib/approval.js";
16
16
  import { McpManager } from "../lib/mcp/client.js";
17
17
  import { AgentOrchestrator } from "../lib/multi-agent.js";
18
+ import { ToolPanel, status, StreamRenderer, gradients, icons } from "../lib/ui.js";
18
19
  import chalk from "chalk";
19
20
  import readline from "readline";
20
21
 
21
- 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.
22
+ 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.
22
23
 
23
24
  You have these tool categories:
24
25
  1. LOCAL CODING TOOLS — read_file, write_file, edit_file, run_command, search_files, search_content, list_directory
@@ -229,27 +230,53 @@ async function executeTool(name, args, orchestrator, allTools, parseSSE, mcpMana
229
230
  }
230
231
 
231
232
  // ── SSE Stream Parser ─────────────────────────────────────────────────────────
233
+ const STREAM_TIMEOUT_MS = 120000; // 2 minutes with no data = timeout
234
+
232
235
  async function* parseSSEStream(response) {
233
236
  const reader = response.body.getReader();
234
237
  const decoder = new TextDecoder();
235
238
  let buffer = "";
239
+ let lastDataAt = Date.now();
240
+
236
241
  try {
237
242
  while (true) {
238
- const { done, value } = await reader.read();
243
+ // Race between next chunk and timeout
244
+ const timeoutPromise = new Promise((_, reject) => {
245
+ const remaining = STREAM_TIMEOUT_MS - (Date.now() - lastDataAt);
246
+ if (remaining <= 0) reject(new Error("Stream timed out — no data received for 2 minutes"));
247
+ else setTimeout(() => reject(new Error("Stream timed out — no data received for 2 minutes")), remaining);
248
+ });
249
+
250
+ let result;
251
+ try {
252
+ result = await Promise.race([reader.read(), timeoutPromise]);
253
+ } catch (err) {
254
+ log.warn(err.message);
255
+ break;
256
+ }
257
+
258
+ const { done, value } = result;
239
259
  if (done) break;
260
+
261
+ lastDataAt = Date.now();
240
262
  buffer += decoder.decode(value, { stream: true });
241
263
  const lines = buffer.split("\n");
242
264
  buffer = lines.pop() || "";
265
+
243
266
  for (const line of lines) {
244
267
  if (line.startsWith("data: ")) {
245
268
  const data = line.slice(6).trim();
246
269
  if (data === "[DONE]") return;
247
- try { yield JSON.parse(data); } catch {}
270
+ try {
271
+ yield JSON.parse(data);
272
+ } catch (parseErr) {
273
+ log.dim(`SSE parse warning: ${parseErr.message}`);
274
+ }
248
275
  }
249
276
  }
250
277
  }
251
278
  } finally {
252
- reader.releaseLock();
279
+ try { reader.releaseLock(); } catch {}
253
280
  }
254
281
  }
255
282
 
@@ -264,19 +291,12 @@ export async function runAgent(userRequest, options = {}) {
264
291
  const memoryContext = getMemoryContext();
265
292
  const skillContext = getSkillContext(plugins.skills, userRequest);
266
293
 
267
- // Initialize MCP servers
268
- const mcpManager = new McpManager();
269
- const mcpServers = getConfigValue("mcp.servers") || {};
270
- for (const [name, config] of Object.entries(mcpServers)) {
271
- if (config.command) await mcpManager.addServer(name, config);
272
- }
273
-
274
294
  // Create provider
275
295
  const providerName = getConfigValue("agent.provider") || getConfigValue("ai.provider") || "cheri";
276
296
  const provider = createProvider(providerName);
277
297
  const agentModel = getConfigValue("agent.model") || getConfigValue("ai.model") || "";
278
298
 
279
- // Handle slash commands
299
+ // Handle slash commands (before MCP init to avoid leaking child processes)
280
300
  if (userRequest.startsWith("/")) {
281
301
  const cmdName = userRequest.slice(1).split(/\s+/)[0];
282
302
  const cmdArgs = userRequest.slice(1 + cmdName.length).trim();
@@ -385,6 +405,13 @@ export async function runAgent(userRequest, options = {}) {
385
405
  }
386
406
  }
387
407
 
408
+ // Initialize MCP servers (after slash commands to avoid leaking child processes on early returns)
409
+ const mcpManager = new McpManager();
410
+ const mcpServers = getConfigValue("mcp.servers") || {};
411
+ for (const [name, config] of Object.entries(mcpServers)) {
412
+ if (config.command) await mcpManager.addServer(name, config);
413
+ }
414
+
388
415
  // Run SessionStart hooks
389
416
  if (!currentSession) await runHooks("SessionStart", { cwd: process.cwd() });
390
417
 
@@ -425,116 +452,165 @@ export async function runAgent(userRequest, options = {}) {
425
452
 
426
453
  const MAX_ITERATIONS = getConfigValue("agent.maxIterations") || 15;
427
454
 
428
- for (let i = 0; i < MAX_ITERATIONS; i++) {
429
- const response = await provider.chatStream(currentSession.messages, ALL_TOOLS, {
430
- model: agentModel || undefined,
431
- });
455
+ try {
456
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
457
+ // Show thinking animation while waiting for response
458
+ const thinkingSpinner = status.thinking('Analyzing request...');
459
+ thinkingSpinner.start();
432
460
 
433
- let fullText = "";
434
- const toolCalls = {};
461
+ const response = await provider.chatStream(currentSession.messages, ALL_TOOLS, {
462
+ model: agentModel || undefined,
463
+ });
435
464
 
436
- for await (const chunk of parseSSEStream(response)) {
437
- const delta = chunk.choices?.[0]?.delta;
438
- const finishReason = chunk.choices?.[0]?.finish_reason;
465
+ let fullText = "";
466
+ const toolCalls = {};
467
+ let firstContent = false;
468
+
469
+ for await (const chunk of parseSSEStream(response)) {
470
+ const delta = chunk.choices?.[0]?.delta;
471
+ const finishReason = chunk.choices?.[0]?.finish_reason;
472
+
473
+ if (delta?.content) {
474
+ // Stop thinking spinner on first content
475
+ if (!firstContent) {
476
+ thinkingSpinner.stop();
477
+ firstContent = true;
478
+ }
479
+ // Stream with gradient color
480
+ process.stdout.write(gradients.cyan(delta.content));
481
+ fullText += delta.content;
482
+ }
439
483
 
440
- if (delta?.content) {
441
- process.stdout.write(delta.content);
442
- fullText += delta.content;
443
- }
484
+ if (delta?.tool_calls) {
485
+ // Stop spinner if we're getting tool calls
486
+ if (!firstContent) {
487
+ thinkingSpinner.stop();
488
+ firstContent = true;
489
+ }
490
+
491
+ for (const tc of delta.tool_calls) {
492
+ const idx = tc.index;
493
+ if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
494
+ if (tc.id) toolCalls[idx].id = tc.id;
495
+ if (tc.function?.name) toolCalls[idx].name = tc.function.name;
496
+ if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
497
+ }
498
+ }
444
499
 
445
- if (delta?.tool_calls) {
446
- for (const tc of delta.tool_calls) {
447
- const idx = tc.index;
448
- if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
449
- if (tc.id) toolCalls[idx].id = tc.id;
450
- if (tc.function?.name) toolCalls[idx].name = tc.function.name;
451
- if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
500
+ if (finishReason) {
501
+ if (!firstContent) thinkingSpinner.stop();
502
+ break;
452
503
  }
453
504
  }
454
505
 
455
- if (finishReason) break;
456
- }
506
+ const toolCallList = Object.values(toolCalls);
457
507
 
458
- const toolCallList = Object.values(toolCalls);
508
+ if (toolCallList.length === 0) {
509
+ // Text was already streamed to stdout, just add a newline
510
+ if (fullText) {
511
+ process.stdout.write("\n");
512
+ }
513
+ currentSession.messages.push({ role: "assistant", content: fullText });
514
+ saveSession(currentSession.id, currentSession);
515
+ return;
516
+ }
459
517
 
460
- if (toolCallList.length === 0) {
461
- // Render markdown for final text output
462
518
  if (fullText) {
463
- process.stdout.write("\r\x1b[K"); // clear streaming line
464
- console.log(renderMarkdown(fullText));
519
+ process.stdout.write("\n");
520
+ // Add visual separator after text response
521
+ if (toolCallList.length > 0) {
522
+ console.log(chalk.dim("─".repeat(60)));
523
+ }
465
524
  }
466
- currentSession.messages.push({ role: "assistant", content: fullText });
467
- saveSession(currentSession.id, currentSession);
468
- mcpManager.disconnectAll();
469
- return;
470
- }
471
-
472
- if (fullText) process.stdout.write("\n");
473
-
474
- const assistantMsg = { role: "assistant", content: fullText || null };
475
- assistantMsg.tool_calls = toolCallList.map(tc => ({
476
- id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments },
477
- }));
478
- currentSession.messages.push(assistantMsg);
479
-
480
- // Execute tools — parallel for safe tools, sequential for others
481
- const safeTools = [];
482
- const unsafeTools = [];
483
-
484
- for (const tc of toolCallList) {
485
- let input = {};
486
- try { input = JSON.parse(tc.arguments); } catch {}
487
- const isSafe = tc.name === "read_file" || tc.name === "list_directory" ||
488
- tc.name === "search_files" || tc.name === "search_content" ||
489
- CLOUD_TOOL_NAMES.has(tc.name);
490
- (isSafe ? safeTools : unsafeTools).push({ tc, input });
491
- }
492
525
 
493
- // Execute safe tools in parallel
494
- const executeOne = async ({ tc, input }) => {
495
- const isMcp = mcpManager.isMcpTool(tc.name);
496
- const isLocal = !CLOUD_TOOL_NAMES.has(tc.name) && !AGENT_TOOL_NAMES.has(tc.name) && !isMcp;
497
- const prefix = isMcp ? chalk.magenta("mcp") : isLocal ? chalk.magenta("local") : chalk.blue("cloud");
498
-
499
- // Command safety label
500
- let safetyStr = "";
501
- if (tc.name === "run_command" && input.command) {
502
- const safety = getSafetyLabel(input.command);
503
- safetyStr = ` ${chalk[safety.color](`[${safety.label}]`)}`;
526
+ const assistantMsg = { role: "assistant", content: fullText || null };
527
+ assistantMsg.tool_calls = toolCallList.map(tc => ({
528
+ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments },
529
+ }));
530
+ currentSession.messages.push(assistantMsg);
531
+
532
+ // Execute tools — parallel for safe tools, sequential for others
533
+ const safeTools = [];
534
+ const unsafeTools = [];
535
+
536
+ for (const tc of toolCallList) {
537
+ let input = {};
538
+ try { input = JSON.parse(tc.arguments); } catch {}
539
+ const isSafe = tc.name === "read_file" || tc.name === "list_directory" ||
540
+ tc.name === "search_files" || tc.name === "search_content" ||
541
+ CLOUD_TOOL_NAMES.has(tc.name);
542
+ (isSafe ? safeTools : unsafeTools).push({ tc, input });
504
543
  }
505
544
 
506
- log.info(`${prefix} ${chalk.cyan(tc.name)}${safetyStr}${Object.keys(input).length ? chalk.dim(" " + truncate(JSON.stringify(input), 80)) : ""}`);
545
+ // Execute safe tools in parallel
546
+ const executeOne = async ({ tc, input }) => {
547
+ const isMcp = mcpManager.isMcpTool(tc.name);
548
+ const isLocal = !CLOUD_TOOL_NAMES.has(tc.name) && !AGENT_TOOL_NAMES.has(tc.name) && !isMcp;
507
549
 
508
- const result = await executeTool(tc.name, input, orchestrator, ALL_TOOLS, parseSSEStream, mcpManager);
550
+ // Create animated panel for this tool execution
551
+ const panel = new ToolPanel(tc.name, input);
509
552
 
510
- if (result.error) log.error(result.error);
511
- else log.success(tc.name);
553
+ // Command safety label for run_command
554
+ if (tc.name === "run_command" && input.command) {
555
+ const safety = getSafetyLabel(input.command);
556
+ const safetyLabel = chalk[safety.color](`[${safety.label}]`);
557
+ panel.update(`running ${safetyLabel}`);
558
+ }
512
559
 
513
- return { id: tc.id, result };
514
- };
560
+ const result = await executeTool(tc.name, input, orchestrator, ALL_TOOLS, parseSSEStream, mcpManager);
515
561
 
516
- // Parallel execution for safe tools
517
- const safeResults = safeTools.length > 0 ? await Promise.all(safeTools.map(executeOne)) : [];
562
+ // Determine result message based on tool type
563
+ let resultMsg = '';
564
+ if (result.stdout && tc.name === "run_command") {
565
+ const preview = result.stdout.split('\n')[0].slice(0, 50);
566
+ resultMsg = preview + (result.stdout.length > 50 ? '...' : '');
567
+ } else if (result.path || result.file_path) {
568
+ resultMsg = result.path || result.file_path;
569
+ }
518
570
 
519
- // Sequential execution for unsafe tools
520
- const unsafeResults = [];
521
- for (const item of unsafeTools) {
522
- unsafeResults.push(await executeOne(item));
523
- }
571
+ if (result.error) {
572
+ panel.fail(result.error.slice(0, 80));
573
+ } else {
574
+ panel.succeed(resultMsg);
575
+ }
576
+
577
+ return { id: tc.id, result };
578
+ };
524
579
 
525
- // Add all results to messages
526
- for (const { id, result } of [...safeResults, ...unsafeResults]) {
527
- const resultStr = JSON.stringify(result);
528
- const truncatedResult = resultStr.length > 8000 ? resultStr.slice(0, 8000) + "...(truncated)" : resultStr;
529
- currentSession.messages.push({ role: "tool", tool_call_id: id, content: truncatedResult });
580
+ // Parallel execution for safe tools — use allSettled to avoid losing results on single failure
581
+ const safeResults = [];
582
+ if (safeTools.length > 0) {
583
+ const settled = await Promise.allSettled(safeTools.map(executeOne));
584
+ for (let j = 0; j < settled.length; j++) {
585
+ if (settled[j].status === "fulfilled") {
586
+ safeResults.push(settled[j].value);
587
+ } else {
588
+ safeResults.push({ id: safeTools[j].tc.id, result: { error: settled[j].reason?.message || "Tool execution failed" } });
589
+ }
590
+ }
591
+ }
592
+
593
+ // Sequential execution for unsafe tools
594
+ const unsafeResults = [];
595
+ for (const item of unsafeTools) {
596
+ unsafeResults.push(await executeOne(item));
597
+ }
598
+
599
+ // Add all results to messages
600
+ for (const { id, result } of [...safeResults, ...unsafeResults]) {
601
+ const resultStr = JSON.stringify(result);
602
+ const truncatedResult = resultStr.length > 8000 ? resultStr.slice(0, 8000) + "...(truncated)" : resultStr;
603
+ currentSession.messages.push({ role: "tool", tool_call_id: id, content: truncatedResult });
604
+ }
605
+
606
+ saveSession(currentSession.id, currentSession);
530
607
  }
531
608
 
532
- saveSession(currentSession.id, currentSession);
609
+ log.warn("Agent reached maximum iterations. Stopping.");
610
+ await runHooks("Stop", { reason: "max_iterations" });
611
+ } finally {
612
+ mcpManager.disconnectAll();
533
613
  }
534
-
535
- log.warn("Agent reached maximum iterations. Stopping.");
536
- await runHooks("Stop", { reason: "max_iterations" });
537
- mcpManager.disconnectAll();
538
614
  }
539
615
 
540
616
  function truncate(str, max) {
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import inquirer from "inquirer";
2
+ import readline from "readline";
3
3
  import { setConfigValue, getConfigValue } from "../lib/config-store.js";
4
4
  import { apiClient } from "../lib/api-client.js";
5
5
  import { log } from "../lib/logger.js";
@@ -15,18 +15,38 @@ export async function loginFlow() {
15
15
  log.blank();
16
16
  console.log(` ${chalk.cyan.underline(`${apiUrl}/auth/github?source=cli`)}`);
17
17
  log.blank();
18
- log.info("Step 2: After GitHub login, your token will be shown automatically.");
18
+ log.info("Step 2: After GitHub login, your token will be shown.");
19
19
  log.info(" Copy it and paste it below.");
20
20
  log.blank();
21
21
 
22
- const { token } = await inquirer.prompt([
23
- {
24
- type: "password",
25
- name: "token",
26
- message: "Paste your API token:",
27
- mask: "*",
28
- },
29
- ]);
22
+ // Use raw readline to avoid inquirer's readline conflicts with REPL
23
+ const token = await new Promise((resolve) => {
24
+ const rl = readline.createInterface({
25
+ input: process.stdin,
26
+ output: process.stdout,
27
+ terminal: true,
28
+ });
29
+
30
+ // Mask input for security
31
+ let input = "";
32
+ const originalWrite = process.stdout.write.bind(process.stdout);
33
+
34
+ rl.question(chalk.cyan(" Paste your API token: "), (answer) => {
35
+ rl.close();
36
+ resolve(answer);
37
+ });
38
+
39
+ // Override _writeToOutput to mask characters
40
+ rl._writeToOutput = function (str) {
41
+ if (str.includes("\n") || str.includes("\r")) {
42
+ originalWrite.call(process.stdout, str);
43
+ } else if (str.length === 1 && str !== " " && !str.startsWith("\x1b")) {
44
+ originalWrite.call(process.stdout, "*");
45
+ } else {
46
+ originalWrite.call(process.stdout, str);
47
+ }
48
+ };
49
+ });
30
50
 
31
51
  if (!token || !token.trim()) {
32
52
  throw new Error("No token provided.");
@@ -8,7 +8,7 @@ export async function showMemory(options = {}) {
8
8
  const spinner = ora("Fetching memories...").start();
9
9
 
10
10
  try {
11
- const { memories } = await apiClient.getMemory();
11
+ const { memories = [] } = await apiClient.getMemory();
12
12
  spinner.stop();
13
13
 
14
14
  log.blank();
@@ -66,7 +66,7 @@ export async function exportMemory(options = {}) {
66
66
  const spinner = ora("Exporting memories...").start();
67
67
 
68
68
  try {
69
- const { memories } = await apiClient.getMemory();
69
+ const { memories = [] } = await apiClient.getMemory();
70
70
 
71
71
  const exportData = {
72
72
  version: "0.1.0",
@@ -27,7 +27,7 @@ export async function showStatus() {
27
27
  // Workspaces
28
28
  log.header("Workspaces");
29
29
  try {
30
- const { workspaces } = await apiClient.listWorkspaces();
30
+ const { workspaces = [] } = await apiClient.listWorkspaces();
31
31
  if (workspaces.length === 0) {
32
32
  log.keyValue("Count", "0");
33
33
  log.dim(` Run ${chalk.cyan("cheri workspace launch owner/repo")} to create one.`);
@@ -14,19 +14,22 @@ export async function showUsage() {
14
14
  spinner.stop();
15
15
 
16
16
  // Rate limit
17
+ const rateLimit = data.rateLimit || {};
17
18
  log.header("Rate Limit");
18
19
  log.keyValue("Plan", data.plan === "pro" ? chalk.green("Pro") : "Free");
19
- log.keyValue("Limit", `${data.rateLimit.limit} requests/hour`);
20
- const remaining = data.rateLimit.remaining;
21
- const limit = data.rateLimit.limit;
20
+ log.keyValue("Limit", `${rateLimit.limit ?? "N/A"} requests/hour`);
21
+ const remaining = rateLimit.remaining ?? 0;
22
+ const limit = rateLimit.limit ?? 1;
22
23
  const remainColor = remaining > limit * 0.5 ? chalk.green : remaining > limit * 0.1 ? chalk.yellow : chalk.red;
23
24
  log.keyValue("Remaining", remainColor(`${remaining}`));
24
- log.keyValue("Resets at", data.rateLimit.resetsAt);
25
+ log.keyValue("Resets at", rateLimit.resetsAt ?? "N/A");
25
26
 
26
27
  // Today's usage
28
+ const usage = data.usage || {};
29
+ const today = usage.today || {};
27
30
  log.header("Today");
28
- log.keyValue("Requests", `${data.usage.today.requests}`);
29
- const endpoints = data.usage.today.endpoints || {};
31
+ log.keyValue("Requests", `${today.requests ?? 0}`);
32
+ const endpoints = today.endpoints || {};
30
33
  if (Object.keys(endpoints).length > 0) {
31
34
  for (const [ep, count] of Object.entries(endpoints)) {
32
35
  console.log(` ${chalk.dim(ep)} ${chalk.cyan(count)}`);
@@ -34,10 +37,11 @@ export async function showUsage() {
34
37
  }
35
38
 
36
39
  // Summary
40
+ const summary = data.summary || {};
37
41
  log.header("Summary");
38
- log.keyValue("Last 7 days", `${data.usage.last7d.requests} requests`);
39
- log.keyValue("Last 30 days", `${data.usage.last30d.requests} requests`);
40
- log.keyValue("All time", `${data.summary.totalRequests} requests`);
42
+ log.keyValue("Last 7 days", `${usage.last7d?.requests ?? 0} requests`);
43
+ log.keyValue("Last 30 days", `${usage.last30d?.requests ?? 0} requests`);
44
+ log.keyValue("All time", `${summary.totalRequests ?? 0} requests`);
41
45
  if (data.summary.memberSince) {
42
46
  log.keyValue("Member since", new Date(data.summary.memberSince).toLocaleDateString());
43
47
  }
@@ -70,7 +70,7 @@ export async function listWorkspaces() {
70
70
  const statusColor =
71
71
  ws.status === "running" ? chalk.green : chalk.dim;
72
72
  console.log(
73
- ` ${ws.id.padEnd(24)} ${(ws.repo || "").padEnd(24)} ${statusIcon} ${statusColor(ws.status.padEnd(9))} ${chalk.cyan(ws.url || "")}`
73
+ ` ${ws.id.padEnd(24)} ${(ws.repo || "").padEnd(24)} ${statusIcon} ${statusColor((ws.status || "unknown").padEnd(9))} ${chalk.cyan(ws.url || "")}`
74
74
  );
75
75
  });
76
76
 
@@ -135,7 +135,13 @@ const MODERATE_PATTERNS = [
135
135
  export function classifyCommand(command) {
136
136
  const trimmed = command.trim();
137
137
 
138
- // Check exact safe commands first
138
+ // Check dangerous patterns FIRST (before safe commands, since safe commands
139
+ // like "cat", "echo", "sed" can be dangerous with certain arguments)
140
+ for (const pattern of DANGEROUS_PATTERNS) {
141
+ if (pattern.test(trimmed)) return "dangerous";
142
+ }
143
+
144
+ // Check exact safe commands
139
145
  if (SAFE_COMMANDS.has(trimmed)) return "safe";
140
146
 
141
147
  // Check if it starts with a safe command (with arguments)
@@ -143,11 +149,6 @@ export function classifyCommand(command) {
143
149
  if (trimmed.startsWith(safe + " ") || trimmed === safe) return "safe";
144
150
  }
145
151
 
146
- // Check dangerous patterns
147
- for (const pattern of DANGEROUS_PATTERNS) {
148
- if (pattern.test(trimmed)) return "dangerous";
149
- }
150
-
151
152
  // Check moderate patterns
152
153
  for (const pattern of MODERATE_PATTERNS) {
153
154
  if (pattern.test(trimmed)) return "moderate";