@chatcode/cco-market 1.45.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.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The market's own settings namespace: the half that makes `allowRestart`
3
+ * a switch on the plugin configuration page instead of a line the user has
4
+ * to hand-write into cordis.yml.
5
+ *
6
+ * `allowRestart: false` is the documented answer for a host owned by
7
+ * systemd, launchd or pm2 — a supervisor restarts it, so the market's
8
+ * one-click restart must not launch a second one. Until now the only way to
9
+ * say that was editing YAML in the right place with the right indentation,
10
+ * where a stray space stops the profile booting.
11
+ *
12
+ * Only `allowRestart` is exposed. `profile` names which profile this
13
+ * instance manages: it is decided at mount from the composition or the
14
+ * command line, and a running instance cannot switch to another one, so
15
+ * offering it as a field would promise something the write cannot deliver.
16
+ *
17
+ * The release channel is NOT here either, and that is a correction rather
18
+ * than an omission. It was, briefly, and it made this namespace a second
19
+ * writer for a value the market already stores in its own state.json: the
20
+ * mount read the user's saved channel off disk, then `onChange` assigned
21
+ * `source().channel` — which knows nothing about that file — straight back
22
+ * over it. The choice survived exactly until the next settings event.
23
+ *
24
+ * Only a real host could show that; the unit lane mounts the routes without
25
+ * this layer at all. `allowRestart` needs this door because its only other
26
+ * one is hand-edited YAML. The channel has a control of its own on the
27
+ * plugin configuration page, so a second door bought nothing and cost the
28
+ * setting its memory.
29
+ *
30
+ * The wiring rides the scoped fiber, so a host with no settings service —
31
+ * every dsh before 0.1.0-rc.7 — simply never runs any of this and the entry
32
+ * configuration stands as composed. That is why this needs no version check
33
+ * of its own.
34
+ *
35
+ * It depends on the SERVICE and nothing else, which is the whole point of
36
+ * the shape below. This module used to import two convenience helpers,
37
+ * `installSettingsSection` and `settingsNamespace`, from
38
+ * `@deepseek-ai/dsh-settings`. dsh 0.1.2-alpha.1 deleted both — and a
39
+ * missing NAMED EXPORT is not a missing service. `ctx.inject` degrades
40
+ * quietly; an ESM named import that resolves to nothing is a SyntaxError at
41
+ * module evaluation, which cordis's loader reports as a failed entry and the
42
+ * host exits 1. Installing the market stopped the host from booting at all:
43
+ *
44
+ * SyntaxError: The requested module '@deepseek-ai/dsh-settings' does not
45
+ * provide an export named 'installSettingsSection'
46
+ *
47
+ * The service itself never changed — `sctx.settings.register(ns, schema,
48
+ * { base })` is identical in 0.1.0-rc.7 and 0.1.2-alpha.2. Only the two
49
+ * wrappers went away. So this inlines what the wrapper did (verified against
50
+ * its source: an inject, a register, a watch, and an unload effect) and
51
+ * validates the namespace here. Nothing about the graceful-degradation story
52
+ * changes; it just stops being conditional on an export that upstream is
53
+ * free to move.
54
+ */
55
+ import z from '@deepseek-ai/schemastery';
56
+ import { restartAllowed } from './restart.js';
57
+ /**
58
+ * The namespace pattern `settingsNamespace` enforced before it was removed.
59
+ * Kept as a literal check rather than an import for the reason above.
60
+ */
61
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
62
+ /** Namespace the card on the browser side keys itself to. */
63
+ export const MARKET_SETTINGS_NS = 'dsh-market';
64
+ if (!NAMESPACE_PATTERN.test(MARKET_SETTINGS_NS)) {
65
+ throw new TypeError(`settings namespace "${MARKET_SETTINGS_NS}" must match ${String(NAMESPACE_PATTERN)}`);
66
+ }
67
+ export const MarketSettings = z.object({
68
+ allowRestart: z.boolean().default(true),
69
+ });
70
+ /**
71
+ * Wire the namespace so a saved change reaches the routes immediately.
72
+ *
73
+ * The routes read `allowRestart` off this object on every request (the
74
+ * status route reports the capability, the restart route enforces it), so
75
+ * updating it in place is what makes a toggle take effect without a
76
+ * restart — which would be a poor thing to require of a setting whose whole
77
+ * subject is restarting.
78
+ *
79
+ * @param ctx - the plugin context owning the wiring.
80
+ * @param resolved - the live config object the routes read.
81
+ */
82
+ export function installMarketSettings(ctx, resolved) {
83
+ // The switch must show what the routes will actually DO, which since #229
84
+ // is not simply "unset means on": under a detected supervisor an unset
85
+ // value means off, because the supervisor owns restarts. Asking
86
+ // restartAllowed() rather than re-deriving it here is what keeps the two
87
+ // from drifting — a switch showing On beside a hidden button is the same
88
+ // class of confusion the detection exists to end.
89
+ const entry = { allowRestart: restartAllowed(resolved) };
90
+ let source = () => entry;
91
+ // Assigns ONLY what this namespace owns. Writing back a field the market
92
+ // stores elsewhere is how the channel lost its memory.
93
+ const apply = () => { resolved.allowRestart = source().allowRestart; };
94
+ // `inject` is the graceful-degradation boundary: on a host with no
95
+ // settings service the callback never runs and the composed entry stands.
96
+ ctx.inject(['settings'], (scopedCtx) => {
97
+ const scoped = scopedCtx;
98
+ const scope = scoped.settings.register(MARKET_SETTINGS_NS, MarketSettings, { base: entry });
99
+ source = () => scope.get();
100
+ // Unload restores the composed entry, so a disabled section cannot leave
101
+ // the routes reading a value nobody can see or change any more.
102
+ scoped.effect(() => () => {
103
+ source = () => entry;
104
+ apply();
105
+ });
106
+ apply();
107
+ scope.watch(apply);
108
+ });
109
+ }
@@ -0,0 +1,119 @@
1
+ import { marketFetch } from '../net.js';
2
+ /** Client for the public skill catalog and archive endpoints. */
3
+ export class SkillMarketApi {
4
+ config;
5
+ constructor(config) {
6
+ this.config = config;
7
+ }
8
+ /** Fetch one validated catalog page. */
9
+ async list(query = {}, signal) {
10
+ const page = positiveInteger(query.page, 1, 'page');
11
+ const pageSize = positiveInteger(query.pageSize, this.config.defaultPageSize, 'pageSize');
12
+ const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
13
+ for (const key of ['search', 'category', 'sort', 'tag']) {
14
+ const value = query[key];
15
+ if (value !== undefined && value.trim().length > 0)
16
+ params.set(key, value.trim());
17
+ }
18
+ const response = await fetchWithTimeout(`${this.config.baseUrl}/scm/inner/skillMarketplace/open/list?${params.toString()}`, this.config.requestTimeoutMs, signal);
19
+ if (!response.ok)
20
+ throw new Error(`技能列表请求失败: HTTP ${response.status}`);
21
+ const envelope = await response.json();
22
+ if (envelope.code !== 200 && envelope.code !== '200') {
23
+ throw new Error(`技能列表请求失败: ${typeof envelope.message === 'string' ? envelope.message : `code=${String(envelope.code)}`}`);
24
+ }
25
+ return parseListResult(envelope.data);
26
+ }
27
+ /** Download a skill archive; the caller consumes and size-caps the body. */
28
+ async download(id, signal) {
29
+ if (!Number.isSafeInteger(id) || id <= 0)
30
+ throw new TypeError('skill id must be a positive integer');
31
+ const response = await fetchWithTimeout(`${this.config.baseUrl}/scm/inner/skillMarketplace/open/download?id=${id}`, this.config.downloadTimeoutMs, signal);
32
+ if (response.status === 404)
33
+ throw new Error('技能不存在或无权下载');
34
+ if (!response.ok)
35
+ throw new Error(`技能下载失败: HTTP ${response.status}`);
36
+ const length = Number(response.headers.get('content-length'));
37
+ if (Number.isFinite(length) && length > this.config.maxDownloadBytes) {
38
+ throw new Error(`技能包超过 ${this.config.maxDownloadBytes} 字节限制`);
39
+ }
40
+ return response;
41
+ }
42
+ }
43
+ async function fetchWithTimeout(url, timeoutMs, signal) {
44
+ const controller = new AbortController();
45
+ const abort = () => controller.abort(signal?.reason);
46
+ signal?.addEventListener('abort', abort, { once: true });
47
+ const timeout = setTimeout(() => controller.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs);
48
+ try {
49
+ return await marketFetch(url, { signal: controller.signal, headers: { accept: 'application/json' } });
50
+ }
51
+ catch (error) {
52
+ if (controller.signal.aborted && !signal?.aborted)
53
+ throw new Error(`技能市场请求超时(${timeoutMs}ms)`, { cause: error });
54
+ throw error;
55
+ }
56
+ finally {
57
+ clearTimeout(timeout);
58
+ signal?.removeEventListener('abort', abort);
59
+ }
60
+ }
61
+ function positiveInteger(value, fallback, name) {
62
+ const resolved = value ?? fallback;
63
+ if (!Number.isSafeInteger(resolved) || resolved <= 0)
64
+ throw new TypeError(`${name} must be a positive integer`);
65
+ return resolved;
66
+ }
67
+ function parseListResult(value) {
68
+ if (!isRecord(value) || !Array.isArray(value.items))
69
+ throw new Error('技能列表响应格式异常:缺少 items 字段');
70
+ const total = readNonNegativeInteger(value.total, 'total');
71
+ const page = readPositiveInteger(value.page, 'page');
72
+ const pageSize = readPositiveInteger(value.pageSize, 'pageSize');
73
+ return { items: value.items.map(parseItem), total, page, pageSize };
74
+ }
75
+ function parseItem(value) {
76
+ if (!isRecord(value))
77
+ throw new Error('技能列表响应格式异常:技能条目不是对象');
78
+ return {
79
+ id: readPositiveInteger(value.id, 'item.id'),
80
+ name: readString(value.name, 'item.name'),
81
+ description: optionalString(value.description),
82
+ category: optionalString(value.category),
83
+ status: optionalString(value.status),
84
+ downloads: optionalNonNegativeInteger(value.downloads),
85
+ views: optionalNonNegativeInteger(value.views),
86
+ readme: optionalString(value.readme),
87
+ version: optionalString(value.version),
88
+ author: optionalString(value.author),
89
+ creatorName: optionalString(value.creatorName),
90
+ tags: optionalString(value.tags),
91
+ createTime: optionalString(value.createTime),
92
+ updateTime: optionalString(value.updateTime),
93
+ visibility: optionalString(value.visibility),
94
+ };
95
+ }
96
+ function isRecord(value) {
97
+ return typeof value === 'object' && value !== null;
98
+ }
99
+ function readString(value, name) {
100
+ if (typeof value !== 'string' || value.trim().length === 0)
101
+ throw new Error(`技能列表响应格式异常:${name}`);
102
+ return value;
103
+ }
104
+ function optionalString(value) {
105
+ return typeof value === 'string' ? value : '';
106
+ }
107
+ function readPositiveInteger(value, name) {
108
+ if (!Number.isSafeInteger(value) || value <= 0)
109
+ throw new Error(`技能列表响应格式异常:${name}`);
110
+ return value;
111
+ }
112
+ function readNonNegativeInteger(value, name) {
113
+ if (!Number.isSafeInteger(value) || value < 0)
114
+ throw new Error(`技能列表响应格式异常:${name}`);
115
+ return value;
116
+ }
117
+ function optionalNonNegativeInteger(value) {
118
+ return Number.isSafeInteger(value) && value >= 0 ? value : 0;
119
+ }
@@ -0,0 +1,29 @@
1
+ const DEFAULTS = Object.freeze({
2
+ baseUrl: 'https://devops.chinaunicom.cn/inner_source_scm',
3
+ requestTimeoutMs: 15_000,
4
+ downloadTimeoutMs: 60_000,
5
+ defaultPageSize: 10,
6
+ maxDownloadBytes: 32 * 1024 * 1024,
7
+ maxArchiveEntries: 256,
8
+ maxUncompressedBytes: 64 * 1024 * 1024,
9
+ });
10
+ /** Resolve and validate deployment-varying skill-market settings. */
11
+ export function resolveSkillMarketConfig(input) {
12
+ const resolved = { ...DEFAULTS, ...input, baseUrl: (input?.baseUrl ?? DEFAULTS.baseUrl).replace(/\/+$/u, '') };
13
+ if (resolved.baseUrl.length === 0 || !/^https?:\/\//u.test(resolved.baseUrl)) {
14
+ throw new TypeError('dsh-market: skillMarket.baseUrl must be an HTTP(S) URL');
15
+ }
16
+ for (const [name, value] of Object.entries({
17
+ requestTimeoutMs: resolved.requestTimeoutMs,
18
+ downloadTimeoutMs: resolved.downloadTimeoutMs,
19
+ defaultPageSize: resolved.defaultPageSize,
20
+ maxDownloadBytes: resolved.maxDownloadBytes,
21
+ maxArchiveEntries: resolved.maxArchiveEntries,
22
+ maxUncompressedBytes: resolved.maxUncompressedBytes,
23
+ })) {
24
+ if (!Number.isSafeInteger(value) || value <= 0) {
25
+ throw new TypeError(`dsh-market: skillMarket.${name} must be a positive integer`);
26
+ }
27
+ }
28
+ return Object.freeze(resolved);
29
+ }
@@ -0,0 +1,352 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { Transform } from 'node:stream';
7
+ import { load as loadYaml } from 'js-yaml';
8
+ import yauzl from 'yauzl';
9
+ import { resolveChatCodeHome } from '../home-paths.js';
10
+ export const SKILL_MARKET_META_FILE = '.skill-market.json';
11
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
12
+ /** Installs validated catalog archives into ChatCode CLI skill roots. */
13
+ export class SkillMarketInstaller {
14
+ api;
15
+ config;
16
+ constructor(api, config) {
17
+ this.api = api;
18
+ this.config = config;
19
+ }
20
+ /** Install or update one catalog skill for a local session workspace. */
21
+ async install(options) {
22
+ assertSkillName(options.name);
23
+ const roots = await resolveSkillRoots(options.cwd);
24
+ const skillsDir = roots[options.location];
25
+ const target = join(skillsDir, options.name);
26
+ const temp = await mkdtemp(join(tmpdir(), 'dsh-skill-market-'));
27
+ const archive = join(temp, 'skill.zip');
28
+ const extracted = join(temp, 'extracted');
29
+ let staged;
30
+ let backup;
31
+ try {
32
+ await this.downloadArchive(options.id, archive, options.signal);
33
+ await mkdir(extracted, { recursive: true });
34
+ await extractZip(archive, extracted, this.config, options.signal);
35
+ const contentRoot = await findSingleSkillRoot(extracted);
36
+ const manifest = await parseSkillManifest(join(contentRoot, 'SKILL.md'));
37
+ if (manifest.name !== options.name) {
38
+ throw new Error(`技能包名称不匹配:目录请求 ${options.name},SKILL.md 声明 ${manifest.name}`);
39
+ }
40
+ await mkdir(skillsDir, { recursive: true });
41
+ const existing = await installedMeta(target);
42
+ const updated = existing !== undefined;
43
+ if (updated && existing.id !== options.id) {
44
+ throw new Error(`目标 ${target} 已存在,且不属于技能市场条目 ${options.id}`);
45
+ }
46
+ if (!updated && await pathExists(target)) {
47
+ throw new Error(`目标 ${target} 已存在;不会覆盖非市场安装的技能`);
48
+ }
49
+ staged = join(skillsDir, `.${options.name}.install-${crypto.randomUUID()}`);
50
+ await cp(contentRoot, staged, { recursive: true, errorOnExist: true, force: false });
51
+ const meta = { id: options.id, name: options.name, version: options.version };
52
+ await writeFile(join(staged, SKILL_MARKET_META_FILE), `${JSON.stringify(meta, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
53
+ if (updated) {
54
+ backup = join(skillsDir, `.${options.name}.backup-${crypto.randomUUID()}`);
55
+ await rename(target, backup);
56
+ }
57
+ try {
58
+ await rename(staged, target);
59
+ staged = undefined;
60
+ }
61
+ catch (error) {
62
+ if (backup !== undefined)
63
+ await rename(backup, target);
64
+ backup = undefined;
65
+ throw error;
66
+ }
67
+ if (backup !== undefined) {
68
+ await rm(backup, { recursive: true, force: true });
69
+ backup = undefined;
70
+ }
71
+ return { skillName: options.name, installPath: target, location: options.location, updated };
72
+ }
73
+ finally {
74
+ if (staged !== undefined)
75
+ await rm(staged, { recursive: true, force: true });
76
+ if (backup !== undefined) {
77
+ if (!await pathExists(target))
78
+ await rename(backup, target);
79
+ else
80
+ await rm(backup, { recursive: true, force: true });
81
+ }
82
+ await rm(temp, { recursive: true, force: true });
83
+ }
84
+ }
85
+ async downloadArchive(id, destination, signal) {
86
+ const controller = new AbortController();
87
+ const abort = () => controller.abort(signal?.reason);
88
+ signal?.addEventListener('abort', abort, { once: true });
89
+ const timeout = setTimeout(() => controller.abort(new Error(`技能下载超时(${this.config.downloadTimeoutMs}ms)`)), this.config.downloadTimeoutMs);
90
+ try {
91
+ const response = await this.api.download(id, controller.signal);
92
+ await writeCappedResponse(response, destination, this.config.maxDownloadBytes, controller.signal);
93
+ }
94
+ catch (error) {
95
+ if (controller.signal.aborted && !signal?.aborted) {
96
+ throw new Error(`技能下载超时(${this.config.downloadTimeoutMs}ms)`, { cause: error });
97
+ }
98
+ throw error;
99
+ }
100
+ finally {
101
+ clearTimeout(timeout);
102
+ signal?.removeEventListener('abort', abort);
103
+ }
104
+ }
105
+ /** List market-managed skills visible from a local session workspace. */
106
+ async installed(cwd) {
107
+ const roots = await resolveSkillRoots(cwd);
108
+ const found = await Promise.all(['project', 'user'].map(async (location) => {
109
+ const root = roots[location];
110
+ let entries;
111
+ try {
112
+ entries = await readdir(root, { withFileTypes: true });
113
+ }
114
+ catch (error) {
115
+ if (isMissing(error))
116
+ return [];
117
+ throw error;
118
+ }
119
+ const installed = await Promise.all(entries.filter(entry => entry.isDirectory()).map(async (entry) => {
120
+ const path = join(root, entry.name);
121
+ if (!await pathExists(join(path, 'SKILL.md')))
122
+ return undefined;
123
+ const meta = await installedMeta(path);
124
+ return {
125
+ name: entry.name,
126
+ location,
127
+ path,
128
+ ...(meta?.id === undefined ? {} : { marketId: meta.id }),
129
+ ...(meta?.version === undefined ? {} : { version: meta.version }),
130
+ };
131
+ }));
132
+ return installed.filter((item) => item !== undefined);
133
+ }));
134
+ return found.flat().sort((a, b) => a.name.localeCompare(b.name) || a.location.localeCompare(b.location));
135
+ }
136
+ }
137
+ /** Resolve the two skill roots visible to a session workspace. */
138
+ export async function resolveSkillRoots(cwd) {
139
+ if (!isAbsolute(cwd))
140
+ throw new Error('技能市场仅支持本地绝对工作目录');
141
+ const cwdStat = await stat(cwd).catch(error => {
142
+ if (isMissing(error))
143
+ throw new Error(`工作目录不存在:${cwd}`, { cause: error });
144
+ throw error;
145
+ });
146
+ if (!cwdStat.isDirectory())
147
+ throw new Error(`工作目录不是目录:${cwd}`);
148
+ let projectRoot = resolve(cwd);
149
+ while (true) {
150
+ if (await pathExists(join(projectRoot, '.git')))
151
+ break;
152
+ const parent = dirname(projectRoot);
153
+ if (parent === projectRoot) {
154
+ projectRoot = resolve(cwd);
155
+ break;
156
+ }
157
+ projectRoot = parent;
158
+ }
159
+ return {
160
+ project: join(projectRoot, '.chatcode-cli', 'skills'),
161
+ user: join(resolveChatCodeHome(), 'skills'),
162
+ };
163
+ }
164
+ async function writeCappedResponse(response, destination, limit, signal) {
165
+ if (response.body === null)
166
+ throw new Error('技能下载响应无内容');
167
+ let bytes = 0;
168
+ const counter = new Transform({
169
+ transform(chunk, _encoding, callback) {
170
+ bytes += chunk.length;
171
+ if (bytes > limit)
172
+ callback(new Error(`技能包超过 ${limit} 字节限制`));
173
+ else
174
+ callback(null, chunk);
175
+ },
176
+ });
177
+ await pipeline(response.body, counter, createWriteStream(destination, { flags: 'wx', mode: 0o600 }), { signal });
178
+ }
179
+ async function extractZip(zipPath, target, config, signal) {
180
+ const zip = await openZip(zipPath);
181
+ let entries = 0;
182
+ let totalBytes = 0;
183
+ let abort;
184
+ try {
185
+ await new Promise((resolvePromise, rejectPromise) => {
186
+ let settled = false;
187
+ const fail = (error) => {
188
+ if (settled)
189
+ return;
190
+ settled = true;
191
+ rejectPromise(error);
192
+ };
193
+ abort = () => fail(signal?.reason ?? new Error('技能安装已取消'));
194
+ signal?.addEventListener('abort', abort, { once: true });
195
+ zip.on('error', fail);
196
+ zip.on('end', () => {
197
+ if (settled)
198
+ return;
199
+ settled = true;
200
+ resolvePromise();
201
+ });
202
+ zip.on('entry', (entry) => {
203
+ void extractEntry(zip, entry, target, {
204
+ addEntry: () => {
205
+ entries += 1;
206
+ if (entries > config.maxArchiveEntries)
207
+ throw new Error(`技能包文件数超过 ${config.maxArchiveEntries} 个限制`);
208
+ },
209
+ addBytes: (count) => {
210
+ totalBytes += count;
211
+ if (totalBytes > config.maxUncompressedBytes)
212
+ throw new Error(`技能包解压后超过 ${config.maxUncompressedBytes} 字节限制`);
213
+ },
214
+ }, signal).then(() => zip.readEntry(), fail);
215
+ });
216
+ zip.readEntry();
217
+ });
218
+ }
219
+ finally {
220
+ if (abort !== undefined)
221
+ signal?.removeEventListener('abort', abort);
222
+ zip.close();
223
+ }
224
+ }
225
+ async function extractEntry(zip, entry, target, limits, signal) {
226
+ limits.addEntry();
227
+ const name = entry.fileName;
228
+ if (name.includes('\\') || name.includes('\0') || isAbsolute(name))
229
+ throw new Error(`技能包包含非法路径:${name}`);
230
+ const normalized = normalize(name);
231
+ if (normalized === '..' || normalized.startsWith(`..${sep}`))
232
+ throw new Error(`技能包路径越界:${name}`);
233
+ const output = resolve(target, normalized);
234
+ const inside = relative(target, output);
235
+ if (inside.startsWith('..') || isAbsolute(inside))
236
+ throw new Error(`技能包路径越界:${name}`);
237
+ const mode = (entry.externalFileAttributes >>> 16) & 0o170000;
238
+ if (mode === 0o120000)
239
+ throw new Error(`技能包不允许符号链接:${name}`);
240
+ if (name.endsWith('/')) {
241
+ await mkdir(output, { recursive: true });
242
+ return;
243
+ }
244
+ limits.addBytes(entry.uncompressedSize);
245
+ await mkdir(dirname(output), { recursive: true });
246
+ const input = await openReadStream(zip, entry);
247
+ let actualBytes = 0;
248
+ const counter = new Transform({
249
+ transform(chunk, _encoding, callback) {
250
+ actualBytes += chunk.length;
251
+ if (actualBytes > entry.uncompressedSize || signal?.aborted)
252
+ callback(new Error('技能包内容长度异常或安装已取消'));
253
+ else
254
+ callback(null, chunk);
255
+ },
256
+ });
257
+ await pipeline(input, counter, createWriteStream(output, { flags: 'wx', mode: 0o600 }), { signal });
258
+ }
259
+ function openZip(path) {
260
+ return new Promise((resolvePromise, rejectPromise) => {
261
+ yauzl.open(path, { lazyEntries: true, strictFileNames: true, validateEntrySizes: true }, (error, zip) => {
262
+ if (error !== null)
263
+ rejectPromise(error);
264
+ else if (zip === undefined)
265
+ rejectPromise(new Error('无法打开技能包'));
266
+ else
267
+ resolvePromise(zip);
268
+ });
269
+ });
270
+ }
271
+ function openReadStream(zip, entry) {
272
+ return new Promise((resolvePromise, rejectPromise) => {
273
+ zip.openReadStream(entry, (error, stream) => {
274
+ if (error !== null)
275
+ rejectPromise(error);
276
+ else if (stream === undefined)
277
+ rejectPromise(new Error(`无法读取技能包条目:${entry.fileName}`));
278
+ else
279
+ resolvePromise(stream);
280
+ });
281
+ });
282
+ }
283
+ async function findSingleSkillRoot(root) {
284
+ const found = [];
285
+ async function walk(directory) {
286
+ const entries = await readdir(directory, { withFileTypes: true });
287
+ if (entries.some(entry => entry.isFile() && entry.name === 'SKILL.md'))
288
+ found.push(directory);
289
+ for (const entry of entries) {
290
+ if (!entry.isDirectory() || entry.name.toLowerCase() === '__macosx')
291
+ continue;
292
+ await walk(join(directory, entry.name));
293
+ }
294
+ }
295
+ await walk(root);
296
+ if (found.length === 0)
297
+ throw new Error('无效的技能包:缺少 SKILL.md');
298
+ if (found.length > 1)
299
+ throw new Error('无效的技能包:包含多个 SKILL.md 根目录');
300
+ return found[0];
301
+ }
302
+ async function parseSkillManifest(path) {
303
+ const content = await readFile(path, 'utf8');
304
+ const match = /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(content);
305
+ if (match === null)
306
+ throw new Error('无效的 SKILL.md:缺少 YAML frontmatter');
307
+ const data = loadYaml(match[1]);
308
+ if (typeof data !== 'object' || data === null)
309
+ throw new Error('无效的 SKILL.md:frontmatter 不是对象');
310
+ const name = data.name;
311
+ const description = data.description;
312
+ if (typeof name !== 'string')
313
+ throw new Error('无效的 SKILL.md:缺少 name');
314
+ assertSkillName(name);
315
+ if (typeof description !== 'string' || description.trim().length === 0)
316
+ throw new Error('无效的 SKILL.md:缺少 description');
317
+ return { name };
318
+ }
319
+ function assertSkillName(name) {
320
+ if (!SKILL_NAME.test(name))
321
+ throw new TypeError(`无效的技能名称:${name}`);
322
+ }
323
+ async function installedMeta(target) {
324
+ try {
325
+ const parsed = JSON.parse(await readFile(join(target, SKILL_MARKET_META_FILE), 'utf8'));
326
+ if (typeof parsed !== 'object' || parsed === null)
327
+ return undefined;
328
+ const value = parsed;
329
+ if (!Number.isSafeInteger(value.id) || typeof value.name !== 'string')
330
+ return undefined;
331
+ return { id: value.id, name: value.name, version: typeof value.version === 'string' ? value.version : undefined };
332
+ }
333
+ catch (error) {
334
+ if (isMissing(error) || error instanceof SyntaxError)
335
+ return undefined;
336
+ throw error;
337
+ }
338
+ }
339
+ async function pathExists(path) {
340
+ try {
341
+ await stat(path);
342
+ return true;
343
+ }
344
+ catch (error) {
345
+ if (isMissing(error))
346
+ return false;
347
+ throw error;
348
+ }
349
+ }
350
+ function isMissing(error) {
351
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
352
+ }
@@ -0,0 +1,13 @@
1
+ import { SkillMarketApi } from './api.js';
2
+ import { SkillMarketInstaller } from './installer.js';
3
+ /** Shared skill-market operations consumed by Web routes and the terminal scene. */
4
+ export class SkillMarketRuntime {
5
+ config;
6
+ api;
7
+ installer;
8
+ constructor(config) {
9
+ this.config = config;
10
+ this.api = new SkillMarketApi(config);
11
+ this.installer = new SkillMarketInstaller(this.api, config);
12
+ }
13
+ }