@tacuchi/agent-workflow-cli 22.4.2 → 24.0.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.
Files changed (295) hide show
  1. package/README.md +39 -3
  2. package/dist/adapters/git-cli.js +59 -17
  3. package/dist/adapters/git-cli.js.map +1 -1
  4. package/dist/adapters/node-file-system.js +55 -1
  5. package/dist/adapters/node-file-system.js.map +1 -1
  6. package/dist/adapters/postgres-readonly-tools.js +277 -0
  7. package/dist/adapters/postgres-readonly-tools.js.map +1 -0
  8. package/dist/application/artifacts-service.js +2 -2
  9. package/dist/application/artifacts-service.js.map +1 -1
  10. package/dist/application/capability/dispatcher.js +2 -2
  11. package/dist/application/capability/dispatcher.js.map +1 -1
  12. package/dist/application/capability/installed-inventory.js +2 -2
  13. package/dist/application/capability/installed-inventory.js.map +1 -1
  14. package/dist/application/capability/readiness.js +1 -1
  15. package/dist/application/capability/readiness.js.map +1 -1
  16. package/dist/application/check-branch-service.js +2 -2
  17. package/dist/application/check-branch-service.js.map +1 -1
  18. package/dist/application/checkpoint/files-touched.js +271 -0
  19. package/dist/application/checkpoint/files-touched.js.map +1 -0
  20. package/dist/application/checkpoint/markdown.js +61 -8
  21. package/dist/application/checkpoint/markdown.js.map +1 -1
  22. package/dist/application/checkpoint/state-reader.js +8 -2
  23. package/dist/application/checkpoint/state-reader.js.map +1 -1
  24. package/dist/application/checkpoint-write-service.js +16 -5
  25. package/dist/application/checkpoint-write-service.js.map +1 -1
  26. package/dist/application/claims-ledger.js +330 -0
  27. package/dist/application/claims-ledger.js.map +1 -0
  28. package/dist/application/claims-recovery.js +338 -0
  29. package/dist/application/claims-recovery.js.map +1 -0
  30. package/dist/application/database-mcp-server.js +168 -0
  31. package/dist/application/database-mcp-server.js.map +1 -0
  32. package/dist/application/database-mcp-stdio.js +137 -0
  33. package/dist/application/database-mcp-stdio.js.map +1 -0
  34. package/dist/application/database-tool-catalog.js +226 -0
  35. package/dist/application/database-tool-catalog.js.map +1 -0
  36. package/dist/application/dev-only-services.js +154 -44
  37. package/dist/application/dev-only-services.js.map +1 -1
  38. package/dist/application/dsn-reader-service.js +1 -1
  39. package/dist/application/elicitation-server.js +382 -0
  40. package/dist/application/elicitation-server.js.map +1 -0
  41. package/dist/application/elicitation-stdio.js +48 -0
  42. package/dist/application/elicitation-stdio.js.map +1 -0
  43. package/dist/application/flow/advance.js +86 -5
  44. package/dist/application/flow/advance.js.map +1 -1
  45. package/dist/application/flow/checkout-observation.js +152 -0
  46. package/dist/application/flow/checkout-observation.js.map +1 -0
  47. package/dist/application/flow/flow-service.js +72 -13
  48. package/dist/application/flow/flow-service.js.map +1 -1
  49. package/dist/application/flow/internal-actions.js +460 -1
  50. package/dist/application/flow/internal-actions.js.map +1 -1
  51. package/dist/application/flow/internal-drive.js +22 -9
  52. package/dist/application/flow/internal-drive.js.map +1 -1
  53. package/dist/application/flow/prove.js +205 -0
  54. package/dist/application/flow/prove.js.map +1 -0
  55. package/dist/application/flow/run-journey.js +15 -0
  56. package/dist/application/flow/run-journey.js.map +1 -0
  57. package/dist/application/flow/run-projection.js +15 -3
  58. package/dist/application/flow/run-projection.js.map +1 -1
  59. package/dist/application/flow/run-state-service.js +19 -4
  60. package/dist/application/flow/run-state-service.js.map +1 -1
  61. package/dist/application/flow/submit.js +250 -72
  62. package/dist/application/flow/submit.js.map +1 -1
  63. package/dist/application/generate-launch-service.js +4 -4
  64. package/dist/application/generate-launch-service.js.map +1 -1
  65. package/dist/application/git-flow-service.js +9 -0
  66. package/dist/application/git-flow-service.js.map +1 -1
  67. package/dist/application/local-proposal.js +9 -0
  68. package/dist/application/local-proposal.js.map +1 -1
  69. package/dist/application/lock-service.js +96 -28
  70. package/dist/application/lock-service.js.map +1 -1
  71. package/dist/application/logging/log-events.js +19 -3
  72. package/dist/application/logging/log-events.js.map +1 -1
  73. package/dist/application/logging/logger.js +2 -2
  74. package/dist/application/logging/logger.js.map +1 -1
  75. package/dist/application/mcp-connections-service.js +115 -41
  76. package/dist/application/mcp-connections-service.js.map +1 -1
  77. package/dist/application/mcp-doctor-service.js +98 -54
  78. package/dist/application/mcp-doctor-service.js.map +1 -1
  79. package/dist/application/mcp-entry-classification.js +60 -0
  80. package/dist/application/mcp-entry-classification.js.map +1 -0
  81. package/dist/application/mcp-host-reader.js +114 -81
  82. package/dist/application/mcp-host-reader.js.map +1 -1
  83. package/dist/application/mcp-host-receipt-service.js +399 -0
  84. package/dist/application/mcp-host-receipt-service.js.map +1 -0
  85. package/dist/application/mcp-host-receipt-store.js +122 -0
  86. package/dist/application/mcp-host-receipt-store.js.map +1 -0
  87. package/dist/application/mcp-host-receipts.js +28 -0
  88. package/dist/application/mcp-host-receipts.js.map +1 -0
  89. package/dist/application/mcp-host-writer.js +321 -126
  90. package/dist/application/mcp-host-writer.js.map +1 -1
  91. package/dist/application/mcp-launch-probe-service.js +142 -0
  92. package/dist/application/mcp-launch-probe-service.js.map +1 -0
  93. package/dist/application/mcp-migration-service.js +351 -0
  94. package/dist/application/mcp-migration-service.js.map +1 -0
  95. package/dist/application/mcp-native-host-check-service.js +154 -0
  96. package/dist/application/mcp-native-host-check-service.js.map +1 -0
  97. package/dist/application/mcp-receipt-registration-service.js +105 -0
  98. package/dist/application/mcp-receipt-registration-service.js.map +1 -0
  99. package/dist/application/mcp-receipt-removal-service.js +62 -0
  100. package/dist/application/mcp-receipt-removal-service.js.map +1 -0
  101. package/dist/application/mcp-remove-service.js +84 -21
  102. package/dist/application/mcp-remove-service.js.map +1 -1
  103. package/dist/application/mcp-scope-common.js +2 -4
  104. package/dist/application/mcp-scope-common.js.map +1 -1
  105. package/dist/application/mcp-setup-service.js +60 -21
  106. package/dist/application/mcp-setup-service.js.map +1 -1
  107. package/dist/application/mcp-stdio-probe.js +526 -0
  108. package/dist/application/mcp-stdio-probe.js.map +1 -0
  109. package/dist/application/mcp-test-connection-service.js +23 -50
  110. package/dist/application/mcp-test-connection-service.js.map +1 -1
  111. package/dist/application/merge-state-service.js +10 -16
  112. package/dist/application/merge-state-service.js.map +1 -1
  113. package/dist/application/multiroot/claude.js +34 -15
  114. package/dist/application/multiroot/claude.js.map +1 -1
  115. package/dist/application/multiroot/codex.js +9 -5
  116. package/dist/application/multiroot/codex.js.map +1 -1
  117. package/dist/application/multiroot-service.js +20 -11
  118. package/dist/application/multiroot-service.js.map +1 -1
  119. package/dist/application/parsers/project-block.js +25 -2
  120. package/dist/application/parsers/project-block.js.map +1 -1
  121. package/dist/application/paths-service.js +30 -31
  122. package/dist/application/paths-service.js.map +1 -1
  123. package/dist/application/plan-exec-batch-service.js +549 -0
  124. package/dist/application/plan-exec-batch-service.js.map +1 -0
  125. package/dist/application/plan-exec-decision-service.js +273 -0
  126. package/dist/application/plan-exec-decision-service.js.map +1 -0
  127. package/dist/application/project-md-service.js +2 -2
  128. package/dist/application/project-md-service.js.map +1 -1
  129. package/dist/application/project-md-upsert-service.js +4 -4
  130. package/dist/application/project-md-upsert-service.js.map +1 -1
  131. package/dist/application/project-tab-data.js +12 -7
  132. package/dist/application/project-tab-data.js.map +1 -1
  133. package/dist/application/release-data-service.js +2 -2
  134. package/dist/application/release-data-service.js.map +1 -1
  135. package/dist/application/resume-service.js +32 -11
  136. package/dist/application/resume-service.js.map +1 -1
  137. package/dist/application/retirement/apply.js +84 -0
  138. package/dist/application/retirement/apply.js.map +1 -1
  139. package/dist/application/retirement/attribution.js +31 -1
  140. package/dist/application/retirement/attribution.js.map +1 -1
  141. package/dist/application/retirement/graph.js +19 -1
  142. package/dist/application/retirement/graph.js.map +1 -1
  143. package/dist/application/retirement/journal.js +11 -0
  144. package/dist/application/retirement/journal.js.map +1 -1
  145. package/dist/application/retirement/prepare.js +89 -3
  146. package/dist/application/retirement/prepare.js.map +1 -1
  147. package/dist/application/retirement/preview.js +16 -0
  148. package/dist/application/retirement/preview.js.map +1 -1
  149. package/dist/application/retirement/resolve.js +65 -0
  150. package/dist/application/retirement/resolve.js.map +1 -1
  151. package/dist/application/self/host-states.js +2 -0
  152. package/dist/application/self/host-states.js.map +1 -1
  153. package/dist/application/self/install-skill.js +21 -17
  154. package/dist/application/self/install-skill.js.map +1 -1
  155. package/dist/application/self/mcp-config.js +392 -84
  156. package/dist/application/self/mcp-config.js.map +1 -1
  157. package/dist/application/self/mcp-via-state.js +62 -0
  158. package/dist/application/self/mcp-via-state.js.map +1 -0
  159. package/dist/application/self/uninstall.js +15 -5
  160. package/dist/application/self/uninstall.js.map +1 -1
  161. package/dist/application/session-close-service.js +24 -1
  162. package/dist/application/session-close-service.js.map +1 -1
  163. package/dist/application/session-create-service.js +7 -0
  164. package/dist/application/session-create-service.js.map +1 -1
  165. package/dist/application/session-resume-service.js +2 -2
  166. package/dist/application/session-resume-service.js.map +1 -1
  167. package/dist/application/sessions-service.js +2 -4
  168. package/dist/application/sessions-service.js.map +1 -1
  169. package/dist/application/skills-resolver-service.js +4 -4
  170. package/dist/application/skills-resolver-service.js.map +1 -1
  171. package/dist/application/source-boundary-policy.js +18 -7
  172. package/dist/application/source-boundary-policy.js.map +1 -1
  173. package/dist/application/source-remove-service.js +12 -1
  174. package/dist/application/source-remove-service.js.map +1 -1
  175. package/dist/application/sources-service.js +2 -2
  176. package/dist/application/sources-service.js.map +1 -1
  177. package/dist/application/status-service.js +4 -0
  178. package/dist/application/status-service.js.map +1 -1
  179. package/dist/application/visibility-doctor-service.js +1 -1
  180. package/dist/application/visibility-doctor-service.js.map +1 -1
  181. package/dist/application/workline-index-service.js +182 -27
  182. package/dist/application/workline-index-service.js.map +1 -1
  183. package/dist/application/workspace-init-service.js +89 -271
  184. package/dist/application/workspace-init-service.js.map +1 -1
  185. package/dist/application/workspace-materialization-service.js +300 -0
  186. package/dist/application/workspace-materialization-service.js.map +1 -0
  187. package/dist/application/worktree-service.js +32 -3
  188. package/dist/application/worktree-service.js.map +1 -1
  189. package/dist/cli/commands/capability.js +4 -7
  190. package/dist/cli/commands/capability.js.map +1 -1
  191. package/dist/cli/commands/claims.js +72 -0
  192. package/dist/cli/commands/claims.js.map +1 -0
  193. package/dist/cli/commands/dev-only.js +73 -25
  194. package/dist/cli/commands/dev-only.js.map +1 -1
  195. package/dist/cli/commands/flow.js +145 -42
  196. package/dist/cli/commands/flow.js.map +1 -1
  197. package/dist/cli/commands/index.js +4 -0
  198. package/dist/cli/commands/index.js.map +1 -1
  199. package/dist/cli/commands/mcp.js +752 -76
  200. package/dist/cli/commands/mcp.js.map +1 -1
  201. package/dist/cli/commands/multiroot.js +40 -7
  202. package/dist/cli/commands/multiroot.js.map +1 -1
  203. package/dist/cli/commands/resume.js +9 -3
  204. package/dist/cli/commands/resume.js.map +1 -1
  205. package/dist/cli/commands/retirement.js +9 -0
  206. package/dist/cli/commands/retirement.js.map +1 -1
  207. package/dist/cli/commands/session-create.js +1 -1
  208. package/dist/cli/commands/session-create.js.map +1 -1
  209. package/dist/cli/commands/skills.js +1 -1
  210. package/dist/cli/commands/skills.js.map +1 -1
  211. package/dist/cli/commands/status.js +72 -24
  212. package/dist/cli/commands/status.js.map +1 -1
  213. package/dist/cli/commands/tool.js +141 -0
  214. package/dist/cli/commands/tool.js.map +1 -0
  215. package/dist/cli/commands/workspace-init.js +5 -5
  216. package/dist/cli/commands/workspace-init.js.map +1 -1
  217. package/dist/cli/help-groups.js +6 -1
  218. package/dist/cli/help-groups.js.map +1 -1
  219. package/dist/cli/main.js +272 -67
  220. package/dist/cli/main.js.map +1 -1
  221. package/dist/cli/parser.js +8 -1
  222. package/dist/cli/parser.js.map +1 -1
  223. package/dist/cli/registry.js.map +1 -1
  224. package/dist/cli/render.js +15 -2
  225. package/dist/cli/render.js.map +1 -1
  226. package/dist/cli/tui/app.js +3 -2
  227. package/dist/cli/tui/app.js.map +1 -1
  228. package/dist/cli/tui/components/workspace-init-form.js +7 -7
  229. package/dist/cli/tui/components/workspace-init-form.js.map +1 -1
  230. package/dist/cli/tui/data/workflow-content.js +2 -2
  231. package/dist/cli/tui/data/workflow-content.js.map +1 -1
  232. package/dist/cli/tui/tabs/config-tab.js +4 -3
  233. package/dist/cli/tui/tabs/config-tab.js.map +1 -1
  234. package/dist/cli/tui/tabs/mcp-tab-helpers.js +72 -1
  235. package/dist/cli/tui/tabs/mcp-tab-helpers.js.map +1 -1
  236. package/dist/cli/tui/tabs/mcp-tab.js +101 -42
  237. package/dist/cli/tui/tabs/mcp-tab.js.map +1 -1
  238. package/dist/cli/tui/tabs/project-tab.js +18 -21
  239. package/dist/cli/tui/tabs/project-tab.js.map +1 -1
  240. package/dist/cli/tui/workspace-root.js +13 -0
  241. package/dist/cli/tui/workspace-root.js.map +1 -0
  242. package/dist/domain/capability/protocol.js +2 -2
  243. package/dist/domain/capability/protocol.js.map +1 -1
  244. package/dist/domain/database-tools.js +445 -0
  245. package/dist/domain/database-tools.js.map +1 -0
  246. package/dist/domain/degradation-notice.js +60 -0
  247. package/dist/domain/degradation-notice.js.map +1 -0
  248. package/dist/domain/elicitation.js +152 -0
  249. package/dist/domain/elicitation.js.map +1 -0
  250. package/dist/domain/flow/answer.js +173 -42
  251. package/dist/domain/flow/answer.js.map +1 -1
  252. package/dist/domain/flow/authority.js +184 -127
  253. package/dist/domain/flow/authority.js.map +1 -1
  254. package/dist/domain/flow/directive.js +47 -1
  255. package/dist/domain/flow/directive.js.map +1 -1
  256. package/dist/domain/flow/rules.js +7 -4
  257. package/dist/domain/flow/rules.js.map +1 -1
  258. package/dist/domain/flow/run-state.js +636 -31
  259. package/dist/domain/flow/run-state.js.map +1 -1
  260. package/dist/domain/harnesses.js +16 -0
  261. package/dist/domain/harnesses.js.map +1 -1
  262. package/dist/domain/mcp-entry.js +164 -28
  263. package/dist/domain/mcp-entry.js.map +1 -1
  264. package/dist/domain/postgres-object-search.js +320 -0
  265. package/dist/domain/postgres-object-search.js.map +1 -0
  266. package/dist/domain/redaction.js +47 -0
  267. package/dist/domain/redaction.js.map +1 -0
  268. package/dist/domain/retirement/proposal.js +1 -0
  269. package/dist/domain/retirement/proposal.js.map +1 -1
  270. package/dist/domain/structured-choice-stamp.js +32 -4
  271. package/dist/domain/structured-choice-stamp.js.map +1 -1
  272. package/dist/domain/workline-mcp-entry.js +34 -0
  273. package/dist/domain/workline-mcp-entry.js.map +1 -0
  274. package/dist/ports/postgres-tools.js +10 -0
  275. package/dist/ports/postgres-tools.js.map +1 -0
  276. package/dist/runtime/namespace-resolver.js +104 -18
  277. package/dist/runtime/namespace-resolver.js.map +1 -1
  278. package/package.json +5 -1
  279. package/skills/w/README.md +2 -2
  280. package/skills/w/SKILL.md +9 -10
  281. package/skills/w/artifacts/README.md +1 -1
  282. package/skills/w/commands/README.md +3 -3
  283. package/skills/w/commands/persist.md +3 -1
  284. package/skills/w/commands/resume.md +1 -1
  285. package/skills/w/commands/spec-new.md +2 -2
  286. package/skills/w/commands/status.md +1 -1
  287. package/skills/w/commands/workspace-init.md +9 -10
  288. package/skills/w/context/MANIFEST.json +1 -4
  289. package/skills/w/harness/HARNESS.md +1 -1
  290. package/skills/w/loops/quick-loop/LOOP.md +1 -1
  291. package/skills/w/modules/PROMPT-CONTINUITY.md +1 -1
  292. package/skills/w/modules/WORKSPACE-SCAFFOLD.md +17 -20
  293. package/skills/w/schemas/capability-descriptor.schema.json +1 -1
  294. package/dist/application/mcp-dbhub-launcher.js +0 -216
  295. package/dist/application/mcp-dbhub-launcher.js.map +0 -1
@@ -1,32 +1,84 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
1
+ import { closeSync, existsSync, fchmodSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import { parse as parseToml } from "smol-toml";
5
- import { isDbhubManagedEntry, } from "../domain/mcp-entry.js";
5
+ import { mcpEntryShapeForHost, } from "../domain/mcp-entry.js";
6
6
  import { crushGlobalMcpFile, opencodeGlobalMcpFile } from "./mcp-host-paths.js";
7
7
  import { backupFile, escapeRegex, purgeStaleBackups } from "./multiroot/paths.js";
8
8
  import { resolveWarpGlobalMcpPath, resolveWarpProjectMcpPath } from "./multiroot/warp.js";
9
9
  // Atomic replace: stage to a tmp sibling and rename over the target. At global
10
10
  // scope the targets are live user files (~/.claude.json is rewritten by any
11
11
  // running Claude Code session); rename keeps a concurrent reader from seeing a
12
- // truncated/half-written file. Lost-update between two writers remains possible
13
- // (would need locking) — accepted residual risk.
14
- let atomicWriteCounter = 0;
12
+ // truncated/half-written file. `withHostConfigLock` serializes Workline
13
+ // writers; an external host can still race, so every caller must read back.
14
+ const MAX_ATOMIC_TEMP_ATTEMPTS = 64;
15
15
  function atomicWriteFileSync(path, content) {
16
- const tmp = `${path}.${process.pid}.${++atomicWriteCounter}.tmp`;
16
+ let descriptor;
17
+ let tmp;
17
18
  try {
18
- writeFileSync(tmp, content, "utf-8");
19
+ const staged = reserveAtomicTempFile(path);
20
+ descriptor = staged.descriptor;
21
+ tmp = staged.path;
22
+ // The temporary file is exclusively created with 0600. Tighten it again
23
+ // where POSIX permissions exist; Windows ACL filesystems can reject
24
+ // fchmod, and that must not turn an otherwise safe atomic replacement
25
+ // into a platform-specific setup failure.
26
+ try {
27
+ fchmodSync(descriptor, 0o600);
28
+ }
29
+ catch {
30
+ // The exclusive create mode above remains the only portable guarantee.
31
+ }
32
+ writeFileSync(descriptor, content, "utf-8");
33
+ closeSync(descriptor);
34
+ descriptor = undefined;
19
35
  renameSync(tmp, path);
36
+ tmp = undefined;
20
37
  }
21
38
  catch (err) {
39
+ if (descriptor !== undefined)
40
+ closeAtomicTempFile(descriptor);
41
+ if (tmp !== undefined)
42
+ discardAtomicTempFile(tmp);
43
+ throw err;
44
+ }
45
+ }
46
+ function reserveAtomicTempFile(path) {
47
+ for (let attempt = 0; attempt < MAX_ATOMIC_TEMP_ATTEMPTS; attempt += 1) {
48
+ const tmp = `${path}.${process.pid}.${attempt + 1}.tmp`;
22
49
  try {
23
- unlinkSync(tmp);
50
+ return { path: tmp, descriptor: openSync(tmp, "wx", 0o600) };
24
51
  }
25
- catch {
26
- // tmp may not exist if writeFileSync failed before creating it
52
+ catch (error) {
53
+ if (filesystemErrorCode(error) === "EEXIST")
54
+ continue;
55
+ throw error;
27
56
  }
28
- throw err;
29
57
  }
58
+ throw new McpWriterError("no se pudo reservar un archivo temporal seguro para la configuración MCP", path);
59
+ }
60
+ function closeAtomicTempFile(descriptor) {
61
+ try {
62
+ closeSync(descriptor);
63
+ }
64
+ catch {
65
+ // The failed write is reported below; cleanup is best-effort.
66
+ }
67
+ }
68
+ function discardAtomicTempFile(path) {
69
+ try {
70
+ unlinkSync(path);
71
+ }
72
+ catch {
73
+ // Only an exclusively-created temp reaches this branch. It may already
74
+ // have been removed by the filesystem after a failed write.
75
+ }
76
+ }
77
+ function filesystemErrorCode(error) {
78
+ if (error === null || typeof error !== "object")
79
+ return undefined;
80
+ const code = error.code;
81
+ return typeof code === "string" ? code : undefined;
30
82
  }
31
83
  function claudeMcpFile(scope) {
32
84
  return scope.kind === "global"
@@ -36,58 +88,69 @@ function claudeMcpFile(scope) {
36
88
  function legacyClaudeSettingsFile(scope) {
37
89
  return join(scope.scopeDir, ".claude", "settings.json");
38
90
  }
39
- function cleanupLegacyClaudeMcpEntry(scope, name, dryRun) {
40
- if (dryRun)
41
- return;
42
- const legacy = legacyClaudeSettingsFile(scope);
43
- if (!existsSync(legacy))
44
- return;
91
+ /**
92
+ * The old Claude settings location is still live user configuration. A
93
+ * same-named server there is ours only when it has the exact current generated
94
+ * Claude shape. Invalid containers are also a conflict: writing a second
95
+ * location while one cannot be safely interpreted would make the operation
96
+ * partially mutate the user's configuration.
97
+ */
98
+ function inspectLegacyClaudeMcpEntry(scope, entry, replaceLegacy) {
99
+ const target = legacyClaudeSettingsFile(scope);
100
+ if (!existsSync(target))
101
+ return { state: "absent", target };
45
102
  let data;
46
103
  try {
47
- const text = readFileSync(legacy, "utf-8");
104
+ const text = readFileSync(target, "utf-8");
48
105
  if (text.trim().length === 0)
49
- return;
106
+ return { state: "absent", target };
50
107
  const parsed = JSON.parse(text);
51
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
52
- return;
108
+ if (!isRecord(parsed))
109
+ return { state: "conflict", target };
53
110
  data = parsed;
54
111
  }
55
112
  catch {
56
- return;
113
+ return { state: "conflict", target };
57
114
  }
58
- const mcpServers = data.mcpServers;
59
- if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers))
60
- return;
61
- const servers = mcpServers;
62
- if (!(name in servers))
63
- return;
64
- // Ownership guard: at global scope this file is the user's real
65
- // ~/.claude/settings.json — a same-named entry the tool never wrote stays.
66
- const existing = servers[name];
67
- if (!existing || typeof existing !== "object" || Array.isArray(existing))
115
+ const current = data.mcpServers;
116
+ if (current === undefined)
117
+ return { state: "absent", target };
118
+ if (!isRecord(current))
119
+ return { state: "conflict", target };
120
+ if (!(entry.name in current))
121
+ return { state: "absent", target };
122
+ const expected = mcpEntryShapeForHost("claude", entry);
123
+ const legacy = replaceLegacy === undefined ? undefined : mcpEntryShapeForHost("claude", replaceLegacy);
124
+ if (!isDeepStrictEqual(current[entry.name], expected) &&
125
+ !isDeepStrictEqual(current[entry.name], legacy)) {
126
+ return { state: "conflict", target };
127
+ }
128
+ return { state: "owned", target, data, servers: current };
129
+ }
130
+ function cleanupLegacyClaudeMcpEntry(scope, entry, dryRun, replaceLegacy) {
131
+ if (dryRun)
68
132
  return;
69
- if (!isDbhubManagedEntry(existing))
133
+ const legacy = inspectLegacyClaudeMcpEntry(scope, entry, replaceLegacy);
134
+ if (legacy.state !== "owned")
70
135
  return;
71
- servers[name] = undefined;
72
- const remaining = Object.fromEntries(Object.entries(servers).filter(([, v]) => v !== undefined));
136
+ legacy.servers[entry.name] = undefined;
137
+ const remaining = Object.fromEntries(Object.entries(legacy.servers).filter(([, value]) => value !== undefined));
73
138
  if (Object.keys(remaining).length === 0) {
74
- data.mcpServers = undefined;
139
+ legacy.data.mcpServers = undefined;
75
140
  }
76
141
  else {
77
- data.mcpServers = remaining;
142
+ legacy.data.mcpServers = remaining;
78
143
  }
79
- purgeStaleBackups(legacy);
80
- const legacyBackup = backupFile(legacy);
81
- atomicWriteFileSync(legacy, `${JSON.stringify(data, null, 2)}\n`);
144
+ purgeStaleBackups(legacy.target);
145
+ const legacyBackup = backupFile(legacy.target);
146
+ atomicWriteFileSync(legacy.target, `${JSON.stringify(legacy.data, null, 2)}\n`);
82
147
  discardBackup(legacyBackup);
83
148
  }
84
149
  export class McpWriterError extends Error {
85
150
  target;
86
- cause;
87
- constructor(message, target, cause) {
151
+ constructor(message, target) {
88
152
  super(message);
89
153
  this.target = target;
90
- this.cause = cause;
91
154
  this.name = "McpWriterError";
92
155
  }
93
156
  }
@@ -100,6 +163,16 @@ export class McpWriterError extends Error {
100
163
  * `assertNeverHost` default turns that into a compile error instead.
101
164
  */
102
165
  export function writeMcpEntry(host, entry, scope, opts = {}) {
166
+ if (opts.dryRun)
167
+ return writeMcpEntryUnlocked(host, entry, scope, opts);
168
+ return withHostConfigLock(hostConfigTarget(host, scope), () => writeMcpEntryUnlocked(host, entry, scope, opts));
169
+ }
170
+ export function removeMcpEntry(host, entry, scope, opts = {}) {
171
+ if (opts.dryRun)
172
+ return removeMcpEntryUnlocked(host, entry, scope, opts);
173
+ return withHostConfigLock(hostConfigTarget(host, scope), () => removeMcpEntryUnlocked(host, entry, scope, opts));
174
+ }
175
+ function writeMcpEntryUnlocked(host, entry, scope, opts) {
103
176
  switch (host) {
104
177
  case "claude":
105
178
  return writeClaudeMcpEntry(entry, scope, opts);
@@ -119,7 +192,7 @@ export function writeMcpEntry(host, entry, scope, opts = {}) {
119
192
  return assertNeverHost(host);
120
193
  }
121
194
  }
122
- export function removeMcpEntry(host, entry, scope, opts = {}) {
195
+ function removeMcpEntryUnlocked(host, entry, scope, opts) {
123
196
  switch (host) {
124
197
  case "claude":
125
198
  return removeClaudeMcpEntry(entry, scope, opts);
@@ -139,6 +212,62 @@ export function removeMcpEntry(host, entry, scope, opts = {}) {
139
212
  return assertNeverHost(host);
140
213
  }
141
214
  }
215
+ function hostConfigTarget(host, scope) {
216
+ switch (host) {
217
+ case "claude":
218
+ return claudeMcpFile(scope);
219
+ case "codex":
220
+ return join(scope.scopeDir, ".codex", "config.toml");
221
+ case "warp":
222
+ return warpMcpFile(scope);
223
+ case "gemini":
224
+ return geminiMcpFile(scope);
225
+ case "kimi":
226
+ return kimiMcpFile(scope);
227
+ case "opencode":
228
+ return opencodeMcpFile(scope);
229
+ case "crush":
230
+ return crushMcpFile(scope);
231
+ default:
232
+ return assertNeverHost(host);
233
+ }
234
+ }
235
+ /**
236
+ * Serializes the whole read-modify-write window, not merely the final rename.
237
+ * A competing writer fails explicitly instead of silently dropping one host
238
+ * entry. A stale lock is intentionally not guessed away: removing it requires
239
+ * an operator decision because it may belong to another live process.
240
+ */
241
+ function withHostConfigLock(target, work) {
242
+ const lock = `${target}.agent-workflow.lock`;
243
+ mkdirSync(dirname(lock), { recursive: true });
244
+ let descriptor;
245
+ try {
246
+ descriptor = openSync(lock, "wx", 0o600);
247
+ }
248
+ catch (error) {
249
+ if (filesystemErrorCode(error) === "EEXIST") {
250
+ throw new McpWriterError("otro proceso está actualizando esta configuración MCP; reintentá cuando termine", target);
251
+ }
252
+ throw error;
253
+ }
254
+ try {
255
+ return work();
256
+ }
257
+ finally {
258
+ try {
259
+ closeSync(descriptor);
260
+ }
261
+ finally {
262
+ try {
263
+ unlinkSync(lock);
264
+ }
265
+ catch {
266
+ // A remaining lock is safer than claiming a concurrent write succeeded.
267
+ }
268
+ }
269
+ }
270
+ }
142
271
  /** Compile-time exhaustiveness; at runtime it refuses instead of writing somewhere wrong. */
143
272
  function assertNeverHost(host) {
144
273
  const id = String(host);
@@ -169,57 +298,30 @@ function crushMcpFile(scope) {
169
298
  function kimiMcpFile(scope) {
170
299
  return join(scope.scopeDir, ".kimi-code", "mcp.json");
171
300
  }
172
- // Per-host serialization of a dbhub McpEntry into the host's JSON schema.
173
- // Exhaustive for the same reason the dispatchers are: a new host must state its
174
- // shape, not inherit Claude's by falling off the end.
175
- function mcpShapeFor(host, entry) {
176
- switch (host) {
177
- case "opencode":
178
- // OpenCode: { type: "local", command: [cmd, ...args], environment, enabled }
179
- return {
180
- type: "local",
181
- command: [entry.command, ...entry.args],
182
- environment: { ...entry.env },
183
- enabled: true,
184
- };
185
- case "crush":
186
- // Crush: { type: "stdio", command, args, env }
187
- return {
188
- type: "stdio",
189
- command: entry.command,
190
- args: [...entry.args],
191
- env: { ...entry.env },
192
- };
193
- case "claude":
194
- case "codex":
195
- case "warp":
196
- case "gemini":
197
- // Kimi's stdio entry is `command` + optional `args`/`env`/`cwd` — the same
198
- // shape, verified against v0.29.2's MCP config schema.
199
- case "kimi":
200
- return expectedClaudeShape(entry);
201
- default:
202
- return assertNeverHost(host);
203
- }
204
- }
205
301
  // Generic writer for hosts whose MCP config is a JSON file with a top-level
206
302
  // object keyed by server name (Gemini `mcpServers`, OpenCode/Crush `mcp`).
207
303
  // Preserves other top-level keys and other server entries; idempotent; dry-run;
208
304
  // transient backup purged on success.
209
305
  function writeJsonMcpEntry(host, file, topKey, entry, opts) {
210
306
  const data = readJsonFile(file);
211
- const bag = ensureRecord(data, topKey);
307
+ const bag = recordContainer(data, topKey, file) ?? {};
212
308
  const existing = bag[entry.name];
213
- const expected = mcpShapeFor(host, entry);
309
+ const expected = mcpEntryShapeForHost(host, entry);
310
+ const legacy = opts.replaceLegacy === undefined ? undefined : mcpEntryShapeForHost(host, opts.replaceLegacy);
214
311
  if (isDeepStrictEqual(existing, expected)) {
215
312
  return resultSkipped(host, file, entry.name);
216
313
  }
314
+ // A matching name is not enough to establish ownership. Replacing a server
315
+ // whose shape differs would overwrite somebody else's configuration.
316
+ if (existing !== undefined && !isDeepStrictEqual(existing, legacy)) {
317
+ return resultConflict(host, file, entry.name);
318
+ }
217
319
  bag[entry.name] = expected;
218
320
  data[topKey] = bag;
219
321
  const newJson = `${JSON.stringify(data, null, 2)}\n`;
220
322
  if (opts.dryRun) {
221
323
  return resultDryRun(host, file, entry.name, [
222
- `${topKey}.${entry.name}: ${existing ? "update" : "add"}`,
324
+ `${topKey}.${entry.name}: ${existing === undefined ? "add" : "replace known legacy"}`,
223
325
  ]);
224
326
  }
225
327
  mkdirSync(dirname(file), { recursive: true });
@@ -231,11 +333,18 @@ function writeJsonMcpEntry(host, file, topKey, entry, opts) {
231
333
  }
232
334
  function removeJsonMcpEntry(host, file, topKey, entry, opts) {
233
335
  const data = readJsonFile(file);
234
- const bag = ensureRecord(data, topKey);
336
+ const bag = recordContainer(data, topKey, file);
337
+ if (bag === undefined)
338
+ return resultSkipped(host, file, entry.name);
235
339
  const existing = bag[entry.name];
340
+ const expected = mcpEntryShapeForHost(host, entry);
341
+ const legacy = opts.replaceLegacy === undefined ? undefined : mcpEntryShapeForHost(host, opts.replaceLegacy);
236
342
  if (existing === undefined) {
237
343
  return resultSkipped(host, file, entry.name);
238
344
  }
345
+ if (!isDeepStrictEqual(existing, expected) && !isDeepStrictEqual(existing, legacy)) {
346
+ return resultConflict(host, file, entry.name);
347
+ }
239
348
  bag[entry.name] = undefined;
240
349
  const remaining = Object.fromEntries(Object.entries(bag).filter(([, v]) => v !== undefined));
241
350
  data[topKey] = Object.keys(remaining).length === 0 ? undefined : remaining;
@@ -250,33 +359,60 @@ function removeJsonMcpEntry(host, file, topKey, entry, opts) {
250
359
  discardBackup(backup);
251
360
  return resultRemoved(host, file, entry.name, null);
252
361
  }
253
- // Claude = the generic JSON writer plus the legacy .claude/settings.json sweep,
254
- // which runs exactly when the original inline pipeline did: on idempotent skips
255
- // (respecting dry-run) and after a real write/remove — never on dry-run diffs.
362
+ // Claude = the generic JSON writer plus an ownership-checked legacy
363
+ // .claude/settings.json sweep. A foreign or malformed legacy entry stops before
364
+ // the primary file changes; an exact own entry is swept on a real write/remove.
256
365
  function writeClaudeMcpEntry(entry, scope, opts) {
366
+ const legacy = inspectLegacyClaudeMcpEntry(scope, entry, opts.replaceLegacy);
367
+ if (legacy.state === "conflict")
368
+ return resultConflict("claude", legacy.target, entry.name);
257
369
  const res = writeJsonMcpEntry("claude", claudeMcpFile(scope), "mcpServers", entry, opts);
258
- if (res.action === "skipped-idempotent")
259
- cleanupLegacyClaudeMcpEntry(scope, entry.name, opts.dryRun ?? false);
260
- if (res.action === "written")
261
- cleanupLegacyClaudeMcpEntry(scope, entry.name, false);
370
+ // The canonical entry may already be present while the old location still
371
+ // needs a cleanup. That is still an observable write: report it as such so
372
+ // a caller's dry-run and first-mutation guard do not mistake it for a noop.
373
+ if (legacy.state === "owned" && res.action === "skipped-idempotent") {
374
+ if (opts.dryRun)
375
+ return resultDryRun("claude", legacy.target, entry.name, [
376
+ `mcpServers.${entry.name}: remove legacy entry`,
377
+ ]);
378
+ try {
379
+ cleanupLegacyClaudeMcpEntry(scope, entry, false, opts.replaceLegacy);
380
+ return resultWritten("claude", legacy.target, entry.name, null);
381
+ }
382
+ catch {
383
+ return partialLegacyCleanup(res, legacy.target);
384
+ }
385
+ }
386
+ if (legacy.state === "owned" && res.action === "written") {
387
+ try {
388
+ cleanupLegacyClaudeMcpEntry(scope, entry, false, opts.replaceLegacy);
389
+ }
390
+ catch {
391
+ // The primary descriptor is already durable. Do not hide that mutation
392
+ // or roll it back blindly: the old location might have been changed by a
393
+ // host meanwhile. The caller will fail readback, withhold the receipt,
394
+ // and a later explicit setup/migration can retry exact cleanup.
395
+ return partialLegacyCleanup(res, legacy.target);
396
+ }
397
+ }
262
398
  return res;
263
399
  }
264
400
  function writeCodexMcpEntry(entry, scope, opts) {
265
401
  const configFile = join(scope.scopeDir, ".codex", "config.toml");
266
402
  const oldContent = existsSync(configFile) ? readFileSync(configFile, "utf-8") : "";
267
- let parsed;
268
- try {
269
- parsed = oldContent.length > 0 ? parseToml(oldContent) : {};
270
- }
271
- catch (err) {
272
- throw new McpWriterError(`config.toml inválido en ${configFile}`, configFile, err.message);
273
- }
274
- const mcpServers = (parsed.mcp_servers ?? {});
403
+ const parsed = parseCodexConfig(oldContent, configFile);
404
+ const mcpServers = recordContainer(parsed, "mcp_servers", configFile) ?? {};
275
405
  const existing = mcpServers[entry.name];
276
- const expected = expectedClaudeShape(entry);
406
+ const expected = mcpEntryShapeForHost("codex", entry);
407
+ const legacy = opts.replaceLegacy === undefined
408
+ ? undefined
409
+ : mcpEntryShapeForHost("codex", opts.replaceLegacy);
277
410
  if (isDeepStrictEqual(existing, expected)) {
278
411
  return resultSkipped("codex", configFile, entry.name);
279
412
  }
413
+ if (existing !== undefined && !isDeepStrictEqual(existing, legacy)) {
414
+ return resultConflict("codex", configFile, entry.name);
415
+ }
280
416
  const cleaned = removeCodexMcpBlocks(oldContent, entry.name);
281
417
  const newContent = appendCodexMcpBlocks(cleaned, entry);
282
418
  if (newContent === oldContent) {
@@ -284,8 +420,8 @@ function writeCodexMcpEntry(entry, scope, opts) {
284
420
  }
285
421
  if (opts.dryRun) {
286
422
  return resultDryRun("codex", configFile, entry.name, [
287
- `[mcp_servers.${entry.name}]: ${existing ? "update" : "add"}`,
288
- `[mcp_servers.${entry.name}.env]: ${existing ? "update" : "add"}`,
423
+ `[mcp_servers.${entry.name}]: ${existing === undefined ? "add" : "replace known legacy"}`,
424
+ `[mcp_servers.${entry.name}.env]: add`,
289
425
  ]);
290
426
  }
291
427
  mkdirSync(dirname(configFile), { recursive: true });
@@ -296,23 +432,55 @@ function writeCodexMcpEntry(entry, scope, opts) {
296
432
  return resultWritten("codex", configFile, entry.name, null);
297
433
  }
298
434
  function removeClaudeMcpEntry(entry, scope, opts) {
435
+ const legacy = inspectLegacyClaudeMcpEntry(scope, entry, opts.replaceLegacy);
436
+ if (legacy.state === "conflict")
437
+ return resultConflict("claude", legacy.target, entry.name);
299
438
  const res = removeJsonMcpEntry("claude", claudeMcpFile(scope), "mcpServers", entry, opts);
300
- if (res.action === "skipped-idempotent")
301
- cleanupLegacyClaudeMcpEntry(scope, entry.name, opts.dryRun ?? false);
302
- if (res.action === "removed")
303
- cleanupLegacyClaudeMcpEntry(scope, entry.name, false);
439
+ // See the matching setup path above: removing an own entry left only in the
440
+ // legacy file is a removal, not an idempotent skip.
441
+ if (legacy.state === "owned" && res.action === "skipped-idempotent") {
442
+ if (opts.dryRun)
443
+ return resultDryRun("claude", legacy.target, entry.name, [
444
+ `mcpServers.${entry.name}: remove legacy entry`,
445
+ ]);
446
+ try {
447
+ cleanupLegacyClaudeMcpEntry(scope, entry, false, opts.replaceLegacy);
448
+ return resultRemoved("claude", legacy.target, entry.name, null);
449
+ }
450
+ catch {
451
+ return partialLegacyCleanup(res, legacy.target);
452
+ }
453
+ }
454
+ if (legacy.state === "owned" && res.action === "removed") {
455
+ try {
456
+ cleanupLegacyClaudeMcpEntry(scope, entry, false, opts.replaceLegacy);
457
+ }
458
+ catch {
459
+ // As in setup, the primary removal is real but legacy retirement is not
460
+ // complete. Keep the receipt and registry through the caller's partial
461
+ // error path so the next explicit remove can recover safely.
462
+ return partialLegacyCleanup(res, legacy.target);
463
+ }
464
+ }
304
465
  return res;
305
466
  }
306
467
  function removeCodexMcpEntry(entry, scope, opts) {
307
468
  const configFile = join(scope.scopeDir, ".codex", "config.toml");
308
469
  const oldContent = existsSync(configFile) ? readFileSync(configFile, "utf-8") : "";
309
- if (oldContent.length > 0) {
310
- try {
311
- parseToml(oldContent);
312
- }
313
- catch (err) {
314
- throw new McpWriterError(`config.toml inválido en ${configFile}`, configFile, err.message);
315
- }
470
+ const parsed = parseCodexConfig(oldContent, configFile);
471
+ const mcpServers = recordContainer(parsed, "mcp_servers", configFile);
472
+ if (mcpServers === undefined)
473
+ return resultSkipped("codex", configFile, entry.name);
474
+ const existing = mcpServers[entry.name];
475
+ if (existing === undefined) {
476
+ return resultSkipped("codex", configFile, entry.name);
477
+ }
478
+ const legacy = opts.replaceLegacy === undefined
479
+ ? undefined
480
+ : mcpEntryShapeForHost("codex", opts.replaceLegacy);
481
+ if (!isDeepStrictEqual(existing, mcpEntryShapeForHost("codex", entry)) &&
482
+ !isDeepStrictEqual(existing, legacy)) {
483
+ return resultConflict("codex", configFile, entry.name);
316
484
  }
317
485
  const newContent = removeCodexMcpBlocks(oldContent, entry.name);
318
486
  if (newContent === oldContent) {
@@ -352,30 +520,42 @@ function readJsonFile(file) {
352
520
  return {};
353
521
  try {
354
522
  const parsed = JSON.parse(text);
355
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
523
+ if (isRecord(parsed))
356
524
  return parsed;
357
- }
358
525
  throw new Error("contenido no es un objeto JSON");
359
526
  }
360
- catch (err) {
361
- throw new McpWriterError(`JSON inválido en ${file}`, file, err.message);
527
+ catch {
528
+ throw new McpWriterError(`JSON inválido en ${file}`, file);
362
529
  }
363
530
  }
364
- function ensureRecord(parent, key) {
531
+ function parseCodexConfig(content, configFile) {
532
+ if (content.trim().length === 0)
533
+ return {};
534
+ try {
535
+ const parsed = parseToml(content);
536
+ if (isRecord(parsed))
537
+ return parsed;
538
+ throw new Error("contenido TOML no es un objeto");
539
+ }
540
+ catch {
541
+ throw new McpWriterError(`config.toml inválido en ${configFile}`, configFile);
542
+ }
543
+ }
544
+ /**
545
+ * Missing MCP containers are created only by a successful write. A present
546
+ * scalar, null, or array is malformed user configuration and must never be
547
+ * replaced with an empty object behind their back.
548
+ */
549
+ function recordContainer(parent, key, file) {
365
550
  const current = parent[key];
366
- if (current && typeof current === "object" && !Array.isArray(current)) {
551
+ if (current === undefined)
552
+ return undefined;
553
+ if (isRecord(current))
367
554
  return current;
368
- }
369
- const fresh = {};
370
- parent[key] = fresh;
371
- return fresh;
555
+ throw new McpWriterError(`contenedor '${key}' inválido en ${file}`, file);
372
556
  }
373
- function expectedClaudeShape(entry) {
374
- return {
375
- command: entry.command,
376
- args: [...entry.args],
377
- env: { ...entry.env },
378
- };
557
+ function isRecord(value) {
558
+ return value !== null && typeof value === "object" && !Array.isArray(value);
379
559
  }
380
560
  // backupFile/purgeStaleBackups/escapeRegex live in multiroot/paths.ts (single
381
561
  // backup mechanism for host-config files: keep-latest, purge-then-copy).
@@ -420,6 +600,8 @@ export function appendCodexMcpBlocks(text, entry) {
420
600
  buffer.push(`[mcp_servers.${entry.name}]`);
421
601
  buffer.push(`command = ${tomlString(entry.command)}`);
422
602
  buffer.push(`args = [${entry.args.map(tomlString).join(", ")}]`);
603
+ if (entry.optional)
604
+ buffer.push("required = false");
423
605
  buffer.push("");
424
606
  buffer.push(`[mcp_servers.${entry.name}.env]`);
425
607
  for (const [k, v] of Object.entries(entry.env)) {
@@ -444,6 +626,19 @@ function resultSkipped(host, target, name) {
444
626
  function resultDryRun(host, target, name, diff) {
445
627
  return { ...action(host, target, name, "dry-run", null), diff };
446
628
  }
629
+ function resultConflict(host, target, name) {
630
+ return action(host, target, name, "conflict", null);
631
+ }
632
+ function partialLegacyCleanup(result, target) {
633
+ return {
634
+ ...result,
635
+ partial: {
636
+ code: "MCP_LEGACY_CLEANUP_FAILED",
637
+ target,
638
+ message: "No se pudo retirar la ubicación legacy de Claude; no recargues el host y reintentá la operación.",
639
+ },
640
+ };
641
+ }
447
642
  function action(host, target, name, status, backup) {
448
643
  return { host, target, name, action: status, backup };
449
644
  }