facult 1.0.3 → 1.1.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 +200 -10
- package/package.json +1 -1
- package/src/adapters/codex.ts +1 -0
- package/src/adapters/types.ts +1 -0
- package/src/agents.ts +180 -0
- package/src/ai-state.ts +55 -0
- package/src/audit/update-index.ts +12 -10
- package/src/autosync.ts +959 -0
- package/src/doctor.ts +128 -0
- package/src/enable-disable.ts +12 -7
- package/src/global-docs.ts +461 -0
- package/src/index-builder.ts +7 -5
- package/src/index.ts +13 -1
- package/src/manage.ts +591 -6
- package/src/paths.ts +48 -16
- package/src/query.ts +15 -6
- package/src/remote.ts +5 -1
- package/src/snippets.ts +106 -0
- package/src/trust.ts +12 -11
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ensureAiIndexPath, legacyAiIndexPath } from "./ai-state";
|
|
5
|
+
import { repairAutosyncServices } from "./autosync";
|
|
6
|
+
import { facultAiIndexPath, facultConfigPath, facultRootDir } from "./paths";
|
|
7
|
+
|
|
8
|
+
function legacyDefaultRoot(home: string): string {
|
|
9
|
+
return join(home, "agents", ".facult");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function repairLegacyRootConfig(home: string): Promise<boolean> {
|
|
13
|
+
const configPath = facultConfigPath(home);
|
|
14
|
+
const preferredRoot = join(home, ".ai");
|
|
15
|
+
const legacyRoot = legacyDefaultRoot(home);
|
|
16
|
+
|
|
17
|
+
let parsed: Record<string, unknown> | null = null;
|
|
18
|
+
try {
|
|
19
|
+
const text = await Bun.file(configPath).text();
|
|
20
|
+
const value = JSON.parse(text) as unknown;
|
|
21
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
22
|
+
parsed = value as Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (parsed?.rootDir !== legacyRoot) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const stat = await Bun.file(preferredRoot).stat();
|
|
34
|
+
if (!stat.isDirectory()) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const next = {
|
|
42
|
+
...parsed,
|
|
43
|
+
rootDir: preferredRoot,
|
|
44
|
+
};
|
|
45
|
+
await mkdir(join(home, ".facult"), { recursive: true });
|
|
46
|
+
await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function printHelp() {
|
|
51
|
+
console.log(`facult doctor — inspect and repair local facult state
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
facult doctor [--repair]
|
|
55
|
+
|
|
56
|
+
Options:
|
|
57
|
+
--repair Reconcile legacy AI state, canonical root config, and autosync service config when needed
|
|
58
|
+
`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function doctorCommand(argv: string[]) {
|
|
62
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
63
|
+
printHelp();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const repair = argv.includes("--repair");
|
|
68
|
+
const home = process.env.HOME?.trim() || homedir();
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
let rootConfigRepaired = false;
|
|
72
|
+
let autosyncRepaired = false;
|
|
73
|
+
if (repair) {
|
|
74
|
+
rootConfigRepaired = await repairLegacyRootConfig(home);
|
|
75
|
+
autosyncRepaired = await repairAutosyncServices(home);
|
|
76
|
+
}
|
|
77
|
+
const rootDir = facultRootDir(home);
|
|
78
|
+
const generated = facultAiIndexPath(home);
|
|
79
|
+
const legacy = legacyAiIndexPath(rootDir);
|
|
80
|
+
const result = await ensureAiIndexPath({ homeDir: home, rootDir, repair });
|
|
81
|
+
|
|
82
|
+
console.log(`Canonical root: ${rootDir}`);
|
|
83
|
+
console.log(`Generated AI index: ${generated}`);
|
|
84
|
+
console.log(`Legacy root index: ${legacy}`);
|
|
85
|
+
|
|
86
|
+
if (rootConfigRepaired) {
|
|
87
|
+
console.log(`Updated facult root config to ${join(home, ".ai")}`);
|
|
88
|
+
}
|
|
89
|
+
if (autosyncRepaired) {
|
|
90
|
+
console.log("Repaired autosync launch agent configuration.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (result.source === "generated") {
|
|
94
|
+
console.log("AI index is healthy.");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (repair && result.source === "legacy") {
|
|
99
|
+
console.log(
|
|
100
|
+
`Repaired generated AI index from legacy root index: ${generated}`
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (repair && result.source === "rebuilt") {
|
|
106
|
+
console.log(
|
|
107
|
+
`Rebuilt generated AI index from canonical source: ${generated}`
|
|
108
|
+
);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (result.source === "legacy") {
|
|
113
|
+
console.log(
|
|
114
|
+
"Legacy root index detected. Run `facult doctor --repair` to reconcile it."
|
|
115
|
+
);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(
|
|
121
|
+
"Generated AI index is missing. Run `facult doctor --repair` or `facult index`."
|
|
122
|
+
);
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/enable-disable.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { ensureAiIndexPath } from "./ai-state";
|
|
3
4
|
import type { FacultIndex } from "./index-builder";
|
|
4
5
|
import { loadManagedState, syncManagedTools } from "./manage";
|
|
5
|
-
import { facultRootDir } from "./paths";
|
|
6
|
+
import { facultAiIndexPath, facultRootDir } from "./paths";
|
|
6
7
|
|
|
7
8
|
type EntryKind = "skills" | "mcp";
|
|
8
9
|
|
|
@@ -61,8 +62,12 @@ function computeNextEnabledFor({
|
|
|
61
62
|
return uniqueSorted(base.filter((tool) => !targetTools.includes(tool)));
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
async function loadIndex(
|
|
65
|
-
const indexPath =
|
|
65
|
+
async function loadIndex(homeDir: string): Promise<FacultIndex> {
|
|
66
|
+
const { path: indexPath } = await ensureAiIndexPath({
|
|
67
|
+
homeDir,
|
|
68
|
+
rootDir: facultRootDir(homeDir),
|
|
69
|
+
repair: true,
|
|
70
|
+
});
|
|
66
71
|
const file = Bun.file(indexPath);
|
|
67
72
|
if (!(await file.exists())) {
|
|
68
73
|
throw new Error(`Index not found at ${indexPath}. Run "facult index".`);
|
|
@@ -71,8 +76,8 @@ async function loadIndex(rootDir: string): Promise<FacultIndex> {
|
|
|
71
76
|
return JSON.parse(raw) as FacultIndex;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
async function writeIndex(
|
|
75
|
-
const indexPath =
|
|
79
|
+
async function writeIndex(homeDir: string, index: FacultIndex) {
|
|
80
|
+
const indexPath = facultAiIndexPath(homeDir);
|
|
76
81
|
await Bun.write(indexPath, `${JSON.stringify(index, null, 2)}\n`);
|
|
77
82
|
}
|
|
78
83
|
|
|
@@ -200,7 +205,7 @@ export async function applyEnableDisable({
|
|
|
200
205
|
|
|
201
206
|
const allTools = managedTools.length ? managedTools : targetTools;
|
|
202
207
|
|
|
203
|
-
const index = ensureIndexStructure(await loadIndex(
|
|
208
|
+
const index = ensureIndexStructure(await loadIndex(home));
|
|
204
209
|
const missing: string[] = [];
|
|
205
210
|
const mcpUpdates: string[] = [];
|
|
206
211
|
|
|
@@ -241,7 +246,7 @@ export async function applyEnableDisable({
|
|
|
241
246
|
}
|
|
242
247
|
|
|
243
248
|
index.updatedAt = new Date().toISOString();
|
|
244
|
-
await writeIndex(
|
|
249
|
+
await writeIndex(home, index);
|
|
245
250
|
|
|
246
251
|
await updateCanonicalServers({
|
|
247
252
|
rootDir: root,
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { mkdir, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { renderCanonicalText } from "./agents";
|
|
4
|
+
import { renderSnippetText } from "./snippets";
|
|
5
|
+
|
|
6
|
+
export interface GlobalDocPlan {
|
|
7
|
+
write: string[];
|
|
8
|
+
remove: string[];
|
|
9
|
+
contents: Map<string, string>;
|
|
10
|
+
managedTargets: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RulesPlan {
|
|
14
|
+
write: string[];
|
|
15
|
+
remove: string[];
|
|
16
|
+
contents: Map<string, string>;
|
|
17
|
+
managedRulesDir: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ToolConfigPlan {
|
|
21
|
+
targetPath: string;
|
|
22
|
+
write: boolean;
|
|
23
|
+
remove: boolean;
|
|
24
|
+
contents: string | null;
|
|
25
|
+
managedConfig: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SourceTarget {
|
|
29
|
+
sourcePath: string;
|
|
30
|
+
targetPath: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TOML_BARE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
34
|
+
|
|
35
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
36
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function fileExists(pathValue: string): Promise<boolean> {
|
|
40
|
+
try {
|
|
41
|
+
const stat = await Bun.file(pathValue).stat();
|
|
42
|
+
return stat.isFile();
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readTextIfExists(pathValue: string): Promise<string | null> {
|
|
49
|
+
if (!(await fileExists(pathValue))) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return await Bun.file(pathValue).text();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readTomlFile(
|
|
56
|
+
pathValue: string
|
|
57
|
+
): Promise<Record<string, unknown> | null> {
|
|
58
|
+
const text = await readTextIfExists(pathValue);
|
|
59
|
+
if (text == null) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const parsed = Bun.TOML.parse(text);
|
|
63
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mergeTomlObjects(
|
|
67
|
+
base: Record<string, unknown>,
|
|
68
|
+
override: Record<string, unknown>
|
|
69
|
+
): Record<string, unknown> {
|
|
70
|
+
const merged: Record<string, unknown> = { ...base };
|
|
71
|
+
for (const [key, value] of Object.entries(override)) {
|
|
72
|
+
const current = merged[key];
|
|
73
|
+
if (isPlainObject(current) && isPlainObject(value)) {
|
|
74
|
+
merged[key] = mergeTomlObjects(current, value);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
merged[key] = value;
|
|
78
|
+
}
|
|
79
|
+
return merged;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shouldQuoteTomlKey(key: string): boolean {
|
|
83
|
+
return !TOML_BARE_KEY_PATTERN.test(key);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function escapeTomlString(value: string): string {
|
|
87
|
+
return value
|
|
88
|
+
.replace(/\\/g, "\\\\")
|
|
89
|
+
.replace(/"/g, '\\"')
|
|
90
|
+
.replace(/\n/g, "\\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatTomlKey(key: string): string {
|
|
94
|
+
return shouldQuoteTomlKey(key) ? `"${escapeTomlString(key)}"` : key;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatTomlValue(value: unknown): string {
|
|
98
|
+
if (typeof value === "string") {
|
|
99
|
+
return `"${escapeTomlString(value)}"`;
|
|
100
|
+
}
|
|
101
|
+
if (typeof value === "number" || typeof value === "bigint") {
|
|
102
|
+
return String(value);
|
|
103
|
+
}
|
|
104
|
+
if (typeof value === "boolean") {
|
|
105
|
+
return value ? "true" : "false";
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
return `[${value.map((entry) => formatTomlValue(entry)).join(", ")}]`;
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`Unsupported TOML value: ${typeof value}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stringifyTomlObject(obj: Record<string, unknown>): string {
|
|
114
|
+
const lines: string[] = [];
|
|
115
|
+
|
|
116
|
+
function emitTable(table: Record<string, unknown>, pathParts: string[] = []) {
|
|
117
|
+
const scalars: [string, unknown][] = [];
|
|
118
|
+
const subtables: [string, Record<string, unknown>][] = [];
|
|
119
|
+
|
|
120
|
+
for (const [key, value] of Object.entries(table)) {
|
|
121
|
+
if (isPlainObject(value)) {
|
|
122
|
+
subtables.push([key, value]);
|
|
123
|
+
} else {
|
|
124
|
+
scalars.push([key, value]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (pathParts.length > 0) {
|
|
129
|
+
if (lines.length > 0) {
|
|
130
|
+
lines.push("");
|
|
131
|
+
}
|
|
132
|
+
lines.push(`[${pathParts.map((part) => formatTomlKey(part)).join(".")}]`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const [key, value] of scalars) {
|
|
136
|
+
lines.push(`${formatTomlKey(key)} = ${formatTomlValue(value)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const [key, subtable] of subtables) {
|
|
140
|
+
emitTable(subtable, [...pathParts, key]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
emitTable(obj);
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function listGlobalDocSources(args: {
|
|
149
|
+
rootDir: string;
|
|
150
|
+
tool: string;
|
|
151
|
+
toolHome: string;
|
|
152
|
+
}): Promise<SourceTarget[]> {
|
|
153
|
+
const { rootDir, tool, toolHome } = args;
|
|
154
|
+
if (tool !== "codex") {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const candidates: SourceTarget[] = [];
|
|
159
|
+
const base = join(rootDir, "AGENTS.global.md");
|
|
160
|
+
if (await fileExists(base)) {
|
|
161
|
+
candidates.push({
|
|
162
|
+
sourcePath: base,
|
|
163
|
+
targetPath: join(toolHome, "AGENTS.md"),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const override = join(rootDir, "AGENTS.override.global.md");
|
|
168
|
+
if (await fileExists(override)) {
|
|
169
|
+
candidates.push({
|
|
170
|
+
sourcePath: override,
|
|
171
|
+
targetPath: join(toolHome, "AGENTS.override.md"),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return candidates;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function renderSourceTarget(args: {
|
|
179
|
+
homeDir: string;
|
|
180
|
+
rootDir: string;
|
|
181
|
+
sourcePath: string;
|
|
182
|
+
targetPath: string;
|
|
183
|
+
tool: string;
|
|
184
|
+
}): Promise<string> {
|
|
185
|
+
const raw = await Bun.file(args.sourcePath).text();
|
|
186
|
+
const withSnippets = await renderSnippetText({
|
|
187
|
+
text: raw,
|
|
188
|
+
filePath: args.sourcePath,
|
|
189
|
+
rootDir: args.rootDir,
|
|
190
|
+
});
|
|
191
|
+
if (withSnippets.errors.length) {
|
|
192
|
+
throw new Error(withSnippets.errors.join("\n"));
|
|
193
|
+
}
|
|
194
|
+
return await renderCanonicalText(withSnippets.text, {
|
|
195
|
+
homeDir: args.homeDir,
|
|
196
|
+
rootDir: args.rootDir,
|
|
197
|
+
targetTool: args.tool,
|
|
198
|
+
targetPath: args.targetPath,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function planToolGlobalDocsSync(args: {
|
|
203
|
+
homeDir: string;
|
|
204
|
+
rootDir: string;
|
|
205
|
+
tool: string;
|
|
206
|
+
toolHome: string;
|
|
207
|
+
previouslyManagedTargets?: string[];
|
|
208
|
+
}): Promise<GlobalDocPlan> {
|
|
209
|
+
const docs = await listGlobalDocSources(args);
|
|
210
|
+
const contents = new Map<string, string>();
|
|
211
|
+
const managedTargets = docs.map((doc) => doc.targetPath).sort();
|
|
212
|
+
|
|
213
|
+
for (const doc of docs) {
|
|
214
|
+
const rendered = await renderSourceTarget({
|
|
215
|
+
homeDir: args.homeDir,
|
|
216
|
+
rootDir: args.rootDir,
|
|
217
|
+
sourcePath: doc.sourcePath,
|
|
218
|
+
targetPath: doc.targetPath,
|
|
219
|
+
tool: args.tool,
|
|
220
|
+
});
|
|
221
|
+
contents.set(doc.targetPath, rendered);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const write: string[] = [];
|
|
225
|
+
for (const targetPath of managedTargets) {
|
|
226
|
+
const current = await readTextIfExists(targetPath);
|
|
227
|
+
const desired = contents.get(targetPath);
|
|
228
|
+
if (desired != null && current !== desired) {
|
|
229
|
+
write.push(targetPath);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const remove = (args.previouslyManagedTargets ?? [])
|
|
234
|
+
.filter((targetPath) => !contents.has(targetPath))
|
|
235
|
+
.sort();
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
write: write.sort(),
|
|
239
|
+
remove,
|
|
240
|
+
contents,
|
|
241
|
+
managedTargets,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function syncToolGlobalDocs(args: {
|
|
246
|
+
homeDir: string;
|
|
247
|
+
rootDir: string;
|
|
248
|
+
tool: string;
|
|
249
|
+
toolHome: string;
|
|
250
|
+
previouslyManagedTargets?: string[];
|
|
251
|
+
dryRun?: boolean;
|
|
252
|
+
}): Promise<GlobalDocPlan> {
|
|
253
|
+
const plan = await planToolGlobalDocsSync(args);
|
|
254
|
+
if (args.dryRun) {
|
|
255
|
+
return plan;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const pathValue of plan.remove) {
|
|
259
|
+
await rm(pathValue, { force: true });
|
|
260
|
+
}
|
|
261
|
+
for (const pathValue of plan.write) {
|
|
262
|
+
const desired = plan.contents.get(pathValue);
|
|
263
|
+
if (desired != null) {
|
|
264
|
+
await mkdir(dirname(pathValue), { recursive: true });
|
|
265
|
+
await Bun.write(
|
|
266
|
+
pathValue,
|
|
267
|
+
desired.endsWith("\n") ? desired : `${desired}\n`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return plan;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function listToolRules(args: {
|
|
275
|
+
rootDir: string;
|
|
276
|
+
tool: string;
|
|
277
|
+
}): Promise<{ sourcePath: string; targetPath: string }[]> {
|
|
278
|
+
const sourceRoot = join(args.rootDir, "tools", args.tool, "rules");
|
|
279
|
+
const entries = await readdir(sourceRoot, { withFileTypes: true }).catch(
|
|
280
|
+
() => [] as import("node:fs").Dirent[]
|
|
281
|
+
);
|
|
282
|
+
const out: { sourcePath: string; targetPath: string }[] = [];
|
|
283
|
+
for (const entry of entries) {
|
|
284
|
+
if (!(entry.isFile() && entry.name.endsWith(".rules"))) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
out.push({
|
|
288
|
+
sourcePath: join(sourceRoot, entry.name),
|
|
289
|
+
targetPath: entry.name,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return out.sort((a, b) => a.targetPath.localeCompare(b.targetPath));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function planToolRulesSync(args: {
|
|
296
|
+
homeDir: string;
|
|
297
|
+
rootDir: string;
|
|
298
|
+
tool: string;
|
|
299
|
+
rulesDir: string;
|
|
300
|
+
previouslyManaged?: boolean;
|
|
301
|
+
}): Promise<RulesPlan> {
|
|
302
|
+
const rules = await listToolRules(args);
|
|
303
|
+
const contents = new Map<string, string>();
|
|
304
|
+
|
|
305
|
+
for (const rule of rules) {
|
|
306
|
+
const targetPath = join(args.rulesDir, rule.targetPath);
|
|
307
|
+
const raw = await Bun.file(rule.sourcePath).text();
|
|
308
|
+
const rendered = await renderCanonicalText(raw, {
|
|
309
|
+
homeDir: args.homeDir,
|
|
310
|
+
rootDir: args.rootDir,
|
|
311
|
+
targetTool: args.tool,
|
|
312
|
+
targetPath,
|
|
313
|
+
});
|
|
314
|
+
contents.set(targetPath, rendered);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const write: string[] = [];
|
|
318
|
+
for (const [targetPath, desired] of contents.entries()) {
|
|
319
|
+
const current = await readTextIfExists(targetPath);
|
|
320
|
+
if (current !== desired) {
|
|
321
|
+
write.push(targetPath);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const remove: string[] = [];
|
|
326
|
+
if (args.previouslyManaged) {
|
|
327
|
+
const existing = await readdir(args.rulesDir, {
|
|
328
|
+
withFileTypes: true,
|
|
329
|
+
}).catch(() => [] as import("node:fs").Dirent[]);
|
|
330
|
+
for (const entry of existing) {
|
|
331
|
+
if (!(entry.isFile() && entry.name.endsWith(".rules"))) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const existingPath = join(args.rulesDir, entry.name);
|
|
335
|
+
if (!contents.has(existingPath)) {
|
|
336
|
+
remove.push(existingPath);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
write: write.sort(),
|
|
343
|
+
remove: remove.sort(),
|
|
344
|
+
contents,
|
|
345
|
+
managedRulesDir: rules.length > 0,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function syncToolRules(args: {
|
|
350
|
+
homeDir: string;
|
|
351
|
+
rootDir: string;
|
|
352
|
+
tool: string;
|
|
353
|
+
rulesDir: string;
|
|
354
|
+
previouslyManaged?: boolean;
|
|
355
|
+
dryRun?: boolean;
|
|
356
|
+
}): Promise<RulesPlan> {
|
|
357
|
+
const plan = await planToolRulesSync(args);
|
|
358
|
+
if (args.dryRun) {
|
|
359
|
+
return plan;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const pathValue of plan.remove) {
|
|
363
|
+
await rm(pathValue, { force: true });
|
|
364
|
+
}
|
|
365
|
+
for (const pathValue of plan.write) {
|
|
366
|
+
const desired = plan.contents.get(pathValue);
|
|
367
|
+
if (desired != null) {
|
|
368
|
+
await mkdir(dirname(pathValue), { recursive: true });
|
|
369
|
+
await Bun.write(
|
|
370
|
+
pathValue,
|
|
371
|
+
desired.endsWith("\n") ? desired : `${desired}\n`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return plan;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function planToolConfigSync(args: {
|
|
379
|
+
homeDir: string;
|
|
380
|
+
rootDir: string;
|
|
381
|
+
tool: string;
|
|
382
|
+
toolConfigPath: string;
|
|
383
|
+
existingConfigPath?: string;
|
|
384
|
+
previouslyManaged?: boolean;
|
|
385
|
+
}): Promise<ToolConfigPlan> {
|
|
386
|
+
const sourcePath = join(args.rootDir, "tools", args.tool, "config.toml");
|
|
387
|
+
if (!(await fileExists(sourcePath))) {
|
|
388
|
+
return {
|
|
389
|
+
targetPath: args.toolConfigPath,
|
|
390
|
+
write: false,
|
|
391
|
+
remove: false,
|
|
392
|
+
contents: null,
|
|
393
|
+
managedConfig: false,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const rendered = await renderSourceTarget({
|
|
398
|
+
homeDir: args.homeDir,
|
|
399
|
+
rootDir: args.rootDir,
|
|
400
|
+
sourcePath,
|
|
401
|
+
targetPath: args.toolConfigPath,
|
|
402
|
+
tool: args.tool,
|
|
403
|
+
});
|
|
404
|
+
const canonicalConfig = Bun.TOML.parse(rendered);
|
|
405
|
+
const existingConfig =
|
|
406
|
+
(await readTomlFile(args.toolConfigPath)) ??
|
|
407
|
+
(args.existingConfigPath
|
|
408
|
+
? await readTomlFile(args.existingConfigPath)
|
|
409
|
+
: null) ??
|
|
410
|
+
({} as Record<string, unknown>);
|
|
411
|
+
const merged = mergeTomlObjects(
|
|
412
|
+
existingConfig,
|
|
413
|
+
isPlainObject(canonicalConfig) ? canonicalConfig : {}
|
|
414
|
+
);
|
|
415
|
+
const nextContents = stringifyTomlObject(merged);
|
|
416
|
+
const current = await readTextIfExists(args.toolConfigPath);
|
|
417
|
+
return {
|
|
418
|
+
targetPath: args.toolConfigPath,
|
|
419
|
+
write: current !== `${nextContents}\n`,
|
|
420
|
+
remove: false,
|
|
421
|
+
contents: nextContents,
|
|
422
|
+
managedConfig: true,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function syncToolConfig(args: {
|
|
427
|
+
homeDir: string;
|
|
428
|
+
rootDir: string;
|
|
429
|
+
tool: string;
|
|
430
|
+
toolConfigPath: string;
|
|
431
|
+
existingConfigPath?: string;
|
|
432
|
+
previouslyManaged?: boolean;
|
|
433
|
+
dryRun?: boolean;
|
|
434
|
+
}): Promise<ToolConfigPlan> {
|
|
435
|
+
const plan = await planToolConfigSync({
|
|
436
|
+
homeDir: args.homeDir,
|
|
437
|
+
rootDir: args.rootDir,
|
|
438
|
+
tool: args.tool,
|
|
439
|
+
toolConfigPath: args.toolConfigPath,
|
|
440
|
+
existingConfigPath: args.existingConfigPath,
|
|
441
|
+
previouslyManaged: args.previouslyManaged,
|
|
442
|
+
});
|
|
443
|
+
if (args.dryRun) {
|
|
444
|
+
return plan;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (plan.remove) {
|
|
448
|
+
await rm(plan.targetPath, { force: true });
|
|
449
|
+
return plan;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (plan.write && plan.contents != null) {
|
|
453
|
+
await mkdir(dirname(plan.targetPath), { recursive: true });
|
|
454
|
+
await Bun.write(
|
|
455
|
+
plan.targetPath,
|
|
456
|
+
plan.contents.endsWith("\n") ? plan.contents : `${plan.contents}\n`
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return plan;
|
|
461
|
+
}
|
package/src/index-builder.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readdir } from "node:fs/promises";
|
|
2
|
-
import { basename, join, relative } from "node:path";
|
|
3
|
-
import { facultRootDir } from "./paths";
|
|
2
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import { facultAiIndexPath, facultRootDir } from "./paths";
|
|
4
4
|
import { lastModified } from "./util/skills";
|
|
5
5
|
|
|
6
6
|
export interface SkillEntry {
|
|
@@ -472,6 +472,8 @@ export async function buildIndex(opts?: {
|
|
|
472
472
|
force?: boolean;
|
|
473
473
|
/** Override the default canonical root dir (useful for tests). */
|
|
474
474
|
rootDir?: string;
|
|
475
|
+
/** Override home directory for generated state placement (useful for tests). */
|
|
476
|
+
homeDir?: string;
|
|
475
477
|
}): Promise<{ index: FacultIndex; outputPath: string }> {
|
|
476
478
|
const force = Boolean(opts?.force);
|
|
477
479
|
|
|
@@ -485,7 +487,7 @@ export async function buildIndex(opts?: {
|
|
|
485
487
|
? serversJsonPath
|
|
486
488
|
: mcpJsonPath;
|
|
487
489
|
|
|
488
|
-
const outputPath =
|
|
490
|
+
const outputPath = facultAiIndexPath(opts?.homeDir);
|
|
489
491
|
|
|
490
492
|
let previousIndex: Record<string, unknown> | null = null;
|
|
491
493
|
if (!force) {
|
|
@@ -538,7 +540,7 @@ export async function buildIndex(opts?: {
|
|
|
538
540
|
snippets,
|
|
539
541
|
};
|
|
540
542
|
|
|
541
|
-
await mkdir(
|
|
543
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
542
544
|
await Bun.write(outputPath, `${JSON.stringify(index, null, 2)}\n`);
|
|
543
545
|
|
|
544
546
|
return { index, outputPath };
|
|
@@ -546,7 +548,7 @@ export async function buildIndex(opts?: {
|
|
|
546
548
|
|
|
547
549
|
export async function indexCommand(argv: string[]) {
|
|
548
550
|
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
549
|
-
console.log(`facult index — rebuild index
|
|
551
|
+
console.log(`facult index — rebuild the generated index for the canonical store
|
|
550
552
|
|
|
551
553
|
Usage:
|
|
552
554
|
facult index [--force]
|