oct
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- oct.complexity.command (complexity command)
48- oct.mutation.command (mutate command)
49- oct.git (git_group - the git command group)
50- oct.migrate (migrate_group - the migrate command group)
51- oct.audit (audit_group - the audit command)
52- oct.install.oct_install (oct_install_cmd - the install command)
53- oct.core.diagnostics (_dbg structured logger)
54- oc_diagnostics (lazy; init with file-only output before each command)
55- oct.timing.ledger (lazy; the timing stage around each command)
56- oct.core.compat (lazy; oc_diagnostics compatibility warning for scaffold)
57- oct.scaffold (lazy; run_scaffold for the scaffold command)
58- oct.scaffold.oct_scaffold (lazy; Claude Code skills copy for scaffold --with-claude)
59- oct.core.workspace (lazy; manifest subproject name for the Doxyfile PROJECT_NAME)
60- oct.docs.callgraph_command (lazy; docs --callgraph)
61- oct.docs.doxyfile_init (lazy; Doxyfile writer for docs --init-doxygen)
62- oct.docs.oct_docs (lazy; Sphinx, Doxygen, all-tier and clean docs runs, project name fallback)
63- oct.docs.typedoc (lazy; docs --typedoc)
64- oct.testing.cache (lazy; pytest cache plugin flags and the cached run's file count)
65- oct.core.git (lazy; changed-file discovery for test --changed)
66- oct.tools.source_exporter (lazy; export-source, and source export and pycache removal for clean)
67- oct.tools.skeleton_exporter (lazy; export-skeleton, and skeleton export removal for clean)
68- oct.tools.build_artifacts (lazy; build and dist tree removal for clean)
69- oct.git.oct_git (lazy; enhanced hooks template for the deprecated install-hooks)
70- oct.tools.install_mcp (lazy; install-mcp command)
71- oc_chat.registry (not imported; run as python -m oc_chat.registry by the agent command)
72- pytest (not imported; run as python -m pytest by the test command)
73"""
74
75import json
76import os
77import sys
78import click
79from pathlib import Path
80
81from oct import __version__
82from oct.complexity.command import complexity
83from oct.core.project_root import find_project_root, get_project_root
84from oct.linter.oct_lint import run_linter
85from oct.mutation.command import mutate
86from oct.formatter.oct_format import run_formatter
87from oct.health.oct_health import run_health
88from oct.generator.new_python_file import create_new_file
89from oct.diag.oct_diag import (
90 list_domains,
91 migrate_config,
92 restore_config,
93 set_level,
94 validate_config,
95)
96from oct.deps.oct_deps import run_deps
97from oct.hooks.oct_hooks import install_hooks
98from oct.typecheck.oct_typecheck import run_typecheck
99
100
101_COMMAND_FLAGS = {
102 "lint": [
103 ("--fix-headers", "Automatically fix shebang, encoding, and path headers"),
104 ("--dry-run", "Preview changes without modifying files"),
105 ("--json", "Emit machine-readable JSON to stdout"),
106 ("--jobs N, -j N", "Parallel workers (default: 1)"),
107 ("--changed", "Lint only git-modified Python files"),
108 ("--profile P", "Lint profile: proto, compact, strict"),
109 ("--secret-match MODE", "Secret-name matcher: substring (default) | word"),
110 ("--code-entropy-threshold N", "Shannon entropy threshold for code strings (default: 4.5)"),
111 ("--no-entropy", "Disable entropy-based secret detection"),
112 ("--no-cache", "Bypass lint cache reads (still writes)"),
113 ("--baseline", "Save current violations as the baseline"),
114 ("--against-baseline", "Report only new/resolved vs saved baseline"),
115 ("--explain RULE", "Show rule documentation (rationale, pattern, spec ref)"),
116 ("PATH ...", "Files or directories to lint (default: entire project)"),
117 ],
118 "format": [
119 ("--fix", "Apply formatting changes (default: dry-run)"),
120 ("--dry-run", "Report what would change without modifying"),
121 ("--json", "Output machine-readable JSON"),
122 ("--verbose", "Show per-file details"),
123 ("--changed", "Format only git-modified Python files"),
124 ],
125 "agent": [
126 ("SUBCOMMAND ...", "Forwarded verbatim to 'python -m oc_chat.registry' "
127 "(init, doctor, queue, plan, round, roadmap, "
128 "regenerate, checkpoint, ...)"),
129 ("(passthrough)", "The registry JSON envelope, --json flags, and exit "
130 "codes (0/1/2/3/5) are preserved by the child CLI"),
131 ],
132 "new": [
133 ("PATH", "Target file path (required)"),
134 ("--force", "Overwrite existing file"),
135 ("--test", "Also generate companion test file in tests/"),
136 ("--dry-run", "Show what would be created without writing"),
137 ("--verbose", "Show generated file content"),
138 ],
139 "scaffold": [
140 ("NAME", "Project name (required)"),
141 ("--dry-run", "Show what would be created without writing"),
142 ("--no-venv", "Skip .venv/ creation (FS-503: on by default)"),
143 ("--with-claude", "Copy bundled Claude Code skills into .claude/skills/"),
144 ],
145 "docs": [
146 ("--sphinx", "Generate Sphinx HTML documentation"),
147 ("--doxygen", "Generate Doxygen HTML documentation"),
148 ("--typedoc", "Generate TypeDoc HTML documentation (needs typedoc.json)"),
149 ("--all", "Generate every declared tier: Sphinx, Doxygen, TypeDoc"),
150 ("--clean", "Remove generated documentation artifacts"),
151 ("--dry-run", "Preview what --clean would remove"),
152 ("--no-workspace", "Force single-project mode at a workspace root (FS-WS-DOCS)"),
153 ("--init-doxygen", "Write docs/doxygen_work_dir/Doxyfile from the bundled template"),
154 ("--force", "With --init-doxygen: overwrite an existing Doxyfile"),
155 ("--callgraph QUALNAME", "Full call graph of one function (DOT + SVG, docs/_build/callgraph/)"),
156 ("--depth N", "With --callgraph: stop N calls below the root"),
157 ("--trace -- ARGS", "With --callgraph: run 'oct ARGS' for real and compare what ran"),
158 ],
159 "test": [
160 ("--json", "Emit machine-readable JSON summary to stdout"),
161 ("--changed", "Run tests for git-modified files only"),
162 ("--cached", "Skip test files unchanged since their last green run"),
163 ("--oct-cache-clear", "Empty the test cache before running"),
164 ],
165 "export-source": [
166 ("--verbose", "Show per-file statistics"),
167 ("--clean", "[DEPRECATED: use 'oct clean'] Remove _source_code-* dirs"),
168 ("--single-dir", "Write all output into one root-level directory"),
169 ("--diff REF", "Export only files changed since REF"),
170 ("--warn-secrets", "Warn on suspected secrets in exported files"),
171 ("--block-secrets", "Abort export when secrets are detected"),
172 ("--allow-outside-project", "Allow export targets outside the project root"),
173 ],
174 "export-skeleton": [
175 ("--json", "Emit machine-readable JSON summary to stdout"),
176 ("--verbose", "Show per-file statistics"),
177 ("--single-dir", "Write all output into one root-level directory"),
178 ("--no-diagnostics", "Omit Diagnostics section from docstrings"),
179 ("--clean", "Remove previously exported skeletons"),
180 ("--allow-outside-project", "Allow skeleton targets outside the project root"),
181 ],
182 "clean": [
183 ("--dry-run", "Show what would be deleted without deleting"),
184 ("--allow-outside-project", "Allow cleaning paths outside the project root"),
185 ],
186 "health": [
187 ("--tests", "Run the project test suite"),
188 ("--timing", "Show only the timing report"),
189 ("--timing-last N", "Recent records per stage (default: 20)"),
190 ("--write-baseline", "Write this host's timing medians"),
191 ("--force-host", "Allow adding another baseline host"),
192 ("--session PATH", "Read tool latency and tokens from session events"),
193 ("--json", "Machine-readable JSON output"),
194 ("--verbose", "Detailed per-file breakdown"),
195 ("--timeout N", "Per-check timeout in seconds (default: 300)"),
196 ],
197 "complexity": [
198 ("--coverage-data PATH", "Map an existing coverage.py data file"),
199 ("--json", "Emit the full machine-readable report"),
200 ("--top N", "Limit human output to the worst N functions"),
201 ("--max-cc N", "Exit 2 when a function exceeds CC N (advisory: 15)"),
202 ("--max-crap M", "Exit 2 when a function exceeds CRAP M (advisory: 30)"),
203 ("--changed", "Measure only git-modified Python files"),
204 ("--baseline PATH", "Ratchet against recorded debt; new or worse fails"),
205 ("--write-baseline PATH", "Record this run's measurements as a baseline"),
206 ("PATH ...", "Python files or directories (default: project root)"),
207 ],
208 "mutate": [
209 ("--target FILE", "Python file to mutate (required)"),
210 ("--tests PATH", "Tests run once per mutant (required for a run)"),
211 ("--timeout N", "Seconds per test run before a mutant times out"),
212 ("--json", "Emit the full machine-readable report"),
213 ("--list", "Enumerate the mutants without running anything"),
214 ],
215 "deps": [
216 ("--json", "JSON adjacency list output"),
217 ("--mermaid", "Mermaid diagram output"),
218 ("--dot", "DOT/Graphviz output"),
219 ],
220 "install": [
221 ("TARGET", "Project root to install (default: current directory)"),
222 ("--editable, -e", "Install in editable mode (required)"),
223 ("--reinstall", "Allow re-install over an existing shadow"),
224 ("--use-active-venv", "Bypass venv-match check"),
225 ("--allow-worktree", "Allow installing from a Claude Code worktree"),
226 ("--allow-outside-project", "Allow running outside an Option C project"),
227 ("--json", "Emit JSON output"),
228 ],
229 "install-hooks": [
230 ("--force", "Overwrite existing .pre-commit-config.yaml"),
231 ],
232 "git": [
233 ("status", "Show git status with Option C compliance annotations"),
234 ("check", "Run the unified quality gate (lint + format + secrets)"),
235 ("init", "Initialise or enhance a git repo with Option C conventions"),
236 ("commit", "Commit with quality gate and secrets enforcement"),
237 ("branch", "Create or switch branches with naming validation"),
238 ("add", "Stage files for commit (whole-file or interactive)"),
239 ("restore", "Restore working-tree edits or unstage paths"),
240 ("ignore", "Manage .gitignore patterns within the OCT-managed block"),
241 ("commit-msg", "Validate a commit message file (pre-commit stage)"),
242 ("hooks", "Manage Option C git hooks"),
243 ("changelog", "Generate changelog from Conventional Commits history"),
244 ],
245 "git status": [
246 ("--json", "Emit machine-readable JSON output"),
247 ("--no-check", "Skip compliance annotations (plain git status)"),
248 ("--scope SCOPE", "Scope: project (default), repo, auto"),
249 ],
250 "git add": [
251 ("PATHS...", "Files or directories to stage"),
252 ("--patch, -p", "Interactive hunk-level staging"),
253 ("--update, -u", "Stage all already-tracked modified files"),
254 ("--all, -A", "Stage all changes within project scope"),
255 ("--dry-run", "Show what would be staged without staging"),
256 ("--json", "Emit JSON output"),
257 ],
258 "git ignore": [
259 ("PATTERNS...", "Glob patterns to add to .gitignore"),
260 ("--remove PATTERN", "Remove a pattern from the OCT-managed block"),
261 ("--list", "List currently active ignore patterns"),
262 ("--scope SCOPE", "Target .gitignore: workspace, project, nearest (default)"),
263 ("--json", "Emit JSON output"),
264 ],
265 "git restore": [
266 ("PATHS...", "Files to restore (default: scope by --all)"),
267 ("--staged", "Unstage paths from the index without touching working tree"),
268 ("--all, -A", "Restore all in-scope paths"),
269 ("--yes", "Skip confirmation prompt for --staged --all"),
270 ("--json", "Emit JSON output"),
271 ],
272 "git init": [
273 ("--dry-run", "Show what would be created without writing"),
274 ("--github", "Also generate GitHub Actions workflow"),
275 ("--no-hooks", "Skip hook installation"),
276 ],
277 "git branch": [
278 ("--create NAME", "Create a new branch from HEAD"),
279 ("--switch NAME", "Switch to an existing branch"),
280 ("--json", "Emit JSON output"),
281 ],
282 "git commit": [
283 ("-m MESSAGE, --message MESSAGE", "Commit message (Conventional Commits format)"),
284 ("--force", "Skip lint/format checks (secrets still checked)"),
285 ("--no-verify", "Forward --no-verify to git (hooks skipped)"),
286 ("--profile P", "Override lint profile: proto, compact, strict"),
287 ("--json", "Emit JSON output"),
288 ("--ignore-unstaged-mutation", "Downgrade B1.0 mutation detection to warn for this run"),
289 ],
290 "git commit-msg": [
291 ("MESSAGE_FILE", "Path to the commit message file (required)"),
292 ("--json", "Emit machine-readable JSON output"),
293 ],
294 "git check": [
295 ("--staged-only", "Check only staged (index) files"),
296 ("--all", "Check every Python file in the project"),
297 ("--include-tests", "Also run `oct test` (for pre-push use)"),
298 ("--fix", "Attempt auto-fix of violations, then re-check"),
299 ("--profile P", "Override lint profile: proto, compact, strict"),
300 ("--json", "Emit machine-readable JSON output"),
301 ("--sandbox", "Run pytest under the MCP sandbox environment"),
302 ],
303 "git hooks": [
304 ("install", "Generate .pre-commit-config.yaml with all hooks"),
305 ("status", "Show installed hook state"),
306 ("remove", "Remove Option C hooks (preserves non-OCT hooks)"),
307 ],
308 "git changelog": [
309 ("--release V", "Release version tag (e.g. v0.13.0)"),
310 ("--since REF", "Git ref to start from (default: last tag)"),
311 ("--dry-run", "Print to stdout without writing"),
312 ("--json", "Emit structured JSON"),
313 ],
314 "git hooks install": [
315 ("--force", "Overwrite existing configuration"),
316 ],
317 "typecheck": [
318 ("--json", "Emit machine-readable JSON summary to stdout"),
319 ("ARGS ...", "Arguments forwarded to pyright/mypy"),
320 ],
321 "diag": [
322 ("validate-config", "Validate debug_config.json against schema"),
323 (" --strict", "Treat validation warnings as errors"),
324 ("list-domains", "List configured domains with levels and colors"),
325 ("set-level DOMAIN N", "Set a domain's debug level (0-9)"),
326 (" --dry-run", "Preview edit without writing (use with set-level)"),
327 ("restore", "Restore debug_config.json from .bk backup"),
328 ("migrate-config [PATH]", "Upgrade legacy v1 schema to v2.0"),
329 (" --dry-run", "Preview migration without writing (use with migrate-config)"),
330 ],
331 "install-mcp": [
332 ("--host HOST", "Target AI host: claude-code, cursor, vscode, auto (default: auto)"),
333 ("--dry-run / --no-dry-run", "Preview without writing (default: --dry-run)"),
334 ],
335 "migrate": [
336 ("option-c", "Migrate to canonical .option_c/ dotdir layout (FS-539)"),
337 ],
338 "migrate option-c": [
339 ("--dry-run", "Show plan without writing anything"),
340 ("--no-backup", "Skip .bk backup when overwriting"),
341 ],
342 "audit": [
343 ("--json-output", "Emit JSON instead of human-readable text"),
344 ],
345}
346
347
348#: ``ctx.meta`` slot carrying this invocation's timing sample, so a
349#: command can add the scope and file count the ledger records (Plan
350#: 249 P5). ``Context.meta`` is shared with every subcommand context.
351TIMING_SAMPLE_KEY = "oct.timing.sample"
352
353
354#: Attribute a command sets to ``True`` to receive its positional arguments
355#: verbatim on Windows. See :func:`_resolve_leaf_command` for why this
356#: exists; ``oct git ignore`` is the command that needs it.
357NO_GLOB_EXPANSION_ATTR: str = "no_glob_expansion"
358
359
361 group: click.Group, args: list[str],
362) -> click.Command | None:
363 """Return the command *args* would dispatch to, parsing nothing else.
364
365 Walks the group tree with ``resilient_parsing`` contexts, so option
366 callbacks never run and a malformed command line resolves to ``None``
367 rather than raising - the real parse that follows reports the error
368 properly. Used before Click's Windows argument expansion to find out
369 whether the target command wants that expansion at all.
370 """
371 cmd: click.Command = group
372 remaining = list(args)
373 try:
374 while isinstance(cmd, click.Group):
375 with cmd.make_context(
376 cmd.name or "oct", remaining, resilient_parsing=True,
377 ) as ctx:
378 # Click 8.2 moved protected_args behind an underscore; take
379 # whichever this version has, plus the ordinary remainder.
380 remaining = [
381 *getattr(ctx, "_protected_args", ()), *ctx.args,
382 ]
383 if not remaining:
384 return cmd
385 name, remaining = remaining[0], remaining[1:]
386 nxt = cmd.get_command(ctx, name)
387 if nxt is None:
388 return None
389 cmd = nxt
390 except click.ClickException:
391 return None
392 return cmd
393
394
395def _expand_windows_args(group: click.Group, args: list[str]) -> list[str]:
396 """Apply Click's Windows shell-expansion unless the target opts out.
397
398 Click's ``Command.main`` runs ``_expand_args`` over the whole argv on
399 Windows - ``~``, ``$VAR`` and globs - because cmd and PowerShell do
400 none of that themselves, and ``oct lint oct/*.py`` relies on it. But
401 the expansion cannot tell a path from a pattern: ``oct git ignore
402 "**/docs/html/"`` had its one argument replaced by the fourteen
403 directories it happened to match, every one of which the pattern
404 validator then rejected (2026-09-27). A command that takes patterns
405 sets :data:`NO_GLOB_EXPANSION_ATTR` and receives its argv verbatim;
406 every other command keeps the expansion it always had.
407 """
408 from click.utils import _expand_args
409 leaf = _resolve_leaf_command(group, args)
410 if leaf is not None and getattr(leaf, NO_GLOB_EXPANSION_ATTR, False):
411 return list(args)
412 return _expand_args(args)
413
414
415def _start_diagnostics() -> None:
416 """Start oc_diagnostics for this process: exception hook, no stream capture.
417
418 Output goes to the log file only, so oct's terminal output stays its
419 own. The file's FORMAT is left to the config's ``log_format`` (text, or
420 ``jsonl`` for machine reading; oc_diagnostics >= 2.2.0 keeps the two
421 independent). Idempotent; a no-op without oc_diagnostics.
422 """
423 try:
424 from oc_diagnostics import init, set_global_setting
425 init(intercept_streams=False)
426 set_global_setting("output_mode", "file")
427 except ImportError:
428 pass
429
430
431class OctGroup(click.Group):
432 """Custom group that shows per-command flags in the main --help output."""
433
434 def main(self, args: list[str] | None = None, *rest: object,
435 windows_expand_args: bool = True, **kwargs: object) -> object:
436 """Click's ``main`` with the Windows expansion made command-aware.
437
438 Click only expands when it reads ``sys.argv`` itself, so the argv
439 is taken here, expanded (or not) per the target command, and
440 handed over with expansion switched off so it is not done twice.
441 """
442 if args is None:
443 args = sys.argv[1:]
444 if os.name == "nt" and windows_expand_args:
445 args = _expand_windows_args(self, args)
446 return super().main(args, *rest, windows_expand_args=False, **kwargs)
447
448 def invoke(self, ctx: click.Context) -> object:
449 """Time the registered command path without recording its arguments."""
450 # Diagnostics start HERE, before the timing stage opens: the stage's
451 # own ENTER record was the first _dbg call of every command, and it
452 # ran before the group callback used to call init(), so every oct
453 # invocation printed "_dbg() called before init()" (masked until
454 # 2026-09-28 by an earlier offender in oct.migrate; both found by the
455 # chart C runtime trace). init() is idempotent.
457 from oct.timing.ledger import command_stage, timed_stage
458 root = ctx.params.get("root_dir")
459 if root is None:
460 try:
461 root = find_project_root(Path.cwd())
462 except Exception:
463 root = Path.cwd()
464 with timed_stage(
465 Path(root or Path.cwd()), command_stage(ctx), outcome=0,
466 ) as sample:
467 # Plan 249 P5: a command that measures something worth
468 # recording (``oct test --cached`` counts the files it
469 # actually ran) sets ``scope``/``files`` on this sample.
470 ctx.meta[TIMING_SAMPLE_KEY] = sample
471 return super().invoke(ctx)
472
474 self,
475 ctx: click.Context,
476 formatter: click.HelpFormatter,
477 ) -> None:
478 formatter.write(f"oct {__version__}\n")
479 formatter.write_paragraph()
480 self.format_usage(ctx, formatter)
481
482 if self.help:
483 formatter.write_paragraph()
484 with formatter.indentation():
485 formatter.write(self.help)
486 formatter.write_paragraph()
487
488 # Global options
489 opts = []
490 for param in self.get_params(ctx):
491 rv = param.get_help_record(ctx)
492 if rv:
493 opts.append(rv)
494 if opts:
495 with formatter.section("Global Options"):
496 formatter.write_dl(opts)
497
498 # Commands with inline flags
499 commands = []
500 for name in self.list_commands(ctx):
501 cmd = self.get_command(ctx, name)
502 if cmd is None or cmd.hidden:
503 continue
504 commands.append((name, cmd))
505
506 if commands:
507 with formatter.section("Commands"):
508 for name, cmd in commands:
509 short_help = cmd.get_short_help_str(limit=60)
510 formatter.write(f" {name:<16} {short_help}\n")
511 flags = _COMMAND_FLAGS.get(name, [])
512 for flag, desc in flags:
513 formatter.write(f" {flag:<22} {desc}\n")
514 if flags:
515 formatter.write("\n")
516
517
518@click.group(cls=OctGroup)
519@click.version_option(__version__, "-V", "--version", prog_name="oct",
520 message="%(prog)s %(version)s")
521@click.option("--root-dir", default=None, type=click.Path(exists=True, file_okay=False),
522 help="Override project root directory (skip auto-detection).")
523@click.option("--allow-outside-project", is_flag=True,
524 help="Allow operations outside auto-detected project root (OI-405).")
525@click.option("--strict-config", is_flag=True,
526 help="Fail on malformed .octrc.json or schema errors (OI-411).")
527@click.option("--log", "log_output", is_flag=True,
528 help="Persist command output as JSON to .option_c/logs/.")
529@click.pass_context
530def cli(
531 ctx: click.Context,
532 root_dir: str | None,
533 allow_outside_project: bool,
534 strict_config: bool,
535 log_output: bool,
536) -> None:
537 """OCT - Option C Tools"""
538 # Diagnostics were started by OctGroup.invoke, which Click runs before
539 # this callback; a _dbg call here reaches a live logger.
540 func = "cli"
541 _dbg("OCT-CLI", func,
542 f"cli start: root_dir={root_dir} strict_config={strict_config}", 3)
543 ctx.ensure_object(dict)
544 ctx.obj["root_dir"] = root_dir
545 ctx.obj["allow_outside_project"] = allow_outside_project
546 ctx.obj["strict_config"] = strict_config
547 ctx.obj["log_output"] = log_output
548 if strict_config:
549 os.environ["OCT_STRICT_CONFIG"] = "1"
550
551
552cli.add_command(complexity)
553cli.add_command(mutate)
554
555
556@cli.command(context_settings=dict(ignore_unknown_options=True))
557@click.argument("args", nargs=-1, type=click.UNPROCESSED)
558@click.pass_context
559def lint(ctx: click.Context, args: tuple[str, ...]) -> None:
560 """Run the Option C linter."""
561 project_root = get_project_root(ctx)
562 exit_code = run_linter(project_root, list(args))
563 if exit_code != 0:
564 raise SystemExit(exit_code)
565
566
567@cli.command(context_settings=dict(ignore_unknown_options=True))
568@click.argument("args", nargs=-1, type=click.UNPROCESSED)
569@click.pass_context
570def format(ctx: click.Context, args: tuple[str, ...]) -> None:
571 """Auto-fix Option C compliance violations in Python files."""
572 project_root = get_project_root(ctx)
573 run_formatter(project_root, list(args))
574
575
576@cli.command(name="agent", context_settings=dict(ignore_unknown_options=True))
577@click.argument("args", nargs=-1, type=click.UNPROCESSED)
578def agent(args: tuple[str, ...]) -> None:
579 """Delegate to the OC Chat registry agent CLI.
580
581 A thin subprocess facade over ``python -m oc_chat.registry`` (Plan 151
582 T6). oct NEVER imports oc_chat - the CLI-boundary rule; all arguments,
583 stdin/stdout/stderr, the JSON envelope, and the exit-code contract
584 (0 ok, 1 source-validation, 2 build/refusal/scope, 3 not-byte-stable,
585 5 stale schema) are preserved verbatim by delegating to the child
586 process. Requires oc_chat to be importable on the interpreter running
587 the facade (the monorepo workspace install).
588 """
589 import subprocess
590 import sys
591
592 # ``-P`` (PYTHONSAFEPATH) keeps the current working directory off the
593 # child's import path. From the monorepo root the ``oc_chat/`` directory
594 # would otherwise shadow the installed ``oc_chat`` as a namespace package
595 # (the Plan 151 §2.7b "chimera" - every monorepo ``python -m oc_chat.*``
596 # invocation is pinned to ``-P``), and the registry CLI resolves its
597 # store via workspace discovery, not via cwd imports.
598 try:
599 result = subprocess.run(
600 [sys.executable, "-P", "-m", "oc_chat.registry", *args]
601 )
602 except FileNotFoundError as exc: # pragma: no cover - interpreter always present
603 raise click.ClickException(f"could not launch the registry CLI: {exc}")
604 if result.returncode != 0:
605 raise SystemExit(result.returncode)
606
607
608@cli.command()
609@click.argument("path")
610@click.option("--force", is_flag=True, help="Overwrite existing file")
611@click.option("--test", is_flag=True, help="Also generate companion test file")
612@click.option("--dry-run", is_flag=True, help="Show what would be created without writing anything")
613@click.option("--verbose", is_flag=True, help="Show generated file content")
614@click.pass_context
615def new(
616 ctx: click.Context,
617 path: str,
618 force: bool,
619 test: bool,
620 dry_run: bool,
621 verbose: bool,
622) -> None:
623 """Create a new Option C-compliant Python file."""
624 project_root = get_project_root(ctx)
625 create_new_file(project_root, path, force, dry_run=dry_run, test=test, verbose=verbose)
626
627
628@cli.command()
629@click.argument("name")
630@click.option("--dry-run", is_flag=True,
631 help="Show what would be created without writing anything")
632@click.option("--no-venv", is_flag=True, default=False,
633 help="Skip .venv/ creation (FS-503: on by default).")
634@click.option("--with-claude", is_flag=True, default=False,
635 help="Copy bundled Claude Code skills into .claude/skills/.")
636def scaffold(
637 name: str,
638 dry_run: bool,
639 no_venv: bool,
640 with_claude: bool,
641) -> None:
642 """Create a new Option C project."""
643 from oct.core.compat import check_oc_diagnostics_compat
644 from oct.scaffold import run_scaffold
645
646 compat_msg = check_oc_diagnostics_compat()
647 if compat_msg:
648 click.echo(compat_msg, err=True)
649
650 result = run_scaffold(
651 name,
652 Path.cwd(),
653 dry_run=dry_run,
654 include_venv=not no_venv,
655 )
656
657 if with_claude:
658 from oct.scaffold.oct_scaffold import _copy_claude_skills
659 _copy_claude_skills(Path.cwd() / name, result, dry_run=dry_run)
660
661 for msg in result.messages:
662 click.echo(msg)
663 if not dry_run:
664 click.echo(f"Created new Option C project: {Path.cwd() / name}")
665
666
667def _workspace_subproject_name(project_root: Path) -> str | None:
668 """Return the manifest name of the subproject at *project_root*, if any.
669
670 ``None`` when *project_root* is not inside a workspace, the manifest
671 does not load, or no subproject entry contains it.
672 """
673 from oct.core.workspace import (
674 WorkspaceError, assign_to_subproject, find_workspace_root, load_workspace,
675 )
676 ws_root = find_workspace_root(project_root)
677 if ws_root is None:
678 return None
679 try:
680 workspace = load_workspace(ws_root)
681 except WorkspaceError:
682 return None
683 sp = assign_to_subproject(project_root, workspace)
684 return sp.name if sp is not None else None
685
686
687@cli.command()
688@click.option("--sphinx", "sphinx_mode", is_flag=True,
689 help="Generate Sphinx HTML documentation")
690@click.option("--doxygen", "doxygen_mode", is_flag=True,
691 help="Generate Doxygen HTML documentation")
692@click.option("--typedoc", "typedoc_mode", is_flag=True,
693 help="Generate TypeDoc HTML documentation (TypeScript projects "
694 "declaring a typedoc.json)")
695@click.option("--all", "all_mode", is_flag=True,
696 help="Generate every declared tier: Sphinx, Doxygen, TypeDoc")
697@click.option("--clean", "clean_mode", is_flag=True,
698 help="Remove generated documentation artifacts")
699@click.option("--dry-run", "dry_run", is_flag=True,
700 help="Preview what --clean would remove")
701@click.option("--no-workspace", "no_workspace", is_flag=True,
702 help="Force single-project mode even at a workspace root "
703 "(escape hatch for the FS-WS-DOCS workspace fan-out)")
704@click.option("--init-doxygen", "init_doxygen", is_flag=True,
705 help="Write docs/doxygen_work_dir/Doxyfile from the bundled "
706 "template, declaring this project for Doxygen builds. "
707 "Keeps an existing Doxyfile unless --force is given.")
708@click.option("--force", "force", is_flag=True,
709 help="With --init-doxygen: overwrite an existing Doxyfile.")
710@click.option("--callgraph", "callgraph", metavar="QUALNAME", default=None,
711 help="Draw the full call graph of one function (dotted name, or "
712 "a unique suffix) to docs/_build/callgraph/ as DOT and SVG, "
713 "and report what could and could not be resolved.")
714@click.option("--depth", "depth", type=click.IntRange(min=0), default=None,
715 help="With --callgraph: stop this many calls below the root.")
716@click.option("--trace", "trace_mode", is_flag=True,
717 help="With --callgraph: run 'oct ARGS' (given after --) in this "
718 "process, FOR REAL, record what ran, and draw it against the "
719 "static graph. Run it in a throwaway project for commands "
720 "with side effects, such as git commit.")
721@click.argument("trace_args", nargs=-1, type=click.UNPROCESSED)
722@click.pass_context
723def docs(
724 ctx: click.Context,
725 sphinx_mode: bool,
726 doxygen_mode: bool,
727 typedoc_mode: bool,
728 all_mode: bool,
729 clean_mode: bool,
730 dry_run: bool,
731 no_workspace: bool,
732 init_doxygen: bool,
733 force: bool,
734 callgraph: str | None,
735 depth: int | None,
736 trace_mode: bool,
737 trace_args: tuple[str, ...],
738) -> None:
739 """Validate or build Option C documentation."""
740 project_root = get_project_root(ctx)
741
742 if callgraph is None and (depth is not None or trace_mode):
743 raise click.UsageError("--depth and --trace need --callgraph QUALNAME.")
744 if trace_args and not trace_mode:
745 raise click.UsageError(
746 f"Unexpected arguments {' '.join(trace_args)!r}; arguments after "
747 "-- are the oct command that --trace runs.")
748 if callgraph is not None:
749 from oct.docs.callgraph_command import run_docs_callgraph
750 exit_code = run_docs_callgraph(
751 project_root, callgraph, depth=depth,
752 trace_args=list(trace_args) if trace_mode else None,
753 )
754 if exit_code != 0:
755 raise SystemExit(exit_code)
756 return
757
758 if init_doxygen:
759 from oct.docs.doxyfile_init import DoxyfileTemplateMissing, write_doxyfile
760 from oct.docs.oct_docs import _load_project_info
761 # PROJECT_NAME is the title Doxygen shows. Prefer the workspace
762 # manifest's name for this subproject, because that is what the docs
763 # hub card is labelled with; the pyproject name is the distribution
764 # name and can differ (oc_chat's is 'oc_chat-workspace').
765 name = _workspace_subproject_name(project_root)
766 if name is None:
767 name = _load_project_info(project_root).get("name") or project_root.name
768 try:
769 path, written = write_doxyfile(project_root, name, force=force)
770 except DoxyfileTemplateMissing as exc:
771 click.echo(f"Error: {exc}", err=True)
772 raise SystemExit(1)
773 if written:
774 click.echo(f"Wrote {path} (PROJECT_NAME = {name!r}).")
775 click.echo("This project now builds under 'oct docs --doxygen' "
776 "and the workspace fan-out, and gets a Doxygen card "
777 "on the docs hub, unless its workspace manifest entry "
778 "sets html_docs.doxygen to false.")
779 else:
780 click.echo(f"Kept existing {path}; pass --force to overwrite it.")
781 return
782
783 if clean_mode:
784 from oct.docs.oct_docs import run_docs_clean
785 exit_code = run_docs_clean(project_root, dry_run=dry_run, no_workspace=no_workspace)
786 if exit_code != 0:
787 raise SystemExit(exit_code)
788 return
789
790 if all_mode:
791 from oct.docs.oct_docs import run_docs_all
792 exit_code = run_docs_all(project_root, no_workspace=no_workspace)
793 if exit_code != 0:
794 raise SystemExit(exit_code)
795 return
796
797 if sphinx_mode:
798 from oct.docs.oct_docs import run_docs_sphinx
799 exit_code = run_docs_sphinx(project_root, no_workspace=no_workspace)
800 if exit_code != 0:
801 raise SystemExit(exit_code)
802 return
803
804 if doxygen_mode:
805 from oct.docs.oct_docs import run_docs_doxygen
806 exit_code = run_docs_doxygen(project_root, no_workspace=no_workspace)
807 if exit_code != 0:
808 raise SystemExit(exit_code)
809 return
810
811 if typedoc_mode:
812 from oct.docs.typedoc import run_docs_typedoc
813 exit_code = run_docs_typedoc(project_root, no_workspace=no_workspace)
814 if exit_code != 0:
815 raise SystemExit(exit_code)
816 return
817
818 _oc_diag = project_root / "oc_diagnostics"
819 diag_dir = _oc_diag if _oc_diag.exists() else project_root / "diagnostics"
820 # Plan 151 T6 (G7 deprecation-clearance): warn ONLY when a LEGACY
821 # ``diagnostics/`` directory actually exists, not whenever
822 # ``oc_diagnostics/`` is merely absent.
823 if diag_dir.name == "diagnostics" and diag_dir.is_dir():
824 click.echo(
825 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
826 "Rename to 'oc_diagnostics/'.",
827 err=True,
828 )
829 required = [
830 project_root / "docs" / "ARCHITECTURE.md",
831 project_root / "tests" / "Tests_README.md",
832 diag_dir / "debug_config.json",
833 ]
834
835 missing = [p for p in required if not p.exists()]
836
837 if missing:
838 click.echo("Missing required documentation files:")
839 for m in missing:
840 click.echo(f" - {m}")
841 else:
842 click.echo("All required Option C documentation is present.")
843
844# The traced command's arguments (after --) must reach it verbatim, so the
845# Windows glob expansion does not touch 'oct docs' argv; docs takes no paths.
846setattr(docs, NO_GLOB_EXPANSION_ATTR, True)
847
848_PYTEST_INSTALL_HINT = (
849 "pytest is not installed. Install it with one of:\n"
850 " pip install oct[test] # installs OCT's test extras\n"
851 " pip install pytest # install pytest directly"
852)
853
854
855def _pytest_missing(combined_output: str) -> bool:
856 """Detect the 'No module named pytest' signature in combined output."""
857 return "No module named pytest" in combined_output
858
859
860def _run_pytest(project_root: Path, args: tuple, json_mode: bool = False) -> None:
861 """Run pytest in *project_root* and exit on failure.
862
863 When *json_mode* is True, captures pytest output, parses the summary
864 line, and emits a machine-readable JSON object to stdout:
865 ``{"tool": "test", "passed": N, "failed": N, "errors": N,
866 "warnings": N, "output": "...", "exit_code": N}``
867
868 OI-511: pytest is now declared in the ``[project.optional-dependencies].test``
869 extra rather than as a runtime dependency; if ``python -m pytest`` reports
870 it is not importable, emit the install hint above and exit 1.
871 """
872 import json as _json
873 import re as _re
874 import subprocess, sys
875 if json_mode:
876 try:
877 result = subprocess.run(
878 [sys.executable, "-m", "pytest", "--tb=short", "-q"] + list(args),
879 cwd=project_root,
880 capture_output=True,
881 text=True,
882 encoding="utf-8",
883 errors="replace",
884 )
885 except FileNotFoundError:
886 click.echo(_json.dumps({
887 "tool": "test", "passed": 0, "failed": 0, "errors": 1,
888 "warnings": 0, "output": _PYTEST_INSTALL_HINT,
889 "exit_code": 1,
890 }))
891 raise SystemExit(1)
892 combined = (result.stdout or "") + (result.stderr or "")
893 if result.returncode != 0 and _pytest_missing(combined):
894 click.echo(_json.dumps({
895 "tool": "test", "passed": 0, "failed": 0, "errors": 1,
896 "warnings": 0, "output": _PYTEST_INSTALL_HINT,
897 "exit_code": 1,
898 }))
899 raise SystemExit(1)
900 # Parse the pytest summary line: "N passed", "N failed", "N error"
901 passed = failed = errors = warnings = 0
902 for m in _re.finditer(
903 r"(\d+)\s+(passed|failed|error(?:s)?|warning(?:s)?)",
904 combined,
905 ):
906 n, kind = int(m.group(1)), m.group(2)
907 if kind == "passed":
908 passed = n
909 elif kind == "failed":
910 failed = n
911 elif kind.startswith("error"):
912 errors = n
913 elif kind.startswith("warning"):
914 warnings = n
915 click.echo(_json.dumps({
916 "tool": "test",
917 "passed": passed,
918 "failed": failed,
919 "errors": errors,
920 "warnings": warnings,
921 "output": combined,
922 "exit_code": result.returncode,
923 }))
924 if result.returncode != 0:
925 raise SystemExit(result.returncode)
926 return
927 try:
928 result = subprocess.run(
929 [sys.executable, "-m", "pytest"] + list(args),
930 cwd=project_root,
931 capture_output=True,
932 text=True,
933 encoding="utf-8",
934 errors="replace",
935 )
936 except FileNotFoundError:
937 raise click.ClickException(_PYTEST_INSTALL_HINT)
938 combined = (result.stdout or "") + (result.stderr or "")
939 if result.returncode != 0 and _pytest_missing(combined):
940 raise click.ClickException(_PYTEST_INSTALL_HINT)
941 # Replay captured output so users still see pytest's normal reporting.
942 if result.stdout:
943 click.echo(result.stdout, nl=False)
944 if result.stderr:
945 click.echo(result.stderr, nl=False, err=True)
946 if result.returncode == 0:
947 click.echo("All tests passed.")
948 else:
949 click.echo("Some tests failed.")
950 raise SystemExit(result.returncode)
951
952
953def _map_source_to_test(source: Path, project_root: Path) -> Path | None:
954 """Heuristic: map a source .py file to its likely test file.
955
956 Tries several common patterns and returns the first existing match,
957 or ``None`` if no test file is found.
958 """
959 try:
960 rel = source.relative_to(project_root)
961 except ValueError:
962 return None
963
964 parts = list(rel.parts)
965 stem = source.stem
966
967 # Pattern 1: oct/<pkg>/foo.py → tests/tests_<pkg>/test_foo.py
968 if len(parts) >= 3 and parts[0] == "oct":
969 pkg = parts[1]
970 test_path = project_root / "tests" / f"tests_{pkg}" / f"test_{stem}.py"
971 if test_path.is_file():
972 return test_path
973
974 # Pattern 2: src/foo.py → tests/test_foo.py
975 if len(parts) >= 2 and parts[0] == "src":
976 test_path = project_root / "tests" / f"test_{stem}.py"
977 if test_path.is_file():
978 return test_path
979
980 # Pattern 3: <any>/foo.py → tests/test_foo.py
981 test_path = project_root / "tests" / f"test_{stem}.py"
982 if test_path.is_file():
983 return test_path
984
985 return None
986
987
988def _cache_args(cached: bool, cache_clear: bool) -> tuple[str, ...]:
989 """Pytest arguments that drive the Plan 249 content-hashed cache.
990
991 ``-p oct.testing.cache`` loads the plugin for ANY project, so the
992 flags work whether or not the project's root conftest wires the
993 seams itself; the plugin registers its options at most once.
994 """
995 if not (cached or cache_clear):
996 return ()
997 from oct.testing.cache import CACHE_CLEAR_OPTION, CACHE_OPTION
998
999 flags = ["-p", "oct.testing.cache"]
1000 if cached:
1001 flags.append(CACHE_OPTION)
1002 if cache_clear:
1003 flags.append(CACHE_CLEAR_OPTION)
1004 return tuple(flags)
1005
1006
1007def _record_cached_run(ctx: click.Context, project_root: Path) -> None:
1008 """Put the cached run's file count in the timing ledger (P5).
1009
1010 ``files`` is the number of files that actually RAN, so the saving
1011 a cache produced is visible in ``oct health --timing`` beside the
1012 uncached runs of the same stage.
1013 """
1014 from oct.testing.cache import read_last_run
1015
1016 sample = ctx.meta.get(TIMING_SAMPLE_KEY)
1017 if sample is None:
1018 return
1019 sample.scope = "cached"
1020 last_run = read_last_run(project_root)
1021 ran = last_run.get("ran")
1022 if isinstance(ran, int) and ran >= 0:
1023 sample.files = ran
1024
1025
1026@cli.command()
1027@click.option("--json", "json_mode", is_flag=True,
1028 help="Emit machine-readable JSON summary to stdout.")
1029@click.option("--changed", is_flag=True,
1030 help="Run tests for git-modified files only.")
1031@click.option("--cached", is_flag=True,
1032 help="Skip test files unchanged since their last green run.")
1033@click.option("--oct-cache-clear", "cache_clear", is_flag=True,
1034 help="Empty the Option C test cache before running.")
1035@click.argument("args", nargs=-1)
1036@click.pass_context
1038 ctx: click.Context,
1039 json_mode: bool,
1040 changed: bool,
1041 cached: bool,
1042 cache_clear: bool,
1043 args: tuple[str, ...],
1044) -> None:
1045 """Run the project's test suite."""
1046 project_root = get_project_root(ctx)
1047 args = _cache_args(cached, cache_clear) + args
1048 if cached:
1049 ctx.call_on_close(
1050 lambda: _record_cached_run(ctx, project_root),
1051 )
1052
1053 _PYTEST_CFG_FILES = ("pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini")
1054
1055 # --changed: discover changed source files and map to tests.
1056 if changed:
1057 try:
1058 from oct.core.git import git_changed_files
1059 changed_py = git_changed_files(project_root, extensions={".py"})
1060 test_files: list[Path] = []
1061 for src in changed_py:
1062 mapped = _map_source_to_test(src, project_root)
1063 if mapped and mapped not in test_files:
1064 test_files.append(mapped)
1065 if test_files:
1066 rel_paths = [str(t.relative_to(project_root)) for t in test_files]
1067 if not json_mode:
1068 click.echo(f"Running {len(test_files)} test file(s) for changed sources.")
1069 _run_pytest(project_root, tuple(rel_paths) + args, json_mode=json_mode)
1070 return
1071 else:
1072 click.echo(
1073 "No test files found for changed sources. Running all tests.",
1074 err=True,
1075 )
1076 except Exception as exc:
1077 click.echo(
1078 f"Warning: --changed failed ({exc}). Running all tests.",
1079 err=True,
1080 )
1081
1082 has_tests_dir = (project_root / "tests").is_dir()
1083 has_pytest_cfg = any((project_root / f).is_file() for f in _PYTEST_CFG_FILES)
1084
1085 if has_tests_dir or has_pytest_cfg:
1086 # Standard single-project test run.
1087 _run_pytest(project_root, args, json_mode=json_mode)
1088 return
1089
1090 # Meta-project: discover sub-projects that have tests/ AND a pytest config.
1091 sub_projects = []
1092 for child in sorted(project_root.iterdir()):
1093 if not child.is_dir() or child.name.startswith("."):
1094 continue
1095 if (child / "tests").is_dir() and any(
1096 (child / f).is_file() for f in _PYTEST_CFG_FILES
1097 ):
1098 sub_projects.append(child)
1099
1100 if not sub_projects:
1101 raise click.ClickException(
1102 f"No tests/ directory or pytest configuration found in {project_root}, "
1103 f"and no testable sub-projects detected. "
1104 f"Run 'oct test' from a project that has a test suite."
1105 )
1106
1107 import subprocess, sys
1108
1109 click.echo(f"No test suite at root -- found {len(sub_projects)} "
1110 f"sub-project(s) with tests:")
1111 for sp in sub_projects:
1112 click.echo(f" - {sp.name}/")
1113 click.echo()
1114
1115 failed = []
1116 for sp in sub_projects:
1117 click.echo(f"{'=' * 60}")
1118 click.echo(f"Running tests in {sp.name}/")
1119 click.echo(f"{'=' * 60}")
1120 try:
1121 result = subprocess.run(
1122 [sys.executable, "-m", "pytest"] + list(args),
1123 cwd=sp,
1124 )
1125 if result.returncode != 0:
1126 failed.append(sp.name)
1127 except FileNotFoundError:
1128 raise click.ClickException(
1129 "pytest is not installed. Install it with: pip install pytest"
1130 )
1131 click.echo()
1132
1133 if failed:
1134 click.echo(f"Tests failed in: {', '.join(failed)}")
1135 raise SystemExit(1)
1136 else:
1137 click.echo("All sub-project tests passed.")
1138
1139@cli.command(name="export-source")
1140@click.option("--verbose", is_flag=True, help="Show per-file statistics.")
1141@click.option("--clean", is_flag=True, help="[DEPRECATED: use 'oct clean'] Remove _source_code-* dirs.")
1142@click.option("--single-dir", is_flag=True, help="Write all output into one root-level directory.")
1143@click.option("--allow-outside-project", is_flag=True,
1144 help="Allow PATH outside the auto-detected project root (OI-405).")
1145@click.option("--warn-secrets/--no-warn-secrets", default=True,
1146 help="Scan file contents for potential secrets (OI-416).")
1147@click.option("--block-secrets", is_flag=True,
1148 help="Skip files with potential secrets (implies --warn-secrets, OI-416).")
1149@click.option("--diff", "diff_ref", default=None,
1150 help="Export only files changed since REF (git ref).")
1151@click.argument("path", required=False)
1152@click.pass_context
1154 ctx: click.Context,
1155 verbose: bool,
1156 clean: bool,
1157 single_dir: bool,
1158 allow_outside_project: bool,
1159 warn_secrets: bool,
1160 block_secrets: bool,
1161 diff_ref: str | None,
1162 path: str | None,
1163) -> None:
1164 """Export consolidated source-code files for inspection."""
1165 from oct.core.project_root import validate_path_containment
1166 from oct.tools.source_exporter import run_exporter
1167
1168 # Merge global and command-level allow_outside_project flags
1169 global_allow = ctx.obj.get("allow_outside_project", False) if ctx.obj else False
1170 allow_outside_project = allow_outside_project or global_allow
1171
1172 # Update context for get_project_root to use
1173 if ctx.obj:
1174 ctx.obj["allow_outside_project"] = allow_outside_project
1175
1176 if path:
1177 if allow_outside_project:
1178 project_root = Path(path).resolve()
1179 else:
1180 auto_root = get_project_root(ctx)
1181 project_root = validate_path_containment(
1182 Path(path), auto_root, allow_outside=False
1183 )
1184 else:
1185 project_root = get_project_root(ctx)
1186
1187 args = []
1188 if verbose:
1189 args.append("--verbose")
1190 if clean:
1191 click.echo(
1192 "Warning: 'export-source --clean' is deprecated. "
1193 "Use 'oct clean' (with optional --dry-run) instead.",
1194 err=True,
1195 )
1196 args.append("--clean")
1197 if single_dir:
1198 args.append("--single-dir")
1199 if warn_secrets:
1200 args.append("--warn-secrets")
1201 if block_secrets:
1202 args.append("--block-secrets")
1203 if diff_ref:
1204 args.append(f"--diff={diff_ref}")
1205
1206 try:
1207 run_exporter(project_root, args)
1208 except Exception as e:
1209 click.echo(f"Error: export-source failed unexpectedly: {e}", err=True)
1210 raise SystemExit(1)
1211
1212
1213@cli.command(name="export-skeleton")
1214@click.option("--json", "json_mode", is_flag=True,
1215 help="Emit machine-readable JSON summary to stdout.")
1216@click.option("--verbose", is_flag=True, help="Show per-file statistics.")
1217@click.option("--single-dir", is_flag=True, help="Write all output into one root-level directory.")
1218@click.option("--no-diagnostics", is_flag=True, help="Omit Diagnostics section from docstrings.")
1219@click.option("--clean", is_flag=True, help="Remove _skeleton_code-* dirs.")
1220@click.option("--allow-outside-project", is_flag=True,
1221 help="Allow PATH outside the auto-detected project root (OI-405).")
1222@click.argument("path", required=False)
1223@click.pass_context
1225 ctx: click.Context,
1226 json_mode: bool,
1227 verbose: bool,
1228 single_dir: bool,
1229 no_diagnostics: bool,
1230 clean: bool,
1231 allow_outside_project: bool,
1232 path: str | None,
1233) -> None:
1234 """Export structural skeletons of source files for AI inspection."""
1235 from oct.core.project_root import validate_path_containment
1236 from oct.tools.skeleton_exporter import run_skeleton_exporter
1237
1238 if path:
1239 if allow_outside_project:
1240 project_root = Path(path).resolve()
1241 else:
1242 auto_root = get_project_root(ctx)
1243 project_root = validate_path_containment(
1244 Path(path), auto_root, allow_outside=False
1245 )
1246 else:
1247 project_root = get_project_root(ctx)
1248
1249 args = []
1250 if verbose:
1251 args.append("--verbose")
1252 if single_dir:
1253 args.append("--single-dir")
1254 if no_diagnostics:
1255 args.append("--no-diagnostics")
1256 if clean:
1257 args.append("--clean")
1258
1259 try:
1260 run_skeleton_exporter(project_root, args, json_mode=json_mode)
1261 except Exception as e:
1262 click.echo(f"Error: export-skeleton failed unexpectedly: {e}", err=True)
1263 raise SystemExit(1)
1264
1265
1266@cli.command()
1267@click.argument("path", required=False)
1268@click.option("--dry-run", is_flag=True, help="Show what would be deleted without deleting anything")
1269@click.option("--allow-outside-project", is_flag=True,
1270 help="Allow PATH outside the auto-detected project root (OI-405).")
1271@click.pass_context
1273 ctx: click.Context,
1274 path: str | None,
1275 dry_run: bool,
1276 allow_outside_project: bool,
1277) -> None:
1278 """Remove _source_code-*, _skeleton_code-* export dirs, __pycache__ dirs
1279 and packaging build/ and dist/ trees.
1280
1281 A build/ or dist/ directory is only removed when it sits beside a
1282 pyproject.toml / setup.py / setup.cfg AND contains nothing but
1283 packaging output. One that fails either test is kept and the reason
1284 printed, because both are ordinary words and a project is entitled to
1285 a source directory called either.
1286 """
1287 from oct.core.project_root import validate_path_containment
1288 from oct.tools.build_artifacts import clean_build_dirs
1289 from oct.tools.source_exporter import clean_source_dirs, clean_pycache_dirs
1290 from oct.tools.skeleton_exporter import clean_skeleton_dirs
1291
1292 if path:
1293 if allow_outside_project:
1294 target = Path(path).resolve()
1295 else:
1296 try:
1297 auto_root = get_project_root(ctx)
1298 except click.ClickException:
1299 auto_root = Path.cwd()
1300 target = validate_path_containment(
1301 Path(path), auto_root, allow_outside=False
1302 )
1303 else:
1304 target = Path.cwd()
1305 failed_total = 0
1306 for cleaner in (clean_source_dirs, clean_skeleton_dirs, clean_pycache_dirs,
1307 clean_build_dirs):
1308 _removed, failed = cleaner(target, dry_run=dry_run)
1309 failed_total += len(failed)
1310 if failed_total:
1311 click.echo(
1312 f"oct clean: {failed_total} directories could not be removed; "
1313 "see the list above.",
1314 err=True,
1315 )
1316 ctx.exit(1)
1317
1318
1319@cli.command()
1320@click.option("--json", "json_mode", is_flag=True, help="Machine-readable JSON output")
1321@click.option("--verbose", is_flag=True, help="Detailed per-file breakdown")
1322@click.option("--tests", "run_tests", is_flag=True, help="Run the project test suite")
1323@click.option(
1324 "--timeout",
1325 "test_timeout",
1326 type=click.IntRange(min=1),
1327 default=None,
1328 help="Test execution timeout in seconds (default: 300, or health.test_timeout in .octrc.json).",
1329)
1330@click.option("--timing", is_flag=True, help="Show only timing statistics")
1331@click.option("--timing-last", type=click.IntRange(min=1), default=20, help="Recent records per stage")
1332@click.option("--write-baseline", is_flag=True, help="Write this host's timing baseline")
1333@click.option("--force-host", is_flag=True, help="Allow adding a different baseline host")
1334@click.option("--session", type=click.Path(exists=True, dir_okay=False, path_type=Path), help="Session events JSONL")
1335@click.pass_context
1336def health(
1337 ctx: click.Context,
1338 json_mode: bool,
1339 verbose: bool,
1340 run_tests: bool,
1341 test_timeout: int | None,
1342 timing: bool,
1343 timing_last: int,
1344 write_baseline: bool,
1345 force_host: bool,
1346 session: Path | None,
1347) -> None:
1348 """Display project compliance dashboard."""
1349 project_root = get_project_root(ctx)
1350 run_health(
1351 project_root,
1352 json_mode=json_mode,
1353 verbose=verbose,
1354 run_tests=run_tests,
1355 test_timeout=test_timeout,
1356 log_output=ctx.obj.get("log_output", False),
1357 **({"timing": timing, "timing_last": timing_last,
1358 "write_baseline": write_baseline, "force_host": force_host,
1359 "session": session} if timing or write_baseline or session or force_host else {}),
1360 )
1361
1362
1363@cli.command(context_settings=dict(ignore_unknown_options=True))
1364@click.argument("args", nargs=-1, type=click.UNPROCESSED)
1365@click.pass_context
1366def deps(ctx: click.Context, args: tuple[str, ...]) -> None:
1367 """Analyse inter-module dependencies."""
1368 project_root = get_project_root(ctx)
1369 exit_code = run_deps(project_root, list(args))
1370 if exit_code != 0:
1371 raise SystemExit(exit_code)
1372
1373
1374@cli.command(name="install-hooks")
1375@click.option("--force", is_flag=True, help="Overwrite existing .pre-commit-config.yaml")
1376@click.pass_context
1377def install_hooks_cmd(ctx: click.Context, force: bool) -> None:
1378 """[DEPRECATED] Use 'oct git hooks install' instead."""
1379 click.echo(
1380 "Notice: 'oct install-hooks' is deprecated. "
1381 "Use 'oct git hooks install' instead.",
1382 err=True,
1383 )
1384 project_root = get_project_root(ctx)
1385 # Delegate to the enhanced hooks installer via the new module.
1386 from oct.git.oct_git import _ENHANCED_HOOKS_TEMPLATE
1387 config_path = project_root / ".pre-commit-config.yaml"
1388 if config_path.exists() and not force:
1389 click.echo(f"Error: {config_path} already exists. Use --force to overwrite.")
1390 raise SystemExit(1)
1391 config_path.write_text(_ENHANCED_HOOKS_TEMPLATE, encoding="utf-8")
1392 click.echo(f"Created {config_path}")
1393 click.echo("Run 'pre-commit install' to activate the hooks.")
1394
1395
1396@cli.command(context_settings=dict(ignore_unknown_options=True))
1397@click.option("--json", "json_mode", is_flag=True,
1398 help="Emit machine-readable JSON summary to stdout.")
1399@click.argument("args", nargs=-1, type=click.UNPROCESSED)
1400@click.pass_context
1401def typecheck(
1402 ctx: click.Context,
1403 json_mode: bool,
1404 args: tuple[str, ...],
1405) -> None:
1406 """Run type checking (pyright or mypy)."""
1407 project_root = get_project_root(ctx)
1408 exit_code = run_typecheck(project_root, list(args), json_mode=json_mode)
1409 if exit_code != 0:
1410 raise SystemExit(exit_code)
1411
1412
1413# -- git command group (Phase 4B) ------------------------------------
1414from oct.git import git_group as _git_group
1415cli.add_command(_git_group, name="git")
1416
1417# -- migrate command group (FS-539) ----------------------------------
1418from oct.migrate import migrate_group as _migrate_group
1419cli.add_command(_migrate_group, name="migrate")
1420
1421# -- audit command (FS-539 / FS-501 stub) ----------------------------
1422from oct.audit import audit_group as _audit_cmd
1423cli.add_command(_audit_cmd, name="audit")
1424
1425# -- install command (Phase 4-followup B1.4) -------------------------
1426from oct.install.oct_install import oct_install_cmd as _oct_install_cmd
1427from oct.core.diagnostics import _dbg
1428cli.add_command(_oct_install_cmd, name="install")
1429
1430
1431@cli.group()
1432@click.pass_context
1433def diag(ctx: click.Context) -> None:
1434 """oc_diagnostics configuration tools."""
1435 pass
1436
1437
1438@diag.command(name="validate-config")
1439@click.option(
1440 "--strict",
1441 is_flag=True,
1442 default=False,
1443 help="OI-534: escalate WARNING to failure; exit 2 on any ERROR or WARNING.",
1444)
1445@click.pass_context
1446def diag_validate_config(ctx: click.Context, strict: bool) -> None:
1447 """Validate debug_config.json against the expected schema."""
1448 project_root = get_project_root(ctx)
1449 issues = validate_config(project_root)
1450 if not issues:
1451 click.echo("debug_config.json is valid.")
1452 return
1453 for issue in issues:
1454 click.echo(issue)
1455 has_error = any(i.startswith("ERROR") for i in issues)
1456 has_warning = any(i.startswith("WARNING") for i in issues)
1457 # OI-534 / FS-513: --strict promotes WARNING to failure and uses
1458 # exit code 2 so CI can distinguish a config audit failure from the
1459 # generic exit 1 used by the non-strict path.
1460 if strict and (has_error or has_warning):
1461 raise SystemExit(2)
1462 if has_error:
1463 raise SystemExit(1)
1464
1465
1466@diag.command(name="list-domains")
1467@click.pass_context
1468def diag_list_domains(ctx: click.Context) -> None:
1469 """List configured domains with their levels and colors."""
1470 project_root = get_project_root(ctx)
1471 try:
1472 domains = list_domains(project_root)
1473 except FileNotFoundError as e:
1474 raise click.ClickException(str(e))
1475
1476 if not domains:
1477 click.echo("No domains configured.")
1478 return
1479
1480 click.echo(f"{'Domain':<24} {'Level':<8} {'Color'}")
1481 click.echo("-" * 44)
1482 for d in domains:
1483 click.echo(f"{d['name']:<24} {d['level']:<8} {d['color']}")
1484
1485
1486@diag.command(name="set-level")
1487@click.argument("domain")
1488@click.argument("level", type=click.IntRange(0, 9))
1489@click.option(
1490 "--dry-run",
1491 is_flag=True,
1492 default=False,
1493 help="Preview the edit without writing the config or its .bk backup.",
1494)
1495@click.pass_context
1497 ctx: click.Context,
1498 domain: str,
1499 level: int,
1500 dry_run: bool,
1501) -> None:
1502 """Set a domain's debug level (0-9).
1503
1504 OI-528: the write is always preceded by a ``<path>.bk`` backup so
1505 ``oct diag restore`` can undo a bad edit. ``--dry-run`` prints the
1506 would-be post-edit config to stdout and skips both writes.
1507 """
1508 project_root = get_project_root(ctx)
1509 try:
1510 new_config = set_level(project_root, domain, level, dry_run=dry_run)
1511 except (FileNotFoundError, ValueError, KeyError) as e:
1512 raise click.ClickException(str(e))
1513 if dry_run:
1514 click.echo(json.dumps(new_config, indent=2))
1515 click.echo(
1516 f"[DRY RUN] Would set {domain} level to {level} (no write).",
1517 err=True,
1518 )
1519 return
1520 click.echo(f"Set {domain} level to {level}.")
1521
1522
1523@diag.command(name="restore")
1524@click.pass_context
1525def diag_restore(ctx: click.Context) -> None:
1526 """OI-528: restore debug_config.json from its .bk backup."""
1527 project_root = get_project_root(ctx)
1528 try:
1529 restored = restore_config(project_root)
1530 except FileNotFoundError as e:
1531 raise click.ClickException(str(e))
1532 click.echo(f"Restored {restored} from backup.")
1533
1534
1535@diag.command(name="migrate-config")
1536@click.argument(
1537 "config_path",
1538 required=False,
1539 type=click.Path(dir_okay=False, path_type=Path),
1540)
1541@click.option(
1542 "--dry-run",
1543 is_flag=True,
1544 default=False,
1545 help="Show the migrated config on stdout without writing to disk.",
1546)
1547@click.pass_context
1549 ctx: click.Context,
1550 config_path: Path | None,
1551 dry_run: bool,
1552) -> None:
1553 """OI-508 / FS-515: upgrade a legacy v1 debug_config.json to v2.0.
1554
1555 When PATH is omitted, migrates the project's own debug_config.json.
1556 Writes ``<path>.bk`` before overwriting; idempotent - running twice
1557 over an already-v2 file is a no-op.
1558 """
1559 from oct.diag.oct_diag import _find_config
1560 if config_path is None:
1561 project_root = get_project_root(ctx)
1562 config_path = _find_config(project_root)
1563 try:
1564 new_config = migrate_config(config_path, dry_run=dry_run)
1565 except (FileNotFoundError, ValueError) as e:
1566 raise click.ClickException(str(e))
1567 if dry_run:
1568 click.echo(json.dumps(new_config, indent=2))
1569 click.echo(f"[DRY RUN] Would write v2 schema to {config_path}", err=True)
1570 return
1571 backup_path = config_path.with_suffix(config_path.suffix + ".bk")
1572 click.echo(f"Migrated {config_path} -> v{new_config.get('_schema_version')} "
1573 f"(backup: {backup_path.name})")
1574
1575
1576@cli.command(name="install-mcp")
1577@click.option(
1578 "--host",
1579 type=click.Choice(["claude-code", "cursor", "vscode", "auto"], case_sensitive=False),
1580 default="auto",
1581 help="Target AI host (default: auto-detect).",
1582)
1583@click.option(
1584 "--dry-run/--no-dry-run",
1585 default=True,
1586 help="Preview without writing (default: --dry-run).",
1587)
1588@click.pass_context
1589def install_mcp(ctx: click.Context, host: str, dry_run: bool) -> None:
1590 """Install oct-mcp into an AI host's MCP config (Claude Code, Cursor, VS Code)."""
1591 from oct.tools.install_mcp import run_install_mcp
1592 project_root = get_project_root(ctx)
1593 exit_code = run_install_mcp(host=host, project_root=project_root, dry_run=dry_run)
1594 if exit_code != 0:
1595 raise SystemExit(exit_code)
1596
1597
1598if __name__ == "__main__":
1599 cli()
None format_help(self, click.Context ctx, click.HelpFormatter formatter)
Definition cli.py:477
object invoke(self, click.Context ctx)
Definition cli.py:448
object main(self, list[str]|None args=None, *object rest, bool windows_expand_args=True, **object kwargs)
Definition cli.py:435
None export_skeleton(click.Context ctx, bool json_mode, bool verbose, bool single_dir, bool no_diagnostics, bool clean, bool allow_outside_project, str|None path)
Definition cli.py:1233
bool _pytest_missing(str combined_output)
Definition cli.py:855
None _record_cached_run(click.Context ctx, Path project_root)
Definition cli.py:1007
None format(click.Context ctx, tuple[str,...] args)
Definition cli.py:570
None diag_validate_config(click.Context ctx, bool strict)
Definition cli.py:1446
None _run_pytest(Path project_root, tuple args, bool json_mode=False)
Definition cli.py:860
None agent(tuple[str,...] args)
Definition cli.py:578
list[str] _expand_windows_args(click.Group group, list[str] args)
Definition cli.py:395
None diag_list_domains(click.Context ctx)
Definition cli.py:1468
None diag_migrate_config(click.Context ctx, Path|None config_path, bool dry_run)
Definition cli.py:1552
None lint(click.Context ctx, tuple[str,...] args)
Definition cli.py:559
tuple[str,...] _cache_args(bool cached, bool cache_clear)
Definition cli.py:988
None _start_diagnostics()
Definition cli.py:415
None clean(click.Context ctx, str|None path, bool dry_run, bool allow_outside_project)
Definition cli.py:1277
None export_source(click.Context ctx, bool verbose, bool clean, bool single_dir, bool allow_outside_project, bool warn_secrets, bool block_secrets, str|None diff_ref, str|None path)
Definition cli.py:1163
str|None _workspace_subproject_name(Path project_root)
Definition cli.py:667
None test(click.Context ctx, bool json_mode, bool changed, bool cached, bool cache_clear, tuple[str,...] args)
Definition cli.py:1044
Path|None _map_source_to_test(Path source, Path project_root)
Definition cli.py:953
None diag_restore(click.Context ctx)
Definition cli.py:1525
None new(click.Context ctx, str path, bool force, bool test, bool dry_run, bool verbose)
Definition cli.py:622
None install_hooks_cmd(click.Context ctx, bool force)
Definition cli.py:1377
None diag_set_level(click.Context ctx, str domain, int level, bool dry_run)
Definition cli.py:1501
click.Command|None _resolve_leaf_command(click.Group group, list[str] args)
Definition cli.py:362