Option C Tools
Loading...
Searching...
No Matches
executor.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/executor.py
4
5"""
6Purpose
7-------
8Tool Executor (Layer 4, inner logic) for the ``oct-mcp`` six-layer
9pipeline. Maps validated tool arguments to ``oct`` CLI argv lists and
10delegates execution to :class:`oct.mcp.sandbox.SandboxExecutor`.
11
12Responsibilities
13----------------
14- Provide one ``_build_argv_<tool>()`` function per Phase 5A tool that
15 converts a validated Pydantic model to an ``["oct", subcommand, ...]``
16 argv list.
17- Provide :class:`ToolExecutor` which wires together the Pydantic model
18 dict and the sandbox.
19- Never import oct internals directly — always call the ``oct`` CLI as
20 a subprocess (blueprint §5.5 isolation principle, design decision D3).
21
22Diagnostics
23-----------
24Domain: MCP
25Levels:
26 L2 — lifecycle: tool execution
27 L3 — argv details
28 L4 — deep trace
29
30Contracts
31---------
32- This module does not import ``click`` or the MCP SDK.
33- ``_build_argv_*()`` functions accept a ``dict`` (from
34 ``model.model_dump()``) and return a ``list[str]``. They never raise
35 for valid validated input.
36- :meth:`ToolExecutor.execute` never raises — it returns
37 :class:`~oct.mcp.sandbox.SandboxResult` directly from the sandbox.
38"""
39
40from __future__ import annotations
41
42import sys
43from pathlib import Path
44from typing import Callable
45
46try:
47 from oc_diagnostics import _dbg as _real_dbg
48
49 def _dbg(*args, **kwargs) -> None:
50 _real_dbg(*args, **kwargs)
51except ImportError: # pragma: no cover
52 def _dbg(*args, **kwargs) -> None:
53 return None
54
55from oct.mcp.sandbox import SandboxExecutor, SandboxResult
56
57
58# ---------------------------------------------------------------------
59# argv builders (one per tool)
60# ---------------------------------------------------------------------
61
62
63def _build_argv_lint(args: dict, project_root: Path) -> list[str]:
64 """Build argv for ``oct lint``."""
65 cmd = [sys.executable, "-m", "oct", "lint"]
66 if args.get("json_output", True):
67 cmd.append("--json")
68 profile = args.get("profile")
69 if profile:
70 cmd.extend(["--profile", profile])
71 if args.get("changed", False):
72 cmd.append("--changed")
73 if args.get("fix_headers", False):
74 cmd.append("--fix-headers")
75 jobs = args.get("jobs", 1)
76 if jobs > 1:
77 cmd.extend(["--jobs", str(jobs)])
78 paths = args.get("paths", [])
79 cmd.extend(paths)
80 return cmd
81
82
83def _build_argv_health(args: dict, project_root: Path) -> list[str]:
84 """Build argv for ``oct health``."""
85 cmd = [sys.executable, "-m", "oct", "health"]
86 if args.get("json_output", True):
87 cmd.append("--json")
88 if args.get("verbose", False):
89 cmd.append("--verbose")
90 return cmd
91
92
93def _build_argv_skeleton(args: dict, project_root: Path) -> list[str]:
94 """Build argv for ``oct export-skeleton``.
95
96 OI-502: no longer emits ``--output-dir``; that flag is not accepted
97 by the CLI. The output destination is derived by the CLI from the
98 project root.
99 """
100 cmd = [sys.executable, "-m", "oct", "export-skeleton"]
101 if args.get("json_output", True):
102 cmd.append("--json")
103 return cmd
104
105
106def _build_argv_deps(args: dict, project_root: Path) -> list[str]:
107 """Build argv for ``oct deps``.
108
109 OI-502: no longer emits ``--check``; that flag is not accepted by
110 the CLI. Pin auditing is instead available via ``--audit-pins`` (not
111 modelled here yet).
112 """
113 cmd = [sys.executable, "-m", "oct", "deps"]
114 if args.get("json_output", True):
115 cmd.append("--json")
116 return cmd
117
118
119def _build_argv_test(args: dict, project_root: Path) -> list[str]:
120 """Build argv for ``oct test``.
121
122 OI-502: no longer emits ``--jobs``; the CLI does not accept it and
123 the ``jobs`` field was removed from :class:`OctTestArgs`.
124 """
125 cmd = [sys.executable, "-m", "oct", "test"]
126 if args.get("json_output", True):
127 cmd.append("--json")
128 if args.get("changed", False):
129 cmd.append("--changed")
130 paths = args.get("paths", [])
131 cmd.extend(paths)
132 return cmd
133
134
135def _build_argv_typecheck(args: dict, project_root: Path) -> list[str]:
136 """Build argv for ``oct typecheck``."""
137 cmd = [sys.executable, "-m", "oct", "typecheck"]
138 if args.get("json_output", True):
139 cmd.append("--json")
140 paths = args.get("paths", [])
141 cmd.extend(paths)
142 return cmd
143
144
145def _build_argv_diag(args: dict, project_root: Path) -> list[str]:
146 """Build argv for ``oct diag <subcommand>``.
147
148 OI-502: no longer emits ``--json``; no diag subcommand currently
149 accepts that flag (``validate-config`` may grow one via OI-534, at
150 which point this builder will conditionally re-emit it).
151 """
152 subcommand = args.get("subcommand", "validate-config")
153 cmd = [sys.executable, "-m", "oct", "diag", subcommand]
154 return cmd
155
156
157def _build_argv_git_status(args: dict, project_root: Path) -> list[str]:
158 """Build argv for ``oct git status``."""
159 cmd = [sys.executable, "-m", "oct", "git", "status"]
160 if args.get("json_output", True):
161 cmd.append("--json")
162 if args.get("verbose", False):
163 cmd.append("--verbose")
164 return cmd
165
166
167def _build_argv_git_check(args: dict, project_root: Path) -> list[str]:
168 """Build argv for ``oct git check``."""
169 cmd = [sys.executable, "-m", "oct", "git", "check"]
170 if args.get("json_output", True):
171 cmd.append("--json")
172 if args.get("verbose", False):
173 cmd.append("--verbose")
174 return cmd
175
176
177# ---------------------------------------------------------------------
178# Phase 5B — argv builders for write tools (11)
179# Note: dry_run is determined by SafetyGate, not just by the args dict.
180# Each builder receives a ``_dry_run_override`` key injected by
181# ToolExecutor.execute_write() when SafetyDecision.dry_run is True.
182# ---------------------------------------------------------------------
183
184
185def _build_argv_format(args: dict, project_root: Path) -> list[str]:
186 """Build argv for ``oct format``.
187
188 Uses ``--dry-run`` unless ``apply=True`` AND ``confirm=True``.
189 The safety gate enforces this rule upstream; the argv builder also
190 checks ``_dry_run_override`` (set by execute_write()) as a redundant guard.
191 """
192 cmd = [sys.executable, "-m", "oct", "format"]
193 # SafetyGate passes _dry_run_override=True when apply=False + confirm=True.
194 dry_run = args.get("_dry_run_override", not (args.get("apply") and args.get("confirm")))
195 if dry_run:
196 cmd.append("--dry-run")
197 else:
198 cmd.append("--fix")
199 if args.get("json_output", True):
200 cmd.append("--json")
201 if args.get("changed", False):
202 cmd.append("--changed")
203 if args.get("verbose", False):
204 cmd.append("--verbose")
205 # OI-502: ``jobs`` removed; the formatter argparser does not accept it.
206 cmd.extend(args.get("paths", []))
207 return cmd
208
209
210def _build_argv_docs(args: dict, project_root: Path) -> list[str]:
211 """Build argv for ``oct docs``."""
212 cmd = [sys.executable, "-m", "oct", "docs"]
213 if args.get("all_docs", False):
214 cmd.append("--all")
215 elif args.get("sphinx", False):
216 cmd.append("--sphinx")
217 elif args.get("doxygen", False):
218 cmd.append("--doxygen")
219 if args.get("clean", False):
220 cmd.append("--clean")
221 if args.get("dry_run", True):
222 cmd.append("--dry-run")
223 return cmd
224
225
226def _build_argv_export_source(args: dict, project_root: Path) -> list[str]:
227 """Build argv for ``oct export-source``."""
228 cmd = [sys.executable, "-m", "oct", "export-source"]
229 if args.get("verbose", False):
230 cmd.append("--verbose")
231 if args.get("single_dir", False):
232 cmd.append("--single-dir")
233 if args.get("warn_secrets", True):
234 cmd.append("--warn-secrets")
235 if args.get("block_secrets", False):
236 cmd.append("--block-secrets")
237 return cmd
238
239
240def _build_argv_new(args: dict, project_root: Path) -> list[str]:
241 """Build argv for ``oct new``."""
242 path = args.get("path", "")
243 cmd = [sys.executable, "-m", "oct", "new", path]
244 if args.get("dry_run", True):
245 cmd.append("--dry-run")
246 if args.get("force", False):
247 cmd.append("--force")
248 if args.get("create_test", False):
249 cmd.append("--test")
250 if args.get("verbose", False):
251 cmd.append("--verbose")
252 return cmd
253
254
255def _build_argv_scaffold(args: dict, project_root: Path) -> list[str]:
256 """Build argv for ``oct scaffold``."""
257 name = args.get("name", "")
258 cmd = [sys.executable, "-m", "oct", "scaffold", name]
259 if args.get("dry_run", True):
260 cmd.append("--dry-run")
261 return cmd
262
263
264def _build_argv_clean(args: dict, project_root: Path) -> list[str]:
265 """Build argv for ``oct clean``."""
266 cmd = [sys.executable, "-m", "oct", "clean"]
267 path = args.get("path")
268 if path:
269 cmd.append(path)
270 if args.get("dry_run", True):
271 cmd.append("--dry-run")
272 return cmd
273
274
275def _build_argv_install_hooks(args: dict, project_root: Path) -> list[str]:
276 """Build argv for ``oct install-hooks`` (deprecated; calls git hooks install)."""
277 cmd = [sys.executable, "-m", "oct", "git", "hooks", "install"]
278 if args.get("force", False):
279 cmd.append("--force")
280 return cmd
281
282
283def _build_argv_git_commit(args: dict, project_root: Path) -> list[str]:
284 """Build argv for ``oct git commit``.
285
286 Note: ``--no-verify`` is intentionally omitted — bypassing hooks
287 is never permitted via MCP (design decision D-5B-6).
288 """
289 message = args.get("message", "")
290 cmd = [sys.executable, "-m", "oct", "git", "commit", "-m", message]
291 if args.get("json_output", True):
292 cmd.append("--json")
293 if args.get("force", False):
294 cmd.append("--force")
295 return cmd
296
297
298def _build_argv_git_hooks_install(args: dict, project_root: Path) -> list[str]:
299 """Build argv for ``oct git hooks install``."""
300 cmd = [sys.executable, "-m", "oct", "git", "hooks", "install"]
301 if args.get("force", False):
302 cmd.append("--force")
303 return cmd
304
305
306def _build_argv_git_init(args: dict, project_root: Path) -> list[str]:
307 """Build argv for ``oct git init``."""
308 cmd = [sys.executable, "-m", "oct", "git", "init"]
309 if args.get("dry_run", True):
310 cmd.append("--dry-run")
311 if args.get("github", False):
312 cmd.append("--github")
313 if args.get("no_hooks", False):
314 cmd.append("--no-hooks")
315 return cmd
316
317
318def _build_argv_git_changelog(args: dict, project_root: Path) -> list[str]:
319 """Build argv for ``oct git changelog``."""
320 cmd = [sys.executable, "-m", "oct", "git", "changelog"]
321 if args.get("json_output", True):
322 cmd.append("--json")
323 if args.get("dry_run", True):
324 cmd.append("--dry-run")
325 since = args.get("since")
326 if since:
327 cmd.extend(["--since", since])
328 version = args.get("version")
329 if version:
330 cmd.extend(["--release", version])
331 return cmd
332
333
334# ---------------------------------------------------------------------
335# Argv builder registry
336# ---------------------------------------------------------------------
337
338_ARGV_BUILDERS: dict[str, Callable[[dict, Path], list[str]]] = {
339 # Phase 5A — read-only
340 "oct_lint": _build_argv_lint,
341 "oct_health": _build_argv_health,
342 "oct_skeleton": _build_argv_skeleton,
343 "oct_deps": _build_argv_deps,
344 "oct_test": _build_argv_test,
345 "oct_typecheck": _build_argv_typecheck,
346 "oct_diag": _build_argv_diag,
347 "oct_git_status": _build_argv_git_status,
348 "oct_git_check": _build_argv_git_check,
349 # Phase 5B — write tools
350 "oct_format": _build_argv_format,
351 "oct_docs": _build_argv_docs,
352 "oct_export_source": _build_argv_export_source,
353 "oct_new": _build_argv_new,
354 "oct_scaffold": _build_argv_scaffold,
355 "oct_clean": _build_argv_clean,
356 "oct_install_hooks": _build_argv_install_hooks,
357 "oct_git_commit": _build_argv_git_commit,
358 "oct_git_hooks_install": _build_argv_git_hooks_install,
359 "oct_git_init": _build_argv_git_init,
360 "oct_git_changelog": _build_argv_git_changelog,
361}
362
363
364# ---------------------------------------------------------------------
365# Tool executor
366# ---------------------------------------------------------------------
367
368
370 """Maps MCP tool names and validated args to sandboxed ``oct`` calls.
371
372 Parameters
373 ----------
374 sandbox
375 :class:`~oct.mcp.sandbox.SandboxExecutor` instance (injected
376 so tests can substitute a mock).
377 timeout_default
378 Default subprocess timeout in seconds.
379 timeout_test
380 Override timeout for ``oct_test`` (pytest may need more time).
381 max_output_bytes
382 Hard cap on combined stdout+stderr passed to the sandbox.
383 """
384
386 self,
387 sandbox: SandboxExecutor | None = None,
388 timeout_default: int = 60,
389 timeout_test: int = 300,
390 max_output_bytes: int = 1_048_576,
391 ) -> None:
392 func = "ToolExecutor.__init__"
393 self._sandbox = sandbox or SandboxExecutor()
394 self._timeout_default = timeout_default
395 self._timeout_test = timeout_test
396 self._max_output_bytes = max_output_bytes
397 _dbg("MCP", func, "executor initialised", 2)
398
400 self,
401 tool_name: str,
402 validated_args: dict,
403 project_root: Path,
404 ) -> SandboxResult:
405 """Execute *tool_name* with *validated_args* inside the sandbox.
406
407 Parameters
408 ----------
409 tool_name
410 One of the 9 Phase 5A tool names.
411 validated_args
412 A ``model_dump()`` dict from the corresponding Pydantic model.
413 project_root
414 Absolute project root path (used as subprocess cwd and
415 forwarded to the argv builder).
416
417 Returns
418 -------
419 :class:`~oct.mcp.sandbox.SandboxResult` — never raises.
420 """
421 func = "ToolExecutor.execute"
422 builder = _ARGV_BUILDERS.get(tool_name)
423 if builder is None:
424 msg = f"No argv builder for tool {tool_name!r}"
425 _dbg("MCP", func, f"SYSTEM_ERROR: {msg}", 1)
426 return SandboxResult(
427 exit_code=1,
428 stdout="",
429 stderr=msg,
430 error_message=msg,
431 )
432
433 cmd = builder(validated_args, project_root)
434 _dbg("MCP", func, f"tool={tool_name} cmd={cmd!r}", 3)
435
436 # oct test gets a longer timeout (pytest can be slow).
437 timeout = (
438 self._timeout_test
439 if tool_name == "oct_test"
440 else self._timeout_default
441 )
442
443 return self._sandbox.run(
444 cmd=cmd,
445 cwd=project_root,
446 timeout=timeout,
447 max_output_bytes=self._max_output_bytes,
448 )
449
451 self,
452 tool_name: str,
453 validated_args: dict,
454 project_root: Path,
455 dry_run_override: bool = False,
456 ) -> SandboxResult:
457 """Execute a write tool, injecting the safety-gate dry-run decision.
458
459 Parameters
460 ----------
461 tool_name
462 One of the 11 Phase 5B write tool names.
463 validated_args
464 A ``model_dump()`` dict from the corresponding Pydantic model.
465 project_root
466 Absolute project root path.
467 dry_run_override
468 When ``True`` (as determined by :class:`~oct.mcp.safety.SafetyGate`),
469 injects ``_dry_run_override=True`` into the args dict so that
470 ``_build_argv_format`` always passes ``--dry-run`` regardless of
471 the ``apply`` field value.
472 """
473 func = "ToolExecutor.execute_write"
474 args = dict(validated_args)
475 if dry_run_override:
476 args["_dry_run_override"] = True
477
478 builder = _ARGV_BUILDERS.get(tool_name)
479 if builder is None:
480 msg = f"No argv builder for write tool {tool_name!r}"
481 _dbg("MCP", func, f"SYSTEM_ERROR: {msg}", 1)
482 return SandboxResult(
483 exit_code=1,
484 stdout="",
485 stderr=msg,
486 error_message=msg,
487 )
488
489 cmd = builder(args, project_root)
490 _dbg("MCP", func, f"tool={tool_name} cmd={cmd!r}", 3)
491
492 # Write tools use the default timeout (doc builds can be slow;
493 # git commit runs the quality gate; both are bounded by timeout_default).
494 return self._sandbox.run(
495 cmd=cmd,
496 cwd=project_root,
497 timeout=self._timeout_default,
498 max_output_bytes=self._max_output_bytes,
499 )
None __init__(self, SandboxExecutor|None sandbox=None, int timeout_default=60, int timeout_test=300, int max_output_bytes=1_048_576)
Definition executor.py:391
SandboxResult execute_write(self, str tool_name, dict validated_args, Path project_root, bool dry_run_override=False)
Definition executor.py:456
SandboxResult execute(self, str tool_name, dict validated_args, Path project_root)
Definition executor.py:404
list[str] _build_argv_git_check(dict args, Path project_root)
Definition executor.py:167
list[str] _build_argv_health(dict args, Path project_root)
Definition executor.py:83
list[str] _build_argv_clean(dict args, Path project_root)
Definition executor.py:264
list[str] _build_argv_install_hooks(dict args, Path project_root)
Definition executor.py:275
list[str] _build_argv_new(dict args, Path project_root)
Definition executor.py:240
list[str] _build_argv_git_init(dict args, Path project_root)
Definition executor.py:306
list[str] _build_argv_docs(dict args, Path project_root)
Definition executor.py:210
list[str] _build_argv_diag(dict args, Path project_root)
Definition executor.py:145
None _dbg(*args, **kwargs)
Definition executor.py:49
list[str] _build_argv_typecheck(dict args, Path project_root)
Definition executor.py:135
list[str] _build_argv_test(dict args, Path project_root)
Definition executor.py:119
list[str] _build_argv_git_status(dict args, Path project_root)
Definition executor.py:157
list[str] _build_argv_git_changelog(dict args, Path project_root)
Definition executor.py:318
list[str] _build_argv_git_hooks_install(dict args, Path project_root)
Definition executor.py:298
list[str] _build_argv_lint(dict args, Path project_root)
Definition executor.py:63
list[str] _build_argv_deps(dict args, Path project_root)
Definition executor.py:106
list[str] _build_argv_skeleton(dict args, Path project_root)
Definition executor.py:93
list[str] _build_argv_export_source(dict args, Path project_root)
Definition executor.py:226
list[str] _build_argv_format(dict args, Path project_root)
Definition executor.py:185
list[str] _build_argv_scaffold(dict args, Path project_root)
Definition executor.py:255
list[str] _build_argv_git_commit(dict args, Path project_root)
Definition executor.py:283