quor 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quor/__init__.py +1 -0
- quor/__main__.py +104 -0
- quor/adapters/__init__.py +0 -0
- quor/adapters/base.py +39 -0
- quor/adapters/claude.py +65 -0
- quor/adapters/dispatcher.py +212 -0
- quor/cli/__init__.py +0 -0
- quor/cli/commands/__init__.py +0 -0
- quor/cli/commands/doctor.py +190 -0
- quor/cli/commands/explain.py +84 -0
- quor/cli/commands/gain.py +54 -0
- quor/cli/commands/init.py +210 -0
- quor/cli/commands/validate.py +55 -0
- quor/cli/commands/verify.py +52 -0
- quor/cli/main.py +58 -0
- quor/config/__init__.py +0 -0
- quor/config/loader.py +43 -0
- quor/config/model.py +60 -0
- quor/errors.py +69 -0
- quor/filters/__init__.py +0 -0
- quor/filters/builtin/__init__.py +0 -0
- quor/filters/builtin/build.toml +89 -0
- quor/filters/builtin/cat.toml +50 -0
- quor/filters/builtin/git.toml +131 -0
- quor/filters/builtin/pytest.toml +59 -0
- quor/filters/builtin/z_generic.toml +34 -0
- quor/filters/loader.py +32 -0
- quor/filters/registry.py +278 -0
- quor/filters/trust.py +19 -0
- quor/pipeline/__init__.py +0 -0
- quor/pipeline/content_type.py +69 -0
- quor/pipeline/engine.py +151 -0
- quor/pipeline/mask.py +57 -0
- quor/pipeline/plugin_loader.py +517 -0
- quor/pipeline/stages/__init__.py +0 -0
- quor/pipeline/stages/_utils.py +48 -0
- quor/pipeline/stages/base.py +88 -0
- quor/pipeline/stages/deduplicate_consecutive.py +68 -0
- quor/pipeline/stages/group_repeated.py +162 -0
- quor/pipeline/stages/max_tokens.py +126 -0
- quor/pipeline/stages/remove_ansi.py +71 -0
- quor/pipeline/stages/strip_lines.py +70 -0
- quor/plugins/__init__.py +61 -0
- quor/plugins/base.py +473 -0
- quor/plugins/registry.py +349 -0
- quor/py.typed +0 -0
- quor/rewrite/__init__.py +0 -0
- quor/rewrite/classifier.py +222 -0
- quor/rewrite/lexer.py +198 -0
- quor/rewrite/rules.py +145 -0
- quor/tracking/__init__.py +0 -0
- quor/tracking/db.py +310 -0
- quor/tracking/schema.sql +28 -0
- quor-0.1.0.dist-info/METADATA +259 -0
- quor-0.1.0.dist-info/RECORD +58 -0
- quor-0.1.0.dist-info/WHEEL +4 -0
- quor-0.1.0.dist-info/entry_points.txt +3 -0
- quor-0.1.0.dist-info/licenses/LICENSE +201 -0
quor/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
quor/__main__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quor entry point.
|
|
3
|
+
|
|
4
|
+
Version check runs at module level before any Quor import so we fail fast on
|
|
5
|
+
old Python without importing anything that might not exist.
|
|
6
|
+
|
|
7
|
+
Routing:
|
|
8
|
+
quor hook claude → hook path (no rich, stdout must stay valid JSON)
|
|
9
|
+
quor git status → dispatcher (run command, apply filter, print output)
|
|
10
|
+
quor schema / init / … → CLI path via typer
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
# CLI subcommands defined by Phase 7. Anything NOT in this set and NOT starting
|
|
17
|
+
# with "-" is treated as a dispatch target (e.g. "quor git status").
|
|
18
|
+
_CLI_COMMANDS: frozenset[str] = frozenset(
|
|
19
|
+
{"schema", "hook", "init", "validate", "explain", "gain", "verify", "doctor"}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _check_python_version() -> None:
|
|
24
|
+
# Runtime guard: sys.executable may differ from the installer Python, so
|
|
25
|
+
# the requires-python constraint (enforced only at install time) is not
|
|
26
|
+
# sufficient. This must run before any other import.
|
|
27
|
+
if sys.version_info < (3, 11): # noqa: UP036
|
|
28
|
+
print(
|
|
29
|
+
f"Quor requires Python 3.11 or higher. "
|
|
30
|
+
f"You are running {sys.version_info.major}.{sys.version_info.minor}. "
|
|
31
|
+
f"Please upgrade: https://python.org/downloads/",
|
|
32
|
+
file=sys.stderr,
|
|
33
|
+
)
|
|
34
|
+
sys.exit(5) # ExitCode.DEPENDENCY_MISSING
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_check_python_version()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_hook() -> None:
|
|
41
|
+
# Read stdin immediately so it is available in the except branch.
|
|
42
|
+
original_bytes = sys.stdin.buffer.read()
|
|
43
|
+
try:
|
|
44
|
+
adapter = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
45
|
+
if adapter != "claude":
|
|
46
|
+
sys.stdout.buffer.write(original_bytes)
|
|
47
|
+
print(f"[quor] Unknown hook adapter: {adapter!r}", file=sys.stderr)
|
|
48
|
+
return
|
|
49
|
+
import io
|
|
50
|
+
|
|
51
|
+
sys.stdin = io.TextIOWrapper(io.BytesIO(original_bytes), encoding="utf-8")
|
|
52
|
+
from quor.adapters.claude import run_hook
|
|
53
|
+
|
|
54
|
+
run_hook()
|
|
55
|
+
except Exception as exc: # noqa: BLE001 — hook must never raise
|
|
56
|
+
sys.stdout.buffer.write(original_bytes)
|
|
57
|
+
print(f"[quor] Hook error — returning original: {exc}", file=sys.stderr)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ensure_utf8_stdio() -> None:
|
|
61
|
+
# Windows consoles default text-mode stdout/stderr to the system codepage
|
|
62
|
+
# (often cp1252), which cannot encode the ✓/✗ glyphs used throughout the
|
|
63
|
+
# CLI and dispatch output. Reconfigure to UTF-8; harmless if already UTF-8
|
|
64
|
+
# or if the stream (e.g. a test runner's capture buffer) doesn't support it.
|
|
65
|
+
for stream in (sys.stdout, sys.stderr):
|
|
66
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
67
|
+
if reconfigure is not None:
|
|
68
|
+
with contextlib.suppress(ValueError, OSError):
|
|
69
|
+
reconfigure(encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _run_dispatch(args: list[str]) -> None:
|
|
73
|
+
from quor.adapters.dispatcher import run_dispatch
|
|
74
|
+
from quor.tracking.db import get_tracking_db
|
|
75
|
+
|
|
76
|
+
tracking = get_tracking_db()
|
|
77
|
+
try:
|
|
78
|
+
exit_code = run_dispatch(args, tracking=tracking)
|
|
79
|
+
finally:
|
|
80
|
+
tracking.close()
|
|
81
|
+
sys.exit(exit_code)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> None:
|
|
85
|
+
# Keep hook branch first: must never touch rich or typer.
|
|
86
|
+
if len(sys.argv) >= 2 and sys.argv[1] == "hook":
|
|
87
|
+
_run_hook()
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
_ensure_utf8_stdio()
|
|
91
|
+
|
|
92
|
+
# Dispatch branch: "quor git status" → run git status through filter
|
|
93
|
+
first_arg = sys.argv[1] if len(sys.argv) >= 2 else ""
|
|
94
|
+
if first_arg and first_arg not in _CLI_COMMANDS and not first_arg.startswith("-"):
|
|
95
|
+
_run_dispatch(sys.argv[1:])
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
from quor.cli.main import app
|
|
99
|
+
|
|
100
|
+
app()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
|
File without changes
|
quor/adapters/base.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Base types shared by all hook adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolInput(BaseModel):
|
|
11
|
+
"""The tool_input object inside a Claude Code PreToolUse hook payload."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="allow", frozen=True)
|
|
14
|
+
command: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HookInput(BaseModel):
|
|
18
|
+
"""Full Claude Code PreToolUse hook stdin payload."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(extra="allow", frozen=True)
|
|
21
|
+
tool_name: str = ""
|
|
22
|
+
tool_input: ToolInput
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HookOutput(BaseModel):
|
|
26
|
+
"""Hook stdout payload (same shape as HookInput, command possibly rewritten)."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="allow", frozen=True)
|
|
29
|
+
tool_name: str = ""
|
|
30
|
+
tool_input: ToolInput
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class HookAdapter(Protocol):
|
|
35
|
+
"""Protocol for hook adapters. Each adapter handles one AI tool's hook format."""
|
|
36
|
+
|
|
37
|
+
def run_hook(self) -> None:
|
|
38
|
+
"""Read JSON from sys.stdin, write (possibly modified) JSON to sys.stdout."""
|
|
39
|
+
...
|
quor/adapters/claude.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Claude Code hook adapter.
|
|
2
|
+
|
|
3
|
+
Reads a Claude Code PreToolUse JSON payload from sys.stdin, rewrites the
|
|
4
|
+
`tool_input.command` field if Quor has a filter for it, and writes the
|
|
5
|
+
(possibly modified) JSON to sys.stdout.
|
|
6
|
+
|
|
7
|
+
Called by __main__._run_hook() which guarantees:
|
|
8
|
+
- sys.stdin is a TextIOWrapper over the original stdin bytes
|
|
9
|
+
- Any exception raised here is caught by __main__ and returns original bytes
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import orjson
|
|
18
|
+
|
|
19
|
+
from quor.adapters.base import HookInput
|
|
20
|
+
from quor.rewrite.classifier import rewrite_command
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Hook script template — written by `quor init --claude` (Phase 7)
|
|
24
|
+
# Placeholders: {python} is replaced with sys.executable at install time.
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
HOOK_COMMAND = "{python} -m quor hook claude"
|
|
28
|
+
|
|
29
|
+
HOOK_PS1_TEMPLATE = """\
|
|
30
|
+
# Quor hook script — generated by `quor init --claude`
|
|
31
|
+
# Do not edit this file. To update, run `quor init --claude` again.
|
|
32
|
+
$ErrorActionPreference = 'Stop'
|
|
33
|
+
$json = [Console]::In.ReadToEnd()
|
|
34
|
+
$json | & '{python}' -m quor hook claude
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_UTF8_BOM = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_hook() -> None:
|
|
41
|
+
"""Process one PreToolUse hook call.
|
|
42
|
+
|
|
43
|
+
Reads JSON from sys.stdin (already set up by __main__._run_hook).
|
|
44
|
+
Writes modified JSON to sys.stdout.
|
|
45
|
+
Raises on parse/validation errors — caller handles fail-open.
|
|
46
|
+
"""
|
|
47
|
+
raw = sys.stdin.read()
|
|
48
|
+
|
|
49
|
+
# Strip UTF-8 BOM (single or doubled — Cursor sends doubled BOM on Windows)
|
|
50
|
+
raw = raw.lstrip(_UTF8_BOM)
|
|
51
|
+
|
|
52
|
+
# Parse full payload as a dict so extra fields are preserved verbatim
|
|
53
|
+
data: dict[str, Any] = orjson.loads(raw)
|
|
54
|
+
|
|
55
|
+
# Validate the fields we care about (raises ValidationError on bad shape)
|
|
56
|
+
hook_input = HookInput.model_validate(data)
|
|
57
|
+
original_cmd = hook_input.tool_input.command
|
|
58
|
+
|
|
59
|
+
rewritten = rewrite_command(original_cmd)
|
|
60
|
+
if rewritten is not None and rewritten != original_cmd:
|
|
61
|
+
# Modify the dict in-place to preserve every extra field
|
|
62
|
+
data["tool_input"]["command"] = rewritten
|
|
63
|
+
|
|
64
|
+
sys.stdout.buffer.write(orjson.dumps(data))
|
|
65
|
+
sys.stdout.buffer.flush()
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Hook dispatcher: run the real command and apply content filtering.
|
|
2
|
+
|
|
3
|
+
Called when Claude Code executes the rewritten command `quor git status`.
|
|
4
|
+
sys.argv[1:] = ["git", "status"] → subprocess runs git status, output filtered.
|
|
5
|
+
|
|
6
|
+
Execution order:
|
|
7
|
+
subprocess → PRE_FILTER plugins → ContentMask filter → POST_FILTER plugins → stdout
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
import warnings
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from quor.filters.registry import FilterRegistry
|
|
20
|
+
from quor.pipeline.content_type import detect
|
|
21
|
+
from quor.tracking.db import InvocationRecord, TrackingDB, count_tokens
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_dispatch(args: list[str], tracking: TrackingDB | None = None) -> int:
|
|
25
|
+
"""Run `args` as a subprocess, apply filter and plugin pipeline, write to stdout.
|
|
26
|
+
|
|
27
|
+
Returns the subprocess exit code. Never raises — any error falls through
|
|
28
|
+
to unfiltered output (fail-open). If `tracking` is provided, records the
|
|
29
|
+
invocation.
|
|
30
|
+
|
|
31
|
+
Plugin pipeline (fail-open at each step):
|
|
32
|
+
PRE_FILTER — before ContentMask; plugins may annotate or modify raw output
|
|
33
|
+
ContentMask — built-in TOML-configured compression stages
|
|
34
|
+
POST_FILTER — after ContentMask; plugins may observe or transform final output
|
|
35
|
+
"""
|
|
36
|
+
if not args:
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
cmd_str = " ".join(args)
|
|
40
|
+
t0 = time.monotonic()
|
|
41
|
+
|
|
42
|
+
# --- Run the real command ---
|
|
43
|
+
try:
|
|
44
|
+
proc = subprocess.run(
|
|
45
|
+
args,
|
|
46
|
+
stdout=subprocess.PIPE,
|
|
47
|
+
stderr=None, # inherit: real stderr flows to user directly
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
errors="replace", # graceful handling of non-UTF-8 output
|
|
50
|
+
timeout=25, # 25 s leaves room for filter + plugins within 30 s hook budget
|
|
51
|
+
)
|
|
52
|
+
except subprocess.TimeoutExpired:
|
|
53
|
+
print(f"[quor] command {args[0]!r} timed out after 25 s", file=sys.stderr)
|
|
54
|
+
return 124
|
|
55
|
+
except (OSError, FileNotFoundError) as exc:
|
|
56
|
+
print(f"[quor] cannot run {args[0]!r}: {exc}", file=sys.stderr)
|
|
57
|
+
return 127
|
|
58
|
+
|
|
59
|
+
captured = proc.stdout or ""
|
|
60
|
+
|
|
61
|
+
# --- Lookup filter ---
|
|
62
|
+
filter_config = None
|
|
63
|
+
registry: FilterRegistry | None = None
|
|
64
|
+
try:
|
|
65
|
+
registry = FilterRegistry(project_root=Path.cwd())
|
|
66
|
+
filter_config = registry.find(cmd_str)
|
|
67
|
+
except Exception as exc: # noqa: BLE001
|
|
68
|
+
warnings.warn(f"[quor] filter registry error: {exc}", stacklevel=1)
|
|
69
|
+
|
|
70
|
+
# --- Setup plugin pipeline (fail-open; empty registry = no-op) ---
|
|
71
|
+
from quor.plugins.base import ExecutionMode, PluginCategory, PluginContext, PluginPayload
|
|
72
|
+
from quor.plugins.registry import PluginRegistry
|
|
73
|
+
|
|
74
|
+
plugin_registry = PluginRegistry()
|
|
75
|
+
plugin_ctx: PluginContext | None = None
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
from quor.config.loader import load_user_config
|
|
79
|
+
from quor.pipeline.plugin_loader import discover_plugins
|
|
80
|
+
|
|
81
|
+
discover_plugins(plugin_registry, use_cache=True, tier="user")
|
|
82
|
+
if plugin_registry.all_plugins():
|
|
83
|
+
mode_str = load_user_config().mode
|
|
84
|
+
try:
|
|
85
|
+
mode = ExecutionMode(mode_str)
|
|
86
|
+
except ValueError:
|
|
87
|
+
mode = ExecutionMode.OPTIMIZE
|
|
88
|
+
|
|
89
|
+
plugin_ctx = PluginContext(
|
|
90
|
+
project_root=Path.cwd(),
|
|
91
|
+
mode=mode,
|
|
92
|
+
session_id="",
|
|
93
|
+
invocation_id=uuid.uuid4().hex,
|
|
94
|
+
)
|
|
95
|
+
plugin_registry.initialize_all(plugin_ctx)
|
|
96
|
+
except Exception as exc: # noqa: BLE001
|
|
97
|
+
warnings.warn(f"[quor] plugin discovery error: {exc}", stacklevel=1)
|
|
98
|
+
plugin_ctx = None
|
|
99
|
+
|
|
100
|
+
# --- PRE_FILTER plugins ---
|
|
101
|
+
pre_output = captured
|
|
102
|
+
if plugin_ctx is not None:
|
|
103
|
+
try:
|
|
104
|
+
pre_payload = PluginPayload(
|
|
105
|
+
command=cmd_str,
|
|
106
|
+
raw_output=captured,
|
|
107
|
+
current_output=captured,
|
|
108
|
+
content_type=detect(captured).value,
|
|
109
|
+
)
|
|
110
|
+
pre_payload = plugin_registry.run_category(
|
|
111
|
+
PluginCategory.PRE_FILTER, pre_payload, plugin_ctx
|
|
112
|
+
)
|
|
113
|
+
pre_output = pre_payload.current_output
|
|
114
|
+
except Exception as exc: # noqa: BLE001
|
|
115
|
+
warnings.warn(f"[quor] PRE_FILTER plugin error: {exc}", stacklevel=1)
|
|
116
|
+
|
|
117
|
+
# --- Passthrough when no filter matches ---
|
|
118
|
+
if filter_config is None or registry is None:
|
|
119
|
+
_teardown_plugins(plugin_registry, plugin_ctx)
|
|
120
|
+
_track(
|
|
121
|
+
tracking,
|
|
122
|
+
cmd_str=cmd_str,
|
|
123
|
+
original=captured,
|
|
124
|
+
filtered=pre_output,
|
|
125
|
+
filter_name=None,
|
|
126
|
+
was_passthrough=True,
|
|
127
|
+
t0=t0,
|
|
128
|
+
)
|
|
129
|
+
sys.stdout.write(pre_output)
|
|
130
|
+
sys.stdout.flush()
|
|
131
|
+
return proc.returncode
|
|
132
|
+
|
|
133
|
+
# --- Apply ContentMask filter ---
|
|
134
|
+
content_type = detect(pre_output).value
|
|
135
|
+
try:
|
|
136
|
+
filtered = registry.apply(filter_config, pre_output, content_type=content_type)
|
|
137
|
+
except Exception as exc: # noqa: BLE001
|
|
138
|
+
warnings.warn(f"[quor] filter apply error: {exc}", stacklevel=1)
|
|
139
|
+
filtered = pre_output
|
|
140
|
+
|
|
141
|
+
# --- POST_FILTER plugins ---
|
|
142
|
+
if plugin_ctx is not None:
|
|
143
|
+
try:
|
|
144
|
+
post_payload = PluginPayload(
|
|
145
|
+
command=cmd_str,
|
|
146
|
+
raw_output=captured,
|
|
147
|
+
current_output=filtered,
|
|
148
|
+
content_type=content_type,
|
|
149
|
+
)
|
|
150
|
+
post_payload = plugin_registry.run_category(
|
|
151
|
+
PluginCategory.POST_FILTER, post_payload, plugin_ctx
|
|
152
|
+
)
|
|
153
|
+
filtered = post_payload.current_output
|
|
154
|
+
except Exception as exc: # noqa: BLE001
|
|
155
|
+
warnings.warn(f"[quor] POST_FILTER plugin error: {exc}", stacklevel=1)
|
|
156
|
+
|
|
157
|
+
_teardown_plugins(plugin_registry, plugin_ctx)
|
|
158
|
+
_track(
|
|
159
|
+
tracking,
|
|
160
|
+
cmd_str=cmd_str,
|
|
161
|
+
original=captured,
|
|
162
|
+
filtered=filtered,
|
|
163
|
+
filter_name=filter_config.name,
|
|
164
|
+
was_passthrough=False,
|
|
165
|
+
t0=t0,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
sys.stdout.write(filtered)
|
|
169
|
+
sys.stdout.flush()
|
|
170
|
+
return proc.returncode
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _teardown_plugins(
|
|
174
|
+
plugin_registry: object, plugin_ctx: object | None
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Shutdown all plugins if they were initialized. Fail-open."""
|
|
177
|
+
if plugin_ctx is None:
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
from quor.plugins.registry import PluginRegistry as _PR
|
|
181
|
+
|
|
182
|
+
if isinstance(plugin_registry, _PR):
|
|
183
|
+
plugin_registry.shutdown_all()
|
|
184
|
+
except Exception as exc: # noqa: BLE001
|
|
185
|
+
warnings.warn(f"[quor] plugin shutdown error: {exc}", stacklevel=1)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _track(
|
|
189
|
+
tracking: TrackingDB | None,
|
|
190
|
+
*,
|
|
191
|
+
cmd_str: str,
|
|
192
|
+
original: str,
|
|
193
|
+
filtered: str,
|
|
194
|
+
filter_name: str | None,
|
|
195
|
+
was_passthrough: bool,
|
|
196
|
+
t0: float,
|
|
197
|
+
) -> None:
|
|
198
|
+
if tracking is None:
|
|
199
|
+
return
|
|
200
|
+
try:
|
|
201
|
+
rec = InvocationRecord(
|
|
202
|
+
command=cmd_str,
|
|
203
|
+
project_path=Path.cwd().as_posix(),
|
|
204
|
+
original_tokens=count_tokens(original),
|
|
205
|
+
final_tokens=count_tokens(filtered),
|
|
206
|
+
filter_name=filter_name,
|
|
207
|
+
was_passthrough=was_passthrough,
|
|
208
|
+
duration_ms=(time.monotonic() - t0) * 1000,
|
|
209
|
+
)
|
|
210
|
+
tracking.record(rec)
|
|
211
|
+
except Exception as exc: # noqa: BLE001
|
|
212
|
+
warnings.warn(f"[quor] tracking record error: {exc}", stacklevel=1)
|
quor/cli/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""quor doctor — health check: dependencies, hook, tracking DB, filters, mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import io
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import platformdirs
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from quor.config.loader import load_user_config
|
|
15
|
+
from quor.errors import ExitCode
|
|
16
|
+
from quor.filters.registry import FilterRegistry
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
_REQUIRED_PACKAGES = ("typer", "pydantic", "orjson", "platformdirs", "regex", "rich")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _FakeStdout:
|
|
24
|
+
"""Stand-in for sys.stdout whose .buffer is writable (Python 3.14 makes the real one read-only)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self.buffer = io.BytesIO()
|
|
28
|
+
|
|
29
|
+
def write(self, s: str) -> int:
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
def flush(self) -> None:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def doctor(
|
|
37
|
+
settings_path: Path | None = typer.Option(
|
|
38
|
+
None, "--settings-path", hidden=True, help="Override the Claude settings.json path (for testing)."
|
|
39
|
+
),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Run health checks and print a summary with colored status indicators."""
|
|
42
|
+
checks: list[tuple[str, bool, str]] = []
|
|
43
|
+
|
|
44
|
+
checks.append(_check_python_version())
|
|
45
|
+
checks.extend(_check_dependencies())
|
|
46
|
+
checks.append(_check_hook_script())
|
|
47
|
+
checks.append(_check_hook_roundtrip())
|
|
48
|
+
checks.append(_check_hook_collision(settings_path))
|
|
49
|
+
checks.append(_check_sqlite())
|
|
50
|
+
checks.append(_check_filters())
|
|
51
|
+
checks.append(_check_mode())
|
|
52
|
+
checks.append(_check_plugins())
|
|
53
|
+
|
|
54
|
+
all_ok = True
|
|
55
|
+
for name, ok, detail in checks:
|
|
56
|
+
symbol = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
57
|
+
suffix = f" — {detail}" if detail else ""
|
|
58
|
+
console.print(f"{symbol} {name}{suffix}")
|
|
59
|
+
all_ok = all_ok and ok
|
|
60
|
+
|
|
61
|
+
if not all_ok:
|
|
62
|
+
raise typer.Exit(code=ExitCode.GENERAL_ERROR)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check_python_version() -> tuple[str, bool, str]:
|
|
66
|
+
ok = sys.version_info >= (3, 11)
|
|
67
|
+
return ("Python ≥ 3.11", ok, f"{sys.version_info.major}.{sys.version_info.minor}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _check_dependencies() -> list[tuple[str, bool, str]]:
|
|
71
|
+
results: list[tuple[str, bool, str]] = []
|
|
72
|
+
for pkg in _REQUIRED_PACKAGES:
|
|
73
|
+
try:
|
|
74
|
+
importlib.import_module(pkg)
|
|
75
|
+
results.append((f"Dependency '{pkg}'", True, ""))
|
|
76
|
+
except ImportError as exc:
|
|
77
|
+
results.append((f"Dependency '{pkg}'", False, str(exc)))
|
|
78
|
+
return results
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_hook_script() -> tuple[str, bool, str]:
|
|
82
|
+
hook_path = Path(platformdirs.user_data_dir("quor")) / "hooks" / "claude-hook.ps1"
|
|
83
|
+
exists = hook_path.exists()
|
|
84
|
+
detail = str(hook_path) if exists else f"not found at {hook_path} — run `quor init --claude`"
|
|
85
|
+
return ("Hook script installed", exists, detail)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_hook_collision(settings_path: Path | None = None) -> tuple[str, bool, str]:
|
|
89
|
+
"""Warn if another tool's PreToolUse Bash hook is registered alongside Quor's."""
|
|
90
|
+
from quor.cli.commands.init import _find_conflicting_hooks, _read_settings
|
|
91
|
+
from quor.errors import ConfigError
|
|
92
|
+
|
|
93
|
+
settings_file = settings_path or (Path.home() / ".claude" / "settings.json")
|
|
94
|
+
if not settings_file.exists():
|
|
95
|
+
return ("No conflicting PreToolUse hooks", True, "")
|
|
96
|
+
try:
|
|
97
|
+
settings = _read_settings(settings_file)
|
|
98
|
+
conflicts = _find_conflicting_hooks(settings)
|
|
99
|
+
if conflicts:
|
|
100
|
+
detail = (
|
|
101
|
+
f"{len(conflicts)} other Bash hook(s) detected — "
|
|
102
|
+
"double-rewriting risk; run `quor init --claude` to review"
|
|
103
|
+
)
|
|
104
|
+
return ("No conflicting PreToolUse hooks", False, detail)
|
|
105
|
+
return ("No conflicting PreToolUse hooks", True, "")
|
|
106
|
+
except ConfigError as exc:
|
|
107
|
+
return ("No conflicting PreToolUse hooks", True, f"(could not check: {exc})")
|
|
108
|
+
except Exception: # noqa: BLE001 — settings file may not exist or be parseable
|
|
109
|
+
return ("No conflicting PreToolUse hooks", True, "")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _check_hook_roundtrip() -> tuple[str, bool, str]:
|
|
113
|
+
import orjson
|
|
114
|
+
|
|
115
|
+
from quor.adapters.claude import run_hook
|
|
116
|
+
|
|
117
|
+
payload = orjson.dumps({"tool_name": "Bash", "tool_input": {"command": "git status"}})
|
|
118
|
+
old_stdin, old_stdout = sys.stdin, sys.stdout
|
|
119
|
+
try:
|
|
120
|
+
sys.stdin = io.TextIOWrapper(io.BytesIO(payload), encoding="utf-8")
|
|
121
|
+
fake_stdout = _FakeStdout()
|
|
122
|
+
sys.stdout = fake_stdout
|
|
123
|
+
run_hook()
|
|
124
|
+
result = orjson.loads(fake_stdout.buffer.getvalue())
|
|
125
|
+
rewritten = result.get("tool_input", {}).get("command", "")
|
|
126
|
+
if rewritten == "quor git status":
|
|
127
|
+
return ("Hook responds correctly", True, "")
|
|
128
|
+
return ("Hook responds correctly", False, f"unexpected rewrite: {rewritten!r}")
|
|
129
|
+
except Exception as exc: # noqa: BLE001
|
|
130
|
+
return ("Hook responds correctly", False, str(exc))
|
|
131
|
+
finally:
|
|
132
|
+
sys.stdin = old_stdin
|
|
133
|
+
sys.stdout = old_stdout
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _check_sqlite() -> tuple[str, bool, str]:
|
|
137
|
+
from quor.tracking.db import get_tracking_db
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
db = get_tracking_db()
|
|
141
|
+
db.flush()
|
|
142
|
+
db.close()
|
|
143
|
+
return ("Tracking DB readable/writable", True, "")
|
|
144
|
+
except Exception as exc: # noqa: BLE001
|
|
145
|
+
return ("Tracking DB readable/writable", False, str(exc))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _check_filters() -> tuple[str, bool, str]:
|
|
149
|
+
registry = FilterRegistry(project_root=Path.cwd())
|
|
150
|
+
total_failures = 0
|
|
151
|
+
for _, filter_config in registry.all_filters():
|
|
152
|
+
total_failures += len(registry.run_tests(filter_config))
|
|
153
|
+
if total_failures:
|
|
154
|
+
return ("Built-in filter tests pass", False, f"{total_failures} inline test failure(s)")
|
|
155
|
+
return ("Built-in filter tests pass", True, "")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_mode() -> tuple[str, bool, str]:
|
|
159
|
+
mode = load_user_config().mode
|
|
160
|
+
return (f"Mode: {mode}", True, "")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check_plugins() -> tuple[str, bool, str]:
|
|
164
|
+
"""Report discovered third-party stages and plugins; flag any load failures."""
|
|
165
|
+
from quor.pipeline.plugin_loader import get_load_report
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
report = get_load_report(use_cache=False)
|
|
169
|
+
except Exception as exc: # noqa: BLE001
|
|
170
|
+
return ("Plugin discovery", True, f"(could not check: {exc})")
|
|
171
|
+
|
|
172
|
+
if report.is_empty:
|
|
173
|
+
return ("Plugin discovery", True, "no third-party plugins installed")
|
|
174
|
+
|
|
175
|
+
if report.failures:
|
|
176
|
+
names = ", ".join(f.entry_point_name for f in report.failures)
|
|
177
|
+
return (
|
|
178
|
+
"Plugin discovery",
|
|
179
|
+
False,
|
|
180
|
+
f"{len(report.failures)} load failure(s): {names}",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
parts: list[str] = []
|
|
184
|
+
if report.stages:
|
|
185
|
+
stage_names = ", ".join(s.stage_type for s in report.stages)
|
|
186
|
+
parts.append(f"{len(report.stages)} stage(s): {stage_names}")
|
|
187
|
+
if report.plugins:
|
|
188
|
+
plugin_names = ", ".join(f"{p.plugin_id}@{p.version}" for p in report.plugins)
|
|
189
|
+
parts.append(f"{len(report.plugins)} plugin(s): {plugin_names}")
|
|
190
|
+
return ("Plugin discovery", True, "; ".join(parts))
|