minitest-cli 0.7.0__tar.gz → 0.8.0__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.
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/PKG-INFO +15 -6
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/README.md +14 -5
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/pyproject.toml +1 -1
- minitest_cli-0.8.0/src/minitest_cli/api/apps_manager_client.py +89 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/api/client.py +4 -0
- minitest_cli-0.8.0/src/minitest_cli/commands/app_knowledge.py +145 -0
- minitest_cli-0.8.0/src/minitest_cli/commands/app_knowledge_helpers.py +99 -0
- minitest_cli-0.8.0/src/minitest_cli/commands/apps.py +174 -0
- minitest_cli-0.8.0/src/minitest_cli/commands/apps_helpers.py +97 -0
- minitest_cli-0.8.0/src/minitest_cli/commands/flow_types.py +99 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/user_story.py +36 -3
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/user_story_helpers.py +19 -4
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/user_story_modify.py +56 -12
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/core/config.py +10 -0
- minitest_cli-0.8.0/src/minitest_cli/core/tenants.py +128 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/main.py +4 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/models/__init__.py +8 -1
- minitest_cli-0.8.0/src/minitest_cli/models/app.py +62 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/models/user_story.py +2 -0
- minitest_cli-0.8.0/tests/test_app_knowledge_commands.py +318 -0
- minitest_cli-0.8.0/tests/test_apps_commands.py +466 -0
- minitest_cli-0.7.0/tests/test_apps_commands.py → minitest_cli-0.8.0/tests/test_flow_types_commands.py +58 -53
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/test_user_story_commands.py +223 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/uv.lock +1 -1
- minitest_cli-0.7.0/src/minitest_cli/commands/apps.py +0 -68
- minitest_cli-0.7.0/src/minitest_cli/models/app.py +0 -17
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/.env.example +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/.github/workflows/ci.yml +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/.github/workflows/install-scripts.yml +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/.github/workflows/release.yml +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/.gitignore +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/.opencode/skill/release/SKILL.md +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/AGENTS.md +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/RELEASE.md +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/install.ps1 +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/install.sh +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/pyrightconfig.json +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/__init__.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/api/__init__.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/assets/__init__.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/assets/callback.html +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/__init__.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/auth.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/batch.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/batch_helpers.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/build.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/build_helpers.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/maintenance_check.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/run.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/run_display.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/run_helpers.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/skill.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/commands/upgrade.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/core/__init__.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/core/app_context.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/core/auth.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/core/credentials.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/core/oauth.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/core/token_exchange.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/models/base.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/models/build.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/models/maintenance_check.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/models/story_run.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/utils/__init__.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/utils/output.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/utils/skill_refresh.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/src/minitest_cli/utils/update_check.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/__init__.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/test_auth.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/test_auth_commands.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/test_batch_commands.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/test_build_commands.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/test_code_quality.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/test_run_commands.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/test_skill_command.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/test_upgrade_command.py +0 -0
- {minitest_cli-0.7.0 → minitest_cli-0.8.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: minitest-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Minitest CLI – command-line interface for the Minitest testing platform
|
|
5
5
|
Project-URL: Homepage, https://minitap.ai/
|
|
6
6
|
Project-URL: Source, https://github.com/minitap-ai/minitest-cli
|
|
@@ -91,17 +91,22 @@ minitest auth login
|
|
|
91
91
|
# List your apps
|
|
92
92
|
minitest apps list
|
|
93
93
|
|
|
94
|
+
# Create a new app on your tenant
|
|
95
|
+
minitest apps create --name "My App"
|
|
96
|
+
|
|
94
97
|
# Run tests
|
|
95
98
|
minitest run --app <app-id>
|
|
96
99
|
```
|
|
97
100
|
|
|
98
101
|
## Configuration
|
|
99
102
|
|
|
100
|
-
| Environment Variable
|
|
101
|
-
|
|
|
102
|
-
| `MINITEST_TOKEN`
|
|
103
|
-
| `MINITEST_APP_ID`
|
|
104
|
-
| `MINITEST_API_URL`
|
|
103
|
+
| Environment Variable | Description | Required |
|
|
104
|
+
| ----------------------------- | ---------------------------------------------------------- | ---------------------------------- |
|
|
105
|
+
| `MINITEST_TOKEN` | API authentication token | Yes (or use `minitest auth login`) |
|
|
106
|
+
| `MINITEST_APP_ID` | Default app ID | No (can use `--app` flag) |
|
|
107
|
+
| `MINITEST_API_URL` | testing-service base URL | No (defaults to production) |
|
|
108
|
+
| `MINITEST_APPS_MANAGER_URL` | apps-manager base URL (used by `minitest apps create`) | No (defaults to production) |
|
|
109
|
+
| `MINITEST_INTEGRATIONS_URL` | minihands-integrations base URL (used to list tenants) | No (defaults to production) |
|
|
105
110
|
|
|
106
111
|
## Global Flags
|
|
107
112
|
|
|
@@ -140,6 +145,8 @@ To point the CLI at the **dev** environment instead of production, set these env
|
|
|
140
145
|
MINITEST_SUPABASE_URL=https://qrezuucghnmfvaxghqsv.supabase.co \
|
|
141
146
|
MINITEST_SUPABASE_PUBLISHABLE_KEY=sb_publishable_4JRhoCm8pa5PbII0dhS09A_jhpkQhmy \
|
|
142
147
|
MINITEST_API_URL=https://testing-service.dev.minitap.ai \
|
|
148
|
+
MINITEST_APPS_MANAGER_URL=https://apps-manager.dev.minitap.ai \
|
|
149
|
+
MINITEST_INTEGRATIONS_URL=https://integrations.dev.minitap.ai \
|
|
143
150
|
minitest auth login
|
|
144
151
|
```
|
|
145
152
|
|
|
@@ -149,6 +156,8 @@ This authenticates against the dev environment and stores a dev-specific auth to
|
|
|
149
156
|
MINITEST_SUPABASE_URL=https://qrezuucghnmfvaxghqsv.supabase.co \
|
|
150
157
|
MINITEST_SUPABASE_PUBLISHABLE_KEY=sb_publishable_4JRhoCm8pa5PbII0dhS09A_jhpkQhmy \
|
|
151
158
|
MINITEST_API_URL=https://testing-service.dev.minitap.ai \
|
|
159
|
+
MINITEST_APPS_MANAGER_URL=https://apps-manager.dev.minitap.ai \
|
|
160
|
+
MINITEST_INTEGRATIONS_URL=https://integrations.dev.minitap.ai \
|
|
152
161
|
minitest apps list
|
|
153
162
|
```
|
|
154
163
|
|
|
@@ -58,17 +58,22 @@ minitest auth login
|
|
|
58
58
|
# List your apps
|
|
59
59
|
minitest apps list
|
|
60
60
|
|
|
61
|
+
# Create a new app on your tenant
|
|
62
|
+
minitest apps create --name "My App"
|
|
63
|
+
|
|
61
64
|
# Run tests
|
|
62
65
|
minitest run --app <app-id>
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
## Configuration
|
|
66
69
|
|
|
67
|
-
| Environment Variable
|
|
68
|
-
|
|
|
69
|
-
| `MINITEST_TOKEN`
|
|
70
|
-
| `MINITEST_APP_ID`
|
|
71
|
-
| `MINITEST_API_URL`
|
|
70
|
+
| Environment Variable | Description | Required |
|
|
71
|
+
| ----------------------------- | ---------------------------------------------------------- | ---------------------------------- |
|
|
72
|
+
| `MINITEST_TOKEN` | API authentication token | Yes (or use `minitest auth login`) |
|
|
73
|
+
| `MINITEST_APP_ID` | Default app ID | No (can use `--app` flag) |
|
|
74
|
+
| `MINITEST_API_URL` | testing-service base URL | No (defaults to production) |
|
|
75
|
+
| `MINITEST_APPS_MANAGER_URL` | apps-manager base URL (used by `minitest apps create`) | No (defaults to production) |
|
|
76
|
+
| `MINITEST_INTEGRATIONS_URL` | minihands-integrations base URL (used to list tenants) | No (defaults to production) |
|
|
72
77
|
|
|
73
78
|
## Global Flags
|
|
74
79
|
|
|
@@ -107,6 +112,8 @@ To point the CLI at the **dev** environment instead of production, set these env
|
|
|
107
112
|
MINITEST_SUPABASE_URL=https://qrezuucghnmfvaxghqsv.supabase.co \
|
|
108
113
|
MINITEST_SUPABASE_PUBLISHABLE_KEY=sb_publishable_4JRhoCm8pa5PbII0dhS09A_jhpkQhmy \
|
|
109
114
|
MINITEST_API_URL=https://testing-service.dev.minitap.ai \
|
|
115
|
+
MINITEST_APPS_MANAGER_URL=https://apps-manager.dev.minitap.ai \
|
|
116
|
+
MINITEST_INTEGRATIONS_URL=https://integrations.dev.minitap.ai \
|
|
110
117
|
minitest auth login
|
|
111
118
|
```
|
|
112
119
|
|
|
@@ -116,6 +123,8 @@ This authenticates against the dev environment and stores a dev-specific auth to
|
|
|
116
123
|
MINITEST_SUPABASE_URL=https://qrezuucghnmfvaxghqsv.supabase.co \
|
|
117
124
|
MINITEST_SUPABASE_PUBLISHABLE_KEY=sb_publishable_4JRhoCm8pa5PbII0dhS09A_jhpkQhmy \
|
|
118
125
|
MINITEST_API_URL=https://testing-service.dev.minitap.ai \
|
|
126
|
+
MINITEST_APPS_MANAGER_URL=https://apps-manager.dev.minitap.ai \
|
|
127
|
+
MINITEST_INTEGRATIONS_URL=https://integrations.dev.minitap.ai \
|
|
119
128
|
minitest apps list
|
|
120
129
|
```
|
|
121
130
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Async HTTP client for the apps-manager service.
|
|
2
|
+
|
|
3
|
+
Mirrors the shape of :class:`minitest_cli.api.client.ApiClient` but targets a
|
|
4
|
+
different base URL (``MINITEST_APPS_MANAGER_URL``). Reuses the same Supabase
|
|
5
|
+
JWT auth flow.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from minitest_cli.core.auth import load_token
|
|
13
|
+
from minitest_cli.core.config import Settings
|
|
14
|
+
|
|
15
|
+
CHANNEL_HEADER = "X-Minitest-Channel"
|
|
16
|
+
CHANNEL_VALUE = "cli"
|
|
17
|
+
DEFAULT_TIMEOUT = 30.0
|
|
18
|
+
UPLOAD_TIMEOUT = 300.0 # 5 minutes for large file uploads
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AppsManagerClient:
|
|
22
|
+
"""Wraps httpx.AsyncClient pointed at the apps-manager service.
|
|
23
|
+
|
|
24
|
+
Auth and channel headers are auto-injected just like
|
|
25
|
+
:class:`minitest_cli.api.client.ApiClient`.
|
|
26
|
+
|
|
27
|
+
Usage::
|
|
28
|
+
|
|
29
|
+
async with AppsManagerClient(settings) as client:
|
|
30
|
+
response = await client.post("/api/v1/tenants/<id>/apps", data=...)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, settings: Settings) -> None:
|
|
34
|
+
self._settings = settings
|
|
35
|
+
self._client: httpx.AsyncClient | None = None
|
|
36
|
+
|
|
37
|
+
async def __aenter__(self) -> "AppsManagerClient":
|
|
38
|
+
token = load_token(self._settings)
|
|
39
|
+
self._client = httpx.AsyncClient(
|
|
40
|
+
base_url=self._settings.apps_manager_url,
|
|
41
|
+
headers={
|
|
42
|
+
"Authorization": f"Bearer {token}",
|
|
43
|
+
CHANNEL_HEADER: CHANNEL_VALUE,
|
|
44
|
+
},
|
|
45
|
+
timeout=DEFAULT_TIMEOUT,
|
|
46
|
+
)
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
async def __aexit__(
|
|
50
|
+
self,
|
|
51
|
+
exc_type: type[BaseException] | None,
|
|
52
|
+
exc_val: BaseException | None,
|
|
53
|
+
exc_tb: object,
|
|
54
|
+
) -> None:
|
|
55
|
+
if self._client:
|
|
56
|
+
await self._client.aclose()
|
|
57
|
+
|
|
58
|
+
def _ensure_client(self) -> httpx.AsyncClient:
|
|
59
|
+
if self._client is None:
|
|
60
|
+
msg = "AppsManagerClient must be used as an async context manager"
|
|
61
|
+
raise RuntimeError(msg)
|
|
62
|
+
return self._client
|
|
63
|
+
|
|
64
|
+
async def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
65
|
+
"""Send a GET request."""
|
|
66
|
+
return await self._ensure_client().get(path, **kwargs)
|
|
67
|
+
|
|
68
|
+
async def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
69
|
+
"""Send a POST request."""
|
|
70
|
+
return await self._ensure_client().post(path, **kwargs)
|
|
71
|
+
|
|
72
|
+
async def upload_form(
|
|
73
|
+
self,
|
|
74
|
+
path: str,
|
|
75
|
+
*,
|
|
76
|
+
data: dict[str, str] | None = None,
|
|
77
|
+
files: dict[str, Any] | None = None,
|
|
78
|
+
timeout: float = UPLOAD_TIMEOUT,
|
|
79
|
+
**kwargs: Any,
|
|
80
|
+
) -> httpx.Response:
|
|
81
|
+
"""Send a POST request with multipart form data and extended timeout."""
|
|
82
|
+
client = self._ensure_client()
|
|
83
|
+
return await client.post(
|
|
84
|
+
path,
|
|
85
|
+
data=data or {},
|
|
86
|
+
files=files or {},
|
|
87
|
+
timeout=timeout,
|
|
88
|
+
**kwargs,
|
|
89
|
+
)
|
|
@@ -61,6 +61,10 @@ class ApiClient:
|
|
|
61
61
|
"""Send a POST request."""
|
|
62
62
|
return await self._ensure_client().post(path, **kwargs)
|
|
63
63
|
|
|
64
|
+
async def put(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
65
|
+
"""Send a PUT request."""
|
|
66
|
+
return await self._ensure_client().put(path, **kwargs)
|
|
67
|
+
|
|
64
68
|
async def patch(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
65
69
|
"""Send a PATCH request."""
|
|
66
70
|
return await self._ensure_client().patch(path, **kwargs)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""App-knowledge commands: get and update an app's AppKnowledge prompt.
|
|
2
|
+
|
|
3
|
+
- ``get`` reads from ``GET /api/v1/apps/{app_id}/test-config`` and extracts the
|
|
4
|
+
``app_knowledge`` field. testing-service has no dedicated GET endpoint for
|
|
5
|
+
app knowledge alone — the test-config response carries the latest version.
|
|
6
|
+
- ``update`` calls ``PUT /api/v1/apps/{app_id}/app-knowledge`` with body
|
|
7
|
+
``{"content": "..."}`` and returns the new ``version_number``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from minitest_cli.commands.app_knowledge_helpers import (
|
|
16
|
+
fetch_app_knowledge,
|
|
17
|
+
update_app_knowledge,
|
|
18
|
+
)
|
|
19
|
+
from minitest_cli.core.auth import require_auth
|
|
20
|
+
from minitest_cli.core.config import Settings
|
|
21
|
+
from minitest_cli.utils.output import (
|
|
22
|
+
print_error,
|
|
23
|
+
print_info,
|
|
24
|
+
print_json,
|
|
25
|
+
print_success,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(name="app-knowledge", help="Read and update an app's AppKnowledge.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback()
|
|
32
|
+
def _callback() -> None:
|
|
33
|
+
"""App-knowledge operations."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_settings() -> Settings:
|
|
37
|
+
return typer.Context.settings # type: ignore[attr-defined]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_json_mode() -> bool:
|
|
41
|
+
return typer.Context.json_mode # type: ignore[attr-defined]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command(name="get")
|
|
45
|
+
def get_app_knowledge(
|
|
46
|
+
app_id: Annotated[
|
|
47
|
+
str,
|
|
48
|
+
typer.Option(
|
|
49
|
+
"--app",
|
|
50
|
+
help="App ID to read AppKnowledge for. Required.",
|
|
51
|
+
),
|
|
52
|
+
],
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Read the current AppKnowledge content for an app.
|
|
55
|
+
|
|
56
|
+
Without ``--json``, prints the markdown content to stdout. With ``--json``,
|
|
57
|
+
prints the full record (including ``versionNumber`` if available).
|
|
58
|
+
"""
|
|
59
|
+
settings = _get_settings()
|
|
60
|
+
json_mode = _is_json_mode()
|
|
61
|
+
require_auth(settings)
|
|
62
|
+
|
|
63
|
+
record = fetch_app_knowledge(settings, app_id)
|
|
64
|
+
|
|
65
|
+
if json_mode:
|
|
66
|
+
print_json(record)
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
content = record.get("content")
|
|
70
|
+
if not content:
|
|
71
|
+
print_info("No AppKnowledge content set for this app.")
|
|
72
|
+
return
|
|
73
|
+
print(content) # noqa: T201
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command(name="update")
|
|
77
|
+
def update_app_knowledge_command(
|
|
78
|
+
app_id: Annotated[
|
|
79
|
+
str,
|
|
80
|
+
typer.Option(
|
|
81
|
+
"--app",
|
|
82
|
+
help="App ID to update AppKnowledge for. Required.",
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
content: Annotated[
|
|
86
|
+
str | None,
|
|
87
|
+
typer.Option(
|
|
88
|
+
"--content",
|
|
89
|
+
help="New AppKnowledge markdown content (inline string).",
|
|
90
|
+
),
|
|
91
|
+
] = None,
|
|
92
|
+
content_file: Annotated[
|
|
93
|
+
Path | None,
|
|
94
|
+
typer.Option(
|
|
95
|
+
"--content-file",
|
|
96
|
+
help="Path to a file whose contents become the new AppKnowledge.",
|
|
97
|
+
exists=True,
|
|
98
|
+
readable=True,
|
|
99
|
+
dir_okay=False,
|
|
100
|
+
),
|
|
101
|
+
] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Create a new version of the AppKnowledge prompt for an app.
|
|
104
|
+
|
|
105
|
+
Either ``--content`` or ``--content-file`` must be provided (not both).
|
|
106
|
+
Prints the new version number to stdout. With ``--json``, prints the full
|
|
107
|
+
response record.
|
|
108
|
+
"""
|
|
109
|
+
settings = _get_settings()
|
|
110
|
+
json_mode = _is_json_mode()
|
|
111
|
+
require_auth(settings)
|
|
112
|
+
|
|
113
|
+
body = _resolve_content(content, content_file)
|
|
114
|
+
record: dict[str, Any] = update_app_knowledge(settings, app_id, body)
|
|
115
|
+
|
|
116
|
+
if json_mode:
|
|
117
|
+
print_json(record)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
version_number = record.get("versionNumber") or record.get("version_number")
|
|
121
|
+
if version_number is None:
|
|
122
|
+
print_error("Backend response did not include a version number.")
|
|
123
|
+
raise typer.Exit(code=1)
|
|
124
|
+
print_success(f"AppKnowledge updated (version {version_number}).")
|
|
125
|
+
print(version_number) # noqa: T201
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_content(content: str | None, content_file: Path | None) -> str:
|
|
129
|
+
if content is None and content_file is None:
|
|
130
|
+
print_error("Provide --content or --content-file.")
|
|
131
|
+
raise typer.Exit(code=1)
|
|
132
|
+
if content is not None and content_file is not None:
|
|
133
|
+
print_error("Use either --content or --content-file, not both.")
|
|
134
|
+
raise typer.Exit(code=1)
|
|
135
|
+
if content is not None:
|
|
136
|
+
if content == "":
|
|
137
|
+
print_error("--content must not be empty.")
|
|
138
|
+
raise typer.Exit(code=1)
|
|
139
|
+
return content
|
|
140
|
+
assert content_file is not None # noqa: S101 for type checkers
|
|
141
|
+
text = content_file.read_text(encoding="utf-8")
|
|
142
|
+
if not text:
|
|
143
|
+
print_error(f"--content-file is empty: {content_file}")
|
|
144
|
+
raise typer.Exit(code=1)
|
|
145
|
+
return text
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Helpers for app-knowledge commands: error mapping and HTTP calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Coroutine
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from minitest_cli.api.client import ApiClient
|
|
13
|
+
from minitest_cli.core.config import Settings
|
|
14
|
+
from minitest_cli.utils.output import print_error
|
|
15
|
+
|
|
16
|
+
EXIT_GENERAL_ERROR = 1
|
|
17
|
+
EXIT_NETWORK_ERROR = 3
|
|
18
|
+
EXIT_NOT_FOUND = 4
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_detail(resp: httpx.Response) -> str:
|
|
22
|
+
try:
|
|
23
|
+
body = resp.json()
|
|
24
|
+
if isinstance(body, dict):
|
|
25
|
+
detail = body.get("detail") or body.get("message")
|
|
26
|
+
if detail:
|
|
27
|
+
return str(detail)
|
|
28
|
+
except Exception: # noqa: BLE001
|
|
29
|
+
pass
|
|
30
|
+
return resp.text or f"HTTP {resp.status_code}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _handle_response_error(resp: httpx.Response, *, resource: str = "App") -> None:
|
|
34
|
+
if resp.status_code < 400:
|
|
35
|
+
return
|
|
36
|
+
detail = _extract_detail(resp)
|
|
37
|
+
if resp.status_code == 404:
|
|
38
|
+
print_error(f"{resource} not found: {detail}")
|
|
39
|
+
raise typer.Exit(code=EXIT_NOT_FOUND)
|
|
40
|
+
if resp.status_code in (401, 403):
|
|
41
|
+
print_error(f"Authentication failed ({resp.status_code}): {detail}")
|
|
42
|
+
raise typer.Exit(code=EXIT_GENERAL_ERROR)
|
|
43
|
+
if 400 <= resp.status_code < 500:
|
|
44
|
+
print_error(f"Validation error ({resp.status_code}): {detail}")
|
|
45
|
+
raise typer.Exit(code=EXIT_GENERAL_ERROR)
|
|
46
|
+
print_error(f"Backend error ({resp.status_code}): {detail}")
|
|
47
|
+
raise typer.Exit(code=EXIT_NETWORK_ERROR)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _run_api_call[T](coro: Coroutine[Any, Any, T]) -> T:
|
|
51
|
+
try:
|
|
52
|
+
return asyncio.run(coro)
|
|
53
|
+
except httpx.HTTPError as exc:
|
|
54
|
+
print_error(f"Network error: {exc}")
|
|
55
|
+
raise typer.Exit(code=EXIT_NETWORK_ERROR) from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fetch_app_knowledge(settings: Settings, app_id: str) -> dict[str, Any]:
|
|
59
|
+
"""Read the current AppKnowledge for an app.
|
|
60
|
+
|
|
61
|
+
testing-service has no dedicated GET endpoint, so we read the test-config
|
|
62
|
+
record (which carries the latest ``app_knowledge`` content) and surface a
|
|
63
|
+
minimal ``{appId, content}`` shape for callers.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
async def _run() -> dict[str, Any]:
|
|
67
|
+
async with ApiClient(settings) as client:
|
|
68
|
+
resp = await client.get(f"/api/v1/apps/{app_id}/test-config")
|
|
69
|
+
_handle_response_error(resp)
|
|
70
|
+
payload = resp.json()
|
|
71
|
+
content: Any = None
|
|
72
|
+
if isinstance(payload, dict):
|
|
73
|
+
content = payload.get("appKnowledge") or payload.get("app_knowledge")
|
|
74
|
+
return {"appId": app_id, "content": content}
|
|
75
|
+
|
|
76
|
+
return _run_api_call(_run())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def update_app_knowledge(settings: Settings, app_id: str, content: str) -> dict[str, Any]:
|
|
80
|
+
"""Push a new AppKnowledge version for an app.
|
|
81
|
+
|
|
82
|
+
Calls ``PUT /api/v1/apps/{app_id}/app-knowledge`` with body
|
|
83
|
+
``{"content": ...}`` and returns the parsed response.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
async def _run() -> dict[str, Any]:
|
|
87
|
+
async with ApiClient(settings) as client:
|
|
88
|
+
resp = await client.put(
|
|
89
|
+
f"/api/v1/apps/{app_id}/app-knowledge",
|
|
90
|
+
json={"content": content},
|
|
91
|
+
)
|
|
92
|
+
_handle_response_error(resp)
|
|
93
|
+
data = resp.json()
|
|
94
|
+
if not isinstance(data, dict):
|
|
95
|
+
print_error("Unexpected response shape from update endpoint.")
|
|
96
|
+
raise typer.Exit(code=EXIT_NETWORK_ERROR)
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
return _run_api_call(_run())
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""App management commands: list, create."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from minitest_cli.api.apps_manager_client import AppsManagerClient # noqa: F401 re-exported for tests
|
|
11
|
+
from minitest_cli.api.client import ApiClient
|
|
12
|
+
from minitest_cli.commands.apps_helpers import create_app_request
|
|
13
|
+
from minitest_cli.core.auth import require_auth
|
|
14
|
+
from minitest_cli.core.config import Settings
|
|
15
|
+
from minitest_cli.core.tenants import fetch_user_tenants, resolve_tenant_id
|
|
16
|
+
from minitest_cli.models.app import AppDetailResponse, AppListResponse
|
|
17
|
+
from minitest_cli.utils.output import (
|
|
18
|
+
err_console,
|
|
19
|
+
print_error,
|
|
20
|
+
print_json,
|
|
21
|
+
print_success,
|
|
22
|
+
print_table,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
EXIT_NETWORK_ERROR = 3
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(name="apps", help="App management.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.callback()
|
|
31
|
+
def _callback() -> None:
|
|
32
|
+
"""App management."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
APP_TABLE_HEADERS = ["ID", "Name", "Platform"]
|
|
36
|
+
|
|
37
|
+
_PLATFORM_LABELS: dict[str, str] = {
|
|
38
|
+
"android": "Android",
|
|
39
|
+
"ios": "iOS",
|
|
40
|
+
"cross_platform": "Cross-platform",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _format_platform(value: str | None) -> str:
|
|
45
|
+
"""Render an AppPlatform value for the table; pass through unknown values."""
|
|
46
|
+
if value is None:
|
|
47
|
+
return "—"
|
|
48
|
+
return _PLATFORM_LABELS.get(value, value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_settings() -> Settings:
|
|
52
|
+
return typer.Context.settings # type: ignore[attr-defined]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_json_mode() -> bool:
|
|
56
|
+
return typer.Context.json_mode # type: ignore[attr-defined]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command(name="list")
|
|
60
|
+
def list_apps() -> None:
|
|
61
|
+
"""List all apps."""
|
|
62
|
+
settings = _get_settings()
|
|
63
|
+
json_mode = _is_json_mode()
|
|
64
|
+
require_auth(settings)
|
|
65
|
+
|
|
66
|
+
async def _list() -> AppListResponse:
|
|
67
|
+
async with ApiClient(settings) as client:
|
|
68
|
+
resp = await client.get("/api/v1/apps")
|
|
69
|
+
if resp.status_code >= 400:
|
|
70
|
+
detail = resp.text
|
|
71
|
+
try:
|
|
72
|
+
body = resp.json()
|
|
73
|
+
if isinstance(body, dict):
|
|
74
|
+
detail = body.get("detail") or body.get("message") or detail
|
|
75
|
+
except Exception: # noqa: BLE001
|
|
76
|
+
pass
|
|
77
|
+
print_error(f"API error ({resp.status_code}): {detail}")
|
|
78
|
+
raise typer.Exit(code=EXIT_NETWORK_ERROR)
|
|
79
|
+
return AppListResponse.model_validate(resp.json())
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
data = asyncio.run(_list())
|
|
83
|
+
except httpx.HTTPError as exc:
|
|
84
|
+
print_error(f"Network error: {exc}")
|
|
85
|
+
raise typer.Exit(code=EXIT_NETWORK_ERROR) from exc
|
|
86
|
+
|
|
87
|
+
if json_mode:
|
|
88
|
+
print_json([a.model_dump(mode="json", by_alias=True) for a in data.apps])
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
rows = [[a.id, a.name, _format_platform(a.platform)] for a in data.apps]
|
|
92
|
+
print_table(APP_TABLE_HEADERS, rows, title="Apps")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command(name="create")
|
|
96
|
+
def create_app(
|
|
97
|
+
name: Annotated[
|
|
98
|
+
str,
|
|
99
|
+
typer.Option(
|
|
100
|
+
"--name",
|
|
101
|
+
help="Human-readable app name.",
|
|
102
|
+
),
|
|
103
|
+
],
|
|
104
|
+
tenant: Annotated[
|
|
105
|
+
str | None,
|
|
106
|
+
typer.Option(
|
|
107
|
+
"--tenant",
|
|
108
|
+
help=(
|
|
109
|
+
"Tenant ID to create the app under. Auto-resolved when the user has a "
|
|
110
|
+
"single tenant; prompts on a TTY otherwise."
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
] = None,
|
|
114
|
+
description: Annotated[
|
|
115
|
+
str | None,
|
|
116
|
+
typer.Option("--description", help="Optional description."),
|
|
117
|
+
] = None,
|
|
118
|
+
slug: Annotated[
|
|
119
|
+
str | None,
|
|
120
|
+
typer.Option(
|
|
121
|
+
"--slug",
|
|
122
|
+
help="URL-friendly slug. Auto-generated from the name if omitted.",
|
|
123
|
+
),
|
|
124
|
+
] = None,
|
|
125
|
+
icon: Annotated[
|
|
126
|
+
Path | None,
|
|
127
|
+
typer.Option(
|
|
128
|
+
"--icon",
|
|
129
|
+
help="Optional path to an icon image to upload.",
|
|
130
|
+
exists=True,
|
|
131
|
+
readable=True,
|
|
132
|
+
dir_okay=False,
|
|
133
|
+
),
|
|
134
|
+
] = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Create a new app on a tenant.
|
|
137
|
+
|
|
138
|
+
Calls ``POST /api/v1/tenants/{tenant_id}/apps`` on apps-manager. Prints the
|
|
139
|
+
created app's id to stdout; with ``--json``, prints the full record.
|
|
140
|
+
"""
|
|
141
|
+
settings = _get_settings()
|
|
142
|
+
json_mode = _is_json_mode()
|
|
143
|
+
require_auth(settings)
|
|
144
|
+
|
|
145
|
+
async def _run() -> AppDetailResponse:
|
|
146
|
+
if tenant:
|
|
147
|
+
resolved_tenant_id = tenant
|
|
148
|
+
else:
|
|
149
|
+
tenants = await fetch_user_tenants(settings)
|
|
150
|
+
resolved_tenant_id = resolve_tenant_id(settings, None, tenants)
|
|
151
|
+
if len(tenants) > 1:
|
|
152
|
+
err_console.print(f"[dim]Using tenant {resolved_tenant_id}[/dim]")
|
|
153
|
+
|
|
154
|
+
return await create_app_request(
|
|
155
|
+
settings,
|
|
156
|
+
tenant_id=resolved_tenant_id,
|
|
157
|
+
name=name,
|
|
158
|
+
description=description,
|
|
159
|
+
slug=slug,
|
|
160
|
+
icon=icon,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
created = asyncio.run(_run())
|
|
165
|
+
except httpx.HTTPError as exc:
|
|
166
|
+
print_error(f"Network error: {exc}")
|
|
167
|
+
raise typer.Exit(code=EXIT_NETWORK_ERROR) from exc
|
|
168
|
+
|
|
169
|
+
if json_mode:
|
|
170
|
+
print_json(created.model_dump(mode="json", by_alias=True))
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
print_success(f"Created app '{created.name}' (slug: {created.slug})")
|
|
174
|
+
print(created.id) # noqa: T201
|