codedebrief 0.11.0__py3-none-any.whl

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 (48) hide show
  1. codedebrief/__init__.py +12 -0
  2. codedebrief/analysis/__init__.py +16 -0
  3. codedebrief/analysis/common.py +527 -0
  4. codedebrief/analysis/discovery.py +100 -0
  5. codedebrief/analysis/languages/__init__.py +6 -0
  6. codedebrief/analysis/languages/_common.py +68 -0
  7. codedebrief/analysis/languages/c.py +96 -0
  8. codedebrief/analysis/languages/cpp.py +146 -0
  9. codedebrief/analysis/languages/csharp.py +137 -0
  10. codedebrief/analysis/languages/go.py +157 -0
  11. codedebrief/analysis/languages/java.py +158 -0
  12. codedebrief/analysis/languages/php.py +83 -0
  13. codedebrief/analysis/languages/ruby.py +75 -0
  14. codedebrief/analysis/languages/rust.py +96 -0
  15. codedebrief/analysis/project.py +373 -0
  16. codedebrief/analysis/python.py +939 -0
  17. codedebrief/analysis/registry.py +320 -0
  18. codedebrief/analysis/treesitter.py +884 -0
  19. codedebrief/analysis/typescript.py +1019 -0
  20. codedebrief/artifacts.py +49 -0
  21. codedebrief/cli.py +585 -0
  22. codedebrief/config.py +226 -0
  23. codedebrief/doctor.py +175 -0
  24. codedebrief/install.py +441 -0
  25. codedebrief/mcp_server.py +2720 -0
  26. codedebrief/model.py +189 -0
  27. codedebrief/py.typed +1 -0
  28. codedebrief/quality.py +392 -0
  29. codedebrief/query.py +641 -0
  30. codedebrief/render/__init__.py +6 -0
  31. codedebrief/render/assets/generated/codedebrief-viewer-runtime.iife.js +10 -0
  32. codedebrief/render/assets/panels.js +462 -0
  33. codedebrief/render/assets/shell.js +1649 -0
  34. codedebrief/render/assets/styles.css +1715 -0
  35. codedebrief/render/assets/tree.js +616 -0
  36. codedebrief/render/html.py +191 -0
  37. codedebrief/render/markdown.py +153 -0
  38. codedebrief/render/payload.py +326 -0
  39. codedebrief/render/snapshot.py +769 -0
  40. codedebrief/schema/codedebrief.schema.json +449 -0
  41. codedebrief/util.py +65 -0
  42. codedebrief/validation.py +214 -0
  43. codedebrief-0.11.0.dist-info/METADATA +426 -0
  44. codedebrief-0.11.0.dist-info/RECORD +48 -0
  45. codedebrief-0.11.0.dist-info/WHEEL +4 -0
  46. codedebrief-0.11.0.dist-info/entry_points.txt +2 -0
  47. codedebrief-0.11.0.dist-info/licenses/LICENSE +176 -0
  48. codedebrief-0.11.0.dist-info/licenses/NOTICE +9 -0
codedebrief/install.py ADDED
@@ -0,0 +1,441 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ START = "<!-- codedebrief:instructions:start -->"
8
+ END = "<!-- codedebrief:instructions:end -->"
9
+ LOCAL_NOTES_START = "<!-- codedebrief:local-notes:start -->"
10
+ LOCAL_NOTES_END = "<!-- codedebrief:local-notes:end -->"
11
+ LOCAL_NOTES_HINT = (
12
+ "<!-- Add project-specific local notes here. This section is preserved by "
13
+ "`codedebrief setup-agent`. -->"
14
+ )
15
+ AGENT_INSTRUCTION_TARGETS = {
16
+ "codex": Path("AGENTS.md"),
17
+ "claude": Path("CLAUDE.md"),
18
+ "gemini": Path("GEMINI.md"),
19
+ "cursor": Path(".cursor/rules/codedebrief.mdc"),
20
+ }
21
+ AGENT_SKILL_TARGETS = {
22
+ "codex": Path(".agents/skills/codedebrief/SKILL.md"),
23
+ "claude": Path(".claude/skills/codedebrief/SKILL.md"),
24
+ "gemini": Path(".gemini/skills/codedebrief/SKILL.md"),
25
+ }
26
+ MCP_CONFIG_TARGETS = ("codex", "claude", "gemini", "cursor")
27
+ CODEX_MCP_START = "# codedebrief:mcp-config:start"
28
+ CODEX_MCP_END = "# codedebrief:mcp-config:end"
29
+
30
+ SKILL_DESCRIPTION = (
31
+ "Use when answering codebase logic, behavior, workflow/flusso, decision, "
32
+ "state/status, changed-code context, testing, or visual workflow/canvas questions in a "
33
+ "project that uses CodeDebrief. Prefer the CodeDebrief MCP agent_context tool before "
34
+ "broad searches, and use snapshot_slice, the canonical workflow_slice Mermaid "
35
+ "visual, or viewer_targets when the user asks to show, visualize, render, diagram, "
36
+ "canvas, workflow, flusso, or workflow_slice."
37
+ )
38
+
39
+ SKILL_TEMPLATE = f"""---
40
+ name: codedebrief
41
+ description: {SKILL_DESCRIPTION}
42
+ ---
43
+
44
+ # CodeDebrief
45
+
46
+ Use CodeDebrief as the first path for code-logic questions in projects with CodeDebrief
47
+ configured.
48
+
49
+ ## Default Workflow
50
+
51
+ 1. Call MCP `agent_context` before broad file-by-file search. Pass the user question plus
52
+ changed files, current file, selected code, flow id, symbol, or dependency path when
53
+ available.
54
+ 2. Inspect `workflow_slice`. Answer from deterministic fields: presentation,
55
+ primary/supporting flows, ordered steps, decisions, source ranges, calls, and visuals.
56
+ 3. Use `expand_slice`, `workflow_path`, `explain_flow`, `explain_node`, or `explain_edge`
57
+ only when the first slice is too narrow.
58
+ 4. Treat CodeDebrief artifacts as part of done for workflow-relevant changes. After each
59
+ meaningful source, route, config, or agent-instruction change, refresh the graph before
60
+ finalizing: run MCP `update_codedebrief` when available, otherwise run
61
+ `codedebrief update`, then run `validate_artifacts` or
62
+ `codedebrief validate --check-sync`. Keep `codedebrief-out/codedebrief.json` and
63
+ `codedebrief-out/codedebrief.md` synchronized when they change.
64
+
65
+ ## Visual Workflow Requests
66
+
67
+ When the user asks to show a workflow, workflow_slice, diagram, visual flow, canvas,
68
+ flusso, or similar code path:
69
+
70
+ 1. Call `agent_context` with `include_visual=true` when available. Use a stable token
71
+ budget for similar requests, but inspect the full returned `workflow_slice` before
72
+ deciding what to show.
73
+ 2. If the tool result is too large, saved externally, truncated, or missing the exact
74
+ `workflow_slice.presentation.canonical_visual.diagram`, retry with a smaller
75
+ `token_budget` and a narrower `flow_id`, `symbol`, `current_file`, or `scope`. Do not
76
+ recover by listing flows and hand-building a diagram.
77
+ 3. If the first slice omits relevant callers, callees, branches, adjacent flows, or paths,
78
+ use the returned slice handles with `expand_slice` or `workflow_path` before answering.
79
+ 4. Choose the first visible depth yourself: show the clearest useful subset of the
80
+ selected workflow, not every low-signal implementation node, but do not remove facts
81
+ needed to understand the logical path.
82
+ 5. Render `workflow_slice.presentation.canonical_visual.diagram` exactly as the default
83
+ chat visual only when the client renders Mermaid blocks inline. It is the canonical
84
+ top-to-bottom Mermaid graph and should be preferred over SVG snapshots for repeated
85
+ chat answers.
86
+ 6. Call `snapshot_slice` using `workflow_slice.id`, `workflow_slice.handle.flow_ids`, and
87
+ any `workflow_slice` handles returned by CodeDebrief to persist local artifacts. In
88
+ clients that cannot render Mermaid inline, or when Mermaid would appear as a raw code
89
+ block, call `snapshot_slice` with `include_svg=false` and provide
90
+ `artifact.mermaid_path`, `artifact.mermaid_markdown_path`, or
91
+ `artifact.mermaid_open_command` as the visual result before prose. Do not paste a long
92
+ Mermaid code block as the primary visual unless the user explicitly asks for raw or
93
+ copyable Mermaid.
94
+ 7. Do not render `snapshot.svg` inline by default. SVG/HTML snapshot artifacts are for
95
+ explicit SVG requests or local inspection only, because their layout may differ from
96
+ Mermaid and can overlap text in some clients. Keep the returned `diagram_hash` visible
97
+ when useful. Do not synthesize a new Mermaid diagram and do not add limits, error
98
+ codes, branches, or service steps that are absent from the `workflow_slice` payload.
99
+ CodeDebrief Mermaid visuals are vertical/top-to-bottom by default. Use a horizontal
100
+ layout only when the user explicitly asks for a compact horizontal overview.
101
+ 8. Do not read source files to rebuild, relabel, or extend the diagram. Source reads are
102
+ allowed only as follow-up explanation after the deterministic CodeDebrief visual is
103
+ shown, and they must not change the displayed nodes, edges, labels, or branches.
104
+ 9. If neither exact canonical Mermaid nor a returned Mermaid artifact can be used, say
105
+ that the deterministic visual cannot be shown in this client and provide `viewer_targets`;
106
+ never create a replacement Mermaid diagram from prose or source reads.
107
+ 10. Say that the displayed diagram is a bounded summary of the selected logic and can be
108
+ expanded. If the user asks for a more language-friendly version, rewrite the technical
109
+ block labels in simple wording using the language of the user's request. Present that
110
+ as a human-friendly translation derived only from returned node, edge, decision, and
111
+ source fields.
112
+ 11. End with concise follow-up choices in the user's language: simplify the labels into
113
+ language-friendly wording, expand omitted nodes/branches/adjacent flows, or explore a
114
+ related area or deeper path.
115
+ 12. Also provide the `viewer_targets` command and hash
116
+ target so the user can open the same visual in `codedebrief view`.
117
+ 13. Treat `workflow_slice.presentation` as supporting context for this request, not as the
118
+ primary output.
119
+ 14. Keep the textual summary short and secondary. Do not answer with raw JSON or YAML unless
120
+ the user explicitly asks for it.
121
+
122
+ ## Guardrails
123
+
124
+ - MCP is local-first and deterministic; do not ask for provider keys for the primary
125
+ workflow.
126
+ - Treat language-friendly labels as a presentation layer derived from deterministic
127
+ workflow facts.
128
+ - Use CodeDebrief to explain modeled code logic, not to present possible defects.
129
+ - Use `codedebrief view` only for the human manual UI.
130
+ """
131
+
132
+
133
+ def _instruction_block(local_notes: str = "") -> str:
134
+ notes = local_notes.strip() or LOCAL_NOTES_HINT
135
+ return f"""{START}
136
+ ## CodeDebrief
137
+
138
+ This project uses CodeDebrief to keep decision flows synchronized with the source code.
139
+
140
+ For codebase questions about behavior, decisions, workflow structure, or changed-code context:
141
+
142
+ 1. Prefer the CodeDebrief MCP `agent_context` tool before broad file-by-file searches.
143
+ 2. Use `agent_context` for substantial changes, passing changed files, selected code,
144
+ current file, flow id, symbol, or dependency path when available; inspect
145
+ its returned `workflow_slice` before answering.
146
+ 3. When the user asks to show a workflow, flusso, visual flow, canvas, or
147
+ `workflow_slice`, prefer the canonical Mermaid visual: render
148
+ `workflow_slice.presentation.canonical_visual.diagram` exactly as returned only when
149
+ the client renders Mermaid inline. If the client cannot render Mermaid inline, or if
150
+ Mermaid would appear as a raw code block, call `snapshot_slice` with
151
+ `include_svg=false` and provide `artifact.mermaid_path`,
152
+ `artifact.mermaid_markdown_path`, or `artifact.mermaid_open_command` as the visual
153
+ result before prose. Do not paste a long Mermaid code block as the primary visual
154
+ unless the user explicitly asks for raw or copyable Mermaid. Do not render
155
+ `snapshot.svg` inline by default; SVG artifacts are for explicit SVG requests or local
156
+ inspection because their layout can differ from Mermaid.
157
+ Keep CodeDebrief visuals vertical/top-to-bottom by default. Use a horizontal layout
158
+ only when the user explicitly asks for a compact horizontal overview.
159
+ Inspect the full returned `workflow_slice` before deciding what to show. Choose the
160
+ first visible depth yourself: show the clearest useful subset, then say that the
161
+ displayed diagram is a bounded summary and can be expanded.
162
+ If the CodeDebrief result is too large, saved externally, truncated, or missing the exact
163
+ canonical visual, retry with a smaller `token_budget` and narrower `flow_id`, `symbol`,
164
+ `current_file`, or `scope`; do not recover by listing flows and hand-building a
165
+ diagram.
166
+ Do not synthesize a new Mermaid diagram and do not add limits, error codes, branches,
167
+ or service steps that are absent from the `workflow_slice` payload. Do not read source
168
+ files to rebuild, relabel, or extend the diagram; source reads are only follow-up
169
+ explanation after the deterministic visual is shown and must not change displayed
170
+ nodes, edges, labels, or branches. If neither exact canonical Mermaid nor a returned
171
+ Mermaid artifact can be used, say so and provide `viewer_targets` instead of creating
172
+ a replacement Mermaid diagram. If the user asks for a more language-friendly version, rewrite the
173
+ technical block labels in simple wording using the language of the user's request. This
174
+ is allowed only as a separate presentation layer derived from returned node, edge,
175
+ decision, and source fields. End visual answers with concise options in the user's
176
+ language: simplify labels, expand omitted nodes/branches/adjacent flows, or explore a
177
+ related area. Show raw JSON or YAML only when explicitly requested.
178
+ 4. Use `expand_slice`, `workflow_path`, `snapshot_slice`, `explain_flow`, `explain_node`,
179
+ or `explain_edge` only when the first slice needs more precise context.
180
+ 5. Use `codedebrief view ...` only when a human wants the manual UI flowchart.
181
+
182
+ When helping a user set up or learn CodeDebrief:
183
+
184
+ 1. Start with `codedebrief --help`, then use `codedebrief <command> --help` for the specific
185
+ command you plan to run or recommend.
186
+ 2. Use `codedebrief doctor` when install, dependency, or parser capability issues are
187
+ unclear.
188
+ 3. Do not ask for LLM provider keys for the primary workflow. Language-friendly labels
189
+ are a presentation layer derived from deterministic workflow facts.
190
+ 4. `codedebrief setup-agent <target>` updates only that target's files. Run the command
191
+ separately for each agent surface you want to configure, preserving any target-specific
192
+ frontmatter and local notes.
193
+
194
+ After code or workflow-relevant changes:
195
+
196
+ 1. Treat CodeDebrief artifacts as part of done. After every meaningful source, route,
197
+ config, or agent-instruction change, run `codedebrief update` before finalizing or
198
+ committing so MCP answers and `codedebrief view` use current graphs. Skip only changes
199
+ that cannot affect the modeled code logic, such as unrelated copy edits or images.
200
+ 2. Use `codedebrief update --full` after analyzer upgrades, parser/dependency changes,
201
+ large refactors, or when cached file models should be ignored.
202
+ 3. Run `codedebrief validate --check-sync`.
203
+ 4. Commit synchronized changes to:
204
+ - `codedebrief-out/codedebrief.json`
205
+ - `codedebrief-out/codedebrief.md`
206
+ 5. Use CodeDebrief MCP `agent_context` to inspect affected entry points and callers when
207
+ explaining or reviewing the change.
208
+ 6. Ground the explanation in the returned `workflow_slice`; expand it through MCP only
209
+ when the initial slice omits relevant callers, callees, domain states, or paths.
210
+
211
+ For viewer/UI changes:
212
+
213
+ 1. Run `npm run viewer:typecheck`, `npm run viewer:test`, and `npm run viewer:build`.
214
+ 2. Regenerate HTML artifacts with `codedebrief update` and
215
+ `codedebrief view --render-only --no-open`.
216
+ 3. Check the generated viewer with a cache-buster URL.
217
+
218
+ {LOCAL_NOTES_START}
219
+ {notes}
220
+ {LOCAL_NOTES_END}
221
+
222
+ CodeDebrief is a comprehension and navigation tool for source-grounded workflows. Use it
223
+ to explain modeled logic, not to present possible defects.
224
+ {END}
225
+ """
226
+
227
+
228
+ INSTRUCTION_BLOCK = _instruction_block()
229
+
230
+
231
+ def install_all(root: Path, platform: str = "all", mcp_config: str = "none") -> list[Path]:
232
+ changed = install_agent_instructions(root, platform)
233
+ changed.extend(install_agent_skill(root, platform))
234
+ if mcp_config != "none":
235
+ changed.extend(install_mcp_config(root, mcp_config))
236
+ return changed
237
+
238
+
239
+ def install_agent_instructions(root: Path, platform: str = "all") -> list[Path]:
240
+ names = tuple(AGENT_INSTRUCTION_TARGETS) if platform == "all" else (platform,)
241
+ unknown = set(names) - set(AGENT_INSTRUCTION_TARGETS)
242
+ if unknown:
243
+ known = ", ".join(("all", *AGENT_INSTRUCTION_TARGETS))
244
+ raise ValueError(f"unknown agent instruction target {platform!r}; known targets: {known}")
245
+ targets = [root / AGENT_INSTRUCTION_TARGETS[name] for name in names]
246
+
247
+ changed: list[Path] = []
248
+ for target in targets:
249
+ target.parent.mkdir(parents=True, exist_ok=True)
250
+ existing = target.read_text(encoding="utf-8") if target.exists() else ""
251
+ updated = _upsert(existing, INSTRUCTION_BLOCK)
252
+ if target.suffix == ".mdc" and not updated.startswith("---"):
253
+ frontmatter = (
254
+ "---\ndescription: Keep CodeDebrief synchronized\nalwaysApply: true\n---\n\n"
255
+ )
256
+ updated = frontmatter + updated
257
+ if updated != existing:
258
+ target.write_text(updated, encoding="utf-8")
259
+ changed.append(target)
260
+ return changed
261
+
262
+
263
+ def install_agent_skill(root: Path, platform: str = "all") -> list[Path]:
264
+ names = tuple(AGENT_SKILL_TARGETS) if platform == "all" else (platform,)
265
+ unknown = set(names) - set(AGENT_INSTRUCTION_TARGETS)
266
+ if unknown:
267
+ known = ", ".join(("all", *AGENT_INSTRUCTION_TARGETS))
268
+ raise ValueError(f"unknown agent skill target {platform!r}; known targets: {known}")
269
+
270
+ changed: list[Path] = []
271
+ for name in names:
272
+ target_path = AGENT_SKILL_TARGETS.get(name)
273
+ if target_path is None:
274
+ continue
275
+ target = root / target_path
276
+ target.parent.mkdir(parents=True, exist_ok=True)
277
+ existing = target.read_text(encoding="utf-8") if target.exists() else ""
278
+ if existing != SKILL_TEMPLATE:
279
+ target.write_text(SKILL_TEMPLATE, encoding="utf-8")
280
+ changed.append(target)
281
+ return changed
282
+
283
+
284
+ def install_mcp_config(root: Path, target: str = "all") -> list[Path]:
285
+ root = root.resolve()
286
+ targets = MCP_CONFIG_TARGETS if target == "all" else (target,)
287
+ unknown = set(targets) - set(MCP_CONFIG_TARGETS)
288
+ if unknown:
289
+ known = ", ".join(("all", *MCP_CONFIG_TARGETS))
290
+ raise ValueError(f"unknown MCP config target {target!r}; known targets: {known}")
291
+
292
+ changed: list[Path] = []
293
+ for item in targets:
294
+ if item == "codex":
295
+ path = _install_codex_mcp_config(root)
296
+ elif item == "claude":
297
+ path = _install_json_mcp_config(root / ".mcp.json", root)
298
+ elif item == "gemini":
299
+ path = _install_json_mcp_config(root / ".gemini" / "settings.json", root)
300
+ else:
301
+ path = _install_json_mcp_config(root / ".cursor" / "mcp.json", root)
302
+ if path is not None:
303
+ changed.append(path)
304
+ return changed
305
+
306
+
307
+ def _upsert(existing: str, block: str) -> str:
308
+ if START in existing and END in existing:
309
+ before, remainder = existing.split(START, 1)
310
+ managed, after = remainder.split(END, 1)
311
+ block = _instruction_block(_extract_local_notes(managed))
312
+ # When the block sits at the very top (no prose before it), don't reintroduce a
313
+ # leading blank line - otherwise re-running `install` on a freshly created file
314
+ # would keep prepending whitespace instead of reaching a fixed point.
315
+ prefix = before.rstrip() + "\n\n" if before.strip() else ""
316
+ return prefix + block.rstrip() + "\n" + after.lstrip()
317
+ if not existing.strip():
318
+ # Match the fixed point the upsert branch produces for a block-only file, so a
319
+ # second `install` on a freshly created file is a true no-op.
320
+ return block.rstrip() + "\n"
321
+ return existing.rstrip() + "\n\n" + block.rstrip() + "\n"
322
+
323
+
324
+ def _extract_local_notes(managed: str) -> str:
325
+ if LOCAL_NOTES_START in managed and LOCAL_NOTES_END in managed:
326
+ _, remainder = managed.split(LOCAL_NOTES_START, 1)
327
+ notes, _ = remainder.split(LOCAL_NOTES_END, 1)
328
+ stripped = notes.strip()
329
+ return "" if stripped == LOCAL_NOTES_HINT else stripped
330
+ return _extract_legacy_local_notes(managed)
331
+
332
+
333
+ def _extract_legacy_local_notes(managed: str) -> str:
334
+ marker = "For local real-world regression checks:"
335
+ start = managed.find(marker)
336
+ if start == -1:
337
+ return ""
338
+ tail = managed[start:].splitlines()
339
+ collected: list[str] = []
340
+ saw_list_item = False
341
+ for line in tail:
342
+ stripped = line.strip()
343
+ if not stripped:
344
+ collected.append(line)
345
+ continue
346
+ if not collected or stripped == marker:
347
+ collected.append(line)
348
+ continue
349
+ if re.match(r"^(?:\d+\.|[-*])\s+", stripped):
350
+ saw_list_item = True
351
+ collected.append(line)
352
+ continue
353
+ if saw_list_item:
354
+ break
355
+ collected.append(line)
356
+ return "\n".join(collected).strip()
357
+
358
+
359
+ def _install_codex_mcp_config(root: Path) -> Path | None:
360
+ target = root / ".codex" / "config.toml"
361
+ target.parent.mkdir(parents=True, exist_ok=True)
362
+ existing = target.read_text(encoding="utf-8") if target.exists() else ""
363
+ unmanaged = _without_managed_block(existing, CODEX_MCP_START, CODEX_MCP_END)
364
+ if re.search(r"(?m)^\s*\[mcp_servers\.codedebrief\]\s*$", unmanaged):
365
+ raise ValueError(
366
+ f"{target} already defines [mcp_servers.codedebrief] outside the "
367
+ "CodeDebrief managed block."
368
+ )
369
+ block = "\n".join(
370
+ [
371
+ CODEX_MCP_START,
372
+ "[mcp_servers.codedebrief]",
373
+ 'command = "codedebrief"',
374
+ f"args = {_toml_array(['mcp', str(root)])}",
375
+ f"cwd = {json.dumps(str(root))}",
376
+ 'default_tools_approval_mode = "approve"',
377
+ CODEX_MCP_END,
378
+ "",
379
+ ]
380
+ )
381
+ updated = _upsert_managed_block(existing, block, CODEX_MCP_START, CODEX_MCP_END)
382
+ if updated != existing:
383
+ target.write_text(updated, encoding="utf-8")
384
+ return target
385
+ return None
386
+
387
+
388
+ def _install_json_mcp_config(target: Path, root: Path) -> Path | None:
389
+ target.parent.mkdir(parents=True, exist_ok=True)
390
+ existing = target.read_text(encoding="utf-8") if target.exists() else ""
391
+ data: dict[str, object]
392
+ if existing.strip():
393
+ try:
394
+ loaded = json.loads(existing)
395
+ except json.JSONDecodeError as error:
396
+ raise ValueError(f"invalid JSON in {target}: {error}") from error
397
+ if not isinstance(loaded, dict):
398
+ raise ValueError(f"{target} must contain a JSON object")
399
+ data = loaded
400
+ else:
401
+ data = {}
402
+
403
+ servers = data.setdefault("mcpServers", {})
404
+ if not isinstance(servers, dict):
405
+ raise ValueError(f"{target} has non-object mcpServers")
406
+ existing_server = servers.get("codedebrief", {})
407
+ if existing_server is not None and not isinstance(existing_server, dict):
408
+ raise ValueError(f"{target} has non-object mcpServers.codedebrief")
409
+ server = dict(existing_server or {})
410
+ server.update({"command": "codedebrief", "args": ["mcp", str(root)]})
411
+ servers["codedebrief"] = server
412
+
413
+ updated = json.dumps(data, indent=2) + "\n"
414
+ if updated != existing:
415
+ target.write_text(updated, encoding="utf-8")
416
+ return target
417
+ return None
418
+
419
+
420
+ def _without_managed_block(existing: str, start: str, end: str) -> str:
421
+ if start not in existing or end not in existing:
422
+ return existing
423
+ before, remainder = existing.split(start, 1)
424
+ _, after = remainder.split(end, 1)
425
+ return before + after
426
+
427
+
428
+ def _upsert_managed_block(existing: str, block: str, start: str, end: str) -> str:
429
+ if start in existing and end in existing:
430
+ before, remainder = existing.split(start, 1)
431
+ _, after = remainder.split(end, 1)
432
+ prefix = before.rstrip() + "\n\n" if before.strip() else ""
433
+ suffix = "\n" + after.lstrip() if after.strip() else ""
434
+ return prefix + block.rstrip() + "\n" + suffix
435
+ if not existing.strip():
436
+ return block.rstrip() + "\n"
437
+ return existing.rstrip() + "\n\n" + block.rstrip() + "\n"
438
+
439
+
440
+ def _toml_array(values: list[str]) -> str:
441
+ return "[" + ", ".join(json.dumps(value) for value in values) + "]"