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/snippets.ts
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { facultRootDir } from "./paths";
|
|
4
|
+
|
|
5
|
+
export const SNIPPET_MARKER_RE = /<!--\s*(\/?)fclty:([^>]*?)\s*-->/g;
|
|
6
|
+
|
|
7
|
+
const VALID_MARKER_NAME_RE = /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/;
|
|
8
|
+
const WHITESPACE_RE = /\s/;
|
|
9
|
+
const NEWLINE_SPLIT_RE = /\r?\n/;
|
|
10
|
+
|
|
11
|
+
function normalizeNewlines(text: string): string {
|
|
12
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function snippetNameToRelPath(name: string): string {
|
|
16
|
+
// Marker names are validated and may contain slashes for scoping.
|
|
17
|
+
// Snippets are stored as markdown files, so map marker names to `<name>.md`.
|
|
18
|
+
return `${name}.md`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function trimTrailingWhitespacePerLine(text: string): string {
|
|
22
|
+
return normalizeNewlines(text)
|
|
23
|
+
.split("\n")
|
|
24
|
+
.map((line) => line.replace(/\s+$/g, ""))
|
|
25
|
+
.join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function validateSnippetMarkerName(name: string): string | null {
|
|
29
|
+
if (!name) {
|
|
30
|
+
return "Marker name cannot be empty.";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (name.trim() !== name) {
|
|
34
|
+
return "Marker name cannot have leading or trailing whitespace.";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (WHITESPACE_RE.test(name)) {
|
|
38
|
+
return "Marker name cannot contain whitespace.";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (name.startsWith("/") || name.endsWith("/")) {
|
|
42
|
+
return "Marker name cannot start or end with '/'";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (name.includes("..")) {
|
|
46
|
+
return "Marker name cannot contain '..' (path traversal).";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (name.includes("//")) {
|
|
50
|
+
return "Marker name cannot contain empty path segments ('//').";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!VALID_MARKER_NAME_RE.test(name)) {
|
|
54
|
+
return "Marker name may only contain letters, numbers, '-', '_', and '/' for scoping.";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function lineNumberAt(text: string, index: number): number {
|
|
61
|
+
if (index <= 0) {
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
return text.slice(0, index).split(NEWLINE_SPLIT_RE).length;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Marker {
|
|
68
|
+
name: string;
|
|
69
|
+
isClosing: boolean;
|
|
70
|
+
start: number;
|
|
71
|
+
end: number;
|
|
72
|
+
raw: string;
|
|
73
|
+
line: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MarkerPair {
|
|
77
|
+
name: string;
|
|
78
|
+
open: Marker;
|
|
79
|
+
close: Marker;
|
|
80
|
+
contentStart: number;
|
|
81
|
+
contentEnd: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function validateSnippetMarkersInText(
|
|
85
|
+
text: string,
|
|
86
|
+
filePath?: string
|
|
87
|
+
): string[] {
|
|
88
|
+
const errors: string[] = [];
|
|
89
|
+
for (const match of text.matchAll(SNIPPET_MARKER_RE)) {
|
|
90
|
+
const rawName = match[2] ?? "";
|
|
91
|
+
const name = rawName.trim();
|
|
92
|
+
const err = validateSnippetMarkerName(name);
|
|
93
|
+
if (!err) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const line = lineNumberAt(text, match.index ?? 0);
|
|
97
|
+
const location = filePath ? `${filePath}:${line}` : `line ${line}`;
|
|
98
|
+
errors.push(
|
|
99
|
+
`${location}: invalid snippet marker name "${rawName}": ${err}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return errors;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function findMarkersInText(
|
|
106
|
+
text: string,
|
|
107
|
+
filePath?: string
|
|
108
|
+
): { pairs: MarkerPair[]; errors: string[] } {
|
|
109
|
+
const errors: string[] = [];
|
|
110
|
+
const markers: Marker[] = [];
|
|
111
|
+
|
|
112
|
+
for (const match of text.matchAll(SNIPPET_MARKER_RE)) {
|
|
113
|
+
const rawName = match[2] ?? "";
|
|
114
|
+
const name = rawName.trim();
|
|
115
|
+
const err = validateSnippetMarkerName(name);
|
|
116
|
+
const start = match.index ?? 0;
|
|
117
|
+
const raw = match[0] ?? "";
|
|
118
|
+
const end = start + raw.length;
|
|
119
|
+
const line = lineNumberAt(text, start);
|
|
120
|
+
const isClosing = (match[1] ?? "") === "/";
|
|
121
|
+
|
|
122
|
+
if (err) {
|
|
123
|
+
const location = filePath ? `${filePath}:${line}` : `line ${line}`;
|
|
124
|
+
errors.push(
|
|
125
|
+
`${location}: invalid snippet marker name "${rawName}": ${err}`
|
|
126
|
+
);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
markers.push({ name, isClosing, start, end, raw, line });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const stack: Marker[] = [];
|
|
134
|
+
const pairs: MarkerPair[] = [];
|
|
135
|
+
|
|
136
|
+
for (const m of markers) {
|
|
137
|
+
if (!m.isClosing) {
|
|
138
|
+
stack.push(m);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Find matching open marker for recovery if nesting is broken.
|
|
143
|
+
let openIndex = -1;
|
|
144
|
+
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
145
|
+
if (stack[i]?.name === m.name) {
|
|
146
|
+
openIndex = i;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (openIndex < 0) {
|
|
152
|
+
const location = filePath ? `${filePath}:${m.line}` : `line ${m.line}`;
|
|
153
|
+
errors.push(`${location}: closing marker without opening: ${m.name}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (openIndex !== stack.length - 1) {
|
|
158
|
+
// Unclosed inner markers; report and drop them so we can continue.
|
|
159
|
+
const dropped = stack.slice(openIndex + 1);
|
|
160
|
+
for (const d of dropped) {
|
|
161
|
+
const loc = filePath ? `${filePath}:${d.line}` : `line ${d.line}`;
|
|
162
|
+
errors.push(
|
|
163
|
+
`${loc}: marker not closed before closing ${m.name}: ${d.name}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
stack.splice(openIndex + 1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const open = stack.pop();
|
|
170
|
+
if (!open) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
pairs.push({
|
|
175
|
+
name: m.name,
|
|
176
|
+
open,
|
|
177
|
+
close: m,
|
|
178
|
+
contentStart: open.end,
|
|
179
|
+
contentEnd: m.start,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const open of stack) {
|
|
184
|
+
const loc = filePath ? `${filePath}:${open.line}` : `line ${open.line}`;
|
|
185
|
+
errors.push(`${loc}: opening marker without closing: ${open.name}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
pairs: pairs.sort((a, b) => a.open.start - b.open.start),
|
|
190
|
+
errors,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeSnippetBody(text: string): string {
|
|
195
|
+
// Compare snippets and marker blocks in a whitespace-tolerant way to avoid churn.
|
|
196
|
+
return trimTrailingWhitespacePerLine(text).trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function formatSnippetInjection(snippet: string): string {
|
|
200
|
+
const normalized = trimTrailingWhitespacePerLine(snippet).trimEnd();
|
|
201
|
+
return `\n${normalized}\n`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface SnippetResolution {
|
|
205
|
+
/** The marker name requested (e.g. "codingstyle", "global/codingstyle", "myproj/context"). */
|
|
206
|
+
marker: string;
|
|
207
|
+
/** The snippet file path used for injection. */
|
|
208
|
+
path: string;
|
|
209
|
+
/** Global or project snippet. */
|
|
210
|
+
scope: "global" | "project";
|
|
211
|
+
/** Project name when scope=project. */
|
|
212
|
+
project?: string;
|
|
213
|
+
/** Snippet file contents. */
|
|
214
|
+
content: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface SyncChange {
|
|
218
|
+
marker: string;
|
|
219
|
+
status: "updated" | "unchanged" | "not-found" | "error";
|
|
220
|
+
snippetPath?: string;
|
|
221
|
+
lines?: number;
|
|
222
|
+
message?: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface SyncResult {
|
|
226
|
+
filePath: string;
|
|
227
|
+
dryRun: boolean;
|
|
228
|
+
changed: boolean;
|
|
229
|
+
changes: SyncChange[];
|
|
230
|
+
errors: string[];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isSafePathString(p: string): boolean {
|
|
234
|
+
// Protect filesystem APIs from null-byte paths.
|
|
235
|
+
return !p.includes("\0");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
239
|
+
try {
|
|
240
|
+
const st = await Bun.file(p).stat();
|
|
241
|
+
return st.isFile();
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function dirExists(p: string): Promise<boolean> {
|
|
248
|
+
try {
|
|
249
|
+
const st = await Bun.file(p).stat();
|
|
250
|
+
return st.isDirectory();
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function detectProjectForFile(filePath: string): Promise<string | null> {
|
|
257
|
+
// Find the nearest parent directory containing a `.git` entry (dir or file).
|
|
258
|
+
// Use that directory basename as the project name.
|
|
259
|
+
let dir = dirname(resolve(filePath));
|
|
260
|
+
for (let i = 0; i < 50; i += 1) {
|
|
261
|
+
const git = join(dir, ".git");
|
|
262
|
+
try {
|
|
263
|
+
const st = await Bun.file(git).stat();
|
|
264
|
+
if (st.isDirectory() || st.isFile()) {
|
|
265
|
+
return basename(dir);
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// ignore
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const parent = dirname(dir);
|
|
272
|
+
if (parent === dir) {
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
dir = parent;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function markerToCandidates(args: {
|
|
281
|
+
rootDir: string;
|
|
282
|
+
marker: string;
|
|
283
|
+
projectContext?: string | null;
|
|
284
|
+
}): { scope: "global" | "project"; path: string; project?: string }[] {
|
|
285
|
+
const { rootDir, marker, projectContext } = args;
|
|
286
|
+
const snippetsDir = join(rootDir, "snippets");
|
|
287
|
+
|
|
288
|
+
const parts = marker.split("/").filter(Boolean);
|
|
289
|
+
const scope = parts[0];
|
|
290
|
+
|
|
291
|
+
// Explicit global.
|
|
292
|
+
if (scope === "global" && parts.length >= 2) {
|
|
293
|
+
const rel = parts.slice(1).join("/");
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
scope: "global",
|
|
297
|
+
path: join(snippetsDir, "global", snippetNameToRelPath(rel)),
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Explicit project scope: "<project>/<name...>".
|
|
303
|
+
if (parts.length >= 2) {
|
|
304
|
+
const project = parts[0]!;
|
|
305
|
+
const rel = parts.slice(1).join("/");
|
|
306
|
+
return [
|
|
307
|
+
{
|
|
308
|
+
scope: "project",
|
|
309
|
+
project,
|
|
310
|
+
path: join(snippetsDir, "projects", project, snippetNameToRelPath(rel)),
|
|
311
|
+
},
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Implicit: prefer project override, then global.
|
|
316
|
+
const rel = marker;
|
|
317
|
+
const out: { scope: "global" | "project"; path: string; project?: string }[] =
|
|
318
|
+
[];
|
|
319
|
+
if (projectContext) {
|
|
320
|
+
out.push({
|
|
321
|
+
scope: "project",
|
|
322
|
+
project: projectContext,
|
|
323
|
+
path: join(
|
|
324
|
+
snippetsDir,
|
|
325
|
+
"projects",
|
|
326
|
+
projectContext,
|
|
327
|
+
snippetNameToRelPath(rel)
|
|
328
|
+
),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
out.push({
|
|
332
|
+
scope: "global",
|
|
333
|
+
path: join(snippetsDir, "global", snippetNameToRelPath(rel)),
|
|
334
|
+
});
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function findSnippet(args: {
|
|
339
|
+
marker: string;
|
|
340
|
+
/** Project context for implicit markers (git repo basename). */
|
|
341
|
+
project?: string | null;
|
|
342
|
+
/** Override canonical root (useful for tests). */
|
|
343
|
+
rootDir?: string;
|
|
344
|
+
}): Promise<SnippetResolution | null> {
|
|
345
|
+
const rootDir = args.rootDir ?? facultRootDir();
|
|
346
|
+
const marker = args.marker;
|
|
347
|
+
|
|
348
|
+
const candidates = markerToCandidates({
|
|
349
|
+
rootDir,
|
|
350
|
+
marker,
|
|
351
|
+
projectContext: args.project ?? null,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
for (const c of candidates) {
|
|
355
|
+
if (!isSafePathString(c.path)) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (!(await fileExists(c.path))) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const content = await Bun.file(c.path).text();
|
|
362
|
+
return {
|
|
363
|
+
marker,
|
|
364
|
+
path: c.path,
|
|
365
|
+
scope: c.scope,
|
|
366
|
+
project: c.project,
|
|
367
|
+
content,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function hasMarkers(text: string): boolean {
|
|
375
|
+
return text.includes("fclty:");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function listFilesRecursive(dir: string): Promise<string[]> {
|
|
379
|
+
const out: string[] = [];
|
|
380
|
+
const stack: string[] = [dir];
|
|
381
|
+
while (stack.length) {
|
|
382
|
+
const current = stack.pop();
|
|
383
|
+
if (!current) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
let entries: any[];
|
|
387
|
+
try {
|
|
388
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
389
|
+
} catch {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
for (const ent of entries) {
|
|
393
|
+
const name = String(ent?.name ?? "");
|
|
394
|
+
if (!name) {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (ent.isSymbolicLink?.()) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const abs = join(current, name);
|
|
401
|
+
if (ent.isDirectory?.()) {
|
|
402
|
+
stack.push(abs);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (ent.isFile?.()) {
|
|
406
|
+
out.push(abs);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return out.sort();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function countLines(text: string): number {
|
|
414
|
+
const norm = normalizeNewlines(text);
|
|
415
|
+
const trimmed = norm.trim();
|
|
416
|
+
if (!trimmed) {
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
419
|
+
return trimmed.split("\n").length;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function syncFile(args: {
|
|
423
|
+
filePath: string;
|
|
424
|
+
dryRun?: boolean;
|
|
425
|
+
/** Override canonical root (useful for tests). */
|
|
426
|
+
rootDir?: string;
|
|
427
|
+
}): Promise<SyncResult> {
|
|
428
|
+
const dryRun = Boolean(args.dryRun);
|
|
429
|
+
const filePath = args.filePath;
|
|
430
|
+
const rootDir = args.rootDir ?? facultRootDir();
|
|
431
|
+
|
|
432
|
+
const errors: string[] = [];
|
|
433
|
+
const changes: SyncChange[] = [];
|
|
434
|
+
|
|
435
|
+
if (!isSafePathString(filePath)) {
|
|
436
|
+
return {
|
|
437
|
+
filePath,
|
|
438
|
+
dryRun,
|
|
439
|
+
changed: false,
|
|
440
|
+
changes: [],
|
|
441
|
+
errors: [`Ignored unsafe path: ${filePath}`],
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const file = Bun.file(filePath);
|
|
446
|
+
if (!(await file.exists())) {
|
|
447
|
+
return {
|
|
448
|
+
filePath,
|
|
449
|
+
dryRun,
|
|
450
|
+
changed: false,
|
|
451
|
+
changes: [],
|
|
452
|
+
errors: [`File not found: ${filePath}`],
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let text: string;
|
|
457
|
+
try {
|
|
458
|
+
text = await file.text();
|
|
459
|
+
} catch (e: unknown) {
|
|
460
|
+
return {
|
|
461
|
+
filePath,
|
|
462
|
+
dryRun,
|
|
463
|
+
changed: false,
|
|
464
|
+
changes: [],
|
|
465
|
+
errors: [`Failed to read file: ${filePath}`],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!hasMarkers(text)) {
|
|
470
|
+
return { filePath, dryRun, changed: false, changes: [], errors: [] };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const found = findMarkersInText(text, filePath);
|
|
474
|
+
if (found.errors.length) {
|
|
475
|
+
return {
|
|
476
|
+
filePath,
|
|
477
|
+
dryRun,
|
|
478
|
+
changed: false,
|
|
479
|
+
changes: [],
|
|
480
|
+
errors: found.errors,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Disallow overlapping/nested marker blocks for safety (v1).
|
|
485
|
+
let lastEnd = -1;
|
|
486
|
+
for (const p of found.pairs) {
|
|
487
|
+
if (p.open.start < lastEnd) {
|
|
488
|
+
errors.push(
|
|
489
|
+
`${filePath}:${p.open.line}: snippet markers may not be nested or overlapping (found ${p.name})`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
lastEnd = p.close.end;
|
|
493
|
+
}
|
|
494
|
+
if (errors.length) {
|
|
495
|
+
return {
|
|
496
|
+
filePath,
|
|
497
|
+
dryRun,
|
|
498
|
+
changed: false,
|
|
499
|
+
changes: [],
|
|
500
|
+
errors,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const project = await detectProjectForFile(filePath);
|
|
505
|
+
|
|
506
|
+
type Replacement = { start: number; end: number; next: string };
|
|
507
|
+
const replacements: Replacement[] = [];
|
|
508
|
+
|
|
509
|
+
for (const pair of found.pairs) {
|
|
510
|
+
const marker = pair.name;
|
|
511
|
+
const existing = text.slice(pair.contentStart, pair.contentEnd);
|
|
512
|
+
const existingNorm = normalizeSnippetBody(existing);
|
|
513
|
+
|
|
514
|
+
const snippet = await findSnippet({
|
|
515
|
+
marker,
|
|
516
|
+
project,
|
|
517
|
+
rootDir,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
if (!snippet) {
|
|
521
|
+
changes.push({ marker, status: "not-found" });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const snippetNorm = normalizeSnippetBody(snippet.content);
|
|
526
|
+
if (existingNorm === snippetNorm) {
|
|
527
|
+
changes.push({
|
|
528
|
+
marker,
|
|
529
|
+
status: "unchanged",
|
|
530
|
+
snippetPath: snippet.path,
|
|
531
|
+
lines: countLines(snippetNorm),
|
|
532
|
+
});
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const injection = formatSnippetInjection(snippet.content);
|
|
537
|
+
replacements.push({
|
|
538
|
+
start: pair.contentStart,
|
|
539
|
+
end: pair.contentEnd,
|
|
540
|
+
next: injection,
|
|
541
|
+
});
|
|
542
|
+
changes.push({
|
|
543
|
+
marker,
|
|
544
|
+
status: "updated",
|
|
545
|
+
snippetPath: snippet.path,
|
|
546
|
+
lines: countLines(snippetNorm),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (replacements.length === 0) {
|
|
551
|
+
return { filePath, dryRun, changed: false, changes, errors: [] };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Apply replacements back-to-front to keep indices valid.
|
|
555
|
+
let updated = text;
|
|
556
|
+
for (const r of replacements.sort((a, b) => b.start - a.start)) {
|
|
557
|
+
updated = updated.slice(0, r.start) + r.next + updated.slice(r.end);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (!dryRun) {
|
|
561
|
+
await Bun.write(filePath, updated);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return { filePath, dryRun, changed: true, changes, errors: [] };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export async function syncAll(args?: {
|
|
568
|
+
dryRun?: boolean;
|
|
569
|
+
/** Override canonical root (useful for tests). */
|
|
570
|
+
rootDir?: string;
|
|
571
|
+
/** Optional explicit file list; if provided, only sync these. */
|
|
572
|
+
files?: string[];
|
|
573
|
+
}): Promise<SyncResult[]> {
|
|
574
|
+
const dryRun = Boolean(args?.dryRun);
|
|
575
|
+
const rootDir = args?.rootDir ?? facultRootDir();
|
|
576
|
+
|
|
577
|
+
const files: string[] = [];
|
|
578
|
+
|
|
579
|
+
if (args?.files && args.files.length) {
|
|
580
|
+
for (const p of args.files) {
|
|
581
|
+
if (p) {
|
|
582
|
+
files.push(p);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
} else {
|
|
586
|
+
const agentsDir = join(rootDir, "agents");
|
|
587
|
+
if (await dirExists(agentsDir)) {
|
|
588
|
+
const discovered = await listFilesRecursive(agentsDir);
|
|
589
|
+
for (const p of discovered) {
|
|
590
|
+
files.push(p);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const results: SyncResult[] = [];
|
|
596
|
+
for (const p of files.sort()) {
|
|
597
|
+
// Only attempt to sync files that look like they contain markers.
|
|
598
|
+
try {
|
|
599
|
+
const f = Bun.file(p);
|
|
600
|
+
if (!(await f.exists())) {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const preview = await f.text();
|
|
604
|
+
if (!hasMarkers(preview)) {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
} catch {
|
|
608
|
+
// ignore unreadable files
|
|
609
|
+
}
|
|
610
|
+
results.push(await syncFile({ filePath: p, dryRun, rootDir }));
|
|
611
|
+
}
|
|
612
|
+
return results;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export async function listSnippets(args?: {
|
|
616
|
+
/** Override canonical root (useful for tests). */
|
|
617
|
+
rootDir?: string;
|
|
618
|
+
}): Promise<{ marker: string; path: string }[]> {
|
|
619
|
+
const rootDir = args?.rootDir ?? facultRootDir();
|
|
620
|
+
const snippetsDir = join(rootDir, "snippets");
|
|
621
|
+
if (!(await dirExists(snippetsDir))) {
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const out: { marker: string; path: string }[] = [];
|
|
626
|
+
const glob = new Bun.Glob("**/*.md");
|
|
627
|
+
for await (const rel of glob.scan({ cwd: snippetsDir, onlyFiles: true })) {
|
|
628
|
+
const abs = join(snippetsDir, rel);
|
|
629
|
+
if (!isSafePathString(abs)) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// rel is like:
|
|
634
|
+
// - global/foo.md
|
|
635
|
+
// - projects/myproj/bar.md
|
|
636
|
+
if (!rel.endsWith(".md")) {
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const noExt = rel.slice(0, -".md".length);
|
|
641
|
+
const parts = noExt.split("/").filter(Boolean);
|
|
642
|
+
if (parts[0] === "global") {
|
|
643
|
+
const name = parts.slice(1).join("/");
|
|
644
|
+
if (name) {
|
|
645
|
+
out.push({ marker: `global/${name}`, path: abs });
|
|
646
|
+
}
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (parts[0] === "projects" && parts.length >= 3) {
|
|
650
|
+
const project = parts[1]!;
|
|
651
|
+
const name = parts.slice(2).join("/");
|
|
652
|
+
out.push({ marker: `${project}/${name}`, path: abs });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return out.sort((a, b) => a.marker.localeCompare(b.marker));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export async function ensureSnippetFile(args: {
|
|
660
|
+
marker: string;
|
|
661
|
+
/** Override canonical root (useful for tests). */
|
|
662
|
+
rootDir?: string;
|
|
663
|
+
}): Promise<{ path: string; created: boolean }> {
|
|
664
|
+
const rootDir = args.rootDir ?? facultRootDir();
|
|
665
|
+
const marker = args.marker;
|
|
666
|
+
|
|
667
|
+
const parts = marker.split("/").filter(Boolean);
|
|
668
|
+
if (parts[0] === "global" && parts.length >= 2) {
|
|
669
|
+
const rel = parts.slice(1).join("/");
|
|
670
|
+
const p = join(rootDir, "snippets", "global", snippetNameToRelPath(rel));
|
|
671
|
+
await mkdir(dirname(p), { recursive: true });
|
|
672
|
+
const exists = await fileExists(p);
|
|
673
|
+
if (!exists) {
|
|
674
|
+
await Bun.write(p, "");
|
|
675
|
+
}
|
|
676
|
+
return { path: p, created: !exists };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// If marker includes an explicit project scope, create under that project.
|
|
680
|
+
if (parts.length >= 2) {
|
|
681
|
+
const project = parts[0]!;
|
|
682
|
+
const rel = parts.slice(1).join("/");
|
|
683
|
+
const p = join(
|
|
684
|
+
rootDir,
|
|
685
|
+
"snippets",
|
|
686
|
+
"projects",
|
|
687
|
+
project,
|
|
688
|
+
snippetNameToRelPath(rel)
|
|
689
|
+
);
|
|
690
|
+
await mkdir(dirname(p), { recursive: true });
|
|
691
|
+
const exists = await fileExists(p);
|
|
692
|
+
if (!exists) {
|
|
693
|
+
await Bun.write(p, "");
|
|
694
|
+
}
|
|
695
|
+
return { path: p, created: !exists };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Default: create a global snippet for unscoped names.
|
|
699
|
+
const p = join(rootDir, "snippets", "global", snippetNameToRelPath(marker));
|
|
700
|
+
await mkdir(dirname(p), { recursive: true });
|
|
701
|
+
const exists = await fileExists(p);
|
|
702
|
+
if (!exists) {
|
|
703
|
+
await Bun.write(p, "");
|
|
704
|
+
}
|
|
705
|
+
return { path: p, created: !exists };
|
|
706
|
+
}
|