arcgraph 0.1.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 (165) hide show
  1. arcgraph/__init__.py +41 -0
  2. arcgraph/_build_provenance.json +10 -0
  3. arcgraph/adapters/__init__.py +6 -0
  4. arcgraph/adapters/arq_adapter.py +369 -0
  5. arcgraph/adapters/celery_adapter.py +440 -0
  6. arcgraph/adapters/common.py +436 -0
  7. arcgraph/adapters/django_adapter.py +765 -0
  8. arcgraph/adapters/fastapi_adapter.py +415 -0
  9. arcgraph/adapters/logging_adapter.py +335 -0
  10. arcgraph/adapters/mcp_adapter.py +100 -0
  11. arcgraph/adapters/pydantic_adapter.py +290 -0
  12. arcgraph/adapters/pytest_adapter.py +221 -0
  13. arcgraph/adapters/pytest_collection.py +96 -0
  14. arcgraph/adapters/pytest_fixtures.py +371 -0
  15. arcgraph/adapters/pytest_types.py +251 -0
  16. arcgraph/adapters/registry.py +34 -0
  17. arcgraph/adapters/service_container_adapter.py +548 -0
  18. arcgraph/adapters/sqlalchemy_adapter.py +446 -0
  19. arcgraph/adapters/typer_click_adapter.py +292 -0
  20. arcgraph/analyzers/__init__.py +1 -0
  21. arcgraph/analyzers/bindings.py +782 -0
  22. arcgraph/analyzers/calls/__init__.py +6 -0
  23. arcgraph/analyzers/calls/analyzer.py +2921 -0
  24. arcgraph/analyzers/calls/constants.py +702 -0
  25. arcgraph/analyzers/calls/context.py +298 -0
  26. arcgraph/analyzers/calls/lexical.py +237 -0
  27. arcgraph/analyzers/coverage.py +826 -0
  28. arcgraph/analyzers/exports.py +46 -0
  29. arcgraph/analyzers/har_trace.py +323 -0
  30. arcgraph/analyzers/imports.py +190 -0
  31. arcgraph/analyzers/otel_trace.py +352 -0
  32. arcgraph/analyzers/precise_references.py +1199 -0
  33. arcgraph/analyzers/python_scopes.py +56 -0
  34. arcgraph/analyzers/runtime_trace.py +516 -0
  35. arcgraph/analyzers/similarity.py +430 -0
  36. arcgraph/analyzers/symbols.py +363 -0
  37. arcgraph/analyzers/type_unions.py +89 -0
  38. arcgraph/analyzers/types.py +2293 -0
  39. arcgraph/assets/__init__.py +1 -0
  40. arcgraph/assets/workbench/__init__.py +1 -0
  41. arcgraph/assets/workbench/index.html +2398 -0
  42. arcgraph/assets/workbench/vendor/LICENSE.d3.txt +15 -0
  43. arcgraph/assets/workbench/vendor/d3.v7.min.js +2 -0
  44. arcgraph/change/__init__.py +10 -0
  45. arcgraph/change/baseline.py +145 -0
  46. arcgraph/change/changed_regions.py +728 -0
  47. arcgraph/change/contracts.py +927 -0
  48. arcgraph/change/errors.py +88 -0
  49. arcgraph/change/evidence.py +289 -0
  50. arcgraph/change/graph_delta.py +1422 -0
  51. arcgraph/change/identities.py +1277 -0
  52. arcgraph/change/paths.py +234 -0
  53. arcgraph/change/pins.py +426 -0
  54. arcgraph/change/planner.py +504 -0
  55. arcgraph/change/protected_surfaces.py +328 -0
  56. arcgraph/change/provider.py +46 -0
  57. arcgraph/change/service.py +1030 -0
  58. arcgraph/change/source_provider.py +311 -0
  59. arcgraph/change/store.py +1138 -0
  60. arcgraph/change/trusted_runners.py +154 -0
  61. arcgraph/change/verdicts.py +141 -0
  62. arcgraph/change/verification_plan.py +113 -0
  63. arcgraph/change/verifier.py +579 -0
  64. arcgraph/core/__init__.py +1 -0
  65. arcgraph/core/assurance.py +343 -0
  66. arcgraph/core/binding_metrics.py +75 -0
  67. arcgraph/core/cleanup.py +701 -0
  68. arcgraph/core/confidence_profiles.py +108 -0
  69. arcgraph/core/evidence_manifest.py +862 -0
  70. arcgraph/core/expression_kinds.py +11 -0
  71. arcgraph/core/force_graph_export.py +1125 -0
  72. arcgraph/core/freshness.py +94 -0
  73. arcgraph/core/graph_store.py +1099 -0
  74. arcgraph/core/ids.py +206 -0
  75. arcgraph/core/language_tiers.py +150 -0
  76. arcgraph/core/merge.py +649 -0
  77. arcgraph/core/metadata.py +30 -0
  78. arcgraph/core/operation_lock.py +218 -0
  79. arcgraph/core/passes.py +142 -0
  80. arcgraph/core/payload_policy.py +708 -0
  81. arcgraph/core/precise_references.py +124 -0
  82. arcgraph/core/query_engine.py +3078 -0
  83. arcgraph/core/recovery.py +18 -0
  84. arcgraph/core/scanner.py +1454 -0
  85. arcgraph/core/schemas.py +700 -0
  86. arcgraph/core/semantic.py +263 -0
  87. arcgraph/core/semantic_metrics.py +619 -0
  88. arcgraph/core/sharing_retry.py +84 -0
  89. arcgraph/core/structural.py +260 -0
  90. arcgraph/core/target_resolver.py +486 -0
  91. arcgraph/core/type_metrics.py +67 -0
  92. arcgraph/core/unresolved_classification.py +471 -0
  93. arcgraph/core/utils.py +100 -0
  94. arcgraph/core/visual_contract.py +240 -0
  95. arcgraph/core/visual_slice_engine.py +933 -0
  96. arcgraph/interfaces/__init__.py +1 -0
  97. arcgraph/interfaces/agent_capabilities.py +493 -0
  98. arcgraph/interfaces/agent_help.py +313 -0
  99. arcgraph/interfaces/benchmark.py +548 -0
  100. arcgraph/interfaces/ci.py +1992 -0
  101. arcgraph/interfaces/cli.py +2799 -0
  102. arcgraph/interfaces/cli_change.py +690 -0
  103. arcgraph/interfaces/cli_feedback.py +176 -0
  104. arcgraph/interfaces/cli_ops.py +69 -0
  105. arcgraph/interfaces/cli_support.py +113 -0
  106. arcgraph/interfaces/cli_trial.py +42 -0
  107. arcgraph/interfaces/cli_visual.py +601 -0
  108. arcgraph/interfaces/cli_workspace.py +300 -0
  109. arcgraph/interfaces/client_setup.py +481 -0
  110. arcgraph/interfaces/docs.py +1255 -0
  111. arcgraph/interfaces/human_output.py +409 -0
  112. arcgraph/interfaces/local_state.py +577 -0
  113. arcgraph/interfaces/mcp_server.py +457 -0
  114. arcgraph/interfaces/mcp_tools.py +1209 -0
  115. arcgraph/interfaces/metrics.py +571 -0
  116. arcgraph/interfaces/ops.py +330 -0
  117. arcgraph/interfaces/precision.py +1670 -0
  118. arcgraph/interfaces/project_probe.py +75 -0
  119. arcgraph/interfaces/reports.py +1121 -0
  120. arcgraph/interfaces/stdio_encoding.py +29 -0
  121. arcgraph/interfaces/trace.py +592 -0
  122. arcgraph/interfaces/trial_feedback.py +519 -0
  123. arcgraph/interfaces/trial_setup.py +535 -0
  124. arcgraph/interfaces/visual_server.py +354 -0
  125. arcgraph/interfaces/visual_slice.py +168 -0
  126. arcgraph/interfaces/visual_smoke.py +666 -0
  127. arcgraph/interfaces/workbench.py +576 -0
  128. arcgraph/interfaces/workspace.py +1513 -0
  129. arcgraph/pipeline/__init__.py +1 -0
  130. arcgraph/pipeline/contracts.py +122 -0
  131. arcgraph/pipeline/external_frontend.py +451 -0
  132. arcgraph/pipeline/external_l3_frontends.py +368 -0
  133. arcgraph/pipeline/external_protocol.py +358 -0
  134. arcgraph/pipeline/frontends.py +437 -0
  135. arcgraph/pipeline/indexer.py +661 -0
  136. arcgraph/pipeline/openapi_frontend.py +708 -0
  137. arcgraph/pipeline/python_frontend.py +298 -0
  138. arcgraph/pipeline/reindexer.py +1333 -0
  139. arcgraph/pipeline/scip_frontend.py +726 -0
  140. arcgraph/pipeline/typescript_extractor/declarations.mjs +183 -0
  141. arcgraph/pipeline/typescript_extractor/edges.mjs +2794 -0
  142. arcgraph/pipeline/typescript_extractor/graph_primitives.mjs +189 -0
  143. arcgraph/pipeline/typescript_extractor/module_resolution.mjs +877 -0
  144. arcgraph/pipeline/typescript_extractor/runtime.mjs +56 -0
  145. arcgraph/pipeline/typescript_extractor/similarity.mjs +619 -0
  146. arcgraph/pipeline/typescript_extractor/similarity_runner.mjs +19 -0
  147. arcgraph/pipeline/typescript_extractor/sources.mjs +23 -0
  148. arcgraph/pipeline/typescript_extractor.mjs +177 -0
  149. arcgraph/pipeline/typescript_frameworks.py +947 -0
  150. arcgraph/pipeline/typescript_frontend.py +656 -0
  151. arcgraph/pipeline/typescript_references.mjs +167 -0
  152. arcgraph/providers/__init__.py +27 -0
  153. arcgraph/providers/context_provider.py +1258 -0
  154. arcgraph/providers/flow_payload.py +117 -0
  155. arcgraph/providers/memory_connector.py +216 -0
  156. arcgraph/providers/orchestrator.py +475 -0
  157. arcgraph/providers/relationship_payload.py +102 -0
  158. arcgraph/providers/risk_provider.py +843 -0
  159. arcgraph/providers/why_provider.py +349 -0
  160. arcgraph/version_info.py +246 -0
  161. arcgraph-0.1.0.dist-info/METADATA +676 -0
  162. arcgraph-0.1.0.dist-info/RECORD +165 -0
  163. arcgraph-0.1.0.dist-info/WHEEL +4 -0
  164. arcgraph-0.1.0.dist-info/entry_points.txt +2 -0
  165. arcgraph-0.1.0.dist-info/licenses/LICENSE +203 -0
@@ -0,0 +1,481 @@
1
+ """Explicit multi-client onboarding. No client approval or model call is implied."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import math
9
+ import os
10
+ from pathlib import Path
11
+ import shlex
12
+ import stat
13
+ import sys
14
+ import subprocess
15
+ import tempfile
16
+ import tomllib
17
+ from typing import Any
18
+
19
+ import yaml
20
+
21
+ from arcgraph.interfaces.trial_setup import _arcgraph_executable_selection
22
+
23
+ CLIENTS = ("claude", "codex", "cursor", "hermes", "pi")
24
+
25
+
26
+ def add_setup_parser(subparsers: Any) -> None:
27
+ parser = subparsers.add_parser(
28
+ "setup",
29
+ help="Build/refresh an index and connect a client, or preview with --dry-run.",
30
+ )
31
+ parser.add_argument("--client", choices=CLIENTS, required=True)
32
+ parser.add_argument(
33
+ "--dry-run",
34
+ action="store_true",
35
+ help="Inspect and render a plan without writes, indexing or subprocesses.",
36
+ )
37
+ parser.add_argument(
38
+ "--client-config",
39
+ type=Path,
40
+ help="Explicit client config path (Pi: SKILL.md); useful for isolated profiles.",
41
+ )
42
+ parser.add_argument(
43
+ "--timeout",
44
+ type=float,
45
+ default=300,
46
+ help="Positive per-step timeout in seconds.",
47
+ )
48
+ parser.set_defaults(handler=handle_setup)
49
+
50
+
51
+ # An NTFS junction redirects a directory like a symlink, but is_symlink() is
52
+ # False for it. Other reparse points, such as cloud-file placeholders, are
53
+ # ordinary directories and stay allowed.
54
+ _IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003
55
+
56
+
57
+ def _is_link(path: Path) -> bool:
58
+ if path.is_symlink():
59
+ return True
60
+ try:
61
+ tag = getattr(os.lstat(path), "st_reparse_tag", 0)
62
+ except OSError:
63
+ return False
64
+ return tag == _IO_REPARSE_TAG_MOUNT_POINT
65
+
66
+
67
+ def _no_links(path: Path) -> None:
68
+ for item in (path, *path.parents):
69
+ if _is_link(item):
70
+ raise ValueError(
71
+ "Setup refuses symlinked or junctioned configuration or output paths"
72
+ )
73
+ if item.exists() and item != path and not item.is_dir():
74
+ raise ValueError("A setup parent is not a directory")
75
+ if path.exists() and not path.is_file():
76
+ raise ValueError("Configuration destination is not a regular file")
77
+
78
+
79
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
80
+ result: dict[str, Any] = {}
81
+ for key, value in pairs:
82
+ if key in result:
83
+ raise ValueError("Duplicate configuration key")
84
+ result[key] = value
85
+ return result
86
+
87
+
88
+ class _UniqueYamlLoader(yaml.SafeLoader):
89
+ pass
90
+
91
+
92
+ def _yaml_mapping(loader: _UniqueYamlLoader, node: Any) -> dict[str, Any]:
93
+ loader.flatten_mapping(node)
94
+ return _unique_object(
95
+ [
96
+ (loader.construct_object(k), loader.construct_object(v))
97
+ for k, v in node.value
98
+ ]
99
+ )
100
+
101
+
102
+ _UniqueYamlLoader.add_constructor(
103
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _yaml_mapping
104
+ )
105
+
106
+
107
+ def _load_unique_yaml(text: str) -> Any:
108
+ # SafeLoader subclass with duplicate-key rejection. Avoid yaml.load() so
109
+ # Bandit B506 does not treat this as unsafe full-loader YAML.
110
+ loader = _UniqueYamlLoader(text)
111
+ try:
112
+ return loader.get_single_data()
113
+ finally:
114
+ loader.dispose()
115
+
116
+
117
+ def _config_path(client: str, repo: Path) -> Path:
118
+ if client == "hermes":
119
+ return (
120
+ Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes")))
121
+ / "config.yaml"
122
+ )
123
+ return (
124
+ repo
125
+ / {
126
+ "claude": ".mcp.json",
127
+ "codex": ".codex/config.toml",
128
+ "cursor": ".cursor/mcp.json",
129
+ "pi": ".pi/skills/arcgraph/SKILL.md",
130
+ }[client]
131
+ )
132
+
133
+
134
+ def _guide(executable: str, repo: Path, output: Path) -> str:
135
+ # JSON argv is portable; the shell illustration is explicitly POSIX only.
136
+ argv = [executable, "--repo-root", str(repo), "--output-dir", str(output)]
137
+ return "\n".join(
138
+ [
139
+ "---",
140
+ "name: arcgraph",
141
+ "description: Inspect code relationships and change impact with the local ArcGraph CLI.",
142
+ "---",
143
+ "",
144
+ "# ArcGraph project workflow",
145
+ "",
146
+ "Use the following fixed argument prefix (JSON argv; invoke without a shell where possible):",
147
+ "",
148
+ "```json",
149
+ json.dumps(argv, ensure_ascii=False),
150
+ "```",
151
+ "",
152
+ "POSIX shell example (on Windows use the argv array with the host's native quoting):",
153
+ "```sh",
154
+ shlex.join([*argv, "help"]),
155
+ "```",
156
+ "",
157
+ "1. Run help, then current before trusting graph answers and after editing source.",
158
+ "2. For stale indexes run sync --if-stale with this SAME prefix; build a missing/incompatible index. These are explicit index writes.",
159
+ "3. Use bounded context/explain/impact/callers/callees/tests queries on concrete targets. Inspect freshness, target resolution, warnings, analysis limits and truncation.",
160
+ "4. Use source search for exact text and tests for behavior. Empty or unresolved results do not prove absence. CLI shares the same analysis limitations as MCP.",
161
+ "5. Do not default to --raw, execute project code to index it, or change unrelated client configuration.",
162
+ "",
163
+ ]
164
+ )
165
+
166
+
167
+ def _render(
168
+ client: str, old: bytes | None, name: str, server: dict[str, Any], guide: str
169
+ ) -> bytes:
170
+ if client == "pi":
171
+ result = guide.encode()
172
+ if old is not None and old != result:
173
+ raise ValueError(
174
+ "Existing Pi skill differs; choose another --client-config path or review it manually"
175
+ )
176
+ return result
177
+ try:
178
+ text = (old or b"").decode("utf-8")
179
+ if client == "codex":
180
+ data = tomllib.loads(text)
181
+ elif client == "hermes":
182
+ data = _load_unique_yaml(text) if text.strip() else {}
183
+ else:
184
+ data = (
185
+ json.loads(text, object_pairs_hook=_unique_object)
186
+ if text.strip()
187
+ else {}
188
+ )
189
+ except (ValueError, yaml.YAMLError, UnicodeError) as error:
190
+ raise ValueError(
191
+ "Client configuration is not valid supported JSON/TOML/YAML; existing bytes were not changed"
192
+ ) from error
193
+ if not isinstance(data, dict):
194
+ raise ValueError("Client configuration must be a mapping")
195
+ key = "mcp_servers" if client in {"codex", "hermes"} else "mcpServers"
196
+ servers = data.get(key, {})
197
+ if not isinstance(servers, dict):
198
+ raise ValueError("MCP configuration must be a mapping")
199
+ entry = dict(server)
200
+ if client in {"claude", "cursor"}:
201
+ entry["type"] = "stdio"
202
+ if name in servers:
203
+ if servers[name] != entry:
204
+ raise ValueError(
205
+ "Existing ArcGraph server entry differs; refusing to replace it automatically"
206
+ )
207
+ return old or b""
208
+ if client == "codex":
209
+ # Append one new table; preserve every existing byte/comment and let
210
+ # tomllib reject layouts (e.g. inline sealed tables) that cannot extend.
211
+ result = text + "\n[mcp_servers." + json.dumps(name) + "]\n"
212
+ result += "command = " + json.dumps(server["command"]) + "\n"
213
+ result += "args = " + json.dumps(server["args"]) + "\n"
214
+ try:
215
+ parsed = tomllib.loads(result)
216
+ except ValueError as error:
217
+ raise ValueError(
218
+ "Cannot safely extend this TOML layout; use a separate config"
219
+ ) from error
220
+ expected = {**data, key: {**servers, name: entry}}
221
+ if parsed != expected:
222
+ raise ValueError("TOML append changed unrelated configuration")
223
+ return result.encode()
224
+ data[key] = {**servers, name: entry}
225
+ if client == "hermes":
226
+ return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).encode()
227
+ return (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode()
228
+
229
+
230
+ def _write_config(path: Path, old: bytes | None, new: bytes) -> str | None:
231
+ if old == new:
232
+ return None
233
+ _no_links(path)
234
+ if (path.read_bytes() if path.exists() else None) != old:
235
+ raise ValueError(
236
+ "Configuration changed during setup; rerun instead of overwriting"
237
+ )
238
+ path.parent.mkdir(parents=True, exist_ok=True)
239
+ backup = None
240
+ if old is not None:
241
+ descriptor, backup = tempfile.mkstemp(
242
+ prefix=path.name + ".arcgraph-backup-", dir=path.parent
243
+ )
244
+ with os.fdopen(descriptor, "wb") as stream:
245
+ stream.write(old)
246
+ descriptor, temporary = tempfile.mkstemp(
247
+ prefix=".arcgraph-config-", dir=path.parent
248
+ )
249
+ try:
250
+ with os.fdopen(descriptor, "wb") as stream:
251
+ stream.write(new)
252
+ stream.flush()
253
+ os.fsync(stream.fileno())
254
+ if os.name == "posix" and path.exists():
255
+ os.chmod(temporary, stat.S_IMODE(path.stat().st_mode))
256
+ _no_links(path)
257
+ if (path.read_bytes() if path.exists() else None) != old:
258
+ raise ValueError(
259
+ "Configuration changed during setup; rerun instead of overwriting"
260
+ )
261
+ os.replace(temporary, path)
262
+ finally:
263
+ if os.path.exists(temporary):
264
+ os.unlink(temporary)
265
+ return backup
266
+
267
+
268
+ def _run_json(command: list[str], timeout: float, repo: Path) -> dict[str, Any]:
269
+ result = subprocess.run(
270
+ command,
271
+ cwd=repo,
272
+ capture_output=True,
273
+ text=True,
274
+ encoding="utf-8",
275
+ errors="replace",
276
+ timeout=timeout,
277
+ check=False,
278
+ )
279
+ if result.returncode:
280
+ raise RuntimeError(
281
+ f"ArcGraph {command[-1]} exited {result.returncode}; run the reported command directly for diagnostics"
282
+ )
283
+ data = json.loads(result.stdout)
284
+ if not isinstance(data, dict):
285
+ raise ValueError("Expected an ArcGraph JSON object")
286
+ return data
287
+
288
+
289
+ def _probe(server: dict[str, Any], timeout: float) -> dict[str, Any]:
290
+ import anyio
291
+ from mcp import StdioServerParameters
292
+ from mcp.client import Client
293
+ from mcp.client.stdio import stdio_client
294
+ from arcgraph.interfaces.agent_capabilities import mcp_capability_names
295
+
296
+ async def check() -> dict[str, Any]:
297
+ with anyio.fail_after(timeout):
298
+ with tempfile.TemporaryFile(
299
+ mode="w+", encoding="utf-8", errors="replace"
300
+ ) as errors:
301
+ params = StdioServerParameters(
302
+ command=server["command"], args=server["args"]
303
+ )
304
+ async with Client(
305
+ stdio_client(params, errlog=errors),
306
+ mode="auto",
307
+ raise_exceptions=True,
308
+ read_timeout_seconds=timeout,
309
+ ) as session:
310
+ tools = await session.list_tools()
311
+ names = {tool.name for tool in tools.tools}
312
+ if names != set(mcp_capability_names()):
313
+ raise ValueError("Unexpected MCP tool inventory")
314
+ response = await session.call_tool("arcgraph_index_status", {})
315
+ payload = response.structured_content
316
+ if (
317
+ response.is_error
318
+ or not payload
319
+ or payload.get("freshness", {}).get("status") != "fresh"
320
+ ):
321
+ raise ValueError("MCP index status is not fresh")
322
+ return {
323
+ "status": "pass",
324
+ "tool_count": len(names),
325
+ "index_version": payload.get("index_version"),
326
+ "build_identity": payload.get("build_identity"),
327
+ }
328
+
329
+ return anyio.run(check)
330
+
331
+
332
+ def handle_setup(args: argparse.Namespace) -> dict[str, Any]:
333
+ result: dict[str, Any] = {
334
+ "schema_version": "1.0.0",
335
+ "client": args.client,
336
+ "status": "blocked",
337
+ "configuration_written": False,
338
+ "client_connection_verified": False,
339
+ "model_call_verified": False,
340
+ "_exit_code": 1,
341
+ }
342
+ try:
343
+ if not math.isfinite(args.timeout) or args.timeout <= 0:
344
+ raise ValueError("--timeout must be positive and finite")
345
+ repo = Path(args.repo_root).resolve()
346
+ if not repo.is_dir():
347
+ raise ValueError("Repository root is not a directory")
348
+ output = Path(os.path.abspath(repo / args.output_dir))
349
+ # Validate ancestors without rejecting the index directory itself.
350
+ _no_links(output / "current.json")
351
+ config = Path(
352
+ os.path.abspath(args.client_config or _config_path(args.client, repo))
353
+ )
354
+ _no_links(config)
355
+ selected = _arcgraph_executable_selection()
356
+ executable = str(selected.path)
357
+ if (
358
+ selected.source not in {"invocation", "distribution_record"}
359
+ or not selected.path.is_file()
360
+ ):
361
+ raise ValueError(
362
+ "Use an installed arcgraph executable belonging to this runtime"
363
+ )
364
+ name = "arcgraph-" + hashlib.sha256(str(repo).encode()).hexdigest()[:12]
365
+ prefix = [executable, "--repo-root", str(repo), "--output-dir", str(output)]
366
+ server = {
367
+ "command": executable,
368
+ "args": [
369
+ "mcp",
370
+ "serve",
371
+ "--repo-root",
372
+ str(repo),
373
+ "--output-dir",
374
+ str(output),
375
+ ],
376
+ }
377
+ guide = _guide(executable, repo, output)
378
+ old = config.read_bytes() if config.exists() else None
379
+ rendered = _render(args.client, old, name, server, guide)
380
+ result.update(
381
+ {
382
+ "repo_root": str(repo),
383
+ "output_dir": str(output),
384
+ "client_config": str(config),
385
+ "server_name": name if args.client != "pi" else None,
386
+ "transport": "cli_skill" if args.client == "pi" else "stdio",
387
+ "serve_command": (
388
+ [server["command"], *server["args"]]
389
+ if args.client != "pi"
390
+ else None
391
+ ),
392
+ "cli_prefix": prefix,
393
+ "planned_change": old != rendered,
394
+ "configuration_entry": (
395
+ server if args.client != "pi" else {"skill": guide}
396
+ ),
397
+ "guidance": guide,
398
+ "warnings": (
399
+ [
400
+ "Hermes writes its selected profile config; JSON/YAML edits preserve values but may reformat comments. Existing files are backed up."
401
+ ]
402
+ if args.client == "hermes"
403
+ else []
404
+ ),
405
+ "next_steps": [
406
+ "Open the target project in the selected client; approve project trust/MCP if requested and reconnect.",
407
+ "Ask the agent to call arcgraph_help then arcgraph_index_status (Pi: use the arcgraph skill and CLI help/current).",
408
+ "Registration and a protocol probe do not prove that the client exposed tools to its model.",
409
+ ],
410
+ }
411
+ )
412
+ if args.dry_run:
413
+ result.update(status="planned", _exit_code=0)
414
+ return result
415
+ if args.client != "pi":
416
+ try:
417
+ import mcp.client # noqa: F401
418
+ except ImportError as error:
419
+ raise ValueError(
420
+ "Install arcgraph[mcp] in this same environment before setup"
421
+ ) from error
422
+ print("ArcGraph setup: checking index", file=sys.stderr)
423
+ current_path = output / "current.json"
424
+ current = (
425
+ _run_json([*prefix, "current"], args.timeout, repo)
426
+ if current_path.exists()
427
+ else {}
428
+ )
429
+ if current and Path(current.get("repo_root", "")).resolve() != repo:
430
+ raise ValueError(
431
+ "Existing index belongs to another repository; choose a separate --output-dir"
432
+ )
433
+ if current.get("freshness", {}).get("status") != "fresh":
434
+ operation = (
435
+ "sync"
436
+ if current.get("freshness", {}).get("status") == "stale"
437
+ else "build"
438
+ )
439
+ command = [*prefix, operation] + (
440
+ ["--if-stale"] if operation == "sync" else []
441
+ )
442
+ result["index_command"] = command
443
+ print("ArcGraph setup: " + operation + " index", file=sys.stderr)
444
+ _run_json(command, args.timeout, repo)
445
+ current = _run_json([*prefix, "current"], args.timeout, repo)
446
+ if Path(current.get("repo_root", "")).resolve() != repo:
447
+ raise ValueError("Index repository does not match the selected project")
448
+ if current.get("freshness", {}).get("status") != "fresh":
449
+ raise ValueError("Index is not fresh after initialization")
450
+ result["index_version"] = current.get("index_version")
451
+ if args.client != "pi":
452
+ result["protocol_probe"] = _probe(server, min(args.timeout, 60))
453
+ else:
454
+ _run_json([*prefix, "help"], args.timeout, repo)
455
+ result["cli_probe"] = {"status": "pass"}
456
+ backup = _write_config(config, old, rendered)
457
+ result.update(
458
+ status="prepared",
459
+ configuration_written=old != rendered,
460
+ backup_path=backup,
461
+ _exit_code=0,
462
+ )
463
+ except (
464
+ OSError,
465
+ ValueError,
466
+ RuntimeError,
467
+ ImportError,
468
+ subprocess.SubprocessError,
469
+ ) as error:
470
+ result["error"] = (
471
+ str(error)
472
+ if isinstance(error, (ValueError, RuntimeError))
473
+ else type(error).__name__
474
+ )
475
+ except Exception as error:
476
+ # MCP SDK errors may be exception groups; don't serialize transport
477
+ # internals or client configuration into user-visible diagnostics.
478
+ result["error"] = (
479
+ f"Setup probe failed ({type(error).__name__}); configuration was not applied"
480
+ )
481
+ return result