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.
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/PKG-INFO +17 -11
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/agent.py +11 -62
- meshagent_cli-0.6.1/meshagent/cli/api_keys.py +102 -0
- meshagent_cli-0.6.1/meshagent/cli/auth_async.py +295 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/call.py +82 -19
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/chatbot.py +83 -49
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/cli.py +26 -70
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/cli_mcp.py +61 -27
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/cli_secrets.py +1 -1
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/common_options.py +2 -10
- meshagent_cli-0.6.1/meshagent/cli/containers.py +577 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/developer.py +7 -25
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/exec.py +162 -76
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/helper.py +35 -67
- meshagent_cli-0.6.1/meshagent/cli/helpers.py +131 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/mailbot.py +31 -26
- meshagent_cli-0.6.1/meshagent/cli/meeting_transcriber.py +124 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/messaging.py +12 -51
- meshagent_cli-0.6.1/meshagent/cli/oauth2.py +189 -0
- meshagent_cli-0.6.1/meshagent/cli/participant_token.py +61 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/queue.py +6 -37
- meshagent_cli-0.6.1/meshagent/cli/services.py +490 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/storage.py +24 -89
- meshagent_cli-0.6.1/meshagent/cli/version.py +1 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/voicebot.py +39 -28
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/webhook.py +3 -3
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent_cli.egg-info/PKG-INFO +17 -11
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent_cli.egg-info/SOURCES.txt +4 -1
- meshagent_cli-0.6.1/meshagent_cli.egg-info/requires.txt +23 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/pyproject.toml +22 -10
- meshagent_cli-0.5.18/meshagent/cli/api_keys.py +0 -149
- meshagent_cli-0.5.18/meshagent/cli/auth_async.py +0 -138
- meshagent_cli-0.5.18/meshagent/cli/otel.py +0 -122
- meshagent_cli-0.5.18/meshagent/cli/participant_token.py +0 -50
- meshagent_cli-0.5.18/meshagent/cli/services.py +0 -525
- meshagent_cli-0.5.18/meshagent/cli/version.py +0 -1
- meshagent_cli-0.5.18/meshagent_cli.egg-info/requires.txt +0 -15
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/README.md +0 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/__init__.py +0 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/async_typer.py +0 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/auth.py +0 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/projects.py +0 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent/cli/sessions.py +0 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent_cli.egg-info/dependency_links.txt +0 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent_cli.egg-info/entry_points.txt +0 -0
- {meshagent_cli-0.5.18 → meshagent_cli-0.6.1}/meshagent_cli.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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:
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|