sin-code-bundle 0.9.2__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 (41) hide show
  1. sin_code_bundle/__init__.py +6 -0
  2. sin_code_bundle/agents_md.py +245 -0
  3. sin_code_bundle/ast_edit.py +323 -0
  4. sin_code_bundle/bench.py +506 -0
  5. sin_code_bundle/budget.py +51 -0
  6. sin_code_bundle/cache.py +131 -0
  7. sin_code_bundle/checkpoint.py +230 -0
  8. sin_code_bundle/cli.py +1943 -0
  9. sin_code_bundle/codocs.py +328 -0
  10. sin_code_bundle/dap_bridge.py +135 -0
  11. sin_code_bundle/data/codocs/SKILL.md +280 -0
  12. sin_code_bundle/gitnexus.py +368 -0
  13. sin_code_bundle/hashline.py +216 -0
  14. sin_code_bundle/hooks.py +249 -0
  15. sin_code_bundle/immortal_commit.py +288 -0
  16. sin_code_bundle/interceptor.py +119 -0
  17. sin_code_bundle/lsp_backend.py +303 -0
  18. sin_code_bundle/lsp_bootstrap.py +85 -0
  19. sin_code_bundle/markitdown.py +254 -0
  20. sin_code_bundle/mcp_config.py +455 -0
  21. sin_code_bundle/mcp_server.py +963 -0
  22. sin_code_bundle/memory.py +208 -0
  23. sin_code_bundle/merge_safety.py +313 -0
  24. sin_code_bundle/orchestration_worktrees.py +102 -0
  25. sin_code_bundle/policy.py +224 -0
  26. sin_code_bundle/preflight.py +152 -0
  27. sin_code_bundle/programming_workflow.py +541 -0
  28. sin_code_bundle/rtk.py +154 -0
  29. sin_code_bundle/safety.py +52 -0
  30. sin_code_bundle/session_warmup.py +247 -0
  31. sin_code_bundle/skills.py +188 -0
  32. sin_code_bundle/symbol_resolve.py +166 -0
  33. sin_code_bundle/tools/__init__.py +4 -0
  34. sin_code_bundle/tools/pypi_setup.py +289 -0
  35. sin_code_bundle/vfs.py +264 -0
  36. sin_code_bundle-0.9.2.dist-info/METADATA +470 -0
  37. sin_code_bundle-0.9.2.dist-info/RECORD +41 -0
  38. sin_code_bundle-0.9.2.dist-info/WHEEL +5 -0
  39. sin_code_bundle-0.9.2.dist-info/entry_points.txt +4 -0
  40. sin_code_bundle-0.9.2.dist-info/licenses/LICENSE +21 -0
  41. sin_code_bundle-0.9.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,280 @@
1
+ ---
2
+ name: sin-codocs
3
+ description: SOTA Code Documentation — every code file gets both a `.doc.md` companion AND proper inline `#` comments. Create both for new files, update both on changes, verify with `sin codocs check`. Use when the user says "document this", "add docs", "explain the code", "comment this", "add inline documentation", "self-documenting code", or "SOTA docs".
4
+ ---
5
+
6
+ ## Code Documentation Standard
7
+
8
+ Every meaningful code file needs **two documentation layers**:
9
+
10
+ 1. **`.doc.md` companion** — the "what and why" overview
11
+ 2. **Inline `#` comments** — the "how and why here" detail in the code itself
12
+
13
+ Both layers must exist. Neither replaces the other.
14
+
15
+ ---
16
+
17
+ ## Layer 1: CoDocs (.doc.md companion)
18
+
19
+ Every code file gets a `.doc.md` companion file in the same directory.
20
+
21
+ ### Naming
22
+
23
+ ```
24
+ router.py → router.doc.md
25
+ config.yaml → config.doc.md
26
+ api/types.ts → api/types.doc.md
27
+ Makefile → Makefile.doc.md
28
+ ```
29
+
30
+ ### Code reference
31
+
32
+ First line of the code file:
33
+
34
+ ```python
35
+ # Docs: router.doc.md
36
+ ```
37
+
38
+ ```ts
39
+ // Docs: types.doc.md
40
+ ```
41
+
42
+ ```makefile
43
+ # Docs: Makefile.doc.md
44
+ ```
45
+
46
+ ### What belongs in a `.doc.md`
47
+
48
+ - What does this file do? (1 sentence)
49
+ - Which other files import / touch it? (dependency map)
50
+ - Important config values & limits
51
+ - Why certain decisions were made (e.g. "no async here because X")
52
+ - Usage examples (1-2 lines)
53
+ - Known caveats or footguns
54
+
55
+ ### What does NOT belong in a `.doc.md`
56
+
57
+ - Implementation details (inline comments handle that)
58
+ - Git history (that's what `git log` is for)
59
+
60
+ ---
61
+
62
+ ## Layer 2: SOTA Inline Documentation
63
+
64
+ Every code file must also have professional inline `#`/`//`/`#:` comments. This is **not** about "comment every line" — it is about providing **semantic context** that an agent can't infer from the code alone.
65
+
66
+ ### SOTA Inline Doc Rules
67
+
68
+ #### 1. File header (mandatory)
69
+
70
+ Every code file starts with:
71
+
72
+ ```
73
+ # Purpose: <what this file does in 1 line>
74
+ # Docs: <companion .doc.md path>
75
+ ```
76
+
77
+ For Python use `"""` module docstrings instead of `#`:
78
+
79
+ ```python
80
+ """Handle user authentication.
81
+
82
+ Docs: auth.doc.md
83
+ """
84
+ ```
85
+
86
+ For TypeScript/Rust/etc use doc-comment style:
87
+
88
+ ```ts
89
+ /**
90
+ * Handle user authentication.
91
+ * Docs: auth.doc.md
92
+ */
93
+ ```
94
+
95
+ #### 2. Public API: docstrings (mandatory)
96
+
97
+ Every public function, method, class, type, and constant needs a docstring:
98
+
99
+ ```python
100
+ def calculate_route(
101
+ origin: Coordinate,
102
+ dest: Coordinate,
103
+ traffic: bool = False,
104
+ ) -> Route:
105
+ """Shortest path between two coordinates.
106
+
107
+ Uses A* with Manhattan heuristic. Raises if both coords
108
+ are identical (avoids zero-length route).
109
+ """
110
+ ...
111
+ ```
112
+
113
+ ```ts
114
+ /** Shortest path between two coordinates.
115
+ *
116
+ * Uses A* with Manhattan heuristic. Throws if both coords
117
+ * are identical (avoids zero-length route).
118
+ */
119
+ function calculateRoute(
120
+ origin: Coordinate,
121
+ dest: Coordinate,
122
+ traffic: boolean = false,
123
+ ): Route { ... }
124
+ ```
125
+
126
+ #### 3. Non-obvious logic: inline context comments
127
+
128
+ Add a comment when the code does something surprising:
129
+
130
+ - **Why NOT the obvious approach**: `# not using dict comprehension because ...`
131
+ - **Why this value**: `# 50ms timeout — must be < retry-after of upstream (60ms)`
132
+ - **Why this ordering**: `# flush before close — close may skip unflushed data`
133
+ - **Edge case**: `# handles None because protocol allows null fields`
134
+ - **Performance note**: `# O(n²) but n ≤ 10 in practice`
135
+ - **Security note**: `# sanitize_input() prevents SQL injection here`
136
+
137
+ #### 4. Section separators (recommended for 100+ line files)
138
+
139
+ ```
140
+ # ── Auth ──────────────────────────────────────
141
+ ```
142
+
143
+ Visually group related blocks. The long line makes sections scannable.
144
+
145
+ #### 5. Magic values & config keys
146
+
147
+ Always explain:
148
+
149
+ ```python
150
+ MAX_RETRIES = 3 # upstream SLA guarantees < 2 failures per 1000
151
+ WAIT_SECONDS = 60 # must match upstream rate-limit window
152
+ ```
153
+
154
+ ```ts
155
+ const MAX_RETRIES = 3 // upstream SLA guarantees < 2 per 1000
156
+ const WAIT_SECONDS = 60 // must match upstream rate-limit window
157
+ ```
158
+
159
+ #### 6. Tests: describe scenario + expected behavior
160
+
161
+ ```python
162
+ def test_retry_exhaustion():
163
+ """After 3 retries, route should raise UpstreamError."""
164
+ ```
165
+
166
+ Test names plus docstrings = executable documentation.
167
+
168
+ #### 7. Deprecation & migration markers
169
+
170
+ ```python
171
+ def old_login(): # DEPRECATED(v2): use authenticate() instead
172
+ ```
173
+
174
+ ### When to update inline docs
175
+
176
+ - **Every change to a function's signature**: update its docstring
177
+ - **Every change to non-obvious logic**: add/update the context comment
178
+ - **Every new module**: file header + section separators
179
+ - **Every new public API**: docstring on add
180
+
181
+ ### When NOT to comment
182
+
183
+ - `i += 1` — obvious code needs no comment
184
+ - `x = 1` — unless 1 is a meaningful constant
185
+ - Getter/setter boilerplate
186
+ - Standard library calls with obvious semantics
187
+
188
+ ---
189
+
190
+ ## Validation
191
+
192
+ After changes, verify with the bundle CLI:
193
+
194
+ ```bash
195
+ sin codocs check # exit 1 if any .doc.md reference is broken
196
+ sin codocs check --json # machine-readable output
197
+ sin codocs list # list every reference and whether it resolves
198
+ ```
199
+
200
+ For inline docs, use manual review with:
201
+
202
+ ```bash
203
+ # Check files that have NO module-level docstring/Purpose line
204
+ python3 -c "
205
+ import ast, sys
206
+ for f in sys.argv[1:]:
207
+ try:
208
+ tree = ast.parse(open(f).read())
209
+ if not (isinstance(tree.body[0], ast.Expr) and hasattr(tree.body[0].value, 'value') and 'Purpose' in tree.body[0].value.s if hasattr(tree.body[0].value, 's') else isinstance(tree.body[0], ast.Expr) and isinstance(tree.body[0].value, ast.Constant)):
210
+ print(f'MISSING PURPOSE: {f}')
211
+ except: print(f'PARSE ERROR: {f}')
212
+ "
213
+ ```
214
+
215
+ ## Exceptions
216
+
217
+ - `docs/` folder — architecture docs, ADRs, setup guides
218
+ - `README.md` — project overview
219
+ - No `.doc.md` for pure config files without logic (`.gitignore`, `.prettierrc`, etc.)
220
+ - No inline docs required for throwaway scripts in `debug/`, `tmp/`, experimental branches
221
+
222
+ ---
223
+
224
+ ## MarkItDown Integration (Microsoft)
225
+
226
+ **Converts everything to Markdown** for LLM consumption: PDF, DOCX, PPTX, XLSX,
227
+ Images (OCR), Audio, HTML, CSV/JSON/XML, ZIP, YouTube, EPUB, Outlook MSG.
228
+
229
+ ### Installation
230
+
231
+ ```bash
232
+ pipx install markitdown # recommended (CLI + library)
233
+ pip install 'markitdown[pdf, docx, pptx, xlsx]' # minimal
234
+ pip install 'markitdown[all]' # everything
235
+ ```
236
+
237
+ ### CLI
238
+
239
+ ```bash
240
+ markitdown file.pdf > file.md # stdout
241
+ markitdown file.pdf -o file.md # output file
242
+ cat file.pdf | markitdown # pipe
243
+ markitdown --use-plugins file.pdf # with plugins (OCR)
244
+ markitdown file.pdf --use-cu --cu-endpoint "<e>" # Azure Content Understanding
245
+ ```
246
+
247
+ ### Python API
248
+
249
+ ```python
250
+ from markitdown import MarkItDown
251
+ md = MarkItDown()
252
+ result = md.convert("document.pdf")
253
+ print(result.text_content)
254
+
255
+ # With LLM vision (image descriptions in PPTX/Images)
256
+ from openai import OpenAI
257
+ md = MarkItDown(llm_client=OpenAI(), llm_model="gpt-4o")
258
+
259
+ # Security: local files only
260
+ result = md.convert_local("document.pdf")
261
+ ```
262
+
263
+ ### CoDocs pipeline
264
+
265
+ ```bash
266
+ for f in docs/*.pdf docs/*.docx docs/*.pptx; do
267
+ markitdown "$f" -o "${f%.*}.doc.md"
268
+ done
269
+ # then add `# Docs: filename.doc.md` to the matching code file
270
+ ```
271
+
272
+ ### Security
273
+
274
+ - `convert()` runs with the calling process's full file-IO rights. Never pass
275
+ untrusted input directly.
276
+ - Prefer `convert_local()` / `convert_stream()` for controlled access.
277
+
278
+ ### Reference
279
+
280
+ https://github.com/microsoft/markitdown | `pipx install markitdown`
@@ -0,0 +1,368 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """GitNexus bridge.
3
+
4
+ GitNexus (https://github.com/abhigyanpatwari/GitNexus) is an *upstream* tool,
5
+ distributed as the npm package ``gitnexus`` under the PolyForm Noncommercial
6
+ license. We never vendor or copy its source; we only invoke the published
7
+ package via ``npx`` and read the artifacts it produces. This keeps the bundle
8
+ MIT-licensed while making GitNexus a hard, always-on dependency so that coder
9
+ agents never operate "blind" on a repository.
10
+
11
+ The bridge provides:
12
+ * discovery / health checks for Node + the ``gitnexus`` package,
13
+ * an ``ensure_index`` helper that auto-indexes a repo when the graph is
14
+ missing or stale,
15
+ * thin wrappers over the GitNexus CLI query surface
16
+ (``ai-context``, ``query``, ``context``, ``impact``),
17
+ * MCP wiring so OpenCode / Codex / Hermes each get the GitNexus MCP server.
18
+
19
+ Docs: gitnexus.doc.md
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import shutil
27
+ import subprocess
28
+ import time
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ # ── GitNexusBridge: Graph Context Provider ─────────────────────────────
34
+ # This module turns GitNexus (the upstream npm package) into a hard,
35
+ # always-on dependency for coder agents. We never vendor the package —
36
+ # we only invoke it via npx, which fetches and caches the published
37
+ # build on first use (mirroring GitNexus' own `.mcp.json` recommendation).
38
+
39
+ # How GitNexus is provided. We always pin to the published package and let npx
40
+ # fetch/cache it, mirroring GitNexus' own `.mcp.json` recommendation.
41
+ GITNEXUS_PACKAGE = "gitnexus@latest"
42
+
43
+ # Re-index when the stored graph is older than this many seconds (default 24h).
44
+ DEFAULT_STALE_SECONDS = 24 * 60 * 60
45
+
46
+ # GitNexus stores its index per-repo under this directory.
47
+ INDEX_DIRNAME = ".gitnexus"
48
+
49
+
50
+ class GitNexusError(RuntimeError):
51
+ """Raised when GitNexus is unavailable or a command fails."""
52
+
53
+
54
+ @dataclass
55
+ class GitNexusEnv:
56
+ """Resolved runtime environment for invoking GitNexus."""
57
+
58
+ node: str | None
59
+ npx: str | None
60
+ package: str = GITNEXUS_PACKAGE
61
+
62
+ @property
63
+ def available(self) -> bool:
64
+ """True iff a usable ``npx`` was detected on PATH."""
65
+ return bool(self.npx)
66
+
67
+ def base_cmd(self) -> list[str]:
68
+ """Return the base command list to invoke the GitNexus package.
69
+
70
+ Raises GitNexusError if npx is missing — this is the gate every GitNexus
71
+ command in the bundle funnels through, so the error is raised once and
72
+ in one place.
73
+ """
74
+ if not self.npx:
75
+ raise GitNexusError(
76
+ "npx not found on PATH. GitNexus requires Node.js (>=18). "
77
+ "Install Node, then re-run. The bundle does not vendor GitNexus."
78
+ )
79
+ # `npx -y <pkg>` auto-installs/caches the published package on first use.
80
+ return [self.npx, "-y", self.package]
81
+
82
+
83
+ def detect_env(package: str = GITNEXUS_PACKAGE) -> GitNexusEnv:
84
+ """Locate Node + npx without mutating anything."""
85
+ return GitNexusEnv(
86
+ node=shutil.which("node"),
87
+ npx=shutil.which("npx"),
88
+ package=package,
89
+ )
90
+
91
+
92
+ def _run(
93
+ cmd: list[str],
94
+ cwd: str | os.PathLike[str] | None = None,
95
+ timeout: int = 900, # 900s = 15min default — npx cold-cache + first-time `gitnexus analyze` can be slow on monorepos
96
+ capture: bool = True,
97
+ ) -> subprocess.CompletedProcess:
98
+ try:
99
+ return subprocess.run(
100
+ cmd,
101
+ cwd=str(cwd) if cwd else None,
102
+ check=False,
103
+ text=True,
104
+ capture_output=capture,
105
+ timeout=timeout,
106
+ )
107
+ except FileNotFoundError as exc: # npx vanished mid-run
108
+ raise GitNexusError(f"Failed to execute {cmd[0]!r}: {exc}") from exc
109
+ except subprocess.TimeoutExpired as exc:
110
+ raise GitNexusError(
111
+ f"GitNexus command timed out after {timeout}s: {' '.join(cmd)}"
112
+ ) from exc
113
+
114
+
115
+ @dataclass
116
+ class IndexState:
117
+ """Whether a repo has a usable GitNexus index."""
118
+
119
+ exists: bool
120
+ path: Path
121
+ age_seconds: float | None = None
122
+ stale: bool = False
123
+ details: dict[str, Any] = field(default_factory=dict)
124
+
125
+ def to_dict(self) -> dict[str, Any]:
126
+ """Serialize index state for diagnostic JSON (e.g. ``doctor()``)."""
127
+ return {
128
+ "exists": self.exists,
129
+ "path": str(self.path),
130
+ "age_seconds": self.age_seconds,
131
+ "stale": self.stale,
132
+ **({"details": self.details} if self.details else {}),
133
+ }
134
+
135
+
136
+ def index_state(root: str = ".", stale_seconds: int = DEFAULT_STALE_SECONDS) -> IndexState:
137
+ """Inspect the on-disk GitNexus index for ``root`` without running GitNexus."""
138
+ index_path = Path(root).resolve() / INDEX_DIRNAME
139
+ if not index_path.exists():
140
+ return IndexState(exists=False, path=index_path)
141
+
142
+ # Use the most recently modified file inside the index dir as the age basis.
143
+ # We deliberately do NOT use the directory's own mtime — editors and package
144
+ # managers often touch the dir without rewriting any real index file.
145
+ newest = 0.0
146
+ for p in index_path.rglob("*"):
147
+ if p.is_file():
148
+ newest = max(newest, p.stat().st_mtime)
149
+ age = time.time() - newest if newest else None
150
+ stale = age is not None and age > stale_seconds
151
+ return IndexState(exists=True, path=index_path, age_seconds=age, stale=stale)
152
+
153
+
154
+ def analyze(
155
+ root: str = ".",
156
+ env: GitNexusEnv | None = None,
157
+ timeout: int = 1800, # 1800s = 30min — full re-analyze of a large repo; auto-only on missing or stale
158
+ ) -> subprocess.CompletedProcess:
159
+ """Build/refresh the GitNexus index for ``root`` (``gitnexus analyze``)."""
160
+ env = env or detect_env()
161
+ cmd = env.base_cmd() + ["analyze", "--path", str(Path(root).resolve())]
162
+ proc = _run(cmd, cwd=root, timeout=timeout)
163
+ if proc.returncode != 0:
164
+ raise GitNexusError(
165
+ f"`gitnexus analyze` failed (exit {proc.returncode}).\n{proc.stderr or proc.stdout}"
166
+ )
167
+ return proc
168
+
169
+
170
+ # ── Preflight: Validate Index Freshness ───────────────────────────────
171
+ # `ensure_index` is the gate every query flows through: it inspects the
172
+ # on-disk `.gitnexus/` directory, compares its age against the staleness
173
+ # threshold, and (by default) auto-rebuilds so agents never run blind.
174
+
175
+
176
+ def ensure_index(
177
+ root: str = ".",
178
+ *,
179
+ env: GitNexusEnv | None = None,
180
+ stale_seconds: int = DEFAULT_STALE_SECONDS,
181
+ auto: bool = True,
182
+ ) -> IndexState:
183
+ """Guarantee a fresh index exists.
184
+
185
+ With ``auto=True`` (the bundle default) a missing or stale index is rebuilt
186
+ automatically so agents always have graph context. With ``auto=False`` the
187
+ caller is told to index but nothing is mutated.
188
+ """
189
+ env = env or detect_env()
190
+ if not env.available:
191
+ raise GitNexusError(
192
+ "GitNexus is required but Node/npx is not available. "
193
+ "Install Node.js (>=18) so coder agents are not flying blind."
194
+ )
195
+ # Note: we only auto-rebuild on missing OR stale. A valid-but-old index
196
+ # within `stale_seconds` is trusted as-is to avoid a full re-analyze on
197
+ # every query, which on large repos can take minutes.
198
+ state = index_state(root, stale_seconds=stale_seconds)
199
+ if state.exists and not state.stale:
200
+ return state
201
+ if not auto:
202
+ return state
203
+ analyze(root, env=env)
204
+ return index_state(root, stale_seconds=stale_seconds)
205
+
206
+
207
+ # ── Query: Cached Codebase Graph Access ───────────────────────────────
208
+ # Thin wrappers over the GitNexus CLI query surface. Each is a one-line
209
+ # passthrough to `_query` so the error-handling and timeout policy stay
210
+ # in one place. The CLI's own caching layer is what makes these fast
211
+ # for repeated calls within a session.
212
+
213
+
214
+ def _query(
215
+ subcommand: list[str],
216
+ root: str = ".",
217
+ env: GitNexusEnv | None = None,
218
+ timeout: int = 300, # 300s = 5min for read-only graph queries (should hit npx cache, hence lower than analyze)
219
+ ) -> str:
220
+ """Run a read-only GitNexus query command and return stdout."""
221
+ env = env or detect_env()
222
+ cmd = env.base_cmd() + subcommand
223
+ proc = _run(cmd, cwd=root, timeout=timeout)
224
+ if proc.returncode != 0:
225
+ raise GitNexusError(
226
+ f"`gitnexus {' '.join(subcommand)}` failed (exit {proc.returncode}).\n"
227
+ f"{proc.stderr or proc.stdout}"
228
+ )
229
+ return proc.stdout.strip()
230
+
231
+
232
+ def ai_context(task: str, root: str = ".", env: GitNexusEnv | None = None) -> str:
233
+ """Get task-scoped, graph-aware context for an agent (``gitnexus ai-context``)."""
234
+ return _query(["ai-context", task], root=root, env=env)
235
+
236
+
237
+ def query(question: str, root: str = ".", env: GitNexusEnv | None = None) -> str:
238
+ """Natural-language graph query (``gitnexus query``)."""
239
+ return _query(["query", question], root=root, env=env)
240
+
241
+
242
+ def context(symbol: str, root: str = ".", env: GitNexusEnv | None = None) -> str:
243
+ """Structural context for a symbol (``gitnexus context``)."""
244
+ return _query(["context", symbol], root=root, env=env)
245
+
246
+
247
+ def impact(symbol: str, root: str = ".", env: GitNexusEnv | None = None) -> str:
248
+ """Blast-radius / impact analysis for a symbol (``gitnexus impact``)."""
249
+ return _query(["impact", symbol], root=root, env=env)
250
+
251
+
252
+ def doctor(root: str = ".", env: GitNexusEnv | None = None) -> dict[str, Any]:
253
+ """Aggregate health report: runtime + index availability."""
254
+ env = env or detect_env()
255
+ report: dict[str, Any] = {
256
+ "node": env.node,
257
+ "npx": env.npx,
258
+ "package": env.package,
259
+ "available": env.available,
260
+ }
261
+ if env.available:
262
+ state = index_state(root)
263
+ report["index"] = state.to_dict()
264
+ else:
265
+ report["error"] = "Node.js/npx not found on PATH."
266
+ return report
267
+
268
+
269
+ # ── MCP wiring: GitNexus server for coder agents ──────────────────────
270
+ # GitNexus exposes its graph tools over stdio via `gitnexus mcp`. We register
271
+ # that same command with every supported agent so the agent's tools list
272
+ # includes `gitnexus_query`, `gitnexus_context`, `gitnexus_impact`, etc.
273
+
274
+
275
+ # The single MCP server entry every agent should run. GitNexus exposes its graph
276
+ # tools over stdio via `gitnexus mcp`.
277
+ def mcp_server_command(package: str = GITNEXUS_PACKAGE) -> dict[str, Any]:
278
+ """Return the MCP server launch spec as a ``{command, args}`` dict.
279
+
280
+ This is the canonical payload used by every ``_wire_*`` helper below, and
281
+ can also be passed to external MCP-aware clients directly.
282
+ """
283
+ return {"command": "npx", "args": ["-y", package, "mcp"]}
284
+
285
+
286
+ def _opencode_config_path() -> Path:
287
+ return Path.home() / ".config" / "opencode" / "opencode.json"
288
+
289
+
290
+ def _codex_config_path() -> Path:
291
+ return Path.home() / ".codex" / "config.toml"
292
+
293
+
294
+ def _hermes_config_path() -> Path:
295
+ return Path.home() / ".hermes" / "mcp.json"
296
+
297
+
298
+ AGENTS = ("opencode", "codex", "hermes")
299
+
300
+
301
+ def _wire_opencode(package: str) -> str:
302
+ path = _opencode_config_path()
303
+ path.parent.mkdir(parents=True, exist_ok=True)
304
+ data: dict[str, Any] = {}
305
+ if path.is_file():
306
+ try:
307
+ data = json.loads(path.read_text() or "{}")
308
+ except json.JSONDecodeError:
309
+ data = {}
310
+ mcp = data.setdefault("mcp", {})
311
+ mcp["gitnexus"] = {
312
+ "type": "local",
313
+ "command": ["npx", "-y", package, "mcp"],
314
+ "enabled": True,
315
+ }
316
+ path.write_text(json.dumps(data, indent=2) + "\n")
317
+ return str(path)
318
+
319
+
320
+ def _wire_codex(package: str) -> str:
321
+ path = _codex_config_path()
322
+ path.parent.mkdir(parents=True, exist_ok=True)
323
+ block = f'\n[mcp_servers.gitnexus]\ncommand = "npx"\nargs = ["-y", "{package}", "mcp"]\n'
324
+ existing = path.read_text() if path.is_file() else ""
325
+ if "[mcp_servers.gitnexus]" in existing:
326
+ return str(path) # already wired; leave user edits intact
327
+ path.write_text(existing + block)
328
+ return str(path)
329
+
330
+
331
+ def _wire_hermes(package: str) -> str:
332
+ path = _hermes_config_path()
333
+ path.parent.mkdir(parents=True, exist_ok=True)
334
+ data: dict[str, Any] = {}
335
+ if path.is_file():
336
+ try:
337
+ data = json.loads(path.read_text() or "{}")
338
+ except json.JSONDecodeError:
339
+ data = {}
340
+ servers = data.setdefault("mcpServers", {})
341
+ servers["gitnexus"] = mcp_server_command(package)
342
+ path.write_text(json.dumps(data, indent=2) + "\n")
343
+ return str(path)
344
+
345
+
346
+ _WIRERS = {
347
+ "opencode": _wire_opencode,
348
+ "codex": _wire_codex,
349
+ "hermes": _wire_hermes,
350
+ }
351
+
352
+
353
+ def setup_agents(
354
+ agents: list[str] | None = None,
355
+ package: str = GITNEXUS_PACKAGE,
356
+ ) -> dict[str, str]:
357
+ """Wire the GitNexus MCP server into each agent's config.
358
+
359
+ Returns a mapping of agent -> config file written.
360
+ """
361
+ chosen = agents or list(AGENTS)
362
+ written: dict[str, str] = {}
363
+ for agent in chosen:
364
+ wirer = _WIRERS.get(agent)
365
+ if not wirer:
366
+ raise GitNexusError(f"Unknown agent: {agent!r}. Known: {', '.join(AGENTS)}")
367
+ written[agent] = wirer(package)
368
+ return written