dna-cli 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (70) hide show
  1. {dna_cli-0.2.0 → dna_cli-0.3.0}/PKG-INFO +2 -2
  2. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/init_cmd.py +250 -36
  3. {dna_cli-0.2.0 → dna_cli-0.3.0}/pyproject.toml +3 -3
  4. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_init_cmd.py +198 -0
  5. {dna_cli-0.2.0 → dna_cli-0.3.0}/.gitignore +0 -0
  6. {dna_cli-0.2.0 → dna_cli-0.3.0}/LICENSE +0 -0
  7. {dna_cli-0.2.0 → dna_cli-0.3.0}/README.md +0 -0
  8. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/__init__.py +0 -0
  9. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_active_story.py +0 -0
  10. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_banner.py +0 -0
  11. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_ctx.py +0 -0
  12. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_git_symbiosis.py +0 -0
  13. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_github_bridge.py +0 -0
  14. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_methodology_gates.py +0 -0
  15. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/data/git-hooks/prepare-commit-msg +0 -0
  16. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/data/onboarding-scope/AGENTS.md +0 -0
  17. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/data/onboarding-scope/skills/dna-sdlc-cli/SKILL.md +0 -0
  18. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/doc_cmd.py +0 -0
  19. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/docs_cmd.py +0 -0
  20. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/eval_cmd.py +0 -0
  21. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/hooks_cmd.py +0 -0
  22. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/install_cmd.py +0 -0
  23. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/issue_bridge_cmd.py +0 -0
  24. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/kind_cmd.py +0 -0
  25. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/memory_cmd.py +0 -0
  26. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/pr_cmd.py +0 -0
  27. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/recall_cmd.py +0 -0
  28. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/research_cmd.py +0 -0
  29. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/scope_cmd.py +0 -0
  30. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/sdlc_cmd.py +0 -0
  31. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/source_cmd.py +0 -0
  32. {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/testkit_cmd.py +0 -0
  33. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/conftest.py +0 -0
  34. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_changelog_cmd.py +0 -0
  35. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_doc_apply_bundle_entries.py +0 -0
  36. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_doc_apply_prompt_budget.py +0 -0
  37. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_docs_cmd.py +0 -0
  38. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_eval_cmd.py +0 -0
  39. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_focus_gates_cli.py +0 -0
  40. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_focus_gates_guards.py +0 -0
  41. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_git_symbiosis_hook.py +0 -0
  42. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_hooks_cli.py +0 -0
  43. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_install_cmd.py +0 -0
  44. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_install_github_real.py +0 -0
  45. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_issue_bridge_cli.py +0 -0
  46. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_issue_comment_cli.py +0 -0
  47. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_kaizen_event.py +0 -0
  48. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_kaizen_transitions.py +0 -0
  49. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_memory_cmd.py +0 -0
  50. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_plan_from_file.py +0 -0
  51. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_produces_cmd.py +0 -0
  52. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_recall_cmd.py +0 -0
  53. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_research_cmd.py +0 -0
  54. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_scope_detect.py +0 -0
  55. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_sdlc_brief.py +0 -0
  56. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_sdlc_correctness.py +0 -0
  57. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_sdlc_ergonomics.py +0 -0
  58. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_sdlc_scope_default.py +0 -0
  59. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_sdlc_workitem_cli.py +0 -0
  60. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_source_url_resolution.py +0 -0
  61. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_spike_cli.py +0 -0
  62. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_check.py +0 -0
  63. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_commits_backref.py +0 -0
  64. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_done_guard.py +0 -0
  65. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_pr_cli.py +0 -0
  66. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_review_pr_guard.py +0 -0
  67. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_show.py +0 -0
  68. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_start_gate.py +0 -0
  69. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_tenant_write_note.py +0 -0
  70. {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_testkit_cmd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dna-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: DNA command-line interface — the `dna` binary for the declarative SDLC + document CRUD (kernel-local, no service required)
5
5
  Project-URL: Homepage, https://ruinosus.github.io/dna/
6
6
  Project-URL: Repository, https://github.com/ruinosus/dna
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Requires-Python: <3.14,>=3.12
18
18
  Requires-Dist: click>=8.1.0
19
- Requires-Dist: dna-sdk<0.3,>=0.2
19
+ Requires-Dist: dna-sdk<0.4,>=0.3
20
20
  Requires-Dist: pyyaml>=6.0
21
21
  Requires-Dist: rich>=13.0
22
22
  Provides-Extra: dev
@@ -28,6 +28,26 @@ by the SDK's own market readers/writers, not by copying text templates:
28
28
  hooks dir (same semantics as ``dna sdlc hooks install``); skipped
29
29
  with a note when the directory is not a git repository.
30
30
 
31
+ ``--from <uri>`` (i-015) swaps the EMBEDDED onboarding assets for a
32
+ distributed **onboarding pack** — a repository subtree carrying your
33
+ team's own Skill bundle(s) and (optionally) an AGENTS.md. The fetch and
34
+ the untrusted-input validation are the exact machinery of ``dna install``
35
+ (``install_cmd._fetch`` / ``_scan_tree`` / ``_validate_doc`` — one code
36
+ path, the defenses can never drift); only the DESTINATION differs:
37
+ ``dna install`` writes documents into the ``.dna/`` source, ``dna init
38
+ --from`` PROJECTS Kinds into tool directories. The two compose: run
39
+ ``dna install <uri>`` with the same ref when you also want the pack's
40
+ docs on the board.
41
+
42
+ SECURITY (``--from``) — pack content is UNTRUSTED DATA: only registered
43
+ Kinds pass, each ``spec`` is schema-validated before any projection,
44
+ document names must be plain slugs (path-shaped names never reach the
45
+ projection paths), and a Genome in the pack is ignored (a pack never
46
+ redefines the board scope). The residual risk is inherent to the
47
+ artifact: a Skill IS agent instructions — installing a third-party pack
48
+ is installing a dependency, and the summary says so ("review before
49
+ committing").
50
+
31
51
  Design evidence: Research/rsh-cross-tool-agent-standards (dna-development
32
52
  board) — the cross-tool adoption facts behind the multi-tool projection.
33
53
 
@@ -39,6 +59,7 @@ from __future__ import annotations
39
59
  import asyncio
40
60
  import re
41
61
  import stat
62
+ from dataclasses import dataclass, field
42
63
  from importlib.resources import files as _pkg_files
43
64
  from pathlib import Path
44
65
 
@@ -48,6 +69,11 @@ from dna_cli._ctx import build_source_from_env, fail, print_json
48
69
 
49
70
  SKILL_NAME = "dna-sdlc-cli"
50
71
 
72
+ #: The (apiVersion, kind) pairs `dna init` knows how to PROJECT. Everything
73
+ #: else a pack may carry is board content — `dna install`'s territory.
74
+ _SKILL_KEY = ("agentskills.io/v1", "Skill")
75
+ _AGENTS_KEY = ("agents.md/v1", "AgentDefinition")
76
+
51
77
  #: Container dirs seeded (with .gitkeep) so the fresh board is recognized by
52
78
  #: the CLI's sole-SDLC-scope autodetection (``_autodetect_sdlc_scope`` probes
53
79
  #: for these) and survives a git clone before the first Story lands.
@@ -98,6 +124,134 @@ def _onboarding_root() -> Path:
98
124
  return Path(str(_pkg_files("dna_cli").joinpath("data/onboarding-scope")))
99
125
 
100
126
 
127
+ # ─── onboarding pack (embedded or --from) ─────────────────────────────
128
+
129
+
130
+ @dataclass
131
+ class OnboardingPack:
132
+ """What `dna init` projects: skills + the AGENTS.md instruction surface.
133
+
134
+ ``skills`` are raw ``agentskills.io/v1`` Skill docs (already validated
135
+ when the pack is remote); ``agents_raw`` is the raw AgentDefinition or
136
+ ``None`` (the caller falls back to the embedded one, with a note).
137
+ ``label`` names the pack origin for the summary; ``notes`` carry
138
+ per-document rejections/ignores so nothing disappears silently.
139
+ """
140
+ skills: list[dict]
141
+ agents_raw: dict | None
142
+ label: str
143
+ remote: bool = False
144
+ notes: list[str] = field(default_factory=list)
145
+
146
+
147
+ def _load_embedded_pack() -> OnboardingPack:
148
+ """The onboarding scope shipped inside dna-cli (the default pack)."""
149
+ from dna.extensions.agentskills import SkillReader
150
+ from dna.extensions.agentsmd import AgentDefinitionReader
151
+ from dna.kernel.bundle_handle import FilesystemBundleHandle
152
+
153
+ root = _onboarding_root()
154
+ skill = SkillReader().read(FilesystemBundleHandle(root / "skills" / SKILL_NAME))
155
+ agents = AgentDefinitionReader().read(FilesystemBundleHandle(root))
156
+ return OnboardingPack(skills=[skill], agents_raw=agents, label="embedded")
157
+
158
+
159
+ def _normalize_from_uri(value: str) -> str:
160
+ """Accept ``github:``/``local:`` URIs plus a bare existing directory
161
+ (sugar for ``local:<abspath>`` — the offline-friendly authoring loop)."""
162
+ if value.startswith(("github:", "local:")):
163
+ return value
164
+ path = Path(value).expanduser()
165
+ if path.is_dir():
166
+ return f"local:{path.resolve()}"
167
+ raise fail(
168
+ f"--from {value!r}: not a github:owner/repo[/subdir][@ref] or "
169
+ f"local:<path> URI, and not an existing directory either"
170
+ )
171
+
172
+
173
+ def _load_pack_from(from_uri: str) -> OnboardingPack:
174
+ """Fetch + validate a distributed onboarding pack (the i-015 channel).
175
+
176
+ Reuses ``dna install``'s machinery verbatim — ``_fetch`` (GitHubResolver
177
+ / local trees), ``_scan_tree`` (reader-driven walk) and ``_validate_doc``
178
+ (the first defense against untrusted input: registered-Kind-only, JSON
179
+ Schema, slug-only names). One deliberate difference from install's scan:
180
+ the walk runs with the SkillReader only, and the pack root is probed for
181
+ AGENTS.md separately — a reader that claims a directory stops the
182
+ recursion there, so scanning with the agentsmd reader would let a
183
+ root-level AGENTS.md hide the pack's skills.
184
+ """
185
+ from dna.extensions.agentsmd import AgentDefinitionReader
186
+ from dna.extensions.agentskills import SkillReader
187
+ from dna.kernel import Kernel
188
+ from dna.kernel.bundle_handle import FilesystemBundleHandle
189
+ from dna_cli.install_cmd import ScannedDoc, _fetch, _scan_tree, _validate_doc
190
+
191
+ fetched = _fetch(_normalize_from_uri(from_uri))
192
+ kernel = Kernel.auto() # Kind registry only — no source is touched
193
+
194
+ pack = OnboardingPack(
195
+ skills=[], agents_raw=None, label=fetched.describe, remote=True,
196
+ )
197
+ seen: set[str] = set()
198
+ scanned = _scan_tree(fetched.root, [SkillReader()])
199
+
200
+ # The instruction surface: AGENTS.md at the pack ROOT (the agents.md
201
+ # convention), read with the same reader the embedded path uses.
202
+ agents_reader = AgentDefinitionReader()
203
+ root_handle = FilesystemBundleHandle(fetched.root)
204
+ if agents_reader.detect(root_handle):
205
+ raw = agents_reader.read(root_handle)
206
+ reason = _validate_doc(kernel, ScannedDoc(raw=raw, rel_path="AGENTS.md"))
207
+ if reason:
208
+ pack.notes.append(f"AGENTS.md: rejected — {reason}")
209
+ else:
210
+ pack.agents_raw = raw
211
+
212
+ for sd in scanned:
213
+ raw = sd.raw
214
+ key = (str(raw.get("apiVersion", "")), str(raw.get("kind", "")))
215
+ label = f"{sd.rel_path}"
216
+ if key == _AGENTS_KEY:
217
+ continue # root AGENTS.md is the surface; bundles stay skills-only
218
+ if key != _SKILL_KEY:
219
+ kp = kernel.kind_port_for(key[1], api_version=key[0])
220
+ if kp is not None and getattr(kp, "is_root", False):
221
+ pack.notes.append(
222
+ f"{label}: Genome ignored — a pack never redefines the "
223
+ f"board scope"
224
+ )
225
+ else:
226
+ pack.notes.append(
227
+ f"{label}: {key[1]} is not projectable by `dna init` — "
228
+ f"only Skill bundles + AGENTS.md are; `dna install` is "
229
+ f"the channel for board content"
230
+ )
231
+ continue
232
+ reason = _validate_doc(kernel, sd)
233
+ if reason:
234
+ pack.notes.append(f"{label}: rejected — {reason}")
235
+ continue
236
+ name = str(raw["metadata"]["name"])
237
+ if name in seen:
238
+ pack.notes.append(
239
+ f"{label}: duplicate skill {name!r} — the first one wins"
240
+ )
241
+ continue
242
+ seen.add(name)
243
+ pack.skills.append(raw)
244
+
245
+ if not pack.skills:
246
+ detail = "\n".join(f" - {n}" for n in pack.notes) or " (empty tree)"
247
+ raise fail(
248
+ f"no valid Skill found in {fetched.describe} — an onboarding "
249
+ f"pack must carry at least one agentskills.io Skill bundle "
250
+ f"(skills/<name>/SKILL.md).\n{detail}"
251
+ )
252
+ return pack
253
+
254
+
101
255
  def _derive_scope(target: Path) -> str:
102
256
  """Default board-scope name: ``<dirname>-dev``, slugified.
103
257
 
@@ -158,46 +312,60 @@ async def _bootstrap_board(target: Path, scope: str) -> None:
158
312
 
159
313
 
160
314
  def _materialize_skills(
161
- target: Path, force: bool, tools: list[str],
315
+ target: Path, force: bool, tools: list[str], pack: OnboardingPack,
162
316
  ) -> list[tuple[str, str, str]]:
163
- """Skill Kind → one projection per selected tool, via reader→writer.
164
-
165
- The embedded bundle is parsed ONCE with the registered
166
- ``agentskills-skill`` reader and re-emitted with the byte-faithful
167
- SkillWriter into each tool's skill directory — the same round-trip
168
- machinery the market-conformance suites enforce. Returns one
169
- ``(step, outcome, relative path)`` per tool.
317
+ """Skill Kind(s) → one projection per selected tool, via reader→writer.
318
+
319
+ Each pack skill was parsed ONCE with the registered ``agentskills-skill``
320
+ reader and is re-emitted with the byte-faithful SkillWriter into each
321
+ tool's skill directory — the same round-trip machinery the
322
+ market-conformance suites enforce. Returns one ``(step, outcome,
323
+ relative path)`` per (skill, tool). The embedded pack keeps the historic
324
+ ``skill[<tool>]`` step ids; a remote pack may carry several skills, so
325
+ its step ids are ``skill[<tool>:<name>]`` (unique in ``--json``).
170
326
  """
171
- from dna.extensions.agentskills import SkillReader, SkillWriter
327
+ from dna.extensions.agentskills import SkillWriter
172
328
  from dna.kernel.bundle_handle import FilesystemBundleHandle
173
329
 
174
- raw = SkillReader().read(
175
- FilesystemBundleHandle(_onboarding_root() / "skills" / SKILL_NAME)
176
- )
177
330
  writer = SkillWriter()
178
331
  results: list[tuple[str, str, str]] = []
179
- for tool in tools:
180
- rel = f"{TOOL_SKILL_DIRS[tool]}/{SKILL_NAME}"
181
- dest = target / TOOL_SKILL_DIRS[tool] / SKILL_NAME
182
- if (dest / "SKILL.md").exists() and not force:
183
- results.append((f"skill[{tool}]", _SKIPPED, rel))
184
- continue
185
- dest.mkdir(parents=True, exist_ok=True)
186
- writer.write(FilesystemBundleHandle(dest), raw)
187
- results.append((f"skill[{tool}]", _CREATED, rel))
332
+ for raw in pack.skills:
333
+ name = str(raw["metadata"]["name"])
334
+ for tool in tools:
335
+ step = f"skill[{tool}:{name}]" if pack.remote else f"skill[{tool}]"
336
+ rel = f"{TOOL_SKILL_DIRS[tool]}/{name}"
337
+ dest = target / TOOL_SKILL_DIRS[tool] / name
338
+ if (dest / "SKILL.md").exists() and not force:
339
+ results.append((step, _SKIPPED, rel))
340
+ continue
341
+ dest.mkdir(parents=True, exist_ok=True)
342
+ writer.write(FilesystemBundleHandle(dest), raw)
343
+ results.append((step, _CREATED, rel))
188
344
  return results
189
345
 
190
346
 
191
- def _materialize_agents_md(target: Path, force: bool) -> tuple[str, str]:
192
- """AgentDefinition Kind ``<target>/AGENTS.md`` via reader→writer."""
347
+ def _materialize_agents_md(
348
+ target: Path, force: bool, pack: OnboardingPack,
349
+ ) -> tuple[str, str]:
350
+ """AgentDefinition Kind → ``<target>/AGENTS.md`` via reader→writer.
351
+
352
+ A pack without an AGENTS.md falls back to the EMBEDDED one (with an
353
+ explicit note): the project must still end up with the canonical
354
+ instruction surface — a team distributing only a skill inherits the
355
+ default conventions file rather than an agent-blind root.
356
+ """
193
357
  from dna.extensions.agentsmd import AgentDefinitionReader, AgentDefinitionWriter
194
358
  from dna.kernel.bundle_handle import FilesystemBundleHandle
195
359
 
360
+ detail = "AGENTS.md"
361
+ raw = pack.agents_raw
362
+ if raw is None:
363
+ raw = AgentDefinitionReader().read(FilesystemBundleHandle(_onboarding_root()))
364
+ detail = "AGENTS.md (embedded default — the pack ships none)"
196
365
  if (target / "AGENTS.md").exists() and not force:
197
- return _SKIPPED, "AGENTS.md"
198
- raw = AgentDefinitionReader().read(FilesystemBundleHandle(_onboarding_root()))
366
+ return _SKIPPED, detail
199
367
  AgentDefinitionWriter().write(FilesystemBundleHandle(target), raw)
200
- return _CREATED, "AGENTS.md"
368
+ return _CREATED, detail
201
369
 
202
370
 
203
371
  def _install_hooks(target: Path) -> tuple[str, str]:
@@ -256,9 +424,20 @@ def _install_hooks(target: Path) -> tuple[str, str]:
256
424
  "AGENTS.md). The board Genome is never rewritten — an existing "
257
425
  "board is verified and kept.",
258
426
  )
427
+ @click.option(
428
+ "--from", "from_opt", default=None, metavar="URI",
429
+ help="Project a DISTRIBUTED onboarding pack instead of the embedded "
430
+ "one: `github:owner/repo[/subdir][@ref]`, `local:<path>`, or a "
431
+ "bare directory path. The pack must carry at least one agentskills.io "
432
+ "Skill bundle; a root AGENTS.md replaces the embedded instruction "
433
+ "surface (absent: the embedded one is used, with a note). Pack "
434
+ "content is validated with the same defenses as `dna install` and "
435
+ "only PROJECTED into tool directories — combine with `dna install "
436
+ "<same-uri>` when you also want it on the board.",
437
+ )
259
438
  @click.option("--json", "as_json", is_flag=True, help="Machine-readable summary.")
260
439
  def init(scope_opt: str | None, dir_opt: str, tools_opt: str,
261
- force: bool, as_json: bool) -> None:
440
+ force: bool, from_opt: str | None, as_json: bool) -> None:
262
441
  """Make a project agent-ready: board + skill + AGENTS.md + git hooks.
263
442
 
264
443
  One command bootstraps everything an AI coding agent needs to work
@@ -278,6 +457,14 @@ def init(scope_opt: str | None, dir_opt: str, tools_opt: str,
278
457
  readers/writers — one Kind, N regenerable projections. AGENTS.md serves
279
458
  every tool at once; Gemini CLI users can point GEMINI.md at it.
280
459
 
460
+ With --from, the skills + AGENTS.md come from a DISTRIBUTED onboarding
461
+ pack (your team's own conventions) instead of the embedded scope. The
462
+ pack is fetched and validated with the same machinery as `dna install`
463
+ (untrusted-input defenses included) but only PROJECTED into tool
464
+ directories — nothing from the pack is written to the .dna/ source.
465
+ Review the projected files before committing: a skill is agent
466
+ instructions; treat a third-party pack like a dependency.
467
+
281
468
  Idempotent: re-running never overwrites an existing file unless
282
469
  --force is given; the summary reports what was created vs skipped.
283
470
 
@@ -289,6 +476,8 @@ def init(scope_opt: str | None, dir_opt: str, tools_opt: str,
289
476
  dna init --tools all # every supported tool dir
290
477
  dna init --tools claude,cursor # explicit projection set
291
478
  dna init --dir ../other-project # initialize another directory
479
+ dna init --from github:acme/onboarding-pack@v1 # your team's pack
480
+ dna init --from local:../onboarding-pack # offline authoring loop
292
481
  """
293
482
  target = Path(dir_opt)
294
483
  if not target.is_dir():
@@ -301,9 +490,13 @@ def init(scope_opt: str | None, dir_opt: str, tools_opt: str,
301
490
  )
302
491
  tools = _parse_tools(tools_opt)
303
492
 
493
+ # 0. The onboarding pack — embedded, or fetched + validated via --from.
494
+ pack = _load_pack_from(from_opt) if from_opt else _load_embedded_pack()
495
+
304
496
  results: list[tuple[str, str, str]] = [] # (step, outcome, detail)
305
497
 
306
- # 1. Board — create when missing, verify + keep when present.
498
+ # 1. Board — create when missing, verify + keep when present. Always the
499
+ # LOCAL Genome (derived scope): a pack never redefines the board.
307
500
  board_rel = f".dna/{scope}"
308
501
  if _board_exists(target, scope):
309
502
  results.append(("board", _SKIPPED, f"{board_rel} already exists"))
@@ -311,32 +504,48 @@ def init(scope_opt: str | None, dir_opt: str, tools_opt: str,
311
504
  asyncio.run(_bootstrap_board(target, scope))
312
505
  results.append(("board", _CREATED, board_rel))
313
506
 
314
- # 2. Skill Kind → one projection per tool (byte-faithful writer).
315
- results.extend(_materialize_skills(target, force, tools))
507
+ # 2. Skill Kind(s) → one projection per tool (byte-faithful writer).
508
+ results.extend(_materialize_skills(target, force, tools, pack))
316
509
 
317
510
  # 3. AGENTS.md (agentsmd-agent Kind) — the canonical instruction surface.
318
- outcome, detail = _materialize_agents_md(target, force)
511
+ outcome, detail = _materialize_agents_md(target, force, pack)
319
512
  results.append(("agents-md", outcome, detail))
320
513
 
321
514
  # 4. Git hooks (Work-Item trailers).
322
515
  outcome, detail = _install_hooks(target)
323
516
  results.append(("hooks", outcome, detail))
324
517
 
518
+ review_note = (
519
+ "pack content is third-party — review the projected files before "
520
+ "committing (a skill is agent instructions; treat a pack like a "
521
+ "dependency)"
522
+ ) if pack.remote else None
523
+
325
524
  if as_json:
326
- print_json({
525
+ payload = {
327
526
  "dir": str(target),
328
527
  "scope": scope,
329
528
  "steps": [
330
529
  {"step": s, "outcome": o, "detail": d} for s, o, d in results
331
530
  ],
332
- })
531
+ }
532
+ if pack.remote:
533
+ payload["from"] = pack.label
534
+ payload["notes"] = pack.notes
535
+ payload["review_note"] = review_note
536
+ print_json(payload)
333
537
  return
334
538
 
335
- click.secho(f"dna init — {target} (board scope: {scope})", bold=True)
539
+ header = f"dna init — {target} (board scope: {scope})"
540
+ if pack.remote:
541
+ header += f"\n pack: {pack.label}"
542
+ click.secho(header, bold=True)
336
543
  step_width = max(len(s) for s, _, _ in results)
337
544
  for step, outcome, detail in results:
338
545
  color = "green" if outcome == _CREATED else "yellow"
339
546
  click.secho(f" {outcome:<7} {step:<{step_width}} {detail}", fg=color)
547
+ for note in pack.notes:
548
+ click.secho(f" note {note}", fg="yellow", dim=True)
340
549
  n_created = sum(1 for _, o, _ in results if o == _CREATED)
341
550
  n_skipped = len(results) - n_created
342
551
  click.secho(
@@ -344,12 +553,17 @@ def init(scope_opt: str | None, dir_opt: str, tools_opt: str,
344
553
  + ("" if force or not n_skipped else " (re-run with --force to overwrite files)"),
345
554
  bold=True,
346
555
  )
556
+ if review_note:
557
+ click.secho(f"\n⚠ {review_note}", fg="yellow", bold=True)
558
+ skill_names = ", ".join(
559
+ str(raw["metadata"]["name"]) for raw in pack.skills
560
+ )
347
561
  click.echo(
348
562
  "\nNext steps:\n"
349
563
  " dna sdlc feature create f-my-area --title \"...\" --desc \"...\"\n"
350
564
  " dna sdlc story create s-my-first-story --feature f-my-area --desc \"...\" \\\n"
351
565
  " --ac \"Given/When/Then ...\" --dod \"code+tests+docs\"\n"
352
566
  " dna sdlc story start s-my-first-story --plan \"plan of attack\"\n"
353
- f" (your agent: read AGENTS.md + the {SKILL_NAME} skill in its "
354
- f"tool's skills dir)"
567
+ f" (your agent: read AGENTS.md + the {skill_names} skill"
568
+ f"{'s' if len(pack.skills) > 1 else ''} in its tool's skills dir)"
355
569
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dna-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "DNA command-line interface — the `dna` binary for the declarative SDLC + document CRUD (kernel-local, no service required)"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,7 +24,7 @@ dependencies = [
24
24
  # replaces the registry lookup with the workspace path source below
25
25
  # ([tool.uv.sources] — editable ../sdk-py), so dev installs keep tracking
26
26
  # the sibling package live; the constraint only bites consumers.
27
- "dna-sdk>=0.2,<0.3",
27
+ "dna-sdk>=0.3,<0.4",
28
28
  ]
29
29
 
30
30
  [project.scripts]
@@ -56,7 +56,7 @@ packages = ["dna_cli"]
56
56
  # sibling package (editable), NOT from PyPI — the registry constraint above
57
57
  # is what published artifacts carry. Two paths, one name:
58
58
  # dev (this repo) → ../sdk-py editable, always current
59
- # user (pip/uv add) → PyPI dna-sdk>=0.2,<0.3
59
+ # user (pip/uv add) → PyPI dna-sdk>=0.3,<0.4
60
60
  [tool.uv.sources]
61
61
  dna-sdk = { path = "../sdk-py", editable = true }
62
62
 
@@ -274,3 +274,201 @@ def test_init_json_output(runner, project):
274
274
  "skill[claude]": "created", "skill[copilot]": "created",
275
275
  "agents-md": "created", "hooks": "created",
276
276
  }
277
+ # The embedded path stays byte-compatible: no pack keys leak in.
278
+ assert "from" not in payload and "notes" not in payload
279
+
280
+
281
+ # --- --from: distributed onboarding packs (i-015) --------------------------------
282
+
283
+ _PACK_SKILL_MD = """---
284
+ name: team-conventions
285
+ description: The team's house conventions for agent work
286
+ ---
287
+
288
+ # Team conventions
289
+
290
+ Follow the house rules.
291
+ """
292
+
293
+ _PACK_SECOND_SKILL_MD = """---
294
+ name: team-review
295
+ description: How the team reviews pull requests
296
+ ---
297
+
298
+ # Review guide
299
+
300
+ Review with care.
301
+ """
302
+
303
+ _PACK_AGENTS_MD = "# Team AGENTS\n\nHouse instruction surface.\n"
304
+
305
+
306
+ def _make_pack(
307
+ tmp_path: Path, *, agents: bool = True, skills: bool = True,
308
+ ) -> Path:
309
+ pack = tmp_path / "pack"
310
+ pack.mkdir(exist_ok=True)
311
+ if agents:
312
+ (pack / "AGENTS.md").write_text(_PACK_AGENTS_MD, encoding="utf-8")
313
+ if skills:
314
+ d = pack / "skills" / "team-conventions"
315
+ d.mkdir(parents=True)
316
+ (d / "SKILL.md").write_text(_PACK_SKILL_MD, encoding="utf-8")
317
+ return pack
318
+
319
+
320
+ def test_init_from_local_pack_projects_pack_assets(runner, project, tmp_path):
321
+ pack = _make_pack(tmp_path)
322
+ r = runner.invoke(init, ["--scope", "acme-dev", "--from", f"local:{pack}"])
323
+ assert r.exit_code == 0, r.output
324
+
325
+ # Pack skill projected for the default tools; embedded skill NOT projected.
326
+ for tool in ("claude", "copilot"):
327
+ skill_md = project / TOOL_SKILL_DIRS[tool] / "team-conventions" / "SKILL.md"
328
+ assert skill_md.exists(), tool
329
+ assert not (
330
+ project / TOOL_SKILL_DIRS[tool] / SKILL_NAME / "SKILL.md"
331
+ ).exists(), tool
332
+
333
+ # Pack AGENTS.md replaces the embedded instruction surface.
334
+ assert (project / "AGENTS.md").read_text() == _PACK_AGENTS_MD
335
+
336
+ # Board is still born from the LOCAL scope — never from the pack.
337
+ assert (project / ".dna" / "acme-dev" / "manifest.yaml").exists()
338
+
339
+ # The step ids carry the skill name, and the security note is printed.
340
+ assert "skill[claude:team-conventions]" in r.output
341
+ assert "review the projected files before committing" in r.output
342
+
343
+
344
+ def test_init_from_accepts_bare_directory_path(runner, project, tmp_path):
345
+ pack = _make_pack(tmp_path)
346
+ r = runner.invoke(init, ["--scope", "acme-dev", "--from", str(pack)])
347
+ assert r.exit_code == 0, r.output
348
+ assert (project / ".claude" / "skills" / "team-conventions" / "SKILL.md").exists()
349
+
350
+ r = CliRunner().invoke(init, ["--from", "does/not/exist"])
351
+ assert r.exit_code != 0
352
+ assert "not an existing directory" in (r.output or "") + str(r.exception or "")
353
+
354
+
355
+ def test_init_from_pack_without_agents_md_falls_back_to_embedded(
356
+ runner, project, tmp_path,
357
+ ):
358
+ pack = _make_pack(tmp_path, agents=False)
359
+ r = runner.invoke(init, ["--scope", "acme-dev", "--from", f"local:{pack}"])
360
+ assert r.exit_code == 0, r.output
361
+ # Fallback is explicit in the summary, and the content is the embedded one.
362
+ assert "embedded default" in r.output
363
+ embedded = (_onboarding_root() / "AGENTS.md").read_text()
364
+ assert (project / "AGENTS.md").read_text() == embedded
365
+
366
+
367
+ def test_init_from_pack_without_any_skill_fails_didactically(
368
+ runner, project, tmp_path,
369
+ ):
370
+ pack = _make_pack(tmp_path, skills=False)
371
+ r = runner.invoke(init, ["--scope", "acme-dev", "--from", f"local:{pack}"])
372
+ assert r.exit_code != 0
373
+ msg = (r.output or "") + str(r.exception or "")
374
+ assert "no valid Skill found" in msg
375
+ # Nothing was projected — the failure happens before any write.
376
+ assert not (project / "AGENTS.md").exists()
377
+ assert not (project / ".dna").exists()
378
+ assert not (project / ".claude").exists()
379
+
380
+
381
+ def test_init_from_multi_skill_pack_projects_all(runner, project, tmp_path):
382
+ pack = _make_pack(tmp_path)
383
+ d = pack / "skills" / "team-review"
384
+ d.mkdir(parents=True)
385
+ (d / "SKILL.md").write_text(_PACK_SECOND_SKILL_MD, encoding="utf-8")
386
+ r = runner.invoke(init, [
387
+ "--scope", "acme-dev", "--tools", "claude", "--from", f"local:{pack}",
388
+ "--json",
389
+ ])
390
+ assert r.exit_code == 0, r.output
391
+ payload = json.loads(r.output)
392
+ steps = {s["step"]: s["outcome"] for s in payload["steps"]}
393
+ assert steps["skill[claude:team-conventions]"] == "created"
394
+ assert steps["skill[claude:team-review]"] == "created"
395
+ assert payload["from"]
396
+ assert "review the projected files" in payload["review_note"]
397
+
398
+
399
+ def test_init_from_is_idempotent_and_force_overwrites(runner, project, tmp_path):
400
+ pack = _make_pack(tmp_path)
401
+ assert runner.invoke(
402
+ init, ["--scope", "acme-dev", "--from", f"local:{pack}"],
403
+ ).exit_code == 0
404
+
405
+ skill_md = project / ".claude" / "skills" / "team-conventions" / "SKILL.md"
406
+ skill_md.write_text("---\nname: team-conventions\n---\n\ncustomized\n")
407
+ r = runner.invoke(init, ["--scope", "acme-dev", "--from", f"local:{pack}"])
408
+ assert r.exit_code == 0, r.output
409
+ assert "0 created" in r.output
410
+ assert "customized" in skill_md.read_text()
411
+
412
+ r = runner.invoke(
413
+ init, ["--scope", "acme-dev", "--from", f"local:{pack}", "--force"],
414
+ )
415
+ assert r.exit_code == 0, r.output
416
+ assert "house rules" in skill_md.read_text()
417
+
418
+
419
+ def test_init_from_rejects_untrusted_input_like_install(runner, project, tmp_path):
420
+ """The install defenses run verbatim: path-shaped names, schema-invalid
421
+ specs and pack Genomes never reach the projection paths."""
422
+ pack = _make_pack(tmp_path)
423
+ (pack / "evil.yaml").write_text(
424
+ "apiVersion: agentskills.io/v1\n"
425
+ "kind: Skill\n"
426
+ "metadata:\n name: ../../escape\n"
427
+ "spec:\n instruction: nope\n",
428
+ encoding="utf-8",
429
+ )
430
+ (pack / "bad.yaml").write_text(
431
+ "apiVersion: agentskills.io/v1\n"
432
+ "kind: Skill\n"
433
+ "metadata:\n name: bad-skill\n"
434
+ "spec:\n instruction: 42\n",
435
+ encoding="utf-8",
436
+ )
437
+ (pack / "Genome.yaml").write_text(
438
+ "apiVersion: github.com/ruinosus/dna/v1\n"
439
+ "kind: Genome\n"
440
+ "metadata: { name: evil-takeover }\n"
441
+ "spec: {}\n",
442
+ encoding="utf-8",
443
+ )
444
+ r = runner.invoke(init, ["--scope", "acme-dev", "--from", f"local:{pack}"])
445
+ assert r.exit_code == 0, r.output
446
+
447
+ # The valid skill landed; the hostile/broken ones did not.
448
+ assert (project / ".claude" / "skills" / "team-conventions" / "SKILL.md").exists()
449
+ for leaked in ("escape", "bad-skill", "evil-takeover"):
450
+ assert not list(project.rglob(f"*{leaked}*")), leaked
451
+ # The board scope is the LOCAL one — the pack Genome was ignored.
452
+ assert (project / ".dna" / "acme-dev" / "manifest.yaml").exists()
453
+ assert not (project / ".dna" / "evil-takeover").exists()
454
+ # Every rejection is visible, never silent.
455
+ assert "not a plain slug" in r.output
456
+ assert "schema validation failed" in r.output
457
+ assert "Genome ignored" in r.output
458
+
459
+
460
+ def test_init_from_example_pack_in_this_repo(runner, project):
461
+ """Dogfood: the repo's own examples/onboarding-pack is a valid pack and
462
+ its SKILL.md is writer-canonical (projection is byte-identical)."""
463
+ repo_root = Path(__file__).resolve().parents[3]
464
+ pack = repo_root / "examples" / "onboarding-pack"
465
+ assert (pack / "skills" / "acme-conventions" / "SKILL.md").exists()
466
+ r = runner.invoke(init, ["--scope", "acme-dev", "--from", f"local:{pack}"])
467
+ assert r.exit_code == 0, r.output
468
+ projected = project / ".claude" / "skills" / "acme-conventions" / "SKILL.md"
469
+ assert projected.read_bytes() == (
470
+ pack / "skills" / "acme-conventions" / "SKILL.md"
471
+ ).read_bytes()
472
+ assert (project / "AGENTS.md").read_bytes() == (
473
+ pack / "AGENTS.md"
474
+ ).read_bytes()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes