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.
@@ -0,0 +1,598 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """Shared action layer for CLI, MCP, and UI."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, is_dataclass
6
+ from datetime import datetime, time, timezone
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from cfg.adapters.base import AtomicityUnavailable, AmbiguousConfig, NoSuchConfig, StaleHead, StaleLive
12
+ from cfg.core.authz import PermissionDenied, permission_role
13
+ from cfg.core.config import ProjectConfig, load_config
14
+ from cfg.core.diff import format_diff
15
+ from cfg.core.engine import BranchingDisabled, Engine, RecordRef, SecretBlocked
16
+ from cfg.core.identity import IdentityError, resolve_identity, resolve_self_asserted_author
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ActionContext:
21
+ config_file: str | None = None
22
+ env: str = "dev"
23
+ author: str | None = None
24
+ branch: str | None = None
25
+
26
+
27
+ EXIT_OK = 0
28
+ EXIT_ARG = 1
29
+ EXIT_DIRTY = 2
30
+ EXIT_STORAGE = 3
31
+ EXIT_FORBIDDEN = 4
32
+ EXIT_NOT_FOUND = 5
33
+ EXIT_INVARIANT = 6
34
+
35
+
36
+ def make_engine(ctx: ActionContext) -> Engine:
37
+ project = load_config(ctx.config_file)
38
+ return engine_for_project(project, env_name=ctx.env, author=ctx.author)
39
+
40
+
41
+ def engine_for_project(project: ProjectConfig, *, env_name: str, author: str | None) -> Engine:
42
+ if env_name not in project.envs:
43
+ raise ValueError(f"unknown env: {env_name}")
44
+ env = project.envs[env_name]
45
+ if env.database == "mongo":
46
+ from cfg.adapters.mongo import MongoAdapter
47
+
48
+ adapter = MongoAdapter(project=project, env_name=env_name)
49
+ elif env.database == "postgres":
50
+ from cfg.adapters.postgres import PostgresAdapter
51
+
52
+ adapter = PostgresAdapter(project=project, env_name=env_name)
53
+ else:
54
+ raise ValueError(f"unsupported database for v1 slice: {env.database}")
55
+ identity = resolve_identity(env, adapter, explicit_author=author)
56
+ return Engine(project, adapter, env=env_name, identity=identity)
57
+
58
+
59
+ def envelope(fn, *args, **kwargs) -> dict[str, Any]:
60
+ try:
61
+ data, code = fn(*args, **kwargs)
62
+ status = "ok" if code == EXIT_OK else "dirty" if code == EXIT_DIRTY else "error"
63
+ return {"status": status, "code": code, "message": "", "data": to_json(data)}
64
+ except AmbiguousConfig as exc:
65
+ return _error("bad_config", EXIT_INVARIANT, exc)
66
+ except (StaleHead, StaleLive) as exc:
67
+ return _error("changed_outside_cfgit", EXIT_DIRTY, exc)
68
+ except PermissionDenied as exc:
69
+ return _error("forbidden", EXIT_FORBIDDEN, exc)
70
+ except IdentityError as exc:
71
+ return _error("identity_required", EXIT_FORBIDDEN, exc)
72
+ except AtomicityUnavailable as exc:
73
+ return _error("atomicity_unavailable", EXIT_STORAGE, exc)
74
+ except NoSuchConfig as exc:
75
+ return _error("not_found", EXIT_NOT_FOUND, exc)
76
+ except (BranchingDisabled, SecretBlocked, ValueError, FileNotFoundError, KeyError) as exc:
77
+ return _error("error", EXIT_ARG, exc)
78
+ except Exception as exc:
79
+ return _error("error", EXIT_STORAGE, exc)
80
+
81
+
82
+ def whoami(engine: Engine) -> tuple[dict[str, Any], int]:
83
+ env = engine.config.envs[engine.env]
84
+ return {
85
+ "author": engine.author,
86
+ "identity": engine.identity.history_meta(),
87
+ "identity_display": engine.identity.display,
88
+ "env": engine.env,
89
+ "database": env.database,
90
+ "permission_role": permission_role(env.permissions, engine.author),
91
+ "permission_mode": env.permissions.mode,
92
+ "identity_mode": env.identity.mode,
93
+ "config_file": str(engine.config.path),
94
+ }, EXIT_OK
95
+
96
+
97
+ def init(engine: Engine) -> tuple[dict[str, Any], int]:
98
+ result = engine.init()
99
+ violations = result["invariant_violations"]
100
+ return plain_init(result), EXIT_INVARIANT if violations else EXIT_OK
101
+
102
+
103
+ def status(engine: Engine, record: str | None = None) -> tuple[list[Any], int]:
104
+ rows = engine.status(parse_record(record) if record else None)
105
+ code = EXIT_DIRTY if any(r.state == "changed_outside_cfgit" for r in rows) else EXIT_OK
106
+ return rows, code
107
+
108
+
109
+ def doctor(engine: Engine, record: str | None = None, *, large_field_bytes: int = 20000) -> tuple[dict[str, Any], int]:
110
+ report = engine.doctor(
111
+ parse_record(record) if record else None,
112
+ large_field_bytes=large_field_bytes,
113
+ )
114
+ code = EXIT_OK if report.get("ok") else EXIT_DIRTY
115
+ return report, code
116
+
117
+
118
+ def import_records(
119
+ engine: Engine,
120
+ record: str | None,
121
+ *,
122
+ all_records: bool,
123
+ message: str,
124
+ allow_secret: bool = False,
125
+ ) -> tuple[Any, int]:
126
+ if not all_records and not record:
127
+ raise ValueError("import needs all_records=true or a record")
128
+ result = engine.import_records(
129
+ parse_record(record) if record else None,
130
+ message=message,
131
+ allow_secret=allow_secret,
132
+ )
133
+ return result, EXIT_OK
134
+
135
+
136
+ def diff(engine: Engine, record: str, a: str = "=HEAD", b: str = "=live") -> tuple[dict[str, Any], int]:
137
+ changes = engine.diff(parse_record(record), a, b)
138
+ return {"changes": changes, "text": format_diff(changes)}, EXIT_OK
139
+
140
+
141
+ def commit(
142
+ engine: Engine,
143
+ record: str,
144
+ doc: dict[str, Any],
145
+ *,
146
+ message: str,
147
+ allow_secret: bool = False,
148
+ branch: str | None = None,
149
+ ) -> tuple[dict[str, Any], int]:
150
+ if branch and branch != engine.config.branches.default_branch:
151
+ result = engine.branch_commit(branch, parse_record(record), doc, message=message, allow_secret=allow_secret)
152
+ else:
153
+ result = engine.commit(parse_record(record), doc, message=message, allow_secret=allow_secret)
154
+ code = EXIT_DIRTY if result.get("state") == "changed_outside_cfgit" else EXIT_OK
155
+ return result, code
156
+
157
+
158
+ def bulk_commit(
159
+ engine: Engine,
160
+ items: Any,
161
+ *,
162
+ message: str,
163
+ allow_secret: bool = False,
164
+ branch: str | None = None,
165
+ ) -> tuple[dict[str, Any], int]:
166
+ parsed = _bulk_commit_items(items)
167
+ if branch and branch != engine.config.branches.default_branch:
168
+ result = engine.branch_commit_many(branch, parsed, message=message, allow_secret=allow_secret)
169
+ else:
170
+ result = engine.commit_many(parsed, message=message, allow_secret=allow_secret)
171
+ return result, bulk_commit_exit_code(result)
172
+
173
+
174
+ def log(engine: Engine, record: str, *, limit: int | None = 20) -> tuple[list[dict[str, Any]], int]:
175
+ return engine.log(parse_record(record), limit=limit), EXIT_OK
176
+
177
+
178
+ def show(engine: Engine, record: str, ref: str) -> tuple[dict[str, Any], int]:
179
+ return engine.resolve_ref(parse_record(record), ref), EXIT_OK
180
+
181
+
182
+ def adopt(
183
+ engine: Engine,
184
+ record: str | None,
185
+ *,
186
+ all_records: bool,
187
+ message: str,
188
+ allow_secret: bool = False,
189
+ ) -> tuple[Any, int]:
190
+ if all_records:
191
+ results = []
192
+ for row in engine.status():
193
+ if row.state == "changed_outside_cfgit":
194
+ result = engine.adopt(
195
+ RecordRef(row.collection, row.record_id),
196
+ message=message,
197
+ allow_secret=allow_secret,
198
+ )
199
+ results.append({"collection": row.collection, "record_id": row.record_id, **result})
200
+ return results, EXIT_OK
201
+ if not record:
202
+ raise ValueError("adopt needs all_records=true or a record")
203
+ return engine.adopt(parse_record(record), message=message, allow_secret=allow_secret), EXIT_OK
204
+
205
+
206
+ def restore(
207
+ engine: Engine,
208
+ *,
209
+ record: str | None = None,
210
+ ref: str | None = None,
211
+ as_of: str | None = None,
212
+ tag: str | None = None,
213
+ dry_run: bool = False,
214
+ message: str,
215
+ ) -> tuple[dict[str, Any], int]:
216
+ if as_of and tag:
217
+ raise ValueError("restore accepts only one of as_of or tag")
218
+ if as_of:
219
+ if record or ref:
220
+ raise ValueError("restore as_of restores all records; omit record and ref")
221
+ result = engine.restore_system_as_of(parse_when(as_of), message=message, dry_run=dry_run)
222
+ code = restore_exit_code(result)
223
+ return result, code
224
+ if tag:
225
+ if record or ref:
226
+ raise ValueError("restore tag restores all records; omit record and ref")
227
+ result = engine.restore_system_tag(tag, message=message, dry_run=dry_run)
228
+ code = restore_exit_code(result)
229
+ return result, code
230
+ if dry_run:
231
+ raise ValueError("dry_run is only supported with system restore")
232
+ if not record or not ref:
233
+ raise ValueError("restore needs record and ref, or as_of/tag")
234
+ result = engine.restore(parse_record(record), ref, message=message)
235
+ code = EXIT_DIRTY if result.get("state") == "changed_outside_cfgit" else EXIT_OK
236
+ return result, code
237
+
238
+
239
+ def tag(engine: Engine, name: str) -> tuple[list[dict[str, Any]], int]:
240
+ return engine.tag(name), EXIT_OK
241
+
242
+
243
+ def branch_list(engine: Engine) -> tuple[list[dict[str, Any]], int]:
244
+ return engine.branch_list(), EXIT_OK
245
+
246
+
247
+ def branch_create(
248
+ engine: Engine,
249
+ name: str,
250
+ *,
251
+ from_branch: str = "main",
252
+ message: str | None = None,
253
+ ) -> tuple[dict[str, Any], int]:
254
+ return engine.branch_create(name, from_branch=from_branch, message=message), EXIT_OK
255
+
256
+
257
+ def branch_delete(engine: Engine, name: str) -> tuple[dict[str, Any], int]:
258
+ return engine.branch_delete(name), EXIT_OK
259
+
260
+
261
+ def branch_diff(engine: Engine, range_expr: str) -> tuple[dict[str, Any], int]:
262
+ return engine.branch_diff(range_expr), EXIT_OK
263
+
264
+
265
+ def branch_log(engine: Engine, branch: str, *, limit: int | None = 20) -> tuple[list[dict[str, Any]], int]:
266
+ return engine.branch_log(branch, limit=limit), EXIT_OK
267
+
268
+
269
+ def pr_create(engine: Engine, *, base: str, head: str, message: str) -> tuple[dict[str, Any], int]:
270
+ return engine.pr_create(base=base, head=head, message=message), EXIT_OK
271
+
272
+
273
+ def pr_list(engine: Engine, *, status: str | None = None) -> tuple[list[dict[str, Any]], int]:
274
+ return engine.pr_list(status=status), EXIT_OK
275
+
276
+
277
+ def pr_show(engine: Engine, pr_id: str) -> tuple[dict[str, Any], int]:
278
+ return engine.pr_show(pr_id), EXIT_OK
279
+
280
+
281
+ def pr_close(engine: Engine, pr_id: str) -> tuple[dict[str, Any], int]:
282
+ return engine.pr_close(pr_id), EXIT_OK
283
+
284
+
285
+ def pr_merge(engine: Engine, pr_id: str, *, message: str | None = None) -> tuple[dict[str, Any], int]:
286
+ return engine.pr_merge(pr_id, message=message), EXIT_OK
287
+
288
+
289
+ def fsck(engine: Engine) -> tuple[dict[str, Any], int]:
290
+ return {
291
+ "invariant_violations": engine.adapter.check_runtime_invariant(),
292
+ "atomicity": engine.adapter.check_atomicity_scope(),
293
+ "reconcile": engine.adapter.reconcile(),
294
+ }, EXIT_OK
295
+
296
+
297
+ def impact(
298
+ engine: Engine,
299
+ record: str,
300
+ *,
301
+ a: str = "=HEAD",
302
+ b: str = "=live",
303
+ use_llm: bool = False,
304
+ provider: str | None = None,
305
+ model: str | None = None,
306
+ against: list[str] | None = None,
307
+ ) -> tuple[dict[str, Any], int]:
308
+ try:
309
+ from cfg_impact.overview import overview
310
+ except ModuleNotFoundError as exc:
311
+ raise ValueError(
312
+ "cfgit-impact plugin is not installed. Install plugins/cfg_impact or cfgit[impact]."
313
+ ) from exc
314
+ return (
315
+ overview(engine, record, a=a, b=b, use_llm=use_llm, provider=provider, model=model, against=against),
316
+ EXIT_OK,
317
+ )
318
+
319
+
320
+ def run_named_action(name: str, engine: Engine, payload: dict[str, Any] | None = None) -> tuple[Any, int]:
321
+ payload = payload or {}
322
+ if name == "whoami":
323
+ return whoami(engine)
324
+ if name == "init":
325
+ return init(engine)
326
+ if name == "status":
327
+ return status(engine, _blank_to_none(payload.get("record")))
328
+ if name == "doctor":
329
+ return doctor(
330
+ engine,
331
+ _blank_to_none(payload.get("record")),
332
+ large_field_bytes=int(payload.get("large_field_bytes") or 20000),
333
+ )
334
+ if name == "import":
335
+ return import_records(
336
+ engine,
337
+ _blank_to_none(payload.get("record")),
338
+ all_records=bool(payload.get("all_records") or payload.get("all")),
339
+ message=str(payload.get("message") or "initial import"),
340
+ allow_secret=bool(payload.get("allow_secret")),
341
+ )
342
+ if name == "diff":
343
+ return diff(
344
+ engine,
345
+ _required(payload, "record"),
346
+ str(payload.get("a") or "=HEAD"),
347
+ str(payload.get("b") or "=live"),
348
+ )
349
+ if name == "commit":
350
+ if payload.get("items") is not None:
351
+ return bulk_commit(
352
+ engine,
353
+ payload.get("items"),
354
+ message=str(payload.get("message") or "commit"),
355
+ allow_secret=bool(payload.get("allow_secret")),
356
+ branch=_blank_to_none(payload.get("branch")),
357
+ )
358
+ return commit(
359
+ engine,
360
+ _required(payload, "record"),
361
+ _doc(payload.get("doc")),
362
+ message=str(payload.get("message") or "commit"),
363
+ allow_secret=bool(payload.get("allow_secret")),
364
+ branch=_blank_to_none(payload.get("branch")),
365
+ )
366
+ if name in {"bulk_commit", "commit_many"}:
367
+ return bulk_commit(
368
+ engine,
369
+ payload.get("items"),
370
+ message=str(payload.get("message") or "bulk commit"),
371
+ allow_secret=bool(payload.get("allow_secret")),
372
+ branch=_blank_to_none(payload.get("branch")),
373
+ )
374
+ if name == "log":
375
+ return log(engine, _required(payload, "record"), limit=int(payload.get("limit") or 20))
376
+ if name == "show":
377
+ return show(engine, _required(payload, "record"), str(payload.get("ref") or "HEAD"))
378
+ if name == "adopt":
379
+ return adopt(
380
+ engine,
381
+ _blank_to_none(payload.get("record")),
382
+ all_records=bool(payload.get("all_records") or payload.get("all")),
383
+ message=str(payload.get("message") or "adopt drift"),
384
+ allow_secret=bool(payload.get("allow_secret")),
385
+ )
386
+ if name == "restore":
387
+ return restore(
388
+ engine,
389
+ record=_blank_to_none(payload.get("record")),
390
+ ref=_blank_to_none(payload.get("ref")),
391
+ as_of=_blank_to_none(payload.get("as_of")),
392
+ tag=_blank_to_none(payload.get("tag")),
393
+ dry_run=bool(payload.get("dry_run")),
394
+ message=str(payload.get("message") or "restore"),
395
+ )
396
+ if name == "tag":
397
+ return tag(engine, _required(payload, "name"))
398
+ if name == "branch_list":
399
+ return branch_list(engine)
400
+ if name == "branch_create":
401
+ return branch_create(
402
+ engine,
403
+ _required(payload, "name"),
404
+ from_branch=str(payload.get("from_branch") or payload.get("from") or "main"),
405
+ message=_blank_to_none(payload.get("message")),
406
+ )
407
+ if name == "branch_delete":
408
+ return branch_delete(engine, _required(payload, "name"))
409
+ if name == "branch_diff":
410
+ return branch_diff(engine, _required(payload, "range"))
411
+ if name == "branch_log":
412
+ return branch_log(engine, _required(payload, "branch"), limit=int(payload.get("limit") or 20))
413
+ if name == "pr_create":
414
+ return pr_create(
415
+ engine,
416
+ base=str(payload.get("base") or "main"),
417
+ head=_required(payload, "head"),
418
+ message=str(payload.get("message") or "open PR"),
419
+ )
420
+ if name == "pr_list":
421
+ return pr_list(engine, status=_blank_to_none(payload.get("status")))
422
+ if name == "pr_show":
423
+ return pr_show(engine, _required(payload, "id"))
424
+ if name == "pr_close":
425
+ return pr_close(engine, _required(payload, "id"))
426
+ if name == "pr_merge":
427
+ return pr_merge(engine, _required(payload, "id"), message=_blank_to_none(payload.get("message")))
428
+ if name == "fsck":
429
+ return fsck(engine)
430
+ if name == "impact":
431
+ return impact(
432
+ engine,
433
+ _required(payload, "record"),
434
+ a=str(payload.get("a") or "=HEAD"),
435
+ b=str(payload.get("b") or "=live"),
436
+ use_llm=bool(payload.get("use_llm") or payload.get("llm")),
437
+ provider=_blank_to_none(payload.get("provider")),
438
+ model=_blank_to_none(payload.get("model")),
439
+ against=_as_record_list(payload.get("against")),
440
+ )
441
+ raise ValueError(f"unknown action: {name}")
442
+
443
+
444
+ def _as_record_list(value: Any) -> list[str] | None:
445
+ """Parse an `against` selection from a list (MCP/JSON) or a comma/space string (form)."""
446
+ if value is None:
447
+ return None
448
+ if isinstance(value, str):
449
+ items = [part.strip() for part in value.replace("\n", ",").replace(" ", ",").split(",")]
450
+ elif isinstance(value, (list, tuple)):
451
+ items = [
452
+ part.strip()
453
+ for raw in value
454
+ for part in str(raw).replace("\n", ",").replace(" ", ",").split(",")
455
+ ]
456
+ else:
457
+ return None
458
+ items = [part for part in items if part]
459
+ return items or None
460
+
461
+
462
+ def parse_record(raw: str | None) -> RecordRef:
463
+ if not raw or ":" not in raw:
464
+ raise ValueError("record must be collection:id, for example agent_configs:agent_planner")
465
+ collection, record_id = raw.split(":", 1)
466
+ if not collection or not record_id:
467
+ raise ValueError("record must be collection:id")
468
+ return RecordRef(collection, record_id)
469
+
470
+
471
+ def parse_when(raw: str) -> datetime:
472
+ value = raw.strip()
473
+ date_only = len(value) == 10 and value[4] == "-" and value[7] == "-"
474
+ if date_only:
475
+ dt = datetime.combine(datetime.fromisoformat(value).date(), time.max)
476
+ else:
477
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
478
+ if dt.tzinfo is None:
479
+ dt = dt.replace(tzinfo=timezone.utc)
480
+ return dt.astimezone(timezone.utc)
481
+
482
+
483
+ def load_json_file(path: str) -> dict[str, Any]:
484
+ with Path(path).open("r", encoding="utf-8") as f:
485
+ data = json.load(f)
486
+ if not isinstance(data, dict):
487
+ raise ValueError("--from file must contain one JSON object")
488
+ return data
489
+
490
+
491
+ def parse_json_doc(value: str | dict[str, Any]) -> dict[str, Any]:
492
+ return _doc(value)
493
+
494
+
495
+ def parse_bulk_commit_items(value: Any) -> list[tuple[RecordRef, dict[str, Any]]]:
496
+ return _bulk_commit_items(value)
497
+
498
+
499
+ def to_json(value: Any) -> Any:
500
+ if is_dataclass(value):
501
+ return to_json(asdict(value))
502
+ if isinstance(value, dict):
503
+ return {str(k): to_json(v) for k, v in value.items()}
504
+ if isinstance(value, (list, tuple)):
505
+ return [to_json(v) for v in value]
506
+ if isinstance(value, datetime):
507
+ return value.isoformat()
508
+ try:
509
+ json.dumps(value)
510
+ return value
511
+ except TypeError:
512
+ return str(value)
513
+
514
+
515
+ def plain_init(result: dict[str, Any]) -> dict[str, Any]:
516
+ atomic = result["atomic"]
517
+ return {
518
+ "atomic": asdict(atomic) if is_dataclass(atomic) else atomic,
519
+ "invariant_violations": result["invariant_violations"],
520
+ "branches": result.get("branches"),
521
+ }
522
+
523
+
524
+ def restore_exit_code(result: dict[str, Any]) -> int:
525
+ if result.get("state") == "blocked":
526
+ return EXIT_DIRTY
527
+ if result.get("state") == "partial":
528
+ return EXIT_STORAGE
529
+ return EXIT_OK
530
+
531
+
532
+ def bulk_commit_exit_code(result: dict[str, Any]) -> int:
533
+ if result.get("state") == "blocked":
534
+ return EXIT_DIRTY
535
+ if result.get("state") == "partial":
536
+ return EXIT_STORAGE
537
+ return EXIT_OK
538
+
539
+
540
+ def resolve_author(explicit: str | None = None) -> str:
541
+ return resolve_self_asserted_author(explicit)
542
+
543
+
544
+ def _bulk_commit_items(value: Any) -> list[tuple[RecordRef, dict[str, Any]]]:
545
+ if value is None:
546
+ raise ValueError("bulk commit needs items")
547
+ if isinstance(value, str):
548
+ return _bulk_commit_items(json.loads(value))
549
+ if isinstance(value, dict):
550
+ if "items" in value:
551
+ return _bulk_commit_items(value["items"])
552
+ return [(parse_record(record), _doc(doc)) for record, doc in value.items()]
553
+ if not isinstance(value, list):
554
+ raise ValueError("bulk commit items must be a list, mapping, or JSON string")
555
+
556
+ out: list[tuple[RecordRef, dict[str, Any]]] = []
557
+ for index, item in enumerate(value, start=1):
558
+ if not isinstance(item, dict):
559
+ raise ValueError(f"bulk commit item {index} must be an object")
560
+ record = _blank_to_none(item.get("record"))
561
+ if record is None:
562
+ collection = _blank_to_none(item.get("collection"))
563
+ record_id = _blank_to_none(item.get("record_id") or item.get("id"))
564
+ if not collection or not record_id:
565
+ raise ValueError(f"bulk commit item {index} needs record or collection+record_id")
566
+ record = f"{collection}:{record_id}"
567
+ if "doc" not in item:
568
+ raise ValueError(f"bulk commit item {index} needs doc")
569
+ out.append((parse_record(record), _doc(item.get("doc"))))
570
+ return out
571
+
572
+
573
+ def _doc(value: Any) -> dict[str, Any]:
574
+ if isinstance(value, dict):
575
+ return value
576
+ if isinstance(value, str):
577
+ data = json.loads(value)
578
+ if isinstance(data, dict):
579
+ return data
580
+ raise ValueError("doc must be a JSON object")
581
+
582
+
583
+ def _required(payload: dict[str, Any], key: str) -> str:
584
+ value = _blank_to_none(payload.get(key))
585
+ if value is None:
586
+ raise ValueError(f"{key} is required")
587
+ return value
588
+
589
+
590
+ def _blank_to_none(value: Any) -> str | None:
591
+ if value is None:
592
+ return None
593
+ text = str(value).strip()
594
+ return text or None
595
+
596
+
597
+ def _error(status: str, code: int, exc: Exception) -> dict[str, Any]:
598
+ return {"status": status, "code": code, "message": str(exc), "data": None}
cfg/mcp/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ # Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
2
+ """MCP server — the agent surface (SPEC §12).
3
+
4
+ Every tool returns a UNIFORM ENVELOPE carrying what CLI exit codes carry:
5
+ {status: ok|dirty|conflict|needs_approval|declined|not_found|error|invariant_violation,
6
+ code, message, data}
7
+ Tools: whoami, init, status, doctor, diff, impact, show, commit, bulk_commit,
8
+ adopt, restore, tag, fsck, identity_hash. There is intentionally NO approve/deny
9
+ tool — an agent can observe an approval, never grant it (SPEC §5.18, §11).
10
+ """