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.
- {dna_cli-0.2.0 → dna_cli-0.3.0}/PKG-INFO +2 -2
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/init_cmd.py +250 -36
- {dna_cli-0.2.0 → dna_cli-0.3.0}/pyproject.toml +3 -3
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_init_cmd.py +198 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/.gitignore +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/LICENSE +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/README.md +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/__init__.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_active_story.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_banner.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_ctx.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_git_symbiosis.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_github_bridge.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/_methodology_gates.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/data/git-hooks/prepare-commit-msg +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/data/onboarding-scope/AGENTS.md +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/data/onboarding-scope/skills/dna-sdlc-cli/SKILL.md +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/doc_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/docs_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/eval_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/hooks_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/install_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/issue_bridge_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/kind_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/memory_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/pr_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/recall_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/research_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/scope_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/sdlc_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/source_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/dna_cli/testkit_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/conftest.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_changelog_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_doc_apply_bundle_entries.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_doc_apply_prompt_budget.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_docs_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_eval_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_focus_gates_cli.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_focus_gates_guards.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_git_symbiosis_hook.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_hooks_cli.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_install_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_install_github_real.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_issue_bridge_cli.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_issue_comment_cli.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_kaizen_event.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_kaizen_transitions.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_memory_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_plan_from_file.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_produces_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_recall_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_research_cmd.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_scope_detect.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_sdlc_brief.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_sdlc_correctness.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_sdlc_ergonomics.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_sdlc_scope_default.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_sdlc_workitem_cli.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_source_url_resolution.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_spike_cli.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_check.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_commits_backref.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_done_guard.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_pr_cli.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_review_pr_guard.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_show.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_story_start_gate.py +0 -0
- {dna_cli-0.2.0 → dna_cli-0.3.0}/tests/test_tenant_write_note.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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(
|
|
192
|
-
|
|
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,
|
|
198
|
-
raw = AgentDefinitionReader().read(FilesystemBundleHandle(_onboarding_root()))
|
|
366
|
+
return _SKIPPED, detail
|
|
199
367
|
AgentDefinitionWriter().write(FilesystemBundleHandle(target), raw)
|
|
200
|
-
return _CREATED,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|