Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion solvent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
143 changes: 143 additions & 0 deletions solvent/init.py
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed the workspace under the initialized home

When SOLVENT_HOME points outside the current working directory, this call still uses workspace_path()'s default .solvent/workspace under the process cwd. As a result, solvent init reports the selected home as initialized while placing SOUL/BRAIN/AGENTS elsewhere; a later solvent or solvent doctor run from a different cwd will not use the workspace that init just created. Set/pass the workspace path relative to home or make the chosen workspace path explicit before seeding.

Useful? React with 👍 / 👎.

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()
130 changes: 130 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
@@ -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()
Loading