facult 2.5.2 → 2.7.0

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.
package/src/manage.ts CHANGED
@@ -15,6 +15,7 @@ import { renderCanonicalText } from "./agents";
15
15
  import { ensureAiIndexPath } from "./ai-state";
16
16
  import { builtinSyncDefaultsEnabled, facultBuiltinPackRoot } from "./builtin";
17
17
  import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
18
+ import { renderBullets, renderCode, renderPage } from "./cli-ui";
18
19
  import { contentHash, normalizeText } from "./conflicts";
19
20
  import {
20
21
  globalDocTargetPaths,
@@ -31,6 +32,11 @@ import {
31
32
  type FacultIndex,
32
33
  type SkillEntry,
33
34
  } from "./index-builder";
35
+ import {
36
+ extractServersObject,
37
+ loadCanonicalMcpState,
38
+ stringifyCanonicalMcpServers,
39
+ } from "./mcp-config";
34
40
  import {
35
41
  facultMachineStateDir,
36
42
  facultRootDir,
@@ -903,24 +909,6 @@ async function loadEnabledSkillNames({
903
909
  return entries.map((entry) => entry.name);
904
910
  }
905
911
 
906
- function extractServersObject(parsed: unknown): Record<string, unknown> | null {
907
- if (!isPlainObject(parsed)) {
908
- return null;
909
- }
910
- const raw = parsed as Record<string, unknown>;
911
- const servers =
912
- (raw.servers as Record<string, unknown> | undefined) ??
913
- (raw.mcpServers as Record<string, unknown> | undefined) ??
914
- ((raw.mcp as Record<string, unknown> | undefined)?.servers as
915
- | Record<string, unknown>
916
- | undefined) ??
917
- null;
918
- if (servers && isPlainObject(servers)) {
919
- return servers;
920
- }
921
- return null;
922
- }
923
-
924
912
  function canonicalServerToToolConfig(server: unknown): unknown {
925
913
  if (!isPlainObject(server)) {
926
914
  return server;
@@ -970,21 +958,11 @@ async function loadCanonicalServers(rootDir: string): Promise<{
970
958
  servers: Record<string, unknown>;
971
959
  sourcePath: string | null;
972
960
  }> {
973
- const serversPath = join(rootDir, "mcp", "servers.json");
974
- const mcpPath = join(rootDir, "mcp", "mcp.json");
975
-
976
- const preferred = (await fileExists(serversPath)) ? serversPath : mcpPath;
977
- if (!(await fileExists(preferred))) {
978
- return { servers: {}, sourcePath: null };
979
- }
980
- try {
981
- const txt = await Bun.file(preferred).text();
982
- const parsed = JSON.parse(txt) as unknown;
983
- const servers = extractServersObject(parsed) ?? {};
984
- return { servers, sourcePath: preferred };
985
- } catch {
986
- return { servers: {}, sourcePath: preferred };
987
- }
961
+ const loaded = await loadCanonicalMcpState(rootDir);
962
+ const sourcePath = (await fileExists(loaded.trackedPath))
963
+ ? loaded.trackedPath
964
+ : null;
965
+ return { servers: loaded.trackedServers, sourcePath };
988
966
  }
989
967
 
990
968
  async function ensureEmptyDir(p: string) {
@@ -1744,12 +1722,6 @@ async function adoptExistingToolConfig(args: {
1744
1722
  ];
1745
1723
  }
1746
1724
 
1747
- function normalizeCanonicalMcpServers(
1748
- servers: Record<string, unknown>
1749
- ): string {
1750
- return JSON.stringify({ servers }, null, 2);
1751
- }
1752
-
1753
1725
  async function planExistingMcpAdoption(args: {
1754
1726
  rootDir: string;
1755
1727
  tool: string;
@@ -1845,7 +1817,7 @@ async function adoptExistingMcpServers(args: {
1845
1817
  const canonicalPath =
1846
1818
  canonical.sourcePath ?? join(args.rootDir, "mcp", "servers.json");
1847
1819
  await ensureDir(dirname(canonicalPath));
1848
- await Bun.write(canonicalPath, `${normalizeCanonicalMcpServers(merged)}\n`);
1820
+ await Bun.write(canonicalPath, stringifyCanonicalMcpServers(merged));
1849
1821
  return adopted;
1850
1822
  }
1851
1823
 
@@ -2044,7 +2016,9 @@ async function planMcpWrite({
2044
2016
  rootDir: string;
2045
2017
  tool: string;
2046
2018
  }): Promise<{ needsWrite: boolean; contents: string }> {
2047
- const { servers } = await loadCanonicalServers(rootDir);
2019
+ const { servers } = await loadCanonicalMcpState(rootDir, {
2020
+ includeLocal: true,
2021
+ });
2048
2022
  const filtered = filterServersForTool(servers, tool);
2049
2023
  const contents = `${JSON.stringify({ mcpServers: filtered }, null, 2)}\n`;
2050
2024
 
@@ -2090,7 +2064,9 @@ async function writeToolMcpConfig({
2090
2064
  rootDir: string;
2091
2065
  tool: string;
2092
2066
  }) {
2093
- const { servers } = await loadCanonicalServers(rootDir);
2067
+ const { servers } = await loadCanonicalMcpState(rootDir, {
2068
+ includeLocal: true,
2069
+ });
2094
2070
  const filtered = filterServersForTool(servers, tool);
2095
2071
  await ensureDir(dirname(mcpConfigPath));
2096
2072
  await Bun.write(
@@ -3667,11 +3643,20 @@ export async function managedCommand(argv: string[] = []) {
3667
3643
  parsed.argv.includes("-h") ||
3668
3644
  parsed.argv[0] === "help"
3669
3645
  ) {
3670
- console.log(`fclt managed — list tools currently in managed mode
3671
-
3672
- Usage:
3673
- fclt managed [--root PATH|--global|--project]
3674
- `);
3646
+ console.log(
3647
+ renderPage({
3648
+ title: "fclt managed",
3649
+ subtitle: "List tools currently in managed mode.",
3650
+ sections: [
3651
+ {
3652
+ title: "Usage",
3653
+ lines: renderBullets([
3654
+ renderCode("fclt managed [--root PATH|--global|--project]"),
3655
+ ]),
3656
+ },
3657
+ ],
3658
+ })
3659
+ );
3675
3660
  return;
3676
3661
  }
3677
3662
  const tools = await listManagedTools({
@@ -3682,12 +3667,27 @@ Usage:
3682
3667
  }),
3683
3668
  });
3684
3669
  if (!tools.length) {
3685
- console.log("No managed tools.");
3670
+ console.log(
3671
+ renderPage({
3672
+ title: "fclt managed",
3673
+ subtitle: "No managed tools.",
3674
+ sections: [],
3675
+ })
3676
+ );
3686
3677
  return;
3687
3678
  }
3688
- for (const tool of tools) {
3689
- console.log(tool);
3690
- }
3679
+ console.log(
3680
+ renderPage({
3681
+ title: "fclt managed",
3682
+ subtitle: `${tools.length} managed tool${tools.length === 1 ? "" : "s"}`,
3683
+ sections: [
3684
+ {
3685
+ title: "Tools",
3686
+ lines: renderBullets(tools),
3687
+ },
3688
+ ],
3689
+ })
3690
+ );
3691
3691
  }
3692
3692
 
3693
3693
  export async function syncCommand(argv: string[]) {
@@ -0,0 +1,132 @@
1
+ import { basename, dirname, join } from "node:path";
2
+ import { parseJsonLenient } from "./util/json";
3
+
4
+ const INLINE_SECRET_PLACEHOLDER_VALUES = new Set(["<set-me>", "<redacted>"]);
5
+ const INLINE_SECRET_ENV_REF_RE = /^\$\{[^}]+\}$/;
6
+
7
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
8
+ return !!value && typeof value === "object" && !Array.isArray(value);
9
+ }
10
+
11
+ export function isInlineMcpSecretValue(value: unknown): value is string {
12
+ if (typeof value !== "string") {
13
+ return false;
14
+ }
15
+ const trimmed = value.trim();
16
+ if (!trimmed) {
17
+ return false;
18
+ }
19
+ if (INLINE_SECRET_PLACEHOLDER_VALUES.has(trimmed)) {
20
+ return false;
21
+ }
22
+ if (INLINE_SECRET_ENV_REF_RE.test(trimmed)) {
23
+ return false;
24
+ }
25
+ return true;
26
+ }
27
+
28
+ export function extractServersObject(
29
+ parsed: unknown
30
+ ): Record<string, unknown> | null {
31
+ if (!isPlainObject(parsed)) {
32
+ return null;
33
+ }
34
+ const raw = parsed as Record<string, unknown>;
35
+ const servers =
36
+ (raw.servers as Record<string, unknown> | undefined) ??
37
+ (raw.mcpServers as Record<string, unknown> | undefined) ??
38
+ ((raw.mcp as Record<string, unknown> | undefined)?.servers as
39
+ | Record<string, unknown>
40
+ | undefined) ??
41
+ null;
42
+ if (servers && isPlainObject(servers)) {
43
+ return servers;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function mergeJsonObjects(
49
+ base: Record<string, unknown>,
50
+ override: Record<string, unknown>
51
+ ): Record<string, unknown> {
52
+ const out: Record<string, unknown> = { ...base };
53
+ for (const [key, value] of Object.entries(override)) {
54
+ const current = out[key];
55
+ if (isPlainObject(current) && isPlainObject(value)) {
56
+ out[key] = mergeJsonObjects(current, value);
57
+ continue;
58
+ }
59
+ out[key] = value;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export function canonicalMcpTrackedPath(rootDir: string): string {
65
+ return join(rootDir, "mcp", "servers.json");
66
+ }
67
+
68
+ export function canonicalMcpPaths(
69
+ rootDir: string,
70
+ trackedPath?: string | null
71
+ ): { trackedPath: string; localPath: string } {
72
+ const resolvedTrackedPath = trackedPath ?? canonicalMcpTrackedPath(rootDir);
73
+ const fileName = basename(resolvedTrackedPath);
74
+ const localFileName =
75
+ fileName === "mcp.json" ? "mcp.local.json" : "servers.local.json";
76
+ return {
77
+ trackedPath: resolvedTrackedPath,
78
+ localPath: join(dirname(resolvedTrackedPath), localFileName),
79
+ };
80
+ }
81
+
82
+ async function loadServersFromPath(
83
+ path: string
84
+ ): Promise<Record<string, unknown>> {
85
+ const file = Bun.file(path);
86
+ if (!(await file.exists())) {
87
+ return {};
88
+ }
89
+ try {
90
+ const parsed = parseJsonLenient(await file.text());
91
+ return extractServersObject(parsed) ?? {};
92
+ } catch {
93
+ return {};
94
+ }
95
+ }
96
+
97
+ export async function loadCanonicalMcpState(
98
+ rootDir: string,
99
+ opts?: { includeLocal?: boolean }
100
+ ): Promise<{
101
+ trackedPath: string;
102
+ localPath: string;
103
+ trackedServers: Record<string, unknown>;
104
+ localServers: Record<string, unknown>;
105
+ servers: Record<string, unknown>;
106
+ }> {
107
+ const serversPath = join(rootDir, "mcp", "servers.json");
108
+ const mcpPath = join(rootDir, "mcp", "mcp.json");
109
+
110
+ const trackedPath = (await Bun.file(serversPath).exists())
111
+ ? serversPath
112
+ : (await Bun.file(mcpPath).exists())
113
+ ? mcpPath
114
+ : serversPath;
115
+ const { localPath } = canonicalMcpPaths(rootDir, trackedPath);
116
+ const trackedServers = await loadServersFromPath(trackedPath);
117
+ const localServers =
118
+ opts?.includeLocal === true ? await loadServersFromPath(localPath) : {};
119
+ return {
120
+ trackedPath,
121
+ localPath,
122
+ trackedServers,
123
+ localServers,
124
+ servers: mergeJsonObjects(trackedServers, localServers),
125
+ };
126
+ }
127
+
128
+ export function stringifyCanonicalMcpServers(
129
+ servers: Record<string, unknown>
130
+ ): string {
131
+ return `${JSON.stringify({ servers }, null, 2)}\n`;
132
+ }