Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,6 +92,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**
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

The resulting project structure looks like:

Expand Down Expand Up @@ -166,6 +169,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.

Expand Down
2 changes: 2 additions & 0 deletions cli/nao_core/commands/sync/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Expand Down
5 changes: 5 additions & 0 deletions cli/nao_core/commands/sync/providers/obsidian/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Obsidian syncing functionality for syncing local Obsidian vault notes."""

from .provider import ObsidianSyncProvider

__all__ = ["ObsidianSyncProvider"]
115 changes: 115 additions & 0 deletions cli/nao_core/commands/sync/providers/obsidian/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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 nao_core.obsidian import OBSIDIAN_OUTPUT_DIR

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 OBSIDIAN_OUTPUT_DIR

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,
)
2 changes: 2 additions & 0 deletions cli/nao_core/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -37,6 +38,7 @@
"PROVIDER_AUTH",
"ProviderAuthConfig",
"SlackConfig",
"ObsidianConfig",
"InitError",
"resolve_project_path",
]
17 changes: 17 additions & 0 deletions cli/nao_core/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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),
)
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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)

Expand All @@ -143,6 +152,7 @@ def _prompt_extend(cls, existing: "NaoConfig") -> "NaoConfig":
llm=llm,
slack=slack,
notion=notion,
obsidian=obsidian,
mcp=mcp,
skills=skills,
)
Expand Down Expand Up @@ -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."""
Expand Down
16 changes: 16 additions & 0 deletions cli/nao_core/config/obsidian/__init__.py
Original file line number Diff line number Diff line change
@@ -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]
3 changes: 3 additions & 0 deletions cli/nao_core/obsidian.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Shared Obsidian integration constants."""

OBSIDIAN_OUTPUT_DIR = "docs/obsidian"
5 changes: 4 additions & 1 deletion cli/nao_core/templates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,6 +33,8 @@
"NaoContext",
"NotionPage",
"NotionProvider",
"ObsidianNote",
"ObsidianProvider",
"create_nao_context",
# Render
"TemplateRenderResult",
Expand Down
Loading
Loading