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 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()
@@ -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)