cfgit 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.
cfg/cli/main.py ADDED
@@ -0,0 +1,665 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """cfg CLI."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from dataclasses import asdict, is_dataclass
7
+ from datetime import datetime, time, timezone
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import sys
12
+ from typing import Any
13
+
14
+ from cfg.adapters.base import AtomicityUnavailable, AmbiguousConfig, NoSuchConfig, StaleHead, StaleLive
15
+ from cfg.core.authz import PermissionDenied, permission_role
16
+ from cfg.core.config import ProjectConfig, load_config
17
+ from cfg.core.diff import format_diff
18
+ from cfg.core.engine import BranchingDisabled, Engine, RecordRef, SecretBlocked
19
+ from cfg.core.identity import IdentityError, hash_token, resolve_identity
20
+
21
+
22
+ EXIT_OK = 0
23
+ EXIT_ARG = 1
24
+ EXIT_DIRTY = 2
25
+ EXIT_STORAGE = 3
26
+ EXIT_FORBIDDEN = 4
27
+ EXIT_NOT_FOUND = 5
28
+ EXIT_INVARIANT = 6
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ _load_dotenv(Path(".env"))
33
+ parser = _parser()
34
+ raw_argv = list(sys.argv[1:] if argv is None else argv)
35
+ json_mode = "--json" in raw_argv
36
+ raw_argv = [item for item in raw_argv if item != "--json"]
37
+ explicit_ui_port = _has_option(raw_argv, "--port")
38
+ args = parser.parse_args(raw_argv)
39
+ args.json = bool(args.json or json_mode)
40
+ if args.cmd == "ui":
41
+ from cfg.ui.server import run_ui
42
+
43
+ return run_ui(
44
+ config_file=args.config_file,
45
+ env=args.env,
46
+ author=args.author,
47
+ host=args.host,
48
+ port=args.port,
49
+ open_browser=not args.no_open,
50
+ allow_port_fallback=not explicit_ui_port,
51
+ )
52
+ if args.cmd == "identity-hash":
53
+ try:
54
+ token = _identity_hash_input(args)
55
+ hashed = hash_token(token)
56
+ _emit({"sha256": hashed, "fingerprint": hashed[7:12]}, json_mode=args.json)
57
+ return EXIT_OK
58
+ except ValueError as exc:
59
+ _emit_error("error", str(exc), args)
60
+ return EXIT_ARG
61
+ try:
62
+ project = load_config(args.config_file)
63
+ engine = _engine(project, args.env, author=args.author)
64
+ result, code = _dispatch(engine, args)
65
+ _emit(result, json_mode=args.json)
66
+ return code
67
+ except AmbiguousConfig as exc:
68
+ _emit_error("bad_config", str(exc), args)
69
+ return EXIT_INVARIANT
70
+ except (StaleHead, StaleLive) as exc:
71
+ _emit_error("changed_outside_cfgit", str(exc), args)
72
+ return EXIT_DIRTY
73
+ except PermissionDenied as exc:
74
+ _emit_error("forbidden", str(exc), args)
75
+ return EXIT_FORBIDDEN
76
+ except IdentityError as exc:
77
+ _emit_error("identity_required", str(exc), args)
78
+ return EXIT_FORBIDDEN
79
+ except AtomicityUnavailable as exc:
80
+ _emit_error("atomicity_unavailable", str(exc), args)
81
+ return EXIT_STORAGE
82
+ except NoSuchConfig as exc:
83
+ _emit_error("not_found", str(exc), args)
84
+ return EXIT_NOT_FOUND
85
+ except (BranchingDisabled, SecretBlocked, ValueError, FileNotFoundError, KeyError) as exc:
86
+ _emit_error("error", str(exc), args)
87
+ return EXIT_ARG
88
+ except Exception as exc: # pragma: no cover - final CLI guard
89
+ _emit_error("error", str(exc), args)
90
+ return EXIT_STORAGE
91
+
92
+
93
+ def _parser() -> argparse.ArgumentParser:
94
+ parser = argparse.ArgumentParser(prog="cfg")
95
+ parser.add_argument("--config-file", default=None)
96
+ parser.add_argument("--env", default="dev")
97
+ parser.add_argument("--author", default=None)
98
+ parser.add_argument("--branch", default=None)
99
+ parser.add_argument("--json", action="store_true")
100
+ sub = parser.add_subparsers(dest="cmd", required=True)
101
+
102
+ sub.add_parser("init")
103
+ sub.add_parser("whoami")
104
+
105
+ p_branch = sub.add_parser("branch")
106
+ branch_sub = p_branch.add_subparsers(dest="branch_cmd", required=True)
107
+ branch_sub.add_parser("list")
108
+ p_branch_create = branch_sub.add_parser("create")
109
+ p_branch_create.add_argument("name")
110
+ p_branch_create.add_argument("--from", dest="from_branch", default="main")
111
+ p_branch_create.add_argument("-m", "--message", default=None)
112
+ p_branch_delete = branch_sub.add_parser("delete")
113
+ p_branch_delete.add_argument("name")
114
+
115
+ p_switch = sub.add_parser("switch")
116
+ p_switch.add_argument("name")
117
+
118
+ p_pr = sub.add_parser("pr")
119
+ pr_sub = p_pr.add_subparsers(dest="pr_cmd", required=True)
120
+ p_pr_create = pr_sub.add_parser("create")
121
+ p_pr_create.add_argument("--base", default="main")
122
+ p_pr_create.add_argument("--head", required=True)
123
+ p_pr_create.add_argument("-m", "--message", required=True)
124
+ p_pr_list = pr_sub.add_parser("list")
125
+ p_pr_list.add_argument("--status", default=None)
126
+ p_pr_show = pr_sub.add_parser("show")
127
+ p_pr_show.add_argument("id")
128
+ p_pr_close = pr_sub.add_parser("close")
129
+ p_pr_close.add_argument("id")
130
+ p_pr_merge = pr_sub.add_parser("merge")
131
+ p_pr_merge.add_argument("id")
132
+ p_pr_merge.add_argument("-m", "--message", default=None)
133
+
134
+ p_import = sub.add_parser("import")
135
+ p_import.add_argument("record", nargs="?")
136
+ p_import.add_argument("--all", action="store_true")
137
+ p_import.add_argument("-m", "--message", default="initial import")
138
+ p_import.add_argument("--allow-secret", action="store_true")
139
+
140
+ p_doctor = sub.add_parser("doctor")
141
+ p_doctor.add_argument("record", nargs="?")
142
+ p_doctor.add_argument("--large-field-bytes", type=int, default=20000,
143
+ help="flag string fields at or above this size (default 20000)")
144
+
145
+ p_status = sub.add_parser("status")
146
+ p_status.add_argument("record", nargs="?")
147
+
148
+ p_diff = sub.add_parser("diff")
149
+ p_diff.add_argument("record")
150
+ p_diff.add_argument("a", nargs="?", default="=HEAD")
151
+ p_diff.add_argument("b", nargs="?", default="=live")
152
+
153
+ p_impact = sub.add_parser("impact")
154
+ p_impact.add_argument("record")
155
+ p_impact.add_argument("a", nargs="?", default="=HEAD")
156
+ p_impact.add_argument("b", nargs="?", default="=live")
157
+ p_impact.add_argument("--llm", action="store_true")
158
+ p_impact.add_argument("--provider")
159
+ p_impact.add_argument("--model")
160
+ p_impact.add_argument(
161
+ "--against",
162
+ action="append",
163
+ metavar="RECORD",
164
+ help="reason the change against these records only (repeat, or comma-separate). "
165
+ "Without it, the whole system is used.",
166
+ )
167
+
168
+ p_commit = sub.add_parser("commit")
169
+ p_commit.add_argument("record", nargs="?")
170
+ p_commit.add_argument("--from", dest="from_file")
171
+ p_commit.add_argument(
172
+ "--bulk-from",
173
+ dest="bulk_from_file",
174
+ help="JSON file containing a list/map of record+doc items to commit as one batch intent",
175
+ )
176
+ p_commit.add_argument("-m", "--message", required=True)
177
+ p_commit.add_argument("--allow-secret", action="store_true")
178
+
179
+ p_log = sub.add_parser("log")
180
+ p_log.add_argument("record", nargs="?")
181
+ p_log.add_argument("-n", "--limit", type=int, default=20)
182
+
183
+ p_show = sub.add_parser("show")
184
+ p_show.add_argument("record")
185
+ p_show.add_argument("ref")
186
+
187
+ p_adopt = sub.add_parser("adopt")
188
+ p_adopt.add_argument("record", nargs="?")
189
+ p_adopt.add_argument("--all", action="store_true")
190
+ p_adopt.add_argument("-m", "--message", required=True)
191
+ p_adopt.add_argument("--allow-secret", action="store_true")
192
+
193
+ p_restore = sub.add_parser("restore")
194
+ p_restore.add_argument("record", nargs="?")
195
+ p_restore.add_argument("ref", nargs="?")
196
+ p_restore.add_argument("--as-of", dest="as_of")
197
+ p_restore.add_argument("--tag", dest="tag")
198
+ p_restore.add_argument("--dry-run", action="store_true")
199
+ p_restore.add_argument("-m", "--message", required=True)
200
+
201
+ p_tag = sub.add_parser("tag")
202
+ p_tag.add_argument("name")
203
+
204
+ sub.add_parser("fsck")
205
+
206
+ p_identity_hash = sub.add_parser("identity-hash")
207
+ p_identity_hash.add_argument("token", nargs="?")
208
+ p_identity_hash.add_argument("--stdin", action="store_true")
209
+
210
+ p_ui = sub.add_parser("ui")
211
+ p_ui.add_argument("--host", default="127.0.0.1")
212
+ p_ui.add_argument("--port", type=int, default=8765)
213
+ p_ui.add_argument("--no-open", action="store_true")
214
+ return parser
215
+
216
+
217
+ def _has_option(argv: list[str], name: str) -> bool:
218
+ return any(item == name or item.startswith(f"{name}=") for item in argv)
219
+
220
+
221
+ def _dispatch(engine: Engine, args: argparse.Namespace) -> tuple[Any, int]:
222
+ if args.cmd == "init":
223
+ result = engine.init()
224
+ violations = result["invariant_violations"]
225
+ return _plain_init(result), EXIT_INVARIANT if violations else EXIT_OK
226
+
227
+ if args.cmd == "whoami":
228
+ env = engine.config.envs[engine.env]
229
+ return {
230
+ "author": engine.author,
231
+ "identity": engine.identity.history_meta(),
232
+ "identity_display": engine.identity.display,
233
+ "env": engine.env,
234
+ "database": env.database,
235
+ "permission_role": permission_role(env.permissions, engine.author),
236
+ "permission_mode": env.permissions.mode,
237
+ "identity_mode": env.identity.mode,
238
+ }, EXIT_OK
239
+
240
+ if args.cmd == "branch":
241
+ if args.branch_cmd == "list":
242
+ return engine.branch_list(), EXIT_OK
243
+ if args.branch_cmd == "create":
244
+ return engine.branch_create(args.name, from_branch=args.from_branch, message=args.message), EXIT_OK
245
+ if args.branch_cmd == "delete":
246
+ return engine.branch_delete(args.name), EXIT_OK
247
+ raise ValueError(f"unknown branch command: {args.branch_cmd}")
248
+
249
+ if args.cmd == "switch":
250
+ result = engine.branch_current(args.name)
251
+ _write_state(engine.config, engine.env, result["branch"])
252
+ return {**result, "state": "switched"}, EXIT_OK
253
+
254
+ if args.cmd == "pr":
255
+ if args.pr_cmd == "create":
256
+ return engine.pr_create(base=args.base, head=args.head, message=args.message), EXIT_OK
257
+ if args.pr_cmd == "list":
258
+ return engine.pr_list(status=args.status), EXIT_OK
259
+ if args.pr_cmd == "show":
260
+ return engine.pr_show(args.id), EXIT_OK
261
+ if args.pr_cmd == "close":
262
+ return engine.pr_close(args.id), EXIT_OK
263
+ if args.pr_cmd == "merge":
264
+ return engine.pr_merge(args.id, message=args.message), EXIT_OK
265
+ raise ValueError(f"unknown PR command: {args.pr_cmd}")
266
+
267
+ if args.cmd == "import":
268
+ if not args.all and not args.record:
269
+ raise ValueError("import needs --all or a record")
270
+ result = engine.import_records(
271
+ _parse_record(args.record) if args.record else None,
272
+ message=args.message,
273
+ allow_secret=args.allow_secret,
274
+ )
275
+ return result, EXIT_OK
276
+
277
+ if args.cmd == "status":
278
+ rows = engine.status(_parse_record(args.record) if args.record else None)
279
+ code = EXIT_DIRTY if any(r.state == "changed_outside_cfgit" for r in rows) else EXIT_OK
280
+ return rows, code
281
+
282
+ if args.cmd == "doctor":
283
+ report = engine.doctor(
284
+ _parse_record(args.record) if args.record else None,
285
+ large_field_bytes=args.large_field_bytes,
286
+ )
287
+ report["text"] = _format_doctor(report)
288
+ code = EXIT_OK if report["ok"] else EXIT_DIRTY
289
+ return report, code
290
+
291
+ if args.cmd == "diff":
292
+ if ".." in args.record and ":" not in args.record:
293
+ return engine.branch_diff(args.record), EXIT_OK
294
+ changes = engine.diff(_parse_record(args.record), args.a, args.b)
295
+ return {"changes": changes, "text": format_diff(changes)}, EXIT_OK
296
+
297
+ if args.cmd == "impact":
298
+ from cfg.interfaces.actions import impact
299
+
300
+ against = None
301
+ if args.against:
302
+ against = [
303
+ part.strip()
304
+ for entry in args.against
305
+ for part in str(entry).split(",")
306
+ if part.strip()
307
+ ] or None
308
+ return impact(
309
+ engine,
310
+ args.record,
311
+ a=args.a,
312
+ b=args.b,
313
+ use_llm=args.llm,
314
+ provider=args.provider,
315
+ model=args.model,
316
+ against=against,
317
+ )
318
+
319
+ if args.cmd == "commit":
320
+ branch = _active_branch(engine.config, engine.env, args)
321
+ if args.bulk_from_file:
322
+ if args.record or args.from_file:
323
+ raise ValueError("bulk commit uses --bulk-from without record or --from")
324
+ from cfg.interfaces.actions import bulk_commit_exit_code, parse_bulk_commit_items
325
+
326
+ items = parse_bulk_commit_items(_load_json_any(args.bulk_from_file))
327
+ if branch != engine.config.branches.default_branch:
328
+ result = engine.branch_commit_many(
329
+ branch,
330
+ items,
331
+ message=args.message,
332
+ allow_secret=args.allow_secret,
333
+ )
334
+ else:
335
+ result = engine.commit_many(
336
+ items,
337
+ message=args.message,
338
+ allow_secret=args.allow_secret,
339
+ )
340
+ return result, bulk_commit_exit_code(result)
341
+ if not args.record or not args.from_file:
342
+ raise ValueError("commit needs record and --from, or --bulk-from")
343
+ doc = _load_json_file(args.from_file)
344
+ if branch != engine.config.branches.default_branch:
345
+ result = engine.branch_commit(
346
+ branch,
347
+ _parse_record(args.record),
348
+ doc,
349
+ message=args.message,
350
+ allow_secret=args.allow_secret,
351
+ )
352
+ else:
353
+ result = engine.commit(
354
+ _parse_record(args.record),
355
+ doc,
356
+ message=args.message,
357
+ allow_secret=args.allow_secret,
358
+ )
359
+ code = EXIT_DIRTY if result.get("state") == "changed_outside_cfgit" else EXIT_OK
360
+ return result, code
361
+
362
+ if args.cmd == "log":
363
+ branch = _active_branch(engine.config, engine.env, args)
364
+ if branch != engine.config.branches.default_branch:
365
+ if args.record:
366
+ raise ValueError("branch log does not take a record in v1")
367
+ return engine.branch_log(branch, limit=args.limit), EXIT_OK
368
+ if not args.record:
369
+ raise ValueError("log needs a record on main, or switch/select a branch")
370
+ return engine.log(_parse_record(args.record), limit=args.limit), EXIT_OK
371
+
372
+ if args.cmd == "show":
373
+ return engine.resolve_ref(_parse_record(args.record), args.ref), EXIT_OK
374
+
375
+ if args.cmd == "adopt":
376
+ if args.all:
377
+ results = []
378
+ for row in engine.status():
379
+ if row.state == "changed_outside_cfgit":
380
+ result = engine.adopt(
381
+ RecordRef(row.collection, row.record_id),
382
+ message=args.message,
383
+ allow_secret=args.allow_secret,
384
+ )
385
+ results.append({"collection": row.collection, "record_id": row.record_id, **result})
386
+ return results, EXIT_OK
387
+ if not args.record:
388
+ raise ValueError("adopt needs --all or a record")
389
+ return engine.adopt(
390
+ _parse_record(args.record),
391
+ message=args.message,
392
+ allow_secret=args.allow_secret,
393
+ ), EXIT_OK
394
+
395
+ if args.cmd == "restore":
396
+ if args.as_of and args.tag:
397
+ raise ValueError("restore accepts only one of --as-of or --tag")
398
+ if args.as_of:
399
+ if args.record or args.ref:
400
+ raise ValueError("restore --as-of restores all records; omit record and ref")
401
+ result = engine.restore_system_as_of(
402
+ _parse_when(args.as_of),
403
+ message=args.message,
404
+ dry_run=args.dry_run,
405
+ )
406
+ code = _restore_exit_code(result)
407
+ return result, code
408
+ if args.tag:
409
+ if args.record or args.ref:
410
+ raise ValueError("restore --tag restores all records; omit record and ref")
411
+ result = engine.restore_system_tag(args.tag, message=args.message, dry_run=args.dry_run)
412
+ code = _restore_exit_code(result)
413
+ return result, code
414
+ if args.dry_run:
415
+ raise ValueError("--dry-run is only supported with system restore")
416
+ if not args.record or not args.ref:
417
+ raise ValueError("restore needs record and ref, or --as-of/--tag")
418
+ result = engine.restore(_parse_record(args.record), args.ref, message=args.message)
419
+ code = EXIT_DIRTY if result.get("state") == "changed_outside_cfgit" else EXIT_OK
420
+ return result, code
421
+
422
+ if args.cmd == "tag":
423
+ return engine.tag(args.name), EXIT_OK
424
+
425
+ if args.cmd == "fsck":
426
+ return {
427
+ "invariant_violations": engine.adapter.check_runtime_invariant(),
428
+ "atomicity": engine.adapter.check_atomicity_scope(),
429
+ "reconcile": engine.adapter.reconcile(),
430
+ }, EXIT_OK
431
+
432
+ raise ValueError(f"unknown command: {args.cmd}")
433
+
434
+
435
+ def _engine(project: ProjectConfig, env_name: str, *, author: str | None) -> Engine:
436
+ if env_name not in project.envs:
437
+ raise ValueError(f"unknown env: {env_name}")
438
+ env = project.envs[env_name]
439
+ if env.database == "mongo":
440
+ from cfg.adapters.mongo import MongoAdapter
441
+
442
+ adapter = MongoAdapter(project=project, env_name=env_name)
443
+ elif env.database == "postgres":
444
+ from cfg.adapters.postgres import PostgresAdapter
445
+
446
+ adapter = PostgresAdapter(project=project, env_name=env_name)
447
+ else:
448
+ raise ValueError(f"unsupported database for v1 slice: {env.database}")
449
+
450
+ identity = resolve_identity(env, adapter, explicit_author=author)
451
+ return Engine(project, adapter, env=env_name, identity=identity)
452
+
453
+
454
+ def _active_branch(project: ProjectConfig, env: str, args: argparse.Namespace) -> str:
455
+ if args.branch:
456
+ return str(args.branch)
457
+ if not project.branches.enabled:
458
+ return project.branches.default_branch
459
+ state = _read_state(project)
460
+ if state.get("env") == env and state.get("branch"):
461
+ return str(state["branch"])
462
+ return project.branches.default_branch
463
+
464
+
465
+ def _state_path(project: ProjectConfig) -> Path:
466
+ return project.path.parent / ".cfgit" / "state.json"
467
+
468
+
469
+ def _read_state(project: ProjectConfig) -> dict[str, Any]:
470
+ path = _state_path(project)
471
+ if not path.exists():
472
+ return {}
473
+ try:
474
+ data = json.loads(path.read_text(encoding="utf-8"))
475
+ except (OSError, json.JSONDecodeError):
476
+ return {}
477
+ return data if isinstance(data, dict) else {}
478
+
479
+
480
+ def _write_state(project: ProjectConfig, env: str, branch: str) -> None:
481
+ path = _state_path(project)
482
+ path.parent.mkdir(parents=True, exist_ok=True)
483
+ path.write_text(json.dumps({"env": env, "branch": branch}, indent=2, sort_keys=True), encoding="utf-8")
484
+
485
+
486
+ def _parse_record(raw: str | None) -> RecordRef:
487
+ if not raw or ":" not in raw:
488
+ raise ValueError("record must be collection:id, for example agent_configs:agent_planner")
489
+ collection, record_id = raw.split(":", 1)
490
+ if not collection or not record_id:
491
+ raise ValueError("record must be collection:id")
492
+ return RecordRef(collection, record_id)
493
+
494
+
495
+ def _load_json_file(path: str) -> dict[str, Any]:
496
+ data = _load_json_any(path)
497
+ if not isinstance(data, dict):
498
+ raise ValueError("--from file must contain one JSON object")
499
+ return data
500
+
501
+
502
+ def _load_json_any(path: str) -> Any:
503
+ with Path(path).open("r", encoding="utf-8") as f:
504
+ return json.load(f)
505
+
506
+
507
+ def _parse_when(raw: str) -> datetime:
508
+ value = raw.strip()
509
+ date_only = len(value) == 10 and value[4] == "-" and value[7] == "-"
510
+ if date_only:
511
+ dt = datetime.combine(datetime.fromisoformat(value).date(), time.max)
512
+ else:
513
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
514
+ if dt.tzinfo is None:
515
+ dt = dt.replace(tzinfo=timezone.utc)
516
+ return dt.astimezone(timezone.utc)
517
+
518
+
519
+ def _format_doctor(report: dict[str, Any]) -> str:
520
+ lines: list[str] = []
521
+ n = report.get("scanned", 0)
522
+ if report.get("ok"):
523
+ lines.append(f"doctor: {n} live record(s) scanned — no blockers. Safe to import.")
524
+ return "\n".join(lines)
525
+ sb = report.get("secret_blocks", [])
526
+ lf = report.get("large_fields", [])
527
+ ki = report.get("key_issues", [])
528
+ lines.append(
529
+ f"doctor: {n} live record(s) scanned — {len(sb)} secret block(s), "
530
+ f"{len(lf)} large field(s), {len(ki)} key issue(s)."
531
+ )
532
+ if ki:
533
+ lines.append("")
534
+ lines.append("Key / live-rule issues (fix id_field or live_when before import):")
535
+ for issue in ki:
536
+ lines.append(f" {issue}")
537
+ if sb:
538
+ has_value = any(g["kind"] == "value" for g in sb)
539
+ lines.append("")
540
+ lines.append("Secret-deny matches (would refuse import). Two ways to resolve each:")
541
+ lines.append(" - secret_fields = strip the value from history (use when the field is NOT")
542
+ lines.append(" needed in the record, or is schema structure).")
543
+ lines.append(" - import/commit --allow-secret = STORE the real value in history (use when")
544
+ lines.append(" the key must stay in the record so restore writes it back; value is then")
545
+ lines.append(" in cfgit history in plaintext).")
546
+ for g in sb:
547
+ tag = "real value" if g["kind"] == "value" else "field name"
548
+ lines.append(f" {g['collection']}: {g['path']} [{tag}: {g['pattern']}] "
549
+ f"x{g['count']} (e.g. {g['example']})")
550
+ if has_value:
551
+ lines.append(" ! at least one match is a REAL secret VALUE — if that key must live in")
552
+ lines.append(" the record, keep it OUT of secret_fields and import with --allow-secret.")
553
+ if lf:
554
+ lines.append("")
555
+ kb = report.get("large_field_bytes", 0) // 1000
556
+ lines.append(f"Large fields (>= {kb}KB; consider ignore_fields to keep diffs readable):")
557
+ for g in lf:
558
+ lines.append(f" {g['collection']}: {g['path']} up to {g['max_bytes']//1000}KB x{g['count']}")
559
+ sug = report.get("suggestions", {})
560
+ if sug:
561
+ lines.append("")
562
+ lines.append("Paste-ready fixes (per collection in .cfg.toml):")
563
+ for coll in sorted(sug):
564
+ entry = sug[coll]
565
+ lines.append(f" # [[collection]] name = \"{coll}\"")
566
+ if entry.get("secret_fields"):
567
+ joined = ", ".join(f'"{p}"' for p in sorted(set(entry["secret_fields"])))
568
+ lines.append(f" secret_fields = [{joined}]")
569
+ if entry.get("ignore_fields"):
570
+ joined = ", ".join(f'"{p}"' for p in sorted(set(entry["ignore_fields"])))
571
+ lines.append(f" ignore_fields = [{joined}]")
572
+ return "\n".join(lines)
573
+
574
+
575
+ def _emit(value: Any, *, json_mode: bool) -> None:
576
+ if json_mode:
577
+ print(json.dumps(_to_json(value), indent=2, sort_keys=True))
578
+ return
579
+ if isinstance(value, list):
580
+ for item in value:
581
+ print(_format_item(item))
582
+ return
583
+ if isinstance(value, dict) and "text" in value and ("changes" in value or "secret_blocks" in value):
584
+ print(value["text"])
585
+ return
586
+ print(_format_item(value))
587
+
588
+
589
+ def _emit_error(status: str, message: str, args: argparse.Namespace) -> None:
590
+ if getattr(args, "json", False):
591
+ print(json.dumps({"status": status, "message": message}, indent=2), file=sys.stderr)
592
+ else:
593
+ print(f"{status}: {message}", file=sys.stderr)
594
+
595
+
596
+ def _format_item(value: Any) -> str:
597
+ if is_dataclass(value):
598
+ value = asdict(value)
599
+ if isinstance(value, dict):
600
+ if {"collection", "record_id", "state"} <= set(value):
601
+ return f"{value['collection']}:{value['record_id']} {value['state']}"
602
+ if {"collection", "record_id", "seq", "oid"} <= set(value):
603
+ return f"{value['collection']}:{value['record_id']} @{value['seq']} {str(value['oid'])[:12]}"
604
+ return json.dumps(_to_json(value), sort_keys=True)
605
+ return str(value)
606
+
607
+
608
+ def _to_json(value: Any) -> Any:
609
+ if is_dataclass(value):
610
+ return _to_json(asdict(value))
611
+ if isinstance(value, dict):
612
+ return {str(k): _to_json(v) for k, v in value.items()}
613
+ if isinstance(value, (list, tuple)):
614
+ return [_to_json(v) for v in value]
615
+ if isinstance(value, datetime):
616
+ return value.isoformat()
617
+ try:
618
+ json.dumps(value)
619
+ return value
620
+ except TypeError:
621
+ return str(value)
622
+
623
+
624
+ def _plain_init(result: dict[str, Any]) -> dict[str, Any]:
625
+ atomic = result["atomic"]
626
+ return {
627
+ "atomic": asdict(atomic) if is_dataclass(atomic) else atomic,
628
+ "invariant_violations": result["invariant_violations"],
629
+ "branches": result.get("branches"),
630
+ }
631
+
632
+
633
+ def _restore_exit_code(result: dict[str, Any]) -> int:
634
+ if result.get("state") == "blocked":
635
+ return EXIT_DIRTY
636
+ if result.get("state") == "partial":
637
+ return EXIT_STORAGE
638
+ return EXIT_OK
639
+
640
+
641
+ def _identity_hash_input(args: argparse.Namespace) -> str:
642
+ if args.stdin:
643
+ token = sys.stdin.read().strip()
644
+ else:
645
+ token = args.token or ""
646
+ if not token:
647
+ raise ValueError("identity-hash needs a token argument or --stdin")
648
+ return token
649
+
650
+
651
+ def _load_dotenv(path: Path) -> None:
652
+ if not path.exists():
653
+ return
654
+ for line in path.read_text(encoding="utf-8").splitlines():
655
+ line = line.strip()
656
+ if not line or line.startswith("#") or "=" not in line:
657
+ continue
658
+ key, value = line.split("=", 1)
659
+ key = key.strip()
660
+ value = value.strip().strip('"').strip("'")
661
+ os.environ.setdefault(key, value)
662
+
663
+
664
+ if __name__ == "__main__":
665
+ raise SystemExit(main())
cfg/core/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """cfg.core — the engine (SPEC §1).
3
+
4
+ Depends ONLY on StorageAdapter (cfg.adapters.base) and ApprovalProvider
5
+ (cfg.approval.base). MUST NOT import any DB driver (pymongo/psycopg/...) or LLM SDK.
6
+ That boundary is enforced by tests/test_core_purity.py.
7
+
8
+ Modules (to be built, SPEC §17):
9
+ hashing — oid(doc) = sha256(canonical(strip(doc))) [SPEC §4]
10
+ asof — valid-time interval reconstruction [SPEC §5.8, V3-5]
11
+ engine — commit/restore/adopt/status orchestration over apply()
12
+ refs — ref grammar (@seq | sha256: | @{date} | tag: | =live | =HEAD) [SPEC §5.9]
13
+ """