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
@@ -0,0 +1,68 @@
1
+ export type Severity = "low" | "medium" | "high" | "critical";
2
+
3
+ export const SEVERITY_ORDER: Record<Severity, number> = {
4
+ low: 0,
5
+ medium: 1,
6
+ high: 2,
7
+ critical: 3,
8
+ };
9
+
10
+ export function parseSeverity(raw: string): Severity | null {
11
+ const v = raw.trim().toLowerCase();
12
+ if (v === "low" || v === "medium" || v === "high" || v === "critical") {
13
+ return v;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export function isAtLeastSeverity(sev: Severity, min?: Severity): boolean {
19
+ if (!min) {
20
+ return true;
21
+ }
22
+ return SEVERITY_ORDER[sev] >= SEVERITY_ORDER[min];
23
+ }
24
+
25
+ export interface AuditRule {
26
+ id: string;
27
+ severity: Severity;
28
+ pattern: string;
29
+ message: string;
30
+ target?: "skill" | "mcp" | "any";
31
+ }
32
+
33
+ export interface CompiledAuditRule extends AuditRule {
34
+ regex: RegExp;
35
+ }
36
+
37
+ export interface AuditFinding {
38
+ severity: Severity;
39
+ ruleId: string;
40
+ message: string;
41
+ location?: string;
42
+ evidence?: string;
43
+ }
44
+
45
+ export interface AuditItemResult {
46
+ item: string;
47
+ type: "skill" | "mcp" | "mcp-config" | "asset";
48
+ sourceId?: string;
49
+ path: string;
50
+ passed: boolean;
51
+ findings: AuditFinding[];
52
+ /** Optional extra context (mostly for agent-assisted audits). */
53
+ notes?: string;
54
+ }
55
+
56
+ export interface StaticAuditReport {
57
+ timestamp: string;
58
+ mode: "static";
59
+ minSeverity?: Severity;
60
+ rulesPath?: string | null;
61
+ results: AuditItemResult[];
62
+ summary: {
63
+ totalItems: number;
64
+ totalFindings: number;
65
+ bySeverity: Record<Severity, number>;
66
+ flaggedItems: number;
67
+ };
68
+ }
@@ -0,0 +1,115 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import type { FacultIndex } from "../index-builder";
4
+ import { facultRootDir } from "../paths";
5
+ import type { AuditItemResult, Severity } from "./types";
6
+ import { SEVERITY_ORDER } from "./types";
7
+
8
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
9
+ return !!v && typeof v === "object" && !Array.isArray(v);
10
+ }
11
+
12
+ function ensureIndexStructure(index: FacultIndex): FacultIndex {
13
+ return {
14
+ version: index.version ?? 1,
15
+ updatedAt: index.updatedAt ?? new Date().toISOString(),
16
+ skills: index.skills ?? {},
17
+ mcp: index.mcp ?? { servers: {} },
18
+ agents: index.agents ?? {},
19
+ snippets: index.snippets ?? {},
20
+ };
21
+ }
22
+
23
+ function computeAuditStatus(
24
+ findings: { severity: Severity; ruleId: string }[]
25
+ ): "pending" | "passed" | "flagged" {
26
+ if (findings.some((f) => f.ruleId === "agent-error")) {
27
+ return "pending";
28
+ }
29
+ const worst = findings.reduce(
30
+ (m, f) => Math.max(m, SEVERITY_ORDER[f.severity]),
31
+ -1
32
+ );
33
+ return worst >= SEVERITY_ORDER.high ? "flagged" : "passed";
34
+ }
35
+
36
+ async function loadIndex(rootDir: string): Promise<FacultIndex | null> {
37
+ const indexPath = join(rootDir, "index.json");
38
+ const file = Bun.file(indexPath);
39
+ if (!(await file.exists())) {
40
+ return null;
41
+ }
42
+ try {
43
+ return JSON.parse(await file.text()) as FacultIndex;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ async function writeIndex(rootDir: string, index: FacultIndex) {
50
+ const indexPath = join(rootDir, "index.json");
51
+ await Bun.write(indexPath, `${JSON.stringify(index, null, 2)}\n`);
52
+ }
53
+
54
+ export async function updateIndexFromAuditReport(opts: {
55
+ homeDir?: string;
56
+ timestamp: string;
57
+ results: AuditItemResult[];
58
+ }): Promise<{ updated: boolean; reason?: string }> {
59
+ const home = opts.homeDir ?? homedir();
60
+ const rootDir = facultRootDir(home);
61
+
62
+ const loaded = await loadIndex(rootDir);
63
+ if (!loaded) {
64
+ return { updated: false, reason: "index-missing" };
65
+ }
66
+
67
+ const index = ensureIndexStructure(loaded);
68
+ let changed = false;
69
+
70
+ for (const r of opts.results) {
71
+ if (r.type !== "skill" && r.type !== "mcp") {
72
+ continue;
73
+ }
74
+ if (r.type === "skill") {
75
+ const entry = index.skills[r.item] as unknown;
76
+ if (!(entry && isPlainObject(entry))) {
77
+ continue;
78
+ }
79
+ if (typeof entry.path === "string" && entry.path !== r.path) {
80
+ // Only update the canonical instance tracked in the index.
81
+ continue;
82
+ }
83
+ const status = computeAuditStatus(
84
+ r.findings.map((f) => ({ severity: f.severity, ruleId: f.ruleId }))
85
+ );
86
+ entry.auditStatus = status;
87
+ entry.lastAuditAt = opts.timestamp;
88
+ changed = true;
89
+ continue;
90
+ }
91
+
92
+ // MCP
93
+ const entry = index.mcp?.servers?.[r.item] as unknown;
94
+ if (!(entry && isPlainObject(entry))) {
95
+ continue;
96
+ }
97
+ if (typeof entry.path === "string" && entry.path !== r.path) {
98
+ continue;
99
+ }
100
+ const status = computeAuditStatus(
101
+ r.findings.map((f) => ({ severity: f.severity, ruleId: f.ruleId }))
102
+ );
103
+ entry.auditStatus = status;
104
+ entry.lastAuditAt = opts.timestamp;
105
+ changed = true;
106
+ }
107
+
108
+ if (!changed) {
109
+ return { updated: false, reason: "no-matching-items" };
110
+ }
111
+
112
+ index.updatedAt = new Date().toISOString();
113
+ await writeIndex(rootDir, index);
114
+ return { updated: true };
115
+ }
@@ -0,0 +1,135 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import type { CanonicalMcpServer } from "./schema";
3
+
4
+ const SHA_SPLIT_REGEX = /\s+/;
5
+
6
+ export type AutoMode = "keep-newest" | "keep-current" | "keep-incoming";
7
+
8
+ export type AutoDecision = "keep-current" | "keep-incoming" | "keep-both";
9
+
10
+ export interface ConflictMeta {
11
+ modified: Date | null;
12
+ }
13
+
14
+ function normalizeLineEndings(input: string): string {
15
+ return input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
16
+ }
17
+
18
+ function trimLineWhitespace(input: string): string {
19
+ return input
20
+ .split("\n")
21
+ .map((line) => line.replace(/\s+$/g, ""))
22
+ .join("\n");
23
+ }
24
+
25
+ function sortValue(value: unknown): unknown {
26
+ if (Array.isArray(value)) {
27
+ return value.map((item) => sortValue(item));
28
+ }
29
+ if (!value || typeof value !== "object") {
30
+ return value;
31
+ }
32
+ const obj = value as Record<string, unknown>;
33
+ const out: Record<string, unknown> = {};
34
+ for (const key of Object.keys(obj).sort()) {
35
+ out[key] = sortValue(obj[key]);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * Normalize text content for comparison.
42
+ *
43
+ * Trims surrounding whitespace, normalizes line endings to LF,
44
+ * and removes trailing whitespace on each line.
45
+ */
46
+ export function normalizeText(input: string): string {
47
+ const normalized = normalizeLineEndings(input);
48
+ return trimLineWhitespace(normalized).trim();
49
+ }
50
+
51
+ /**
52
+ * Normalize JSON content for comparison.
53
+ *
54
+ * Parses JSON and stringifies with stable key ordering.
55
+ */
56
+ export function normalizeJson(input: string): string {
57
+ const parsed = JSON.parse(input) as unknown;
58
+ const stable = sortValue(parsed);
59
+ return JSON.stringify(stable, null, 2).trim();
60
+ }
61
+
62
+ /**
63
+ * Compute a sha256 hash for normalized content.
64
+ */
65
+ export function contentHash(input: string): string {
66
+ if (typeof Bun !== "undefined" && "CryptoHasher" in Bun) {
67
+ const hasher = new Bun.CryptoHasher("sha256");
68
+ hasher.update(input);
69
+ return hasher.digest("hex");
70
+ }
71
+
72
+ const res = spawnSync("shasum", ["-a", "256"], {
73
+ input,
74
+ encoding: "utf8",
75
+ });
76
+ if (res.status === 0 && res.stdout) {
77
+ return res.stdout.trim().split(SHA_SPLIT_REGEX)[0] ?? "";
78
+ }
79
+
80
+ throw new Error("Unable to compute content hash");
81
+ }
82
+
83
+ export function hashesMatch(
84
+ currentHash: string | null,
85
+ incomingHash: string | null
86
+ ): boolean {
87
+ return Boolean(currentHash && incomingHash && currentHash === incomingHash);
88
+ }
89
+
90
+ /**
91
+ * Normalize a canonical MCP server entry for hashing.
92
+ *
93
+ * Strips provenance metadata and normalizes JSON key ordering.
94
+ */
95
+ export function normalizeMcpServer(entry: CanonicalMcpServer): string {
96
+ const { provenance: _provenance, ...rest } = entry;
97
+ return normalizeJson(JSON.stringify(rest, null, 2));
98
+ }
99
+
100
+ /**
101
+ * Compute a hash for a canonical MCP server entry.
102
+ */
103
+ export function mcpServerHash(entry: CanonicalMcpServer): string {
104
+ return contentHash(normalizeMcpServer(entry));
105
+ }
106
+
107
+ /**
108
+ * Decide an automatic conflict resolution based on configured mode.
109
+ */
110
+ export function decideAuto(
111
+ mode: AutoMode | undefined,
112
+ currentMeta: ConflictMeta,
113
+ incomingMeta: ConflictMeta
114
+ ): AutoDecision {
115
+ if (!mode) {
116
+ return "keep-both";
117
+ }
118
+ if (mode === "keep-current") {
119
+ return "keep-current";
120
+ }
121
+ if (mode === "keep-incoming") {
122
+ return "keep-incoming";
123
+ }
124
+
125
+ const currentTime = currentMeta.modified?.getTime() ?? null;
126
+ const incomingTime = incomingMeta.modified?.getTime() ?? null;
127
+
128
+ if (currentTime !== null && incomingTime !== null) {
129
+ return incomingTime > currentTime ? "keep-incoming" : "keep-current";
130
+ }
131
+ if (incomingTime !== null && currentTime === null) {
132
+ return "keep-incoming";
133
+ }
134
+ return "keep-current";
135
+ }
@@ -0,0 +1,57 @@
1
+ import {
2
+ type AutoDecision,
3
+ type AutoMode,
4
+ type ConflictMeta,
5
+ decideAuto,
6
+ hashesMatch,
7
+ } from "./conflicts";
8
+
9
+ export type ConflictDecision = AutoDecision | "skip";
10
+
11
+ export interface PromptConflictResolutionArgs {
12
+ title: string;
13
+ currentLabel: string;
14
+ incomingLabel: string;
15
+ currentContent: string;
16
+ incomingContent: string;
17
+ }
18
+
19
+ export interface ResolveConflictActionArgs {
20
+ title: string;
21
+ currentLabel: string;
22
+ incomingLabel: string;
23
+ currentContent: string | null;
24
+ incomingContent: string | null;
25
+ currentHash: string | null;
26
+ incomingHash: string | null;
27
+ autoMode: AutoMode | undefined;
28
+ currentMeta: ConflictMeta;
29
+ incomingMeta: ConflictMeta;
30
+ promptConflictResolution: (
31
+ args: PromptConflictResolutionArgs
32
+ ) => Promise<ConflictDecision>;
33
+ }
34
+
35
+ export async function resolveConflictAction(
36
+ args: ResolveConflictActionArgs
37
+ ): Promise<ConflictDecision> {
38
+ if (!args.currentContent) {
39
+ return "keep-incoming";
40
+ }
41
+ if (!args.incomingContent) {
42
+ return "keep-current";
43
+ }
44
+ if (hashesMatch(args.currentHash, args.incomingHash)) {
45
+ return "keep-current";
46
+ }
47
+ if (args.autoMode) {
48
+ return decideAuto(args.autoMode, args.currentMeta, args.incomingMeta);
49
+ }
50
+ return await args.promptConflictResolution({
51
+ title: args.title,
52
+ currentLabel: args.currentLabel,
53
+ incomingLabel: args.incomingLabel,
54
+ currentContent: args.currentContent,
55
+ incomingContent: args.incomingContent,
56
+ });
57
+ }