meshagent-cli 0.38.2__tar.gz → 0.38.4__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.
Files changed (121) hide show
  1. {meshagent_cli-0.38.2/meshagent_cli.egg-info → meshagent_cli-0.38.4}/PKG-INFO +15 -14
  2. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/auth.py +52 -6
  3. meshagent_cli-0.38.4/meshagent/cli/auth_test.py +309 -0
  4. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/cli.py +6 -0
  5. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/cli_test.py +1 -0
  6. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/containers.py +192 -1
  7. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/containers_test.py +161 -1
  8. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/image.py +252 -3
  9. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/image_test.py +389 -0
  10. meshagent_cli-0.38.4/meshagent/cli/launch.py +98 -0
  11. meshagent_cli-0.38.4/meshagent/cli/launch_test.py +106 -0
  12. meshagent_cli-0.38.4/meshagent/cli/projects.py +280 -0
  13. meshagent_cli-0.38.4/meshagent/cli/projects_test.py +282 -0
  14. meshagent_cli-0.38.4/meshagent/cli/root_commands.py +339 -0
  15. meshagent_cli-0.38.4/meshagent/cli/root_commands_test.py +809 -0
  16. meshagent_cli-0.38.4/meshagent/cli/tool_integrations.py +1413 -0
  17. meshagent_cli-0.38.4/meshagent/cli/tool_integrations_test.py +881 -0
  18. meshagent_cli-0.38.4/meshagent/cli/tui/auth_switch.py +277 -0
  19. meshagent_cli-0.38.4/meshagent/cli/tui/auth_switch_test.py +125 -0
  20. meshagent_cli-0.38.4/meshagent/cli/tui/project_activate.py +372 -0
  21. meshagent_cli-0.38.4/meshagent/cli/tui/project_activate_test.py +153 -0
  22. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/tui/setup.py +999 -17
  23. meshagent_cli-0.38.4/meshagent/cli/tui/setup_test.py +737 -0
  24. meshagent_cli-0.38.4/meshagent/cli/version.py +1 -0
  25. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4/meshagent_cli.egg-info}/PKG-INFO +15 -14
  26. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent_cli.egg-info/SOURCES.txt +9 -0
  27. meshagent_cli-0.38.4/meshagent_cli.egg-info/requires.txt +34 -0
  28. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/pyproject.toml +14 -13
  29. meshagent_cli-0.38.2/meshagent/cli/auth_test.py +0 -109
  30. meshagent_cli-0.38.2/meshagent/cli/projects.py +0 -119
  31. meshagent_cli-0.38.2/meshagent/cli/root_commands.py +0 -190
  32. meshagent_cli-0.38.2/meshagent/cli/root_commands_test.py +0 -185
  33. meshagent_cli-0.38.2/meshagent/cli/tui/setup_test.py +0 -22
  34. meshagent_cli-0.38.2/meshagent/cli/version.py +0 -1
  35. meshagent_cli-0.38.2/meshagent_cli.egg-info/requires.txt +0 -33
  36. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/LICENSE +0 -0
  37. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/README.md +0 -0
  38. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/__init__.py +0 -0
  39. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/agent.py +0 -0
  40. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/agent_cli_options_test.py +0 -0
  41. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/agent_package_cli.py +0 -0
  42. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/agent_package_cli_test.py +0 -0
  43. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/api_keys.py +0 -0
  44. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/api_keys_test.py +0 -0
  45. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/ask.py +0 -0
  46. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/ask_test.py +0 -0
  47. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/async_typer.py +0 -0
  48. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/async_typer_test.py +0 -0
  49. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/auth_async.py +0 -0
  50. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/auth_async_test.py +0 -0
  51. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/call.py +0 -0
  52. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/chatbot.py +0 -0
  53. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/cli_mcp.py +0 -0
  54. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/cli_secrets.py +0 -0
  55. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/codex.py +0 -0
  56. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/common_options.py +0 -0
  57. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/database.py +0 -0
  58. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/database_test.py +0 -0
  59. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/developer.py +0 -0
  60. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/developer_test.py +0 -0
  61. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/helper.py +0 -0
  62. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/helper_test.py +0 -0
  63. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/helpers.py +0 -0
  64. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/host.py +0 -0
  65. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/llm.py +0 -0
  66. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/llm_test.py +0 -0
  67. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/local_settings.py +0 -0
  68. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/local_settings_test.py +0 -0
  69. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/mailbot.py +0 -0
  70. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/mailbot_test.py +0 -0
  71. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/mailboxes.py +0 -0
  72. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/meeting_transcriber.py +0 -0
  73. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/memory.py +0 -0
  74. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/memory_test.py +0 -0
  75. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/messaging.py +0 -0
  76. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/multi.py +0 -0
  77. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/oauth2.py +0 -0
  78. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/oauth2_test.py +0 -0
  79. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/oci_archive.py +0 -0
  80. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/oci_archive_test.py +0 -0
  81. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/participant_token.py +0 -0
  82. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/port.py +0 -0
  83. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/port_test.py +0 -0
  84. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/process.py +0 -0
  85. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/process_test.py +0 -0
  86. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/queue.py +0 -0
  87. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/queue_test.py +0 -0
  88. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/registry.py +0 -0
  89. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/registry_test.py +0 -0
  90. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/room.py +0 -0
  91. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/room_connect.py +0 -0
  92. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/room_connect_test.py +0 -0
  93. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/room_services.py +0 -0
  94. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/rooms.py +0 -0
  95. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/rooms_test.py +0 -0
  96. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/routes.py +0 -0
  97. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/scheduled_tasks.py +0 -0
  98. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/scheduled_tasks_test.py +0 -0
  99. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/services.py +0 -0
  100. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/services_test.py +0 -0
  101. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/sessions.py +0 -0
  102. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/sessions_test.py +0 -0
  103. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/storage.py +0 -0
  104. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/storage_test.py +0 -0
  105. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/sync.py +0 -0
  106. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/sync_test.py +0 -0
  107. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/task_runner.py +0 -0
  108. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/task_runner_test.py +0 -0
  109. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/test.py +0 -0
  110. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/tui/__init__.py +0 -0
  111. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/tui/setup_splash_frames.py +0 -0
  112. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/voicebot.py +0 -0
  113. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/webhook.py +0 -0
  114. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/webserver.py +0 -0
  115. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/webserver_test.py +0 -0
  116. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/worker.py +0 -0
  117. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent/cli/worker_test.py +0 -0
  118. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent_cli.egg-info/dependency_links.txt +0 -0
  119. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent_cli.egg-info/entry_points.txt +0 -0
  120. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/meshagent_cli.egg-info/top_level.txt +0 -0
  121. {meshagent_cli-0.38.2 → meshagent_cli-0.38.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshagent-cli
3
- Version: 0.38.2
3
+ Version: 0.38.4
4
4
  Summary: CLI for Meshagent
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Documentation, https://docs.meshagent.com
@@ -19,26 +19,27 @@ Requires-Dist: pyyaml~=6.0.2
19
19
  Requires-Dist: pydantic-yaml~=1.5
20
20
  Requires-Dist: pathspec<2,>=1.0.3
21
21
  Requires-Dist: zstandard~=0.25.0
22
- Requires-Dist: meshagent-llm-proxy==0.38.2
22
+ Requires-Dist: meshagent-llm-proxy==0.38.4
23
23
  Requires-Dist: rich~=14.3.0
24
24
  Requires-Dist: textual<9.0,>=8.2.3
25
25
  Requires-Dist: prompt-toolkit~=3.0.52
26
26
  Provides-Extra: all
27
- Requires-Dist: meshagent-agents[all]==0.38.2; extra == "all"
28
- Requires-Dist: meshagent-api[all]==0.38.2; extra == "all"
29
- Requires-Dist: meshagent-computers==0.38.2; extra == "all"
30
- Requires-Dist: meshagent-openai==0.38.2; extra == "all"
31
- Requires-Dist: meshagent-anthropic==0.38.2; extra == "all"
32
- Requires-Dist: meshagent-codex==0.38.2; extra == "all"
33
- Requires-Dist: meshagent-mcp==0.38.2; extra == "all"
34
- Requires-Dist: meshagent-tools==0.38.2; extra == "all"
27
+ Requires-Dist: meshagent-agents[all]==0.38.4; extra == "all"
28
+ Requires-Dist: meshagent-api[all]==0.38.4; extra == "all"
29
+ Requires-Dist: meshagent-computers==0.38.4; extra == "all"
30
+ Requires-Dist: meshagent-openai==0.38.4; extra == "all"
31
+ Requires-Dist: meshagent-anthropic==0.38.4; extra == "all"
32
+ Requires-Dist: meshagent-codex==0.38.4; extra == "all"
33
+ Requires-Dist: meshagent-otel==0.38.4; extra == "all"
34
+ Requires-Dist: meshagent-mcp==0.38.4; extra == "all"
35
+ Requires-Dist: meshagent-tools==0.38.4; extra == "all"
35
36
  Requires-Dist: supabase-auth~=2.28.0; extra == "all"
36
37
  Requires-Dist: prompt-toolkit~=3.0.52; extra == "all"
37
38
  Provides-Extra: mcp-service
38
- Requires-Dist: meshagent-agents[all]==0.38.2; extra == "mcp-service"
39
- Requires-Dist: meshagent-api==0.38.2; extra == "mcp-service"
40
- Requires-Dist: meshagent-mcp==0.38.2; extra == "mcp-service"
41
- Requires-Dist: meshagent-tools==0.38.2; extra == "mcp-service"
39
+ Requires-Dist: meshagent-agents[all]==0.38.4; extra == "mcp-service"
40
+ Requires-Dist: meshagent-api==0.38.4; extra == "mcp-service"
41
+ Requires-Dist: meshagent-mcp==0.38.4; extra == "mcp-service"
42
+ Requires-Dist: meshagent-tools==0.38.4; extra == "mcp-service"
42
43
  Requires-Dist: supabase-auth~=2.28.0; extra == "mcp-service"
43
44
  Dynamic: license-file
44
45
 
@@ -1,3 +1,4 @@
1
+ import sys
1
2
  import typer
2
3
  from typing import Annotated
3
4
 
@@ -69,27 +70,62 @@ def _format_saved_profile(profile: SavedProfileRecord) -> str:
69
70
  )
70
71
 
71
72
 
73
+ def _should_launch_switch_tui(
74
+ *,
75
+ profile: str | None,
76
+ stdin_is_tty: bool,
77
+ stdout_is_tty: bool,
78
+ ) -> bool:
79
+ return profile is None and stdin_is_tty and stdout_is_tty
80
+
81
+
82
+ async def _run_auth_switch_tui(
83
+ *,
84
+ saved_profiles: list[SavedProfileRecord],
85
+ ):
86
+ from meshagent.cli.tui.auth_switch import run_auth_switch_tui
87
+
88
+ return await run_auth_switch_tui(saved_profiles=saved_profiles)
89
+
90
+
72
91
  @app.async_command("switch")
73
92
  async def switch(
74
93
  profile: Annotated[
75
94
  str | None,
76
95
  typer.Argument(
77
- help="Saved profile user id or email. If omitted, saved profiles are listed.",
96
+ help=(
97
+ "Saved profile user id or email. If omitted, saved profiles are "
98
+ "listed or an interactive picker is shown in a TTY."
99
+ ),
78
100
  ),
79
101
  ] = None,
80
102
  ):
81
- if profile is None:
103
+ selected_profile_selector = profile
104
+ if selected_profile_selector is None:
82
105
  saved_profiles = list_saved_profiles()
83
106
  if len(saved_profiles) == 0:
84
107
  typer.echo("No saved local profiles.")
85
108
  return
86
109
 
87
- for saved_profile in saved_profiles:
88
- typer.echo(_format_saved_profile(saved_profile))
89
- return
110
+ if _should_launch_switch_tui(
111
+ profile=selected_profile_selector,
112
+ stdin_is_tty=sys.stdin.isatty(),
113
+ stdout_is_tty=sys.stdout.isatty(),
114
+ ):
115
+ result = await _run_auth_switch_tui(saved_profiles=saved_profiles)
116
+ if result.status != "completed" or result.selected_profile is None:
117
+ if result.message is not None:
118
+ typer.echo(result.message)
119
+ return
120
+
121
+ selected_profile_selector = result.selected_profile.user_id
122
+ else:
123
+ for saved_profile in saved_profiles:
124
+ typer.echo(_format_saved_profile(saved_profile))
125
+ return
90
126
 
91
127
  try:
92
- selected_profile = switch_active_profile(profile)
128
+ selected_profile = switch_active_profile(selected_profile_selector)
93
129
  except LookupError as exc:
94
130
  typer.echo(str(exc))
95
131
  raise typer.Exit(code=1) from exc
@@ -114,3 +150,13 @@ async def whoami():
114
150
  await client.close()
115
151
 
116
152
  typer.echo(_format_user_identity(profile))
153
+
154
+
155
+ @app.async_command("token")
156
+ async def token():
157
+ access_token = await auth_async.get_access_token()
158
+ if access_token is None:
159
+ typer.echo("Not logged in", err=True)
160
+ raise typer.Exit(code=1)
161
+
162
+ typer.echo(access_token)
@@ -0,0 +1,309 @@
1
+ from types import SimpleNamespace
2
+
3
+ import pytest
4
+
5
+ from meshagent.cli import auth
6
+ from meshagent.cli.local_settings import SavedProfileRecord, StoredUserProfile
7
+
8
+
9
+ class _FakeClient:
10
+ def __init__(self, *, profile: dict[str, object]) -> None:
11
+ self._profile = profile
12
+ self.closed = False
13
+
14
+ async def get_user_profile(self, user_id: str) -> dict[str, object]:
15
+ assert user_id == "me"
16
+ return self._profile
17
+
18
+ async def close(self) -> None:
19
+ self.closed = True
20
+
21
+
22
+ class _FakeTTY:
23
+ def __init__(self, *, is_tty: bool) -> None:
24
+ self._is_tty = is_tty
25
+
26
+ def isatty(self) -> bool:
27
+ return self._is_tty
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_whoami_uses_user_profile(monkeypatch) -> None:
32
+ output: list[str] = []
33
+ client = _FakeClient(
34
+ profile={
35
+ "id": "user-123",
36
+ "first_name": "Jesse",
37
+ "last_name": "Ezell",
38
+ "email": "jesse@example.com",
39
+ }
40
+ )
41
+
42
+ async def _fake_get_access_token() -> str | None:
43
+ return "oauth-token"
44
+
45
+ monkeypatch.setattr(auth.auth_async, "get_access_token", _fake_get_access_token)
46
+ monkeypatch.setattr(
47
+ auth,
48
+ "CustomMeshagentClient",
49
+ lambda *, base_url, token: client,
50
+ )
51
+ monkeypatch.setattr(auth.typer, "echo", output.append)
52
+
53
+ await auth.whoami()
54
+
55
+ assert output == ["Jesse Ezell <jesse@example.com>"]
56
+ assert client.closed is True
57
+
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_whoami_prints_not_logged_in_without_identity(monkeypatch) -> None:
61
+ output: list[str] = []
62
+
63
+ async def _fake_get_access_token() -> str | None:
64
+ return None
65
+
66
+ monkeypatch.setattr(auth.auth_async, "get_access_token", _fake_get_access_token)
67
+ monkeypatch.setattr(auth.typer, "echo", output.append)
68
+
69
+ await auth.whoami()
70
+
71
+ assert output == ["Not logged in"]
72
+
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_token_prints_access_token(monkeypatch) -> None:
76
+ output: list[str] = []
77
+
78
+ async def _fake_get_access_token() -> str | None:
79
+ return "oauth-token"
80
+
81
+ monkeypatch.setattr(auth.auth_async, "get_access_token", _fake_get_access_token)
82
+ monkeypatch.setattr(auth.typer, "echo", output.append)
83
+
84
+ await auth.token()
85
+
86
+ assert output == ["oauth-token"]
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_token_exits_when_not_logged_in(monkeypatch) -> None:
91
+ output: list[tuple[str, bool]] = []
92
+
93
+ async def _fake_get_access_token() -> str | None:
94
+ return None
95
+
96
+ def _fake_echo(message: str, *, err: bool = False) -> None:
97
+ output.append((message, err))
98
+
99
+ monkeypatch.setattr(auth.auth_async, "get_access_token", _fake_get_access_token)
100
+ monkeypatch.setattr(auth.typer, "echo", _fake_echo)
101
+
102
+ with pytest.raises(auth.typer.Exit) as exc_info:
103
+ await auth.token()
104
+
105
+ assert exc_info.value.exit_code == 1
106
+ assert output == [("Not logged in", True)]
107
+
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_login_passes_api_url_to_auth_async(monkeypatch) -> None:
111
+ captured: dict[str, str | None] = {}
112
+
113
+ async def _fake_login(*, api_url: str | None = None) -> None:
114
+ captured["api_url"] = api_url
115
+
116
+ async def _fake_get_active_project() -> str | None:
117
+ return "project-123"
118
+
119
+ monkeypatch.setattr(auth.auth_async, "login", _fake_login)
120
+ monkeypatch.setattr(auth, "get_active_project", _fake_get_active_project)
121
+
122
+ await auth.login(api_url="https://override.meshagent.test")
123
+
124
+ assert captured == {"api_url": "https://override.meshagent.test"}
125
+
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_switch_lists_saved_profiles(monkeypatch) -> None:
129
+ output: list[str] = []
130
+
131
+ monkeypatch.setattr(
132
+ auth,
133
+ "list_saved_profiles",
134
+ lambda: [
135
+ SavedProfileRecord(
136
+ user_id="user-123",
137
+ profile=StoredUserProfile(
138
+ id="user-123",
139
+ first_name="Jesse",
140
+ last_name="Ezell",
141
+ email="jesse@example.com",
142
+ ),
143
+ api_url="https://api.meshagent.test",
144
+ is_active=True,
145
+ )
146
+ ],
147
+ )
148
+ monkeypatch.setattr(auth.sys, "stdin", _FakeTTY(is_tty=False))
149
+ monkeypatch.setattr(auth.sys, "stdout", _FakeTTY(is_tty=False))
150
+ monkeypatch.setattr(auth.typer, "echo", output.append)
151
+
152
+ await auth.switch()
153
+
154
+ assert output == [
155
+ "* Jesse Ezell [user-123] @ https://api.meshagent.test",
156
+ ]
157
+
158
+
159
+ def test_should_launch_switch_tui_only_when_selector_missing_in_tty() -> None:
160
+ assert (
161
+ auth._should_launch_switch_tui(
162
+ profile=None,
163
+ stdin_is_tty=True,
164
+ stdout_is_tty=True,
165
+ )
166
+ is True
167
+ )
168
+ assert (
169
+ auth._should_launch_switch_tui(
170
+ profile="user-123",
171
+ stdin_is_tty=True,
172
+ stdout_is_tty=True,
173
+ )
174
+ is False
175
+ )
176
+ assert (
177
+ auth._should_launch_switch_tui(
178
+ profile=None,
179
+ stdin_is_tty=False,
180
+ stdout_is_tty=True,
181
+ )
182
+ is False
183
+ )
184
+
185
+
186
+ @pytest.mark.asyncio
187
+ async def test_switch_launches_tui_when_no_selector_in_tty(monkeypatch) -> None:
188
+ output: list[str] = []
189
+ saved_profiles = [
190
+ SavedProfileRecord(
191
+ user_id="user-123",
192
+ profile=StoredUserProfile(
193
+ id="user-123",
194
+ first_name="Jesse",
195
+ last_name="Ezell",
196
+ email="jesse@example.com",
197
+ ),
198
+ api_url="https://api.meshagent.test",
199
+ is_active=True,
200
+ ),
201
+ SavedProfileRecord(
202
+ user_id="user-456",
203
+ profile=StoredUserProfile(
204
+ id="user-456",
205
+ first_name="Taylor",
206
+ last_name="Swift",
207
+ email="taylor@example.com",
208
+ ),
209
+ api_url="https://api.meshagent.test",
210
+ is_active=False,
211
+ ),
212
+ ]
213
+ selected_selectors: list[str] = []
214
+
215
+ async def _fake_run_auth_switch_tui(*, saved_profiles):
216
+ return SimpleNamespace(
217
+ status="completed",
218
+ message=None,
219
+ selected_profile=saved_profiles[1],
220
+ )
221
+
222
+ monkeypatch.setattr(auth, "list_saved_profiles", lambda: saved_profiles)
223
+ monkeypatch.setattr(auth.sys, "stdin", _FakeTTY(is_tty=True))
224
+ monkeypatch.setattr(auth.sys, "stdout", _FakeTTY(is_tty=True))
225
+ monkeypatch.setattr(
226
+ auth,
227
+ "_run_auth_switch_tui",
228
+ _fake_run_auth_switch_tui,
229
+ )
230
+ monkeypatch.setattr(
231
+ auth,
232
+ "switch_active_profile",
233
+ lambda selector: (
234
+ selected_selectors.append(selector)
235
+ or SavedProfileRecord(
236
+ user_id="user-456",
237
+ profile=StoredUserProfile(
238
+ id="user-456",
239
+ first_name="Taylor",
240
+ last_name="Swift",
241
+ email="taylor@example.com",
242
+ ),
243
+ api_url="https://api.meshagent.test",
244
+ is_active=True,
245
+ )
246
+ ),
247
+ )
248
+ monkeypatch.setattr(auth.typer, "echo", output.append)
249
+
250
+ await auth.switch()
251
+
252
+ assert selected_selectors == ["user-456"]
253
+ assert output == [
254
+ "Active profile: * Taylor Swift [user-456] @ https://api.meshagent.test",
255
+ ]
256
+
257
+
258
+ @pytest.mark.asyncio
259
+ async def test_switch_prints_cancel_message_when_tui_is_canceled(monkeypatch) -> None:
260
+ output: list[str] = []
261
+ switch_attempted = False
262
+
263
+ monkeypatch.setattr(
264
+ auth,
265
+ "list_saved_profiles",
266
+ lambda: [
267
+ SavedProfileRecord(
268
+ user_id="user-123",
269
+ profile=StoredUserProfile(
270
+ id="user-123",
271
+ first_name="Jesse",
272
+ last_name="Ezell",
273
+ email="jesse@example.com",
274
+ ),
275
+ api_url="https://api.meshagent.test",
276
+ is_active=True,
277
+ )
278
+ ],
279
+ )
280
+ monkeypatch.setattr(auth.sys, "stdin", _FakeTTY(is_tty=True))
281
+ monkeypatch.setattr(auth.sys, "stdout", _FakeTTY(is_tty=True))
282
+
283
+ async def _fake_run_auth_switch_tui(*, saved_profiles):
284
+ del saved_profiles
285
+ return SimpleNamespace(
286
+ status="canceled",
287
+ message="Profile switch canceled.",
288
+ selected_profile=None,
289
+ )
290
+
291
+ monkeypatch.setattr(
292
+ auth,
293
+ "_run_auth_switch_tui",
294
+ _fake_run_auth_switch_tui,
295
+ )
296
+
297
+ def _unexpected_switch(selector: str):
298
+ del selector
299
+ nonlocal switch_attempted
300
+ switch_attempted = True
301
+ raise AssertionError("switch_active_profile should not be called")
302
+
303
+ monkeypatch.setattr(auth, "switch_active_profile", _unexpected_switch)
304
+ monkeypatch.setattr(auth.typer, "echo", output.append)
305
+
306
+ await auth.switch()
307
+
308
+ assert switch_attempted is False
309
+ assert output == ["Profile switch canceled."]
@@ -97,6 +97,12 @@ app.add_lazy_command(
97
97
  help="Send a one-shot prompt through the LLM router",
98
98
  attribute="ask_command",
99
99
  )
100
+ app.add_lazy_command(
101
+ name="launch",
102
+ module="meshagent.cli.launch",
103
+ attribute="launch_group",
104
+ help="Launch CLI apps through MeshAgent",
105
+ )
100
106
  app.add_lazy_command(
101
107
  name="token",
102
108
  module="meshagent.cli.participant_token",
@@ -43,6 +43,7 @@ def test_root_help_hides_legacy_command_namespaces() -> None:
43
43
  assert result.exit_code == 0
44
44
  assert "│ build" in result.output
45
45
  assert "│ deploy" in result.output
46
+ assert "│ launch" in result.output
46
47
  assert "│ room" in result.output
47
48
  assert "│ call" not in result.output
48
49
  assert "│ package" not in result.output
@@ -7,6 +7,7 @@ import os
7
7
  import re
8
8
  import tarfile
9
9
  import time
10
+ from datetime import datetime
10
11
  from pathlib import Path
11
12
 
12
13
  import pathlib
@@ -16,6 +17,8 @@ import aiofiles
16
17
  import aiofiles.ospath
17
18
  import typer
18
19
  from rich import print
20
+ from rich.console import Console
21
+ from rich.table import Table
19
22
  from typing import Annotated, Optional, List, Dict
20
23
 
21
24
  from meshagent.cli import async_typer
@@ -33,6 +36,7 @@ from meshagent.api import (
33
36
  )
34
37
  from meshagent.api.room_server_client import (
35
38
  DockerSecret,
39
+ ImageDescriptor,
36
40
  )
37
41
  from meshagent.api.specs.service import (
38
42
  ContainerMountSpec,
@@ -163,6 +167,52 @@ def _parse_image_operation_mounts(
163
167
  return mount_spec
164
168
 
165
169
 
170
+ def _format_image_timestamp(value: datetime | None) -> str:
171
+ if value is None:
172
+ return "—"
173
+ return value.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
174
+
175
+
176
+ def _format_image_bytes(value: int | None) -> str:
177
+ if value is None:
178
+ return "—"
179
+ units = ["B", "KB", "MB", "GB", "TB"]
180
+ size = float(value)
181
+ unit_index = 0
182
+ while size >= 1024 and unit_index < len(units) - 1:
183
+ size /= 1024
184
+ unit_index += 1
185
+ if unit_index == 0:
186
+ return f"{int(size)} {units[unit_index]}"
187
+ return f"{size:.1f} {units[unit_index]}"
188
+
189
+
190
+ def _image_reference(image) -> str:
191
+ if image.preferred_ref:
192
+ return image.preferred_ref
193
+ if len(image.references) > 0:
194
+ return image.references[0]
195
+ return image.id
196
+
197
+
198
+ def _descriptor_table(*, title: str, descriptor: ImageDescriptor | None) -> Table:
199
+ table = Table(title=title, show_header=True, header_style="bold magenta")
200
+ table.add_column("Field", style="cyan")
201
+ table.add_column("Value")
202
+ if descriptor is None:
203
+ table.add_row("descriptor", "—")
204
+ return table
205
+ table.add_row("digest", descriptor.digest)
206
+ table.add_row("media_type", descriptor.media_type or "—")
207
+ table.add_row("size", _format_image_bytes(descriptor.size))
208
+ if len(descriptor.annotations) == 0:
209
+ table.add_row("annotations", "—")
210
+ else:
211
+ for key, value in descriptor.annotations.items():
212
+ table.add_row(f"annotation:{key}", value)
213
+ return table
214
+
215
+
166
216
  async def _stream_container_job_logs_and_wait_for_exit(
167
217
  *,
168
218
  client: RoomClient,
@@ -774,6 +824,10 @@ async def images_list(
774
824
  *,
775
825
  project_id: ProjectIdOption,
776
826
  room: RoomOption,
827
+ output: Annotated[
828
+ str,
829
+ typer.Option(help="table | json"),
830
+ ] = "table",
777
831
  ):
778
832
  account_client, client = await _with_client(
779
833
  project_id=project_id,
@@ -781,7 +835,144 @@ async def images_list(
781
835
  )
782
836
  try:
783
837
  imgs = await client.containers.list_images()
784
- print([i.model_dump() for i in imgs])
838
+ if output == "json":
839
+ print([i.model_dump(mode="json") for i in imgs])
840
+ return
841
+
842
+ table = Table(title="Images")
843
+ table.add_column("Reference", style="cyan")
844
+ table.add_column("ID")
845
+ table.add_column("Updated")
846
+ table.add_column("Created")
847
+ table.add_column("Media Type")
848
+ for image in imgs:
849
+ table.add_row(
850
+ _image_reference(image),
851
+ image.id,
852
+ _format_image_timestamp(image.updated_at),
853
+ _format_image_timestamp(image.created_at),
854
+ image.target_media_type or "—",
855
+ )
856
+ Console().print(table)
857
+ finally:
858
+ await client.__aexit__(None, None, None)
859
+ await account_client.close()
860
+
861
+
862
+ @images_app.async_command(
863
+ "inspect",
864
+ help="Inspect a container image in a room by image ID.",
865
+ )
866
+ async def images_inspect(
867
+ *,
868
+ project_id: ProjectIdOption,
869
+ room: RoomOption,
870
+ image_id: Annotated[
871
+ str,
872
+ typer.Option(..., "--image-id", help="Image ID from `meshagent images list`"),
873
+ ],
874
+ ):
875
+ account_client, client = await _with_client(
876
+ project_id=project_id,
877
+ room=room,
878
+ )
879
+ try:
880
+ inspection = await client.containers.inspect_image(image_id=image_id)
881
+ console = Console()
882
+
883
+ summary_table = Table(
884
+ title="Image", show_header=True, header_style="bold magenta"
885
+ )
886
+ summary_table.add_column("Field", style="cyan")
887
+ summary_table.add_column("Value")
888
+ summary_table.add_row("reference", _image_reference(inspection.image))
889
+ summary_table.add_row("id", inspection.image.id)
890
+ summary_table.add_row(
891
+ "references",
892
+ ", ".join(inspection.image.references)
893
+ if inspection.image.references
894
+ else "—",
895
+ )
896
+ summary_table.add_row(
897
+ "created_at",
898
+ _format_image_timestamp(inspection.image.created_at),
899
+ )
900
+ summary_table.add_row(
901
+ "updated_at",
902
+ _format_image_timestamp(inspection.image.updated_at),
903
+ )
904
+ summary_table.add_row(
905
+ "target_media_type",
906
+ inspection.image.target_media_type or "—",
907
+ )
908
+ summary_table.add_row(
909
+ "content_size",
910
+ _format_image_bytes(inspection.content_size),
911
+ )
912
+ if len(inspection.image.labels) == 0:
913
+ summary_table.add_row("labels", "—")
914
+ else:
915
+ for key, value in inspection.image.labels.items():
916
+ summary_table.add_row(f"label:{key}", value)
917
+
918
+ manifests_table = Table(
919
+ title="Manifests",
920
+ show_header=True,
921
+ header_style="bold magenta",
922
+ )
923
+ manifests_table.add_column("Digest", style="cyan")
924
+ manifests_table.add_column("Media Type")
925
+ manifests_table.add_column("Size")
926
+ manifests_table.add_column("Platform")
927
+ if len(inspection.manifests) == 0:
928
+ manifests_table.add_row("—", "—", "—", "—")
929
+ else:
930
+ for manifest in inspection.manifests:
931
+ platform = "/".join(
932
+ part
933
+ for part in (
934
+ manifest.platform_os,
935
+ manifest.platform_architecture,
936
+ manifest.platform_variant,
937
+ )
938
+ if part
939
+ )
940
+ manifests_table.add_row(
941
+ manifest.descriptor.digest,
942
+ manifest.descriptor.media_type or "—",
943
+ _format_image_bytes(manifest.descriptor.size),
944
+ platform or "—",
945
+ )
946
+
947
+ layers_table = Table(
948
+ title="Layers",
949
+ show_header=True,
950
+ header_style="bold magenta",
951
+ )
952
+ layers_table.add_column("Digest", style="cyan")
953
+ layers_table.add_column("Media Type")
954
+ layers_table.add_column("Size")
955
+ if len(inspection.layers) == 0:
956
+ layers_table.add_row("—", "—", "—")
957
+ else:
958
+ for layer in inspection.layers:
959
+ layers_table.add_row(
960
+ layer.digest,
961
+ layer.media_type or "—",
962
+ _format_image_bytes(layer.size),
963
+ )
964
+
965
+ console.print(summary_table)
966
+ console.print(_descriptor_table(title="Target", descriptor=inspection.target))
967
+ console.print(
968
+ _descriptor_table(
969
+ title="Selected Manifest",
970
+ descriptor=inspection.selected_manifest,
971
+ )
972
+ )
973
+ console.print(_descriptor_table(title="Config", descriptor=inspection.config))
974
+ console.print(manifests_table)
975
+ console.print(layers_table)
785
976
  finally:
786
977
  await client.__aexit__(None, None, None)
787
978
  await account_client.close()