@zds-ai/cli 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A conversational AI CLI tool powered by Grok with intelligent text editor capabilities and tool usage.
4
4
 
5
- <img width="980" height="435" alt="Screenshot 2025-07-21 at 13 35 41" src="https://github.com/user-attachments/assets/192402e3-30a8-47df-9fc8-a084c5696e78" />
5
+ <img width="720" height="528" alt="Image" src="https://github.com/user-attachments/assets/f697a273-141e-4f02-8c15-37143aa7ec0e" />
6
6
 
7
7
  ## Features
8
8
 
@@ -22,6 +22,26 @@ A conversational AI CLI tool powered by Grok with intelligent text editor capabi
22
22
  - GROK API key from X.AI
23
23
  - (Optional, Recommended) Morph API key for Fast Apply editing
24
24
 
25
+ ### System Dependencies
26
+
27
+ zai-cli requires the following system tools for certain features:
28
+
29
+ - **ripgrep** (required for search functionality)
30
+ - macOS: `brew install ripgrep`
31
+ - Ubuntu/Debian: `apt install ripgrep`
32
+ - Windows: `choco install ripgrep` or download from [releases](https://github.com/BurntSushi/ripgrep/releases)
33
+ - Other platforms: See [ripgrep installation guide](https://github.com/BurntSushi/ripgrep#installation)
34
+
35
+ - **Python 3 with openpyxl** (optional, required for XLSX file reading)
36
+ - Install: `pip3 install openpyxl` or `python3 -m pip install openpyxl`
37
+ - Most systems already have Python 3 installed
38
+
39
+ - **exiftool** (optional, required for PNG metadata extraction)
40
+ - macOS: `brew install exiftool`
41
+ - Ubuntu/Debian: `apt install libimage-exiftool-perl`
42
+ - Windows: Download from [exiftool.org](https://exiftool.org/)
43
+ - Other platforms: See [exiftool installation guide](https://exiftool.org/install.html)
44
+
25
45
  ### Global Installation (Recommended)
26
46
 
27
47
  ```sh
@@ -49,52 +69,52 @@ bun link
49
69
  1. Get your GROK API key from [X.AI](https://x.ai)
50
70
 
51
71
  2. Set up your API key (choose one method):
52
-
53
- **Method 1: Environment Variable**
54
-
55
- ```sh
56
- export GROK_API_KEY=your_api_key_here
57
- ```
58
-
59
- **Method 2: .env File**
60
-
61
- ```sh
62
- cp .env.example .env
63
- # Edit .env and add your API key
64
- ```
65
-
66
- **Method 3: Command Line Flag**
67
-
68
- ```sh
69
- zai-cli --api-key your_api_key_here
70
- ```
71
-
72
- **Method 4: User Settings File**
73
-
74
- Create `~/.grok/user-settings.json`:
75
-
76
- ```json
77
- {
78
- "apiKey": "your_api_key_here"
79
- }
80
- ```
72
+
73
+ **Method 1: Environment Variable**
74
+
75
+ ```sh
76
+ export GROK_API_KEY=your_api_key_here
77
+ ```
78
+
79
+ **Method 2: .env File**
80
+
81
+ ```sh
82
+ cp .env.example .env
83
+ # Edit .env and add your API key
84
+ ```
85
+
86
+ **Method 3: Command Line Flag**
87
+
88
+ ```sh
89
+ zai-cli --api-key your_api_key_here
90
+ ```
91
+
92
+ **Method 4: User Settings File**
93
+
94
+ Create `~/.grok/user-settings.json`:
95
+
96
+ ```json
97
+ {
98
+ "apiKey": "your_api_key_here"
99
+ }
100
+ ```
81
101
 
82
102
  3. (Optional, Recommended) Get your Morph API key from [Morph Dashboard](https://morphllm.com/dashboard/api-keys)
83
103
 
84
104
  4. Set up your Morph API key for Fast Apply editing (choose one method):
85
105
 
86
- **Method 1: Environment Variable**
87
-
88
- ```sh
89
- export MORPH_API_KEY=your_morph_api_key_here
90
- ```
91
-
92
- **Method 2: .env File**
93
-
94
- ```sh
95
- # Add to your .env file
96
- MORPH_API_KEY=your_morph_api_key_here
97
- ```
106
+ **Method 1: Environment Variable**
107
+
108
+ ```sh
109
+ export MORPH_API_KEY=your_morph_api_key_here
110
+ ```
111
+
112
+ **Method 2: .env File**
113
+
114
+ ```sh
115
+ # Add to your .env file
116
+ MORPH_API_KEY=your_morph_api_key_here
117
+ ```
98
118
 
99
119
  ### Custom Base URL (Optional)
100
120
 
@@ -61,7 +61,7 @@ export declare class GrokAgent extends EventEmitter {
61
61
  private activeTaskAction;
62
62
  private activeTaskColor;
63
63
  private apiKeyEnvVar;
64
- private pendingContextEdit;
64
+ private pendingContextEditSession;
65
65
  constructor(apiKey: string, baseURL?: string, model?: string, maxToolRounds?: number, debugLogFile?: string, startupHookOutput?: string, temperature?: number, maxTokens?: number);
66
66
  private startupHookOutput?;
67
67
  private systemPrompt;
@@ -119,12 +119,12 @@ export declare class GrokAgent extends EventEmitter {
119
119
  getActiveTask(): string;
120
120
  getActiveTaskAction(): string;
121
121
  getActiveTaskColor(): string;
122
- setPendingContextEdit(tmpJsonPath: string, contextFilePath: string): void;
123
- getPendingContextEdit(): {
122
+ setPendingContextEditSession(tmpJsonPath: string, contextFilePath: string): void;
123
+ getPendingContextEditSession(): {
124
124
  tmpJsonPath: string;
125
125
  contextFilePath: string;
126
126
  } | null;
127
- clearPendingContextEdit(): void;
127
+ clearPendingContextEditSession(): void;
128
128
  setPersona(persona: string, color?: string): Promise<{
129
129
  success: boolean;
130
130
  error?: string;
@@ -9,6 +9,67 @@ import { EventEmitter } from "events";
9
9
  import { createTokenCounter } from "../utils/token-counter.js";
10
10
  import { getSettingsManager } from "../utils/settings-manager.js";
11
11
  import { executeOperationHook, executeToolApprovalHook, applyHookCommands } from "../utils/hook-executor.js";
12
+ // Interval (ms) between token count updates when streaming
13
+ const TOKEN_UPDATE_INTERVAL_MS = 250;
14
+ // Minimum delay (in ms) applied when stopping a task to ensure smooth UI/UX.
15
+ const MINIMUM_STOP_TASK_DELAY_MS = 3000;
16
+ // Maximum number of attempts to parse nested JSON strings in executeTool
17
+ const MAX_JSON_PARSE_ATTEMPTS = 5;
18
+ /**
19
+ * Threshold used to determine whether an AI response is "substantial" (in characters).
20
+ */
21
+ const SUBSTANTIAL_RESPONSE_THRESHOLD = 50;
22
+ /**
23
+ * Extracts the first complete JSON object from a string.
24
+ * Handles duplicate/concatenated JSON objects (LLM bug) like: {"key":"val"}{"key":"val"}
25
+ * @param jsonString The string potentially containing concatenated JSON objects
26
+ * @returns The first complete JSON object, or the original string if no duplicates found
27
+ */
28
+ function extractFirstJsonObject(jsonString) {
29
+ if (!jsonString.includes('}{'))
30
+ return jsonString;
31
+ try {
32
+ // Find the end of the first complete JSON object
33
+ let depth = 0;
34
+ let firstObjEnd = -1;
35
+ for (let i = 0; i < jsonString.length; i++) {
36
+ if (jsonString[i] === "{")
37
+ depth++;
38
+ if (jsonString[i] === "}") {
39
+ depth--;
40
+ if (depth === 0) {
41
+ firstObjEnd = i + 1;
42
+ break;
43
+ }
44
+ }
45
+ }
46
+ if (firstObjEnd > 0 && firstObjEnd < jsonString.length) {
47
+ // Extract and validate first object
48
+ const firstObj = jsonString.substring(0, firstObjEnd);
49
+ JSON.parse(firstObj); // Validate it's valid JSON
50
+ return firstObj;
51
+ }
52
+ }
53
+ catch {
54
+ // If extraction fails, return the original string
55
+ }
56
+ return jsonString;
57
+ }
58
+ /**
59
+ * Cleans up LLM-generated JSON argument strings for tool calls.
60
+ * Removes duplicate/concatenated JSON objects and trims.
61
+ * @param args The raw arguments string from the tool call
62
+ * @returns Cleaned and sanitized argument string
63
+ */
64
+ function sanitizeToolArguments(args) {
65
+ let argsString = args?.trim() || "{}";
66
+ // Handle duplicate/concatenated JSON objects (LLM bug)
67
+ const extractedArgsString = extractFirstJsonObject(argsString);
68
+ if (extractedArgsString !== argsString) {
69
+ argsString = extractedArgsString;
70
+ }
71
+ return argsString;
72
+ }
12
73
  export class GrokAgent extends EventEmitter {
13
74
  grokClient;
14
75
  textEditor;
@@ -44,7 +105,7 @@ export class GrokAgent extends EventEmitter {
44
105
  activeTaskAction = "";
45
106
  activeTaskColor = "white";
46
107
  apiKeyEnvVar = "GROK_API_KEY";
47
- pendingContextEdit = null;
108
+ pendingContextEditSession = null;
48
109
  constructor(apiKey, baseURL, model, maxToolRounds, debugLogFile, startupHookOutput, temperature, maxTokens) {
49
110
  super();
50
111
  const manager = getSettingsManager();
@@ -350,33 +411,7 @@ Current working directory: ${process.cwd()}`;
350
411
  // Clean up tool call arguments before adding to conversation history
351
412
  // This prevents Ollama from rejecting malformed tool calls on subsequent API calls
352
413
  const cleanedToolCalls = assistantMessage.tool_calls.map(toolCall => {
353
- let argsString = toolCall.function.arguments?.trim() || "{}";
354
- // Handle duplicate/concatenated JSON objects (LLM bug)
355
- if (argsString.includes('}{')) {
356
- try {
357
- let depth = 0;
358
- let firstObjEnd = -1;
359
- for (let i = 0; i < argsString.length; i++) {
360
- if (argsString[i] === '{')
361
- depth++;
362
- if (argsString[i] === '}') {
363
- depth--;
364
- if (depth === 0) {
365
- firstObjEnd = i + 1;
366
- break;
367
- }
368
- }
369
- }
370
- if (firstObjEnd > 0 && firstObjEnd < argsString.length) {
371
- const firstObj = argsString.substring(0, firstObjEnd);
372
- JSON.parse(firstObj); // Validate
373
- argsString = firstObj; // Use cleaned version
374
- }
375
- }
376
- catch (e) {
377
- // Keep original if cleaning fails
378
- }
379
- }
414
+ let argsString = sanitizeToolArguments(toolCall.function.arguments);
380
415
  return {
381
416
  ...toolCall,
382
417
  function: {
@@ -546,8 +581,8 @@ Current working directory: ${process.cwd()}`;
546
581
  // - A structured response format that explicitly marks completion
547
582
  // For now, we break immediately after a substantial response to avoid
548
583
  // the cascade of duplicate responses caused by "give it one more chance" logic.
549
- // If the AI provided a substantial response (>50 chars), task is complete
550
- if (assistantMessage.content && assistantMessage.content.trim().length > 50) {
584
+ // If the AI provided a substantial response (>SUBSTANTIAL_RESPONSE_THRESHOLD chars), task is complete
585
+ if (assistantMessage.content && assistantMessage.content.trim().length > SUBSTANTIAL_RESPONSE_THRESHOLD) {
551
586
  break; // Task complete - bot gave a full response
552
587
  }
553
588
  // Short/empty response, give AI another chance
@@ -847,7 +882,7 @@ Current working directory: ${process.cwd()}`;
847
882
  };
848
883
  // Emit token count update
849
884
  const now = Date.now();
850
- if (now - lastTokenUpdate > 250) {
885
+ if (now - lastTokenUpdate > TOKEN_UPDATE_INTERVAL_MS) {
851
886
  lastTokenUpdate = now;
852
887
  yield {
853
888
  type: "token_count",
@@ -875,33 +910,7 @@ Current working directory: ${process.cwd()}`;
875
910
  // Clean up tool call arguments before adding to conversation history
876
911
  // This prevents Ollama from rejecting malformed tool calls on subsequent API calls
877
912
  const cleanedToolCalls = accumulatedMessage.tool_calls?.map(toolCall => {
878
- let argsString = toolCall.function.arguments?.trim() || "{}";
879
- // Handle duplicate/concatenated JSON objects (LLM bug)
880
- if (argsString.includes('}{')) {
881
- try {
882
- let depth = 0;
883
- let firstObjEnd = -1;
884
- for (let i = 0; i < argsString.length; i++) {
885
- if (argsString[i] === '{')
886
- depth++;
887
- if (argsString[i] === '}') {
888
- depth--;
889
- if (depth === 0) {
890
- firstObjEnd = i + 1;
891
- break;
892
- }
893
- }
894
- }
895
- if (firstObjEnd > 0 && firstObjEnd < argsString.length) {
896
- const firstObj = argsString.substring(0, firstObjEnd);
897
- JSON.parse(firstObj); // Validate
898
- argsString = firstObj; // Use cleaned version
899
- }
900
- }
901
- catch (e) {
902
- // Keep original if cleaning fails
903
- }
904
- }
913
+ let argsString = sanitizeToolArguments(toolCall.function.arguments);
905
914
  return {
906
915
  ...toolCall,
907
916
  function: {
@@ -1178,41 +1187,16 @@ Current working directory: ${process.cwd()}`;
1178
1187
  // Handle duplicate/concatenated JSON objects (LLM bug)
1179
1188
  // Pattern: {"key":"val"}{"key":"val"}
1180
1189
  let hadDuplicateJson = false;
1181
- if (argsString.includes('}{')) {
1182
- try {
1183
- // Find the end of the first complete JSON object
1184
- let depth = 0;
1185
- let firstObjEnd = -1;
1186
- for (let i = 0; i < argsString.length; i++) {
1187
- if (argsString[i] === '{')
1188
- depth++;
1189
- if (argsString[i] === '}') {
1190
- depth--;
1191
- if (depth === 0) {
1192
- firstObjEnd = i + 1;
1193
- break;
1194
- }
1195
- }
1196
- }
1197
- if (firstObjEnd > 0 && firstObjEnd < argsString.length) {
1198
- // Extract and validate first object
1199
- const firstObj = argsString.substring(0, firstObjEnd);
1200
- JSON.parse(firstObj); // Validate it's valid JSON
1201
- // Use only the first object
1202
- hadDuplicateJson = true;
1203
- argsString = firstObj;
1204
- }
1205
- }
1206
- catch (e) {
1207
- // If extraction fails, continue with original string
1208
- // The error will be caught by the main JSON.parse below
1209
- }
1190
+ const extractedArgsString = extractFirstJsonObject(argsString);
1191
+ if (extractedArgsString !== argsString) {
1192
+ hadDuplicateJson = true;
1193
+ argsString = extractedArgsString;
1210
1194
  }
1211
1195
  let args = JSON.parse(argsString);
1212
1196
  // Handle multiple layers of JSON encoding (API bug)
1213
1197
  // Keep parsing until we get an object, not a string
1214
1198
  let parseCount = 0;
1215
- while (typeof args === 'string' && parseCount < 5) {
1199
+ while (typeof args === 'string' && parseCount < MAX_JSON_PARSE_ATTEMPTS) {
1216
1200
  parseCount++;
1217
1201
  try {
1218
1202
  args = JSON.parse(args);
@@ -1561,14 +1545,14 @@ Current working directory: ${process.cwd()}`;
1561
1545
  getActiveTaskColor() {
1562
1546
  return this.activeTaskColor;
1563
1547
  }
1564
- setPendingContextEdit(tmpJsonPath, contextFilePath) {
1565
- this.pendingContextEdit = { tmpJsonPath, contextFilePath };
1548
+ setPendingContextEditSession(tmpJsonPath, contextFilePath) {
1549
+ this.pendingContextEditSession = { tmpJsonPath, contextFilePath };
1566
1550
  }
1567
- getPendingContextEdit() {
1568
- return this.pendingContextEdit;
1551
+ getPendingContextEditSession() {
1552
+ return this.pendingContextEditSession;
1569
1553
  }
1570
- clearPendingContextEdit() {
1571
- this.pendingContextEdit = null;
1554
+ clearPendingContextEditSession() {
1555
+ this.pendingContextEditSession = null;
1572
1556
  }
1573
1557
  async setPersona(persona, color) {
1574
1558
  // Execute hook if configured
@@ -1908,7 +1892,7 @@ Current working directory: ${process.cwd()}`;
1908
1892
  }
1909
1893
  // Calculate remaining time to meet 3-second minimum
1910
1894
  const elapsed = Date.now() - startTime;
1911
- const minimumDelay = 3000;
1895
+ const minimumDelay = MINIMUM_STOP_TASK_DELAY_MS;
1912
1896
  const remainingDelay = Math.max(0, minimumDelay - elapsed);
1913
1897
  // Wait for remaining time if needed
1914
1898
  if (remainingDelay > 0) {
@@ -2410,7 +2394,7 @@ Current working directory: ${process.cwd()}`;
2410
2394
  this.emit('modelChange', { model });
2411
2395
  }
2412
2396
  else {
2413
- console.warn(`Failed to restore backend: API key not found in environment variable ${state.apiKeyEnvVar}`);
2397
+ console.warn("Failed to restore backend: API key not found in environment.");
2414
2398
  }
2415
2399
  }
2416
2400
  catch (error) {