diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf35fa..39345ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ### Added - **CLI**: `skillware test` runs bundle tests via pytest — all roots, by skill ID, or by `--category`; supports `-v` and `--no-header`. Documented in [cli.md](docs/usage/cli.md), [TESTING.md](docs/TESTING.md), and contributor guides (#83). +- **CLI**: `skillware list --examples` shows per-skill example script counts; `skillware examples [skill_id]` lists indexed runnable scripts from `examples/README.md` with GitHub source links; interactive menu option **examples** (#126). Ships `examples/README.md` in the wheel so pip installs can resolve the index. ## [0.3.7] - 2026-06-22 diff --git a/MANIFEST.in b/MANIFEST.in index 52194bf..7933142 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ graft skills +include examples/README.md global-exclude __pycache__ global-exclude *.py[cod] diff --git a/docs/usage/README.md b/docs/usage/README.md index af268d4..ba84497 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -21,7 +21,7 @@ To list locally available skills or run bundle tests from the terminal, see the | OpenAI (ChatGPT) | `to_openai_tool()` | [openai.md](openai.md) | `OPENAI_API_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 test` | [cli.md](cli.md) | pytest in `[dev]` for `test` | +| CLI | `skillware list`, `skillware test`, `skillware examples` | [cli.md](cli.md) | pytest in `[dev]` for `test` | 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 c03721b..3008cb4 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -18,6 +18,7 @@ After installation, the `skillware` command is available directly: skillware skillware list skillware test + skillware examples skillware --version If `skillware` is not recognized, Python's `Scripts` directory may not be on @@ -78,9 +79,9 @@ Available commands: | Input | Command | Status | | :--- | :--- | :--- | | `1` / `list` | List all locally installed skills | Available | -| `2` / `paths` | Show and repair skill directory resolution paths | Coming soon | | `3` / `test` | Run bundle tests (`test_skill.py`) for one or all skills | Available | -| `4` / `help` | Print rich-formatted help with commands, flags, and examples | Available | +| `4` / `paths` | Show and repair skill directory resolution paths | Coming soon | +| `5` / `help` | Print rich-formatted help with commands, flags, and examples | Available | ## Commands @@ -104,18 +105,44 @@ Sample output: | `--category ` | Show only skills in the given category. Discovered at runtime, never hardcoded. | | `--issuer ` | Show only skills by a given GitHub handle or issuer name. | | `--skills-root ` | Override the skills directory for this command only. | +| `--examples` | Add an **EXAMPLES** column with the count of indexed scripts per skill (`-` when none). Works with `--category` and `--issuer`. | #### Examples # Filter by category skillware list --category compliance + # Show example script counts per skill + skillware list --examples + skillware list --examples --category dev_tools + # Filter by issuer skillware list --issuer rosspeili # Use a custom skills directory skillware list --skills-root /path/to/my/skills +### skillware examples + +List runnable scripts indexed in `examples/README.md` (source of truth — the CLI does not scan `examples/*.py` directly). + + skillware examples + skillware examples compliance/tos_evaluator + skillware examples finance/wallet_screening + +#### Arguments + +| Input | Description | +| :--- | :--- | +| *(no args)* | All indexed scripts (script-first view) | +| `/` | Scripts linked to that skill ID only | + +Columns: Script, Skill ID(s), Provider, Required extra, and a **GITHUB** link to the script on `main` (for example `https://github.com/ARPAHLS/skillware/blob/main/examples/gemini_tos_evaluator.py`). Environment variables and longer notes stay in `examples/README.md`; a one-line pointer is printed below the table. + +Unknown skill IDs exit with a helpful message and non-zero status. + +In the interactive menu, choose **`2` / `examples`**, optionally enter a skill ID, then browse the same table with GitHub links. + ### skillware test Run skill **bundle tests** (`test_skill.py`) via pytest. Uses the same skill roots as `skillware list` (`SKILLWARE_SKILL_PATH`, `--skills-root`, cwd `skills/`, bundled registry). diff --git a/docs/vision.md b/docs/vision.md index 54b8fd4..ed55b14 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -106,7 +106,7 @@ Honest snapshot for **v0** (current v0.3.x line): - **Registry**: Skills under `skills/` with docs in [docs/skills/](skills/README.md). - **Loader**: Dynamic import, dependency checks, and adapters for major LLM tool formats. -- **CLI**: `skillware list`, `skillware test`, and an interactive menu, included with `pip install skillware`. +- **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. - **Active work**: Wallet screening enhancements ([RFC #115](https://github.com/ARPAHLS/skillware/issues/115)), CLI polish, 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/examples/README.md b/examples/README.md index f6c11a4..61a82d0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,9 @@ Runnable examples in this directory show how to load Skillware skills, adapt them for a provider, execute local skill logic, and return tool results to an -agent loop. Provider setup details live in the usage guides: +agent loop. From the repository root, run `skillware examples` or +`skillware list --examples` to browse the index in the terminal (see +[CLI reference](../docs/usage/cli.md)). Provider setup details live in the usage guides: - [API keys for skills](../docs/usage/api_keys.md) - [Gemini](../docs/usage/gemini.md) diff --git a/skillware/cli.py b/skillware/cli.py index f7ab925..bbfdc62 100644 --- a/skillware/cli.py +++ b/skillware/cli.py @@ -1,7 +1,9 @@ import argparse +import re import subprocess import sys import yaml +from collections import defaultdict from pathlib import Path from typing import List, Dict, Any, Optional, Tuple @@ -23,6 +25,108 @@ SPLASH_STYLE = "#C7CEEA" # lavender - skillware splash color MENU_STYLE = "#FFDAC1" # peach - menu category +_EXAMPLES_README_REL = Path("examples") / "README.md" +_PARENT_WALK_LIMIT = 6 +_SKILL_ID_PATTERN = re.compile(r"`([\w-]+/[\w-]+)`") +_EXAMPLES_GITHUB_BLOB_BASE = "https://github.com/ARPAHLS/skillware/blob/main/examples" + + +def _flatten_table_cell(text: str, max_len: int = 80) -> str: + """Strip markdown backticks and truncate for compact terminal tables.""" + cleaned = " ".join(text.replace("`", "").split()) + if len(cleaned) > max_len: + return cleaned[: max_len - 1] + "…" + return cleaned + + +def _examples_readme_display_path(readme_path: Path) -> str: + """Prefer a short relative path in CLI output.""" + try: + return readme_path.relative_to(Path.cwd()).as_posix() + except ValueError: + return "examples/README.md" + + +def _example_github_url(script: str) -> str: + """Canonical GitHub blob URL for an indexed example script.""" + return f"{_EXAMPLES_GITHUB_BLOB_BASE}/{script}" + + +def _examples_readme_path() -> Optional[Path]: + """Resolve examples/README.md for editable checkout or bundled install.""" + candidates: List[Path] = [] + + package_root = Path(__file__).resolve().parent.parent + candidates.append(package_root.parent / _EXAMPLES_README_REL) + + cwd = Path.cwd() + for directory in [cwd, *list(cwd.parents)[:_PARENT_WALK_LIMIT]]: + candidates.append(directory / _EXAMPLES_README_REL) + + seen = set() + for path in candidates: + resolved = path.resolve() + if resolved in seen: + continue + seen.add(resolved) + if resolved.is_file(): + return resolved + + return None + + +def _parse_examples_index(readme_path: Path) -> List[Dict[str, Any]]: + """Parse the Runnable Scripts table from examples/README.md.""" + text = readme_path.read_text(encoding="utf-8") + section_start = text.find("## Runnable Scripts") + if section_start == -1: + return [] + + rows: List[Dict[str, Any]] = [] + for line in text[section_start:].splitlines(): + stripped = line.strip() + if not stripped.startswith("|") or ":---" in stripped: + continue + if "Script" in stripped and "Skill ID" in stripped: + continue + + cells = [cell.strip() for cell in stripped.strip("|").split("|")] + if len(cells) < 5: + continue + + script = cells[0].strip("`") + skill_ids = _SKILL_ID_PATTERN.findall(cells[1]) + if not skill_ids: + skill_ids = [part.strip() for part in cells[1].split(",") if part.strip()] + + rows.append( + { + "script": script, + "skill_ids": skill_ids, + "provider": cells[2], + "extra": cells[3], + "env_vars": cells[4], + } + ) + + return rows + + +def _example_counts_by_skill(rows: List[Dict[str, Any]]) -> Dict[str, int]: + """Map skill ID to indexed script count (multi-skill rows count toward each ID).""" + counts: Dict[str, int] = defaultdict(int) + for row in rows: + for skill_id in row["skill_ids"]: + counts[skill_id] += 1 + return dict(counts) + + +def _load_examples_index() -> Tuple[List[Dict[str, Any]], Optional[Path]]: + readme_path = _examples_readme_path() + if readme_path is None: + return [], None + return _parse_examples_index(readme_path), readme_path + def _get_skill_roots(skills_root_override: Optional[Path] = None) -> List[Path]: """Return the list of roots to search for skills, mirrors SkillLoader resolution order.""" @@ -198,6 +302,7 @@ def cmd_list( skills_root_override: Optional[Path] = None, category_filter: Optional[str] = None, issuer_filter: Optional[str] = None, + show_examples: bool = False, console=None, ) -> None: """Print a formatted table of all available skills.""" @@ -216,6 +321,17 @@ def cmd_list( console.print("No skills found.") return + example_counts: Dict[str, int] = {} + if show_examples: + rows, readme_path = _load_examples_index() + if readme_path is None: + console.print( + "examples/README.md not found; cannot show example counts.", + style="bold #FF9AA2", + ) + else: + example_counts = _example_counts_by_skill(rows) + table = Table( box=box.SIMPLE_HEAVY, border_style=BORDER_STYLE, @@ -229,18 +345,107 @@ def cmd_list( table.add_column("ISSUER", style="dim", no_wrap=True, ratio=1) table.add_column("DESCRIPTION", ratio=3) table.add_column("REQUIREMENTS", style="dim", ratio=2) + if show_examples: + table.add_column("EXAMPLES", style="dim", no_wrap=True, ratio=1) for skill in skills: - table.add_row( + row = [ skill["id"], skill["version"], skill["category"], skill["issuer"], skill["description"], skill["requirements"], + ] + if show_examples: + count = example_counts.get(skill["id"], 0) + row.append(str(count) if count else "-") + table.add_row(*row) + + console.print(table) + + +def cmd_examples( + skill_id: Optional[str] = None, + console=None, +) -> int: + """Print runnable example scripts from examples/README.md.""" + if console is None: + console = Console() + + rows, readme_path = _load_examples_index() + if readme_path is None: + console.print("examples/README.md not found.", style="bold #FF9AA2") + return 1 + + if skill_id: + parts = skill_id.split("/") + if len(parts) != 2 or not all(parts): + console.print( + f"Invalid skill ID '{skill_id}'. Expected category/skill_name.", + style="bold #FF9AA2", + ) + return 2 + + rows = [row for row in rows if skill_id in row["skill_ids"]] + if not rows: + console.print( + f"No indexed examples for '{skill_id}'. " + f"See {_examples_readme_display_path(readme_path)} for the full inventory.", + style="bold #FF9AA2", + ) + return 1 + + if not rows: + console.print("No runnable scripts found in examples/README.md.") + return 1 + + table = Table( + box=box.SIMPLE_HEAVY, + border_style=BORDER_STYLE, + header_style=TABLE_STYLE, + expand=True, + ) + table.add_column("SCRIPT", style=ID_STYLE, no_wrap=True, ratio=2) + table.add_column("SKILL ID", style=CATEGORY_STYLE, ratio=2) + table.add_column("PROVIDER", no_wrap=True, ratio=1) + table.add_column("EXTRA", style="dim", ratio=1) + table.add_column("GITHUB", style=f"dim {SPLASH_STYLE}", ratio=3) + + for row in rows: + table.add_row( + row["script"], + ", ".join(row["skill_ids"]), + _flatten_table_cell(row["provider"], max_len=32), + _flatten_table_cell(row["extra"], max_len=28), + _example_github_url(row["script"]), ) console.print(table) + console.print( + f"Full notes: {_examples_readme_display_path(readme_path)}", + style="dim", + ) + return 0 + + +def _prompt_examples_skill_id(console) -> Tuple[Optional[str], bool]: + """Return (skill_id or None for all, should_run).""" + try: + raw = input(" skill id (optional, Enter for all): ").strip() + except (KeyboardInterrupt, EOFError): + console.print("\n Cancelled.", style="dim") + return None, False + if not raw: + return None, True + parts = raw.split("/") + if len(parts) != 2 or not all(parts): + console.print( + f" Invalid skill ID '{raw}'. Expected category/skill_name.", + style="dim #FF9AA2", + ) + return None, False + return raw, True def _print_menu(console, menu) -> None: @@ -261,6 +466,11 @@ def cmd_help(console=None) -> None: console.print(" skillware list --category — filter by category") console.print(" skillware list --issuer — filter by issuer") console.print(" skillware list --skills-root — override skills directory") + console.print( + " skillware list --examples — add per-skill example script count" + ) + console.print(" skillware examples — list indexed runnable scripts") + console.print(" skillware examples — scripts for one skill") console.print(" skillware test — run all bundle tests") console.print(" skillware test — run one skill bundle test") console.print(" skillware test --category — run tests for a category") @@ -269,6 +479,7 @@ def cmd_help(console=None) -> None: console.print(Text("Commands", style=f"bold {TABLE_STYLE}")) console.print(" list available now", style=ID_STYLE) + console.print(" examples available now", style=ID_STYLE) console.print(" test available now", style=ID_STYLE) console.print(" paths coming soon", style="dim") console.print() @@ -277,15 +488,15 @@ def cmd_help(console=None) -> None: console.print( " skillware — open interactive menu", style="dim" ) - console.print(" 1-4 or command name — select a menu option", style="dim") + console.print(" 1-5 or command name — select a menu option", style="dim") console.print(" q or Ctrl+C — exit", style="dim") console.print() console.print(Text("Examples", style=f"bold {TABLE_STYLE}")) console.print(" skillware list --category compliance", style=MENU_STYLE) - console.print(" skillware list --issuer rosspeili", style=MENU_STYLE) + console.print(" skillware list --examples --category dev_tools", style=MENU_STYLE) + console.print(" skillware examples compliance/tos_evaluator", style=MENU_STYLE) console.print(" skillware test finance/wallet_screening", style=MENU_STYLE) - console.print(" skillware test --category compliance -v", style=MENU_STYLE) console.print() console.print(Text("Install", style=f"bold {TABLE_STYLE}")) @@ -334,19 +545,22 @@ def cmd_interactive(console=None, parser=None) -> None: menu = [ ("1", "list", "discover and display all locally installed skills"), - ("2", "paths (soon)", "show and repair skill directory resolution paths"), + ("2", "examples", "browse runnable scripts from examples/README.md"), ("3", "test", "run bundle tests (test_skill.py) for one or all skills"), - ("4", "help", "usage guide for any command"), + ("4", "paths (soon)", "show and repair skill directory resolution paths"), + ("5", "help", "usage guide for any command"), ] commands = { "1": "list", "list": "list", - "2": "paths", - "paths": "paths", + "2": "examples", + "examples": "examples", "3": "test", "test": "test", - "4": "help", + "4": "paths", + "paths": "paths", + "5": "help", "help": "help", } @@ -367,6 +581,10 @@ def cmd_interactive(console=None, parser=None) -> None: if command == "list": cmd_list(console=console) + elif command == "examples": + skill_id, run = _prompt_examples_skill_id(console) + if run: + cmd_examples(skill_id=skill_id, console=console) elif command == "test": cmd_test(console=console) elif command == "paths": @@ -424,6 +642,22 @@ def main() -> None: default=None, help="Filter skills by issuer GitHub handle or name.", ) + list_parser.add_argument( + "--examples", + action="store_true", + help="Add EXAMPLES column with indexed script count per skill.", + ) + + examples_parser = subparsers.add_parser( + "examples", + help="List runnable example scripts from examples/README.md.", + ) + examples_parser.add_argument( + "skill_id", + nargs="?", + default=None, + help="Optional skill ID (category/skill_name) to filter scripts.", + ) test_parser = subparsers.add_parser( "test", @@ -469,7 +703,10 @@ def main() -> None: skills_root_override=args.skills_root, category_filter=args.category, issuer_filter=args.issuer, + show_examples=args.examples, ) + elif args.command == "examples": + raise SystemExit(cmd_examples(skill_id=args.skill_id)) elif args.command == "test": raise SystemExit( cmd_test( diff --git a/tests/test_cli.py b/tests/test_cli.py index 05370fe..f93f231 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,11 @@ from skillware.cli import ( _discover_skills, _resolve_pytest_targets, + _parse_examples_index, + _example_counts_by_skill, + _example_github_url, cmd_list, + cmd_examples, cmd_interactive, cmd_test, _short_description, @@ -247,14 +251,15 @@ def test_cmd_help_includes_list_examples(capsys): assert "--category" in output assert "--issuer" in output assert "skillware test" in output + assert "skillware examples" in output def test_interactive_help_dispatches_to_cmd_help(monkeypatch): - """Interactive menu option 4 / help should call cmd_help.""" + """Interactive menu option 5 / help should call cmd_help.""" import io from rich.console import Console - responses = iter(["4", "q"]) + responses = iter(["5", "q"]) monkeypatch.setattr("builtins.input", lambda _: next(responses)) buf = io.StringIO() @@ -439,3 +444,156 @@ def fake_test(**kwargs): cmd_interactive(console=Console(file=buf, force_terminal=False)) assert captured.get("called") is True + + +def test_interactive_examples_dispatch(examples_readme, monkeypatch): + """Entering examples should prompt and dispatch to cmd_examples.""" + import io + from rich.console import Console + + captured = {} + + def fake_examples(**kwargs): + captured["skill_id"] = kwargs.get("skill_id") + return 0 + + monkeypatch.setattr("skillware.cli.cmd_examples", fake_examples) + + responses = iter(["examples", "", "q"]) + monkeypatch.setattr("builtins.input", lambda _: next(responses)) + + buf = io.StringIO() + cmd_interactive(console=Console(file=buf, force_terminal=False)) + + assert captured.get("skill_id") is None + + +SAMPLE_EXAMPLES_README = """# Examples + +## Runnable Scripts + +| Script | Skill ID | Provider | Required extra | Required env vars | Description | +| :--- | :--- | :--- | :--- | :--- | :--- | +| `gemini_tos_evaluator.py` | `compliance/tos_evaluator` | Gemini | `[gemini]` | `GOOGLE_API_KEY` | Demo. | +| `ollama_skills_test.py` | `finance/wallet_screening`, `office/pdf_form_filler` | Ollama | `[office]` | None | Multi. | +""" + + +@pytest.fixture +def examples_readme(tmp_path, monkeypatch): + readme = tmp_path / "examples" / "README.md" + readme.parent.mkdir(parents=True) + readme.write_text(SAMPLE_EXAMPLES_README, encoding="utf-8") + monkeypatch.setattr("skillware.cli._examples_readme_path", lambda: readme) + return readme + + +def test_parse_examples_index_handles_multi_skill_ids(examples_readme): + rows = _parse_examples_index(examples_readme) + assert len(rows) == 2 + assert rows[1]["skill_ids"] == [ + "finance/wallet_screening", + "office/pdf_form_filler", + ] + + +def test_example_counts_by_skill_includes_multi_skill_rows(examples_readme): + rows = _parse_examples_index(examples_readme) + counts = _example_counts_by_skill(rows) + assert counts["compliance/tos_evaluator"] == 1 + assert counts["finance/wallet_screening"] == 1 + assert counts["office/pdf_form_filler"] == 1 + + +def test_cmd_list_examples_column(tmp_path, examples_readme): + import io + from rich.console import Console + + _make_bundle(tmp_path, "compliance", "tos_evaluator") + _make_bundle(tmp_path, "finance", "wallet_screening") + _make_bundle(tmp_path, "office", "pdf_form_filler") + _make_bundle(tmp_path, "data_engineering", "novelty_extractor") + + buf = io.StringIO() + console = Console(file=buf, force_terminal=False, width=200) + cmd_list( + skills_root_override=tmp_path, + show_examples=True, + console=console, + ) + + output = buf.getvalue() + assert "EXAMPLES" in output + assert "compliance" in output + assert "finance" in output + + +def test_cmd_examples_lists_all_scripts(examples_readme): + import io + from rich.console import Console + + buf = io.StringIO() + rc = cmd_examples(console=Console(file=buf, force_terminal=False, width=200)) + assert rc == 0 + output = buf.getvalue() + assert "gemini_tos_evaluator.py" in output + assert "ollama_skills_test.py" in output + assert "Full notes:" in output + assert "examples/README.md" in output + + +def test_cmd_examples_filters_by_skill_id(examples_readme): + import io + from rich.console import Console + + buf = io.StringIO() + rc = cmd_examples( + skill_id="compliance/tos_evaluator", + console=Console(file=buf, force_terminal=False, width=200), + ) + assert rc == 0 + output = buf.getvalue() + assert "gemini_tos_evaluator.py" in output + assert "ollama_skills_test.py" not in output + + +def test_example_github_url(): + url = _example_github_url("build_dataset_demo.py") + assert url == ( + "https://github.com/ARPAHLS/skillware/blob/main/examples/build_dataset_demo.py" + ) + + +def test_cmd_examples_includes_github_links(examples_readme): + import io + from rich.console import Console + + buf = io.StringIO() + cmd_examples( + skill_id="compliance/tos_evaluator", + console=Console(file=buf, force_terminal=False, width=220), + ) + output = buf.getvalue() + assert "gemini_tos_evaluator.py" in output + assert "github.com/ARPAHLS/skillware/blob/main/examples/" in output + + +def test_cmd_examples_unknown_skill_returns_nonzero(examples_readme): + rc = cmd_examples(skill_id="compliance/missing") + assert rc == 1 + + +def test_main_examples_subcommand_exits_with_cmd_examples_code(monkeypatch): + import sys + from skillware.cli import main + + monkeypatch.setattr("skillware.cli.cmd_examples", lambda **kwargs: 0) + + argv = sys.argv + sys.argv = ["skillware", "examples", "compliance/tos_evaluator"] + try: + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 0 + finally: + sys.argv = argv