diff --git a/CHANGELOG.md b/CHANGELOG.md index 993b3a0..8b33b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ## [Unreleased] +### Added + +- **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). + ### Changed - **Loader:** `SkillLoader.load_skill()` validates manifest `requirements` version specifiers (for example `web3>=6.0.0`) against installed package versions before loading `skill.py`; unpinned entries still require importability only (#14). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88bcec2..67b51bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Pick the path that matches your issue. Only the **skill** row requires the full | **Skill upgrade** | Existing bundle under `skills/` | `skill upgrade`, `enhancement` | Skill Upgrade issue | Bundle test + catalog/docs as needed | | **Documentation** | `docs/`, `README.md`, `CONTRIBUTING.md` | `documentation` | Documentation Fix issue | Links valid; tone consistent | | **Core framework** | `skillware/core/`, framework `tests/` | `core framework`, `enhancement` | Framework Feature issue | `pytest tests/`; update usage docs if API changes | -| **CLI** | `skillware/cli.py`, `docs/usage/cli.md` | `cli` | CLI issue | `pytest tests/test_cli.py` when relevant | +| **CLI** | `skillware/cli.py`, `docs/usage/cli.md` | `cli` | CLI issue | `pytest tests/test_cli.py` when relevant (`list`, `doctor`, `test`, `paths`, `examples`, menu) | | **Examples** | `examples/*.py`, agent loops, examples index | `examples` | Examples issue | Script runs; `pytest tests/test_registry_docs.py` when index changes | | **Packaging** | `pyproject.toml`, `MANIFEST.in`, wheel | `packaging` | Packaging issue | `scripts/wheel_smoke_test.py` after wheel build (see [TESTING.md](docs/TESTING.md#packaging-smoke-test)) | | **Bug fix** | Paths named in issue | `bug` | Bug Report | Reproduction or failing test | @@ -54,7 +54,7 @@ Check [existing issues](https://github.com/ARPAHLS/skillware/issues) before star | New capability in the registry | [New Skill Proposal](https://github.com/ARPAHLS/skillware/issues/new/choose) | | Upgrade an existing skill | [Skill Upgrade](https://github.com/ARPAHLS/skillware/issues/new/choose) | | Loader, adapters, `base_skill` | [Framework Feature](https://github.com/ARPAHLS/skillware/issues/new/choose) | -| CLI (`list`, `test`, `examples`, menu) | [CLI](https://github.com/ARPAHLS/skillware/issues/new/choose) | +| CLI (`list`, `test`, `doctor`, `examples`, menu) | [CLI](https://github.com/ARPAHLS/skillware/issues/new/choose) | | Runnable examples / agent loops | [Examples](https://github.com/ARPAHLS/skillware/issues/new/choose) | | PyPI wheel / install packaging | [Packaging](https://github.com/ARPAHLS/skillware/issues/new/choose) | | Docs only | [Documentation Fix](https://github.com/ARPAHLS/skillware/issues/new/choose) | diff --git a/docs/TESTING.md b/docs/TESTING.md index c580fe8..e67f0c7 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -223,9 +223,10 @@ Pytest is configured to collect from `tests/` and `skills/` only (`examples/` is Before pushing your code, run the following commands: 1. `skillware list` (verify install and path resolution) -2. `python -m black --check .` (verify formatting; use `python -m black .` to fix) -3. `python -m flake8 .` (check quality) -4. `python -m pytest skills/` or `skillware test` (bundle tests — same scope as CI) -5. `python -m pytest tests/` (framework + maintainer tests — same scope as CI) -6. `python scripts/sync_extras.py --check` (when `manifest.yaml` or `pyproject.toml` extras change) -7. `python -m pytest skills///test_skill.py` or `skillware test /` for a single skill +2. `skillware doctor` (optional — check manifest deps and skill.py import readiness) +3. `python -m black --check .` (verify formatting; use `python -m black .` to fix) +4. `python -m flake8 .` (check quality) +5. `python -m pytest skills/` or `skillware test` (bundle tests — same scope as CI) +6. `python -m pytest tests/` (framework + maintainer tests — same scope as CI) +7. `python scripts/sync_extras.py --check` (when `manifest.yaml` or `pyproject.toml` extras change) +8. `python -m pytest skills///test_skill.py` or `skillware test /` for a single skill diff --git a/docs/usage/README.md b/docs/usage/README.md index 6afcd55..87a9ba0 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -16,7 +16,7 @@ By default, `SkillLoader.load_skill()` validates manifest `requirements` before > **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, or run bundle tests from the terminal, see the [CLI reference](cli.md) (`skillware list`, `skillware paths`, `skillware test`). +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`). | Provider | Adapter | Guide | Agent API key (typical) | | :--- | :--- | :--- | :--- | @@ -26,7 +26,7 @@ To list locally available skills, inspect path resolution, or run bundle tests f | 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 test`, `skillware examples` | [cli.md](cli.md) | pytest in `[dev]` for `test` | +| CLI | `skillware list`, `skillware paths`, `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 9a840bb..7d23655 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -35,6 +35,7 @@ After installation, the `skillware` command is available directly: skillware skillware list + skillware doctor skillware test skillware examples skillware --version @@ -56,6 +57,7 @@ as Python is installed): python -m skillware python -m skillware list python -m skillware test finance/wallet_screening + python -m skillware doctor optimization/prompt_rewriter python -m skillware list --category compliance python -m skillware --help @@ -105,7 +107,8 @@ Available commands: | `2` / `examples` | Browse runnable example scripts (index from `examples/README.md`, or from GitHub when no local copy exists) | Available | | `3` / `test` | Run bundle tests (`test_skill.py`) for one or all skills | Available | | `4` / `paths` | Show skill root resolution order, tiers, and shadowing | Available | -| `5` / `help` | Print rich-formatted help with commands, flags, and examples | Available | +| `5` / `doctor` | Check manifest deps and skill.py import readiness | Available | +| `6` / `help` | Print rich-formatted help with commands, flags, and examples | Available | ## Commands @@ -207,6 +210,30 @@ Show where Skillware looks for skills — same order as `SkillLoader.load_skill( Read-only in v0.4.x; persist project/external paths via config is tracked in #246. Interactive menu: **`4` / `paths`**. +### skillware doctor + +Check whether skills can load in the current environment — manifest **requirements** pre-flight (**DEPS**) and `skill.py` import (**LOAD**) — without running `execute()`. Uses the same skill roots as `skillware list`. + + skillware doctor + skillware doctor finance/wallet_screening + skillware doctor --category compliance + skillware doctor --skills-root /path/to/my/skills + +#### Arguments and flags + +| Input | Description | +| :--- | :--- | +| *(no args)* | Diagnose all registry skills visible to `list` | +| `/` | Diagnose one skill | +| `--category ` | Diagnose all skills in a category | +| `--skills-root ` | Override the skills directory for discovery and load | + +**DEPS** validates manifest `requirements` (same rules as `SkillLoader.load_skill()`). **LOAD** imports `skill.py` and discovers the `BaseSkill` subclass; it is skipped (shown as `—`) when **DEPS** fails. The **DETAIL** column shows the first line of any error. + +Exit code is non-zero when any skill fails **DEPS** or **LOAD**. For full bundle behavior, use `skillware test`. + +Interactive menu: **`5` / `doctor`**. + ## Path resolution `skillware list` searches for skills in the same order as `SkillLoader`: diff --git a/docs/usage/install_extras.md b/docs/usage/install_extras.md index b64bdba..b10bc6d 100644 --- a/docs/usage/install_extras.md +++ b/docs/usage/install_extras.md @@ -158,6 +158,8 @@ On failure it raises `ImportError` with: The loader does not install or upgrade packages — use pip extras or install the requirement strings yourself. +Use `SkillLoader.load_skill(..., execute_module=False)` for inspect-only loads (manifest, instructions, card, and optional requirement pre-flight) without executing `skill.py`. The CLI `skillware doctor` command uses this mode for the **DEPS** column before attempting a full import for **LOAD**. + Packaging smoke tests use `check_requirements=False` so a base wheel install can verify bundles without optional extras ([TESTING.md](../TESTING.md#packaging-smoke-test)). ## Contributors diff --git a/docs/vision.md b/docs/vision.md index 0aba735..2e34e83 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -45,7 +45,7 @@ Multi-layer screening runs locally in one `execute()` call. No generated scraper Skillware is designed so agents and their operators can discover, vet, and integrate capabilities without reinventing the wheel. - **Manifests** declare inputs, outputs, dependencies, and constitution in `manifest.yaml`. -- **`skillware list`**, **`skillware examples`** / **`skillware list --examples`**, and **`skillware test`** (CLI) surface the local registry, browse the runnable example index, and run bundle tests. +- **`skillware list`**, **`skillware doctor`**, **`skillware examples`** / **`skillware list --examples`**, and **`skillware test`** (CLI) surface the local registry, check load readiness, browse the runnable example index, and run bundle tests. - **[Examples index](../examples/README.md)** maps runnable provider scripts to skills. - **[Usage guides](usage/README.md)** show the same load / tool-call / execute loop for Gemini, Claude, OpenAI, DeepSeek, and Ollama. - **[Agent contribution workflow](contributing/ai_native_workflow.md)** documents how supervised agents propose scoped changes and open PRs. @@ -106,7 +106,7 @@ Honest snapshot for **v0** (current v0.4.x line): - **Registry**: Skills under `skills/` with docs in [docs/skills/](skills/README.md). - **Loader**: Dynamic import, auto-discovered `bundle["class"]`, dependency checks, and adapters for major LLM tool formats. -- **CLI**: `skillware list`, `skillware test`, and an interactive menu, included with `pip install skillware`. Use `skillware list --examples` and `skillware examples` to browse the runnable script index from the terminal. +- **CLI**: `skillware list`, `skillware doctor`, `skillware test`, and an interactive menu, included with `pip install skillware`. Use `skillware list --examples` and `skillware examples` to browse the runnable script index from the terminal. - **Active work**: Wallet screening enhancements ([RFC #115](https://github.com/ARPAHLS/skillware/issues/115)), contributor docs, and good first issues across docs and framework. Browse [open good first issues](https://github.com/ARPAHLS/skillware/issues?q=is%3Aopen+label%3A%22good+first+issue%22) if you want a low-risk entry point. diff --git a/skillware/cli.py b/skillware/cli.py index 2d00b14..e03d9a8 100644 --- a/skillware/cli.py +++ b/skillware/cli.py @@ -568,6 +568,135 @@ def cmd_paths( return 0 +def _doctor_load_target( + skill_id: str, skills_root_override: Optional[Path] = None +) -> str: + """Resolve skill path for doctor; honor --skills-root like list discovery.""" + if skills_root_override is not None: + candidate = skills_root_override.expanduser().resolve() / skill_id + if candidate.is_dir() and SkillLoader._is_skill_dir(candidate): + return str(candidate) + return skill_id + + +def _resolve_doctor_skill_ids( + skills_root_override: Optional[Path] = None, + skill_id: Optional[str] = None, + category: Optional[str] = None, +) -> Tuple[List[str], Optional[str]]: + if skill_id and category: + return [], "Use either a skill ID or --category, not both." + + if skill_id: + return [skill_id.replace("\\", "/").strip("/")], None + + skills = _discover_skills(skills_root_override) + if category: + skills = [skill for skill in skills if skill["category"] == category] + + if not skills: + if category: + return [], f"No skills found in category '{category}'." + return [], "No skills found." + + return [skill["id"] for skill in skills], None + + +def _diagnose_skill( + skill_id: str, + skills_root_override: Optional[Path] = None, +) -> Tuple[str, str, str]: + """Return (deps_status, load_status, detail). Status values: ok, fail, skip.""" + load_target = _doctor_load_target(skill_id, skills_root_override) + + try: + SkillLoader.load_skill( + load_target, + execute_module=False, + check_requirements=True, + ) + deps_status = "ok" + except ImportError as exc: + detail = _flatten_table_cell(str(exc).splitlines()[0], 72) + return "fail", "skip", detail + + try: + SkillLoader.load_skill( + load_target, + execute_module=True, + check_requirements=False, + ) + return deps_status, "ok", "" + except ImportError as exc: + detail = _flatten_table_cell(str(exc).splitlines()[0], 72) + return deps_status, "fail", detail + + +def cmd_doctor( + skills_root_override: Optional[Path] = None, + skill_id: Optional[str] = None, + category: Optional[str] = None, + console=None, +) -> int: + """Check manifest deps and skill.py import without running execute().""" + if console is None: + console = Console(stderr=True) + + skill_ids, error = _resolve_doctor_skill_ids( + skills_root_override=skills_root_override, + skill_id=skill_id, + category=category, + ) + if error: + console.print(error, style="bold #FF9AA2") + return 2 if skill_id and category else 1 + + table = Table( + box=box.SIMPLE_HEAVY, + border_style=BORDER_STYLE, + header_style=TABLE_STYLE, + expand=True, + ) + table.add_column("ID", style=ID_STYLE, no_wrap=True, ratio=2) + table.add_column("DEPS", no_wrap=True, ratio=1) + table.add_column("LOAD", no_wrap=True, ratio=1) + table.add_column("DETAIL", style="dim", ratio=4) + + failures = 0 + for sid in sorted(skill_ids): + try: + deps_status, load_status, detail = _diagnose_skill( + sid, skills_root_override=skills_root_override + ) + except FileNotFoundError as exc: + console.print(str(exc), style="bold #FF9AA2") + return 1 + + if deps_status != "ok" or load_status == "fail": + failures += 1 + + deps_cell = Text( + deps_status, + style=ID_STYLE if deps_status == "ok" else "bold #FF9AA2", + ) + if load_status == "skip": + load_cell = Text("—", style="dim") + elif load_status == "ok": + load_cell = Text(load_status, style=ID_STYLE) + else: + load_cell = Text(load_status, style="bold #FF9AA2") + + table.add_row(sid, deps_cell, load_cell, detail or "—") + + console.print(table) + console.print( + "DEPS = manifest requirements; LOAD = skill.py import. " + "See docs/usage/install_extras.md", + style="dim", + ) + return 1 if failures else 0 + + def _prompt_examples_skill_id(console) -> Tuple[Optional[str], bool]: """Return (skill_id or None for all, should_run).""" try: @@ -614,6 +743,9 @@ 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 doctor — check deps and skill.py import") + console.print(" skillware doctor — diagnose one skill") + console.print(" skillware doctor --category — diagnose a category") console.print(" skillware --version — print installed version") console.print() @@ -622,13 +754,14 @@ 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(" doctor available now", style=ID_STYLE) console.print() console.print(Text("Interactive mode", style=f"bold {TABLE_STYLE}")) console.print( " skillware — open interactive menu", style="dim" ) - console.print(" 1-5 or command name — select a menu option", style="dim") + console.print(" 1-6 or command name — select a menu option", style="dim") console.print(" q or Ctrl+C — exit", style="dim") console.print() @@ -638,6 +771,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 doctor --category compliance", style=MENU_STYLE) console.print() console.print(Text("Install", style=f"bold {TABLE_STYLE}")) @@ -727,7 +861,8 @@ def cmd_interactive(console=None, parser=None) -> None: ("2", "examples", "browse runnable scripts from examples/README.md"), ("3", "test", "run bundle tests (test_skill.py) for one or all skills"), ("4", "paths", "show skill directory resolution order and shadowing"), - ("5", "help", "usage guide for any command"), + ("5", "doctor", "check manifest deps and skill.py import readiness"), + ("6", "help", "usage guide for any command"), ] commands = { @@ -739,7 +874,9 @@ def cmd_interactive(console=None, parser=None) -> None: "test": "test", "4": "paths", "paths": "paths", - "5": "help", + "5": "doctor", + "doctor": "doctor", + "6": "help", "help": "help", } @@ -768,6 +905,10 @@ def cmd_interactive(console=None, parser=None) -> None: cmd_test(console=console) elif command == "paths": cmd_paths(console=console) + elif command == "doctor": + rc = cmd_doctor(console=console) + if rc: + console.print(f" doctor exited with status {rc}", style="dim #FF9AA2") elif command == "help": cmd_help(console=console) else: @@ -878,6 +1019,28 @@ def main() -> None: help="Override the skills directory path for this command only.", ) + doctor_parser = subparsers.add_parser( + "doctor", + help="Check manifest deps and skill.py import readiness.", + ) + doctor_parser.add_argument( + "skill_id", + nargs="?", + default=None, + help="Skill ID (category/skill_name) to diagnose.", + ) + doctor_parser.add_argument( + "--skills-root", + type=Path, + default=None, + help="Override the skills directory path.", + ) + doctor_parser.add_argument( + "--category", + default=None, + help="Diagnose all skills in a category.", + ) + args = parser.parse_args() if args.help and args.command is None: @@ -905,6 +1068,14 @@ def main() -> None: ) elif args.command == "paths": raise SystemExit(cmd_paths(skills_root_override=args.skills_root)) + elif args.command == "doctor": + raise SystemExit( + cmd_doctor( + skills_root_override=args.skills_root, + skill_id=args.skill_id, + category=args.category, + ) + ) else: cmd_interactive(parser=parser) diff --git a/skillware/core/extras.py b/skillware/core/extras.py index 5221d6b..6e5d4a7 100644 --- a/skillware/core/extras.py +++ b/skillware/core/extras.py @@ -260,6 +260,34 @@ def build_missing_requirements_message( return "\n".join(lines) +def build_skill_module_import_error( + manifest: Mapping[str, object], + registry_id: Optional[str], + skill_file: str, + exc: BaseException, +) -> str: + """Build a diagnostic when skill.py import fails after manifest pre-flight.""" + skill_name = str(manifest.get("name") or registry_id or "unknown") + lines = [ + f"Skill '{skill_name}' failed while importing {skill_file}: {exc}", + "", + "Manifest requirement pre-flight passed; this error usually means", + "skill.py imports a package that is missing from manifest requirements", + "or is not installed in the current environment.", + "", + ] + missing_name = getattr(exc, "name", None) + if missing_name: + lines.append(f"Missing module: {missing_name}") + lines.append("") + hints = _pip_install_hints(registry_id) + if hints: + lines.extend(hints) + else: + lines.append("Add the package to manifest requirements and reinstall extras.") + return "\n".join(lines) + + def build_version_mismatch_message( manifest: Mapping[str, object], registry_id: Optional[str], diff --git a/skillware/core/loader.py b/skillware/core/loader.py index f327407..ca4bea8 100644 --- a/skillware/core/loader.py +++ b/skillware/core/loader.py @@ -18,7 +18,10 @@ get_skill_roots, is_skill_dir, ) -from skillware.core.extras import check_manifest_requirements +from skillware.core.extras import ( + build_skill_module_import_error, + check_manifest_requirements, +) SKILLWARE_SKILL_PATH_ENV = _discovery.SKILLWARE_SKILL_PATH_ENV @@ -167,18 +170,22 @@ def get_skill_class(skill_bundle: Dict[str, Any]) -> Type[Any]: skill_class = skill_bundle.get("class") if skill_class is None: raise KeyError( - "Skill bundle has no 'class' key; load with SkillLoader.load_skill() first." + "Skill bundle has no 'class' key; use SkillLoader.load_skill(..., " + "execute_module=True) (default) for a full load." ) return skill_class @staticmethod def load_skill( - skill_path: str, *, check_requirements: bool = True + skill_path: str, + *, + check_requirements: bool = True, + execute_module: bool = True, ) -> Dict[str, Any]: """ Loads a skill and returns a bundled object with: - - module: The loaded skill.py module - - class: The BaseSkill subclass (uninstantiated) + - module: The loaded skill.py module (``None`` when ``execute_module=False``) + - class: The BaseSkill subclass (``None`` when ``execute_module=False``) - manifest: The YAML metadata - instructions: The system prompt content - card: The UI card definition @@ -186,6 +193,9 @@ def load_skill( Set ``check_requirements=False`` for packaging smoke tests that install the base wheel without optional skill extras (``[all]``, ``[defi]``, etc.). + + Set ``execute_module=False`` for inspect-only loads (manifest, instructions, + card, and optional requirement pre-flight) without executing ``skill.py``. """ resolved_path = SkillLoader._resolve_skill_path(skill_path) skill_path = str(resolved_path) @@ -221,20 +231,38 @@ def load_skill( with open(card_path, "r", encoding="utf-8") as f: card = json.load(f) + base_bundle = { + "manifest": manifest, + "instructions": instructions, + "card": card, + "registry_id": registry_id, + } + + if not execute_module: + return { + **base_bundle, + "module": None, + "class": None, + } + # Load Python Module skill_file = os.path.join(skill_path, "skill.py") spec = importlib.util.spec_from_file_location("skill_module", skill_file) if spec and spec.loader: module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + try: + spec.loader.exec_module(module) + except ImportError as exc: + raise ImportError( + build_skill_module_import_error( + manifest, registry_id, skill_file, exc + ) + ) from exc skill_class = SkillLoader._discover_skill_class(module, skill_file) return { + **base_bundle, "module": module, "class": skill_class, - "manifest": manifest, - "instructions": instructions, - "card": card, - "registry_id": registry_id, } return {} diff --git a/tests/test_cli.py b/tests/test_cli.py index 7e5db44..f0b388a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,8 +12,11 @@ _short_description, cmd_help, cmd_paths, + cmd_doctor, ) +import importlib.util + import pytest @@ -257,11 +260,11 @@ def test_cmd_help_includes_list_examples(capsys): def test_interactive_help_dispatches_to_cmd_help(monkeypatch): - """Interactive menu option 5 / help should call cmd_help.""" + """Interactive menu option 6 / help should call cmd_help.""" import io from rich.console import Console - responses = iter(["5", "q"]) + responses = iter(["6", "q"]) monkeypatch.setattr("builtins.input", lambda _: next(responses)) buf = io.StringIO() @@ -679,6 +682,7 @@ def test_cmd_help_includes_paths_command(): cmd_help(console=console) output = buf.getvalue() assert "skillware paths" in output + assert "skillware doctor" in output assert "available now" in output assert "coming soon" not in output.lower() @@ -713,3 +717,96 @@ def test_interactive_paths_dispatches(monkeypatch): output = buf.getvalue() assert "Skill path resolution" in output assert "not yet implemented" not in output.lower() + + +def test_interactive_doctor_dispatches(monkeypatch): + import io + from rich.console import Console + + responses = iter(["5", "q"]) + monkeypatch.setattr("builtins.input", lambda _: next(responses)) + + buf = io.StringIO() + console = Console(file=buf, force_terminal=False, width=120) + cmd_interactive(console=console) + + output = buf.getvalue() + assert "DEPS" in output + assert "LOAD" in output + assert "manifest requirements" in output.lower() + + +def test_cmd_doctor_reports_ok_skill(tmp_path, monkeypatch): + import io + from rich.console import Console + + skill_dir = tmp_path / "skills" / "office" / "demo" + skill_dir.mkdir(parents=True) + (skill_dir / "manifest.yaml").write_text( + "name: office/demo\nversion: 0.1.0\ndescription: test\n" + "parameters:\n type: object\n properties: {}\n", + encoding="utf-8", + ) + (skill_dir / "skill.py").write_text( + "from skillware.core.base_skill import BaseSkill\n" + "class DemoSkill(BaseSkill):\n" + " def execute(self, **kwargs):\n" + " return {}\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + buf = io.StringIO() + console = Console(file=buf, force_terminal=False, width=120) + assert cmd_doctor(skill_id="office/demo", console=console) == 0 + + output = buf.getvalue() + assert "office/demo" in output + assert " ok " in output or "ok" in output + + +def test_cmd_doctor_reports_missing_deps(tmp_path, monkeypatch): + import io + from rich.console import Console + + skill_dir = tmp_path / "skills" / "demo" / "needs_pkg" + skill_dir.mkdir(parents=True) + (skill_dir / "manifest.yaml").write_text( + "name: demo/needs_pkg\nversion: 0.1.0\ndescription: test\n" + "parameters:\n type: object\n properties: {}\n" + "requirements:\n - totally_missing_pkg_xyz\n", + encoding="utf-8", + ) + (skill_dir / "skill.py").write_text( + "from skillware.core.base_skill import BaseSkill\n" + "class NeedsPkgSkill(BaseSkill):\n" + " def execute(self, **kwargs):\n" + " return {}\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(importlib.util, "find_spec", lambda name, package=None: None) + + buf = io.StringIO() + console = Console(file=buf, force_terminal=False, width=120) + assert cmd_doctor(skill_id="demo/needs_pkg", console=console) == 1 + + output = buf.getvalue() + assert "fail" in output + assert "demo/needs_pkg" in output + + +def test_main_doctor_subcommand(monkeypatch): + import sys + from skillware.cli import main + + monkeypatch.setattr("skillware.cli.cmd_doctor", lambda **kwargs: 0) + + argv = sys.argv + sys.argv = ["skillware", "doctor", "office/demo"] + try: + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 0 + finally: + sys.argv = argv diff --git a/tests/test_loader.py b/tests/test_loader.py index 2a63125..686c3d6 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -29,6 +29,41 @@ def test_load_skill_registry_has_manifest(): assert SkillLoader.get_skill_class(bundle) is bundle["class"] +def test_load_skill_inspect_mode_skips_module_exec(): + bundle = SkillLoader.load_skill( + "optimization/prompt_rewriter", + execute_module=False, + ) + assert bundle["manifest"].get("name") == "optimization/prompt_rewriter" + assert bundle["instructions"] + assert bundle["module"] is None + assert bundle["class"] is None + with pytest.raises(KeyError, match="execute_module=True"): + SkillLoader.get_skill_class(bundle) + + +def test_load_skill_module_import_error_after_preflight(tmp_path, monkeypatch): + skill_dir = tmp_path / "skills" / "demo" / "hidden_import" + skill_dir.mkdir(parents=True) + (skill_dir / "manifest.yaml").write_text( + "name: demo/hidden_import\nversion: 0.1.0\ndescription: test\n" + "parameters:\n type: object\n properties: {}\n", + encoding="utf-8", + ) + (skill_dir / "skill.py").write_text( + "import undeclared_hidden_pkg\n" + "from skillware.core.base_skill import BaseSkill\n" + "class HiddenImportSkill(BaseSkill):\n" + " def execute(self, **kwargs):\n" + " return {}\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + with pytest.raises(ImportError, match="Manifest requirement pre-flight passed"): + SkillLoader.load_skill("demo/hidden_import") + + def test_load_skill_can_skip_requirement_check(): """Packaging smoke installs base wheel only; optional extras may be absent.""" bundle = SkillLoader.load_skill( diff --git a/tests/test_requirements_check.py b/tests/test_requirements_check.py index cdb6002..7b6a281 100644 --- a/tests/test_requirements_check.py +++ b/tests/test_requirements_check.py @@ -84,3 +84,16 @@ def test_build_version_mismatch_message_includes_install_hint(): assert "web3>=6.0.0" in message assert "5.31.0" in message assert "skillware[defi_evm_tx_handler]" in message + + +def test_build_skill_module_import_error_includes_hint(): + from skillware.core.extras import build_skill_module_import_error + + message = build_skill_module_import_error( + {"name": "demo/skill"}, + "demo/skill", + "/tmp/skill.py", + ModuleNotFoundError("No module named 'hidden_pkg'"), + ) + assert "Manifest requirement pre-flight passed" in message + assert "skillware[demo_skill]" in message