@lobehub/cli 0.0.1-canary.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/dist/index.js +1170 -0
- package/package.json +38 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/connect.ts
|
|
7
|
+
import os3 from "os";
|
|
8
|
+
import path3 from "path";
|
|
9
|
+
|
|
10
|
+
// ../../packages/device-gateway-client/src/client.ts
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
import { EventEmitter } from "events";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import WebSocket from "ws";
|
|
15
|
+
var DEFAULT_GATEWAY_URL = "https://device-gateway.lobehub.com";
|
|
16
|
+
var HEARTBEAT_INTERVAL = 3e4;
|
|
17
|
+
var INITIAL_RECONNECT_DELAY = 1e3;
|
|
18
|
+
var MAX_RECONNECT_DELAY = 3e4;
|
|
19
|
+
var noopLogger = {
|
|
20
|
+
debug: () => {
|
|
21
|
+
},
|
|
22
|
+
error: () => {
|
|
23
|
+
},
|
|
24
|
+
info: () => {
|
|
25
|
+
},
|
|
26
|
+
warn: () => {
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var GatewayClient = class extends EventEmitter {
|
|
30
|
+
ws = null;
|
|
31
|
+
heartbeatTimer = null;
|
|
32
|
+
reconnectTimer = null;
|
|
33
|
+
reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
34
|
+
status = "disconnected";
|
|
35
|
+
intentionalDisconnect = false;
|
|
36
|
+
deviceId;
|
|
37
|
+
gatewayUrl;
|
|
38
|
+
token;
|
|
39
|
+
userId;
|
|
40
|
+
logger;
|
|
41
|
+
autoReconnect;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
super();
|
|
44
|
+
this.token = options.token;
|
|
45
|
+
this.gatewayUrl = options.gatewayUrl || DEFAULT_GATEWAY_URL;
|
|
46
|
+
this.deviceId = options.deviceId || randomUUID();
|
|
47
|
+
this.userId = options.userId;
|
|
48
|
+
this.logger = options.logger || noopLogger;
|
|
49
|
+
this.autoReconnect = options.autoReconnect ?? true;
|
|
50
|
+
}
|
|
51
|
+
// ─── Public API ───
|
|
52
|
+
get connectionStatus() {
|
|
53
|
+
return this.status;
|
|
54
|
+
}
|
|
55
|
+
get currentDeviceId() {
|
|
56
|
+
return this.deviceId;
|
|
57
|
+
}
|
|
58
|
+
on(event, listener) {
|
|
59
|
+
return super.on(event, listener);
|
|
60
|
+
}
|
|
61
|
+
emit(event, ...args) {
|
|
62
|
+
return super.emit(event, ...args);
|
|
63
|
+
}
|
|
64
|
+
async connect() {
|
|
65
|
+
if (this.status === "connected" || this.status === "connecting") {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.intentionalDisconnect = false;
|
|
69
|
+
this.doConnect();
|
|
70
|
+
}
|
|
71
|
+
async disconnect() {
|
|
72
|
+
this.intentionalDisconnect = true;
|
|
73
|
+
this.cleanup();
|
|
74
|
+
this.setStatus("disconnected");
|
|
75
|
+
}
|
|
76
|
+
sendToolCallResponse(response) {
|
|
77
|
+
this.sendMessage({
|
|
78
|
+
...response,
|
|
79
|
+
type: "tool_call_response"
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
sendSystemInfoResponse(response) {
|
|
83
|
+
this.sendMessage({
|
|
84
|
+
...response,
|
|
85
|
+
type: "system_info_response"
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// ─── Connection Logic ───
|
|
89
|
+
doConnect() {
|
|
90
|
+
this.clearReconnectTimer();
|
|
91
|
+
this.setStatus("connecting");
|
|
92
|
+
try {
|
|
93
|
+
const wsUrl = this.buildWsUrl();
|
|
94
|
+
this.logger.debug(`Connecting to: ${wsUrl}`);
|
|
95
|
+
const ws = new WebSocket(wsUrl);
|
|
96
|
+
ws.on("open", this.handleOpen);
|
|
97
|
+
ws.on("message", this.handleMessage);
|
|
98
|
+
ws.on("close", this.handleClose);
|
|
99
|
+
ws.on("error", this.handleError);
|
|
100
|
+
this.ws = ws;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
103
|
+
this.logger.error("Failed to create WebSocket:", msg);
|
|
104
|
+
this.setStatus("disconnected");
|
|
105
|
+
if (this.autoReconnect) {
|
|
106
|
+
this.scheduleReconnect();
|
|
107
|
+
} else {
|
|
108
|
+
this.emit("disconnected");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
buildWsUrl() {
|
|
113
|
+
const wsProtocol = this.gatewayUrl.startsWith("https") ? "wss" : "ws";
|
|
114
|
+
const host = this.gatewayUrl.replace(/^https?:\/\//, "");
|
|
115
|
+
const params = new URLSearchParams({
|
|
116
|
+
deviceId: this.deviceId,
|
|
117
|
+
hostname: os.hostname(),
|
|
118
|
+
platform: process.platform
|
|
119
|
+
});
|
|
120
|
+
if (this.userId) {
|
|
121
|
+
params.set("userId", this.userId);
|
|
122
|
+
}
|
|
123
|
+
return `${wsProtocol}://${host}/ws?${params.toString()}`;
|
|
124
|
+
}
|
|
125
|
+
// ─── WebSocket Event Handlers ───
|
|
126
|
+
handleOpen = () => {
|
|
127
|
+
this.logger.info("WebSocket connected, sending auth...");
|
|
128
|
+
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
129
|
+
this.setStatus("authenticating");
|
|
130
|
+
this.sendMessage({ type: "auth", token: this.token });
|
|
131
|
+
};
|
|
132
|
+
handleMessage = (data) => {
|
|
133
|
+
try {
|
|
134
|
+
const message = JSON.parse(String(data));
|
|
135
|
+
switch (message.type) {
|
|
136
|
+
case "auth_success": {
|
|
137
|
+
this.logger.info("Authentication successful");
|
|
138
|
+
this.setStatus("connected");
|
|
139
|
+
this.startHeartbeat();
|
|
140
|
+
this.emit("connected");
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "auth_failed": {
|
|
144
|
+
const reason = message.reason || "Unknown reason";
|
|
145
|
+
this.logger.error(`Authentication failed: ${reason}`);
|
|
146
|
+
this.emit("auth_failed", reason);
|
|
147
|
+
this.disconnect();
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case "heartbeat_ack": {
|
|
151
|
+
this.emit("heartbeat_ack");
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case "tool_call_request": {
|
|
155
|
+
this.emit("tool_call_request", message);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case "system_info_request": {
|
|
159
|
+
this.emit("system_info_request", message);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "auth_expired": {
|
|
163
|
+
this.logger.warn("Received auth_expired from gateway");
|
|
164
|
+
this.emit("auth_expired");
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
default: {
|
|
168
|
+
this.logger.warn("Unknown message type:", message.type);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
this.logger.error("Failed to parse WebSocket message:", error);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
handleClose = (code, reason) => {
|
|
176
|
+
this.logger.info(`WebSocket closed: code=${code} reason=${reason.toString()}`);
|
|
177
|
+
this.stopHeartbeat();
|
|
178
|
+
this.ws = null;
|
|
179
|
+
if (!this.intentionalDisconnect && this.autoReconnect) {
|
|
180
|
+
this.setStatus("reconnecting");
|
|
181
|
+
this.scheduleReconnect();
|
|
182
|
+
} else {
|
|
183
|
+
this.setStatus("disconnected");
|
|
184
|
+
this.emit("disconnected");
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
handleError = (error) => {
|
|
188
|
+
this.logger.error("WebSocket error:", error.message);
|
|
189
|
+
this.emit("error", error);
|
|
190
|
+
};
|
|
191
|
+
// ─── Heartbeat ───
|
|
192
|
+
startHeartbeat() {
|
|
193
|
+
this.stopHeartbeat();
|
|
194
|
+
this.heartbeatTimer = setInterval(() => {
|
|
195
|
+
this.sendMessage({ type: "heartbeat" });
|
|
196
|
+
}, HEARTBEAT_INTERVAL);
|
|
197
|
+
}
|
|
198
|
+
stopHeartbeat() {
|
|
199
|
+
if (this.heartbeatTimer) {
|
|
200
|
+
clearInterval(this.heartbeatTimer);
|
|
201
|
+
this.heartbeatTimer = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ─── Reconnection (exponential backoff) ───
|
|
205
|
+
scheduleReconnect() {
|
|
206
|
+
this.clearReconnectTimer();
|
|
207
|
+
const delay = this.reconnectDelay;
|
|
208
|
+
this.logger.info(`Scheduling reconnect in ${delay}ms`);
|
|
209
|
+
this.emit("reconnecting", delay);
|
|
210
|
+
this.reconnectTimer = setTimeout(() => {
|
|
211
|
+
this.reconnectTimer = null;
|
|
212
|
+
this.logger.info("Attempting reconnect");
|
|
213
|
+
this.doConnect();
|
|
214
|
+
}, delay);
|
|
215
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
216
|
+
}
|
|
217
|
+
clearReconnectTimer() {
|
|
218
|
+
if (this.reconnectTimer) {
|
|
219
|
+
clearTimeout(this.reconnectTimer);
|
|
220
|
+
this.reconnectTimer = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// ─── Status ───
|
|
224
|
+
setStatus(status) {
|
|
225
|
+
if (this.status === status) return;
|
|
226
|
+
this.status = status;
|
|
227
|
+
this.emit("status_changed", status);
|
|
228
|
+
}
|
|
229
|
+
// ─── Helpers ───
|
|
230
|
+
sendMessage(data) {
|
|
231
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
232
|
+
this.ws.send(JSON.stringify(data));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
closeWebSocket() {
|
|
236
|
+
if (this.ws) {
|
|
237
|
+
this.ws.removeAllListeners();
|
|
238
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
239
|
+
this.ws.close(1e3, "Client disconnect");
|
|
240
|
+
}
|
|
241
|
+
this.ws = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
cleanup() {
|
|
245
|
+
this.stopHeartbeat();
|
|
246
|
+
this.clearReconnectTimer();
|
|
247
|
+
this.closeWebSocket();
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// src/utils/logger.ts
|
|
252
|
+
import pc from "picocolors";
|
|
253
|
+
var verbose = false;
|
|
254
|
+
var setVerbose = (v) => {
|
|
255
|
+
verbose = v;
|
|
256
|
+
};
|
|
257
|
+
var timestamp = () => {
|
|
258
|
+
const now = /* @__PURE__ */ new Date();
|
|
259
|
+
return pc.dim(
|
|
260
|
+
`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`
|
|
261
|
+
);
|
|
262
|
+
};
|
|
263
|
+
var log = {
|
|
264
|
+
debug: (msg, ...args) => {
|
|
265
|
+
if (verbose) {
|
|
266
|
+
console.log(`${timestamp()} ${pc.dim("[DEBUG]")} ${msg}`, ...args);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
error: (msg, ...args) => {
|
|
270
|
+
console.error(`${timestamp()} ${pc.red("[ERROR]")} ${pc.red(msg)}`, ...args);
|
|
271
|
+
},
|
|
272
|
+
heartbeat: () => {
|
|
273
|
+
if (verbose) {
|
|
274
|
+
process.stdout.write(pc.dim("."));
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
info: (msg, ...args) => {
|
|
278
|
+
console.log(`${timestamp()} ${pc.blue("[INFO]")} ${msg}`, ...args);
|
|
279
|
+
},
|
|
280
|
+
status: (status) => {
|
|
281
|
+
const color = status === "connected" ? pc.green : status === "disconnected" ? pc.red : pc.yellow;
|
|
282
|
+
console.log(`${timestamp()} ${pc.bold("[STATUS]")} ${color(status)}`);
|
|
283
|
+
},
|
|
284
|
+
toolCall: (apiName, requestId, args) => {
|
|
285
|
+
console.log(
|
|
286
|
+
`${timestamp()} ${pc.magenta("[TOOL]")} ${pc.bold(apiName)} ${pc.dim(`(${requestId})`)}`
|
|
287
|
+
);
|
|
288
|
+
if (args && verbose) {
|
|
289
|
+
console.log(` ${pc.dim(args)}`);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
toolResult: (requestId, success, content) => {
|
|
293
|
+
const icon = success ? pc.green("OK") : pc.red("FAIL");
|
|
294
|
+
console.log(`${timestamp()} ${pc.magenta("[RESULT]")} ${icon} ${pc.dim(`(${requestId})`)}`);
|
|
295
|
+
if (content && verbose) {
|
|
296
|
+
const preview = content.length > 200 ? content.slice(0, 200) + "..." : content;
|
|
297
|
+
console.log(` ${pc.dim(preview)}`);
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
warn: (msg, ...args) => {
|
|
301
|
+
console.warn(`${timestamp()} ${pc.yellow("[WARN]")} ${pc.yellow(msg)}`, ...args);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// src/auth/credentials.ts
|
|
306
|
+
import crypto from "crypto";
|
|
307
|
+
import fs from "fs";
|
|
308
|
+
import os2 from "os";
|
|
309
|
+
import path from "path";
|
|
310
|
+
var CREDENTIALS_DIR = path.join(os2.homedir(), ".lobehub");
|
|
311
|
+
var CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
|
|
312
|
+
function deriveKey() {
|
|
313
|
+
const material = `lobehub-cli:${os2.hostname()}:${os2.userInfo().username}`;
|
|
314
|
+
return crypto.pbkdf2Sync(material, "lobehub-cli-salt", 1e5, 32, "sha256");
|
|
315
|
+
}
|
|
316
|
+
function encrypt(plaintext) {
|
|
317
|
+
const key = deriveKey();
|
|
318
|
+
const iv = crypto.randomBytes(12);
|
|
319
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
320
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
321
|
+
const authTag = cipher.getAuthTag();
|
|
322
|
+
const packed = Buffer.concat([iv, authTag, encrypted]);
|
|
323
|
+
return packed.toString("base64");
|
|
324
|
+
}
|
|
325
|
+
function decrypt(encoded) {
|
|
326
|
+
const key = deriveKey();
|
|
327
|
+
const packed = Buffer.from(encoded, "base64");
|
|
328
|
+
const iv = packed.subarray(0, 12);
|
|
329
|
+
const authTag = packed.subarray(12, 28);
|
|
330
|
+
const ciphertext = packed.subarray(28);
|
|
331
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
332
|
+
decipher.setAuthTag(authTag);
|
|
333
|
+
return decipher.update(ciphertext) + decipher.final("utf8");
|
|
334
|
+
}
|
|
335
|
+
function saveCredentials(credentials) {
|
|
336
|
+
fs.mkdirSync(CREDENTIALS_DIR, { mode: 448, recursive: true });
|
|
337
|
+
const encrypted = encrypt(JSON.stringify(credentials));
|
|
338
|
+
fs.writeFileSync(CREDENTIALS_FILE, encrypted, { mode: 384 });
|
|
339
|
+
}
|
|
340
|
+
function loadCredentials() {
|
|
341
|
+
try {
|
|
342
|
+
const data = fs.readFileSync(CREDENTIALS_FILE, "utf8");
|
|
343
|
+
try {
|
|
344
|
+
const decrypted = decrypt(data);
|
|
345
|
+
return JSON.parse(decrypted);
|
|
346
|
+
} catch {
|
|
347
|
+
const credentials = JSON.parse(data);
|
|
348
|
+
saveCredentials(credentials);
|
|
349
|
+
return credentials;
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function clearCredentials() {
|
|
356
|
+
try {
|
|
357
|
+
fs.unlinkSync(CREDENTIALS_FILE);
|
|
358
|
+
return true;
|
|
359
|
+
} catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/auth/refresh.ts
|
|
365
|
+
var CLIENT_ID = "lobehub-cli";
|
|
366
|
+
async function getValidToken() {
|
|
367
|
+
const credentials = loadCredentials();
|
|
368
|
+
if (!credentials) return null;
|
|
369
|
+
if (credentials.expiresAt && Date.now() / 1e3 < credentials.expiresAt - 60) {
|
|
370
|
+
return { credentials };
|
|
371
|
+
}
|
|
372
|
+
if (!credentials.refreshToken) return null;
|
|
373
|
+
const refreshed = await refreshAccessToken(credentials.serverUrl, credentials.refreshToken);
|
|
374
|
+
if (!refreshed) return null;
|
|
375
|
+
const updated = {
|
|
376
|
+
accessToken: refreshed.access_token,
|
|
377
|
+
expiresAt: refreshed.expires_in ? Math.floor(Date.now() / 1e3) + refreshed.expires_in : void 0,
|
|
378
|
+
refreshToken: refreshed.refresh_token || credentials.refreshToken,
|
|
379
|
+
serverUrl: credentials.serverUrl
|
|
380
|
+
};
|
|
381
|
+
saveCredentials(updated);
|
|
382
|
+
return { credentials: updated };
|
|
383
|
+
}
|
|
384
|
+
async function refreshAccessToken(serverUrl, refreshToken) {
|
|
385
|
+
try {
|
|
386
|
+
const res = await fetch(`${serverUrl}/oidc/token`, {
|
|
387
|
+
body: new URLSearchParams({
|
|
388
|
+
client_id: CLIENT_ID,
|
|
389
|
+
grant_type: "refresh_token",
|
|
390
|
+
refresh_token: refreshToken
|
|
391
|
+
}),
|
|
392
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
393
|
+
method: "POST"
|
|
394
|
+
});
|
|
395
|
+
const body = await res.json();
|
|
396
|
+
if (!res.ok || body.error || !body.access_token) return null;
|
|
397
|
+
return body;
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/auth/resolveToken.ts
|
|
404
|
+
function parseJwtSub(token) {
|
|
405
|
+
try {
|
|
406
|
+
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
|
|
407
|
+
return payload.sub;
|
|
408
|
+
} catch {
|
|
409
|
+
return void 0;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async function resolveToken(options) {
|
|
413
|
+
if (options.token) {
|
|
414
|
+
const userId = parseJwtSub(options.token);
|
|
415
|
+
if (!userId) {
|
|
416
|
+
log.error("Could not extract userId from token. Provide --user-id explicitly.");
|
|
417
|
+
process.exit(1);
|
|
418
|
+
}
|
|
419
|
+
return { token: options.token, userId };
|
|
420
|
+
}
|
|
421
|
+
if (options.serviceToken) {
|
|
422
|
+
if (!options.userId) {
|
|
423
|
+
log.error("--user-id is required when using --service-token");
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
return { token: options.serviceToken, userId: options.userId };
|
|
427
|
+
}
|
|
428
|
+
const result = await getValidToken();
|
|
429
|
+
if (result) {
|
|
430
|
+
log.debug("Using stored credentials");
|
|
431
|
+
const token = result.credentials.accessToken;
|
|
432
|
+
const userId = parseJwtSub(token);
|
|
433
|
+
if (!userId) {
|
|
434
|
+
log.error("Stored token is invalid. Run 'lh login' again.");
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
return { token, userId };
|
|
438
|
+
}
|
|
439
|
+
log.error("No authentication found. Run 'lh login' first, or provide --token.");
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/tools/file.ts
|
|
444
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "fs/promises";
|
|
445
|
+
import path2 from "path";
|
|
446
|
+
import { createPatch } from "diff";
|
|
447
|
+
import fg from "fast-glob";
|
|
448
|
+
async function readLocalFile({ path: filePath, loc, fullContent }) {
|
|
449
|
+
const effectiveLoc = fullContent ? void 0 : loc ?? [0, 200];
|
|
450
|
+
log.debug(`Reading file: ${filePath}, loc=${JSON.stringify(effectiveLoc)}`);
|
|
451
|
+
try {
|
|
452
|
+
const content = await readFile(filePath, "utf8");
|
|
453
|
+
const lines = content.split("\n");
|
|
454
|
+
const totalLineCount = lines.length;
|
|
455
|
+
const totalCharCount = content.length;
|
|
456
|
+
let selectedContent;
|
|
457
|
+
let lineCount;
|
|
458
|
+
let actualLoc;
|
|
459
|
+
if (effectiveLoc === void 0) {
|
|
460
|
+
selectedContent = content;
|
|
461
|
+
lineCount = totalLineCount;
|
|
462
|
+
actualLoc = [0, totalLineCount];
|
|
463
|
+
} else {
|
|
464
|
+
const [startLine, endLine] = effectiveLoc;
|
|
465
|
+
const selectedLines = lines.slice(startLine, endLine);
|
|
466
|
+
selectedContent = selectedLines.join("\n");
|
|
467
|
+
lineCount = selectedLines.length;
|
|
468
|
+
actualLoc = effectiveLoc;
|
|
469
|
+
}
|
|
470
|
+
const fileStat = await stat(filePath);
|
|
471
|
+
return {
|
|
472
|
+
charCount: selectedContent.length,
|
|
473
|
+
content: selectedContent,
|
|
474
|
+
createdTime: fileStat.birthtime,
|
|
475
|
+
fileType: path2.extname(filePath).toLowerCase().replace(".", "") || "unknown",
|
|
476
|
+
filename: path2.basename(filePath),
|
|
477
|
+
lineCount,
|
|
478
|
+
loc: actualLoc,
|
|
479
|
+
modifiedTime: fileStat.mtime,
|
|
480
|
+
totalCharCount,
|
|
481
|
+
totalLineCount
|
|
482
|
+
};
|
|
483
|
+
} catch (error) {
|
|
484
|
+
const errorMessage = error.message;
|
|
485
|
+
return {
|
|
486
|
+
charCount: 0,
|
|
487
|
+
content: `Error accessing or processing file: ${errorMessage}`,
|
|
488
|
+
createdTime: /* @__PURE__ */ new Date(),
|
|
489
|
+
fileType: path2.extname(filePath).toLowerCase().replace(".", "") || "unknown",
|
|
490
|
+
filename: path2.basename(filePath),
|
|
491
|
+
lineCount: 0,
|
|
492
|
+
loc: [0, 0],
|
|
493
|
+
modifiedTime: /* @__PURE__ */ new Date(),
|
|
494
|
+
totalCharCount: 0,
|
|
495
|
+
totalLineCount: 0
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function writeLocalFile({ path: filePath, content }) {
|
|
500
|
+
if (!filePath) return { error: "Path cannot be empty", success: false };
|
|
501
|
+
if (content === void 0) return { error: "Content cannot be empty", success: false };
|
|
502
|
+
try {
|
|
503
|
+
const dirname = path2.dirname(filePath);
|
|
504
|
+
await mkdir(dirname, { recursive: true });
|
|
505
|
+
await writeFile(filePath, content, "utf8");
|
|
506
|
+
log.debug(`File written: ${filePath} (${content.length} chars)`);
|
|
507
|
+
return { success: true };
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return { error: `Failed to write file: ${error.message}`, success: false };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async function editLocalFile({
|
|
513
|
+
file_path: filePath,
|
|
514
|
+
old_string,
|
|
515
|
+
new_string,
|
|
516
|
+
replace_all = false
|
|
517
|
+
}) {
|
|
518
|
+
try {
|
|
519
|
+
const content = await readFile(filePath, "utf8");
|
|
520
|
+
if (!content.includes(old_string)) {
|
|
521
|
+
return {
|
|
522
|
+
error: "The specified old_string was not found in the file",
|
|
523
|
+
replacements: 0,
|
|
524
|
+
success: false
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
let newContent;
|
|
528
|
+
let replacements;
|
|
529
|
+
if (replace_all) {
|
|
530
|
+
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&"), "g");
|
|
531
|
+
const matches = content.match(regex);
|
|
532
|
+
replacements = matches ? matches.length : 0;
|
|
533
|
+
newContent = content.replaceAll(old_string, new_string);
|
|
534
|
+
} else {
|
|
535
|
+
const index = content.indexOf(old_string);
|
|
536
|
+
if (index === -1) {
|
|
537
|
+
return { error: "Old string not found", replacements: 0, success: false };
|
|
538
|
+
}
|
|
539
|
+
newContent = content.slice(0, index) + new_string + content.slice(index + old_string.length);
|
|
540
|
+
replacements = 1;
|
|
541
|
+
}
|
|
542
|
+
await writeFile(filePath, newContent, "utf8");
|
|
543
|
+
const patch = createPatch(filePath, content, newContent, "", "");
|
|
544
|
+
const diffText = `diff --git a${filePath} b${filePath}
|
|
545
|
+
${patch}`;
|
|
546
|
+
const patchLines = patch.split("\n");
|
|
547
|
+
let linesAdded = 0;
|
|
548
|
+
let linesDeleted = 0;
|
|
549
|
+
for (const line of patchLines) {
|
|
550
|
+
if (line.startsWith("+") && !line.startsWith("+++")) linesAdded++;
|
|
551
|
+
else if (line.startsWith("-") && !line.startsWith("---")) linesDeleted++;
|
|
552
|
+
}
|
|
553
|
+
return { diffText, linesAdded, linesDeleted, replacements, success: true };
|
|
554
|
+
} catch (error) {
|
|
555
|
+
return { error: error.message, replacements: 0, success: false };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async function listLocalFiles({
|
|
559
|
+
path: dirPath,
|
|
560
|
+
sortBy = "modifiedTime",
|
|
561
|
+
sortOrder = "desc",
|
|
562
|
+
limit = 100
|
|
563
|
+
}) {
|
|
564
|
+
try {
|
|
565
|
+
const entries = await readdir(dirPath);
|
|
566
|
+
const results = [];
|
|
567
|
+
for (const entry of entries) {
|
|
568
|
+
const fullPath = path2.join(dirPath, entry);
|
|
569
|
+
try {
|
|
570
|
+
const stats = await stat(fullPath);
|
|
571
|
+
const isDirectory = stats.isDirectory();
|
|
572
|
+
results.push({
|
|
573
|
+
createdTime: stats.birthtime,
|
|
574
|
+
isDirectory,
|
|
575
|
+
lastAccessTime: stats.atime,
|
|
576
|
+
modifiedTime: stats.mtime,
|
|
577
|
+
name: entry,
|
|
578
|
+
path: fullPath,
|
|
579
|
+
size: stats.size,
|
|
580
|
+
type: isDirectory ? "directory" : path2.extname(entry).toLowerCase().replace(".", "")
|
|
581
|
+
});
|
|
582
|
+
} catch {
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
results.sort((a, b) => {
|
|
586
|
+
let comparison;
|
|
587
|
+
switch (sortBy) {
|
|
588
|
+
case "name": {
|
|
589
|
+
comparison = (a.name || "").localeCompare(b.name || "");
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
case "modifiedTime": {
|
|
593
|
+
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
case "createdTime": {
|
|
597
|
+
comparison = a.createdTime.getTime() - b.createdTime.getTime();
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
case "size": {
|
|
601
|
+
comparison = a.size - b.size;
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
default: {
|
|
605
|
+
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return sortOrder === "desc" ? -comparison : comparison;
|
|
609
|
+
});
|
|
610
|
+
const totalCount = results.length;
|
|
611
|
+
return { files: results.slice(0, limit), totalCount };
|
|
612
|
+
} catch (error) {
|
|
613
|
+
log.error(`Failed to list directory ${dirPath}:`, error);
|
|
614
|
+
return { files: [], totalCount: 0 };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async function globLocalFiles({ pattern, cwd }) {
|
|
618
|
+
try {
|
|
619
|
+
const files = await fg(pattern, {
|
|
620
|
+
cwd: cwd || process.cwd(),
|
|
621
|
+
dot: false,
|
|
622
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
623
|
+
});
|
|
624
|
+
return { files };
|
|
625
|
+
} catch (error) {
|
|
626
|
+
return { error: error.message, files: [] };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function grepContent({ pattern, cwd, filePattern }) {
|
|
630
|
+
const { spawn: spawn2 } = await import("child_process");
|
|
631
|
+
return new Promise((resolve) => {
|
|
632
|
+
const args = ["--json", "-n"];
|
|
633
|
+
if (filePattern) args.push("--glob", filePattern);
|
|
634
|
+
args.push(pattern);
|
|
635
|
+
const child = spawn2("rg", args, { cwd: cwd || process.cwd() });
|
|
636
|
+
let stdout = "";
|
|
637
|
+
child.stdout?.on("data", (data) => {
|
|
638
|
+
stdout += data.toString();
|
|
639
|
+
});
|
|
640
|
+
child.stderr?.on("data", () => {
|
|
641
|
+
});
|
|
642
|
+
child.on("close", (code) => {
|
|
643
|
+
if (code !== 0 && code !== 1) {
|
|
644
|
+
log.debug("rg not available, falling back to simple search");
|
|
645
|
+
resolve({ matches: [], success: false });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
const matches = stdout.split("\n").filter(Boolean).map((line) => {
|
|
650
|
+
try {
|
|
651
|
+
return JSON.parse(line);
|
|
652
|
+
} catch {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
}).filter(Boolean);
|
|
656
|
+
resolve({ matches, success: true });
|
|
657
|
+
} catch {
|
|
658
|
+
resolve({ matches: [], success: true });
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
child.on("error", () => {
|
|
662
|
+
log.debug("rg not available");
|
|
663
|
+
resolve({ matches: [], success: false });
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
async function searchLocalFiles({
|
|
668
|
+
keywords,
|
|
669
|
+
directory,
|
|
670
|
+
contentContains,
|
|
671
|
+
limit = 30
|
|
672
|
+
}) {
|
|
673
|
+
try {
|
|
674
|
+
const cwd = directory || process.cwd();
|
|
675
|
+
const files = await fg(`**/*${keywords}*`, {
|
|
676
|
+
cwd,
|
|
677
|
+
dot: false,
|
|
678
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
679
|
+
});
|
|
680
|
+
let results = files.map((f) => ({ name: path2.basename(f), path: path2.join(cwd, f) }));
|
|
681
|
+
if (contentContains) {
|
|
682
|
+
const filtered = [];
|
|
683
|
+
for (const file of results) {
|
|
684
|
+
try {
|
|
685
|
+
const content = await readFile(file.path, "utf8");
|
|
686
|
+
if (content.includes(contentContains)) {
|
|
687
|
+
filtered.push(file);
|
|
688
|
+
}
|
|
689
|
+
} catch {
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
results = filtered;
|
|
693
|
+
}
|
|
694
|
+
return results.slice(0, limit);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
log.error("File search failed:", error);
|
|
697
|
+
return [];
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/tools/shell.ts
|
|
702
|
+
import { spawn } from "child_process";
|
|
703
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
704
|
+
var MAX_OUTPUT_LENGTH = 8e4;
|
|
705
|
+
var ANSI_REGEX = (
|
|
706
|
+
// eslint-disable-next-line no-control-regex
|
|
707
|
+
/\u001B(?:[\u0040-\u005A\u005C-\u005F]|\[[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E])/g
|
|
708
|
+
);
|
|
709
|
+
var stripAnsi = (str) => str.replaceAll(ANSI_REGEX, "");
|
|
710
|
+
var truncateOutput = (str, maxLength = MAX_OUTPUT_LENGTH) => {
|
|
711
|
+
const cleaned = stripAnsi(str);
|
|
712
|
+
if (cleaned.length <= maxLength) return cleaned;
|
|
713
|
+
return cleaned.slice(0, maxLength) + "\n... [truncated, " + (cleaned.length - maxLength) + " more characters]";
|
|
714
|
+
};
|
|
715
|
+
var shellProcesses = /* @__PURE__ */ new Map();
|
|
716
|
+
function cleanupAllProcesses() {
|
|
717
|
+
for (const [id, sp] of shellProcesses) {
|
|
718
|
+
try {
|
|
719
|
+
sp.process.kill();
|
|
720
|
+
} catch {
|
|
721
|
+
}
|
|
722
|
+
shellProcesses.delete(id);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async function runCommand({
|
|
726
|
+
command,
|
|
727
|
+
description,
|
|
728
|
+
run_in_background,
|
|
729
|
+
timeout = 12e4
|
|
730
|
+
}) {
|
|
731
|
+
const logPrefix = `[runCommand: ${description || command.slice(0, 50)}]`;
|
|
732
|
+
log.debug(`${logPrefix} Starting`, { background: run_in_background, timeout });
|
|
733
|
+
const effectiveTimeout = Math.min(Math.max(timeout, 1e3), 6e5);
|
|
734
|
+
const shellConfig = process.platform === "win32" ? { args: ["/c", command], cmd: "cmd.exe" } : { args: ["-c", command], cmd: "/bin/sh" };
|
|
735
|
+
try {
|
|
736
|
+
if (run_in_background) {
|
|
737
|
+
const shellId = randomUUID2();
|
|
738
|
+
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
|
|
739
|
+
env: process.env,
|
|
740
|
+
shell: false
|
|
741
|
+
});
|
|
742
|
+
const shellProcess = {
|
|
743
|
+
lastReadStderr: 0,
|
|
744
|
+
lastReadStdout: 0,
|
|
745
|
+
process: childProcess,
|
|
746
|
+
stderr: [],
|
|
747
|
+
stdout: []
|
|
748
|
+
};
|
|
749
|
+
childProcess.stdout?.on("data", (data) => {
|
|
750
|
+
shellProcess.stdout.push(data.toString());
|
|
751
|
+
});
|
|
752
|
+
childProcess.stderr?.on("data", (data) => {
|
|
753
|
+
shellProcess.stderr.push(data.toString());
|
|
754
|
+
});
|
|
755
|
+
childProcess.on("exit", (code) => {
|
|
756
|
+
log.debug(`${logPrefix} Background process exited`, { code, shellId });
|
|
757
|
+
});
|
|
758
|
+
shellProcesses.set(shellId, shellProcess);
|
|
759
|
+
log.debug(`${logPrefix} Started background`, { shellId });
|
|
760
|
+
return { shell_id: shellId, success: true };
|
|
761
|
+
} else {
|
|
762
|
+
return new Promise((resolve) => {
|
|
763
|
+
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
|
|
764
|
+
env: process.env,
|
|
765
|
+
shell: false
|
|
766
|
+
});
|
|
767
|
+
let stdout = "";
|
|
768
|
+
let stderr = "";
|
|
769
|
+
let killed = false;
|
|
770
|
+
const timeoutHandle = setTimeout(() => {
|
|
771
|
+
killed = true;
|
|
772
|
+
childProcess.kill();
|
|
773
|
+
resolve({
|
|
774
|
+
error: `Command timed out after ${effectiveTimeout}ms`,
|
|
775
|
+
stderr: truncateOutput(stderr),
|
|
776
|
+
stdout: truncateOutput(stdout),
|
|
777
|
+
success: false
|
|
778
|
+
});
|
|
779
|
+
}, effectiveTimeout);
|
|
780
|
+
childProcess.stdout?.on("data", (data) => {
|
|
781
|
+
stdout += data.toString();
|
|
782
|
+
});
|
|
783
|
+
childProcess.stderr?.on("data", (data) => {
|
|
784
|
+
stderr += data.toString();
|
|
785
|
+
});
|
|
786
|
+
childProcess.on("exit", (code) => {
|
|
787
|
+
if (!killed) {
|
|
788
|
+
clearTimeout(timeoutHandle);
|
|
789
|
+
const success = code === 0;
|
|
790
|
+
resolve({
|
|
791
|
+
exit_code: code || 0,
|
|
792
|
+
output: truncateOutput(stdout + stderr),
|
|
793
|
+
stderr: truncateOutput(stderr),
|
|
794
|
+
stdout: truncateOutput(stdout),
|
|
795
|
+
success
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
childProcess.on("error", (error) => {
|
|
800
|
+
clearTimeout(timeoutHandle);
|
|
801
|
+
resolve({
|
|
802
|
+
error: error.message,
|
|
803
|
+
stderr: truncateOutput(stderr),
|
|
804
|
+
stdout: truncateOutput(stdout),
|
|
805
|
+
success: false
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
} catch (error) {
|
|
811
|
+
return { error: error.message, success: false };
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
async function getCommandOutput({ shell_id, filter }) {
|
|
815
|
+
const shellProcess = shellProcesses.get(shell_id);
|
|
816
|
+
if (!shellProcess) {
|
|
817
|
+
return {
|
|
818
|
+
error: `Shell ID ${shell_id} not found`,
|
|
819
|
+
output: "",
|
|
820
|
+
running: false,
|
|
821
|
+
stderr: "",
|
|
822
|
+
stdout: "",
|
|
823
|
+
success: false
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
const { lastReadStderr, lastReadStdout, process: childProcess, stderr, stdout } = shellProcess;
|
|
827
|
+
const newStdout = stdout.slice(lastReadStdout).join("");
|
|
828
|
+
const newStderr = stderr.slice(lastReadStderr).join("");
|
|
829
|
+
let output = newStdout + newStderr;
|
|
830
|
+
if (filter) {
|
|
831
|
+
try {
|
|
832
|
+
const regex = new RegExp(filter, "gm");
|
|
833
|
+
const lines = output.split("\n");
|
|
834
|
+
output = lines.filter((line) => regex.test(line)).join("\n");
|
|
835
|
+
} catch {
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
shellProcess.lastReadStdout = stdout.length;
|
|
839
|
+
shellProcess.lastReadStderr = stderr.length;
|
|
840
|
+
const running = childProcess.exitCode === null;
|
|
841
|
+
return {
|
|
842
|
+
output: truncateOutput(output),
|
|
843
|
+
running,
|
|
844
|
+
stderr: truncateOutput(newStderr),
|
|
845
|
+
stdout: truncateOutput(newStdout),
|
|
846
|
+
success: true
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
async function killCommand({ shell_id }) {
|
|
850
|
+
const shellProcess = shellProcesses.get(shell_id);
|
|
851
|
+
if (!shellProcess) {
|
|
852
|
+
return { error: `Shell ID ${shell_id} not found`, success: false };
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
shellProcess.process.kill();
|
|
856
|
+
shellProcesses.delete(shell_id);
|
|
857
|
+
return { success: true };
|
|
858
|
+
} catch (error) {
|
|
859
|
+
return { error: error.message, success: false };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/tools/index.ts
|
|
864
|
+
var methodMap = {
|
|
865
|
+
editLocalFile,
|
|
866
|
+
getCommandOutput,
|
|
867
|
+
globLocalFiles,
|
|
868
|
+
grepContent,
|
|
869
|
+
killCommand,
|
|
870
|
+
listLocalFiles,
|
|
871
|
+
readLocalFile,
|
|
872
|
+
runCommand,
|
|
873
|
+
searchLocalFiles,
|
|
874
|
+
writeLocalFile
|
|
875
|
+
};
|
|
876
|
+
async function executeToolCall(apiName, argsStr) {
|
|
877
|
+
const handler = methodMap[apiName];
|
|
878
|
+
if (!handler) {
|
|
879
|
+
return { content: "", error: `Unknown tool API: ${apiName}`, success: false };
|
|
880
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
const args = JSON.parse(argsStr);
|
|
883
|
+
const result = await handler(args);
|
|
884
|
+
const content = typeof result === "string" ? result : JSON.stringify(result);
|
|
885
|
+
return { content, success: true };
|
|
886
|
+
} catch (error) {
|
|
887
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
888
|
+
log.error(`Tool call failed: ${apiName} - ${errorMsg}`);
|
|
889
|
+
return { content: "", error: errorMsg, success: false };
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// src/commands/connect.ts
|
|
894
|
+
function registerConnectCommand(program2) {
|
|
895
|
+
program2.command("connect").description("Connect to the device gateway and listen for tool calls").option("--token <jwt>", "JWT access token").option("--service-token <token>", "Service token (requires --user-id)").option("--user-id <id>", "User ID (required with --service-token)").option("--gateway <url>", "Gateway URL", "https://device-gateway.lobehub.com").option("--device-id <id>", "Device ID (auto-generated if not provided)").option("-v, --verbose", "Enable verbose logging").action(async (options) => {
|
|
896
|
+
if (options.verbose) setVerbose(true);
|
|
897
|
+
const auth = await resolveToken(options);
|
|
898
|
+
const client = new GatewayClient({
|
|
899
|
+
deviceId: options.deviceId,
|
|
900
|
+
gatewayUrl: options.gateway,
|
|
901
|
+
logger: log,
|
|
902
|
+
token: auth.token,
|
|
903
|
+
userId: auth.userId
|
|
904
|
+
});
|
|
905
|
+
log.info("\u2500\u2500\u2500 LobeHub CLI \u2500\u2500\u2500");
|
|
906
|
+
log.info(` Device ID : ${client.currentDeviceId}`);
|
|
907
|
+
log.info(` Hostname : ${os3.hostname()}`);
|
|
908
|
+
log.info(` Platform : ${process.platform}`);
|
|
909
|
+
log.info(` Gateway : ${options.gateway || "https://device-gateway.lobehub.com"}`);
|
|
910
|
+
log.info(` Auth : ${options.serviceToken ? "service-token" : "jwt"}`);
|
|
911
|
+
log.info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
912
|
+
client.on("system_info_request", (request) => {
|
|
913
|
+
log.info(`Received system_info_request: requestId=${request.requestId}`);
|
|
914
|
+
const systemInfo = collectSystemInfo();
|
|
915
|
+
client.sendSystemInfoResponse({
|
|
916
|
+
requestId: request.requestId,
|
|
917
|
+
result: { success: true, systemInfo }
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
client.on("tool_call_request", async (request) => {
|
|
921
|
+
const { requestId, toolCall } = request;
|
|
922
|
+
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
|
|
923
|
+
const result = await executeToolCall(toolCall.apiName, toolCall.arguments);
|
|
924
|
+
log.toolResult(requestId, result.success, result.content);
|
|
925
|
+
client.sendToolCallResponse({
|
|
926
|
+
requestId,
|
|
927
|
+
result: {
|
|
928
|
+
content: result.content,
|
|
929
|
+
error: result.error,
|
|
930
|
+
success: result.success
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
client.on("auth_failed", (reason) => {
|
|
935
|
+
log.error(`Authentication failed: ${reason}`);
|
|
936
|
+
log.error("Run 'lh login' to re-authenticate.");
|
|
937
|
+
cleanup();
|
|
938
|
+
process.exit(1);
|
|
939
|
+
});
|
|
940
|
+
client.on("auth_expired", async () => {
|
|
941
|
+
log.warn("Authentication expired. Attempting to refresh...");
|
|
942
|
+
const refreshed = await resolveToken({});
|
|
943
|
+
if (refreshed) {
|
|
944
|
+
log.info("Token refreshed. Please reconnect.");
|
|
945
|
+
} else {
|
|
946
|
+
log.error("Could not refresh token. Run 'lh login' to re-authenticate.");
|
|
947
|
+
}
|
|
948
|
+
cleanup();
|
|
949
|
+
process.exit(1);
|
|
950
|
+
});
|
|
951
|
+
client.on("error", (error) => {
|
|
952
|
+
log.error(`Connection error: ${error.message}`);
|
|
953
|
+
});
|
|
954
|
+
const cleanup = () => {
|
|
955
|
+
log.info("Shutting down...");
|
|
956
|
+
cleanupAllProcesses();
|
|
957
|
+
client.disconnect();
|
|
958
|
+
};
|
|
959
|
+
process.on("SIGINT", () => {
|
|
960
|
+
cleanup();
|
|
961
|
+
process.exit(0);
|
|
962
|
+
});
|
|
963
|
+
process.on("SIGTERM", () => {
|
|
964
|
+
cleanup();
|
|
965
|
+
process.exit(0);
|
|
966
|
+
});
|
|
967
|
+
await client.connect();
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
function collectSystemInfo() {
|
|
971
|
+
const home = os3.homedir();
|
|
972
|
+
const platform = process.platform;
|
|
973
|
+
const videosDir = platform === "linux" ? "Videos" : "Movies";
|
|
974
|
+
return {
|
|
975
|
+
arch: os3.arch(),
|
|
976
|
+
desktopPath: path3.join(home, "Desktop"),
|
|
977
|
+
documentsPath: path3.join(home, "Documents"),
|
|
978
|
+
downloadsPath: path3.join(home, "Downloads"),
|
|
979
|
+
homePath: home,
|
|
980
|
+
musicPath: path3.join(home, "Music"),
|
|
981
|
+
picturesPath: path3.join(home, "Pictures"),
|
|
982
|
+
userDataPath: path3.join(home, ".lobehub"),
|
|
983
|
+
videosPath: path3.join(home, videosDir),
|
|
984
|
+
workingDirectory: process.cwd()
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/commands/login.ts
|
|
989
|
+
import { execFile } from "child_process";
|
|
990
|
+
var CLIENT_ID2 = "lobehub-cli";
|
|
991
|
+
var SCOPES = "openid profile email offline_access";
|
|
992
|
+
function registerLoginCommand(program2) {
|
|
993
|
+
program2.command("login").description("Log in to LobeHub via browser (Device Code Flow)").option("--server <url>", "LobeHub server URL", "https://app.lobehub.com").action(async (options) => {
|
|
994
|
+
const serverUrl = options.server.replace(/\/$/, "");
|
|
995
|
+
log.info("Starting login...");
|
|
996
|
+
let deviceAuth;
|
|
997
|
+
try {
|
|
998
|
+
const res = await fetch(`${serverUrl}/oidc/device/auth`, {
|
|
999
|
+
body: new URLSearchParams({
|
|
1000
|
+
client_id: CLIENT_ID2,
|
|
1001
|
+
resource: "urn:lobehub:chat",
|
|
1002
|
+
scope: SCOPES
|
|
1003
|
+
}),
|
|
1004
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1005
|
+
method: "POST"
|
|
1006
|
+
});
|
|
1007
|
+
if (!res.ok) {
|
|
1008
|
+
const text = await res.text();
|
|
1009
|
+
log.error(`Failed to start device authorization: ${res.status} ${text}`);
|
|
1010
|
+
process.exit(1);
|
|
1011
|
+
}
|
|
1012
|
+
deviceAuth = await res.json();
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
log.error(`Failed to reach server: ${error.message}`);
|
|
1015
|
+
log.error(`Make sure ${serverUrl} is reachable.`);
|
|
1016
|
+
process.exit(1);
|
|
1017
|
+
}
|
|
1018
|
+
const verifyUrl = deviceAuth.verification_uri_complete || deviceAuth.verification_uri;
|
|
1019
|
+
log.info("");
|
|
1020
|
+
log.info(" Open this URL in your browser:");
|
|
1021
|
+
log.info(` ${verifyUrl}`);
|
|
1022
|
+
log.info("");
|
|
1023
|
+
log.info(` Enter code: ${deviceAuth.user_code}`);
|
|
1024
|
+
log.info("");
|
|
1025
|
+
openBrowser(verifyUrl);
|
|
1026
|
+
log.info("Waiting for authorization...");
|
|
1027
|
+
const interval = (deviceAuth.interval || 5) * 1e3;
|
|
1028
|
+
const expiresAt = Date.now() + deviceAuth.expires_in * 1e3;
|
|
1029
|
+
let pollInterval = interval;
|
|
1030
|
+
while (Date.now() < expiresAt) {
|
|
1031
|
+
await sleep(pollInterval);
|
|
1032
|
+
try {
|
|
1033
|
+
const res = await fetch(`${serverUrl}/oidc/token`, {
|
|
1034
|
+
body: new URLSearchParams({
|
|
1035
|
+
client_id: CLIENT_ID2,
|
|
1036
|
+
device_code: deviceAuth.device_code,
|
|
1037
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
1038
|
+
}),
|
|
1039
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1040
|
+
method: "POST"
|
|
1041
|
+
});
|
|
1042
|
+
const body = await res.json();
|
|
1043
|
+
if (body.error) {
|
|
1044
|
+
switch (body.error) {
|
|
1045
|
+
case "authorization_pending": {
|
|
1046
|
+
break;
|
|
1047
|
+
}
|
|
1048
|
+
case "slow_down": {
|
|
1049
|
+
pollInterval += 5e3;
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
1052
|
+
case "access_denied": {
|
|
1053
|
+
log.error("Authorization denied by user.");
|
|
1054
|
+
process.exit(1);
|
|
1055
|
+
break;
|
|
1056
|
+
}
|
|
1057
|
+
case "expired_token": {
|
|
1058
|
+
log.error("Device code expired. Please run login again.");
|
|
1059
|
+
process.exit(1);
|
|
1060
|
+
break;
|
|
1061
|
+
}
|
|
1062
|
+
default: {
|
|
1063
|
+
log.error(`Authorization error: ${body.error} - ${body.error_description || ""}`);
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
} else if (body.access_token) {
|
|
1068
|
+
saveCredentials({
|
|
1069
|
+
accessToken: body.access_token,
|
|
1070
|
+
expiresAt: body.expires_in ? Math.floor(Date.now() / 1e3) + body.expires_in : void 0,
|
|
1071
|
+
refreshToken: body.refresh_token,
|
|
1072
|
+
serverUrl
|
|
1073
|
+
});
|
|
1074
|
+
log.info("Login successful! Credentials saved.");
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
} catch {
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
log.error("Device code expired. Please run login again.");
|
|
1081
|
+
process.exit(1);
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
function sleep(ms) {
|
|
1085
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1086
|
+
}
|
|
1087
|
+
function openBrowser(url) {
|
|
1088
|
+
if (process.platform === "win32") {
|
|
1089
|
+
execFile("rundll32", ["url.dll,FileProtocolHandler", url], (err) => {
|
|
1090
|
+
if (err) {
|
|
1091
|
+
log.debug(`Could not open browser automatically: ${err.message}`);
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
} else {
|
|
1095
|
+
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
1096
|
+
execFile(cmd, [url], (err) => {
|
|
1097
|
+
if (err) {
|
|
1098
|
+
log.debug(`Could not open browser automatically: ${err.message}`);
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/commands/logout.ts
|
|
1105
|
+
function registerLogoutCommand(program2) {
|
|
1106
|
+
program2.command("logout").description("Log out and remove stored credentials").action(() => {
|
|
1107
|
+
const removed = clearCredentials();
|
|
1108
|
+
if (removed) {
|
|
1109
|
+
log.info("Logged out. Credentials removed.");
|
|
1110
|
+
} else {
|
|
1111
|
+
log.info("No credentials found. Already logged out.");
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// src/commands/status.ts
|
|
1117
|
+
function registerStatusCommand(program2) {
|
|
1118
|
+
program2.command("status").description("Check if gateway connection can be established").option("--token <jwt>", "JWT access token").option("--service-token <token>", "Service token (requires --user-id)").option("--user-id <id>", "User ID (required with --service-token)").option("--gateway <url>", "Gateway URL", "https://device-gateway.lobehub.com").option("--timeout <ms>", "Connection timeout in ms", "10000").option("-v, --verbose", "Enable verbose logging").action(async (options) => {
|
|
1119
|
+
if (options.verbose) setVerbose(true);
|
|
1120
|
+
const auth = await resolveToken(options);
|
|
1121
|
+
const timeout = Number.parseInt(options.timeout || "10000", 10);
|
|
1122
|
+
const client = new GatewayClient({
|
|
1123
|
+
autoReconnect: false,
|
|
1124
|
+
gatewayUrl: options.gateway,
|
|
1125
|
+
logger: log,
|
|
1126
|
+
token: auth.token,
|
|
1127
|
+
userId: auth.userId
|
|
1128
|
+
});
|
|
1129
|
+
const timer = setTimeout(() => {
|
|
1130
|
+
log.error("FAILED - Connection timed out");
|
|
1131
|
+
client.disconnect();
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
}, timeout);
|
|
1134
|
+
client.on("connected", () => {
|
|
1135
|
+
clearTimeout(timer);
|
|
1136
|
+
log.info("CONNECTED");
|
|
1137
|
+
client.disconnect();
|
|
1138
|
+
process.exit(0);
|
|
1139
|
+
});
|
|
1140
|
+
client.on("disconnected", () => {
|
|
1141
|
+
clearTimeout(timer);
|
|
1142
|
+
log.error("FAILED - Connection closed by server");
|
|
1143
|
+
process.exit(1);
|
|
1144
|
+
});
|
|
1145
|
+
client.on("auth_failed", (reason) => {
|
|
1146
|
+
clearTimeout(timer);
|
|
1147
|
+
log.error(`FAILED - Authentication failed: ${reason}`);
|
|
1148
|
+
process.exit(1);
|
|
1149
|
+
});
|
|
1150
|
+
client.on("auth_expired", () => {
|
|
1151
|
+
clearTimeout(timer);
|
|
1152
|
+
log.error("FAILED - Authentication expired");
|
|
1153
|
+
client.disconnect();
|
|
1154
|
+
process.exit(1);
|
|
1155
|
+
});
|
|
1156
|
+
client.on("error", (error) => {
|
|
1157
|
+
log.error(`Connection error: ${error.message}`);
|
|
1158
|
+
});
|
|
1159
|
+
await client.connect();
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// src/index.ts
|
|
1164
|
+
var program = new Command();
|
|
1165
|
+
program.name("lh").description("LobeHub CLI - manage and connect to LobeHub services").version("0.1.0");
|
|
1166
|
+
registerLoginCommand(program);
|
|
1167
|
+
registerLogoutCommand(program);
|
|
1168
|
+
registerConnectCommand(program);
|
|
1169
|
+
registerStatusCommand(program);
|
|
1170
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lobehub/cli",
|
|
3
|
+
"version": "0.0.1-canary.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"lh": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "npx tsup",
|
|
13
|
+
"dev": "bun src/index.ts",
|
|
14
|
+
"prepublishOnly": "npm run build",
|
|
15
|
+
"test": "bunx vitest run --config vitest.config.mts --silent='passed-only'",
|
|
16
|
+
"test:coverage": "bunx vitest run --config vitest.config.mts --coverage",
|
|
17
|
+
"type-check": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"commander": "^13.1.0",
|
|
21
|
+
"diff": "^7.0.0",
|
|
22
|
+
"fast-glob": "^3.3.3",
|
|
23
|
+
"picocolors": "^1.1.1",
|
|
24
|
+
"ws": "^8.18.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@lobechat/device-gateway-client": "workspace:*",
|
|
28
|
+
"@types/diff": "^6.0.0",
|
|
29
|
+
"@types/node": "^22.13.5",
|
|
30
|
+
"@types/ws": "^8.18.1",
|
|
31
|
+
"tsup": "^8.4.0",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"registry": "https://registry.npmjs.org"
|
|
37
|
+
}
|
|
38
|
+
}
|