@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,90 @@
1
+ import time
2
+ from pathlib import Path
3
+
4
+ from obsidian_mcp.config import Config
5
+ from obsidian_mcp.indexer.indexer import Indexer
6
+ from obsidian_mcp.indexer.store import IndexStore
7
+ from obsidian_mcp.models import Note
8
+ from obsidian_mcp.tools.registry import dispatch
9
+ from obsidian_mcp.vault.repository import VaultRepository
10
+
11
+
12
+ def test_end_to_end_workflow(tmp_path):
13
+ config = Config(
14
+ vault_path=tmp_path / "vault",
15
+ index_dir=tmp_path / ".index",
16
+ bundle_path=Path(__file__).resolve().parents[3],
17
+ )
18
+
19
+ # 1. Sync bundle as initial knowledge
20
+ result = dispatch("sync_from_bundle", {"force": True}, config)
21
+ assert result["status"] == "synced"
22
+ assert result["indexed_bundle_files"] > 0
23
+
24
+ # 2. Search existing knowledge
25
+ search = dispatch("search_notes", {"query": "orchestrator", "mode": "text"}, config)
26
+ assert "orchestrator" in search.lower() or "No results" not in search
27
+
28
+ # 3. Create a note with a link
29
+ dispatch(
30
+ "create_note",
31
+ {
32
+ "path": "projects/mcp-integration.md",
33
+ "title": "MCP Integration",
34
+ "content": "We integrated Obsidian via [[MCP]].",
35
+ "tags": ["integration"],
36
+ },
37
+ config,
38
+ )
39
+
40
+ # 4. Learn from new text
41
+ learn = dispatch(
42
+ "learn_from_text",
43
+ {"text": "Novo conceito: MCP Server conecta Obsidian local ao bundle LEA."},
44
+ config,
45
+ )
46
+ assert learn["status"] == "learned"
47
+ assert any("mcp-server" in p.lower() for p in learn["created_notes"])
48
+
49
+ # 5. Re-index and find related notes
50
+ vault = VaultRepository(config)
51
+ indexer = Indexer(config, vault, IndexStore(config.index_dir / "index.db"))
52
+ indexer.index_all()
53
+ indexer.compute_backlinks()
54
+
55
+ related = dispatch("get_related_notes", {"path": "projects/mcp-integration.md"}, config)
56
+ assert "mcp" in related.lower()
57
+
58
+ # 6. List notes
59
+ listed = dispatch("list_notes", {"folder": "concepts"}, config)
60
+ assert "mcp-server" in listed.lower()
61
+
62
+ # 7. Read generated concept note
63
+ concept_path = learn["created_notes"][0]
64
+ concept = dispatch("read_note", {"path": concept_path}, config)
65
+ assert "MCP Server" in concept
66
+
67
+
68
+ def test_search_performance(tmp_path):
69
+ config = Config(
70
+ vault_path=tmp_path / "vault",
71
+ index_dir=tmp_path / ".index",
72
+ bundle_path=tmp_path,
73
+ )
74
+ vault = VaultRepository(config)
75
+ for i in range(1000):
76
+ vault.save(
77
+ Note(
78
+ path=f"notes/note-{i:04d}.md",
79
+ title=f"Note {i}",
80
+ content=f"This is note number {i} about topic {i % 10} and keyword {i}.",
81
+ )
82
+ )
83
+ indexer = Indexer(config, vault, IndexStore(config.index_dir / "index.db"))
84
+ indexer.index_all()
85
+
86
+ start = time.time()
87
+ result = dispatch("search_notes", {"query": "topic 5", "mode": "text", "limit": 10}, config)
88
+ elapsed = time.time() - start
89
+ assert elapsed < 2.0, f"search took {elapsed:.2f}s"
90
+ assert "note-" in result
@@ -0,0 +1,34 @@
1
+ from obsidian_mcp.learning.detector import LearningDetector
2
+ from obsidian_mcp.learning.note_generator import NoteGenerator
3
+ from obsidian_mcp.tools.registry import dispatch
4
+ from obsidian_mcp.vault.repository import VaultRepository
5
+
6
+
7
+ def test_detect_concept(config, vault):
8
+ detector = LearningDetector(config)
9
+ learnings = detector.detect("Novo conceito: Graph RAG será usado no projeto.")
10
+ assert any(l.type == "concept" and "Graph RAG" in l.title for l in learnings)
11
+
12
+
13
+ def test_detect_decision(config, vault):
14
+ detector = LearningDetector(config)
15
+ learnings = detector.detect("Decidimos que o vault ficará em ~/.lea.")
16
+ assert any(l.type == "decision" for l in learnings)
17
+
18
+
19
+ def test_note_generator(config, vault):
20
+ detector = LearningDetector(config)
21
+ learnings = detector.detect("Novo conceito: MCP Server.")
22
+ gen = NoteGenerator(config, vault)
23
+ note = gen.to_note(learnings[0])
24
+ assert note.path.startswith("concepts/")
25
+ assert note.frontmatter["auto_generated"] is True
26
+
27
+
28
+ def test_learn_from_text_dedup(config):
29
+ VaultRepository(config)
30
+ text = "Novo conceito: Dedup Test no projeto."
31
+ first = dispatch("learn_from_text", {"text": text}, config)
32
+ assert first["status"] == "learned"
33
+ second = dispatch("learn_from_text", {"text": text}, config)
34
+ assert second["status"] == "duplicate"
@@ -0,0 +1,31 @@
1
+ import pytest
2
+
3
+ from obsidian_mcp.config import Config
4
+ from obsidian_mcp.privacy.filter import PrivacyFilter
5
+
6
+
7
+ def test_blocks_api_key():
8
+ f = PrivacyFilter()
9
+ assert not f.is_safe("API_KEY=sk-123")
10
+ with pytest.raises(ValueError):
11
+ f.validate("API_KEY=sk-123")
12
+
13
+
14
+ def test_allows_safe_text():
15
+ f = PrivacyFilter()
16
+ assert f.is_safe("This is a normal note about MCP.")
17
+
18
+
19
+ def test_blocks_email():
20
+ f = PrivacyFilter()
21
+ assert not f.is_safe("Contact me at user@example.com")
22
+
23
+
24
+ def test_blocks_secret_key_prefix():
25
+ f = PrivacyFilter()
26
+ assert not f.is_safe("Key: sk-abc123def456ghi789")
27
+
28
+
29
+ def test_allows_tokenization_word():
30
+ f = PrivacyFilter()
31
+ assert f.is_safe("This note discusses tokenization of text.")
@@ -0,0 +1,44 @@
1
+ import pytest
2
+
3
+ from obsidian_mcp.config import Config, PrivacyConfig
4
+ from obsidian_mcp.privacy.filter import PrivacyFilter
5
+
6
+
7
+ def test_privacy_filter_disabled_via_config():
8
+ config = Config(privacy={"enabled": False})
9
+ f = PrivacyFilter(config)
10
+ assert f.is_safe("API_KEY=secret")
11
+ f.validate("API_KEY=secret") # no exception
12
+
13
+
14
+ def test_privacy_filter_allow_list_bypass():
15
+ config = Config(privacy={"allowed_strings": ["user@example.com"]})
16
+ f = PrivacyFilter(config)
17
+ assert f.is_safe("Contact user@example.com please")
18
+ f.validate("Contact user@example.com please")
19
+
20
+
21
+ def test_privacy_filter_toggle_emails():
22
+ config = Config(privacy={"block_emails": False})
23
+ f = PrivacyFilter(config)
24
+ assert f.is_safe("user@example.com")
25
+
26
+
27
+ def test_privacy_filter_toggle_credit_cards():
28
+ config = Config(privacy={"block_credit_cards": False})
29
+ f = PrivacyFilter(config)
30
+ assert f.is_safe("1234 5678 9012 3456")
31
+
32
+
33
+ def test_privacy_filter_default_still_blocks():
34
+ f = PrivacyFilter()
35
+ assert not f.is_safe("API_KEY=secret")
36
+ assert not f.is_safe("user@example.com")
37
+ with pytest.raises(ValueError):
38
+ f.validate("API_KEY=secret")
39
+
40
+
41
+ def test_privacy_config_object():
42
+ config = Config(privacy=PrivacyConfig(enabled=False))
43
+ f = PrivacyFilter(config)
44
+ assert f.is_safe("anything")
@@ -0,0 +1,64 @@
1
+ from obsidian_mcp.indexer.indexer import Indexer
2
+ from obsidian_mcp.models import Note
3
+ from obsidian_mcp.rag.engine import RAGEngine
4
+
5
+
6
+ def test_text_search(indexer, vault, config, store):
7
+ note = Note(path="search.md", title="Search", content="Obsidian vault local second brain.")
8
+ vault.save(note)
9
+ indexer.index_note(note)
10
+ engine = RAGEngine(config, store)
11
+ results = engine.search("Obsidian", mode="text")
12
+ assert any(r.note_path == "search.md" for r in results)
13
+
14
+
15
+ def test_related_notes(indexer, vault, config, store):
16
+ vault.save(Note(path="a.md", title="A", content="See [[b]]."))
17
+ vault.save(Note(path="b.md", title="B", content="Content."))
18
+ indexer.index_note(vault.read("a.md"))
19
+ indexer.index_note(vault.read("b.md"))
20
+ indexer.compute_backlinks()
21
+ engine = RAGEngine(config, store)
22
+ results = engine.related("a.md")
23
+ assert any(r.note_path == "b.md" for r in results)
24
+
25
+
26
+ def test_graph_search(indexer, vault, config, store):
27
+ vault.save(Note(path="a.md", title="A", content="See [[b]]."))
28
+ vault.save(Note(path="b.md", title="B", content="Content."))
29
+ indexer.index_note(vault.read("a.md"))
30
+ indexer.index_note(vault.read("b.md"))
31
+ indexer.compute_backlinks()
32
+ engine = RAGEngine(config, store)
33
+ results = engine.search("b", mode="graph")
34
+ assert any(r.note_path == "b.md" for r in results)
35
+
36
+
37
+ def test_vector_search_fallback(config, vault, store):
38
+ vault.save(Note(path="v.md", title="V", content="MCP local second brain"))
39
+ indexer = Indexer(config, vault, store)
40
+ indexer.index_all()
41
+ engine = RAGEngine(config, store)
42
+ results = engine.search("MCP", mode="vector")
43
+ assert any(r.note_path == "v.md" for r in results)
44
+
45
+
46
+ def test_vector_search_fallback_ranks_matching_note(config, vault, store):
47
+ vault.save(Note(path="a.md", title="A", content="MCP local second brain"))
48
+ vault.save(Note(path="b.md", title="B", content="Another note about servers and embedding"))
49
+ indexer = Indexer(config, vault, store)
50
+ indexer.index_all()
51
+ engine = RAGEngine(config, store)
52
+ results = engine.search("MCP server", mode="vector")
53
+ assert results[0].note_path == "a.md"
54
+
55
+
56
+ def test_hybrid_search_normalizes_scores(config, vault, store):
57
+ vault.save(Note(path="a.md", title="A", content="MCP local second brain"))
58
+ vault.save(Note(path="b.md", title="B", content="Another note about servers and embedding"))
59
+ indexer = Indexer(config, vault, store)
60
+ indexer.index_all()
61
+ engine = RAGEngine(config, store)
62
+ results = engine.search("MCP", mode="hybrid")
63
+ assert results
64
+ assert all(0.0 <= r.score <= 1.0 for r in results)
@@ -0,0 +1,37 @@
1
+ import pytest
2
+
3
+ from obsidian_mcp.tools.registry import dispatch
4
+ from obsidian_mcp.vault.repository import VaultRepository
5
+
6
+
7
+ def test_read_note_returns_exact_file_content(config, temp_vault):
8
+ VaultRepository(config)
9
+ raw = "---\ntitle: Exact Note\ntags:\n - alpha\n - beta\n---\n\n# Exact Note\n\nKeep the frontmatter exactly as written."
10
+ (temp_vault / "exact.md").write_text(raw, encoding="utf-8")
11
+ result = dispatch("read_note", {"path": "exact.md"}, config)
12
+ assert result == raw
13
+
14
+
15
+ def test_read_note_does_not_inject_title_or_tags(config, temp_vault):
16
+ VaultRepository(config)
17
+ raw = "Plain body without title or tags."
18
+ (temp_vault / "plain.md").write_text(raw, encoding="utf-8")
19
+ result = dispatch("read_note", {"path": "plain.md"}, config)
20
+ assert result == raw
21
+ assert "# " not in result
22
+ assert "**Tags:**" not in result
23
+
24
+
25
+ def test_read_note_non_utf8_replacement(config, vault, temp_vault):
26
+ VaultRepository(config)
27
+ path = temp_vault / "binary.md"
28
+ path.write_bytes(b"\xff\xfeHello\xfa world")
29
+ result = dispatch("read_note", {"path": "binary.md"}, config)
30
+ assert "Hello" in result
31
+ assert "world" in result
32
+
33
+
34
+ def test_read_note_missing_raises(config):
35
+ VaultRepository(config)
36
+ with pytest.raises(FileNotFoundError):
37
+ dispatch("read_note", {"path": "missing.md"}, config)
@@ -0,0 +1,54 @@
1
+ from obsidian_mcp.indexer.indexer import Indexer
2
+ from obsidian_mcp.models import Chunk, Note
3
+ from obsidian_mcp.rag.engine import RAGEngine
4
+ from obsidian_mcp.rag.vector_search import TfidfIndex
5
+
6
+
7
+ def test_tfidf_index_fits_on_corpus():
8
+ index = TfidfIndex()
9
+ chunks = [
10
+ Chunk(id="c1", note_path="a.md", text="MCP local second brain"),
11
+ Chunk(id="c2", note_path="b.md", text="Another note about servers"),
12
+ ]
13
+ index.fit(chunks)
14
+ results = index.query("MCP", top_k=10)
15
+ assert any(doc_id == "c1" for doc_id, _ in results)
16
+
17
+
18
+ def test_tfidf_index_query_empty_corpus():
19
+ index = TfidfIndex()
20
+ index.fit([])
21
+ assert index.query("anything") == []
22
+
23
+
24
+ def test_vector_search_builds_corpus_fitted_index(config, vault, store):
25
+ vault.save(Note(path="a.md", title="A", content="MCP local second brain"))
26
+ indexer = Indexer(config, vault, store)
27
+ indexer.index_all()
28
+ engine = RAGEngine(config, store)
29
+ assert engine.vector_search._tfidf_index is None
30
+ results = engine.search("MCP", mode="vector")
31
+ assert results
32
+ assert engine.vector_search._tfidf_index is not None
33
+ assert engine.vector_search._tfidf_chunk_count > 0
34
+
35
+
36
+ def test_vector_search_reuses_fitted_index(config, vault, store):
37
+ vault.save(Note(path="a.md", title="A", content="MCP local second brain"))
38
+ indexer = Indexer(config, vault, store)
39
+ indexer.index_all()
40
+ engine = RAGEngine(config, store)
41
+ engine.search("MCP", mode="vector")
42
+ first_index = engine.vector_search._tfidf_index
43
+ engine.search("local", mode="vector")
44
+ assert engine.vector_search._tfidf_index is first_index
45
+
46
+
47
+ def test_vector_search_ranks_matching_note_highest(config, vault, store):
48
+ vault.save(Note(path="a.md", title="A", content="MCP local second brain"))
49
+ vault.save(Note(path="b.md", title="B", content="Another note about servers and embedding"))
50
+ indexer = Indexer(config, vault, store)
51
+ indexer.index_all()
52
+ engine = RAGEngine(config, store)
53
+ results = engine.search("MCP server", mode="vector")
54
+ assert results[0].note_path == "a.md"
@@ -0,0 +1,108 @@
1
+ import pytest
2
+
3
+ from obsidian_mcp.indexer.indexer import Indexer
4
+ from obsidian_mcp.indexer.store import IndexStore
5
+ from obsidian_mcp.models import Note
6
+ from obsidian_mcp.tools.registry import dispatch
7
+ from obsidian_mcp.vault.repository import VaultRepository
8
+
9
+
10
+ def test_tool_create_and_read(config):
11
+ VaultRepository(config)
12
+ result = dispatch("create_note", {"path": "t.md", "content": "Hello"}, config)
13
+ assert result["status"] == "created"
14
+ text = dispatch("read_note", {"path": "t.md"}, config)
15
+ assert "Hello" in text
16
+
17
+
18
+ def test_tool_create_note_adds_extension(config):
19
+ VaultRepository(config)
20
+ result = dispatch("create_note", {"path": "no-extension", "content": "body"}, config)
21
+ assert result["path"] == "no-extension.md"
22
+ text = dispatch("read_note", {"path": "no-extension.md"}, config)
23
+ assert "body" in text
24
+
25
+
26
+ def test_tool_create_note_title_from_path(config):
27
+ VaultRepository(config)
28
+ dispatch("create_note", {"path": "my-great-note.md", "content": "body"}, config)
29
+ text = dispatch("read_note", {"path": "my-great-note.md"}, config)
30
+ assert "My Great Note" in text
31
+
32
+
33
+ def test_tool_read_note_returns_raw_content(config, temp_vault):
34
+ VaultRepository(config)
35
+ dispatch(
36
+ "create_note",
37
+ {"path": "fm.md", "title": "FM", "content": "body", "tags": ["a", "b"]},
38
+ config,
39
+ )
40
+ raw = (temp_vault / "fm.md").read_text(encoding="utf-8")
41
+ text = dispatch("read_note", {"path": "fm.md"}, config)
42
+ assert text == raw
43
+ assert "body" in text
44
+ assert "FM" in text
45
+
46
+
47
+ def test_tool_search(config):
48
+ vault = VaultRepository(config)
49
+ vault.save(Note(path="s.md", title="S", content="MCP server local"))
50
+ indexer = Indexer(config, vault, IndexStore(config.index_dir / "index.db"))
51
+ indexer.index_all()
52
+ text = dispatch("search_notes", {"query": "MCP", "mode": "text"}, config)
53
+ assert "s.md" in text
54
+
55
+
56
+ def test_tool_privacy_blocks(config):
57
+ with pytest.raises(ValueError):
58
+ dispatch("create_note", {"path": "bad.md", "content": "API_KEY=secret"}, config)
59
+
60
+
61
+ def test_tool_update_and_delete(config):
62
+ VaultRepository(config)
63
+ dispatch("create_note", {"path": "u.md", "content": "Original", "tags": ["a"]}, config)
64
+ dispatch("update_note", {"path": "u.md", "append": "More", "tags": ["b"]}, config)
65
+ text = dispatch("read_note", {"path": "u.md"}, config)
66
+ assert "Original" in text
67
+ assert "More" in text
68
+ assert "b" in text
69
+ dispatch("delete_note", {"path": "u.md"}, config)
70
+ with pytest.raises(FileNotFoundError):
71
+ dispatch("read_note", {"path": "u.md"}, config)
72
+
73
+
74
+ def test_tool_list_and_related(config):
75
+ vault = VaultRepository(config)
76
+ vault.save(Note(path="x.md", title="X", content="Link to [[y]]."))
77
+ vault.save(Note(path="y.md", title="Y", content="Content."))
78
+ indexer = Indexer(config, vault, IndexStore(config.index_dir / "index.db"))
79
+ indexer.index_all()
80
+ indexer.compute_backlinks()
81
+ listed = dispatch("list_notes", {}, config)
82
+ assert "x.md" in listed
83
+ related = dispatch("get_related_notes", {"path": "x.md"}, config)
84
+ assert "y.md" in related
85
+
86
+
87
+ def test_tool_sync_from_bundle(config, temp_vault):
88
+ (temp_vault / "skills" / "orchestrator").mkdir(parents=True)
89
+ (temp_vault / "skills" / "orchestrator" / "SKILL.md").write_text("# Orchestrator\n")
90
+ (temp_vault / "README.md").write_text("# Bundle\n")
91
+ result = dispatch("sync_from_bundle", {"force": True}, config)
92
+ assert result["status"] == "synced"
93
+ assert result["indexed_bundle_files"] > 0
94
+
95
+
96
+ def test_tool_path_traversal_blocked(config):
97
+ with pytest.raises(ValueError):
98
+ dispatch("create_note", {"path": "../outside.md", "content": "x"}, config)
99
+
100
+
101
+ def test_tool_learn_from_text(config):
102
+ result = dispatch(
103
+ "learn_from_text",
104
+ {"text": "Novo conceito: Observability no projeto LEA."},
105
+ config,
106
+ )
107
+ assert result["status"] == "learned"
108
+ assert any("observability" in p.lower() for p in result["created_notes"])
@@ -0,0 +1,103 @@
1
+ import pytest
2
+
3
+ from obsidian_mcp.models import Note
4
+ from obsidian_mcp.vault.parser import parse_note
5
+ from obsidian_mcp.vault.repository import VaultRepository
6
+
7
+
8
+ def test_parse_note_with_frontmatter(temp_vault):
9
+ path = temp_vault / "test.md"
10
+ path.write_text("---\ntitle: Hello\ntags: [a, b]\n---\n\nBody here with [[link]].")
11
+ note = parse_note("test.md", path)
12
+ assert note.title == "Hello"
13
+ assert note.frontmatter["tags"] == ["a", "b"]
14
+ assert "Body here" in note.content
15
+ assert note.links == ["link.md"]
16
+
17
+
18
+ def test_parse_note_without_frontmatter(temp_vault):
19
+ path = temp_vault / "no-front.md"
20
+ path.write_text("# My Title\n\nContent.")
21
+ note = parse_note("no-front.md", path)
22
+ assert note.title == "My Title"
23
+ assert note.content == "Content."
24
+
25
+
26
+ def test_parse_note_with_invalid_frontmatter(temp_vault):
27
+ path = temp_vault / "bad-front.md"
28
+ path.write_text("---\ntitle: [unclosed\n---\n\nBody.")
29
+ note = parse_note("bad-front.md", path)
30
+ assert note.frontmatter == {}
31
+ assert "Body." in note.content
32
+
33
+
34
+ def test_vault_crud(config):
35
+ repo = VaultRepository(config)
36
+ note = Note(path="foo.md", title="Foo", content="bar")
37
+ repo.save(note)
38
+ assert repo.exists("foo.md")
39
+ read = repo.read("foo.md")
40
+ assert read.title == "Foo"
41
+ repo.delete("foo.md")
42
+ assert not repo.exists("foo.md")
43
+
44
+
45
+ def test_vault_path_escape(config):
46
+ repo = VaultRepository(config)
47
+ with pytest.raises(ValueError):
48
+ repo._resolve("../outside.md")
49
+
50
+
51
+ def test_writer_preserves_created_and_tags(config):
52
+ repo = VaultRepository(config)
53
+ note = Note(path="w.md", title="W", content="body", tags=["tag1"])
54
+ repo.save(note)
55
+ first = repo.read("w.md")
56
+ created = first.frontmatter["created"]
57
+ repo.save(first)
58
+ second = repo.read("w.md")
59
+ assert second.frontmatter["created"] == created
60
+ assert "tag1" in second.frontmatter["tags"]
61
+
62
+
63
+ def test_vault_ignores_hidden_dirs(config):
64
+ repo = VaultRepository(config)
65
+ hidden = repo.root / ".hidden"
66
+ hidden.mkdir()
67
+ (hidden / "secret.md").write_text("# secret")
68
+ (repo.root / "visible.md").write_text("# visible")
69
+ notes = repo.list_notes()
70
+ assert "visible.md" in notes
71
+ assert "secret.md" not in notes
72
+
73
+
74
+ def test_vault_blocks_symlink_escape(config, temp_vault):
75
+ repo = VaultRepository(config)
76
+ outside = temp_vault.parent / "outside.md"
77
+ outside.write_text("# outside")
78
+ link = repo.root / "escape.md"
79
+ link.symlink_to(outside)
80
+ assert "escape.md" not in repo.list_notes()
81
+ with pytest.raises(ValueError):
82
+ repo.read("escape.md")
83
+
84
+
85
+ def test_vault_read_raw_returns_exact_content(config):
86
+ repo = VaultRepository(config)
87
+ raw = "---\ntitle: Raw\ntags:\n - a\n---\n\nbody"
88
+ (repo.root / "raw.md").write_text(raw, encoding="utf-8")
89
+ assert repo.read_raw("raw.md") == raw
90
+
91
+
92
+ def test_vault_read_raw_missing_raises(config):
93
+ repo = VaultRepository(config)
94
+ with pytest.raises(FileNotFoundError):
95
+ repo.read_raw("missing.md")
96
+
97
+
98
+ def test_vault_read_raw_replaces_non_utf8(config, temp_vault):
99
+ repo = VaultRepository(config)
100
+ (repo.root / "binary.md").write_bytes(b"\xff\xfeHello\xfaworld")
101
+ text = repo.read_raw("binary.md")
102
+ assert "Hello" in text
103
+ assert "world" in text