feat: rewrite skill creator skill in ts and bundle (#333)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ bun.lockb
|
||||
bin/
|
||||
letta.js
|
||||
letta.js.map
|
||||
/skills/
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,303 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Skill Initializer - Creates a new skill from template
|
||||
|
||||
Usage:
|
||||
init_skill.py <skill-name> --path <path>
|
||||
|
||||
Examples:
|
||||
init_skill.py my-new-skill --path skills/public
|
||||
init_skill.py my-api-helper --path skills/private
|
||||
init_skill.py custom-skill --path /custom/location
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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
|
||||
|
||||
This skill includes example resource directories that demonstrate how to organize different types of bundled resources:
|
||||
|
||||
### 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 the Letta Code agent for patching or environment adjustments.
|
||||
|
||||
### references/
|
||||
Documentation and reference material intended to be loaded into context to inform the Letta Code agent'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 the Letta Code agent should reference while working.
|
||||
|
||||
### assets/
|
||||
Files not intended to be loaded into context, but rather used within the output the Letta Code agent 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.
|
||||
|
||||
---
|
||||
|
||||
**Any unneeded directories can be deleted.** 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 the Letta Code agent 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 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 init_skill(skill_name, path):
|
||||
"""
|
||||
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
|
||||
|
||||
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"✅ Created skill directory: {skill_dir}")
|
||||
except Exception as e:
|
||||
print(f"❌ 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("✅ Created SKILL.md")
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating SKILL.md: {e}")
|
||||
return None
|
||||
|
||||
# Create resource directories with example files
|
||||
try:
|
||||
# Create scripts/ directory with example script
|
||||
scripts_dir = skill_dir / 'scripts'
|
||||
scripts_dir.mkdir(exist_ok=True)
|
||||
example_script = scripts_dir / 'example.py'
|
||||
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
|
||||
example_script.chmod(0o755)
|
||||
print("✅ Created scripts/example.py")
|
||||
|
||||
# Create references/ directory with example reference doc
|
||||
references_dir = skill_dir / 'references'
|
||||
references_dir.mkdir(exist_ok=True)
|
||||
example_reference = references_dir / 'api_reference.md'
|
||||
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
|
||||
print("✅ Created references/api_reference.md")
|
||||
|
||||
# Create assets/ directory with example asset placeholder
|
||||
assets_dir = skill_dir / 'assets'
|
||||
assets_dir.mkdir(exist_ok=True)
|
||||
example_asset = assets_dir / 'example_asset.txt'
|
||||
example_asset.write_text(EXAMPLE_ASSET)
|
||||
print("✅ Created assets/example_asset.txt")
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating resource directories: {e}")
|
||||
return None
|
||||
|
||||
# Print next steps
|
||||
print(f"\n✅ 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")
|
||||
print("2. Customize or delete the example files in scripts/, references/, and assets/")
|
||||
print("3. Run the validator when ready to check the skill structure")
|
||||
|
||||
return skill_dir
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 4 or sys.argv[2] != '--path':
|
||||
print("Usage: init_skill.py <skill-name> --path <path>")
|
||||
print("\nSkill name requirements:")
|
||||
print(" - Hyphen-case identifier (e.g., 'data-analyzer')")
|
||||
print(" - Lowercase letters, digits, and hyphens only")
|
||||
print(" - Max 40 characters")
|
||||
print(" - Must match directory name exactly")
|
||||
print("\nExamples:")
|
||||
print(" init_skill.py my-new-skill --path skills/public")
|
||||
print(" init_skill.py my-api-helper --path skills/private")
|
||||
print(" init_skill.py custom-skill --path /custom/location")
|
||||
sys.exit(1)
|
||||
|
||||
skill_name = sys.argv[1]
|
||||
path = sys.argv[3]
|
||||
|
||||
print(f"🚀 Initializing skill: {skill_name}")
|
||||
print(f" Location: {path}")
|
||||
print()
|
||||
|
||||
result = init_skill(skill_name, path)
|
||||
|
||||
if result:
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Skill Packager - Creates a distributable .skill file of a skill folder
|
||||
|
||||
Usage:
|
||||
python utils/package_skill.py <path/to/skill-folder> [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 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"❌ Validation failed: {message}")
|
||||
print(" Please fix the validation errors before packaging.")
|
||||
return None
|
||||
print(f"✅ {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"
|
||||
|
||||
# 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('*'):
|
||||
if file_path.is_file():
|
||||
# Calculate the relative path within the zip
|
||||
arcname = file_path.relative_to(skill_path.parent)
|
||||
zipf.write(file_path, arcname)
|
||||
print(f" Added: {arcname}")
|
||||
|
||||
print(f"\n✅ Successfully packaged skill to: {skill_filename}")
|
||||
return skill_filename
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating .skill file: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python utils/package_skill.py <path/to/skill-folder> [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()
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick validation script for skills - minimal version
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
def validate_skill(skill_path):
|
||||
"""Basic validation of a skill"""
|
||||
skill_path = Path(skill_path)
|
||||
|
||||
# Check SKILL.md exists
|
||||
skill_md = skill_path / 'SKILL.md'
|
||||
if not skill_md.exists():
|
||||
return False, "SKILL.md not found"
|
||||
|
||||
# Read and validate frontmatter
|
||||
content = skill_md.read_text()
|
||||
if not content.startswith('---'):
|
||||
return False, "No YAML frontmatter found"
|
||||
|
||||
# Extract frontmatter
|
||||
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not match:
|
||||
return False, "Invalid frontmatter format"
|
||||
|
||||
frontmatter_text = match.group(1)
|
||||
|
||||
# Parse YAML frontmatter
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_text)
|
||||
if not isinstance(frontmatter, dict):
|
||||
return False, "Frontmatter must be a YAML dictionary"
|
||||
except yaml.YAMLError as e:
|
||||
return False, f"Invalid YAML in frontmatter: {e}"
|
||||
|
||||
# Define allowed properties
|
||||
ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata'}
|
||||
|
||||
# Check for unexpected properties (excluding nested keys under metadata)
|
||||
unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES
|
||||
if unexpected_keys:
|
||||
return False, (
|
||||
f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. "
|
||||
f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}"
|
||||
)
|
||||
|
||||
# Check required fields
|
||||
if 'name' not in frontmatter:
|
||||
return False, "Missing 'name' in frontmatter"
|
||||
if 'description' not in frontmatter:
|
||||
return False, "Missing 'description' in frontmatter"
|
||||
|
||||
# Extract name for validation
|
||||
name = frontmatter.get('name', '')
|
||||
if not isinstance(name, str):
|
||||
return False, f"Name must be a string, got {type(name).__name__}"
|
||||
name = name.strip()
|
||||
if name:
|
||||
# Check naming convention (hyphen-case: lowercase with hyphens)
|
||||
if not re.match(r'^[a-z0-9-]+$', name):
|
||||
return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)"
|
||||
if name.startswith('-') or name.endswith('-') or '--' in name:
|
||||
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens"
|
||||
# Check name length (max 64 characters per spec)
|
||||
if len(name) > 64:
|
||||
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters."
|
||||
|
||||
# Extract and validate description
|
||||
description = frontmatter.get('description', '')
|
||||
if not isinstance(description, str):
|
||||
return False, f"Description must be a string, got {type(description).__name__}"
|
||||
description = description.strip()
|
||||
if description:
|
||||
# Check for angle brackets
|
||||
if '<' in description or '>' in description:
|
||||
return False, "Description cannot contain angle brackets (< or >)"
|
||||
# Check description length (max 1024 characters per spec)
|
||||
if len(description) > 1024:
|
||||
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters."
|
||||
|
||||
return True, "Skill is valid!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python quick_validate.py <skill_directory>")
|
||||
sys.exit(1)
|
||||
|
||||
valid, message = validate_skill(sys.argv[1])
|
||||
print(message)
|
||||
sys.exit(0 if valid else 1)
|
||||
15
build.js
15
build.js
@@ -5,7 +5,7 @@
|
||||
* Bundles TypeScript source into a single JavaScript file
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { cpSync, existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -54,6 +54,19 @@ await Bun.write(outputPath, withShebang);
|
||||
// Make executable
|
||||
await Bun.$`chmod +x letta.js`;
|
||||
|
||||
// Copy bundled skills to skills/ directory for shipping
|
||||
const bundledSkillsSrc = join(__dirname, "src/skills/builtin");
|
||||
const bundledSkillsDst = join(__dirname, "skills");
|
||||
|
||||
if (existsSync(bundledSkillsSrc)) {
|
||||
// Clean and copy
|
||||
if (existsSync(bundledSkillsDst)) {
|
||||
rmSync(bundledSkillsDst, { recursive: true });
|
||||
}
|
||||
cpSync(bundledSkillsSrc, bundledSkillsDst, { recursive: true });
|
||||
console.log("📂 Copied bundled skills to skills/");
|
||||
}
|
||||
|
||||
console.log("✅ Build complete!");
|
||||
console.log(` Output: letta.js`);
|
||||
console.log(
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"README.md",
|
||||
"letta.js",
|
||||
"scripts",
|
||||
"skills",
|
||||
"vendor"
|
||||
],
|
||||
"repository": {
|
||||
|
||||
@@ -9,17 +9,27 @@
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
// Import bundled skills (embedded at build time)
|
||||
import memoryInitSkillMd from "../skills/builtin/memory-init/SKILL.md";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseFrontmatter } from "../utils/frontmatter";
|
||||
|
||||
/**
|
||||
* Bundled skill sources - embedded at build time
|
||||
* Get the bundled skills directory path
|
||||
* This is where skills ship with the package (skills/ directory next to letta.js)
|
||||
*/
|
||||
const BUNDLED_SKILL_SOURCES: Array<{ id: string; content: string }> = [
|
||||
{ id: "memory-init", content: memoryInitSkillMd },
|
||||
];
|
||||
function getBundledSkillsPath(): string {
|
||||
// In dev mode (running from src/), look in src/skills/builtin/
|
||||
// In production (running from letta.js), look in skills/ next to letta.js
|
||||
const thisDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Check if we're in dev mode (thisDir contains 'src/agent')
|
||||
if (thisDir.includes("src/agent") || thisDir.includes("src\\agent")) {
|
||||
return join(thisDir, "../skills/builtin");
|
||||
}
|
||||
|
||||
// Production mode - skills/ is next to the bundled letta.js
|
||||
return join(thisDir, "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of a skill (for display and override resolution)
|
||||
@@ -90,65 +100,13 @@ const SKILLS_BLOCK_CHAR_LIMIT = 20000;
|
||||
/**
|
||||
* Parse a bundled skill from its embedded content
|
||||
*/
|
||||
function parseBundledSkill(id: string, content: string): Skill {
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
const name =
|
||||
(typeof frontmatter.name === "string" ? frontmatter.name : null) ||
|
||||
id
|
||||
.split("/")
|
||||
.pop()
|
||||
?.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase()) ||
|
||||
id;
|
||||
|
||||
let description =
|
||||
typeof frontmatter.description === "string"
|
||||
? frontmatter.description
|
||||
: null;
|
||||
if (!description) {
|
||||
const firstParagraph = body.trim().split("\n\n")[0];
|
||||
description = firstParagraph || "No description available";
|
||||
}
|
||||
|
||||
// Strip surrounding quotes
|
||||
description = description.trim();
|
||||
if (
|
||||
(description.startsWith('"') && description.endsWith('"')) ||
|
||||
(description.startsWith("'") && description.endsWith("'"))
|
||||
) {
|
||||
description = description.slice(1, -1);
|
||||
}
|
||||
|
||||
let tags: string[] | undefined;
|
||||
if (Array.isArray(frontmatter.tags)) {
|
||||
tags = frontmatter.tags;
|
||||
} else if (typeof frontmatter.tags === "string") {
|
||||
tags = [frontmatter.tags];
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
category:
|
||||
typeof frontmatter.category === "string"
|
||||
? frontmatter.category
|
||||
: undefined,
|
||||
tags,
|
||||
path: "", // Bundled skills don't have a file path
|
||||
source: "bundled",
|
||||
content, // Store the full content for bundled skills
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bundled skills (embedded at build time)
|
||||
* Get bundled skills by discovering from the bundled skills directory
|
||||
*/
|
||||
export function getBundledSkills(): Skill[] {
|
||||
return BUNDLED_SKILL_SOURCES.map(({ id, content }) =>
|
||||
parseBundledSkill(id, content),
|
||||
);
|
||||
export async function getBundledSkills(): Promise<Skill[]> {
|
||||
const bundledPath = getBundledSkillsPath();
|
||||
const result = await discoverSkillsFromDir(bundledPath, "bundled");
|
||||
return result.skills;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,7 +160,8 @@ export async function discoverSkills(
|
||||
const skillsById = new Map<string, Skill>();
|
||||
|
||||
// 1. Start with bundled skills (lowest priority)
|
||||
for (const skill of getBundledSkills()) {
|
||||
const bundledSkills = await getBundledSkills();
|
||||
for (const skill of bundledSkills) {
|
||||
skillsById.set(skill.id, skill);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: skill-creator
|
||||
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Letta Code's capabilities with specialized knowledge, workflows, or tool integrations.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
# Skill Creator
|
||||
@@ -56,7 +55,7 @@ skill-name/
|
||||
│ │ └── description: (required)
|
||||
│ └── Markdown instructions (required)
|
||||
└── Bundled Resources (optional)
|
||||
├── scripts/ - Executable code (Python/Bash/etc.)
|
||||
├── scripts/ - Executable code (TypeScript/Python/Bash/etc.)
|
||||
├── references/ - Documentation intended to be loaded into context as needed
|
||||
└── assets/ - Files used in output (templates, icons, fonts, etc.)
|
||||
```
|
||||
@@ -72,10 +71,10 @@ Every SKILL.md in Letta Code consists of:
|
||||
|
||||
##### Scripts (`scripts/`)
|
||||
|
||||
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
|
||||
Executable code (TypeScript/Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
|
||||
|
||||
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
|
||||
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
|
||||
- **Example**: `scripts/rotate-pdf.ts` for PDF rotation tasks
|
||||
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
|
||||
- **Note**: Scripts may still need to be read by the Letta Code agent for patching or environment-specific adjustments
|
||||
|
||||
@@ -205,9 +204,9 @@ Skill creation involves these steps:
|
||||
|
||||
1. Understand the skill with concrete examples
|
||||
2. Plan reusable skill contents (scripts, references, assets)
|
||||
3. Initialize the skill (run init_skill.py)
|
||||
3. Initialize the skill (run init-skill.ts)
|
||||
4. Edit the skill (implement resources and write SKILL.md)
|
||||
5. Package the skill (run package_skill.py)
|
||||
5. Package the skill (run package-skill.ts)
|
||||
6. Iterate based on real usage
|
||||
|
||||
Follow these steps in order, skipping only if there is a clear reason why they are not applicable.
|
||||
@@ -239,7 +238,7 @@ To turn concrete examples into an effective skill, analyze each example by:
|
||||
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
|
||||
|
||||
1. Rotating a PDF requires re-writing the same code each time
|
||||
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
|
||||
2. A `scripts/rotate-pdf.ts` script would be helpful to store in the skill
|
||||
|
||||
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
|
||||
|
||||
@@ -259,12 +258,12 @@ At this point, it is time to actually create the skill.
|
||||
|
||||
Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.
|
||||
|
||||
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.
|
||||
When creating a new skill from scratch, always run the `init-skill.ts` 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.
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
scripts/init_skill.py <skill-name> --path <output-directory>
|
||||
npx ts-node scripts/init-skill.ts <skill-name> --path <output-directory>
|
||||
```
|
||||
|
||||
The script:
|
||||
@@ -322,13 +321,13 @@ Write instructions for using the skill and its bundled resources.
|
||||
Once development of the skill is complete, it must be packaged into a distributable .skill file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:
|
||||
|
||||
```bash
|
||||
scripts/package_skill.py <path/to/skill-folder>
|
||||
npx ts-node scripts/package-skill.ts <path/to/skill-folder>
|
||||
```
|
||||
|
||||
Optional output directory specification:
|
||||
|
||||
```bash
|
||||
scripts/package_skill.py <path/to/skill-folder> ./dist
|
||||
npx ts-node scripts/package-skill.ts <path/to/skill-folder> ./dist
|
||||
```
|
||||
|
||||
The packaging script will:
|
||||
@@ -7,11 +7,11 @@ For complex tasks, break operations into clear, sequential steps. It is often he
|
||||
```markdown
|
||||
Filling a PDF form involves these steps:
|
||||
|
||||
1. Analyze the form (run analyze_form.py)
|
||||
1. Analyze the form (run analyze-form.ts)
|
||||
2. Create field mapping (edit fields.json)
|
||||
3. Validate mapping (run validate_fields.py)
|
||||
4. Fill the form (run fill_form.py)
|
||||
5. Verify output (run verify_output.py)
|
||||
3. Validate mapping (run validate-fields.ts)
|
||||
4. Fill the form (run fill-form.ts)
|
||||
5. Verify output (run verify-output.ts)
|
||||
```
|
||||
|
||||
## Conditional Workflows
|
||||
279
src/skills/builtin/skill-creator/scripts/init-skill.ts
Normal file
279
src/skills/builtin/skill-creator/scripts/init-skill.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
/**
|
||||
* Skill Initializer - Creates a new skill from template
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node init-skill.ts <skill-name> --path <path>
|
||||
*
|
||||
* Examples:
|
||||
* npx ts-node init-skill.ts my-new-skill --path .skills
|
||||
* npx ts-node init-skill.ts my-api-helper --path ~/.letta/skills
|
||||
*/
|
||||
|
||||
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const 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
|
||||
|
||||
This skill includes example resource directories that demonstrate how to organize different types of bundled resources:
|
||||
|
||||
### scripts/
|
||||
Executable code (TypeScript/Python/Bash/etc.) that can be run directly to perform specific operations.
|
||||
|
||||
**Appropriate for:** Scripts that perform automation, data processing, or specific operations.
|
||||
|
||||
**Note:** Scripts may be executed without loading into context, but can still be read by the Letta Code agent for patching or environment adjustments.
|
||||
|
||||
### references/
|
||||
Documentation and reference material intended to be loaded into context to inform the Letta Code agent's process and thinking.
|
||||
|
||||
**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that the Letta Code agent should reference while working.
|
||||
|
||||
### assets/
|
||||
Files not intended to be loaded into context, but rather used within the output the Letta Code agent produces.
|
||||
|
||||
**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
|
||||
|
||||
---
|
||||
|
||||
**Any unneeded directories can be deleted.** Not every skill requires all three types of resources.
|
||||
`;
|
||||
|
||||
const EXAMPLE_SCRIPT = `#!/usr/bin/env npx ts-node
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
function main() {
|
||||
console.log("This is an example script for {skill_name}");
|
||||
// TODO: Add actual script logic here
|
||||
// This could be data processing, file conversion, API calls, etc.
|
||||
}
|
||||
|
||||
main();
|
||||
`;
|
||||
|
||||
const 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.
|
||||
|
||||
## 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
|
||||
`;
|
||||
|
||||
const 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 the Letta Code agent produces.
|
||||
|
||||
## 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.
|
||||
`;
|
||||
|
||||
function titleCaseSkillName(skillName: string): string {
|
||||
return skillName
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function initSkill(skillName: string, path: string): string | null {
|
||||
const skillDir = resolve(path, skillName);
|
||||
|
||||
// Check if directory already exists
|
||||
if (existsSync(skillDir)) {
|
||||
console.error(`Error: Skill directory already exists: ${skillDir}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create skill directory
|
||||
try {
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
console.log(`Created skill directory: ${skillDir}`);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error creating directory: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const skillTitle = titleCaseSkillName(skillName);
|
||||
|
||||
// Create SKILL.md from template
|
||||
const skillContent = SKILL_TEMPLATE.replace(
|
||||
/{skill_name}/g,
|
||||
skillName,
|
||||
).replace(/{skill_title}/g, skillTitle);
|
||||
|
||||
try {
|
||||
writeFileSync(join(skillDir, "SKILL.md"), skillContent);
|
||||
console.log("Created SKILL.md");
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error creating SKILL.md: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create resource directories with example files
|
||||
try {
|
||||
// Create scripts/ directory with example script
|
||||
const scriptsDir = join(skillDir, "scripts");
|
||||
mkdirSync(scriptsDir, { recursive: true });
|
||||
const exampleScriptPath = join(scriptsDir, "example.ts");
|
||||
writeFileSync(
|
||||
exampleScriptPath,
|
||||
EXAMPLE_SCRIPT.replace(/{skill_name}/g, skillName),
|
||||
);
|
||||
chmodSync(exampleScriptPath, 0o755);
|
||||
console.log("Created scripts/example.ts");
|
||||
|
||||
// Create references/ directory with example reference doc
|
||||
const referencesDir = join(skillDir, "references");
|
||||
mkdirSync(referencesDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(referencesDir, "api-reference.md"),
|
||||
EXAMPLE_REFERENCE.replace(/{skill_title}/g, skillTitle),
|
||||
);
|
||||
console.log("Created references/api-reference.md");
|
||||
|
||||
// Create assets/ directory with example asset placeholder
|
||||
const assetsDir = join(skillDir, "assets");
|
||||
mkdirSync(assetsDir, { recursive: true });
|
||||
writeFileSync(join(assetsDir, "example-asset.txt"), EXAMPLE_ASSET);
|
||||
console.log("Created assets/example-asset.txt");
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error creating resource directories: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Print next steps
|
||||
console.log(`\nSkill '${skillName}' initialized successfully at ${skillDir}`);
|
||||
console.log("\nNext steps:");
|
||||
console.log(
|
||||
"1. Edit SKILL.md to complete the TODO items and update the description",
|
||||
);
|
||||
console.log(
|
||||
"2. Customize or delete the example files in scripts/, references/, and assets/",
|
||||
);
|
||||
console.log("3. Run the validator when ready to check the skill structure");
|
||||
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length < 3 || args[1] !== "--path") {
|
||||
console.log("Usage: npx ts-node init-skill.ts <skill-name> --path <path>");
|
||||
console.log("\nSkill name requirements:");
|
||||
console.log(" - Hyphen-case identifier (e.g., 'data-analyzer')");
|
||||
console.log(" - Lowercase letters, digits, and hyphens only");
|
||||
console.log(" - Max 64 characters");
|
||||
console.log(" - Must match directory name exactly");
|
||||
console.log("\nExamples:");
|
||||
console.log(" npx ts-node init-skill.ts my-new-skill --path .skills");
|
||||
console.log(
|
||||
" npx ts-node init-skill.ts my-api-helper --path ~/.letta/skills",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const skillName = args[0] as string;
|
||||
const path = args[2] as string;
|
||||
|
||||
console.log(`Initializing skill: ${skillName}`);
|
||||
console.log(`Location: ${path}\n`);
|
||||
|
||||
const result = initSkill(skillName, path);
|
||||
process.exit(result ? 0 : 1);
|
||||
}
|
||||
|
||||
export { initSkill, titleCaseSkillName };
|
||||
268
src/skills/builtin/skill-creator/scripts/package-skill.ts
Normal file
268
src/skills/builtin/skill-creator/scripts/package-skill.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
/**
|
||||
* Skill Packager - Creates a distributable .skill file of a skill folder
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node package-skill.ts <path/to/skill-folder> [output-directory]
|
||||
*
|
||||
* Example:
|
||||
* npx ts-node package-skill.ts .skills/my-skill
|
||||
* npx ts-node package-skill.ts .skills/my-skill ./dist
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
// Simple zip implementation using Node.js built-in zlib
|
||||
// For a proper zip file, we'll create the structure manually
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { validateSkill } from "./validate-skill";
|
||||
|
||||
interface ZipEntry {
|
||||
path: string;
|
||||
data: Buffer;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
function createZip(entries: ZipEntry[]): Buffer {
|
||||
// Use archiver-like approach with raw buffer manipulation
|
||||
// For simplicity, we'll use a basic ZIP format
|
||||
|
||||
const localHeaders: Buffer[] = [];
|
||||
const centralHeaders: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const pathBuffer = Buffer.from(entry.path, "utf8");
|
||||
const compressedData = entry.isDirectory
|
||||
? Buffer.alloc(0)
|
||||
: deflateSync(entry.data);
|
||||
const uncompressedSize = entry.isDirectory ? 0 : entry.data.length;
|
||||
const compressedSize = compressedData.length;
|
||||
|
||||
// CRC32 calculation
|
||||
const crc = entry.isDirectory ? 0 : crc32(entry.data);
|
||||
|
||||
// Local file header
|
||||
const localHeader = Buffer.alloc(30 + pathBuffer.length);
|
||||
localHeader.writeUInt32LE(0x04034b50, 0); // Local file header signature
|
||||
localHeader.writeUInt16LE(20, 4); // Version needed to extract
|
||||
localHeader.writeUInt16LE(0, 6); // General purpose bit flag
|
||||
localHeader.writeUInt16LE(entry.isDirectory ? 0 : 8, 8); // Compression method (8 = deflate)
|
||||
localHeader.writeUInt16LE(0, 10); // File last modification time
|
||||
localHeader.writeUInt16LE(0, 12); // File last modification date
|
||||
localHeader.writeUInt32LE(crc, 14); // CRC-32
|
||||
localHeader.writeUInt32LE(compressedSize, 18); // Compressed size
|
||||
localHeader.writeUInt32LE(uncompressedSize, 22); // Uncompressed size
|
||||
localHeader.writeUInt16LE(pathBuffer.length, 26); // File name length
|
||||
localHeader.writeUInt16LE(0, 28); // Extra field length
|
||||
pathBuffer.copy(localHeader, 30);
|
||||
|
||||
localHeaders.push(localHeader);
|
||||
localHeaders.push(compressedData);
|
||||
|
||||
// Central directory header
|
||||
const centralHeader = Buffer.alloc(46 + pathBuffer.length);
|
||||
centralHeader.writeUInt32LE(0x02014b50, 0); // Central directory signature
|
||||
centralHeader.writeUInt16LE(20, 4); // Version made by
|
||||
centralHeader.writeUInt16LE(20, 6); // Version needed to extract
|
||||
centralHeader.writeUInt16LE(0, 8); // General purpose bit flag
|
||||
centralHeader.writeUInt16LE(entry.isDirectory ? 0 : 8, 10); // Compression method
|
||||
centralHeader.writeUInt16LE(0, 12); // File last modification time
|
||||
centralHeader.writeUInt16LE(0, 14); // File last modification date
|
||||
centralHeader.writeUInt32LE(crc, 16); // CRC-32
|
||||
centralHeader.writeUInt32LE(compressedSize, 20); // Compressed size
|
||||
centralHeader.writeUInt32LE(uncompressedSize, 24); // Uncompressed size
|
||||
centralHeader.writeUInt16LE(pathBuffer.length, 28); // File name length
|
||||
centralHeader.writeUInt16LE(0, 30); // Extra field length
|
||||
centralHeader.writeUInt16LE(0, 32); // File comment length
|
||||
centralHeader.writeUInt16LE(0, 34); // Disk number start
|
||||
centralHeader.writeUInt16LE(0, 36); // Internal file attributes
|
||||
centralHeader.writeUInt32LE(entry.isDirectory ? 0x10 : 0, 38); // External file attributes
|
||||
centralHeader.writeUInt32LE(offset, 42); // Relative offset of local header
|
||||
pathBuffer.copy(centralHeader, 46);
|
||||
|
||||
centralHeaders.push(centralHeader);
|
||||
|
||||
offset += localHeader.length + compressedData.length;
|
||||
}
|
||||
|
||||
const centralDirOffset = offset;
|
||||
const centralDirSize = centralHeaders.reduce((sum, h) => sum + h.length, 0);
|
||||
|
||||
// End of central directory record
|
||||
const endRecord = Buffer.alloc(22);
|
||||
endRecord.writeUInt32LE(0x06054b50, 0); // End of central directory signature
|
||||
endRecord.writeUInt16LE(0, 4); // Number of this disk
|
||||
endRecord.writeUInt16LE(0, 6); // Disk where central directory starts
|
||||
endRecord.writeUInt16LE(entries.length, 8); // Number of central directory records on this disk
|
||||
endRecord.writeUInt16LE(entries.length, 10); // Total number of central directory records
|
||||
endRecord.writeUInt32LE(centralDirSize, 12); // Size of central directory
|
||||
endRecord.writeUInt32LE(centralDirOffset, 16); // Offset of start of central directory
|
||||
endRecord.writeUInt16LE(0, 20); // Comment length
|
||||
|
||||
return Buffer.concat([...localHeaders, ...centralHeaders, endRecord]);
|
||||
}
|
||||
|
||||
// CRC32 implementation
|
||||
function crc32(data: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
const table = getCrc32Table();
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: array access within bounds
|
||||
crc = (crc >>> 8) ^ (table[(crc ^ data[i]!) & 0xff] as number);
|
||||
}
|
||||
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
let crc32Table: number[] | null = null;
|
||||
function getCrc32Table(): number[] {
|
||||
if (crc32Table) return crc32Table;
|
||||
|
||||
crc32Table = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
crc32Table[i] = c;
|
||||
}
|
||||
return crc32Table;
|
||||
}
|
||||
|
||||
function getAllFiles(dir: string, baseDir: string): ZipEntry[] {
|
||||
const entries: ZipEntry[] = [];
|
||||
const items = readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = join(dir, item.name);
|
||||
const relativePath = relative(baseDir, fullPath);
|
||||
|
||||
if (item.isDirectory()) {
|
||||
entries.push({
|
||||
path: `${relativePath}/`,
|
||||
data: Buffer.alloc(0),
|
||||
isDirectory: true,
|
||||
});
|
||||
entries.push(...getAllFiles(fullPath, baseDir));
|
||||
} else {
|
||||
entries.push({
|
||||
path: relativePath,
|
||||
data: readFileSync(fullPath),
|
||||
isDirectory: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function packageSkill(skillPath: string, outputDir?: string): string | null {
|
||||
const resolvedPath = resolve(skillPath);
|
||||
|
||||
// Validate skill folder exists
|
||||
if (!existsSync(resolvedPath)) {
|
||||
console.error(`Error: Skill folder not found: ${resolvedPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!statSync(resolvedPath).isDirectory()) {
|
||||
console.error(`Error: Path is not a directory: ${resolvedPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate SKILL.md exists
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md");
|
||||
if (!existsSync(skillMdPath)) {
|
||||
console.error(`Error: SKILL.md not found in ${resolvedPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Run validation before packaging
|
||||
console.log("Validating skill...");
|
||||
const { valid, message } = validateSkill(resolvedPath);
|
||||
if (!valid) {
|
||||
console.error(`Validation failed: ${message}`);
|
||||
console.error("Please fix the validation errors before packaging.");
|
||||
return null;
|
||||
}
|
||||
console.log(`${message}\n`);
|
||||
|
||||
// Determine output location
|
||||
const skillName = basename(resolvedPath);
|
||||
const outputPath = outputDir ? resolve(outputDir) : process.cwd();
|
||||
|
||||
if (outputDir && !existsSync(outputPath)) {
|
||||
mkdirSync(outputPath, { recursive: true });
|
||||
}
|
||||
|
||||
const skillFilename = join(outputPath, `${skillName}.skill`);
|
||||
|
||||
// Create the .skill file (zip format)
|
||||
try {
|
||||
// Get all files, using parent directory as base so skill folder is included
|
||||
const parentDir = dirname(resolvedPath);
|
||||
const entries = getAllFiles(resolvedPath, parentDir);
|
||||
|
||||
// Add the skill directory itself
|
||||
entries.unshift({
|
||||
path: `${skillName}/`,
|
||||
data: Buffer.alloc(0),
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
const zipBuffer = createZip(entries);
|
||||
writeFileSync(skillFilename, zipBuffer);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory) {
|
||||
console.log(` Added: ${entry.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSuccessfully packaged skill to: ${skillFilename}`);
|
||||
return skillFilename;
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error creating .skill file: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length < 1) {
|
||||
console.log(
|
||||
"Usage: npx ts-node package-skill.ts <path/to/skill-folder> [output-directory]",
|
||||
);
|
||||
console.log("\nExample:");
|
||||
console.log(" npx ts-node package-skill.ts .skills/my-skill");
|
||||
console.log(" npx ts-node package-skill.ts .skills/my-skill ./dist");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const skillPath = args[0] as string;
|
||||
const outputDir = args[1];
|
||||
|
||||
console.log(`Packaging skill: ${skillPath}`);
|
||||
if (outputDir) {
|
||||
console.log(`Output directory: ${outputDir}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
const result = packageSkill(skillPath, outputDir);
|
||||
process.exit(result ? 0 : 1);
|
||||
}
|
||||
|
||||
export { packageSkill };
|
||||
161
src/skills/builtin/skill-creator/scripts/validate-skill.ts
Normal file
161
src/skills/builtin/skill-creator/scripts/validate-skill.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
/**
|
||||
* Skill Validator - Validates skill structure and frontmatter
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node validate-skill.ts <skill-directory>
|
||||
*
|
||||
* Example:
|
||||
* npx ts-node validate-skill.ts .skills/my-skill
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const ALLOWED_PROPERTIES = new Set([
|
||||
"name",
|
||||
"description",
|
||||
"license",
|
||||
"allowed-tools",
|
||||
"metadata",
|
||||
]);
|
||||
|
||||
export function validateSkill(skillPath: string): ValidationResult {
|
||||
// Check SKILL.md exists
|
||||
const skillMdPath = join(skillPath, "SKILL.md");
|
||||
if (!existsSync(skillMdPath)) {
|
||||
return { valid: false, message: "SKILL.md not found" };
|
||||
}
|
||||
|
||||
// Read content
|
||||
const content = readFileSync(skillMdPath, "utf-8");
|
||||
|
||||
// Check for frontmatter
|
||||
if (!content.startsWith("---")) {
|
||||
return { valid: false, message: "No YAML frontmatter found" };
|
||||
}
|
||||
|
||||
// Extract frontmatter
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) {
|
||||
return { valid: false, message: "Invalid frontmatter format" };
|
||||
}
|
||||
|
||||
const frontmatterText = match[1] as string;
|
||||
|
||||
// Parse YAML frontmatter
|
||||
let frontmatter: Record<string, unknown>;
|
||||
try {
|
||||
frontmatter = parseYaml(frontmatterText);
|
||||
if (typeof frontmatter !== "object" || frontmatter === null) {
|
||||
return { valid: false, message: "Frontmatter must be a YAML dictionary" };
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Invalid YAML in frontmatter: ${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for unexpected properties
|
||||
const unexpectedKeys = Object.keys(frontmatter).filter(
|
||||
(key) => !ALLOWED_PROPERTIES.has(key),
|
||||
);
|
||||
if (unexpectedKeys.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Unexpected key(s) in SKILL.md frontmatter: ${unexpectedKeys.sort().join(", ")}. Allowed properties are: ${[...ALLOWED_PROPERTIES].sort().join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if (!("name" in frontmatter)) {
|
||||
return { valid: false, message: "Missing 'name' in frontmatter" };
|
||||
}
|
||||
if (!("description" in frontmatter)) {
|
||||
return { valid: false, message: "Missing 'description' in frontmatter" };
|
||||
}
|
||||
|
||||
// Validate name
|
||||
const name = frontmatter.name;
|
||||
if (typeof name !== "string") {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Name must be a string, got ${typeof name}`,
|
||||
};
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
if (trimmedName) {
|
||||
// Check naming convention (hyphen-case: lowercase with hyphens)
|
||||
if (!/^[a-z0-9-]+$/.test(trimmedName)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Name '${trimmedName}' should be hyphen-case (lowercase letters, digits, and hyphens only)`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
trimmedName.startsWith("-") ||
|
||||
trimmedName.endsWith("-") ||
|
||||
trimmedName.includes("--")
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Name '${trimmedName}' cannot start/end with hyphen or contain consecutive hyphens`,
|
||||
};
|
||||
}
|
||||
// Check name length (max 64 characters)
|
||||
if (trimmedName.length > 64) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Name is too long (${trimmedName.length} characters). Maximum is 64 characters.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Validate description
|
||||
const description = frontmatter.description;
|
||||
if (typeof description !== "string") {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Description must be a string, got ${typeof description}`,
|
||||
};
|
||||
}
|
||||
const trimmedDescription = description.trim();
|
||||
if (trimmedDescription) {
|
||||
// Check for angle brackets
|
||||
if (trimmedDescription.includes("<") || trimmedDescription.includes(">")) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "Description cannot contain angle brackets (< or >)",
|
||||
};
|
||||
}
|
||||
// Check description length (max 1024 characters)
|
||||
if (trimmedDescription.length > 1024) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Description is too long (${trimmedDescription.length} characters). Maximum is 1024 characters.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, message: "Skill is valid!" };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 1) {
|
||||
console.log("Usage: npx ts-node validate-skill.ts <skill-directory>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { valid, message } = validateSkill(args[0] as string);
|
||||
console.log(message);
|
||||
process.exit(valid ? 0 : 1);
|
||||
}
|
||||
306
src/tests/skills/skill-creator-scripts.test.ts
Normal file
306
src/tests/skills/skill-creator-scripts.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Tests for the bundled skill-creator scripts
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
initSkill,
|
||||
titleCaseSkillName,
|
||||
} from "../../skills/builtin/skill-creator/scripts/init-skill";
|
||||
import { packageSkill } from "../../skills/builtin/skill-creator/scripts/package-skill";
|
||||
import { validateSkill } from "../../skills/builtin/skill-creator/scripts/validate-skill";
|
||||
|
||||
const TEST_DIR = join(import.meta.dir, ".test-skill-creator");
|
||||
|
||||
describe("validate-skill", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("validates a valid skill", () => {
|
||||
const skillDir = join(TEST_DIR, "valid-skill");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: valid-skill
|
||||
description: A valid test skill
|
||||
---
|
||||
|
||||
# Valid Skill
|
||||
|
||||
This is a valid skill.
|
||||
`,
|
||||
);
|
||||
|
||||
const result = validateSkill(skillDir);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.message).toBe("Skill is valid!");
|
||||
});
|
||||
|
||||
test("fails when SKILL.md is missing", () => {
|
||||
const skillDir = join(TEST_DIR, "missing-skill");
|
||||
mkdirSync(skillDir);
|
||||
|
||||
const result = validateSkill(skillDir);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toBe("SKILL.md not found");
|
||||
});
|
||||
|
||||
test("fails when frontmatter is missing", () => {
|
||||
const skillDir = join(TEST_DIR, "no-frontmatter");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`# No Frontmatter
|
||||
|
||||
This skill has no frontmatter.
|
||||
`,
|
||||
);
|
||||
|
||||
const result = validateSkill(skillDir);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toBe("No YAML frontmatter found");
|
||||
});
|
||||
|
||||
test("fails when name is missing", () => {
|
||||
const skillDir = join(TEST_DIR, "no-name");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
description: A skill without a name
|
||||
---
|
||||
|
||||
# No Name
|
||||
`,
|
||||
);
|
||||
|
||||
const result = validateSkill(skillDir);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toBe("Missing 'name' in frontmatter");
|
||||
});
|
||||
|
||||
test("fails when description is missing", () => {
|
||||
const skillDir = join(TEST_DIR, "no-description");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: no-description
|
||||
---
|
||||
|
||||
# No Description
|
||||
`,
|
||||
);
|
||||
|
||||
const result = validateSkill(skillDir);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toBe("Missing 'description' in frontmatter");
|
||||
});
|
||||
|
||||
test("fails when name has invalid characters", () => {
|
||||
const skillDir = join(TEST_DIR, "invalid-name");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: Invalid_Name
|
||||
description: A skill with invalid name
|
||||
---
|
||||
|
||||
# Invalid Name
|
||||
`,
|
||||
);
|
||||
|
||||
const result = validateSkill(skillDir);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toContain("should be hyphen-case");
|
||||
});
|
||||
|
||||
test("fails when name starts with hyphen", () => {
|
||||
const skillDir = join(TEST_DIR, "hyphen-start");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: -invalid-start
|
||||
description: A skill with invalid name
|
||||
---
|
||||
|
||||
# Invalid Start
|
||||
`,
|
||||
);
|
||||
|
||||
const result = validateSkill(skillDir);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toContain("cannot start/end with hyphen");
|
||||
});
|
||||
|
||||
test("fails when description contains angle brackets", () => {
|
||||
const skillDir = join(TEST_DIR, "angle-brackets");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: angle-brackets
|
||||
description: A skill with <invalid> description
|
||||
---
|
||||
|
||||
# Angle Brackets
|
||||
`,
|
||||
);
|
||||
|
||||
const result = validateSkill(skillDir);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toContain("cannot contain angle brackets");
|
||||
});
|
||||
|
||||
test("fails when unexpected frontmatter keys are present", () => {
|
||||
const skillDir = join(TEST_DIR, "unexpected-keys");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: unexpected-keys
|
||||
description: A skill with unexpected keys
|
||||
author: Someone
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Unexpected Keys
|
||||
`,
|
||||
);
|
||||
|
||||
const result = validateSkill(skillDir);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toContain("Unexpected key(s)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("init-skill", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("titleCaseSkillName converts hyphenated names", () => {
|
||||
expect(titleCaseSkillName("my-skill")).toBe("My Skill");
|
||||
expect(titleCaseSkillName("pdf-editor")).toBe("Pdf Editor");
|
||||
expect(titleCaseSkillName("big-query-helper")).toBe("Big Query Helper");
|
||||
});
|
||||
|
||||
test("creates a new skill directory with all files", () => {
|
||||
const result = initSkill("test-skill", TEST_DIR);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(existsSync(join(TEST_DIR, "test-skill"))).toBe(true);
|
||||
expect(existsSync(join(TEST_DIR, "test-skill", "SKILL.md"))).toBe(true);
|
||||
expect(existsSync(join(TEST_DIR, "test-skill", "scripts"))).toBe(true);
|
||||
expect(existsSync(join(TEST_DIR, "test-skill", "references"))).toBe(true);
|
||||
expect(existsSync(join(TEST_DIR, "test-skill", "assets"))).toBe(true);
|
||||
});
|
||||
|
||||
test("created skill passes validation", () => {
|
||||
initSkill("valid-init", TEST_DIR);
|
||||
|
||||
// The initialized skill should pass validation (except for TODO in description)
|
||||
const skillDir = join(TEST_DIR, "valid-init");
|
||||
expect(existsSync(join(skillDir, "SKILL.md"))).toBe(true);
|
||||
});
|
||||
|
||||
test("fails when directory already exists", () => {
|
||||
const skillDir = join(TEST_DIR, "existing-skill");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
|
||||
const result = initSkill("existing-skill", TEST_DIR);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("package-skill", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("packages a valid skill into a .skill file", () => {
|
||||
// Create a valid skill
|
||||
const skillDir = join(TEST_DIR, "packagable-skill");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: packagable-skill
|
||||
description: A skill that can be packaged
|
||||
---
|
||||
|
||||
# Packagable Skill
|
||||
|
||||
This skill can be packaged.
|
||||
`,
|
||||
);
|
||||
|
||||
const result = packageSkill(skillDir, TEST_DIR);
|
||||
expect(result).not.toBeNull();
|
||||
expect(existsSync(join(TEST_DIR, "packagable-skill.skill"))).toBe(true);
|
||||
});
|
||||
|
||||
test("fails when skill directory does not exist", () => {
|
||||
const result = packageSkill(join(TEST_DIR, "nonexistent"), TEST_DIR);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("fails when SKILL.md is missing", () => {
|
||||
const skillDir = join(TEST_DIR, "no-skill-md");
|
||||
mkdirSync(skillDir);
|
||||
|
||||
const result = packageSkill(skillDir, TEST_DIR);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("fails when skill validation fails", () => {
|
||||
const skillDir = join(TEST_DIR, "invalid-skill");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: Invalid_Name
|
||||
description: Invalid skill
|
||||
---
|
||||
|
||||
# Invalid
|
||||
`,
|
||||
);
|
||||
|
||||
const result = packageSkill(skillDir, TEST_DIR);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -108,11 +108,15 @@ async function readSkillContent(
|
||||
skillId: string,
|
||||
skillsDir: string,
|
||||
): Promise<string> {
|
||||
// 1. Check bundled skills first (they have embedded content)
|
||||
const bundledSkills = getBundledSkills();
|
||||
// 1. Check bundled skills first (they have a path now)
|
||||
const bundledSkills = await getBundledSkills();
|
||||
const bundledSkill = bundledSkills.find((s) => s.id === skillId);
|
||||
if (bundledSkill?.content) {
|
||||
return bundledSkill.content;
|
||||
if (bundledSkill?.path) {
|
||||
try {
|
||||
return await readFile(bundledSkill.path, "utf-8");
|
||||
} catch {
|
||||
// Bundled skill path not found, continue to other sources
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try global skills directory
|
||||
|
||||
@@ -27,5 +27,6 @@
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/skills/builtin/**/scripts/**"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user