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,203 @@
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { facultStateDir } from "./paths";
5
+ import { parseJsonLenient } from "./util/json";
6
+
7
+ export type SourceTrustLevel = "trusted" | "review" | "blocked";
8
+
9
+ export interface SourceTrustPolicy {
10
+ level: SourceTrustLevel;
11
+ note?: string;
12
+ updatedAt: string;
13
+ updatedBy: "user";
14
+ }
15
+
16
+ export interface SourceTrustState {
17
+ version: 1;
18
+ updatedAt: string;
19
+ sources: Record<string, SourceTrustPolicy>;
20
+ }
21
+
22
+ const SOURCE_TRUST_VERSION = 1;
23
+
24
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
25
+ return !!v && typeof v === "object" && !Array.isArray(v);
26
+ }
27
+
28
+ function sourceTrustPath(home: string): string {
29
+ return join(facultStateDir(home), "trust", "sources.json");
30
+ }
31
+
32
+ function normalizeSourceName(name: string): string {
33
+ return name.trim();
34
+ }
35
+
36
+ function parseTrustLevel(raw: unknown): SourceTrustLevel | null {
37
+ if (raw !== "trusted" && raw !== "review" && raw !== "blocked") {
38
+ return null;
39
+ }
40
+ return raw;
41
+ }
42
+
43
+ function parsePolicy(raw: unknown): SourceTrustPolicy | null {
44
+ if (!isPlainObject(raw)) {
45
+ return null;
46
+ }
47
+ const level = parseTrustLevel(raw.level);
48
+ if (!level) {
49
+ return null;
50
+ }
51
+ const updatedAt = typeof raw.updatedAt === "string" ? raw.updatedAt : "";
52
+ if (!updatedAt) {
53
+ return null;
54
+ }
55
+ const updatedBy = raw.updatedBy === "user" ? "user" : "user";
56
+ const note = typeof raw.note === "string" ? raw.note.trim() : undefined;
57
+ return {
58
+ level,
59
+ note: note || undefined,
60
+ updatedAt,
61
+ updatedBy,
62
+ };
63
+ }
64
+
65
+ export function defaultSourceTrustLevel(args: {
66
+ sourceName: string;
67
+ }): SourceTrustLevel {
68
+ // Builtin templates are local/offline and safe as a trusted base.
69
+ if (args.sourceName === "facult") {
70
+ return "trusted";
71
+ }
72
+ // External and custom sources default to review.
73
+ return "review";
74
+ }
75
+
76
+ export function sourceTrustLevelFor(args: {
77
+ sourceName: string;
78
+ state: SourceTrustState;
79
+ }): {
80
+ level: SourceTrustLevel;
81
+ explicit: boolean;
82
+ policy?: SourceTrustPolicy;
83
+ } {
84
+ const name = normalizeSourceName(args.sourceName);
85
+ const policy = args.state.sources[name];
86
+ if (policy) {
87
+ return {
88
+ level: policy.level,
89
+ explicit: true,
90
+ policy,
91
+ };
92
+ }
93
+ return {
94
+ level: defaultSourceTrustLevel({ sourceName: name }),
95
+ explicit: false,
96
+ };
97
+ }
98
+
99
+ export async function loadSourceTrustState(opts?: {
100
+ homeDir?: string;
101
+ }): Promise<SourceTrustState> {
102
+ const home = opts?.homeDir ?? homedir();
103
+ const path = sourceTrustPath(home);
104
+ try {
105
+ const raw = await readFile(path, "utf8");
106
+ const parsed = parseJsonLenient(raw);
107
+ if (!isPlainObject(parsed)) {
108
+ throw new Error("invalid");
109
+ }
110
+
111
+ const version =
112
+ typeof parsed.version === "number" && Number.isFinite(parsed.version)
113
+ ? Math.floor(parsed.version)
114
+ : SOURCE_TRUST_VERSION;
115
+ const updatedAt =
116
+ typeof parsed.updatedAt === "string"
117
+ ? parsed.updatedAt
118
+ : new Date(0).toISOString();
119
+ const sourcesRaw = isPlainObject(parsed.sources)
120
+ ? (parsed.sources as Record<string, unknown>)
121
+ : {};
122
+
123
+ const sources: Record<string, SourceTrustPolicy> = {};
124
+ for (const [name, value] of Object.entries(sourcesRaw)) {
125
+ const normalized = normalizeSourceName(name);
126
+ if (!normalized) {
127
+ continue;
128
+ }
129
+ const policy = parsePolicy(value);
130
+ if (!policy) {
131
+ continue;
132
+ }
133
+ sources[normalized] = policy;
134
+ }
135
+
136
+ return {
137
+ version: version === 1 ? 1 : SOURCE_TRUST_VERSION,
138
+ updatedAt,
139
+ sources,
140
+ };
141
+ } catch {
142
+ return {
143
+ version: SOURCE_TRUST_VERSION,
144
+ updatedAt: new Date(0).toISOString(),
145
+ sources: {},
146
+ };
147
+ }
148
+ }
149
+
150
+ export async function saveSourceTrustState(args: {
151
+ state: SourceTrustState;
152
+ homeDir?: string;
153
+ }): Promise<void> {
154
+ const home = args.homeDir ?? homedir();
155
+ const path = sourceTrustPath(home);
156
+ await mkdir(join(facultStateDir(home), "trust"), { recursive: true });
157
+ await Bun.write(path, `${JSON.stringify(args.state, null, 2)}\n`);
158
+ }
159
+
160
+ export async function setSourceTrustPolicy(args: {
161
+ sourceName: string;
162
+ level: SourceTrustLevel;
163
+ note?: string;
164
+ homeDir?: string;
165
+ now?: () => Date;
166
+ }): Promise<SourceTrustState> {
167
+ const home = args.homeDir ?? homedir();
168
+ const now = args.now ? args.now() : new Date();
169
+ const state = await loadSourceTrustState({ homeDir: home });
170
+ const sourceName = normalizeSourceName(args.sourceName);
171
+ if (!sourceName) {
172
+ throw new Error("Source name cannot be empty.");
173
+ }
174
+
175
+ state.sources[sourceName] = {
176
+ level: args.level,
177
+ note: args.note?.trim() || undefined,
178
+ updatedAt: now.toISOString(),
179
+ updatedBy: "user",
180
+ };
181
+ state.updatedAt = now.toISOString();
182
+ await saveSourceTrustState({ state, homeDir: home });
183
+ return state;
184
+ }
185
+
186
+ export async function clearSourceTrustPolicy(args: {
187
+ sourceName: string;
188
+ homeDir?: string;
189
+ now?: () => Date;
190
+ }): Promise<SourceTrustState> {
191
+ const home = args.homeDir ?? homedir();
192
+ const now = args.now ? args.now() : new Date();
193
+ const state = await loadSourceTrustState({ homeDir: home });
194
+ const sourceName = normalizeSourceName(args.sourceName);
195
+ if (!sourceName) {
196
+ throw new Error("Source name cannot be empty.");
197
+ }
198
+
199
+ delete state.sources[sourceName];
200
+ state.updatedAt = now.toISOString();
201
+ await saveSourceTrustState({ state, homeDir: home });
202
+ return state;
203
+ }
@@ -0,0 +1,232 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { FacultIndex, McpEntry, SkillEntry } from "./index-builder";
5
+ import { facultStateDir } from "./paths";
6
+ import { parseJsonLenient } from "./util/json";
7
+
8
+ const SHA256_HEX_RE = /^[a-f0-9]{64}$/;
9
+
10
+ export interface OrgTrustList {
11
+ version: number;
12
+ issuer?: string;
13
+ generatedAt?: string;
14
+ skills: Set<string>;
15
+ mcpServers: Set<string>;
16
+ }
17
+
18
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
19
+ return !!v && typeof v === "object" && !Array.isArray(v);
20
+ }
21
+
22
+ function sortValue(value: unknown): unknown {
23
+ if (Array.isArray(value)) {
24
+ return value.map((item) => sortValue(item));
25
+ }
26
+ if (!isPlainObject(value)) {
27
+ return value;
28
+ }
29
+ const out: Record<string, unknown> = {};
30
+ for (const key of Object.keys(value).sort()) {
31
+ out[key] = sortValue(value[key]);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ function stableStringify(value: unknown): string {
37
+ return JSON.stringify(sortValue(value));
38
+ }
39
+
40
+ function sha256Hex(text: string): string {
41
+ return createHash("sha256").update(text).digest("hex");
42
+ }
43
+
44
+ function normalizeNameList(raw: unknown): string[] {
45
+ if (!Array.isArray(raw)) {
46
+ return [];
47
+ }
48
+ return Array.from(
49
+ new Set(
50
+ raw
51
+ .filter((v): v is string => typeof v === "string")
52
+ .map((v) => v.trim())
53
+ .filter(Boolean)
54
+ )
55
+ ).sort();
56
+ }
57
+
58
+ function normalizeChecksum(raw: unknown): string | null {
59
+ if (typeof raw !== "string") {
60
+ return null;
61
+ }
62
+ const trimmed = raw.trim().toLowerCase();
63
+ if (!trimmed) {
64
+ return null;
65
+ }
66
+ const value = trimmed.startsWith("sha256:")
67
+ ? trimmed.slice("sha256:".length)
68
+ : trimmed;
69
+ if (!SHA256_HEX_RE.test(value)) {
70
+ return null;
71
+ }
72
+ return value;
73
+ }
74
+
75
+ function buildCanonicalPayload(args: {
76
+ version: number;
77
+ issuer?: string;
78
+ generatedAt?: string;
79
+ skills: string[];
80
+ mcpServers: string[];
81
+ }): Record<string, unknown> {
82
+ const payload: Record<string, unknown> = {
83
+ version: args.version,
84
+ skills: args.skills,
85
+ mcp: args.mcpServers,
86
+ };
87
+ if (args.issuer) {
88
+ payload.issuer = args.issuer;
89
+ }
90
+ if (args.generatedAt) {
91
+ payload.generatedAt = args.generatedAt;
92
+ }
93
+ return payload;
94
+ }
95
+
96
+ function extractTrustLists(obj: Record<string, unknown>): {
97
+ skills: string[];
98
+ mcpServers: string[];
99
+ } {
100
+ const topLevelSkills = normalizeNameList(obj.skills);
101
+ const topLevelMcp = normalizeNameList(obj.mcp);
102
+
103
+ const trust = isPlainObject(obj.trust)
104
+ ? (obj.trust as Record<string, unknown>)
105
+ : null;
106
+ const nestedSkills = normalizeNameList(trust?.skills);
107
+ const nestedMcp = normalizeNameList(trust?.mcp);
108
+
109
+ return {
110
+ skills: topLevelSkills.length ? topLevelSkills : nestedSkills,
111
+ mcpServers: topLevelMcp.length ? topLevelMcp : nestedMcp,
112
+ };
113
+ }
114
+
115
+ function localTrustIsExplicit(entry: { trusted?: boolean }): boolean {
116
+ return typeof entry.trusted === "boolean";
117
+ }
118
+
119
+ function applyTrustOverlay<T extends SkillEntry | McpEntry>(args: {
120
+ entries: Record<string, T>;
121
+ trustedNames: Set<string>;
122
+ trustedBy: string;
123
+ trustedAt?: string;
124
+ }): Record<string, T> {
125
+ const next: Record<string, T> = { ...args.entries };
126
+ for (const name of args.trustedNames) {
127
+ const current = next[name];
128
+ if (!current || localTrustIsExplicit(current)) {
129
+ continue;
130
+ }
131
+ const withTrust = {
132
+ ...current,
133
+ trusted: true,
134
+ trustedBy: args.trustedBy,
135
+ trustedAt: current.trustedAt ?? args.trustedAt,
136
+ } as T;
137
+ next[name] = withTrust;
138
+ }
139
+ return next;
140
+ }
141
+
142
+ export async function loadOrgTrustList(opts?: {
143
+ homeDir?: string;
144
+ }): Promise<OrgTrustList | null> {
145
+ const home = opts?.homeDir ?? homedir();
146
+ const trustPath = join(facultStateDir(home), "trust", "org-list.json");
147
+ const file = Bun.file(trustPath);
148
+ if (!(await file.exists())) {
149
+ return null;
150
+ }
151
+
152
+ let parsed: unknown;
153
+ try {
154
+ parsed = parseJsonLenient(await file.text());
155
+ } catch {
156
+ return null;
157
+ }
158
+ if (!isPlainObject(parsed)) {
159
+ return null;
160
+ }
161
+
162
+ const obj = parsed as Record<string, unknown>;
163
+ const version =
164
+ typeof obj.version === "number" && Number.isFinite(obj.version)
165
+ ? Math.floor(obj.version)
166
+ : 1;
167
+ const issuer = typeof obj.issuer === "string" ? obj.issuer.trim() : "";
168
+ const generatedAt =
169
+ typeof obj.generatedAt === "string" ? obj.generatedAt : undefined;
170
+ const { skills, mcpServers } = extractTrustLists(obj);
171
+
172
+ const expectedChecksum = normalizeChecksum(obj.checksum);
173
+ if (!expectedChecksum) {
174
+ return null;
175
+ }
176
+
177
+ const payload = buildCanonicalPayload({
178
+ version,
179
+ issuer: issuer || undefined,
180
+ generatedAt,
181
+ skills,
182
+ mcpServers,
183
+ });
184
+ const actualChecksum = sha256Hex(stableStringify(payload));
185
+ if (actualChecksum !== expectedChecksum) {
186
+ return null;
187
+ }
188
+
189
+ return {
190
+ version,
191
+ issuer: issuer || undefined,
192
+ generatedAt,
193
+ skills: new Set(skills),
194
+ mcpServers: new Set(mcpServers),
195
+ };
196
+ }
197
+
198
+ export async function applyOrgTrustList(
199
+ index: FacultIndex,
200
+ opts?: {
201
+ homeDir?: string;
202
+ }
203
+ ): Promise<FacultIndex> {
204
+ const orgList = await loadOrgTrustList(opts);
205
+ if (!orgList) {
206
+ return index;
207
+ }
208
+
209
+ const trustedBy = orgList.issuer ? `org:${orgList.issuer}` : "org";
210
+ const next: FacultIndex = {
211
+ version: index.version,
212
+ updatedAt: index.updatedAt,
213
+ skills: applyTrustOverlay({
214
+ entries: index.skills,
215
+ trustedNames: orgList.skills,
216
+ trustedBy,
217
+ trustedAt: orgList.generatedAt,
218
+ }),
219
+ mcp: {
220
+ servers: applyTrustOverlay({
221
+ entries: index.mcp?.servers ?? {},
222
+ trustedNames: orgList.mcpServers,
223
+ trustedBy,
224
+ trustedAt: orgList.generatedAt,
225
+ }),
226
+ },
227
+ agents: index.agents ?? {},
228
+ snippets: index.snippets ?? {},
229
+ };
230
+
231
+ return next;
232
+ }
package/src/trust.ts ADDED
@@ -0,0 +1,170 @@
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
+
6
+ type TrustMode = "trust" | "untrust";
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 parseEntryName(raw: string): { kind: "skill" | "mcp"; name: string } {
24
+ if (raw.startsWith("mcp:")) {
25
+ return { kind: "mcp", name: raw.slice("mcp:".length) };
26
+ }
27
+ return { kind: "skill", name: raw };
28
+ }
29
+
30
+ async function loadIndex(rootDir: string): Promise<FacultIndex> {
31
+ const indexPath = join(rootDir, "index.json");
32
+ const file = Bun.file(indexPath);
33
+ if (!(await file.exists())) {
34
+ throw new Error(`Index not found at ${indexPath}. Run "facult index".`);
35
+ }
36
+ const raw = await file.text();
37
+ return JSON.parse(raw) as FacultIndex;
38
+ }
39
+
40
+ async function writeIndex(rootDir: string, index: FacultIndex) {
41
+ const indexPath = join(rootDir, "index.json");
42
+ await Bun.write(indexPath, `${JSON.stringify(index, null, 2)}\n`);
43
+ }
44
+
45
+ function setTrustFields(
46
+ entry: Record<string, unknown>,
47
+ mode: TrustMode,
48
+ nowIso: string
49
+ ) {
50
+ if (mode === "trust") {
51
+ entry.trusted = true;
52
+ entry.trustedAt = nowIso;
53
+ entry.trustedBy = "user";
54
+ } else {
55
+ entry.trusted = false;
56
+ entry.trustedAt = undefined;
57
+ entry.trustedBy = undefined;
58
+ }
59
+
60
+ // Ensure auditStatus exists for downstream filtering.
61
+ const rawStatus = entry.auditStatus;
62
+ if (typeof rawStatus !== "string" || rawStatus.trim() === "") {
63
+ entry.auditStatus = "pending";
64
+ }
65
+ }
66
+
67
+ export async function applyTrust({
68
+ names,
69
+ mode,
70
+ homeDir,
71
+ rootDir,
72
+ }: {
73
+ names: string[];
74
+ mode: TrustMode;
75
+ homeDir?: string;
76
+ rootDir?: string;
77
+ }) {
78
+ if (!names.length) {
79
+ throw new Error("At least one name is required.");
80
+ }
81
+ const home = homeDir ?? homedir();
82
+ const root = rootDir ?? facultRootDir(home);
83
+
84
+ const index = ensureIndexStructure(await loadIndex(root));
85
+ const now = new Date().toISOString();
86
+
87
+ const missing: string[] = [];
88
+ for (const raw of names) {
89
+ const { kind, name } = parseEntryName(raw);
90
+ if (kind === "skill") {
91
+ const entry = index.skills[name] as unknown;
92
+ if (!(entry && isPlainObject(entry))) {
93
+ missing.push(raw);
94
+ continue;
95
+ }
96
+ setTrustFields(entry, mode, now);
97
+ } else {
98
+ const entry = index.mcp?.servers?.[name] as unknown;
99
+ if (!(entry && isPlainObject(entry))) {
100
+ missing.push(raw);
101
+ continue;
102
+ }
103
+ setTrustFields(entry, mode, now);
104
+ }
105
+ }
106
+
107
+ if (missing.length) {
108
+ throw new Error(`Entries not found: ${missing.join(", ")}`);
109
+ }
110
+
111
+ index.updatedAt = new Date().toISOString();
112
+ await writeIndex(root, index);
113
+ }
114
+
115
+ function parseNamesFromArgv(argv: string[]): string[] {
116
+ const names: string[] = [];
117
+ for (const arg of argv) {
118
+ if (!arg) {
119
+ continue;
120
+ }
121
+ if (arg.startsWith("-")) {
122
+ throw new Error(`Unknown option: ${arg}`);
123
+ }
124
+ names.push(arg);
125
+ }
126
+ return names;
127
+ }
128
+
129
+ export async function trustCommand(argv: string[]) {
130
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
131
+ console.log(`facult trust — mark skills or MCP servers as trusted (annotation only)
132
+
133
+ Usage:
134
+ facult trust <name> [moreNames...]
135
+ facult trust mcp:<name> [moreNames...]
136
+ `);
137
+ return;
138
+ }
139
+ try {
140
+ const names = parseNamesFromArgv(argv);
141
+ await applyTrust({ names, mode: "trust" });
142
+ console.log(`Marked as trusted: ${names.join(", ")}`);
143
+ console.log(
144
+ 'Note: Trust is an annotation. Run "facult audit" for security review.'
145
+ );
146
+ } catch (err) {
147
+ console.error(err instanceof Error ? err.message : String(err));
148
+ process.exitCode = 1;
149
+ }
150
+ }
151
+
152
+ export async function untrustCommand(argv: string[]) {
153
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
154
+ console.log(`facult untrust — remove trusted annotation
155
+
156
+ Usage:
157
+ facult untrust <name> [moreNames...]
158
+ facult untrust mcp:<name> [moreNames...]
159
+ `);
160
+ return;
161
+ }
162
+ try {
163
+ const names = parseNamesFromArgv(argv);
164
+ await applyTrust({ names, mode: "untrust" });
165
+ console.log(`Marked as untrusted: ${names.join(", ")}`);
166
+ } catch (err) {
167
+ console.error(err instanceof Error ? err.message : String(err));
168
+ process.exitCode = 1;
169
+ }
170
+ }
package/src/tui.ts ADDED
@@ -0,0 +1,118 @@
1
+ import {
2
+ BoxRenderable,
3
+ createCliRenderer,
4
+ type KeyEvent,
5
+ type SelectOption,
6
+ SelectRenderable,
7
+ TextRenderable,
8
+ } from "@opentui/core";
9
+ import type { ScanResult } from "./scan";
10
+ import { computeSkillOccurrences } from "./util/skills";
11
+
12
+ export async function runSkillsTui(res: ScanResult): Promise<void> {
13
+ const renderer = await createCliRenderer({
14
+ // Keep default exit-on-ctrl+c behavior.
15
+ exitOnCtrlC: true,
16
+ });
17
+
18
+ renderer.setBackgroundColor("#001122");
19
+
20
+ const width = renderer.width;
21
+ const height = renderer.height;
22
+
23
+ const container = new BoxRenderable(renderer, {
24
+ id: "container",
25
+ position: "absolute",
26
+ left: 0,
27
+ top: 0,
28
+ width,
29
+ height,
30
+ borderStyle: "double",
31
+ borderColor: "#4CC9F0",
32
+ title: "facult scan — skills",
33
+ titleAlignment: "center",
34
+ backgroundColor: "#001122",
35
+ });
36
+
37
+ const hint = new TextRenderable(renderer, {
38
+ id: "hint",
39
+ position: "absolute",
40
+ left: 2,
41
+ top: 1,
42
+ width: Math.max(10, width - 4),
43
+ height: 1,
44
+ fg: "#E0FBFC",
45
+ content:
46
+ "↑/↓ or j/k to scroll • Enter to copy name • d = duplicates-only • a = all • q / Esc to quit",
47
+ });
48
+
49
+ const occurrencesAll = computeSkillOccurrences(res);
50
+
51
+ function toOptions(occ: typeof occurrencesAll): SelectOption[] {
52
+ return occ.map((o) => ({
53
+ name: `${o.count > 1 ? "[dup] " : ""}${o.name} (${o.count})`,
54
+ description: o.locations.join(" · "),
55
+ value: o.name,
56
+ }));
57
+ }
58
+
59
+ const options: SelectOption[] = toOptions(occurrencesAll);
60
+
61
+ const select = new SelectRenderable(renderer, {
62
+ id: "skills",
63
+ position: "absolute",
64
+ left: 1,
65
+ top: 3,
66
+ width: Math.max(10, width - 2),
67
+ height: Math.max(5, height - 4),
68
+ options,
69
+ showDescription: true,
70
+ showScrollIndicator: true,
71
+ wrapSelection: true,
72
+ });
73
+
74
+ // Focus so it receives key input.
75
+ select.focus();
76
+
77
+ // Key handling: quit + enter to copy.
78
+ renderer.keyInput.on("keypress", async (key: KeyEvent) => {
79
+ if (key.name === "escape" || key.name === "q") {
80
+ renderer.destroy();
81
+ return;
82
+ }
83
+
84
+ if (key.name === "d") {
85
+ const next = occurrencesAll.filter((o) => o.count > 1);
86
+ select.options = toOptions(next);
87
+ return;
88
+ }
89
+
90
+ if (key.name === "a") {
91
+ select.options = toOptions(occurrencesAll);
92
+ return;
93
+ }
94
+
95
+ if (key.name === "return" || key.name === "enter") {
96
+ const opt = select.getSelectedOption();
97
+ const value = typeof opt?.value === "string" ? opt.value : "";
98
+ if (value) {
99
+ // Best-effort clipboard support (macOS pbcopy).
100
+ try {
101
+ await Bun.$`printf %s ${value} | pbcopy`.quiet();
102
+ } catch {
103
+ // ignore
104
+ }
105
+ }
106
+ }
107
+ });
108
+
109
+ renderer.root.add(container);
110
+ renderer.root.add(hint);
111
+ renderer.root.add(select);
112
+
113
+ // Keep it live for smoother scrolling.
114
+ renderer.start();
115
+
116
+ // Wait until renderer destroys (q/esc/ctrl+c).
117
+ await renderer.idle();
118
+ }