aixlab-skills 0.1.2 → 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.
package/README.md CHANGED
@@ -1,12 +1,23 @@
1
1
  # aixlab-skills
2
2
 
3
- AixLab 自有数字资产平台的 Agent Skill 安装器。它通过浏览器设备授权取得最小权限令牌,下载用户已拥有的私有 ZIP,校验 SHA-256 与 `SKILL.md` 名称后,再调用固定版本的通用 `skills` CLI 完成全局复制安装。
3
+ 先在 AixLab 领取或购买 Skill 并下载完整 ZIP,再从本地安装。安装器不登录平台、不打开设备授权页面、不保存账号令牌,也不代替用户领取或下载资产。
4
+
5
+ 需要 Node.js 20+。将下载的 ZIP 放在当前目录或用户的 Downloads 目录,再执行:
4
6
 
5
7
  ```bash
6
- npx aixlab-skills add xhs-creator -g -y
8
+ npx --yes aixlab-skills@1.0.0 add ppdesign-h3 -g -y
7
9
  ```
8
10
 
9
- 本地联调可设置 `AIXLAB_SKILLS_HOST=http://localhost:8180`。生产环境默认连接 `https://www.aixc4d.com`。
11
+ 文件名可以是 `ppdesign-h3.zip`、`ppdesign-h3-1.0.0.zip` 或浏览器添加序号的同名文件。如果有多个版本,或 ZIP 放在其他目录,请明确指定:
12
+
13
+ ```bash
14
+ npx --yes aixlab-skills@1.0.0 add ppdesign-h3 -g -y --file "C:/Users/你的用户名/Downloads/ppdesign-h3-1.0.0.zip"
15
+ ```
16
+
17
+ 可用 AIXLAB_SKILLS_DOWNLOAD_DIR 指定下载目录;安装器只检查当前目录和该下载目录,不递归扫描用户文件。
18
+
19
+ 安装前会校验 ZIP 路径、文件类型、解压大小和唯一 SKILL.md 的名称,然后调用固定版本 skills@1.5.22 完成全局复制安装。Skill 的所有配套资源会随目录一起安装。首次获取通用安装器需要访问 npm,但不需要 AixLab 二次授权。
20
+
21
+ 也可直接解压完整 Skill 目录并放入兼容 Agent 的 Skills 目录,无需运行本安装器。更新已有 Skill 前应保留用户自己的修改。
10
22
 
11
- 该安装器已作为公开 npm 包发布。账户、版本、发布、验证和交接流程见
12
- [`docs/agent/runbooks/AixLab-Skills-npm发布与交接手册.md`](../../../docs/agent/runbooks/AixLab-Skills-npm发布与交接手册.md)。
23
+ npm 只包含公开安装器代码;付费 Skill、素材、私有下载地址和用户凭据均不在 npm 包中。平台领取与下载继续按原有权益规则执行。
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aixlab-skills",
3
- "version": "0.1.2",
4
- "description": "Secure installer for owned AixLab agent Skills",
3
+ "version": "1.0.0",
4
+ "description": "Install downloaded AixLab Skill ZIPs locally without account authorization",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "aixlab-skills": "bin/aixlab-skills.js"
package/src/index.js CHANGED
@@ -1,13 +1,11 @@
1
- import { createHash } from "node:crypto";
2
1
  import { createWriteStream } from "node:fs";
3
- import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
4
3
  import { homedir, platform, tmpdir } from "node:os";
5
- import { dirname, join, win32 as win32Path } from "node:path";
4
+ import { dirname, isAbsolute, join, resolve, win32 as win32Path } from "node:path";
6
5
  import { pipeline } from "node:stream/promises";
7
6
  import { spawn } from "node:child_process";
8
7
  import yauzl from "yauzl";
9
8
 
10
- const DEFAULT_HOST = "https://www.aixc4d.com";
11
9
  const GENERIC_INSTALLER_VERSION = "1.5.22";
12
10
  const MAX_PACKAGE_BYTES = 100 * 1024 * 1024;
13
11
  const MAX_ARCHIVE_ENTRIES = 2000;
@@ -16,7 +14,7 @@ export function parseArguments(args) {
16
14
  const values = [...args];
17
15
 
18
16
  if (values.shift() !== "add") {
19
- throw new Error("用法:aixlab-skills add <skill-name> -g -y");
17
+ throw new Error("用法:aixlab-skills add <skill-name> -g -y [--file <已下载的 ZIP 路径>]");
20
18
  }
21
19
 
22
20
  const skillName = values.shift() ?? "";
@@ -26,31 +24,23 @@ export function parseArguments(args) {
26
24
  }
27
25
 
28
26
  const accepted = new Set(["-g", "--global", "-y", "--yes"]);
27
+ let packageFile;
29
28
 
30
- if (values.some(value => ! accepted.has(value))) {
31
- throw new Error("包含不支持的安装参数。");
32
- }
33
-
34
- return { skillName };
35
- }
29
+ while (values.length) {
30
+ const value = values.shift();
36
31
 
37
- export function normalizeHost(rawHost) {
38
- const url = new URL(rawHost || DEFAULT_HOST);
39
- const localHttp = url.protocol === "http:"
40
- && ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
32
+ if (value === "--file" && packageFile === undefined) {
33
+ packageFile = values.shift();
41
34
 
42
- if ((url.protocol !== "https:" && ! localHttp)
43
- || url.username
44
- || url.password
45
- || url.search
46
- || url.hash
47
- ) {
48
- throw new Error("AIXLAB_SKILLS_HOST 必须是 HTTPS 地址;仅本机调试允许 HTTP。");
35
+ if (typeof packageFile !== "string" || ! packageFile.trim() || packageFile.startsWith("-")) {
36
+ throw new Error("--file 后需要填写已下载的 ZIP 路径。");
37
+ }
38
+ } else if (! accepted.has(value)) {
39
+ throw new Error("包含不支持的安装参数。");
40
+ }
49
41
  }
50
42
 
51
- url.pathname = url.pathname.replace(/\/?$/, "/");
52
-
53
- return url;
43
+ return { skillName, ...(packageFile === undefined ? {} : { packageFile }) };
54
44
  }
55
45
 
56
46
  export function skillNameFromMarkdown(markdown) {
@@ -61,183 +51,43 @@ export function skillNameFromMarkdown(markdown) {
61
51
  return match?.[2] ?? "";
62
52
  }
63
53
 
64
- function configPath() {
65
- return join(homedir(), ".config", "aixlab-skills", "config.json");
66
- }
67
-
68
- async function loadToken(host) {
69
- const environmentToken = process.env.AIXLAB_SKILLS_TOKEN?.trim();
70
-
71
- if (environmentToken) return environmentToken;
72
-
73
- try {
74
- const stored = JSON.parse(await readFile(configPath(), "utf8"));
75
-
76
- return stored.host === host.origin && typeof stored.access_token === "string"
77
- ? stored.access_token
78
- : null;
79
- } catch {
80
- return null;
81
- }
82
- }
83
-
84
- async function saveToken(host, token) {
85
- const target = configPath();
86
- const temporary = `${target}.${process.pid}.tmp`;
87
- await mkdir(dirname(target), { recursive: true, mode: 0o700 });
88
- await writeFile(temporary, `${JSON.stringify({ host: host.origin, access_token: token })}\n`, {
89
- encoding: "utf8",
90
- mode: 0o600,
91
- });
92
- await chmod(temporary, 0o600);
93
- await rename(temporary, target);
94
- }
95
-
96
- async function requestJson(host, path, { method = "GET", token, body, statuses = [200] } = {}) {
97
- const url = new URL(path.replace(/^\//, ""), host);
98
- const response = await fetch(url, {
99
- method,
100
- redirect: "manual",
101
- headers: {
102
- Accept: "application/json",
103
- ...(body ? { "Content-Type": "application/json" } : {}),
104
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
105
- },
106
- ...(body ? { body: JSON.stringify(body) } : {}),
107
- });
108
- let payload = {};
109
-
110
- try {
111
- payload = await response.json();
112
- } catch {
113
- // The status remains the source of truth for malformed error responses.
114
- }
115
-
116
- if (! statuses.includes(response.status)) {
117
- const error = new Error(payload.message || payload.error || `平台请求失败(HTTP ${response.status})。`);
118
- error.status = response.status;
119
- throw error;
120
- }
121
-
122
- return { status: response.status, payload };
123
- }
124
-
125
- export function openBrowser(url, {
126
- platformName = platform(),
127
- spawnProcess = spawn,
54
+ export async function resolvePackage(skillName, packageFile, {
55
+ cwd = process.cwd(),
56
+ downloadsDirectory = process.env.AIXLAB_SKILLS_DOWNLOAD_DIR || join(homedir(), "Downloads"),
128
57
  } = {}) {
129
- const command = platformName === "win32"
130
- ? ["explorer.exe", [url]]
131
- : platformName === "darwin"
132
- ? ["open", [url]]
133
- : ["xdg-open", [url]];
134
-
135
- try {
136
- const child = spawnProcess(command[0], command[1], { detached: true, stdio: "ignore" });
137
- child.once("error", () => {
138
- // Printing the URL is sufficient when the desktop opener is unavailable.
139
- });
140
- child.unref();
141
- } catch {
142
- // Printing the URL is sufficient when the desktop opener is unavailable.
143
- }
144
- }
145
-
146
- async function authorizeDevice(host) {
147
- const { payload } = await requestJson(host, "marketplace-cli/device-codes", { method: "POST" });
148
- const expiresIn = Number(payload.expires_in);
149
- const interval = Number(payload.interval);
58
+ if (packageFile !== undefined) {
59
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(packageFile) || ! /\.zip$/i.test(packageFile)) {
60
+ throw new Error("--file 只接受本地 ZIP 文件,不接受下载网址。");
61
+ }
150
62
 
151
- if (typeof payload.device_code !== "string"
152
- || typeof payload.user_code !== "string"
153
- || typeof payload.verification_uri_complete !== "string"
154
- || ! Number.isFinite(expiresIn)
155
- || ! Number.isFinite(interval)
156
- ) {
157
- throw new Error("平台返回了无效的设备授权信息。");
63
+ return isAbsolute(packageFile) ? packageFile : resolve(cwd, packageFile);
158
64
  }
159
65
 
160
- process.stderr.write(`请在浏览器中确认 AixLab 安装授权:\n${payload.verification_uri_complete}\n授权码:${payload.user_code}\n`);
161
- openBrowser(payload.verification_uri_complete);
162
- const deadline = Date.now() + (expiresIn * 1000);
163
-
164
- while (Date.now() < deadline) {
165
- await new Promise(resolve => setTimeout(resolve, Math.max(2, interval) * 1000));
166
- const result = await requestJson(host, "marketplace-cli/device-token", {
167
- method: "POST",
168
- body: { device_code: payload.device_code },
169
- statuses: [200, 202, 410],
170
- });
66
+ const filename = new RegExp("^" + skillName + "(?:-[0-9][0-9A-Za-z.+_-]*)?(?: \\(\\d+\\))?\\.zip$", "i");
171
67
 
172
- if (result.status === 202) continue;
173
- if (result.status === 410) break;
68
+ for (const directory of new Set([cwd, downloadsDirectory])) {
69
+ let entries;
174
70
 
175
- if (typeof result.payload.access_token !== "string" || result.payload.token_type !== "Bearer") {
176
- throw new Error("平台返回了无效的访问令牌。");
71
+ try {
72
+ entries = await readdir(directory, { withFileTypes: true });
73
+ } catch (error) {
74
+ if (error.code === "ENOENT") continue;
75
+ throw error;
177
76
  }
178
77
 
179
- await saveToken(host, result.payload.access_token);
180
-
181
- return result.payload.access_token;
182
- }
183
-
184
- throw new Error("安装授权已过期,请重新执行原命令。");
185
- }
186
-
187
- function validateManifest(payload, skillName, host) {
188
- const packageData = payload?.package;
189
-
190
- if (payload?.skill_name !== skillName
191
- || typeof payload.version !== "string"
192
- || ! packageData
193
- || typeof packageData.url !== "string"
194
- || typeof packageData.sha256 !== "string"
195
- || ! /^[0-9a-f]{64}$/.test(packageData.sha256)
196
- || ! Number.isSafeInteger(packageData.size_bytes)
197
- || packageData.size_bytes < 0
198
- || packageData.size_bytes > MAX_PACKAGE_BYTES
199
- ) {
200
- throw new Error("平台返回了无效的 Skill 安装清单。");
201
- }
78
+ const matches = entries.filter(entry => entry.isFile() && filename.test(entry.name));
202
79
 
203
- const packageUrl = new URL(packageData.url);
80
+ if (matches.length > 1) {
81
+ throw new Error("找到多个同名 Skill 压缩包,请使用 --file 指定要安装的 ZIP 路径。");
82
+ }
204
83
 
205
- if (packageUrl.origin !== host.origin || packageUrl.protocol !== host.protocol) {
206
- throw new Error("平台返回的下载地址不属于当前 AixLab 站点。");
84
+ if (matches.length === 1) return join(directory, matches[0].name);
207
85
  }
208
86
 
209
- return { ...payload, package: { ...packageData, url: packageUrl } };
210
- }
211
-
212
- async function issuePackage(host, token, skillName) {
213
- const { payload } = await requestJson(
214
- host,
215
- `marketplace-cli/skills/${encodeURIComponent(skillName)}/package-access`,
216
- { method: "POST", token },
87
+ throw new Error(
88
+ "未找到已下载的 Skill ZIP。请先在资产平台领取并下载,将 ZIP 放到当前目录或 Downloads,"
89
+ + "也可在命令后添加 --file \"ZIP 文件路径\"。安装过程无需账号或设备授权。",
217
90
  );
218
-
219
- return validateManifest(payload, skillName, host);
220
- }
221
-
222
- async function downloadPackage(manifest, token, target) {
223
- const response = await fetch(manifest.package.url, {
224
- headers: { Authorization: `Bearer ${token}`, Accept: "application/zip" },
225
- redirect: "manual",
226
- });
227
-
228
- if (response.status !== 200) {
229
- throw new Error(`Skill 下载失败(HTTP ${response.status})。`);
230
- }
231
-
232
- const bytes = Buffer.from(await response.arrayBuffer());
233
-
234
- if (bytes.length !== manifest.package.size_bytes
235
- || createHash("sha256").update(bytes).digest("hex") !== manifest.package.sha256
236
- ) {
237
- throw new Error("Skill 包完整性校验失败。");
238
- }
239
-
240
- await writeFile(target, bytes, { flag: "wx", mode: 0o600 });
241
91
  }
242
92
 
243
93
  function validatedEntryName(name) {
@@ -432,26 +282,25 @@ async function installSkill(skillRoot) {
432
282
  }
433
283
  }
434
284
 
435
- async function execute(skillName) {
285
+ async function execute({ skillName, packageFile }, options) {
436
286
  const major = Number(process.versions.node.split(".")[0]);
437
287
 
438
288
  if (! Number.isInteger(major) || major < 20) {
439
289
  throw new Error("aixlab-skills 需要 Node.js 20 或更高版本。");
440
290
  }
441
291
 
442
- const host = normalizeHost(process.env.AIXLAB_SKILLS_HOST || DEFAULT_HOST);
443
- let token = await loadToken(host);
444
-
445
- if (! token) token = await authorizeDevice(host);
446
-
447
- let manifest;
292
+ const source = await resolvePackage(skillName, packageFile, options);
293
+ let sourceInfo;
448
294
 
449
295
  try {
450
- manifest = await issuePackage(host, token, skillName);
296
+ sourceInfo = await lstat(source);
451
297
  } catch (error) {
452
- if (error.status !== 401) throw error;
453
- token = await authorizeDevice(host);
454
- manifest = await issuePackage(host, token, skillName);
298
+ if (error.code !== "ENOENT") throw error;
299
+ throw new Error("指定的 ZIP 文件不存在,请检查 --file 路径。");
300
+ }
301
+
302
+ if (! sourceInfo.isFile() || sourceInfo.size === 0 || sourceInfo.size > MAX_PACKAGE_BYTES) {
303
+ throw new Error("请选择有效的本地 ZIP 文件,压缩包最大 100 MiB。");
455
304
  }
456
305
 
457
306
  const working = await mkdtemp(join(tmpdir(), "aixlab-skills-"));
@@ -459,30 +308,30 @@ async function execute(skillName) {
459
308
  try {
460
309
  const archive = join(working, "package.zip");
461
310
  const extracted = join(working, "extracted");
462
- await downloadPackage(manifest, token, archive);
311
+ await copyFile(source, archive);
463
312
  await extractPackage(archive, extracted);
464
313
  const skillRoot = await validateExtractedSkill(extracted, skillName);
465
- await installSkill(skillRoot);
314
+ await (options.install ?? installSkill)(skillRoot);
466
315
 
467
316
  return {
468
317
  skill_name: skillName,
469
318
  success: true,
470
- version: manifest.version,
471
319
  verified: true,
472
- message: "Skill 已通过 AixLab 权益校验、完整性校验并完成全局安装。",
320
+ message: "已验证本地 ZIP 结构与 Skill 名称并完成全局安装,无需二次授权。",
473
321
  };
474
322
  } finally {
475
323
  await rm(working, { recursive: true, force: true });
476
324
  }
477
325
  }
478
326
 
479
- export async function run(args) {
327
+ export async function run(args, options = {}) {
480
328
  let skillName = "";
481
329
 
482
330
  try {
483
- ({ skillName } = parseArguments(args));
331
+ const parsed = parseArguments(args);
332
+ skillName = parsed.skillName;
484
333
 
485
- return await execute(skillName);
334
+ return await execute(parsed, options);
486
335
  } catch (error) {
487
336
  return {
488
337
  skill_name: skillName,