agentic-comms 0.3.2__tar.gz → 0.4.1__tar.gz
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.
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/PKG-INFO +1 -1
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agent_comms/api.py +30 -1
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agent_comms/cli.py +62 -4
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agent_comms/hook.py +104 -5
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agentic_comms.egg-info/PKG-INFO +1 -1
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/pyproject.toml +1 -1
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/tests/test_cli.py +55 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/README.md +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agent_comms/__init__.py +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agent_comms/__main__.py +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agent_comms/config.py +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agent_comms/install.py +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agentic_comms.egg-info/SOURCES.txt +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agentic_comms.egg-info/dependency_links.txt +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agentic_comms.egg-info/entry_points.txt +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agentic_comms.egg-info/requires.txt +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/agentic_comms.egg-info/top_level.txt +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.1}/setup.cfg +0 -0
|
@@ -30,6 +30,9 @@ class Client:
|
|
|
30
30
|
def heartbeat(self, handle: str):
|
|
31
31
|
return self._check(self._h.post(f"/api/identities/{handle}/heartbeat"))
|
|
32
32
|
|
|
33
|
+
def set_mission(self, handle: str, text: str | None):
|
|
34
|
+
return self._check(self._h.post(f"/api/identities/{handle}/mission", json={"text": text or ""}))
|
|
35
|
+
|
|
33
36
|
def list_identities(self, project: str | None = None):
|
|
34
37
|
params = {"project": project} if project else {}
|
|
35
38
|
return self._check(self._h.get("/api/identities", params=params))
|
|
@@ -39,13 +42,39 @@ class Client:
|
|
|
39
42
|
|
|
40
43
|
def post_message(self, from_handle: str, title: str, summary: str, body: str,
|
|
41
44
|
to_handle: str | None = None, in_reply_to: str | None = None,
|
|
42
|
-
tags: list[str] | None = None):
|
|
45
|
+
tags: list[str] | None = None, attachment_ids: list[str] | None = None):
|
|
43
46
|
payload = {
|
|
44
47
|
"from_handle": from_handle, "title": title, "summary": summary, "body": body,
|
|
45
48
|
"to_handle": to_handle, "in_reply_to": in_reply_to, "tags": tags or [],
|
|
49
|
+
"attachment_ids": attachment_ids or [],
|
|
46
50
|
}
|
|
47
51
|
return self._check(self._h.post("/api/messages", json=payload))
|
|
48
52
|
|
|
53
|
+
def upload_attachment(self, path: str | Path, uploaded_by: str | None = None) -> dict:
|
|
54
|
+
from pathlib import Path as _P
|
|
55
|
+
p = _P(path)
|
|
56
|
+
with p.open("rb") as f:
|
|
57
|
+
files = {"file": (p.name, f, None)}
|
|
58
|
+
data = {"uploaded_by": uploaded_by or ""}
|
|
59
|
+
r = self._h.post("/api/attachments", files=files, data=data)
|
|
60
|
+
return self._check(r)
|
|
61
|
+
|
|
62
|
+
def attachment_meta(self, aid: str) -> dict:
|
|
63
|
+
return self._check(self._h.get(f"/api/attachments/{aid}/meta"))
|
|
64
|
+
|
|
65
|
+
def download_attachment(self, aid: str, out_path: str | Path) -> Path:
|
|
66
|
+
from pathlib import Path as _P
|
|
67
|
+
out = _P(out_path)
|
|
68
|
+
with self._h.stream("GET", f"/api/attachments/{aid}/raw") as r:
|
|
69
|
+
if r.status_code >= 400:
|
|
70
|
+
r.read()
|
|
71
|
+
raise RuntimeError(f"HTTP {r.status_code}: {r.text}")
|
|
72
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
with out.open("wb") as f:
|
|
74
|
+
for chunk in r.iter_bytes():
|
|
75
|
+
f.write(chunk)
|
|
76
|
+
return out
|
|
77
|
+
|
|
49
78
|
def feed(self, limit: int = 10, since_hours: int = 168, project: str | None = None):
|
|
50
79
|
params = {"limit": limit, "since_hours": since_hours}
|
|
51
80
|
if project:
|
|
@@ -297,20 +297,64 @@ def post(
|
|
|
297
297
|
to: Optional[str] = typer.Option(None, "--to", help="Recipient handle. Omit for broadcast feed."),
|
|
298
298
|
reply_to: Optional[str] = typer.Option(None, "--reply-to"),
|
|
299
299
|
tags: Optional[str] = typer.Option(None, "--tags", help="comma-separated"),
|
|
300
|
+
attach: Optional[list[str]] = typer.Option(None, "--attach", help="Path to a file to attach. Repeatable."),
|
|
300
301
|
json_: bool = typer.Option(False, "--json"),
|
|
301
302
|
):
|
|
302
|
-
"""Post a message. Broadcast goes to the feed if --to is omitted.
|
|
303
|
+
"""Post a message. Broadcast goes to the feed if --to is omitted.
|
|
304
|
+
--attach path uploads the file + links it to the message (repeatable)."""
|
|
303
305
|
me = _current_identity_or_exit()
|
|
304
306
|
if body == "-":
|
|
305
307
|
body = sys.stdin.read()
|
|
306
308
|
tag_list = [t.strip() for t in tags.split(",")] if tags else []
|
|
307
|
-
|
|
308
|
-
|
|
309
|
+
|
|
310
|
+
c = _client()
|
|
311
|
+
attachment_ids: list[str] = []
|
|
312
|
+
for path in (attach or []):
|
|
313
|
+
meta = _run(c.upload_attachment, path, uploaded_by=me)
|
|
314
|
+
attachment_ids.append(meta["id"])
|
|
315
|
+
|
|
316
|
+
m = _run(c.post_message, me, title, summary, body,
|
|
317
|
+
to_handle=to, in_reply_to=reply_to, tags=tag_list,
|
|
318
|
+
attachment_ids=attachment_ids)
|
|
309
319
|
if json_:
|
|
310
320
|
_emit_json(m); return
|
|
311
321
|
dest = f"to {to}" if to else "(broadcast)"
|
|
312
322
|
reply = f" reply_to={reply_to}" if reply_to else ""
|
|
313
|
-
|
|
323
|
+
att = f" attachments={','.join(attachment_ids)}" if attachment_ids else ""
|
|
324
|
+
print(f"posted id={m['id']} {dest}{reply}{att}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@app.command()
|
|
328
|
+
def attach(
|
|
329
|
+
path: str = typer.Argument(..., help="File to upload as a standalone attachment."),
|
|
330
|
+
json_: bool = typer.Option(False, "--json"),
|
|
331
|
+
):
|
|
332
|
+
"""Upload a file as an attachment; prints the id for use with `comms post --attach`."""
|
|
333
|
+
from pathlib import Path as _P
|
|
334
|
+
p = _P(path)
|
|
335
|
+
if not p.is_file():
|
|
336
|
+
_die(f"not a file: {path}")
|
|
337
|
+
me = _current_identity_or_exit()
|
|
338
|
+
meta = _run(_client().upload_attachment, p, uploaded_by=me)
|
|
339
|
+
if json_:
|
|
340
|
+
_emit_json(meta); return
|
|
341
|
+
print(f"id={meta['id']} sha256={meta['sha256']} filename={meta['filename']} size={meta['size']} mime={meta['mime']}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@app.command()
|
|
345
|
+
def fetch(
|
|
346
|
+
aid: str = typer.Argument(..., help="Attachment id (from a DM or `comms attach`)."),
|
|
347
|
+
out: Optional[str] = typer.Option(None, "--out", "-o", help="Output path. Defaults to ./attachments/<filename>."),
|
|
348
|
+
):
|
|
349
|
+
"""Download an attachment. Prints the absolute path of the saved file so you can Read it."""
|
|
350
|
+
from pathlib import Path as _P
|
|
351
|
+
c = _client()
|
|
352
|
+
meta = _run(c.attachment_meta, aid)
|
|
353
|
+
target = _P(out) if out else _P("attachments") / meta["filename"]
|
|
354
|
+
saved = _run(c.download_attachment, aid, target)
|
|
355
|
+
abs_ = saved.resolve()
|
|
356
|
+
print(f"saved {meta['filename']} ({meta['size']} bytes) → {abs_}")
|
|
357
|
+
print(f"sha256={meta['sha256']} mime={meta['mime']}")
|
|
314
358
|
|
|
315
359
|
|
|
316
360
|
@app.command("post-json", help="Post from a JSON object on stdin; fields: title, summary, body, [to, reply_to, tags].")
|
|
@@ -374,6 +418,20 @@ def uninstall_hook(project: bool = typer.Option(False, "--project")):
|
|
|
374
418
|
print(f"removed {n} hook entries from {path}")
|
|
375
419
|
|
|
376
420
|
|
|
421
|
+
@app.command()
|
|
422
|
+
def mission(
|
|
423
|
+
text: Optional[str] = typer.Argument(None, help="Mission title (omit or use --clear to reset)."),
|
|
424
|
+
clear: bool = typer.Option(False, "--clear", help="Clear the mission title."),
|
|
425
|
+
):
|
|
426
|
+
"""Set this session's persistent mission title — a one-line description of what this
|
|
427
|
+
agent is trying to achieve. Shown in the UI alongside the handle.
|
|
428
|
+
Auto-updated by the hook from Claude's own ai-title; this command lets you override."""
|
|
429
|
+
me = _current_identity_or_exit()
|
|
430
|
+
payload = None if clear else text
|
|
431
|
+
r = _run(_client().set_mission, me, payload)
|
|
432
|
+
print(f"handle={me} mission_title={r.get('mission_title') or '(none)'}")
|
|
433
|
+
|
|
434
|
+
|
|
377
435
|
@app.command()
|
|
378
436
|
def ping():
|
|
379
437
|
"""Check server connectivity + auth."""
|
|
@@ -30,6 +30,8 @@ POLL_EVERY_N_CALLS = 4
|
|
|
30
30
|
MIN_GAP_SECONDS = 15
|
|
31
31
|
FORCE_POLL_SECONDS = 300
|
|
32
32
|
MAX_BODY_CHARS = 4000
|
|
33
|
+
INLINE_ATTACHMENT_BYTES = 4 * 1024
|
|
34
|
+
INLINE_ATTACHMENT_MIMES = ("text/", "application/json", "application/xml", "application/x-sh")
|
|
33
35
|
HTTP_TIMEOUT = 3.0
|
|
34
36
|
EVENT_PREVIEW_CAP = 2000
|
|
35
37
|
|
|
@@ -103,26 +105,71 @@ def _preview(s: str | None) -> str | None:
|
|
|
103
105
|
return s
|
|
104
106
|
|
|
105
107
|
|
|
106
|
-
def
|
|
108
|
+
def _is_inline_mime(mime: str | None) -> bool:
|
|
109
|
+
if not mime:
|
|
110
|
+
return False
|
|
111
|
+
return any(mime.startswith(p) for p in INLINE_ATTACHMENT_MIMES)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _fmt_attachments(client, aids: list[str]) -> str:
|
|
115
|
+
"""For each attachment on a DM: print metadata, inline small text bodies, else a fetch hint."""
|
|
116
|
+
if not aids:
|
|
117
|
+
return ""
|
|
118
|
+
parts = ["<attachments>"]
|
|
119
|
+
for aid in aids:
|
|
120
|
+
try:
|
|
121
|
+
meta = client.attachment_meta(aid)
|
|
122
|
+
except Exception:
|
|
123
|
+
parts.append(f' <attachment id="{aid}" error="metadata_fetch_failed"/>')
|
|
124
|
+
continue
|
|
125
|
+
attrs = (f'id="{meta["id"]}" filename="{meta["filename"]}" size="{meta["size"]}" '
|
|
126
|
+
f'mime="{meta.get("mime") or ""}" sha256="{meta.get("sha256","")[:12]}..."')
|
|
127
|
+
if meta["size"] <= INLINE_ATTACHMENT_BYTES and _is_inline_mime(meta.get("mime")):
|
|
128
|
+
try:
|
|
129
|
+
import tempfile
|
|
130
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix="-" + meta["filename"]) as tf:
|
|
131
|
+
tmp_path = tf.name
|
|
132
|
+
client.download_attachment(aid, tmp_path)
|
|
133
|
+
with open(tmp_path, "rb") as f:
|
|
134
|
+
raw = f.read()
|
|
135
|
+
text = _sanitize(raw.decode("utf-8", errors="replace"))
|
|
136
|
+
parts.append(f' <attachment {attrs}>')
|
|
137
|
+
parts.append(f' <preview>\n{text}\n </preview>')
|
|
138
|
+
parts.append(" </attachment>")
|
|
139
|
+
except Exception:
|
|
140
|
+
parts.append(f' <attachment {attrs} fetch_with="comms fetch {meta["id"]}"/>')
|
|
141
|
+
else:
|
|
142
|
+
parts.append(f' <attachment {attrs} fetch_with="comms fetch {meta["id"]}"/>')
|
|
143
|
+
parts.append("</attachments>")
|
|
144
|
+
return "\n".join(parts)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _fmt_dm(m: dict, client=None) -> str:
|
|
107
148
|
body = _sanitize(m.get("body") or "")
|
|
108
149
|
if len(body) > MAX_BODY_CHARS:
|
|
109
150
|
body = body[:MAX_BODY_CHARS] + f"\n[truncated — run `comms read {m['id']}` for full]"
|
|
110
151
|
title = _sanitize(m.get("title") or "")
|
|
111
152
|
summary = _sanitize(m.get("summary") or "")
|
|
112
153
|
tags = ",".join(m.get("tags") or [])
|
|
154
|
+
attach_block = ""
|
|
155
|
+
if client is not None:
|
|
156
|
+
aids = m.get("attachment_ids") or []
|
|
157
|
+
if aids:
|
|
158
|
+
attach_block = "\n" + _fmt_attachments(client, aids)
|
|
113
159
|
return (
|
|
114
160
|
f'<dm id="{m["id"]}" from="{m["from_handle"]}" received="{m["created_at"]}" tags="{tags}">\n'
|
|
115
161
|
f"<title>{title}</title>\n"
|
|
116
162
|
f"<summary>{summary}</summary>\n"
|
|
117
|
-
f"<body>\n{body}\n</body
|
|
163
|
+
f"<body>\n{body}\n</body>"
|
|
164
|
+
f"{attach_block}\n"
|
|
118
165
|
f"</dm>"
|
|
119
166
|
)
|
|
120
167
|
|
|
121
168
|
|
|
122
|
-
def _fmt_dm_block(handle: str, dms: list[dict]) -> str:
|
|
169
|
+
def _fmt_dm_block(handle: str, dms: list[dict], client=None) -> str:
|
|
123
170
|
plural = "s" if len(dms) != 1 else ""
|
|
124
171
|
ids = ", ".join(m["id"] for m in dms)
|
|
125
|
-
dms_block = "\n\n".join(_fmt_dm(m) for m in dms)
|
|
172
|
+
dms_block = "\n\n".join(_fmt_dm(m, client=client) for m in dms)
|
|
126
173
|
return (
|
|
127
174
|
f'<agent_comms_inbox handle="{handle}">\n'
|
|
128
175
|
f"You received {len(dms)} new direct message{plural} on agent-comms. "
|
|
@@ -219,6 +266,54 @@ def _post_event(client, ident, event_name: str, hook_input: dict) -> None:
|
|
|
219
266
|
pass
|
|
220
267
|
|
|
221
268
|
|
|
269
|
+
def _latest_ai_title(transcript_path: str | None) -> str | None:
|
|
270
|
+
"""Grep the transcript JSONL for the most recent Claude Code ai-title entry.
|
|
271
|
+
Cheap (reverse scan, stops at first hit). Returns None on missing/unreadable."""
|
|
272
|
+
if not transcript_path:
|
|
273
|
+
return None
|
|
274
|
+
try:
|
|
275
|
+
p = Path(transcript_path)
|
|
276
|
+
if not p.is_file():
|
|
277
|
+
return None
|
|
278
|
+
with p.open("rb") as f:
|
|
279
|
+
try:
|
|
280
|
+
f.seek(-65536, 2)
|
|
281
|
+
except OSError:
|
|
282
|
+
f.seek(0)
|
|
283
|
+
tail = f.read().decode("utf-8", errors="replace")
|
|
284
|
+
latest = None
|
|
285
|
+
for line in tail.splitlines():
|
|
286
|
+
if '"ai-title"' in line or '"aiTitle"' in line:
|
|
287
|
+
try:
|
|
288
|
+
d = json.loads(line)
|
|
289
|
+
except Exception:
|
|
290
|
+
continue
|
|
291
|
+
t = d.get("aiTitle") or (d.get("type") == "ai-title" and d.get("aiTitle"))
|
|
292
|
+
if t:
|
|
293
|
+
latest = t
|
|
294
|
+
return latest
|
|
295
|
+
except Exception:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _maybe_push_mission(client, handle: str, transcript_path: str | None) -> None:
|
|
300
|
+
"""If the latest Claude ai-title differs from what we last pushed, POST it as mission."""
|
|
301
|
+
if not transcript_path:
|
|
302
|
+
return
|
|
303
|
+
title = _latest_ai_title(transcript_path)
|
|
304
|
+
if not title:
|
|
305
|
+
return
|
|
306
|
+
h_key = _hash(handle)
|
|
307
|
+
cache_path = _cache_dir() / f"mission-{h_key}"
|
|
308
|
+
if _safe_read(cache_path, "") == title:
|
|
309
|
+
return
|
|
310
|
+
try:
|
|
311
|
+
client._h.post(f"/api/identities/{handle}/mission", json={"text": title})
|
|
312
|
+
_write(cache_path, title)
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
|
|
222
317
|
def _call_id_for(hook_input: dict) -> str:
|
|
223
318
|
"""Stable call_id per (session_id, tool_input) so PreToolUse and PostToolUse pair up.
|
|
224
319
|
Claude Code doesn't hand us a native id, so we hash the tool_input — deterministic
|
|
@@ -278,6 +373,10 @@ def main() -> int:
|
|
|
278
373
|
except Exception:
|
|
279
374
|
return 0
|
|
280
375
|
|
|
376
|
+
# Mission-title tailer — piggybacks on Claude Code's own ai-title generation.
|
|
377
|
+
# Free (no inference), runs on every event that has a transcript_path.
|
|
378
|
+
_maybe_push_mission(client, handle, hook_input.get("transcript_path"))
|
|
379
|
+
|
|
281
380
|
# Broadcast activity event (control mode only).
|
|
282
381
|
if control:
|
|
283
382
|
_post_event(client, ident, event_name, hook_input)
|
|
@@ -311,7 +410,7 @@ def main() -> int:
|
|
|
311
410
|
if new_dms:
|
|
312
411
|
new_dms.sort(key=lambda m: m["created_at"])
|
|
313
412
|
_write(watermark_path, new_dms[-1]["created_at"])
|
|
314
|
-
pieces.append(_fmt_dm_block(handle, new_dms))
|
|
413
|
+
pieces.append(_fmt_dm_block(handle, new_dms, client=client))
|
|
315
414
|
|
|
316
415
|
# --- Operator inputs (control mode only) ---
|
|
317
416
|
if control:
|
|
@@ -285,6 +285,61 @@ def test_pid_walk_finds_claude(monkeypatch):
|
|
|
285
285
|
assert cfg.find_claude_pid() == os.getppid()
|
|
286
286
|
|
|
287
287
|
|
|
288
|
+
def test_attach_and_fetch_round_trip(env, tmp_path, monkeypatch):
|
|
289
|
+
run(["init", "--handle", "alpha"])
|
|
290
|
+
src = tmp_path / "note.md"
|
|
291
|
+
src.write_text("# hello\nattachment round-trip works")
|
|
292
|
+
# upload
|
|
293
|
+
r = run(["attach", str(src), "--json"])
|
|
294
|
+
assert r.exit_code == 0, r.output
|
|
295
|
+
data = json.loads(r.output)
|
|
296
|
+
aid = data["id"]
|
|
297
|
+
assert data["filename"] == "note.md"
|
|
298
|
+
# fetch into a fresh location
|
|
299
|
+
out = tmp_path / "pulled" / "note.md"
|
|
300
|
+
r = run(["fetch", aid, "--out", str(out)])
|
|
301
|
+
assert r.exit_code == 0, r.output
|
|
302
|
+
assert out.read_text() == src.read_text()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_post_with_attachment(env, tmp_path):
|
|
306
|
+
run(["init", "--handle", "alpha"])
|
|
307
|
+
from agent_comms.api import Client
|
|
308
|
+
Client().register_identity(handle="bob")
|
|
309
|
+
src = tmp_path / "script.sh"
|
|
310
|
+
src.write_text("#!/bin/bash\necho hi")
|
|
311
|
+
r = run(["post", "-t", "look", "-s", "attached script", "-b", "body",
|
|
312
|
+
"--to", "bob", "--attach", str(src)])
|
|
313
|
+
assert r.exit_code == 0, r.output
|
|
314
|
+
assert "attachments=" in r.output
|
|
315
|
+
# Recipient sees attachment_ids on the message
|
|
316
|
+
msgs = Client().inbox("bob")
|
|
317
|
+
assert msgs[0]["attachment_ids"]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_mission_set_clear_via_cli(env):
|
|
321
|
+
run(["init", "--handle", "alpha"])
|
|
322
|
+
r = run(["mission", "refactoring auth"])
|
|
323
|
+
assert r.exit_code == 0 and "refactoring auth" in r.output
|
|
324
|
+
r = run(["mission", "--clear"])
|
|
325
|
+
assert "(none)" in r.output
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_mission_tailer_picks_latest_ai_title(env, tmp_path, monkeypatch):
|
|
329
|
+
# Build a fake transcript with multiple ai-title entries
|
|
330
|
+
tx = tmp_path / "session.jsonl"
|
|
331
|
+
tx.write_text(
|
|
332
|
+
'{"type":"user","message":{"content":"hi"}}\n'
|
|
333
|
+
'{"type":"ai-title","aiTitle":"Old topic"}\n'
|
|
334
|
+
'{"type":"assistant","message":{"content":[{"type":"text","text":"ok"}]}}\n'
|
|
335
|
+
'{"type":"ai-title","aiTitle":"New fresher topic"}\n'
|
|
336
|
+
)
|
|
337
|
+
from agent_comms.hook import _latest_ai_title
|
|
338
|
+
assert _latest_ai_title(str(tx)) == "New fresher topic"
|
|
339
|
+
assert _latest_ai_title(None) is None
|
|
340
|
+
assert _latest_ai_title("/nonexistent/file") is None
|
|
341
|
+
|
|
342
|
+
|
|
288
343
|
def test_post_json(env):
|
|
289
344
|
run(["init", "--handle", "alpha"])
|
|
290
345
|
payload = json.dumps({"title": "T", "summary": "S", "body": "B", "tags": ["x"]})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|