katvan 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.
- katvan/__init__.py +11 -0
- katvan/__main__.py +8 -0
- katvan/cli/__init__.py +131 -0
- katvan/cli/_commands/__init__.py +0 -0
- katvan/cli/_commands/explain.py +38 -0
- katvan/cli/_commands/learn.py +110 -0
- katvan/cli/_errors.py +44 -0
- katvan/cli/_output.py +58 -0
- katvan/explain/__init__.py +27 -0
- katvan/explain/catalog.py +104 -0
- katvan/frontmatter.py +233 -0
- katvan/repos.py +240 -0
- katvan-0.1.0.dist-info/METADATA +63 -0
- katvan-0.1.0.dist-info/RECORD +17 -0
- katvan-0.1.0.dist-info/WHEEL +4 -0
- katvan-0.1.0.dist-info/entry_points.txt +2 -0
- katvan-0.1.0.dist-info/licenses/LICENSE +21 -0
katvan/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""katvan — maintain sibling-repo docs under one roof on the culture.dev site."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _v
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _v("katvan")
|
|
8
|
+
except PackageNotFoundError: # editable install without metadata
|
|
9
|
+
__version__ = "0.0.0+local"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
katvan/__main__.py
ADDED
katvan/cli/__init__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Unified CLI entry point for katvan.
|
|
2
|
+
|
|
3
|
+
Top-level globals (``learn``, ``explain``) live under
|
|
4
|
+
:mod:`katvan.cli._commands` and are registered here. Per-noun groups
|
|
5
|
+
(``overview``, ``pull``, ``doctor`` — landing in a later release) will
|
|
6
|
+
register via their own ``register()`` functions following the same pattern.
|
|
7
|
+
|
|
8
|
+
Error propagation contract
|
|
9
|
+
--------------------------
|
|
10
|
+
Every handler raises :class:`katvan.cli._errors.KatvanError` on failure; the
|
|
11
|
+
top-level ``main()`` catches it via :func:`_dispatch` and routes through
|
|
12
|
+
:mod:`katvan.cli._output`. Unknown exceptions are wrapped into a
|
|
13
|
+
``KatvanError`` so no Python traceback leaks to stderr.
|
|
14
|
+
|
|
15
|
+
Argparse errors (unknown verb, missing required arg) also route through our
|
|
16
|
+
structured format — ``_KatvanArgumentParser`` overrides ``.error()``. The
|
|
17
|
+
subparsers are built with ``parser_class=_KatvanArgumentParser`` so subparser
|
|
18
|
+
errors follow the same path. Whether the error is emitted as text or JSON
|
|
19
|
+
depends on whether ``--json`` appears in the raw argv (:func:`main` sets
|
|
20
|
+
``_KatvanArgumentParser._json_hint`` before ``parse_args``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
from katvan import __version__
|
|
29
|
+
from katvan.cli._commands import explain as _explain_cmd
|
|
30
|
+
from katvan.cli._commands import learn as _learn_cmd
|
|
31
|
+
from katvan.cli._errors import EXIT_INTERNAL_ERROR, EXIT_USER_ERROR, KatvanError
|
|
32
|
+
from katvan.cli._output import emit_error
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _KatvanArgumentParser(argparse.ArgumentParser):
|
|
36
|
+
"""ArgumentParser that routes errors through :func:`emit_error`.
|
|
37
|
+
|
|
38
|
+
Argparse's default error handler writes ``prog: error: <msg>`` to stderr
|
|
39
|
+
and exits with code 2. That skips our KatvanError plumbing and — crucially
|
|
40
|
+
— produces no ``hint:`` line, which would make katvan itself fail the
|
|
41
|
+
rubric's error-propagation bundle. This subclass emits our structured
|
|
42
|
+
error format instead and exits with :attr:`EXIT_USER_ERROR`.
|
|
43
|
+
|
|
44
|
+
JSON mode: parse-time errors happen before ``args.json`` is populated, so
|
|
45
|
+
we rely on a class-level ``_json_hint`` that :func:`main` pre-populates
|
|
46
|
+
by scanning the raw argv for ``--json`` / ``--json=…``. Best-effort and
|
|
47
|
+
shared across all subparser instances (argparse's subparser factory
|
|
48
|
+
produces instances of the class but doesn't thread state).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_json_hint: bool = False
|
|
52
|
+
|
|
53
|
+
def error(self, message: str) -> None: # type: ignore[override]
|
|
54
|
+
err = KatvanError(
|
|
55
|
+
code=EXIT_USER_ERROR,
|
|
56
|
+
message=message,
|
|
57
|
+
remediation=f"run '{self.prog} --help' to see valid arguments",
|
|
58
|
+
)
|
|
59
|
+
emit_error(err, json_mode=type(self)._json_hint)
|
|
60
|
+
raise SystemExit(err.code)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _argv_has_json(argv: list[str] | None) -> bool:
|
|
64
|
+
tokens = argv if argv is not None else sys.argv[1:]
|
|
65
|
+
return any(t == "--json" or t.startswith("--json=") for t in tokens)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
69
|
+
parser = _KatvanArgumentParser(
|
|
70
|
+
prog="katvan",
|
|
71
|
+
description="katvan — maintain sibling-repo docs on the culture.dev site",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--version",
|
|
75
|
+
action="version",
|
|
76
|
+
version=f"%(prog)s {__version__}",
|
|
77
|
+
)
|
|
78
|
+
# parser_class propagates to every subparser so their .error() routes
|
|
79
|
+
# through _KatvanArgumentParser too. Without this, a bogus subcommand arg
|
|
80
|
+
# would hit argparse's default error path (no hint: line, wrong code).
|
|
81
|
+
sub = parser.add_subparsers(dest="command", parser_class=_KatvanArgumentParser)
|
|
82
|
+
|
|
83
|
+
# Globals (top-level, not nested under a noun).
|
|
84
|
+
_learn_cmd.register(sub)
|
|
85
|
+
_explain_cmd.register(sub)
|
|
86
|
+
# The docs verbs — overview / pull / doctor — register here in a later
|
|
87
|
+
# release once they are ported from the librarian skill.
|
|
88
|
+
|
|
89
|
+
return parser
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _dispatch(args: argparse.Namespace) -> int:
|
|
93
|
+
"""Invoke the registered handler and translate exceptions to exit codes.
|
|
94
|
+
|
|
95
|
+
Handler protocol: a handler may return ``None`` (treated as success,
|
|
96
|
+
exit 0) or an ``int`` (used directly as the exit code). Failures MUST
|
|
97
|
+
raise :class:`KatvanError`; any other exception is wrapped into one so no
|
|
98
|
+
Python traceback leaks.
|
|
99
|
+
"""
|
|
100
|
+
json_mode = bool(getattr(args, "json", False))
|
|
101
|
+
try:
|
|
102
|
+
rc = args.func(args)
|
|
103
|
+
except KatvanError as err:
|
|
104
|
+
emit_error(err, json_mode=json_mode)
|
|
105
|
+
return err.code
|
|
106
|
+
except Exception as err: # noqa: BLE001 - last-resort; wrap and route cleanly
|
|
107
|
+
wrapped = KatvanError(
|
|
108
|
+
code=EXIT_INTERNAL_ERROR,
|
|
109
|
+
message=f"unexpected: {err.__class__.__name__}: {err}",
|
|
110
|
+
remediation="file a bug at https://github.com/agentculture/katvan/issues",
|
|
111
|
+
)
|
|
112
|
+
emit_error(wrapped, json_mode=json_mode)
|
|
113
|
+
return wrapped.code
|
|
114
|
+
return rc if rc is not None else 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main(argv: list[str] | None = None) -> int:
|
|
118
|
+
# Pre-parse peek so argparse-level errors honour --json.
|
|
119
|
+
_KatvanArgumentParser._json_hint = _argv_has_json(argv)
|
|
120
|
+
parser = _build_parser()
|
|
121
|
+
args = parser.parse_args(argv)
|
|
122
|
+
|
|
123
|
+
if args.command is None:
|
|
124
|
+
parser.print_help()
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
return _dispatch(args)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""``katvan explain <path>...`` — global markdown catalog lookup.
|
|
2
|
+
|
|
3
|
+
``explain`` is global (not nested under a noun). It takes zero or more path
|
|
4
|
+
tokens and resolves them via :mod:`katvan.explain`. Unknown paths raise
|
|
5
|
+
:class:`KatvanError` with a remediation pointing at ``katvan explain katvan``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
from katvan.cli._output import emit_result
|
|
13
|
+
from katvan.explain import resolve
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_explain(args: argparse.Namespace) -> int:
|
|
17
|
+
path = tuple(args.path) if args.path else ()
|
|
18
|
+
markdown = resolve(path) # raises KatvanError on miss → caught in _dispatch
|
|
19
|
+
json_mode = bool(getattr(args, "json", False))
|
|
20
|
+
if json_mode:
|
|
21
|
+
emit_result({"path": list(path), "markdown": markdown}, json_mode=True)
|
|
22
|
+
else:
|
|
23
|
+
emit_result(markdown, json_mode=False)
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register(sub: argparse._SubParsersAction) -> None:
|
|
28
|
+
p = sub.add_parser(
|
|
29
|
+
"explain",
|
|
30
|
+
help="Print markdown docs for a noun/verb path (e.g. 'katvan explain learn').",
|
|
31
|
+
)
|
|
32
|
+
p.add_argument(
|
|
33
|
+
"path",
|
|
34
|
+
nargs="*",
|
|
35
|
+
help="Command path tokens; empty = root (same as 'katvan').",
|
|
36
|
+
)
|
|
37
|
+
p.add_argument("--json", action="store_true", help="Emit structured JSON.")
|
|
38
|
+
p.set_defaults(func=cmd_explain)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""``katvan learn`` — the learnability affordance.
|
|
2
|
+
|
|
3
|
+
Prints a structured self-teaching prompt with enough shape that an agent can
|
|
4
|
+
author its own usage skill without scraping ``--help``. Also supports
|
|
5
|
+
``--json`` for agents that would rather parse structure than text.
|
|
6
|
+
|
|
7
|
+
Content satisfies the agent-first rubric's learnability bundle: ≥200 chars
|
|
8
|
+
and mentions purpose, command map, exit codes, ``--json``, and ``explain``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
|
|
15
|
+
from katvan import __version__
|
|
16
|
+
from katvan.cli._output import emit_result
|
|
17
|
+
|
|
18
|
+
_TEXT = """\
|
|
19
|
+
katvan — maintain sibling-repo docs under one roof on the culture.dev site.
|
|
20
|
+
|
|
21
|
+
Purpose
|
|
22
|
+
-------
|
|
23
|
+
katvan keeps the culture.dev Jekyll site's docs tree coherent: it surveys
|
|
24
|
+
which AgentCulture sibling repos are synced and fresh, pulls their raw
|
|
25
|
+
markdown docs/ trees into site/docs/<repo>/ with Jekyll frontmatter
|
|
26
|
+
injected, and diagnoses doc defects (missing frontmatter, broken links,
|
|
27
|
+
staleness). It is the librarian skill's logic, growing into a real CLI.
|
|
28
|
+
|
|
29
|
+
Commands
|
|
30
|
+
--------
|
|
31
|
+
katvan learn Print this self-teaching prompt. Supports --json.
|
|
32
|
+
katvan explain <path>... Print markdown docs for any noun/verb path; the
|
|
33
|
+
primary way for an agent to introspect katvan's
|
|
34
|
+
grammar. Supports --json.
|
|
35
|
+
|
|
36
|
+
The docs verbs — overview, pull, and doctor — are coming in a later
|
|
37
|
+
release, ported from the librarian skill.
|
|
38
|
+
|
|
39
|
+
Universal verb tier (agent-first)
|
|
40
|
+
---------------------------------
|
|
41
|
+
katvan exposes the universal agent-first verbs:
|
|
42
|
+
|
|
43
|
+
- learn — what is this tool?
|
|
44
|
+
- explain — what does this command do?
|
|
45
|
+
|
|
46
|
+
Machine-readable output
|
|
47
|
+
-----------------------
|
|
48
|
+
Every command that produces a listing or report supports --json. Errors in
|
|
49
|
+
JSON mode emit {"code", "message", "remediation"} to stderr. Stdout and
|
|
50
|
+
stderr are never mixed.
|
|
51
|
+
|
|
52
|
+
Exit-code policy
|
|
53
|
+
----------------
|
|
54
|
+
0 success
|
|
55
|
+
1 user-input error (bad flag, bad path, missing arg)
|
|
56
|
+
2 environment / setup error (registry not found, unreadable file)
|
|
57
|
+
3 internal/unexpected error (a bug in katvan)
|
|
58
|
+
4+ reserved
|
|
59
|
+
|
|
60
|
+
More detail
|
|
61
|
+
-----------
|
|
62
|
+
katvan explain katvan
|
|
63
|
+
katvan explain learn
|
|
64
|
+
katvan explain explain
|
|
65
|
+
|
|
66
|
+
Homepage: https://github.com/agentculture/katvan
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _as_json_payload() -> dict[str, object]:
|
|
71
|
+
return {
|
|
72
|
+
"tool": "katvan",
|
|
73
|
+
"version": __version__,
|
|
74
|
+
"purpose": ("Maintain sibling-repo docs under one roof on the culture.dev site."),
|
|
75
|
+
"commands": [
|
|
76
|
+
{"path": ["learn"], "summary": "Self-teaching prompt."},
|
|
77
|
+
{"path": ["explain"], "summary": "Markdown docs by noun/verb path."},
|
|
78
|
+
],
|
|
79
|
+
"coming_soon": [
|
|
80
|
+
{"path": ["overview"], "summary": "Survey synced/fresh sibling docs."},
|
|
81
|
+
{"path": ["pull"], "summary": "Sync a sibling's docs/ into the site."},
|
|
82
|
+
{"path": ["doctor"], "summary": "Detect and report doc defects."},
|
|
83
|
+
],
|
|
84
|
+
"exit_codes": {
|
|
85
|
+
"0": "success",
|
|
86
|
+
"1": "user-input error",
|
|
87
|
+
"2": "environment/setup error",
|
|
88
|
+
"3": "internal/unexpected error (bug)",
|
|
89
|
+
},
|
|
90
|
+
"json_support": True,
|
|
91
|
+
"explain_pointer": "katvan explain <path> (e.g. 'katvan explain learn')",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_learn(args: argparse.Namespace) -> int:
|
|
96
|
+
json_mode = bool(getattr(args, "json", False))
|
|
97
|
+
if json_mode:
|
|
98
|
+
emit_result(_as_json_payload(), json_mode=True)
|
|
99
|
+
else:
|
|
100
|
+
emit_result(_TEXT, json_mode=False)
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def register(sub: argparse._SubParsersAction) -> None:
|
|
105
|
+
p = sub.add_parser(
|
|
106
|
+
"learn",
|
|
107
|
+
help="Print a structured self-teaching prompt for agent consumers.",
|
|
108
|
+
)
|
|
109
|
+
p.add_argument("--json", action="store_true", help="Emit structured JSON.")
|
|
110
|
+
p.set_defaults(func=cmd_learn)
|
katvan/cli/_errors.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""KatvanError and exit-code policy.
|
|
2
|
+
|
|
3
|
+
Every failure inside katvan raises :class:`KatvanError`. The top-level
|
|
4
|
+
``main()`` catches it, formats via :mod:`katvan.cli._output`, and exits with
|
|
5
|
+
:attr:`KatvanError.code`. This guarantees:
|
|
6
|
+
|
|
7
|
+
* no Python traceback leaks to stderr (agent-first error rubric);
|
|
8
|
+
* every error has a structured shape ``{code, message, remediation}``;
|
|
9
|
+
* the exit-code policy is centralised in one place.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
# Exit-code policy. Documented in ``katvan learn`` output.
|
|
17
|
+
# 0 = success
|
|
18
|
+
# 1 = user-input error (bad flag, missing required arg, unknown path)
|
|
19
|
+
# 2 = environment / setup error (tool not installed, file unreadable)
|
|
20
|
+
# 3 = internal/unexpected error (a bug in katvan)
|
|
21
|
+
# 4+ = reserved for future categorisation
|
|
22
|
+
EXIT_SUCCESS = 0
|
|
23
|
+
EXIT_USER_ERROR = 1
|
|
24
|
+
EXIT_ENV_ERROR = 2
|
|
25
|
+
EXIT_INTERNAL_ERROR = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class KatvanError(Exception):
|
|
30
|
+
"""Structured error raised within katvan; carries a remediation hint for agents."""
|
|
31
|
+
|
|
32
|
+
code: int
|
|
33
|
+
message: str
|
|
34
|
+
remediation: str = ""
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
super().__init__(self.message)
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, object]:
|
|
40
|
+
return {
|
|
41
|
+
"code": self.code,
|
|
42
|
+
"message": self.message,
|
|
43
|
+
"remediation": self.remediation,
|
|
44
|
+
}
|
katvan/cli/_output.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""stdout / stderr helpers with a strict split.
|
|
2
|
+
|
|
3
|
+
Rule: **results go to stdout, diagnostics and errors go to stderr.** Agents
|
|
4
|
+
parsing katvan output can rely on this invariant. JSON mode routes structured
|
|
5
|
+
payloads to the same streams — never mixes them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any, TextIO
|
|
13
|
+
|
|
14
|
+
from katvan.cli._errors import KatvanError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def emit_result(data: Any, *, json_mode: bool, stream: TextIO | None = None) -> None:
|
|
18
|
+
"""Write a command result.
|
|
19
|
+
|
|
20
|
+
Text mode: ``data`` is treated as a string (or stringified) and a trailing
|
|
21
|
+
newline is ensured. JSON mode: ``data`` is JSON-dumped with a trailing
|
|
22
|
+
newline. Default stream is stdout.
|
|
23
|
+
"""
|
|
24
|
+
s = stream if stream is not None else sys.stdout
|
|
25
|
+
if json_mode:
|
|
26
|
+
json.dump(data, s, ensure_ascii=False)
|
|
27
|
+
s.write("\n")
|
|
28
|
+
return
|
|
29
|
+
text = data if isinstance(data, str) else str(data)
|
|
30
|
+
s.write(text)
|
|
31
|
+
if not text.endswith("\n"):
|
|
32
|
+
s.write("\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def emit_error(err: KatvanError, *, json_mode: bool, stream: TextIO | None = None) -> None:
|
|
36
|
+
"""Write a :class:`KatvanError` to stderr.
|
|
37
|
+
|
|
38
|
+
Text mode renders as two lines when a remediation is present::
|
|
39
|
+
|
|
40
|
+
error: <message>
|
|
41
|
+
hint: <remediation>
|
|
42
|
+
|
|
43
|
+
The ``hint:`` prefix is what the error-propagation rubric bundle looks for.
|
|
44
|
+
"""
|
|
45
|
+
s = stream if stream is not None else sys.stderr
|
|
46
|
+
if json_mode:
|
|
47
|
+
json.dump(err.to_dict(), s, ensure_ascii=False)
|
|
48
|
+
s.write("\n")
|
|
49
|
+
return
|
|
50
|
+
s.write(f"error: {err.message}\n")
|
|
51
|
+
if err.remediation:
|
|
52
|
+
s.write(f"hint: {err.remediation}\n")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def emit_diagnostic(message: str, *, stream: TextIO | None = None) -> None:
|
|
56
|
+
"""Write a human diagnostic (progress, summary) to stderr."""
|
|
57
|
+
s = stream if stream is not None else sys.stderr
|
|
58
|
+
s.write(message if message.endswith("\n") else message + "\n")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Explain catalog — markdown keyed by command-path tuples.
|
|
2
|
+
|
|
3
|
+
See :mod:`katvan.explain.catalog` for the string bodies and :func:`resolve`
|
|
4
|
+
for lookup. Every noun/verb in the CLI should have a catalog entry.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from katvan.cli._errors import EXIT_USER_ERROR, KatvanError
|
|
10
|
+
from katvan.explain.catalog import ENTRIES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve(path: tuple[str, ...]) -> str:
|
|
14
|
+
"""Return the markdown body for ``path`` or raise :class:`KatvanError`."""
|
|
15
|
+
if path in ENTRIES:
|
|
16
|
+
return ENTRIES[path]
|
|
17
|
+
display = " ".join(path) if path else "<root>"
|
|
18
|
+
raise KatvanError(
|
|
19
|
+
code=EXIT_USER_ERROR,
|
|
20
|
+
message=f"no explain entry for: {display}",
|
|
21
|
+
remediation="list known entries with: katvan explain katvan",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def known_paths() -> list[tuple[str, ...]]:
|
|
26
|
+
"""Return every catalog path (used by tests)."""
|
|
27
|
+
return list(ENTRIES.keys())
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Markdown catalog for ``katvan explain <path>``.
|
|
2
|
+
|
|
3
|
+
Each entry is verbatim markdown. Keys are command-path tuples. The empty
|
|
4
|
+
tuple and ``("katvan",)`` both resolve to the root entry (aliased).
|
|
5
|
+
|
|
6
|
+
Keep bodies self-contained — an agent reading a single entry should get
|
|
7
|
+
enough context without chaining reads.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
_ROOT = """\
|
|
13
|
+
# katvan
|
|
14
|
+
|
|
15
|
+
katvan maintains the docs of sibling AgentCulture repos under one roof on
|
|
16
|
+
the culture.dev Jekyll site. It surveys which siblings are synced and
|
|
17
|
+
fresh, pulls their raw-markdown `docs/` trees into `site/docs/<repo>/` with
|
|
18
|
+
Jekyll frontmatter injected, and diagnoses doc defects. It is the
|
|
19
|
+
`librarian` skill's logic, migrating into a real installable CLI.
|
|
20
|
+
|
|
21
|
+
## Verbs
|
|
22
|
+
|
|
23
|
+
- `katvan learn` — structured self-teaching prompt.
|
|
24
|
+
- `katvan explain <path>` — markdown docs for any noun/verb.
|
|
25
|
+
|
|
26
|
+
The docs verbs — `overview`, `pull`, and `doctor` — land in a later
|
|
27
|
+
release, ported from the librarian skill.
|
|
28
|
+
|
|
29
|
+
## Universal verb tier (agent-first)
|
|
30
|
+
|
|
31
|
+
katvan exposes the universal agent-first verbs:
|
|
32
|
+
|
|
33
|
+
- `learn` — what is this tool?
|
|
34
|
+
- `explain <path>` — what does this command do?
|
|
35
|
+
|
|
36
|
+
## Exit-code policy
|
|
37
|
+
|
|
38
|
+
- `0` success
|
|
39
|
+
- `1` user-input error (bad flag, bad path, missing arg)
|
|
40
|
+
- `2` environment / setup error (registry not found, unreadable file)
|
|
41
|
+
- `3+` reserved
|
|
42
|
+
|
|
43
|
+
## See also
|
|
44
|
+
|
|
45
|
+
- `katvan explain learn`
|
|
46
|
+
- `katvan explain explain`
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_LEARN = """\
|
|
50
|
+
# katvan learn
|
|
51
|
+
|
|
52
|
+
Prints a structured self-teaching prompt covering katvan's purpose, command
|
|
53
|
+
map, exit-code policy, `--json` support, and `explain` pointer.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
katvan learn
|
|
58
|
+
katvan learn --json
|
|
59
|
+
|
|
60
|
+
In JSON mode, emits
|
|
61
|
+
`{"tool", "version", "purpose", "commands", "coming_soon", "exit_codes",
|
|
62
|
+
"json_support", "explain_pointer"}` to stdout.
|
|
63
|
+
|
|
64
|
+
## Rubric role
|
|
65
|
+
|
|
66
|
+
`learn` is the learnability bundle of the agent-first rubric. Any CLI that
|
|
67
|
+
passes it prints ≥200 characters and mentions purpose, commands, exit
|
|
68
|
+
codes, `--json`, and `explain`.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
_EXPLAIN = """\
|
|
72
|
+
# katvan explain <path>
|
|
73
|
+
|
|
74
|
+
Prints markdown documentation for any noun/verb path. Unlike `--help`
|
|
75
|
+
(terse, positional), `explain` is global and addressable by path.
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
katvan explain katvan
|
|
80
|
+
katvan explain learn
|
|
81
|
+
katvan explain explain --json
|
|
82
|
+
|
|
83
|
+
In text mode emits the markdown to stdout. In JSON mode emits
|
|
84
|
+
`{"path": [...], "markdown": "..."}` to stdout.
|
|
85
|
+
|
|
86
|
+
## Path resolution
|
|
87
|
+
|
|
88
|
+
Paths are shell-tokenised: `katvan explain learn` resolves to the catalog
|
|
89
|
+
entry `("learn",)`. Unknown paths exit `1` with a `hint:` pointing at
|
|
90
|
+
`katvan explain katvan` for the top-level map.
|
|
91
|
+
|
|
92
|
+
## Rubric role
|
|
93
|
+
|
|
94
|
+
`explain` is the explain bundle of the agent-first rubric: every registered
|
|
95
|
+
noun must resolve, and bad paths must exit non-zero with remediation.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
ENTRIES: dict[tuple[str, ...], str] = {
|
|
100
|
+
(): _ROOT,
|
|
101
|
+
("katvan",): _ROOT,
|
|
102
|
+
("learn",): _LEARN,
|
|
103
|
+
("explain",): _EXPLAIN,
|
|
104
|
+
}
|
katvan/frontmatter.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Inject Jekyll frontmatter into a pulled sibling-repo doc.
|
|
2
|
+
|
|
3
|
+
Ported near-verbatim from the librarian skill's ``_frontmatter.py``. katvan's
|
|
4
|
+
``pull`` verb (landing in a later release) pipes every ``.md`` file from a
|
|
5
|
+
sibling repo's ``docs/`` tree through this module before writing it under
|
|
6
|
+
``site/docs/<repo>/``. Sibling docs are raw markdown — most have no
|
|
7
|
+
frontmatter at all — but every page on the culture.dev multi-site build
|
|
8
|
+
needs at minimum a ``sites:`` key.
|
|
9
|
+
|
|
10
|
+
Logic
|
|
11
|
+
-----
|
|
12
|
+
* Parse an existing ``---\\n...\\n---`` frontmatter block if present.
|
|
13
|
+
Minimal hand-rolled line-oriented parser — stdlib only, NO PyYAML.
|
|
14
|
+
* Merge in defaults ONLY for keys that are absent (never clobber):
|
|
15
|
+
|
|
16
|
+
- ``sites:`` → ``[culture]``
|
|
17
|
+
- ``title:`` → derived from the first ``# H1`` in the body; omitted
|
|
18
|
+
if there is no H1 (``doctor`` flags this as ``no-h1``)
|
|
19
|
+
- ``permalink:`` → ``/<repo>/<rel-path sans .md>/`` (always trailing slash)
|
|
20
|
+
- ``nav_order:`` → ``N`` if the filename has a numeric prefix like
|
|
21
|
+
``01-foo.md``, else omitted
|
|
22
|
+
|
|
23
|
+
* Emit ``---\\n<merged frontmatter>\\n---\\n<original body>``. The body is
|
|
24
|
+
preserved byte-for-byte.
|
|
25
|
+
|
|
26
|
+
The core is exposed as :func:`inject` so callers (the future ``pull`` verb)
|
|
27
|
+
can use it directly as a library function. :func:`main` keeps the
|
|
28
|
+
stdin→stdout CLI behaviour so ``python -m katvan.frontmatter`` still works.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
from typing import List
|
|
37
|
+
|
|
38
|
+
# Keys we emit in a stable order so output is deterministic and reviewable.
|
|
39
|
+
_KEY_ORDER = ["title", "sites", "permalink", "nav_order"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def split_frontmatter(text, rel_path=None):
|
|
43
|
+
"""Return ``(frontmatter_lines, body, warning)``.
|
|
44
|
+
|
|
45
|
+
``frontmatter_lines`` is the list of raw lines between the ``---`` fences
|
|
46
|
+
(empty list if there is no frontmatter block). ``body`` is everything
|
|
47
|
+
after the closing fence, byte-for-byte (or the whole input if there was
|
|
48
|
+
no block). ``warning`` is ``None`` normally, or a human-readable string
|
|
49
|
+
when a malformed opener (no closing fence) was detected — callers may
|
|
50
|
+
surface it however they like; :func:`main` writes it to stderr to
|
|
51
|
+
preserve the original script's behaviour.
|
|
52
|
+
"""
|
|
53
|
+
# A frontmatter block must start on the very first line with `---`.
|
|
54
|
+
if not text.startswith("---\n") and text != "---\n" and not text.startswith("---\r\n"):
|
|
55
|
+
return [], text, None
|
|
56
|
+
|
|
57
|
+
lines = text.split("\n")
|
|
58
|
+
# lines[0] is the opening `---`. Find the closing fence.
|
|
59
|
+
for idx in range(1, len(lines)):
|
|
60
|
+
if lines[idx].strip() == "---":
|
|
61
|
+
fm_lines = lines[1:idx]
|
|
62
|
+
# Body is everything after the closing fence. Slice it out of
|
|
63
|
+
# the original `text` by string offset rather than rejoining
|
|
64
|
+
# the split lines — rejoining would normalize CRLF to LF and
|
|
65
|
+
# break the "byte-for-byte" guarantee. `lines[:idx+1]` is the
|
|
66
|
+
# opening fence + frontmatter + closing fence; it was produced
|
|
67
|
+
# by splitting on "\n", so re-joining on "\n" and adding back
|
|
68
|
+
# the one trailing "\n" reconstructs the exact prefix length.
|
|
69
|
+
prefix = "\n".join(lines[: idx + 1]) + "\n"
|
|
70
|
+
body = text[len(prefix) :]
|
|
71
|
+
return fm_lines, body, None
|
|
72
|
+
# Opener with no closing fence — malformed. Surface a warning so the
|
|
73
|
+
# operator sees it (otherwise the malformed text would silently be
|
|
74
|
+
# treated as body and then re-wrapped in a fresh block, duplicating
|
|
75
|
+
# it), and treat the whole input as body.
|
|
76
|
+
where = rel_path if rel_path else "<stdin>"
|
|
77
|
+
warning = (
|
|
78
|
+
"katvan.frontmatter: warning: {}: frontmatter opener with no closing "
|
|
79
|
+
"fence — treating whole file as body".format(where)
|
|
80
|
+
)
|
|
81
|
+
return [], text, warning
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_frontmatter_keys(fm_lines):
|
|
85
|
+
"""Return the set of top-level keys present in the frontmatter block.
|
|
86
|
+
|
|
87
|
+
We only need to know which keys EXIST (so we never clobber them), not
|
|
88
|
+
their values — so this is a deliberately shallow parse: a line that
|
|
89
|
+
starts in column 0 with ``key:`` registers ``key``. Nested/list lines
|
|
90
|
+
(indented, or starting with ``-``) are ignored.
|
|
91
|
+
"""
|
|
92
|
+
keys = set()
|
|
93
|
+
for line in fm_lines:
|
|
94
|
+
if not line or line[0] in (" ", "\t", "-", "#"):
|
|
95
|
+
continue
|
|
96
|
+
m = re.match(r"^([A-Za-z0-9_-]+):", line)
|
|
97
|
+
if m:
|
|
98
|
+
keys.add(m.group(1))
|
|
99
|
+
return keys
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def derive_title(body):
|
|
103
|
+
"""First ``# H1`` in the body, leading ``# `` stripped. None if absent.
|
|
104
|
+
|
|
105
|
+
Pure-Python, no regex (a lazy ``(.+?)\\s*$`` regex here is ReDoS-prone —
|
|
106
|
+
SonarCloud ``python:S5852``). A line is an H1 iff it starts with ``#``,
|
|
107
|
+
the character right after the ``#`` is whitespace, and there is non-empty
|
|
108
|
+
content once stripped. ``## Foo`` and ``#Foo`` therefore do not match.
|
|
109
|
+
"""
|
|
110
|
+
for line in body.split("\n"):
|
|
111
|
+
if not line.startswith("#"):
|
|
112
|
+
continue
|
|
113
|
+
# The char right after the leading `#` must be whitespace.
|
|
114
|
+
after = line[1:2]
|
|
115
|
+
if not after or not after.isspace():
|
|
116
|
+
continue
|
|
117
|
+
title = line[1:].strip()
|
|
118
|
+
if title:
|
|
119
|
+
return title
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def derive_nav_order(rel_path):
|
|
124
|
+
"""N if the filename has a numeric prefix like ``01-foo.md``, else None.
|
|
125
|
+
|
|
126
|
+
Pure-Python, no regex (``0*(\\d+)`` has overlapping character classes and
|
|
127
|
+
is ReDoS-prone — SonarCloud ``python:S5852``). Take the leading run of
|
|
128
|
+
ASCII digits from the filename; it counts only if immediately followed by
|
|
129
|
+
``-`` or ``_``. ``int()`` handles leading zeros (``007`` -> ``7``).
|
|
130
|
+
"""
|
|
131
|
+
filename = rel_path.rsplit("/", 1)[-1]
|
|
132
|
+
i = 0
|
|
133
|
+
while i < len(filename) and filename[i] in "0123456789":
|
|
134
|
+
i += 1
|
|
135
|
+
if i == 0 or i >= len(filename) or filename[i] not in "-_":
|
|
136
|
+
return None
|
|
137
|
+
return int(filename[:i])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_permalink(repo, rel_path):
|
|
141
|
+
"""``/<repo>/<rel-path sans .md>/`` — always a trailing slash."""
|
|
142
|
+
stem = rel_path
|
|
143
|
+
if stem.endswith(".md"):
|
|
144
|
+
stem = stem[:-3]
|
|
145
|
+
stem = stem.strip("/")
|
|
146
|
+
return f"/{repo}/{stem}/"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def render_value(key, value):
|
|
150
|
+
"""Render a single frontmatter line for an injected default."""
|
|
151
|
+
if key == "sites":
|
|
152
|
+
# value is a list of site ids.
|
|
153
|
+
inner = ", ".join(value)
|
|
154
|
+
return f"sites: [{inner}]"
|
|
155
|
+
if key == "nav_order":
|
|
156
|
+
return f"nav_order: {value}"
|
|
157
|
+
# title / permalink: scalar. Quote titles that could confuse the parser.
|
|
158
|
+
if key == "title":
|
|
159
|
+
if re.search(r"[:#\[\]{}]", str(value)) or str(value) != str(value).strip():
|
|
160
|
+
return 'title: "{}"'.format(str(value).replace('"', '\\"'))
|
|
161
|
+
return f"title: {value}"
|
|
162
|
+
return f"{key}: {value}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def inject(text: str, repo: str, rel_path: str) -> tuple[str, List[str]]:
|
|
166
|
+
"""Inject Jekyll frontmatter defaults into ``text``.
|
|
167
|
+
|
|
168
|
+
Returns ``(output, warnings)`` — ``output`` is the rewritten document
|
|
169
|
+
(``---\\n<merged frontmatter>\\n---\\n<body>``), and ``warnings`` is a
|
|
170
|
+
list of human-readable warning strings (empty when nothing was amiss; a
|
|
171
|
+
malformed frontmatter opener produces one entry).
|
|
172
|
+
|
|
173
|
+
This is the core of the module, callable as a plain library function.
|
|
174
|
+
The merge is non-clobbering: an existing key is never overwritten, and
|
|
175
|
+
the original body is preserved byte-for-byte.
|
|
176
|
+
"""
|
|
177
|
+
warnings: List[str] = []
|
|
178
|
+
fm_lines, body, warning = split_frontmatter(text, rel_path)
|
|
179
|
+
if warning:
|
|
180
|
+
warnings.append(warning)
|
|
181
|
+
existing_keys = parse_frontmatter_keys(fm_lines)
|
|
182
|
+
|
|
183
|
+
# Compute the defaults we would inject.
|
|
184
|
+
injected = {}
|
|
185
|
+
if "sites" not in existing_keys:
|
|
186
|
+
injected["sites"] = ["culture"]
|
|
187
|
+
if "title" not in existing_keys:
|
|
188
|
+
title = derive_title(body)
|
|
189
|
+
if title is not None:
|
|
190
|
+
injected["title"] = title
|
|
191
|
+
# else: omit — doctor will flag `no-h1`.
|
|
192
|
+
if "permalink" not in existing_keys:
|
|
193
|
+
injected["permalink"] = build_permalink(repo, rel_path)
|
|
194
|
+
if "nav_order" not in existing_keys:
|
|
195
|
+
nav = derive_nav_order(rel_path)
|
|
196
|
+
if nav is not None:
|
|
197
|
+
injected["nav_order"] = nav
|
|
198
|
+
|
|
199
|
+
# Assemble the merged frontmatter: existing lines verbatim, then the
|
|
200
|
+
# injected defaults in stable key order.
|
|
201
|
+
merged = list(fm_lines)
|
|
202
|
+
for key in _KEY_ORDER:
|
|
203
|
+
if key in injected:
|
|
204
|
+
merged.append(render_value(key, injected[key]))
|
|
205
|
+
|
|
206
|
+
out = "---\n" + "\n".join(merged) + "\n---\n" + body
|
|
207
|
+
return out, warnings
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main(argv=None):
|
|
211
|
+
"""stdin → stdout CLI shim, preserving ``_frontmatter.py``'s behaviour."""
|
|
212
|
+
parser = argparse.ArgumentParser(
|
|
213
|
+
prog="katvan.frontmatter",
|
|
214
|
+
description="Inject Jekyll frontmatter into a pulled sibling-repo doc.",
|
|
215
|
+
)
|
|
216
|
+
parser.add_argument("--repo", required=True, help="sibling repo id, e.g. ghafi")
|
|
217
|
+
parser.add_argument(
|
|
218
|
+
"--rel-path",
|
|
219
|
+
required=True,
|
|
220
|
+
help="path of the file within the sibling's docs/ dir, e.g. guides/intro.md",
|
|
221
|
+
)
|
|
222
|
+
args = parser.parse_args(argv)
|
|
223
|
+
|
|
224
|
+
text = sys.stdin.read()
|
|
225
|
+
out, warnings = inject(text, args.repo, args.rel_path)
|
|
226
|
+
for warning in warnings:
|
|
227
|
+
sys.stderr.write(warning + "\n")
|
|
228
|
+
sys.stdout.write(out)
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
sys.exit(main())
|
katvan/repos.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Sibling-repo registry — ported from the librarian skill's ``_repos.sh``.
|
|
2
|
+
|
|
3
|
+
Parses ``site/_data/agentculture_repos.yml`` (the sibling-repo registry that
|
|
4
|
+
drives katvan's docs verbs) and exposes lookup helpers the overview / pull /
|
|
5
|
+
doctor verbs build on.
|
|
6
|
+
|
|
7
|
+
Public surface
|
|
8
|
+
--------------
|
|
9
|
+
- :func:`registry_path` — absolute path to the registry YAML.
|
|
10
|
+
- :func:`repos` — iterate ``(id, docs_mode, local_path)`` per registry entry.
|
|
11
|
+
- :func:`classify` — the ``docs_mode`` for one repo id (``unknown`` if absent).
|
|
12
|
+
- :func:`local_path` — the local checkout path for one repo id (``""`` if not
|
|
13
|
+
checked out).
|
|
14
|
+
- :func:`set_siblings_root` / :func:`siblings_root` — override / read where
|
|
15
|
+
sibling repos are expected to be checked out locally.
|
|
16
|
+
|
|
17
|
+
YAML parsing is **stdlib only** — a minimal line-oriented parser, matching
|
|
18
|
+
the hand-rolled fallback the bash helper used. PyYAML is *not* a dependency.
|
|
19
|
+
|
|
20
|
+
Path resolution differs deliberately from the bash original: ``_repos.sh``
|
|
21
|
+
hardcoded the repo root and ``/home/spark/git``. This port walks up from the
|
|
22
|
+
current working directory to find the repo root, and reads the siblings root
|
|
23
|
+
from ``$KATVAN_SIBLINGS_ROOT`` (default: the parent of the discovered repo
|
|
24
|
+
root). Callers may also override it programmatically via
|
|
25
|
+
:func:`set_siblings_root` — PR 2's verbs thread a ``--siblings-root`` flag
|
|
26
|
+
through this way.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import functools
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Iterator
|
|
37
|
+
|
|
38
|
+
from katvan.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, KatvanError
|
|
39
|
+
|
|
40
|
+
# Relative location of the registry within a katvan checkout.
|
|
41
|
+
_REGISTRY_REL = Path("site/_data/agentculture_repos.yml")
|
|
42
|
+
|
|
43
|
+
# Programmatic override for the siblings root. ``None`` means "not set" —
|
|
44
|
+
# fall back to ``$KATVAN_SIBLINGS_ROOT`` then the repo root's parent.
|
|
45
|
+
_siblings_root_override: Path | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_siblings_root(path: str | os.PathLike[str] | None) -> None:
|
|
49
|
+
"""Override where sibling repos are expected to be checked out locally.
|
|
50
|
+
|
|
51
|
+
Pass ``None`` to clear the override and fall back to
|
|
52
|
+
``$KATVAN_SIBLINGS_ROOT`` / the repo root's parent. PR 2's verbs call
|
|
53
|
+
this to honour a ``--siblings-root`` flag.
|
|
54
|
+
"""
|
|
55
|
+
global _siblings_root_override
|
|
56
|
+
_siblings_root_override = Path(path) if path is not None else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Memoization note: ``_find_repo_root`` and ``_parse_registry`` are wrapped
|
|
60
|
+
# with ``functools.lru_cache`` — the repo root and the registry contents are
|
|
61
|
+
# stable for the life of a process, and PR 2's verbs loop ``classify()`` over
|
|
62
|
+
# every repo, so without caching that would be O(n^2) file reads. The
|
|
63
|
+
# siblings-root (``siblings_root()`` / ``local_path()`` / ``set_siblings_root()``)
|
|
64
|
+
# is deliberately NOT cached: it is intentionally mutable at runtime, and
|
|
65
|
+
# nothing the caches hold depends on it, so ``set_siblings_root()`` stays
|
|
66
|
+
# effective even after the registry/root have been cached.
|
|
67
|
+
@functools.lru_cache(maxsize=None)
|
|
68
|
+
def _find_repo_root(start: Path | None = None) -> Path:
|
|
69
|
+
"""Walk up from ``start`` (default CWD) to a dir containing the registry."""
|
|
70
|
+
here = (start or Path.cwd()).resolve()
|
|
71
|
+
for candidate in (here, *here.parents):
|
|
72
|
+
if (candidate / _REGISTRY_REL).is_file():
|
|
73
|
+
return candidate
|
|
74
|
+
raise KatvanError(
|
|
75
|
+
code=EXIT_ENV_ERROR,
|
|
76
|
+
message=(f"could not find {_REGISTRY_REL} in any parent of {here}"),
|
|
77
|
+
remediation="run katvan from within a katvan checkout (the repo with site/)",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def registry_path() -> Path:
|
|
82
|
+
"""Return the absolute path to ``site/_data/agentculture_repos.yml``.
|
|
83
|
+
|
|
84
|
+
Raises :class:`KatvanError` (exit 2, environment error) if no katvan
|
|
85
|
+
checkout is found by walking up from the current working directory.
|
|
86
|
+
"""
|
|
87
|
+
return _find_repo_root() / _REGISTRY_REL
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def siblings_root() -> Path:
|
|
91
|
+
"""Return the directory where sibling repos are expected locally.
|
|
92
|
+
|
|
93
|
+
Precedence: explicit :func:`set_siblings_root` override >
|
|
94
|
+
``$KATVAN_SIBLINGS_ROOT`` > the parent directory of the discovered repo
|
|
95
|
+
root.
|
|
96
|
+
"""
|
|
97
|
+
if _siblings_root_override is not None:
|
|
98
|
+
return _siblings_root_override
|
|
99
|
+
env = os.environ.get("KATVAN_SIBLINGS_ROOT")
|
|
100
|
+
if env:
|
|
101
|
+
return Path(env)
|
|
102
|
+
return _find_repo_root().parent
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@functools.lru_cache(maxsize=None)
|
|
106
|
+
def _parse_registry(path: Path) -> tuple[tuple[str, str], ...]:
|
|
107
|
+
"""Return ``(id, docs_mode)`` for every registry entry.
|
|
108
|
+
|
|
109
|
+
Hand-rolled line-oriented parse — stdlib only, no PyYAML. Mirrors the
|
|
110
|
+
fallback parser in ``_repos.sh``: a ``- id:`` line opens an entry, a
|
|
111
|
+
following column-0 ``docs_mode:`` line sets its mode (default ``skip``).
|
|
112
|
+
|
|
113
|
+
Memoized on ``path`` — see the note above :func:`_find_repo_root`. The
|
|
114
|
+
result is a tuple (not a list) so it is hashable and safe to cache.
|
|
115
|
+
|
|
116
|
+
Raises :class:`KatvanError` if the file has non-comment content but the
|
|
117
|
+
parser still yields zero entries — a parse failure (e.g. block-style
|
|
118
|
+
YAML) that would otherwise masquerade as a legitimately empty registry.
|
|
119
|
+
A file with only comments / blank lines yields zero entries without error.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
raw = path.read_text(encoding="utf-8")
|
|
123
|
+
except OSError as err:
|
|
124
|
+
raise KatvanError(
|
|
125
|
+
code=EXIT_ENV_ERROR,
|
|
126
|
+
message=f"registry not readable: {path}: {err}",
|
|
127
|
+
remediation="check the file exists and is readable",
|
|
128
|
+
) from err
|
|
129
|
+
|
|
130
|
+
entries: list[tuple[str, str]] = []
|
|
131
|
+
has_content = False
|
|
132
|
+
cur: list[str] | None = None
|
|
133
|
+
for line in raw.splitlines():
|
|
134
|
+
stripped = line.strip()
|
|
135
|
+
if not stripped or stripped.startswith("#"):
|
|
136
|
+
continue
|
|
137
|
+
has_content = True
|
|
138
|
+
if stripped.startswith("- id:"):
|
|
139
|
+
if cur is not None:
|
|
140
|
+
entries.append((cur[0], cur[1]))
|
|
141
|
+
rid = stripped.split(":", 1)[1].strip().strip("'\"")
|
|
142
|
+
cur = [rid, "skip"]
|
|
143
|
+
elif cur is not None and stripped.startswith("docs_mode:"):
|
|
144
|
+
cur[1] = stripped.split(":", 1)[1].strip().strip("'\"")
|
|
145
|
+
if cur is not None:
|
|
146
|
+
entries.append((cur[0], cur[1]))
|
|
147
|
+
|
|
148
|
+
if not entries and has_content:
|
|
149
|
+
raise KatvanError(
|
|
150
|
+
code=EXIT_ENV_ERROR,
|
|
151
|
+
message=(
|
|
152
|
+
f"registry parsed zero entries from {path} — expected '- id:' "
|
|
153
|
+
"entries; the file may be malformed or in an unsupported YAML style"
|
|
154
|
+
),
|
|
155
|
+
remediation="check the registry uses inline '- id: <name>' entry openers",
|
|
156
|
+
)
|
|
157
|
+
return tuple(entries)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def local_path(repo_id: str) -> str:
|
|
161
|
+
"""Return the local checkout path for ``repo_id``, or ``""`` if absent.
|
|
162
|
+
|
|
163
|
+
The candidate is ``<siblings_root>/<repo_id>``; it is returned only when
|
|
164
|
+
that directory actually exists.
|
|
165
|
+
"""
|
|
166
|
+
candidate = siblings_root() / repo_id
|
|
167
|
+
return str(candidate) if candidate.is_dir() else ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def repos() -> Iterator[tuple[str, str, str]]:
|
|
171
|
+
"""Yield ``(id, docs_mode, local_path)`` for every registry entry.
|
|
172
|
+
|
|
173
|
+
``local_path`` is ``<siblings_root>/<id>`` when that directory exists,
|
|
174
|
+
else the empty string — matching ``_repos.sh``'s ``librarian_repos``.
|
|
175
|
+
"""
|
|
176
|
+
for rid, mode in _parse_registry(registry_path()):
|
|
177
|
+
if not rid:
|
|
178
|
+
continue
|
|
179
|
+
yield rid, mode, local_path(rid)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def classify(repo_id: str) -> str:
|
|
183
|
+
"""Return the ``docs_mode`` for ``repo_id``.
|
|
184
|
+
|
|
185
|
+
One of ``pull`` / ``self-published`` / ``skip`` for a registered repo, or
|
|
186
|
+
``"unknown"`` if the id is not in the registry. (The bash original also
|
|
187
|
+
signalled this via a non-zero return code; the Python port surfaces it
|
|
188
|
+
purely through the ``"unknown"`` sentinel.)
|
|
189
|
+
"""
|
|
190
|
+
for rid, mode in _parse_registry(registry_path()):
|
|
191
|
+
if rid == repo_id:
|
|
192
|
+
return mode
|
|
193
|
+
return "unknown"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main(argv: list[str] | None = None) -> int:
|
|
197
|
+
"""Tiny CLI shim so ``python -m katvan.repos`` is inspectable.
|
|
198
|
+
|
|
199
|
+
With no args, prints the ``id<TAB>docs_mode<TAB>local_path`` table — the
|
|
200
|
+
same shape ``_repos.sh``'s ``librarian_repos`` emitted.
|
|
201
|
+
"""
|
|
202
|
+
parser = argparse.ArgumentParser(
|
|
203
|
+
prog="katvan.repos",
|
|
204
|
+
description="Inspect the AgentCulture sibling-repo registry.",
|
|
205
|
+
)
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--registry-path",
|
|
208
|
+
action="store_true",
|
|
209
|
+
help="Print the absolute registry path and exit.",
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--classify",
|
|
213
|
+
metavar="REPO_ID",
|
|
214
|
+
help="Print the docs_mode for REPO_ID and exit.",
|
|
215
|
+
)
|
|
216
|
+
args = parser.parse_args(argv)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
if args.registry_path:
|
|
220
|
+
print(registry_path())
|
|
221
|
+
return 0
|
|
222
|
+
if args.classify:
|
|
223
|
+
mode = classify(args.classify)
|
|
224
|
+
print(mode)
|
|
225
|
+
# The library ``classify()`` returns the ``"unknown"`` sentinel,
|
|
226
|
+
# but the CLI shim mirrors ``_repos.sh``'s ``librarian_classify``,
|
|
227
|
+
# which exited non-zero for an unregistered repo id.
|
|
228
|
+
return EXIT_USER_ERROR if mode == "unknown" else 0
|
|
229
|
+
for rid, mode, path in repos():
|
|
230
|
+
print(f"{rid}\t{mode}\t{path}")
|
|
231
|
+
except KatvanError as err:
|
|
232
|
+
sys.stderr.write(f"error: {err.message}\n")
|
|
233
|
+
if err.remediation:
|
|
234
|
+
sys.stderr.write(f"hint: {err.remediation}\n")
|
|
235
|
+
return err.code
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__": # pragma: no cover
|
|
240
|
+
sys.exit(main())
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: katvan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Maintain sibling-repo docs under one roof on the culture.dev site.
|
|
5
|
+
Project-URL: Homepage, https://github.com/agentculture/katvan
|
|
6
|
+
Project-URL: Issues, https://github.com/agentculture/katvan/issues
|
|
7
|
+
Author: AgentCulture
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Documentation
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# katvan
|
|
19
|
+
|
|
20
|
+
Home of the **culture.dev** documentation site, and the `katvan` CLI that
|
|
21
|
+
maintains it.
|
|
22
|
+
|
|
23
|
+
The Jekyll project lives under [`site/`](site/) — config, theme, data,
|
|
24
|
+
assets, and the docs content tree. Repo-root [`docs/`](docs/) is
|
|
25
|
+
katvan's own internal documentation (skill provenance, design specs),
|
|
26
|
+
not part of the published site.
|
|
27
|
+
|
|
28
|
+
## The `katvan` CLI
|
|
29
|
+
|
|
30
|
+
`katvan` is a Python CLI that maintains the docs of sibling AgentCulture
|
|
31
|
+
repos under one roof on the culture.dev site — surveying which siblings
|
|
32
|
+
are synced and fresh, pulling their raw-markdown `docs/` trees into
|
|
33
|
+
`site/docs/<repo>/` with Jekyll frontmatter injected, and diagnosing doc
|
|
34
|
+
defects. It is the `librarian` skill's logic, migrating into a real
|
|
35
|
+
installable CLI.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv tool install katvan
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Verbs available today:
|
|
42
|
+
|
|
43
|
+
- `katvan learn` — structured self-teaching prompt for agent consumers
|
|
44
|
+
(supports `--json`).
|
|
45
|
+
- `katvan explain <path>` — markdown docs for any noun/verb path
|
|
46
|
+
(supports `--json`).
|
|
47
|
+
|
|
48
|
+
The docs verbs — `overview`, `pull`, and `doctor` — land in a later
|
|
49
|
+
release, ported from the `librarian` skill.
|
|
50
|
+
|
|
51
|
+
## Build the site locally
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cd site
|
|
55
|
+
bundle install
|
|
56
|
+
bundle exec jekyll serve --config _config.base.yml,_config.culture.yml
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
CI builds the site on every PR via `.github/workflows/docs-check.yml`.
|
|
60
|
+
|
|
61
|
+
Migration design and implementation plan:
|
|
62
|
+
`docs/superpowers/specs/2026-05-14-culture-site-migration-design.md`
|
|
63
|
+
and `docs/superpowers/plans/2026-05-14-culture-site-migration.md`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
katvan/__init__.py,sha256=sTgcOychXlxDdNO1bXghqcE4NgqusbAr2sZ6NG-uZqU,344
|
|
2
|
+
katvan/__main__.py,sha256=JPnbmd7r4F0NS9sKlaeBjm_Dkt7uoWOXc_xjr_isD28,142
|
|
3
|
+
katvan/frontmatter.py,sha256=JpBNETgBxdhmpzbjLVbPlgnbqYaSU8-T8Kbep_xNsP8,9228
|
|
4
|
+
katvan/repos.py,sha256=pKAMTs_dg6O3uf8Z6vYhlV82gyL4olD34QzDDOpaO9g,9316
|
|
5
|
+
katvan/cli/__init__.py,sha256=x2zz5S9r0n9nrQK7pi4W4w1QXSfGta2tlqnIZfcZdG0,5139
|
|
6
|
+
katvan/cli/_errors.py,sha256=7HAAM1b--20zpkNF1LyZ_8_wLvxF9GoN1oOMl2aOX28,1355
|
|
7
|
+
katvan/cli/_output.py,sha256=XSzW2GALJJ6vw1lGmwX_yb-po3xvwrEiiPMW4v5EH8o,1886
|
|
8
|
+
katvan/cli/_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
katvan/cli/_commands/explain.py,sha256=QBS41rzi0SoyC8r3u3lHYv2UJPz-GM0_u-qGMF6lqK8,1277
|
|
10
|
+
katvan/cli/_commands/learn.py,sha256=1Pgr1VdbOACVrp_WCTFeA9B178WtS7eNw4VhNYVPat4,3769
|
|
11
|
+
katvan/explain/__init__.py,sha256=lsmol6CziqWtDQ42J8Cz2cXfNGVBwx2vwDf1RpB-b1c,891
|
|
12
|
+
katvan/explain/catalog.py,sha256=JgCRk_ATL8VwQwjGH6ZjU2O8gvr0agInIUjR0BL7PWI,2849
|
|
13
|
+
katvan-0.1.0.dist-info/METADATA,sha256=RG0O54DogUXp5csNJpaB2_cQz2FnvUIQRb9PfNM5e6w,2095
|
|
14
|
+
katvan-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
katvan-0.1.0.dist-info/entry_points.txt,sha256=UrUztp0az7mAHLIxFLxUCkwh97d8JxyBmL5_tncW7ow,43
|
|
16
|
+
katvan-0.1.0.dist-info/licenses/LICENSE,sha256=wCcdPywGtFXx1P8N0j0eEDINSWfSjrIsU7ds1YZl-MA,1069
|
|
17
|
+
katvan-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentCulture
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|