diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/agents/openai.yaml b/codex-rs/core/src/skills/assets/samples/skill-creator/agents/openai.yaml new file mode 100644 index 000000000..3095c600c --- /dev/null +++ b/codex-rs/core/src/skills/assets/samples/skill-creator/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Skill Creator" + short_description: "Create or update a skill" + icon_small: "./assets/skill-creator-small.svg" + icon_large: "./assets/skill-creator.png" diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator-small.svg b/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator-small.svg new file mode 100644 index 000000000..c6e4f67c6 --- /dev/null +++ b/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator.png b/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator.png new file mode 100644 index 000000000..4f3d6d82f Binary files /dev/null and b/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator.png differ diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/references/openai_yaml.md b/codex-rs/core/src/skills/assets/samples/skill-creator/references/openai_yaml.md new file mode 100644 index 000000000..da5629f8d --- /dev/null +++ b/codex-rs/core/src/skills/assets/samples/skill-creator/references/openai_yaml.md @@ -0,0 +1,43 @@ +# openai.yaml fields (full example + descriptions) + +`agents/openai.yaml` is an extended, product-specific config intended for the machine/harness to read, not the agent. Other product-specific config can also live in the `agents/` folder. + +## Full example + +```yaml +interface: + display_name: "Optional user-facing name" + short_description: "Optional user-facing description" + icon_small: "./assets/small-400px.png" + icon_large: "./assets/large-logo.svg" + brand_color: "#3B82F6" + default_prompt: "Optional surrounding prompt to use the skill with" + +dependencies: + tools: + - type: "mcp" + value: "github" + description: "GitHub MCP server" + transport: "streamable_http" + url: "https://api.githubcopilot.com/mcp/" +``` + +## Field descriptions and constraints + +Top-level constraints: + +- Quote all string values. +- Keep keys unquoted. +- For `interface.default_prompt`: generate a helpful, short (typically 1 sentence) example starting prompt based on the skill. It must explicitly mention the skill as `$skill-name` (e.g., "Use $skill-name-here to draft a concise weekly status update."). + +- `interface.display_name`: Human-facing title shown in UI skill lists and chips. +- `interface.short_description`: Human-facing short UI blurb (25–64 chars) for quick scanning. +- `interface.icon_small`: Path to a small icon asset (relative to skill dir). Default to `./assets/` and place icons in the skill's `assets/` folder. +- `interface.icon_large`: Path to a larger logo asset (relative to skill dir). Default to `./assets/` and place icons in the skill's `assets/` folder. +- `interface.brand_color`: Hex color used for UI accents (e.g., badges). +- `interface.default_prompt`: Default prompt snippet inserted when invoking the skill. +- `dependencies.tools[].type`: Dependency category. Only `mcp` is supported for now. +- `dependencies.tools[].value`: Identifier of the tool or dependency. +- `dependencies.tools[].description`: Human-readable explanation of the dependency. +- `dependencies.tools[].transport`: Connection type when `type` is `mcp`. +- `dependencies.tools[].url`: MCP server URL when `type` is `mcp`. diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/generate_openai_yaml.py b/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/generate_openai_yaml.py new file mode 100644 index 000000000..1a9d784f8 --- /dev/null +++ b/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/generate_openai_yaml.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +OpenAI YAML Generator - Creates agents/openai.yaml for a skill folder. + +Usage: + generate_openai_yaml.py [--name ] [--interface key=value] +""" + +import argparse +import re +import sys +from pathlib import Path + +import yaml + +ACRONYMS = { + "GH", + "MCP", + "API", + "CI", + "CLI", + "LLM", + "PDF", + "PR", + "UI", + "URL", + "SQL", +} + +BRANDS = { + "openai": "OpenAI", + "openapi": "OpenAPI", + "github": "GitHub", + "pagerduty": "PagerDuty", + "datadog": "DataDog", + "sqlite": "SQLite", + "fastapi": "FastAPI", +} + +SMALL_WORDS = {"and", "or", "to", "up", "with"} + +ALLOWED_INTERFACE_KEYS = { + "display_name", + "short_description", + "icon_small", + "icon_large", + "brand_color", + "default_prompt", +} + + +def yaml_quote(value): + escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + return f'"{escaped}"' + + +def format_display_name(skill_name): + words = [word for word in skill_name.split("-") if word] + formatted = [] + for index, word in enumerate(words): + lower = word.lower() + upper = word.upper() + if upper in ACRONYMS: + formatted.append(upper) + continue + if lower in BRANDS: + formatted.append(BRANDS[lower]) + continue + if index > 0 and lower in SMALL_WORDS: + formatted.append(lower) + continue + formatted.append(word.capitalize()) + return " ".join(formatted) + + +def generate_short_description(display_name): + description = f"Help with {display_name} tasks" + + if len(description) < 25: + description = f"Help with {display_name} tasks and workflows" + if len(description) < 25: + description = f"Help with {display_name} tasks with guidance" + + if len(description) > 64: + description = f"Help with {display_name}" + if len(description) > 64: + description = f"{display_name} helper" + if len(description) > 64: + description = f"{display_name} tools" + if len(description) > 64: + suffix = " helper" + max_name_length = 64 - len(suffix) + trimmed = display_name[:max_name_length].rstrip() + description = f"{trimmed}{suffix}" + if len(description) > 64: + description = description[:64].rstrip() + + if len(description) < 25: + description = f"{description} workflows" + if len(description) > 64: + description = description[:64].rstrip() + + return description + + +def read_frontmatter_name(skill_dir): + skill_md = Path(skill_dir) / "SKILL.md" + if not skill_md.exists(): + print(f"[ERROR] SKILL.md not found in {skill_dir}") + return None + content = skill_md.read_text() + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + if not match: + print("[ERROR] Invalid SKILL.md frontmatter format.") + return None + frontmatter_text = match.group(1) + try: + frontmatter = yaml.safe_load(frontmatter_text) + except yaml.YAMLError as exc: + print(f"[ERROR] Invalid YAML frontmatter: {exc}") + return None + if not isinstance(frontmatter, dict): + print("[ERROR] Frontmatter must be a YAML dictionary.") + return None + name = frontmatter.get("name", "") + if not isinstance(name, str) or not name.strip(): + print("[ERROR] Frontmatter 'name' is missing or invalid.") + return None + return name.strip() + + +def parse_interface_overrides(raw_overrides): + overrides = {} + optional_order = [] + for item in raw_overrides: + if "=" not in item: + print(f"[ERROR] Invalid interface override '{item}'. Use key=value.") + return None, None + key, value = item.split("=", 1) + key = key.strip() + value = value.strip() + if not key: + print(f"[ERROR] Invalid interface override '{item}'. Key is empty.") + return None, None + if key not in ALLOWED_INTERFACE_KEYS: + allowed = ", ".join(sorted(ALLOWED_INTERFACE_KEYS)) + print(f"[ERROR] Unknown interface field '{key}'. Allowed: {allowed}") + return None, None + overrides[key] = value + if key not in ("display_name", "short_description") and key not in optional_order: + optional_order.append(key) + return overrides, optional_order + + +def write_openai_yaml(skill_dir, skill_name, raw_overrides): + overrides, optional_order = parse_interface_overrides(raw_overrides) + if overrides is None: + return None + + display_name = overrides.get("display_name") or format_display_name(skill_name) + short_description = overrides.get("short_description") or generate_short_description(display_name) + + if not (25 <= len(short_description) <= 64): + print( + "[ERROR] short_description must be 25-64 characters " + f"(got {len(short_description)})." + ) + return None + + interface_lines = [ + "interface:", + f" display_name: {yaml_quote(display_name)}", + f" short_description: {yaml_quote(short_description)}", + ] + + for key in optional_order: + value = overrides.get(key) + if value is not None: + interface_lines.append(f" {key}: {yaml_quote(value)}") + + agents_dir = Path(skill_dir) / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + output_path = agents_dir / "openai.yaml" + output_path.write_text("\n".join(interface_lines) + "\n") + print(f"[OK] Created agents/openai.yaml") + return output_path + + +def main(): + parser = argparse.ArgumentParser( + description="Create agents/openai.yaml for a skill directory.", + ) + parser.add_argument("skill_dir", help="Path to the skill directory") + parser.add_argument( + "--name", + help="Skill name override (defaults to SKILL.md frontmatter)", + ) + parser.add_argument( + "--interface", + action="append", + default=[], + help="Interface override in key=value format (repeatable)", + ) + args = parser.parse_args() + + skill_dir = Path(args.skill_dir).resolve() + if not skill_dir.exists(): + print(f"[ERROR] Skill directory not found: {skill_dir}") + sys.exit(1) + if not skill_dir.is_dir(): + print(f"[ERROR] Path is not a directory: {skill_dir}") + sys.exit(1) + + skill_name = args.name or read_frontmatter_name(skill_dir) + if not skill_name: + sys.exit(1) + + result = write_openai_yaml(skill_dir, skill_name, args.interface) + if result: + sys.exit(0) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/agents/openai.yaml b/codex-rs/core/src/skills/assets/samples/skill-installer/agents/openai.yaml new file mode 100644 index 000000000..88d40cd94 --- /dev/null +++ b/codex-rs/core/src/skills/assets/samples/skill-installer/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Skill Installer" + short_description: "Install curated skills from openai/skills or other repos" + icon_small: "./assets/skill-installer-small.svg" + icon_large: "./assets/skill-installer.png" diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer-small.svg b/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer-small.svg new file mode 100644 index 000000000..ccfc03424 --- /dev/null +++ b/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer.png b/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer.png new file mode 100644 index 000000000..2977cd5bb Binary files /dev/null and b/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer.png differ diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/list-skills.py b/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/list-skills.py new file mode 100755 index 000000000..0977c296a --- /dev/null +++ b/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/list-skills.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""List skills from a GitHub repo path.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error + +from github_utils import github_api_contents_url, github_request + +DEFAULT_REPO = "openai/skills" +DEFAULT_PATH = "skills/.curated" +DEFAULT_REF = "main" + + +class ListError(Exception): + pass + + +class Args(argparse.Namespace): + repo: str + path: str + ref: str + format: str + + +def _request(url: str) -> bytes: + return github_request(url, "codex-skill-list") + + +def _codex_home() -> str: + return os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex")) + + +def _installed_skills() -> set[str]: + root = os.path.join(_codex_home(), "skills") + if not os.path.isdir(root): + return set() + entries = set() + for name in os.listdir(root): + path = os.path.join(root, name) + if os.path.isdir(path): + entries.add(name) + return entries + + +def _list_skills(repo: str, path: str, ref: str) -> list[str]: + api_url = github_api_contents_url(repo, path, ref) + try: + payload = _request(api_url) + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise ListError( + "Skills path not found: " + f"https://github.com/{repo}/tree/{ref}/{path}" + ) from exc + raise ListError(f"Failed to fetch skills: HTTP {exc.code}") from exc + data = json.loads(payload.decode("utf-8")) + if not isinstance(data, list): + raise ListError("Unexpected skills listing response.") + skills = [item["name"] for item in data if item.get("type") == "dir"] + return sorted(skills) + + +def _parse_args(argv: list[str]) -> Args: + parser = argparse.ArgumentParser(description="List skills.") + parser.add_argument("--repo", default=DEFAULT_REPO) + parser.add_argument( + "--path", + default=DEFAULT_PATH, + help="Repo path to list (default: skills/.curated)", + ) + parser.add_argument("--ref", default=DEFAULT_REF) + parser.add_argument( + "--format", + choices=["text", "json"], + default="text", + help="Output format", + ) + return parser.parse_args(argv, namespace=Args()) + + +def main(argv: list[str]) -> int: + args = _parse_args(argv) + try: + skills = _list_skills(args.repo, args.path, args.ref) + installed = _installed_skills() + if args.format == "json": + payload = [ + {"name": name, "installed": name in installed} for name in skills + ] + print(json.dumps(payload)) + else: + for idx, name in enumerate(skills, start=1): + suffix = " (already installed)" if name in installed else "" + print(f"{idx}. {name}{suffix}") + return 0 + except ListError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))