From 185a6313da940bb48ab61c8d23f6180843c6d576 Mon Sep 17 00:00:00 2001 From: Leonid Gorkin Date: Fri, 25 Sep 2026 13:46:51 -0400 Subject: [PATCH] feat: add openbot command launcher --- openbot | 82 +++++++++++++++++++++++++++++++++++++++++ scripts/test_openbot.py | 78 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100755 openbot create mode 100755 scripts/test_openbot.py diff --git a/openbot b/openbot new file mode 100755 index 0000000..2601df5 --- /dev/null +++ b/openbot @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Start OpenBot using the repository's production-style launcher. +set -Eeuo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: openbot [--root DIRECTORY] [--db-root DIRECTORY] + +Start OpenBot with DIRECTORY as the workspace root and DIRECTORY/.openbot/openbot.db +as the SQLite database. DIRECTORY defaults to the current working directory; the database +root defaults to ~/.openbot. +EOF +} + +error() { + printf 'openbot: %s\n' "$1" >&2 + usage + exit 2 +} + +expand_path() { + local value="$1" + if [[ "$value" == "~" ]]; then + value="$HOME" + elif [[ "$value" == "~/"* ]]; then + value="$HOME/${value:2}" + fi + printf '%s' "$value" +} + +canonical_directory() { + local value="$1" + if [[ ! -d "$value" ]]; then + error "directory does not exist or is not a directory: $value" + fi + if ! (cd -- "$value" && pwd -P); then + error "cannot access directory: $value" + fi +} + +workspace_input="$(pwd -P)" +db_input="${HOME:?HOME is not set}/.openbot" +while (($#)); do + case "$1" in + --root|--db-root) + option="$1" + shift + (($#)) || error "$option requires a directory" + [[ "$1" != -* ]] || error "$option requires a directory, got: $1" + if [[ "$option" == "--root" ]]; then + workspace_input="$1" + else + db_input="$1" + fi + shift + ;; + -h|--help) + usage >&2 + exit 0 + ;; + *) + error "unknown argument: $1" + ;; + esac +done + +workspace_root="$(canonical_directory "$(expand_path "$workspace_input")")" +db_root="$(expand_path "$db_input")" +if [[ ! -e "$db_root" ]]; then + if ! mkdir -p -- "$db_root"; then + error "cannot create database directory: $db_root" + fi +fi +db_root="$(canonical_directory "$db_root")" + +# `make run` remains the canonical production-style startup path; these environment variables +# provide the caller-specific paths without reimplementing backend initialization here. +export WORKSPACE_ROOT="$workspace_root" +export DATABASE_URL="sqlite+aiosqlite:///$db_root/openbot.db" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +exec make -C "$script_dir" run diff --git a/scripts/test_openbot.py b/scripts/test_openbot.py new file mode 100755 index 0000000..2bdf891 --- /dev/null +++ b/scripts/test_openbot.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).parents[1] / "openbot" + + +class OpenBotLauncherTests(unittest.TestCase): + def run_launcher(self, *args, cwd=None, home=None): + with tempfile.TemporaryDirectory() as tmp: + fake_make = Path(tmp) / "make" + fake_make.write_text( + "#!/bin/sh\n" + "printf 'cwd=%s\\n' \"$PWD\"\n" + "printf 'workspace=%s\\n' \"$WORKSPACE_ROOT\"\n" + "printf 'database=%s\\n' \"$DATABASE_URL\"\n" + "printf 'args='; printf '%s|' \"$@\"; printf '\\n'\n" + ) + fake_make.chmod(0o755) + env = os.environ.copy() + env["PATH"] = f"{tmp}{os.pathsep}{env['PATH']}" + if home is not None: + env["HOME"] = str(home) + return subprocess.run( + [str(SCRIPT), *args], cwd=cwd, env=env, text=True, capture_output=True, check=False + ) + + def test_defaults_use_current_directory_and_home_database(self): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as home: + result = self.run_launcher(cwd=tmp, home=home) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"workspace={Path(tmp).resolve()}", result.stdout) + self.assertIn(f"database=sqlite+aiosqlite:///{Path(home).resolve() / '.openbot' / 'openbot.db'}", result.stdout) + self.assertIn("args=-C|", result.stdout) + + def test_root_override_preserves_spaces(self): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as home: + root = Path(tmp) / "workspace with spaces" + root.mkdir() + result = self.run_launcher("--root", str(root), cwd=tmp, home=home) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"workspace={root.resolve()}", result.stdout) + + def test_db_root_override_expands_tilde(self): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as home: + result = self.run_launcher("--db-root", "~/data with spaces", cwd=tmp, home=home) + self.assertEqual(result.returncode, 0, result.stderr) + expected = Path(os.path.realpath(home)) / "data with spaces" / "openbot.db" + self.assertIn(f"database=sqlite+aiosqlite:///{expected}", result.stdout) + + def test_both_overrides_are_forwarded(self): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as home: + root = Path(tmp) / "root" + db = Path(tmp) / "db root" + root.mkdir() + result = self.run_launcher("--root", str(root), "--db-root", str(db), cwd=tmp, home=home) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"workspace={root.resolve()}", result.stdout) + self.assertIn(f"database=sqlite+aiosqlite:///{db.resolve() / 'openbot.db'}", result.stdout) + + def test_invalid_usage_is_clear(self): + result = self.run_launcher("--root") + self.assertEqual(result.returncode, 2) + self.assertIn("--root requires a directory", result.stderr) + + def test_missing_directory_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + result = self.run_launcher("--root", str(Path(tmp) / "missing"), cwd=tmp) + self.assertEqual(result.returncode, 2) + self.assertIn("does not exist", result.stderr) + + +if __name__ == "__main__": + unittest.main()