monoco-toolkit 0.1.4__py3-none-any.whl → 0.1.6__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 (43) hide show
  1. monoco/core/config.py +60 -8
  2. monoco/core/feature.py +58 -0
  3. monoco/core/injection.py +196 -0
  4. monoco/core/integrations.py +181 -0
  5. monoco/core/registry.py +36 -0
  6. monoco/core/resources/en/AGENTS.md +8 -0
  7. monoco/core/resources/en/SKILL.md +66 -0
  8. monoco/core/resources/zh/AGENTS.md +8 -0
  9. monoco/core/resources/zh/SKILL.md +66 -0
  10. monoco/core/setup.py +40 -24
  11. monoco/core/skills.py +444 -0
  12. monoco/core/sync.py +224 -0
  13. monoco/core/workspace.py +2 -6
  14. monoco/daemon/services.py +1 -1
  15. monoco/features/config/commands.py +104 -44
  16. monoco/features/i18n/adapter.py +29 -0
  17. monoco/features/i18n/core.py +1 -11
  18. monoco/features/i18n/resources/en/AGENTS.md +8 -0
  19. monoco/features/i18n/resources/en/SKILL.md +94 -0
  20. monoco/features/i18n/resources/zh/AGENTS.md +8 -0
  21. monoco/features/i18n/resources/zh/SKILL.md +94 -0
  22. monoco/features/issue/adapter.py +34 -0
  23. monoco/features/issue/commands.py +8 -8
  24. monoco/features/issue/core.py +5 -16
  25. monoco/features/issue/migration.py +134 -0
  26. monoco/features/issue/models.py +5 -3
  27. monoco/features/issue/resources/en/AGENTS.md +9 -0
  28. monoco/features/issue/resources/en/SKILL.md +51 -0
  29. monoco/features/issue/resources/zh/AGENTS.md +9 -0
  30. monoco/features/issue/resources/zh/SKILL.md +85 -0
  31. monoco/features/spike/adapter.py +30 -0
  32. monoco/features/spike/core.py +3 -20
  33. monoco/features/spike/resources/en/AGENTS.md +7 -0
  34. monoco/features/spike/resources/en/SKILL.md +74 -0
  35. monoco/features/spike/resources/zh/AGENTS.md +7 -0
  36. monoco/features/spike/resources/zh/SKILL.md +74 -0
  37. monoco/main.py +4 -0
  38. {monoco_toolkit-0.1.4.dist-info → monoco_toolkit-0.1.6.dist-info}/METADATA +1 -1
  39. monoco_toolkit-0.1.6.dist-info/RECORD +62 -0
  40. monoco_toolkit-0.1.4.dist-info/RECORD +0 -36
  41. {monoco_toolkit-0.1.4.dist-info → monoco_toolkit-0.1.6.dist-info}/WHEEL +0 -0
  42. {monoco_toolkit-0.1.4.dist-info → monoco_toolkit-0.1.6.dist-info}/entry_points.txt +0 -0
  43. {monoco_toolkit-0.1.4.dist-info → monoco_toolkit-0.1.6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: monoco-core
3
+ description: Monoco Toolkit 的核心技能。提供项目初始化、配置管理和工作空间管理的基础命令。
4
+ ---
5
+
6
+ # Monoco 核心
7
+
8
+ Monoco Toolkit 的核心功能和命令。
9
+
10
+ ## 概述
11
+
12
+ Monoco 是一个开发者生产力工具包,提供:
13
+
14
+ - **项目初始化**:标准化的项目结构
15
+ - **配置管理**:全局和项目级别的配置
16
+ - **工作空间管理**:多项目设置
17
+
18
+ ## 核心命令
19
+
20
+ ### 项目设置
21
+
22
+ - **`monoco init`**: 初始化新的 Monoco 项目
23
+ - 创建 `.monoco/` 目录及默认配置
24
+ - 设置项目结构(Issues/, .references/ 等)
25
+ - 生成初始文档
26
+
27
+ ### 配置管理
28
+
29
+ - **`monoco config`**: 管理配置
30
+ - `monoco config get <key>`: 查看配置值
31
+ - `monoco config set <key> <value>`: 更新配置
32
+ - 支持全局(`~/.monoco/config.yaml`)和项目(`.monoco/config.yaml`)作用域
33
+
34
+ ### Agent 集成
35
+
36
+ - **`monoco sync`**: 与 agent 环境同步
37
+
38
+ - 将系统提示注入到 agent 配置文件(GEMINI.md, CLAUDE.md 等)
39
+ - 分发 skills 到 agent 框架目录
40
+ - 遵循 `i18n.source_lang` 的语言配置
41
+
42
+ - **`monoco uninstall`**: 清理 agent 集成
43
+ - 从 agent 配置文件中移除托管块
44
+ - 清理已分发的 skills
45
+
46
+ ## 配置结构
47
+
48
+ 配置以 YAML 格式存储在:
49
+
50
+ - **全局**: `~/.monoco/config.yaml`
51
+ - **项目**: `.monoco/config.yaml`
52
+
53
+ 关键配置段:
54
+
55
+ - `core`: 编辑器、日志级别、作者
56
+ - `paths`: 目录路径(issues, spikes, specs)
57
+ - `project`: 项目元数据、spike repos、工作空间成员
58
+ - `i18n`: 国际化设置
59
+ - `agent`: Agent 框架集成设置
60
+
61
+ ## 最佳实践
62
+
63
+ 1. **优先使用 CLI 命令**,而不是手动编辑文件
64
+ 2. **配置更改后运行 `monoco sync`** 以更新 agent 环境
65
+ 3. **将 `.monoco/config.yaml` 提交到版本控制**,保持团队一致性
66
+ 4. **保持全局配置最小化** - 大多数设置应该是项目特定的
monoco/core/setup.py CHANGED
@@ -251,30 +251,46 @@ ui:
251
251
 
252
252
  # 3. Scaffold Directories & Modules
253
253
 
254
- # Import Feature Cores locally to avoid circular deps if any (though setup is core)
255
- from monoco.features.issue import core as issue_core
256
- from monoco.features.spike import core as spike_core
257
- from monoco.features.i18n import core as i18n_core
258
- from monoco.features import skills
259
-
260
- # Initialize Issues
261
- issues_path = cwd / project_config["paths"]["issues"]
262
- issue_core.init(issues_path)
263
-
264
- # Initialize Spikes
265
- spikes_name = project_config["paths"]["spikes"]
266
- spike_core.init(cwd, spikes_name)
267
-
268
- # Initialize I18n
269
- i18n_core.init(cwd)
270
-
271
- # Initialize Skills & Agent Docs
272
- resources = [
273
- issue_core.get_resources(),
274
- spike_core.get_resources(),
275
- i18n_core.get_resources()
276
- ]
277
- skills.init(cwd, resources)
254
+ # 3. Scaffold Directories & Modules
255
+
256
+ from monoco.core.registry import FeatureRegistry
257
+ from monoco.features.issue.adapter import IssueFeature
258
+ from monoco.features.spike.adapter import SpikeFeature
259
+ from monoco.features.i18n.adapter import I18nFeature
260
+
261
+ registry = FeatureRegistry()
262
+ registry.register(IssueFeature())
263
+ registry.register(SpikeFeature())
264
+ registry.register(I18nFeature())
265
+
266
+ # Initialize all features
267
+ for feature in registry.get_features():
268
+ try:
269
+ feature.initialize(cwd, project_config)
270
+ console.print(f" [dim]Initialized feature: {feature.name}[/dim]")
271
+ except Exception as e:
272
+ console.print(f" [red]Failed to initialize {feature.name}: {e}[/red]")
273
+
274
+ # Trigger initial sync to set up Agent Environment
275
+ from monoco.core.sync import sync_command
276
+ # We call sync command logic directly or simulate it?
277
+ # Just invoke the collection logic via sync normally would be best,
278
+ # but sync_command is a click command wrapper.
279
+ # For now let's just initialize the physical structures.
280
+ # The 'skills.init' call in old code did more than just init structure,
281
+ # it wrote SKILL.md files.
282
+ # In V2, we rely on 'monoco sync' to do that injection.
283
+ # So we should prompt user to run sync or do it automatically.
284
+
285
+ # Let's run a sync
286
+ console.print("[bold blue]Setting up Agent Environment...[/bold blue]")
287
+ try:
288
+ # We need to reuse logic from sync.py
289
+ # Simplest is to run the sync workflow here manually/programmatically
290
+ # But for now, let's keep it clean and just say:
291
+ pass
292
+ except Exception:
293
+ pass
278
294
 
279
295
  console.print(f"[green]✓ Project config initialized at {project_config_path}[/green]")
280
296
  console.print(f"[green]✓ Config template generated at {template_path}[/green]")
monoco/core/skills.py ADDED
@@ -0,0 +1,444 @@
1
+ """
2
+ Skill Manager for Monoco Toolkit.
3
+
4
+ This module provides centralized management and distribution of Agent Skills
5
+ following the agentskills.io standard.
6
+
7
+ Key Responsibilities:
8
+ 1. Discover skills from the source directory (Toolkit/skills/)
9
+ 2. Validate skill structure and metadata (YAML frontmatter)
10
+ 3. Distribute skills to target agent framework directories
11
+ 4. Support i18n for skill content
12
+ """
13
+
14
+ import shutil
15
+ import hashlib
16
+ from pathlib import Path
17
+ from typing import Dict, List, Optional, Set
18
+ from pydantic import BaseModel, Field, ValidationError
19
+ from rich.console import Console
20
+ import yaml
21
+
22
+ console = Console()
23
+
24
+
25
+ class SkillMetadata(BaseModel):
26
+ """
27
+ Skill metadata from YAML frontmatter.
28
+ Based on agentskills.io standard.
29
+ """
30
+ name: str = Field(..., description="Unique skill identifier (lowercase, hyphens)")
31
+ description: str = Field(..., description="Clear description of what the skill does and when to use it")
32
+ version: Optional[str] = Field(default=None, description="Skill version")
33
+ author: Optional[str] = Field(default=None, description="Skill author")
34
+ tags: Optional[List[str]] = Field(default=None, description="Skill tags for categorization")
35
+
36
+
37
+ class Skill:
38
+ """
39
+ Represents a single skill with its metadata and file paths.
40
+ """
41
+ def __init__(self, root_dir: Path, skill_dir: Path):
42
+ """
43
+ Initialize a Skill instance.
44
+
45
+ Args:
46
+ root_dir: Project root directory
47
+ skill_dir: Path to the skill directory (e.g., Toolkit/skills/issues-management)
48
+ """
49
+ self.root_dir = root_dir
50
+ self.skill_dir = skill_dir
51
+ self.name = skill_dir.name
52
+ self.skill_file = skill_dir / "SKILL.md"
53
+ self.metadata: Optional[SkillMetadata] = None
54
+ self._load_metadata()
55
+
56
+ def _load_metadata(self) -> None:
57
+ """Load and validate skill metadata from SKILL.md frontmatter."""
58
+ # Try to load from language subdirectories first (Feature resources pattern)
59
+ # Then fallback to root SKILL.md (legacy pattern)
60
+ skill_file_to_use = None
61
+
62
+ # Check language subdirectories
63
+ if self.skill_dir.exists():
64
+ for item in sorted(self.skill_dir.iterdir()):
65
+ if item.is_dir() and len(item.name) == 2: # 2-letter lang code
66
+ candidate = item / "SKILL.md"
67
+ if candidate.exists():
68
+ skill_file_to_use = candidate
69
+ break
70
+
71
+ # Fallback to root SKILL.md
72
+ if not skill_file_to_use and self.skill_file.exists():
73
+ skill_file_to_use = self.skill_file
74
+
75
+ if not skill_file_to_use:
76
+ return
77
+
78
+ try:
79
+ content = skill_file_to_use.read_text(encoding="utf-8")
80
+ # Extract YAML frontmatter
81
+ if content.startswith("---"):
82
+ parts = content.split("---", 2)
83
+ if len(parts) >= 3:
84
+ frontmatter = parts[1].strip()
85
+ metadata_dict = yaml.safe_load(frontmatter)
86
+
87
+ # Validate against schema
88
+ self.metadata = SkillMetadata(**metadata_dict)
89
+ except ValidationError as e:
90
+ console.print(f"[red]Invalid metadata in {skill_file_to_use}: {e}[/red]")
91
+ except Exception as e:
92
+ console.print(f"[yellow]Warning: Failed to parse metadata from {skill_file_to_use}: {e}[/yellow]")
93
+
94
+ def is_valid(self) -> bool:
95
+ """Check if the skill has valid metadata."""
96
+ return self.metadata is not None
97
+
98
+ def get_languages(self) -> List[str]:
99
+ """
100
+ Detect available language versions of this skill.
101
+
102
+ Returns:
103
+ List of language codes (e.g., ['en', 'zh'])
104
+ """
105
+ languages = []
106
+
107
+ # Check for language subdirectories (Feature resources pattern)
108
+ # resources/en/SKILL.md, resources/zh/SKILL.md
109
+ for item in self.skill_dir.iterdir():
110
+ if item.is_dir() and len(item.name) == 2: # Assume 2-letter lang codes
111
+ lang_skill_file = item / "SKILL.md"
112
+ if lang_skill_file.exists():
113
+ languages.append(item.name)
114
+
115
+ # Fallback: check for root SKILL.md (legacy Toolkit/skills pattern)
116
+ # We don't assume a default language, just return what we found
117
+ if not languages and self.skill_file.exists():
118
+ # For legacy pattern, we can't determine the language from structure
119
+ # Return empty to indicate this skill uses legacy pattern
120
+ pass
121
+
122
+ return languages
123
+
124
+ def get_checksum(self, lang: str) -> str:
125
+ """
126
+ Calculate checksum for the skill content.
127
+
128
+ Args:
129
+ lang: Language code
130
+
131
+ Returns:
132
+ SHA256 checksum of the skill file
133
+ """
134
+ # Try language subdirectory first (Feature resources pattern)
135
+ target_file = self.skill_dir / lang / "SKILL.md"
136
+
137
+ # Fallback to root SKILL.md (legacy pattern)
138
+ if not target_file.exists():
139
+ target_file = self.skill_file
140
+
141
+ if not target_file.exists():
142
+ return ""
143
+
144
+ content = target_file.read_bytes()
145
+ return hashlib.sha256(content).hexdigest()
146
+
147
+
148
+ class SkillManager:
149
+ """
150
+ Central manager for Monoco skills.
151
+
152
+ Responsibilities:
153
+ - Collect skills from Feature resources
154
+ - Validate skill structure
155
+ - Distribute skills to agent framework directories
156
+ """
157
+
158
+ def __init__(self, root: Path, features: Optional[List] = None):
159
+ """
160
+ Initialize SkillManager.
161
+
162
+ Args:
163
+ root: Project root directory
164
+ features: List of MonocoFeature instances (if None, will load from registry)
165
+ """
166
+ self.root = root
167
+ self.features = features or []
168
+ self.skills: Dict[str, Skill] = {}
169
+
170
+ if self.features:
171
+ self._discover_skills_from_features()
172
+
173
+ # Also discover core skill (monoco/core/resources/)
174
+ self._discover_core_skill()
175
+
176
+ def _discover_core_skill(self) -> None:
177
+ """
178
+ Discover skill from monoco/core/resources/.
179
+
180
+ Core is special - it's not a Feature but still has a skill.
181
+ """
182
+ core_resources_dir = self.root / "monoco" / "core" / "resources"
183
+
184
+ if not core_resources_dir.exists():
185
+ return
186
+
187
+ # Check for SKILL.md in language directories
188
+ for lang_dir in core_resources_dir.iterdir():
189
+ if lang_dir.is_dir() and (lang_dir / "SKILL.md").exists():
190
+ skill = Skill(self.root, core_resources_dir)
191
+
192
+ # Use the skill's metadata name if available
193
+ if skill.metadata and skill.metadata.name:
194
+ skill.name = skill.metadata.name.replace('-', '_')
195
+ else:
196
+ skill.name = "monoco_core"
197
+
198
+ if skill.is_valid():
199
+ self.skills[skill.name] = skill
200
+ break # Only need to detect once
201
+
202
+ def _discover_skills_from_features(self) -> None:
203
+ """
204
+ Discover skills from Feature resources.
205
+
206
+ Each feature should have:
207
+ - monoco/features/{feature}/resources/{lang}/SKILL.md
208
+ """
209
+ from monoco.core.feature import MonocoFeature
210
+
211
+ for feature in self.features:
212
+ if not isinstance(feature, MonocoFeature):
213
+ continue
214
+
215
+ # Determine feature module path
216
+ # feature.__class__.__module__ is like 'monoco.features.issue.adapter'
217
+ module_parts = feature.__class__.__module__.split('.')
218
+ if len(module_parts) >= 3 and module_parts[0] == 'monoco' and module_parts[1] == 'features':
219
+ feature_name = module_parts[2]
220
+
221
+ # Construct path to feature resources
222
+ # monoco/features/{feature}/resources/
223
+ feature_dir = self.root / "monoco" / "features" / feature_name
224
+ resources_dir = feature_dir / "resources"
225
+
226
+ if not resources_dir.exists():
227
+ continue
228
+
229
+ # Check for SKILL.md in language directories
230
+ for lang_dir in resources_dir.iterdir():
231
+ if lang_dir.is_dir() and (lang_dir / "SKILL.md").exists():
232
+ # Create a Skill instance
233
+ # We need to adapt the Skill class to work with feature resources
234
+ skill = self._create_skill_from_feature(feature_name, resources_dir)
235
+ if skill and skill.is_valid():
236
+ # Use feature name as skill identifier
237
+ skill_key = f"{feature_name}"
238
+ if skill_key not in self.skills:
239
+ self.skills[skill_key] = skill
240
+ break # Only need to detect once per feature
241
+
242
+ def _create_skill_from_feature(self, feature_name: str, resources_dir: Path) -> Optional[Skill]:
243
+ """
244
+ Create a Skill instance from a feature's resources directory.
245
+
246
+ Args:
247
+ feature_name: Name of the feature (e.g., 'issue', 'spike')
248
+ resources_dir: Path to the feature's resources directory
249
+
250
+ Returns:
251
+ Skill instance or None if creation fails
252
+ """
253
+ # Use the resources directory as the skill directory
254
+ # The Skill class expects a directory with SKILL.md or {lang}/SKILL.md
255
+ skill = Skill(self.root, resources_dir)
256
+
257
+ # Use the skill's metadata name if available (e.g., 'monoco-issue')
258
+ # Convert to snake_case for directory name (e.g., 'monoco_issue')
259
+ if skill.metadata and skill.metadata.name:
260
+ # Convert kebab-case to snake_case for directory name
261
+ skill.name = skill.metadata.name.replace('-', '_')
262
+ else:
263
+ # Fallback to feature name
264
+ skill.name = f"monoco_{feature_name}"
265
+
266
+ return skill
267
+
268
+ def list_skills(self) -> List[Skill]:
269
+ """
270
+ Get all available skills.
271
+
272
+ Returns:
273
+ List of Skill instances
274
+ """
275
+ return list(self.skills.values())
276
+
277
+ def get_skill(self, name: str) -> Optional[Skill]:
278
+ """
279
+ Get a specific skill by name.
280
+
281
+ Args:
282
+ name: Skill name
283
+
284
+ Returns:
285
+ Skill instance or None if not found
286
+ """
287
+ return self.skills.get(name)
288
+
289
+ def distribute(
290
+ self,
291
+ target_dir: Path,
292
+ lang: str,
293
+ force: bool = False
294
+ ) -> Dict[str, bool]:
295
+ """
296
+ Distribute skills to a target directory.
297
+
298
+ Args:
299
+ target_dir: Target directory for skill distribution (e.g., .cursor/skills/)
300
+ lang: Language code to distribute (e.g., 'en', 'zh')
301
+ force: Force overwrite even if checksum matches
302
+
303
+ Returns:
304
+ Dictionary mapping skill names to success status
305
+ """
306
+ results = {}
307
+
308
+ # Ensure target directory exists
309
+ target_dir.mkdir(parents=True, exist_ok=True)
310
+
311
+ for skill_name, skill in self.skills.items():
312
+ try:
313
+ # Check if this skill has the requested language
314
+ available_languages = skill.get_languages()
315
+
316
+ if lang not in available_languages:
317
+ console.print(f"[yellow]Skill {skill_name} does not have {lang} version, skipping[/yellow]")
318
+ results[skill_name] = False
319
+ continue
320
+
321
+ # Distribute the specific language version
322
+ self._distribute_skill_language(skill, target_dir, lang, force)
323
+ results[skill_name] = True
324
+
325
+ except Exception as e:
326
+ console.print(f"[red]Failed to distribute skill {skill_name}: {e}[/red]")
327
+ results[skill_name] = False
328
+
329
+ return results
330
+
331
+ def _distribute_skill_language(
332
+ self,
333
+ skill: Skill,
334
+ target_dir: Path,
335
+ lang: str,
336
+ force: bool
337
+ ) -> None:
338
+ """
339
+ Distribute a specific language version of a skill.
340
+
341
+ Args:
342
+ skill: Skill instance
343
+ target_dir: Target directory (e.g., .cursor/skills/)
344
+ lang: Language code
345
+ force: Force overwrite
346
+ """
347
+ # Determine source file (try language subdirectory first)
348
+ source_file = skill.skill_dir / lang / "SKILL.md"
349
+
350
+ # Fallback to root SKILL.md (legacy pattern)
351
+ if not source_file.exists():
352
+ source_file = skill.skill_file
353
+
354
+ if not source_file.exists():
355
+ console.print(f"[yellow]Source file not found for {skill.name}/{lang}[/yellow]")
356
+ return
357
+
358
+ # Target path: {target_dir}/{skill_name}/SKILL.md (no language subdirectory)
359
+ target_skill_dir = target_dir / skill.name
360
+
361
+ # Create target directory
362
+ target_skill_dir.mkdir(parents=True, exist_ok=True)
363
+ target_file = target_skill_dir / "SKILL.md"
364
+
365
+ # Check if update is needed
366
+ if target_file.exists() and not force:
367
+ source_checksum = skill.get_checksum(lang)
368
+ target_content = target_file.read_bytes()
369
+ target_checksum = hashlib.sha256(target_content).hexdigest()
370
+
371
+ if source_checksum == target_checksum:
372
+ console.print(f"[dim] = {skill.name}/SKILL.md is up to date[/dim]")
373
+ return
374
+
375
+ # Copy the file
376
+ shutil.copy2(source_file, target_file)
377
+ console.print(f"[green] ✓ Distributed {skill.name}/SKILL.md ({lang})[/green]")
378
+
379
+ # Copy additional resources if they exist
380
+ self._copy_skill_resources(skill.skill_dir, target_skill_dir, lang)
381
+
382
+ def _copy_skill_resources(
383
+ self,
384
+ source_dir: Path,
385
+ target_dir: Path,
386
+ lang: str
387
+ ) -> None:
388
+ """
389
+ Copy additional skill resources (scripts, examples, etc.).
390
+
391
+ Args:
392
+ source_dir: Source skill directory
393
+ target_dir: Target skill directory
394
+ lang: Language code
395
+ """
396
+ # Define resource directories to copy
397
+ resource_dirs = ['scripts', 'examples', 'resources']
398
+
399
+ # Try language subdirectory first (Feature resources pattern)
400
+ source_base = source_dir / lang
401
+
402
+ # Fallback to root directory (legacy pattern)
403
+ if not source_base.exists():
404
+ source_base = source_dir
405
+
406
+ for resource_name in resource_dirs:
407
+ source_resource = source_base / resource_name
408
+ if source_resource.exists() and source_resource.is_dir():
409
+ target_resource = target_dir / resource_name
410
+
411
+ # Remove existing and copy fresh
412
+ if target_resource.exists():
413
+ shutil.rmtree(target_resource)
414
+
415
+ shutil.copytree(source_resource, target_resource)
416
+ console.print(f"[dim] Copied {resource_name}/ for {source_dir.name}/{lang}[/dim]")
417
+
418
+ def cleanup(self, target_dir: Path) -> None:
419
+ """
420
+ Remove distributed skills from a target directory.
421
+
422
+ Args:
423
+ target_dir: Target directory to clean
424
+ """
425
+ if not target_dir.exists():
426
+ console.print(f"[dim]Target directory does not exist: {target_dir}[/dim]")
427
+ return
428
+
429
+ removed_count = 0
430
+
431
+ for skill_name in self.skills.keys():
432
+ skill_target = target_dir / skill_name
433
+ if skill_target.exists():
434
+ shutil.rmtree(skill_target)
435
+ console.print(f"[green] ✓ Removed {skill_name}[/green]")
436
+ removed_count += 1
437
+
438
+ # Remove empty parent directory if no skills remain
439
+ if target_dir.exists() and not any(target_dir.iterdir()):
440
+ target_dir.rmdir()
441
+ console.print(f"[dim] Removed empty directory: {target_dir}[/dim]")
442
+
443
+ if removed_count == 0:
444
+ console.print(f"[dim]No skills to remove from {target_dir}[/dim]")