nao-core 0.0.38__py3-none-manylinux2014_aarch64.whl

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 (98) hide show
  1. nao_core/__init__.py +2 -0
  2. nao_core/__init__.py.bak +2 -0
  3. nao_core/bin/build-info.json +5 -0
  4. nao_core/bin/fastapi/main.py +268 -0
  5. nao_core/bin/fastapi/test_main.py +156 -0
  6. nao_core/bin/migrations-postgres/0000_user_auth_and_chat_tables.sql +98 -0
  7. nao_core/bin/migrations-postgres/0001_message_feedback.sql +9 -0
  8. nao_core/bin/migrations-postgres/0002_chat_message_stop_reason_and_error_message.sql +2 -0
  9. nao_core/bin/migrations-postgres/0003_handle_slack_with_thread.sql +2 -0
  10. nao_core/bin/migrations-postgres/0004_input_and_output_tokens.sql +8 -0
  11. nao_core/bin/migrations-postgres/0005_add_project_tables.sql +39 -0
  12. nao_core/bin/migrations-postgres/0006_llm_model_ids.sql +4 -0
  13. nao_core/bin/migrations-postgres/0007_chat_message_llm_info.sql +2 -0
  14. nao_core/bin/migrations-postgres/meta/0000_snapshot.json +707 -0
  15. nao_core/bin/migrations-postgres/meta/0001_snapshot.json +766 -0
  16. nao_core/bin/migrations-postgres/meta/0002_snapshot.json +778 -0
  17. nao_core/bin/migrations-postgres/meta/0003_snapshot.json +799 -0
  18. nao_core/bin/migrations-postgres/meta/0004_snapshot.json +847 -0
  19. nao_core/bin/migrations-postgres/meta/0005_snapshot.json +1129 -0
  20. nao_core/bin/migrations-postgres/meta/0006_snapshot.json +1141 -0
  21. nao_core/bin/migrations-postgres/meta/_journal.json +62 -0
  22. nao_core/bin/migrations-sqlite/0000_user_auth_and_chat_tables.sql +98 -0
  23. nao_core/bin/migrations-sqlite/0001_message_feedback.sql +8 -0
  24. nao_core/bin/migrations-sqlite/0002_chat_message_stop_reason_and_error_message.sql +2 -0
  25. nao_core/bin/migrations-sqlite/0003_handle_slack_with_thread.sql +2 -0
  26. nao_core/bin/migrations-sqlite/0004_input_and_output_tokens.sql +8 -0
  27. nao_core/bin/migrations-sqlite/0005_add_project_tables.sql +38 -0
  28. nao_core/bin/migrations-sqlite/0006_llm_model_ids.sql +4 -0
  29. nao_core/bin/migrations-sqlite/0007_chat_message_llm_info.sql +2 -0
  30. nao_core/bin/migrations-sqlite/meta/0000_snapshot.json +674 -0
  31. nao_core/bin/migrations-sqlite/meta/0001_snapshot.json +735 -0
  32. nao_core/bin/migrations-sqlite/meta/0002_snapshot.json +749 -0
  33. nao_core/bin/migrations-sqlite/meta/0003_snapshot.json +763 -0
  34. nao_core/bin/migrations-sqlite/meta/0004_snapshot.json +819 -0
  35. nao_core/bin/migrations-sqlite/meta/0005_snapshot.json +1086 -0
  36. nao_core/bin/migrations-sqlite/meta/0006_snapshot.json +1100 -0
  37. nao_core/bin/migrations-sqlite/meta/_journal.json +62 -0
  38. nao_core/bin/nao-chat-server +0 -0
  39. nao_core/bin/public/assets/code-block-F6WJLWQG-CV0uOmNJ.js +153 -0
  40. nao_core/bin/public/assets/index-DcbndLHo.css +1 -0
  41. nao_core/bin/public/assets/index-t1hZI3nl.js +560 -0
  42. nao_core/bin/public/favicon.ico +0 -0
  43. nao_core/bin/public/index.html +18 -0
  44. nao_core/bin/rg +0 -0
  45. nao_core/commands/__init__.py +6 -0
  46. nao_core/commands/chat.py +225 -0
  47. nao_core/commands/debug.py +158 -0
  48. nao_core/commands/init.py +358 -0
  49. nao_core/commands/sync/__init__.py +124 -0
  50. nao_core/commands/sync/accessors.py +290 -0
  51. nao_core/commands/sync/cleanup.py +156 -0
  52. nao_core/commands/sync/providers/__init__.py +32 -0
  53. nao_core/commands/sync/providers/base.py +113 -0
  54. nao_core/commands/sync/providers/databases/__init__.py +17 -0
  55. nao_core/commands/sync/providers/databases/bigquery.py +79 -0
  56. nao_core/commands/sync/providers/databases/databricks.py +79 -0
  57. nao_core/commands/sync/providers/databases/duckdb.py +78 -0
  58. nao_core/commands/sync/providers/databases/postgres.py +79 -0
  59. nao_core/commands/sync/providers/databases/provider.py +129 -0
  60. nao_core/commands/sync/providers/databases/snowflake.py +79 -0
  61. nao_core/commands/sync/providers/notion/__init__.py +5 -0
  62. nao_core/commands/sync/providers/notion/provider.py +205 -0
  63. nao_core/commands/sync/providers/repositories/__init__.py +5 -0
  64. nao_core/commands/sync/providers/repositories/provider.py +134 -0
  65. nao_core/commands/sync/registry.py +23 -0
  66. nao_core/config/__init__.py +30 -0
  67. nao_core/config/base.py +100 -0
  68. nao_core/config/databases/__init__.py +55 -0
  69. nao_core/config/databases/base.py +85 -0
  70. nao_core/config/databases/bigquery.py +99 -0
  71. nao_core/config/databases/databricks.py +79 -0
  72. nao_core/config/databases/duckdb.py +41 -0
  73. nao_core/config/databases/postgres.py +83 -0
  74. nao_core/config/databases/snowflake.py +125 -0
  75. nao_core/config/exceptions.py +7 -0
  76. nao_core/config/llm/__init__.py +19 -0
  77. nao_core/config/notion/__init__.py +8 -0
  78. nao_core/config/repos/__init__.py +3 -0
  79. nao_core/config/repos/base.py +11 -0
  80. nao_core/config/slack/__init__.py +12 -0
  81. nao_core/context/__init__.py +54 -0
  82. nao_core/context/base.py +57 -0
  83. nao_core/context/git.py +177 -0
  84. nao_core/context/local.py +59 -0
  85. nao_core/main.py +13 -0
  86. nao_core/templates/__init__.py +41 -0
  87. nao_core/templates/context.py +193 -0
  88. nao_core/templates/defaults/databases/columns.md.j2 +23 -0
  89. nao_core/templates/defaults/databases/description.md.j2 +32 -0
  90. nao_core/templates/defaults/databases/preview.md.j2 +22 -0
  91. nao_core/templates/defaults/databases/profiling.md.j2 +34 -0
  92. nao_core/templates/engine.py +133 -0
  93. nao_core/templates/render.py +196 -0
  94. nao_core-0.0.38.dist-info/METADATA +150 -0
  95. nao_core-0.0.38.dist-info/RECORD +98 -0
  96. nao_core-0.0.38.dist-info/WHEEL +4 -0
  97. nao_core-0.0.38.dist-info/entry_points.txt +2 -0
  98. nao_core-0.0.38.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,83 @@
1
+ from typing import Literal
2
+
3
+ import ibis
4
+ from ibis import BaseBackend
5
+ from pydantic import Field
6
+ from rich.prompt import Prompt
7
+
8
+ from nao_core.config.exceptions import InitError
9
+
10
+ from .base import DatabaseConfig, console
11
+
12
+
13
+ class PostgresConfig(DatabaseConfig):
14
+ """PostgreSQL-specific configuration."""
15
+
16
+ type: Literal["postgres"] = "postgres"
17
+ host: str = Field(description="PostgreSQL host")
18
+ port: int = Field(default=5432, description="PostgreSQL port")
19
+ database: str = Field(description="Database name")
20
+ user: str = Field(description="Username")
21
+ password: str = Field(description="Password")
22
+ schema_name: str | None = Field(default=None, description="Default schema (optional, uses 'public' if not set)")
23
+
24
+ @classmethod
25
+ def promptConfig(cls) -> "PostgresConfig":
26
+ """Interactively prompt the user for PostgreSQL configuration."""
27
+ console.print("\n[bold cyan]PostgreSQL Configuration[/bold cyan]\n")
28
+
29
+ name = Prompt.ask("[bold]Connection name[/bold]", default="postgres-prod")
30
+
31
+ host = Prompt.ask("[bold]Host[/bold]", default="localhost")
32
+
33
+ port = Prompt.ask("[bold]Port[/bold]", default="5432")
34
+ if not port.isdigit():
35
+ raise InitError("Port must be a valid integer.")
36
+
37
+ database = Prompt.ask("[bold]Database name[/bold]")
38
+ if not database:
39
+ raise InitError("Database name cannot be empty.")
40
+
41
+ user = Prompt.ask("[bold]Username[/bold]")
42
+ if not user:
43
+ raise InitError("Username cannot be empty.")
44
+
45
+ password = Prompt.ask("[bold]Password[/bold]", password=True)
46
+
47
+ schema_name = Prompt.ask(
48
+ "[bold]Default schema[/bold] [dim](optional, uses 'public' if empty)[/dim]",
49
+ default="",
50
+ )
51
+
52
+ return PostgresConfig(
53
+ name=name,
54
+ host=host,
55
+ port=int(port),
56
+ database=database,
57
+ user=user,
58
+ password=password,
59
+ schema_name=schema_name or None,
60
+ )
61
+
62
+ def connect(self) -> BaseBackend:
63
+ """Create an Ibis PostgreSQL connection."""
64
+
65
+ kwargs: dict = {
66
+ "host": self.host,
67
+ "port": self.port,
68
+ "database": self.database,
69
+ "user": self.user,
70
+ "password": self.password,
71
+ }
72
+
73
+ if self.schema_name:
74
+ kwargs["schema"] = self.schema_name
75
+
76
+ return ibis.postgres.connect(
77
+ **kwargs,
78
+ )
79
+
80
+ def get_database_name(self) -> str:
81
+ """Get the database name for Postgres."""
82
+
83
+ return self.database
@@ -0,0 +1,125 @@
1
+ from typing import Literal
2
+
3
+ import ibis
4
+ from cryptography.hazmat.backends import default_backend
5
+ from cryptography.hazmat.primitives import serialization
6
+ from ibis import BaseBackend
7
+ from pydantic import Field
8
+ from rich.prompt import Confirm, Prompt
9
+
10
+ from nao_core.config.exceptions import InitError
11
+
12
+ from .base import DatabaseConfig, console
13
+
14
+
15
+ class SnowflakeConfig(DatabaseConfig):
16
+ """Snowflake-specific configuration."""
17
+
18
+ type: Literal["snowflake"] = "snowflake"
19
+ username: str = Field(description="Snowflake username")
20
+ account_id: str = Field(description="Snowflake account identifier (e.g., 'xy12345.us-east-1')")
21
+ password: str | None = Field(default=None, description="Snowflake password")
22
+ database: str = Field(description="Snowflake database")
23
+ schema_name: str | None = Field(
24
+ default=None,
25
+ validation_alias="schema",
26
+ serialization_alias="schema",
27
+ description="Snowflake schema (optional)",
28
+ )
29
+ warehouse: str | None = Field(default=None, description="Snowflake warehouse to use (optional)")
30
+ private_key_path: str | None = Field(
31
+ default=None,
32
+ description="Path to private key file for key-pair authentication",
33
+ )
34
+ passphrase: str | None = Field(
35
+ default=None,
36
+ description="Passphrase for the private key if it is encrypted",
37
+ )
38
+
39
+ @classmethod
40
+ def promptConfig(cls) -> "SnowflakeConfig":
41
+ """Interactively prompt the user for Snowflake configuration."""
42
+ console.print("\n[bold cyan]Snowflake Configuration[/bold cyan]\n")
43
+
44
+ name = Prompt.ask("[bold]Connection name[/bold]", default="snowflake-prod")
45
+
46
+ username = Prompt.ask("[bold]Snowflake username[/bold]")
47
+ if not username:
48
+ raise InitError("Snowflake username cannot be empty.")
49
+
50
+ account_id = Prompt.ask("[bold]Snowflake account identifier[/bold]")
51
+ if not account_id:
52
+ raise InitError("Snowflake account identifier cannot be empty.")
53
+
54
+ database = Prompt.ask("[bold]Snowflake database[/bold]")
55
+ if not database:
56
+ raise InitError("Snowflake database cannot be empty.")
57
+
58
+ warehouse = Prompt.ask(
59
+ "[bold]Snowflake warehouse[/bold] [dim](optional, press Enter to skip)[/dim]", default=None
60
+ )
61
+
62
+ schema = Prompt.ask("[bold]Default schema[/bold] [dim](optional, press Enter to skip)[/dim]", default=None)
63
+
64
+ key_pair_auth = Confirm.ask("[bold]Use key-pair authentication for authentication?[/bold]", default=False)
65
+
66
+ if key_pair_auth:
67
+ private_key_path = Prompt.ask("[bold]Path to private key file[/bold]")
68
+ if not private_key_path:
69
+ raise InitError("Path to private key file cannot be empty.")
70
+ passphrase = Prompt.ask(
71
+ "[bold]Passphrase for the private key[/bold] [dim](optional, press Enter to skip)[/dim]",
72
+ default=None,
73
+ password=True,
74
+ )
75
+ else:
76
+ password = Prompt.ask("[bold]Snowflake password[/bold]", password=True)
77
+ if not password:
78
+ raise InitError("Snowflake password cannot be empty.")
79
+
80
+ return SnowflakeConfig(
81
+ name=name,
82
+ username=username,
83
+ password=password if not key_pair_auth else None,
84
+ account_id=account_id,
85
+ database=database,
86
+ warehouse=warehouse,
87
+ schema_name=schema,
88
+ private_key_path=private_key_path if key_pair_auth else None,
89
+ passphrase=passphrase if key_pair_auth else None,
90
+ )
91
+
92
+ def connect(self) -> BaseBackend:
93
+ """Create an Ibis Snowflake connection."""
94
+ kwargs: dict = {"user": self.username}
95
+ kwargs["account"] = self.account_id
96
+
97
+ if self.database and self.schema_name:
98
+ kwargs["database"] = f"{self.database}/{self.schema_name}"
99
+ elif self.database:
100
+ kwargs["database"] = self.database
101
+
102
+ if self.warehouse:
103
+ kwargs["warehouse"] = self.warehouse
104
+
105
+ if self.private_key_path:
106
+ with open(self.private_key_path, "rb") as key_file:
107
+ private_key = serialization.load_pem_private_key(
108
+ key_file.read(),
109
+ password=self.passphrase.encode() if self.passphrase else None,
110
+ backend=default_backend(),
111
+ )
112
+ # Convert to DER format which Snowflake expects
113
+ kwargs["private_key"] = private_key.private_bytes(
114
+ encoding=serialization.Encoding.DER,
115
+ format=serialization.PrivateFormat.PKCS8,
116
+ encryption_algorithm=serialization.NoEncryption(),
117
+ )
118
+ kwargs["password"] = self.password
119
+
120
+ return ibis.snowflake.connect(**kwargs)
121
+
122
+ def get_database_name(self) -> str:
123
+ """Get the database name for Snowflake."""
124
+
125
+ return self.database
@@ -0,0 +1,7 @@
1
+ """Shared exceptions for nao_core."""
2
+
3
+
4
+ class InitError(Exception):
5
+ """Base exception for init command errors."""
6
+
7
+ pass
@@ -0,0 +1,19 @@
1
+ from enum import Enum
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class LLMProvider(str, Enum):
7
+ """Supported LLM providers."""
8
+
9
+ OPENAI = "openai"
10
+ ANTHROPIC = "anthropic"
11
+ MISTRAL = "mistral"
12
+ GEMINI = "gemini"
13
+
14
+
15
+ class LLMConfig(BaseModel):
16
+ """LLM configuration."""
17
+
18
+ provider: LLMProvider = Field(description="The LLM provider to use")
19
+ api_key: str = Field(description="The API key to use")
@@ -0,0 +1,8 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class NotionConfig(BaseModel):
5
+ """Notion configuration."""
6
+
7
+ api_key: str = Field(description="The API key to use")
8
+ pages: list[str] = Field(description="The pages to sync")
@@ -0,0 +1,3 @@
1
+ from .base import RepoConfig
2
+
3
+ __all__ = ["RepoConfig"]
@@ -0,0 +1,11 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class RepoConfig(BaseModel):
7
+ """Repository configuration."""
8
+
9
+ name: str = Field(description="The name of the repository")
10
+ url: str = Field(description="The URL of the repository")
11
+ branch: Optional[str] = Field(default=None, description="The branch of the repository")
@@ -0,0 +1,12 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class SlackConfig(BaseModel):
5
+ """Slack configuration."""
6
+
7
+ bot_token: str = Field(description="The bot token to use")
8
+ signing_secret: str = Field(description="The signing secret for verifying requests")
9
+ post_message_url: str = Field(
10
+ default="https://slack.com/api/chat.postMessage",
11
+ description="The Slack API URL for posting messages",
12
+ )
@@ -0,0 +1,54 @@
1
+ """Context provider module for loading nao project context from various sources."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .base import ContextProvider
7
+ from .git import GitContextProvider
8
+ from .local import LocalContextProvider
9
+
10
+
11
+ def get_context_provider() -> ContextProvider:
12
+ """Factory function to create the appropriate context provider based on environment variables.
13
+
14
+ Environment variables:
15
+ NAO_CONTEXT_SOURCE: 'local' (default) or 'git'
16
+ NAO_DEFAULT_PROJECT_PATH: Target path for context (required)
17
+
18
+ For git source:
19
+ NAO_CONTEXT_GIT_URL: Git repository URL (required)
20
+ NAO_CONTEXT_GIT_BRANCH: Branch to clone/pull (default: 'main')
21
+ NAO_CONTEXT_GIT_TOKEN: Auth token for private repos (optional)
22
+
23
+ Returns:
24
+ ContextProvider instance based on configuration
25
+ """
26
+ source = os.environ.get("NAO_CONTEXT_SOURCE", "local").lower()
27
+ target_path = Path(os.environ.get("NAO_DEFAULT_PROJECT_PATH", "/app/context"))
28
+
29
+ if source == "git":
30
+ git_url = os.environ.get("NAO_CONTEXT_GIT_URL")
31
+ if not git_url:
32
+ raise ValueError("NAO_CONTEXT_GIT_URL is required when NAO_CONTEXT_SOURCE=git")
33
+
34
+ branch = os.environ.get("NAO_CONTEXT_GIT_BRANCH", "main")
35
+ token = os.environ.get("NAO_CONTEXT_GIT_TOKEN")
36
+
37
+ return GitContextProvider(
38
+ repo_url=git_url,
39
+ target_path=target_path,
40
+ branch=branch,
41
+ token=token,
42
+ )
43
+ elif source == "local":
44
+ return LocalContextProvider(target_path=target_path)
45
+ else:
46
+ raise ValueError(f"Unknown NAO_CONTEXT_SOURCE: {source}. Must be 'local' or 'git'")
47
+
48
+
49
+ __all__ = [
50
+ "ContextProvider",
51
+ "GitContextProvider",
52
+ "LocalContextProvider",
53
+ "get_context_provider",
54
+ ]
@@ -0,0 +1,57 @@
1
+ """Base class for context providers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+
6
+
7
+ class ContextProvider(ABC):
8
+ """Abstract base class for context providers.
9
+
10
+ A context provider is responsible for loading the nao project context
11
+ from a source (local filesystem, git repository, etc.) to the target path.
12
+ """
13
+
14
+ def __init__(self, target_path: Path):
15
+ """Initialize the context provider.
16
+
17
+ Args:
18
+ target_path: The local filesystem path where context should be available.
19
+ """
20
+ self.target_path = target_path
21
+
22
+ @abstractmethod
23
+ def init(self) -> None:
24
+ """Initialize the context.
25
+
26
+ This is called on container startup to ensure context is available.
27
+ For local provider, this validates the path exists.
28
+ For git provider, this clones or pulls the repository.
29
+ """
30
+ pass
31
+
32
+ @abstractmethod
33
+ def refresh(self) -> bool:
34
+ """Refresh the context from the source.
35
+
36
+ Returns:
37
+ True if context was updated, False if no changes.
38
+ """
39
+ pass
40
+
41
+ @abstractmethod
42
+ def is_initialized(self) -> bool:
43
+ """Check if context has been initialized.
44
+
45
+ Returns:
46
+ True if context is available and ready.
47
+ """
48
+ pass
49
+
50
+ def validate(self) -> bool:
51
+ """Validate that the context contains required files.
52
+
53
+ Returns:
54
+ True if nao_config.yaml exists in target path.
55
+ """
56
+ config_file = self.target_path / "nao_config.yaml"
57
+ return config_file.exists()
@@ -0,0 +1,177 @@
1
+ """Git-based context provider."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+
8
+ from .base import ContextProvider
9
+
10
+ console = Console()
11
+
12
+
13
+ class GitContextProvider(ContextProvider):
14
+ """Context provider that clones/pulls from a git repository.
15
+
16
+ This provider enables containerized deployments without volume mounts
17
+ by fetching context from a git repository on startup and refresh.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ repo_url: str,
23
+ target_path: Path,
24
+ branch: str = "main",
25
+ token: str | None = None,
26
+ ):
27
+ """Initialize the git context provider.
28
+
29
+ Args:
30
+ repo_url: Git repository URL (https:// or git@).
31
+ target_path: Local path where repo will be cloned.
32
+ branch: Branch to clone/pull (default: 'main').
33
+ token: Auth token for private repos (optional).
34
+ """
35
+ super().__init__(target_path)
36
+ self.repo_url = repo_url
37
+ self.branch = branch
38
+ self.token = token
39
+
40
+ def _get_auth_url(self) -> str:
41
+ """Inject token into URL for private repos.
42
+
43
+ Returns:
44
+ Repository URL with token if provided, original URL otherwise.
45
+ """
46
+ if not self.token:
47
+ return self.repo_url
48
+
49
+ # Handle HTTPS URLs
50
+ if self.repo_url.startswith("https://"):
51
+ # https://github.com/org/repo → https://token@github.com/org/repo
52
+ return self.repo_url.replace("https://", f"https://{self.token}@")
53
+
54
+ # For SSH URLs or other formats, return as-is
55
+ return self.repo_url
56
+
57
+ def init(self) -> None:
58
+ """Clone the repository if not exists, otherwise pull.
59
+
60
+ Raises:
61
+ subprocess.CalledProcessError: If git command fails.
62
+ ValueError: If cloned repo doesn't contain nao_config.yaml.
63
+ """
64
+ if self.is_initialized():
65
+ console.print(f"[dim]Context already initialized at {self.target_path}[/dim]")
66
+ self.refresh()
67
+ else:
68
+ self._clone()
69
+
70
+ if not self.validate():
71
+ raise ValueError(
72
+ "nao_config.yaml not found in cloned repository.\n"
73
+ "Ensure the repository contains a valid nao project at its root."
74
+ )
75
+
76
+ def _clone(self) -> None:
77
+ """Clone the repository.
78
+
79
+ Uses shallow clone (--depth 1) for faster initial setup.
80
+ """
81
+ console.print(f"[cyan]Cloning context from {self.repo_url}...[/cyan]")
82
+
83
+ # Ensure parent directory exists
84
+ self.target_path.parent.mkdir(parents=True, exist_ok=True)
85
+
86
+ # Remove target if it exists but isn't a git repo
87
+ if self.target_path.exists() and not (self.target_path / ".git").exists():
88
+ import shutil
89
+
90
+ shutil.rmtree(self.target_path)
91
+
92
+ cmd = [
93
+ "git",
94
+ "clone",
95
+ "--branch",
96
+ self.branch,
97
+ "--depth",
98
+ "1",
99
+ "--single-branch",
100
+ self._get_auth_url(),
101
+ str(self.target_path),
102
+ ]
103
+
104
+ try:
105
+ subprocess.run(
106
+ cmd,
107
+ check=True,
108
+ capture_output=True,
109
+ text=True,
110
+ )
111
+ console.print(f"[green]✓[/green] Context cloned to {self.target_path}")
112
+ except subprocess.CalledProcessError as e:
113
+ # Sanitize error message to not expose token
114
+ error_msg = e.stderr.replace(self.token, "***") if self.token else e.stderr
115
+ console.print(f"[red]✗[/red] Failed to clone repository: {error_msg}")
116
+ raise
117
+
118
+ def refresh(self) -> bool:
119
+ """Pull latest changes from the repository.
120
+
121
+ Returns:
122
+ True if changes were pulled, False if already up-to-date.
123
+
124
+ Raises:
125
+ subprocess.CalledProcessError: If git pull fails.
126
+ """
127
+ if not self.is_initialized():
128
+ console.print("[yellow]Context not initialized, running init instead[/yellow]")
129
+ self.init()
130
+ return True
131
+
132
+ console.print(f"[cyan]Refreshing context from {self.repo_url}...[/cyan]")
133
+
134
+ try:
135
+ # Fetch with the auth URL
136
+ subprocess.run(
137
+ ["git", "fetch", self._get_auth_url(), self.branch],
138
+ cwd=self.target_path,
139
+ check=True,
140
+ capture_output=True,
141
+ text=True,
142
+ )
143
+
144
+ # Check if there are changes
145
+ diff_result = subprocess.run(
146
+ ["git", "diff", "HEAD..FETCH_HEAD", "--stat"],
147
+ cwd=self.target_path,
148
+ capture_output=True,
149
+ text=True,
150
+ )
151
+
152
+ if diff_result.stdout.strip():
153
+ # There are changes, do a hard reset to FETCH_HEAD
154
+ subprocess.run(
155
+ ["git", "reset", "--hard", "FETCH_HEAD"],
156
+ cwd=self.target_path,
157
+ check=True,
158
+ capture_output=True,
159
+ )
160
+ console.print("[green]✓[/green] Context updated")
161
+ return True
162
+ else:
163
+ console.print("[dim]Context already up-to-date[/dim]")
164
+ return False
165
+
166
+ except subprocess.CalledProcessError as e:
167
+ error_msg = e.stderr.replace(self.token, "***") if self.token else e.stderr
168
+ console.print(f"[red]✗[/red] Failed to refresh context: {error_msg}")
169
+ raise
170
+
171
+ def is_initialized(self) -> bool:
172
+ """Check if repository has been cloned.
173
+
174
+ Returns:
175
+ True if .git directory exists at target path.
176
+ """
177
+ return (self.target_path / ".git").exists()
@@ -0,0 +1,59 @@
1
+ """Local filesystem context provider."""
2
+
3
+ from pathlib import Path
4
+
5
+ from .base import ContextProvider
6
+
7
+
8
+ class LocalContextProvider(ContextProvider):
9
+ """Context provider for local filesystem.
10
+
11
+ This is the default provider that expects context to already exist
12
+ at the target path (e.g., via Docker volume mount).
13
+ """
14
+
15
+ def __init__(self, target_path: Path):
16
+ """Initialize the local context provider.
17
+
18
+ Args:
19
+ target_path: Path where context should exist.
20
+ """
21
+ super().__init__(target_path)
22
+
23
+ def init(self) -> None:
24
+ """Validate that the local context path exists.
25
+
26
+ Raises:
27
+ FileNotFoundError: If target path does not exist.
28
+ ValueError: If nao_config.yaml is not found.
29
+ """
30
+ if not self.target_path.exists():
31
+ raise FileNotFoundError(
32
+ f"Context path does not exist: {self.target_path}\n"
33
+ "For local mode, ensure the path is mounted as a Docker volume "
34
+ "or use NAO_CONTEXT_SOURCE=git for git-based context."
35
+ )
36
+
37
+ if not self.validate():
38
+ raise ValueError(
39
+ f"nao_config.yaml not found in {self.target_path}\n"
40
+ "Ensure the context path contains a valid nao project."
41
+ )
42
+
43
+ def refresh(self) -> bool:
44
+ """Refresh is a no-op for local provider.
45
+
46
+ Local context is managed externally (e.g., volume mount updates).
47
+
48
+ Returns:
49
+ False (no refresh performed)
50
+ """
51
+ return False
52
+
53
+ def is_initialized(self) -> bool:
54
+ """Check if local context is available.
55
+
56
+ Returns:
57
+ True if target path exists and contains nao_config.yaml.
58
+ """
59
+ return self.target_path.exists() and self.validate()
nao_core/main.py ADDED
@@ -0,0 +1,13 @@
1
+ from cyclopts import App
2
+
3
+ from nao_core.commands import chat, debug, init, sync
4
+
5
+ app = App()
6
+
7
+ app.command(chat)
8
+ app.command(debug)
9
+ app.command(init)
10
+ app.command(sync)
11
+
12
+ if __name__ == "__main__":
13
+ app()
@@ -0,0 +1,41 @@
1
+ """Template engine module for nao providers.
2
+
3
+ This module provides a Jinja2-based templating system that allows users
4
+ to customize the output of sync providers (databases, repos, etc.).
5
+
6
+ Default templates are stored in this package and can be overridden by
7
+ placing templates with the same name in the project's `templates/` directory.
8
+
9
+ Additionally, this module supports rendering user Jinja templates in the
10
+ context folder, making the `nao` object available for accessing provider data.
11
+
12
+ Example user template (docs/report.md.j2):
13
+ # {{ nao.config.project_name }}
14
+
15
+ {{ nao.notion.page('https://notion.so/...').content }}
16
+ """
17
+
18
+ from .context import NaoContext, NotionPage, NotionProvider, create_nao_context
19
+ from .engine import TemplateEngine, get_template_engine
20
+ from .render import (
21
+ TemplateRenderResult,
22
+ discover_templates,
23
+ render_all_templates,
24
+ render_template,
25
+ )
26
+
27
+ __all__ = [
28
+ # Engine
29
+ "TemplateEngine",
30
+ "get_template_engine",
31
+ # Context
32
+ "NaoContext",
33
+ "NotionPage",
34
+ "NotionProvider",
35
+ "create_nao_context",
36
+ # Render
37
+ "TemplateRenderResult",
38
+ "discover_templates",
39
+ "render_template",
40
+ "render_all_templates",
41
+ ]