devsync 0.5.5__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.
- aiconfigkit/__init__.py +0 -0
- aiconfigkit/__main__.py +6 -0
- aiconfigkit/ai_tools/__init__.py +0 -0
- aiconfigkit/ai_tools/base.py +236 -0
- aiconfigkit/ai_tools/capability_registry.py +262 -0
- aiconfigkit/ai_tools/claude.py +91 -0
- aiconfigkit/ai_tools/claude_desktop.py +97 -0
- aiconfigkit/ai_tools/cline.py +92 -0
- aiconfigkit/ai_tools/copilot.py +92 -0
- aiconfigkit/ai_tools/cursor.py +109 -0
- aiconfigkit/ai_tools/detector.py +169 -0
- aiconfigkit/ai_tools/kiro.py +85 -0
- aiconfigkit/ai_tools/mcp_syncer.py +291 -0
- aiconfigkit/ai_tools/roo.py +110 -0
- aiconfigkit/ai_tools/translator.py +390 -0
- aiconfigkit/ai_tools/winsurf.py +102 -0
- aiconfigkit/cli/__init__.py +0 -0
- aiconfigkit/cli/delete.py +118 -0
- aiconfigkit/cli/download.py +274 -0
- aiconfigkit/cli/install.py +237 -0
- aiconfigkit/cli/install_new.py +937 -0
- aiconfigkit/cli/list.py +275 -0
- aiconfigkit/cli/main.py +454 -0
- aiconfigkit/cli/mcp_configure.py +232 -0
- aiconfigkit/cli/mcp_install.py +166 -0
- aiconfigkit/cli/mcp_sync.py +165 -0
- aiconfigkit/cli/package.py +383 -0
- aiconfigkit/cli/package_create.py +323 -0
- aiconfigkit/cli/package_install.py +472 -0
- aiconfigkit/cli/template.py +19 -0
- aiconfigkit/cli/template_backup.py +261 -0
- aiconfigkit/cli/template_init.py +499 -0
- aiconfigkit/cli/template_install.py +261 -0
- aiconfigkit/cli/template_list.py +172 -0
- aiconfigkit/cli/template_uninstall.py +146 -0
- aiconfigkit/cli/template_update.py +225 -0
- aiconfigkit/cli/template_validate.py +234 -0
- aiconfigkit/cli/tools.py +47 -0
- aiconfigkit/cli/uninstall.py +125 -0
- aiconfigkit/cli/update.py +309 -0
- aiconfigkit/core/__init__.py +0 -0
- aiconfigkit/core/checksum.py +211 -0
- aiconfigkit/core/component_detector.py +905 -0
- aiconfigkit/core/conflict_resolution.py +329 -0
- aiconfigkit/core/git_operations.py +539 -0
- aiconfigkit/core/mcp/__init__.py +1 -0
- aiconfigkit/core/mcp/credentials.py +279 -0
- aiconfigkit/core/mcp/manager.py +308 -0
- aiconfigkit/core/mcp/set_manager.py +1 -0
- aiconfigkit/core/mcp/validator.py +1 -0
- aiconfigkit/core/models.py +1661 -0
- aiconfigkit/core/package_creator.py +743 -0
- aiconfigkit/core/package_manifest.py +248 -0
- aiconfigkit/core/repository.py +298 -0
- aiconfigkit/core/secret_detector.py +438 -0
- aiconfigkit/core/template_manifest.py +283 -0
- aiconfigkit/core/version.py +201 -0
- aiconfigkit/storage/__init__.py +0 -0
- aiconfigkit/storage/library.py +429 -0
- aiconfigkit/storage/mcp_tracker.py +1 -0
- aiconfigkit/storage/package_tracker.py +234 -0
- aiconfigkit/storage/template_library.py +229 -0
- aiconfigkit/storage/template_tracker.py +296 -0
- aiconfigkit/storage/tracker.py +416 -0
- aiconfigkit/tui/__init__.py +5 -0
- aiconfigkit/tui/installer.py +511 -0
- aiconfigkit/utils/__init__.py +0 -0
- aiconfigkit/utils/atomic_write.py +90 -0
- aiconfigkit/utils/backup.py +169 -0
- aiconfigkit/utils/dotenv.py +128 -0
- aiconfigkit/utils/git_helpers.py +187 -0
- aiconfigkit/utils/logging.py +60 -0
- aiconfigkit/utils/namespace.py +134 -0
- aiconfigkit/utils/paths.py +205 -0
- aiconfigkit/utils/project.py +109 -0
- aiconfigkit/utils/streaming.py +216 -0
- aiconfigkit/utils/ui.py +194 -0
- aiconfigkit/utils/validation.py +187 -0
- devsync-0.5.5.dist-info/LICENSE +21 -0
- devsync-0.5.5.dist-info/METADATA +477 -0
- devsync-0.5.5.dist-info/RECORD +84 -0
- devsync-0.5.5.dist-info/WHEEL +5 -0
- devsync-0.5.5.dist-info/entry_points.txt +2 -0
- devsync-0.5.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1661 @@
|
|
|
1
|
+
"""Core data models for InstructionKit."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AIToolType(Enum):
|
|
10
|
+
"""Supported AI coding tool types."""
|
|
11
|
+
|
|
12
|
+
CURSOR = "cursor"
|
|
13
|
+
COPILOT = "copilot"
|
|
14
|
+
WINSURF = "winsurf"
|
|
15
|
+
CLAUDE = "claude"
|
|
16
|
+
KIRO = "kiro"
|
|
17
|
+
CLINE = "cline"
|
|
18
|
+
ROO = "roo"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConflictResolution(Enum):
|
|
22
|
+
"""Conflict resolution strategies."""
|
|
23
|
+
|
|
24
|
+
PROMPT = "prompt" # Interactive prompting (default)
|
|
25
|
+
SKIP = "skip"
|
|
26
|
+
RENAME = "rename"
|
|
27
|
+
OVERWRITE = "overwrite"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InstallationScope(Enum):
|
|
31
|
+
"""Installation scope."""
|
|
32
|
+
|
|
33
|
+
GLOBAL = "global"
|
|
34
|
+
PROJECT = "project"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RefType(Enum):
|
|
38
|
+
"""Git reference types for version control."""
|
|
39
|
+
|
|
40
|
+
TAG = "tag"
|
|
41
|
+
BRANCH = "branch"
|
|
42
|
+
COMMIT = "commit"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConflictType(Enum):
|
|
46
|
+
"""Types of conflicts during template updates."""
|
|
47
|
+
|
|
48
|
+
NONE = "none" # No conflict, safe to update
|
|
49
|
+
LOCAL_MODIFIED = "local_modified" # User modified local file
|
|
50
|
+
BOTH_MODIFIED = "both_modified" # Both local and remote changed
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class IssueType(Enum):
|
|
54
|
+
"""Types of validation issues."""
|
|
55
|
+
|
|
56
|
+
TRACKING_INCONSISTENCY = "tracking_inconsistency" # File exists but not tracked
|
|
57
|
+
MISSING_FILE = "missing_file" # Tracked but file missing
|
|
58
|
+
OUTDATED = "outdated" # Newer version available
|
|
59
|
+
BROKEN_DEPENDENCY = "broken_dependency" # Required template not installed
|
|
60
|
+
LOCAL_MODIFICATION = "local_modification" # File changed since install
|
|
61
|
+
SEMANTIC_CONFLICT = "semantic_conflict" # AI detected conflicting guidance
|
|
62
|
+
CLARITY_ISSUE = "clarity_issue" # AI detected unclear instructions
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class IssueSeverity(Enum):
|
|
66
|
+
"""Severity levels for validation issues."""
|
|
67
|
+
|
|
68
|
+
ERROR = "error" # Must fix
|
|
69
|
+
WARNING = "warning" # Should fix
|
|
70
|
+
INFO = "info" # Nice to know
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Instruction:
|
|
75
|
+
"""
|
|
76
|
+
Represents a single instruction file.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
name: Unique identifier (e.g., 'python-best-practices')
|
|
80
|
+
description: Human-readable description
|
|
81
|
+
content: The actual instruction text
|
|
82
|
+
file_path: Relative path in repository (e.g., 'instructions/python-best-practices.md')
|
|
83
|
+
tags: Categorization tags (e.g., ['python', 'backend'])
|
|
84
|
+
checksum: SHA-256 hash for integrity validation
|
|
85
|
+
ai_tools: List of compatible AI tools (empty = all compatible)
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
description: str
|
|
90
|
+
content: str
|
|
91
|
+
file_path: str
|
|
92
|
+
tags: list[str] = field(default_factory=list)
|
|
93
|
+
checksum: Optional[str] = None
|
|
94
|
+
ai_tools: list[AIToolType] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
"""Validate instruction data."""
|
|
98
|
+
if not self.name:
|
|
99
|
+
raise ValueError("Instruction name cannot be empty")
|
|
100
|
+
if not self.description:
|
|
101
|
+
raise ValueError("Instruction description cannot be empty")
|
|
102
|
+
if not self.content:
|
|
103
|
+
raise ValueError("Instruction content cannot be empty")
|
|
104
|
+
if not self.file_path:
|
|
105
|
+
raise ValueError("Instruction file_path cannot be empty")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class InstructionBundle:
|
|
110
|
+
"""
|
|
111
|
+
Represents a bundle of related instructions.
|
|
112
|
+
|
|
113
|
+
Attributes:
|
|
114
|
+
name: Bundle identifier (e.g., 'python-backend')
|
|
115
|
+
description: What this bundle provides
|
|
116
|
+
instructions: List of instruction names in this bundle
|
|
117
|
+
tags: Bundle-level tags
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
name: str
|
|
121
|
+
description: str
|
|
122
|
+
instructions: list[str]
|
|
123
|
+
tags: list[str] = field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
def __post_init__(self) -> None:
|
|
126
|
+
"""Validate bundle data."""
|
|
127
|
+
if not self.name:
|
|
128
|
+
raise ValueError("Bundle name cannot be empty")
|
|
129
|
+
if not self.description:
|
|
130
|
+
raise ValueError("Bundle description cannot be empty")
|
|
131
|
+
if not self.instructions:
|
|
132
|
+
raise ValueError("Bundle must contain at least one instruction")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class Repository:
|
|
137
|
+
"""
|
|
138
|
+
Represents an instruction repository.
|
|
139
|
+
|
|
140
|
+
Attributes:
|
|
141
|
+
url: Git repository URL (empty string if not yet set)
|
|
142
|
+
instructions: Available instructions
|
|
143
|
+
bundles: Available bundles
|
|
144
|
+
metadata: Additional repository metadata
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
url: str = ""
|
|
148
|
+
instructions: list[Instruction] = field(default_factory=list)
|
|
149
|
+
bundles: list[InstructionBundle] = field(default_factory=list)
|
|
150
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class InstallationRecord:
|
|
155
|
+
"""
|
|
156
|
+
Tracks an installed instruction.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
instruction_name: Name of installed instruction
|
|
160
|
+
ai_tool: Which AI tool it's installed to
|
|
161
|
+
source_repo: Repository URL it came from
|
|
162
|
+
installed_path: Path where file was installed (relative to project root for PROJECT scope)
|
|
163
|
+
installed_at: Installation timestamp
|
|
164
|
+
checksum: File checksum at installation time
|
|
165
|
+
bundle_name: If installed as part of bundle
|
|
166
|
+
scope: Installation scope (global or project)
|
|
167
|
+
source_ref: Git reference (tag, branch, or commit) the instruction came from
|
|
168
|
+
source_ref_type: Type of Git reference (tag, branch, or commit)
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
instruction_name: str
|
|
172
|
+
ai_tool: AIToolType
|
|
173
|
+
source_repo: str
|
|
174
|
+
installed_path: str
|
|
175
|
+
installed_at: datetime
|
|
176
|
+
checksum: Optional[str] = None
|
|
177
|
+
bundle_name: Optional[str] = None
|
|
178
|
+
scope: InstallationScope = InstallationScope.GLOBAL
|
|
179
|
+
source_ref: Optional[str] = None
|
|
180
|
+
source_ref_type: Optional[RefType] = None
|
|
181
|
+
|
|
182
|
+
def __post_init__(self) -> None:
|
|
183
|
+
"""Validate installation record."""
|
|
184
|
+
if not self.instruction_name:
|
|
185
|
+
raise ValueError("Instruction name cannot be empty")
|
|
186
|
+
if not self.source_repo:
|
|
187
|
+
raise ValueError("Source repository cannot be empty")
|
|
188
|
+
if not self.installed_path:
|
|
189
|
+
raise ValueError("Installed path cannot be empty")
|
|
190
|
+
|
|
191
|
+
def to_dict(self) -> dict:
|
|
192
|
+
"""Convert to dictionary for JSON serialization."""
|
|
193
|
+
return {
|
|
194
|
+
"instruction_name": self.instruction_name,
|
|
195
|
+
"ai_tool": self.ai_tool.value,
|
|
196
|
+
"source_repo": self.source_repo,
|
|
197
|
+
"installed_path": self.installed_path,
|
|
198
|
+
"installed_at": self.installed_at.isoformat(),
|
|
199
|
+
"checksum": self.checksum,
|
|
200
|
+
"bundle_name": self.bundle_name,
|
|
201
|
+
"scope": self.scope.value,
|
|
202
|
+
"source_ref": self.source_ref,
|
|
203
|
+
"source_ref_type": self.source_ref_type.value if self.source_ref_type else None,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def from_dict(cls, data: dict) -> "InstallationRecord":
|
|
208
|
+
"""Create from dictionary (JSON deserialization)."""
|
|
209
|
+
# Handle backwards compatibility - old records won't have scope
|
|
210
|
+
scope_value = data.get("scope", "global")
|
|
211
|
+
scope = InstallationScope(scope_value) if isinstance(scope_value, str) else scope_value
|
|
212
|
+
|
|
213
|
+
# Handle backwards compatibility - old records won't have ref fields
|
|
214
|
+
source_ref = data.get("source_ref")
|
|
215
|
+
source_ref_type = None
|
|
216
|
+
if data.get("source_ref_type"):
|
|
217
|
+
source_ref_type = RefType(data["source_ref_type"])
|
|
218
|
+
|
|
219
|
+
return cls(
|
|
220
|
+
instruction_name=data["instruction_name"],
|
|
221
|
+
ai_tool=AIToolType(data["ai_tool"]),
|
|
222
|
+
source_repo=data["source_repo"],
|
|
223
|
+
installed_path=data["installed_path"],
|
|
224
|
+
installed_at=datetime.fromisoformat(data["installed_at"]),
|
|
225
|
+
checksum=data.get("checksum"),
|
|
226
|
+
bundle_name=data.get("bundle_name"),
|
|
227
|
+
scope=scope,
|
|
228
|
+
source_ref=source_ref,
|
|
229
|
+
source_ref_type=source_ref_type,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass
|
|
234
|
+
class ConflictInfo:
|
|
235
|
+
"""
|
|
236
|
+
Information about a file conflict.
|
|
237
|
+
|
|
238
|
+
Attributes:
|
|
239
|
+
instruction_name: Name of conflicting instruction
|
|
240
|
+
existing_path: Path to existing file
|
|
241
|
+
resolution: How the conflict was resolved
|
|
242
|
+
new_path: New path if renamed
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
instruction_name: str
|
|
246
|
+
existing_path: str
|
|
247
|
+
resolution: ConflictResolution
|
|
248
|
+
new_path: Optional[str] = None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class LibraryInstruction:
|
|
253
|
+
"""
|
|
254
|
+
Represents an instruction in the local library (downloaded but not yet installed).
|
|
255
|
+
|
|
256
|
+
Attributes:
|
|
257
|
+
id: Unique identifier (repo_namespace/name)
|
|
258
|
+
name: Instruction name
|
|
259
|
+
description: Human-readable description
|
|
260
|
+
repo_namespace: Repository namespace (e.g., 'company-instructions')
|
|
261
|
+
repo_url: Source repository URL
|
|
262
|
+
repo_name: Repository display name
|
|
263
|
+
author: Author or team name
|
|
264
|
+
version: Instruction version
|
|
265
|
+
file_path: Path to instruction file in library
|
|
266
|
+
tags: Categorization tags
|
|
267
|
+
downloaded_at: When it was downloaded
|
|
268
|
+
checksum: SHA-256 hash for integrity
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
id: str
|
|
272
|
+
name: str
|
|
273
|
+
description: str
|
|
274
|
+
repo_namespace: str
|
|
275
|
+
repo_url: str
|
|
276
|
+
repo_name: str
|
|
277
|
+
author: str
|
|
278
|
+
version: str
|
|
279
|
+
file_path: str
|
|
280
|
+
tags: list[str] = field(default_factory=list)
|
|
281
|
+
downloaded_at: Optional[datetime] = None
|
|
282
|
+
checksum: Optional[str] = None
|
|
283
|
+
|
|
284
|
+
def __post_init__(self) -> None:
|
|
285
|
+
"""Validate library instruction data."""
|
|
286
|
+
if not self.id:
|
|
287
|
+
raise ValueError("Instruction id cannot be empty")
|
|
288
|
+
if not self.name:
|
|
289
|
+
raise ValueError("Instruction name cannot be empty")
|
|
290
|
+
if not self.repo_namespace:
|
|
291
|
+
raise ValueError("Repository namespace cannot be empty")
|
|
292
|
+
|
|
293
|
+
def to_dict(self) -> dict:
|
|
294
|
+
"""Convert to dictionary for JSON serialization."""
|
|
295
|
+
return {
|
|
296
|
+
"id": self.id,
|
|
297
|
+
"name": self.name,
|
|
298
|
+
"description": self.description,
|
|
299
|
+
"repo_namespace": self.repo_namespace,
|
|
300
|
+
"repo_url": self.repo_url,
|
|
301
|
+
"repo_name": self.repo_name,
|
|
302
|
+
"author": self.author,
|
|
303
|
+
"version": self.version,
|
|
304
|
+
"file_path": self.file_path,
|
|
305
|
+
"tags": self.tags,
|
|
306
|
+
"downloaded_at": self.downloaded_at.isoformat() if self.downloaded_at else None,
|
|
307
|
+
"checksum": self.checksum,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def from_dict(cls, data: dict) -> "LibraryInstruction":
|
|
312
|
+
"""Create from dictionary (JSON deserialization)."""
|
|
313
|
+
downloaded_at = None
|
|
314
|
+
if data.get("downloaded_at"):
|
|
315
|
+
downloaded_at = datetime.fromisoformat(data["downloaded_at"])
|
|
316
|
+
|
|
317
|
+
return cls(
|
|
318
|
+
id=data["id"],
|
|
319
|
+
name=data["name"],
|
|
320
|
+
description=data["description"],
|
|
321
|
+
repo_namespace=data["repo_namespace"],
|
|
322
|
+
repo_url=data["repo_url"],
|
|
323
|
+
repo_name=data["repo_name"],
|
|
324
|
+
author=data["author"],
|
|
325
|
+
version=data["version"],
|
|
326
|
+
file_path=data["file_path"],
|
|
327
|
+
tags=data.get("tags", []),
|
|
328
|
+
downloaded_at=downloaded_at,
|
|
329
|
+
checksum=data.get("checksum"),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass
|
|
334
|
+
class LibraryRepository:
|
|
335
|
+
"""
|
|
336
|
+
Represents a downloaded repository in the library.
|
|
337
|
+
|
|
338
|
+
Attributes:
|
|
339
|
+
namespace: Repository namespace identifier (e.g., 'github.com_company_instructions')
|
|
340
|
+
name: Repository display name
|
|
341
|
+
description: Repository description
|
|
342
|
+
url: Source repository URL
|
|
343
|
+
author: Author or team name
|
|
344
|
+
version: Repository version
|
|
345
|
+
downloaded_at: When it was downloaded
|
|
346
|
+
alias: User-friendly alias for this source (optional)
|
|
347
|
+
instructions: List of instructions in this repository
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
namespace: str
|
|
351
|
+
name: str
|
|
352
|
+
description: str
|
|
353
|
+
url: str
|
|
354
|
+
author: str
|
|
355
|
+
version: str
|
|
356
|
+
downloaded_at: datetime
|
|
357
|
+
alias: Optional[str] = None
|
|
358
|
+
instructions: list[LibraryInstruction] = field(default_factory=list)
|
|
359
|
+
|
|
360
|
+
def __post_init__(self) -> None:
|
|
361
|
+
"""Validate library repository data."""
|
|
362
|
+
if not self.namespace:
|
|
363
|
+
raise ValueError("Repository namespace cannot be empty")
|
|
364
|
+
if not self.name:
|
|
365
|
+
raise ValueError("Repository name cannot be empty")
|
|
366
|
+
|
|
367
|
+
def to_dict(self) -> dict:
|
|
368
|
+
"""Convert to dictionary for JSON serialization."""
|
|
369
|
+
return {
|
|
370
|
+
"namespace": self.namespace,
|
|
371
|
+
"name": self.name,
|
|
372
|
+
"description": self.description,
|
|
373
|
+
"url": self.url,
|
|
374
|
+
"author": self.author,
|
|
375
|
+
"version": self.version,
|
|
376
|
+
"downloaded_at": self.downloaded_at.isoformat(),
|
|
377
|
+
"alias": self.alias,
|
|
378
|
+
"instructions": [inst.to_dict() for inst in self.instructions],
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
@classmethod
|
|
382
|
+
def from_dict(cls, data: dict) -> "LibraryRepository":
|
|
383
|
+
"""Create from dictionary (JSON deserialization)."""
|
|
384
|
+
instructions = [LibraryInstruction.from_dict(inst) for inst in data.get("instructions", [])]
|
|
385
|
+
|
|
386
|
+
return cls(
|
|
387
|
+
namespace=data["namespace"],
|
|
388
|
+
name=data["name"],
|
|
389
|
+
description=data["description"],
|
|
390
|
+
url=data["url"],
|
|
391
|
+
author=data["author"],
|
|
392
|
+
version=data["version"],
|
|
393
|
+
downloaded_at=datetime.fromisoformat(data["downloaded_at"]),
|
|
394
|
+
alias=data.get("alias"), # Optional field, may not exist in old data
|
|
395
|
+
instructions=instructions,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# Template Sync System Models
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@dataclass
|
|
403
|
+
class TemplateFile:
|
|
404
|
+
"""
|
|
405
|
+
Represents a template file with IDE targeting.
|
|
406
|
+
|
|
407
|
+
Attributes:
|
|
408
|
+
path: Relative path from repository root
|
|
409
|
+
ide: Target IDE ("all", "cursor", "claude", "windsurf", "copilot", "kiro", "cline")
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
path: str
|
|
413
|
+
ide: str = "all"
|
|
414
|
+
|
|
415
|
+
def __post_init__(self) -> None:
|
|
416
|
+
"""Validate template file data."""
|
|
417
|
+
if not self.path:
|
|
418
|
+
raise ValueError("Template file path cannot be empty")
|
|
419
|
+
valid_ides = ["all", "cursor", "claude", "windsurf", "copilot", "kiro", "cline", "roo"]
|
|
420
|
+
if self.ide not in valid_ides:
|
|
421
|
+
raise ValueError(f"Invalid IDE type: {self.ide}. Must be one of {valid_ides}")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@dataclass
|
|
425
|
+
class TemplateDefinition:
|
|
426
|
+
"""
|
|
427
|
+
Represents a single template definition from manifest.
|
|
428
|
+
|
|
429
|
+
Attributes:
|
|
430
|
+
name: Template identifier
|
|
431
|
+
description: What this template provides
|
|
432
|
+
files: List of template files
|
|
433
|
+
tags: Categorization tags
|
|
434
|
+
dependencies: Other templates required by this one
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
name: str
|
|
438
|
+
description: str
|
|
439
|
+
files: list[TemplateFile]
|
|
440
|
+
tags: list[str] = field(default_factory=list)
|
|
441
|
+
dependencies: list[str] = field(default_factory=list)
|
|
442
|
+
|
|
443
|
+
def __post_init__(self) -> None:
|
|
444
|
+
"""Validate template definition."""
|
|
445
|
+
if not self.name:
|
|
446
|
+
raise ValueError("Template name cannot be empty")
|
|
447
|
+
if not self.description:
|
|
448
|
+
raise ValueError("Template description cannot be empty")
|
|
449
|
+
if not self.files:
|
|
450
|
+
raise ValueError("Template must have at least one file")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@dataclass
|
|
454
|
+
class TemplateBundle:
|
|
455
|
+
"""
|
|
456
|
+
Represents a predefined bundle of templates.
|
|
457
|
+
|
|
458
|
+
Attributes:
|
|
459
|
+
name: Bundle identifier
|
|
460
|
+
description: What this bundle provides
|
|
461
|
+
template_refs: Template names in this bundle
|
|
462
|
+
tags: Categorization tags
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
name: str
|
|
466
|
+
description: str
|
|
467
|
+
template_refs: list[str]
|
|
468
|
+
tags: list[str] = field(default_factory=list)
|
|
469
|
+
|
|
470
|
+
def __post_init__(self) -> None:
|
|
471
|
+
"""Validate template bundle."""
|
|
472
|
+
if not self.name:
|
|
473
|
+
raise ValueError("Bundle name cannot be empty")
|
|
474
|
+
if not self.description:
|
|
475
|
+
raise ValueError("Bundle description cannot be empty")
|
|
476
|
+
if len(self.template_refs) < 2:
|
|
477
|
+
raise ValueError("Bundle must contain at least 2 templates")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@dataclass
|
|
481
|
+
class TemplateManifest:
|
|
482
|
+
"""
|
|
483
|
+
Represents a template repository manifest (templatekit.yaml).
|
|
484
|
+
|
|
485
|
+
Attributes:
|
|
486
|
+
name: Repository name
|
|
487
|
+
description: Repository description
|
|
488
|
+
version: Semantic version
|
|
489
|
+
author: Author or team name
|
|
490
|
+
templates: Available templates
|
|
491
|
+
bundles: Predefined bundles
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
name: str
|
|
495
|
+
description: str
|
|
496
|
+
version: str
|
|
497
|
+
author: Optional[str] = None
|
|
498
|
+
templates: list[TemplateDefinition] = field(default_factory=list)
|
|
499
|
+
bundles: list[TemplateBundle] = field(default_factory=list)
|
|
500
|
+
|
|
501
|
+
def __post_init__(self) -> None:
|
|
502
|
+
"""Validate manifest data."""
|
|
503
|
+
if not self.name:
|
|
504
|
+
raise ValueError("Manifest name cannot be empty")
|
|
505
|
+
if not self.description:
|
|
506
|
+
raise ValueError("Manifest description cannot be empty")
|
|
507
|
+
if not self.version:
|
|
508
|
+
raise ValueError("Manifest version cannot be empty")
|
|
509
|
+
if not self.templates:
|
|
510
|
+
raise ValueError("Manifest must contain at least one template")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@dataclass
|
|
514
|
+
class TemplateInstallationRecord:
|
|
515
|
+
"""
|
|
516
|
+
Tracks an installed template.
|
|
517
|
+
|
|
518
|
+
Attributes:
|
|
519
|
+
id: Unique installation ID (UUID)
|
|
520
|
+
template_name: Installed template identifier
|
|
521
|
+
source_repo: Source repository name
|
|
522
|
+
source_version: Repository version at install
|
|
523
|
+
namespace: Repository namespace (derived from repo name)
|
|
524
|
+
installed_path: Absolute path to installed file
|
|
525
|
+
scope: Installation scope (project or global)
|
|
526
|
+
installed_at: Installation timestamp
|
|
527
|
+
checksum: SHA-256 of installed content
|
|
528
|
+
ide_type: Target IDE for this installation
|
|
529
|
+
custom_metadata: User-defined metadata
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
id: str
|
|
533
|
+
template_name: str
|
|
534
|
+
source_repo: str
|
|
535
|
+
source_version: str
|
|
536
|
+
namespace: str
|
|
537
|
+
installed_path: str
|
|
538
|
+
scope: InstallationScope
|
|
539
|
+
installed_at: datetime
|
|
540
|
+
checksum: str
|
|
541
|
+
ide_type: AIToolType
|
|
542
|
+
custom_metadata: dict[str, str] = field(default_factory=dict)
|
|
543
|
+
|
|
544
|
+
def __post_init__(self) -> None:
|
|
545
|
+
"""Validate installation record."""
|
|
546
|
+
if not self.id:
|
|
547
|
+
raise ValueError("Installation ID cannot be empty")
|
|
548
|
+
if not self.template_name:
|
|
549
|
+
raise ValueError("Template name cannot be empty")
|
|
550
|
+
if not self.source_repo:
|
|
551
|
+
raise ValueError("Source repository cannot be empty")
|
|
552
|
+
if not self.namespace:
|
|
553
|
+
raise ValueError("Namespace cannot be empty")
|
|
554
|
+
if not self.installed_path:
|
|
555
|
+
raise ValueError("Installed path cannot be empty")
|
|
556
|
+
if len(self.checksum) != 64: # SHA-256 is always 64 hex characters
|
|
557
|
+
raise ValueError("Checksum must be a valid SHA-256 hash (64 characters)")
|
|
558
|
+
|
|
559
|
+
def to_dict(self) -> dict:
|
|
560
|
+
"""Convert to dictionary for JSON serialization."""
|
|
561
|
+
return {
|
|
562
|
+
"id": self.id,
|
|
563
|
+
"template_name": self.template_name,
|
|
564
|
+
"source_repo": self.source_repo,
|
|
565
|
+
"source_version": self.source_version,
|
|
566
|
+
"namespace": self.namespace,
|
|
567
|
+
"installed_path": self.installed_path,
|
|
568
|
+
"scope": self.scope.value,
|
|
569
|
+
"installed_at": self.installed_at.isoformat(),
|
|
570
|
+
"checksum": self.checksum,
|
|
571
|
+
"ide_type": self.ide_type.value,
|
|
572
|
+
"custom_metadata": self.custom_metadata,
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
@classmethod
|
|
576
|
+
def from_dict(cls, data: dict) -> "TemplateInstallationRecord":
|
|
577
|
+
"""Create from dictionary (JSON deserialization)."""
|
|
578
|
+
return cls(
|
|
579
|
+
id=data["id"],
|
|
580
|
+
template_name=data["template_name"],
|
|
581
|
+
source_repo=data["source_repo"],
|
|
582
|
+
source_version=data["source_version"],
|
|
583
|
+
namespace=data["namespace"],
|
|
584
|
+
installed_path=data["installed_path"],
|
|
585
|
+
scope=InstallationScope(data["scope"]),
|
|
586
|
+
installed_at=datetime.fromisoformat(data["installed_at"]),
|
|
587
|
+
checksum=data["checksum"],
|
|
588
|
+
ide_type=AIToolType(data["ide_type"]),
|
|
589
|
+
custom_metadata=data.get("custom_metadata", {}),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@dataclass
|
|
594
|
+
class AIAnalysis:
|
|
595
|
+
"""
|
|
596
|
+
AI-provided analysis for validation issues.
|
|
597
|
+
|
|
598
|
+
Attributes:
|
|
599
|
+
confidence: AI confidence (0.0-1.0)
|
|
600
|
+
explanation: Why AI flagged this
|
|
601
|
+
suggested_fix: AI-generated fix
|
|
602
|
+
can_merge: For conflicts: is merge possible?
|
|
603
|
+
merge_suggestion: Merged version if applicable
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
confidence: float
|
|
607
|
+
explanation: str
|
|
608
|
+
suggested_fix: Optional[str] = None
|
|
609
|
+
can_merge: Optional[bool] = None
|
|
610
|
+
merge_suggestion: Optional[str] = None
|
|
611
|
+
|
|
612
|
+
def __post_init__(self) -> None:
|
|
613
|
+
"""Validate AI analysis data."""
|
|
614
|
+
if not 0.0 <= self.confidence <= 1.0:
|
|
615
|
+
raise ValueError("Confidence must be between 0.0 and 1.0")
|
|
616
|
+
if not self.explanation:
|
|
617
|
+
raise ValueError("Explanation cannot be empty")
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@dataclass
|
|
621
|
+
class ValidationIssue:
|
|
622
|
+
"""
|
|
623
|
+
Represents a problem detected during template validation.
|
|
624
|
+
|
|
625
|
+
Attributes:
|
|
626
|
+
issue_type: Type of validation issue
|
|
627
|
+
severity: How critical the issue is
|
|
628
|
+
title: Short description of issue
|
|
629
|
+
description: Detailed explanation
|
|
630
|
+
affected_items: Templates/repos affected
|
|
631
|
+
recommendation: How to fix the issue
|
|
632
|
+
auto_fixable: Can system auto-fix this?
|
|
633
|
+
fix_command: Command to fix issue
|
|
634
|
+
ai_analysis: AI-provided insights
|
|
635
|
+
"""
|
|
636
|
+
|
|
637
|
+
issue_type: IssueType
|
|
638
|
+
severity: IssueSeverity
|
|
639
|
+
title: str
|
|
640
|
+
description: str
|
|
641
|
+
affected_items: list[str]
|
|
642
|
+
recommendation: str
|
|
643
|
+
auto_fixable: bool
|
|
644
|
+
fix_command: Optional[str] = None
|
|
645
|
+
ai_analysis: Optional[AIAnalysis] = None
|
|
646
|
+
|
|
647
|
+
def __post_init__(self) -> None:
|
|
648
|
+
"""Validate validation issue data."""
|
|
649
|
+
if not self.title:
|
|
650
|
+
raise ValueError("Issue title cannot be empty")
|
|
651
|
+
if not self.description:
|
|
652
|
+
raise ValueError("Issue description cannot be empty")
|
|
653
|
+
if not self.affected_items:
|
|
654
|
+
raise ValueError("Issue must affect at least one item")
|
|
655
|
+
if not self.recommendation:
|
|
656
|
+
raise ValueError("Issue recommendation cannot be empty")
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# MCP Server Configuration Management Models
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@dataclass(frozen=True)
|
|
663
|
+
class MCPServer:
|
|
664
|
+
"""
|
|
665
|
+
Represents a single MCP server definition from a template repository.
|
|
666
|
+
|
|
667
|
+
Attributes:
|
|
668
|
+
name: Unique identifier within namespace (alphanumeric, hyphens, underscores)
|
|
669
|
+
command: Executable command to launch MCP server
|
|
670
|
+
args: Command-line arguments for the server
|
|
671
|
+
env: Environment variables (None = requires user configuration)
|
|
672
|
+
namespace: Source template namespace (for namespaced identification)
|
|
673
|
+
"""
|
|
674
|
+
|
|
675
|
+
name: str
|
|
676
|
+
command: str
|
|
677
|
+
args: list[str]
|
|
678
|
+
env: dict[str, Optional[str]]
|
|
679
|
+
namespace: str
|
|
680
|
+
|
|
681
|
+
def __post_init__(self) -> None:
|
|
682
|
+
"""Validate MCP server data."""
|
|
683
|
+
import re
|
|
684
|
+
|
|
685
|
+
if not re.match(r"^[a-zA-Z0-9_-]+$", self.name):
|
|
686
|
+
raise ValueError(f"Invalid server name: {self.name}. Must match ^[a-zA-Z0-9_-]+$")
|
|
687
|
+
if not self.command:
|
|
688
|
+
raise ValueError("Server command cannot be empty")
|
|
689
|
+
# Validate env var names
|
|
690
|
+
for key in self.env.keys():
|
|
691
|
+
if not re.match(r"^[A-Z][A-Z0-9_]*$", key):
|
|
692
|
+
raise ValueError(f"Invalid environment variable name: {key}. Must match ^[A-Z][A-Z0-9_]*$")
|
|
693
|
+
|
|
694
|
+
def get_fully_qualified_name(self) -> str:
|
|
695
|
+
"""Returns {namespace}.{name}."""
|
|
696
|
+
return f"{self.namespace}.{self.name}"
|
|
697
|
+
|
|
698
|
+
def get_required_env_vars(self) -> list[str]:
|
|
699
|
+
"""Returns env var names where value is None."""
|
|
700
|
+
return [key for key, value in self.env.items() if value is None]
|
|
701
|
+
|
|
702
|
+
def has_all_credentials(self, env_config: "EnvironmentConfig") -> bool:
|
|
703
|
+
"""Check if all required env vars are configured."""
|
|
704
|
+
required = self.get_required_env_vars()
|
|
705
|
+
return all(env_config.has(var) for var in required)
|
|
706
|
+
|
|
707
|
+
def to_dict(self) -> dict:
|
|
708
|
+
"""Serialize for JSON."""
|
|
709
|
+
return {
|
|
710
|
+
"name": self.name,
|
|
711
|
+
"command": self.command,
|
|
712
|
+
"args": self.args,
|
|
713
|
+
"env": self.env,
|
|
714
|
+
"namespace": self.namespace,
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
@classmethod
|
|
718
|
+
def from_dict(cls, data: dict, namespace: str) -> "MCPServer":
|
|
719
|
+
"""Deserialize from templatekit.yaml."""
|
|
720
|
+
return cls(
|
|
721
|
+
name=data["name"],
|
|
722
|
+
command=data["command"],
|
|
723
|
+
args=data.get("args", []),
|
|
724
|
+
env=data.get("env", {}),
|
|
725
|
+
namespace=namespace,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@dataclass(frozen=True)
|
|
730
|
+
class MCPSet:
|
|
731
|
+
"""
|
|
732
|
+
A named collection of MCP servers for a specific workflow or task context.
|
|
733
|
+
|
|
734
|
+
Attributes:
|
|
735
|
+
name: Set identifier (e.g., "backend-dev", "frontend-dev")
|
|
736
|
+
description: Human-readable description of set purpose
|
|
737
|
+
server_names: Names of MCP servers included in this set
|
|
738
|
+
namespace: Source template namespace
|
|
739
|
+
"""
|
|
740
|
+
|
|
741
|
+
name: str
|
|
742
|
+
description: str
|
|
743
|
+
server_names: list[str]
|
|
744
|
+
namespace: str
|
|
745
|
+
|
|
746
|
+
def __post_init__(self) -> None:
|
|
747
|
+
"""Validate MCP set data."""
|
|
748
|
+
import re
|
|
749
|
+
|
|
750
|
+
if not re.match(r"^[a-zA-Z0-9_-]+$", self.name):
|
|
751
|
+
raise ValueError(f"Invalid set name: {self.name}. Must match ^[a-zA-Z0-9_-]+$")
|
|
752
|
+
if not self.server_names:
|
|
753
|
+
raise ValueError("Set must contain at least one server")
|
|
754
|
+
|
|
755
|
+
def get_fully_qualified_name(self) -> str:
|
|
756
|
+
"""Returns {namespace}.{name}."""
|
|
757
|
+
return f"{self.namespace}.{self.name}"
|
|
758
|
+
|
|
759
|
+
def resolve_servers(self, all_servers: list[MCPServer]) -> list[MCPServer]:
|
|
760
|
+
"""Get actual server objects."""
|
|
761
|
+
server_map = {s.name: s for s in all_servers}
|
|
762
|
+
resolved = []
|
|
763
|
+
for server_name in self.server_names:
|
|
764
|
+
if server_name not in server_map:
|
|
765
|
+
raise ValueError(f"Set '{self.name}' references unknown server '{server_name}'")
|
|
766
|
+
resolved.append(server_map[server_name])
|
|
767
|
+
return resolved
|
|
768
|
+
|
|
769
|
+
def to_dict(self) -> dict:
|
|
770
|
+
"""Serialize."""
|
|
771
|
+
return {
|
|
772
|
+
"name": self.name,
|
|
773
|
+
"description": self.description,
|
|
774
|
+
"servers": self.server_names,
|
|
775
|
+
"namespace": self.namespace,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
@classmethod
|
|
779
|
+
def from_dict(cls, data: dict, namespace: str) -> "MCPSet":
|
|
780
|
+
"""Deserialize."""
|
|
781
|
+
return cls(
|
|
782
|
+
name=data["name"],
|
|
783
|
+
description=data["description"],
|
|
784
|
+
server_names=data.get("servers", []),
|
|
785
|
+
namespace=namespace,
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@dataclass
|
|
790
|
+
class MCPTemplate:
|
|
791
|
+
"""
|
|
792
|
+
Represents an installed MCP template from a repository.
|
|
793
|
+
|
|
794
|
+
Attributes:
|
|
795
|
+
namespace: Unique identifier for this template
|
|
796
|
+
source_url: Git URL or None for local installs
|
|
797
|
+
source_path: Local directory path or None for Git installs
|
|
798
|
+
version: Template version from templatekit.yaml
|
|
799
|
+
description: Template description
|
|
800
|
+
installed_at: Installation timestamp
|
|
801
|
+
servers: MCP servers defined in template
|
|
802
|
+
sets: MCP sets defined in template
|
|
803
|
+
"""
|
|
804
|
+
|
|
805
|
+
namespace: str
|
|
806
|
+
source_url: Optional[str]
|
|
807
|
+
source_path: Optional[str]
|
|
808
|
+
version: str
|
|
809
|
+
description: str
|
|
810
|
+
installed_at: datetime
|
|
811
|
+
servers: list[MCPServer] = field(default_factory=list)
|
|
812
|
+
sets: list[MCPSet] = field(default_factory=list)
|
|
813
|
+
|
|
814
|
+
def __post_init__(self) -> None:
|
|
815
|
+
"""Validate MCP template data."""
|
|
816
|
+
if not self.namespace:
|
|
817
|
+
raise ValueError("Template namespace cannot be empty")
|
|
818
|
+
if self.source_url and self.source_path:
|
|
819
|
+
raise ValueError("Template cannot have both source_url and source_path")
|
|
820
|
+
if not self.source_url and not self.source_path:
|
|
821
|
+
raise ValueError("Template must have either source_url or source_path")
|
|
822
|
+
|
|
823
|
+
def get_server_by_name(self, name: str) -> Optional[MCPServer]:
|
|
824
|
+
"""Find server by name."""
|
|
825
|
+
for server in self.servers:
|
|
826
|
+
if server.name == name:
|
|
827
|
+
return server
|
|
828
|
+
return None
|
|
829
|
+
|
|
830
|
+
def get_set_by_name(self, name: str) -> Optional[MCPSet]:
|
|
831
|
+
"""Find set by name."""
|
|
832
|
+
for mcp_set in self.sets:
|
|
833
|
+
if mcp_set.name == name:
|
|
834
|
+
return mcp_set
|
|
835
|
+
return None
|
|
836
|
+
|
|
837
|
+
def to_dict(self) -> dict:
|
|
838
|
+
"""Serialize."""
|
|
839
|
+
return {
|
|
840
|
+
"namespace": self.namespace,
|
|
841
|
+
"source_url": self.source_url,
|
|
842
|
+
"source_path": self.source_path,
|
|
843
|
+
"version": self.version,
|
|
844
|
+
"description": self.description,
|
|
845
|
+
"installed_at": self.installed_at.isoformat(),
|
|
846
|
+
"servers": [s.to_dict() for s in self.servers],
|
|
847
|
+
"sets": [s.to_dict() for s in self.sets],
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
@classmethod
|
|
851
|
+
def from_dict(cls, data: dict) -> "MCPTemplate":
|
|
852
|
+
"""Deserialize."""
|
|
853
|
+
namespace = data["namespace"]
|
|
854
|
+
return cls(
|
|
855
|
+
namespace=namespace,
|
|
856
|
+
source_url=data.get("source_url"),
|
|
857
|
+
source_path=data.get("source_path"),
|
|
858
|
+
version=data["version"],
|
|
859
|
+
description=data["description"],
|
|
860
|
+
installed_at=datetime.fromisoformat(data["installed_at"]),
|
|
861
|
+
servers=[MCPServer.from_dict(s, namespace) for s in data.get("servers", [])],
|
|
862
|
+
sets=[MCPSet.from_dict(s, namespace) for s in data.get("sets", [])],
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
@dataclass
|
|
867
|
+
class EnvironmentConfig:
|
|
868
|
+
"""
|
|
869
|
+
Manages environment variables from `.instructionkit/.env` file.
|
|
870
|
+
|
|
871
|
+
Attributes:
|
|
872
|
+
variables: Environment variable name-value pairs
|
|
873
|
+
file_path: Path to .env file
|
|
874
|
+
scope: PROJECT or GLOBAL
|
|
875
|
+
"""
|
|
876
|
+
|
|
877
|
+
variables: dict[str, str] = field(default_factory=dict)
|
|
878
|
+
file_path: Optional[str] = None
|
|
879
|
+
scope: InstallationScope = InstallationScope.PROJECT
|
|
880
|
+
|
|
881
|
+
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
882
|
+
"""Get variable value."""
|
|
883
|
+
return self.variables.get(key, default)
|
|
884
|
+
|
|
885
|
+
def set(self, key: str, value: str) -> None:
|
|
886
|
+
"""Set variable value (validates name)."""
|
|
887
|
+
import re
|
|
888
|
+
|
|
889
|
+
if not re.match(r"^[A-Z][A-Z0-9_]*$", key):
|
|
890
|
+
raise ValueError(f"Invalid environment variable name: {key}. Must match ^[A-Z][A-Z0-9_]*$")
|
|
891
|
+
self.variables[key] = value
|
|
892
|
+
|
|
893
|
+
def has(self, key: str) -> bool:
|
|
894
|
+
"""Check if variable exists."""
|
|
895
|
+
return key in self.variables
|
|
896
|
+
|
|
897
|
+
def validate_for_server(self, server: MCPServer) -> list[str]:
|
|
898
|
+
"""Return list of missing required vars."""
|
|
899
|
+
required = server.get_required_env_vars()
|
|
900
|
+
return [var for var in required if not self.has(var)]
|
|
901
|
+
|
|
902
|
+
def to_dict(self) -> dict[str, str]:
|
|
903
|
+
"""Export as plain dictionary."""
|
|
904
|
+
return self.variables.copy()
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
@dataclass
|
|
908
|
+
class ActiveSetState:
|
|
909
|
+
"""
|
|
910
|
+
Tracks which MCP set is currently active in a project.
|
|
911
|
+
|
|
912
|
+
Attributes:
|
|
913
|
+
namespace: Active set's namespace (None = no active set)
|
|
914
|
+
set_name: Active set's name (None = no active set)
|
|
915
|
+
activated_at: When set was activated
|
|
916
|
+
active_servers: Fully qualified server names currently active
|
|
917
|
+
"""
|
|
918
|
+
|
|
919
|
+
namespace: Optional[str] = None
|
|
920
|
+
set_name: Optional[str] = None
|
|
921
|
+
activated_at: Optional[datetime] = None
|
|
922
|
+
active_servers: list[str] = field(default_factory=list)
|
|
923
|
+
|
|
924
|
+
def __post_init__(self) -> None:
|
|
925
|
+
"""Validate state data."""
|
|
926
|
+
if (self.namespace is None) != (self.set_name is None):
|
|
927
|
+
raise ValueError("namespace and set_name must both be set or both be None")
|
|
928
|
+
if not self.namespace and self.active_servers:
|
|
929
|
+
raise ValueError("active_servers must be empty if no active set")
|
|
930
|
+
|
|
931
|
+
def activate_set(self, mcp_set: MCPSet, servers: list[MCPServer]) -> None:
|
|
932
|
+
"""Set active set."""
|
|
933
|
+
self.namespace = mcp_set.namespace
|
|
934
|
+
self.set_name = mcp_set.name
|
|
935
|
+
self.activated_at = datetime.now()
|
|
936
|
+
self.active_servers = [s.get_fully_qualified_name() for s in servers]
|
|
937
|
+
|
|
938
|
+
def deactivate(self) -> None:
|
|
939
|
+
"""Clear active set."""
|
|
940
|
+
self.namespace = None
|
|
941
|
+
self.set_name = None
|
|
942
|
+
self.activated_at = None
|
|
943
|
+
self.active_servers = []
|
|
944
|
+
|
|
945
|
+
def is_active(self) -> bool:
|
|
946
|
+
"""Check if any set is active."""
|
|
947
|
+
return self.namespace is not None and self.set_name is not None
|
|
948
|
+
|
|
949
|
+
def get_active_set_fqn(self) -> Optional[str]:
|
|
950
|
+
"""Returns 'namespace.set_name' or None."""
|
|
951
|
+
if self.is_active():
|
|
952
|
+
return f"{self.namespace}.{self.set_name}"
|
|
953
|
+
return None
|
|
954
|
+
|
|
955
|
+
def to_dict(self) -> dict:
|
|
956
|
+
"""Serialize."""
|
|
957
|
+
return {
|
|
958
|
+
"namespace": self.namespace,
|
|
959
|
+
"set_name": self.set_name,
|
|
960
|
+
"activated_at": self.activated_at.isoformat() if self.activated_at else None,
|
|
961
|
+
"active_servers": self.active_servers,
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
@classmethod
|
|
965
|
+
def from_dict(cls, data: dict) -> "ActiveSetState":
|
|
966
|
+
"""Deserialize."""
|
|
967
|
+
activated_at = None
|
|
968
|
+
if data.get("activated_at"):
|
|
969
|
+
activated_at = datetime.fromisoformat(data["activated_at"])
|
|
970
|
+
return cls(
|
|
971
|
+
namespace=data.get("namespace"),
|
|
972
|
+
set_name=data.get("set_name"),
|
|
973
|
+
activated_at=activated_at,
|
|
974
|
+
active_servers=data.get("active_servers", []),
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
# ============================================================================
|
|
979
|
+
# Package System Models (Feature 004-config-package)
|
|
980
|
+
# ============================================================================
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
class ComponentType(Enum):
|
|
984
|
+
"""Types of components that can be included in a package."""
|
|
985
|
+
|
|
986
|
+
INSTRUCTION = "instruction"
|
|
987
|
+
MCP_SERVER = "mcp_server"
|
|
988
|
+
HOOK = "hook"
|
|
989
|
+
COMMAND = "command" # Legacy: .claude/commands/
|
|
990
|
+
SKILL = "skill" # Claude skills: .claude/skills/
|
|
991
|
+
WORKFLOW = "workflow" # Windsurf workflows: .windsurf/workflows/
|
|
992
|
+
RESOURCE = "resource"
|
|
993
|
+
MEMORY_FILE = "memory_file" # CLAUDE.md files
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
class InstallationStatus(Enum):
|
|
997
|
+
"""Status of package installation."""
|
|
998
|
+
|
|
999
|
+
INSTALLING = "installing"
|
|
1000
|
+
COMPLETE = "complete"
|
|
1001
|
+
PARTIAL = "partial" # Some components failed
|
|
1002
|
+
UPDATING = "updating"
|
|
1003
|
+
FAILED = "failed"
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
class ComponentStatus(Enum):
|
|
1007
|
+
"""Status of individual component installation."""
|
|
1008
|
+
|
|
1009
|
+
INSTALLED = "installed"
|
|
1010
|
+
FAILED = "failed"
|
|
1011
|
+
SKIPPED = "skipped" # Unsupported by IDE
|
|
1012
|
+
PENDING_CREDENTIALS = "pending_credentials" # MCP missing credentials
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
class SecretConfidence(Enum):
|
|
1016
|
+
"""Confidence level for secret detection."""
|
|
1017
|
+
|
|
1018
|
+
HIGH = "high" # Auto-template
|
|
1019
|
+
MEDIUM = "medium" # Prompt user
|
|
1020
|
+
SAFE = "safe" # Preserve value
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
@dataclass
|
|
1024
|
+
class CredentialDescriptor:
|
|
1025
|
+
"""
|
|
1026
|
+
Declaration of required environment variable for MCP server.
|
|
1027
|
+
|
|
1028
|
+
Attributes:
|
|
1029
|
+
name: Environment variable name (UPPER_SNAKE_CASE)
|
|
1030
|
+
description: What the credential is for
|
|
1031
|
+
required: Whether credential is mandatory
|
|
1032
|
+
default: Default value if not required
|
|
1033
|
+
example: Example value for guidance
|
|
1034
|
+
"""
|
|
1035
|
+
|
|
1036
|
+
name: str
|
|
1037
|
+
description: str
|
|
1038
|
+
required: bool = True
|
|
1039
|
+
default: Optional[str] = None
|
|
1040
|
+
example: Optional[str] = None
|
|
1041
|
+
|
|
1042
|
+
def __post_init__(self) -> None:
|
|
1043
|
+
"""Validate credential descriptor."""
|
|
1044
|
+
if not self.name:
|
|
1045
|
+
raise ValueError("Credential name cannot be empty")
|
|
1046
|
+
if not self.name.isupper() or not self.name.replace("_", "").isalnum():
|
|
1047
|
+
raise ValueError(f"Credential name '{self.name}' must be UPPER_SNAKE_CASE")
|
|
1048
|
+
if self.required and self.default:
|
|
1049
|
+
raise ValueError("Required credentials cannot have default values")
|
|
1050
|
+
|
|
1051
|
+
def to_dict(self) -> dict:
|
|
1052
|
+
"""Serialize to dictionary."""
|
|
1053
|
+
return {
|
|
1054
|
+
"name": self.name,
|
|
1055
|
+
"description": self.description,
|
|
1056
|
+
"required": self.required,
|
|
1057
|
+
"default": self.default,
|
|
1058
|
+
"example": self.example,
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
@classmethod
|
|
1062
|
+
def from_dict(cls, data: dict) -> "CredentialDescriptor":
|
|
1063
|
+
"""Deserialize from dictionary."""
|
|
1064
|
+
return cls(
|
|
1065
|
+
name=data["name"],
|
|
1066
|
+
description=data["description"],
|
|
1067
|
+
required=data.get("required", True),
|
|
1068
|
+
default=data.get("default"),
|
|
1069
|
+
example=data.get("example"),
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
@dataclass
|
|
1074
|
+
class InstructionComponent:
|
|
1075
|
+
"""
|
|
1076
|
+
Reference to an instruction file in a package.
|
|
1077
|
+
|
|
1078
|
+
Attributes:
|
|
1079
|
+
name: Instruction identifier
|
|
1080
|
+
file: Relative path to instruction file
|
|
1081
|
+
description: What the instruction does
|
|
1082
|
+
tags: Searchable tags
|
|
1083
|
+
ide_support: Specific IDE support (if restricted)
|
|
1084
|
+
"""
|
|
1085
|
+
|
|
1086
|
+
name: str
|
|
1087
|
+
file: str
|
|
1088
|
+
description: str
|
|
1089
|
+
tags: list[str] = field(default_factory=list)
|
|
1090
|
+
ide_support: Optional[list[str]] = None
|
|
1091
|
+
|
|
1092
|
+
def to_dict(self) -> dict:
|
|
1093
|
+
"""Serialize to dictionary."""
|
|
1094
|
+
return {
|
|
1095
|
+
"name": self.name,
|
|
1096
|
+
"file": self.file,
|
|
1097
|
+
"description": self.description,
|
|
1098
|
+
"tags": self.tags,
|
|
1099
|
+
"ide_support": self.ide_support,
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
@classmethod
|
|
1103
|
+
def from_dict(cls, data: dict) -> "InstructionComponent":
|
|
1104
|
+
"""Deserialize from dictionary."""
|
|
1105
|
+
return cls(
|
|
1106
|
+
name=data["name"],
|
|
1107
|
+
file=data["file"],
|
|
1108
|
+
description=data["description"],
|
|
1109
|
+
tags=data.get("tags", []),
|
|
1110
|
+
ide_support=data.get("ide_support"),
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
@dataclass
|
|
1115
|
+
class MCPServerComponent:
|
|
1116
|
+
"""
|
|
1117
|
+
Reference to an MCP server configuration template.
|
|
1118
|
+
|
|
1119
|
+
Attributes:
|
|
1120
|
+
name: Server identifier
|
|
1121
|
+
file: Relative path to MCP config template
|
|
1122
|
+
description: What the server provides
|
|
1123
|
+
credentials: Required environment variables
|
|
1124
|
+
ide_support: IDEs that support MCP
|
|
1125
|
+
"""
|
|
1126
|
+
|
|
1127
|
+
name: str
|
|
1128
|
+
file: str
|
|
1129
|
+
description: str
|
|
1130
|
+
credentials: list[CredentialDescriptor] = field(default_factory=list)
|
|
1131
|
+
ide_support: list[str] = field(default_factory=lambda: ["claude_code", "windsurf"])
|
|
1132
|
+
|
|
1133
|
+
def to_dict(self) -> dict:
|
|
1134
|
+
"""Serialize to dictionary."""
|
|
1135
|
+
return {
|
|
1136
|
+
"name": self.name,
|
|
1137
|
+
"file": self.file,
|
|
1138
|
+
"description": self.description,
|
|
1139
|
+
"credentials": [c.to_dict() for c in self.credentials],
|
|
1140
|
+
"ide_support": self.ide_support,
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
@classmethod
|
|
1144
|
+
def from_dict(cls, data: dict) -> "MCPServerComponent":
|
|
1145
|
+
"""Deserialize from dictionary."""
|
|
1146
|
+
return cls(
|
|
1147
|
+
name=data["name"],
|
|
1148
|
+
file=data["file"],
|
|
1149
|
+
description=data["description"],
|
|
1150
|
+
credentials=[CredentialDescriptor.from_dict(c) for c in data.get("credentials", [])],
|
|
1151
|
+
ide_support=data.get("ide_support", ["claude_code", "windsurf"]),
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
@dataclass
|
|
1156
|
+
class HookComponent:
|
|
1157
|
+
"""
|
|
1158
|
+
Reference to an IDE lifecycle hook script.
|
|
1159
|
+
|
|
1160
|
+
Attributes:
|
|
1161
|
+
name: Hook identifier
|
|
1162
|
+
file: Relative path to hook script
|
|
1163
|
+
description: What the hook does
|
|
1164
|
+
hook_type: Hook trigger (e.g., pre-commit, post-install)
|
|
1165
|
+
ide_support: IDEs that support hooks
|
|
1166
|
+
"""
|
|
1167
|
+
|
|
1168
|
+
name: str
|
|
1169
|
+
file: str
|
|
1170
|
+
description: str
|
|
1171
|
+
hook_type: str
|
|
1172
|
+
ide_support: list[str] = field(default_factory=lambda: ["claude_code"])
|
|
1173
|
+
|
|
1174
|
+
def to_dict(self) -> dict:
|
|
1175
|
+
"""Serialize to dictionary."""
|
|
1176
|
+
return {
|
|
1177
|
+
"name": self.name,
|
|
1178
|
+
"file": self.file,
|
|
1179
|
+
"description": self.description,
|
|
1180
|
+
"hook_type": self.hook_type,
|
|
1181
|
+
"ide_support": self.ide_support,
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
@classmethod
|
|
1185
|
+
def from_dict(cls, data: dict) -> "HookComponent":
|
|
1186
|
+
"""Deserialize from dictionary."""
|
|
1187
|
+
return cls(
|
|
1188
|
+
name=data["name"],
|
|
1189
|
+
file=data["file"],
|
|
1190
|
+
description=data["description"],
|
|
1191
|
+
hook_type=data["hook_type"],
|
|
1192
|
+
ide_support=data.get("ide_support", ["claude_code"]),
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
@dataclass
|
|
1197
|
+
class CommandComponent:
|
|
1198
|
+
"""
|
|
1199
|
+
Reference to a slash command or script.
|
|
1200
|
+
|
|
1201
|
+
Attributes:
|
|
1202
|
+
name: Command identifier
|
|
1203
|
+
file: Relative path to command script
|
|
1204
|
+
description: What the command does
|
|
1205
|
+
command_type: Type (slash, shell)
|
|
1206
|
+
ide_support: IDEs that support commands
|
|
1207
|
+
"""
|
|
1208
|
+
|
|
1209
|
+
name: str
|
|
1210
|
+
file: str
|
|
1211
|
+
description: str
|
|
1212
|
+
command_type: str
|
|
1213
|
+
ide_support: list[str] = field(default_factory=list)
|
|
1214
|
+
|
|
1215
|
+
def to_dict(self) -> dict:
|
|
1216
|
+
"""Serialize to dictionary."""
|
|
1217
|
+
return {
|
|
1218
|
+
"name": self.name,
|
|
1219
|
+
"file": self.file,
|
|
1220
|
+
"description": self.description,
|
|
1221
|
+
"command_type": self.command_type,
|
|
1222
|
+
"ide_support": self.ide_support,
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
@classmethod
|
|
1226
|
+
def from_dict(cls, data: dict) -> "CommandComponent":
|
|
1227
|
+
"""Deserialize from dictionary."""
|
|
1228
|
+
return cls(
|
|
1229
|
+
name=data["name"],
|
|
1230
|
+
file=data["file"],
|
|
1231
|
+
description=data["description"],
|
|
1232
|
+
command_type=data["command_type"],
|
|
1233
|
+
ide_support=data.get("ide_support", []),
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
@dataclass
|
|
1238
|
+
class ResourceComponent:
|
|
1239
|
+
"""
|
|
1240
|
+
Reference to an arbitrary file resource.
|
|
1241
|
+
|
|
1242
|
+
Attributes:
|
|
1243
|
+
name: Resource identifier
|
|
1244
|
+
file: Relative path to resource file
|
|
1245
|
+
description: What the resource is
|
|
1246
|
+
install_path: Where to install the resource (defaults to file path)
|
|
1247
|
+
checksum: SHA256 checksum for integrity
|
|
1248
|
+
size: File size in bytes
|
|
1249
|
+
"""
|
|
1250
|
+
|
|
1251
|
+
name: str
|
|
1252
|
+
file: str
|
|
1253
|
+
description: str
|
|
1254
|
+
install_path: str
|
|
1255
|
+
checksum: str
|
|
1256
|
+
size: int
|
|
1257
|
+
|
|
1258
|
+
def to_dict(self) -> dict:
|
|
1259
|
+
"""Serialize to dictionary."""
|
|
1260
|
+
return {
|
|
1261
|
+
"name": self.name,
|
|
1262
|
+
"file": self.file,
|
|
1263
|
+
"description": self.description,
|
|
1264
|
+
"install_path": self.install_path,
|
|
1265
|
+
"checksum": self.checksum,
|
|
1266
|
+
"size": self.size,
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
@classmethod
|
|
1270
|
+
def from_dict(cls, data: dict) -> "ResourceComponent":
|
|
1271
|
+
"""Deserialize from dictionary."""
|
|
1272
|
+
return cls(
|
|
1273
|
+
name=data["name"],
|
|
1274
|
+
file=data["file"],
|
|
1275
|
+
description=data["description"],
|
|
1276
|
+
install_path=data.get("install_path", data["file"]), # Default to file path if not specified
|
|
1277
|
+
checksum=data["checksum"],
|
|
1278
|
+
size=data["size"],
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
@dataclass
|
|
1283
|
+
class SkillComponent:
|
|
1284
|
+
"""
|
|
1285
|
+
Reference to a Claude skill directory.
|
|
1286
|
+
|
|
1287
|
+
Skills are directories containing SKILL.md with optional supporting files.
|
|
1288
|
+
They can be invoked via slash commands and are shared across Claude products.
|
|
1289
|
+
|
|
1290
|
+
Attributes:
|
|
1291
|
+
name: Skill identifier (directory name)
|
|
1292
|
+
file: Relative path to skill directory
|
|
1293
|
+
description: What the skill does (from SKILL.md frontmatter)
|
|
1294
|
+
ide_support: IDEs that support skills (Claude only)
|
|
1295
|
+
"""
|
|
1296
|
+
|
|
1297
|
+
name: str
|
|
1298
|
+
file: str
|
|
1299
|
+
description: str
|
|
1300
|
+
ide_support: list[str] = field(default_factory=lambda: ["claude"])
|
|
1301
|
+
|
|
1302
|
+
def to_dict(self) -> dict:
|
|
1303
|
+
"""Serialize to dictionary."""
|
|
1304
|
+
return {
|
|
1305
|
+
"name": self.name,
|
|
1306
|
+
"file": self.file,
|
|
1307
|
+
"description": self.description,
|
|
1308
|
+
"ide_support": self.ide_support,
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
@classmethod
|
|
1312
|
+
def from_dict(cls, data: dict) -> "SkillComponent":
|
|
1313
|
+
"""Deserialize from dictionary."""
|
|
1314
|
+
return cls(
|
|
1315
|
+
name=data["name"],
|
|
1316
|
+
file=data["file"],
|
|
1317
|
+
description=data["description"],
|
|
1318
|
+
ide_support=data.get("ide_support", ["claude"]),
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
@dataclass
|
|
1323
|
+
class WorkflowComponent:
|
|
1324
|
+
"""
|
|
1325
|
+
Reference to a Windsurf workflow.
|
|
1326
|
+
|
|
1327
|
+
Workflows define multi-step automated processes in Windsurf.
|
|
1328
|
+
|
|
1329
|
+
Attributes:
|
|
1330
|
+
name: Workflow identifier
|
|
1331
|
+
file: Relative path to workflow file
|
|
1332
|
+
description: What the workflow does
|
|
1333
|
+
ide_support: IDEs that support workflows (Windsurf only)
|
|
1334
|
+
"""
|
|
1335
|
+
|
|
1336
|
+
name: str
|
|
1337
|
+
file: str
|
|
1338
|
+
description: str
|
|
1339
|
+
ide_support: list[str] = field(default_factory=lambda: ["windsurf"])
|
|
1340
|
+
|
|
1341
|
+
def to_dict(self) -> dict:
|
|
1342
|
+
"""Serialize to dictionary."""
|
|
1343
|
+
return {
|
|
1344
|
+
"name": self.name,
|
|
1345
|
+
"file": self.file,
|
|
1346
|
+
"description": self.description,
|
|
1347
|
+
"ide_support": self.ide_support,
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
@classmethod
|
|
1351
|
+
def from_dict(cls, data: dict) -> "WorkflowComponent":
|
|
1352
|
+
"""Deserialize from dictionary."""
|
|
1353
|
+
return cls(
|
|
1354
|
+
name=data["name"],
|
|
1355
|
+
file=data["file"],
|
|
1356
|
+
description=data["description"],
|
|
1357
|
+
ide_support=data.get("ide_support", ["windsurf"]),
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
@dataclass
|
|
1362
|
+
class MemoryFileComponent:
|
|
1363
|
+
"""
|
|
1364
|
+
Reference to a CLAUDE.md memory file.
|
|
1365
|
+
|
|
1366
|
+
Memory files persist context across Claude Code sessions and can exist
|
|
1367
|
+
at project root or in subdirectories.
|
|
1368
|
+
|
|
1369
|
+
Attributes:
|
|
1370
|
+
name: File identifier (usually "CLAUDE" or path-based)
|
|
1371
|
+
file: Relative path to CLAUDE.md file
|
|
1372
|
+
description: What context the memory file provides
|
|
1373
|
+
ide_support: IDEs that support memory files (Claude only)
|
|
1374
|
+
"""
|
|
1375
|
+
|
|
1376
|
+
name: str
|
|
1377
|
+
file: str
|
|
1378
|
+
description: str
|
|
1379
|
+
ide_support: list[str] = field(default_factory=lambda: ["claude"])
|
|
1380
|
+
|
|
1381
|
+
def to_dict(self) -> dict:
|
|
1382
|
+
"""Serialize to dictionary."""
|
|
1383
|
+
return {
|
|
1384
|
+
"name": self.name,
|
|
1385
|
+
"file": self.file,
|
|
1386
|
+
"description": self.description,
|
|
1387
|
+
"ide_support": self.ide_support,
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
@classmethod
|
|
1391
|
+
def from_dict(cls, data: dict) -> "MemoryFileComponent":
|
|
1392
|
+
"""Deserialize from dictionary."""
|
|
1393
|
+
return cls(
|
|
1394
|
+
name=data["name"],
|
|
1395
|
+
file=data["file"],
|
|
1396
|
+
description=data["description"],
|
|
1397
|
+
ide_support=data.get("ide_support", ["claude"]),
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
@dataclass
|
|
1402
|
+
class PackageComponents:
|
|
1403
|
+
"""
|
|
1404
|
+
Container for all component types in a package.
|
|
1405
|
+
|
|
1406
|
+
Attributes:
|
|
1407
|
+
instructions: Instruction files
|
|
1408
|
+
mcp_servers: MCP server configs
|
|
1409
|
+
hooks: IDE lifecycle hooks
|
|
1410
|
+
commands: Slash commands/scripts (legacy)
|
|
1411
|
+
skills: Claude skills (directories with SKILL.md)
|
|
1412
|
+
workflows: Windsurf workflows
|
|
1413
|
+
memory_files: CLAUDE.md memory files
|
|
1414
|
+
resources: Arbitrary files
|
|
1415
|
+
"""
|
|
1416
|
+
|
|
1417
|
+
instructions: list[InstructionComponent] = field(default_factory=list)
|
|
1418
|
+
mcp_servers: list[MCPServerComponent] = field(default_factory=list)
|
|
1419
|
+
hooks: list[HookComponent] = field(default_factory=list)
|
|
1420
|
+
commands: list[CommandComponent] = field(default_factory=list)
|
|
1421
|
+
skills: list[SkillComponent] = field(default_factory=list)
|
|
1422
|
+
workflows: list[WorkflowComponent] = field(default_factory=list)
|
|
1423
|
+
memory_files: list[MemoryFileComponent] = field(default_factory=list)
|
|
1424
|
+
resources: list[ResourceComponent] = field(default_factory=list)
|
|
1425
|
+
|
|
1426
|
+
@property
|
|
1427
|
+
def total_count(self) -> int:
|
|
1428
|
+
"""Total number of components."""
|
|
1429
|
+
return (
|
|
1430
|
+
len(self.instructions)
|
|
1431
|
+
+ len(self.mcp_servers)
|
|
1432
|
+
+ len(self.hooks)
|
|
1433
|
+
+ len(self.commands)
|
|
1434
|
+
+ len(self.skills)
|
|
1435
|
+
+ len(self.workflows)
|
|
1436
|
+
+ len(self.memory_files)
|
|
1437
|
+
+ len(self.resources)
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
@property
|
|
1441
|
+
def component_types(self) -> list[str]:
|
|
1442
|
+
"""List of component types present."""
|
|
1443
|
+
types = []
|
|
1444
|
+
if self.instructions:
|
|
1445
|
+
types.append("instructions")
|
|
1446
|
+
if self.mcp_servers:
|
|
1447
|
+
types.append("mcp_servers")
|
|
1448
|
+
if self.hooks:
|
|
1449
|
+
types.append("hooks")
|
|
1450
|
+
if self.commands:
|
|
1451
|
+
types.append("commands")
|
|
1452
|
+
if self.skills:
|
|
1453
|
+
types.append("skills")
|
|
1454
|
+
if self.workflows:
|
|
1455
|
+
types.append("workflows")
|
|
1456
|
+
if self.memory_files:
|
|
1457
|
+
types.append("memory_files")
|
|
1458
|
+
if self.resources:
|
|
1459
|
+
types.append("resources")
|
|
1460
|
+
return types
|
|
1461
|
+
|
|
1462
|
+
def __post_init__(self) -> None:
|
|
1463
|
+
"""Validate components (currently allows empty packages for testing)."""
|
|
1464
|
+
# Note: Validation for at least one component is done in PackageManifestParser.validate()
|
|
1465
|
+
# but we allow empty packages here for edge case testing
|
|
1466
|
+
pass
|
|
1467
|
+
|
|
1468
|
+
def to_dict(self) -> dict:
|
|
1469
|
+
"""Serialize to dictionary."""
|
|
1470
|
+
return {
|
|
1471
|
+
"instructions": [i.to_dict() for i in self.instructions],
|
|
1472
|
+
"mcp_servers": [m.to_dict() for m in self.mcp_servers],
|
|
1473
|
+
"hooks": [h.to_dict() for h in self.hooks],
|
|
1474
|
+
"commands": [c.to_dict() for c in self.commands],
|
|
1475
|
+
"skills": [s.to_dict() for s in self.skills],
|
|
1476
|
+
"workflows": [w.to_dict() for w in self.workflows],
|
|
1477
|
+
"memory_files": [m.to_dict() for m in self.memory_files],
|
|
1478
|
+
"resources": [r.to_dict() for r in self.resources],
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
@classmethod
|
|
1482
|
+
def from_dict(cls, data: dict) -> "PackageComponents":
|
|
1483
|
+
"""Deserialize from dictionary."""
|
|
1484
|
+
return cls(
|
|
1485
|
+
instructions=[InstructionComponent.from_dict(i) for i in data.get("instructions", [])],
|
|
1486
|
+
mcp_servers=[MCPServerComponent.from_dict(m) for m in data.get("mcp_servers", [])],
|
|
1487
|
+
hooks=[HookComponent.from_dict(h) for h in data.get("hooks", [])],
|
|
1488
|
+
commands=[CommandComponent.from_dict(c) for c in data.get("commands", [])],
|
|
1489
|
+
skills=[SkillComponent.from_dict(s) for s in data.get("skills", [])],
|
|
1490
|
+
workflows=[WorkflowComponent.from_dict(w) for w in data.get("workflows", [])],
|
|
1491
|
+
memory_files=[MemoryFileComponent.from_dict(m) for m in data.get("memory_files", [])],
|
|
1492
|
+
resources=[ResourceComponent.from_dict(r) for r in data.get("resources", [])],
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
@dataclass
|
|
1497
|
+
class Package:
|
|
1498
|
+
"""
|
|
1499
|
+
A bundle of related configuration components with metadata.
|
|
1500
|
+
|
|
1501
|
+
Attributes:
|
|
1502
|
+
name: Package identifier (lowercase, hyphenated)
|
|
1503
|
+
version: Semantic version (major.minor.patch)
|
|
1504
|
+
description: Human-readable description
|
|
1505
|
+
author: Package author/maintainer
|
|
1506
|
+
license: License identifier (e.g., MIT, Apache-2.0)
|
|
1507
|
+
namespace: Repository namespace (e.g., owner/repo)
|
|
1508
|
+
components: Included components
|
|
1509
|
+
created_at: Package creation timestamp
|
|
1510
|
+
updated_at: Last update timestamp
|
|
1511
|
+
"""
|
|
1512
|
+
|
|
1513
|
+
name: str
|
|
1514
|
+
version: str
|
|
1515
|
+
description: str
|
|
1516
|
+
author: str
|
|
1517
|
+
license: str
|
|
1518
|
+
namespace: str
|
|
1519
|
+
components: PackageComponents
|
|
1520
|
+
created_at: Optional[datetime] = None
|
|
1521
|
+
updated_at: Optional[datetime] = None
|
|
1522
|
+
|
|
1523
|
+
def __post_init__(self) -> None:
|
|
1524
|
+
"""Validate package data."""
|
|
1525
|
+
if not self.name:
|
|
1526
|
+
raise ValueError("Package name cannot be empty")
|
|
1527
|
+
if not self.name.replace("-", "").replace("_", "").isalnum():
|
|
1528
|
+
raise ValueError(f"Package name '{self.name}' must be lowercase alphanumeric with hyphens")
|
|
1529
|
+
if not self.version:
|
|
1530
|
+
raise ValueError("Package version cannot be empty")
|
|
1531
|
+
if not self.namespace:
|
|
1532
|
+
raise ValueError("Package namespace cannot be empty")
|
|
1533
|
+
|
|
1534
|
+
def to_dict(self) -> dict:
|
|
1535
|
+
"""Serialize to dictionary."""
|
|
1536
|
+
return {
|
|
1537
|
+
"name": self.name,
|
|
1538
|
+
"version": self.version,
|
|
1539
|
+
"description": self.description,
|
|
1540
|
+
"author": self.author,
|
|
1541
|
+
"license": self.license,
|
|
1542
|
+
"namespace": self.namespace,
|
|
1543
|
+
"components": self.components.to_dict(),
|
|
1544
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
1545
|
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
@classmethod
|
|
1549
|
+
def from_dict(cls, data: dict) -> "Package":
|
|
1550
|
+
"""Deserialize from dictionary."""
|
|
1551
|
+
created_at = None
|
|
1552
|
+
if data.get("created_at"):
|
|
1553
|
+
created_at = datetime.fromisoformat(data["created_at"])
|
|
1554
|
+
updated_at = None
|
|
1555
|
+
if data.get("updated_at"):
|
|
1556
|
+
updated_at = datetime.fromisoformat(data["updated_at"])
|
|
1557
|
+
return cls(
|
|
1558
|
+
name=data["name"],
|
|
1559
|
+
version=data["version"],
|
|
1560
|
+
description=data["description"],
|
|
1561
|
+
author=data["author"],
|
|
1562
|
+
license=data["license"],
|
|
1563
|
+
namespace=data["namespace"],
|
|
1564
|
+
components=PackageComponents.from_dict(data["components"]),
|
|
1565
|
+
created_at=created_at,
|
|
1566
|
+
updated_at=updated_at,
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
@dataclass
|
|
1571
|
+
class InstalledComponent:
|
|
1572
|
+
"""
|
|
1573
|
+
Tracks individual installed component within a package.
|
|
1574
|
+
|
|
1575
|
+
Attributes:
|
|
1576
|
+
type: Component type
|
|
1577
|
+
name: Component name
|
|
1578
|
+
installed_path: Relative path where installed
|
|
1579
|
+
checksum: File checksum for update detection
|
|
1580
|
+
status: Installation status
|
|
1581
|
+
"""
|
|
1582
|
+
|
|
1583
|
+
type: ComponentType
|
|
1584
|
+
name: str
|
|
1585
|
+
installed_path: str
|
|
1586
|
+
checksum: str
|
|
1587
|
+
status: ComponentStatus
|
|
1588
|
+
|
|
1589
|
+
def to_dict(self) -> dict:
|
|
1590
|
+
"""Serialize to dictionary."""
|
|
1591
|
+
return {
|
|
1592
|
+
"type": self.type.value,
|
|
1593
|
+
"name": self.name,
|
|
1594
|
+
"installed_path": self.installed_path,
|
|
1595
|
+
"checksum": self.checksum,
|
|
1596
|
+
"status": self.status.value,
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
@classmethod
|
|
1600
|
+
def from_dict(cls, data: dict) -> "InstalledComponent":
|
|
1601
|
+
"""Deserialize from dictionary."""
|
|
1602
|
+
return cls(
|
|
1603
|
+
type=ComponentType(data["type"]),
|
|
1604
|
+
name=data["name"],
|
|
1605
|
+
installed_path=data["installed_path"],
|
|
1606
|
+
checksum=data["checksum"],
|
|
1607
|
+
status=ComponentStatus(data["status"]),
|
|
1608
|
+
)
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
@dataclass
|
|
1612
|
+
class PackageInstallationRecord:
|
|
1613
|
+
"""
|
|
1614
|
+
Tracks installed package in a project.
|
|
1615
|
+
|
|
1616
|
+
Attributes:
|
|
1617
|
+
package_name: Package identifier
|
|
1618
|
+
namespace: Repository namespace
|
|
1619
|
+
version: Installed version
|
|
1620
|
+
installed_at: Installation timestamp
|
|
1621
|
+
updated_at: Last update timestamp
|
|
1622
|
+
scope: Installation scope (project_level)
|
|
1623
|
+
components: Installed component details
|
|
1624
|
+
status: Installation state
|
|
1625
|
+
"""
|
|
1626
|
+
|
|
1627
|
+
package_name: str
|
|
1628
|
+
namespace: str
|
|
1629
|
+
version: str
|
|
1630
|
+
installed_at: datetime
|
|
1631
|
+
updated_at: datetime
|
|
1632
|
+
scope: InstallationScope
|
|
1633
|
+
components: list[InstalledComponent]
|
|
1634
|
+
status: InstallationStatus
|
|
1635
|
+
|
|
1636
|
+
def to_dict(self) -> dict:
|
|
1637
|
+
"""Serialize to dictionary."""
|
|
1638
|
+
return {
|
|
1639
|
+
"package_name": self.package_name,
|
|
1640
|
+
"namespace": self.namespace,
|
|
1641
|
+
"version": self.version,
|
|
1642
|
+
"installed_at": self.installed_at.isoformat(),
|
|
1643
|
+
"updated_at": self.updated_at.isoformat(),
|
|
1644
|
+
"scope": self.scope.value,
|
|
1645
|
+
"components": [c.to_dict() for c in self.components],
|
|
1646
|
+
"status": self.status.value,
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
@classmethod
|
|
1650
|
+
def from_dict(cls, data: dict) -> "PackageInstallationRecord":
|
|
1651
|
+
"""Deserialize from dictionary."""
|
|
1652
|
+
return cls(
|
|
1653
|
+
package_name=data["package_name"],
|
|
1654
|
+
namespace=data["namespace"],
|
|
1655
|
+
version=data["version"],
|
|
1656
|
+
installed_at=datetime.fromisoformat(data["installed_at"]),
|
|
1657
|
+
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
1658
|
+
scope=InstallationScope(data["scope"]),
|
|
1659
|
+
components=[InstalledComponent.from_dict(c) for c in data.get("components", [])],
|
|
1660
|
+
status=InstallationStatus(data["status"]),
|
|
1661
|
+
)
|