dsh-skill-hub 0.3.18 → 0.3.20

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 (49) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +14 -8
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +187 -102
  7. package/lib/types/client/api.d.ts +3 -3
  8. package/lib/types/client/locales/market.d.ts +1 -0
  9. package/lib/types/client/locales.d.ts +1 -0
  10. package/lib/types/protocol/config.d.ts +17 -11
  11. package/lib/types/protocol/market.d.ts +11 -0
  12. package/lib/types/protocol/repo.d.ts +6 -0
  13. package/lib/types/protocol.d.ts +1 -1
  14. package/lib/types/repo/github-client.d.ts +11 -4
  15. package/lib/types/repo.d.ts +1 -1
  16. package/lib/types/routes/catalog-data.d.ts +11 -2
  17. package/lib/types/routes/deps.d.ts +6 -1
  18. package/lib/types/routes/route-state.d.ts +5 -0
  19. package/lib/types/skillfs/scan.d.ts +6 -5
  20. package/lib/types/skillfs.d.ts +25 -1
  21. package/package.json +22 -20
  22. package/src/client/api.ts +4 -3
  23. package/src/client/locales/market.ts +2 -0
  24. package/src/client/panel/RepoScanCard.tsx +2 -1
  25. package/src/client/panel/hooks/useMarketFlow.ts +4 -2
  26. package/src/client/panel/panel.module.css +10 -2
  27. package/src/client/settings-card.tsx +2 -1
  28. package/src/client/settings-form.ts +6 -3
  29. package/src/index.ts +1 -1
  30. package/src/protocol/config.ts +27 -24
  31. package/src/protocol/market.ts +12 -0
  32. package/src/protocol/repo.ts +6 -0
  33. package/src/protocol.ts +1 -0
  34. package/src/repo/github-client.ts +26 -14
  35. package/src/repo/install.ts +5 -2
  36. package/src/repo.test.ts +40 -1
  37. package/src/repo.ts +1 -1
  38. package/src/routes/catalog-data.ts +20 -6
  39. package/src/routes/catalog.ts +4 -9
  40. package/src/routes/deps.ts +7 -2
  41. package/src/routes/market.ts +13 -2
  42. package/src/routes/repo-import.ts +49 -8
  43. package/src/routes/route-state.test.ts +1 -0
  44. package/src/routes/route-state.ts +5 -0
  45. package/src/routes.test.ts +187 -0
  46. package/src/skillfs/scan.ts +30 -17
  47. package/src/skillfs.test.ts +64 -3
  48. package/src/skillfs.ts +39 -8
  49. package/src/update.ts +6 -6
@@ -30,6 +30,14 @@ export declare function githubAuthHeaders(): Record<string, string>;
30
30
  export declare const NO_COMPRESSION: Record<string, string>;
31
31
  /** JSON API 默认请求头(含鉴权)。 */
32
32
  export declare function apiHeaders(): Record<string, string>;
33
+ /**
34
+ * When the response shows an exhausted GitHub rate limit, the instant it
35
+ * resets; otherwise undefined. Shared by the repo fetch layer and the plugin's
36
+ * own update check so both agree on what counts as a rate-limit rejection
37
+ * (403/429 + `x-ratelimit-remaining: 0` + a parseable `x-ratelimit-reset`)
38
+ * instead of each re-deriving it from the headers.
39
+ */
40
+ export declare function rateLimitResetAt(response: Response): Date | undefined;
33
41
  /**
34
42
  * Build a RepoFetchError; when the response shows an exhausted rate limit,
35
43
  * report the reset time instead of a bare HTTP status.
@@ -46,10 +54,9 @@ export declare function fetchJson(url: string, fetchImpl: typeof fetch, context:
46
54
  response: Response;
47
55
  }>;
48
56
  /**
49
- * Load repo metadata and the recursive git tree at the given ref. The meta
50
- * request always resolves the default branch (used when ref is absent); the
51
- * tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
52
- * the exact same content a later download would fetch.
57
+ * Load one JSON document from the GitHub API, reusing an ETag when the URL was
58
+ * fetched before. `fetchImpl`/`context` exist so callers can stub the transport
59
+ * and shape the error text; the payload is whatever the endpoint returned.
53
60
  */
54
61
  export declare function fetchJsonCached(url: string, fetchImpl: typeof fetch, context: string): Promise<{
55
62
  json: unknown;
@@ -12,7 +12,7 @@
12
12
  * The implementation now lives under `./repo/` (types, discovery, api,
13
13
  * install) and this module is a barrel that re-exports it.
14
14
  */
15
- export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, setGithubToken } from './repo/github-client.ts';
15
+ export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, rateLimitResetAt, setGithubToken } from './repo/github-client.ts';
16
16
  export type { RepoRef, RepoTreeItem, RepoFile } from './repo/types.ts';
17
17
  export { REPO_ROOT, isRepoRoot, repoSlug, normalizeRepoInput, collectRepoSkillFiles, originForRoot, discoverRepoEntries, skillManifest, skillDirOf, skillFileAt, skillPathIn, relativeToSkillDir, diffRemoteSkills, repoSkillEntry, } from './repo/discovery.ts';
18
18
  export { loadRepoTree, getLatestReleaseTag, getRepoStats, listRepoReleases, listRepoBranches, getLatestCommit, loadRepoTreeAt, } from './repo/api.ts';
@@ -6,8 +6,17 @@ import type { SkillDefinition } from '@deepseek-ai/dsh-skill';
6
6
  import { type CatalogResponse, type SkillDetail } from '../protocol.ts';
7
7
  import { type SkillInterface } from '../skillfs.ts';
8
8
  import { type SkillHubRouteDeps } from './deps.ts';
9
- /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
10
- export declare function knownSkillNames(deps: SkillHubRouteDeps): Promise<Set<string>>;
9
+ /**
10
+ * 目录中存在的技能名集合。默认(`includeDisabled` 未显式关闭)为「启用目录 ∪
11
+ * 已禁用名单」——tag 成员校验用:技能被禁用后不该从 tag 里掉出去。
12
+ *
13
+ * 导入去重必须传 `{ includeDisabled: false }`:禁用只意味着「暂不启用」,把它
14
+ * 算作「已存在」会让一次禁用产生「永久占名」的副作用,真技能再也导不进来
15
+ * (issue #16)。
16
+ */
17
+ export declare function knownSkillNames(deps: SkillHubRouteDeps, options?: {
18
+ includeDisabled?: boolean;
19
+ }): Promise<Set<string>>;
11
20
  /** 已知工作区条目(dsh 的 workspace.json 表)。 */
12
21
  export interface WorkspaceEntry {
13
22
  path: string;
@@ -37,7 +37,12 @@ export interface SkillHubRouteDeps {
37
37
  }
38
38
  /** The resolved hub config a route sees (the shared resolver fills defaults). */
39
39
  export declare function configOf(deps: SkillHubRouteDeps): HubConfig;
40
- /** The raw saved config layer a route reports (empty when the owner omits it). */
40
+ /**
41
+ * The raw saved config layer as persisted in the sidecar, reported for display
42
+ * only. Since dsh 0.1.7 the settings namespace is the one user layer, so this
43
+ * is legacy data: nothing writes it any more and the effective config above
44
+ * never merges it.
45
+ */
41
46
  export declare function savedOf(deps: SkillHubRouteDeps): Partial<HubConfig>;
42
47
  /** Refuse business routes while the master switch is off (the config route stays up). */
43
48
  export declare function disabledGate(deps: SkillHubRouteDeps, res: ServerResponse): boolean;
@@ -40,6 +40,11 @@ export interface ImportJob {
40
40
  name: string;
41
41
  error: string;
42
42
  }>;
43
+ /**
44
+ * 导入时被移入回收站让位的「已禁用同名占位」名字(issue #16)。占位已挪走
45
+ * 即计入,即使随后的下载失败也在列 —— 否则用户不知道自己的占位不见了。
46
+ */
47
+ replaced: string[];
43
48
  status: 'running' | 'done' | 'cancelled' | 'error';
44
49
  error?: string;
45
50
  controller: AbortController;
@@ -1,4 +1,4 @@
1
- import type { DiagnosticEntry, WritableRoot } from '../protocol.ts';
1
+ import { type DiagnosticEntry, type WritableRoot } from '../protocol.ts';
2
2
  /** One discoverable skill file in a scanned root. */
3
3
  export interface SkillEntry {
4
4
  /** Absolute path of the discovery file (SKILL.md or the flat .md). */
@@ -39,9 +39,10 @@ export declare function readSkillInterface(directory: string): Promise<SkillInte
39
39
  */
40
40
  export declare function findProjectRoot(cwd: string): Promise<string>;
41
41
  /**
42
- * Scan one writable root for files the provider ignores, so the GUI can
43
- * show why a skill never appears — a
44
- * missing frontmatter must be visible, not silent). .disabled files belong
45
- * to the hub and are skipped.
42
+ * Scan one writable root for files the provider ignores, so the GUI can show
43
+ * why a skill never appears — a missing frontmatter, a name that disagrees
44
+ * with the discovery path, and the like must be visible, not silent. Files the
45
+ * hub itself disabled (`.disabled`) are skipped: they are a toggle state, not
46
+ * a defect.
46
47
  */
47
48
  export declare function scanDiagnostics(root: WritableRoot, home?: string): Promise<DiagnosticEntry[]>;
@@ -29,6 +29,22 @@ export declare function createSkill(root: WritableRoot, name: string, descriptio
29
29
  export declare function disableSkill(path: string): Promise<string>;
30
30
  /** Re-enable a previously disabled file (reverse of disableSkill). */
31
31
  export declare function enableSkill(disabledPath: string): Promise<string>;
32
+ /**
33
+ * Move one already-resolved skill (a bundle directory or a flat .md file) into
34
+ * its root's trash as `<root>/.trash/<name>-<timestamp>`.
35
+ *
36
+ * The layout is the restore contract, not an implementation detail:
37
+ * `restoreSkill` renames the entry back and only proves the parent is `.trash`,
38
+ * and `clearTrash` deletes in place. Every producer of a trash entry must go
39
+ * through here — skill delete, the hub-disabled whole-group delete, and repo
40
+ * import's placeholder replacement all move skills for the same recovery flow.
41
+ *
42
+ * @returns the trashed path plus the original path that was moved.
43
+ */
44
+ export declare function moveIntoTrash(source: string): Promise<{
45
+ path: string;
46
+ source: string;
47
+ }>;
32
48
  /**
33
49
  * Move a skill (directory bundle or flat .md file) into the root's trash.
34
50
  * The skill stays recoverable: the source is renamed into
@@ -44,5 +60,13 @@ export declare function trashSkill(sourcePath: string): Promise<{
44
60
  export declare function restoreSkill(entry: TrashEntry, home?: string): Promise<string>;
45
61
  /** Permanently delete one trashed skill (directory or flat file). */
46
62
  export declare function clearTrash(entry: TrashEntry, home?: string): Promise<string>;
47
- /** Repair one file on disk when its frontmatter is auto-fixable. Returns the new text. */
63
+ /**
64
+ * Repair one file on disk when its frontmatter is auto-fixable. Returns the new text.
65
+ *
66
+ * The write is atomic (sibling temp file + rename), matching the sidecar's
67
+ * persist path: this rewrites a real skill file in place, and a crash midway
68
+ * through a plain write would leave a truncated SKILL.md — exactly the failure
69
+ * the store layer already guards against. The `.tmp` sibling is never picked
70
+ * up by discovery (only `SKILL.md` and `*.md` are scanned).
71
+ */
48
72
  export declare function fixDiagnosticFile(path: string, home?: string): Promise<string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -65,7 +65,9 @@
65
65
  "0.1.7-alpha.2",
66
66
  "0.1.7-rc.1",
67
67
  "0.1.7-rc.2",
68
- "0.2.0-rc.1"
68
+ "0.2.0-rc.1",
69
+ "0.2.0-rc.2",
70
+ "0.2.1-alpha.2"
69
71
  ]
70
72
  },
71
73
  "capability": {
@@ -85,7 +87,7 @@
85
87
  "js-yaml": "^4.1.0"
86
88
  },
87
89
  "peerDependencies": {
88
- "@deepseek-ai/cordis": "^4.0.1 || ^4.0.2",
90
+ "@deepseek-ai/cordis": "^4.0.1 || ^4.0.2 || ^4.0.5-alpha.1",
89
91
  "@deepseek-ai/dsh-client-connection": ">=0.1.7-alpha.1 <0.3.0-0",
90
92
  "@deepseek-ai/dsh-client-locale": ">=0.1.7-alpha.1 <0.3.0-0",
91
93
  "@deepseek-ai/dsh-client-runtime": ">=0.1.7-alpha.1 <0.3.0-0",
@@ -99,7 +101,7 @@
99
101
  "@deepseek-ai/dsh-settings": ">=0.1.7-alpha.1 <0.3.0-0",
100
102
  "@deepseek-ai/dsh-skill": ">=0.1.7-alpha.1 <0.3.0-0",
101
103
  "@deepseek-ai/dsh-system-prompt": ">=0.1.7-alpha.1 <0.3.0-0",
102
- "@deepseek-ai/schemastery": "^3.18.3",
104
+ "@deepseek-ai/schemastery": "^3.18.3 || ^3.18.5-alpha.1",
103
105
  "react": "^18.2.0",
104
106
  "react-dom": "^18.2.0"
105
107
  },
@@ -112,22 +114,22 @@
112
114
  }
113
115
  },
114
116
  "devDependencies": {
115
- "@deepseek-ai/cordis": "^4.0.2",
116
- "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.1",
117
- "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
118
- "@deepseek-ai/dsh-client-store": "^0.2.0-rc.1",
119
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.2.0-rc.1",
120
- "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.0-rc.1",
121
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
122
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.2.0-rc.1",
123
- "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
124
- "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
125
- "@deepseek-ai/dsh-session": "^0.2.0-rc.1",
126
- "@deepseek-ai/dsh-session-query": "^0.2.0-rc.1",
127
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
128
- "@deepseek-ai/dsh-skill": "^0.2.0-rc.1",
129
- "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.1",
130
- "@deepseek-ai/schemastery": "^3.18.3",
117
+ "@deepseek-ai/cordis": "^4.0.5-alpha.1",
118
+ "@deepseek-ai/dsh-client-connection": "^0.2.1-alpha.2",
119
+ "@deepseek-ai/dsh-client-locale": "^0.2.1-alpha.2",
120
+ "@deepseek-ai/dsh-client-store": "^0.2.1-alpha.2",
121
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.2.1-alpha.2",
122
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.1-alpha.2",
123
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.1-alpha.2",
124
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.2.1-alpha.2",
125
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.1-alpha.2",
126
+ "@deepseek-ai/dsh-host-webserver": "^0.2.1-alpha.2",
127
+ "@deepseek-ai/dsh-session": "^0.2.1-alpha.2",
128
+ "@deepseek-ai/dsh-session-query": "^0.2.1-alpha.2",
129
+ "@deepseek-ai/dsh-settings": "^0.2.1-alpha.2",
130
+ "@deepseek-ai/dsh-skill": "^0.2.1-alpha.2",
131
+ "@deepseek-ai/dsh-system-prompt": "^0.2.1-alpha.2",
132
+ "@deepseek-ai/schemastery": "^3.18.5-alpha.1",
131
133
  "@types/js-yaml": "^4.0.9",
132
134
  "@types/node": "^22.20.0",
133
135
  "@types/react": "~18.3.1",
package/src/client/api.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type DiagnosticFixResponse,
13
13
  type GroupsResponse,
14
14
  type MarketCheckResponse,
15
+ type MarketSourceAddResponse,
15
16
  type MarketSourceRefRequest,
16
17
  type MarketSourceRequest,
17
18
  type MarketSourceResponse,
@@ -156,9 +157,9 @@ export class SkillHubApi {
156
157
  return this.get<MarketSourcesResponse>(SKILL_HUB_API.market)
157
158
  }
158
159
 
159
- /** Add a market source repo; resolves with the fresh list. */
160
- addMarketSource(repo: string): Promise<MarketSourcesResponse> {
161
- return this.post<MarketSourceResponse>(SKILL_HUB_API.marketSource, { repo } satisfies MarketSourceRequest)
160
+ /** Add a market source repo; resolves with the fresh list plus the resolved slug. */
161
+ addMarketSource(repo: string): Promise<MarketSourceAddResponse> {
162
+ return this.post<MarketSourceAddResponse>(SKILL_HUB_API.marketSource, { repo } satisfies MarketSourceRequest)
162
163
  }
163
164
 
164
165
  /** Remove a market source repo; resolves with the fresh list. */
@@ -70,6 +70,7 @@ export const zhMarket = {
70
70
  'repo.files': '{count} 个文件 · {size}',
71
71
  'repo.imported': '导入成功 {count} 个',
72
72
  'repo.skippedExisting': '跳过已存在 {count} 个',
73
+ 'repo.replaced': '已替换禁用占位 {count} 个(旧目录已移入回收站)',
73
74
  'repo.failed': '失败 {count} 个',
74
75
  'repo.cancel': '取消',
75
76
  'repo.cancelled': '已取消 · 已导入 {imported}/{total} 已保留,临时文件已清理',
@@ -151,6 +152,7 @@ export const enMarket: Record<keyof typeof zhMarket, string> = {
151
152
  'repo.files': '{count} files · {size}',
152
153
  'repo.imported': 'Imported {count}',
153
154
  'repo.skippedExisting': 'Skipped existing {count}',
155
+ 'repo.replaced': 'Replaced {count} disabled placeholder(s) — old dir moved to trash',
154
156
  'repo.failed': 'Failed {count}',
155
157
  'repo.cancel': 'Cancel',
156
158
  'repo.cancelled': 'Cancelled · {imported}/{total} kept, temp files cleaned',
@@ -55,7 +55,7 @@ export function RepoScanCard(props: { hub: SkillHubState }): JSX.Element | null
55
55
  // 导入后 catalog 已包含新技能,但 scan 快照的 existing 仍为 false,需用实时 catalog + 本次导入结果兜底,避免已导入仍可选
56
56
  const installedNames = new Set<string>([
57
57
  ...((hub.catalog?.skills ?? []).map((s) => s.name)),
58
- ...((hub.catalog?.disabled ?? []).map((d) => d.name)),
58
+ // 已禁用不算「已存在」:禁用只该影响启用态,不该永久占名堵住导入(issue #16)。
59
59
  ...((repoResult?.imported ?? []).map((r) => r.name)),
60
60
  ...((repoResult?.skipped ?? []).map((r) => r.name)),
61
61
  ])
@@ -174,6 +174,7 @@ export function RepoScanCard(props: { hub: SkillHubState }): JSX.Element | null
174
174
  {repoResult !== null && repoResult.status !== 'running' ? (
175
175
  <div className={repoResult.status === 'cancelled' ? css.errorBanner + ' ' + css.actionsTop : css.formSuccess + ' ' + css.actionsTop}>
176
176
  {repoResult.status === 'cancelled' ? tt('repo.cancelled', { imported: repoResult.imported.length, total: repoResult.total }) : `${tt('repo.imported', { count: repoResult.imported.length })} · ${tt('repo.skippedExisting', { count: repoResult.skipped.length })} · ${tt('repo.failed', { count: repoResult.failed.length })}`}
177
+ {(repoResult.replaced?.length ?? 0) > 0 ? <span> · {tt('repo.replaced', { count: repoResult.replaced?.length ?? 0 })}</span> : null}
177
178
  {repoResult.failed.length > 0 ? <div style={{marginTop:6}}>{repoResult.failed.map(f => `${f.name}: ${f.error}`).join('; ')}</div> : null}
178
179
  {repoResult.status === 'error' && repoResult.error ? <div style={{marginTop:6}}>{repoResult.error}</div> : null}
179
180
  </div>
@@ -75,8 +75,10 @@ export function useMarketFlow(
75
75
  setNewSourceName('')
76
76
  setRepoResult(null)
77
77
  setRepoSelected(new Set())
78
- // 用服务端归一化后的 slug 扫描(URL/owner@repo 输入都能对上记录)。
79
- const slug = result.repos[result.repos.length - 1].repo
78
+ // 用服务端回传的归一化 slug 扫描(URL/owner@repo 输入都能对上记录)。
79
+ // 不能猜 `repos` 的位置:重复添加一个已存在的源只改它的 ref、不重排,
80
+ // 取末位会扫到另一个源。
81
+ const slug = result.repo
80
82
  setScanningRepo(slug)
81
83
  setRepoDiscoverState({ status: 'scanning' })
82
84
  try {
@@ -4,9 +4,17 @@
4
4
  on :root, with dark overrides on body[data-ds-dark-theme]). Every var()
5
5
  keeps the colour this file used before the token pass as its fallback, so a
6
6
  host without the theme package renders exactly as before. Sizes, spacing,
7
- radii, fonts and layout are deliberately untouched. */
7
+ radii, fonts and layout are deliberately untouched.
8
8
 
9
- .panel { padding: 28px 20px 44px; display: flex; flex-direction: column; gap: 14px; max-width: 720px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; color-scheme: light dark; /* Dot colors mirror src/client/SkillHubSettingsCard.tsx (DEFAULT_DOT_MODEL_COLOR / DEFAULT_DOT_USER_COLOR) — CSS cannot import TS, so keep the two in sync. */ --hub-model: #2f81f7; --hub-user: #3fb950; /* Host theme tokens (--dsw-alias-*, dsh-client-ui-theme). Each var() carries the pre-token colour of this file as its fallback, so a missing theme package changes nothing. */ --hub-accent: var(--dsw-alias-state-business-primary, #2f81f7); --hub-text: var(--dsw-alias-label-primary, currentColor); --hub-text-2: var(--dsw-alias-label-secondary, #61666b); --hub-text-3: var(--dsw-alias-label-tertiary, #81858c); --hub-text-4: var(--dsw-alias-label-caption, #adb2b8); --hub-surface: var(--dsw-alias-bg-module-platform, rgba(128,128,128,.06)); --hub-hover: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.08)); --hub-active: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.16)); --hub-border: var(--dsw-alias-border-l2, rgba(128,128,128,.13)); --hub-border-2: var(--dsw-alias-border-l3, rgba(128,128,128,.2)); --hub-danger: var(--dsw-alias-state-error-primary, #d1242f); --hub-success: var(--dsw-alias-state-success-primary, #3fb950); --hub-warn: var(--dsw-alias-state-warn-primary, #d29922); --hub-on-accent: var(--dsw-alias-label-primary-foreground, #fff); --hub-skeleton: var(--dsw-alias-bg-skeleton, rgba(128,128,128,.1)); }
9
+ color-scheme is deliberately NOT declared here. The host layout writes the
10
+ *resolved* scheme to document.documentElement.style.colorScheme; declaring
11
+ `light dark` locally overrides that and makes native controls (<select>
12
+ popups, scrollbars) follow the *OS* preference instead — with a manually
13
+ selected dark theme on a light OS the <option> list renders light while
14
+ .select inherits light text, i.e. invisible (issue #13). Inheriting from the
15
+ root keeps both dark paths (manual + system) correct. */
16
+
17
+ .panel { padding: 28px 20px 44px; display: flex; flex-direction: column; gap: 14px; max-width: 720px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; /* Dot colors mirror src/client/SkillHubSettingsCard.tsx (DEFAULT_DOT_MODEL_COLOR / DEFAULT_DOT_USER_COLOR) — CSS cannot import TS, so keep the two in sync. */ --hub-model: #2f81f7; --hub-user: #3fb950; /* Host theme tokens (--dsw-alias-*, dsh-client-ui-theme). Each var() carries the pre-token colour of this file as its fallback, so a missing theme package changes nothing. */ --hub-accent: var(--dsw-alias-state-business-primary, #2f81f7); --hub-text: var(--dsw-alias-label-primary, currentColor); --hub-text-2: var(--dsw-alias-label-secondary, #61666b); --hub-text-3: var(--dsw-alias-label-tertiary, #81858c); --hub-text-4: var(--dsw-alias-label-caption, #adb2b8); --hub-surface: var(--dsw-alias-bg-module-platform, rgba(128,128,128,.06)); --hub-hover: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.08)); --hub-active: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.16)); --hub-border: var(--dsw-alias-border-l2, rgba(128,128,128,.13)); --hub-border-2: var(--dsw-alias-border-l3, rgba(128,128,128,.2)); --hub-danger: var(--dsw-alias-state-error-primary, #d1242f); --hub-success: var(--dsw-alias-state-success-primary, #3fb950); --hub-warn: var(--dsw-alias-state-warn-primary, #d29922); --hub-on-accent: var(--dsw-alias-label-primary-foreground, #fff); --hub-skeleton: var(--dsw-alias-bg-skeleton, rgba(128,128,128,.1)); }
10
18
  .header { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
11
19
  .title { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
12
20
  .hint { font-size: 12px; color: var(--hub-text-3); }
@@ -10,6 +10,7 @@ import { useState, type ReactElement } from 'react'
10
10
  import { IconChevronDownOutline14 } from './icons.tsx'
11
11
  import type { HubKey } from './locales.ts'
12
12
  import type { CardShell } from './settings-form.ts'
13
+ import { HEX_COLOR_RE } from '../protocol.ts'
13
14
  import css from './settings-card.module.css'
14
15
 
15
16
  /** Card-level chrome props. */
@@ -178,7 +179,7 @@ export interface ColorFieldProps extends FieldBaseProps {
178
179
  }
179
180
 
180
181
  export function ColorField(props: ColorFieldProps): ReactElement {
181
- const value = /^#[0-9a-f]{6}$/i.test(props.text) ? props.text : props.defaultColor
182
+ const value = HEX_COLOR_RE.test(props.text) ? props.text : props.defaultColor
182
183
  return (
183
184
  <div className={css.field}>
184
185
  <FieldShell {...props} />
@@ -10,6 +10,9 @@
10
10
  */
11
11
 
12
12
  import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
13
+ // One definition of what a valid color/token looks like, shared with the host
14
+ // routes that re-validate the same drafts.
15
+ import { GITHUB_TOKEN_RE, HEX_COLOR_RE } from '../protocol.ts'
13
16
 
14
17
  /** One staged field: its draft text plus the clear/reset marker. */
15
18
  interface StagedEdit {
@@ -41,9 +44,9 @@ export function booleanField(field: string): FieldSpec {
41
44
  export function colorField(field: string): FieldSpec {
42
45
  return {
43
46
  field,
44
- format: (value) => (typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) ? value : ''),
47
+ format: (value) => (typeof value === 'string' && HEX_COLOR_RE.test(value) ? value : ''),
45
48
  parse: (text) => {
46
- if (/^#[0-9a-f]{6}$/i.test(text)) return { kind: 'set', value: text }
49
+ if (HEX_COLOR_RE.test(text)) return { kind: 'set', value: text }
47
50
  return undefined
48
51
  },
49
52
  }
@@ -61,7 +64,7 @@ export function secretField(field: string): FieldSpec {
61
64
  parse: (text) => {
62
65
  const trimmed = text.trim()
63
66
  if (trimmed === '') return undefined
64
- if (!/^[A-Za-z0-9_-]{8,255}$/.test(trimmed)) return undefined
67
+ if (!GITHUB_TOKEN_RE.test(trimmed)) return undefined
65
68
  return { kind: 'set', value: trimmed }
66
69
  },
67
70
  }
package/src/index.ts CHANGED
@@ -163,7 +163,7 @@ export function apply(ctx: Context, config?: Config): void {
163
163
  }
164
164
  return out as Partial<HubConfig>
165
165
  }
166
- const current = (): HubConfig => resolveHubConfig({}, readConfig())
166
+ const current = (): HubConfig => resolveHubConfig(readConfig())
167
167
 
168
168
  /** The Settings service, when this deployment mounts it. Absent ⇒ no user layer to read or write. */
169
169
  const settingsOf = (): SettingsForms | undefined => ctx.get('settings') as SettingsForms | undefined
@@ -89,29 +89,30 @@ export const HUB_CONFIG_DEFAULTS = {
89
89
  } as const
90
90
 
91
91
  /**
92
- * Resolve the effective hub config: saved sidecar overrides win over the
93
- * cordis composition entry (the web card owns runtime config), missing
94
- * booleans fall back to HUB_CONFIG_DEFAULTS, and dot colors pass through
95
- * (saved first, then base) only when set. Numeric stats knobs are clamped to
96
- * their sane ranges (window ≥ 0, scan interval ≥ 1 minute).
92
+ * Fill a raw settings value into the effective hub config: schema defaults for
93
+ * anything missing, and the numeric stats knobs clamped to sane ranges
94
+ * (window ≥ 0, scan interval ≥ 1 minute).
95
+ *
96
+ * There is deliberately no "saved sidecar layer" parameter. Until dsh 0.1.7 the
97
+ * hub kept its own runtime overrides in the sidecar and merged them here; the
98
+ * settings namespace is now the only user layer (`updateConfig` writes through
99
+ * `ctx.settings.mutate`), so a merge against a sidecar layer could never
100
+ * observe anything but `{}` — see the git history of `resolveHubConfig`.
97
101
  */
98
- export function resolveHubConfig(saved: Partial<HubConfig>, base: Partial<HubConfig> = {}): HubConfig {
99
- const dotModelColor = saved.dotModelColor !== undefined ? saved.dotModelColor : base.dotModelColor
100
- const dotUserColor = saved.dotUserColor !== undefined ? saved.dotUserColor : base.dotUserColor
101
- const githubToken = saved.githubToken !== undefined ? saved.githubToken : base.githubToken
102
- const windowDays = clampNumber(saved.statsWindowDays ?? base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays
103
- const scanMinutes = clampNumber(saved.statsScanMinutes ?? base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes
102
+ export function resolveHubConfig(base: Partial<HubConfig> = {}): HubConfig {
103
+ const windowDays = clampNumber(base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays
104
+ const scanMinutes = clampNumber(base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes
104
105
  return {
105
- enabled: saved.enabled ?? base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
106
- announceToAgent: saved.announceToAgent ?? base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
107
- showUseCount: saved.showUseCount ?? base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
108
- showUseTime: saved.showUseTime ?? base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
109
- showGroupSummary: saved.showGroupSummary ?? base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
106
+ enabled: base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
107
+ announceToAgent: base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
108
+ showUseCount: base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
109
+ showUseTime: base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
110
+ showGroupSummary: base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
110
111
  statsWindowDays: windowDays,
111
112
  statsScanMinutes: scanMinutes,
112
- ...(dotModelColor !== undefined ? { dotModelColor } : {}),
113
- ...(dotUserColor !== undefined ? { dotUserColor } : {}),
114
- ...(githubToken !== undefined ? { githubToken } : {}),
113
+ ...(base.dotModelColor !== undefined ? { dotModelColor: base.dotModelColor } : {}),
114
+ ...(base.dotUserColor !== undefined ? { dotUserColor: base.dotUserColor } : {}),
115
+ ...(base.githubToken !== undefined ? { githubToken: base.githubToken } : {}),
115
116
  }
116
117
  }
117
118
 
@@ -148,7 +149,13 @@ export interface ConfigResponse {
148
149
  githubTokenSet: boolean
149
150
  }
150
151
 
151
- /** POST /api/skill-hub/config — a partial patch; omitted fields keep their values. */
152
+ /**
153
+ * POST /api/skill-hub/config — a partial patch; omitted fields keep their values.
154
+ *
155
+ * Only the fields the handler actually parses belong here: the stats knobs are
156
+ * owned by the settings namespace (`statsWindowDays` / `statsScanMinutes` are
157
+ * still part of {@link HubSettingsValue}), not by this route.
158
+ */
152
159
  export interface ConfigRequest {
153
160
  /** Set the field; null clears the saved override so it re-inherits the default. */
154
161
  enabled?: boolean | null
@@ -164,10 +171,6 @@ export interface ConfigRequest {
164
171
  dotModelColor?: string | null
165
172
  /** Set the dot color; null clears the saved override so it re-inherits the default. */
166
173
  dotUserColor?: string | null
167
- /** 统计滚动窗口天数(0 = 全部历史);null 清除覆盖回默认。 */
168
- statsWindowDays?: number | null
169
- /** 自动统计扫描间隔(分钟);null 清除覆盖回默认。 */
170
- statsScanMinutes?: number | null
171
174
  /** Set the GitHub token; null/empty clears it back to anonymous. */
172
175
  githubToken?: string | null
173
176
  }
@@ -27,6 +27,18 @@ export interface MarketSourceResponse {
27
27
  repos: MarketSourceRecord[]
28
28
  }
29
29
 
30
+ /**
31
+ * POST /api/skill-hub/market/source — same list, plus the slug the host
32
+ * resolved from the input. Clients must scan *this* slug rather than assume
33
+ * the acted-on source sits at some position in `repos`: re-adding a repo the
34
+ * user already tracks only updates its `ref` in place and never reorders the
35
+ * list.
36
+ */
37
+ export interface MarketSourceAddResponse extends MarketSourceResponse {
38
+ /** Normalized `owner/repo` the host recorded for the submitted input. */
39
+ repo: string
40
+ }
41
+
30
42
  /** POST /api/skill-hub/market/source/ref — pin a market source to an explicit ref. */
31
43
  export interface MarketSourceRefRequest {
32
44
  repo: string
@@ -95,6 +95,12 @@ export interface RepoImportProgressResponse {
95
95
  imported: Array<{ name: string; origin: string; path: string }>
96
96
  skipped: Array<{ name: string; reason: 'exists' }>
97
97
  failed: Array<{ name: string; error: string }>
98
+ /**
99
+ * 本次导入中「让位的已禁用同名占位」名字(issue #16):这些条目的旧目录已
100
+ * 移入回收站,新技能随后落地。旧目录挪走即计入,下载失败时也在列。老客户端
101
+ * 不认这个字段时忽略即可,故为可选。
102
+ */
103
+ replaced?: string[]
98
104
  /** error 状态时的原因 */
99
105
  error?: string
100
106
  }
package/src/protocol.ts CHANGED
@@ -36,6 +36,7 @@ export type {
36
36
  MarketSourceRecord,
37
37
  MarketSourcesResponse,
38
38
  MarketSourceRequest,
39
+ MarketSourceAddResponse,
39
40
  MarketSourceResponse,
40
41
  MarketSourceRefRequest,
41
42
  MarketSourceVersionsResponse,
@@ -66,21 +66,34 @@ function etagCacheSet(url: string, etag: string, json: unknown): void {
66
66
  }
67
67
  etagCache.set(url, { etag, json })
68
68
  }
69
+
70
+ /**
71
+ * When the response shows an exhausted GitHub rate limit, the instant it
72
+ * resets; otherwise undefined. Shared by the repo fetch layer and the plugin's
73
+ * own update check so both agree on what counts as a rate-limit rejection
74
+ * (403/429 + `x-ratelimit-remaining: 0` + a parseable `x-ratelimit-reset`)
75
+ * instead of each re-deriving it from the headers.
76
+ */
77
+ export function rateLimitResetAt(response: Response): Date | undefined {
78
+ if (response.status !== 403 && response.status !== 429) return undefined
79
+ if (response.headers.get('x-ratelimit-remaining') !== '0') return undefined
80
+ const reset = response.headers.get('x-ratelimit-reset')
81
+ if (reset === null) return undefined
82
+ const at = new Date(Number(reset) * 1000)
83
+ return Number.isNaN(at.getTime()) ? undefined : at
84
+ }
85
+
69
86
  /**
70
87
  * Build a RepoFetchError; when the response shows an exhausted rate limit,
71
88
  * report the reset time instead of a bare HTTP status.
72
89
  */
73
90
  export function fetchError(context: string, response: Response, fallbackStatus = 502): RepoFetchError {
74
- const remaining = response.headers.get('x-ratelimit-remaining')
75
- const reset = response.headers.get('x-ratelimit-reset')
76
- if (response.status === 403 || response.status === 429) {
77
- if (remaining === '0' && reset !== null) {
78
- const at = new Date(Number(reset) * 1000)
79
- return new RepoFetchError(
80
- 'github rate limit reached (anonymous quota exhausted); retry after ' + at.toLocaleTimeString() + ' or set GITHUB_TOKEN',
81
- 403,
82
- )
83
- }
91
+ const resetAt = rateLimitResetAt(response)
92
+ if (resetAt !== undefined) {
93
+ return new RepoFetchError(
94
+ 'github rate limit reached (anonymous quota exhausted); retry after ' + resetAt.toLocaleTimeString() + ' or set GITHUB_TOKEN',
95
+ 403,
96
+ )
84
97
  }
85
98
  return new RepoFetchError(context + ' (HTTP ' + response.status + ')', response.status === 404 ? 404 : fallbackStatus)
86
99
  }
@@ -111,10 +124,9 @@ export async function fetchJson(url: string, fetchImpl: typeof fetch, context: s
111
124
  }
112
125
 
113
126
  /**
114
- * Load repo metadata and the recursive git tree at the given ref. The meta
115
- * request always resolves the default branch (used when ref is absent); the
116
- * tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
117
- * the exact same content a later download would fetch.
127
+ * Load one JSON document from the GitHub API, reusing an ETag when the URL was
128
+ * fetched before. `fetchImpl`/`context` exist so callers can stub the transport
129
+ * and shape the error text; the payload is whatever the endpoint returned.
118
130
  */
119
131
  export async function fetchJsonCached(url: string, fetchImpl: typeof fetch, context: string): Promise<{ json: unknown; response: Response }> {
120
132
  const cached = etagCache.get(url)
@@ -34,8 +34,11 @@ export async function downloadGitHubFile(repo: string, ref: string, path: string
34
34
  }
35
35
  if (response === null || !response.ok) {
36
36
  if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
37
- // Fallback: api.github.com/contents with the raw media type.
38
- const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}`
37
+ // Fallback: api.github.com/contents with the raw media type. The `?ref=`
38
+ // is mandatory — without it /contents/ serves the default branch, so a
39
+ // transient raw failure would silently mix default-branch content into a
40
+ // tag-pinned import (issue #15).
41
+ const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`
39
42
  try {
40
43
  response = await fetchImpl(apiUrl, { headers: { accept: 'application/vnd.github.raw', ...NO_COMPRESSION, ...githubAuthHeaders() }, ...(signal !== undefined ? { signal } : {}) })
41
44
  } catch (error) {