dna-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dna_cli/__init__.py +87 -0
- dna_cli/_active_story.py +134 -0
- dna_cli/_banner.py +61 -0
- dna_cli/_ctx.py +433 -0
- dna_cli/_git_symbiosis.py +167 -0
- dna_cli/_github_bridge.py +294 -0
- dna_cli/_methodology_gates.py +164 -0
- dna_cli/data/git-hooks/prepare-commit-msg +132 -0
- dna_cli/doc_cmd.py +842 -0
- dna_cli/docs_cmd.py +79 -0
- dna_cli/eval_cmd.py +274 -0
- dna_cli/hooks_cmd.py +146 -0
- dna_cli/install_cmd.py +496 -0
- dna_cli/issue_bridge_cmd.py +305 -0
- dna_cli/kind_cmd.py +84 -0
- dna_cli/memory_cmd.py +298 -0
- dna_cli/pr_cmd.py +251 -0
- dna_cli/recall_cmd.py +161 -0
- dna_cli/research_cmd.py +284 -0
- dna_cli/scope_cmd.py +129 -0
- dna_cli/sdlc_cmd.py +6549 -0
- dna_cli/source_cmd.py +437 -0
- dna_cli/testkit_cmd.py +352 -0
- dna_cli-0.1.0.dist-info/METADATA +57 -0
- dna_cli-0.1.0.dist-info/RECORD +28 -0
- dna_cli-0.1.0.dist-info/WHEEL +4 -0
- dna_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dna_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
dna_cli/source_cmd.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""``dna source`` — declarative source replicas + introspection.
|
|
2
|
+
|
|
3
|
+
Operates on ``<repo>/.dna-replicas.yaml`` (auto-discovered via upward
|
|
4
|
+
walk from cwd, just like git locates ``.gitignore``). The CLI never
|
|
5
|
+
touches the kernel directly: it only edits the YAML file. The harness
|
|
6
|
+
reads the file at boot and registers the post_save/post_delete
|
|
7
|
+
subscribers (host platforms wire the replica engine at boot).
|
|
8
|
+
|
|
9
|
+
Subcommands::
|
|
10
|
+
|
|
11
|
+
dna source replica add <id> --replica fs://./examples \\
|
|
12
|
+
--scopes dna-development [--kinds Story,Feature]
|
|
13
|
+
dna source replica list
|
|
14
|
+
dna source replica show <id>
|
|
15
|
+
dna source replica drop <id>
|
|
16
|
+
dna source replica enable <id>
|
|
17
|
+
dna source replica disable <id>
|
|
18
|
+
|
|
19
|
+
Phase 1 only supports ``fs://`` / ``file://`` replica URLs. ``sqlite://``
|
|
20
|
+
and ``postgres://`` destinations are roadmapped (the engine already
|
|
21
|
+
accepts any WritableSourcePort; the CLI just gates the URL until the
|
|
22
|
+
async-init plumbing for non-FS schemes lands).
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
import click
|
|
31
|
+
|
|
32
|
+
from dna_cli._ctx import fail, print_json, print_table
|
|
33
|
+
|
|
34
|
+
CONFIG_FILENAME = ".dna-replicas.yaml"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ─── Config rw helpers ────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _find_config(start: Path | None = None) -> Path | None:
|
|
41
|
+
"""Walk upward from cwd looking for ``.dna-replicas.yaml``."""
|
|
42
|
+
cur = (start or Path.cwd()).resolve()
|
|
43
|
+
for _ in range(20):
|
|
44
|
+
candidate = cur / CONFIG_FILENAME
|
|
45
|
+
if candidate.is_file():
|
|
46
|
+
return candidate
|
|
47
|
+
if cur.parent == cur:
|
|
48
|
+
return None
|
|
49
|
+
cur = cur.parent
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_config_path() -> Path:
|
|
54
|
+
"""Where ``add`` writes when no config exists yet — at cwd."""
|
|
55
|
+
return Path.cwd() / CONFIG_FILENAME
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_or_init(path: Path) -> dict[str, Any]:
|
|
59
|
+
import yaml
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return {
|
|
62
|
+
"apiVersion": "dna.io/v1",
|
|
63
|
+
"kind": "ReplicaConfig",
|
|
64
|
+
"replicas": [],
|
|
65
|
+
}
|
|
66
|
+
raw = yaml.safe_load(path.read_text()) or {}
|
|
67
|
+
if not isinstance(raw, dict):
|
|
68
|
+
raise click.ClickException(f"{path}: top-level must be a mapping")
|
|
69
|
+
raw.setdefault("apiVersion", "dna.io/v1")
|
|
70
|
+
raw.setdefault("kind", "ReplicaConfig")
|
|
71
|
+
raw.setdefault("replicas", [])
|
|
72
|
+
if not isinstance(raw["replicas"], list):
|
|
73
|
+
raise click.ClickException(f"{path}: 'replicas' must be a list")
|
|
74
|
+
return raw
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _save(path: Path, cfg: dict[str, Any]) -> None:
|
|
78
|
+
import yaml
|
|
79
|
+
path.write_text(yaml.safe_dump(cfg, sort_keys=False, default_flow_style=False))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _entry_index(cfg: dict[str, Any], replica_id: str) -> int:
|
|
83
|
+
for i, e in enumerate(cfg["replicas"]):
|
|
84
|
+
if isinstance(e, dict) and e.get("id") == replica_id:
|
|
85
|
+
return i
|
|
86
|
+
return -1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _validate_url(url: str) -> None:
|
|
90
|
+
parsed = urlparse(url)
|
|
91
|
+
scheme = parsed.scheme or "file"
|
|
92
|
+
if scheme not in ("file", "fs"):
|
|
93
|
+
raise click.ClickException(
|
|
94
|
+
f"unsupported replica scheme '{scheme}://' — Phase 1 supports "
|
|
95
|
+
f"fs:// or file:// only. (sqlite:// / postgres:// are roadmapped.)"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_url_to_path(url: str, cfg_dir: Path) -> Path:
|
|
100
|
+
parsed = urlparse(url)
|
|
101
|
+
raw = parsed.netloc + parsed.path if parsed.netloc else parsed.path
|
|
102
|
+
p = Path(raw)
|
|
103
|
+
return p if p.is_absolute() else (cfg_dir / p).resolve()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ─── Click group ──────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@click.group(help="Source-level operations: declarative replicas, introspection.")
|
|
110
|
+
def source() -> None:
|
|
111
|
+
"""Top-level ``dna source`` group."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@source.command("diff")
|
|
115
|
+
@click.argument("other_url")
|
|
116
|
+
@click.option("--scope", required=True, help="Scope to compare.")
|
|
117
|
+
@click.option("--tenant", default=None, help="Tenant layer (default: base).")
|
|
118
|
+
@click.option(
|
|
119
|
+
"--authored-only", is_flag=True,
|
|
120
|
+
help="Skip runtime-generated Kinds (EvalRun, Narrative, …) — compare only "
|
|
121
|
+
"authored docs (the FS git source-of-truth class).",
|
|
122
|
+
)
|
|
123
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
|
|
124
|
+
def diff(
|
|
125
|
+
other_url: str, scope: str, tenant: str | None,
|
|
126
|
+
authored_only: bool, as_json: bool,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""s-sync-s4 — semantic diff of a scope between the CURRENT source
|
|
129
|
+
(DNA_SOURCE_URL) and OTHER_URL (e.g. file://./scopes or a postgres URL).
|
|
130
|
+
|
|
131
|
+
Compares Kind-aware content digests (not raw text), so formatting,
|
|
132
|
+
frontmatter re-serialization and volatile stamps never show as drift.
|
|
133
|
+
Reports added (in current, missing in other), removed (in other only),
|
|
134
|
+
and changed (digest drifted).
|
|
135
|
+
"""
|
|
136
|
+
from dna_cli._ctx import dna_session
|
|
137
|
+
from dna.kernel import Kernel
|
|
138
|
+
from dna_cli._ctx import build_source_from_env
|
|
139
|
+
|
|
140
|
+
with dna_session(scope) as s:
|
|
141
|
+
kernel = s.kernel
|
|
142
|
+
# authored-only → drop docs whose Kind is a runtime artifact.
|
|
143
|
+
include = None
|
|
144
|
+
if authored_only:
|
|
145
|
+
artifact_kinds = {
|
|
146
|
+
kp.kind for kp in kernel._kinds.values()
|
|
147
|
+
if getattr(kp, "is_runtime_artifact", False)
|
|
148
|
+
}
|
|
149
|
+
include = lambda raw: raw.get("kind") not in artifact_kinds # noqa: E731
|
|
150
|
+
|
|
151
|
+
async def _run() -> dict:
|
|
152
|
+
current = await kernel.digest_manifest(
|
|
153
|
+
scope, tenant=tenant, include=include,
|
|
154
|
+
)
|
|
155
|
+
other_src = await build_source_from_env(
|
|
156
|
+
Kernel.auto(), _source_url=other_url,
|
|
157
|
+
)
|
|
158
|
+
other = await kernel.digest_manifest(
|
|
159
|
+
scope, tenant=tenant, include=include, source=other_src,
|
|
160
|
+
)
|
|
161
|
+
return Kernel.diff_manifests(current, other)
|
|
162
|
+
|
|
163
|
+
d = s.run(_run())
|
|
164
|
+
|
|
165
|
+
if as_json:
|
|
166
|
+
print_json({k: [f"{kind}/{name}" for kind, name in v] for k, v in d.items()})
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
total = sum(len(v) for v in d.values())
|
|
170
|
+
if total == 0:
|
|
171
|
+
click.secho(f"✔ in sync — {scope} matches {other_url} (0 diffs)", fg="green")
|
|
172
|
+
return
|
|
173
|
+
click.secho(
|
|
174
|
+
f"{scope}: {len(d['added'])} added · {len(d['changed'])} changed · "
|
|
175
|
+
f"{len(d['removed'])} removed (current ↔ {other_url})",
|
|
176
|
+
fg="yellow", bold=True,
|
|
177
|
+
)
|
|
178
|
+
for label, color in (("added", "green"), ("changed", "cyan"), ("removed", "red")):
|
|
179
|
+
for kind, name in d[label]:
|
|
180
|
+
click.secho(f" {label[0].upper()} {kind}/{name}", fg=color)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@source.command("push")
|
|
184
|
+
@click.argument("to_url")
|
|
185
|
+
@click.option("--scope", required=True, help="Scope to reconcile.")
|
|
186
|
+
@click.option("--tenant", default=None, help="Tenant layer (default: base).")
|
|
187
|
+
@click.option(
|
|
188
|
+
"--authored-only", is_flag=True,
|
|
189
|
+
help="Skip runtime-generated Kinds — push only authored docs.",
|
|
190
|
+
)
|
|
191
|
+
@click.option(
|
|
192
|
+
"--apply", "do_apply", is_flag=True,
|
|
193
|
+
help="Actually write to the target (default: dry-run preview only).",
|
|
194
|
+
)
|
|
195
|
+
@click.option(
|
|
196
|
+
"--prune", is_flag=True,
|
|
197
|
+
help="Delete docs that exist ONLY in the target (off by default).",
|
|
198
|
+
)
|
|
199
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
|
|
200
|
+
def push(
|
|
201
|
+
to_url: str, scope: str, tenant: str | None,
|
|
202
|
+
authored_only: bool, do_apply: bool, prune: bool, as_json: bool,
|
|
203
|
+
) -> None:
|
|
204
|
+
"""s-sync-s5 — reconcile TO_URL to match the CURRENT source (DNA_SOURCE_URL,
|
|
205
|
+
the source-of-truth) for a scope. Writes added/changed docs atomically
|
|
206
|
+
(doc + bundle via the s-sync-s3 net). Dry-run by default — pass --apply to
|
|
207
|
+
write. --prune also removes docs that exist only in the target.
|
|
208
|
+
"""
|
|
209
|
+
from dna_cli._ctx import dna_session
|
|
210
|
+
from dna.kernel import Kernel
|
|
211
|
+
from dna_cli._ctx import build_source_from_env
|
|
212
|
+
|
|
213
|
+
with dna_session(scope) as s:
|
|
214
|
+
kernel = s.kernel
|
|
215
|
+
include = None
|
|
216
|
+
if authored_only:
|
|
217
|
+
artifact_kinds = {
|
|
218
|
+
kp.kind for kp in kernel._kinds.values()
|
|
219
|
+
if getattr(kp, "is_runtime_artifact", False)
|
|
220
|
+
}
|
|
221
|
+
include = lambda raw: raw.get("kind") not in artifact_kinds # noqa: E731
|
|
222
|
+
|
|
223
|
+
async def _run() -> dict:
|
|
224
|
+
to_src = await build_source_from_env(
|
|
225
|
+
Kernel.auto(), _source_url=to_url,
|
|
226
|
+
)
|
|
227
|
+
return await kernel.push_scope(
|
|
228
|
+
scope, to_src, tenant=tenant, include=include,
|
|
229
|
+
dry_run=not do_apply, prune=prune,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
out = s.run(_run())
|
|
233
|
+
|
|
234
|
+
if as_json:
|
|
235
|
+
print_json({
|
|
236
|
+
k: [f"{kind}/{name}" for kind, name in v] if k != "applied"
|
|
237
|
+
else [f"{op}:{kind}/{name}" for op, kind, name in v]
|
|
238
|
+
for k, v in out.items()
|
|
239
|
+
})
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
n_writes = len(out["added"]) + len(out["changed"])
|
|
243
|
+
verb = "would write" if not do_apply else "wrote"
|
|
244
|
+
if n_writes == 0 and (not prune or not out["removed"]):
|
|
245
|
+
click.secho(f"✔ {scope} already in sync with {to_url} (nothing to push)", fg="green")
|
|
246
|
+
return
|
|
247
|
+
head = "DRY-RUN" if not do_apply else "APPLIED"
|
|
248
|
+
click.secho(
|
|
249
|
+
f"[{head}] {scope} → {to_url}: {verb} {n_writes} "
|
|
250
|
+
f"({len(out['added'])} added, {len(out['changed'])} changed)"
|
|
251
|
+
+ (f"; prune {len(out['removed'])} removed" if prune else ""),
|
|
252
|
+
fg="yellow", bold=True,
|
|
253
|
+
)
|
|
254
|
+
for kind, name in out["added"]:
|
|
255
|
+
click.secho(f" + {kind}/{name}", fg="green")
|
|
256
|
+
for kind, name in out["changed"]:
|
|
257
|
+
click.secho(f" ~ {kind}/{name}", fg="cyan")
|
|
258
|
+
if prune:
|
|
259
|
+
for kind, name in out["removed"]:
|
|
260
|
+
click.secho(f" - {kind}/{name}", fg="red")
|
|
261
|
+
if not do_apply:
|
|
262
|
+
click.secho(" (dry-run — re-run with --apply to write)", fg="white")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@source.group(help="Manage source replicas (.dna-replicas.yaml).")
|
|
266
|
+
def replica() -> None:
|
|
267
|
+
"""Replica subgroup."""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ─── add ──────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@replica.command("add")
|
|
274
|
+
@click.argument("replica_id")
|
|
275
|
+
@click.option("--replica", "replica_url", required=True,
|
|
276
|
+
help="Destination URL (fs://path or file://path).")
|
|
277
|
+
@click.option("--scopes", required=True,
|
|
278
|
+
help="Comma-separated scope allowlist (e.g. dna-development).")
|
|
279
|
+
@click.option("--kinds", default=None,
|
|
280
|
+
help="Comma-separated Kind allowlist. Omit = all kinds.")
|
|
281
|
+
@click.option("--config", "config_path", default=None, type=click.Path(),
|
|
282
|
+
help="Path to .dna-replicas.yaml (default: walk up from cwd, then cwd).")
|
|
283
|
+
def add(
|
|
284
|
+
replica_id: str,
|
|
285
|
+
replica_url: str,
|
|
286
|
+
scopes: str,
|
|
287
|
+
kinds: str | None,
|
|
288
|
+
config_path: str | None,
|
|
289
|
+
) -> None:
|
|
290
|
+
"""Add a new replica entry. Errors on duplicate id."""
|
|
291
|
+
_validate_url(replica_url)
|
|
292
|
+
scope_list = [s.strip() for s in scopes.split(",") if s.strip()]
|
|
293
|
+
if not scope_list:
|
|
294
|
+
raise click.ClickException("--scopes must contain at least one scope")
|
|
295
|
+
kind_list: list[str] | None
|
|
296
|
+
if kinds is None:
|
|
297
|
+
kind_list = None
|
|
298
|
+
else:
|
|
299
|
+
kind_list = [k.strip() for k in kinds.split(",") if k.strip()] or None
|
|
300
|
+
|
|
301
|
+
cfg_path = Path(config_path) if config_path else (_find_config() or _default_config_path())
|
|
302
|
+
cfg = _load_or_init(cfg_path)
|
|
303
|
+
if _entry_index(cfg, replica_id) >= 0:
|
|
304
|
+
raise click.ClickException(
|
|
305
|
+
f"replica id '{replica_id}' already exists in {cfg_path}; "
|
|
306
|
+
f"use 'drop' first or pick a different id"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Sanity-check the resolved destination dir exists. This mirrors the
|
|
310
|
+
# harness check that warns + skips at boot — surfacing it earlier
|
|
311
|
+
# avoids declaring a replica that silently never fires.
|
|
312
|
+
dest = _resolve_url_to_path(replica_url, cfg_path.parent)
|
|
313
|
+
if not dest.exists():
|
|
314
|
+
click.echo(
|
|
315
|
+
f"warning: replica destination {dest} does not exist; "
|
|
316
|
+
f"create it before booting harness or it will be skipped",
|
|
317
|
+
err=True,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
entry: dict[str, Any] = {
|
|
321
|
+
"id": replica_id,
|
|
322
|
+
"replica": replica_url,
|
|
323
|
+
"scopes": scope_list,
|
|
324
|
+
"kinds": kind_list,
|
|
325
|
+
"enabled": True,
|
|
326
|
+
}
|
|
327
|
+
cfg["replicas"].append(entry)
|
|
328
|
+
_save(cfg_path, cfg)
|
|
329
|
+
click.echo(f"ADDED replica/{replica_id} -> {cfg_path}")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ─── list ─────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@replica.command("list")
|
|
336
|
+
@click.option("--config", "config_path", default=None, type=click.Path())
|
|
337
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
338
|
+
def list_replicas(config_path: str | None, as_json: bool) -> None:
|
|
339
|
+
"""List replicas declared in the config file."""
|
|
340
|
+
cfg_path = Path(config_path) if config_path else _find_config()
|
|
341
|
+
if cfg_path is None:
|
|
342
|
+
if as_json:
|
|
343
|
+
print_json([])
|
|
344
|
+
else:
|
|
345
|
+
click.echo("(no .dna-replicas.yaml found)")
|
|
346
|
+
return
|
|
347
|
+
cfg = _load_or_init(cfg_path)
|
|
348
|
+
rows = []
|
|
349
|
+
for e in cfg.get("replicas", []):
|
|
350
|
+
if not isinstance(e, dict):
|
|
351
|
+
continue
|
|
352
|
+
rows.append({
|
|
353
|
+
"id": e.get("id", ""),
|
|
354
|
+
"replica": e.get("replica", ""),
|
|
355
|
+
"scopes": ",".join(e.get("scopes", []) or []),
|
|
356
|
+
"kinds": "all" if not e.get("kinds") else ",".join(e.get("kinds") or []),
|
|
357
|
+
"enabled": "yes" if e.get("enabled", True) else "NO",
|
|
358
|
+
})
|
|
359
|
+
if as_json:
|
|
360
|
+
print_json(rows)
|
|
361
|
+
elif not rows:
|
|
362
|
+
click.echo(f"{cfg_path}: 0 replicas declared")
|
|
363
|
+
else:
|
|
364
|
+
print_table(rows, ["id", "replica", "scopes", "kinds", "enabled"])
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ─── show ─────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@replica.command("show")
|
|
371
|
+
@click.argument("replica_id")
|
|
372
|
+
@click.option("--config", "config_path", default=None, type=click.Path())
|
|
373
|
+
def show(replica_id: str, config_path: str | None) -> None:
|
|
374
|
+
"""Show full entry for one replica id."""
|
|
375
|
+
cfg_path = Path(config_path) if config_path else _find_config()
|
|
376
|
+
if cfg_path is None:
|
|
377
|
+
raise fail("no .dna-replicas.yaml found in cwd or any parent")
|
|
378
|
+
cfg = _load_or_init(cfg_path)
|
|
379
|
+
idx = _entry_index(cfg, replica_id)
|
|
380
|
+
if idx < 0:
|
|
381
|
+
raise fail(f"replica id '{replica_id}' not found in {cfg_path}")
|
|
382
|
+
entry = cfg["replicas"][idx]
|
|
383
|
+
print_json({"config": str(cfg_path), "entry": entry})
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ─── drop ─────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@replica.command("drop")
|
|
390
|
+
@click.argument("replica_id")
|
|
391
|
+
@click.option("--config", "config_path", default=None, type=click.Path())
|
|
392
|
+
def drop(replica_id: str, config_path: str | None) -> None:
|
|
393
|
+
"""Remove a replica entry."""
|
|
394
|
+
cfg_path = Path(config_path) if config_path else _find_config()
|
|
395
|
+
if cfg_path is None:
|
|
396
|
+
raise fail("no .dna-replicas.yaml found in cwd or any parent")
|
|
397
|
+
cfg = _load_or_init(cfg_path)
|
|
398
|
+
idx = _entry_index(cfg, replica_id)
|
|
399
|
+
if idx < 0:
|
|
400
|
+
raise fail(f"replica id '{replica_id}' not found in {cfg_path}")
|
|
401
|
+
cfg["replicas"].pop(idx)
|
|
402
|
+
_save(cfg_path, cfg)
|
|
403
|
+
click.echo(f"DROPPED replica/{replica_id} from {cfg_path}")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ─── enable / disable ─────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@replica.command("enable")
|
|
410
|
+
@click.argument("replica_id")
|
|
411
|
+
@click.option("--config", "config_path", default=None, type=click.Path())
|
|
412
|
+
def enable(replica_id: str, config_path: str | None) -> None:
|
|
413
|
+
"""Set enabled=true for a replica."""
|
|
414
|
+
_toggle_enabled(replica_id, True, config_path)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@replica.command("disable")
|
|
418
|
+
@click.argument("replica_id")
|
|
419
|
+
@click.option("--config", "config_path", default=None, type=click.Path())
|
|
420
|
+
def disable(replica_id: str, config_path: str | None) -> None:
|
|
421
|
+
"""Set enabled=false for a replica (soft-mute without losing config)."""
|
|
422
|
+
_toggle_enabled(replica_id, False, config_path)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _toggle_enabled(replica_id: str, value: bool, config_path: str | None) -> None:
|
|
426
|
+
cfg_path = Path(config_path) if config_path else _find_config()
|
|
427
|
+
if cfg_path is None:
|
|
428
|
+
raise fail("no .dna-replicas.yaml found in cwd or any parent")
|
|
429
|
+
cfg = _load_or_init(cfg_path)
|
|
430
|
+
idx = _entry_index(cfg, replica_id)
|
|
431
|
+
if idx < 0:
|
|
432
|
+
raise fail(f"replica id '{replica_id}' not found in {cfg_path}")
|
|
433
|
+
cfg["replicas"][idx]["enabled"] = value
|
|
434
|
+
_save(cfg_path, cfg)
|
|
435
|
+
click.echo(
|
|
436
|
+
f"{'ENABLED' if value else 'DISABLED'} replica/{replica_id} in {cfg_path}"
|
|
437
|
+
)
|