@morehao/dsh-skills-console 0.2.1 → 0.2.3

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.
@@ -25,6 +25,27 @@ export function githubToken(env = process.env) {
25
25
  return env.GITHUB_TOKEN || env.GH_TOKEN || null;
26
26
  }
27
27
 
28
+ /**
29
+ * 限流相关响应头。GitHub 对匿名请求按**出口 IP** 记 60 次/小时,403 时这三个头是
30
+ * 用户唯一能拿到的可执行信息(还剩多少、什么时候恢复、要不要等)。
31
+ */
32
+ function rateLimitOf(response) {
33
+ const read = (name) => {
34
+ const value = response?.headers?.get?.(name);
35
+ return value === null || value === undefined || value === '' ? null : String(value);
36
+ };
37
+ const reset = read('x-ratelimit-reset');
38
+ const retryAfter = read('retry-after');
39
+ const resetAt = reset && /^\d+$/.test(reset) ? Number(reset) * 1000 : null;
40
+ return { remaining: read('x-ratelimit-remaining'), resetAt, retryAfter };
41
+ }
42
+
43
+ /** 403 + 剩余 0,或 429 —— 与上游判定一致(blob.ts:147-152)。 */
44
+ export function isRateLimited(status, rateLimit) {
45
+ if (status === 429) return true;
46
+ return status === 403 && rateLimit?.remaining === '0';
47
+ }
48
+
28
49
  async function callJson(url, { token, etag, fetchImpl = fetch } = {}) {
29
50
  const headers = {
30
51
  accept: 'application/vnd.github+json',
@@ -34,13 +55,14 @@ async function callJson(url, { token, etag, fetchImpl = fetch } = {}) {
34
55
  if (token) headers.authorization = `Bearer ${token}`;
35
56
  if (etag) headers['if-none-match'] = etag;
36
57
  const response = await fetchImpl(url, { headers });
37
- if (response.status === 304) return { status: 304, notModified: true, etag };
58
+ const rateLimit = rateLimitOf(response);
59
+ if (response.status === 304) return { status: 304, notModified: true, etag, rateLimit };
38
60
  if (!response.ok) {
39
61
  const text = await response.text().catch(() => '');
40
- return { status: response.status, error: `HTTP ${response.status} ${text.slice(0, 200)}` };
62
+ return { status: response.status, error: `HTTP ${response.status} ${text.slice(0, 200)}`, rateLimit };
41
63
  }
42
64
  const body = await response.json();
43
- return { status: response.status, body, etag: response.headers.get('etag') };
65
+ return { status: response.status, body, etag: response.headers.get('etag'), rateLimit };
44
66
  }
45
67
 
46
68
  /**
@@ -53,7 +75,15 @@ export async function fetchTree({ source, ref = 'main', etag, token, fetchImpl }
53
75
  const url = `${API}/repos/${parsed.owner}/${parsed.repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
54
76
  const result = await callJson(url, { token, etag, fetchImpl });
55
77
  if (result.notModified) return { notModified: true, etag: result.etag };
56
- if (result.error) return { error: result.error, status: result.status };
78
+ if (result.error) {
79
+ return {
80
+ error: result.error,
81
+ status: result.status,
82
+ rateLimited: isRateLimited(result.status, result.rateLimit),
83
+ resetAt: result.rateLimit?.resetAt ?? null,
84
+ rateLimit: result.rateLimit,
85
+ };
86
+ }
57
87
  const tree = Array.isArray(result.body?.tree) ? result.body.tree : [];
58
88
  return {
59
89
  tree,
@@ -64,6 +94,33 @@ export async function fetchTree({ source, ref = 'main', etag, token, fetchImpl }
64
94
  };
65
95
  }
66
96
 
97
+ /**
98
+ * 仓库默认分支。`ref` 缺省时以它为准:不少技能仓库默认是 `master`,
99
+ * 硬套 `main` 只会得到 404(例如 alchaincyf/huashu-design)。
100
+ *
101
+ * 返回结构化结果而不是 `string | null`:调用方要靠 `status` / `rateLimited` / `resetAt`
102
+ * 把「仓库不存在」「被限流」「网络不通」拆成三句不同的话(T1)。
103
+ */
104
+ export async function fetchDefaultBranch({ source, token, fetchImpl } = {}) {
105
+ const parsed = parseSource(source);
106
+ if (!parsed) {
107
+ return { branch: null, status: null, rateLimited: false, resetAt: null, error: 'invalid-source' };
108
+ }
109
+ let result;
110
+ try {
111
+ result = await callJson(`${API}/repos/${parsed.owner}/${parsed.repo}`, { token, fetchImpl });
112
+ } catch (error) {
113
+ return { branch: null, status: null, rateLimited: false, resetAt: null, error: error?.message || String(error) };
114
+ }
115
+ const rateLimit = result.rateLimit || {};
116
+ const rateLimited = isRateLimited(result.status, rateLimit);
117
+ const base = { status: result.status ?? null, rateLimited, resetAt: rateLimit.resetAt ?? null };
118
+ if (result.error) return { ...base, branch: null, error: result.error };
119
+ const branch = result.body?.default_branch;
120
+ if (!branch) return { ...base, branch: null, error: 'response has no default_branch' };
121
+ return { ...base, branch, error: null };
122
+ }
123
+
67
124
  /** 解析 ref 对应的 commit(只在安装/计划时需要,检测阶段不付这笔成本)。 */
68
125
  export async function resolveCommit({ source, ref = 'main', token, fetchImpl } = {}) {
69
126
  const parsed = parseSource(source);
@@ -76,6 +133,18 @@ export async function resolveCommit({ source, ref = 'main', token, fetchImpl } =
76
133
  return result.body.sha;
77
134
  }
78
135
 
136
+ const TREE_SHA_RE = /^[0-9a-f]{40}$/;
137
+
138
+ /**
139
+ * lock 的 `skillFolderHash` 形态。CLI 取不到树时(限流)会把 64 位内容哈希写进同一个字段
140
+ * (`add.ts:2254`),所以形态取决于**安装那一刻 API 通不通**,不是取决于来源类型。
141
+ * `tree` = 40 位 git tree SHA;`content` = 64 位内容哈希;`missing` = 没有这个字段。
142
+ */
143
+ export function hashShape(value) {
144
+ if (!value || typeof value !== 'string') return 'missing';
145
+ return TREE_SHA_RE.test(value) ? 'tree' : 'content';
146
+ }
147
+
79
148
  /** `KEY` + `/dir/SKILL.md` → `dir`(F18:`skillPath` 是文件路径,目录运算先 dirname)。 */
80
149
  export function skillDir(skillPath) {
81
150
  const parts = String(skillPath).split('/');
@@ -114,22 +183,41 @@ export function listSkillsFromTree({ tree, rootSha, subpath, repoName }) {
114
183
  }
115
184
 
116
185
  /**
117
- * 单技能判定(L2/L3)。哈希形态必须配对(F19):
118
- * `sourceType=github` 时 lock 里应是 40 位 tree SHA;64 位内容哈希说明来源形态不对,直接给 `unknown`。
186
+ * 单技能判定(L2/L3)。
187
+ *
188
+ * 基线取「第一个 40 位 tree SHA」:优先 lock 的 `skillFolderHash`,其次安装时记下的
189
+ * `state.installs[].treeSha`。CLI 在限流时会退化成 64 位内容哈希(`add.ts:2236`/`2254`),
190
+ * 那玩意儿与上游 tree SHA 不同源,直接比必然不等 —— 既不能判 `current`,也不该判
191
+ * `update-available`,此时改用手边记下的基线;两样都没有才给 `unknown`(老状态,行为与以前一致)。
119
192
  */
120
- export function checkSkill({ lockHash, snapshotEntry, tree, rootSha }) {
193
+ export function checkSkill({ lockHash, recordedTreeSha, snapshotEntry, tree, rootSha }) {
121
194
  if (!snapshotEntry) return { status: 'upstream-deleted', detail: '上游整树里已找不到这个 skillPath' };
122
- if (!lockHash) return { status: 'unknown', detail: 'lock 里没有 skillFolderHash' };
123
- if (!/^[0-9a-f]{40}$/.test(lockHash)) {
195
+ const baseline = TREE_SHA_RE.test(String(lockHash || ''))
196
+ ? { sha: lockHash, from: 'lock' }
197
+ : TREE_SHA_RE.test(String(recordedTreeSha || ''))
198
+ ? { sha: recordedTreeSha, from: 'recorded' }
199
+ : null;
200
+ if (!baseline) {
124
201
  return {
125
202
  status: 'unknown',
126
- detail: 'lock 的哈希不是 40 位 tree SHA(形态不配对),无法按 tree 比对',
203
+ detail: 'lock 里没有 40 位 tree SHA,安装时也未记录基线,无法按 tree 比对',
127
204
  };
128
205
  }
129
206
  const target = skillTreeSha({ tree, skillPath: snapshotEntry.skillPath, rootSha });
130
207
  if (!target) return { status: 'unknown', detail: '整树里找不到该目录的 tree 条目' };
131
- if (target === lockHash) return { status: 'current', detail: '与安装时的 tree SHA 一致' };
132
- return { status: 'update-available', detail: 'tree SHA 已变化', targetTreeSha: target };
208
+ if (target === baseline.sha) {
209
+ return {
210
+ status: 'current',
211
+ detail: baseline.from === 'lock' ? '与安装时的 tree SHA 一致' : '与安装时记录的 tree SHA 一致',
212
+ baseline: baseline.from,
213
+ };
214
+ }
215
+ return {
216
+ status: 'update-available',
217
+ detail: `tree SHA 已变化(基线来自 ${baseline.from})`,
218
+ targetTreeSha: target,
219
+ baseline: baseline.from,
220
+ };
133
221
  }
134
222
 
135
223
  /** 计划 → 应用的防换手 token:磁盘或上游在"计划"之后被改动就拒绝(设计 §7.4③)。 */
package/lib/routes.js CHANGED
@@ -59,6 +59,7 @@ function matchRoute(method, pathname) {
59
59
  if (segments.length === 1 && segments[0] === 'settings' && method === 'POST') return { name: 'settings' };
60
60
  if (segments.length === 1 && segments[0] === 'links' && method === 'POST') return { name: 'links' };
61
61
  if (segments.length === 1 && segments[0] === 'install' && method === 'POST') return { name: 'install' };
62
+ if (segments.length === 1 && segments[0] === 'probe' && method === 'POST') return { name: 'probe' };
62
63
  if (segments.length === 1 && segments[0] === 'diagnostics' && method === 'GET') return { name: 'diagnostics' };
63
64
 
64
65
  if (segments[0] === 'skills' && segments.length === 2 && method === 'GET') {
@@ -154,6 +155,8 @@ async function dispatch(route, { body, query, service }) {
154
155
  return service.setLinks(body);
155
156
  case 'install':
156
157
  return service.install(body);
158
+ case 'probe':
159
+ return service.probeSource(body);
157
160
  case 'diagnostics':
158
161
  return service.diagnostics();
159
162
  case 'skill.body':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morehao/dsh-skills-console",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "统一管理本地技能的安装、更新、删除与接入,让同一份技能在多个 Agent 间复用。A skill manager for DeepSeek Harness: install, update, remove, link and toggle agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "morehao <morehao@qq.com>",