@zhalker/gentle-shell-codegraph-subagents 0.1.0 → 0.2.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.
@@ -0,0 +1,122 @@
1
+ import net from "node:net";
2
+ import { readdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve, sep } from "node:path";
5
+
6
+ /** The hello line a CodeGraph daemon sends carries `protocol`; this is the only one spoken here. */
7
+ export const DAEMON_PROTOCOL = 1;
8
+ /** An explore answer is tens of kilobytes; anything near this size is not one worth keeping in memory. */
9
+ export const MAX_REPLY_CHARS = 1_000_000;
10
+ /** CodeGraph answers an explore without a project with this plain text, not with an error. */
11
+ export const NO_PROJECT_TEXT = "No CodeGraph project is loaded";
12
+
13
+ export type DaemonExploreResult =
14
+ | { status: "ok"; text: string }
15
+ /** No daemon to ask, or it did not behave as expected: the caller should use another way. */
16
+ | { status: "unavailable" }
17
+ /** A daemon was there but did not answer in time: a second attempt would only be slower. */
18
+ | { status: "timeout" };
19
+
20
+ export interface DaemonOptions {
21
+ /** Where daemons register themselves; defaults to `~/.codegraph/daemons`. */
22
+ registryDir?: string;
23
+ }
24
+
25
+ /** The same project can be spelled with another letter case or a trailing separator. */
26
+ export function normalizeRoot(path: string): string {
27
+ let real: string;
28
+ try { real = realpathSync(path); } catch { real = resolve(path); }
29
+ while (real.length > 1 && (real.endsWith(sep) || real.endsWith("/")) && !/^[A-Za-z]:[\\/]$/.test(real)) real = real.slice(0, -1);
30
+ return process.platform === "win32" ? real.toLowerCase() : real;
31
+ }
32
+
33
+ /** Named pipes of the daemons registered for `root`, newest first. Reads the registry only; starts nothing. */
34
+ function socketsFor(root: string, registryDir: string): string[] {
35
+ let files: string[];
36
+ try { files = readdirSync(registryDir).filter((file) => file.endsWith(".json")); } catch { return []; }
37
+ const wanted = normalizeRoot(root);
38
+ const found: { socketPath: string; startedAt: number }[] = [];
39
+ for (const file of files) {
40
+ try {
41
+ const entry = JSON.parse(readFileSync(join(registryDir, file), "utf8"));
42
+ if (typeof entry.root !== "string" || typeof entry.socketPath !== "string") continue;
43
+ if (normalizeRoot(entry.root) !== wanted) continue;
44
+ found.push({ socketPath: entry.socketPath, startedAt: typeof entry.startedAt === "number" ? entry.startedAt : 0 });
45
+ } catch { /* a damaged record is not a daemon */ }
46
+ }
47
+ return found.sort((a, b) => b.startedAt - a.startedAt).map((entry) => entry.socketPath);
48
+ }
49
+
50
+ /** One conversation with one daemon: hello, initialize, one codegraph_explore. */
51
+ function ask(socketPath: string, query: string, timeoutMs: number): Promise<DaemonExploreResult> {
52
+ return new Promise((resolveResult) => {
53
+ let settled = false;
54
+ const socket = net.createConnection(socketPath);
55
+ const finish = (result: DaemonExploreResult) => {
56
+ if (settled) return;
57
+ settled = true;
58
+ clearTimeout(timer);
59
+ socket.destroy();
60
+ resolveResult(result);
61
+ };
62
+ const timer = setTimeout(() => finish({ status: "timeout" }), Math.max(1, timeoutMs));
63
+ const send = (message: object) => socket.write(JSON.stringify(message) + "\n");
64
+ let buffer = "";
65
+ let stage: "hello" | "initialize" | "explore" = "hello";
66
+ socket.setEncoding("utf8");
67
+ socket.on("error", () => finish({ status: "unavailable" }));
68
+ socket.on("close", () => finish({ status: "unavailable" }));
69
+ socket.on("data", (chunk: string) => {
70
+ buffer += chunk;
71
+ if (buffer.length > MAX_REPLY_CHARS) return finish({ status: "unavailable" });
72
+ let newline: number;
73
+ while ((newline = buffer.indexOf("\n")) >= 0) {
74
+ const line = buffer.slice(0, newline).trim();
75
+ buffer = buffer.slice(newline + 1);
76
+ if (line.length === 0) continue;
77
+ let message: any;
78
+ try { message = JSON.parse(line); } catch { return finish({ status: "unavailable" }); }
79
+ if (stage === "hello") {
80
+ if (typeof message.codegraph !== "string" || message.protocol !== DAEMON_PROTOCOL) return finish({ status: "unavailable" });
81
+ stage = "initialize";
82
+ send({
83
+ jsonrpc: "2.0",
84
+ id: 1,
85
+ method: "initialize",
86
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "gentle-shell-codegraph-subagents", version: "0" } },
87
+ });
88
+ } else if (stage === "initialize" && message.id === 1) {
89
+ if (message.error !== undefined) return finish({ status: "unavailable" });
90
+ stage = "explore";
91
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
92
+ send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "codegraph_explore", arguments: { query } } });
93
+ } else if (stage === "explore" && message.id === 2) {
94
+ // CodeGraph reports a crashed query worker with isError and a text, not with a JSON-RPC error.
95
+ const text = message.result?.content?.[0]?.text;
96
+ const failed = message.error !== undefined || message.result?.isError === true;
97
+ if (failed || typeof text !== "string" || text.startsWith(NO_PROJECT_TEXT)) return finish({ status: "unavailable" });
98
+ return finish({ status: "ok", text });
99
+ }
100
+ }
101
+ });
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Runs `codegraph_explore` through the daemon already serving `root`, the same text the CLI prints but without
107
+ * a process of its own. It only reads the daemon registry and connects: it never starts a daemon, and never
108
+ * throws.
109
+ */
110
+ export async function exploreViaDaemon(root: string, query: string, timeoutMs: number, options: DaemonOptions = {}): Promise<DaemonExploreResult> {
111
+ try {
112
+ const registryDir = options.registryDir ?? join(homedir(), ".codegraph", "daemons");
113
+ const deadline = Date.now() + timeoutMs;
114
+ for (const socketPath of socketsFor(root, registryDir)) {
115
+ const result = await ask(socketPath, query, deadline - Date.now());
116
+ if (result.status !== "unavailable") return result;
117
+ }
118
+ return { status: "unavailable" };
119
+ } catch {
120
+ return { status: "unavailable" };
121
+ }
122
+ }
@@ -0,0 +1,230 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { normalizeRoot } from "./daemonclient.ts";
6
+
7
+ /**
8
+ * One `codegraph init` per project at a time, across every Pi session of this user on the machine (the temp folder
9
+ * is per user, so sessions of another Windows account are not seen). CodeGraph's own `init`
10
+ * can hang for good when two start at the same instant, or when anything opens the database in the first
11
+ * moments after the index was created, so the sessions that lose the race leave the index alone.
12
+ *
13
+ * The lock is a directory in the temp folder (creating a directory is atomic) holding `owner.json`. Clearing a
14
+ * stale lock happens under a second directory, `<lock>.takeover`, so only one session at a time can do it.
15
+ * The pids in `owner.json` are not the whole truth: on Windows `codegraph` is started through a shell, and that
16
+ * shell can die while the init itself goes on. CodeGraph's own `.codegraph/codegraph.lock` names the process that
17
+ * really writes the index, so it is consulted as well.
18
+ */
19
+
20
+ /** The longest an init is allowed to run (10 minutes) plus a minute: beyond that the lock is not trusted. */
21
+ export const LOCK_MAX_AGE_MS = 11 * 60_000;
22
+ /** An empty lock directory this young is an owner about to write its record; older ones were abandoned. */
23
+ const UNWRITTEN_GRACE_MS = 10_000;
24
+ /** Clearing a stale lock takes milliseconds: a takeover guard older than this was left by a session that died. */
25
+ const TAKEOVER_GRACE_MS = 10_000;
26
+ /**
27
+ * How long a record whose adopted pid may be only the shell around the real init is trusted after that spawn.
28
+ * The shell can die while the init goes on, and the init announces itself in CodeGraph's own lock only a
29
+ * moment later (about half a second here); before that there is nothing to tell a starting init from a dead one.
30
+ */
31
+ export const PROXY_TRUST_MS = 60_000;
32
+
33
+ export interface LockOptions {
34
+ /** Where lock directories live; defaults to the temp folder. */
35
+ dir?: string;
36
+ isAlive?(pid: number): boolean;
37
+ now?(): number;
38
+ /** Tests only: called after a stale lock was found and before it is cleared. */
39
+ onStale?(): void;
40
+ }
41
+
42
+ export type LockAttempt =
43
+ | {
44
+ status: "acquired";
45
+ /**
46
+ * Records the init process too: the lock stays held while it lives, even after this session is gone.
47
+ * `proxy: true` when the pid is only the process that was spawned around the real init (Windows runs
48
+ * `codegraph` through a shell): that shell can die while the init goes on, so the record is trusted for
49
+ * {@link PROXY_TRUST_MS} after the spawn as well, until CodeGraph's own lock names the init.
50
+ */
51
+ adopt(pid: number, options?: { proxy?: boolean }): void;
52
+ release(): void;
53
+ }
54
+ | { status: "busy" };
55
+
56
+ /** What `autoIndex` needs from a lock; the real one is {@link realInitLock}. */
57
+ export interface InitLock {
58
+ acquire(project: string): LockAttempt;
59
+ isHeld(project: string): boolean;
60
+ }
61
+
62
+ interface Owner {
63
+ pid: number;
64
+ startedAt: number;
65
+ /** Tells this session's lock apart from a later one taken by a session with the same pid. */
66
+ token: string;
67
+ /** The init process this session started, which can outlive the session. */
68
+ initPid?: number;
69
+ /** When `initPid` was adopted, for the window in which a proxy pid is trusted on its own. */
70
+ initPidAt?: number;
71
+ /** `initPid` is the process that was spawned around the init (a shell), not the init itself. */
72
+ initProxy?: boolean;
73
+ }
74
+
75
+ function lockPath(project: string, options: LockOptions): string {
76
+ const key = createHash("sha256").update(normalizeRoot(project)).digest("hex").slice(0, 16);
77
+ return join(options.dir ?? tmpdir(), `pcs-init-${key}`);
78
+ }
79
+
80
+ function processExists(pid: number): boolean {
81
+ try {
82
+ process.kill(pid, 0);
83
+ return true;
84
+ } catch (error) {
85
+ // EPERM: the process exists but belongs to someone else.
86
+ return (error as { code?: string }).code === "EPERM";
87
+ }
88
+ }
89
+
90
+ /** CodeGraph writes the pid of whatever writes the index here and removes the file when it ends, on failure too. */
91
+ const CODEGRAPH_LOCK = join(".codegraph", "codegraph.lock");
92
+
93
+ /**
94
+ * Whether a CodeGraph process is writing the index of `project` right now, whoever started it (another Pi, the
95
+ * project daemon, a git hook, a terminal). CodeGraph holds `.codegraph/codegraph.lock` for every operation that
96
+ * writes the index (`init`, `sync`, a file index) and removes it when it ends; reads and queries never hold it.
97
+ * A pid that is not alive left the file behind. Any other error is a false: this never throws.
98
+ */
99
+ export function codegraphWritingIndex(project: string): boolean {
100
+ try {
101
+ const pid = Number.parseInt(readFileSync(join(project, CODEGRAPH_LOCK), "utf8").trim(), 10);
102
+ return Number.isInteger(pid) && pid > 0 && processExists(pid);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ function readOwner(lock: string): Owner | undefined {
109
+ try {
110
+ const owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8"));
111
+ const valid = Number.isInteger(owner.pid) && typeof owner.startedAt === "number" && typeof owner.token === "string"
112
+ && (owner.initPid === undefined || Number.isInteger(owner.initPid))
113
+ && (owner.initPidAt === undefined || typeof owner.initPidAt === "number")
114
+ && (owner.initProxy === undefined || typeof owner.initProxy === "boolean");
115
+ return valid ? owner : undefined;
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /** Whether the lock at `lock` still belongs to someone who is working. A missing lock is not held. */
122
+ function held(project: string, lock: string, options: LockOptions): boolean {
123
+ const now = options.now?.() ?? Date.now();
124
+ let directory;
125
+ try { directory = statSync(lock); } catch { return false; }
126
+ const owner = readOwner(lock);
127
+ // No readable record: its owner is probably still writing it, unless it has been this way for a while.
128
+ if (owner === undefined) return now - directory.mtimeMs < UNWRITTEN_GRACE_MS;
129
+ if (now - owner.startedAt > LOCK_MAX_AGE_MS) return false;
130
+ const isAlive = options.isAlive ?? processExists;
131
+ if (isAlive(owner.pid)) return true;
132
+ if (owner.initPid !== undefined && isAlive(owner.initPid)) return true;
133
+ // The process writing the index itself, whatever pid this session spawned or adopted: on Windows the
134
+ // adopted pid is the shell, and a shell dies with the session that started it while the init goes on.
135
+ // That pid is another process, so it is judged with the real process table, not with a test hook.
136
+ if (codegraphWritingIndex(project)) return true;
137
+ // Right after the spawn CodeGraph has not written its lock yet; until then the record speaks for the init.
138
+ return owner.initProxy === true && owner.initPidAt !== undefined && now - owner.initPidAt < PROXY_TRUST_MS;
139
+ }
140
+
141
+ /** Replaces the owner record in one step, so a reader sees the old record or the new one, never none. */
142
+ function writeOwner(lock: string, owner: Owner): void {
143
+ const draft = join(lock, `owner.${owner.token}.tmp`);
144
+ writeFileSync(draft, JSON.stringify(owner));
145
+ renameSync(draft, join(lock, "owner.json"));
146
+ }
147
+
148
+ /**
149
+ * Clears the stale lock at `lock`; true when the caller may try to take it again. Only the session holding the
150
+ * takeover guard gets this far, and it checks again under the guard: another session may have taken the lock
151
+ * over since this one judged it stale.
152
+ */
153
+ function clearStale(project: string, lock: string, options: LockOptions, token: string): boolean {
154
+ const guard = `${lock}.takeover`;
155
+ try {
156
+ mkdirSync(guard);
157
+ } catch {
158
+ // Someone else is taking it over right now, or died doing it: then the next session gets its turn.
159
+ try {
160
+ if ((options.now?.() ?? Date.now()) - statSync(guard).mtimeMs > TAKEOVER_GRACE_MS) rmSync(guard, { recursive: true, force: true });
161
+ } catch { /* already gone */ }
162
+ return false;
163
+ }
164
+ try {
165
+ if (held(project, lock, options)) return false;
166
+ const judged = readOwner(lock)?.token;
167
+ const aside = `${lock}.stale-${token}`;
168
+ try { renameSync(lock, aside); } catch { return true; /* its owner removed it meanwhile */ }
169
+ if (readOwner(aside)?.token !== judged) {
170
+ // Not the lock judged stale: its owner left and another session took it in between. Give it back.
171
+ try { renameSync(aside, lock); } catch { /* the lock is taken again; the moved one is lost either way */ }
172
+ return false;
173
+ }
174
+ try { rmSync(aside, { recursive: true, force: true }); } catch { /* leftovers are harmless */ }
175
+ return true;
176
+ } finally {
177
+ try { rmSync(guard, { recursive: true, force: true }); } catch { /* a guard left behind expires */ }
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Takes the init lock of `project`. Never throws: when the temp folder cannot be used the session goes on as if
183
+ * it had the lock, which is how it behaved before locks existed.
184
+ */
185
+ export function tryAcquireInitLock(project: string, options: LockOptions = {}): LockAttempt {
186
+ const lock = lockPath(project, options);
187
+ const mine: Owner = { pid: process.pid, startedAt: options.now?.() ?? Date.now(), token: randomBytes(8).toString("hex") };
188
+ const adopt = (pid: number, adoptOptions: { proxy?: boolean } = {}) => {
189
+ if (readOwner(lock)?.token !== mine.token) return;
190
+ mine.initPid = pid;
191
+ mine.initPidAt = options.now?.() ?? Date.now();
192
+ mine.initProxy = adoptOptions.proxy === true;
193
+ try { writeOwner(lock, mine); } catch { /* the lock still holds while this session lives */ }
194
+ };
195
+ const release = () => {
196
+ // Only remove what is still ours: after a takeover the lock belongs to someone else.
197
+ const owner = readOwner(lock);
198
+ if (owner && owner.token === mine.token) rmSync(lock, { recursive: true, force: true });
199
+ };
200
+ for (let attempt = 0; attempt < 2; attempt++) {
201
+ try {
202
+ mkdirSync(lock);
203
+ writeOwner(lock, mine);
204
+ return { status: "acquired", adopt, release };
205
+ } catch (error) {
206
+ if ((error as { code?: string }).code !== "EEXIST") return { status: "acquired", adopt: () => {}, release: () => {} };
207
+ }
208
+ if (held(project, lock, options)) return { status: "busy" };
209
+ options.onStale?.();
210
+ if (!clearStale(project, lock, options, mine.token)) return { status: "busy" };
211
+ }
212
+ return { status: "busy" };
213
+ }
214
+
215
+ /**
216
+ * Whether the init lock of `project` is held right now: a session is running an init, or CodeGraph itself is still
217
+ * writing the index it started. Reads only.
218
+ */
219
+ export function isInitLocked(project: string, options: LockOptions = {}): boolean {
220
+ try {
221
+ return held(project, lockPath(project, options), options);
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ export const realInitLock: InitLock = {
228
+ acquire: (project) => tryAcquireInitLock(project),
229
+ isHeld: (project) => isInitLocked(project),
230
+ };
package/src/mcp.ts CHANGED
@@ -1,25 +1,93 @@
1
- /**
2
- * Pi declares MCP tools to the model only for servers with `exposure: "direct"`;
3
- * the default, `codemode`, makes them reachable from scripts but never callable
4
- * as a tool, so an agent `tools:` entry like `mcp__codegraph__*` would match nothing.
5
- * Returns the config with the codegraph server set to `direct`; identical text when nothing changes.
6
- */
7
- export function normalizeMcpConfig(text: string): string {
8
- let config: unknown;
9
- try {
10
- config = JSON.parse(text);
11
- } catch {
12
- return text;
13
- }
14
- if (typeof config !== "object" || config === null || Array.isArray(config)) return text;
15
- const servers = (config as { mcpServers?: unknown }).mcpServers;
16
- if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return text;
17
- const server = (servers as Record<string, unknown>).codegraph;
18
- if (typeof server !== "object" || server === null || Array.isArray(server)) return text;
19
- const entry = server as Record<string, unknown>;
20
- if (entry.enabled === false || entry.exposure === "direct") return text;
21
-
22
- entry.exposure = "direct";
23
- const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
24
- return JSON.stringify(config, null, indent) + "\n";
25
- }
1
+ const EXPLORE = "codegraph_explore";
2
+
3
+ function matchingExposure(entry: Record<string, unknown>): string | undefined {
4
+ const tools = entry.toolExposure;
5
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) return undefined;
6
+ const values = tools as Record<string, unknown>;
7
+ if (typeof values[EXPLORE] === "string") return values[EXPLORE] as string;
8
+ for (const [pattern, exposure] of Object.entries(values)) {
9
+ if (!pattern.includes("*") || typeof exposure !== "string") continue;
10
+ const regex = new RegExp(`^${pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
11
+ if (regex.test(EXPLORE)) return exposure;
12
+ }
13
+ return undefined;
14
+ }
15
+
16
+ function codegraphServer(text: string): Record<string, unknown> | undefined {
17
+ try {
18
+ const config = JSON.parse(text);
19
+ const entry = config?.mcpServers?.codegraph;
20
+ return entry && typeof entry === "object" && !Array.isArray(entry) ? entry : undefined;
21
+ } catch { return undefined; }
22
+ }
23
+
24
+ /** Missing is distinct from disabled, invalid configuration and servers registered by extensions. */
25
+ export function isCodegraphServerMissing(globalText: string, projectText?: string, registered = false): boolean {
26
+ if (registered) return false;
27
+ const read = (text: string): Record<string, unknown> | undefined => {
28
+ try {
29
+ const value = JSON.parse(text);
30
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
31
+ } catch { return undefined; }
32
+ };
33
+ const global = read(globalText);
34
+ const project = projectText === undefined ? undefined : read(projectText);
35
+ if (!global || (projectText !== undefined && !project)) return false;
36
+ const hasServer = (config: Record<string, unknown>): boolean => {
37
+ const servers = config.mcpServers;
38
+ return !!servers && typeof servers === "object" && !Array.isArray(servers)
39
+ && Object.prototype.hasOwnProperty.call(servers, "codegraph");
40
+ };
41
+ if (hasServer(global)) return false;
42
+ if (!project || !hasServer(project)) return true;
43
+ const servers = project.mcpServers as Record<string, unknown>;
44
+ const entry = servers.codegraph;
45
+ // A project-only override cannot create a server without a transport definition.
46
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
47
+ return !("command" in entry || "url" in entry || "type" in entry);
48
+ }
49
+ return false; // Let Pi report an invalid server entry instead of claiming it is absent.
50
+ }
51
+
52
+ /** Explicit restrictions must be respected, not overridden silently. */
53
+ export function inspectMcpExposure(text: string): string | undefined {
54
+ const entry = codegraphServer(text);
55
+ if (!entry) return undefined;
56
+ if (entry.enabled === false) return "disabled";
57
+ const selected = matchingExposure(entry);
58
+ if (entry.exposure === "hidden" && selected === "direct") return "hidden-with-direct";
59
+ if (selected !== undefined) return selected === "direct" ? undefined : selected;
60
+ const exposure = typeof entry.exposure === "string" ? entry.exposure : "codemode";
61
+ return exposure === "direct" ? undefined : exposure;
62
+ }
63
+
64
+ /** Inspect the effective file configuration, including a trusted project's override when supplied. */
65
+ export function inspectEffectiveMcpExposure(globalText: string, projectText?: string): string | undefined {
66
+ const global = codegraphServer(globalText);
67
+ const project = projectText === undefined ? undefined : codegraphServer(projectText);
68
+ if (!project) return inspectMcpExposure(globalText);
69
+ const replacement = "command" in project || "url" in project || "type" in project;
70
+ const entry = replacement ? project : { ...global, ...project };
71
+ return inspectMcpExposure(JSON.stringify({ mcpServers: { codegraph: entry } }));
72
+ }
73
+
74
+ /** Select direct access for explore without changing the server-wide exposure. */
75
+ export function normalizeMcpConfig(text: string): string {
76
+ let config: unknown;
77
+ try {
78
+ config = JSON.parse(text);
79
+ } catch {
80
+ return text;
81
+ }
82
+ if (typeof config !== "object" || config === null || Array.isArray(config)) return text;
83
+ const servers = (config as { mcpServers?: unknown }).mcpServers;
84
+ if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return text;
85
+ const server = (servers as Record<string, unknown>).codegraph;
86
+ if (typeof server !== "object" || server === null || Array.isArray(server)) return text;
87
+ const entry = server as Record<string, unknown>;
88
+ if (entry.enabled === false || matchingExposure(entry) !== undefined || entry.exposure === "hidden") return text;
89
+ if (entry.toolExposure !== undefined && (typeof entry.toolExposure !== "object" || entry.toolExposure === null || Array.isArray(entry.toolExposure))) return text;
90
+ entry.toolExposure = { ...(entry.toolExposure as Record<string, unknown> | undefined), [EXPLORE]: "direct" };
91
+ const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
92
+ return JSON.stringify(config, null, indent) + "\n";
93
+ }