Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion cecli/helpers/skills.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,29 @@ def __init__(
if default_skill_dir not in directory_paths:
directory_paths = [default_skill_dir] + list(directory_paths)

self.directory_paths = [Path(p).expanduser().resolve() for p in directory_paths]
# Resolve every path and drop exact directory duplicates.
resolved_paths = []
seen_paths = set()
for p in directory_paths:
try:
path = Path(p).expanduser().resolve()
except Exception:
continue
if path in seen_paths:
continue
seen_paths.add(path)
resolved_paths.append(path)

# Order paths: local project dirs first, then configured, home dirs last.
git_root_path = Path(git_root).expanduser().resolve() if git_root else None
ordered = sorted(
enumerate(resolved_paths),
key=lambda item: (
self._directory_priority(item[1], git_root_path),
item[0],
),
)
self.directory_paths = [path for _, path in ordered]
self.include_list = set(include_list) if include_list else None
self.exclude_list = set(exclude_list) if exclude_list else set()
self.git_root = Path(git_root).expanduser().resolve() if git_root else None
Expand DownExpand Up@@ -111,6 +133,35 @@ def __init__(

# Save initial state from config

@staticmethod
def _directory_priority(path: Path, git_root: Optional[Path] = None) -> int:
"""Return the ordering priority of a skill directory.

Lower values are scanned first and therefore win by-name conflicts:

0 - local project directory (under the git root or working directory)
1 - any other configured directory
2 - a home directory (including the implicit ``~/.cecli/skills`` default)
"""
home = Path.home().resolve()

# Implicit default skills dir is always treated as a home dir.
if path == (home / ".cecli" / "skills"):
return 2

local_anchor = git_root if git_root is not None else Path.cwd()
try:
path.relative_to(local_anchor)
return 0
except ValueError:
pass

try:
path.relative_to(home)
return 2
except ValueError:
return 1

def _get_coder(self):
"""Return coder via weak reference, or None if collected."""
if self._coder_ref is not None:
Expand DownExpand Up@@ -176,6 +227,7 @@ def find_skills(self, reload: bool = False) -> List[SkillMetadata]:
return self._skills_find_cache

skills = []
seen_names: set[str] = set()

for directory_path in self.directory_paths:
directory_path = Path(directory_path)
Expand All@@ -193,6 +245,11 @@ def find_skills(self, reload: bool = False) -> List[SkillMetadata]:
metadata = self._parse_skill_metadata(skill_md_path)
skill_name = metadata.name

# First directory wins for duplicate skill names.
if skill_name in seen_names:
continue
seen_names.add(skill_name)

# Apply include/exclude filters
if self.include_list and skill_name not in self.include_list:
continue
Expand Down
8 changes: 8 additions & 0 deletions cecli/website/docs/config/skills.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,6 +77,14 @@ Skills are configured through the `agent-config` parameter in the YAML configura
- **`skills_includelist`**: Array of skill names to include (whitelist)
- **`skills_excludelist`**: Array of skill names to exclude (blacklist)

> **Duplicate skill names**: When the same skill name is found in more than
> one configured directory, only one copy is loaded. Directories are scanned
> in priority order: local project directories (e.g. `./.cecli/skills`) first,
> then other configured directories in the order they are listed, with home
> directories (e.g. `~/skills` and the implicit `~/.cecli/skills` default)
> last. If no `skills_paths` are configured, the only directory searched is
> `~/.cecli/skills`.

Complete configuration example in YAML configuration file (`.cecli.conf.yml` or `~/.cecli.conf.yml`):

```yaml
Expand Down
Loading