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,429 @@
|
|
|
1
|
+
"""Library management for downloaded instructions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from aiconfigkit.core.models import LibraryInstruction, LibraryRepository
|
|
10
|
+
from aiconfigkit.utils.paths import get_library_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LibraryManager:
|
|
14
|
+
"""
|
|
15
|
+
Manages the local library of downloaded instructions.
|
|
16
|
+
|
|
17
|
+
The library structure:
|
|
18
|
+
~/.instructionkit/
|
|
19
|
+
├── library/
|
|
20
|
+
│ ├── repo-namespace-1/
|
|
21
|
+
│ │ └── instructions/
|
|
22
|
+
│ │ └── instruction.md
|
|
23
|
+
│ └── repo-namespace-2/
|
|
24
|
+
│ └── instructions/
|
|
25
|
+
└── library.json (index of all repositories)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, library_dir: Optional[Path] = None):
|
|
29
|
+
"""
|
|
30
|
+
Initialize library manager.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
library_dir: Path to library directory (default: ~/.instructionkit/library)
|
|
34
|
+
"""
|
|
35
|
+
self.library_dir = library_dir or get_library_dir()
|
|
36
|
+
self.library_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
self.index_file = self.library_dir.parent / "library.json"
|
|
39
|
+
|
|
40
|
+
def get_repo_namespace(self, url: str, repo_name: str) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Generate a unique namespace for a repository.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
url: Repository URL
|
|
46
|
+
repo_name: Repository name
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Namespace string (e.g., 'github.com_company_instructions')
|
|
50
|
+
"""
|
|
51
|
+
# Parse URL to extract host and path
|
|
52
|
+
# For local paths, use the folder name
|
|
53
|
+
if url.startswith(("http://", "https://", "git@")):
|
|
54
|
+
# Extract domain and repo path
|
|
55
|
+
# https://github.com/company/instructions -> github.com_company_instructions
|
|
56
|
+
import re
|
|
57
|
+
|
|
58
|
+
# Remove protocol
|
|
59
|
+
clean_url = re.sub(r"^(https?://|git@)", "", url)
|
|
60
|
+
# Remove .git suffix
|
|
61
|
+
clean_url = re.sub(r"\.git$", "", clean_url)
|
|
62
|
+
# Replace special chars with underscore
|
|
63
|
+
namespace = re.sub(r"[^a-zA-Z0-9]", "_", clean_url)
|
|
64
|
+
else:
|
|
65
|
+
# Local path - use folder name + sanitized path
|
|
66
|
+
path = Path(url).resolve()
|
|
67
|
+
namespace = f"local_{path.name}_{abs(hash(str(path))) % 100000}"
|
|
68
|
+
|
|
69
|
+
return namespace
|
|
70
|
+
|
|
71
|
+
def generate_alias(self, url: str, repo_name: str) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Auto-generate a friendly alias from URL or repo name.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
url: Repository URL
|
|
77
|
+
repo_name: Repository name
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Friendly alias (e.g., 'company-instructions' from github.com/company/instructions)
|
|
81
|
+
"""
|
|
82
|
+
import re
|
|
83
|
+
|
|
84
|
+
if url.startswith(("http://", "https://")):
|
|
85
|
+
# Extract repo path from URL
|
|
86
|
+
# https://github.com/company/instructions -> company-instructions
|
|
87
|
+
match = re.search(r"/([^/]+)/([^/]+?)(?:\.git)?$", url)
|
|
88
|
+
if match:
|
|
89
|
+
org, repo = match.groups()
|
|
90
|
+
return f"{org}-{repo}".lower()
|
|
91
|
+
|
|
92
|
+
# Fallback to sanitized repo name
|
|
93
|
+
return re.sub(r"[^a-z0-9-]", "-", repo_name.lower()).strip("-")
|
|
94
|
+
|
|
95
|
+
def load_index(self) -> dict[str, LibraryRepository]:
|
|
96
|
+
"""
|
|
97
|
+
Load the library index.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Dictionary mapping namespace to LibraryRepository
|
|
101
|
+
"""
|
|
102
|
+
if not self.index_file.exists():
|
|
103
|
+
return {}
|
|
104
|
+
|
|
105
|
+
with open(self.index_file, "r", encoding="utf-8") as f:
|
|
106
|
+
data = json.load(f)
|
|
107
|
+
|
|
108
|
+
return {namespace: LibraryRepository.from_dict(repo_data) for namespace, repo_data in data.items()}
|
|
109
|
+
|
|
110
|
+
def save_index(self, repositories: dict[str, LibraryRepository]) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Save the library index.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
repositories: Dictionary mapping namespace to LibraryRepository
|
|
116
|
+
"""
|
|
117
|
+
data = {namespace: repo.to_dict() for namespace, repo in repositories.items()}
|
|
118
|
+
|
|
119
|
+
with open(self.index_file, "w", encoding="utf-8") as f:
|
|
120
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
121
|
+
|
|
122
|
+
def add_repository(
|
|
123
|
+
self,
|
|
124
|
+
repo_name: str,
|
|
125
|
+
repo_description: str,
|
|
126
|
+
repo_url: str,
|
|
127
|
+
repo_author: str,
|
|
128
|
+
repo_version: str,
|
|
129
|
+
instructions: list[LibraryInstruction],
|
|
130
|
+
alias: Optional[str] = None,
|
|
131
|
+
namespace: Optional[str] = None,
|
|
132
|
+
) -> LibraryRepository:
|
|
133
|
+
"""
|
|
134
|
+
Add a repository to the library.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
repo_name: Repository display name
|
|
138
|
+
repo_description: Repository description
|
|
139
|
+
repo_url: Repository URL
|
|
140
|
+
repo_author: Repository author
|
|
141
|
+
repo_version: Repository version
|
|
142
|
+
instructions: List of instructions to add
|
|
143
|
+
alias: User-friendly alias (auto-generated if not provided)
|
|
144
|
+
namespace: Repository namespace (auto-generated if not provided)
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Created LibraryRepository
|
|
148
|
+
"""
|
|
149
|
+
# Generate namespace if not provided
|
|
150
|
+
if namespace is None:
|
|
151
|
+
namespace = self.get_repo_namespace(repo_url, repo_name)
|
|
152
|
+
|
|
153
|
+
# Auto-generate alias if not provided
|
|
154
|
+
if alias is None:
|
|
155
|
+
alias = self.generate_alias(repo_url, repo_name)
|
|
156
|
+
|
|
157
|
+
# Create repository directory
|
|
158
|
+
repo_dir = self.library_dir / namespace
|
|
159
|
+
repo_dir.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
|
|
161
|
+
# Create instructions directory
|
|
162
|
+
instructions_dir = repo_dir / "instructions"
|
|
163
|
+
instructions_dir.mkdir(exist_ok=True)
|
|
164
|
+
|
|
165
|
+
# Create repository object
|
|
166
|
+
library_repo = LibraryRepository(
|
|
167
|
+
namespace=namespace,
|
|
168
|
+
name=repo_name,
|
|
169
|
+
description=repo_description,
|
|
170
|
+
url=repo_url,
|
|
171
|
+
author=repo_author,
|
|
172
|
+
version=repo_version,
|
|
173
|
+
downloaded_at=datetime.now(),
|
|
174
|
+
alias=alias,
|
|
175
|
+
instructions=instructions,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Update index
|
|
179
|
+
index = self.load_index()
|
|
180
|
+
index[namespace] = library_repo
|
|
181
|
+
self.save_index(index)
|
|
182
|
+
|
|
183
|
+
return library_repo
|
|
184
|
+
|
|
185
|
+
def remove_repository(self, namespace: str) -> bool:
|
|
186
|
+
"""
|
|
187
|
+
Remove a repository from the library.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
namespace: Repository namespace to remove
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
True if removed, False if not found
|
|
194
|
+
"""
|
|
195
|
+
# Load index
|
|
196
|
+
index = self.load_index()
|
|
197
|
+
|
|
198
|
+
if namespace not in index:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
# Remove from index
|
|
202
|
+
del index[namespace]
|
|
203
|
+
self.save_index(index)
|
|
204
|
+
|
|
205
|
+
# Remove directory
|
|
206
|
+
repo_dir = self.library_dir / namespace
|
|
207
|
+
if repo_dir.exists():
|
|
208
|
+
shutil.rmtree(repo_dir)
|
|
209
|
+
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
def get_repository(self, namespace: str) -> Optional[LibraryRepository]:
|
|
213
|
+
"""
|
|
214
|
+
Get a repository by namespace.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
namespace: Repository namespace
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
LibraryRepository or None if not found
|
|
221
|
+
"""
|
|
222
|
+
index = self.load_index()
|
|
223
|
+
return index.get(namespace)
|
|
224
|
+
|
|
225
|
+
def get_repository_by_url(self, url: str) -> Optional[LibraryRepository]:
|
|
226
|
+
"""
|
|
227
|
+
Find a repository by its source URL.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
url: Repository URL (will be normalized for comparison)
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
LibraryRepository or None if not found
|
|
234
|
+
"""
|
|
235
|
+
# Normalize path for comparison
|
|
236
|
+
if not url.startswith(("http://", "https://", "git@")):
|
|
237
|
+
url = str(Path(url).resolve())
|
|
238
|
+
|
|
239
|
+
index = self.load_index()
|
|
240
|
+
for repo in index.values():
|
|
241
|
+
repo_url = repo.url
|
|
242
|
+
# Normalize repo URL for comparison
|
|
243
|
+
if not repo_url.startswith(("http://", "https://", "git@")):
|
|
244
|
+
repo_url = str(Path(repo_url).resolve())
|
|
245
|
+
|
|
246
|
+
if repo_url == url:
|
|
247
|
+
return repo
|
|
248
|
+
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def list_repositories(self) -> list[LibraryRepository]:
|
|
252
|
+
"""
|
|
253
|
+
List all repositories in the library.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
List of LibraryRepository objects
|
|
257
|
+
"""
|
|
258
|
+
index = self.load_index()
|
|
259
|
+
return list(index.values())
|
|
260
|
+
|
|
261
|
+
def list_instructions(self) -> list[LibraryInstruction]:
|
|
262
|
+
"""
|
|
263
|
+
List all instructions across all repositories.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Flattened list of all LibraryInstruction objects
|
|
267
|
+
"""
|
|
268
|
+
instructions = []
|
|
269
|
+
for repo in self.list_repositories():
|
|
270
|
+
instructions.extend(repo.instructions)
|
|
271
|
+
return instructions
|
|
272
|
+
|
|
273
|
+
def get_instruction(self, instruction_id: str) -> Optional[LibraryInstruction]:
|
|
274
|
+
"""
|
|
275
|
+
Get an instruction by ID.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
instruction_id: Instruction ID (namespace/name)
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
LibraryInstruction or None if not found
|
|
282
|
+
"""
|
|
283
|
+
for instruction in self.list_instructions():
|
|
284
|
+
if instruction.id == instruction_id:
|
|
285
|
+
return instruction
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def get_instructions_by_name(self, name: str) -> list[LibraryInstruction]:
|
|
289
|
+
"""
|
|
290
|
+
Get all instructions with a given name (may be multiple from different repos).
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
name: Instruction name
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
List of LibraryInstruction objects with matching name
|
|
297
|
+
"""
|
|
298
|
+
return [inst for inst in self.list_instructions() if inst.name == name]
|
|
299
|
+
|
|
300
|
+
def get_instructions_by_source_and_name(self, source_alias: str, name: str) -> list[LibraryInstruction]:
|
|
301
|
+
"""
|
|
302
|
+
Get instructions by source alias and name.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
source_alias: Source alias to filter by
|
|
306
|
+
name: Instruction name
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
List of LibraryInstruction objects matching source and name
|
|
310
|
+
"""
|
|
311
|
+
# Find repositories matching the source alias
|
|
312
|
+
matching_repos = []
|
|
313
|
+
for repo in self.list_repositories():
|
|
314
|
+
if repo.alias and repo.alias.lower() == source_alias.lower():
|
|
315
|
+
matching_repos.append(repo)
|
|
316
|
+
|
|
317
|
+
# Get instructions from matching repos
|
|
318
|
+
return [inst for repo in matching_repos for inst in repo.instructions if inst.name == name]
|
|
319
|
+
|
|
320
|
+
def search_instructions(
|
|
321
|
+
self,
|
|
322
|
+
query: Optional[str] = None,
|
|
323
|
+
repo_namespace: Optional[str] = None,
|
|
324
|
+
tags: Optional[list[str]] = None,
|
|
325
|
+
) -> list[LibraryInstruction]:
|
|
326
|
+
"""
|
|
327
|
+
Search instructions with filters.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
query: Search query (matches name or description)
|
|
331
|
+
repo_namespace: Filter by repository namespace
|
|
332
|
+
tags: Filter by tags (instruction must have at least one matching tag)
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
List of matching LibraryInstruction objects
|
|
336
|
+
"""
|
|
337
|
+
instructions = self.list_instructions()
|
|
338
|
+
|
|
339
|
+
# Filter by query
|
|
340
|
+
if query:
|
|
341
|
+
query_lower = query.lower()
|
|
342
|
+
instructions = [
|
|
343
|
+
inst
|
|
344
|
+
for inst in instructions
|
|
345
|
+
if query_lower in inst.name.lower() or query_lower in inst.description.lower()
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
# Filter by repo
|
|
349
|
+
if repo_namespace:
|
|
350
|
+
instructions = [inst for inst in instructions if inst.repo_namespace == repo_namespace]
|
|
351
|
+
|
|
352
|
+
# Filter by tags
|
|
353
|
+
if tags:
|
|
354
|
+
instructions = [inst for inst in instructions if any(tag in inst.tags for tag in tags)]
|
|
355
|
+
|
|
356
|
+
return instructions
|
|
357
|
+
|
|
358
|
+
def get_instruction_file_path(self, instruction_id: str) -> Optional[Path]:
|
|
359
|
+
"""
|
|
360
|
+
Get the absolute path to an instruction file.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
instruction_id: Instruction ID (namespace/name)
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
Path to instruction file or None if not found
|
|
367
|
+
"""
|
|
368
|
+
instruction = self.get_instruction(instruction_id)
|
|
369
|
+
if not instruction:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
return Path(instruction.file_path)
|
|
373
|
+
|
|
374
|
+
def get_versioned_namespace(self, repo_identifier: str, ref: str) -> str:
|
|
375
|
+
"""
|
|
376
|
+
Generate filesystem-safe namespace for repo@ref.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
repo_identifier: Repository identifier (URL or name)
|
|
380
|
+
ref: Git reference (tag, branch, or commit)
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Versioned namespace string (e.g., 'github.com_owner_repo@v1.0.0')
|
|
384
|
+
"""
|
|
385
|
+
import re
|
|
386
|
+
|
|
387
|
+
# Get base namespace without version
|
|
388
|
+
base_namespace = self.get_repo_namespace(repo_identifier, "")
|
|
389
|
+
|
|
390
|
+
# Sanitize ref for filesystem (replace special chars)
|
|
391
|
+
# Handle refs like 'feature/new-feature' or 'refs/tags/v1.0.0'
|
|
392
|
+
safe_ref = re.sub(r"[^a-zA-Z0-9._-]", "_", ref)
|
|
393
|
+
|
|
394
|
+
return f"{base_namespace}@{safe_ref}"
|
|
395
|
+
|
|
396
|
+
def list_repository_versions(self, repo_identifier: str) -> list[tuple[str, str]]:
|
|
397
|
+
"""
|
|
398
|
+
List all downloaded versions of a repository.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
repo_identifier: Repository identifier (URL or base namespace)
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
List of tuples (version_ref, full_namespace) for all versions found
|
|
405
|
+
"""
|
|
406
|
+
import re
|
|
407
|
+
|
|
408
|
+
# Get base namespace pattern to match
|
|
409
|
+
base_namespace = self.get_repo_namespace(repo_identifier, "")
|
|
410
|
+
|
|
411
|
+
# Load index
|
|
412
|
+
index = self.load_index()
|
|
413
|
+
|
|
414
|
+
# Find all namespaces that match base pattern with version suffix
|
|
415
|
+
versions = []
|
|
416
|
+
pattern = re.compile(rf"^{re.escape(base_namespace)}@(.+)$")
|
|
417
|
+
|
|
418
|
+
for namespace in index.keys():
|
|
419
|
+
match = pattern.match(namespace)
|
|
420
|
+
if match:
|
|
421
|
+
version_ref = match.group(1)
|
|
422
|
+
# Convert sanitized ref back (e.g., 'feature_new-feature' -> original might vary)
|
|
423
|
+
versions.append((version_ref, namespace))
|
|
424
|
+
|
|
425
|
+
# Also check if there's a non-versioned namespace (legacy support)
|
|
426
|
+
if base_namespace in index:
|
|
427
|
+
versions.append(("default", base_namespace))
|
|
428
|
+
|
|
429
|
+
return versions
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Track active MCP sets per project."""
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Package installation tracking and persistence."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from aiconfigkit.core.models import (
|
|
10
|
+
InstallationScope,
|
|
11
|
+
InstallationStatus,
|
|
12
|
+
PackageInstallationRecord,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PackageTracker:
|
|
19
|
+
"""
|
|
20
|
+
Manages tracking of installed packages.
|
|
21
|
+
|
|
22
|
+
Stores package installation records in <project-root>/.ai-config-kit/packages.json
|
|
23
|
+
for project-level tracking.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, tracker_file: Path):
|
|
27
|
+
"""
|
|
28
|
+
Initialize package tracker.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
tracker_file: Path to package tracker JSON file
|
|
32
|
+
"""
|
|
33
|
+
self.tracker_file = tracker_file
|
|
34
|
+
self._ensure_tracker_file()
|
|
35
|
+
|
|
36
|
+
def _ensure_tracker_file(self) -> None:
|
|
37
|
+
"""Ensure tracker file and directory exist."""
|
|
38
|
+
self.tracker_file.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
if not self.tracker_file.exists():
|
|
41
|
+
# Create empty tracker file
|
|
42
|
+
self._write_records([])
|
|
43
|
+
|
|
44
|
+
def _read_records(self) -> list[PackageInstallationRecord]:
|
|
45
|
+
"""Read all package installation records from file."""
|
|
46
|
+
try:
|
|
47
|
+
with open(self.tracker_file, "r", encoding="utf-8") as f:
|
|
48
|
+
data = json.load(f)
|
|
49
|
+
|
|
50
|
+
records = []
|
|
51
|
+
for item in data:
|
|
52
|
+
try:
|
|
53
|
+
record = PackageInstallationRecord.from_dict(item)
|
|
54
|
+
records.append(record)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.warning(f"Skipping invalid package record: {e}")
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
return records
|
|
60
|
+
|
|
61
|
+
except json.JSONDecodeError as e:
|
|
62
|
+
logger.error(f"Invalid JSON in tracker file {self.tracker_file}: {e}")
|
|
63
|
+
return []
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
logger.debug(f"Tracker file not found: {self.tracker_file}, will create on first write")
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
def _write_records(self, records: list[PackageInstallationRecord]) -> None:
|
|
69
|
+
"""Write all package installation records to file."""
|
|
70
|
+
try:
|
|
71
|
+
data = [record.to_dict() for record in records]
|
|
72
|
+
|
|
73
|
+
self.tracker_file.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
with open(self.tracker_file, "w", encoding="utf-8") as f:
|
|
76
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
77
|
+
|
|
78
|
+
logger.debug(f"Wrote {len(records)} package records to {self.tracker_file}")
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error(f"Failed to write package tracker: {e}")
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
def record_installation(self, record: PackageInstallationRecord) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Record a new package installation.
|
|
87
|
+
|
|
88
|
+
If a package with the same name and scope already exists, it will be updated.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
record: Package installation record to save
|
|
92
|
+
"""
|
|
93
|
+
records = self._read_records()
|
|
94
|
+
|
|
95
|
+
# Check if package already exists (same name and scope)
|
|
96
|
+
existing_index = None
|
|
97
|
+
for i, existing in enumerate(records):
|
|
98
|
+
if existing.package_name == record.package_name and existing.scope == record.scope:
|
|
99
|
+
existing_index = i
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
if existing_index is not None:
|
|
103
|
+
# Update existing record
|
|
104
|
+
logger.info(f"Updating existing package installation: {record.package_name}")
|
|
105
|
+
records[existing_index] = record
|
|
106
|
+
else:
|
|
107
|
+
# Add new record
|
|
108
|
+
logger.info(f"Recording new package installation: {record.package_name}")
|
|
109
|
+
records.append(record)
|
|
110
|
+
|
|
111
|
+
self._write_records(records)
|
|
112
|
+
|
|
113
|
+
def get_installed_packages(self, scope: Optional[InstallationScope] = None) -> list[PackageInstallationRecord]:
|
|
114
|
+
"""
|
|
115
|
+
Get all installed packages.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
scope: Filter by installation scope (optional)
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
List of package installation records
|
|
122
|
+
"""
|
|
123
|
+
records = self._read_records()
|
|
124
|
+
|
|
125
|
+
if scope:
|
|
126
|
+
records = [r for r in records if r.scope == scope]
|
|
127
|
+
|
|
128
|
+
return records
|
|
129
|
+
|
|
130
|
+
def get_package(self, package_name: str, scope: InstallationScope) -> Optional[PackageInstallationRecord]:
|
|
131
|
+
"""
|
|
132
|
+
Get installation record for a specific package.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
package_name: Name of the package
|
|
136
|
+
scope: Installation scope
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Package installation record, or None if not found
|
|
140
|
+
"""
|
|
141
|
+
records = self._read_records()
|
|
142
|
+
|
|
143
|
+
for record in records:
|
|
144
|
+
if record.package_name == package_name and record.scope == scope:
|
|
145
|
+
return record
|
|
146
|
+
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def update_package(
|
|
150
|
+
self,
|
|
151
|
+
package_name: str,
|
|
152
|
+
scope: InstallationScope,
|
|
153
|
+
status: Optional[InstallationStatus] = None,
|
|
154
|
+
version: Optional[str] = None,
|
|
155
|
+
) -> bool:
|
|
156
|
+
"""
|
|
157
|
+
Update an existing package installation record.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
package_name: Name of the package to update
|
|
161
|
+
scope: Installation scope
|
|
162
|
+
status: New installation status (optional)
|
|
163
|
+
version: New version (optional)
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
True if package was found and updated, False otherwise
|
|
167
|
+
"""
|
|
168
|
+
records = self._read_records()
|
|
169
|
+
|
|
170
|
+
for record in records:
|
|
171
|
+
if record.package_name == package_name and record.scope == scope:
|
|
172
|
+
# Update fields
|
|
173
|
+
if status:
|
|
174
|
+
record.status = status
|
|
175
|
+
if version:
|
|
176
|
+
record.version = version
|
|
177
|
+
record.updated_at = datetime.now()
|
|
178
|
+
|
|
179
|
+
self._write_records(records)
|
|
180
|
+
logger.info(f"Updated package installation: {package_name}")
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
logger.warning(f"Package not found for update: {package_name} (scope: {scope.value})")
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
def remove_package(self, package_name: str, scope: InstallationScope) -> bool:
|
|
187
|
+
"""
|
|
188
|
+
Remove a package installation record.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
package_name: Name of the package to remove
|
|
192
|
+
scope: Installation scope
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
True if package was found and removed, False otherwise
|
|
196
|
+
"""
|
|
197
|
+
records = self._read_records()
|
|
198
|
+
original_count = len(records)
|
|
199
|
+
|
|
200
|
+
# Filter out the package to remove
|
|
201
|
+
records = [r for r in records if not (r.package_name == package_name and r.scope == scope)]
|
|
202
|
+
|
|
203
|
+
if len(records) < original_count:
|
|
204
|
+
self._write_records(records)
|
|
205
|
+
logger.info(f"Removed package installation: {package_name}")
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
logger.warning(f"Package not found for removal: {package_name} (scope: {scope.value})")
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
def is_package_installed(self, package_name: str, scope: InstallationScope) -> bool:
|
|
212
|
+
"""
|
|
213
|
+
Check if a package is installed.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
package_name: Name of the package
|
|
217
|
+
scope: Installation scope
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
True if package is installed, False otherwise
|
|
221
|
+
"""
|
|
222
|
+
return self.get_package(package_name, scope) is not None
|
|
223
|
+
|
|
224
|
+
def get_package_count(self, scope: Optional[InstallationScope] = None) -> int:
|
|
225
|
+
"""
|
|
226
|
+
Get count of installed packages.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
scope: Filter by installation scope (optional)
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Number of installed packages
|
|
233
|
+
"""
|
|
234
|
+
return len(self.get_installed_packages(scope))
|