pluriply 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.
@@ -5,11 +5,60 @@ import {
5
5
  writeFileSync,
6
6
  copyFileSync,
7
7
  renameSync,
8
+ realpathSync,
9
+ statSync,
10
+ chmodSync,
8
11
  } from "node:fs";
9
12
  import { join } from "node:path";
10
13
  import { homedir } from "node:os";
11
14
  import { agyCommand } from "../shared/agy.js";
12
- import { MCP_REGISTRARS } from "../shared/mcp-register.js";
15
+ import { insertTomlKey, removeTomlSections } from "./toml-lite.js";
16
+
17
+ /**
18
+ * 도구별 pluriply MCP 서버 등록 명령. `list`로 이미 등록됐는지 보고 없으면 `add`한다.
19
+ * claude-code 는 워커 템플릿이 --mcp-config 로 인라인 등록하므로 여기 없다.
20
+ * @type {Record<string, {label: string, list: string[], add: (node: string, bin: string) => string[], hint: (bin: string) => string}>}
21
+ */
22
+ export const MCP_REGISTRARS = {
23
+ codex: {
24
+ label: "Codex",
25
+ list: ["codex", "mcp", "list"],
26
+ add: (node, bin) => [
27
+ "codex",
28
+ "mcp",
29
+ "add",
30
+ "pluriply",
31
+ "--",
32
+ node,
33
+ bin,
34
+ "connector",
35
+ "--agent",
36
+ "codex",
37
+ ],
38
+ hint: (bin) =>
39
+ `codex mcp add pluriply -- node "${bin}" connector --agent codex`,
40
+ },
41
+ antigravity: {
42
+ label: "Antigravity CLI",
43
+ // agy mcp add 는 user 스코프가 기본이다. `--` 는 필수: 뒤의 명령·인자가 `-` 로 시작할 수
44
+ // 있고(--agent), 플래그는 <name> 앞에만 올 수 있다(agy mcp add --help).
45
+ list: [agyCommand(), "mcp", "list"],
46
+ add: (node, bin) => [
47
+ agyCommand(),
48
+ "mcp",
49
+ "add",
50
+ "pluriply",
51
+ "--",
52
+ node,
53
+ bin,
54
+ "connector",
55
+ "--agent",
56
+ "antigravity",
57
+ ],
58
+ hint: (bin) =>
59
+ `agy mcp add pluriply -- node "${bin}" connector --agent antigravity`,
60
+ },
61
+ };
13
62
 
14
63
  /**
15
64
  * 어댑터가 쓰는 환경. 테스트는 exec/fs/homeDir/platform 을 주입한다.
@@ -24,28 +73,49 @@ export function makeEnv(o = {}) {
24
73
  platform: process.platform,
25
74
  node: process.execPath,
26
75
  processEnv: process.env,
27
- fs: { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync },
76
+ fs: {
77
+ existsSync,
78
+ readFileSync,
79
+ writeFileSync,
80
+ copyFileSync,
81
+ renameSync,
82
+ realpathSync,
83
+ statSync,
84
+ chmodSync,
85
+ },
28
86
  ...o,
29
87
  };
30
88
  }
31
89
 
32
90
  /** 테스트용 스킵 변수(구형 PLURIPLY_SKIP_CODEX_MCP 도 인정) */
33
- function skipped(env) {
91
+ export function isSkipped(env) {
34
92
  return Boolean(
35
93
  env.processEnv.PLURIPLY_SKIP_MCP_REGISTER ||
36
- env.processEnv.PLURIPLY_SKIP_CODEX_MCP,
94
+ env.processEnv.PLURIPLY_SKIP_CODEX_MCP,
37
95
  );
38
96
  }
39
97
 
98
+ /**
99
+ * MCP 도구 호출 타임아웃(초). ask_agent·request_review 의 wait_seconds 상한 300 의 두 배(스펙 §5).
100
+ * Codex `tool_timeout_sec`, Antigravity CLI·IDE `timeoutSeconds` 에 쓴다. Claude Code 는 stdio 유휴
101
+ * 30분에 progress 로 유지되고 Claude Desktop 은 설정 키가 없어 대상이 아니다.
102
+ */
103
+ export const TOOL_TIMEOUT_SEC = 600;
104
+
40
105
  /**
41
106
  * @param {"claude-desktop"|"antigravity-ide"} id
42
107
  * Antigravity 는 2.x 부터 IDE(`~/.gemini/antigravity-ide`) 와 허브(`~/.gemini/antigravity`) 로
43
108
  * 데이터 폴더가 갈라졌다. IDE 폴더가 있으면 그쪽, 없으면(구버전) 옛 경로.
44
109
  */
45
- export function configPath(id, { homeDir, platform, processEnv = {}, fs = { existsSync } }) {
110
+ export function configPath(
111
+ id,
112
+ { homeDir, platform, processEnv = {}, fs = { existsSync } },
113
+ ) {
46
114
  if (id === "antigravity-ide") {
47
115
  const ideDir = join(homeDir, ".gemini", "antigravity-ide");
48
- const dir = fs.existsSync(ideDir) ? ideDir : join(homeDir, ".gemini", "antigravity");
116
+ const dir = fs.existsSync(ideDir)
117
+ ? ideDir
118
+ : join(homeDir, ".gemini", "antigravity");
49
119
  return join(dir, "mcp_config.json");
50
120
  }
51
121
  if (platform === "darwin")
@@ -65,8 +135,132 @@ export function configPath(id, { homeDir, platform, processEnv = {}, fs = { exis
65
135
  return join(homeDir, ".config", "Claude", "claude_desktop_config.json");
66
136
  }
67
137
 
138
+ /** codex 설정 파일. CODEX_HOME 이 있으면 그 폴더. */
139
+ export function codexConfigPath({ homeDir, processEnv = {} }) {
140
+ return join(processEnv.CODEX_HOME || join(homeDir, ".codex"), "config.toml");
141
+ }
142
+
143
+ /** Antigravity CLI(agy)가 `agy mcp add` 로 쓰는 파일 */
144
+ export function agyConfigPath({ homeDir }) {
145
+ return join(homeDir, ".gemini", "config", "mcp_config.json");
146
+ }
147
+
148
+ /**
149
+ * Claude Code 의 user-scope(`-s user`) pluriply 등록 여부만 판정한다.
150
+ * `claude mcp list`/`claude mcp get` 은 project-local 등 모든 스코프를 합쳐 보여주므로, 이 저장소처럼
151
+ * project-local 에도 pluriply 가 등록돼 있으면 user 스코프가 비어 있어도 "있다"고 오판해서
152
+ * `setup --remove` 로 user 스코프를 지운 뒤 `setup` 을 다시 돌려도 재등록을 건너뛰게 된다.
153
+ * user-scope 서버는 `~/.claude.json` 최상위 `mcpServers.<name>` 에만 저장되고(프로젝트별 항목은
154
+ * `projects["<경로>"].mcpServers` 아래) 이 파일은 읽기 전용으로만 다룬다 — 여기서 쓰지 않는다.
155
+ * 파일이 없으면(첫 실행 전) missing, 파싱 실패·루트가 객체가 아니면 null(= `mcp list` 폴백).
156
+ * CLAUDE_CONFIG_DIR 가 있으면 Claude Code 가 이 파일을 그 폴더로 옮기므로 홈 대신 그쪽을 본다.
157
+ * @param {object} env
158
+ * @returns {"present"|"missing"|null}
159
+ */
160
+ function claudeUserScopeStatus(env) {
161
+ const dir = env.processEnv?.CLAUDE_CONFIG_DIR;
162
+ const path = join(dir || env.homeDir, ".claude.json");
163
+ if (!env.fs.existsSync(path)) return "missing";
164
+ try {
165
+ const doc = JSON.parse(env.fs.readFileSync(path, "utf8"));
166
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
167
+ const servers = doc.mcpServers;
168
+ // hasOwn: 프로토타입 체인을 타지 않는다 — `{"mcpServers":{}}` 의 constructor 같은 상속 키가
169
+ // 등록으로 보이면 안 된다.
170
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
171
+ return "missing";
172
+ return Object.hasOwn(servers, "pluriply") ? "present" : "missing";
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /** JSON 설정 파일을 읽는다. 빈 파일은 { mcpServers: {} }, 루트가 객체가 아니면 던진다. */
179
+ function readJsonDoc(env, path) {
180
+ const raw = env.fs.readFileSync(path, "utf8");
181
+ if (raw.trim() === "") return { mcpServers: {} };
182
+ const doc = JSON.parse(raw);
183
+ if (!doc || typeof doc !== "object" || Array.isArray(doc))
184
+ throw new Error("config root is not an object");
185
+ return doc;
186
+ }
187
+
188
+ /** 대상이 아직 없을 때 쓰는 보수적인 모드. 새 설정 파일에 API 키가 들어갈 수 있다. */
189
+ const FRESH_FILE_MODE = 0o600;
190
+
191
+ /**
192
+ * `.bak` 백업 뒤 tmp+rename 으로 쓴다(JSON·TOML 공용).
193
+ *
194
+ * 두 가지를 지킨다.
195
+ * - **심볼릭 링크는 따라간다**: dotfiles 저장소로 링크된 `~/.codex/config.toml` 을 rename 으로
196
+ * 덮으면 링크가 일반 파일로 바뀌어 dotfiles 사본이 더는 갱신되지 않는다. realpath 로 실제
197
+ * 파일을 찾아 그 폴더에서 tmp+rename 하므로 링크는 링크로 남고 대상 파일이 갱신된다.
198
+ * - **원본 모드를 지킨다**: 이 파일들엔 API 키·다른 MCP 서버의 env 가 들어 있어 사용자가
199
+ * 0600 으로 잠가두는 일이 흔하다. 기본 모드로 쓰면 umask 022 에서 0644(전체 읽기)로 넓어진다.
200
+ * `writeFileSync` 의 mode 옵션은 umask 에 깎이므로 `chmodSync` 로 한 번 더 못박는다.
201
+ * 대상이 아직 없으면(같은 실행에서 방금 만든 JSON) 0600 으로 시작한다.
202
+ */
203
+ function writeFileAtomic(env, path, content) {
204
+ const fs = env.fs;
205
+ let target = path;
206
+ let mode = FRESH_FILE_MODE;
207
+ let exists = false;
208
+ try {
209
+ exists = fs.existsSync(path);
210
+ } catch {
211
+ exists = false;
212
+ }
213
+ if (exists) {
214
+ // realpath 가 없는 주입 fs(테스트) 나 경합으로 사라진 파일은 원래 경로 그대로 쓴다.
215
+ try {
216
+ target = fs.realpathSync ? fs.realpathSync(path) : path;
217
+ } catch {
218
+ target = path;
219
+ }
220
+ try {
221
+ mode = fs.statSync(target).mode & 0o777;
222
+ } catch {
223
+ mode = FRESH_FILE_MODE;
224
+ }
225
+ }
226
+ if (exists) {
227
+ fs.copyFileSync(target, `${target}.bak`);
228
+ // 이미 있던 `.bak` 은 copyFileSync 가 모드를 물려받지 않으므로 직접 맞춘다.
229
+ chmodQuiet(fs, `${target}.bak`, mode);
230
+ }
231
+ const tmp = `${target}.${process.pid}.tmp`;
232
+ fs.writeFileSync(tmp, content, { mode });
233
+ chmodQuiet(fs, tmp, mode);
234
+ fs.renameSync(tmp, target);
235
+ }
236
+
237
+ /** chmod 을 못 쓰는 파일시스템(일부 Windows 공유)에서 쓰기 자체가 실패하면 안 된다. */
238
+ function chmodQuiet(fs, path, mode) {
239
+ try {
240
+ fs.chmodSync?.(path, mode);
241
+ } catch {
242
+ // 모드 고정 실패는 치명적이지 않다 — 내용은 이미 쓰였다
243
+ }
244
+ }
245
+
246
+ function writeJsonAtomic(env, path, doc) {
247
+ writeFileAtomic(env, path, JSON.stringify(doc, null, 2) + "\n");
248
+ }
249
+
68
250
  /** CLI 가 자체 `mcp list/add` 를 제공하는 클라이언트 */
69
- function cliClient({ id, label, agent, command, list, add, hint }) {
251
+ function cliClient({
252
+ id,
253
+ label,
254
+ agent,
255
+ command,
256
+ list,
257
+ add,
258
+ remove,
259
+ hint,
260
+ afterAdd,
261
+ afterRemove,
262
+ statusOverride,
263
+ }) {
70
264
  const cmd = () => (typeof command === "function" ? command() : command);
71
265
  return {
72
266
  id,
@@ -75,7 +269,8 @@ function cliClient({ id, label, agent, command, list, add, hint }) {
75
269
  kind: "cli",
76
270
  detect(env) {
77
271
  try {
78
- const out = env.exec(cmd(), ["--version"], { stdio: "pipe" })
272
+ const out = env
273
+ .exec(cmd(), ["--version"], { stdio: "pipe" })
79
274
  .toString()
80
275
  .trim();
81
276
  return { installed: true, detail: out.split("\n")[0] };
@@ -84,6 +279,12 @@ function cliClient({ id, label, agent, command, list, add, hint }) {
84
279
  }
85
280
  },
86
281
  status(env) {
282
+ // 스코프 인식이 필요한 클라이언트(claude-code)는 statusOverride 가 먼저 판정하고,
283
+ // null 을 돌려줄 때만(파일 없음 이외의 이유로 못 읽을 때) 기존 `mcp list` 판정으로 폴백한다.
284
+ if (statusOverride) {
285
+ const overridden = statusOverride(env);
286
+ if (overridden != null) return overridden;
287
+ }
87
288
  try {
88
289
  const out = env.exec(cmd(), list, { stdio: "pipe" }).toString();
89
290
  return out.includes("pluriply") ? "present" : "missing";
@@ -92,36 +293,81 @@ function cliClient({ id, label, agent, command, list, add, hint }) {
92
293
  }
93
294
  },
94
295
  register(env) {
95
- if (skipped(env)) return "skipped";
296
+ if (isSkipped(env)) return "skipped";
96
297
  const st = this.status(env);
97
298
  if (st === "present") return "present";
98
299
  if (typeof st === "object") {
99
- env.log(`hint: make sure ${label} has pluriply registered: ${hint(env.binPath)}`);
300
+ env.log(
301
+ `hint: make sure ${label} has pluriply registered: ${hint(env.binPath)}`,
302
+ );
100
303
  return "failed";
101
304
  }
102
305
  try {
103
306
  env.exec(cmd(), add(env.node, env.binPath), { stdio: "pipe" });
104
- env.log(`registered pluriply MCP server in ${label}`);
105
- return "registered";
106
307
  } catch {
107
- env.log(`hint: make sure ${label} has pluriply registered: ${hint(env.binPath)}`);
308
+ env.log(
309
+ `hint: make sure ${label} has pluriply registered: ${hint(env.binPath)}`,
310
+ );
108
311
  return "failed";
109
312
  }
313
+ if (afterAdd) {
314
+ const r = afterAdd(env);
315
+ if (!r.ok) {
316
+ // 타임아웃을 못 쓰면 등록을 되돌려 missing 으로 남긴다(스펙 D4). registered 는 항상 완전한 상태다.
317
+ let rolledBack = true;
318
+ try {
319
+ env.exec(cmd(), remove, { stdio: "pipe" });
320
+ } catch {
321
+ // 되돌리기 자체가 실패하면 등록이 타임아웃 없이 남는다 — 메시지로 분명히 알린다
322
+ rolledBack = false;
323
+ }
324
+ env.log(
325
+ rolledBack
326
+ ? `could not configure ${label}: ${r.reason}`
327
+ : `could not configure ${label}: ${r.reason} (registration left in place; run "pluriply setup --remove")`,
328
+ );
329
+ return `failed: ${r.reason}`;
330
+ }
331
+ }
332
+ env.log(`registered pluriply MCP server in ${label}`);
333
+ return "registered";
334
+ },
335
+ unregister(env) {
336
+ if (isSkipped(env)) return "skipped";
337
+ const st = this.status(env);
338
+ if (st === "missing") return "absent";
339
+ if (typeof st === "object") {
340
+ env.log(`cannot check ${label}: ${st.error}`);
341
+ return "failed";
342
+ }
343
+ try {
344
+ env.exec(cmd(), remove, { stdio: "pipe" });
345
+ } catch {
346
+ env.log(
347
+ `hint: remove pluriply from ${label} manually: ${cmd()} ${remove.join(" ")}`,
348
+ );
349
+ return "failed";
350
+ }
351
+ if (afterRemove) {
352
+ try {
353
+ afterRemove(env);
354
+ } catch (err) {
355
+ env.log(
356
+ `hint: pluriply was removed from ${label} but leftover config could not be cleaned: ${err.message}`,
357
+ );
358
+ return "failed";
359
+ }
360
+ }
361
+ env.log(`removed pluriply MCP server from ${label}`);
362
+ return "removed";
110
363
  },
111
364
  };
112
365
  }
113
366
 
114
367
  /** `mcpServers` JSON 설정 파일을 쓰는 데스크톱 앱 */
115
- function jsonClient({ id, label, agent }) {
368
+ function jsonClient({ id, label, agent, entryExtras = {} }) {
116
369
  const file = (env) => configPath(id, env);
117
- const read = (env) => {
118
- const raw = env.fs.readFileSync(file(env), "utf8");
119
- if (raw.trim() === "") return { mcpServers: {} };
120
- const doc = JSON.parse(raw);
121
- if (!doc || typeof doc !== "object" || Array.isArray(doc))
122
- throw new Error("config root is not an object");
123
- return doc;
124
- };
370
+ const read = (env) => readJsonDoc(env, file(env));
125
371
  return {
126
372
  id,
127
373
  label,
@@ -140,7 +386,7 @@ function jsonClient({ id, label, agent }) {
140
386
  }
141
387
  },
142
388
  register(env) {
143
- if (skipped(env)) return "skipped";
389
+ if (isSkipped(env)) return "skipped";
144
390
  const st = this.status(env);
145
391
  if (st === "present") return "present";
146
392
  if (typeof st === "object") {
@@ -155,15 +401,38 @@ function jsonClient({ id, label, agent }) {
155
401
  pluriply: {
156
402
  command: env.node,
157
403
  args: [env.binPath, "connector", "--agent", agent],
404
+ ...entryExtras,
158
405
  },
159
406
  };
160
- env.fs.copyFileSync(path, `${path}.bak`);
161
- const tmp = `${path}.${process.pid}.tmp`;
162
- env.fs.writeFileSync(tmp, JSON.stringify(doc, null, 2) + "\n");
163
- env.fs.renameSync(tmp, path);
164
- env.log(`registered pluriply MCP server in ${label} (${path}, backup ${path}.bak)`);
407
+ writeJsonAtomic(env, path, doc);
408
+ env.log(
409
+ `registered pluriply MCP server in ${label} (${path}, backup ${path}.bak)`,
410
+ );
165
411
  return "registered";
166
412
  },
413
+ unregister(env) {
414
+ if (isSkipped(env)) return "skipped";
415
+ const st = this.status(env);
416
+ if (st === "missing") return "absent";
417
+ if (typeof st === "object") {
418
+ env.log(`cannot update ${label} config: ${st.error}`);
419
+ return "failed";
420
+ }
421
+ const path = file(env);
422
+ try {
423
+ const doc = read(env);
424
+ const { pluriply: _removed, ...rest } = doc.mcpServers ?? {};
425
+ doc.mcpServers = rest;
426
+ writeJsonAtomic(env, path, doc);
427
+ } catch (err) {
428
+ env.log(`cannot update ${label} config: ${err.message}`);
429
+ return "failed";
430
+ }
431
+ env.log(
432
+ `removed pluriply MCP server from ${label} (${path}, backup ${path}.bak)`,
433
+ );
434
+ return "removed";
435
+ },
167
436
  };
168
437
  }
169
438
 
@@ -175,9 +444,22 @@ export const CLIENTS = [
175
444
  command: "claude",
176
445
  list: ["mcp", "list"],
177
446
  add: (node, bin) => [
178
- "mcp", "add", "-s", "user", "pluriply", "--", node, bin, "connector", "--agent", "claude-code",
447
+ "mcp",
448
+ "add",
449
+ "-s",
450
+ "user",
451
+ "pluriply",
452
+ "--",
453
+ node,
454
+ bin,
455
+ "connector",
456
+ "--agent",
457
+ "claude-code",
179
458
  ],
180
- hint: (bin) => `claude mcp add -s user pluriply -- node "${bin}" connector --agent claude-code`,
459
+ hint: (bin) =>
460
+ `claude mcp add -s user pluriply -- node "${bin}" connector --agent claude-code`,
461
+ remove: ["mcp", "remove", "-s", "user", "pluriply"],
462
+ statusOverride: claudeUserScopeStatus,
181
463
  }),
182
464
  cliClient({
183
465
  id: "codex",
@@ -187,6 +469,57 @@ export const CLIENTS = [
187
469
  list: MCP_REGISTRARS.codex.list.slice(1),
188
470
  add: (node, bin) => MCP_REGISTRARS.codex.add(node, bin).slice(1),
189
471
  hint: MCP_REGISTRARS.codex.hint,
472
+ remove: ["mcp", "remove", "pluriply"],
473
+ // codex mcp add 는 타임아웃 플래그가 없어 config.toml 을 직접 편집한다(스펙 §5)
474
+ afterAdd(env) {
475
+ const path = codexConfigPath(env);
476
+ try {
477
+ if (!env.fs.existsSync(path))
478
+ return {
479
+ ok: false,
480
+ reason: `tool_timeout_sec not written (${path} missing)`,
481
+ };
482
+ const r = insertTomlKey(
483
+ env.fs.readFileSync(path, "utf8"),
484
+ "mcp_servers.pluriply",
485
+ "tool_timeout_sec",
486
+ String(TOOL_TIMEOUT_SEC),
487
+ );
488
+ if (r.reason === "no-header")
489
+ return {
490
+ ok: false,
491
+ reason: `tool_timeout_sec not written ([mcp_servers.pluriply] not found in ${path})`,
492
+ };
493
+ if (r.reason === "unsupported")
494
+ return {
495
+ ok: false,
496
+ reason: `tool_timeout_sec not written (${path} contains triple-quoted strings; add \`tool_timeout_sec = ${TOOL_TIMEOUT_SEC}\` under [mcp_servers.pluriply] by hand)`,
497
+ };
498
+ if (r.changed) writeFileAtomic(env, path, r.text);
499
+ return { ok: true };
500
+ } catch (err) {
501
+ return {
502
+ ok: false,
503
+ reason: `tool_timeout_sec not written (${err.message})`,
504
+ };
505
+ }
506
+ },
507
+ // codex mcp remove 가 하위 테이블([mcp_servers.pluriply.tools.*])을 남길 수 있어 마저 지운다
508
+ afterRemove(env) {
509
+ const path = codexConfigPath(env);
510
+ if (!env.fs.existsSync(path)) return;
511
+ const { text, removed, reason } = removeTomlSections(
512
+ env.fs.readFileSync(path, "utf8"),
513
+ "mcp_servers.pluriply",
514
+ );
515
+ if (reason === "unsupported") {
516
+ env.log(
517
+ `hint: ${path} contains triple-quoted strings; if any [mcp_servers.pluriply*] sections remain, remove them by hand`,
518
+ );
519
+ return;
520
+ }
521
+ if (removed > 0) writeFileAtomic(env, path, text);
522
+ },
190
523
  }),
191
524
  cliClient({
192
525
  id: "antigravity",
@@ -196,7 +529,40 @@ export const CLIENTS = [
196
529
  list: MCP_REGISTRARS.antigravity.list.slice(1),
197
530
  add: (node, bin) => MCP_REGISTRARS.antigravity.add(node, bin).slice(1),
198
531
  hint: MCP_REGISTRARS.antigravity.hint,
532
+ remove: ["mcp", "remove", "pluriply"],
533
+ // agy mcp add 도 타임아웃 플래그가 없다. agy 는 JSONC 를 읽지만 우리는 JSON.parse 만 쓴다(스펙 §11).
534
+ afterAdd(env) {
535
+ const path = agyConfigPath(env);
536
+ try {
537
+ const doc = readJsonDoc(env, path);
538
+ const entry = doc.mcpServers?.pluriply;
539
+ if (!entry || typeof entry !== "object")
540
+ return {
541
+ ok: false,
542
+ reason: `timeoutSeconds not written (mcpServers.pluriply not found in ${path})`,
543
+ };
544
+ if (entry.timeoutSeconds === undefined) {
545
+ entry.timeoutSeconds = TOOL_TIMEOUT_SEC;
546
+ writeJsonAtomic(env, path, doc);
547
+ }
548
+ return { ok: true };
549
+ } catch (err) {
550
+ return {
551
+ ok: false,
552
+ reason: `timeoutSeconds not written (${err.message})`,
553
+ };
554
+ }
555
+ },
556
+ }),
557
+ jsonClient({
558
+ id: "claude-desktop",
559
+ label: "Claude Desktop",
560
+ agent: "claude-desktop",
561
+ }),
562
+ jsonClient({
563
+ id: "antigravity-ide",
564
+ label: "Antigravity IDE",
565
+ agent: "antigravity-ide",
566
+ entryExtras: { timeoutSeconds: TOOL_TIMEOUT_SEC },
199
567
  }),
200
- jsonClient({ id: "claude-desktop", label: "Claude Desktop", agent: "claude-desktop" }),
201
- jsonClient({ id: "antigravity-ide", label: "Antigravity IDE", agent: "antigravity-ide" }),
202
568
  ];