dsh-skill-hub 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +146 -33
  2. package/lib/client.js +3477 -436
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2408 -366
  5. package/lib/types/client/SkillHubSettingsCard.d.ts +5 -0
  6. package/lib/types/client/api-config-scope.d.ts +6 -0
  7. package/lib/types/client/api.d.ts +44 -2
  8. package/lib/types/client/grouping.d.ts +64 -0
  9. package/lib/types/client/locales.d.ts +155 -18
  10. package/lib/types/client/market-catalog.d.ts +14 -0
  11. package/lib/types/client/panel/DisabledRow.d.ts +12 -0
  12. package/lib/types/client/panel/GroupSummary.d.ts +10 -0
  13. package/lib/types/client/panel/MarketView.d.ts +11 -0
  14. package/lib/types/client/panel/ScenesView.d.ts +11 -0
  15. package/lib/types/client/panel/SkillDetailView.d.ts +31 -0
  16. package/lib/types/client/panel/SkillHubPanel.d.ts +11 -3
  17. package/lib/types/client/panel/SkillRow.d.ts +11 -0
  18. package/lib/types/client/panel/SourceStatusBadge.d.ts +18 -0
  19. package/lib/types/client/panel/SourcesView.d.ts +10 -0
  20. package/lib/types/client/panel/TagEditorView.d.ts +29 -0
  21. package/lib/types/client/panel/dialogs.d.ts +73 -0
  22. package/lib/types/client/panel/format.d.ts +14 -0
  23. package/lib/types/client/panel/useSkillHub.d.ts +183 -0
  24. package/lib/types/client/settings-card.d.ts +21 -4
  25. package/lib/types/client/settings-form.d.ts +2 -0
  26. package/lib/types/client/settings-nav-icon.d.ts +8 -0
  27. package/lib/types/index.d.ts +6 -0
  28. package/lib/types/protocol.d.ts +428 -7
  29. package/lib/types/provider.d.ts +6 -1
  30. package/lib/types/repo.d.ts +133 -0
  31. package/lib/types/routes.d.ts +9 -4
  32. package/lib/types/skillfs.d.ts +19 -14
  33. package/lib/types/stats.d.ts +17 -4
  34. package/lib/types/store.d.ts +112 -6
  35. package/lib/types/update.d.ts +20 -0
  36. package/package.json +5 -3
  37. package/src/client/SkillHubSettingsCard.tsx +61 -8
  38. package/src/client/api-config-scope.test.ts +79 -0
  39. package/src/client/api-config-scope.ts +28 -4
  40. package/src/client/api.ts +239 -9
  41. package/src/client/grouping.test.ts +109 -0
  42. package/src/client/grouping.ts +120 -0
  43. package/src/client/index.tsx +5 -2
  44. package/src/client/locales.ts +314 -40
  45. package/src/client/market-catalog.ts +22 -0
  46. package/src/client/panel/DisabledRow.tsx +32 -0
  47. package/src/client/panel/GroupSummary.tsx +27 -0
  48. package/src/client/panel/MarketView.tsx +173 -0
  49. package/src/client/panel/ScenesView.tsx +75 -0
  50. package/src/client/panel/SkillDetailView.tsx +95 -0
  51. package/src/client/panel/SkillHubPanel.tsx +251 -257
  52. package/src/client/panel/SkillRow.tsx +54 -0
  53. package/src/client/panel/SourceStatusBadge.tsx +61 -0
  54. package/src/client/panel/SourcesView.tsx +104 -0
  55. package/src/client/panel/TagEditorView.tsx +89 -0
  56. package/src/client/panel/dialogs.tsx +185 -0
  57. package/src/client/panel/format.ts +31 -0
  58. package/src/client/panel/panel.module.css +209 -45
  59. package/src/client/panel/useSkillHub.ts +797 -0
  60. package/src/client/settings-card.module.css +44 -28
  61. package/src/client/settings-card.tsx +64 -8
  62. package/src/client/settings-form.ts +12 -0
  63. package/src/client/settings-nav-icon.ts +42 -0
  64. package/src/index.ts +19 -15
  65. package/src/protocol.ts +460 -7
  66. package/src/provider.test.ts +2 -4
  67. package/src/provider.ts +18 -14
  68. package/src/repo.test.ts +212 -0
  69. package/src/repo.ts +528 -0
  70. package/src/routes.test.ts +564 -52
  71. package/src/routes.ts +999 -153
  72. package/src/skillfs.test.ts +98 -16
  73. package/src/skillfs.ts +68 -33
  74. package/src/stats.test.ts +52 -17
  75. package/src/stats.ts +55 -18
  76. package/src/store.test.ts +205 -1
  77. package/src/store.ts +527 -24
  78. package/src/update.test.ts +73 -0
  79. package/src/update.ts +136 -0
@@ -11,7 +11,7 @@
11
11
  * userInvocable defaults true), legacy keys rejected, and the returned
12
12
  * content is the body after the frontmatter block, trimmed.
13
13
  */
14
- import type { DiagnosticEntry, WritableRoot } from './protocol.ts';
14
+ import type { DiagnosticEntry, TrashEntry, WritableRoot } from './protocol.ts';
15
15
  /** Root ids this module may write to. */
16
16
  export declare const WRITABLE_ROOTS: readonly WritableRoot[];
17
17
  /** Resolve the absolute directory of one writable root. */
@@ -32,13 +32,26 @@ export declare function createSkill(root: WritableRoot, name: string, descriptio
32
32
  export declare function disableSkill(path: string): Promise<string>;
33
33
  /** Re-enable a previously disabled file (reverse of disableSkill). */
34
34
  export declare function enableSkill(disabledPath: string): Promise<string>;
35
+ /**
36
+ * Move a skill (directory bundle or flat .md file) into the root's trash.
37
+ * The skill stays recoverable: the source is renamed into
38
+ * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
39
+ * SKILL.md path and moved as a whole directory.
40
+ * @returns the trashed path plus the original path that was moved.
41
+ */
42
+ export declare function trashSkill(sourcePath: string): Promise<{
43
+ path: string;
44
+ source: string;
45
+ }>;
46
+ /** Restore a trashed skill (directory or flat file) to its original location. */
47
+ export declare function restoreSkill(entry: TrashEntry, home?: string): Promise<string>;
48
+ /** Permanently delete one trashed skill (directory or flat file). */
49
+ export declare function clearTrash(entry: TrashEntry, home?: string): Promise<string>;
35
50
  /** One parsed frontmatter outcome (official provider semantics). */
36
51
  export interface FrontmatterValue {
37
52
  name: string;
38
53
  description: string;
39
54
  whenToUse?: string;
40
- /** Optional group names the skill declares (frontmatter `sets`). */
41
- sets?: string[];
42
55
  invocation: {
43
56
  modelInvocable: boolean;
44
57
  userInvocable: boolean;
@@ -46,13 +59,6 @@ export interface FrontmatterValue {
46
59
  /** Instruction body after the frontmatter block, trimmed. */
47
60
  content: string;
48
61
  }
49
- /**
50
- * Normalize an optional frontmatter `sets` value into a clean string list.
51
- * Accepts a single string or a string array; trims, drops empties, and
52
- * ignores non-string entries. Returns undefined when absent or empty, so a
53
- * skill without sets stays uncategorized.
54
- */
55
- export declare function normalizeSets(raw: unknown): string[] | undefined;
56
62
  /** Parse a SKILL.md the way the official filesystem provider does. */
57
63
  export declare function parseFrontmatter(text: string): {
58
64
  value: FrontmatterValue;
@@ -70,11 +76,10 @@ export interface SkillEntry {
70
76
  /**
71
77
  * Scan one skills root for discovery files: directory bundles (SKILL.md)
72
78
  * and flat <name>.md files. Hub-disabled files (.disabled) are excluded;
73
- * the .system subdirectory is excluded when skipSystem is set (mirrors the
74
- * official provider's user-dsh behavior).
79
+ * dot-prefixed entries (including .trash and .system) are always skipped.
75
80
  */
76
- export declare function scanRoot(base: string, skipSystem: boolean): Promise<SkillEntry[]>;
77
- /** Scan one writable root (user-dsh skips .system, mirroring the official provider). */
81
+ export declare function scanRoot(base: string): Promise<SkillEntry[]>;
82
+ /** Scan one writable root. */
78
83
  export declare function listSkillEntries(root: WritableRoot, home?: string): Promise<SkillEntry[]>;
79
84
  /**
80
85
  * Walk up from cwd (max 32 levels) for a project marker (.dsh or .git);
@@ -26,11 +26,24 @@ export interface SessionQueryLike {
26
26
  events: SessionEvent[];
27
27
  }>;
28
28
  }
29
- /** Count skill invocations in one session's event log, keyed by skill name. */
30
- export declare function countSkillInvocations(events: readonly SessionEvent[]): Map<string, number>;
31
- /** Scan every session and total per-skill invocation counts. */
29
+ /** Per-skill invocation stats in one session's event log. */
30
+ export interface InvocationStat {
31
+ count: number;
32
+ /** Unix epoch ms of the most recent invocation in this batch. */
33
+ lastUsed: number;
34
+ }
35
+ /** Collect per-skill invocation counts and last-used times from one session. */
36
+ export declare function countSkillInvocations(events: readonly SessionEvent[]): Map<string, InvocationStat>;
37
+ /** Scan every session and total per-skill counts, keeping the latest lastUsed. */
32
38
  export declare function readSkillStats(query: SessionQueryLike): Promise<SkillStat[]>;
33
39
  /** A memoized stats reader (the panel polls, but logs change slowly). */
34
40
  export type SkillStatsReader = () => Promise<SkillStat[]>;
35
- /** Wrap a query in a TTL cache so a 5s panel poll does not rescan every log. */
41
+ /**
42
+ * Wrap a query in a stale-while-revalidate cache: responses never wait for a
43
+ * full session-log scan. While the TTL is fresh the cached totals are
44
+ * returned; after expiry the stale totals are returned immediately and a
45
+ * single background rescan refreshes them — the panel's next poll picks the
46
+ * fresh numbers. A full scan decompresses every session log and can take
47
+ * seconds, so it must never sit on the request path.
48
+ */
36
49
  export declare function createSkillStatsReader(query: SessionQueryLike, ttlMs?: number): SkillStatsReader;
@@ -1,25 +1,55 @@
1
1
  /**
2
- * Hub sidecar store: remembers which skills the hub toggled off. Disabling
3
- * renames the skill's SKILL.md (or flat .md) out of the filesystem provider's
4
- * discovery shapes, so the provider catalog alone cannot see disabled skills;
5
- * this store keeps name/path/root so the GUI can list them and re-enable.
2
+ * Hub sidecar store: remembers which skills the hub toggled off and the
3
+ * user's organization/tracking records. Disabling renames the skill's
4
+ * SKILL.md (or flat .md) out of the filesystem provider's discovery shapes,
5
+ * so the provider catalog alone cannot see disabled skills; this store keeps
6
+ * name/path/root so the GUI can list them and re-enable. It also persists
7
+ * user tag groups, upstream source records (repo + commit snapshot for
8
+ * update checks), the market source list, and the trash (skills removed
9
+ * after upstream deletion).
6
10
  *
7
11
  * State file: $DSH_HOME/dsh-skill-hub.json — a small JSON document written
8
12
  * atomically (tmp file + rename).
9
13
  */
10
- import type { DisabledSkill, HubConfig } from './protocol.ts';
14
+ import type { DisabledSkill, HubConfig, MarketSourceRecord, RepoRoot, SkillTag, SourceRecord, TrashEntry } from './protocol.ts';
15
+ /** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
16
+ export declare const DEFAULT_SCENE_NAME = "\u901A\u7528";
11
17
  /** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
12
18
  export declare function dshHome(): string;
13
19
  /** Resolve the sidecar state path (injectable in tests). */
14
20
  export declare function statePath(home?: string): string;
21
+ /** Current sidecar schema version. Bump on breaking shape changes and add a migration below. */
22
+ export declare const STORE_VERSION = 3;
23
+ /**
24
+ * Business-rule failure the routes layer can map onto a 4xx status instead
25
+ * of a blanket 500: user input is invalid (validation → 400), the target
26
+ * does not exist (not-found → 404), or the operation conflicts with an
27
+ * invariant (conflict → 409).
28
+ */
29
+ export declare class StoreError extends Error {
30
+ readonly kind: 'validation' | 'not-found' | 'conflict';
31
+ constructor(kind: StoreError['kind'], message: string);
32
+ }
15
33
  /** Sidecar state owner. */
16
34
  export declare class SkillHubStore {
17
35
  private readonly file;
18
36
  private entries;
19
37
  private config;
38
+ private tagsById;
39
+ private sourcesByRepo;
40
+ private marketSources;
41
+ private trashByName;
20
42
  private loaded;
43
+ /** Serializes persist runs: concurrent mutators must not let an earlier
44
+ * snapshot overwrite a later one (rename is atomic, ordering is not). */
45
+ private writeChain;
21
46
  constructor(file?: string);
22
47
  private ensureLoaded;
48
+ /**
49
+ * 保证存在默认场景:没有任何 default 标记的 tag 时创建「通用」。
50
+ * 新技能创建后自动归入它;用户可改名,但默认场景不可删除。
51
+ */
52
+ private ensureDefaultTag;
23
53
  listDisabled(): Promise<DisabledSkill[]>;
24
54
  getDisabled(name: string): Promise<DisabledSkill | undefined>;
25
55
  addDisabled(entry: DisabledSkill): Promise<void>;
@@ -28,9 +58,85 @@ export declare class SkillHubStore {
28
58
  getConfig(): Promise<Partial<HubConfig>>;
29
59
  /**
30
60
  * Persist a runtime-config patch, merged over the saved values. A field
31
- * whose patch value is `undefined` is removed from the saved layer, so the
61
+ * whose patch value is undefined is removed from the saved layer, so the
32
62
  * setting re-inherits its default (the web card's "reset" path).
33
63
  */
34
64
  setConfig(config: Partial<HubConfig>): Promise<void>;
65
+ /** All user-defined tag groups, in creation order. */
66
+ listTags(): Promise<SkillTag[]>;
67
+ /** One tag by id (undefined when absent). */
68
+ getTag(id: string): Promise<SkillTag | undefined>;
69
+ /**
70
+ * Create (no id) or rename (with id) a tag. Returns the saved tag.
71
+ * Creating assigns a fresh UUID; renaming keeps members.
72
+ */
73
+ saveTag(input: {
74
+ id?: string;
75
+ name: string;
76
+ }): Promise<SkillTag>;
77
+ /** Delete a tag by id (no-op when absent). The default scene cannot be deleted. */
78
+ deleteTag(id: string): Promise<void>;
79
+ /** The default scene (「通用」), guaranteed to exist after ensureLoaded. */
80
+ getDefaultTag(): Promise<SkillTag | undefined>;
81
+ /** Append one skill name to a tag (deduplicated; no-op when already a member). */
82
+ addSkillToTag(id: string, name: string): Promise<SkillTag | undefined>;
83
+ /**
84
+ * Replace a tag's member list wholesale (idempotent). Deduplicates and
85
+ * drops blank names; unknown skill names are kept (they may arrive later),
86
+ * the routes layer filters against the live catalog before persisting.
87
+ */
88
+ setTagMembers(id: string, skillNames: readonly string[]): Promise<SkillTag | undefined>;
89
+ /** Remove one skill from every tag group (used when the skill is deleted). */
90
+ removeSkillFromTags(name: string): Promise<void>;
91
+ /** All source records, sorted by repo. */
92
+ listSources(): Promise<SourceRecord[]>;
93
+ /** One source record by repo (undefined when absent). */
94
+ getSource(repo: string): Promise<SourceRecord | undefined>;
95
+ /** The source record that tracks a skill name (undefined when untracked). */
96
+ getSourceForSkill(name: string): Promise<SourceRecord | undefined>;
97
+ /** Remove a source record entirely (no-op when absent). */
98
+ deleteSource(repo: string): Promise<void>;
99
+ /**
100
+ * Upsert one skill into a source record. When the repo has no record yet a
101
+ * new one is created (root + commit snapshot from the caller).
102
+ */
103
+ addSourceSkill(repo: string, root: RepoRoot, commitSha: string, ref: string | undefined, skillName: string): Promise<void>;
104
+ /** Replace a source's skill list (used after sync/confirm-delete). */
105
+ setSourceSkills(repo: string, skills: readonly string[]): Promise<SourceRecord | undefined>;
106
+ /** Remove one skill from every source record (used when the skill is deleted). */
107
+ removeSkillFromSources(name: string): Promise<void>;
108
+ /** Update a source's commit snapshot. */
109
+ setSourceCommit(repo: string, commitSha: string): Promise<void>;
110
+ /** Update a source's pinned ref (release tag / branch) when the market syncs. */
111
+ setSourceRef(repo: string, ref: string): Promise<void>;
112
+ /**
113
+ * Merge per-path manifest entries into a source (incremental imports).
114
+ * When `dir` is given, every baseline path under that skill directory is
115
+ * dropped first, so files the upstream removed never linger in the
116
+ * baseline and skew later update diffs.
117
+ */
118
+ mergeSourceManifest(repo: string, manifest: Record<string, number>, dir?: string): Promise<void>;
119
+ /** skillName → collection name for every recorded origin (derived from sources). */
120
+ listOrigins(): Promise<Record<string, string>>;
121
+ /** The user's market sources, in addition order. */
122
+ listMarketSources(): Promise<MarketSourceRecord[]>;
123
+ /** One market source by repo (undefined when absent). */
124
+ getMarketSource(repo: string): Promise<MarketSourceRecord | undefined>;
125
+ /** Add a repo (deduplicated), optionally with a pinned ref. Returns the fresh list. */
126
+ addMarketSource(repo: string, ref?: string): Promise<MarketSourceRecord[]>;
127
+ /** Remove a repo (no-op when absent). Returns the fresh list. */
128
+ removeMarketSource(repo: string): Promise<MarketSourceRecord[]>;
129
+ /** Pin a market source to an explicit ref (branch/tag). Returns the record. */
130
+ setMarketSourceRef(repo: string, ref: string): Promise<MarketSourceRecord | undefined>;
131
+ /** Record the commit a market source's pinned ref resolved to (update baseline). */
132
+ setMarketSourceCommit(repo: string, commitSha: string): Promise<void>;
133
+ /** All trashed skills, newest first. */
134
+ listTrash(): Promise<TrashEntry[]>;
135
+ /** One trash entry by skill name (undefined when absent). */
136
+ getTrash(name: string): Promise<TrashEntry | undefined>;
137
+ /** Record a trashed skill. */
138
+ addTrash(entry: TrashEntry): Promise<void>;
139
+ /** Remove a trash record (after restore). */
140
+ removeTrash(name: string): Promise<void>;
35
141
  private persist;
36
142
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Self-update check for dsh-skill-hub, mirroring cc-switch's behavior:
3
+ * query GitHub's latest release and compare it to the installed version.
4
+ *
5
+ * This stays dependency-free: Node's global fetch is used and the version
6
+ * comparison is a small semver-subset parser (the plugin only publishes
7
+ * plain x.y.z tags, optionally prefixed with `v`).
8
+ */
9
+ import type { UpdateCheckResponse } from './protocol.ts';
10
+ /** Repository checked for releases. Keep in sync with package.json. */
11
+ export declare const UPDATE_REPO = "cheshireez/dsh-skill-hub";
12
+ export declare const CURRENT_VERSION: string;
13
+ /** Normalize a release tag (`v1.2.3` → `1.2.3`) and parse it numerically. */
14
+ export declare function parseVersion(tag: string): [number, number, number] | null;
15
+ /** Compare two dotted version strings: negative when a < b, 0 equal, positive when a > b. */
16
+ export declare function compareVersions(a: string, b: string): number;
17
+ /** True when latest is strictly newer than current. */
18
+ export declare function isUpdateAvailable(currentVersion: string, latestVersion: string): boolean;
19
+ /** Best-effort GitHub latest-release lookup. Always resolves with ok:true. */
20
+ export declare function checkLatestRelease(repo?: string, fetchImpl?: typeof fetch): Promise<UpdateCheckResponse>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.1.0",
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 — the full manager beyond the read-only dsh-skill-manager browser.",
3
+ "version": "0.2.0",
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": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -46,6 +46,7 @@
46
46
  "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
47
47
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
48
48
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
49
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
49
50
  "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
50
51
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
51
52
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
@@ -62,6 +63,7 @@
62
63
  "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
63
64
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
64
65
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
66
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
65
67
  "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
66
68
  "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.0-rc.6",
67
69
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
@@ -122,4 +124,4 @@
122
124
  "url": "https://github.com/cheshireez/dsh-skill-hub/issues"
123
125
  },
124
126
  "homepage": "https://github.com/cheshireez/dsh-skill-hub"
125
- }
127
+ }
@@ -9,13 +9,19 @@
9
9
 
10
10
  import type { ReactElement } from 'react'
11
11
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
- import { BooleanField, PluginSettingsCard, SwitchField } from './settings-card.tsx'
13
- import { booleanField, CardForm, type CardShell, type FieldState, type FormScope } from './settings-form.ts'
12
+ import { ColorField, PluginSettingsCard, SwitchField } from './settings-card.tsx'
13
+ import { booleanField, CardForm, colorField, type CardShell, type FieldState, type FormScope } from './settings-form.ts'
14
+ import { DEFAULT_DOT_MODEL_COLOR, DEFAULT_DOT_USER_COLOR } from './api-config-scope.ts'
14
15
 
15
16
  /** The card's projected state. */
16
17
  export interface SkillHubSettingsState extends CardShell {
17
18
  enabled: FieldState
18
19
  announceToAgent: FieldState
20
+ dotModelColor: FieldState
21
+ dotUserColor: FieldState
22
+ showUseCount: FieldState
23
+ showUseTime: FieldState
24
+ showGroupSummary: FieldState
19
25
  }
20
26
 
21
27
  /** Props the slot renderer receives (locale copy + injected form actions). */
@@ -35,7 +41,7 @@ export class SkillHubSettingsCardController {
35
41
 
36
42
  /** @param scope - the hub config scope the card edits (ApiConfigScope). */
37
43
  constructor(scope: FormScope) {
38
- this.form = new CardForm(scope, [booleanField('enabled'), booleanField('announceToAgent')])
44
+ this.form = new CardForm(scope, [booleanField('enabled'), booleanField('announceToAgent'), colorField('dotModelColor'), colorField('dotUserColor'), booleanField('showUseCount'), booleanField('showUseTime'), booleanField('showGroupSummary')])
39
45
  this.store = this.form.bind(() => this.projection())
40
46
  }
41
47
 
@@ -44,6 +50,11 @@ export class SkillHubSettingsCardController {
44
50
  ...this.form.shell(),
45
51
  enabled: this.form.field('enabled'),
46
52
  announceToAgent: this.form.field('announceToAgent'),
53
+ dotModelColor: this.form.field('dotModelColor'),
54
+ dotUserColor: this.form.field('dotUserColor'),
55
+ showUseCount: this.form.field('showUseCount'),
56
+ showUseTime: this.form.field('showUseTime'),
57
+ showGroupSummary: this.form.field('showGroupSummary'),
47
58
  }
48
59
  }
49
60
 
@@ -90,18 +101,60 @@ export function SkillHubSettingsCard(props: SkillHubSettingsCardProps): ReactEle
90
101
  onEdit={(text) => { props.edit('enabled', text) }}
91
102
  onReset={() => { props.resetField('enabled') }}
92
103
  />
93
- <BooleanField
94
- id='settings-skill-hub-announce'
104
+ <SwitchField
95
105
  label={t('settings.announceToAgent')}
96
106
  hint={t('settings.announceToAgentHint')}
97
- inheritLabel={t('settings.inherit')}
98
- onLabel={t('settings.on')}
99
- offLabel={t('settings.off')}
100
107
  {...fieldProps}
101
108
  {...state.announceToAgent}
102
109
  onEdit={(text) => { props.edit('announceToAgent', text) }}
103
110
  onReset={() => { props.resetField('announceToAgent') }}
104
111
  />
112
+ <ColorField
113
+ id='skill-hub-dot-model-color'
114
+ label={t('settings.dotModelColor')}
115
+ hint={t('settings.dotModelColorHint')}
116
+ inheritLabel={t('settings.inherit')}
117
+ defaultColor={DEFAULT_DOT_MODEL_COLOR}
118
+ {...fieldProps}
119
+ {...state.dotModelColor}
120
+ onEdit={(text) => { props.edit('dotModelColor', text) }}
121
+ onReset={() => { props.resetField('dotModelColor') }}
122
+ />
123
+ <ColorField
124
+ id='skill-hub-dot-user-color'
125
+ label={t('settings.dotUserColor')}
126
+ hint={t('settings.dotUserColorHint')}
127
+ inheritLabel={t('settings.inherit')}
128
+ defaultColor={DEFAULT_DOT_USER_COLOR}
129
+ {...fieldProps}
130
+ {...state.dotUserColor}
131
+ onEdit={(text) => { props.edit('dotUserColor', text) }}
132
+ onReset={() => { props.resetField('dotUserColor') }}
133
+ />
134
+ <SwitchField
135
+ label={t('settings.showUseCount')}
136
+ hint={t('settings.showUseCountHint')}
137
+ {...fieldProps}
138
+ {...state.showUseCount}
139
+ onEdit={(text) => { props.edit('showUseCount', text) }}
140
+ onReset={() => { props.resetField('showUseCount') }}
141
+ />
142
+ <SwitchField
143
+ label={t('settings.showUseTime')}
144
+ hint={t('settings.showUseTimeHint')}
145
+ {...fieldProps}
146
+ {...state.showUseTime}
147
+ onEdit={(text) => { props.edit('showUseTime', text) }}
148
+ onReset={() => { props.resetField('showUseTime') }}
149
+ />
150
+ <SwitchField
151
+ label={t('settings.showGroupSummary')}
152
+ hint={t('settings.showGroupSummaryHint')}
153
+ {...fieldProps}
154
+ {...state.showGroupSummary}
155
+ onEdit={(text) => { props.edit('showGroupSummary', text) }}
156
+ onReset={() => { props.resetField('showGroupSummary') }}
157
+ />
105
158
  </PluginSettingsCard>
106
159
  )
107
160
  }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ConfigRequest, ConfigResponse, HubConfig } from '../protocol.ts'
3
+ import type { SkillHubApi } from './api.ts'
4
+ import { ApiConfigScope } from './api-config-scope.ts'
5
+
6
+ function deferredReady(scope: ApiConfigScope): Promise<void> {
7
+ return new Promise((resolve) => setTimeout(resolve, 0))
8
+ }
9
+
10
+ describe('ApiConfigScope', () => {
11
+ it('persists the panel display toggles (showUseCount etc.)', async () => {
12
+ const saved: Partial<HubConfig> = {}
13
+ const writes: ConfigRequest[] = []
14
+ const api = {
15
+ config: async (): Promise<ConfigResponse> => ({
16
+ ok: true,
17
+ config: {
18
+ enabled: true,
19
+ announceToAgent: true,
20
+ showUseCount: true,
21
+ showUseTime: true,
22
+ showGroupSummary: true,
23
+ },
24
+ saved,
25
+ }),
26
+ saveConfig: async (patch: ConfigRequest): Promise<ConfigResponse> => {
27
+ writes.push(patch)
28
+ for (const [key, value] of Object.entries(patch)) {
29
+ if (value === null) delete saved[key as keyof HubConfig]
30
+ else saved[key as keyof HubConfig] = value as never
31
+ }
32
+ return {
33
+ ok: true,
34
+ config: {
35
+ enabled: true,
36
+ announceToAgent: true,
37
+ showUseCount: saved.showUseCount !== false,
38
+ showUseTime: saved.showUseTime !== false,
39
+ showGroupSummary: saved.showGroupSummary !== false,
40
+ },
41
+ saved,
42
+ }
43
+ },
44
+ } as unknown as SkillHubApi
45
+
46
+ const scope = new ApiConfigScope(api)
47
+ await deferredReady(scope)
48
+
49
+ await scope.set('showUseCount', false)
50
+ expect(writes).toContainEqual({ showUseCount: false })
51
+ expect(scope.getSnapshot().value).toMatchObject({ showUseCount: false })
52
+ expect(scope.getSnapshot().user).toMatchObject({ showUseCount: false })
53
+
54
+ await scope.unset('showUseCount')
55
+ expect(writes).toContainEqual({ showUseCount: null })
56
+ expect(scope.getSnapshot().user).not.toHaveProperty('showUseCount')
57
+ expect(scope.getSnapshot().value).toMatchObject({ showUseCount: true })
58
+ })
59
+
60
+ it('ignores unknown fields', async () => {
61
+ const writes: ConfigRequest[] = []
62
+ const api = {
63
+ config: async (): Promise<ConfigResponse> => ({
64
+ ok: true,
65
+ config: { enabled: true, announceToAgent: true },
66
+ saved: {},
67
+ }),
68
+ saveConfig: async (patch: ConfigRequest): Promise<ConfigResponse> => {
69
+ writes.push(patch)
70
+ return { ok: true, config: { enabled: true, announceToAgent: true }, saved: {} }
71
+ },
72
+ } as unknown as SkillHubApi
73
+
74
+ const scope = new ApiConfigScope(api)
75
+ await deferredReady(scope)
76
+ await scope.set('not-a-field', true)
77
+ expect(writes).toHaveLength(0)
78
+ })
79
+ })
@@ -17,11 +17,32 @@ import type { HubConfig } from '../protocol.ts'
17
17
  import type { SkillHubApi } from './api.ts'
18
18
  import type { FormScope } from './settings-form.ts'
19
19
 
20
+ /** Model-invocable dot color default. Single source for the TS side; the
21
+ * panel's CSS mirrors it via --hub-model (panel.module.css). */
22
+ export const DEFAULT_DOT_MODEL_COLOR = '#2f81f7'
23
+ /** User-invocable dot color default. Single source for the TS side; the
24
+ * panel's CSS mirrors it via --hub-user (panel.module.css). */
25
+ export const DEFAULT_DOT_USER_COLOR = '#3fb950'
26
+
20
27
  /** Built-in defaults every field inherits until the user overrides it. */
21
- const DEFAULTS: HubConfig = { enabled: true, announceToAgent: true }
28
+ const DEFAULTS: HubConfig = {
29
+ enabled: true,
30
+ announceToAgent: true,
31
+ showUseCount: true,
32
+ showUseTime: true,
33
+ showGroupSummary: true,
34
+ }
35
+
36
+ /** Boolean config fields (color fields are strings). */
37
+ const BOOLEAN_FIELDS = new Set<keyof HubConfig>([
38
+ 'enabled', 'announceToAgent', 'showUseCount', 'showUseTime', 'showGroupSummary',
39
+ ])
22
40
 
23
41
  /** Config fields the card edits (used to guard set/unset field names). */
24
- const FIELDS = new Set<keyof HubConfig>(['enabled', 'announceToAgent'])
42
+ const FIELDS = new Set<keyof HubConfig>([
43
+ 'enabled', 'announceToAgent', 'dotModelColor', 'dotUserColor',
44
+ 'showUseCount', 'showUseTime', 'showGroupSummary',
45
+ ])
25
46
 
26
47
  /** FormScope-compatible reactive config handle (see module doc). */
27
48
  export class ApiConfigScope implements FormScope {
@@ -67,7 +88,10 @@ export class ApiConfigScope implements FormScope {
67
88
  /** Write one field through the config route and adopt the persisted state. */
68
89
  async set(field: string, value: unknown): Promise<void> {
69
90
  if (!FIELDS.has(field as keyof HubConfig)) return
70
- await this.apply({ [field]: Boolean(value) } as Partial<HubConfig>)
91
+ const patch = BOOLEAN_FIELDS.has(field as keyof HubConfig)
92
+ ? { [field]: Boolean(value) }
93
+ : { [field]: typeof value === 'string' ? value : undefined }
94
+ await this.apply(patch as Partial<HubConfig>)
71
95
  }
72
96
 
73
97
  /** Clear one field's override so it re-inherits the default. */
@@ -79,7 +103,7 @@ export class ApiConfigScope implements FormScope {
79
103
  /** Persist a patch, then re-seed from the route's fresh response. */
80
104
  private async apply(patch: Partial<HubConfig>): Promise<void> {
81
105
  // null on the wire clears a saved override (see ConfigRequest).
82
- const request: Record<string, boolean | null> = {}
106
+ const request: Record<string, boolean | string | null> = {}
83
107
  for (const [key, value] of Object.entries(patch)) {
84
108
  request[key] = value === undefined ? null : value
85
109
  }