markov-cli 1.0.18 → 1.0.19

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": "markov-cli",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "description": "Markov CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -96,7 +96,15 @@ export async function runAgentLoop(messages, opts = {}) {
96
96
 
97
97
  if (onToolCall) onToolCall(name ?? 'unknown', args ?? {});
98
98
 
99
- const isFileEdit = name === 'search_replace';
99
+ const isFileEdit = [
100
+ 'search_replace',
101
+ 'drive_write_file',
102
+ 'drive_create_folder',
103
+ 'drive_rename',
104
+ 'drive_move',
105
+ 'drive_copy',
106
+ 'drive_delete',
107
+ ].includes(name);
100
108
  let result;
101
109
  if (isFileEdit && confirmFileEdit) {
102
110
  const ok = await confirmFileEdit(name, args ?? {});
@@ -93,10 +93,12 @@ export function buildAgentSystemMessage() {
93
93
  // `- create_folder: create directories.\n` +
94
94
  `- read_file: read file contents before editing.\n` +
95
95
  `- search_replace: replace first occurrence of text in a file (for small, targeted edits).\n` +
96
+ `- drive_list / drive_read_file / drive_write_file / drive_create_folder / drive_rename / drive_move / drive_copy / drive_delete: manage the logged-in user's remote Drive files through the backend API. These tools are scoped to the authenticated user's own Drive files only.\n` +
96
97
  // `- delete_file: delete a file.\n` +
97
98
  // `- list_dir: list directory contents (path optional, defaults to current dir).\n` +
98
99
  `- web_search: search the web for current information; use when the user asks about recent events, docs, or facts.\n` +
99
100
  `When the user asks to run commands, create/edit/delete files, scaffold projects, or to "plan" or "start" an app (e.g. npm run dev), you MUST call the appropriate tool. Do not only describe steps in your reply.\n` +
100
- `For file creation or overwriting, use run_terminal_command only (e.g. echo, cat, heredocs, tee). For small edits use search_replace. Never paste full file contents directly in your reply. All file operations must use RELATIVE paths. Do not output modified file contents in chat — apply changes through tool calls only.\n`;
101
+ `Use local tools for files in the current project folder. Use drive_* tools for the user's remote Drive files. Never try to access remote Drive content with run_terminal_command or local read_file.\n` +
102
+ `For local file creation or overwriting, use run_terminal_command only (e.g. echo, cat, heredocs, tee). For small local edits use search_replace. Never paste full file contents directly in your reply. Local file operations must use RELATIVE paths. Do not output modified file contents in chat — apply changes through tool calls only.\n`;
101
103
  return { role: 'system', content: getSystemMessageBase() + toolInstructions };
102
104
  }
@@ -0,0 +1,149 @@
1
+ import { API_URL, getToken } from './auth.js';
2
+
3
+ const DRIVE_API_URL = `${API_URL}/files/cli`;
4
+
5
+ function getAuthHeaders(extraHeaders = {}) {
6
+ const token = getToken();
7
+ if (!token) {
8
+ throw new Error('Not logged in. Use /login to authenticate first.');
9
+ }
10
+
11
+ return {
12
+ Authorization: `Bearer ${token}`,
13
+ ...extraHeaders,
14
+ };
15
+ }
16
+
17
+ async function parseResponse(res) {
18
+ if (res.status === 204) {
19
+ return null;
20
+ }
21
+
22
+ const text = await res.text();
23
+ if (!text) {
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ return JSON.parse(text);
29
+ } catch {
30
+ return text;
31
+ }
32
+ }
33
+
34
+ async function request(path, options = {}) {
35
+ const res = await fetch(`${DRIVE_API_URL}${path}`, {
36
+ ...options,
37
+ headers: getAuthHeaders({
38
+ 'Content-Type': 'application/json',
39
+ ...(options.headers ?? {}),
40
+ }),
41
+ });
42
+
43
+ const body = await parseResponse(res);
44
+ if (!res.ok) {
45
+ const message =
46
+ body?.error?.message ??
47
+ body?.message ??
48
+ (typeof body === 'string' ? body : `Drive API error (${res.status})`);
49
+ throw new Error(message);
50
+ }
51
+
52
+ return body?.data ?? body;
53
+ }
54
+
55
+ export async function listDrive(parentId = null) {
56
+ const query = parentId ? `?parentId=${encodeURIComponent(parentId)}` : '';
57
+ const data = await request(`${query}`, { method: 'GET' });
58
+ return {
59
+ entries: Array.isArray(data) ? data : [],
60
+ parentId: parentId ?? null,
61
+ };
62
+ }
63
+
64
+ export async function getDriveItem(fileId) {
65
+ if (!fileId) throw new Error('file_id is required');
66
+ return request(`/${encodeURIComponent(fileId)}`, { method: 'GET' });
67
+ }
68
+
69
+ export async function readDriveFile(fileId) {
70
+ if (!fileId) throw new Error('file_id is required');
71
+ const data = await request(`/${encodeURIComponent(fileId)}/content`, { method: 'GET' });
72
+ return {
73
+ file: data?.file ?? null,
74
+ content: data?.content ?? '',
75
+ };
76
+ }
77
+
78
+ export async function writeDriveFile({ fileId = null, parentId = null, name = null, content = '' } = {}) {
79
+ if (typeof content !== 'string') {
80
+ throw new Error('content must be a string');
81
+ }
82
+
83
+ if (fileId) {
84
+ const data = await request(`/${encodeURIComponent(fileId)}/content`, {
85
+ method: 'PATCH',
86
+ body: JSON.stringify({ content }),
87
+ });
88
+ return { action: 'updated', file: data?.file ?? null, content: data?.content ?? content };
89
+ }
90
+
91
+ if (!name || typeof name !== 'string' || !name.trim()) {
92
+ throw new Error('name is required when creating a new remote file');
93
+ }
94
+
95
+ const data = await request('/text', {
96
+ method: 'POST',
97
+ body: JSON.stringify({ name: name.trim(), parentId, content }),
98
+ });
99
+ return { action: 'created', file: data?.file ?? null, content: data?.content ?? content };
100
+ }
101
+
102
+ export async function createDriveFolder({ name, parentId = null } = {}) {
103
+ if (!name || typeof name !== 'string' || !name.trim()) {
104
+ throw new Error('name is required');
105
+ }
106
+
107
+ return request('/folders', {
108
+ method: 'POST',
109
+ body: JSON.stringify({ name: name.trim(), parentId }),
110
+ });
111
+ }
112
+
113
+ export async function renameDriveItem(fileId, name) {
114
+ if (!fileId) throw new Error('file_id is required');
115
+ if (!name || typeof name !== 'string' || !name.trim()) {
116
+ throw new Error('name is required');
117
+ }
118
+
119
+ return request(`/${encodeURIComponent(fileId)}/rename`, {
120
+ method: 'PATCH',
121
+ body: JSON.stringify({ name: name.trim() }),
122
+ });
123
+ }
124
+
125
+ export async function moveDriveItem(fileId, destinationId = null) {
126
+ if (!fileId) throw new Error('file_id is required');
127
+
128
+ return request(`/${encodeURIComponent(fileId)}/move`, {
129
+ method: 'PATCH',
130
+ body: JSON.stringify({ destinationId }),
131
+ });
132
+ }
133
+
134
+ export async function copyDriveItem(fileId, destinationId) {
135
+ if (!fileId) throw new Error('file_id is required');
136
+ if (!destinationId) throw new Error('destination_id is required');
137
+
138
+ return request(`/${encodeURIComponent(fileId)}/copy`, {
139
+ method: 'POST',
140
+ body: JSON.stringify({ destinationId }),
141
+ });
142
+ }
143
+
144
+ export async function deleteDriveItem(fileId) {
145
+ if (!fileId) throw new Error('file_id is required');
146
+
147
+ await request(`/${encodeURIComponent(fileId)}`, { method: 'DELETE' });
148
+ return { success: true, fileId };
149
+ }
package/src/tools.js CHANGED
@@ -3,6 +3,16 @@ import { promisify } from 'util';
3
3
  import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, readdirSync, statSync } from 'fs';
4
4
  import { resolve, dirname } from 'path';
5
5
  import { chatWithTools } from './ollama.js';
6
+ import {
7
+ listDrive,
8
+ readDriveFile,
9
+ writeDriveFile,
10
+ createDriveFolder,
11
+ renameDriveItem,
12
+ moveDriveItem,
13
+ copyDriveItem,
14
+ deleteDriveItem,
15
+ } from './remoteDrive.js';
6
16
 
7
17
  const execAsync = promisify(exec);
8
18
 
@@ -118,6 +128,186 @@ export const SEARCH_REPLACE_TOOL = {
118
128
  },
119
129
  };
120
130
 
131
+ /** Ollama-format tool definition for listing authenticated remote-drive contents */
132
+ export const DRIVE_LIST_TOOL = {
133
+ type: 'function',
134
+ function: {
135
+ name: 'drive_list',
136
+ description: 'List the authenticated user\'s remote-drive files and folders. Omit parent_id to list the user\'s remote root.',
137
+ parameters: {
138
+ type: 'object',
139
+ required: [],
140
+ properties: {
141
+ parent_id: {
142
+ type: 'string',
143
+ description: 'Optional remote folder ID to list.',
144
+ },
145
+ },
146
+ },
147
+ },
148
+ };
149
+
150
+ /** Ollama-format tool definition for reading a text file from the authenticated user drive */
151
+ export const DRIVE_READ_FILE_TOOL = {
152
+ type: 'function',
153
+ function: {
154
+ name: 'drive_read_file',
155
+ description: 'Read a text file from the authenticated user\'s remote drive by file ID.',
156
+ parameters: {
157
+ type: 'object',
158
+ required: ['file_id'],
159
+ properties: {
160
+ file_id: {
161
+ type: 'string',
162
+ description: 'Remote file ID to read.',
163
+ },
164
+ },
165
+ },
166
+ },
167
+ };
168
+
169
+ /** Ollama-format tool definition for creating or overwriting a text file in the authenticated user drive */
170
+ export const DRIVE_WRITE_FILE_TOOL = {
171
+ type: 'function',
172
+ function: {
173
+ name: 'drive_write_file',
174
+ description: 'Create a new text file or overwrite an existing text file in the authenticated user\'s remote drive. To update, pass file_id and content. To create, pass name, optional parent_id, and content.',
175
+ parameters: {
176
+ type: 'object',
177
+ required: ['content'],
178
+ properties: {
179
+ file_id: {
180
+ type: 'string',
181
+ description: 'Existing remote file ID to overwrite.',
182
+ },
183
+ parent_id: {
184
+ type: 'string',
185
+ description: 'Optional remote folder ID for a newly created file.',
186
+ },
187
+ name: {
188
+ type: 'string',
189
+ description: 'File name for a newly created file.',
190
+ },
191
+ content: {
192
+ type: 'string',
193
+ description: 'Full text file content.',
194
+ },
195
+ },
196
+ },
197
+ },
198
+ };
199
+
200
+ /** Ollama-format tool definition for creating a remote folder */
201
+ export const DRIVE_CREATE_FOLDER_TOOL = {
202
+ type: 'function',
203
+ function: {
204
+ name: 'drive_create_folder',
205
+ description: 'Create a folder in the authenticated user\'s remote drive.',
206
+ parameters: {
207
+ type: 'object',
208
+ required: ['name'],
209
+ properties: {
210
+ name: {
211
+ type: 'string',
212
+ description: 'Folder name to create.',
213
+ },
214
+ parent_id: {
215
+ type: 'string',
216
+ description: 'Optional parent remote folder ID.',
217
+ },
218
+ },
219
+ },
220
+ },
221
+ };
222
+
223
+ /** Ollama-format tool definition for renaming a remote item */
224
+ export const DRIVE_RENAME_TOOL = {
225
+ type: 'function',
226
+ function: {
227
+ name: 'drive_rename',
228
+ description: 'Rename a file or folder in the authenticated user\'s remote drive.',
229
+ parameters: {
230
+ type: 'object',
231
+ required: ['file_id', 'name'],
232
+ properties: {
233
+ file_id: {
234
+ type: 'string',
235
+ description: 'Remote file or folder ID.',
236
+ },
237
+ name: {
238
+ type: 'string',
239
+ description: 'New file or folder name.',
240
+ },
241
+ },
242
+ },
243
+ },
244
+ };
245
+
246
+ /** Ollama-format tool definition for moving a remote item */
247
+ export const DRIVE_MOVE_TOOL = {
248
+ type: 'function',
249
+ function: {
250
+ name: 'drive_move',
251
+ description: 'Move a file or folder in the authenticated user\'s remote drive. Omit destination_id to move it to the remote root.',
252
+ parameters: {
253
+ type: 'object',
254
+ required: ['file_id'],
255
+ properties: {
256
+ file_id: {
257
+ type: 'string',
258
+ description: 'Remote file or folder ID to move.',
259
+ },
260
+ destination_id: {
261
+ type: 'string',
262
+ description: 'Optional destination folder ID.',
263
+ },
264
+ },
265
+ },
266
+ },
267
+ };
268
+
269
+ /** Ollama-format tool definition for copying a remote item */
270
+ export const DRIVE_COPY_TOOL = {
271
+ type: 'function',
272
+ function: {
273
+ name: 'drive_copy',
274
+ description: 'Copy a file or folder into another folder in the authenticated user\'s remote drive.',
275
+ parameters: {
276
+ type: 'object',
277
+ required: ['file_id', 'destination_id'],
278
+ properties: {
279
+ file_id: {
280
+ type: 'string',
281
+ description: 'Remote file or folder ID to copy.',
282
+ },
283
+ destination_id: {
284
+ type: 'string',
285
+ description: 'Destination folder ID.',
286
+ },
287
+ },
288
+ },
289
+ },
290
+ };
291
+
292
+ /** Ollama-format tool definition for deleting a remote item */
293
+ export const DRIVE_DELETE_TOOL = {
294
+ type: 'function',
295
+ function: {
296
+ name: 'drive_delete',
297
+ description: 'Delete a file or folder from the authenticated user\'s remote drive.',
298
+ parameters: {
299
+ type: 'object',
300
+ required: ['file_id'],
301
+ properties: {
302
+ file_id: {
303
+ type: 'string',
304
+ description: 'Remote file or folder ID to delete.',
305
+ },
306
+ },
307
+ },
308
+ },
309
+ };
310
+
121
311
  /** Ollama-format tool definition for deleting a file */
122
312
  // export const DELETE_FILE_TOOL = {
123
313
  // type: 'function',
@@ -209,6 +399,14 @@ export const AGENT_TOOLS = [
209
399
  READ_FILE_TOOL,
210
400
  // WRITE_FILE_TOOL, // disabled: model uses run_terminal_command for file creation/overwrite
211
401
  SEARCH_REPLACE_TOOL,
402
+ DRIVE_LIST_TOOL,
403
+ DRIVE_READ_FILE_TOOL,
404
+ DRIVE_WRITE_FILE_TOOL,
405
+ DRIVE_CREATE_FOLDER_TOOL,
406
+ DRIVE_RENAME_TOOL,
407
+ DRIVE_MOVE_TOOL,
408
+ DRIVE_COPY_TOOL,
409
+ DRIVE_DELETE_TOOL,
212
410
  // DELETE_FILE_TOOL,
213
411
  // LIST_DIR_TOOL,
214
412
  WEB_SEARCH_TOOL,
@@ -280,6 +478,85 @@ const TOOLS_MAP = {
280
478
  return { success: false, error: err.message };
281
479
  }
282
480
  },
481
+ drive_list: async (args) => {
482
+ try {
483
+ const parentId = typeof args.parent_id === 'string' && args.parent_id.trim() ? args.parent_id.trim() : null;
484
+ const data = await listDrive(parentId);
485
+ return { success: true, parent_id: data.parentId, entries: data.entries };
486
+ } catch (err) {
487
+ return { success: false, error: err.message };
488
+ }
489
+ },
490
+ drive_read_file: async (args) => {
491
+ try {
492
+ const fileId = typeof args.file_id === 'string' ? args.file_id.trim() : '';
493
+ const data = await readDriveFile(fileId);
494
+ return { success: true, file: data.file, content: data.content };
495
+ } catch (err) {
496
+ return { success: false, error: err.message };
497
+ }
498
+ },
499
+ drive_write_file: async (args) => {
500
+ try {
501
+ const fileId = typeof args.file_id === 'string' && args.file_id.trim() ? args.file_id.trim() : null;
502
+ const parentId = typeof args.parent_id === 'string' && args.parent_id.trim() ? args.parent_id.trim() : null;
503
+ const name = typeof args.name === 'string' && args.name.trim() ? args.name.trim() : null;
504
+ const content = typeof args.content === 'string' ? args.content : String(args?.content ?? '');
505
+ const data = await writeDriveFile({ fileId, parentId, name, content });
506
+ return { success: true, action: data.action, file: data.file, content: data.content };
507
+ } catch (err) {
508
+ return { success: false, error: err.message };
509
+ }
510
+ },
511
+ drive_create_folder: async (args) => {
512
+ try {
513
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
514
+ const parentId = typeof args.parent_id === 'string' && args.parent_id.trim() ? args.parent_id.trim() : null;
515
+ const folder = await createDriveFolder({ name, parentId });
516
+ return { success: true, folder };
517
+ } catch (err) {
518
+ return { success: false, error: err.message };
519
+ }
520
+ },
521
+ drive_rename: async (args) => {
522
+ try {
523
+ const fileId = typeof args.file_id === 'string' ? args.file_id.trim() : '';
524
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
525
+ const file = await renameDriveItem(fileId, name);
526
+ return { success: true, file };
527
+ } catch (err) {
528
+ return { success: false, error: err.message };
529
+ }
530
+ },
531
+ drive_move: async (args) => {
532
+ try {
533
+ const fileId = typeof args.file_id === 'string' ? args.file_id.trim() : '';
534
+ const destinationId = typeof args.destination_id === 'string' && args.destination_id.trim() ? args.destination_id.trim() : null;
535
+ const file = await moveDriveItem(fileId, destinationId);
536
+ return { success: true, file };
537
+ } catch (err) {
538
+ return { success: false, error: err.message };
539
+ }
540
+ },
541
+ drive_copy: async (args) => {
542
+ try {
543
+ const fileId = typeof args.file_id === 'string' ? args.file_id.trim() : '';
544
+ const destinationId = typeof args.destination_id === 'string' ? args.destination_id.trim() : '';
545
+ const file = await copyDriveItem(fileId, destinationId);
546
+ return { success: true, file };
547
+ } catch (err) {
548
+ return { success: false, error: err.message };
549
+ }
550
+ },
551
+ drive_delete: async (args) => {
552
+ try {
553
+ const fileId = typeof args.file_id === 'string' ? args.file_id.trim() : '';
554
+ const result = await deleteDriveItem(fileId);
555
+ return { success: true, file_id: result.fileId };
556
+ } catch (err) {
557
+ return { success: false, error: err.message };
558
+ }
559
+ },
283
560
  delete_file: async (args, opts = {}) => {
284
561
  const { path: relPath, absPath } = getPath(args, opts);
285
562
  if (!relPath) return { success: false, error: 'Error: empty path' };
@@ -85,6 +85,30 @@ export function formatFileEditPreview(name, args) {
85
85
  chalk.green(' + ' + (newPreview || '(empty)').replace(/\n/g, '\n '))
86
86
  );
87
87
  }
88
+ if (name === 'drive_write_file') {
89
+ const target = args?.file_id ? `remote file ${args.file_id}` : `new remote file ${args?.name ?? '(no name)'}`;
90
+ const content = String(args?.content ?? '');
91
+ const preview = content.length > 120 ? content.slice(0, 120) + '…' : content;
92
+ return (
93
+ chalk.cyan(target) + '\n' +
94
+ chalk.green(' + ' + (preview || '(empty)').replace(/\n/g, '\n '))
95
+ );
96
+ }
97
+ if (name === 'drive_create_folder') {
98
+ return chalk.cyan(`remote folder ${args?.name ?? '(no name)'}`) + (args?.parent_id ? chalk.dim(` in ${args.parent_id}`) : '');
99
+ }
100
+ if (name === 'drive_rename') {
101
+ return chalk.cyan(`remote item ${args?.file_id ?? '(no id)'}`) + '\n' + chalk.green(` + ${args?.name ?? '(no name)'}`);
102
+ }
103
+ if (name === 'drive_move') {
104
+ return chalk.cyan(`remote item ${args?.file_id ?? '(no id)'}`) + '\n' + chalk.green(` -> ${args?.destination_id ?? '(remote root)'}`);
105
+ }
106
+ if (name === 'drive_copy') {
107
+ return chalk.cyan(`remote item ${args?.file_id ?? '(no id)'}`) + '\n' + chalk.green(` copy -> ${args?.destination_id ?? '(no destination)'}`);
108
+ }
109
+ if (name === 'drive_delete') {
110
+ return chalk.red(`remote item ${args?.file_id ?? '(no id)'}`);
111
+ }
88
112
  return path;
89
113
  }
90
114
 
@@ -93,6 +117,14 @@ export function formatToolCallSummary(name, args) {
93
117
  const a = args ?? {};
94
118
  if (name === 'run_terminal_command') return (a.command ?? '').trim() || '(empty)';
95
119
  if (name === 'search_replace') return (a.path ?? '') + (a.old_string ? ` "${String(a.old_string).slice(0, 30)}…"` : '');
120
+ if (name === 'drive_list') return a.parent_id ?? '(remote root)';
121
+ if (name === 'drive_read_file') return a.file_id ?? '(no file_id)';
122
+ if (name === 'drive_write_file') return a.file_id ?? a.name ?? '(remote write)';
123
+ if (name === 'drive_create_folder') return a.name ?? '(no folder name)';
124
+ if (name === 'drive_rename') return `${a.file_id ?? '(no file_id)'} -> ${a.name ?? '(no name)'}`;
125
+ if (name === 'drive_move') return `${a.file_id ?? '(no file_id)'} -> ${a.destination_id ?? '(remote root)'}`;
126
+ if (name === 'drive_copy') return `${a.file_id ?? '(no file_id)'} -> ${a.destination_id ?? '(no destination)'}`;
127
+ if (name === 'drive_delete') return a.file_id ?? '(no file_id)';
96
128
  if (name === 'read_file' || name === 'delete_file') return a.path ?? '(no path)';
97
129
  if (name === 'create_folder') return a.path ?? '(no path)';
98
130
  if (name === 'list_dir') return (a.path ?? '.') || '.';
@@ -118,6 +150,17 @@ export function formatToolResultSummary(name, resultJson) {
118
150
  if (name === 'search_replace' || name === 'delete_file' || name === 'create_folder') {
119
151
  return obj.success !== false ? chalk.green('✓ ' + (obj.path ? obj.path : 'ok')) : chalk.red('✗ ' + (obj.error || 'failed'));
120
152
  }
153
+ if (name === 'drive_list') {
154
+ return obj.entries ? chalk.green(`✓ ${obj.entries.length ?? 0} remote entries`) : chalk.red('✗ ' + (obj.error || ''));
155
+ }
156
+ if (name === 'drive_read_file') {
157
+ const fileName = obj.file?.name ?? obj.file?.id ?? 'remote file';
158
+ return obj.content != null ? chalk.green(`✓ ${fileName}`) + chalk.dim(` (${String(obj.content).length} chars)`) : chalk.red('✗ ' + (obj.error || ''));
159
+ }
160
+ if (['drive_write_file', 'drive_create_folder', 'drive_rename', 'drive_move', 'drive_copy', 'drive_delete'].includes(name)) {
161
+ const label = obj.file?.name ?? obj.folder?.name ?? obj.file_id ?? obj.action ?? 'ok';
162
+ return obj.success !== false ? chalk.green(`✓ ${label}`) : chalk.red('✗ ' + (obj.error || 'failed'));
163
+ }
121
164
  if (name === 'read_file') return obj.content != null ? chalk.green('✓ ' + (obj.path ?? '') + chalk.dim(` (${String(obj.content).length} chars)`)) : chalk.red('✗ ' + (obj.error || ''));
122
165
  if (name === 'list_dir') return obj.entries ? chalk.green('✓ ' + (obj.entries.length ?? 0) + ' entries') : chalk.red('✗ ' + (obj.error || ''));
123
166
  if (name === 'web_search') return obj.error ? chalk.red('✗ ' + obj.error) : (obj.results ? chalk.green('✓ ' + (obj.results.length ?? 0) + ' results') : chalk.dim('—'));