kata-cli 0.7.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.
- kata_cli-0.7.0.dist-info/METADATA +36 -0
- kata_cli-0.7.0.dist-info/RECORD +33 -0
- kata_cli-0.7.0.dist-info/WHEEL +4 -0
- kata_cli-0.7.0.dist-info/entry_points.txt +3 -0
- kata_cli-0.7.0.dist-info/licenses/LICENSE +21 -0
- seer/__init__.py +34 -0
- seer/__main__.py +8 -0
- seer/cli/__init__.py +117 -0
- seer/cli/_commands/__init__.py +1 -0
- seer/cli/_commands/classify.py +40 -0
- seer/cli/_commands/explain.py +44 -0
- seer/cli/_commands/grep.py +44 -0
- seer/cli/_commands/learn.py +49 -0
- seer/cli/_commands/recent.py +52 -0
- seer/cli/_commands/whoami.py +42 -0
- seer/cli/_errors.py +59 -0
- seer/cli/_output.py +47 -0
- seer/lookup/__init__.py +25 -0
- seer/lookup/ast_scope.py +74 -0
- seer/lookup/classify.py +301 -0
- seer/lookup/grep_context.py +160 -0
- seer/lookup/recent_outline.py +304 -0
- seer/lookup/render.py +41 -0
- seer/repo/__init__.py +9 -0
- seer/repo/__main__.py +228 -0
- seer/repo/config.py +57 -0
- seer/repo/connections.py +298 -0
- seer/repo/detect.py +86 -0
- seer/repo/errors.py +81 -0
- seer/repo/graph.py +182 -0
- seer/repo/manifest.py +36 -0
- seer/repo/profile.py +700 -0
- seer/repo/render.py +470 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""seer.lookup.recent_outline — git log + AST symbol diff per commit.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
recent_with_outline — run ``git log`` and pair every changed file with
|
|
5
|
+
a structural symbol-diff (functions/classes added /
|
|
6
|
+
removed / modified) at the AST level.
|
|
7
|
+
render_recent_markdown — format the result as a Markdown commit log.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import subprocess # noqa: S404 # nosec B404
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from seer.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, SeerError
|
|
18
|
+
from seer.lookup.ast_scope import list_symbols
|
|
19
|
+
|
|
20
|
+
__all__ = ["recent_with_outline", "render_recent_markdown"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run_git( # type: ignore[type-arg]
|
|
24
|
+
args: list[str],
|
|
25
|
+
path: Path,
|
|
26
|
+
allow_nonzero: bool = False,
|
|
27
|
+
) -> subprocess.CompletedProcess:
|
|
28
|
+
"""Run a git command in *path* and return the CompletedProcess.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
SeerError(EXIT_ENV_ERROR): git not found on PATH.
|
|
32
|
+
SeerError(EXIT_ENV_ERROR): git exits non-zero (unless *allow_nonzero*).
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
result = subprocess.run( # noqa: S603,S607 # nosec B603 B607
|
|
36
|
+
["git", "-C", str(path), *args],
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
check=False,
|
|
40
|
+
timeout=30,
|
|
41
|
+
)
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
raise SeerError(
|
|
44
|
+
code=EXIT_ENV_ERROR,
|
|
45
|
+
kind="env_error",
|
|
46
|
+
message="git not found on PATH",
|
|
47
|
+
remediation="install git and ensure it is on your PATH.",
|
|
48
|
+
)
|
|
49
|
+
except subprocess.SubprocessError as exc:
|
|
50
|
+
raise SeerError(
|
|
51
|
+
code=EXIT_ENV_ERROR,
|
|
52
|
+
kind="env_error",
|
|
53
|
+
message=f"git subprocess failed: {exc}",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if not allow_nonzero and result.returncode != 0:
|
|
57
|
+
raise SeerError(
|
|
58
|
+
code=EXIT_ENV_ERROR,
|
|
59
|
+
kind="env_error",
|
|
60
|
+
message=f"git exited with code {result.returncode}",
|
|
61
|
+
reason=result.stderr.strip()[:400],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _symbols_from_source(source: str) -> dict[str, Any]:
|
|
68
|
+
"""Parse *source* and return a mapping of symbol name → Scope.
|
|
69
|
+
|
|
70
|
+
On parse failure (SyntaxError, ValueError) returns an empty dict —
|
|
71
|
+
graceful degradation for files with syntax errors.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
tree = ast.parse(source)
|
|
75
|
+
except (SyntaxError, ValueError):
|
|
76
|
+
return {}
|
|
77
|
+
return {s.name: s for s in list_symbols(tree)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _file_diff(sha: str, file_path: str, repo_path: Path, is_initial: bool) -> dict[str, Any]:
|
|
81
|
+
"""Return the symbol diff for a single *file_path* in commit *sha*.
|
|
82
|
+
|
|
83
|
+
For non-Python files always returns empty added/removed/modified.
|
|
84
|
+
|
|
85
|
+
The "modified" heuristic compares (start_line, end_line) tuples between
|
|
86
|
+
before and after versions. A pure line-shift (e.g. adding a blank line
|
|
87
|
+
above a function) can cause a false positive here; content hashing would
|
|
88
|
+
eliminate these false positives but is deferred as a future improvement.
|
|
89
|
+
"""
|
|
90
|
+
# Get "before" content (parent version). For the initial commit the
|
|
91
|
+
# parent reference <sha>^ does not exist, so we treat before as empty.
|
|
92
|
+
if is_initial:
|
|
93
|
+
before = ""
|
|
94
|
+
else:
|
|
95
|
+
before_result = _run_git(
|
|
96
|
+
["show", f"{sha}^:{file_path}"],
|
|
97
|
+
repo_path,
|
|
98
|
+
allow_nonzero=True,
|
|
99
|
+
)
|
|
100
|
+
before = before_result.stdout if before_result.returncode == 0 else ""
|
|
101
|
+
|
|
102
|
+
# Get "after" content (this commit's version).
|
|
103
|
+
after_result = _run_git(
|
|
104
|
+
["show", f"{sha}:{file_path}"],
|
|
105
|
+
repo_path,
|
|
106
|
+
allow_nonzero=True,
|
|
107
|
+
)
|
|
108
|
+
after = after_result.stdout if after_result.returncode == 0 else ""
|
|
109
|
+
|
|
110
|
+
entry: dict[str, Any] = {"file": file_path, "added": [], "removed": [], "modified": []}
|
|
111
|
+
|
|
112
|
+
if not file_path.endswith(".py"):
|
|
113
|
+
return entry
|
|
114
|
+
|
|
115
|
+
before_map = _symbols_from_source(before)
|
|
116
|
+
after_map = _symbols_from_source(after)
|
|
117
|
+
|
|
118
|
+
before_names = set(before_map)
|
|
119
|
+
after_names = set(after_map)
|
|
120
|
+
|
|
121
|
+
entry["added"] = sorted(after_names - before_names)
|
|
122
|
+
entry["removed"] = sorted(before_names - after_names)
|
|
123
|
+
entry["modified"] = sorted(
|
|
124
|
+
name
|
|
125
|
+
for name in before_names & after_names
|
|
126
|
+
if (before_map[name].start_line, before_map[name].end_line)
|
|
127
|
+
!= (after_map[name].start_line, after_map[name].end_line)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return entry
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _validate_recent_args(n: int, path: str | Path) -> Path:
|
|
134
|
+
"""Validate *n* and *path*; return the resolved repo Path on success."""
|
|
135
|
+
repo = Path(path)
|
|
136
|
+
if n < 1:
|
|
137
|
+
raise SeerError(
|
|
138
|
+
code=EXIT_USER_ERROR,
|
|
139
|
+
kind="user_error",
|
|
140
|
+
message=f"n must be >= 1, got {n}",
|
|
141
|
+
remediation="pass a positive integer for the commit count.",
|
|
142
|
+
)
|
|
143
|
+
if not repo.exists() or not repo.is_dir():
|
|
144
|
+
raise SeerError(
|
|
145
|
+
code=EXIT_USER_ERROR,
|
|
146
|
+
kind="user_error",
|
|
147
|
+
message=f"path is not an existing directory: {path}",
|
|
148
|
+
remediation="pass an existing directory that contains a git repository.",
|
|
149
|
+
)
|
|
150
|
+
rev_parse = _run_git(["rev-parse", "--is-inside-work-tree"], repo, allow_nonzero=True)
|
|
151
|
+
if rev_parse.returncode != 0 or rev_parse.stdout.strip() != "true":
|
|
152
|
+
raise SeerError(
|
|
153
|
+
code=EXIT_USER_ERROR,
|
|
154
|
+
kind="user_error",
|
|
155
|
+
message=f"not a git repository: {path}",
|
|
156
|
+
reason=(rev_parse.stderr.strip()[:200] or "git rev-parse rejected the path."),
|
|
157
|
+
remediation="pass a path that is inside a git work tree.",
|
|
158
|
+
)
|
|
159
|
+
return repo
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _fetch_commit_lines(repo: Path, n: int) -> list[str]:
|
|
163
|
+
"""Run `git log -n N`; return the non-empty SHA/date/subject lines.
|
|
164
|
+
|
|
165
|
+
Returns ``[]`` for the empty-repo case (exit 128 with the sentinel
|
|
166
|
+
stderr). Raises ``SeerError(EXIT_ENV_ERROR)`` for any other fatal error.
|
|
167
|
+
"""
|
|
168
|
+
log_result = _run_git(
|
|
169
|
+
["log", f"-n{n}", "--pretty=format:%H%x09%cI%x09%s"], repo, allow_nonzero=True
|
|
170
|
+
)
|
|
171
|
+
if log_result.returncode == 128:
|
|
172
|
+
stderr_lc = log_result.stderr.lower()
|
|
173
|
+
if "does not have any commits yet" not in stderr_lc and "fatal" in stderr_lc:
|
|
174
|
+
raise SeerError(
|
|
175
|
+
code=EXIT_ENV_ERROR,
|
|
176
|
+
kind="env_error",
|
|
177
|
+
message="git log failed",
|
|
178
|
+
reason=log_result.stderr.strip()[:400],
|
|
179
|
+
)
|
|
180
|
+
elif log_result.returncode != 0:
|
|
181
|
+
raise SeerError(
|
|
182
|
+
code=EXIT_ENV_ERROR,
|
|
183
|
+
kind="env_error",
|
|
184
|
+
message=f"git log exited with code {log_result.returncode}",
|
|
185
|
+
reason=log_result.stderr.strip()[:400],
|
|
186
|
+
)
|
|
187
|
+
raw_log = log_result.stdout.strip()
|
|
188
|
+
return raw_log.splitlines() if raw_log else []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def recent_with_outline(n: int = 20, path: str | Path = ".") -> dict[str, Any]:
|
|
192
|
+
"""Return the last *n* commits in *path*, each paired with AST symbol diffs.
|
|
193
|
+
|
|
194
|
+
The returned dict has the shape::
|
|
195
|
+
|
|
196
|
+
{"commits": [
|
|
197
|
+
{"sha": "abc1234", # 7-char prefix
|
|
198
|
+
"date": "2026-05-15", # YYYY-MM-DD
|
|
199
|
+
"subject": "feat: ...",
|
|
200
|
+
"changes": [
|
|
201
|
+
{"file": "lib.py",
|
|
202
|
+
"added": ["bar"], "removed": [], "modified": ["foo"]},
|
|
203
|
+
{"file": "README.md",
|
|
204
|
+
"added": [], "removed": [], "modified": []},
|
|
205
|
+
]},
|
|
206
|
+
...
|
|
207
|
+
]}
|
|
208
|
+
|
|
209
|
+
Commits are ordered newest-first (same as ``git log``).
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
SeerError(EXIT_USER_ERROR): *n* < 1 or *path* is not an existing directory.
|
|
213
|
+
SeerError(EXIT_ENV_ERROR): git not found, or git exits with a fatal error.
|
|
214
|
+
"""
|
|
215
|
+
repo = _validate_recent_args(n, path)
|
|
216
|
+
commit_lines = _fetch_commit_lines(repo, n)
|
|
217
|
+
if not commit_lines:
|
|
218
|
+
return {"commits": []}
|
|
219
|
+
|
|
220
|
+
# Determine which commit is the initial commit (no parent).
|
|
221
|
+
# We do this by getting the root commit SHA.
|
|
222
|
+
root_result = _run_git(
|
|
223
|
+
["rev-list", "--max-parents=0", "HEAD"],
|
|
224
|
+
repo,
|
|
225
|
+
allow_nonzero=True,
|
|
226
|
+
)
|
|
227
|
+
root_sha = root_result.stdout.strip() if root_result.returncode == 0 else None
|
|
228
|
+
|
|
229
|
+
commits: list[dict[str, Any]] = []
|
|
230
|
+
|
|
231
|
+
for line in commit_lines:
|
|
232
|
+
parts = line.split("\t", 2)
|
|
233
|
+
if len(parts) < 3:
|
|
234
|
+
continue
|
|
235
|
+
full_sha, iso_date, subject = parts
|
|
236
|
+
short_sha = full_sha[:7]
|
|
237
|
+
date = iso_date.split("T")[0]
|
|
238
|
+
is_initial = root_sha is not None and full_sha == root_sha
|
|
239
|
+
|
|
240
|
+
# Get the list of files changed in this commit.
|
|
241
|
+
if is_initial:
|
|
242
|
+
diff_args = ["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", full_sha]
|
|
243
|
+
else:
|
|
244
|
+
diff_args = ["diff-tree", "--no-commit-id", "--name-only", "-r", full_sha]
|
|
245
|
+
|
|
246
|
+
files_result = _run_git(diff_args, repo, allow_nonzero=True)
|
|
247
|
+
changed_files = [f.strip() for f in files_result.stdout.splitlines() if f.strip()]
|
|
248
|
+
|
|
249
|
+
changes = [_file_diff(full_sha, f, repo, is_initial=is_initial) for f in changed_files]
|
|
250
|
+
|
|
251
|
+
commits.append(
|
|
252
|
+
{
|
|
253
|
+
"sha": short_sha,
|
|
254
|
+
"date": date,
|
|
255
|
+
"subject": subject,
|
|
256
|
+
"changes": changes,
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return {"commits": commits}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _render_change_line(ch: dict[str, Any]) -> str:
|
|
264
|
+
"""Format one changed-file entry as a Markdown bullet line."""
|
|
265
|
+
file_name = ch.get("file", "")
|
|
266
|
+
added = ch.get("added") or []
|
|
267
|
+
removed = ch.get("removed") or []
|
|
268
|
+
modified = ch.get("modified") or []
|
|
269
|
+
if not (added or removed or modified):
|
|
270
|
+
return f"- {file_name}"
|
|
271
|
+
parts: list[str] = []
|
|
272
|
+
if added:
|
|
273
|
+
parts.append(f"+{', '.join(added)}")
|
|
274
|
+
if removed:
|
|
275
|
+
parts.append(f"-{', '.join(removed)}")
|
|
276
|
+
if modified:
|
|
277
|
+
parts.append(f"~{', '.join(modified)}")
|
|
278
|
+
return f"- **{file_name}**: {', '.join(parts)}"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def render_recent_markdown(data: dict[str, Any]) -> str:
|
|
282
|
+
"""Render a :func:`recent_with_outline` result dict as Markdown.
|
|
283
|
+
|
|
284
|
+
Each commit gets a ``###`` heading followed by a bullet list of changed
|
|
285
|
+
files. Python files with non-empty symbol diffs render with ``+added``,
|
|
286
|
+
``-removed``, ``~modified`` inline annotations.
|
|
287
|
+
"""
|
|
288
|
+
commits = data.get("commits") or []
|
|
289
|
+
if not commits:
|
|
290
|
+
return "_No commits found._\n"
|
|
291
|
+
|
|
292
|
+
lines: list[str] = []
|
|
293
|
+
for commit in commits:
|
|
294
|
+
sha = commit.get("sha", "")
|
|
295
|
+
date = commit.get("date", "")
|
|
296
|
+
subject = commit.get("subject", "")
|
|
297
|
+
changes = commit.get("changes") or []
|
|
298
|
+
lines.append(f"### {sha} ({date}) {subject}")
|
|
299
|
+
lines.append("")
|
|
300
|
+
for ch in changes:
|
|
301
|
+
lines.append(_render_change_line(ch))
|
|
302
|
+
lines.append("")
|
|
303
|
+
|
|
304
|
+
return "\n".join(lines)
|
seer/lookup/render.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Markdown emitter for seer.lookup.classify."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _section_break(lines: list[str]) -> None:
|
|
9
|
+
"""Emit a blank line + horizontal rule before a top-level section heading."""
|
|
10
|
+
lines.append("")
|
|
11
|
+
lines.append("---")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render_classify_markdown(data: dict[str, Any]) -> str:
|
|
15
|
+
"""Render a classify() dict as a markdown report."""
|
|
16
|
+
lines: list[str] = []
|
|
17
|
+
lines.append(f"# {data.get('path', '(unknown)')}")
|
|
18
|
+
|
|
19
|
+
manifest = data.get("manifest")
|
|
20
|
+
language = data.get("language") or "unknown"
|
|
21
|
+
if manifest:
|
|
22
|
+
lines.append(f"- **Manifest:** {manifest} ({language})")
|
|
23
|
+
else:
|
|
24
|
+
lines.append(f"- **Manifest:** none ({language})")
|
|
25
|
+
|
|
26
|
+
tags = data.get("tags") or []
|
|
27
|
+
if tags:
|
|
28
|
+
names = ", ".join(t["name"] for t in tags)
|
|
29
|
+
lines.append(f"- **Tags:** {names}")
|
|
30
|
+
else:
|
|
31
|
+
lines.append("- **Tags:** _(none)_")
|
|
32
|
+
|
|
33
|
+
if tags:
|
|
34
|
+
_section_break(lines)
|
|
35
|
+
lines.append("## Tags")
|
|
36
|
+
lines.append("| Tag | Evidence |")
|
|
37
|
+
lines.append("|---|---|")
|
|
38
|
+
for t in tags:
|
|
39
|
+
lines.append(f"| `{t['name']}` | {t['evidence']} |")
|
|
40
|
+
|
|
41
|
+
return "\n".join(lines) + "\n"
|
seer/repo/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""seer.repo — repo profiling, connection walks, and workspace graphs.
|
|
2
|
+
|
|
3
|
+
Forward-compatible with seer-cli's eventual `whoami` / `explain` / `learn`
|
|
4
|
+
verbs; today the entry point is `python -m seer.repo <verb>`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = []
|
seer/repo/__main__.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""argparse dispatch for ``python -m seer.repo``.
|
|
2
|
+
|
|
3
|
+
Verbs:
|
|
4
|
+
|
|
5
|
+
profile <path> [--depth shallow|deep] [--json]
|
|
6
|
+
connections <path> [--depth N|all] [--profile] [--depth-profile shallow|deep]
|
|
7
|
+
[--root PATH ...] [--marker FILE ...] [--strict] [--json]
|
|
8
|
+
graph [<root>...] [--marker FILE ...] [--strict] [--json]
|
|
9
|
+
|
|
10
|
+
Output: markdown by default; JSON when ``--json`` is passed.
|
|
11
|
+
Errors: routed through :func:`_dispatch`, which loads config and invokes the
|
|
12
|
+
verb handler inside a single try/except so neither config-load nor verb
|
|
13
|
+
execution can leak a Python traceback. Partial-failure inlining for walks
|
|
14
|
+
lives inside :func:`seer.repo.connections.walk` /
|
|
15
|
+
:func:`seer.repo.graph.build_graph`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from seer.cli._errors import (
|
|
26
|
+
EXIT_ENV_ERROR,
|
|
27
|
+
EXIT_INTERNAL,
|
|
28
|
+
EXIT_USER_ERROR,
|
|
29
|
+
SeerError,
|
|
30
|
+
)
|
|
31
|
+
from seer.cli._output import emit_error, emit_result
|
|
32
|
+
from seer.repo.config import RepoMapConfig, load_config
|
|
33
|
+
from seer.repo.connections import walk
|
|
34
|
+
from seer.repo.errors import path_not_a_directory
|
|
35
|
+
from seer.repo.graph import build_graph
|
|
36
|
+
from seer.repo.profile import profile_deep, profile_shallow
|
|
37
|
+
from seer.repo.render import (
|
|
38
|
+
render_connections_markdown,
|
|
39
|
+
render_graph_markdown,
|
|
40
|
+
render_profile_markdown,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _profile(args: argparse.Namespace, _cfg: RepoMapConfig) -> int:
|
|
45
|
+
"""Handle the ``profile`` verb."""
|
|
46
|
+
path = Path(args.path)
|
|
47
|
+
if not path.is_dir():
|
|
48
|
+
raise path_not_a_directory(path)
|
|
49
|
+
basic = bool(getattr(args, "basic", False))
|
|
50
|
+
if args.depth == "deep":
|
|
51
|
+
data = profile_deep(path, basic=basic)
|
|
52
|
+
else:
|
|
53
|
+
data = profile_shallow(path, basic=basic)
|
|
54
|
+
if args.json:
|
|
55
|
+
emit_result({"ok": True, "data": data}, json_mode=True)
|
|
56
|
+
else:
|
|
57
|
+
emit_result(render_profile_markdown(data), json_mode=False)
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _connections(args: argparse.Namespace, cfg: RepoMapConfig) -> int:
|
|
62
|
+
"""Handle the ``connections`` verb."""
|
|
63
|
+
seed = Path(args.path)
|
|
64
|
+
if not seed.is_dir():
|
|
65
|
+
raise path_not_a_directory(seed)
|
|
66
|
+
roots = [Path(r) for r in (args.root or [str(p) for p in cfg.roots])]
|
|
67
|
+
# `--depth` defaults to None at the parser level so the configured
|
|
68
|
+
# `default_connections_depth` is reachable when the user doesn't pass
|
|
69
|
+
# an explicit flag.
|
|
70
|
+
depth = args.depth if args.depth is not None else cfg.default_connections_depth
|
|
71
|
+
result = walk(
|
|
72
|
+
seed=seed,
|
|
73
|
+
roots=roots,
|
|
74
|
+
depth=depth,
|
|
75
|
+
with_profile=args.profile,
|
|
76
|
+
depth_profile=args.depth_profile,
|
|
77
|
+
additional_markers=(args.marker or cfg.additional_markers),
|
|
78
|
+
skip_dirs=cfg.skip_dirs,
|
|
79
|
+
strict=args.strict,
|
|
80
|
+
)
|
|
81
|
+
if args.json:
|
|
82
|
+
emit_result({"ok": True, "data": result}, json_mode=True)
|
|
83
|
+
else:
|
|
84
|
+
emit_result(render_connections_markdown(result), json_mode=False)
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _graph(args: argparse.Namespace, cfg: RepoMapConfig) -> int:
|
|
89
|
+
"""Handle the ``graph`` verb."""
|
|
90
|
+
roots = [Path(r) for r in (args.roots or [str(p) for p in cfg.roots])]
|
|
91
|
+
result = build_graph(
|
|
92
|
+
roots,
|
|
93
|
+
additional_markers=(args.marker or cfg.additional_markers),
|
|
94
|
+
skip_dirs=cfg.skip_dirs,
|
|
95
|
+
strict=args.strict,
|
|
96
|
+
)
|
|
97
|
+
if args.json:
|
|
98
|
+
emit_result({"ok": True, "data": result}, json_mode=True)
|
|
99
|
+
else:
|
|
100
|
+
emit_result(render_graph_markdown(result), json_mode=False)
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
105
|
+
"""Build and return the argparse parser for ``python -m seer.repo``."""
|
|
106
|
+
parser = argparse.ArgumentParser(
|
|
107
|
+
prog="seer.repo",
|
|
108
|
+
description="repo-map engine: profile / connections / graph.",
|
|
109
|
+
)
|
|
110
|
+
sub = parser.add_subparsers(dest="verb")
|
|
111
|
+
|
|
112
|
+
pp = sub.add_parser("profile", help="Profile one repo.")
|
|
113
|
+
pp.add_argument("path")
|
|
114
|
+
pp.add_argument("--depth", choices=["shallow", "deep"], default="shallow")
|
|
115
|
+
pp.add_argument(
|
|
116
|
+
"--basic",
|
|
117
|
+
action="store_true",
|
|
118
|
+
help=(
|
|
119
|
+
"Skip Tier-2 online fields (github_state, pypi_state) — "
|
|
120
|
+
"Tier-1 mechanical fields only."
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
pp.add_argument("--json", action="store_true")
|
|
124
|
+
pp.set_defaults(func=_profile)
|
|
125
|
+
|
|
126
|
+
pc = sub.add_parser("connections", help="Walk outward from a seed repo.")
|
|
127
|
+
pc.add_argument("path")
|
|
128
|
+
pc.add_argument(
|
|
129
|
+
"--depth",
|
|
130
|
+
default=None,
|
|
131
|
+
help=(
|
|
132
|
+
"non-negative int or 'all'. When omitted, falls back to "
|
|
133
|
+
"config `default_connections_depth` (default: 1)."
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
pc.add_argument(
|
|
137
|
+
"--profile",
|
|
138
|
+
action="store_true",
|
|
139
|
+
help="include each internal node's profile",
|
|
140
|
+
)
|
|
141
|
+
pc.add_argument(
|
|
142
|
+
"--depth-profile",
|
|
143
|
+
choices=["shallow", "deep"],
|
|
144
|
+
default="shallow",
|
|
145
|
+
dest="depth_profile",
|
|
146
|
+
)
|
|
147
|
+
pc.add_argument(
|
|
148
|
+
"--root",
|
|
149
|
+
action="append",
|
|
150
|
+
default=None,
|
|
151
|
+
help="root to search for connected repos (repeatable)",
|
|
152
|
+
)
|
|
153
|
+
pc.add_argument(
|
|
154
|
+
"--marker",
|
|
155
|
+
action="append",
|
|
156
|
+
default=None,
|
|
157
|
+
help="additional marker filename (repeatable)",
|
|
158
|
+
)
|
|
159
|
+
pc.add_argument(
|
|
160
|
+
"--strict",
|
|
161
|
+
action="store_true",
|
|
162
|
+
help="fail on any per-node error",
|
|
163
|
+
)
|
|
164
|
+
pc.add_argument("--json", action="store_true")
|
|
165
|
+
pc.set_defaults(func=_connections)
|
|
166
|
+
|
|
167
|
+
pg = sub.add_parser("graph", help="Multi-root workspace view.")
|
|
168
|
+
pg.add_argument("roots", nargs="*")
|
|
169
|
+
pg.add_argument("--marker", action="append", default=None)
|
|
170
|
+
pg.add_argument("--strict", action="store_true", help="fail on any per-node error")
|
|
171
|
+
pg.add_argument("--json", action="store_true")
|
|
172
|
+
pg.set_defaults(func=_graph)
|
|
173
|
+
|
|
174
|
+
return parser
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _dispatch(args: argparse.Namespace) -> int:
|
|
178
|
+
"""Load config and invoke the verb handler with structured error wrapping.
|
|
179
|
+
|
|
180
|
+
Centralises the policy: every failure — whether it originates in
|
|
181
|
+
:func:`seer.repo.config.load_config` or inside a verb handler — is
|
|
182
|
+
routed through :func:`seer.cli._output.emit_error` and translated to an
|
|
183
|
+
exit code. No Python traceback leaks.
|
|
184
|
+
"""
|
|
185
|
+
json_mode = bool(getattr(args, "json", False))
|
|
186
|
+
try:
|
|
187
|
+
cfg = load_config()
|
|
188
|
+
return args.func(args, cfg)
|
|
189
|
+
except SeerError as err:
|
|
190
|
+
emit_error(err, json_mode=json_mode)
|
|
191
|
+
return err.code
|
|
192
|
+
except json.JSONDecodeError as err:
|
|
193
|
+
wrapped = SeerError(
|
|
194
|
+
code=EXIT_ENV_ERROR,
|
|
195
|
+
kind="env_error",
|
|
196
|
+
message="Malformed .claude/skills/repo-map/config.json",
|
|
197
|
+
reason=f"JSON parse error: {err}",
|
|
198
|
+
remediation=("Fix the JSON syntax or delete the file to fall back to defaults."),
|
|
199
|
+
)
|
|
200
|
+
emit_error(wrapped, json_mode=json_mode)
|
|
201
|
+
return wrapped.code
|
|
202
|
+
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
|
203
|
+
wrapped = SeerError(
|
|
204
|
+
code=EXIT_INTERNAL,
|
|
205
|
+
kind="bug",
|
|
206
|
+
message=f"unexpected: {err.__class__.__name__}: {err}",
|
|
207
|
+
reason="An unhandled exception escaped a seer.repo verb.",
|
|
208
|
+
remediation="file a bug at https://github.com/agentculture/seer-cli/issues",
|
|
209
|
+
)
|
|
210
|
+
emit_error(wrapped, json_mode=json_mode)
|
|
211
|
+
return wrapped.code
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main(argv: list[str] | None = None) -> int:
|
|
215
|
+
"""argparse entry point for ``python -m seer.repo``."""
|
|
216
|
+
parser = _build_parser()
|
|
217
|
+
args = parser.parse_args(argv)
|
|
218
|
+
if args.verb is None:
|
|
219
|
+
parser.print_help()
|
|
220
|
+
return 0
|
|
221
|
+
if not hasattr(args, "func"):
|
|
222
|
+
parser.print_help()
|
|
223
|
+
return EXIT_USER_ERROR
|
|
224
|
+
return _dispatch(args)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
sys.exit(main())
|
seer/repo/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Per-workspace defaults for repo-map.
|
|
2
|
+
|
|
3
|
+
Loaded from `.claude/skills/repo-map/config.json` (or any path the caller
|
|
4
|
+
passes). Missing file => defaults; missing keys => per-key defaults.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DEFAULT_SKIP_DIRS: tuple[str, ...] = (
|
|
14
|
+
".git",
|
|
15
|
+
".venv",
|
|
16
|
+
"node_modules",
|
|
17
|
+
"__pycache__",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_roots() -> list[Path]:
|
|
22
|
+
"""Return the default root search path list."""
|
|
23
|
+
return [Path.home() / "git"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _default_skip_dirs() -> list[str]:
|
|
27
|
+
"""Return the default list of directory names to skip during traversal."""
|
|
28
|
+
return list(DEFAULT_SKIP_DIRS)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RepoMapConfig:
|
|
33
|
+
"""Per-workspace defaults consumed by every repo-map verb."""
|
|
34
|
+
|
|
35
|
+
roots: list[Path] = field(default_factory=_default_roots)
|
|
36
|
+
additional_markers: list[str] = field(default_factory=list)
|
|
37
|
+
skip_dirs: list[str] = field(default_factory=_default_skip_dirs)
|
|
38
|
+
default_connections_depth: int = 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_config(path: Path | None = None) -> RepoMapConfig:
|
|
42
|
+
"""Load config from `path` (default `.claude/skills/repo-map/config.json`).
|
|
43
|
+
|
|
44
|
+
Returns :class:`RepoMapConfig` with defaults filled in for missing keys.
|
|
45
|
+
Raises :exc:`json.JSONDecodeError` if the file exists but is not valid JSON.
|
|
46
|
+
"""
|
|
47
|
+
if path is None:
|
|
48
|
+
path = Path(".claude/skills/repo-map/config.json")
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return RepoMapConfig()
|
|
51
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
return RepoMapConfig(
|
|
53
|
+
roots=([Path(r) for r in raw["roots"]] if "roots" in raw else _default_roots()),
|
|
54
|
+
additional_markers=list(raw.get("additional_markers", [])),
|
|
55
|
+
skip_dirs=list(raw.get("skip_dirs", DEFAULT_SKIP_DIRS)),
|
|
56
|
+
default_connections_depth=int(raw.get("default_connections_depth", 1)),
|
|
57
|
+
)
|