From a7be0b3c9eaf967c0079ab1c1a08be4f2010fc09 Mon Sep 17 00:00:00 2001 From: Yan-ke Guo Date: Tue, 3 Mar 2026 18:14:26 +0800 Subject: [PATCH 1/2] sync missing scripts from upstream openclaw repository --- nanobot/skills/skill-creator/SKILL.md | 3 +- .../skill-creator/scripts/init_skill.py | 378 ++++++++++++++++++ .../skill-creator/scripts/package_skill.py | 139 +++++++ 3 files changed, 519 insertions(+), 1 deletion(-) create mode 100755 nanobot/skills/skill-creator/scripts/init_skill.py create mode 100755 nanobot/skills/skill-creator/scripts/package_skill.py diff --git a/nanobot/skills/skill-creator/SKILL.md b/nanobot/skills/skill-creator/SKILL.md index 9b5eb6f..f4d6e0b 100644 --- a/nanobot/skills/skill-creator/SKILL.md +++ b/nanobot/skills/skill-creator/SKILL.md @@ -349,7 +349,6 @@ scripts/package_skill.py ./dist The packaging script will: 1. **Validate** the skill automatically, checking: - - YAML frontmatter format and required fields - Skill naming conventions and directory structure - Description completeness and quality @@ -357,6 +356,8 @@ The packaging script will: 2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension. + Security restriction: symlinks are rejected and packaging fails when any symlink is present. + If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again. ### Step 6: Iterate diff --git a/nanobot/skills/skill-creator/scripts/init_skill.py b/nanobot/skills/skill-creator/scripts/init_skill.py new file mode 100755 index 0000000..8633fe9 --- /dev/null +++ b/nanobot/skills/skill-creator/scripts/init_skill.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +""" +Skill Initializer - Creates a new skill from template + +Usage: + init_skill.py --path [--resources scripts,references,assets] [--examples] + +Examples: + init_skill.py my-new-skill --path skills/public + init_skill.py my-new-skill --path skills/public --resources scripts,references + init_skill.py my-api-helper --path skills/private --resources scripts --examples + init_skill.py custom-skill --path /custom/location +""" + +import argparse +import re +import sys +from pathlib import Path + +MAX_SKILL_NAME_LENGTH = 64 +ALLOWED_RESOURCES = {"scripts", "references", "assets"} + +SKILL_TEMPLATE = """--- +name: {skill_name} +description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] +--- + +# {skill_title} + +## Overview + +[TODO: 1-2 sentences explaining what this skill enables] + +## Structuring This Skill + +[TODO: Choose the structure that best fits this skill's purpose. Common patterns: + +**1. Workflow-Based** (best for sequential processes) +- Works well when there are clear step-by-step procedures +- Example: DOCX skill with "Workflow Decision Tree" -> "Reading" -> "Creating" -> "Editing" +- Structure: ## Overview -> ## Workflow Decision Tree -> ## Step 1 -> ## Step 2... + +**2. Task-Based** (best for tool collections) +- Works well when the skill offers different operations/capabilities +- Example: PDF skill with "Quick Start" -> "Merge PDFs" -> "Split PDFs" -> "Extract Text" +- Structure: ## Overview -> ## Quick Start -> ## Task Category 1 -> ## Task Category 2... + +**3. Reference/Guidelines** (best for standards or specifications) +- Works well for brand guidelines, coding standards, or requirements +- Example: Brand styling with "Brand Guidelines" -> "Colors" -> "Typography" -> "Features" +- Structure: ## Overview -> ## Guidelines -> ## Specifications -> ## Usage... + +**4. Capabilities-Based** (best for integrated systems) +- Works well when the skill provides multiple interrelated features +- Example: Product Management with "Core Capabilities" -> numbered capability list +- Structure: ## Overview -> ## Core Capabilities -> ### 1. Feature -> ### 2. Feature... + +Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). + +Delete this entire "Structuring This Skill" section when done - it's just guidance.] + +## [TODO: Replace with the first main section based on chosen structure] + +[TODO: Add content here. See examples in existing skills: +- Code samples for technical skills +- Decision trees for complex workflows +- Concrete examples with realistic user requests +- References to scripts/templates/references as needed] + +## Resources (optional) + +Create only the resource directories this skill actually needs. Delete this section if no resources are required. + +### scripts/ +Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. + +**Examples from other skills:** +- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation +- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing + +**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. + +**Note:** Scripts may be executed without loading into context, but can still be read by Codex for patching or environment adjustments. + +### references/ +Documentation and reference material intended to be loaded into context to inform Codex's process and thinking. + +**Examples from other skills:** +- Product management: `communication.md`, `context_building.md` - detailed workflow guides +- BigQuery: API reference documentation and query examples +- Finance: Schema documentation, company policies + +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Codex should reference while working. + +### assets/ +Files not intended to be loaded into context, but rather used within the output Codex produces. + +**Examples from other skills:** +- Brand styling: PowerPoint template files (.pptx), logo files +- Frontend builder: HTML/React boilerplate project directories +- Typography: Font files (.ttf, .woff2) + +**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. + +--- + +**Not every skill requires all three types of resources.** +""" + +EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 +""" +Example helper script for {skill_name} + +This is a placeholder script that can be executed directly. +Replace with actual implementation or delete if not needed. + +Example real scripts from other skills: +- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields +- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images +""" + +def main(): + print("This is an example script for {skill_name}") + # TODO: Add actual script logic here + # This could be data processing, file conversion, API calls, etc. + +if __name__ == "__main__": + main() +''' + +EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} + +This is a placeholder for detailed reference documentation. +Replace with actual reference content or delete if not needed. + +Example real reference docs from other skills: +- product-management/references/communication.md - Comprehensive guide for status updates +- product-management/references/context_building.md - Deep-dive on gathering context +- bigquery/references/ - API references and query examples + +## When Reference Docs Are Useful + +Reference docs are ideal for: +- Comprehensive API documentation +- Detailed workflow guides +- Complex multi-step processes +- Information too lengthy for main SKILL.md +- Content that's only needed for specific use cases + +## Structure Suggestions + +### API Reference Example +- Overview +- Authentication +- Endpoints with examples +- Error codes +- Rate limits + +### Workflow Guide Example +- Prerequisites +- Step-by-step instructions +- Common patterns +- Troubleshooting +- Best practices +""" + +EXAMPLE_ASSET = """# Example Asset File + +This placeholder represents where asset files would be stored. +Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. + +Asset files are NOT intended to be loaded into context, but rather used within +the output Codex produces. + +Example asset files from other skills: +- Brand guidelines: logo.png, slides_template.pptx +- Frontend builder: hello-world/ directory with HTML/React boilerplate +- Typography: custom-font.ttf, font-family.woff2 +- Data: sample_data.csv, test_dataset.json + +## Common Asset Types + +- Templates: .pptx, .docx, boilerplate directories +- Images: .png, .jpg, .svg, .gif +- Fonts: .ttf, .otf, .woff, .woff2 +- Boilerplate code: Project directories, starter files +- Icons: .ico, .svg +- Data files: .csv, .json, .xml, .yaml + +Note: This is a text placeholder. Actual assets can be any file type. +""" + + +def normalize_skill_name(skill_name): + """Normalize a skill name to lowercase hyphen-case.""" + normalized = skill_name.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + return normalized + + +def title_case_skill_name(skill_name): + """Convert hyphenated skill name to Title Case for display.""" + return " ".join(word.capitalize() for word in skill_name.split("-")) + + +def parse_resources(raw_resources): + if not raw_resources: + return [] + resources = [item.strip() for item in raw_resources.split(",") if item.strip()] + invalid = sorted({item for item in resources if item not in ALLOWED_RESOURCES}) + if invalid: + allowed = ", ".join(sorted(ALLOWED_RESOURCES)) + print(f"[ERROR] Unknown resource type(s): {', '.join(invalid)}") + print(f" Allowed: {allowed}") + sys.exit(1) + deduped = [] + seen = set() + for resource in resources: + if resource not in seen: + deduped.append(resource) + seen.add(resource) + return deduped + + +def create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples): + for resource in resources: + resource_dir = skill_dir / resource + resource_dir.mkdir(exist_ok=True) + if resource == "scripts": + if include_examples: + example_script = resource_dir / "example.py" + example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) + example_script.chmod(0o755) + print("[OK] Created scripts/example.py") + else: + print("[OK] Created scripts/") + elif resource == "references": + if include_examples: + example_reference = resource_dir / "api_reference.md" + example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) + print("[OK] Created references/api_reference.md") + else: + print("[OK] Created references/") + elif resource == "assets": + if include_examples: + example_asset = resource_dir / "example_asset.txt" + example_asset.write_text(EXAMPLE_ASSET) + print("[OK] Created assets/example_asset.txt") + else: + print("[OK] Created assets/") + + +def init_skill(skill_name, path, resources, include_examples): + """ + Initialize a new skill directory with template SKILL.md. + + Args: + skill_name: Name of the skill + path: Path where the skill directory should be created + resources: Resource directories to create + include_examples: Whether to create example files in resource directories + + Returns: + Path to created skill directory, or None if error + """ + # Determine skill directory path + skill_dir = Path(path).resolve() / skill_name + + # Check if directory already exists + if skill_dir.exists(): + print(f"[ERROR] Skill directory already exists: {skill_dir}") + return None + + # Create skill directory + try: + skill_dir.mkdir(parents=True, exist_ok=False) + print(f"[OK] Created skill directory: {skill_dir}") + except Exception as e: + print(f"[ERROR] Error creating directory: {e}") + return None + + # Create SKILL.md from template + skill_title = title_case_skill_name(skill_name) + skill_content = SKILL_TEMPLATE.format(skill_name=skill_name, skill_title=skill_title) + + skill_md_path = skill_dir / "SKILL.md" + try: + skill_md_path.write_text(skill_content) + print("[OK] Created SKILL.md") + except Exception as e: + print(f"[ERROR] Error creating SKILL.md: {e}") + return None + + # Create resource directories if requested + if resources: + try: + create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples) + except Exception as e: + print(f"[ERROR] Error creating resource directories: {e}") + return None + + # Print next steps + print(f"\n[OK] Skill '{skill_name}' initialized successfully at {skill_dir}") + print("\nNext steps:") + print("1. Edit SKILL.md to complete the TODO items and update the description") + if resources: + if include_examples: + print("2. Customize or delete the example files in scripts/, references/, and assets/") + else: + print("2. Add resources to scripts/, references/, and assets/ as needed") + else: + print("2. Create resource directories only if needed (scripts/, references/, assets/)") + print("3. Run the validator when ready to check the skill structure") + + return skill_dir + + +def main(): + parser = argparse.ArgumentParser( + description="Create a new skill directory with a SKILL.md template.", + ) + parser.add_argument("skill_name", help="Skill name (normalized to hyphen-case)") + parser.add_argument("--path", required=True, help="Output directory for the skill") + parser.add_argument( + "--resources", + default="", + help="Comma-separated list: scripts,references,assets", + ) + parser.add_argument( + "--examples", + action="store_true", + help="Create example files inside the selected resource directories", + ) + args = parser.parse_args() + + raw_skill_name = args.skill_name + skill_name = normalize_skill_name(raw_skill_name) + if not skill_name: + print("[ERROR] Skill name must include at least one letter or digit.") + sys.exit(1) + if len(skill_name) > MAX_SKILL_NAME_LENGTH: + print( + f"[ERROR] Skill name '{skill_name}' is too long ({len(skill_name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters." + ) + sys.exit(1) + if skill_name != raw_skill_name: + print(f"Note: Normalized skill name from '{raw_skill_name}' to '{skill_name}'.") + + resources = parse_resources(args.resources) + if args.examples and not resources: + print("[ERROR] --examples requires --resources to be set.") + sys.exit(1) + + path = args.path + + print(f"Initializing skill: {skill_name}") + print(f" Location: {path}") + if resources: + print(f" Resources: {', '.join(resources)}") + if args.examples: + print(" Examples: enabled") + else: + print(" Resources: none (create as needed)") + print() + + result = init_skill(skill_name, path, resources, args.examples) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/nanobot/skills/skill-creator/scripts/package_skill.py b/nanobot/skills/skill-creator/scripts/package_skill.py new file mode 100755 index 0000000..aa4de89 --- /dev/null +++ b/nanobot/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable .skill file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import sys +import zipfile +from pathlib import Path + +from quick_validate import validate_skill + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a .skill file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the .skill file (defaults to current directory) + + Returns: + Path to the created .skill file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"[ERROR] Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"[ERROR] Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"[ERROR] SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"[ERROR] Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"[OK] {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + skill_filename = output_path / f"{skill_name}.skill" + + EXCLUDED_DIRS = {".git", ".svn", ".hg", "__pycache__", "node_modules"} + + # Create the .skill file (zip format) + try: + with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory + for file_path in skill_path.rglob("*"): + # Security: never follow or package symlinks. + if file_path.is_symlink(): + print(f"[WARN] Skipping symlink: {file_path}") + continue + + rel_parts = file_path.relative_to(skill_path).parts + if any(part in EXCLUDED_DIRS for part in rel_parts): + continue + + if file_path.is_file(): + resolved_file = file_path.resolve() + if not _is_within(resolved_file, skill_path): + print(f"[ERROR] File escapes skill root: {file_path}") + return None + # If output lives under skill_path, avoid writing archive into itself. + if resolved_file == skill_filename.resolve(): + print(f"[WARN] Skipping output archive: {file_path}") + continue + + # Calculate the relative path within the zip. + arcname = Path(skill_name) / file_path.relative_to(skill_path) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n[OK] Successfully packaged skill to: {skill_filename}") + return skill_filename + + except Exception as e: + print(f"[ERROR] Error creating .skill file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() From b7ecc94c9b85aadc79e0d6598ea42ad7dbaa15f1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Mar 2026 09:16:23 +0000 Subject: [PATCH 2/2] fix(skill-creator): restore validation and align packaging docs --- nanobot/skills/skill-creator/SKILL.md | 10 +- .../skill-creator/scripts/package_skill.py | 77 ++++--- .../skill-creator/scripts/quick_validate.py | 213 ++++++++++++++++++ tests/test_skill_creator_scripts.py | 127 +++++++++++ 4 files changed, 392 insertions(+), 35 deletions(-) create mode 100644 nanobot/skills/skill-creator/scripts/quick_validate.py create mode 100644 tests/test_skill_creator_scripts.py diff --git a/nanobot/skills/skill-creator/SKILL.md b/nanobot/skills/skill-creator/SKILL.md index f4d6e0b..ea53abe 100644 --- a/nanobot/skills/skill-creator/SKILL.md +++ b/nanobot/skills/skill-creator/SKILL.md @@ -268,6 +268,8 @@ Skip this step only if the skill being developed already exists, and iteration o When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. +For `nanobot`, custom skills should live under the active workspace `skills/` directory so they can be discovered automatically at runtime (for example, `/skills/my-skill/SKILL.md`). + Usage: ```bash @@ -277,9 +279,9 @@ scripts/init_skill.py --path [--resources script Examples: ```bash -scripts/init_skill.py my-skill --path skills/public -scripts/init_skill.py my-skill --path skills/public --resources scripts,references -scripts/init_skill.py my-skill --path skills/public --resources scripts --examples +scripts/init_skill.py my-skill --path ./workspace/skills +scripts/init_skill.py my-skill --path ./workspace/skills --resources scripts,references +scripts/init_skill.py my-skill --path ./workspace/skills --resources scripts --examples ``` The script: @@ -326,7 +328,7 @@ Write the YAML frontmatter with `name` and `description`: - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to the agent. - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when the agent needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" -Do not include any other fields in YAML frontmatter. +Keep frontmatter minimal. In `nanobot`, `metadata` and `always` are also supported when needed, but avoid adding extra fields unless they are actually required. ##### Body diff --git a/nanobot/skills/skill-creator/scripts/package_skill.py b/nanobot/skills/skill-creator/scripts/package_skill.py index aa4de89..48fcbbe 100755 --- a/nanobot/skills/skill-creator/scripts/package_skill.py +++ b/nanobot/skills/skill-creator/scripts/package_skill.py @@ -3,11 +3,11 @@ Skill Packager - Creates a distributable .skill file of a skill folder Usage: - python utils/package_skill.py [output-directory] + python package_skill.py [output-directory] Example: - python utils/package_skill.py skills/public/my-skill - python utils/package_skill.py skills/public/my-skill ./dist + python package_skill.py skills/public/my-skill + python package_skill.py skills/public/my-skill ./dist """ import sys @@ -25,6 +25,14 @@ def _is_within(path: Path, root: Path) -> bool: return False +def _cleanup_partial_archive(skill_filename: Path) -> None: + try: + if skill_filename.exists(): + skill_filename.unlink() + except OSError: + pass + + def package_skill(skill_path, output_dir=None): """ Package a skill folder into a .skill file. @@ -74,49 +82,56 @@ def package_skill(skill_path, output_dir=None): EXCLUDED_DIRS = {".git", ".svn", ".hg", "__pycache__", "node_modules"} + files_to_package = [] + resolved_archive = skill_filename.resolve() + + for file_path in skill_path.rglob("*"): + # Fail closed on symlinks so the packaged contents are explicit and predictable. + if file_path.is_symlink(): + print(f"[ERROR] Symlink not allowed in packaged skill: {file_path}") + _cleanup_partial_archive(skill_filename) + return None + + rel_parts = file_path.relative_to(skill_path).parts + if any(part in EXCLUDED_DIRS for part in rel_parts): + continue + + if file_path.is_file(): + resolved_file = file_path.resolve() + if not _is_within(resolved_file, skill_path): + print(f"[ERROR] File escapes skill root: {file_path}") + _cleanup_partial_archive(skill_filename) + return None + # If output lives under skill_path, avoid writing archive into itself. + if resolved_file == resolved_archive: + print(f"[WARN] Skipping output archive: {file_path}") + continue + files_to_package.append(file_path) + # Create the .skill file (zip format) try: with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf: - # Walk through the skill directory - for file_path in skill_path.rglob("*"): - # Security: never follow or package symlinks. - if file_path.is_symlink(): - print(f"[WARN] Skipping symlink: {file_path}") - continue - - rel_parts = file_path.relative_to(skill_path).parts - if any(part in EXCLUDED_DIRS for part in rel_parts): - continue - - if file_path.is_file(): - resolved_file = file_path.resolve() - if not _is_within(resolved_file, skill_path): - print(f"[ERROR] File escapes skill root: {file_path}") - return None - # If output lives under skill_path, avoid writing archive into itself. - if resolved_file == skill_filename.resolve(): - print(f"[WARN] Skipping output archive: {file_path}") - continue - - # Calculate the relative path within the zip. - arcname = Path(skill_name) / file_path.relative_to(skill_path) - zipf.write(file_path, arcname) - print(f" Added: {arcname}") + for file_path in files_to_package: + # Calculate the relative path within the zip. + arcname = Path(skill_name) / file_path.relative_to(skill_path) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") print(f"\n[OK] Successfully packaged skill to: {skill_filename}") return skill_filename except Exception as e: + _cleanup_partial_archive(skill_filename) print(f"[ERROR] Error creating .skill file: {e}") return None def main(): if len(sys.argv) < 2: - print("Usage: python utils/package_skill.py [output-directory]") + print("Usage: python package_skill.py [output-directory]") print("\nExample:") - print(" python utils/package_skill.py skills/public/my-skill") - print(" python utils/package_skill.py skills/public/my-skill ./dist") + print(" python package_skill.py skills/public/my-skill") + print(" python package_skill.py skills/public/my-skill ./dist") sys.exit(1) skill_path = sys.argv[1] diff --git a/nanobot/skills/skill-creator/scripts/quick_validate.py b/nanobot/skills/skill-creator/scripts/quick_validate.py new file mode 100644 index 0000000..03d246d --- /dev/null +++ b/nanobot/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +Minimal validator for nanobot skill folders. +""" + +import re +import sys +from pathlib import Path +from typing import Optional + +try: + import yaml +except ModuleNotFoundError: + yaml = None + +MAX_SKILL_NAME_LENGTH = 64 +ALLOWED_FRONTMATTER_KEYS = { + "name", + "description", + "metadata", + "always", + "license", + "allowed-tools", +} +ALLOWED_RESOURCE_DIRS = {"scripts", "references", "assets"} +PLACEHOLDER_MARKERS = ("[todo", "todo:") + + +def _extract_frontmatter(content: str) -> Optional[str]: + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + return "\n".join(lines[1:i]) + return None + + +def _parse_simple_frontmatter(frontmatter_text: str) -> Optional[dict[str, str]]: + """Fallback parser for simple frontmatter when PyYAML is unavailable.""" + parsed: dict[str, str] = {} + current_key: Optional[str] = None + multiline_key: Optional[str] = None + + for raw_line in frontmatter_text.splitlines(): + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + continue + + is_indented = raw_line[:1].isspace() + if is_indented: + if current_key is None: + return None + current_value = parsed[current_key] + parsed[current_key] = f"{current_value}\n{stripped}" if current_value else stripped + continue + + if ":" not in stripped: + return None + + key, value = stripped.split(":", 1) + key = key.strip() + value = value.strip() + if not key: + return None + + if value in {"|", ">"}: + parsed[key] = "" + current_key = key + multiline_key = key + continue + + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + parsed[key] = value + current_key = key + multiline_key = None + + if multiline_key is not None and multiline_key not in parsed: + return None + return parsed + + +def _load_frontmatter(frontmatter_text: str) -> tuple[Optional[dict], Optional[str]]: + if yaml is not None: + try: + frontmatter = yaml.safe_load(frontmatter_text) + except yaml.YAMLError as exc: + return None, f"Invalid YAML in frontmatter: {exc}" + if not isinstance(frontmatter, dict): + return None, "Frontmatter must be a YAML dictionary" + return frontmatter, None + + frontmatter = _parse_simple_frontmatter(frontmatter_text) + if frontmatter is None: + return None, "Invalid YAML in frontmatter: unsupported syntax without PyYAML installed" + return frontmatter, None + + +def _validate_skill_name(name: str, folder_name: str) -> Optional[str]: + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name): + return ( + f"Name '{name}' should be hyphen-case " + "(lowercase letters, digits, and single hyphens only)" + ) + if len(name) > MAX_SKILL_NAME_LENGTH: + return ( + f"Name is too long ({len(name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters." + ) + if name != folder_name: + return f"Skill name '{name}' must match directory name '{folder_name}'" + return None + + +def _validate_description(description: str) -> Optional[str]: + trimmed = description.strip() + if not trimmed: + return "Description cannot be empty" + lowered = trimmed.lower() + if any(marker in lowered for marker in PLACEHOLDER_MARKERS): + return "Description still contains TODO placeholder text" + if "<" in trimmed or ">" in trimmed: + return "Description cannot contain angle brackets (< or >)" + if len(trimmed) > 1024: + return f"Description is too long ({len(trimmed)} characters). Maximum is 1024 characters." + return None + + +def validate_skill(skill_path): + """Validate a skill folder structure and required frontmatter.""" + skill_path = Path(skill_path).resolve() + + if not skill_path.exists(): + return False, f"Skill folder not found: {skill_path}" + if not skill_path.is_dir(): + return False, f"Path is not a directory: {skill_path}" + + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + return False, "SKILL.md not found" + + try: + content = skill_md.read_text(encoding="utf-8") + except OSError as exc: + return False, f"Could not read SKILL.md: {exc}" + + frontmatter_text = _extract_frontmatter(content) + if frontmatter_text is None: + return False, "Invalid frontmatter format" + + frontmatter, error = _load_frontmatter(frontmatter_text) + if error: + return False, error + + unexpected_keys = sorted(set(frontmatter.keys()) - ALLOWED_FRONTMATTER_KEYS) + if unexpected_keys: + allowed = ", ".join(sorted(ALLOWED_FRONTMATTER_KEYS)) + unexpected = ", ".join(unexpected_keys) + return ( + False, + f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}", + ) + + if "name" not in frontmatter: + return False, "Missing 'name' in frontmatter" + if "description" not in frontmatter: + return False, "Missing 'description' in frontmatter" + + name = frontmatter["name"] + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name_error = _validate_skill_name(name.strip(), skill_path.name) + if name_error: + return False, name_error + + description = frontmatter["description"] + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description_error = _validate_description(description) + if description_error: + return False, description_error + + always = frontmatter.get("always") + if always is not None and not isinstance(always, bool): + return False, f"'always' must be a boolean, got {type(always).__name__}" + + for child in skill_path.iterdir(): + if child.name == "SKILL.md": + continue + if child.is_dir() and child.name in ALLOWED_RESOURCE_DIRS: + continue + if child.is_symlink(): + continue + return ( + False, + f"Unexpected file or directory in skill root: {child.name}. " + "Only SKILL.md, scripts/, references/, and assets/ are allowed.", + ) + + return True, "Skill is valid!" + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) diff --git a/tests/test_skill_creator_scripts.py b/tests/test_skill_creator_scripts.py new file mode 100644 index 0000000..4207c6f --- /dev/null +++ b/tests/test_skill_creator_scripts.py @@ -0,0 +1,127 @@ +import importlib +import shutil +import sys +import zipfile +from pathlib import Path + + +SCRIPT_DIR = Path("nanobot/skills/skill-creator/scripts").resolve() +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +init_skill = importlib.import_module("init_skill") +package_skill = importlib.import_module("package_skill") +quick_validate = importlib.import_module("quick_validate") + + +def test_init_skill_creates_expected_files(tmp_path: Path) -> None: + skill_dir = init_skill.init_skill( + "demo-skill", + tmp_path, + ["scripts", "references", "assets"], + include_examples=True, + ) + + assert skill_dir == tmp_path / "demo-skill" + assert (skill_dir / "SKILL.md").exists() + assert (skill_dir / "scripts" / "example.py").exists() + assert (skill_dir / "references" / "api_reference.md").exists() + assert (skill_dir / "assets" / "example_asset.txt").exists() + + +def test_validate_skill_accepts_existing_skill_creator() -> None: + valid, message = quick_validate.validate_skill( + Path("nanobot/skills/skill-creator").resolve() + ) + + assert valid, message + + +def test_validate_skill_rejects_placeholder_description(tmp_path: Path) -> None: + skill_dir = tmp_path / "placeholder-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: placeholder-skill\n" + 'description: "[TODO: fill me in]"\n' + "---\n" + "# Placeholder\n", + encoding="utf-8", + ) + + valid, message = quick_validate.validate_skill(skill_dir) + + assert not valid + assert "TODO placeholder" in message + + +def test_validate_skill_rejects_root_files_outside_allowed_dirs(tmp_path: Path) -> None: + skill_dir = tmp_path / "bad-root-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: bad-root-skill\n" + "description: Valid description\n" + "---\n" + "# Skill\n", + encoding="utf-8", + ) + (skill_dir / "README.md").write_text("extra\n", encoding="utf-8") + + valid, message = quick_validate.validate_skill(skill_dir) + + assert not valid + assert "Unexpected file or directory in skill root" in message + + +def test_package_skill_creates_archive(tmp_path: Path) -> None: + skill_dir = tmp_path / "package-me" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: package-me\n" + "description: Package this skill.\n" + "---\n" + "# Skill\n", + encoding="utf-8", + ) + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "helper.py").write_text("print('ok')\n", encoding="utf-8") + + archive_path = package_skill.package_skill(skill_dir, tmp_path / "dist") + + assert archive_path == (tmp_path / "dist" / "package-me.skill") + assert archive_path.exists() + with zipfile.ZipFile(archive_path, "r") as archive: + names = set(archive.namelist()) + assert "package-me/SKILL.md" in names + assert "package-me/scripts/helper.py" in names + + +def test_package_skill_rejects_symlink(tmp_path: Path) -> None: + skill_dir = tmp_path / "symlink-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: symlink-skill\n" + "description: Reject symlinks during packaging.\n" + "---\n" + "# Skill\n", + encoding="utf-8", + ) + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir() + target = tmp_path / "outside.txt" + target.write_text("secret\n", encoding="utf-8") + link = scripts_dir / "outside.txt" + + try: + link.symlink_to(target) + except (OSError, NotImplementedError): + return + + archive_path = package_skill.package_skill(skill_dir, tmp_path / "dist") + + assert archive_path is None + assert not (tmp_path / "dist" / "symlink-skill.skill").exists()