@fruition/fcp-mcp-server 1.21.0 → 1.22.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/dist/index.d.ts +10 -0
- package/dist/index.js +4399 -0
- package/dist/skills-sync-cli.d.ts +23 -0
- package/dist/skills-sync-cli.js +121 -0
- package/dist/skills-sync.d.ts +144 -0
- package/dist/skills-sync.js +712 -0
- package/package.json +1 -1
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills Sync — bidirectional sync between local ~/.claude/skills/<slug>/SKILL.md
|
|
3
|
+
* files and the Fruition team's shared knowledge graph hosted in Unroo.
|
|
4
|
+
*
|
|
5
|
+
* Distribution model: every developer's workstation runs `@fruition/fcp-mcp-server`
|
|
6
|
+
* via `npx` on each `claude` startup. This module hooks into that startup to
|
|
7
|
+
* converge skills across the team — push local additions/edits up, pull team
|
|
8
|
+
* additions down — without ever clobbering local edits.
|
|
9
|
+
*
|
|
10
|
+
* Endpoints (all gated by X-API-Key + X-FCP-User-Email Fruition-org check):
|
|
11
|
+
* GET https://app.unroo.io/api/external/fcp/knowledge/search?node_type=skill
|
|
12
|
+
* POST https://app.unroo.io/api/external/fcp/knowledge/capture-skill
|
|
13
|
+
*
|
|
14
|
+
* State file: ~/.claude/skills/.sync-state.json — tracks last-pushed mtime and
|
|
15
|
+
* last-pulled body hash per slug so we can detect three-way conflicts.
|
|
16
|
+
*
|
|
17
|
+
* Safety:
|
|
18
|
+
* - First invocation on a fresh workstation is dry-run; user must opt in once.
|
|
19
|
+
* - Bodies containing apparent secrets are refused (loud warning, no upload).
|
|
20
|
+
* - Skills under directories starting with a digit, dot, or underscore are
|
|
21
|
+
* scratch/private and never pushed.
|
|
22
|
+
* - `private: true` in frontmatter also opts out.
|
|
23
|
+
* - Network failures and missing auth never block startup — we log + continue.
|
|
24
|
+
*/
|
|
25
|
+
import { promises as fs, existsSync, readFileSync, writeFileSync, mkdirSync, } from 'fs';
|
|
26
|
+
import { homedir } from 'os';
|
|
27
|
+
import { join, basename } from 'path';
|
|
28
|
+
import { createHash } from 'crypto';
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Constants
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const STATE_VERSION = 1;
|
|
33
|
+
const DEFAULT_UNROO_URL = 'https://app.unroo.io';
|
|
34
|
+
const DEFAULT_FCP_URL = 'https://fcp.fru.io';
|
|
35
|
+
// Direct mode: skills-sync talks straight to Unroo's knowledge-graph endpoints
|
|
36
|
+
// with a personal UNROO_API_KEY.
|
|
37
|
+
const SEARCH_PATH = '/api/external/fcp/knowledge/search';
|
|
38
|
+
const CAPTURE_PATH = '/api/external/fcp/knowledge/capture-skill';
|
|
39
|
+
// Proxy mode: skills-sync talks to FCP, which forwards to the same Unroo
|
|
40
|
+
// endpoints using FCP's own service key. This lets a single FCP API key (the
|
|
41
|
+
// one from `claude mcp add`) cover skill sync — no personal Unroo key to
|
|
42
|
+
// obtain, export, or rotate. Mirrors USE_FCP_UNROO_PROXY in the MCP server.
|
|
43
|
+
// See app/api/mcp/unroo/[...path]/route.ts.
|
|
44
|
+
const PROXY_SEARCH_PATH = '/api/mcp/unroo/knowledge/search';
|
|
45
|
+
const PROXY_CAPTURE_PATH = '/api/mcp/unroo/knowledge/capture-skill';
|
|
46
|
+
// Refuse to upload bodies containing strings that look like a baked-in secret.
|
|
47
|
+
// We capture the leading "label" and the candidate "value" separately so we
|
|
48
|
+
// can reject obvious placeholder shapes (env-var names, template variables,
|
|
49
|
+
// angle-bracket / curly-brace placeholders) instead of false-positiving on them.
|
|
50
|
+
const SECRET_PATTERN = /(api[_-]?key|token|secret|password|client[_-]?secret)\s*[:=]\s*['"]?([\w\-+/]{16,})/i;
|
|
51
|
+
// Shapes that are obviously placeholders — never real secrets.
|
|
52
|
+
// We discriminate primarily by underscore presence: real high-entropy keys
|
|
53
|
+
// (AWS access keys "AKIA…", GitHub PATs "ghp_…" once stripped of the prefix,
|
|
54
|
+
// Stripe keys "sk_live_…" — wait, those *do* have underscores in the prefix —
|
|
55
|
+
// see exceptions below) typically don't have *only* uppercase letters with
|
|
56
|
+
// underscores. Env-var placeholder names always do.
|
|
57
|
+
// - M2M_CLIENT_SECRET → placeholder
|
|
58
|
+
// - JWT_SECRET → placeholder
|
|
59
|
+
// - YOUR_API_KEY → placeholder
|
|
60
|
+
// - AKIAIOSFODNN7EXAMPLE → real-shape (no underscores) — flag
|
|
61
|
+
// - aB3xY9zQ7mN2pK4LvR8t → real-shape (mixed case) — flag
|
|
62
|
+
function looksLikePlaceholder(value) {
|
|
63
|
+
// Must contain at least one underscore to be considered a placeholder.
|
|
64
|
+
if (!value.includes('_'))
|
|
65
|
+
return false;
|
|
66
|
+
// All uppercase + digits + underscores → env-var name shape.
|
|
67
|
+
if (/^[A-Z][A-Z0-9_]+$/.test(value))
|
|
68
|
+
return true;
|
|
69
|
+
// Allow lowercase too if the SHAPE is still env-var-like (snake_case_var).
|
|
70
|
+
// But require it to look like a name, not a real key. Real keys are usually
|
|
71
|
+
// long contiguous runs without underscores between every 3-5 characters.
|
|
72
|
+
if (/^[A-Za-z][A-Za-z0-9_]+$/.test(value) && /_[A-Za-z]/.test(value)) {
|
|
73
|
+
// Average segment length between underscores. Real secrets that happen to
|
|
74
|
+
// contain underscores (rare) will have one segment >= 16 chars.
|
|
75
|
+
const segments = value.split('_');
|
|
76
|
+
const longest = Math.max(...segments.map((s) => s.length));
|
|
77
|
+
if (longest < 16)
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
// Skills under dirs whose name starts with one of these are treated as scratch
|
|
83
|
+
// or user-private and never synced.
|
|
84
|
+
const SCRATCH_PREFIXES = ['_', '.'];
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// State helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
export function defaultSkillsDir() {
|
|
89
|
+
return join(homedir(), '.claude', 'skills');
|
|
90
|
+
}
|
|
91
|
+
export function defaultStateFile(skillsDir) {
|
|
92
|
+
return join(skillsDir, '.sync-state.json');
|
|
93
|
+
}
|
|
94
|
+
export function loadState(stateFile) {
|
|
95
|
+
try {
|
|
96
|
+
if (!existsSync(stateFile)) {
|
|
97
|
+
return { version: STATE_VERSION, skills: {} };
|
|
98
|
+
}
|
|
99
|
+
const raw = readFileSync(stateFile, 'utf-8');
|
|
100
|
+
const parsed = JSON.parse(raw);
|
|
101
|
+
return {
|
|
102
|
+
version: typeof parsed.version === 'number' ? parsed.version : STATE_VERSION,
|
|
103
|
+
optedIn: parsed.optedIn === true,
|
|
104
|
+
skills: parsed.skills && typeof parsed.skills === 'object' ? parsed.skills : {},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Corrupt state file shouldn't break startup; reset.
|
|
109
|
+
return { version: STATE_VERSION, skills: {} };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function saveState(stateFile, state) {
|
|
113
|
+
const dir = stateFile.replace(/\/[^/]+$/, '');
|
|
114
|
+
if (dir && !existsSync(dir)) {
|
|
115
|
+
mkdirSync(dir, { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8');
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Frontmatter parsing — minimal YAML (no external deps)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
/**
|
|
123
|
+
* Parse the YAML-ish frontmatter block from a SKILL.md.
|
|
124
|
+
*
|
|
125
|
+
* We support exactly the shape used in ~/.claude/skills/*\/SKILL.md today:
|
|
126
|
+
* - `key: value` scalars (string, boolean)
|
|
127
|
+
* - `key: |` multi-line block scalars (folded into one space-joined string)
|
|
128
|
+
* - `key:` followed by ` - item` list entries (parsed as string[])
|
|
129
|
+
* - `triggers: a, b, c` inline comma list (also parsed as string[])
|
|
130
|
+
*
|
|
131
|
+
* Anything fancier (anchors, nested maps, JSON-style flow) is out of scope —
|
|
132
|
+
* if a skill author needs that we'd pull in `js-yaml`, but every existing
|
|
133
|
+
* SKILL.md in the team library fits this subset.
|
|
134
|
+
*/
|
|
135
|
+
export function parseFrontmatter(source) {
|
|
136
|
+
if (!source.startsWith('---')) {
|
|
137
|
+
return { frontmatter: {}, body: source };
|
|
138
|
+
}
|
|
139
|
+
const end = source.indexOf('\n---', 3);
|
|
140
|
+
if (end === -1) {
|
|
141
|
+
return { frontmatter: {}, body: source };
|
|
142
|
+
}
|
|
143
|
+
const block = source.slice(3, end).replace(/^\r?\n/, '');
|
|
144
|
+
const after = source.slice(end + 4).replace(/^\r?\n/, '');
|
|
145
|
+
const lines = block.split(/\r?\n/);
|
|
146
|
+
const fm = {};
|
|
147
|
+
let i = 0;
|
|
148
|
+
while (i < lines.length) {
|
|
149
|
+
const line = lines[i];
|
|
150
|
+
if (!line.trim() || line.trim().startsWith('#')) {
|
|
151
|
+
i++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
|
155
|
+
if (!m) {
|
|
156
|
+
i++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const key = m[1];
|
|
160
|
+
const rest = m[2];
|
|
161
|
+
// Block scalar: `key: |` -> read indented continuation lines
|
|
162
|
+
if (rest === '|' || rest === '>') {
|
|
163
|
+
i++;
|
|
164
|
+
const collected = [];
|
|
165
|
+
while (i < lines.length && /^\s+/.test(lines[i])) {
|
|
166
|
+
collected.push(lines[i].replace(/^\s+/, ''));
|
|
167
|
+
i++;
|
|
168
|
+
}
|
|
169
|
+
fm[key] = collected.join(' ').trim();
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// List form: `key:` then ` - item`
|
|
173
|
+
if (rest === '') {
|
|
174
|
+
i++;
|
|
175
|
+
const items = [];
|
|
176
|
+
while (i < lines.length && /^\s+-\s+/.test(lines[i])) {
|
|
177
|
+
items.push(lines[i].replace(/^\s+-\s+/, '').trim());
|
|
178
|
+
i++;
|
|
179
|
+
}
|
|
180
|
+
fm[key] = items;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
// Scalar
|
|
184
|
+
let value = rest.trim();
|
|
185
|
+
if (typeof value === 'string') {
|
|
186
|
+
// Strip wrapping quotes
|
|
187
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
188
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
189
|
+
value = value.slice(1, -1);
|
|
190
|
+
}
|
|
191
|
+
// Boolean coercion for `private`
|
|
192
|
+
if (value === 'true')
|
|
193
|
+
value = true;
|
|
194
|
+
else if (value === 'false')
|
|
195
|
+
value = false;
|
|
196
|
+
// Inline comma list for `triggers`
|
|
197
|
+
else if (key === 'triggers' && value.includes(',')) {
|
|
198
|
+
value = value
|
|
199
|
+
.split(',')
|
|
200
|
+
.map((t) => t.trim())
|
|
201
|
+
.filter(Boolean);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
fm[key] = value;
|
|
205
|
+
i++;
|
|
206
|
+
}
|
|
207
|
+
// Normalize: triggers should always be string[] if provided
|
|
208
|
+
if (typeof fm.triggers === 'string') {
|
|
209
|
+
fm.triggers = fm.triggers
|
|
210
|
+
.split(',')
|
|
211
|
+
.map((t) => t.trim())
|
|
212
|
+
.filter(Boolean);
|
|
213
|
+
}
|
|
214
|
+
return { frontmatter: fm, body: after };
|
|
215
|
+
}
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Skill discovery
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
export async function discoverLocalSkills(skillsDir) {
|
|
220
|
+
if (!existsSync(skillsDir))
|
|
221
|
+
return [];
|
|
222
|
+
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
|
223
|
+
const skills = [];
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (!entry.isDirectory())
|
|
226
|
+
continue;
|
|
227
|
+
const slug = entry.name;
|
|
228
|
+
const skillFile = join(skillsDir, slug, 'SKILL.md');
|
|
229
|
+
if (!existsSync(skillFile))
|
|
230
|
+
continue;
|
|
231
|
+
try {
|
|
232
|
+
const stat = await fs.stat(skillFile);
|
|
233
|
+
const source = await fs.readFile(skillFile, 'utf-8');
|
|
234
|
+
const { frontmatter } = parseFrontmatter(source);
|
|
235
|
+
skills.push({
|
|
236
|
+
slug,
|
|
237
|
+
filePath: skillFile,
|
|
238
|
+
frontmatter,
|
|
239
|
+
body: source,
|
|
240
|
+
mtimeMs: stat.mtimeMs,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Unreadable SKILL.md — skip silently. We don't want a single bad file
|
|
245
|
+
// to break startup for the whole team.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return skills;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Decide whether a skill should be pushed.
|
|
252
|
+
* Returns null if pushable, or a reason string if not.
|
|
253
|
+
*/
|
|
254
|
+
export function pushSkipReason(skill) {
|
|
255
|
+
// Slug starts with digit -> user-private experiment
|
|
256
|
+
if (/^\d/.test(skill.slug))
|
|
257
|
+
return 'slug starts with digit (private experiment)';
|
|
258
|
+
// Slug starts with _ or . -> scratch
|
|
259
|
+
if (SCRATCH_PREFIXES.some((p) => skill.slug.startsWith(p))) {
|
|
260
|
+
return 'slug starts with scratch prefix';
|
|
261
|
+
}
|
|
262
|
+
// Frontmatter `private: true`
|
|
263
|
+
if (skill.frontmatter.private === true)
|
|
264
|
+
return 'frontmatter private:true';
|
|
265
|
+
// Skill has no name/description — not enough metadata to share usefully
|
|
266
|
+
if (!skill.frontmatter.name || !skill.frontmatter.description) {
|
|
267
|
+
return 'missing name or description in frontmatter';
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Detect control characters (other than tab, LF, CR) in the body. The Unroo
|
|
273
|
+
* capture-skill endpoint 500s on null bytes — and they're never legitimate
|
|
274
|
+
* skill content anyway. Returns a short description of the first hit, or
|
|
275
|
+
* null if clean.
|
|
276
|
+
*/
|
|
277
|
+
export function detectControlChars(body) {
|
|
278
|
+
// Allow \t (0x09), \n (0x0A), \r (0x0D); reject everything else < 0x20 and 0x7F.
|
|
279
|
+
const re = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
|
|
280
|
+
const m = body.match(re);
|
|
281
|
+
if (!m)
|
|
282
|
+
return null;
|
|
283
|
+
const code = m[0].charCodeAt(0);
|
|
284
|
+
const hex = code.toString(16).padStart(2, '0');
|
|
285
|
+
const offset = m.index ?? 0;
|
|
286
|
+
return `control char 0x${hex} at offset ${offset}`;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Detect apparent secrets in a skill body. Returns the matched substring
|
|
290
|
+
* (truncated) for logging, or null if clean.
|
|
291
|
+
*/
|
|
292
|
+
export function detectSecret(body) {
|
|
293
|
+
// Use a /g regex so we can iterate; the first non-placeholder match wins.
|
|
294
|
+
const re = new RegExp(SECRET_PATTERN.source, 'gi');
|
|
295
|
+
let m;
|
|
296
|
+
while ((m = re.exec(body)) !== null) {
|
|
297
|
+
const value = m[2];
|
|
298
|
+
if (looksLikePlaceholder(value))
|
|
299
|
+
continue;
|
|
300
|
+
return m[0].slice(0, 60) + (m[0].length > 60 ? '…' : '');
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
export function hashBody(body) {
|
|
305
|
+
return createHash('sha256').update(body, 'utf-8').digest('hex');
|
|
306
|
+
}
|
|
307
|
+
async function postCapture(ctx, payload) {
|
|
308
|
+
const res = await ctx.fetchImpl(ctx.captureUrl, {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
headers: {
|
|
311
|
+
'Content-Type': 'application/json',
|
|
312
|
+
Accept: 'application/json',
|
|
313
|
+
...ctx.authHeaders,
|
|
314
|
+
},
|
|
315
|
+
body: JSON.stringify(payload),
|
|
316
|
+
});
|
|
317
|
+
const body = await res.text();
|
|
318
|
+
return { ok: res.ok, status: res.status, body };
|
|
319
|
+
}
|
|
320
|
+
async function getSearch(ctx) {
|
|
321
|
+
const all = [];
|
|
322
|
+
let cursor = null;
|
|
323
|
+
// Defensive cap: 50 pages × 100 = 5000 skills, far more than the team will
|
|
324
|
+
// ever have. Prevents a runaway loop if the server forgets to clear nextCursor.
|
|
325
|
+
for (let page = 0; page < 50; page++) {
|
|
326
|
+
const qs = new URLSearchParams({ node_type: 'skill', limit: '100' });
|
|
327
|
+
if (cursor)
|
|
328
|
+
qs.set('cursor', cursor);
|
|
329
|
+
const res = await ctx.fetchImpl(`${ctx.searchUrl}?${qs.toString()}`, {
|
|
330
|
+
headers: {
|
|
331
|
+
Accept: 'application/json',
|
|
332
|
+
...ctx.authHeaders,
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
if (!res.ok) {
|
|
336
|
+
throw new Error(`Unroo search HTTP ${res.status}`);
|
|
337
|
+
}
|
|
338
|
+
const data = (await res.json());
|
|
339
|
+
if (data.results)
|
|
340
|
+
all.push(...data.results);
|
|
341
|
+
if (!data.nextCursor)
|
|
342
|
+
return all;
|
|
343
|
+
cursor = data.nextCursor;
|
|
344
|
+
}
|
|
345
|
+
return all;
|
|
346
|
+
}
|
|
347
|
+
async function pushOnce(ctx, state, result) {
|
|
348
|
+
const skills = await discoverLocalSkills(ctx.skillsDir);
|
|
349
|
+
for (const skill of skills) {
|
|
350
|
+
const skipReason = pushSkipReason(skill);
|
|
351
|
+
if (skipReason) {
|
|
352
|
+
result.skipped.push({ slug: skill.slug, why: skipReason });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// mtime-based short-circuit (cheap; covers the common no-change case)
|
|
356
|
+
const prev = state.skills[skill.slug] ?? {};
|
|
357
|
+
if (prev.lastPushedMtimeMs && prev.lastPushedMtimeMs >= skill.mtimeMs) {
|
|
358
|
+
result.skipped.push({ slug: skill.slug, why: 'unchanged since last push' });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
// Control-char guard — null bytes etc. trip the Unroo capture endpoint
|
|
362
|
+
// and are never legitimate skill content. Refuse before we even try.
|
|
363
|
+
const ctrl = detectControlChars(skill.body);
|
|
364
|
+
if (ctrl) {
|
|
365
|
+
ctx.log(`[skills-sync] REFUSED to push ${skill.slug}: body contains ${ctrl}. ` +
|
|
366
|
+
`Strip control characters from SKILL.md, then retry.`);
|
|
367
|
+
result.refused.push({ slug: skill.slug, why: `control char: ${ctrl}` });
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
// Secret guard
|
|
371
|
+
const secret = detectSecret(skill.body);
|
|
372
|
+
if (secret) {
|
|
373
|
+
ctx.log(`[skills-sync] REFUSED to push ${skill.slug}: body contains apparent secret (${secret}). ` +
|
|
374
|
+
`Edit SKILL.md to redact, then retry.`);
|
|
375
|
+
result.refused.push({ slug: skill.slug, why: `secret detected: ${secret}` });
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const payload = {
|
|
379
|
+
skillSlug: skill.slug,
|
|
380
|
+
name: String(skill.frontmatter.name ?? skill.slug),
|
|
381
|
+
description: String(skill.frontmatter.description ?? ''),
|
|
382
|
+
triggers: Array.isArray(skill.frontmatter.triggers)
|
|
383
|
+
? skill.frontmatter.triggers
|
|
384
|
+
: undefined,
|
|
385
|
+
bodyMarkdown: skill.body,
|
|
386
|
+
sourceRepo: 'claude-skills-local',
|
|
387
|
+
};
|
|
388
|
+
if (ctx.dryRun) {
|
|
389
|
+
ctx.log(`[skills-sync] DRY RUN: would push ${skill.slug}`);
|
|
390
|
+
result.pushed.push(skill.slug);
|
|
391
|
+
// Still update state under dry run? No — we want a real push to update.
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
const res = await postCapture(ctx, payload);
|
|
396
|
+
if (!res.ok) {
|
|
397
|
+
ctx.log(`[skills-sync] push ${skill.slug} failed (HTTP ${res.status}): ${res.body.slice(0, 200)}`);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
result.pushed.push(skill.slug);
|
|
401
|
+
state.skills[skill.slug] = {
|
|
402
|
+
...prev,
|
|
403
|
+
lastPushedMtimeMs: skill.mtimeMs,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
ctx.log(`[skills-sync] push ${skill.slug} threw: ${err.message}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async function pullOnce(ctx, state, result) {
|
|
412
|
+
let remoteSkills;
|
|
413
|
+
try {
|
|
414
|
+
remoteSkills = await getSearch(ctx);
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
ctx.log(`[skills-sync] pull failed: ${err.message}`);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
for (const node of remoteSkills) {
|
|
421
|
+
const slug = node.source_id || node.name;
|
|
422
|
+
if (!slug)
|
|
423
|
+
continue;
|
|
424
|
+
const props = node.properties ?? {};
|
|
425
|
+
const bodyMarkdown = typeof props.bodyMarkdown === 'string' ? props.bodyMarkdown : '';
|
|
426
|
+
if (!bodyMarkdown) {
|
|
427
|
+
result.skipped.push({ slug, why: 'remote has no bodyMarkdown' });
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const localPath = join(ctx.skillsDir, slug, 'SKILL.md');
|
|
431
|
+
const existed = existsSync(localPath);
|
|
432
|
+
const prev = state.skills[slug] ?? {};
|
|
433
|
+
// Local doesn't exist — straight pull (clean install or new team skill)
|
|
434
|
+
if (!existed) {
|
|
435
|
+
if (ctx.dryRun) {
|
|
436
|
+
ctx.log(`[skills-sync] DRY RUN: would create ${slug}`);
|
|
437
|
+
result.pulled.push(slug);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const dir = join(ctx.skillsDir, slug);
|
|
441
|
+
if (!existsSync(dir))
|
|
442
|
+
mkdirSync(dir, { recursive: true });
|
|
443
|
+
writeFileSync(localPath, bodyMarkdown, 'utf-8');
|
|
444
|
+
state.skills[slug] = {
|
|
445
|
+
...prev,
|
|
446
|
+
lastPulledBodyHash: hashBody(bodyMarkdown),
|
|
447
|
+
lastPulledRemoteUpdatedAt: node.updated_at,
|
|
448
|
+
};
|
|
449
|
+
result.pulled.push(slug);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
// Local exists — only overwrite if remote is strictly newer AND user
|
|
453
|
+
// hasn't edited locally since the last pull. This is the three-way
|
|
454
|
+
// merge: lastPulledBodyHash + current local hash + remote.
|
|
455
|
+
const localBody = readFileSync(localPath, 'utf-8');
|
|
456
|
+
const localHash = hashBody(localBody);
|
|
457
|
+
const remoteUpdated = node.updated_at;
|
|
458
|
+
const remoteIsNewer = !prev.lastPulledRemoteUpdatedAt ||
|
|
459
|
+
Date.parse(remoteUpdated) > Date.parse(prev.lastPulledRemoteUpdatedAt);
|
|
460
|
+
if (!remoteIsNewer) {
|
|
461
|
+
result.skipped.push({ slug, why: 'remote not newer' });
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const localUntouched = prev.lastPulledBodyHash !== undefined && prev.lastPulledBodyHash === localHash;
|
|
465
|
+
if (!localUntouched) {
|
|
466
|
+
ctx.log(`[skills-sync] skipping ${slug}: local edits since last pull (conflict). ` +
|
|
467
|
+
`Remote updated_at=${remoteUpdated}; resolve by either pushing your edits or ` +
|
|
468
|
+
`deleting local SKILL.md to accept remote.`);
|
|
469
|
+
result.conflicts.push({ slug, why: 'local edits since last pull' });
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (ctx.dryRun) {
|
|
473
|
+
ctx.log(`[skills-sync] DRY RUN: would update ${slug} from remote`);
|
|
474
|
+
result.pulled.push(slug);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
writeFileSync(localPath, bodyMarkdown, 'utf-8');
|
|
478
|
+
state.skills[slug] = {
|
|
479
|
+
...prev,
|
|
480
|
+
lastPulledBodyHash: hashBody(bodyMarkdown),
|
|
481
|
+
lastPulledRemoteUpdatedAt: remoteUpdated,
|
|
482
|
+
};
|
|
483
|
+
result.pulled.push(slug);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Decide how skills-sync reaches the knowledge graph.
|
|
488
|
+
*
|
|
489
|
+
* Direct mode: a personal UNROO_API_KEY talks straight to Unroo. Used when the
|
|
490
|
+
* caller explicitly supplies an Unroo key (legacy / power-user setup).
|
|
491
|
+
*
|
|
492
|
+
* Proxy mode: the FCP API key talks to FCP, which forwards to the same Unroo
|
|
493
|
+
* endpoints with its own service key. This is the default — it means a
|
|
494
|
+
* developer only needs the one FCP key from `claude mcp add`, with no second
|
|
495
|
+
* key to obtain, export, or rotate. Mirrors USE_FCP_UNROO_PROXY in index.ts.
|
|
496
|
+
*/
|
|
497
|
+
function resolveTransport(opts) {
|
|
498
|
+
const email = opts.userEmail ?? '';
|
|
499
|
+
const unrooKey = opts.unrooApiKey ?? '';
|
|
500
|
+
const fcpToken = opts.fcpApiToken ?? '';
|
|
501
|
+
// Direct mode wins when a personal Unroo key is explicitly provided.
|
|
502
|
+
if (unrooKey) {
|
|
503
|
+
if (!email) {
|
|
504
|
+
return {
|
|
505
|
+
configured: false,
|
|
506
|
+
reason: 'UNROO_API_KEY is set but FCP_USER_EMAIL is not — cannot attribute sync',
|
|
507
|
+
mode: 'direct', searchUrl: '', captureUrl: '', authHeaders: {},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const base = opts.unrooApiUrl ?? DEFAULT_UNROO_URL;
|
|
511
|
+
return {
|
|
512
|
+
configured: true,
|
|
513
|
+
mode: 'direct',
|
|
514
|
+
searchUrl: `${base}${SEARCH_PATH}`,
|
|
515
|
+
captureUrl: `${base}${CAPTURE_PATH}`,
|
|
516
|
+
authHeaders: { 'X-API-Key': unrooKey, 'X-FCP-User-Email': email },
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
// Proxy mode: the FCP key carries the sync.
|
|
520
|
+
if (fcpToken) {
|
|
521
|
+
if (fcpToken === 'dev_bypass') {
|
|
522
|
+
return {
|
|
523
|
+
configured: false,
|
|
524
|
+
reason: 'FCP_API_TOKEN=dev_bypass (local dev) — skill sync skipped',
|
|
525
|
+
mode: 'proxy', searchUrl: '', captureUrl: '', authHeaders: {},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const base = opts.fcpApiUrl ?? DEFAULT_FCP_URL;
|
|
529
|
+
// The FCP proxy reads X-Acting-User-Email for attribution; it honors the
|
|
530
|
+
// override only when it matches the key owner (or an Auth0 session),
|
|
531
|
+
// otherwise it falls back to the key owner — itself a real Fruition user,
|
|
532
|
+
// so the knowledge graph's Fruition-org gate passes either way.
|
|
533
|
+
const authHeaders = { 'X-API-Key': fcpToken };
|
|
534
|
+
if (email)
|
|
535
|
+
authHeaders['X-Acting-User-Email'] = email;
|
|
536
|
+
return {
|
|
537
|
+
configured: true,
|
|
538
|
+
mode: 'proxy',
|
|
539
|
+
searchUrl: `${base}${PROXY_SEARCH_PATH}`,
|
|
540
|
+
captureUrl: `${base}${PROXY_CAPTURE_PATH}`,
|
|
541
|
+
authHeaders,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
configured: false,
|
|
546
|
+
reason: 'neither UNROO_API_KEY nor FCP_API_TOKEN is set — cannot reach the knowledge graph',
|
|
547
|
+
mode: 'proxy', searchUrl: '', captureUrl: '', authHeaders: {},
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function makeCtx(opts, dryRunOverride, transport) {
|
|
551
|
+
const skillsDir = opts.skillsDir ?? defaultSkillsDir();
|
|
552
|
+
const stateFile = opts.stateFile ?? defaultStateFile(skillsDir);
|
|
553
|
+
return {
|
|
554
|
+
skillsDir,
|
|
555
|
+
stateFile,
|
|
556
|
+
mode: transport.mode,
|
|
557
|
+
searchUrl: transport.searchUrl,
|
|
558
|
+
captureUrl: transport.captureUrl,
|
|
559
|
+
authHeaders: transport.authHeaders,
|
|
560
|
+
fetchImpl: opts.fetchImpl ?? fetch,
|
|
561
|
+
log: opts.log ?? ((m) => console.error(m)),
|
|
562
|
+
dryRun: dryRunOverride,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
function emptyResult(dryRun) {
|
|
566
|
+
return {
|
|
567
|
+
ok: true,
|
|
568
|
+
pushed: [],
|
|
569
|
+
pulled: [],
|
|
570
|
+
skipped: [],
|
|
571
|
+
conflicts: [],
|
|
572
|
+
refused: [],
|
|
573
|
+
dryRun,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Decide whether this run should be a dry run, and persist auto-opt-in when
|
|
578
|
+
* appropriate. Returns the resolved `dryRun` boolean and mutates `state` to
|
|
579
|
+
* record auto-opt-in so subsequent runs don't re-log the upgrade banner.
|
|
580
|
+
*
|
|
581
|
+
* Precedence:
|
|
582
|
+
* 1. Explicit `opts.dryRun` always wins (tests, --dry-run flag, callers
|
|
583
|
+
* that mean "I know what I'm doing").
|
|
584
|
+
* 2. Already opted in -> real run.
|
|
585
|
+
* 3. Proxy mode (FCP_API_TOKEN -> FCP proxy -> Unroo) -> auto-opt-in. Only
|
|
586
|
+
* Fruition team members can mint an FCP API key, so reaching the proxy
|
|
587
|
+
* is itself proof of team membership. Persist optedIn=true so we log the
|
|
588
|
+
* auto-enable message exactly once.
|
|
589
|
+
* 4. Direct mode (personal UNROO_API_KEY) -> stay dry-run until the user
|
|
590
|
+
* runs `fcp-mcp-server sync-skills --enable`. Direct mode is the
|
|
591
|
+
* power-user / script path; we don't want to assume intent there.
|
|
592
|
+
*/
|
|
593
|
+
function resolveDryRun(opts, state, transport, log) {
|
|
594
|
+
if (opts.dryRun !== undefined)
|
|
595
|
+
return opts.dryRun;
|
|
596
|
+
if (state.optedIn === true)
|
|
597
|
+
return false;
|
|
598
|
+
if (transport.configured && transport.mode === 'proxy') {
|
|
599
|
+
state.optedIn = true;
|
|
600
|
+
log('[skills-sync] auto-enabled for Fruition team member ' +
|
|
601
|
+
'(reached via FCP proxy). Skills with name+description in ' +
|
|
602
|
+
'frontmatter will be pushed; scratch (_*, .*, digit-prefixed) ' +
|
|
603
|
+
'and private:true skills are skipped. To disable, delete ' +
|
|
604
|
+
'~/.claude/skills/.sync-state.json or set private:true.');
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
/** Push local skills up to Unroo. */
|
|
610
|
+
export async function pushSkills(opts = {}) {
|
|
611
|
+
const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
|
|
612
|
+
const transport = resolveTransport(opts);
|
|
613
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
614
|
+
const dryRun = resolveDryRun(opts, state, transport, log);
|
|
615
|
+
const ctx = makeCtx(opts, dryRun, transport);
|
|
616
|
+
const result = emptyResult(dryRun);
|
|
617
|
+
if (!transport.configured) {
|
|
618
|
+
ctx.log(`[skills-sync] skipping push: ${transport.reason}`);
|
|
619
|
+
result.ok = false;
|
|
620
|
+
result.reason = transport.reason ?? 'transport not configured';
|
|
621
|
+
return result;
|
|
622
|
+
}
|
|
623
|
+
await pushOnce(ctx, state, result);
|
|
624
|
+
saveState(ctx.stateFile, state);
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
/** Pull team skills from Unroo down to local. */
|
|
628
|
+
export async function pullSkills(opts = {}) {
|
|
629
|
+
const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
|
|
630
|
+
const transport = resolveTransport(opts);
|
|
631
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
632
|
+
const dryRun = resolveDryRun(opts, state, transport, log);
|
|
633
|
+
const ctx = makeCtx(opts, dryRun, transport);
|
|
634
|
+
const result = emptyResult(dryRun);
|
|
635
|
+
if (!transport.configured) {
|
|
636
|
+
ctx.log(`[skills-sync] skipping pull: ${transport.reason}`);
|
|
637
|
+
result.ok = false;
|
|
638
|
+
result.reason = transport.reason ?? 'transport not configured';
|
|
639
|
+
return result;
|
|
640
|
+
}
|
|
641
|
+
await pullOnce(ctx, state, result);
|
|
642
|
+
saveState(ctx.stateFile, state);
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
/** Bidirectional sync: pull then push. */
|
|
646
|
+
export async function syncSkills(opts = {}) {
|
|
647
|
+
const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
|
|
648
|
+
const transport = resolveTransport(opts);
|
|
649
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
650
|
+
const dryRun = resolveDryRun(opts, state, transport, log);
|
|
651
|
+
const ctx = makeCtx(opts, dryRun, transport);
|
|
652
|
+
const result = emptyResult(dryRun);
|
|
653
|
+
if (!transport.configured) {
|
|
654
|
+
ctx.log(`[skills-sync] skipping sync: ${transport.reason}`);
|
|
655
|
+
result.ok = false;
|
|
656
|
+
result.reason = transport.reason ?? 'transport not configured';
|
|
657
|
+
return result;
|
|
658
|
+
}
|
|
659
|
+
// Pull first so a brand-new workstation gets the team library before
|
|
660
|
+
// pushing anything; on a long-lived workstation the pull is mostly no-ops.
|
|
661
|
+
await pullOnce(ctx, state, result);
|
|
662
|
+
await pushOnce(ctx, state, result);
|
|
663
|
+
saveState(ctx.stateFile, state);
|
|
664
|
+
return result;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Persist the user's opt-in. Called by the CLI when the user runs
|
|
668
|
+
* `fcp-mcp-server sync-skills --enable` for the first time.
|
|
669
|
+
*/
|
|
670
|
+
export function recordOptIn(stateFile, skillsDir) {
|
|
671
|
+
const dir = skillsDir ?? defaultSkillsDir();
|
|
672
|
+
const file = stateFile ?? defaultStateFile(dir);
|
|
673
|
+
if (!existsSync(dir))
|
|
674
|
+
mkdirSync(dir, { recursive: true });
|
|
675
|
+
const state = loadState(file);
|
|
676
|
+
state.optedIn = true;
|
|
677
|
+
saveState(file, state);
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Background entry called from the MCP server's main(). Never throws — any
|
|
681
|
+
* failure is logged and swallowed. Default behavior on a fresh workstation:
|
|
682
|
+
* dry-run only. The user opts in by running `fcp-mcp-server sync-skills --enable`.
|
|
683
|
+
*/
|
|
684
|
+
export async function runBackgroundSync(opts = {}) {
|
|
685
|
+
try {
|
|
686
|
+
const result = await syncSkills(opts);
|
|
687
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
688
|
+
const tag = result.dryRun ? '[skills-sync] (dry run)' : '[skills-sync]';
|
|
689
|
+
if (result.reason) {
|
|
690
|
+
log(`${tag} ${result.reason}`);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
log(`${tag} pulled=${result.pulled.length} pushed=${result.pushed.length} ` +
|
|
694
|
+
`skipped=${result.skipped.length} conflicts=${result.conflicts.length} ` +
|
|
695
|
+
`refused=${result.refused.length}`);
|
|
696
|
+
if (result.dryRun && (result.pulled.length || result.pushed.length)) {
|
|
697
|
+
log('[skills-sync] To enable real sync, run: fcp-mcp-server sync-skills --enable');
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
702
|
+
log(`[skills-sync] background sync error (ignored): ${err.message}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// Exported for testing
|
|
706
|
+
export const __test__ = {
|
|
707
|
+
parseFrontmatter,
|
|
708
|
+
detectSecret,
|
|
709
|
+
pushSkipReason,
|
|
710
|
+
hashBody,
|
|
711
|
+
basename, // re-export so tests can verify path handling without importing path
|
|
712
|
+
};
|