From d1bc8ead69f5cbdd97a46817149de0570f86eb93 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Sat, 21 Mar 2026 02:21:24 +0900 Subject: [PATCH] chore: cleanup repo and enhance validation - Add .gitignore (.DS_Store, .vscode, __pycache__) - Remove stale .gitkeep files (directories have content now) - Expand schema.toml to document all 6 content types (agent, hand, integration, skill, plugin) - Add plugin validation and contribution guide - Add id/name vs directory name consistency checks - Add cross-type routing alias collision detection (14 warnings found) --- .gitignore | 5 ++ CONTRIBUTING.md | 25 ++++++ agents/.gitkeep | 0 hands/.gitkeep | 0 integrations/.gitkeep | 0 schema.toml | 189 ++++++++++++++++++++++++++++++++++++++++-- scripts/validate.py | 84 ++++++++++++++++++- skills/.gitkeep | 0 8 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 .gitignore delete mode 100644 agents/.gitkeep delete mode 100644 hands/.gitkeep delete mode 100644 integrations/.gitkeep delete mode 100644 skills/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd6467b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +.vscode/ +__pycache__/ +*.pyc +.env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00d347f..c11c34b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,6 +162,31 @@ Plus a `main.py` with your implementation. - [ ] Prompt-only skills have a `[prompt].template` with correct `{{param}}` placeholders - [ ] Python skills include all required files +## Adding a Plugin + +Create a directory `plugins//` with a `plugin.toml` and hook scripts: + +```toml +name = "my-plugin" +version = "0.1.0" +description = "What this plugin does" +author = "your-name" + +[hooks] +ingest = "hooks/ingest.py" # Called when user message is received +after_turn = "hooks/after_turn.py" # Called after each conversation turn +``` + +Hook scripts communicate via stdin/stdout JSON. See [schema.toml](schema.toml) for the protocol format. + +### Plugin Checklist + +- [ ] `name` matches the directory name +- [ ] `[hooks]` lists at least one hook +- [ ] All referenced hook files exist +- [ ] Hook scripts read JSON from stdin and write JSON to stdout +- [ ] `requirements.txt` lists any Python dependencies (stdlib-only preferred) + ## Adding or Updating a Provider / Model Edit the appropriate provider file in `providers/`. If the provider doesn't exist, create a new file. diff --git a/agents/.gitkeep b/agents/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/hands/.gitkeep b/hands/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/integrations/.gitkeep b/integrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/schema.toml b/schema.toml index d2e0ac0..3dad453 100644 --- a/schema.toml +++ b/schema.toml @@ -1,9 +1,11 @@ -# Model Catalog Schema Reference -# ================================ -# This file documents all available fields and their types. -# It is NOT a real provider file — it exists purely as documentation. -# -# Required fields are marked. All other fields are optional. +# LibreFang Registry Schema Reference +# ==================================== +# This file documents all available fields and their types for each content type. +# It is NOT a real definition — it exists purely as documentation. + +# ═══════════════════════════════════════════════════════════════════════════════ +# PROVIDER / MODEL SCHEMA (providers/*.toml) +# ═══════════════════════════════════════════════════════════════════════════════ [provider] id = "provider-id" # Required: unique provider identifier (lowercase, hyphenated) @@ -13,7 +15,7 @@ base_url = "https://api.example.com" # Required: default API base URL key_required = true # Required: whether an API key is needed (false for local providers) [[models]] -id = "model-id" # Required: unique model identifier +id = "model-id" # Required: unique model identifier (API model ID) display_name = "Human Name" # Required: human-readable display name tier = "smart" # Required: one of "frontier", "smart", "balanced", "fast", "local" # frontier — cutting-edge, most capable (e.g. Claude Opus, GPT-4.1) @@ -29,3 +31,176 @@ supports_tools = true # Optional: tool/function calling support supports_vision = true # Optional: vision/image input support (default: false) supports_streaming = true # Optional: streaming response support (default: true) aliases = ["alias1", "alias2"] # Optional: alternative names for this model + +# ═══════════════════════════════════════════════════════════════════════════════ +# AGENT SCHEMA (agents//agent.toml) +# ═══════════════════════════════════════════════════════════════════════════════ + +# Top-level fields: +# name = "agent-name" # Required: agent identifier, must match directory name +# version = "0.1.0" # Optional: semver version string +# description = "What this agent does" # Required: one-sentence description +# author = "author-name" # Optional: author or organization +# module = "builtin:chat" # Required: runtime module (builtin:chat, builtin:tool, etc.) +# +# [model] # Agent's LLM configuration +# provider = "default" # Optional: provider ID or "default" +# model = "default" # Optional: model ID or "default" +# max_tokens = 4096 # Optional: max response tokens +# temperature = 0.7 # Optional: sampling temperature (0.0–2.0) +# system_prompt = "..." # Required: behavioral instructions for the agent +# +# [metadata.routing] # Optional: routing configuration +# aliases = ["exact match phrases"] # Phrases that route directly to this agent +# weak_aliases = ["partial match keywords"] # Keywords that suggest this agent +# +# [resources] # Optional: resource limits +# max_llm_tokens_per_hour = 100000 # Token budget per hour +# +# [capabilities] # Optional: agent permissions +# tools = ["tool1", "tool2"] # List of allowed tool names +# network = ["*"] # Network access patterns ("*" = unrestricted) +# memory_read = ["*"] # Memory read permissions +# memory_write = ["self.*"] # Memory write permissions +# agent_spawn = false # Whether this agent can spawn sub-agents + +# ═══════════════════════════════════════════════════════════════════════════════ +# HAND SCHEMA (hands//HAND.toml) +# ═══════════════════════════════════════════════════════════════════════════════ + +# Top-level fields: +# id = "hand-id" # Required: unique identifier, must match directory name +# name = "Hand Name" # Required: human-readable display name +# description = "What this hand does" # Required: one-sentence description +# category = "productivity" # Required: one of "communication", "content", "data", +# # "development", "devops", "finance", "productivity", +# # "research", "social" +# icon = "🌐" # Optional: emoji icon +# tools = ["tool1", "tool2"] # Required: list of tool names this hand uses +# +# [routing] # Optional: routing configuration +# aliases = ["activate phrases"] +# weak_aliases = ["keyword hints"] +# +# [[requires]] # Optional: external dependencies (repeatable) +# key = "python3" # Unique dependency key +# label = "Python 3" # Human-readable label +# requirement_type = "binary" # "binary", "package", "service" +# check_value = "python3" # Binary name or check command +# optional = false # Whether the dependency is optional +# description = "Why this is needed" +# [requires.install] # Platform-specific install instructions +# macos = "brew install python3" +# windows = "winget install Python.Python.3.12" +# linux_apt = "sudo apt install python3" +# manual_url = "https://..." +# +# [[settings]] # Optional: user-configurable settings (repeatable) +# key = "setting_key" # Unique setting key +# label = "Setting Label" # Human-readable label +# description = "What this controls" +# setting_type = "toggle" # "toggle", "select", "text", "number" +# default = "true" # Default value as string +# [[settings.options]] # For "select" type: available options +# value = "option1" +# label = "Option Label" +# +# [agent] # Required: the agent that powers this hand +# name = "hand-agent-name" +# description = "Agent description" +# module = "builtin:chat" +# provider = "default" +# model = "default" +# max_tokens = 16384 +# temperature = 0.3 +# max_iterations = 60 +# system_prompt = "..." # Detailed behavioral prompt +# +# [dashboard] # Optional: dashboard metrics +# [[dashboard.metrics]] +# label = "Metric Name" +# memory_key = "metric_memory_key" +# format = "number" # "number", "currency", "percentage" +# +# [metadata] # Optional: operational metadata +# frequency = "continuous" # "continuous", "on-demand", "scheduled" +# token_consumption = "low" # "low", "medium", "high" +# default_active = true # Whether active by default + +# ═══════════════════════════════════════════════════════════════════════════════ +# INTEGRATION SCHEMA (integrations/.toml) +# ═══════════════════════════════════════════════════════════════════════════════ + +# Top-level fields: +# id = "integration-id" # Required: unique identifier, must match filename +# name = "Service Name" # Required: human-readable display name +# description = "What this provides" # Optional: one-sentence description +# category = "devtools" # Optional: "devtools", "communication", "storage", +# # "monitoring", "data", "productivity" +# icon = "🐙" # Optional: emoji icon +# tags = ["tag1", "tag2"] # Optional: searchable tags +# +# [transport] # Required: MCP server transport configuration +# type = "stdio" # "stdio" or "sse" +# command = "npx" # Command to launch the MCP server +# args = ["-y", "@pkg/server"] # Command arguments +# +# [[required_env]] # Optional: required environment variables (repeatable) +# name = "SERVICE_API_KEY" # Environment variable name +# label = "API Key" # Human-readable label +# help = "How to get this" # Help text +# is_secret = true # Whether this is a secret value +# get_url = "https://..." # URL where user can get the value +# +# [oauth] # Optional: OAuth configuration +# provider = "github" +# scopes = ["repo", "read:org"] +# auth_url = "https://..." +# token_url = "https://..." +# +# [health_check] # Optional: health check configuration +# interval_secs = 60 +# unhealthy_threshold = 3 +# +# setup_instructions = "..." # Optional: multi-line setup guide + +# ═══════════════════════════════════════════════════════════════════════════════ +# SKILL SCHEMA (skills//skill.toml) +# ═══════════════════════════════════════════════════════════════════════════════ + +# [skill] # Required: skill metadata +# name = "skill-name" # Required: skill identifier, must match directory name +# version = "0.1.0" # Optional: semver version +# description = "What this skill does" # Optional: one-sentence description +# author = "author-name" # Optional: author +# tags = ["tag1", "tag2"] # Optional: searchable tags +# +# [runtime] # Required: execution runtime +# type = "promptonly" # Required: "promptonly", "python", "node", "shell" +# entry = "main.py" # Required for non-promptonly: entry point file +# +# [input] # Optional: input parameter definitions +# param_name = { type = "string", description = "...", required = true } +# +# [prompt] # Required for promptonly: prompt template +# template = "Use {{param_name}} in the template" + +# ═══════════════════════════════════════════════════════════════════════════════ +# PLUGIN SCHEMA (plugins//plugin.toml) +# ═══════════════════════════════════════════════════════════════════════════════ + +# Top-level fields: +# name = "plugin-name" # Required: plugin identifier, must match directory name +# version = "0.1.0" # Required: semver version +# description = "What this plugin does" # Required: one-sentence description +# author = "author-name" # Optional: author +# +# [hooks] # Required: hook entry points +# ingest = "hooks/ingest.py" # Optional: called when user message is received +# after_turn = "hooks/after_turn.py" # Optional: called after each conversation turn +# +# Hook scripts communicate via stdin/stdout JSON: +# ingest receives: {"type": "ingest", "agent_id": "...", "message": "..."} +# ingest returns: {"type": "ingest_result", "memories": [{"content": "..."}]} +# after_turn receives: {"type": "after_turn", "agent_id": "...", "messages": [...]} +# after_turn returns: {"type": "ok"} diff --git a/scripts/validate.py b/scripts/validate.py index f1d6f07..20b694b 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -106,12 +106,18 @@ def validate_agent_file(filepath: Path) -> list[str]: return [err] errors = [] + dir_name = filepath.parent.name rel = filepath.relative_to(filepath.parent.parent) for field in ("name", "description", "module"): if field not in data: errors.append(f"{rel}: Missing required field '{field}'") + # name should match directory name + name = data.get("name") + if name and name != dir_name: + errors.append(f"{rel}: name '{name}' does not match directory '{dir_name}'") + if "model" in data: model = data["model"] if "system_prompt" not in model and "provider" not in model: @@ -127,12 +133,18 @@ def validate_hand_file(filepath: Path) -> list[str]: return [err] errors = [] + dir_name = filepath.parent.name rel = filepath.relative_to(filepath.parent.parent.parent) for field in ("id", "name", "description"): if field not in data: errors.append(f"{rel}: Missing required field '{field}'") + # id should match directory name + hand_id = data.get("id") + if hand_id and hand_id != dir_name: + errors.append(f"{rel}: id '{hand_id}' does not match directory '{dir_name}'") + category = data.get("category") if category and category not in VALID_HAND_CATEGORIES: errors.append(f"{rel}: Invalid category '{category}' (valid: {', '.join(sorted(VALID_HAND_CATEGORIES))})") @@ -150,11 +162,17 @@ def validate_integration_file(filepath: Path) -> list[str]: return [err] errors = [] + expected_id = filepath.stem # filename without .toml for field in ("id", "name"): if field not in data: errors.append(f"{filepath.name}: Missing required field '{field}'") + # id should match filename + int_id = data.get("id") + if int_id and int_id != expected_id: + errors.append(f"{filepath.name}: id '{int_id}' does not match filename '{expected_id}'") + if "transport" not in data: errors.append(f"{filepath.name}: Missing [transport] section") else: @@ -297,9 +315,67 @@ def main(): all_errors.append(f"skills/{d.name}: Missing skill.toml") stats["skills"] = len(skill_dirs) + # --- Plugins --- + plugins_dir = root / "plugins" + if plugins_dir.is_dir(): + plugin_dirs = sorted([d for d in plugins_dir.iterdir() if d.is_dir() and not d.name.startswith(".")]) + for d in plugin_dirs: + plugin_toml = d / "plugin.toml" + if plugin_toml.exists(): + data, err = load_toml(plugin_toml) + if err: + all_errors.append(err) + elif data: + if "name" not in data: + all_errors.append(f"plugins/{d.name}: Missing required field 'name'") + elif data["name"] != d.name: + all_errors.append(f"plugins/{d.name}: name '{data['name']}' does not match directory '{d.name}'") + if "hooks" not in data: + all_errors.append(f"plugins/{d.name}: Missing [hooks] section") + else: + for hook_name, hook_path in data["hooks"].items(): + full = d / hook_path + if not full.exists(): + all_errors.append(f"plugins/{d.name}: Hook file '{hook_path}' not found") + else: + all_errors.append(f"plugins/{d.name}: Missing plugin.toml") + stats["plugins"] = len(plugin_dirs) + # --- Aliases --- all_errors.extend(validate_aliases_file(root / "aliases.toml")) + # --- Cross-type: duplicate routing aliases --- + all_aliases = {} # alias -> (type, name) + warnings = [] + + # Collect agent routing aliases + if agents_dir.is_dir(): + for d in sorted([d for d in agents_dir.iterdir() if d.is_dir() and not d.name.startswith(".")]): + data, _ = load_toml(d / "agent.toml") + if data: + routing = data.get("metadata", {}).get("routing", {}) + for alias in routing.get("aliases", []) + routing.get("weak_aliases", []): + key = alias.lower() + if key in all_aliases: + prev_type, prev_name = all_aliases[key] + warnings.append(f"Routing alias '{alias}' used by both {prev_type}/{prev_name} and agent/{d.name}") + else: + all_aliases[key] = ("agent", d.name) + + # Collect hand routing aliases + if hands_dir.is_dir(): + for d in sorted([d for d in hands_dir.iterdir() if d.is_dir() and not d.name.startswith(".")]): + data, _ = load_toml(d / "HAND.toml") + if data: + routing = data.get("routing", {}) + for alias in routing.get("aliases", []) + routing.get("weak_aliases", []): + key = alias.lower() + if key in all_aliases: + prev_type, prev_name = all_aliases[key] + warnings.append(f"Routing alias '{alias}' used by both {prev_type}/{prev_name} and hand/{d.name}") + else: + all_aliases[key] = ("hand", d.name) + # --- Report --- print("=" * 60) print("LibreFang Registry Validation") @@ -313,11 +389,17 @@ def main(): print() print("Content summary:") - for key in ("providers", "models", "agents", "hands", "integrations", "skills"): + for key in ("providers", "models", "agents", "hands", "integrations", "skills", "plugins"): if key in stats: print(f" {key:20s} {stats[key]:>4}") print() + if warnings: + print(f"WARNINGS ({len(warnings)}):") + for w in warnings: + print(f" - {w}") + print() + if all_errors: print(f"VALIDATION FAILED with {len(all_errors)} error(s)") sys.exit(1) diff --git a/skills/.gitkeep b/skills/.gitkeep deleted file mode 100644 index e69de29..0000000