# skill-search

LLM フレームワーク非依存のスキル検索ライブラリ。

`skills/` ディレクトリ内の SKILL.md を解析し、[tantivy](https://github.com/quickwit-oss/tantivy) ベースの高速全文検索エンジンで検索可能にする。
OpenAI / Anthropic / LangChain 等、任意の LLM ライブラリから利用可能なツール定義と実行ハンドラを提供する。

[English README](README.md)

## インストール

```bash
pip install skill-search
```

## 使い方

### 基本

```python
from skill_search import SkillSearch

# skills/ ディレクトリを指定して初期化
ss = SkillSearch(skills_dirs=["./skills"])

# LLM に渡すツール定義 (OpenAI function calling 形式)
tools = ss.get_tool_definitions()

# システムプロンプトにスキル一覧を追加
system_prompt = ss.get_system_prompt()

# LLM から呼ばれたツールを実行
result = ss.call_tool("search_skills", {"query": "API reference", "top_k": 3})
```

### OpenAI SDK での使用例

```python
import json
from openai import OpenAI
from skill_search import SkillSearch

client = OpenAI()
ss = SkillSearch(skills_dirs=["./skills"])

messages = [
    {"role": "system", "content": ss.get_system_prompt()},
    {"role": "user", "content": "Figma API の使い方を教えて"},
]

response = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=ss.get_tool_definitions(),
)

# tool_call があればハンドラで実行
for choice in response.choices:
    if choice.message.tool_calls:
        for tc in choice.message.tool_calls:
            result = ss.call_tool(
                tc.function.name,
                json.loads(tc.function.arguments),
            )
            messages.append({"role": "tool", "content": result, "tool_call_id": tc.id})
```

### Anthropic SDK での使用例

```python
from anthropic import Anthropic
from skill_search import SkillSearch

client = Anthropic()
ss = SkillSearch(skills_dirs=["./skills"])

# Anthropic 形式に変換
tools = [
    {
        "name": t["function"]["name"],
        "description": t["function"]["description"],
        "input_schema": t["function"]["parameters"],
    }
    for t in ss.get_tool_definitions()
]

response = client.messages.create(
    model="claude-sonnet-4-20250514",
    system=ss.get_system_prompt(),
    messages=[{"role": "user", "content": "Jira の検索方法は?"}],
    tools=tools,
)

# tool_use があれば実行
for block in response.content:
    if block.type == "tool_use":
        result = ss.call_tool(block.name, block.input)
```

## 提供ツール

| ツール名 | 説明 |
|---|---|
| `list_skills` | 利用可能なスキル一覧を取得 |
| `read_skill` | スキルの SKILL.md 全文を読み込み |
| `search_skills` | 全文検索 (BM25, tantivy) |
| `read_resource` | スキル内の補足リソースファイルを読み込み |

## SKILL.md フォーマット

```markdown
---
name: my-skill
description: スキルの概要説明
---

# My Skill

## 使い方

1. ステップ1
2. ステップ2
```

## 仕組み

1. **Discovery**: `skills/` 配下の SKILL.md を再帰探索し、YAML frontmatter を解析
2. **Indexing**: 全ドキュメントを Markdown 見出し単位でチャンク分割し、tantivy インデックスに登録
3. **Search**: BM25 スコアリングで関連度順に検索結果を返却
4. **Tool Execution**: LLM の tool_call を `call_tool()` で実行し、結果を文字列で返却

## セキュリティ

LLM からのツール呼び出しは信頼できない入力として扱い、多層防御で保護しています。

### パストラバーサル防止

| 防御レイヤー | 場所 | 対策内容 |
|---|---|---|
| Discovery 時 | `discover_resources()` | シンボリックリンクを `resolve()` し、スキルディレクトリ外を指すものは除外 |
| リソース名検証 | `read_resource` ハンドラ | `..` を含む `resource_name` は即座に拒否 |
| パス解決検証 | `read_resource` ハンドラ | ファイルパスを `resolve()` し、スキルディレクトリ外のパスは拒否 |
| ホワイトリスト方式 | `read_resource` ハンドラ | discovery 時に登録されたリソースのみアクセス可能 (任意パスの読み取り不可) |

### 拡張子フィルタ

リソースとして登録されるファイルは以下の拡張子のみ:

`.md`, `.json`, `.yaml`, `.yml`, `.csv`, `.xml`, `.txt`

実行ファイル (`.py`, `.sh`, `.exe` 等) やバイナリファイルはリソースに含まれません。

### 設計原則

- **読み取り専用**: ファイルの読み取りのみ。書き込み・実行機能は提供しない
- **ホワイトリスト方式**: discovery 時に安全が確認されたリソースのみアクセス可能
- **多層防御**: 入力値検証 → パス解決検証 → ディレクトリ境界検証の3段階

## 開発

```bash
uv run pytest tests/ -v
```

## ライセンス

MIT
