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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/bin/facult.cjs +302 -0
  4. package/package.json +78 -0
  5. package/src/adapters/claude-cli.ts +18 -0
  6. package/src/adapters/claude-desktop.ts +15 -0
  7. package/src/adapters/clawdbot.ts +18 -0
  8. package/src/adapters/codex.ts +19 -0
  9. package/src/adapters/cursor.ts +18 -0
  10. package/src/adapters/index.ts +69 -0
  11. package/src/adapters/mcp.ts +270 -0
  12. package/src/adapters/reference.ts +9 -0
  13. package/src/adapters/skills.ts +47 -0
  14. package/src/adapters/types.ts +42 -0
  15. package/src/adapters/version.ts +18 -0
  16. package/src/audit/agent.ts +1071 -0
  17. package/src/audit/index.ts +74 -0
  18. package/src/audit/static.ts +1130 -0
  19. package/src/audit/tui.ts +704 -0
  20. package/src/audit/types.ts +68 -0
  21. package/src/audit/update-index.ts +115 -0
  22. package/src/conflicts.ts +135 -0
  23. package/src/consolidate-conflict-action.ts +57 -0
  24. package/src/consolidate.ts +1637 -0
  25. package/src/enable-disable.ts +349 -0
  26. package/src/index-builder.ts +562 -0
  27. package/src/index.ts +589 -0
  28. package/src/manage.ts +894 -0
  29. package/src/migrate.ts +272 -0
  30. package/src/paths.ts +238 -0
  31. package/src/quarantine.ts +217 -0
  32. package/src/query.ts +186 -0
  33. package/src/remote-manifest-integrity.ts +367 -0
  34. package/src/remote-providers.ts +905 -0
  35. package/src/remote-source-policy.ts +237 -0
  36. package/src/remote-sources.ts +162 -0
  37. package/src/remote-types.ts +136 -0
  38. package/src/remote.ts +1970 -0
  39. package/src/scan.ts +2427 -0
  40. package/src/schema.ts +39 -0
  41. package/src/self-update.ts +408 -0
  42. package/src/snippets-cli.ts +293 -0
  43. package/src/snippets.ts +706 -0
  44. package/src/source-trust.ts +203 -0
  45. package/src/trust-list.ts +232 -0
  46. package/src/trust.ts +170 -0
  47. package/src/tui.ts +118 -0
  48. package/src/util/codex-toml.ts +126 -0
  49. package/src/util/json.ts +32 -0
  50. package/src/util/skills.ts +55 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hack Dance
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,383 @@
1
+ # facult
2
+
3
+ `facult` is a CLI for managing coding-agent skills and MCP configs across tools.
4
+
5
+ It helps you:
6
+ - discover what is installed on your machine
7
+ - consolidate everything into one canonical store
8
+ - review trust/security before installing remote content
9
+ - enable a curated skill set across Codex, Cursor, and Claude
10
+
11
+ ## What facult Is
12
+
13
+ If your agent setup feels scattered (`~/.codex`, `~/.agents`, tool-specific MCP JSON/TOML), `facult` gives you one place to manage it safely.
14
+
15
+ Think of it as:
16
+ - inventory + auditing for agent assets
17
+ - package manager interface for skill/MCP catalogs
18
+ - sync layer that applies your chosen setup to each tool
19
+
20
+ ## Quick Start
21
+
22
+ ### 1. Install facult
23
+
24
+ Recommended global install:
25
+
26
+ ```bash
27
+ npm install -g facult
28
+ # or
29
+ bun add -g facult
30
+ facult --help
31
+ ```
32
+
33
+ One-off usage without global install:
34
+
35
+ ```bash
36
+ npx facult --help
37
+ bunx facult --help
38
+ ```
39
+
40
+ Direct binary install from GitHub Releases (macOS/Linux):
41
+
42
+ ```bash
43
+ curl -fsSL https://github.com/hack-dance/facult/releases/latest/download/facult-install.sh | bash
44
+ ```
45
+
46
+ If release assets are private, set a token first:
47
+
48
+ ```bash
49
+ export FACULT_GITHUB_TOKEN="<github-token>"
50
+ ```
51
+
52
+ Windows and manual installs can download the correct binary from each release page:
53
+ `facult-<version>-<platform>-<arch>`.
54
+
55
+ Update later with:
56
+
57
+ ```bash
58
+ facult self-update
59
+ # or
60
+ facult update --self
61
+ ```
62
+
63
+ Pin to a specific version:
64
+
65
+ ```bash
66
+ facult self-update --version 0.0.1
67
+ ```
68
+
69
+ ### 2. Import existing skills/configs
70
+
71
+ ```bash
72
+ facult consolidate --auto keep-current --from ~/.codex/skills --from ~/.agents/skills
73
+ facult index
74
+ ```
75
+
76
+ Why `keep-current`: it is deterministic and non-interactive for duplicate sources.
77
+
78
+ Default canonical store: `~/agents/.facult`. You can change it later with `FACULT_ROOT_DIR` or `~/.facult/config.json`.
79
+
80
+ ### 3. Inspect what you have
81
+
82
+ ```bash
83
+ facult list skills
84
+ facult list mcp
85
+ facult show requesting-code-review
86
+ facult show mcp:github
87
+ ```
88
+
89
+ ### 4. Enable managed mode for your tools
90
+
91
+ ```bash
92
+ facult manage codex
93
+ facult manage cursor
94
+ facult manage claude
95
+
96
+ facult enable requesting-code-review receiving-code-review brainstorming systematic-debugging --for codex,cursor,claude
97
+ facult sync
98
+ ```
99
+
100
+ At this point, your selected skills are actively synced to all managed tools.
101
+
102
+ ### 5. Turn on source trust and strict install flow
103
+
104
+ ```bash
105
+ facult sources list
106
+ facult verify-source skills.sh --json
107
+ facult sources trust skills.sh --note "reviewed"
108
+
109
+ facult install skills.sh:code-review --as code-review-skills-sh --strict-source-trust
110
+ ```
111
+
112
+ ## Use facult from your agents
113
+
114
+ `facult` is CLI-first. The practical setup is:
115
+ 1. Install `facult` globally so any agent runtime can execute it.
116
+ 2. Put allowed `facult` workflows in your agent instructions/skills.
117
+ 3. Optionally scaffold MCP wrappers if you want an MCP entry that delegates to `facult`.
118
+
119
+ ```bash
120
+ # Scaffold reusable templates in the canonical store
121
+ facult templates init agents
122
+ facult templates init claude
123
+ facult templates init skill facult-manager
124
+
125
+ # Enable that skill for managed tools
126
+ facult manage codex
127
+ facult manage cursor
128
+ facult manage claude
129
+ facult enable facult-manager --for codex,cursor,claude
130
+ facult sync
131
+ ```
132
+
133
+ Optional MCP scaffold:
134
+
135
+ ```bash
136
+ facult templates init mcp facult-cli
137
+ facult enable mcp:facult-cli --for codex,cursor,claude
138
+ facult sync
139
+ ```
140
+
141
+ Note: `templates init mcp ...` is a scaffold, not a running server by itself.
142
+
143
+ ## Security and Trust
144
+
145
+ `facult` has two trust layers:
146
+ - Item trust: `facult trust <name>` / `facult untrust <name>`
147
+ - Source trust: `facult sources ...` with levels `trusted`, `review`, `blocked`
148
+
149
+ `facult` also supports two audit modes:
150
+
151
+ 1. Interactive audit workflow:
152
+ ```bash
153
+ facult audit
154
+ ```
155
+ 2. Static audit rules (deterministic pattern checks):
156
+ ```bash
157
+ facult audit --non-interactive --severity high
158
+ facult audit --non-interactive mcp:github --severity medium --json
159
+ ```
160
+ 3. Agent-based audit (Claude/Codex review pass):
161
+ ```bash
162
+ facult audit --non-interactive --with claude --max-items 50
163
+ facult audit --non-interactive --with codex --max-items all --json
164
+ ```
165
+
166
+ Recommended security flow:
167
+ 1. `facult verify-source <source>`
168
+ 2. `facult sources trust <source>` only after review
169
+ 3. use `--strict-source-trust` for `install`/`update`
170
+ 4. run both static and agent audits on a schedule
171
+
172
+ ## Comprehensive Reference
173
+
174
+ ### Capability categories
175
+
176
+ - Inventory: discover local skills, MCP configs, hooks, and instruction files
177
+ - Management: consolidate, index, manage/unmanage tools, enable/disable entries
178
+ - Security: static audit, agent audit, item trust, source trust, source verification
179
+ - Distribution: search/install/update from catalogs and verified manifests
180
+ - DX: scaffold templates and sync snippets into instruction/config files
181
+
182
+ ### Command categories
183
+
184
+ - Inventory and discovery
185
+ ```bash
186
+ facult scan [--from <path>] [--json] [--show-duplicates]
187
+ facult list [skills|mcp|agents|snippets] [--enabled-for <tool>] [--untrusted] [--flagged] [--pending]
188
+ facult show <name>
189
+ facult show mcp:<name> [--show-secrets]
190
+ ```
191
+
192
+ - Canonical store and migration
193
+ ```bash
194
+ facult consolidate [--auto keep-current|keep-incoming|keep-newest] [--from <path> ...]
195
+ facult index [--force]
196
+ facult migrate [--from <path>] [--dry-run] [--move] [--write-config]
197
+ ```
198
+
199
+ - Managed mode and rollout
200
+ ```bash
201
+ facult manage <tool>
202
+ facult unmanage <tool>
203
+ facult managed
204
+ facult enable <name> [--for <tool1,tool2,...>]
205
+ facult enable mcp:<name> [--for <tool1,tool2,...>]
206
+ facult disable <name> [--for <tool1,tool2,...>]
207
+ facult sync [tool] [--dry-run]
208
+ ```
209
+
210
+ - Remote catalogs and policies
211
+ ```bash
212
+ facult search <query> [--index <name>] [--limit <n>]
213
+ facult install <index:item> [--as <name>] [--force] [--strict-source-trust]
214
+ facult update [--apply] [--strict-source-trust]
215
+ facult verify-source <name> [--json]
216
+ facult sources list
217
+ facult sources trust <source> [--note <text>]
218
+ facult sources review <source> [--note <text>]
219
+ facult sources block <source> [--note <text>]
220
+ facult sources clear <source>
221
+ ```
222
+
223
+ - Templates and snippets
224
+ ```bash
225
+ facult templates list
226
+ facult templates init skill <name>
227
+ facult templates init mcp <name>
228
+ facult templates init snippet <marker>
229
+ facult templates init agents
230
+ facult templates init claude
231
+
232
+ facult snippets list
233
+ facult snippets show <marker>
234
+ facult snippets create <marker>
235
+ facult snippets edit <marker>
236
+ facult snippets sync [--dry-run] [file...]
237
+ ```
238
+
239
+ For full flags and exact usage:
240
+ ```bash
241
+ facult --help
242
+ facult <command> --help
243
+ ```
244
+
245
+ ### Root resolution
246
+
247
+ `facult` resolves the canonical root in this order:
248
+ 1. `FACULT_ROOT_DIR`
249
+ 2. `~/.facult/config.json` (`rootDir`)
250
+ 3. `~/agents/.facult` (or a detected legacy store under `~/agents/`)
251
+
252
+ ### Runtime env vars
253
+
254
+ - `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
+ - `FACULT_VERSION`: version selector for `scripts/install.sh` (`latest` by default)
258
+ - `FACULT_INSTALL_DIR`: install target dir for `scripts/install.sh` (`~/.facult/bin` by default)
259
+ - `FACULT_INSTALL_PM`: force package manager detection for npm bootstrap launcher (`npm` or `bun`)
260
+
261
+ ### State and report files
262
+
263
+ Under `~/.facult/`:
264
+ - `sources.json` (latest inventory scan state)
265
+ - `consolidated.json` (consolidation state)
266
+ - `managed.json` (managed tool state)
267
+ - `audit/static-latest.json` (latest static audit report)
268
+ - `audit/agent-latest.json` (latest agent audit report)
269
+ - `trust/sources.json` (source trust policy state)
270
+
271
+ ### Config reference
272
+
273
+ `~/.facult/config.json` supports:
274
+ - `rootDir`
275
+ - `scanFrom`
276
+ - `scanFromIgnore`
277
+ - `scanFromNoDefaultIgnore`
278
+ - `scanFromMaxVisits`
279
+ - `scanFromMaxResults`
280
+
281
+ `scanFrom*` settings are used by `scan`/`audit` unless `--no-config-from` is passed.
282
+
283
+ Example:
284
+ ```json
285
+ {
286
+ "rootDir": "~/agents/.facult",
287
+ "scanFrom": ["~/dev", "~/work"],
288
+ "scanFromIgnore": ["vendor", ".venv"],
289
+ "scanFromNoDefaultIgnore": false,
290
+ "scanFromMaxVisits": 20000,
291
+ "scanFromMaxResults": 5000
292
+ }
293
+ ```
294
+
295
+ ### Source aliases and custom indices
296
+
297
+ Default source aliases:
298
+ - `facult` (builtin templates)
299
+ - `smithery`
300
+ - `glama`
301
+ - `skills.sh`
302
+ - `clawhub`
303
+
304
+ Custom remote sources can be defined in `~/.facult/indices.json` (manifest URL, optional integrity, optional signature keys/signature verification settings).
305
+
306
+ ## Local Install Modes
307
+
308
+ For local CLI setup (outside npm global install), use:
309
+
310
+ ```bash
311
+ bun run install:dev
312
+ bun run install:bin
313
+ bun run install:status
314
+ ```
315
+
316
+ Default install path is `~/.facult/bin/facult`. You can pass a custom target dir via `--dir=/path`.
317
+
318
+ ## CI and Release Automation
319
+
320
+ - CI workflow: `.github/workflows/ci.yml`
321
+ - Release workflow: `.github/workflows/release.yml`
322
+ - Semantic-release config: `.releaserc.json`
323
+
324
+ Release behavior:
325
+ 1. Every push to `main` runs full checks.
326
+ 2. `semantic-release` creates the version/tag and GitHub release (npm publish is disabled in this phase).
327
+ 3. The same release workflow then builds platform binaries and uploads them to that GitHub release.
328
+ 4. npm publish runs only after binary asset upload succeeds (`publish-npm` depends on `publish-assets`).
329
+ 5. Published release assets include platform binaries, `facult-install.sh`, and `SHA256SUMS`.
330
+ 6. The npm package launcher resolves your platform, downloads the matching release binary, caches it under `~/.facult/runtime/<version>/<platform-arch>/`, and runs it.
331
+
332
+ Current prebuilt binary targets:
333
+ - `darwin-x64`
334
+ - `darwin-arm64`
335
+ - `linux-x64`
336
+ - `windows-x64`
337
+
338
+ Self-update behavior:
339
+ 1. npm/bun global install: updates via package manager (`npm install -g facult@...` or `bun add -g facult@...`).
340
+ 2. Direct binary install (release script/local binary path): downloads and replaces the binary in place.
341
+ 3. Use `facult self-update` (or `facult update --self`).
342
+
343
+ Required secrets for publish:
344
+ - `NPM_TOKEN`
345
+
346
+ Local semantic-release dry-runs require a supported Node runtime (`>=24.10`).
347
+
348
+ Recommended one-time bootstrap before first auto release:
349
+ ```bash
350
+ git tag v0.0.0
351
+ git push origin v0.0.0
352
+ ```
353
+
354
+ This makes the first semantic-release increment land at `0.0.1` for patch-level changes.
355
+
356
+ ## Commit Hygiene
357
+
358
+ Some MCP config files can contain secrets. Keep local generated artifacts and secret-bearing config files ignored and out of commits.
359
+
360
+ ## Local Development
361
+
362
+ ```bash
363
+ bun run install:status
364
+ bun run install:dev
365
+ bun run install:bin
366
+ bun run build
367
+ bun run build:verify
368
+ bun run type-check
369
+ bun run test:ci
370
+ bun test
371
+ bun run check
372
+ bun run fix
373
+ bun run pack:dry-run
374
+ bun run release:dry-run
375
+ ```
376
+
377
+ ## FAQ
378
+
379
+ ### Does facult run its own MCP server today?
380
+
381
+ Not as a first-party `facult mcp serve` runtime.
382
+
383
+ `facult` currently focuses on inventory, trust/audit, install/update, and managed sync of skills/MCP configs.
package/bin/facult.cjs ADDED
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const fsp = require("node:fs/promises");
6
+ const https = require("node:https");
7
+ const os = require("node:os");
8
+ const path = require("node:path");
9
+ const { spawnSync } = require("node:child_process");
10
+
11
+ const pkg = require("../package.json");
12
+
13
+ const REPO_OWNER = "hack-dance";
14
+ const REPO_NAME = "facult";
15
+ const DOWNLOAD_RETRIES = 12;
16
+ const DOWNLOAD_RETRY_DELAY_MS = 5000;
17
+
18
+ async function main() {
19
+ const resolved = resolveTarget();
20
+ if (!resolved.ok) {
21
+ console.error(resolved.message);
22
+ process.exit(1);
23
+ }
24
+
25
+ const version = String(pkg.version || "").trim();
26
+ if (!version) {
27
+ console.error("Invalid package version.");
28
+ process.exit(1);
29
+ }
30
+
31
+ const home = os.homedir();
32
+ const cacheRoot = path.join(home, ".facult", "runtime");
33
+ const installDir = path.join(
34
+ cacheRoot,
35
+ version,
36
+ `${resolved.platform}-${resolved.arch}`
37
+ );
38
+ const binaryName = resolved.platform === "windows" ? "facult.exe" : "facult";
39
+ const binaryPath = path.join(installDir, binaryName);
40
+ const githubToken = resolveGitHubToken();
41
+
42
+ if (!(await fileExists(binaryPath))) {
43
+ const tag = `v${version}`;
44
+ const assetName = `facult-${version}-${resolved.platform}-${resolved.arch}${resolved.ext}`;
45
+ const url = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${tag}/${assetName}`;
46
+
47
+ await fsp.mkdir(installDir, { recursive: true });
48
+ const tmpPath = `${binaryPath}.tmp-${Date.now()}`;
49
+ try {
50
+ await downloadWithRetry(url, tmpPath, {
51
+ attempts: DOWNLOAD_RETRIES,
52
+ delayMs: DOWNLOAD_RETRY_DELAY_MS,
53
+ token: githubToken,
54
+ });
55
+ if (resolved.platform !== "windows") {
56
+ await fsp.chmod(tmpPath, 0o755);
57
+ }
58
+ await fsp.rename(tmpPath, binaryPath);
59
+ } catch (error) {
60
+ await safeUnlink(tmpPath);
61
+ const message =
62
+ error instanceof Error ? error.message : String(error ?? "");
63
+ console.error(
64
+ [
65
+ "Unable to download the facult binary for this platform.",
66
+ `Expected asset: ${assetName}`,
67
+ `URL: ${url}`,
68
+ `Reason: ${message}`,
69
+ "",
70
+ "Try installing directly from releases:",
71
+ "https://github.com/hack-dance/facult/releases",
72
+ ].join("\n")
73
+ );
74
+ process.exit(1);
75
+ }
76
+ }
77
+
78
+ const packageManager = detectPackageManager();
79
+ await writeInstallState({
80
+ method: "npm-binary-cache",
81
+ version,
82
+ binaryPath,
83
+ packageManager,
84
+ });
85
+
86
+ const args = process.argv.slice(2);
87
+ const result = spawnSync(binaryPath, args, {
88
+ stdio: "inherit",
89
+ env: {
90
+ ...process.env,
91
+ FACULT_INSTALL_METHOD: "npm-binary-cache",
92
+ FACULT_NPM_PACKAGE_VERSION: version,
93
+ FACULT_RUNTIME_BINARY: binaryPath,
94
+ FACULT_INSTALL_PM: packageManager,
95
+ },
96
+ });
97
+
98
+ if (typeof result.status === "number") {
99
+ process.exit(result.status);
100
+ }
101
+ process.exit(1);
102
+ }
103
+
104
+ function resolveTarget() {
105
+ const platform = process.platform;
106
+ const arch = process.arch;
107
+
108
+ if (platform === "darwin" && arch === "arm64") {
109
+ return { ok: true, platform: "darwin", arch: "arm64", ext: "" };
110
+ }
111
+ if (platform === "darwin" && arch === "x64") {
112
+ return { ok: true, platform: "darwin", arch: "x64", ext: "" };
113
+ }
114
+ if (platform === "linux" && arch === "x64") {
115
+ return { ok: true, platform: "linux", arch: "x64", ext: "" };
116
+ }
117
+ if (platform === "win32" && arch === "x64") {
118
+ return { ok: true, platform: "windows", arch: "x64", ext: ".exe" };
119
+ }
120
+ return {
121
+ ok: false,
122
+ message: [
123
+ `Unsupported platform/arch: ${platform}/${arch}`,
124
+ "Prebuilt binaries are currently available for:",
125
+ " - darwin/x64",
126
+ " - darwin/arm64",
127
+ " - linux/x64",
128
+ " - windows/x64",
129
+ ].join("\n"),
130
+ };
131
+ }
132
+
133
+ function detectPackageManager() {
134
+ const forced = String(process.env.FACULT_INSTALL_PM || "").trim();
135
+ if (forced === "bun" || forced === "npm") {
136
+ return forced;
137
+ }
138
+
139
+ const userAgent = String(process.env.npm_config_user_agent || "");
140
+ if (userAgent.startsWith("bun/")) {
141
+ return "bun";
142
+ }
143
+ if (userAgent.startsWith("npm/")) {
144
+ return "npm";
145
+ }
146
+ if (__filename.includes(`${path.sep}.bun${path.sep}`)) {
147
+ return "bun";
148
+ }
149
+ return "npm";
150
+ }
151
+
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 = {
169
+ "user-agent": "facult-installer",
170
+ accept: "application/octet-stream",
171
+ };
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
+ }
187
+
188
+ async function download(url, destinationPath, options = {}) {
189
+ await new Promise((resolve, reject) => {
190
+ const request = https.get(
191
+ url,
192
+ {
193
+ headers: buildRequestHeaders(url, options.token),
194
+ },
195
+ (response) => {
196
+ if (
197
+ response.statusCode &&
198
+ response.statusCode >= 300 &&
199
+ response.statusCode < 400 &&
200
+ response.headers.location
201
+ ) {
202
+ response.resume();
203
+ download(response.headers.location, destinationPath, options)
204
+ .then(resolve)
205
+ .catch(reject);
206
+ return;
207
+ }
208
+
209
+ if (response.statusCode !== 200) {
210
+ response.resume();
211
+ reject(
212
+ new Error(
213
+ `HTTP ${response.statusCode ?? "unknown"} while downloading`
214
+ )
215
+ );
216
+ return;
217
+ }
218
+
219
+ const file = fs.createWriteStream(destinationPath);
220
+ response.pipe(file);
221
+ file.on("finish", () => {
222
+ file.close((err) => {
223
+ if (err) {
224
+ reject(err);
225
+ return;
226
+ }
227
+ resolve(undefined);
228
+ });
229
+ });
230
+ file.on("error", (err) => reject(err));
231
+ }
232
+ );
233
+
234
+ request.on("error", (err) => reject(err));
235
+ });
236
+ }
237
+
238
+ async function downloadWithRetry(url, destinationPath, options) {
239
+ let lastError = null;
240
+ for (let attempt = 1; attempt <= options.attempts; attempt += 1) {
241
+ try {
242
+ await safeUnlink(destinationPath);
243
+ await download(url, destinationPath, { token: options.token });
244
+ return;
245
+ } catch (error) {
246
+ lastError = error;
247
+ if (attempt >= options.attempts) {
248
+ break;
249
+ }
250
+ await sleep(options.delayMs);
251
+ }
252
+ }
253
+ throw lastError instanceof Error
254
+ ? lastError
255
+ : new Error(String(lastError ?? ""));
256
+ }
257
+
258
+ function sleep(ms) {
259
+ return new Promise((resolve) => {
260
+ setTimeout(resolve, ms);
261
+ });
262
+ }
263
+
264
+ async function writeInstallState(state) {
265
+ const home = os.homedir();
266
+ const dir = path.join(home, ".facult");
267
+ const installPath = path.join(dir, "install.json");
268
+ await fsp.mkdir(dir, { recursive: true });
269
+ const payload = {
270
+ version: 1,
271
+ method: state.method,
272
+ installedAt: new Date().toISOString(),
273
+ packageVersion: state.version,
274
+ binaryPath: state.binaryPath,
275
+ source: "npm",
276
+ packageManager: state.packageManager,
277
+ };
278
+ await fsp.writeFile(installPath, `${JSON.stringify(payload, null, 2)}\n`);
279
+ }
280
+
281
+ async function fileExists(filePath) {
282
+ try {
283
+ await fsp.stat(filePath);
284
+ return true;
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
289
+
290
+ async function safeUnlink(filePath) {
291
+ try {
292
+ await fsp.unlink(filePath);
293
+ } catch {
294
+ // ignore
295
+ }
296
+ }
297
+
298
+ main().catch((error) => {
299
+ const message = error instanceof Error ? error.message : String(error ?? "");
300
+ console.error(message);
301
+ process.exit(1);
302
+ });