recce-cloud 1.32.0__py3-none-any.whl → 1.33.1__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.
@@ -0,0 +1 @@
1
+ # CLI commands - presentation layer
@@ -0,0 +1,174 @@
1
+ """
2
+ Diagnostics CLI commands.
3
+
4
+ This module contains CLI presentation logic for diagnostic commands,
5
+ delegating business logic to the diagnostic service.
6
+ """
7
+
8
+ import json
9
+ import sys
10
+
11
+ import click
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+
15
+ from recce_cloud.services.diagnostic_service import (
16
+ CheckStatus,
17
+ DiagnosticResults,
18
+ DiagnosticService,
19
+ )
20
+
21
+
22
+ class DiagnosticRenderer:
23
+ """Renders diagnostic results to the console."""
24
+
25
+ def __init__(self, console: Console):
26
+ self.console = console
27
+
28
+ def render_header(self) -> None:
29
+ """Render the diagnostic header."""
30
+ header = Panel(
31
+ "[bold]🩺 Recce Doctor[/bold]\n[dim]Checking your Recce Cloud setup...[/dim]",
32
+ expand=False,
33
+ padding=(0, 3),
34
+ )
35
+ self.console.print()
36
+ self.console.print(header)
37
+ self.console.print()
38
+ self.console.print("━" * 65)
39
+ self.console.print()
40
+
41
+ def render_login_check(self, results: DiagnosticResults) -> None:
42
+ """Render the login status check."""
43
+ self.console.print("[bold]1. Login Status[/bold] ($ recce-cloud login)")
44
+ check = results.login
45
+
46
+ if check.passed:
47
+ email = check.details.get("email", "Unknown")
48
+ self.console.print(f"[green]✓[/green] Logged in as [cyan]{email}[/cyan]")
49
+ else:
50
+ self._render_failure(check)
51
+
52
+ self.console.print()
53
+
54
+ def render_project_binding_check(self, results: DiagnosticResults) -> None:
55
+ """Render the project binding check."""
56
+ self.console.print("[bold]2. Project Binding[/bold] ($ recce-cloud init)")
57
+ check = results.project_binding
58
+
59
+ if check.passed:
60
+ org = check.details.get("org")
61
+ project = check.details.get("project")
62
+ source = check.details.get("source")
63
+ source_label = " (via env vars)" if source == "env_vars" else ""
64
+ self.console.print(f"[green]✓[/green] Bound to [cyan]{org}/{project}[/cyan]{source_label}")
65
+ else:
66
+ self._render_failure(check)
67
+
68
+ self.console.print()
69
+
70
+ def render_production_check(self, results: DiagnosticResults) -> None:
71
+ """Render the production metadata check."""
72
+ self.console.print("[bold]3. Production Metadata[/bold]")
73
+ check = results.production_metadata
74
+
75
+ if check.status == CheckStatus.SKIP:
76
+ self.console.print(f"[yellow]⚠[/yellow] {check.message}")
77
+ elif check.passed:
78
+ session_name = check.details.get("session_name", "(unnamed)")
79
+ relative_time = check.details.get("relative_time")
80
+ time_str = f" (uploaded {relative_time})" if relative_time else ""
81
+ self.console.print(f'[green]✓[/green] Found production session "[cyan]{session_name}[/cyan]"{time_str}')
82
+ else:
83
+ self._render_failure(check)
84
+
85
+ self.console.print()
86
+
87
+ def render_dev_session_check(self, results: DiagnosticResults) -> None:
88
+ """Render the dev session check."""
89
+ self.console.print("[bold]4. Dev Session[/bold]")
90
+ check = results.dev_session
91
+
92
+ if check.status == CheckStatus.SKIP:
93
+ self.console.print(f"[yellow]⚠[/yellow] {check.message}")
94
+ elif check.passed:
95
+ session_name = check.details.get("session_name", "(unnamed)")
96
+ relative_time = check.details.get("relative_time")
97
+ time_str = f" (uploaded {relative_time})" if relative_time else ""
98
+ self.console.print(f'[green]✓[/green] Found dev session "[cyan]{session_name}[/cyan]"{time_str}')
99
+ else:
100
+ self._render_failure(check)
101
+
102
+ def render_summary(self, results: DiagnosticResults) -> None:
103
+ """Render the summary section."""
104
+ self.console.print()
105
+ self.console.print("━" * 65)
106
+ self.console.print()
107
+
108
+ if results.all_passed:
109
+ self.console.print("[green]✓ All checks passed![/green] Your Recce setup is ready.")
110
+ self.console.print()
111
+ self.console.print("Next step:")
112
+ self.console.print(" $ recce-cloud review --session-name <session_name>")
113
+ else:
114
+ self.console.print(
115
+ f"[yellow]⚠ {results.passed_count}/{results.total_count} checks passed.[/yellow] "
116
+ "See above for remediation steps."
117
+ )
118
+
119
+ def render_all(self, results: DiagnosticResults) -> None:
120
+ """Render all diagnostic results."""
121
+ self.render_header()
122
+ self.render_login_check(results)
123
+ self.render_project_binding_check(results)
124
+ self.render_production_check(results)
125
+ self.render_dev_session_check(results)
126
+ self.render_summary(results)
127
+
128
+ def _render_failure(self, check) -> None:
129
+ """Render a failed check with its suggestion."""
130
+ self.console.print(f"[red]✗[/red] {check.message}")
131
+ if check.suggestion:
132
+ self.console.print()
133
+ self.console.print("[dim]→ To fix:[/dim]")
134
+ for line in check.suggestion.split("\n"):
135
+ self.console.print(f" {line}")
136
+
137
+
138
+ @click.command()
139
+ @click.option(
140
+ "--json",
141
+ "output_json",
142
+ is_flag=True,
143
+ help="Output in JSON format for scripting",
144
+ )
145
+ def doctor(output_json: bool) -> None:
146
+ """
147
+ Check Recce Cloud setup and configuration.
148
+
149
+ Validates login status, project binding, and session availability.
150
+ Provides actionable suggestions when issues are found.
151
+
152
+ \b
153
+ Examples:
154
+ # Check setup status
155
+ recce-cloud doctor
156
+
157
+ # Machine-readable output
158
+ recce-cloud doctor --json
159
+ """
160
+ console = Console()
161
+
162
+ # Run diagnostic checks
163
+ service = DiagnosticService()
164
+ results = service.run_all_checks()
165
+
166
+ # Output results
167
+ if output_json:
168
+ console.print(json.dumps(results.to_dict(), indent=2, default=str))
169
+ else:
170
+ renderer = DiagnosticRenderer(console)
171
+ renderer.render_all(results)
172
+
173
+ # Exit with appropriate code
174
+ sys.exit(0 if results.all_passed else 1)
@@ -0,0 +1,19 @@
1
+ """
2
+ Configuration module for Recce Cloud CLI.
3
+
4
+ This module provides project binding and configuration resolution.
5
+ """
6
+
7
+ from recce_cloud.config.project_config import (
8
+ clear_project_binding,
9
+ get_project_binding,
10
+ save_project_binding,
11
+ )
12
+ from recce_cloud.config.resolver import resolve_config
13
+
14
+ __all__ = [
15
+ "clear_project_binding",
16
+ "get_project_binding",
17
+ "resolve_config",
18
+ "save_project_binding",
19
+ ]
@@ -0,0 +1,187 @@
1
+ """
2
+ Project configuration management for .recce/config.
3
+
4
+ This module manages local project binding stored in the project directory.
5
+ """
6
+
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+ import yaml
12
+
13
+ RECCE_CONFIG_DIR = ".recce"
14
+ RECCE_CONFIG_FILE = "config"
15
+
16
+
17
+ def get_config_path(project_dir: Optional[str] = None) -> Path:
18
+ """
19
+ Get the path to the local config file.
20
+
21
+ Args:
22
+ project_dir: Project directory path. Defaults to current directory.
23
+
24
+ Returns:
25
+ Path to .recce/config file.
26
+ """
27
+ base_dir = Path(project_dir) if project_dir else Path.cwd()
28
+ return base_dir / RECCE_CONFIG_DIR / RECCE_CONFIG_FILE
29
+
30
+
31
+ def get_config_dir(project_dir: Optional[str] = None) -> Path:
32
+ """
33
+ Get the path to the .recce directory.
34
+
35
+ Args:
36
+ project_dir: Project directory path. Defaults to current directory.
37
+
38
+ Returns:
39
+ Path to .recce directory.
40
+ """
41
+ base_dir = Path(project_dir) if project_dir else Path.cwd()
42
+ return base_dir / RECCE_CONFIG_DIR
43
+
44
+
45
+ def load_config(project_dir: Optional[str] = None) -> Dict[str, Any]:
46
+ """
47
+ Load configuration from .recce/config.
48
+
49
+ Args:
50
+ project_dir: Project directory path. Defaults to current directory.
51
+
52
+ Returns:
53
+ Configuration dictionary, or empty dict if file doesn't exist.
54
+ """
55
+ config_path = get_config_path(project_dir)
56
+ if not config_path.exists():
57
+ return {}
58
+
59
+ with open(config_path, "r", encoding="utf-8") as f:
60
+ config = yaml.safe_load(f)
61
+ return config if config else {}
62
+
63
+
64
+ def save_config(config: Dict[str, Any], project_dir: Optional[str] = None) -> None:
65
+ """
66
+ Save configuration to .recce/config.
67
+
68
+ Args:
69
+ config: Configuration dictionary to save.
70
+ project_dir: Project directory path. Defaults to current directory.
71
+ """
72
+ config_dir = get_config_dir(project_dir)
73
+ config_path = get_config_path(project_dir)
74
+
75
+ # Ensure .recce directory exists
76
+ config_dir.mkdir(parents=True, exist_ok=True)
77
+
78
+ with open(config_path, "w", encoding="utf-8") as f:
79
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
80
+
81
+
82
+ def get_project_binding(project_dir: Optional[str] = None) -> Optional[Dict[str, Any]]:
83
+ """
84
+ Get the current project binding from .recce/config.
85
+
86
+ Args:
87
+ project_dir: Project directory path. Defaults to current directory.
88
+
89
+ Returns:
90
+ Dictionary with org, project, bound_at, bound_by fields,
91
+ or None if not bound.
92
+ """
93
+ config = load_config(project_dir)
94
+ cloud_config = config.get("cloud", {})
95
+
96
+ if not cloud_config.get("org") or not cloud_config.get("project"):
97
+ return None
98
+
99
+ return {
100
+ "org": cloud_config.get("org"),
101
+ "project": cloud_config.get("project"),
102
+ "bound_at": cloud_config.get("bound_at"),
103
+ "bound_by": cloud_config.get("bound_by"),
104
+ }
105
+
106
+
107
+ def save_project_binding(
108
+ org: str,
109
+ project: str,
110
+ bound_by: Optional[str] = None,
111
+ project_dir: Optional[str] = None,
112
+ ) -> None:
113
+ """
114
+ Save project binding to .recce/config.
115
+
116
+ Args:
117
+ org: Organization name/slug.
118
+ project: Project name/slug.
119
+ bound_by: Email of user who created the binding.
120
+ project_dir: Project directory path. Defaults to current directory.
121
+ """
122
+ config = load_config(project_dir)
123
+
124
+ config["version"] = 1
125
+ config["cloud"] = {
126
+ "org": org,
127
+ "project": project,
128
+ "bound_at": datetime.now(timezone.utc).isoformat(),
129
+ }
130
+
131
+ if bound_by:
132
+ config["cloud"]["bound_by"] = bound_by
133
+
134
+ save_config(config, project_dir)
135
+
136
+
137
+ def clear_project_binding(project_dir: Optional[str] = None) -> bool:
138
+ """
139
+ Clear project binding from .recce/config.
140
+
141
+ Args:
142
+ project_dir: Project directory path. Defaults to current directory.
143
+
144
+ Returns:
145
+ True if binding was cleared, False if no binding existed.
146
+ """
147
+ config = load_config(project_dir)
148
+
149
+ if "cloud" not in config:
150
+ return False
151
+
152
+ del config["cloud"]
153
+ save_config(config, project_dir)
154
+ return True
155
+
156
+
157
+ def add_to_gitignore(project_dir: Optional[str] = None) -> bool:
158
+ """
159
+ Add .recce/ to .gitignore if not already present.
160
+
161
+ Args:
162
+ project_dir: Project directory path. Defaults to current directory.
163
+
164
+ Returns:
165
+ True if .gitignore was modified, False otherwise.
166
+ """
167
+ base_dir = Path(project_dir) if project_dir else Path.cwd()
168
+ gitignore_path = base_dir / ".gitignore"
169
+
170
+ # Check if .recce/ is already in .gitignore
171
+ if gitignore_path.exists():
172
+ with open(gitignore_path, "r", encoding="utf-8") as f:
173
+ content = f.read()
174
+ if ".recce/" in content or ".recce" in content:
175
+ return False
176
+
177
+ # Append .recce/ to .gitignore
178
+ with open(gitignore_path, "a", encoding="utf-8") as f:
179
+ if gitignore_path.exists():
180
+ # Check if file ends with newline
181
+ with open(gitignore_path, "r", encoding="utf-8") as rf:
182
+ content = rf.read()
183
+ if content and not content.endswith("\n"):
184
+ f.write("\n")
185
+ f.write("\n# Recce Cloud local config\n.recce/\n")
186
+
187
+ return True
@@ -0,0 +1,137 @@
1
+ """
2
+ Configuration resolver for Recce Cloud CLI.
3
+
4
+ Resolves org/project configuration from multiple sources with priority:
5
+ 1. CLI flags (--org, --project)
6
+ 2. Environment variables (RECCE_ORG, RECCE_PROJECT)
7
+ 3. Local config file (.recce/config)
8
+ 4. Error (no configuration found)
9
+ """
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+ from typing import Optional
14
+
15
+ from recce_cloud.config.project_config import get_project_binding
16
+
17
+
18
+ @dataclass
19
+ class ResolvedConfig:
20
+ """Resolved configuration with source information."""
21
+
22
+ org: str
23
+ project: str
24
+ source: str # "cli", "env", "config"
25
+
26
+
27
+ class ConfigurationError(Exception):
28
+ """Raised when configuration cannot be resolved."""
29
+
30
+ pass
31
+
32
+
33
+ def resolve_config(
34
+ cli_org: Optional[str] = None,
35
+ cli_project: Optional[str] = None,
36
+ project_dir: Optional[str] = None,
37
+ ) -> ResolvedConfig:
38
+ """
39
+ Resolve org/project configuration from multiple sources.
40
+
41
+ Priority order:
42
+ 1. CLI flags (--org, --project) - highest priority
43
+ 2. Environment variables (RECCE_ORG, RECCE_PROJECT)
44
+ 3. Local config file (.recce/config)
45
+ 4. Error if nothing found
46
+
47
+ Args:
48
+ cli_org: Organization from CLI flag.
49
+ cli_project: Project from CLI flag.
50
+ project_dir: Project directory for local config lookup.
51
+
52
+ Returns:
53
+ ResolvedConfig with org, project, and source.
54
+
55
+ Raises:
56
+ ConfigurationError: If org/project cannot be resolved.
57
+ """
58
+ # Priority 1: CLI flags
59
+ if cli_org and cli_project:
60
+ return ResolvedConfig(org=cli_org, project=cli_project, source="cli")
61
+
62
+ # Priority 2: Environment variables
63
+ env_org = os.environ.get("RECCE_ORG")
64
+ env_project = os.environ.get("RECCE_PROJECT")
65
+ if env_org and env_project:
66
+ return ResolvedConfig(org=env_org, project=env_project, source="env")
67
+
68
+ # Priority 3: Local config file
69
+ binding = get_project_binding(project_dir)
70
+ if binding:
71
+ return ResolvedConfig(
72
+ org=binding["org"],
73
+ project=binding["project"],
74
+ source="config",
75
+ )
76
+
77
+ # Priority 4: Error
78
+ raise ConfigurationError(
79
+ "No project configured. Run 'recce-cloud init' to bind this directory to a project, "
80
+ "or use --org and --project flags."
81
+ )
82
+
83
+
84
+ def resolve_org(
85
+ cli_org: Optional[str] = None,
86
+ project_dir: Optional[str] = None,
87
+ ) -> Optional[str]:
88
+ """
89
+ Resolve organization from multiple sources.
90
+
91
+ Args:
92
+ cli_org: Organization from CLI flag.
93
+ project_dir: Project directory for local config lookup.
94
+
95
+ Returns:
96
+ Organization name/slug, or None if not found.
97
+ """
98
+ if cli_org:
99
+ return cli_org
100
+
101
+ env_org = os.environ.get("RECCE_ORG")
102
+ if env_org:
103
+ return env_org
104
+
105
+ binding = get_project_binding(project_dir)
106
+ if binding:
107
+ return binding["org"]
108
+
109
+ return None
110
+
111
+
112
+ def resolve_project(
113
+ cli_project: Optional[str] = None,
114
+ project_dir: Optional[str] = None,
115
+ ) -> Optional[str]:
116
+ """
117
+ Resolve project from multiple sources.
118
+
119
+ Args:
120
+ cli_project: Project from CLI flag.
121
+ project_dir: Project directory for local config lookup.
122
+
123
+ Returns:
124
+ Project name/slug, or None if not found.
125
+ """
126
+ if cli_project:
127
+ return cli_project
128
+
129
+ env_project = os.environ.get("RECCE_PROJECT")
130
+ if env_project:
131
+ return env_project
132
+
133
+ binding = get_project_binding(project_dir)
134
+ if binding:
135
+ return binding["project"]
136
+
137
+ return None
@@ -0,0 +1 @@
1
+ # Services layer for business logic