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/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 ""))
|