Option C Tools
Loading...
Searching...
No Matches
cli.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/cli.py
4
5"""
6Purpose
7-------
8Provide the main command-line interface for the OCT (Option C Tools) ecosystem.
9
10Responsibilities
11----------------
12- Define top-level commands: oct lint, oct format, oct new, oct scaffold, oct docs,
13 oct test, oct export-source, oct clean, oct health.
14- Delegate work to the appropriate OCT modules.
15- Scaffold new Option C projects with a correct ``oc_diagnostics/debug_config.json``
16 pre-configured for use with ``oc_diagnostics``.
17- Set up Sphinx documentation templates in ``docs/``.
18- Ensure consistent behavior across all projects.
19
20Diagnostics
21-----------
22Domain: OCT-CLI
23Levels:
24 L2 — lifecycle
25 L3 — semantic details
26 L4 — deep tracing
27
28Contracts
29---------
30- Must be installed as the console entry point for the OCT package.
31- Must not contain business logic; only dispatching.
32- Scaffolded ``debug_config.json`` must be a valid ``oc_diagnostics`` configuration.
33
34Dependencies
35------------
36- oct (package version)
37- oct.core.project_root (project root detection)
38- oct.linter.oct_lint (linter command implementation)
39- oct.formatter.oct_format (formatter command implementation)
40- oct.health.oct_health (health dashboard command)
41- oct.generator.new_python_file (new file scaffolding)
42- oct.diag.oct_diag (diagnostics config commands)
43- oct.deps.oct_deps (dependency graph command)
44- oct.hooks.oct_hooks (pre-commit hooks generator)
45- oct.typecheck.oct_typecheck (type-checking wrapper)
46- click (CLI framework)
47"""
48
49import json
50import os
51import click
52from pathlib import Path
53
54from oct import __version__
55from oct.core.project_root import find_project_root, get_project_root
56from oct.linter.oct_lint import run_linter
57from oct.formatter.oct_format import run_formatter
58from oct.health.oct_health import run_health
59from oct.generator.new_python_file import create_new_file
60from oct.diag.oct_diag import (
61 list_domains,
62 migrate_config,
63 restore_config,
64 set_level,
65 validate_config,
66)
67from oct.deps.oct_deps import run_deps
68from oct.hooks.oct_hooks import install_hooks
69from oct.typecheck.oct_typecheck import run_typecheck
70
71
72_COMMAND_FLAGS = {
73 "lint": [
74 ("--fix-headers", "Automatically fix shebang, encoding, and path headers"),
75 ("--dry-run", "Preview changes without modifying files"),
76 ("--json", "Emit machine-readable JSON to stdout"),
77 ("--jobs N, -j N", "Parallel workers (default: 1)"),
78 ("--changed", "Lint only git-modified Python files"),
79 ("--profile P", "Lint profile: proto, compact, strict"),
80 ("--secret-match MODE", "Secret-name matcher: substring (default) | word"),
81 ("--code-entropy-threshold N", "Shannon entropy threshold for code strings (default: 4.5)"),
82 ("--no-entropy", "Disable entropy-based secret detection"),
83 ("--no-cache", "Bypass lint cache reads (still writes)"),
84 ("--baseline", "Save current violations as the baseline"),
85 ("--against-baseline", "Report only new/resolved vs saved baseline"),
86 ("--explain RULE", "Show rule documentation (rationale, pattern, spec ref)"),
87 ("PATH ...", "Files or directories to lint (default: entire project)"),
88 ],
89 "format": [
90 ("--fix", "Apply formatting changes (default: dry-run)"),
91 ("--dry-run", "Report what would change without modifying"),
92 ("--json", "Output machine-readable JSON"),
93 ("--verbose", "Show per-file details"),
94 ("--changed", "Format only git-modified Python files"),
95 ],
96 "new": [
97 ("PATH", "Target file path (required)"),
98 ("--force", "Overwrite existing file"),
99 ("--test", "Also generate companion test file in tests/"),
100 ("--dry-run", "Show what would be created without writing"),
101 ("--verbose", "Show generated file content"),
102 ],
103 "scaffold": [
104 ("NAME", "Project name (required)"),
105 ("--dry-run", "Show what would be created without writing"),
106 ("--no-venv", "Skip .venv/ creation (FS-503: on by default)"),
107 ("--with-claude", "Copy bundled Claude Code skills into .claude/skills/"),
108 ],
109 "docs": [
110 ("--sphinx", "Generate Sphinx HTML documentation"),
111 ("--doxygen", "Generate Doxygen HTML documentation"),
112 ("--all", "Generate both Sphinx and Doxygen documentation"),
113 ("--clean", "Remove generated documentation artifacts"),
114 ("--dry-run", "Preview what --clean would remove"),
115 ],
116 "test": [
117 ("--json", "Emit machine-readable JSON summary to stdout"),
118 ("--changed", "Run tests for git-modified files only"),
119 ],
120 "export-source": [
121 ("--verbose", "Show per-file statistics"),
122 ("--clean", "[DEPRECATED: use 'oct clean'] Remove _source_code-* dirs"),
123 ("--single-dir", "Write all output into one root-level directory"),
124 ("--diff REF", "Export only files changed since REF"),
125 ("--warn-secrets", "Warn on suspected secrets in exported files"),
126 ("--block-secrets", "Abort export when secrets are detected"),
127 ("--allow-outside-project", "Allow export targets outside the project root"),
128 ],
129 "export-skeleton": [
130 ("--json", "Emit machine-readable JSON summary to stdout"),
131 ("--verbose", "Show per-file statistics"),
132 ("--single-dir", "Write all output into one root-level directory"),
133 ("--no-diagnostics", "Omit Diagnostics section from docstrings"),
134 ("--clean", "Remove previously exported skeletons"),
135 ("--allow-outside-project", "Allow skeleton targets outside the project root"),
136 ],
137 "clean": [
138 ("--dry-run", "Show what would be deleted without deleting"),
139 ("--allow-outside-project", "Allow cleaning paths outside the project root"),
140 ],
141 "health": [
142 ("--json", "Machine-readable JSON output"),
143 ("--verbose", "Detailed per-file breakdown"),
144 ("--timeout N", "Per-check timeout in seconds (default: 300)"),
145 ],
146 "deps": [
147 ("--json", "JSON adjacency list output"),
148 ("--mermaid", "Mermaid diagram output"),
149 ("--dot", "DOT/Graphviz output"),
150 ],
151 "install": [
152 ("TARGET", "Project root to install (default: current directory)"),
153 ("--editable, -e", "Install in editable mode (required)"),
154 ("--reinstall", "Allow re-install over an existing shadow"),
155 ("--use-active-venv", "Bypass venv-match check"),
156 ("--allow-worktree", "Allow installing from a Claude Code worktree"),
157 ("--allow-outside-project", "Allow running outside an Option C project"),
158 ("--json", "Emit JSON output"),
159 ],
160 "install-hooks": [
161 ("--force", "Overwrite existing .pre-commit-config.yaml"),
162 ],
163 "git": [
164 ("status", "Show git status with Option C compliance annotations"),
165 ("check", "Run the unified quality gate (lint + format + secrets)"),
166 ("init", "Initialise or enhance a git repo with Option C conventions"),
167 ("commit", "Commit with quality gate and secrets enforcement"),
168 ("branch", "Create or switch branches with naming validation"),
169 ("add", "Stage files for commit (whole-file or interactive)"),
170 ("restore", "Restore working-tree edits or unstage paths"),
171 ("ignore", "Manage .gitignore patterns within the OCT-managed block"),
172 ("commit-msg", "Validate a commit message file (pre-commit stage)"),
173 ("hooks", "Manage Option C git hooks"),
174 ("changelog", "Generate changelog from Conventional Commits history"),
175 ],
176 "git status": [
177 ("--json", "Emit machine-readable JSON output"),
178 ("--no-check", "Skip compliance annotations (plain git status)"),
179 ("--scope SCOPE", "Scope: project (default), repo, auto"),
180 ],
181 "git add": [
182 ("PATHS...", "Files or directories to stage"),
183 ("--patch, -p", "Interactive hunk-level staging"),
184 ("--update, -u", "Stage all already-tracked modified files"),
185 ("--all, -A", "Stage all changes within project scope"),
186 ("--dry-run", "Show what would be staged without staging"),
187 ("--json", "Emit JSON output"),
188 ],
189 "git ignore": [
190 ("PATTERNS...", "Glob patterns to add to .gitignore"),
191 ("--remove PATTERN", "Remove a pattern from the OCT-managed block"),
192 ("--list", "List currently active ignore patterns"),
193 ("--scope SCOPE", "Target .gitignore: workspace, project, nearest (default)"),
194 ("--json", "Emit JSON output"),
195 ],
196 "git restore": [
197 ("PATHS...", "Files to restore (default: scope by --all)"),
198 ("--staged", "Unstage paths from the index without touching working tree"),
199 ("--all, -A", "Restore all in-scope paths"),
200 ("--yes", "Skip confirmation prompt for --staged --all"),
201 ("--json", "Emit JSON output"),
202 ],
203 "git init": [
204 ("--dry-run", "Show what would be created without writing"),
205 ("--github", "Also generate GitHub Actions workflow"),
206 ("--no-hooks", "Skip hook installation"),
207 ],
208 "git branch": [
209 ("--create NAME", "Create a new branch from HEAD"),
210 ("--switch NAME", "Switch to an existing branch"),
211 ("--json", "Emit JSON output"),
212 ],
213 "git commit": [
214 ("-m MESSAGE, --message MESSAGE", "Commit message (Conventional Commits format)"),
215 ("--force", "Skip lint/format checks (secrets still checked)"),
216 ("--no-verify", "Forward --no-verify to git (hooks skipped)"),
217 ("--profile P", "Override lint profile: proto, compact, strict"),
218 ("--json", "Emit JSON output"),
219 ("--ignore-unstaged-mutation", "Downgrade B1.0 mutation detection to warn for this run"),
220 ],
221 "git commit-msg": [
222 ("MESSAGE_FILE", "Path to the commit message file (required)"),
223 ("--json", "Emit machine-readable JSON output"),
224 ],
225 "git check": [
226 ("--staged-only", "Check only staged (index) files"),
227 ("--all", "Check every Python file in the project"),
228 ("--include-tests", "Also run `oct test` (for pre-push use)"),
229 ("--fix", "Attempt auto-fix of violations, then re-check"),
230 ("--profile P", "Override lint profile: proto, compact, strict"),
231 ("--json", "Emit machine-readable JSON output"),
232 ("--sandbox", "Run pytest under the MCP sandbox environment"),
233 ],
234 "git hooks": [
235 ("install", "Generate .pre-commit-config.yaml with all hooks"),
236 ("status", "Show installed hook state"),
237 ("remove", "Remove Option C hooks (preserves non-OCT hooks)"),
238 ],
239 "git changelog": [
240 ("--release V", "Release version tag (e.g. v0.13.0)"),
241 ("--since REF", "Git ref to start from (default: last tag)"),
242 ("--dry-run", "Print to stdout without writing"),
243 ("--json", "Emit structured JSON"),
244 ],
245 "git hooks install": [
246 ("--force", "Overwrite existing configuration"),
247 ],
248 "typecheck": [
249 ("--json", "Emit machine-readable JSON summary to stdout"),
250 ("ARGS ...", "Arguments forwarded to pyright/mypy"),
251 ],
252 "diag": [
253 ("validate-config", "Validate debug_config.json against schema"),
254 (" --strict", "Treat validation warnings as errors"),
255 ("list-domains", "List configured domains with levels and colors"),
256 ("set-level DOMAIN N", "Set a domain's debug level (0-4)"),
257 (" --dry-run", "Preview edit without writing (use with set-level)"),
258 ("restore", "Restore debug_config.json from .bk backup"),
259 ("migrate-config [PATH]", "Upgrade legacy v1 schema to v2.0"),
260 (" --dry-run", "Preview migration without writing (use with migrate-config)"),
261 ],
262 "install-mcp": [
263 ("--host HOST", "Target AI host: claude-code, cursor, vscode, auto (default: auto)"),
264 ("--dry-run / --no-dry-run", "Preview without writing (default: --dry-run)"),
265 ],
266 "migrate": [
267 ("option-c", "Migrate to canonical .option_c/ dotdir layout (FS-539)"),
268 ],
269 "migrate option-c": [
270 ("--dry-run", "Show plan without writing anything"),
271 ("--no-backup", "Skip .bk backup when overwriting"),
272 ],
273 "audit": [
274 ("--json-output", "Emit JSON instead of human-readable text"),
275 ],
276}
277
278
279class OctGroup(click.Group):
280 """Custom group that shows per-command flags in the main --help output."""
281
282 def format_help(self, ctx, formatter):
283 formatter.write(f"oct {__version__}\n")
284 formatter.write_paragraph()
285 self.format_usage(ctx, formatter)
286
287 if self.help:
288 formatter.write_paragraph()
289 with formatter.indentation():
290 formatter.write(self.help)
291 formatter.write_paragraph()
292
293 # Global options
294 opts = []
295 for param in self.get_params(ctx):
296 rv = param.get_help_record(ctx)
297 if rv:
298 opts.append(rv)
299 if opts:
300 with formatter.section("Global Options"):
301 formatter.write_dl(opts)
302
303 # Commands with inline flags
304 commands = []
305 for name in self.list_commands(ctx):
306 cmd = self.get_command(ctx, name)
307 if cmd is None or cmd.hidden:
308 continue
309 commands.append((name, cmd))
310
311 if commands:
312 with formatter.section("Commands"):
313 for name, cmd in commands:
314 short_help = cmd.get_short_help_str(limit=60)
315 formatter.write(f" {name:<16} {short_help}\n")
316 flags = _COMMAND_FLAGS.get(name, [])
317 for flag, desc in flags:
318 formatter.write(f" {flag:<22} {desc}\n")
319 if flags:
320 formatter.write("\n")
321
322
323@click.group(cls=OctGroup)
324@click.version_option(__version__, "-V", "--version", prog_name="oct",
325 message="%(prog)s %(version)s")
326@click.option("--root-dir", default=None, type=click.Path(exists=True, file_okay=False),
327 help="Override project root directory (skip auto-detection).")
328@click.option("--allow-outside-project", is_flag=True,
329 help="Allow operations outside auto-detected project root (OI-405).")
330@click.option("--strict-config", is_flag=True,
331 help="Fail on malformed .octrc.json or schema errors (OI-411).")
332@click.option("--log", "log_output", is_flag=True,
333 help="Persist command output as JSON to .option_c/logs/.")
334@click.pass_context
335def cli(ctx, root_dir, allow_outside_project, strict_config, log_output):
336 """OCT — Option C Tools"""
337 try:
338 from oc_diagnostics import init, set_global_setting
339 init(intercept_streams=False)
340 set_global_setting("output_mode", "file")
341 except ImportError:
342 pass
343 ctx.ensure_object(dict)
344 ctx.obj["root_dir"] = root_dir
345 ctx.obj["allow_outside_project"] = allow_outside_project
346 ctx.obj["strict_config"] = strict_config
347 ctx.obj["log_output"] = log_output
348 if strict_config:
349 os.environ["OCT_STRICT_CONFIG"] = "1"
350
351
352@cli.command(context_settings=dict(ignore_unknown_options=True))
353@click.argument("args", nargs=-1, type=click.UNPROCESSED)
354@click.pass_context
355def lint(ctx, args):
356 """Run the Option C linter."""
357 project_root = get_project_root(ctx)
358 exit_code = run_linter(project_root, list(args))
359 if exit_code != 0:
360 raise SystemExit(exit_code)
361
362
363@cli.command(context_settings=dict(ignore_unknown_options=True))
364@click.argument("args", nargs=-1, type=click.UNPROCESSED)
365@click.pass_context
366def format(ctx, args):
367 """Auto-fix Option C compliance violations in Python files."""
368 project_root = get_project_root(ctx)
369 run_formatter(project_root, list(args))
370
371
372@cli.command()
373@click.argument("path")
374@click.option("--force", is_flag=True, help="Overwrite existing file")
375@click.option("--test", is_flag=True, help="Also generate companion test file")
376@click.option("--dry-run", is_flag=True, help="Show what would be created without writing anything")
377@click.option("--verbose", is_flag=True, help="Show generated file content")
378@click.pass_context
379def new(ctx, path, force, test, dry_run, verbose):
380 """Create a new Option C-compliant Python file."""
381 project_root = get_project_root(ctx)
382 create_new_file(project_root, path, force, dry_run=dry_run, test=test, verbose=verbose)
383
384
385@cli.command()
386@click.argument("name")
387@click.option("--dry-run", is_flag=True,
388 help="Show what would be created without writing anything")
389@click.option("--no-venv", is_flag=True, default=False,
390 help="Skip .venv/ creation (FS-503: on by default).")
391@click.option("--with-claude", is_flag=True, default=False,
392 help="Copy bundled Claude Code skills into .claude/skills/.")
393def scaffold(name, dry_run, no_venv, with_claude):
394 """Create a new Option C project."""
395 from oct.core.compat import check_oc_diagnostics_compat
396 from oct.scaffold import run_scaffold
397
398 compat_msg = check_oc_diagnostics_compat()
399 if compat_msg:
400 click.echo(compat_msg, err=True)
401
402 result = run_scaffold(
403 name,
404 Path.cwd(),
405 dry_run=dry_run,
406 include_venv=not no_venv,
407 )
408
409 if with_claude:
410 from oct.scaffold.oct_scaffold import _copy_claude_skills
411 _copy_claude_skills(Path.cwd() / name, result, dry_run=dry_run)
412
413 for msg in result.messages:
414 click.echo(msg)
415 if not dry_run:
416 click.echo(f"Created new Option C project: {Path.cwd() / name}")
417
418
419@cli.command()
420@click.option("--sphinx", "sphinx_mode", is_flag=True,
421 help="Generate Sphinx HTML documentation")
422@click.option("--doxygen", "doxygen_mode", is_flag=True,
423 help="Generate Doxygen HTML documentation")
424@click.option("--all", "all_mode", is_flag=True,
425 help="Generate both Sphinx and Doxygen documentation")
426@click.option("--clean", "clean_mode", is_flag=True,
427 help="Remove generated documentation artifacts")
428@click.option("--dry-run", "dry_run", is_flag=True,
429 help="Preview what --clean would remove")
430@click.pass_context
431def docs(ctx, sphinx_mode, doxygen_mode, all_mode, clean_mode, dry_run):
432 """Validate or build Option C documentation."""
433 project_root = get_project_root(ctx)
434
435 if clean_mode:
436 from oct.docs.oct_docs import run_docs_clean
437 exit_code = run_docs_clean(project_root, dry_run=dry_run)
438 if exit_code != 0:
439 raise SystemExit(exit_code)
440 return
441
442 if all_mode:
443 from oct.docs.oct_docs import run_docs_all
444 exit_code = run_docs_all(project_root)
445 if exit_code != 0:
446 raise SystemExit(exit_code)
447 return
448
449 if sphinx_mode:
450 from oct.docs.oct_docs import run_docs_sphinx
451 exit_code = run_docs_sphinx(project_root)
452 if exit_code != 0:
453 raise SystemExit(exit_code)
454 return
455
456 if doxygen_mode:
457 from oct.docs.oct_docs import run_docs_doxygen
458 exit_code = run_docs_doxygen(project_root)
459 if exit_code != 0:
460 raise SystemExit(exit_code)
461 return
462
463 _oc_diag = project_root / "oc_diagnostics"
464 diag_dir = _oc_diag if _oc_diag.exists() else project_root / "diagnostics"
465 if diag_dir.name == "diagnostics":
466 click.echo(
467 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
468 "Rename to 'oc_diagnostics/'.",
469 err=True,
470 )
471 required = [
472 project_root / "docs" / "ARCHITECTURE.md",
473 project_root / "tests" / "Tests_README.md",
474 diag_dir / "debug_config.json",
475 ]
476
477 missing = [p for p in required if not p.exists()]
478
479 if missing:
480 click.echo("Missing required documentation files:")
481 for m in missing:
482 click.echo(f" - {m}")
483 else:
484 click.echo("All required Option C documentation is present.")
485
486_PYTEST_INSTALL_HINT = (
487 "pytest is not installed. Install it with one of:\n"
488 " pip install oct[test] # installs OCT's test extras\n"
489 " pip install pytest # install pytest directly"
490)
491
492
493def _pytest_missing(combined_output: str) -> bool:
494 """Detect the 'No module named pytest' signature in combined output."""
495 return "No module named pytest" in combined_output
496
497
498def _run_pytest(project_root: Path, args: tuple, json_mode: bool = False) -> None:
499 """Run pytest in *project_root* and exit on failure.
500
501 When *json_mode* is True, captures pytest output, parses the summary
502 line, and emits a machine-readable JSON object to stdout:
503 ``{"tool": "test", "passed": N, "failed": N, "errors": N,
504 "warnings": N, "output": "...", "exit_code": N}``
505
506 OI-511: pytest is now declared in the ``[project.optional-dependencies].test``
507 extra rather than as a runtime dependency; if ``python -m pytest`` reports
508 it is not importable, emit the install hint above and exit 1.
509 """
510 import json as _json
511 import re as _re
512 import subprocess, sys
513 if json_mode:
514 try:
515 result = subprocess.run(
516 [sys.executable, "-m", "pytest", "--tb=short", "-q"] + list(args),
517 cwd=project_root,
518 capture_output=True,
519 text=True,
520 encoding="utf-8",
521 errors="replace",
522 )
523 except FileNotFoundError:
524 click.echo(_json.dumps({
525 "tool": "test", "passed": 0, "failed": 0, "errors": 1,
526 "warnings": 0, "output": _PYTEST_INSTALL_HINT,
527 "exit_code": 1,
528 }))
529 raise SystemExit(1)
530 combined = (result.stdout or "") + (result.stderr or "")
531 if result.returncode != 0 and _pytest_missing(combined):
532 click.echo(_json.dumps({
533 "tool": "test", "passed": 0, "failed": 0, "errors": 1,
534 "warnings": 0, "output": _PYTEST_INSTALL_HINT,
535 "exit_code": 1,
536 }))
537 raise SystemExit(1)
538 # Parse the pytest summary line: "N passed", "N failed", "N error"
539 passed = failed = errors = warnings = 0
540 for m in _re.finditer(
541 r"(\d+)\s+(passed|failed|error(?:s)?|warning(?:s)?)",
542 combined,
543 ):
544 n, kind = int(m.group(1)), m.group(2)
545 if kind == "passed":
546 passed = n
547 elif kind == "failed":
548 failed = n
549 elif kind.startswith("error"):
550 errors = n
551 elif kind.startswith("warning"):
552 warnings = n
553 click.echo(_json.dumps({
554 "tool": "test",
555 "passed": passed,
556 "failed": failed,
557 "errors": errors,
558 "warnings": warnings,
559 "output": combined,
560 "exit_code": result.returncode,
561 }))
562 if result.returncode != 0:
563 raise SystemExit(result.returncode)
564 return
565 try:
566 result = subprocess.run(
567 [sys.executable, "-m", "pytest"] + list(args),
568 cwd=project_root,
569 capture_output=True,
570 text=True,
571 encoding="utf-8",
572 errors="replace",
573 )
574 except FileNotFoundError:
575 raise click.ClickException(_PYTEST_INSTALL_HINT)
576 combined = (result.stdout or "") + (result.stderr or "")
577 if result.returncode != 0 and _pytest_missing(combined):
578 raise click.ClickException(_PYTEST_INSTALL_HINT)
579 # Replay captured output so users still see pytest's normal reporting.
580 if result.stdout:
581 click.echo(result.stdout, nl=False)
582 if result.stderr:
583 click.echo(result.stderr, nl=False, err=True)
584 if result.returncode == 0:
585 click.echo("All tests passed.")
586 else:
587 click.echo("Some tests failed.")
588 raise SystemExit(result.returncode)
589
590
591def _map_source_to_test(source: Path, project_root: Path) -> Path | None:
592 """Heuristic: map a source .py file to its likely test file.
593
594 Tries several common patterns and returns the first existing match,
595 or ``None`` if no test file is found.
596 """
597 try:
598 rel = source.relative_to(project_root)
599 except ValueError:
600 return None
601
602 parts = list(rel.parts)
603 stem = source.stem
604
605 # Pattern 1: oct/<pkg>/foo.py → tests/tests_<pkg>/test_foo.py
606 if len(parts) >= 3 and parts[0] == "oct":
607 pkg = parts[1]
608 test_path = project_root / "tests" / f"tests_{pkg}" / f"test_{stem}.py"
609 if test_path.is_file():
610 return test_path
611
612 # Pattern 2: src/foo.py → tests/test_foo.py
613 if len(parts) >= 2 and parts[0] == "src":
614 test_path = project_root / "tests" / f"test_{stem}.py"
615 if test_path.is_file():
616 return test_path
617
618 # Pattern 3: <any>/foo.py → tests/test_foo.py
619 test_path = project_root / "tests" / f"test_{stem}.py"
620 if test_path.is_file():
621 return test_path
622
623 return None
624
625
626@cli.command()
627@click.option("--json", "json_mode", is_flag=True,
628 help="Emit machine-readable JSON summary to stdout.")
629@click.option("--changed", is_flag=True,
630 help="Run tests for git-modified files only.")
631@click.argument("args", nargs=-1)
632@click.pass_context
633def test(ctx, json_mode, changed, args):
634 """Run the project's test suite."""
635 project_root = get_project_root(ctx)
636
637 _PYTEST_CFG_FILES = ("pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini")
638
639 # --changed: discover changed source files and map to tests.
640 if changed:
641 try:
642 from oct.core.git import git_changed_files
643 changed_py = git_changed_files(project_root, extensions={".py"})
644 test_files: list[Path] = []
645 for src in changed_py:
646 mapped = _map_source_to_test(src, project_root)
647 if mapped and mapped not in test_files:
648 test_files.append(mapped)
649 if test_files:
650 rel_paths = [str(t.relative_to(project_root)) for t in test_files]
651 if not json_mode:
652 click.echo(f"Running {len(test_files)} test file(s) for changed sources.")
653 _run_pytest(project_root, tuple(rel_paths) + args, json_mode=json_mode)
654 return
655 else:
656 click.echo(
657 "No test files found for changed sources. Running all tests.",
658 err=True,
659 )
660 except Exception as exc:
661 click.echo(
662 f"Warning: --changed failed ({exc}). Running all tests.",
663 err=True,
664 )
665
666 has_tests_dir = (project_root / "tests").is_dir()
667 has_pytest_cfg = any((project_root / f).is_file() for f in _PYTEST_CFG_FILES)
668
669 if has_tests_dir or has_pytest_cfg:
670 # Standard single-project test run.
671 _run_pytest(project_root, args, json_mode=json_mode)
672 return
673
674 # Meta-project: discover sub-projects that have tests/ AND a pytest config.
675 sub_projects = []
676 for child in sorted(project_root.iterdir()):
677 if not child.is_dir() or child.name.startswith("."):
678 continue
679 if (child / "tests").is_dir() and any(
680 (child / f).is_file() for f in _PYTEST_CFG_FILES
681 ):
682 sub_projects.append(child)
683
684 if not sub_projects:
685 raise click.ClickException(
686 f"No tests/ directory or pytest configuration found in {project_root}, "
687 f"and no testable sub-projects detected. "
688 f"Run 'oct test' from a project that has a test suite."
689 )
690
691 import subprocess, sys
692
693 click.echo(f"No test suite at root -- found {len(sub_projects)} "
694 f"sub-project(s) with tests:")
695 for sp in sub_projects:
696 click.echo(f" - {sp.name}/")
697 click.echo()
698
699 failed = []
700 for sp in sub_projects:
701 click.echo(f"{'=' * 60}")
702 click.echo(f"Running tests in {sp.name}/")
703 click.echo(f"{'=' * 60}")
704 try:
705 result = subprocess.run(
706 [sys.executable, "-m", "pytest"] + list(args),
707 cwd=sp,
708 )
709 if result.returncode != 0:
710 failed.append(sp.name)
711 except FileNotFoundError:
712 raise click.ClickException(
713 "pytest is not installed. Install it with: pip install pytest"
714 )
715 click.echo()
716
717 if failed:
718 click.echo(f"Tests failed in: {', '.join(failed)}")
719 raise SystemExit(1)
720 else:
721 click.echo("All sub-project tests passed.")
722
723@cli.command(name="export-source")
724@click.option("--verbose", is_flag=True, help="Show per-file statistics.")
725@click.option("--clean", is_flag=True, help="[DEPRECATED: use 'oct clean'] Remove _source_code-* dirs.")
726@click.option("--single-dir", is_flag=True, help="Write all output into one root-level directory.")
727@click.option("--allow-outside-project", is_flag=True,
728 help="Allow PATH outside the auto-detected project root (OI-405).")
729@click.option("--warn-secrets/--no-warn-secrets", default=True,
730 help="Scan file contents for potential secrets (OI-416).")
731@click.option("--block-secrets", is_flag=True,
732 help="Skip files with potential secrets (implies --warn-secrets, OI-416).")
733@click.option("--diff", "diff_ref", default=None,
734 help="Export only files changed since REF (git ref).")
735@click.argument("path", required=False)
736@click.pass_context
737def export_source(ctx, verbose, clean, single_dir, allow_outside_project,
738 warn_secrets, block_secrets, diff_ref, path):
739 """Export consolidated source-code files for inspection."""
740 from oct.core.project_root import validate_path_containment
741 from oct.tools.source_exporter import run_exporter
742
743 # Merge global and command-level allow_outside_project flags
744 global_allow = ctx.obj.get("allow_outside_project", False) if ctx.obj else False
745 allow_outside_project = allow_outside_project or global_allow
746
747 # Update context for get_project_root to use
748 if ctx.obj:
749 ctx.obj["allow_outside_project"] = allow_outside_project
750
751 if path:
752 if allow_outside_project:
753 project_root = Path(path).resolve()
754 else:
755 auto_root = get_project_root(ctx)
756 project_root = validate_path_containment(
757 Path(path), auto_root, allow_outside=False
758 )
759 else:
760 project_root = get_project_root(ctx)
761
762 args = []
763 if verbose:
764 args.append("--verbose")
765 if clean:
766 click.echo(
767 "Warning: 'export-source --clean' is deprecated. "
768 "Use 'oct clean' (with optional --dry-run) instead.",
769 err=True,
770 )
771 args.append("--clean")
772 if single_dir:
773 args.append("--single-dir")
774 if warn_secrets:
775 args.append("--warn-secrets")
776 if block_secrets:
777 args.append("--block-secrets")
778 if diff_ref:
779 args.append(f"--diff={diff_ref}")
780
781 try:
782 run_exporter(project_root, args)
783 except Exception as e:
784 click.echo(f"Error: export-source failed unexpectedly: {e}", err=True)
785 raise SystemExit(1)
786
787
788@cli.command(name="export-skeleton")
789@click.option("--json", "json_mode", is_flag=True,
790 help="Emit machine-readable JSON summary to stdout.")
791@click.option("--verbose", is_flag=True, help="Show per-file statistics.")
792@click.option("--single-dir", is_flag=True, help="Write all output into one root-level directory.")
793@click.option("--no-diagnostics", is_flag=True, help="Omit Diagnostics section from docstrings.")
794@click.option("--clean", is_flag=True, help="Remove _skeleton_code-* dirs.")
795@click.option("--allow-outside-project", is_flag=True,
796 help="Allow PATH outside the auto-detected project root (OI-405).")
797@click.argument("path", required=False)
798@click.pass_context
799def export_skeleton(ctx, json_mode, verbose, single_dir, no_diagnostics, clean,
800 allow_outside_project, path):
801 """Export structural skeletons of source files for AI inspection."""
802 from oct.core.project_root import validate_path_containment
803 from oct.tools.skeleton_exporter import run_skeleton_exporter
804
805 if path:
806 if allow_outside_project:
807 project_root = Path(path).resolve()
808 else:
809 auto_root = get_project_root(ctx)
810 project_root = validate_path_containment(
811 Path(path), auto_root, allow_outside=False
812 )
813 else:
814 project_root = get_project_root(ctx)
815
816 args = []
817 if verbose:
818 args.append("--verbose")
819 if single_dir:
820 args.append("--single-dir")
821 if no_diagnostics:
822 args.append("--no-diagnostics")
823 if clean:
824 args.append("--clean")
825
826 try:
827 run_skeleton_exporter(project_root, args, json_mode=json_mode)
828 except Exception as e:
829 click.echo(f"Error: export-skeleton failed unexpectedly: {e}", err=True)
830 raise SystemExit(1)
831
832
833@cli.command()
834@click.argument("path", required=False)
835@click.option("--dry-run", is_flag=True, help="Show what would be deleted without deleting anything")
836@click.option("--allow-outside-project", is_flag=True,
837 help="Allow PATH outside the auto-detected project root (OI-405).")
838@click.pass_context
839def clean(ctx, path, dry_run, allow_outside_project):
840 """Remove _source_code-*, _skeleton_code-* export dirs and __pycache__ dirs."""
841 from oct.core.project_root import validate_path_containment
842 from oct.tools.source_exporter import clean_source_dirs, clean_pycache_dirs
843 from oct.tools.skeleton_exporter import clean_skeleton_dirs
844
845 if path:
846 if allow_outside_project:
847 target = Path(path).resolve()
848 else:
849 try:
850 auto_root = get_project_root(ctx)
851 except click.ClickException:
852 auto_root = Path.cwd()
853 target = validate_path_containment(
854 Path(path), auto_root, allow_outside=False
855 )
856 else:
857 target = Path.cwd()
858 clean_source_dirs(target, dry_run=dry_run)
859 clean_skeleton_dirs(target, dry_run=dry_run)
860 clean_pycache_dirs(target, dry_run=dry_run)
861
862
863@cli.command()
864@click.option("--json", "json_mode", is_flag=True, help="Machine-readable JSON output")
865@click.option("--verbose", is_flag=True, help="Detailed per-file breakdown")
866@click.option(
867 "--timeout",
868 "test_timeout",
869 type=click.IntRange(min=1),
870 default=None,
871 help="Test execution timeout in seconds (default: 300, or health.test_timeout in .octrc.json).",
872)
873@click.pass_context
874def health(ctx, json_mode, verbose, test_timeout):
875 """Display project compliance dashboard."""
876 project_root = get_project_root(ctx)
877 run_health(
878 project_root,
879 json_mode=json_mode,
880 verbose=verbose,
881 test_timeout=test_timeout,
882 log_output=ctx.obj.get("log_output", False),
883 )
884
885
886@cli.command(context_settings=dict(ignore_unknown_options=True))
887@click.argument("args", nargs=-1, type=click.UNPROCESSED)
888@click.pass_context
889def deps(ctx, args):
890 """Analyse inter-module dependencies."""
891 project_root = get_project_root(ctx)
892 exit_code = run_deps(project_root, list(args))
893 if exit_code != 0:
894 raise SystemExit(exit_code)
895
896
897@cli.command(name="install-hooks")
898@click.option("--force", is_flag=True, help="Overwrite existing .pre-commit-config.yaml")
899@click.pass_context
900def install_hooks_cmd(ctx, force):
901 """[DEPRECATED] Use 'oct git hooks install' instead."""
902 click.echo(
903 "Notice: 'oct install-hooks' is deprecated. "
904 "Use 'oct git hooks install' instead.",
905 err=True,
906 )
907 project_root = get_project_root(ctx)
908 # Delegate to the enhanced hooks installer via the new module.
909 from oct.git.oct_git import _ENHANCED_HOOKS_TEMPLATE
910 config_path = project_root / ".pre-commit-config.yaml"
911 if config_path.exists() and not force:
912 click.echo(f"Error: {config_path} already exists. Use --force to overwrite.")
913 raise SystemExit(1)
914 config_path.write_text(_ENHANCED_HOOKS_TEMPLATE, encoding="utf-8")
915 click.echo(f"Created {config_path}")
916 click.echo("Run 'pre-commit install' to activate the hooks.")
917
918
919@cli.command(context_settings=dict(ignore_unknown_options=True))
920@click.option("--json", "json_mode", is_flag=True,
921 help="Emit machine-readable JSON summary to stdout.")
922@click.argument("args", nargs=-1, type=click.UNPROCESSED)
923@click.pass_context
924def typecheck(ctx, json_mode, args):
925 """Run type checking (pyright or mypy)."""
926 project_root = get_project_root(ctx)
927 exit_code = run_typecheck(project_root, list(args), json_mode=json_mode)
928 if exit_code != 0:
929 raise SystemExit(exit_code)
930
931
932# -- git command group (Phase 4B) ------------------------------------
933from oct.git import git_group as _git_group
934cli.add_command(_git_group, name="git")
935
936# -- migrate command group (FS-539) ----------------------------------
937from oct.migrate import migrate_group as _migrate_group
938cli.add_command(_migrate_group, name="migrate")
939
940# -- audit command (FS-539 / FS-501 stub) ----------------------------
941from oct.audit import audit_group as _audit_cmd
942cli.add_command(_audit_cmd, name="audit")
943
944# -- install command (Phase 4-followup B1.4) -------------------------
945from oct.install.oct_install import oct_install_cmd as _oct_install_cmd
946cli.add_command(_oct_install_cmd, name="install")
947
948
949@cli.group()
950@click.pass_context
951def diag(ctx):
952 """oc_diagnostics configuration tools."""
953 pass
954
955
956@diag.command(name="validate-config")
957@click.option(
958 "--strict",
959 is_flag=True,
960 default=False,
961 help="OI-534: escalate WARNING to failure; exit 2 on any ERROR or WARNING.",
962)
963@click.pass_context
964def diag_validate_config(ctx, strict: bool):
965 """Validate debug_config.json against the expected schema."""
966 project_root = get_project_root(ctx)
967 issues = validate_config(project_root)
968 if not issues:
969 click.echo("debug_config.json is valid.")
970 return
971 for issue in issues:
972 click.echo(issue)
973 has_error = any(i.startswith("ERROR") for i in issues)
974 has_warning = any(i.startswith("WARNING") for i in issues)
975 # OI-534 / FS-513: --strict promotes WARNING to failure and uses
976 # exit code 2 so CI can distinguish a config audit failure from the
977 # generic exit 1 used by the non-strict path.
978 if strict and (has_error or has_warning):
979 raise SystemExit(2)
980 if has_error:
981 raise SystemExit(1)
982
983
984@diag.command(name="list-domains")
985@click.pass_context
987 """List configured domains with their levels and colors."""
988 project_root = get_project_root(ctx)
989 try:
990 domains = list_domains(project_root)
991 except FileNotFoundError as e:
992 raise click.ClickException(str(e))
993
994 if not domains:
995 click.echo("No domains configured.")
996 return
997
998 click.echo(f"{'Domain':<24} {'Level':<8} {'Color'}")
999 click.echo("-" * 44)
1000 for d in domains:
1001 click.echo(f"{d['name']:<24} {d['level']:<8} {d['color']}")
1002
1003
1004@diag.command(name="set-level")
1005@click.argument("domain")
1006@click.argument("level", type=click.IntRange(0, 9))
1007@click.option(
1008 "--dry-run",
1009 is_flag=True,
1010 default=False,
1011 help="Preview the edit without writing the config or its .bk backup.",
1012)
1013@click.pass_context
1014def diag_set_level(ctx, domain, level, dry_run: bool):
1015 """Set a domain's debug level (0-4).
1016
1017 OI-528: the write is always preceded by a ``<path>.bk`` backup so
1018 ``oct diag restore`` can undo a bad edit. ``--dry-run`` prints the
1019 would-be post-edit config to stdout and skips both writes.
1020 """
1021 project_root = get_project_root(ctx)
1022 try:
1023 new_config = set_level(project_root, domain, level, dry_run=dry_run)
1024 except (FileNotFoundError, ValueError, KeyError) as e:
1025 raise click.ClickException(str(e))
1026 if dry_run:
1027 click.echo(json.dumps(new_config, indent=2))
1028 click.echo(
1029 f"[DRY RUN] Would set {domain} level to {level} (no write).",
1030 err=True,
1031 )
1032 return
1033 click.echo(f"Set {domain} level to {level}.")
1034
1035
1036@diag.command(name="restore")
1037@click.pass_context
1039 """OI-528: restore debug_config.json from its .bk backup."""
1040 project_root = get_project_root(ctx)
1041 try:
1042 restored = restore_config(project_root)
1043 except FileNotFoundError as e:
1044 raise click.ClickException(str(e))
1045 click.echo(f"Restored {restored} from backup.")
1046
1047
1048@diag.command(name="migrate-config")
1049@click.argument(
1050 "config_path",
1051 required=False,
1052 type=click.Path(dir_okay=False, path_type=Path),
1053)
1054@click.option(
1055 "--dry-run",
1056 is_flag=True,
1057 default=False,
1058 help="Show the migrated config on stdout without writing to disk.",
1059)
1060@click.pass_context
1061def diag_migrate_config(ctx, config_path: Path | None, dry_run: bool):
1062 """OI-508 / FS-515: upgrade a legacy v1 debug_config.json to v2.0.
1063
1064 When PATH is omitted, migrates the project's own debug_config.json.
1065 Writes ``<path>.bk`` before overwriting; idempotent — running twice
1066 over an already-v2 file is a no-op.
1067 """
1068 from oct.diag.oct_diag import _find_config
1069 if config_path is None:
1070 project_root = get_project_root(ctx)
1071 config_path = _find_config(project_root)
1072 try:
1073 new_config = migrate_config(config_path, dry_run=dry_run)
1074 except (FileNotFoundError, ValueError) as e:
1075 raise click.ClickException(str(e))
1076 if dry_run:
1077 click.echo(json.dumps(new_config, indent=2))
1078 click.echo(f"[DRY RUN] Would write v2 schema to {config_path}", err=True)
1079 return
1080 backup_path = config_path.with_suffix(config_path.suffix + ".bk")
1081 click.echo(f"Migrated {config_path} -> v{new_config.get('_schema_version')} "
1082 f"(backup: {backup_path.name})")
1083
1084
1085@cli.command(name="install-mcp")
1086@click.option(
1087 "--host",
1088 type=click.Choice(["claude-code", "cursor", "vscode", "auto"], case_sensitive=False),
1089 default="auto",
1090 help="Target AI host (default: auto-detect).",
1091)
1092@click.option(
1093 "--dry-run/--no-dry-run",
1094 default=True,
1095 help="Preview without writing (default: --dry-run).",
1096)
1097@click.pass_context
1098def install_mcp(ctx, host, dry_run):
1099 """Install oct-mcp into an AI host's MCP config (Claude Code, Cursor, VS Code)."""
1100 from oct.tools.install_mcp import run_install_mcp
1101 project_root = get_project_root(ctx)
1102 exit_code = run_install_mcp(host=host, project_root=project_root, dry_run=dry_run)
1103 if exit_code != 0:
1104 raise SystemExit(exit_code)
1105
1106
1107if __name__ == "__main__":
1108 cli()
format_help(self, ctx, formatter)
Definition cli.py:282
new(ctx, path, force, test, dry_run, verbose)
Definition cli.py:379
bool _pytest_missing(str combined_output)
Definition cli.py:493
diag_restore(ctx)
Definition cli.py:1038
format(ctx, args)
Definition cli.py:366
export_skeleton(ctx, json_mode, verbose, single_dir, no_diagnostics, clean, allow_outside_project, path)
Definition cli.py:800
clean(ctx, path, dry_run, allow_outside_project)
Definition cli.py:839
None _run_pytest(Path project_root, tuple args, bool json_mode=False)
Definition cli.py:498
diag_validate_config(ctx, bool strict)
Definition cli.py:964
install_hooks_cmd(ctx, force)
Definition cli.py:900
diag_set_level(ctx, domain, level, bool dry_run)
Definition cli.py:1014
export_source(ctx, verbose, clean, single_dir, allow_outside_project, warn_secrets, block_secrets, diff_ref, path)
Definition cli.py:738
test(ctx, json_mode, changed, args)
Definition cli.py:633
diag_list_domains(ctx)
Definition cli.py:986
lint(ctx, args)
Definition cli.py:355
Path|None _map_source_to_test(Path source, Path project_root)
Definition cli.py:591
diag_migrate_config(ctx, Path|None config_path, bool dry_run)
Definition cli.py:1061