From 124838ec88de2d116d777de9cda7ede7063611e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Lenne?= Date: Sun, 5 Jul 2026 15:35:36 +0200 Subject: [PATCH 1/5] feat: add the obsidian connector for nao cli [nao-cli] --- cli/README.md | 2 + .../commands/sync/providers/__init__.py | 2 + .../sync/providers/obsidian/__init__.py | 5 + .../sync/providers/obsidian/provider.py | 114 ++++++++++++++++++ cli/nao_core/config/__init__.py | 2 + cli/nao_core/config/base.py | 17 +++ cli/nao_core/config/obsidian/__init__.py | 16 +++ cli/nao_core/templates/__init__.py | 5 +- cli/nao_core/templates/context.py | 77 ++++++++++++ .../commands/sync/test_obsidian_provider.py | 67 ++++++++++ .../nao_core/commands/sync/test_providers.py | 4 +- cli/tests/nao_core/commands/test_init.py | 1 + example/nao_config.yaml | 1 + scripts/generate-config-docs.py | 14 +++ 14 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 cli/nao_core/commands/sync/providers/obsidian/__init__.py create mode 100644 cli/nao_core/commands/sync/providers/obsidian/provider.py create mode 100644 cli/nao_core/config/obsidian/__init__.py create mode 100644 cli/tests/nao_core/commands/sync/test_obsidian_provider.py diff --git a/cli/README.md b/cli/README.md index c6cc0e1aa..474dfa4ae 100644 --- a/cli/README.md +++ b/cli/README.md @@ -90,6 +90,7 @@ This will create a new nao project in the current directory. It will prompt you - **`ai_summary` template + model** (prompted only when you enable `ai_summary` for databases) - **Slack integration** - **Notion integration** +- **Obsidian integration** The resulting project structure looks like: @@ -166,6 +167,7 @@ Syncs configured resources to local files: - **Databases** - generates markdown docs (`columns.md`, `preview.md`, `description.md`) for each table into `databases/` - **Git repositories** — clones or pulls repos into `repos/` - **Notion pages** — exports pages as markdown into `docs/notion/` +- **Obsidian notes** — copies markdown notes from a local vault into `docs/obsidian/` After syncing, any Jinja templates (`*.j2` files) in the project directory are rendered with the nao context. diff --git a/cli/nao_core/commands/sync/providers/__init__.py b/cli/nao_core/commands/sync/providers/__init__.py index 1a8e7a847..e31e70394 100644 --- a/cli/nao_core/commands/sync/providers/__init__.py +++ b/cli/nao_core/commands/sync/providers/__init__.py @@ -5,11 +5,13 @@ from .base import SyncProvider, SyncResult from .databases.provider import DatabaseSyncProvider from .notion.provider import NotionSyncProvider +from .obsidian.provider import ObsidianSyncProvider from .repositories.provider import RepositorySyncProvider # Provider registry mapping CLI-friendly names to provider instances PROVIDER_REGISTRY: dict[str, SyncProvider] = { "notion": NotionSyncProvider(), + "obsidian": ObsidianSyncProvider(), "repositories": RepositorySyncProvider(), "databases": DatabaseSyncProvider(), } diff --git a/cli/nao_core/commands/sync/providers/obsidian/__init__.py b/cli/nao_core/commands/sync/providers/obsidian/__init__.py new file mode 100644 index 000000000..175f3bee1 --- /dev/null +++ b/cli/nao_core/commands/sync/providers/obsidian/__init__.py @@ -0,0 +1,5 @@ +"""Obsidian syncing functionality for syncing local Obsidian vault notes.""" + +from .provider import ObsidianSyncProvider + +__all__ = ["ObsidianSyncProvider"] diff --git a/cli/nao_core/commands/sync/providers/obsidian/provider.py b/cli/nao_core/commands/sync/providers/obsidian/provider.py new file mode 100644 index 000000000..5369ad114 --- /dev/null +++ b/cli/nao_core/commands/sync/providers/obsidian/provider.py @@ -0,0 +1,114 @@ +from pathlib import Path, PurePosixPath + +from rich.console import Console + +from nao_core.config.base import NaoConfig +from nao_core.config.obsidian import ObsidianConfig + +from ..base import SyncProvider, SyncResult + +console = Console() + +IGNORED_DIR_NAMES = {".obsidian"} + + +def cleanup_stale_notes(synced_files: set[str], output_path: Path, verbose: bool = False) -> int: + """Remove markdown files and empty directories that were not synced.""" + if not output_path.exists(): + return 0 + + removed_count = 0 + for file_path in sorted(output_path.rglob("*.md"), reverse=True): + relative = PurePosixPath(file_path.relative_to(output_path)).as_posix() + if relative not in synced_files: + file_path.unlink() + removed_count += 1 + if verbose: + console.print(f" [dim red]removing stale note:[/dim red] {relative}") + + for dir_path in sorted((p for p in output_path.rglob("*") if p.is_dir()), reverse=True): + if dir_path != output_path: + try: + dir_path.rmdir() + except OSError: + pass + + return removed_count + + +def iter_markdown_files(vault_path: Path): + """Yield markdown files from the vault, skipping Obsidian metadata directories.""" + for path in vault_path.rglob("*.md"): + if any(part in IGNORED_DIR_NAMES for part in path.parts): + continue + if not path.is_file(): + continue + yield path + + +class ObsidianSyncProvider(SyncProvider): + """Provider for syncing local Obsidian vault notes.""" + + @property + def name(self) -> str: + return "Obsidian" + + @property + def emoji(self) -> str: + return "🗂️" + + @property + def default_output_dir(self) -> str: + return "docs/obsidian" + + def get_items(self, config: NaoConfig) -> list[ObsidianConfig]: + return [config.obsidian] if config.obsidian else [] + + def sync( + self, + items: list[ObsidianConfig], + output_path: Path, + project_path: Path | None = None, + *, + threads: int = 1, + ) -> SyncResult: + if not items: + console.print("\n[dim]No Obsidian vault configured[/dim]") + return SyncResult(provider_name=self.name, items_synced=0, summary="No Obsidian configuration configured") + + obsidian_config = items[0] + vault_path = Path(obsidian_config.path).expanduser().resolve() + if not vault_path.exists(): + raise FileNotFoundError(f"Obsidian vault path does not exist: {vault_path}") + if not vault_path.is_dir(): + raise ValueError(f"Obsidian vault path is not a directory: {vault_path}") + + output_path.mkdir(parents=True, exist_ok=True) + notes_synced = 0 + synced_files: set[str] = set() + + console.print(f"\n[bold cyan]{self.emoji} Syncing {self.name}[/bold cyan]") + console.print(f"[dim]Vault:[/dim] {vault_path}") + console.print(f"[dim]Location:[/dim] {output_path.absolute()}\n") + + for note_path in iter_markdown_files(vault_path): + relative_path = note_path.relative_to(vault_path) + destination = output_path / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(note_path.read_text(encoding="utf-8"), encoding="utf-8") + + notes_synced += 1 + synced_files.add(PurePosixPath(relative_path).as_posix()) + + removed_count = cleanup_stale_notes(synced_files, output_path, verbose=True) + + summary = f"{notes_synced} markdown notes synced" + if removed_count > 0: + summary += f", {removed_count} stale removed" + + return SyncResult( + provider_name=self.name, + items_synced=notes_synced, + details={"removed": removed_count}, + summary=summary, + ) diff --git a/cli/nao_core/config/__init__.py b/cli/nao_core/config/__init__.py index 7bdfe865b..cc0cbf7ad 100644 --- a/cli/nao_core/config/__init__.py +++ b/cli/nao_core/config/__init__.py @@ -15,6 +15,7 @@ ) from .exceptions import InitError from .llm import PROVIDER_AUTH, LLMConfig, LLMProvider, ProviderAuthConfig +from .obsidian import ObsidianConfig from .slack import SlackConfig __all__ = [ @@ -37,6 +38,7 @@ "PROVIDER_AUTH", "ProviderAuthConfig", "SlackConfig", + "ObsidianConfig", "InitError", "resolve_project_path", ] diff --git a/cli/nao_core/config/base.py b/cli/nao_core/config/base.py index b0e09bfb9..179cb0744 100644 --- a/cli/nao_core/config/base.py +++ b/cli/nao_core/config/base.py @@ -19,6 +19,7 @@ from .llm import LLMConfig from .mcp import McpConfig from .notion import NotionConfig +from .obsidian import ObsidianConfig from .repos import RepoConfig from .secrets import process_secrets from .skills import SkillsConfig @@ -39,6 +40,7 @@ class NaoConfig(BaseModel): databases: list[AnyDatabaseConfig] = Field(default_factory=list, description="The databases to use") repos: list[RepoConfig] = Field(default_factory=list, description="The repositories to use") notion: NotionConfig | None = Field(default=None, description="The Notion configurations") + obsidian: ObsidianConfig | None = Field(default=None, description="The Obsidian configuration") llm: LLMConfig | None = Field(default=None, description="The LLM configuration") slack: SlackConfig | None = Field(default=None, description="The Slack configuration") mcp: McpConfig | None = Field(default=None, description="The MCP configuration") @@ -76,6 +78,7 @@ def promptConfig(cls, project_name: str, existing: "NaoConfig | None" = None) -> llm=llm, slack=cls._prompt_slack(), notion=cls._prompt_notion(), + obsidian=cls._prompt_obsidian(), mcp=cls._prompt_mcp(project_name), skills=cls._prompt_skills(project_name), ) @@ -88,6 +91,7 @@ def _prompt_extend(cls, existing: "NaoConfig") -> "NaoConfig": llm = existing.llm slack = existing.slack notion = existing.notion + obsidian = existing.obsidian mcp = existing.mcp skills = existing.skills @@ -103,6 +107,8 @@ def _prompt_extend(cls, existing: "NaoConfig") -> "NaoConfig": UI.print(" Slack: configured") if notion: UI.print(" Notion: configured") + if obsidian: + UI.print(" Obsidian: configured") if mcp: UI.print(" MCP: configured") if skills: @@ -128,6 +134,9 @@ def _prompt_extend(cls, existing: "NaoConfig") -> "NaoConfig": if not notion: notion = cls._prompt_notion() + if not obsidian: + obsidian = cls._prompt_obsidian() + if not mcp: mcp = cls._prompt_mcp(existing.project_name) @@ -143,6 +152,7 @@ def _prompt_extend(cls, existing: "NaoConfig") -> "NaoConfig": llm=llm, slack=slack, notion=notion, + obsidian=obsidian, mcp=mcp, skills=skills, ) @@ -263,6 +273,13 @@ def _prompt_notion() -> NotionConfig | None: return NotionConfig.promptConfig() return None + @staticmethod + def _prompt_obsidian() -> ObsidianConfig | None: + """Prompt for Obsidian configuration using questionary.""" + if ask_confirm("Set up Obsidian integration?", default=False): + return ObsidianConfig.promptConfig() + return None + @staticmethod def _prompt_mcp(project_name: str) -> McpConfig | None: """Prompt for MCP configuration using questionary.""" diff --git a/cli/nao_core/config/obsidian/__init__.py b/cli/nao_core/config/obsidian/__init__.py new file mode 100644 index 000000000..055a06974 --- /dev/null +++ b/cli/nao_core/config/obsidian/__init__.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, Field + +from nao_core.ui import UI, ask_text + + +class ObsidianConfig(BaseModel): + """Obsidian configuration.""" + + path: str = Field(description="The local path to the Obsidian vault") + + @classmethod + def promptConfig(cls) -> "ObsidianConfig": + """Interactively prompt the user for Obsidian configuration.""" + UI.info("Enter the local path to your Obsidian vault:") + path = ask_text("Vault path:", required_field=True) + return ObsidianConfig(path=path) # type: ignore[arg-type] diff --git a/cli/nao_core/templates/__init__.py b/cli/nao_core/templates/__init__.py index 03a1d4f90..80eb6a8cc 100644 --- a/cli/nao_core/templates/__init__.py +++ b/cli/nao_core/templates/__init__.py @@ -13,9 +13,10 @@ # {{ nao.config.project_name }} {{ nao.notion.page('https://notion.so/...').content }} + {{ nao.obsidian.note('Projects/roadmap.md').content }} """ -from .context import NaoContext, NotionPage, NotionProvider, create_nao_context +from .context import NaoContext, NotionPage, NotionProvider, ObsidianNote, ObsidianProvider, create_nao_context from .engine import TemplateEngine, get_template_engine from .render import ( TemplateRenderResult, @@ -32,6 +33,8 @@ "NaoContext", "NotionPage", "NotionProvider", + "ObsidianNote", + "ObsidianProvider", "create_nao_context", # Render "TemplateRenderResult", diff --git a/cli/nao_core/templates/context.py b/cli/nao_core/templates/context.py index 6b0107061..c9e588243 100644 --- a/cli/nao_core/templates/context.py +++ b/cli/nao_core/templates/context.py @@ -6,6 +6,7 @@ Example template usage: {{ nao.notion.page('https://notion.so/...').content }} {{ nao.notion.page('abc123').title }} + {{ nao.obsidian.note('Projects/roadmap.md').content }} {{ nao.file.yaml('metadata.yaml').description }} {{ nao.file.text('README.md') }} """ @@ -334,6 +335,72 @@ def page(self, page_url_or_id: str) -> NotionPage: return self._page_cache[page_url_or_id] +@dataclass +class ObsidianNote: + """Represents an Obsidian note from the synced local vault.""" + + relative_path: str + snapshot_root: Path + _data: dict[str, Any] | None = None + + def _load(self) -> dict[str, Any]: + """Lazily load note data from the synced snapshot.""" + if self._data is None: + note_path = (self.snapshot_root / self.relative_path).resolve() + if not note_path.is_relative_to(self.snapshot_root.resolve()): + raise ValueError(f"Path traversal is not allowed: '{self.relative_path}'") + if not note_path.exists(): + raise FileNotFoundError(f"Obsidian note not found: '{self.relative_path}'") + if not note_path.is_file(): + raise ValueError(f"Obsidian note path is not a file: '{self.relative_path}'") + + content = note_path.read_text(encoding="utf-8") + frontmatter = FileProvider(self.snapshot_root).frontmatter(self.relative_path) + self._data = { + "path": self.relative_path, + "title": note_path.stem, + "content": content, + "frontmatter": frontmatter["meta"], + } + return self._data + + @property + def path(self) -> str: + return self._load()["path"] + + @property + def title(self) -> str: + return self._load()["title"] + + @property + def content(self) -> str: + return self._load()["content"] + + @property + def frontmatter(self) -> dict[str, Any]: + return self._load()["frontmatter"] + + def __str__(self) -> str: + return self.content + + +class ObsidianProvider: + """Provider interface for accessing synced Obsidian notes in templates.""" + + def __init__(self, project_path: Path): + self._snapshot_root = project_path / "docs" / "obsidian" + self._note_cache: dict[str, ObsidianNote] = {} + + def note(self, relative_path: str) -> ObsidianNote: + """Get a synced Obsidian note by path relative to the vault root.""" + if relative_path not in self._note_cache: + self._note_cache[relative_path] = ObsidianNote( + relative_path=relative_path, + snapshot_root=self._snapshot_root, + ) + return self._note_cache[relative_path] + + class NaoContext: """The main context object exposed as `nao` in user templates. @@ -343,6 +410,7 @@ class NaoContext: Example template usage: {{ nao.notion.page('url').content }} + {{ nao.obsidian.note('Projects/roadmap.md').content }} {{ nao.config.project_name }} """ @@ -375,6 +443,15 @@ def notion(self) -> NotionProvider: """ return NotionProvider(self._config) + @cached_property + def obsidian(self) -> ObsidianProvider: + """Access synced Obsidian notes. + + Example: + {{ nao.obsidian.note('Projects/roadmap.md').content }} + """ + return ObsidianProvider(self._project_path) + @property def config(self) -> NaoConfig: """Access the nao configuration. diff --git a/cli/tests/nao_core/commands/sync/test_obsidian_provider.py b/cli/tests/nao_core/commands/sync/test_obsidian_provider.py new file mode 100644 index 000000000..15f59d0ce --- /dev/null +++ b/cli/tests/nao_core/commands/sync/test_obsidian_provider.py @@ -0,0 +1,67 @@ +"""Unit tests for the Obsidian sync provider.""" + +from pathlib import Path + +import pytest + +from nao_core.commands.sync.providers.obsidian.provider import ObsidianSyncProvider +from nao_core.config.obsidian import ObsidianConfig + + +def test_sync_notes_preserves_directory_structure(tmp_path: Path): + vault_path = tmp_path / "vault" + (vault_path / "Projects").mkdir(parents=True) + (vault_path / "Projects" / "roadmap.md").write_text("# Roadmap", encoding="utf-8") + (vault_path / "Inbox.md").write_text("# Inbox", encoding="utf-8") + + provider = ObsidianSyncProvider() + config = ObsidianConfig(path=str(vault_path)) + + result = provider.sync([config], tmp_path / "output") + + assert result.items_synced == 2 + assert (tmp_path / "output" / "Projects" / "roadmap.md").read_text(encoding="utf-8") == "# Roadmap" + assert (tmp_path / "output" / "Inbox.md").read_text(encoding="utf-8") == "# Inbox" + + +def test_sync_notes_ignores_obsidian_metadata(tmp_path: Path): + vault_path = tmp_path / "vault" + (vault_path / ".obsidian").mkdir(parents=True) + (vault_path / ".obsidian" / "workspace.md").write_text("# Hidden", encoding="utf-8") + (vault_path / "Visible.md").write_text("# Visible", encoding="utf-8") + + provider = ObsidianSyncProvider() + config = ObsidianConfig(path=str(vault_path)) + + result = provider.sync([config], tmp_path / "output") + + assert result.items_synced == 1 + assert (tmp_path / "output" / "Visible.md").exists() + assert not (tmp_path / "output" / ".obsidian" / "workspace.md").exists() + + +def test_sync_notes_removes_stale_files(tmp_path: Path): + vault_path = tmp_path / "vault" + vault_path.mkdir() + (vault_path / "Current.md").write_text("# Current", encoding="utf-8") + + output_path = tmp_path / "output" + (output_path / "Old.md").parent.mkdir(parents=True, exist_ok=True) + (output_path / "Old.md").write_text("# Old", encoding="utf-8") + + provider = ObsidianSyncProvider() + config = ObsidianConfig(path=str(vault_path)) + + result = provider.sync([config], output_path) + + assert result.items_synced == 1 + assert not (output_path / "Old.md").exists() + assert (output_path / "Current.md").exists() + + +def test_sync_notes_requires_existing_directory(tmp_path: Path): + provider = ObsidianSyncProvider() + config = ObsidianConfig(path=str(tmp_path / "missing")) + + with pytest.raises(FileNotFoundError, match="Obsidian vault path does not exist"): + provider.sync([config], tmp_path / "output") diff --git a/cli/tests/nao_core/commands/sync/test_providers.py b/cli/tests/nao_core/commands/sync/test_providers.py index a209b6f60..96537a203 100644 --- a/cli/tests/nao_core/commands/sync/test_providers.py +++ b/cli/tests/nao_core/commands/sync/test_providers.py @@ -10,6 +10,7 @@ ) from nao_core.commands.sync.providers.databases.provider import DatabaseSyncProvider from nao_core.commands.sync.providers.notion.provider import NotionSyncProvider +from nao_core.commands.sync.providers.obsidian.provider import ObsidianSyncProvider from nao_core.commands.sync.providers.repositories.provider import RepositorySyncProvider @@ -51,10 +52,11 @@ class TestGetAllProviders: def test_returns_list_of_providers(self): providers = get_all_providers() - assert len(providers) == 3 + assert len(providers) == 4 assert any(isinstance(p.provider, RepositorySyncProvider) for p in providers) assert any(isinstance(p.provider, DatabaseSyncProvider) for p in providers) assert any(isinstance(p.provider, NotionSyncProvider) for p in providers) + assert any(isinstance(p.provider, ObsidianSyncProvider) for p in providers) def test_returns_copy_of_providers(self): providers1 = get_all_providers() diff --git a/cli/tests/nao_core/commands/test_init.py b/cli/tests/nao_core/commands/test_init.py index e928368f3..1f13c7832 100644 --- a/cli/tests/nao_core/commands/test_init.py +++ b/cli/tests/nao_core/commands/test_init.py @@ -891,6 +891,7 @@ def test_creates_minimal_config_when_no_existing(self): assert result.llm is None assert result.slack is None assert result.notion is None + assert result.obsidian is None assert result.mcp is None assert result.skills is None diff --git a/example/nao_config.yaml b/example/nao_config.yaml index 3d47e2943..2d5a98aa1 100644 --- a/example/nao_config.yaml +++ b/example/nao_config.yaml @@ -16,5 +16,6 @@ repos: url: https://github.com/dbt-labs/jaffle_shop_duckdb.git branch: null notion: null +obsidian: null llm: null slack: null diff --git a/scripts/generate-config-docs.py b/scripts/generate-config-docs.py index f9cabe492..a00d6eb32 100755 --- a/scripts/generate-config-docs.py +++ b/scripts/generate-config-docs.py @@ -39,6 +39,7 @@ from nao_core.config.llm import DEFAULT_ANNOTATION_MODELS, LLMConfig, LLMProvider # noqa: E402 from nao_core.config.mcp import McpConfig # noqa: E402 from nao_core.config.notion import NotionConfig # noqa: E402 +from nao_core.config.obsidian import ObsidianConfig # noqa: E402 from nao_core.config.repos.base import RepoConfig # noqa: E402 from nao_core.config.skills import SkillsConfig # noqa: E402 from nao_core.config.slack import SlackConfig # noqa: E402 @@ -237,6 +238,14 @@ def _section_notion() -> str: return "\n".join(parts) +def _section_obsidian() -> str: + parts: list[str] = [] + parts.append("## Obsidian\n") + parts.append(_fields_table(ObsidianConfig)) + parts.append("") + return "\n".join(parts) + + def _section_slack() -> str: parts: list[str] = [] parts.append("## Slack\n") @@ -305,6 +314,9 @@ def _example_yaml() -> str: pages: - https://notion.so/my-page-id +obsidian: + path: /Users/me/Documents/Knowledge + slack: bot_token: ${{ env('SLACK_BOT_TOKEN') }} signing_secret: ${{ env('SLACK_SIGNING_SECRET') }} @@ -341,6 +353,7 @@ def generate_markdown() -> str: "databases": "[DatabaseConfig[]](#databases)", "repos": "[RepoConfig[]](#repos)", "notion": "[NotionConfig](#notion)", + "obsidian": "[ObsidianConfig](#obsidian)", "llm": "[LLMConfig](#llm)", "slack": "[SlackConfig](#slack)", "mcp": "[McpConfig](#mcp)", @@ -354,6 +367,7 @@ def generate_markdown() -> str: parts.append(_section_llm()) parts.append(_section_repos()) parts.append(_section_notion()) + parts.append(_section_obsidian()) parts.append(_section_slack()) parts.append(_section_mcp()) parts.append(_section_skills()) From 3c591d221553e0d2cd23b7077b02e6f162e598f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Lenne?= Date: Sun, 5 Jul 2026 19:20:48 +0200 Subject: [PATCH 2/5] Fix Obsidian context type narrowing --- cli/nao_core/templates/context.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/nao_core/templates/context.py b/cli/nao_core/templates/context.py index c9e588243..ab6cdf6b7 100644 --- a/cli/nao_core/templates/context.py +++ b/cli/nao_core/templates/context.py @@ -450,7 +450,14 @@ def obsidian(self) -> ObsidianProvider: Example: {{ nao.obsidian.note('Projects/roadmap.md').content }} """ - return ObsidianProvider(self._project_path) + project_path = self._project_path + if project_path is None: + raise RuntimeError( + "Obsidian note reading requires a project path. " + "This context was created without a project_path — " + "ensure nao sync is run from a valid nao project directory." + ) + return ObsidianProvider(project_path) @property def config(self) -> NaoConfig: From 5b3a7a4060b615d6f31182257881e080d73f128d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Lenne?= Date: Sun, 5 Jul 2026 19:28:03 +0200 Subject: [PATCH 3/5] Clarify Obsidian installation in CLI README --- cli/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/README.md b/cli/README.md index 474dfa4ae..4829e5e38 100644 --- a/cli/README.md +++ b/cli/README.md @@ -39,6 +39,8 @@ pip install 'nao-core[ollama]' pip install 'nao-core[notion]' ``` +Obsidian support is included in the core package and does not require an extra. + Combine multiple extras in a single install: ```bash From cfd85be9c3d7882b1cca9761215505cfdc8980bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Lenne?= Date: Sun, 5 Jul 2026 19:28:19 +0200 Subject: [PATCH 4/5] Share Obsidian output directory between sync and templates --- cli/nao_core/commands/sync/providers/obsidian/provider.py | 3 ++- cli/nao_core/templates/context.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/nao_core/commands/sync/providers/obsidian/provider.py b/cli/nao_core/commands/sync/providers/obsidian/provider.py index 5369ad114..bb7520ded 100644 --- a/cli/nao_core/commands/sync/providers/obsidian/provider.py +++ b/cli/nao_core/commands/sync/providers/obsidian/provider.py @@ -10,6 +10,7 @@ console = Console() IGNORED_DIR_NAMES = {".obsidian"} +OBSIDIAN_OUTPUT_DIR = "docs/obsidian" def cleanup_stale_notes(synced_files: set[str], output_path: Path, verbose: bool = False) -> int: @@ -59,7 +60,7 @@ def emoji(self) -> str: @property def default_output_dir(self) -> str: - return "docs/obsidian" + return OBSIDIAN_OUTPUT_DIR def get_items(self, config: NaoConfig) -> list[ObsidianConfig]: return [config.obsidian] if config.obsidian else [] diff --git a/cli/nao_core/templates/context.py b/cli/nao_core/templates/context.py index ab6cdf6b7..a6e94ab51 100644 --- a/cli/nao_core/templates/context.py +++ b/cli/nao_core/templates/context.py @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any import yaml +from nao_core.commands.sync.providers.obsidian.provider import OBSIDIAN_OUTPUT_DIR if TYPE_CHECKING: from jinja2 import Environment @@ -388,7 +389,7 @@ class ObsidianProvider: """Provider interface for accessing synced Obsidian notes in templates.""" def __init__(self, project_path: Path): - self._snapshot_root = project_path / "docs" / "obsidian" + self._snapshot_root = project_path / OBSIDIAN_OUTPUT_DIR self._note_cache: dict[str, ObsidianNote] = {} def note(self, relative_path: str) -> ObsidianNote: From 5fe17732eeb5a36f317299024b6cb13af812a8a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Lenne?= Date: Wed, 8 Jul 2026 23:16:02 +0200 Subject: [PATCH 5/5] Fix Obsidian circular import --- cli/nao_core/commands/sync/providers/obsidian/provider.py | 2 +- cli/nao_core/obsidian.py | 3 +++ cli/nao_core/templates/context.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 cli/nao_core/obsidian.py diff --git a/cli/nao_core/commands/sync/providers/obsidian/provider.py b/cli/nao_core/commands/sync/providers/obsidian/provider.py index bb7520ded..e222cf0b0 100644 --- a/cli/nao_core/commands/sync/providers/obsidian/provider.py +++ b/cli/nao_core/commands/sync/providers/obsidian/provider.py @@ -4,13 +4,13 @@ from nao_core.config.base import NaoConfig from nao_core.config.obsidian import ObsidianConfig +from nao_core.obsidian import OBSIDIAN_OUTPUT_DIR from ..base import SyncProvider, SyncResult console = Console() IGNORED_DIR_NAMES = {".obsidian"} -OBSIDIAN_OUTPUT_DIR = "docs/obsidian" def cleanup_stale_notes(synced_files: set[str], output_path: Path, verbose: bool = False) -> int: diff --git a/cli/nao_core/obsidian.py b/cli/nao_core/obsidian.py new file mode 100644 index 000000000..83292078e --- /dev/null +++ b/cli/nao_core/obsidian.py @@ -0,0 +1,3 @@ +"""Shared Obsidian integration constants.""" + +OBSIDIAN_OUTPUT_DIR = "docs/obsidian" diff --git a/cli/nao_core/templates/context.py b/cli/nao_core/templates/context.py index a6e94ab51..110ad08f0 100644 --- a/cli/nao_core/templates/context.py +++ b/cli/nao_core/templates/context.py @@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, Any import yaml -from nao_core.commands.sync.providers.obsidian.provider import OBSIDIAN_OUTPUT_DIR +from nao_core.obsidian import OBSIDIAN_OUTPUT_DIR if TYPE_CHECKING: from jinja2 import Environment