personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""CLI interface for app tools via Typer.
5
6Provides ``sol call <app> <command> [args]`` as a human-friendly CLI that
7parallels app tool functions. Each app can contribute a ``call.py``
8module exporting a ``app = typer.Typer()`` instance whose commands are
9auto-discovered and mounted as sub-commands.
10
11Discovery scans ``apps/*/call.py``, imports modules, and mounts subcommands.
12"""
13
14import importlib
15import logging
16from pathlib import Path
17
18import typer
19
20logger = logging.getLogger(__name__)
21
22call_app = typer.Typer(
23 name="call",
24 help="Call app functions from the command line.",
25 no_args_is_help=True,
26)
27
28
29def _discover_app_calls() -> None:
30 """Discover and mount Typer sub-apps from apps/*/call.py.
31
32 Each ``call.py`` must export an ``app`` variable that is a
33 ``typer.Typer`` instance. The app directory name becomes the
34 sub-command name (e.g. ``sol call todos list ...``).
35
36 Errors in one app do not prevent others from loading.
37 """
38 apps_dir = Path(__file__).parent.parent / "apps"
39
40 if not apps_dir.exists():
41 logger.debug("No apps/ directory found, skipping app call discovery")
42 return
43
44 for app_dir in sorted(apps_dir.iterdir()):
45 if not app_dir.is_dir() or app_dir.name.startswith("_"):
46 continue
47
48 call_file = app_dir / "call.py"
49 if not call_file.exists():
50 continue
51
52 app_name = app_dir.name
53
54 try:
55 module = importlib.import_module(f"apps.{app_name}.call")
56
57 sub_app = getattr(module, "app", None)
58 if not isinstance(sub_app, typer.Typer):
59 logger.warning(
60 f"apps/{app_name}/call.py has no 'app' Typer instance, skipping"
61 )
62 continue
63
64 call_app.add_typer(sub_app, name=app_name)
65 logger.info(f"Loaded CLI commands from app: {app_name}")
66 except Exception as e:
67 logger.error(
68 f"Failed to load CLI from app '{app_name}': {e}", exc_info=True
69 )
70
71
72_discover_app_calls()
73
74# Mount built-in CLIs (not auto-discovered since they live under think/)
75from think.tools.call import app as journal_app
76from think.tools.navigate import app as navigate_app
77from think.tools.routines import app as routines_app
78from think.tools.sol import app as sol_app
79
80call_app.add_typer(journal_app, name="journal")
81call_app.add_typer(navigate_app, name="navigate")
82call_app.add_typer(routines_app, name="routines")
83call_app.add_typer(sol_app, name="identity")
84
85
86def main() -> None:
87 """Entry point for ``sol call``."""
88 call_app()