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 CHANGED
@@ -1,12 +1,28 @@
1
1
  # facult
2
2
 
3
- `facult` is a CLI for managing coding-agent skills and MCP configs across tools.
3
+ <div align="center">
4
+ <a aria-label="NPM version" href="https://www.npmjs.com/package/facult">
5
+ <img alt="facult npm version" src="https://img.shields.io/npm/v/facult.svg?style=flat-square&logo=npm&labelColor=000000&label=facult">
6
+ </a>
7
+ <a aria-label="CI status" href="https://github.com/hack-dance/facult/actions/workflows/ci.yml">
8
+ <img alt="CI" src="https://img.shields.io/github/actions/workflow/status/hack-dance/facult/ci.yml?branch=main&style=flat-square&logo=github&label=ci&labelColor=000000">
9
+ </a>
10
+ <a aria-label="hack.dance" href="https://hack.dance">
11
+ <img alt="Made by hack.dance" src="https://img.shields.io/badge/MADE%20BY%20HACK.DANCE-000000.svg?style=flat-square&labelColor=000000">
12
+ </a>
13
+ <a aria-label="X" href="https://x.com/dimitrikennedy">
14
+ <img alt="Follow on X" src="https://img.shields.io/twitter/follow/dimitrikennedy?style=social">
15
+ </a>
16
+ </div>
17
+
18
+ `facult` is a CLI for managing coding-agent configuration across tools.
4
19
 
5
20
  It helps you:
6
21
  - discover what is installed on your machine
7
22
  - consolidate everything into one canonical store
8
23
  - review trust/security before installing remote content
9
- - enable a curated skill set across Codex, Cursor, and Claude
24
+ - sync managed outputs into Codex, Cursor, and Claude
25
+ - manage a git-backed personal AI store under `~/.ai`
10
26
 
11
27
  ## What facult Is
12
28
 
@@ -16,6 +32,7 @@ Think of it as:
16
32
  - inventory + auditing for agent assets
17
33
  - package manager interface for skill/MCP catalogs
18
34
  - sync layer that applies your chosen setup to each tool
35
+ - canonical source manager for global AI instructions, agents, snippets, tool configs, and rules
19
36
 
20
37
  ## Quick Start
21
38
 
@@ -43,12 +60,6 @@ Direct binary install from GitHub Releases (macOS/Linux):
43
60
  curl -fsSL https://github.com/hack-dance/facult/releases/latest/download/facult-install.sh | bash
44
61
  ```
45
62
 
46
- If release assets are private, set a token first:
47
-
48
- ```bash
49
- export FACULT_GITHUB_TOKEN="<github-token>"
50
- ```
51
-
52
63
  Windows and manual installs can download the correct binary from each release page:
53
64
  `facult-<version>-<platform>-<arch>`.
54
65
 
@@ -66,7 +77,17 @@ Pin to a specific version:
66
77
  facult self-update --version 0.0.1
67
78
  ```
68
79
 
69
- ### 2. Import existing skills/configs
80
+ ### 2. Start with a read-only inventory (recommended first)
81
+
82
+ ```bash
83
+ facult scan --show-duplicates
84
+ # optional machine-readable output
85
+ facult scan --json
86
+ ```
87
+
88
+ `scan` is read-only. It inspects local configs and reports what `facult` found without changing files.
89
+
90
+ ### 3. Import existing skills/configs
70
91
 
71
92
  ```bash
72
93
  facult consolidate --auto keep-current --from ~/.codex/skills --from ~/.agents/skills
@@ -75,9 +96,9 @@ facult index
75
96
 
76
97
  Why `keep-current`: it is deterministic and non-interactive for duplicate sources.
77
98
 
78
- Default canonical store: `~/agents/.facult`. You can change it later with `FACULT_ROOT_DIR` or `~/.facult/config.json`.
99
+ Canonical source root: `~/.ai`. Generated state remains under `~/.facult`.
79
100
 
80
- ### 3. Inspect what you have
101
+ ### 4. Inspect what you have
81
102
 
82
103
  ```bash
83
104
  facult list skills
@@ -86,7 +107,7 @@ facult show requesting-code-review
86
107
  facult show mcp:github
87
108
  ```
88
109
 
89
- ### 4. Enable managed mode for your tools
110
+ ### 5. Enable managed mode for your tools
90
111
 
91
112
  ```bash
92
113
  facult manage codex
@@ -99,7 +120,21 @@ facult sync
99
120
 
100
121
  At this point, your selected skills are actively synced to all managed tools.
101
122
 
102
- ### 5. Turn on source trust and strict install flow
123
+ ### 6. Turn on background autosync
124
+
125
+ ```bash
126
+ facult autosync install --git-remote origin --git-branch main --git-interval-minutes 60
127
+ facult autosync status
128
+ ```
129
+
130
+ This installs a per-user macOS LaunchAgent that:
131
+ - watches `~/.ai` for local changes and syncs managed tool outputs automatically
132
+ - tracks dirty state for the canonical repo
133
+ - runs a slower git autosync loop that batches changes, auto-commits them, rebases on the configured remote branch, and pushes on success
134
+
135
+ If the repo hits a rebase conflict, remote autosync stops and reports the blocked state, but local tool sync continues.
136
+
137
+ ### 7. Turn on source trust and strict install flow
103
138
 
104
139
  ```bash
105
140
  facult sources list
@@ -140,6 +175,94 @@ facult sync
140
175
 
141
176
  Note: `templates init mcp ...` is a scaffold, not a running server by itself.
142
177
 
178
+ ## The `~/.ai` Model
179
+
180
+ `facult` now treats `~/.ai` as the canonical, git-backed source of truth for personal AI configuration.
181
+
182
+ Typical layout:
183
+
184
+ ```text
185
+ ~/.ai/
186
+ AGENTS.global.md
187
+ AGENTS.override.global.md
188
+ config.toml
189
+ config.local.toml
190
+ instructions/
191
+ snippets/
192
+ agents/
193
+ skills/
194
+ mcp/
195
+ templates/
196
+ tools/
197
+ codex/
198
+ config.toml
199
+ rules/
200
+ projects/
201
+ <slug>/
202
+ config.toml
203
+ config.local.toml
204
+ snippets/
205
+ instructions/
206
+ ```
207
+
208
+ Important split:
209
+ - `~/.ai` is canonical source
210
+ - `~/.facult` is generated state, trust state, managed tool state, autosync state, and caches
211
+ - tool homes such as `~/.codex` are rendered outputs
212
+
213
+ ### Canonical conventions
214
+
215
+ - Use `instructions/` for reusable markdown documents
216
+ - Use `snippets/` for composable partial blocks injected into markdown templates
217
+ - Use `tools/codex/rules/*.rules` for actual Codex approval-policy rules
218
+ - Use logical refs such as `@ai/instructions/WRITING.md` in tracked source
219
+ - Use config-backed refs in prompts where you want stable named references such as `${refs.writing_rule}`
220
+
221
+ ### Config and env layering
222
+
223
+ Canonical render context is layered explicitly:
224
+ 1. built-ins injected by `facult`
225
+ 2. `~/.ai/config.toml`
226
+ 3. `~/.ai/config.local.toml`
227
+ 4. `~/.ai/projects/<slug>/config.toml`
228
+ 5. `~/.ai/projects/<slug>/config.local.toml`
229
+ 6. explicit runtime overrides
230
+
231
+ Built-ins currently include:
232
+ - `AI_ROOT`
233
+ - `HOME`
234
+ - `PROJECT_ROOT`
235
+ - `PROJECT_SLUG`
236
+ - `TARGET_TOOL`
237
+ - `TARGET_PATH`
238
+
239
+ Recommended split:
240
+ - `config.toml`: tracked, portable, non-secret refs/defaults
241
+ - `config.local.toml`: ignored, machine-local paths and secrets
242
+
243
+ ### Snippets
244
+
245
+ Snippets use HTML comment markers:
246
+
247
+ ```md
248
+ <!-- fclty:global/codex/baseline -->
249
+ <!-- /fclty:global/codex/baseline -->
250
+ ```
251
+
252
+ Resolution rules:
253
+ - unscoped marker `codingstyle` prefers `snippets/projects/<project>/codingstyle.md`, then falls back to `snippets/global/codingstyle.md`
254
+ - explicit marker `global/codex/baseline` resolves directly to `snippets/global/codex/baseline.md`
255
+
256
+ Commands:
257
+
258
+ ```bash
259
+ facult snippets list
260
+ facult snippets show global/codex/baseline
261
+ facult snippets sync [--dry-run] [file...]
262
+ ```
263
+
264
+ Snippets are already used during global Codex `AGENTS.md` rendering.
265
+
143
266
  ## Security and Trust
144
267
 
145
268
  `facult` has two trust layers:
@@ -174,10 +297,11 @@ Recommended security flow:
174
297
  ### Capability categories
175
298
 
176
299
  - Inventory: discover local skills, MCP configs, hooks, and instruction files
177
- - Management: consolidate, index, manage/unmanage tools, enable/disable entries
300
+ - Management: consolidate, index, manage/unmanage tools, enable/disable entries, manage canonical AI config
178
301
  - Security: static audit, agent audit, item trust, source trust, source verification
179
302
  - Distribution: search/install/update from catalogs and verified manifests
180
303
  - DX: scaffold templates and sync snippets into instruction/config files
304
+ - Automation: background autosync for local tool propagation and canonical repo git sync
181
305
 
182
306
  ### Command categories
183
307
 
@@ -205,6 +329,10 @@ facult enable <name> [--for <tool1,tool2,...>]
205
329
  facult enable mcp:<name> [--for <tool1,tool2,...>]
206
330
  facult disable <name> [--for <tool1,tool2,...>]
207
331
  facult sync [tool] [--dry-run]
332
+ facult autosync install [tool] [--git-remote <name>] [--git-branch <name>] [--git-interval-minutes <n>] [--git-disable]
333
+ facult autosync status [tool]
334
+ facult autosync restart [tool]
335
+ facult autosync uninstall [tool]
208
336
  ```
209
337
 
210
338
  - Remote catalogs and policies
@@ -247,13 +375,12 @@ facult <command> --help
247
375
  `facult` resolves the canonical root in this order:
248
376
  1. `FACULT_ROOT_DIR`
249
377
  2. `~/.facult/config.json` (`rootDir`)
250
- 3. `~/agents/.facult` (or a detected legacy store under `~/agents/`)
378
+ 3. `~/.ai`
379
+ 4. `~/agents/.facult` (or a detected legacy store under `~/agents/`)
251
380
 
252
381
  ### Runtime env vars
253
382
 
254
383
  - `FACULT_ROOT_DIR`: override canonical store location
255
- - `FACULT_GITHUB_TOKEN`: auth token for private GitHub release asset downloads
256
- - `GITHUB_TOKEN` / `GH_TOKEN`: fallback tokens for release asset downloads
257
384
  - `FACULT_VERSION`: version selector for `scripts/install.sh` (`latest` by default)
258
385
  - `FACULT_INSTALL_DIR`: install target dir for `scripts/install.sh` (`~/.facult/bin` by default)
259
386
  - `FACULT_INSTALL_PM`: force package manager detection for npm bootstrap launcher (`npm` or `bun`)
@@ -264,9 +391,13 @@ Under `~/.facult/`:
264
391
  - `sources.json` (latest inventory scan state)
265
392
  - `consolidated.json` (consolidation state)
266
393
  - `managed.json` (managed tool state)
394
+ - `ai/index.json` (generated canonical AI inventory)
267
395
  - `audit/static-latest.json` (latest static audit report)
268
396
  - `audit/agent-latest.json` (latest agent audit report)
269
397
  - `trust/sources.json` (source trust policy state)
398
+ - `autosync/services/*.json` (autosync service configs)
399
+ - `autosync/state/*.json` (autosync runtime state)
400
+ - `autosync/logs/*` (autosync service logs)
270
401
 
271
402
  ### Config reference
272
403
 
@@ -283,7 +414,7 @@ Under `~/.facult/`:
283
414
  Example:
284
415
  ```json
285
416
  {
286
- "rootDir": "~/agents/.facult",
417
+ "rootDir": "~/.ai",
287
418
  "scanFrom": ["~/dev", "~/work"],
288
419
  "scanFromIgnore": ["vendor", ".venv"],
289
420
  "scanFromNoDefaultIgnore": false,
@@ -315,6 +446,42 @@ bun run install:status
315
446
 
316
447
  Default install path is `~/.facult/bin/facult`. You can pass a custom target dir via `--dir=/path`.
317
448
 
449
+ ## Autosync
450
+
451
+ `facult autosync` is the background propagation layer for managed installs.
452
+
453
+ Current v1 behavior:
454
+ - macOS LaunchAgent-backed
455
+ - immediate local managed-tool sync on `~/.ai` file changes
456
+ - periodic git autosync for the canonical repo
457
+ - automatic autosync commits with source-tagged commit messages such as:
458
+ - `chore(facult-autosync): sync canonical ai changes from <host> [service:all]`
459
+
460
+ Recommended usage:
461
+
462
+ ```bash
463
+ facult autosync install
464
+ facult autosync status
465
+ ```
466
+
467
+ Tool-scoped service:
468
+
469
+ ```bash
470
+ facult autosync install codex
471
+ ```
472
+
473
+ One-shot runner for verification/debugging:
474
+
475
+ ```bash
476
+ facult autosync run --service all --once
477
+ ```
478
+
479
+ Remote git policy:
480
+ - do not sync on every file event
481
+ - mark the canonical repo dirty on local changes
482
+ - on the configured timer, fetch, auto-commit local canonical changes if needed, pull `--rebase`, then push
483
+ - if rebase conflicts occur, remote autosync is blocked and reported, but local managed-tool sync keeps running
484
+
318
485
  ## CI and Release Automation
319
486
 
320
487
  - CI workflow: `.github/workflows/ci.yml`
@@ -381,3 +548,18 @@ bun run release:dry-run
381
548
  Not as a first-party `facult mcp serve` runtime.
382
549
 
383
550
  `facult` currently focuses on inventory, trust/audit, install/update, and managed sync of skills/MCP configs.
551
+
552
+ ### Does facult now manage global AI config, not just skills and MCP?
553
+
554
+ Yes. The core model now includes:
555
+ - canonical personal AI source in `~/.ai`
556
+ - rendered managed outputs in tool homes such as `~/.codex`
557
+ - global instruction docs such as `AGENTS.global.md`
558
+ - tool-native configs such as `~/.codex/config.toml`
559
+ - tool-native rule files such as `~/.codex/rules/*.rules`
560
+
561
+ ### Do I still need to run `facult sync` manually?
562
+
563
+ If autosync is not installed, yes.
564
+
565
+ If autosync is installed, local changes under `~/.ai` propagate automatically to managed tools. Manual `facult sync` is still useful for explicit repair, dry-runs, and non-daemon workflows.
package/bin/facult.cjs CHANGED
@@ -37,7 +37,6 @@ async function main() {
37
37
  );
38
38
  const binaryName = resolved.platform === "windows" ? "facult.exe" : "facult";
39
39
  const binaryPath = path.join(installDir, binaryName);
40
- const githubToken = resolveGitHubToken();
41
40
 
42
41
  if (!(await fileExists(binaryPath))) {
43
42
  const tag = `v${version}`;
@@ -50,7 +49,6 @@ async function main() {
50
49
  await downloadWithRetry(url, tmpPath, {
51
50
  attempts: DOWNLOAD_RETRIES,
52
51
  delayMs: DOWNLOAD_RETRY_DELAY_MS,
53
- token: githubToken,
54
52
  });
55
53
  if (resolved.platform !== "windows") {
56
54
  await fsp.chmod(tmpPath, 0o755);
@@ -149,48 +147,19 @@ function detectPackageManager() {
149
147
  return "npm";
150
148
  }
151
149
 
152
- function resolveGitHubToken() {
153
- const tokenCandidates = [
154
- process.env.FACULT_GITHUB_TOKEN,
155
- process.env.GITHUB_TOKEN,
156
- process.env.GH_TOKEN,
157
- ];
158
- for (const candidate of tokenCandidates) {
159
- const token = String(candidate || "").trim();
160
- if (token) {
161
- return token;
162
- }
163
- }
164
- return "";
165
- }
166
-
167
- function buildRequestHeaders(url, token) {
168
- const headers = {
150
+ function buildRequestHeaders() {
151
+ return {
169
152
  "user-agent": "facult-installer",
170
153
  accept: "application/octet-stream",
171
154
  };
172
- if (!token) {
173
- return headers;
174
- }
175
-
176
- try {
177
- const hostname = new URL(url).hostname.toLowerCase();
178
- if (hostname === "github.com" || hostname === "api.github.com") {
179
- headers.authorization = `Bearer ${token}`;
180
- }
181
- } catch {
182
- // Keep default headers if URL parsing fails.
183
- }
184
-
185
- return headers;
186
155
  }
187
156
 
188
- async function download(url, destinationPath, options = {}) {
157
+ async function download(url, destinationPath) {
189
158
  await new Promise((resolve, reject) => {
190
159
  const request = https.get(
191
160
  url,
192
161
  {
193
- headers: buildRequestHeaders(url, options.token),
162
+ headers: buildRequestHeaders(),
194
163
  },
195
164
  (response) => {
196
165
  if (
@@ -200,7 +169,7 @@ async function download(url, destinationPath, options = {}) {
200
169
  response.headers.location
201
170
  ) {
202
171
  response.resume();
203
- download(response.headers.location, destinationPath, options)
172
+ download(response.headers.location, destinationPath)
204
173
  .then(resolve)
205
174
  .catch(reject);
206
175
  return;
@@ -240,7 +209,7 @@ async function downloadWithRetry(url, destinationPath, options) {
240
209
  for (let attempt = 1; attempt <= options.attempts; attempt += 1) {
241
210
  try {
242
211
  await safeUnlink(destinationPath);
243
- await download(url, destinationPath, { token: options.token });
212
+ await download(url, destinationPath);
244
213
  return;
245
214
  } catch (error) {
246
215
  lastError = error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "facult",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Manage coding-agent skills and MCP configs across tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@ export const codexAdapter: ToolAdapter = {
11
11
  getDefaultPaths: () => ({
12
12
  mcp: "~/.codex/mcp.json",
13
13
  skills: "~/.codex/skills",
14
+ agents: "~/.codex/agents",
14
15
  config: "~/.config/openai/codex.json",
15
16
  }),
16
17
  parseMcp: (config) => parseMcpConfig(config),
@@ -21,6 +21,7 @@ export interface CanonicalSkill {
21
21
  export interface AdapterDefaultPaths {
22
22
  mcp?: string;
23
23
  skills?: string | string[];
24
+ agents?: string | string[];
24
25
  config?: string;
25
26
  }
26
27
 
package/src/agents.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { join } from "node:path";
2
+
3
+ const AI_REF_RE = /(?<![\w@])@ai\/([^\s"'`<>]+)/g;
4
+ const INTERPOLATION_RE = /\$\{([^}]+)\}/g;
5
+ const TRAILING_PUNCTUATION_RE = /[.,;:!?)}\]]+$/;
6
+ const MAX_RENDER_PASSES = 10;
7
+
8
+ export interface RenderCanonicalTextOptions {
9
+ homeDir?: string;
10
+ rootDir: string;
11
+ projectSlug?: string;
12
+ projectRoot?: string;
13
+ targetTool?: string;
14
+ targetPath?: string;
15
+ overrides?: Record<string, unknown>;
16
+ }
17
+
18
+ type RenderContext = Record<string, unknown>;
19
+
20
+ function trimTrailingPunctuation(refPath: string): {
21
+ path: string;
22
+ suffix: string;
23
+ } {
24
+ const match = TRAILING_PUNCTUATION_RE.exec(refPath);
25
+ if (!match) {
26
+ return { path: refPath, suffix: "" };
27
+ }
28
+
29
+ const suffix = match[0];
30
+ return {
31
+ path: refPath.slice(0, -suffix.length),
32
+ suffix,
33
+ };
34
+ }
35
+
36
+ export function renderAiRefs(input: string, canonicalRoot: string): string {
37
+ return input.replace(AI_REF_RE, (_match, refPath: string) => {
38
+ const { path, suffix } = trimTrailingPunctuation(refPath);
39
+ return `${join(canonicalRoot, path)}${suffix}`;
40
+ });
41
+ }
42
+
43
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
44
+ return !!value && typeof value === "object" && !Array.isArray(value);
45
+ }
46
+
47
+ function mergeContexts(
48
+ base: Record<string, unknown>,
49
+ override: Record<string, unknown>
50
+ ): Record<string, unknown> {
51
+ const merged: Record<string, unknown> = { ...base };
52
+
53
+ for (const [key, value] of Object.entries(override)) {
54
+ const current = merged[key];
55
+ if (isPlainObject(current) && isPlainObject(value)) {
56
+ merged[key] = mergeContexts(current, value);
57
+ continue;
58
+ }
59
+ merged[key] = value;
60
+ }
61
+
62
+ return merged;
63
+ }
64
+
65
+ async function readTomlFile(
66
+ pathValue: string
67
+ ): Promise<Record<string, unknown> | null> {
68
+ const file = Bun.file(pathValue);
69
+ if (!(await file.exists())) {
70
+ return null;
71
+ }
72
+
73
+ const text = await file.text();
74
+ const parsed = Bun.TOML.parse(text);
75
+ return isPlainObject(parsed) ? parsed : null;
76
+ }
77
+
78
+ function getContextValue(
79
+ context: Record<string, unknown>,
80
+ dottedPath: string
81
+ ): unknown {
82
+ const segments = dottedPath
83
+ .split(".")
84
+ .map((segment) => segment.trim())
85
+ .filter(Boolean);
86
+ if (segments.length === 0) {
87
+ return undefined;
88
+ }
89
+
90
+ let current: unknown = context;
91
+ for (const segment of segments) {
92
+ if (!(isPlainObject(current) && segment in current)) {
93
+ return undefined;
94
+ }
95
+ current = current[segment];
96
+ }
97
+ return current;
98
+ }
99
+
100
+ function interpolateString(
101
+ input: string,
102
+ context: Record<string, unknown>
103
+ ): string {
104
+ return input.replace(INTERPOLATION_RE, (match, keyPath: string) => {
105
+ const value = getContextValue(context, keyPath.trim());
106
+ return typeof value === "string" ? value : match;
107
+ });
108
+ }
109
+
110
+ export async function loadRenderContext(
111
+ options: RenderCanonicalTextOptions
112
+ ): Promise<RenderContext> {
113
+ const {
114
+ homeDir,
115
+ overrides,
116
+ projectRoot,
117
+ projectSlug,
118
+ rootDir,
119
+ targetPath,
120
+ targetTool,
121
+ } = options;
122
+ const contextBase: RenderContext = {
123
+ AI_ROOT: rootDir,
124
+ HOME: homeDir ?? "",
125
+ PROJECT_ROOT: projectRoot ?? "",
126
+ PROJECT_SLUG: projectSlug ?? "",
127
+ TARGET_PATH: targetPath ?? "",
128
+ TARGET_TOOL: targetTool ?? "",
129
+ };
130
+
131
+ let context = contextBase;
132
+ const layers = [
133
+ await readTomlFile(join(rootDir, "config.toml")),
134
+ await readTomlFile(join(rootDir, "config.local.toml")),
135
+ projectSlug
136
+ ? await readTomlFile(
137
+ join(rootDir, "projects", projectSlug, "config.toml")
138
+ )
139
+ : null,
140
+ projectSlug
141
+ ? await readTomlFile(
142
+ join(rootDir, "projects", projectSlug, "config.local.toml")
143
+ )
144
+ : null,
145
+ overrides && isPlainObject(overrides) ? overrides : null,
146
+ ];
147
+
148
+ for (const layer of layers) {
149
+ if (layer) {
150
+ context = mergeContexts(context, layer);
151
+ }
152
+ }
153
+
154
+ return context;
155
+ }
156
+
157
+ export async function renderCanonicalText(
158
+ input: string,
159
+ options: RenderCanonicalTextOptions
160
+ ): Promise<string> {
161
+ const context = await loadRenderContext(options);
162
+ let rendered = input;
163
+ const seen = new Set<string>();
164
+
165
+ for (let pass = 0; pass < MAX_RENDER_PASSES; pass += 1) {
166
+ if (seen.has(rendered)) {
167
+ break;
168
+ }
169
+ seen.add(rendered);
170
+
171
+ const interpolated = interpolateString(rendered, context);
172
+ const withRefs = renderAiRefs(interpolated, options.rootDir);
173
+ if (withRefs === rendered) {
174
+ return withRefs;
175
+ }
176
+ rendered = withRefs;
177
+ }
178
+
179
+ return rendered;
180
+ }
@@ -0,0 +1,55 @@
1
+ import { copyFile, mkdir, stat } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { buildIndex } from "./index-builder";
4
+ import { facultAiIndexPath } from "./paths";
5
+
6
+ async function fileExists(path: string): Promise<boolean> {
7
+ try {
8
+ return (await stat(path)).isFile();
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ export function legacyAiIndexPath(rootDir: string): string {
15
+ return join(rootDir, "index.json");
16
+ }
17
+
18
+ export async function ensureAiIndexPath(args: {
19
+ homeDir: string;
20
+ rootDir: string;
21
+ repair?: boolean;
22
+ }): Promise<{
23
+ path: string;
24
+ repaired: boolean;
25
+ source: "generated" | "legacy" | "rebuilt" | "missing";
26
+ }> {
27
+ const generatedPath = facultAiIndexPath(args.homeDir);
28
+ if (await fileExists(generatedPath)) {
29
+ return { path: generatedPath, repaired: false, source: "generated" };
30
+ }
31
+
32
+ const legacyPath = legacyAiIndexPath(args.rootDir);
33
+ if (await fileExists(legacyPath)) {
34
+ if (args.repair !== false) {
35
+ await mkdir(dirname(generatedPath), { recursive: true });
36
+ await copyFile(legacyPath, generatedPath);
37
+ }
38
+ return {
39
+ path: generatedPath,
40
+ repaired: args.repair !== false,
41
+ source: "legacy",
42
+ };
43
+ }
44
+
45
+ if (args.repair !== false) {
46
+ const { outputPath } = await buildIndex({
47
+ rootDir: args.rootDir,
48
+ homeDir: args.homeDir,
49
+ force: false,
50
+ });
51
+ return { path: outputPath, repaired: true, source: "rebuilt" };
52
+ }
53
+
54
+ return { path: generatedPath, repaired: false, source: "missing" };
55
+ }