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.
- 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 +706 -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 +1454 -0
- scc_cli/cli_org.py +1428 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +892 -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 +604 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1074 -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 +1521 -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/adapter.py +74 -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 +257 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +260 -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 +960 -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 +588 -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 +382 -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 +677 -0
- scc_cli/ui/dashboard/loaders.py +395 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +390 -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 +538 -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 +675 -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.1.dist-info/METADATA +369 -0
- scc_cli-1.4.1.dist-info/RECORD +113 -0
- scc_cli-1.4.1.dist-info/WHEEL +4 -0
- scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
- 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
|
scc_cli/org_templates.py
ADDED
|
@@ -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()))
|