scorpion-cli 0.1.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/README.md +152 -0
- package/install.ps1 +22 -0
- package/install.sh +19 -0
- package/package.json +44 -0
- package/src/agent.js +226 -0
- package/src/commands.js +193 -0
- package/src/index.js +86 -0
- package/src/ollama.js +126 -0
- package/src/registry.js +112 -0
- package/src/tools/context.js +432 -0
- package/src/tools/deep-research.js +425 -0
- package/src/tools/documents.js +299 -0
- package/src/tools/filesystem.js +402 -0
- package/src/tools/shell.js +134 -0
- package/src/tools/system.js +379 -0
- package/src/tools/web.js +433 -0
- package/src/ui/charts.js +213 -0
- package/src/ui/export.js +141 -0
- package/src/ui/formatter.js +365 -0
- package/src/ui/images.js +153 -0
- package/src/ui/panels.js +150 -0
- package/src/ui/progress.js +131 -0
- package/src/ui/repl.js +477 -0
- package/src/ui/spinner.js +99 -0
- package/src/ui/table.js +145 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell Tools
|
|
3
|
+
* Execute PowerShell and CMD commands
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { exec } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
|
|
9
|
+
const execAsync = promisify(exec);
|
|
10
|
+
|
|
11
|
+
// Tool: run_command
|
|
12
|
+
export const runCommand = {
|
|
13
|
+
schema: {
|
|
14
|
+
type: 'function',
|
|
15
|
+
function: {
|
|
16
|
+
name: 'run_command',
|
|
17
|
+
description: 'Execute a shell command in PowerShell. Use this to run system commands, list files, check processes, etc.',
|
|
18
|
+
parameters: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
required: ['command'],
|
|
21
|
+
properties: {
|
|
22
|
+
command: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'The command to execute (e.g., "Get-Process", "dir", "systeminfo")'
|
|
25
|
+
},
|
|
26
|
+
timeout: {
|
|
27
|
+
type: 'integer',
|
|
28
|
+
description: 'Timeout in milliseconds (default: 30000)'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
execute: async ({ command, timeout = 30000 }) => {
|
|
35
|
+
try {
|
|
36
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
37
|
+
shell: 'powershell.exe',
|
|
38
|
+
timeout,
|
|
39
|
+
maxBuffer: 1024 * 1024 * 10, // 10MB buffer
|
|
40
|
+
windowsHide: true
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return JSON.stringify({
|
|
44
|
+
success: true,
|
|
45
|
+
stdout: stdout.trim(),
|
|
46
|
+
stderr: stderr.trim() || undefined
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return JSON.stringify({
|
|
50
|
+
success: false,
|
|
51
|
+
error: error.message,
|
|
52
|
+
stderr: error.stderr?.trim(),
|
|
53
|
+
code: error.code
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Tool: run_powershell_script
|
|
60
|
+
export const runPowershellScript = {
|
|
61
|
+
schema: {
|
|
62
|
+
type: 'function',
|
|
63
|
+
function: {
|
|
64
|
+
name: 'run_powershell_script',
|
|
65
|
+
description: 'Execute a multi-line PowerShell script. Use for complex operations that require multiple commands.',
|
|
66
|
+
parameters: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
required: ['script'],
|
|
69
|
+
properties: {
|
|
70
|
+
script: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
description: 'The PowerShell script to execute (can be multi-line)'
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
execute: async ({ script }) => {
|
|
79
|
+
try {
|
|
80
|
+
// Encode script to base64 to handle special characters
|
|
81
|
+
const encodedScript = Buffer.from(script, 'utf16le').toString('base64');
|
|
82
|
+
|
|
83
|
+
const { stdout, stderr } = await execAsync(
|
|
84
|
+
`powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodedScript}`,
|
|
85
|
+
{
|
|
86
|
+
timeout: 60000,
|
|
87
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
88
|
+
windowsHide: true
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return JSON.stringify({
|
|
93
|
+
success: true,
|
|
94
|
+
stdout: stdout.trim(),
|
|
95
|
+
stderr: stderr.trim() || undefined
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return JSON.stringify({
|
|
99
|
+
success: false,
|
|
100
|
+
error: error.message,
|
|
101
|
+
stderr: error.stderr?.trim()
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Tool: get_environment_variable
|
|
108
|
+
export const getEnvVariable = {
|
|
109
|
+
schema: {
|
|
110
|
+
type: 'function',
|
|
111
|
+
function: {
|
|
112
|
+
name: 'get_environment_variable',
|
|
113
|
+
description: 'Get the value of an environment variable',
|
|
114
|
+
parameters: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
required: ['name'],
|
|
117
|
+
properties: {
|
|
118
|
+
name: {
|
|
119
|
+
type: 'string',
|
|
120
|
+
description: 'Name of the environment variable (e.g., "PATH", "USERPROFILE")'
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
execute: async ({ name }) => {
|
|
127
|
+
const value = process.env[name];
|
|
128
|
+
return JSON.stringify({
|
|
129
|
+
name,
|
|
130
|
+
value: value || null,
|
|
131
|
+
exists: value !== undefined
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
};
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System Tools
|
|
3
|
+
* System monitoring and analysis
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import si from 'systeminformation';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
|
|
9
|
+
// Tool: get_cpu_usage
|
|
10
|
+
export const getCpuUsage = {
|
|
11
|
+
schema: {
|
|
12
|
+
type: 'function',
|
|
13
|
+
function: {
|
|
14
|
+
name: 'get_cpu_usage',
|
|
15
|
+
description: 'Get current CPU usage and information',
|
|
16
|
+
parameters: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
execute: async () => {
|
|
23
|
+
try {
|
|
24
|
+
const [cpu, load, speed] = await Promise.all([
|
|
25
|
+
si.cpu(),
|
|
26
|
+
si.currentLoad(),
|
|
27
|
+
si.cpuCurrentSpeed()
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
return JSON.stringify({
|
|
31
|
+
success: true,
|
|
32
|
+
model: `${cpu.manufacturer} ${cpu.brand}`,
|
|
33
|
+
cores: cpu.cores,
|
|
34
|
+
physicalCores: cpu.physicalCores,
|
|
35
|
+
speed: `${speed.avg.toFixed(2)} GHz`,
|
|
36
|
+
usage: {
|
|
37
|
+
total: `${load.currentLoad.toFixed(1)}%`,
|
|
38
|
+
user: `${load.currentLoadUser.toFixed(1)}%`,
|
|
39
|
+
system: `${load.currentLoadSystem.toFixed(1)}%`,
|
|
40
|
+
idle: `${load.currentLoadIdle.toFixed(1)}%`
|
|
41
|
+
},
|
|
42
|
+
perCore: load.cpus.map((c, i) => ({
|
|
43
|
+
core: i,
|
|
44
|
+
load: `${c.load.toFixed(1)}%`
|
|
45
|
+
}))
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return JSON.stringify({ success: false, error: error.message });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Tool: get_memory_usage
|
|
54
|
+
export const getMemoryUsage = {
|
|
55
|
+
schema: {
|
|
56
|
+
type: 'function',
|
|
57
|
+
function: {
|
|
58
|
+
name: 'get_memory_usage',
|
|
59
|
+
description: 'Get current memory (RAM) usage',
|
|
60
|
+
parameters: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
execute: async () => {
|
|
67
|
+
try {
|
|
68
|
+
const mem = await si.mem();
|
|
69
|
+
|
|
70
|
+
return JSON.stringify({
|
|
71
|
+
success: true,
|
|
72
|
+
total: formatBytes(mem.total),
|
|
73
|
+
used: formatBytes(mem.used),
|
|
74
|
+
free: formatBytes(mem.free),
|
|
75
|
+
available: formatBytes(mem.available),
|
|
76
|
+
usagePercent: `${((mem.used / mem.total) * 100).toFixed(1)}%`,
|
|
77
|
+
swap: {
|
|
78
|
+
total: formatBytes(mem.swaptotal),
|
|
79
|
+
used: formatBytes(mem.swapused),
|
|
80
|
+
free: formatBytes(mem.swapfree)
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return JSON.stringify({ success: false, error: error.message });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Tool: get_disk_usage
|
|
90
|
+
export const getDiskUsage = {
|
|
91
|
+
schema: {
|
|
92
|
+
type: 'function',
|
|
93
|
+
function: {
|
|
94
|
+
name: 'get_disk_usage',
|
|
95
|
+
description: 'Get disk/storage usage for all drives',
|
|
96
|
+
parameters: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
drive: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
description: 'Specific drive letter to check (e.g., "C:"). If not provided, shows all drives.'
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
execute: async ({ drive } = {}) => {
|
|
108
|
+
try {
|
|
109
|
+
const disks = await si.fsSize();
|
|
110
|
+
|
|
111
|
+
let filtered = disks;
|
|
112
|
+
if (drive) {
|
|
113
|
+
filtered = disks.filter(d =>
|
|
114
|
+
d.mount.toLowerCase().startsWith(drive.toLowerCase())
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return JSON.stringify({
|
|
119
|
+
success: true,
|
|
120
|
+
drives: filtered.map(d => ({
|
|
121
|
+
mount: d.mount,
|
|
122
|
+
type: d.type,
|
|
123
|
+
total: formatBytes(d.size),
|
|
124
|
+
used: formatBytes(d.used),
|
|
125
|
+
free: formatBytes(d.available),
|
|
126
|
+
usagePercent: `${d.use.toFixed(1)}%`
|
|
127
|
+
}))
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return JSON.stringify({ success: false, error: error.message });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Tool: get_running_processes
|
|
136
|
+
export const getRunningProcesses = {
|
|
137
|
+
schema: {
|
|
138
|
+
type: 'function',
|
|
139
|
+
function: {
|
|
140
|
+
name: 'get_running_processes',
|
|
141
|
+
description: 'List running processes sorted by CPU or memory usage',
|
|
142
|
+
parameters: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
sort_by: {
|
|
146
|
+
type: 'string',
|
|
147
|
+
enum: ['cpu', 'memory'],
|
|
148
|
+
description: 'Sort by CPU or memory usage (default: cpu)'
|
|
149
|
+
},
|
|
150
|
+
limit: {
|
|
151
|
+
type: 'integer',
|
|
152
|
+
description: 'Number of processes to return (default: 10)'
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
execute: async ({ sort_by = 'cpu', limit = 10 }) => {
|
|
159
|
+
try {
|
|
160
|
+
const processes = await si.processes();
|
|
161
|
+
|
|
162
|
+
const sorted = processes.list
|
|
163
|
+
.sort((a, b) => {
|
|
164
|
+
if (sort_by === 'memory') {
|
|
165
|
+
return b.memRss - a.memRss;
|
|
166
|
+
}
|
|
167
|
+
return b.cpu - a.cpu;
|
|
168
|
+
})
|
|
169
|
+
.slice(0, limit);
|
|
170
|
+
|
|
171
|
+
return JSON.stringify({
|
|
172
|
+
success: true,
|
|
173
|
+
totalProcesses: processes.all,
|
|
174
|
+
running: processes.running,
|
|
175
|
+
blocked: processes.blocked,
|
|
176
|
+
topProcesses: sorted.map(p => ({
|
|
177
|
+
pid: p.pid,
|
|
178
|
+
name: p.name,
|
|
179
|
+
cpu: `${p.cpu.toFixed(1)}%`,
|
|
180
|
+
memory: formatBytes(p.memRss),
|
|
181
|
+
memoryPercent: `${p.mem.toFixed(1)}%`,
|
|
182
|
+
state: p.state
|
|
183
|
+
}))
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return JSON.stringify({ success: false, error: error.message });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Tool: get_system_info
|
|
192
|
+
export const getSystemInfo = {
|
|
193
|
+
schema: {
|
|
194
|
+
type: 'function',
|
|
195
|
+
function: {
|
|
196
|
+
name: 'get_system_info',
|
|
197
|
+
description: 'Get comprehensive system information (OS, hardware, uptime)',
|
|
198
|
+
parameters: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
properties: {}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
execute: async () => {
|
|
205
|
+
try {
|
|
206
|
+
const [osInfo, system, bios, time] = await Promise.all([
|
|
207
|
+
si.osInfo(),
|
|
208
|
+
si.system(),
|
|
209
|
+
si.bios(),
|
|
210
|
+
si.time()
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
return JSON.stringify({
|
|
214
|
+
success: true,
|
|
215
|
+
os: {
|
|
216
|
+
platform: osInfo.platform,
|
|
217
|
+
distro: osInfo.distro,
|
|
218
|
+
release: osInfo.release,
|
|
219
|
+
arch: osInfo.arch,
|
|
220
|
+
hostname: osInfo.hostname
|
|
221
|
+
},
|
|
222
|
+
system: {
|
|
223
|
+
manufacturer: system.manufacturer,
|
|
224
|
+
model: system.model,
|
|
225
|
+
version: system.version
|
|
226
|
+
},
|
|
227
|
+
bios: {
|
|
228
|
+
vendor: bios.vendor,
|
|
229
|
+
version: bios.version
|
|
230
|
+
},
|
|
231
|
+
uptime: formatUptime(time.uptime),
|
|
232
|
+
currentTime: new Date().toISOString(),
|
|
233
|
+
timezone: time.timezone
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return JSON.stringify({ success: false, error: error.message });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Tool: analyze_performance
|
|
242
|
+
export const analyzePerformance = {
|
|
243
|
+
schema: {
|
|
244
|
+
type: 'function',
|
|
245
|
+
function: {
|
|
246
|
+
name: 'analyze_performance',
|
|
247
|
+
description: 'Get a comprehensive performance snapshot including CPU, memory, disk, and top processes',
|
|
248
|
+
parameters: {
|
|
249
|
+
type: 'object',
|
|
250
|
+
properties: {}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
execute: async () => {
|
|
255
|
+
try {
|
|
256
|
+
const [cpu, load, mem, disks, processes, osInfo, time] = await Promise.all([
|
|
257
|
+
si.cpu(),
|
|
258
|
+
si.currentLoad(),
|
|
259
|
+
si.mem(),
|
|
260
|
+
si.fsSize(),
|
|
261
|
+
si.processes(),
|
|
262
|
+
si.osInfo(),
|
|
263
|
+
si.time()
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
// Get top 5 processes by CPU
|
|
267
|
+
const topProcesses = processes.list
|
|
268
|
+
.sort((a, b) => b.cpu - a.cpu)
|
|
269
|
+
.slice(0, 5)
|
|
270
|
+
.map(p => ({
|
|
271
|
+
name: p.name,
|
|
272
|
+
cpu: `${p.cpu.toFixed(1)}%`,
|
|
273
|
+
memory: formatBytes(p.memRss)
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
return JSON.stringify({
|
|
277
|
+
success: true,
|
|
278
|
+
timestamp: new Date().toISOString(),
|
|
279
|
+
system: {
|
|
280
|
+
hostname: osInfo.hostname,
|
|
281
|
+
os: `${osInfo.distro} ${osInfo.release}`,
|
|
282
|
+
uptime: formatUptime(time.uptime)
|
|
283
|
+
},
|
|
284
|
+
cpu: {
|
|
285
|
+
model: `${cpu.manufacturer} ${cpu.brand}`,
|
|
286
|
+
cores: cpu.cores,
|
|
287
|
+
usage: `${load.currentLoad.toFixed(1)}%`
|
|
288
|
+
},
|
|
289
|
+
memory: {
|
|
290
|
+
total: formatBytes(mem.total),
|
|
291
|
+
used: formatBytes(mem.used),
|
|
292
|
+
free: formatBytes(mem.available),
|
|
293
|
+
usagePercent: `${((mem.used / mem.total) * 100).toFixed(1)}%`
|
|
294
|
+
},
|
|
295
|
+
storage: disks.map(d => ({
|
|
296
|
+
drive: d.mount,
|
|
297
|
+
total: formatBytes(d.size),
|
|
298
|
+
used: formatBytes(d.used),
|
|
299
|
+
free: formatBytes(d.available),
|
|
300
|
+
usagePercent: `${d.use.toFixed(1)}%`
|
|
301
|
+
})),
|
|
302
|
+
topProcesses,
|
|
303
|
+
processCount: processes.all
|
|
304
|
+
});
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return JSON.stringify({ success: false, error: error.message });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// Tool: get_network_info
|
|
312
|
+
export const getNetworkInfo = {
|
|
313
|
+
schema: {
|
|
314
|
+
type: 'function',
|
|
315
|
+
function: {
|
|
316
|
+
name: 'get_network_info',
|
|
317
|
+
description: 'Get network interfaces and connection information',
|
|
318
|
+
parameters: {
|
|
319
|
+
type: 'object',
|
|
320
|
+
properties: {}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
execute: async () => {
|
|
325
|
+
try {
|
|
326
|
+
const [interfaces, stats, defaultGateway] = await Promise.all([
|
|
327
|
+
si.networkInterfaces(),
|
|
328
|
+
si.networkStats(),
|
|
329
|
+
si.networkGatewayDefault()
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
return JSON.stringify({
|
|
333
|
+
success: true,
|
|
334
|
+
defaultGateway,
|
|
335
|
+
interfaces: interfaces
|
|
336
|
+
.filter(i => i.ip4)
|
|
337
|
+
.map(i => ({
|
|
338
|
+
name: i.iface,
|
|
339
|
+
type: i.type,
|
|
340
|
+
ip4: i.ip4,
|
|
341
|
+
ip6: i.ip6,
|
|
342
|
+
mac: i.mac,
|
|
343
|
+
speed: i.speed ? `${i.speed} Mbps` : 'Unknown'
|
|
344
|
+
})),
|
|
345
|
+
stats: stats.map(s => ({
|
|
346
|
+
interface: s.iface,
|
|
347
|
+
rxBytes: formatBytes(s.rx_bytes),
|
|
348
|
+
txBytes: formatBytes(s.tx_bytes),
|
|
349
|
+
rxSpeed: formatBytes(s.rx_sec) + '/s',
|
|
350
|
+
txSpeed: formatBytes(s.tx_sec) + '/s'
|
|
351
|
+
}))
|
|
352
|
+
});
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return JSON.stringify({ success: false, error: error.message });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// Helper functions
|
|
360
|
+
function formatBytes(bytes) {
|
|
361
|
+
if (!bytes || bytes === 0) return '0 B';
|
|
362
|
+
const k = 1024;
|
|
363
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
364
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
365
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function formatUptime(seconds) {
|
|
369
|
+
const days = Math.floor(seconds / 86400);
|
|
370
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
371
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
372
|
+
|
|
373
|
+
const parts = [];
|
|
374
|
+
if (days > 0) parts.push(`${days}d`);
|
|
375
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
376
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
377
|
+
|
|
378
|
+
return parts.join(' ') || '< 1m';
|
|
379
|
+
}
|