diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b0c089a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt -r requirements-dev.txt + - run: ruff check . + - run: pytest diff --git a/README.md b/README.md index df671f2..b2e670a 100644 --- a/README.md +++ b/README.md @@ -76,19 +76,47 @@ tools=[{ ## Claude Code hooks -`hooks/` contains PostToolUse hook scripts that log agent activity into ai-data-store automatically, and `scripts/install_hook.sh` installs one into your global `~/.claude/settings.json`: +`hooks/log_artifact_entry.py` is a generic PostToolUse hook that logs JSON artifacts produced by agent scripts into ai-data-store automatically. Install it once globally: ```bash -./scripts/install_hook.sh log_finance_entry.py +./scripts/install_hook.sh ``` This copies the script to `~/.claude/hooks/` and registers it as a `command`-type `PostToolUse` hook, without touching any other hooks or settings already there — safe to re-run. It requires the `ai-data-store` MCP server to already be configured in `~/.claude.json` (see Laptop setup above), since the hook reads that config to find the server URL and API key. -Run it with no argument to install the default `log_finance_entry.py`, or name a different script in `hooks/` to install that one instead. Hooks are self-filtering — each one only acts on the tool calls it recognizes and is a silent no-op otherwise, so it's safe to have installed even outside the project it targets. +The hook fires on every tool call in every project, but only acts on Bash calls in projects that **opt in** by having a `.ai-data-store.json` config at their root; everywhere else it's a silent no-op. The convention: an artifact-producing script prints just the path of the JSON file it wrote, and the hook picks it up from stdout the moment it's created. -| Hook | Fires on | Logs | -|------|----------|------| -| `log_finance_entry.py` | Bash calls in `finance-agent` whose stdout is the path to a known `skills/*/tmp/*.json` artifact (verdict, debate, panel, check_stock, check_crypto, price history, portfolio, news) — every artifact script in that repo prints just this path, so the hook reads the file straight off disk | A ticker/stage-aware description and keywords built from the artifact's content, e.g. `Verdict - MSFT (bear, medium confidence)` | +The config declares how artifacts become entries — regex rules over the artifact path, with `str.format` templates for the description and keywords. Named regex groups (`{ticker}`) and dotted paths into the artifact JSON (`{content.verdict.winner}`) are both available; `description` takes a list of templates and uses the first whose fields all resolve, so richer descriptions degrade gracefully: + +```json +{ + "source": "finance-agent", + "rules": [ + { + "pattern": "/tmp/verdict_(?P[^_]+)_[^/]*\\.json$", + "description": [ + "Verdict - {content.symbol} ({content.verdict.winner}, {content.verdict.confidence} confidence)", + "Verdict - {ticker}" + ], + "keywords": ["{ticker}", "verdict"] + } + ] +} +``` + +Ready-made configs live in `hooks/configs/` — copy one to the target project's root: + +```bash +cp hooks/configs/finance-agent.json /.ai-data-store.json +``` + +**Migrating from `log_finance_entry.py`** (the old finance-only hook): uninstall it, install the generic hook, and drop the config into the finance-agent repo: + +```bash +./scripts/uninstall_hook.sh log_finance_entry.py +./scripts/install_hook.sh +cp hooks/configs/finance-agent.json /.ai-data-store.json +``` ## MCP tools @@ -111,4 +139,14 @@ curl -X POST http://:8742/entries \ -d '{"source": "finance-agent", "description": "Verdict - MSFT", "keywords": ["MSFT", "verdict"], "data": {}}' ``` -Returns the created entry (`201`) or a `400` with an error message for a missing field or invalid JSON body. +Returns the created entry (`201`) or a `400` with an error message for a missing or wrongly-typed field or invalid JSON body. + +## Development + +```bash +pip install -r requirements.txt -r requirements-dev.txt +ruff check . +pytest +``` + +Tests run against an in-memory MongoDB (`mongomock-motor`) — no containers needed. CI runs the same two commands on every push and PR. diff --git a/app/database.py b/app/database.py index 8310426..ffd5f30 100644 --- a/app/database.py +++ b/app/database.py @@ -1,4 +1,5 @@ from motor.motor_asyncio import AsyncIOMotorClient + from app.config import settings _client: AsyncIOMotorClient | None = None @@ -15,6 +16,13 @@ def get_db(): return get_client()[settings.mongodb_db] +async def ensure_indexes(): + db = get_db() + await db.entries.create_index([("created_at", -1)]) + await db.entries.create_index("source") + await db.entries.create_index("keywords") + + async def close_client(): global _client if _client is not None: diff --git a/app/formatting.py b/app/formatting.py index 3d46df6..3351374 100644 --- a/app/formatting.py +++ b/app/formatting.py @@ -13,5 +13,5 @@ def format_doc(doc: dict) -> dict: def parse_id(entry_id: str) -> ObjectId: try: return ObjectId(entry_id) - except (InvalidId, Exception): - raise ValueError(f"Invalid entry ID: {entry_id}") + except (InvalidId, TypeError): + raise ValueError(f"Invalid entry ID: {entry_id}") from None diff --git a/app/server.py b/app/server.py index 033dc01..680ea07 100644 --- a/app/server.py +++ b/app/server.py @@ -1,19 +1,24 @@ +import secrets from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any import uvicorn from fastmcp import FastMCP +from pydantic import BaseModel, ValidationError from starlette.requests import Request from starlette.responses import JSONResponse from app.config import settings -from app.database import close_client, get_db +from app.database import close_client, ensure_indexes, get_db from app.formatting import format_doc, parse_id +MAX_LIMIT = 100 + @asynccontextmanager async def lifespan(server: FastMCP): + await ensure_indexes() yield await close_client() @@ -31,7 +36,8 @@ async def __call__(self, scope, receive, send): if scope["type"] == "http": headers = dict(scope.get("headers", [])) auth = headers.get(b"authorization", b"").decode() - if not auth.startswith("Bearer ") or auth[7:] != settings.api_key: + token = auth[7:] if auth.startswith("Bearer ") else "" + if not secrets.compare_digest(token.encode(), settings.api_key.encode()): response = JSONResponse({"error": "Unauthorized"}, status_code=401) await response(scope, receive, send) return @@ -42,14 +48,14 @@ async def _create_entry( source: str, description: str, data: dict[str, Any], - keywords: list[str] = [], + keywords: list[str] | None = None, ) -> dict: db = get_db() - now = datetime.now(timezone.utc) + now = datetime.now(UTC) doc = { "source": source, "description": description, - "keywords": keywords, + "keywords": keywords or [], "data": data, "created_at": now, "updated_at": now, @@ -64,7 +70,7 @@ async def create_entry( source: str, description: str, data: dict[str, Any], - keywords: list[str] = [], + keywords: list[str] | None = None, ) -> dict: """Store a new data entry produced by an AI agent.""" return await _create_entry(source, description, data, keywords) @@ -78,6 +84,8 @@ async def list_entries( skip: int = 0, ) -> dict: """List stored entries. Optionally filter by source agent or keywords.""" + limit = max(1, min(limit, MAX_LIMIT)) + skip = max(0, skip) db = get_db() query: dict = {} if source: @@ -121,7 +129,7 @@ async def update_entry( if not updates: raise ValueError("No fields provided to update") db = get_db() - updates["updated_at"] = datetime.now(timezone.utc) + updates["updated_at"] = datetime.now(UTC) result = await db.entries.find_one_and_update( {"_id": parse_id(entry_id)}, {"$set": updates}, @@ -142,6 +150,13 @@ async def delete_entry(entry_id: str) -> dict: return {"deleted": True, "id": entry_id} +class EntryPayload(BaseModel): + source: str + description: str + data: dict[str, Any] + keywords: list[str] = [] + + async def create_entry_endpoint(request: Request) -> JSONResponse: """REST equivalent of create_entry, for callers that can't speak MCP (e.g. hook scripts).""" try: @@ -150,17 +165,19 @@ async def create_entry_endpoint(request: Request) -> JSONResponse: return JSONResponse({"error": "Invalid JSON body"}, status_code=400) try: - source = body["source"] - description = body["description"] - data = body["data"] - except KeyError as e: - return JSONResponse({"error": f"Missing required field: {e.args[0]}"}, status_code=400) + payload = EntryPayload.model_validate(body) + except ValidationError as e: + errors = "; ".join( + f"{'.'.join(str(loc) for loc in err['loc']) or 'body'}: {err['msg']}" + for err in e.errors() + ) + return JSONResponse({"error": errors}, status_code=400) entry = await _create_entry( - source=source, - description=description, - data=data, - keywords=body.get("keywords", []), + source=payload.source, + description=payload.description, + data=payload.data, + keywords=payload.keywords, ) return JSONResponse(entry, status_code=201) diff --git a/hooks/configs/finance-agent.json b/hooks/configs/finance-agent.json new file mode 100644 index 0000000..5b9f4d2 --- /dev/null +++ b/hooks/configs/finance-agent.json @@ -0,0 +1,76 @@ +{ + "source": "finance-agent", + "rules": [ + { + "pattern": "/skills/(?P[^/]+)/tmp/verdict_(?P[^_]+)_[^/]*\\.json$", + "description": [ + "Verdict - {content.symbol} ({content.verdict.winner}, {content.verdict.confidence} confidence)", + "Verdict - {ticker}" + ], + "keywords": ["{ticker}", "{skill}", "verdict"] + }, + { + "pattern": "/skills/(?P[^/]+)/tmp/debate_(?P[^_]+)_[^/]*\\.json$", + "description": [ + "Debate - {content.symbol} ({content.rounds} rounds)", + "Debate - {ticker}" + ], + "keywords": ["{ticker}", "{skill}", "debate"] + }, + { + "pattern": "/skills/(?P[^/]+)/tmp/panel_(?P[^_]+)_[^/]*\\.json$", + "description": [ + "Panel analysis - {content.symbol}", + "Panel analysis - {ticker}" + ], + "keywords": ["{ticker}", "{skill}", "panel"] + }, + { + "pattern": "/skills/(?P[^/]+)/tmp/check_stock_(?P[^_]+)_[^/]*\\.json$", + "description": [ + "{content.name} ({content.symbol}) - ${content.price:,.2f} ({content.change_percent:+.2f}%)", + "{content.name} ({content.symbol}) price check", + "{ticker} price check" + ], + "keywords": ["{ticker}", "{skill}", "check_stock"] + }, + { + "pattern": "/skills/(?P[^/]+)/tmp/check_crypto_(?P[^_]+)_[^/]*\\.json$", + "description": [ + "{content.name} ({content.symbol}) - ${content.price:,.2f} ({content.change_percent_24h:+.2f}% 24h)", + "{content.name} ({content.symbol}) price check", + "{ticker} price check" + ], + "keywords": ["{ticker}", "{skill}", "check_crypto"] + }, + { + "pattern": "/skills/(?P[^/]+)/tmp/history_(?P[^_]+)_(?P[^_]+)_[^/]*\\.json$", + "description": "Price history - {ticker}", + "keywords": ["{ticker}", "{skill}", "history", "{period}"] + }, + { + "pattern": "/skills/(?P[^/]+)/tmp/portfolio_[^/]*\\.json$", + "description": [ + "Portfolio snapshot - {content.account_summary.currency} {content.account_summary.totalValue:,.2f}", + "Portfolio snapshot" + ], + "keywords": ["{skill}"] + }, + { + "pattern": "/skills/(?P[^/]+)/tmp/news_results_[^/]*\\.json$", + "description": [ + "News search - {content_length} articles", + "News search" + ], + "keywords": ["{skill}", "news_results"] + }, + { + "pattern": "/skills/(?P[^/]+)/tmp/analysis_[^/]*\\.json$", + "description": [ + "News analysis - {content_length} articles ranked", + "News analysis" + ], + "keywords": ["{skill}", "analysis"] + } + ] +} diff --git a/hooks/log_artifact_entry.py b/hooks/log_artifact_entry.py new file mode 100644 index 0000000..3d77b40 --- /dev/null +++ b/hooks/log_artifact_entry.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""PostToolUse hook: logs JSON artifacts from any opted-in project to ai-data-store. + +Generic successor to log_finance_entry.py. Registered globally in +~/.claude/settings.json so it fires on every tool call in every project, but +it only acts on Bash calls in projects that opt in by having a +.ai-data-store.json config at their root (searched upward from the session +cwd). The artifact convention is unchanged: a script that produces an +artifact prints just the path of the JSON file it wrote, and the hook picks +it up from stdout the moment it's created. Everything else is a silent +no-op, so it adds no noise to the store and negligible latency to unrelated +tool calls. + +Config format — templates use str.format syntax, where dotted names traverse +into the artifact JSON ({content.verdict.winner}) and regex groups from the +rule's pattern are available by name ({ticker}): + + { + "source": "finance-agent", // optional, defaults to the project dir name + "rules": [ + { + "pattern": "/tmp/verdict_(?P[^_]+)_[^/]*\\.json$", + "description": [ + "Verdict - {content.symbol} ({content.verdict.winner} wins)", + "Verdict - {ticker}" + ], + "keywords": ["{ticker}", "verdict"] + } + ] + } + +The first rule whose pattern matches the artifact path wins. "description" +is a template or a list of them — the first whose fields all resolve is +used, falling back to the artifact filename. Keyword templates that fail to +resolve are dropped, and duplicates are removed. {content_length} is +available when the artifact's top level is a list, {filename} always. +""" +import json +import os +import re +import string +import sys +import urllib.request + +CONFIG_NAME = ".ai-data-store.json" + + +class _DotFormatter(string.Formatter): + """str.format where {a.b.c} traverses nested dicts/lists and None counts as missing.""" + + def get_field(self, field_name, args, kwargs): + obj = kwargs + for part in field_name.split("."): + if isinstance(obj, dict) and part in obj: + obj = obj[part] + elif isinstance(obj, list) and part.isdigit() and int(part) < len(obj): + obj = obj[int(part)] + else: + raise KeyError(field_name) + if obj is None: + raise KeyError(field_name) + return obj, field_name + + +_formatter = _DotFormatter() + + +def render(template: str, context: dict) -> str | None: + """Render one template, or None if any field is missing or unformattable.""" + try: + return _formatter.format(template, **context) + except (KeyError, IndexError, ValueError, TypeError): + return None + + +def find_project_root(start: str) -> str | None: + """Walk up from the session cwd to the nearest directory holding a config.""" + path = os.path.realpath(start) + while True: + if os.path.isfile(os.path.join(path, CONFIG_NAME)): + return path + parent = os.path.dirname(path) + if parent == path: + return None + path = parent + + +def build_entry(config: dict, source: str, file_path: str, content, event: dict) -> dict | None: + for rule in config.get("rules", []): + match = re.search(rule["pattern"], file_path) + if match: + break + else: + return None + + context = {k: v for k, v in match.groupdict().items() if v is not None} + context["content"] = content + context["filename"] = os.path.basename(file_path) + if isinstance(content, list): + context["content_length"] = len(content) + + templates = rule.get("description", []) + if isinstance(templates, str): + templates = [templates] + description = next( + (d for d in (render(t, context) for t in templates) if d is not None), + os.path.basename(file_path), + ) + + keywords = [] + for template in rule.get("keywords", []): + keyword = render(template, context) + if keyword and keyword not in keywords: + keywords.append(keyword) + + return { + "source": source, + "description": description, + "keywords": keywords, + "data": { + "tool_name": event.get("tool_name"), + "tool_input": event.get("tool_input"), + "artifact_path": file_path, + "artifact": content, + }, + } + + +def load_ingest_config() -> tuple[str, str]: + with open(os.path.expanduser("~/.claude.json")) as f: + cfg = json.load(f) + server = cfg["mcpServers"]["ai-data-store"] + base_url = server["url"].rsplit("/sse", 1)[0] + token = server["headers"]["Authorization"].split(" ", 1)[1] + return f"{base_url}/entries", token + + +def post_entry(url: str, token: str, payload: dict) -> None: + req = urllib.request.Request( + url, + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + resp.read() + + +def main() -> None: + event = json.load(sys.stdin) + + if event.get("tool_name") != "Bash": + return + cwd = event.get("cwd") or "" + if not cwd: + return + + tool_response = event.get("tool_response") or {} + if isinstance(tool_response, str): + try: + tool_response = json.loads(tool_response) + except json.JSONDecodeError: + return + stdout = (tool_response.get("stdout") or "").strip() + if not stdout: + return + raw_path = stdout.splitlines()[0].strip() + if not raw_path.endswith(".json"): + return + file_path = os.path.realpath(raw_path if os.path.isabs(raw_path) else os.path.join(cwd, raw_path)) + + root = find_project_root(cwd) + if root is None: + return + # Only log artifacts that live inside the opted-in project + if os.path.commonpath([root, file_path]) != root: + return + + with open(os.path.join(root, CONFIG_NAME)) as f: + config = json.load(f) + + try: + with open(file_path) as f: + content = json.load(f) + except (OSError, json.JSONDecodeError): + return + + source = config.get("source") or os.path.basename(root) + entry = build_entry(config, source, file_path, content, event) + if entry is None: + return + + url, token = load_ingest_config() + post_entry(url, token, entry) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"log_artifact_entry hook failed: {e}", file=sys.stderr) diff --git a/hooks/log_finance_entry.py b/hooks/log_finance_entry.py deleted file mode 100644 index 7d222cd..0000000 --- a/hooks/log_finance_entry.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -"""PostToolUse hook: logs finance-agent artifacts to ai-data-store as they're produced. - -Registered globally in ~/.claude/settings.json so it fires on every tool call -in every project, but it only acts on a Bash call inside finance-agent whose -stdout is the path to a known artifact under skills/*/tmp/ — every artifact -script in this repo follows the convention of printing just that path, so -this fires deterministically the moment an artifact is created, regardless -of whether the agent later reads it back into its own context. (An earlier -version hooked Read instead, which missed panel/debate stages entirely, -since the trader skill only re-reads the final verdict file.) Everything -else is a silent no-op, so it adds no noise to the store and negligible -latency to unrelated tool calls. -""" -import json -import os -import re -import sys -import urllib.request - -TICKER_PREFIXES = {"verdict", "debate", "panel", "check_stock", "check_crypto", "history"} -NO_TICKER_PREFIXES = {"portfolio", "news_results", "analysis"} -ALL_PREFIXES = TICKER_PREFIXES | NO_TICKER_PREFIXES - -ARTIFACT_RE = re.compile(r"/skills/(?P[^/]+)/tmp/(?P[^/]+\.json)$") - - -def match_prefix(filename: str) -> str | None: - for prefix in sorted(ALL_PREFIXES, key=len, reverse=True): - if filename.startswith(prefix + "_"): - return prefix - return None - - -def describe(prefix: str, ticker: str | None, content) -> str: - if prefix == "verdict": - symbol = content.get("symbol", ticker) - v = content.get("verdict", {}) - return f"Verdict - {symbol} ({v.get('winner', '?')}, {v.get('confidence', '?')} confidence)" - if prefix == "debate": - symbol = content.get("symbol", ticker) - return f"Debate - {symbol} ({content.get('rounds', '?')} rounds)" - if prefix == "panel": - return f"Panel analysis - {content.get('symbol', ticker)}" - if prefix == "check_stock": - name, symbol = content.get("name", ticker), content.get("symbol", ticker) - price, pct = content.get("price"), content.get("change_percent") - if price is not None and pct is not None: - return f"{name} ({symbol}) - ${price:,.2f} ({pct:+.2f}%)" - return f"{name} ({symbol}) price check" - if prefix == "check_crypto": - name, symbol = content.get("name", ticker), content.get("symbol", ticker) - price, pct = content.get("price"), content.get("change_percent_24h") - if price is not None and pct is not None: - return f"{name} ({symbol}) - ${price:,.2f} ({pct:+.2f}% 24h)" - return f"{name} ({symbol}) price check" - if prefix == "history": - return f"Price history - {ticker}" - if prefix == "portfolio": - summary = content.get("account_summary", {}) - total = summary.get("totalValue") - if total is not None: - return f"Portfolio snapshot - {summary.get('currency', '')} {total:,.2f}" - return "Portfolio snapshot" - if prefix == "news_results": - return f"News search - {len(content)} articles" if isinstance(content, list) else "News search" - if prefix == "analysis": - return f"News analysis - {len(content)} articles ranked" if isinstance(content, list) else "News analysis" - return prefix - - -def build_keywords(prefix: str, skill_dir: str, ticker: str | None, period: str | None) -> list[str]: - keywords = [ticker] if ticker else [] - keywords.append(skill_dir) - if prefix != skill_dir: - keywords.append(prefix) - if period: - keywords.append(period) - return keywords - - -def load_ingest_config() -> tuple[str, str]: - with open(os.path.expanduser("~/.claude.json")) as f: - cfg = json.load(f) - server = cfg["mcpServers"]["ai-data-store"] - base_url = server["url"].rsplit("/sse", 1)[0] - token = server["headers"]["Authorization"].split(" ", 1)[1] - return f"{base_url}/entries", token - - -def post_entry(url: str, token: str, payload: dict) -> None: - req = urllib.request.Request( - url, - data=json.dumps(payload).encode(), - headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=5) as resp: - resp.read() - - -def main() -> None: - event = json.load(sys.stdin) - - if os.path.basename(event.get("cwd", "")) != "finance-agent": - return - if event.get("tool_name") != "Bash": - return - - tool_response = event.get("tool_response") or {} - if isinstance(tool_response, str): - tool_response = json.loads(tool_response) - stdout = (tool_response.get("stdout") or "").strip() - if not stdout: - return - file_path = stdout.splitlines()[0].strip() - - match = ARTIFACT_RE.search(file_path) - if not match: - return - skill_dir, filename = match["skill_dir"], match["filename"] - - prefix = match_prefix(filename) - if not prefix: - return - - rest = filename[len(prefix) + 1:].removesuffix(".json") - parts = rest.split("_") - ticker = parts[0] if prefix in TICKER_PREFIXES else None - period = parts[1] if prefix == "history" and len(parts) > 1 else None - - try: - with open(file_path) as f: - content = json.load(f) - except (OSError, json.JSONDecodeError): - return - - entry = { - "source": "finance-agent", - "description": describe(prefix, ticker, content), - "keywords": build_keywords(prefix, skill_dir, ticker, period), - "data": { - "tool_name": event.get("tool_name"), - "tool_input": event.get("tool_input"), - "artifact_path": file_path, - "artifact": content, - }, - } - - url, token = load_ingest_config() - post_entry(url, token, entry) - - -if __name__ == "__main__": - try: - main() - except Exception as e: - print(f"log_finance_entry hook failed: {e}", file=sys.stderr) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a255430 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9f233e4 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest==8.3.4 +pytest-asyncio==0.25.2 +mongomock-motor==0.0.36 +httpx==0.28.1 +ruff==0.9.4 diff --git a/scripts/install_hook.sh b/scripts/install_hook.sh index 0da6093..0fced26 100755 --- a/scripts/install_hook.sh +++ b/scripts/install_hook.sh @@ -2,7 +2,7 @@ set -e REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -HOOK_NAME="${1:-log_finance_entry.py}" +HOOK_NAME="${1:-log_artifact_entry.py}" HOOK_SRC="$REPO_ROOT/hooks/$HOOK_NAME" HOOK_DEST_DIR="$HOME/.claude/hooks" HOOK_DEST="$HOOK_DEST_DIR/$HOOK_NAME" diff --git a/scripts/uninstall_hook.sh b/scripts/uninstall_hook.sh new file mode 100755 index 0000000..1f76367 --- /dev/null +++ b/scripts/uninstall_hook.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -e + +HOOK_NAME="${1:?usage: uninstall_hook.sh }" +HOOK_DEST="$HOME/.claude/hooks/$HOOK_NAME" +SETTINGS="$HOME/.claude/settings.json" + +python3 - "$SETTINGS" "$HOOK_DEST" <<'PYEOF' +import json +import sys + +settings_path, hook_path = sys.argv[1], sys.argv[2] +command = f"python3 {hook_path}" + +try: + with open(settings_path) as f: + settings = json.load(f) +except FileNotFoundError: + print("✓ No settings file, nothing to unregister") + sys.exit(0) + +groups = settings.get("hooks", {}).get("PostToolUse", []) +kept = [] +removed = False +for group in groups: + hooks = [ + h for h in group.get("hooks", []) + if not (h.get("type") == "command" and h.get("command") == command) + ] + if len(hooks) != len(group.get("hooks", [])): + removed = True + if hooks: + kept.append({**group, "hooks": hooks}) + +if removed: + settings["hooks"]["PostToolUse"] = kept + with open(settings_path, "w") as f: + json.dump(settings, f, indent=2) + f.write("\n") + print(f"✓ Unregistered PostToolUse hook from {settings_path}") +else: + print("✓ Hook was not registered in PostToolUse, nothing to unregister") +PYEOF + +if [ -f "$HOOK_DEST" ]; then + rm "$HOOK_DEST" + echo "✓ Removed $HOOK_DEST" +fi diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..04bdeb4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import os + +os.environ.setdefault("API_KEY", "test-api-key") +os.environ.setdefault("UI_USERNAME", "admin") +os.environ.setdefault("UI_PASSWORD", "test-password") + +import pytest +from mongomock_motor import AsyncMongoMockClient + +from app import database + + +@pytest.fixture(autouse=True) +def mock_db(monkeypatch): + """Back every test with a fresh in-memory MongoDB.""" + client = AsyncMongoMockClient() + monkeypatch.setattr(database, "_client", client) + yield database.get_db() diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..079fb82 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,11 @@ +from app import database + + +async def test_ensure_indexes_creates_expected_indexes(mock_db): + await database.ensure_indexes() + + index_info = await mock_db.entries.index_information() + indexed_keys = {tuple(info["key"]) for info in index_info.values()} + assert (("created_at", -1),) in indexed_keys + assert (("source", 1),) in indexed_keys + assert (("keywords", 1),) in indexed_keys diff --git a/tests/test_formatting.py b/tests/test_formatting.py new file mode 100644 index 0000000..d8d9f73 --- /dev/null +++ b/tests/test_formatting.py @@ -0,0 +1,40 @@ +from datetime import UTC, datetime + +import pytest +from bson import ObjectId + +from app.formatting import format_doc, parse_id + + +def test_format_doc_converts_id_and_timestamps(): + oid = ObjectId() + now = datetime(2026, 8, 1, 12, 0, 0, tzinfo=UTC) + doc = {"_id": oid, "source": "x", "created_at": now, "updated_at": now} + + out = format_doc(doc) + + assert out["id"] == str(oid) + assert "_id" not in out + assert out["created_at"] == "2026-08-01T12:00:00+00:00" + assert out["updated_at"] == "2026-08-01T12:00:00+00:00" + + +def test_format_doc_does_not_mutate_input(): + doc = { + "_id": ObjectId(), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), + } + format_doc(doc) + assert "_id" in doc + + +def test_parse_id_roundtrip(): + oid = ObjectId() + assert parse_id(str(oid)) == oid + + +@pytest.mark.parametrize("bad", ["not-an-id", "", "68571a2c", 123]) +def test_parse_id_invalid_raises_value_error(bad): + with pytest.raises(ValueError, match="Invalid entry ID"): + parse_id(bad) diff --git a/tests/test_hook.py b/tests/test_hook.py new file mode 100644 index 0000000..b7150d7 --- /dev/null +++ b/tests/test_hook.py @@ -0,0 +1,235 @@ +import importlib.util +import io +import json +from pathlib import Path + +import pytest + +HOOKS_DIR = Path(__file__).parent.parent / "hooks" +FINANCE_CONFIG = HOOKS_DIR / "configs" / "finance-agent.json" + +spec = importlib.util.spec_from_file_location( + "log_artifact_entry", HOOKS_DIR / "log_artifact_entry.py" +) +hook = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hook) + + +# --- template engine --- + + +def test_render_plain_and_group_fields(): + assert hook.render("Verdict - {ticker}", {"ticker": "MSFT"}) == "Verdict - MSFT" + assert hook.render("static text", {}) == "static text" + + +def test_render_dotted_paths_and_format_specs(): + ctx = {"content": {"price": 512.5, "verdict": {"winner": "bear"}}} + assert hook.render("${content.price:,.2f}", ctx) == "$512.50" + assert hook.render("{content.verdict.winner}", ctx) == "bear" + + +def test_render_list_index(): + ctx = {"content": {"articles": [{"title": "first"}]}} + assert hook.render("{content.articles.0.title}", ctx) == "first" + + +def test_render_returns_none_for_missing_or_bad_fields(): + assert hook.render("{missing}", {}) is None + assert hook.render("{content.nope}", {"content": {}}) is None + assert hook.render("{content.price:,.2f}", {"content": {"price": "not a number"}}) is None + assert hook.render("{ticker}", {"ticker": None}) is None + + +# --- finance-agent config, end to end through main() --- + + +@pytest.fixture +def project(tmp_path): + """A finance-agent repo layout opted in with the shipped config.""" + root = tmp_path / "finance-agent" + (root / "skills" / "trader" / "tmp").mkdir(parents=True) + (root / ".ai-data-store.json").write_text(FINANCE_CONFIG.read_text()) + return root + + +def write_artifact(root, filename, content, skill="trader"): + tmp_dir = root / "skills" / skill / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + path = tmp_dir / filename + path.write_text(json.dumps(content)) + return path + + +def run_main(monkeypatch, event): + posted = [] + monkeypatch.setattr(hook, "load_ingest_config", lambda: ("http://test/entries", "tok")) + monkeypatch.setattr(hook, "post_entry", lambda url, token, payload: posted.append(payload)) + monkeypatch.setattr(hook.sys, "stdin", io.StringIO(json.dumps(event))) + hook.main() + return posted + + +def bash_event(cwd, path): + return { + "cwd": str(cwd), + "tool_name": "Bash", + "tool_input": {"command": "python some_script.py"}, + "tool_response": {"stdout": f"{path}\n"}, + } + + +def test_verdict_artifact(monkeypatch, project): + path = write_artifact( + project, + "verdict_MSFT_20260801_122634.json", + {"symbol": "MSFT", "verdict": {"winner": "bear", "confidence": "medium"}}, + ) + + posted = run_main(monkeypatch, bash_event(project, path)) + + assert len(posted) == 1 + entry = posted[0] + assert entry["source"] == "finance-agent" + assert entry["description"] == "Verdict - MSFT (bear, medium confidence)" + assert entry["keywords"] == ["MSFT", "trader", "verdict"] + assert entry["data"]["artifact"]["symbol"] == "MSFT" + assert entry["data"]["artifact_path"] == str(path) + + +def test_verdict_falls_back_when_content_is_sparse(monkeypatch, project): + path = write_artifact(project, "verdict_MSFT_20260801.json", {"unexpected": "shape"}) + + posted = run_main(monkeypatch, bash_event(project, path)) + + assert posted[0]["description"] == "Verdict - MSFT" + + +def test_check_stock_with_and_without_price(monkeypatch, project): + with_price = write_artifact( + project, + "check_stock_MSFT_20260801.json", + {"name": "Microsoft", "symbol": "MSFT", "price": 512.5, "change_percent": -1.25}, + skill="check", + ) + without_price = write_artifact( + project, + "check_stock_AAPL_20260801.json", + {"name": "Apple", "symbol": "AAPL"}, + skill="check", + ) + + entry = run_main(monkeypatch, bash_event(project, with_price))[0] + assert entry["description"] == "Microsoft (MSFT) - $512.50 (-1.25%)" + assert entry["keywords"] == ["MSFT", "check", "check_stock"] + + entry = run_main(monkeypatch, bash_event(project, without_price))[0] + assert entry["description"] == "Apple (AAPL) price check" + + +def test_portfolio_snapshot(monkeypatch, project): + path = write_artifact( + project, + "portfolio_20260801.json", + {"account_summary": {"totalValue": 12345.67, "currency": "GBP"}}, + skill="portfolio", + ) + + entry = run_main(monkeypatch, bash_event(project, path))[0] + assert entry["description"] == "Portfolio snapshot - GBP 12,345.67" + assert entry["keywords"] == ["portfolio"] + + +def test_history_includes_period_keyword(monkeypatch, project): + path = write_artifact(project, "history_AAPL_1mo_20260801.json", []) + + entry = run_main(monkeypatch, bash_event(project, path))[0] + assert entry["description"] == "Price history - AAPL" + assert entry["keywords"] == ["AAPL", "trader", "history", "1mo"] + + +def test_news_results_counts_articles(monkeypatch, project): + path = write_artifact(project, "news_results_20260801.json", [1, 2, 3], skill="news") + + entry = run_main(monkeypatch, bash_event(project, path))[0] + assert entry["description"] == "News search - 3 articles" + assert entry["keywords"] == ["news", "news_results"] + + +def test_source_defaults_to_project_dir_name(monkeypatch, project): + config = json.loads((project / ".ai-data-store.json").read_text()) + del config["source"] + (project / ".ai-data-store.json").write_text(json.dumps(config)) + path = write_artifact(project, "history_AAPL_1mo_20260801.json", []) + + entry = run_main(monkeypatch, bash_event(project, path))[0] + assert entry["source"] == "finance-agent" + + +def test_config_found_from_subdirectory_cwd(monkeypatch, project): + path = write_artifact(project, "history_AAPL_1mo_20260801.json", []) + + posted = run_main(monkeypatch, bash_event(project / "skills" / "trader", path)) + assert len(posted) == 1 + + +# --- no-op paths --- + + +def test_ignores_projects_without_config(monkeypatch, tmp_path): + root = tmp_path / "some-other-project" + (root / "skills" / "trader" / "tmp").mkdir(parents=True) + path = write_artifact(root, "verdict_MSFT_20260801.json", {"symbol": "MSFT"}) + + assert run_main(monkeypatch, bash_event(root, path)) == [] + + +def test_ignores_artifacts_outside_the_project(monkeypatch, project, tmp_path): + outside = tmp_path / "elsewhere" + (outside / "skills" / "trader" / "tmp").mkdir(parents=True) + path = write_artifact(outside, "verdict_MSFT_20260801.json", {"symbol": "MSFT"}) + + assert run_main(monkeypatch, bash_event(project, path)) == [] + + +def test_ignores_non_bash_tools(monkeypatch, project): + path = write_artifact(project, "verdict_MSFT_20260801.json", {"symbol": "MSFT"}) + event = {**bash_event(project, path), "tool_name": "Read"} + + assert run_main(monkeypatch, event) == [] + + +def test_ignores_unmatched_filenames(monkeypatch, project): + path = write_artifact(project, "random_output.json", {}) + + assert run_main(monkeypatch, bash_event(project, path)) == [] + + +def test_ignores_non_artifact_stdout(monkeypatch, project): + event = bash_event(project, "") + event["tool_response"] = {"stdout": "total 4\n-rw-r--r-- 1 user user 0 file.txt\n"} + + assert run_main(monkeypatch, event) == [] + + +def test_ignores_missing_artifact_file(monkeypatch, project): + path = write_artifact(project, "verdict_MSFT_20260801.json", {"symbol": "MSFT"}) + path.unlink() + + assert run_main(monkeypatch, bash_event(project, path)) == [] + + +def test_ignores_invalid_artifact_json(monkeypatch, project): + path = project / "skills" / "trader" / "tmp" / "verdict_MSFT_20260801.json" + path.write_text("not json {") + + assert run_main(monkeypatch, bash_event(project, path)) == [] + + +def test_relative_artifact_path_resolves_against_cwd(monkeypatch, project): + write_artifact(project, "verdict_MSFT_20260801.json", {"symbol": "MSFT"}) + event = bash_event(project, "skills/trader/tmp/verdict_MSFT_20260801.json") + + posted = run_main(monkeypatch, event) + assert len(posted) == 1 + assert posted[0]["description"] == "Verdict - MSFT" diff --git a/tests/test_rest_endpoint.py b/tests/test_rest_endpoint.py new file mode 100644 index 0000000..9948021 --- /dev/null +++ b/tests/test_rest_endpoint.py @@ -0,0 +1,76 @@ +import httpx +import pytest +from starlette.applications import Starlette +from starlette.routing import Route + +from app.server import BearerAuthMiddleware, create_entry_endpoint + +VALID = { + "source": "finance-agent", + "description": "Verdict - MSFT", + "keywords": ["MSFT", "verdict"], + "data": {"winner": "bear"}, +} +AUTH = {"Authorization": "Bearer test-api-key"} + + +@pytest.fixture +def client(): + app = Starlette(routes=[Route("/entries", create_entry_endpoint, methods=["POST"])]) + app.add_middleware(BearerAuthMiddleware) + transport = httpx.ASGITransport(app=app) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +async def test_rejects_missing_token(client): + resp = await client.post("/entries", json=VALID) + assert resp.status_code == 401 + + +async def test_rejects_wrong_token(client): + resp = await client.post("/entries", json=VALID, headers={"Authorization": "Bearer nope"}) + assert resp.status_code == 401 + + +async def test_rejects_non_bearer_scheme(client): + resp = await client.post("/entries", json=VALID, headers={"Authorization": "test-api-key"}) + assert resp.status_code == 401 + + +async def test_creates_entry(client): + resp = await client.post("/entries", json=VALID, headers=AUTH) + + assert resp.status_code == 201 + entry = resp.json() + assert entry["source"] == "finance-agent" + assert entry["keywords"] == ["MSFT", "verdict"] + assert entry["data"] == {"winner": "bear"} + assert entry["id"] + + +async def test_keywords_optional(client): + payload = {k: v for k, v in VALID.items() if k != "keywords"} + resp = await client.post("/entries", json=payload, headers=AUTH) + assert resp.status_code == 201 + assert resp.json()["keywords"] == [] + + +async def test_rejects_invalid_json(client): + resp = await client.post( + "/entries", content=b"not json", headers={**AUTH, "Content-Type": "application/json"} + ) + assert resp.status_code == 400 + assert resp.json()["error"] == "Invalid JSON body" + + +async def test_rejects_missing_field(client): + payload = {k: v for k, v in VALID.items() if k != "description"} + resp = await client.post("/entries", json=payload, headers=AUTH) + assert resp.status_code == 400 + assert "description" in resp.json()["error"] + + +async def test_rejects_wrong_field_type(client): + resp = await client.post("/entries", json={**VALID, "data": "not a dict"}, headers=AUTH) + assert resp.status_code == 400 + assert "data" in resp.json()["error"] diff --git a/tests/test_server_tools.py b/tests/test_server_tools.py new file mode 100644 index 0000000..82ac406 --- /dev/null +++ b/tests/test_server_tools.py @@ -0,0 +1,131 @@ +import itertools +from datetime import UTC, datetime, timedelta + +import pytest +from bson import ObjectId + +from app import server + +# @mcp.tool() wraps functions in FunctionTool objects; the plain coroutine is .fn +create_entry = server.create_entry.fn +list_entries = server.list_entries.fn +get_entry = server.get_entry.fn +update_entry = server.update_entry.fn +delete_entry = server.delete_entry.fn + + +@pytest.fixture(autouse=True) +def ticking_clock(monkeypatch): + """Advance time 1ms per call — Mongo timestamps have millisecond precision, + so real wall-clock time gives consecutive writes identical created_at.""" + base = datetime(2026, 8, 8, 12, 0, 0, tzinfo=UTC) + ticks = itertools.count() + + class SteppingDatetime: + @staticmethod + def now(tz=None): + return base + timedelta(milliseconds=next(ticks)) + + monkeypatch.setattr(server, "datetime", SteppingDatetime) + + +async def test_create_entry_returns_formatted_doc(): + entry = await create_entry("finance-agent", "Verdict - MSFT", {"x": 1}, ["MSFT"]) + + assert entry["source"] == "finance-agent" + assert entry["description"] == "Verdict - MSFT" + assert entry["keywords"] == ["MSFT"] + assert entry["data"] == {"x": 1} + assert entry["created_at"] == entry["updated_at"] + assert ObjectId(entry["id"]) + + +async def test_create_entry_defaults_keywords_to_empty_list(): + entry = await create_entry("a", "b", {}) + assert entry["keywords"] == [] + + +async def test_list_entries_filters_and_sorts_newest_first(): + await create_entry("agent-a", "first", {}, ["MSFT"]) + await create_entry("agent-a", "second", {}, ["MSFT", "verdict"]) + await create_entry("agent-b", "other", {}, ["AAPL"]) + + result = await list_entries() + assert result["total"] == 3 + assert [i["description"] for i in result["items"]] == ["other", "second", "first"] + + by_source = await list_entries(source="agent-a") + assert by_source["total"] == 2 + + by_keywords = await list_entries(keywords=["MSFT", "verdict"]) + assert by_keywords["total"] == 1 + assert by_keywords["items"][0]["description"] == "second" + + +async def test_list_entries_pagination(): + for i in range(5): + await create_entry("a", f"entry-{i}", {}) + + page = await list_entries(limit=2, skip=2) + assert page["total"] == 5 + assert len(page["items"]) == 2 + assert [i["description"] for i in page["items"]] == ["entry-2", "entry-1"] + + +async def test_list_entries_clamps_bad_limit_and_skip(): + await create_entry("a", "only", {}) + + result = await list_entries(limit=-5, skip=-10) + assert len(result["items"]) == 1 + + result = await list_entries(limit=10_000_000) + assert len(result["items"]) == 1 + + +async def test_get_entry_roundtrip(): + created = await create_entry("a", "b", {"k": "v"}) + fetched = await get_entry(created["id"]) + assert fetched == created + + +async def test_get_entry_not_found(): + with pytest.raises(ValueError, match="Entry not found"): + await get_entry(str(ObjectId())) + + +async def test_get_entry_invalid_id(): + with pytest.raises(ValueError, match="Invalid entry ID"): + await get_entry("garbage") + + +async def test_update_entry_changes_only_given_fields(): + created = await create_entry("a", "old", {"k": "v"}, ["kw"]) + + updated = await update_entry(created["id"], description="new") + + assert updated["description"] == "new" + assert updated["source"] == "a" + assert updated["keywords"] == ["kw"] + assert updated["data"] == {"k": "v"} + assert updated["updated_at"] > created["updated_at"] + + +async def test_update_entry_requires_a_field(): + created = await create_entry("a", "b", {}) + with pytest.raises(ValueError, match="No fields provided"): + await update_entry(created["id"]) + + +async def test_update_entry_not_found(): + with pytest.raises(ValueError, match="Entry not found"): + await update_entry(str(ObjectId()), description="x") + + +async def test_delete_entry(): + created = await create_entry("a", "b", {}) + + result = await delete_entry(created["id"]) + assert result == {"deleted": True, "id": created["id"]} + + with pytest.raises(ValueError, match="Entry not found"): + await delete_entry(created["id"]) diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..9ec1d57 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,92 @@ +from datetime import UTC, datetime + +import httpx +import pytest +from bson import ObjectId + +from app.web import app as web_app + +AUTH = ("admin", "test-password") + + +@pytest.fixture +def client(): + transport = httpx.ASGITransport(app=web_app) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +async def seed(db, **overrides): + now = datetime.now(UTC) + doc = { + "source": "finance-agent", + "description": "Verdict - MSFT", + "keywords": ["MSFT", "verdict"], + "data": {"winner": "bear"}, + "created_at": now, + "updated_at": now, + **overrides, + } + result = await db.entries.insert_one(doc) + return str(result.inserted_id) + + +async def test_requires_auth(client): + resp = await client.get("/") + assert resp.status_code == 401 + assert resp.headers["www-authenticate"] == 'Basic realm="ai-data-store"' + + +async def test_rejects_wrong_password(client): + resp = await client.get("/", auth=("admin", "wrong")) + assert resp.status_code == 401 + + +async def test_index_empty_state(client): + resp = await client.get("/", auth=AUTH) + assert resp.status_code == 200 + assert "No entries found" in resp.text + + +async def test_index_lists_entries(client, mock_db): + await seed(mock_db) + await seed(mock_db, source="other-agent", description="Something else", keywords=[]) + + resp = await client.get("/", auth=AUTH) + assert resp.status_code == 200 + assert "Verdict - MSFT" in resp.text + assert "Something else" in resp.text + assert "2 entries" in resp.text + + +async def test_index_filters_by_source(client, mock_db): + await seed(mock_db) + await seed(mock_db, source="other-agent", description="Something else") + + resp = await client.get("/", params={"source": "other-agent"}, auth=AUTH) + assert "Something else" in resp.text + assert "Verdict - MSFT" not in resp.text + + +async def test_index_escapes_html(client, mock_db): + await seed(mock_db, description="") + + resp = await client.get("/", auth=AUTH) + assert "" not in resp.text + assert "<script>" in resp.text + + +async def test_detail_page(client, mock_db): + entry_id = await seed(mock_db) + + resp = await client.get(f"/entries/{entry_id}", auth=AUTH) + assert resp.status_code == 200 + assert "Verdict - MSFT" in resp.text + assert "winner" in resp.text + assert entry_id in resp.text + + +@pytest.mark.parametrize("entry_id", ["garbage", str(ObjectId())]) +async def test_detail_not_found(client, entry_id): + resp = await client.get(f"/entries/{entry_id}", auth=AUTH) + assert resp.status_code == 404 + assert "Entry not found" in resp.text