facult 1.0.3 → 1.2.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/README.md +491 -15
- package/package.json +1 -1
- package/src/adapters/codex.ts +1 -0
- package/src/adapters/types.ts +1 -0
- package/src/agents.ts +205 -0
- package/src/ai-state.ts +80 -0
- package/src/ai.ts +1763 -0
- package/src/audit/update-index.ts +13 -10
- package/src/autosync.ts +1028 -0
- package/src/builtin.ts +61 -0
- package/src/cli-context.ts +198 -0
- package/src/doctor.ts +128 -0
- package/src/enable-disable.ts +13 -7
- package/src/global-docs.ts +505 -0
- package/src/graph-query.ts +175 -0
- package/src/graph.ts +119 -0
- package/src/index-builder.ts +1104 -44
- package/src/index.ts +458 -24
- package/src/manage.ts +2482 -215
- package/src/paths.ts +181 -17
- package/src/query.ts +147 -7
- package/src/remote.ts +145 -10
- package/src/snippets.ts +106 -0
- package/src/trust-list.ts +1 -0
- package/src/trust.ts +13 -11
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { mkdir, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { renderCanonicalText } from "./agents";
|
|
4
|
+
import { builtinSyncDefaultsEnabled, facultBuiltinPackRoot } from "./builtin";
|
|
5
|
+
import { projectRootFromAiRoot } from "./paths";
|
|
6
|
+
import { renderSnippetText } from "./snippets";
|
|
7
|
+
|
|
8
|
+
export interface GlobalDocPlan {
|
|
9
|
+
write: string[];
|
|
10
|
+
remove: string[];
|
|
11
|
+
contents: Map<string, string>;
|
|
12
|
+
sources: Map<string, string>;
|
|
13
|
+
managedTargets: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RulesPlan {
|
|
17
|
+
write: string[];
|
|
18
|
+
remove: string[];
|
|
19
|
+
contents: Map<string, string>;
|
|
20
|
+
sources: Map<string, string>;
|
|
21
|
+
managedRulesDir: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ToolConfigPlan {
|
|
25
|
+
targetPath: string;
|
|
26
|
+
write: boolean;
|
|
27
|
+
remove: boolean;
|
|
28
|
+
contents: string | null;
|
|
29
|
+
sourcePath?: string;
|
|
30
|
+
managedConfig: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface SourceTarget {
|
|
34
|
+
sourcePath: string;
|
|
35
|
+
targetPath: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface GlobalDocTargetPaths {
|
|
39
|
+
primary: string;
|
|
40
|
+
override?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TOML_BARE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
44
|
+
|
|
45
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
46
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function fileExists(pathValue: string): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
const stat = await Bun.file(pathValue).stat();
|
|
52
|
+
return stat.isFile();
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readTextIfExists(pathValue: string): Promise<string | null> {
|
|
59
|
+
if (!(await fileExists(pathValue))) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return await Bun.file(pathValue).text();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readTomlFile(
|
|
66
|
+
pathValue: string
|
|
67
|
+
): Promise<Record<string, unknown> | null> {
|
|
68
|
+
const text = await readTextIfExists(pathValue);
|
|
69
|
+
if (text == null) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const parsed = Bun.TOML.parse(text);
|
|
73
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mergeTomlObjects(
|
|
77
|
+
base: Record<string, unknown>,
|
|
78
|
+
override: Record<string, unknown>
|
|
79
|
+
): Record<string, unknown> {
|
|
80
|
+
const merged: Record<string, unknown> = { ...base };
|
|
81
|
+
for (const [key, value] of Object.entries(override)) {
|
|
82
|
+
const current = merged[key];
|
|
83
|
+
if (isPlainObject(current) && isPlainObject(value)) {
|
|
84
|
+
merged[key] = mergeTomlObjects(current, value);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
merged[key] = value;
|
|
88
|
+
}
|
|
89
|
+
return merged;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function shouldQuoteTomlKey(key: string): boolean {
|
|
93
|
+
return !TOML_BARE_KEY_PATTERN.test(key);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function escapeTomlString(value: string): string {
|
|
97
|
+
return value
|
|
98
|
+
.replace(/\\/g, "\\\\")
|
|
99
|
+
.replace(/"/g, '\\"')
|
|
100
|
+
.replace(/\n/g, "\\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatTomlKey(key: string): string {
|
|
104
|
+
return shouldQuoteTomlKey(key) ? `"${escapeTomlString(key)}"` : key;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatTomlValue(value: unknown): string {
|
|
108
|
+
if (typeof value === "string") {
|
|
109
|
+
return `"${escapeTomlString(value)}"`;
|
|
110
|
+
}
|
|
111
|
+
if (typeof value === "number" || typeof value === "bigint") {
|
|
112
|
+
return String(value);
|
|
113
|
+
}
|
|
114
|
+
if (typeof value === "boolean") {
|
|
115
|
+
return value ? "true" : "false";
|
|
116
|
+
}
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
return `[${value.map((entry) => formatTomlValue(entry)).join(", ")}]`;
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`Unsupported TOML value: ${typeof value}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stringifyTomlObject(obj: Record<string, unknown>): string {
|
|
124
|
+
const lines: string[] = [];
|
|
125
|
+
|
|
126
|
+
function emitTable(table: Record<string, unknown>, pathParts: string[] = []) {
|
|
127
|
+
const scalars: [string, unknown][] = [];
|
|
128
|
+
const subtables: [string, Record<string, unknown>][] = [];
|
|
129
|
+
|
|
130
|
+
for (const [key, value] of Object.entries(table)) {
|
|
131
|
+
if (isPlainObject(value)) {
|
|
132
|
+
subtables.push([key, value]);
|
|
133
|
+
} else {
|
|
134
|
+
scalars.push([key, value]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (pathParts.length > 0) {
|
|
139
|
+
if (lines.length > 0) {
|
|
140
|
+
lines.push("");
|
|
141
|
+
}
|
|
142
|
+
lines.push(`[${pathParts.map((part) => formatTomlKey(part)).join(".")}]`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const [key, value] of scalars) {
|
|
146
|
+
lines.push(`${formatTomlKey(key)} = ${formatTomlValue(value)}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const [key, subtable] of subtables) {
|
|
150
|
+
emitTable(subtable, [...pathParts, key]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
emitTable(obj);
|
|
155
|
+
return lines.join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function listGlobalDocSources(args: {
|
|
159
|
+
rootDir: string;
|
|
160
|
+
tool: string;
|
|
161
|
+
toolHome: string;
|
|
162
|
+
}): Promise<SourceTarget[]> {
|
|
163
|
+
const { rootDir, tool, toolHome } = args;
|
|
164
|
+
const targets = globalDocTargetPaths(tool, toolHome);
|
|
165
|
+
const useBuiltinDefaults = await builtinSyncDefaultsEnabled(rootDir);
|
|
166
|
+
|
|
167
|
+
const candidates: SourceTarget[] = [];
|
|
168
|
+
const base = join(rootDir, "AGENTS.global.md");
|
|
169
|
+
if (await fileExists(base)) {
|
|
170
|
+
candidates.push({
|
|
171
|
+
sourcePath: base,
|
|
172
|
+
targetPath: targets.primary,
|
|
173
|
+
});
|
|
174
|
+
} else if (useBuiltinDefaults) {
|
|
175
|
+
const builtinBase = join(facultBuiltinPackRoot(), "AGENTS.global.md");
|
|
176
|
+
if (await fileExists(builtinBase)) {
|
|
177
|
+
candidates.push({
|
|
178
|
+
sourcePath: builtinBase,
|
|
179
|
+
targetPath: targets.primary,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const override = join(rootDir, "AGENTS.override.global.md");
|
|
185
|
+
if (targets.override && (await fileExists(override))) {
|
|
186
|
+
candidates.push({
|
|
187
|
+
sourcePath: override,
|
|
188
|
+
targetPath: targets.override,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return candidates;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function globalDocTargetPaths(
|
|
196
|
+
tool: string,
|
|
197
|
+
toolHome: string
|
|
198
|
+
): GlobalDocTargetPaths {
|
|
199
|
+
if (tool === "claude") {
|
|
200
|
+
return {
|
|
201
|
+
primary: join(toolHome, "CLAUDE.md"),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
primary: join(toolHome, "AGENTS.md"),
|
|
207
|
+
override: join(toolHome, "AGENTS.override.md"),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function renderSourceTarget(args: {
|
|
212
|
+
homeDir: string;
|
|
213
|
+
rootDir: string;
|
|
214
|
+
sourcePath: string;
|
|
215
|
+
targetPath: string;
|
|
216
|
+
tool: string;
|
|
217
|
+
}): Promise<string> {
|
|
218
|
+
const raw = await Bun.file(args.sourcePath).text();
|
|
219
|
+
const withSnippets = await renderSnippetText({
|
|
220
|
+
text: raw,
|
|
221
|
+
filePath: args.sourcePath,
|
|
222
|
+
rootDir: args.rootDir,
|
|
223
|
+
});
|
|
224
|
+
if (withSnippets.errors.length) {
|
|
225
|
+
throw new Error(withSnippets.errors.join("\n"));
|
|
226
|
+
}
|
|
227
|
+
return await renderCanonicalText(withSnippets.text, {
|
|
228
|
+
homeDir: args.homeDir,
|
|
229
|
+
rootDir: args.rootDir,
|
|
230
|
+
projectRoot: projectRootFromAiRoot(args.rootDir, args.homeDir) ?? undefined,
|
|
231
|
+
targetTool: args.tool,
|
|
232
|
+
targetPath: args.targetPath,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function planToolGlobalDocsSync(args: {
|
|
237
|
+
homeDir: string;
|
|
238
|
+
rootDir: string;
|
|
239
|
+
tool: string;
|
|
240
|
+
toolHome: string;
|
|
241
|
+
previouslyManagedTargets?: string[];
|
|
242
|
+
}): Promise<GlobalDocPlan> {
|
|
243
|
+
const docs = await listGlobalDocSources(args);
|
|
244
|
+
const contents = new Map<string, string>();
|
|
245
|
+
const sources = new Map<string, string>();
|
|
246
|
+
const managedTargets = docs.map((doc) => doc.targetPath).sort();
|
|
247
|
+
|
|
248
|
+
for (const doc of docs) {
|
|
249
|
+
const rendered = await renderSourceTarget({
|
|
250
|
+
homeDir: args.homeDir,
|
|
251
|
+
rootDir: args.rootDir,
|
|
252
|
+
sourcePath: doc.sourcePath,
|
|
253
|
+
targetPath: doc.targetPath,
|
|
254
|
+
tool: args.tool,
|
|
255
|
+
});
|
|
256
|
+
contents.set(doc.targetPath, rendered);
|
|
257
|
+
sources.set(doc.targetPath, doc.sourcePath);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const write: string[] = [];
|
|
261
|
+
for (const targetPath of managedTargets) {
|
|
262
|
+
const current = await readTextIfExists(targetPath);
|
|
263
|
+
const desired = contents.get(targetPath);
|
|
264
|
+
if (desired != null && current !== desired) {
|
|
265
|
+
write.push(targetPath);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const remove = (args.previouslyManagedTargets ?? [])
|
|
270
|
+
.filter((targetPath) => !contents.has(targetPath))
|
|
271
|
+
.sort();
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
write: write.sort(),
|
|
275
|
+
remove,
|
|
276
|
+
contents,
|
|
277
|
+
sources,
|
|
278
|
+
managedTargets,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function syncToolGlobalDocs(args: {
|
|
283
|
+
homeDir: string;
|
|
284
|
+
rootDir: string;
|
|
285
|
+
tool: string;
|
|
286
|
+
toolHome: string;
|
|
287
|
+
previouslyManagedTargets?: string[];
|
|
288
|
+
dryRun?: boolean;
|
|
289
|
+
}): Promise<GlobalDocPlan> {
|
|
290
|
+
const plan = await planToolGlobalDocsSync(args);
|
|
291
|
+
if (args.dryRun) {
|
|
292
|
+
return plan;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const pathValue of plan.remove) {
|
|
296
|
+
await rm(pathValue, { force: true });
|
|
297
|
+
}
|
|
298
|
+
for (const pathValue of plan.write) {
|
|
299
|
+
const desired = plan.contents.get(pathValue);
|
|
300
|
+
if (desired != null) {
|
|
301
|
+
await mkdir(dirname(pathValue), { recursive: true });
|
|
302
|
+
await Bun.write(
|
|
303
|
+
pathValue,
|
|
304
|
+
desired.endsWith("\n") ? desired : `${desired}\n`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return plan;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function listToolRules(args: {
|
|
312
|
+
rootDir: string;
|
|
313
|
+
tool: string;
|
|
314
|
+
}): Promise<{ sourcePath: string; targetPath: string }[]> {
|
|
315
|
+
const sourceRoot = join(args.rootDir, "tools", args.tool, "rules");
|
|
316
|
+
const entries = await readdir(sourceRoot, { withFileTypes: true }).catch(
|
|
317
|
+
() => [] as import("node:fs").Dirent[]
|
|
318
|
+
);
|
|
319
|
+
const out: { sourcePath: string; targetPath: string }[] = [];
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
if (!(entry.isFile() && entry.name.endsWith(".rules"))) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
out.push({
|
|
325
|
+
sourcePath: join(sourceRoot, entry.name),
|
|
326
|
+
targetPath: entry.name,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return out.sort((a, b) => a.targetPath.localeCompare(b.targetPath));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function planToolRulesSync(args: {
|
|
333
|
+
homeDir: string;
|
|
334
|
+
rootDir: string;
|
|
335
|
+
tool: string;
|
|
336
|
+
rulesDir: string;
|
|
337
|
+
previouslyManaged?: boolean;
|
|
338
|
+
}): Promise<RulesPlan> {
|
|
339
|
+
const rules = await listToolRules(args);
|
|
340
|
+
const contents = new Map<string, string>();
|
|
341
|
+
const sources = new Map<string, string>();
|
|
342
|
+
|
|
343
|
+
for (const rule of rules) {
|
|
344
|
+
const targetPath = join(args.rulesDir, rule.targetPath);
|
|
345
|
+
const raw = await Bun.file(rule.sourcePath).text();
|
|
346
|
+
const rendered = await renderCanonicalText(raw, {
|
|
347
|
+
homeDir: args.homeDir,
|
|
348
|
+
rootDir: args.rootDir,
|
|
349
|
+
projectRoot:
|
|
350
|
+
projectRootFromAiRoot(args.rootDir, args.homeDir) ?? undefined,
|
|
351
|
+
targetTool: args.tool,
|
|
352
|
+
targetPath,
|
|
353
|
+
});
|
|
354
|
+
contents.set(targetPath, rendered);
|
|
355
|
+
sources.set(targetPath, rule.sourcePath);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const write: string[] = [];
|
|
359
|
+
for (const [targetPath, desired] of contents.entries()) {
|
|
360
|
+
const current = await readTextIfExists(targetPath);
|
|
361
|
+
if (current !== desired) {
|
|
362
|
+
write.push(targetPath);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const remove: string[] = [];
|
|
367
|
+
if (args.previouslyManaged) {
|
|
368
|
+
const existing = await readdir(args.rulesDir, {
|
|
369
|
+
withFileTypes: true,
|
|
370
|
+
}).catch(() => [] as import("node:fs").Dirent[]);
|
|
371
|
+
for (const entry of existing) {
|
|
372
|
+
if (!(entry.isFile() && entry.name.endsWith(".rules"))) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const existingPath = join(args.rulesDir, entry.name);
|
|
376
|
+
if (!contents.has(existingPath)) {
|
|
377
|
+
remove.push(existingPath);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
write: write.sort(),
|
|
384
|
+
remove: remove.sort(),
|
|
385
|
+
contents,
|
|
386
|
+
sources,
|
|
387
|
+
managedRulesDir: rules.length > 0,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export async function syncToolRules(args: {
|
|
392
|
+
homeDir: string;
|
|
393
|
+
rootDir: string;
|
|
394
|
+
tool: string;
|
|
395
|
+
rulesDir: string;
|
|
396
|
+
previouslyManaged?: boolean;
|
|
397
|
+
dryRun?: boolean;
|
|
398
|
+
}): Promise<RulesPlan> {
|
|
399
|
+
const plan = await planToolRulesSync(args);
|
|
400
|
+
if (args.dryRun) {
|
|
401
|
+
return plan;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (const pathValue of plan.remove) {
|
|
405
|
+
await rm(pathValue, { force: true });
|
|
406
|
+
}
|
|
407
|
+
for (const pathValue of plan.write) {
|
|
408
|
+
const desired = plan.contents.get(pathValue);
|
|
409
|
+
if (desired != null) {
|
|
410
|
+
await mkdir(dirname(pathValue), { recursive: true });
|
|
411
|
+
await Bun.write(
|
|
412
|
+
pathValue,
|
|
413
|
+
desired.endsWith("\n") ? desired : `${desired}\n`
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return plan;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function planToolConfigSync(args: {
|
|
421
|
+
homeDir: string;
|
|
422
|
+
rootDir: string;
|
|
423
|
+
tool: string;
|
|
424
|
+
toolConfigPath: string;
|
|
425
|
+
existingConfigPath?: string;
|
|
426
|
+
previouslyManaged?: boolean;
|
|
427
|
+
}): Promise<ToolConfigPlan> {
|
|
428
|
+
const sourcePath = join(args.rootDir, "tools", args.tool, "config.toml");
|
|
429
|
+
if (!(await fileExists(sourcePath))) {
|
|
430
|
+
return {
|
|
431
|
+
targetPath: args.toolConfigPath,
|
|
432
|
+
write: false,
|
|
433
|
+
remove: false,
|
|
434
|
+
contents: null,
|
|
435
|
+
sourcePath,
|
|
436
|
+
managedConfig: false,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const rendered = await renderSourceTarget({
|
|
441
|
+
homeDir: args.homeDir,
|
|
442
|
+
rootDir: args.rootDir,
|
|
443
|
+
sourcePath,
|
|
444
|
+
targetPath: args.toolConfigPath,
|
|
445
|
+
tool: args.tool,
|
|
446
|
+
});
|
|
447
|
+
const canonicalConfig = Bun.TOML.parse(rendered);
|
|
448
|
+
const existingConfig =
|
|
449
|
+
(await readTomlFile(args.toolConfigPath)) ??
|
|
450
|
+
(args.existingConfigPath
|
|
451
|
+
? await readTomlFile(args.existingConfigPath)
|
|
452
|
+
: null) ??
|
|
453
|
+
({} as Record<string, unknown>);
|
|
454
|
+
const merged = mergeTomlObjects(
|
|
455
|
+
existingConfig,
|
|
456
|
+
isPlainObject(canonicalConfig) ? canonicalConfig : {}
|
|
457
|
+
);
|
|
458
|
+
const nextContents = stringifyTomlObject(merged);
|
|
459
|
+
const current = await readTextIfExists(args.toolConfigPath);
|
|
460
|
+
return {
|
|
461
|
+
targetPath: args.toolConfigPath,
|
|
462
|
+
write: current !== `${nextContents}\n`,
|
|
463
|
+
remove: false,
|
|
464
|
+
contents: nextContents,
|
|
465
|
+
sourcePath,
|
|
466
|
+
managedConfig: true,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export async function syncToolConfig(args: {
|
|
471
|
+
homeDir: string;
|
|
472
|
+
rootDir: string;
|
|
473
|
+
tool: string;
|
|
474
|
+
toolConfigPath: string;
|
|
475
|
+
existingConfigPath?: string;
|
|
476
|
+
previouslyManaged?: boolean;
|
|
477
|
+
dryRun?: boolean;
|
|
478
|
+
}): Promise<ToolConfigPlan> {
|
|
479
|
+
const plan = await planToolConfigSync({
|
|
480
|
+
homeDir: args.homeDir,
|
|
481
|
+
rootDir: args.rootDir,
|
|
482
|
+
tool: args.tool,
|
|
483
|
+
toolConfigPath: args.toolConfigPath,
|
|
484
|
+
existingConfigPath: args.existingConfigPath,
|
|
485
|
+
previouslyManaged: args.previouslyManaged,
|
|
486
|
+
});
|
|
487
|
+
if (args.dryRun) {
|
|
488
|
+
return plan;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (plan.remove) {
|
|
492
|
+
await rm(plan.targetPath, { force: true });
|
|
493
|
+
return plan;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (plan.write && plan.contents != null) {
|
|
497
|
+
await mkdir(dirname(plan.targetPath), { recursive: true });
|
|
498
|
+
await Bun.write(
|
|
499
|
+
plan.targetPath,
|
|
500
|
+
plan.contents.endsWith("\n") ? plan.contents : `${plan.contents}\n`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return plan;
|
|
505
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssetScope,
|
|
3
|
+
AssetSourceKind,
|
|
4
|
+
FacultGraph,
|
|
5
|
+
GraphEdge,
|
|
6
|
+
GraphNode,
|
|
7
|
+
GraphNodeKind,
|
|
8
|
+
} from "./graph";
|
|
9
|
+
import { facultAiGraphPath } from "./paths";
|
|
10
|
+
|
|
11
|
+
type QueryableGraphKind =
|
|
12
|
+
| GraphNodeKind
|
|
13
|
+
| "skills"
|
|
14
|
+
| "agents"
|
|
15
|
+
| "snippets"
|
|
16
|
+
| "instructions"
|
|
17
|
+
| "docs"
|
|
18
|
+
| "tool-configs"
|
|
19
|
+
| "tool-rules"
|
|
20
|
+
| "rendered-targets";
|
|
21
|
+
|
|
22
|
+
export interface GraphSelection {
|
|
23
|
+
sourceKind?: AssetSourceKind;
|
|
24
|
+
scope?: AssetScope;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GraphRelation {
|
|
28
|
+
edge: GraphEdge;
|
|
29
|
+
node: GraphNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const KIND_ALIASES: Record<QueryableGraphKind, GraphNodeKind> = {
|
|
33
|
+
skill: "skill",
|
|
34
|
+
skills: "skill",
|
|
35
|
+
agent: "agent",
|
|
36
|
+
agents: "agent",
|
|
37
|
+
snippet: "snippet",
|
|
38
|
+
snippets: "snippet",
|
|
39
|
+
instruction: "instruction",
|
|
40
|
+
instructions: "instruction",
|
|
41
|
+
mcp: "mcp",
|
|
42
|
+
doc: "doc",
|
|
43
|
+
docs: "doc",
|
|
44
|
+
"tool-config": "tool-config",
|
|
45
|
+
"tool-configs": "tool-config",
|
|
46
|
+
"tool-rule": "tool-rule",
|
|
47
|
+
"tool-rules": "tool-rule",
|
|
48
|
+
"rendered-target": "rendered-target",
|
|
49
|
+
"rendered-targets": "rendered-target",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function normalizeKindToken(token: string): GraphNodeKind | null {
|
|
53
|
+
return KIND_ALIASES[token as QueryableGraphKind] ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sourceRank(sourceKind: AssetSourceKind): number {
|
|
57
|
+
switch (sourceKind) {
|
|
58
|
+
case "project":
|
|
59
|
+
return 0;
|
|
60
|
+
case "global":
|
|
61
|
+
return 1;
|
|
62
|
+
case "builtin":
|
|
63
|
+
return 2;
|
|
64
|
+
default:
|
|
65
|
+
return 99;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function matchesSelection(
|
|
70
|
+
node: GraphNode,
|
|
71
|
+
selection?: GraphSelection & { kind?: GraphNodeKind }
|
|
72
|
+
): boolean {
|
|
73
|
+
if (selection?.kind && node.kind !== selection.kind) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (selection?.sourceKind && node.sourceKind !== selection.sourceKind) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (selection?.scope && node.scope !== selection.scope) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function loadGraph(opts?: {
|
|
86
|
+
rootDir?: string;
|
|
87
|
+
homeDir?: string;
|
|
88
|
+
}): Promise<FacultGraph> {
|
|
89
|
+
const homeDir = opts?.homeDir ?? process.env.HOME ?? "";
|
|
90
|
+
const graphPath = facultAiGraphPath(homeDir, opts?.rootDir);
|
|
91
|
+
const file = Bun.file(graphPath);
|
|
92
|
+
if (!(await file.exists())) {
|
|
93
|
+
throw new Error(`Graph not found at ${graphPath}. Run "facult index".`);
|
|
94
|
+
}
|
|
95
|
+
return JSON.parse(await file.text()) as FacultGraph;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveGraphNode(
|
|
99
|
+
graph: FacultGraph,
|
|
100
|
+
query: string,
|
|
101
|
+
selection?: GraphSelection
|
|
102
|
+
): GraphNode | null {
|
|
103
|
+
const trimmed = query.trim();
|
|
104
|
+
if (!trimmed) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const candidates = Object.values(graph.nodes).filter((node) =>
|
|
109
|
+
matchesSelection(node, selection)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const exactId = graph.nodes[trimmed];
|
|
113
|
+
if (exactId && matchesSelection(exactId, selection)) {
|
|
114
|
+
return exactId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const canonical = candidates.find((node) => node.canonicalRef === trimmed);
|
|
118
|
+
if (canonical) {
|
|
119
|
+
return canonical;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const colonIndex = trimmed.indexOf(":");
|
|
123
|
+
if (colonIndex > 0) {
|
|
124
|
+
const kindToken = trimmed.slice(0, colonIndex);
|
|
125
|
+
const kind = normalizeKindToken(kindToken);
|
|
126
|
+
if (kind) {
|
|
127
|
+
const name = trimmed.slice(colonIndex + 1);
|
|
128
|
+
const matches = candidates
|
|
129
|
+
.filter((node) => node.kind === kind && node.name === name)
|
|
130
|
+
.sort((a, b) => sourceRank(a.sourceKind) - sourceRank(b.sourceKind));
|
|
131
|
+
return matches[0] ?? null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const byName = candidates
|
|
136
|
+
.filter((node) => node.name === trimmed)
|
|
137
|
+
.sort((a, b) => sourceRank(a.sourceKind) - sourceRank(b.sourceKind));
|
|
138
|
+
return byName[0] ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function graphDependencies(
|
|
142
|
+
graph: FacultGraph,
|
|
143
|
+
nodeId: string
|
|
144
|
+
): GraphRelation[] {
|
|
145
|
+
return graph.edges
|
|
146
|
+
.filter((edge) => edge.from === nodeId)
|
|
147
|
+
.flatMap((edge) => {
|
|
148
|
+
const node = graph.nodes[edge.to];
|
|
149
|
+
return node ? [{ edge, node }] : [];
|
|
150
|
+
})
|
|
151
|
+
.sort((a, b) => {
|
|
152
|
+
if (a.edge.kind !== b.edge.kind) {
|
|
153
|
+
return a.edge.kind.localeCompare(b.edge.kind);
|
|
154
|
+
}
|
|
155
|
+
return a.node.id.localeCompare(b.node.id);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function graphDependents(
|
|
160
|
+
graph: FacultGraph,
|
|
161
|
+
nodeId: string
|
|
162
|
+
): GraphRelation[] {
|
|
163
|
+
return graph.edges
|
|
164
|
+
.filter((edge) => edge.to === nodeId)
|
|
165
|
+
.flatMap((edge) => {
|
|
166
|
+
const node = graph.nodes[edge.from];
|
|
167
|
+
return node ? [{ edge, node }] : [];
|
|
168
|
+
})
|
|
169
|
+
.sort((a, b) => {
|
|
170
|
+
if (a.edge.kind !== b.edge.kind) {
|
|
171
|
+
return a.edge.kind.localeCompare(b.edge.kind);
|
|
172
|
+
}
|
|
173
|
+
return a.node.id.localeCompare(b.node.id);
|
|
174
|
+
});
|
|
175
|
+
}
|