bowerbot 1.5.0__py3-none-any.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 (58) hide show
  1. bowerbot/__init__.py +8 -0
  2. bowerbot/agent.py +185 -0
  3. bowerbot/cli.py +430 -0
  4. bowerbot/config.py +141 -0
  5. bowerbot/dispatcher.py +84 -0
  6. bowerbot/gateway/__init__.py +3 -0
  7. bowerbot/project.py +156 -0
  8. bowerbot/prompts/__init__.py +26 -0
  9. bowerbot/prompts/core.md +9 -0
  10. bowerbot/prompts/library.md +64 -0
  11. bowerbot/prompts/scene_building.md +347 -0
  12. bowerbot/prompts/textures.md +31 -0
  13. bowerbot/schemas/__init__.py +51 -0
  14. bowerbot/schemas/assets.py +52 -0
  15. bowerbot/schemas/intake.py +43 -0
  16. bowerbot/schemas/lights.py +42 -0
  17. bowerbot/schemas/materials.py +24 -0
  18. bowerbot/schemas/textures.py +46 -0
  19. bowerbot/schemas/transforms.py +57 -0
  20. bowerbot/schemas/validation.py +36 -0
  21. bowerbot/services/__init__.py +10 -0
  22. bowerbot/services/asset_service.py +372 -0
  23. bowerbot/services/library_service.py +27 -0
  24. bowerbot/services/light_service.py +360 -0
  25. bowerbot/services/material_service.py +215 -0
  26. bowerbot/services/stage_service.py +166 -0
  27. bowerbot/services/texture_service.py +26 -0
  28. bowerbot/services/validation_service.py +45 -0
  29. bowerbot/skills/__init__.py +46 -0
  30. bowerbot/skills/base.py +152 -0
  31. bowerbot/skills/registry.py +168 -0
  32. bowerbot/state.py +65 -0
  33. bowerbot/token_manager.py +367 -0
  34. bowerbot/tools/__init__.py +15 -0
  35. bowerbot/tools/_helpers.py +36 -0
  36. bowerbot/tools/asset_tools.py +290 -0
  37. bowerbot/tools/library_tools.py +96 -0
  38. bowerbot/tools/light_tools.py +293 -0
  39. bowerbot/tools/material_tools.py +238 -0
  40. bowerbot/tools/stage_tools.py +248 -0
  41. bowerbot/tools/texture_tools.py +94 -0
  42. bowerbot/tools/validation_tools.py +63 -0
  43. bowerbot/utils/__init__.py +4 -0
  44. bowerbot/utils/asset_folder_utils.py +336 -0
  45. bowerbot/utils/asset_intake_utils.py +583 -0
  46. bowerbot/utils/dependency_utils.py +127 -0
  47. bowerbot/utils/geometry_utils.py +168 -0
  48. bowerbot/utils/library_utils.py +136 -0
  49. bowerbot/utils/light_utils.py +338 -0
  50. bowerbot/utils/material_utils.py +282 -0
  51. bowerbot/utils/naming_utils.py +34 -0
  52. bowerbot/utils/stage_utils.py +555 -0
  53. bowerbot/utils/texture_utils.py +62 -0
  54. bowerbot/utils/validation_utils.py +173 -0
  55. bowerbot-1.5.0.dist-info/METADATA +597 -0
  56. bowerbot-1.5.0.dist-info/RECORD +58 -0
  57. bowerbot-1.5.0.dist-info/WHEEL +4 -0
  58. bowerbot-1.5.0.dist-info/entry_points.txt +3 -0
bowerbot/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ # Copyright 2026 Binary Core LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """BowerBot — AI-powered 3D scene assembly using OpenUSD."""
5
+
6
+ from importlib.metadata import version
7
+
8
+ __version__ = version("bowerbot")
bowerbot/agent.py ADDED
@@ -0,0 +1,185 @@
1
+ # Copyright 2026 Binary Core LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """AgentRuntime — the 'Architect' layer.
5
+
6
+ Runs a tool-calling loop against an LLM: the model decides what to
7
+ call, the dispatcher executes it against the shared
8
+ :class:`~bowerbot.state.SceneState`, results feed back into the
9
+ conversation, and the loop repeats until the model returns prose.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ import litellm
20
+
21
+ from bowerbot import dispatcher
22
+ from bowerbot.config import Settings
23
+ from bowerbot.prompts import load_prompt
24
+ from bowerbot.skills.base import ToolResult
25
+ from bowerbot.skills.registry import SkillRegistry
26
+ from bowerbot.state import SceneState
27
+ from bowerbot.token_manager import TokenManager
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ MAX_VALIDATION_RETRIES = 2
32
+
33
+
34
+ @dataclass
35
+ class AgentRuntime:
36
+ """LLM tool-calling loop bound to a single :class:`SceneState`."""
37
+
38
+ settings: Settings
39
+ state: SceneState
40
+ skill_registry: SkillRegistry
41
+ conversation_history: list[dict[str, Any]] = field(default_factory=list)
42
+ _system_prompt: str = field(default="", init=False)
43
+ _tools: list[dict[str, Any]] = field(default_factory=list, init=False)
44
+ _token_manager: TokenManager = field(init=False)
45
+ _scene_tool_names: set[str] = field(default_factory=set, init=False)
46
+
47
+ def __post_init__(self) -> None:
48
+ """Assemble the system prompt and cache the tool list."""
49
+ self._system_prompt = self._build_system_prompt()
50
+ self._tools = (
51
+ dispatcher.get_tool_schemas()
52
+ + self.skill_registry.get_all_tools()
53
+ )
54
+ self._token_manager = TokenManager(self.settings.llm)
55
+ self._scene_tool_names = dispatcher.get_tool_names()
56
+
57
+ logger.info(
58
+ "System prompt: %d chars, %d scene tools + %d skill(s)",
59
+ len(self._system_prompt),
60
+ len(dispatcher.TOOLS),
61
+ self.skill_registry.skill_count,
62
+ )
63
+
64
+ def _build_system_prompt(self) -> str:
65
+ """Stitch together core + scene-building + skill prompt sections."""
66
+ sections = [
67
+ load_prompt("core"),
68
+ f"# Scene Building\n\n{load_prompt('scene_building')}",
69
+ f"# Asset Library\n\n{load_prompt('library')}",
70
+ f"# Textures\n\n{load_prompt('textures')}",
71
+ ]
72
+ skill_prompts = self.skill_registry.get_skill_prompts()
73
+ if skill_prompts:
74
+ sections.append(f"# Extension Skills\n\n{skill_prompts}")
75
+ return "\n\n---\n\n".join(sections)
76
+
77
+ async def process(self, user_message: str) -> str:
78
+ """Run the tool-calling loop until the LLM returns prose."""
79
+ self.conversation_history.append(
80
+ {"role": "user", "content": user_message},
81
+ )
82
+ validation_retries = 0
83
+
84
+ for round_num in range(self.settings.llm.max_tool_rounds):
85
+ logger.info("Agent loop round %s", round_num + 1)
86
+
87
+ messages, _ = await self._token_manager.prepare_messages(
88
+ self._system_prompt, self.conversation_history,
89
+ )
90
+
91
+ kwargs: dict[str, Any] = {
92
+ "model": self.settings.llm.model,
93
+ "messages": messages,
94
+ "max_tokens": self.settings.llm.max_tokens,
95
+ "temperature": self.settings.llm.temperature,
96
+ "num_retries": self.settings.llm.num_retries,
97
+ "timeout": self.settings.llm.request_timeout,
98
+ }
99
+ api_key = self.settings.get_api_key()
100
+ if api_key:
101
+ kwargs["api_key"] = api_key
102
+ if self._tools:
103
+ kwargs["tools"] = self._tools
104
+
105
+ response = await litellm.acompletion(**kwargs)
106
+ message = response.choices[0].message
107
+
108
+ if not message.tool_calls:
109
+ content = message.content or ""
110
+ self.conversation_history.append(
111
+ {"role": "assistant", "content": content},
112
+ )
113
+ return content
114
+
115
+ logger.info("LLM requested %s tool call(s)", len(message.tool_calls))
116
+ self.conversation_history.append(message.model_dump())
117
+
118
+ for tool_call in message.tool_calls:
119
+ await self._run_tool_call(tool_call)
120
+
121
+ if self._nudge_on_validation_errors(
122
+ message.tool_calls, validation_retries,
123
+ ):
124
+ validation_retries += 1
125
+
126
+ return "Reached maximum tool-calling rounds. Please try a simpler request."
127
+
128
+ async def _run_tool_call(self, tool_call: Any) -> None:
129
+ """Execute a single tool call and append its result to history."""
130
+ func_name = tool_call.function.name
131
+ func_args = json.loads(tool_call.function.arguments)
132
+ logger.info("Executing tool: %s(%s)", func_name, func_args)
133
+
134
+ result = await self._dispatch_tool(func_name, func_args)
135
+ self.conversation_history.append({
136
+ "role": "tool",
137
+ "tool_call_id": tool_call.id,
138
+ "content": json.dumps(
139
+ result.data if result.success else {"error": result.error},
140
+ ),
141
+ })
142
+
143
+ async def _dispatch_tool(
144
+ self, func_name: str, func_args: dict[str, Any],
145
+ ) -> ToolResult:
146
+ """Route a tool call to the dispatcher or the skill registry."""
147
+ if func_name in self._scene_tool_names:
148
+ return await dispatcher.execute(self.state, func_name, func_args)
149
+ return await self.skill_registry.execute_tool(
150
+ func_name, func_args, self.state,
151
+ )
152
+
153
+ def _nudge_on_validation_errors(
154
+ self, tool_calls: list, retries: int,
155
+ ) -> bool:
156
+ """If ``validate_scene`` returned errors, nudge the LLM to fix them."""
157
+ for tool_call in tool_calls:
158
+ if tool_call.function.name != "validate_scene":
159
+ continue
160
+ for msg in reversed(self.conversation_history):
161
+ if msg.get("tool_call_id") != tool_call.id:
162
+ continue
163
+ try:
164
+ content = json.loads(msg["content"])
165
+ except (json.JSONDecodeError, TypeError):
166
+ return False
167
+ if content.get("is_valid", True) or retries >= MAX_VALIDATION_RETRIES:
168
+ return False
169
+ self.conversation_history.append({
170
+ "role": "user",
171
+ "content": (
172
+ "The scene has validation errors. Please fix the "
173
+ "issues listed above, then call validate_scene again."
174
+ ),
175
+ })
176
+ logger.info(
177
+ "Validation retry nudge (%d/%d)",
178
+ retries + 1, MAX_VALIDATION_RETRIES,
179
+ )
180
+ return True
181
+ return False
182
+
183
+ def reset(self) -> None:
184
+ """Clear conversation history for a new session."""
185
+ self.conversation_history.clear()
bowerbot/cli.py ADDED
@@ -0,0 +1,430 @@
1
+ # Copyright 2026 Binary Core LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """BowerBot CLI — natural language 3D scene assembly."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import litellm
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+ from rich.theme import Theme
17
+
18
+ from bowerbot import __version__, dispatcher
19
+ from bowerbot.agent import AgentRuntime
20
+ from bowerbot.config import (
21
+ BOWERBOT_HOME,
22
+ GLOBAL_CONFIG_PATH,
23
+ LLMSettings,
24
+ SceneDefaults,
25
+ Settings,
26
+ ensure_home,
27
+ load_settings,
28
+ save_settings,
29
+ )
30
+ from bowerbot.project import Project
31
+ from bowerbot.utils import stage_utils
32
+ from bowerbot.skills.registry import SkillRegistry
33
+ from bowerbot.state import SceneState
34
+ from bowerbot.utils.naming_utils import safe_project_name
35
+
36
+ theme = Theme({
37
+ "sf": "bold green",
38
+ "user": "bold cyan",
39
+ "info": "dim",
40
+ })
41
+ console = Console(theme=theme)
42
+
43
+
44
+ def _build_state(
45
+ settings: Settings, project: Project | None = None,
46
+ ) -> SceneState:
47
+ """Build a SceneState, optionally binding it to *project*."""
48
+ state = SceneState(
49
+ scene_defaults=settings.scene_defaults,
50
+ library_dir=Path(settings.assets_dir),
51
+ )
52
+ if project is None:
53
+ return state
54
+
55
+ state.project = project
56
+ state.stage_path = project.scene_path
57
+
58
+ if project.scene_path.exists():
59
+ state.stage = stage_utils.open_stage(project.scene_path)
60
+ state.object_count = len(stage_utils.list_prims(state.stage))
61
+ return state
62
+
63
+
64
+ def _build_registry(settings: Settings) -> SkillRegistry:
65
+ """Build a SkillRegistry with extension skills only."""
66
+ registry = SkillRegistry()
67
+ registry.load_from_settings(settings)
68
+ return registry
69
+
70
+
71
+ @click.group()
72
+ @click.version_option()
73
+ def main() -> None:
74
+ """BowerBot — AI-powered 3D scene assembly using OpenUSD."""
75
+
76
+
77
+ @main.command()
78
+ @click.argument("name")
79
+ def new(name: str) -> None:
80
+ """Create a new BowerBot project."""
81
+ settings = load_settings()
82
+ projects_dir = Path(settings.projects_dir)
83
+ projects_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ try:
86
+ project = Project.create(projects_dir, name)
87
+ console.print(f"[sf]✅ Created project:[/] {project.name}")
88
+ console.print(f" Path: {project.path}")
89
+ console.print("\n[info]Start working:[/]")
90
+ console.print(f" cd {project.path}")
91
+ console.print(" bowerbot chat")
92
+ except FileExistsError:
93
+ console.print(f"[red]Project already exists:[/] {name}")
94
+
95
+
96
+ @main.command(name="list")
97
+ def list_projects() -> None:
98
+ """List all BowerBot projects."""
99
+ settings = load_settings()
100
+ projects = Project.list_projects(Path(settings.projects_dir))
101
+
102
+ if not projects:
103
+ console.print("[info]No projects yet. Create one with:[/]")
104
+ console.print(" bowerbot new my_project")
105
+ return
106
+
107
+ table = Table(title="BowerBot Projects")
108
+ table.add_column("Name", style="bold green")
109
+ table.add_column("Updated", style="dim")
110
+ table.add_column("Path", style="dim")
111
+
112
+ for p in projects:
113
+ table.add_row(p.name, p.meta.updated_at[:10], str(p.path))
114
+
115
+ console.print(table)
116
+
117
+
118
+ @main.command()
119
+ @click.argument("name")
120
+ def open(name: str) -> None:
121
+ """Open a project and start an interactive session."""
122
+ settings = load_settings()
123
+ projects_dir = Path(settings.projects_dir)
124
+
125
+ project_path = projects_dir / name.lower().replace(" ", "_")
126
+ if not project_path.exists():
127
+ console.print(f"[red]Project not found:[/] {name}")
128
+ console.print("[info]Available projects:[/]")
129
+ for p in Project.list_projects(projects_dir):
130
+ console.print(f" • {p.meta.name} ({p.path.name})")
131
+ return
132
+
133
+ project = Project.load(project_path)
134
+ _start_chat(settings, project)
135
+
136
+
137
+ @main.command()
138
+ def chat() -> None:
139
+ """Interactive scene building session.
140
+
141
+ If run inside a project directory, auto-loads that project.
142
+ Otherwise starts without a project (use 'new' to create one).
143
+ """
144
+ settings = load_settings()
145
+ project = Project.detect(Path.cwd())
146
+ if project:
147
+ console.print(f"[sf]Detected project:[/] {project.name}")
148
+ _start_chat(settings, project)
149
+
150
+
151
+ def _format_object_summary(obj: dict) -> str:
152
+ """Format a scene object for the resume context message."""
153
+ path = obj["prim_path"]
154
+ pos = obj.get("position")
155
+ label = obj.get("asset") or obj.get("light_type") or obj.get("type", "unknown")
156
+ return f" - {path} ({label}, position: {pos})"
157
+
158
+
159
+ def _start_chat(settings: Settings, project: Project | None = None) -> None:
160
+ """Start an interactive chat session, optionally inside a project."""
161
+ state = _build_state(settings, project=project)
162
+ registry = _build_registry(settings)
163
+
164
+ status = f"[sf]BowerBot[/] v{__version__} — Interactive Scene Builder\n"
165
+ status += f"[info]Model:[/] {settings.llm.model}\n"
166
+ status += f"[info]Skills:[/] {', '.join(registry.enabled_skills)}\n"
167
+
168
+ if project:
169
+ status += f"[info]Project:[/] {project.name}\n"
170
+ status += f"[info]Path:[/] {project.path}\n"
171
+ if project.scene_path.exists():
172
+ status += (
173
+ f"[info]Scene:[/] {project.meta.scene_file} "
174
+ f"({state.object_count} object(s))\n"
175
+ )
176
+ else:
177
+ status += "[info]Project:[/] none (use 'bowerbot new' to create one)\n"
178
+
179
+ status += "\n[info]Commands: 'quit' to exit, 'reset' to start a new session[/]"
180
+ console.print(Panel(status, title="[sf]BowerBot[/]", border_style="green"))
181
+
182
+ agent = AgentRuntime(
183
+ settings=settings, state=state, skill_registry=registry,
184
+ )
185
+
186
+ if project and project.scene_path.exists() and state.object_count > 0:
187
+ objects = stage_utils.list_prims(state.stage)
188
+ object_summary = "\n".join(
189
+ _format_object_summary(o) for o in objects
190
+ )
191
+ context = (
192
+ f"You are resuming project '{project.name}'. The scene is "
193
+ f"already open at {project.scene_path} with "
194
+ f"{len(objects)} object(s):\n{object_summary}\n"
195
+ f"The stage is loaded and ready — you do NOT need to call "
196
+ f"create_stage."
197
+ )
198
+ agent.conversation_history.append(
199
+ {"role": "system", "content": context},
200
+ )
201
+
202
+ asyncio.run(_chat_loop(agent, console))
203
+
204
+
205
+ async def _chat_loop(agent: AgentRuntime, console: Console) -> None:
206
+ """Run the interactive chat loop."""
207
+ while True:
208
+ console.print()
209
+ try:
210
+ user_input = console.input("[user]You:[/] ").strip()
211
+ except (EOFError, KeyboardInterrupt):
212
+ console.print("\n[info]Goodbye![/]")
213
+ break
214
+
215
+ if not user_input:
216
+ continue
217
+ if user_input.lower() in ("quit", "exit", "q"):
218
+ console.print("[info]Goodbye![/]")
219
+ break
220
+ if user_input.lower() == "reset":
221
+ agent.reset()
222
+ console.print("[info]Session reset — starting fresh.[/]")
223
+ continue
224
+
225
+ try:
226
+ with console.status("[sf]BowerBot is thinking...[/]", spinner="dots"):
227
+ response = await agent.process(user_input)
228
+ console.print(f"\n[sf]BowerBot:[/] {response}")
229
+ except KeyboardInterrupt:
230
+ console.print("\n[info]Interrupted. Type 'quit' to exit.[/]")
231
+ except litellm.AuthenticationError:
232
+ console.print(
233
+ "\n[red]Authentication failed.[/] "
234
+ "Check your API key with 'bowerbot info'.",
235
+ )
236
+ except litellm.RateLimitError:
237
+ console.print(
238
+ "\n[yellow]Rate limited.[/] "
239
+ "Retries exhausted — wait a moment and try again.",
240
+ )
241
+ except litellm.APIConnectionError:
242
+ console.print(
243
+ "\n[red]Cannot reach API.[/] Check your network connection.",
244
+ )
245
+ except litellm.Timeout:
246
+ console.print(
247
+ "\n[yellow]Request timed out.[/] "
248
+ "Try again or increase request_timeout in config.",
249
+ )
250
+ except Exception as e:
251
+ console.print(f"\n[red]Error:[/] {e}")
252
+ console.print(
253
+ "[info]You can keep going or type 'reset' to start over.[/]",
254
+ )
255
+
256
+
257
+ @main.command()
258
+ @click.argument("prompt")
259
+ def build(prompt: str) -> None:
260
+ """Build a USD scene from a single prompt (auto-creates a project)."""
261
+ settings = load_settings()
262
+
263
+ words = prompt.split()[:4]
264
+ project_name = " ".join(words)
265
+ projects_dir = Path(settings.projects_dir)
266
+ projects_dir.mkdir(parents=True, exist_ok=True)
267
+
268
+ try:
269
+ project = Project.create(projects_dir, project_name)
270
+ except FileExistsError:
271
+ safe_name = safe_project_name(project_name)
272
+ project = Project.load(projects_dir / safe_name)
273
+
274
+ console.print("[sf]BowerBot[/] Building scene...")
275
+ console.print(f" Prompt: {prompt}")
276
+ console.print(f" Model: {settings.llm.model}")
277
+ console.print(f" Project: {project.name}")
278
+ console.print(f" Path: {project.path}")
279
+
280
+ state = _build_state(settings, project=project)
281
+ registry = _build_registry(settings)
282
+ console.print(f" Skills: {registry.enabled_skills}")
283
+
284
+ agent = AgentRuntime(
285
+ settings=settings, state=state, skill_registry=registry,
286
+ )
287
+
288
+ try:
289
+ response = asyncio.run(agent.process(prompt))
290
+ console.print(f"\n{response}")
291
+ except litellm.AuthenticationError:
292
+ console.print(
293
+ "\n[red]Authentication failed.[/] "
294
+ "Check your API key with 'bowerbot info'.",
295
+ )
296
+ except litellm.RateLimitError:
297
+ console.print(
298
+ "\n[yellow]Rate limited.[/] "
299
+ "Retries exhausted — wait a moment and try again.",
300
+ )
301
+ except litellm.APIConnectionError:
302
+ console.print(
303
+ "\n[red]Cannot reach API.[/] Check your network connection.",
304
+ )
305
+ except litellm.Timeout:
306
+ console.print(
307
+ "\n[yellow]Request timed out.[/] "
308
+ "Try again or increase request_timeout in config.",
309
+ )
310
+ except Exception as e:
311
+ console.print(f"\n[red]Error:[/] {e}")
312
+
313
+
314
+ @main.command()
315
+ def skills() -> None:
316
+ """List available and enabled skills."""
317
+ settings = load_settings()
318
+
319
+ scene_tools = dispatcher.get_tool_names()
320
+ console.print(f"[sf]Scene builder:[/] {len(scene_tools)} tools")
321
+ for name in sorted(scene_tools):
322
+ console.print(f" - {name}")
323
+
324
+ registry = _build_registry(settings)
325
+
326
+ if registry.skill_count == 0:
327
+ console.print("\n[info]No extension skills enabled.[/]")
328
+ return
329
+
330
+ console.print("\n[sf]Extension skills:[/]")
331
+ for name in registry.enabled_skills:
332
+ tools = [
333
+ t["function"]["name"]
334
+ for t in registry.get_all_tools()
335
+ if t["function"]["name"].startswith(name)
336
+ ]
337
+ console.print(f" • {name} ({len(tools)} tools)")
338
+ for tool_name in tools:
339
+ console.print(f" - {tool_name}")
340
+
341
+
342
+ @main.command()
343
+ def info() -> None:
344
+ """Show current configuration."""
345
+ settings = load_settings()
346
+
347
+ console.print("[sf]BowerBot Configuration[/]")
348
+ console.print(f" Model: {settings.llm.model}")
349
+ console.print(f" Temperature: {settings.llm.temperature}")
350
+ console.print(f" Max tokens: {settings.llm.max_tokens}")
351
+ console.print(
352
+ f" API key: "
353
+ f"{'✅ set' if settings.get_api_key() else '❌ missing'}",
354
+ )
355
+ console.print(f" Projects dir: {settings.projects_dir}")
356
+ console.print(f" Meters per unit: {settings.scene_defaults.meters_per_unit}")
357
+ console.print(f" Up axis: {settings.scene_defaults.up_axis}")
358
+ console.print(f" Room bounds: {settings.scene_defaults.default_room_bounds}")
359
+
360
+ skills_enabled = [k for k, v in settings.skills.items() if v.enabled]
361
+ console.print(f" Skills enabled: {skills_enabled or 'none'}")
362
+
363
+
364
+ @main.command()
365
+ def onboard() -> None:
366
+ """Set up BowerBot for first use."""
367
+ console.print(Panel(
368
+ "[sf]BowerBot[/] — First Time Setup\n\n"
369
+ "This will create your global configuration at:\n"
370
+ f" [info]{BOWERBOT_HOME}[/]",
371
+ title="[sf]Setup[/]",
372
+ border_style="green",
373
+ ))
374
+
375
+ if GLOBAL_CONFIG_PATH.exists():
376
+ console.print(f"\n[info]Config already exists at {GLOBAL_CONFIG_PATH}[/]")
377
+ overwrite = console.input("Overwrite? (y/N): ").strip().lower()
378
+ if overwrite != "y":
379
+ console.print("[info]Keeping existing config.[/]")
380
+ return
381
+
382
+ ensure_home()
383
+
384
+ console.print("\n[sf]LLM Configuration[/]")
385
+ model = console.input(" Model [gpt-4.1]: ").strip() or "gpt-4.1"
386
+ api_key = console.input(" API key: ").strip()
387
+
388
+ if not api_key:
389
+ console.print(
390
+ "[yellow]Warning:[/] No API key provided. "
391
+ "BowerBot won't work without one.\n"
392
+ "You can add it later in ~/.bowerbot/config.json",
393
+ )
394
+
395
+ console.print("\n[sf]Directories[/]")
396
+ assets_dir = (
397
+ console.input(" Asset directory [./assets]: ").strip() or "./assets"
398
+ )
399
+ projects_dir = (
400
+ console.input(" Projects directory [./scenes]: ").strip() or "./scenes"
401
+ )
402
+
403
+ settings = Settings(
404
+ llm=LLMSettings(
405
+ model=model,
406
+ api_key=api_key,
407
+ temperature=0.1,
408
+ max_tokens=4096,
409
+ ),
410
+ scene_defaults=SceneDefaults(),
411
+ skills={},
412
+ assets_dir=assets_dir,
413
+ projects_dir=projects_dir,
414
+ )
415
+
416
+ save_settings(settings)
417
+
418
+ console.print(f"\n[sf]Config saved to {GLOBAL_CONFIG_PATH}[/]")
419
+ console.print(
420
+ "\n[info]Skills are extension packages you install separately. "
421
+ "After installing one (e.g. [sf]pip install bowerbot-skill-sketchfab[/]), "
422
+ "add its config to your config.json under the [sf]skills[/] block.[/]",
423
+ )
424
+ console.print("\n[info]You're ready to go! Try:[/]")
425
+ console.print(" [sf]bowerbot new my_first_scene[/]")
426
+ console.print(" [sf]bowerbot chat[/]")
427
+
428
+
429
+ if __name__ == "__main__":
430
+ main()