From 187fb4b1bd3bde23404c1fac05402e4accbde0fe Mon Sep 17 00:00:00 2001 From: CCimen <91538535+CCimen@users.noreply.github.com> Date: Sun, 25 Jan 2026 12:06:42 +0100 Subject: [PATCH] Polish config enforcement output and update examples --- examples/11-release-readiness-org.json | 28 +-- examples/99-complete-reference.json | 14 +- examples/README.md | 10 +- .../application/compute_effective_config.py | 29 +++ src/scc_cli/cli.py | 2 - src/scc_cli/commands/config.py | 221 +++++++++++++++++- src/scc_cli/commands/init.py | 19 +- src/scc_cli/commands/launch/render.py | 8 + src/scc_cli/commands/org/validate_cmd.py | 134 +++++++++-- src/scc_cli/commands/team.py | 143 +++++++++++- src/scc_cli/config.py | 7 + src/scc_cli/kinds.py | 1 + src/scc_cli/validate.py | 31 +++ tests/test_config_inheritance.py | 61 ++++- tests/test_config_validate.py | 100 ++++++++ tests/test_org_cli.py | 28 +++ tests/test_start_dryrun.py | 18 ++ tests/test_team_cli.py | 71 ++++++ uv.lock | 2 +- 19 files changed, 855 insertions(+), 72 deletions(-) create mode 100644 tests/test_config_validate.py diff --git a/examples/11-release-readiness-org.json b/examples/11-release-readiness-org.json index c4bbd05..df5fd7e 100644 --- a/examples/11-release-readiness-org.json +++ b/examples/11-release-readiness-org.json @@ -8,7 +8,7 @@ "contact": "platform@example.com" }, "marketplaces": { - "official-plugins": { + "sandboxed-code-official": { "source": "github", "owner": "CCimen", "repo": "sandboxed-code-plugins", @@ -45,13 +45,13 @@ }, "defaults": { "enabled_plugins": [ - "scc-safety-net@official-plugins" + "scc-safety-net@sandboxed-code-official" ], "disabled_plugins": [ - "legacy-tools@official-plugins" + "legacy-tools@sandboxed-code-official" ], "allowed_plugins": [ - "*@official-plugins" + "*@sandboxed-code-official" ], "allowed_mcp_servers": [ "https://mcp.context7.com/*", @@ -65,7 +65,7 @@ "/usr/local/bin/*" ], "extra_marketplaces": [ - "official-plugins" + "sandboxed-code-official" ], "cache_ttl_hours": 24, "network_policy": "corp-proxy-only", @@ -91,8 +91,8 @@ "platform": { "description": "Platform engineering tools and core MCP access", "additional_plugins": [ - "platform-tooling@official-plugins", - "infra-helper@official-plugins" + "platform-tooling@sandboxed-code-official", + "infra-helper@sandboxed-code-official" ], "additional_mcp_servers": [ { @@ -112,8 +112,8 @@ "frontend": { "description": "Frontend team with design and UI MCP servers", "additional_plugins": [ - "frontend-toolkit@official-plugins", - "ui-kit@official-plugins" + "frontend-toolkit@sandboxed-code-official", + "ui-kit@sandboxed-code-official" ], "additional_mcp_servers": [ { @@ -135,7 +135,7 @@ "backend": { "description": "Backend services team with internal API MCP", "additional_plugins": [ - "backend-tooling@official-plugins" + "backend-tooling@sandboxed-code-official" ], "additional_mcp_servers": [ { @@ -159,8 +159,8 @@ "data": { "description": "Data team with catalog MCP and longer sessions", "additional_plugins": [ - "data-tooling@official-plugins", - "notebook-helper@official-plugins" + "data-tooling@sandboxed-code-official", + "notebook-helper@sandboxed-code-official" ], "additional_mcp_servers": [ { @@ -181,7 +181,7 @@ "browser-automation": { "description": "Browser automation team using Playwright MCP", "additional_plugins": [ - "browser-tools@official-plugins" + "browser-tools@sandboxed-code-official" ], "additional_mcp_servers": [ { @@ -204,7 +204,7 @@ "security": { "description": "Security team with scanning tools and isolated network policy", "additional_plugins": [ - "security-audit@official-plugins" + "security-audit@sandboxed-code-official" ], "additional_mcp_servers": [ { diff --git a/examples/99-complete-reference.json b/examples/99-complete-reference.json index 92cba54..8d70c1f 100644 --- a/examples/99-complete-reference.json +++ b/examples/99-complete-reference.json @@ -8,7 +8,7 @@ "contact": "docs@example.com" }, "marketplaces": { - "official-plugins": { + "sandboxed-code-official": { "source": "github", "owner": "CCimen", "repo": "sandboxed-code-plugins", @@ -64,22 +64,22 @@ }, "defaults": { "enabled_plugins": [ - "scc-safety-net@official-plugins" + "scc-safety-net@sandboxed-code-official" ], "disabled_plugins": [ - "legacy-tool@official-plugins" + "legacy-tool@sandboxed-code-official" ], "allowed_plugins": [ "core-*", "org-approved-*", - "*@official-plugins" + "*@sandboxed-code-official" ], "allowed_mcp_servers": [ "https://mcp.example.com/*", "https://*.company.com/*" ], "extra_marketplaces": [ - "official-plugins" + "sandboxed-code-official" ], "cache_ttl_hours": 24, "network_policy": "unrestricted", @@ -110,8 +110,8 @@ "team-a": { "description": "Example team A with HTTP MCP servers (authenticated and public)", "additional_plugins": [ - "team-plugin-1@official-plugins", - "team-plugin-2@official-plugins" + "team-plugin-1@sandboxed-code-official", + "team-plugin-2@sandboxed-code-official" ], "additional_mcp_servers": [ { diff --git a/examples/README.md b/examples/README.md index 50efff2..481f70d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -228,7 +228,13 @@ PY -If your SCC version supports it, you can validate a team by name after the org config is installed: +If your SCC version supports it, you can validate a team config file directly: + +``` +scc team validate --file team-config.json +``` + +If the org config is installed, you can also validate by team name: ``` scc team validate @@ -236,7 +242,7 @@ scc team validate If you are unsure which commands your version supports, see: -- [docs/CLI-REFERENCE.md](../docs/CLI-REFERENCE.md) +- [CLI Reference](https://scc-cli.dev/reference/cli/overview/) --- diff --git a/src/scc_cli/application/compute_effective_config.py b/src/scc_cli/application/compute_effective_config.py index a079eca..21209a4 100644 --- a/src/scc_cli/application/compute_effective_config.py +++ b/src/scc_cli/application/compute_effective_config.py @@ -440,6 +440,14 @@ def compute_effective_config( ) if default_session.get("auto_resume") is not None: result.session_config.auto_resume = default_session["auto_resume"] + result.decisions.append( + ConfigDecision( + field="session.auto_resume", + value=default_session["auto_resume"], + reason="Organization default session auto-resume", + source="org.defaults", + ) + ) profiles = org_config.get("profiles", {}) team_config = profiles.get(team_name, {}) @@ -602,6 +610,16 @@ def compute_effective_config( source=f"team.{team_name}", ) ) + if team_session.get("auto_resume") is not None: + result.session_config.auto_resume = team_session["auto_resume"] + result.decisions.append( + ConfigDecision( + field="session.auto_resume", + value=team_session["auto_resume"], + reason=f"Overridden by team profile '{team_name}'", + source=f"team.{team_name}", + ) + ) if project_config: project_delegated, delegation_reason = is_project_delegated(org_config, team_name) @@ -742,5 +760,16 @@ def compute_effective_config( source="project", ) ) + if project_session.get("auto_resume") is not None: + if project_delegated: + result.session_config.auto_resume = project_session["auto_resume"] + result.decisions.append( + ConfigDecision( + field="session.auto_resume", + value=project_session["auto_resume"], + reason="Overridden by project config", + source="project", + ) + ) return result diff --git a/src/scc_cli/cli.py b/src/scc_cli/cli.py index 7f01736..b278748 100644 --- a/src/scc_cli/cli.py +++ b/src/scc_cli/cli.py @@ -145,7 +145,6 @@ def main_callback( session_name=None, resume=False, select=False, - continue_session=False, worktree_name=None, fresh=False, install_deps=False, @@ -174,7 +173,6 @@ def main_callback( session_name=None, resume=False, select=False, - continue_session=False, worktree_name=None, fresh=False, install_deps=False, diff --git a/src/scc_cli/commands/config.py b/src/scc_cli/commands/config.py index d1e8250..97b847f 100644 --- a/src/scc_cli/commands/config.py +++ b/src/scc_cli/commands/config.py @@ -19,11 +19,11 @@ ) from ..cli_common import console, handle_errors from ..core import personal_profiles -from ..core.enums import NetworkPolicy +from ..core.enums import NetworkPolicy, RequestSource from ..core.exit_codes import EXIT_USAGE from ..core.network_policy import collect_proxy_env, is_more_or_equal_restrictive from ..maintenance import get_paths, get_total_size -from ..panels import create_error_panel, create_info_panel +from ..panels import create_error_panel, create_info_panel, create_success_panel from ..source_resolver import ResolveError, resolve_source from ..stores.exception_store import RepoStore, UserStore from ..utils.ttl import format_relative @@ -148,7 +148,9 @@ def setup_cmd( @handle_errors def config_cmd( - action: str = typer.Argument(None, help="Action: set, get, show, edit, explain, paths"), + action: str = typer.Argument( + None, help="Action: set, get, show, edit, explain, validate, paths" + ), key: str = typer.Argument(None, help="Config key (for set/get, e.g. hooks.enabled)"), value: str = typer.Argument(None, help="Value (for set only)"), show: bool = typer.Option(False, "--show", help="Show current config"), @@ -159,9 +161,12 @@ def config_cmd( workspace: str | None = typer.Option( None, "--workspace", help="Workspace path for project config (default: current directory)" ), + team: str | None = typer.Option( + None, "--team", "-t", help="Team profile to use for explain/validate" + ), json_output: Annotated[ bool, - typer.Option("--json", help="Output as JSON (for paths action)."), + typer.Option("--json", help="Output as JSON (paths/explain/validate)."), ] = False, show_env: Annotated[ bool, @@ -177,6 +182,7 @@ def config_cmd( scc config --edit # Open in editor scc config explain # Explain effective config scc config explain --field plugins # Explain only plugins + scc config validate # Validate .scc.yaml scc config paths # Show SCC file locations scc config paths --json # Show paths as JSON """ @@ -207,10 +213,33 @@ def config_cmd( _config_explain( field_filter=field, workspace_path=workspace, + team_override=team, + json_output=True, + ) + else: + _config_explain( + field_filter=field, + workspace_path=workspace, + team_override=team, + json_output=False, + ) + return + if action == "validate": + if json_output: + from ..output_mode import json_command_mode, json_output_mode + + with json_output_mode(), json_command_mode(): + _config_validate( + workspace_path=workspace, + team_override=team, json_output=True, ) else: - _config_explain(field_filter=field, workspace_path=workspace, json_output=False) + _config_validate( + workspace_path=workspace, + team_override=team, + json_output=False, + ) return # Handle --show and --edit flags @@ -288,6 +317,7 @@ def _config_get(key: str) -> None: def _config_explain( field_filter: str | None = None, workspace_path: str | None = None, + team_override: str | None = None, json_output: bool = False, ) -> None: """Explain the effective configuration with source attribution. @@ -304,7 +334,7 @@ def _config_explain( raise typer.Exit(1) # Get selected profile/team - team = config.get_selected_profile() + team = team_override or config.get_selected_profile() if not team: console.print("[red]No team selected. Run 'scc team switch ' first.[/red]") raise typer.Exit(1) @@ -516,6 +546,174 @@ def _render_advisory_warnings(warnings: list[str], field_filter: str | None) -> console.print() +def _config_validate( + *, + workspace_path: str | None, + team_override: str | None, + json_output: bool, +) -> None: + from ..core.exit_codes import EXIT_CONFIG, EXIT_GOVERNANCE, EXIT_SUCCESS + from ..json_output import build_envelope + from ..kinds import Kind + from ..output_mode import print_json + + errors: list[str] = [] + warnings: list[str] = [] + + org_config = config.load_cached_org_config() + if not org_config: + errors.append("No organization config found. Run 'scc setup' first.") + + team = team_override or config.get_selected_profile() + if not team: + errors.append("No team selected. Run 'scc team switch ' first.") + + ws_path = Path(workspace_path) if workspace_path else Path.cwd() + config_file = ws_path / config.PROJECT_CONFIG_FILE + + project_config: dict[str, Any] | None = None + if not errors and team and org_config: + profiles = org_config.get("profiles", {}) + if team not in profiles: + errors.append(f"Team '{team}' not found in org config.") + + if not errors: + try: + project_config = config.read_project_config(ws_path) + except ValueError as exc: + errors.append(str(exc)) + + if not errors and project_config is None: + if not config_file.exists(): + errors.append(f"No .scc.yaml found at {config_file}") + else: + errors.append(f"{config_file} is empty.") + + blocked_items: list[dict[str, Any]] = [] + denied_additions: list[dict[str, Any]] = [] + unknown_keys: list[str] = [] + + if not errors and project_config and org_config: + allowed_keys = {"additional_plugins", "additional_mcp_servers", "session"} + unknown_keys = sorted([key for key in project_config if key not in allowed_keys]) + if unknown_keys: + warnings.append("Unknown keys in .scc.yaml (ignored): " + ", ".join(unknown_keys)) + + project_session = project_config.get("session", {}) + if "auto_resume" in project_session: + warnings.append("session.auto_resume is advisory only and not enforced.") + + effective = compute_effective_config( + org_config=org_config, + team_name=team, + project_config=project_config, + ) + + project_plugins = set(project_config.get("additional_plugins", [])) + project_mcp_tokens: set[str] = set() + for server in project_config.get("additional_mcp_servers", []): + name = server.get("name") + url = server.get("url") + if name: + project_mcp_tokens.add(name) + if url: + project_mcp_tokens.add(url) + + for blocked in effective.blocked_items: + if blocked.item not in project_plugins and blocked.item not in project_mcp_tokens: + continue + blocked_items.append( + { + "item": blocked.item, + "blocked_by": blocked.blocked_by, + "source": blocked.source, + "target_type": blocked.target_type, + } + ) + errors.append(f"{blocked.item} blocked by {blocked.blocked_by} ({blocked.source})") + + for denied in effective.denied_additions: + if denied.requested_by != RequestSource.PROJECT: + continue + denied_additions.append( + { + "item": denied.item, + "requested_by": denied.requested_by, + "reason": denied.reason, + "target_type": denied.target_type, + } + ) + errors.append(f"{denied.item} denied ({denied.reason})") + + ok = not errors + exit_code = EXIT_SUCCESS if ok else EXIT_CONFIG + if denied_additions or blocked_items: + exit_code = EXIT_GOVERNANCE + + if json_output: + data = { + "workspace_path": str(ws_path), + "team": team, + "project_config_path": str(config_file), + "project_config_found": project_config is not None, + "blocked_items": blocked_items, + "denied_additions": denied_additions, + "unknown_keys": unknown_keys, + } + envelope = build_envelope( + Kind.CONFIG_VALIDATE, + data=data, + ok=ok, + errors=errors, + warnings=warnings, + ) + print_json(envelope) + raise typer.Exit(exit_code) + + if ok: + team_label = team or "unknown" + console.print( + create_success_panel( + "Project Config Valid", + { + "Workspace": str(ws_path), + "Config": str(config_file), + "Team": team_label, + }, + ) + ) + else: + console.print( + create_error_panel( + "Project Config Invalid", + errors[0], + "Run 'scc config explain --field denied' for details.", + ) + ) + + if blocked_items: + console.print("[bold red]Blocked Items[/bold red]") + for item in blocked_items: + console.print( + f" [red]✗[/red] {item['item']} [dim](blocked by {item['blocked_by']})[/dim]" + ) + console.print() + + if denied_additions: + console.print("[bold yellow]Denied Additions[/bold yellow]") + for item in denied_additions: + console.print(f" [yellow]⚠[/yellow] {item['item']}: {item['reason']}") + console.print() + + if warnings: + console.print("[bold yellow]Warnings[/bold yellow]") + for warning in warnings: + console.print(f" [yellow]⚠[/yellow] {warning}") + console.print() + + raise typer.Exit(exit_code) + + def _render_config_decisions(effective: EffectiveConfig, field_filter: str | None) -> None: """Render config decisions grouped by field.""" # Group decisions by field @@ -563,11 +761,20 @@ def _render_config_decisions(effective: EffectiveConfig, field_filter: str | Non (d for d in effective.decisions if "timeout" in d.field.lower()), None, ) + auto_resume_decision = next( + (d for d in effective.decisions if d.field == "session.auto_resume"), + None, + ) if timeout_decision: console.print(f" timeout_hours: {timeout} [dim](from {timeout_decision.source})[/dim]") else: console.print(f" timeout_hours: {timeout} [dim](default)[/dim]") - console.print(f" auto_resume: {auto_resume}") + if auto_resume_decision: + console.print( + f" auto_resume: {auto_resume} [dim](from {auto_resume_decision.source})[/dim]" + ) + else: + console.print(f" auto_resume: {auto_resume}") console.print() if not field_filter or field_filter == "network": diff --git a/src/scc_cli/commands/init.py b/src/scc_cli/commands/init.py index 9cfdcba..f4e0e1f 100644 --- a/src/scc_cli/commands/init.py +++ b/src/scc_cli/commands/init.py @@ -73,30 +73,29 @@ def generate_template_content() -> str: # This file configures SCC (Sandboxed Claude CLI) for this project. # Place this file in your repository root. # -# For full documentation, see: https://github.com/sundsvall/scc-cli#configuration +# For full documentation, see: https://scc-cli.dev/reference/configuration/project-schema/ # ───────────────────────────────────────────────────────────────────────────── # Additional plugins to enable for this project # These plugins are loaded on top of your team profile's plugins. # Only plugins allowed by your organization can be added here. +# Format: plugin-name@marketplace additional_plugins: [] - # - "project-specific-linter" - # - "custom-formatter" + # - "project-specific-linter@internal" + # - "custom-formatter@claude-plugins-official" # Session configuration session: # Session timeout in hours (default: 8) timeout_hours: 8 + # auto_resume is advisory only in v1 (not enforced) + # auto_resume: false # Optional: MCP servers specific to this project -# mcp_servers: [] +# additional_mcp_servers: # - name: "project-db" -# command: "npx" -# args: ["@project/mcp-server"] - -# Optional: Environment variables for the sandbox -# env: {} -# PROJECT_NAME: "my-project" +# type: "sse" +# url: "https://db.example.com/mcp" """ diff --git a/src/scc_cli/commands/launch/render.py b/src/scc_cli/commands/launch/render.py index 5900995..262dd77 100644 --- a/src/scc_cli/commands/launch/render.py +++ b/src/scc_cli/commands/launch/render.py @@ -78,6 +78,7 @@ def build_dry_run_data( """ plugins: list[dict[str, Any]] = [] blocked_items: list[str] = [] + network_policy: str | None = None if org_config and team: from ...application.compute_effective_config import compute_effective_config @@ -89,6 +90,7 @@ def build_dry_run_data( project_config=project_config, workspace_path=workspace_for_project, ) + network_policy = effective.network_policy for plugin in sorted(effective.plugins): plugins.append({"name": plugin, "source": "resolved"}) @@ -112,6 +114,7 @@ def build_dry_run_data( "team": team, "plugins": plugins, "blocked_items": blocked_items, + "network_policy": network_policy, "ready_to_start": len(blocked_items) == 0, "resolution_reason": resolution_reason, } @@ -210,6 +213,11 @@ def show_dry_run_panel(data: dict[str, Any]) -> None: # Team grid.add_row("Team:", data.get("team") or "standalone") + # Network policy + network_policy = data.get("network_policy") + if network_policy: + grid.add_row("Network policy:", network_policy) + # Plugins plugins = data.get("plugins", []) if plugins: diff --git a/src/scc_cli/commands/org/validate_cmd.py b/src/scc_cli/commands/org/validate_cmd.py index 2f6429c..9320b23 100644 --- a/src/scc_cli/commands/org/validate_cmd.py +++ b/src/scc_cli/commands/org/validate_cmd.py @@ -14,13 +14,17 @@ from ...kinds import Kind from ...output_mode import json_output_mode, print_json, set_pretty_mode from ...panels import create_error_panel, create_success_panel, create_warning_panel +from ...source_resolver import ResolveError, resolve_source from ...validate import validate_org_config from ._builders import build_validation_data, check_semantic_errors @handle_errors def org_validate_cmd( - source: str = typer.Argument(..., help="Path to config file to validate"), + source: str = typer.Argument( + ..., + help="Config source (file path, HTTPS URL, or shorthand like github:org/repo:path)", + ), json_output: bool = typer.Option(False, "--json", help="Output as JSON"), pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"), ) -> None: @@ -37,38 +41,124 @@ def org_validate_cmd( json_output = True set_pretty_mode(True) - # Load config file - config_path = Path(source).expanduser().resolve() - if not config_path.exists(): + resolved = resolve_source(source) + if isinstance(resolved, ResolveError): + error_msg = resolved.message + if resolved.suggestion: + error_msg = f"{resolved.message}\n{resolved.suggestion}" if json_output: with json_output_mode(): data = build_validation_data( source=source, - schema_errors=[f"File not found: {source}"], + schema_errors=[error_msg], semantic_errors=[], ) envelope = build_envelope(Kind.ORG_VALIDATION, data=data, ok=False) print_json(envelope) raise typer.Exit(EXIT_CONFIG) - console.print(create_error_panel("File Not Found", f"Cannot find config file: {source}")) + console.print(create_error_panel("Invalid Source", error_msg)) raise typer.Exit(EXIT_CONFIG) - # Parse JSON - try: - config = json.loads(config_path.read_text()) - except json.JSONDecodeError as e: - if json_output: - with json_output_mode(): - data = build_validation_data( - source=source, - schema_errors=[f"Invalid JSON: {e}"], - semantic_errors=[], - ) - envelope = build_envelope(Kind.ORG_VALIDATION, data=data, ok=False) - print_json(envelope) - raise typer.Exit(EXIT_CONFIG) - console.print(create_error_panel("Invalid JSON", f"Failed to parse JSON: {e}")) - raise typer.Exit(EXIT_CONFIG) + # Load config from file or remote source + if resolved.is_file: + config_path = Path(resolved.resolved_url) + if not config_path.exists(): + if json_output: + with json_output_mode(): + data = build_validation_data( + source=source, + schema_errors=[f"File not found: {source}"], + semantic_errors=[], + ) + envelope = build_envelope(Kind.ORG_VALIDATION, data=data, ok=False) + print_json(envelope) + raise typer.Exit(EXIT_CONFIG) + console.print( + create_error_panel("File Not Found", f"Cannot find config file: {source}") + ) + raise typer.Exit(EXIT_CONFIG) + + try: + config = json.loads(config_path.read_text()) + except json.JSONDecodeError as e: + if json_output: + with json_output_mode(): + data = build_validation_data( + source=source, + schema_errors=[f"Invalid JSON: {e}"], + semantic_errors=[], + ) + envelope = build_envelope(Kind.ORG_VALIDATION, data=data, ok=False) + print_json(envelope) + raise typer.Exit(EXIT_CONFIG) + console.print(create_error_panel("Invalid JSON", f"Failed to parse JSON: {e}")) + raise typer.Exit(EXIT_CONFIG) + else: + import requests + + try: + response = requests.get(resolved.resolved_url, timeout=30) + except requests.RequestException as e: + error_msg = f"Failed to fetch config: {e}" + if json_output: + with json_output_mode(): + data = build_validation_data( + source=source, + schema_errors=[error_msg], + semantic_errors=[], + ) + envelope = build_envelope(Kind.ORG_VALIDATION, data=data, ok=False) + print_json(envelope) + raise typer.Exit(EXIT_CONFIG) + console.print(create_error_panel("Network Error", error_msg)) + raise typer.Exit(EXIT_CONFIG) + + if response.status_code == 404: + error_msg = f"Config not found at {resolved.resolved_url}" + if json_output: + with json_output_mode(): + data = build_validation_data( + source=source, + schema_errors=[error_msg], + semantic_errors=[], + ) + envelope = build_envelope(Kind.ORG_VALIDATION, data=data, ok=False) + print_json(envelope) + raise typer.Exit(EXIT_CONFIG) + console.print(create_error_panel("Not Found", error_msg)) + raise typer.Exit(EXIT_CONFIG) + + if response.status_code != 200: + error_msg = f"HTTP {response.status_code} from {resolved.resolved_url}" + if json_output: + with json_output_mode(): + data = build_validation_data( + source=source, + schema_errors=[error_msg], + semantic_errors=[], + ) + envelope = build_envelope(Kind.ORG_VALIDATION, data=data, ok=False) + print_json(envelope) + raise typer.Exit(EXIT_CONFIG) + console.print(create_error_panel("HTTP Error", error_msg)) + raise typer.Exit(EXIT_CONFIG) + + try: + config = response.json() + except json.JSONDecodeError as e: + error_msg = f"Invalid JSON in response: {e}" + if json_output: + with json_output_mode(): + data = build_validation_data( + source=source, + schema_errors=[error_msg], + semantic_errors=[], + ) + envelope = build_envelope(Kind.ORG_VALIDATION, data=data, ok=False) + print_json(envelope) + raise typer.Exit(EXIT_CONFIG) + console.print(create_error_panel("Invalid JSON", error_msg)) + raise typer.Exit(EXIT_CONFIG) # Validate against schema schema_errors = validate_org_config(config) diff --git a/src/scc_cli/commands/team.py b/src/scc_cli/commands/team.py index 0f3c8ac..c824d6a 100644 --- a/src/scc_cli/commands/team.py +++ b/src/scc_cli/commands/team.py @@ -11,6 +11,8 @@ All commands support --json output with proper envelopes. """ +import json +from pathlib import Path from typing import Any import typer @@ -20,6 +22,7 @@ from .. import config, teams from ..bootstrap import get_default_adapters from ..cli_common import console, handle_errors, render_responsive_table +from ..core.constants import CURRENT_SCHEMA_VERSION from ..json_command import json_command from ..kinds import Kind from ..marketplace.compute import TeamNotFoundError @@ -28,9 +31,10 @@ from ..marketplace.team_fetch import TeamFetchResult, fetch_team_config from ..marketplace.trust import TrustViolationError from ..output_mode import is_json_mode, print_human -from ..panels import create_warning_panel +from ..panels import create_error_panel, create_success_panel, create_warning_panel from ..ui.gate import InteractivityContext from ..ui.picker import TeamSwitchRequested, pick_team +from ..validate import validate_team_config # ═══════════════════════════════════════════════════════════════════════════════ # Display Helpers @@ -58,7 +62,84 @@ def _format_plugins_for_display(plugins: list[str], max_display: int = 2) -> str # Show first N and count of remaining names = [p.split("@")[0] for p in plugins[:max_display]] remaining = len(plugins) - max_display - return f"{', '.join(names)} +{remaining} more" + return f"{', '.join(names)} +{remaining} more" + + +def _looks_like_path(value: str) -> bool: + """Best-effort detection for file-like inputs.""" + return any(token in value for token in ("/", "\\", "~", ".json", ".jsonc", ".json5")) + + +def _validate_team_config_file(source: str, verbose: bool) -> dict[str, Any]: + """Validate a team config file against the bundled schema.""" + path = Path(source).expanduser() + if not path.exists(): + if not is_json_mode(): + console.print( + create_error_panel( + "File Not Found", + f"Cannot find team config file: {source}", + ) + ) + return { + "mode": "file", + "source": source, + "valid": False, + "error": f"File not found: {source}", + } + + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + if not is_json_mode(): + console.print( + create_error_panel( + "Invalid JSON", + f"Failed to parse JSON: {exc}", + ) + ) + return { + "mode": "file", + "source": str(path), + "valid": False, + "error": f"Invalid JSON: {exc}", + } + + errors = validate_team_config(data) + is_valid = not errors + + if not is_json_mode(): + if is_valid: + console.print( + create_success_panel( + "Validation Passed", + { + "Source": str(path), + "Schema Version": CURRENT_SCHEMA_VERSION, + "Status": "Valid", + }, + ) + ) + else: + console.print( + create_error_panel( + "Validation Failed", + "\n".join(f"• {e}" for e in errors), + ) + ) + + response: dict[str, Any] = { + "mode": "file", + "source": str(path), + "valid": is_valid, + } + if "schema_version" in data: + response["schema_version"] = data.get("schema_version") + if errors: + response["errors"] = errors + if verbose and "errors" not in response: + response["errors"] = [] + return response # ═══════════════════════════════════════════════════════════════════════════════ @@ -661,7 +742,12 @@ def team_info( @json_command(Kind.TEAM_VALIDATE) @handle_errors def team_validate( - team_name: str = typer.Argument(..., help="Team name to validate"), + team_name: str | None = typer.Argument( + None, help="Team name to validate (defaults to current)" + ), + file: str | None = typer.Option( + None, "--file", "-f", help="Path to a team config file to validate" + ), verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"), json_output: bool = typer.Option(False, "--json", help="Output as JSON envelope"), pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"), @@ -674,9 +760,50 @@ def team_validate( - Marketplace trust grants (for federated teams) - Cache freshness status (for federated teams) - Use --verbose to see detailed validation information including - individual blocked/disabled plugins and their reasons. + Use --file to validate a local team config file against the schema. + Use --verbose to see detailed validation information. """ + if file and team_name: + if not is_json_mode(): + console.print( + create_warning_panel( + "Conflicting Inputs", + "Use either TEAM_NAME or --file, not both.", + "Examples: scc team validate backend | scc team validate --file team.json", + ) + ) + return { + "mode": "team", + "team": team_name, + "valid": False, + "error": "Conflicting inputs: provide TEAM_NAME or --file, not both", + } + + # File validation mode (explicit or detected) + if file or (team_name and _looks_like_path(team_name)): + source = file or team_name or "" + return _validate_team_config_file(source, verbose) + + # Default to current team if omitted + if not team_name: + cfg = config.load_user_config() + team_name = cfg.get("selected_profile") + if not team_name: + if not is_json_mode(): + console.print( + create_warning_panel( + "No Team Selected", + "No team provided and no current team is selected.", + "Run 'scc team list' or 'scc team switch ' to select one.", + ) + ) + return { + "mode": "team", + "team": None, + "valid": False, + "error": "No team selected", + } + org_config_data = config.load_cached_org_config() if not org_config_data: if not is_json_mode(): @@ -688,6 +815,7 @@ def team_validate( ) ) return { + "mode": "team", "team": team_name, "valid": False, "error": "No organization configuration found", @@ -706,6 +834,7 @@ def team_validate( ) ) return { + "mode": "team", "team": team_name, "valid": False, "error": f"Invalid org config: {e}", @@ -724,6 +853,7 @@ def team_validate( ) ) return { + "mode": "team", "team": team_name, "valid": False, "error": f"Team not found: {team_name}", @@ -739,6 +869,7 @@ def team_validate( ) ) return { + "mode": "team", "team": team_name, "valid": False, "error": f"Trust violation: {e.violation}", @@ -754,6 +885,7 @@ def team_validate( ) ) return { + "mode": "team", "team": team_name, "valid": False, "error": str(e), @@ -770,6 +902,7 @@ def team_validate( # Build JSON response response: dict[str, Any] = { + "mode": "team", "team": team_name, "valid": is_valid, "is_federated": effective.is_federated, diff --git a/src/scc_cli/config.py b/src/scc_cli/config.py index c7c466b..161cc7a 100644 --- a/src/scc_cli/config.py +++ b/src/scc_cli/config.py @@ -477,6 +477,10 @@ def read_project_config(workspace_path: str | Path) -> dict[str, Any] | None: if config is None: return None + # Normalize legacy keys before validation + if "mcp_servers" in config and "additional_mcp_servers" not in config: + config["additional_mcp_servers"] = config.pop("mcp_servers") + # Validate schema _validate_project_config_schema(config) @@ -512,3 +516,6 @@ def _validate_project_config_schema(config: dict[str, Any]) -> None: if "timeout_hours" in session: if not isinstance(session["timeout_hours"], int): raise ValueError("session.timeout_hours must be an integer") + if "auto_resume" in session: + if not isinstance(session["auto_resume"], bool): + raise ValueError("session.auto_resume must be a boolean") diff --git a/src/scc_cli/kinds.py b/src/scc_cli/kinds.py index 6f43dc0..0212718 100644 --- a/src/scc_cli/kinds.py +++ b/src/scc_cli/kinds.py @@ -54,6 +54,7 @@ class Kind(str, Enum): # Config CONFIG_EXPLAIN = "ConfigExplain" + CONFIG_VALIDATE = "ConfigValidate" # Start START_DRY_RUN = "StartDryRun" diff --git a/src/scc_cli/validate.py b/src/scc_cli/validate.py index c0fdd29..b1216e4 100644 --- a/src/scc_cli/validate.py +++ b/src/scc_cli/validate.py @@ -80,6 +80,7 @@ class VersionCompatibility: ORG_SCHEMA_FILENAME = "org-v1.schema.json" +TEAM_SCHEMA_FILENAME = "team-config.v1.schema.json" def load_bundled_schema() -> dict[Any, Any]: @@ -92,6 +93,16 @@ def load_bundled_schema() -> dict[Any, Any]: raise FileNotFoundError(f"Schema file '{ORG_SCHEMA_FILENAME}' not found") +def load_bundled_team_schema() -> dict[Any, Any]: + """Load bundled team config schema from package resources.""" + schema_file = files("scc_cli.schemas").joinpath(TEAM_SCHEMA_FILENAME) + try: + content = schema_file.read_text() + return cast(dict[Any, Any], json.loads(content)) + except FileNotFoundError: + raise FileNotFoundError(f"Schema file '{TEAM_SCHEMA_FILENAME}' not found") + + # ═══════════════════════════════════════════════════════════════════════════════ # Config Validation # ═══════════════════════════════════════════════════════════════════════════════ @@ -118,6 +129,26 @@ def validate_org_config(config: dict[str, Any]) -> list[str]: return errors +def validate_team_config(config: dict[str, Any]) -> list[str]: + """Validate team config against bundled schema. + + Args: + config: Team config dict to validate + + Returns: + List of error strings. Empty list means config is valid. + """ + schema = load_bundled_team_schema() + validator = Draft7Validator(schema) + + errors = [] + for error in validator.iter_errors(config): + path = "/".join(str(p) for p in error.path) or "(root)" + errors.append(f"{path}: {error.message}") + + return errors + + # ═══════════════════════════════════════════════════════════════════════════════ # Version Compatibility Checks # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/tests/test_config_inheritance.py b/tests/test_config_inheritance.py index d6b6c03..583d878 100644 --- a/tests/test_config_inheritance.py +++ b/tests/test_config_inheritance.py @@ -418,6 +418,29 @@ def test_project_extends_team_when_delegated(self, valid_org_config, project_con assert "gis-tools" in result.plugins # from team assert "project-specific-tool" in result.plugins # from project + def test_auto_resume_overrides_team_and_project(self, valid_org_config): + """Project and team session auto_resume should override defaults.""" + from scc_cli.application.compute_effective_config import compute_effective_config + + valid_org_config["profiles"]["urban-planning"]["session"] = {"auto_resume": False} + + result = compute_effective_config( + org_config=valid_org_config, + team_name="urban-planning", + project_config=None, + ) + + assert result.session_config.auto_resume is False + + project_config = {"session": {"auto_resume": True, "timeout_hours": 4}} + result = compute_effective_config( + org_config=valid_org_config, + team_name="urban-planning", + project_config=project_config, + ) + + assert result.session_config.auto_resume is True + # Session should use project override assert result.session_config.timeout_hours == 4 @@ -1040,8 +1063,27 @@ def test_read_project_config_mcp_servers_only(self, tmp_path): server = result["additional_mcp_servers"][0] assert server["name"] == "local-api" assert server["type"] == "stdio" - assert server["command"] == "npx" - assert "-y" in server["args"] + + def test_read_project_config_accepts_mcp_servers_alias(self, tmp_path): + """Should accept legacy mcp_servers as alias for additional_mcp_servers.""" + from scc_cli.config import read_project_config + + scc_yaml = tmp_path / ".scc.yaml" + scc_yaml.write_text(""" +mcp_servers: + - name: "project-api" + type: "sse" + url: "https://api.example.com/mcp" +""") + + result = read_project_config(tmp_path) + + assert result is not None + assert "additional_mcp_servers" in result + server = result["additional_mcp_servers"][0] + assert server["name"] == "project-api" + assert server["type"] == "sse" + assert server["url"] == "https://api.example.com/mcp" class TestReadProjectConfigValidation: @@ -1079,6 +1121,21 @@ def test_read_project_config_mcp_servers_must_be_list(self, tmp_path): assert "mcp_servers" in str(exc_info.value).lower() or "list" in str(exc_info.value).lower() + def test_read_project_config_auto_resume_must_be_bool(self, tmp_path): + """Should raise error if session.auto_resume is not a boolean.""" + from scc_cli.config import read_project_config + + scc_yaml = tmp_path / ".scc.yaml" + scc_yaml.write_text(""" +session: + auto_resume: "yes" +""") + + with pytest.raises(ValueError) as exc_info: + read_project_config(tmp_path) + + assert "auto_resume" in str(exc_info.value) + def test_read_project_config_session_must_be_dict(self, tmp_path): """Should raise error if session is not a dict.""" from scc_cli.config import read_project_config diff --git a/tests/test_config_validate.py b/tests/test_config_validate.py new file mode 100644 index 0000000..0f9b628 --- /dev/null +++ b/tests/test_config_validate.py @@ -0,0 +1,100 @@ +"""Tests for scc config validate command.""" + +from __future__ import annotations + +import json + +from typer.testing import CliRunner + +from scc_cli import cli + +runner = CliRunner() + + +def _org_config(*, allowed_plugins: list[str] | None = None) -> dict[str, object]: + return { + "schema_version": "1.0.0", + "organization": {"name": "Test Org", "id": "test-org"}, + "defaults": {"allowed_plugins": allowed_plugins}, + "delegation": {"projects": {"inherit_team_delegation": True}}, + "profiles": {"backend": {"delegation": {"allow_project_overrides": True}}}, + } + + +def test_config_validate_success(tmp_path, monkeypatch): + scc_yaml = tmp_path / ".scc.yaml" + scc_yaml.write_text( + """ +additional_plugins: + - "project-tool@internal" +session: + timeout_hours: 4 +""" + ) + + org_config = _org_config(allowed_plugins=["project-*"]) + + monkeypatch.setattr( + "scc_cli.commands.config.config.load_cached_org_config", + lambda: org_config, + ) + + result = runner.invoke( + cli.app, + ["config", "validate", "--workspace", str(tmp_path), "--team", "backend"], + ) + + assert result.exit_code == 0 + assert "Project Config Valid" in result.output + + +def test_config_validate_denied_plugin_returns_governance_exit(tmp_path, monkeypatch): + scc_yaml = tmp_path / ".scc.yaml" + scc_yaml.write_text( + """ +additional_plugins: + - "blocked-tool@internal" +""" + ) + + org_config = _org_config(allowed_plugins=["safe-*"]) + + monkeypatch.setattr( + "scc_cli.commands.config.config.load_cached_org_config", + lambda: org_config, + ) + + result = runner.invoke( + cli.app, + [ + "config", + "validate", + "--workspace", + str(tmp_path), + "--team", + "backend", + "--json", + ], + ) + + assert result.exit_code == 6 + payload = json.loads(result.output) + assert payload["kind"] == "ConfigValidate" + assert payload["status"]["ok"] is False + assert payload["status"]["errors"] + + +def test_config_validate_missing_config(tmp_path, monkeypatch): + org_config = _org_config(allowed_plugins=["*"]) + monkeypatch.setattr( + "scc_cli.commands.config.config.load_cached_org_config", + lambda: org_config, + ) + + result = runner.invoke( + cli.app, + ["config", "validate", "--workspace", str(tmp_path), "--team", "backend"], + ) + + assert result.exit_code == 3 + assert "Project Config Invalid" in result.output diff --git a/tests/test_org_cli.py b/tests/test_org_cli.py index cbc5874..c572117 100644 --- a/tests/test_org_cli.py +++ b/tests/test_org_cli.py @@ -154,6 +154,34 @@ def test_validate_json_output_invalid(self, tmp_path: Path, capsys) -> None: assert output["status"]["ok"] is False assert len(output["status"]["errors"]) > 0 + def test_validate_remote_config(self, capsys) -> None: + """validate should support HTTPS sources.""" + from scc_cli.commands.org import org_validate_cmd + + class DummyResponse: + status_code = 200 + + def json(self): + return { + "schema_version": "1.0.0", + "organization": {"name": "Test Org", "id": "test-org"}, + } + + with patch("requests.get", return_value=DummyResponse()): + try: + org_validate_cmd( + source="https://example.com/org.json", + json_output=True, + pretty=False, + ) + except click.exceptions.Exit: + pass + + captured = capsys.readouterr() + output = json.loads(captured.out) + assert output["kind"] == "OrgValidation" + assert output["status"]["ok"] is True + def test_validate_nonexistent_file(self) -> None: """validate should fail for nonexistent file.""" from scc_cli.commands.org import org_validate_cmd diff --git a/tests/test_start_dryrun.py b/tests/test_start_dryrun.py index 5635206..3cf204c 100644 --- a/tests/test_start_dryrun.py +++ b/tests/test_start_dryrun.py @@ -280,6 +280,24 @@ def test_build_dry_run_data_with_plugins(self, tmp_path): plugin_names = [p["name"] for p in result["plugins"]] assert "github-copilot" in plugin_names + def test_build_dry_run_data_includes_network_policy(self, tmp_path): + """build_dry_run_data should include network policy when available.""" + from scc_cli.commands.launch import build_dry_run_data + + mock_org = { + "defaults": {"network_policy": "isolated"}, + "profiles": {"platform": {"description": "Platform team"}}, + } + + result = build_dry_run_data( + workspace_path=tmp_path, + team="platform", + org_config=mock_org, + project_config=None, + ) + + assert result["network_policy"] == "isolated" + def test_build_dry_run_data_ready_to_start(self, tmp_path): """build_dry_run_data should indicate ready state when no blockers.""" from scc_cli.commands.launch import build_dry_run_data diff --git a/tests/test_team_cli.py b/tests/test_team_cli.py index 35a97d2..dd58b30 100644 --- a/tests/test_team_cli.py +++ b/tests/test_team_cli.py @@ -5,6 +5,7 @@ """ import json +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -568,3 +569,73 @@ def test_json_status_has_ok_errors_warnings(self, mock_config, mock_org_config): assert "warnings" in data["status"] assert isinstance(data["status"]["errors"], list) assert isinstance(data["status"]["warnings"], list) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Team Validate Command Tests +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestTeamValidate: + """Tests for 'scc team validate' command.""" + + def test_team_validate_defaults_to_current_team(self, mock_config): + """team validate should use current team when none provided.""" + dummy_effective = SimpleNamespace( + has_security_violations=False, + is_federated=False, + plugin_count=0, + blocked_plugins=[], + disabled_plugins=[], + not_allowed_plugins=[], + enabled_plugins=[], + extra_marketplaces=[], + used_cached_config=False, + cache_is_stale=False, + staleness_warning=None, + source_description=None, + config_commit_sha=None, + config_etag=None, + ) + + def fake_resolve(_org_config, team_name): + assert team_name == "platform" + return dummy_effective + + with ( + patch("scc_cli.commands.team.config.load_user_config", return_value=mock_config), + patch( + "scc_cli.commands.team.config.load_cached_org_config", + return_value={"profiles": {"platform": {}}}, + ), + patch("scc_cli.commands.team.normalize_org_config_data", return_value={}), + patch("scc_cli.commands.team.OrganizationConfig.model_validate", return_value=object()), + patch("scc_cli.commands.team.resolve_effective_config", side_effect=fake_resolve), + ): + result = runner.invoke(app, ["team", "validate", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["data"]["team"] == "platform" + assert data["data"]["valid"] is True + + def test_team_validate_file_flag(self, tmp_path): + """team validate --file should validate team config files.""" + config_path = tmp_path / "team-config.json" + config_path.write_text(json.dumps({"schema_version": "1.0.0"})) + + result = runner.invoke(app, ["team", "validate", "--file", str(config_path), "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["data"]["mode"] == "file" + assert data["data"]["valid"] is True + + def test_team_validate_file_arg_detection(self, tmp_path): + """team validate should treat JSON paths as file inputs.""" + config_path = tmp_path / "team-config.json" + config_path.write_text(json.dumps({"schema_version": "1.0.0"})) + + result = runner.invoke(app, ["team", "validate", str(config_path), "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["data"]["mode"] == "file" + assert data["data"]["valid"] is True diff --git a/uv.lock b/uv.lock index 4383083..273c77e 100644 --- a/uv.lock +++ b/uv.lock @@ -951,7 +951,7 @@ wheels = [ [[package]] name = "scc-cli" -version = "1.7.0" +version = "1.7.1" source = { editable = "." } dependencies = [ { name = "jsonschema" },