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,358 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ from cyclopts import Parameter
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.prompt import Confirm, Prompt
10
+
11
+ from nao_core.config import (
12
+ AnyDatabaseConfig,
13
+ BigQueryConfig,
14
+ DatabaseType,
15
+ DatabricksConfig,
16
+ DuckDBConfig,
17
+ LLMConfig,
18
+ LLMProvider,
19
+ NaoConfig,
20
+ PostgresConfig,
21
+ SlackConfig,
22
+ SnowflakeConfig,
23
+ )
24
+ from nao_core.config.exceptions import InitError
25
+ from nao_core.config.repos import RepoConfig
26
+
27
+ console = Console()
28
+
29
+
30
+ class EmptyProjectNameError(InitError):
31
+ """Raised when project name is empty."""
32
+
33
+ def __init__(self):
34
+ super().__init__("Project name cannot be empty.")
35
+
36
+
37
+ class ProjectExistsError(InitError):
38
+ """Raised when project folder already exists."""
39
+
40
+ def __init__(self, project_name: str):
41
+ self.project_name = project_name
42
+ super().__init__(f"Folder '{project_name}' already exists.")
43
+
44
+
45
+ class EmptyApiKeyError(InitError):
46
+ """Raised when API key is empty."""
47
+
48
+ def __init__(self):
49
+ super().__init__("API key cannot be empty.")
50
+
51
+
52
+ @dataclass
53
+ class CreatedFile:
54
+ path: Path
55
+ content: str | None
56
+
57
+
58
+ def setup_project_name(force: bool = False) -> tuple[str, Path]:
59
+ """Setup the project name."""
60
+ # Check if we're in a directory with an existing nao_config.yaml
61
+ current_dir = Path.cwd()
62
+ config_file = current_dir / "nao_config.yaml"
63
+
64
+ if config_file.exists():
65
+ # Load existing config to get project name
66
+ existing_config = NaoConfig.try_load(current_dir)
67
+ if existing_config:
68
+ console.print("\n[bold yellow]Found existing nao_config.yaml[/bold yellow]")
69
+ console.print(f"[dim]Project: {existing_config.project_name}[/dim]\n")
70
+
71
+ if force or Confirm.ask("[bold]Re-initialize this project?[/bold]", default=True):
72
+ return existing_config.project_name, current_dir
73
+ else:
74
+ raise InitError("Initialization cancelled.")
75
+
76
+ # Normal flow: prompt for project name
77
+ project_name = Prompt.ask("[bold]Enter your project name[/bold]")
78
+
79
+ if not project_name:
80
+ raise EmptyProjectNameError()
81
+
82
+ project_path = Path(project_name)
83
+
84
+ if project_path.exists() and not force:
85
+ raise ProjectExistsError(project_name)
86
+
87
+ project_path.mkdir(parents=True, exist_ok=True)
88
+
89
+ return project_name, project_path
90
+
91
+
92
+ def setup_bigquery() -> BigQueryConfig:
93
+ """Setup a BigQuery database configuration."""
94
+ return BigQueryConfig.promptConfig()
95
+
96
+
97
+ def setup_duckdb() -> DuckDBConfig:
98
+ """Setup a DuckDB database configuration."""
99
+ return DuckDBConfig.promptConfig()
100
+
101
+
102
+ def setup_databricks() -> DatabricksConfig:
103
+ """Setup a Databricks database configuration."""
104
+ return DatabricksConfig.promptConfig()
105
+
106
+
107
+ def setup_snowflake() -> SnowflakeConfig:
108
+ """Setup a Snowflake database configuration."""
109
+ return SnowflakeConfig.promptConfig()
110
+
111
+
112
+ def setup_postgres() -> PostgresConfig:
113
+ """Setup a PostgreSQL database configuration."""
114
+ return PostgresConfig.promptConfig()
115
+
116
+
117
+ def setup_databases() -> list[AnyDatabaseConfig]:
118
+ """Setup database configurations."""
119
+ databases: list[AnyDatabaseConfig] = []
120
+
121
+ should_setup = Confirm.ask("\n[bold]Set up database connections?[/bold]", default=True)
122
+
123
+ if not should_setup:
124
+ return databases
125
+
126
+ while True:
127
+ console.print("\n[bold cyan]Database Configuration[/bold cyan]\n")
128
+
129
+ db_type_choices = [t.value for t in DatabaseType]
130
+ db_type = Prompt.ask(
131
+ "[bold]Select database type[/bold]",
132
+ choices=db_type_choices,
133
+ default=db_type_choices[0],
134
+ )
135
+
136
+ if db_type == DatabaseType.BIGQUERY.value:
137
+ db_config = setup_bigquery()
138
+ databases.append(db_config)
139
+ console.print(f"\n[bold green]✓[/bold green] Added database [cyan]{db_config.name}[/cyan]")
140
+ elif db_type == DatabaseType.POSTGRES.value:
141
+ db_config = setup_postgres()
142
+ databases.append(db_config)
143
+ console.print(f"\n[bold green]✓[/bold green] Added database [cyan]{db_config.name}[/cyan]")
144
+
145
+ elif db_type == DatabaseType.DUCKDB.value:
146
+ db_config = setup_duckdb()
147
+ databases.append(db_config)
148
+ console.print(f"\n[bold green]✓[/bold green] Added database [cyan]{db_config.name}[/cyan]")
149
+
150
+ elif db_type == DatabaseType.DATABRICKS.value:
151
+ db_config = setup_databricks()
152
+ databases.append(db_config)
153
+ console.print(f"\n[bold green]✓[/bold green] Added database [cyan]{db_config.name}[/cyan]")
154
+
155
+ elif db_type == DatabaseType.SNOWFLAKE.value:
156
+ db_config = setup_snowflake()
157
+ databases.append(db_config)
158
+ console.print(f"\n[bold green]✓[/bold green] Added database [cyan]{db_config.name}[/cyan]")
159
+
160
+ add_another = Confirm.ask("\n[bold]Add another database?[/bold]", default=False)
161
+ if not add_another:
162
+ break
163
+
164
+ return databases
165
+
166
+
167
+ def setup_repos() -> list[RepoConfig]:
168
+ """Setup repository configurations."""
169
+ repos: list[RepoConfig] = []
170
+ should_setup = Confirm.ask("\n[bold]Set up git repositories?[/bold]", default=True)
171
+
172
+ if not should_setup:
173
+ return repos
174
+
175
+ while True:
176
+ console.print("\n[bold cyan]Git Repository Configuration[/bold cyan]\n")
177
+ name = Prompt.ask("[bold]Repository name[/bold]")
178
+ url = Prompt.ask("[bold]Repository URL[/bold]")
179
+
180
+ repos.append(RepoConfig(name=name, url=url))
181
+ console.print(f"\n[bold green]✓[/bold green] Added repository [cyan]{name}[/cyan]")
182
+
183
+ add_another = Confirm.ask("\n[bold]Add another repository?[/bold]", default=False)
184
+ if not add_another:
185
+ break
186
+
187
+ return repos
188
+
189
+
190
+ def setup_llm() -> LLMConfig | None:
191
+ """Setup the LLM configuration."""
192
+ llm_config = None
193
+ should_setup = Confirm.ask("\n[bold]Set up LLM configuration?[/bold]", default=True)
194
+
195
+ if should_setup:
196
+ console.print("\n[bold cyan]LLM Configuration[/bold cyan]\n")
197
+
198
+ provider_choices = [p.value for p in LLMProvider]
199
+ llm_provider = Prompt.ask(
200
+ "[bold]Select LLM provider[/bold]",
201
+ choices=provider_choices,
202
+ default=provider_choices[0],
203
+ )
204
+
205
+ api_key = Prompt.ask(
206
+ f"[bold]Enter your {llm_provider.upper()} API key[/bold]",
207
+ password=True,
208
+ )
209
+
210
+ if not api_key:
211
+ raise EmptyApiKeyError()
212
+
213
+ llm_config = LLMConfig(
214
+ provider=LLMProvider(llm_provider),
215
+ api_key=api_key,
216
+ )
217
+
218
+ return llm_config
219
+
220
+
221
+ def setup_slack() -> SlackConfig | None:
222
+ """Setup the Slack configuration."""
223
+ slack_config = None
224
+ should_setup = Confirm.ask("\n[bold]Set up Slack integration?[/bold]", default=False)
225
+
226
+ if should_setup:
227
+ console.print("\n[bold cyan]Slack Configuration[/bold cyan]\n")
228
+
229
+ bot_token = Prompt.ask(
230
+ "[bold]Enter your Slack bot token[/bold]",
231
+ password=True,
232
+ )
233
+
234
+ if not bot_token:
235
+ raise InitError("Slack bot token cannot be empty.")
236
+
237
+ signing_secret = Prompt.ask(
238
+ "[bold]Enter your Slack signing secret[/bold]",
239
+ password=True,
240
+ )
241
+
242
+ if not signing_secret:
243
+ raise InitError("Slack signing secret cannot be empty.")
244
+
245
+ slack_config = SlackConfig(
246
+ bot_token=bot_token,
247
+ signing_secret=signing_secret,
248
+ )
249
+
250
+ return slack_config
251
+
252
+
253
+ def create_empty_structure(project_path: Path) -> tuple[list[str], list[CreatedFile]]:
254
+ """Create project folder structure to guide users.
255
+
256
+ To add new folders, simply append them to the FOLDERS list below.
257
+ Each folder will be created automatically (can be empty).
258
+ """
259
+ FOLDERS = [
260
+ "databases",
261
+ "queries",
262
+ "docs",
263
+ "semantics",
264
+ "repos",
265
+ "agent/tools",
266
+ "agent/mcps",
267
+ ]
268
+
269
+ FILES = [
270
+ CreatedFile(path=Path("RULES.md"), content=None),
271
+ CreatedFile(path=Path(".naoignore"), content="templates/\n*.j2\n"),
272
+ ]
273
+
274
+ created_folders = []
275
+ for folder in FOLDERS:
276
+ folder_path = project_path / folder
277
+ folder_path.mkdir(parents=True, exist_ok=True)
278
+ created_folders.append(folder)
279
+
280
+ created_files = []
281
+ for file in FILES:
282
+ file_path = project_path / file.path
283
+ if file.content:
284
+ file_path.write_text(file.content)
285
+ else:
286
+ file_path.touch()
287
+ created_files.append(file)
288
+
289
+ return created_folders, created_files
290
+
291
+
292
+ def init(
293
+ *,
294
+ force: Annotated[bool, Parameter(name=["-f", "--force"])] = False,
295
+ ):
296
+ """Initialize a new nao project.
297
+
298
+ Creates a project folder with a nao_config.yaml configuration file.
299
+
300
+ Parameters
301
+ ----------
302
+ force : bool
303
+ Force re-initialization even if the folder already exists.
304
+ """
305
+ console.print("\n[bold cyan]🚀 nao project initialization[/bold cyan]\n")
306
+
307
+ try:
308
+ project_name, project_path = setup_project_name(force=force)
309
+ config = NaoConfig(
310
+ project_name=project_name,
311
+ databases=setup_databases(),
312
+ repos=setup_repos(),
313
+ llm=setup_llm(),
314
+ slack=setup_slack(),
315
+ )
316
+ config.save(project_path)
317
+
318
+ # Create project folder structure
319
+ created_folders, created_files = create_empty_structure(project_path)
320
+
321
+ console.print()
322
+ console.print(f"[bold green]✓[/bold green] Created project [cyan]{project_name}[/cyan]")
323
+ console.print(f"[bold green]✓[/bold green] Created [dim]{project_path / 'nao_config.yaml'}[/dim]")
324
+ console.print()
325
+ console.print("[bold green]Done![/bold green] Your nao project is ready. 🎉")
326
+
327
+ is_subfolder = project_path.resolve() != Path.cwd().resolve()
328
+
329
+ has_connections = config.databases or config.llm
330
+ if has_connections:
331
+ # Change directory for the debug command to run in the right context
332
+ os.chdir(project_path)
333
+ from nao_core.commands.debug import debug
334
+
335
+ debug()
336
+
337
+ console.print()
338
+
339
+ cd_instruction = ""
340
+ if is_subfolder:
341
+ cd_instruction = f"\n[bold]First, navigate to your project:[/bold]\n[cyan]cd {project_path}[/cyan]\n\n"
342
+
343
+ help_content = f"""{cd_instruction}[bold]Available Commands:[/bold]
344
+
345
+ [cyan]nao debug[/cyan] - Test connectivity to your configured databases and LLM
346
+ Verifies that all connections are working properly
347
+
348
+ [cyan]nao sync[/cyan] - Sync database schemas to local markdown files
349
+ Creates documentation for your tables and columns
350
+
351
+ [cyan]nao chat[/cyan] - Start the nao chat interface
352
+ Launch the web UI to chat with your data
353
+ """
354
+ console.print(Panel(help_content, border_style="cyan", title="🚀 Get Started", title_align="left"))
355
+ console.print()
356
+
357
+ except InitError as e:
358
+ console.print(f"[bold red]✗[/bold red] {e}")
@@ -0,0 +1,124 @@
1
+ """Sync command for synchronizing repositories and database schemas."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+
8
+ from nao_core.config import NaoConfig
9
+ from nao_core.templates.render import render_all_templates
10
+
11
+ from .providers import SyncProvider, SyncResult, get_all_providers
12
+
13
+ console = Console()
14
+
15
+
16
+ def sync(
17
+ output_dirs: dict[str, str] | None = None,
18
+ providers: list[SyncProvider] | None = None,
19
+ render_templates: bool = True,
20
+ ):
21
+ """Sync resources using configured providers.
22
+
23
+ Creates folder structures based on each provider's default output directory:
24
+ - repos/<repo_name>/ (git repositories)
25
+ - databases/<type>/<connection>/<dataset>/<table>/*.md (database schemas)
26
+
27
+ After syncing providers, renders any Jinja templates (*.j2 files) found in
28
+ the project directory, making the `nao` context object available for
29
+ accessing provider data.
30
+
31
+ Args:
32
+ output_dirs: Optional dict mapping provider names to custom output directories.
33
+ If not specified, uses each provider's default_output_dir.
34
+ providers: Optional list of providers to use. If not specified, uses all
35
+ registered providers.
36
+ render_templates: Whether to render Jinja templates after syncing providers.
37
+ Defaults to True.
38
+ """
39
+ console.print("\n[bold cyan]🔄 nao sync[/bold cyan]\n")
40
+
41
+ config = NaoConfig.try_load()
42
+ if config is None:
43
+ console.print("[bold red]✗[/bold red] No nao_config.yaml found in current directory")
44
+ console.print("[dim]Run 'nao init' to create a configuration file[/dim]")
45
+ sys.exit(1)
46
+ assert config is not None # Help type checker after sys.exit
47
+
48
+ # Get project path (current working directory after NaoConfig.try_load)
49
+ project_path = Path.cwd()
50
+
51
+ console.print(f"[dim]Project:[/dim] {config.project_name}")
52
+
53
+ # Use provided providers or default to all registered providers
54
+ active_providers = providers if providers is not None else get_all_providers()
55
+ output_dirs = output_dirs or {}
56
+
57
+ # Run each provider
58
+ results: list[SyncResult] = []
59
+ for provider in active_providers:
60
+ # Get output directory (custom or default)
61
+ output_dir = output_dirs.get(provider.name, provider.default_output_dir)
62
+ output_path = Path(output_dir)
63
+
64
+ try:
65
+ provider.pre_sync(config, output_path)
66
+
67
+ if not provider.should_sync(config):
68
+ continue
69
+
70
+ # Get items and sync
71
+ items = provider.get_items(config)
72
+ result = provider.sync(items, output_path, project_path=project_path)
73
+ results.append(result)
74
+ except Exception as e:
75
+ # Capture error but continue with other providers
76
+ results.append(SyncResult.from_error(provider.name, e))
77
+ console.print(f" [yellow]⚠[/yellow] {provider.emoji} {provider.name}: [red]{e}[/red]")
78
+
79
+ # Render user Jinja templates
80
+ template_result = None
81
+ if render_templates:
82
+ console.print("\n[bold cyan]📝 Rendering templates[/bold cyan]\n")
83
+ template_result = render_all_templates(project_path, config, console)
84
+
85
+ # Separate successful and failed results
86
+ successful_results = [r for r in results if r.success]
87
+ failed_results = [r for r in results if not r.success]
88
+
89
+ # Print summary with appropriate status
90
+ if failed_results:
91
+ if successful_results:
92
+ console.print("\n[bold yellow]⚠ Sync Completed with Errors[/bold yellow]\n")
93
+ else:
94
+ console.print("\n[bold red]✗ Sync Failed[/bold red]\n")
95
+ else:
96
+ console.print("\n[bold green]✓ Sync Complete[/bold green]\n")
97
+
98
+ has_results = False
99
+
100
+ # Show successful syncs
101
+ for result in successful_results:
102
+ if result.items_synced > 0:
103
+ has_results = True
104
+ console.print(f" [dim]{result.provider_name}:[/dim] {result.get_summary()}")
105
+
106
+ # Show template results
107
+ if template_result and (template_result.templates_rendered > 0 or template_result.templates_failed > 0):
108
+ has_results = True
109
+ console.print(f" [dim]Templates:[/dim] {template_result.get_summary()}")
110
+
111
+ # Show errors section if any
112
+ if failed_results:
113
+ has_results = True
114
+ console.print("\n [bold red]Errors:[/bold red]")
115
+ for result in failed_results:
116
+ console.print(f" [red]•[/red] {result.provider_name}: {result.error}")
117
+
118
+ if not has_results:
119
+ console.print(" [dim]Nothing to sync[/dim]")
120
+
121
+ console.print()
122
+
123
+
124
+ __all__ = ["sync"]