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 +3 -0
- lmgram_cli/__main__.py +9 -0
- lmgram_cli/api.py +97 -0
- lmgram_cli/cli.py +496 -0
- lmgram_cli/config.py +106 -0
- lmgram_cli/duration.py +32 -0
- lmgram_cli/errors.py +45 -0
- lmgram_cli/output.py +25 -0
- lmgram_cli-0.1.0.dist-info/METADATA +109 -0
- lmgram_cli-0.1.0.dist-info/RECORD +14 -0
- lmgram_cli-0.1.0.dist-info/WHEEL +5 -0
- lmgram_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lmgram_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- lmgram_cli-0.1.0.dist-info/top_level.txt +1 -0
lmgram_cli/__init__.py
ADDED
lmgram_cli/__main__.py
ADDED
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,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
|