Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ private-agents/
private-hooks/
private-voices/

# Local-only index targets, not committed
skills/INDEX.local.json
agents/INDEX.local.json

# Draft articles (work in progress, not committed)
drafts/
draft-*.md
Expand Down
89 changes: 0 additions & 89 deletions agents/INDEX.json
Original file line number Diff line number Diff line change
Expand Up @@ -931,95 +931,6 @@
],
"complexity": "Medium",
"category": "language"
},
"wrestlejoy-amy-writer-compact": {
"file": "private-agents/wrestlejoy-amy-writer-compact.md",
"short_description": "Use this agent when you need to write WrestleJoy content in Amy Nemmity's voice with context optimization",
"triggers": [
"WrestleJoy",
"Amy voice",
"Amy compact"
],
"pairs_with": [
"voice-writer",
"wrestlejoy-mmr-research"
],
"complexity": "Comprehensive",
"category": "content"
},
"wrestlejoy-amy-writer-expanded": {
"file": "private-agents/wrestlejoy-amy-writer-expanded.md",
"short_description": "Use this agent when you need to write WrestleJoy content in Amy Nemmity's voice with maximum depth and extensive examples",
"triggers": [
"WrestleJoy",
"Amy voice",
"Amy expanded"
],
"pairs_with": [
"voice-writer",
"wrestlejoy-mmr-research"
],
"complexity": "Comprehensive",
"category": "content"
},
"wrestlejoy-amy-writer": {
"file": "private-agents/wrestlejoy-amy-writer.md",
"short_description": "Use this agent when writing WrestleJoy content in Amy Nemmity's voice",
"triggers": [
"WrestleJoy",
"Amy voice",
"Amy's voice",
"Amy Nemmity",
"wrestling article",
"wrestling awards",
"Amy style"
],
"pairs_with": [
"voice-writer",
"wrestlejoy-mmr-research",
"wrestlejoy-research-transform"
],
"complexity": "Comprehensive",
"category": "content"
},
"wrestlejoy-news-editor": {
"file": "private-agents/wrestlejoy-news-editor.md",
"short_description": "Editorial review agent for WrestleJoy automated news pipeline",
"triggers": [
"wrestlejoy edit",
"review article",
"editorial check",
"news review",
"check article tone",
"wrestlejoy quality check",
"pre-publish review"
],
"pairs_with": [
"wrestlejoy-news-producer",
"wrestlejoy-news-pipeline",
"voice-andy-nemmity"
],
"complexity": "Simple",
"category": "content"
},
"wrestlejoy-news-producer": {
"file": "private-agents/wrestlejoy-news-producer.md",
"short_description": "Orchestrator agent for the WrestleJoy automated news production pipeline",
"triggers": [
"wrestlejoy news",
"aew news",
"wrestling news pipeline",
"news automation"
],
"pairs_with": [
"wrestlejoy-news-pipeline",
"wrestlejoy-news-editor",
"voice-andy-nemmity",
"wordpress-uploader",
"seo-optimizer"
],
"complexity": "Complex",
"category": "content"
}
}
}
25 changes: 25 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Scripts

Utility scripts for the Claude Code Toolkit.

## Index Generators

`generate-skill-index.py` and `generate-agent-index.py` walk `skills/` and
`agents/` and produce `INDEX.json` files consumed by the `/do` router.

By default the generators skip symlinked directories, so the tracked index
files reflect only directly-committed content.

### Local development workflow

To index symlinked entries for local workflows, use `--include-private` with
a separate output target:

```bash
python3 scripts/generate-skill-index.py --include-private --output skills/INDEX.local.json
python3 scripts/generate-agent-index.py --include-private --output agents/INDEX.local.json
```

The router (`scripts/routing-manifest.py`) prefers the local file when present,
so local runs see all entries while the tracked index stays public. The
`*.local.json` files are gitignored and never committed.
63 changes: 50 additions & 13 deletions scripts/generate-agent-index.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@

Usage:
python scripts/generate-agent-index.py
python scripts/generate-agent-index.py --include-private
python scripts/generate-agent-index.py --include-private --output agents/INDEX.local.json

Options:
--include-private Include symlinked agent files (default: skip them)
--output PATH Output path (default: agents/INDEX.json)

Output:
agents/INDEX.json - Routing index for /do router
agents/INDEX.json - Routing index for /do router (public, tracked)
"""

import argparse
import json
import re
from pathlib import Path
Expand Down Expand Up @@ -108,19 +115,28 @@ def extract_short_description(description: str) -> str:
return description[:100]


def generate_index(agents_dir: Path, relative_to: Path | None = None) -> dict:
def generate_index(
agents_dir: Path,
relative_to: Path | None = None,
include_private: bool = False,
) -> dict:
"""Generate routing index from all agent files.

Args:
agents_dir: Directory containing agent markdown files.
relative_to: If provided, agent file paths in the index will be relative
to this directory (e.g. repo root), so private agents get
``private-agents/filename.md`` instead of bare ``filename.md``.
include_private: When True, include symlinked agent files. When False (default),
only directly-tracked files are indexed.
"""
index = {"version": "1.0", "generated_by": "scripts/generate-agent-index.py", "agents": {}}
errors = []

for agent_file in sorted(agents_dir.glob("*.md")):
# Skip symlinked files unless include_private is set.
if agent_file.is_symlink() and not include_private:
continue
try:
content = agent_file.read_text(encoding="utf-8")
except Exception as e:
Expand Down Expand Up @@ -179,8 +195,26 @@ def generate_index(agents_dir: Path, relative_to: Path | None = None) -> dict:
return index


def main():
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate agent routing index from YAML frontmatter.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--include-private",
action="store_true",
default=False,
help="Include symlinked agent files. Use with --output for local-only workflows.",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Output file path (default: agents/INDEX.json relative to repo root).",
)
args = parser.parse_args()

# Find agents directory relative to script
script_dir = Path(__file__).parent
repo_root = script_dir.parent
Expand All @@ -190,22 +224,25 @@ def main():
print(f"Error: agents directory not found at {agents_dir}")
return 1

# Generate index from public agents
index = generate_index(agents_dir, relative_to=repo_root)
# Resolve output path
output_path: Path = args.output if args.output is not None else agents_dir / "INDEX.json"

# Generate index: symlinked files skipped by default, included with --include-private.
# Private agents directory (gitignored) is only scanned when --include-private is set.
index = generate_index(agents_dir, relative_to=repo_root, include_private=args.include_private)

# Also scan private agents if they exist (gitignored, user-specific)
private_agents_dir = repo_root / "private-agents"
if private_agents_dir.exists() and any(private_agents_dir.iterdir()):
private_index = generate_index(private_agents_dir, relative_to=repo_root)
index["agents"].update(private_index["agents"])
if args.include_private:
private_agents_dir = repo_root / "private-agents"
if private_agents_dir.exists() and any(private_agents_dir.iterdir()):
private_index = generate_index(private_agents_dir, relative_to=repo_root, include_private=True)
index["agents"].update(private_index["agents"])

# Write index file
index_file = agents_dir / "INDEX.json"
with open(index_file, "w") as f:
with open(output_path, "w") as f:
json.dump(index, f, indent=2)

# Summary
print(f"Generated {index_file}")
print(f"Generated {output_path}")
print(f" Total agents: {len(index['agents'])}")

# Show agents with routing metadata
Expand Down
43 changes: 40 additions & 3 deletions scripts/generate-skill-index.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@

Usage:
python scripts/generate-skill-index.py
python scripts/generate-skill-index.py --include-private
python scripts/generate-skill-index.py --include-private --output skills/INDEX.local.json

Options:
--include-private Include symlinked directories (default: skip them)
--output PATH Output path (default: skills/INDEX.json)

Output:
skills/INDEX.json - Skill routing index for /do router
skills/INDEX.json - Skill routing index for /do router (public, tracked)


Exit codes:
Expand All @@ -19,6 +25,7 @@
2 - Trigger collisions detected among force-routed entries
"""

import argparse
import json
import re
import sys
Expand Down Expand Up @@ -264,6 +271,7 @@ def generate_index(
dir_prefix: str,
collection_key: str,
is_pipeline: bool = False,
include_private: bool = False,
) -> tuple[dict, list[str]]:
"""Generate a dict-keyed routing index from all SKILL.md files in a directory.

Expand All @@ -272,6 +280,8 @@ def generate_index(
dir_prefix: Path prefix for file field (e.g., "skills" or "pipelines").
collection_key: Top-level key name in the index (e.g., "skills" or "pipelines").
is_pipeline: Whether entries are pipelines (enables phase extraction).
include_private: When True, include symlinked directories. When False (default),
only directly-tracked directories are indexed.

Returns:
tuple: (index dict with version/generated/generated_by/collection,
Expand All @@ -289,6 +299,11 @@ def generate_index(
if not skill_dir.is_dir():
continue

# Skip symlinked directories unless --include-private was passed.
# The public index reflects directly-tracked files only.
if skill_dir.is_symlink() and not include_private:
continue

skill_file = skill_dir / "SKILL.md"
if not skill_file.exists():
continue
Expand Down Expand Up @@ -385,6 +400,24 @@ def write_index(index: dict, output_path: Path) -> bool:

def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate skill routing index from YAML frontmatter.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--include-private",
action="store_true",
default=False,
help="Include symlinked directories. Use with --output for local-only workflows.",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Output file path (default: skills/INDEX.json relative to repo root).",
)
args = parser.parse_args()

script_dir = Path(__file__).parent
repo_root = script_dir.parent
skills_dir = repo_root / "skills"
Expand All @@ -393,12 +426,16 @@ def main() -> int:
print(f"Error: skills directory not found at {skills_dir}", file=sys.stderr)
return 1

# Resolve output path
output_path: Path = args.output if args.output is not None else skills_dir / "INDEX.json"

# Generate skills index
skills_index, skills_warnings = generate_index(
source_dir=skills_dir,
dir_prefix="skills",
collection_key="skills",
is_pipeline=False,
include_private=args.include_private,
)

# Report warnings if any
Expand All @@ -412,8 +449,8 @@ def main() -> int:
print("Error: No skills found. Index file not written.", file=sys.stderr)
return 1

# Write skills/INDEX.json
skills_index_path = skills_dir / "INDEX.json"
# Write to output path (default: skills/INDEX.json)
skills_index_path = output_path
if not write_index(skills_index, skills_index_path):
return 1

Expand Down
16 changes: 14 additions & 2 deletions scripts/routing-manifest.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,21 @@

REPO_ROOT = Path(__file__).resolve().parent.parent


def _resolve_index(tracked: Path, local_name: str) -> Path:
"""Return the local override path when it exists, otherwise the tracked path.

Local override files (INDEX.local.json) are gitignored and may contain
entries for symlinked directories. They are produced by running the
generator with --include-private --output <local-path>.
"""
local = tracked.parent / local_name
return local if local.exists() else tracked


INDEX_PATHS = {
"skills": REPO_ROOT / "skills" / "INDEX.json",
"agents": REPO_ROOT / "agents" / "INDEX.json",
"skills": _resolve_index(REPO_ROOT / "skills" / "INDEX.json", "INDEX.local.json"),
"agents": _resolve_index(REPO_ROOT / "agents" / "INDEX.json", "INDEX.local.json"),
"pipelines": REPO_ROOT / "skills" / "workflow" / "references" / "pipeline-index.json",
}

Expand Down
8 changes: 4 additions & 4 deletions scripts/scan-negative-framing.py
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""Scan content for negative framing patterns.

VexJoy and WrestleJoy are joy-centered publications. Content should frame
experiences positively or neutrally, not through grievance, accusation,
or victimhood. This script detects negative framing patterns that don't
match the joy-centered editorial voice.
Joy-centered content evaluation. Content should frame experiences positively
or neutrally, not through grievance, accusation, or victimhood. This script
detects negative framing patterns that don't match a joy-centered editorial
voice.

Usage:
python3 scripts/scan-negative-framing.py <file>
Expand Down
Loading
Loading