scc-cli 1.4.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.

Potentially problematic release.


This version of scc-cli might be problematic. Click here for more details.

Files changed (112) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +259 -0
  8. scc_cli/cli_admin.py +683 -0
  9. scc_cli/cli_audit.py +245 -0
  10. scc_cli/cli_common.py +166 -0
  11. scc_cli/cli_config.py +527 -0
  12. scc_cli/cli_exceptions.py +705 -0
  13. scc_cli/cli_helpers.py +244 -0
  14. scc_cli/cli_init.py +272 -0
  15. scc_cli/cli_launch.py +1400 -0
  16. scc_cli/cli_org.py +1433 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +858 -0
  19. scc_cli/cli_worktree.py +865 -0
  20. scc_cli/config.py +583 -0
  21. scc_cli/console.py +562 -0
  22. scc_cli/constants.py +79 -0
  23. scc_cli/contexts.py +377 -0
  24. scc_cli/deprecation.py +54 -0
  25. scc_cli/deps.py +189 -0
  26. scc_cli/docker/__init__.py +127 -0
  27. scc_cli/docker/core.py +466 -0
  28. scc_cli/docker/credentials.py +726 -0
  29. scc_cli/docker/launch.py +603 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1082 -0
  32. scc_cli/doctor/render.py +346 -0
  33. scc_cli/doctor/types.py +66 -0
  34. scc_cli/errors.py +288 -0
  35. scc_cli/evaluation/__init__.py +27 -0
  36. scc_cli/evaluation/apply_exceptions.py +207 -0
  37. scc_cli/evaluation/evaluate.py +97 -0
  38. scc_cli/evaluation/models.py +80 -0
  39. scc_cli/exit_codes.py +55 -0
  40. scc_cli/git.py +1405 -0
  41. scc_cli/json_command.py +166 -0
  42. scc_cli/json_output.py +96 -0
  43. scc_cli/kinds.py +62 -0
  44. scc_cli/marketplace/__init__.py +123 -0
  45. scc_cli/marketplace/compute.py +377 -0
  46. scc_cli/marketplace/constants.py +87 -0
  47. scc_cli/marketplace/managed.py +135 -0
  48. scc_cli/marketplace/materialize.py +723 -0
  49. scc_cli/marketplace/normalize.py +548 -0
  50. scc_cli/marketplace/render.py +238 -0
  51. scc_cli/marketplace/resolve.py +459 -0
  52. scc_cli/marketplace/schema.py +502 -0
  53. scc_cli/marketplace/sync.py +257 -0
  54. scc_cli/marketplace/team_cache.py +195 -0
  55. scc_cli/marketplace/team_fetch.py +688 -0
  56. scc_cli/marketplace/trust.py +244 -0
  57. scc_cli/models/__init__.py +41 -0
  58. scc_cli/models/exceptions.py +273 -0
  59. scc_cli/models/plugin_audit.py +434 -0
  60. scc_cli/org_templates.py +269 -0
  61. scc_cli/output_mode.py +167 -0
  62. scc_cli/panels.py +113 -0
  63. scc_cli/platform.py +350 -0
  64. scc_cli/profiles.py +1034 -0
  65. scc_cli/remote.py +443 -0
  66. scc_cli/schemas/__init__.py +1 -0
  67. scc_cli/schemas/org-v1.schema.json +456 -0
  68. scc_cli/schemas/team-config.v1.schema.json +163 -0
  69. scc_cli/sessions.py +425 -0
  70. scc_cli/setup.py +582 -0
  71. scc_cli/source_resolver.py +470 -0
  72. scc_cli/stats.py +378 -0
  73. scc_cli/stores/__init__.py +13 -0
  74. scc_cli/stores/exception_store.py +251 -0
  75. scc_cli/subprocess_utils.py +88 -0
  76. scc_cli/teams.py +339 -0
  77. scc_cli/templates/__init__.py +2 -0
  78. scc_cli/templates/org/__init__.py +0 -0
  79. scc_cli/templates/org/minimal.json +19 -0
  80. scc_cli/templates/org/reference.json +74 -0
  81. scc_cli/templates/org/strict.json +38 -0
  82. scc_cli/templates/org/teams.json +42 -0
  83. scc_cli/templates/statusline.sh +75 -0
  84. scc_cli/theme.py +348 -0
  85. scc_cli/ui/__init__.py +124 -0
  86. scc_cli/ui/branding.py +68 -0
  87. scc_cli/ui/chrome.py +395 -0
  88. scc_cli/ui/dashboard/__init__.py +62 -0
  89. scc_cli/ui/dashboard/_dashboard.py +669 -0
  90. scc_cli/ui/dashboard/loaders.py +369 -0
  91. scc_cli/ui/dashboard/models.py +184 -0
  92. scc_cli/ui/dashboard/orchestrator.py +337 -0
  93. scc_cli/ui/formatters.py +443 -0
  94. scc_cli/ui/gate.py +350 -0
  95. scc_cli/ui/help.py +157 -0
  96. scc_cli/ui/keys.py +521 -0
  97. scc_cli/ui/list_screen.py +431 -0
  98. scc_cli/ui/picker.py +700 -0
  99. scc_cli/ui/prompts.py +200 -0
  100. scc_cli/ui/wizard.py +490 -0
  101. scc_cli/update.py +680 -0
  102. scc_cli/utils/__init__.py +39 -0
  103. scc_cli/utils/fixit.py +264 -0
  104. scc_cli/utils/fuzzy.py +124 -0
  105. scc_cli/utils/locks.py +101 -0
  106. scc_cli/utils/ttl.py +376 -0
  107. scc_cli/validate.py +455 -0
  108. scc_cli-1.4.0.dist-info/METADATA +369 -0
  109. scc_cli-1.4.0.dist-info/RECORD +112 -0
  110. scc_cli-1.4.0.dist-info/WHEEL +4 -0
  111. scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
  112. scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,257 @@
1
+ """
2
+ Marketplace sync orchestration for Claude Code integration.
3
+
4
+ This module provides the high-level sync_marketplace_settings() function that
5
+ orchestrates the full pipeline:
6
+ 1. Parse org config
7
+ 2. Compute effective plugins for team
8
+ 3. Materialize required marketplaces
9
+ 4. Render settings to Claude format
10
+ 5. Merge with existing user settings (non-destructive)
11
+ 6. Save managed state tracking
12
+ 7. Write settings.local.json
13
+
14
+ This is the main entry point for integrating marketplace functionality
15
+ into the start command.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from scc_cli.marketplace.managed import ManagedState, save_managed_state
26
+ from scc_cli.marketplace.materialize import MaterializationError, materialize_marketplace
27
+ from scc_cli.marketplace.normalize import matches_pattern
28
+ from scc_cli.marketplace.render import check_conflicts, merge_settings, render_settings
29
+ from scc_cli.marketplace.resolve import resolve_effective_config
30
+ from scc_cli.marketplace.schema import (
31
+ MarketplaceSource,
32
+ OrganizationConfig,
33
+ )
34
+
35
+
36
+ class SyncError(Exception):
37
+ """Error during marketplace sync operation."""
38
+
39
+ def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
40
+ self.details = details or {}
41
+ super().__init__(message)
42
+
43
+
44
+ class SyncResult:
45
+ """Result of a marketplace sync operation."""
46
+
47
+ def __init__(
48
+ self,
49
+ success: bool,
50
+ plugins_enabled: list[str] | None = None,
51
+ marketplaces_materialized: list[str] | None = None,
52
+ warnings: list[str] | None = None,
53
+ settings_path: Path | None = None,
54
+ ) -> None:
55
+ self.success = success
56
+ self.plugins_enabled = plugins_enabled or []
57
+ self.marketplaces_materialized = marketplaces_materialized or []
58
+ self.warnings = warnings or []
59
+ self.settings_path = settings_path
60
+
61
+
62
+ def sync_marketplace_settings(
63
+ project_dir: Path,
64
+ org_config_data: dict[str, Any],
65
+ team_id: str | None = None,
66
+ org_config_url: str | None = None,
67
+ force_refresh: bool = False,
68
+ dry_run: bool = False,
69
+ ) -> SyncResult:
70
+ """Sync marketplace settings for a project.
71
+
72
+ Orchestrates the full pipeline:
73
+ 1. Parse and validate org config
74
+ 2. Compute effective plugins for team
75
+ 3. Materialize required marketplaces
76
+ 4. Render settings to Claude format
77
+ 5. Merge with existing user settings (non-destructive)
78
+ 6. Save managed state tracking
79
+ 7. Write settings.local.json (unless dry_run)
80
+
81
+ Args:
82
+ project_dir: Project root directory
83
+ org_config_data: Parsed org config dictionary
84
+ team_id: Team profile ID (uses defaults if None)
85
+ org_config_url: URL where org config was fetched (for tracking)
86
+ force_refresh: Force re-materialization of marketplaces
87
+ dry_run: If True, compute but don't write files
88
+
89
+ Returns:
90
+ SyncResult with success status and details
91
+
92
+ Raises:
93
+ SyncError: On validation or processing errors
94
+ TeamNotFoundError: If team_id not found in config
95
+ """
96
+ warnings: list[str] = []
97
+
98
+ # ── Step 1: Parse org config ─────────────────────────────────────────────
99
+ try:
100
+ org_config = OrganizationConfig.model_validate(org_config_data)
101
+ except Exception as e:
102
+ raise SyncError(f"Invalid org config: {e}") from e
103
+
104
+ # ── Step 2: Resolve effective config (federation-aware) ────────────────────
105
+ if team_id is None:
106
+ raise SyncError("team_id is required for marketplace sync")
107
+
108
+ # Use resolve_effective_config for federation support (T2a-24)
109
+ # This handles both inline and federated teams uniformly
110
+ effective_config = resolve_effective_config(org_config, team_id=team_id)
111
+
112
+ # Convert to Phase 1 format for backward compatibility
113
+ effective, effective_marketplaces = effective_config.to_phase1_format()
114
+
115
+ # Check for blocked plugins that user has installed
116
+ # First, check if org-enabled plugins were blocked
117
+ if effective.blocked:
118
+ existing = _load_existing_plugins(project_dir)
119
+ conflict_warnings = check_conflicts(
120
+ existing_plugins=existing,
121
+ blocked_plugins=[
122
+ {"plugin_id": b.plugin_id, "reason": b.reason, "pattern": b.pattern}
123
+ for b in effective.blocked
124
+ ],
125
+ )
126
+ warnings.extend(conflict_warnings)
127
+
128
+ # Also check user's existing plugins against security.blocked_plugins patterns
129
+ security = org_config.security
130
+ if security and security.blocked_plugins:
131
+ existing = _load_existing_plugins(project_dir)
132
+ blocked_reason = security.blocked_reason or "Blocked by organization policy"
133
+ for plugin in existing:
134
+ for pattern in security.blocked_plugins:
135
+ if matches_pattern(plugin, pattern):
136
+ warnings.append(
137
+ f"⚠️ Plugin '{plugin}' is blocked by team policy: {blocked_reason} "
138
+ f"(matched pattern: {pattern})"
139
+ )
140
+ break # Only one warning per plugin
141
+
142
+ # ── Step 3: Materialize required marketplaces ────────────────────────────
143
+ materialized: dict[str, Any] = {}
144
+ marketplaces_used = set()
145
+
146
+ # Determine which marketplaces are needed
147
+ for plugin_ref in effective.enabled:
148
+ if "@" in plugin_ref:
149
+ marketplace_name = plugin_ref.split("@")[1]
150
+ marketplaces_used.add(marketplace_name)
151
+
152
+ # Also include any extra marketplaces from the effective result
153
+ for marketplace_name in effective.extra_marketplaces:
154
+ marketplaces_used.add(marketplace_name)
155
+
156
+ # Materialize each marketplace
157
+ for marketplace_name in marketplaces_used:
158
+ # Skip implicit marketplaces (claude-plugins-official)
159
+ from scc_cli.marketplace.constants import IMPLICIT_MARKETPLACES
160
+
161
+ if marketplace_name in IMPLICIT_MARKETPLACES:
162
+ continue
163
+
164
+ # Find source configuration from effective marketplaces (includes team sources for federated)
165
+ # This is the key change for T2a-24: effective_marketplaces comes from resolve_effective_config
166
+ source = effective_marketplaces.get(marketplace_name)
167
+ if source is None:
168
+ # Fallback to org config lookup for backwards compatibility
169
+ source = _find_marketplace_source(org_config, marketplace_name)
170
+ if source is None:
171
+ warnings.append(f"Marketplace '{marketplace_name}' not found in org config")
172
+ continue
173
+
174
+ try:
175
+ result = materialize_marketplace(
176
+ name=marketplace_name,
177
+ source=source,
178
+ project_dir=project_dir,
179
+ force_refresh=force_refresh,
180
+ )
181
+ materialized[marketplace_name] = {
182
+ "relative_path": result.relative_path,
183
+ "source_type": result.source_type,
184
+ }
185
+ except MaterializationError as e:
186
+ warnings.append(f"Failed to materialize '{marketplace_name}': {e}")
187
+
188
+ # ── Step 4: Render settings ──────────────────────────────────────────────
189
+ effective_dict = {
190
+ "enabled": effective.enabled,
191
+ "extra_marketplaces": effective.extra_marketplaces,
192
+ }
193
+ rendered = render_settings(effective_dict, materialized)
194
+
195
+ # ── Step 5: Merge with existing settings ─────────────────────────────────
196
+ merged = merge_settings(project_dir, rendered)
197
+
198
+ # ── Step 6: Prepare managed state ────────────────────────────────────────
199
+ managed_state = ManagedState(
200
+ managed_plugins=list(effective.enabled),
201
+ managed_marketplaces=[m.get("relative_path", "") for m in materialized.values()],
202
+ last_sync=datetime.now(timezone.utc),
203
+ org_config_url=org_config_url,
204
+ team_id=team_id,
205
+ )
206
+
207
+ # ── Step 7: Write files (unless dry_run) ─────────────────────────────────
208
+ settings_path = project_dir / ".claude" / "settings.local.json"
209
+
210
+ if not dry_run:
211
+ # Ensure .claude directory exists
212
+ claude_dir = project_dir / ".claude"
213
+ claude_dir.mkdir(parents=True, exist_ok=True)
214
+
215
+ # Write settings
216
+ settings_path.write_text(json.dumps(merged, indent=2))
217
+
218
+ # Save managed state
219
+ save_managed_state(project_dir, managed_state)
220
+
221
+ return SyncResult(
222
+ success=True,
223
+ plugins_enabled=list(effective.enabled),
224
+ marketplaces_materialized=list(materialized.keys()),
225
+ warnings=warnings,
226
+ settings_path=settings_path if not dry_run else None,
227
+ )
228
+
229
+
230
+ def _load_existing_plugins(project_dir: Path) -> list[str]:
231
+ """Load existing plugins from settings.local.json."""
232
+ settings_path = project_dir / ".claude" / "settings.local.json"
233
+ if not settings_path.exists():
234
+ return []
235
+
236
+ try:
237
+ data: dict[str, Any] = json.loads(settings_path.read_text())
238
+ plugins = data.get("enabledPlugins", [])
239
+ if isinstance(plugins, list):
240
+ return [str(p) for p in plugins]
241
+ return []
242
+ except (json.JSONDecodeError, OSError):
243
+ return []
244
+
245
+
246
+ def _find_marketplace_source(
247
+ org_config: OrganizationConfig, marketplace_name: str
248
+ ) -> MarketplaceSource | None:
249
+ """Find marketplace source configuration by name."""
250
+ if org_config.marketplaces is None:
251
+ return None
252
+
253
+ for name, source in org_config.marketplaces.items():
254
+ if name == marketplace_name:
255
+ return source
256
+
257
+ return None
@@ -0,0 +1,195 @@
1
+ """Team config caching for federated configurations (Phase 2).
2
+
3
+ This module provides:
4
+ - TeamCacheMeta: Metadata about cached team configurations
5
+ - DEFAULT_TTL: How long cached configs are considered fresh (24h)
6
+ - MAX_STALE_AGE: Maximum age for fallback to stale cache (7d)
7
+ - Cache path utilities for team config storage
8
+
9
+ Cache Design:
10
+ Team configs are cached under ~/.cache/scc/team_configs/
11
+ Each team has two files:
12
+ - {team_name}.json: The actual team config
13
+ - {team_name}.meta.json: Metadata (source, timestamps, SHA)
14
+
15
+ Freshness Model:
16
+ - Fresh (age < DEFAULT_TTL): Use cached config directly
17
+ - Stale (TTL < age < MAX_STALE_AGE): Try refresh, fallback to cache
18
+ - Expired (age > MAX_STALE_AGE): Must fetch, no fallback allowed
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from datetime import datetime, timedelta, timezone
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ # ─────────────────────────────────────────────────────────────────────────────
29
+ # Constants
30
+ # ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ # How long cached team configs are considered fresh
33
+ DEFAULT_TTL: timedelta = timedelta(hours=24)
34
+
35
+ # Maximum age for fallback to stale cache when fetch fails
36
+ MAX_STALE_AGE: timedelta = timedelta(days=7)
37
+
38
+
39
+ # ─────────────────────────────────────────────────────────────────────────────
40
+ # Cache Metadata
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+
43
+
44
+ @dataclass
45
+ class TeamCacheMeta:
46
+ """Metadata about a cached team configuration.
47
+
48
+ Tracks when and how a team config was fetched, enabling:
49
+ - Cache freshness decisions (is_fresh)
50
+ - Fallback policy (is_within_max_stale_age)
51
+ - Conditional fetching with ETags/commit SHAs
52
+
53
+ Attributes:
54
+ team_name: Team identifier
55
+ source_type: How config was fetched (github, git, url)
56
+ source_url: Where config was fetched from
57
+ fetched_at: When config was last fetched
58
+ commit_sha: Git commit SHA (for git/github sources)
59
+ etag: HTTP ETag (for URL sources)
60
+ branch: Git branch (for git/github sources)
61
+ """
62
+
63
+ team_name: str
64
+ source_type: str
65
+ source_url: str
66
+ fetched_at: datetime
67
+ commit_sha: str | None = None
68
+ etag: str | None = None
69
+ branch: str | None = None
70
+
71
+ @property
72
+ def age(self) -> timedelta:
73
+ """Calculate how old this cached config is.
74
+
75
+ Returns:
76
+ Time elapsed since fetched_at
77
+ """
78
+ now = datetime.now(timezone.utc)
79
+ # Handle timezone-naive datetimes
80
+ fetched = self.fetched_at
81
+ if fetched.tzinfo is None:
82
+ fetched = fetched.replace(tzinfo=timezone.utc)
83
+ return now - fetched
84
+
85
+ def is_fresh(self, ttl: timedelta = DEFAULT_TTL) -> bool:
86
+ """Check if cached config is still fresh.
87
+
88
+ Args:
89
+ ttl: Time-to-live threshold
90
+
91
+ Returns:
92
+ True if age < ttl, meaning cache can be used without refresh
93
+ """
94
+ return self.age < ttl
95
+
96
+ def is_within_max_stale_age(self, max_age: timedelta = MAX_STALE_AGE) -> bool:
97
+ """Check if stale cache can be used as fallback.
98
+
99
+ Args:
100
+ max_age: Maximum acceptable age for fallback
101
+
102
+ Returns:
103
+ True if age < max_age, meaning cache can be used when fetch fails
104
+ """
105
+ return self.age < max_age
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ """Serialize to dictionary for JSON storage.
109
+
110
+ Returns:
111
+ Dict with all fields, datetime as ISO format string
112
+ """
113
+ return {
114
+ "team_name": self.team_name,
115
+ "source_type": self.source_type,
116
+ "source_url": self.source_url,
117
+ "fetched_at": self.fetched_at.isoformat(),
118
+ "commit_sha": self.commit_sha,
119
+ "etag": self.etag,
120
+ "branch": self.branch,
121
+ }
122
+
123
+ @classmethod
124
+ def from_dict(cls, data: dict[str, Any]) -> TeamCacheMeta:
125
+ """Deserialize from dictionary loaded from JSON.
126
+
127
+ Args:
128
+ data: Dict with serialized cache metadata
129
+
130
+ Returns:
131
+ Restored TeamCacheMeta instance
132
+ """
133
+ fetched_at = data.get("fetched_at")
134
+ if isinstance(fetched_at, str):
135
+ fetched_at = datetime.fromisoformat(fetched_at)
136
+ else:
137
+ fetched_at = datetime.now(timezone.utc)
138
+
139
+ return cls(
140
+ team_name=data["team_name"],
141
+ source_type=data["source_type"],
142
+ source_url=data["source_url"],
143
+ fetched_at=fetched_at,
144
+ commit_sha=data.get("commit_sha"),
145
+ etag=data.get("etag"),
146
+ branch=data.get("branch"),
147
+ )
148
+
149
+
150
+ # ─────────────────────────────────────────────────────────────────────────────
151
+ # Cache Paths
152
+ # ─────────────────────────────────────────────────────────────────────────────
153
+
154
+
155
+ def get_team_cache_dir(cache_root: Path | None = None) -> Path:
156
+ """Get the directory for team config caches.
157
+
158
+ Args:
159
+ cache_root: Base cache directory (defaults to XDG cache path)
160
+
161
+ Returns:
162
+ Path to team_configs subdirectory
163
+ """
164
+ if cache_root is None:
165
+ from scc_cli.config import get_cache_dir
166
+
167
+ cache_root = get_cache_dir()
168
+
169
+ return cache_root / "team_configs"
170
+
171
+
172
+ def get_team_config_cache_path(team_name: str, cache_root: Path | None = None) -> Path:
173
+ """Get the cache file path for a team's config.
174
+
175
+ Args:
176
+ team_name: Team identifier
177
+ cache_root: Base cache directory
178
+
179
+ Returns:
180
+ Path to {team_name}.json file
181
+ """
182
+ return get_team_cache_dir(cache_root) / f"{team_name}.json"
183
+
184
+
185
+ def get_team_meta_cache_path(team_name: str, cache_root: Path | None = None) -> Path:
186
+ """Get the cache metadata file path for a team.
187
+
188
+ Args:
189
+ team_name: Team identifier
190
+ cache_root: Base cache directory
191
+
192
+ Returns:
193
+ Path to {team_name}.meta.json file
194
+ """
195
+ return get_team_cache_dir(cache_root) / f"{team_name}.meta.json"