diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..24d16eee --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -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." + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a22246e..41411c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9016e322..fb3c8d5b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json new file mode 100644 index 00000000..93282056 --- /dev/null +++ b/claude-plugin/.claude-plugin/plugin.json @@ -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" +} diff --git a/claude-plugin/.mcp.json b/claude-plugin/.mcp.json new file mode 100644 index 00000000..f6fb25f9 --- /dev/null +++ b/claude-plugin/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "haiku-rag": { + "command": "haiku-rag", + "args": ["mcp", "--stdio"] + } + } +} diff --git a/claude-plugin/skills/haiku-rag/SKILL.md b/claude-plugin/skills/haiku-rag/SKILL.md new file mode 100644 index 00000000..f93631a3 --- /dev/null +++ b/claude-plugin/skills/haiku-rag/SKILL.md @@ -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. diff --git a/docs/mcp.md b/docs/mcp.md index 59093b3b..cbf08999 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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`): diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 5c5a1e2e..9593cd89 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -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) diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py new file mode 100644 index 00000000..4f8fcf4f --- /dev/null +++ b/tests/test_bump_version.py @@ -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" + ) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 7bf3183d..4f1160b8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -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):