diff --git a/CLAUDE.md b/CLAUDE.md index 78fa9c0..57fb082 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ Models: `neurostack-ask` (RAG), `neurostack-search` (hybrid), `neurostack-tiered | `neurostack serve` | Start MCP server. `--transport stdio\|sse\|http`, `--host`, `--port` | | `neurostack api` | Start OpenAI-compatible HTTP API. `--host`, `--port` | -## MCP Tools (20 tools) +## MCP Tools (24 tools) ### Search & Retrieval - `vault_search(query, top_k, mode, depth, context, workspace)` - Hybrid search with tiered depth @@ -144,6 +144,12 @@ Models: `neurostack-ask` (RAG), `neurostack-search` (hybrid), `neurostack-tiered - `vault_session_start(source_agent, workspace)` - Begin memory session - `vault_session_end(session_id, summarize, auto_harvest)` - End session +### Vault Files (raw markdown CRUD over MCP) +- `vault_read_file(path)` - Read a .md file under vault_root +- `vault_list_files(directory, pattern, recursive)` - List .md files (hidden segments excluded) +- `vault_write_file(path, content, commit_message)` - Create/overwrite a .md file; commits + pushes origin/main. Hard-rejects writes without required frontmatter (`date`, `tags`, `type`). On push conflict: `git pull --rebase --autostash` + retry once, then rollback. +- `vault_delete_file(path, commit_message)` - Delete a .md file; commits + pushes origin/main + ## Global Flags All query commands support `--json` for machine-readable output: diff --git a/npm/package.json b/npm/package.json index f3c1cbc..d06e411 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "neurostack", - "version": "0.13.0", + "version": "0.14.0", "description": "Build, maintain, and search your knowledge vault with AI", "bin": { "neurostack": "bin/neurostack.js", diff --git a/pyproject.toml b/pyproject.toml index 548ccf6..13a4d3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "neurostack" -version = "0.13.0" +version = "0.14.0" description = "Build, maintain, and search your knowledge vault. CLI + MCP server with stale note detection, semantic search, and neuroscience-grounded memory." readme = "README.md" license = "Apache-2.0" diff --git a/src/neurostack/__init__.py b/src/neurostack/__init__.py index b360726..f34e646 100644 --- a/src/neurostack/__init__.py +++ b/src/neurostack/__init__.py @@ -2,4 +2,4 @@ # Copyright (c) 2024-2026 Raphael Southall """NeuroStack — AI-provider-agnostic, neuroscience-grounded knowledge management.""" -__version__ = "0.12.1" +__version__ = "0.14.0" diff --git a/src/neurostack/tools/__init__.py b/src/neurostack/tools/__init__.py index 88c9f25..7b87d90 100644 --- a/src/neurostack/tools/__init__.py +++ b/src/neurostack/tools/__init__.py @@ -19,6 +19,7 @@ def ensure_registered() -> ToolRegistry: global _registered if not _registered: from . import ( + file_tools, # noqa: F401 insight_tools, # noqa: F401 memory_tools, # noqa: F401 search_tools, # noqa: F401 diff --git a/src/neurostack/tools/file_tools.py b/src/neurostack/tools/file_tools.py new file mode 100644 index 0000000..ef62f87 --- /dev/null +++ b/src/neurostack/tools/file_tools.py @@ -0,0 +1,483 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2024-2026 Raphael Southall +"""Vault file CRUD tools — read/list/write/delete .md files in the brain vault. + +Designed for MCP clients (e.g. Microsoft Copilot Studio) that need to author +vault notes without ssh access. Writes commit + push to origin/main under an +flock, with rebase-on-conflict and rollback on push failure. +""" + +from __future__ import annotations + +import fcntl +import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +import yaml + +from .registry import ToolAnnotationHints as Hints +from .registry import registry + +_READ_ONLY = Hints(read_only=True, open_world=False) +_WRITE_IDEMPOTENT = Hints( + read_only=False, destructive=False, idempotent=True, open_world=False, +) +_WRITE_DESTRUCTIVE = Hints( + read_only=False, destructive=True, idempotent=True, open_world=False, +) + +REQUIRED_FRONTMATTER_FIELDS = ("date", "tags", "type") +LOCK_FILENAME = ".neurostack-write.lock" + +_FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---(\r?\n|$)", re.DOTALL) + + +def _vault_root() -> Path: + from ..config import get_config + return get_config().vault_root.resolve() + + +# --------------------------- path safety ------------------------------------- + + +class PathSafetyError(ValueError): + """Raised when a caller-supplied path violates the safety rules.""" + + +def _check_segments(relative: str, parts: tuple[str, ...]) -> None: + for part in parts: + if part in ("", "."): + raise PathSafetyError(f"empty or '.' segment in {relative!r}") + if part == "..": + raise PathSafetyError(f"'..' segment not allowed in {relative!r}") + if part.startswith("."): + raise PathSafetyError( + f"hidden segment {part!r} not allowed in {relative!r}" + ) + + +def _check_symlinks_inside(vault_root: Path, parts: tuple[str, ...]) -> None: + current = vault_root + for part in parts: + current = current / part + if current.is_symlink(): + target = current.resolve() + try: + target.relative_to(vault_root) + except ValueError as exc: + raise PathSafetyError( + f"symlink {part!r} points outside vault" + ) from exc + + +def _safe_path(relative: str, vault_root: Path) -> Path: + """Resolve a relative .md path inside vault_root, rejecting unsafe inputs.""" + if not relative or not relative.strip(): + raise PathSafetyError("path is empty") + p = Path(relative) + if p.is_absolute(): + raise PathSafetyError(f"path must be relative, got {relative!r}") + + _check_segments(relative, p.parts) + + if p.suffix != ".md": + raise PathSafetyError( + f"only .md files allowed, got {p.suffix!r} in {relative!r}" + ) + + _check_symlinks_inside(vault_root, p.parts) + + abs_path = (vault_root / p).resolve() + try: + abs_path.relative_to(vault_root) + except ValueError as exc: + raise PathSafetyError( + f"path escapes vault root: {relative!r}" + ) from exc + return abs_path + + +def _safe_dir(relative: str, vault_root: Path) -> Path: + """Resolve a relative directory inside vault_root. Empty string = vault root.""" + if not relative: + return vault_root + p = Path(relative) + if p.is_absolute(): + raise PathSafetyError(f"directory must be relative, got {relative!r}") + + _check_segments(relative, p.parts) + _check_symlinks_inside(vault_root, p.parts) + + abs_path = (vault_root / p).resolve() + try: + abs_path.relative_to(vault_root) + except ValueError as exc: + raise PathSafetyError( + f"directory escapes vault root: {relative!r}" + ) from exc + return abs_path + + +# --------------------------- frontmatter ------------------------------------- + + +def _parse_frontmatter(content: str) -> tuple[dict | None, list[str]]: + """Return (frontmatter_dict_or_none, list_of_errors).""" + m = _FRONTMATTER_RE.match(content) + if not m: + return None, [ + "missing frontmatter block (expected leading --- YAML --- at start of file)", + ] + try: + data = yaml.safe_load(m.group(1)) or {} + except yaml.YAMLError as exc: + return None, [f"frontmatter YAML parse error: {exc}"] + if not isinstance(data, dict): + return None, [ + f"frontmatter is not a YAML mapping (got {type(data).__name__})", + ] + return data, [] + + +def _missing_required_fields(fm: dict) -> list[str]: + """Return required fields that are absent or None. Empty list/string is allowed.""" + missing = [] + for field in REQUIRED_FRONTMATTER_FIELDS: + if field not in fm or fm[field] is None: + missing.append(field) + return missing + + +# --------------------------- git helpers ------------------------------------- + + +def _run_git( + args: list[str], cwd: Path, *, check: bool = True, +) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], cwd=str(cwd), check=check, + capture_output=True, text=True, + ) + + +def _git_head(cwd: Path) -> str | None: + """Return current HEAD sha or None on empty repo.""" + r = _run_git(["rev-parse", "HEAD"], cwd, check=False) + if r.returncode != 0: + return None + return r.stdout.strip() or None + + +def _stage_and_diff(vault_root: Path, relative_path: str) -> bool: + """Stage the path; return True if anything actually changed in the index.""" + _run_git(["add", "--", relative_path], vault_root) + diff_check = _run_git( + ["diff", "--cached", "--quiet"], vault_root, check=False, + ) + return diff_check.returncode != 0 + + +def _try_push_with_rebase(vault_root: Path) -> str | None: + """Push origin main. Return None on success, error string on failure.""" + try: + _run_git(["push", "origin", "main"], vault_root) + return None + except subprocess.CalledProcessError as first: + stderr1 = (first.stderr or "").strip() + try: + _run_git( + ["pull", "--rebase", "--autostash", "origin", "main"], + vault_root, + ) + except subprocess.CalledProcessError as rebase_err: + _run_git(["rebase", "--abort"], vault_root, check=False) + return ( + f"push failed and rebase failed: " + f"push={stderr1}; rebase={(rebase_err.stderr or '').strip()}" + ) + try: + _run_git(["push", "origin", "main"], vault_root) + return None + except subprocess.CalledProcessError as second: + return ( + f"push failed twice: first={stderr1}; " + f"after-rebase={(second.stderr or '').strip()}" + ) + + +def _rollback_commit(vault_root: Path, our_commit_sha: str | None) -> None: + """Reset HEAD by one commit if our commit is still on top. Best-effort.""" + if not our_commit_sha: + return + current = _git_head(vault_root) + if current == our_commit_sha: + _run_git(["reset", "--hard", "HEAD~1"], vault_root, check=False) + + +def _commit_and_push( + vault_root: Path, relative_path: str, commit_message: str, +) -> dict: + """Stage + commit + push. Returns dict with commit_sha/pushed/error fields.""" + try: + changed = _stage_and_diff(vault_root, relative_path) + except subprocess.CalledProcessError as exc: + return { + "committed": False, "pushed": False, + "error": f"git add failed: {(exc.stderr or str(exc)).strip()}", + } + if not changed: + return { + "committed": False, "pushed": False, "no_changes": True, + "commit_sha": None, + } + + try: + _run_git(["commit", "-m", commit_message], vault_root) + except subprocess.CalledProcessError as exc: + return { + "committed": False, "pushed": False, + "error": f"git commit failed: {(exc.stderr or str(exc)).strip()}", + } + commit_sha = _git_head(vault_root) + + push_err = _try_push_with_rebase(vault_root) + if push_err is None: + return { + "committed": True, "pushed": True, + "commit_sha": commit_sha, + } + + _rollback_commit(vault_root, commit_sha) + return { + "committed": True, "pushed": False, "rolled_back": True, + "commit_sha": None, "error": push_err, + } + + +# --------------------------- locking ----------------------------------------- + + +def _acquire_vault_lock(vault_root: Path): + """Acquire an exclusive flock on the vault write lock. Returns file handle.""" + lock_path = vault_root / LOCK_FILENAME + fh = open(lock_path, "a+") + fcntl.flock(fh.fileno(), fcntl.LOCK_EX) + return fh + + +def _release_vault_lock(fh) -> None: + try: + fcntl.flock(fh.fileno(), fcntl.LOCK_UN) + finally: + fh.close() + + +# --------------------------- tools ------------------------------------------- + + +@registry.tool(tags=["vault-files", "read"], annotations=_READ_ONLY) +def vault_read_file(path: str) -> dict: + """Read a markdown file from the vault. + + Args: + path: Relative path under vault_root (e.g. "home/projects/foo.md"). + Must end in .md. Absolute paths, '..', and dot-prefixed + segments (.git, .obsidian, .trash, etc.) are rejected. + """ + vault_root = _vault_root() + try: + abs_path = _safe_path(path, vault_root) + except PathSafetyError as exc: + return {"path": path, "exists": False, "error": str(exc)} + + if not abs_path.is_file(): + return {"path": path, "exists": False, "size_bytes": 0, "content": ""} + + data = abs_path.read_bytes() + return { + "path": path, + "exists": True, + "size_bytes": len(data), + "content": data.decode("utf-8"), + } + + +@registry.tool(tags=["vault-files", "read"], annotations=_READ_ONLY) +def vault_list_files( + directory: str = "", + pattern: str = "*.md", + recursive: bool = True, +) -> dict: + """List markdown files under a vault directory. + + Hidden segments (.git, .obsidian, .trash, .neurostack, .claude, etc.) are + always excluded from results. + + Args: + directory: Relative directory under vault_root. "" = vault root. + pattern: Glob pattern (default "*.md"). Only .md files are returned + regardless of pattern. + recursive: If True (default), walk subdirectories. + """ + vault_root = _vault_root() + try: + abs_dir = _safe_dir(directory, vault_root) + except PathSafetyError as exc: + return {"directory": directory, "files": [], "error": str(exc)} + + if not abs_dir.is_dir(): + return { + "directory": directory, "files": [], + "error": f"not a directory: {directory!r}", + } + + iterator = abs_dir.rglob(pattern) if recursive else abs_dir.glob(pattern) + files = [] + for fp in sorted(iterator): + if not fp.is_file(): + continue + if fp.suffix != ".md": + continue + try: + rel = fp.relative_to(vault_root) + except ValueError: + continue + if any(part.startswith(".") for part in rel.parts): + continue + stat = fp.stat() + modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) + files.append({ + "path": str(rel), + "size_bytes": stat.st_size, + "modified_iso": modified.isoformat(), + }) + return {"directory": directory, "files": files} + + +@registry.tool(tags=["vault-files", "write"], annotations=_WRITE_IDEMPOTENT) +def vault_write_file( + path: str, + content: str, + commit_message: str = None, +) -> dict: + """Create or overwrite a markdown file in the vault. Commits + pushes to origin/main. + + Requires a YAML frontmatter block with required fields date, tags, type; + hard-rejects writes that lack any of them. On push conflict, attempts + `git pull --rebase --autostash` and retries once; on second failure, + rolls back the local commit and returns pushed=false. + + Args: + path: Relative .md path under vault_root. + content: Full file content. Must start with `---\\n...\\n---\\n` YAML. + commit_message: Optional. Default: "vault_write_file: (via MCP)". + """ + vault_root = _vault_root() + try: + abs_path = _safe_path(path, vault_root) + except PathSafetyError as exc: + return {"path": path, "written": False, "error": str(exc)} + + fm, fm_errors = _parse_frontmatter(content) + if fm is None: + return { + "path": path, "written": False, + "error": "frontmatter validation failed", + "frontmatter_errors": fm_errors, + } + missing = _missing_required_fields(fm) + if missing: + return { + "path": path, "written": False, + "error": ( + f"missing required frontmatter fields: {', '.join(missing)} " + f"(required: {', '.join(REQUIRED_FRONTMATTER_FIELDS)})" + ), + "missing_fields": missing, + } + + msg = commit_message or f"vault_write_file: {path} (via MCP)" + fh = _acquire_vault_lock(vault_root) + try: + created = not abs_path.exists() + abs_path.parent.mkdir(parents=True, exist_ok=True) + encoded = content.encode("utf-8") + abs_path.write_bytes(encoded) + git_result = _commit_and_push(vault_root, path, msg) + finally: + _release_vault_lock(fh) + + rel_parent = abs_path.parent.relative_to(vault_root) + index_hint = None + if created: + index_hint = ( + f"- [[{abs_path.stem}]] — " + f"(add to {rel_parent / 'index.md'})" + ) + + return { + "path": path, + "written": True, + "created": created, + "bytes_written": len(encoded), + "commit_sha": git_result.get("commit_sha"), + "pushed": git_result.get("pushed", False), + "rolled_back": git_result.get("rolled_back", False), + "no_changes": git_result.get("no_changes", False), + "git_error": git_result.get("error"), + "index_update_needed": created, + "index_hint": index_hint, + } + + +@registry.tool(tags=["vault-files", "write"], annotations=_WRITE_DESTRUCTIVE) +def vault_delete_file( + path: str, + commit_message: str = None, +) -> dict: + """Delete a markdown file from the vault. Commits + pushes to origin/main. + + Same path-safety rules as the write/read tools. Returns deleted=False + with an error if the file does not exist. Conflict and rollback behaviour + matches vault_write_file. + + Args: + path: Relative .md path under vault_root. + commit_message: Optional. Default: "vault_delete_file: (via MCP)". + """ + vault_root = _vault_root() + try: + abs_path = _safe_path(path, vault_root) + except PathSafetyError as exc: + return {"path": path, "deleted": False, "error": str(exc)} + + if not abs_path.is_file(): + return { + "path": path, "deleted": False, + "error": f"file does not exist: {path}", + } + + msg = commit_message or f"vault_delete_file: {path} (via MCP)" + fh = _acquire_vault_lock(vault_root) + try: + abs_path.unlink() + git_result = _commit_and_push(vault_root, path, msg) + finally: + _release_vault_lock(fh) + + rel_parent = abs_path.parent.relative_to(vault_root) + return { + "path": path, + "deleted": True, + "commit_sha": git_result.get("commit_sha"), + "pushed": git_result.get("pushed", False), + "rolled_back": git_result.get("rolled_back", False), + "no_changes": git_result.get("no_changes", False), + "git_error": git_result.get("error"), + "index_update_needed": True, + "index_hint": ( + f"remove the [[{abs_path.stem}]] entry from {rel_parent / 'index.md'}" + ), + } diff --git a/tests/test_file_tools.py b/tests/test_file_tools.py new file mode 100644 index 0000000..8874616 --- /dev/null +++ b/tests/test_file_tools.py @@ -0,0 +1,383 @@ +"""Tests for neurostack.tools.file_tools — vault file CRUD over MCP.""" + +from __future__ import annotations + +import os +import subprocess +import threading +import time +from pathlib import Path + +import pytest + +from neurostack import config as nsconfig +from neurostack.tools.file_tools import ( + PathSafetyError, + _safe_dir, + _safe_path, + vault_delete_file, + vault_list_files, + vault_read_file, + vault_write_file, +) + +# --------------------------- fixtures ---------------------------------------- + + +VALID_FRONTMATTER = ( + "---\n" + "date: 2026-05-11\n" + "tags: [test]\n" + "type: project\n" + "---\n\n" + "# Test Note\n\nBody.\n" +) + + +def _git(args: list[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], cwd=str(cwd), check=check, + capture_output=True, text=True, + ) + + +@pytest.fixture +def tmp_vault_repo(tmp_path, monkeypatch): + """tmp_path/vault → working tree, tmp_path/remote.git → bare remote.""" + bare = tmp_path / "remote.git" + _git(["init", "--bare", "--initial-branch=main", str(bare)], cwd=tmp_path) + + vault = tmp_path / "vault" + vault.mkdir() + _git(["init", "--initial-branch=main"], cwd=vault) + _git(["config", "user.email", "test@example.com"], cwd=vault) + _git(["config", "user.name", "test"], cwd=vault) + _git(["config", "commit.gpgsign", "false"], cwd=vault) + _git(["remote", "add", "origin", str(bare)], cwd=vault) + (vault / "README.md").write_text("# vault\n") + _git(["add", "README.md"], cwd=vault) + _git(["commit", "-m", "init"], cwd=vault) + _git(["push", "origin", "main"], cwd=vault) + + monkeypatch.setenv("NEUROSTACK_VAULT_ROOT", str(vault)) + nsconfig._config = None + yield vault + nsconfig._config = None + + +def _head(vault: Path) -> str: + return _git(["rev-parse", "HEAD"], cwd=vault).stdout.strip() + + +def _remote_head(bare: Path) -> str: + return _git(["rev-parse", "main"], cwd=bare).stdout.strip() + + +# --------------------------- path safety ------------------------------------- + + +class TestPathSafety: + def test_traversal_rejected(self, tmp_vault_repo): + with pytest.raises(PathSafetyError, match="'..'"): + _safe_path("../etc/passwd", tmp_vault_repo) + + def test_absolute_rejected(self, tmp_vault_repo): + with pytest.raises(PathSafetyError, match="relative"): + _safe_path("/etc/passwd", tmp_vault_repo) + + def test_dot_segment_rejected(self, tmp_vault_repo): + with pytest.raises(PathSafetyError, match="hidden"): + _safe_path(".git/config", tmp_vault_repo) + + def test_dot_prefix_filename_rejected(self, tmp_vault_repo): + with pytest.raises(PathSafetyError, match="hidden|.md"): + _safe_path(".secret.md", tmp_vault_repo) + + def test_non_md_rejected(self, tmp_vault_repo): + with pytest.raises(PathSafetyError, match=".md"): + _safe_path("home/foo.txt", tmp_vault_repo) + + def test_empty_rejected(self, tmp_vault_repo): + with pytest.raises(PathSafetyError, match="empty"): + _safe_path("", tmp_vault_repo) + + def test_symlink_outside_rejected(self, tmp_path, tmp_vault_repo): + outside = tmp_path / "outside.md" + outside.write_text("---\ndate: x\ntags: []\ntype: x\n---\nbad\n") + link = tmp_vault_repo / "evil.md" + os.symlink(outside, link) + with pytest.raises(PathSafetyError, match="outside"): + _safe_path("evil.md", tmp_vault_repo) + + def test_valid_path_accepted(self, tmp_vault_repo): + p = _safe_path("home/projects/note.md", tmp_vault_repo) + assert p == (tmp_vault_repo / "home" / "projects" / "note.md").resolve() + + def test_safe_dir_empty_is_root(self, tmp_vault_repo): + assert _safe_dir("", tmp_vault_repo) == tmp_vault_repo + + def test_safe_dir_traversal_rejected(self, tmp_vault_repo): + with pytest.raises(PathSafetyError): + _safe_dir("..", tmp_vault_repo) + + +# --------------------------- vault_read_file --------------------------------- + + +class TestReadFile: + def test_read_existing(self, tmp_vault_repo): + target = tmp_vault_repo / "home" / "note.md" + target.parent.mkdir(parents=True) + target.write_text("hello\n") + result = vault_read_file(path="home/note.md") + assert result["exists"] is True + assert result["content"] == "hello\n" + assert result["size_bytes"] == len("hello\n") + assert result["path"] == "home/note.md" + + def test_read_nonexistent(self, tmp_vault_repo): + result = vault_read_file(path="home/missing.md") + assert result["exists"] is False + assert result["content"] == "" + + def test_read_rejected_path(self, tmp_vault_repo): + result = vault_read_file(path="../etc/passwd") + assert result["exists"] is False + assert "error" in result + + +# --------------------------- vault_list_files -------------------------------- + + +class TestListFiles: + def _seed_notes(self, vault: Path) -> None: + (vault / "home" / "a").mkdir(parents=True) + (vault / "home" / "a" / "one.md").write_text("a") + (vault / "home" / "a" / "two.md").write_text("b") + (vault / "home" / "b.md").write_text("c") + (vault / ".obsidian").mkdir() + (vault / ".obsidian" / "workspace.md").write_text("hidden") + + def test_empty_dir(self, tmp_vault_repo): + (tmp_vault_repo / "empty").mkdir() + result = vault_list_files(directory="empty") + assert result["files"] == [] + + def test_recursive(self, tmp_vault_repo): + self._seed_notes(tmp_vault_repo) + result = vault_list_files(directory="home", recursive=True) + paths = sorted(f["path"] for f in result["files"]) + assert paths == ["home/a/one.md", "home/a/two.md", "home/b.md"] + + def test_non_recursive(self, tmp_vault_repo): + self._seed_notes(tmp_vault_repo) + result = vault_list_files(directory="home", recursive=False) + paths = sorted(f["path"] for f in result["files"]) + assert paths == ["home/b.md"] + + def test_excludes_hidden(self, tmp_vault_repo): + self._seed_notes(tmp_vault_repo) + result = vault_list_files(directory="", recursive=True) + paths = [f["path"] for f in result["files"]] + for p in paths: + assert not any(seg.startswith(".") for seg in Path(p).parts) + # README.md from fixture is the only root-level .md + assert "README.md" in paths + assert ".obsidian/workspace.md" not in paths + + def test_pattern(self, tmp_vault_repo): + self._seed_notes(tmp_vault_repo) + result = vault_list_files( + directory="home/a", pattern="one*.md", recursive=False, + ) + paths = [f["path"] for f in result["files"]] + assert paths == ["home/a/one.md"] + + def test_rejected_directory(self, tmp_vault_repo): + result = vault_list_files(directory="../etc") + assert result["files"] == [] + assert "error" in result + + +# --------------------------- vault_write_file -------------------------------- + + +class TestWriteFile: + def test_create_new(self, tmp_vault_repo): + result = vault_write_file( + path="home/new.md", content=VALID_FRONTMATTER, + ) + assert result["written"] is True + assert result["created"] is True + assert result["pushed"] is True + assert result["commit_sha"] + assert result["index_update_needed"] is True + assert result["index_hint"] + assert (tmp_vault_repo / "home" / "new.md").is_file() + # commit exists + log = _git(["log", "--oneline", "-2"], cwd=tmp_vault_repo).stdout + assert "vault_write_file: home/new.md" in log + # remote has the new HEAD + bare = tmp_vault_repo.parent / "remote.git" + assert _remote_head(bare) == _head(tmp_vault_repo) + + def test_overwrite_existing(self, tmp_vault_repo): + # seed + vault_write_file(path="home/x.md", content=VALID_FRONTMATTER) + new_content = VALID_FRONTMATTER + "Extra line.\n" + result = vault_write_file(path="home/x.md", content=new_content) + assert result["written"] is True + assert result["created"] is False + assert result["pushed"] is True + assert (tmp_vault_repo / "home" / "x.md").read_text() == new_content + + def test_missing_frontmatter_rejected(self, tmp_vault_repo): + result = vault_write_file( + path="home/bad.md", content="# Just a heading, no frontmatter\n", + ) + assert result["written"] is False + assert "frontmatter" in result["error"] + assert not (tmp_vault_repo / "home" / "bad.md").exists() + + def test_missing_required_field_rejected(self, tmp_vault_repo): + content = ( + "---\n" + "date: 2026-05-11\n" + "tags: [test]\n" + # missing 'type' + "---\n" + "body\n" + ) + result = vault_write_file(path="home/bad.md", content=content) + assert result["written"] is False + assert "type" in result["missing_fields"] + assert not (tmp_vault_repo / "home" / "bad.md").exists() + + def test_custom_commit_message(self, tmp_vault_repo): + result = vault_write_file( + path="home/cm.md", + content=VALID_FRONTMATTER, + commit_message="custom: write home/cm.md", + ) + assert result["pushed"] is True + msg = _git( + ["log", "-1", "--pretty=%s"], cwd=tmp_vault_repo, + ).stdout.strip() + assert msg == "custom: write home/cm.md" + + def test_idempotent_no_change(self, tmp_vault_repo): + first = vault_write_file(path="home/i.md", content=VALID_FRONTMATTER) + assert first["pushed"] is True + head_before = _head(tmp_vault_repo) + # same content again + second = vault_write_file(path="home/i.md", content=VALID_FRONTMATTER) + assert second["written"] is True + assert second["no_changes"] is True + assert second["pushed"] is False + assert second["commit_sha"] is None + assert _head(tmp_vault_repo) == head_before + + def test_rollback_on_push_failure_create(self, tmp_vault_repo): + head_before = _head(tmp_vault_repo) + _git( + ["remote", "set-url", "origin", "/nonexistent/repo.git"], + cwd=tmp_vault_repo, + ) + result = vault_write_file( + path="home/lost.md", content=VALID_FRONTMATTER, + ) + assert result["written"] is True + assert result["pushed"] is False + assert result["rolled_back"] is True + assert "git_error" in result and result["git_error"] + # HEAD restored + assert _head(tmp_vault_repo) == head_before + # File gone + assert not (tmp_vault_repo / "home" / "lost.md").exists() + + def test_rollback_on_push_failure_overwrite(self, tmp_vault_repo): + seed = vault_write_file( + path="home/o.md", content=VALID_FRONTMATTER, + ) + assert seed["pushed"] is True + head_after_seed = _head(tmp_vault_repo) + original_content = (tmp_vault_repo / "home" / "o.md").read_text() + + _git( + ["remote", "set-url", "origin", "/nonexistent/repo.git"], + cwd=tmp_vault_repo, + ) + new_content = VALID_FRONTMATTER + "added\n" + result = vault_write_file(path="home/o.md", content=new_content) + assert result["pushed"] is False + assert result["rolled_back"] is True + assert _head(tmp_vault_repo) == head_after_seed + # original content restored by git reset --hard HEAD~1 + assert (tmp_vault_repo / "home" / "o.md").read_text() == original_content + + +# --------------------------- vault_delete_file ------------------------------- + + +class TestDeleteFile: + def test_delete_existing(self, tmp_vault_repo): + vault_write_file(path="home/d.md", content=VALID_FRONTMATTER) + result = vault_delete_file(path="home/d.md") + assert result["deleted"] is True + assert result["pushed"] is True + assert result["commit_sha"] + assert result["index_update_needed"] is True + assert not (tmp_vault_repo / "home" / "d.md").exists() + msg = _git( + ["log", "-1", "--pretty=%s"], cwd=tmp_vault_repo, + ).stdout.strip() + assert msg == "vault_delete_file: home/d.md (via MCP)" + + def test_delete_nonexistent(self, tmp_vault_repo): + result = vault_delete_file(path="home/never.md") + assert result["deleted"] is False + assert "error" in result + + def test_delete_rejected_path(self, tmp_vault_repo): + result = vault_delete_file(path="../etc/passwd") + assert result["deleted"] is False + assert "error" in result + + +# --------------------------- concurrency ------------------------------------- + + +class TestConcurrency: + def test_two_writers_serialize(self, tmp_vault_repo): + """Two threads writing different files must both succeed without git collision.""" + errors = [] + + def writer(name: str, delay: float) -> None: + try: + content = VALID_FRONTMATTER.replace( + "# Test Note", f"# Note {name}", + ) + if delay: + time.sleep(delay) + result = vault_write_file( + path=f"home/{name}.md", content=content, + ) + if not result.get("pushed"): + errors.append((name, result)) + except Exception as exc: # noqa: BLE001 + errors.append((name, exc)) + + t1 = threading.Thread(target=writer, args=("alpha", 0.0)) + t2 = threading.Thread(target=writer, args=("bravo", 0.05)) + t1.start() + t2.start() + t1.join(timeout=30) + t2.join(timeout=30) + + assert not errors, f"writer errors: {errors}" + assert (tmp_vault_repo / "home" / "alpha.md").is_file() + assert (tmp_vault_repo / "home" / "bravo.md").is_file() + # both commits made it + log = _git(["log", "--oneline"], cwd=tmp_vault_repo).stdout + assert "home/alpha.md" in log + assert "home/bravo.md" in log