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 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
@@ -0,0 +1,8 @@
1
+ """Allow running katvan as ``python -m katvan``."""
2
+
3
+ import sys
4
+
5
+ from katvan.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ katvan = katvan.cli:main
@@ -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.