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/docs_cmd.py ADDED
@@ -0,0 +1,79 @@
1
+ """``dna docs`` — browse the in-product Doc corpus.
2
+
3
+ Reads from the ``docs`` scope cross-scope (same as
4
+ ``manifest_tools.docs.list_docs`` under the hood). The CLI verb
5
+ remains ``dna docs`` because that's the natural UX phrasing — the
6
+ underlying Kind is ``Doc``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import click
11
+
12
+ from dna_cli._ctx import fail, get_holder, print_json, print_table
13
+
14
+ DOCS_SCOPE = "docs"
15
+ DOC_KIND = "Doc"
16
+
17
+
18
+ @click.group(name="docs", help="Browse the in-product Doc corpus.")
19
+ def docs_() -> None:
20
+ """Group root.
21
+
22
+ Phase 16 — renamed from ``dna help`` to ``dna docs``. The trailing
23
+ underscore on the function name avoids clashing with the ``docs``
24
+ builtin module name in some toolchains.
25
+ """
26
+
27
+
28
+ @docs_.command("list")
29
+ @click.option("--json", "as_json", is_flag=True)
30
+ @click.option("--locale", default="pt-BR", show_default=True)
31
+ def list_docs(as_json: bool, locale: str) -> None:
32
+ """List all docs (sidebar metadata)."""
33
+ holder = get_holder()
34
+ try:
35
+ mi = holder.kernel.instance(DOCS_SCOPE)
36
+ except Exception as e:
37
+ raise fail(f"docs scope unreachable: {e}")
38
+ _ = mi # kept for future mi-based ops; current path uses kernel directly
39
+ rows = []
40
+ for d in holder.kernel.query_list_sync(DOCS_SCOPE, DOC_KIND):
41
+ loc = d.spec.get("locale") or "pt-BR"
42
+ if loc != locale:
43
+ continue
44
+ rows.append(
45
+ {
46
+ "name": d.name,
47
+ "icon": d.spec.get("icon", ""),
48
+ "title": d.metadata.get("description") or d.name,
49
+ "order": d.spec.get("order", 999),
50
+ "kind_of": d.spec.get("kind_of") or "",
51
+ "category": d.spec.get("category") or "",
52
+ }
53
+ )
54
+ rows.sort(key=lambda r: r["order"])
55
+ if as_json:
56
+ print_json(rows)
57
+ else:
58
+ print_table(rows, ["order", "icon", "name", "title", "kind_of", "category"])
59
+
60
+
61
+ @docs_.command("show")
62
+ @click.argument("doc_name")
63
+ @click.option("--locale", default="pt-BR")
64
+ def show(doc_name: str, locale: str) -> None:
65
+ """Print the full markdown body of a doc."""
66
+ holder = get_holder()
67
+ _mi = holder.kernel.instance(DOCS_SCOPE)
68
+ candidates = [
69
+ d for d in holder.kernel.query_list_sync(DOCS_SCOPE, DOC_KIND)
70
+ if d.name == doc_name
71
+ ]
72
+ if not candidates:
73
+ raise fail(f"Doc '{doc_name}' not found.")
74
+ chosen = next(
75
+ (c for c in candidates if (c.spec.get("locale") or "pt-BR") == locale),
76
+ candidates[0],
77
+ )
78
+ body = chosen.spec.get("body") or ""
79
+ click.echo(body)
dna_cli/eval_cmd.py ADDED
@@ -0,0 +1,274 @@
1
+ """``dna eval`` — run EvalSuites locally, offline, deterministically.
2
+
3
+ The runner is the SDK's pure library (``dna.extensions.eval.runner``):
4
+ the default target is the KERNEL ITSELF — a case with
5
+ ``target: {type: prompt, agent: X}`` composes ``build_prompt(agent=X)``
6
+ and applies the case's checks (contains/regex/equals/length) to the
7
+ composed prompt. "Does my agent compose what I expect?" is a real
8
+ evaluation of declarative config, with zero LLM and zero network.
9
+ LLM/live targets are host-registered ``EvalTargetPort``s (see
10
+ docs/guides/evaluating-agents.md) — this CLI ships only the
11
+ deterministic built-in.
12
+
13
+ Commands:
14
+ dna eval run <suite> [--scope] [--save] [--baseline <name>] [--json]
15
+ dna eval list [--scope] — suites + saved runs
16
+ dna eval show <run> [--scope] — one run, per-case detail
17
+ dna eval pin <run> [--name] [--label] — pin a run as the baseline
18
+
19
+ Exit codes (CI-friendly):
20
+ ``run`` → 1 when any case failed/errored
21
+ ``run --baseline <b>`` → 1 when the run REGRESSES vs the baseline
22
+ (pre-existing failures don't re-fail CI)
23
+
24
+ Kernel-bound: boots a local kernel against ``DNA_SOURCE_URL`` /
25
+ ``DNA_BASE_DIR`` (filesystem source, default ``./.dna``). No service.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from datetime import datetime, timezone
30
+ from typing import Any
31
+
32
+ import click
33
+
34
+ from dna_cli._ctx import dna_session, fail, print_json, print_table
35
+
36
+ EVAL_API_VERSION = "github.com/ruinosus/dna/eval/v1"
37
+
38
+ _STATUS_GLYPH = {"passed": "✓", "failed": "✗", "error": "!", "skipped": "-"}
39
+
40
+
41
+ def _spec_of(doc: Any) -> dict:
42
+ spec = getattr(doc, "spec", None)
43
+ if not isinstance(spec, dict):
44
+ spec = dict(spec) if spec else {}
45
+ return spec
46
+
47
+
48
+ def _now() -> str:
49
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
50
+
51
+
52
+ @click.group(name="eval")
53
+ def eval_() -> None:
54
+ """Run EvalSuites locally (offline, deterministic) and compare runs
55
+ against a pinned EvalBaseline."""
56
+
57
+
58
+ # ── run ─────────────────────────────────────────────────────────────────────
59
+
60
+
61
+ def _print_run(spec: dict) -> None:
62
+ for r in spec.get("results") or []:
63
+ glyph = _STATUS_GLYPH.get(str(r.get("status")), "?")
64
+ line = f" {glyph} {r.get('case')} [{r.get('status')}]"
65
+ click.echo(line)
66
+ if r.get("status") == "failed":
67
+ for c in r.get("checks") or []:
68
+ if not c.get("passed"):
69
+ click.echo(f" · {c.get('type')}: {c.get('detail', '')}")
70
+ elif r.get("status") == "error":
71
+ click.echo(f" · {r.get('error', '')}")
72
+ click.echo(
73
+ f"{spec.get('passed', 0)} passed · {spec.get('failed', 0)} failed · "
74
+ f"{spec.get('errored', 0)} errored · {spec.get('skipped', 0)} skipped "
75
+ f"(total {spec.get('total', 0)})"
76
+ )
77
+
78
+
79
+ @eval_.command("run")
80
+ @click.argument("suite")
81
+ @click.option("--scope", default=None, help="Scope to run in (default: resolved from the source).")
82
+ @click.option("--save", is_flag=True, help="Persist the result as an EvalRun document.")
83
+ @click.option("--baseline", default=None, metavar="NAME",
84
+ help="Compare against the EvalBaseline document NAME; exit 1 on regressions.")
85
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
86
+ def cmd_run(suite: str, scope: str | None, save: bool, baseline: str | None, as_json: bool):
87
+ """Execute SUITE offline and report per-case results.
88
+
89
+ Without ``--baseline`` the exit code reflects the run itself (1 when
90
+ any case failed/errored). With ``--baseline`` it reflects the DIFF:
91
+ only a regression (a case the baseline passed, now failing) exits 1.
92
+ """
93
+ from dna.extensions.eval import compare, run_suite
94
+
95
+ with dna_session(scope) as s:
96
+ try:
97
+ raw = run_suite(s.kernel, s.scope, suite)
98
+ except ValueError as exc:
99
+ raise fail(str(exc))
100
+ spec = raw["spec"]
101
+ run_name = raw["metadata"]["name"]
102
+
103
+ diff = None
104
+ baseline_run = None
105
+ if baseline:
106
+ base_doc = s.get_doc("EvalBaseline", baseline)
107
+ if base_doc is None:
108
+ raise fail(
109
+ f"EvalBaseline '{baseline}' not found in scope '{s.scope}' "
110
+ f"— pin one with `dna eval pin <run>`."
111
+ )
112
+ baseline_run = str(_spec_of(base_doc).get("run_name") or "")
113
+ base_run_doc = s.get_doc("EvalRun", baseline_run)
114
+ if base_run_doc is None:
115
+ raise fail(
116
+ f"EvalBaseline '{baseline}' points at EvalRun "
117
+ f"'{baseline_run}', which does not exist."
118
+ )
119
+ diff = compare(spec, _spec_of(base_run_doc))
120
+
121
+ if save:
122
+ s.run(s.kernel.write_document(s.scope, "EvalRun", run_name, raw))
123
+
124
+ if as_json:
125
+ payload: dict[str, Any] = {"run": raw, "saved": save}
126
+ if diff is not None:
127
+ payload["baseline"] = {"name": baseline, "run_name": baseline_run}
128
+ payload["compare"] = diff
129
+ print_json(payload)
130
+ else:
131
+ click.echo(f"suite: {suite} (scope {s.scope})")
132
+ _print_run(spec)
133
+ if save:
134
+ click.echo(f"saved: EvalRun/{run_name}")
135
+ if diff is not None:
136
+ click.echo(
137
+ f"vs baseline {baseline} ({baseline_run}): "
138
+ f"{len(diff['regressions'])} regression(s) · "
139
+ f"{len(diff['improvements'])} improvement(s) · "
140
+ f"{len(diff['unchanged'])} unchanged"
141
+ )
142
+ for case in diff["regressions"]:
143
+ click.echo(f" ✗ REGRESSION: {case}")
144
+ for case in diff["improvements"]:
145
+ click.echo(f" ✓ improved: {case}")
146
+
147
+ if diff is not None:
148
+ if diff["has_regressions"]:
149
+ raise SystemExit(1)
150
+ elif int(spec.get("failed", 0)) + int(spec.get("errored", 0)) > 0:
151
+ raise SystemExit(1)
152
+
153
+
154
+ # ── list ────────────────────────────────────────────────────────────────────
155
+
156
+
157
+ @eval_.command("list")
158
+ @click.option("--scope", default=None, help="Scope to list (default: resolved from the source).")
159
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
160
+ def cmd_list(scope: str | None, as_json: bool):
161
+ """List EvalSuites, saved EvalRuns and pinned EvalBaselines."""
162
+ with dna_session(scope) as s:
163
+ suites = s.query_list("EvalSuite")
164
+ runs = s.query_list("EvalRun")
165
+ baselines = s.query_list("EvalBaseline")
166
+
167
+ if as_json:
168
+ print_json({
169
+ "scope": s.scope,
170
+ "suites": [
171
+ {"name": d.name, **{k: _spec_of(d).get(k) for k in ("description",)},
172
+ "cases": len(_spec_of(d).get("cases") or [])}
173
+ for d in suites
174
+ ],
175
+ "runs": [
176
+ {"name": d.name, "suite": _spec_of(d).get("suite"),
177
+ "passed": _spec_of(d).get("passed"), "failed": _spec_of(d).get("failed"),
178
+ "total": _spec_of(d).get("total"),
179
+ "finished_at": _spec_of(d).get("finished_at")}
180
+ for d in runs
181
+ ],
182
+ "baselines": [
183
+ {"name": d.name, "suite": _spec_of(d).get("suite"),
184
+ "run_name": _spec_of(d).get("run_name")}
185
+ for d in baselines
186
+ ],
187
+ })
188
+ return
189
+
190
+ click.echo(f"scope: {s.scope}")
191
+ click.echo("\nsuites:")
192
+ print_table(
193
+ [{"name": d.name, "cases": len(_spec_of(d).get("cases") or []) or "all",
194
+ "description": (_spec_of(d).get("description") or "")[:60]}
195
+ for d in suites],
196
+ ["name", "cases", "description"],
197
+ )
198
+ click.echo("\nruns:")
199
+ print_table(
200
+ [{"name": d.name, "suite": _spec_of(d).get("suite", ""),
201
+ "result": f"{_spec_of(d).get('passed', 0)}/{_spec_of(d).get('total', 0)} passed",
202
+ "finished_at": _spec_of(d).get("finished_at", "")}
203
+ for d in runs],
204
+ ["name", "suite", "result", "finished_at"],
205
+ )
206
+ click.echo("\nbaselines:")
207
+ print_table(
208
+ [{"name": d.name, "suite": _spec_of(d).get("suite", ""),
209
+ "run_name": _spec_of(d).get("run_name", "")}
210
+ for d in baselines],
211
+ ["name", "suite", "run_name"],
212
+ )
213
+
214
+
215
+ # ── show ────────────────────────────────────────────────────────────────────
216
+
217
+
218
+ @eval_.command("show")
219
+ @click.argument("run_name")
220
+ @click.option("--scope", default=None, help="Scope to read (default: resolved from the source).")
221
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
222
+ def cmd_show(run_name: str, scope: str | None, as_json: bool):
223
+ """Show one saved EvalRun with per-case detail."""
224
+ with dna_session(scope) as s:
225
+ doc = s.get_doc("EvalRun", run_name)
226
+ if doc is None:
227
+ raise fail(f"EvalRun '{run_name}' not found in scope '{s.scope}'.")
228
+ spec = _spec_of(doc)
229
+ if as_json:
230
+ print_json({"name": run_name, "spec": spec})
231
+ return
232
+ click.echo(f"EvalRun/{run_name} (suite {spec.get('suite')}, scope {s.scope})")
233
+ click.echo(f"started {spec.get('started_at')} · finished {spec.get('finished_at')}")
234
+ _print_run(spec)
235
+
236
+
237
+ # ── pin ─────────────────────────────────────────────────────────────────────
238
+
239
+
240
+ @eval_.command("pin")
241
+ @click.argument("run_name")
242
+ @click.option("--scope", default=None, help="Scope to write in (default: resolved from the source).")
243
+ @click.option("--name", "baseline_name", default=None,
244
+ help="Baseline document name (default: baseline-<suite>).")
245
+ @click.option("--label", default=None, help="Why this run is the reference.")
246
+ def cmd_pin(run_name: str, scope: str | None, baseline_name: str | None, label: str | None):
247
+ """Pin RUN_NAME as the EvalBaseline for its suite.
248
+
249
+ Future ``dna eval run <suite> --baseline <name>`` executions are
250
+ compared against the pinned run (regressions exit non-zero)."""
251
+ with dna_session(scope) as s:
252
+ run_doc = s.get_doc("EvalRun", run_name)
253
+ if run_doc is None:
254
+ raise fail(
255
+ f"EvalRun '{run_name}' not found in scope '{s.scope}' "
256
+ f"— run with --save first."
257
+ )
258
+ suite = str(_spec_of(run_doc).get("suite") or "")
259
+ name = baseline_name or f"baseline-{suite}"
260
+ spec: dict[str, Any] = {
261
+ "suite": suite,
262
+ "run_name": run_name,
263
+ "pinned_at": _now(),
264
+ }
265
+ if label:
266
+ spec["label"] = label
267
+ raw = {
268
+ "apiVersion": EVAL_API_VERSION,
269
+ "kind": "EvalBaseline",
270
+ "metadata": {"name": name},
271
+ "spec": spec,
272
+ }
273
+ s.run(s.kernel.write_document(s.scope, "EvalBaseline", name, raw))
274
+ click.echo(f"pinned: EvalBaseline/{name} → EvalRun/{run_name} (suite {suite})")
dna_cli/hooks_cmd.py ADDED
@@ -0,0 +1,146 @@
1
+ """``dna sdlc hooks`` — install/uninstall/status of the git↔SDLC hook.
2
+
3
+ One command wires the symbiosis into a clone::
4
+
5
+ dna sdlc hooks install # git config core.hooksPath scripts/git-hooks
6
+ dna sdlc hooks status # what's wired + active story + coauthor
7
+ dna sdlc hooks uninstall # git config --unset core.hooksPath
8
+
9
+ ``install`` points ``core.hooksPath`` at the repo-versioned hooks dir
10
+ (``scripts/git-hooks/``). NOTE: that directory becomes the clone's ONLY
11
+ hooks dir — git stops looking at ``.git/hooks`` entirely. If you keep
12
+ personal hooks there, move them into ``scripts/git-hooks/`` (they are
13
+ runnable from a versioned dir like any other) or skip the installer and
14
+ wire the ``prepare-commit-msg`` script by hand.
15
+
16
+ When ``scripts/git-hooks/prepare-commit-msg`` doesn't exist yet (e.g. a
17
+ project other than the DNA repo adopting the convention), ``install``
18
+ materializes it from the copy packaged inside dna-cli.
19
+
20
+ Registered onto the ``sdlc`` group at import time (same pattern as
21
+ ``testkit_cmd``) — see ``dna_cli/__init__.py``.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import stat
26
+ from pathlib import Path
27
+
28
+ import click
29
+
30
+ from dna_cli import _git_symbiosis as gs
31
+ from dna_cli._active_story import read_active_story
32
+ from dna_cli._ctx import fail
33
+ from dna_cli.sdlc_cmd import sdlc
34
+
35
+
36
+ def _hooks_path_config(root: Path) -> str | None:
37
+ out = gs._run_git(["config", "--get", "core.hooksPath"], cwd=root)
38
+ if out is None:
39
+ return None
40
+ return out.strip() or None
41
+
42
+
43
+ def _require_repo_root() -> Path:
44
+ root = gs.repo_root()
45
+ if root is None:
46
+ raise fail("not inside a git repository (git rev-parse --show-toplevel failed)")
47
+ return root
48
+
49
+
50
+ def _ensure_hook_file(root: Path) -> tuple[Path, bool]:
51
+ """Make sure ``scripts/git-hooks/prepare-commit-msg`` exists + is
52
+ executable. Returns ``(path, created)``."""
53
+ hook = root / gs.HOOKS_DIR / gs.HOOK_NAME
54
+ created = False
55
+ if not hook.exists():
56
+ src = gs.hook_source_path()
57
+ hook.parent.mkdir(parents=True, exist_ok=True)
58
+ hook.write_bytes(src.read_bytes())
59
+ created = True
60
+ mode = hook.stat().st_mode
61
+ if not mode & stat.S_IXUSR:
62
+ hook.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
63
+ return hook, created
64
+
65
+
66
+ @sdlc.group("hooks")
67
+ def hooks_group() -> None:
68
+ """Git hooks that close the git↔SDLC loop (Work-Item trailers)."""
69
+
70
+
71
+ @hooks_group.command("install")
72
+ def cmd_hooks_install() -> None:
73
+ """Wire the repo's versioned hooks dir into this clone.
74
+
75
+ Sets ``git config core.hooksPath scripts/git-hooks`` — from then on
76
+ every ``git commit`` runs the versioned ``prepare-commit-msg``, which
77
+ stamps ``Work-Item:`` + the dna-sdlc[bot] provenance trailer whenever
78
+ a Story is active (``dna sdlc story start``).
79
+ """
80
+ root = _require_repo_root()
81
+ hook, created = _ensure_hook_file(root)
82
+ current = _hooks_path_config(root)
83
+ if current not in (None, gs.HOOKS_DIR):
84
+ raise fail(
85
+ f"core.hooksPath is already set to '{current}' — refusing to overwrite. "
86
+ f"Unset it first (git config --unset core.hooksPath) or merge your hooks "
87
+ f"into {gs.HOOKS_DIR}/."
88
+ )
89
+ if gs._run_git(["config", "core.hooksPath", gs.HOOKS_DIR], cwd=root) is None:
90
+ raise fail("git config core.hooksPath failed")
91
+ if created:
92
+ click.secho(f" created {gs.HOOKS_DIR}/{gs.HOOK_NAME} (from dna-cli packaged copy)", fg="cyan")
93
+ click.secho(f"INSTALLED — core.hooksPath = {gs.HOOKS_DIR}", fg="green")
94
+ click.echo(
95
+ f" note: {gs.HOOKS_DIR}/ is now this clone's ONLY hooks dir "
96
+ f"(.git/hooks is no longer consulted)."
97
+ )
98
+ click.echo(
99
+ " commits made with an active story (dna sdlc story start) now get "
100
+ f"'{gs.WORK_ITEM_TRAILER}:' + '{gs.COAUTHOR_TRAILER}: {gs.sdlc_coauthor()}'."
101
+ )
102
+
103
+
104
+ @hooks_group.command("uninstall")
105
+ def cmd_hooks_uninstall() -> None:
106
+ """Remove the ``core.hooksPath`` wiring (git falls back to .git/hooks)."""
107
+ root = _require_repo_root()
108
+ current = _hooks_path_config(root)
109
+ if current is None:
110
+ click.secho("core.hooksPath not set — nothing to uninstall", fg="yellow")
111
+ return
112
+ if current != gs.HOOKS_DIR:
113
+ raise fail(
114
+ f"core.hooksPath points at '{current}' (not '{gs.HOOKS_DIR}') — "
115
+ f"not ours to remove; unset it manually if intended."
116
+ )
117
+ if gs._run_git(["config", "--unset", "core.hooksPath"], cwd=root) is None:
118
+ raise fail("git config --unset core.hooksPath failed")
119
+ click.secho("UNINSTALLED — core.hooksPath unset (git uses .git/hooks again)", fg="green")
120
+
121
+
122
+ @hooks_group.command("status")
123
+ def cmd_hooks_status() -> None:
124
+ """Show the symbiosis wiring: hooksPath, hook file, active story, coauthor."""
125
+ root = _require_repo_root()
126
+ current = _hooks_path_config(root)
127
+ hook = root / gs.HOOKS_DIR / gs.HOOK_NAME
128
+ installed = current == gs.HOOKS_DIR
129
+ click.secho("git↔SDLC symbiosis", fg="cyan", bold=True)
130
+ click.echo(f" repo: {root}")
131
+ click.echo(f" core.hooksPath: {current or '(not set)'}"
132
+ + ("" if installed else f" → run `dna sdlc hooks install`"))
133
+ if hook.exists():
134
+ exec_ok = bool(hook.stat().st_mode & stat.S_IXUSR)
135
+ click.echo(f" hook file: {gs.HOOKS_DIR}/{gs.HOOK_NAME}"
136
+ + ("" if exec_ok else " (NOT executable!)"))
137
+ else:
138
+ click.echo(f" hook file: MISSING ({gs.HOOKS_DIR}/{gs.HOOK_NAME})")
139
+ active = read_active_story(start=root)
140
+ if active:
141
+ click.echo(f" active story: {active[0]}:{active[1]} → commits get "
142
+ f"'{gs.WORK_ITEM_TRAILER}: Story/{active[1]}'")
143
+ else:
144
+ click.echo(" active story: (none — commits are not stamped)")
145
+ click.echo(f" coauthor: {gs.sdlc_coauthor()}"
146
+ + (f" (via ${gs.COAUTHOR_ENV})" if gs.sdlc_coauthor() != gs.DEFAULT_SDLC_COAUTHOR else ""))