facult 1.0.2 → 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 -18
- package/bin/facult.cjs +6 -37
- 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/paths.ts
CHANGED
|
@@ -43,6 +43,11 @@ function isSafePathString(p: string): boolean {
|
|
|
43
43
|
return !p.includes("\0");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function defaultHomeDir(): string {
|
|
47
|
+
const fromEnv = process.env.HOME?.trim();
|
|
48
|
+
return fromEnv || homedir();
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
function expandHomePath(p: string, home: string): string {
|
|
47
52
|
if (p === "~") {
|
|
48
53
|
return home;
|
|
@@ -74,13 +79,22 @@ function fileExists(p: string): boolean {
|
|
|
74
79
|
}
|
|
75
80
|
}
|
|
76
81
|
|
|
82
|
+
function legacyPreferredRoot(home: string): string {
|
|
83
|
+
return join(home, "agents", ".facult");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isLegacyConfiguredRoot(root: string, home: string): boolean {
|
|
87
|
+
return resolve(root) === resolve(legacyPreferredRoot(home));
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
function looksLikeFacultRoot(root: string): boolean {
|
|
78
91
|
if (!dirExists(root)) {
|
|
79
92
|
return false;
|
|
80
93
|
}
|
|
81
94
|
// Heuristic: treat as a facult store if it contains something we'd create.
|
|
82
95
|
return (
|
|
83
|
-
|
|
96
|
+
dirExists(join(root, "rules")) ||
|
|
97
|
+
dirExists(join(root, "agents")) ||
|
|
84
98
|
dirExists(join(root, "skills")) ||
|
|
85
99
|
dirExists(join(root, "mcp")) ||
|
|
86
100
|
dirExists(join(root, "snippets"))
|
|
@@ -117,16 +131,24 @@ function detectLegacyStoreUnderAgents(home: string): string | null {
|
|
|
117
131
|
return candidates.length === 1 ? (candidates[0] ?? null) : null;
|
|
118
132
|
}
|
|
119
133
|
|
|
120
|
-
export function facultStateDir(home: string =
|
|
134
|
+
export function facultStateDir(home: string = defaultHomeDir()): string {
|
|
121
135
|
return join(home, ".facult");
|
|
122
136
|
}
|
|
123
137
|
|
|
124
|
-
export function
|
|
138
|
+
export function facultAiStateDir(home: string = defaultHomeDir()): string {
|
|
139
|
+
return join(facultStateDir(home), "ai");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function facultAiIndexPath(home: string = defaultHomeDir()): string {
|
|
143
|
+
return join(facultAiStateDir(home), "index.json");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function facultConfigPath(home: string = defaultHomeDir()): string {
|
|
125
147
|
return join(facultStateDir(home), "config.json");
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
export function readFacultConfig(
|
|
129
|
-
home: string =
|
|
151
|
+
home: string = defaultHomeDir()
|
|
130
152
|
): FacultConfig | null {
|
|
131
153
|
const p = facultConfigPath(home);
|
|
132
154
|
if (!(isSafePathString(p) && fileExists(p))) {
|
|
@@ -203,33 +225,43 @@ export function readFacultConfig(
|
|
|
203
225
|
* Precedence:
|
|
204
226
|
* 1) `FACULT_ROOT_DIR` env var
|
|
205
227
|
* 2) `~/.facult/config.json` { "rootDir": "..." }
|
|
206
|
-
* 3)
|
|
207
|
-
* 4)
|
|
208
|
-
* 5)
|
|
228
|
+
* 3) `~/.ai` (if it looks like a store)
|
|
229
|
+
* 4) `~/agents/.facult` (if it looks like a store)
|
|
230
|
+
* 5) a legacy store under `~/agents/` (if it looks like a store)
|
|
231
|
+
* 6) default: `~/.ai`
|
|
209
232
|
*/
|
|
210
|
-
export function facultRootDir(home: string =
|
|
233
|
+
export function facultRootDir(home: string = defaultHomeDir()): string {
|
|
211
234
|
const envRoot = process.env.FACULT_ROOT_DIR?.trim();
|
|
235
|
+
const preferred = join(home, ".ai");
|
|
236
|
+
|
|
212
237
|
if (envRoot) {
|
|
213
238
|
const resolved = resolvePath(envRoot, home);
|
|
214
|
-
return isSafePathString(resolved)
|
|
215
|
-
? resolved
|
|
216
|
-
: join(home, "agents", ".facult");
|
|
239
|
+
return isSafePathString(resolved) ? resolved : preferred;
|
|
217
240
|
}
|
|
218
241
|
|
|
219
242
|
const cfg = readFacultConfig(home);
|
|
220
243
|
const cfgRoot = cfg?.rootDir?.trim();
|
|
221
244
|
if (cfgRoot) {
|
|
222
245
|
const resolved = resolvePath(cfgRoot, home);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
246
|
+
if (!isSafePathString(resolved)) {
|
|
247
|
+
return preferred;
|
|
248
|
+
}
|
|
249
|
+
if (
|
|
250
|
+
isLegacyConfiguredRoot(resolved, home) &&
|
|
251
|
+
looksLikeFacultRoot(preferred)
|
|
252
|
+
) {
|
|
253
|
+
return preferred;
|
|
254
|
+
}
|
|
255
|
+
return resolved;
|
|
226
256
|
}
|
|
227
257
|
|
|
228
|
-
const preferred = join(home, "agents", ".facult");
|
|
229
|
-
|
|
230
258
|
if (looksLikeFacultRoot(preferred)) {
|
|
231
259
|
return preferred;
|
|
232
260
|
}
|
|
261
|
+
const legacyPreferred = legacyPreferredRoot(home);
|
|
262
|
+
if (looksLikeFacultRoot(legacyPreferred)) {
|
|
263
|
+
return legacyPreferred;
|
|
264
|
+
}
|
|
233
265
|
const legacy = detectLegacyStoreUnderAgents(home);
|
|
234
266
|
if (legacy) {
|
|
235
267
|
return legacy;
|
package/src/query.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import {
|
|
2
|
+
import { ensureAiIndexPath } from "./ai-state";
|
|
3
3
|
import type {
|
|
4
4
|
AgentEntry,
|
|
5
5
|
FacultIndex,
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
SkillEntry,
|
|
8
8
|
SnippetEntry,
|
|
9
9
|
} from "./index-builder";
|
|
10
|
-
import { facultRootDir } from "./paths";
|
|
10
|
+
import { facultAiIndexPath, facultRootDir } from "./paths";
|
|
11
11
|
import { applyOrgTrustList } from "./trust-list";
|
|
12
12
|
|
|
13
13
|
export interface QueryFilters {
|
|
@@ -108,7 +108,7 @@ export function facultRootDirPath(home: string = homedir()): string {
|
|
|
108
108
|
* Return the path to the facult index.json file.
|
|
109
109
|
*/
|
|
110
110
|
export function facultIndexPath(home: string = homedir()): string {
|
|
111
|
-
return
|
|
111
|
+
return facultAiIndexPath(home);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/**
|
|
@@ -120,15 +120,24 @@ export async function loadIndex(opts?: {
|
|
|
120
120
|
/** Override home directory for org trust-list loading (useful for tests). */
|
|
121
121
|
homeDir?: string;
|
|
122
122
|
}): Promise<FacultIndex> {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
123
|
+
const homeDir = opts?.homeDir;
|
|
124
|
+
const resolvedHome = homeDir ?? process.env.HOME;
|
|
125
|
+
if (!resolvedHome) {
|
|
126
|
+
throw new Error("HOME is not set.");
|
|
127
|
+
}
|
|
128
|
+
const rootDir = opts?.rootDir ?? facultRootDir(resolvedHome);
|
|
129
|
+
const { path: indexPath } = await ensureAiIndexPath({
|
|
130
|
+
homeDir: resolvedHome,
|
|
131
|
+
rootDir,
|
|
132
|
+
repair: true,
|
|
133
|
+
});
|
|
125
134
|
const file = Bun.file(indexPath);
|
|
126
135
|
if (!(await file.exists())) {
|
|
127
136
|
throw new Error(`Index not found at ${indexPath}. Run "facult index".`);
|
|
128
137
|
}
|
|
129
138
|
const raw = await file.text();
|
|
130
139
|
const parsed = JSON.parse(raw) as FacultIndex;
|
|
131
|
-
return await applyOrgTrustList(parsed, { homeDir:
|
|
140
|
+
return await applyOrgTrustList(parsed, { homeDir: resolvedHome });
|
|
132
141
|
}
|
|
133
142
|
|
|
134
143
|
/**
|
package/src/remote.ts
CHANGED
|
@@ -1106,7 +1106,11 @@ async function installParsedItem(args: {
|
|
|
1106
1106
|
updatedAt: nowIso(args.now),
|
|
1107
1107
|
items: dedup.sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
1108
1108
|
});
|
|
1109
|
-
await buildIndex({
|
|
1109
|
+
await buildIndex({
|
|
1110
|
+
rootDir: args.rootDir,
|
|
1111
|
+
homeDir: args.homeDir,
|
|
1112
|
+
force: false,
|
|
1113
|
+
});
|
|
1110
1114
|
return result;
|
|
1111
1115
|
}
|
|
1112
1116
|
|
package/src/snippets.ts
CHANGED
|
@@ -230,6 +230,12 @@ export interface SyncResult {
|
|
|
230
230
|
errors: string[];
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
export interface RenderSnippetTextResult {
|
|
234
|
+
text: string;
|
|
235
|
+
changes: SyncChange[];
|
|
236
|
+
errors: string[];
|
|
237
|
+
}
|
|
238
|
+
|
|
233
239
|
function isSafePathString(p: string): boolean {
|
|
234
240
|
// Protect filesystem APIs from null-byte paths.
|
|
235
241
|
return !p.includes("\0");
|
|
@@ -564,6 +570,106 @@ export async function syncFile(args: {
|
|
|
564
570
|
return { filePath, dryRun, changed: true, changes, errors: [] };
|
|
565
571
|
}
|
|
566
572
|
|
|
573
|
+
export async function renderSnippetText(args: {
|
|
574
|
+
text: string;
|
|
575
|
+
project?: string | null;
|
|
576
|
+
filePath?: string;
|
|
577
|
+
rootDir?: string;
|
|
578
|
+
}): Promise<RenderSnippetTextResult> {
|
|
579
|
+
const rootDir = args.rootDir ?? facultRootDir();
|
|
580
|
+
const text = args.text;
|
|
581
|
+
const filePath = args.filePath;
|
|
582
|
+
const errors: string[] = [];
|
|
583
|
+
const changes: SyncChange[] = [];
|
|
584
|
+
|
|
585
|
+
if (!hasMarkers(text)) {
|
|
586
|
+
return { text, changes, errors };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const found = findMarkersInText(text, filePath);
|
|
590
|
+
if (found.errors.length) {
|
|
591
|
+
return {
|
|
592
|
+
text,
|
|
593
|
+
changes,
|
|
594
|
+
errors: found.errors,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
let lastEnd = -1;
|
|
599
|
+
for (const p of found.pairs) {
|
|
600
|
+
if (p.open.start < lastEnd) {
|
|
601
|
+
const location = filePath
|
|
602
|
+
? `${filePath}:${p.open.line}`
|
|
603
|
+
: `line ${p.open.line}`;
|
|
604
|
+
errors.push(
|
|
605
|
+
`${location}: snippet markers may not be nested or overlapping (found ${p.name})`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
lastEnd = p.close.end;
|
|
609
|
+
}
|
|
610
|
+
if (errors.length) {
|
|
611
|
+
return { text, changes, errors };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
type Replacement = { start: number; end: number; next: string };
|
|
615
|
+
const replacements: Replacement[] = [];
|
|
616
|
+
|
|
617
|
+
for (const pair of found.pairs) {
|
|
618
|
+
const marker = pair.name;
|
|
619
|
+
const existing = text.slice(pair.contentStart, pair.contentEnd);
|
|
620
|
+
const existingNorm = normalizeSnippetBody(existing);
|
|
621
|
+
|
|
622
|
+
const snippet = await findSnippet({
|
|
623
|
+
marker,
|
|
624
|
+
project: args.project ?? null,
|
|
625
|
+
rootDir,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
if (!snippet) {
|
|
629
|
+
changes.push({ marker, status: "not-found" });
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const snippetNorm = normalizeSnippetBody(snippet.content);
|
|
634
|
+
if (existingNorm === snippetNorm) {
|
|
635
|
+
changes.push({
|
|
636
|
+
marker,
|
|
637
|
+
status: "unchanged",
|
|
638
|
+
snippetPath: snippet.path,
|
|
639
|
+
lines: countLines(snippetNorm),
|
|
640
|
+
});
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
replacements.push({
|
|
645
|
+
start: pair.contentStart,
|
|
646
|
+
end: pair.contentEnd,
|
|
647
|
+
next: formatSnippetInjection(snippet.content),
|
|
648
|
+
});
|
|
649
|
+
changes.push({
|
|
650
|
+
marker,
|
|
651
|
+
status: "updated",
|
|
652
|
+
snippetPath: snippet.path,
|
|
653
|
+
lines: countLines(snippetNorm),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (replacements.length === 0) {
|
|
658
|
+
return { text, changes, errors };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let updated = text;
|
|
662
|
+
for (const r of replacements.sort((a, b) => b.start - a.start)) {
|
|
663
|
+
updated = updated.slice(0, r.start) + r.next + updated.slice(r.end);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
text: updated,
|
|
668
|
+
changes,
|
|
669
|
+
errors,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
567
673
|
export async function syncAll(args?: {
|
|
568
674
|
dryRun?: boolean;
|
|
569
675
|
/** Override canonical root (useful for tests). */
|
package/src/trust.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import {
|
|
2
|
+
import { ensureAiIndexPath } from "./ai-state";
|
|
3
3
|
import type { FacultIndex } from "./index-builder";
|
|
4
|
-
import { facultRootDir } from "./paths";
|
|
4
|
+
import { facultAiIndexPath, facultRootDir } from "./paths";
|
|
5
5
|
|
|
6
6
|
type TrustMode = "trust" | "untrust";
|
|
7
7
|
|
|
@@ -27,8 +27,12 @@ function parseEntryName(raw: string): { kind: "skill" | "mcp"; name: string } {
|
|
|
27
27
|
return { kind: "skill", name: raw };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
async function loadIndex(
|
|
31
|
-
const indexPath =
|
|
30
|
+
async function loadIndex(homeDir: string): Promise<FacultIndex> {
|
|
31
|
+
const { path: indexPath } = await ensureAiIndexPath({
|
|
32
|
+
homeDir,
|
|
33
|
+
rootDir: facultRootDir(homeDir),
|
|
34
|
+
repair: true,
|
|
35
|
+
});
|
|
32
36
|
const file = Bun.file(indexPath);
|
|
33
37
|
if (!(await file.exists())) {
|
|
34
38
|
throw new Error(`Index not found at ${indexPath}. Run "facult index".`);
|
|
@@ -37,8 +41,8 @@ async function loadIndex(rootDir: string): Promise<FacultIndex> {
|
|
|
37
41
|
return JSON.parse(raw) as FacultIndex;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
async function writeIndex(
|
|
41
|
-
const indexPath =
|
|
44
|
+
async function writeIndex(homeDir: string, index: FacultIndex) {
|
|
45
|
+
const indexPath = facultAiIndexPath(homeDir);
|
|
42
46
|
await Bun.write(indexPath, `${JSON.stringify(index, null, 2)}\n`);
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -68,20 +72,17 @@ export async function applyTrust({
|
|
|
68
72
|
names,
|
|
69
73
|
mode,
|
|
70
74
|
homeDir,
|
|
71
|
-
rootDir,
|
|
72
75
|
}: {
|
|
73
76
|
names: string[];
|
|
74
77
|
mode: TrustMode;
|
|
75
78
|
homeDir?: string;
|
|
76
|
-
rootDir?: string;
|
|
77
79
|
}) {
|
|
78
80
|
if (!names.length) {
|
|
79
81
|
throw new Error("At least one name is required.");
|
|
80
82
|
}
|
|
81
83
|
const home = homeDir ?? homedir();
|
|
82
|
-
const root = rootDir ?? facultRootDir(home);
|
|
83
84
|
|
|
84
|
-
const index = ensureIndexStructure(await loadIndex(
|
|
85
|
+
const index = ensureIndexStructure(await loadIndex(home));
|
|
85
86
|
const now = new Date().toISOString();
|
|
86
87
|
|
|
87
88
|
const missing: string[] = [];
|
|
@@ -109,7 +110,7 @@ export async function applyTrust({
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
index.updatedAt = new Date().toISOString();
|
|
112
|
-
await writeIndex(
|
|
113
|
+
await writeIndex(home, index);
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
function parseNamesFromArgv(argv: string[]): string[] {
|