meshagent-cli 0.5.18__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of meshagent-cli might be problematic. Click here for more details.

Files changed (47) hide show
  1. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/PKG-INFO +17 -11
  2. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/agent.py +11 -62
  3. meshagent_cli-0.6.1/meshagent/cli/api_keys.py +102 -0
  4. meshagent_cli-0.6.1/meshagent/cli/auth_async.py +295 -0
  5. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/call.py +82 -19
  6. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/chatbot.py +83 -49
  7. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/cli.py +26 -70
  8. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/cli_mcp.py +61 -27
  9. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/cli_secrets.py +1 -1
  10. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/common_options.py +2 -10
  11. meshagent_cli-0.6.1/meshagent/cli/containers.py +577 -0
  12. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/developer.py +7 -25
  13. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/exec.py +162 -76
  14. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/helper.py +35 -67
  15. meshagent_cli-0.6.1/meshagent/cli/helpers.py +131 -0
  16. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/mailbot.py +31 -26
  17. meshagent_cli-0.6.1/meshagent/cli/meeting_transcriber.py +124 -0
  18. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/messaging.py +12 -51
  19. meshagent_cli-0.6.1/meshagent/cli/oauth2.py +189 -0
  20. meshagent_cli-0.6.1/meshagent/cli/participant_token.py +61 -0
  21. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/queue.py +6 -37
  22. meshagent_cli-0.6.1/meshagent/cli/services.py +490 -0
  23. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/storage.py +24 -89
  24. meshagent_cli-0.6.1/meshagent/cli/version.py +1 -0
  25. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/voicebot.py +39 -28
  26. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/webhook.py +3 -3
  27. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent_cli.egg-info/PKG-INFO +17 -11
  28. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent_cli.egg-info/SOURCES.txt +4 -1
  29. meshagent_cli-0.6.1/meshagent_cli.egg-info/requires.txt +23 -0
  30. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/pyproject.toml +22 -10
  31. meshagent_cli-0.5.18/meshagent/cli/api_keys.py +0 -149
  32. meshagent_cli-0.5.18/meshagent/cli/auth_async.py +0 -138
  33. meshagent_cli-0.5.18/meshagent/cli/otel.py +0 -122
  34. meshagent_cli-0.5.18/meshagent/cli/participant_token.py +0 -50
  35. meshagent_cli-0.5.18/meshagent/cli/services.py +0 -525
  36. meshagent_cli-0.5.18/meshagent/cli/version.py +0 -1
  37. meshagent_cli-0.5.18/meshagent_cli.egg-info/requires.txt +0 -15
  38. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/README.md +0 -0
  39. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/__init__.py +0 -0
  40. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/async_typer.py +0 -0
  41. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/auth.py +0 -0
  42. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/projects.py +0 -0
  43. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/sessions.py +0 -0
  44. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent_cli.egg-info/dependency_links.txt +0 -0
  45. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent_cli.egg-info/entry_points.txt +0 -0
  46. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent_cli.egg-info/top_level.txt +0 -0
  47. {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/setup.cfg +0 -0
@@ -1,28 +1,34 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshagent-cli
3
- Version: 0.5.18
3
+ Version: 0.6.1
4
4
  Summary: CLI for Meshagent
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Documentation, https://docs.meshagent.com
7
7
  Project-URL: Website, https://www.meshagent.com
8
8
  Project-URL: Source, https://www.meshagent.com
9
- Requires-Python: >=3.12
9
+ Requires-Python: >=3.13
10
10
  Description-Content-Type: text/markdown
11
11
  Requires-Dist: typer~=0.15
12
- Requires-Dist: pydantic-yaml~=1.4
13
- Requires-Dist: meshagent-api~=0.5.18
14
- Requires-Dist: meshagent-agents~=0.5.18
15
- Requires-Dist: meshagent-computers~=0.5.18
16
- Requires-Dist: meshagent-openai~=0.5.18
17
- Requires-Dist: meshagent-tools~=0.5.18
18
- Requires-Dist: meshagent-mcp~=0.5.18
19
- Requires-Dist: supabase~=2.15
20
12
  Requires-Dist: fastmcp~=2.8
21
13
  Requires-Dist: opentelemetry-distro~=0.54b1
22
14
  Requires-Dist: opentelemetry-exporter-otlp-proto-http~=1.33
23
15
  Requires-Dist: art~=6.5
24
16
  Requires-Dist: pydantic-yaml~=1.5
25
- Requires-Dist: supabase-auth~=2.12.3
17
+ Requires-Dist: pathspec~=0.12.1
18
+ Provides-Extra: all
19
+ Requires-Dist: meshagent-agents[all]~=0.6.1; extra == "all"
20
+ Requires-Dist: meshagent-api[all]~=0.6.1; extra == "all"
21
+ Requires-Dist: meshagent-computers~=0.6.1; extra == "all"
22
+ Requires-Dist: meshagent-openai~=0.6.1; extra == "all"
23
+ Requires-Dist: meshagent-mcp~=0.6.1; extra == "all"
24
+ Requires-Dist: meshagent-tools~=0.6.1; extra == "all"
25
+ Requires-Dist: supabase-auth~=2.12.3; extra == "all"
26
+ Provides-Extra: mcp-service
27
+ Requires-Dist: meshagent-agents[all]~=0.6.1; extra == "mcp-service"
28
+ Requires-Dist: meshagent-api~=0.6.1; extra == "mcp-service"
29
+ Requires-Dist: meshagent-mcp~=0.6.1; extra == "mcp-service"
30
+ Requires-Dist: meshagent-tools~=0.6.1; extra == "mcp-service"
31
+ Requires-Dist: supabase-auth~=2.12.3; extra == "mcp-service"
26
32
 
27
33
  # [Meshagent](https://www.meshagent.com)
28
34
 
@@ -1,20 +1,19 @@
1
1
  import typer
2
2
  from rich import print
3
3
  from typing import Annotated, Optional
4
- from meshagent.cli.common_options import ProjectIdOption, ApiKeyIdOption, RoomOption
4
+ from meshagent.cli.common_options import ProjectIdOption, RoomOption
5
5
  import json
6
6
  import asyncio
7
7
 
8
8
  from meshagent.api.helpers import meshagent_base_url, websocket_room_url
9
9
  from meshagent.api import (
10
10
  RoomClient,
11
- ParticipantToken,
12
11
  WebSocketClientProtocol,
13
12
  RoomException,
14
13
  )
15
- from meshagent.cli.helper import resolve_project_id, resolve_api_key
14
+ from meshagent.cli.helper import resolve_project_id
16
15
  from meshagent.cli import async_typer
17
- from meshagent.cli.helper import get_client, resolve_token_jwt, resolve_room
16
+ from meshagent.cli.helper import get_client, resolve_room
18
17
 
19
18
  app = async_typer.AsyncTyper()
20
19
 
@@ -24,9 +23,6 @@ async def ask(
24
23
  *,
25
24
  project_id: ProjectIdOption = None,
26
25
  room: RoomOption,
27
- api_key_id: ApiKeyIdOption = None,
28
- name: Annotated[str, typer.Option(..., help="Participant name")] = "cli",
29
- role: str = "user",
30
26
  agent: Annotated[str, typer.Option()],
31
27
  input: Annotated[str, typer.Option()],
32
28
  timeout: Annotated[
@@ -39,27 +35,15 @@ async def ask(
39
35
  account_client = await get_client()
40
36
  try:
41
37
  project_id = await resolve_project_id(project_id=project_id)
42
- api_key_id = await resolve_api_key(project_id, api_key_id)
43
38
  room = resolve_room(room)
44
39
 
45
- key = (
46
- await account_client.decrypt_project_api_key(
47
- project_id=project_id, id=api_key_id
48
- )
49
- )["token"]
50
-
51
- token = ParticipantToken(
52
- name=name, project_id=project_id, api_key_id=api_key_id
53
- )
54
-
55
- token.add_role_grant(role=role)
56
- token.add_room_grant(room)
40
+ connection = await account_client.connect_room(project_id=project_id, room=room)
57
41
 
58
42
  print("[bold green]Connecting to room...[/bold green]")
59
43
  async with RoomClient(
60
44
  protocol=WebSocketClientProtocol(
61
45
  url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
62
- token=token.to_jwt(token=key),
46
+ token=connection.jwt,
63
47
  )
64
48
  ) as client:
65
49
  found = timeout == 0
@@ -97,10 +81,6 @@ async def invoke_tool(
97
81
  *,
98
82
  project_id: ProjectIdOption = None,
99
83
  room: RoomOption,
100
- token_path: Annotated[Optional[str], typer.Option()] = None,
101
- api_key_id: ApiKeyIdOption = None,
102
- name: Annotated[str, typer.Option(..., help="Participant name")] = "cli",
103
- role: str = "user",
104
84
  toolkit: Annotated[str, typer.Option(..., help="Toolkit name")],
105
85
  tool: Annotated[str, typer.Option(..., help="Tool name")],
106
86
  arguments: Annotated[
@@ -130,23 +110,15 @@ async def invoke_tool(
130
110
  account_client = await get_client()
131
111
  try:
132
112
  project_id = await resolve_project_id(project_id=project_id)
133
- api_key_id = await resolve_api_key(project_id, api_key_id)
134
113
  room = resolve_room(room)
135
114
 
136
- jwt = await resolve_token_jwt(
137
- project_id=project_id,
138
- api_key_id=api_key_id,
139
- token_path=token_path,
140
- name=name,
141
- role=role,
142
- room=room,
143
- )
115
+ connection = await account_client.connect_room(project_id=project_id, room=room)
144
116
 
145
117
  print("[bold green]Connecting to room...[/bold green]")
146
118
  async with RoomClient(
147
119
  protocol=WebSocketClientProtocol(
148
120
  url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
149
- token=jwt,
121
+ token=connection.jwt,
150
122
  )
151
123
  ) as client:
152
124
  found = timeout == 0
@@ -194,10 +166,6 @@ async def list_agents_command(
194
166
  *,
195
167
  project_id: ProjectIdOption = None,
196
168
  room: RoomOption,
197
- token_path: Annotated[Optional[str], typer.Option()] = None,
198
- api_key_id: ApiKeyIdOption = None,
199
- name: Annotated[str, typer.Option(..., help="Participant name")] = "cli",
200
- role: str = "user",
201
169
  ):
202
170
  """
203
171
  List all agents available in the room.
@@ -205,23 +173,15 @@ async def list_agents_command(
205
173
  account_client = await get_client()
206
174
  try:
207
175
  project_id = await resolve_project_id(project_id=project_id)
208
- api_key_id = await resolve_api_key(project_id, api_key_id)
209
176
  room = resolve_room(room)
210
177
 
211
- jwt = await resolve_token_jwt(
212
- project_id=project_id,
213
- api_key_id=api_key_id,
214
- token_path=token_path,
215
- name=name,
216
- role=role,
217
- room=room,
218
- )
178
+ connection = await account_client.connect_room(project_id=project_id, room=room)
219
179
 
220
180
  print("[bold green]Connecting to room...[/bold green]")
221
181
  async with RoomClient(
222
182
  protocol=WebSocketClientProtocol(
223
183
  url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
224
- token=jwt,
184
+ token=connection.jwt,
225
185
  )
226
186
  ) as client:
227
187
  print("[bold green]Fetching list of agents...[/bold green]")
@@ -250,9 +210,6 @@ async def list_toolkits_command(
250
210
  *,
251
211
  project_id: ProjectIdOption = None,
252
212
  room: RoomOption,
253
- token_path: Annotated[Optional[str], typer.Option()] = None,
254
- api_key_id: ApiKeyIdOption = None,
255
- name: Annotated[str, typer.Option(..., help="Participant name")] = "cli",
256
213
  role: str = "user",
257
214
  participant_id: Annotated[
258
215
  Optional[str], typer.Option(..., help="Optional participant ID")
@@ -264,22 +221,14 @@ async def list_toolkits_command(
264
221
  account_client = await get_client()
265
222
  try:
266
223
  project_id = await resolve_project_id(project_id=project_id)
267
- api_key_id = await resolve_api_key(project_id, api_key_id)
268
224
  room = resolve_room(room)
269
- jwt = await resolve_token_jwt(
270
- project_id=project_id,
271
- api_key_id=api_key_id,
272
- token_path=token_path,
273
- name=name,
274
- role=role,
275
- room=room,
276
- )
225
+ connection = await account_client.connect_room(project_id=project_id, room=room)
277
226
 
278
227
  print("[bold green]Connecting to room...[/bold green]")
279
228
  async with RoomClient(
280
229
  protocol=WebSocketClientProtocol(
281
230
  url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
282
- token=jwt,
231
+ token=connection.jwt,
283
232
  )
284
233
  ) as client:
285
234
  print("[bold green]Fetching list of toolkits...[/bold green]")
@@ -0,0 +1,102 @@
1
+ import json
2
+ from rich import print
3
+
4
+ from meshagent.cli.common_options import ProjectIdOption
5
+ from meshagent.cli import async_typer
6
+ from meshagent.cli.helper import (
7
+ get_client,
8
+ print_json_table,
9
+ resolve_project_id,
10
+ set_active_api_key,
11
+ )
12
+ from meshagent.cli.common_options import OutputFormatOption
13
+ from typing import Annotated
14
+ import typer
15
+
16
+ app = async_typer.AsyncTyper(help="Manage or activate api-keys for your project")
17
+
18
+
19
+ @app.async_command("list")
20
+ async def list(
21
+ *,
22
+ project_id: ProjectIdOption = None,
23
+ o: OutputFormatOption = "table",
24
+ ):
25
+ project_id = await resolve_project_id(project_id=project_id)
26
+ client = await get_client()
27
+ keys = (await client.list_api_keys(project_id=project_id))["keys"]
28
+
29
+ if len(keys) > 0:
30
+ if o == "json":
31
+ sanitized_keys = [
32
+ {k: v for k, v in key.items() if k != "created_by"} for key in keys
33
+ ]
34
+ print(json.dumps({"api-keys": sanitized_keys}, indent=2))
35
+ else:
36
+ print_json_table(keys, "id", "name", "description")
37
+ else:
38
+ print("There are not currently any API keys in the project")
39
+ await client.close()
40
+
41
+
42
+ @app.async_command("create")
43
+ async def create(
44
+ *,
45
+ project_id: ProjectIdOption = None,
46
+ name: str,
47
+ description: Annotated[
48
+ str, typer.Option(..., help="a description for the api key")
49
+ ] = "",
50
+ activate: Annotated[
51
+ bool,
52
+ typer.Option(
53
+ ..., help="use this key by default for commands that accept an API key"
54
+ ),
55
+ ] = False,
56
+ silent: Annotated[bool, typer.Option(..., help="do not print api key")] = False,
57
+ ):
58
+ project_id = await resolve_project_id(project_id=project_id)
59
+
60
+ client = await get_client()
61
+ api_key = await client.create_api_key(
62
+ project_id=project_id, name=name, description=description
63
+ )
64
+ if not silent:
65
+ if not activate:
66
+ print(
67
+ "[green]This is your token. Save it for later, you will not be able to get the value again:[/green]\n"
68
+ )
69
+ print(api_key["value"])
70
+ print(
71
+ "[green]\nNote: you can use the --activate flag to save a key in your local project settings when creating a key.[/green]\n"
72
+ )
73
+ else:
74
+ print("[green]This is your token:[/green]\n")
75
+ print(api_key["value"])
76
+
77
+ await client.close()
78
+ if activate:
79
+ await set_active_api_key(project_id=project_id, key=api_key["value"])
80
+ print(
81
+ "[green]your api key has been activated and will be used automatically with commands that require a key[/green]\n"
82
+ )
83
+
84
+
85
+ @app.async_command("activate")
86
+ async def activate(
87
+ *,
88
+ project_id: ProjectIdOption = None,
89
+ key: str,
90
+ ):
91
+ project_id = await resolve_project_id(project_id=project_id)
92
+ if activate:
93
+ await set_active_api_key(project_id=project_id, key=key)
94
+
95
+
96
+ @app.async_command("delete")
97
+ async def delete(*, project_id: ProjectIdOption = None, id: str):
98
+ project_id = await resolve_project_id(project_id=project_id)
99
+
100
+ client = await get_client()
101
+ await client.delete_api_key(project_id=project_id, id=id)
102
+ await client.close()
@@ -0,0 +1,295 @@
1
+ import os
2
+ import json
3
+ import time
4
+ import base64
5
+ import hashlib
6
+ import secrets
7
+ import webbrowser
8
+ import asyncio
9
+ from pathlib import Path
10
+ from urllib.parse import urlencode
11
+ from aiohttp import web, ClientSession
12
+
13
+ # -----------------------------------------------------------------------------
14
+ # Config
15
+ # -----------------------------------------------------------------------------
16
+
17
+ CACHE_FILE = Path.home() / ".meshagent" / "session.json"
18
+ REDIRECT_PORT = 8765
19
+ REDIRECT_URL = f"http://localhost:{REDIRECT_PORT}/callback"
20
+
21
+ # Expected env vars:
22
+ # - MESHAGENT_API_URL (required): e.g., https://api.meshagent.com
23
+ # - MESHAGENT_OAUTH_CLIENT_ID (required)
24
+ # - MESHAGENT_OAUTH_CLIENT_SECRET (optional; only if your server requires it)
25
+ # - MESHAGENT_OAUTH_SCOPES (optional; defaults to "openid email profile")
26
+
27
+ # -----------------------------------------------------------------------------
28
+ # Helpers
29
+ # -----------------------------------------------------------------------------
30
+
31
+
32
+ def _ensure_cache_dir():
33
+ CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
34
+
35
+
36
+ def _now() -> int:
37
+ return int(time.time())
38
+
39
+
40
+ def _b64url_no_pad(data: bytes) -> str:
41
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
42
+
43
+
44
+ def _pkce_pair():
45
+ """
46
+ Returns (code_verifier, code_challenge) using S256 per RFC 7636.
47
+ """
48
+ verifier = _b64url_no_pad(secrets.token_bytes(32))
49
+ digest = hashlib.sha256(verifier.encode()).digest()
50
+ challenge = _b64url_no_pad(digest)
51
+ return verifier, challenge
52
+
53
+
54
+ def _api_base() -> str:
55
+ api = os.getenv("MESHAGENT_API_URL", "https://api.meshagent.com")
56
+ if not api:
57
+ raise RuntimeError("MESHAGENT_API_URL is not set")
58
+ return api.rstrip("/")
59
+
60
+
61
+ def _authorization_url() -> str:
62
+ return f"{_api_base()}/oauth/authorize"
63
+
64
+
65
+ def _token_url() -> str:
66
+ return f"{_api_base()}/oauth/token"
67
+
68
+
69
+ def _client_id() -> str:
70
+ cid = os.getenv("MESHAGENT_OAUTH_CLIENT_ID", "p8xy1ZUi73jJUJbNfTg92HUSDpCSZJcc")
71
+ if not cid:
72
+ raise RuntimeError("MESHAGENT_OAUTH_CLIENT_ID is not set")
73
+ return cid
74
+
75
+
76
+ def _client_secret() -> str | None:
77
+ return os.getenv("MESHAGENT_OAUTH_CLIENT_SECRET")
78
+
79
+
80
+ def _scopes() -> str:
81
+ return os.getenv("MESHAGENT_OAUTH_SCOPES", "admin")
82
+
83
+
84
+ def _save(tokens: dict):
85
+ """
86
+ Persist minimal token info to disk.
87
+ Expected keys: access_token, refresh_token (optional), expires_at (epoch int).
88
+ """
89
+ _ensure_cache_dir()
90
+ CACHE_FILE.write_text(
91
+ json.dumps(
92
+ {
93
+ "access_token": tokens.get("access_token"),
94
+ "refresh_token": tokens.get("refresh_token"),
95
+ "expires_at": tokens.get("expires_at"),
96
+ "token_type": tokens.get("token_type", "Bearer"),
97
+ "scope": tokens.get("scope"),
98
+ "id_token": tokens.get("id_token"),
99
+ }
100
+ )
101
+ )
102
+
103
+
104
+ def _load() -> dict | None:
105
+ _ensure_cache_dir()
106
+ if CACHE_FILE.exists():
107
+ return json.loads(CACHE_FILE.read_text())
108
+
109
+
110
+ async def _post_form(url: str, form: dict) -> dict:
111
+ """
112
+ POST application/x-www-form-urlencoded and return parsed JSON or raise.
113
+ """
114
+ headers = {"Accept": "application/json"}
115
+ async with ClientSession() as s:
116
+ async with s.post(url, data=form, headers=headers) as resp:
117
+ text = await resp.text()
118
+ if resp.status >= 400:
119
+ raise RuntimeError(f"Token endpoint error {resp.status}: {text}")
120
+ try:
121
+ return json.loads(text)
122
+ except json.JSONDecodeError:
123
+ raise RuntimeError(
124
+ f"Unexpected non-JSON response from token endpoint: {text}"
125
+ )
126
+
127
+
128
+ # -----------------------------------------------------------------------------
129
+ # Local HTTP callback
130
+ # -----------------------------------------------------------------------------
131
+
132
+
133
+ async def _wait_for_code(expected_state: str) -> str:
134
+ """
135
+ Spin up a one-shot aiohttp server and await ?code=…&state=…
136
+ Validates 'state' if provided. Returns the 'code'.
137
+ """
138
+ app = web.Application()
139
+ code_fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
140
+
141
+ async def callback(request):
142
+ code = request.query.get("code")
143
+ state = request.query.get("state")
144
+ if expected_state and state != expected_state:
145
+ return web.Response(status=400, text="State mismatch. Close this tab.")
146
+ if code:
147
+ if not code_fut.done():
148
+ code_fut.set_result(code)
149
+ return web.Response(text="You may close this tab.")
150
+ return web.Response(status=400, text="Missing 'code'.")
151
+
152
+ app.add_routes([web.get("/callback", callback)])
153
+ runner = web.AppRunner(app, access_log=None)
154
+ await runner.setup()
155
+ site = web.TCPSite(runner, "localhost", REDIRECT_PORT)
156
+ await site.start()
157
+
158
+ try:
159
+ return await code_fut
160
+ finally:
161
+ await runner.cleanup()
162
+
163
+
164
+ # -----------------------------------------------------------------------------
165
+ # OAuth flows
166
+ # -----------------------------------------------------------------------------
167
+
168
+
169
+ async def _exchange_code_for_tokens(code: str, code_verifier: str) -> dict:
170
+ form = {
171
+ "grant_type": "authorization_code",
172
+ "code": code,
173
+ "redirect_uri": REDIRECT_URL,
174
+ "client_id": _client_id(),
175
+ "code_verifier": code_verifier,
176
+ }
177
+ # Include client_secret only if provided (public clients typically omit)
178
+ client_secret = _client_secret()
179
+ if client_secret:
180
+ form["client_secret"] = client_secret
181
+
182
+ token_json = await _post_form(_token_url(), form)
183
+
184
+ # Compute absolute expiry; default to 3600s if expires_in missing
185
+ expires_in = int(token_json.get("expires_in", 3600))
186
+ token_json["expires_at"] = _now() + max(0, expires_in - 30) # small safety skew
187
+ return token_json
188
+
189
+
190
+ async def _refresh_tokens(tokens: dict) -> dict:
191
+ if not tokens or not tokens.get("refresh_token"):
192
+ raise RuntimeError("No refresh token available to refresh access token.")
193
+
194
+ form = {
195
+ "grant_type": "refresh_token",
196
+ "refresh_token": tokens["refresh_token"],
197
+ "client_id": _client_id(),
198
+ }
199
+ client_secret = _client_secret()
200
+ if client_secret:
201
+ form["client_secret"] = client_secret
202
+
203
+ token_json = await _post_form(_token_url(), form)
204
+
205
+ # Some servers rotate refresh tokens; keep old one if none returned
206
+ token_json["refresh_token"] = token_json.get(
207
+ "refresh_token", tokens.get("refresh_token")
208
+ )
209
+ expires_in = int(token_json.get("expires_in", 3600))
210
+ token_json["expires_at"] = _now() + max(0, expires_in - 30)
211
+ return token_json
212
+
213
+
214
+ # -----------------------------------------------------------------------------
215
+ # Public API (unchanged names)
216
+ # -----------------------------------------------------------------------------
217
+
218
+
219
+ async def login():
220
+ """
221
+ Launches the system browser for OAuth 2.0 Authorization Code + PKCE.
222
+ Persists tokens to ~/.meshagent/session.json
223
+ """
224
+ authz = _authorization_url()
225
+ client_id = _client_id()
226
+ scope = _scopes()
227
+
228
+ code_verifier, code_challenge = _pkce_pair()
229
+ state = _b64url_no_pad(secrets.token_bytes(16))
230
+
231
+ query = {
232
+ "response_type": "code",
233
+ "client_id": client_id,
234
+ "redirect_uri": REDIRECT_URL,
235
+ "scope": scope,
236
+ "code_challenge": code_challenge,
237
+ "code_challenge_method": "S256",
238
+ "state": state,
239
+ }
240
+ auth_url = f"{authz}?{urlencode(query)}"
241
+
242
+ # Kick user to browser without blocking the loop
243
+ await asyncio.to_thread(webbrowser.open, auth_url)
244
+ print(f"Waiting for auth redirect on {auth_url}…")
245
+
246
+ # Await the auth code, then exchange for tokens
247
+ auth_code = await _wait_for_code(state)
248
+ print("Got code, exchanging…")
249
+
250
+ tokens = await _exchange_code_for_tokens(auth_code, code_verifier)
251
+ _save(tokens)
252
+ print("✅ Logged in (tokens cached).")
253
+
254
+
255
+ async def session():
256
+ """
257
+ Returns a tuple (client, tokens_dict)
258
+ - client is None (kept for backward compatibility with prior signature).
259
+ - tokens_dict contains access_token, refresh_token, expires_at, token_type, scope, id_token.
260
+ Will auto-refresh if expired/near-expiry and update the cache.
261
+ """
262
+ tokens = _load()
263
+ if not tokens:
264
+ return None, None
265
+
266
+ # Refresh if expired or within 5 min of expiry
267
+ if not tokens.get("expires_at") or tokens["expires_at"] <= _now() + 5 * 60:
268
+ try:
269
+ tokens = await _refresh_tokens(tokens)
270
+ _save(tokens)
271
+ except Exception as e:
272
+ # If refresh fails, wipe session to force re-login
273
+ print(f"⚠️ Token refresh failed: {e}")
274
+ return None, None
275
+
276
+ return None, tokens
277
+
278
+
279
+ async def logout():
280
+ """
281
+ Clears the cached tokens. (If your OAuth server supports revocation,
282
+ you can add a call here; not provided in the spec.)
283
+ """
284
+ _, tokens = await session()
285
+ # Optional: call a revocation endpoint here if your server provides one.
286
+ CACHE_FILE.unlink(missing_ok=True)
287
+ print("👋 Signed out")
288
+
289
+
290
+ async def get_access_token():
291
+ """
292
+ Returns a fresh access token, refreshing if needed.
293
+ """
294
+ _, tokens = await session()
295
+ return tokens["access_token"] if tokens else None