oxn 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 (95) hide show
  1. oxn/__init__.py +10 -0
  2. oxn/__main__.py +16 -0
  3. oxn/_app.py +470 -0
  4. oxn/calibration.py +603 -0
  5. oxn/calls.py +282 -0
  6. oxn/check.py +770 -0
  7. oxn/cli.py +256 -0
  8. oxn/config.py +403 -0
  9. oxn/context/__init__.py +15 -0
  10. oxn/context/bm25.py +133 -0
  11. oxn/context/bundle.py +352 -0
  12. oxn/context/decisions.py +46 -0
  13. oxn/context/project.py +34 -0
  14. oxn/doctor.py +94 -0
  15. oxn/graph/__init__.py +1 -0
  16. oxn/graph/algos.py +380 -0
  17. oxn/graph/architecture.py +509 -0
  18. oxn/graph/asp.py +113 -0
  19. oxn/graph/builder.py +313 -0
  20. oxn/graph/contracts.py +357 -0
  21. oxn/graph/depgraph.py +283 -0
  22. oxn/graph/imports.py +470 -0
  23. oxn/graph/indexer.py +433 -0
  24. oxn/graph/manifests.py +393 -0
  25. oxn/graph/model.py +219 -0
  26. oxn/graph/resolve.py +461 -0
  27. oxn/graph/resolve_rust.py +162 -0
  28. oxn/graph/rings.py +72 -0
  29. oxn/graph/rows.py +114 -0
  30. oxn/graph/sources.py +165 -0
  31. oxn/graph/store.py +596 -0
  32. oxn/health.py +205 -0
  33. oxn/init.py +518 -0
  34. oxn/languages.py +68 -0
  35. oxn/llm.py +386 -0
  36. oxn/metrics/__init__.py +26 -0
  37. oxn/metrics/callgraph.py +240 -0
  38. oxn/metrics/cognitive.py +549 -0
  39. oxn/metrics/cohesion.py +242 -0
  40. oxn/metrics/coupling.py +385 -0
  41. oxn/metrics/cyclomatic.py +113 -0
  42. oxn/metrics/engine.py +304 -0
  43. oxn/metrics/halstead.py +129 -0
  44. oxn/metrics/profile.py +197 -0
  45. oxn/metrics/shredding.py +173 -0
  46. oxn/metrics/size.py +250 -0
  47. oxn/profiles/__init__.py +59 -0
  48. oxn/profiles/base.py +535 -0
  49. oxn/profiles/go.py +206 -0
  50. oxn/profiles/java.py +214 -0
  51. oxn/profiles/python.py +238 -0
  52. oxn/profiles/rust.py +186 -0
  53. oxn/profiles/spec.py +305 -0
  54. oxn/profiles/typescript.py +298 -0
  55. oxn/py.typed +0 -0
  56. oxn/render.py +613 -0
  57. oxn/report.py +602 -0
  58. oxn/resolve/__init__.py +25 -0
  59. oxn/resolve/importers.py +201 -0
  60. oxn/resolve/measure.py +424 -0
  61. oxn/resolve/members.py +544 -0
  62. oxn/resolve/project.py +156 -0
  63. oxn/resolve/scopes.py +641 -0
  64. oxn/resolve/symbols.py +366 -0
  65. oxn/retry.py +231 -0
  66. oxn/review.py +475 -0
  67. oxn/rules/__init__.py +0 -0
  68. oxn/rules/adr.py +249 -0
  69. oxn/rules/builtin.py +239 -0
  70. oxn/rules/engine.py +299 -0
  71. oxn/rules/facts.py +262 -0
  72. oxn/rules/model.py +152 -0
  73. oxn/scip/__init__.py +29 -0
  74. oxn/scip/aliases.py +183 -0
  75. oxn/scip/index.py +247 -0
  76. oxn/scip/ingest.py +208 -0
  77. oxn/scip/join.py +401 -0
  78. oxn/scip/runner.py +399 -0
  79. oxn/scip/wire.py +99 -0
  80. oxn/server.py +434 -0
  81. oxn/thresholds.py +186 -0
  82. oxn/vcs/__init__.py +33 -0
  83. oxn/vcs/analysis.py +249 -0
  84. oxn/vcs/log.py +291 -0
  85. oxn/volume/__init__.py +24 -0
  86. oxn/volume/clones.py +360 -0
  87. oxn/volume/erosion.py +96 -0
  88. oxn/volume/scan.py +169 -0
  89. oxn/volume/tokens.py +107 -0
  90. oxn/writers.py +281 -0
  91. oxn-0.1.0.dist-info/METADATA +498 -0
  92. oxn-0.1.0.dist-info/RECORD +95 -0
  93. oxn-0.1.0.dist-info/WHEEL +4 -0
  94. oxn-0.1.0.dist-info/entry_points.txt +2 -0
  95. oxn-0.1.0.dist-info/licenses/LICENSE +21 -0
oxn/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """OXN -- a local architecture and quality gatekeeper for LLM coding agents.
2
+
3
+ This module is imported by every entry point, including the PostToolUse hook that runs
4
+ after every agent edit. Keep it free of imports: see ADR-0002 for the latency budget
5
+ (p95 <= 200 ms per file, of which a bare interpreter already costs ~15 ms).
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["__version__"]
oxn/__main__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Run OXN as a module: ``python -m oxn``.
2
+
3
+ Equivalent to the ``oxn`` console script, and useful wherever that script is not on
4
+ ``PATH`` -- an unactivated virtualenv, a hook whose environment you do not control, ``uv
5
+ run``, or a CI step that only knows an interpreter path.
6
+
7
+ Kept deliberately thin. The PostToolUse hook may invoke OXN this way, so this module must
8
+ respect the same rule as :mod:`oxn.cli`: **nothing heavy at import time.**
9
+ ``tests/test_import_guard.py`` enforces it for both entry points.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from oxn.cli import main
15
+
16
+ main()
oxn/_app.py ADDED
@@ -0,0 +1,470 @@
1
+ """The human-facing typer application.
2
+
3
+ Imported lazily by :func:`oxn.cli.main`, never by the hook fast path. Anything that is
4
+ pleasant for a person and expensive for a machine belongs here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ import typer
13
+
14
+ from oxn import __version__
15
+
16
+ app = typer.Typer(
17
+ name="oxn",
18
+ help="Local architecture and quality gatekeeper for LLM coding agents.",
19
+ no_args_is_help=True,
20
+ add_completion=False,
21
+ )
22
+
23
+
24
+ if TYPE_CHECKING: # pragma: no cover
25
+ from rich.console import Console
26
+
27
+
28
+ def _console() -> Console:
29
+ from rich.console import Console
30
+
31
+ return Console()
32
+
33
+
34
+ @app.command()
35
+ def version() -> None:
36
+ """Print the OXN version."""
37
+ _console().print(f"oxn {__version__}")
38
+
39
+
40
+ @app.command()
41
+ def check(
42
+ paths: list[str] = typer.Argument(None, help="Files or directories to check."),
43
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
44
+ deep: bool = typer.Option(
45
+ False, "--deep", help="Also check repository-scoped contracts. Too slow for a hook."
46
+ ),
47
+ no_baseline: bool = typer.Option(
48
+ False, "--no-baseline", help="Ignore .oxn/baseline.json and report every violation."
49
+ ),
50
+ ) -> None:
51
+ """Check files against the project's architectural and quality invariants.
52
+
53
+ The ``--json`` form is dispatched by :mod:`oxn.cli` before typer is ever imported, so
54
+ this function is only reached on the human path.
55
+ """
56
+ from oxn.check import render, run_check
57
+ from oxn.config import ConfigError
58
+
59
+ console = _console()
60
+ try:
61
+ report = run_check(paths or ["."], deep=deep, use_baseline=not no_baseline)
62
+ except ConfigError as error:
63
+ console.print(f"[red]oxn.yaml[/red]: {error}")
64
+ raise typer.Exit(1) from error
65
+
66
+ if json_output:
67
+ import json
68
+
69
+ console.print_json(json.dumps(report.as_dict()))
70
+ else:
71
+ render(report, console)
72
+ raise typer.Exit(report.exit_code)
73
+
74
+
75
+ @app.command()
76
+ def baseline(
77
+ paths: list[str] = typer.Argument(None, help="Files or directories to record."),
78
+ deep: bool = typer.Option(False, "--deep", help="Include repository-scoped contracts."),
79
+ ) -> None:
80
+ """Record today's violations as accepted debt, so only *new* ones fail.
81
+
82
+ The ratchet, not an amnesty: a recorded violation stops failing the build, but the same
83
+ violation getting worse starts failing it again, and anything new fails immediately.
84
+ This is what makes `oxn check` adoptable on a repository that predates it.
85
+ """
86
+ from oxn.check import run_check, write_baseline
87
+ from oxn.config import Config, ConfigError
88
+
89
+ console = _console()
90
+ try:
91
+ settings = Config.load()
92
+ report = run_check(paths or ["."], config=settings, deep=deep, use_baseline=False)
93
+ except ConfigError as error:
94
+ console.print(f"[red]oxn.yaml[/red]: {error}")
95
+ raise typer.Exit(1) from error
96
+
97
+ if report.errors:
98
+ for path, problem in report.errors.items():
99
+ console.print(f"[red]error[/red] {path}: {problem}")
100
+ raise typer.Exit(1)
101
+
102
+ recorded = write_baseline(report, settings.baseline_path)
103
+ where = settings.baseline_path.relative_to(settings.root)
104
+ console.print(f"recorded [bold]{recorded}[/bold] violation(s) as accepted debt in {where}")
105
+ console.print("[dim]new violations, and these getting worse, will still fail[/dim]")
106
+
107
+
108
+ @app.command()
109
+ def init(
110
+ no_hook: bool = typer.Option(
111
+ False, "--no-hook", help="Write config and docs but do not install the hook."
112
+ ),
113
+ no_mcp: bool = typer.Option(
114
+ False, "--no-mcp", help="Do not wire the MCP server into .mcp.json."
115
+ ),
116
+ github: bool = typer.Option(
117
+ False, "--github", help="Also write the `@oxn` pull-request review workflow."
118
+ ),
119
+ ) -> None:
120
+ """Wire OXN into this repository. Additive and idempotent -- run it twice safely.
121
+
122
+ Nothing is overwritten. `oxn.yaml` is written only if absent, the `CLAUDE.md` section
123
+ lives between markers, and existing `.claude/settings.json` and `.mcp.json` files are
124
+ merged rather than replaced.
125
+ """
126
+ from oxn.init import run_init
127
+
128
+ console = _console()
129
+ report = run_init(with_hook=not no_hook, with_mcp=not no_mcp, with_github=github)
130
+ for label, paths in (
131
+ ("created", report.created),
132
+ ("updated", report.updated),
133
+ ("unchanged", report.unchanged),
134
+ ):
135
+ for path in paths:
136
+ colour = {"created": "green", "updated": "cyan"}.get(label, "dim")
137
+ console.print(f"[{colour}]{label:9}[/{colour}] {path}")
138
+ for note in report.notes:
139
+ console.print(f"[yellow]note[/yellow] {note}")
140
+ console.print(
141
+ "\n[dim]next: `oxn check` to see where you stand, `oxn baseline` to accept it[/dim]"
142
+ )
143
+
144
+
145
+ @app.command()
146
+ def calibration(
147
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
148
+ ) -> None:
149
+ """List every tunable threshold with the evidence behind it.
150
+
151
+ A number with no stated provenance is folklore, and folklore is what makes developers
152
+ distrust a gate. This is also the surface P10's calibration work optimises over.
153
+ """
154
+ from oxn.calibration import gate_status, summary
155
+ from oxn.calibration import parameters as _parameters
156
+ from oxn.config import Config
157
+
158
+ console = _console()
159
+ report = summary()
160
+ active = gate_status(Config.load(Path.cwd()))
161
+ report["disabled"] = sorted(name for name, on in active.items() if not on)
162
+ if json_output:
163
+ import json
164
+
165
+ console.print_json(json.dumps(report))
166
+ return
167
+
168
+ for parameter in _parameters():
169
+ marker = (
170
+ "[yellow]provisional[/yellow]" if parameter.is_provisional else "[green]fitted[/green]"
171
+ )
172
+ state = "" if active.get(parameter.name, True) else " [red]off in this project[/red]"
173
+ console.print(
174
+ f"[bold]{parameter.name}[/bold] = {parameter.value:g} "
175
+ f"{marker} {parameter.evidence.value} n={parameter.observations}{state}"
176
+ )
177
+ console.print(f" [dim]{parameter.provenance}[/dim]")
178
+ if parameter.fit_when:
179
+ console.print(f" [dim]fit when: {parameter.fit_when}[/dim]")
180
+ console.print(f"\n[dim]{report['provisional']}/{report['total']} provisional[/dim]")
181
+ console.print(f"[dim]{report['note']}[/dim]")
182
+
183
+
184
+ @app.command()
185
+ def parse(
186
+ paths: list[str] = typer.Argument(None, help="Files or directories to parse."),
187
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
188
+ force: bool = typer.Option(False, "--force", help="Ignore the cache and re-parse."),
189
+ stats: bool = typer.Option(False, "--stats", help="Show cache totals instead of entities."),
190
+ ) -> None:
191
+ """Parse sources into the code graph and report what was built.
192
+
193
+ A debugging surface for the parse layer: it shows the containment skeleton the metric
194
+ engine will read, and whether a run hit the cache.
195
+ """
196
+ from oxn.render import Output
197
+ from oxn.report import run_parse
198
+
199
+ run_parse(
200
+ paths or ["."],
201
+ Output(console=None if json_output else _console()),
202
+ force=force,
203
+ stats_only=stats,
204
+ )
205
+
206
+
207
+ @app.command()
208
+ def metrics(
209
+ paths: list[str] = typer.Argument(None, help="Files or directories to measure."),
210
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
211
+ sort_by: str = typer.Option("cognitive_complexity", "--sort-by", help="Metric to rank by."),
212
+ limit: int = typer.Option(20, "--limit", help="How many entities to show."),
213
+ explain: bool = typer.Option(
214
+ False, "--explain", help="Show the increment trail for the worst offender."
215
+ ),
216
+ ) -> None:
217
+ """Rank code by a Tier-1 metric.
218
+
219
+ Every point of a cognitive-complexity score can be traced to a line and a reason:
220
+ ``oxn metrics --explain`` prints that trail.
221
+ """
222
+ from oxn.render import Output
223
+ from oxn.report import run_metrics
224
+
225
+ run_metrics(
226
+ paths or ["."],
227
+ Output(console=None if json_output else _console()),
228
+ sort_by=sort_by,
229
+ limit=limit,
230
+ explain=explain,
231
+ )
232
+
233
+
234
+ @app.command()
235
+ def volume(
236
+ paths: list[str] = typer.Argument(None, help="Files or directories to analyse."),
237
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
238
+ no_history: bool = typer.Option(False, "--no-history", help="Skip git history."),
239
+ ) -> None:
240
+ """Report duplication, verbosity, structural erosion and hotspots.
241
+
242
+ These are the signals agent-written code degrades: volume correlates with
243
+ architectural decay where prompt specificity does not.
244
+ """
245
+ from oxn.render import Output
246
+ from oxn.report import run_volume
247
+
248
+ run_volume(
249
+ paths or ["."],
250
+ Output(console=None if json_output else _console()),
251
+ include_history=not no_history,
252
+ )
253
+
254
+
255
+ @app.command()
256
+ def arch(
257
+ paths: list[str] = typer.Argument(None, help="Files or directories to analyse."),
258
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
259
+ granularity: str = typer.Option(
260
+ "directory", "--by", help="Component granularity: directory | file | top."
261
+ ),
262
+ show_unresolved: bool = typer.Option(
263
+ False, "--unresolved", help="List imports that could not be resolved."
264
+ ),
265
+ ) -> None:
266
+ """Report the dependency graph: cycles, Martin metrics, Lakos levels, smells.
267
+
268
+ Report-path only. Building the graph compares every file against every other, so it
269
+ never runs on the hook's per-edit budget.
270
+ """
271
+ from oxn.render import Output
272
+ from oxn.report import run_arch
273
+
274
+ run_arch(
275
+ paths or ["."],
276
+ Output(console=None if json_output else _console()),
277
+ granularity=granularity,
278
+ show_unresolved=show_unresolved,
279
+ )
280
+
281
+
282
+ @app.command()
283
+ def index(
284
+ paths: list[str] = typer.Argument(None, help="Directory to index."),
285
+ language: str = typer.Option("python", "--language", help="Language to index."),
286
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
287
+ reuse: str = typer.Option(
288
+ "", "--index-file", help="Ingest an existing .scip file instead of generating one."
289
+ ),
290
+ ) -> None:
291
+ """Build a SCIP index and merge compiler-grade symbols into the graph.
292
+
293
+ This is resolution rung L2 (ADR-0002): it makes coupling and call-graph metrics exact
294
+ rather than heuristic. It needs a per-language indexer, which OXN detects but never
295
+ installs -- run `oxn doctor` to see what is available.
296
+ """
297
+ from oxn.render import Output
298
+ from oxn.report import run_index
299
+
300
+ run_index(
301
+ paths or ["."],
302
+ Output(console=None if json_output else _console()),
303
+ language=language,
304
+ index_file=reuse or None,
305
+ )
306
+
307
+
308
+ @app.command()
309
+ def calls(
310
+ paths: list[str] = typer.Argument(None, help="Files or directories to analyse."),
311
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
312
+ limit: int = typer.Option(20, "--limit", help="How many rows to show."),
313
+ ) -> None:
314
+ """Fan-in, fan-out, recursion and dead-code candidates over the call graph.
315
+
316
+ Needs L2. OXN recovers call edges from a SCIP index and never invents them from names,
317
+ so on a tree that has not been indexed this says it has no call graph rather than
318
+ reporting an empty one -- see `oxn index`.
319
+ """
320
+ from oxn.calls import run_calls
321
+ from oxn.render import Output
322
+
323
+ run_calls(
324
+ paths or ["."],
325
+ Output(console=None if json_output else _console()),
326
+ limit=limit,
327
+ )
328
+
329
+
330
+ @app.command()
331
+ def review(
332
+ base: str = typer.Option("origin/main", "--base", help="Ref the pull request targets."),
333
+ head: str = typer.Option("HEAD", "--head", help="Ref under review."),
334
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
335
+ write: str = typer.Option(
336
+ "", "--write", help="Backend that turns the measurement into prose: `ollama`."
337
+ ),
338
+ model: str = typer.Option("", "--model", help="Model for `--write`."),
339
+ ) -> None:
340
+ """Measure what a pull request changed, and optionally write the comment.
341
+
342
+ Without `--write` this prints the measurement and nothing else, which is the half OXN is
343
+ sure about. With it, a model turns the measurement into review prose -- and any comment
344
+ stating a number the measurement does not contain is **refused rather than posted**.
345
+
346
+ This never gates. The CI job running `oxn check` already does, and two gates disagreeing
347
+ about one pull request is worse than one gate.
348
+ """
349
+ from oxn.render import Output
350
+ from oxn.report import run_review_report
351
+
352
+ run_review_report(
353
+ base,
354
+ head,
355
+ Output(console=None if json_output else _console()),
356
+ write=write,
357
+ model=model,
358
+ )
359
+
360
+
361
+ @app.command()
362
+ def classes(
363
+ paths: list[str] = typer.Argument(None, help="Files or directories to analyse."),
364
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
365
+ sort_by: str = typer.Option(
366
+ "lcom_star", "--sort-by", help="lcom_star | lcom4 | wmc | cbo | rfc"
367
+ ),
368
+ limit: int = typer.Option(20, "--limit", help="How many classes to show."),
369
+ ) -> None:
370
+ """Report class cohesion (LCOM) and coupling (the CK suite).
371
+
372
+ These are the metrics that detect the "Modular Mirage" -- file-level modularity with no
373
+ semantic cohesion -- which is the documented signature of agent-written code.
374
+ """
375
+ from oxn.render import Output
376
+ from oxn.report import run_classes
377
+
378
+ run_classes(
379
+ paths or ["."],
380
+ Output(console=None if json_output else _console()),
381
+ sort_by=sort_by,
382
+ limit=limit,
383
+ )
384
+
385
+
386
+ @app.command()
387
+ def health(
388
+ paths: list[str] = typer.Argument(None, help="Files or directories to report on."),
389
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
390
+ limit: int = typer.Option(10, "--limit", help="Entities to name per profile."),
391
+ ) -> None:
392
+ """Risk profiles: how much of the code is over budget, and which code.
393
+
394
+ The informational output of `docs/metrics.md` section 10.1 -- it never blocks, and it
395
+ reports no single score. Every share names the entities that carry it, because a number
396
+ on its own is not something an agent or a person can act on.
397
+ """
398
+ from oxn.health import run_health
399
+ from oxn.render import Output
400
+
401
+ run_health(
402
+ paths or ["."],
403
+ Output(console=None if json_output else _console()),
404
+ limit=limit,
405
+ )
406
+
407
+
408
+ @app.command()
409
+ def doctor() -> None:
410
+ """Report the environment OXN found: grammars, toolchains, and indexers."""
411
+ from oxn.doctor import run_doctor
412
+
413
+ run_doctor(_console())
414
+
415
+
416
+ @app.command()
417
+ def context(
418
+ task: str = typer.Argument(..., help="What you are about to do, in your own words."),
419
+ files: list[str] = typer.Option(
420
+ None, "--file", "-f", help="Files the task is about. Scope beats wording (ADR-0006)."
421
+ ),
422
+ limit: int = typer.Option(0, "--limit", help="Constraints to show; 0 uses the budget."),
423
+ json_output: bool = typer.Option(False, "--json", help="The bundle as typed JSON."),
424
+ ) -> None:
425
+ """Show the constraints that govern a task -- ranked, capped, and never a gate.
426
+
427
+ This is the human view of what P9's MCP `get_architectural_context` will serve. It
428
+ cannot pass or fail anything: ADR-0006 keeps retrieval out of the gate entirely.
429
+ """
430
+ from oxn import thresholds
431
+ from oxn.config import Config
432
+ from oxn.context.bundle import build_bundle
433
+ from oxn.context.project import load_project
434
+
435
+ bundle = build_bundle(
436
+ load_project(Config.load()),
437
+ task=task,
438
+ targets=files or (),
439
+ limit=limit or thresholds.MAX_BUNDLE_CONSTRAINTS,
440
+ )
441
+
442
+ console = _console()
443
+ if json_output:
444
+ console.print_json(bundle.model_dump_json())
445
+ return
446
+ for constraint in bundle.constraints:
447
+ mark = "gated" if constraint.enforced else "prose"
448
+ console.print(f"[bold]{constraint.statement}[/bold]")
449
+ console.print(f" {mark} · {constraint.source} · governs {constraint.governs} file(s)")
450
+ console.print(f"\n{bundle.omitted} more not shown. {bundle.note}")
451
+
452
+
453
+ @app.command()
454
+ def serve(
455
+ root: str = typer.Option(
456
+ "", "--root", help="The repository to serve. Defaults to $OXN_ROOT, else discovery."
457
+ ),
458
+ ) -> None:
459
+ """Speak MCP over stdio, so an agent can ask before it edits.
460
+
461
+ The server is also OXN's warm process (ADR-0004): it is alive for the whole session, so
462
+ "current when the agent asks" costs no daemon. It informs and never enforces -- the gate
463
+ is the `PostToolUse` hook, and `check_code` here reaches the same verdict without being
464
+ able to stop anything.
465
+
466
+ Wire it with `claude mcp add oxn -- oxn serve`. Nothing is printed: stdout is the wire.
467
+ """
468
+ from oxn.server import serve as run_server
469
+
470
+ run_server(root=root or None)