skillshelf 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wang-Cankun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # skillshelf
2
+
3
+ **A package manager for your agent skills — one canonical library, loaded on demand, never all at once.**
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
6
+ [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-black?logo=bun)](https://bun.sh)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/Wang-Cankun/skillshelf/ci.yml?branch=main)](https://github.com/Wang-Cankun/skillshelf/actions)
8
+ [![npm](https://img.shields.io/npm/v/skillshelf.svg)](https://www.npmjs.com/package/skillshelf)
9
+
10
+ Your skills are scattered: some in `~/.claude/skills`, some buried in Obsidian or notes
11
+ vaults, more copied into a dozen per-project `.claude` directories. You forget which ones
12
+ exist, rewrite ones you already have, and copies drift out of sync. The naive fix — dump
13
+ everything into `~/.claude/skills` — makes every session pay the token cost of loading
14
+ hundreds of skill descriptions at once.
15
+
16
+ skillshelf is the middle path: a single git-backed **library** that is a *passive shelf*
17
+ (nothing auto-loads), plus a CLI to **search, tag, bundle, and load** exactly the skills a
18
+ project needs, exactly when it needs them. Find anything in one place; pay only for what you
19
+ actually use.
20
+
21
+ ## Install
22
+
23
+ skillshelf runs on [Bun](https://bun.sh) (>= 1.0). No other runtime dependencies.
24
+
25
+ ```bash
26
+ # Run it without installing
27
+ bunx skillshelf <command>
28
+
29
+ # Or install the `skl` binary globally
30
+ bun add -g skillshelf
31
+ skl <command>
32
+ ```
33
+
34
+ ## Quickstart
35
+
36
+ ```bash
37
+ # 1. Set up the config + library and link the thin global core
38
+ skl init
39
+
40
+ # 2. Scaffold a new skill into the library (or `skl add` a third-party one)
41
+ skl new rnaseq-qc --domain bioinfo --desc "QC gate for RNA-seq count matrices"
42
+
43
+ # 3. Find skills across the whole library
44
+ skl ls
45
+ skl search rnaseq
46
+
47
+ # 4. Read a skill's instructions on demand — no token cost until you ask
48
+ skl show rnaseq-qc
49
+
50
+ # 5. Activate a domain bundle in the project you're working in
51
+ cd ~/projects/my-analysis
52
+ skl use bioinfo # symlinks every skill tagged `bioinfo` into ./.claude/skills
53
+ skl status # what's currently linked here
54
+ skl drop bioinfo # unlink when you're done
55
+ ```
56
+
57
+ Add `--json` to any command for machine-readable output (skillshelf is built to be driven
58
+ by an agent as well as a human).
59
+
60
+ ## How it works
61
+
62
+ skillshelf separates *owning* a skill from *loading* it.
63
+
64
+ - **Canonical library** — a dedicated git repo, one file per skill in its primary-domain
65
+ folder. This is a passive shelf: nothing here auto-loads, which is exactly what kills the
66
+ all-at-once token cost.
67
+ - **Domain bundles** — bundles are *tag queries*, not folders. A skill tagged
68
+ `domains: [coding, bioinfo]` shows up in both bundles from a single copy on disk.
69
+ `skl use bioinfo` resolves every skill carrying that tag.
70
+ - **Thin global core** — a handful of universal skills (commit, search, memory) are
71
+ symlinked permanently into `~/.claude/skills` so they always auto-trigger. Small, bounded
72
+ token cost — "some loaded is fine; all-at-once is the problem."
73
+ - **On-demand `show`** — prints only the SKILL.md instruction body and lists the paths of
74
+ any bundled reference files (without reading them). Progressive disclosure: cheap by
75
+ default, deep when you ask. Works mid-task with no reload.
76
+ - **Sidecar overlay** — installed third-party skills keep a pristine `upstream/` body plus a
77
+ `<skill>.shelf.json` overlay holding *your* tags, bundle membership, and notes. `skl update`
78
+ swaps the upstream body cleanly while your taxonomy survives — updates never clobber your tags.
79
+
80
+ ```
81
+ skl search / ls / show skl use <bundle>
82
+ │ │
83
+ ┌──────────────────────┐ │ ┌──────────────────────┐ │ ┌─────────────────────┐
84
+ │ canonical library │────┴──▶│ bundles = tag query │───┴──▶│ project .claude/ │
85
+ │ (passive git shelf) │ │ bioinfo · coding · … │ │ skills/ (symlinks) │
86
+ └──────────┬───────────┘ └──────────────────────┘ └─────────────────────┘
87
+
88
+ └──── thin global core ──▶ ~/.claude/skills (always-on, bounded)
89
+ ```
90
+
91
+ See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for the full design.
92
+
93
+ ## Command reference
94
+
95
+ | Command | Summary | Key flags |
96
+ |---|---|---|
97
+ | `skl init` | Set up `~/.skillshelf` config + library and link the global-core skills | `--force` |
98
+ | `skl new <name>` | Scaffold a new skill dir + SKILL.md into the library | `--domain <d>`, `--desc "..."`, `--force` |
99
+ | `skl ls [bundle]` | One-line listing of the library, or one bundle | `--all` |
100
+ | `skl search <kw...>` | Fuzzy match over name + description + domains across the library | — |
101
+ | `skl show <name>` | Print a skill's SKILL.md body; list reference-file paths (not contents) | — |
102
+ | `skl status` | Show which library skills are linked into `./.claude/skills` | — |
103
+ | `skl use <bundle>` | Symlink a bundle's skills into `./.claude/skills/` (hot-loads) | — |
104
+ | `skl drop <bundle>` | Remove a bundle's symlinks from `./.claude/skills/` | — |
105
+ | `skl add <src>` | Install a third-party skill (`github:`/registry), record provenance, auto-tag | `--domain <d>`, `--name <slug>`, `--no-infer`, `--force` |
106
+ | `skl outdated [name]` | Check upstream ref per tracked skill and mark stale ones | — |
107
+ | `skl update [name]` | Re-pull upstream body, preserve overlay, diff if local body diverged | `--force`, `--dry-run` |
108
+ | `skl index` | Regenerate `INDEX.md` (catalog grouped by domain) | — |
109
+ | `skl infer` | Re-run AI domain taxonomy over the library (emit/apply/provider modes) | see below |
110
+
111
+ Every command also accepts `--json`.
112
+
113
+ ## AI taxonomy & inference
114
+
115
+ Domains and tags can be inferred and re-run by an LLM via `skl infer`. **This is optional —
116
+ the entire core (search, ls, show, use, bundles, add, update) works fully without any LLM.**
117
+ Inference has three mutually exclusive modes:
118
+
119
+ ```
120
+ skl infer [--emit | --apply <file.json> | --provider <name>] \
121
+ [--base-url <url>] [--model <id>] [--include-retired] [--json]
122
+ ```
123
+
124
+ **Agent modes (no network call from skillshelf):**
125
+
126
+ - `--emit` — print a self-contained prompt + the library payload as JSON. Hand it to whatever
127
+ agent or model you already have open; it does the reasoning.
128
+ - `--apply <file.json>` — apply the taxonomy proposal the agent produced back into the library
129
+ (for review/approval), updating tags via the overlay.
130
+
131
+ **API mode (skillshelf calls an OpenAI-compatible endpoint itself):**
132
+
133
+ Entered when **either** `--provider` **or** `--base-url` is given. The request is
134
+ `POST {base}/chat/completions`, OpenAI schema, `temperature: 0`,
135
+ `response_format: {type: "json_object"}` (strict JSON; falls back to brace-extraction if the
136
+ model wraps JSON in prose or fences).
137
+
138
+ `--provider <name>` is sugar that only sets a default base URL — the API key always comes
139
+ from the environment or a dotenv file.
140
+
141
+ | Provider | Base URL |
142
+ |---|---|
143
+ | `openai` | `https://api.openai.com/v1` |
144
+ | `openrouter` | `https://openrouter.ai/api/v1` |
145
+ | `groq` | `https://api.groq.com/openai/v1` |
146
+ | `ollama` | `http://localhost:11434/v1` |
147
+ | `custom` | resolved entirely from `--base-url` / env |
148
+
149
+ Resolution order, applied independently to base URL, API key, and model (highest precedence
150
+ first):
151
+
152
+ 1. CLI flags (`--base-url`, `--model`; `--provider` for the base-URL preset)
153
+ 2. Environment variables — `SKILLSHELF_LLM_*` primary, then `OPENAI_*` fallback
154
+ 3. Optional dotenv file at `$SKILLSHELF_ENV_FILE` (default: `./.env` if it exists, else none)
155
+
156
+ **Environment variables:**
157
+
158
+ | Variable | Purpose |
159
+ |---|---|
160
+ | `SKILLSHELF_LLM_BASE_URL` | base URL including `/v1` |
161
+ | `SKILLSHELF_LLM_API_KEY` | bearer API key |
162
+ | `SKILLSHELF_LLM_MODEL` | chat model id |
163
+ | `SKILLSHELF_ENV_FILE` | path to a dotenv file (optional; default `./.env` if present) |
164
+ | `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL` | convention fallbacks for the three above |
165
+
166
+ Defaults: base URL `https://api.openai.com/v1`, model `gpt-4o-mini` (a placeholder —
167
+ override with `--model` or `*_MODEL`).
168
+
169
+ The dotenv parser supports `KEY=value` and `export KEY=value`, strips surrounding quotes,
170
+ ignores blank and `#` comment lines, and never throws.
171
+
172
+ Errors are deterministic and raised before any network call:
173
+
174
+ - No resolvable key → `missing API key. Set SKILLSHELF_LLM_API_KEY (or OPENAI_API_KEY) in the environment or a dotenv file ($SKILLSHELF_ENV_FILE, default ./.env).`
175
+ - Unknown provider → `unknown provider "X". known: openai, openrouter, groq, ollama, custom`
176
+
177
+ Example:
178
+
179
+ ```bash
180
+ export SKILLSHELF_LLM_API_KEY=sk-...
181
+ skl infer --provider openai --model gpt-4o-mini
182
+ # or fully self-hosted:
183
+ skl infer --base-url http://localhost:11434/v1 --model llama3.1
184
+ ```
185
+
186
+ ## Configuration
187
+
188
+ | Setting | What it controls | Default |
189
+ |---|---|---|
190
+ | `SKILLSHELF_LIBRARY` | path to the canonical library (env, highest precedence) | `~/.skillshelf/library` |
191
+ | `~/.skillshelf/config.json` | `{ "library": "...", "globalCore": "..." }` | — |
192
+ | `SKILLSHELF_GLOBAL_CORE` | where global-core skills are symlinked | `~/.claude/skills` |
193
+
194
+ Library path resolution: `SKILLSHELF_LIBRARY` → `config.json` → default. See the
195
+ [AI taxonomy](#ai-taxonomy--inference) section for the `SKILLSHELF_LLM_*` / `OPENAI_*` /
196
+ `SKILLSHELF_ENV_FILE` inference variables.
197
+
198
+ ## Contributing
199
+
200
+ Contributions are welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md). Tests run on Bun:
201
+
202
+ ```bash
203
+ bun test
204
+ ```
205
+
206
+ ## License
207
+
208
+ [MIT](./LICENSE) © skillshelf contributors.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "skillshelf",
3
+ "version": "0.1.0",
4
+ "description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Wang-Cankun (https://github.com/Wang-Cankun)",
8
+ "homepage": "https://github.com/Wang-Cankun/skillshelf#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Wang-Cankun/skillshelf.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/Wang-Cankun/skillshelf/issues"
15
+ },
16
+ "keywords": [
17
+ "claude-code",
18
+ "skills",
19
+ "agent",
20
+ "cli",
21
+ "registry",
22
+ "bun"
23
+ ],
24
+ "bin": {
25
+ "skl": "./src/cli.ts"
26
+ },
27
+ "scripts": {
28
+ "skl": "bun run src/cli.ts",
29
+ "test": "bun test"
30
+ },
31
+ "engines": {
32
+ "bun": ">=1.0.0"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "LICENSE"
41
+ ]
42
+ }
@@ -0,0 +1,253 @@
1
+ // Agent-driven inference adapter (LLM-FREE core).
2
+ //
3
+ // `skl infer` is dual-mode but its deterministic core never calls an LLM:
4
+ // - emit : assemble an InferenceCorpus + JSON schema + instruction and print it
5
+ // to stdout so the HOST agent (Claude Code) can reason over it and
6
+ // produce a proposal file.
7
+ // - apply: read the agent's proposal JSON and write proposed domains/tags into
8
+ // each skill's `<name>.shelf.json` overlay (never upstream SKILL.md).
9
+ //
10
+ // The api.ts adapter reuses buildCorpus() + applyProposal() to close the loop
11
+ // automatically against any OpenAI-compatible LLM endpoint.
12
+
13
+ import type { InferenceCorpus, Overlay, Skill } from "../../types.ts";
14
+ import { listDomains } from "../../core/library.ts";
15
+ import { readOverlay, writeOverlay } from "../../core/overlay.ts";
16
+ import { parseFrontmatter } from "../../lib/frontmatter.ts";
17
+
18
+ /** Max characters of SKILL.md body included per skill in the corpus preview. */
19
+ const BODY_PREVIEW_CHARS = 1200;
20
+
21
+ /**
22
+ * The proposal shape the host agent (or the gateway) must return: a map of
23
+ * skill name -> proposed domains, plus optional primary + notes. Authors apply
24
+ * `domains` into each overlay (unioned with existing, never destructive).
25
+ */
26
+ export interface InferenceProposalEntry {
27
+ name: string;
28
+ domains: string[];
29
+ primaryDomain?: string | null;
30
+ notes?: string;
31
+ }
32
+
33
+ export interface InferenceProposal {
34
+ /** vocabulary the model settled on (may surface new domains) */
35
+ domains?: string[];
36
+ /** per-skill assignments */
37
+ assignments: InferenceProposalEntry[];
38
+ }
39
+
40
+ /** JSON Schema describing the InferenceProposal the agent must produce. */
41
+ export const PROPOSAL_SCHEMA = {
42
+ type: "object",
43
+ required: ["assignments"],
44
+ properties: {
45
+ domains: {
46
+ type: "array",
47
+ items: { type: "string" },
48
+ description: "The domain vocabulary you settled on. Surface new domains freely.",
49
+ },
50
+ assignments: {
51
+ type: "array",
52
+ items: {
53
+ type: "object",
54
+ required: ["name", "domains"],
55
+ properties: {
56
+ name: { type: "string", description: "Exact skill name from the corpus." },
57
+ domains: {
58
+ type: "array",
59
+ items: { type: "string" },
60
+ description: "Domain tags for this skill, primary first. Lowercase, hyphenated.",
61
+ },
62
+ primaryDomain: {
63
+ type: ["string", "null"],
64
+ description: "The single primary domain (usually domains[0]).",
65
+ },
66
+ notes: { type: "string", description: "Optional one-line rationale." },
67
+ },
68
+ additionalProperties: false,
69
+ },
70
+ },
71
+ },
72
+ additionalProperties: false,
73
+ } as const;
74
+
75
+ /** Instruction text handed to the host agent alongside the corpus + schema. */
76
+ export const INFER_INSTRUCTION = [
77
+ "You are the taxonomy inference pass for a personal skill library.",
78
+ "Read the `corpus` below: each entry is a skill with its name, description,",
79
+ "current domain tags, and a body preview. Cluster the skills into a small,",
80
+ "coherent set of domains. You MAY invent domains the author did not think of",
81
+ "(e.g. `coding`) when the evidence supports it. Assign each skill a primary",
82
+ "domain plus any honest secondary tags (a dual-use skill belongs to multiple).",
83
+ "Keep domain tokens lowercase and hyphenated.",
84
+ "Return ONE JSON object that validates against `schema` (no prose, no markdown",
85
+ "fences). Then run `skl infer --apply <file.json>` to write it into the overlays.",
86
+ ].join(" ");
87
+
88
+ /** Build the deterministic InferenceCorpus snapshot from loaded skills. */
89
+ export async function buildCorpus(
90
+ skills: Skill[],
91
+ opts: { generatedAt?: string; includeRetired?: boolean } = {},
92
+ ): Promise<InferenceCorpus> {
93
+ const pool = opts.includeRetired ? skills : skills.filter((s) => !s.retired);
94
+ // Prefer canonical copies: skip .agents bridge mirrors so each name appears once.
95
+ const seen = new Set<string>();
96
+ const corpusSkills: InferenceCorpus["skills"] = [];
97
+ for (const s of pool) {
98
+ if (s.mirrorOf) continue;
99
+ if (seen.has(s.name)) continue;
100
+ seen.add(s.name);
101
+ corpusSkills.push({
102
+ name: s.name,
103
+ description: s.description,
104
+ currentDomains: s.domains,
105
+ bodyPreview: await readBodyPreview(s),
106
+ });
107
+ }
108
+ corpusSkills.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
109
+ return {
110
+ skills: corpusSkills,
111
+ observedDomains: listDomains(pool),
112
+ generatedAt: opts.generatedAt ?? new Date().toISOString(),
113
+ };
114
+ }
115
+
116
+ /** Read + trim the SKILL.md body (frontmatter stripped) for a corpus preview. */
117
+ async function readBodyPreview(skill: Skill): Promise<string> {
118
+ let raw = "";
119
+ try {
120
+ raw = await Bun.file(skill.bodyPath).text();
121
+ } catch {
122
+ return "";
123
+ }
124
+ const { body } = parseFrontmatter(raw);
125
+ const flat = body.replace(/\s+/g, " ").trim();
126
+ if (flat.length <= BODY_PREVIEW_CHARS) return flat;
127
+ return flat.slice(0, BODY_PREVIEW_CHARS - 1).trimEnd() + "…";
128
+ }
129
+
130
+ /** The full emit payload: instruction + schema + corpus. */
131
+ export interface InferEmitPayload {
132
+ instruction: string;
133
+ schema: typeof PROPOSAL_SCHEMA;
134
+ corpus: InferenceCorpus;
135
+ }
136
+
137
+ /** Assemble the emit payload a host agent reasons over. */
138
+ export async function buildEmitPayload(
139
+ skills: Skill[],
140
+ opts: { generatedAt?: string; includeRetired?: boolean } = {},
141
+ ): Promise<InferEmitPayload> {
142
+ return {
143
+ instruction: INFER_INSTRUCTION,
144
+ schema: PROPOSAL_SCHEMA,
145
+ corpus: await buildCorpus(skills, opts),
146
+ };
147
+ }
148
+
149
+ /** Coerce arbitrary parsed JSON into a normalized InferenceProposal. */
150
+ export function normalizeProposal(raw: unknown): InferenceProposal {
151
+ const obj = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
152
+ // Accept either {assignments:[...]} or a bare {name:domains} map.
153
+ let assignmentsRaw: unknown = obj.assignments;
154
+ if (!Array.isArray(assignmentsRaw)) {
155
+ // try a plain map form: { "skill-a": ["x","y"], ... }
156
+ const map = obj as Record<string, unknown>;
157
+ const fromMap: InferenceProposalEntry[] = [];
158
+ for (const [k, v] of Object.entries(map)) {
159
+ if (k === "domains" || k === "assignments") continue;
160
+ if (Array.isArray(v)) {
161
+ fromMap.push({ name: k, domains: v.map((x) => String(x).trim()).filter(Boolean) });
162
+ }
163
+ }
164
+ assignmentsRaw = fromMap;
165
+ }
166
+ const assignments: InferenceProposalEntry[] = [];
167
+ for (const a of assignmentsRaw as unknown[]) {
168
+ if (!a || typeof a !== "object") continue;
169
+ const e = a as Record<string, unknown>;
170
+ const name = typeof e.name === "string" ? e.name.trim() : "";
171
+ if (name === "") continue;
172
+ const domains = Array.isArray(e.domains)
173
+ ? e.domains.map((x) => String(x).trim()).filter(Boolean)
174
+ : [];
175
+ const entry: InferenceProposalEntry = { name, domains };
176
+ if (typeof e.primaryDomain === "string") entry.primaryDomain = e.primaryDomain.trim();
177
+ else if (e.primaryDomain === null) entry.primaryDomain = null;
178
+ if (typeof e.notes === "string" && e.notes.trim() !== "") entry.notes = e.notes.trim();
179
+ assignments.push(entry);
180
+ }
181
+ const domains = Array.isArray(obj.domains)
182
+ ? obj.domains.map((x) => String(x).trim()).filter(Boolean)
183
+ : undefined;
184
+ return domains ? { domains, assignments } : { assignments };
185
+ }
186
+
187
+ export interface ApplyResult {
188
+ /** skill name -> domains written into its overlay */
189
+ applied: Array<{ name: string; domains: string[]; added: string[] }>;
190
+ /** assignment names with no matching skill in the library */
191
+ unmatched: string[];
192
+ /** assignment names skipped because they proposed no domains */
193
+ skipped: string[];
194
+ }
195
+
196
+ /**
197
+ * Apply a proposal into each skill's overlay. Domains are UNIONED with the
198
+ * skill's existing effective domains (never destructive), written to
199
+ * `<name>.shelf.json` only — upstream SKILL.md is never touched.
200
+ */
201
+ export async function applyProposal(
202
+ skills: Skill[],
203
+ proposal: InferenceProposal,
204
+ ): Promise<ApplyResult> {
205
+ const byName = new Map<string, Skill>();
206
+ for (const s of skills) {
207
+ // prefer canonical (non-mirror) copy when a name appears twice
208
+ const existing = byName.get(s.name);
209
+ if (!existing || (existing.mirrorOf && !s.mirrorOf)) byName.set(s.name, s);
210
+ }
211
+
212
+ const applied: ApplyResult["applied"] = [];
213
+ const unmatched: string[] = [];
214
+ const skipped: string[] = [];
215
+
216
+ for (const a of proposal.assignments) {
217
+ const skill = byName.get(a.name);
218
+ if (!skill) {
219
+ unmatched.push(a.name);
220
+ continue;
221
+ }
222
+ // Order: primaryDomain first (if given), then proposed domains, de-duped.
223
+ const ordered: string[] = [];
224
+ const push = (d: string | null | undefined) => {
225
+ const s = (d ?? "").trim();
226
+ if (s !== "" && !ordered.includes(s)) ordered.push(s);
227
+ };
228
+ push(a.primaryDomain);
229
+ for (const d of a.domains) push(d);
230
+ if (ordered.length === 0) {
231
+ skipped.push(a.name);
232
+ continue;
233
+ }
234
+
235
+ const prev = await readOverlay(skill);
236
+ const existingDomains = Array.isArray(prev?.domains) ? prev!.domains : [];
237
+ const merged: string[] = [...existingDomains];
238
+ const added: string[] = [];
239
+ for (const d of ordered) {
240
+ if (!merged.includes(d)) {
241
+ merged.push(d);
242
+ added.push(d);
243
+ }
244
+ }
245
+
246
+ const next: Overlay = { ...(prev ?? {}), domains: merged };
247
+ if (a.notes) next.notes = a.notes;
248
+ await writeOverlay(skill, next);
249
+ applied.push({ name: a.name, domains: merged, added });
250
+ }
251
+
252
+ return { applied, unmatched, skipped };
253
+ }