@archznn/crewloop-skills 0.2.0 → 0.4.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 (106) hide show
  1. package/README.md +21 -31
  2. package/assets/templates/skill-template.md +58 -0
  3. package/package.json +5 -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/dashboard/README.md +87 -0
  9. package/servers/dashboard/bin/crewloop-dashboard.js +5 -0
  10. package/servers/dashboard/config-examples/codex-hooks.json +14 -0
  11. package/servers/dashboard/config-examples/kimi-code-config.toml +6 -0
  12. package/servers/dashboard/config-examples/opencode-plugin/crewloop-dashboard.js +64 -0
  13. package/servers/dashboard/package.json +46 -0
  14. package/servers/dashboard/public/app.js +447 -0
  15. package/servers/dashboard/public/index.html +96 -0
  16. package/servers/dashboard/public/styles.css +664 -0
  17. package/servers/dashboard/src/adapters/codex.ts +50 -0
  18. package/servers/dashboard/src/adapters/kimi.ts +40 -0
  19. package/servers/dashboard/src/adapters/opencode.ts +36 -0
  20. package/servers/dashboard/src/adapters/shim.test.ts +74 -0
  21. package/servers/dashboard/src/adapters/shim.ts +120 -0
  22. package/servers/dashboard/src/api/event.ts +70 -0
  23. package/servers/dashboard/src/api/skills.ts +11 -0
  24. package/servers/dashboard/src/config.ts +66 -0
  25. package/servers/dashboard/src/filters/sanitize.test.ts +94 -0
  26. package/servers/dashboard/src/filters/sanitize.ts +78 -0
  27. package/servers/dashboard/src/index.ts +24 -0
  28. package/servers/dashboard/src/presenter.test.ts +69 -0
  29. package/servers/dashboard/src/presenter.ts +56 -0
  30. package/servers/dashboard/src/server.test.ts +123 -0
  31. package/servers/dashboard/src/server.ts +191 -0
  32. package/servers/dashboard/src/skills/infer.test.ts +86 -0
  33. package/servers/dashboard/src/skills/infer.ts +53 -0
  34. package/servers/dashboard/src/skills/mapping.ts +26 -0
  35. package/servers/dashboard/src/skills/registry.ts +60 -0
  36. package/servers/dashboard/src/state.test.ts +88 -0
  37. package/servers/dashboard/src/state.ts +115 -0
  38. package/servers/dashboard/src/types.ts +110 -0
  39. package/servers/dashboard/tsconfig.json +19 -0
  40. package/servers/obsidian-mcp/README.md +82 -0
  41. package/servers/obsidian-mcp/pyproject.toml +32 -0
  42. package/servers/obsidian-mcp/src/obsidian_mcp/__init__.py +0 -0
  43. package/servers/obsidian-mcp/src/obsidian_mcp/config.py +47 -0
  44. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/__init__.py +0 -0
  45. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/embeddings.py +105 -0
  46. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/indexer.py +79 -0
  47. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/store.py +141 -0
  48. package/servers/obsidian-mcp/src/obsidian_mcp/indexer/sync.py +37 -0
  49. package/servers/obsidian-mcp/src/obsidian_mcp/learning/__init__.py +0 -0
  50. package/servers/obsidian-mcp/src/obsidian_mcp/learning/detector.py +66 -0
  51. package/servers/obsidian-mcp/src/obsidian_mcp/learning/note_generator.py +40 -0
  52. package/servers/obsidian-mcp/src/obsidian_mcp/main.py +4 -0
  53. package/servers/obsidian-mcp/src/obsidian_mcp/models.py +42 -0
  54. package/servers/obsidian-mcp/src/obsidian_mcp/privacy/__init__.py +0 -0
  55. package/servers/obsidian-mcp/src/obsidian_mcp/privacy/filter.py +68 -0
  56. package/servers/obsidian-mcp/src/obsidian_mcp/rag/__init__.py +0 -0
  57. package/servers/obsidian-mcp/src/obsidian_mcp/rag/engine.py +50 -0
  58. package/servers/obsidian-mcp/src/obsidian_mcp/rag/graph_search.py +55 -0
  59. package/servers/obsidian-mcp/src/obsidian_mcp/rag/text_search.py +37 -0
  60. package/servers/obsidian-mcp/src/obsidian_mcp/rag/vector_search.py +118 -0
  61. package/servers/obsidian-mcp/src/obsidian_mcp/server.py +61 -0
  62. package/servers/obsidian-mcp/src/obsidian_mcp/tools/__init__.py +0 -0
  63. package/servers/obsidian-mcp/src/obsidian_mcp/tools/create.py +43 -0
  64. package/servers/obsidian-mcp/src/obsidian_mcp/tools/delete.py +16 -0
  65. package/servers/obsidian-mcp/src/obsidian_mcp/tools/learn.py +42 -0
  66. package/servers/obsidian-mcp/src/obsidian_mcp/tools/list.py +16 -0
  67. package/servers/obsidian-mcp/src/obsidian_mcp/tools/read.py +15 -0
  68. package/servers/obsidian-mcp/src/obsidian_mcp/tools/registry.py +130 -0
  69. package/servers/obsidian-mcp/src/obsidian_mcp/tools/related.py +20 -0
  70. package/servers/obsidian-mcp/src/obsidian_mcp/tools/search.py +26 -0
  71. package/servers/obsidian-mcp/src/obsidian_mcp/tools/sync.py +22 -0
  72. package/servers/obsidian-mcp/src/obsidian_mcp/tools/update.py +34 -0
  73. package/servers/obsidian-mcp/src/obsidian_mcp/vault/__init__.py +0 -0
  74. package/servers/obsidian-mcp/src/obsidian_mcp/vault/parser.py +82 -0
  75. package/servers/obsidian-mcp/src/obsidian_mcp/vault/repository.py +68 -0
  76. package/servers/obsidian-mcp/src/obsidian_mcp/vault/writer.py +61 -0
  77. package/servers/obsidian-mcp/tests/conftest.py +39 -0
  78. package/servers/obsidian-mcp/tests/test_async_tools.py +87 -0
  79. package/servers/obsidian-mcp/tests/test_edge_cases.py +59 -0
  80. package/servers/obsidian-mcp/tests/test_indexer.py +27 -0
  81. package/servers/obsidian-mcp/tests/test_integration.py +90 -0
  82. package/servers/obsidian-mcp/tests/test_learning.py +34 -0
  83. package/servers/obsidian-mcp/tests/test_privacy.py +31 -0
  84. package/servers/obsidian-mcp/tests/test_privacy_config.py +44 -0
  85. package/servers/obsidian-mcp/tests/test_rag.py +64 -0
  86. package/servers/obsidian-mcp/tests/test_read_raw.py +37 -0
  87. package/servers/obsidian-mcp/tests/test_tfidf_fallback.py +54 -0
  88. package/servers/obsidian-mcp/tests/test_tools.py +108 -0
  89. package/servers/obsidian-mcp/tests/test_vault.py +103 -0
  90. package/servers/obsidian-mcp/tests/test_writer.py +139 -0
  91. package/skills/accessibility-auditor/SKILL.md +262 -0
  92. package/skills/accessibility-auditor/references/a11y-checklist.md +66 -0
  93. package/skills/architect/SKILL.md +1 -1
  94. package/skills/designer/SKILL.md +1 -1
  95. package/skills/docs-writer/SKILL.md +1 -1
  96. package/skills/engineer/SKILL.md +1 -1
  97. package/skills/maintainer/SKILL.md +22 -22
  98. package/skills/obsidian-second-brain/SKILL.md +48 -13
  99. package/skills/orchestrator/SKILL.md +1 -1
  100. package/skills/product-manager/SKILL.md +22 -22
  101. package/skills/researcher/SKILL.md +22 -22
  102. package/skills/reviewer/SKILL.md +1 -1
  103. package/skills/security-guard/SKILL.md +142 -0
  104. package/skills/security-guard/references/security-checklist.md +57 -0
  105. package/skills/shipper/SKILL.md +1 -1
  106. package/skills/tester/SKILL.md +22 -22
@@ -0,0 +1,61 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import time
5
+
6
+ from mcp import ErrorData, McpError
7
+ from mcp.server import Server
8
+ from mcp.server.stdio import stdio_server
9
+ from mcp.types import TextContent, Tool
10
+
11
+ from obsidian_mcp.config import Config
12
+ from obsidian_mcp.tools.registry import TOOLS, dispatch_async
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _error_code_for(exc: Exception) -> int:
18
+ if isinstance(exc, (ValueError, FileExistsError)):
19
+ return -32600
20
+ if isinstance(exc, FileNotFoundError):
21
+ return -32602
22
+ return -32603
23
+
24
+
25
+ async def serve(config: Config | None = None):
26
+ config = config or Config()
27
+ server = Server("obsidian-mcp")
28
+
29
+ @server.list_tools()
30
+ async def list_tools() -> list[Tool]:
31
+ return [
32
+ Tool(name=name, description=meta["description"], inputSchema=meta["input_schema"])
33
+ for name, meta in TOOLS.items()
34
+ ]
35
+
36
+ @server.call_tool()
37
+ async def call_tool(name: str, arguments: dict | None) -> list[TextContent]:
38
+ arguments = arguments or {}
39
+ start = time.perf_counter()
40
+ logger.info("tool start: %s", name)
41
+ try:
42
+ result = await dispatch_async(name, arguments, config)
43
+ if isinstance(result, str):
44
+ return [TextContent(type="text", text=result)]
45
+ return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
46
+ except Exception as exc:
47
+ logger.exception("tool error: %s", name)
48
+ raise McpError(
49
+ ErrorData(code=_error_code_for(exc), message=str(exc))
50
+ ) from exc
51
+ finally:
52
+ elapsed = time.perf_counter() - start
53
+ logger.info("tool end: %s (%.3fs)", name, elapsed)
54
+
55
+ async with stdio_server() as (read_stream, write_stream):
56
+ await server.run(read_stream, write_stream, server.create_initialization_options())
57
+
58
+
59
+ def main():
60
+ logging.basicConfig(level=logging.INFO)
61
+ asyncio.run(serve())
@@ -0,0 +1,43 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ from obsidian_mcp.config import Config
5
+ from obsidian_mcp.models import Note
6
+ from obsidian_mcp.privacy.filter import PrivacyFilter
7
+ from obsidian_mcp.vault.repository import VaultRepository
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def handle_create_note(arguments: dict, config: Config) -> dict:
13
+ path = arguments.get("path")
14
+ content = arguments.get("content", "")
15
+ title = arguments.get("title")
16
+ tags = arguments.get("tags", [])
17
+ overwrite = bool(arguments.get("overwrite", False))
18
+ if not path:
19
+ raise ValueError("path is required")
20
+
21
+ if not path.endswith(".md"):
22
+ path = path + ".md"
23
+
24
+ PrivacyFilter(config).validate(path)
25
+ PrivacyFilter(config).validate(content)
26
+
27
+ vault = VaultRepository(config)
28
+ if vault.exists(path) and not overwrite:
29
+ raise FileExistsError(f"note already exists: {path}")
30
+
31
+ note = Note(
32
+ path=path,
33
+ title=title or _title_from_path(path),
34
+ content=content,
35
+ tags=tags,
36
+ )
37
+ vault.save(note)
38
+ logger.info("created note: %s", path)
39
+ return {"status": "created", "path": path}
40
+
41
+
42
+ def _title_from_path(path: str) -> str:
43
+ return Path(path).stem.replace("-", " ").replace("_", " ").title()
@@ -0,0 +1,16 @@
1
+ import logging
2
+
3
+ from obsidian_mcp.config import Config
4
+ from obsidian_mcp.vault.repository import VaultRepository
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def handle_delete_note(arguments: dict, config: Config) -> dict:
10
+ path = arguments.get("path")
11
+ if not path:
12
+ raise ValueError("path is required")
13
+ vault = VaultRepository(config)
14
+ vault.delete(path)
15
+ logger.info("deleted note: %s", path)
16
+ return {"status": "deleted", "path": path}
@@ -0,0 +1,42 @@
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.learning.detector import LearningDetector
7
+ from obsidian_mcp.learning.note_generator import NoteGenerator
8
+ from obsidian_mcp.privacy.filter import PrivacyFilter
9
+ from obsidian_mcp.vault.repository import VaultRepository
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def handle_learn_from_text(arguments: dict, config: Config) -> dict:
15
+ text = arguments.get("text", "")
16
+ if not text:
17
+ raise ValueError("text is required")
18
+
19
+ PrivacyFilter(config).validate(text)
20
+
21
+ detector = LearningDetector(config)
22
+ learnings = detector.detect(text)
23
+ if not learnings:
24
+ return {"status": "no_learning_detected"}
25
+
26
+ vault = VaultRepository(config)
27
+ generator = NoteGenerator(config, vault)
28
+ indexer = Indexer(config, vault, IndexStore(config.index_dir / "index.db"))
29
+
30
+ created = []
31
+ for learning in learnings:
32
+ if vault.exists(generator.path_for(learning)):
33
+ continue
34
+ note = generator.generate_and_save(learning)
35
+ indexer.index_note(note)
36
+ created.append(note.path)
37
+
38
+ if not created:
39
+ logger.info("no new learnings created from text")
40
+ return {"status": "duplicate", "created_notes": []}
41
+ logger.info("learned from text, created notes: %s", created)
42
+ return {"status": "learned", "created_notes": created}
@@ -0,0 +1,16 @@
1
+ import logging
2
+
3
+ from obsidian_mcp.config import Config
4
+ from obsidian_mcp.vault.repository import VaultRepository
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def handle_list_notes(arguments: dict, config: Config) -> str:
10
+ folder = arguments.get("folder")
11
+ vault = VaultRepository(config)
12
+ notes = vault.list_notes(folder)
13
+ logger.debug("listed %d notes in folder %s", len(notes), folder)
14
+ if not notes:
15
+ return "No notes found."
16
+ return "\n".join(f"- {n}" for n in notes)
@@ -0,0 +1,15 @@
1
+ import logging
2
+
3
+ from obsidian_mcp.config import Config
4
+ from obsidian_mcp.vault.repository import VaultRepository
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def handle_read_note(arguments: dict, config: Config) -> str:
10
+ path = arguments.get("path")
11
+ if not path:
12
+ raise ValueError("path is required")
13
+ vault = VaultRepository(config)
14
+ logger.debug("reading note: %s", path)
15
+ return vault.read_raw(path)
@@ -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")