facult 2.7.0 → 2.7.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/README.md +141 -337
- package/package.json +1 -1
- package/src/adapters/codex.ts +1 -1
- package/src/builtin.ts +7 -1
- package/src/doctor.ts +327 -0
- package/src/global-docs.ts +43 -2
- package/src/index.ts +60 -53
- package/src/manage.ts +880 -37
- package/src/project-sync.ts +288 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { projectRootFromAiRoot } from "./paths";
|
|
4
|
+
|
|
5
|
+
type ProjectSyncNamedSurface = "skills" | "agents" | "mcpServers";
|
|
6
|
+
type ProjectSyncToolSurface = "globalDocs" | "toolRules" | "toolConfig";
|
|
7
|
+
|
|
8
|
+
interface ProjectSyncToolPolicy {
|
|
9
|
+
skills: string[];
|
|
10
|
+
agents: string[];
|
|
11
|
+
mcpServers: string[];
|
|
12
|
+
globalDocs: boolean;
|
|
13
|
+
toolRules: boolean;
|
|
14
|
+
toolConfig: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ProjectSyncConfig {
|
|
18
|
+
tools: Record<string, ProjectSyncToolPolicy>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readTomlObject(
|
|
26
|
+
pathValue: string
|
|
27
|
+
): Promise<Record<string, unknown> | null> {
|
|
28
|
+
const file = Bun.file(pathValue);
|
|
29
|
+
if (!(await file.exists())) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const parsed = Bun.TOML.parse(await file.text());
|
|
33
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mergeTomlObjects(
|
|
37
|
+
base: Record<string, unknown>,
|
|
38
|
+
override: Record<string, unknown>
|
|
39
|
+
): Record<string, unknown> {
|
|
40
|
+
const merged: Record<string, unknown> = { ...base };
|
|
41
|
+
for (const [key, value] of Object.entries(override)) {
|
|
42
|
+
const current = merged[key];
|
|
43
|
+
if (isPlainObject(current) && isPlainObject(value)) {
|
|
44
|
+
merged[key] = mergeTomlObjects(current, value);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
merged[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return merged;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseStringList(value: unknown): string[] {
|
|
53
|
+
if (!Array.isArray(value)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
return [
|
|
57
|
+
...new Set(value.map((entry) => String(entry).trim()).filter(Boolean)),
|
|
58
|
+
].sort((a, b) => a.localeCompare(b));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseBoolean(value: unknown): boolean {
|
|
62
|
+
return value === true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function projectSyncToolPolicyFromObject(
|
|
66
|
+
value: unknown
|
|
67
|
+
): ProjectSyncToolPolicy {
|
|
68
|
+
const table = isPlainObject(value) ? value : {};
|
|
69
|
+
return {
|
|
70
|
+
skills: parseStringList(table.skills),
|
|
71
|
+
agents: parseStringList(table.agents),
|
|
72
|
+
mcpServers: parseStringList(table.mcp_servers ?? table.mcp),
|
|
73
|
+
globalDocs: parseBoolean(table.global_docs ?? table.docs),
|
|
74
|
+
toolRules: parseBoolean(table.tool_rules ?? table.rules),
|
|
75
|
+
toolConfig: parseBoolean(table.tool_config ?? table.config),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseProjectSyncConfig(
|
|
80
|
+
data: Record<string, unknown> | null
|
|
81
|
+
): ProjectSyncConfig {
|
|
82
|
+
const raw = isPlainObject(data?.project_sync) ? data.project_sync : {};
|
|
83
|
+
const tools: Record<string, ProjectSyncToolPolicy> = {};
|
|
84
|
+
|
|
85
|
+
for (const [tool, value] of Object.entries(raw)) {
|
|
86
|
+
tools[tool] = projectSyncToolPolicyFromObject(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { tools };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function loadProjectSyncConfig(args: {
|
|
93
|
+
rootDir: string;
|
|
94
|
+
}): Promise<ProjectSyncConfig> {
|
|
95
|
+
const [tracked, local] = await Promise.all([
|
|
96
|
+
readTomlObject(join(args.rootDir, "config.toml")),
|
|
97
|
+
readTomlObject(join(args.rootDir, "config.local.toml")),
|
|
98
|
+
]);
|
|
99
|
+
const merged = mergeTomlObjects(tracked ?? {}, local ?? {});
|
|
100
|
+
return parseProjectSyncConfig(merged);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function emptyPolicy(): ProjectSyncToolPolicy {
|
|
104
|
+
return {
|
|
105
|
+
skills: [],
|
|
106
|
+
agents: [],
|
|
107
|
+
mcpServers: [],
|
|
108
|
+
globalDocs: false,
|
|
109
|
+
toolRules: false,
|
|
110
|
+
toolConfig: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function includesExplicitName(allowed: string[], name: string): boolean {
|
|
115
|
+
return allowed.includes("*") || allowed.includes(name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isProjectManagedRoot(args: {
|
|
119
|
+
homeDir: string;
|
|
120
|
+
rootDir: string;
|
|
121
|
+
}): boolean {
|
|
122
|
+
return projectRootFromAiRoot(args.rootDir, args.homeDir) != null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function loadProjectToolSyncPolicy(args: {
|
|
126
|
+
homeDir: string;
|
|
127
|
+
rootDir: string;
|
|
128
|
+
tool: string;
|
|
129
|
+
}): Promise<ProjectSyncToolPolicy | null> {
|
|
130
|
+
if (!isProjectManagedRoot(args)) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const config = await loadProjectSyncConfig({ rootDir: args.rootDir });
|
|
134
|
+
return config.tools[args.tool] ?? emptyPolicy();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function projectSyncAllowsNamedAsset(args: {
|
|
138
|
+
homeDir: string;
|
|
139
|
+
rootDir: string;
|
|
140
|
+
tool: string;
|
|
141
|
+
surface: ProjectSyncNamedSurface;
|
|
142
|
+
name: string;
|
|
143
|
+
}): Promise<boolean> {
|
|
144
|
+
const policy = await loadProjectToolSyncPolicy(args);
|
|
145
|
+
if (!policy) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
const allowed =
|
|
149
|
+
args.surface === "skills"
|
|
150
|
+
? policy.skills
|
|
151
|
+
: args.surface === "agents"
|
|
152
|
+
? policy.agents
|
|
153
|
+
: policy.mcpServers;
|
|
154
|
+
return includesExplicitName(allowed, args.name);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function projectSyncAllowsToolSurface(args: {
|
|
158
|
+
homeDir: string;
|
|
159
|
+
rootDir: string;
|
|
160
|
+
tool: string;
|
|
161
|
+
surface: ProjectSyncToolSurface;
|
|
162
|
+
}): Promise<boolean> {
|
|
163
|
+
const policy = await loadProjectToolSyncPolicy(args);
|
|
164
|
+
if (!policy) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (args.surface === "globalDocs") {
|
|
168
|
+
return policy.globalDocs;
|
|
169
|
+
}
|
|
170
|
+
if (args.surface === "toolRules") {
|
|
171
|
+
return policy.toolRules;
|
|
172
|
+
}
|
|
173
|
+
return policy.toolConfig;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function loadConfiguredProjectSyncTools(args: {
|
|
177
|
+
rootDir: string;
|
|
178
|
+
}): Promise<string[]> {
|
|
179
|
+
const config = await loadProjectSyncConfig({ rootDir: args.rootDir });
|
|
180
|
+
return Object.keys(config.tools).sort((a, b) => a.localeCompare(b));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function escapeTomlString(value: string): string {
|
|
184
|
+
return value
|
|
185
|
+
.replace(/\\/g, "\\\\")
|
|
186
|
+
.replace(/"/g, '\\"')
|
|
187
|
+
.replace(/\n/g, "\\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function formatTomlValue(value: unknown): string {
|
|
191
|
+
if (typeof value === "string") {
|
|
192
|
+
return `"${escapeTomlString(value)}"`;
|
|
193
|
+
}
|
|
194
|
+
if (typeof value === "boolean") {
|
|
195
|
+
return value ? "true" : "false";
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(value)) {
|
|
198
|
+
return `[${value.map((entry) => formatTomlValue(entry)).join(", ")}]`;
|
|
199
|
+
}
|
|
200
|
+
if (typeof value === "number" || typeof value === "bigint") {
|
|
201
|
+
return String(value);
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Unsupported TOML value: ${typeof value}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function stringifyTomlObject(obj: Record<string, unknown>): string {
|
|
207
|
+
const lines: string[] = [];
|
|
208
|
+
|
|
209
|
+
function emitTable(table: Record<string, unknown>, pathParts: string[] = []) {
|
|
210
|
+
const scalars: [string, unknown][] = [];
|
|
211
|
+
const subtables: [string, Record<string, unknown>][] = [];
|
|
212
|
+
|
|
213
|
+
for (const [key, value] of Object.entries(table)) {
|
|
214
|
+
if (isPlainObject(value)) {
|
|
215
|
+
subtables.push([key, value]);
|
|
216
|
+
} else {
|
|
217
|
+
scalars.push([key, value]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (pathParts.length > 0) {
|
|
222
|
+
if (lines.length > 0) {
|
|
223
|
+
lines.push("");
|
|
224
|
+
}
|
|
225
|
+
lines.push(`[${pathParts.join(".")}]`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const [key, value] of scalars) {
|
|
229
|
+
lines.push(`${key} = ${formatTomlValue(value)}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const [key, subtable] of subtables) {
|
|
233
|
+
emitTable(subtable, [...pathParts, key]);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
emitTable(obj);
|
|
238
|
+
return `${lines.join("\n")}\n`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function writeProjectSyncPolicy(args: {
|
|
242
|
+
rootDir: string;
|
|
243
|
+
toolPolicies: Record<string, Partial<ProjectSyncToolPolicy>>;
|
|
244
|
+
targetFile?: "config.toml" | "config.local.toml";
|
|
245
|
+
}): Promise<{ path: string; changed: boolean }> {
|
|
246
|
+
const targetFile = args.targetFile ?? "config.local.toml";
|
|
247
|
+
const targetPath = join(args.rootDir, targetFile);
|
|
248
|
+
const current = (await readTomlObject(targetPath)) ?? {};
|
|
249
|
+
const next = mergeTomlObjects(current, {});
|
|
250
|
+
const projectSync = isPlainObject(next.project_sync)
|
|
251
|
+
? { ...next.project_sync }
|
|
252
|
+
: {};
|
|
253
|
+
|
|
254
|
+
for (const [tool, partialPolicy] of Object.entries(args.toolPolicies)) {
|
|
255
|
+
const previousPolicy = projectSyncToolPolicyFromObject(projectSync[tool]);
|
|
256
|
+
const mergedPolicy: ProjectSyncToolPolicy = {
|
|
257
|
+
skills: parseStringList(partialPolicy.skills ?? previousPolicy.skills),
|
|
258
|
+
agents: parseStringList(partialPolicy.agents ?? previousPolicy.agents),
|
|
259
|
+
mcpServers: parseStringList(
|
|
260
|
+
partialPolicy.mcpServers ?? previousPolicy.mcpServers
|
|
261
|
+
),
|
|
262
|
+
globalDocs: partialPolicy.globalDocs ?? previousPolicy.globalDocs,
|
|
263
|
+
toolRules: partialPolicy.toolRules ?? previousPolicy.toolRules,
|
|
264
|
+
toolConfig: partialPolicy.toolConfig ?? previousPolicy.toolConfig,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
projectSync[tool] = {
|
|
268
|
+
skills: mergedPolicy.skills,
|
|
269
|
+
agents: mergedPolicy.agents,
|
|
270
|
+
mcp_servers: mergedPolicy.mcpServers,
|
|
271
|
+
global_docs: mergedPolicy.globalDocs,
|
|
272
|
+
tool_rules: mergedPolicy.toolRules,
|
|
273
|
+
tool_config: mergedPolicy.toolConfig,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
next.project_sync = projectSync;
|
|
278
|
+
const rendered = stringifyTomlObject(next);
|
|
279
|
+
const currentText = (await Bun.file(targetPath).exists())
|
|
280
|
+
? await Bun.file(targetPath).text()
|
|
281
|
+
: null;
|
|
282
|
+
if (currentText === rendered) {
|
|
283
|
+
return { path: targetPath, changed: false };
|
|
284
|
+
}
|
|
285
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
286
|
+
await Bun.write(targetPath, rendered);
|
|
287
|
+
return { path: targetPath, changed: true };
|
|
288
|
+
}
|