reviso-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.
reviso_cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Reviso command line client."""
2
+ from __future__ import annotations
3
+
4
+ from .client import RevisoClient
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["RevisoClient", "__version__"]
reviso_cli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
reviso_cli/access.py ADDED
@@ -0,0 +1,49 @@
1
+ """Access levels used by invites and share links.
2
+
3
+ The three levels below are the vocabulary a user types and sees. What each level
4
+ resolves to on the server is deliberately not represented here: the server owns
5
+ that mapping, and the client sends the level, not an expanded result.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ ACCESS_TO_ROLE = {
10
+ "view": "viewer",
11
+ "comment": "commenter",
12
+ "edit": "editor",
13
+ }
14
+
15
+ VALID_ACCESS = tuple(ACCESS_TO_ROLE)
16
+
17
+
18
+ def normalize_access(access: str) -> str:
19
+ value = str(access or "comment").strip()
20
+ if value not in ACCESS_TO_ROLE:
21
+ raise ValueError("access must be view, comment, or edit")
22
+ return value
23
+
24
+
25
+ def role_for_access(access: str) -> str:
26
+ return ACCESS_TO_ROLE[normalize_access(access)]
27
+
28
+
29
+ def access_from_body(body: dict) -> str:
30
+ if str(body.get("role", "")).strip():
31
+ raise ValueError("role is no longer accepted; pass access")
32
+ return normalize_access(str(body.get("access") or "comment"))
33
+
34
+
35
+ def invite_access_from_body(body: dict) -> str:
36
+ return access_from_body(body)
37
+
38
+
39
+ def invite_for_public(item: dict) -> dict:
40
+ result = {key: value for key, value in item.items() if key != "role"}
41
+ result["access"] = normalize_access(str(item.get("access") or "comment"))
42
+ return result
43
+
44
+
45
+ def invite_list_for_public(result: dict) -> dict:
46
+ return {
47
+ **result,
48
+ "invites": [invite_for_public(item) for item in result.get("invites", [])],
49
+ }
@@ -0,0 +1,276 @@
1
+ """Command line dispatcher. Every server call goes through RevisoClient."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from .. import command_journal
10
+ from ..client import RevisoClient
11
+ from ..document_route_keys import public_document_path
12
+ from ..document_search import search_documents
13
+ from ..errors import ValidationError
14
+ from ..local import read_mapping, write_mapping # noqa: F401
15
+ from ..origin import DEFAULT_SERVER_URL
16
+ from ..paths import config_path, packet_path, read_json, write_json
17
+ from ..scope import resolve_agent_create_scope
18
+ from .doctor import doctor_report, print_doctor
19
+ from .guest_comments import add_guest_comment_commands
20
+ from .invites import add_invite_commands
21
+ from .versions import add_version_commands, format_file_arg
22
+
23
+ # Response blocks that are browser UI state. The human status line drops them;
24
+ # --json keeps them.
25
+ ACCESS_KEYS = ("effective_access", "security_policy")
26
+
27
+
28
+ def _load_config() -> dict:
29
+ path = config_path()
30
+ if path.exists():
31
+ return read_json(path)
32
+ return {}
33
+
34
+
35
+ def _server_url() -> str:
36
+ cfg = _load_config()
37
+ return os.environ.get("REVISO_SERVER",
38
+ cfg.get("server", DEFAULT_SERVER_URL))
39
+
40
+
41
+ def _automation_key(cfg: dict) -> str:
42
+ return (os.environ.get("REVISO_AUTOMATION_KEY")
43
+ or cfg.get("automation_key", ""))
44
+
45
+
46
+ def _client() -> RevisoClient:
47
+ return RevisoClient(_server_url(), _automation_key(_load_config()) or None)
48
+
49
+
50
+ def cmd_auth(args: argparse.Namespace) -> None:
51
+ if args.action == "login":
52
+ key = args.key or os.environ.get("REVISO_AUTOMATION_KEY", "")
53
+ server = args.server or os.environ.get("REVISO_SERVER",
54
+ DEFAULT_SERVER_URL)
55
+ if not key:
56
+ print("No automation key. Create one in Reviso under Settings -> "
57
+ "Integrations, then set REVISO_AUTOMATION_KEY or pass --key.")
58
+ return
59
+ write_json(config_path(), {"server": server, "automation_key": key})
60
+ print(f"Saved to {config_path()}")
61
+ elif args.action == "status":
62
+ cfg = _load_config()
63
+ print(f"server: {cfg.get('server', '(not set)')}")
64
+ print(f"key: {'set' if cfg.get('automation_key') else '(not set)'}")
65
+ print(f"config: {config_path()}")
66
+ else:
67
+ print(f"unknown auth action: {args.action}")
68
+
69
+
70
+ def cmd_publish(args: argparse.Namespace) -> None:
71
+ path = Path(args.file)
72
+ scope = _workspace_scope(args.workspace_id or "", args.parent_id or "")
73
+ content = path.read_text()
74
+ fmt = format_file_arg(args, path)
75
+ title = args.title or path.name
76
+ # A journaled operation_id is reused across reruns of THIS command, so a
77
+ # crashed-then-rerun publish replays instead of double-creating. Cleared
78
+ # only after every local success step has completed.
79
+ parts = [title, content, fmt, args.description or "",
80
+ scope.workspace_id, scope.parent_id]
81
+ op = command_journal.operation_id_for("publish", parts)
82
+ result = _client().publish(title, content, fmt, args.description,
83
+ scope.workspace_id, scope.parent_id,
84
+ operation_id=op)
85
+ mapping = write_mapping(result, path)
86
+ print(mapping["url"])
87
+ command_journal.forget("publish", parts)
88
+
89
+
90
+ def cmd_open(args: argparse.Namespace) -> None:
91
+ print(f"{_server_url()}{public_document_path(args.document_id)}")
92
+
93
+
94
+ def cmd_status(args: argparse.Namespace) -> None:
95
+ payload = _client().status(args.document_id)
96
+ if args.json:
97
+ print(json.dumps(payload, indent=2, sort_keys=True))
98
+ return
99
+ for key, value in payload.items():
100
+ if key in ACCESS_KEYS:
101
+ continue
102
+ print(f"{key}: {value}")
103
+
104
+
105
+ def cmd_server_info(args: argparse.Namespace) -> None:
106
+ client = _client()
107
+ payload = {"health": client.health,
108
+ "ready": client.ready,
109
+ "version": client.version_info}[args.kind]()
110
+ print(json.dumps(payload, indent=2, sort_keys=True))
111
+
112
+
113
+ def cmd_workspaces(args: argparse.Namespace) -> None:
114
+ payload = _client().workspaces()
115
+ if args.json:
116
+ print(json.dumps(payload, indent=2, sort_keys=True))
117
+ return
118
+ for ws in payload.get("workspaces", []):
119
+ print(f"{ws.get('workspace_id')}\t{ws.get('workspace_name')}\t"
120
+ f"[{ws.get('workspace_type')}]\t{ws.get('role')}")
121
+
122
+
123
+ def cmd_documents(args: argparse.Namespace) -> None:
124
+ payload = _client().dashboard(args.workspace_id or "")
125
+ if args.json:
126
+ print(json.dumps(payload, indent=2, sort_keys=True))
127
+ return
128
+ for doc in payload.get("reviews", []):
129
+ status = doc.get("lifecycle_status") or ""
130
+ print(f"{doc.get('document_id')}\t{doc.get('title')}\t"
131
+ f"[{status}]\t{doc.get('unresolved_count')} open")
132
+
133
+
134
+ def cmd_search(args: argparse.Namespace) -> None:
135
+ try:
136
+ payload = search_documents(
137
+ _client(), args.query, workspace_id=args.workspace_id or "",
138
+ limit=args.limit, archived=args.archived,
139
+ include_content=args.include_content)
140
+ except ValidationError as exc:
141
+ raise SystemExit(str(exc)) from exc
142
+ if args.json:
143
+ print(json.dumps(payload, indent=2, sort_keys=True))
144
+ return
145
+ for doc in payload.get("matches", []):
146
+ print(f"{doc.get('document_id')}\t{doc.get('title')}\t"
147
+ f"[{doc.get('match_field')}]\t{doc.get('snippet')}")
148
+
149
+
150
+ def cmd_doctor(args: argparse.Namespace) -> None:
151
+ cfg = _load_config()
152
+ report = doctor_report(
153
+ _client(), _server_url(), _automation_key(cfg),
154
+ os.environ.get("REVISO_WORKSPACE_ID", cfg.get("workspace_id", "")))
155
+ if args.json:
156
+ print(json.dumps(report, indent=2, sort_keys=True))
157
+ return
158
+ print_doctor(report)
159
+
160
+
161
+ def cmd_packet(args: argparse.Namespace) -> None:
162
+ v = "2" if args.v2 else "3"
163
+ packet = _client().packet(args.document_id, args.thread_id, version=v)
164
+ pid = packet.get("document_id", args.document_id)
165
+ path = packet_path(f"pkt_{pid}", "json")
166
+ write_json(path, packet)
167
+ print(str(path))
168
+
169
+
170
+ def _workspace_scope(explicit: str = "", parent_id: str = ""):
171
+ cfg = _load_config()
172
+ key = _automation_key(cfg)
173
+ return resolve_agent_create_scope(
174
+ explicit_workspace_id=explicit, explicit_parent_id=parent_id,
175
+ config=cfg, has_automation_key=key.startswith("rak_"))
176
+
177
+
178
+ def _add_basic(sub: argparse._SubParsersAction) -> None:
179
+ auth = sub.add_parser("auth")
180
+ auth.add_argument("action", nargs="?", default="login")
181
+ auth.add_argument("--server")
182
+ auth.add_argument("--key")
183
+ auth.set_defaults(func=cmd_auth)
184
+ pub = sub.add_parser("publish")
185
+ pub.add_argument("file")
186
+ pub.add_argument("--title")
187
+ pub.add_argument("--format", choices=["markdown", "md", "html"])
188
+ pub.add_argument("--description", default="Initial draft")
189
+ pub.add_argument("--workspace-id", default="")
190
+ pub.add_argument("--parent-id", default="")
191
+ pub.set_defaults(func=cmd_publish)
192
+ opn = sub.add_parser("open")
193
+ opn.add_argument("document_id")
194
+ opn.set_defaults(func=cmd_open)
195
+ st = sub.add_parser("status")
196
+ st.add_argument("document_id")
197
+ st.add_argument("--json", action="store_true")
198
+ st.set_defaults(func=cmd_status)
199
+ wss = sub.add_parser("workspaces")
200
+ wss.add_argument("--json", action="store_true")
201
+ wss.set_defaults(func=cmd_workspaces)
202
+ docs = sub.add_parser("documents")
203
+ docs.add_argument("--workspace-id", default="")
204
+ docs.add_argument("--json", action="store_true")
205
+ docs.set_defaults(func=cmd_documents)
206
+ search = sub.add_parser("search")
207
+ search.add_argument("query")
208
+ search.add_argument("--workspace-id", default="")
209
+ search.add_argument("--limit", type=int, default=10)
210
+ search.add_argument("--archived", action="store_true")
211
+ search.add_argument("--include-content", action="store_true")
212
+ search.add_argument("--json", action="store_true")
213
+ search.set_defaults(func=cmd_search)
214
+ doctor = sub.add_parser("doctor")
215
+ doctor.add_argument("--json", action="store_true")
216
+ doctor.set_defaults(func=cmd_doctor)
217
+ for name in ("health", "ready", "version"):
218
+ srv = sub.add_parser(name)
219
+ srv.set_defaults(func=cmd_server_info, kind=name)
220
+
221
+
222
+ def _add_packet(sub: argparse._SubParsersAction) -> None:
223
+ pkt = sub.add_parser("packet")
224
+ pkt.add_argument("document_id")
225
+ pkt.add_argument("--format", choices=["json"], default="json")
226
+ pkt.add_argument("--thread-id", action="append")
227
+ pkt.add_argument("--v2", action="store_true",
228
+ help="Use legacy packet v2 with sections")
229
+ pkt.set_defaults(func=cmd_packet)
230
+
231
+
232
+ def _add_review_io(sub: argparse._SubParsersAction) -> None:
233
+ exp = sub.add_parser("export")
234
+ exp.add_argument("document_id")
235
+ exp.add_argument("--output", default="")
236
+ exp.set_defaults(func=cmd_export)
237
+ imp = sub.add_parser("import")
238
+ imp.add_argument("file")
239
+ imp.add_argument("--workspace-id", default="")
240
+ imp.add_argument("--parent-id", default="")
241
+ imp.set_defaults(func=cmd_import)
242
+
243
+
244
+ def cmd_export(args: argparse.Namespace) -> None:
245
+ bundle = _client().export_review(args.document_id)
246
+ if args.output:
247
+ write_json(Path(args.output), bundle)
248
+ print(args.output)
249
+ return
250
+ print(json.dumps(bundle, indent=2, sort_keys=True))
251
+
252
+
253
+ def cmd_import(args: argparse.Namespace) -> None:
254
+ bundle = read_json(Path(args.file))
255
+ scope = _workspace_scope(args.workspace_id or "", args.parent_id or "")
256
+ result = _client().import_review(
257
+ bundle, scope.workspace_id, scope.parent_id)
258
+ print(json.dumps(result, indent=2, sort_keys=True))
259
+
260
+
261
+ def build_parser() -> argparse.ArgumentParser:
262
+ parser = argparse.ArgumentParser(prog="reviso")
263
+ sub = parser.add_subparsers(dest="cmd", required=True)
264
+ _add_basic(sub)
265
+ _add_packet(sub)
266
+ _add_review_io(sub)
267
+ add_guest_comment_commands(sub, _client)
268
+ add_invite_commands(sub, _client)
269
+ add_version_commands(sub, _client)
270
+ return parser
271
+
272
+
273
+ def main(argv: list[str] | None = None) -> None:
274
+ parser = build_parser()
275
+ args = parser.parse_args(argv)
276
+ args.func(args)
@@ -0,0 +1,88 @@
1
+ """CLI environment diagnostics."""
2
+ from __future__ import annotations
3
+
4
+ import urllib.error
5
+ import urllib.request
6
+
7
+
8
+ def doctor_report(client, server_url: str, token: str,
9
+ workspace_id: str = "") -> dict:
10
+ checks = [
11
+ _static("server_config", bool(server_url), server_url or "not set"),
12
+ _static("auth_token", bool(token), _token_message(token)),
13
+ _call("health", client.health),
14
+ _call("ready", client.ready),
15
+ _call("version", client.version_info),
16
+ _call("workspaces", client.workspaces),
17
+ _mcp_check(server_url),
18
+ ]
19
+ if workspace_id:
20
+ checks.append(_workspace_check(checks[-2], workspace_id))
21
+ return {"ok": all(check["status"] != "fail" for check in checks),
22
+ "checks": checks}
23
+
24
+
25
+ def print_doctor(report: dict) -> None:
26
+ for check in report["checks"]:
27
+ print(f"{check['status'].upper()}\t{check['name']}\t{check['message']}")
28
+ print("OK" if report["ok"] else "FAILED")
29
+
30
+
31
+ def _static(name: str, ok: bool, message: str) -> dict:
32
+ return {"name": name, "status": "ok" if ok else "warn",
33
+ "message": message}
34
+
35
+
36
+ def _call(name: str, func) -> dict:
37
+ try:
38
+ payload = func()
39
+ except SystemExit as exc:
40
+ return {"name": name, "status": "fail", "message": str(exc)}
41
+ except Exception as exc: # noqa: BLE001 - diagnostic boundary.
42
+ return {"name": name, "status": "fail", "message": str(exc)}
43
+ return {"name": name, "status": "ok", "message": _summary(payload),
44
+ "payload": payload}
45
+
46
+
47
+ def _mcp_check(server_url: str) -> dict:
48
+ if not server_url:
49
+ return {"name": "mcp_endpoint", "status": "warn",
50
+ "message": "server not set"}
51
+ req = urllib.request.Request(
52
+ f"{server_url.rstrip('/')}/mcp",
53
+ headers={"Accept": "application/json"},
54
+ method="GET",
55
+ )
56
+ try:
57
+ urllib.request.urlopen(req, timeout=5).read()
58
+ except urllib.error.HTTPError as exc:
59
+ if exc.code == 405:
60
+ return {"name": "mcp_endpoint", "status": "ok",
61
+ "message": "reachable; POST JSON-RPC expected"}
62
+ return {"name": "mcp_endpoint", "status": "fail",
63
+ "message": f"HTTP {exc.code}"}
64
+ except Exception as exc: # noqa: BLE001 - diagnostic boundary.
65
+ return {"name": "mcp_endpoint", "status": "fail",
66
+ "message": str(exc)}
67
+ return {"name": "mcp_endpoint", "status": "ok", "message": "reachable"}
68
+
69
+
70
+ def _workspace_check(workspaces: dict, workspace_id: str) -> dict:
71
+ rows = (workspaces.get("payload") or {}).get("workspaces") or []
72
+ found = any(row.get("workspace_id") == workspace_id for row in rows)
73
+ return {"name": "workspace_scope", "status": "ok" if found else "fail",
74
+ "message": workspace_id if found else f"{workspace_id} not visible"}
75
+
76
+
77
+ def _token_message(token: str) -> str:
78
+ return "set" if token else "missing REVISO_AUTOMATION_KEY"
79
+
80
+
81
+ def _summary(payload: dict) -> str:
82
+ if "workspaces" in payload:
83
+ return f"{len(payload['workspaces'])} workspaces"
84
+ if "version" in payload:
85
+ return str(payload["version"])
86
+ if "status" in payload:
87
+ return str(payload["status"])
88
+ return "ok"
@@ -0,0 +1,61 @@
1
+ """CLI commands for guest comment workflows."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from typing import Callable
7
+
8
+
9
+ def add_guest_comment_commands(sub: argparse._SubParsersAction,
10
+ client_factory: Callable) -> None:
11
+ guest = sub.add_parser("guest")
12
+ guest.add_argument("document_id"); guest.add_argument("--name", required=True)
13
+ guest.add_argument("--email")
14
+ guest.set_defaults(func=lambda a: cmd_guest(a, client_factory))
15
+ comment = sub.add_parser("comment")
16
+ comment.add_argument("document_id")
17
+ comment.add_argument("--guest-id", default="")
18
+ comment.add_argument("--guest-token", default="")
19
+ comment.add_argument("--body", required=True)
20
+ comment.add_argument("--anchor-type", choices=["block", "selection"],
21
+ default="block")
22
+ comment.add_argument("--block-id", default="")
23
+ comment.add_argument("--quote", default="")
24
+ comment.set_defaults(func=lambda a: cmd_comment(a, client_factory))
25
+ reply = sub.add_parser("reply")
26
+ reply.add_argument("thread_id")
27
+ reply.add_argument("--guest-id", default="")
28
+ reply.add_argument("--guest-token", default="")
29
+ reply.add_argument("--body", required=True)
30
+ reply.set_defaults(func=lambda a: cmd_reply(a, client_factory))
31
+ resolve = sub.add_parser("resolve")
32
+ resolve.add_argument("thread_id")
33
+ resolve.add_argument("--guest-id", default="")
34
+ resolve.add_argument("--guest-token", default="")
35
+ resolve.add_argument("--reason", required=True)
36
+ resolve.set_defaults(func=lambda a: cmd_resolve(a, client_factory))
37
+
38
+
39
+ def cmd_guest(args: argparse.Namespace, client_factory: Callable) -> None:
40
+ result = client_factory().guest(args.document_id, args.name, args.email or None)
41
+ print(json.dumps(result, indent=2, sort_keys=True))
42
+
43
+
44
+ def cmd_comment(args: argparse.Namespace, client_factory: Callable) -> None:
45
+ anchor = {"type": args.anchor_type, "block_id": args.block_id,
46
+ "quote": args.quote}
47
+ result = client_factory().comment(args.document_id, args.guest_id,
48
+ args.guest_token, args.body, anchor)
49
+ print(json.dumps(result, indent=2, sort_keys=True))
50
+
51
+
52
+ def cmd_reply(args: argparse.Namespace, client_factory: Callable) -> None:
53
+ result = client_factory().reply(args.thread_id, args.guest_id,
54
+ args.guest_token, args.body)
55
+ print(json.dumps(result, indent=2, sort_keys=True))
56
+
57
+
58
+ def cmd_resolve(args: argparse.Namespace, client_factory: Callable) -> None:
59
+ result = client_factory().resolve(args.thread_id, args.guest_id,
60
+ args.guest_token, args.reason)
61
+ print(json.dumps(result, indent=2, sort_keys=True))
@@ -0,0 +1,72 @@
1
+ """CLI invite commands.
2
+
3
+ Anonymous share links are deliberately not part of this client. A share link
4
+ carries a bearer token in its URL, and creating one is a browser action where the
5
+ person handing it out can see who it is going to. Invites by email are here.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ from typing import Callable
12
+
13
+ from ..client import RevisoClient
14
+ from ..access import invite_access_from_body, invite_for_public, invite_list_for_public
15
+
16
+
17
+ def add_invite_commands(sub: argparse._SubParsersAction,
18
+ client_factory: Callable[[], RevisoClient]) -> None:
19
+ invite = sub.add_parser("invite")
20
+ invite_sub = invite.add_subparsers(dest="invite_cmd", required=True)
21
+ create = invite_sub.add_parser("create")
22
+ create.add_argument("document_id")
23
+ create.add_argument("--email", required=True)
24
+ create.add_argument("--access", default="",
25
+ choices=["view", "comment", "edit"])
26
+ create.add_argument("--json", action="store_true")
27
+ create.set_defaults(func=lambda a: _create(a, client_factory))
28
+ list_cmd = invite_sub.add_parser("list")
29
+ list_cmd.add_argument("document_id")
30
+ list_cmd.add_argument("--json", action="store_true")
31
+ list_cmd.set_defaults(func=lambda a: _list(a, client_factory))
32
+ revoke = invite_sub.add_parser("revoke")
33
+ revoke.add_argument("invite_id")
34
+ revoke.add_argument("--json", action="store_true")
35
+ revoke.set_defaults(func=lambda a: _revoke(a, client_factory))
36
+
37
+
38
+ def _create(args: argparse.Namespace,
39
+ client_factory: Callable[[], RevisoClient]) -> None:
40
+ body = {}
41
+ if args.access:
42
+ body["access"] = args.access
43
+ access = invite_access_from_body(body)
44
+ result = invite_for_public(
45
+ client_factory().create_invite(
46
+ args.document_id, args.email, access))
47
+ if args.json:
48
+ print(json.dumps(result, indent=2, sort_keys=True))
49
+ return
50
+ if result.get("existing"):
51
+ print(f"pending invite already exists: {result['invite_id']}")
52
+ else:
53
+ print(result.get("invite_url") or result["invite_id"])
54
+
55
+
56
+ def _list(args: argparse.Namespace,
57
+ client_factory: Callable[[], RevisoClient]) -> None:
58
+ result = invite_list_for_public(client_factory().list_invites(args.document_id))
59
+ if args.json:
60
+ print(json.dumps(result, indent=2, sort_keys=True))
61
+ return
62
+ for item in result.get("invites", []):
63
+ print(f"{item['id']}\t{item['status']}\t{item['access']}\t{item['email']}")
64
+
65
+
66
+ def _revoke(args: argparse.Namespace,
67
+ client_factory: Callable[[], RevisoClient]) -> None:
68
+ result = client_factory().revoke_invite(args.invite_id)
69
+ if args.json:
70
+ print(json.dumps(result, indent=2, sort_keys=True))
71
+ else:
72
+ print(f"{result['id']}: {result['status']}")
@@ -0,0 +1,86 @@
1
+ """V1.0 version/edit CLI commands."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .. import command_journal
9
+
10
+
11
+ def cmd_update(args: argparse.Namespace) -> None:
12
+ path = Path(args.file)
13
+ client = args.client_factory()
14
+ content = path.read_text()
15
+ fmt = format_file_arg(args, path)
16
+ # Journal key is USER inputs only -- NOT the dynamically resolved latest, so
17
+ # a rerun after a lost response does not drift onto the version the first run
18
+ # just committed. The concrete base resolved on first run is replayed via the
19
+ # stored request, keeping operation_id + base_version_id in lockstep (P1c).
20
+ parts = [args.document_id, content, args.description or "", fmt,
21
+ args.agent_name or "", args.base_version_id or ""]
22
+
23
+ def resolve() -> dict:
24
+ base = (args.base_version_id
25
+ or client.latest_content(args.document_id)["version_id"])
26
+ return {"base_version_id": base}
27
+
28
+ entry = command_journal.reserve("update", parts, resolve=resolve)
29
+ op = entry["operation_id"]
30
+ base = entry["request"]["base_version_id"]
31
+ result = client.update(args.document_id, content, base,
32
+ args.description, fmt,
33
+ args.agent_name, operation_id=op)
34
+ print(json.dumps(result, indent=2, sort_keys=True))
35
+ # Forget only after the local output step succeeded (see cmd_publish).
36
+ command_journal.forget("update", parts)
37
+
38
+
39
+ def cmd_versions(args: argparse.Namespace) -> None:
40
+ print(json.dumps(args.client_factory().versions(args.document_id),
41
+ indent=2, sort_keys=True))
42
+
43
+
44
+ def cmd_diff(args: argparse.Namespace) -> None:
45
+ result = args.client_factory().diff(args.document_id, args.from_version_id,
46
+ args.to_version_id)
47
+ if args.json:
48
+ print(json.dumps(result, indent=2, sort_keys=True))
49
+ else:
50
+ print("\n".join(result.get("unified_diff", [])))
51
+
52
+
53
+ def cmd_rollback(args: argparse.Namespace) -> None:
54
+ client = args.client_factory()
55
+ base = args.base_version_id or client.latest_content(args.document_id)["version_id"]
56
+ result = client.rollback(args.document_id, args.target_version_id, base,
57
+ args.description)
58
+ print(json.dumps(result, indent=2, sort_keys=True))
59
+
60
+
61
+ def add_version_commands(sub: argparse._SubParsersAction, client_factory) -> None:
62
+ upd = sub.add_parser("update")
63
+ upd.add_argument("document_id"); upd.add_argument("file")
64
+ upd.add_argument("--description", required=True)
65
+ upd.add_argument("--base-version-id", help="Version to base the update on (CAS). Omit to auto-base on the latest version (last-writer-wins); pass it for documents others are editing.")
66
+ upd.add_argument("--format", choices=["markdown", "md", "html"])
67
+ upd.add_argument("--agent-name", default="Agent")
68
+ upd.set_defaults(func=cmd_update, client_factory=client_factory)
69
+ vers = sub.add_parser("versions")
70
+ vers.add_argument("document_id")
71
+ vers.set_defaults(func=cmd_versions, client_factory=client_factory)
72
+ diff = sub.add_parser("diff")
73
+ diff.add_argument("document_id"); diff.add_argument("from_version_id")
74
+ diff.add_argument("to_version_id"); diff.add_argument("--json", action="store_true")
75
+ diff.set_defaults(func=cmd_diff, client_factory=client_factory)
76
+ rb = sub.add_parser("rollback")
77
+ rb.add_argument("document_id"); rb.add_argument("target_version_id")
78
+ rb.add_argument("--description", required=True)
79
+ rb.add_argument("--base-version-id")
80
+ rb.set_defaults(func=cmd_rollback, client_factory=client_factory)
81
+
82
+
83
+ def format_file_arg(args: argparse.Namespace, path: Path) -> str:
84
+ if getattr(args, "format", None):
85
+ return "html" if args.format == "html" else "markdown"
86
+ return "html" if path.suffix.lower() in (".html", ".htm") else "markdown"