@archznn/crewloop-skills 0.2.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 (74) hide show
  1. package/README.md +21 -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 +1 -1
  62. package/skills/designer/SKILL.md +1 -1
  63. package/skills/docs-writer/SKILL.md +1 -1
  64. package/skills/engineer/SKILL.md +1 -1
  65. package/skills/maintainer/SKILL.md +22 -22
  66. package/skills/obsidian-second-brain/SKILL.md +48 -13
  67. package/skills/orchestrator/SKILL.md +1 -1
  68. package/skills/product-manager/SKILL.md +22 -22
  69. package/skills/researcher/SKILL.md +22 -22
  70. package/skills/reviewer/SKILL.md +1 -1
  71. package/skills/security-guard/SKILL.md +142 -0
  72. package/skills/security-guard/references/security-checklist.md +57 -0
  73. package/skills/shipper/SKILL.md +1 -1
  74. package/skills/tester/SKILL.md +22 -22
@@ -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
@@ -0,0 +1,139 @@
1
+ from datetime import datetime
2
+
3
+ import pytest
4
+ import yaml
5
+
6
+ from obsidian_mcp.models import Note
7
+ from obsidian_mcp.vault.writer import write_note
8
+
9
+
10
+ def _parse_frontmatter(text: str) -> dict:
11
+ assert text.startswith("---")
12
+ end = text.find("\n---", 3)
13
+ assert end != -1
14
+ return yaml.safe_load(text[3:end])
15
+
16
+
17
+ def _get_body(text: str) -> str:
18
+ end = text.find("\n---", 3)
19
+ return text[end + 5 :].lstrip("\n")
20
+
21
+
22
+ def test_write_note_without_frontmatter(temp_vault):
23
+ path = temp_vault / "clean.md"
24
+ note = Note(path="clean.md", title="Clean Note", content="# Clean Note\n\nBody.")
25
+ write_note(path, note)
26
+
27
+ written = path.read_text(encoding="utf-8")
28
+ frontmatter = _parse_frontmatter(written)
29
+ body = _get_body(written)
30
+
31
+ assert frontmatter["title"] == "Clean Note"
32
+ assert "created" in frontmatter
33
+ assert "updated" in frontmatter
34
+ assert body == "# Clean Note\n\nBody."
35
+
36
+
37
+ def test_write_note_with_embedded_frontmatter(temp_vault):
38
+ path = temp_vault / "embedded.md"
39
+ content = (
40
+ "---\n"
41
+ "type: journal\n"
42
+ "title: Embedded Title\n"
43
+ "tags: [a, b]\n"
44
+ "updated: 2026-06-16T17:35:00Z\n"
45
+ "---\n"
46
+ "\n"
47
+ "# Embedded Title\n"
48
+ "\n"
49
+ "Body text."
50
+ )
51
+ note = Note(path="embedded.md", title="Server Title", content=content, tags=["c"])
52
+ write_note(path, note)
53
+
54
+ written = path.read_text(encoding="utf-8")
55
+ frontmatter = _parse_frontmatter(written)
56
+ body = _get_body(written)
57
+
58
+ assert frontmatter["title"] == "Server Title"
59
+ assert frontmatter["type"] == "journal"
60
+ assert frontmatter["tags"] == ["a", "b", "c"]
61
+ assert "created" in frontmatter
62
+ assert "updated" in frontmatter
63
+ assert body == "# Embedded Title\n\nBody text."
64
+
65
+
66
+ def test_write_note_with_malformed_frontmatter(temp_vault, caplog):
67
+ path = temp_vault / "bad.md"
68
+ content = "---\ntitle: [unclosed\n---\n\nBody."
69
+ note = Note(path="bad.md", title="Bad Note", content=content)
70
+
71
+ with caplog.at_level("WARNING"):
72
+ write_note(path, note)
73
+
74
+ written = path.read_text(encoding="utf-8")
75
+ frontmatter = _parse_frontmatter(written)
76
+ body = _get_body(written)
77
+
78
+ assert "failed to parse frontmatter" in caplog.text.lower()
79
+ assert frontmatter["title"] == "Bad Note"
80
+ assert body == "Body."
81
+
82
+
83
+ def test_write_note_merges_tags(temp_vault):
84
+ path = temp_vault / "tags.md"
85
+ content = "---\ntags: [z, a, a]\n---\n\nBody."
86
+ note = Note(path="tags.md", title="Tags Note", content=content, tags=["b", "a"])
87
+ write_note(path, note)
88
+
89
+ written = path.read_text(encoding="utf-8")
90
+ frontmatter = _parse_frontmatter(written)
91
+
92
+ assert frontmatter["tags"] == ["a", "b", "z"]
93
+
94
+
95
+ def test_write_note_preserves_created_on_update(temp_vault):
96
+ path = temp_vault / "preserve.md"
97
+ note = Note(path="preserve.md", title="Preserve", content="Body.")
98
+ write_note(path, note)
99
+
100
+ first = path.read_text(encoding="utf-8")
101
+ first_created = _parse_frontmatter(first)["created"]
102
+
103
+ note2 = Note(
104
+ path="preserve.md",
105
+ title="Preserve",
106
+ content="Updated body.",
107
+ frontmatter={"created": first_created},
108
+ )
109
+ write_note(path, note2)
110
+
111
+ second = path.read_text(encoding="utf-8")
112
+ second_frontmatter = _parse_frontmatter(second)
113
+
114
+ assert second_frontmatter["created"] == first_created
115
+ assert second_frontmatter["updated"] != first_created
116
+
117
+
118
+ def test_write_note_title_precedence(temp_vault):
119
+ path = temp_vault / "title.md"
120
+ content = "---\ntitle: Embedded Title\n---\n\nBody."
121
+ note = Note(path="title.md", title="Server Title", content=content)
122
+ write_note(path, note)
123
+
124
+ written = path.read_text(encoding="utf-8")
125
+ frontmatter = _parse_frontmatter(written)
126
+
127
+ assert frontmatter["title"] == "Server Title"
128
+
129
+
130
+ def test_write_note_tags_as_string(temp_vault):
131
+ path = temp_vault / "string-tags.md"
132
+ content = "---\ntags: x, y, z\n---\n\nBody."
133
+ note = Note(path="string-tags.md", title="String Tags", content=content)
134
+ write_note(path, note)
135
+
136
+ written = path.read_text(encoding="utf-8")
137
+ frontmatter = _parse_frontmatter(written)
138
+
139
+ assert frontmatter["tags"] == ["x", "y", "z"]