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/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
- fileExists(join(root, "index.json")) ||
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 = homedir()): string {
134
+ export function facultStateDir(home: string = defaultHomeDir()): string {
121
135
  return join(home, ".facult");
122
136
  }
123
137
 
124
- export function facultConfigPath(home: string = homedir()): string {
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 = homedir()
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) `~/agents/.facult` (if it looks like a store)
207
- * 4) a legacy store under `~/agents/` (if it looks like a store)
208
- * 5) default: `~/agents/.facult`
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 = homedir()): 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
- return isSafePathString(resolved)
224
- ? resolved
225
- : join(home, "agents", ".facult");
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 { join } from "node:path";
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 join(facultRootDir(home), "index.json");
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 root = opts?.rootDir ?? facultRootDir();
124
- const indexPath = join(root, "index.json");
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: opts?.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({ rootDir: args.rootDir, force: false });
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 { join } from "node:path";
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(rootDir: string): Promise<FacultIndex> {
31
- const indexPath = join(rootDir, "index.json");
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(rootDir: string, index: FacultIndex) {
41
- const indexPath = join(rootDir, "index.json");
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(root));
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(root, index);
113
+ await writeIndex(home, index);
113
114
  }
114
115
 
115
116
  function parseNamesFromArgv(argv: string[]): string[] {