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.
Files changed (84) hide show
  1. aiconfigkit/__init__.py +0 -0
  2. aiconfigkit/__main__.py +6 -0
  3. aiconfigkit/ai_tools/__init__.py +0 -0
  4. aiconfigkit/ai_tools/base.py +236 -0
  5. aiconfigkit/ai_tools/capability_registry.py +262 -0
  6. aiconfigkit/ai_tools/claude.py +91 -0
  7. aiconfigkit/ai_tools/claude_desktop.py +97 -0
  8. aiconfigkit/ai_tools/cline.py +92 -0
  9. aiconfigkit/ai_tools/copilot.py +92 -0
  10. aiconfigkit/ai_tools/cursor.py +109 -0
  11. aiconfigkit/ai_tools/detector.py +169 -0
  12. aiconfigkit/ai_tools/kiro.py +85 -0
  13. aiconfigkit/ai_tools/mcp_syncer.py +291 -0
  14. aiconfigkit/ai_tools/roo.py +110 -0
  15. aiconfigkit/ai_tools/translator.py +390 -0
  16. aiconfigkit/ai_tools/winsurf.py +102 -0
  17. aiconfigkit/cli/__init__.py +0 -0
  18. aiconfigkit/cli/delete.py +118 -0
  19. aiconfigkit/cli/download.py +274 -0
  20. aiconfigkit/cli/install.py +237 -0
  21. aiconfigkit/cli/install_new.py +937 -0
  22. aiconfigkit/cli/list.py +275 -0
  23. aiconfigkit/cli/main.py +454 -0
  24. aiconfigkit/cli/mcp_configure.py +232 -0
  25. aiconfigkit/cli/mcp_install.py +166 -0
  26. aiconfigkit/cli/mcp_sync.py +165 -0
  27. aiconfigkit/cli/package.py +383 -0
  28. aiconfigkit/cli/package_create.py +323 -0
  29. aiconfigkit/cli/package_install.py +472 -0
  30. aiconfigkit/cli/template.py +19 -0
  31. aiconfigkit/cli/template_backup.py +261 -0
  32. aiconfigkit/cli/template_init.py +499 -0
  33. aiconfigkit/cli/template_install.py +261 -0
  34. aiconfigkit/cli/template_list.py +172 -0
  35. aiconfigkit/cli/template_uninstall.py +146 -0
  36. aiconfigkit/cli/template_update.py +225 -0
  37. aiconfigkit/cli/template_validate.py +234 -0
  38. aiconfigkit/cli/tools.py +47 -0
  39. aiconfigkit/cli/uninstall.py +125 -0
  40. aiconfigkit/cli/update.py +309 -0
  41. aiconfigkit/core/__init__.py +0 -0
  42. aiconfigkit/core/checksum.py +211 -0
  43. aiconfigkit/core/component_detector.py +905 -0
  44. aiconfigkit/core/conflict_resolution.py +329 -0
  45. aiconfigkit/core/git_operations.py +539 -0
  46. aiconfigkit/core/mcp/__init__.py +1 -0
  47. aiconfigkit/core/mcp/credentials.py +279 -0
  48. aiconfigkit/core/mcp/manager.py +308 -0
  49. aiconfigkit/core/mcp/set_manager.py +1 -0
  50. aiconfigkit/core/mcp/validator.py +1 -0
  51. aiconfigkit/core/models.py +1661 -0
  52. aiconfigkit/core/package_creator.py +743 -0
  53. aiconfigkit/core/package_manifest.py +248 -0
  54. aiconfigkit/core/repository.py +298 -0
  55. aiconfigkit/core/secret_detector.py +438 -0
  56. aiconfigkit/core/template_manifest.py +283 -0
  57. aiconfigkit/core/version.py +201 -0
  58. aiconfigkit/storage/__init__.py +0 -0
  59. aiconfigkit/storage/library.py +429 -0
  60. aiconfigkit/storage/mcp_tracker.py +1 -0
  61. aiconfigkit/storage/package_tracker.py +234 -0
  62. aiconfigkit/storage/template_library.py +229 -0
  63. aiconfigkit/storage/template_tracker.py +296 -0
  64. aiconfigkit/storage/tracker.py +416 -0
  65. aiconfigkit/tui/__init__.py +5 -0
  66. aiconfigkit/tui/installer.py +511 -0
  67. aiconfigkit/utils/__init__.py +0 -0
  68. aiconfigkit/utils/atomic_write.py +90 -0
  69. aiconfigkit/utils/backup.py +169 -0
  70. aiconfigkit/utils/dotenv.py +128 -0
  71. aiconfigkit/utils/git_helpers.py +187 -0
  72. aiconfigkit/utils/logging.py +60 -0
  73. aiconfigkit/utils/namespace.py +134 -0
  74. aiconfigkit/utils/paths.py +205 -0
  75. aiconfigkit/utils/project.py +109 -0
  76. aiconfigkit/utils/streaming.py +216 -0
  77. aiconfigkit/utils/ui.py +194 -0
  78. aiconfigkit/utils/validation.py +187 -0
  79. devsync-0.5.5.dist-info/LICENSE +21 -0
  80. devsync-0.5.5.dist-info/METADATA +477 -0
  81. devsync-0.5.5.dist-info/RECORD +84 -0
  82. devsync-0.5.5.dist-info/WHEEL +5 -0
  83. devsync-0.5.5.dist-info/entry_points.txt +2 -0
  84. 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
+ )