@fruition/fcp-mcp-server 1.16.2 → 1.19.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.
@@ -57,12 +57,20 @@ if [ -f ".unroo" ]; then
57
57
  PROJECT_SLUG=$(grep -E "^project_slug=" .unroo 2>/dev/null | cut -d= -f2)
58
58
  fi
59
59
 
60
- # Get machine ID (for multi-machine tracking)
60
+ # Get machine ID (for multi-machine tracking) — portable across Linux and macOS
61
61
  MACHINE_ID=""
62
62
  if [ -f /etc/machine-id ]; then
63
- MACHINE_ID=$(cat /etc/machine-id | head -c 8)
64
- elif command -v hostid &> /dev/null; then
65
- MACHINE_ID=$(hostid)
63
+ # Linux (systemd)
64
+ MACHINE_ID=$(head -c 8 /etc/machine-id 2>/dev/null)
65
+ elif [ -r /var/lib/dbus/machine-id ]; then
66
+ # Linux (dbus, no systemd)
67
+ MACHINE_ID=$(head -c 8 /var/lib/dbus/machine-id 2>/dev/null)
68
+ elif command -v ioreg >/dev/null 2>&1; then
69
+ # macOS — IOPlatformUUID is stable per machine
70
+ MACHINE_ID=$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null \
71
+ | awk -F'"' '/IOPlatformUUID/{print $4}' | head -c 8)
72
+ elif command -v hostid >/dev/null 2>&1; then
73
+ MACHINE_ID=$(hostid 2>/dev/null)
66
74
  fi
67
75
 
68
76
  # Build JSON payload
package/dist/index.js CHANGED
@@ -34,6 +34,8 @@ catch {
34
34
  }
35
35
  }
36
36
  import { execSync } from 'child_process';
37
+ import { runSkillsCli } from './skills-sync-cli.js';
38
+ import { runBackgroundSync } from './skills-sync.js';
37
39
  // Configuration
38
40
  const FCP_API_URL = process.env.FCP_API_URL || 'https://fcp.fru.io';
39
41
  const FCP_API_TOKEN = process.env.FCP_API_TOKEN || '';
@@ -61,6 +63,7 @@ function detectUserEmail() {
61
63
  catch {
62
64
  // Not in a git repo or no email configured
63
65
  }
66
+ console.error('[MCP Server] WARNING: FCP_USER_EMAIL not set and git email not detected. Unroo task updates will be attributed to the FCP API key owner, not you. Set FCP_USER_EMAIL in your MCP server env config.');
64
67
  return '';
65
68
  }
66
69
  const FCP_USER_EMAIL = detectUserEmail();
@@ -74,6 +77,148 @@ const UNROO_AVAILABLE = UNROO_API_KEY || (USE_FCP_UNROO_PROXY && FCP_API_TOKEN);
74
77
  // - Multiple processes on same machine (PID)
75
78
  // - Process restarts (timestamp)
76
79
  const INSTANCE_ID = `${process.env.HOSTNAME || 'local'}-${process.pid}-${Date.now()}`;
80
+ const ROLE_HIERARCHY = [
81
+ 'super_admin',
82
+ 'admin',
83
+ 'billing_admin',
84
+ 'operator',
85
+ 'viewer',
86
+ 'none',
87
+ ];
88
+ const TOOL_PERMISSIONS = {
89
+ // --- super_admin only: destructive, irreversible, or move raw prod data ---
90
+ fcp_create_site: 'super_admin',
91
+ fcp_delete_site: 'super_admin',
92
+ fcp_delete_launch: 'super_admin',
93
+ fcp_clone_to_staging: 'super_admin',
94
+ fcp_clone_confirm: 'super_admin',
95
+ fcp_shield_remove_domain: 'super_admin',
96
+ fcp_backup_delete_pairing: 'super_admin',
97
+ fcp_filesync_cancel_sync: 'super_admin',
98
+ // --- admin+: mutating ops with real-world side effects ---
99
+ fcp_create_launch: 'admin',
100
+ fcp_update_launch: 'admin',
101
+ fcp_update_site: 'admin',
102
+ fcp_delete_checklist_item: 'admin',
103
+ fcp_shield_add_domain: 'admin',
104
+ fcp_shield_update_domain: 'admin',
105
+ fcp_backup_enable: 'admin',
106
+ fcp_backup_trigger: 'admin',
107
+ fcp_backup_check_trigger: 'admin',
108
+ fcp_backup_update_pairing: 'admin',
109
+ fcp_backup_download: 'admin',
110
+ fcp_backup_download_prepared: 'admin',
111
+ fcp_kinsta_backup_download: 'admin',
112
+ fcp_filesync_start_sync: 'admin',
113
+ fcp_filesync_get_confirmation: 'admin',
114
+ fcp_trigger_nuclei_scan: 'admin',
115
+ fcp_scan_security_headers: 'admin',
116
+ // --- operator+: routine writes (checklists, progress notes, Unroo tasks) ---
117
+ fcp_add_checklist_item: 'operator',
118
+ fcp_update_checklist_item: 'operator',
119
+ fcp_validate_checklist_item: 'operator',
120
+ fcp_add_progress_note: 'operator',
121
+ unroo_create_task: 'operator',
122
+ unroo_update_task: 'operator',
123
+ unroo_add_comment: 'operator',
124
+ unroo_create_follow_up: 'operator',
125
+ unroo_log_future_work: 'operator',
126
+ unroo_convert_to_backlog: 'operator',
127
+ unroo_start_session: 'operator',
128
+ unroo_end_session: 'operator',
129
+ // --- viewer (read-only) — explicit for clarity; any unmapped tool also defaults here ---
130
+ fcp_list_launches: 'viewer',
131
+ fcp_get_launch: 'viewer',
132
+ fcp_get_legacy_access: 'viewer',
133
+ fcp_get_checklist: 'viewer',
134
+ fcp_get_claude_md: 'viewer',
135
+ fcp_get_success_factors: 'viewer',
136
+ fcp_get_unroo_section: 'viewer',
137
+ fcp_filesync_list_configs: 'viewer',
138
+ fcp_filesync_get_config: 'viewer',
139
+ fcp_filesync_get_job_status: 'viewer',
140
+ fcp_get_nuclei_results: 'viewer',
141
+ fcp_get_security_headers_results: 'viewer',
142
+ fcp_list_sites: 'viewer',
143
+ fcp_search_sites: 'viewer',
144
+ fcp_get_site: 'viewer',
145
+ fcp_get_local_setup_guide: 'viewer',
146
+ fcp_shield_list_domains: 'viewer',
147
+ fcp_shield_get_domain: 'viewer',
148
+ fcp_shield_get_metrics: 'viewer',
149
+ fcp_backup_list_sites: 'viewer',
150
+ fcp_backup_get_config: 'viewer',
151
+ fcp_backup_list_eligible: 'viewer',
152
+ fcp_backup_list_backups: 'viewer',
153
+ fcp_backup_get_status: 'viewer',
154
+ fcp_backup_sanitize_status: 'viewer',
155
+ fcp_backup_list_pairings: 'viewer',
156
+ fcp_kinsta_backup_list_sites: 'viewer',
157
+ fcp_kinsta_backup_list: 'viewer',
158
+ fcp_clone_status: 'viewer',
159
+ fcp_clone_list: 'viewer',
160
+ fcp_get_dev_environment_info: 'viewer',
161
+ unroo_list_projects: 'viewer',
162
+ unroo_list_tasks: 'viewer',
163
+ unroo_get_task: 'viewer',
164
+ unroo_list_comments: 'viewer',
165
+ unroo_get_my_tasks: 'viewer',
166
+ unroo_get_parking_lot: 'viewer',
167
+ unroo_get_backlog: 'viewer',
168
+ };
169
+ // Resolved role for the current API key. Cached only on successful lookup so
170
+ // transient FCP outages don't pin us to 'none' for the rest of the session.
171
+ let cachedUserRole;
172
+ async function fetchUserRole() {
173
+ if (cachedUserRole !== undefined)
174
+ return cachedUserRole;
175
+ // dev_bypass is the local-dev token; treat as super_admin to avoid
176
+ // gating local development against role lookup.
177
+ if (FCP_API_TOKEN === 'dev_bypass') {
178
+ cachedUserRole = 'super_admin';
179
+ return cachedUserRole;
180
+ }
181
+ if (!FCP_API_TOKEN) {
182
+ return 'none';
183
+ }
184
+ try {
185
+ const res = await fetch(`${FCP_API_URL}/api/mcp/me`, {
186
+ headers: { 'X-API-Key': FCP_API_TOKEN },
187
+ signal: AbortSignal.timeout(10_000),
188
+ });
189
+ if (!res.ok) {
190
+ console.error(`[MCP Server] Role lookup failed: HTTP ${res.status}`);
191
+ return 'none';
192
+ }
193
+ const data = await res.json();
194
+ const role = data?.role ?? 'none';
195
+ cachedUserRole = role;
196
+ console.error(`[MCP Server] Resolved user role: ${role} (${data?.email ?? 'unknown'})`);
197
+ return role;
198
+ }
199
+ catch (err) {
200
+ console.error('[MCP Server] Role lookup error:', err);
201
+ return 'none';
202
+ }
203
+ }
204
+ function isRoleAtLeast(actual, required) {
205
+ if (actual === 'none')
206
+ return required === 'none';
207
+ const a = ROLE_HIERARCHY.indexOf(actual);
208
+ const r = ROLE_HIERARCHY.indexOf(required);
209
+ if (a === -1 || r === -1)
210
+ return false;
211
+ return a <= r;
212
+ }
213
+ async function enforceToolPermission(toolName) {
214
+ const required = TOOL_PERMISSIONS[toolName] ?? 'viewer';
215
+ const actual = await fetchUserRole();
216
+ if (!isRoleAtLeast(actual, required)) {
217
+ throw new Error(`Permission denied: tool '${toolName}' requires ${required} role; ` +
218
+ `your role is '${actual}'. Contact a super_admin (Brad, Mattox, or Andrea) ` +
219
+ `if you need access.`);
220
+ }
221
+ }
77
222
  let currentProject = null;
78
223
  /**
79
224
  * Detect the current git remote URL
@@ -2264,6 +2409,26 @@ const TOOLS = [
2264
2409
  type: 'number',
2265
2410
  description: 'UptimeRobot monitor ID for this site. Used by scheduler to pause/resume monitors on scale events.',
2266
2411
  },
2412
+ pvc_name: {
2413
+ type: 'string',
2414
+ description: 'PVC name in the K8s namespace (e.g., "public-files-nfs"). Update after RWO→RWX migration so the snapshot scheduler targets the new volume.',
2415
+ },
2416
+ pvc_storage_class: {
2417
+ type: 'string',
2418
+ description: 'PVC storage class (e.g., "openebs-kernel-nfs", "do-block-storage-xfs-retain"). Update after a storage migration.',
2419
+ },
2420
+ pvc_size: {
2421
+ type: 'string',
2422
+ description: 'PVC size with unit (e.g., "10Gi"). Update after expanding the volume.',
2423
+ },
2424
+ pvc_mount_path: {
2425
+ type: 'string',
2426
+ description: 'Where the PVC is mounted inside the container (e.g., "/var/www/html/wp-content/uploads").',
2427
+ },
2428
+ pvc_sidecar_detected: {
2429
+ type: 'boolean',
2430
+ description: 'Whether a backup sidecar was auto-detected on this PVC.',
2431
+ },
2267
2432
  },
2268
2433
  required: ['website_id'],
2269
2434
  },
@@ -2782,6 +2947,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2782
2947
  // Track tool call for session management
2783
2948
  await sessionTracker.trackToolCall(name, `Called ${name}`);
2784
2949
  try {
2950
+ // Role-based access control: gate destructive / sensitive tools by RBAC role
2951
+ // resolved from /api/auth/me. Throws on insufficient privilege; the catch
2952
+ // block below converts the error into the standard MCP error response.
2953
+ await enforceToolPermission(name);
2785
2954
  switch (name) {
2786
2955
  case 'fcp_list_launches': {
2787
2956
  const result = await client.listLaunches(args);
@@ -4023,16 +4192,71 @@ async function main() {
4023
4192
  initializeProjectDetection();
4024
4193
  // Check for updates (non-blocking)
4025
4194
  checkForUpdates();
4026
- // Handle graceful shutdown
4027
- const shutdown = async () => {
4028
- console.error('Shutting down MCP server...');
4029
- await sessionTracker.endSession();
4195
+ // Sync ~/.claude/skills with the shared Unroo KG (non-blocking, opt-in).
4196
+ // First run on a fresh workstation is dry-run only; user opts in via
4197
+ // `fcp-mcp-server sync-skills --enable`.
4198
+ // Passing the FCP key lets skills-sync route through FCP's Unroo proxy when
4199
+ // no personal UNROO_API_KEY is set — so the single `claude mcp add` key
4200
+ // covers skill sync too.
4201
+ runBackgroundSync({
4202
+ unrooApiKey: process.env.UNROO_API_KEY ?? "",
4203
+ userEmail: FCP_USER_EMAIL,
4204
+ unrooApiUrl: process.env.UNROO_API_URL,
4205
+ fcpApiUrl: FCP_API_URL,
4206
+ fcpApiToken: FCP_API_TOKEN,
4207
+ });
4208
+ // Handle graceful shutdown — idempotent, safe to call from any signal/event
4209
+ let shuttingDown = false;
4210
+ const shutdown = async (reason) => {
4211
+ if (shuttingDown)
4212
+ return;
4213
+ shuttingDown = true;
4214
+ console.error(`Shutting down MCP server (${reason})...`);
4215
+ try {
4216
+ await sessionTracker.endSession();
4217
+ }
4218
+ catch (err) {
4219
+ console.error('Error ending session during shutdown:', err);
4220
+ }
4030
4221
  process.exit(0);
4031
4222
  };
4032
- process.on('SIGINT', shutdown);
4033
- process.on('SIGTERM', shutdown);
4223
+ process.on('SIGINT', () => void shutdown('SIGINT'));
4224
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
4225
+ process.on('SIGHUP', () => void shutdown('SIGHUP'));
4226
+ // Stdin closure means the parent (Claude Code) closed the pipe — exit.
4227
+ // Without this, the server lingers as a zombie when the parent restarts
4228
+ // or crashes, and accumulates over a long workstation session.
4229
+ process.stdin.on('end', () => void shutdown('stdin end'));
4230
+ process.stdin.on('close', () => void shutdown('stdin close'));
4231
+ process.stdin.on('error', (err) => {
4232
+ console.error('stdin error:', err);
4233
+ void shutdown('stdin error');
4234
+ });
4235
+ // Orphan watchdog: if our parent process dies and we get reparented to
4236
+ // init (PID 1), no signal fires and stdin may stay technically open. Poll
4237
+ // for ppid change and shut down if we're orphaned.
4238
+ const initialPpid = process.ppid;
4239
+ setInterval(() => {
4240
+ const currentPpid = process.ppid;
4241
+ if (currentPpid === 1 && initialPpid !== 1) {
4242
+ void shutdown(`orphaned (ppid ${initialPpid} -> 1)`);
4243
+ }
4244
+ }, 30_000).unref();
4245
+ }
4246
+ // Subcommand: `fcp-mcp-server sync-skills [...]` runs the skills sync CLI
4247
+ // instead of starting the MCP server. This lets the same binary serve both
4248
+ // the long-running stdio MCP role and the one-shot sync-skills tool.
4249
+ if (process.argv[2] === 'sync-skills') {
4250
+ runSkillsCli(process.argv.slice(3))
4251
+ .then((code) => process.exit(code))
4252
+ .catch((err) => {
4253
+ console.error('[skills-sync] fatal:', err);
4254
+ process.exit(1);
4255
+ });
4256
+ }
4257
+ else {
4258
+ main().catch((error) => {
4259
+ console.error('Fatal error:', error);
4260
+ process.exit(1);
4261
+ });
4034
4262
  }
4035
- main().catch((error) => {
4036
- console.error('Fatal error:', error);
4037
- process.exit(1);
4038
- });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Skills sync CLI subcommand handler.
3
+ *
4
+ * Invoked when the MCP server binary is called with the `sync-skills` argv:
5
+ * fcp-mcp-server sync-skills [--push|--pull|--sync] [--dry-run] [--enable] [--once]
6
+ *
7
+ * Designed for both:
8
+ * - Direct user invocation (one-shot, exit when done).
9
+ * - Background invocation from main() at MCP server startup (non-blocking,
10
+ * never throws).
11
+ */
12
+ interface CliArgs {
13
+ mode: 'push' | 'pull' | 'sync';
14
+ dryRun?: boolean;
15
+ enable: boolean;
16
+ help: boolean;
17
+ }
18
+ export declare function parseArgs(argv: string[]): CliArgs;
19
+ /**
20
+ * Main entry. Returns process exit code.
21
+ */
22
+ export declare function runSkillsCli(argv: string[]): Promise<number>;
23
+ export {};
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Skills sync CLI subcommand handler.
3
+ *
4
+ * Invoked when the MCP server binary is called with the `sync-skills` argv:
5
+ * fcp-mcp-server sync-skills [--push|--pull|--sync] [--dry-run] [--enable] [--once]
6
+ *
7
+ * Designed for both:
8
+ * - Direct user invocation (one-shot, exit when done).
9
+ * - Background invocation from main() at MCP server startup (non-blocking,
10
+ * never throws).
11
+ */
12
+ import { pushSkills, pullSkills, syncSkills, recordOptIn, defaultSkillsDir, defaultStateFile, } from './skills-sync.js';
13
+ export function parseArgs(argv) {
14
+ const args = { mode: 'sync', enable: false, help: false };
15
+ for (const a of argv) {
16
+ if (a === '--push')
17
+ args.mode = 'push';
18
+ else if (a === '--pull')
19
+ args.mode = 'pull';
20
+ else if (a === '--sync')
21
+ args.mode = 'sync';
22
+ else if (a === '--dry-run')
23
+ args.dryRun = true;
24
+ else if (a === '--no-dry-run')
25
+ args.dryRun = false;
26
+ else if (a === '--enable')
27
+ args.enable = true;
28
+ else if (a === '--help' || a === '-h')
29
+ args.help = true;
30
+ }
31
+ return args;
32
+ }
33
+ function printHelp() {
34
+ const help = `
35
+ fcp-mcp-server sync-skills — sync ~/.claude/skills/<slug>/SKILL.md with the
36
+ shared Fruition Unroo knowledge graph.
37
+
38
+ Usage:
39
+ fcp-mcp-server sync-skills [options]
40
+
41
+ Options:
42
+ --push Only push local skills up to Unroo
43
+ --pull Only pull team skills from Unroo down to local
44
+ --sync Pull then push (default)
45
+ --dry-run Print what would happen without making changes/network calls
46
+ --no-dry-run Force real sync even before --enable opt-in (for scripting)
47
+ --enable Persist the one-time opt-in flag and run a real sync
48
+ --help, -h Show this help
49
+
50
+ Environment (proxy mode — default, recommended):
51
+ FCP_API_TOKEN FCP API key. With this set and no UNROO_API_KEY, sync routes
52
+ through FCP's Unroo proxy — no personal Unroo key needed.
53
+ FCP_API_URL FCP base URL (default https://fcp.fru.io).
54
+
55
+ Environment (direct mode — optional, legacy):
56
+ UNROO_API_KEY Personal Unroo key. If set, sync talks to Unroo directly.
57
+ FCP_USER_EMAIL Your Fruition team email; required for direct-mode attribution.
58
+ UNROO_API_URL Unroo base URL override (default https://app.unroo.io).
59
+
60
+ State file: ${defaultStateFile(defaultSkillsDir())}
61
+ `.trim();
62
+ console.error(help);
63
+ }
64
+ function summarize(result, prefix) {
65
+ const tag = result.dryRun ? `${prefix} (dry run)` : prefix;
66
+ if (result.reason) {
67
+ console.error(`${tag} ${result.reason}`);
68
+ return;
69
+ }
70
+ console.error(`${tag} pulled=${result.pulled.length} pushed=${result.pushed.length} ` +
71
+ `skipped=${result.skipped.length} conflicts=${result.conflicts.length} ` +
72
+ `refused=${result.refused.length}`);
73
+ for (const c of result.conflicts) {
74
+ console.error(` conflict: ${c.slug} — ${c.why}`);
75
+ }
76
+ for (const r of result.refused) {
77
+ console.error(` refused: ${r.slug} — ${r.why}`);
78
+ }
79
+ if (result.dryRun && (result.pulled.length || result.pushed.length)) {
80
+ console.error('\nTo enable real sync, run: fcp-mcp-server sync-skills --enable');
81
+ }
82
+ }
83
+ /**
84
+ * Main entry. Returns process exit code.
85
+ */
86
+ export async function runSkillsCli(argv) {
87
+ const args = parseArgs(argv);
88
+ if (args.help) {
89
+ printHelp();
90
+ return 0;
91
+ }
92
+ // --enable flips the opt-in flag, then runs a real sync in the requested
93
+ // mode (--push, --pull, or --sync). Previously --enable always ran full
94
+ // sync; that turned `--enable --pull` into a surprise push for the user.
95
+ if (args.enable) {
96
+ recordOptIn();
97
+ console.error(`[skills-sync] Opt-in recorded. Running real sync (mode=${args.mode})...`);
98
+ }
99
+ const opts = {
100
+ unrooApiKey: process.env.UNROO_API_KEY ?? '',
101
+ userEmail: process.env.FCP_USER_EMAIL ?? '',
102
+ unrooApiUrl: process.env.UNROO_API_URL,
103
+ // FCP key enables proxy mode when no personal UNROO_API_KEY is set.
104
+ fcpApiUrl: process.env.FCP_API_URL,
105
+ fcpApiToken: process.env.FCP_API_TOKEN,
106
+ // --enable forces a real run; otherwise let lib decide based on opt-in flag
107
+ dryRun: args.enable ? false : args.dryRun,
108
+ };
109
+ let result;
110
+ if (args.mode === 'push') {
111
+ result = await pushSkills(opts);
112
+ }
113
+ else if (args.mode === 'pull') {
114
+ result = await pullSkills(opts);
115
+ }
116
+ else {
117
+ result = await syncSkills(opts);
118
+ }
119
+ summarize(result, '[skills-sync]');
120
+ return result.ok ? 0 : 1;
121
+ }
@@ -0,0 +1,144 @@
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
+ export interface SkillFrontmatter {
26
+ name?: string;
27
+ description?: string;
28
+ triggers?: string[];
29
+ private?: boolean;
30
+ [key: string]: unknown;
31
+ }
32
+ export interface ParsedSkill {
33
+ slug: string;
34
+ filePath: string;
35
+ frontmatter: SkillFrontmatter;
36
+ body: string;
37
+ mtimeMs: number;
38
+ }
39
+ export interface SyncStateEntry {
40
+ lastPushedMtimeMs?: number;
41
+ lastPulledBodyHash?: string;
42
+ lastPulledRemoteUpdatedAt?: string;
43
+ }
44
+ export interface SyncState {
45
+ optedIn?: boolean;
46
+ skills: Record<string, SyncStateEntry>;
47
+ version: number;
48
+ }
49
+ export interface SyncOptions {
50
+ skillsDir?: string;
51
+ stateFile?: string;
52
+ unrooApiUrl?: string;
53
+ unrooApiKey?: string;
54
+ fcpApiUrl?: string;
55
+ fcpApiToken?: string;
56
+ userEmail?: string;
57
+ dryRun?: boolean;
58
+ log?: (msg: string) => void;
59
+ fetchImpl?: typeof fetch;
60
+ now?: () => Date;
61
+ }
62
+ export interface SyncResult {
63
+ ok: boolean;
64
+ reason?: string;
65
+ pushed: string[];
66
+ pulled: string[];
67
+ skipped: Array<{
68
+ slug: string;
69
+ why: string;
70
+ }>;
71
+ conflicts: Array<{
72
+ slug: string;
73
+ why: string;
74
+ }>;
75
+ refused: Array<{
76
+ slug: string;
77
+ why: string;
78
+ }>;
79
+ dryRun: boolean;
80
+ }
81
+ export declare function defaultSkillsDir(): string;
82
+ export declare function defaultStateFile(skillsDir: string): string;
83
+ export declare function loadState(stateFile: string): SyncState;
84
+ export declare function saveState(stateFile: string, state: SyncState): void;
85
+ /**
86
+ * Parse the YAML-ish frontmatter block from a SKILL.md.
87
+ *
88
+ * We support exactly the shape used in ~/.claude/skills/*\/SKILL.md today:
89
+ * - `key: value` scalars (string, boolean)
90
+ * - `key: |` multi-line block scalars (folded into one space-joined string)
91
+ * - `key:` followed by ` - item` list entries (parsed as string[])
92
+ * - `triggers: a, b, c` inline comma list (also parsed as string[])
93
+ *
94
+ * Anything fancier (anchors, nested maps, JSON-style flow) is out of scope —
95
+ * if a skill author needs that we'd pull in `js-yaml`, but every existing
96
+ * SKILL.md in the team library fits this subset.
97
+ */
98
+ export declare function parseFrontmatter(source: string): {
99
+ frontmatter: SkillFrontmatter;
100
+ body: string;
101
+ };
102
+ export declare function discoverLocalSkills(skillsDir: string): Promise<ParsedSkill[]>;
103
+ /**
104
+ * Decide whether a skill should be pushed.
105
+ * Returns null if pushable, or a reason string if not.
106
+ */
107
+ export declare function pushSkipReason(skill: ParsedSkill): string | null;
108
+ /**
109
+ * Detect control characters (other than tab, LF, CR) in the body. The Unroo
110
+ * capture-skill endpoint 500s on null bytes — and they're never legitimate
111
+ * skill content anyway. Returns a short description of the first hit, or
112
+ * null if clean.
113
+ */
114
+ export declare function detectControlChars(body: string): string | null;
115
+ /**
116
+ * Detect apparent secrets in a skill body. Returns the matched substring
117
+ * (truncated) for logging, or null if clean.
118
+ */
119
+ export declare function detectSecret(body: string): string | null;
120
+ export declare function hashBody(body: string): string;
121
+ /** Push local skills up to Unroo. */
122
+ export declare function pushSkills(opts?: SyncOptions): Promise<SyncResult>;
123
+ /** Pull team skills from Unroo down to local. */
124
+ export declare function pullSkills(opts?: SyncOptions): Promise<SyncResult>;
125
+ /** Bidirectional sync: pull then push. */
126
+ export declare function syncSkills(opts?: SyncOptions): Promise<SyncResult>;
127
+ /**
128
+ * Persist the user's opt-in. Called by the CLI when the user runs
129
+ * `fcp-mcp-server sync-skills --enable` for the first time.
130
+ */
131
+ export declare function recordOptIn(stateFile?: string, skillsDir?: string): void;
132
+ /**
133
+ * Background entry called from the MCP server's main(). Never throws — any
134
+ * failure is logged and swallowed. Default behavior on a fresh workstation:
135
+ * dry-run only. The user opts in by running `fcp-mcp-server sync-skills --enable`.
136
+ */
137
+ export declare function runBackgroundSync(opts?: SyncOptions): Promise<void>;
138
+ export declare const __test__: {
139
+ parseFrontmatter: typeof parseFrontmatter;
140
+ detectSecret: typeof detectSecret;
141
+ pushSkipReason: typeof pushSkipReason;
142
+ hashBody: typeof hashBody;
143
+ basename: (path: string, suffix?: string) => string;
144
+ };
@@ -0,0 +1,678 @@
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruition/fcp-mcp-server",
3
- "version": "1.16.2",
3
+ "version": "1.19.0",
4
4
  "description": "MCP Server for FCP Launch Coordination System - enables Claude Code to interact with FCP launches and track development time",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -39,7 +39,7 @@
39
39
  "@modelcontextprotocol/sdk": "^1.0.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@types/node": "^20.0.0",
42
+ "@types/node": "^25.6.2",
43
43
  "tsx": "^4.7.0",
44
44
  "typescript": "^5.3.0"
45
45
  }