minitest-cli 0.8.2__tar.gz → 0.9.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.
Files changed (85) hide show
  1. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/AGENTS.md +1 -1
  2. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/PKG-INFO +1 -1
  3. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/pyproject.toml +1 -1
  4. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/build.py +2 -0
  5. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/build_helpers.py +37 -1
  6. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/skill.py +3 -3
  7. minitest_cli-0.9.0/src/minitest_cli/commands/test_file.py +163 -0
  8. minitest_cli-0.9.0/src/minitest_cli/commands/test_file_helpers.py +68 -0
  9. minitest_cli-0.9.0/src/minitest_cli/commands/test_file_list.py +58 -0
  10. minitest_cli-0.9.0/src/minitest_cli/commands/test_profile.py +172 -0
  11. minitest_cli-0.9.0/src/minitest_cli/commands/test_profile_helpers.py +70 -0
  12. minitest_cli-0.9.0/src/minitest_cli/commands/test_profile_list.py +69 -0
  13. minitest_cli-0.9.0/src/minitest_cli/commands/user_story_bindings.py +154 -0
  14. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/main.py +6 -0
  15. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/build.py +1 -0
  16. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_build_commands.py +73 -0
  17. minitest_cli-0.9.0/tests/test_test_file_commands.py +135 -0
  18. minitest_cli-0.9.0/tests/test_test_profile_commands.py +192 -0
  19. minitest_cli-0.9.0/tests/test_user_story_bindings_commands.py +140 -0
  20. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/uv.lock +1 -1
  21. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.env.example +0 -0
  22. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.github/workflows/ci.yml +0 -0
  23. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.github/workflows/install-scripts.yml +0 -0
  24. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.github/workflows/release.yml +0 -0
  25. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.gitignore +0 -0
  26. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/.opencode/skill/release/SKILL.md +0 -0
  27. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/README.md +0 -0
  28. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/RELEASE.md +0 -0
  29. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/install.ps1 +0 -0
  30. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/install.sh +0 -0
  31. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/pyrightconfig.json +0 -0
  32. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/__init__.py +0 -0
  33. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/api/__init__.py +0 -0
  34. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/api/apps_manager_client.py +0 -0
  35. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/api/client.py +0 -0
  36. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/assets/__init__.py +0 -0
  37. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/assets/callback.html +0 -0
  38. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/__init__.py +0 -0
  39. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/app_knowledge.py +0 -0
  40. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/app_knowledge_helpers.py +0 -0
  41. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/apps.py +0 -0
  42. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/apps_helpers.py +0 -0
  43. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/auth.py +0 -0
  44. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/batch.py +0 -0
  45. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/batch_helpers.py +0 -0
  46. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/flow_types.py +0 -0
  47. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/maintenance_check.py +0 -0
  48. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/run.py +0 -0
  49. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/run_display.py +0 -0
  50. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/run_helpers.py +0 -0
  51. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/upgrade.py +0 -0
  52. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/user_story.py +0 -0
  53. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/user_story_helpers.py +0 -0
  54. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/commands/user_story_modify.py +0 -0
  55. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/__init__.py +0 -0
  56. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/app_context.py +0 -0
  57. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/auth.py +0 -0
  58. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/config.py +0 -0
  59. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/credentials.py +0 -0
  60. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/oauth.py +0 -0
  61. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/tenants.py +0 -0
  62. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/core/token_exchange.py +0 -0
  63. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/__init__.py +0 -0
  64. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/app.py +0 -0
  65. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/base.py +0 -0
  66. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/maintenance_check.py +0 -0
  67. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/story_run.py +0 -0
  68. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/models/user_story.py +0 -0
  69. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/utils/__init__.py +0 -0
  70. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/utils/output.py +0 -0
  71. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/utils/skill_refresh.py +0 -0
  72. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/src/minitest_cli/utils/update_check.py +0 -0
  73. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/__init__.py +0 -0
  74. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_app_knowledge_commands.py +0 -0
  75. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_apps_commands.py +0 -0
  76. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_auth.py +0 -0
  77. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_auth_commands.py +0 -0
  78. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_batch_commands.py +0 -0
  79. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_code_quality.py +0 -0
  80. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_flow_types_commands.py +0 -0
  81. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_run_commands.py +0 -0
  82. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_skill_command.py +0 -0
  83. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_upgrade_command.py +0 -0
  84. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_user_story_commands.py +0 -0
  85. {minitest_cli-0.8.2 → minitest_cli-0.9.0}/tests/test_version.py +0 -0
@@ -49,7 +49,7 @@ uv add <package> # Add new dependency (always
49
49
  ### Output Convention
50
50
  - `--json` flag: JSON to stdout, diagnostics to stderr
51
51
  - Without `--json`: human-friendly rich tables to stdout, diagnostics to stderr
52
- - Exit codes: 0=Success, 1=General error, 2=Auth error, 3=Network/API error, 4=Not found
52
+ - Exit codes: 0=Success, 1=General error, 2=Auth error, 3=Network/API error, 4=Not found, 5=Build invalid
53
53
 
54
54
  ### No Interactive Prompts
55
55
  - All input via flags, env vars, or stdin
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: minitest-cli
3
- Version: 0.8.2
3
+ Version: 0.9.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "minitest-cli"
3
- version = "0.8.2"
3
+ version = "0.9.0"
4
4
  description = "Minitest CLI – command-line interface for the Minitest testing platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -14,6 +14,7 @@ from minitest_cli.commands.build_helpers import (
14
14
  format_build_row,
15
15
  format_pagination_info,
16
16
  handle_response_error,
17
+ print_validation_warnings,
17
18
  resolve_app,
18
19
  run_api_call,
19
20
  upload_status_message,
@@ -79,6 +80,7 @@ def upload(
79
80
  if json_mode:
80
81
  output(result_dict, json_mode=True)
81
82
  else:
83
+ print_validation_warnings(result.validation_warnings)
82
84
  print_success(f"Build uploaded: {file.name} ({resolved_platform})")
83
85
  output(result_dict, json_mode=False)
84
86
 
@@ -12,10 +12,13 @@ import typer
12
12
  from minitest_cli.core.app_context import resolve_app_id
13
13
  from minitest_cli.core.config import Settings
14
14
  from minitest_cli.models import BuildListResponse, BuildResponse
15
- from minitest_cli.utils.output import print_error
15
+ from minitest_cli.utils.output import err_console, print_error
16
16
 
17
17
  EXIT_NETWORK_ERROR = 3
18
18
  EXIT_NOT_FOUND = 4
19
+ EXIT_BUILD_INVALID = 5
20
+
21
+ BUILD_INVALID_ERROR_CODE = "build_invalid"
19
22
 
20
23
  # ---------------------------------------------------------------------------
21
24
  # Context accessors
@@ -93,11 +96,44 @@ def handle_response_error(resp: httpx.Response, *, resource: str = "Build") -> N
93
96
  if resp.status_code == 404:
94
97
  print_error(f"{resource} not found: {extract_detail(resp)}")
95
98
  raise typer.Exit(code=EXIT_NOT_FOUND)
99
+ if resp.status_code == 422 and _try_handle_build_invalid(resp):
100
+ raise typer.Exit(code=EXIT_BUILD_INVALID)
96
101
  if resp.status_code >= 400:
97
102
  print_error(f"API error ({resp.status_code}): {extract_detail(resp)}")
98
103
  raise typer.Exit(code=EXIT_NETWORK_ERROR)
99
104
 
100
105
 
106
+ def _try_handle_build_invalid(resp: httpx.Response) -> bool:
107
+ """If response is a build-invalid error, render issues and return True."""
108
+ try:
109
+ body = resp.json()
110
+ except Exception: # noqa: BLE001
111
+ return False
112
+ if not isinstance(body, dict) or body.get("error_code") != BUILD_INVALID_ERROR_CODE:
113
+ return False
114
+ issues = body.get("issues") or []
115
+ print_error("Build rejected: failed validation for virtual-device execution.")
116
+ for issue in issues:
117
+ if not isinstance(issue, dict):
118
+ continue
119
+ code = issue.get("code", "unknown")
120
+ message = issue.get("message", "")
121
+ err_console.print(f" [red]✖[/red] {code}: {message}")
122
+ return True
123
+
124
+
125
+ def print_validation_warnings(warnings: list[dict] | None) -> None:
126
+ """Print non-fatal validation warnings to stderr."""
127
+ if not warnings:
128
+ return
129
+ for warning in warnings:
130
+ if not isinstance(warning, dict):
131
+ continue
132
+ code = warning.get("code", "unknown")
133
+ message = warning.get("message", "")
134
+ err_console.print(f" [yellow]⚠[/yellow] {code}: {message}")
135
+
136
+
101
137
  def run_api_call[T](coro: Coroutine[object, object, T]) -> T:
102
138
  """Run an async API coroutine, catching network errors → exit 3."""
103
139
  try:
@@ -1,5 +1,4 @@
1
- """Skill command – fetch the latest CLI skill instructions from GitHub."""
2
-
1
+ import os
3
2
  import sys
4
3
 
5
4
  import httpx
@@ -9,9 +8,10 @@ from minitest_cli.utils.output import err_console, print_error
9
8
 
10
9
  app = typer.Typer(name="skill", help="Retrieve the minitest CLI skill for AI agents.")
11
10
 
12
- SKILL_URL = (
11
+ _DEFAULT_SKILL_URL = (
13
12
  "https://raw.githubusercontent.com/minitap-ai/agent-skills/main/skills/minitest-cli/SKILL.md"
14
13
  )
14
+ SKILL_URL = os.environ.get("MINITEST_SKILL_URL", _DEFAULT_SKILL_URL)
15
15
 
16
16
  EXIT_NETWORK_ERROR = 3
17
17
 
@@ -0,0 +1,163 @@
1
+ import mimetypes
2
+ from pathlib import Path
3
+ from typing import Annotated, Any
4
+
5
+ import typer
6
+
7
+ from minitest_cli.api.client import ApiClient
8
+ from minitest_cli.commands import test_file_list
9
+ from minitest_cli.commands.test_file_helpers import (
10
+ MAX_UPLOAD_BYTES,
11
+ base_path,
12
+ get_app_flag,
13
+ get_settings,
14
+ handle_file_response,
15
+ is_json_mode,
16
+ run_api_call,
17
+ )
18
+ from minitest_cli.core.app_context import resolve_app_id
19
+ from minitest_cli.core.auth import require_auth
20
+ from minitest_cli.utils.output import output, print_error, print_success
21
+
22
+ app = typer.Typer(name="test-file", help="Test-file operations (app-scoped).")
23
+ test_file_list.register(app)
24
+
25
+
26
+ @app.command(name="upload")
27
+ def upload_file(
28
+ path: Annotated[
29
+ Path,
30
+ typer.Argument(
31
+ exists=True,
32
+ file_okay=True,
33
+ dir_okay=False,
34
+ readable=True,
35
+ resolve_path=True,
36
+ help="Path to the local file to upload.",
37
+ ),
38
+ ],
39
+ name: Annotated[
40
+ str | None, typer.Option("--name", help="Display name (defaults to the file basename).")
41
+ ] = None,
42
+ note: Annotated[
43
+ str | None, typer.Option("--note", help="Optional 'what this file is for' note.")
44
+ ] = None,
45
+ ) -> None:
46
+ settings = get_settings()
47
+ json_mode = is_json_mode()
48
+ require_auth(settings)
49
+ app_id = resolve_app_id(settings, get_app_flag())
50
+
51
+ size = path.stat().st_size
52
+ if size > MAX_UPLOAD_BYTES:
53
+ print_error(f"File too large: {size} bytes (max {MAX_UPLOAD_BYTES} bytes / 25 MB).")
54
+ raise typer.Exit(code=1)
55
+
56
+ mime, _ = mimetypes.guess_type(path.name)
57
+ mime = mime or "application/octet-stream"
58
+
59
+ async def _run() -> dict[str, Any]:
60
+ async with ApiClient(settings) as client:
61
+ with path.open("rb") as fh:
62
+ data = {}
63
+ if name is not None:
64
+ data["name"] = name
65
+ if note is not None:
66
+ data["note"] = note
67
+ resp = await client.upload_file(
68
+ base_path(app_id),
69
+ files={"file": (path.name, fh, mime)},
70
+ data=data,
71
+ )
72
+ handle_file_response(resp)
73
+ return resp.json()
74
+
75
+ data = run_api_call(_run())
76
+ if not json_mode:
77
+ print_success(f"Test file uploaded: {data.get('id', '')}")
78
+ output(data, json_mode=json_mode)
79
+
80
+
81
+ @app.command(name="get")
82
+ def get_file(
83
+ file_id: Annotated[str, typer.Argument(help="Test file ID.")],
84
+ ) -> None:
85
+ settings = get_settings()
86
+ json_mode = is_json_mode()
87
+ require_auth(settings)
88
+ app_id = resolve_app_id(settings, get_app_flag())
89
+
90
+ async def _run() -> dict[str, Any]:
91
+ async with ApiClient(settings) as client:
92
+ resp = await client.get(f"{base_path(app_id)}/{file_id}")
93
+ handle_file_response(resp)
94
+ return resp.json()
95
+
96
+ output(run_api_call(_run()), json_mode=json_mode)
97
+
98
+
99
+ @app.command(name="update")
100
+ def update_file(
101
+ file_id: Annotated[str, typer.Argument(help="Test file ID.")],
102
+ name: Annotated[str | None, typer.Option("--name", help="New display name.")] = None,
103
+ note: Annotated[str | None, typer.Option("--note", help="New note.")] = None,
104
+ clear_note: Annotated[
105
+ bool, typer.Option("--clear-note", help="Remove the existing note.")
106
+ ] = False,
107
+ ) -> None:
108
+ settings = get_settings()
109
+ json_mode = is_json_mode()
110
+ require_auth(settings)
111
+ app_id = resolve_app_id(settings, get_app_flag())
112
+
113
+ if note is not None and clear_note:
114
+ print_error("Use either --note or --clear-note, not both.")
115
+ raise typer.Exit(code=1)
116
+
117
+ body: dict[str, Any] = {}
118
+ if name is not None:
119
+ body["name"] = name
120
+ if note is not None:
121
+ body["note"] = note
122
+ if clear_note:
123
+ body["clearNote"] = True
124
+
125
+ if not body:
126
+ print_error("Provide at least one field to update.")
127
+ raise typer.Exit(code=1)
128
+
129
+ async def _run() -> dict[str, Any]:
130
+ async with ApiClient(settings) as client:
131
+ resp = await client.patch(f"{base_path(app_id)}/{file_id}", json=body)
132
+ handle_file_response(resp)
133
+ return resp.json()
134
+
135
+ data = run_api_call(_run())
136
+ if not json_mode:
137
+ print_success(f"Test file updated: {file_id}")
138
+ output(data, json_mode=json_mode)
139
+
140
+
141
+ @app.command(name="delete")
142
+ def delete_file(
143
+ file_id: Annotated[str, typer.Argument(help="Test file ID.")],
144
+ force: Annotated[bool, typer.Option("--force", help="Skip confirmation.")] = False,
145
+ ) -> None:
146
+ settings = get_settings()
147
+ json_mode = is_json_mode()
148
+ require_auth(settings)
149
+ if not force:
150
+ print_error("Delete requires --force flag.")
151
+ raise typer.Exit(code=1)
152
+ app_id = resolve_app_id(settings, get_app_flag())
153
+
154
+ async def _run() -> None:
155
+ async with ApiClient(settings) as client:
156
+ resp = await client.delete(f"{base_path(app_id)}/{file_id}")
157
+ handle_file_response(resp)
158
+
159
+ run_api_call(_run())
160
+ if json_mode:
161
+ output({"deleted": True, "id": file_id}, json_mode=True)
162
+ else:
163
+ print_success(f"Test file deleted: {file_id}")
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+ import typer
7
+
8
+ from minitest_cli.commands.user_story_helpers import (
9
+ extract_detail,
10
+ get_app_flag,
11
+ get_settings,
12
+ is_json_mode,
13
+ run_api_call,
14
+ )
15
+ from minitest_cli.utils.output import print_error
16
+
17
+ EXIT_GENERAL_ERROR = 1
18
+ EXIT_NETWORK_ERROR = 3
19
+ EXIT_NOT_FOUND = 4
20
+
21
+ MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # mirrors the testing-service multipart cap
22
+
23
+ FILE_TABLE_HEADERS = ["ID", "Name", "Original Filename", "Kind", "MIME", "Size", "Updated At"]
24
+
25
+
26
+ def base_path(app_id: str) -> str:
27
+ return f"/api/v1/apps/{app_id}/test-files"
28
+
29
+
30
+ def handle_file_response(resp: httpx.Response, *, resource: str = "Test file") -> None:
31
+ if resp.status_code == 404:
32
+ detail = extract_detail(resp)
33
+ print_error(detail or f"{resource} not found.")
34
+ raise typer.Exit(code=EXIT_NOT_FOUND)
35
+ if resp.status_code >= 400:
36
+ detail = extract_detail(resp)
37
+ print_error(detail or f"API error: {resp.status_code}")
38
+ raise typer.Exit(code=EXIT_NETWORK_ERROR)
39
+
40
+
41
+ def format_file_row(entry: dict[str, Any]) -> list[str]:
42
+ size = entry.get("sizeBytes") or entry.get("size_bytes") or 0
43
+ return [
44
+ str(entry.get("id", "")),
45
+ entry.get("name", ""),
46
+ entry.get("originalFilename") or entry.get("original_filename") or "",
47
+ entry.get("kind", "") or "",
48
+ entry.get("mimeType") or entry.get("mime_type") or "",
49
+ str(size),
50
+ entry.get("updatedAt") or entry.get("updated_at") or "",
51
+ ]
52
+
53
+
54
+ __all__ = [
55
+ "EXIT_GENERAL_ERROR",
56
+ "EXIT_NETWORK_ERROR",
57
+ "EXIT_NOT_FOUND",
58
+ "FILE_TABLE_HEADERS",
59
+ "MAX_UPLOAD_BYTES",
60
+ "base_path",
61
+ "extract_detail",
62
+ "format_file_row",
63
+ "get_app_flag",
64
+ "get_settings",
65
+ "handle_file_response",
66
+ "is_json_mode",
67
+ "run_api_call",
68
+ ]
@@ -0,0 +1,58 @@
1
+ from typing import Annotated, Any
2
+
3
+ import typer
4
+
5
+ from minitest_cli.api.client import ApiClient
6
+ from minitest_cli.commands.test_file_helpers import (
7
+ FILE_TABLE_HEADERS,
8
+ base_path,
9
+ format_file_row,
10
+ get_app_flag,
11
+ get_settings,
12
+ handle_file_response,
13
+ is_json_mode,
14
+ run_api_call,
15
+ )
16
+ from minitest_cli.core.app_context import resolve_app_id
17
+ from minitest_cli.core.auth import require_auth
18
+ from minitest_cli.utils.output import output, print_info, print_table
19
+
20
+
21
+ def register(app: typer.Typer) -> None:
22
+
23
+ @app.command(name="list")
24
+ def list_files(
25
+ kind: Annotated[
26
+ str | None,
27
+ typer.Option("--kind", help="Filter by kind (image|document|video|audio|other)."),
28
+ ] = None,
29
+ page: Annotated[int, typer.Option("--page", min=1, help="Page number.")] = 1,
30
+ page_size: Annotated[
31
+ int, typer.Option("--page-size", min=1, max=100, help="Items per page.")
32
+ ] = 20,
33
+ ) -> None:
34
+ settings = get_settings()
35
+ json_mode = is_json_mode()
36
+ require_auth(settings)
37
+ app_id = resolve_app_id(settings, get_app_flag())
38
+
39
+ params: dict[str, Any] = {"page": page, "page_size": page_size}
40
+ if kind is not None:
41
+ params["kind"] = kind
42
+
43
+ async def _run() -> Any:
44
+ async with ApiClient(settings) as client:
45
+ resp = await client.get(base_path(app_id), params=params)
46
+ handle_file_response(resp)
47
+ return resp.json()
48
+
49
+ data = run_api_call(_run())
50
+ items = data["items"] if isinstance(data, dict) and "items" in data else data
51
+ if json_mode:
52
+ output(items, json_mode=True)
53
+ return
54
+ if not items:
55
+ print_info("No test files found.")
56
+ return
57
+ rows = [format_file_row(f) for f in items]
58
+ print_table(FILE_TABLE_HEADERS, rows, title=f"Test files ({len(items)})")
@@ -0,0 +1,172 @@
1
+ import sys
2
+ from typing import Annotated, Any
3
+
4
+ import typer
5
+
6
+ from minitest_cli.api.client import ApiClient
7
+ from minitest_cli.commands import test_profile_list
8
+ from minitest_cli.commands.test_profile_helpers import (
9
+ app_base_path,
10
+ get_app_flag,
11
+ get_settings,
12
+ handle_profile_response,
13
+ is_json_mode,
14
+ run_api_call,
15
+ )
16
+ from minitest_cli.core.app_context import resolve_app_id
17
+ from minitest_cli.core.auth import require_auth
18
+ from minitest_cli.utils.output import output, print_error, print_success
19
+
20
+ app = typer.Typer(name="test-profile", help="Test-profile operations (app-scoped).")
21
+ test_profile_list.register(app)
22
+
23
+
24
+ def _read_password(password: str | None, password_stdin: bool) -> str | None:
25
+ if password_stdin:
26
+ if password is not None:
27
+ print_error("Use either --password or --password-stdin, not both.")
28
+ raise typer.Exit(code=1)
29
+ return sys.stdin.read().rstrip("\r\n")
30
+ return password
31
+
32
+
33
+ @app.command(name="create")
34
+ def create_profile(
35
+ name: Annotated[str, typer.Option("--name", help="Profile name.")],
36
+ username: Annotated[str | None, typer.Option("--username", help="Account username.")] = None,
37
+ password: Annotated[
38
+ str | None, typer.Option("--password", help="Account password (use stdin for security).")
39
+ ] = None,
40
+ password_stdin: Annotated[
41
+ bool,
42
+ typer.Option("--password-stdin", help="Read the password from stdin (no echo)."),
43
+ ] = False,
44
+ about: Annotated[
45
+ str | None, typer.Option("--about", help="Free-text notes about the account.")
46
+ ] = None,
47
+ ) -> None:
48
+ settings = get_settings()
49
+ json_mode = is_json_mode()
50
+ require_auth(settings)
51
+ app_id = resolve_app_id(settings, get_app_flag())
52
+ pwd = _read_password(password, password_stdin)
53
+
54
+ body: dict[str, Any] = {"name": name}
55
+ if username is not None:
56
+ body["username"] = username
57
+ if pwd is not None:
58
+ body["password"] = pwd
59
+ if about is not None:
60
+ body["about"] = about
61
+
62
+ async def _run() -> dict[str, Any]:
63
+ async with ApiClient(settings) as client:
64
+ resp = await client.post(app_base_path(app_id), json=body)
65
+ handle_profile_response(resp)
66
+ return resp.json()
67
+
68
+ data = run_api_call(_run())
69
+ if not json_mode:
70
+ print_success(f"Test profile created: {data.get('id', '')}")
71
+ output(data, json_mode=json_mode)
72
+
73
+
74
+ @app.command(name="get")
75
+ def get_profile(
76
+ profile_id: Annotated[str, typer.Argument(help="Test profile ID.")],
77
+ ) -> None:
78
+ settings = get_settings()
79
+ json_mode = is_json_mode()
80
+ require_auth(settings)
81
+ app_id = resolve_app_id(settings, get_app_flag())
82
+
83
+ async def _run() -> dict[str, Any]:
84
+ async with ApiClient(settings) as client:
85
+ resp = await client.get(f"{app_base_path(app_id)}/{profile_id}")
86
+ handle_profile_response(resp)
87
+ return resp.json()
88
+
89
+ output(run_api_call(_run()), json_mode=json_mode)
90
+
91
+
92
+ @app.command(name="update")
93
+ def update_profile(
94
+ profile_id: Annotated[str, typer.Argument(help="Test profile ID.")],
95
+ name: Annotated[str | None, typer.Option("--name", help="New profile name.")] = None,
96
+ username: Annotated[str | None, typer.Option("--username", help="New username.")] = None,
97
+ password: Annotated[
98
+ str | None, typer.Option("--password", help="New password (overrides existing).")
99
+ ] = None,
100
+ password_stdin: Annotated[
101
+ bool,
102
+ typer.Option("--password-stdin", help="Read the new password from stdin."),
103
+ ] = False,
104
+ clear_password: Annotated[
105
+ bool,
106
+ typer.Option("--clear-password", help="Remove the stored password."),
107
+ ] = False,
108
+ about: Annotated[
109
+ str | None, typer.Option("--about", help="New about text (pass '' to clear).")
110
+ ] = None,
111
+ ) -> None:
112
+ settings = get_settings()
113
+ json_mode = is_json_mode()
114
+ require_auth(settings)
115
+ app_id = resolve_app_id(settings, get_app_flag())
116
+
117
+ pwd = _read_password(password, password_stdin)
118
+ if pwd is not None and clear_password:
119
+ print_error("Use either --password/--password-stdin or --clear-password, not both.")
120
+ raise typer.Exit(code=1)
121
+
122
+ body: dict[str, Any] = {}
123
+ if name is not None:
124
+ body["name"] = name
125
+ if username is not None:
126
+ body["username"] = username
127
+ if pwd is not None:
128
+ body["password"] = pwd
129
+ elif clear_password:
130
+ body["password"] = None
131
+ if about is not None:
132
+ body["about"] = about
133
+
134
+ if not body:
135
+ print_error("Provide at least one field to update.")
136
+ raise typer.Exit(code=1)
137
+
138
+ async def _run() -> dict[str, Any]:
139
+ async with ApiClient(settings) as client:
140
+ resp = await client.patch(f"{app_base_path(app_id)}/{profile_id}", json=body)
141
+ handle_profile_response(resp)
142
+ return resp.json()
143
+
144
+ data = run_api_call(_run())
145
+ if not json_mode:
146
+ print_success(f"Test profile updated: {profile_id}")
147
+ output(data, json_mode=json_mode)
148
+
149
+
150
+ @app.command(name="delete")
151
+ def delete_profile(
152
+ profile_id: Annotated[str, typer.Argument(help="Test profile ID.")],
153
+ force: Annotated[bool, typer.Option("--force", help="Skip confirmation.")] = False,
154
+ ) -> None:
155
+ settings = get_settings()
156
+ json_mode = is_json_mode()
157
+ require_auth(settings)
158
+ if not force:
159
+ print_error("Delete requires --force flag.")
160
+ raise typer.Exit(code=1)
161
+ app_id = resolve_app_id(settings, get_app_flag())
162
+
163
+ async def _run() -> None:
164
+ async with ApiClient(settings) as client:
165
+ resp = await client.delete(f"{app_base_path(app_id)}/{profile_id}")
166
+ handle_profile_response(resp)
167
+
168
+ run_api_call(_run())
169
+ if json_mode:
170
+ output({"deleted": True, "id": profile_id}, json_mode=True)
171
+ else:
172
+ print_success(f"Test profile deleted: {profile_id}")
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+ import typer
7
+
8
+ from minitest_cli.commands.user_story_helpers import (
9
+ extract_detail,
10
+ get_app_flag,
11
+ get_settings,
12
+ is_json_mode,
13
+ run_api_call,
14
+ )
15
+ from minitest_cli.utils.output import print_error
16
+
17
+ EXIT_NETWORK_ERROR = 3
18
+ EXIT_NOT_FOUND = 4
19
+
20
+ PROFILE_TABLE_HEADERS = ["ID", "Name", "Username", "Scope", "Updated At"]
21
+
22
+
23
+ def app_base_path(app_id: str) -> str:
24
+ return f"/api/v1/apps/{app_id}/test-profiles"
25
+
26
+
27
+ SHARED_PATH = "/api/v1/test-profiles/shared"
28
+
29
+
30
+ def handle_profile_response(resp: httpx.Response, *, resource: str = "Test profile") -> None:
31
+ if resp.status_code == 404:
32
+ detail = extract_detail(resp)
33
+ print_error(detail or f"{resource} not found.")
34
+ raise typer.Exit(code=EXIT_NOT_FOUND)
35
+ if resp.status_code >= 400:
36
+ detail = extract_detail(resp)
37
+ print_error(detail or f"API error: {resp.status_code}")
38
+ raise typer.Exit(code=EXIT_NETWORK_ERROR)
39
+
40
+
41
+ def profile_scope(profile: dict[str, Any]) -> str:
42
+ if profile.get("isShared") or profile.get("is_shared"):
43
+ return "shared"
44
+ return "app"
45
+
46
+
47
+ def format_profile_row(profile: dict[str, Any]) -> list[str]:
48
+ return [
49
+ str(profile.get("id", "")),
50
+ profile.get("name", ""),
51
+ profile.get("username") or "",
52
+ profile_scope(profile),
53
+ profile.get("updatedAt") or profile.get("updated_at") or "",
54
+ ]
55
+
56
+
57
+ __all__ = [
58
+ "EXIT_NETWORK_ERROR",
59
+ "EXIT_NOT_FOUND",
60
+ "PROFILE_TABLE_HEADERS",
61
+ "SHARED_PATH",
62
+ "app_base_path",
63
+ "extract_detail",
64
+ "format_profile_row",
65
+ "get_app_flag",
66
+ "get_settings",
67
+ "handle_profile_response",
68
+ "is_json_mode",
69
+ "run_api_call",
70
+ ]