Skill: matrixy/agent-registry (version 2.0.1)

--- name: agent-registry version: 2.0.1 description: | MANDATORY agent discovery system for token-efficient agent loading. Claude MUST use this skill instead of loading agents directly from ~/.claude/agents/ or .claude/agents/. Provides lazy loading via search and get tools. Use when: (1) user task may benefit from specialized agent expertise, (2) user asks about available agents, (3) starting complex workflows that historically used agents. This skill reduces context window usage by ~95% compared to loading all agents upfront. hooks: UserPromptSubmit: - hooks: - type: command command: "bun ${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_search.js" timeout: 5 --- # Agent Registry Lazy-loading system for Claude Code agents. Eliminates the "~16k tokens" warning by loading agents on-demand. ## CRITICAL RULE **NEVER assume agents are pre-loaded.** Always use this registry to discover and load agents. ## Workflow ``` User Request → search_agents(intent) → select best match → get_agent(name) → execute with agent ``` ## Available Commands | Command | When to Use | Example | |---------|-------------|---------| | `list.js` | User asks "what agents do I have" or needs overview | `bun bin/list.js` | | `search.js` | Find agents matching user intent (ALWAYS do this first) | `bun bin/search.js "code review security"` | | `search-paged.js` | Paged search for large registries (300+ agents) | `bun bin/search-paged.js "query" --page 1 --page-size 10` | | `get.js` | Load a specific agent's full instructions | `bun bin/get.js code-reviewer` | ## Search First Pattern 1. **Extract intent keywords** from user request 2. **Run search**: `bun bin/search.js "<keywords>"` 3. **Review results**: Check relevance scores (0.0-1.0) 4. **Load if needed**: `bun bin/get.js <agent-name>` 5. **Execute**: Follow the loaded agent's instructions ## Example User: "Can you review my authentication code for security issues?" ```bash # Step 1: Search for relevant agents bun bin/search.js "code review security authentication" # Output: # Found 2 matching agents: # 1. security-auditor (score: 0.89) - Analyzes code for security vulnerabilities # 2. code-reviewer (score: 0.71) - General code review and best practices # Step 2: Load the best match bun bin/get.js security-auditor # Step 3: Follow loaded agent instructions for the task ``` ## Installation ### Step 1: Install the Skill **Quick Install (Recommended):** ```bash # Using Skills CLI (recommended) npx skills add MaTriXy/Agent-Registry@agent-registry # Discover skills interactively npx skills find # Update existing skills npx skills update ``` **Traditional Install:** ```bash # User-level installation ./install.sh # OR project-level installation ./install.sh --project # Optional: install enhanced interactive UI dependency ./install.sh --install-deps ``` **What install.sh does:** 1. Copies skill files to `~/.claude/skills/agent-registry/` 2. Creates empty registry structure 3. Optionally installs dependencies via `--install-deps` (`@clack/prompts` for enhanced UI) ### Step 2: Migrate Your Agents Run the interactive migration script: ```bash cd ~/.claude/skills/agent-registry bun bin/init.js # Optional destructive mode: bun bin/init.js --move ``` **Interactive selection modes:** - **With @clack/prompts** (default): Beautiful checkbox UI with category grouping, token indicators, and paging - Arrow keys navigate, Space toggle, Enter confirm - Visual indicators: [green] <1k tokens, [yellow] 1-3k, [red] >3k - Grouped by subdirectory - **Fallback**: Text-based number input - Enter comma-separated numbers (e.g., `1,3,5`) - Type `all` to migrate everything **What init.js does:** 1. Scans `~/.claude/agents/` and `.claude/agents/` for agent files 2. Displays available agents with metadata 3. Lets you interactively select which to migrate 4. Copies selected agents to the registry by default (`--move` is explicit opt-in) 5. Builds search index (`registry.json`) ## Dependencies - **Bun** (ships with Claude Code) — zero additional dependencies for core functionality - **@clack/prompts**: Optional enhanced interactive selection UI (install via `./install.sh --install-deps`) ## Registry Location - **Global**: `~/.claude/skills/agent-registry/` - **Project**: `.claude/skills/agent-registry/` (optional override) Agents not migrated remain in their original locations and load normally (contributing to token overhead).

path: CLAUDE.md
content: # CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Agent Registry is a **lazy-loading system for Claude Code agents** that reduces context window token usage by 70-90%. Instead of loading all agents upfront (~117 tokens/agent), it maintains a lightweight JSON index (~20-25 tokens/agent) and loads agents on-demand via search.

The project ships as a **Claude Code Skill** (defined in `SKILL.md`) and is installed to `~/.claude/skills/agent-registry/`.

## Architecture

### Core Data Flow

```
registry.json (index) → search.js (BM25 ranking) → get.js (lazy load) → agent .md file
```

### Automatic Discovery (Hook)

```
User prompt → UserPromptSubmit hook → user_prompt_search.js (in-process BM25) → additionalContext → Claude loads agents
```

The `hooks/user_prompt_search.js` hook runs on Bun (Claude Code's runtime) automatically on every user prompt. It reads `registry.json` directly and runs the BM25 search in-process. If high-confidence matches are found (score >= 0.5), they're injected as context. This makes agent discovery transparent.

### Key Components

- **`references/registry.json`** — Lightweight index storing agent metadata (name, summary, keywords, token_estimate, content_hash). This is the only file loaded into context at conversation start.
- **`agents/`** — Migrated agent markdown files, organized by subdirectory categories. Entirely git-ignored (user-specific data).
- **`lib/`** — Shared JavaScript modules (run on Bun):
  - `registry.js` — Path utilities and registry I/O (read/write registry.json, resolve skill paths).
  - `parse.js` — Agent file parsing (extract frontmatter, summary, keywords, token estimates).
  - `search.js` — BM25 + keyword matching search engine. Custom BM25 implementation (no external dependencies).
  - `telemetry.js` — Fire-and-forget anonymous telemetry using fetch. Disabled by default; opt-in via `AGENT_REGISTRY_TELEMETRY=1`. Also respects `AGENT_REGISTRY_NO_TELEMETRY=1` and `DO_NOT_TRACK=1`.
- **`bin/`** — CLI entry points (run via `bun bin/X.js`):
  - `init.js` — Interactive migration wizard with @clack/prompts UI (paginated, category-grouped). Scans `~/.claude/agents/` and `.claude/agents/`, builds the index.
  - `search.js` — Search agents by intent using BM25.
  - `search-paged.js` — Paginated variant for large registries (300+ agents).
  - `get.js` — Load full agent content by name (exact match, then partial match).
  - `list.js` — List all indexed agents with metadata table.
  - `rebuild.js` — Rebuild `registry.json` from agents in the `agents/` directory.
  - `cli.js` — Thin CLI dispatcher (routes subcommands to the above scripts).
- **`hooks/`** — Hook scripts that integrate with Claude Code's event system:
  - `user_prompt_search.js` — `UserPromptSubmit` hook (Bun). Automatically searches the registry on each user prompt using an in-process BM25 engine. Injects matching agents (score >= 0.5) as `additionalContext`. Fails silently on errors.
- **`SKILL.md`** — Skill definition with YAML frontmatter consumed by Claude Code's skill system. Includes hook registration in the `hooks` frontmatter key.
- **`install.sh`** — Bash installer that copies files to `~/.claude/skills/agent-registry/`, creates directory structure, and only installs optional dependencies when `--install-deps` is passed.

### Search Algorithm

`lib/search.js` implements BM25 (Best Matching 25) from scratch with no external search libraries. Keywords from the registry index are matched against query terms with relevance scoring (0.0-1.0).

### Telemetry

CLI scripts call `telemetry.track()` from `lib/telemetry.js`, but network events are sent only if `AGENT_REGISTRY_TELEMETRY=1` is set. Payload remains anonymous (event type, result count, timing, OS, runtime version) and excludes search queries, agent names, and file paths.

## Commands

### Run CLI tools (from repo root)

```bash
bun bin/search.js "query terms"
bun bin/get.js <agent-name>
bun bin/list.js
bun bin/rebuild.js
bun bin/init.js                    # Interactive migration (non-destructive copy)
bun bin/init.js --move             # Destructive move (explicit opt-in)
```

### Run tests

```bash
bun test                  # 101 tests across 5 files (unit + CLI integration)
```

### Install

```bash
./install.sh                 # User-level install to ~/.claude/skills/agent-registry/
./install.sh --project       # Project-level install to .claude/skills/agent-registry/
./install.sh --install-deps  # Optional: install @clack/prompts
```

### Disable telemetry during development

```bash
AGENT_REGISTRY_TELEMETRY=1 bun bin/search.js "query"
```

## Development Notes

- **Bun runtime required.** Bun ships with Claude Code — no additional runtime installation needed.
- **External dependency:** `@clack/prompts` (for interactive checkbox UI in `bin/init.js`). All search/indexing uses only built-in APIs.
- `lib/` modules use CommonJS (require/module.exports). CLI scripts in `bin/` import from `../lib/`.
- `registry.json` and `agents/*` are git-ignored (user-specific data populated during migration).
- The `remotion-video/` directory is git-ignored and unrelated to the core skill (used for promotional video).


path: README.md
content: # Agent Registry

[![Version](https://img.shields.io/badge/version-2.0.1-blue)](https://github.com/MaTriXy/Agent-Registry/releases/tag/v2.0.1) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)

> **Lazy-loading system for Claude Code agents that reduces context window usage by 70-90%**

As your agent collection grows, Claude Code loads **every single agent** into every conversation.

With dozens or hundreds of agents installed, this creates token overhead that wastes your context window on agents you'll never use in that session.

**Agent Registry solves this** with on-demand loading: index your agents once, then load only what you need.

## The Problem

Claude Code's default behavior loads **all agents upfront** into every conversation:

- **Token overhead:** ~117 tokens per agent x agent count = wasted context
- **Scales poorly:** 50 agents = 5.8k, 150 agents = 17.5k, 300+ agents = 35k+ tokens
- **Context waste:** Typically only 1-3 agents are relevant per conversation
- **All or nothing:** You pay the full cost even if you use zero agents
- **Slow startup:** Processing hundreds of agent files delays conversation start

### Real-World Impact: Before & After

Here's the actual difference from a real Claude Code session with 140 agents:

<table>
<tr>
<td width="50%">

### Before: All Agents Loaded

![Before Agent Registry](docs/images/context-usage-before.png)

**Context consumption:**
- Custom agents: **16.4k tokens (8.2%)**
- Total: 76k/200k (38%)
- **Problem:** 14k tokens wasted on unused agents

</td>
<td width="50%">

### After: Agent Registry

![After Agent Registry](docs/images/context-usage-after.png)

**Context consumption:**
- Custom agents: **2.7k tokens (1.4%)**
- Total: 42k/200k (21%)
- **Savings:** 13.7k tokens freed = **83% reduction**

</td>
</tr>
</table>

**Bottom line:** Agent Registry **freed up 34k tokens** in total context (38% -> 21%), giving you **56% more free workspace** (79k -> 113k available) for your actual code and conversations.

> **Testing methodology:** Both screenshots were captured from the same repository in separate Claude Code sessions. Each session was started fresh using the `/clear` command to ensure zero existing context, providing accurate baseline measurements of agent-related token overhead.

## The Solution

**Agent Registry** shifts from **eager loading** to **lazy loading**:

```
Before: Load ALL agents -> Context Window -> Use 1-2 agents
        (~16-35k tokens)    (limited)      (~200-300 tokens)

        Wastes 90%+ of agent tokens on unused agents

After:  Search registry -> Load specific agent -> Use what you need
        (~2-4k tokens)   (instant)          (~200-300 tokens)

        Saves 70-90% of agent-related tokens
```

### Automatic Discovery (Hook)

A `UserPromptSubmit` hook makes agent discovery **fully automatic**. Every user prompt is analyzed by an in-process BM25 search engine (JavaScript, runs on Bun -- Claude Code's runtime). If high-confidence matches are found (score >= 0.5), they're injected as context before Claude responds.

```
User: "review my authentication code for security issues"

-> Hook finds: security-auditor (0.89), code-reviewer (0.71)
-> Claude sees suggestions in additionalContext
-> Loads the best match and follows its instructions
```

No manual search step. Runs in ~100ms. Fails silently on any error -- never blocks the user.

**The math (140 agents example):**
- **Before:** 16.4k tokens (all agents loaded)
- **After:** 2.7k tokens (registry index loaded, agents on-demand)
- **Savings:** 13.7k tokens saved -> **83% reduction**

**Scaling examples:**
- 50 agents: Save ~3-4k tokens (5.8k -> 2.5k) = 60-70% reduction
- 150 agents: Save ~14k tokens (17.5k -> 3k) = 80% reduction
- 300 agents: Save ~30k tokens (35k -> 3.5k) = 85-90% reduction

## What This Skill Provides

### Smart Search (BM25 + Keyword Matching)
Find agents by intent, not by name:
```bash
bun bin/search.js "code review security"
# Returns: security-auditor (0.89), code-reviewer (0.71)

bun bin/search-paged.js "backend api" --page 1 --page-size 10
# Paginated results for large agent collections
```

**Supported:**
- Intent-based search using BM25 algorithm
- Keyword matching with fuzzy matching
- Relevance scoring (0.0-1.0)
- Pagination for 100+ agent results
- JSON output mode for scripting

### Interactive Migration UI
Beautiful selection interface with advanced features:
- **Multi-level Select All:** Global, per-category, per-page selection
- **Pagination:** Automatic 10-item pages for large collections (100+ agents)
- **Visual indicators:** Color-coded token estimates (<1k, 1-3k, >3k)
- **Category grouping:** Auto-organized by subdirectory structure
- **Keyboard navigation:** Arrow keys navigate, Space toggle, Enter confirm
- **Selection persistence:** Selections preserved across page navigation
- **Graceful fallback:** Text input mode if @clack/prompts unavailable

**Supported:**
- Interactive checkbox UI with @clack/prompts
- Page-based navigation
- Finish selection workflow
- Text-based fallback mode

### Lightweight Index
Registry stores only metadata -- not full agent content:
- Agent name and summary
- Keywords for search matching
- Token estimates for capacity planning
- File paths for lazy loading
- Content hashes for change detection

**Index size scales slowly:**
- 50 agents = ~2k tokens
- 150 agents = ~3-4k tokens
- 300 agents = ~6-8k tokens

**Much smaller than loading all agents:**
- Traditional: ~117 tokens/agent x count
- Registry: ~20-25 tokens/agent in index

## Installation

### Prerequisites
- Bun (ships with Claude Code -- no separate installation needed)
- Git (for traditional installation)

### Method 1: Skills CLI (Recommended)

Install via Skills CLI (one command):
```bash
npx skills add MaTriXy/Agent-Registry@agent-registry
```

Discover skills interactively:
```bash
npx skills find
```

Update existing skills:
```bash
npx skills update
```

**Then run migration:**
```bash
cd ~/.claude/skills/agent-registry
bun bin/init.js
```

### Method 2: npm Install

```bash
npm install @claude-code/agent-registry
```

### Method 3: Traditional Install

Clone and install:
```bash
# Clone to Claude skills directory
git clone https://github.com/MaTriXy/Agent-Registry.git ~/.claude/skills/agent-registry

# Run installer
cd ~/.claude/skills/agent-registry
./install.sh
```

**What the installer does:**
1. Verifies installation directory
2. Creates registry structure (`references/`, `agents/`, `lib/`, `bin/`)
3. Optionally installs dependencies with `./install.sh --install-deps`

### Post-Installation

**All methods require migration:**
```bash
bun bin/init.js
```

This interactive wizard:
1. Scans your `~/.claude/agents/` directory
2. Shows all available agents with token estimates
3. Lets you select which agents to copy (or move with `--move`)
4. Builds the searchable registry index

### Migrate Your Agents

```bash
# Run interactive migration
bun bin/init.js

# Optional: destructive migration (moves source files)
bun bin/init.js --move
```

**Interactive selection modes:**

**With @clack/prompts** (default):
```
? Select agents to add to registry (arrow keys navigate, Space toggle, Enter confirm)
  ---------- FRONTEND ----------
> [x] react-expert - React specialist for modern component... [1850 tokens]
  [ ] angular-expert - Angular framework expert with... [3200 tokens]
  [ ] vue-expert - Vue.js specialist for reactive UIs... [750 tokens]
  ---------- BACKEND ----------
  [ ] django-expert - Django web framework specialist... [2100 tokens]
  [ ] fastapi-expert - FastAPI for high-performance APIs... [980 tokens]
```

**Without @clack/prompts** (fallback):
```
Select agents to add to the registry:
  Enter numbers separated by commas (e.g., 1,3,5)
  Enter 'all' to add all agents
```

## Usage

### The Search-First Pattern

Instead of Claude loading all agents, use this pattern:

```bash
# 1. User asks: "Can you review my authentication code for security issues?"

# 2. Search for relevant agents
bun bin/search.js "code review security authentication"

# Output:
# Found 2 matching agents:
#   1. security-auditor (score: 0.89) - Analyzes code for security vulnerabilities
#   2. code-reviewer (score: 0.71) - General code review and best practices

# 3. Load the best match
bun bin/get.js security-auditor

# 4. Follow loaded agent's instructions
```

### Available Commands

| Command | Purpose | Example |
|---------|---------|---------|
| `search.js` | Find agents matching intent | `bun bin/search.js "react hooks"` |
| `search-paged.js` | Paged search for large registries | `bun bin/search-paged.js "query" --page 1` |
| `get.js` | Load specific agent | `bun bin/get.js react-expert` |
| `list.js` | Show all indexed agents | `bun bin/list.js` |
| `rebuild.js` | Rebuild index after changes | `bun bin/rebuild.js` |

> **Note:** The `UserPromptSubmit` hook (`hooks/user_prompt_search.js`) runs automatically on every prompt -- no manual search step is needed for typical usage. The commands above are available for direct use when needed.

## Architecture

### How It Works

```
Traditional Approach (Eager Loading)

  Load ALL agents -> Context Window -> Use 1-2 agents
  (~16-35k tokens)   (limited)        (~200-400 tokens)

  Wastes 85-90% of loaded agent tokens

Agent Registry Approach (Lazy Loading)

  registry.json -> Search -> Load specific agent
  (~2-4k tokens) (fast)   (~200-400 tokens)

  Saves 70-90% of agent-related tokens
```

### Registry Structure

```
~/.claude/skills/agent-registry/
├── SKILL.md                 # Skill definition + hook registration
├── install.sh               # Installer script
├── package.json             # Dependencies (@clack/prompts)
├── hooks/
│   └── user_prompt_search.js  # UserPromptSubmit hook (Bun)
├── references/
│   └── registry.json        # Lightweight agent index
├── agents/                  # Migrated agents stored here
│   ├── frontend/
│   │   ├── react-expert.md
│   │   └── vue-expert.md
│   └── backend/
│       ├── django-expert.md
│       └── fastapi-expert.md
├── lib/
│   ├── registry.js          # Path utilities + registry I/O
│   ├── parse.js             # Agent file parsing
│   ├── search.js            # BM25 search engine
│   └── telemetry.js         # Anonymous telemetry
└── bin/
    ├── cli.js               # CLI dispatcher
    ├── init.js              # Interactive migration
    ├── search.js            # Search by intent
    ├── search-paged.js      # Paginated search
    ├── get.js               # Load specific agent
    ├── list.js              # List all agents
    └── rebuild.js           # Rebuild index
```

### Registry Format

```json
{
  "version": 1,
  "agents": [
    {
      "name": "react-expert",
      "path": "frontend/react-expert.md",
      "summary": "React specialist focused on modern component architecture...",
      "keywords": ["react", "javascript", "frontend", "hooks"],
      "token_estimate": 1850,
      "content_hash": "a3f2b1c4"
    }
  ],
  "stats": {
    "total_agents": 150,
    "total_tokens": 17500,
    "tokens_saved_vs_preload": 14000
  }
}
```

**Index stays small:** Even with 300+ agents, the registry index typically stays under 8k tokens (vs 35k+ for loading all agents).

## Dependencies

- **Bun** -- Ships with Claude Code, no separate installation needed
- **@clack/prompts** -- Interactive selection UI (optional, install with `./install.sh --install-deps`)

Core functionality works without optional dependencies (text fallback UI is built in).

## Telemetry Disclosure

> **Notice:** Agent Registry can collect anonymous usage data to help improve the tool.
> This is **disabled by default** and only runs when explicitly enabled.

### What We Collect

We collect **anonymous, aggregate metrics only**:

| Data | Example | Purpose |
|------|---------|---------|
| Event type | `search`, `get`, `list` | Know which features are used |
| Result counts | `5 results` | Understand search effectiveness |
| Timing | `45ms` | Monitor performance |
| System info | `darwin`, `bun 1.x` | Ensure compatibility |
| Tool version | `2.0.1` | Track adoption |

### What We Do NOT Collect

- **No search queries** - We never see what you search for
- **No agent names** - We don't know which agents you use
- **No file paths** - We don't see your directory structure
- **No IP addresses** - We don't track your location
- **No personal information** - Completely anonymous

### Enable Telemetry (Optional)

```bash
# Explicit opt-in
export AGENT_REGISTRY_TELEMETRY=1
```

### Disable Telemetry

```bash
# Remove opt-in
unset AGENT_REGISTRY_TELEMETRY

# Universal standard (also respected)
export DO_NOT_TRACK=1
```

### Automatic Opt-Out

Telemetry is **automatically disabled** in CI environments:
- GitHub Actions, GitLab CI, CircleCI, Travis CI, Buildkite, Jenkins

### Transparency

The telemetry implementation is fully open source: [`lib/telemetry.js`](lib/telemetry.js)

## Configuration

The skill works at two levels:

- **User-level:** `~/.claude/skills/agent-registry/` (default)
- **Project-level:** `.claude/skills/agent-registry/` (optional override)

Agents not migrated remain in `~/.claude/agents/` and load normally.

## Benefits

### Token Efficiency
- **Before:** ~117 tokens/agent x count loaded upfront
- **After:** ~20-25 tokens/agent in index + full agent only when used
- **Savings:** 70-90% reduction in agent-related token overhead

**Real-world examples:**
- 50 agents: Save ~3-4k tokens (5.8k -> 2.5k) = 60-70% reduction
- 140 agents: Save ~13.7k tokens (16.4k -> 2.7k) = 83% reduction
- 300 agents: Save ~30k tokens (35k -> 5k) = 85-90% reduction

### Performance
- **Faster startup:** Less context to process at conversation start
- **Efficient loading:** Only pay token cost for agents actually used
- **Instant search:** BM25 + keyword matching in <100ms
- **Scalable:** Handles 300+ agents without performance degradation

### Organization
- **Category grouping:** Agents auto-organized by subdirectory
- **Visual indicators:** Color-coded token estimates
- **Easy discovery:** Search by intent, not memorized names
- **Pagination:** Browse large collections without terminal overflow

### Flexibility
- **Opt-in migration:** Choose exactly which agents to index
- **Graceful degradation:** Text fallback if @clack/prompts unavailable
- **Backward compatible:** Non-migrated agents load normally
- **No lock-in:** Agents can stay in original `~/.claude/agents/` if preferred

## Workflow Integration

### For Users

1. **Install once:** Run `./install.sh`
2. **Migrate agents:** Run `bun bin/init.js`
3. **Use normally:** Claude automatically searches registry on-demand

### For Claude

The skill provides a CRITICAL RULE:

> **NEVER assume agents are pre-loaded.** Always use this registry to discover and load agents.

Claude follows this pattern:
```
User Request -> search_agents(intent) -> select best match -> get_agent(name) -> execute
```

## Development

### Running Tests

```bash
bun test
```

101 tests across 5 files covering:
- **Unit tests:** lib/registry, lib/search, lib/parse, lib/telemetry
- **CLI integration tests:** all bin/ scripts tested via subprocess
- **Hook tests:** user_prompt_search.js stdin/stdout behavior

## Contributing

Found an issue or want to improve the registry? PRs welcome!

1. Fork the repo
2. Create a feature branch (`git checkout -b feature/improvement`)
3. Run tests (`bun test`)
4. Commit your changes (`git commit -m 'Add improvement'`)
5. Push to the branch (`git push origin feature/improvement`)
6. Open a Pull Request

## License

MIT

## Credits

Built for the Claude Code community to solve the "~16k tokens" agent loading problem.

**Author:** Yossi Elkrief ([@MaTriXy](https://github.com/MaTriXy))

---

**Questions?** Open an issue on [GitHub](https://github.com/MaTriXy/Agent-Registry/issues)


path: bin/build-release-artifact.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

VERSION="$(sed -nE 's/^[[:space:]]*"version":[[:space:]]*"([^"]+)".*/\1/p' package.json | head -n1)"
if [[ -z "${VERSION}" ]]; then
  echo "Failed to read version from package.json" >&2
  exit 1
fi

OUT_PATH="${1:-/tmp/agent-registry-v${VERSION}.zip}"

TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT

MANIFEST="$TMP_DIR/files.txt"
git -c core.fsmonitor=false ls-files | LC_ALL=C sort > "$MANIFEST"

zip -X -q "$OUT_PATH" -@ < "$MANIFEST"

SHA256="$(shasum -a 256 "$OUT_PATH" | awk '{print $1}')"
BYTES="$(wc -c < "$OUT_PATH" | tr -d '[:space:]')"

echo "Artifact: $OUT_PATH"
echo "Version:  $VERSION"
echo "SHA256:   $SHA256"
echo "Size:     $BYTES bytes"


path: bin/cli.js
content: #!/usr/bin/env bun
"use strict";

/**
 * Agent Registry CLI Dispatcher
 *
 * Routes subcommands to the appropriate bin/ script.
 * Usage: agent-registry <command> [args...]
 */

const path = require("path");

const commands = {
  search: "search.js",
  "search-paged": "search-paged.js",
  get: "get.js",
  list: "list.js",
  rebuild: "rebuild.js",
  init: "init.js",
};

const command = process.argv[2];

if (!command || command === "--help" || command === "-h" || !commands[command]) {
  console.log("Agent Registry — Lazy-loading system for Claude Code agents\n");
  console.log("Usage: agent-registry <command> [args...]\n");
  console.log("Commands:");
  console.log("  search        Search agents by intent");
  console.log("  search-paged  Search with pagination for large registries");
  console.log("  get           Load a specific agent's full instructions");
  console.log("  list          List all indexed agents");
  console.log("  rebuild       Rebuild the registry index");
  console.log("  init          Interactive agent migration wizard (copy by default)");
  console.log("\nExamples:");
  console.log('  agent-registry search "code review security"');
  console.log("  agent-registry get code-reviewer");
  console.log("  agent-registry list --detailed");
  console.log("  agent-registry init --move");

  if (command && command !== "--help" && command !== "-h" && !commands[command]) {
    console.error(`\nUnknown command: ${command}`);
    process.exit(1);
  }
  process.exit(0);
}

// Rewrite process.argv so the subcommand sees itself as argv[1]
process.argv = [process.argv[0], ...process.argv.slice(2)];

require(path.join(__dirname, commands[command]));


path: bin/get.js
content: #!/usr/bin/env bun
"use strict";

const fs = require("fs");
const { loadRegistry, resolveRegistryAgentPath } = require("../lib/registry");
const { track } = require("../lib/telemetry");

function findAgent(registry, name) {
  const agents = registry.agents || [];
  const nameLower = name.toLowerCase();

  // Exact match first
  for (const agent of agents) {
    if (agent.name.toLowerCase() === nameLower) return agent;
  }

  // Partial match
  const matches = agents.filter((a) => a.name.toLowerCase().includes(nameLower));
  if (matches.length === 1) return matches[0];
  if (matches.length > 1) {
    process.stderr.write(`Multiple agents match '${name}':\n`);
    for (const m of matches) process.stderr.write(`  - ${m.name}\n`);
    process.stderr.write("\nPlease specify the exact name.\n");
    return null;
  }

  return null;
}

function loadAgentContent(agent) {
  const resolved = resolveRegistryAgentPath(agent.path);
  if (!resolved.ok) {
    process.stderr.write(`Error: ${resolved.error}\n`);
    return null;
  }

  if (!fs.existsSync(resolved.path)) {
    process.stderr.write(`Error: Agent file not found: ${agent.path}\n`);
    return null;
  }

  try {
    return fs.readFileSync(resolved.path, "utf8");
  } catch (e) {
    process.stderr.write(`Error reading agent file: ${e.message}\n`);
    return null;
  }
}

function formatAgentOutput(agent, content, raw) {
  if (raw) return content;
  const sep = "=".repeat(70);
  const tokens = agent.token_estimate || 0;
  return `${sep}\nAGENT: ${agent.name}\nTokens: ~${tokens.toLocaleString()}\n${sep}\n\n${content}\n\n${sep}\nEND OF AGENT INSTRUCTIONS\n${sep}`;
}

function formatJsonOutput(agent, content) {
  return JSON.stringify(
    {
      name: agent.name,
      summary: agent.summary || "",
      token_estimate: agent.token_estimate || 0,
      keywords: agent.keywords || [],
      content,
    },
    null,
    2
  );
}

function listAvailableAgents(registry) {
  const agents = registry.agents || [];
  if (agents.length === 0) return "No agents in registry.";

  const lines = ["Available agents:"];
  const sorted = [...agents].sort((a, b) => a.name.localeCompare(b.name));
  for (const agent of sorted) {
    const summary = (agent.summary || "").slice(0, 50) + "...";
    lines.push(`  - ${agent.name}: ${summary}`);
  }
  lines.push("");
  lines.push("Usage: bun bin/get.js <agent-name>");
  return lines.join("\n");
}

function main() {
  const args = process.argv.slice(2);

  let jsonOutput = false;
  let rawOutput = false;
  const remaining = [];

  for (const arg of args) {
    if (arg === "--json") jsonOutput = true;
    else if (arg === "--raw") rawOutput = true;
    else if (arg === "--help" || arg === "-h") {
      console.log("Usage: bun bin/get.js <agent-name> [OPTIONS]\n");
      console.log("Loads the full content of a specific agent.\n");
      console.log("Arguments:");
      console.log("  agent-name    Name of the agent to load (case-insensitive)\n");
      console.log("Options:");
      console.log("  --json        Output as JSON with metadata");
      console.log("  --raw         Output raw content without headers");
      console.log("  --help, -h    Show this help\n");
      console.log("Examples:");
      console.log("  bun bin/get.js code-reviewer");
      console.log("  bun bin/get.js security --json");
      return;
    } else remaining.push(arg);
  }

  const registry = loadRegistry();
  if (!registry) process.exit(1);

  if (remaining.length === 0) {
    console.log(listAvailableAgents(registry));
    process.exit(0);
  }

  const agentName = remaining.join(" ");
  const agent = findAgent(registry, agentName);
  if (!agent) {
    process.stderr.write(`Error: Agent '${agentName}' not found.\n\n`);
    process.stderr.write(listAvailableAgents(registry) + "\n");
    process.exit(1);
  }

  const content = loadAgentContent(agent);
  if (!content) process.exit(1);

  track("get", {
    found: true,
    tokens: agent.token_estimate || 0,
    fmt: jsonOutput ? "json" : rawOutput ? "raw" : "formatted",
  });

  if (jsonOutput) {
    console.log(formatJsonOutput(agent, content));
  } else {
    console.log(formatAgentOutput(agent, content, rawOutput));
  }
}

main();


path: bin/init.js
content: #!/usr/bin/env bun
"use strict";

const fs = require("fs");
const path = require("path");
const os = require("os");
const readline = require("readline");
const { getSkillDir, getAgentsDir, getRegistryPath } = require("../lib/registry");
const { parseAgentFile } = require("../lib/parse");
const { track } = require("../lib/telemetry");

// ANSI colors
const C = {
  header: "\x1b[95m",
  blue: "\x1b[94m",
  cyan: "\x1b[96m",
  green: "\x1b[92m",
  yellow: "\x1b[93m",
  red: "\x1b[91m",
  bold: "\x1b[1m",
  dim: "\x1b[2m",
  reset: "\x1b[0m",
};

function colorize(text, color) {
  return process.stdout.isTTY ? `${color}${text}${C.reset}` : text;
}

function parseOptions(argv) {
  const options = {
    mode: "copy",
    help: false,
  };

  for (const arg of argv) {
    if (arg === "--move") options.mode = "move";
    else if (arg === "--copy") options.mode = "copy";
    else if (arg === "--help" || arg === "-h") options.help = true;
    else {
      process.stderr.write(`Unknown option: ${arg}\n`);
      options.help = true;
      options.invalid = true;
    }
  }

  return options;
}

function printHelp() {
  console.log("Usage: bun bin/init.js [OPTIONS]\n");
  console.log("Interactive agent migration wizard.\n");
  console.log("Options:");
  console.log("  --copy        Copy selected agents into registry (default)");
  console.log("  --move        Move selected agents into registry (destructive)");
  console.log("  --help, -h    Show this help");
}

function walkDir(dir) {
  const results = [];
  if (!fs.existsSync(dir)) return results;
  const entries = fs.readdirSync(dir, { withFileTypes: true });
  for (const entry of entries) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) results.push(...walkDir(full));
    else if (entry.isFile() && entry.name.endsWith(".md")) results.push(full);
  }
  return results;
}

function findAgentLocations() {
  const locations = [];
  const home = os.homedir();
  const globalAgents = path.join(home, ".claude", "agents");
  if (fs.existsSync(globalAgents)) locations.push(globalAgents);

  const projectAgents = path.join(process.cwd(), ".claude", "agents");
  if (fs.existsSync(projectAgents) && projectAgents !== globalAgents) {
    locations.push(projectAgents);
  }
  return locations;
}

function scanAgents(directory) {
  const agents = [];
  for (const filepath of walkDir(directory)) {
    const info = parseAgentFile(filepath);
    if (info) {
      info.source_dir = directory;
      agents.push(info);
    }
  }
  return agents;
}

function scanExistingRegistry() {
  const agentsDir = getAgentsDir();
  if (!fs.existsSync(agentsDir)) return [];
  const agents = [];
  for (const filepath of walkDir(agentsDir)) {
    const info = parseAgentFile(filepath);
    if (info) {
      info.source_dir = agentsDir;
      agents.push(info);
    }
  }
  return agents;
}

function displayAgentList(agents, title) {
  console.log(colorize(`\n${title}`, C.header + C.bold));
  console.log("=".repeat(60));
  if (agents.length === 0) {
    console.log(colorize("  No agents found", C.dim));
    return;
  }
  for (let i = 0; i < agents.length; i++) {
    const agent = agents[i];
    const tokens = agent.token_estimate;
    const tokenColor = tokens < 1000 ? C.green : tokens < 3000 ? C.yellow : C.red;
    console.log(`  [${i + 1}] ${colorize(agent.name, C.cyan)}`);
    const summary = agent.summary.length > 60 ? agent.summary.slice(0, 60) + "..." : agent.summary;
    console.log(`      ${colorize(summary, C.dim)}`);
    console.log(`      Tokens: ${colorize(String(tokens), tokenColor)} | Source: ${path.basename(agent.source_dir)}`);
    console.log();
  }
}

function getCategory(agent) {
  try {
    const agentPath = agent.path;
    const sourceDir = agent.source_dir || "";
    const relPath = path.relative(sourceDir, agentPath);
    const parts = relPath.split(path.sep);
    if (parts.length > 1) return parts[0].toUpperCase().replace(/-/g, " ");
  } catch {}
  return "ROOT";
}

// Try @clack/prompts for interactive TUI
async function interactiveSelectionClack(agents) {
  const clack = require("@clack/prompts");

  // Build grouped options
  const categories = {};
  for (const agent of agents) {
    const cat = getCategory(agent);
    if (!categories[cat]) categories[cat] = [];
    categories[cat].push(agent);
  }

  const groups = {};
  for (const cat of Object.keys(categories).sort()) {
    const items = categories[cat].sort((a, b) => a.name.localeCompare(b.name));
    groups[cat] = items.map((agent) => {
      const tokens = agent.token_estimate;
      const indicator = tokens < 1000 ? "\uD83D\uDFE2" : tokens < 3000 ? "\uD83D\uDFE1" : "\uD83D\uDD34";
      const summary = agent.summary.length > 50 ? agent.summary.slice(0, 50) + "..." : agent.summary;
      return {
        value: agent.name,
        label: `${agent.name} ${indicator} ${tokens}`,
        hint: summary,
      };
    });
  }

  const selected = await clack.groupMultiselect({
    message: "Select agents to add to registry",
    options: groups,
    required: false,
  });

  if (clack.isCancel(selected)) {
    console.log(colorize("\nMigration cancelled.", C.yellow));
    return [];
  }

  // Map selected names back to agent objects
  const selectedNames = new Set(selected);
  return agents.filter((a) => selectedNames.has(a.name));
}

// Fallback: readline-based selection
async function interactiveSelectionFallback(agents) {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));

  console.log(colorize("\nSelect agents to add to the registry:", C.bold));
  console.log(colorize("  Enter numbers separated by commas (e.g., 1,3,5)", C.dim));
  console.log(colorize("  Enter 'all' to add all agents", C.dim));
  console.log(colorize("  Enter 'none' or press Enter to skip migration", C.dim));
  console.log();

  while (true) {
    const selection = (await ask(colorize("Your selection: ", C.cyan))).trim().toLowerCase();

    if (selection === "" || selection === "none") {
      rl.close();
      return [];
    }
    if (selection === "all") {
      rl.close();
      return agents;
    }

    try {
      const indices = selection.split(",").map((s) => parseInt(s.trim(), 10));
      const selected = [];
      let invalid = false;
      for (const idx of indices) {
        if (idx >= 1 && idx <= agents.length) {
          selected.push(agents[idx - 1]);
        } else {
          console.log(colorize(`  Invalid index: ${idx}. Valid range: 1-${agents.length}`, C.red));
          invalid = true;
        }
      }
      if (!invalid && selected.length > 0) {
        rl.close();
        return selected;
      }
    } catch {
      console.log(colorize("  Invalid input. Please enter numbers separated by commas.", C.red));
    }
  }
}

async function interactiveSelection(agents) {
  if (agents.length === 0) return [];

  try {
    const clack = require("@clack/prompts");
    if (typeof clack.groupMultiselect !== "function") throw new Error("groupMultiselect not available");
    return await interactiveSelectionClack(agents);
  } catch {
    console.log(colorize("\nNote: Install @clack/prompts for a better interactive UI", C.yellow));
    return await interactiveSelectionFallback(agents);
  }
}

function migrateAgents(agents, targetDir, mode) {
  const migrated = [];
  const errors = [];
  if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });

  for (const agent of agents) {
    const source = agent.path;
    const target = path.join(targetDir, agent.filename);

    try {
      if (fs.existsSync(target)) {
        console.log(colorize(`  Skipping ${agent.name}: already exists in registry`, C.yellow));
        agent.path = target;
        migrated.push(agent);
        continue;
      }

      if (mode === "move") {
        fs.renameSync(source, target);
      } else {
        fs.copyFileSync(source, target);
      }

      agent.path = target;
      migrated.push(agent);
      const action = mode === "move" ? "Moved" : "Copied";
      console.log(colorize(`  \u2713 ${action}: ${agent.name}`, C.green));
    } catch (e) {
      if (mode === "move") {
        // renameSync fails across devices; fall back to copy+delete
        try {
          fs.copyFileSync(source, target);
          fs.unlinkSync(source);
          agent.path = target;
          migrated.push(agent);
          console.log(colorize(`  \u2713 Moved: ${agent.name}`, C.green));
          continue;
        } catch (e2) {
          errors.push(`${agent.name}: ${e2.message}`);
          console.log(colorize(`  \u2717 Failed: ${agent.name} - ${e2.message}`, C.red));
          continue;
        }
      }

      errors.push(`${agent.name}: ${e.message}`);
      console.log(colorize(`  \u2717 Failed: ${agent.name} - ${e.message}`, C.red));
    }
  }

  return { migrated, errors };
}

function buildRegistry(agents, registryPath) {
  const agentsDir = getAgentsDir();
  const dir = path.dirname(registryPath);
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });

  const totalTokens = agents.reduce((sum, a) => sum + a.token_estimate, 0);

  const registry = {
    version: 1,
    generated_at: new Date().toISOString(),
    skill_dir: getSkillDir(),
    agents: [],
    stats: {
      total_agents: agents.length,
      total_tokens: totalTokens,
      tokens_saved_vs_preload: totalTokens,
    },
  };

  for (const agent of agents) {
    let relPath;
    try {
      relPath = path.relative(agentsDir, agent.path);
      if (relPath === "" || relPath === "." || relPath === ".." || relPath.startsWith(".." + path.sep)) {
        relPath = agent.filename;
      }
    } catch {
      relPath = agent.filename;
    }

    registry.agents.push({
      name: agent.name,
      path: relPath,
      summary: agent.summary,
      keywords: agent.keywords,
      token_estimate: agent.token_estimate,
      content_hash: agent.content_hash,
    });
  }

  fs.writeFileSync(registryPath, JSON.stringify(registry));

  console.log(colorize(`\n\u2713 Registry built: ${registryPath}`, C.green));
  console.log(`  Agents indexed: ${agents.length}`);
  console.log(`  Total tokens available: ${totalTokens.toLocaleString()}`);
  console.log(colorize(`  Tokens saved by lazy loading: ~${totalTokens.toLocaleString()}`, C.green + C.bold));
}

async function main() {
  const options = parseOptions(process.argv.slice(2));
  if (options.help) {
    printHelp();
    process.exit(options.invalid ? 1 : 0);
  }

  console.log(colorize("\n\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557", C.header));
  console.log(colorize("\u2551          Agent Registry Initialization                   \u2551", C.header));
  console.log(colorize("\u2551  Reduce token overhead by lazy-loading agents            \u2551", C.header));
  console.log(colorize("\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d", C.header));
  console.log(colorize(`Mode: ${options.mode === "move" ? "move (destructive)" : "copy (non-destructive)"}`, C.cyan));

  // Find agent locations
  const locations = findAgentLocations();

  if (locations.length === 0) {
    console.log(colorize("\nNo agent directories found at:", C.yellow));
    console.log("  - ~/.claude/agents/");
    console.log("  - .claude/agents/");

    const existing = scanExistingRegistry();
    if (existing.length > 0) {
      console.log(colorize(`\nFound ${existing.length} agents already in registry.`, C.cyan));
      console.log("Rebuilding registry index...");
      buildRegistry(existing, getRegistryPath());
      return;
    }

    console.log("\nCreate agent files in these directories, then run this script again.");
    return;
  }

  // Scan all locations
  const allAgents = [];
  for (const loc of locations) {
    console.log(colorize(`\nScanning: ${loc}`, C.blue));
    const agents = scanAgents(loc);
    allAgents.push(...agents);
    console.log(`  Found ${agents.length} agent(s)`);
  }

  // Scan existing registry agents
  const existingRegistryAgents = scanExistingRegistry();
  const existingNames = new Set(existingRegistryAgents.map((a) => a.name));

  // Filter out agents already in registry
  const newAgents = allAgents.filter((a) => !existingNames.has(a.name));

  if (existingRegistryAgents.length > 0) {
    console.log(colorize(`\nAlready in registry: ${existingRegistryAgents.length} agent(s)`, C.cyan));
  }

  if (newAgents.length === 0 && existingRegistryAgents.length === 0) {
    console.log(colorize("\nNo agents found to migrate.", C.yellow));
    return;
  }

  let allRegistryAgents;
  let selectedCount = 0;

  if (newAgents.length > 0) {
    displayAgentList(newAgents, "Available Agents to Add");

    const selected = await interactiveSelection(newAgents);
    selectedCount = selected.length;

    if (selected.length > 0) {
      const action = options.mode === "move" ? "Moving" : "Copying";
      console.log(colorize(`\n${action} ${selected.length} agent(s)...`, C.blue));
      const { migrated, errors } = migrateAgents(selected, getAgentsDir(), options.mode);

      if (errors.length > 0) {
        console.log(colorize(`\nEncountered ${errors.length} error(s):`, C.red));
        for (const err of errors) console.log(`  - ${err}`);
      }

      allRegistryAgents = [...existingRegistryAgents, ...migrated];
    } else {
      console.log(colorize("\nNo agents selected for migration.", C.yellow));
      allRegistryAgents = existingRegistryAgents;
    }
  } else {
    console.log(colorize("\nNo new agents to migrate.", C.cyan));
    allRegistryAgents = existingRegistryAgents;
  }

  if (allRegistryAgents.length > 0) {
    console.log(colorize("\nBuilding registry index...", C.blue));
    buildRegistry(allRegistryAgents, getRegistryPath());

    const totalTokens = allRegistryAgents.reduce((sum, a) => sum + (a.token_estimate || 0), 0);
    track("init", {
      selected: selectedCount,
      available: newAgents.length,
      total: allRegistryAgents.length,
      tokens_saved: totalTokens,
    });

    console.log(colorize("\n\u2713 Setup complete!", C.green + C.bold));
    console.log("\nNext steps:");
    console.log("  1. The skill is ready to use");
    console.log("  2. Claude will now search agents on-demand instead of loading all upfront");
    console.log("  3. Run 'bun bin/list.js' to see indexed agents");
    console.log("  4. Run 'bun bin/search.js <query>' to search");
  } else {
    console.log(colorize("\nNo agents in registry. Run this script again after adding agents.", C.yellow));
  }
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});


path: bin/list.js
content: #!/usr/bin/env bun
"use strict";

const { loadRegistry } = require("../lib/registry");
const { track } = require("../lib/telemetry");

function formatTable(agents) {
  if (agents.length === 0) {
    return "No agents registered.\n\nRun 'bun bin/init.js' to set up the registry.";
  }

  const lines = [];
  lines.push(
    `${"Agent Name".padEnd(25)} ${"Tokens".padEnd(10)} Summary`
  );
  lines.push("-".repeat(80));

  let totalTokens = 0;
  const sorted = [...agents].sort((a, b) => a.name.localeCompare(b.name));
  for (const agent of sorted) {
    const name = agent.name.slice(0, 24);
    const tokens = agent.token_estimate || 0;
    totalTokens += tokens;
    let summary = (agent.summary || "").slice(0, 42);
    if ((agent.summary || "").length > 42) summary += "...";
    lines.push(
      `${name.padEnd(25)} ${tokens.toLocaleString().padEnd(10)} ${summary}`
    );
  }

  lines.push("-".repeat(80));
  lines.push(
    `Total: ${agents.length} agents, ${totalTokens.toLocaleString()} tokens (saved from preloading)`
  );

  return lines.join("\n");
}

function formatDetailed(agents) {
  if (agents.length === 0) return "No agents registered.";

  const lines = [];
  let totalTokens = 0;
  const sorted = [...agents].sort((a, b) => a.name.localeCompare(b.name));

  for (const agent of sorted) {
    const tokens = agent.token_estimate || 0;
    totalTokens += tokens;
    const dashLen = Math.max(2, 50 - agent.name.length);
    lines.push(`\u256d\u2500 ${agent.name} ${"\u2500".repeat(dashLen)}`);
    lines.push(`\u2502 Summary: ${agent.summary || "No description"}`);
    lines.push(`\u2502 Tokens:  ~${tokens.toLocaleString()}`);
    lines.push(
      `\u2502 Keywords: ${(agent.keywords || []).slice(0, 8).join(", ")}`
    );
    lines.push(`\u2502 Path:    ${agent.path || "unknown"}`);
    lines.push("\u2570" + "\u2500".repeat(55));
    lines.push("");
  }

  lines.push(
    `Total: ${agents.length} agents | ${totalTokens.toLocaleString()} tokens saved by lazy loading`
  );
  return lines.join("\n");
}

function formatJson(registry) {
  return JSON.stringify(
    {
      agents: registry.agents || [],
      stats: registry.stats || {},
      generated_at: registry.generated_at || "",
    },
    null,
    2
  );
}

function formatSimple(agents) {
  return [...agents]
    .sort((a, b) => a.name.localeCompare(b.name))
    .map((a) => a.name)
    .join("\n");
}

function main() {
  const args = process.argv.slice(2);

  let outputFormat = "table";
  for (const arg of args) {
    if (arg === "--json") outputFormat = "json";
    else if (arg === "--detailed" || arg === "-d") outputFormat = "detailed";
    else if (arg === "--simple" || arg === "-s") outputFormat = "simple";
    else if (arg === "--help" || arg === "-h") {
      console.log("Usage: bun bin/list.js [OPTIONS]\n");
      console.log("Options:");
      console.log("  --detailed, -d  Show full details for each agent");
      console.log("  --simple, -s    Show just agent names");
      console.log("  --json          Output as JSON");
      console.log("  --help, -h      Show this help");
      return;
    }
  }

  const registry = loadRegistry();
  if (!registry) process.exit(1);

  const agents = registry.agents || [];

  track("list", { count: agents.length, fmt: outputFormat });

  if (outputFormat === "json") console.log(formatJson(registry));
  else if (outputFormat === "detailed") console.log(formatDetailed(agents));
  else if (outputFormat === "simple") console.log(formatSimple(agents));
  else console.log(formatTable(agents));
}

main();


path: bin/rebuild.js
content: #!/usr/bin/env bun
"use strict";

const fs = require("fs");
const path = require("path");
const { getSkillDir, getAgentsDir, getRegistryPath } = require("../lib/registry");
const { parseAgentFile } = require("../lib/parse");
const { track } = require("../lib/telemetry");

function walkDir(dir) {
  const results = [];
  if (!fs.existsSync(dir)) return results;
  const entries = fs.readdirSync(dir, { withFileTypes: true });
  for (const entry of entries) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      results.push(...walkDir(full));
    } else if (entry.isFile() && entry.name.endsWith(".md")) {
      results.push(full);
    }
  }
  return results;
}

function scanAgents() {
  const agentsDir = getAgentsDir();
  if (!fs.existsSync(agentsDir)) {
    process.stderr.write(`Agents directory not found: ${agentsDir}\n`);
    return [];
  }

  const agents = [];
  for (const filepath of walkDir(agentsDir)) {
    const info = parseAgentFile(filepath);
    if (info) agents.push(info);
  }
  return agents;
}

function buildRegistry(agents) {
  const skillDir = getSkillDir();
  const agentsDir = getAgentsDir();

  const registry = {
    version: 1,
    generated_at: new Date().toISOString(),
    skill_dir: skillDir,
    agents: [],
    stats: {
      total_agents: agents.length,
      total_tokens: agents.reduce((sum, a) => sum + a.token_estimate, 0),
      tokens_saved_vs_preload: agents.reduce((sum, a) => sum + a.token_estimate, 0),
    },
  };

  for (const agent of agents) {
    let relPath;
    try {
      relPath = path.relative(agentsDir, agent.path);
      if (relPath === "" || relPath === "." || relPath === ".." || relPath.startsWith(".." + path.sep)) {
        relPath = agent.filename;
      }
    } catch {
      relPath = agent.filename;
    }

    registry.agents.push({
      name: agent.name,
      path: relPath,
      summary: agent.summary,
      keywords: agent.keywords,
      token_estimate: agent.token_estimate,
      content_hash: agent.content_hash,
    });
  }

  return registry;
}

function main() {
  console.log("Scanning agents directory...");

  const agents = scanAgents();
  if (agents.length === 0) {
    console.log("No agents found in agents/ directory.");
    console.log(`Add .md files to: ${getAgentsDir()}`);
    process.exit(1);
  }

  console.log(`Found ${agents.length} agent(s)`);

  const registry = buildRegistry(agents);

  const registryPath = getRegistryPath();
  const registryDir = path.dirname(registryPath);
  if (!fs.existsSync(registryDir)) fs.mkdirSync(registryDir, { recursive: true });

  fs.writeFileSync(registryPath, JSON.stringify(registry));

  track("rebuild", {
    scanned: agents.length,
    tokens: registry.stats.total_tokens,
  });

  console.log(`\n\u2713 Registry rebuilt: ${registryPath}`);
  console.log(`  Agents indexed: ${agents.length}`);
  console.log(`  Total tokens: ${registry.stats.total_tokens.toLocaleString()}`);

  const sorted = [...agents].sort((a, b) => a.name.localeCompare(b.name));
  for (const agent of sorted) {
    console.log(`    - ${agent.name} (${agent.token_estimate.toLocaleString()} tokens)`);
  }
}

main();


path: bin/search-paged.js
content: #!/usr/bin/env bun
"use strict";

const { loadRegistry } = require("../lib/registry");
const { searchAgentsAll } = require("../lib/search");
const { track } = require("../lib/telemetry");

function paginateResults(results, opts) {
  const totalResults = results.length;
  const { page = 1, pageSize = 20, offset, limit } = opts;

  // Offset-based pagination takes precedence
  if (offset != null) {
    const startIdx = Math.max(0, offset);
    const endIdx = Math.min(totalResults, startIdx + (limit || pageSize));
    return {
      query: null,
      total_results: totalResults,
      offset: startIdx,
      limit: endIdx - startIdx,
      results: results.slice(startIdx, endIdx),
    };
  }

  // Page-based pagination
  const p = Math.max(1, page);
  const totalPages = pageSize > 0 ? Math.ceil(totalResults / pageSize) : 1;
  const startIdx = (p - 1) * pageSize;
  const endIdx = Math.min(totalResults, startIdx + pageSize);

  return {
    query: null,
    total_results: totalResults,
    page: p,
    page_size: pageSize,
    total_pages: totalPages,
    has_next: p < totalPages,
    has_prev: p > 1,
    results: results.slice(startIdx, endIdx),
  };
}

function formatResults(paginated, jsonOutput) {
  if (jsonOutput) return JSON.stringify(paginated, null, 2);

  const query = paginated.query || "N/A";
  const total = paginated.total_results;
  const results = paginated.results;

  if (total === 0) {
    return `No agents found matching: '${query}'\n\nTry broader search terms or run 'bun bin/list.js' to see all agents.`;
  }

  const lines = [];

  if (paginated.page != null) {
    lines.push(`Found ${total} matching agent(s) for: '${query}'`);
    lines.push(
      `Page ${paginated.page}/${paginated.total_pages} (${results.length} results on this page)`
    );
  } else {
    lines.push(`Found ${total} matching agent(s) for: '${query}'`);
    lines.push(
      `Showing results ${paginated.offset + 1}-${paginated.offset + results.length}`
    );
  }
  lines.push("");

  for (let i = 0; i < results.length; i++) {
    const r = results[i];
    const globalIdx =
      paginated.page != null
        ? (paginated.page - 1) * paginated.page_size + i + 1
        : paginated.offset + i + 1;

    const filled = Math.floor(r.score * 10);
    const scoreBar = "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
    lines.push(
      `${globalIdx}. ${r.name} (score: ${r.score.toFixed(2)}) [${scoreBar}]`
    );
    const summary =
      r.summary.length > 80 ? r.summary.slice(0, 80) + "..." : r.summary;
    lines.push(`   ${summary}`);
    lines.push(
      `   Tokens: ~${r.token_estimate.toLocaleString()} | Keywords: ${r.keywords.slice(0, 5).join(", ")}`
    );
    lines.push("");
  }

  if (paginated.page != null) {
    if (paginated.has_next)
      lines.push(`\u2192 Next page: --page ${paginated.page + 1}`);
    if (paginated.has_prev)
      lines.push(`\u2190 Previous page: --page ${paginated.page - 1}`);
  }

  lines.push("");
  lines.push("To load an agent: bun bin/get.js <agent-name>");
  return lines.join("\n");
}

function main() {
  const rawArgs = process.argv.slice(2);

  if (rawArgs.length === 0) {
    console.log(
      "Usage: bun bin/search-paged.js <query> [--page N] [--page-size N] [--offset N] [--limit N] [--json]\n"
    );
    console.log("Examples:");
    console.log(
      '  bun bin/search-paged.js "security" --page 1 --page-size 10'
    );
    console.log('  bun bin/search-paged.js "react" --offset 20 --limit 10');
    console.log('  bun bin/search-paged.js "backend" --json');
    process.exit(1);
  }

  let jsonOutput = false;
  let page = 1;
  let pageSize = 20;
  let offset = undefined;
  let limit = undefined;
  const queryParts = [];

  for (let i = 0; i < rawArgs.length; i++) {
    if (rawArgs[i] === "--json") {
      jsonOutput = true;
    } else if (rawArgs[i] === "--page" && i + 1 < rawArgs.length) {
      page = parseInt(rawArgs[++i], 10) || 1;
    } else if (rawArgs[i] === "--page-size" && i + 1 < rawArgs.length) {
      pageSize = parseInt(rawArgs[++i], 10) || 20;
    } else if (rawArgs[i] === "--offset" && i + 1 < rawArgs.length) {
      offset = parseInt(rawArgs[++i], 10) || 0;
    } else if (rawArgs[i] === "--limit" && i + 1 < rawArgs.length) {
      limit = parseInt(rawArgs[++i], 10) || undefined;
    } else {
      queryParts.push(rawArgs[i]);
    }
  }

  const query = queryParts.join(" ");

  const registry = loadRegistry();
  if (!registry) process.exit(1);

  const start = Date.now();
  const allResults = searchAgentsAll(query, registry);
  const elapsedMs = Date.now() - start;

  const paginated = paginateResults(allResults, { page, pageSize, offset, limit });
  paginated.query = query;

  track("search_paged", {
    n: allResults.length,
    page,
    size: pageSize,
    ms: elapsedMs,
  });

  console.log(formatResults(paginated, jsonOutput));
}

main();


path: bin/search.js
content: #!/usr/bin/env bun
"use strict";

const { loadRegistry } = require("../lib/registry");
const { searchAgents } = require("../lib/search");
const { track } = require("../lib/telemetry");

function formatResults(results, query) {
  if (results.length === 0) {
    return `No agents found matching: '${query}'\n\nTry broader search terms or run 'bun bin/list.js' to see all agents.`;
  }

  const lines = [`Found ${results.length} matching agent(s) for: '${query}'\n`];

  for (let i = 0; i < results.length; i++) {
    const r = results[i];
    const filled = Math.floor(r.score * 10);
    const scoreBar = "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
    lines.push(
      `${i + 1}. ${r.name} (score: ${r.score.toFixed(2)}) [${scoreBar}]`
    );
    const summary = r.summary.length > 80 ? r.summary.slice(0, 80) + "..." : r.summary;
    lines.push(`   ${summary}`);
    lines.push(
      `   Tokens: ~${r.token_estimate.toLocaleString()} | Keywords: ${r.keywords.slice(0, 5).join(", ")}`
    );
    lines.push("");
  }

  lines.push("To load an agent: bun bin/get.js <agent-name>");
  return lines.join("\n");
}

function main() {
  const rawArgs = process.argv.slice(2);

  if (rawArgs.length === 0) {
    console.log("Usage: bun bin/search.js <query> [--json] [--top N]\n");
    console.log("Examples:");
    console.log('  bun bin/search.js "code review security"');
    console.log('  bun bin/search.js "docker kubernetes" --top 3');
    console.log('  bun bin/search.js "python testing" --json');
    process.exit(1);
  }

  let jsonOutput = false;
  let topK = 5;
  const queryParts = [];

  for (let i = 0; i < rawArgs.length; i++) {
    if (rawArgs[i] === "--json") {
      jsonOutput = true;
    } else if (rawArgs[i] === "--top" && i + 1 < rawArgs.length) {
      const n = parseInt(rawArgs[i + 1], 10);
      if (!isNaN(n)) topK = n;
      i++;
    } else {
      queryParts.push(rawArgs[i]);
    }
  }

  const query = queryParts.join(" ");

  const registry = loadRegistry();
  if (!registry) process.exit(1);

  const start = Date.now();
  const results = searchAgents(query, registry, topK);
  const elapsedMs = Date.now() - start;

  const topScore = results.length > 0 ? results[0].score : 0;
  track("search", {
    n: results.length,
    score: Math.round(topScore * 100) / 100,
    ms: elapsedMs,
    fmt: jsonOutput ? "json" : "table",
  });

  if (jsonOutput) {
    console.log(JSON.stringify(results, null, 2));
  } else {
    console.log(formatResults(results, query));
  }
}

main();


path: docs/releases/v2.0.1.md
content: # Agent Registry v2.0.1 (Security Hardening)

Release date: 2026-02-15

## Why this release

The previously shared VirusTotal report ([REDACTED_SECRET]) corresponds to an older `skill.zip` snapshot and referenced outdated behavior (Python-based migration/install flow).  
Version `2.0.1` ships hardened JavaScript/Bun implementation changes and updated docs/tests.

## Security and trust updates

1. Path confinement for agent loading:
   - `bin/get.js` now resolves agent paths through a strict validator.
   - Registry entries cannot escape the skill `agents/` directory.
2. Non-destructive migration default:
   - `bin/init.js` now copies by default.
   - `--move` is explicit opt-in for destructive migration.
3. Installer network behavior tightened:
   - `install.sh` no longer installs dependencies by default.
   - Optional dependency install requires `--install-deps`.
4. Telemetry changed to opt-in:
   - No telemetry requests are sent unless `AGENT_REGISTRY_TELEMETRY=1`.

## Version updates

- `package.json`: `2.0.1`
- `package-lock.json`: `2.0.1`
- `SKILL.md`: `2.0.1`
- `lib/telemetry.js`: `2.0.1`

## Validation

- Test suite result: `107 passed, 0 failed`
- Command: `bun test`

## Release artifact and hash

Build artifact from tracked files:

```bash
./bin/build-release-artifact.sh
```

Example output:

- Artifact: `/tmp/agent-registry-v2.0.1.zip`
- SHA256: `aa77d4384c87f8604c6b2af4a0fc42d70bab07c0a3a22f35ace7c30645381a73`

## Publishing checklist

1. Commit and push `v2.0.1` changes.
2. Tag release: `git tag v2.0.1 && git push origin v2.0.1`
3. Build artifact: `./bin/build-release-artifact.sh`
4. Upload `/tmp/agent-registry-v2.0.1.zip` to VirusTotal and ClawHub.
5. Add the new SHA-256 and report URLs to the GitHub release notes.


path: hooks/user_prompt_search.js
content: #!/usr/bin/env bun
"use strict";

/**
 * UserPromptSubmit Hook for Agent Registry
 *
 * Automatically searches the agent registry when the user submits a prompt.
 * If high-confidence agent matches are found, injects them as additionalContext
 * so Claude can load relevant agents without manual search.
 *
 * Receives JSON on stdin: {"prompt": "...", "session_id": "...", ...}
 * Outputs JSON on stdout: {"additionalContext": "..."} or nothing
 *
 * Runs on Bun (Claude Code's runtime) — no Python dependency. Imports the
 * shared BM25 search engine from lib/search.js.
 */

const fs = require("fs");
const path = require("path");
const { searchAgents } = require("../lib/search");

// Configuration
const MIN_WORDS = 3;
const SCORE_THRESHOLD = 0.5;
const TOP_K = 3;

// ============================================================================
// Hook Entry Point
// ============================================================================

function main() {
  let raw;
  try {
    raw = fs.readFileSync(0, "utf8"); // fd 0 = stdin
  } catch {
    process.exit(0);
  }

  let data;
  try {
    data = JSON.parse(raw);
  } catch {
    process.exit(0);
  }

  const prompt = (data.prompt || "").trim();

  // Skip slash commands and short prompts
  if (!prompt || prompt.startsWith("/") || prompt.split(/\s+/).length < MIN_WORDS) {
    process.exit(0);
  }

  // Load registry directly — silent fail for hooks
  const registryPath = path.join(path.resolve(__dirname, ".."), "references", "registry.json");

  let registry;
  try {
    registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
  } catch {
    process.exit(0);
  }

  // Search and filter to high-confidence matches
  const results = searchAgents(prompt, registry, TOP_K).filter(
    (r) => r.score >= SCORE_THRESHOLD
  );

  if (results.length === 0) {
    process.exit(0);
  }

  // Format and output additionalContext
  const lines = ["Agent Registry found relevant agents for this task:", ""];
  for (const r of results) {
    lines.push(`  - ${r.name} (score: ${r.score.toFixed(2)}): ${r.summary}`);
  }
  lines.push("");
  lines.push("To load an agent: bun bin/get.js <agent-name>");

  console.log(JSON.stringify({ additionalContext: lines.join("\n") }));
}

main();


path: install.sh
content: #!/bin/bash
# Agent Registry Skill Installer
# Installs the agent-registry skill to your Claude Code skills directory

set -e

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color

echo -e "${CYAN}"
echo "╔══════════════════════════════════════════════════════════╗"
echo "║       Agent Registry Skill Installer                     ║"
echo "║  Reduce agent token overhead by ~95%                     ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo -e "${NC}"

usage() {
    echo "Usage: ./install.sh [--project|-p] [--install-deps] [--help|-h]"
    echo ""
    echo "Options:"
    echo "  --project, -p      Install to ./.claude/skills/agent-registry"
    echo "  --install-deps     Install optional dependencies (@clack/prompts)"
    echo "  --help, -h         Show this help"
}

INSTALL_DIR="$HOME/.claude/skills/agent-registry"
INSTALL_DEPS=0

for arg in "$@"; do
    case "$arg" in
        --project|-p)
            INSTALL_DIR=".claude/skills/agent-registry"
            ;;
        --install-deps)
            INSTALL_DEPS=1
            ;;
        --help|-h)
            usage
            exit 0
            ;;
        *)
            echo -e "${RED}Unknown option: $arg${NC}"
            usage
            exit 1
            ;;
    esac
done

if [ "$INSTALL_DIR" = ".claude/skills/agent-registry" ]; then
    echo -e "${YELLOW}Installing to project-level: ${INSTALL_DIR}${NC}"
else
    echo -e "${GREEN}Installing to user-level: ${INSTALL_DIR}${NC}"
fi

# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Create target directory
echo -e "\n${CYAN}Creating skill directory...${NC}"
mkdir -p "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR/lib"
mkdir -p "$INSTALL_DIR/bin"
mkdir -p "$INSTALL_DIR/references"
mkdir -p "$INSTALL_DIR/agents"
mkdir -p "$INSTALL_DIR/hooks"

# Copy files
echo -e "${CYAN}Copying skill files...${NC}"

cp "$SCRIPT_DIR/SKILL.md" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/package.json" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/lib/"*.js "$INSTALL_DIR/lib/"
cp "$SCRIPT_DIR/bin/"*.js "$INSTALL_DIR/bin/"
chmod +x "$INSTALL_DIR/bin/"*.js
cp "$SCRIPT_DIR/hooks/"*.js "$INSTALL_DIR/hooks/"
chmod +x "$INSTALL_DIR/hooks/"*.js

# Create empty registry if it doesn't exist
if [ ! -f "$INSTALL_DIR/references/registry.json" ]; then
    echo '{"version": 1, "agents": [], "stats": {"total_agents": 0, "total_tokens": 0}}' > "$INSTALL_DIR/references/registry.json"
fi

# Install optional dependencies
if [ "$INSTALL_DEPS" -eq 1 ]; then
    echo -e "\n${CYAN}Installing optional dependencies...${NC}"
    if (cd "$INSTALL_DIR" && npm install --production >/dev/null 2>&1); then
        echo -e "${GREEN}✓ Optional dependencies installed via npm${NC}"
    elif (cd "$INSTALL_DIR" && bun install >/dev/null 2>&1); then
        echo -e "${GREEN}✓ Optional dependencies installed via bun${NC}"
    else
        echo -e "${YELLOW}! Could not install optional dependencies automatically${NC}"
        echo -e "${YELLOW}! Fallback text mode still works for init${NC}"
    fi
else
    echo -e "\n${YELLOW}Skipping dependency install (pass --install-deps to enable).${NC}"
fi

echo -e "\n${GREEN}✓ Skill installed successfully!${NC}"
echo ""
echo -e "${CYAN}Next steps:${NC}"
echo ""
echo "1. Run the migration script to copy your agents into the registry:"
echo -e "   ${YELLOW}cd $INSTALL_DIR && bun bin/init.js${NC}"
echo -e "   ${YELLOW}(use --move if you explicitly want destructive migration)${NC}"
echo ""
echo "2. After migration, Claude Code will use lazy loading for agents"
echo ""
echo "3. Verify with:"
echo -e "   ${YELLOW}cd $INSTALL_DIR && bun bin/list.js${NC}"
echo ""
echo -e "${GREEN}Installation complete!${NC}"


path: lib/parse.js
content: "use strict";

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const TECH_TERMS = [
  "python", "javascript", "typescript", "rust", "go", "java", "ruby",
  "react", "vue", "angular", "node", "docker", "kubernetes", "k8s",
  "aws", "gcp", "azure", "terraform", "ansible",
  "sql", "postgres", "mysql", "mongodb", "redis",
  "api", "rest", "graphql", "grpc",
  "test", "testing", "unittest", "pytest", "jest",
  "security", "auth", "authentication", "authorization",
  "ci", "cd", "pipeline", "deploy", "deployment",
  "code", "review", "refactor", "debug", "debugging",
  "git", "github", "gitlab", "bitbucket",
  "linux", "bash", "shell", "cli",
  "frontend", "backend", "fullstack", "devops", "sre",
  "performance", "optimization", "monitoring", "logging",
  "documentation", "docs", "readme", "comment",
  "architecture", "design", "pattern", "patterns",
  "database", "cache", "queue", "message",
  "microservice", "monolith", "serverless", "lambda",
  "machine learning", "ml", "ai", "data", "analytics",
];

/**
 * Extract a summary from agent markdown content.
 * Skips YAML frontmatter, finds first non-empty non-header lines,
 * joins them, truncates at 200 chars.
 */
function extractSummary(content) {
  const lines = content.trim().split("\n");

  // Skip YAML frontmatter if present
  let startIdx = 0;
  if (lines.length > 0 && lines[0].trim() === "---") {
    for (let i = 1; i < lines.length; i++) {
      if (lines[i].trim() === "---") {
        startIdx = i + 1;
        break;
      }
    }
  }

  // Find first non-empty, non-header lines
  const summaryLines = [];
  for (let i = startIdx; i < lines.length; i++) {
    const stripped = lines[i].trim();
    if (!stripped) {
      if (summaryLines.length > 0) break;
      continue;
    }
    if (stripped.startsWith("#")) continue;
    summaryLines.push(stripped);
    if (summaryLines.join(" ").length > 150) break;
  }

  let summary = summaryLines.join(" ");
  if (summary.length > 200) {
    summary = summary.slice(0, 197) + "...";
  }

  return summary || "No description available";
}

/**
 * Extract keywords from agent content.
 * Matches against known tech terms and extracts words from headers.
 * Returns up to 20 keywords.
 */
function extractKeywords(content) {
  const contentLower = content.toLowerCase();
  const foundKeywords = [];

  for (const term of TECH_TERMS) {
    if (contentLower.includes(term)) {
      foundKeywords.push(term);
    }
  }

  // Also extract words from headers
  for (const line of content.split("\n")) {
    if (line.trim().startsWith("#")) {
      const words = line.toLowerCase().replace(/#/g, "").trim().split(/\s+/);
      for (const word of words) {
        const cleanWord = word.replace(/[^a-z0-9]/g, "");
        if (cleanWord.length > 3 && !foundKeywords.includes(cleanWord)) {
          foundKeywords.push(cleanWord);
        }
      }
    }
  }

  return foundKeywords.slice(0, 20);
}

/**
 * Compute a short MD5 content hash (first 8 hex chars).
 */
function contentHash(content) {
  return crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
}

/**
 * Parse an agent .md file and return its metadata.
 * Returns null if the file cannot be read.
 */
function parseAgentFile(filepath) {
  try {
    const content = fs.readFileSync(filepath, "utf-8");
    const name = path.basename(filepath, path.extname(filepath));

    return {
      name,
      filename: path.basename(filepath),
      path: filepath,
      summary: extractSummary(content),
      keywords: extractKeywords(content),
      token_estimate: Math.floor(content.length / 4),
      content_hash: contentHash(content),
    };
  } catch (e) {
    process.stderr.write(`Warning: Could not parse ${filepath}: ${e.message}\n`);
    return null;
  }
}

module.exports = {
  extractSummary,
  extractKeywords,
  contentHash,
  parseAgentFile,
};


path: lib/registry.js
content: "use strict";

const fs = require("fs");
const path = require("path");

/**
 * Path utilities and registry I/O for Agent Registry.
 *
 * Centralizes the path resolution and JSON read/write logic that was
 * previously duplicated across every Python script.
 */

function getSkillDir() {
  return path.resolve(__dirname, "..");
}

function getRegistryPath() {
  return path.join(getSkillDir(), "references", "registry.json");
}

function getAgentsDir() {
  return path.join(getSkillDir(), "agents");
}

function isPathInsideDir(parentDir, targetPath) {
  const rel = path.relative(parentDir, targetPath);
  return rel === "" || (rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel));
}

function resolveRegistryAgentPath(agentPath) {
  if (typeof agentPath !== "string" || agentPath.trim() === "") {
    return { ok: false, error: "Invalid agent path in registry entry." };
  }

  const skillDir = getSkillDir();
  const agentsDir = getAgentsDir();
  const raw = agentPath.trim();

  let resolved;
  if (path.isAbsolute(raw)) {
    resolved = path.resolve(raw);
  } else if (raw.startsWith("agents/") || raw.startsWith("agents" + path.sep)) {
    resolved = path.resolve(skillDir, raw);
  } else {
    resolved = path.resolve(agentsDir, raw);
  }

  if (!isPathInsideDir(agentsDir, resolved)) {
    return {
      ok: false,
      error: `Refusing to load agent outside '${agentsDir}': ${agentPath}`,
    };
  }

  return { ok: true, path: resolved };
}

function loadRegistry() {
  const registryPath = getRegistryPath();

  try {
    const raw = fs.readFileSync(registryPath, "utf8");
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === "ENOENT") {
      process.stderr.write(
        "Error: Registry not found. Run 'bun bin/init.js' first.\n"
      );
    } else {
      process.stderr.write("Error loading registry: " + err.message + "\n");
    }
    return null;
  }
}

function saveRegistry(registry) {
  const registryPath = getRegistryPath();
  const dir = path.dirname(registryPath);

  try {
    fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(registryPath, JSON.stringify(registry), "utf8");
  } catch (err) {
    process.stderr.write("Error saving registry: " + err.message + "\n");
    return false;
  }
  return true;
}

module.exports = {
  getSkillDir,
  getRegistryPath,
  getAgentsDir,
  resolveRegistryAgentPath,
  loadRegistry,
  saveRegistry,
};


path: lib/search.js
content: "use strict";

/**
 * BM25 + Keyword Matching Search Engine for Agent Registry.
 *
 * Extracted from hooks/user_prompt_search.js so both the hook and
 * the CLI scripts share a single implementation.
 */

function tokenize(text) {
  return text.toLowerCase().match(/\b[a-z0-9]+\b/g) || [];
}

class BM25 {
  constructor(k1 = 1.5, b = 0.75) {
    this.k1 = k1;
    this.b = b;
    this.corpus = [];
    this.docLengths = [];
    this.avgdl = 0;
    this.idf = {};
    this.N = 0;
  }

  fit(documents) {
    this.corpus = [];
    this.docLengths = [];
    const docFreqs = {};

    for (const doc of documents) {
      const tokens = tokenize(doc);
      this.corpus.push(tokens);
      this.docLengths.push(tokens.length);

      const seen = new Set(tokens);
      for (const term of seen) {
        docFreqs[term] = (docFreqs[term] || 0) + 1;
      }
    }

    this.N = documents.length;
    this.avgdl =
      this.N > 0
        ? this.docLengths.reduce((a, b) => a + b, 0) / this.N
        : 0;

    this.idf = {};
    for (const [term, df] of Object.entries(docFreqs)) {
      this.idf[term] = Math.log((this.N - df + 0.5) / (df + 0.5) + 1);
    }
  }

  score(query, docIdx) {
    const queryTokens = tokenize(query);
    const docTokens = this.corpus[docIdx];
    const docLen = this.docLengths[docIdx];

    const tf = {};
    for (const t of docTokens) {
      tf[t] = (tf[t] || 0) + 1;
    }

    let s = 0;
    for (const term of queryTokens) {
      if (!(term in this.idf)) continue;
      const freq = tf[term] || 0;
      if (freq === 0) continue;

      const num = this.idf[term] * freq * (this.k1 + 1);
      const den =
        freq + this.k1 * (1 - this.b + (this.b * docLen) / this.avgdl);
      s += num / den;
    }
    return s;
  }

  search(query, topK) {
    const scores = [];
    for (let i = 0; i < this.corpus.length; i++) {
      const s = this.score(query, i);
      if (s > 0) scores.push([i, s]);
    }
    scores.sort((a, b) => b[1] - a[1]);
    return scores.slice(0, topK);
  }
}

function keywordMatchScore(query, agent) {
  const queryTerms = new Set(tokenize(query));
  if (queryTerms.size === 0) return 0;

  let matches = 0;
  let totalWeight = 0;

  // Name (highest weight)
  const nameTerms = new Set(tokenize(agent.name || ""));
  let nameMatches = 0;
  for (const t of queryTerms) {
    if (nameTerms.has(t)) nameMatches++;
  }
  matches += nameMatches * 3;
  totalWeight += queryTerms.size * 3;

  // Keywords (medium weight)
  const agentKeywords = new Set(
    (agent.keywords || []).map((k) => k.toLowerCase())
  );
  let kwMatches = 0;
  for (const t of queryTerms) {
    if (agentKeywords.has(t)) kwMatches++;
  }
  matches += kwMatches * 2;
  totalWeight += queryTerms.size * 2;

  // Summary (lower weight)
  const summaryTerms = new Set(tokenize(agent.summary || ""));
  let sumMatches = 0;
  for (const t of queryTerms) {
    if (summaryTerms.has(t)) sumMatches++;
  }
  matches += sumMatches;
  totalWeight += queryTerms.size;

  // Partial matches in summary
  const summaryLower = (agent.summary || "").toLowerCase();
  for (const term of queryTerms) {
    if (term.length >= 3 && summaryLower.includes(term)) {
      matches += 0.5;
    }
  }

  return totalWeight > 0 ? matches / totalWeight : 0;
}

function _searchCore(query, registry) {
  const agents = registry.agents || [];
  if (agents.length === 0) return [];

  const corpus = agents.map(
    (a) => `${a.name} ${a.summary || ""} ${(a.keywords || []).join(" ")}`
  );

  const bm25 = new BM25();
  bm25.fit(corpus);
  const bm25Results = bm25.search(query, agents.length);

  const bm25Scores = {};
  for (const [idx, score] of bm25Results) {
    bm25Scores[idx] = score;
  }

  // Normalize BM25 scores to 0-1
  const vals = Object.values(bm25Scores);
  const maxBm25 = vals.length > 0 ? Math.max(...vals) : 1;
  if (maxBm25 > 0) {
    for (const idx in bm25Scores) {
      bm25Scores[idx] /= maxBm25;
    }
  }

  const results = [];
  for (let i = 0; i < agents.length; i++) {
    const bm25Score = bm25Scores[i] || 0;
    const kwScore = keywordMatchScore(query, agents[i]);
    const combined = 0.6 * bm25Score + 0.4 * kwScore;

    if (combined > 0.05) {
      results.push({
        name: agents[i].name,
        summary: agents[i].summary,
        score: Math.round(combined * 1000) / 1000,
        bm25_score: Math.round(bm25Score * 1000) / 1000,
        keyword_score: Math.round(kwScore * 1000) / 1000,
        token_estimate: agents[i].token_estimate || 0,
        keywords: (agents[i].keywords || []).slice(0, 5),
      });
    }
  }

  results.sort((a, b) => b.score - a.score);
  return results;
}

function searchAgents(query, registry, topK = 5) {
  return _searchCore(query, registry).slice(0, topK);
}

function searchAgentsAll(query, registry) {
  return _searchCore(query, registry);
}

module.exports = {
  tokenize,
  BM25,
  keywordMatchScore,
  searchAgents,
  searchAgentsAll,
};


path: lib/telemetry.js
content: "use strict";

/**
 * Anonymous Telemetry Module
 *
 * Privacy-first, fire-and-forget usage tracking for Agent Registry.
 * No personal information or search queries are collected.
 *
 * Opt-in:
 *   export AGENT_REGISTRY_TELEMETRY=1
 *
 * Opt-out (still supported):
 *   unset AGENT_REGISTRY_TELEMETRY
 *   export DO_NOT_TRACK=1
 *
 * Automatically disabled in CI environments.
 */

const ENDPOINT = "https://t.insightx.pro";
const VERSION = "2.0.1";
const TOOL_ID = "agent-registry";

const CI_VARS = [
  "CI",
  "GITHUB_ACTIONS",
  "GITLAB_CI",
  "CIRCLECI",
  "TRAVIS",
  "BUILDKITE",
  "JENKINS_URL",
];

function isDisabled() {
  const enabled = String(process.env.AGENT_REGISTRY_TELEMETRY || "").toLowerCase();
  const telemetryOptIn = enabled === "1" || enabled === "true" || enabled === "yes";
  if (!telemetryOptIn) return true;
  if (process.env.AGENT_REGISTRY_NO_TELEMETRY) return true;
  if (process.env.DO_NOT_TRACK) return true;
  return CI_VARS.some(function (v) {
    return process.env[v];
  });
}

function track(event, data) {
  if (isDisabled()) return;

  try {
    var params = {
      t: TOOL_ID,
      e: event,
      v: VERSION,
      rt: typeof Bun !== "undefined" ? "bun" : "node",
      os: process.platform,
    };

    if (data) {
      var keys = Object.keys(data);
      for (var i = 0; i < keys.length; i++) {
        params[keys[i]] = data[keys[i]];
      }
    }

    var qs = Object.keys(params)
      .map(function (k) {
        return (
          encodeURIComponent(k) + "=" + encodeURIComponent(String(params[k]))
        );
      })
      .join("&");

    var url = ENDPOINT + "?" + qs;

    // Fire-and-forget: never await, never throw
    fetch(url).catch(function () {});
  } catch (_) {
    // Silent failure — telemetry should never break functionality
  }
}

module.exports = { track, VERSION, TOOL_ID };


path: package-lock.json
content: {
  "name": "@claude-code/agent-registry",
  "version": "2.0.1",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "@claude-code/agent-registry",
      "version": "2.0.1",
      "hasInstallScript": true,
      "license": "MIT",
      "dependencies": {
        "@clack/prompts": "^1.0.0"
      },
      "bin": {
        "agent-registry": "bin/cli.js"
      }
    },
    "node_modules/@clack/core": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "picocolors": "^1.0.0",
        "sisteransi": "^1.0.5"
      }
    },
    "node_modules/@clack/prompts": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@clack/core": "1.0.0",
        "picocolors": "^1.0.0",
        "sisteransi": "^1.0.5"
      }
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC"
    },
    "node_modules/sisteransi": {
      "version": "1.0.5",
      "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    }
  }
}


path: package.json
content: {
  "name": "@claude-code/agent-registry",
  "version": "2.0.1",
  "description": "Lazy-loading system for Claude Code agents - reduce token overhead by 70-90%",
  "keywords": [
    "claude-code",
    "agents",
    "ai",
    "cli",
    "skills",
    "bm25",
    "lazy-loading"
  ],
  "homepage": "https://github.com/MaTriXy/Agent-Registry#readme",
  "repository": {
    "type": "git",
    "url": "https://github.com/MaTriXy/Agent-Registry.git"
  },
  "author": "Yossi Elkrief",
  "license": "MIT",
  "bin": {
    "agent-registry": "./bin/cli.js"
  },
  "dependencies": {
    "@clack/prompts": "^1.0.0"
  },
  "files": [
    "SKILL.md",
    "install.sh",
    "lib/",
    "bin/",
    "hooks/",
    "references/",
    "README.md"
  ],
  "scripts": {
    "test": "bun test",
    "postinstall": "echo '\\n✓ Agent Registry installed! Run: bun bin/init.js\\n'"
  }
}


path: tests/cli.test.js
content: const { describe, test, expect, beforeAll, afterAll } = require("bun:test");
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("path");

const BIN = path.resolve(__dirname, "..");

/**
 * Runs a bun script and captures stdout, stderr, exit code.
 */
function run(script, args = [], opts = {}) {
  return new Promise((resolve) => {
    const proc = spawn("bun", [path.join(BIN, script), ...args], {
      cwd: BIN,
      env: { ...process.env, AGENT_REGISTRY_NO_TELEMETRY: "1" },
      ...opts,
    });

    let stdout = "";
    let stderr = "";

    proc.stdout.on("data", (d) => (stdout += d));
    proc.stderr.on("data", (d) => (stderr += d));

    proc.on("close", (code) => {
      resolve({ stdout, stderr, code });
    });
  });
}

/**
 * Runs a bun script with data piped to stdin.
 */
function runWithStdin(script, stdinData, args = []) {
  return new Promise((resolve) => {
    const proc = spawn("bun", [path.join(BIN, script), ...args], {
      cwd: BIN,
      env: { ...process.env, AGENT_REGISTRY_NO_TELEMETRY: "1" },
    });

    let stdout = "";
    let stderr = "";

    proc.stdout.on("data", (d) => (stdout += d));
    proc.stderr.on("data", (d) => (stderr += d));

    proc.on("close", (code) => {
      resolve({ stdout, stderr, code });
    });

    proc.stdin.write(stdinData);
    proc.stdin.end();
  });
}

// Check if a real registry exists for integration tests that need it
const registryPath = path.join(BIN, "references", "registry.json");
const hasRegistry = fs.existsSync(registryPath);

describe("CLI integration", () => {
  describe("bin/cli.js (dispatcher)", () => {
    test("shows help with no arguments", async () => {
      const { stdout, code } = await run("bin/cli.js");
      expect(code).toBe(0);
      expect(stdout).toContain("Agent Registry");
      expect(stdout).toContain("Commands:");
      expect(stdout).toContain("search");
      expect(stdout).toContain("get");
      expect(stdout).toContain("list");
      expect(stdout).toContain("init");
      expect(stdout).toContain("rebuild");
    });

    test("shows help with --help flag", async () => {
      const { stdout, code } = await run("bin/cli.js", ["--help"]);
      expect(code).toBe(0);
      expect(stdout).toContain("Usage:");
    });

    test("exits with error for unknown command", async () => {
      const { stderr, code } = await run("bin/cli.js", ["unknown-command"]);
      expect(code).toBe(1);
      expect(stderr).toContain("Unknown command");
    });
  });

  describe("bin/search.js", () => {
    test("shows usage with no arguments", async () => {
      const { stdout, code } = await run("bin/search.js");
      expect(code).toBe(1);
      expect(stdout).toContain("Usage:");
    });

    if (hasRegistry) {
      test("returns results for a valid query", async () => {
        const { stdout, code } = await run("bin/search.js", ["code review"]);
        expect(code).toBe(0);
        expect(stdout).toContain("matching agent");
      });

      test("supports --json flag", async () => {
        const { stdout, code } = await run("bin/search.js", [
          "code review",
          "--json",
        ]);
        expect(code).toBe(0);
        const parsed = JSON.parse(stdout);
        expect(Array.isArray(parsed)).toBe(true);
      });

      test("supports --top flag", async () => {
        const { stdout, code } = await run("bin/search.js", [
          "test",
          "--top",
          "2",
          "--json",
        ]);
        expect(code).toBe(0);
        const parsed = JSON.parse(stdout);
        expect(parsed.length).toBeLessThanOrEqual(2);
      });

      test("returns empty for nonsense query", async () => {
        const { stdout, code } = await run("bin/search.js", [
          "zzzzxxxxxyyyyy",
          "--json",
        ]);
        expect(code).toBe(0);
        const parsed = JSON.parse(stdout);
        expect(parsed).toEqual([]);
      });
    }
  });

  describe("bin/get.js", () => {
    test("shows help with --help", async () => {
      const { stdout, code } = await run("bin/get.js", ["--help"]);
      expect(code).toBe(0);
      expect(stdout).toContain("Usage:");
      expect(stdout).toContain("--json");
      expect(stdout).toContain("--raw");
    });

    if (hasRegistry) {
      test("lists agents when no name given", async () => {
        const { stdout, code } = await run("bin/get.js");
        expect(code).toBe(0);
        expect(stdout).toContain("Available agents:");
      });

      test("exits with error for non-existent agent", async () => {
        const { stderr, code } = await run("bin/get.js", [
          "zzz-nonexistent-agent-zzz",
        ]);
        expect(code).toBe(1);
        expect(stderr).toContain("not found");
      });
    }
  });

  describe("bin/list.js", () => {
    test("shows help with --help", async () => {
      const { stdout, code } = await run("bin/list.js", ["--help"]);
      expect(code).toBe(0);
      expect(stdout).toContain("Usage:");
      expect(stdout).toContain("--detailed");
      expect(stdout).toContain("--simple");
      expect(stdout).toContain("--json");
    });

    if (hasRegistry) {
      test("shows table by default", async () => {
        const { stdout, code } = await run("bin/list.js");
        expect(code).toBe(0);
        expect(stdout).toContain("Agent Name");
        expect(stdout).toContain("Tokens");
        expect(stdout).toContain("Total:");
      });

      test("supports --json output", async () => {
        const { stdout, code } = await run("bin/list.js", ["--json"]);
        expect(code).toBe(0);
        const parsed = JSON.parse(stdout);
        expect(parsed).toHaveProperty("agents");
        expect(Array.isArray(parsed.agents)).toBe(true);
      });

      test("supports --simple output", async () => {
        const { stdout, code } = await run("bin/list.js", ["--simple"]);
        expect(code).toBe(0);
        // Simple output is just agent names, one per line
        const lines = stdout.trim().split("\n");
        expect(lines.length).toBeGreaterThan(0);
        // No header row in simple mode
        expect(stdout).not.toContain("Agent Name");
      });

      test("supports --detailed output", async () => {
        const { stdout, code } = await run("bin/list.js", ["--detailed"]);
        expect(code).toBe(0);
        expect(stdout).toContain("Summary:");
        expect(stdout).toContain("Tokens:");
        expect(stdout).toContain("Keywords:");
      });
    }
  });

  describe("bin/search-paged.js", () => {
    test("shows usage with no arguments", async () => {
      const { stdout, code } = await run("bin/search-paged.js");
      expect(code).toBe(1);
      expect(stdout).toContain("Usage:");
    });

    if (hasRegistry) {
      test("returns paginated results", async () => {
        const { stdout, code } = await run("bin/search-paged.js", [
          "code",
          "--json",
        ]);
        expect(code).toBe(0);
        const parsed = JSON.parse(stdout);
        expect(parsed).toHaveProperty("total_results");
        expect(parsed).toHaveProperty("results");
        expect(Array.isArray(parsed.results)).toBe(true);
      });

      test("supports --page and --page-size", async () => {
        const { stdout, code } = await run("bin/search-paged.js", [
          "test",
          "--page",
          "1",
          "--page-size",
          "3",
          "--json",
        ]);
        expect(code).toBe(0);
        const parsed = JSON.parse(stdout);
        expect(parsed.page).toBe(1);
        expect(parsed.page_size).toBe(3);
        expect(parsed.results.length).toBeLessThanOrEqual(3);
      });

      test("supports --offset and --limit", async () => {
        const { stdout, code } = await run("bin/search-paged.js", [
          "code",
          "--offset",
          "0",
          "--limit",
          "2",
          "--json",
        ]);
        expect(code).toBe(0);
        const parsed = JSON.parse(stdout);
        expect(parsed).toHaveProperty("offset");
        expect(parsed).toHaveProperty("limit");
        expect(parsed.results.length).toBeLessThanOrEqual(2);
      });
    }
  });

  describe("bin/rebuild.js", () => {
    // Rebuild needs an agents/ dir with .md files. We create a temporary one.
    const testAgentsDir = path.join(BIN, "agents", "_test_rebuild_agent");
    const testAgentFile = path.join(testAgentsDir, "rebuild-test-agent.md");

    beforeAll(() => {
      fs.mkdirSync(testAgentsDir, { recursive: true });
      fs.writeFileSync(
        testAgentFile,
        "# Rebuild Test Agent\n\nA test agent for rebuild testing.\n\n## Skills\n- Testing\n- JavaScript\n",
        "utf8"
      );
    });

    afterAll(() => {
      fs.rmSync(testAgentsDir, { recursive: true, force: true });
    });

    test("rebuilds registry from agents directory", async () => {
      const { stdout, code } = await run("bin/rebuild.js");
      expect(code).toBe(0);
      expect(stdout).toContain("Scanning agents directory");
      expect(stdout).toContain("Registry rebuilt");
      expect(stdout).toContain("rebuild-test-agent");
    });
  });
});

describe("hooks/user_prompt_search.js", () => {
  if (hasRegistry) {
    test("outputs additionalContext for matching prompts", async () => {
      const input = JSON.stringify({
        prompt: "I need help with code review and security auditing my project",
      });
      const { stdout, code } = await runWithStdin(
        "hooks/user_prompt_search.js",
        input
      );
      // The hook may or may not find high-confidence matches depending on the registry
      // But it should exit cleanly (0)
      expect(code).toBe(0);
      if (stdout.trim()) {
        const parsed = JSON.parse(stdout);
        expect(parsed).toHaveProperty("additionalContext");
      }
    });

    test("exits silently for short prompts (< 3 words)", async () => {
      const input = JSON.stringify({ prompt: "help me" });
      const { stdout, code } = await runWithStdin(
        "hooks/user_prompt_search.js",
        input
      );
      expect(code).toBe(0);
      expect(stdout.trim()).toBe("");
    });

    test("exits silently for slash commands", async () => {
      const input = JSON.stringify({ prompt: "/help with something here" });
      const { stdout, code } = await runWithStdin(
        "hooks/user_prompt_search.js",
        input
      );
      expect(code).toBe(0);
      expect(stdout.trim()).toBe("");
    });

    test("exits silently for empty prompts", async () => {
      const input = JSON.stringify({ prompt: "" });
      const { stdout, code } = await runWithStdin(
        "hooks/user_prompt_search.js",
        input
      );
      expect(code).toBe(0);
      expect(stdout.trim()).toBe("");
    });
  }

  test("exits silently for invalid JSON input", async () => {
    const { stdout, code } = await runWithStdin(
      "hooks/user_prompt_search.js",
      "NOT VALID JSON"
    );
    expect(code).toBe(0);
    expect(stdout.trim()).toBe("");
  });
});


path: tests/parse.test.js
content: const { describe, test, expect, beforeAll, afterAll } = require("bun:test");
const fs = require("fs");
const path = require("path");

const { extractSummary, extractKeywords, contentHash, parseAgentFile } = require("../lib/parse");

describe("parse", () => {
  describe("extractSummary", () => {
    test("extracts first paragraph from plain content", () => {
      const content = "This is a test agent that does things.\n\nMore details here.";
      expect(extractSummary(content)).toBe("This is a test agent that does things.");
    });

    test("skips YAML frontmatter", () => {
      const content = "---\nname: test\ndescription: foo\n---\n\nActual summary here.";
      expect(extractSummary(content)).toBe("Actual summary here.");
    });

    test("skips markdown headers", () => {
      const content = "# My Agent\n\n## Overview\n\nThis is the real summary.";
      expect(extractSummary(content)).toBe("This is the real summary.");
    });

    test("truncates long summaries at 200 chars", () => {
      const longLine = "A".repeat(250);
      const summary = extractSummary(longLine);
      expect(summary.length).toBeLessThanOrEqual(200);
      expect(summary).toEndWith("...");
    });

    test("returns fallback for empty content", () => {
      expect(extractSummary("")).toBe("No description available");
    });

    test("returns fallback for header-only content", () => {
      expect(extractSummary("# Just a header\n## Another header")).toBe("No description available");
    });

    test("joins multiple lines into single summary", () => {
      const content = "First line of summary.\nSecond line continues.\n\nNew paragraph.";
      expect(extractSummary(content)).toBe("First line of summary. Second line continues.");
    });

    test("handles frontmatter with content after", () => {
      const content = "---\nname: agent\n---\n# Title\n\nDescription after frontmatter and title.";
      expect(extractSummary(content)).toBe("Description after frontmatter and title.");
    });

    test("stops collecting at first empty line after content", () => {
      const content = "Line one.\nLine two.\n\nLine three should not appear.";
      const summary = extractSummary(content);
      expect(summary).toBe("Line one. Line two.");
      expect(summary).not.toContain("Line three");
    });
  });

  describe("extractKeywords", () => {
    test("finds tech terms in content", () => {
      const content = "This agent uses React and TypeScript for frontend development.";
      const keywords = extractKeywords(content);
      expect(keywords).toContain("react");
      expect(keywords).toContain("typescript");
      expect(keywords).toContain("frontend");
    });

    test("extracts words from headers", () => {
      const content = "# Authentication Handler\n\nSome content.";
      const keywords = extractKeywords(content);
      expect(keywords).toContain("auth");
      expect(keywords).toContain("authentication");
    });

    test("limits to 20 keywords max", () => {
      // Content with many tech terms
      const content = "python javascript typescript rust go java ruby react vue angular " +
        "node docker kubernetes k8s aws gcp azure terraform ansible " +
        "sql postgres mysql mongodb redis api rest graphql grpc " +
        "test testing security frontend backend devops";
      const keywords = extractKeywords(content);
      expect(keywords.length).toBeLessThanOrEqual(20);
    });

    test("returns empty array for content with no tech terms", () => {
      const content = "This is a very generic text with no recognized terms.";
      const keywords = extractKeywords(content);
      // May find some header words but no tech terms
      expect(Array.isArray(keywords)).toBe(true);
    });

    test("avoids duplicate keywords", () => {
      const content = "# React Expert\n\nReact is great for react apps.\nKeywords: react";
      const keywords = extractKeywords(content);
      const reactCount = keywords.filter(k => k === "react").length;
      expect(reactCount).toBe(1);
    });

    test("ignores short header words (<=3 chars)", () => {
      const content = "# My API\n\nContent here.";
      const keywords = extractKeywords(content);
      // "my" is <=3 chars, should not be included from header
      expect(keywords).not.toContain("my");
    });

    test("finds multi-word terms like 'machine learning'", () => {
      const content = "This agent specializes in machine learning tasks.";
      const keywords = extractKeywords(content);
      expect(keywords).toContain("machine learning");
    });
  });

  describe("contentHash", () => {
    test("returns 8 character hex string", () => {
      const hash = contentHash("test content");
      expect(hash).toHaveLength(8);
      expect(hash).toMatch(/^[0-9a-f]{8}$/);
    });

    test("returns consistent hash for same content", () => {
      const hash1 = contentHash("hello world");
      const hash2 = contentHash("hello world");
      expect(hash1).toBe(hash2);
    });

    test("returns different hash for different content", () => {
      const hash1 = contentHash("content A");
      const hash2 = contentHash("content B");
      expect(hash1).not.toBe(hash2);
    });
  });

  describe("parseAgentFile", () => {
    const testDir = path.join(__dirname, "_test_agents");
    const testFile = path.join(testDir, "test-agent.md");

    beforeAll(() => {
      fs.mkdirSync(testDir, { recursive: true });
      fs.writeFileSync(testFile, "---\nname: test-agent\n---\n# Test Agent\n\nA test agent for unit testing.\n\n## Features\n\n- Uses React and TypeScript\n- Supports Docker deployments\n", "utf8");
    });

    afterAll(() => {
      fs.rmSync(testDir, { recursive: true, force: true });
    });

    test("parses a valid agent file", () => {
      const result = parseAgentFile(testFile);
      expect(result).not.toBeNull();
      expect(result.name).toBe("test-agent");
      expect(result.filename).toBe("test-agent.md");
      expect(result.path).toBe(testFile);
      expect(typeof result.summary).toBe("string");
      expect(result.summary).not.toBe("No description available");
      expect(Array.isArray(result.keywords)).toBe(true);
      expect(typeof result.token_estimate).toBe("number");
      expect(result.token_estimate).toBeGreaterThan(0);
      expect(result.content_hash).toMatch(/^[0-9a-f]{8}$/);
    });

    test("extracts correct name from filename", () => {
      const result = parseAgentFile(testFile);
      expect(result.name).toBe("test-agent");
    });

    test("calculates token estimate as content.length / 4", () => {
      const content = fs.readFileSync(testFile, "utf8");
      const result = parseAgentFile(testFile);
      expect(result.token_estimate).toBe(Math.floor(content.length / 4));
    });

    test("returns null for non-existent file", () => {
      const result = parseAgentFile("/non/existent/file.md");
      expect(result).toBeNull();
    });

    test("extracts keywords from content", () => {
      const result = parseAgentFile(testFile);
      expect(result.keywords).toContain("react");
      expect(result.keywords).toContain("typescript");
      expect(result.keywords).toContain("docker");
    });
  });
});


path: tests/registry.test.js
content: const { describe, test, expect, beforeAll, afterAll, beforeEach } = require("bun:test");
const fs = require("fs");
const path = require("path");

const {
  getSkillDir,
  getRegistryPath,
  getAgentsDir,
  resolveRegistryAgentPath,
  loadRegistry,
  saveRegistry,
} = require("../lib/registry");

describe("registry", () => {
  describe("getSkillDir", () => {
    test("returns the project root (parent of lib/)", () => {
      const skillDir = getSkillDir();
      expect(skillDir).toBe(path.resolve(__dirname, ".."));
      expect(fs.existsSync(path.join(skillDir, "lib"))).toBe(true);
    });
  });

  describe("getRegistryPath", () => {
    test("returns path to references/registry.json", () => {
      const registryPath = getRegistryPath();
      expect(registryPath).toEndWith(path.join("references", "registry.json"));
    });
  });

  describe("getAgentsDir", () => {
    test("returns path to agents/", () => {
      const agentsDir = getAgentsDir();
      expect(agentsDir).toEndWith("agents");
    });
  });

  describe("resolveRegistryAgentPath", () => {
    test("resolves bare file names under agents/", () => {
      const result = resolveRegistryAgentPath("test-agent.md");
      expect(result.ok).toBe(true);
      expect(result.path).toEndWith(path.join("agents", "test-agent.md"));
    });

    test("resolves paths prefixed with agents/", () => {
      const result = resolveRegistryAgentPath(path.join("agents", "category", "a.md"));
      expect(result.ok).toBe(true);
      expect(result.path).toEndWith(path.join("agents", "category", "a.md"));
    });

    test("rejects path traversal entries", () => {
      const result = resolveRegistryAgentPath("../../etc/passwd");
      expect(result.ok).toBe(false);
      expect(result.error).toContain("Refusing to load agent outside");
    });

    test("rejects absolute paths outside agents/", () => {
      const result = resolveRegistryAgentPath("/tmp/outside.md");
      expect(result.ok).toBe(false);
      expect(result.error).toContain("Refusing to load agent outside");
    });
  });

  describe("loadRegistry", () => {
    test("loads and parses existing registry.json", () => {
      const registry = loadRegistry();
      // Our real registry exists and has agents
      if (registry) {
        expect(registry).toHaveProperty("agents");
        expect(registry).toHaveProperty("version");
        expect(Array.isArray(registry.agents)).toBe(true);
      }
    });

    test("returns null for missing file", () => {
      // Temporarily move registry to test missing file behavior
      const registryPath = getRegistryPath();
      const backupPath = registryPath + ".bak";
      const exists = fs.existsSync(registryPath);
      if (exists) fs.renameSync(registryPath, backupPath);

      const result = loadRegistry();
      expect(result).toBeNull();

      if (exists) fs.renameSync(backupPath, registryPath);
    });

    test("returns null for invalid JSON", () => {
      const registryPath = getRegistryPath();
      const backupPath = registryPath + ".bak";
      const exists = fs.existsSync(registryPath);
      let original;
      if (exists) {
        original = fs.readFileSync(registryPath, "utf8");
        fs.writeFileSync(registryPath, "NOT VALID JSON{{{", "utf8");
      }

      const result = loadRegistry();
      if (exists) {
        expect(result).toBeNull();
        fs.writeFileSync(registryPath, original, "utf8");
      }
    });
  });

  describe("saveRegistry", () => {
    const testDir = path.join(getSkillDir(), "references", "_test_save");
    const testPath = path.join(testDir, "test_registry.json");

    afterAll(() => {
      try {
        fs.rmSync(testDir, { recursive: true, force: true });
      } catch {}
    });

    test("saves registry as minified JSON", () => {
      const registryPath = getRegistryPath();
      const backupPath = registryPath + ".save.bak";
      const exists = fs.existsSync(registryPath);
      let original;
      if (exists) {
        original = fs.readFileSync(registryPath, "utf8");
      }

      const testData = { version: 1, agents: [], stats: {} };
      const result = saveRegistry(testData);
      expect(result).toBe(true);

      const saved = JSON.parse(fs.readFileSync(registryPath, "utf8"));
      expect(saved).toEqual(testData);

      // Restore original
      if (exists) {
        fs.writeFileSync(registryPath, original, "utf8");
      }
    });
  });
});


path: tests/search.test.js
content: const { describe, test, expect } = require("bun:test");

const { tokenize, BM25, keywordMatchScore, searchAgents, searchAgentsAll } = require("../lib/search");

describe("search", () => {
  describe("tokenize", () => {
    test("lowercases and splits on word boundaries", () => {
      expect(tokenize("Hello World")).toEqual(["hello", "world"]);
    });

    test("returns empty array for empty input", () => {
      expect(tokenize("")).toEqual([]);
    });

    test("handles hyphens as word boundaries", () => {
      expect(tokenize("react-native")).toEqual(["react", "native"]);
    });

    test("treats underscores as non-boundary (regex \\b behavior)", () => {
      // \b considers _ a word char, so expert_v2 doesn't split
      expect(tokenize("react-expert_v2")).toEqual(["react"]);
    });

    test("includes numbers", () => {
      expect(tokenize("node18 k8s")).toEqual(["node18", "k8s"]);
    });

    test("filters out non-alphanumeric tokens", () => {
      const tokens = tokenize("hello --- world *** test");
      expect(tokens).toEqual(["hello", "world", "test"]);
    });
  });

  describe("BM25", () => {
    let bm25;

    test("can be constructed with default params", () => {
      bm25 = new BM25();
      expect(bm25.k1).toBe(1.5);
      expect(bm25.b).toBe(0.75);
    });

    test("can be constructed with custom params", () => {
      bm25 = new BM25(2.0, 0.5);
      expect(bm25.k1).toBe(2.0);
      expect(bm25.b).toBe(0.5);
    });

    test("fit indexes documents correctly", () => {
      bm25 = new BM25();
      bm25.fit(["hello world", "foo bar baz", "hello foo"]);
      expect(bm25.N).toBe(3);
      expect(bm25.corpus.length).toBe(3);
      expect(bm25.docLengths).toEqual([2, 3, 2]);
      expect(bm25.avgdl).toBeCloseTo(7 / 3);
    });

    test("computes IDF scores", () => {
      bm25 = new BM25();
      bm25.fit(["hello world", "foo bar", "hello foo"]);
      // "hello" appears in 2 of 3 docs
      expect(bm25.idf["hello"]).toBeDefined();
      // "world" appears in 1 of 3 docs — should have higher IDF
      expect(bm25.idf["world"]).toBeGreaterThan(bm25.idf["hello"]);
    });

    test("score returns 0 for no matching terms", () => {
      bm25 = new BM25();
      bm25.fit(["hello world", "foo bar"]);
      expect(bm25.score("xyz", 0)).toBe(0);
    });

    test("score returns positive for matching terms", () => {
      bm25 = new BM25();
      bm25.fit(["hello world", "foo bar"]);
      expect(bm25.score("hello", 0)).toBeGreaterThan(0);
    });

    test("search returns results sorted by score descending", () => {
      bm25 = new BM25();
      bm25.fit([
        "react hooks frontend",
        "python backend api",
        "react typescript frontend components",
      ]);
      const results = bm25.search("react frontend", 3);
      expect(results.length).toBeGreaterThan(0);
      // Results should be sorted descending
      for (let i = 1; i < results.length; i++) {
        expect(results[i][1]).toBeLessThanOrEqual(results[i - 1][1]);
      }
    });

    test("search respects topK limit", () => {
      bm25 = new BM25();
      bm25.fit(["a b c", "a d e", "a f g", "a h i"]);
      const results = bm25.search("a", 2);
      expect(results.length).toBeLessThanOrEqual(2);
    });

    test("handles empty corpus", () => {
      bm25 = new BM25();
      bm25.fit([]);
      expect(bm25.N).toBe(0);
      expect(bm25.avgdl).toBe(0);
      const results = bm25.search("hello", 5);
      expect(results).toEqual([]);
    });

    test("handles single document corpus", () => {
      bm25 = new BM25();
      bm25.fit(["single document with some words"]);
      const results = bm25.search("document", 5);
      expect(results.length).toBe(1);
      expect(results[0][0]).toBe(0);
      expect(results[0][1]).toBeGreaterThan(0);
    });
  });

  describe("keywordMatchScore", () => {
    test("returns 0 for empty query", () => {
      expect(keywordMatchScore("", { name: "test", keywords: ["foo"] })).toBe(0);
    });

    test("scores higher for name matches", () => {
      const agent = { name: "react-expert", keywords: ["vue"], summary: "A vue specialist" };
      const reactScore = keywordMatchScore("react", agent);
      const vueScore = keywordMatchScore("vue", agent);
      // "react" matches name (weight 3) but not keywords/summary
      // "vue" matches keywords (weight 2) and summary (weight 1)
      // Both should be positive
      expect(reactScore).toBeGreaterThan(0);
      expect(vueScore).toBeGreaterThan(0);
    });

    test("matches keywords with medium weight", () => {
      const agent = { name: "test", keywords: ["docker", "kubernetes"], summary: "Container tools" };
      const score = keywordMatchScore("docker", agent);
      expect(score).toBeGreaterThan(0);
    });

    test("matches summary terms with lower weight", () => {
      const agent = { name: "test", keywords: [], summary: "Specializes in react development" };
      const score = keywordMatchScore("react", agent);
      expect(score).toBeGreaterThan(0);
    });

    test("adds partial match bonus for 3+ char terms in summary", () => {
      const agent = { name: "test", keywords: [], summary: "authentication handler" };
      const score = keywordMatchScore("auth", agent);
      expect(score).toBeGreaterThan(0);
    });

    test("handles missing fields gracefully", () => {
      const agent = { name: "test" };
      expect(() => keywordMatchScore("query", agent)).not.toThrow();
      const score = keywordMatchScore("query", agent);
      expect(typeof score).toBe("number");
    });

    test("score can exceed 1 when partial match bonus applies", () => {
      // Partial match bonus (+0.5) is added on top of weighted score,
      // so when a term matches name + keywords + summary + partial, it exceeds 1.0
      const agent = { name: "react", keywords: ["react", "frontend"], summary: "React specialist for react apps" };
      const score = keywordMatchScore("react", agent);
      expect(score).toBeGreaterThan(1);
    });

    test("score is 0 or positive", () => {
      const agent = { name: "test", keywords: ["foo"], summary: "bar" };
      const score = keywordMatchScore("xyz", agent);
      expect(score).toBeGreaterThanOrEqual(0);
    });
  });

  describe("searchAgents", () => {
    const mockRegistry = {
      agents: [
        { name: "react-expert", summary: "React specialist for frontend", keywords: ["react", "frontend", "javascript"], token_estimate: 2000 },
        { name: "django-expert", summary: "Django web framework specialist", keywords: ["django", "python", "backend"], token_estimate: 3000 },
        { name: "security-auditor", summary: "Security vulnerability scanner", keywords: ["security", "audit", "vulnerabilities"], token_estimate: 1500 },
        { name: "docker-specialist", summary: "Docker and container orchestration", keywords: ["docker", "kubernetes", "devops"], token_estimate: 2500 },
        { name: "code-reviewer", summary: "General code review and best practices", keywords: ["code", "review", "quality"], token_estimate: 1800 },
      ],
    };

    test("returns results matching query", () => {
      const results = searchAgents("react frontend", mockRegistry);
      expect(results.length).toBeGreaterThan(0);
      expect(results[0].name).toBe("react-expert");
    });

    test("respects topK parameter", () => {
      const results = searchAgents("code", mockRegistry, 2);
      expect(results.length).toBeLessThanOrEqual(2);
    });

    test("defaults to topK=5", () => {
      const results = searchAgents("a", mockRegistry);
      expect(results.length).toBeLessThanOrEqual(5);
    });

    test("results have required fields", () => {
      const results = searchAgents("security", mockRegistry);
      expect(results.length).toBeGreaterThan(0);
      const r = results[0];
      expect(r).toHaveProperty("name");
      expect(r).toHaveProperty("summary");
      expect(r).toHaveProperty("score");
      expect(r).toHaveProperty("bm25_score");
      expect(r).toHaveProperty("keyword_score");
      expect(r).toHaveProperty("token_estimate");
      expect(r).toHaveProperty("keywords");
    });

    test("results are sorted by score descending", () => {
      const results = searchAgents("code review", mockRegistry, 5);
      for (let i = 1; i < results.length; i++) {
        expect(results[i].score).toBeLessThanOrEqual(results[i - 1].score);
      }
    });

    test("returns empty array for no matches", () => {
      const results = searchAgents("zzzzzzzzz", mockRegistry);
      expect(results).toEqual([]);
    });

    test("returns empty array for empty registry", () => {
      const results = searchAgents("test", { agents: [] });
      expect(results).toEqual([]);
    });

    test("scores are rounded to 3 decimal places", () => {
      const results = searchAgents("react", mockRegistry);
      for (const r of results) {
        const str = String(r.score);
        const decimals = str.includes(".") ? str.split(".")[1].length : 0;
        expect(decimals).toBeLessThanOrEqual(3);
      }
    });
  });

  describe("searchAgentsAll", () => {
    const mockRegistry = {
      agents: [
        { name: "agent-1", summary: "First agent", keywords: ["test"], token_estimate: 100 },
        { name: "agent-2", summary: "Second test agent", keywords: ["test"], token_estimate: 200 },
        { name: "agent-3", summary: "Third test agent", keywords: ["test"], token_estimate: 300 },
        { name: "agent-4", summary: "Fourth test agent", keywords: ["test"], token_estimate: 400 },
        { name: "agent-5", summary: "Fifth test agent", keywords: ["test"], token_estimate: 500 },
        { name: "agent-6", summary: "Sixth test agent", keywords: ["test"], token_estimate: 600 },
      ],
    };

    test("returns all matching results without limit", () => {
      const results = searchAgentsAll("test agent", mockRegistry);
      // Should return more than the default topK=5
      expect(results.length).toBeGreaterThan(0);
    });

    test("returns empty array for empty registry", () => {
      const results = searchAgentsAll("test", { agents: [] });
      expect(results).toEqual([]);
    });
  });
});


path: tests/telemetry.test.js
content: const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test");

describe("telemetry", () => {
  let originalEnv;
  let originalFetch;

  beforeEach(() => {
    originalEnv = { ...process.env };
    originalFetch = global.fetch;
    // Clear telemetry-related env vars
    delete process.env.AGENT_REGISTRY_NO_TELEMETRY;
    delete process.env.AGENT_REGISTRY_TELEMETRY;
    delete process.env.DO_NOT_TRACK;
    delete process.env.CI;
    delete process.env.GITHUB_ACTIONS;
    delete process.env.GITLAB_CI;
    delete process.env.CIRCLECI;
    delete process.env.TRAVIS;
    delete process.env.BUILDKITE;
    delete process.env.JENKINS_URL;

    // Clear module cache to get fresh isDisabled state
    delete require.cache[require.resolve("../lib/telemetry")];
  });

  afterEach(() => {
    process.env = originalEnv;
    global.fetch = originalFetch;
    delete require.cache[require.resolve("../lib/telemetry")];
  });

  describe("exports", () => {
    test("exports track, VERSION, and TOOL_ID", () => {
      const telemetry = require("../lib/telemetry");
      expect(typeof telemetry.track).toBe("function");
      expect(telemetry.VERSION).toBe("2.0.1");
      expect(telemetry.TOOL_ID).toBe("agent-registry");
    });
  });

  describe("track", () => {
    test("does not throw when called with event and data", () => {
      const { track } = require("../lib/telemetry");
      expect(() => track("test_event", { n: 5, ms: 100 })).not.toThrow();
    });

    test("does not throw when called with just event", () => {
      const { track } = require("../lib/telemetry");
      expect(() => track("test_event")).not.toThrow();
    });

    test("does not throw when called with null data", () => {
      const { track } = require("../lib/telemetry");
      expect(() => track("test_event", null)).not.toThrow();
    });

    test("is disabled by default (no opt-in)", () => {
      const calls = [];
      global.fetch = mock((url) => {
        calls.push(url);
        return Promise.resolve();
      });

      const { track } = require("../lib/telemetry");
      track("test_event");
      expect(calls.length).toBe(0);
    });

    test("sends telemetry when explicitly opted in", async () => {
      process.env.AGENT_REGISTRY_TELEMETRY = "1";
      const calls = [];
      global.fetch = mock((url) => {
        calls.push(url);
        return Promise.resolve();
      });

      const { track } = require("../lib/telemetry");
      track("test_event", { n: 1 });
      await Promise.resolve();
      expect(calls.length).toBe(1);
    });
  });

  describe("opt-out via env vars", () => {
    test("is disabled when AGENT_REGISTRY_NO_TELEMETRY is set", () => {
      process.env.AGENT_REGISTRY_NO_TELEMETRY = "1";
      const { track } = require("../lib/telemetry");
      // Should not make any fetch calls — just verify it doesn't throw
      expect(() => track("test")).not.toThrow();
    });

    test("is disabled when DO_NOT_TRACK is set", () => {
      process.env.DO_NOT_TRACK = "1";
      const { track } = require("../lib/telemetry");
      expect(() => track("test")).not.toThrow();
    });

    test("is disabled in CI environment (GITHUB_ACTIONS)", () => {
      process.env.GITHUB_ACTIONS = "true";
      const { track } = require("../lib/telemetry");
      expect(() => track("test")).not.toThrow();
    });

    test("is disabled in CI environment (CI)", () => {
      process.env.CI = "true";
      const { track } = require("../lib/telemetry");
      expect(() => track("test")).not.toThrow();
    });

    test("is disabled in CI environment (GITLAB_CI)", () => {
      process.env.GITLAB_CI = "true";
      const { track } = require("../lib/telemetry");
      expect(() => track("test")).not.toThrow();
    });

    test("is disabled in CI environment (JENKINS_URL)", () => {
      process.env.JENKINS_URL = "http://jenkins.example.com";
      const { track } = require("../lib/telemetry");
      expect(() => track("test")).not.toThrow();
    });
  });
});


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)