scc-cli 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (113) 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 +706 -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 +1454 -0
  16. scc_cli/cli_org.py +1428 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +892 -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 +604 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1074 -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 +1521 -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/adapter.py +74 -0
  46. scc_cli/marketplace/compute.py +377 -0
  47. scc_cli/marketplace/constants.py +87 -0
  48. scc_cli/marketplace/managed.py +135 -0
  49. scc_cli/marketplace/materialize.py +723 -0
  50. scc_cli/marketplace/normalize.py +548 -0
  51. scc_cli/marketplace/render.py +257 -0
  52. scc_cli/marketplace/resolve.py +459 -0
  53. scc_cli/marketplace/schema.py +506 -0
  54. scc_cli/marketplace/sync.py +260 -0
  55. scc_cli/marketplace/team_cache.py +195 -0
  56. scc_cli/marketplace/team_fetch.py +688 -0
  57. scc_cli/marketplace/trust.py +244 -0
  58. scc_cli/models/__init__.py +41 -0
  59. scc_cli/models/exceptions.py +273 -0
  60. scc_cli/models/plugin_audit.py +434 -0
  61. scc_cli/org_templates.py +269 -0
  62. scc_cli/output_mode.py +167 -0
  63. scc_cli/panels.py +113 -0
  64. scc_cli/platform.py +350 -0
  65. scc_cli/profiles.py +960 -0
  66. scc_cli/remote.py +443 -0
  67. scc_cli/schemas/__init__.py +1 -0
  68. scc_cli/schemas/org-v1.schema.json +456 -0
  69. scc_cli/schemas/team-config.v1.schema.json +163 -0
  70. scc_cli/sessions.py +425 -0
  71. scc_cli/setup.py +588 -0
  72. scc_cli/source_resolver.py +470 -0
  73. scc_cli/stats.py +378 -0
  74. scc_cli/stores/__init__.py +13 -0
  75. scc_cli/stores/exception_store.py +251 -0
  76. scc_cli/subprocess_utils.py +88 -0
  77. scc_cli/teams.py +382 -0
  78. scc_cli/templates/__init__.py +2 -0
  79. scc_cli/templates/org/__init__.py +0 -0
  80. scc_cli/templates/org/minimal.json +19 -0
  81. scc_cli/templates/org/reference.json +74 -0
  82. scc_cli/templates/org/strict.json +38 -0
  83. scc_cli/templates/org/teams.json +42 -0
  84. scc_cli/templates/statusline.sh +75 -0
  85. scc_cli/theme.py +348 -0
  86. scc_cli/ui/__init__.py +124 -0
  87. scc_cli/ui/branding.py +68 -0
  88. scc_cli/ui/chrome.py +395 -0
  89. scc_cli/ui/dashboard/__init__.py +62 -0
  90. scc_cli/ui/dashboard/_dashboard.py +677 -0
  91. scc_cli/ui/dashboard/loaders.py +395 -0
  92. scc_cli/ui/dashboard/models.py +184 -0
  93. scc_cli/ui/dashboard/orchestrator.py +390 -0
  94. scc_cli/ui/formatters.py +443 -0
  95. scc_cli/ui/gate.py +350 -0
  96. scc_cli/ui/help.py +157 -0
  97. scc_cli/ui/keys.py +538 -0
  98. scc_cli/ui/list_screen.py +431 -0
  99. scc_cli/ui/picker.py +700 -0
  100. scc_cli/ui/prompts.py +200 -0
  101. scc_cli/ui/wizard.py +675 -0
  102. scc_cli/update.py +680 -0
  103. scc_cli/utils/__init__.py +39 -0
  104. scc_cli/utils/fixit.py +264 -0
  105. scc_cli/utils/fuzzy.py +124 -0
  106. scc_cli/utils/locks.py +101 -0
  107. scc_cli/utils/ttl.py +376 -0
  108. scc_cli/validate.py +455 -0
  109. scc_cli-1.4.1.dist-info/METADATA +369 -0
  110. scc_cli-1.4.1.dist-info/RECORD +113 -0
  111. scc_cli-1.4.1.dist-info/WHEEL +4 -0
  112. scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
  113. scc_cli-1.4.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,434 @@
1
+ """Define data models for plugin audit feature.
2
+
3
+ Provide models for auditing Claude Code plugins, including manifest
4
+ parsing results and status reporting.
5
+
6
+ The audit feature gives visibility into plugin components (MCP servers,
7
+ hooks) without enforcing any policies.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ class ManifestStatus(str, Enum):
20
+ """Status of a manifest file parsing attempt."""
21
+
22
+ PARSED = "parsed"
23
+ """Manifest found and successfully parsed."""
24
+
25
+ MISSING = "missing"
26
+ """Manifest file not found at expected location."""
27
+
28
+ UNREADABLE = "unreadable"
29
+ """Manifest exists but cannot be read (permission error)."""
30
+
31
+ MALFORMED = "malformed"
32
+ """Manifest exists but contains invalid JSON."""
33
+
34
+
35
+ @dataclass
36
+ class ParseError:
37
+ """Details of a JSON parse error with location info."""
38
+
39
+ message: str
40
+ """Human-readable error message."""
41
+
42
+ line: int | None = None
43
+ """Line number where error occurred (1-indexed), if available."""
44
+
45
+ column: int | None = None
46
+ """Column number where error occurred (1-indexed), if available."""
47
+
48
+ @classmethod
49
+ def from_json_error(cls, error: json.JSONDecodeError) -> ParseError:
50
+ """Create ParseError from a JSONDecodeError.
51
+
52
+ Args:
53
+ error: The JSON decode error with position info.
54
+
55
+ Returns:
56
+ ParseError with line/column if available.
57
+ """
58
+ return cls(
59
+ message=error.msg,
60
+ line=error.lineno,
61
+ column=error.colno,
62
+ )
63
+
64
+ def format(self) -> str:
65
+ """Format error for display.
66
+
67
+ Returns:
68
+ Formatted string like "line 15, col 8: Expected ',' but found '}'"
69
+ """
70
+ if self.line is not None and self.column is not None:
71
+ return f"line {self.line}, col {self.column}: {self.message}"
72
+ elif self.line is not None:
73
+ return f"line {self.line}: {self.message}"
74
+ else:
75
+ return self.message
76
+
77
+
78
+ @dataclass
79
+ class ManifestResult:
80
+ """Result of parsing a single manifest file."""
81
+
82
+ status: ManifestStatus
83
+ """The parsing status."""
84
+
85
+ path: Path | None = None
86
+ """Path to the manifest file (relative to plugin root)."""
87
+
88
+ content: dict[str, Any] | None = None
89
+ """Parsed content if status is PARSED."""
90
+
91
+ error: ParseError | None = None
92
+ """Parse error details if status is MALFORMED."""
93
+
94
+ error_message: str | None = None
95
+ """Raw error message for UNREADABLE status."""
96
+
97
+ @property
98
+ def is_ok(self) -> bool:
99
+ """Check if manifest was successfully parsed or is cleanly missing.
100
+
101
+ Returns:
102
+ True if status is PARSED or MISSING (clean states).
103
+ """
104
+ return self.status in (ManifestStatus.PARSED, ManifestStatus.MISSING)
105
+
106
+ @property
107
+ def has_problems(self) -> bool:
108
+ """Check if manifest has problems that should fail CI.
109
+
110
+ Returns:
111
+ True if status is MALFORMED or UNREADABLE.
112
+ """
113
+ return self.status in (ManifestStatus.MALFORMED, ManifestStatus.UNREADABLE)
114
+
115
+
116
+ @dataclass
117
+ class MCPServerInfo:
118
+ """Extracted information about an MCP server from manifest."""
119
+
120
+ name: str
121
+ """Server name/identifier."""
122
+
123
+ transport: str
124
+ """Transport type: 'stdio', 'http', 'sse', etc."""
125
+
126
+ command: str | None = None
127
+ """Command to run (for stdio transport)."""
128
+
129
+ url: str | None = None
130
+ """URL (for http/sse transport)."""
131
+
132
+ description: str | None = None
133
+ """Server description if provided."""
134
+
135
+
136
+ @dataclass
137
+ class HookInfo:
138
+ """Extracted information about a hook from manifest."""
139
+
140
+ event: str
141
+ """Event type: 'PreToolUse', 'PostToolUse', etc."""
142
+
143
+ hook_type: str
144
+ """Hook type: 'command', 'prompt', 'agent'."""
145
+
146
+ matcher: str | None = None
147
+ """Matcher pattern if specified."""
148
+
149
+
150
+ @dataclass
151
+ class PluginManifests:
152
+ """Aggregated manifest information for a plugin."""
153
+
154
+ mcp: ManifestResult
155
+ """Result of parsing .mcp.json."""
156
+
157
+ hooks: ManifestResult
158
+ """Result of parsing hooks/hooks.json or inline hooks."""
159
+
160
+ plugin_json: ManifestResult | None = None
161
+ """Result of parsing .claude-plugin/plugin.json (optional)."""
162
+
163
+ @property
164
+ def has_declarations(self) -> bool:
165
+ """Check if plugin has any component declarations.
166
+
167
+ Returns:
168
+ True if MCP or hooks manifests exist and are parsed.
169
+ """
170
+ return (
171
+ self.mcp.status == ManifestStatus.PARSED or self.hooks.status == ManifestStatus.PARSED
172
+ )
173
+
174
+ @property
175
+ def has_problems(self) -> bool:
176
+ """Check if any manifest has problems.
177
+
178
+ Returns:
179
+ True if any manifest is MALFORMED or UNREADABLE.
180
+ """
181
+ return self.mcp.has_problems or self.hooks.has_problems
182
+
183
+ @property
184
+ def mcp_servers(self) -> list[MCPServerInfo]:
185
+ """Extract MCP server info from parsed manifest.
186
+
187
+ Returns:
188
+ List of MCPServerInfo from .mcp.json content.
189
+ """
190
+ if self.mcp.status != ManifestStatus.PARSED or self.mcp.content is None:
191
+ return []
192
+
193
+ servers = []
194
+ mcp_servers = self.mcp.content.get("mcpServers", {})
195
+ if not isinstance(mcp_servers, dict):
196
+ return []
197
+ for name, config in mcp_servers.items():
198
+ if not isinstance(config, dict):
199
+ continue
200
+ transport = config.get("transport", "stdio")
201
+ servers.append(
202
+ MCPServerInfo(
203
+ name=name,
204
+ transport=transport,
205
+ command=config.get("command"),
206
+ url=config.get("url"),
207
+ description=config.get("description"),
208
+ )
209
+ )
210
+ return servers
211
+
212
+ @property
213
+ def hooks_info(self) -> list[HookInfo]:
214
+ """Extract hook info from parsed manifest.
215
+
216
+ Returns:
217
+ List of HookInfo from hooks content.
218
+ """
219
+ if self.hooks.status != ManifestStatus.PARSED or self.hooks.content is None:
220
+ return []
221
+
222
+ hooks_list = []
223
+ hooks_config = self.hooks.content.get("hooks", {})
224
+ if not isinstance(hooks_config, dict):
225
+ return []
226
+ for event, event_hooks in hooks_config.items():
227
+ if isinstance(event_hooks, list):
228
+ for hook_group in event_hooks:
229
+ matcher = hook_group.get("matcher")
230
+ for hook in hook_group.get("hooks", []):
231
+ hooks_list.append(
232
+ HookInfo(
233
+ event=event,
234
+ hook_type=hook.get("type", "unknown"),
235
+ matcher=matcher,
236
+ )
237
+ )
238
+ return hooks_list
239
+
240
+
241
+ @dataclass
242
+ class PluginAuditResult:
243
+ """Full audit result for a single plugin."""
244
+
245
+ plugin_id: str
246
+ """Plugin identifier in format 'name@marketplace'."""
247
+
248
+ plugin_name: str
249
+ """Plugin name (without marketplace)."""
250
+
251
+ marketplace: str
252
+ """Marketplace name."""
253
+
254
+ version: str
255
+ """Installed version."""
256
+
257
+ install_path: Path | None = None
258
+ """Absolute path to installed plugin, if installed."""
259
+
260
+ installed: bool = False
261
+ """Whether the plugin is currently installed."""
262
+
263
+ manifests: PluginManifests | None = None
264
+ """Manifest parsing results, if installed."""
265
+
266
+ @property
267
+ def status_summary(self) -> str:
268
+ """Get a summary status for the plugin.
269
+
270
+ Returns:
271
+ Summary string: 'clean', 'parsed', 'malformed', 'unreadable', 'not installed'
272
+ """
273
+ if not self.installed:
274
+ return "not installed"
275
+
276
+ if self.manifests is None:
277
+ return "unknown"
278
+
279
+ if self.manifests.has_problems:
280
+ # Report the worst status
281
+ if self.manifests.mcp.status == ManifestStatus.MALFORMED:
282
+ return "malformed"
283
+ if self.manifests.hooks.status == ManifestStatus.MALFORMED:
284
+ return "malformed"
285
+ return "unreadable"
286
+
287
+ if self.manifests.has_declarations:
288
+ return "parsed"
289
+
290
+ return "clean"
291
+
292
+ @property
293
+ def has_ci_failures(self) -> bool:
294
+ """Check if this plugin should cause CI failure.
295
+
296
+ Returns:
297
+ True if any manifest is malformed or unreadable.
298
+ """
299
+ if self.manifests is None:
300
+ return False
301
+ return self.manifests.has_problems
302
+
303
+
304
+ @dataclass
305
+ class AuditOutput:
306
+ """Overall audit output for all plugins."""
307
+
308
+ schema_version: int = 1
309
+ """Schema version for JSON output."""
310
+
311
+ plugins: list[PluginAuditResult] = field(default_factory=list)
312
+ """Results for each audited plugin."""
313
+
314
+ warnings: list[str] = field(default_factory=list)
315
+ """Warning messages (non-fatal issues)."""
316
+
317
+ @property
318
+ def total_plugins(self) -> int:
319
+ """Total number of plugins audited."""
320
+ return len(self.plugins)
321
+
322
+ @property
323
+ def clean_count(self) -> int:
324
+ """Number of clean plugins (no declarations)."""
325
+ return sum(1 for p in self.plugins if p.status_summary == "clean")
326
+
327
+ @property
328
+ def parsed_count(self) -> int:
329
+ """Number of plugins with parsed manifests."""
330
+ return sum(1 for p in self.plugins if p.status_summary == "parsed")
331
+
332
+ @property
333
+ def problem_count(self) -> int:
334
+ """Number of plugins with problems."""
335
+ return sum(1 for p in self.plugins if p.has_ci_failures)
336
+
337
+ @property
338
+ def has_ci_failures(self) -> bool:
339
+ """Check if any plugin should cause CI failure.
340
+
341
+ Returns:
342
+ True if any plugin has malformed or unreadable manifests.
343
+ """
344
+ return any(p.has_ci_failures for p in self.plugins)
345
+
346
+ @property
347
+ def exit_code(self) -> int:
348
+ """Get CI exit code.
349
+
350
+ Returns:
351
+ 0 if all plugins OK, 1 if any have problems.
352
+ """
353
+ return 1 if self.has_ci_failures else 0
354
+
355
+ def to_dict(self) -> dict[str, Any]:
356
+ """Convert to dictionary for JSON serialization.
357
+
358
+ Returns:
359
+ Dict with schemaVersion and structured plugin data.
360
+ """
361
+ return {
362
+ "schemaVersion": self.schema_version,
363
+ "summary": {
364
+ "total": self.total_plugins,
365
+ "clean": self.clean_count,
366
+ "parsed": self.parsed_count,
367
+ "problems": self.problem_count,
368
+ },
369
+ "plugins": [self._plugin_to_dict(p) for p in self.plugins],
370
+ "warnings": self.warnings,
371
+ }
372
+
373
+ def _plugin_to_dict(self, plugin: PluginAuditResult) -> dict[str, Any]:
374
+ """Convert a single plugin result to dictionary."""
375
+ result: dict[str, Any] = {
376
+ "pluginId": plugin.plugin_id,
377
+ "name": plugin.plugin_name,
378
+ "marketplace": plugin.marketplace,
379
+ "version": plugin.version,
380
+ "installed": plugin.installed,
381
+ "status": plugin.status_summary,
382
+ }
383
+
384
+ if plugin.install_path:
385
+ # Use relative path for security (don't leak full paths in CI logs)
386
+ try:
387
+ result["installPath"] = str(plugin.install_path.relative_to(Path.home()))
388
+ except ValueError:
389
+ # Path not under home, use just the name
390
+ result["installPath"] = plugin.install_path.name
391
+
392
+ if plugin.manifests:
393
+ result["manifests"] = {
394
+ "mcp": self._manifest_to_dict(plugin.manifests.mcp),
395
+ "hooks": self._manifest_to_dict(plugin.manifests.hooks),
396
+ }
397
+
398
+ # Add extracted component info
399
+ if plugin.manifests.mcp_servers:
400
+ result["mcpServers"] = [
401
+ {
402
+ "name": s.name,
403
+ "transport": s.transport,
404
+ "description": s.description,
405
+ }
406
+ for s in plugin.manifests.mcp_servers
407
+ ]
408
+
409
+ if plugin.manifests.hooks_info:
410
+ result["hooks"] = [
411
+ {
412
+ "event": h.event,
413
+ "type": h.hook_type,
414
+ "matcher": h.matcher,
415
+ }
416
+ for h in plugin.manifests.hooks_info
417
+ ]
418
+
419
+ return result
420
+
421
+ def _manifest_to_dict(self, manifest: ManifestResult) -> dict[str, Any]:
422
+ """Convert manifest result to dictionary."""
423
+ result: dict[str, Any] = {"status": manifest.status.value}
424
+
425
+ if manifest.path:
426
+ result["path"] = str(manifest.path)
427
+
428
+ if manifest.error:
429
+ result["error"] = manifest.error.format()
430
+
431
+ if manifest.error_message:
432
+ result["errorMessage"] = manifest.error_message
433
+
434
+ return result
@@ -0,0 +1,269 @@
1
+ """
2
+ Organization config template loading and generation.
3
+
4
+ Provides template-based skeleton generation for `scc org init`.
5
+ Templates are bundled JSON files with placeholder substitutions.
6
+
7
+ Key functions:
8
+ - list_templates(): List available template names with descriptions
9
+ - load_template(): Load a template with variable substitutions
10
+ - render_template(): Generate JSON output for a template
11
+
12
+ Template naming convention:
13
+ - minimal: Minimal quickstart config
14
+ - teams: Multi-team with profiles
15
+ - strict: Strict security for regulated industries
16
+ - reference: Complete reference with all fields
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from dataclasses import dataclass
23
+ from importlib.resources import files
24
+ from typing import Any, cast
25
+
26
+ # ═══════════════════════════════════════════════════════════════════════════════
27
+ # Template Registry
28
+ # ═══════════════════════════════════════════════════════════════════════════════
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class TemplateInfo:
33
+ """Metadata about an available template.
34
+
35
+ Attributes:
36
+ name: Template identifier (e.g., "minimal", "teams").
37
+ description: Human-readable description.
38
+ level: Complexity level ("beginner", "intermediate", "advanced").
39
+ use_case: Primary use case description.
40
+ """
41
+
42
+ name: str
43
+ description: str
44
+ level: str
45
+ use_case: str
46
+
47
+
48
+ # Available templates with metadata
49
+ TEMPLATES: dict[str, TemplateInfo] = {
50
+ "minimal": TemplateInfo(
51
+ name="minimal",
52
+ description="Minimal quickstart config",
53
+ level="beginner",
54
+ use_case="First-time setup, single team, sensible defaults",
55
+ ),
56
+ "teams": TemplateInfo(
57
+ name="teams",
58
+ description="Multi-team with profiles and delegation",
59
+ level="intermediate",
60
+ use_case="Organizations with multiple teams needing different configs",
61
+ ),
62
+ "strict": TemplateInfo(
63
+ name="strict",
64
+ description="Strict security for regulated industries",
65
+ level="advanced",
66
+ use_case="Financial, healthcare, or compliance-heavy environments",
67
+ ),
68
+ "reference": TemplateInfo(
69
+ name="reference",
70
+ description="Complete reference with all fields",
71
+ level="reference",
72
+ use_case="Documentation and learning all available options",
73
+ ),
74
+ }
75
+
76
+
77
+ # ═══════════════════════════════════════════════════════════════════════════════
78
+ # Template Loading
79
+ # ═══════════════════════════════════════════════════════════════════════════════
80
+
81
+
82
+ class TemplateNotFoundError(Exception):
83
+ """Raised when a template name is not recognized."""
84
+
85
+ def __init__(self, name: str, available: list[str]) -> None:
86
+ self.name = name
87
+ self.available = available
88
+ super().__init__(
89
+ f"Unknown template '{name}'. Available templates: {', '.join(sorted(available))}"
90
+ )
91
+
92
+
93
+ def list_templates() -> list[TemplateInfo]:
94
+ """List all available templates with metadata.
95
+
96
+ Returns:
97
+ List of TemplateInfo objects describing each template.
98
+
99
+ Example:
100
+ >>> templates = list_templates()
101
+ >>> templates[0].name
102
+ 'minimal'
103
+ """
104
+ return list(TEMPLATES.values())
105
+
106
+
107
+ def get_template_info(name: str) -> TemplateInfo:
108
+ """Get metadata for a specific template.
109
+
110
+ Args:
111
+ name: Template identifier.
112
+
113
+ Returns:
114
+ TemplateInfo for the requested template.
115
+
116
+ Raises:
117
+ TemplateNotFoundError: If template name is not recognized.
118
+ """
119
+ if name not in TEMPLATES:
120
+ raise TemplateNotFoundError(name, list(TEMPLATES.keys()))
121
+ return TEMPLATES[name]
122
+
123
+
124
+ def load_template_raw(name: str) -> str:
125
+ """Load raw template content from package resources.
126
+
127
+ Args:
128
+ name: Template identifier (e.g., "minimal", "teams").
129
+
130
+ Returns:
131
+ Raw template content as string (with placeholders).
132
+
133
+ Raises:
134
+ TemplateNotFoundError: If template name is not recognized.
135
+ FileNotFoundError: If template file doesn't exist.
136
+ """
137
+ # Validate template name exists
138
+ if name not in TEMPLATES:
139
+ raise TemplateNotFoundError(name, list(TEMPLATES.keys()))
140
+
141
+ # Load from package resources
142
+ template_file = files("scc_cli.templates.org").joinpath(f"{name}.json")
143
+ try:
144
+ return template_file.read_text()
145
+ except FileNotFoundError:
146
+ raise FileNotFoundError(f"Template file '{name}.json' not found in package")
147
+
148
+
149
+ # ═══════════════════════════════════════════════════════════════════════════════
150
+ # Template Rendering
151
+ # ═══════════════════════════════════════════════════════════════════════════════
152
+
153
+
154
+ @dataclass
155
+ class TemplateVars:
156
+ """Variables for template substitution.
157
+
158
+ Attributes:
159
+ org_name: Organization name (e.g., "acme-corp").
160
+ org_domain: Organization domain (e.g., "acme.com").
161
+ schema_version: Schema version (default: "1.0.0").
162
+ min_cli_version: Minimum CLI version (default: "1.2.0").
163
+ """
164
+
165
+ org_name: str = "my-org"
166
+ org_domain: str = "example.com"
167
+ schema_version: str = "1.0.0"
168
+ min_cli_version: str = "1.2.0"
169
+
170
+
171
+ def render_template(
172
+ name: str,
173
+ vars: TemplateVars | None = None,
174
+ ) -> dict[str, Any]:
175
+ """Render a template with variable substitutions.
176
+
177
+ Template placeholders use the format {{VAR_NAME}}.
178
+ Supported placeholders:
179
+ - {{ORG_NAME}}: Organization name
180
+ - {{ORG_DOMAIN}}: Organization domain
181
+ - {{SCHEMA_VERSION}}: Schema version
182
+ - {{MIN_CLI_VERSION}}: Minimum CLI version
183
+
184
+ Args:
185
+ name: Template identifier.
186
+ vars: Template variables for substitution.
187
+
188
+ Returns:
189
+ Rendered template as a dict.
190
+
191
+ Raises:
192
+ TemplateNotFoundError: If template name is not recognized.
193
+ json.JSONDecodeError: If rendered template is not valid JSON.
194
+
195
+ Example:
196
+ >>> result = render_template("minimal", TemplateVars(org_name="acme"))
197
+ >>> result["name"]
198
+ 'acme'
199
+ """
200
+ if vars is None:
201
+ vars = TemplateVars()
202
+
203
+ # Load raw template
204
+ raw = load_template_raw(name)
205
+
206
+ # Apply substitutions
207
+ substitutions = {
208
+ "{{ORG_NAME}}": vars.org_name,
209
+ "{{ORG_DOMAIN}}": vars.org_domain,
210
+ "{{SCHEMA_VERSION}}": vars.schema_version,
211
+ "{{MIN_CLI_VERSION}}": vars.min_cli_version,
212
+ }
213
+
214
+ rendered = raw
215
+ for placeholder, value in substitutions.items():
216
+ rendered = rendered.replace(placeholder, value)
217
+
218
+ # Parse as JSON
219
+ return cast(dict[str, Any], json.loads(rendered))
220
+
221
+
222
+ def render_template_string(
223
+ name: str,
224
+ vars: TemplateVars | None = None,
225
+ indent: int = 2,
226
+ ) -> str:
227
+ """Render a template as a formatted JSON string.
228
+
229
+ This is useful for --stdout output or file writing.
230
+
231
+ Args:
232
+ name: Template identifier.
233
+ vars: Template variables for substitution.
234
+ indent: JSON indentation level (default: 2).
235
+
236
+ Returns:
237
+ Formatted JSON string.
238
+
239
+ Example:
240
+ >>> output = render_template_string("minimal")
241
+ >>> print(output)
242
+ {
243
+ "name": "my-org",
244
+ ...
245
+ }
246
+ """
247
+ data = render_template(name, vars)
248
+ return json.dumps(data, indent=indent, ensure_ascii=False)
249
+
250
+
251
+ # ═══════════════════════════════════════════════════════════════════════════════
252
+ # Validation
253
+ # ═══════════════════════════════════════════════════════════════════════════════
254
+
255
+
256
+ def validate_template_name(name: str) -> None:
257
+ """Validate that a template name is recognized.
258
+
259
+ This is a strict validation that raises an error for unknown names.
260
+ Use this before any operation that requires a valid template.
261
+
262
+ Args:
263
+ name: Template name to validate.
264
+
265
+ Raises:
266
+ TemplateNotFoundError: If template name is not recognized.
267
+ """
268
+ if name not in TEMPLATES:
269
+ raise TemplateNotFoundError(name, list(TEMPLATES.keys()))