claude-comms 0.2.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.
Files changed (55) hide show
  1. claude_comms/__init__.py +3 -0
  2. claude_comms/__main__.py +6 -0
  3. claude_comms/artifact.py +342 -0
  4. claude_comms/broker.py +679 -0
  5. claude_comms/cli.py +1863 -0
  6. claude_comms/config.py +255 -0
  7. claude_comms/conversation.py +346 -0
  8. claude_comms/hook_installer.py +401 -0
  9. claude_comms/log_exporter.py +467 -0
  10. claude_comms/mcp_server.py +1349 -0
  11. claude_comms/mcp_tools.py +2123 -0
  12. claude_comms/mention.py +95 -0
  13. claude_comms/message.py +230 -0
  14. claude_comms/notification_hook.cmd +37 -0
  15. claude_comms/notification_hook.sh +51 -0
  16. claude_comms/participant.py +184 -0
  17. claude_comms/presence.py +284 -0
  18. claude_comms/reactions.py +425 -0
  19. claude_comms/tui/__init__.py +17 -0
  20. claude_comms/tui/app.py +936 -0
  21. claude_comms/tui/channel_list.py +190 -0
  22. claude_comms/tui/chat_view.py +672 -0
  23. claude_comms/tui/message_input.py +167 -0
  24. claude_comms/tui/participant_list.py +232 -0
  25. claude_comms/tui/status_bar.py +85 -0
  26. claude_comms/tui/styles.tcss +244 -0
  27. claude_comms/web/dist/assets/bash-CWGUrYGm.js +2 -0
  28. claude_comms/web/dist/assets/bash-CWGUrYGm.js.map +1 -0
  29. claude_comms/web/dist/assets/index--L2RjrKe.css +1 -0
  30. claude_comms/web/dist/assets/index-CuZtx16o.js +24 -0
  31. claude_comms/web/dist/assets/index-CuZtx16o.js.map +1 -0
  32. claude_comms/web/dist/assets/javascript-ySlJ1b_l.js +2 -0
  33. claude_comms/web/dist/assets/javascript-ySlJ1b_l.js.map +1 -0
  34. claude_comms/web/dist/assets/json-DTAJTTim.js +2 -0
  35. claude_comms/web/dist/assets/json-DTAJTTim.js.map +1 -0
  36. claude_comms/web/dist/assets/python-DBPt_AfP.js +2 -0
  37. claude_comms/web/dist/assets/python-DBPt_AfP.js.map +1 -0
  38. claude_comms/web/dist/assets/typescript-Dj6nwHGl.js +2 -0
  39. claude_comms/web/dist/assets/typescript-Dj6nwHGl.js.map +1 -0
  40. claude_comms/web/dist/assets/vendor-diff-jvjshTi7.js +7 -0
  41. claude_comms/web/dist/assets/vendor-diff-jvjshTi7.js.map +1 -0
  42. claude_comms/web/dist/assets/vendor-markdown-CQd6Ih9h.js +217 -0
  43. claude_comms/web/dist/assets/vendor-markdown-CQd6Ih9h.js.map +1 -0
  44. claude_comms/web/dist/assets/vendor-mqtt-CmTbKJ4B.js +19 -0
  45. claude_comms/web/dist/assets/vendor-mqtt-CmTbKJ4B.js.map +1 -0
  46. claude_comms/web/dist/assets/vendor-ui-Bf00hZLj.js +2642 -0
  47. claude_comms/web/dist/assets/vendor-ui-Bf00hZLj.js.map +1 -0
  48. claude_comms/web/dist/assets/vendor-ui-BpcL6yKj.css +1 -0
  49. claude_comms/web/dist/index.html +21 -0
  50. claude_comms/working_indicator.py +127 -0
  51. claude_comms-0.2.0.dist-info/METADATA +1258 -0
  52. claude_comms-0.2.0.dist-info/RECORD +55 -0
  53. claude_comms-0.2.0.dist-info/WHEEL +4 -0
  54. claude_comms-0.2.0.dist-info/entry_points.txt +2 -0
  55. claude_comms-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Claude Comms — Distributed inter-Claude messaging platform."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m claude_comms`."""
2
+
3
+ from claude_comms.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,342 @@
1
+ """Artifact storage — Pydantic models and file I/O for collaborative documents.
2
+
3
+ Artifacts are versioned documents (plans, docs, code) that users and Claude
4
+ agents can create, edit, and share within conversations. Each artifact is
5
+ persisted as a single JSON file under ``{data_dir}/{conversation}/{name}.json``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import unicodedata
15
+ import uuid
16
+ from pathlib import Path
17
+ from typing import Any, Literal
18
+
19
+ from pydantic import BaseModel, Field, field_validator
20
+
21
+ from claude_comms.message import Sender, now_iso
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Constants
27
+ # ---------------------------------------------------------------------------
28
+
29
+ # Reject: NUL + control chars, plus Windows-forbidden chars: < > : " / \ | ? *
30
+ # Also reject: backtick (shell quoting hazard), newline, tab (whitespace confusion)
31
+ ARTIFACT_NAME_FORBIDDEN = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*`\n\r\t]')
32
+
33
+ WINDOWS_RESERVED = frozenset(
34
+ {
35
+ "CON",
36
+ "PRN",
37
+ "AUX",
38
+ "NUL",
39
+ *(f"COM{i}" for i in range(1, 10)),
40
+ *(f"LPT{i}" for i in range(1, 10)),
41
+ }
42
+ )
43
+
44
+ DEFAULT_GET_CHUNK_SIZE = 50_000
45
+ MAX_VERSIONS = 50
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Normalization
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ def _normalize_name(name: str) -> str:
53
+ """Canonical name form used for on-disk paths and in-memory identity.
54
+
55
+ R5-3: NFC normalization must be applied at every user-name → filesystem-name
56
+ boundary so e.g. ``café`` (NFC) and ``café`` (NFD) can't coexist as two
57
+ "different" artifacts.
58
+ """
59
+ return unicodedata.normalize("NFC", name)
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Validation
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def validate_artifact_name(name: str) -> tuple[bool, str]:
68
+ """Return ``(is_valid, error_message)`` for an artifact name.
69
+
70
+ Empty error means valid.
71
+
72
+ Windows-filesystem-compatible permissive naming: allows spaces, Unicode,
73
+ most punctuation. Only forbids characters Windows itself rejects plus a
74
+ small set of structural / safety rules.
75
+
76
+ R4-7 hardening: NFC-normalize to eliminate macOS HFS+ NFD collisions;
77
+ reject ``.json`` suffix (on-disk name collision risk); reject fullwidth
78
+ confusables (e.g. U+FF0F fullwidth slash).
79
+ """
80
+ if not name:
81
+ return False, "name cannot be empty"
82
+
83
+ # R4-7: Normalize to NFC first. Store and compare the normalized form so
84
+ # e.g. `café` (NFC) and `café` (NFD) can't coexist as "different" artifacts.
85
+ name = _normalize_name(name)
86
+
87
+ if len(name) > 128:
88
+ return False, "name exceeds 128 characters"
89
+
90
+ if ARTIFACT_NAME_FORBIDDEN.search(name):
91
+ return (
92
+ False,
93
+ 'name contains a forbidden character (< > : " / \\ | ? * or control char)',
94
+ )
95
+
96
+ # R4-7: Reject confusable fullwidth chars (U+FF00–U+FFEF) — they render
97
+ # indistinguishably from ASCII in many fonts and are a phishing vector.
98
+ if any(0xFF00 <= ord(c) <= 0xFFEF for c in name):
99
+ return False, "name contains confusable fullwidth characters"
100
+
101
+ # Leading space or dot is confusing and some filesystems reject it.
102
+ if name.startswith(" ") or name.startswith("."):
103
+ return False, "name cannot start with a space or dot"
104
+
105
+ # Windows silently strips trailing dot / space → file collision risk.
106
+ # Also reject trailing hyphen/underscore — visually ambiguous with
107
+ # accidental concatenation.
108
+ if (
109
+ name.endswith(".")
110
+ or name.endswith(" ")
111
+ or name.endswith("-")
112
+ or name.endswith("_")
113
+ ):
114
+ return False, "name cannot end with a dot, space, hyphen, or underscore"
115
+
116
+ if ".." in name:
117
+ return False, "name cannot contain '..'"
118
+
119
+ # R4-7: Reject `.json` suffix to prevent `foo.json` input producing
120
+ # on-disk `foo.json.json` and future collision with a user creating `foo.json.json`.
121
+ if name.lower().endswith(".json"):
122
+ return False, "name cannot end with '.json' (reserved by storage format)"
123
+
124
+ # Windows reserves the stem (part before first dot). E.g. CON.txt collides with CON.
125
+ stem = name.split(".", 1)[0].upper()
126
+ if stem in WINDOWS_RESERVED:
127
+ return False, f"name {name!r} conflicts with Windows reserved device name"
128
+
129
+ return True, ""
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Models
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ class ArtifactVersion(BaseModel):
138
+ """A single immutable snapshot of an artifact's content."""
139
+
140
+ version: int = Field(
141
+ ..., ge=1, description="Monotonically increasing version number"
142
+ )
143
+ content: str = Field(..., description="Full document content at this version")
144
+ author: Sender = Field(..., description="Who created this version")
145
+ timestamp: str = Field(
146
+ default_factory=now_iso,
147
+ description="ISO 8601 timestamp with timezone",
148
+ )
149
+ summary: str = Field(default="", description="Human-readable change summary")
150
+
151
+
152
+ class Artifact(BaseModel):
153
+ """A versioned collaborative document within a conversation."""
154
+
155
+ id: str = Field(
156
+ default_factory=lambda: str(uuid.uuid4()),
157
+ description="Unique artifact UUID",
158
+ )
159
+ name: str = Field(..., description="Filesystem-safe name used as filename stem")
160
+ title: str = Field(..., min_length=1, description="Human-readable title")
161
+ type: Literal["plan", "doc", "code"] = Field(..., description="Artifact category")
162
+ conversation_id: str = Field(..., description="Owning conversation ID")
163
+ created_by: Sender = Field(..., description="Original author")
164
+ created_at: str = Field(
165
+ default_factory=now_iso,
166
+ description="ISO 8601 creation timestamp",
167
+ )
168
+ versions: list[ArtifactVersion] = Field(
169
+ default_factory=list,
170
+ description="Version history, newest last",
171
+ )
172
+
173
+ @field_validator("name")
174
+ @classmethod
175
+ def _enforce_nfc(cls, v: str) -> str:
176
+ """R5-3: enforce NFC on the model so construction and JSON
177
+ deserialization always produce a canonical identity string."""
178
+ return _normalize_name(v)
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # File I/O
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ def _artifact_path(conversation: str, name: str, data_dir: Path) -> Path:
187
+ """Return the on-disk path for an artifact (NFC-normalized)."""
188
+ return data_dir / conversation / f"{_normalize_name(name)}.json"
189
+
190
+
191
+ def save_artifact(artifact: Artifact, data_dir: Path) -> None:
192
+ """Persist *artifact* to disk with an atomic rename.
193
+
194
+ Creates the conversation directory if it does not exist. If the
195
+ version list exceeds ``MAX_VERSIONS``, the oldest entries are
196
+ discarded before writing.
197
+ """
198
+ # Prune old versions
199
+ if len(artifact.versions) > MAX_VERSIONS:
200
+ artifact.versions = artifact.versions[-MAX_VERSIONS:]
201
+
202
+ conv_dir = data_dir / artifact.conversation_id
203
+ conv_dir.mkdir(parents=True, exist_ok=True)
204
+
205
+ # `artifact.name` is already NFC via the field_validator, but be defensive.
206
+ stem = _normalize_name(artifact.name)
207
+ target = conv_dir / f"{stem}.json"
208
+ tmp = conv_dir / f"{stem}.json.tmp"
209
+
210
+ tmp.write_text(artifact.model_dump_json(indent=2), encoding="utf-8")
211
+ os.rename(tmp, target)
212
+
213
+
214
+ def load_artifact(conversation: str, name: str, data_dir: Path) -> Artifact | None:
215
+ """Load an artifact from disk, or return ``None`` if not found.
216
+
217
+ Returns ``None`` for missing files or malformed JSON.
218
+ """
219
+ name = _normalize_name(name)
220
+ path = _artifact_path(conversation, name, data_dir)
221
+ if not path.is_file():
222
+ return None
223
+ try:
224
+ raw = path.read_text(encoding="utf-8")
225
+ return Artifact.model_validate_json(raw)
226
+ except (json.JSONDecodeError, ValueError) as exc:
227
+ logger.warning("Failed to load artifact %s: %s", path, exc)
228
+ return None
229
+
230
+
231
+ def list_artifacts(conversation: str, data_dir: Path) -> list[dict[str, Any]]:
232
+ """Return summary metadata for every artifact in a conversation.
233
+
234
+ Each entry contains ``name``, ``title``, ``type``, ``version_count``,
235
+ and the latest version's ``author``, ``timestamp``, and ``summary``.
236
+ Content is deliberately excluded to keep the response lightweight.
237
+
238
+ Returns an empty list if the conversation directory does not exist.
239
+ """
240
+ conv_dir = data_dir / conversation
241
+ if not conv_dir.is_dir():
242
+ return []
243
+
244
+ results: list[dict[str, Any]] = []
245
+ for json_file in sorted(conv_dir.glob("*.json")):
246
+ try:
247
+ raw = json_file.read_text(encoding="utf-8")
248
+ artifact = Artifact.model_validate_json(raw)
249
+ except (json.JSONDecodeError, ValueError) as exc:
250
+ logger.warning("Skipping malformed artifact %s: %s", json_file, exc)
251
+ continue
252
+
253
+ entry: dict[str, Any] = {
254
+ "name": artifact.name,
255
+ "title": artifact.title,
256
+ "type": artifact.type,
257
+ "version_count": len(artifact.versions),
258
+ }
259
+ if artifact.versions:
260
+ latest = artifact.versions[-1]
261
+ entry["author"] = latest.author.model_dump()
262
+ entry["timestamp"] = latest.timestamp
263
+ entry["summary"] = latest.summary
264
+ results.append(entry)
265
+
266
+ return results
267
+
268
+
269
+ def delete_artifact(conversation: str, name: str, data_dir: Path) -> bool:
270
+ """Remove an artifact's JSON file from disk.
271
+
272
+ Returns ``True`` if the file was deleted, ``False`` if it did not exist.
273
+ """
274
+ name = _normalize_name(name)
275
+ path = _artifact_path(conversation, name, data_dir)
276
+ try:
277
+ path.unlink()
278
+ return True
279
+ except FileNotFoundError:
280
+ return False
281
+ except OSError as exc:
282
+ logger.warning("Failed to delete artifact %s: %s", path, exc)
283
+ return False
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # NFC migration (one-time at startup)
288
+ # ---------------------------------------------------------------------------
289
+
290
+
291
+ def migrate_artifact_names_to_nfc(data_dir: Path) -> tuple[int, int]:
292
+ """Rename any NFD artifact files to NFC form. Idempotent.
293
+
294
+ Collisions are QUARANTINED, not left in place (R6-2 fix) — otherwise
295
+ the collision produces two in-memory Artifact records with the same
296
+ NFC name, breaking identity downstream.
297
+
298
+ Returns ``(renamed_count, quarantined_count)``. Runs at daemon startup.
299
+ """
300
+ if not data_dir.is_dir():
301
+ return 0, 0
302
+
303
+ renamed = 0
304
+ quarantined = 0
305
+ quarantine_root = data_dir / ".nfc-migration-quarantine"
306
+
307
+ for conv_dir in data_dir.iterdir():
308
+ if not conv_dir.is_dir() or conv_dir.name.startswith("."):
309
+ continue
310
+ for json_file in conv_dir.glob("*.json"):
311
+ stem = json_file.stem
312
+ nfc = unicodedata.normalize("NFC", stem)
313
+ if nfc == stem:
314
+ continue
315
+ target = json_file.with_name(f"{nfc}.json")
316
+ if target.exists():
317
+ # R6-2: quarantine the NFD file — do NOT leave it next to
318
+ # the NFC version. Otherwise list_artifacts() would build
319
+ # two Artifact records that the Pydantic NFC validator
320
+ # then collapses to the same name — split-brain.
321
+ q_dir = quarantine_root / conv_dir.name
322
+ q_dir.mkdir(parents=True, exist_ok=True)
323
+ q_target = q_dir / json_file.name
324
+ json_file.rename(q_target)
325
+ logger.warning(
326
+ "NFC migration: collision on %s; quarantined NFD file to %s",
327
+ target,
328
+ q_target,
329
+ )
330
+ quarantined += 1
331
+ continue
332
+ json_file.rename(target)
333
+ logger.info("NFC migration: renamed %s -> %s", json_file.name, target.name)
334
+ renamed += 1
335
+
336
+ if quarantined > 0:
337
+ logger.warning(
338
+ "NFC migration quarantined %d file(s). Review %s and reconcile manually.",
339
+ quarantined,
340
+ quarantine_root,
341
+ )
342
+ return renamed, quarantined