@greatlhd/ailo-desktop 1.0.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.
Files changed (73) hide show
  1. package/copy-static.mjs +11 -0
  2. package/dist/browser_control.js +767 -0
  3. package/dist/browser_snapshot.js +174 -0
  4. package/dist/cli.js +36 -0
  5. package/dist/code_executor.js +95 -0
  6. package/dist/config_server.js +658 -0
  7. package/dist/connection_util.js +14 -0
  8. package/dist/constants.js +2 -0
  9. package/dist/desktop_state_store.js +57 -0
  10. package/dist/desktop_types.js +1 -0
  11. package/dist/desktop_verifier.js +40 -0
  12. package/dist/dingtalk-handler.js +173 -0
  13. package/dist/dingtalk-types.js +1 -0
  14. package/dist/email_handler.js +501 -0
  15. package/dist/exec_tool.js +90 -0
  16. package/dist/feishu-handler.js +620 -0
  17. package/dist/feishu-types.js +8 -0
  18. package/dist/feishu-utils.js +162 -0
  19. package/dist/fs_tools.js +398 -0
  20. package/dist/index.js +433 -0
  21. package/dist/mcp/config-manager.js +64 -0
  22. package/dist/mcp/index.js +3 -0
  23. package/dist/mcp/rpc.js +109 -0
  24. package/dist/mcp/session.js +140 -0
  25. package/dist/mcp_manager.js +253 -0
  26. package/dist/mouse_keyboard.js +516 -0
  27. package/dist/qq-handler.js +153 -0
  28. package/dist/qq-types.js +15 -0
  29. package/dist/qq-ws.js +178 -0
  30. package/dist/screenshot.js +271 -0
  31. package/dist/skills_hub.js +212 -0
  32. package/dist/skills_manager.js +103 -0
  33. package/dist/static/AGENTS.md +25 -0
  34. package/dist/static/app.css +539 -0
  35. package/dist/static/app.html +292 -0
  36. package/dist/static/app.js +380 -0
  37. package/dist/static/chat.html +994 -0
  38. package/dist/time_tool.js +22 -0
  39. package/dist/utils.js +15 -0
  40. package/package.json +38 -0
  41. package/src/browser_control.ts +739 -0
  42. package/src/browser_snapshot.ts +196 -0
  43. package/src/cli.ts +44 -0
  44. package/src/code_executor.ts +101 -0
  45. package/src/config_server.ts +723 -0
  46. package/src/connection_util.ts +23 -0
  47. package/src/constants.ts +2 -0
  48. package/src/desktop_state_store.ts +64 -0
  49. package/src/desktop_types.ts +44 -0
  50. package/src/desktop_verifier.ts +45 -0
  51. package/src/dingtalk-types.ts +26 -0
  52. package/src/exec_tool.ts +93 -0
  53. package/src/feishu-handler.ts +722 -0
  54. package/src/feishu-types.ts +66 -0
  55. package/src/feishu-utils.ts +174 -0
  56. package/src/fs_tools.ts +411 -0
  57. package/src/index.ts +474 -0
  58. package/src/mcp/config-manager.ts +85 -0
  59. package/src/mcp/index.ts +7 -0
  60. package/src/mcp/rpc.ts +131 -0
  61. package/src/mcp/session.ts +182 -0
  62. package/src/mcp_manager.ts +273 -0
  63. package/src/mouse_keyboard.ts +526 -0
  64. package/src/qq-types.ts +49 -0
  65. package/src/qq-ws.ts +223 -0
  66. package/src/screenshot.ts +297 -0
  67. package/src/static/app.css +539 -0
  68. package/src/static/app.html +292 -0
  69. package/src/static/app.js +380 -0
  70. package/src/static/chat.html +994 -0
  71. package/src/time_tool.ts +24 -0
  72. package/src/utils.ts +22 -0
  73. package/tsconfig.json +13 -0
package/dist/qq-ws.js ADDED
@@ -0,0 +1,178 @@
1
+ import WebSocket from "ws";
2
+ import { OP_DISPATCH, OP_HEARTBEAT, OP_IDENTIFY, OP_RESUME, OP_RECONNECT, OP_INVALID_SESSION, OP_HELLO, OP_HEARTBEAT_ACK, INTENT_PUBLIC_GUILD_MESSAGES, INTENT_DIRECT_MESSAGE, INTENT_GROUP_AND_C2C, DEFAULT_API_BASE, TOKEN_URL, RECONNECT_DELAYS, MAX_RECONNECT_ATTEMPTS, } from "./qq-types.js";
3
+ export class QQGatewayClient {
4
+ config;
5
+ ws = null;
6
+ heartbeatTimer = null;
7
+ lastSeq = null;
8
+ sessionId = null;
9
+ reconnectAttempts = 0;
10
+ closed = false;
11
+ accessToken = "";
12
+ tokenExpiresAt = 0;
13
+ onDispatch;
14
+ log;
15
+ constructor(config, onDispatch, log) {
16
+ this.config = config;
17
+ this.onDispatch = onDispatch;
18
+ this.log = log ?? ((level, msg, data) => console.log(`[qq-ws] [${level}] ${msg}`, data ?? ""));
19
+ }
20
+ get apiBase() {
21
+ return (this.config.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
22
+ }
23
+ async refreshToken() {
24
+ if (this.accessToken && Date.now() < this.tokenExpiresAt - 30_000) {
25
+ return this.accessToken;
26
+ }
27
+ const res = await fetch(TOKEN_URL, {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify({ appId: this.config.appId, clientSecret: this.config.appSecret }),
31
+ });
32
+ if (!res.ok)
33
+ throw new Error(`QQ token refresh failed: HTTP ${res.status}`);
34
+ const body = (await res.json());
35
+ this.accessToken = body.access_token;
36
+ this.tokenExpiresAt = Date.now() + body.expires_in * 1000;
37
+ this.log("info", "access token refreshed", { expires_in: body.expires_in });
38
+ return this.accessToken;
39
+ }
40
+ getAccessToken() {
41
+ return this.accessToken;
42
+ }
43
+ async connect() {
44
+ this.closed = false;
45
+ await this.refreshToken();
46
+ const gatewayUrl = await this.fetchGatewayUrl();
47
+ this.log("info", `connecting to gateway: ${gatewayUrl}`);
48
+ this.createConnection(gatewayUrl);
49
+ }
50
+ async fetchGatewayUrl() {
51
+ const token = await this.refreshToken();
52
+ const res = await fetch(`${this.apiBase}/gateway`, {
53
+ headers: { Authorization: `QQBot ${token}` },
54
+ });
55
+ if (!res.ok)
56
+ throw new Error(`QQ gateway fetch failed: HTTP ${res.status}`);
57
+ const body = (await res.json());
58
+ return body.url;
59
+ }
60
+ createConnection(url) {
61
+ const ws = new WebSocket(url);
62
+ this.ws = ws;
63
+ ws.on("open", () => {
64
+ this.log("info", "WebSocket connected");
65
+ this.reconnectAttempts = 0;
66
+ });
67
+ ws.on("message", (raw) => {
68
+ try {
69
+ const payload = JSON.parse(raw.toString("utf-8"));
70
+ this.handlePayload(payload);
71
+ }
72
+ catch (err) {
73
+ this.log("error", "failed to parse WS message", { err: String(err) });
74
+ }
75
+ });
76
+ ws.on("close", (code, reason) => {
77
+ this.log("warn", `WebSocket closed: ${code} ${reason.toString("utf-8")}`);
78
+ this.stopHeartbeat();
79
+ if (!this.closed)
80
+ this.scheduleReconnect();
81
+ });
82
+ ws.on("error", (err) => {
83
+ this.log("error", "WebSocket error", { err: err.message });
84
+ });
85
+ }
86
+ handlePayload(payload) {
87
+ if (payload.s != null)
88
+ this.lastSeq = payload.s;
89
+ switch (payload.op) {
90
+ case OP_HELLO:
91
+ this.startHeartbeat(payload.d?.heartbeat_interval ?? 41250);
92
+ if (this.sessionId) {
93
+ this.sendResume();
94
+ }
95
+ else {
96
+ this.sendIdentify();
97
+ }
98
+ break;
99
+ case OP_DISPATCH:
100
+ if (payload.t === "READY") {
101
+ this.sessionId = payload.d?.session_id ?? null;
102
+ this.log("info", "READY", { session_id: this.sessionId });
103
+ }
104
+ if (payload.t) {
105
+ this.onDispatch(payload.t, payload.d);
106
+ }
107
+ break;
108
+ case OP_HEARTBEAT_ACK:
109
+ break;
110
+ case OP_RECONNECT:
111
+ this.log("info", "server requested reconnect");
112
+ this.ws?.close(4000, "reconnect");
113
+ break;
114
+ case OP_INVALID_SESSION:
115
+ this.log("warn", "invalid session, re-identifying");
116
+ this.sessionId = null;
117
+ this.lastSeq = null;
118
+ setTimeout(() => this.sendIdentify(), 2000);
119
+ break;
120
+ default:
121
+ this.log("debug", `unhandled op: ${payload.op}`, { d: payload.d });
122
+ }
123
+ }
124
+ sendIdentify() {
125
+ const intents = INTENT_PUBLIC_GUILD_MESSAGES | INTENT_DIRECT_MESSAGE | INTENT_GROUP_AND_C2C;
126
+ this.send({
127
+ op: OP_IDENTIFY,
128
+ d: { token: `QQBot ${this.accessToken}`, intents, shard: [0, 1] },
129
+ });
130
+ this.log("debug", "sent IDENTIFY");
131
+ }
132
+ sendResume() {
133
+ this.send({
134
+ op: OP_RESUME,
135
+ d: { token: `QQBot ${this.accessToken}`, session_id: this.sessionId, seq: this.lastSeq },
136
+ });
137
+ this.log("debug", "sent RESUME");
138
+ }
139
+ startHeartbeat(intervalMs) {
140
+ this.stopHeartbeat();
141
+ this.heartbeatTimer = setInterval(() => {
142
+ this.send({ op: OP_HEARTBEAT, d: this.lastSeq });
143
+ }, intervalMs);
144
+ }
145
+ stopHeartbeat() {
146
+ if (this.heartbeatTimer) {
147
+ clearInterval(this.heartbeatTimer);
148
+ this.heartbeatTimer = null;
149
+ }
150
+ }
151
+ send(payload) {
152
+ if (this.ws?.readyState === WebSocket.OPEN) {
153
+ this.ws.send(JSON.stringify(payload));
154
+ }
155
+ }
156
+ scheduleReconnect() {
157
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
158
+ this.log("error", "max reconnect attempts reached, giving up");
159
+ return;
160
+ }
161
+ const delay = RECONNECT_DELAYS[Math.min(this.reconnectAttempts, RECONNECT_DELAYS.length - 1)] * 1000;
162
+ this.reconnectAttempts++;
163
+ this.log("info", `reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts})`);
164
+ setTimeout(() => {
165
+ if (!this.closed) {
166
+ this.connect().catch((err) => this.log("error", "reconnect failed", { err: String(err) }));
167
+ }
168
+ }, delay);
169
+ }
170
+ close() {
171
+ this.closed = true;
172
+ this.stopHeartbeat();
173
+ if (this.ws) {
174
+ this.ws.close(1000, "shutdown");
175
+ this.ws = null;
176
+ }
177
+ }
178
+ }
@@ -0,0 +1,271 @@
1
+ import { randomUUID } from "crypto";
2
+ import { spawnSync } from "child_process";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
5
+ import * as path from "path";
6
+ function ok(message, observation) {
7
+ return [
8
+ {
9
+ type: "text",
10
+ text: JSON.stringify({
11
+ ok: true,
12
+ message,
13
+ observation: {
14
+ observation_id: observation.id,
15
+ timestamp: observation.timestamp,
16
+ scope: observation.scope,
17
+ coordinate_space: observation.coordinateSpace,
18
+ image_width: observation.imageWidth,
19
+ image_height: observation.imageHeight,
20
+ image_path: observation.image.path,
21
+ },
22
+ }, null, 2),
23
+ },
24
+ {
25
+ type: "image",
26
+ media: {
27
+ type: "image",
28
+ path: observation.image.path,
29
+ mime: observation.image.mime,
30
+ name: observation.image.name,
31
+ },
32
+ },
33
+ ];
34
+ }
35
+ function fail(error) {
36
+ return {
37
+ parts: [{ type: "text", text: JSON.stringify({ ok: false, error }, null, 2) }],
38
+ };
39
+ }
40
+ export function getScreenInfo() {
41
+ const platform = os.platform();
42
+ if (platform === "win32") {
43
+ const ps = [
44
+ "Add-Type -AssemblyName System.Windows.Forms",
45
+ "$screens=[System.Windows.Forms.Screen]::AllScreens",
46
+ "$primary=[System.Windows.Forms.Screen]::PrimaryScreen",
47
+ "$vs=[System.Windows.Forms.SystemInformation]::VirtualScreen",
48
+ "$r=@()",
49
+ "for($i=0;$i -lt $screens.Count;$i++){",
50
+ " $b=$screens[$i].Bounds",
51
+ " $r+=@{index=$i;x=$b.X;y=$b.Y;width=$b.Width;height=$b.Height;primary=($screens[$i] -eq $primary)}",
52
+ "}",
53
+ "Write-Output (@{count=$screens.Count;screens=$r;virtualBounds=@{x=$vs.X;y=$vs.Y;width=$vs.Width;height=$vs.Height}}|ConvertTo-Json -Compress)",
54
+ ].join("; ");
55
+ const r = spawnSync("powershell", ["-Command", ps], { encoding: "utf-8", timeout: 5000 });
56
+ try {
57
+ const j = JSON.parse((r.stdout ?? "").trim());
58
+ return {
59
+ count: j.count ?? 1,
60
+ screens: (j.screens ?? []).map((s) => ({
61
+ index: s.index,
62
+ x: s.x ?? 0,
63
+ y: s.y ?? 0,
64
+ width: s.width,
65
+ height: s.height,
66
+ primary: !!s.primary,
67
+ })),
68
+ virtualBounds: {
69
+ x: j.virtualBounds?.x ?? 0,
70
+ y: j.virtualBounds?.y ?? 0,
71
+ width: j.virtualBounds?.width ?? 1920,
72
+ height: j.virtualBounds?.height ?? 1080,
73
+ },
74
+ };
75
+ }
76
+ catch {
77
+ return {
78
+ count: 1,
79
+ screens: [{ index: 0, x: 0, y: 0, width: 1920, height: 1080, primary: true }],
80
+ virtualBounds: { x: 0, y: 0, width: 1920, height: 1080 },
81
+ };
82
+ }
83
+ }
84
+ else if (platform === "darwin") {
85
+ const r = spawnSync("system_profiler", ["SPDisplaysDataType", "-json"], { encoding: "utf-8", timeout: 5000 });
86
+ try {
87
+ const j = JSON.parse((r.stdout ?? "").trim());
88
+ const displays = (j?.SPDisplaysDataType ?? []).flatMap((d) => d.spdisplays_ndrvs ?? [d]);
89
+ const screens = displays
90
+ .filter((d) => d.spdisplays_resolution)
91
+ .map((d, i) => {
92
+ const m = String(d.spdisplays_resolution ?? "").match(/(\d+)\s*x\s*(\d+)/);
93
+ return {
94
+ index: i,
95
+ x: 0,
96
+ y: 0,
97
+ width: m ? Number(m[1]) : 1920,
98
+ height: m ? Number(m[2]) : 1080,
99
+ primary: i === 0,
100
+ };
101
+ });
102
+ const primary = screens[0] ?? { index: 0, x: 0, y: 0, width: 1920, height: 1080, primary: true };
103
+ return { count: screens.length || 1, screens: screens.length ? screens : [primary], virtualBounds: { x: 0, y: 0, width: primary.width, height: primary.height } };
104
+ }
105
+ catch {
106
+ return {
107
+ count: 1,
108
+ screens: [{ index: 0, x: 0, y: 0, width: 1920, height: 1080, primary: true }],
109
+ virtualBounds: { x: 0, y: 0, width: 1920, height: 1080 },
110
+ };
111
+ }
112
+ }
113
+ else {
114
+ const r = spawnSync("xdpyinfo", [], { encoding: "utf-8", timeout: 5000 });
115
+ const m = (r.stdout ?? "").match(/dimensions:\s*(\d+)x(\d+)/);
116
+ const width = m ? Number(m[1]) : 1920;
117
+ const height = m ? Number(m[2]) : 1080;
118
+ return {
119
+ count: 1,
120
+ screens: [{ index: 0, x: 0, y: 0, width, height, primary: true }],
121
+ virtualBounds: { x: 0, y: 0, width, height },
122
+ };
123
+ }
124
+ }
125
+ function buildObservation(args) {
126
+ const { imagePath, imageWidth, imageHeight, captureWindow, screenOpt } = args;
127
+ const screenInfo = getScreenInfo();
128
+ let scope;
129
+ let coordinateSpace;
130
+ if (captureWindow) {
131
+ scope = {
132
+ kind: "window",
133
+ bounds: { x: 0, y: 0, width: imageWidth, height: imageHeight },
134
+ };
135
+ coordinateSpace = "window_local";
136
+ }
137
+ else if (typeof screenOpt === "number") {
138
+ const screen = screenInfo.screens.find((item) => item.index === screenOpt) ?? screenInfo.screens[0];
139
+ scope = {
140
+ kind: "screen",
141
+ screenIndex: screen.index,
142
+ bounds: { x: screen.x, y: screen.y, width: screen.width, height: screen.height },
143
+ };
144
+ coordinateSpace = "screen_local";
145
+ }
146
+ else {
147
+ scope = {
148
+ kind: "virtual_screen",
149
+ bounds: screenInfo.virtualBounds,
150
+ };
151
+ coordinateSpace = "virtual_screen";
152
+ }
153
+ return {
154
+ id: `obs_${randomUUID()}`,
155
+ timestamp: Date.now(),
156
+ scope,
157
+ coordinateSpace,
158
+ imageWidth,
159
+ imageHeight,
160
+ image: {
161
+ path: imagePath,
162
+ mime: "image/png",
163
+ name: "screenshot.png",
164
+ },
165
+ };
166
+ }
167
+ export async function captureDesktopObservation(opts = false) {
168
+ const captureWindow = typeof opts === "boolean" ? opts : !!opts.capture_window;
169
+ const screenOpt = typeof opts === "boolean" ? undefined : opts?.screen;
170
+ const tmpPath = path.join(os.tmpdir(), `ailo_screenshot_${Date.now()}.png`);
171
+ const platform = os.platform();
172
+ if (platform === "darwin") {
173
+ if (captureWindow) {
174
+ spawnSync("screencapture", ["-w", tmpPath]);
175
+ }
176
+ else if (typeof screenOpt === "number") {
177
+ spawnSync("screencapture", ["-x", "-D", String(screenOpt + 1), tmpPath]);
178
+ }
179
+ else {
180
+ spawnSync("screencapture", ["-x", tmpPath]);
181
+ }
182
+ }
183
+ else if (platform === "win32") {
184
+ const escaped = tmpPath.replace(/'/g, "''").replace(/\\/g, "\\\\");
185
+ let ps;
186
+ if (typeof screenOpt === "number") {
187
+ ps = [
188
+ "Add-Type -AssemblyName System.Windows.Forms",
189
+ "Add-Type -AssemblyName System.Drawing",
190
+ "$screens=[System.Windows.Forms.Screen]::AllScreens",
191
+ `$idx=${screenOpt}`,
192
+ "if($idx -ge $screens.Count){ $idx=0 }",
193
+ "$screen=$screens[$idx].Bounds",
194
+ "$bmp=New-Object System.Drawing.Bitmap($screen.Width,$screen.Height)",
195
+ "$g=[System.Drawing.Graphics]::FromImage($bmp)",
196
+ "$g.CopyFromScreen($screen.Location,[System.Drawing.Point]::Empty,$screen.Size)",
197
+ `$bmp.Save('${escaped}')`,
198
+ ].join("; ");
199
+ }
200
+ else {
201
+ ps = [
202
+ "Add-Type -AssemblyName System.Windows.Forms",
203
+ "Add-Type -AssemblyName System.Drawing",
204
+ "$vs=[System.Windows.Forms.SystemInformation]::VirtualScreen",
205
+ "$bmp=New-Object System.Drawing.Bitmap($vs.Width,$vs.Height)",
206
+ "$g=[System.Drawing.Graphics]::FromImage($bmp)",
207
+ "$g.CopyFromScreen($vs.X,$vs.Y,0,0,[System.Drawing.Size]::new($vs.Width,$vs.Height))",
208
+ `$bmp.Save('${escaped}')`,
209
+ ].join("; ");
210
+ }
211
+ spawnSync("powershell", ["-Command", ps]);
212
+ }
213
+ else {
214
+ const r = spawnSync("scrot", [tmpPath]);
215
+ if (r.status !== 0)
216
+ spawnSync("import", ["-window", "root", tmpPath]);
217
+ }
218
+ if (!fs.existsSync(tmpPath))
219
+ return fail("截图失败:未能生成截图文件");
220
+ let imageWidth = 0;
221
+ let imageHeight = 0;
222
+ try {
223
+ const screenInfo = getScreenInfo();
224
+ if (typeof screenOpt === "number") {
225
+ const screen = screenInfo.screens.find((item) => item.index === screenOpt) ?? screenInfo.screens[0];
226
+ imageWidth = screen.width;
227
+ imageHeight = screen.height;
228
+ }
229
+ else {
230
+ imageWidth = screenInfo.virtualBounds.width;
231
+ imageHeight = screenInfo.virtualBounds.height;
232
+ }
233
+ }
234
+ catch {
235
+ imageWidth = 1920;
236
+ imageHeight = 1080;
237
+ }
238
+ const observation = buildObservation({
239
+ imagePath: tmpPath,
240
+ imageWidth,
241
+ imageHeight,
242
+ captureWindow,
243
+ screenOpt,
244
+ });
245
+ let message;
246
+ if (captureWindow) {
247
+ message = "窗口截图完成";
248
+ }
249
+ else {
250
+ const screenInfo = getScreenInfo();
251
+ if (screenInfo.count > 1) {
252
+ if (typeof screenOpt === "number") {
253
+ message = `截图完成(第 ${screenOpt + 1} 块/共 ${screenInfo.count} 块屏。可用 screen=0~${screenInfo.count - 1} 分别截取其他屏)`;
254
+ }
255
+ else {
256
+ message = `截图完成(共 ${screenInfo.count} 块屏,已截取全部。可用 screen=0~${screenInfo.count - 1} 分别截取单块)`;
257
+ }
258
+ }
259
+ else {
260
+ message = typeof screenOpt === "number" ? `显示器 ${screenOpt} 截图完成` : "截图完成";
261
+ }
262
+ }
263
+ return {
264
+ parts: ok(message, observation),
265
+ observation,
266
+ };
267
+ }
268
+ export async function takeScreenshot(opts = false) {
269
+ const result = await captureDesktopObservation(opts);
270
+ return result.parts;
271
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Skills Hub client — compatible with skills.sh, clawhub.ai, skillsmp.com, GitHub.
3
+ * Downloads SKILL.md + references/ + scripts/ bundles from any supported marketplace URL.
4
+ */
5
+ import { mkdir, writeFile } from "fs/promises";
6
+ import { join, dirname } from "path";
7
+ import { readConfig } from "@lmcl/ailo-endpoint-sdk";
8
+ import { CONFIG_FILENAME } from "./constants.js";
9
+ function loadLocalConfig() {
10
+ return readConfig(join(process.cwd(), CONFIG_FILENAME));
11
+ }
12
+ function detectSource(url) {
13
+ const u = new URL(url);
14
+ const host = u.hostname.replace("www.", "");
15
+ if (host === "skills.sh")
16
+ return "skills_sh";
17
+ if (host === "clawhub.ai")
18
+ return "clawhub";
19
+ if (host === "skillsmp.com")
20
+ return "skillsmp";
21
+ if (host === "github.com")
22
+ return "github";
23
+ return null;
24
+ }
25
+ export async function installFromUrl(url, targetDir) {
26
+ const source = detectSource(url);
27
+ if (!source)
28
+ throw new Error(`Unsupported URL: ${url}. Supported: skills.sh, clawhub.ai, skillsmp.com, github.com`);
29
+ let bundle;
30
+ switch (source) {
31
+ case "skills_sh":
32
+ bundle = await fetchSkillsSh(url);
33
+ break;
34
+ case "clawhub":
35
+ bundle = await fetchClawhub(url);
36
+ break;
37
+ case "skillsmp":
38
+ bundle = await fetchSkillsmp(url);
39
+ break;
40
+ case "github":
41
+ bundle = await fetchGitHub(url);
42
+ break;
43
+ }
44
+ const skillDir = join(targetDir, bundle.name);
45
+ await mkdir(skillDir, { recursive: true });
46
+ await writeFile(join(skillDir, "SKILL.md"), bundle.skillMd, "utf-8");
47
+ for (const [relPath, content] of bundle.files) {
48
+ const fullPath = join(skillDir, relPath);
49
+ await mkdir(dirname(fullPath), { recursive: true });
50
+ await writeFile(fullPath, content, "utf-8");
51
+ }
52
+ return bundle;
53
+ }
54
+ // --- skills.sh ---
55
+ // Format: https://skills.sh/{owner}/{repo}/{skill}
56
+ async function fetchSkillsSh(url) {
57
+ const u = new URL(url);
58
+ const parts = u.pathname.split("/").filter(Boolean);
59
+ if (parts.length < 3)
60
+ throw new Error("skills.sh URL must be: skills.sh/{owner}/{repo}/{skill}");
61
+ const [owner, repo, skill] = parts;
62
+ const dirs = [`skills/${skill}`, skill];
63
+ for (const dir of dirs) {
64
+ try {
65
+ return await fetchGitHubDir(owner, repo, "main", dir, skill);
66
+ }
67
+ catch {
68
+ try {
69
+ return await fetchGitHubDir(owner, repo, "master", dir, skill);
70
+ }
71
+ catch { }
72
+ }
73
+ }
74
+ throw new Error(`Skill not found in ${owner}/${repo}: tried ${dirs.join(", ")}`);
75
+ }
76
+ // --- clawhub.ai ---
77
+ // Format: https://clawhub.ai/{slug}
78
+ async function fetchClawhub(url) {
79
+ const u = new URL(url);
80
+ const slug = u.pathname.split("/").filter(Boolean).pop();
81
+ if (!slug)
82
+ throw new Error("clawhub URL must contain a slug");
83
+ const cfg = loadLocalConfig();
84
+ const hubBase = cfg.skillsHubBaseUrl ?? "https://clawhub.ai";
85
+ const meta = await httpJson(`${hubBase}/api/v1/skills/${slug}`);
86
+ const repoUrl = meta.repo_url ?? meta.github_url;
87
+ if (!repoUrl)
88
+ throw new Error(`No repo_url found for skill ${slug}`);
89
+ return fetchGitHub(repoUrl);
90
+ }
91
+ // --- skillsmp.com ---
92
+ // Format: https://skillsmp.com/{slug}
93
+ // Slug: openclaw-openclaw-skills-himalaya-skill-md → owner/repo/skill_hint
94
+ async function fetchSkillsmp(url) {
95
+ const u = new URL(url);
96
+ const slug = u.pathname.split("/").filter(Boolean).pop();
97
+ if (!slug)
98
+ throw new Error("skillsmp URL must contain a slug");
99
+ const parts = slug.split("-");
100
+ if (parts.length < 3)
101
+ throw new Error(`Cannot parse skillsmp slug: ${slug}`);
102
+ const owner = parts[0];
103
+ const repo = parts.slice(1, 3).join("-");
104
+ const skillHint = parts.slice(3).join("-").replace(/-skill-md$/, "");
105
+ const dirs = [skillHint, `skills/${skillHint}`];
106
+ for (const dir of dirs) {
107
+ try {
108
+ return await fetchGitHubDir(owner, repo, "main", dir, skillHint);
109
+ }
110
+ catch { }
111
+ try {
112
+ return await fetchGitHubDir(owner, repo, "master", dir, skillHint);
113
+ }
114
+ catch { }
115
+ }
116
+ throw new Error(`Skill not found: ${owner}/${repo} hint=${skillHint}`);
117
+ }
118
+ // --- GitHub ---
119
+ // Format: https://github.com/{owner}/{repo}/tree/{branch}/{path}
120
+ // or: https://github.com/{owner}/{repo}/blob/{branch}/{path}/SKILL.md
121
+ async function fetchGitHub(url) {
122
+ const u = new URL(url);
123
+ const parts = u.pathname.split("/").filter(Boolean);
124
+ if (parts.length < 2)
125
+ throw new Error("GitHub URL must be: github.com/{owner}/{repo}/...");
126
+ const owner = parts[0];
127
+ const repo = parts[1];
128
+ if (parts.length >= 4 && (parts[2] === "tree" || parts[2] === "blob")) {
129
+ const branch = parts[3];
130
+ let dirPath = parts.slice(4).join("/");
131
+ if (dirPath.endsWith("/SKILL.md") || dirPath.endsWith("SKILL.md")) {
132
+ dirPath = dirPath.replace(/\/?SKILL\.md$/, "");
133
+ }
134
+ const name = dirPath.split("/").pop() || repo;
135
+ return fetchGitHubDir(owner, repo, branch, dirPath, name);
136
+ }
137
+ return fetchGitHubDir(owner, repo, "main", "", repo);
138
+ }
139
+ // --- Core GitHub fetcher ---
140
+ async function fetchGitHubDir(owner, repo, branch, dirPath, name) {
141
+ const prefix = dirPath ? `${dirPath}/` : "";
142
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${branch}`;
143
+ const entries = await httpJson(apiUrl);
144
+ if (!Array.isArray(entries)) {
145
+ if (entries.name === "SKILL.md") {
146
+ const content = await httpText(entries.download_url);
147
+ return { name, skillMd: content, files: new Map() };
148
+ }
149
+ throw new Error(`Not a directory: ${dirPath}`);
150
+ }
151
+ const skillEntry = entries.find((e) => e.name === "SKILL.md");
152
+ if (!skillEntry)
153
+ throw new Error(`SKILL.md not found in ${owner}/${repo}/${dirPath}`);
154
+ const skillMd = await httpText(skillEntry.download_url);
155
+ const files = new Map();
156
+ for (const entry of entries) {
157
+ if (entry.name === "SKILL.md")
158
+ continue;
159
+ if (entry.type === "file") {
160
+ const relPath = entry.path.replace(prefix, "");
161
+ try {
162
+ const content = await httpText(entry.download_url);
163
+ files.set(relPath, content);
164
+ }
165
+ catch { }
166
+ }
167
+ else if (entry.type === "dir") {
168
+ await fetchGitHubDirRecursive(owner, repo, branch, entry.path, prefix, files);
169
+ }
170
+ }
171
+ return { name, skillMd, files };
172
+ }
173
+ async function fetchGitHubDirRecursive(owner, repo, branch, dirPath, rootPrefix, files) {
174
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${branch}`;
175
+ try {
176
+ const entries = await httpJson(apiUrl);
177
+ if (!Array.isArray(entries))
178
+ return;
179
+ for (const entry of entries) {
180
+ const relPath = entry.path.replace(rootPrefix, "");
181
+ if (entry.type === "file") {
182
+ try {
183
+ const content = await httpText(entry.download_url);
184
+ files.set(relPath, content);
185
+ }
186
+ catch { }
187
+ }
188
+ else if (entry.type === "dir") {
189
+ await fetchGitHubDirRecursive(owner, repo, branch, entry.path, rootPrefix, files);
190
+ }
191
+ }
192
+ }
193
+ catch { }
194
+ }
195
+ // --- HTTP helpers ---
196
+ function httpJson(url) {
197
+ return httpText(url).then((t) => JSON.parse(t));
198
+ }
199
+ async function httpText(url) {
200
+ const headers = { "User-Agent": "ailo-desktop/1.0" };
201
+ try {
202
+ const cfg = loadLocalConfig();
203
+ const ghToken = cfg.githubToken || "";
204
+ if (ghToken && url.includes("api.github.com"))
205
+ headers.Authorization = `token ${ghToken}`;
206
+ }
207
+ catch { /* config not available, proceed without token */ }
208
+ const res = await fetch(url, { headers, redirect: "follow" });
209
+ if (!res.ok)
210
+ throw new Error(`HTTP ${res.status} for ${url}`);
211
+ return res.text();
212
+ }