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/install_cmd.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""``dna install`` — install bundles/Kinds from an external repo (s-dna-install).
|
|
2
|
+
|
|
3
|
+
The front door of the ecosystem: fetch a tree from a repository, detect the
|
|
4
|
+
DNA documents inside it with the kernel's registered readers, validate each
|
|
5
|
+
one, and write the valid ones into the local source through
|
|
6
|
+
``kernel.write_document`` — so every write guard the kernel has (schema
|
|
7
|
+
vetoes, tenancy rules, Kind retirement blocks) runs exactly as it would for
|
|
8
|
+
a locally-authored doc.
|
|
9
|
+
|
|
10
|
+
URI grammar (the same one ``GitHubResolver`` has always parsed for Genome
|
|
11
|
+
dependencies — see ``dna/adapters/resolvers/github.py``):
|
|
12
|
+
|
|
13
|
+
github:owner/repo[/subdir][@ref] # shallow clone, optional subtree + ref
|
|
14
|
+
local:<path> # a directory on disk (offline-friendly)
|
|
15
|
+
|
|
16
|
+
SECURITY — manifests from a third-party repository are UNTRUSTED DATA, not
|
|
17
|
+
code you have reviewed. ``dna install`` treats them accordingly (the layered
|
|
18
|
+
defense SECURITY.md's threat model calls for — "a manifest is executable
|
|
19
|
+
behavior"):
|
|
20
|
+
|
|
21
|
+
* only documents whose ``(apiVersion, kind)`` resolves to a registered
|
|
22
|
+
KindPort are considered — everything else is rejected, not guessed at;
|
|
23
|
+
* each doc's ``spec`` is validated against the Kind's JSON Schema BEFORE
|
|
24
|
+
any write (the first defense); the kernel's own ``pre_save`` veto guards
|
|
25
|
+
then run as the second;
|
|
26
|
+
* document names must be plain slugs — path-shaped names (``../evil``)
|
|
27
|
+
are rejected before they ever reach the filesystem adapter;
|
|
28
|
+
* root Kinds (Genome) found in the fetched tree are never installed:
|
|
29
|
+
``dna install`` installs CONTENT into your scope, it does not let a
|
|
30
|
+
remote repo redefine the scope's identity.
|
|
31
|
+
|
|
32
|
+
Provenance: every install upserts ``<scope>/installed.lock`` (the lockfile
|
|
33
|
+
shape from ``dna.kernel.lock`` — v3), recording per document the origin URI
|
|
34
|
+
pinned to the resolved commit, the path inside the fetched tree, and a
|
|
35
|
+
SHA-256 of the installed raw doc.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import re
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
import click
|
|
46
|
+
import yaml
|
|
47
|
+
|
|
48
|
+
from dna_cli._ctx import dna_session, fail, print_json
|
|
49
|
+
|
|
50
|
+
# Names must be plain slugs: no separators, no traversal, no dotfiles.
|
|
51
|
+
# The FS adapter path-joins ``<scope>/<container>/<name>`` verbatim, so this
|
|
52
|
+
# guard is load-bearing, not cosmetic.
|
|
53
|
+
_SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
54
|
+
|
|
55
|
+
_SKIP_DIRS = {"node_modules", "__pycache__"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ─── scan ─────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ScannedDoc:
|
|
63
|
+
"""One raw document found in the fetched tree."""
|
|
64
|
+
raw: dict
|
|
65
|
+
rel_path: str # path of the bundle dir / yaml file, relative to the root
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _scan_tree(root: Path, readers: list) -> list[ScannedDoc]:
|
|
69
|
+
"""Walk ``root`` detecting documents with the registered readers.
|
|
70
|
+
|
|
71
|
+
Mirrors ``FilesystemCache._read_tree`` (the kernel's own reader-driven
|
|
72
|
+
scan of resolved dependencies) with two install-specific twists: the
|
|
73
|
+
root directory itself is probed first (so a URI can point straight at a
|
|
74
|
+
single bundle), and standalone ``*.yaml`` documents are collected at
|
|
75
|
+
every level. A directory claimed by a reader is not recursed into —
|
|
76
|
+
its files belong to that bundle.
|
|
77
|
+
"""
|
|
78
|
+
from dna.kernel.bundle_handle import FilesystemBundleHandle
|
|
79
|
+
|
|
80
|
+
found: list[ScannedDoc] = []
|
|
81
|
+
|
|
82
|
+
def _rel(p: Path) -> str:
|
|
83
|
+
return "." if p == root else str(p.relative_to(root))
|
|
84
|
+
|
|
85
|
+
def _visit(directory: Path) -> None:
|
|
86
|
+
bundle = FilesystemBundleHandle(directory)
|
|
87
|
+
for reader in readers:
|
|
88
|
+
try:
|
|
89
|
+
if reader.detect(bundle):
|
|
90
|
+
raw = reader.read(bundle)
|
|
91
|
+
if isinstance(raw, dict) and raw.get("kind"):
|
|
92
|
+
found.append(ScannedDoc(raw=raw, rel_path=_rel(directory)))
|
|
93
|
+
return # claimed by a reader — its subtree is the bundle
|
|
94
|
+
except Exception as e: # noqa: BLE001 — one broken bundle must not kill the scan
|
|
95
|
+
click.secho(
|
|
96
|
+
f"warning: reader {type(reader).__name__} failed on "
|
|
97
|
+
f"{_rel(directory)}: {e}",
|
|
98
|
+
fg="yellow", err=True,
|
|
99
|
+
)
|
|
100
|
+
for yf in sorted(list(directory.glob("*.yaml")) + list(directory.glob("*.yml"))):
|
|
101
|
+
try:
|
|
102
|
+
content = yaml.safe_load(yf.read_text(encoding="utf-8"))
|
|
103
|
+
except (yaml.YAMLError, UnicodeDecodeError):
|
|
104
|
+
continue
|
|
105
|
+
if (
|
|
106
|
+
isinstance(content, dict)
|
|
107
|
+
and content.get("apiVersion")
|
|
108
|
+
and content.get("kind")
|
|
109
|
+
and isinstance(content.get("metadata"), dict)
|
|
110
|
+
and content["metadata"].get("name")
|
|
111
|
+
):
|
|
112
|
+
found.append(ScannedDoc(raw=content, rel_path=_rel(yf)))
|
|
113
|
+
for sub in sorted(directory.iterdir()):
|
|
114
|
+
if sub.is_dir() and not sub.name.startswith(".") and sub.name not in _SKIP_DIRS:
|
|
115
|
+
_visit(sub)
|
|
116
|
+
|
|
117
|
+
_visit(root)
|
|
118
|
+
return found
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ─── plan ─────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class PlanItem:
|
|
126
|
+
"""One document in the install plan."""
|
|
127
|
+
kind: str
|
|
128
|
+
name: str
|
|
129
|
+
api_version: str
|
|
130
|
+
rel_path: str
|
|
131
|
+
action: str # install | overwrite | skip | reject
|
|
132
|
+
note: str = ""
|
|
133
|
+
raw: dict = field(default_factory=dict)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _validate_doc(kernel, scanned: ScannedDoc) -> str | None:
|
|
137
|
+
"""First-defense validation of an untrusted doc. Returns a rejection
|
|
138
|
+
reason (didactic, one line) or None when the doc may proceed to the
|
|
139
|
+
kernel write (where the pre_save veto guards run as second defense)."""
|
|
140
|
+
raw = scanned.raw
|
|
141
|
+
kind = raw.get("kind", "")
|
|
142
|
+
api_version = raw.get("apiVersion", "")
|
|
143
|
+
name = ((raw.get("metadata") or {}).get("name")) or ""
|
|
144
|
+
|
|
145
|
+
if not name or not isinstance(name, str):
|
|
146
|
+
return "document has no metadata.name"
|
|
147
|
+
if not _SAFE_NAME.match(name) or ".." in name:
|
|
148
|
+
return (
|
|
149
|
+
f"name {name!r} is not a plain slug — path-shaped names are "
|
|
150
|
+
f"rejected (untrusted input never reaches the filesystem layout)"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
kp = kernel.kind_port_for(kind, api_version=api_version)
|
|
154
|
+
if kp is None:
|
|
155
|
+
return (
|
|
156
|
+
f"Kind {kind!r} ({api_version}) is not registered in this kernel "
|
|
157
|
+
f"— `dna kind list` shows what this installation understands"
|
|
158
|
+
)
|
|
159
|
+
if getattr(kp, "is_root", False):
|
|
160
|
+
return (
|
|
161
|
+
"root Kind (scope identity) — `dna install` installs content, "
|
|
162
|
+
"it never lets a remote repo redefine the target scope's Genome"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
schema = None
|
|
166
|
+
try:
|
|
167
|
+
schema = kp.schema()
|
|
168
|
+
except Exception: # noqa: BLE001 — a Kind without a working schema stays permissive
|
|
169
|
+
schema = None
|
|
170
|
+
if isinstance(schema, dict) and schema:
|
|
171
|
+
import jsonschema
|
|
172
|
+
try:
|
|
173
|
+
jsonschema.validate(raw.get("spec") or {}, schema)
|
|
174
|
+
except jsonschema.ValidationError as e:
|
|
175
|
+
path = ".".join(str(p) for p in e.absolute_path) or "spec"
|
|
176
|
+
return (
|
|
177
|
+
f"schema validation failed at spec.{path}: {e.message} — "
|
|
178
|
+
f"see `dna kind show {kind}` for the expected shape"
|
|
179
|
+
)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def _build_plan(
|
|
184
|
+
kernel, scope: str, scanned: list[ScannedDoc], force: bool,
|
|
185
|
+
) -> list[PlanItem]:
|
|
186
|
+
plan: list[PlanItem] = []
|
|
187
|
+
seen: set[tuple[str, str]] = set()
|
|
188
|
+
for sd in scanned:
|
|
189
|
+
raw = sd.raw
|
|
190
|
+
kind = str(raw.get("kind", ""))
|
|
191
|
+
api_version = str(raw.get("apiVersion", ""))
|
|
192
|
+
name = str(((raw.get("metadata") or {}).get("name")) or "")
|
|
193
|
+
item = PlanItem(
|
|
194
|
+
kind=kind, name=name, api_version=api_version,
|
|
195
|
+
rel_path=sd.rel_path, action="install", raw=raw,
|
|
196
|
+
)
|
|
197
|
+
reason = _validate_doc(kernel, sd)
|
|
198
|
+
if reason:
|
|
199
|
+
item.action, item.note = "reject", reason
|
|
200
|
+
plan.append(item)
|
|
201
|
+
continue
|
|
202
|
+
if (kind, name) in seen:
|
|
203
|
+
item.action = "reject"
|
|
204
|
+
item.note = "duplicate (kind, name) earlier in the fetched tree wins"
|
|
205
|
+
plan.append(item)
|
|
206
|
+
continue
|
|
207
|
+
seen.add((kind, name))
|
|
208
|
+
try:
|
|
209
|
+
existing = await kernel.get_document(scope, kind, name)
|
|
210
|
+
except FileNotFoundError:
|
|
211
|
+
existing = None # scope not born yet — no local doc to conflict with
|
|
212
|
+
if existing is not None:
|
|
213
|
+
if force:
|
|
214
|
+
item.action, item.note = "overwrite", "exists locally — --force"
|
|
215
|
+
else:
|
|
216
|
+
item.action = "skip"
|
|
217
|
+
item.note = "already exists locally (use --force to overwrite)"
|
|
218
|
+
plan.append(item)
|
|
219
|
+
return plan
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ─── provenance ───────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _pinned_origin(uri: str, commit: str | None) -> str:
|
|
226
|
+
"""The origin URI pinned to the immutable revision that was fetched
|
|
227
|
+
(``github:owner/repo[/subdir]@<commit>``); local trees stay as-is."""
|
|
228
|
+
if not commit:
|
|
229
|
+
return uri
|
|
230
|
+
base = uri.split("@", 1)[0]
|
|
231
|
+
return f"{base}@{commit}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _write_provenance(
|
|
235
|
+
base_dir: str, scope: str, origin: str,
|
|
236
|
+
installed: list[PlanItem],
|
|
237
|
+
) -> Path:
|
|
238
|
+
"""Upsert ``<scope>/installed.lock`` — reuses the kernel's lockfile v3
|
|
239
|
+
shape (``dna.kernel.lock``) verbatim: merge by (kind, name), origin =
|
|
240
|
+
pinned URI, path = location inside the fetched tree, sha256 of the
|
|
241
|
+
canonical raw-doc JSON (same digest recipe as ``mi.lock.generate()``)."""
|
|
242
|
+
from dna.kernel.lock import LockEntry, Lockfile, read_lockfile, write_lockfile
|
|
243
|
+
|
|
244
|
+
lock_path = Path(base_dir) / scope / "installed.lock"
|
|
245
|
+
existing = read_lockfile(lock_path)
|
|
246
|
+
merged: dict[tuple[str, str], LockEntry] = {
|
|
247
|
+
(e.kind, e.name): e for e in existing.documents
|
|
248
|
+
}
|
|
249
|
+
for item in installed:
|
|
250
|
+
digest = hashlib.sha256(
|
|
251
|
+
json.dumps(item.raw, sort_keys=True, ensure_ascii=False).encode()
|
|
252
|
+
).hexdigest()
|
|
253
|
+
merged[(item.kind, item.name)] = LockEntry(
|
|
254
|
+
name=item.name, kind=item.kind, api_version=item.api_version,
|
|
255
|
+
origin=origin, path=item.rel_path, sha256=digest,
|
|
256
|
+
)
|
|
257
|
+
lock = Lockfile(
|
|
258
|
+
scope=scope,
|
|
259
|
+
documents=sorted(merged.values(), key=lambda e: (e.kind, e.name)),
|
|
260
|
+
)
|
|
261
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
write_lockfile(lock, lock_path)
|
|
263
|
+
return lock_path
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ─── fetch ────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class Fetched:
|
|
271
|
+
root: Path
|
|
272
|
+
origin: str # provenance origin (commit-pinned for github)
|
|
273
|
+
describe: str # one-line human description of what was fetched
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _fetch(uri: str) -> Fetched:
|
|
277
|
+
"""Resolve a ``github:`` / ``local:`` URI to a directory tree.
|
|
278
|
+
|
|
279
|
+
Network and not-found failures surface as didactic ClickExceptions,
|
|
280
|
+
never tracebacks.
|
|
281
|
+
"""
|
|
282
|
+
if uri.startswith("github:"):
|
|
283
|
+
from dna.adapters.resolvers.github import GitHubResolver
|
|
284
|
+
from dna.kernel.protocols import ResolveError
|
|
285
|
+
try:
|
|
286
|
+
ft = GitHubResolver().fetch_tree(uri)
|
|
287
|
+
except ResolveError as e:
|
|
288
|
+
raise click.ClickException(
|
|
289
|
+
f"{e}\n\n"
|
|
290
|
+
"Could not fetch the repository. Check that:\n"
|
|
291
|
+
" - the URI is github:owner/repo[/subdir][@ref] "
|
|
292
|
+
"(e.g. github:anthropics/skills/skills@main)\n"
|
|
293
|
+
" - the repo is public and the ref/subdir exist\n"
|
|
294
|
+
" - you are online (offline? use local:<path> against a "
|
|
295
|
+
"clone you already have)"
|
|
296
|
+
) from e
|
|
297
|
+
pinned = _pinned_origin(uri, ft.commit)
|
|
298
|
+
describe = f"{ft.owner}/{ft.repo}" + (f"/{ft.subdir}" if ft.subdir else "")
|
|
299
|
+
if ft.ref:
|
|
300
|
+
describe += f"@{ft.ref}"
|
|
301
|
+
if ft.commit:
|
|
302
|
+
describe += f" (commit {ft.commit[:12]})"
|
|
303
|
+
return Fetched(root=ft.root, origin=pinned, describe=describe)
|
|
304
|
+
|
|
305
|
+
if uri.startswith("local:"):
|
|
306
|
+
path = Path(uri.removeprefix("local:")).expanduser()
|
|
307
|
+
if not path.is_absolute():
|
|
308
|
+
path = (Path.cwd() / path).resolve()
|
|
309
|
+
if not path.is_dir():
|
|
310
|
+
raise click.ClickException(
|
|
311
|
+
f"local path {path} does not exist or is not a directory"
|
|
312
|
+
)
|
|
313
|
+
return Fetched(root=path, origin=uri, describe=str(path))
|
|
314
|
+
|
|
315
|
+
raise click.ClickException(
|
|
316
|
+
f"unsupported install URI {uri!r} — use github:owner/repo[/subdir][@ref] "
|
|
317
|
+
f"or local:<path>"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _derive_scope(uri: str) -> str:
|
|
322
|
+
"""Default target scope from the URI: ``<owner>-<repo>`` for github,
|
|
323
|
+
the directory basename for local."""
|
|
324
|
+
if uri.startswith("github:"):
|
|
325
|
+
from dna.adapters.resolvers.github import parse_github_uri
|
|
326
|
+
owner, repo, _, _ = parse_github_uri(uri)
|
|
327
|
+
slug = f"{owner}-{repo}"
|
|
328
|
+
else:
|
|
329
|
+
slug = Path(uri.removeprefix("local:")).name or "installed"
|
|
330
|
+
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", slug).strip("-").lower()
|
|
331
|
+
return slug or "installed"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ─── command ──────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
_ACTION_COLORS = {
|
|
337
|
+
"install": "green", "overwrite": "cyan", "skip": "yellow", "reject": "red",
|
|
338
|
+
}
|
|
339
|
+
_ACTION_MARKS = {"install": "+", "overwrite": "~", "skip": "=", "reject": "!"}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _print_plan(plan: list[PlanItem], scope: str, describe: str) -> None:
|
|
343
|
+
click.secho(f"install plan — {describe} → scope {scope!r}", bold=True)
|
|
344
|
+
for it in plan:
|
|
345
|
+
mark = _ACTION_MARKS[it.action]
|
|
346
|
+
line = f" {mark} {it.action:<9} {it.kind}/{it.name} ({it.rel_path})"
|
|
347
|
+
click.secho(line, fg=_ACTION_COLORS[it.action])
|
|
348
|
+
if it.note:
|
|
349
|
+
click.secho(f" {it.note}", fg=_ACTION_COLORS[it.action], dim=True)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@click.command("install")
|
|
353
|
+
@click.argument("uri")
|
|
354
|
+
@click.option(
|
|
355
|
+
"--scope", "scope_opt", default=None,
|
|
356
|
+
help="Target scope (default: derived from the URI — <owner>-<repo> for "
|
|
357
|
+
"github:, the directory name for local:). Created with a minimal "
|
|
358
|
+
"Genome when it does not exist yet.",
|
|
359
|
+
)
|
|
360
|
+
@click.option(
|
|
361
|
+
"--dry-run", is_flag=True,
|
|
362
|
+
help="Print the install plan (what would be written where, what gets "
|
|
363
|
+
"rejected and why) and stop — nothing is fetched into the source.",
|
|
364
|
+
)
|
|
365
|
+
@click.option(
|
|
366
|
+
"--force", is_flag=True,
|
|
367
|
+
help="Overwrite documents that already exist locally (default: skip "
|
|
368
|
+
"them with a warning).",
|
|
369
|
+
)
|
|
370
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine-readable summary.")
|
|
371
|
+
def install(uri: str, scope_opt: str | None, dry_run: bool, force: bool, as_json: bool) -> None:
|
|
372
|
+
"""Install bundles/Kinds from a repository into the local source.
|
|
373
|
+
|
|
374
|
+
URI is `github:owner/repo[/subdir][@ref]` (shallow clone) or
|
|
375
|
+
`local:<path>` (a directory on disk). The fetched tree is scanned with the kernel's
|
|
376
|
+
registered readers (Skill/Soul/AGENTS.md bundles, standalone YAML docs,
|
|
377
|
+
...); each detected document is validated and then written through
|
|
378
|
+
kernel.write_document, so every write guard runs.
|
|
379
|
+
|
|
380
|
+
Third-party manifests are UNTRUSTED DATA: schema validation is the first
|
|
381
|
+
defense (an invalid doc is rejected with the reason; the install
|
|
382
|
+
continues with the valid ones), the kernel's pre-save veto guards are
|
|
383
|
+
the second. Root Kinds (Genome) in the fetched tree are never installed.
|
|
384
|
+
Provenance lands in <scope>/installed.lock (origin pinned to the fetched
|
|
385
|
+
commit). See SECURITY.md for the threat model this implements.
|
|
386
|
+
|
|
387
|
+
Examples:
|
|
388
|
+
|
|
389
|
+
\b
|
|
390
|
+
dna install github:anthropics/skills/skills/pdf --scope market --dry-run
|
|
391
|
+
dna install github:anthropics/skills/skills/pdf --scope market
|
|
392
|
+
dna install local:../some-checkout/skills --scope playground --force
|
|
393
|
+
"""
|
|
394
|
+
fetched = _fetch(uri)
|
|
395
|
+
scope = scope_opt or _derive_scope(uri)
|
|
396
|
+
|
|
397
|
+
with dna_session(scope) as s:
|
|
398
|
+
kernel = s.kernel
|
|
399
|
+
readers = list(kernel.active_readers)
|
|
400
|
+
scanned = _scan_tree(fetched.root, readers)
|
|
401
|
+
if not scanned:
|
|
402
|
+
raise fail(
|
|
403
|
+
f"no DNA documents detected in {fetched.describe} — nothing the "
|
|
404
|
+
f"registered readers or the standalone-YAML scan recognize. "
|
|
405
|
+
f"Point the URI at a subtree that contains bundles "
|
|
406
|
+
f"(e.g. github:owner/repo/skills)."
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
plan = s.run(_build_plan(kernel, scope, scanned, force))
|
|
410
|
+
writes = [p for p in plan if p.action in ("install", "overwrite")]
|
|
411
|
+
rejected = [p for p in plan if p.action == "reject"]
|
|
412
|
+
skipped = [p for p in plan if p.action == "skip"]
|
|
413
|
+
|
|
414
|
+
if dry_run:
|
|
415
|
+
if as_json:
|
|
416
|
+
print_json({
|
|
417
|
+
"uri": uri, "origin": fetched.origin, "scope": scope,
|
|
418
|
+
"dry_run": True,
|
|
419
|
+
"plan": [
|
|
420
|
+
{"action": p.action, "kind": p.kind, "name": p.name,
|
|
421
|
+
"path": p.rel_path, "note": p.note}
|
|
422
|
+
for p in plan
|
|
423
|
+
],
|
|
424
|
+
})
|
|
425
|
+
else:
|
|
426
|
+
_print_plan(plan, scope, fetched.describe)
|
|
427
|
+
click.secho(
|
|
428
|
+
f"\ndry-run: {len(writes)} to write · {len(skipped)} to skip "
|
|
429
|
+
f"· {len(rejected)} rejected — re-run without --dry-run to "
|
|
430
|
+
f"apply", bold=True,
|
|
431
|
+
)
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
# Ensure the target scope exists (a scope is born from its Genome).
|
|
435
|
+
base_dir = str(kernel.source_metadata().get("base_dir") or "")
|
|
436
|
+
installed: list[PlanItem] = []
|
|
437
|
+
write_failures: list[PlanItem] = []
|
|
438
|
+
|
|
439
|
+
async def _apply() -> None:
|
|
440
|
+
scope_dir = Path(base_dir) / scope if base_dir else None
|
|
441
|
+
has_genome = scope_dir is not None and (
|
|
442
|
+
(scope_dir / "Genome.yaml").exists()
|
|
443
|
+
or (scope_dir / "manifest.yaml").exists()
|
|
444
|
+
)
|
|
445
|
+
if not has_genome:
|
|
446
|
+
await kernel.write_document(scope, "Genome", scope, {
|
|
447
|
+
"apiVersion": "github.com/ruinosus/dna/v1",
|
|
448
|
+
"kind": "Genome",
|
|
449
|
+
"metadata": {
|
|
450
|
+
"name": scope,
|
|
451
|
+
"description": f"Created by `dna install {uri}`",
|
|
452
|
+
},
|
|
453
|
+
"spec": {},
|
|
454
|
+
})
|
|
455
|
+
for item in writes:
|
|
456
|
+
try:
|
|
457
|
+
await kernel.write_document(scope, item.kind, item.name, item.raw)
|
|
458
|
+
installed.append(item)
|
|
459
|
+
except Exception as e: # noqa: BLE001 — guard veto/adapter error on ONE doc
|
|
460
|
+
item.action = "reject"
|
|
461
|
+
item.note = f"kernel write guard rejected it: {e}"
|
|
462
|
+
write_failures.append(item)
|
|
463
|
+
|
|
464
|
+
s.run(_apply())
|
|
465
|
+
rejected += write_failures
|
|
466
|
+
|
|
467
|
+
lock_path: Path | None = None
|
|
468
|
+
if installed and base_dir:
|
|
469
|
+
lock_path = _write_provenance(base_dir, scope, fetched.origin, installed)
|
|
470
|
+
|
|
471
|
+
if as_json:
|
|
472
|
+
print_json({
|
|
473
|
+
"uri": uri, "origin": fetched.origin, "scope": scope,
|
|
474
|
+
"installed": [f"{p.kind}/{p.name}" for p in installed],
|
|
475
|
+
"skipped": [f"{p.kind}/{p.name}" for p in skipped],
|
|
476
|
+
"rejected": [
|
|
477
|
+
{"doc": f"{p.kind}/{p.name}", "path": p.rel_path, "reason": p.note}
|
|
478
|
+
for p in rejected
|
|
479
|
+
],
|
|
480
|
+
"lockfile": str(lock_path) if lock_path else None,
|
|
481
|
+
})
|
|
482
|
+
else:
|
|
483
|
+
_print_plan(plan, scope, fetched.describe)
|
|
484
|
+
click.echo()
|
|
485
|
+
click.secho(
|
|
486
|
+
f"installed {len(installed)} · skipped {len(skipped)} · "
|
|
487
|
+
f"rejected {len(rejected)}",
|
|
488
|
+
fg="green" if installed else "yellow", bold=True,
|
|
489
|
+
)
|
|
490
|
+
if lock_path:
|
|
491
|
+
click.secho(f"provenance → {lock_path}", dim=True)
|
|
492
|
+
|
|
493
|
+
if not installed and not skipped:
|
|
494
|
+
# Everything was rejected — nothing usable landed; exit non-zero so
|
|
495
|
+
# scripts notice, with the reasons already printed above.
|
|
496
|
+
raise click.exceptions.Exit(1)
|