@archznn/crewloop-skills 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +43 -31
  2. package/assets/templates/skill-template.md +58 -0
  3. package/package.json +4 -1
  4. package/references/conventions.md +144 -0
  5. package/references/obsidian-mcp-usage.md +190 -0
  6. package/references/skill-anatomy.md +77 -0
  7. package/references/workflow.md +64 -0
  8. package/servers/obsidian-mcp/README.md +82 -0
  9. package/servers/obsidian-mcp/pyproject.toml +32 -0
  10. package/servers/obsidian-mcp/src/obsidian_mcp/__init__.py +0 -0
  11. package/servers/obsidian-mcp/src/obsidian_mcp/config.py +47 -0
  12. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/__init__.py +0 -0
  13. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/embeddings.py +105 -0
  14. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/indexer.py +79 -0
  15. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/store.py +141 -0
  16. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/sync.py +37 -0
  17. package/servers/obsidian-mcp/src/obsidian_mcp/learning/__init__.py +0 -0
  18. package/servers/obsidian-mcp/src/obsidian_mcp/learning/detector.py +66 -0
  19. package/servers/obsidian-mcp/src/obsidian_mcp/learning/note_generator.py +40 -0
  20. package/servers/obsidian-mcp/src/obsidian_mcp/main.py +4 -0
  21. package/servers/obsidian-mcp/src/obsidian_mcp/models.py +42 -0
  22. package/servers/obsidian-mcp/src/obsidian_mcp/privacy/__init__.py +0 -0
  23. package/servers/obsidian-mcp/src/obsidian_mcp/privacy/filter.py +68 -0
  24. package/servers/obsidian-mcp/src/obsidian_mcp/rag/__init__.py +0 -0
  25. package/servers/obsidian-mcp/src/obsidian_mcp/rag/engine.py +50 -0
  26. package/servers/obsidian-mcp/src/obsidian_mcp/rag/graph_search.py +55 -0
  27. package/servers/obsidian-mcp/src/obsidian_mcp/rag/text_search.py +37 -0
  28. package/servers/obsidian-mcp/src/obsidian_mcp/rag/vector_search.py +118 -0
  29. package/servers/obsidian-mcp/src/obsidian_mcp/server.py +61 -0
  30. package/servers/obsidian-mcp/src/obsidian_mcp/tools/__init__.py +0 -0
  31. package/servers/obsidian-mcp/src/obsidian_mcp/tools/create.py +43 -0
  32. package/servers/obsidian-mcp/src/obsidian_mcp/tools/delete.py +16 -0
  33. package/servers/obsidian-mcp/src/obsidian_mcp/tools/learn.py +42 -0
  34. package/servers/obsidian-mcp/src/obsidian_mcp/tools/list.py +16 -0
  35. package/servers/obsidian-mcp/src/obsidian_mcp/tools/read.py +15 -0
  36. package/servers/obsidian-mcp/src/obsidian_mcp/tools/registry.py +130 -0
  37. package/servers/obsidian-mcp/src/obsidian_mcp/tools/related.py +20 -0
  38. package/servers/obsidian-mcp/src/obsidian_mcp/tools/search.py +26 -0
  39. package/servers/obsidian-mcp/src/obsidian_mcp/tools/sync.py +22 -0
  40. package/servers/obsidian-mcp/src/obsidian_mcp/tools/update.py +34 -0
  41. package/servers/obsidian-mcp/src/obsidian_mcp/vault/__init__.py +0 -0
  42. package/servers/obsidian-mcp/src/obsidian_mcp/vault/parser.py +82 -0
  43. package/servers/obsidian-mcp/src/obsidian_mcp/vault/repository.py +68 -0
  44. package/servers/obsidian-mcp/src/obsidian_mcp/vault/writer.py +61 -0
  45. package/servers/obsidian-mcp/tests/conftest.py +39 -0
  46. package/servers/obsidian-mcp/tests/test_async_tools.py +87 -0
  47. package/servers/obsidian-mcp/tests/test_edge_cases.py +59 -0
  48. package/servers/obsidian-mcp/tests/test_indexer.py +27 -0
  49. package/servers/obsidian-mcp/tests/test_integration.py +90 -0
  50. package/servers/obsidian-mcp/tests/test_learning.py +34 -0
  51. package/servers/obsidian-mcp/tests/test_privacy.py +31 -0
  52. package/servers/obsidian-mcp/tests/test_privacy_config.py +44 -0
  53. package/servers/obsidian-mcp/tests/test_rag.py +64 -0
  54. package/servers/obsidian-mcp/tests/test_read_raw.py +37 -0
  55. package/servers/obsidian-mcp/tests/test_tfidf_fallback.py +54 -0
  56. package/servers/obsidian-mcp/tests/test_tools.py +108 -0
  57. package/servers/obsidian-mcp/tests/test_vault.py +103 -0
  58. package/servers/obsidian-mcp/tests/test_writer.py +139 -0
  59. package/skills/accessibility-auditor/SKILL.md +262 -0
  60. package/skills/accessibility-auditor/references/a11y-checklist.md +66 -0
  61. package/skills/architect/SKILL.md +302 -302
  62. package/skills/architect/references/templates/design-template.md +58 -58
  63. package/skills/architect/references/templates/proposal-template.md +30 -30
  64. package/skills/architect/references/templates/spec-delta-template.md +23 -23
  65. package/skills/architect/references/templates/tasks-template.md +28 -28
  66. package/skills/designer/SKILL.md +245 -245
  67. package/skills/docs-writer/SKILL.md +192 -192
  68. package/skills/engineer/SKILL.md +302 -302
  69. package/skills/maintainer/SKILL.md +102 -102
  70. package/skills/obsidian-second-brain/SKILL.md +298 -263
  71. package/skills/orchestrator/SKILL.md +346 -346
  72. package/skills/product-manager/SKILL.md +98 -98
  73. package/skills/researcher/SKILL.md +99 -99
  74. package/skills/reviewer/SKILL.md +297 -297
  75. package/skills/security-guard/SKILL.md +142 -0
  76. package/skills/security-guard/references/security-checklist.md +57 -0
  77. package/skills/shipper/SKILL.md +433 -433
  78. package/skills/tester/SKILL.md +98 -98
@@ -0,0 +1,130 @@
1
+ import asyncio
2
+
3
+ from obsidian_mcp.config import Config
4
+ from obsidian_mcp.tools.create import handle_create_note
5
+ from obsidian_mcp.tools.delete import handle_delete_note
6
+ from obsidian_mcp.tools.learn import handle_learn_from_text
7
+ from obsidian_mcp.tools.list import handle_list_notes
8
+ from obsidian_mcp.tools.read import handle_read_note
9
+ from obsidian_mcp.tools.related import handle_get_related_notes
10
+ from obsidian_mcp.tools.search import handle_search_notes
11
+ from obsidian_mcp.tools.sync import handle_sync_from_bundle
12
+ from obsidian_mcp.tools.update import handle_update_note
13
+
14
+
15
+ TOOLS = {
16
+ "read_note": {
17
+ "description": "Read a note from the Obsidian vault.",
18
+ "input_schema": {
19
+ "type": "object",
20
+ "properties": {"path": {"type": "string"}},
21
+ "required": ["path"],
22
+ },
23
+ "handler": handle_read_note,
24
+ },
25
+ "search_notes": {
26
+ "description": "Search notes by text, vector, graph or hybrid.",
27
+ "input_schema": {
28
+ "type": "object",
29
+ "properties": {
30
+ "query": {"type": "string"},
31
+ "mode": {"type": "string", "enum": ["text", "vector", "graph", "hybrid"]},
32
+ "limit": {"type": "integer", "default": 10},
33
+ },
34
+ "required": ["query"],
35
+ },
36
+ "handler": handle_search_notes,
37
+ },
38
+ "create_note": {
39
+ "description": "Create a new note in the vault.",
40
+ "input_schema": {
41
+ "type": "object",
42
+ "properties": {
43
+ "path": {"type": "string"},
44
+ "content": {"type": "string"},
45
+ "title": {"type": "string"},
46
+ "tags": {"type": "array", "items": {"type": "string"}},
47
+ "overwrite": {"type": "boolean", "default": False},
48
+ },
49
+ "required": ["path"],
50
+ },
51
+ "handler": handle_create_note,
52
+ },
53
+ "update_note": {
54
+ "description": "Update or append content to an existing note.",
55
+ "input_schema": {
56
+ "type": "object",
57
+ "properties": {
58
+ "path": {"type": "string"},
59
+ "content": {"type": "string"},
60
+ "append": {"type": "string"},
61
+ "tags": {"type": "array", "items": {"type": "string"}},
62
+ },
63
+ "required": ["path"],
64
+ },
65
+ "handler": handle_update_note,
66
+ },
67
+ "delete_note": {
68
+ "description": "Delete a note from the vault.",
69
+ "input_schema": {
70
+ "type": "object",
71
+ "properties": {"path": {"type": "string"}},
72
+ "required": ["path"],
73
+ },
74
+ "handler": handle_delete_note,
75
+ },
76
+ "list_notes": {
77
+ "description": "List notes in the vault.",
78
+ "input_schema": {
79
+ "type": "object",
80
+ "properties": {"folder": {"type": "string"}},
81
+ },
82
+ "handler": handle_list_notes,
83
+ },
84
+ "get_related_notes": {
85
+ "description": "Get notes related by links and graph traversal.",
86
+ "input_schema": {
87
+ "type": "object",
88
+ "properties": {
89
+ "path": {"type": "string"},
90
+ "depth": {"type": "integer", "default": 1},
91
+ },
92
+ "required": ["path"],
93
+ },
94
+ "handler": handle_get_related_notes,
95
+ },
96
+ "sync_from_bundle": {
97
+ "description": "Re-index the loop-engineering-agents bundle and local vault.",
98
+ "input_schema": {
99
+ "type": "object",
100
+ "properties": {"force": {"type": "boolean", "default": False}},
101
+ },
102
+ "handler": handle_sync_from_bundle,
103
+ },
104
+ "learn_from_text": {
105
+ "description": "Detect learnings in text and create notes automatically.",
106
+ "input_schema": {
107
+ "type": "object",
108
+ "properties": {"text": {"type": "string"}},
109
+ "required": ["text"],
110
+ },
111
+ "handler": handle_learn_from_text,
112
+ },
113
+ }
114
+
115
+
116
+ def dispatch(name: str, arguments: dict, config: Config):
117
+ tool = TOOLS.get(name)
118
+ if tool is None:
119
+ raise ValueError(f"unknown tool: {name}")
120
+ return tool["handler"](arguments, config)
121
+
122
+
123
+ async def dispatch_async(name: str, arguments: dict, config: Config):
124
+ tool = TOOLS.get(name)
125
+ if tool is None:
126
+ raise ValueError(f"unknown tool: {name}")
127
+ handler = tool["handler"]
128
+ if asyncio.iscoroutinefunction(handler):
129
+ return await handler(arguments, config)
130
+ return await asyncio.to_thread(handler, arguments, config)
@@ -0,0 +1,20 @@
1
+ import logging
2
+
3
+ from obsidian_mcp.config import Config
4
+ from obsidian_mcp.indexer.store import IndexStore
5
+ from obsidian_mcp.rag.engine import RAGEngine
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def handle_get_related_notes(arguments: dict, config: Config) -> str:
11
+ path = arguments.get("path")
12
+ if not path:
13
+ raise ValueError("path is required")
14
+ depth = int(arguments.get("depth", 1))
15
+ logger.info("getting related notes: path=%s depth=%d", path, depth)
16
+ engine = RAGEngine(config, IndexStore(config.index_dir / "index.db"))
17
+ results = engine.related(path, depth=depth)
18
+ if not results:
19
+ return "No related notes found."
20
+ return "\n".join(f"- **{r.note_path}** (score: {r.score:.3f})" for r in results)
@@ -0,0 +1,26 @@
1
+ import logging
2
+
3
+ from obsidian_mcp.config import Config
4
+ from obsidian_mcp.indexer.store import IndexStore
5
+ from obsidian_mcp.rag.engine import RAGEngine
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def handle_search_notes(arguments: dict, config: Config) -> str:
11
+ query = arguments.get("query")
12
+ if not query:
13
+ raise ValueError("query is required")
14
+ mode = arguments.get("mode", "hybrid")
15
+ limit = int(arguments.get("limit", 10))
16
+ logger.info("searching notes: mode=%s query=%r limit=%d", mode, query, limit)
17
+ engine = RAGEngine(config, IndexStore(config.index_dir / "index.db"))
18
+ results = engine.search(query, mode=mode, limit=limit)
19
+ if not results:
20
+ return "No results found."
21
+ lines = []
22
+ for r in results:
23
+ lines.append(f"- **{r.note_path}** (score: {r.score:.3f})")
24
+ if r.snippet:
25
+ lines.append(f" {r.snippet[:200].replace(chr(10), ' ')}")
26
+ return "\n".join(lines)
@@ -0,0 +1,22 @@
1
+ import logging
2
+
3
+ from obsidian_mcp.config import Config
4
+ from obsidian_mcp.indexer.indexer import Indexer
5
+ from obsidian_mcp.indexer.store import IndexStore
6
+ from obsidian_mcp.indexer.sync import BundleSync
7
+ from obsidian_mcp.vault.repository import VaultRepository
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def handle_sync_from_bundle(arguments: dict, config: Config) -> dict:
13
+ force = bool(arguments.get("force", False))
14
+ logger.info("syncing from bundle: force=%s", force)
15
+ vault = VaultRepository(config)
16
+ store = IndexStore(config.index_dir / "index.db")
17
+ indexer = Indexer(config, vault, store)
18
+ indexer.index_all(force=force)
19
+ sync = BundleSync(config, indexer, vault)
20
+ report = sync.sync(force=force)
21
+ logger.info("synced from bundle: %s", report)
22
+ return {"status": "synced", **report}
@@ -0,0 +1,34 @@
1
+ import logging
2
+ from datetime import datetime, timezone
3
+
4
+ from obsidian_mcp.config import Config
5
+ from obsidian_mcp.privacy.filter import PrivacyFilter
6
+ from obsidian_mcp.vault.repository import VaultRepository
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def handle_update_note(arguments: dict, config: Config) -> dict:
12
+ path = arguments.get("path")
13
+ if not path:
14
+ raise ValueError("path is required")
15
+ content = arguments.get("content")
16
+ append = arguments.get("append")
17
+ tags = arguments.get("tags")
18
+
19
+ vault = VaultRepository(config)
20
+ note = vault.read(path)
21
+
22
+ PrivacyFilter(config).validate(content or "")
23
+ PrivacyFilter(config).validate(append or "")
24
+
25
+ if content is not None:
26
+ note.content = content
27
+ if append:
28
+ note.content = note.content.rstrip() + "\n\n" + append
29
+ if tags is not None:
30
+ note.tags = tags
31
+ note.frontmatter["updated"] = datetime.now(timezone.utc).isoformat()
32
+ vault.save(note)
33
+ logger.info("updated note: %s", path)
34
+ return {"status": "updated", "path": path}
@@ -0,0 +1,82 @@
1
+ import logging
2
+ import re
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from obsidian_mcp.models import Note
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
14
+ LINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]")
15
+
16
+
17
+ def extract_frontmatter(content: str) -> tuple[dict, str]:
18
+ match = FRONTMATTER_RE.match(content)
19
+ if not match:
20
+ return {}, content
21
+ try:
22
+ frontmatter = yaml.safe_load(match.group(1)) or {}
23
+ except yaml.YAMLError as exc:
24
+ logger.warning("failed to parse frontmatter: %s", exc)
25
+ frontmatter = {}
26
+ body = content[match.end():]
27
+ return frontmatter, body
28
+
29
+
30
+ def extract_title_and_body(body: str) -> tuple[str, str]:
31
+ lines = body.splitlines()
32
+ if lines and lines[0].startswith("# "):
33
+ title = lines[0][2:].strip()
34
+ return title, "\n".join(lines[1:]).lstrip("\n")
35
+ return "", body
36
+
37
+
38
+ def extract_title(body: str, path: str) -> str:
39
+ title, _ = extract_title_and_body(body)
40
+ if title:
41
+ return title
42
+ return Path(path).stem.replace("-", " ").replace("_", " ").title()
43
+
44
+
45
+ def normalize_link(link: str) -> str:
46
+ link = link.strip()
47
+ if not any(link.endswith(ext) for ext in (".md", ".png", ".jpg", ".jpeg", ".pdf")):
48
+ link += ".md"
49
+ return link
50
+
51
+
52
+ def extract_links(content: str) -> list[str]:
53
+ return [normalize_link(m.group(1)) for m in LINK_RE.finditer(content)]
54
+
55
+
56
+ def parse_note(path: str, full_path: Path) -> Note:
57
+ content = full_path.read_text(encoding="utf-8")
58
+ frontmatter, body = extract_frontmatter(content)
59
+ if frontmatter.get("title"):
60
+ title = frontmatter["title"]
61
+ else:
62
+ title, body = extract_title_and_body(body)
63
+ if not title:
64
+ title = extract_title(body, path)
65
+ links = extract_links(content)
66
+ stat = full_path.stat()
67
+ ctime = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
68
+ mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
69
+ tags = frontmatter.get("tags", []) or []
70
+ if isinstance(tags, str):
71
+ tags = [t.strip() for t in tags.split(",") if t.strip()]
72
+ return Note(
73
+ path=path,
74
+ title=title,
75
+ content=body,
76
+ frontmatter=frontmatter,
77
+ links=links,
78
+ backlinks=[],
79
+ tags=tags,
80
+ ctime=ctime,
81
+ mtime=mtime,
82
+ )
@@ -0,0 +1,68 @@
1
+ from pathlib import Path
2
+
3
+ from obsidian_mcp.config import Config
4
+ from obsidian_mcp.models import Note
5
+ from obsidian_mcp.vault.parser import parse_note
6
+ from obsidian_mcp.vault.writer import write_note
7
+
8
+
9
+ class VaultRepository:
10
+ def __init__(self, config: Config):
11
+ self.config = config
12
+ self.root = config.vault_path
13
+ self.root.mkdir(parents=True, exist_ok=True)
14
+
15
+ def _resolve(self, path: str) -> Path:
16
+ safe = path.lstrip("/")
17
+ if ".." in safe.split("/"):
18
+ raise ValueError(f"invalid note path: {path}")
19
+ full = (self.root / safe).resolve()
20
+ if full != self.root and self.root not in full.parents:
21
+ raise ValueError(f"invalid note path: {path}")
22
+ return full
23
+
24
+ def _is_inside_vault(self, path: Path) -> bool:
25
+ resolved = path.resolve()
26
+ return resolved == self.root or self.root in resolved.parents
27
+
28
+ def list_notes(self, folder: str | None = None) -> list[str]:
29
+ base = self.root
30
+ if folder:
31
+ base = self._resolve(folder)
32
+ paths = []
33
+ for p in base.rglob("*.md"):
34
+ if not self._is_inside_vault(p):
35
+ continue
36
+ rel = p.relative_to(self.root)
37
+ if any(part.startswith(".") for part in rel.parts):
38
+ continue
39
+ paths.append(rel.as_posix())
40
+ return sorted(paths)
41
+
42
+ def read(self, path: str) -> Note:
43
+ full = self._resolve(path)
44
+ if not full.exists():
45
+ raise FileNotFoundError(f"note not found: {path}")
46
+ return parse_note(path, full)
47
+
48
+ def read_raw(self, path: str) -> str:
49
+ full = self._resolve(path)
50
+ if not full.exists():
51
+ raise FileNotFoundError(f"note not found: {path}")
52
+ return full.read_text(encoding="utf-8", errors="replace")
53
+
54
+ def read_all(self) -> list[Note]:
55
+ return [self.read(p) for p in self.list_notes()]
56
+
57
+ def exists(self, path: str) -> bool:
58
+ return self._resolve(path).exists()
59
+
60
+ def save(self, note: Note) -> None:
61
+ full = self._resolve(note.path)
62
+ write_note(full, note)
63
+
64
+ def delete(self, path: str) -> None:
65
+ full = self._resolve(path)
66
+ if not full.exists():
67
+ raise FileNotFoundError(f"note not found: {path}")
68
+ full.unlink()
@@ -0,0 +1,61 @@
1
+ import logging
2
+ from datetime import datetime, timezone
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+ from obsidian_mcp.models import Note
8
+ from obsidian_mcp.vault.parser import extract_frontmatter
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def to_frontmatter(data: dict) -> str:
14
+ if not data:
15
+ return ""
16
+ return "---\n" + yaml.safe_dump(data, sort_keys=False, allow_unicode=True) + "---\n\n"
17
+
18
+
19
+ def _normalize_tags(tags) -> list[str]:
20
+ if tags is None:
21
+ return []
22
+ if isinstance(tags, str):
23
+ return [t.strip() for t in tags.split(",") if t.strip()]
24
+ return list(tags)
25
+
26
+
27
+ def write_note(full_path: Path, note: Note) -> None:
28
+ full_path.parent.mkdir(parents=True, exist_ok=True)
29
+
30
+ body = note.content
31
+ extracted_frontmatter = {}
32
+ if note.content.startswith("---"):
33
+ extracted_frontmatter, body = extract_frontmatter(note.content)
34
+ if extracted_frontmatter is None:
35
+ logger.warning("failed to parse embedded frontmatter in %s", full_path)
36
+ extracted_frontmatter = {}
37
+ body = note.content
38
+
39
+ frontmatter = dict(note.frontmatter)
40
+ frontmatter.setdefault("title", note.title)
41
+
42
+ server_tags = _normalize_tags(note.tags)
43
+ extracted_tags = _normalize_tags(extracted_frontmatter.pop("tags", []))
44
+ all_tags = set(server_tags)
45
+ all_tags.update(extracted_tags)
46
+ all_tags.update(_normalize_tags(frontmatter.get("tags", [])))
47
+ if all_tags:
48
+ frontmatter["tags"] = sorted(all_tags)
49
+ elif "tags" in frontmatter:
50
+ del frontmatter["tags"]
51
+
52
+ for key, value in extracted_frontmatter.items():
53
+ if key not in ("title", "created", "updated"):
54
+ frontmatter.setdefault(key, value)
55
+
56
+ now = datetime.now(timezone.utc).isoformat()
57
+ frontmatter.setdefault("created", now)
58
+ frontmatter["updated"] = now
59
+
60
+ content = to_frontmatter(frontmatter) + body
61
+ full_path.write_text(content, encoding="utf-8")
@@ -0,0 +1,39 @@
1
+ import tempfile
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from obsidian_mcp.config import Config
7
+ from obsidian_mcp.indexer.indexer import Indexer
8
+ from obsidian_mcp.indexer.store import IndexStore
9
+ from obsidian_mcp.vault.repository import VaultRepository
10
+
11
+
12
+ @pytest.fixture
13
+ def temp_vault():
14
+ with tempfile.TemporaryDirectory() as tmp:
15
+ yield Path(tmp)
16
+
17
+
18
+ @pytest.fixture
19
+ def config(temp_vault):
20
+ return Config(
21
+ vault_path=temp_vault,
22
+ index_dir=temp_vault / ".index",
23
+ bundle_path=temp_vault,
24
+ )
25
+
26
+
27
+ @pytest.fixture
28
+ def vault(config):
29
+ return VaultRepository(config)
30
+
31
+
32
+ @pytest.fixture
33
+ def store(config):
34
+ return IndexStore(config.index_dir / "index.db")
35
+
36
+
37
+ @pytest.fixture
38
+ def indexer(config, vault, store):
39
+ return Indexer(config, vault, store)
@@ -0,0 +1,87 @@
1
+ import asyncio
2
+ import threading
3
+ import time
4
+
5
+ import pytest
6
+
7
+ from obsidian_mcp.config import Config
8
+ from obsidian_mcp.tools.registry import TOOLS, dispatch_async
9
+ from obsidian_mcp.vault.repository import VaultRepository
10
+
11
+
12
+ def test_dispatch_async_runs_sync_handler_in_thread(monkeypatch):
13
+ events = []
14
+
15
+ def slow_handler(arguments, config):
16
+ events.append(("start", threading.current_thread().name))
17
+ time.sleep(0.1)
18
+ events.append(("end", threading.current_thread().name))
19
+ return "slow-result"
20
+
21
+ monkeypatch.setitem(TOOLS, "slow_tool", {
22
+ "description": "Slow tool",
23
+ "input_schema": {"type": "object"},
24
+ "handler": slow_handler,
25
+ })
26
+
27
+ async def _run():
28
+ start = time.monotonic()
29
+ result = await dispatch_async("slow_tool", {}, Config())
30
+ elapsed = time.monotonic() - start
31
+ return result, elapsed
32
+
33
+ result, elapsed = asyncio.run(_run())
34
+ assert result == "slow-result"
35
+ assert elapsed >= 0.05
36
+ assert any("start" in e[0] for e in events)
37
+
38
+
39
+ def test_dispatch_async_awaits_async_handler(monkeypatch):
40
+ async def async_handler(arguments, config):
41
+ await asyncio.sleep(0.01)
42
+ return "async-result"
43
+
44
+ monkeypatch.setitem(TOOLS, "async_tool", {
45
+ "description": "Async tool",
46
+ "input_schema": {"type": "object"},
47
+ "handler": async_handler,
48
+ })
49
+
50
+ async def _run():
51
+ return await dispatch_async("async_tool", {}, Config())
52
+
53
+ result = asyncio.run(_run())
54
+ assert result == "async-result"
55
+
56
+
57
+ def test_dispatch_async_does_not_block_event_loop(monkeypatch):
58
+ async def background_task():
59
+ await asyncio.sleep(0.05)
60
+ return "background-done"
61
+
62
+ def slow_handler(arguments, config):
63
+ time.sleep(0.15)
64
+ return "done"
65
+
66
+ monkeypatch.setitem(TOOLS, "slow_tool2", {
67
+ "description": "Slow tool",
68
+ "input_schema": {"type": "object"},
69
+ "handler": slow_handler,
70
+ })
71
+
72
+ async def _run():
73
+ task = asyncio.create_task(background_task())
74
+ result = await dispatch_async("slow_tool2", {}, Config())
75
+ background_result = await task
76
+ return result, background_result
77
+
78
+ result, background_result = asyncio.run(_run())
79
+ assert result == "done"
80
+ assert background_result == "background-done"
81
+
82
+
83
+ def test_dispatch_stays_synchronous(config):
84
+ VaultRepository(config)
85
+ from obsidian_mcp.tools.registry import dispatch
86
+ result = dispatch("create_note", {"path": "sync.md", "content": "hello"}, config)
87
+ assert result["status"] == "created"
@@ -0,0 +1,59 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from obsidian_mcp.config import Config
6
+ from obsidian_mcp.indexer.indexer import Indexer
7
+ from obsidian_mcp.indexer.store import IndexStore
8
+ from obsidian_mcp.models import Note
9
+ from obsidian_mcp.rag.engine import RAGEngine
10
+ from obsidian_mcp.tools.registry import dispatch
11
+ from obsidian_mcp.vault.repository import VaultRepository
12
+
13
+
14
+ def test_config_bundle_path_from_env(monkeypatch, tmp_path):
15
+ monkeypatch.setenv("CREWLOOP_BUNDLE_PATH", str(tmp_path))
16
+ config = Config()
17
+ assert config.bundle_path == tmp_path.resolve()
18
+
19
+
20
+ def test_vector_search_empty_vault(config, store):
21
+ engine = RAGEngine(config, store)
22
+ results = engine.search("anything", mode="vector")
23
+ assert results == []
24
+
25
+
26
+ def test_text_search_empty_vault(config, store):
27
+ engine = RAGEngine(config, store)
28
+ results = engine.search("anything", mode="text")
29
+ assert results == []
30
+
31
+
32
+ def test_graph_search_empty_vault(config, store):
33
+ engine = RAGEngine(config, store)
34
+ results = engine.search("anything", mode="graph")
35
+ assert results == []
36
+
37
+
38
+ def test_related_notes_empty_vault(config, store):
39
+ engine = RAGEngine(config, store)
40
+ results = engine.related("missing.md")
41
+ assert results == []
42
+
43
+
44
+ def test_list_notes_empty_vault(config):
45
+ VaultRepository(config)
46
+ result = dispatch("list_notes", {}, config)
47
+ assert result == "No notes found."
48
+
49
+
50
+ def test_sync_from_bundle_empty_bundle(config, temp_vault):
51
+ VaultRepository(config)
52
+ result = dispatch("sync_from_bundle", {"force": True}, config)
53
+ assert result["status"] == "synced"
54
+ assert result["indexed_bundle_files"] == 0
55
+
56
+
57
+ def test_learn_from_text_no_learning(config):
58
+ result = dispatch("learn_from_text", {"text": "Just a random sentence with no clear concept."}, config)
59
+ assert result["status"] == "no_learning_detected"
@@ -0,0 +1,27 @@
1
+ from obsidian_mcp.indexer.embeddings import chunk_text
2
+ from obsidian_mcp.models import Note
3
+
4
+
5
+ def test_chunk_text_character_based():
6
+ long_text = "word " * 200
7
+ chunks = chunk_text(long_text, chunk_size=50, overlap=10)
8
+ assert len(chunks) > 1
9
+ assert all(len(text) <= 80 for text, _, _ in chunks)
10
+
11
+
12
+ def test_index_note(indexer, vault, store):
13
+ note = Note(path="test.md", title="Test", content="Hello world from MCP.")
14
+ vault.save(note)
15
+ indexer.index_note(note)
16
+ chunks = store.get_all_chunks()
17
+ assert any(c.note_path == "test.md" for c in chunks)
18
+
19
+
20
+ def test_compute_backlinks(indexer, vault, store):
21
+ vault.save(Note(path="a.md", title="A", content="Link to [[b]]."))
22
+ vault.save(Note(path="b.md", title="B", content="Content."))
23
+ indexer.index_note(vault.read("a.md"))
24
+ indexer.index_note(vault.read("b.md"))
25
+ indexer.compute_backlinks()
26
+ edges = store.get_all_edges()
27
+ assert any(e.source == "b.md" and e.target == "a.md" and e.relation == "backlink" for e in edges)