lmgram-cli 0.1.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.
lmgram_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """LMGram command line interface."""
2
+
3
+ __version__ = "0.1.0"
lmgram_cli/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
lmgram_cli/api.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+ from urllib.error import HTTPError, URLError
6
+ from urllib.parse import urlencode
7
+ from urllib.request import Request, urlopen
8
+
9
+ from .errors import ApiError, NetworkError
10
+
11
+
12
+ class ApiClient:
13
+ def __init__(self, base_url: str, token: str | None = None, timeout: int = 30, verbose: bool = False):
14
+ self.base_url = base_url.rstrip("/")
15
+ self.token = token
16
+ self.timeout = timeout
17
+ self.verbose = verbose
18
+
19
+ def request(
20
+ self,
21
+ method: str,
22
+ path: str,
23
+ body: dict[str, Any] | None = None,
24
+ query: dict[str, Any] | None = None,
25
+ auth: bool = True,
26
+ ) -> Any:
27
+ url = self._url(path, query)
28
+ data = None if body is None else json.dumps(body).encode("utf-8")
29
+ headers = {"Accept": "application/json", "User-Agent": "lmgram-cli/0.1.0"}
30
+ if body is not None:
31
+ headers["Content-Type"] = "application/json"
32
+ if auth and self.token:
33
+ headers["Authorization"] = f"Bearer {self.token}"
34
+
35
+ request = Request(url, data=data, method=method.upper(), headers=headers)
36
+ try:
37
+ with urlopen(request, timeout=self.timeout) as response:
38
+ raw = response.read()
39
+ if not raw:
40
+ return None
41
+ content_type = response.headers.get("Content-Type", "")
42
+ if "json" not in content_type.lower():
43
+ return raw.decode("utf-8")
44
+ return json.loads(raw.decode("utf-8"))
45
+ except HTTPError as exc:
46
+ payload = self._read_error(exc)
47
+ message = self._message_from_payload(payload) or f"LMGram API returned HTTP {exc.code}."
48
+ raise ApiError(exc.code, message, payload) from exc
49
+ except URLError as exc:
50
+ raise NetworkError(str(exc.reason)) from exc
51
+ except TimeoutError as exc:
52
+ raise NetworkError("Request timed out.") from exc
53
+
54
+ def get(self, path: str, query: dict[str, Any] | None = None) -> Any:
55
+ return self.request("GET", path, query=query)
56
+
57
+ def post(self, path: str, body: dict[str, Any] | None = None, auth: bool = True) -> Any:
58
+ return self.request("POST", path, body=body or {}, auth=auth)
59
+
60
+ def patch(self, path: str, body: dict[str, Any] | None = None) -> Any:
61
+ return self.request("PATCH", path, body=body or {})
62
+
63
+ def delete(self, path: str) -> Any:
64
+ return self.request("DELETE", path)
65
+
66
+ def _url(self, path: str, query: dict[str, Any] | None) -> str:
67
+ clean_path = path if path.startswith("/") else f"/{path}"
68
+ url = f"{self.base_url}{clean_path}"
69
+ if query:
70
+ cleaned = {key: value for key, value in query.items() if value is not None}
71
+ if cleaned:
72
+ url = f"{url}?{urlencode(cleaned, doseq=True)}"
73
+ return url
74
+
75
+ @staticmethod
76
+ def _read_error(exc: HTTPError) -> Any:
77
+ raw = exc.read()
78
+ if not raw:
79
+ return None
80
+ try:
81
+ return json.loads(raw.decode("utf-8"))
82
+ except json.JSONDecodeError:
83
+ return raw.decode("utf-8", errors="replace")
84
+
85
+ @staticmethod
86
+ def _message_from_payload(payload: Any) -> str | None:
87
+ if isinstance(payload, dict):
88
+ error = payload.get("error")
89
+ if isinstance(error, dict):
90
+ return str(error.get("message") or error.get("code") or "")
91
+ if error:
92
+ return str(error)
93
+ if payload.get("message"):
94
+ return str(payload["message"])
95
+ if isinstance(payload, str):
96
+ return payload
97
+ return None
lmgram_cli/cli.py ADDED
@@ -0,0 +1,496 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import socket
6
+ import sys
7
+ import time
8
+ import webbrowser
9
+ from datetime import datetime, timedelta, timezone
10
+ from typing import Any
11
+
12
+ from . import __version__
13
+ from .api import ApiClient
14
+ from .config import DEFAULT_API_URL, DEFAULT_PROFILE, ConfigStore, StoredProfile, token_from_env
15
+ from .duration import expires_at
16
+ from .errors import EXIT_AUTH_REQUIRED, EXIT_CANCELLED, EXIT_VALIDATION, ApiError, LmgramError
17
+ from .output import compact_id, emit_json, error_payload, human_error
18
+
19
+
20
+ DEFAULT_SCOPES = [
21
+ "profile:read",
22
+ "intents:read",
23
+ "intents:write",
24
+ "matches:read",
25
+ "matches:respond",
26
+ "requests:read",
27
+ "requests:respond",
28
+ "chats:write",
29
+ ]
30
+
31
+ MODE_TO_MATCH_TYPE = {
32
+ "peer_advice": "peer",
33
+ "provider_search": "helper",
34
+ "collaboration": "collaborator",
35
+ "hiring": "helper",
36
+ "feedback": "peer",
37
+ "customer_discovery": "peer",
38
+ "technical_help": "helper",
39
+ "social": "peer",
40
+ "other": "peer",
41
+ }
42
+
43
+
44
+ def build_parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(prog="lmgram", description="LMGram CLI for agents and developers.")
46
+ parser.add_argument("--version", action="version", version=f"lmgram {__version__}")
47
+ parser.add_argument("--json", action="store_true", help="Output JSON only.")
48
+ parser.add_argument("--api-url", default=os.environ.get("LMGRAM_API_URL"), help="Override LMGram API base URL.")
49
+ parser.add_argument("--profile", default=os.environ.get("LMGRAM_PROFILE", DEFAULT_PROFILE), help="Use a named profile.")
50
+ parser.add_argument("--yes", action="store_true", help="Skip confirmation prompts where allowed.")
51
+ parser.add_argument("--no-color", action="store_true", help="Reserved for compatibility.")
52
+ parser.add_argument("--verbose", action="store_true", help="Show debug details.")
53
+
54
+ subparsers = parser.add_subparsers(dest="command")
55
+
56
+ login = subparsers.add_parser("login", help="Connect this CLI to LMGram.")
57
+ login.add_argument("--scopes", help="Space-separated scopes.")
58
+ login.add_argument("--device-name", default=socket.gethostname(), help="Device name shown on approval screen.")
59
+ login.add_argument("--client-name", default="lmgram-cli", help="Client name shown on approval screen.")
60
+ login.add_argument("--no-open", action="store_true", help="Do not open the approval URL in a browser.")
61
+ login.set_defaults(handler=cmd_login)
62
+
63
+ logout = subparsers.add_parser("logout", help="Revoke and clear local credentials.")
64
+ logout.set_defaults(handler=cmd_logout)
65
+
66
+ whoami = subparsers.add_parser("whoami", help="Show current LMGram account.")
67
+ whoami.set_defaults(handler=cmd_whoami)
68
+
69
+ intent = subparsers.add_parser("intent", help="Create and manage intents.")
70
+ intent_sub = intent.add_subparsers(dest="intent_command")
71
+
72
+ intent_create = intent_sub.add_parser("create", help="Create an intent.")
73
+ intent_create.add_argument("text")
74
+ intent_create.add_argument("--context")
75
+ intent_create.add_argument("--mode", default="peer_advice", choices=sorted(MODE_TO_MATCH_TYPE))
76
+ intent_create.add_argument("--tag", action="append", default=[])
77
+ intent_create.add_argument("--expires", default="72h")
78
+ intent_create.add_argument("--location")
79
+ intent_create.add_argument("--project")
80
+ intent_create.add_argument("--agent", default="lmgram-cli")
81
+ intent_create.add_argument("--language")
82
+ intent_create.add_argument("--max-candidates", type=int, default=5)
83
+ intent_create.set_defaults(handler=cmd_intent_create)
84
+
85
+ intent_list = intent_sub.add_parser("list", help="List intents.")
86
+ intent_list.add_argument("--active", action="store_true")
87
+ intent_list.add_argument("--closed", action="store_true")
88
+ intent_list.set_defaults(handler=cmd_intent_list)
89
+
90
+ intent_show = intent_sub.add_parser("show", help="Show an intent.")
91
+ intent_show.add_argument("intent_id")
92
+ intent_show.set_defaults(handler=cmd_intent_show)
93
+
94
+ intent_close = intent_sub.add_parser("close", help="Close an intent.")
95
+ intent_close.add_argument("intent_id")
96
+ intent_close.set_defaults(handler=cmd_intent_close)
97
+
98
+ intent_update = intent_sub.add_parser("update", help="Update an intent.")
99
+ intent_update.add_argument("intent_id")
100
+ intent_update.add_argument("--context")
101
+ intent_update.add_argument("--expires")
102
+ intent_update.set_defaults(handler=cmd_intent_update)
103
+
104
+ matches = subparsers.add_parser("matches", help="Read and respond to matches.")
105
+ matches_sub = matches.add_subparsers(dest="matches_command")
106
+
107
+ matches_list = matches_sub.add_parser("list", help="List matches.")
108
+ matches_list.add_argument("--intent")
109
+ matches_list.add_argument("--status")
110
+ matches_list.set_defaults(handler=cmd_matches_list)
111
+
112
+ matches_show = matches_sub.add_parser("show", help="Show a match.")
113
+ matches_show.add_argument("match_id")
114
+ matches_show.set_defaults(handler=cmd_matches_show)
115
+
116
+ matches_accept = matches_sub.add_parser("accept", help="Accept a match.")
117
+ matches_accept.add_argument("match_id")
118
+ matches_accept.add_argument("--message")
119
+ matches_accept.set_defaults(handler=cmd_matches_accept)
120
+
121
+ matches_decline = matches_sub.add_parser("decline", help="Decline a match.")
122
+ matches_decline.add_argument("match_id")
123
+ matches_decline.add_argument("--reason")
124
+ matches_decline.set_defaults(handler=cmd_matches_decline)
125
+
126
+ return parser
127
+
128
+
129
+ def main(argv: list[str] | None = None) -> int:
130
+ parser = build_parser()
131
+ args = parser.parse_args(argv)
132
+ if not hasattr(args, "handler"):
133
+ parser.print_help()
134
+ return 0
135
+
136
+ try:
137
+ return int(args.handler(args) or 0)
138
+ except LmgramError as exc:
139
+ if args.json:
140
+ emit_json(error_payload(exc.code, exc.message))
141
+ else:
142
+ human_error(exc.message)
143
+ return exc.exit_code
144
+
145
+
146
+ def client_for(args: argparse.Namespace, require_auth: bool = True) -> tuple[ApiClient, ConfigStore, StoredProfile]:
147
+ store = ConfigStore()
148
+ profile = store.get_profile(args.profile)
149
+ api_url = (args.api_url or profile.api_base_url or DEFAULT_API_URL).rstrip("/")
150
+ token = token_from_env() or profile.access_token
151
+ if require_auth and not token:
152
+ raise LmgramError("AUTH_REQUIRED", "Run lmgram login to connect this CLI to your LMGram account.", EXIT_AUTH_REQUIRED)
153
+ return ApiClient(api_url, token=token, verbose=args.verbose), store, profile
154
+
155
+
156
+ def cmd_login(args: argparse.Namespace) -> int:
157
+ api_url = (args.api_url or DEFAULT_API_URL).rstrip("/")
158
+ client = ApiClient(api_url, verbose=args.verbose)
159
+ scopes = args.scopes.split() if args.scopes else DEFAULT_SCOPES
160
+ device = client.post(
161
+ "/api/cli/oauth/device-code",
162
+ {
163
+ "client_name": args.client_name,
164
+ "device_name": args.device_name,
165
+ "requested_scopes": scopes,
166
+ },
167
+ auth=False,
168
+ )
169
+
170
+ if args.json:
171
+ emit_json({"ok": True, "device": device})
172
+ else:
173
+ print("To connect this agent to LMGram:")
174
+ print(f"1. Open {device.get('verification_uri')}")
175
+ print(f"2. Enter code: {device.get('user_code')}")
176
+ if device.get("verification_uri_complete"):
177
+ print()
178
+ print(device["verification_uri_complete"])
179
+ if not args.no_open:
180
+ webbrowser.open(device["verification_uri_complete"])
181
+ print()
182
+ print("Waiting for approval...")
183
+
184
+ deadline = time.monotonic() + int(device.get("expires_in") or 600)
185
+ interval = max(int(device.get("interval") or 5), 1)
186
+ while time.monotonic() < deadline:
187
+ time.sleep(interval)
188
+ try:
189
+ token = client.post(
190
+ "/api/cli/oauth/token",
191
+ {
192
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
193
+ "device_code": device.get("device_code"),
194
+ },
195
+ auth=False,
196
+ )
197
+ return save_login(args, api_url, token)
198
+ except ApiError as exc:
199
+ api_code = ""
200
+ if isinstance(exc.payload, dict):
201
+ api_code = str(exc.payload.get("error") or "")
202
+ if api_code == "authorization_pending":
203
+ continue
204
+ if api_code in {"authorization_denied", "access_denied"}:
205
+ raise LmgramError("AUTHORIZATION_DENIED", "The LMGram connection was denied.", EXIT_CANCELLED) from exc
206
+ raise
207
+ raise LmgramError("AUTHORIZATION_EXPIRED", "The LMGram connection code expired.", EXIT_CANCELLED)
208
+
209
+
210
+ def save_login(args: argparse.Namespace, api_url: str, token: dict[str, Any]) -> int:
211
+ expires = None
212
+ if token.get("expires_in"):
213
+ expires = (datetime.now(timezone.utc) + timedelta(seconds=int(token["expires_in"]))).replace(microsecond=0).isoformat().replace("+00:00", "Z")
214
+
215
+ profile = StoredProfile(
216
+ api_base_url=api_url,
217
+ access_token=token.get("access_token"),
218
+ refresh_token=token.get("refresh_token"),
219
+ token_type=token.get("token_type") or "Bearer",
220
+ expires_at=expires,
221
+ scopes=str(token.get("scope") or "").split(),
222
+ )
223
+ auth_client = ApiClient(api_url, token=profile.access_token, verbose=args.verbose)
224
+ try:
225
+ me = get_me(auth_client)
226
+ profile.user_id = me.get("id") or me.get("user_id")
227
+ profile.account_email = me.get("email")
228
+ except LmgramError:
229
+ me = None
230
+
231
+ ConfigStore().save_profile(args.profile, profile)
232
+ if args.json:
233
+ emit_json({"ok": True, "profile": profile.to_dict(), "me": me})
234
+ else:
235
+ name = me.get("displayName") or me.get("name") if isinstance(me, dict) else "LMGram"
236
+ print(f"Logged in as {name}.")
237
+ return 0
238
+
239
+
240
+ def cmd_logout(args: argparse.Namespace) -> int:
241
+ client, store, _profile = client_for(args, require_auth=False)
242
+ try:
243
+ if client.token:
244
+ client.post("/api/cli/oauth/revoke", {})
245
+ except LmgramError:
246
+ pass
247
+ store.clear_profile(args.profile)
248
+ if args.json:
249
+ emit_json({"ok": True})
250
+ else:
251
+ print("Logged out.")
252
+ return 0
253
+
254
+
255
+ def cmd_whoami(args: argparse.Namespace) -> int:
256
+ client, _store, _profile = client_for(args)
257
+ me = get_me(client)
258
+ if args.json:
259
+ emit_json({"ok": True, "user": me})
260
+ else:
261
+ display = me.get("displayName") or me.get("name") or me.get("id") or me.get("user_id")
262
+ handle = me.get("publicId") or me.get("handle")
263
+ print(f"Logged in as {display}{f' (@{handle})' if handle else ''}")
264
+ if me.get("email"):
265
+ print(f"Email: {me['email']}")
266
+ scopes = me.get("scopes")
267
+ if scopes:
268
+ print(f"Scopes: {', '.join(scopes)}")
269
+ return 0
270
+
271
+
272
+ def get_me(client: ApiClient) -> dict[str, Any]:
273
+ try:
274
+ return client.get("/api/cli/me")
275
+ except ApiError as exc:
276
+ if exc.status == 404:
277
+ return client.get("/api/me")
278
+ raise
279
+
280
+
281
+ def cmd_intent_create(args: argparse.Namespace) -> int:
282
+ client, _store, _profile = client_for(args)
283
+ expires = expires_at(args.expires)
284
+ body = {
285
+ "text": args.text,
286
+ "context": args.context,
287
+ "visibility": "lmgram_authenticated_users",
288
+ "source": "cli",
289
+ "source_context": {"tool": args.agent, "project_name": args.project},
290
+ "expires_at": expires,
291
+ "tags": args.tag,
292
+ "mode": args.mode,
293
+ "location": args.location,
294
+ }
295
+ try:
296
+ result = client.post("/api/cli/intents", body)
297
+ except ApiError as exc:
298
+ if exc.status not in {404, 405}:
299
+ raise
300
+ result = legacy_create_intent(client, args)
301
+ result = {"fallback": "session-match", **result}
302
+
303
+ if args.json:
304
+ emit_json({"ok": True, "result": result})
305
+ else:
306
+ intent = result.get("intent") or result.get("session") or result
307
+ print(f"Intent created: {compact_id(intent, 'id', 'sessionId')}")
308
+ print(f"Text: {args.text}")
309
+ print(f"Expires: {intent.get('expiresAt') or intent.get('expires_at') or expires}")
310
+ print()
311
+ print(f"Run: lmgram matches list --intent {compact_id(intent, 'id', 'sessionId')}")
312
+ return 0
313
+
314
+
315
+ def legacy_create_intent(client: ApiClient, args: argparse.Namespace) -> dict[str, Any]:
316
+ return client.post(
317
+ "/api/llm/session-match",
318
+ {
319
+ "provider": args.agent or "lmgram-cli",
320
+ "topic": args.text[:96],
321
+ "summary": args.context or args.text,
322
+ "need": args.text,
323
+ "matchType": MODE_TO_MATCH_TYPE.get(args.mode, "peer"),
324
+ "urgency": 0.5,
325
+ "sensitivity": "normal",
326
+ "language": args.language,
327
+ "maxCandidates": args.max_candidates,
328
+ },
329
+ )
330
+
331
+
332
+ def cmd_intent_list(args: argparse.Namespace) -> int:
333
+ client, _store, _profile = client_for(args)
334
+ query = {"active": args.active or None, "closed": args.closed or None}
335
+ try:
336
+ result = client.get("/api/cli/intents", query=query)
337
+ except ApiError as exc:
338
+ if exc.status != 404:
339
+ raise
340
+ me = get_me(client)
341
+ user_id = me.get("id") or me.get("user_id")
342
+ if not user_id:
343
+ raise LmgramError("AUTH_REQUIRED", "Current account did not include a user id.", EXIT_AUTH_REQUIRED) from exc
344
+ result = client.get(f"/api/users/{user_id}/search-history")
345
+
346
+ if args.json:
347
+ emit_json({"ok": True, "intents": result})
348
+ else:
349
+ items = result if isinstance(result, list) else result.get("intents", [])
350
+ if not items:
351
+ print("No intents found.")
352
+ return 0
353
+ for item in items:
354
+ print(f"{compact_id(item, 'id', 'sessionId')} {item.get('topic') or item.get('text') or item.get('need')}")
355
+ return 0
356
+
357
+
358
+ def cmd_intent_show(args: argparse.Namespace) -> int:
359
+ client, _store, _profile = client_for(args)
360
+ result = client.get(f"/api/cli/intents/{args.intent_id}")
361
+ if args.json:
362
+ emit_json({"ok": True, "intent": result})
363
+ else:
364
+ print_record(result)
365
+ return 0
366
+
367
+
368
+ def cmd_intent_close(args: argparse.Namespace) -> int:
369
+ client, _store, _profile = client_for(args)
370
+ result = client.post(f"/api/cli/intents/{args.intent_id}/close")
371
+ if args.json:
372
+ emit_json({"ok": True, "intent": result})
373
+ else:
374
+ print(f"Intent closed: {args.intent_id}")
375
+ return 0
376
+
377
+
378
+ def cmd_intent_update(args: argparse.Namespace) -> int:
379
+ client, _store, _profile = client_for(args)
380
+ body: dict[str, Any] = {}
381
+ if args.context:
382
+ body["context"] = args.context
383
+ if args.expires:
384
+ body["expires_at"] = expires_at(args.expires)
385
+ if not body:
386
+ raise LmgramError("VALIDATION_ERROR", "Provide at least one field to update.", EXIT_VALIDATION)
387
+ result = client.patch(f"/api/cli/intents/{args.intent_id}", body)
388
+ if args.json:
389
+ emit_json({"ok": True, "intent": result})
390
+ else:
391
+ print(f"Intent updated: {args.intent_id}")
392
+ return 0
393
+
394
+
395
+ def cmd_matches_list(args: argparse.Namespace) -> int:
396
+ client, _store, _profile = client_for(args)
397
+ query = {"intent": args.intent, "status": args.status}
398
+ try:
399
+ result = client.get("/api/cli/matches", query=query)
400
+ except ApiError as exc:
401
+ if exc.status != 404:
402
+ raise
403
+ me = get_me(client)
404
+ user_id = me.get("id") or me.get("user_id")
405
+ if not user_id:
406
+ raise LmgramError("AUTH_REQUIRED", "Current account did not include a user id.", EXIT_AUTH_REQUIRED) from exc
407
+ candidate = client.get(f"/api/users/{user_id}/match-request-summaries", query={"role": "candidate"})
408
+ requester = client.get(f"/api/users/{user_id}/match-request-summaries", query={"role": "requester"})
409
+ result = candidate + requester
410
+ if args.status:
411
+ result = [item for item in result if str(item.get("status", "")).lower() == args.status.lower()]
412
+ if args.intent:
413
+ result = [item for item in result if item.get("sessionId") == args.intent or item.get("candidateSessionId") == args.intent]
414
+
415
+ if args.json:
416
+ emit_json({"ok": True, "matches": result})
417
+ else:
418
+ items = result if isinstance(result, list) else result.get("matches", [])
419
+ print(f"{len(items)} matches")
420
+ for item in items:
421
+ print()
422
+ print(f"{compact_id(item, 'id', 'match_id')} status={item.get('status', '-')}, score={item.get('score', '-')}")
423
+ other = item.get("candidateDisplayName") or item.get("requesterDisplayName") or item.get("other_user", {}).get("name")
424
+ if other:
425
+ print(f" Person: {other}")
426
+ text = item.get("candidateNeed") or item.get("need") or item.get("other_intent", {}).get("text")
427
+ if text:
428
+ print(f" Intent: {text}")
429
+ if item.get("explanation") or item.get("reason"):
430
+ print(f" Reason: {item.get('explanation') or item.get('reason')}")
431
+ return 0
432
+
433
+
434
+ def cmd_matches_show(args: argparse.Namespace) -> int:
435
+ client, _store, _profile = client_for(args)
436
+ result = client.get(f"/api/cli/matches/{args.match_id}")
437
+ if args.json:
438
+ emit_json({"ok": True, "match": result})
439
+ else:
440
+ print_record(result)
441
+ return 0
442
+
443
+
444
+ def cmd_matches_accept(args: argparse.Namespace) -> int:
445
+ client, _store, _profile = client_for(args)
446
+ body = {"message": args.message, "source": "cli", "agent_generated": bool(args.message)}
447
+ try:
448
+ result = client.post(f"/api/cli/matches/{args.match_id}/accept", body)
449
+ except ApiError as exc:
450
+ if exc.status != 404:
451
+ raise
452
+ result = client.post(f"/api/match-requests/{args.match_id}/accept", {})
453
+ if args.message:
454
+ result = {"match": result, "warning": "Legacy API accepted the match but did not send the message."}
455
+
456
+ if args.json:
457
+ emit_json({"ok": True, "result": result})
458
+ else:
459
+ print(f"Match accepted: {args.match_id}")
460
+ return 0
461
+
462
+
463
+ def cmd_matches_decline(args: argparse.Namespace) -> int:
464
+ client, _store, _profile = client_for(args)
465
+ body = {"reason": args.reason}
466
+ try:
467
+ result = client.post(f"/api/cli/matches/{args.match_id}/decline", body)
468
+ except ApiError as exc:
469
+ if exc.status != 404:
470
+ raise
471
+ result = client.post(f"/api/match-requests/{args.match_id}/decline", {})
472
+ if args.json:
473
+ emit_json({"ok": True, "result": result})
474
+ else:
475
+ print(f"Match declined: {args.match_id}")
476
+ return 0
477
+
478
+
479
+ def print_record(value: Any, indent: int = 0) -> None:
480
+ prefix = " " * indent
481
+ if isinstance(value, dict):
482
+ for key, item in value.items():
483
+ if isinstance(item, (dict, list)):
484
+ print(f"{prefix}{key}:")
485
+ print_record(item, indent + 2)
486
+ else:
487
+ print(f"{prefix}{key}: {item}")
488
+ elif isinstance(value, list):
489
+ for item in value:
490
+ print_record(item, indent)
491
+ else:
492
+ print(f"{prefix}{value}")
493
+
494
+
495
+ if __name__ == "__main__":
496
+ sys.exit(main())
lmgram_cli/config.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ DEFAULT_API_URL = "https://api.lmgram.com"
12
+ DEFAULT_PROFILE = "default"
13
+
14
+
15
+ def config_path() -> Path:
16
+ override = os.environ.get("LMGRAM_CONFIG")
17
+ if override:
18
+ return Path(override).expanduser()
19
+
20
+ system = platform.system().lower()
21
+ if system == "windows":
22
+ root = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
23
+ return Path(root) / "lmgram" / "config.json"
24
+ if system == "darwin":
25
+ return Path.home() / "Library" / "Application Support" / "lmgram" / "config.json"
26
+ return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "lmgram" / "config.json"
27
+
28
+
29
+ @dataclass
30
+ class StoredProfile:
31
+ api_base_url: str = DEFAULT_API_URL
32
+ access_token: str | None = None
33
+ refresh_token: str | None = None
34
+ token_type: str = "Bearer"
35
+ expires_at: str | None = None
36
+ user_id: str | None = None
37
+ account_email: str | None = None
38
+ scopes: list[str] | None = None
39
+
40
+ @classmethod
41
+ def from_dict(cls, value: dict[str, Any]) -> "StoredProfile":
42
+ return cls(
43
+ api_base_url=value.get("api_base_url") or DEFAULT_API_URL,
44
+ access_token=value.get("access_token"),
45
+ refresh_token=value.get("refresh_token"),
46
+ token_type=value.get("token_type") or "Bearer",
47
+ expires_at=value.get("expires_at"),
48
+ user_id=value.get("user_id"),
49
+ account_email=value.get("account_email"),
50
+ scopes=list(value.get("scopes") or []),
51
+ )
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ return {
55
+ "api_base_url": self.api_base_url,
56
+ "access_token": self.access_token,
57
+ "refresh_token": self.refresh_token,
58
+ "token_type": self.token_type,
59
+ "expires_at": self.expires_at,
60
+ "user_id": self.user_id,
61
+ "account_email": self.account_email,
62
+ "scopes": self.scopes or [],
63
+ }
64
+
65
+
66
+ class ConfigStore:
67
+ def __init__(self, path: Path | None = None):
68
+ self.path = path or config_path()
69
+
70
+ def read(self) -> dict[str, Any]:
71
+ if not self.path.exists():
72
+ return {"profiles": {}}
73
+ with self.path.open("r", encoding="utf-8") as handle:
74
+ data = json.load(handle)
75
+ if "profiles" not in data:
76
+ data = {"profiles": {DEFAULT_PROFILE: data}}
77
+ return data
78
+
79
+ def write(self, data: dict[str, Any]) -> None:
80
+ self.path.parent.mkdir(parents=True, exist_ok=True)
81
+ with self.path.open("w", encoding="utf-8") as handle:
82
+ json.dump(data, handle, indent=2, sort_keys=True)
83
+ handle.write("\n")
84
+ if os.name == "posix":
85
+ self.path.chmod(0o600)
86
+
87
+ def get_profile(self, name: str) -> StoredProfile:
88
+ data = self.read()
89
+ raw = data.get("profiles", {}).get(name, {})
90
+ return StoredProfile.from_dict(raw)
91
+
92
+ def save_profile(self, name: str, profile: StoredProfile) -> None:
93
+ data = self.read()
94
+ profiles = data.setdefault("profiles", {})
95
+ profiles[name] = profile.to_dict()
96
+ self.write(data)
97
+
98
+ def clear_profile(self, name: str) -> None:
99
+ data = self.read()
100
+ data.setdefault("profiles", {}).pop(name, None)
101
+ self.write(data)
102
+
103
+
104
+ def token_from_env() -> str | None:
105
+ value = os.environ.get("LMGRAM_ACCESS_TOKEN")
106
+ return value.strip() if value and value.strip() else None
lmgram_cli/duration.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ from .errors import EXIT_VALIDATION, LmgramError
7
+
8
+
9
+ _DURATION_RE = re.compile(r"^\s*(\d+)\s*([mhdw])\s*$", re.IGNORECASE)
10
+
11
+
12
+ def parse_duration(value: str) -> timedelta:
13
+ match = _DURATION_RE.match(value)
14
+ if not match:
15
+ raise LmgramError("VALIDATION_ERROR", "Duration must look like 30m, 2h, 7d or 1w.", EXIT_VALIDATION)
16
+
17
+ amount = int(match.group(1))
18
+ unit = match.group(2).lower()
19
+ if amount <= 0:
20
+ raise LmgramError("VALIDATION_ERROR", "Duration must be greater than zero.", EXIT_VALIDATION)
21
+
22
+ if unit == "m":
23
+ return timedelta(minutes=amount)
24
+ if unit == "h":
25
+ return timedelta(hours=amount)
26
+ if unit == "d":
27
+ return timedelta(days=amount)
28
+ return timedelta(weeks=amount)
29
+
30
+
31
+ def expires_at(value: str) -> str:
32
+ return (datetime.now(timezone.utc) + parse_duration(value)).replace(microsecond=0).isoformat().replace("+00:00", "Z")
lmgram_cli/errors.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ EXIT_GENERAL = 1
5
+ EXIT_AUTH_REQUIRED = 2
6
+ EXIT_FORBIDDEN = 3
7
+ EXIT_VALIDATION = 4
8
+ EXIT_NETWORK = 5
9
+ EXIT_NOT_FOUND = 6
10
+ EXIT_CONFLICT = 7
11
+ EXIT_CANCELLED = 8
12
+
13
+
14
+ class LmgramError(Exception):
15
+ def __init__(self, code: str, message: str, exit_code: int = EXIT_GENERAL):
16
+ super().__init__(message)
17
+ self.code = code
18
+ self.message = message
19
+ self.exit_code = exit_code
20
+
21
+
22
+ class ApiError(LmgramError):
23
+ def __init__(self, status: int, message: str, payload: object | None = None):
24
+ code = {
25
+ 400: "VALIDATION_ERROR",
26
+ 401: "AUTH_REQUIRED",
27
+ 403: "FORBIDDEN",
28
+ 404: "NOT_FOUND",
29
+ 409: "CONFLICT",
30
+ }.get(status, "API_ERROR")
31
+ exit_code = {
32
+ 400: EXIT_VALIDATION,
33
+ 401: EXIT_AUTH_REQUIRED,
34
+ 403: EXIT_FORBIDDEN,
35
+ 404: EXIT_NOT_FOUND,
36
+ 409: EXIT_CONFLICT,
37
+ }.get(status, EXIT_GENERAL)
38
+ super().__init__(code, message, exit_code)
39
+ self.status = status
40
+ self.payload = payload
41
+
42
+
43
+ class NetworkError(LmgramError):
44
+ def __init__(self, message: str):
45
+ super().__init__("NETWORK_ERROR", message, EXIT_NETWORK)
lmgram_cli/output.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any
6
+
7
+
8
+ def emit_json(value: Any) -> None:
9
+ print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
10
+
11
+
12
+ def error_payload(code: str, message: str) -> dict[str, Any]:
13
+ return {"ok": False, "error": {"code": code, "message": message}}
14
+
15
+
16
+ def human_error(message: str) -> None:
17
+ print(f"Error: {message}", file=sys.stderr)
18
+
19
+
20
+ def compact_id(value: dict[str, Any], *keys: str) -> str:
21
+ for key in keys:
22
+ item = value.get(key)
23
+ if item:
24
+ return str(item)
25
+ return "-"
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: lmgram-cli
3
+ Version: 0.1.0
4
+ Summary: Command line interface for LMGram agent-driven intent matching.
5
+ Author: LMGram
6
+ Maintainer: LMGram
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://lmgram.com
9
+ Project-URL: Documentation, https://lmgram.com
10
+ Project-URL: Repository, https://github.com/lmgram/lmgram
11
+ Project-URL: Issues, https://github.com/lmgram/lmgram/issues
12
+ Keywords: lmgram,cli,agents,matching,intent
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Information Technology
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Communications
26
+ Classifier: Topic :: Software Development :: User Interfaces
27
+ Requires-Python: >=3.9
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Dynamic: license-file
31
+
32
+ # LMGram CLI
33
+
34
+ Python CLI for LMGram agents and developer workflows.
35
+
36
+ ```bash
37
+ pip install lmgram-cli
38
+ lmgram login
39
+ lmgram whoami
40
+ lmgram intent create "Need advice from someone who passed Google OAuth verification"
41
+ lmgram matches list
42
+ ```
43
+
44
+ The CLI is designed around authenticated LMGram accounts. It never asks for Google credentials. `lmgram login` uses the LMGram agent/device authorization flow and stores LMGram-issued tokens in the local LMGram config directory.
45
+
46
+ ## Configuration
47
+
48
+ Defaults:
49
+
50
+ - API: `https://api.lmgram.com`
51
+ - App approval URL: returned by the API, normally `https://app.lmgram.com/agent-connect`
52
+ - Config path:
53
+ - Windows: `%APPDATA%\lmgram\config.json`
54
+ - macOS: `~/Library/Application Support/lmgram/config.json`
55
+ - Linux: `~/.config/lmgram/config.json`
56
+
57
+ Environment overrides:
58
+
59
+ ```bash
60
+ LMGRAM_API_URL=http://localhost:5000
61
+ LMGRAM_ACCESS_TOKEN=lmg_at_xxx
62
+ LMGRAM_PROFILE=default
63
+ ```
64
+
65
+ Global options:
66
+
67
+ ```bash
68
+ lmgram --json --api-url http://localhost:5000 --profile dev whoami
69
+ ```
70
+
71
+ ## MVP commands
72
+
73
+ ```bash
74
+ lmgram login
75
+ lmgram logout
76
+ lmgram whoami
77
+
78
+ lmgram intent create "Need to find someone who implemented Google OAuth verification"
79
+ lmgram intent list
80
+ lmgram intent show intent_123
81
+ lmgram intent close intent_123
82
+
83
+ lmgram matches list
84
+ lmgram matches show match_123
85
+ lmgram matches accept match_123
86
+ lmgram matches decline match_123
87
+ ```
88
+
89
+ ## Backend compatibility
90
+
91
+ The preferred API surface is `/api/cli/*`, matching `docs/cli-tool-implementation.md`.
92
+
93
+ Until those endpoints are fully implemented, the CLI uses compatible existing endpoints where possible:
94
+
95
+ - `GET /api/me` for `whoami`.
96
+ - `POST /api/llm/session-match` as a fallback for `intent create`.
97
+ - `GET /api/users/{id}/search-history` as a fallback for `intent list`.
98
+ - `GET /api/users/{id}/match-request-summaries` as a fallback for `matches list`.
99
+ - `POST /api/match-requests/{id}/accept` and `/decline` as fallbacks for match responses.
100
+
101
+ Commands with `--json` emit only JSON and use stable error envelopes.
102
+
103
+ ## Build locally
104
+
105
+ ```bash
106
+ cd apps/cli
107
+ python -m pip install -e .
108
+ python -m unittest
109
+ ```
@@ -0,0 +1,14 @@
1
+ lmgram_cli/__init__.py,sha256=41Lq3UMTCf4erhyxDA_maxpoMuKY5qsyvdVWRrXPJzg,60
2
+ lmgram_cli/__main__.py,sha256=pb2bv7Lw6q-BWUue8v6YGW8OXQEZ9V_cSmC9G70FtjE,120
3
+ lmgram_cli/api.py,sha256=HW4-mleuAui3svYGEA7qAy6oW5ftYIPIK-3AniGpzII,3692
4
+ lmgram_cli/cli.py,sha256=ToNA7Ey1ClDg0uwckHaFWv1z6RQ6drUN3IGX5PuBbFg,19049
5
+ lmgram_cli/config.py,sha256=Q5JsJrMdjsF0Ng_suIHwXmX5c-Ty5yEDUXEZdqXC9B0,3551
6
+ lmgram_cli/duration.py,sha256=ItYvslNzSaSzjZxh73cT8BRyIipCXBCsu4SR2rSQ8PM,994
7
+ lmgram_cli/errors.py,sha256=9bT7sGZ4OpLfmmaYMNSc-0gHCLOJFff8-juOx8L2nwI,1227
8
+ lmgram_cli/output.py,sha256=q105qq6gpjpfXOVUfS2pjBYYrV7i5jjNu1LBnYApDKE,586
9
+ lmgram_cli-0.1.0.dist-info/licenses/LICENSE,sha256=gQW8x8MpE0VbOVh3cF7TKMC0xMc3xOaHsWYeyRxwVXg,1063
10
+ lmgram_cli-0.1.0.dist-info/METADATA,sha256=2pli2hnUHplIOq0u__2mQ6KaOB1WyeBl6ywB-mdCaWk,3415
11
+ lmgram_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ lmgram_cli-0.1.0.dist-info/entry_points.txt,sha256=lpQtAY20CUu_oBMcd8JJkbG-piVZmecgxmIZwaOemVo,52
13
+ lmgram_cli-0.1.0.dist-info/top_level.txt,sha256=dXFqHbejd3dGZPzlOIv2m9Ah-WEBaN6hoZtKRozvYQM,11
14
+ lmgram_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lmgram = lmgram_cli.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LMGram
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ lmgram_cli