cite2site 1.0.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.
c2s/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Cite2Site first-slice implementation."""
2
+
3
+ __all__ = [
4
+ "__version__",
5
+ "BaseAdapter",
6
+ "FilesystemTextAdapter",
7
+ "MarkdownAdapter",
8
+ "AdapterArtifact",
9
+ "get_adapter",
10
+ "adapter_for_uri",
11
+ "adapter_diagnostics",
12
+ ]
13
+
14
+ __version__ = "1.0.0"
15
+
16
+ from .adapter import ( # noqa: E402 — re-export after version
17
+ AdapterArtifact,
18
+ BaseAdapter,
19
+ FilesystemTextAdapter,
20
+ MarkdownAdapter,
21
+ adapter_diagnostics,
22
+ adapter_for_uri,
23
+ get_adapter,
24
+ )
c2s/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
c2s/_context_menu.py ADDED
@@ -0,0 +1,44 @@
1
+ """Windows context-menu integration for Cite2Site.
2
+
3
+ Provides `c2s install-context-menu` and `c2s uninstall-context-menu` CLI
4
+ commands. Registers Cite2Site entries in the Windows Explorer right-click
5
+ menu so users can look up citations and cite selections directly from any
6
+ file.
7
+ """
8
+ from __future__ import annotations
9
+
10
+
11
+ def _menu_entries() -> list[dict[str, str]]:
12
+ """Return the context-menu entries to register."""
13
+ return [
14
+ {
15
+ "name": "Look up citations here",
16
+ "command": 'cmd /k c2s lookup-actions --artifact "%1" --start 0 --end 0',
17
+ },
18
+ {
19
+ "name": "Cite selection here",
20
+ "command": 'cmd /k c2s cite-selection --artifact "%1" --start 0 --end 0',
21
+ },
22
+ ]
23
+
24
+
25
+ def install() -> None:
26
+ """Register Cite2Site in the Windows Explorer context menu."""
27
+ from context_menu import menus
28
+
29
+ entries = [
30
+ menus.ContextCommand(name=e["name"], command=e["command"])
31
+ for e in _menu_entries()
32
+ ]
33
+ menu = menus.ContextMenu(name="Cite2Site", type="FILES", entries=entries)
34
+ menu.compile()
35
+ print("Cite2Site added to Windows right-click menu.")
36
+ print("Right-click any file -> Cite2Site -> Look up citations here / Cite selection here")
37
+
38
+
39
+ def uninstall() -> None:
40
+ """Remove Cite2Site from the Windows Explorer context menu."""
41
+ from context_menu import menus
42
+
43
+ menus.ContextMenu(name="Cite2Site", type="FILES", entries=[]).remove()
44
+ print("Cite2Site removed from Windows right-click menu.")
c2s/adapter.py ADDED
@@ -0,0 +1,436 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import dataclasses
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .core import (
9
+ C2SError,
10
+ Repo,
11
+ canonical_text,
12
+ evidence_for_text,
13
+ line_hashes,
14
+ line_number_at,
15
+ make_locator,
16
+ normalize_uri,
17
+ resolve_artifact_path,
18
+ sha256_json,
19
+ TEXT_CANON,
20
+ )
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Adapter diagnostics — stable structured codes for adapter-level failures
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _ADAPTER_DIAGNOSTICS = {
27
+ "E_ADAPTER_UNSUPPORTED": "the requested adapter is not installed or recognized",
28
+ "E_ADAPTER_UTF8": "artifact content is not valid UTF-8 text",
29
+ "E_ADAPTER_MISSING": "artifact file does not exist at the resolved path",
30
+ "E_ADAPTER_EMPTY_SELECTION": "selection range captured zero characters",
31
+ "E_ADAPTER_RANGE_INVALID": "selection range is outside artifact bounds",
32
+ "E_ADAPTER_AMBIGUOUS": "adapter cannot resolve a single authoritative evidence",
33
+ "E_ADAPTER_UNAVAILABLE": "artifact is temporarily unavailable (network, timeout, or converter failure)",
34
+ }
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Adapter contract types
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True)
42
+ class AdapterArtifact:
43
+ """Stable artifact identity produced by an adapter."""
44
+
45
+ adapter: str
46
+ uri: str
47
+ artifact_id: str
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Abstract adapter protocol
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ class BaseAdapter(abc.ABC):
56
+ """Protocol every Cite2Site adapter must fulfil.
57
+
58
+ Concrete adapters implement eight contract methods:
59
+ identify – stable artifact identity
60
+ canonicalize – read artifact and return canonical text
61
+ evidence – produce canonical evidence from selected text
62
+ locate – produce unambiguous locator coordinates
63
+ observe – read current artifact bytes at a locator (no mutation)
64
+ compare – deterministic resolved/changed status
65
+ summarize – metadata-safe human-readable range summary
66
+ privacy – comply with the active publication privacy mode
67
+ """
68
+
69
+ # Subclasses override this.
70
+ name: str = "base"
71
+
72
+ # ------------------------------------------------------------------
73
+ # Contract methods (must be implemented)
74
+ # ------------------------------------------------------------------
75
+
76
+ @abc.abstractmethod
77
+ def identify(self, repo: Repo, uri: str) -> AdapterArtifact:
78
+ """Return stable artifact identity for *uri*."""
79
+
80
+ @abc.abstractmethod
81
+ def canonicalize(self, repo: Repo, uri: str) -> str:
82
+ """Read artifact and return canonical text.
83
+
84
+ Raises structured C2SError with an adapter-level diagnostic on failure.
85
+ """
86
+
87
+ @abc.abstractmethod
88
+ def evidence(self, selected_text: str) -> dict[str, Any]:
89
+ """Produce canonical evidence dict for *selected_text*."""
90
+
91
+ @abc.abstractmethod
92
+ def locate(self, source_text: str, start: int, end: int) -> dict[str, Any]:
93
+ """Produce an unambiguous locator from *start*/*end* scalar offsets."""
94
+
95
+ @abc.abstractmethod
96
+ def observe(self, repo: Repo, artifact: dict[str, Any], locator: dict[str, Any]) -> dict[str, Any]:
97
+ """Read current artifact content at *locator* without mutation.
98
+
99
+ Returns an evidence dict for the currently observed selection.
100
+ """
101
+
102
+ @abc.abstractmethod
103
+ def compare(self, accepted_evidence: dict[str, Any], observed_evidence: dict[str, Any]) -> str:
104
+ """Return 'resolved' or 'changed'. 'missing' and 'unsupported' are
105
+ determined externally by core.observe_status()."""
106
+
107
+ @abc.abstractmethod
108
+ def summarize(self, locator: dict[str, Any]) -> str:
109
+ """Return a metadata-safe human-readable range summary."""
110
+
111
+ @abc.abstractmethod
112
+ def privacy(self, citation: dict[str, Any], mode: str, policy: dict[str, Any]) -> None:
113
+ """Mutate *citation* in-place to comply with *mode*/*policy*.
114
+
115
+ The default implementation delegates to the core apply_privacy,
116
+ which is adapter-agnostic. Adapters that need specialised
117
+ redaction (e.g. stripping structured fields) override this.
118
+ """
119
+
120
+ # ------------------------------------------------------------------
121
+ # Derived helpers (not part of the conformance surface)
122
+ # ------------------------------------------------------------------
123
+
124
+ def citation_id_for(self, artifact: AdapterArtifact, locator: dict[str, Any], evidence: dict[str, Any]) -> str:
125
+ return sha256_json(
126
+ {
127
+ "kind": "c2s.citation",
128
+ "artifact": {"adapter": artifact.adapter, "uri": artifact.uri, "artifact_id": artifact.artifact_id},
129
+ "locator": locator,
130
+ "accepted_evidence_hash": evidence["content_hash"],
131
+ }
132
+ )
133
+
134
+ def validate_selection(self, source_text: str, start: int, end: int) -> str:
135
+ """Return the selected text, or raise a structured diagnostic."""
136
+ if start < 0 or end < start or end > len(source_text):
137
+ raise C2SError(
138
+ "E_ADAPTER_RANGE_INVALID",
139
+ _ADAPTER_DIAGNOSTICS["E_ADAPTER_RANGE_INVALID"],
140
+ start=start,
141
+ end=end,
142
+ length=len(source_text),
143
+ )
144
+ selected = source_text[start:end]
145
+ if selected == "":
146
+ raise C2SError(
147
+ "E_ADAPTER_EMPTY_SELECTION",
148
+ _ADAPTER_DIAGNOSTICS["E_ADAPTER_EMPTY_SELECTION"],
149
+ start=start,
150
+ end=end,
151
+ )
152
+ return selected
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Filesystem text adapter — the canonical plain-text adapter
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ class FilesystemTextAdapter(BaseAdapter):
161
+ """Plain-text filesystem adapter (UTF-8, LF-normalised)."""
162
+
163
+ name = "filesystem-text"
164
+
165
+ # -- contract -------------------------------------------------------
166
+
167
+ def identify(self, repo: Repo, uri: str) -> AdapterArtifact:
168
+ path = resolve_artifact_path(repo, uri)
169
+ identity = {"adapter": self.name, "uri": normalize_uri(uri), "path": str(path)}
170
+ return AdapterArtifact(adapter=self.name, uri=normalize_uri(uri), artifact_id=sha256_json(identity))
171
+
172
+ def canonicalize(self, repo: Repo, uri: str) -> str:
173
+ path = resolve_artifact_path(repo, uri)
174
+ try:
175
+ raw = path.read_text(encoding="utf-8")
176
+ except FileNotFoundError as exc:
177
+ raise C2SError(
178
+ "E_ADAPTER_MISSING",
179
+ _ADAPTER_DIAGNOSTICS["E_ADAPTER_MISSING"],
180
+ artifact=uri,
181
+ ) from exc
182
+ except UnicodeDecodeError as exc:
183
+ raise C2SError(
184
+ "E_ADAPTER_UTF8",
185
+ _ADAPTER_DIAGNOSTICS["E_ADAPTER_UTF8"],
186
+ artifact=uri,
187
+ ) from exc
188
+ return canonical_text(raw)
189
+
190
+ def evidence(self, selected_text: str) -> dict[str, Any]:
191
+ return evidence_for_text(selected_text)
192
+
193
+ def locate(self, source_text: str, start: int, end: int) -> dict[str, Any]:
194
+ selected = self.validate_selection(source_text, start, end)
195
+ return make_locator(source_text, start, end)
196
+
197
+ def observe(self, repo: Repo, artifact: dict[str, Any], locator: dict[str, Any]) -> dict[str, Any]:
198
+ uri = str(artifact["uri"])
199
+ source = self.canonicalize(repo, uri)
200
+ start, end = int(locator["start"]), int(locator["end"])
201
+ if end > len(source):
202
+ raise C2SError(
203
+ "E_ADAPTER_RANGE_INVALID",
204
+ _ADAPTER_DIAGNOSTICS["E_ADAPTER_RANGE_INVALID"],
205
+ artifact=uri,
206
+ start=start,
207
+ end=end,
208
+ length=len(source),
209
+ )
210
+ selected = source[start:end]
211
+ if selected == "":
212
+ raise C2SError(
213
+ "E_ADAPTER_EMPTY_SELECTION",
214
+ _ADAPTER_DIAGNOSTICS["E_ADAPTER_EMPTY_SELECTION"],
215
+ artifact=uri,
216
+ start=start,
217
+ end=end,
218
+ )
219
+ return self.evidence(selected)
220
+
221
+ def compare(self, accepted_evidence: dict[str, Any], observed_evidence: dict[str, Any]) -> str:
222
+ if accepted_evidence["content_hash"] == observed_evidence["content_hash"]:
223
+ return "resolved"
224
+ return "changed"
225
+
226
+ def summarize(self, locator: dict[str, Any]) -> str:
227
+ if "start_line" in locator and "end_line" in locator:
228
+ return f"lines {locator['start_line']}-{locator['end_line']}"
229
+ return f"{locator['start']}-{locator['end']}"
230
+
231
+ def privacy(self, citation: dict[str, Any], mode: str, policy: dict[str, Any]) -> None:
232
+ # Delegate to core.apply_privacy — adapter-agnostic transform.
233
+ from .core import apply_privacy as _apply_privacy
234
+
235
+ _apply_privacy(citation, mode, policy)
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Markdown adapter — text semantics with explicit Markdown awareness marker
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ class MarkdownAdapter(FilesystemTextAdapter):
244
+ """Markdown adapter using text-line semantics.
245
+
246
+ Block-aware locators, summaries, and observation are not yet
247
+ implemented. This adapter intentionally inherits the plain-text
248
+ contract until the block-level spec and test suite are delivered.
249
+ """
250
+
251
+ name = "markdown"
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Converter adapter — shells out to external format converters
256
+ # ---------------------------------------------------------------------------
257
+ #
258
+ # One generic adapter, configured per file extension, replaces three
259
+ # separate per-format adapters (DOCX, PDF, XLSX). canonicalize() shells
260
+ # out to a registered converter and treats stdout as canonical text;
261
+ # evidence() / locate() / compare() reuse the filesystem-text code path.
262
+ # identify() records converter name + version so a converter upgrade that
263
+ # changes output is detectable, not a silent replay failure.
264
+
265
+
266
+ class ConverterAdapter(FilesystemTextAdapter):
267
+ """Generic adapter backed by an external format converter.
268
+
269
+ Configured with a *name*, a list of *extensions* (e.g. ``[".docx"]``),
270
+ a *convert_cmd* list where ``{path}`` is replaced with the artifact
271
+ path, and a *version_cmd* list that prints the converter version.
272
+ """
273
+
274
+ name: str
275
+ extensions: tuple[str, ...]
276
+ convert_cmd: list[str]
277
+ version_cmd: list[str]
278
+
279
+ def __init__(
280
+ self,
281
+ name: str,
282
+ extensions: tuple[str, ...],
283
+ convert_cmd: list[str],
284
+ version_cmd: list[str],
285
+ ):
286
+ self.name = name
287
+ self.extensions = extensions
288
+ self.convert_cmd = convert_cmd
289
+ self.version_cmd = version_cmd
290
+ self._converter_version: str | None = None
291
+
292
+ def _detect_version(self) -> str:
293
+ """Run version_cmd and return stripped stdout, caching the result."""
294
+ if self._converter_version is None:
295
+ import subprocess
296
+ try:
297
+ r = subprocess.run(self.version_cmd, capture_output=True, text=True, timeout=10)
298
+ self._converter_version = r.stdout.strip() or r.stderr.strip() or "unknown"
299
+ except Exception:
300
+ self._converter_version = "unknown"
301
+ return self._converter_version
302
+
303
+ # -- contract overrides --------------------------------------------
304
+
305
+ def identify(self, repo: Repo, uri: str) -> AdapterArtifact:
306
+ path = resolve_artifact_path(repo, uri)
307
+ version = self._detect_version()
308
+ identity = {
309
+ "adapter": self.name,
310
+ "uri": normalize_uri(uri),
311
+ "path": str(path),
312
+ "converter": self.name,
313
+ "converter_version": version,
314
+ }
315
+ return AdapterArtifact(
316
+ adapter=self.name,
317
+ uri=normalize_uri(uri),
318
+ artifact_id=sha256_json(identity),
319
+ )
320
+
321
+ def canonicalize(self, repo: Repo, uri: str) -> str:
322
+ path = resolve_artifact_path(repo, uri)
323
+ if not path.exists():
324
+ raise C2SError(
325
+ "E_ADAPTER_MISSING",
326
+ _ADAPTER_DIAGNOSTICS["E_ADAPTER_MISSING"],
327
+ artifact=uri,
328
+ )
329
+ import subprocess
330
+ cmd = [str(path) if arg == "{path}" else arg for arg in self.convert_cmd]
331
+ try:
332
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
333
+ except FileNotFoundError as exc:
334
+ raise C2SError(
335
+ "E_ADAPTER_UNSUPPORTED",
336
+ f"converter '{self.convert_cmd[0]}' not found on PATH",
337
+ adapter=self.name,
338
+ ) from exc
339
+ except subprocess.TimeoutExpired as exc:
340
+ raise C2SError(
341
+ "E_ADAPTER_UNAVAILABLE",
342
+ f"converter '{self.convert_cmd[0]}' timed out",
343
+ adapter=self.name,
344
+ ) from exc
345
+ if r.returncode != 0:
346
+ raise C2SError(
347
+ "E_ADAPTER_UTF8",
348
+ f"converter '{self.convert_cmd[0]}' failed: {r.stderr.strip() or 'exit ' + str(r.returncode)}",
349
+ adapter=self.name,
350
+ )
351
+ # Normalize line endings, same as filesystem-text
352
+ return canonical_text(r.stdout)
353
+
354
+ # evidence(), locate(), observe(), compare(), summarize(), privacy()
355
+ # are all inherited from FilesystemTextAdapter.
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Adapter registry
360
+ # ---------------------------------------------------------------------------
361
+
362
+
363
+ _CONVERTER_ADAPTERS: dict[str, BaseAdapter] = {}
364
+
365
+ # Register pandoc adapter for .docx files (optional — converter must be on PATH)
366
+ try:
367
+ _CONVERTER_ADAPTERS["pandoc"] = ConverterAdapter(
368
+ name="pandoc",
369
+ extensions=(".docx",),
370
+ convert_cmd=["pandoc", "-f", "docx", "-t", "plain", "--wrap=none", "{path}"],
371
+ version_cmd=["pandoc", "--version"],
372
+ )
373
+ except Exception:
374
+ pass
375
+
376
+ # Register pdftotext adapter for .pdf files (optional — converter must be on PATH)
377
+ try:
378
+ _CONVERTER_ADAPTERS["pdftotext"] = ConverterAdapter(
379
+ name="pdftotext",
380
+ extensions=(".pdf",),
381
+ convert_cmd=["pdftotext", "-layout", "{path}", "-"],
382
+ version_cmd=["pdftotext", "-v"],
383
+ )
384
+ except Exception:
385
+ pass
386
+
387
+ # Register passthrough text-converter for testing (uses OS cat/type equivalent)
388
+ _cat_cmd = ["python", "-c", "import sys; sys.stdout.write(open(sys.argv[1]).read())"]
389
+ _cat_ver = ["python", "--version"]
390
+ _CONVERTER_ADAPTERS["text-converter"] = ConverterAdapter(
391
+ name="text-converter",
392
+ extensions=(".txt", ".csv"),
393
+ convert_cmd=_cat_cmd + ["{path}"],
394
+ version_cmd=_cat_ver,
395
+ )
396
+
397
+ _SUPPORTED: dict[str, BaseAdapter] = {
398
+ "filesystem-text": FilesystemTextAdapter(),
399
+ "markdown": MarkdownAdapter(),
400
+ **_CONVERTER_ADAPTERS,
401
+ }
402
+
403
+
404
+ def get_adapter(name: str) -> BaseAdapter:
405
+ """Return the adapter instance for *name*.
406
+
407
+ Raises E_ADAPTER_UNSUPPORTED when *name* is not a supported adapter.
408
+ """
409
+ if name not in _SUPPORTED:
410
+ raise C2SError(
411
+ "E_ADAPTER_UNSUPPORTED",
412
+ _ADAPTER_DIAGNOSTICS["E_ADAPTER_UNSUPPORTED"],
413
+ adapter=name,
414
+ supported=sorted(_SUPPORTED.keys()),
415
+ )
416
+ return _SUPPORTED[name]
417
+
418
+
419
+ def adapter_for_uri(uri: str, requested: str | None = None) -> str:
420
+ """Resolve the adapter name for *uri*, optionally overriding with *requested*."""
421
+ if requested:
422
+ get_adapter(requested) # validates
423
+ return requested
424
+ lower = uri.lower()
425
+ if lower.endswith((".md", ".markdown")):
426
+ return "markdown"
427
+ if lower.endswith(".docx") and "pandoc" in _SUPPORTED:
428
+ return "pandoc"
429
+ if lower.endswith(".pdf") and "pdftotext" in _SUPPORTED:
430
+ return "pdftotext"
431
+ return "filesystem-text"
432
+
433
+
434
+ def adapter_diagnostics() -> dict[str, str]:
435
+ """Return the stable adapter diagnostic code→message map (for docs/tests)."""
436
+ return dict(_ADAPTER_DIAGNOSTICS)
c2s/cli.py ADDED
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from typing import Any, Callable
7
+
8
+ from . import core
9
+ from .adapter import _SUPPORTED as _ADAPTER_CHOICES # noqa: F811 — registry
10
+
11
+ _ADAPTER_NAMES = sorted(_ADAPTER_CHOICES.keys())
12
+
13
+
14
+ class JsonArgumentParser(argparse.ArgumentParser):
15
+ def error(self, message: str) -> None:
16
+ raise core.C2SError("E_USAGE", message)
17
+
18
+
19
+ def emit(value: dict[str, Any]) -> int:
20
+ print(json.dumps(value, indent=2, sort_keys=True))
21
+ return 0 if value.get("ok", False) else 1
22
+
23
+
24
+ def emit_jsonl(value: dict[str, Any]) -> int:
25
+ for citation in value["citations"]:
26
+ print(json.dumps(citation, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
27
+ return 0
28
+
29
+
30
+ def main(argv: list[str] | None = None) -> int:
31
+ parser = JsonArgumentParser(prog="c2s")
32
+ parser.add_argument("--repo", default=".c2s", help="citation repository path")
33
+ sub = parser.add_subparsers(dest="command", required=True, parser_class=JsonArgumentParser)
34
+
35
+ p_init = sub.add_parser("init")
36
+ p_init.add_argument("--force", action="store_true")
37
+ p_init.set_defaults(func=lambda args: core.init_repo(core.repo_from_arg(args.repo), force=args.force))
38
+
39
+ def add_actor(p: argparse.ArgumentParser) -> None:
40
+ p.add_argument("--actor-kind", default="user", choices=["user", "agent", "machine"])
41
+ p.add_argument("--actor-id")
42
+
43
+ p_cite = sub.add_parser("cite-selection")
44
+ p_cite.add_argument("--artifact", required=True)
45
+ p_cite.add_argument("--adapter", choices=_ADAPTER_NAMES)
46
+ p_cite.add_argument("--start", required=True, type=int)
47
+ p_cite.add_argument("--end", required=True, type=int)
48
+ p_cite.add_argument("--expected-content-hash")
49
+ p_cite.add_argument("--handle")
50
+ p_cite.add_argument("--label")
51
+ p_cite.add_argument("--tag", action="append", default=[])
52
+ p_cite.add_argument("--note")
53
+ p_cite.add_argument("--handle-from-first-line", action="store_true")
54
+ add_actor(p_cite)
55
+ p_cite.set_defaults(func=core.cite_selection)
56
+
57
+ p_preflight = sub.add_parser("preflight-selection")
58
+ p_preflight.add_argument("--artifact", required=True)
59
+ p_preflight.add_argument("--adapter", choices=_ADAPTER_NAMES)
60
+ p_preflight.add_argument("--start", required=True, type=int)
61
+ p_preflight.add_argument("--end", required=True, type=int)
62
+ p_preflight.add_argument("--expected-content-hash")
63
+ p_preflight.add_argument("--handle")
64
+ p_preflight.add_argument("--label")
65
+ p_preflight.add_argument("--tag", action="append", default=[])
66
+ p_preflight.add_argument("--note")
67
+ p_preflight.add_argument("--handle-from-first-line", action="store_true")
68
+ p_preflight.set_defaults(func=core.preflight_selection)
69
+
70
+ p_batch = sub.add_parser("cite-batch")
71
+ p_batch.add_argument("--request", required=True)
72
+ add_actor(p_batch)
73
+ p_batch.set_defaults(func=core.cite_batch)
74
+
75
+ p_handle = sub.add_parser("set-handle")
76
+ p_handle.add_argument("--citation-id", required=True)
77
+ p_handle.add_argument("--handle", required=True)
78
+ p_handle.add_argument("--action", default="bind", choices=["bind", "rename", "alias", "retire"])
79
+ p_handle.add_argument("--previous-handle")
80
+ add_actor(p_handle)
81
+ p_handle.set_defaults(func=core.set_handle)
82
+
83
+ p_accept = sub.add_parser("accept-current")
84
+ p_accept.add_argument("--citation-id", required=True)
85
+ p_accept.add_argument("--expected-content-hash")
86
+ add_actor(p_accept)
87
+ p_accept.set_defaults(func=core.accept_current)
88
+
89
+ for command, func in [("retract", core.retract), ("restore", core.restore)]:
90
+ action = sub.add_parser(command)
91
+ action.add_argument("--citation-id", required=True)
92
+ add_actor(action)
93
+ action.set_defaults(func=func)
94
+
95
+ p_relocate = sub.add_parser("relocate")
96
+ p_relocate.add_argument("--citation-id", required=True)
97
+ p_relocate.add_argument("--artifact")
98
+ p_relocate.add_argument("--adapter", choices=_ADAPTER_NAMES)
99
+ p_relocate.add_argument("--start", required=True, type=int)
100
+ p_relocate.add_argument("--end", required=True, type=int)
101
+ p_relocate.add_argument("--expected-content-hash")
102
+ add_actor(p_relocate)
103
+ p_relocate.set_defaults(func=core.relocate)
104
+
105
+ p_note = sub.add_parser("note")
106
+ p_note.add_argument("--citation-id", required=True)
107
+ p_note.add_argument("--note", required=True)
108
+ add_actor(p_note)
109
+ p_note.set_defaults(func=core.note)
110
+
111
+ p_lookup = sub.add_parser("lookup-actions")
112
+ p_lookup.add_argument("--artifact", required=True)
113
+ p_lookup.add_argument("--start", required=True, type=int)
114
+ p_lookup.add_argument("--end", required=True, type=int)
115
+ p_lookup.add_argument("--privacy", default=None, choices=sorted(core.PRIVACY_MODES))
116
+ p_lookup.set_defaults(func=core.lookup_actions)
117
+
118
+ p_status = sub.add_parser("status")
119
+ p_status.add_argument("--privacy", default=None, choices=sorted(core.PRIVACY_MODES))
120
+ p_status.set_defaults(func=core.status)
121
+
122
+ p_citations = sub.add_parser("citations")
123
+ p_citations.add_argument("--artifact")
124
+ p_citations.add_argument("--handle")
125
+ p_citations.add_argument("--tag")
126
+ p_citations.add_argument("--status", choices=sorted(core.VALID_STATUSES))
127
+ p_citations.add_argument("--batch")
128
+ p_citations.add_argument("--privacy", default=None, choices=sorted(core.PRIVACY_MODES))
129
+ p_citations.add_argument("--format", default="json", choices=["json", "jsonl"])
130
+ p_citations.set_defaults(func=core.citations)
131
+
132
+ p_export = sub.add_parser("export")
133
+ p_export.add_argument("--privacy", default=None, choices=sorted(core.PRIVACY_MODES))
134
+ p_export.set_defaults(func=core.export)
135
+
136
+ p_check = sub.add_parser("check")
137
+ p_check.set_defaults(func=core.check)
138
+
139
+ # -- context-menu integration --
140
+ def _install_context_menu(_args: argparse.Namespace) -> dict[str, Any]:
141
+ from ._context_menu import install
142
+ install()
143
+ return {"ok": True}
144
+
145
+ def _uninstall_context_menu(_args: argparse.Namespace) -> dict[str, Any]:
146
+ from ._context_menu import uninstall
147
+ uninstall()
148
+ return {"ok": True}
149
+
150
+ p_cm_install = sub.add_parser("install-context-menu")
151
+ p_cm_install.set_defaults(func=_install_context_menu)
152
+
153
+ p_cm_uninstall = sub.add_parser("uninstall-context-menu")
154
+ p_cm_uninstall.set_defaults(func=_uninstall_context_menu)
155
+
156
+ try:
157
+ args = parser.parse_args(argv)
158
+ func: Callable[[argparse.Namespace], dict[str, Any]] = args.func
159
+ value = func(args)
160
+ return emit_jsonl(value) if args.command == "citations" and args.format == "jsonl" else emit(value)
161
+ except core.C2SError as exc:
162
+ print(json.dumps(exc.to_json(), indent=2, sort_keys=True), file=sys.stderr)
163
+ return 1
164
+
165
+
166
+ if __name__ == "__main__":
167
+ raise SystemExit(main())