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/README.md
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
# facult
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
-
|
|
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.
|
|
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
|
-
|
|
99
|
+
Canonical source root: `~/.ai`. Generated state remains under `~/.facult`.
|
|
79
100
|
|
|
80
|
-
###
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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.
|
|
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": "
|
|
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
|
|
153
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
212
|
+
await download(url, destinationPath);
|
|
244
213
|
return;
|
|
245
214
|
} catch (error) {
|
|
246
215
|
lastError = error;
|
package/package.json
CHANGED
package/src/adapters/codex.ts
CHANGED
package/src/adapters/types.ts
CHANGED
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
|
+
}
|
package/src/ai-state.ts
ADDED
|
@@ -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
|
+
}
|