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/remote.ts ADDED
@@ -0,0 +1,1970 @@
1
+ import { mkdir, readFile, rm } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { buildIndex } from "./index-builder";
5
+ import { facultRootDir } from "./paths";
6
+ import {
7
+ assertManifestIntegrity,
8
+ assertManifestSignature,
9
+ } from "./remote-manifest-integrity";
10
+ import { loadProviderManifest } from "./remote-providers";
11
+ import {
12
+ assertSourceAllowed,
13
+ evaluateSourceTrust,
14
+ sourcesCommand as runSourcesCommand,
15
+ } from "./remote-source-policy";
16
+ import { readIndexSources, resolveKnownIndexSource } from "./remote-sources";
17
+ import {
18
+ BUILTIN_INDEX_NAME,
19
+ BUILTIN_INDEX_URL,
20
+ CLAWHUB_INDEX_NAME,
21
+ GLAMA_INDEX_NAME,
22
+ type IndexSource,
23
+ type LoadManifestHints,
24
+ type RemoteAgentItem,
25
+ type RemoteIndexItem,
26
+ type RemoteIndexManifest,
27
+ type RemoteItemType,
28
+ type RemoteMcpItem,
29
+ type RemoteSkillItem,
30
+ type RemoteSnippetItem,
31
+ SKILLS_SH_INDEX_NAME,
32
+ SMITHERY_INDEX_NAME,
33
+ } from "./remote-types";
34
+ import { validateSnippetMarkerName } from "./snippets";
35
+ import { loadSourceTrustState, type SourceTrustLevel } from "./source-trust";
36
+ import { parseJsonLenient } from "./util/json";
37
+
38
+ const REMOTE_STATE_VERSION = 1;
39
+ const VERSION_TOKEN_RE = /[A-Za-z]+|[0-9]+/g;
40
+ const QUERY_SPLIT_RE = /\s+/;
41
+ const MD_EXT_RE = /\.md$/i;
42
+
43
+ interface InstalledRemoteItem {
44
+ ref: string;
45
+ index: string;
46
+ itemId: string;
47
+ type: RemoteItemType;
48
+ installedAs: string;
49
+ path: string;
50
+ version?: string;
51
+ sourceUrl?: string;
52
+ sourceTrustLevel?: SourceTrustLevel;
53
+ installedAt: string;
54
+ }
55
+
56
+ interface InstalledRemoteState {
57
+ version: number;
58
+ updatedAt: string;
59
+ items: InstalledRemoteItem[];
60
+ }
61
+
62
+ interface RemoteCommandContext {
63
+ homeDir?: string;
64
+ rootDir?: string;
65
+ cwd?: string;
66
+ now?: () => Date;
67
+ fetchJson?: (url: string) => Promise<unknown>;
68
+ fetchText?: (url: string) => Promise<string>;
69
+ strictSourceTrust?: boolean;
70
+ }
71
+
72
+ interface SearchResult {
73
+ index: string;
74
+ item: RemoteIndexItem;
75
+ score: number;
76
+ }
77
+
78
+ interface InstallResult {
79
+ ref: string;
80
+ type: RemoteItemType;
81
+ installedAs: string;
82
+ path: string;
83
+ sourceTrustLevel: SourceTrustLevel;
84
+ dryRun: boolean;
85
+ changedPaths: string[];
86
+ }
87
+
88
+ interface UpdateCheckResult {
89
+ installed: InstalledRemoteItem;
90
+ latestVersion?: string;
91
+ currentVersion?: string;
92
+ status:
93
+ | "up-to-date"
94
+ | "outdated"
95
+ | "missing-index"
96
+ | "missing-item"
97
+ | "blocked-source"
98
+ | "review-source";
99
+ }
100
+
101
+ interface UpdateReport {
102
+ checkedAt: string;
103
+ checks: UpdateCheckResult[];
104
+ applied: InstallResult[];
105
+ }
106
+
107
+ type VerifyCheckStatus =
108
+ | "passed"
109
+ | "failed"
110
+ | "not-configured"
111
+ | "not-applicable";
112
+
113
+ interface VerifySourceReport {
114
+ checkedAt: string;
115
+ source: {
116
+ name: string;
117
+ url: string;
118
+ kind: IndexSource["kind"];
119
+ };
120
+ trust: {
121
+ level: SourceTrustLevel;
122
+ explicit: boolean;
123
+ note?: string;
124
+ updatedAt?: string;
125
+ };
126
+ checks: {
127
+ fetch: VerifyCheckStatus;
128
+ parse: VerifyCheckStatus;
129
+ integrity: VerifyCheckStatus;
130
+ signature: VerifyCheckStatus;
131
+ items: number;
132
+ };
133
+ error?: string;
134
+ }
135
+
136
+ const BUILTIN_MANIFEST: RemoteIndexManifest = {
137
+ name: BUILTIN_INDEX_NAME,
138
+ url: BUILTIN_INDEX_URL,
139
+ updatedAt: "2026-02-21T00:00:00.000Z",
140
+ items: [
141
+ {
142
+ id: "skill-template",
143
+ type: "skill",
144
+ title: "Skill Template",
145
+ description:
146
+ "Production-ready SKILL.md scaffold with clear trigger, workflow, and output sections.",
147
+ version: "1.0.0",
148
+ tags: ["template", "dx", "skill"],
149
+ skill: {
150
+ name: "my-skill",
151
+ files: {
152
+ "SKILL.md": `---
153
+ description: "{{name}} workflow skill"
154
+ tags: [template, workflow]
155
+ ---
156
+
157
+ # {{name}}
158
+
159
+ ## When To Use
160
+ Use this skill when the task repeatedly follows a known workflow and you want consistent, reviewable outputs.
161
+
162
+ ## Inputs
163
+ - Goal and expected outcome.
164
+ - Constraints (time, tooling, compatibility).
165
+ - Required artifacts (files, commands, links).
166
+
167
+ ## Steps
168
+ 1. Confirm scope and assumptions in one short summary.
169
+ 2. Gather only the context needed to complete the task.
170
+ 3. Execute the workflow incrementally and validate after each major change.
171
+ 4. Report results with concrete file/command references and remaining risks.
172
+
173
+ ## Output Contract
174
+ - Include what changed and why.
175
+ - Include validation evidence (tests/checks run).
176
+ - Include clear next steps when follow-up work exists.
177
+ `,
178
+ },
179
+ },
180
+ },
181
+ {
182
+ id: "mcp-stdio-template",
183
+ type: "mcp",
184
+ title: "MCP Stdio Template",
185
+ description:
186
+ "Safe starting MCP server entry with explicit command/args/env placeholders.",
187
+ version: "1.0.0",
188
+ tags: ["template", "dx", "mcp"],
189
+ mcp: {
190
+ name: "example-server",
191
+ definition: {
192
+ command: "node",
193
+ args: ["./servers/{{name}}/index.js"],
194
+ env: {
195
+ API_KEY: "<set-me>",
196
+ },
197
+ enabledFor: [],
198
+ },
199
+ },
200
+ },
201
+ {
202
+ id: "agents-md-template",
203
+ type: "agent",
204
+ title: "AGENTS.md Template",
205
+ description:
206
+ "Project-wide agent instruction template optimized for clarity, quality gates, and DX.",
207
+ version: "1.0.0",
208
+ tags: ["template", "dx", "instructions"],
209
+ agent: {
210
+ fileName: "AGENTS.md",
211
+ content: `# Project Agent Instructions
212
+
213
+ ## Mission
214
+ Ship reliable changes quickly while keeping behavior predictable.
215
+
216
+ ## Working Rules
217
+ - Prefer small, reviewable diffs.
218
+ - Preserve existing style and architecture unless a refactor is explicitly requested.
219
+ - Validate behavior with tests/checks after meaningful changes.
220
+ - Avoid destructive actions unless explicitly approved.
221
+
222
+ ## Engineering Quality
223
+ - Keep implementations simple and observable.
224
+ - Fail with actionable error messages.
225
+ - Prioritize backwards compatibility and data safety.
226
+
227
+ ## Delivery Format
228
+ - Summarize what changed.
229
+ - Include file and command references.
230
+ - Call out open risks and next steps.
231
+ `,
232
+ },
233
+ },
234
+ {
235
+ id: "claude-md-template",
236
+ type: "agent",
237
+ title: "CLAUDE.md Template",
238
+ description:
239
+ "Agent-specific instruction template for consistent collaboration and output quality.",
240
+ version: "1.0.0",
241
+ tags: ["template", "dx", "instructions"],
242
+ agent: {
243
+ fileName: "CLAUDE.md",
244
+ content: `# Claude Working Contract
245
+
246
+ ## Default Mode
247
+ - Be concise, factual, and implementation-first.
248
+ - Prefer executable steps over abstract advice.
249
+
250
+ ## Safety + Correctness
251
+ - Verify assumptions in code or tests before claiming completion.
252
+ - Surface uncertainties explicitly.
253
+ - Never leak secrets or include sensitive raw values in logs/output.
254
+
255
+ ## Code Expectations
256
+ - Write readable code with clear intent.
257
+ - Add tests for behavior changes.
258
+ - Keep command usage reproducible.
259
+
260
+ ## Response Expectations
261
+ - Lead with outcome.
262
+ - Include concrete references to files and validation.
263
+ - End with the smallest useful next-step list.
264
+ `,
265
+ },
266
+ },
267
+ {
268
+ id: "snippet-template",
269
+ type: "snippet",
270
+ title: "Snippet Template",
271
+ description:
272
+ "Reusable snippet block template for coding standards and communication style.",
273
+ version: "1.0.0",
274
+ tags: ["template", "dx", "snippet"],
275
+ snippet: {
276
+ marker: "team/codingstyle",
277
+ content: `## Coding Style
278
+ - Prefer explicit, descriptive names over abbreviations.
279
+ - Keep functions focused and side-effect boundaries obvious.
280
+ - Add tests when behavior changes.
281
+
282
+ ## Review Checklist
283
+ - Is behavior correct for edge cases?
284
+ - Are failure modes clear and actionable?
285
+ - Is the change minimal for the goal?
286
+ `,
287
+ },
288
+ },
289
+ ],
290
+ };
291
+
292
+ function isSafePathString(p: string): boolean {
293
+ return !p.includes("\0");
294
+ }
295
+
296
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
297
+ return !!v && typeof v === "object" && !Array.isArray(v);
298
+ }
299
+
300
+ function uniqueSorted(values: string[]): string[] {
301
+ return Array.from(new Set(values)).sort();
302
+ }
303
+
304
+ function nowIso(now?: () => Date): string {
305
+ return (now ? now() : new Date()).toISOString();
306
+ }
307
+
308
+ function parseSourceTrustLevel(raw: unknown): SourceTrustLevel | undefined {
309
+ if (raw === "trusted" || raw === "review" || raw === "blocked") {
310
+ return raw;
311
+ }
312
+ return undefined;
313
+ }
314
+
315
+ function renderTemplate(text: string, values: Record<string, string>): string {
316
+ let out = text;
317
+ for (const [k, v] of Object.entries(values)) {
318
+ out = out.replaceAll(`{{${k}}}`, v);
319
+ }
320
+ return out;
321
+ }
322
+
323
+ function compareVersions(a: string, b: string): number {
324
+ const aTokens = (a.match(VERSION_TOKEN_RE) ?? []).map((t) => t.toLowerCase());
325
+ const bTokens = (b.match(VERSION_TOKEN_RE) ?? []).map((t) => t.toLowerCase());
326
+ const n = Math.max(aTokens.length, bTokens.length);
327
+ for (let i = 0; i < n; i += 1) {
328
+ const av = aTokens[i];
329
+ const bv = bTokens[i];
330
+ if (av === undefined && bv === undefined) {
331
+ return 0;
332
+ }
333
+ if (av === undefined) {
334
+ return -1;
335
+ }
336
+ if (bv === undefined) {
337
+ return 1;
338
+ }
339
+
340
+ const an = Number(av);
341
+ const bn = Number(bv);
342
+ const aIsNum = Number.isFinite(an) && `${an}` === av;
343
+ const bIsNum = Number.isFinite(bn) && `${bn}` === bv;
344
+ if (aIsNum && bIsNum) {
345
+ if (an < bn) {
346
+ return -1;
347
+ }
348
+ if (an > bn) {
349
+ return 1;
350
+ }
351
+ continue;
352
+ }
353
+
354
+ const cmp = av.localeCompare(bv);
355
+ if (cmp !== 0) {
356
+ return cmp;
357
+ }
358
+ }
359
+ return 0;
360
+ }
361
+
362
+ function isSafeRelativePath(relPath: string): boolean {
363
+ if (!relPath || isAbsolute(relPath) || !isSafePathString(relPath)) {
364
+ return false;
365
+ }
366
+ const normalized = relPath.replaceAll("\\", "/");
367
+ const parts = normalized.split("/").filter(Boolean);
368
+ if (!parts.length) {
369
+ return false;
370
+ }
371
+ if (parts.includes(".") || parts.includes("..")) {
372
+ return false;
373
+ }
374
+ return true;
375
+ }
376
+
377
+ function isSubpath(parent: string, child: string): boolean {
378
+ const rel = relative(resolve(parent), resolve(child));
379
+ return rel === "" || !(rel.startsWith("..") || isAbsolute(rel));
380
+ }
381
+
382
+ function parseRef(ref: string): { index: string; itemId: string } | null {
383
+ const i = ref.indexOf(":");
384
+ if (i <= 0 || i >= ref.length - 1) {
385
+ return null;
386
+ }
387
+ return {
388
+ index: ref.slice(0, i).trim(),
389
+ itemId: ref.slice(i + 1).trim(),
390
+ };
391
+ }
392
+
393
+ async function fileExists(path: string): Promise<boolean> {
394
+ try {
395
+ await Bun.file(path).stat();
396
+ return true;
397
+ } catch {
398
+ return false;
399
+ }
400
+ }
401
+
402
+ async function defaultFetchJson(url: string, cwd: string): Promise<unknown> {
403
+ if (url.startsWith("http://") || url.startsWith("https://")) {
404
+ const res = await fetch(url);
405
+ if (!res.ok) {
406
+ throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
407
+ }
408
+ return (await res.json()) as unknown;
409
+ }
410
+
411
+ let path = url;
412
+ if (url.startsWith("file://")) {
413
+ const parsed = new URL(url);
414
+ path = decodeURIComponent(parsed.pathname);
415
+ } else if (!isAbsolute(url)) {
416
+ path = resolve(cwd, url);
417
+ }
418
+
419
+ const raw = await readFile(path, "utf8");
420
+ return parseJsonLenient(raw);
421
+ }
422
+
423
+ async function defaultFetchText(url: string, cwd: string): Promise<string> {
424
+ if (url.startsWith("http://") || url.startsWith("https://")) {
425
+ const res = await fetch(url);
426
+ if (!res.ok) {
427
+ throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
428
+ }
429
+ return await res.text();
430
+ }
431
+
432
+ let path = url;
433
+ if (url.startsWith("file://")) {
434
+ const parsed = new URL(url);
435
+ path = decodeURIComponent(parsed.pathname);
436
+ } else if (!isAbsolute(url)) {
437
+ path = resolve(cwd, url);
438
+ }
439
+
440
+ return await readFile(path, "utf8");
441
+ }
442
+
443
+ function parseIndexItem(raw: unknown): RemoteIndexItem | null {
444
+ if (!isPlainObject(raw)) {
445
+ return null;
446
+ }
447
+ const obj = raw as Record<string, unknown>;
448
+ const id = typeof obj.id === "string" ? obj.id.trim() : "";
449
+ const type = typeof obj.type === "string" ? obj.type.trim() : "";
450
+ if (!id) {
451
+ return null;
452
+ }
453
+ if (
454
+ type !== "skill" &&
455
+ type !== "mcp" &&
456
+ type !== "agent" &&
457
+ type !== "snippet"
458
+ ) {
459
+ return null;
460
+ }
461
+ const title = typeof obj.title === "string" ? obj.title : undefined;
462
+ const description =
463
+ typeof obj.description === "string" ? obj.description : undefined;
464
+ const version = typeof obj.version === "string" ? obj.version : undefined;
465
+ const sourceUrl =
466
+ typeof obj.sourceUrl === "string" ? obj.sourceUrl : undefined;
467
+ const tags = Array.isArray(obj.tags)
468
+ ? uniqueSorted(
469
+ obj.tags
470
+ .filter((v) => typeof v === "string")
471
+ .map((v) => v.trim())
472
+ .filter(Boolean)
473
+ )
474
+ : undefined;
475
+
476
+ if (type === "skill") {
477
+ const skillRaw = obj.skill;
478
+ if (!isPlainObject(skillRaw)) {
479
+ return null;
480
+ }
481
+ const name =
482
+ typeof skillRaw.name === "string" ? skillRaw.name.trim() : "new-skill";
483
+ const filesRaw = skillRaw.files;
484
+ if (!isPlainObject(filesRaw)) {
485
+ return null;
486
+ }
487
+ const files: Record<string, string> = {};
488
+ for (const [k, v] of Object.entries(filesRaw)) {
489
+ if (!isSafeRelativePath(k) || typeof v !== "string") {
490
+ continue;
491
+ }
492
+ files[k] = v;
493
+ }
494
+ if (!Object.keys(files).length) {
495
+ files["SKILL.md"] = "# {{name}}\n";
496
+ }
497
+ return {
498
+ id,
499
+ type,
500
+ title,
501
+ description,
502
+ version,
503
+ sourceUrl,
504
+ tags,
505
+ skill: { name, files },
506
+ };
507
+ }
508
+
509
+ if (type === "mcp") {
510
+ const mcpRaw = obj.mcp;
511
+ if (!isPlainObject(mcpRaw)) {
512
+ return null;
513
+ }
514
+ const name =
515
+ typeof mcpRaw.name === "string" ? mcpRaw.name.trim() : "example-server";
516
+ const defRaw = mcpRaw.definition;
517
+ if (!isPlainObject(defRaw)) {
518
+ return null;
519
+ }
520
+ return {
521
+ id,
522
+ type,
523
+ title,
524
+ description,
525
+ version,
526
+ sourceUrl,
527
+ tags,
528
+ mcp: { name, definition: defRaw },
529
+ };
530
+ }
531
+
532
+ if (type === "agent") {
533
+ const agentRaw = obj.agent;
534
+ if (!isPlainObject(agentRaw)) {
535
+ return null;
536
+ }
537
+ const fileName =
538
+ typeof agentRaw.fileName === "string" ? agentRaw.fileName.trim() : "";
539
+ const content =
540
+ typeof agentRaw.content === "string" ? agentRaw.content : "";
541
+ if (!(fileName && content)) {
542
+ return null;
543
+ }
544
+ return {
545
+ id,
546
+ type,
547
+ title,
548
+ description,
549
+ version,
550
+ sourceUrl,
551
+ tags,
552
+ agent: { fileName, content },
553
+ };
554
+ }
555
+
556
+ const snippetRaw = obj.snippet;
557
+ if (!isPlainObject(snippetRaw)) {
558
+ return null;
559
+ }
560
+ const marker =
561
+ typeof snippetRaw.marker === "string" ? snippetRaw.marker.trim() : "";
562
+ const content =
563
+ typeof snippetRaw.content === "string" ? snippetRaw.content : "";
564
+ if (!(marker && content)) {
565
+ return null;
566
+ }
567
+ return {
568
+ id,
569
+ type,
570
+ title,
571
+ description,
572
+ version,
573
+ sourceUrl,
574
+ tags,
575
+ snippet: { marker, content },
576
+ };
577
+ }
578
+
579
+ function parseManifest(source: IndexSource, raw: unknown): RemoteIndexManifest {
580
+ const base: RemoteIndexManifest = {
581
+ name: source.name,
582
+ url: source.url,
583
+ items: [],
584
+ };
585
+
586
+ if (Array.isArray(raw)) {
587
+ base.items = raw
588
+ .map(parseIndexItem)
589
+ .filter((v): v is RemoteIndexItem => !!v);
590
+ return base;
591
+ }
592
+
593
+ if (!isPlainObject(raw)) {
594
+ return base;
595
+ }
596
+
597
+ const obj = raw as Record<string, unknown>;
598
+ const updatedAt =
599
+ typeof obj.updatedAt === "string" ? obj.updatedAt : undefined;
600
+ const itemsRaw = Array.isArray(obj.items) ? obj.items : [];
601
+ return {
602
+ ...base,
603
+ updatedAt,
604
+ items: itemsRaw
605
+ .map(parseIndexItem)
606
+ .filter((v): v is RemoteIndexItem => !!v),
607
+ };
608
+ }
609
+
610
+ async function loadManifest(
611
+ source: IndexSource,
612
+ ctx: Required<Pick<RemoteCommandContext, "cwd">> & {
613
+ homeDir: string;
614
+ fetchJson: (url: string) => Promise<unknown>;
615
+ fetchText: (url: string) => Promise<string>;
616
+ },
617
+ hints: LoadManifestHints = {}
618
+ ): Promise<RemoteIndexManifest> {
619
+ if (source.kind === "builtin") {
620
+ return BUILTIN_MANIFEST;
621
+ }
622
+ if (source.kind !== "manifest") {
623
+ return await loadProviderManifest({
624
+ source,
625
+ fetchJson: ctx.fetchJson,
626
+ fetchText: ctx.fetchText,
627
+ hints,
628
+ });
629
+ }
630
+ const rawText = await ctx.fetchText(source.url);
631
+ if (source.integrity) {
632
+ assertManifestIntegrity({
633
+ sourceName: source.name,
634
+ sourceUrl: source.url,
635
+ integrity: source.integrity,
636
+ manifestText: rawText,
637
+ });
638
+ }
639
+ if (source.signature) {
640
+ await assertManifestSignature({
641
+ sourceName: source.name,
642
+ sourceUrl: source.url,
643
+ signature: source.signature,
644
+ signatureKeys: source.signatureKeys,
645
+ manifestText: rawText,
646
+ cwd: ctx.cwd,
647
+ homeDir: ctx.homeDir,
648
+ });
649
+ }
650
+ const raw = parseJsonLenient(rawText);
651
+ return parseManifest(source, raw);
652
+ }
653
+
654
+ function matchScore(item: RemoteIndexItem, query: string): number {
655
+ if (!query.trim()) {
656
+ return 1;
657
+ }
658
+ const haystack = [
659
+ item.id,
660
+ item.title ?? "",
661
+ item.description ?? "",
662
+ ...(item.tags ?? []),
663
+ ]
664
+ .join(" ")
665
+ .toLowerCase();
666
+
667
+ let score = 0;
668
+ for (const token of query
669
+ .toLowerCase()
670
+ .split(QUERY_SPLIT_RE)
671
+ .filter(Boolean)) {
672
+ if (haystack.includes(token)) {
673
+ score += 1;
674
+ }
675
+ }
676
+ return score;
677
+ }
678
+
679
+ async function loadInstalledState(
680
+ rootDir: string
681
+ ): Promise<InstalledRemoteState> {
682
+ const path = join(rootDir, "remote", "installed.json");
683
+ if (!(await fileExists(path))) {
684
+ return {
685
+ version: REMOTE_STATE_VERSION,
686
+ updatedAt: new Date(0).toISOString(),
687
+ items: [],
688
+ };
689
+ }
690
+ try {
691
+ const parsed = parseJsonLenient(await readFile(path, "utf8"));
692
+ if (!isPlainObject(parsed)) {
693
+ return {
694
+ version: REMOTE_STATE_VERSION,
695
+ updatedAt: new Date(0).toISOString(),
696
+ items: [],
697
+ };
698
+ }
699
+ const version =
700
+ typeof parsed.version === "number"
701
+ ? parsed.version
702
+ : REMOTE_STATE_VERSION;
703
+ const updatedAt =
704
+ typeof parsed.updatedAt === "string"
705
+ ? parsed.updatedAt
706
+ : new Date(0).toISOString();
707
+ const itemsRaw = Array.isArray(parsed.items) ? parsed.items : [];
708
+ const items: InstalledRemoteItem[] = [];
709
+ for (const raw of itemsRaw) {
710
+ if (!isPlainObject(raw)) {
711
+ continue;
712
+ }
713
+ const ref = typeof raw.ref === "string" ? raw.ref : "";
714
+ const index = typeof raw.index === "string" ? raw.index : "";
715
+ const itemId = typeof raw.itemId === "string" ? raw.itemId : "";
716
+ const type = typeof raw.type === "string" ? raw.type : "";
717
+ const installedAs =
718
+ typeof raw.installedAs === "string" ? raw.installedAs : "";
719
+ const pathValue = typeof raw.path === "string" ? raw.path : "";
720
+ if (!(ref && index && itemId && installedAs && pathValue)) {
721
+ continue;
722
+ }
723
+ if (
724
+ type !== "skill" &&
725
+ type !== "mcp" &&
726
+ type !== "agent" &&
727
+ type !== "snippet"
728
+ ) {
729
+ continue;
730
+ }
731
+ items.push({
732
+ ref,
733
+ index,
734
+ itemId,
735
+ type,
736
+ installedAs,
737
+ path: pathValue,
738
+ version: typeof raw.version === "string" ? raw.version : undefined,
739
+ sourceUrl:
740
+ typeof raw.sourceUrl === "string" ? raw.sourceUrl : undefined,
741
+ sourceTrustLevel: parseSourceTrustLevel(raw.sourceTrustLevel),
742
+ installedAt:
743
+ typeof raw.installedAt === "string"
744
+ ? raw.installedAt
745
+ : new Date(0).toISOString(),
746
+ });
747
+ }
748
+ return { version, updatedAt, items };
749
+ } catch {
750
+ return {
751
+ version: REMOTE_STATE_VERSION,
752
+ updatedAt: new Date(0).toISOString(),
753
+ items: [],
754
+ };
755
+ }
756
+ }
757
+
758
+ async function saveInstalledState(
759
+ rootDir: string,
760
+ state: InstalledRemoteState
761
+ ): Promise<void> {
762
+ const path = join(rootDir, "remote", "installed.json");
763
+ await mkdir(dirname(path), { recursive: true });
764
+ await Bun.write(path, `${JSON.stringify(state, null, 2)}\n`);
765
+ }
766
+
767
+ async function loadCanonicalMcpContainer(rootDir: string): Promise<{
768
+ path: string;
769
+ parsed: Record<string, unknown>;
770
+ getServers: () => Record<string, unknown>;
771
+ setServers: (servers: Record<string, unknown>) => void;
772
+ }> {
773
+ const serversPath = join(rootDir, "mcp", "servers.json");
774
+ const mcpPath = join(rootDir, "mcp", "mcp.json");
775
+
776
+ let path = serversPath;
777
+ if (await fileExists(serversPath)) {
778
+ path = serversPath;
779
+ } else if (await fileExists(mcpPath)) {
780
+ path = mcpPath;
781
+ }
782
+
783
+ let parsed: Record<string, unknown> = {};
784
+ if (await fileExists(path)) {
785
+ const raw = await readFile(path, "utf8");
786
+ const obj = parseJsonLenient(raw);
787
+ if (isPlainObject(obj)) {
788
+ parsed = { ...obj };
789
+ }
790
+ }
791
+
792
+ const getServers = () => {
793
+ if (isPlainObject(parsed.servers)) {
794
+ return parsed.servers as Record<string, unknown>;
795
+ }
796
+ if (isPlainObject(parsed.mcpServers)) {
797
+ return parsed.mcpServers as Record<string, unknown>;
798
+ }
799
+ if (
800
+ isPlainObject(parsed.mcp) &&
801
+ isPlainObject((parsed.mcp as Record<string, unknown>).servers)
802
+ ) {
803
+ return (parsed.mcp as Record<string, unknown>).servers as Record<
804
+ string,
805
+ unknown
806
+ >;
807
+ }
808
+ parsed.servers = {};
809
+ return parsed.servers as Record<string, unknown>;
810
+ };
811
+
812
+ const setServers = (servers: Record<string, unknown>) => {
813
+ if (isPlainObject(parsed.servers)) {
814
+ parsed.servers = servers;
815
+ return;
816
+ }
817
+ if (isPlainObject(parsed.mcpServers)) {
818
+ parsed.mcpServers = servers;
819
+ return;
820
+ }
821
+ if (
822
+ isPlainObject(parsed.mcp) &&
823
+ isPlainObject((parsed.mcp as Record<string, unknown>).servers)
824
+ ) {
825
+ (parsed.mcp as Record<string, unknown>).servers = servers;
826
+ return;
827
+ }
828
+ parsed.servers = servers;
829
+ };
830
+
831
+ return { path, parsed, getServers, setServers };
832
+ }
833
+
834
+ function snippetMarkerToPath(rootDir: string, marker: string): string {
835
+ const parts = marker.split("/").filter(Boolean);
836
+ if (parts[0] === "global" && parts.length >= 2) {
837
+ return join(
838
+ rootDir,
839
+ "snippets",
840
+ "global",
841
+ `${parts.slice(1).join("/")}.md`
842
+ );
843
+ }
844
+ if (parts.length >= 2) {
845
+ const project = parts[0] ?? "project";
846
+ const name = parts.slice(1).join("/");
847
+ return join(rootDir, "snippets", "projects", project, `${name}.md`);
848
+ }
849
+ return join(rootDir, "snippets", "global", `${marker}.md`);
850
+ }
851
+
852
+ function assertInstallPath(path: string, parent: string): void {
853
+ if (!(isSafePathString(path) && isSubpath(parent, path))) {
854
+ throw new Error(`Refusing unsafe install path: ${path}`);
855
+ }
856
+ }
857
+
858
+ async function installSkillItem(args: {
859
+ item: RemoteSkillItem;
860
+ installAs?: string;
861
+ rootDir: string;
862
+ force: boolean;
863
+ dryRun: boolean;
864
+ }): Promise<{ installedAs: string; path: string; changedPaths: string[] }> {
865
+ const installedAs = (args.installAs ?? args.item.skill.name).trim();
866
+ if (!installedAs) {
867
+ throw new Error("Skill install target cannot be empty.");
868
+ }
869
+ const skillDir = join(args.rootDir, "skills", installedAs);
870
+ assertInstallPath(skillDir, join(args.rootDir, "skills"));
871
+
872
+ if ((await fileExists(skillDir)) && !args.force) {
873
+ throw new Error(
874
+ `Skill already exists: ${installedAs} (use --force to overwrite)`
875
+ );
876
+ }
877
+
878
+ const changedPaths: string[] = [];
879
+ const files = Object.entries(args.item.skill.files);
880
+ if (files.length === 0) {
881
+ throw new Error(`Skill template ${args.item.id} has no files.`);
882
+ }
883
+
884
+ if (!args.dryRun) {
885
+ if (args.force && (await fileExists(skillDir))) {
886
+ await rm(skillDir, { recursive: true, force: true });
887
+ }
888
+ await mkdir(skillDir, { recursive: true });
889
+ }
890
+
891
+ for (const [relPath, rawContent] of files) {
892
+ if (!isSafeRelativePath(relPath)) {
893
+ throw new Error(`Unsafe skill template file path: ${relPath}`);
894
+ }
895
+ const outPath = join(skillDir, relPath);
896
+ assertInstallPath(outPath, skillDir);
897
+ const content = renderTemplate(rawContent, { name: installedAs });
898
+ changedPaths.push(outPath);
899
+ if (!args.dryRun) {
900
+ await mkdir(dirname(outPath), { recursive: true });
901
+ await Bun.write(outPath, content);
902
+ }
903
+ }
904
+
905
+ return { installedAs, path: skillDir, changedPaths };
906
+ }
907
+
908
+ async function installMcpItem(args: {
909
+ item: RemoteMcpItem;
910
+ installAs?: string;
911
+ rootDir: string;
912
+ force: boolean;
913
+ dryRun: boolean;
914
+ }): Promise<{ installedAs: string; path: string; changedPaths: string[] }> {
915
+ const installedAs = (args.installAs ?? args.item.mcp.name).trim();
916
+ if (!installedAs) {
917
+ throw new Error("MCP server name cannot be empty.");
918
+ }
919
+
920
+ const container = await loadCanonicalMcpContainer(args.rootDir);
921
+ const servers = { ...container.getServers() };
922
+ if (servers[installedAs] && !args.force) {
923
+ throw new Error(
924
+ `MCP server already exists: ${installedAs} (use --force to overwrite)`
925
+ );
926
+ }
927
+
928
+ const rendered = JSON.parse(
929
+ JSON.stringify(args.item.mcp.definition).replaceAll("{{name}}", installedAs)
930
+ ) as Record<string, unknown>;
931
+ servers[installedAs] = rendered;
932
+ container.setServers(servers);
933
+
934
+ if (!args.dryRun) {
935
+ await mkdir(dirname(container.path), { recursive: true });
936
+ await Bun.write(
937
+ container.path,
938
+ `${JSON.stringify(container.parsed, null, 2)}\n`
939
+ );
940
+ }
941
+
942
+ return {
943
+ installedAs,
944
+ path: container.path,
945
+ changedPaths: [container.path],
946
+ };
947
+ }
948
+
949
+ async function installAgentItem(args: {
950
+ item: RemoteAgentItem;
951
+ installAs?: string;
952
+ rootDir: string;
953
+ force: boolean;
954
+ dryRun: boolean;
955
+ }): Promise<{ installedAs: string; path: string; changedPaths: string[] }> {
956
+ const fileName = (args.installAs ?? args.item.agent.fileName).trim();
957
+ if (!fileName) {
958
+ throw new Error("Agent instruction file name cannot be empty.");
959
+ }
960
+ if (!isSafeRelativePath(fileName)) {
961
+ throw new Error(`Unsafe agent instruction file name: ${fileName}`);
962
+ }
963
+ const filePath = join(args.rootDir, "agents", fileName);
964
+ assertInstallPath(filePath, join(args.rootDir, "agents"));
965
+
966
+ if ((await fileExists(filePath)) && !args.force) {
967
+ throw new Error(
968
+ `Agent instruction already exists: ${fileName} (use --force to overwrite)`
969
+ );
970
+ }
971
+
972
+ if (!args.dryRun) {
973
+ await mkdir(dirname(filePath), { recursive: true });
974
+ await Bun.write(
975
+ filePath,
976
+ renderTemplate(args.item.agent.content, {
977
+ name: fileName.replace(MD_EXT_RE, ""),
978
+ })
979
+ );
980
+ }
981
+ return { installedAs: fileName, path: filePath, changedPaths: [filePath] };
982
+ }
983
+
984
+ async function installSnippetItem(args: {
985
+ item: RemoteSnippetItem;
986
+ installAs?: string;
987
+ rootDir: string;
988
+ force: boolean;
989
+ dryRun: boolean;
990
+ }): Promise<{ installedAs: string; path: string; changedPaths: string[] }> {
991
+ const marker = (args.installAs ?? args.item.snippet.marker).trim();
992
+ const markerErr = validateSnippetMarkerName(marker);
993
+ if (markerErr) {
994
+ throw new Error(`Invalid snippet marker "${marker}": ${markerErr}`);
995
+ }
996
+ const snippetPath = snippetMarkerToPath(args.rootDir, marker);
997
+ assertInstallPath(snippetPath, join(args.rootDir, "snippets"));
998
+ if ((await fileExists(snippetPath)) && !args.force) {
999
+ throw new Error(
1000
+ `Snippet already exists: ${marker} (use --force to overwrite)`
1001
+ );
1002
+ }
1003
+ if (!args.dryRun) {
1004
+ await mkdir(dirname(snippetPath), { recursive: true });
1005
+ await Bun.write(
1006
+ snippetPath,
1007
+ renderTemplate(args.item.snippet.content, { name: marker })
1008
+ );
1009
+ }
1010
+ return {
1011
+ installedAs: marker,
1012
+ path: snippetPath,
1013
+ changedPaths: [snippetPath],
1014
+ };
1015
+ }
1016
+
1017
+ async function installParsedItem(args: {
1018
+ parsedRef: { index: string; itemId: string };
1019
+ item: RemoteIndexItem;
1020
+ sourceTrustLevel: SourceTrustLevel;
1021
+ installAs?: string;
1022
+ dryRun: boolean;
1023
+ force: boolean;
1024
+ homeDir: string;
1025
+ rootDir: string;
1026
+ now?: () => Date;
1027
+ }): Promise<InstallResult> {
1028
+ let writeResult: {
1029
+ installedAs: string;
1030
+ path: string;
1031
+ changedPaths: string[];
1032
+ } | null = null;
1033
+
1034
+ if (args.item.type === "skill") {
1035
+ writeResult = await installSkillItem({
1036
+ item: args.item,
1037
+ installAs: args.installAs,
1038
+ rootDir: args.rootDir,
1039
+ force: args.force,
1040
+ dryRun: args.dryRun,
1041
+ });
1042
+ } else if (args.item.type === "mcp") {
1043
+ writeResult = await installMcpItem({
1044
+ item: args.item,
1045
+ installAs: args.installAs,
1046
+ rootDir: args.rootDir,
1047
+ force: args.force,
1048
+ dryRun: args.dryRun,
1049
+ });
1050
+ } else if (args.item.type === "agent") {
1051
+ writeResult = await installAgentItem({
1052
+ item: args.item,
1053
+ installAs: args.installAs,
1054
+ rootDir: args.rootDir,
1055
+ force: args.force,
1056
+ dryRun: args.dryRun,
1057
+ });
1058
+ } else {
1059
+ writeResult = await installSnippetItem({
1060
+ item: args.item,
1061
+ installAs: args.installAs,
1062
+ rootDir: args.rootDir,
1063
+ force: args.force,
1064
+ dryRun: args.dryRun,
1065
+ });
1066
+ }
1067
+
1068
+ const result: InstallResult = {
1069
+ ref: `${args.parsedRef.index}:${args.item.id}`,
1070
+ type: args.item.type,
1071
+ installedAs: writeResult.installedAs,
1072
+ path: writeResult.path,
1073
+ sourceTrustLevel: args.sourceTrustLevel,
1074
+ dryRun: args.dryRun,
1075
+ changedPaths: writeResult.changedPaths,
1076
+ };
1077
+
1078
+ if (args.dryRun) {
1079
+ return result;
1080
+ }
1081
+
1082
+ const state = await loadInstalledState(args.rootDir);
1083
+ const next: InstalledRemoteItem = {
1084
+ ref: result.ref,
1085
+ index: args.parsedRef.index,
1086
+ itemId: args.item.id,
1087
+ type: args.item.type,
1088
+ installedAs: result.installedAs,
1089
+ path: result.path,
1090
+ version: args.item.version,
1091
+ sourceUrl: args.item.sourceUrl,
1092
+ sourceTrustLevel: args.sourceTrustLevel,
1093
+ installedAt: nowIso(args.now),
1094
+ };
1095
+ const dedup = state.items.filter(
1096
+ (existing) =>
1097
+ !(
1098
+ existing.ref === next.ref &&
1099
+ existing.installedAs === next.installedAs &&
1100
+ existing.type === next.type
1101
+ )
1102
+ );
1103
+ dedup.push(next);
1104
+ await saveInstalledState(args.rootDir, {
1105
+ version: REMOTE_STATE_VERSION,
1106
+ updatedAt: nowIso(args.now),
1107
+ items: dedup.sort((a, b) => a.ref.localeCompare(b.ref)),
1108
+ });
1109
+ await buildIndex({ rootDir: args.rootDir, force: false });
1110
+ return result;
1111
+ }
1112
+
1113
+ async function resolveIndexSourcesAndManifests(args: {
1114
+ homeDir: string;
1115
+ cwd: string;
1116
+ fetchJson: (url: string) => Promise<unknown>;
1117
+ fetchText: (url: string) => Promise<string>;
1118
+ onlyIndex?: string;
1119
+ hints?: LoadManifestHints;
1120
+ throwOnSourceError?: boolean;
1121
+ }): Promise<Map<string, RemoteIndexManifest>> {
1122
+ const sources = await readIndexSources(args.homeDir, args.cwd);
1123
+ if (
1124
+ args.onlyIndex &&
1125
+ !sources.some((source) => source.name === args.onlyIndex)
1126
+ ) {
1127
+ const known = resolveKnownIndexSource(args.onlyIndex);
1128
+ if (known) {
1129
+ sources.push(known);
1130
+ }
1131
+ }
1132
+ const filtered = args.onlyIndex
1133
+ ? sources.filter((source) => source.name === args.onlyIndex)
1134
+ : sources;
1135
+ const manifests = new Map<string, RemoteIndexManifest>();
1136
+ for (const source of filtered) {
1137
+ try {
1138
+ const manifest = await loadManifest(
1139
+ source,
1140
+ {
1141
+ homeDir: args.homeDir,
1142
+ cwd: args.cwd,
1143
+ fetchJson: args.fetchJson,
1144
+ fetchText: args.fetchText,
1145
+ },
1146
+ args.hints
1147
+ );
1148
+ manifests.set(source.name, manifest);
1149
+ } catch (err) {
1150
+ if (args.throwOnSourceError) {
1151
+ throw err;
1152
+ }
1153
+ }
1154
+ }
1155
+ return manifests;
1156
+ }
1157
+
1158
+ export async function searchRemoteItems(args: {
1159
+ query: string;
1160
+ limit?: number;
1161
+ index?: string;
1162
+ homeDir?: string;
1163
+ cwd?: string;
1164
+ fetchJson?: (url: string) => Promise<unknown>;
1165
+ fetchText?: (url: string) => Promise<string>;
1166
+ }): Promise<SearchResult[]> {
1167
+ const home = args.homeDir ?? homedir();
1168
+ const cwd = args.cwd ?? process.cwd();
1169
+ const fetchJson =
1170
+ args.fetchJson ?? (async (url: string) => await defaultFetchJson(url, cwd));
1171
+ const fetchText =
1172
+ args.fetchText ?? (async (url: string) => await defaultFetchText(url, cwd));
1173
+ const manifests = await resolveIndexSourcesAndManifests({
1174
+ homeDir: home,
1175
+ cwd,
1176
+ fetchJson,
1177
+ fetchText,
1178
+ onlyIndex: args.index,
1179
+ hints: { query: args.query },
1180
+ throwOnSourceError: Boolean(args.index),
1181
+ });
1182
+
1183
+ const rows: SearchResult[] = [];
1184
+ for (const [index, manifest] of manifests.entries()) {
1185
+ for (const item of manifest.items) {
1186
+ const score = matchScore(item, args.query);
1187
+ if (score <= 0) {
1188
+ continue;
1189
+ }
1190
+ rows.push({ index, item, score });
1191
+ }
1192
+ }
1193
+
1194
+ rows.sort((a, b) => {
1195
+ if (b.score !== a.score) {
1196
+ return b.score - a.score;
1197
+ }
1198
+ if (a.index !== b.index) {
1199
+ return a.index.localeCompare(b.index);
1200
+ }
1201
+ return a.item.id.localeCompare(b.item.id);
1202
+ });
1203
+ const limit = args.limit && args.limit > 0 ? args.limit : 50;
1204
+ return rows.slice(0, limit);
1205
+ }
1206
+
1207
+ export async function installRemoteItem(args: {
1208
+ ref: string;
1209
+ as?: string;
1210
+ dryRun?: boolean;
1211
+ force?: boolean;
1212
+ strictSourceTrust?: boolean;
1213
+ homeDir?: string;
1214
+ rootDir?: string;
1215
+ cwd?: string;
1216
+ now?: () => Date;
1217
+ fetchJson?: (url: string) => Promise<unknown>;
1218
+ fetchText?: (url: string) => Promise<string>;
1219
+ }): Promise<InstallResult> {
1220
+ const parsedRef = parseRef(args.ref);
1221
+ if (!parsedRef) {
1222
+ throw new Error(`Invalid ref "${args.ref}". Use <index>:<item>.`);
1223
+ }
1224
+ const home = args.homeDir ?? homedir();
1225
+ const root = args.rootDir ?? facultRootDir(home);
1226
+ const cwd = args.cwd ?? process.cwd();
1227
+ const strictSourceTrust = Boolean(args.strictSourceTrust);
1228
+ const fetchJson =
1229
+ args.fetchJson ?? (async (url: string) => await defaultFetchJson(url, cwd));
1230
+ const fetchText =
1231
+ args.fetchText ?? (async (url: string) => await defaultFetchText(url, cwd));
1232
+ const manifests = await resolveIndexSourcesAndManifests({
1233
+ homeDir: home,
1234
+ cwd,
1235
+ fetchJson,
1236
+ fetchText,
1237
+ onlyIndex: parsedRef.index,
1238
+ hints: { itemId: parsedRef.itemId },
1239
+ throwOnSourceError: true,
1240
+ });
1241
+ const manifest = manifests.get(parsedRef.index);
1242
+ if (!manifest) {
1243
+ throw new Error(`Index not found: ${parsedRef.index}`);
1244
+ }
1245
+ const item = manifest.items.find(
1246
+ (candidate) => candidate.id === parsedRef.itemId
1247
+ );
1248
+ if (!item) {
1249
+ throw new Error(`Item not found: ${args.ref}`);
1250
+ }
1251
+ const trustState = await loadSourceTrustState({ homeDir: home });
1252
+ const sourceTrustLevel = assertSourceAllowed({
1253
+ sourceName: parsedRef.index,
1254
+ trustState,
1255
+ strictSourceTrust,
1256
+ });
1257
+ return await installParsedItem({
1258
+ parsedRef,
1259
+ item,
1260
+ sourceTrustLevel,
1261
+ installAs: args.as,
1262
+ dryRun: Boolean(args.dryRun),
1263
+ force: Boolean(args.force),
1264
+ homeDir: home,
1265
+ rootDir: root,
1266
+ now: args.now,
1267
+ });
1268
+ }
1269
+
1270
+ export async function checkRemoteUpdates(args?: {
1271
+ apply?: boolean;
1272
+ force?: boolean;
1273
+ strictSourceTrust?: boolean;
1274
+ homeDir?: string;
1275
+ rootDir?: string;
1276
+ cwd?: string;
1277
+ now?: () => Date;
1278
+ fetchJson?: (url: string) => Promise<unknown>;
1279
+ fetchText?: (url: string) => Promise<string>;
1280
+ }): Promise<UpdateReport> {
1281
+ const home = args?.homeDir ?? homedir();
1282
+ const root = args?.rootDir ?? facultRootDir(home);
1283
+ const cwd = args?.cwd ?? process.cwd();
1284
+ const fetchJson =
1285
+ args?.fetchJson ??
1286
+ (async (url: string) => await defaultFetchJson(url, cwd));
1287
+ const fetchText =
1288
+ args?.fetchText ??
1289
+ (async (url: string) => await defaultFetchText(url, cwd));
1290
+ const strictSourceTrust = Boolean(args?.strictSourceTrust);
1291
+ const sourceTrustState = await loadSourceTrustState({ homeDir: home });
1292
+
1293
+ const installed = await loadInstalledState(root);
1294
+ const checks: UpdateCheckResult[] = [];
1295
+ const applied: InstallResult[] = [];
1296
+ if (!installed.items.length) {
1297
+ return { checkedAt: nowIso(args?.now), checks, applied };
1298
+ }
1299
+
1300
+ const configuredSources = await readIndexSources(home, cwd);
1301
+ const sourceByName = new Map<string, IndexSource>();
1302
+ for (const source of configuredSources) {
1303
+ sourceByName.set(source.name, source);
1304
+ }
1305
+ for (const item of installed.items) {
1306
+ if (sourceByName.has(item.index)) {
1307
+ continue;
1308
+ }
1309
+ const known = resolveKnownIndexSource(item.index);
1310
+ if (known) {
1311
+ sourceByName.set(known.name, known);
1312
+ }
1313
+ }
1314
+ const manifestCache = new Map<string, RemoteIndexManifest>();
1315
+
1316
+ for (const entry of installed.items) {
1317
+ const trust = evaluateSourceTrust({
1318
+ sourceName: entry.index,
1319
+ trustState: sourceTrustState,
1320
+ });
1321
+ if (trust.level === "blocked") {
1322
+ checks.push({
1323
+ installed: entry,
1324
+ status: "blocked-source",
1325
+ });
1326
+ continue;
1327
+ }
1328
+ if (strictSourceTrust && trust.level === "review") {
1329
+ checks.push({
1330
+ installed: entry,
1331
+ status: "review-source",
1332
+ });
1333
+ continue;
1334
+ }
1335
+
1336
+ const source = sourceByName.get(entry.index);
1337
+ if (!source) {
1338
+ checks.push({ installed: entry, status: "missing-index" });
1339
+ continue;
1340
+ }
1341
+ const cacheKey = `${source.name}:${entry.itemId}`;
1342
+ let manifest = manifestCache.get(cacheKey);
1343
+ if (!manifest) {
1344
+ try {
1345
+ manifest = await loadManifest(
1346
+ source,
1347
+ { homeDir: home, cwd, fetchJson, fetchText },
1348
+ { itemId: entry.itemId }
1349
+ );
1350
+ } catch {
1351
+ checks.push({ installed: entry, status: "missing-index" });
1352
+ continue;
1353
+ }
1354
+ manifestCache.set(cacheKey, manifest);
1355
+ }
1356
+
1357
+ const item = manifest.items.find(
1358
+ (candidate) => candidate.id === entry.itemId
1359
+ );
1360
+ if (!item) {
1361
+ checks.push({ installed: entry, status: "missing-item" });
1362
+ continue;
1363
+ }
1364
+ const latestVersion = item.version;
1365
+ const currentVersion = entry.version;
1366
+ if (!(latestVersion && currentVersion)) {
1367
+ checks.push({
1368
+ installed: entry,
1369
+ status: "up-to-date",
1370
+ latestVersion,
1371
+ currentVersion,
1372
+ });
1373
+ continue;
1374
+ }
1375
+ const cmp = compareVersions(currentVersion, latestVersion);
1376
+ if (cmp < 0) {
1377
+ checks.push({
1378
+ installed: entry,
1379
+ status: "outdated",
1380
+ latestVersion,
1381
+ currentVersion,
1382
+ });
1383
+ if (args?.apply) {
1384
+ const next = await installRemoteItem({
1385
+ ref: entry.ref,
1386
+ as: entry.installedAs,
1387
+ dryRun: false,
1388
+ force: args.force ?? true,
1389
+ strictSourceTrust,
1390
+ homeDir: home,
1391
+ rootDir: root,
1392
+ cwd,
1393
+ now: args.now,
1394
+ fetchJson,
1395
+ fetchText,
1396
+ });
1397
+ applied.push(next);
1398
+ }
1399
+ continue;
1400
+ }
1401
+ checks.push({
1402
+ installed: entry,
1403
+ status: "up-to-date",
1404
+ latestVersion,
1405
+ currentVersion,
1406
+ });
1407
+ }
1408
+
1409
+ return { checkedAt: nowIso(args?.now), checks, applied };
1410
+ }
1411
+
1412
+ async function verifySource(args: {
1413
+ sourceName: string;
1414
+ homeDir?: string;
1415
+ cwd?: string;
1416
+ now?: () => Date;
1417
+ fetchJson?: (url: string) => Promise<unknown>;
1418
+ fetchText?: (url: string) => Promise<string>;
1419
+ }): Promise<VerifySourceReport> {
1420
+ const home = args.homeDir ?? homedir();
1421
+ const cwd = args.cwd ?? process.cwd();
1422
+ const fetchJson =
1423
+ args.fetchJson ?? (async (url: string) => await defaultFetchJson(url, cwd));
1424
+ const fetchText =
1425
+ args.fetchText ?? (async (url: string) => await defaultFetchText(url, cwd));
1426
+ const configured = await readIndexSources(home, cwd);
1427
+ const source =
1428
+ configured.find((candidate) => candidate.name === args.sourceName) ??
1429
+ resolveKnownIndexSource(args.sourceName);
1430
+ if (!source) {
1431
+ throw new Error(`Source not found: ${args.sourceName}`);
1432
+ }
1433
+
1434
+ const trustState = await loadSourceTrustState({ homeDir: home });
1435
+ const trust = evaluateSourceTrust({
1436
+ sourceName: source.name,
1437
+ trustState,
1438
+ });
1439
+
1440
+ const report: VerifySourceReport = {
1441
+ checkedAt: nowIso(args.now),
1442
+ source: {
1443
+ name: source.name,
1444
+ url: source.url,
1445
+ kind: source.kind,
1446
+ },
1447
+ trust,
1448
+ checks: {
1449
+ fetch: "not-applicable",
1450
+ parse: "not-applicable",
1451
+ integrity: "not-applicable",
1452
+ signature: "not-applicable",
1453
+ items: 0,
1454
+ },
1455
+ };
1456
+
1457
+ try {
1458
+ if (source.kind === "builtin") {
1459
+ report.checks.parse = "passed";
1460
+ report.checks.items = BUILTIN_MANIFEST.items.length;
1461
+ return report;
1462
+ }
1463
+
1464
+ if (source.kind === "manifest") {
1465
+ const rawText = await fetchText(source.url);
1466
+ report.checks.fetch = "passed";
1467
+
1468
+ if (source.integrity) {
1469
+ assertManifestIntegrity({
1470
+ sourceName: source.name,
1471
+ sourceUrl: source.url,
1472
+ integrity: source.integrity,
1473
+ manifestText: rawText,
1474
+ });
1475
+ report.checks.integrity = "passed";
1476
+ } else {
1477
+ report.checks.integrity = "not-configured";
1478
+ }
1479
+
1480
+ if (source.signature) {
1481
+ await assertManifestSignature({
1482
+ sourceName: source.name,
1483
+ sourceUrl: source.url,
1484
+ signature: source.signature,
1485
+ signatureKeys: source.signatureKeys,
1486
+ manifestText: rawText,
1487
+ cwd,
1488
+ homeDir: home,
1489
+ });
1490
+ report.checks.signature = "passed";
1491
+ } else {
1492
+ report.checks.signature = "not-configured";
1493
+ }
1494
+
1495
+ const parsed = parseJsonLenient(rawText);
1496
+ const manifest = parseManifest(source, parsed);
1497
+ report.checks.parse = "passed";
1498
+ report.checks.items = manifest.items.length;
1499
+ return report;
1500
+ }
1501
+
1502
+ const manifest = await loadProviderManifest({
1503
+ source,
1504
+ fetchJson,
1505
+ fetchText,
1506
+ hints: {},
1507
+ });
1508
+ report.checks.fetch = "passed";
1509
+ report.checks.parse = "passed";
1510
+ report.checks.items = manifest.items.length;
1511
+ return report;
1512
+ } catch (err) {
1513
+ const message = err instanceof Error ? err.message : String(err);
1514
+ report.error = message;
1515
+ if (report.checks.fetch === "not-applicable") {
1516
+ report.checks.fetch = "failed";
1517
+ }
1518
+ if (report.checks.parse === "not-applicable") {
1519
+ report.checks.parse = "failed";
1520
+ }
1521
+ if (
1522
+ report.checks.integrity === "not-applicable" &&
1523
+ source.kind === "manifest"
1524
+ ) {
1525
+ report.checks.integrity = source.integrity ? "failed" : "not-configured";
1526
+ }
1527
+ if (
1528
+ report.checks.signature === "not-applicable" &&
1529
+ source.kind === "manifest"
1530
+ ) {
1531
+ report.checks.signature = source.signature ? "failed" : "not-configured";
1532
+ }
1533
+ return report;
1534
+ }
1535
+ }
1536
+
1537
+ function printSearchHelp() {
1538
+ console.log(`facult search — search configured remote indices
1539
+
1540
+ Usage:
1541
+ facult search <query> [--index <name>] [--limit <n>] [--json]
1542
+
1543
+ Notes:
1544
+ - Builtin index "${BUILTIN_INDEX_NAME}" is always available.
1545
+ - Builtin provider aliases: "${SMITHERY_INDEX_NAME}", "${GLAMA_INDEX_NAME}", "${SKILLS_SH_INDEX_NAME}", "${CLAWHUB_INDEX_NAME}".
1546
+ - Optional custom indices can be configured in ~/.facult/indices.json.
1547
+ `);
1548
+ }
1549
+
1550
+ function printInstallHelp() {
1551
+ console.log(`facult install — install an item from a remote index
1552
+
1553
+ Usage:
1554
+ facult install <index:item> [--as <name>] [--dry-run] [--force] [--strict-source-trust] [--json]
1555
+
1556
+ Examples:
1557
+ facult install facult:skill-template --as my-skill
1558
+ facult install facult:mcp-stdio-template --as github
1559
+ facult install smithery:github
1560
+ facult install glama:systeminit/si --as system-initiative
1561
+ facult install skills.sh:owner/repo --as my-skill
1562
+ facult install clawhub:my-skill
1563
+ `);
1564
+ }
1565
+
1566
+ function printUpdateHelp() {
1567
+ console.log(`facult update — check for updates to remotely installed items
1568
+
1569
+ Usage:
1570
+ facult update [--apply] [--strict-source-trust] [--json]
1571
+
1572
+ Options:
1573
+ --apply Install available updates
1574
+ --strict-source-trust Block review-level sources unless explicitly trusted
1575
+ `);
1576
+ }
1577
+
1578
+ function printTemplatesHelp() {
1579
+ console.log(`facult templates — DX-first local scaffolding for skills/instructions/MCP/snippets
1580
+
1581
+ Usage:
1582
+ facult templates list [--json]
1583
+ facult templates init skill <name> [--force] [--dry-run]
1584
+ facult templates init mcp <name> [--force] [--dry-run]
1585
+ facult templates init snippet <marker> [--force] [--dry-run]
1586
+ facult templates init agents [--force] [--dry-run]
1587
+ facult templates init claude [--force] [--dry-run]
1588
+
1589
+ Notes:
1590
+ - Templates are powered by the builtin remote index (${BUILTIN_INDEX_NAME}).
1591
+ `);
1592
+ }
1593
+
1594
+ function printVerifySourceHelp() {
1595
+ console.log(`facult verify-source — verify source trust/integrity/signature status
1596
+
1597
+ Usage:
1598
+ facult verify-source <name> [--json]
1599
+
1600
+ Examples:
1601
+ facult verify-source facult
1602
+ facult verify-source smithery
1603
+ facult verify-source local-index --json
1604
+ `);
1605
+ }
1606
+
1607
+ function parseLongFlag(argv: string[], flag: string): string | null {
1608
+ for (let i = 0; i < argv.length; i += 1) {
1609
+ const arg = argv[i];
1610
+ if (!arg) {
1611
+ continue;
1612
+ }
1613
+ if (arg === flag) {
1614
+ return argv[i + 1] ?? null;
1615
+ }
1616
+ if (arg.startsWith(`${flag}=`)) {
1617
+ return arg.slice(flag.length + 1);
1618
+ }
1619
+ }
1620
+ return null;
1621
+ }
1622
+
1623
+ export async function sourcesCommand(
1624
+ argv: string[],
1625
+ ctx: RemoteCommandContext = {}
1626
+ ) {
1627
+ await runSourcesCommand({
1628
+ argv,
1629
+ ctx: {
1630
+ homeDir: ctx.homeDir,
1631
+ cwd: ctx.cwd,
1632
+ now: ctx.now,
1633
+ },
1634
+ readIndexSources,
1635
+ builtinIndexName: BUILTIN_INDEX_NAME,
1636
+ });
1637
+ }
1638
+
1639
+ export async function searchCommand(
1640
+ argv: string[],
1641
+ ctx: RemoteCommandContext = {}
1642
+ ) {
1643
+ if (
1644
+ !argv.length ||
1645
+ argv.includes("--help") ||
1646
+ argv.includes("-h") ||
1647
+ argv[0] === "help"
1648
+ ) {
1649
+ printSearchHelp();
1650
+ return;
1651
+ }
1652
+ const query = argv.find((arg) => arg && !arg.startsWith("-"));
1653
+ if (!query) {
1654
+ console.error("search requires a query");
1655
+ process.exitCode = 1;
1656
+ return;
1657
+ }
1658
+ const index = parseLongFlag(argv, "--index") ?? undefined;
1659
+ const limitRaw = parseLongFlag(argv, "--limit");
1660
+ const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
1661
+ if (limitRaw && (!Number.isFinite(limit) || (limit ?? 0) <= 0)) {
1662
+ console.error(`Invalid --limit value: ${limitRaw}`);
1663
+ process.exitCode = 1;
1664
+ return;
1665
+ }
1666
+ const json = argv.includes("--json");
1667
+
1668
+ try {
1669
+ const results = await searchRemoteItems({
1670
+ query,
1671
+ index,
1672
+ limit,
1673
+ homeDir: ctx.homeDir,
1674
+ cwd: ctx.cwd,
1675
+ fetchJson: ctx.fetchJson,
1676
+ fetchText: ctx.fetchText,
1677
+ });
1678
+ if (json) {
1679
+ console.log(JSON.stringify(results, null, 2));
1680
+ return;
1681
+ }
1682
+ if (!results.length) {
1683
+ console.log("(no results)");
1684
+ return;
1685
+ }
1686
+ for (const row of results) {
1687
+ const version = row.item.version ?? "-";
1688
+ const title = row.item.title ?? row.item.description ?? "";
1689
+ console.log(
1690
+ `${row.index}:${row.item.id}\t${row.item.type}\t${version}\t${title}`
1691
+ );
1692
+ }
1693
+ } catch (err) {
1694
+ console.error(err instanceof Error ? err.message : String(err));
1695
+ process.exitCode = 1;
1696
+ }
1697
+ }
1698
+
1699
+ export async function installCommand(
1700
+ argv: string[],
1701
+ ctx: RemoteCommandContext = {}
1702
+ ) {
1703
+ if (
1704
+ !argv.length ||
1705
+ argv.includes("--help") ||
1706
+ argv.includes("-h") ||
1707
+ argv[0] === "help"
1708
+ ) {
1709
+ printInstallHelp();
1710
+ return;
1711
+ }
1712
+ const ref = argv.find((arg) => arg && !arg.startsWith("-"));
1713
+ if (!ref) {
1714
+ console.error("install requires a ref like <index:item>");
1715
+ process.exitCode = 1;
1716
+ return;
1717
+ }
1718
+ const as = parseLongFlag(argv, "--as") ?? undefined;
1719
+ const dryRun = argv.includes("--dry-run");
1720
+ const force = argv.includes("--force");
1721
+ const strictSourceTrust =
1722
+ argv.includes("--strict-source-trust") || Boolean(ctx.strictSourceTrust);
1723
+ const json = argv.includes("--json");
1724
+ try {
1725
+ const result = await installRemoteItem({
1726
+ ref,
1727
+ as,
1728
+ dryRun,
1729
+ force,
1730
+ strictSourceTrust,
1731
+ homeDir: ctx.homeDir,
1732
+ rootDir: ctx.rootDir,
1733
+ cwd: ctx.cwd,
1734
+ fetchJson: ctx.fetchJson,
1735
+ fetchText: ctx.fetchText,
1736
+ now: ctx.now,
1737
+ });
1738
+ if (json) {
1739
+ console.log(JSON.stringify(result, null, 2));
1740
+ return;
1741
+ }
1742
+ const action = dryRun ? "Would install" : "Installed";
1743
+ console.log(`${action} ${result.ref} as ${result.installedAs}`);
1744
+ if (result.sourceTrustLevel === "review" && !strictSourceTrust) {
1745
+ console.log(
1746
+ " ! source policy: review (use --strict-source-trust to enforce trust-only installs)"
1747
+ );
1748
+ }
1749
+ for (const path of result.changedPaths) {
1750
+ console.log(` - ${path}`);
1751
+ }
1752
+ } catch (err) {
1753
+ console.error(err instanceof Error ? err.message : String(err));
1754
+ process.exitCode = 1;
1755
+ }
1756
+ }
1757
+
1758
+ export async function updateCommand(
1759
+ argv: string[],
1760
+ ctx: RemoteCommandContext = {}
1761
+ ) {
1762
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
1763
+ printUpdateHelp();
1764
+ return;
1765
+ }
1766
+ const apply = argv.includes("--apply");
1767
+ const strictSourceTrust =
1768
+ argv.includes("--strict-source-trust") || Boolean(ctx.strictSourceTrust);
1769
+ const json = argv.includes("--json");
1770
+ try {
1771
+ const report = await checkRemoteUpdates({
1772
+ apply,
1773
+ strictSourceTrust,
1774
+ homeDir: ctx.homeDir,
1775
+ rootDir: ctx.rootDir,
1776
+ cwd: ctx.cwd,
1777
+ fetchJson: ctx.fetchJson,
1778
+ fetchText: ctx.fetchText,
1779
+ now: ctx.now,
1780
+ });
1781
+ if (json) {
1782
+ console.log(JSON.stringify(report, null, 2));
1783
+ return;
1784
+ }
1785
+ if (!report.checks.length) {
1786
+ console.log("No remotely installed items found.");
1787
+ return;
1788
+ }
1789
+ for (const check of report.checks) {
1790
+ const current = check.currentVersion ?? "-";
1791
+ const latest = check.latestVersion ?? "-";
1792
+ console.log(
1793
+ `${check.installed.ref} (${check.installed.installedAs})\t${check.status}\t${current} -> ${latest}`
1794
+ );
1795
+ }
1796
+ if (apply) {
1797
+ console.log(`Applied ${report.applied.length} updates.`);
1798
+ }
1799
+ } catch (err) {
1800
+ console.error(err instanceof Error ? err.message : String(err));
1801
+ process.exitCode = 1;
1802
+ }
1803
+ }
1804
+
1805
+ export async function verifySourceCommand(
1806
+ argv: string[],
1807
+ ctx: RemoteCommandContext = {}
1808
+ ) {
1809
+ if (
1810
+ !argv.length ||
1811
+ argv.includes("--help") ||
1812
+ argv.includes("-h") ||
1813
+ argv[0] === "help"
1814
+ ) {
1815
+ printVerifySourceHelp();
1816
+ return;
1817
+ }
1818
+
1819
+ const sourceName = argv.find((arg) => arg && !arg.startsWith("-"));
1820
+ if (!sourceName) {
1821
+ console.error("verify-source requires a source name");
1822
+ process.exitCode = 1;
1823
+ return;
1824
+ }
1825
+ const json = argv.includes("--json");
1826
+
1827
+ try {
1828
+ const report = await verifySource({
1829
+ sourceName,
1830
+ homeDir: ctx.homeDir,
1831
+ cwd: ctx.cwd,
1832
+ now: ctx.now,
1833
+ fetchJson: ctx.fetchJson,
1834
+ fetchText: ctx.fetchText,
1835
+ });
1836
+ if (json) {
1837
+ console.log(JSON.stringify(report, null, 2));
1838
+ } else {
1839
+ const trustOrigin = report.trust.explicit ? "explicit" : "default";
1840
+ console.log(
1841
+ `${report.source.name}\t${report.source.kind}\t${report.source.url}`
1842
+ );
1843
+ console.log(
1844
+ `trust=${report.trust.level} (${trustOrigin})\tfetch=${report.checks.fetch}\tparse=${report.checks.parse}\tintegrity=${report.checks.integrity}\tsignature=${report.checks.signature}\titems=${report.checks.items}`
1845
+ );
1846
+ if (report.error) {
1847
+ console.log(`error: ${report.error}`);
1848
+ }
1849
+ }
1850
+
1851
+ if (report.error) {
1852
+ process.exitCode = 1;
1853
+ }
1854
+ } catch (err) {
1855
+ console.error(err instanceof Error ? err.message : String(err));
1856
+ process.exitCode = 1;
1857
+ }
1858
+ }
1859
+
1860
+ export async function templatesCommand(
1861
+ argv: string[],
1862
+ ctx: RemoteCommandContext = {}
1863
+ ) {
1864
+ const [sub, ...rest] = argv;
1865
+ if (!sub || sub === "-h" || sub === "--help" || sub === "help") {
1866
+ printTemplatesHelp();
1867
+ return;
1868
+ }
1869
+ if (sub === "list") {
1870
+ const json = rest.includes("--json");
1871
+ const rows = BUILTIN_MANIFEST.items.map((item) => ({
1872
+ id: item.id,
1873
+ type: item.type,
1874
+ title: item.title ?? "",
1875
+ description: item.description ?? "",
1876
+ version: item.version ?? "",
1877
+ }));
1878
+ if (json) {
1879
+ console.log(JSON.stringify(rows, null, 2));
1880
+ return;
1881
+ }
1882
+ for (const row of rows) {
1883
+ console.log(`${row.id}\t${row.type}\t${row.version}\t${row.title}`);
1884
+ }
1885
+ return;
1886
+ }
1887
+ if (sub !== "init") {
1888
+ console.error(`Unknown templates command: ${sub}`);
1889
+ process.exitCode = 2;
1890
+ return;
1891
+ }
1892
+
1893
+ const [kind, ...args] = rest;
1894
+ if (!kind) {
1895
+ console.error(
1896
+ "templates init requires a kind (skill|mcp|snippet|agents|claude)"
1897
+ );
1898
+ process.exitCode = 2;
1899
+ return;
1900
+ }
1901
+ const dryRun = args.includes("--dry-run");
1902
+ const force = args.includes("--force");
1903
+ const json = args.includes("--json");
1904
+ const positional = args.filter((a) => a && !a.startsWith("-"));
1905
+
1906
+ let ref = "";
1907
+ let as: string | undefined;
1908
+ if (kind === "skill") {
1909
+ ref = `${BUILTIN_INDEX_NAME}:skill-template`;
1910
+ as = positional[0];
1911
+ if (!as) {
1912
+ console.error("templates init skill requires a <name>");
1913
+ process.exitCode = 2;
1914
+ return;
1915
+ }
1916
+ } else if (kind === "mcp") {
1917
+ ref = `${BUILTIN_INDEX_NAME}:mcp-stdio-template`;
1918
+ as = positional[0];
1919
+ if (!as) {
1920
+ console.error("templates init mcp requires a <name>");
1921
+ process.exitCode = 2;
1922
+ return;
1923
+ }
1924
+ } else if (kind === "snippet") {
1925
+ ref = `${BUILTIN_INDEX_NAME}:snippet-template`;
1926
+ as = positional[0];
1927
+ if (!as) {
1928
+ console.error("templates init snippet requires a <marker>");
1929
+ process.exitCode = 2;
1930
+ return;
1931
+ }
1932
+ } else if (kind === "agents") {
1933
+ ref = `${BUILTIN_INDEX_NAME}:agents-md-template`;
1934
+ as = positional[0];
1935
+ } else if (kind === "claude") {
1936
+ ref = `${BUILTIN_INDEX_NAME}:claude-md-template`;
1937
+ as = positional[0];
1938
+ } else {
1939
+ console.error(`Unknown template kind: ${kind}`);
1940
+ process.exitCode = 2;
1941
+ return;
1942
+ }
1943
+
1944
+ try {
1945
+ const result = await installRemoteItem({
1946
+ ref,
1947
+ as,
1948
+ dryRun,
1949
+ force,
1950
+ homeDir: ctx.homeDir,
1951
+ rootDir: ctx.rootDir,
1952
+ cwd: ctx.cwd,
1953
+ fetchJson: ctx.fetchJson,
1954
+ fetchText: ctx.fetchText,
1955
+ now: ctx.now,
1956
+ });
1957
+ if (json) {
1958
+ console.log(JSON.stringify(result, null, 2));
1959
+ return;
1960
+ }
1961
+ const action = dryRun ? "Would scaffold" : "Scaffolded";
1962
+ console.log(`${action} ${kind} template as ${result.installedAs}`);
1963
+ for (const path of result.changedPaths) {
1964
+ console.log(` - ${path}`);
1965
+ }
1966
+ } catch (err) {
1967
+ console.error(err instanceof Error ? err.message : String(err));
1968
+ process.exitCode = 1;
1969
+ }
1970
+ }