quantikmind-cli 0.1.0__py3-none-any.whl

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.
quantikmind_cli/cli.py ADDED
@@ -0,0 +1,1672 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import time
8
+ from typing import Literal
9
+
10
+ import click
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ import yaml
14
+
15
+ from .api import AgentMetricsEndpointUnavailable, ApiError, AuthenticationError, NetworkError, QuantikMindApiClient
16
+ from .agent.prometheus import PrometheusClient, PrometheusError
17
+ from .config import (
18
+ DEFAULT_BASE_URL,
19
+ ConfigError,
20
+ agent_state_path,
21
+ load_agent_state,
22
+ credentials_path,
23
+ load_repo_config,
24
+ load_credentials,
25
+ resolve_base_url,
26
+ write_agent_state,
27
+ write_credentials,
28
+ write_repo_config,
29
+ )
30
+ from .git import BuildMetadata, GitError, get_current_build_metadata
31
+ from .manifest import ManifestImportError, load_manifest_payload
32
+ from .record_tests import ResultsImportError, collect_junit_records
33
+ from .scanner import LibraryScanError, SYNC_LIBRARY_FRAMEWORKS, scan_functional_library
34
+ from .subset import (
35
+ SUBSET_FRAMEWORKS,
36
+ SubsetError,
37
+ format_test_selectors,
38
+ machine_readable_selection_payload,
39
+ selected_test_selectors,
40
+ selection_stats,
41
+ )
42
+
43
+
44
+ console = Console(width=120)
45
+
46
+ SUPPORTED_FRAMEWORKS = (
47
+ "pytest",
48
+ "jest",
49
+ "maven",
50
+ "gradle",
51
+ "cypress",
52
+ "playwright",
53
+ "rspec",
54
+ "cucumber",
55
+ "robot",
56
+ "other",
57
+ )
58
+
59
+ SelectionReadiness = Literal["ready", "partial", "not_ready", "unknown"]
60
+ READY_SIGNAL_STATES = {"ready", "healthy", "ok", "full"}
61
+ NOT_READY_SIGNAL_STATES = {"insufficient_signal", "not_ready", "unhealthy", "error", "failed"}
62
+
63
+ DEFAULT_PATHS = {
64
+ "pytest": ("tests", "test-results"),
65
+ "jest": ("tests", "coverage"),
66
+ "maven": ("src/test", "target/surefire-reports"),
67
+ "gradle": ("src/test", "build/test-results"),
68
+ "cypress": ("cypress", "cypress/results"),
69
+ "playwright": ("tests", "test-results"),
70
+ "rspec": ("spec", "tmp/qmind-results"),
71
+ "cucumber": ("features", "reports/cucumber"),
72
+ "robot": ("tests", "results"),
73
+ "other": ("tests", "qmind-results"),
74
+ }
75
+
76
+ DEFAULT_PROMETHEUS_URL = "http://localhost:9090"
77
+ DEFAULT_AGENT_PUSH_INTERVAL_SECONDS = 30
78
+ AGENT_REQUIRED_METRICS = (
79
+ "service_cpu_load",
80
+ "service_errors_total",
81
+ "service_requests_total",
82
+ "service_response_time_seconds_bucket",
83
+ )
84
+ MAX_AGENT_PUSH_BACKOFF_SECONDS = 300
85
+
86
+
87
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
88
+ @click.version_option(package_name="quantikmind-cli")
89
+ def cli() -> None:
90
+ """Quantik Mind command line tools."""
91
+
92
+
93
+ @cli.command()
94
+ @click.option("--api-key", envvar="QMIND_API_KEY", help="Quantik Mind API key.")
95
+ @click.option(
96
+ "--api-url",
97
+ envvar="QMIND_API_URL",
98
+ default=DEFAULT_BASE_URL,
99
+ show_default=True,
100
+ help="Quantik Mind API base URL.",
101
+ )
102
+ @click.option("--project-name", envvar="QMIND_PROJECT_NAME", help="Existing project name.")
103
+ @click.option("--project-id", envvar="QMIND_PROJECT_ID", help="Existing internal project UUID.")
104
+ @click.option(
105
+ "--framework",
106
+ envvar="QMIND_FRAMEWORK",
107
+ type=click.Choice(SUPPORTED_FRAMEWORKS),
108
+ help="Primary test framework.",
109
+ )
110
+ @click.option("--test-dir", envvar="QMIND_TEST_DIR", help="Test directory to record in qmind.yaml.")
111
+ @click.option("--results-dir", envvar="QMIND_RESULTS_DIR", help="Test results directory.")
112
+ @click.option("--non-interactive", is_flag=True, help="Fail instead of prompting for missing values.")
113
+ def init(
114
+ api_key: str | None,
115
+ api_url: str,
116
+ project_name: str | None,
117
+ project_id: str | None,
118
+ framework: str | None,
119
+ test_dir: str | None,
120
+ results_dir: str | None,
121
+ non_interactive: bool,
122
+ ) -> None:
123
+ """Create qmind.yaml and connect local credentials."""
124
+ non_interactive = non_interactive or _is_ci()
125
+ api_url = api_url.rstrip("/")
126
+ existing_credentials = _load_existing_credentials()
127
+ api_key = api_key or existing_credentials.get("api_key")
128
+
129
+ if not api_key and non_interactive:
130
+ raise click.ClickException(
131
+ "Missing API key. Set QMIND_API_KEY or pass --api-key in CI/non-interactive mode."
132
+ )
133
+ if not api_key:
134
+ api_key = click.prompt("Quantik Mind API key", hide_input=True).strip()
135
+
136
+ client = QuantikMindApiClient(base_url=api_url, api_key=api_key)
137
+ identity: dict | None = None
138
+ try:
139
+ identity = client.get_auth_identity()
140
+ except (AuthenticationError, ApiError):
141
+ try:
142
+ client.validate_credentials()
143
+ except (AuthenticationError, NetworkError, ApiError) as exc:
144
+ raise click.ClickException(str(exc)) from exc
145
+ except NetworkError as exc:
146
+ raise click.ClickException(str(exc)) from exc
147
+
148
+ cwd = Path.cwd()
149
+ if not project_name and not project_id:
150
+ if non_interactive:
151
+ raise click.ClickException(
152
+ "Missing project. Pass --project-id or --project-name in CI/non-interactive mode."
153
+ )
154
+ project_name = click.prompt("Project name", default=cwd.name).strip()
155
+
156
+ selected_project = None
157
+ if project_id:
158
+ selected_project = _resolve_existing_project(client, project_id=project_id, project_name=project_name)
159
+ if not selected_project:
160
+ try:
161
+ projects = client.list_projects()
162
+ except (AuthenticationError, NetworkError, ApiError) as exc:
163
+ raise click.ClickException(str(exc)) from exc
164
+
165
+ selected_project = _select_project(
166
+ projects,
167
+ project_id=project_id,
168
+ project_name=project_name,
169
+ )
170
+ if not selected_project:
171
+ if project_name and not project_id and _identity_can_create_project(identity):
172
+ try:
173
+ selected_project = client.create_project(project_name)
174
+ except AuthenticationError as exc:
175
+ raise click.ClickException(
176
+ "This API key cannot create projects. Create the project in the UI or use an "
177
+ "organization-scoped API key with project creation permission."
178
+ ) from exc
179
+ except (NetworkError, ApiError) as exc:
180
+ raise click.ClickException(str(exc)) from exc
181
+ else:
182
+ raise click.ClickException(
183
+ "This API key cannot create projects. Create the project in the UI or use an "
184
+ "organization-scoped API key with project creation permission."
185
+ )
186
+
187
+ project_id = str(selected_project.get("id") or selected_project.get("project_id") or "")
188
+ project_name_value = selected_project.get("name") or project_name
189
+ project_name = str(project_name_value).strip() if project_name_value else ""
190
+ if project_name == project_id:
191
+ project_name = ""
192
+ if not project_id:
193
+ raise click.ClickException("The selected project response did not include a usable project id.")
194
+
195
+ if not framework:
196
+ if non_interactive:
197
+ raise click.ClickException(
198
+ "Missing framework. Pass --framework or set QMIND_FRAMEWORK in CI/non-interactive mode."
199
+ )
200
+ framework = click.prompt(
201
+ "Test framework",
202
+ type=click.Choice(SUPPORTED_FRAMEWORKS),
203
+ default="pytest",
204
+ show_choices=True,
205
+ )
206
+
207
+ default_test_dir, default_results_dir = DEFAULT_PATHS[framework]
208
+ if not test_dir:
209
+ if non_interactive:
210
+ test_dir = default_test_dir
211
+ else:
212
+ test_dir = click.prompt("Test directory", default=default_test_dir).strip()
213
+ if not results_dir:
214
+ if non_interactive:
215
+ results_dir = default_results_dir
216
+ else:
217
+ results_dir = click.prompt("Results directory", default=default_results_dir).strip()
218
+
219
+ repo_config = {
220
+ "project_id": project_id,
221
+ "framework": framework,
222
+ "test_dir": test_dir,
223
+ "results_dir": results_dir,
224
+ "api_url": api_url,
225
+ "agent": {
226
+ "prometheus_url": "",
227
+ "push_interval_seconds": 60,
228
+ },
229
+ }
230
+ if project_name:
231
+ repo_config["project_name"] = project_name
232
+ repo_config_path = write_repo_config(repo_config, root=cwd)
233
+ credentials_file = write_credentials(
234
+ {"api_key": api_key, "auth_mode": _auth_mode_for_api_key(api_key, existing_credentials)},
235
+ path=credentials_path(),
236
+ )
237
+
238
+ table = Table(title="Quantik Mind setup")
239
+ table.add_column("Setting")
240
+ table.add_column("Value")
241
+ table.add_row("Project", _project_display(project_id=project_id, project_name=project_name))
242
+ table.add_row("Framework", framework)
243
+ table.add_row("Test directory", test_dir)
244
+ table.add_row("Results directory", results_dir)
245
+ table.add_row("API URL", api_url)
246
+ table.add_row("Repo config", str(repo_config_path))
247
+ table.add_row("Credentials", str(credentials_file))
248
+ console.print(table)
249
+ _print_next_steps(
250
+ [
251
+ ("Preview detected library", "qmind sync library --dry-run"),
252
+ ("Upload library", "qmind sync library"),
253
+ ("Check readiness", "qmind status"),
254
+ ]
255
+ )
256
+
257
+
258
+ @cli.group()
259
+ def record() -> None:
260
+ """Record build and test execution metadata."""
261
+
262
+
263
+ @record.command()
264
+ @click.option("--commit", "commit_hash", help="Commit SHA or build identifier to record.")
265
+ @click.option("--branch", help="Branch name to record with --commit.")
266
+ @click.option("--message", help="Commit/build message to record with --commit.")
267
+ @click.option("--author", help="Author to record with --commit.")
268
+ def build(
269
+ commit_hash: str | None,
270
+ branch: str | None,
271
+ message: str | None,
272
+ author: str | None,
273
+ ) -> None:
274
+ """Record build metadata."""
275
+ try:
276
+ repo_config = load_repo_config(root=Path.cwd())
277
+ except ConfigError as exc:
278
+ raise click.ClickException(f"{exc}. Run `qmind init` from the repository root first.") from exc
279
+
280
+ project_id = str(repo_config.get("project_id") or "").strip()
281
+ if not project_id:
282
+ raise click.ClickException("Missing project_id in qmind.yaml. Run `qmind init` to configure this repo.")
283
+
284
+ try:
285
+ credentials = load_credentials(path=credentials_path())
286
+ except ConfigError as exc:
287
+ raise click.ClickException(f"{exc}. Run `qmind init` or set up ~/.qmind/credentials.yaml.") from exc
288
+
289
+ client = QuantikMindApiClient.from_config(repo_config, credentials)
290
+ try:
291
+ client.validate_credentials()
292
+ except AuthenticationError as exc:
293
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
294
+ except (NetworkError, ApiError) as exc:
295
+ raise click.ClickException(f"Could not validate Quantik Mind credentials: {exc}") from exc
296
+
297
+ if commit_hash:
298
+ metadata = BuildMetadata(
299
+ commit_hash=commit_hash.strip(),
300
+ message=(message or commit_hash).strip(),
301
+ author=(author or "").strip(),
302
+ timestamp=_utc_timestamp(),
303
+ branch=branch.strip() if branch else None,
304
+ files_changed=[],
305
+ )
306
+ else:
307
+ try:
308
+ metadata = get_current_build_metadata()
309
+ except GitError as exc:
310
+ raise click.ClickException(
311
+ f"{exc}. Run this command inside a git repository with at least one commit, "
312
+ "or pass --commit <sha-or-id> and optionally --branch <branch>."
313
+ ) from exc
314
+
315
+ try:
316
+ response = client.record_build_commit(
317
+ project_id=project_id,
318
+ commit_hash=metadata.commit_hash,
319
+ message=metadata.message,
320
+ author=metadata.author,
321
+ branch=metadata.branch,
322
+ files_changed=metadata.files_changed,
323
+ )
324
+ except AuthenticationError as exc:
325
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
326
+ except (NetworkError, ApiError) as exc:
327
+ raise click.ClickException(f"Could not record build: {exc}") from exc
328
+
329
+ if response.get("status") == "duplicate":
330
+ console.print(f"[bold green]✓ Build already recorded:[/bold green] {metadata.short_hash}")
331
+ else:
332
+ console.print(
333
+ f"[bold green]✓ Build recorded:[/bold green] {metadata.short_hash} "
334
+ f"({len(metadata.files_changed)} files changed)"
335
+ )
336
+
337
+
338
+ @record.command("tests")
339
+ @click.option("--results-dir", type=click.Path(path_type=Path), help="Override the qmind.yaml results directory.")
340
+ def record_tests(results_dir: Path | None) -> None:
341
+ """Record test execution metadata."""
342
+ try:
343
+ repo_config = load_repo_config(root=Path.cwd())
344
+ except ConfigError as exc:
345
+ raise click.ClickException(f"{exc}. Run `qmind init` from the repository root first.") from exc
346
+
347
+ project_id = str(repo_config.get("project_id") or "").strip()
348
+ if not project_id:
349
+ raise click.ClickException("Missing project_id in qmind.yaml. Run `qmind init` to configure this repo.")
350
+
351
+ configured_results_dir = results_dir or str(repo_config.get("results_dir") or "").strip()
352
+ if not configured_results_dir:
353
+ raise click.ClickException("Missing results_dir in qmind.yaml. Run `qmind init` or pass --results-dir.")
354
+
355
+ try:
356
+ credentials = load_credentials(path=credentials_path())
357
+ except ConfigError as exc:
358
+ raise click.ClickException(f"{exc}. Run `qmind init` or set up ~/.qmind/credentials.yaml.") from exc
359
+
360
+ client = QuantikMindApiClient.from_config(repo_config, credentials)
361
+ try:
362
+ client.validate_credentials()
363
+ except AuthenticationError as exc:
364
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
365
+ except (NetworkError, ApiError) as exc:
366
+ raise click.ClickException(f"Could not validate Quantik Mind credentials: {exc}") from exc
367
+
368
+ try:
369
+ parse_result = collect_junit_records(configured_results_dir)
370
+ except ResultsImportError as exc:
371
+ raise click.ClickException(str(exc)) from exc
372
+
373
+ for warning in parse_result.warnings:
374
+ click.echo(f"Warning: {warning}", err=True)
375
+
376
+ if not parse_result.records:
377
+ raise click.ClickException("No usable test result records found.")
378
+
379
+ try:
380
+ response = client.import_functional_history(project_id=project_id, records=parse_result.records)
381
+ except AuthenticationError as exc:
382
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
383
+ except (NetworkError, ApiError) as exc:
384
+ raise click.ClickException(f"Could not record test results: {exc}") from exc
385
+
386
+ counts = _status_counts(parse_result.records)
387
+ if response.get("status") == "already_imported":
388
+ console.print("[bold green]✓ Results already imported[/bold green]")
389
+ else:
390
+ console.print(
391
+ "[bold green]✓ Results recorded:[/bold green] "
392
+ f"{len(parse_result.records)} tests "
393
+ f"({counts.get('passed', 0)} passed, {counts.get('failed', 0)} failed"
394
+ f"{_skipped_summary(counts)})"
395
+ )
396
+
397
+
398
+ @cli.command()
399
+ @click.option(
400
+ "--framework",
401
+ type=click.Choice(SUBSET_FRAMEWORKS),
402
+ help="Override the framework configured in qmind.yaml.",
403
+ )
404
+ @click.option(
405
+ "--internal-ids",
406
+ is_flag=True,
407
+ help="Print backend internal numeric test IDs instead of canonical test_id values.",
408
+ )
409
+ @click.option(
410
+ "--changed-file",
411
+ "changed_files",
412
+ multiple=True,
413
+ help="Changed source file to use for selection. Repeat for multiple files.",
414
+ )
415
+ @click.option(
416
+ "--changed-files-file",
417
+ type=click.Path(path_type=Path, dir_okay=False),
418
+ help="JSON file containing an array of changed source files.",
419
+ )
420
+ @click.option(
421
+ "--json",
422
+ "json_output",
423
+ is_flag=True,
424
+ help="Print the complete selection payload as machine-readable JSON.",
425
+ )
426
+ def subset(
427
+ framework: str | None,
428
+ internal_ids: bool,
429
+ changed_files: tuple[str, ...],
430
+ changed_files_file: Path | None,
431
+ json_output: bool,
432
+ ) -> None:
433
+ """Request or inspect selected test subsets."""
434
+ try:
435
+ repo_config = load_repo_config(root=Path.cwd())
436
+ except ConfigError as exc:
437
+ raise click.ClickException(f"{exc}. Run `qmind init` from the repository root first.") from exc
438
+
439
+ project_id = str(repo_config.get("project_id") or "").strip()
440
+ if not project_id:
441
+ raise click.ClickException("Missing project_id in qmind.yaml. Run `qmind init` to configure this repo.")
442
+
443
+ selected_framework = framework or str(repo_config.get("framework") or "").strip()
444
+ if selected_framework not in SUBSET_FRAMEWORKS:
445
+ raise click.ClickException(
446
+ "Missing or unsupported framework for qmind subset. "
447
+ "Set framework in qmind.yaml or pass --framework."
448
+ )
449
+
450
+ try:
451
+ credentials = load_credentials(path=credentials_path())
452
+ except ConfigError as exc:
453
+ raise click.ClickException(f"{exc}. Run `qmind init` or set up ~/.qmind/credentials.yaml.") from exc
454
+
455
+ client = QuantikMindApiClient.from_config(repo_config, credentials)
456
+ try:
457
+ explicit_changed_files = _resolve_subset_changed_files(
458
+ changed_files=changed_files,
459
+ changed_files_file=changed_files_file,
460
+ )
461
+ response = client.select_functional_tests(
462
+ project_id,
463
+ changed_files=explicit_changed_files,
464
+ )
465
+ selectors = selected_test_selectors(response, use_internal_ids=internal_ids)
466
+ if json_output:
467
+ formatted = json.dumps(
468
+ machine_readable_selection_payload(response, selected_tests=selectors),
469
+ indent=2,
470
+ sort_keys=True,
471
+ )
472
+ else:
473
+ formatted = format_test_selectors(selected_framework, selectors)
474
+ except AuthenticationError as exc:
475
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
476
+ except (NetworkError, ApiError) as exc:
477
+ raise click.ClickException(f"Could not select tests: {exc}") from exc
478
+ except SubsetError as exc:
479
+ raise click.ClickException(str(exc)) from exc
480
+
481
+ stats = selection_stats(response)
482
+ if stats and not json_output:
483
+ click.echo(stats, err=True)
484
+ click.echo(formatted)
485
+
486
+
487
+ @cli.group()
488
+ def sync() -> None:
489
+ """Sync local Quantik Mind resources."""
490
+
491
+
492
+ @sync.command("library")
493
+ @click.option(
494
+ "--framework",
495
+ type=click.Choice(SYNC_LIBRARY_FRAMEWORKS),
496
+ help="Override the framework configured in qmind.yaml.",
497
+ )
498
+ @click.option("--test-dir", type=click.Path(path_type=Path), help="Override the qmind.yaml test directory.")
499
+ @click.option(
500
+ "--manifest",
501
+ type=click.Path(path_type=Path),
502
+ help="Import an explicit YAML or JSON enterprise test inventory manifest.",
503
+ )
504
+ @click.option(
505
+ "--file",
506
+ "library_file",
507
+ type=click.Path(path_type=Path),
508
+ help="Import an explicit YAML or JSON test library file.",
509
+ )
510
+ @click.option("--dry-run", is_flag=True, help="Generate and validate the library without uploading it.")
511
+ @click.option(
512
+ "--output",
513
+ type=click.Path(path_type=Path, dir_okay=False),
514
+ help="Write the generated library payload to a JSON file.",
515
+ )
516
+ def sync_library(
517
+ framework: str | None,
518
+ test_dir: Path | None,
519
+ manifest: Path | None,
520
+ library_file: Path | None,
521
+ dry_run: bool,
522
+ output: Path | None,
523
+ ) -> None:
524
+ """Generate and upload a functional test library from local tests."""
525
+ try:
526
+ repo_config = load_repo_config(root=Path.cwd())
527
+ except ConfigError as exc:
528
+ raise click.ClickException(f"{exc}. Run `qmind init` from the repository root first.") from exc
529
+
530
+ project_id = str(repo_config.get("project_id") or "").strip()
531
+ if not project_id:
532
+ raise click.ClickException("Missing project_id in qmind.yaml. Run `qmind init` to configure this repo.")
533
+
534
+ explicit_library_file = library_file or manifest
535
+ if manifest and library_file:
536
+ raise click.ClickException("Pass only one explicit library input: --file or --manifest.")
537
+
538
+ if explicit_library_file:
539
+ try:
540
+ payload = load_manifest_payload(explicit_library_file)
541
+ except ManifestImportError as exc:
542
+ raise click.ClickException(str(exc)) from exc
543
+ if not payload:
544
+ raise click.ClickException(f"No tests found in library file: {explicit_library_file}.")
545
+ else:
546
+ selected_framework = framework or str(repo_config.get("framework") or "").strip()
547
+ if selected_framework not in SYNC_LIBRARY_FRAMEWORKS:
548
+ raise click.ClickException(
549
+ "Missing or unsupported framework for qmind sync library. "
550
+ "Set framework in qmind.yaml or pass --framework."
551
+ )
552
+
553
+ selected_test_dir = test_dir or str(repo_config.get("test_dir") or "").strip()
554
+ if not selected_test_dir:
555
+ raise click.ClickException("Missing test_dir in qmind.yaml. Run `qmind init` or pass --test-dir.")
556
+
557
+ client = None
558
+ if not dry_run:
559
+ try:
560
+ credentials = load_credentials(path=credentials_path())
561
+ except ConfigError as exc:
562
+ raise click.ClickException(f"{exc}. Run `qmind init` or set up ~/.qmind/credentials.yaml.") from exc
563
+
564
+ client = QuantikMindApiClient.from_config(repo_config, credentials)
565
+ try:
566
+ client.validate_credentials()
567
+ except AuthenticationError as exc:
568
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
569
+ except (NetworkError, ApiError) as exc:
570
+ raise click.ClickException(f"Could not validate Quantik Mind credentials: {exc}") from exc
571
+
572
+ if not explicit_library_file:
573
+ try:
574
+ scan_result = scan_functional_library(selected_framework, selected_test_dir, root=Path.cwd())
575
+ except LibraryScanError as exc:
576
+ raise click.ClickException(str(exc)) from exc
577
+
578
+ for warning in scan_result.warnings:
579
+ click.echo(f"Warning: {warning}", err=True)
580
+
581
+ if not scan_result.tests:
582
+ raise click.ClickException(f"No tests found for {selected_framework} in {selected_test_dir}.")
583
+
584
+ payload = scan_result.payload(Path.cwd())
585
+ _validate_library_payload(payload)
586
+
587
+ if output:
588
+ _write_library_payload(output, payload)
589
+
590
+ if dry_run:
591
+ console.print(f"[bold green]✓ Test library dry run complete:[/bold green] {len(payload)} tests discovered")
592
+ breakdown = _library_breakdown(payload, include_criticality=True)
593
+ if breakdown:
594
+ console.print(breakdown)
595
+ if output:
596
+ click.echo(f"Output written to {output}")
597
+ console.print("Upload skipped because --dry-run is enabled.")
598
+ next_steps = [("Upload this library", "qmind sync library")]
599
+ if not output:
600
+ next_steps.append(("Export preview", "qmind sync library --dry-run --output qmind-library.preview.json"))
601
+ _print_next_steps(next_steps)
602
+ return
603
+
604
+ try:
605
+ if client is None:
606
+ raise click.ClickException("Could not sync test library: API client was not initialized.")
607
+ response = client.upload_functional_library(project_id, payload)
608
+ except AuthenticationError as exc:
609
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
610
+ except (NetworkError, ApiError) as exc:
611
+ raise click.ClickException(f"Could not sync test library: {exc}") from exc
612
+
613
+ errors = response.get("errors") or []
614
+ if errors:
615
+ raise click.ClickException(_library_upload_error_summary(response))
616
+
617
+ uploaded = _uploaded_library_count(response, len(payload))
618
+ if uploaded == 0 and response.get("skipped"):
619
+ console.print("[bold green]✓ Test library already up to date[/bold green]")
620
+ elif explicit_library_file:
621
+ console.print(f"[bold green]✓ Test library synced from file:[/bold green] {uploaded} tests uploaded")
622
+ else:
623
+ console.print(f"[bold green]✓ Test library synced:[/bold green] {uploaded} tests uploaded")
624
+ breakdown = _library_breakdown(payload)
625
+ if breakdown:
626
+ console.print(breakdown)
627
+ _print_duration_estimation_warning(response)
628
+ _print_next_steps(
629
+ [
630
+ ("Record a build", "qmind record build"),
631
+ ("Ask for a subset", "qmind subset"),
632
+ ("Import test results after running tests", "qmind record tests --results-dir <path>"),
633
+ ]
634
+ )
635
+
636
+
637
+ @cli.group()
638
+ def agent() -> None:
639
+ """Manage the local Quantik Mind agent."""
640
+
641
+
642
+ @agent.command("init")
643
+ @click.option("--prometheus-url", help="Prometheus base URL.")
644
+ @click.option(
645
+ "--push-interval",
646
+ type=click.IntRange(min=1),
647
+ default=DEFAULT_AGENT_PUSH_INTERVAL_SECONDS,
648
+ show_default=True,
649
+ help="Metric push interval in seconds.",
650
+ )
651
+ def agent_init(prometheus_url: str | None, push_interval: int) -> None:
652
+ """Initialize local agent configuration."""
653
+ try:
654
+ repo_config = load_repo_config(root=Path.cwd())
655
+ except ConfigError as exc:
656
+ raise click.ClickException(f"{exc}. Run `qmind init` from the repository root first.") from exc
657
+
658
+ project_id = str(repo_config.get("project_id") or "").strip()
659
+ if not project_id:
660
+ raise click.ClickException("Missing project_id in qmind.yaml. Run `qmind init` to configure this repo.")
661
+
662
+ try:
663
+ credentials = load_credentials(path=credentials_path())
664
+ except ConfigError as exc:
665
+ raise click.ClickException(f"{exc}. Run `qmind init` or set up ~/.qmind/credentials.yaml.") from exc
666
+
667
+ client = QuantikMindApiClient.from_config(repo_config, credentials)
668
+ try:
669
+ client.validate_credentials()
670
+ except AuthenticationError as exc:
671
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
672
+ except (NetworkError, ApiError) as exc:
673
+ raise click.ClickException(f"Could not validate Quantik Mind credentials: {exc}") from exc
674
+
675
+ if not prometheus_url and _is_ci():
676
+ raise click.ClickException("Missing Prometheus URL. Pass --prometheus-url in CI/non-interactive mode.")
677
+ if not prometheus_url:
678
+ prometheus_url = click.prompt("Prometheus URL", default=DEFAULT_PROMETHEUS_URL).strip()
679
+
680
+ try:
681
+ prometheus = PrometheusClient(prometheus_url)
682
+ prometheus.check_connectivity()
683
+ except PrometheusError as exc:
684
+ raise click.ClickException(f"Could not connect to Prometheus: {exc}") from exc
685
+
686
+ console.print(f"[green]Prometheus connectivity ok:[/green] {prometheus.base_url}")
687
+ for metric_name in AGENT_REQUIRED_METRICS:
688
+ check = prometheus.check_metric(metric_name)
689
+ if check.warning:
690
+ console.print(f"[yellow]Warning:[/yellow] {metric_name}: not found ({check.warning})")
691
+ elif check.found:
692
+ console.print(f"[green]found[/green] {metric_name}")
693
+ else:
694
+ console.print(f"[yellow]not found[/yellow] {metric_name}")
695
+
696
+ agent_config = dict(repo_config.get("agent") or {})
697
+ agent_config.update(
698
+ {
699
+ "prometheus_url": prometheus.base_url,
700
+ "push_interval_seconds": push_interval,
701
+ }
702
+ )
703
+ repo_config["agent"] = agent_config
704
+ repo_config_path = write_repo_config(repo_config, root=Path.cwd())
705
+
706
+ console.print(f"[bold green]✓ Agent configuration saved:[/bold green] {repo_config_path}")
707
+ console.print("Agent pushes metrics outbound; no inbound firewall rule is required.")
708
+ _print_next_steps([("Start the agent", "qmind agent start")])
709
+
710
+
711
+ @agent.command("start")
712
+ @click.option("--once", is_flag=True, help="Run one collect-and-push cycle and exit.")
713
+ @click.option("--interval", type=click.IntRange(min=1), help="Override qmind.yaml push interval in seconds.")
714
+ def agent_start(once: bool, interval: int | None) -> None:
715
+ """Start the local agent."""
716
+ repo_config, project_id, client = _load_agent_runtime()
717
+ agent_config = _agent_config(repo_config)
718
+ prometheus_url = str(agent_config.get("prometheus_url") or "").strip()
719
+ if not prometheus_url:
720
+ raise click.ClickException("Missing agent.prometheus_url in qmind.yaml. Run `qmind agent init` first.")
721
+
722
+ configured_interval = interval or int(agent_config.get("push_interval_seconds") or 0)
723
+ if configured_interval < 1:
724
+ raise click.ClickException("Missing agent.push_interval_seconds in qmind.yaml. Run `qmind agent init` first.")
725
+
726
+ prometheus = PrometheusClient(prometheus_url)
727
+ push_failures = 0
728
+ console.print(f"Starting qmind agent for project {project_id}. Press Ctrl+C to stop.")
729
+
730
+ try:
731
+ while True:
732
+ pushed, push_failed = _run_agent_cycle(
733
+ project_id=project_id,
734
+ prometheus=prometheus,
735
+ client=client,
736
+ )
737
+ push_failures = push_failures + 1 if push_failed else 0
738
+ if once:
739
+ break
740
+ sleep_seconds = _agent_sleep_seconds(configured_interval, push_failures)
741
+ time.sleep(sleep_seconds)
742
+ except KeyboardInterrupt:
743
+ console.print(f"{_agent_log_prefix()} Agent stopped.")
744
+
745
+
746
+ @agent.command("status")
747
+ def agent_status() -> None:
748
+ """Show local agent status."""
749
+ try:
750
+ repo_config = load_repo_config(root=Path.cwd())
751
+ except ConfigError as exc:
752
+ raise click.ClickException(f"{exc}. Run `qmind init` from the repository root first.") from exc
753
+
754
+ agent_config = repo_config.get("agent")
755
+ if not isinstance(agent_config, dict):
756
+ raise click.ClickException("Missing agent config in qmind.yaml. Run `qmind agent init` first.")
757
+
758
+ state = load_agent_state(path=agent_state_path())
759
+ table = Table(title="Quantik Mind agent status")
760
+ table.add_column("Setting")
761
+ table.add_column("Value")
762
+ table.add_row("Prometheus URL", str(agent_config.get("prometheus_url") or "not configured"))
763
+ table.add_row("Push interval", str(agent_config.get("push_interval_seconds") or "not configured"))
764
+ table.add_row("Last push at", str(state.get("last_push_at") or "never"))
765
+ table.add_row("Last services count", str(state.get("last_services_count") or "0"))
766
+ console.print(table)
767
+
768
+
769
+ @agent.command("stop")
770
+ def agent_stop() -> None:
771
+ """Stop the local agent."""
772
+ console.print("qmind agent stop is not implemented yet.")
773
+
774
+
775
+ @cli.group()
776
+ def observability() -> None:
777
+ """Configure project observability."""
778
+
779
+
780
+ @observability.group()
781
+ def configure() -> None:
782
+ """Configure an observability provider."""
783
+
784
+
785
+ @configure.command("prometheus")
786
+ @click.option("--url", "prometheus_url", help="Prometheus base URL.")
787
+ @click.option("--file", "config_file", type=click.Path(path_type=Path), help="YAML Prometheus signal mapping.")
788
+ @click.option("--service-label", help="Prometheus label that identifies services.")
789
+ def observability_configure_prometheus(
790
+ prometheus_url: str | None,
791
+ config_file: Path | None,
792
+ service_label: str | None,
793
+ ) -> None:
794
+ """Configure project-scoped Prometheus observability."""
795
+ if not prometheus_url and not config_file:
796
+ raise click.ClickException("Pass --url or --file.")
797
+ if prometheus_url and config_file:
798
+ raise click.ClickException("Pass only one Prometheus input: --url or --file.")
799
+
800
+ repo_config, project_id, client = _load_agent_runtime()
801
+ payload = (
802
+ _load_prometheus_observability_file(config_file)
803
+ if config_file
804
+ else {"url": prometheus_url}
805
+ )
806
+ payload["project_id"] = project_id
807
+ if service_label:
808
+ payload["service_label"] = service_label
809
+
810
+ try:
811
+ response = client.configure_prometheus_observability(payload)
812
+ except AuthenticationError as exc:
813
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
814
+ except (NetworkError, ApiError) as exc:
815
+ raise click.ClickException(f"Could not configure Prometheus observability: {exc}") from exc
816
+
817
+ observability_config = dict(repo_config.get("observability") or {})
818
+ observability_config.update(
819
+ {
820
+ "provider": "prometheus",
821
+ "service_label": response.get("service_label") or payload.get("service_label") or "service",
822
+ "mode": response.get("mode") or payload.get("mode"),
823
+ "selected_job": response.get("selected_job") or response.get("effective_job"),
824
+ "profile_ready": response.get("profile_ready"),
825
+ }
826
+ )
827
+ repo_config["observability"] = observability_config
828
+ write_repo_config(repo_config, root=Path.cwd())
829
+
830
+ signals = response.get("configured_signals") or []
831
+ console.print("[bold green]✓ Prometheus observability configured[/bold green]")
832
+ if response.get("preset"):
833
+ console.print(f"Preset: {response.get('preset')}")
834
+ if response.get("mode"):
835
+ console.print(f"Mode: {response.get('mode')}")
836
+ if response.get("effective_job"):
837
+ console.print(f"Effective job: {response.get('effective_job')}")
838
+ if response.get("job_ambiguous"):
839
+ console.print("[yellow]Prometheus job is ambiguous; no job was selected.[/yellow]")
840
+ valid_jobs = response.get("valid_jobs") or []
841
+ if valid_jobs:
842
+ console.print("Valid jobs: " + ", ".join(str(job) for job in valid_jobs))
843
+ profile_state = str(response.get("profile_state") or "ready")
844
+ console.print(f"Detected profile: {profile_state}")
845
+ detected_signals = response.get("detected_signals") or []
846
+ missing_signals = response.get("missing_signals") or []
847
+ invalid_signals = response.get("invalid_signals") or []
848
+ if detected_signals:
849
+ console.print("Validated signals: " + ", ".join(str(signal) for signal in detected_signals))
850
+ missing_or_invalid = _dedupe_display_values(list(missing_signals) + list(invalid_signals))
851
+ if missing_or_invalid:
852
+ console.print(
853
+ "[yellow]Missing/invalid signals:[/yellow] "
854
+ + ", ".join(str(signal) for signal in missing_or_invalid)
855
+ )
856
+ details = str(response.get("details") or "").strip()
857
+ if details:
858
+ console.print(f"Reason: {details}")
859
+ if signals:
860
+ console.print("Signals: " + ", ".join(str(signal) for signal in signals))
861
+ _print_next_steps([("Check observability", "qmind observability status"), ("Check readiness", "qmind status")])
862
+
863
+
864
+ @observability.command("status")
865
+ def observability_status() -> None:
866
+ """Show project observability status."""
867
+ _repo_config, project_id, client = _load_agent_runtime()
868
+ try:
869
+ status = client.get_observability_status(project_id)
870
+ except AuthenticationError as exc:
871
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
872
+ except (NetworkError, ApiError) as exc:
873
+ raise click.ClickException(f"Could not load observability status: {exc}") from exc
874
+
875
+ table = Table(title="Quantik Mind observability")
876
+ table.add_column("Check")
877
+ table.add_column("Value")
878
+ table.add_row("Enabled", _plain_bool(status.get("observability_enabled", True)))
879
+ table.add_row("Provider", str(status.get("provider") or "not configured"))
880
+ table.add_row("Runtime/observability readiness", str(status.get("readiness") or status.get("status") or "unknown"))
881
+ table.add_row("Service label", str(status.get("service_label") or "not configured"))
882
+ table.add_row("Mode", str(status.get("mode") or "unknown"))
883
+ table.add_row("Profile source", str(status.get("profile_source") or "unknown"))
884
+ table.add_row("Effective job", str(status.get("effective_job") or "none"))
885
+ table.add_row("Effective job regex", str(status.get("effective_job_regex") or "none"))
886
+ table.add_row("Signals", ", ".join(status.get("configured_signals") or []) or "none")
887
+ table.add_row("Validated signals", ", ".join(status.get("detected_signals") or []) or "none")
888
+ missing_or_invalid = _dedupe_display_values(
889
+ list(status.get("missing_signals") or []) + list(status.get("invalid_signals") or [])
890
+ )
891
+ table.add_row("Missing/invalid signals", ", ".join(missing_or_invalid) or "none")
892
+ table.add_row("Details", str(status.get("details") or ""))
893
+ console.print(table)
894
+
895
+
896
+ def _dedupe_display_values(values: list) -> list[str]:
897
+ seen = set()
898
+ out = []
899
+ for value in values:
900
+ text = str(value)
901
+ if not text or text in seen:
902
+ continue
903
+ seen.add(text)
904
+ out.append(text)
905
+ return out
906
+
907
+
908
+ def _load_agent_runtime() -> tuple[dict, str, QuantikMindApiClient]:
909
+ try:
910
+ repo_config = load_repo_config(root=Path.cwd())
911
+ except ConfigError as exc:
912
+ raise click.ClickException(f"{exc}. Run `qmind init` from the repository root first.") from exc
913
+
914
+ project_id = str(repo_config.get("project_id") or "").strip()
915
+ if not project_id:
916
+ raise click.ClickException("Missing project_id in qmind.yaml. Run `qmind init` to configure this repo.")
917
+
918
+ try:
919
+ credentials = load_credentials(path=credentials_path())
920
+ except ConfigError as exc:
921
+ raise click.ClickException(f"{exc}. Run `qmind init` or set up ~/.qmind/credentials.yaml.") from exc
922
+
923
+ client = QuantikMindApiClient.from_config(repo_config, credentials)
924
+ try:
925
+ client.validate_credentials()
926
+ except AuthenticationError as exc:
927
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
928
+ except (NetworkError, ApiError) as exc:
929
+ raise click.ClickException(f"Could not validate Quantik Mind credentials: {exc}") from exc
930
+
931
+ return repo_config, project_id, client
932
+
933
+
934
+ def _load_prometheus_observability_file(path: Path) -> dict:
935
+ try:
936
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
937
+ except (OSError, yaml.YAMLError) as exc:
938
+ raise click.ClickException(f"Could not read Prometheus config file {path}: {exc}") from exc
939
+ if not isinstance(data, dict):
940
+ raise click.ClickException(f"Prometheus config file must contain a YAML mapping: {path}")
941
+
942
+ observability_config = data.get("observability", data)
943
+ if not isinstance(observability_config, dict):
944
+ raise click.ClickException("observability must be a mapping.")
945
+ if str(observability_config.get("provider") or "prometheus") != "prometheus":
946
+ raise click.ClickException("Only provider: prometheus is supported by this command.")
947
+
948
+ url = str(observability_config.get("url") or "").strip()
949
+ if not url:
950
+ raise click.ClickException("Prometheus config file is missing observability.url.")
951
+
952
+ signals_input = observability_config.get("signals") or {}
953
+ if not isinstance(signals_input, dict):
954
+ raise click.ClickException("observability.signals must be a mapping.")
955
+
956
+ signals: dict[str, dict] = {}
957
+ for name, config in signals_input.items():
958
+ if not isinstance(config, dict):
959
+ raise click.ClickException(f"observability.signals.{name} must be a mapping.")
960
+ query = str(config.get("query") or config.get("query_template") or "").strip()
961
+ if not query:
962
+ raise click.ClickException(f"observability.signals.{name}.query is required.")
963
+ signals[str(name)] = {
964
+ "query": query,
965
+ "unit": config.get("unit") or "value",
966
+ "direction": config.get("direction") or "higher_is_riskier",
967
+ "weight": config.get("weight") or 0.0,
968
+ }
969
+
970
+ services_input = observability_config.get("services") or {}
971
+ if not isinstance(services_input, dict):
972
+ raise click.ClickException("observability.services must be a mapping.")
973
+ services: dict[str, dict] = {}
974
+ for service_name, service_config in services_input.items():
975
+ if not isinstance(service_config, dict):
976
+ raise click.ClickException(f"observability.services.{service_name} must be a mapping.")
977
+ query_input = service_config.get("queries") or service_config.get("signals") or {}
978
+ if not isinstance(query_input, dict):
979
+ raise click.ClickException(f"observability.services.{service_name}.queries must be a mapping.")
980
+ queries = {}
981
+ for name, config in query_input.items():
982
+ if isinstance(config, str):
983
+ config = {"query": config}
984
+ if not isinstance(config, dict):
985
+ raise click.ClickException(
986
+ f"observability.services.{service_name}.queries.{name} must be a mapping or query string."
987
+ )
988
+ query = str(config.get("query") or config.get("query_template") or "").strip()
989
+ if not query:
990
+ raise click.ClickException(
991
+ f"observability.services.{service_name}.queries.{name}.query is required."
992
+ )
993
+ queries[str(name)] = {
994
+ "query": query,
995
+ "unit": config.get("unit") or "value",
996
+ "direction": config.get("direction") or "higher_is_riskier",
997
+ "weight": config.get("weight") or 0.0,
998
+ }
999
+ services[str(service_name)] = {"queries": queries}
1000
+
1001
+ return {
1002
+ "url": url,
1003
+ "service_label": observability_config.get("service_label") or "service",
1004
+ "lookback": observability_config.get("lookback") or "5m",
1005
+ "signals": signals,
1006
+ "services": services,
1007
+ "mode": "file",
1008
+ }
1009
+
1010
+
1011
+ def _agent_config(repo_config: dict) -> dict:
1012
+ agent_config = repo_config.get("agent")
1013
+ if not isinstance(agent_config, dict):
1014
+ raise click.ClickException("Missing agent config in qmind.yaml. Run `qmind agent init` first.")
1015
+ return agent_config
1016
+
1017
+
1018
+ def _run_agent_cycle(
1019
+ *,
1020
+ project_id: str,
1021
+ prometheus: PrometheusClient,
1022
+ client: QuantikMindApiClient,
1023
+ ) -> tuple[bool, bool]:
1024
+ try:
1025
+ metrics = prometheus.collect_service_metrics()
1026
+ except PrometheusError as exc:
1027
+ console.print(f"{_agent_log_prefix()} [yellow]⚠ Prometheus unavailable:[/yellow] {exc}")
1028
+ return False, False
1029
+
1030
+ if not metrics:
1031
+ console.print(f"{_agent_log_prefix()} [yellow]⚠ No service metrics found.[/yellow]")
1032
+ return False, False
1033
+
1034
+ timestamp = _utc_timestamp()
1035
+ payload = {
1036
+ "project_id": project_id,
1037
+ "timestamp": timestamp,
1038
+ "metrics": metrics,
1039
+ }
1040
+ try:
1041
+ client.push_agent_metrics(payload)
1042
+ except AgentMetricsEndpointUnavailable as exc:
1043
+ console.print(f"{_agent_log_prefix()} [yellow]⚠ Agent metrics endpoint unavailable:[/yellow] {exc}")
1044
+ return False, True
1045
+ except (AuthenticationError, NetworkError, ApiError) as exc:
1046
+ console.print(f"{_agent_log_prefix()} [yellow]⚠ Could not push agent metrics:[/yellow] {exc}")
1047
+ return False, True
1048
+
1049
+ services_count = len(metrics)
1050
+ write_agent_state(
1051
+ {
1052
+ "last_push_at": timestamp,
1053
+ "last_services_count": services_count,
1054
+ "prometheus_url": prometheus.base_url,
1055
+ },
1056
+ path=agent_state_path(),
1057
+ )
1058
+ console.print(f"{_agent_log_prefix(timestamp)} [green]✓ Pushed metrics for {services_count} services[/green]")
1059
+ return True, False
1060
+
1061
+
1062
+ def _agent_sleep_seconds(interval: int, push_failures: int) -> int:
1063
+ if push_failures <= 0:
1064
+ return interval
1065
+ backoff = interval * (2 ** min(push_failures - 1, 6))
1066
+ return min(backoff, MAX_AGENT_PUSH_BACKOFF_SECONDS)
1067
+
1068
+
1069
+ def _utc_timestamp() -> str:
1070
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
1071
+
1072
+
1073
+ def _agent_log_prefix(timestamp: str | None = None) -> str:
1074
+ return f"[{timestamp or _utc_timestamp()}]"
1075
+
1076
+
1077
+ def _is_ci() -> bool:
1078
+ return os.getenv("CI", "").strip().lower() == "true"
1079
+
1080
+
1081
+ def _load_existing_credentials() -> dict[str, str]:
1082
+ try:
1083
+ return load_credentials(path=credentials_path())
1084
+ except ConfigError:
1085
+ return {}
1086
+
1087
+
1088
+ def _resolve_existing_project(
1089
+ client: QuantikMindApiClient,
1090
+ *,
1091
+ project_id: str,
1092
+ project_name: str | None,
1093
+ ) -> dict:
1094
+ project_id = project_id.strip()
1095
+ if not project_id:
1096
+ return {}
1097
+
1098
+ get_project = getattr(client, "get_project", None)
1099
+ if callable(get_project):
1100
+ try:
1101
+ project = get_project(project_id)
1102
+ except (AuthenticationError, NetworkError, ApiError):
1103
+ project = None
1104
+ if isinstance(project, dict):
1105
+ resolved_id = str(project.get("id") or project.get("project_id") or project_id)
1106
+ return {**project, "id": resolved_id}
1107
+
1108
+ project: dict = {"id": project_id}
1109
+ if project_name:
1110
+ project["name"] = project_name
1111
+ return project
1112
+
1113
+
1114
+ def _auth_mode_for_api_key(api_key: str, existing_credentials: dict) -> str:
1115
+ if _looks_like_quantikmind_api_key(api_key):
1116
+ return "api_key"
1117
+ existing_auth_mode = str(existing_credentials.get("auth_mode") or "").strip()
1118
+ return existing_auth_mode or "bearer"
1119
+
1120
+
1121
+ def _looks_like_quantikmind_api_key(value: str) -> bool:
1122
+ return value.startswith(("qm_dev_", "qm_live_"))
1123
+
1124
+
1125
+ def _project_display(*, project_id: str, project_name: str | None) -> str:
1126
+ name = str(project_name or "").strip()
1127
+ if name and name != project_id:
1128
+ return f"{name} ({project_id})"
1129
+ return project_id
1130
+
1131
+
1132
+ def _select_project(
1133
+ projects: list[dict],
1134
+ *,
1135
+ project_id: str | None,
1136
+ project_name: str | None,
1137
+ ) -> dict | None:
1138
+ if project_id:
1139
+ for project in projects:
1140
+ candidate_ids = {
1141
+ str(project.get("id") or ""),
1142
+ str(project.get("project_id") or ""),
1143
+ }
1144
+ if project_id in candidate_ids:
1145
+ return project
1146
+ return None
1147
+
1148
+ if project_name:
1149
+ normalized_name = project_name.strip().lower()
1150
+ for project in projects:
1151
+ if str(project.get("name", "")).strip().lower() == normalized_name:
1152
+ return project
1153
+ return None
1154
+
1155
+
1156
+ def _identity_can_create_project(identity: dict | None) -> bool:
1157
+ if not identity:
1158
+ return False
1159
+ return bool(identity.get("user_id")) and identity.get("principal_type") != "service_account"
1160
+
1161
+
1162
+ def _status_counts(records: list[dict]) -> dict[str, int]:
1163
+ counts: dict[str, int] = {}
1164
+ for record in records:
1165
+ status = str(record.get("status") or "unknown")
1166
+ counts[status] = counts.get(status, 0) + 1
1167
+ return counts
1168
+
1169
+
1170
+ def _skipped_summary(counts: dict[str, int]) -> str:
1171
+ skipped = counts.get("skipped", 0)
1172
+ return f", {skipped} skipped" if skipped else ""
1173
+
1174
+
1175
+ def _uploaded_library_count(response: dict, fallback: int) -> int:
1176
+ added = response.get("added")
1177
+ updated = response.get("updated")
1178
+ if isinstance(added, int) and isinstance(updated, int):
1179
+ return added + updated
1180
+ return fallback
1181
+
1182
+
1183
+ def _library_upload_error_summary(response: dict) -> str:
1184
+ rejected = response.get("skipped")
1185
+ errors = response.get("errors") or []
1186
+ reasons: list[str] = []
1187
+ for error in errors:
1188
+ if isinstance(error, dict):
1189
+ reason = str(error.get("error") or "").strip()
1190
+ else:
1191
+ reason = str(error).strip()
1192
+ if reason and reason not in reasons:
1193
+ reasons.append(reason)
1194
+ reason_text = "\n".join(reasons) if reasons else "Backend rejected the library payload."
1195
+ parts = ["Test library upload failed"]
1196
+ if isinstance(rejected, int):
1197
+ parts.append(f"Rejected tests: {rejected}")
1198
+ parts.append(f"Reason:\n{reason_text}")
1199
+ return "\n\n".join(parts)
1200
+
1201
+
1202
+ def _print_duration_estimation_warning(response: dict) -> None:
1203
+ estimated = response.get("estimated_durations")
1204
+ if not isinstance(estimated, int) or estimated <= 0:
1205
+ return
1206
+ source = str(response.get("duration_estimation_source") or "framework_default")
1207
+ strategy = (
1208
+ "framework-based estimated durations"
1209
+ if source == "framework_default"
1210
+ else f"{source} estimated durations"
1211
+ )
1212
+ plural = "test" if estimated == 1 else "tests"
1213
+ console.print(f"[yellow]Warning:[/yellow] {estimated} {plural} had no duration information.")
1214
+ console.print(
1215
+ f"Quantik Mind assigned {strategy} to bootstrap time-saving calculations."
1216
+ )
1217
+ console.print(
1218
+ "Savings metrics are approximate until real execution history is recorded."
1219
+ )
1220
+
1221
+
1222
+ def _validate_library_payload(payload: list[dict]) -> None:
1223
+ required_fields = ("test_id", "name", "framework", "service")
1224
+ for index, item in enumerate(payload, start=1):
1225
+ missing = [field for field in required_fields if not item.get(field)]
1226
+ if missing:
1227
+ raise click.ClickException(f"Generated library payload item {index} is missing: {', '.join(missing)}.")
1228
+
1229
+
1230
+ def _write_library_payload(path: Path, payload: list[dict]) -> None:
1231
+ try:
1232
+ if path.exists() and path.is_dir():
1233
+ raise OSError(f"Output path is a directory: {path}")
1234
+ if path.parent and path.parent != Path("."):
1235
+ path.parent.mkdir(parents=True, exist_ok=True)
1236
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1237
+ except OSError as exc:
1238
+ raise click.ClickException(f"Could not write library payload to {path}: {exc}") from exc
1239
+
1240
+
1241
+ def _resolve_subset_changed_files(
1242
+ *,
1243
+ changed_files: tuple[str, ...],
1244
+ changed_files_file: Path | None,
1245
+ ) -> list[str] | None:
1246
+ values = [item.strip() for item in changed_files if item.strip()]
1247
+ if changed_files_file is not None:
1248
+ values.extend(_load_changed_files_file(changed_files_file))
1249
+ return values or None
1250
+
1251
+
1252
+ def _load_changed_files_file(path: Path) -> list[str]:
1253
+ try:
1254
+ payload = json.loads(path.read_text(encoding="utf-8"))
1255
+ except OSError as exc:
1256
+ raise click.ClickException(f"Could not read changed files file {path}: {exc}") from exc
1257
+ except json.JSONDecodeError as exc:
1258
+ raise click.ClickException(f"Changed files file must be valid JSON: {exc}") from exc
1259
+
1260
+ if not isinstance(payload, list):
1261
+ raise click.ClickException("Changed files file must contain a JSON array of paths.")
1262
+
1263
+ if not all(isinstance(item, str) and item.strip() for item in payload):
1264
+ raise click.ClickException("Changed files file entries must be non-empty path values.")
1265
+ return [item.strip() for item in payload]
1266
+
1267
+
1268
+ def _library_breakdown(payload: list[dict], *, include_criticality: bool = False) -> str:
1269
+ by_framework: dict[str, int] = {}
1270
+ by_service: dict[str, int] = {}
1271
+ by_criticality: dict[str, int] = {}
1272
+ for item in payload:
1273
+ framework = str(item.get("framework") or "unknown")
1274
+ service = str(item.get("service") or "default")
1275
+ by_framework[framework] = by_framework.get(framework, 0) + 1
1276
+ by_service[service] = by_service.get(service, 0) + 1
1277
+ if include_criticality:
1278
+ criticality = str(item.get("business_criticality") or "unknown")
1279
+ by_criticality[criticality] = by_criticality.get(criticality, 0) + 1
1280
+
1281
+ parts: list[str] = []
1282
+ if by_framework:
1283
+ parts.append("frameworks: " + _format_counts(by_framework))
1284
+ if by_service:
1285
+ parts.append("services: " + _format_counts(by_service))
1286
+ if by_criticality:
1287
+ parts.append("criticality: " + _format_counts(by_criticality))
1288
+ return "; ".join(parts)
1289
+
1290
+
1291
+ def _format_counts(counts: dict[str, int]) -> str:
1292
+ return ", ".join(f"{name}={count}" for name, count in sorted(counts.items()))
1293
+
1294
+
1295
+ def _print_next_steps(steps: list[tuple[str, str]]) -> None:
1296
+ console.print("[bold]Next steps:[/bold]")
1297
+ for label, command in steps:
1298
+ console.print(f" {label}: [cyan]{command}[/cyan]")
1299
+
1300
+
1301
+ @cli.command()
1302
+ def status() -> None:
1303
+ """Show qmind configuration status."""
1304
+ try:
1305
+ repo_config = load_repo_config(root=Path.cwd())
1306
+ except ConfigError as exc:
1307
+ raise click.ClickException(f"{exc}. Run qmind init first.") from exc
1308
+
1309
+ project_id = str(repo_config.get("project_id") or "").strip()
1310
+ if not project_id:
1311
+ raise click.ClickException("Missing project_id in qmind.yaml. Run `qmind init` to configure this repo.")
1312
+
1313
+ try:
1314
+ credentials = load_credentials(path=credentials_path())
1315
+ except ConfigError as exc:
1316
+ raise click.ClickException(f"{exc}. Run `qmind init` or set up ~/.qmind/credentials.yaml.") from exc
1317
+
1318
+ client = QuantikMindApiClient.from_config(repo_config, credentials)
1319
+ try:
1320
+ client.validate_credentials()
1321
+ except AuthenticationError as exc:
1322
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
1323
+ except (NetworkError, ApiError) as exc:
1324
+ raise click.ClickException(f"Could not validate Quantik Mind credentials: {exc}") from exc
1325
+
1326
+ try:
1327
+ readiness = client.get_signal_readiness(project_id)
1328
+ except AuthenticationError as exc:
1329
+ raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
1330
+ except (NetworkError, ApiError) as exc:
1331
+ raise click.ClickException(f"Could not load signal readiness: {exc}") from exc
1332
+
1333
+ subscription: dict | None = None
1334
+ subscription_warning: str | None = None
1335
+ observability_status_payload: dict | None = None
1336
+ observability_warning: str | None = None
1337
+ get_observability_status = getattr(client, "get_observability_status", None)
1338
+ if callable(get_observability_status):
1339
+ try:
1340
+ observability_status_payload = get_observability_status(project_id)
1341
+ except (AuthenticationError, NetworkError, ApiError) as exc:
1342
+ observability_warning = str(exc)
1343
+
1344
+ try:
1345
+ subscription = client.get_subscription()
1346
+ except (AuthenticationError, NetworkError, ApiError) as exc:
1347
+ subscription_warning = str(exc)
1348
+
1349
+ console.print(_status_table(repo_config, readiness, subscription, observability_status_payload))
1350
+ if observability_warning:
1351
+ console.print(f"[yellow]Warning:[/yellow] Observability status unavailable: {observability_warning}")
1352
+ if subscription_warning:
1353
+ console.print(f"[yellow]Warning:[/yellow] Subscription usage unavailable: {subscription_warning}")
1354
+ _print_status_guidance(readiness)
1355
+
1356
+
1357
+ def _status_table(
1358
+ repo_config: dict,
1359
+ readiness: dict,
1360
+ subscription: dict | None,
1361
+ observability_status_payload: dict | None = None,
1362
+ ) -> Table:
1363
+ table = Table(title="Quantik Mind status")
1364
+ table.add_column("Check", no_wrap=True)
1365
+ table.add_column("Status", no_wrap=True)
1366
+ table.add_column("Details")
1367
+
1368
+ project_name = str(repo_config.get("project_name") or "unknown")
1369
+ project_id = str(repo_config.get("project_id") or "unknown")
1370
+ table.add_row("Project name", project_name, "")
1371
+ table.add_row("Project ID", project_id, "")
1372
+ table.add_row("API URL", resolve_base_url(repo_config), "")
1373
+ table.add_row("API connectivity/auth", "[green]ok[/green]", "Credentials validated")
1374
+ table.add_row(
1375
+ "Test library readiness",
1376
+ _bool_status(readiness.get("has_test_library")),
1377
+ _library_details(readiness),
1378
+ )
1379
+ table.add_row(
1380
+ "Historical data readiness",
1381
+ _bool_status(readiness.get("has_historical_signal")),
1382
+ "",
1383
+ )
1384
+ table.add_row(
1385
+ "Observability configuration",
1386
+ _runtime_status(readiness, observability_status_payload),
1387
+ _runtime_details(readiness, observability_status_payload),
1388
+ )
1389
+ table.add_row(
1390
+ "Runtime signal usability",
1391
+ _bool_status(readiness.get("has_runtime_signal")),
1392
+ "usable by selection" if readiness.get("has_runtime_signal") is True else "not usable by selection",
1393
+ )
1394
+ table.add_row(
1395
+ "Selection readiness",
1396
+ _selection_readiness_status(readiness),
1397
+ _readiness_details(readiness),
1398
+ )
1399
+ table.add_row("Subscription/free-tier usage", _subscription_status(subscription), _subscription_details(subscription))
1400
+ return table
1401
+
1402
+
1403
+ def _print_status_guidance(readiness: dict) -> None:
1404
+ selection_readiness = derive_selection_readiness(readiness)
1405
+ if selection_readiness == "ready":
1406
+ console.print(
1407
+ "[green]Ready:[/green] library, history, and runtime signal are all available."
1408
+ )
1409
+ console.print("Next step: [cyan]qmind subset[/cyan]")
1410
+ return
1411
+
1412
+ if selection_readiness == "not_ready":
1413
+ missing = _missing_readiness_dimensions(readiness)
1414
+ if missing:
1415
+ console.print(f"[yellow]Not ready:[/yellow] {', '.join(missing)}.")
1416
+ else:
1417
+ console.print("[yellow]Not ready:[/yellow] qmind subset needs an uploaded library and enough signal.")
1418
+ command = _next_status_command(readiness)
1419
+ if command:
1420
+ console.print(f"Next step: [cyan]{command}[/cyan]")
1421
+ return
1422
+
1423
+ if selection_readiness == "partial":
1424
+ console.print(
1425
+ "[yellow]Partially ready:[/yellow] qmind subset may work, but selections improve with more history or runtime signal."
1426
+ )
1427
+ command = _next_status_command(readiness)
1428
+ if command:
1429
+ console.print(f"Next step: [cyan]{command}[/cyan]")
1430
+ return
1431
+
1432
+ if selection_readiness == "unknown":
1433
+ console.print(
1434
+ "Readiness is unknown. Check qmind.yaml, credentials, and the backend readiness endpoint before relying on qmind subset."
1435
+ )
1436
+
1437
+
1438
+ def _missing_readiness_dimensions(readiness: dict) -> list[str]:
1439
+ missing = []
1440
+ if readiness.get("has_test_library") is False:
1441
+ missing.append(
1442
+ "test library disabled"
1443
+ if readiness.get("test_library_enabled") is False
1444
+ else "missing library"
1445
+ )
1446
+ if readiness.get("has_historical_signal") is False:
1447
+ missing.append(
1448
+ "historical data disabled"
1449
+ if readiness.get("historical_data_enabled") is False
1450
+ else "missing history"
1451
+ )
1452
+ if readiness.get("has_runtime_signal") is False:
1453
+ missing.append(
1454
+ "observability disabled"
1455
+ if readiness.get("observability_enabled") is False
1456
+ else "missing runtime signal"
1457
+ )
1458
+ if readiness.get("sample_limit_reached") is True:
1459
+ missing.append("sample limit reached")
1460
+ return missing
1461
+
1462
+
1463
+ def _next_status_command(readiness: dict) -> str:
1464
+ if readiness.get("has_test_library") is False or readiness.get("sample_limit_reached") is True:
1465
+ if readiness.get("test_library_enabled") is False:
1466
+ return "enable test library in Settings"
1467
+ return "qmind sync library"
1468
+ if readiness.get("has_historical_signal") is False:
1469
+ if readiness.get("historical_data_enabled") is False:
1470
+ return "enable historical data in Settings"
1471
+ return "qmind record tests --results-dir <path>"
1472
+ if readiness.get("has_runtime_signal") is False:
1473
+ if readiness.get("observability_enabled") is False:
1474
+ return "enable observability in Settings"
1475
+ return "qmind observability configure prometheus --url <url>"
1476
+ return ""
1477
+
1478
+
1479
+ def _bool_status(value: object) -> str:
1480
+ if value is True:
1481
+ return "[green]ready[/green]"
1482
+ if value is False:
1483
+ return "[yellow]not ready[/yellow]"
1484
+ return "unknown"
1485
+
1486
+
1487
+ def _runtime_status(readiness: dict, observability_status_payload: dict | None) -> str:
1488
+ status = str((observability_status_payload or {}).get("status") or "").strip()
1489
+ readiness_label = str((observability_status_payload or {}).get("readiness") or "").strip()
1490
+ if (observability_status_payload or {}).get("observability_enabled") is False or readiness.get("observability_enabled") is False:
1491
+ return "[yellow]disabled[/yellow]"
1492
+ if status == "not_configured" or readiness_label == "not configured":
1493
+ return "[yellow]not configured[/yellow]"
1494
+ if status == "error" or readiness_label == "error":
1495
+ return "[yellow]error[/yellow]"
1496
+ if status == "partial" or readiness_label == "partial":
1497
+ return "[yellow]partial[/yellow]"
1498
+ if status in {"configured", "ready"}:
1499
+ return "[green]ready[/green]" if status == "ready" else "[yellow]configured[/yellow]"
1500
+ return _bool_status(readiness.get("has_runtime_signal"))
1501
+
1502
+
1503
+ def _runtime_details(readiness: dict, observability_status_payload: dict | None) -> str:
1504
+ if readiness.get("observability_enabled") is False and not observability_status_payload:
1505
+ return "disabled in project settings"
1506
+ if not observability_status_payload:
1507
+ return ""
1508
+ parts = []
1509
+ if observability_status_payload.get("observability_enabled") is False:
1510
+ parts.append("disabled in project settings")
1511
+ provider = observability_status_payload.get("provider")
1512
+ service_label = observability_status_payload.get("service_label")
1513
+ signals = observability_status_payload.get("configured_signals") or []
1514
+ details = observability_status_payload.get("details")
1515
+ mode = observability_status_payload.get("mode")
1516
+ profile_source = observability_status_payload.get("profile_source")
1517
+ effective_job = observability_status_payload.get("effective_job")
1518
+ effective_job_regex = observability_status_payload.get("effective_job_regex")
1519
+ if provider:
1520
+ parts.append(f"provider={provider}")
1521
+ if service_label:
1522
+ parts.append(f"service_label={service_label}")
1523
+ if mode:
1524
+ parts.append(f"mode={mode}")
1525
+ if profile_source:
1526
+ parts.append(f"profile_source={profile_source}")
1527
+ if effective_job:
1528
+ parts.append(f"effective_job={effective_job}")
1529
+ if effective_job_regex:
1530
+ parts.append(f"effective_job_regex={effective_job_regex}")
1531
+ if signals:
1532
+ parts.append("signals=" + ",".join(str(signal) for signal in signals))
1533
+ if details:
1534
+ parts.append(str(details))
1535
+ configured = str(observability_status_payload.get("status") or "").strip() in {"configured", "ready"}
1536
+ if configured and readiness.get("has_runtime_signal") is False:
1537
+ parts.append("Prometheus configured, but no usable runtime metrics extracted for selection.")
1538
+ return ", ".join(parts)
1539
+
1540
+
1541
+ def _library_details(readiness: dict) -> str:
1542
+ parts = []
1543
+ if readiness.get("test_library_enabled") is False:
1544
+ parts.append("disabled in project settings")
1545
+ if "has_real_library" in readiness:
1546
+ parts.append(f"real={_plain_bool(readiness.get('has_real_library'))}")
1547
+ if "has_sample_library" in readiness:
1548
+ parts.append(f"sample={_plain_bool(readiness.get('has_sample_library'))}")
1549
+ return ", ".join(parts)
1550
+
1551
+
1552
+ def _readiness_details(readiness: dict) -> str:
1553
+ parts = ["derived from signal readiness"]
1554
+ for key in (
1555
+ "state",
1556
+ "signal_state",
1557
+ "effective_mode",
1558
+ "signal_override",
1559
+ "test_library_enabled",
1560
+ "historical_data_enabled",
1561
+ "observability_enabled",
1562
+ ):
1563
+ if key in readiness:
1564
+ parts.append(f"{key}={readiness.get(key)}")
1565
+ if "sample_selection_count" in readiness or "sample_selection_limit" in readiness:
1566
+ count = readiness.get("sample_selection_count", "unknown")
1567
+ limit = readiness.get("sample_selection_limit", "unknown")
1568
+ parts.append(f"sample_usage={count}/{limit}")
1569
+ if readiness.get("sample_limit_reached") is True:
1570
+ parts.append("sample_limit_reached=true")
1571
+ return ", ".join(parts)
1572
+
1573
+
1574
+ def derive_selection_readiness(readiness: dict) -> SelectionReadiness:
1575
+ if readiness.get("sample_limit_reached") is True:
1576
+ return "not_ready"
1577
+
1578
+ has_test_library = readiness.get("has_test_library")
1579
+ if has_test_library is False:
1580
+ return "not_ready"
1581
+ if has_test_library is not True:
1582
+ return "unknown"
1583
+
1584
+ historical = readiness.get("has_historical_signal")
1585
+ runtime = readiness.get("has_runtime_signal")
1586
+ has_known_signal = historical is True or runtime is True
1587
+ has_incomplete_signal = historical is False or runtime is False
1588
+ state = _normalized_readiness_state(readiness)
1589
+
1590
+ if state in READY_SIGNAL_STATES:
1591
+ if has_known_signal:
1592
+ return "ready"
1593
+ if historical is False and runtime is False:
1594
+ return "partial"
1595
+ return "unknown"
1596
+
1597
+ if state in {"historical_only", "runtime_only"}:
1598
+ return "partial" if has_known_signal else "unknown"
1599
+
1600
+ if state in NOT_READY_SIGNAL_STATES:
1601
+ return "partial" if has_incomplete_signal else "not_ready"
1602
+
1603
+ if has_known_signal or has_incomplete_signal:
1604
+ return "partial"
1605
+ return "unknown"
1606
+
1607
+
1608
+ def _normalized_readiness_state(readiness: dict) -> str:
1609
+ for key in ("state", "signal_state"):
1610
+ value = readiness.get(key)
1611
+ if value is not None:
1612
+ return str(value).strip().lower()
1613
+ return ""
1614
+
1615
+
1616
+ def _selection_readiness_status(readiness: dict) -> str:
1617
+ status = derive_selection_readiness(readiness)
1618
+ labels = {
1619
+ "ready": "[green]Ready[/green]",
1620
+ "partial": "[yellow]Partially ready[/yellow]",
1621
+ "not_ready": "[yellow]Not ready[/yellow]",
1622
+ "unknown": "Unknown",
1623
+ }
1624
+ return labels[status]
1625
+
1626
+
1627
+ def _plain_bool(value: object) -> str:
1628
+ if value is True:
1629
+ return "yes"
1630
+ if value is False:
1631
+ return "no"
1632
+ return "unknown"
1633
+
1634
+
1635
+ def _subscription_status(subscription: dict | None) -> str:
1636
+ if not subscription:
1637
+ return "unknown"
1638
+ label = subscription.get("plan_label") or subscription.get("public_plan_code") or subscription.get("plan_code")
1639
+ status = subscription.get("status")
1640
+ if label and status:
1641
+ return f"{label} ({status})"
1642
+ if label:
1643
+ return str(label)
1644
+ return "unknown"
1645
+
1646
+
1647
+ def _subscription_details(subscription: dict | None) -> str:
1648
+ if not subscription:
1649
+ return "unknown"
1650
+
1651
+ parts = []
1652
+ current = subscription.get("test_selections_current")
1653
+ limit = subscription.get("test_selections_limit")
1654
+ remaining = subscription.get("remaining_test_selections")
1655
+ usage_percent = subscription.get("usage_percent")
1656
+ if current is not None or limit is not None:
1657
+ parts.append(f"test_selections={_display_value(current)}/{_display_value(limit)}")
1658
+ if remaining is not None:
1659
+ parts.append(f"remaining={remaining}")
1660
+ if usage_percent is not None:
1661
+ parts.append(f"usage={usage_percent}%")
1662
+ if subscription.get("usage_warning"):
1663
+ parts.append(f"warning={subscription.get('usage_warning')}")
1664
+ return ", ".join(parts) if parts else "unknown"
1665
+
1666
+
1667
+ def _display_value(value: object) -> str:
1668
+ return "unknown" if value is None else str(value)
1669
+
1670
+
1671
+ if __name__ == "__main__":
1672
+ cli()