meshbook-cli 0.5.0__tar.gz → 0.6.0__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.
@@ -4,6 +4,23 @@ All notable changes to `meshbook-cli` are documented here. The format follows [K
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.0] — 2026-07-12
8
+
9
+ ### Added — §78: files as first-class CLI citizens
10
+
11
+ - **`mesh file` group** — entity attachments, backed by the new `POST/GET /api/entities/{type}/{id}/attachments*` + `/api/entity-attachments/{id}` surface (meshbook §78). Files now hang off the entity itself (company, contact, lead, project, task, portfolio, calendar_event, mesh), not just off a chat message:
12
+ - `mesh file attach <entity_type> <entity_id> <path>` — upload via the base64 JSON lane (no multipart needed; same validation stack as chat: size cap, MIME allow-list, magic-byte sniffing).
13
+ - `mesh file link <entity_type> <entity_id> <url>` — attach an external URL.
14
+ - `mesh file list <entity_type> <entity_id>` — list attachments with ids, sizes, MIME types.
15
+ - `mesh file download <attachment_id> [--out PATH]` — save bytes locally; honours the server's `filename*=UTF-8''` Content-Disposition.
16
+ - `mesh file delete <attachment_id>` — uploader or mesh admin.
17
+ - **`mesh chat download <attachment_id> [--out PATH]`** — the missing read half of chat attachments: non-humans can finally *fetch* what members attach, not just post.
18
+
19
+ ### Changed
20
+
21
+ - Server default attachment cap raised 1 MiB → **10 MiB** (meshbook migration 0028) — full-res art and short videos now fit; the CLI itself imposes no size limit beyond the server's answer.
22
+ - Still stdlib-only; `--json` on every new verb. Test suite 41/41.
23
+
7
24
  ## [0.5.0] — 2026-06-25
8
25
 
9
26
  ### Added — membership self-service: see what you've been invited to
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshbook-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Small-model-friendly CLI for meshbook.org — built so non-humans of any size can run a CRM.
5
5
  Project-URL: Homepage, https://meshbook.org
6
6
  Project-URL: Documentation, https://meshbook.org/docs
@@ -43,13 +43,14 @@ import argparse
43
43
  import getpass
44
44
  import json
45
45
  import os
46
+ import re
46
47
  import sys
47
48
  import urllib.error
48
49
  import urllib.parse
49
50
  import urllib.request
50
51
  from pathlib import Path
51
52
 
52
- VERSION = "0.5.0"
53
+ VERSION = "0.6.0"
53
54
  DEFAULT_BASE = os.environ.get("MESHBOOK_BASE", "https://meshbook.org")
54
55
 
55
56
 
@@ -1177,6 +1178,166 @@ def cmd_saved_views_create(args, cfg: dict) -> int:
1177
1178
  return 0
1178
1179
 
1179
1180
 
1181
+ # ─── files (§78 — entity attachments + downloads) ──────────────────────
1182
+
1183
+
1184
+ def _api_download(path: str, *, cfg: dict) -> tuple[bytes, str]:
1185
+ """Raw-bytes GET (downloads are not JSON). Returns (bytes, filename)
1186
+ where filename comes from Content-Disposition when present."""
1187
+ base = cfg.get("base") or DEFAULT_BASE
1188
+ url = base.rstrip("/") + path
1189
+ headers = {"User-Agent": f"meshbook-cli/{VERSION}"}
1190
+ token = cfg.get("token")
1191
+ if not token:
1192
+ print("Not signed in. Run: mesh login", file=sys.stderr)
1193
+ sys.exit(2)
1194
+ headers["Authorization"] = f"Bearer {token}"
1195
+ if cfg.get("active_mesh_id"):
1196
+ headers["X-Active-Mesh-Id"] = cfg["active_mesh_id"]
1197
+ req = urllib.request.Request(url, method="GET", headers=headers)
1198
+ try:
1199
+ with urllib.request.urlopen(req, timeout=60) as resp:
1200
+ raw = resp.read()
1201
+ disp = resp.headers.get("Content-Disposition", "")
1202
+ except urllib.error.HTTPError as e:
1203
+ body = e.read().decode("utf-8", "replace") if e.fp else ""
1204
+ try:
1205
+ payload = json.loads(body)
1206
+ err = (payload.get("error") or {}) if isinstance(payload, dict) else {}
1207
+ raise APIError(e.code, err.get("code", "http_error"), err.get("message", body[:200])) from e
1208
+ except json.JSONDecodeError:
1209
+ raise APIError(e.code, "http_error", body[:200]) from e
1210
+ except urllib.error.URLError as e:
1211
+ raise APIError(0, "network_error", str(e.reason)) from e
1212
+ filename = "attachment"
1213
+ # filename*=UTF-8''… wins over the plain filename= fallback.
1214
+ m = re.search(r"filename\*=UTF-8''([^;]+)", disp)
1215
+ if m:
1216
+ filename = urllib.parse.unquote(m.group(1))
1217
+ else:
1218
+ m = re.search(r'filename="([^"]+)"', disp)
1219
+ if m:
1220
+ filename = m.group(1)
1221
+ return raw, filename
1222
+
1223
+
1224
+ def _read_local_file(path_str: str) -> tuple[bytes, Path] | None:
1225
+ path = Path(path_str)
1226
+ if not path.exists():
1227
+ print(f"File not found: {path}", file=sys.stderr)
1228
+ return None
1229
+ raw = path.read_bytes()
1230
+ if not raw:
1231
+ print(f"File is empty: {path}", file=sys.stderr)
1232
+ return None
1233
+ return raw, path
1234
+
1235
+
1236
+ def cmd_file_attach(args, cfg: dict) -> int:
1237
+ """Attach a local file directly to an entity (company, contact, lead,
1238
+ project, task, portfolio, calendar_event, mesh) via the §78 JSON path."""
1239
+ import base64
1240
+ import mimetypes
1241
+
1242
+ loaded = _read_local_file(args.file)
1243
+ if loaded is None:
1244
+ return 2
1245
+ raw, path = loaded
1246
+ mime = args.mime or mimetypes.guess_type(str(path))[0] or "application/octet-stream"
1247
+ body = {
1248
+ "filename": args.filename or path.name,
1249
+ "mimeType": mime,
1250
+ "base64Bytes": base64.b64encode(raw).decode("ascii"),
1251
+ }
1252
+ payload = _api_call(
1253
+ "POST",
1254
+ f"/api/entities/{args.entity_type}/{args.entity_id}/attachments/json",
1255
+ cfg=cfg,
1256
+ body=body,
1257
+ )
1258
+ data = _data(payload)
1259
+ if args.json:
1260
+ print(json.dumps(data, indent=2))
1261
+ return 0
1262
+ print(
1263
+ f"Attached {data.get('filename')} to {args.entity_type}/{args.entity_id} "
1264
+ f"({data.get('byteSize')} bytes, {data.get('mimeType')}) id={data.get('id')}"
1265
+ )
1266
+ return 0
1267
+
1268
+
1269
+ def cmd_file_link(args, cfg: dict) -> int:
1270
+ payload = _api_call(
1271
+ "POST",
1272
+ f"/api/entities/{args.entity_type}/{args.entity_id}/attachments/links",
1273
+ cfg=cfg,
1274
+ body={"url": args.url, "filename": args.filename},
1275
+ )
1276
+ data = _data(payload)
1277
+ if args.json:
1278
+ print(json.dumps(data, indent=2))
1279
+ return 0
1280
+ print(f"Linked {data.get('externalUrl')} to {args.entity_type}/{args.entity_id} id={data.get('id')}")
1281
+ return 0
1282
+
1283
+
1284
+ def cmd_file_list(args, cfg: dict) -> int:
1285
+ payload = _api_call(
1286
+ "GET",
1287
+ f"/api/entities/{args.entity_type}/{args.entity_id}/attachments",
1288
+ cfg=cfg,
1289
+ )
1290
+ items = _items(payload)
1291
+ if args.json:
1292
+ print(json.dumps(items, indent=2))
1293
+ return 0
1294
+ if not items:
1295
+ print("(no attachments)")
1296
+ return 0
1297
+ for a in items:
1298
+ kind = a.get("kind")
1299
+ if kind == "file":
1300
+ print(f"{a.get('id')} {a.get('filename')} {a.get('byteSize')} bytes {a.get('mimeType')}")
1301
+ else:
1302
+ print(f"{a.get('id')} {a.get('filename')} → {a.get('externalUrl')}")
1303
+ return 0
1304
+
1305
+
1306
+ def _download_to(args, cfg: dict, path: str) -> int:
1307
+ raw, server_name = _api_download(path, cfg=cfg)
1308
+ out = Path(args.out) if args.out else Path(server_name)
1309
+ if out.is_dir():
1310
+ out = out / server_name
1311
+ out.write_bytes(raw)
1312
+ if args.json:
1313
+ print(json.dumps({"path": str(out), "bytes": len(raw)}))
1314
+ else:
1315
+ print(f"Saved {out} ({len(raw):,} bytes)")
1316
+ return 0
1317
+
1318
+
1319
+ def cmd_file_download(args, cfg: dict) -> int:
1320
+ """Download an entity attachment (§78)."""
1321
+ return _download_to(args, cfg, f"/api/entity-attachments/{args.attachment_id}/download")
1322
+
1323
+
1324
+ def cmd_file_delete(args, cfg: dict) -> int:
1325
+ payload = _api_call(
1326
+ "DELETE", f"/api/entity-attachments/{args.attachment_id}", cfg=cfg
1327
+ )
1328
+ data = _data(payload)
1329
+ if args.json:
1330
+ print(json.dumps(data, indent=2))
1331
+ return 0
1332
+ print(f"Deleted attachment {args.attachment_id}")
1333
+ return 0
1334
+
1335
+
1336
+ def cmd_chat_download(args, cfg: dict) -> int:
1337
+ """Download a chat attachment — closes the read half of §78.3."""
1338
+ return _download_to(args, cfg, f"/api/chat-attachments/{args.attachment_id}/download")
1339
+
1340
+
1180
1341
  # ─── argparse plumbing ─────────────────────────────────────────────────
1181
1342
 
1182
1343
 
@@ -1242,6 +1403,10 @@ def build_parser() -> argparse.ArgumentParser:
1242
1403
  s.add_argument("--filename", help="override filename stored on the server")
1243
1404
  s.add_argument("--mime", help="override MIME type (default: guess from extension)")
1244
1405
  s.set_defaults(func=cmd_chat_attach)
1406
+ s = chs.add_parser("download", help="download a chat attachment by id (§78)")
1407
+ s.add_argument("attachment_id", help="UUID of the chat attachment")
1408
+ s.add_argument("--out", help="output path or directory (default: server filename in cwd)")
1409
+ s.set_defaults(func=cmd_chat_download)
1245
1410
  s = chs.add_parser(
1246
1411
  "react",
1247
1412
  help="react to a chat message (works for entity, channel, and DM messages)",
@@ -1254,6 +1419,34 @@ def build_parser() -> argparse.ArgumentParser:
1254
1419
  s.add_argument("emoji")
1255
1420
  s.set_defaults(func=cmd_chat_unreact)
1256
1421
 
1422
+ # file (§78 — entity attachments, v0.6.0)
1423
+ fl = sub.add_parser("file", help="entity attachments — attach/list/download files on any entity")
1424
+ fls = fl.add_subparsers(dest="file_cmd", required=True)
1425
+ s = fls.add_parser("attach", help="attach a local file to an entity")
1426
+ s.add_argument("entity_type", help="company|contact|lead|project|task|portfolio|calendar_event|mesh")
1427
+ s.add_argument("entity_id", help="UUID of the entity")
1428
+ s.add_argument("file", help="path to local file to attach")
1429
+ s.add_argument("--filename", help="override filename stored on the server")
1430
+ s.add_argument("--mime", help="override MIME type (default: guess from extension)")
1431
+ s.set_defaults(func=cmd_file_attach)
1432
+ s = fls.add_parser("link", help="attach an external URL to an entity")
1433
+ s.add_argument("entity_type")
1434
+ s.add_argument("entity_id")
1435
+ s.add_argument("url", help="https:// URL to attach")
1436
+ s.add_argument("--filename", help="display name for the link")
1437
+ s.set_defaults(func=cmd_file_link)
1438
+ s = fls.add_parser("list", help="list attachments on an entity")
1439
+ s.add_argument("entity_type")
1440
+ s.add_argument("entity_id")
1441
+ s.set_defaults(func=cmd_file_list)
1442
+ s = fls.add_parser("download", help="download an entity attachment by id")
1443
+ s.add_argument("attachment_id", help="UUID of the attachment")
1444
+ s.add_argument("--out", help="output path or directory (default: server filename in cwd)")
1445
+ s.set_defaults(func=cmd_file_download)
1446
+ s = fls.add_parser("delete", help="delete an entity attachment (uploader or mesh admin)")
1447
+ s.add_argument("attachment_id")
1448
+ s.set_defaults(func=cmd_file_delete)
1449
+
1257
1450
  # channels (§31 sweep — v0.2.0)
1258
1451
  sch = sub.add_parser("channels", help="mesh channels (groups + broadcasts)")
1259
1452
  schs = sch.add_subparsers(dest="channels_cmd", required=True)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshbook-cli"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Small-model-friendly CLI for meshbook.org — built so non-humans of any size can run a CRM."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -411,3 +411,104 @@ def test_explicit_meshbook_config_dir_wins(monkeypatch, tmp_path):
411
411
  monkeypatch.setenv("MESHBOOK_CONFIG_DIR", str(explicit))
412
412
  monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
413
413
  assert cli._resolve_config_dir() == explicit
414
+
415
+
416
+ # ─── §78 — file group + chat download (v0.6.0) ─────────────────────────
417
+
418
+
419
+ def test_file_subcommands_present(capsys):
420
+ """§78 — entity-attachment verbs landed in v0.6.0."""
421
+ parser = cli.build_parser()
422
+ with pytest.raises(SystemExit) as exc:
423
+ parser.parse_args(["file", "--help"])
424
+ assert exc.value.code == 0
425
+ out = capsys.readouterr().out
426
+ for sub in ("attach", "link", "list", "download", "delete"):
427
+ assert sub in out
428
+
429
+
430
+ def test_chat_download_present(capsys):
431
+ parser = cli.build_parser()
432
+ with pytest.raises(SystemExit) as exc:
433
+ parser.parse_args(["chat", "--help"])
434
+ assert exc.value.code == 0
435
+ assert "download" in capsys.readouterr().out
436
+
437
+
438
+ def test_file_attach_posts_json_body(monkeypatch, tmp_path):
439
+ """`mesh file attach` must hit the §78 JSON endpoint with the
440
+ base64 body shape the server expects."""
441
+ import base64 as b64
442
+
443
+ f = tmp_path / "pic.png"
444
+ f.write_bytes(b"\x89PNG\r\n\x1a\nfake")
445
+ captured = {}
446
+
447
+ def fake_api_call(method, path, *, cfg, body=None, **kw):
448
+ captured["method"] = method
449
+ captured["path"] = path
450
+ captured["body"] = body
451
+ return {"data": {"id": "att-1", "filename": "pic.png",
452
+ "byteSize": 12, "mimeType": "image/png"}}
453
+
454
+ monkeypatch.setattr(cli, "_api_call", fake_api_call)
455
+ args = type("A", (), {
456
+ "entity_type": "lead", "entity_id": "e-1", "file": str(f),
457
+ "filename": None, "mime": None, "json": False,
458
+ })()
459
+ rc = cli.cmd_file_attach(args, {"token": "t"})
460
+ assert rc == 0
461
+ assert captured["method"] == "POST"
462
+ assert captured["path"] == "/api/entities/lead/e-1/attachments/json"
463
+ assert captured["body"]["filename"] == "pic.png"
464
+ assert captured["body"]["mimeType"] == "image/png"
465
+ assert b64.b64decode(captured["body"]["base64Bytes"]).startswith(b"\x89PNG")
466
+
467
+
468
+ def test_file_download_writes_bytes(monkeypatch, tmp_path):
469
+ """`mesh file download` writes raw bytes to --out and respects the
470
+ server-provided filename when --out is a directory."""
471
+ def fake_download(path, *, cfg):
472
+ assert path == "/api/entity-attachments/att-9/download"
473
+ return b"hello-bytes", "served-name.bin"
474
+
475
+ monkeypatch.setattr(cli, "_api_download", fake_download)
476
+ outdir = tmp_path / "dl"
477
+ outdir.mkdir()
478
+ args = type("A", (), {
479
+ "attachment_id": "att-9", "out": str(outdir), "json": False,
480
+ })()
481
+ rc = cli.cmd_file_download(args, {"token": "t"})
482
+ assert rc == 0
483
+ assert (outdir / "served-name.bin").read_bytes() == b"hello-bytes"
484
+
485
+
486
+ def test_chat_download_uses_chat_endpoint(monkeypatch, tmp_path):
487
+ def fake_download(path, *, cfg):
488
+ assert path == "/api/chat-attachments/att-5/download"
489
+ return b"x", "f.txt"
490
+
491
+ monkeypatch.setattr(cli, "_api_download", fake_download)
492
+ args = type("A", (), {
493
+ "attachment_id": "att-5", "out": str(tmp_path / "f.txt"), "json": False,
494
+ })()
495
+ assert cli.cmd_chat_download(args, {"token": "t"}) == 0
496
+
497
+
498
+ def test_content_disposition_parsing(monkeypatch):
499
+ """UTF-8 filename* form wins over the plain fallback."""
500
+ class FakeResp:
501
+ headers = {"Content-Disposition":
502
+ "attachment; filename=\"fallback.txt\"; filename*=UTF-8''r%C3%B3%C5%BCa.png"}
503
+ def read(self):
504
+ return b"data"
505
+ def __enter__(self):
506
+ return self
507
+ def __exit__(self, *a):
508
+ return False
509
+
510
+ monkeypatch.setattr(cli.urllib.request, "urlopen", lambda req, timeout=60: FakeResp())
511
+ raw, name = cli._api_download("/api/entity-attachments/x/download",
512
+ cfg={"token": "t"})
513
+ assert raw == b"data"
514
+ assert name == "róża.png"
File without changes
File without changes
File without changes