@lifeaitools/rdc-skills 0.15.0 → 0.17.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdc",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code — plan, build, review, overnight unattended builds with work-item tracking and TDD enforcement.",
5
5
  "author": {
6
6
  "name": "LIFEAI",
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/rdc-skills-mcp.mjs — local MCP server exposing the rdc-skills library.
4
+ *
5
+ * Mirrors the codeflow MCP topology (packages/codeflow/src/mcp/server.ts):
6
+ * express + @modelcontextprotocol/sdk McpServer + StreamableHTTPServerTransport,
7
+ * stateless (a fresh server+transport per POST /mcp), PORT from env.
8
+ *
9
+ * Routes:
10
+ * POST /mcp — MCP over StreamableHTTP. NO Authorization (URL is the shared
11
+ * secret, consistent with codeflow/clauth tunnel MCPs).
12
+ * GET /health — { status, service, version, skills } — no auth, no heavy work.
13
+ * /.well-known/* + /authorize + /token → 404 (force connectors to skip OAuth).
14
+ *
15
+ * It is tunneled in production at https://rdc-skills.regendevcorp.com/mcp; this
16
+ * process only listens on PORT (default 3110) and does not configure the tunnel.
17
+ *
18
+ * ── Caller detection → variant (best-effort) ────────────────────────────────
19
+ * Tools render a `cli` or `cloud` body. Detection rule: on MCP `initialize`,
20
+ * `clientInfo.name` containing `claude-code` or `codex` → `cli`; anything else
21
+ * (claude.ai web) → `cloud`; unknown → `cloud` (safer for web).
22
+ *
23
+ * APPROACH TAKEN — default + explicit override (NOT session-threaded):
24
+ * The transport is stateless (a new McpServer per POST, sessionIdGenerator:
25
+ * undefined), so there is no durable session to thread clientInfo through to a
26
+ * later tools/call. Rather than build a fragile session map, we:
27
+ * - capture clientInfo on `initialize` and remember the MOST RECENT one
28
+ * process-wide as a soft default (helps the common single-client case), and
29
+ * - ALWAYS honor an explicit `variant` arg on rdc_skill_get (the must-have).
30
+ * If no explicit variant is given and no client has initialized this process,
31
+ * the default is `cloud`. This keeps the override correct and the auto-detect
32
+ * best-effort, exactly as the spec permits.
33
+ */
34
+
35
+ import express from 'express';
36
+ import { z } from 'zod';
37
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
38
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
39
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+ import { fileURLToPath } from 'node:url';
42
+
43
+ import {
44
+ listSkills,
45
+ getSkill,
46
+ getSkillBody,
47
+ getCloudOverride,
48
+ skillNames,
49
+ searchSkills,
50
+ } from '../lib/catalog.mjs';
51
+ import { toCloudBody } from '../lib/cloud-rewrite.mjs';
52
+
53
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
54
+ const REPO_ROOT = path.resolve(__dirname, '..');
55
+ const PORT = parseInt(process.env.PORT || '3110', 10);
56
+
57
+ function pkgVersion() {
58
+ try {
59
+ return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).version || '0.0.0';
60
+ } catch {
61
+ return '0.0.0';
62
+ }
63
+ }
64
+
65
+ // Soft process-wide default variant, updated whenever a client initializes.
66
+ let lastDetectedVariant = 'cloud';
67
+
68
+ /** Map a clientInfo.name → variant. */
69
+ function variantForClient(clientName) {
70
+ const n = String(clientName || '').toLowerCase();
71
+ if (n.includes('claude-code') || n.includes('codex')) return 'cli';
72
+ return 'cloud'; // claude.ai web client and anything unknown
73
+ }
74
+
75
+ /**
76
+ * Render a skill body for the resolved variant.
77
+ * - cli → SKILL.md body unchanged.
78
+ * - cloud → SKILL.cloud.md verbatim if present, else toCloudBody(SKILL.md).
79
+ * Returns { header, body } or null if the skill has no body on disk.
80
+ */
81
+ function renderSkill(name, variant) {
82
+ const body = getSkillBody(name);
83
+ if (body == null) return null;
84
+ if (variant === 'cli') {
85
+ return { header: `<!-- rdc-skills: '${name}' served as CLI variant -->`, body };
86
+ }
87
+ const override = getCloudOverride(name);
88
+ if (override != null) {
89
+ return {
90
+ header: `<!-- rdc-skills: '${name}' served as CLOUD variant (hand-tuned SKILL.cloud.md) -->`,
91
+ body: override,
92
+ };
93
+ }
94
+ return {
95
+ header: `<!-- rdc-skills: '${name}' served as CLOUD variant (auto-rewritten from SKILL.md) -->`,
96
+ body: toCloudBody(body),
97
+ };
98
+ }
99
+
100
+ function textResult(text) {
101
+ return { content: [{ type: 'text', text }] };
102
+ }
103
+
104
+ function buildMcpServer() {
105
+ const srv = new McpServer({ name: 'rdc-skills', version: pkgVersion() });
106
+
107
+ // Capture clientInfo on initialize to refine the soft default variant.
108
+ // The SDK exposes the underlying low-level server; oninitialized fires after
109
+ // the client's initialize params are recorded.
110
+ try {
111
+ srv.server.oninitialized = () => {
112
+ const info = srv.server.getClientVersion?.();
113
+ if (info?.name) lastDetectedVariant = variantForClient(info.name);
114
+ };
115
+ } catch {
116
+ /* non-fatal: detection stays at the cloud default */
117
+ }
118
+
119
+ // ── rdc_skill_list ─────────────────────────────────────────────────────────
120
+ srv.registerTool(
121
+ 'rdc_skill_list',
122
+ {
123
+ description:
124
+ 'List the rdc-skills catalog: every skill with its slash form, category, summary, when-to-use triggers, usage, args, and required capabilities. No input required.',
125
+ inputSchema: {},
126
+ },
127
+ async () => {
128
+ const catalog = listSkills();
129
+ return textResult(JSON.stringify({ count: catalog.length, skills: catalog }, null, 2));
130
+ },
131
+ );
132
+
133
+ // ── rdc_skill_get ──────────────────────────────────────────────────────────
134
+ srv.registerTool(
135
+ 'rdc_skill_get',
136
+ {
137
+ description:
138
+ "Get a skill's SKILL.md body rendered for the caller. variant 'cli' returns the body unchanged; 'cloud' rewrites local-shell/clauth-daemon steps for the claude.ai web client. Omit variant to use auto-detection (defaults to cloud).",
139
+ inputSchema: {
140
+ name: z.string().describe('Skill name (e.g. "deploy", "build"). See rdc_skill_list.'),
141
+ variant: z.enum(['cli', 'cloud']).optional().describe('Force the rendered variant; overrides caller detection.'),
142
+ },
143
+ },
144
+ async ({ name, variant }) => {
145
+ const valid = skillNames();
146
+ if (!valid.includes(name)) {
147
+ return textResult(
148
+ `Unknown skill '${name}'. Valid skills (${valid.length}): ${valid.join(', ')}`,
149
+ );
150
+ }
151
+ const resolved = variant || lastDetectedVariant || 'cloud';
152
+ const rendered = renderSkill(name, resolved);
153
+ if (!rendered) {
154
+ return textResult(`Skill '${name}' exists in the catalog but has no SKILL.md body on disk.`);
155
+ }
156
+ return textResult(`${rendered.header}\n\n${rendered.body}`);
157
+ },
158
+ );
159
+
160
+ // ── rdc_skill_search ───────────────────────────────────────────────────────
161
+ srv.registerTool(
162
+ 'rdc_skill_search',
163
+ {
164
+ description:
165
+ 'Fuzzy/substring search the rdc-skills catalog over name, slash, summary, and trigger phrases. Returns ranked matches.',
166
+ inputSchema: {
167
+ query: z.string().describe('Search terms, e.g. "deploy coolify" or "work items".'),
168
+ },
169
+ },
170
+ async ({ query }) => {
171
+ const results = searchSkills(query);
172
+ return textResult(JSON.stringify({ query, count: results.length, results }, null, 2));
173
+ },
174
+ );
175
+
176
+ return srv;
177
+ }
178
+
179
+ function startHttp() {
180
+ const app = express();
181
+ app.use(express.json({ limit: '4mb' }));
182
+
183
+ // /health — open, cheap, reports live skill count.
184
+ app.get('/health', (_req, res) => {
185
+ let skills = 0;
186
+ try {
187
+ skills = listSkills().length;
188
+ } catch {
189
+ skills = 0;
190
+ }
191
+ res.json({ status: 'ok', service: 'rdc-skills-mcp', version: pkgVersion(), skills });
192
+ });
193
+
194
+ // Block OAuth discovery so connectors skip OAuth and connect direct — /mcp is open.
195
+ app.get('/.well-known/oauth-authorization-server', (_req, res) => res.status(404).end());
196
+ app.get('/.well-known/openid-configuration', (_req, res) => res.status(404).end());
197
+ app.get('/authorize', (_req, res) => res.status(404).end());
198
+ app.post('/token', (_req, res) => res.status(404).end());
199
+
200
+ // POST /mcp — stateless StreamableHTTP transport, no Authorization required.
201
+ app.post('/mcp', async (req, res) => {
202
+ try {
203
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
204
+ const srv = buildMcpServer();
205
+ await srv.connect(transport);
206
+ await transport.handleRequest(req, res, req.body);
207
+ res.on('close', async () => {
208
+ await transport.close().catch(() => {});
209
+ await srv.close().catch(() => {});
210
+ });
211
+ } catch (err) {
212
+ console.error('[rdc-skills-mcp] MCP error:', err);
213
+ if (!res.headersSent) {
214
+ res.status(500).json({
215
+ jsonrpc: '2.0',
216
+ error: { code: -32603, message: err?.message || 'internal error' },
217
+ id: null,
218
+ });
219
+ }
220
+ }
221
+ });
222
+
223
+ app.get('/mcp', (_req, res) => {
224
+ res.status(405).json({
225
+ jsonrpc: '2.0',
226
+ error: { code: -32000, message: 'Use POST for MCP requests' },
227
+ id: null,
228
+ });
229
+ });
230
+
231
+ app.listen(PORT, () => {
232
+ console.log(`[rdc-skills-mcp] ready on port ${PORT}`);
233
+ console.log(` MCP: POST http://localhost:${PORT}/mcp`);
234
+ console.log(` Health: GET http://localhost:${PORT}/health`);
235
+ });
236
+ }
237
+
238
+ startHttp();
@@ -0,0 +1,153 @@
1
+ ---
2
+ mdk_schema_version: "1.0"
3
+ doc_type: guide
4
+ system: claude-workflow
5
+ status: active
6
+ owner: infrastructure
7
+ created: 2026-06-08
8
+ last_reviewed: 2026-06-08
9
+ source_of_truth: true
10
+ supersedes: []
11
+ depends_on:
12
+ - ".claude/rules/architectural-change-approval.md"
13
+ - ".rdc/guides/output-contract.md"
14
+ tags: [rdc, lessons-learned, skills, housekeeping, adaptive]
15
+ ---
16
+
17
+ # Lessons-Learned Capture & Triage — Spec
18
+
19
+ > Auto-referenced by long-running `rdc:*` skills at exit, and by `rdc:housekeeping` for triage.
20
+ > Goal: make the fleet an **interactive adaptive modeler** — every run that teaches us
21
+ > something writes it down, and the weekly housekeeping pass turns those lessons into
22
+ > actual fixes (rules, skill docs, work_items).
23
+
24
+ ---
25
+
26
+ ## Why this exists
27
+
28
+ Lessons learned during a run (a non-obvious infra trap, a wrong assumption, a missing
29
+ gate, a tooling gotcha) used to survive only if someone hand-wrote a memory. This system
30
+ makes capture a **routine exit step** of every long skill, and triage a **routine phase**
31
+ of the weekly housekeeping. Capture is cheap and append-only; triage is where fixes happen.
32
+
33
+ Precedent: brochurify's `rdc-extract-verifier-rules` already does read-log → cluster →
34
+ propose-rule for one domain. This generalizes that pattern fleet-wide.
35
+
36
+ ---
37
+
38
+ ## Storage — directory of per-lesson files
39
+
40
+ Lessons live in **`.rdc/lessons/`**, one markdown file per lesson:
41
+
42
+ ```
43
+ .rdc/lessons/<YYYY-MM-DD>-<skill>-<short-slug>.md
44
+ ```
45
+
46
+ - One file per lesson (NOT a single appended file) so parallel agents finishing at the
47
+ same time never collide on one file in git.
48
+ - `<skill>` is the capturing skill (`build`, `deploy`, `overnight`, `fixit`, `plan`,
49
+ `preplan`, `review`, `release`, `collab`).
50
+ - `<short-slug>` is 2–4 kebab words naming the lesson.
51
+
52
+ A run that taught nothing writes nothing — **absence is the default**. Only write a lesson
53
+ when something was genuinely learned (see § When to capture).
54
+
55
+ ---
56
+
57
+ ## Lesson file schema
58
+
59
+ ```markdown
60
+ ---
61
+ id: <YYYY-MM-DD>-<skill>-<short-slug>
62
+ date: "<YYYY-MM-DD>"
63
+ skill: build | deploy | overnight | fixit | plan | preplan | review | release | collab
64
+ session: <session-id or short ref>
65
+ scope: simple | architectural # triage routing — see § Scope gate
66
+ status: open | triaged | applied | wont-fix
67
+ area: infra | skill | guide | rule | schema | ui | content | other
68
+ links:
69
+ commits: [] # SHAs that relate to the lesson
70
+ memory: [] # memory file slugs, if a memory was also written
71
+ work_items: [] # work_item UUIDs spawned during triage
72
+ ---
73
+
74
+ ## What happened
75
+ <one paragraph — the concrete situation, with evidence (exit code, file:line, command)>
76
+
77
+ ## Root cause
78
+ <one paragraph — the evidenced cause, not a guess>
79
+
80
+ ## The fix / rule
81
+ <what should change so this never recurs: a rule edit, skill-doc line, code change,
82
+ or a check. If already applied in the same run, say so and link the commit.>
83
+ ```
84
+
85
+ `scope` is the single most important field — it routes triage:
86
+
87
+ - **`simple`** — a doc line, a one-file fix, a config tweak, a clarifying sentence in a
88
+ skill, a missing grep guard. Housekeeping applies these directly.
89
+ - **`architectural`** — anything matching `.claude/rules/architectural-change-approval.md`
90
+ (rule/CLAUDE.md/ARCHITECTURE.md edits, cross-cutting refactors, schema reshape, public
91
+ API/MCP changes, skill-contract changes affecting multiple skills). Housekeeping does
92
+ NOT apply these; it surfaces them via `AskUserQuestion` for explicit approval first.
93
+
94
+ When unsure, mark `architectural`.
95
+
96
+ ---
97
+
98
+ ## When to capture (at skill exit)
99
+
100
+ Write a lesson when ANY of these were true during the run:
101
+
102
+ 1. A root cause turned out to be different from the first theory (a wrong assumption).
103
+ 2. The standard/documented path didn't work and you had to do something non-obvious.
104
+ 3. A gate, check, or doc was missing and its absence cost a round.
105
+ 4. A tool/infra behaved in a surprising way (exit codes, caching, serve/PM2/webhook quirks).
106
+ 5. A hook blocked you and the block revealed a real gap (not just your mistake).
107
+
108
+ Do NOT capture: routine success, your own one-off typo, anything already fully documented
109
+ in a rule/guide. If a durable user preference or correction was involved, also write a
110
+ `memory` (this spec and memory are complementary — link them).
111
+
112
+ ---
113
+
114
+ ## Capture procedure (the exit step long skills call)
115
+
116
+ At the end of a long skill run, before the final verdict line:
117
+
118
+ 1. Decide if anything qualifies (§ When to capture). If not, write nothing and move on.
119
+ 2. For each lesson, write `.rdc/lessons/<date>-<skill>-<slug>.md` using the schema above.
120
+ Set `status: open` (or `applied` if you already shipped the fix in this same run, with
121
+ the commit linked).
122
+ 3. Set `scope` honestly (`simple` vs `architectural`).
123
+ 4. Commit the lesson file(s) on `develop` alongside the run's other commits.
124
+ 5. Mention in the verdict/summary that N lessons were captured.
125
+
126
+ ---
127
+
128
+ ## Triage procedure (rdc:housekeeping, weekly)
129
+
130
+ `rdc:housekeeping` adds a **Lessons triage** phase:
131
+
132
+ 1. Read all `.rdc/lessons/*.md` with `status: open`.
133
+ 2. Cluster by `area` + root-cause similarity (dedupe repeats into one fix).
134
+ 3. For each cluster:
135
+ - `scope: simple` → apply the fix directly (rule line, skill-doc edit, config, guard),
136
+ commit it, set the lesson(s) `status: applied` and link the commit.
137
+ - `scope: architectural` → do NOT edit. Present the issue + options via
138
+ `AskUserQuestion` (per `architectural-change-approval.md`). On approval, apply via the
139
+ correct lifecycle (rdc-skills tag/push for skills; cited commit for rules) and set
140
+ `status: applied`. If deferred, set `status: triaged` and spawn a `work_item`.
141
+ - Not worth fixing → `status: wont-fix` with a one-line reason.
142
+ 4. Summarize in the housekeeping report: captured / applied / escalated / deferred counts.
143
+
144
+ Lessons are never silently deleted — `applied` and `wont-fix` files stay as the audit trail.
145
+
146
+ ---
147
+
148
+ ## Skills that capture (the long-running set)
149
+
150
+ `build` · `deploy` · `overnight` · `fixit` · `plan` · `preplan` · `review` · `release` · `collab`
151
+
152
+ Each references this spec from a final "§ Capture lessons" step. A Stop-hook backstop warns
153
+ when one of these skills ends a run with findings but no new `.rdc/lessons/` file.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * lib/catalog.mjs — rdc-skills catalog loader for the local MCP server.
3
+ *
4
+ * Source of truth: `.claude-plugin/plugin.json` → `skills_meta` (an object keyed
5
+ * by skill name). Each entry provides { name, slash, category, usage, args,
6
+ * requires, triggers, ... }. We enrich each entry with the `description` from
7
+ * the skill's own `skills/<name>/SKILL.md` frontmatter (the human summary).
8
+ *
9
+ * Loading is LIVE: the catalog is re-read from disk when older than CACHE_TTL_MS
10
+ * so a skill edit (frontmatter or body) is picked up without a server restart.
11
+ * A skill that has a `skills_meta` entry but no SKILL.md on disk is still listed
12
+ * (meta-only); a skill dir with a SKILL.md but no meta entry is also surfaced
13
+ * (frontmatter-only) so nothing silently disappears from the catalog.
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import YAML from 'yaml';
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const REPO_ROOT = path.resolve(__dirname, '..');
23
+ const PLUGIN_JSON = path.join(REPO_ROOT, '.claude-plugin', 'plugin.json');
24
+ const SKILLS_DIR = path.join(REPO_ROOT, 'skills');
25
+
26
+ const CACHE_TTL_MS = 5000; // short TTL: live-from-disk feel, cheap re-reads
27
+
28
+ let _cache = null;
29
+ let _cacheAt = 0;
30
+
31
+ /** Read + parse the YAML frontmatter block of a SKILL.md. Returns {} on miss. */
32
+ function readFrontmatter(skillMdPath) {
33
+ try {
34
+ const raw = fs.readFileSync(skillMdPath, 'utf8').replace(/\r\n/g, '\n');
35
+ const m = raw.match(/^---\n([\s\S]*?)\n---/);
36
+ if (!m) return {};
37
+ const parsed = YAML.parse(m[1]);
38
+ return parsed && typeof parsed === 'object' ? parsed : {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ /** Map a raw skills_meta entry + SKILL.md frontmatter into a compact catalog row. */
45
+ function toCatalogEntry(name, meta, frontmatter) {
46
+ const description = frontmatter.description || meta.description || '';
47
+ // `summary` is the short customer-facing line; `when_to_use` pulls the triggers.
48
+ const triggers = Array.isArray(meta.triggers) ? meta.triggers : [];
49
+ return {
50
+ name,
51
+ slash: meta.slash || `rdc:${name}`,
52
+ category: meta.category || 'tooling',
53
+ summary: description,
54
+ when_to_use: triggers,
55
+ usage: meta.usage || '',
56
+ args: meta.args || { positional: [], flags: [] },
57
+ requires: Array.isArray(meta.requires) ? meta.requires : [],
58
+ };
59
+ }
60
+
61
+ /** Re-read plugin.json + skill frontmatter from disk and rebuild the catalog. */
62
+ function buildCatalog() {
63
+ let plugin = {};
64
+ try {
65
+ plugin = JSON.parse(fs.readFileSync(PLUGIN_JSON, 'utf8'));
66
+ } catch {
67
+ plugin = {};
68
+ }
69
+ const skillsMeta = plugin.skills_meta && typeof plugin.skills_meta === 'object' ? plugin.skills_meta : {};
70
+
71
+ const byName = new Map();
72
+
73
+ // 1. Every skills_meta entry → enrich with its SKILL.md frontmatter.
74
+ for (const [name, meta] of Object.entries(skillsMeta)) {
75
+ const skillMd = path.join(SKILLS_DIR, name, 'SKILL.md');
76
+ const fm = fs.existsSync(skillMd) ? readFrontmatter(skillMd) : {};
77
+ byName.set(name, toCatalogEntry(name, meta || {}, fm));
78
+ }
79
+
80
+ // 2. Any skill dir with a SKILL.md but NO meta entry → frontmatter-only row.
81
+ if (fs.existsSync(SKILLS_DIR)) {
82
+ for (const entry of fs.readdirSync(SKILLS_DIR, { withFileTypes: true })) {
83
+ if (!entry.isDirectory() || byName.has(entry.name)) continue;
84
+ const skillMd = path.join(SKILLS_DIR, entry.name, 'SKILL.md');
85
+ if (!fs.existsSync(skillMd)) continue;
86
+ const fm = readFrontmatter(skillMd);
87
+ byName.set(entry.name, toCatalogEntry(entry.name, {}, fm));
88
+ }
89
+ }
90
+
91
+ return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
92
+ }
93
+
94
+ /** Return the cached catalog, rebuilding if the TTL has elapsed. */
95
+ function getCatalog() {
96
+ const now = Date.now();
97
+ if (!_cache || now - _cacheAt > CACHE_TTL_MS) {
98
+ _cache = buildCatalog();
99
+ _cacheAt = now;
100
+ }
101
+ return _cache;
102
+ }
103
+
104
+ /** Compact catalog for `rdc_skill_list`. */
105
+ export function listSkills() {
106
+ return getCatalog();
107
+ }
108
+
109
+ /** Full catalog row for one skill, or null if unknown. */
110
+ export function getSkill(name) {
111
+ return getCatalog().find((s) => s.name === name) || null;
112
+ }
113
+
114
+ /** List of valid skill names (for error messages). */
115
+ export function skillNames() {
116
+ return getCatalog().map((s) => s.name);
117
+ }
118
+
119
+ /**
120
+ * Read the raw SKILL.md body (frontmatter stripped) for a skill, live from disk.
121
+ * Returns null if the file does not exist.
122
+ */
123
+ export function getSkillBody(name) {
124
+ const skillMd = path.join(SKILLS_DIR, name, 'SKILL.md');
125
+ if (!fs.existsSync(skillMd)) return null;
126
+ const raw = fs.readFileSync(skillMd, 'utf8').replace(/\r\n/g, '\n');
127
+ // Strip the leading frontmatter block if present.
128
+ const m = raw.match(/^---\n[\s\S]*?\n---\n?/);
129
+ return m ? raw.slice(m[0].length).replace(/^\n+/, '') : raw;
130
+ }
131
+
132
+ /** Absolute path to a hand-tuned cloud override, or null if absent. */
133
+ export function cloudOverridePath(name) {
134
+ const p = path.join(SKILLS_DIR, name, 'SKILL.cloud.md');
135
+ return fs.existsSync(p) ? p : null;
136
+ }
137
+
138
+ /** Read the hand-tuned cloud override body (frontmatter stripped), or null. */
139
+ export function getCloudOverride(name) {
140
+ const p = cloudOverridePath(name);
141
+ if (!p) return null;
142
+ const raw = fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
143
+ const m = raw.match(/^---\n[\s\S]*?\n---\n?/);
144
+ return m ? raw.slice(m[0].length).replace(/^\n+/, '') : raw;
145
+ }
146
+
147
+ /**
148
+ * Fuzzy/substring search over name + slash + summary + triggers.
149
+ * Returns ranked [{ name, slash, summary, score }] (score: higher = better).
150
+ */
151
+ export function searchSkills(query) {
152
+ const q = String(query || '').trim().toLowerCase();
153
+ if (!q) return [];
154
+ const terms = q.split(/\s+/).filter(Boolean);
155
+ const results = [];
156
+ for (const s of getCatalog()) {
157
+ const hay = {
158
+ name: s.name.toLowerCase(),
159
+ slash: (s.slash || '').toLowerCase(),
160
+ summary: (s.summary || '').toLowerCase(),
161
+ triggers: (s.when_to_use || []).join(' ').toLowerCase(),
162
+ };
163
+ let score = 0;
164
+ for (const term of terms) {
165
+ if (hay.name === term) score += 100;
166
+ else if (hay.name.includes(term)) score += 40;
167
+ if (hay.slash.includes(term)) score += 20;
168
+ if (hay.triggers.includes(term)) score += 12;
169
+ if (hay.summary.includes(term)) score += 6;
170
+ }
171
+ if (score > 0) results.push({ name: s.name, slash: s.slash, summary: s.summary, score });
172
+ }
173
+ return results.sort((a, b) => b.score - a.score);
174
+ }