personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add sol call CLI framework with Typer-based app discovery

Introduce `sol call <app> <command>` as a human-friendly CLI that
parallels the MCP tool interface. Apps contribute a `call.py` module
exporting a Typer app, auto-discovered using the same pattern as
`_discover_app_tools`.

Phase 1 includes the dispatcher (think/call.py), the todos app as
a reference implementation (list command with --facet and --to
options), tests for both discovery and app commands, and APPS.md
documentation for the new `call.py` convention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+297 -5
+83
apps/todos/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for todo management. 5 + 6 + Provides human-friendly CLI access to todo operations, paralleling the 7 + MCP tools in ``apps/todos/tools.py`` but optimized for terminal use. 8 + 9 + Auto-discovered by ``think.call`` and mounted as ``sol call todos ...``. 10 + """ 11 + 12 + import typer 13 + 14 + from apps.todos import todo 15 + 16 + app = typer.Typer(help="Todo checklist management.") 17 + 18 + 19 + def _print_day_facet(day: str, facet: str) -> bool: 20 + """Print todos for a single day+facet. Returns True if any items exist.""" 21 + checklist = todo.TodoChecklist.load(day, facet) 22 + if not checklist.items: 23 + return False 24 + typer.echo(checklist.display()) 25 + return True 26 + 27 + 28 + @app.command("list") 29 + def list_todos( 30 + day: str = typer.Argument(help="Journal day in YYYYMMDD format."), 31 + facet: str | None = typer.Option( 32 + None, "--facet", "-f", help="Facet name. Omit to show all facets." 33 + ), 34 + to: str | None = typer.Option( 35 + None, "--to", help="End day for range query (YYYYMMDD, inclusive)." 36 + ), 37 + ) -> None: 38 + """Show the todo checklist for a day (or date range).""" 39 + from think.utils import get_journal 40 + 41 + get_journal() 42 + 43 + if to is not None and to < day: 44 + typer.echo(f"Error: --to ({to}) must not be before day ({day})", err=True) 45 + raise typer.Exit(1) 46 + 47 + # Range query 48 + if to is not None and to != day: 49 + # Use all facets for range — get_facets_with_todos only checks the start day 50 + from think.facets import get_facets 51 + 52 + facets = [facet] if facet else sorted(get_facets()) 53 + 54 + for f in facets: 55 + days_with_todos = todo.get_todo_days_in_range(f, day, to) 56 + if not days_with_todos: 57 + continue 58 + if len(facets) > 1: 59 + typer.echo(f"## {f}") 60 + for day_str in days_with_todos: 61 + checklist = todo.TodoChecklist.load(day_str, f) 62 + if checklist.items: 63 + typer.echo(f"### {day_str}") 64 + typer.echo(checklist.display()) 65 + typer.echo() 66 + return 67 + 68 + # Single day 69 + facets = [facet] if facet else todo.get_facets_with_todos(day) 70 + 71 + if not facets: 72 + typer.echo(f"No todos found for {day}.") 73 + return 74 + 75 + if len(facets) == 1: 76 + if not _print_day_facet(day, facets[0]): 77 + typer.echo(f"No todos found for {day}.") 78 + return 79 + 80 + for f in facets: 81 + typer.echo(f"## {f}") 82 + _print_day_facet(day, f) 83 + typer.echo()
+56
apps/todos/tests/test_call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for todos CLI commands (sol call todos ...).""" 5 + 6 + from typer.testing import CliRunner 7 + 8 + from think.call import call_app 9 + 10 + runner = CliRunner() 11 + 12 + 13 + class TestTodosList: 14 + """Tests for 'sol call todos list' command.""" 15 + 16 + def test_list_with_facet(self, todo_env): 17 + """List todos for a single day with --facet.""" 18 + todo_env( 19 + [{"text": "Buy milk"}, {"text": "Walk dog", "completed": True}], 20 + day="20240101", 21 + ) 22 + result = runner.invoke( 23 + call_app, ["todos", "list", "20240101", "--facet", "personal"] 24 + ) 25 + assert result.exit_code == 0 26 + assert "Buy milk" in result.output 27 + assert "Walk dog" in result.output 28 + 29 + def test_list_all_facets(self, todo_env): 30 + """List todos across all facets when --facet is omitted.""" 31 + todo_env([{"text": "Work task"}], day="20240101", facet="work") 32 + # Add a second facet's todos in the same journal 33 + todo_env([{"text": "Home task"}], day="20240101", facet="home") 34 + result = runner.invoke(call_app, ["todos", "list", "20240101"]) 35 + assert result.exit_code == 0 36 + assert "Work task" in result.output 37 + assert "Home task" in result.output 38 + 39 + def test_list_empty_day(self, todo_env): 40 + """Empty day shows no-todos message.""" 41 + todo_env([], day="20240101") 42 + result = runner.invoke( 43 + call_app, ["todos", "list", "20240101", "--facet", "personal"] 44 + ) 45 + assert result.exit_code == 0 46 + assert "No todos" in result.output 47 + 48 + def test_list_invalid_range(self, todo_env): 49 + """--to before day produces an error.""" 50 + todo_env([], day="20240101") 51 + result = runner.invoke( 52 + call_app, 53 + ["todos", "list", "20240201", "--facet", "personal", "--to", "20240101"], 54 + ) 55 + assert result.exit_code == 1 56 + assert "Error" in result.output
+41 -5
docs/APPS.md
··· 38 38 ├── workspace.html # Required: Main content template 39 39 ├── routes.py # Optional: Flask blueprint (only if custom routes needed) 40 40 ├── tools.py # Optional: MCP tool extensions (auto-discovered) 41 + ├── call.py # Optional: CLI commands via Typer (auto-discovered) 41 42 ├── events.py # Optional: Server-side event handlers (auto-discovered) 42 43 ├── app.json # Optional: Metadata (icon, label, facet support) 43 44 ├── app_bar.html # Optional: Bottom bar controls (forms, buttons) ··· 54 55 | `workspace.html` | **Yes** | Main app content (rendered in container) | 55 56 | `routes.py` | No | Flask blueprint for custom routes (API endpoints, forms, etc.) | 56 57 | `tools.py` | No | MCP tool extensions for AI agents (auto-discovered) | 58 + | `call.py` | No | CLI commands via Typer, accessed as `sol call <app>` (auto-discovered) | 57 59 | `events.py` | No | Server-side Callosum event handlers (auto-discovered) | 58 60 | `app.json` | No | Icon, label, facet support overrides | 59 61 | `app_bar.html` | No | Bottom fixed bar for app controls | ··· 261 263 262 264 --- 263 265 264 - ### 7. `muse/` - App Generators 266 + ### 7. `call.py` - CLI Commands 267 + 268 + Define CLI commands for your app that are automatically discovered and available via `sol call <app> <command>`. 269 + 270 + **Key Points:** 271 + - Only create `call.py` if your app needs human-friendly CLI access to its operations 272 + - Export an `app = typer.Typer()` instance with commands defined via `@app.command()` 273 + - Automatically discovered and mounted at startup 274 + - Errors in one app's CLI don't prevent other apps from loading 275 + - CLI commands call the same data layer as `tools.py` but print formatted console output 276 + 277 + **Required export:** 278 + ```python 279 + import typer 280 + 281 + app = typer.Typer(help="Description of your app commands.") 282 + ``` 283 + 284 + **Command pattern:** Define commands using Typer's `@app.command()` decorator with `typer.Argument` for positional args and `typer.Option` for flags. Call the underlying data layer directly (not MCP tool functions) and print output via `typer.echo()`. 285 + 286 + **CLI vs MCP tools:** CLI commands parallel MCP tools but are optimized for interactive terminal use. Key differences: 287 + - No MCP `Context` parameter — CLI has no MCP context 288 + - No guard parameters (e.g., `line_number`, `observation_number`) — auto-compute them internally since interactive users don't need optimistic locking 289 + - Print formatted text instead of returning dicts 290 + - Use `typer.Exit(1)` for errors instead of returning error dicts 291 + 292 + **Discovery behavior:** The `sol call` dispatcher scans `apps/*/call.py` at startup, imports modules, and mounts any `app` variable that is a `typer.Typer` instance as a sub-command. Private apps (directories starting with `_`) are skipped. 293 + 294 + **Reference implementations:** 295 + - Discovery logic: `think/call.py` - `_discover_app_calls()` function 296 + - App CLI example: `apps/todos/call.py` - Todo list command 297 + 298 + --- 299 + 300 + ### 8. `muse/` - App Generators 265 301 266 302 Define custom generator prompts that integrate with solstone's output generation system. 267 303 ··· 338 374 339 375 --- 340 376 341 - ### 8. `muse/` - App Agents and Generators 377 + ### 9. `muse/` - App Agents and Generators 342 378 343 379 Define custom agents and generator templates that integrate with solstone's Cortex agent system. 344 380 ··· 384 420 385 421 --- 386 422 387 - ### 9. `maint/` - Maintenance Tasks 423 + ### 10. `maint/` - Maintenance Tasks 388 424 389 425 Define one-time maintenance scripts that run automatically on Convey startup. 390 426 ··· 403 439 404 440 --- 405 441 406 - ### 10. `tests/` - App Tests 442 + ### 11. `tests/` - App Tests 407 443 408 444 Apps can include their own tests that are discovered and run separately from core tests. 409 445 ··· 427 463 428 464 --- 429 465 430 - ### 11. `events.py` - Server-Side Event Handlers 466 + ### 12. `events.py` - Server-Side Event Handlers 431 467 432 468 Define server-side handlers that react to Callosum events. Handlers run in Convey's thread pool, enabling reactive backend logic without creating new services. 433 469
+1
pyproject.toml
··· 66 66 "python-slugify", 67 67 "rapidfuzz", 68 68 "platformdirs", 69 + "typer", 69 70 70 71 # Audio processing 71 72 "soundcard",
+2
sol.py
··· 64 64 "cortex": "think.cortex", 65 65 "mcp": "think.mcp", 66 66 "muse": "think.muse_cli", 67 + "call": "think.call", 67 68 # convey package - web UI 68 69 "convey": "convey.cli", 69 70 "restart-convey": "convey.restart", ··· 110 111 "cortex", 111 112 "mcp", 112 113 "muse", 114 + "call", 113 115 ], 114 116 "Convey (web UI)": [ 115 117 "convey",
+30
tests/test_call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for think/call.py CLI dispatcher and app discovery.""" 5 + 6 + from typer.testing import CliRunner 7 + 8 + from think.call import call_app 9 + 10 + runner = CliRunner() 11 + 12 + 13 + class TestDiscovery: 14 + """Tests for app CLI discovery.""" 15 + 16 + def test_no_args_shows_help(self): 17 + """Running 'sol call' with no args shows help.""" 18 + result = runner.invoke(call_app, []) 19 + assert "Call app functions" in result.output 20 + 21 + def test_todos_app_discovered(self): 22 + """The todos app should be auto-discovered.""" 23 + result = runner.invoke(call_app, ["todos", "--help"]) 24 + assert result.exit_code == 0 25 + assert "list" in result.output 26 + 27 + def test_unknown_app_fails(self): 28 + """Unknown app name should produce an error.""" 29 + result = runner.invoke(call_app, ["nonexistent"]) 30 + assert result.exit_code != 0
+78
think/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI interface for app tools via Typer. 5 + 6 + Provides ``sol call <app> <command> [args]`` as a human-friendly CLI that 7 + parallels the MCP tool interface. Each app can contribute a ``call.py`` 8 + module exporting a ``app = typer.Typer()`` instance whose commands are 9 + auto-discovered and mounted as sub-commands. 10 + 11 + Discovery follows the same pattern as ``think.mcp._discover_app_tools``: 12 + scan ``apps/*/call.py``, import, mount. 13 + """ 14 + 15 + import importlib 16 + import logging 17 + from pathlib import Path 18 + 19 + import typer 20 + 21 + logger = logging.getLogger(__name__) 22 + 23 + call_app = typer.Typer( 24 + name="call", 25 + help="Call app functions from the command line.", 26 + no_args_is_help=True, 27 + ) 28 + 29 + 30 + def _discover_app_calls() -> None: 31 + """Discover and mount Typer sub-apps from apps/*/call.py. 32 + 33 + Each ``call.py`` must export an ``app`` variable that is a 34 + ``typer.Typer`` instance. The app directory name becomes the 35 + sub-command name (e.g. ``sol call todos list ...``). 36 + 37 + Errors in one app do not prevent others from loading. 38 + """ 39 + apps_dir = Path(__file__).parent.parent / "apps" 40 + 41 + if not apps_dir.exists(): 42 + logger.debug("No apps/ directory found, skipping app call discovery") 43 + return 44 + 45 + for app_dir in sorted(apps_dir.iterdir()): 46 + if not app_dir.is_dir() or app_dir.name.startswith("_"): 47 + continue 48 + 49 + call_file = app_dir / "call.py" 50 + if not call_file.exists(): 51 + continue 52 + 53 + app_name = app_dir.name 54 + 55 + try: 56 + module = importlib.import_module(f"apps.{app_name}.call") 57 + 58 + sub_app = getattr(module, "app", None) 59 + if not isinstance(sub_app, typer.Typer): 60 + logger.warning( 61 + f"apps/{app_name}/call.py has no 'app' Typer instance, skipping" 62 + ) 63 + continue 64 + 65 + call_app.add_typer(sub_app, name=app_name) 66 + logger.info(f"Loaded CLI commands from app: {app_name}") 67 + except Exception as e: 68 + logger.error( 69 + f"Failed to load CLI from app '{app_name}': {e}", exc_info=True 70 + ) 71 + 72 + 73 + _discover_app_calls() 74 + 75 + 76 + def main() -> None: 77 + """Entry point for ``sol call``.""" 78 + call_app()
+6
uv.lock
··· 4237 4237 { name = "soundcard" }, 4238 4238 { name = "soundfile" }, 4239 4239 { name = "timefhuman" }, 4240 + { name = "typer" }, 4240 4241 { name = "tzlocal" }, 4241 4242 ] 4242 4243 ··· 4286 4287 { name = "soundcard" }, 4287 4288 { name = "soundfile" }, 4288 4289 { name = "timefhuman" }, 4290 + { name = "typer" }, 4289 4291 { name = "tzlocal" }, 4290 4292 ] 4291 4293 ··· 4607 4609 { name = "typing-extensions" }, 4608 4610 ] 4609 4611 wheels = [ 4612 + { url = "https://files.pythonhosted.org/packages/e3/ea/304cf7afb744aa626fa9855245526484ee55aba610d9973a0521c552a843/torch-2.10.0-1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:c37fc46eedd9175f9c81814cc47308f1b42cfe4987e532d4b423d23852f2bf63", size = 79411450, upload-time = "2026-02-06T17:37:35.75Z" }, 4613 + { url = "https://files.pythonhosted.org/packages/25/d8/9e6b8e7df981a1e3ea3907fd5a74673e791da483e8c307f0b6ff012626d0/torch-2.10.0-1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:f699f31a236a677b3118bc0a3ef3d89c0c29b5ec0b20f4c4bf0b110378487464", size = 79423460, upload-time = "2026-02-06T17:37:39.657Z" }, 4614 + { url = "https://files.pythonhosted.org/packages/c9/2f/0b295dd8d199ef71e6f176f576473d645d41357b7b8aa978cc6b042575df/torch-2.10.0-1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6abb224c2b6e9e27b592a1c0015c33a504b00a0e0938f1499f7f514e9b7bfb5c", size = 79498197, upload-time = "2026-02-06T17:37:27.627Z" }, 4615 + { url = "https://files.pythonhosted.org/packages/a4/1b/af5fccb50c341bd69dc016769503cb0857c1423fbe9343410dfeb65240f2/torch-2.10.0-1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:7350f6652dfd761f11f9ecb590bfe95b573e2961f7a242eccb3c8e78348d26fe", size = 79498248, upload-time = "2026-02-06T17:37:31.982Z" }, 4610 4616 { url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" }, 4611 4617 { url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" }, 4612 4618 { url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" },