facult 1.0.1
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/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/facult.cjs +302 -0
- package/package.json +78 -0
- package/src/adapters/claude-cli.ts +18 -0
- package/src/adapters/claude-desktop.ts +15 -0
- package/src/adapters/clawdbot.ts +18 -0
- package/src/adapters/codex.ts +19 -0
- package/src/adapters/cursor.ts +18 -0
- package/src/adapters/index.ts +69 -0
- package/src/adapters/mcp.ts +270 -0
- package/src/adapters/reference.ts +9 -0
- package/src/adapters/skills.ts +47 -0
- package/src/adapters/types.ts +42 -0
- package/src/adapters/version.ts +18 -0
- package/src/audit/agent.ts +1071 -0
- package/src/audit/index.ts +74 -0
- package/src/audit/static.ts +1130 -0
- package/src/audit/tui.ts +704 -0
- package/src/audit/types.ts +68 -0
- package/src/audit/update-index.ts +115 -0
- package/src/conflicts.ts +135 -0
- package/src/consolidate-conflict-action.ts +57 -0
- package/src/consolidate.ts +1637 -0
- package/src/enable-disable.ts +349 -0
- package/src/index-builder.ts +562 -0
- package/src/index.ts +589 -0
- package/src/manage.ts +894 -0
- package/src/migrate.ts +272 -0
- package/src/paths.ts +238 -0
- package/src/quarantine.ts +217 -0
- package/src/query.ts +186 -0
- package/src/remote-manifest-integrity.ts +367 -0
- package/src/remote-providers.ts +905 -0
- package/src/remote-source-policy.ts +237 -0
- package/src/remote-sources.ts +162 -0
- package/src/remote-types.ts +136 -0
- package/src/remote.ts +1970 -0
- package/src/scan.ts +2427 -0
- package/src/schema.ts +39 -0
- package/src/self-update.ts +408 -0
- package/src/snippets-cli.ts +293 -0
- package/src/snippets.ts +706 -0
- package/src/source-trust.ts +203 -0
- package/src/trust-list.ts +232 -0
- package/src/trust.ts +170 -0
- package/src/tui.ts +118 -0
- package/src/util/codex-toml.ts +126 -0
- package/src/util/json.ts +32 -0
- package/src/util/skills.ts +55 -0
package/src/manage.ts
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lstat,
|
|
3
|
+
mkdir,
|
|
4
|
+
readdir,
|
|
5
|
+
readlink,
|
|
6
|
+
rename,
|
|
7
|
+
rm,
|
|
8
|
+
symlink,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { getAdapter } from "./adapters";
|
|
13
|
+
import { facultRootDir } from "./paths";
|
|
14
|
+
|
|
15
|
+
export interface ManagedToolState {
|
|
16
|
+
tool: string;
|
|
17
|
+
managedAt: string;
|
|
18
|
+
skillsDir?: string;
|
|
19
|
+
mcpConfig?: string;
|
|
20
|
+
skillsBackup?: string | null;
|
|
21
|
+
mcpBackup?: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ManagedState {
|
|
25
|
+
version: 1;
|
|
26
|
+
tools: Record<string, ManagedToolState>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ToolPaths {
|
|
30
|
+
tool: string;
|
|
31
|
+
skillsDir?: string;
|
|
32
|
+
mcpConfig?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ManageOptions {
|
|
36
|
+
homeDir?: string;
|
|
37
|
+
rootDir?: string;
|
|
38
|
+
toolPaths?: Record<string, ToolPaths>;
|
|
39
|
+
now?: () => Date;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SyncOptions {
|
|
43
|
+
homeDir?: string;
|
|
44
|
+
rootDir?: string;
|
|
45
|
+
tool?: string;
|
|
46
|
+
dryRun?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MANAGED_VERSION = 1 as const;
|
|
50
|
+
|
|
51
|
+
function nowIso(now?: () => Date): string {
|
|
52
|
+
return (now ? now() : new Date()).toISOString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function homePath(home: string, ...parts: string[]): string {
|
|
56
|
+
return join(home, ...parts);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function expandHomePath(pathValue: string, home: string): string {
|
|
60
|
+
if (pathValue === "~") {
|
|
61
|
+
return home;
|
|
62
|
+
}
|
|
63
|
+
if (pathValue.startsWith("~/")) {
|
|
64
|
+
return join(home, pathValue.slice(2));
|
|
65
|
+
}
|
|
66
|
+
return pathValue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
70
|
+
try {
|
|
71
|
+
await Bun.file(p).stat();
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function ensureDir(p: string) {
|
|
79
|
+
await mkdir(p, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function defaultToolPaths(home: string): Record<string, ToolPaths> {
|
|
83
|
+
const defaults: Record<string, ToolPaths> = {
|
|
84
|
+
cursor: {
|
|
85
|
+
tool: "cursor",
|
|
86
|
+
skillsDir: homePath(home, ".cursor", "skills"),
|
|
87
|
+
mcpConfig: homePath(home, ".cursor", "mcp.json"),
|
|
88
|
+
},
|
|
89
|
+
codex: {
|
|
90
|
+
tool: "codex",
|
|
91
|
+
skillsDir: homePath(home, ".codex", "skills"),
|
|
92
|
+
mcpConfig: homePath(home, ".codex", "mcp.json"),
|
|
93
|
+
},
|
|
94
|
+
claude: {
|
|
95
|
+
tool: "claude",
|
|
96
|
+
skillsDir: homePath(home, ".claude", "skills"),
|
|
97
|
+
mcpConfig: homePath(home, ".claude.json"),
|
|
98
|
+
},
|
|
99
|
+
"claude-desktop": {
|
|
100
|
+
tool: "claude-desktop",
|
|
101
|
+
mcpConfig: homePath(
|
|
102
|
+
home,
|
|
103
|
+
"Library",
|
|
104
|
+
"Application Support",
|
|
105
|
+
"Claude",
|
|
106
|
+
"claude_desktop_config.json"
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
clawdbot: {
|
|
110
|
+
tool: "clawdbot",
|
|
111
|
+
skillsDir: homePath(home, ".clawdbot", "skills"),
|
|
112
|
+
mcpConfig: homePath(home, ".clawdbot", "mcp.json"),
|
|
113
|
+
},
|
|
114
|
+
gemini: {
|
|
115
|
+
tool: "gemini",
|
|
116
|
+
skillsDir: homePath(home, ".gemini", "skills"),
|
|
117
|
+
mcpConfig: homePath(home, ".gemini", "mcp.json"),
|
|
118
|
+
},
|
|
119
|
+
antigravity: {
|
|
120
|
+
tool: "antigravity",
|
|
121
|
+
skillsDir: homePath(home, ".antigravity", "skills"),
|
|
122
|
+
mcpConfig: homePath(home, ".antigravity", "mcp.json"),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const adapterDefaults = (tool: string): ToolPaths | null => {
|
|
127
|
+
const adapter = getAdapter(tool);
|
|
128
|
+
if (!adapter?.getDefaultPaths) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const paths = adapter.getDefaultPaths();
|
|
132
|
+
const rawSkills = paths?.skills;
|
|
133
|
+
const skillsDir = Array.isArray(rawSkills)
|
|
134
|
+
? rawSkills[0]
|
|
135
|
+
: (rawSkills ?? undefined);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
tool,
|
|
139
|
+
skillsDir: skillsDir ? expandHomePath(skillsDir, home) : undefined,
|
|
140
|
+
mcpConfig: paths?.mcp ? expandHomePath(paths.mcp, home) : undefined,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
for (const tool of ["cursor", "codex"]) {
|
|
145
|
+
const adapterPath = adapterDefaults(tool);
|
|
146
|
+
if (adapterPath) {
|
|
147
|
+
defaults[tool] = {
|
|
148
|
+
...defaults[tool],
|
|
149
|
+
...adapterPath,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return defaults;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function resolveToolPaths(
|
|
158
|
+
tool: string,
|
|
159
|
+
home: string,
|
|
160
|
+
override?: Record<string, ToolPaths>
|
|
161
|
+
): Promise<ToolPaths | null> {
|
|
162
|
+
if (override?.[tool]) {
|
|
163
|
+
return override[tool] ?? null;
|
|
164
|
+
}
|
|
165
|
+
const defaults = defaultToolPaths(home);
|
|
166
|
+
const base = defaults[tool] ?? null;
|
|
167
|
+
if (!base) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if (tool !== "codex") {
|
|
171
|
+
return base;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const adapterPaths = getAdapter("codex")?.getDefaultPaths?.();
|
|
175
|
+
const adapterConfig = adapterPaths?.config
|
|
176
|
+
? expandHomePath(adapterPaths.config, home)
|
|
177
|
+
: null;
|
|
178
|
+
|
|
179
|
+
const candidates = [
|
|
180
|
+
adapterConfig,
|
|
181
|
+
homePath(home, ".config", "openai", "codex.json"),
|
|
182
|
+
homePath(home, ".codex", "config.json"),
|
|
183
|
+
homePath(home, ".codex", "mcp.json"),
|
|
184
|
+
].filter((value): value is string => Boolean(value));
|
|
185
|
+
|
|
186
|
+
for (const candidate of candidates) {
|
|
187
|
+
if (await fileExists(candidate)) {
|
|
188
|
+
return { ...base, mcpConfig: candidate };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return base;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function managedStatePath(home: string = homedir()): string {
|
|
196
|
+
return homePath(home, ".facult", "managed.json");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function loadManagedState(
|
|
200
|
+
home: string = homedir()
|
|
201
|
+
): Promise<ManagedState> {
|
|
202
|
+
const p = managedStatePath(home);
|
|
203
|
+
if (!(await fileExists(p))) {
|
|
204
|
+
return { version: MANAGED_VERSION, tools: {} };
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const txt = await Bun.file(p).text();
|
|
208
|
+
const data = JSON.parse(txt) as Partial<ManagedState> | null;
|
|
209
|
+
if (data?.version === MANAGED_VERSION && data.tools) {
|
|
210
|
+
return { version: MANAGED_VERSION, tools: data.tools };
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// fallthrough
|
|
214
|
+
}
|
|
215
|
+
return { version: MANAGED_VERSION, tools: {} };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function saveManagedState(
|
|
219
|
+
state: ManagedState,
|
|
220
|
+
home: string = homedir()
|
|
221
|
+
) {
|
|
222
|
+
const dir = homePath(home, ".facult");
|
|
223
|
+
await ensureDir(dir);
|
|
224
|
+
await Bun.write(
|
|
225
|
+
managedStatePath(home),
|
|
226
|
+
`${JSON.stringify(state, null, 2)}\n`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function nextBackupPath(base: string, now?: () => Date): Promise<string> {
|
|
231
|
+
const first = `${base}.bak`;
|
|
232
|
+
if (!(await fileExists(first))) {
|
|
233
|
+
return first;
|
|
234
|
+
}
|
|
235
|
+
const stamp = nowIso(now).replace(/[:.]/g, "-");
|
|
236
|
+
return `${first}.${stamp}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function backupPath(
|
|
240
|
+
base: string,
|
|
241
|
+
now?: () => Date
|
|
242
|
+
): Promise<string | null> {
|
|
243
|
+
if (!(await fileExists(base))) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const backup = await nextBackupPath(base, now);
|
|
247
|
+
await rename(base, backup);
|
|
248
|
+
if (!(await fileExists(backup))) {
|
|
249
|
+
throw new Error(`Backup failed for ${base}`);
|
|
250
|
+
}
|
|
251
|
+
return backup;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
255
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function listSkillDirs(skillsRoot: string): Promise<string[]> {
|
|
259
|
+
try {
|
|
260
|
+
const entries = await readdir(skillsRoot, { withFileTypes: true });
|
|
261
|
+
return entries
|
|
262
|
+
.filter((entry) => entry.isDirectory())
|
|
263
|
+
.map((entry) => entry.name)
|
|
264
|
+
.sort();
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function skillNamesFromIndex(
|
|
271
|
+
indexData: Record<string, unknown>,
|
|
272
|
+
tool: string
|
|
273
|
+
): string[] {
|
|
274
|
+
const skills = indexData.skills as Record<string, unknown> | undefined;
|
|
275
|
+
if (!skills) {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
const names: string[] = [];
|
|
279
|
+
for (const [name, entry] of Object.entries(skills)) {
|
|
280
|
+
if (!isPlainObject(entry)) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const enabledFor = entry.enabledFor;
|
|
284
|
+
if (Array.isArray(enabledFor)) {
|
|
285
|
+
if (enabledFor.includes(tool)) {
|
|
286
|
+
names.push(name);
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
names.push(name);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return names.sort();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function loadEnabledSkillNames({
|
|
296
|
+
rootDir,
|
|
297
|
+
tool,
|
|
298
|
+
}: {
|
|
299
|
+
rootDir: string;
|
|
300
|
+
tool: string;
|
|
301
|
+
}): Promise<string[]> {
|
|
302
|
+
const indexPath = join(rootDir, "index.json");
|
|
303
|
+
if (await fileExists(indexPath)) {
|
|
304
|
+
try {
|
|
305
|
+
const txt = await Bun.file(indexPath).text();
|
|
306
|
+
const parsed = JSON.parse(txt) as Record<string, unknown>;
|
|
307
|
+
const names = skillNamesFromIndex(parsed, tool);
|
|
308
|
+
if (names.length) {
|
|
309
|
+
return names;
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
// fallthrough to directory listing
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return await listSkillDirs(join(rootDir, "skills"));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function extractServersObject(parsed: unknown): Record<string, unknown> | null {
|
|
319
|
+
if (!isPlainObject(parsed)) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
const raw = parsed as Record<string, unknown>;
|
|
323
|
+
const servers =
|
|
324
|
+
(raw.servers as Record<string, unknown> | undefined) ??
|
|
325
|
+
(raw.mcpServers as Record<string, unknown> | undefined) ??
|
|
326
|
+
((raw.mcp as Record<string, unknown> | undefined)?.servers as
|
|
327
|
+
| Record<string, unknown>
|
|
328
|
+
| undefined) ??
|
|
329
|
+
null;
|
|
330
|
+
if (servers && isPlainObject(servers)) {
|
|
331
|
+
return servers;
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function canonicalServerToToolConfig(server: unknown): unknown {
|
|
337
|
+
if (!isPlainObject(server)) {
|
|
338
|
+
return server;
|
|
339
|
+
}
|
|
340
|
+
const raw = server as Record<string, unknown>;
|
|
341
|
+
const out: Record<string, unknown> = {};
|
|
342
|
+
const excluded = new Set([
|
|
343
|
+
"name",
|
|
344
|
+
"provenance",
|
|
345
|
+
"enabledFor",
|
|
346
|
+
"trusted",
|
|
347
|
+
"auditStatus",
|
|
348
|
+
"vendorExtensions",
|
|
349
|
+
]);
|
|
350
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
351
|
+
if (!excluded.has(k)) {
|
|
352
|
+
out[k] = v;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const vendor = raw.vendorExtensions;
|
|
356
|
+
if (isPlainObject(vendor)) {
|
|
357
|
+
for (const [k, v] of Object.entries(vendor)) {
|
|
358
|
+
out[k] = v;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function filterServersForTool(
|
|
365
|
+
servers: Record<string, unknown>,
|
|
366
|
+
tool: string
|
|
367
|
+
): Record<string, unknown> {
|
|
368
|
+
const out: Record<string, unknown> = {};
|
|
369
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
370
|
+
if (isPlainObject(cfg)) {
|
|
371
|
+
const enabledFor = cfg.enabledFor;
|
|
372
|
+
if (Array.isArray(enabledFor) && !enabledFor.includes(tool)) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
out[name] = canonicalServerToToolConfig(cfg);
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function loadCanonicalServers(rootDir: string): Promise<{
|
|
382
|
+
servers: Record<string, unknown>;
|
|
383
|
+
sourcePath: string | null;
|
|
384
|
+
}> {
|
|
385
|
+
const serversPath = join(rootDir, "mcp", "servers.json");
|
|
386
|
+
const mcpPath = join(rootDir, "mcp", "mcp.json");
|
|
387
|
+
|
|
388
|
+
const preferred = (await fileExists(serversPath)) ? serversPath : mcpPath;
|
|
389
|
+
if (!(await fileExists(preferred))) {
|
|
390
|
+
return { servers: {}, sourcePath: null };
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const txt = await Bun.file(preferred).text();
|
|
394
|
+
const parsed = JSON.parse(txt) as unknown;
|
|
395
|
+
const servers = extractServersObject(parsed) ?? {};
|
|
396
|
+
return { servers, sourcePath: preferred };
|
|
397
|
+
} catch {
|
|
398
|
+
return { servers: {}, sourcePath: preferred };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function ensureEmptyDir(p: string) {
|
|
403
|
+
await rm(p, { recursive: true, force: true });
|
|
404
|
+
await ensureDir(p);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function createSkillSymlinks({
|
|
408
|
+
toolSkillsDir,
|
|
409
|
+
rootDir,
|
|
410
|
+
tool,
|
|
411
|
+
}: {
|
|
412
|
+
toolSkillsDir: string;
|
|
413
|
+
rootDir: string;
|
|
414
|
+
tool: string;
|
|
415
|
+
}) {
|
|
416
|
+
await ensureDir(toolSkillsDir);
|
|
417
|
+
const skillNames = await loadEnabledSkillNames({ rootDir, tool });
|
|
418
|
+
for (const name of skillNames) {
|
|
419
|
+
const target = join(rootDir, "skills", name);
|
|
420
|
+
if (!(await fileExists(target))) {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const linkPath = join(toolSkillsDir, name);
|
|
424
|
+
try {
|
|
425
|
+
const st = await lstat(linkPath);
|
|
426
|
+
if (st.isSymbolicLink()) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
await rm(linkPath, { recursive: true, force: true });
|
|
430
|
+
} catch {
|
|
431
|
+
// not exists
|
|
432
|
+
}
|
|
433
|
+
await symlink(target, linkPath, "dir");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function planSkillSymlinkChanges({
|
|
438
|
+
toolSkillsDir,
|
|
439
|
+
rootDir,
|
|
440
|
+
tool,
|
|
441
|
+
}: {
|
|
442
|
+
toolSkillsDir: string;
|
|
443
|
+
rootDir: string;
|
|
444
|
+
tool: string;
|
|
445
|
+
}): Promise<{ add: string[]; remove: string[] }> {
|
|
446
|
+
const desired = await loadEnabledSkillNames({ rootDir, tool });
|
|
447
|
+
const desiredSet = new Set(desired);
|
|
448
|
+
const existing = await readdir(toolSkillsDir, { withFileTypes: true }).catch(
|
|
449
|
+
() => [] as import("node:fs").Dirent[]
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const remove: string[] = [];
|
|
453
|
+
const add: string[] = [];
|
|
454
|
+
|
|
455
|
+
for (const entry of existing) {
|
|
456
|
+
if (!desiredSet.has(entry.name)) {
|
|
457
|
+
remove.push(entry.name);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const linkPath = join(toolSkillsDir, entry.name);
|
|
461
|
+
const target = join(rootDir, "skills", entry.name);
|
|
462
|
+
try {
|
|
463
|
+
const st = await lstat(linkPath);
|
|
464
|
+
if (!st.isSymbolicLink()) {
|
|
465
|
+
remove.push(entry.name);
|
|
466
|
+
add.push(entry.name);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const current = await readlink(linkPath);
|
|
470
|
+
if (current !== target) {
|
|
471
|
+
remove.push(entry.name);
|
|
472
|
+
add.push(entry.name);
|
|
473
|
+
}
|
|
474
|
+
} catch {
|
|
475
|
+
add.push(entry.name);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
for (const name of desired) {
|
|
480
|
+
if (existing.find((entry) => entry.name === name)) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const target = join(rootDir, "skills", name);
|
|
484
|
+
if (await fileExists(target)) {
|
|
485
|
+
add.push(name);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
add: Array.from(new Set(add)).sort(),
|
|
491
|
+
remove: Array.from(new Set(remove)).sort(),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function syncSkillSymlinks({
|
|
496
|
+
toolSkillsDir,
|
|
497
|
+
rootDir,
|
|
498
|
+
tool,
|
|
499
|
+
dryRun,
|
|
500
|
+
}: {
|
|
501
|
+
toolSkillsDir: string;
|
|
502
|
+
rootDir: string;
|
|
503
|
+
tool: string;
|
|
504
|
+
dryRun?: boolean;
|
|
505
|
+
}): Promise<{ add: string[]; remove: string[] }> {
|
|
506
|
+
const plan = await planSkillSymlinkChanges({ toolSkillsDir, rootDir, tool });
|
|
507
|
+
if (dryRun) {
|
|
508
|
+
return plan;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
await ensureDir(toolSkillsDir);
|
|
512
|
+
for (const name of plan.remove) {
|
|
513
|
+
const linkPath = join(toolSkillsDir, name);
|
|
514
|
+
await rm(linkPath, { recursive: true, force: true });
|
|
515
|
+
}
|
|
516
|
+
for (const name of plan.add) {
|
|
517
|
+
const target = join(rootDir, "skills", name);
|
|
518
|
+
if (!(await fileExists(target))) {
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const linkPath = join(toolSkillsDir, name);
|
|
522
|
+
await symlink(target, linkPath, "dir");
|
|
523
|
+
}
|
|
524
|
+
return plan;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function planMcpWrite({
|
|
528
|
+
mcpConfigPath,
|
|
529
|
+
rootDir,
|
|
530
|
+
tool,
|
|
531
|
+
}: {
|
|
532
|
+
mcpConfigPath: string;
|
|
533
|
+
rootDir: string;
|
|
534
|
+
tool: string;
|
|
535
|
+
}): Promise<{ needsWrite: boolean; contents: string }> {
|
|
536
|
+
const { servers } = await loadCanonicalServers(rootDir);
|
|
537
|
+
const filtered = filterServersForTool(servers, tool);
|
|
538
|
+
const contents = `${JSON.stringify({ mcpServers: filtered }, null, 2)}\n`;
|
|
539
|
+
|
|
540
|
+
if (!(await fileExists(mcpConfigPath))) {
|
|
541
|
+
return { needsWrite: true, contents };
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const current = await Bun.file(mcpConfigPath).text();
|
|
545
|
+
return { needsWrite: current !== contents, contents };
|
|
546
|
+
} catch {
|
|
547
|
+
return { needsWrite: true, contents };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function syncMcpConfig({
|
|
552
|
+
mcpConfigPath,
|
|
553
|
+
rootDir,
|
|
554
|
+
tool,
|
|
555
|
+
dryRun,
|
|
556
|
+
}: {
|
|
557
|
+
mcpConfigPath: string;
|
|
558
|
+
rootDir: string;
|
|
559
|
+
tool: string;
|
|
560
|
+
dryRun?: boolean;
|
|
561
|
+
}): Promise<{ needsWrite: boolean }> {
|
|
562
|
+
const plan = await planMcpWrite({ mcpConfigPath, rootDir, tool });
|
|
563
|
+
if (dryRun) {
|
|
564
|
+
return { needsWrite: plan.needsWrite };
|
|
565
|
+
}
|
|
566
|
+
if (plan.needsWrite) {
|
|
567
|
+
await ensureDir(dirname(mcpConfigPath));
|
|
568
|
+
await Bun.write(mcpConfigPath, plan.contents);
|
|
569
|
+
}
|
|
570
|
+
return { needsWrite: plan.needsWrite };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function writeToolMcpConfig({
|
|
574
|
+
mcpConfigPath,
|
|
575
|
+
rootDir,
|
|
576
|
+
tool,
|
|
577
|
+
}: {
|
|
578
|
+
mcpConfigPath: string;
|
|
579
|
+
rootDir: string;
|
|
580
|
+
tool: string;
|
|
581
|
+
}) {
|
|
582
|
+
const { servers } = await loadCanonicalServers(rootDir);
|
|
583
|
+
const filtered = filterServersForTool(servers, tool);
|
|
584
|
+
await ensureDir(dirname(mcpConfigPath));
|
|
585
|
+
await Bun.write(
|
|
586
|
+
mcpConfigPath,
|
|
587
|
+
`${JSON.stringify({ mcpServers: filtered }, null, 2)}\n`
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export async function manageTool(tool: string, opts: ManageOptions = {}) {
|
|
592
|
+
const home = opts.homeDir ?? homedir();
|
|
593
|
+
const rootDir = opts.rootDir ?? facultRootDir(home);
|
|
594
|
+
const state = await loadManagedState(home);
|
|
595
|
+
|
|
596
|
+
if (state.tools[tool]) {
|
|
597
|
+
throw new Error(`${tool} is already managed`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const toolPaths = await resolveToolPaths(tool, home, opts.toolPaths);
|
|
601
|
+
if (!toolPaths) {
|
|
602
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const skillsBackup = toolPaths.skillsDir
|
|
606
|
+
? await backupPath(toolPaths.skillsDir, opts.now)
|
|
607
|
+
: null;
|
|
608
|
+
const mcpBackup = toolPaths.mcpConfig
|
|
609
|
+
? await backupPath(toolPaths.mcpConfig, opts.now)
|
|
610
|
+
: null;
|
|
611
|
+
|
|
612
|
+
if (toolPaths.skillsDir) {
|
|
613
|
+
await ensureEmptyDir(toolPaths.skillsDir);
|
|
614
|
+
await createSkillSymlinks({
|
|
615
|
+
toolSkillsDir: toolPaths.skillsDir,
|
|
616
|
+
rootDir,
|
|
617
|
+
tool,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (toolPaths.mcpConfig) {
|
|
622
|
+
await writeToolMcpConfig({
|
|
623
|
+
mcpConfigPath: toolPaths.mcpConfig,
|
|
624
|
+
rootDir,
|
|
625
|
+
tool,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
state.tools[tool] = {
|
|
630
|
+
tool,
|
|
631
|
+
managedAt: nowIso(opts.now),
|
|
632
|
+
skillsDir: toolPaths.skillsDir,
|
|
633
|
+
mcpConfig: toolPaths.mcpConfig,
|
|
634
|
+
skillsBackup,
|
|
635
|
+
mcpBackup,
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
await saveManagedState(state, home);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function restoreBackup({
|
|
642
|
+
original,
|
|
643
|
+
backup,
|
|
644
|
+
}: {
|
|
645
|
+
original: string;
|
|
646
|
+
backup: string | null | undefined;
|
|
647
|
+
}) {
|
|
648
|
+
await rm(original, { recursive: true, force: true });
|
|
649
|
+
if (backup && (await fileExists(backup))) {
|
|
650
|
+
await rename(backup, original);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function removeSymlinks(skillsDir: string) {
|
|
655
|
+
try {
|
|
656
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
657
|
+
for (const entry of entries) {
|
|
658
|
+
const full = join(skillsDir, entry.name);
|
|
659
|
+
try {
|
|
660
|
+
const st = await lstat(full);
|
|
661
|
+
if (st.isSymbolicLink()) {
|
|
662
|
+
await rm(full, { force: true });
|
|
663
|
+
} else if (entry.isDirectory()) {
|
|
664
|
+
await rm(full, { recursive: true, force: true });
|
|
665
|
+
} else {
|
|
666
|
+
await rm(full, { force: true });
|
|
667
|
+
}
|
|
668
|
+
} catch {
|
|
669
|
+
// ignore
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
} catch {
|
|
673
|
+
// ignore
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export async function unmanageTool(tool: string, opts: ManageOptions = {}) {
|
|
678
|
+
const home = opts.homeDir ?? homedir();
|
|
679
|
+
const state = await loadManagedState(home);
|
|
680
|
+
const entry = state.tools[tool];
|
|
681
|
+
if (!entry) {
|
|
682
|
+
throw new Error(`${tool} is not managed`);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (entry.skillsDir) {
|
|
686
|
+
await removeSymlinks(entry.skillsDir);
|
|
687
|
+
await restoreBackup({
|
|
688
|
+
original: entry.skillsDir,
|
|
689
|
+
backup: entry.skillsBackup ?? null,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (entry.mcpConfig) {
|
|
694
|
+
await restoreBackup({
|
|
695
|
+
original: entry.mcpConfig,
|
|
696
|
+
backup: entry.mcpBackup ?? null,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const nextTools: ManagedState["tools"] = {};
|
|
701
|
+
for (const [name, config] of Object.entries(state.tools)) {
|
|
702
|
+
if (name === tool) {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
nextTools[name] = config;
|
|
706
|
+
}
|
|
707
|
+
state.tools = nextTools;
|
|
708
|
+
await saveManagedState(state, home);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export async function listManagedTools(
|
|
712
|
+
opts: { homeDir?: string } = {}
|
|
713
|
+
): Promise<string[]> {
|
|
714
|
+
const state = await loadManagedState(opts.homeDir ?? homedir());
|
|
715
|
+
return Object.keys(state.tools).sort();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function logSyncDryRun({
|
|
719
|
+
tool,
|
|
720
|
+
entry,
|
|
721
|
+
skillPlan,
|
|
722
|
+
mcpPlan,
|
|
723
|
+
}: {
|
|
724
|
+
tool: string;
|
|
725
|
+
entry: ManagedToolState;
|
|
726
|
+
skillPlan: { add: string[]; remove: string[] };
|
|
727
|
+
mcpPlan: { needsWrite: boolean };
|
|
728
|
+
}) {
|
|
729
|
+
for (const name of skillPlan.add) {
|
|
730
|
+
console.log(`${tool}: would add skill ${name}`);
|
|
731
|
+
}
|
|
732
|
+
for (const name of skillPlan.remove) {
|
|
733
|
+
console.log(`${tool}: would remove skill ${name}`);
|
|
734
|
+
}
|
|
735
|
+
if (mcpPlan.needsWrite && entry.mcpConfig) {
|
|
736
|
+
console.log(`${tool}: would update mcp config ${entry.mcpConfig}`);
|
|
737
|
+
}
|
|
738
|
+
if (
|
|
739
|
+
skillPlan.add.length === 0 &&
|
|
740
|
+
skillPlan.remove.length === 0 &&
|
|
741
|
+
!mcpPlan.needsWrite
|
|
742
|
+
) {
|
|
743
|
+
console.log(`${tool}: no changes`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function syncManagedToolEntry({
|
|
748
|
+
tool,
|
|
749
|
+
entry,
|
|
750
|
+
rootDir,
|
|
751
|
+
dryRun,
|
|
752
|
+
}: {
|
|
753
|
+
tool: string;
|
|
754
|
+
entry: ManagedToolState;
|
|
755
|
+
rootDir: string;
|
|
756
|
+
dryRun?: boolean;
|
|
757
|
+
}) {
|
|
758
|
+
const skillPlan = entry.skillsDir
|
|
759
|
+
? await syncSkillSymlinks({
|
|
760
|
+
toolSkillsDir: entry.skillsDir,
|
|
761
|
+
rootDir,
|
|
762
|
+
tool,
|
|
763
|
+
dryRun,
|
|
764
|
+
})
|
|
765
|
+
: { add: [], remove: [] };
|
|
766
|
+
|
|
767
|
+
const mcpPlan = entry.mcpConfig
|
|
768
|
+
? await syncMcpConfig({
|
|
769
|
+
mcpConfigPath: entry.mcpConfig,
|
|
770
|
+
rootDir,
|
|
771
|
+
tool,
|
|
772
|
+
dryRun,
|
|
773
|
+
})
|
|
774
|
+
: { needsWrite: false };
|
|
775
|
+
|
|
776
|
+
if (dryRun) {
|
|
777
|
+
logSyncDryRun({ tool, entry, skillPlan, mcpPlan });
|
|
778
|
+
} else {
|
|
779
|
+
console.log(`${tool} synced`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export async function syncManagedTools(opts: SyncOptions = {}) {
|
|
784
|
+
const home = opts.homeDir ?? homedir();
|
|
785
|
+
const rootDir = opts.rootDir ?? facultRootDir(home);
|
|
786
|
+
const state = await loadManagedState(home);
|
|
787
|
+
const tools = opts.tool ? [opts.tool] : Object.keys(state.tools).sort();
|
|
788
|
+
|
|
789
|
+
if (!tools.length) {
|
|
790
|
+
throw new Error("No managed tools to sync.");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
for (const tool of tools) {
|
|
794
|
+
const entry = state.tools[tool];
|
|
795
|
+
if (!entry) {
|
|
796
|
+
throw new Error(`${tool} is not managed`);
|
|
797
|
+
}
|
|
798
|
+
await syncManagedToolEntry({
|
|
799
|
+
tool,
|
|
800
|
+
entry,
|
|
801
|
+
rootDir,
|
|
802
|
+
dryRun: opts.dryRun,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
export async function manageCommand(argv: string[]) {
|
|
808
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
809
|
+
console.log(`facult manage — enter managed mode for a tool (backup + symlinks + MCP generation)
|
|
810
|
+
|
|
811
|
+
Usage:
|
|
812
|
+
facult manage <tool>
|
|
813
|
+
`);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const tool = argv[0];
|
|
817
|
+
if (!tool) {
|
|
818
|
+
console.error("manage requires a tool name");
|
|
819
|
+
process.exitCode = 1;
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
try {
|
|
823
|
+
await manageTool(tool);
|
|
824
|
+
console.log(`${tool} is now managed`);
|
|
825
|
+
} catch (err) {
|
|
826
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
827
|
+
process.exitCode = 1;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export async function unmanageCommand(argv: string[]) {
|
|
832
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
833
|
+
console.log(`facult unmanage — exit managed mode for a tool (restore backups)
|
|
834
|
+
|
|
835
|
+
Usage:
|
|
836
|
+
facult unmanage <tool>
|
|
837
|
+
`);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const tool = argv[0];
|
|
841
|
+
if (!tool) {
|
|
842
|
+
console.error("unmanage requires a tool name");
|
|
843
|
+
process.exitCode = 1;
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
await unmanageTool(tool);
|
|
848
|
+
console.log(`${tool} is no longer managed`);
|
|
849
|
+
} catch (err) {
|
|
850
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
851
|
+
process.exitCode = 1;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export async function managedCommand(argv: string[] = []) {
|
|
856
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
857
|
+
console.log(`facult managed — list tools currently in managed mode
|
|
858
|
+
|
|
859
|
+
Usage:
|
|
860
|
+
facult managed
|
|
861
|
+
`);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const tools = await listManagedTools();
|
|
865
|
+
if (!tools.length) {
|
|
866
|
+
console.log("No managed tools.");
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
for (const tool of tools) {
|
|
870
|
+
console.log(tool);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export async function syncCommand(argv: string[]) {
|
|
875
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
876
|
+
console.log(`facult sync — sync managed tools with canonical state
|
|
877
|
+
|
|
878
|
+
Usage:
|
|
879
|
+
facult sync [tool] [--dry-run]
|
|
880
|
+
|
|
881
|
+
Options:
|
|
882
|
+
--dry-run Show what would change
|
|
883
|
+
`);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const tool = argv.find((arg) => !arg.startsWith("-"));
|
|
887
|
+
const dryRun = argv.includes("--dry-run");
|
|
888
|
+
try {
|
|
889
|
+
await syncManagedTools({ tool, dryRun });
|
|
890
|
+
} catch (err) {
|
|
891
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
892
|
+
process.exitCode = 1;
|
|
893
|
+
}
|
|
894
|
+
}
|