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