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.
- claude_comms/__init__.py +3 -0
- claude_comms/__main__.py +6 -0
- claude_comms/artifact.py +342 -0
- claude_comms/broker.py +679 -0
- claude_comms/cli.py +1863 -0
- claude_comms/config.py +255 -0
- claude_comms/conversation.py +346 -0
- claude_comms/hook_installer.py +401 -0
- claude_comms/log_exporter.py +467 -0
- claude_comms/mcp_server.py +1349 -0
- claude_comms/mcp_tools.py +2123 -0
- claude_comms/mention.py +95 -0
- claude_comms/message.py +230 -0
- claude_comms/notification_hook.cmd +37 -0
- claude_comms/notification_hook.sh +51 -0
- claude_comms/participant.py +184 -0
- claude_comms/presence.py +284 -0
- claude_comms/reactions.py +425 -0
- claude_comms/tui/__init__.py +17 -0
- claude_comms/tui/app.py +936 -0
- claude_comms/tui/channel_list.py +190 -0
- claude_comms/tui/chat_view.py +672 -0
- claude_comms/tui/message_input.py +167 -0
- claude_comms/tui/participant_list.py +232 -0
- claude_comms/tui/status_bar.py +85 -0
- claude_comms/tui/styles.tcss +244 -0
- claude_comms/web/dist/assets/bash-CWGUrYGm.js +2 -0
- claude_comms/web/dist/assets/bash-CWGUrYGm.js.map +1 -0
- claude_comms/web/dist/assets/index--L2RjrKe.css +1 -0
- claude_comms/web/dist/assets/index-CuZtx16o.js +24 -0
- claude_comms/web/dist/assets/index-CuZtx16o.js.map +1 -0
- claude_comms/web/dist/assets/javascript-ySlJ1b_l.js +2 -0
- claude_comms/web/dist/assets/javascript-ySlJ1b_l.js.map +1 -0
- claude_comms/web/dist/assets/json-DTAJTTim.js +2 -0
- claude_comms/web/dist/assets/json-DTAJTTim.js.map +1 -0
- claude_comms/web/dist/assets/python-DBPt_AfP.js +2 -0
- claude_comms/web/dist/assets/python-DBPt_AfP.js.map +1 -0
- claude_comms/web/dist/assets/typescript-Dj6nwHGl.js +2 -0
- claude_comms/web/dist/assets/typescript-Dj6nwHGl.js.map +1 -0
- claude_comms/web/dist/assets/vendor-diff-jvjshTi7.js +7 -0
- claude_comms/web/dist/assets/vendor-diff-jvjshTi7.js.map +1 -0
- claude_comms/web/dist/assets/vendor-markdown-CQd6Ih9h.js +217 -0
- claude_comms/web/dist/assets/vendor-markdown-CQd6Ih9h.js.map +1 -0
- claude_comms/web/dist/assets/vendor-mqtt-CmTbKJ4B.js +19 -0
- claude_comms/web/dist/assets/vendor-mqtt-CmTbKJ4B.js.map +1 -0
- claude_comms/web/dist/assets/vendor-ui-Bf00hZLj.js +2642 -0
- claude_comms/web/dist/assets/vendor-ui-Bf00hZLj.js.map +1 -0
- claude_comms/web/dist/assets/vendor-ui-BpcL6yKj.css +1 -0
- claude_comms/web/dist/index.html +21 -0
- claude_comms/working_indicator.py +127 -0
- claude_comms-0.2.0.dist-info/METADATA +1258 -0
- claude_comms-0.2.0.dist-info/RECORD +55 -0
- claude_comms-0.2.0.dist-info/WHEEL +4 -0
- claude_comms-0.2.0.dist-info/entry_points.txt +2 -0
- claude_comms-0.2.0.dist-info/licenses/LICENSE +21 -0
claude_comms/__init__.py
ADDED
claude_comms/__main__.py
ADDED
claude_comms/artifact.py
ADDED
|
@@ -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
|