dna-cli 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.
- dna_cli/__init__.py +87 -0
- dna_cli/_active_story.py +134 -0
- dna_cli/_banner.py +61 -0
- dna_cli/_ctx.py +433 -0
- dna_cli/_git_symbiosis.py +167 -0
- dna_cli/_github_bridge.py +294 -0
- dna_cli/_methodology_gates.py +164 -0
- dna_cli/data/git-hooks/prepare-commit-msg +132 -0
- dna_cli/doc_cmd.py +842 -0
- dna_cli/docs_cmd.py +79 -0
- dna_cli/eval_cmd.py +274 -0
- dna_cli/hooks_cmd.py +146 -0
- dna_cli/install_cmd.py +496 -0
- dna_cli/issue_bridge_cmd.py +305 -0
- dna_cli/kind_cmd.py +84 -0
- dna_cli/memory_cmd.py +298 -0
- dna_cli/pr_cmd.py +251 -0
- dna_cli/recall_cmd.py +161 -0
- dna_cli/research_cmd.py +284 -0
- dna_cli/scope_cmd.py +129 -0
- dna_cli/sdlc_cmd.py +6549 -0
- dna_cli/source_cmd.py +437 -0
- dna_cli/testkit_cmd.py +352 -0
- dna_cli-0.1.0.dist-info/METADATA +57 -0
- dna_cli-0.1.0.dist-info/RECORD +28 -0
- dna_cli-0.1.0.dist-info/WHEEL +4 -0
- dna_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dna_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
dna_cli/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""``dna`` — user-facing CLI for the DNA kernel.
|
|
2
|
+
|
|
3
|
+
Boots a local Kernel against ``DNA_SOURCE_URL`` / ``DNA_BASE_DIR``
|
|
4
|
+
(filesystem source) and runs one command per invocation. No service
|
|
5
|
+
required — the kernel IS the backend.
|
|
6
|
+
|
|
7
|
+
Output formatting:
|
|
8
|
+
- ``--json`` for machine-readable (default for CRUD writes).
|
|
9
|
+
- rich tables for ``list`` / ``show`` (default).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from dna_cli import (
|
|
19
|
+
doc_cmd,
|
|
20
|
+
docs_cmd,
|
|
21
|
+
eval_cmd,
|
|
22
|
+
install_cmd,
|
|
23
|
+
kind_cmd,
|
|
24
|
+
memory_cmd,
|
|
25
|
+
recall_cmd,
|
|
26
|
+
research_cmd,
|
|
27
|
+
scope_cmd,
|
|
28
|
+
sdlc_cmd,
|
|
29
|
+
source_cmd,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _BannerGroup(click.Group):
|
|
34
|
+
"""Click Group that prints the gradient banner before ``--help`` output."""
|
|
35
|
+
|
|
36
|
+
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
37
|
+
from dna_cli._banner import render_banner
|
|
38
|
+
banner = render_banner()
|
|
39
|
+
if banner:
|
|
40
|
+
formatter.write(banner)
|
|
41
|
+
formatter.write_paragraph()
|
|
42
|
+
super().format_help(ctx, formatter)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.group(
|
|
46
|
+
cls=_BannerGroup,
|
|
47
|
+
help=(
|
|
48
|
+
"DNA — declarative lifecycle + document CLI.\n\n"
|
|
49
|
+
"Boots a local kernel via DNA_SOURCE_URL / DNA_BASE_DIR "
|
|
50
|
+
"(filesystem source). Run `dna kind list` to start exploring, "
|
|
51
|
+
"`dna sdlc --help` for the lifecycle verbs."
|
|
52
|
+
),
|
|
53
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
54
|
+
)
|
|
55
|
+
@click.version_option(version="0.1.0", prog_name="dna")
|
|
56
|
+
def main() -> None:
|
|
57
|
+
"""Top-level group — subcommands attached below."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
main.add_command(kind_cmd.kind)
|
|
61
|
+
main.add_command(doc_cmd.doc)
|
|
62
|
+
main.add_command(scope_cmd.scope)
|
|
63
|
+
main.add_command(docs_cmd.docs_)
|
|
64
|
+
main.add_command(sdlc_cmd.sdlc)
|
|
65
|
+
main.add_command(research_cmd.research)
|
|
66
|
+
main.add_command(recall_cmd.recall)
|
|
67
|
+
main.add_command(recall_cmd.search)
|
|
68
|
+
main.add_command(memory_cmd.memory)
|
|
69
|
+
# Importing testkit_cmd registers `sdlc test-guide` + `sdlc test-run` on the
|
|
70
|
+
# sdlc group via its decorators (TESTS as first-class SDLC).
|
|
71
|
+
from dna_cli import testkit_cmd as _testkit_cmd # noqa: E402,F401
|
|
72
|
+
# Importing hooks_cmd registers `sdlc hooks install|uninstall|status` — the
|
|
73
|
+
# git↔SDLC symbiosis wiring (Work-Item trailers via prepare-commit-msg).
|
|
74
|
+
from dna_cli import hooks_cmd as _hooks_cmd # noqa: E402,F401
|
|
75
|
+
# Importing pr_cmd registers `sdlc story pr` + `sdlc pr-footer` — the PR
|
|
76
|
+
# half of the symbiosis (attribution footer, PR born from the Story).
|
|
77
|
+
from dna_cli import pr_cmd as _pr_cmd # noqa: E402,F401
|
|
78
|
+
# Importing issue_bridge_cmd registers `sdlc issue publish|import|sync` —
|
|
79
|
+
# the GitHub Issues side of the symbiosis (bridge with provenance).
|
|
80
|
+
from dna_cli import issue_bridge_cmd as _issue_bridge_cmd # noqa: E402,F401
|
|
81
|
+
main.add_command(source_cmd.source)
|
|
82
|
+
main.add_command(install_cmd.install)
|
|
83
|
+
main.add_command(eval_cmd.eval_)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
main()
|
dna_cli/_active_story.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Active-Story pointer — `.dna/active-story.txt` at the repo root.
|
|
2
|
+
|
|
3
|
+
Written by ``dna sdlc story start <name>`` so that out-of-band tools
|
|
4
|
+
(Claude Code hooks, IDE plugins, dashboards) can attribute their
|
|
5
|
+
events to the Story the human-or-agent is currently working on.
|
|
6
|
+
|
|
7
|
+
File format (single line, plain text)::
|
|
8
|
+
|
|
9
|
+
<scope>:<story-name>
|
|
10
|
+
|
|
11
|
+
Examples::
|
|
12
|
+
|
|
13
|
+
dna-development:s-foo
|
|
14
|
+
hr-screening:s-bar
|
|
15
|
+
|
|
16
|
+
A trailing newline is optional. The file is gitignored — it is per-
|
|
17
|
+
workstation state, not source-of-truth, so a fresh clone has no
|
|
18
|
+
active Story until ``story start`` is invoked.
|
|
19
|
+
|
|
20
|
+
Discovery walks up from ``cwd`` looking for ``.git`` to anchor the
|
|
21
|
+
``.dna/`` directory; this matches the convention used by source
|
|
22
|
+
replicas (``.dna-replicas.yaml`` discovery in ``source_replicas.py``).
|
|
23
|
+
Override via ``DNA_ACTIVE_STORY_PATH`` for tests.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
_ENV = "DNA_ACTIVE_STORY_PATH"
|
|
31
|
+
_FILENAME = ".dna/active-story.txt"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _find_repo_root(start: Path | None = None) -> Path | None:
|
|
35
|
+
"""Walk up from ``start`` looking for ``.git`` (file or dir).
|
|
36
|
+
|
|
37
|
+
Returns ``None`` when no repo root is found. Symmetric with the
|
|
38
|
+
discovery used in ``source_replicas.py``.
|
|
39
|
+
"""
|
|
40
|
+
cur = (start or Path.cwd()).resolve()
|
|
41
|
+
for parent in (cur, *cur.parents):
|
|
42
|
+
if (parent / ".git").exists():
|
|
43
|
+
return parent
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_active_story_path(*, start: Path | None = None) -> Path:
|
|
48
|
+
"""Resolve the canonical pointer path. Order of precedence:
|
|
49
|
+
|
|
50
|
+
1. ``DNA_ACTIVE_STORY_PATH`` env var (tests + advanced setups)
|
|
51
|
+
2. ``<repo-root>/.dna/active-story.txt`` if walking up finds ``.git``
|
|
52
|
+
3. ``<cwd>/.dna/active-story.txt`` as fallback (loose-tree mode)
|
|
53
|
+
"""
|
|
54
|
+
env = os.environ.get(_ENV)
|
|
55
|
+
if env:
|
|
56
|
+
return Path(env)
|
|
57
|
+
root = _find_repo_root(start)
|
|
58
|
+
if root is not None:
|
|
59
|
+
return root / _FILENAME
|
|
60
|
+
return (start or Path.cwd()).resolve() / _FILENAME
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def write_active_story(scope: str, name: str, *, start: Path | None = None) -> Path:
|
|
64
|
+
"""Stamp ``<scope>:<name>`` to the pointer file. Creates the
|
|
65
|
+
parent directory if missing. Returns the resolved path. Atomic
|
|
66
|
+
via tmpfile + rename.
|
|
67
|
+
"""
|
|
68
|
+
if not scope or not name:
|
|
69
|
+
raise ValueError("scope and name must both be non-empty")
|
|
70
|
+
if ":" in scope or ":" in name:
|
|
71
|
+
# Reserve `:` as the field separator. Story slugs use `s-` /
|
|
72
|
+
# `f-` / `e-` prefixes by convention so this never collides
|
|
73
|
+
# in practice; defensive guard just in case.
|
|
74
|
+
raise ValueError("scope/name must not contain ':'")
|
|
75
|
+
path = get_active_story_path(start=start)
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
78
|
+
tmp.write_text(f"{scope}:{name}\n", encoding="utf-8")
|
|
79
|
+
tmp.replace(path)
|
|
80
|
+
return path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_active_story(
|
|
84
|
+
*, start: Path | None = None
|
|
85
|
+
) -> tuple[str, str] | None:
|
|
86
|
+
"""Read the pointer; returns ``(scope, name)`` or ``None`` when
|
|
87
|
+
absent / empty / malformed. Non-fatal — callers (hooks) treat
|
|
88
|
+
``None`` as "no active story; broadcast as ephemeral".
|
|
89
|
+
"""
|
|
90
|
+
path = get_active_story_path(start=start)
|
|
91
|
+
try:
|
|
92
|
+
raw = path.read_text(encoding="utf-8").strip()
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return None
|
|
95
|
+
except OSError:
|
|
96
|
+
return None
|
|
97
|
+
if not raw:
|
|
98
|
+
return None
|
|
99
|
+
if ":" not in raw:
|
|
100
|
+
return None
|
|
101
|
+
scope, _, name = raw.partition(":")
|
|
102
|
+
scope = scope.strip()
|
|
103
|
+
name = name.strip()
|
|
104
|
+
if not scope or not name:
|
|
105
|
+
return None
|
|
106
|
+
return scope, name
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def clear_active_story(*, start: Path | None = None) -> bool:
|
|
110
|
+
"""Remove the pointer file. Returns True if a file was removed,
|
|
111
|
+
False if it was already absent. Idempotent.
|
|
112
|
+
"""
|
|
113
|
+
path = get_active_story_path(start=start)
|
|
114
|
+
try:
|
|
115
|
+
path.unlink()
|
|
116
|
+
return True
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def clear_if_matches(
|
|
122
|
+
scope: str, name: str, *, start: Path | None = None
|
|
123
|
+
) -> bool:
|
|
124
|
+
"""Clear the pointer ONLY when it currently points at the given
|
|
125
|
+
Story. Used by ``dna sdlc story done|block|cancel`` so that
|
|
126
|
+
closing some other Story (not the active one) doesn't blank the
|
|
127
|
+
pointer. Returns True when cleared.
|
|
128
|
+
"""
|
|
129
|
+
cur = read_active_story(start=start)
|
|
130
|
+
if cur is None:
|
|
131
|
+
return False
|
|
132
|
+
if cur != (scope, name):
|
|
133
|
+
return False
|
|
134
|
+
return clear_active_story(start=start)
|
dna_cli/_banner.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""ASCII banner with cobre/orange gradient, shown on ``dna --help``.
|
|
2
|
+
|
|
3
|
+
Inspired by ``claude-code-templates`` ``cct`` banner. Renders only when
|
|
4
|
+
stdout is a TTY (skip in pipes / CI). Set ``DNA_NO_BANNER=1`` to disable.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
# Cobre → laranja → pêssego gradient (same palette aitmpl.com uses).
|
|
16
|
+
_GRADIENT = ["#EA580C", "#F97316", "#FB923C", "#FDBA74", "#FED7AA", "#FFE7CC"]
|
|
17
|
+
|
|
18
|
+
# Compact 5-line block. Stencil-ish (block characters) for a "Claude Code SDK"
|
|
19
|
+
# feel without being noisy. Width ~50 cols → fits any terminal.
|
|
20
|
+
_LOGO = r"""
|
|
21
|
+
█████ █████ ██████
|
|
22
|
+
██ ██ ██ ██ ██ ██
|
|
23
|
+
███████ ███████ ██████
|
|
24
|
+
██ ██ ██ ██ ██
|
|
25
|
+
██ ██ ██ ██ ██ declarative agent platform
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _gradient_text(line: str) -> Text:
|
|
30
|
+
"""Apply per-character color gradient across visible glyphs."""
|
|
31
|
+
t = Text()
|
|
32
|
+
n = len(_GRADIENT)
|
|
33
|
+
for i, ch in enumerate(line):
|
|
34
|
+
if ch in (" ", "\n", "\t"):
|
|
35
|
+
t.append(ch)
|
|
36
|
+
else:
|
|
37
|
+
t.append(ch, style=_GRADIENT[i % n])
|
|
38
|
+
return t
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_banner() -> str:
|
|
42
|
+
"""Return colorized banner string, or empty if banner suppressed."""
|
|
43
|
+
if os.environ.get("DNA_NO_BANNER") == "1":
|
|
44
|
+
return ""
|
|
45
|
+
if not sys.stdout.isatty():
|
|
46
|
+
return ""
|
|
47
|
+
lines = _LOGO.strip("\n").splitlines()
|
|
48
|
+
console = Console(file=sys.stdout, force_terminal=True)
|
|
49
|
+
# Build the colored block via Rich's capture so we can return it as a string
|
|
50
|
+
# (Click's HelpFormatter writes strings, not Rich objects).
|
|
51
|
+
with console.capture() as cap:
|
|
52
|
+
for line in lines:
|
|
53
|
+
console.print(_gradient_text(line))
|
|
54
|
+
return cap.get()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def print_banner() -> None:
|
|
58
|
+
"""Print banner to stdout when appropriate."""
|
|
59
|
+
out = render_banner()
|
|
60
|
+
if out:
|
|
61
|
+
click.echo(out)
|