facult 2.6.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.
@@ -0,0 +1,132 @@
1
+ import { basename, dirname, join } from "node:path";
2
+ import { parseJsonLenient } from "./util/json";
3
+
4
+ const INLINE_SECRET_PLACEHOLDER_VALUES = new Set(["<set-me>", "<redacted>"]);
5
+ const INLINE_SECRET_ENV_REF_RE = /^\$\{[^}]+\}$/;
6
+
7
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
8
+ return !!value && typeof value === "object" && !Array.isArray(value);
9
+ }
10
+
11
+ export function isInlineMcpSecretValue(value: unknown): value is string {
12
+ if (typeof value !== "string") {
13
+ return false;
14
+ }
15
+ const trimmed = value.trim();
16
+ if (!trimmed) {
17
+ return false;
18
+ }
19
+ if (INLINE_SECRET_PLACEHOLDER_VALUES.has(trimmed)) {
20
+ return false;
21
+ }
22
+ if (INLINE_SECRET_ENV_REF_RE.test(trimmed)) {
23
+ return false;
24
+ }
25
+ return true;
26
+ }
27
+
28
+ export function extractServersObject(
29
+ parsed: unknown
30
+ ): Record<string, unknown> | null {
31
+ if (!isPlainObject(parsed)) {
32
+ return null;
33
+ }
34
+ const raw = parsed as Record<string, unknown>;
35
+ const servers =
36
+ (raw.servers as Record<string, unknown> | undefined) ??
37
+ (raw.mcpServers as Record<string, unknown> | undefined) ??
38
+ ((raw.mcp as Record<string, unknown> | undefined)?.servers as
39
+ | Record<string, unknown>
40
+ | undefined) ??
41
+ null;
42
+ if (servers && isPlainObject(servers)) {
43
+ return servers;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function mergeJsonObjects(
49
+ base: Record<string, unknown>,
50
+ override: Record<string, unknown>
51
+ ): Record<string, unknown> {
52
+ const out: Record<string, unknown> = { ...base };
53
+ for (const [key, value] of Object.entries(override)) {
54
+ const current = out[key];
55
+ if (isPlainObject(current) && isPlainObject(value)) {
56
+ out[key] = mergeJsonObjects(current, value);
57
+ continue;
58
+ }
59
+ out[key] = value;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export function canonicalMcpTrackedPath(rootDir: string): string {
65
+ return join(rootDir, "mcp", "servers.json");
66
+ }
67
+
68
+ export function canonicalMcpPaths(
69
+ rootDir: string,
70
+ trackedPath?: string | null
71
+ ): { trackedPath: string; localPath: string } {
72
+ const resolvedTrackedPath = trackedPath ?? canonicalMcpTrackedPath(rootDir);
73
+ const fileName = basename(resolvedTrackedPath);
74
+ const localFileName =
75
+ fileName === "mcp.json" ? "mcp.local.json" : "servers.local.json";
76
+ return {
77
+ trackedPath: resolvedTrackedPath,
78
+ localPath: join(dirname(resolvedTrackedPath), localFileName),
79
+ };
80
+ }
81
+
82
+ async function loadServersFromPath(
83
+ path: string
84
+ ): Promise<Record<string, unknown>> {
85
+ const file = Bun.file(path);
86
+ if (!(await file.exists())) {
87
+ return {};
88
+ }
89
+ try {
90
+ const parsed = parseJsonLenient(await file.text());
91
+ return extractServersObject(parsed) ?? {};
92
+ } catch {
93
+ return {};
94
+ }
95
+ }
96
+
97
+ export async function loadCanonicalMcpState(
98
+ rootDir: string,
99
+ opts?: { includeLocal?: boolean }
100
+ ): Promise<{
101
+ trackedPath: string;
102
+ localPath: string;
103
+ trackedServers: Record<string, unknown>;
104
+ localServers: Record<string, unknown>;
105
+ servers: Record<string, unknown>;
106
+ }> {
107
+ const serversPath = join(rootDir, "mcp", "servers.json");
108
+ const mcpPath = join(rootDir, "mcp", "mcp.json");
109
+
110
+ const trackedPath = (await Bun.file(serversPath).exists())
111
+ ? serversPath
112
+ : (await Bun.file(mcpPath).exists())
113
+ ? mcpPath
114
+ : serversPath;
115
+ const { localPath } = canonicalMcpPaths(rootDir, trackedPath);
116
+ const trackedServers = await loadServersFromPath(trackedPath);
117
+ const localServers =
118
+ opts?.includeLocal === true ? await loadServersFromPath(localPath) : {};
119
+ return {
120
+ trackedPath,
121
+ localPath,
122
+ trackedServers,
123
+ localServers,
124
+ servers: mergeJsonObjects(trackedServers, localServers),
125
+ };
126
+ }
127
+
128
+ export function stringifyCanonicalMcpServers(
129
+ servers: Record<string, unknown>
130
+ ): string {
131
+ return `${JSON.stringify({ servers }, null, 2)}\n`;
132
+ }
@@ -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
+ }