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/src/query.ts ADDED
@@ -0,0 +1,186 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import type {
4
+ AgentEntry,
5
+ FacultIndex,
6
+ McpEntry,
7
+ SkillEntry,
8
+ SnippetEntry,
9
+ } from "./index-builder";
10
+ import { facultRootDir } from "./paths";
11
+ import { applyOrgTrustList } from "./trust-list";
12
+
13
+ export interface QueryFilters {
14
+ /** Only include entries enabled for this tool name. */
15
+ enabledFor?: string;
16
+ /** Only include entries that are not trusted. */
17
+ untrusted?: boolean;
18
+ /** Only include entries flagged by audit. */
19
+ flagged?: boolean;
20
+ /** Only include entries pending audit. */
21
+ pending?: boolean;
22
+ /** Only include entries with all of these tags. */
23
+ tags?: string[];
24
+ /** Full-text search query (case-insensitive). */
25
+ text?: string;
26
+ }
27
+
28
+ interface IndexEntry {
29
+ name: string;
30
+ description?: string;
31
+ tags?: string[];
32
+ enabledFor?: string[];
33
+ trusted?: boolean;
34
+ auditStatus?: string;
35
+ }
36
+
37
+ const WHITESPACE_RE = /\s+/;
38
+
39
+ function normalizeText(v: string): string {
40
+ return v.trim().toLowerCase();
41
+ }
42
+
43
+ function matchesEnabledFor(entry: IndexEntry, tool?: string): boolean {
44
+ if (!tool) {
45
+ return true;
46
+ }
47
+ const enabledFor = entry.enabledFor;
48
+ if (!Array.isArray(enabledFor)) {
49
+ return false;
50
+ }
51
+ const target = normalizeText(tool);
52
+ return enabledFor.some((t) => normalizeText(t) === target);
53
+ }
54
+
55
+ function matchesUntrusted(entry: IndexEntry, untrusted?: boolean): boolean {
56
+ if (!untrusted) {
57
+ return true;
58
+ }
59
+ return entry.trusted !== true;
60
+ }
61
+
62
+ function matchesFlagged(entry: IndexEntry, flagged?: boolean): boolean {
63
+ if (!flagged) {
64
+ return true;
65
+ }
66
+ return normalizeText(entry.auditStatus ?? "") === "flagged";
67
+ }
68
+
69
+ function matchesPending(entry: IndexEntry, pending?: boolean): boolean {
70
+ if (!pending) {
71
+ return true;
72
+ }
73
+ const status = normalizeText(entry.auditStatus ?? "");
74
+ // Treat missing auditStatus as pending for backward compatibility.
75
+ return !status || status === "pending";
76
+ }
77
+
78
+ function matchesTags(entry: IndexEntry, tags?: string[]): boolean {
79
+ if (!tags || tags.length === 0) {
80
+ return true;
81
+ }
82
+ const entryTags = entry.tags ?? [];
83
+ return tags.every((tag) =>
84
+ entryTags.some((t) => normalizeText(t) === normalizeText(tag))
85
+ );
86
+ }
87
+
88
+ function matchesText(entry: IndexEntry, text?: string): boolean {
89
+ if (!text) {
90
+ return true;
91
+ }
92
+ const haystack = `${entry.name} ${entry.description ?? ""} ${
93
+ entry.tags?.join(" ") ?? ""
94
+ }`.toLowerCase();
95
+ const terms = text
96
+ .split(WHITESPACE_RE)
97
+ .map((t) => t.trim())
98
+ .filter(Boolean);
99
+ return terms.every((term) => haystack.includes(term.toLowerCase()));
100
+ }
101
+
102
+ /** Return the canonical facult root directory. */
103
+ export function facultRootDirPath(home: string = homedir()): string {
104
+ return facultRootDir(home);
105
+ }
106
+
107
+ /**
108
+ * Return the path to the facult index.json file.
109
+ */
110
+ export function facultIndexPath(home: string = homedir()): string {
111
+ return join(facultRootDir(home), "index.json");
112
+ }
113
+
114
+ /**
115
+ * Load the facult index.json into memory.
116
+ */
117
+ export async function loadIndex(opts?: {
118
+ /** Override the default canonical root dir (useful for tests). */
119
+ rootDir?: string;
120
+ /** Override home directory for org trust-list loading (useful for tests). */
121
+ homeDir?: string;
122
+ }): Promise<FacultIndex> {
123
+ const root = opts?.rootDir ?? facultRootDir();
124
+ const indexPath = join(root, "index.json");
125
+ const file = Bun.file(indexPath);
126
+ if (!(await file.exists())) {
127
+ throw new Error(`Index not found at ${indexPath}. Run "facult index".`);
128
+ }
129
+ const raw = await file.text();
130
+ const parsed = JSON.parse(raw) as FacultIndex;
131
+ return await applyOrgTrustList(parsed, { homeDir: opts?.homeDir });
132
+ }
133
+
134
+ /**
135
+ * Filter skill entries using query filters.
136
+ */
137
+ export function filterSkills(
138
+ entries: Record<string, SkillEntry>,
139
+ filters?: QueryFilters
140
+ ): SkillEntry[] {
141
+ return filterEntries(entries, filters);
142
+ }
143
+
144
+ /**
145
+ * Filter MCP server entries using query filters.
146
+ */
147
+ export function filterMcp(
148
+ entries: Record<string, McpEntry>,
149
+ filters?: QueryFilters
150
+ ): McpEntry[] {
151
+ return filterEntries(entries, filters);
152
+ }
153
+
154
+ /**
155
+ * Filter agent entries using query filters.
156
+ */
157
+ export function filterAgents(
158
+ entries: Record<string, AgentEntry>,
159
+ filters?: QueryFilters
160
+ ): AgentEntry[] {
161
+ return filterEntries(entries, filters);
162
+ }
163
+
164
+ /**
165
+ * Filter snippet entries using query filters.
166
+ */
167
+ export function filterSnippets(
168
+ entries: Record<string, SnippetEntry>,
169
+ filters?: QueryFilters
170
+ ): SnippetEntry[] {
171
+ return filterEntries(entries, filters);
172
+ }
173
+
174
+ function filterEntries<T extends IndexEntry>(
175
+ entries: Record<string, T>,
176
+ filters?: QueryFilters
177
+ ): T[] {
178
+ return Object.values(entries)
179
+ .filter((entry) => matchesEnabledFor(entry, filters?.enabledFor))
180
+ .filter((entry) => matchesUntrusted(entry, filters?.untrusted))
181
+ .filter((entry) => matchesFlagged(entry, filters?.flagged))
182
+ .filter((entry) => matchesPending(entry, filters?.pending))
183
+ .filter((entry) => matchesTags(entry, filters?.tags))
184
+ .filter((entry) => matchesText(entry, filters?.text))
185
+ .sort((a, b) => a.name.localeCompare(b.name));
186
+ }
@@ -0,0 +1,367 @@
1
+ import { createHash, createPublicKey, verify } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+
5
+ const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
6
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/i;
7
+ const SHA256_TAGGED_HEX_RE = /^sha256:([0-9a-f]{64})$/i;
8
+ const SHA256_TAGGED_BASE64_RE = /^sha256-([A-Za-z0-9+/]+={0,2})$/;
9
+
10
+ export interface ManifestIntegrity {
11
+ algorithm: "sha256";
12
+ encoding: "hex" | "base64";
13
+ value: string;
14
+ }
15
+
16
+ export interface ManifestSignature {
17
+ algorithm: "ed25519";
18
+ value: string;
19
+ keyId?: string;
20
+ publicKey?: string;
21
+ publicKeyPath?: string;
22
+ }
23
+
24
+ export type ManifestSignatureKeyStatus = "active" | "retired" | "revoked";
25
+
26
+ export interface ManifestSignatureKey {
27
+ id: string;
28
+ status: ManifestSignatureKeyStatus;
29
+ publicKey?: string;
30
+ publicKeyPath?: string;
31
+ }
32
+
33
+ export function parseManifestIntegrity(
34
+ raw: unknown
35
+ ): ManifestIntegrity | undefined {
36
+ if (typeof raw !== "string") {
37
+ return undefined;
38
+ }
39
+ const trimmed = raw.trim();
40
+ if (!trimmed) {
41
+ return undefined;
42
+ }
43
+
44
+ const taggedHex = trimmed.match(SHA256_TAGGED_HEX_RE)?.[1];
45
+ if (taggedHex) {
46
+ return {
47
+ algorithm: "sha256",
48
+ encoding: "hex",
49
+ value: taggedHex.toLowerCase(),
50
+ };
51
+ }
52
+
53
+ const taggedBase64 = trimmed.match(SHA256_TAGGED_BASE64_RE)?.[1];
54
+ if (taggedBase64) {
55
+ return {
56
+ algorithm: "sha256",
57
+ encoding: "base64",
58
+ value: taggedBase64,
59
+ };
60
+ }
61
+
62
+ if (SHA256_HEX_RE.test(trimmed)) {
63
+ return {
64
+ algorithm: "sha256",
65
+ encoding: "hex",
66
+ value: trimmed.toLowerCase(),
67
+ };
68
+ }
69
+
70
+ return undefined;
71
+ }
72
+
73
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
74
+ return !!v && typeof v === "object" && !Array.isArray(v);
75
+ }
76
+
77
+ export function parseManifestSignature(
78
+ raw: unknown
79
+ ): ManifestSignature | undefined {
80
+ if (typeof raw === "string") {
81
+ const value = raw.trim();
82
+ if (!value) {
83
+ return undefined;
84
+ }
85
+ return {
86
+ algorithm: "ed25519",
87
+ value,
88
+ };
89
+ }
90
+
91
+ if (!isPlainObject(raw)) {
92
+ return undefined;
93
+ }
94
+ const obj = raw as Record<string, unknown>;
95
+ const algorithm =
96
+ typeof obj.algorithm === "string" ? obj.algorithm.trim() : "ed25519";
97
+ if (algorithm !== "ed25519") {
98
+ return undefined;
99
+ }
100
+ const valueRaw =
101
+ typeof obj.value === "string"
102
+ ? obj.value
103
+ : typeof obj.signature === "string"
104
+ ? obj.signature
105
+ : typeof obj.sig === "string"
106
+ ? obj.sig
107
+ : "";
108
+ const value = valueRaw.trim();
109
+ if (!value) {
110
+ return undefined;
111
+ }
112
+
113
+ const publicKey =
114
+ typeof obj.publicKey === "string"
115
+ ? obj.publicKey
116
+ : typeof obj.publicKeyPem === "string"
117
+ ? obj.publicKeyPem
118
+ : undefined;
119
+ const keyId =
120
+ typeof obj.keyId === "string"
121
+ ? obj.keyId
122
+ : typeof obj.kid === "string"
123
+ ? obj.kid
124
+ : undefined;
125
+ const publicKeyPath =
126
+ typeof obj.publicKeyPath === "string"
127
+ ? obj.publicKeyPath
128
+ : typeof obj.keyPath === "string"
129
+ ? obj.keyPath
130
+ : undefined;
131
+ return {
132
+ algorithm: "ed25519",
133
+ value,
134
+ keyId: keyId?.trim() || undefined,
135
+ publicKey: publicKey?.trim() || undefined,
136
+ publicKeyPath: publicKeyPath?.trim() || undefined,
137
+ };
138
+ }
139
+
140
+ function parseKeyStatus(raw: unknown): ManifestSignatureKeyStatus {
141
+ if (raw === "revoked") {
142
+ return "revoked";
143
+ }
144
+ if (raw === "retired") {
145
+ return "retired";
146
+ }
147
+ return "active";
148
+ }
149
+
150
+ export function parseManifestSignatureKeys(
151
+ raw: unknown
152
+ ): ManifestSignatureKey[] {
153
+ if (!Array.isArray(raw)) {
154
+ return [];
155
+ }
156
+
157
+ const out = new Map<string, ManifestSignatureKey>();
158
+ for (const entry of raw) {
159
+ if (!isPlainObject(entry)) {
160
+ continue;
161
+ }
162
+ const obj = entry as Record<string, unknown>;
163
+ const id =
164
+ typeof obj.id === "string"
165
+ ? obj.id.trim()
166
+ : typeof obj.keyId === "string"
167
+ ? obj.keyId.trim()
168
+ : typeof obj.kid === "string"
169
+ ? obj.kid.trim()
170
+ : "";
171
+ if (!id) {
172
+ continue;
173
+ }
174
+
175
+ const publicKey =
176
+ typeof obj.publicKey === "string"
177
+ ? obj.publicKey
178
+ : typeof obj.publicKeyPem === "string"
179
+ ? obj.publicKeyPem
180
+ : undefined;
181
+ const publicKeyPath =
182
+ typeof obj.publicKeyPath === "string"
183
+ ? obj.publicKeyPath
184
+ : typeof obj.keyPath === "string"
185
+ ? obj.keyPath
186
+ : undefined;
187
+ out.set(id, {
188
+ id,
189
+ status: parseKeyStatus(obj.status),
190
+ publicKey: publicKey?.trim() || undefined,
191
+ publicKeyPath: publicKeyPath?.trim() || undefined,
192
+ });
193
+ }
194
+ return Array.from(out.values()).sort((a, b) => a.id.localeCompare(b.id));
195
+ }
196
+
197
+ function sha256Hex(input: string): string {
198
+ if (typeof Bun !== "undefined" && "CryptoHasher" in Bun) {
199
+ const hasher = new Bun.CryptoHasher("sha256");
200
+ hasher.update(input);
201
+ return hasher.digest("hex");
202
+ }
203
+ return createHash("sha256").update(input).digest("hex");
204
+ }
205
+
206
+ function toBase64(hex: string): string {
207
+ return Buffer.from(hex, "hex").toString("base64");
208
+ }
209
+
210
+ function decodeBase64(raw: string): Buffer {
211
+ const trimmed = raw.trim();
212
+ if (!(trimmed && BASE64_RE.test(trimmed))) {
213
+ throw new Error("Invalid base64 payload.");
214
+ }
215
+ return Buffer.from(trimmed, "base64");
216
+ }
217
+
218
+ function resolvePath(rawPath: string, cwd: string, homeDir: string): string {
219
+ if (rawPath.startsWith("~/")) {
220
+ return join(homeDir, rawPath.slice(2));
221
+ }
222
+ if (isAbsolute(rawPath)) {
223
+ return rawPath;
224
+ }
225
+ return resolve(cwd, rawPath);
226
+ }
227
+
228
+ async function loadSignaturePublicKeyPem(args: {
229
+ signature: ManifestSignature;
230
+ signatureKeys?: ManifestSignatureKey[];
231
+ cwd: string;
232
+ homeDir: string;
233
+ }): Promise<string> {
234
+ const keyId = args.signature.keyId?.trim();
235
+ const keySet = args.signatureKeys ?? [];
236
+ if (keyId) {
237
+ const key = keySet.find((candidate) => candidate.id === keyId);
238
+ if (!key) {
239
+ throw new Error(
240
+ `Manifest signature keyId "${keyId}" was not found in configured keys.`
241
+ );
242
+ }
243
+ if (key.status === "revoked") {
244
+ throw new Error(`Manifest signature keyId "${keyId}" is revoked.`);
245
+ }
246
+ if (key.publicKey?.trim()) {
247
+ return key.publicKey;
248
+ }
249
+ if (key.publicKeyPath?.trim()) {
250
+ const resolved = resolvePath(key.publicKeyPath, args.cwd, args.homeDir);
251
+ return (await readFile(resolved, "utf8")).trim();
252
+ }
253
+ if (args.signature.publicKey?.trim()) {
254
+ return args.signature.publicKey;
255
+ }
256
+ const path = args.signature.publicKeyPath?.trim();
257
+ if (path) {
258
+ const resolved = resolvePath(path, args.cwd, args.homeDir);
259
+ return (await readFile(resolved, "utf8")).trim();
260
+ }
261
+ throw new Error(
262
+ `Manifest signature keyId "${keyId}" does not provide key material.`
263
+ );
264
+ }
265
+
266
+ if (args.signature.publicKey?.trim()) {
267
+ return args.signature.publicKey;
268
+ }
269
+ const path = args.signature.publicKeyPath?.trim();
270
+ if (!path) {
271
+ const candidates = keySet.filter(
272
+ (key) =>
273
+ key.status !== "revoked" &&
274
+ Boolean(key.publicKey?.trim() || key.publicKeyPath?.trim())
275
+ );
276
+ if (candidates.length === 1 && candidates[0]) {
277
+ const selected = candidates[0];
278
+ if (selected.publicKey?.trim()) {
279
+ return selected.publicKey;
280
+ }
281
+ const selectedPath = selected.publicKeyPath?.trim();
282
+ if (!selectedPath) {
283
+ throw new Error(
284
+ `Manifest signature key "${selected.id}" does not provide key material.`
285
+ );
286
+ }
287
+ const resolved = resolvePath(selectedPath, args.cwd, args.homeDir);
288
+ return (await readFile(resolved, "utf8")).trim();
289
+ }
290
+ if (candidates.length > 1) {
291
+ throw new Error(
292
+ "Manifest signature matches multiple configured keys; set signature.keyId."
293
+ );
294
+ }
295
+ throw new Error(
296
+ "Manifest signature requires publicKey/publicKeyPath or configured signature keys."
297
+ );
298
+ }
299
+ const resolved = resolvePath(path, args.cwd, args.homeDir);
300
+ return (await readFile(resolved, "utf8")).trim();
301
+ }
302
+
303
+ function publicKeyObjectFromInput(publicKeyPem: string) {
304
+ const trimmed = publicKeyPem.trim();
305
+ if (trimmed.includes("BEGIN PUBLIC KEY")) {
306
+ return createPublicKey(trimmed);
307
+ }
308
+ const der = decodeBase64(trimmed);
309
+ return createPublicKey({
310
+ key: der,
311
+ format: "der",
312
+ type: "spki",
313
+ });
314
+ }
315
+
316
+ export function assertManifestIntegrity(args: {
317
+ sourceName: string;
318
+ sourceUrl: string;
319
+ integrity: ManifestIntegrity;
320
+ manifestText: string;
321
+ }): void {
322
+ const digestHex = sha256Hex(args.manifestText);
323
+ const digestBase64 = toBase64(digestHex);
324
+ const matched =
325
+ args.integrity.encoding === "hex"
326
+ ? digestHex.toLowerCase() === args.integrity.value.toLowerCase()
327
+ : digestBase64 === args.integrity.value;
328
+
329
+ if (matched) {
330
+ return;
331
+ }
332
+
333
+ throw new Error(
334
+ `Manifest integrity check failed for source "${args.sourceName}" (${args.sourceUrl}).`
335
+ );
336
+ }
337
+
338
+ export async function assertManifestSignature(args: {
339
+ sourceName: string;
340
+ sourceUrl: string;
341
+ signature: ManifestSignature;
342
+ signatureKeys?: ManifestSignatureKey[];
343
+ manifestText: string;
344
+ cwd: string;
345
+ homeDir: string;
346
+ }): Promise<void> {
347
+ const publicKeyPem = await loadSignaturePublicKeyPem({
348
+ signature: args.signature,
349
+ signatureKeys: args.signatureKeys,
350
+ cwd: args.cwd,
351
+ homeDir: args.homeDir,
352
+ });
353
+ const publicKey = publicKeyObjectFromInput(publicKeyPem);
354
+ const signatureBytes = decodeBase64(args.signature.value);
355
+ const verified = verify(
356
+ null,
357
+ Buffer.from(args.manifestText),
358
+ publicKey,
359
+ signatureBytes
360
+ );
361
+ if (verified) {
362
+ return;
363
+ }
364
+ throw new Error(
365
+ `Manifest signature check failed for source "${args.sourceName}" (${args.sourceUrl}).`
366
+ );
367
+ }