glad-web 1.0.45 → 1.0.46

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.
@@ -330,6 +330,11 @@ class CodexStructuredSession extends EventEmitter {
330
330
  this.timedInputs = new Map();
331
331
  this.promptHistoryCache = null;
332
332
  this.deferredWarnings = null;
333
+ this.activeSkill = options.activeSkill && options.activeSkill.name && options.activeSkill.path
334
+ ? { name: String(options.activeSkill.name), path: String(options.activeSkill.path) }
335
+ : null;
336
+ this.extraSkillRoots = (Array.isArray(options.extraSkillRoots) ? options.extraSkillRoots : [])
337
+ .map(value => String(value || '').trim()).filter(Boolean);
333
338
  }
334
339
 
335
340
  toListItem() {
@@ -540,6 +545,9 @@ class CodexStructuredSession extends EventEmitter {
540
545
  }, { fatalOnTimeout: true });
541
546
  }).then(async () => {
542
547
  this.notify('initialized', {});
548
+ if (this.extraSkillRoots.length) {
549
+ await this.request('skills/extraRoots/set', { extraRoots: this.extraSkillRoots }, { fatalOnTimeout: true });
550
+ }
543
551
  try { await this.refreshConfigDefaults(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] config/read failed: ${error.message}`); }
544
552
  try { await this.refreshModels(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] model/list failed: ${error.message}`); }
545
553
  resolve();
@@ -908,10 +916,11 @@ class CodexStructuredSession extends EventEmitter {
908
916
 
909
917
  async listSkills(forceReload = false) {
910
918
  await this.ensureProcess();
911
- const result = await this.request('skills/list', {
919
+ const params = {
912
920
  cwds: [this.workingDir],
913
921
  forceReload: Boolean(forceReload)
914
- });
922
+ };
923
+ const result = await this.request('skills/list', params);
915
924
  const entries = Array.isArray(result?.data) ? result.data : [];
916
925
  const entry = entries.find(item => item?.cwd === this.workingDir) || entries[0] || {};
917
926
  return {
@@ -921,7 +930,10 @@ class CodexStructuredSession extends EventEmitter {
921
930
  }
922
931
 
923
932
  async resolveSkillInputs(skills) {
924
- const requested = (Array.isArray(skills) ? skills : []).slice(0, 8);
933
+ const requested = [
934
+ ...(this.activeSkill ? [this.activeSkill] : []),
935
+ ...(Array.isArray(skills) ? skills : [])
936
+ ].slice(0, 8);
925
937
  if (!requested.length) return [];
926
938
  const available = await this.listSkills(false);
927
939
  const allowed = new Map(available.skills.map(item => [`${item.name}\n${item.path}`, item]));
@@ -48,10 +48,14 @@ const registerWorkspaceRoutes = require('../server/routes/workspace');
48
48
  const registerProviderRoutes = require('../server/routes/providers');
49
49
  const registerNotificationRoutes = require('../server/routes/notifications');
50
50
  const registerUsageRoutes = require('../server/routes/usage');
51
+ const registerSkillHubRoutes = require('../server/routes/skillhub');
51
52
  const { UsageService } = require('../usage/usage-service');
52
53
  const { ServerChanSettingsStore } = require('../notifications/serverchan-settings-store');
53
54
  const ServerChanClient = require('../notifications/serverchan-client');
54
55
  const NotificationService = require('../notifications/notification-service');
56
+ const { SkillHubSettingsStore } = require('../skillhub/settings-store');
57
+ const { SkillHubClient } = require('../skillhub/client');
58
+ const { SkillInstaller } = require('../skillhub/skill-installer');
55
59
 
56
60
  async function webCommand(options) {
57
61
  const port = parseInt(options.port) || 3000;
@@ -104,6 +108,10 @@ async function webCommand(options) {
104
108
  logger
105
109
  });
106
110
  const usageService = new UsageService({ logger });
111
+ const skillHubSettings = new SkillHubSettingsStore();
112
+ const skillHubClient = new SkillHubClient({ settingsStore: skillHubSettings });
113
+ const skillInstaller = new SkillInstaller({ client: skillHubClient });
114
+ await skillInstaller.initialize();
107
115
  sessionManager.on('output', ({ sessionId, data }) => {
108
116
  broadcastToSession(sessionId, { type: 'output', data });
109
117
  });
@@ -147,6 +155,12 @@ async function webCommand(options) {
147
155
  notificationService
148
156
  });
149
157
  registerUsageRoutes(app, { usageService, sendJson: sendCompressedJson });
158
+ registerSkillHubRoutes(app, {
159
+ settingsStore: skillHubSettings,
160
+ client: skillHubClient,
161
+ installer: skillInstaller,
162
+ sessionManager
163
+ });
150
164
 
151
165
  // API: List all active sessions
152
166
  app.get('/api/sessions', (req, res) => {
@@ -310,7 +324,8 @@ async function webCommand(options) {
310
324
 
311
325
  // API: Delete/Kill session
312
326
  app.delete('/api/sessions/:id', async (req, res) => {
313
- await sessionManager.kill(req.params.id);
327
+ const deleted = await sessionManager.kill(req.params.id);
328
+ if (!deleted) return res.status(404).json({ error: 'Session not found' });
314
329
  res.json({ success: true });
315
330
  });
316
331
 
@@ -509,12 +524,14 @@ async function webCommand(options) {
509
524
  };
510
525
 
511
526
  const webAssets = [
527
+ 'bootstrap.js',
512
528
  'gitgraph.js',
513
529
  'styles.css',
514
530
  'theme.js',
515
531
  'core.js',
516
532
  'layout.js',
517
533
  'notifications.js',
534
+ 'skillhub.js',
518
535
  'claude.js',
519
536
  'schedules.js',
520
537
  'shell.js',
@@ -575,12 +592,14 @@ async function webCommand(options) {
575
592
  console.log(chalk.gray(`Tips: Access from your phone via the Network URL above.\n`));
576
593
  });
577
594
 
578
- process.on('SIGINT', () => {
595
+ const shutdown = () => {
579
596
  schedulerService.stop();
580
597
  notificationService.stop();
581
598
  sessionManager.killAll();
582
- process.exit(0);
583
- });
599
+ process.exit(0);
600
+ };
601
+ process.once('SIGINT', shutdown);
602
+ process.once('SIGTERM', shutdown);
584
603
  }
585
604
 
586
605
  module.exports = webCommand;
@@ -26,6 +26,25 @@ const schema = {
26
26
  clientType: 'wechat'
27
27
  }
28
28
  },
29
+ skillHub: {
30
+ type: 'object',
31
+ properties: {
32
+ baseUrl: { type: 'string', default: '' },
33
+ token: {
34
+ type: 'object',
35
+ properties: {
36
+ ciphertext: { type: 'string', default: '' },
37
+ iv: { type: 'string', default: '' },
38
+ authTag: { type: 'string', default: '' }
39
+ },
40
+ default: { ciphertext: '', iv: '', authTag: '' }
41
+ }
42
+ },
43
+ default: {
44
+ baseUrl: '',
45
+ token: { ciphertext: '', iv: '', authTag: '' }
46
+ }
47
+ },
29
48
  version: {
30
49
  type: 'string',
31
50
  default: '1.0.0'
@@ -0,0 +1,104 @@
1
+ const { v4: uuidv4 } = require('uuid');
2
+
3
+ function statusCode(error) {
4
+ return Number(error?.statusCode) || 500;
5
+ }
6
+
7
+ function errorBody(error) {
8
+ return {
9
+ error: error?.message || 'SkillHub 操作失败',
10
+ ...(error?.code ? { code: error.code } : {})
11
+ };
12
+ }
13
+
14
+ function registerSkillHubRoutes(app, {
15
+ settingsStore,
16
+ client,
17
+ installer,
18
+ sessionManager
19
+ }) {
20
+ app.get('/api/skillhub/status', (_req, res) => {
21
+ res.json({ available: installer.available === true });
22
+ });
23
+
24
+ app.get('/api/skillhub/settings', (_req, res) => {
25
+ try { res.json(settingsStore.getPublic()); }
26
+ catch (error) { res.status(statusCode(error)).json(errorBody(error)); }
27
+ });
28
+
29
+ app.put('/api/skillhub/settings', async (req, res) => {
30
+ try {
31
+ const resolved = settingsStore.resolve(req.body || {});
32
+ const user = await client.test(resolved);
33
+ const settings = settingsStore.save(resolved);
34
+ res.json({ success: true, settings, user });
35
+ } catch (error) {
36
+ res.status(statusCode(error)).json(errorBody(error));
37
+ }
38
+ });
39
+
40
+ app.delete('/api/skillhub/settings', (_req, res) => {
41
+ try { res.json({ success: true, settings: settingsStore.clear() }); }
42
+ catch (error) { res.status(statusCode(error)).json(errorBody(error)); }
43
+ });
44
+
45
+ app.post('/api/skillhub/settings/test', async (req, res) => {
46
+ try {
47
+ const settings = settingsStore.resolve(req.body || {});
48
+ const user = await client.test(settings);
49
+ res.json({ success: true, user });
50
+ } catch (error) {
51
+ res.status(statusCode(error)).json(errorBody(error));
52
+ }
53
+ });
54
+
55
+ app.get('/api/skillhub/skills', async (_req, res) => {
56
+ try {
57
+ installer.assertAvailable();
58
+ const skills = await client.listSkills();
59
+ res.json({ success: true, skills });
60
+ } catch (error) {
61
+ res.status(statusCode(error)).json(errorBody(error));
62
+ }
63
+ });
64
+
65
+ app.post('/api/skillhub/sessions', async (req, res) => {
66
+ const sessionId = uuidv4();
67
+ let created = false;
68
+ try {
69
+ if (req.body?.toolKey !== 'codex') {
70
+ const error = new Error('SkillHub Session 当前只支持 Codex');
71
+ error.statusCode = 400;
72
+ error.code = 'SKILLHUB_CODEX_ONLY';
73
+ throw error;
74
+ }
75
+ const activeSkill = await installer.prepare(sessionId, req.body?.skill || {});
76
+ const session = sessionManager.create({
77
+ id: sessionId,
78
+ toolKey: 'codex',
79
+ workingDirectory: req.body?.workingDirectory,
80
+ name: activeSkill.name,
81
+ codexOptions: {
82
+ activeSkill,
83
+ extraSkillRoots: [activeSkill.skillsRoot]
84
+ },
85
+ disposeResources: () => installer.cleanupSync(sessionId)
86
+ });
87
+ created = true;
88
+ const defaultPrompt = activeSkill.defaultPrompt
89
+ || '请先用中文介绍这个 Skill 能完成什么、适合哪些任务,以及用户接下来应该如何使用。这一轮只做使用引导。';
90
+ const intro = `$${activeSkill.name}\n\n${defaultPrompt}`;
91
+ const started = await sessionManager.sendCodexInput(session.id, intro, [], [activeSkill], []);
92
+ if (!started) throw new Error('Codex Skill 引导会话启动失败');
93
+ res.status(201).json({ id: session.id, name: session.name });
94
+ } catch (error) {
95
+ if (created) await sessionManager.kill(sessionId).catch(() => {});
96
+ else {
97
+ try { installer.cleanupSync(sessionId); } catch (_) { /* 目录可能尚未创建 */ }
98
+ }
99
+ res.status(statusCode(error)).json(errorBody(error));
100
+ }
101
+ });
102
+ }
103
+
104
+ module.exports = registerSkillHubRoutes;
@@ -38,6 +38,12 @@ function promptWithFileReferences(text, attachments) {
38
38
  return prompt ? `${prompt}\n\n${note}` : note;
39
39
  }
40
40
 
41
+ const STRUCTURED_SESSION_KINDS = new Set(['claude-structured', 'codex-structured']);
42
+
43
+ function isStructuredSession(session) {
44
+ return Boolean(session && STRUCTURED_SESSION_KINDS.has(session.kind));
45
+ }
46
+
41
47
  class SessionManager extends EventEmitter {
42
48
  constructor({ baseDir, renderHistoryTools, debugHistoryEnabled = false, logger, hasConnectedSessionClient, claudeTranscriptRepository, codexImageStore, fileAttachmentStore, claudeForkSession } = {}) {
43
49
  super();
@@ -77,7 +83,7 @@ class SessionManager extends EventEmitter {
77
83
  startTime: session.startTime,
78
84
  toolKey: session.tool.key,
79
85
  workingDirectory: this.getSessionWorkingDirectory(session),
80
- mode: ['claude-structured', 'codex-structured'].includes(session.kind) ? 'structured' : 'terminal',
86
+ mode: isStructuredSession(session) ? 'structured' : 'terminal',
81
87
  hasUnreadCompletion: Boolean(session.hasUnreadCompletion),
82
88
  serverChanNotificationEnabled: Boolean(session.serverChanNotificationEnabled),
83
89
  timedInputCount: session.timedInputs
@@ -94,7 +100,7 @@ class SessionManager extends EventEmitter {
94
100
  return this.sessions.has(id);
95
101
  }
96
102
 
97
- create({ toolKey, workingDirectory, name, claudeOptions }) {
103
+ create({ id: requestedId, toolKey, workingDirectory, name, claudeOptions, codexOptions, disposeResources }) {
98
104
  this.logger.info(`Creating session: toolKey=${toolKey || ''}, workingDirectory=${workingDirectory || '(default)'}`);
99
105
  const tool = getToolByKey(toolKey);
100
106
  if (!tool) {
@@ -108,7 +114,14 @@ class SessionManager extends EventEmitter {
108
114
  return this.createClaudeStructuredSession({ tool, workingDirectory, name, claudeOptions });
109
115
  }
110
116
  if (tool.key === 'codex') {
111
- return this.createCodexStructuredSession({ tool, workingDirectory, name });
117
+ return this.createCodexStructuredSession({
118
+ id: requestedId,
119
+ tool,
120
+ workingDirectory,
121
+ name,
122
+ codexOptions,
123
+ disposeResources
124
+ });
112
125
  }
113
126
 
114
127
  const id = uuidv4();
@@ -210,7 +223,7 @@ class SessionManager extends EventEmitter {
210
223
  return session;
211
224
  }
212
225
 
213
- createCodexStructuredSession({ tool, workingDirectory, name, codexOptions = {} }) {
226
+ createCodexStructuredSession({ id: requestedId, tool, workingDirectory, name, codexOptions = {}, disposeResources = null }) {
214
227
  const sessionDir = workingDirectory && String(workingDirectory).trim()
215
228
  ? path.resolve(this.baseDir, String(workingDirectory).trim())
216
229
  : this.baseDir;
@@ -219,12 +232,13 @@ class SessionManager extends EventEmitter {
219
232
  err.statusCode = 400;
220
233
  throw err;
221
234
  }
222
- const id = uuidv4();
235
+ const id = requestedId || uuidv4();
223
236
  const session = new CodexStructuredSession({ id, tool, workingDir: sessionDir, name: name || tool.displayName, logger: this.logger, options: codexOptions });
224
237
  session.imageAttachments = new Map();
225
238
  session.imageUploads = new Map();
226
239
  session.fileAttachments = new Map();
227
240
  session.fileUploads = new Map();
241
+ session.disposeResources = typeof disposeResources === 'function' ? disposeResources : null;
228
242
  this.sessions.set(id, session);
229
243
  session.on('event', event => this.emit('codex-event', { sessionId: id, event, session }));
230
244
  session.on('exit', () => this.handleExit(session));
@@ -239,13 +253,13 @@ class SessionManager extends EventEmitter {
239
253
  const session = this.get(id);
240
254
  if (!session) return false;
241
255
  this.markSessionInput(session, data);
242
- if (['claude-structured', 'codex-structured'].includes(session.kind)) return session.write(data);
256
+ if (isStructuredSession(session)) return session.write(data);
243
257
  return session.ptyManager.write(data);
244
258
  }
245
259
 
246
260
  sendTerminalFileInput(id, text, fileAttachmentIds = []) {
247
261
  const session = this.get(id);
248
- if (!session || ['claude-structured', 'codex-structured'].includes(session.kind)) return false;
262
+ if (!session || isStructuredSession(session)) return false;
249
263
  const files = this.getFileAttachments(id, fileAttachmentIds);
250
264
  const prompt = promptWithFileReferences(text, files);
251
265
  if (!prompt) return false;
@@ -519,7 +533,7 @@ class SessionManager extends EventEmitter {
519
533
  redraw(id, cols, rows) {
520
534
  const session = this.get(id);
521
535
  if (!session) return false;
522
- if (['claude-structured', 'codex-structured'].includes(session.kind)) return true;
536
+ if (isStructuredSession(session)) return true;
523
537
  if (session.renderedHistory) {
524
538
  session.renderedHistory.resize(cols, rows);
525
539
  }
@@ -537,7 +551,7 @@ class SessionManager extends EventEmitter {
537
551
  markCompletionRead(id) {
538
552
  const session = this.get(id);
539
553
  if (!session) return null;
540
- if (['claude-structured', 'codex-structured'].includes(session.kind)) {
554
+ if (isStructuredSession(session)) {
541
555
  session.markCompletionRead();
542
556
  this.logSessionDiagnostics('completion-read', session, {}, { compact: true });
543
557
  return session;
@@ -661,33 +675,57 @@ class SessionManager extends EventEmitter {
661
675
  return true;
662
676
  }
663
677
 
664
- async kill(id) {
665
- const session = this.get(id);
666
- if (!session) return false;
678
+ quiesceSession(session) {
667
679
  clearTimeout(session.completionTimer);
668
680
  this.clearTimedInputs(session);
681
+ }
682
+
683
+ disposeSessionResources(session) {
684
+ this.quiesceSession(session);
669
685
  this.clearCodexImageUploads(session);
670
686
  this.clearCodexImageAttachments(session);
671
- this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
687
+ this.fileAttachmentStore.clear(session);
688
+ if (typeof session.disposeResources === 'function') {
689
+ try { session.disposeResources(); } catch (error) {
690
+ this.logger.error?.(`Failed to clean session resources ${session.id}: ${error.message}`);
691
+ }
692
+ session.disposeResources = null;
693
+ }
672
694
  this.disposeSessionHistory(session);
673
- if (['claude-structured', 'codex-structured'].includes(session.kind)) {
674
- await session.ptyManager.kill();
675
- return true;
695
+ }
696
+
697
+ async stopSessionRuntime(session) {
698
+ if (isStructuredSession(session)) {
699
+ await session.kill();
700
+ return;
676
701
  }
677
702
  session.ptyManager.kill();
678
- this.sessions.delete(id);
679
- this.emit('exit', { sessionId: id, session });
703
+ }
704
+
705
+ removeSession(session) {
706
+ if (this.sessions.get(session.id) !== session) return false;
707
+ this.disposeSessionResources(session);
708
+ this.sessions.delete(session.id);
709
+ this.emit('exit', { sessionId: session.id, session });
710
+ return true;
711
+ }
712
+
713
+ async kill(id) {
714
+ const session = this.get(id);
715
+ if (!session) return false;
716
+ this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
717
+ this.quiesceSession(session);
718
+ await this.stopSessionRuntime(session);
719
+ this.removeSession(session);
680
720
  return true;
681
721
  }
682
722
 
683
723
  killAll() {
684
- for (const session of this.sessions.values()) {
685
- clearTimeout(session.completionTimer);
686
- this.clearTimedInputs(session);
687
- this.clearCodexImageUploads(session);
688
- this.clearCodexImageAttachments(session);
689
- this.disposeSessionHistory(session);
690
- session.ptyManager.kill();
724
+ for (const session of Array.from(this.sessions.values())) {
725
+ this.disposeSessionResources(session);
726
+ void this.stopSessionRuntime(session).catch(error => {
727
+ this.logger.error?.(`Failed to stop session ${session.id}: ${error.message}`);
728
+ });
691
729
  }
692
730
  this.sessions.clear();
693
731
  }
@@ -695,7 +733,7 @@ class SessionManager extends EventEmitter {
695
733
  getHistory(id) {
696
734
  const session = this.get(id);
697
735
  if (!session) return null;
698
- if (['claude-structured', 'codex-structured'].includes(session.kind)) return session.getHistory();
736
+ if (isStructuredSession(session)) return session.getHistory();
699
737
  const historySource = session.renderedHistory || session.textHistory;
700
738
  return {
701
739
  success: true,
@@ -710,7 +748,7 @@ class SessionManager extends EventEmitter {
710
748
  getCatchupOutput(id) {
711
749
  const session = this.get(id);
712
750
  if (!session) return null;
713
- if (['claude-structured', 'codex-structured'].includes(session.kind)) return session.getCatchupOutput();
751
+ if (isStructuredSession(session)) return session.getCatchupOutput();
714
752
 
715
753
  const bufferHistory = session.buffer.getAfter(0);
716
754
  if (bufferHistory.length > 0) {
@@ -849,22 +887,15 @@ class SessionManager extends EventEmitter {
849
887
  }
850
888
 
851
889
  handleExit(session) {
852
- if (!this.sessions.has(session.id)) return;
890
+ if (this.sessions.get(session.id) !== session) return;
853
891
  this.logger.info(`Session ${session.id} (${session.name}) exited.`);
854
- clearTimeout(session.completionTimer);
855
- this.clearTimedInputs(session);
856
- this.clearCodexImageUploads(session);
857
- this.clearCodexImageAttachments(session);
858
- this.fileAttachmentStore.clear(session);
859
- this.disposeSessionHistory(session);
860
- this.sessions.delete(session.id);
861
- this.emit('exit', { sessionId: session.id, session });
892
+ this.removeSession(session);
862
893
  }
863
894
 
864
895
  markSessionInput(session, data) {
865
896
  if (typeof data !== 'string' || data.length === 0) return;
866
897
  session.inputSeq = (session.inputSeq || 0) + 1;
867
- if (['claude-structured', 'codex-structured'].includes(session.kind)) {
898
+ if (isStructuredSession(session)) {
868
899
  session.hasUnreadCompletion = false;
869
900
  this.logSessionDiagnostics('session-input', session, {
870
901
  inputSeq: session.inputSeq,
@@ -883,7 +914,7 @@ class SessionManager extends EventEmitter {
883
914
  }
884
915
 
885
916
  disposeSessionHistory(session) {
886
- if (['claude-structured', 'codex-structured'].includes(session.kind)) return;
917
+ if (isStructuredSession(session)) return;
887
918
  if (session.renderedHistory) {
888
919
  session.renderedHistory.dispose();
889
920
  session.renderedHistory = null;
@@ -907,7 +938,7 @@ class SessionManager extends EventEmitter {
907
938
  }
908
939
 
909
940
  getSessionDiagnostics(session, extra = {}) {
910
- if (['claude-structured', 'codex-structured'].includes(session.kind)) {
941
+ if (isStructuredSession(session)) {
911
942
  return {
912
943
  sessionId: session.id,
913
944
  sessionName: session.name,
@@ -936,7 +967,7 @@ class SessionManager extends EventEmitter {
936
967
  }
937
968
 
938
969
  getCompactSessionDiagnostics(session, extra = {}) {
939
- if (['claude-structured', 'codex-structured'].includes(session.kind)) {
970
+ if (isStructuredSession(session)) {
940
971
  return this.getSessionDiagnostics(session, extra);
941
972
  }
942
973
  const buffer = session.buffer.getDebugSnapshot();
@@ -0,0 +1,121 @@
1
+ const DEFAULT_TIMEOUT_MS = 15_000;
2
+ const MAX_BUNDLE_BYTES = 20 * 1024 * 1024;
3
+
4
+ function clientProblem(message, statusCode = 502, code = 'SKILLHUB_REQUEST_FAILED') {
5
+ const error = new Error(message);
6
+ error.statusCode = statusCode;
7
+ error.code = code;
8
+ return error;
9
+ }
10
+
11
+ class SkillHubClient {
12
+ constructor({ settingsStore, fetchImpl = global.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
13
+ this.settingsStore = settingsStore;
14
+ this.fetchImpl = fetchImpl;
15
+ this.timeoutMs = timeoutMs;
16
+ }
17
+
18
+ async request(pathname, { method = 'GET', settings = null, body = null, accept = 'application/json' } = {}) {
19
+ const current = settings || this.settingsStore.resolve();
20
+ const base = `${current.baseUrl.replace(/\/$/, '')}/`;
21
+ const path = String(pathname || '').replace(/^\//, '');
22
+ const url = new URL(path, base);
23
+ const controller = new AbortController();
24
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
25
+ try {
26
+ const response = await this.fetchImpl(url, {
27
+ method,
28
+ redirect: 'error',
29
+ signal: controller.signal,
30
+ headers: {
31
+ Accept: accept,
32
+ Authorization: `Bearer ${current.token}`,
33
+ ...(body ? { 'Content-Type': 'application/json' } : {})
34
+ },
35
+ ...(body ? { body: JSON.stringify(body) } : {})
36
+ });
37
+ if (!response.ok) {
38
+ let detail = '';
39
+ try {
40
+ const payload = await response.json();
41
+ detail = payload?.error?.message || payload?.error || payload?.message || '';
42
+ } catch (_) { /* response body is not JSON */ }
43
+ const statusCode = response.status === 401 || response.status === 403 ? response.status : 502;
44
+ const code = response.status === 401 ? 'SKILLHUB_UNAUTHORIZED'
45
+ : response.status === 403 ? 'SKILLHUB_FORBIDDEN' : 'SKILLHUB_BAD_RESPONSE';
46
+ throw clientProblem(detail || `SkillHub 返回 HTTP ${response.status}`, statusCode, code);
47
+ }
48
+ return response;
49
+ } catch (error) {
50
+ if (error.statusCode) throw error;
51
+ if (error.name === 'AbortError') throw clientProblem('SkillHub 请求超时', 504, 'SKILLHUB_TIMEOUT');
52
+ throw clientProblem(`无法连接 SkillHub:${error.message}`);
53
+ } finally {
54
+ clearTimeout(timer);
55
+ }
56
+ }
57
+
58
+ async test(settings) {
59
+ const response = await this.request('/api/v1/whoami', { settings });
60
+ return response.json();
61
+ }
62
+
63
+ async listSkills() {
64
+ const items = [];
65
+ let cursor = '';
66
+ for (let page = 0; page < 100; page += 1) {
67
+ const query = new URLSearchParams({ limit: '100', order: 'updated_at_desc' });
68
+ if (cursor) query.set('cursor', cursor);
69
+ const response = await this.request(`/api/runtime/skills?${query}`);
70
+ const payload = await response.json();
71
+ if (!Array.isArray(payload?.data)) throw clientProblem('SkillHub Skill 列表格式无效');
72
+ items.push(...payload.data);
73
+ cursor = String(payload.nextCursor || '');
74
+ if (!cursor) return items;
75
+ }
76
+ throw clientProblem('SkillHub Skill 列表分页过多');
77
+ }
78
+
79
+ async getSkill({ id, version, digest }) {
80
+ const query = new URLSearchParams({ include: 'manifest,skillMd' });
81
+ if (version) query.set('version', version);
82
+ if (digest) query.set('digest', digest);
83
+ const response = await this.request(`/api/runtime/skills/by-id/${encodeURIComponent(id)}?${query}`);
84
+ return response.json();
85
+ }
86
+
87
+ async downloadBundle({ id, version, digest }) {
88
+ const query = new URLSearchParams({ id, format: 'zip' });
89
+ if (version) query.set('version', version);
90
+ if (digest) query.set('digest', digest);
91
+ const response = await this.request(`/api/runtime/skills/bundle?${query}`, {
92
+ accept: 'application/zip'
93
+ });
94
+ const declared = Number(response.headers.get('content-length') || 0);
95
+ if (declared > MAX_BUNDLE_BYTES) {
96
+ throw clientProblem('Skill bundle 超过 20 MB', 413, 'SKILLHUB_BUNDLE_TOO_LARGE');
97
+ }
98
+ if (!response.body) throw clientProblem('SkillHub 返回了空 bundle');
99
+ const reader = response.body.getReader();
100
+ const chunks = [];
101
+ let total = 0;
102
+ while (true) {
103
+ const { done, value } = await reader.read();
104
+ if (done) break;
105
+ total += value.byteLength;
106
+ if (total > MAX_BUNDLE_BYTES) {
107
+ await reader.cancel();
108
+ throw clientProblem('Skill bundle 超过 20 MB', 413, 'SKILLHUB_BUNDLE_TOO_LARGE');
109
+ }
110
+ chunks.push(Buffer.from(value));
111
+ }
112
+ const buffer = Buffer.concat(chunks, total);
113
+ return {
114
+ buffer,
115
+ digest: response.headers.get('x-saker-skill-digest') || '',
116
+ sha256: response.headers.get('x-saker-bundle-sha256') || ''
117
+ };
118
+ }
119
+ }
120
+
121
+ module.exports = { SkillHubClient, MAX_BUNDLE_BYTES };