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/__init__.py +13 -0
- cfg/adapters/__init__.py +25 -0
- cfg/adapters/base.py +127 -0
- cfg/adapters/mongo.py +570 -0
- cfg/adapters/postgres.py +756 -0
- cfg/approval/__init__.py +5 -0
- cfg/approval/base.py +29 -0
- cfg/cli/__init__.py +2 -0
- cfg/cli/main.py +665 -0
- cfg/core/__init__.py +13 -0
- cfg/core/authz.py +58 -0
- cfg/core/config.py +324 -0
- cfg/core/diff.py +43 -0
- cfg/core/engine.py +1388 -0
- cfg/core/hashing.py +102 -0
- cfg/core/identity.py +213 -0
- cfg/interfaces/__init__.py +2 -0
- cfg/interfaces/actions.py +598 -0
- cfg/mcp/__init__.py +10 -0
- cfg/mcp/server.py +452 -0
- cfg/ui/__init__.py +2 -0
- cfg/ui/server.py +1066 -0
- cfgit-0.1.0.dist-info/METADATA +744 -0
- cfgit-0.1.0.dist-info/RECORD +28 -0
- cfgit-0.1.0.dist-info/WHEEL +4 -0
- cfgit-0.1.0.dist-info/entry_points.txt +3 -0
- cfgit-0.1.0.dist-info/licenses/LICENSE +201 -0
- cfgit-0.1.0.dist-info/licenses/NOTICE +10 -0
|
@@ -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
|
+
"""
|