rylox 0.0.1__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.
- rylox/__init__.py +3 -0
- rylox/cache.py +151 -0
- rylox/chunking.py +216 -0
- rylox/cli.py +119 -0
- rylox/config.py +259 -0
- rylox/embedding.py +83 -0
- rylox/errors.py +50 -0
- rylox/indexer.py +117 -0
- rylox-0.0.1.dist-info/METADATA +156 -0
- rylox-0.0.1.dist-info/RECORD +13 -0
- rylox-0.0.1.dist-info/WHEEL +4 -0
- rylox-0.0.1.dist-info/entry_points.txt +2 -0
- rylox-0.0.1.dist-info/licenses/LICENSE +201 -0
rylox/__init__.py
ADDED
rylox/cache.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from rylox.chunking import Chunk
|
|
11
|
+
from rylox.errors import IndexCorruptError, IndexNotFoundError
|
|
12
|
+
|
|
13
|
+
CACHE_DIRNAME = ".rylox"
|
|
14
|
+
INDEX_FILENAME = "index.json"
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CachedChunk:
|
|
20
|
+
path: str
|
|
21
|
+
kind: str
|
|
22
|
+
name: str
|
|
23
|
+
start_line: int
|
|
24
|
+
end_line: int
|
|
25
|
+
parent_class: Optional[str]
|
|
26
|
+
docstring: Optional[str]
|
|
27
|
+
content: str
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_chunk(cls, chunk: Chunk) -> CachedChunk:
|
|
31
|
+
return cls(
|
|
32
|
+
path=chunk.path.as_posix(),
|
|
33
|
+
kind=chunk.kind,
|
|
34
|
+
name=chunk.name,
|
|
35
|
+
start_line=chunk.start_line,
|
|
36
|
+
end_line=chunk.end_line,
|
|
37
|
+
parent_class=chunk.parent_class,
|
|
38
|
+
docstring=chunk.docstring,
|
|
39
|
+
content=chunk.content,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def to_chunk(self) -> Chunk:
|
|
43
|
+
return Chunk(
|
|
44
|
+
path=Path(self.path),
|
|
45
|
+
kind=self.kind, # type: ignore[arg-type]
|
|
46
|
+
name=self.name,
|
|
47
|
+
start_line=self.start_line,
|
|
48
|
+
end_line=self.end_line,
|
|
49
|
+
parent_class=self.parent_class,
|
|
50
|
+
docstring=self.docstring,
|
|
51
|
+
content=self.content,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class FileEntry:
|
|
57
|
+
hash: str
|
|
58
|
+
chunks: list[CachedChunk] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class IndexManifest:
|
|
63
|
+
schema_version: int = SCHEMA_VERSION
|
|
64
|
+
files: dict[str, FileEntry] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cache_dir(repo: Path) -> Path:
|
|
68
|
+
return repo / CACHE_DIRNAME
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def index_path(repo: Path) -> Path:
|
|
72
|
+
return cache_dir(repo) / INDEX_FILENAME
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ensure_cache_dir(repo: Path) -> Path:
|
|
76
|
+
directory = cache_dir(repo)
|
|
77
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
gitignore = directory / ".gitignore"
|
|
79
|
+
if not gitignore.exists():
|
|
80
|
+
gitignore.write_text("*\n", encoding="utf-8")
|
|
81
|
+
return directory
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_index(repo: Path, manifest: IndexManifest) -> None:
|
|
85
|
+
ensure_cache_dir(repo)
|
|
86
|
+
target = index_path(repo)
|
|
87
|
+
payload = _serialize(manifest)
|
|
88
|
+
|
|
89
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=".index-", suffix=".tmp")
|
|
90
|
+
try:
|
|
91
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
92
|
+
json.dump(payload, f, indent=2)
|
|
93
|
+
os.replace(tmp_name, target)
|
|
94
|
+
except Exception:
|
|
95
|
+
if os.path.exists(tmp_name):
|
|
96
|
+
os.remove(tmp_name)
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_index(repo: Path) -> IndexManifest:
|
|
101
|
+
target = index_path(repo)
|
|
102
|
+
if not target.exists():
|
|
103
|
+
raise IndexNotFoundError(
|
|
104
|
+
f"no index found at {target}. Run `rylox index` first."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
raw = json.loads(target.read_text(encoding="utf-8"))
|
|
109
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
110
|
+
raise IndexCorruptError(f"{target} is unreadable or corrupt: {exc}") from exc
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return _deserialize(raw)
|
|
114
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
115
|
+
raise IndexCorruptError(f"{target} has an unexpected structure: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_or_empty(repo: Path) -> IndexManifest:
|
|
119
|
+
try:
|
|
120
|
+
return load_index(repo)
|
|
121
|
+
except IndexNotFoundError:
|
|
122
|
+
return IndexManifest()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _serialize(manifest: IndexManifest) -> dict[str, Any]:
|
|
126
|
+
return {
|
|
127
|
+
"schema_version": manifest.schema_version,
|
|
128
|
+
"files": {
|
|
129
|
+
relpath: {
|
|
130
|
+
"hash": entry.hash,
|
|
131
|
+
"chunks": [asdict(c) for c in entry.chunks],
|
|
132
|
+
}
|
|
133
|
+
for relpath, entry in manifest.files.items()
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _deserialize(raw: dict[str, Any]) -> IndexManifest:
|
|
139
|
+
schema_version = raw["schema_version"]
|
|
140
|
+
if schema_version != SCHEMA_VERSION:
|
|
141
|
+
raise IndexCorruptError(
|
|
142
|
+
f"index schema_version {schema_version} is not supported "
|
|
143
|
+
f"(expected {SCHEMA_VERSION}); run `rylox clean` and re-index."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
files: dict[str, FileEntry] = {}
|
|
147
|
+
for relpath, entry_raw in raw["files"].items():
|
|
148
|
+
chunks = [CachedChunk(**c) for c in entry_raw["chunks"]]
|
|
149
|
+
files[relpath] = FileEntry(hash=entry_raw["hash"], chunks=chunks)
|
|
150
|
+
|
|
151
|
+
return IndexManifest(schema_version=schema_version, files=files)
|
rylox/chunking.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Python chunking via tree-sitter (spec §2).
|
|
2
|
+
|
|
3
|
+
Scope for this module, deliberately narrow:
|
|
4
|
+
- integrate tree-sitter-python and parse .py source into an AST
|
|
5
|
+
- chunk at function / method / class granularity (never file, never line)
|
|
6
|
+
- store per-chunk metadata: file path, line range, parent class, docstring,
|
|
7
|
+
source text
|
|
8
|
+
- skip a file tree-sitter can't parse without crashing the whole run
|
|
9
|
+
|
|
10
|
+
Explicitly NOT in this module yet (later phases): content hashing for
|
|
11
|
+
incremental re-index, the `.rylox/` cache, and CLI wiring for `rylox index`.
|
|
12
|
+
`parse_source` and `parse_file` below are self-contained and testable without
|
|
13
|
+
any of that machinery existing yet.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Literal, Optional
|
|
21
|
+
|
|
22
|
+
import tree_sitter_python as tspython
|
|
23
|
+
from tree_sitter import Language, Node, Parser
|
|
24
|
+
|
|
25
|
+
_PY_LANGUAGE = Language(tspython.language(), "python")
|
|
26
|
+
|
|
27
|
+
# One parser instance, reused across calls. tree_sitter.Parser holds no
|
|
28
|
+
# per-parse mutable state worth avoiding reuse over, so constructing a new
|
|
29
|
+
# one on every parse_source() call was pure overhead.
|
|
30
|
+
_PARSER = Parser()
|
|
31
|
+
_PARSER.set_language(_PY_LANGUAGE)
|
|
32
|
+
|
|
33
|
+
ChunkKind = Literal["function", "method", "class"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Chunk:
|
|
38
|
+
"""One retrievable unit of code, per spec §2's chunk metadata list."""
|
|
39
|
+
|
|
40
|
+
path: Path
|
|
41
|
+
kind: ChunkKind
|
|
42
|
+
name: str
|
|
43
|
+
start_line: int # 1-indexed, inclusive. Includes decorator lines, if any.
|
|
44
|
+
end_line: int # 1-indexed, inclusive
|
|
45
|
+
parent_class: Optional[str]
|
|
46
|
+
docstring: Optional[str]
|
|
47
|
+
content: str # exact source text of the chunk, decorators included
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ParseResult:
|
|
52
|
+
"""Outcome of parsing one file: either chunks, or a recorded error.
|
|
53
|
+
|
|
54
|
+
`error` being set means the file was skipped, not that the whole
|
|
55
|
+
operation failed (spec §12: malformed files are logged and skipped,
|
|
56
|
+
never fatal for the whole `index` run). Callers should check `error`
|
|
57
|
+
and continue on to the next file rather than propagating an exception.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
chunks: list[Chunk]
|
|
61
|
+
error: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_source(source: str, path: Path) -> list[Chunk]:
|
|
65
|
+
"""Parse Python source text into function/method/class chunks.
|
|
66
|
+
|
|
67
|
+
Pure function, no filesystem access — this is what makes chunking
|
|
68
|
+
behavior directly unit-testable against hand-written source strings,
|
|
69
|
+
independent of `parse_file`'s I/O and error handling.
|
|
70
|
+
"""
|
|
71
|
+
tree = _PARSER.parse(source.encode("utf-8"))
|
|
72
|
+
lines = source.splitlines()
|
|
73
|
+
chunks: list[Chunk] = []
|
|
74
|
+
_walk(tree.root_node, path, lines, parent_class=None, chunks=chunks)
|
|
75
|
+
return chunks
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_file(path: Path) -> ParseResult:
|
|
79
|
+
"""Read and parse a single .py file. Never raises — malformed or
|
|
80
|
+
unreadable files come back as a ParseResult with `error` set and an
|
|
81
|
+
empty chunk list, per spec §12.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
source = path.read_text(encoding="utf-8-sig")
|
|
85
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
86
|
+
return ParseResult(chunks=[], error=f"{path}: could not read file ({exc})")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
chunks = parse_source(source, path)
|
|
90
|
+
except Exception as exc: # noqa: BLE001 — deliberately broad: any parser
|
|
91
|
+
# failure must degrade to "skip this file", never crash the whole
|
|
92
|
+
# index run (spec §12). Narrowing this to tree_sitter's specific
|
|
93
|
+
# exception types would risk missing a case and defeating the point.
|
|
94
|
+
return ParseResult(chunks=[], error=f"{path}: failed to parse ({exc})")
|
|
95
|
+
|
|
96
|
+
return ParseResult(chunks=chunks, error=None)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _walk(
|
|
100
|
+
node: Node,
|
|
101
|
+
path: Path,
|
|
102
|
+
lines: list[str],
|
|
103
|
+
*,
|
|
104
|
+
parent_class: Optional[str],
|
|
105
|
+
chunks: list[Chunk],
|
|
106
|
+
) -> None:
|
|
107
|
+
for child in node.children:
|
|
108
|
+
# Decorated definitions (@foo\ndef bar(): ...) wrap the real
|
|
109
|
+
# function_definition/class_definition in a decorated_definition
|
|
110
|
+
# node. Unwrap it, but keep the *outer* node's start point so the
|
|
111
|
+
# decorator line(s) are included in the chunk's span — the
|
|
112
|
+
# decorator is semantically part of the unit being chunked.
|
|
113
|
+
target = child
|
|
114
|
+
span_start = child
|
|
115
|
+
if child.type == "decorated_definition":
|
|
116
|
+
inner = child.child_by_field_name("definition")
|
|
117
|
+
if inner is None:
|
|
118
|
+
_walk(child, path, lines, parent_class=parent_class, chunks=chunks)
|
|
119
|
+
continue
|
|
120
|
+
target = inner
|
|
121
|
+
span_start = child
|
|
122
|
+
|
|
123
|
+
if target.type == "class_definition":
|
|
124
|
+
name = _node_name(target)
|
|
125
|
+
chunks.append(
|
|
126
|
+
Chunk(
|
|
127
|
+
path=path,
|
|
128
|
+
kind="class",
|
|
129
|
+
name=name,
|
|
130
|
+
start_line=span_start.start_point[0] + 1,
|
|
131
|
+
end_line=target.end_point[0] + 1,
|
|
132
|
+
parent_class=parent_class,
|
|
133
|
+
docstring=_docstring(target, lines),
|
|
134
|
+
content=_content(span_start, lines),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
# Recurse with this class as the new parent_class, so nested
|
|
138
|
+
# function_definitions/class_definitions inside it are chunked
|
|
139
|
+
# as methods/nested classes respectively.
|
|
140
|
+
_walk(target, path, lines, parent_class=name, chunks=chunks)
|
|
141
|
+
|
|
142
|
+
elif target.type == "function_definition":
|
|
143
|
+
name = _node_name(target)
|
|
144
|
+
chunks.append(
|
|
145
|
+
Chunk(
|
|
146
|
+
path=path,
|
|
147
|
+
kind="method" if parent_class is not None else "function",
|
|
148
|
+
name=name,
|
|
149
|
+
start_line=span_start.start_point[0] + 1,
|
|
150
|
+
end_line=target.end_point[0] + 1,
|
|
151
|
+
parent_class=parent_class,
|
|
152
|
+
docstring=_docstring(target, lines),
|
|
153
|
+
content=_content(span_start, lines),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
# Recurse without a parent_class: a function nested inside
|
|
157
|
+
# another function is chunked as its own top-level-style
|
|
158
|
+
# "function" (v0.1 doesn't track function-in-function nesting).
|
|
159
|
+
_walk(target, path, lines, parent_class=None, chunks=chunks)
|
|
160
|
+
|
|
161
|
+
else:
|
|
162
|
+
_walk(child, path, lines, parent_class=parent_class, chunks=chunks)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _content(span_start: Node, lines: list[str]) -> str:
|
|
166
|
+
"""Exact source text of the chunk's span (decorator lines included, if any).
|
|
167
|
+
|
|
168
|
+
Safe for both plain definitions and decorated_definition wrappers: a
|
|
169
|
+
decorator can only add lines *before* the def/class line, never after,
|
|
170
|
+
so span_start.end_point always matches the inner definition's end.
|
|
171
|
+
"""
|
|
172
|
+
start = span_start.start_point[0]
|
|
173
|
+
end = span_start.end_point[0]
|
|
174
|
+
return "\n".join(lines[start : end + 1])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _node_name(def_node: Node) -> str:
|
|
178
|
+
name_node = def_node.child_by_field_name("name")
|
|
179
|
+
if name_node is None:
|
|
180
|
+
return "<anonymous>"
|
|
181
|
+
return name_node.text.decode("utf-8")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _docstring(def_node: Node, lines: list[str]) -> Optional[str]:
|
|
185
|
+
"""Extract a def/class's own docstring: the first statement in its body,
|
|
186
|
+
if that statement is a bare string expression.
|
|
187
|
+
"""
|
|
188
|
+
body = def_node.child_by_field_name("body")
|
|
189
|
+
if body is None or body.child_count == 0:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
first_statement = body.children[0]
|
|
193
|
+
if first_statement.type != "expression_statement":
|
|
194
|
+
return None
|
|
195
|
+
if first_statement.child_count == 0:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
string_node = first_statement.children[0]
|
|
199
|
+
if string_node.type != "string":
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
raw = string_node.text.decode("utf-8")
|
|
203
|
+
return _strip_string_literal(raw)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _strip_string_literal(raw: str) -> str:
|
|
207
|
+
"""Strip Python string-literal quoting/prefixes to get the docstring body."""
|
|
208
|
+
text = raw.strip()
|
|
209
|
+
for prefix in ("u", "U", "r", "R", "b", "B", "rb", "Rb", "rB", "RB", "br", "Br", "bR", "BR"):
|
|
210
|
+
if text.startswith(prefix) and text[len(prefix) :].startswith(('"', "'")):
|
|
211
|
+
text = text[len(prefix) :]
|
|
212
|
+
break
|
|
213
|
+
for quote in ('"""', "'''", '"', "'"):
|
|
214
|
+
if text.startswith(quote) and text.endswith(quote) and len(text) >= 2 * len(quote):
|
|
215
|
+
return text[len(quote) : -len(quote)].strip()
|
|
216
|
+
return text.strip()
|
rylox/cli.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Rylox CLI — exactly four commands (spec §9). No fifth command in v0.1.
|
|
2
|
+
|
|
3
|
+
Phase 1 goal: lock in the *interface* (command names, options, exit codes,
|
|
4
|
+
error presentation) before any real logic exists. Every command below is a
|
|
5
|
+
stub that returns a clearly-labeled "not implemented" result rather than
|
|
6
|
+
doing nothing silently — that way `rylox --help` and each subcommand's
|
|
7
|
+
`--help` are already correct and testable, and later phases fill bodies in
|
|
8
|
+
without touching signatures.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from rylox import __version__
|
|
19
|
+
from rylox.errors import RyloxError
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="rylox",
|
|
23
|
+
help=(
|
|
24
|
+
"Local repository context engine — turns a repo + task into a "
|
|
25
|
+
"token-budgeted markdown package."
|
|
26
|
+
),
|
|
27
|
+
add_completion=False,
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _version_callback(value: bool) -> None:
|
|
33
|
+
if value:
|
|
34
|
+
typer.echo(f"rylox {__version__}")
|
|
35
|
+
raise typer.Exit()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.callback()
|
|
39
|
+
def main(
|
|
40
|
+
version: Optional[bool] = typer.Option(
|
|
41
|
+
None,
|
|
42
|
+
"--version",
|
|
43
|
+
callback=_version_callback,
|
|
44
|
+
is_eager=True,
|
|
45
|
+
help="Show the Rylox version and exit.",
|
|
46
|
+
),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Rylox: build the best local repository context assembler."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def index(
|
|
53
|
+
repo: Path = typer.Option(
|
|
54
|
+
Path("."), "--repo", exists=True, file_okay=False, help="Repository root to index."
|
|
55
|
+
),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Build or incrementally update the local `.rylox/` index."""
|
|
58
|
+
_not_implemented("index")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def context(
|
|
63
|
+
task: str = typer.Argument(..., help="The task/question to build context for."),
|
|
64
|
+
max_tokens: int = typer.Option(
|
|
65
|
+
16000, "--max-tokens", min=1, help="Hard token budget for the assembled context."
|
|
66
|
+
),
|
|
67
|
+
output_format: str = typer.Option(
|
|
68
|
+
"markdown",
|
|
69
|
+
"--format",
|
|
70
|
+
help="Output format. Only 'markdown' is supported in v0.1.",
|
|
71
|
+
),
|
|
72
|
+
repo: Path = typer.Option(
|
|
73
|
+
Path("."), "--repo", exists=True, file_okay=False, help="Repository root."
|
|
74
|
+
),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Assemble a token-budgeted, relationship-aware context package for TASK."""
|
|
77
|
+
if output_format != "markdown":
|
|
78
|
+
raise typer.BadParameter("Only --format markdown is supported in v0.1.")
|
|
79
|
+
_not_implemented("context")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def clean(
|
|
84
|
+
repo: Path = typer.Option(
|
|
85
|
+
Path("."), "--repo", exists=True, file_okay=False, help="Repository root."
|
|
86
|
+
),
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Delete the `.rylox/` cache and start fresh."""
|
|
89
|
+
_not_implemented("clean")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def doctor() -> None:
|
|
94
|
+
"""Run environment/health checks and report each pass/fail individually."""
|
|
95
|
+
_not_implemented("doctor")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _not_implemented(command: str) -> None:
|
|
99
|
+
"""Placeholder body for Phase 1 stubs.
|
|
100
|
+
|
|
101
|
+
Deliberately visible and non-zero-exit rather than a silent no-op, so
|
|
102
|
+
running any command today tells you honestly that it's unbuilt instead
|
|
103
|
+
of pretending to succeed.
|
|
104
|
+
"""
|
|
105
|
+
typer.echo(f"rylox {command}: not implemented yet (Phase 1 skeleton)", err=True)
|
|
106
|
+
raise typer.Exit(code=1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run() -> None:
|
|
110
|
+
"""Entry point wrapper: turn RyloxError into a clean message + exit code."""
|
|
111
|
+
try:
|
|
112
|
+
app()
|
|
113
|
+
except RyloxError as err:
|
|
114
|
+
typer.echo(f"error: {err.message}", err=True)
|
|
115
|
+
raise typer.Exit(code=err.exit_code) from err
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
run()
|