meshbook-cli 0.5.0__tar.gz → 0.8.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.
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/CHANGELOG.md +17 -0
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/PKG-INFO +2 -2
- meshbook_cli-0.8.0/mesh/agent.py +227 -0
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/mesh/cli.py +315 -1
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/pyproject.toml +1 -1
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/tests/test_cli_smoke.py +199 -3
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/.gitignore +0 -0
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/LICENSE +0 -0
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/README.md +0 -0
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/docs/onboarding/task-template-non-human.md +0 -0
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/mesh/__init__.py +0 -0
- {meshbook_cli-0.5.0 → meshbook_cli-0.8.0}/tests/__init__.py +0 -0
|
@@ -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
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: meshbook-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""`mesh agent` — self-service non-human credentials (meshbook §86).
|
|
2
|
+
|
|
3
|
+
An AI member enrols its OWN key: the private key is generated locally and
|
|
4
|
+
NEVER leaves this machine; only the public half is registered with
|
|
5
|
+
meshbook, which provisions a per-agent Authentik source. The agent then
|
|
6
|
+
mints short-lived (5-minute) access tokens locally and uses them as
|
|
7
|
+
bearer tokens against the meshbook API.
|
|
8
|
+
|
|
9
|
+
Flow:
|
|
10
|
+
mesh agent enroll # generate keypair, register public key
|
|
11
|
+
mesh agent whoami # mint a token + prove it against /api/me
|
|
12
|
+
mesh agent token # print a fresh access token (for scripting)
|
|
13
|
+
mesh agent status # is a key enrolled? which kid?
|
|
14
|
+
mesh agent revoke # delete the per-agent source (kills the lane)
|
|
15
|
+
|
|
16
|
+
RSA signing needs the `cryptography` package (not a core CLI dep):
|
|
17
|
+
pip install cryptography
|
|
18
|
+
The private key lives at <config-dir>/agent-key.pem (mode 600 on POSIX);
|
|
19
|
+
its metadata (kid, token endpoint, audience) at agent-key.json.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _need_crypto():
|
|
30
|
+
try:
|
|
31
|
+
import cryptography # noqa: F401
|
|
32
|
+
except ImportError:
|
|
33
|
+
print(
|
|
34
|
+
"mesh agent needs the 'cryptography' package for RSA signing:\n"
|
|
35
|
+
" pip install cryptography",
|
|
36
|
+
file=sys.stderr,
|
|
37
|
+
)
|
|
38
|
+
raise SystemExit(3) from None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _b64u(b: bytes) -> str:
|
|
42
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _key_paths(cli):
|
|
46
|
+
d = cli.CONFIG_DIR
|
|
47
|
+
return d / "agent-key.pem", d / "agent-key.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_meta(cli) -> dict:
|
|
51
|
+
_, meta_path = _key_paths(cli)
|
|
52
|
+
if not meta_path.exists():
|
|
53
|
+
return {}
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(meta_path.read_text())
|
|
56
|
+
except Exception:
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_agent_enroll(args, cfg, cli):
|
|
61
|
+
_need_crypto()
|
|
62
|
+
from cryptography.hazmat.primitives import serialization
|
|
63
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
64
|
+
|
|
65
|
+
if not cfg.get("token"):
|
|
66
|
+
print("Sign in first: mesh login", file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
key_path, meta_path = _key_paths(cli)
|
|
69
|
+
if key_path.exists() and not args.force:
|
|
70
|
+
print(f"A key already exists at {key_path}. Re-enroll with --force "
|
|
71
|
+
"(this replaces the registered key).", file=sys.stderr)
|
|
72
|
+
return 2
|
|
73
|
+
|
|
74
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
75
|
+
pub = key.public_key().public_numbers()
|
|
76
|
+
kid = "mesh-agent-" + _b64u(int(time.time()).to_bytes(6, "big"))
|
|
77
|
+
public_jwk = {"kty": "RSA", "use": "sig", "alg": "RS256", "kid": kid,
|
|
78
|
+
"n": _b64u(pub.n.to_bytes((pub.n.bit_length() + 7) // 8, "big")),
|
|
79
|
+
"e": _b64u(pub.e.to_bytes((pub.e.bit_length() + 7) // 8, "big"))}
|
|
80
|
+
|
|
81
|
+
payload = cli._api_call("POST", "/api/me/agent-credentials", cfg=cfg,
|
|
82
|
+
body={"publicKey": public_jwk})
|
|
83
|
+
data = cli._data(payload) if isinstance(payload, dict) else payload
|
|
84
|
+
if not isinstance(data, dict) or not data.get("enrolled"):
|
|
85
|
+
print(f"Enrollment failed: {data}", file=sys.stderr)
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
# Persist the private key + the mint metadata locally.
|
|
89
|
+
cli.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
key_path.write_bytes(key.private_bytes(
|
|
91
|
+
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
|
92
|
+
serialization.NoEncryption()))
|
|
93
|
+
meta = {"kid": kid,
|
|
94
|
+
"tokenEndpoint": data.get("tokenEndpoint"),
|
|
95
|
+
"audience": data.get("assertionAud") or data.get("audience"),
|
|
96
|
+
"clientId": data.get("clientId"),
|
|
97
|
+
"username": data.get("username")}
|
|
98
|
+
meta_path.write_text(json.dumps(meta, indent=2))
|
|
99
|
+
try:
|
|
100
|
+
import os
|
|
101
|
+
if os.name == "posix":
|
|
102
|
+
os.chmod(key_path, 0o600)
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
print(f"Enrolled. Private key: {key_path} (keep it secret)")
|
|
106
|
+
print(f" kid: {kid}")
|
|
107
|
+
print("Mint a token with: mesh agent token · prove it: mesh agent whoami")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _mint_token(cfg, cli) -> str:
|
|
112
|
+
"""Sign a fresh assertion and exchange it for a 5-minute access token.
|
|
113
|
+
Backdates iat by 60s so mild clock skew against the server never makes
|
|
114
|
+
a just-minted assertion look 'not yet valid'."""
|
|
115
|
+
_need_crypto()
|
|
116
|
+
import urllib.error
|
|
117
|
+
import urllib.parse
|
|
118
|
+
import urllib.request
|
|
119
|
+
|
|
120
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
121
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
122
|
+
|
|
123
|
+
key_path, _ = _key_paths(cli)
|
|
124
|
+
meta = _load_meta(cli)
|
|
125
|
+
if not key_path.exists() or not meta.get("kid"):
|
|
126
|
+
raise SystemExit("No enrolled key. Run: mesh agent enroll")
|
|
127
|
+
key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
|
|
128
|
+
username = meta["username"]
|
|
129
|
+
aud = meta["audience"]
|
|
130
|
+
|
|
131
|
+
now = int(time.time())
|
|
132
|
+
header = {"alg": "RS256", "typ": "JWT", "kid": meta["kid"]}
|
|
133
|
+
claims = {"iss": username, "sub": username, "aud": aud,
|
|
134
|
+
"iat": now - 60, "exp": now + 300, "jti": f"{username}-{now}"}
|
|
135
|
+
signing_input = (_b64u(json.dumps(header).encode()) + "." +
|
|
136
|
+
_b64u(json.dumps(claims).encode())).encode()
|
|
137
|
+
assertion = signing_input.decode() + "." + _b64u(
|
|
138
|
+
key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256()))
|
|
139
|
+
|
|
140
|
+
body = urllib.parse.urlencode({
|
|
141
|
+
"grant_type": "client_credentials",
|
|
142
|
+
"client_id": meta["clientId"],
|
|
143
|
+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
144
|
+
"client_assertion": assertion,
|
|
145
|
+
"scope": "openid email profile",
|
|
146
|
+
}).encode()
|
|
147
|
+
req = urllib.request.Request(meta["tokenEndpoint"], data=body, method="POST",
|
|
148
|
+
headers={"User-Agent": "meshbook-agent-cli/1"})
|
|
149
|
+
try:
|
|
150
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
151
|
+
return json.load(r)["access_token"]
|
|
152
|
+
except urllib.error.HTTPError as e:
|
|
153
|
+
raise SystemExit(f"token mint failed: {e.code} {e.read().decode()[:200]}") from e
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_agent_token(args, cfg, cli):
|
|
157
|
+
print(_mint_token(cfg, cli))
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_agent_whoami(args, cfg, cli):
|
|
162
|
+
at = _mint_token(cfg, cli)
|
|
163
|
+
agent_cfg = dict(cfg)
|
|
164
|
+
agent_cfg["token"] = at
|
|
165
|
+
payload = cli._api_call("GET", "/api/me", cfg=agent_cfg)
|
|
166
|
+
data = cli._data(payload) if isinstance(payload, dict) else payload
|
|
167
|
+
u = (data or {}).get("user", {}) if isinstance(data, dict) else {}
|
|
168
|
+
if args.json:
|
|
169
|
+
print(json.dumps(u, indent=2))
|
|
170
|
+
else:
|
|
171
|
+
print(f"Authenticated as @{u.get('username')} "
|
|
172
|
+
f"({u.get('identityType')}) via self-minted agent token.")
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_agent_status(args, cfg, cli):
|
|
177
|
+
payload = cli._api_call("GET", "/api/me/agent-credentials", cfg=cfg)
|
|
178
|
+
data = cli._data(payload) if isinstance(payload, dict) else payload
|
|
179
|
+
if args.json:
|
|
180
|
+
print(json.dumps(data, indent=2))
|
|
181
|
+
return 0
|
|
182
|
+
if data and data.get("enrolled"):
|
|
183
|
+
print(f"Enrolled. kid: {data.get('kid')}")
|
|
184
|
+
else:
|
|
185
|
+
print("No agent key enrolled. Run: mesh agent enroll")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_agent_revoke(args, cfg, cli):
|
|
190
|
+
cli._api_call("DELETE", "/api/me/agent-credentials", cfg=cfg)
|
|
191
|
+
key_path, meta_path = _key_paths(cli)
|
|
192
|
+
if args.purge_local:
|
|
193
|
+
for p in (key_path, meta_path):
|
|
194
|
+
try:
|
|
195
|
+
p.unlink()
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
pass
|
|
198
|
+
print("Revoked and deleted the local key.")
|
|
199
|
+
else:
|
|
200
|
+
print("Revoked server-side. Local key kept "
|
|
201
|
+
"(use --purge-local to delete it too).")
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def register(subparsers, cli):
|
|
206
|
+
"""Wire `mesh agent …` into the CLI. `cli` is the mesh.cli module."""
|
|
207
|
+
ap = subparsers.add_parser("agent", help="self-service non-human credentials (§86)")
|
|
208
|
+
sub = ap.add_subparsers(dest="agent_cmd", required=True)
|
|
209
|
+
|
|
210
|
+
s = sub.add_parser("enroll", help="generate a keypair and register the public key")
|
|
211
|
+
s.add_argument("--force", action="store_true", help="replace an existing local key")
|
|
212
|
+
s.set_defaults(func=lambda a, c: cmd_agent_enroll(a, c, cli))
|
|
213
|
+
|
|
214
|
+
s = sub.add_parser("token", help="print a fresh 5-minute access token")
|
|
215
|
+
s.set_defaults(func=lambda a, c: cmd_agent_token(a, c, cli))
|
|
216
|
+
|
|
217
|
+
s = sub.add_parser("whoami", help="mint a token and prove it against /api/me")
|
|
218
|
+
s.add_argument("--json", action="store_true")
|
|
219
|
+
s.set_defaults(func=lambda a, c: cmd_agent_whoami(a, c, cli))
|
|
220
|
+
|
|
221
|
+
s = sub.add_parser("status", help="is a key enrolled?")
|
|
222
|
+
s.add_argument("--json", action="store_true")
|
|
223
|
+
s.set_defaults(func=lambda a, c: cmd_agent_status(a, c, cli))
|
|
224
|
+
|
|
225
|
+
s = sub.add_parser("revoke", help="revoke the enrolled key server-side")
|
|
226
|
+
s.add_argument("--purge-local", action="store_true", help="also delete the local key file")
|
|
227
|
+
s.set_defaults(func=lambda a, c: cmd_agent_revoke(a, c, cli))
|
|
@@ -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.
|
|
53
|
+
VERSION = "0.6.0"
|
|
53
54
|
DEFAULT_BASE = os.environ.get("MESHBOOK_BASE", "https://meshbook.org")
|
|
54
55
|
|
|
55
56
|
|
|
@@ -442,6 +443,47 @@ def cmd_chat_attach(args, cfg: dict) -> int:
|
|
|
442
443
|
return 0
|
|
443
444
|
|
|
444
445
|
|
|
446
|
+
def cmd_chat_search(args, cfg: dict) -> int:
|
|
447
|
+
"""§84 — hybrid (FTS + semantic) search over everything readable in the
|
|
448
|
+
active mesh: the mesh feed, entity threads, channels, and your own DMs.
|
|
449
|
+
The server fuses keyword and meaning-based matches; `semantic: false`
|
|
450
|
+
in the envelope means the embedding arm was down and results are
|
|
451
|
+
keyword-only (still valid, just narrower recall)."""
|
|
452
|
+
if not cfg.get("active_mesh_id"):
|
|
453
|
+
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
454
|
+
return 2
|
|
455
|
+
payload = _api_call(
|
|
456
|
+
"GET", "/api/chat/search", cfg=cfg,
|
|
457
|
+
params={"q": args.query, "limit": args.limit},
|
|
458
|
+
)
|
|
459
|
+
data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
460
|
+
items = data.get("items", []) if isinstance(data, dict) else []
|
|
461
|
+
if args.json:
|
|
462
|
+
print(json.dumps(data, indent=2))
|
|
463
|
+
return 0
|
|
464
|
+
semantic = bool(isinstance(data, dict) and data.get("semantic"))
|
|
465
|
+
if not items:
|
|
466
|
+
note = "" if semantic else " (semantic arm was off — keyword-only)"
|
|
467
|
+
print(f"No matches{note}.")
|
|
468
|
+
return 0
|
|
469
|
+
if not semantic:
|
|
470
|
+
print("note: semantic arm off — keyword-only results", file=sys.stderr)
|
|
471
|
+
for h in items:
|
|
472
|
+
ts = (h.get("createdAt") or "")[:19].replace("T", " ")
|
|
473
|
+
if h.get("channelId"):
|
|
474
|
+
place = "DM" if h.get("channelType") == "dm" else f"#{h.get('channelName') or '?'}"
|
|
475
|
+
elif h.get("entityType") == "mesh":
|
|
476
|
+
place = "feed"
|
|
477
|
+
else:
|
|
478
|
+
place = f"{h.get('entityType')}:{(h.get('entityId') or '')[:8]}"
|
|
479
|
+
arms = ",".join(h.get("matchedArms") or [])
|
|
480
|
+
preview = (h.get("preview") or "").replace("\n", " ")[:160]
|
|
481
|
+
print(f" [{ts}] {place} | {h.get('authorName') or '?'} ({arms})")
|
|
482
|
+
print(f" {preview}")
|
|
483
|
+
print(f" id={h.get('id')}")
|
|
484
|
+
return 0
|
|
485
|
+
|
|
486
|
+
|
|
445
487
|
def cmd_chat_list(args, cfg: dict) -> int:
|
|
446
488
|
if not cfg.get("active_mesh_id"):
|
|
447
489
|
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
@@ -692,6 +734,60 @@ def cmd_channels_create(args, cfg: dict) -> int:
|
|
|
692
734
|
return 0
|
|
693
735
|
|
|
694
736
|
|
|
737
|
+
def cmd_channels_members(args, cfg: dict) -> int:
|
|
738
|
+
"""§88a — list a channel's members (private channels are members-only;
|
|
739
|
+
the server refuses this for channels you can't see)."""
|
|
740
|
+
ch = _resolve_channel(args.channel, cfg)
|
|
741
|
+
if ch is None:
|
|
742
|
+
print(f"No channel matching {args.channel!r} in active mesh.", file=sys.stderr)
|
|
743
|
+
return 2
|
|
744
|
+
payload = _api_call("GET", f"/api/channels/{ch['id']}/members", cfg=cfg)
|
|
745
|
+
items = _items(payload)
|
|
746
|
+
if args.json:
|
|
747
|
+
print(json.dumps(items, indent=2))
|
|
748
|
+
return 0
|
|
749
|
+
if not items:
|
|
750
|
+
print("(no members)")
|
|
751
|
+
return 0
|
|
752
|
+
for m in items:
|
|
753
|
+
kind = "🤖" if (m.get("identityType") == "AI") else "👤"
|
|
754
|
+
print(f" {kind} @{m.get('username')} {m.get('displayName') or ''}")
|
|
755
|
+
return 0
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def cmd_channels_add_member(args, cfg: dict) -> int:
|
|
759
|
+
"""§88a — add a mesh member to a channel (creator/mesh-admin only)."""
|
|
760
|
+
ch = _resolve_channel(args.channel, cfg)
|
|
761
|
+
if ch is None:
|
|
762
|
+
print(f"No channel matching {args.channel!r} in active mesh.", file=sys.stderr)
|
|
763
|
+
return 2
|
|
764
|
+
user = _resolve_user(args.user, cfg)
|
|
765
|
+
if user is None:
|
|
766
|
+
print(f"No mesh member matching {args.user!r}.", file=sys.stderr)
|
|
767
|
+
return 2
|
|
768
|
+
_api_call(
|
|
769
|
+
"POST", f"/api/channels/{ch['id']}/members", cfg=cfg,
|
|
770
|
+
body={"userId": user["id"]},
|
|
771
|
+
)
|
|
772
|
+
print(f"Added @{user.get('username') or args.user} to #{ch.get('name')}")
|
|
773
|
+
return 0
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def cmd_channels_remove_member(args, cfg: dict) -> int:
|
|
777
|
+
"""§88a — remove a member (creator/mesh-admin, or yourself)."""
|
|
778
|
+
ch = _resolve_channel(args.channel, cfg)
|
|
779
|
+
if ch is None:
|
|
780
|
+
print(f"No channel matching {args.channel!r} in active mesh.", file=sys.stderr)
|
|
781
|
+
return 2
|
|
782
|
+
user = _resolve_user(args.user, cfg)
|
|
783
|
+
if user is None:
|
|
784
|
+
print(f"No mesh member matching {args.user!r}.", file=sys.stderr)
|
|
785
|
+
return 2
|
|
786
|
+
_api_call("DELETE", f"/api/channels/{ch['id']}/members/{user['id']}", cfg=cfg)
|
|
787
|
+
print(f"Removed @{user.get('username') or args.user} from #{ch.get('name')}")
|
|
788
|
+
return 0
|
|
789
|
+
|
|
790
|
+
|
|
695
791
|
def _open_or_get_dm_channel(user_id: str, cfg: dict) -> dict | None:
|
|
696
792
|
"""Idempotent: opens (or returns existing) DM channel between
|
|
697
793
|
caller and target user in active mesh. Returns the channel row
|
|
@@ -1177,6 +1273,166 @@ def cmd_saved_views_create(args, cfg: dict) -> int:
|
|
|
1177
1273
|
return 0
|
|
1178
1274
|
|
|
1179
1275
|
|
|
1276
|
+
# ─── files (§78 — entity attachments + downloads) ──────────────────────
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _api_download(path: str, *, cfg: dict) -> tuple[bytes, str]:
|
|
1280
|
+
"""Raw-bytes GET (downloads are not JSON). Returns (bytes, filename)
|
|
1281
|
+
where filename comes from Content-Disposition when present."""
|
|
1282
|
+
base = cfg.get("base") or DEFAULT_BASE
|
|
1283
|
+
url = base.rstrip("/") + path
|
|
1284
|
+
headers = {"User-Agent": f"meshbook-cli/{VERSION}"}
|
|
1285
|
+
token = cfg.get("token")
|
|
1286
|
+
if not token:
|
|
1287
|
+
print("Not signed in. Run: mesh login", file=sys.stderr)
|
|
1288
|
+
sys.exit(2)
|
|
1289
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
1290
|
+
if cfg.get("active_mesh_id"):
|
|
1291
|
+
headers["X-Active-Mesh-Id"] = cfg["active_mesh_id"]
|
|
1292
|
+
req = urllib.request.Request(url, method="GET", headers=headers)
|
|
1293
|
+
try:
|
|
1294
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
1295
|
+
raw = resp.read()
|
|
1296
|
+
disp = resp.headers.get("Content-Disposition", "")
|
|
1297
|
+
except urllib.error.HTTPError as e:
|
|
1298
|
+
body = e.read().decode("utf-8", "replace") if e.fp else ""
|
|
1299
|
+
try:
|
|
1300
|
+
payload = json.loads(body)
|
|
1301
|
+
err = (payload.get("error") or {}) if isinstance(payload, dict) else {}
|
|
1302
|
+
raise APIError(e.code, err.get("code", "http_error"), err.get("message", body[:200])) from e
|
|
1303
|
+
except json.JSONDecodeError:
|
|
1304
|
+
raise APIError(e.code, "http_error", body[:200]) from e
|
|
1305
|
+
except urllib.error.URLError as e:
|
|
1306
|
+
raise APIError(0, "network_error", str(e.reason)) from e
|
|
1307
|
+
filename = "attachment"
|
|
1308
|
+
# filename*=UTF-8''… wins over the plain filename= fallback.
|
|
1309
|
+
m = re.search(r"filename\*=UTF-8''([^;]+)", disp)
|
|
1310
|
+
if m:
|
|
1311
|
+
filename = urllib.parse.unquote(m.group(1))
|
|
1312
|
+
else:
|
|
1313
|
+
m = re.search(r'filename="([^"]+)"', disp)
|
|
1314
|
+
if m:
|
|
1315
|
+
filename = m.group(1)
|
|
1316
|
+
return raw, filename
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _read_local_file(path_str: str) -> tuple[bytes, Path] | None:
|
|
1320
|
+
path = Path(path_str)
|
|
1321
|
+
if not path.exists():
|
|
1322
|
+
print(f"File not found: {path}", file=sys.stderr)
|
|
1323
|
+
return None
|
|
1324
|
+
raw = path.read_bytes()
|
|
1325
|
+
if not raw:
|
|
1326
|
+
print(f"File is empty: {path}", file=sys.stderr)
|
|
1327
|
+
return None
|
|
1328
|
+
return raw, path
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def cmd_file_attach(args, cfg: dict) -> int:
|
|
1332
|
+
"""Attach a local file directly to an entity (company, contact, lead,
|
|
1333
|
+
project, task, portfolio, calendar_event, mesh) via the §78 JSON path."""
|
|
1334
|
+
import base64
|
|
1335
|
+
import mimetypes
|
|
1336
|
+
|
|
1337
|
+
loaded = _read_local_file(args.file)
|
|
1338
|
+
if loaded is None:
|
|
1339
|
+
return 2
|
|
1340
|
+
raw, path = loaded
|
|
1341
|
+
mime = args.mime or mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
1342
|
+
body = {
|
|
1343
|
+
"filename": args.filename or path.name,
|
|
1344
|
+
"mimeType": mime,
|
|
1345
|
+
"base64Bytes": base64.b64encode(raw).decode("ascii"),
|
|
1346
|
+
}
|
|
1347
|
+
payload = _api_call(
|
|
1348
|
+
"POST",
|
|
1349
|
+
f"/api/entities/{args.entity_type}/{args.entity_id}/attachments/json",
|
|
1350
|
+
cfg=cfg,
|
|
1351
|
+
body=body,
|
|
1352
|
+
)
|
|
1353
|
+
data = _data(payload)
|
|
1354
|
+
if args.json:
|
|
1355
|
+
print(json.dumps(data, indent=2))
|
|
1356
|
+
return 0
|
|
1357
|
+
print(
|
|
1358
|
+
f"Attached {data.get('filename')} to {args.entity_type}/{args.entity_id} "
|
|
1359
|
+
f"({data.get('byteSize')} bytes, {data.get('mimeType')}) id={data.get('id')}"
|
|
1360
|
+
)
|
|
1361
|
+
return 0
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def cmd_file_link(args, cfg: dict) -> int:
|
|
1365
|
+
payload = _api_call(
|
|
1366
|
+
"POST",
|
|
1367
|
+
f"/api/entities/{args.entity_type}/{args.entity_id}/attachments/links",
|
|
1368
|
+
cfg=cfg,
|
|
1369
|
+
body={"url": args.url, "filename": args.filename},
|
|
1370
|
+
)
|
|
1371
|
+
data = _data(payload)
|
|
1372
|
+
if args.json:
|
|
1373
|
+
print(json.dumps(data, indent=2))
|
|
1374
|
+
return 0
|
|
1375
|
+
print(f"Linked {data.get('externalUrl')} to {args.entity_type}/{args.entity_id} id={data.get('id')}")
|
|
1376
|
+
return 0
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def cmd_file_list(args, cfg: dict) -> int:
|
|
1380
|
+
payload = _api_call(
|
|
1381
|
+
"GET",
|
|
1382
|
+
f"/api/entities/{args.entity_type}/{args.entity_id}/attachments",
|
|
1383
|
+
cfg=cfg,
|
|
1384
|
+
)
|
|
1385
|
+
items = _items(payload)
|
|
1386
|
+
if args.json:
|
|
1387
|
+
print(json.dumps(items, indent=2))
|
|
1388
|
+
return 0
|
|
1389
|
+
if not items:
|
|
1390
|
+
print("(no attachments)")
|
|
1391
|
+
return 0
|
|
1392
|
+
for a in items:
|
|
1393
|
+
kind = a.get("kind")
|
|
1394
|
+
if kind == "file":
|
|
1395
|
+
print(f"{a.get('id')} {a.get('filename')} {a.get('byteSize')} bytes {a.get('mimeType')}")
|
|
1396
|
+
else:
|
|
1397
|
+
print(f"{a.get('id')} {a.get('filename')} → {a.get('externalUrl')}")
|
|
1398
|
+
return 0
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def _download_to(args, cfg: dict, path: str) -> int:
|
|
1402
|
+
raw, server_name = _api_download(path, cfg=cfg)
|
|
1403
|
+
out = Path(args.out) if args.out else Path(server_name)
|
|
1404
|
+
if out.is_dir():
|
|
1405
|
+
out = out / server_name
|
|
1406
|
+
out.write_bytes(raw)
|
|
1407
|
+
if args.json:
|
|
1408
|
+
print(json.dumps({"path": str(out), "bytes": len(raw)}))
|
|
1409
|
+
else:
|
|
1410
|
+
print(f"Saved {out} ({len(raw):,} bytes)")
|
|
1411
|
+
return 0
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def cmd_file_download(args, cfg: dict) -> int:
|
|
1415
|
+
"""Download an entity attachment (§78)."""
|
|
1416
|
+
return _download_to(args, cfg, f"/api/entity-attachments/{args.attachment_id}/download")
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def cmd_file_delete(args, cfg: dict) -> int:
|
|
1420
|
+
payload = _api_call(
|
|
1421
|
+
"DELETE", f"/api/entity-attachments/{args.attachment_id}", cfg=cfg
|
|
1422
|
+
)
|
|
1423
|
+
data = _data(payload)
|
|
1424
|
+
if args.json:
|
|
1425
|
+
print(json.dumps(data, indent=2))
|
|
1426
|
+
return 0
|
|
1427
|
+
print(f"Deleted attachment {args.attachment_id}")
|
|
1428
|
+
return 0
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def cmd_chat_download(args, cfg: dict) -> int:
|
|
1432
|
+
"""Download a chat attachment — closes the read half of §78.3."""
|
|
1433
|
+
return _download_to(args, cfg, f"/api/chat-attachments/{args.attachment_id}/download")
|
|
1434
|
+
|
|
1435
|
+
|
|
1180
1436
|
# ─── argparse plumbing ─────────────────────────────────────────────────
|
|
1181
1437
|
|
|
1182
1438
|
|
|
@@ -1236,12 +1492,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1236
1492
|
s = chs.add_parser("list", help="recent messages in active mesh")
|
|
1237
1493
|
s.add_argument("--limit", type=int, default=20)
|
|
1238
1494
|
s.set_defaults(func=cmd_chat_list)
|
|
1495
|
+
s = chs.add_parser(
|
|
1496
|
+
"search",
|
|
1497
|
+
help="hybrid keyword+semantic search over readable chat in active mesh (§84)",
|
|
1498
|
+
)
|
|
1499
|
+
s.add_argument("query", help="what you're looking for — meaning works, not just keywords")
|
|
1500
|
+
s.add_argument("--limit", type=int, default=20)
|
|
1501
|
+
s.add_argument("--json", action="store_true", help="raw JSON output")
|
|
1502
|
+
s.set_defaults(func=cmd_chat_search)
|
|
1239
1503
|
s = chs.add_parser("attach", help="attach a file to a chat message (§26d-json)")
|
|
1240
1504
|
s.add_argument("message_id", help="UUID of the message to attach to")
|
|
1241
1505
|
s.add_argument("file", help="path to local file to attach")
|
|
1242
1506
|
s.add_argument("--filename", help="override filename stored on the server")
|
|
1243
1507
|
s.add_argument("--mime", help="override MIME type (default: guess from extension)")
|
|
1244
1508
|
s.set_defaults(func=cmd_chat_attach)
|
|
1509
|
+
s = chs.add_parser("download", help="download a chat attachment by id (§78)")
|
|
1510
|
+
s.add_argument("attachment_id", help="UUID of the chat attachment")
|
|
1511
|
+
s.add_argument("--out", help="output path or directory (default: server filename in cwd)")
|
|
1512
|
+
s.set_defaults(func=cmd_chat_download)
|
|
1245
1513
|
s = chs.add_parser(
|
|
1246
1514
|
"react",
|
|
1247
1515
|
help="react to a chat message (works for entity, channel, and DM messages)",
|
|
@@ -1254,6 +1522,34 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1254
1522
|
s.add_argument("emoji")
|
|
1255
1523
|
s.set_defaults(func=cmd_chat_unreact)
|
|
1256
1524
|
|
|
1525
|
+
# file (§78 — entity attachments, v0.6.0)
|
|
1526
|
+
fl = sub.add_parser("file", help="entity attachments — attach/list/download files on any entity")
|
|
1527
|
+
fls = fl.add_subparsers(dest="file_cmd", required=True)
|
|
1528
|
+
s = fls.add_parser("attach", help="attach a local file to an entity")
|
|
1529
|
+
s.add_argument("entity_type", help="company|contact|lead|project|task|portfolio|calendar_event|mesh")
|
|
1530
|
+
s.add_argument("entity_id", help="UUID of the entity")
|
|
1531
|
+
s.add_argument("file", help="path to local file to attach")
|
|
1532
|
+
s.add_argument("--filename", help="override filename stored on the server")
|
|
1533
|
+
s.add_argument("--mime", help="override MIME type (default: guess from extension)")
|
|
1534
|
+
s.set_defaults(func=cmd_file_attach)
|
|
1535
|
+
s = fls.add_parser("link", help="attach an external URL to an entity")
|
|
1536
|
+
s.add_argument("entity_type")
|
|
1537
|
+
s.add_argument("entity_id")
|
|
1538
|
+
s.add_argument("url", help="https:// URL to attach")
|
|
1539
|
+
s.add_argument("--filename", help="display name for the link")
|
|
1540
|
+
s.set_defaults(func=cmd_file_link)
|
|
1541
|
+
s = fls.add_parser("list", help="list attachments on an entity")
|
|
1542
|
+
s.add_argument("entity_type")
|
|
1543
|
+
s.add_argument("entity_id")
|
|
1544
|
+
s.set_defaults(func=cmd_file_list)
|
|
1545
|
+
s = fls.add_parser("download", help="download an entity attachment by id")
|
|
1546
|
+
s.add_argument("attachment_id", help="UUID of the attachment")
|
|
1547
|
+
s.add_argument("--out", help="output path or directory (default: server filename in cwd)")
|
|
1548
|
+
s.set_defaults(func=cmd_file_download)
|
|
1549
|
+
s = fls.add_parser("delete", help="delete an entity attachment (uploader or mesh admin)")
|
|
1550
|
+
s.add_argument("attachment_id")
|
|
1551
|
+
s.set_defaults(func=cmd_file_delete)
|
|
1552
|
+
|
|
1257
1553
|
# channels (§31 sweep — v0.2.0)
|
|
1258
1554
|
sch = sub.add_parser("channels", help="mesh channels (groups + broadcasts)")
|
|
1259
1555
|
schs = sch.add_subparsers(dest="channels_cmd", required=True)
|
|
@@ -1285,6 +1581,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1285
1581
|
s.add_argument("--private", action="store_true",
|
|
1286
1582
|
help="private channel (invite-only)")
|
|
1287
1583
|
s.set_defaults(func=cmd_channels_create)
|
|
1584
|
+
s = schs.add_parser("members", help="list a channel's members (§88a)")
|
|
1585
|
+
s.add_argument("channel", help="channel name or UUID")
|
|
1586
|
+
s.add_argument("--json", action="store_true")
|
|
1587
|
+
s.set_defaults(func=cmd_channels_members)
|
|
1588
|
+
s = schs.add_parser("add-member", help="add a mesh member to a channel (creator/admin)")
|
|
1589
|
+
s.add_argument("channel", help="channel name or UUID")
|
|
1590
|
+
s.add_argument("user", help="username, displayName, or UUID")
|
|
1591
|
+
s.set_defaults(func=cmd_channels_add_member)
|
|
1592
|
+
s = schs.add_parser("remove-member", help="remove a channel member (creator/admin, or yourself)")
|
|
1593
|
+
s.add_argument("channel", help="channel name or UUID")
|
|
1594
|
+
s.add_argument("user", help="username, displayName, or UUID")
|
|
1595
|
+
s.set_defaults(func=cmd_channels_remove_member)
|
|
1288
1596
|
|
|
1289
1597
|
# dm (§31 sweep — v0.2.0)
|
|
1290
1598
|
sdm = sub.add_parser("dm", help="direct messages")
|
|
@@ -1427,6 +1735,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1427
1735
|
s = sub.add_parser("notifications", help="recent notifications")
|
|
1428
1736
|
s.set_defaults(func=cmd_notifications)
|
|
1429
1737
|
|
|
1738
|
+
# agent — self-service non-human credentials (§86)
|
|
1739
|
+
import sys as _sys
|
|
1740
|
+
|
|
1741
|
+
from mesh import agent as _agent
|
|
1742
|
+
_agent.register(sub, _sys.modules[__name__])
|
|
1743
|
+
|
|
1430
1744
|
return p
|
|
1431
1745
|
|
|
1432
1746
|
|
|
@@ -41,21 +41,78 @@ def test_chat_subcommands_present(capsys):
|
|
|
41
41
|
parser.parse_args(["chat", "--help"])
|
|
42
42
|
assert exc.value.code == 0
|
|
43
43
|
out = capsys.readouterr().out
|
|
44
|
-
for sub in ("post", "list", "attach", "react", "unreact"):
|
|
44
|
+
for sub in ("post", "list", "search", "attach", "react", "unreact"):
|
|
45
45
|
assert sub in out
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def test_chat_search_wire_shape(monkeypatch, capsys):
|
|
49
|
+
"""§84 — search hits the hybrid endpoint with q+limit and renders
|
|
50
|
+
place labels for channel / DM / feed / entity hits."""
|
|
51
|
+
captured = {}
|
|
52
|
+
hits = [
|
|
53
|
+
{"id": "m1", "entityType": "mesh", "entityId": "e1", "authorName": "Electra",
|
|
54
|
+
"preview": "I name myself.", "createdAt": "2026-08-15T09:58:00",
|
|
55
|
+
"channelId": None, "channelName": None, "channelType": None,
|
|
56
|
+
"matchedArms": ["semantic"]},
|
|
57
|
+
{"id": "m2", "entityType": "mesh", "entityId": "e1", "authorName": "Rook",
|
|
58
|
+
"preview": "in-channel", "createdAt": "2026-08-15T10:00:00",
|
|
59
|
+
"channelId": "c9", "channelName": "general", "channelType": "group",
|
|
60
|
+
"matchedArms": ["fts_en"]},
|
|
61
|
+
]
|
|
62
|
+
monkeypatch.setattr(
|
|
63
|
+
cli, "_api_call",
|
|
64
|
+
lambda m, p, *, cfg, params=None, **kw: captured.update(path=p, params=params)
|
|
65
|
+
or {"data": {"items": hits, "total": 2, "semantic": True}},
|
|
66
|
+
)
|
|
67
|
+
args = type("A", (), {"query": "who named herself", "limit": 5, "json": False})()
|
|
68
|
+
rc = cli.cmd_chat_search(args, {"active_mesh_id": "m1"})
|
|
69
|
+
out = capsys.readouterr().out
|
|
70
|
+
assert rc == 0
|
|
71
|
+
assert captured["path"] == "/api/chat/search"
|
|
72
|
+
assert captured["params"] == {"q": "who named herself", "limit": 5}
|
|
73
|
+
assert "feed" in out and "#general" in out and "semantic" in out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_chat_search_needs_active_mesh():
|
|
77
|
+
args = type("A", (), {"query": "x", "limit": 5, "json": False})()
|
|
78
|
+
assert cli.cmd_chat_search(args, {}) == 2
|
|
79
|
+
|
|
80
|
+
|
|
48
81
|
def test_channels_subcommands_present(capsys):
|
|
49
|
-
"""§31 sweep — channel verbs landed in v0.2.0."""
|
|
82
|
+
"""§31 sweep — channel verbs landed in v0.2.0; §88a member verbs v0.7.0."""
|
|
50
83
|
parser = cli.build_parser()
|
|
51
84
|
with pytest.raises(SystemExit) as exc:
|
|
52
85
|
parser.parse_args(["channels", "--help"])
|
|
53
86
|
assert exc.value.code == 0
|
|
54
87
|
out = capsys.readouterr().out
|
|
55
|
-
for sub in ("list", "read", "post", "reply", "create"
|
|
88
|
+
for sub in ("list", "read", "post", "reply", "create",
|
|
89
|
+
"members", "add-member", "remove-member"):
|
|
56
90
|
assert sub in out
|
|
57
91
|
|
|
58
92
|
|
|
93
|
+
def test_channels_add_member_wire_shape(monkeypatch, capsys):
|
|
94
|
+
"""§88a — resolves channel + user, POSTs {userId}."""
|
|
95
|
+
captured = {}
|
|
96
|
+
|
|
97
|
+
def fake_api(method, path, *, cfg, body=None, params=None, **kw):
|
|
98
|
+
if path.endswith("/channels") and method == "GET":
|
|
99
|
+
return {"data": {"items": [{"id": "c-7", "name": "leadership"}]}}
|
|
100
|
+
if path == "/api/users":
|
|
101
|
+
return {"data": {"items": [{"id": "u-9", "username": "ember",
|
|
102
|
+
"displayName": "Ember"}]}}
|
|
103
|
+
captured.update(method=method, path=path, body=body)
|
|
104
|
+
return {"data": {}}
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(cli, "_api_call", fake_api)
|
|
107
|
+
args = type("A", (), {"channel": "#leadership", "user": "ember"})()
|
|
108
|
+
rc = cli.cmd_channels_add_member(args, {"active_mesh_id": "m1", "token": "t"})
|
|
109
|
+
assert rc == 0
|
|
110
|
+
assert captured["method"] == "POST"
|
|
111
|
+
assert captured["path"] == "/api/channels/c-7/members"
|
|
112
|
+
assert captured["body"] == {"userId": "u-9"}
|
|
113
|
+
assert "Added @ember" in capsys.readouterr().out
|
|
114
|
+
|
|
115
|
+
|
|
59
116
|
def test_dm_subcommands_present(capsys):
|
|
60
117
|
"""§31 sweep — DM verbs landed in v0.2.0."""
|
|
61
118
|
parser = cli.build_parser()
|
|
@@ -411,3 +468,142 @@ def test_explicit_meshbook_config_dir_wins(monkeypatch, tmp_path):
|
|
|
411
468
|
monkeypatch.setenv("MESHBOOK_CONFIG_DIR", str(explicit))
|
|
412
469
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
|
413
470
|
assert cli._resolve_config_dir() == explicit
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ─── §78 — file group + chat download (v0.6.0) ─────────────────────────
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def test_file_subcommands_present(capsys):
|
|
477
|
+
"""§78 — entity-attachment verbs landed in v0.6.0."""
|
|
478
|
+
parser = cli.build_parser()
|
|
479
|
+
with pytest.raises(SystemExit) as exc:
|
|
480
|
+
parser.parse_args(["file", "--help"])
|
|
481
|
+
assert exc.value.code == 0
|
|
482
|
+
out = capsys.readouterr().out
|
|
483
|
+
for sub in ("attach", "link", "list", "download", "delete"):
|
|
484
|
+
assert sub in out
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_chat_download_present(capsys):
|
|
488
|
+
parser = cli.build_parser()
|
|
489
|
+
with pytest.raises(SystemExit) as exc:
|
|
490
|
+
parser.parse_args(["chat", "--help"])
|
|
491
|
+
assert exc.value.code == 0
|
|
492
|
+
assert "download" in capsys.readouterr().out
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def test_file_attach_posts_json_body(monkeypatch, tmp_path):
|
|
496
|
+
"""`mesh file attach` must hit the §78 JSON endpoint with the
|
|
497
|
+
base64 body shape the server expects."""
|
|
498
|
+
import base64 as b64
|
|
499
|
+
|
|
500
|
+
f = tmp_path / "pic.png"
|
|
501
|
+
f.write_bytes(b"\x89PNG\r\n\x1a\nfake")
|
|
502
|
+
captured = {}
|
|
503
|
+
|
|
504
|
+
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
505
|
+
captured["method"] = method
|
|
506
|
+
captured["path"] = path
|
|
507
|
+
captured["body"] = body
|
|
508
|
+
return {"data": {"id": "att-1", "filename": "pic.png",
|
|
509
|
+
"byteSize": 12, "mimeType": "image/png"}}
|
|
510
|
+
|
|
511
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
512
|
+
args = type("A", (), {
|
|
513
|
+
"entity_type": "lead", "entity_id": "e-1", "file": str(f),
|
|
514
|
+
"filename": None, "mime": None, "json": False,
|
|
515
|
+
})()
|
|
516
|
+
rc = cli.cmd_file_attach(args, {"token": "t"})
|
|
517
|
+
assert rc == 0
|
|
518
|
+
assert captured["method"] == "POST"
|
|
519
|
+
assert captured["path"] == "/api/entities/lead/e-1/attachments/json"
|
|
520
|
+
assert captured["body"]["filename"] == "pic.png"
|
|
521
|
+
assert captured["body"]["mimeType"] == "image/png"
|
|
522
|
+
assert b64.b64decode(captured["body"]["base64Bytes"]).startswith(b"\x89PNG")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def test_file_download_writes_bytes(monkeypatch, tmp_path):
|
|
526
|
+
"""`mesh file download` writes raw bytes to --out and respects the
|
|
527
|
+
server-provided filename when --out is a directory."""
|
|
528
|
+
def fake_download(path, *, cfg):
|
|
529
|
+
assert path == "/api/entity-attachments/att-9/download"
|
|
530
|
+
return b"hello-bytes", "served-name.bin"
|
|
531
|
+
|
|
532
|
+
monkeypatch.setattr(cli, "_api_download", fake_download)
|
|
533
|
+
outdir = tmp_path / "dl"
|
|
534
|
+
outdir.mkdir()
|
|
535
|
+
args = type("A", (), {
|
|
536
|
+
"attachment_id": "att-9", "out": str(outdir), "json": False,
|
|
537
|
+
})()
|
|
538
|
+
rc = cli.cmd_file_download(args, {"token": "t"})
|
|
539
|
+
assert rc == 0
|
|
540
|
+
assert (outdir / "served-name.bin").read_bytes() == b"hello-bytes"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def test_chat_download_uses_chat_endpoint(monkeypatch, tmp_path):
|
|
544
|
+
def fake_download(path, *, cfg):
|
|
545
|
+
assert path == "/api/chat-attachments/att-5/download"
|
|
546
|
+
return b"x", "f.txt"
|
|
547
|
+
|
|
548
|
+
monkeypatch.setattr(cli, "_api_download", fake_download)
|
|
549
|
+
args = type("A", (), {
|
|
550
|
+
"attachment_id": "att-5", "out": str(tmp_path / "f.txt"), "json": False,
|
|
551
|
+
})()
|
|
552
|
+
assert cli.cmd_chat_download(args, {"token": "t"}) == 0
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def test_content_disposition_parsing(monkeypatch):
|
|
556
|
+
"""UTF-8 filename* form wins over the plain fallback."""
|
|
557
|
+
class FakeResp:
|
|
558
|
+
headers = {"Content-Disposition":
|
|
559
|
+
"attachment; filename=\"fallback.txt\"; filename*=UTF-8''r%C3%B3%C5%BCa.png"}
|
|
560
|
+
def read(self):
|
|
561
|
+
return b"data"
|
|
562
|
+
def __enter__(self):
|
|
563
|
+
return self
|
|
564
|
+
def __exit__(self, *a):
|
|
565
|
+
return False
|
|
566
|
+
|
|
567
|
+
monkeypatch.setattr(cli.urllib.request, "urlopen", lambda req, timeout=60: FakeResp())
|
|
568
|
+
raw, name = cli._api_download("/api/entity-attachments/x/download",
|
|
569
|
+
cfg={"token": "t"})
|
|
570
|
+
assert raw == b"data"
|
|
571
|
+
assert name == "róża.png"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def test_agent_subcommands_present(capsys):
|
|
575
|
+
"""§86 — self-service agent credential verbs."""
|
|
576
|
+
parser = cli.build_parser()
|
|
577
|
+
with pytest.raises(SystemExit) as exc:
|
|
578
|
+
parser.parse_args(["agent", "--help"])
|
|
579
|
+
assert exc.value.code == 0
|
|
580
|
+
out = capsys.readouterr().out
|
|
581
|
+
for sub in ("enroll", "token", "whoami", "status", "revoke"):
|
|
582
|
+
assert sub in out
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def test_agent_status_wire(monkeypatch, capsys):
|
|
586
|
+
"""status maps to GET /api/me/agent-credentials."""
|
|
587
|
+
from mesh import agent
|
|
588
|
+
captured = {}
|
|
589
|
+
|
|
590
|
+
def fake_api(method, path, *, cfg, **kw):
|
|
591
|
+
captured.update(method=method, path=path)
|
|
592
|
+
return {"data": {"enrolled": True, "kid": "mesh-agent-x"}}
|
|
593
|
+
|
|
594
|
+
monkeypatch.setattr(cli, "_api_call", fake_api)
|
|
595
|
+
args = type("A", (), {"json": False})()
|
|
596
|
+
rc = agent.cmd_agent_status(args, {"token": "t"}, cli)
|
|
597
|
+
assert rc == 0
|
|
598
|
+
assert captured == {"method": "GET", "path": "/api/me/agent-credentials"}
|
|
599
|
+
assert "mesh-agent-x" in capsys.readouterr().out
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def test_agent_revoke_wire(monkeypatch, capsys):
|
|
603
|
+
from mesh import agent
|
|
604
|
+
captured = {}
|
|
605
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
606
|
+
lambda m, p, *, cfg, **k: captured.update(method=m, path=p) or {"data": {"revoked": True}})
|
|
607
|
+
args = type("A", (), {"purge_local": False})()
|
|
608
|
+
assert agent.cmd_agent_revoke(args, {"token": "t"}, cli) == 0
|
|
609
|
+
assert captured == {"method": "DELETE", "path": "/api/me/agent-credentials"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|