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,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))