Ship a Claude Code plugin with the haiku-rag skill

claude-plugin/ holds the plugin manifest, the server configuration
(haiku-rag mcp --stdio, the configuration decides the database) and a
skill that says when to reach for the knowledge base and how to move
from a search result to a document, a section, an answer or a
computation. A repo-root marketplace manifest makes
`claude plugin marketplace add ggozad/haiku.rag` work. The skill
pre-approves every tool the server registers, and a test keeps the two
in step.

The manifest carries the package version, which bump_version.py now
rewrites: a versioned plugin updates only on a bump, so the installed
skill stays in step with the haiku-rag release the user has.

Refs #599
This commit is contained in:
Yiorgis Gozadinos 2026-09-04 12:54:30 +03:00
parent f45ed90b33
commit eb9995e9b7
No known key found for this signature in database
10 changed files with 231 additions and 3 deletions

View file

@ -0,0 +1,14 @@
{
"name": "haiku-rag",
"description": "The haiku.rag knowledge base as Claude Code tools and a skill.",
"owner": {
"name": "Yiorgis Gozadinos"
},
"plugins": [
{
"name": "haiku-rag",
"source": "./claude-plugin",
"description": "Search, read and question your haiku.rag knowledge base from Claude Code."
}
]
}

View file

@ -4,6 +4,9 @@
### Added
- Claude Code plugin under `claude-plugin/`: the server configuration and the
`haiku-rag` skill. `claude plugin marketplace add ggozad/haiku.rag`, then
`claude plugin install haiku-rag`.
- `haiku-rag mcp --no-agents` leaves `ask_question` and `analyze`
unregistered. `create_mcp_server(agents=)`, `HaikuRAGApp.run_mcp(agents=)`.
- MCP tools `get_document_outline` (heading tree with page numbers) and

View file

@ -110,12 +110,19 @@ For direct agent composition, see the [capabilities documentation](https://ggoza
## MCP Server
Use with AI assistants like Claude Desktop:
Use with AI assistants like Claude Code and Claude Desktop:
```bash
haiku-rag mcp --stdio
```
In Claude Code, install the plugin, which registers the server and a skill:
```bash
claude plugin marketplace add ggozad/haiku.rag
claude plugin install haiku-rag
```
Add to your Claude Desktop configuration:
```json

View file

@ -0,0 +1,12 @@
{
"name": "haiku-rag",
"version": "0.82.1",
"description": "Search, read and question your haiku.rag knowledge base from Claude Code.",
"author": {
"name": "Yiorgis Gozadinos",
"email": "ggozadinos@gmail.com"
},
"homepage": "https://ggozad.github.io/haiku.rag/mcp/",
"repository": "https://github.com/ggozad/haiku.rag",
"license": "MIT"
}

8
claude-plugin/.mcp.json Normal file
View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"haiku-rag": {
"command": "haiku-rag",
"args": ["mcp", "--stdio"]
}
}
}

View file

@ -0,0 +1,65 @@
---
name: haiku-rag
description: Search, read and question the user's haiku.rag knowledge base
through the haiku-rag MCP tools. Use whenever a request could be answered
from the user's ingested documents, when asked to find, look up, check or
cite something in their documents or knowledge base, or when the question is
about the user's own material rather than general knowledge.
allowed-tools:
- mcp__plugin_haiku-rag_haiku-rag__search_documents
- mcp__plugin_haiku-rag_haiku-rag__search_documents_by_image
- mcp__plugin_haiku-rag_haiku-rag__get_document
- mcp__plugin_haiku-rag_haiku-rag__get_document_outline
- mcp__plugin_haiku-rag_haiku-rag__get_document_section
- mcp__plugin_haiku-rag_haiku-rag__list_documents
- mcp__plugin_haiku-rag_haiku-rag__ask_question
- mcp__plugin_haiku-rag_haiku-rag__analyze
---
# Working with the knowledge base
Check the knowledge base before answering from memory whenever the question
could be about the user's documents. Say so when it has nothing relevant.
## Find
`search_documents` is the first call. Results come best first with the document
title, section headings and the matching passage. `filter` restricts which
documents are searched, `limit` how many results come back. If it misses,
rephrase once or narrow with a filter before concluding the material is not
there.
## Read
Every search result shows its `Document ID` (and `Collection` when there are
several); pass them to the read tools. `get_document` returns a document's
whole text in reading order. For a long one, `get_document_outline` gives the
heading tree with page numbers and `get_document_section` the text of one
section, subsections included.
## Answer or compute
`ask_question` runs the RAG agent on the server and returns an answer with
citations; use it when the user wants an answer rather than material.
`analyze` runs code in a sandbox over the documents; use it for counting,
aggregation, comparison across many documents or computation over tables. Both
cost a model call and are slower than a search.
## Explore
`list_documents` shows what is stored: titles, URIs and metadata. It is how you
learn what a filter can match.
## Filters
A SQL WHERE clause over the document columns `id`, `uri`, `title`,
`created_at`, `updated_at`, `metadata`. `metadata` is a JSON string, so match
it with LIKE: `metadata LIKE '%"author": "Smith"%'`. Also `uri LIKE '%.pdf'`,
`title = 'Q3 report'`.
## Results and citations
Rank is the signal; scores are not comparable across queries and are never
confidence. Cite the document title or URI, the section heading and page
numbers when present. When results carry `source`, the server covers several
collections: name it, and pass `sources` to search a subset.

View file

@ -40,6 +40,25 @@ document. A name the server does not cover is an error.
`haiku-rag --db-name NAME mcp` serves one. See
[Multiple Databases](configuration/storage.md#multiple-databases).
## Claude Code
The repository ships a plugin that registers the server and a skill telling
Claude when and how to use it:
```bash
claude plugin marketplace add ggozad/haiku.rag
claude plugin install haiku-rag
```
The plugin runs `haiku-rag mcp --stdio`, so `haiku-rag` must be on the PATH
and the configuration decides the database. The skill pre-approves every tool
and is also invocable as `/haiku-rag`. To register the server without the
plugin:
```bash
claude mcp add haiku-rag -- haiku-rag mcp --stdio
```
## Claude Desktop Integration
Add to your Claude Desktop configuration (`claude_desktop_config.json`):

View file

@ -2,7 +2,8 @@
"""
Version bumping script for haiku.rag workspace.
Updates version in all pyproject.toml files and CHANGELOG.md.
Updates version in all pyproject.toml files, the Claude Code plugin manifest
and CHANGELOG.md.
"""
import re
@ -54,6 +55,19 @@ def update_example_dependencies(file_path: Path, new_version: str) -> None:
print(f"✓ Updated example dependencies in {file_path.relative_to(Path.cwd())}")
def update_plugin_version(file_path: Path, new_version: str) -> None:
"""Update the version in the Claude Code plugin manifest."""
content = file_path.read_text()
updated = re.sub(
r'^(\s*"version": )"[^"]+"',
rf'\1"{new_version}"',
content,
flags=re.MULTILINE,
)
file_path.write_text(updated)
print(f"✓ Updated {file_path.relative_to(Path.cwd())}")
def update_changelog(changelog_path: Path, new_version: str) -> None:
"""Update CHANGELOG.md with new version."""
content = changelog_path.read_text()
@ -122,10 +136,13 @@ def main():
root / "app" / "backend" / "pyproject.toml",
]
plugin_file = root / "claude-plugin" / ".claude-plugin" / "plugin.json"
changelog_file = root / "CHANGELOG.md"
# Check all files exist
for file in pyproject_files + example_pyproject_files + [changelog_file]:
for file in (
pyproject_files + example_pyproject_files + [plugin_file, changelog_file]
):
if not file.exists():
print(f"Error: {file} not found")
sys.exit(1)
@ -155,6 +172,8 @@ def main():
for file in example_pyproject_files:
update_example_dependencies(file, new_version)
update_plugin_version(plugin_file, new_version)
# Update CHANGELOG.md
update_changelog(changelog_file, new_version)

View file

@ -0,0 +1,38 @@
import importlib.util
import json
from pathlib import Path
_spec = importlib.util.spec_from_file_location(
"bump_version", Path(__file__).resolve().parents[1] / "scripts" / "bump_version.py"
)
assert _spec is not None and _spec.loader is not None
bump_version = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(bump_version)
def test_update_plugin_version_rewrites_only_the_version_field(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
manifest = tmp_path / "plugin.json"
manifest.write_text(
'{\n "name": "haiku-rag",\n "version": "0.1.0",\n "license": "MIT"\n}\n'
)
bump_version.update_plugin_version(manifest, "0.2.0")
assert json.loads(manifest.read_text()) == {
"name": "haiku-rag",
"version": "0.2.0",
"license": "MIT",
}
assert manifest.read_text().endswith("}\n")
def test_the_shipped_plugin_manifest_carries_the_package_version():
root = Path(__file__).resolve().parents[1]
plugin = json.loads(
(root / "claude-plugin" / ".claude-plugin" / "plugin.json").read_text()
)
assert plugin["version"] == bump_version.get_current_version(
root / "haiku_rag_slim" / "pyproject.toml"
)

View file

@ -1,4 +1,5 @@
import logging
from pathlib import Path
from types import SimpleNamespace
import pytest
@ -1122,6 +1123,48 @@ class TestMCPErrorContract:
)
class TestClaudeCodePlugin:
"""The plugin under claude-plugin/ points at the server this module builds."""
root = Path(__file__).resolve().parents[1]
def test_the_manifests_name_the_plugin_and_its_server(self):
import json
plugin = json.loads(
(self.root / "claude-plugin/.claude-plugin/plugin.json").read_text()
)
marketplace = json.loads(
(self.root / ".claude-plugin/marketplace.json").read_text()
)
servers = json.loads((self.root / "claude-plugin/.mcp.json").read_text())
assert plugin["name"] == "haiku-rag"
assert plugin["description"]
[entry] = marketplace["plugins"]
assert entry["name"] == plugin["name"]
assert entry["source"] == "./claude-plugin"
assert servers["mcpServers"]["haiku-rag"]["args"] == ["mcp", "--stdio"]
@pytest.mark.asyncio
async def test_the_skill_pre_approves_every_tool_the_server_registers(
self, mcp_db, multimodal_embedder
):
import yaml
text = (self.root / "claude-plugin/skills/haiku-rag/SKILL.md").read_text()
_, frontmatter, _ = text.split("---", 2)
skill = yaml.safe_load(frontmatter)
prefix = "mcp__plugin_haiku-rag_haiku-rag__"
assert skill["name"] == "haiku-rag"
assert skill["description"]
assert all(tool.startswith(prefix) for tool in skill["allowed-tools"])
approved = {tool.removeprefix(prefix) for tool in skill["allowed-tools"]}
registered = {t.name for t in await create_mcp_server(mcp_db).list_tools()}
assert approved == registered
class TestMCPClientLifetime:
@pytest.mark.asyncio
async def test_tool_calls_share_one_database_open(self, mcp_db, monkeypatch):