From f3f7213957ff5d5ddd03d430e13d6be944c1cdfa Mon Sep 17 00:00:00 2001 From: rosspeili Date: Mon, 10 Aug 2026 22:39:06 +0300 Subject: [PATCH] feat(config,cli): persistent YAML config and skillware config show (#246) Add generic SkillwareConfig with paths section driving discovery when YAML is present; legacy env/cwd resolution unchanged without config files. Includes skillware config show, docs, and tests. --- .skillware.yaml.example | 32 +++++ CHANGELOG.md | 2 + docs/introduction.md | 2 +- docs/security/skill-trust-model.md | 12 +- docs/usage/README.md | 12 +- docs/usage/cli.md | 48 ++++++- skillware/cli.py | 97 ++++++++++++- skillware/core/config.py | 220 +++++++++++++++++++++++++++++ skillware/core/discovery.py | 188 +++++++++++++++++++++--- skillware/core/loader.py | 5 +- tests/test_cli.py | 16 +++ tests/test_config.py | 208 +++++++++++++++++++++++++++ 12 files changed, 800 insertions(+), 42 deletions(-) create mode 100644 .skillware.yaml.example create mode 100644 skillware/core/config.py create mode 100644 tests/test_config.py diff --git a/.skillware.yaml.example b/.skillware.yaml.example new file mode 100644 index 0000000..9309c86 --- /dev/null +++ b/.skillware.yaml.example @@ -0,0 +1,32 @@ +# Example Skillware configuration. +# Copy to .skillware.yaml in your repository root (or use global config.yaml). +# +# Global config (optional): +# Linux/macOS: ~/.config/skillware/config.yaml +# Windows: %APPDATA%/skillware/config.yaml +# +# The bundled registry from `pip install skillware` is always available. + +paths: + # auto = walk up from cwd for ./skills/ (default) + # Or set an explicit skills root directory: + project: auto + external: + # - /path/to/private-skills + [] + +resolution: + order: + - project + - external + - bundled + +legacy: + # When true, SKILLWARE_SKILL_PATH is merged as external roots (default). + honor_skillware_skill_path: true + +# Reserved for future releases (ignored today; preserved by skillware config show): +# theme: +# preset: default +# chains: +# default: [] diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b33b2d..04ae201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ### Added +- **Config:** Persistent YAML configuration (global `config.yaml` and project `.skillware.yaml`); `paths` section drives skill root discovery when present; bundled registry always included (#246). +- **CLI:** `skillware config show` prints merged configuration (read-only); `skillware paths` tips updated for config files (#246). - **Loader:** `SkillLoader.load_skill(..., execute_module=False)` inspect-only load (manifest, instructions, card, requirement pre-flight) without executing `skill.py`; clearer `ImportError` when `skill.py` import fails after pre-flight (#235). - **CLI:** `skillware doctor` checks manifest deps and `skill.py` import readiness per skill (`DEPS` / `LOAD` table); optional skill ID, `--category`, and `--skills-root` (#235). diff --git a/docs/introduction.md b/docs/introduction.md index 72059cd..7aabf3c 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -76,7 +76,7 @@ A skill is a folder on disk. The loader turns the manifest into whatever tool sc When you run `SkillLoader.load_skill("category/skill_name")`, a complex orchestration happens behind the scenes: ### Step 1: Discovery & Loading -The loader resolves `category/skill_name` to a skill directory by checking, in order: an existing path on disk, roots in `SKILLWARE_SKILL_PATH`, a `skills/` folder in the current working directory (or its parents), then bundled skills installed with the package. Run `skillware paths` for a live view of resolved roots, tiers, and shadowing. Each bundle is a directory containing `manifest.yaml` and `skill.py`. +The loader resolves `category/skill_name` to a skill directory by checking, in order: an existing path on disk, configured skill roots (`.skillware.yaml`, global config, or legacy `SKILLWARE_SKILL_PATH` + cwd `skills/` walk), then bundled skills installed with the package. Run `skillware paths` and `skillware config show` for a live view. Each bundle is a directory containing `manifest.yaml` and `skill.py`. * It dynamically imports the `skill.py` module and auto-discovers the single `BaseSkill` subclass as `bundle["class"]` (no hardcoded class names required). * It parses the `manifest.yaml` (including `issuer` for attribution, separate from tool-calling fields). Registry skills set `name` to the full ID (`category/skill_name`), which Claude uses as the tool name; Gemini, OpenAI, and DeepSeek receive a sanitized variant (slashes → underscores). For registry-layout paths (`///`), the loader warns when `name` does not match the folder path; flat private layouts (`//`) skip this check. Loaded bundles expose `registry_id` when validation applies. * It reads `instructions.md` and, when present, optional `card.json`. diff --git a/docs/security/skill-trust-model.md b/docs/security/skill-trust-model.md index b7dc733..017fc01 100644 --- a/docs/security/skill-trust-model.md +++ b/docs/security/skill-trust-model.md @@ -14,19 +14,23 @@ The trust tiers in this document describe how much you should trust a skill's or ## 2. How skills are resolved on disk -When you pass a registry id (for example finance/wallet_screening) rather than a path that already exists, the loader searches a fixed set of roots and uses the first match it finds, in this order: +When you pass a registry id (for example finance/wallet_screening) rather than a path that already exists, the loader searches skill roots and uses the first match it finds. + +**Default (no config file):** 1. SKILLWARE_SKILL_PATH — one or more roots, separated by your OS path separator. -2. ./skills/ in the current working directory, and its parent directories — the loader walks up to six levels of parents looking for a skills/ directory. +2. `./skills/` in the current working directory, and its parent directories (walk up to six levels). 3. Bundled skills shipped inside the installed skillware package (for example under site-packages/skills/). +**With config (`.skillware.yaml` or global `config.yaml`):** tiers follow `resolution.order` (default: project → external → bundled). Persist private roots under `paths.external`; set `paths.project` to `auto` or an explicit directory. Bundled registry skills are always included. See `skillware config show` and [CLI config](../usage/cli.md#skillware-config). + If you pass a path that already points at a skill directory (absolute or relative to the current directory), the loader uses it directly and skips the search entirely. ### Shadowing -Because the search stops at the first matching id, a skill earlier in the order shadows any skill with the same id later in the order. If a finance/wallet_screening exists under SKILLWARE_SKILL_PATH or in a local ./skills/, it is loaded instead of the bundled, maintainer-reviewed copy of the same id — and the bundled copy never runs. +Because the search stops at the first matching id, a skill earlier in the order shadows any skill with the same id later in the order. If a finance/wallet_screening exists under project or external paths before bundled, it is loaded instead of the bundled, maintainer-reviewed copy — and the bundled copy never runs. -Run `skillware paths` to see which roots are active and which IDs shadow bundled registry skills. +Run `skillware paths` and `skillware config show` to see which roots are active and which IDs shadow bundled registry skills. The practical consequence: placing a skill with the same id as an official one, anywhere earlier in the search order, silently replaces the official skill. Shadowing is a normal feature of the resolution order, but it means the id you ask for does not by itself tell you which code will run — the location does. diff --git a/docs/usage/README.md b/docs/usage/README.md index 87a9ba0..c12e2e1 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -4,19 +4,23 @@ How to load Skillware skills and connect them to language models. Each guide cov ## Finding skills on disk -`SkillLoader.load_skill()` accepts an absolute path to a skill directory, or a registry id such as `compliance/tos_evaluator`. When the id is not already a path on disk, the loader searches in order: +`SkillLoader.load_skill()` accepts an absolute path to a skill directory, or a registry id such as `compliance/tos_evaluator`. When the id is not already a path on disk, the loader searches configured skill roots in resolution order. + +**Default (no config file):** 1. Roots listed in `SKILLWARE_SKILL_PATH` (OS path separator between multiple roots) 2. A `skills/` directory in the current working directory or its parents 3. Bundled skills installed with the `skillware` package (for example under `site-packages/skills/`) -For pip-installed apps, keep project skills in `./skills///` or set `SKILLWARE_SKILL_PATH` to your skills root. +**With config:** copy [`.skillware.yaml.example`](../.skillware.yaml.example) to `.skillware.yaml` (or use global `~/.config/skillware/config.yaml`) to persist project and external paths. Default order is project → external → bundled; the bundled registry is always available. See [CLI — config](cli.md#skillware-config) and `skillware config show`. + +For pip-installed apps, bundled maintainer skills are the default; add private skills under `./skills///`, config `paths.external`, or `SKILLWARE_SKILL_PATH`. By default, `SkillLoader.load_skill()` validates manifest `requirements` before loading `skill.py`: unpinned deps must be importable; pinned specifiers (for example `web3>=6.0.0`) must match the installed version. See [Install extras — Loader behavior](install_extras.md#loader-behavior). > **Security:** Loading a skill executes its `skill.py` in your process — there is no sandbox, and the first matching id in the search order wins (a local skill can shadow a bundled one). Only load skills you trust, and see the [skill trust model](../security/skill-trust-model.md) before loading external skills. -To list locally available skills, inspect path resolution, check load readiness, or run bundle tests from the terminal, see the [CLI reference](cli.md) (`skillware list`, `skillware paths`, `skillware doctor`, `skillware test`). +To list locally available skills, inspect path resolution, show config, check load readiness, or run bundle tests from the terminal, see the [CLI reference](cli.md) (`skillware list`, `skillware paths`, `skillware config show`, `skillware doctor`, `skillware test`). | Provider | Adapter | Guide | Agent API key (typical) | | :--- | :--- | :--- | :--- | @@ -26,7 +30,7 @@ To list locally available skills, inspect path resolution, check load readiness, | OpenAI-compatible hosts | `to_openai_tool()` | [openai_compatible.md](openai_compatible.md) | Host-specific key | | DeepSeek | `to_deepseek_tool()` | [deepseek.md](deepseek.md) | `DEEPSEEK_API_KEY` | | Ollama (prompt mode) | `to_ollama_prompt()` | [ollama.md](ollama.md) | (local; no cloud key) | -| CLI | `skillware list`, `skillware paths`, `skillware doctor`, `skillware test`, `skillware examples` | [cli.md](cli.md) | pytest in `[dev]` for `test` | +| CLI | `skillware list`, `skillware paths`, `skillware config`, `skillware doctor`, `skillware test`, `skillware examples` | [cli.md](cli.md) | pytest in `[dev]` for `test` | | Install extras | Category, skill, SDK, and meta `pip install` targets | [install_extras.md](install_extras.md) | See guide for `[all]`, `[agents]`, per-skill extras | Skill-specific **Usage Examples** (sample prompts and execute payloads) live on each [skill catalog page](../skills/README.md). diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 7d23655..6c85e12 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -36,6 +36,7 @@ After installation, the `skillware` command is available directly: skillware skillware list skillware doctor + skillware config show skillware test skillware examples skillware --version @@ -208,7 +209,7 @@ Show where Skillware looks for skills — same order as `SkillLoader.load_skill( | :--- | :--- | | `--skills-root ` | Override the skills directory for this command only (shows a single override root). | -Read-only in v0.4.x; persist project/external paths via config is tracked in #246. Interactive menu: **`4` / `paths`**. +Read-only in v0.4.x. Interactive menu: **`4` / `paths`**. ### skillware doctor @@ -234,21 +235,60 @@ Exit code is non-zero when any skill fails **DEPS** or **LOAD**. For full bundle Interactive menu: **`5` / `doctor`**. +### skillware config + +Show merged global + project Skillware configuration (read-only). The `paths` section is active today; other top-level keys are preserved for future settings (themes, chains, etc.). + + skillware config show + +**Global config:** `~/.config/skillware/config.yaml` (Linux/macOS), `%APPDATA%/skillware/config.yaml` (Windows), or override with `SKILLWARE_CONFIG_DIR`. + +**Project config:** `.skillware.yaml` in the repository root (walks up from cwd). See [`.skillware.yaml.example`](../../.skillware.yaml.example). + +Example project file: + +```yaml +paths: + project: auto + external: + - /path/to/private-skills +resolution: + order: + - project + - external + - bundled +legacy: + honor_skillware_skill_path: true +``` + +When no config file exists, resolution stays **legacy**: `SKILLWARE_SKILL_PATH` → `./skills/` walk → bundled. When config exists, `resolution.order` applies (default: project → external → bundled). The **bundled** registry from `pip install skillware` is always included and cannot be disabled. + ## Path resolution -`skillware list` searches for skills in the same order as `SkillLoader`: +`skillware list`, `load_skill`, `test`, and `doctor` share the same roots as `SkillLoader`. + +**Without config files (default):** 1. Roots listed in `SKILLWARE_SKILL_PATH` (OS path separator between multiple entries) 2. A `skills/` directory under the current working directory and its parents 3. Bundled skills installed with the `skillware` package -Run `skillware paths` for a live view of resolved roots, tiers, and shadowing. +**With `.skillware.yaml` and/or global config:** -To point the CLI at a persistent custom root, set the environment variable: +1. Tiers in `resolution.order` (default: project → external → bundled) +2. `paths.project`: `auto` (same walk as above) or an explicit directory +3. `paths.external`: persisted private/proprietary skill roots +4. Bundled registry always last-resort fallback (always on) + +Run `skillware paths` for a live view of resolved roots, tiers, and shadowing. Run `skillware config show` for merged YAML settings. + +To point the CLI at custom roots without config files: export SKILLWARE_SKILL_PATH=/path/to/my/skills skillware list +Or copy `.skillware.yaml.example` to `.skillware.yaml` and list paths under `paths.external`. + Only skills with both `manifest.yaml` and `skill.py` present are shown — the same condition `SkillLoader` requires to load a skill successfully. diff --git a/skillware/cli.py b/skillware/cli.py index e03d9a8..9ccee07 100644 --- a/skillware/cli.py +++ b/skillware/cli.py @@ -17,6 +17,13 @@ import importlib.metadata from skillware.core.loader import SkillLoader +from skillware.core.config import ( + GLOBAL_CONFIG_FILENAME, + PROJECT_CONFIG_FILENAME, + format_config_sources, + global_config_path, + load_merged_config, +) from skillware.core.discovery import ( SKILLWARE_SKILL_PATH_ENV, find_shadow_conflicts, @@ -478,7 +485,7 @@ def cmd_paths( skills_root_override: Optional[Path] = None, console=None, ) -> int: - """Show skill root resolution order, tiers, and shadowing (read-only; #246 adds config).""" + """Show skill root resolution order, tiers, and shadowing (read-only config via skillware config show).""" if console is None: console = Console() @@ -550,21 +557,85 @@ def cmd_paths( style=MENU_STYLE, ) console.print( - f" • Persistent external roots: export {SKILLWARE_SKILL_PATH_ENV}=/path/to/skills", + f" • Persistent paths: {PROJECT_CONFIG_FILENAME} or {global_config_path()}", style=MENU_STYLE, ) + console.print( + f" • Legacy env override: export {SKILLWARE_SKILL_PATH_ENV}=/path/to/skills", + style=MENU_STYLE, + ) + console.print(" • Inspect merged config: skillware config show", style=MENU_STYLE) console.print( " • Trust tiers: docs/security/skill-trust-model.md", style=f"dim {SPLASH_STYLE}", ) console.print( - " • Persist project/external paths in config: tracked in #246", + " • Flat-layout skills (//) load but may not appear in list", style="dim", ) + return 0 + + +def cmd_config_show(console=None) -> int: + """Print merged global + project configuration (read-only).""" + if console is None: + console = Console() + + config = load_merged_config(refresh=True) + paths = config.paths + console.print(Text("Skillware config", style=f"bold {TABLE_STYLE}")) + console.print() + + console.print(Text("Config files", style=f"bold {TABLE_STYLE}")) + console.print(f" Global (default): {global_config_path()}", style="dim") + for line in format_config_sources(config): + console.print(f" Loaded: {line}", style=MENU_STYLE if config.layers else "dim") + console.print() + + if not config.has_config_files: + console.print( + "No config files found — using legacy resolution " + f"({SKILLWARE_SKILL_PATH_ENV} → ./skills/ walk → bundled).", + style="dim", + ) + console.print( + f"Create {PROJECT_CONFIG_FILENAME} or {GLOBAL_CONFIG_FILENAME} to persist settings.", + style="dim", + ) + console.print( + " docs/usage/cli.md#skillware-config", style=f"dim {SPLASH_STYLE}" + ) + return 0 + + console.print(Text("paths (active)", style=f"bold {TABLE_STYLE}")) + project_label = paths.project if paths.project is not None else "auto" + console.print(f" project: {project_label}", style=MENU_STYLE) + if paths.external: + console.print(" external:", style=MENU_STYLE) + for entry in paths.external: + console.print(f" - {entry}", style="dim") + else: + console.print(" external: []", style=MENU_STYLE) + + order = " → ".join(paths.resolution_order) + console.print(f" resolution.order: {order}", style=MENU_STYLE) + console.print( + f" legacy.honor_skillware_skill_path: {paths.honor_skillware_skill_path}", + style=MENU_STYLE, + ) + console.print() + + if config.extra: + console.print(Text("Other sections (reserved)", style=f"bold {TABLE_STYLE}")) + for key in sorted(config.extra): + console.print(f" {key}: (present, not applied yet)", style="dim") + console.print() + console.print( - " • Flat-layout skills (//) load but may not appear in list", + "Bundled registry is always included and cannot be removed via config.", style="dim", ) + console.print("Edit YAML manually to change settings.", style="dim") return 0 @@ -743,6 +814,7 @@ def cmd_help(console=None) -> None: console.print(" skillware test — run one skill bundle test") console.print(" skillware test --category — run tests for a category") console.print(" skillware paths — show skill root resolution") + console.print(" skillware config show — show merged configuration") console.print(" skillware doctor — check deps and skill.py import") console.print(" skillware doctor — diagnose one skill") console.print(" skillware doctor --category — diagnose a category") @@ -754,6 +826,7 @@ def cmd_help(console=None) -> None: console.print(" examples available now", style=ID_STYLE) console.print(" test available now", style=ID_STYLE) console.print(" paths available now", style=ID_STYLE) + console.print(" config available now (read-only)", style=ID_STYLE) console.print(" doctor available now", style=ID_STYLE) console.print() @@ -771,6 +844,7 @@ def cmd_help(console=None) -> None: console.print(" skillware examples compliance/tos_evaluator", style=MENU_STYLE) console.print(" skillware test finance/wallet_screening", style=MENU_STYLE) console.print(" skillware paths", style=MENU_STYLE) + console.print(" skillware config show", style=MENU_STYLE) console.print(" skillware doctor --category compliance", style=MENU_STYLE) console.print() @@ -1041,6 +1115,16 @@ def main() -> None: help="Diagnose all skills in a category.", ) + config_parser = subparsers.add_parser( + "config", + help="Show merged Skillware configuration (read-only).", + ) + config_subparsers = config_parser.add_subparsers(dest="config_command") + config_subparsers.add_parser( + "show", + help="Print merged global and project YAML settings.", + ) + args = parser.parse_args() if args.help and args.command is None: @@ -1076,6 +1160,11 @@ def main() -> None: category=args.category, ) ) + elif args.command == "config": + if args.config_command == "show": + raise SystemExit(cmd_config_show()) + config_parser.print_help() + raise SystemExit(2) else: cmd_interactive(parser=parser) diff --git a/skillware/core/config.py b/skillware/core/config.py new file mode 100644 index 0000000..042cbcb --- /dev/null +++ b/skillware/core/config.py @@ -0,0 +1,220 @@ +"""Persistent Skillware user and project configuration.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +import yaml + +GLOBAL_CONFIG_DIR_ENV = "SKILLWARE_CONFIG_DIR" +PROJECT_CONFIG_FILENAME = ".skillware.yaml" +GLOBAL_CONFIG_FILENAME = "config.yaml" +_MAX_PARENT_WALK = 6 + +_DEFAULT_RESOLUTION_ORDER: Tuple[str, ...] = ("project", "external", "bundled") +_VALID_ORDER_TIERS = frozenset({"project", "external", "bundled"}) +_KNOWN_TOP_LEVEL_KEYS = frozenset({"paths", "resolution", "legacy"}) + + +@dataclass(frozen=True) +class ConfigLayer: + """One YAML config file that contributed to the merged result.""" + + path: Path + data: Mapping[str, Any] + + +@dataclass +class PathsSettings: + """Skill root paths and discovery order (``paths`` + related keys in YAML).""" + + project: Optional[str] = None + external: List[str] = field(default_factory=list) + resolution_order: Tuple[str, ...] = _DEFAULT_RESOLUTION_ORDER + honor_skillware_skill_path: bool = True + + def project_is_auto(self) -> bool: + return self.project is None or str(self.project).strip().lower() == "auto" + + +@dataclass +class SkillwareConfig: + """ + Merged Skillware configuration. + + ``paths`` is implemented today. Additional top-level YAML sections (for + example ``theme``, ``chains``, skill presets) are preserved in ``extra`` + for forward compatibility and shown by ``skillware config show``. + """ + + paths: PathsSettings = field(default_factory=PathsSettings) + extra: Dict[str, Any] = field(default_factory=dict) + layers: Tuple[ConfigLayer, ...] = () + + @property + def has_config_files(self) -> bool: + return bool(self.layers) + + +_merged_config_cache: Optional[SkillwareConfig] = None + + +def clear_config_cache() -> None: + """Reset cached config (tests only).""" + global _merged_config_cache + _merged_config_cache = None + + +def global_config_dir() -> Path: + """Return the directory for the global Skillware config file.""" + override = os.environ.get(GLOBAL_CONFIG_DIR_ENV, "").strip() + if override: + return Path(override).expanduser() + + xdg = os.environ.get("XDG_CONFIG_HOME", "").strip() + if xdg: + return Path(xdg).expanduser() / "skillware" + + if os.name == "nt": + appdata = os.environ.get("APPDATA", "").strip() + if appdata: + return Path(appdata) / "skillware" + return Path.home() / "skillware" + + return Path.home() / ".config" / "skillware" + + +def global_config_path() -> Path: + return global_config_dir() / GLOBAL_CONFIG_FILENAME + + +def find_project_config_file(start: Optional[Path] = None) -> Optional[Path]: + """Walk up from ``start`` (default cwd) for ``.skillware.yaml``.""" + current = (start or Path.cwd()).resolve() + for _ in range(_MAX_PARENT_WALK): + candidate = current / PROJECT_CONFIG_FILENAME + if candidate.is_file(): + return candidate + parent = current.parent + if parent == current: + break + current = parent + return None + + +def _read_yaml(path: Path) -> Mapping[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + + +def _parse_resolution_order(raw: Any) -> Tuple[str, ...]: + if not isinstance(raw, list) or not raw: + return _DEFAULT_RESOLUTION_ORDER + + tiers: List[str] = [] + seen: set[str] = set() + for item in raw: + key = str(item).strip().lower() + if key not in _VALID_ORDER_TIERS or key in seen: + continue + seen.add(key) + tiers.append(key) + + if "bundled" not in seen: + tiers.append("bundled") + + return tuple(tiers) if tiers else _DEFAULT_RESOLUTION_ORDER + + +def _layer_from_file(path: Path) -> ConfigLayer: + return ConfigLayer(path=path.resolve(), data=_read_yaml(path)) + + +def _merge_extra_section( + existing: Dict[str, Any], layer_data: Mapping[str, Any] +) -> Dict[str, Any]: + merged = dict(existing) + for key, value in layer_data.items(): + if key in _KNOWN_TOP_LEVEL_KEYS: + continue + merged[key] = value + return merged + + +def _merge_layers(layers: Sequence[ConfigLayer]) -> SkillwareConfig: + paths = PathsSettings() + extra: Dict[str, Any] = {} + + for layer in layers: + extra = _merge_extra_section(extra, layer.data) + + paths_block = layer.data.get("paths") + if isinstance(paths_block, dict): + if "project" in paths_block: + project_value = paths_block.get("project") + if project_value is None: + paths.project = "auto" + else: + paths.project = str(project_value).strip() or "auto" + + raw_external = paths_block.get("external") + if isinstance(raw_external, list): + for entry in raw_external: + text = str(entry).strip() + if text and text not in paths.external: + paths.external.append(text) + + resolution_block = layer.data.get("resolution") + if isinstance(resolution_block, dict) and "order" in resolution_block: + paths.resolution_order = _parse_resolution_order( + resolution_block.get("order") + ) + + legacy_block = layer.data.get("legacy") + if ( + isinstance(legacy_block, dict) + and "honor_skillware_skill_path" in legacy_block + ): + paths.honor_skillware_skill_path = bool( + legacy_block.get("honor_skillware_skill_path") + ) + + return SkillwareConfig(paths=paths, extra=extra, layers=tuple(layers)) + + +def load_merged_config(*, refresh: bool = False) -> SkillwareConfig: + """ + Load global then project config (walk-up). Later layers override earlier + fields. Returns a config with ``has_config_files=False`` when no YAML exists. + """ + global _merged_config_cache + if not refresh and _merged_config_cache is not None: + return _merged_config_cache + + layers: List[ConfigLayer] = [] + global_path = global_config_path() + if global_path.is_file(): + layers.append(_layer_from_file(global_path)) + + project_path = find_project_config_file() + if project_path is not None and ( + not layers or project_path.resolve() != layers[0].path.resolve() + ): + layers.append(_layer_from_file(project_path)) + + if not layers: + _merged_config_cache = SkillwareConfig() + return _merged_config_cache + + _merged_config_cache = _merge_layers(layers) + return _merged_config_cache + + +def format_config_sources(config: SkillwareConfig) -> List[str]: + """Human-readable list of config files that were loaded.""" + if not config.layers: + return ["(none — implicit env/cwd/bundled resolution)"] + return [str(layer.path) for layer in config.layers] diff --git a/skillware/core/discovery.py b/skillware/core/discovery.py index 5df43ce..e13c6b0 100644 --- a/skillware/core/discovery.py +++ b/skillware/core/discovery.py @@ -8,12 +8,14 @@ from pathlib import Path from typing import Dict, List, Optional, Sequence, Tuple +from skillware.core.config import PathsSettings, SkillwareConfig, load_merged_config + SKILLWARE_SKILL_PATH_ENV = "SKILLWARE_SKILL_PATH" _MAX_PARENT_WALK = 6 class SkillRootTier(str, Enum): - """Provenance tier for a filesystem skills root (aligned with trust doc / #234).""" + """Provenance tier for a filesystem skills root (see skill trust model doc).""" EXTERNAL = "external" PROJECT = "project" @@ -119,6 +121,116 @@ def bundled_skill_root(*, include_missing: bool = False) -> SkillRoot: ) +def _external_roots_from_config( + paths: PathsSettings, *, include_missing: bool = False +) -> List[SkillRoot]: + roots: List[SkillRoot] = [] + seen: set[str] = set() + + for entry in paths.external: + path = Path(entry).expanduser() + resolved = path.resolve() if path.exists() else path + key = str(resolved) + if key in seen: + continue + exists = path.is_dir() + if exists or include_missing: + seen.add(key) + roots.append( + SkillRoot( + path=resolved, + tier=SkillRootTier.EXTERNAL, + source="config paths.external", + exists=exists, + ) + ) + + if paths.honor_skillware_skill_path: + for root in env_skill_roots(include_missing=include_missing): + key = str(root.path) + if key in seen: + continue + seen.add(key) + roots.append(root) + + return roots + + +def _project_roots_from_config( + paths: PathsSettings, *, include_missing: bool = False +) -> List[SkillRoot]: + if paths.project_is_auto(): + return cwd_skill_roots(include_missing=include_missing) + + path = Path(str(paths.project)).expanduser() + resolved = path.resolve() if path.exists() else path + exists = path.is_dir() + if exists or include_missing: + return [ + SkillRoot( + path=resolved, + tier=SkillRootTier.PROJECT, + source="config paths.project", + exists=exists, + ) + ] + return [] + + +def _configured_skill_roots( + config: SkillwareConfig, *, include_missing: bool = False +) -> List[SkillRoot]: + paths = config.paths + tier_builders = { + SkillRootTier.PROJECT: lambda: _project_roots_from_config( + paths, include_missing=include_missing + ), + SkillRootTier.EXTERNAL: lambda: _external_roots_from_config( + paths, include_missing=include_missing + ), + SkillRootTier.BUNDLED: lambda: [bundled_skill_root(include_missing=True)], + } + + roots: List[SkillRoot] = [] + seen: set[str] = set() + for tier_name in paths.resolution_order: + try: + tier = SkillRootTier(tier_name) + except ValueError: + continue + builder = tier_builders.get(tier) + if builder is None: + continue + for root in builder(): + key = str(root.path) + if key in seen: + continue + if root.exists or include_missing: + seen.add(key) + roots.append(root) + + return roots + + +def _legacy_skill_roots(*, include_missing: bool = False) -> List[SkillRoot]: + roots: List[SkillRoot] = [] + seen: set[str] = set() + + for root in ( + env_skill_roots(include_missing=include_missing) + + cwd_skill_roots(include_missing=include_missing) + + [bundled_skill_root(include_missing=True)] + ): + key = str(root.path) + if key in seen: + continue + if root.exists or include_missing: + seen.add(key) + roots.append(root) + + return roots + + def get_skill_roots( skills_root_override: Optional[Path] = None, *, @@ -127,12 +239,16 @@ def get_skill_roots( """ Return skill roots in loader resolution order. - When ``for_display`` is False (default), only existing directories are - returned — used by ``list``, ``test``, and ``load_skill``. + When no global or project config file exists, uses legacy resolution: + ``SKILLWARE_SKILL_PATH`` → cwd ``./skills/`` walk → bundled. - When ``for_display`` is True (``skillware paths``), configured env entries - and the bundled root are shown even when missing so operators can diagnose - misconfiguration. + When config files exist, uses merged YAML (global then project) with + ``resolution.order`` (default: project → external → bundled). Bundled is + always included. Set ``legacy.honor_skillware_skill_path: false`` to ignore + the env var when config is active. + + When ``for_display`` is True (``skillware paths``), missing configured + directories are listed so operators can diagnose misconfiguration. """ if skills_root_override is not None: exists = skills_root_override.is_dir() @@ -152,20 +268,11 @@ def get_skill_roots( return [] include_missing = for_display - roots: List[SkillRoot] = [] - seen: set[str] = set() - - for root in ( - env_skill_roots(include_missing=include_missing) - + cwd_skill_roots(include_missing=include_missing) - + [bundled_skill_root(include_missing=True)] - ): - key = str(root.path) - if key in seen: - continue - if root.exists or include_missing: - seen.add(key) - roots.append(root) + config = load_merged_config() + if config.has_config_files: + roots = _configured_skill_roots(config, include_missing=include_missing) + else: + roots = _legacy_skill_roots(include_missing=include_missing) if for_display: return roots @@ -227,18 +334,55 @@ def collect_search_paths_for_skill_id(skill_id: str) -> List[str]: def build_skill_not_found_message(skill_id: str) -> str: """Operator-facing error text aligned with ``skillware paths`` output.""" + config = load_merged_config() searched = collect_search_paths_for_skill_id(skill_id) lines = [ f"Skill not found: {skill_id!r}. Searched:", *[f" {path}" for path in searched], - f"Set {SKILLWARE_SKILL_PATH_ENV} or pass an absolute path to the skill directory.", - "Run `skillware paths` to inspect resolution order and shadowing.", ] + if config.has_config_files: + lines.append( + "Check paths in .skillware.yaml or global config " + "(skillware config show)." + ) + else: + lines.append( + f"Set {SKILLWARE_SKILL_PATH_ENV}, add .skillware.yaml, " + "or pass an absolute path to the skill directory." + ) + lines.append("Run `skillware paths` to inspect resolution order and shadowing.") return "\n".join(lines) def resolution_order_summary() -> List[Tuple[str, str]]: """Short tier descriptions for docs and CLI help.""" + config = load_merged_config() + if config.has_config_files: + order = " → ".join(config.paths.resolution_order) + return [ + ( + "Config", + f"Merged from {len(config.layers)} file(s); order: {order}", + ), + ( + "Project", + "Explicit path or auto `./skills/` walk when tier is enabled", + ), + ( + "External", + "Paths from config `paths.external`" + + ( + f" and {SKILLWARE_SKILL_PATH_ENV}" + if config.paths.honor_skillware_skill_path + else "" + ), + ), + ( + "Bundled", + "Registry shipped inside the installed skillware package (always on)", + ), + ] + return [ ( "External", diff --git a/skillware/core/loader.py b/skillware/core/loader.py index ca4bea8..045deeb 100644 --- a/skillware/core/loader.py +++ b/skillware/core/loader.py @@ -115,9 +115,8 @@ def _resolve_skill_path(skill_path: str) -> Path: or a registry skill id (category/skill_name). Search order when the path is not an existing skill directory: - 1. SKILLWARE_SKILL_PATH entries (os.pathsep-separated roots) - 2. ./skills/ under cwd and parent directories - 3. Bundled skills shipped with the skillware package + Uses ``discovery.get_skill_roots()`` (env/cwd/bundled legacy, or merged + config when ``.skillware.yaml`` / global config exists). """ raw = skill_path.strip() if not raw: diff --git a/tests/test_cli.py b/tests/test_cli.py index f0b388a..ad03881 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -810,3 +810,19 @@ def test_main_doctor_subcommand(monkeypatch): assert exc.value.code == 0 finally: sys.argv = argv + + +def test_main_config_subcommand(monkeypatch): + import sys + from skillware.cli import main + + monkeypatch.setattr("skillware.cli.cmd_config_show", lambda **kwargs: 0) + + argv = sys.argv + sys.argv = ["skillware", "config", "show"] + try: + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 0 + finally: + sys.argv = argv diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..ddfce47 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,208 @@ +"""Tests for Skillware configuration and skill path discovery.""" + +from pathlib import Path + +import pytest + +from skillware.core.config import ( + GLOBAL_CONFIG_DIR_ENV, + PROJECT_CONFIG_FILENAME, + clear_config_cache, + find_project_config_file, + load_merged_config, +) +from skillware.core.discovery import ( + SKILLWARE_SKILL_PATH_ENV, + SkillRootTier, + get_skill_roots, +) +from skillware.core.loader import SkillLoader + + +def _write_config(path: Path, body: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + + +def _write_registry_skill(root: Path, category: str, name: str) -> None: + skill_dir = root / category / name + skill_dir.mkdir(parents=True) + (skill_dir / "skill.py").write_text( + "from skillware.core.base_skill import BaseSkill\n" + "class S(BaseSkill):\n" + " @property\n" + " def manifest(self): return {'name': '%s/%s'}\n" + " def execute(self, p): return {}\n" % (category, name), + encoding="utf-8", + ) + (skill_dir / "manifest.yaml").write_text( + f"name: {category}/{name}\nversion: 0.1.0\n" + "parameters:\n type: object\n properties: {}\n", + encoding="utf-8", + ) + + +@pytest.fixture(autouse=True) +def _reset_config_cache(): + clear_config_cache() + yield + clear_config_cache() + + +def test_no_config_files_uses_legacy_order(tmp_path, monkeypatch): + env_root = tmp_path / "external" + env_root.mkdir() + project_root = tmp_path / "project" / "skills" + project_root.mkdir(parents=True) + monkeypatch.chdir(tmp_path / "project") + monkeypatch.setenv(SKILLWARE_SKILL_PATH_ENV, str(env_root)) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(tmp_path / "empty-config")) + + roots = get_skill_roots() + tiers = [root.tier for root in roots] + + assert tiers[0] == SkillRootTier.EXTERNAL + assert tiers[1] == SkillRootTier.PROJECT + assert tiers[-1] == SkillRootTier.BUNDLED + + +def test_project_config_external_paths(tmp_path, monkeypatch): + external = tmp_path / "private-skills" + external.mkdir() + _write_registry_skill(external, "office", "private_skill") + + repo = tmp_path / "repo" + repo.mkdir() + _write_config( + repo / PROJECT_CONFIG_FILENAME, + "paths:\n external:\n - %s\n" % external.as_posix(), + ) + monkeypatch.chdir(repo) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(tmp_path / "no-global")) + + config = load_merged_config(refresh=True) + assert config.has_config_files + assert Path(config.paths.external[0]) == external.resolve() + + roots = get_skill_roots() + assert any(root.path == external.resolve() for root in roots) + assert roots[-1].tier == SkillRootTier.BUNDLED + + bundle = SkillLoader.load_skill("office/private_skill") + assert bundle["manifest"]["name"] == "office/private_skill" + + +def test_config_resolution_order_project_before_external(tmp_path, monkeypatch): + project = tmp_path / "repo" / "skills" + external = tmp_path / "external" + project.mkdir(parents=True) + external.mkdir() + _write_registry_skill(project, "demo", "from_project") + _write_registry_skill(external, "demo", "from_external") + + repo = tmp_path / "repo" + _write_config( + repo / PROJECT_CONFIG_FILENAME, + "paths:\n project: auto\n external:\n - %s\n" + "resolution:\n order:\n - project\n - external\n - bundled\n" + % external.as_posix(), + ) + monkeypatch.chdir(repo) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(tmp_path / "no-global")) + monkeypatch.delenv(SKILLWARE_SKILL_PATH_ENV, raising=False) + + bundle = SkillLoader.load_skill("demo/from_project") + assert bundle["manifest"]["name"] == "demo/from_project" + + +def test_honor_skillware_skill_path_false_ignores_env(tmp_path, monkeypatch): + env_root = tmp_path / "env-skills" + env_root.mkdir() + _write_registry_skill(env_root, "demo", "env_skill") + + repo = tmp_path / "repo" + repo.mkdir() + _write_config( + repo / PROJECT_CONFIG_FILENAME, + "paths:\n external: []\nlegacy:\n honor_skillware_skill_path: false\n", + ) + monkeypatch.chdir(repo) + monkeypatch.setenv(SKILLWARE_SKILL_PATH_ENV, str(env_root)) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(tmp_path / "no-global")) + + roots = get_skill_roots() + assert not any(root.tier == SkillRootTier.EXTERNAL for root in roots) + + with pytest.raises(FileNotFoundError): + SkillLoader.load_skill("demo/env_skill") + + +def test_global_and_project_config_merge(tmp_path, monkeypatch): + global_dir = tmp_path / "global-config" + global_external = tmp_path / "global-external" + global_external.mkdir() + _write_config( + global_dir / "config.yaml", + "paths:\n external:\n - %s\n" % global_external.as_posix(), + ) + + project_external = tmp_path / "project-external" + project_external.mkdir() + repo = tmp_path / "repo" + repo.mkdir() + _write_config( + repo / PROJECT_CONFIG_FILENAME, + "paths:\n external:\n - %s\n" % project_external.as_posix(), + ) + + monkeypatch.chdir(repo) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(global_dir)) + + config = load_merged_config(refresh=True) + assert len(config.layers) == 2 + assert Path(config.paths.external[0]) == global_external.resolve() + assert Path(config.paths.external[1]) == project_external.resolve() + + +def test_find_project_config_walks_up(tmp_path, monkeypatch): + repo = tmp_path / "repo" + nested = repo / "a" / "b" + nested.mkdir(parents=True) + _write_config(repo / PROJECT_CONFIG_FILENAME, "paths:\n project: auto\n") + monkeypatch.chdir(nested) + + assert find_project_config_file() == (repo / PROJECT_CONFIG_FILENAME).resolve() + + +def test_extra_config_sections_preserved(tmp_path, monkeypatch): + repo = tmp_path / "repo" + repo.mkdir() + _write_config( + repo / PROJECT_CONFIG_FILENAME, + "paths:\n project: auto\n" + "theme:\n preset: dark\n" + "chains:\n default: []\n", + ) + monkeypatch.chdir(repo) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(tmp_path / "no-global")) + + config = load_merged_config(refresh=True) + assert "theme" in config.extra + assert "chains" in config.extra + + +def test_cmd_config_show_reports_no_files(tmp_path, monkeypatch): + import io + from rich.console import Console + + from skillware.cli import cmd_config_show + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(tmp_path / "empty")) + + buf = io.StringIO() + console = Console(file=buf, force_terminal=False, width=100) + assert cmd_config_show(console=console) == 0 + output = buf.getvalue() + assert "No config files found" in output + assert "legacy resolution" in output