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.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +683 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1400 -0
- scc_cli/cli_org.py +1433 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +858 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +603 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1082 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1405 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +238 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +502 -0
- scc_cli/marketplace/sync.py +257 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +1034 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +582 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +339 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +669 -0
- scc_cli/ui/dashboard/loaders.py +369 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +337 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +521 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +490 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.0.dist-info/METADATA +369 -0
- scc_cli-1.4.0.dist-info/RECORD +112 -0
- scc_cli-1.4.0.dist-info/WHEEL +4 -0
- scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
scc_cli/config.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management.
|
|
3
|
+
|
|
4
|
+
Handle LOCAL user configuration only.
|
|
5
|
+
Organization config is fetched remotely (see remote.py).
|
|
6
|
+
|
|
7
|
+
Config structure:
|
|
8
|
+
- ~/.config/scc/config.json - User preferences and org source URL
|
|
9
|
+
- ~/.cache/scc/ - Cache directory (regenerable)
|
|
10
|
+
|
|
11
|
+
Migrate from ~/.config/scc-cli/ to ~/.config/scc/ automatically when needed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, cast
|
|
21
|
+
|
|
22
|
+
import yaml # type: ignore[import-untyped]
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
26
|
+
# XDG Base Directory Paths
|
|
27
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
28
|
+
|
|
29
|
+
# New config directory (XDG compliant)
|
|
30
|
+
CONFIG_DIR = Path.home() / ".config" / "scc"
|
|
31
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
32
|
+
SESSIONS_FILE = CONFIG_DIR / "sessions.json"
|
|
33
|
+
|
|
34
|
+
# Cache directory (regenerable, safe to delete)
|
|
35
|
+
CACHE_DIR = Path.home() / ".cache" / "scc"
|
|
36
|
+
|
|
37
|
+
# Legacy config directory (for migration)
|
|
38
|
+
LEGACY_CONFIG_DIR = Path.home() / ".config" / "scc-cli"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
42
|
+
# User Config Defaults
|
|
43
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
44
|
+
|
|
45
|
+
USER_CONFIG_DEFAULTS = {
|
|
46
|
+
"config_version": "1.0.0",
|
|
47
|
+
"organization_source": None, # Set during setup: {"url": "...", "auth": "..."}
|
|
48
|
+
"selected_profile": None,
|
|
49
|
+
"standalone": False,
|
|
50
|
+
"workspace_team_map": {},
|
|
51
|
+
"cache": {
|
|
52
|
+
"enabled": True,
|
|
53
|
+
"ttl_hours": 24,
|
|
54
|
+
},
|
|
55
|
+
"hooks": {
|
|
56
|
+
"enabled": False,
|
|
57
|
+
},
|
|
58
|
+
"overrides": {
|
|
59
|
+
"workspace_base": "~/projects",
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
65
|
+
# Path Helpers
|
|
66
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_config_dir() -> Path:
|
|
70
|
+
"""Get the configuration directory."""
|
|
71
|
+
return CONFIG_DIR
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_config_file() -> Path:
|
|
75
|
+
"""Get the configuration file path."""
|
|
76
|
+
return CONFIG_FILE
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_cache_dir() -> Path:
|
|
80
|
+
"""Get the cache directory path."""
|
|
81
|
+
return CACHE_DIR
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
85
|
+
# Migration from scc-cli to scc
|
|
86
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def migrate_config_if_needed() -> bool:
|
|
90
|
+
"""Migrate from legacy scc-cli directory to scc.
|
|
91
|
+
|
|
92
|
+
Uses atomic swap pattern for safety:
|
|
93
|
+
1. Create new structure in temp location
|
|
94
|
+
2. Copy & transform
|
|
95
|
+
3. Atomic rename (commit point)
|
|
96
|
+
4. Preserve old directory (don't delete)
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if migration was performed, False if already migrated or fresh install
|
|
100
|
+
"""
|
|
101
|
+
# Already migrated - new config exists
|
|
102
|
+
if CONFIG_DIR.exists():
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
# Fresh install - no legacy config
|
|
106
|
+
if not LEGACY_CONFIG_DIR.exists():
|
|
107
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
# Create temp directory for atomic operation
|
|
111
|
+
temp_dir = CONFIG_DIR.with_suffix(".tmp")
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
|
|
116
|
+
# Copy all files from old to temp
|
|
117
|
+
for item in LEGACY_CONFIG_DIR.iterdir():
|
|
118
|
+
if item.is_file():
|
|
119
|
+
shutil.copy2(item, temp_dir / item.name)
|
|
120
|
+
elif item.is_dir():
|
|
121
|
+
shutil.copytree(item, temp_dir / item.name)
|
|
122
|
+
|
|
123
|
+
# Atomic rename (commit point)
|
|
124
|
+
temp_dir.rename(CONFIG_DIR)
|
|
125
|
+
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
except Exception:
|
|
129
|
+
# Cleanup temp on failure, preserve old
|
|
130
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
131
|
+
raise
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
135
|
+
# Deep Merge Utility
|
|
136
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
140
|
+
"""
|
|
141
|
+
Deep merge override into base.
|
|
142
|
+
|
|
143
|
+
For nested dicts: recursive merge
|
|
144
|
+
For non-dicts: override replaces base
|
|
145
|
+
"""
|
|
146
|
+
for key, value in override.items():
|
|
147
|
+
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
|
148
|
+
deep_merge(base[key], value)
|
|
149
|
+
else:
|
|
150
|
+
base[key] = value
|
|
151
|
+
|
|
152
|
+
return base
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _deep_copy(d: dict[Any, Any]) -> dict[Any, Any]:
|
|
156
|
+
"""Create a deep copy of a dict (simple implementation for JSON-safe data)."""
|
|
157
|
+
return cast(dict[Any, Any], json.loads(json.dumps(d)))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
161
|
+
# User Configuration Loading/Saving
|
|
162
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def load_user_config() -> dict[str, Any]:
|
|
166
|
+
"""
|
|
167
|
+
Load user configuration from ~/.config/scc/config.json.
|
|
168
|
+
|
|
169
|
+
Returns merged config with defaults.
|
|
170
|
+
"""
|
|
171
|
+
# Start with defaults
|
|
172
|
+
config = _deep_copy(USER_CONFIG_DEFAULTS)
|
|
173
|
+
|
|
174
|
+
# Ensure config dir exists
|
|
175
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
|
|
177
|
+
# Load and merge user config if exists
|
|
178
|
+
if CONFIG_FILE.exists():
|
|
179
|
+
try:
|
|
180
|
+
with open(CONFIG_FILE) as f:
|
|
181
|
+
user_config = json.load(f)
|
|
182
|
+
deep_merge(config, user_config)
|
|
183
|
+
except (OSError, json.JSONDecodeError):
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
return config
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def save_user_config(config: dict[str, Any]) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Save user configuration to ~/.config/scc/config.json.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
config: Configuration dict to save
|
|
195
|
+
"""
|
|
196
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
|
|
198
|
+
with open(CONFIG_FILE, "w") as f:
|
|
199
|
+
json.dump(config, f, indent=2)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
203
|
+
# Profile Selection
|
|
204
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_selected_profile() -> str | None:
|
|
208
|
+
"""Get the currently selected profile name.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Profile name string or None if not selected
|
|
212
|
+
"""
|
|
213
|
+
config = load_user_config()
|
|
214
|
+
return config.get("selected_profile")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def set_selected_profile(profile: str) -> None:
|
|
218
|
+
"""Set the selected profile.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
profile: Profile name to select
|
|
222
|
+
"""
|
|
223
|
+
config = load_user_config()
|
|
224
|
+
config["selected_profile"] = profile
|
|
225
|
+
save_user_config(config)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
229
|
+
# Workspace Team Pinning
|
|
230
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _normalize_workspace_key(workspace: str | Path) -> str:
|
|
234
|
+
"""Normalize workspace path for stable config keys."""
|
|
235
|
+
path = Path(workspace).expanduser()
|
|
236
|
+
try:
|
|
237
|
+
return str(path.resolve(strict=False))
|
|
238
|
+
except OSError:
|
|
239
|
+
return str(path.absolute())
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_workspace_team_from_config(cfg: dict[str, Any], workspace: str | Path) -> str | None:
|
|
243
|
+
"""Get the pinned team for a workspace from a loaded config dict."""
|
|
244
|
+
mapping = cfg.get("workspace_team_map", {})
|
|
245
|
+
if not isinstance(mapping, dict):
|
|
246
|
+
return None
|
|
247
|
+
return mapping.get(_normalize_workspace_key(workspace))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def set_workspace_team(workspace: str | Path, team: str | None) -> None:
|
|
251
|
+
"""Persist the last-used team for a workspace.
|
|
252
|
+
|
|
253
|
+
If team is None, removes any existing mapping.
|
|
254
|
+
"""
|
|
255
|
+
cfg = load_user_config()
|
|
256
|
+
mapping = cfg.get("workspace_team_map")
|
|
257
|
+
if not isinstance(mapping, dict):
|
|
258
|
+
mapping = {}
|
|
259
|
+
cfg["workspace_team_map"] = mapping
|
|
260
|
+
|
|
261
|
+
key = _normalize_workspace_key(workspace)
|
|
262
|
+
if team:
|
|
263
|
+
mapping[key] = team
|
|
264
|
+
else:
|
|
265
|
+
mapping.pop(key, None)
|
|
266
|
+
|
|
267
|
+
save_user_config(cfg)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
271
|
+
# Standalone Mode
|
|
272
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def is_standalone_mode() -> bool:
|
|
276
|
+
"""Check if SCC is running in standalone mode (no organization).
|
|
277
|
+
|
|
278
|
+
Standalone mode means no organization config is active. This is the case when:
|
|
279
|
+
1. The `standalone` flag is explicitly set to True, OR
|
|
280
|
+
2. No organization_source URL is configured (fresh install, solo dev)
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
True if standalone mode is enabled (no org config)
|
|
284
|
+
"""
|
|
285
|
+
config = load_user_config()
|
|
286
|
+
|
|
287
|
+
# Explicit standalone flag takes priority
|
|
288
|
+
if config.get("standalone"):
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
# Not standalone if organization_source is configured
|
|
292
|
+
org_source = config.get("organization_source")
|
|
293
|
+
if org_source and org_source.get("url"):
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
# No org configured → default to standalone (solo dev / fresh install)
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
301
|
+
# Initialization
|
|
302
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def init_config(console: Console) -> None:
|
|
306
|
+
"""Initialize configuration directory and files."""
|
|
307
|
+
# Run migration if needed
|
|
308
|
+
migrated = migrate_config_if_needed()
|
|
309
|
+
if migrated:
|
|
310
|
+
console.print(f"[yellow]⚠️ Migrated config from {LEGACY_CONFIG_DIR} to {CONFIG_DIR}[/]")
|
|
311
|
+
console.print("[dim]Old directory preserved. You may delete it manually.[/]")
|
|
312
|
+
|
|
313
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
|
|
315
|
+
if not CONFIG_FILE.exists():
|
|
316
|
+
# Save minimal user config
|
|
317
|
+
save_user_config({"config_version": USER_CONFIG_DEFAULTS["config_version"]})
|
|
318
|
+
console.print(f"[green]✓ Created config file: {CONFIG_FILE}[/green]")
|
|
319
|
+
else:
|
|
320
|
+
console.print(f"[green]✓ Config file exists: {CONFIG_FILE}[/green]")
|
|
321
|
+
|
|
322
|
+
# Create sessions file
|
|
323
|
+
if not SESSIONS_FILE.exists():
|
|
324
|
+
with open(SESSIONS_FILE, "w") as f:
|
|
325
|
+
json.dump({"sessions": []}, f)
|
|
326
|
+
console.print(f"[green]✓ Created sessions file: {SESSIONS_FILE}[/green]")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def open_in_editor() -> None:
|
|
330
|
+
"""Open config file in default editor."""
|
|
331
|
+
editor = os.environ.get("EDITOR", "nano")
|
|
332
|
+
|
|
333
|
+
# Ensure config exists
|
|
334
|
+
if not CONFIG_FILE.exists():
|
|
335
|
+
save_user_config({"config_version": USER_CONFIG_DEFAULTS["config_version"]})
|
|
336
|
+
|
|
337
|
+
subprocess.run([editor, str(CONFIG_FILE)])
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
341
|
+
# Session Management
|
|
342
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def add_recent_workspace(workspace: str, team: str | None = None) -> None:
|
|
346
|
+
"""Add a workspace to recent list."""
|
|
347
|
+
try:
|
|
348
|
+
if SESSIONS_FILE.exists():
|
|
349
|
+
with open(SESSIONS_FILE) as f:
|
|
350
|
+
data = json.load(f)
|
|
351
|
+
else:
|
|
352
|
+
data = {"sessions": []}
|
|
353
|
+
|
|
354
|
+
# Remove existing entry for this workspace
|
|
355
|
+
data["sessions"] = [s for s in data["sessions"] if s.get("workspace") != workspace]
|
|
356
|
+
|
|
357
|
+
# Add new entry at the start
|
|
358
|
+
data["sessions"].insert(
|
|
359
|
+
0,
|
|
360
|
+
{
|
|
361
|
+
"workspace": workspace,
|
|
362
|
+
"team": team,
|
|
363
|
+
"last_used": datetime.now().isoformat(),
|
|
364
|
+
"name": Path(workspace).name,
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Keep only last 20
|
|
369
|
+
data["sessions"] = data["sessions"][:20]
|
|
370
|
+
|
|
371
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
with open(SESSIONS_FILE, "w") as f:
|
|
373
|
+
json.dump(data, f, indent=2)
|
|
374
|
+
|
|
375
|
+
except (OSError, json.JSONDecodeError):
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def get_recent_workspaces(limit: int = 10) -> list[Any]:
|
|
380
|
+
"""Get recent workspaces."""
|
|
381
|
+
try:
|
|
382
|
+
if SESSIONS_FILE.exists():
|
|
383
|
+
with open(SESSIONS_FILE) as f:
|
|
384
|
+
data = json.load(f)
|
|
385
|
+
return cast(list[Any], data.get("sessions", [])[:limit])
|
|
386
|
+
except (OSError, json.JSONDecodeError):
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
return []
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
393
|
+
# Backward Compatibility Aliases
|
|
394
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
395
|
+
|
|
396
|
+
# These are kept for backward compatibility with existing code
|
|
397
|
+
# that imports from config module
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def load_config() -> dict[str, Any]:
|
|
401
|
+
"""Alias for load_user_config (backward compatibility)."""
|
|
402
|
+
return load_user_config()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
406
|
+
"""Alias for save_user_config (backward compatibility)."""
|
|
407
|
+
save_user_config(config)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def get_team_config(team: str) -> dict[str, Any] | None:
|
|
411
|
+
"""Get configuration for a specific team (stub for compatibility).
|
|
412
|
+
|
|
413
|
+
Note: Team config now comes from remote org config, not local config.
|
|
414
|
+
This function is kept for backward compatibility but returns None.
|
|
415
|
+
Use profiles.py for team/profile resolution.
|
|
416
|
+
"""
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def list_available_teams() -> list[str]:
|
|
421
|
+
"""List available team profile names (stub for compatibility).
|
|
422
|
+
|
|
423
|
+
Note: Teams now come from remote org config, not local config.
|
|
424
|
+
This function is kept for backward compatibility but returns empty list.
|
|
425
|
+
Use profiles.py for team/profile listing.
|
|
426
|
+
"""
|
|
427
|
+
return []
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
431
|
+
# Legacy aliases (deprecated - will be removed in future versions)
|
|
432
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
433
|
+
|
|
434
|
+
# These constants are kept for backward compatibility only
|
|
435
|
+
INTERNAL_DEFAULTS = USER_CONFIG_DEFAULTS
|
|
436
|
+
DEFAULT_CONFIG = USER_CONFIG_DEFAULTS.copy()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def load_org_config() -> dict[str, Any] | None:
|
|
440
|
+
"""Deprecated: Org config is now fetched remotely.
|
|
441
|
+
|
|
442
|
+
Use remote.load_org_config() instead.
|
|
443
|
+
"""
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def save_org_config(org_config: dict[str, Any]) -> None:
|
|
448
|
+
"""Deprecated: Org config is now remote.
|
|
449
|
+
|
|
450
|
+
This function is a no-op for backward compatibility.
|
|
451
|
+
"""
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def is_organization_configured() -> bool:
|
|
456
|
+
"""Check if an organization source is configured.
|
|
457
|
+
|
|
458
|
+
Returns True if organization_source URL is set.
|
|
459
|
+
"""
|
|
460
|
+
config = load_user_config()
|
|
461
|
+
org_source = config.get("organization_source")
|
|
462
|
+
return bool(org_source and org_source.get("url"))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def get_organization_name() -> str | None:
|
|
466
|
+
"""Get organization name (deprecated).
|
|
467
|
+
|
|
468
|
+
Note: Organization name now comes from remote org config.
|
|
469
|
+
Returns None - use remote.load_org_config() instead.
|
|
470
|
+
"""
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def load_cached_org_config() -> dict[Any, Any] | None:
|
|
475
|
+
"""Load cached organization config from ~/.cache/scc/org_config.json.
|
|
476
|
+
|
|
477
|
+
This is the NEW architecture function for loading org config.
|
|
478
|
+
The org config contains profiles and marketplaces defined by team admins.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Parsed org config dict, or None if cache doesn't exist or is invalid.
|
|
482
|
+
"""
|
|
483
|
+
cache_file = CACHE_DIR / "org_config.json"
|
|
484
|
+
|
|
485
|
+
if not cache_file.exists():
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
content = cache_file.read_text(encoding="utf-8")
|
|
490
|
+
return cast(dict[Any, Any], json.loads(content))
|
|
491
|
+
except (json.JSONDecodeError, OSError):
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def load_teams_config() -> dict[str, Any]:
|
|
496
|
+
"""Alias for load_user_config (backward compatibility)."""
|
|
497
|
+
return load_user_config()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
501
|
+
# Project Config Reader (.scc.yaml)
|
|
502
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
503
|
+
|
|
504
|
+
# Project config filename
|
|
505
|
+
PROJECT_CONFIG_FILE = ".scc.yaml"
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def read_project_config(workspace_path: str | Path) -> dict[str, Any] | None:
|
|
509
|
+
"""Read project configuration from .scc.yaml file.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
workspace_path: Path to the workspace/project directory (can be str or Path)
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
Parsed project config dict, or None if file doesn't exist or is empty
|
|
516
|
+
|
|
517
|
+
Raises:
|
|
518
|
+
ValueError: If YAML is malformed or config has invalid schema
|
|
519
|
+
"""
|
|
520
|
+
# Convert to Path if string
|
|
521
|
+
if isinstance(workspace_path, str):
|
|
522
|
+
workspace_path = Path(workspace_path)
|
|
523
|
+
|
|
524
|
+
config_file = workspace_path / PROJECT_CONFIG_FILE
|
|
525
|
+
|
|
526
|
+
# File doesn't exist - return None (valid case)
|
|
527
|
+
if not config_file.exists():
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
content = config_file.read_text(encoding="utf-8")
|
|
532
|
+
except OSError as e:
|
|
533
|
+
raise ValueError(f"Failed to read {PROJECT_CONFIG_FILE}: {e}")
|
|
534
|
+
|
|
535
|
+
# Empty file - return None (valid case)
|
|
536
|
+
if not content.strip():
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
# Parse YAML
|
|
540
|
+
try:
|
|
541
|
+
config = yaml.safe_load(content)
|
|
542
|
+
except yaml.YAMLError as e:
|
|
543
|
+
raise ValueError(f"Invalid YAML in {PROJECT_CONFIG_FILE}: {e}")
|
|
544
|
+
|
|
545
|
+
# yaml.safe_load returns None for empty documents
|
|
546
|
+
if config is None:
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
# Validate schema
|
|
550
|
+
_validate_project_config_schema(config)
|
|
551
|
+
|
|
552
|
+
return cast(dict[str, Any], config)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _validate_project_config_schema(config: dict[str, Any]) -> None:
|
|
556
|
+
"""Validate project config schema.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
config: Parsed project config dict
|
|
560
|
+
|
|
561
|
+
Raises:
|
|
562
|
+
ValueError: If config has invalid schema
|
|
563
|
+
"""
|
|
564
|
+
# additional_plugins must be a list
|
|
565
|
+
if "additional_plugins" in config:
|
|
566
|
+
if not isinstance(config["additional_plugins"], list):
|
|
567
|
+
raise ValueError("additional_plugins must be a list")
|
|
568
|
+
|
|
569
|
+
# additional_mcp_servers must be a list
|
|
570
|
+
if "additional_mcp_servers" in config:
|
|
571
|
+
if not isinstance(config["additional_mcp_servers"], list):
|
|
572
|
+
raise ValueError("additional_mcp_servers must be a list")
|
|
573
|
+
|
|
574
|
+
# session must be a dict
|
|
575
|
+
if "session" in config:
|
|
576
|
+
if not isinstance(config["session"], dict):
|
|
577
|
+
raise ValueError("session must be a dict")
|
|
578
|
+
|
|
579
|
+
# timeout_hours must be an integer if present
|
|
580
|
+
session = config["session"]
|
|
581
|
+
if "timeout_hours" in session:
|
|
582
|
+
if not isinstance(session["timeout_hours"], int):
|
|
583
|
+
raise ValueError("session.timeout_hours must be an integer")
|