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/pr_cmd.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""``dna sdlc story pr`` + ``dna sdlc pr-footer`` — PR attribution.
|
|
2
|
+
|
|
3
|
+
The PR-side half of the git↔SDLC symbiosis (s-sdlc-pr-attribution): the
|
|
4
|
+
same way Claude Code signs the PRs it generates, DNA signs the PRs born
|
|
5
|
+
from its Stories. ``dna sdlc story pr <s-x>`` assembles ``gh pr create``
|
|
6
|
+
entirely from the Story document — the PR is born FROM the story, not
|
|
7
|
+
the other way around:
|
|
8
|
+
|
|
9
|
+
- **title** — ``feat(<label-hint>): <story title> (<s-x>)``. Conventional-
|
|
10
|
+
commit shaped: Stories ship features by convention (bugs are Issues),
|
|
11
|
+
the scope hint is the story's first label (fallback ``sdlc``), and the
|
|
12
|
+
slug rides along so the PR title alone is ⌘K-pasteable back to the
|
|
13
|
+
work item.
|
|
14
|
+
- **body** — the story description, the acceptance criteria as a GitHub
|
|
15
|
+
task-list checklist (``- [ ] …``, pre-checked when the AC item carries
|
|
16
|
+
``done: true``), and the attribution footer after a ``---`` rule::
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
🧬 Tracked with [DNA SDLC](https://github.com/ruinosus/dna) — Work-Item: Story/<s-x>
|
|
20
|
+
|
|
21
|
+
Footer template + ``$DNA_SDLC_PR_FOOTER`` override live in
|
|
22
|
+
``_git_symbiosis.py`` next to the commit-trailer constants — one home
|
|
23
|
+
for the whole attribution convention.
|
|
24
|
+
|
|
25
|
+
``--dry-run`` prints the exact title + body and never touches ``gh`` —
|
|
26
|
+
that's the testable (and offline) surface. ``dna sdlc pr-footer <s-x>``
|
|
27
|
+
emits just the footer block for hand-made PRs; it is a pure formatter
|
|
28
|
+
(no kernel session, no validation) so it works instantly from anywhere.
|
|
29
|
+
|
|
30
|
+
On success the PR URL is stamped onto the Story timeline (``pr_opened``
|
|
31
|
+
event, fail-soft) — commit carimbado (hook) + PR assinado (este) + the
|
|
32
|
+
timeline materializes both.
|
|
33
|
+
|
|
34
|
+
Deliberately NOT here — listing PRs back in ``story show`` via ``gh pr
|
|
35
|
+
list --search "Work-Item: Story/<x>"``: the GitHub *search* API is
|
|
36
|
+
rate-limited (~30 req/min), its body indexing is eventually consistent
|
|
37
|
+
(a fresh PR doesn't show up), and this codebase already documents the
|
|
38
|
+
gateway intermittently blocking api.github.com (i-133) — adding that to
|
|
39
|
+
``story show``, a hot fully-local command, would make it slow and flaky.
|
|
40
|
+
The way back already closes deterministically with zero network: a
|
|
41
|
+
squash-merged PR lands a commit whose message carries the ``Work-Item:``
|
|
42
|
+
trailer, so ``story show`` / ``story commits`` surface merged PRs via
|
|
43
|
+
``git log --grep``.
|
|
44
|
+
|
|
45
|
+
Registered onto the ``sdlc`` / ``sdlc story`` groups at import time
|
|
46
|
+
(same pattern as ``hooks_cmd``) — see ``dna_cli/__init__.py``.
|
|
47
|
+
"""
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import os
|
|
51
|
+
import shutil
|
|
52
|
+
import subprocess
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
import click
|
|
57
|
+
|
|
58
|
+
from dna_cli import _git_symbiosis as gs
|
|
59
|
+
from dna_cli._ctx import dna_session, fail
|
|
60
|
+
from dna_cli.sdlc_cmd import (
|
|
61
|
+
_append_timeline,
|
|
62
|
+
_build_raw,
|
|
63
|
+
_scope_option,
|
|
64
|
+
sdlc,
|
|
65
|
+
story_group,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_GH_TIMEOUT = 60 # seconds — pr create talks to the network
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ─── pure builders (the --dry-run-testable surface) ───────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _label_hint(spec: dict[str, Any]) -> str:
|
|
75
|
+
"""Scope hint for the conventional title: first label, else ``sdlc``."""
|
|
76
|
+
labels = spec.get("labels") or []
|
|
77
|
+
for lbl in labels:
|
|
78
|
+
if isinstance(lbl, str) and lbl.strip():
|
|
79
|
+
return lbl.strip()
|
|
80
|
+
return "sdlc"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_pr_title(name: str, spec: dict[str, Any]) -> str:
|
|
84
|
+
"""``feat(<label-hint>): <story title> (<s-x>)`` — pure."""
|
|
85
|
+
title = " ".join(str(spec.get("title") or name).split())
|
|
86
|
+
return f"feat({_label_hint(spec)}): {title} ({name})"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ac_items(raw: Any) -> list[tuple[str, bool]]:
|
|
90
|
+
"""Normalize acceptance_criteria items (strings or ``{text, done}``
|
|
91
|
+
dicts — same shapes ``story show`` renders) to ``(text, done)``."""
|
|
92
|
+
out: list[tuple[str, bool]] = []
|
|
93
|
+
for it in raw or []:
|
|
94
|
+
if isinstance(it, str):
|
|
95
|
+
if it.strip():
|
|
96
|
+
out.append((it.strip(), False))
|
|
97
|
+
elif isinstance(it, dict):
|
|
98
|
+
text = it.get("text") or it.get("criterion") or it.get("item") or it.get("desc") or ""
|
|
99
|
+
if str(text).strip():
|
|
100
|
+
out.append((str(text).strip(), bool(it.get("done") or it.get("checked"))))
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_pr_body(name: str, spec: dict[str, Any]) -> str:
|
|
105
|
+
"""PR body from a Story spec: description + AC checklist + footer — pure."""
|
|
106
|
+
parts: list[str] = []
|
|
107
|
+
desc = str(spec.get("description") or "").strip()
|
|
108
|
+
if desc:
|
|
109
|
+
parts.append(desc)
|
|
110
|
+
ac = _ac_items(spec.get("acceptance_criteria"))
|
|
111
|
+
if ac:
|
|
112
|
+
lines = "\n".join(f"- [{'x' if done else ' '}] {text}" for text, done in ac)
|
|
113
|
+
parts.append(f"## Acceptance criteria\n\n{lines}")
|
|
114
|
+
parts.append(gs.pr_footer_block("Story", name))
|
|
115
|
+
return "\n\n".join(parts) + "\n"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_gh_args(
|
|
119
|
+
title: str, body: str, *,
|
|
120
|
+
base: str | None, head: str | None, draft: bool,
|
|
121
|
+
) -> list[str]:
|
|
122
|
+
"""The exact ``gh`` argv — pure. ``--head`` is only passed when given
|
|
123
|
+
(gh's own default is the current branch)."""
|
|
124
|
+
args = ["gh", "pr", "create", "--title", title, "--body", body]
|
|
125
|
+
if base:
|
|
126
|
+
args += ["--base", base]
|
|
127
|
+
if head:
|
|
128
|
+
args += ["--head", head]
|
|
129
|
+
if draft:
|
|
130
|
+
args.append("--draft")
|
|
131
|
+
return args
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ─── plumbing ─────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _anchor_source_to_repo_root() -> None:
|
|
138
|
+
"""Make the default ``./.dna`` source discovery work from ANY cwd of
|
|
139
|
+
the repo: when no explicit source is configured and cwd has no
|
|
140
|
+
``.dna/``, point ``DNA_BASE_DIR`` at the enclosing repo root (where
|
|
141
|
+
the scope lives by convention). Process-local — the CLI is one
|
|
142
|
+
command per process."""
|
|
143
|
+
if os.getenv("DNA_SOURCE_URL") or os.getenv("DNA_BASE_DIR"):
|
|
144
|
+
return
|
|
145
|
+
if Path(".dna").is_dir():
|
|
146
|
+
return
|
|
147
|
+
root = gs.repo_root()
|
|
148
|
+
if root is not None and (root / ".dna").is_dir():
|
|
149
|
+
os.environ["DNA_BASE_DIR"] = str(root)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _stamp_pr_on_timeline(scope: str, name: str, url: str) -> None:
|
|
153
|
+
"""Append a ``pr_opened`` event carrying the PR URL. Fail-soft — the
|
|
154
|
+
PR already exists; a timeline hiccup must not fail the command."""
|
|
155
|
+
try:
|
|
156
|
+
with dna_session(scope) as s:
|
|
157
|
+
existing = s.get_doc("Story", name)
|
|
158
|
+
if existing is None:
|
|
159
|
+
return
|
|
160
|
+
spec = dict(existing.spec) if isinstance(existing.spec, dict) else {}
|
|
161
|
+
_append_timeline(spec, "pr_opened", summary=f"PR aberto: {url}", pr_url=url)
|
|
162
|
+
raw = _build_raw("Story", name, spec)
|
|
163
|
+
s.run(s.kernel.write_document(scope, "Story", name, raw))
|
|
164
|
+
except Exception as e: # noqa: BLE001 — fail-soft by contract
|
|
165
|
+
click.secho(f"⚠ não consegui carimbar o PR na timeline (non-fatal): {e}",
|
|
166
|
+
fg="yellow", err=True)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ─── commands ─────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@story_group.command("pr")
|
|
173
|
+
@click.argument("name")
|
|
174
|
+
@click.option("--base", default=None,
|
|
175
|
+
help="Base branch do PR (passthrough pro gh; default: o do repo).")
|
|
176
|
+
@click.option("--head", default=None,
|
|
177
|
+
help="Head branch (default: a branch corrente — default do próprio gh).")
|
|
178
|
+
@click.option("--draft", is_flag=True, help="Abre como draft.")
|
|
179
|
+
@click.option("--dry-run", "dry_run", is_flag=True,
|
|
180
|
+
help="Só imprime title + body montados; não chama gh.")
|
|
181
|
+
@_scope_option
|
|
182
|
+
def cmd_story_pr(
|
|
183
|
+
name: str, base: str | None, head: str | None,
|
|
184
|
+
draft: bool, dry_run: bool, scope: str,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Open the Story's PR — ``gh pr create`` pre-filled FROM the story.
|
|
187
|
+
|
|
188
|
+
Title ``feat(<label>): <título> (<s-x>)``; body = description + AC
|
|
189
|
+
como checklist + footer de atribuição (``dna sdlc pr-footer``). O PR
|
|
190
|
+
nasce da story, não o contrário — e a URL volta pra timeline.
|
|
191
|
+
"""
|
|
192
|
+
_anchor_source_to_repo_root()
|
|
193
|
+
with dna_session(scope) as s:
|
|
194
|
+
story = s.get_doc("Story", name)
|
|
195
|
+
if story is None:
|
|
196
|
+
raise fail(f"Story '{name}' not found in scope {scope!r}")
|
|
197
|
+
spec = dict(story.spec) if isinstance(story.spec, dict) else {}
|
|
198
|
+
|
|
199
|
+
title = build_pr_title(name, spec)
|
|
200
|
+
body = build_pr_body(name, spec)
|
|
201
|
+
|
|
202
|
+
if dry_run:
|
|
203
|
+
click.secho("── title ──", fg="cyan")
|
|
204
|
+
click.echo(title)
|
|
205
|
+
click.secho("── body ──", fg="cyan")
|
|
206
|
+
click.echo(body, nl=False)
|
|
207
|
+
click.secho("(dry-run — gh pr create NÃO foi chamado)", fg="yellow", err=True)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
if shutil.which("gh") is None:
|
|
211
|
+
raise fail(
|
|
212
|
+
"gh (GitHub CLI) não encontrado no PATH — instale via "
|
|
213
|
+
"https://cli.github.com e autentique com `gh auth login`. "
|
|
214
|
+
"Sem gh você ainda pode abrir o PR à mão e colar o footer: "
|
|
215
|
+
f"`dna sdlc pr-footer {name}`."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
args = build_gh_args(title, body, base=base, head=head, draft=draft)
|
|
219
|
+
try:
|
|
220
|
+
proc = subprocess.run(
|
|
221
|
+
args, capture_output=True, text=True, timeout=_GH_TIMEOUT,
|
|
222
|
+
)
|
|
223
|
+
except subprocess.TimeoutExpired:
|
|
224
|
+
raise fail(f"gh pr create excedeu {_GH_TIMEOUT}s — rede/gateway? "
|
|
225
|
+
f"Tente de novo ou abra à mão (dna sdlc pr-footer {name}).")
|
|
226
|
+
if proc.returncode != 0:
|
|
227
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
228
|
+
raise fail(
|
|
229
|
+
"gh pr create falhou:\n"
|
|
230
|
+
f"{detail}\n"
|
|
231
|
+
"Dicas: a branch precisa estar publicada (git push -u origin <branch>); "
|
|
232
|
+
"`gh auth status` mostra a autenticação; use --base/--head pra "
|
|
233
|
+
"apontar branches explicitamente."
|
|
234
|
+
)
|
|
235
|
+
url = (proc.stdout or "").strip().splitlines()[-1] if proc.stdout else ""
|
|
236
|
+
click.secho(f"PR CREATED from Story/{name}", fg="green")
|
|
237
|
+
if url:
|
|
238
|
+
click.echo(url)
|
|
239
|
+
_stamp_pr_on_timeline(scope, name, url)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@sdlc.command("pr-footer")
|
|
243
|
+
@click.argument("name")
|
|
244
|
+
def cmd_pr_footer(name: str) -> None:
|
|
245
|
+
"""Print the attribution footer block for a hand-made PR body.
|
|
246
|
+
|
|
247
|
+
Pure formatter (no kernel session, no gh): paste the output at the
|
|
248
|
+
end of the PR body você abriu à mão. Template + override
|
|
249
|
+
(``$DNA_SDLC_PR_FOOTER``) em ``_git_symbiosis.py``.
|
|
250
|
+
"""
|
|
251
|
+
click.echo(gs.pr_footer_block("Story", name))
|
dna_cli/recall_cmd.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""``dna recall`` / ``dna search`` — hybrid semantic search over the scope.
|
|
2
|
+
|
|
3
|
+
Kernel-bound, no server. Registers the embeddable ``SqliteVecRecordSearchProvider``
|
|
4
|
+
(the ``search-sqlite`` extra) on the local kernel, indexes the requested record
|
|
5
|
+
kinds on demand (idempotent by text hash), and runs ``kernel.search()`` — which
|
|
6
|
+
uses the registered provider (dense sqlite-vec + lexical FTS5, fused with RRF).
|
|
7
|
+
|
|
8
|
+
Degrades HONESTLY: when the ``search-sqlite`` extra is not installed, it prints
|
|
9
|
+
a one-line notice and falls back to ``kernel.search()`` with no provider — the
|
|
10
|
+
kernel's lexical token-match scan (``degraded: true``). Search is a read; it
|
|
11
|
+
never raises on a missing extra.
|
|
12
|
+
|
|
13
|
+
dna recall "memory similarity" # over every kind in the scope
|
|
14
|
+
dna recall "reciprocal rank fusion" --kind Story
|
|
15
|
+
dna search "banana" --scope demo -k 5 --json
|
|
16
|
+
|
|
17
|
+
``dna recall`` and ``dna search`` are aliases (recall is the memory-facing verb;
|
|
18
|
+
search the neutral one).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
from dna_cli._ctx import dna_session, print_json
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _register_provider(session: Any) -> Any | None:
|
|
30
|
+
"""Build + register the sqlite-vec provider on the session's kernel.
|
|
31
|
+
Returns the provider, or ``None`` when the ``search-sqlite`` extra is
|
|
32
|
+
absent (caller degrades to the lexical fallback)."""
|
|
33
|
+
try:
|
|
34
|
+
import sqlite_vec # noqa: F401
|
|
35
|
+
except ImportError:
|
|
36
|
+
return None
|
|
37
|
+
import os
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from dna.adapters.search.sqlite_vec import SqliteVecRecordSearchProvider
|
|
41
|
+
|
|
42
|
+
kernel = session.kernel
|
|
43
|
+
# Store lives beside the source (a `.dna-search/` sibling of `.dna/`), so
|
|
44
|
+
# re-runs reuse the index (hash-skip). Overridable via DNA_SEARCH_DIR.
|
|
45
|
+
search_dir = os.getenv("DNA_SEARCH_DIR")
|
|
46
|
+
if not search_dir:
|
|
47
|
+
base = getattr(getattr(kernel, "_source", None), "base_dir", None) or ".dna"
|
|
48
|
+
base_path = Path(base)
|
|
49
|
+
parent = base_path.parent if base_path.name == ".dna" else base_path
|
|
50
|
+
search_dir = str(parent / ".dna-search")
|
|
51
|
+
provider = SqliteVecRecordSearchProvider(kernel, db_dir=search_dir)
|
|
52
|
+
kernel.record_search_provider(provider)
|
|
53
|
+
return provider
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _scope_kinds(session: Any) -> list[str]:
|
|
57
|
+
"""The record kinds present in the scope (from the built instance)."""
|
|
58
|
+
mi = session.mi
|
|
59
|
+
kinds: list[str] = []
|
|
60
|
+
for doc in getattr(mi, "documents", []) or []:
|
|
61
|
+
if doc.kind not in kinds:
|
|
62
|
+
kinds.append(doc.kind)
|
|
63
|
+
return kinds
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _index_kinds(
|
|
67
|
+
session: Any, provider: Any, scope: str, kinds: list[str], tenant: str | None,
|
|
68
|
+
) -> int:
|
|
69
|
+
from dna.adapters.search.sqlite_vec import document_text
|
|
70
|
+
|
|
71
|
+
kernel = session.kernel
|
|
72
|
+
records: list[dict[str, Any]] = []
|
|
73
|
+
for kind in kinds:
|
|
74
|
+
async for raw in kernel.query(scope, kind, tenant=tenant):
|
|
75
|
+
meta = raw.get("metadata") or {}
|
|
76
|
+
name = meta.get("name") or raw.get("name")
|
|
77
|
+
if not name:
|
|
78
|
+
continue
|
|
79
|
+
records.append({
|
|
80
|
+
"scope": scope, "kind": kind, "name": name,
|
|
81
|
+
"tenant": tenant or "",
|
|
82
|
+
"text": document_text(raw),
|
|
83
|
+
"title": (raw.get("spec") or {}).get("title") or name,
|
|
84
|
+
})
|
|
85
|
+
if not records:
|
|
86
|
+
return 0
|
|
87
|
+
return await provider.index(records)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@click.command(name="recall")
|
|
91
|
+
@click.argument("query")
|
|
92
|
+
@click.option("--scope", default=None, help="Scope to search (default: first/only scope).")
|
|
93
|
+
@click.option("--kind", "kinds", multiple=True, help="Restrict to a record kind (repeatable). Default: every kind in the scope.")
|
|
94
|
+
@click.option("--tenant", default=None, help="Tenant overlay (base ∪ overlay; overlay shadows base).")
|
|
95
|
+
@click.option("-k", "--limit", "k", default=10, show_default=True, help="Max hits.")
|
|
96
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
|
|
97
|
+
def recall(
|
|
98
|
+
query: str, scope: str | None, kinds: tuple[str, ...],
|
|
99
|
+
tenant: str | None, k: int, as_json: bool,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Hybrid semantic search (dense + lexical + RRF) over the scope's records."""
|
|
102
|
+
with dna_session(scope) as s:
|
|
103
|
+
provider = _register_provider(s)
|
|
104
|
+
target_scope = s.scope
|
|
105
|
+
kind_list = list(kinds) or _scope_kinds(s)
|
|
106
|
+
|
|
107
|
+
# Single-kind search keeps the kernel's lexical fallback meaningful when
|
|
108
|
+
# the provider is absent (the fallback requires a kind); the provider
|
|
109
|
+
# itself searches across all indexed kinds regardless.
|
|
110
|
+
single_kind = kind_list[0] if len(kind_list) == 1 else None
|
|
111
|
+
|
|
112
|
+
if provider is not None:
|
|
113
|
+
try:
|
|
114
|
+
s.run(_index_kinds(s, provider, target_scope, kind_list, tenant))
|
|
115
|
+
except Exception as exc: # noqa: BLE001 — indexing failure degrades to lexical
|
|
116
|
+
click.secho(f"⚠ index failed ({exc}); lexical fallback", fg="yellow", err=True)
|
|
117
|
+
else:
|
|
118
|
+
click.secho(
|
|
119
|
+
"⚠ search-sqlite extra not installed — degrading to lexical "
|
|
120
|
+
"scan (pip install 'dna-sdk[search-sqlite]' for semantic recall)",
|
|
121
|
+
fg="yellow", err=True,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
result = s.run(s.kernel.search(
|
|
125
|
+
target_scope, query, kind=single_kind, k=k, tenant=tenant,
|
|
126
|
+
))
|
|
127
|
+
if provider is not None:
|
|
128
|
+
provider.close()
|
|
129
|
+
|
|
130
|
+
hits = result.get("hits", [])
|
|
131
|
+
degraded = result.get("degraded", False)
|
|
132
|
+
if as_json:
|
|
133
|
+
print_json({"query": query, "scope": target_scope, "degraded": degraded, "hits": hits})
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
mode = "lexical (degraded)" if degraded else "hybrid (dense+lexical+RRF)"
|
|
137
|
+
click.secho(f"\n🔎 {mode} · scope={target_scope} · '{query}'", bold=True)
|
|
138
|
+
if not hits:
|
|
139
|
+
click.echo(" (no matches)")
|
|
140
|
+
return
|
|
141
|
+
for i, h in enumerate(hits, 1):
|
|
142
|
+
score = h.get("score", 0.0)
|
|
143
|
+
line = f" {i:>2}. {h.get('kind','?')}/{h.get('name','?')} ({score:.4f})"
|
|
144
|
+
click.echo(line)
|
|
145
|
+
snippet = h.get("snippet")
|
|
146
|
+
if snippet:
|
|
147
|
+
click.secho(f" {snippet}", fg="bright_black")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ``dna search`` — neutral alias of the same command.
|
|
151
|
+
@click.command(name="search")
|
|
152
|
+
@click.argument("query")
|
|
153
|
+
@click.option("--scope", default=None)
|
|
154
|
+
@click.option("--kind", "kinds", multiple=True)
|
|
155
|
+
@click.option("--tenant", default=None)
|
|
156
|
+
@click.option("-k", "--limit", "k", default=10, show_default=True)
|
|
157
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
158
|
+
@click.pass_context
|
|
159
|
+
def search(ctx: click.Context, **kwargs: Any) -> None:
|
|
160
|
+
"""Alias of ``dna recall`` (neutral naming)."""
|
|
161
|
+
ctx.invoke(recall, **kwargs)
|