@fruition/fcp-mcp-server 1.19.0 → 1.21.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,678 +0,0 @@
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
- /** Push local skills up to Unroo. */
577
- export async function pushSkills(opts = {}) {
578
- const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
579
- // Fresh workstation -> force dry-run unless explicitly opted in OR caller
580
- // passed dryRun:false meaning "I know what I'm doing".
581
- const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
582
- const transport = resolveTransport(opts);
583
- const ctx = makeCtx(opts, dryRun, transport);
584
- const result = emptyResult(dryRun);
585
- if (!transport.configured) {
586
- ctx.log(`[skills-sync] skipping push: ${transport.reason}`);
587
- result.ok = false;
588
- result.reason = transport.reason ?? 'transport not configured';
589
- return result;
590
- }
591
- await pushOnce(ctx, state, result);
592
- saveState(ctx.stateFile, state);
593
- return result;
594
- }
595
- /** Pull team skills from Unroo down to local. */
596
- export async function pullSkills(opts = {}) {
597
- const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
598
- const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
599
- const transport = resolveTransport(opts);
600
- const ctx = makeCtx(opts, dryRun, transport);
601
- const result = emptyResult(dryRun);
602
- if (!transport.configured) {
603
- ctx.log(`[skills-sync] skipping pull: ${transport.reason}`);
604
- result.ok = false;
605
- result.reason = transport.reason ?? 'transport not configured';
606
- return result;
607
- }
608
- await pullOnce(ctx, state, result);
609
- saveState(ctx.stateFile, state);
610
- return result;
611
- }
612
- /** Bidirectional sync: pull then push. */
613
- export async function syncSkills(opts = {}) {
614
- const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
615
- const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
616
- const transport = resolveTransport(opts);
617
- const ctx = makeCtx(opts, dryRun, transport);
618
- const result = emptyResult(dryRun);
619
- if (!transport.configured) {
620
- ctx.log(`[skills-sync] skipping sync: ${transport.reason}`);
621
- result.ok = false;
622
- result.reason = transport.reason ?? 'transport not configured';
623
- return result;
624
- }
625
- // Pull first so a brand-new workstation gets the team library before
626
- // pushing anything; on a long-lived workstation the pull is mostly no-ops.
627
- await pullOnce(ctx, state, result);
628
- await pushOnce(ctx, state, result);
629
- saveState(ctx.stateFile, state);
630
- return result;
631
- }
632
- /**
633
- * Persist the user's opt-in. Called by the CLI when the user runs
634
- * `fcp-mcp-server sync-skills --enable` for the first time.
635
- */
636
- export function recordOptIn(stateFile, skillsDir) {
637
- const dir = skillsDir ?? defaultSkillsDir();
638
- const file = stateFile ?? defaultStateFile(dir);
639
- if (!existsSync(dir))
640
- mkdirSync(dir, { recursive: true });
641
- const state = loadState(file);
642
- state.optedIn = true;
643
- saveState(file, state);
644
- }
645
- /**
646
- * Background entry called from the MCP server's main(). Never throws — any
647
- * failure is logged and swallowed. Default behavior on a fresh workstation:
648
- * dry-run only. The user opts in by running `fcp-mcp-server sync-skills --enable`.
649
- */
650
- export async function runBackgroundSync(opts = {}) {
651
- try {
652
- const result = await syncSkills(opts);
653
- const log = opts.log ?? ((m) => console.error(m));
654
- const tag = result.dryRun ? '[skills-sync] (dry run)' : '[skills-sync]';
655
- if (result.reason) {
656
- log(`${tag} ${result.reason}`);
657
- return;
658
- }
659
- log(`${tag} pulled=${result.pulled.length} pushed=${result.pushed.length} ` +
660
- `skipped=${result.skipped.length} conflicts=${result.conflicts.length} ` +
661
- `refused=${result.refused.length}`);
662
- if (result.dryRun && (result.pulled.length || result.pushed.length)) {
663
- log('[skills-sync] To enable real sync, run: fcp-mcp-server sync-skills --enable');
664
- }
665
- }
666
- catch (err) {
667
- const log = opts.log ?? ((m) => console.error(m));
668
- log(`[skills-sync] background sync error (ignored): ${err.message}`);
669
- }
670
- }
671
- // Exported for testing
672
- export const __test__ = {
673
- parseFrontmatter,
674
- detectSecret,
675
- pushSkipReason,
676
- hashBody,
677
- basename, // re-export so tests can verify path handling without importing path
678
- };