diff --git a/solvent/__main__.py b/solvent/__main__.py index 14d1bf3..63d65c4 100644 --- a/solvent/__main__.py +++ b/solvent/__main__.py @@ -10,6 +10,7 @@ Commands: (none) run the batch demo / interactive session + init first-run setup: create dirs, DB, and workspace files status live summary: balance, jobs, API key presence; --watch to auto-refresh upgrade check for newer version on PyPI; --check exits 1 if outdated serve webhooks + job API + hosted briefs @@ -42,7 +43,11 @@ def main(): if len(sys.argv) > 1 and sys.argv[1] in ("help", "--help", "-h"): print(HELP) return - if len(sys.argv) > 1 and sys.argv[1] == "status": + if len(sys.argv) > 1 and sys.argv[1] == "init": + sys.argv.pop(1) + from .init import main as init_main + init_main() + elif len(sys.argv) > 1 and sys.argv[1] == "status": sys.argv.pop(1) from .status import main as status_main status_main() diff --git a/solvent/init.py b/solvent/init.py new file mode 100644 index 0000000..0f4d557 --- /dev/null +++ b/solvent/init.py @@ -0,0 +1,143 @@ +""" +init.py — first-run scaffolding for SOLVENT. + +Creates the application home directory, initialises the treasury database, +seeds workspace files, and prints a clear onboarding summary so users know +exactly where everything lives and what to do next. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from .paths import base_dir, data_dir, db_path, reports_dir +from .workspace import seed_workspace, BOOTSTRAP_FILES + + +_ENV_EXAMPLE = """\ +# SOLVENT environment variables (copy to .env and fill in) + +# Override the application home directory (default: ~/.solvent when pip-installed) +# SOLVENT_HOME=~/.solvent + +# NVIDIA Nemotron — live inference (optional; offline stub works without this) +# NVIDIA_API_KEY=nvapi-... + +# Stripe — live test-mode payments (optional; simulate mode works without this) +# STRIPE_API_KEY=sk_test_... + +# Telegram — bot notifications (optional) +# TELEGRAM_BOT_TOKEN=... +# TELEGRAM_CHAT_ID=... +""" + + +def run(*, force: bool = False) -> int: + """ + Scaffold the SOLVENT runtime environment. + + Returns 0 on success, 1 on error. + """ + created: list[str] = [] + already: list[str] = [] + errors: list[str] = [] + + def _track(path: Path, *, was_new: bool) -> None: + (created if was_new else already).append(str(path)) + + # --- application home ------------------------------------------------- + home = base_dir() + _track(home, was_new=not home.exists()) + + # --- data directory + treasury DB ------------------------------------ + ddir = data_dir() + _track(ddir, was_new=not ddir.exists()) + + rdir = reports_dir() + _track(rdir, was_new=not rdir.exists()) + + db = db_path() + db_new = not db.exists() + try: + from .treasury import Treasury + t = Treasury(path=str(db)) + # Touch the DB by running the lightest possible query + with t._conn() as conn: + conn.execute("SELECT 1") + _track(db, was_new=db_new) + except Exception as exc: + errors.append(f"treasury DB: {exc}") + + # --- .env.example ---------------------------------------------------- + env_example = home / ".env.example" + if not env_example.exists(): + env_example.write_text(_ENV_EXAMPLE, encoding="utf-8") + created.append(str(env_example)) + else: + already.append(str(env_example)) + + # --- workspace files -------------------------------------------------- + try: + workspace_root = seed_workspace(force=force) + for name in BOOTSTRAP_FILES: + p = workspace_root / name + _track(p, was_new=not p.exists() or force) + except Exception as exc: + errors.append(f"workspace: {exc}") + + # --- report ----------------------------------------------------------- + print("SOLVENT init") + print("=" * 60) + + print(f"\n Home: {home}") + print(f" Data: {ddir}") + print(f" DB: {db}") + print(f" Reports: {rdir}") + + if created: + print(f"\n Created ({len(created)}):") + for p in created: + print(f" + {p}") + + if already: + print(f"\n Already present ({len(already)}) — skipped.") + + if errors: + print(f"\n Errors ({len(errors)}):") + for e in errors: + print(f" ! {e}") + return 1 + + print(""" +Next steps: + solvent doctor verify the full stack + solvent run the demo (no keys needed) + solvent finance financial report + +Optional: edit .env.example → .env, fill in API keys, then: + source .env + solvent doctor re-run to confirm live features + +Documentation: https://github.com/ianalloway/solvent-agent +""") + return 0 + + +def main(): + import argparse + p = argparse.ArgumentParser( + description="Initialise SOLVENT — create data dir, treasury DB, and workspace files.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument( + "--force", + action="store_true", + help="overwrite existing workspace template files", + ) + args = p.parse_args() + sys.exit(run(force=args.force)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..da4fbf0 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,130 @@ +"""tests/test_init.py — unit tests for the solvent init command.""" + +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + + +class TestInit(unittest.TestCase): + + def _run_init(self, home_dir: str, force: bool = False) -> tuple[int, str]: + """Run init.run() inside a temporary SOLVENT_HOME, return (exit_code, output).""" + buf = io.StringIO() + workspace_dir = str(Path(home_dir) / "workspace") + env = {"SOLVENT_HOME": home_dir, "SOLVENT_WORKSPACE": workspace_dir} + with mock.patch.dict(os.environ, env): + import importlib + import solvent.paths as _paths + import solvent.workspace as _ws + import solvent.init as _init + importlib.reload(_paths) + importlib.reload(_ws) + importlib.reload(_init) + with redirect_stdout(buf): + rc = _init.run(force=force) + return rc, buf.getvalue() + + def test_init_succeeds(self): + with tempfile.TemporaryDirectory() as d: + rc, out = self._run_init(d) + self.assertEqual(rc, 0) + + def test_init_creates_data_dir(self): + with tempfile.TemporaryDirectory() as d: + self._run_init(d) + self.assertTrue(Path(d, "data").is_dir()) + + def test_init_creates_db(self): + with tempfile.TemporaryDirectory() as d: + self._run_init(d) + self.assertTrue(Path(d, "data", "solvent.db").is_file()) + + def test_init_creates_reports_dir(self): + with tempfile.TemporaryDirectory() as d: + self._run_init(d) + self.assertTrue(Path(d, "data", "reports").is_dir()) + + def test_init_creates_env_example(self): + with tempfile.TemporaryDirectory() as d: + self._run_init(d) + env_ex = Path(d, ".env.example") + self.assertTrue(env_ex.is_file()) + content = env_ex.read_text() + self.assertIn("NVIDIA_API_KEY", content) + self.assertIn("STRIPE_API_KEY", content) + + def test_init_seeds_workspace(self): + with tempfile.TemporaryDirectory() as d: + # workspace lives under CONFIG_DIR which is inside base_dir + self._run_init(d) + # soul.md or agents.md should exist somewhere under d + md_files = list(Path(d).rglob("SOUL.md")) + self.assertTrue(len(md_files) > 0, "SOUL.md not found after init") + + def test_init_idempotent(self): + with tempfile.TemporaryDirectory() as d: + rc1, _ = self._run_init(d) + rc2, _ = self._run_init(d) + self.assertEqual(rc1, 0) + self.assertEqual(rc2, 0) + + def test_init_prints_home_path(self): + with tempfile.TemporaryDirectory() as d: + _, out = self._run_init(d) + self.assertIn(d, out) + + def test_init_prints_next_steps(self): + with tempfile.TemporaryDirectory() as d: + _, out = self._run_init(d) + self.assertIn("Next steps", out) + self.assertIn("solvent doctor", out) + + def test_init_force_overwrites_workspace(self): + with tempfile.TemporaryDirectory() as d: + self._run_init(d) + workspace_dir = str(Path(d) / "workspace") + env = {"SOLVENT_HOME": d, "SOLVENT_WORKSPACE": workspace_dir} + import importlib + import solvent.paths as _paths + import solvent.workspace as _ws + with mock.patch.dict(os.environ, env): + importlib.reload(_paths) + importlib.reload(_ws) + soul = _ws.workspace_path() / "SOUL.md" + if soul.is_file(): + soul.write_text("CORRUPTED", encoding="utf-8") + rc, _ = self._run_init(d, force=True) + self.assertEqual(rc, 0) + content = soul.read_text() + self.assertNotEqual(content.strip(), "CORRUPTED") + + +class TestInitCLI(unittest.TestCase): + + def test_main_exits_zero(self): + with tempfile.TemporaryDirectory() as d: + workspace_dir = str(Path(d) / "workspace") + env = {"SOLVENT_HOME": d, "SOLVENT_WORKSPACE": workspace_dir} + with mock.patch.dict(os.environ, env): + import importlib + import solvent.paths as _paths + import solvent.workspace as _ws + import solvent.init as _init + importlib.reload(_paths) + importlib.reload(_ws) + importlib.reload(_init) + # __main__.py pops "init" before calling main(), so argv has no subcommand + with mock.patch.object(sys, "argv", ["solvent"]): + with self.assertRaises(SystemExit) as ctx: + with mock.patch("sys.stdout", new_callable=io.StringIO): + _init.main() + self.assertEqual(ctx.exception.code, 0) + + +if __name__ == "__main__": + unittest.main()