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
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")