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.
@@ -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
+ )