bilrost 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ .pytest_cache/
bilrost-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Peleke Sengstacke
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
bilrost-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: bilrost
3
+ Version: 0.1.0
4
+ Summary: Hardened Lima VM for running AI agents — overlay isolation, network containment, secrets management, and gated sync.
5
+ Project-URL: Homepage, https://github.com/Peleke/openclaw-sandbox
6
+ Project-URL: Documentation, https://peleke.github.io/openclaw-sandbox/
7
+ Project-URL: Repository, https://github.com/Peleke/openclaw-sandbox
8
+ Project-URL: Issues, https://github.com/Peleke/openclaw-sandbox/issues
9
+ Project-URL: Changelog, https://github.com/Peleke/openclaw-sandbox/blob/main/CHANGELOG.md
10
+ Author: Peleke Sengstacke
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: ai-agents,isolation,lima,sandbox,security,vm
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: MacOS
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Security
25
+ Classifier: Topic :: Software Development :: Testing
26
+ Classifier: Topic :: System :: Emulators
27
+ Requires-Python: >=3.11
28
+ Requires-Dist: fastmcp<3,>=2
29
+ Requires-Dist: jinja2<4,>=3.1
30
+ Requires-Dist: pydantic<3,>=2
31
+ Requires-Dist: rich<14,>=13
32
+ Requires-Dist: tomli-w<2,>=1
33
+ Requires-Dist: tomli<3,>=2; python_version < '3.11'
34
+ Requires-Dist: typer<1,>=0.12
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
37
+ Requires-Dist: pyyaml<7,>=6; extra == 'dev'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # bilrost
41
+
42
+ **Hardened Lima VM for running AI agents** — overlay isolation, network containment, secrets management, and gated sync.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ # Via pipx (recommended)
48
+ pipx install bilrost
49
+
50
+ # Via uv
51
+ uv tool install bilrost
52
+
53
+ # Via pip
54
+ pip install bilrost
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```bash
60
+ # Interactive setup
61
+ bilrost init
62
+
63
+ # Provision the VM (~5 min first run)
64
+ bilrost up
65
+
66
+ # Check status
67
+ bilrost status
68
+
69
+ # SSH into the VM
70
+ bilrost ssh
71
+
72
+ # Sync overlay changes to host (with secret scanning)
73
+ bilrost sync
74
+
75
+ # Stop / destroy
76
+ bilrost down
77
+ bilrost destroy
78
+ ```
79
+
80
+ ## MCP Server
81
+
82
+ Agents can manage the sandbox programmatically via FastMCP:
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "sandbox": {
88
+ "command": "bilrost-mcp"
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ 9 tools: `sandbox_status`, `sandbox_up`, `sandbox_down`, `sandbox_destroy`, `sandbox_exec`, `sandbox_validate`, `sandbox_ssh_info`, `sandbox_gateway_info`, `sandbox_agent_identity`.
95
+
96
+ ## What It Does
97
+
98
+ - **OverlayFS isolation** — host code mounted read-only, all writes contained in VM overlay
99
+ - **Network containment** — UFW firewall with explicit allowlist (HTTPS, DNS, Tailscale, NTP only)
100
+ - **Secrets management** — three injection methods, `0600` perms, never in process lists
101
+ - **Gated sync** — gitleaks scanning + path allowlist before changes reach your host
102
+ - **Docker sandboxing** — per-session containers with configurable network isolation
103
+ - **12 Ansible roles** — overlay, secrets, gateway, docker, firewall, sync-gate, gh-cli, buildlog, cadence, qortex, tailscale, and more
104
+
105
+ ## Requirements
106
+
107
+ - macOS (Apple Silicon or Intel)
108
+ - [Homebrew](https://brew.sh/)
109
+ - ~10GB disk space
110
+
111
+ Dependencies (Lima, Ansible, etc.) are installed automatically on first run.
112
+
113
+ ## Documentation
114
+
115
+ Full docs: [peleke.github.io/openclaw-sandbox](https://peleke.github.io/openclaw-sandbox/)
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,80 @@
1
+ # bilrost
2
+
3
+ **Hardened Lima VM for running AI agents** — overlay isolation, network containment, secrets management, and gated sync.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # Via pipx (recommended)
9
+ pipx install bilrost
10
+
11
+ # Via uv
12
+ uv tool install bilrost
13
+
14
+ # Via pip
15
+ pip install bilrost
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ # Interactive setup
22
+ bilrost init
23
+
24
+ # Provision the VM (~5 min first run)
25
+ bilrost up
26
+
27
+ # Check status
28
+ bilrost status
29
+
30
+ # SSH into the VM
31
+ bilrost ssh
32
+
33
+ # Sync overlay changes to host (with secret scanning)
34
+ bilrost sync
35
+
36
+ # Stop / destroy
37
+ bilrost down
38
+ bilrost destroy
39
+ ```
40
+
41
+ ## MCP Server
42
+
43
+ Agents can manage the sandbox programmatically via FastMCP:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "sandbox": {
49
+ "command": "bilrost-mcp"
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ 9 tools: `sandbox_status`, `sandbox_up`, `sandbox_down`, `sandbox_destroy`, `sandbox_exec`, `sandbox_validate`, `sandbox_ssh_info`, `sandbox_gateway_info`, `sandbox_agent_identity`.
56
+
57
+ ## What It Does
58
+
59
+ - **OverlayFS isolation** — host code mounted read-only, all writes contained in VM overlay
60
+ - **Network containment** — UFW firewall with explicit allowlist (HTTPS, DNS, Tailscale, NTP only)
61
+ - **Secrets management** — three injection methods, `0600` perms, never in process lists
62
+ - **Gated sync** — gitleaks scanning + path allowlist before changes reach your host
63
+ - **Docker sandboxing** — per-session containers with configurable network isolation
64
+ - **12 Ansible roles** — overlay, secrets, gateway, docker, firewall, sync-gate, gh-cli, buildlog, cadence, qortex, tailscale, and more
65
+
66
+ ## Requirements
67
+
68
+ - macOS (Apple Silicon or Intel)
69
+ - [Homebrew](https://brew.sh/)
70
+ - ~10GB disk space
71
+
72
+ Dependencies (Lima, Ansible, etc.) are installed automatically on first run.
73
+
74
+ ## Documentation
75
+
76
+ Full docs: [peleke.github.io/openclaw-sandbox](https://peleke.github.io/openclaw-sandbox/)
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bilrost"
7
+ version = "0.1.0"
8
+ description = "Hardened Lima VM for running AI agents — overlay isolation, network containment, secrets management, and gated sync."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Peleke Sengstacke" }]
13
+ keywords = ["sandbox", "ai-agents", "lima", "vm", "security", "isolation"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: MacOS",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Topic :: Security",
26
+ "Topic :: Software Development :: Testing",
27
+ "Topic :: System :: Emulators",
28
+ ]
29
+ dependencies = [
30
+ "typer>=0.12,<1",
31
+ "pydantic>=2,<3",
32
+ "tomli>=2,<3;python_version<'3.11'",
33
+ "tomli-w>=1,<2",
34
+ "rich>=13,<14",
35
+ "jinja2>=3.1,<4",
36
+ "fastmcp>=2,<3",
37
+ ]
38
+
39
+ [project.optional-dependencies]
40
+ dev = ["pytest>=8,<9", "pyyaml>=6,<7"]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/Peleke/openclaw-sandbox"
44
+ Documentation = "https://peleke.github.io/openclaw-sandbox/"
45
+ Repository = "https://github.com/Peleke/openclaw-sandbox"
46
+ Issues = "https://github.com/Peleke/openclaw-sandbox/issues"
47
+ Changelog = "https://github.com/Peleke/openclaw-sandbox/blob/main/CHANGELOG.md"
48
+
49
+ [project.scripts]
50
+ bilrost = "sandbox_cli.app:app"
51
+ bilrost-mcp = "sandbox_cli.mcp_server:main"
52
+ # Keep legacy entry points for backward compat
53
+ sandbox = "sandbox_cli.app:app"
54
+ sandbox-mcp = "sandbox_cli.mcp_server:main"
55
+
56
+ [tool.hatch.build.targets.wheel]
57
+ packages = ["src/sandbox_cli"]
58
+
59
+ [tool.hatch.build.targets.sdist]
60
+ include = [
61
+ "src/sandbox_cli/",
62
+ "README.md",
63
+ "LICENSE",
64
+ ]
65
+
66
+ [tool.pytest.ini_options]
67
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,69 @@
1
+ """Output capture utilities for MCP stdio transport safety.
2
+
3
+ Rich console output and subprocess stdout must not leak into the MCP
4
+ stdio channel. This module provides helpers to capture or suppress
5
+ output so tool implementations stay transport-safe.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import io
12
+ import os
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from typing import Callable
16
+
17
+ from rich.console import Console
18
+
19
+
20
+ @dataclass
21
+ class CapturedExec:
22
+ """Result of a captured subprocess execution."""
23
+
24
+ stdout: str
25
+ stderr: str
26
+ exit_code: int
27
+
28
+
29
+ def make_capture_console() -> Console:
30
+ """Return a Rich console that writes to an in-memory buffer.
31
+
32
+ The caller can retrieve the output via ``console.file.getvalue()``.
33
+ """
34
+ return Console(file=io.StringIO(), force_terminal=False)
35
+
36
+
37
+ @contextlib.contextmanager
38
+ def suppress_stdout():
39
+ """Context manager that redirects ``sys.stdout`` to ``os.devnull``.
40
+
41
+ Useful when calling functions that may print directly (not via Rich)
42
+ or when ``subprocess.run`` inherits stdout.
43
+ """
44
+ devnull = open(os.devnull, "w")
45
+ old_stdout = sys.stdout
46
+ try:
47
+ sys.stdout = devnull
48
+ yield
49
+ finally:
50
+ sys.stdout = old_stdout
51
+ devnull.close()
52
+
53
+
54
+ def run_captured(fn: Callable[..., object], *args: object, **kwargs: object) -> str:
55
+ """Call *fn* with a capture console and return the output text.
56
+
57
+ The function must accept a ``console`` keyword argument (matching
58
+ the pattern used by ``print_post_bootstrap`` and ``print_status_report``).
59
+ """
60
+ cap = make_capture_console()
61
+ fn(*args, console=cap, **kwargs)
62
+ return cap.file.getvalue()
63
+
64
+
65
+ def _truncate(text: str, max_chars: int = 50_000) -> str:
66
+ """Truncate *text* to *max_chars*, appending a marker if clipped."""
67
+ if len(text) <= max_chars:
68
+ return text
69
+ return text[:max_chars] + "\n\n[output truncated]"
@@ -0,0 +1,96 @@
1
+ """Ansible inventory builder and playbook invocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import os
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from .lima_config import secrets_filename
12
+ from .lima_manager import SSHDetails
13
+ from .models import SandboxProfile
14
+
15
+
16
+ def build_inventory(vm_name: str, ssh: SSHDetails) -> str:
17
+ """Return an INI-format Ansible inventory string."""
18
+ return (
19
+ "[sandbox]\n"
20
+ f"{vm_name} "
21
+ f"ansible_host={ssh.host} "
22
+ f"ansible_port={ssh.port} "
23
+ f"ansible_user={ssh.user} "
24
+ f"ansible_ssh_private_key_file={ssh.key_path} "
25
+ "ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'\n"
26
+ )
27
+
28
+
29
+ def build_extra_vars(profile: SandboxProfile) -> list[str]:
30
+ """Build the ``-e key=value`` argument list for ``ansible-playbook``.
31
+
32
+ Matches the exact set of variables that ``bootstrap.sh`` passes.
33
+ """
34
+ sec_fname = secrets_filename(profile)
35
+ tenant = getpass.getuser()
36
+
37
+ # Conditional mount paths — empty string when not configured
38
+ agent_mount = "/mnt/openclaw-agents" if profile.mounts.agent_data else ""
39
+ buildlog_mount = "/mnt/buildlog-data" if profile.mounts.buildlog_data else ""
40
+
41
+ pairs: list[tuple[str, str]] = [
42
+ ("tenant_name", tenant),
43
+ ("provision_path", "/mnt/provision"),
44
+ ("openclaw_path", "/mnt/openclaw"),
45
+ ("obsidian_path", "/mnt/obsidian"),
46
+ ("secrets_filename", sec_fname),
47
+ ("overlay_yolo_mode", str(profile.mode.yolo).lower()),
48
+ ("overlay_yolo_unsafe", str(profile.mode.yolo_unsafe).lower()),
49
+ ("docker_enabled", str(not profile.mode.no_docker).lower()),
50
+ ("agent_data_mount", agent_mount),
51
+ ("buildlog_data_mount", buildlog_mount),
52
+ ("memgraph_enabled", str(profile.mode.memgraph).lower()),
53
+ ]
54
+
55
+ argv: list[str] = []
56
+ for key, value in pairs:
57
+ argv.extend(["-e", f"{key}={value}"])
58
+
59
+ # User-supplied extra vars
60
+ for key, value in profile.extra_vars.items():
61
+ argv.extend(["-e", f"{key}={value}"])
62
+
63
+ return argv
64
+
65
+
66
+ def run_playbook(
67
+ profile: SandboxProfile,
68
+ ssh: SSHDetails,
69
+ bootstrap_dir: Path,
70
+ *,
71
+ vm_name: str = "openclaw-sandbox",
72
+ ) -> int:
73
+ """Write a temp inventory, run ``ansible-playbook``, clean up.
74
+
75
+ Returns the ansible-playbook exit code.
76
+ """
77
+ inventory_text = build_inventory(vm_name, ssh)
78
+ playbook = bootstrap_dir / "ansible" / "playbook.yml"
79
+
80
+ fd, inv_path = tempfile.mkstemp(prefix="sandbox-inv-", suffix=".ini")
81
+ try:
82
+ with os.fdopen(fd, "w") as f:
83
+ f.write(inventory_text)
84
+
85
+ cmd = [
86
+ "ansible-playbook",
87
+ "-i",
88
+ inv_path,
89
+ str(playbook),
90
+ ] + build_extra_vars(profile)
91
+
92
+ env = {**os.environ, "ANSIBLE_HOST_KEY_CHECKING": "False"}
93
+ result = subprocess.run(cmd, env=env)
94
+ return result.returncode
95
+ finally:
96
+ Path(inv_path).unlink(missing_ok=True)
@@ -0,0 +1,194 @@
1
+ """OpenClaw Sandbox CLI — Typer app and subcommand definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from .bootstrap import find_bootstrap_dir, run_script
11
+ from .dashboard import run_dashboard_sync
12
+ from .lima_manager import LimaManager
13
+ from .models import SandboxProfile
14
+ from .orchestrator import orchestrate_up
15
+ from .profile import init_wizard, load_profile
16
+ from .reporting import print_status_report
17
+ from .validation import validate_profile
18
+
19
+ app = typer.Typer(
20
+ name="sandbox",
21
+ help="OpenClaw Sandbox — provision once, run forever.",
22
+ no_args_is_help=True,
23
+ )
24
+ console = Console()
25
+
26
+ _ONBOARD_CMD = (
27
+ 'cd "$(if mountpoint -q /workspace 2>/dev/null; '
28
+ "then echo /workspace; else echo /mnt/openclaw; fi)\" "
29
+ "&& node dist/index.js onboard"
30
+ )
31
+
32
+
33
+ # ── helpers ──────────────────────────────────────────────────────────────
34
+
35
+
36
+ def _load_and_validate(*, strict: bool = True) -> SandboxProfile:
37
+ """Load the profile and run validation. Exit on errors if strict."""
38
+ profile = load_profile()
39
+ result = validate_profile(profile)
40
+ for w in result.warnings:
41
+ console.print(f"[yellow]warning:[/yellow] {w}")
42
+ if not result.ok:
43
+ for e in result.errors:
44
+ console.print(f"[red]error:[/red] {e}")
45
+ if strict:
46
+ raise typer.Exit(1)
47
+ return profile
48
+
49
+
50
+ # ── subcommands ──────────────────────────────────────────────────────────
51
+
52
+
53
+ @app.command()
54
+ def init() -> None:
55
+ """Interactive wizard to create or update your sandbox profile."""
56
+ init_wizard()
57
+
58
+
59
+ @app.command()
60
+ def up(
61
+ fresh: Annotated[
62
+ bool,
63
+ typer.Option("--fresh", help="Destroy existing VM first, then reprovision."),
64
+ ] = False,
65
+ ) -> None:
66
+ """Provision (or reprovision) the sandbox VM."""
67
+ profile = _load_and_validate()
68
+ bootstrap_dir = find_bootstrap_dir(profile)
69
+ lima = LimaManager()
70
+ if fresh:
71
+ console.print("[bold]Destroying existing VM before reprovisioning...[/bold]")
72
+ lima.delete()
73
+ rc = orchestrate_up(profile, bootstrap_dir, lima=lima)
74
+ raise typer.Exit(rc)
75
+
76
+
77
+ @app.command()
78
+ def down() -> None:
79
+ """Stop the sandbox VM (force kill)."""
80
+ _load_and_validate(strict=False)
81
+ lima = LimaManager()
82
+ lima.stop(force=True)
83
+ console.print("VM stopped.")
84
+
85
+
86
+ @app.command()
87
+ def destroy(
88
+ force: Annotated[
89
+ bool,
90
+ typer.Option("-f", "--force", help="Skip confirmation prompt."),
91
+ ] = False,
92
+ ) -> None:
93
+ """Delete the sandbox VM entirely."""
94
+ if not force:
95
+ confirm = typer.confirm("This will permanently delete the VM. Continue?")
96
+ if not confirm:
97
+ raise typer.Abort()
98
+ _load_and_validate(strict=False)
99
+ lima = LimaManager()
100
+ lima.delete()
101
+ console.print("VM deleted.")
102
+
103
+
104
+ @app.command()
105
+ def status() -> None:
106
+ """Show VM state and profile summary."""
107
+ profile = _load_and_validate(strict=False)
108
+ print_status_report(profile, console)
109
+
110
+
111
+ @app.command()
112
+ def ssh() -> None:
113
+ """SSH into the sandbox VM (replaces process for TTY)."""
114
+ _load_and_validate(strict=False)
115
+ lima = LimaManager()
116
+ lima.shell()
117
+
118
+
119
+ @app.command()
120
+ def onboard() -> None:
121
+ """Run the onboarding wizard inside the VM (replaces process for TTY)."""
122
+ _load_and_validate(strict=False)
123
+ lima = LimaManager()
124
+ lima.shell_exec(_ONBOARD_CMD)
125
+
126
+
127
+ @app.command()
128
+ def sync(
129
+ dry_run: Annotated[
130
+ bool,
131
+ typer.Option("--dry-run", help="Preview changes without applying."),
132
+ ] = False,
133
+ ) -> None:
134
+ """Sync overlay changes from VM to host."""
135
+ profile = _load_and_validate(strict=False)
136
+ flags = []
137
+ if dry_run:
138
+ flags.append("--dry-run")
139
+ rc = run_script(profile, "sync-gate.sh", extra_flags=flags)
140
+ raise typer.Exit(rc)
141
+
142
+
143
+ # ── dashboard sub-app ────────────────────────────────────────────────────
144
+
145
+ dashboard_app = typer.Typer(
146
+ name="dashboard",
147
+ help="Gateway dashboard and GitHub-to-Obsidian sync.",
148
+ invoke_without_command=True,
149
+ )
150
+ app.add_typer(dashboard_app)
151
+
152
+
153
+ @dashboard_app.callback(invoke_without_command=True)
154
+ def dashboard_open(
155
+ ctx: typer.Context,
156
+ page: Annotated[
157
+ Optional[str],
158
+ typer.Option("--page", "-p", help="Dashboard page: control, green, learning"),
159
+ ] = None,
160
+ ) -> None:
161
+ """Open the OpenClaw gateway dashboard."""
162
+ if ctx.invoked_subcommand is not None:
163
+ return
164
+ profile = _load_and_validate(strict=False)
165
+ flags = []
166
+ if page:
167
+ flags.append(page)
168
+ rc = run_script(profile, "dashboard.sh", extra_flags=flags)
169
+ raise typer.Exit(rc)
170
+
171
+
172
+ @dashboard_app.command("sync")
173
+ def dashboard_sync(
174
+ dry_run: Annotated[
175
+ bool,
176
+ typer.Option("--dry-run", help="Preview without writing files."),
177
+ ] = False,
178
+ ) -> None:
179
+ """Sync GitHub issues to Obsidian kanban boards."""
180
+ profile = _load_and_validate(strict=False)
181
+ try:
182
+ result = run_dashboard_sync(profile, dry_run=dry_run)
183
+ except FileNotFoundError as exc:
184
+ console.print(f"[red]error:[/red] {exc}")
185
+ raise typer.Exit(1) from None
186
+
187
+ if result.stdout:
188
+ console.print(result.stdout.rstrip())
189
+ if result.returncode != 0:
190
+ if result.stderr:
191
+ console.print(f"[yellow]{result.stderr.rstrip()}[/yellow]")
192
+ console.print(f"[red]Sync failed (exit {result.returncode}).[/red]")
193
+ raise typer.Exit(result.returncode)
194
+ console.print("[green]Dashboard sync complete.[/green]")