@fruition/fcp-mcp-server 1.16.2 → 1.18.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.js +83 -10
- package/dist/skills-sync-cli.d.ts +23 -0
- package/dist/skills-sync-cli.js +113 -0
- package/dist/skills-sync.d.ts +142 -0
- package/dist/skills-sync.js +605 -0
- package/package.json +1 -1
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();
|
|
@@ -2264,6 +2267,26 @@ const TOOLS = [
|
|
|
2264
2267
|
type: 'number',
|
|
2265
2268
|
description: 'UptimeRobot monitor ID for this site. Used by scheduler to pause/resume monitors on scale events.',
|
|
2266
2269
|
},
|
|
2270
|
+
pvc_name: {
|
|
2271
|
+
type: 'string',
|
|
2272
|
+
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.',
|
|
2273
|
+
},
|
|
2274
|
+
pvc_storage_class: {
|
|
2275
|
+
type: 'string',
|
|
2276
|
+
description: 'PVC storage class (e.g., "openebs-kernel-nfs", "do-block-storage-xfs-retain"). Update after a storage migration.',
|
|
2277
|
+
},
|
|
2278
|
+
pvc_size: {
|
|
2279
|
+
type: 'string',
|
|
2280
|
+
description: 'PVC size with unit (e.g., "10Gi"). Update after expanding the volume.',
|
|
2281
|
+
},
|
|
2282
|
+
pvc_mount_path: {
|
|
2283
|
+
type: 'string',
|
|
2284
|
+
description: 'Where the PVC is mounted inside the container (e.g., "/var/www/html/wp-content/uploads").',
|
|
2285
|
+
},
|
|
2286
|
+
pvc_sidecar_detected: {
|
|
2287
|
+
type: 'boolean',
|
|
2288
|
+
description: 'Whether a backup sidecar was auto-detected on this PVC.',
|
|
2289
|
+
},
|
|
2267
2290
|
},
|
|
2268
2291
|
required: ['website_id'],
|
|
2269
2292
|
},
|
|
@@ -4023,16 +4046,66 @@ async function main() {
|
|
|
4023
4046
|
initializeProjectDetection();
|
|
4024
4047
|
// Check for updates (non-blocking)
|
|
4025
4048
|
checkForUpdates();
|
|
4026
|
-
//
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4049
|
+
// Sync ~/.claude/skills with the shared Unroo KG (non-blocking, opt-in).
|
|
4050
|
+
// First run on a fresh workstation is dry-run only; user opts in via
|
|
4051
|
+
// `fcp-mcp-server sync-skills --enable`.
|
|
4052
|
+
runBackgroundSync({
|
|
4053
|
+
unrooApiKey: process.env.UNROO_API_KEY ?? "",
|
|
4054
|
+
userEmail: FCP_USER_EMAIL,
|
|
4055
|
+
unrooApiUrl: process.env.UNROO_API_URL,
|
|
4056
|
+
});
|
|
4057
|
+
// Handle graceful shutdown — idempotent, safe to call from any signal/event
|
|
4058
|
+
let shuttingDown = false;
|
|
4059
|
+
const shutdown = async (reason) => {
|
|
4060
|
+
if (shuttingDown)
|
|
4061
|
+
return;
|
|
4062
|
+
shuttingDown = true;
|
|
4063
|
+
console.error(`Shutting down MCP server (${reason})...`);
|
|
4064
|
+
try {
|
|
4065
|
+
await sessionTracker.endSession();
|
|
4066
|
+
}
|
|
4067
|
+
catch (err) {
|
|
4068
|
+
console.error('Error ending session during shutdown:', err);
|
|
4069
|
+
}
|
|
4030
4070
|
process.exit(0);
|
|
4031
4071
|
};
|
|
4032
|
-
process.on('SIGINT', shutdown);
|
|
4033
|
-
process.on('SIGTERM', shutdown);
|
|
4072
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
4073
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
4074
|
+
process.on('SIGHUP', () => void shutdown('SIGHUP'));
|
|
4075
|
+
// Stdin closure means the parent (Claude Code) closed the pipe — exit.
|
|
4076
|
+
// Without this, the server lingers as a zombie when the parent restarts
|
|
4077
|
+
// or crashes, and accumulates over a long workstation session.
|
|
4078
|
+
process.stdin.on('end', () => void shutdown('stdin end'));
|
|
4079
|
+
process.stdin.on('close', () => void shutdown('stdin close'));
|
|
4080
|
+
process.stdin.on('error', (err) => {
|
|
4081
|
+
console.error('stdin error:', err);
|
|
4082
|
+
void shutdown('stdin error');
|
|
4083
|
+
});
|
|
4084
|
+
// Orphan watchdog: if our parent process dies and we get reparented to
|
|
4085
|
+
// init (PID 1), no signal fires and stdin may stay technically open. Poll
|
|
4086
|
+
// for ppid change and shut down if we're orphaned.
|
|
4087
|
+
const initialPpid = process.ppid;
|
|
4088
|
+
setInterval(() => {
|
|
4089
|
+
const currentPpid = process.ppid;
|
|
4090
|
+
if (currentPpid === 1 && initialPpid !== 1) {
|
|
4091
|
+
void shutdown(`orphaned (ppid ${initialPpid} -> 1)`);
|
|
4092
|
+
}
|
|
4093
|
+
}, 30_000).unref();
|
|
4094
|
+
}
|
|
4095
|
+
// Subcommand: `fcp-mcp-server sync-skills [...]` runs the skills sync CLI
|
|
4096
|
+
// instead of starting the MCP server. This lets the same binary serve both
|
|
4097
|
+
// the long-running stdio MCP role and the one-shot sync-skills tool.
|
|
4098
|
+
if (process.argv[2] === 'sync-skills') {
|
|
4099
|
+
runSkillsCli(process.argv.slice(3))
|
|
4100
|
+
.then((code) => process.exit(code))
|
|
4101
|
+
.catch((err) => {
|
|
4102
|
+
console.error('[skills-sync] fatal:', err);
|
|
4103
|
+
process.exit(1);
|
|
4104
|
+
});
|
|
4105
|
+
}
|
|
4106
|
+
else {
|
|
4107
|
+
main().catch((error) => {
|
|
4108
|
+
console.error('Fatal error:', error);
|
|
4109
|
+
process.exit(1);
|
|
4110
|
+
});
|
|
4034
4111
|
}
|
|
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,113 @@
|
|
|
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:
|
|
51
|
+
UNROO_API_KEY Required. Same key the FCP MCP server uses.
|
|
52
|
+
FCP_USER_EMAIL Required. Your Fruition team email; gates access to the KG.
|
|
53
|
+
UNROO_API_URL Override (default https://app.unroo.io).
|
|
54
|
+
|
|
55
|
+
State file: ${defaultStateFile(defaultSkillsDir())}
|
|
56
|
+
`.trim();
|
|
57
|
+
console.error(help);
|
|
58
|
+
}
|
|
59
|
+
function summarize(result, prefix) {
|
|
60
|
+
const tag = result.dryRun ? `${prefix} (dry run)` : prefix;
|
|
61
|
+
if (result.reason) {
|
|
62
|
+
console.error(`${tag} ${result.reason}`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
console.error(`${tag} pulled=${result.pulled.length} pushed=${result.pushed.length} ` +
|
|
66
|
+
`skipped=${result.skipped.length} conflicts=${result.conflicts.length} ` +
|
|
67
|
+
`refused=${result.refused.length}`);
|
|
68
|
+
for (const c of result.conflicts) {
|
|
69
|
+
console.error(` conflict: ${c.slug} — ${c.why}`);
|
|
70
|
+
}
|
|
71
|
+
for (const r of result.refused) {
|
|
72
|
+
console.error(` refused: ${r.slug} — ${r.why}`);
|
|
73
|
+
}
|
|
74
|
+
if (result.dryRun && (result.pulled.length || result.pushed.length)) {
|
|
75
|
+
console.error('\nTo enable real sync, run: fcp-mcp-server sync-skills --enable');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Main entry. Returns process exit code.
|
|
80
|
+
*/
|
|
81
|
+
export async function runSkillsCli(argv) {
|
|
82
|
+
const args = parseArgs(argv);
|
|
83
|
+
if (args.help) {
|
|
84
|
+
printHelp();
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
// --enable flips the opt-in flag, then runs a real sync in the requested
|
|
88
|
+
// mode (--push, --pull, or --sync). Previously --enable always ran full
|
|
89
|
+
// sync; that turned `--enable --pull` into a surprise push for the user.
|
|
90
|
+
if (args.enable) {
|
|
91
|
+
recordOptIn();
|
|
92
|
+
console.error(`[skills-sync] Opt-in recorded. Running real sync (mode=${args.mode})...`);
|
|
93
|
+
}
|
|
94
|
+
const opts = {
|
|
95
|
+
unrooApiKey: process.env.UNROO_API_KEY ?? '',
|
|
96
|
+
userEmail: process.env.FCP_USER_EMAIL ?? '',
|
|
97
|
+
unrooApiUrl: process.env.UNROO_API_URL,
|
|
98
|
+
// --enable forces a real run; otherwise let lib decide based on opt-in flag
|
|
99
|
+
dryRun: args.enable ? false : args.dryRun,
|
|
100
|
+
};
|
|
101
|
+
let result;
|
|
102
|
+
if (args.mode === 'push') {
|
|
103
|
+
result = await pushSkills(opts);
|
|
104
|
+
}
|
|
105
|
+
else if (args.mode === 'pull') {
|
|
106
|
+
result = await pullSkills(opts);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
result = await syncSkills(opts);
|
|
110
|
+
}
|
|
111
|
+
summarize(result, '[skills-sync]');
|
|
112
|
+
return result.ok ? 0 : 1;
|
|
113
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
userEmail?: string;
|
|
55
|
+
dryRun?: boolean;
|
|
56
|
+
log?: (msg: string) => void;
|
|
57
|
+
fetchImpl?: typeof fetch;
|
|
58
|
+
now?: () => Date;
|
|
59
|
+
}
|
|
60
|
+
export interface SyncResult {
|
|
61
|
+
ok: boolean;
|
|
62
|
+
reason?: string;
|
|
63
|
+
pushed: string[];
|
|
64
|
+
pulled: string[];
|
|
65
|
+
skipped: Array<{
|
|
66
|
+
slug: string;
|
|
67
|
+
why: string;
|
|
68
|
+
}>;
|
|
69
|
+
conflicts: Array<{
|
|
70
|
+
slug: string;
|
|
71
|
+
why: string;
|
|
72
|
+
}>;
|
|
73
|
+
refused: Array<{
|
|
74
|
+
slug: string;
|
|
75
|
+
why: string;
|
|
76
|
+
}>;
|
|
77
|
+
dryRun: boolean;
|
|
78
|
+
}
|
|
79
|
+
export declare function defaultSkillsDir(): string;
|
|
80
|
+
export declare function defaultStateFile(skillsDir: string): string;
|
|
81
|
+
export declare function loadState(stateFile: string): SyncState;
|
|
82
|
+
export declare function saveState(stateFile: string, state: SyncState): void;
|
|
83
|
+
/**
|
|
84
|
+
* Parse the YAML-ish frontmatter block from a SKILL.md.
|
|
85
|
+
*
|
|
86
|
+
* We support exactly the shape used in ~/.claude/skills/*\/SKILL.md today:
|
|
87
|
+
* - `key: value` scalars (string, boolean)
|
|
88
|
+
* - `key: |` multi-line block scalars (folded into one space-joined string)
|
|
89
|
+
* - `key:` followed by ` - item` list entries (parsed as string[])
|
|
90
|
+
* - `triggers: a, b, c` inline comma list (also parsed as string[])
|
|
91
|
+
*
|
|
92
|
+
* Anything fancier (anchors, nested maps, JSON-style flow) is out of scope —
|
|
93
|
+
* if a skill author needs that we'd pull in `js-yaml`, but every existing
|
|
94
|
+
* SKILL.md in the team library fits this subset.
|
|
95
|
+
*/
|
|
96
|
+
export declare function parseFrontmatter(source: string): {
|
|
97
|
+
frontmatter: SkillFrontmatter;
|
|
98
|
+
body: string;
|
|
99
|
+
};
|
|
100
|
+
export declare function discoverLocalSkills(skillsDir: string): Promise<ParsedSkill[]>;
|
|
101
|
+
/**
|
|
102
|
+
* Decide whether a skill should be pushed.
|
|
103
|
+
* Returns null if pushable, or a reason string if not.
|
|
104
|
+
*/
|
|
105
|
+
export declare function pushSkipReason(skill: ParsedSkill): string | null;
|
|
106
|
+
/**
|
|
107
|
+
* Detect control characters (other than tab, LF, CR) in the body. The Unroo
|
|
108
|
+
* capture-skill endpoint 500s on null bytes — and they're never legitimate
|
|
109
|
+
* skill content anyway. Returns a short description of the first hit, or
|
|
110
|
+
* null if clean.
|
|
111
|
+
*/
|
|
112
|
+
export declare function detectControlChars(body: string): string | null;
|
|
113
|
+
/**
|
|
114
|
+
* Detect apparent secrets in a skill body. Returns the matched substring
|
|
115
|
+
* (truncated) for logging, or null if clean.
|
|
116
|
+
*/
|
|
117
|
+
export declare function detectSecret(body: string): string | null;
|
|
118
|
+
export declare function hashBody(body: string): string;
|
|
119
|
+
/** Push local skills up to Unroo. */
|
|
120
|
+
export declare function pushSkills(opts?: SyncOptions): Promise<SyncResult>;
|
|
121
|
+
/** Pull team skills from Unroo down to local. */
|
|
122
|
+
export declare function pullSkills(opts?: SyncOptions): Promise<SyncResult>;
|
|
123
|
+
/** Bidirectional sync: pull then push. */
|
|
124
|
+
export declare function syncSkills(opts?: SyncOptions): Promise<SyncResult>;
|
|
125
|
+
/**
|
|
126
|
+
* Persist the user's opt-in. Called by the CLI when the user runs
|
|
127
|
+
* `fcp-mcp-server sync-skills --enable` for the first time.
|
|
128
|
+
*/
|
|
129
|
+
export declare function recordOptIn(stateFile?: string, skillsDir?: string): void;
|
|
130
|
+
/**
|
|
131
|
+
* Background entry called from the MCP server's main(). Never throws — any
|
|
132
|
+
* failure is logged and swallowed. Default behavior on a fresh workstation:
|
|
133
|
+
* dry-run only. The user opts in by running `fcp-mcp-server sync-skills --enable`.
|
|
134
|
+
*/
|
|
135
|
+
export declare function runBackgroundSync(opts?: SyncOptions): Promise<void>;
|
|
136
|
+
export declare const __test__: {
|
|
137
|
+
parseFrontmatter: typeof parseFrontmatter;
|
|
138
|
+
detectSecret: typeof detectSecret;
|
|
139
|
+
pushSkipReason: typeof pushSkipReason;
|
|
140
|
+
hashBody: typeof hashBody;
|
|
141
|
+
basename: (path: string, suffix?: string) => string;
|
|
142
|
+
};
|
|
@@ -0,0 +1,605 @@
|
|
|
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 SEARCH_PATH = '/api/external/fcp/knowledge/search';
|
|
35
|
+
const CAPTURE_PATH = '/api/external/fcp/knowledge/capture-skill';
|
|
36
|
+
// Refuse to upload bodies containing strings that look like a baked-in secret.
|
|
37
|
+
// We capture the leading "label" and the candidate "value" separately so we
|
|
38
|
+
// can reject obvious placeholder shapes (env-var names, template variables,
|
|
39
|
+
// angle-bracket / curly-brace placeholders) instead of false-positiving on them.
|
|
40
|
+
const SECRET_PATTERN = /(api[_-]?key|token|secret|password|client[_-]?secret)\s*[:=]\s*['"]?([\w\-+/]{16,})/i;
|
|
41
|
+
// Shapes that are obviously placeholders — never real secrets.
|
|
42
|
+
// We discriminate primarily by underscore presence: real high-entropy keys
|
|
43
|
+
// (AWS access keys "AKIA…", GitHub PATs "ghp_…" once stripped of the prefix,
|
|
44
|
+
// Stripe keys "sk_live_…" — wait, those *do* have underscores in the prefix —
|
|
45
|
+
// see exceptions below) typically don't have *only* uppercase letters with
|
|
46
|
+
// underscores. Env-var placeholder names always do.
|
|
47
|
+
// - M2M_CLIENT_SECRET → placeholder
|
|
48
|
+
// - JWT_SECRET → placeholder
|
|
49
|
+
// - YOUR_API_KEY → placeholder
|
|
50
|
+
// - AKIAIOSFODNN7EXAMPLE → real-shape (no underscores) — flag
|
|
51
|
+
// - aB3xY9zQ7mN2pK4LvR8t → real-shape (mixed case) — flag
|
|
52
|
+
function looksLikePlaceholder(value) {
|
|
53
|
+
// Must contain at least one underscore to be considered a placeholder.
|
|
54
|
+
if (!value.includes('_'))
|
|
55
|
+
return false;
|
|
56
|
+
// All uppercase + digits + underscores → env-var name shape.
|
|
57
|
+
if (/^[A-Z][A-Z0-9_]+$/.test(value))
|
|
58
|
+
return true;
|
|
59
|
+
// Allow lowercase too if the SHAPE is still env-var-like (snake_case_var).
|
|
60
|
+
// But require it to look like a name, not a real key. Real keys are usually
|
|
61
|
+
// long contiguous runs without underscores between every 3-5 characters.
|
|
62
|
+
if (/^[A-Za-z][A-Za-z0-9_]+$/.test(value) && /_[A-Za-z]/.test(value)) {
|
|
63
|
+
// Average segment length between underscores. Real secrets that happen to
|
|
64
|
+
// contain underscores (rare) will have one segment >= 16 chars.
|
|
65
|
+
const segments = value.split('_');
|
|
66
|
+
const longest = Math.max(...segments.map((s) => s.length));
|
|
67
|
+
if (longest < 16)
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// Skills under dirs whose name starts with one of these are treated as scratch
|
|
73
|
+
// or user-private and never synced.
|
|
74
|
+
const SCRATCH_PREFIXES = ['_', '.'];
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// State helpers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
export function defaultSkillsDir() {
|
|
79
|
+
return join(homedir(), '.claude', 'skills');
|
|
80
|
+
}
|
|
81
|
+
export function defaultStateFile(skillsDir) {
|
|
82
|
+
return join(skillsDir, '.sync-state.json');
|
|
83
|
+
}
|
|
84
|
+
export function loadState(stateFile) {
|
|
85
|
+
try {
|
|
86
|
+
if (!existsSync(stateFile)) {
|
|
87
|
+
return { version: STATE_VERSION, skills: {} };
|
|
88
|
+
}
|
|
89
|
+
const raw = readFileSync(stateFile, 'utf-8');
|
|
90
|
+
const parsed = JSON.parse(raw);
|
|
91
|
+
return {
|
|
92
|
+
version: typeof parsed.version === 'number' ? parsed.version : STATE_VERSION,
|
|
93
|
+
optedIn: parsed.optedIn === true,
|
|
94
|
+
skills: parsed.skills && typeof parsed.skills === 'object' ? parsed.skills : {},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Corrupt state file shouldn't break startup; reset.
|
|
99
|
+
return { version: STATE_VERSION, skills: {} };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function saveState(stateFile, state) {
|
|
103
|
+
const dir = stateFile.replace(/\/[^/]+$/, '');
|
|
104
|
+
if (dir && !existsSync(dir)) {
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8');
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Frontmatter parsing — minimal YAML (no external deps)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
/**
|
|
113
|
+
* Parse the YAML-ish frontmatter block from a SKILL.md.
|
|
114
|
+
*
|
|
115
|
+
* We support exactly the shape used in ~/.claude/skills/*\/SKILL.md today:
|
|
116
|
+
* - `key: value` scalars (string, boolean)
|
|
117
|
+
* - `key: |` multi-line block scalars (folded into one space-joined string)
|
|
118
|
+
* - `key:` followed by ` - item` list entries (parsed as string[])
|
|
119
|
+
* - `triggers: a, b, c` inline comma list (also parsed as string[])
|
|
120
|
+
*
|
|
121
|
+
* Anything fancier (anchors, nested maps, JSON-style flow) is out of scope —
|
|
122
|
+
* if a skill author needs that we'd pull in `js-yaml`, but every existing
|
|
123
|
+
* SKILL.md in the team library fits this subset.
|
|
124
|
+
*/
|
|
125
|
+
export function parseFrontmatter(source) {
|
|
126
|
+
if (!source.startsWith('---')) {
|
|
127
|
+
return { frontmatter: {}, body: source };
|
|
128
|
+
}
|
|
129
|
+
const end = source.indexOf('\n---', 3);
|
|
130
|
+
if (end === -1) {
|
|
131
|
+
return { frontmatter: {}, body: source };
|
|
132
|
+
}
|
|
133
|
+
const block = source.slice(3, end).replace(/^\r?\n/, '');
|
|
134
|
+
const after = source.slice(end + 4).replace(/^\r?\n/, '');
|
|
135
|
+
const lines = block.split(/\r?\n/);
|
|
136
|
+
const fm = {};
|
|
137
|
+
let i = 0;
|
|
138
|
+
while (i < lines.length) {
|
|
139
|
+
const line = lines[i];
|
|
140
|
+
if (!line.trim() || line.trim().startsWith('#')) {
|
|
141
|
+
i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
|
145
|
+
if (!m) {
|
|
146
|
+
i++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const key = m[1];
|
|
150
|
+
const rest = m[2];
|
|
151
|
+
// Block scalar: `key: |` -> read indented continuation lines
|
|
152
|
+
if (rest === '|' || rest === '>') {
|
|
153
|
+
i++;
|
|
154
|
+
const collected = [];
|
|
155
|
+
while (i < lines.length && /^\s+/.test(lines[i])) {
|
|
156
|
+
collected.push(lines[i].replace(/^\s+/, ''));
|
|
157
|
+
i++;
|
|
158
|
+
}
|
|
159
|
+
fm[key] = collected.join(' ').trim();
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// List form: `key:` then ` - item`
|
|
163
|
+
if (rest === '') {
|
|
164
|
+
i++;
|
|
165
|
+
const items = [];
|
|
166
|
+
while (i < lines.length && /^\s+-\s+/.test(lines[i])) {
|
|
167
|
+
items.push(lines[i].replace(/^\s+-\s+/, '').trim());
|
|
168
|
+
i++;
|
|
169
|
+
}
|
|
170
|
+
fm[key] = items;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// Scalar
|
|
174
|
+
let value = rest.trim();
|
|
175
|
+
if (typeof value === 'string') {
|
|
176
|
+
// Strip wrapping quotes
|
|
177
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
178
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
179
|
+
value = value.slice(1, -1);
|
|
180
|
+
}
|
|
181
|
+
// Boolean coercion for `private`
|
|
182
|
+
if (value === 'true')
|
|
183
|
+
value = true;
|
|
184
|
+
else if (value === 'false')
|
|
185
|
+
value = false;
|
|
186
|
+
// Inline comma list for `triggers`
|
|
187
|
+
else if (key === 'triggers' && value.includes(',')) {
|
|
188
|
+
value = value
|
|
189
|
+
.split(',')
|
|
190
|
+
.map((t) => t.trim())
|
|
191
|
+
.filter(Boolean);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
fm[key] = value;
|
|
195
|
+
i++;
|
|
196
|
+
}
|
|
197
|
+
// Normalize: triggers should always be string[] if provided
|
|
198
|
+
if (typeof fm.triggers === 'string') {
|
|
199
|
+
fm.triggers = fm.triggers
|
|
200
|
+
.split(',')
|
|
201
|
+
.map((t) => t.trim())
|
|
202
|
+
.filter(Boolean);
|
|
203
|
+
}
|
|
204
|
+
return { frontmatter: fm, body: after };
|
|
205
|
+
}
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Skill discovery
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
export async function discoverLocalSkills(skillsDir) {
|
|
210
|
+
if (!existsSync(skillsDir))
|
|
211
|
+
return [];
|
|
212
|
+
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
|
213
|
+
const skills = [];
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
if (!entry.isDirectory())
|
|
216
|
+
continue;
|
|
217
|
+
const slug = entry.name;
|
|
218
|
+
const skillFile = join(skillsDir, slug, 'SKILL.md');
|
|
219
|
+
if (!existsSync(skillFile))
|
|
220
|
+
continue;
|
|
221
|
+
try {
|
|
222
|
+
const stat = await fs.stat(skillFile);
|
|
223
|
+
const source = await fs.readFile(skillFile, 'utf-8');
|
|
224
|
+
const { frontmatter } = parseFrontmatter(source);
|
|
225
|
+
skills.push({
|
|
226
|
+
slug,
|
|
227
|
+
filePath: skillFile,
|
|
228
|
+
frontmatter,
|
|
229
|
+
body: source,
|
|
230
|
+
mtimeMs: stat.mtimeMs,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Unreadable SKILL.md — skip silently. We don't want a single bad file
|
|
235
|
+
// to break startup for the whole team.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return skills;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Decide whether a skill should be pushed.
|
|
242
|
+
* Returns null if pushable, or a reason string if not.
|
|
243
|
+
*/
|
|
244
|
+
export function pushSkipReason(skill) {
|
|
245
|
+
// Slug starts with digit -> user-private experiment
|
|
246
|
+
if (/^\d/.test(skill.slug))
|
|
247
|
+
return 'slug starts with digit (private experiment)';
|
|
248
|
+
// Slug starts with _ or . -> scratch
|
|
249
|
+
if (SCRATCH_PREFIXES.some((p) => skill.slug.startsWith(p))) {
|
|
250
|
+
return 'slug starts with scratch prefix';
|
|
251
|
+
}
|
|
252
|
+
// Frontmatter `private: true`
|
|
253
|
+
if (skill.frontmatter.private === true)
|
|
254
|
+
return 'frontmatter private:true';
|
|
255
|
+
// Skill has no name/description — not enough metadata to share usefully
|
|
256
|
+
if (!skill.frontmatter.name || !skill.frontmatter.description) {
|
|
257
|
+
return 'missing name or description in frontmatter';
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Detect control characters (other than tab, LF, CR) in the body. The Unroo
|
|
263
|
+
* capture-skill endpoint 500s on null bytes — and they're never legitimate
|
|
264
|
+
* skill content anyway. Returns a short description of the first hit, or
|
|
265
|
+
* null if clean.
|
|
266
|
+
*/
|
|
267
|
+
export function detectControlChars(body) {
|
|
268
|
+
// Allow \t (0x09), \n (0x0A), \r (0x0D); reject everything else < 0x20 and 0x7F.
|
|
269
|
+
const re = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
|
|
270
|
+
const m = body.match(re);
|
|
271
|
+
if (!m)
|
|
272
|
+
return null;
|
|
273
|
+
const code = m[0].charCodeAt(0);
|
|
274
|
+
const hex = code.toString(16).padStart(2, '0');
|
|
275
|
+
const offset = m.index ?? 0;
|
|
276
|
+
return `control char 0x${hex} at offset ${offset}`;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Detect apparent secrets in a skill body. Returns the matched substring
|
|
280
|
+
* (truncated) for logging, or null if clean.
|
|
281
|
+
*/
|
|
282
|
+
export function detectSecret(body) {
|
|
283
|
+
// Use a /g regex so we can iterate; the first non-placeholder match wins.
|
|
284
|
+
const re = new RegExp(SECRET_PATTERN.source, 'gi');
|
|
285
|
+
let m;
|
|
286
|
+
while ((m = re.exec(body)) !== null) {
|
|
287
|
+
const value = m[2];
|
|
288
|
+
if (looksLikePlaceholder(value))
|
|
289
|
+
continue;
|
|
290
|
+
return m[0].slice(0, 60) + (m[0].length > 60 ? '…' : '');
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
export function hashBody(body) {
|
|
295
|
+
return createHash('sha256').update(body, 'utf-8').digest('hex');
|
|
296
|
+
}
|
|
297
|
+
async function postCapture(baseUrl, apiKey, email, payload, fetchImpl) {
|
|
298
|
+
const res = await fetchImpl(`${baseUrl}${CAPTURE_PATH}`, {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: {
|
|
301
|
+
'Content-Type': 'application/json',
|
|
302
|
+
Accept: 'application/json',
|
|
303
|
+
'X-API-Key': apiKey,
|
|
304
|
+
'X-FCP-User-Email': email,
|
|
305
|
+
},
|
|
306
|
+
body: JSON.stringify(payload),
|
|
307
|
+
});
|
|
308
|
+
const body = await res.text();
|
|
309
|
+
return { ok: res.ok, status: res.status, body };
|
|
310
|
+
}
|
|
311
|
+
async function getSearch(baseUrl, apiKey, email, fetchImpl) {
|
|
312
|
+
const all = [];
|
|
313
|
+
let cursor = null;
|
|
314
|
+
// Defensive cap: 50 pages × 100 = 5000 skills, far more than the team will
|
|
315
|
+
// ever have. Prevents a runaway loop if the server forgets to clear nextCursor.
|
|
316
|
+
for (let page = 0; page < 50; page++) {
|
|
317
|
+
const qs = new URLSearchParams({ node_type: 'skill', limit: '100' });
|
|
318
|
+
if (cursor)
|
|
319
|
+
qs.set('cursor', cursor);
|
|
320
|
+
const res = await fetchImpl(`${baseUrl}${SEARCH_PATH}?${qs.toString()}`, {
|
|
321
|
+
headers: {
|
|
322
|
+
Accept: 'application/json',
|
|
323
|
+
'X-API-Key': apiKey,
|
|
324
|
+
'X-FCP-User-Email': email,
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
if (!res.ok) {
|
|
328
|
+
throw new Error(`Unroo search HTTP ${res.status}`);
|
|
329
|
+
}
|
|
330
|
+
const data = (await res.json());
|
|
331
|
+
if (data.results)
|
|
332
|
+
all.push(...data.results);
|
|
333
|
+
if (!data.nextCursor)
|
|
334
|
+
return all;
|
|
335
|
+
cursor = data.nextCursor;
|
|
336
|
+
}
|
|
337
|
+
return all;
|
|
338
|
+
}
|
|
339
|
+
async function pushOnce(ctx, state, result) {
|
|
340
|
+
const skills = await discoverLocalSkills(ctx.skillsDir);
|
|
341
|
+
for (const skill of skills) {
|
|
342
|
+
const skipReason = pushSkipReason(skill);
|
|
343
|
+
if (skipReason) {
|
|
344
|
+
result.skipped.push({ slug: skill.slug, why: skipReason });
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
// mtime-based short-circuit (cheap; covers the common no-change case)
|
|
348
|
+
const prev = state.skills[skill.slug] ?? {};
|
|
349
|
+
if (prev.lastPushedMtimeMs && prev.lastPushedMtimeMs >= skill.mtimeMs) {
|
|
350
|
+
result.skipped.push({ slug: skill.slug, why: 'unchanged since last push' });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
// Control-char guard — null bytes etc. trip the Unroo capture endpoint
|
|
354
|
+
// and are never legitimate skill content. Refuse before we even try.
|
|
355
|
+
const ctrl = detectControlChars(skill.body);
|
|
356
|
+
if (ctrl) {
|
|
357
|
+
ctx.log(`[skills-sync] REFUSED to push ${skill.slug}: body contains ${ctrl}. ` +
|
|
358
|
+
`Strip control characters from SKILL.md, then retry.`);
|
|
359
|
+
result.refused.push({ slug: skill.slug, why: `control char: ${ctrl}` });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
// Secret guard
|
|
363
|
+
const secret = detectSecret(skill.body);
|
|
364
|
+
if (secret) {
|
|
365
|
+
ctx.log(`[skills-sync] REFUSED to push ${skill.slug}: body contains apparent secret (${secret}). ` +
|
|
366
|
+
`Edit SKILL.md to redact, then retry.`);
|
|
367
|
+
result.refused.push({ slug: skill.slug, why: `secret detected: ${secret}` });
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const payload = {
|
|
371
|
+
skillSlug: skill.slug,
|
|
372
|
+
name: String(skill.frontmatter.name ?? skill.slug),
|
|
373
|
+
description: String(skill.frontmatter.description ?? ''),
|
|
374
|
+
triggers: Array.isArray(skill.frontmatter.triggers)
|
|
375
|
+
? skill.frontmatter.triggers
|
|
376
|
+
: undefined,
|
|
377
|
+
bodyMarkdown: skill.body,
|
|
378
|
+
sourceRepo: 'claude-skills-local',
|
|
379
|
+
};
|
|
380
|
+
if (ctx.dryRun) {
|
|
381
|
+
ctx.log(`[skills-sync] DRY RUN: would push ${skill.slug}`);
|
|
382
|
+
result.pushed.push(skill.slug);
|
|
383
|
+
// Still update state under dry run? No — we want a real push to update.
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const res = await postCapture(ctx.baseUrl, ctx.apiKey, ctx.email, payload, ctx.fetchImpl);
|
|
388
|
+
if (!res.ok) {
|
|
389
|
+
ctx.log(`[skills-sync] push ${skill.slug} failed (HTTP ${res.status}): ${res.body.slice(0, 200)}`);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
result.pushed.push(skill.slug);
|
|
393
|
+
state.skills[skill.slug] = {
|
|
394
|
+
...prev,
|
|
395
|
+
lastPushedMtimeMs: skill.mtimeMs,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
ctx.log(`[skills-sync] push ${skill.slug} threw: ${err.message}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
async function pullOnce(ctx, state, result) {
|
|
404
|
+
let remoteSkills;
|
|
405
|
+
try {
|
|
406
|
+
remoteSkills = await getSearch(ctx.baseUrl, ctx.apiKey, ctx.email, ctx.fetchImpl);
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
ctx.log(`[skills-sync] pull failed: ${err.message}`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
for (const node of remoteSkills) {
|
|
413
|
+
const slug = node.source_id || node.name;
|
|
414
|
+
if (!slug)
|
|
415
|
+
continue;
|
|
416
|
+
const props = node.properties ?? {};
|
|
417
|
+
const bodyMarkdown = typeof props.bodyMarkdown === 'string' ? props.bodyMarkdown : '';
|
|
418
|
+
if (!bodyMarkdown) {
|
|
419
|
+
result.skipped.push({ slug, why: 'remote has no bodyMarkdown' });
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const localPath = join(ctx.skillsDir, slug, 'SKILL.md');
|
|
423
|
+
const existed = existsSync(localPath);
|
|
424
|
+
const prev = state.skills[slug] ?? {};
|
|
425
|
+
// Local doesn't exist — straight pull (clean install or new team skill)
|
|
426
|
+
if (!existed) {
|
|
427
|
+
if (ctx.dryRun) {
|
|
428
|
+
ctx.log(`[skills-sync] DRY RUN: would create ${slug}`);
|
|
429
|
+
result.pulled.push(slug);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const dir = join(ctx.skillsDir, slug);
|
|
433
|
+
if (!existsSync(dir))
|
|
434
|
+
mkdirSync(dir, { recursive: true });
|
|
435
|
+
writeFileSync(localPath, bodyMarkdown, 'utf-8');
|
|
436
|
+
state.skills[slug] = {
|
|
437
|
+
...prev,
|
|
438
|
+
lastPulledBodyHash: hashBody(bodyMarkdown),
|
|
439
|
+
lastPulledRemoteUpdatedAt: node.updated_at,
|
|
440
|
+
};
|
|
441
|
+
result.pulled.push(slug);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
// Local exists — only overwrite if remote is strictly newer AND user
|
|
445
|
+
// hasn't edited locally since the last pull. This is the three-way
|
|
446
|
+
// merge: lastPulledBodyHash + current local hash + remote.
|
|
447
|
+
const localBody = readFileSync(localPath, 'utf-8');
|
|
448
|
+
const localHash = hashBody(localBody);
|
|
449
|
+
const remoteUpdated = node.updated_at;
|
|
450
|
+
const remoteIsNewer = !prev.lastPulledRemoteUpdatedAt ||
|
|
451
|
+
Date.parse(remoteUpdated) > Date.parse(prev.lastPulledRemoteUpdatedAt);
|
|
452
|
+
if (!remoteIsNewer) {
|
|
453
|
+
result.skipped.push({ slug, why: 'remote not newer' });
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const localUntouched = prev.lastPulledBodyHash !== undefined && prev.lastPulledBodyHash === localHash;
|
|
457
|
+
if (!localUntouched) {
|
|
458
|
+
ctx.log(`[skills-sync] skipping ${slug}: local edits since last pull (conflict). ` +
|
|
459
|
+
`Remote updated_at=${remoteUpdated}; resolve by either pushing your edits or ` +
|
|
460
|
+
`deleting local SKILL.md to accept remote.`);
|
|
461
|
+
result.conflicts.push({ slug, why: 'local edits since last pull' });
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (ctx.dryRun) {
|
|
465
|
+
ctx.log(`[skills-sync] DRY RUN: would update ${slug} from remote`);
|
|
466
|
+
result.pulled.push(slug);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
writeFileSync(localPath, bodyMarkdown, 'utf-8');
|
|
470
|
+
state.skills[slug] = {
|
|
471
|
+
...prev,
|
|
472
|
+
lastPulledBodyHash: hashBody(bodyMarkdown),
|
|
473
|
+
lastPulledRemoteUpdatedAt: remoteUpdated,
|
|
474
|
+
};
|
|
475
|
+
result.pulled.push(slug);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// Public entrypoints
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
function makeCtx(opts, dryRunOverride) {
|
|
482
|
+
const skillsDir = opts.skillsDir ?? defaultSkillsDir();
|
|
483
|
+
const stateFile = opts.stateFile ?? defaultStateFile(skillsDir);
|
|
484
|
+
return {
|
|
485
|
+
skillsDir,
|
|
486
|
+
stateFile,
|
|
487
|
+
baseUrl: opts.unrooApiUrl ?? DEFAULT_UNROO_URL,
|
|
488
|
+
apiKey: opts.unrooApiKey ?? '',
|
|
489
|
+
email: opts.userEmail ?? '',
|
|
490
|
+
fetchImpl: opts.fetchImpl ?? fetch,
|
|
491
|
+
log: opts.log ?? ((m) => console.error(m)),
|
|
492
|
+
dryRun: dryRunOverride,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function emptyResult(dryRun) {
|
|
496
|
+
return {
|
|
497
|
+
ok: true,
|
|
498
|
+
pushed: [],
|
|
499
|
+
pulled: [],
|
|
500
|
+
skipped: [],
|
|
501
|
+
conflicts: [],
|
|
502
|
+
refused: [],
|
|
503
|
+
dryRun,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/** Push local skills up to Unroo. */
|
|
507
|
+
export async function pushSkills(opts = {}) {
|
|
508
|
+
const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
|
|
509
|
+
// Fresh workstation -> force dry-run unless explicitly opted in OR caller
|
|
510
|
+
// passed dryRun:false meaning "I know what I'm doing".
|
|
511
|
+
const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
|
|
512
|
+
const ctx = makeCtx(opts, dryRun);
|
|
513
|
+
const result = emptyResult(dryRun);
|
|
514
|
+
if (!ctx.apiKey || !ctx.email) {
|
|
515
|
+
ctx.log('[skills-sync] skipping push: UNROO_API_KEY or FCP_USER_EMAIL not set');
|
|
516
|
+
result.ok = false;
|
|
517
|
+
result.reason = 'missing auth';
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
await pushOnce(ctx, state, result);
|
|
521
|
+
saveState(ctx.stateFile, state);
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
/** Pull team skills from Unroo down to local. */
|
|
525
|
+
export async function pullSkills(opts = {}) {
|
|
526
|
+
const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
|
|
527
|
+
const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
|
|
528
|
+
const ctx = makeCtx(opts, dryRun);
|
|
529
|
+
const result = emptyResult(dryRun);
|
|
530
|
+
if (!ctx.apiKey || !ctx.email) {
|
|
531
|
+
ctx.log('[skills-sync] skipping pull: UNROO_API_KEY or FCP_USER_EMAIL not set');
|
|
532
|
+
result.ok = false;
|
|
533
|
+
result.reason = 'missing auth';
|
|
534
|
+
return result;
|
|
535
|
+
}
|
|
536
|
+
await pullOnce(ctx, state, result);
|
|
537
|
+
saveState(ctx.stateFile, state);
|
|
538
|
+
return result;
|
|
539
|
+
}
|
|
540
|
+
/** Bidirectional sync: pull then push. */
|
|
541
|
+
export async function syncSkills(opts = {}) {
|
|
542
|
+
const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
|
|
543
|
+
const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
|
|
544
|
+
const ctx = makeCtx(opts, dryRun);
|
|
545
|
+
const result = emptyResult(dryRun);
|
|
546
|
+
if (!ctx.apiKey || !ctx.email) {
|
|
547
|
+
ctx.log('[skills-sync] skipping sync: UNROO_API_KEY or FCP_USER_EMAIL not set');
|
|
548
|
+
result.ok = false;
|
|
549
|
+
result.reason = 'missing auth';
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
// Pull first so a brand-new workstation gets the team library before
|
|
553
|
+
// pushing anything; on a long-lived workstation the pull is mostly no-ops.
|
|
554
|
+
await pullOnce(ctx, state, result);
|
|
555
|
+
await pushOnce(ctx, state, result);
|
|
556
|
+
saveState(ctx.stateFile, state);
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Persist the user's opt-in. Called by the CLI when the user runs
|
|
561
|
+
* `fcp-mcp-server sync-skills --enable` for the first time.
|
|
562
|
+
*/
|
|
563
|
+
export function recordOptIn(stateFile, skillsDir) {
|
|
564
|
+
const dir = skillsDir ?? defaultSkillsDir();
|
|
565
|
+
const file = stateFile ?? defaultStateFile(dir);
|
|
566
|
+
if (!existsSync(dir))
|
|
567
|
+
mkdirSync(dir, { recursive: true });
|
|
568
|
+
const state = loadState(file);
|
|
569
|
+
state.optedIn = true;
|
|
570
|
+
saveState(file, state);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Background entry called from the MCP server's main(). Never throws — any
|
|
574
|
+
* failure is logged and swallowed. Default behavior on a fresh workstation:
|
|
575
|
+
* dry-run only. The user opts in by running `fcp-mcp-server sync-skills --enable`.
|
|
576
|
+
*/
|
|
577
|
+
export async function runBackgroundSync(opts = {}) {
|
|
578
|
+
try {
|
|
579
|
+
const result = await syncSkills(opts);
|
|
580
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
581
|
+
const tag = result.dryRun ? '[skills-sync] (dry run)' : '[skills-sync]';
|
|
582
|
+
if (result.reason) {
|
|
583
|
+
log(`${tag} ${result.reason}`);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
log(`${tag} pulled=${result.pulled.length} pushed=${result.pushed.length} ` +
|
|
587
|
+
`skipped=${result.skipped.length} conflicts=${result.conflicts.length} ` +
|
|
588
|
+
`refused=${result.refused.length}`);
|
|
589
|
+
if (result.dryRun && (result.pulled.length || result.pushed.length)) {
|
|
590
|
+
log('[skills-sync] To enable real sync, run: fcp-mcp-server sync-skills --enable');
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
595
|
+
log(`[skills-sync] background sync error (ignored): ${err.message}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// Exported for testing
|
|
599
|
+
export const __test__ = {
|
|
600
|
+
parseFrontmatter,
|
|
601
|
+
detectSecret,
|
|
602
|
+
pushSkipReason,
|
|
603
|
+
hashBody,
|
|
604
|
+
basename, // re-export so tests can verify path handling without importing path
|
|
605
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruition/fcp-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.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",
|