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/doc_cmd.py ADDED
@@ -0,0 +1,842 @@
1
+ """``dna doc`` — CRUD on documents within a scope.
2
+
3
+ Migrated to dna-client for read/write CRUD (no local kernel needed
4
+ for the common path). `apply` is the lone exception: it walks a
5
+ bundle directory or markdown marker file and needs the kernel's
6
+ registered Kinds to resolve marker→kind — that read happens in-process
7
+ via dna_session. Migration of `apply` requires a server-side endpoint
8
+ that accepts the bundle+marker and resolves kind itself (TODO).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json as _json
13
+ import sys
14
+ from pathlib import Path # noqa: F401 — used in deferred-eval annotations
15
+
16
+ import click
17
+
18
+ from dna_cli._ctx import (
19
+ dna_client,
20
+ dna_session,
21
+ fail,
22
+ print_json,
23
+ print_table,
24
+ run_async,
25
+ )
26
+
27
+
28
+ def _tenant_write_note(tenant: str | None) -> tuple[str | None, str | None]:
29
+ """Resolve the EFFECTIVE write tenant (``--tenant`` > ``DNA_TENANT`` >
30
+ unbound) and a warning (i-020).
31
+
32
+ Returns ``(effective, warning)``. ``warning`` is set when the effective
33
+ tenant is non-null but absent from ``DNA_DEV_ALLOWED_TENANTS`` — the silent
34
+ trap where a write lands in a tenant the Studio never browses (e.g. the old
35
+ ``dev-tenant`` default while Studio browses ``acme``)."""
36
+ import os
37
+ effective = tenant if tenant is not None else os.getenv("DNA_TENANT")
38
+ warning: str | None = None
39
+ allowed_env = os.getenv("DNA_DEV_ALLOWED_TENANTS")
40
+ if effective and allowed_env:
41
+ allowed = [t.strip() for t in allowed_env.split(",") if t.strip()]
42
+ if allowed and effective not in allowed:
43
+ warning = (
44
+ f"tenant '{effective}' não está em DNA_DEV_ALLOWED_TENANTS "
45
+ f"({', '.join(allowed)}) — o doc pode ficar invisível no Studio"
46
+ )
47
+ return effective, warning
48
+
49
+
50
+ @click.group("doc", help="List, show, create, edit, delete documents.")
51
+ def doc() -> None:
52
+ """Group root."""
53
+
54
+
55
+ @doc.command("list")
56
+ @click.argument("kind_name")
57
+ @click.option("--scope", default="dna-development")
58
+ @click.option("--tenant", default=None, help="Bind to this tenant (overrides DNA_TENANT).")
59
+ @click.option("--json", "as_json", is_flag=True)
60
+ def list_docs(
61
+ kind_name: str, scope: str, tenant: str | None, as_json: bool,
62
+ ) -> None:
63
+ """List documents of a Kind in the scope."""
64
+ with dna_client(tenant=tenant) as dna:
65
+ try:
66
+ body = run_async(dna.docs(scope).list(kind=kind_name))
67
+ except Exception as e: # noqa: BLE001
68
+ raise fail(f"docs.list failed: {e}") from e
69
+ items = body.get("items") if isinstance(body, dict) else body
70
+ if not isinstance(items, list):
71
+ items = []
72
+ rows = [
73
+ {
74
+ "name": (it.get("metadata", {}) or {}).get("name") or it.get("name") or "?",
75
+ "kind": it.get("kind") or kind_name,
76
+ }
77
+ for it in items
78
+ ]
79
+ rows.sort(key=lambda r: r["name"])
80
+ if as_json:
81
+ print_json(rows)
82
+ else:
83
+ print_table(rows, ["name", "kind"])
84
+
85
+
86
+ @doc.command("show")
87
+ @click.argument("kind_name")
88
+ @click.argument("doc_name")
89
+ @click.option("--scope", default="dna-development")
90
+ @click.option("--tenant", default=None, help="Bind to this tenant (overrides DNA_TENANT).")
91
+ def show(
92
+ kind_name: str, doc_name: str, scope: str, tenant: str | None,
93
+ ) -> None:
94
+ """Print the full document (raw frontmatter + spec) as JSON."""
95
+ with dna_client(tenant=tenant) as dna:
96
+ try:
97
+ doc_body = run_async(dna.docs(scope).get(kind_name, doc_name))
98
+ except Exception as e: # noqa: BLE001
99
+ msg = str(e)
100
+ if "404" in msg or "not found" in msg.lower() or "not_found" in msg.lower():
101
+ raise fail(f"{kind_name}/{doc_name} not found in scope.") from e
102
+ raise fail(f"docs.get failed: {e}") from e
103
+ raw = doc_body.get("raw") if isinstance(doc_body, dict) else None
104
+ if raw is None and isinstance(doc_body, dict):
105
+ raw = doc_body
106
+ print_json(
107
+ {
108
+ "kind": raw.get("kind") if isinstance(raw, dict) else kind_name,
109
+ "name": (raw.get("metadata") or {}).get("name") or doc_name if isinstance(raw, dict) else doc_name,
110
+ "metadata": (raw.get("metadata") if isinstance(raw, dict) else None) or {},
111
+ "spec": (raw.get("spec") if isinstance(raw, dict) else None) or {},
112
+ }
113
+ )
114
+
115
+
116
+ def _read_spec(spec_arg: str | None) -> dict:
117
+ """Read spec JSON from a file path or `-` for stdin."""
118
+ if spec_arg is None:
119
+ raise fail("Missing --spec=PATH (or `-` for stdin).")
120
+ if spec_arg == "-":
121
+ body = sys.stdin.read()
122
+ else:
123
+ with open(spec_arg, encoding="utf-8") as f:
124
+ body = f.read()
125
+ try:
126
+ return _json.loads(body)
127
+ except _json.JSONDecodeError as e:
128
+ raise fail(f"Invalid JSON in spec: {e}")
129
+
130
+
131
+ def _fetch_kind_descriptor(dna, scope: str, kind_name: str) -> dict:
132
+ """Fetch a kind descriptor via dna-client. Returns {schema, api_version, ...}.
133
+
134
+ Raises fail() if the kind isn't registered in the scope.
135
+ """
136
+ try:
137
+ return run_async(dna.scopes.kind_schema(scope, kind_name))
138
+ except Exception as e: # noqa: BLE001
139
+ msg = str(e)
140
+ if "404" in msg or "not found" in msg.lower():
141
+ raise fail(f"Kind '{kind_name}' not registered in scope '{scope}'.") from e
142
+ raise fail(f"kind_schema fetch failed: {e}") from e
143
+
144
+
145
+ def _coerce_value(value: str, schema_type: str | None) -> object:
146
+ """Coerce a CLI string value to the JSON Schema type the field declares.
147
+
148
+ integer → int, number → float, boolean → bool from truthy strings,
149
+ array → split on ';' (with optional `[]` wrapping). Everything else → str.
150
+ """
151
+ if schema_type == "integer":
152
+ try:
153
+ return int(value)
154
+ except ValueError:
155
+ return value
156
+ if schema_type == "number":
157
+ try:
158
+ return float(value)
159
+ except ValueError:
160
+ return value
161
+ if schema_type == "boolean":
162
+ return value.lower() in ("true", "1", "yes", "y")
163
+ if schema_type == "array":
164
+ v = value.strip()
165
+ if v.startswith("[") and v.endswith("]"):
166
+ v = v[1:-1]
167
+ return [item.strip() for item in v.split(";") if item.strip()]
168
+ return value
169
+
170
+
171
+ @doc.command("make")
172
+ @click.argument("kind_name")
173
+ @click.argument("doc_name")
174
+ @click.argument("fields", nargs=-1)
175
+ @click.option("--scope", default="dna-development")
176
+ @click.option("--tenant", default=None, help="Bind the write to this tenant.")
177
+ @click.option("--dry-run", is_flag=True, help="Validate without writing.")
178
+ def make_doc(
179
+ kind_name: str,
180
+ doc_name: str,
181
+ fields: tuple,
182
+ scope: str,
183
+ tenant: str | None,
184
+ dry_run: bool,
185
+ ) -> None:
186
+ """Create a doc via schema-driven flags (no JSON file needed).
187
+
188
+ Syntax: dna doc make <Kind> <name> field1=value1 field2=value2 ...
189
+
190
+ Field types are coerced from the Kind's JSON Schema:
191
+ severity=high → "high" (string)
192
+ time_box_hours=8 → 8 (integer)
193
+ repro_steps="step1;step2" → ["step1", "step2"] (array)
194
+ labels= → [] (empty array on empty value)
195
+ """
196
+ with dna_client(tenant=tenant) as dna:
197
+ descriptor = _fetch_kind_descriptor(dna, scope, kind_name)
198
+ schema = descriptor.get("schema") or {}
199
+ schema_props = (schema.get("properties") or {}) if isinstance(schema, dict) else {}
200
+ api_version = descriptor.get("api_version") or "github.com/ruinosus/dna/v1"
201
+
202
+ spec: dict[str, object] = {}
203
+ for field in fields:
204
+ if "=" not in field:
205
+ raise fail(f"Field arg '{field}' must be 'key=value' (got no '=').")
206
+ key, _, raw_value = field.partition("=")
207
+ key = key.strip()
208
+ prop = schema_props.get(key) if isinstance(schema_props, dict) else None
209
+ schema_type = prop.get("type") if isinstance(prop, dict) else None
210
+ spec[key] = _coerce_value(raw_value, schema_type)
211
+
212
+ if "created_at" in schema_props and "created_at" not in spec:
213
+ from datetime import datetime, timezone
214
+ spec["created_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
215
+
216
+ raw = {
217
+ "apiVersion": api_version,
218
+ "kind": kind_name,
219
+ "metadata": {"name": doc_name},
220
+ "spec": spec,
221
+ }
222
+ if dry_run:
223
+ print_json({"dry_run": True, "would_write": raw, "tenant": tenant})
224
+ return
225
+ try:
226
+ run_async(dna.docs(scope).put(kind_name, doc_name, raw))
227
+ except Exception as e: # noqa: BLE001
228
+ raise fail(f"write failed: {e}") from e
229
+ click.secho(
230
+ f"Created {kind_name}/{doc_name} in scope {scope} "
231
+ f"({len(spec)} fields){' (tenant=' + tenant + ')' if tenant else ''}",
232
+ fg="green",
233
+ )
234
+
235
+
236
+ @doc.command("transition")
237
+ @click.argument("kind_name")
238
+ @click.argument("doc_name")
239
+ @click.argument("new_status")
240
+ @click.option("--scope", default="dna-development")
241
+ @click.option("--tenant", default=None)
242
+ @click.option("--commit-ref", default=None, help="Git SHA to stamp on transition.")
243
+ @click.option("--reason", default=None, help="Optional reason string.")
244
+ def transition(
245
+ kind_name: str,
246
+ doc_name: str,
247
+ new_status: str,
248
+ scope: str,
249
+ tenant: str | None,
250
+ commit_ref: str | None,
251
+ reason: str | None,
252
+ ) -> None:
253
+ """Generic status transition for any Kind that declares ``status`` in schema.
254
+
255
+ Validates new_status against the Kind's status enum. Stamps updated_at,
256
+ optionally closed_at (if new_status is terminal — heuristic), commit_ref,
257
+ and a timeline entry.
258
+ """
259
+ with dna_client(tenant=tenant) as dna:
260
+ descriptor = _fetch_kind_descriptor(dna, scope, kind_name)
261
+ schema = descriptor.get("schema") or {}
262
+ schema_props = (schema.get("properties") or {}) if isinstance(schema, dict) else {}
263
+ api_version = descriptor.get("api_version") or "github.com/ruinosus/dna/v1"
264
+
265
+ try:
266
+ current = run_async(dna.docs(scope).get(kind_name, doc_name))
267
+ except Exception as e: # noqa: BLE001
268
+ raise fail(f"{kind_name}/{doc_name} not found in scope: {e}") from e
269
+ current_raw = current.get("raw") if isinstance(current, dict) else current
270
+ current_spec = (current_raw or {}).get("spec") if isinstance(current_raw, dict) else None
271
+ if not isinstance(current_spec, dict):
272
+ current_spec = {}
273
+
274
+ status_prop = schema_props.get("status") if isinstance(schema_props, dict) else None
275
+ if isinstance(status_prop, dict) and "enum" in status_prop:
276
+ if new_status not in status_prop["enum"]:
277
+ raise fail(
278
+ f"Status '{new_status}' not in {kind_name}.status enum: "
279
+ f"{status_prop['enum']}"
280
+ )
281
+
282
+ from datetime import datetime, timezone
283
+ prev_status = current_spec.get("status")
284
+ now = datetime.now(timezone.utc).isoformat(timespec="seconds")
285
+ spec = dict(current_spec)
286
+ spec["status"] = new_status
287
+ spec["updated_at"] = now
288
+ if new_status in ("done", "resolved", "wont-fix", "duplicate", "cancelled", "reported", "executed"):
289
+ spec.setdefault("closed_at", now)
290
+ if commit_ref:
291
+ commit_refs = list(spec.get("commit_refs") or [])
292
+ if commit_ref not in commit_refs:
293
+ commit_refs.append(commit_ref)
294
+ spec["commit_refs"] = commit_refs
295
+ timeline_entry: dict[str, object] = {
296
+ "at": now,
297
+ "actor": "cli",
298
+ "type": "status_change",
299
+ "from": prev_status,
300
+ "to": new_status,
301
+ }
302
+ if commit_ref:
303
+ timeline_entry["commit_ref"] = commit_ref
304
+ if reason:
305
+ timeline_entry["reason"] = reason
306
+ spec.setdefault("timeline", []).append(timeline_entry)
307
+ raw = {
308
+ "apiVersion": api_version,
309
+ "kind": kind_name,
310
+ "metadata": {"name": doc_name},
311
+ "spec": spec,
312
+ }
313
+ try:
314
+ run_async(dna.docs(scope).put(kind_name, doc_name, raw))
315
+ except Exception as e: # noqa: BLE001
316
+ raise fail(f"write failed: {e}") from e
317
+ click.secho(
318
+ f"Transitioned {kind_name}/{doc_name}: {prev_status} → {new_status}",
319
+ fg="green",
320
+ )
321
+
322
+
323
+ @doc.command("fields")
324
+ @click.argument("kind_name")
325
+ @click.option("--scope", default="dna-development")
326
+ @click.option("--tenant", default=None)
327
+ def fields_help(kind_name: str, scope: str, tenant: str | None) -> None:
328
+ """List the fields a Kind accepts (with type + enum + required marker)."""
329
+ with dna_client(tenant=tenant) as dna:
330
+ descriptor = _fetch_kind_descriptor(dna, scope, kind_name)
331
+ schema = descriptor.get("schema") or {}
332
+ if not isinstance(schema, dict):
333
+ click.secho(f"Kind '{kind_name}' has no schema()", fg="yellow")
334
+ return
335
+ required = set(schema.get("required") or [])
336
+ props = schema.get("properties") or {}
337
+ click.secho(f"Fields for {kind_name}", bold=True)
338
+ click.echo(f" required: {sorted(required)}")
339
+ click.echo("")
340
+ for name in sorted(props.keys()):
341
+ p = props[name]
342
+ if not isinstance(p, dict):
343
+ continue
344
+ t = p.get("type", "?")
345
+ enum = p.get("enum")
346
+ desc = (p.get("description") or "")[:60]
347
+ marker = " *" if name in required else ""
348
+ enum_str = f" enum={enum}" if enum else ""
349
+ click.echo(f" {name:<24} ({t}){enum_str}{marker} {desc}")
350
+
351
+
352
+ @doc.command("create")
353
+ @click.argument("kind_name")
354
+ @click.argument("doc_name")
355
+ @click.option("--spec", "spec_path", default=None, help="Path to JSON file (or `-` for stdin).")
356
+ @click.option("--scope", default="dna-development")
357
+ @click.option("--tenant", default=None, help="Bind the write to this tenant (overrides DNA_TENANT).")
358
+ @click.option("--dry-run", is_flag=True, help="Validate without writing.")
359
+ def create(
360
+ kind_name: str,
361
+ doc_name: str,
362
+ spec_path: str | None,
363
+ scope: str,
364
+ tenant: str | None,
365
+ dry_run: bool,
366
+ ) -> None:
367
+ """Create a new document via the kernel WriterPort."""
368
+ spec = _read_spec(spec_path)
369
+ with dna_client(tenant=tenant) as dna:
370
+ descriptor = _fetch_kind_descriptor(dna, scope, kind_name)
371
+ api_version = descriptor.get("api_version") or "github.com/ruinosus/dna/v1"
372
+
373
+ raw = {
374
+ "apiVersion": api_version,
375
+ "kind": kind_name,
376
+ "metadata": {"name": doc_name},
377
+ "spec": spec,
378
+ }
379
+ if dry_run:
380
+ print_json({"dry_run": True, "would_write": raw, "tenant": tenant})
381
+ return
382
+ try:
383
+ run_async(dna.docs(scope).put(kind_name, doc_name, raw))
384
+ except Exception as e: # noqa: BLE001
385
+ raise fail(f"write failed: {e}") from e
386
+ _eff, _warn = _tenant_write_note(tenant) # i-020
387
+ if _warn:
388
+ click.secho(f" ⚠ {_warn}", fg="yellow", err=True)
389
+ suffix = f" (tenant={_eff})" if _eff else " (tenant=unbound/global)"
390
+ click.secho(f"Created {kind_name}/{doc_name} in scope {scope}{suffix}.", fg="green")
391
+
392
+
393
+ @doc.command("delete")
394
+ @click.argument("kind_name")
395
+ @click.argument("doc_name")
396
+ @click.option("--scope", default="dna-development")
397
+ @click.option("--tenant", default=None, help="Bind the delete to this tenant (overrides DNA_TENANT).")
398
+ @click.option("--yes", is_flag=True, help="Skip confirmation.")
399
+ def delete(
400
+ kind_name: str, doc_name: str, scope: str, tenant: str | None, yes: bool,
401
+ ) -> None:
402
+ """Delete a document from the scope. Asks for confirmation unless --yes."""
403
+ _eff, _warn = _tenant_write_note(tenant) # i-020: show effective tenant
404
+ if _warn:
405
+ click.secho(f" ⚠ {_warn}", fg="yellow", err=True)
406
+ suffix = f" (tenant={_eff})" if _eff else " (tenant=unbound/global)"
407
+ if not yes:
408
+ click.confirm(
409
+ f"Delete {kind_name}/{doc_name} from scope {scope}{suffix}?",
410
+ abort=True,
411
+ )
412
+ with dna_client(tenant=tenant) as dna:
413
+ try:
414
+ run_async(dna.docs(scope).delete(kind_name, doc_name))
415
+ except Exception as e: # noqa: BLE001
416
+ raise fail(f"delete failed: {e}") from e
417
+ click.secho(f"Deleted {kind_name}/{doc_name}{suffix}.", fg="green")
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # `apply` — bundle / marker handling still needs local kernel for
422
+ # marker→kind resolution. TODO: add a server-side endpoint that takes
423
+ # (bundle_bytes, marker_name) and returns the canonical raw doc, so
424
+ # this can also migrate to dna-client. Until then, `dna doc apply`
425
+ # requires DNA_SOURCE_URL to be set.
426
+ # ---------------------------------------------------------------------------
427
+
428
+
429
+ _BUNDLE_TEXT_EXTS = {
430
+ ".py", ".md", ".markdown", ".txt", ".yaml", ".yml", ".json",
431
+ ".toml", ".cfg", ".ini", ".sh", ".html", ".css", ".js", ".ts",
432
+ ".tsx", ".jsx", ".vue", ".sql",
433
+ }
434
+ _BUNDLE_SKIP_DIRS = {
435
+ ".git", "__pycache__", "node_modules", ".venv", "dist", "build",
436
+ "target", ".pytest_cache", ".ruff_cache", ".mypy_cache", "tasks",
437
+ }
438
+ _BUNDLE_DOCKERFILE_PREFIXES = ("Dockerfile",)
439
+
440
+
441
+ def _is_bundle_text_file(rel_path: "Path", marker_filename: str) -> bool:
442
+ if rel_path.name == marker_filename:
443
+ return False
444
+ if any(part in _BUNDLE_SKIP_DIRS for part in rel_path.parts):
445
+ return False
446
+ if rel_path.suffix in _BUNDLE_TEXT_EXTS:
447
+ return True
448
+ if any(rel_path.name.startswith(p) for p in _BUNDLE_DOCKERFILE_PREFIXES):
449
+ return True
450
+ return False
451
+
452
+
453
+ def _collect_bundle_files(
454
+ root: "Path", marker_filename: str,
455
+ ) -> "dict[str, str | bytes]":
456
+ """All bundle entries under ``root`` (excluding the marker + skip dirs):
457
+ text files as ``str``, everything else (fonts, images, audio, archives)
458
+ as ``bytes``. i-062 — the text-only collection dropped binary assets, so
459
+ `dna doc apply` never synced fonts/images to the target source.
460
+
461
+ The downstream apply pops these from spec.source_files and writes each via
462
+ ``kernel.write_bundle_entry_async`` (which takes ``str | bytes``).
463
+ """
464
+ out: "dict[str, str | bytes]" = {}
465
+ for f in root.rglob("*"):
466
+ if not f.is_file():
467
+ continue
468
+ rel = f.relative_to(root)
469
+ if rel.name == marker_filename:
470
+ continue
471
+ # Skip junk: skip-dirs (.git, __pycache__, …) and dotfiles (.DS_Store).
472
+ if any(part in _BUNDLE_SKIP_DIRS for part in rel.parts):
473
+ continue
474
+ if rel.name.startswith("."):
475
+ continue
476
+ if _is_bundle_text_file(rel, marker_filename):
477
+ try:
478
+ out[str(rel)] = f.read_text(encoding="utf-8")
479
+ except (UnicodeDecodeError, OSError):
480
+ continue
481
+ else:
482
+ try:
483
+ out[str(rel)] = f.read_bytes()
484
+ except OSError:
485
+ continue
486
+ return out
487
+
488
+
489
+ def _load_apply_input(path: str, kernel) -> dict:
490
+ """Load `dna doc apply` input — bundle dir, marker file, or YAML/JSON."""
491
+ import yaml as _yaml
492
+ from pathlib import Path as _Path
493
+ from dna.kernel.generic_rw import _parse_frontmatter, _parse_body # noqa: F401
494
+ p = _Path(path)
495
+
496
+ if p.is_dir():
497
+ marker_path: _Path | None = None
498
+ kind_name_from_marker: str | None = None
499
+ registered_markers = {}
500
+ for kp in kernel._kinds.values():
501
+ sd = getattr(kp, "storage", None)
502
+ marker = getattr(sd, "marker", None) if sd else None
503
+ if marker:
504
+ registered_markers[marker] = kp.kind
505
+ for child in p.iterdir():
506
+ if child.is_file() and child.name in registered_markers:
507
+ marker_path = child
508
+ kind_name_from_marker = registered_markers[child.name]
509
+ break
510
+ if marker_path is None:
511
+ raise fail(
512
+ f"{path}: directory does not contain a recognized bundle marker. "
513
+ f"Looked for {sorted(registered_markers.keys())}."
514
+ )
515
+ raw_text = marker_path.read_text(encoding="utf-8")
516
+ fm, body = _parse_frontmatter(raw_text, source=str(marker_path))
517
+ if "kind" not in fm:
518
+ fm["kind"] = kind_name_from_marker
519
+
520
+ # i-062 — collect text AND binary bundle entries (fonts, images, …).
521
+ source_files = _collect_bundle_files(p, marker_path.name)
522
+
523
+ return _build_raw_from_marker(
524
+ marker_path, fm, body, kernel,
525
+ extra_source_files=source_files or None,
526
+ )
527
+
528
+ suffix = p.suffix.lower()
529
+ if suffix not in (".md", ".markdown"):
530
+ with open(path, encoding="utf-8") as f:
531
+ raw_text = f.read()
532
+ try:
533
+ raw = _yaml.safe_load(raw_text)
534
+ except _yaml.YAMLError as e:
535
+ raise fail(f"Invalid YAML/JSON in {path}: {e}")
536
+ if not isinstance(raw, dict):
537
+ raise fail(
538
+ f"{path} top-level must be a mapping (apiVersion/kind/metadata/spec)."
539
+ )
540
+ return raw
541
+
542
+ raw_text = p.read_text(encoding="utf-8")
543
+ fm, body = _parse_frontmatter(raw_text, source=str(p))
544
+ # i-061 — a single marker file (e.g. AGENT.md) is still a bundle: collect
545
+ # its sibling entries (instruction.md, scripts/, references/) from the
546
+ # parent directory so `dna doc apply path/to/AGENT.md` is equivalent to
547
+ # `dna doc apply path/to/`. Without this, applying the marker alone dropped
548
+ # the instruction_file fragment, zeroing the agent's instruction.
549
+ # i-062 — collect text AND binary siblings (fonts, images, …).
550
+ sibling_files = _collect_bundle_files(p.parent, p.name)
551
+ return _build_raw_from_marker(
552
+ p, fm, body, kernel, extra_source_files=sibling_files or None,
553
+ )
554
+
555
+
556
+ def _build_raw_from_marker(
557
+ marker_path: "Path",
558
+ fm: dict,
559
+ body: str,
560
+ kernel,
561
+ extra_source_files: "dict[str, str] | None" = None,
562
+ ) -> dict:
563
+ """Build the canonical raw doc dict from a parsed marker file."""
564
+ from dna.kernel.generic_rw import _parse_body
565
+
566
+ kind_name: str | None = fm.get("kind")
567
+ if not kind_name:
568
+ marker_name = marker_path.name
569
+ for kp in kernel._kinds.values():
570
+ sd = getattr(kp, "storage", None)
571
+ if sd and getattr(sd, "marker", None) == marker_name:
572
+ kind_name = kp.kind
573
+ break
574
+ if not kind_name:
575
+ raise fail(
576
+ f"{marker_path}: cannot infer kind. Add `kind: <KindName>` to the "
577
+ f"frontmatter or use a recognized marker filename."
578
+ )
579
+
580
+ sd = kernel.storage_for_kind(kind_name)
581
+ if sd is None:
582
+ raise fail(f"{marker_path}: kind {kind_name!r} is not registered in the kernel.")
583
+
584
+ api_version = fm.get("apiVersion") or getattr(
585
+ kernel._kinds.get(kind_name.lower(), object()), "api_version", None,
586
+ )
587
+ if not api_version:
588
+ for kp in kernel._kinds.values():
589
+ if kp.kind == kind_name:
590
+ api_version = getattr(kp, "api_version", None)
591
+ if api_version:
592
+ break
593
+ if not api_version:
594
+ api_version = "github.com/ruinosus/dna/helix/v1"
595
+
596
+ _meta = fm.get("metadata") if isinstance(fm.get("metadata"), dict) else {}
597
+ name_val = fm.get("name") or _meta.get("name")
598
+ if not name_val:
599
+ if marker_path.parent and marker_path.parent.name:
600
+ name_val = marker_path.parent.name
601
+ if not name_val:
602
+ raise fail(
603
+ f"{marker_path}: missing 'name' in frontmatter (or 'metadata.name')."
604
+ )
605
+
606
+ metadata_keys = {"name", "apiVersion", "kind", "description", "labels", "metadata"}
607
+ nested_spec = fm.get("spec") if isinstance(fm.get("spec"), dict) else None
608
+ spec: dict = {
609
+ k: v for k, v in fm.items()
610
+ if k not in metadata_keys and k != "spec"
611
+ }
612
+ if nested_spec:
613
+ spec.update(nested_spec)
614
+ body_field = getattr(sd, "body_field", None)
615
+ body_as = getattr(sd, "body_as", None)
616
+ if body_field and body_as is not None:
617
+ spec[body_field] = _parse_body(body, body_as)
618
+ elif body.strip():
619
+ spec.setdefault("body", body.strip())
620
+
621
+ if extra_source_files:
622
+ spec["source_files"] = extra_source_files
623
+
624
+ raw = {
625
+ "apiVersion": api_version,
626
+ "kind": kind_name,
627
+ "metadata": {
628
+ "name": name_val,
629
+ **({"description": fm["description"]} if fm.get("description") else {}),
630
+ },
631
+ "spec": spec,
632
+ }
633
+ return raw
634
+
635
+
636
+ def _stamp_created_at_if_in_schema(s, kind_name: str, raw: dict) -> None:
637
+ """Stamp spec.created_at if Kind's schema declares it. Fail-soft."""
638
+ spec = raw.get("spec")
639
+ if not isinstance(spec, dict):
640
+ return
641
+ if spec.get("created_at"):
642
+ return
643
+ try:
644
+ kind_port = next(
645
+ (kp for kp in s.kernel._kinds.values() if getattr(kp, "kind", None) == kind_name),
646
+ None,
647
+ )
648
+ if kind_port is None:
649
+ return
650
+ schema = kind_port.schema() or {}
651
+ props = schema.get("properties") or {}
652
+ if "created_at" not in props:
653
+ return
654
+ except Exception: # noqa: BLE001
655
+ return
656
+
657
+ from datetime import datetime, timezone
658
+ spec["created_at"] = datetime.now(timezone.utc).isoformat()
659
+
660
+
661
+ def _load_apply_inputs(path: str, kernel) -> list[dict]:
662
+ """Load `dna doc apply` input as a LIST of raw docs.
663
+
664
+ YAML/JSON files may contain MULTIPLE documents separated by ``---``
665
+ (a YAML stream); each is applied independently. Bundle directories
666
+ and markdown marker files stay single-doc (markers have one body).
667
+
668
+ Single-doc files still return a one-element list, so the apply loop
669
+ behaves identically to the legacy single-doc path.
670
+ """
671
+ from pathlib import Path as _Path
672
+
673
+ p = _Path(path)
674
+ suffix = p.suffix.lower()
675
+ # Multi-doc only makes sense for plain YAML/JSON streams. Bundle dirs
676
+ # and markdown markers carry exactly one document by construction.
677
+ if p.is_dir() or suffix in (".md", ".markdown"):
678
+ return [_load_apply_input(path, kernel)]
679
+
680
+ import yaml as _yaml
681
+
682
+ with open(path, encoding="utf-8") as f:
683
+ raw_text = f.read()
684
+ try:
685
+ docs = [d for d in _yaml.safe_load_all(raw_text) if d is not None]
686
+ except _yaml.YAMLError as e:
687
+ raise fail(f"Invalid YAML/JSON in {path}: {e}")
688
+ if not docs:
689
+ raise fail(f"{path} contains no documents.")
690
+ # Single-doc YAML: defer to _load_apply_input so its validation +
691
+ # behavior (and any test monkeypatch of it) stays the single source of
692
+ # truth for the common case. Multi-doc only kicks in for `---` streams.
693
+ if len(docs) == 1:
694
+ return [_load_apply_input(path, kernel)]
695
+ for idx, d in enumerate(docs):
696
+ if not isinstance(d, dict):
697
+ raise fail(
698
+ f"{path} document #{idx} top-level must be a mapping "
699
+ f"(apiVersion/kind/metadata/spec)."
700
+ )
701
+ return docs
702
+
703
+
704
+ def _apply_one(s, raw: dict, *, path: str, doc_index: int | None,
705
+ tenant: str | None, dry_run: bool) -> None:
706
+ """Validate + upsert a single raw doc. Shared by single- and multi-doc apply."""
707
+ label = f"{path}" if doc_index is None else f"{path} document #{doc_index}"
708
+ if not isinstance(raw, dict):
709
+ raise fail(
710
+ f"{label} top-level must be a mapping (apiVersion/kind/metadata/spec)."
711
+ )
712
+ for key in ("apiVersion", "kind", "metadata"):
713
+ if key not in raw:
714
+ raise fail(f"{label} missing required field: {key}")
715
+ name = (raw.get("metadata") or {}).get("name")
716
+ if not name:
717
+ raise fail(f"{label} missing metadata.name")
718
+ kind_name = raw["kind"]
719
+
720
+ # i-061 — bundle entries (e.g. the `instruction_file` fragment, scripts/,
721
+ # references/) ride in `spec.source_files` from the loader. Pop them BEFORE
722
+ # the doc write so they don't bloat the stored spec, then persist each as a
723
+ # bundle entry AFTER the doc exists. Source-agnostic (FS / SQLite / Postgres)
724
+ # via kernel.write_bundle_entry_async. Without this, applying an
725
+ # instruction_file Agent to a fresh bundle leaves no instruction
726
+ # fragment → resolve_document re-resolves it to empty → broken agent.
727
+ _bundle_entries: dict = {}
728
+ _spec_for_entries = raw.get("spec")
729
+ if isinstance(_spec_for_entries, dict):
730
+ _src = _spec_for_entries.pop("source_files", None)
731
+ if isinstance(_src, dict):
732
+ _bundle_entries = _src
733
+
734
+ current = s.get_doc(kind_name, name)
735
+ if current is None:
736
+ action = "CREATED"
737
+ else:
738
+ current_spec = dict(current.spec) if current.spec else {}
739
+ new_spec = raw.get("spec") or {}
740
+ if _json.dumps(current_spec, sort_keys=True, default=str) == _json.dumps(
741
+ new_spec, sort_keys=True, default=str
742
+ ):
743
+ action = "UNCHANGED"
744
+ else:
745
+ action = "UPDATED"
746
+
747
+ if dry_run:
748
+ print_json(
749
+ {
750
+ "dry_run": True,
751
+ "action": action,
752
+ "kind": kind_name,
753
+ "name": name,
754
+ "scope": s.scope,
755
+ "tenant": tenant,
756
+ }
757
+ )
758
+ return
759
+
760
+ if action == "UNCHANGED":
761
+ click.secho(f"UNCHANGED {kind_name}/{name}", fg="yellow")
762
+ return
763
+
764
+ _stamp_created_at_if_in_schema(s, kind_name, raw)
765
+
766
+ # Tenant binding: explicit --tenant > DNA_TENANT > unbound (legacy/global).
767
+ # i-020: surface the EFFECTIVE tenant + warn on allow-list mismatch.
768
+ effective, _tenant_warn = _tenant_write_note(tenant)
769
+ if _tenant_warn:
770
+ click.secho(f" ⚠ {_tenant_warn}", fg="yellow", err=True)
771
+ kernel = s.kernel.with_tenant(effective) if effective else s.kernel
772
+ try:
773
+ s.run(kernel.write_document(s.scope, kind_name, name, raw))
774
+ except Exception as e: # noqa: BLE001
775
+ # Surface prompt-budget errors with a clear message instead of the
776
+ # generic "write failed:" wrapper. The error message from
777
+ # PromptBudgetExceededError already includes model, cap, and
778
+ # actionable advice — prepending "write failed:" would obscure it.
779
+ try:
780
+ from dna.kernel.prompt_budget import PromptBudgetExceededError
781
+ if isinstance(e, PromptBudgetExceededError):
782
+ click.secho(str(e), fg="red", err=True)
783
+ raise SystemExit(1)
784
+ except ImportError:
785
+ pass
786
+ raise fail(f"write failed: {e}")
787
+ # i-061 — persist bundle entries now that the parent doc exists. These were
788
+ # popped from spec.source_files above; the marker file (AGENT.md) is already
789
+ # written by write_document, and source_files excludes it by construction.
790
+ for _entry_path, _content in _bundle_entries.items():
791
+ try:
792
+ # i-083 — pass text entries as str and binary entries as bytes so
793
+ # the adapter routes them to the right column (content vs
794
+ # content_binary). Force-encoding str→bytes here buried every text
795
+ # payload (instruction fragments, asset.json, scripts) in the
796
+ # binary column. _collect_bundle_files already returns str for text
797
+ # extensions and bytes for binary.
798
+ _data = (
799
+ _content if isinstance(_content, (str, bytes, bytearray))
800
+ else str(_content)
801
+ )
802
+ s.run(kernel.write_bundle_entry_async(
803
+ s.scope, kind_name, name, _entry_path, _data, tenant=effective,
804
+ ))
805
+ except Exception as _be: # noqa: BLE001
806
+ click.secho(
807
+ f" ⚠ bundle entry {_entry_path!r} not persisted: {_be}",
808
+ fg="yellow", err=True,
809
+ )
810
+ s.holder.reload()
811
+ fg = "green" if action == "CREATED" else "cyan"
812
+ suffix = f" (tenant={effective})" if effective else " (tenant=unbound/global)"
813
+ click.secho(f"{action} {kind_name}/{name} in scope {s.scope}{suffix}.", fg=fg)
814
+
815
+
816
+ @doc.command("apply")
817
+ @click.argument("path", type=click.Path(exists=True, dir_okay=True, readable=True))
818
+ @click.option("--scope", default=None, help="Override scope (default from env or doc).")
819
+ @click.option("--tenant", default=None, help="Bind the apply to this tenant (overrides DNA_TENANT).")
820
+ @click.option("--dry-run", is_flag=True, help="Validate without writing.")
821
+ def apply(path: str, scope: str | None, tenant: str | None, dry_run: bool) -> None:
822
+ """Upsert document(s) from a YAML/JSON file, a bundle marker, or a bundle directory.
823
+
824
+ YAML/JSON files may hold MULTIPLE documents separated by ``---`` (a YAML
825
+ stream); each is applied independently in order. Single-doc files behave
826
+ exactly as before.
827
+
828
+ NOTE: this command still uses the local kernel (via dna_session) because
829
+ bundle/marker → kind resolution requires walking registered Kinds. Other
830
+ `dna doc` commands run via dna-client and don't need DNA_SOURCE_URL set.
831
+ """
832
+ with dna_session(scope) as s:
833
+ raws = _load_apply_inputs(path, s.kernel)
834
+ multi = len(raws) > 1
835
+ for idx, raw in enumerate(raws):
836
+ _apply_one(
837
+ s, raw,
838
+ path=path,
839
+ doc_index=idx if multi else None,
840
+ tenant=tenant,
841
+ dry_run=dry_run,
842
+ )