studyctl 2.3.0__tar.gz → 2.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {studyctl-2.3.0 → studyctl-2.4.0}/.gitignore +0 -5
- {studyctl-2.3.0 → studyctl-2.4.0}/PKG-INFO +1 -1
- {studyctl-2.3.0 → studyctl-2.4.0}/pyproject.toml +2 -1
- studyctl-2.4.0/src/studyctl/adapters/__init__.py +29 -0
- studyctl-2.4.0/src/studyctl/adapters/_custom.py +159 -0
- studyctl-2.4.0/src/studyctl/adapters/_local_llm.py +46 -0
- studyctl-2.4.0/src/studyctl/adapters/_protocol.py +53 -0
- studyctl-2.4.0/src/studyctl/adapters/_strategies.py +165 -0
- studyctl-2.4.0/src/studyctl/adapters/claude.py +36 -0
- studyctl-2.4.0/src/studyctl/adapters/gemini.py +46 -0
- studyctl-2.4.0/src/studyctl/adapters/kiro.py +113 -0
- studyctl-2.4.0/src/studyctl/adapters/lmstudio.py +36 -0
- studyctl-2.4.0/src/studyctl/adapters/ollama.py +36 -0
- studyctl-2.4.0/src/studyctl/adapters/opencode.py +67 -0
- studyctl-2.4.0/src/studyctl/adapters/registry.py +174 -0
- studyctl-2.4.0/src/studyctl/agent_launcher.py +404 -0
- studyctl-2.4.0/src/studyctl/cli/_study.py +249 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/history/medication.py +2 -2
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/parking.py +2 -2
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/session/cleanup.py +156 -49
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/session/orchestrator.py +1 -1
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/session/resume.py +18 -4
- studyctl-2.3.0/src/studyctl/cli/_study.py → studyctl-2.4.0/src/studyctl/session/start.py +43 -292
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/settings.py +52 -55
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/shared.py +4 -1
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/routes/artefacts.py +3 -1
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/routes/session.py +2 -2
- studyctl-2.4.0/src/studyctl/web/static/components.js +624 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/index.html +1 -2
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/harness/study.py +7 -3
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/harness/terminal.py +6 -2
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/harness/tmux.py +10 -3
- studyctl-2.4.0/tests/test_adapter_builtins.py +584 -0
- studyctl-2.4.0/tests/test_adapter_custom.py +138 -0
- studyctl-2.4.0/tests/test_adapter_protocol.py +79 -0
- studyctl-2.4.0/tests/test_adapter_registry.py +397 -0
- studyctl-2.4.0/tests/test_adapter_strategies.py +99 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_break_logic.py +8 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_briefing_logic.py +2 -2
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_e2e_session_demo.py +1 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_lan_auth.py +1 -1
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_parking.py +6 -1
- studyctl-2.4.0/tests/test_session_cleanup.py +516 -0
- studyctl-2.4.0/tests/test_session_start.py +267 -0
- studyctl-2.4.0/tests/test_settings_custom.py +305 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_terminal_proxy.py +7 -3
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_topic_resolver.py +8 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_web_app.py +1 -1
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_web_artefacts.py +1 -1
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_web_session.py +5 -3
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_web_terminal.py +1 -1
- studyctl-2.3.0/src/studyctl/agent_launcher.py +0 -585
- studyctl-2.3.0/src/studyctl/web/server.py +0 -259
- studyctl-2.3.0/src/studyctl/web/static/components.js +0 -745
- studyctl-2.3.0/src/studyctl/web/static/vendor/js/split.min.js +0 -3
- studyctl-2.3.0/tests/test_content_cli.py +0 -489
- studyctl-2.3.0/tests/test_fixes_pomodoro_auth_terminal.py +0 -556
- studyctl-2.3.0/tests/test_lazy_imports.py +0 -40
- studyctl-2.3.0/tests/test_migrate_from_empty_db.py +0 -107
- studyctl-2.3.0/tests/test_services_content.py +0 -144
- studyctl-2.3.0/tests/test_services_review.py +0 -265
- studyctl-2.3.0/tests/test_split_pane_layout.py +0 -987
- studyctl-2.3.0/tests/test_web_cli.py +0 -143
- studyctl-2.3.0/tests/test_web_sidebar.py +0 -493
- {studyctl-2.3.0 → studyctl-2.4.0}/README.md +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/__main__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_backup.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_clean.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_config.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_content.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_doctor.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_lazy.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_review.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_session.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_setup.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_shared.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_sync.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_topics.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_upgrade.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/cli/_web.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/content/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/content/markdown_converter.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/content/models.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/content/notebooklm_client.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/content/splitter.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/content/storage.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/content/syllabus.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/data/tmux-studyctl.conf +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/db.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/doctor/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/doctor/agents.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/doctor/config.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/doctor/core.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/doctor/database.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/doctor/deps.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/doctor/models.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/doctor/updates.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/history/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/history/_connection.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/history/bridges.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/history/concepts.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/history/progress.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/history/search.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/history/sessions.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/history/streaks.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/history/teachback.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/logic/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/logic/backlog_logic.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/logic/break_logic.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/logic/briefing_logic.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/logic/clean_logic.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/logic/streaks_logic.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/logic/topic_resolver.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/maintenance.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/mcp/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/mcp/server.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/mcp/tools.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/output.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/pdf.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/review_db.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/review_loader.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/services/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/services/backlog.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/services/content.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/services/flashcard_writer.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/services/review.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/session/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/session_state.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/state.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/sync.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/tmux.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/topics.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/tui/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/tui/__main__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/tui/sidebar.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/app.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/auth.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/routes/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/routes/cards.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/routes/courses.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/routes/history.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/routes/terminal_proxy.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/icon-192.svg +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/icon-512.svg +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/manifest.json +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/style.css +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/sw.js +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/files/inter-latin-ext.woff2 +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/files/inter-latin.woff2 +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff2 +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/inter.css +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/css/opendyslexic-400.css +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/js/alpine-3.14.8.min.js +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/js/htmx-2.0.4.min.js +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/src/studyctl/web/static/vendor/js/htmx-ext-sse-2.2.2.js +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/_helpers.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/harness/__init__.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/harness/agents.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_agent_launcher.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_backlog_logic.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_backup.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_clean.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_cli.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_cli_doctor.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_cli_session.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_cli_upgrade.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_content_notebooklm.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_content_splitter.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_content_storage.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_content_syllabus.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_doctor_agents.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_doctor_config.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_doctor_core.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_doctor_database.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_doctor_deps.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_doctor_integration.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_doctor_models.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_doctor_updates.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_flashcard_writer.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_harness_matrix.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_history.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_install_mentor_prompt.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_mcp_tools.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_orchestrator.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_review_db.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_review_loader.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_session_db_integration.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_session_state.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_setup_wizard.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_shared.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_sidebar_pilot.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_streaks_logic.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_study.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_study_integration.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_study_lifecycle.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_tmux.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_topics_cli.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_uat_terminal.py +0 -0
- {studyctl-2.3.0 → studyctl-2.4.0}/tests/test_web_vendor.py +0 -0
|
@@ -496,11 +496,6 @@ uv-*.lock
|
|
|
496
496
|
|
|
497
497
|
# Iterate runner artefacts (autoresearch pattern — kept untracked)
|
|
498
498
|
results.tsv
|
|
499
|
-
eval-results.tsv
|
|
500
499
|
.pytest-junit.xml
|
|
501
500
|
docs/reports/
|
|
502
501
|
docs/brainstorms/
|
|
503
|
-
packages/studyctl/tests/results/
|
|
504
|
-
|
|
505
|
-
# Dev-only tooling (autoresearch eval harness — not shipped to users)
|
|
506
|
-
dev/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: studyctl
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: AuDHD-aware study tool with AI Socratic mentoring, spaced repetition, and content pipeline
|
|
5
5
|
Project-URL: Homepage, https://github.com/NetDevAutomate/socratic-study-mentor
|
|
6
6
|
Project-URL: Repository, https://github.com/NetDevAutomate/socratic-study-mentor
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "studyctl"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.4.0"
|
|
4
4
|
description = "AuDHD-aware study tool with AI Socratic mentoring, spaced repetition, and content pipeline"
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
readme = "README.md"
|
|
@@ -58,6 +58,7 @@ markers = [
|
|
|
58
58
|
[tool.pyright]
|
|
59
59
|
pythonVersion = "3.12"
|
|
60
60
|
typeCheckingMode = "basic"
|
|
61
|
+
extraPaths = ["../agent-session-tools/src"] # workspace sibling for cross-package imports
|
|
61
62
|
exclude = ["src/studyctl/tui", "src/studyctl/content", "src/studyctl/cli/_content.py", "src/studyctl/cli/_web.py", "src/studyctl/web", "src/studyctl/mcp", "src/studyctl/eval/llm_client.py"] # optional deps not in CI
|
|
62
63
|
|
|
63
64
|
[dependency-groups]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Agent adapter package — modular agent detection and launch.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from studyctl.adapters import detect_agents, get_adapter
|
|
5
|
+
|
|
6
|
+
agents = detect_agents()
|
|
7
|
+
adapter = get_adapter('claude')
|
|
8
|
+
persona = adapter.setup(content, session_dir)
|
|
9
|
+
cmd = adapter.launch_cmd(persona, resume=False)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from studyctl.adapters._protocol import AdapterProtocol, AgentAdapter
|
|
13
|
+
from studyctl.adapters.registry import (
|
|
14
|
+
detect_agents,
|
|
15
|
+
get_adapter,
|
|
16
|
+
get_all_adapters,
|
|
17
|
+
get_default_agent,
|
|
18
|
+
reset_registry,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AdapterProtocol",
|
|
23
|
+
"AgentAdapter",
|
|
24
|
+
"detect_agents",
|
|
25
|
+
"get_adapter",
|
|
26
|
+
"get_all_adapters",
|
|
27
|
+
"get_default_agent",
|
|
28
|
+
"reset_registry",
|
|
29
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Config-driven factory for custom agent adapters.
|
|
2
|
+
|
|
3
|
+
Reads the ``agents.custom`` section from config.yaml and builds
|
|
4
|
+
``AgentAdapter`` instances at runtime. This allows users to add any
|
|
5
|
+
CLI-based AI agent without modifying source code.
|
|
6
|
+
|
|
7
|
+
Example config::
|
|
8
|
+
|
|
9
|
+
agents:
|
|
10
|
+
custom:
|
|
11
|
+
aider:
|
|
12
|
+
binary: aider
|
|
13
|
+
strategy: cli-flag
|
|
14
|
+
launch: "{binary} --read {persona}"
|
|
15
|
+
resume: "{binary} --read {persona} --resume"
|
|
16
|
+
env:
|
|
17
|
+
OPENAI_API_KEY: sk-...
|
|
18
|
+
my-agent:
|
|
19
|
+
binary: my-agent
|
|
20
|
+
strategy: cwd-file
|
|
21
|
+
filename: .context.md
|
|
22
|
+
launch: "{binary}"
|
|
23
|
+
teardown: "pkill -f my-agent"
|
|
24
|
+
mcp:
|
|
25
|
+
format: generic
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import functools
|
|
31
|
+
import logging
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
from studyctl.adapters._protocol import AgentAdapter
|
|
37
|
+
from studyctl.adapters._strategies import cli_flag_setup, cwd_file_setup, write_mcp_config
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
log = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
# Supported strategy names → setup callables
|
|
45
|
+
_STRATEGIES = {
|
|
46
|
+
"cli-flag": cli_flag_setup,
|
|
47
|
+
"cwd-file": cwd_file_setup,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_custom_adapter(name: str, config: dict) -> AgentAdapter:
|
|
52
|
+
"""Build an ``AgentAdapter`` from a config dict.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
name: The adapter name (key from ``agents.custom``).
|
|
56
|
+
config: The raw config dict for this adapter.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A fully-constructed ``AgentAdapter`` instance.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If ``strategy`` is not ``"cli-flag"`` or ``"cwd-file"``.
|
|
63
|
+
"""
|
|
64
|
+
binary: str = config["binary"]
|
|
65
|
+
strategy_name: str = config["strategy"]
|
|
66
|
+
launch_template: str = config["launch"]
|
|
67
|
+
resume_template: str = config.get("resume", launch_template)
|
|
68
|
+
env_vars: dict[str, str] = config.get("env", {}) or {}
|
|
69
|
+
teardown_cmd: str | None = config.get("teardown")
|
|
70
|
+
mcp_config = config.get("mcp")
|
|
71
|
+
|
|
72
|
+
# --- Strategy -----------------------------------------------------------
|
|
73
|
+
if strategy_name not in _STRATEGIES:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Unknown strategy {strategy_name!r} for adapter {name!r}. "
|
|
76
|
+
f"Must be one of: {sorted(_STRATEGIES)}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if strategy_name == "cwd-file":
|
|
80
|
+
filename = config.get("filename", "PERSONA.md")
|
|
81
|
+
setup_fn = functools.partial(cwd_file_setup, filename=filename)
|
|
82
|
+
else:
|
|
83
|
+
setup_fn = _STRATEGIES[strategy_name]
|
|
84
|
+
|
|
85
|
+
# --- Launch command builder ---------------------------------------------
|
|
86
|
+
def _make_launch_cmd(persona_path: Path, resume: bool) -> str:
|
|
87
|
+
template = resume_template if resume else launch_template
|
|
88
|
+
resolved = shutil.which(binary) or binary
|
|
89
|
+
cmd = template.format(
|
|
90
|
+
binary=resolved,
|
|
91
|
+
persona=str(persona_path),
|
|
92
|
+
session_dir=str(persona_path.parent),
|
|
93
|
+
)
|
|
94
|
+
if env_vars:
|
|
95
|
+
prefix = " ".join(f"{k}={v}" for k, v in env_vars.items())
|
|
96
|
+
cmd = f"export {prefix}; {cmd}"
|
|
97
|
+
return cmd
|
|
98
|
+
|
|
99
|
+
# --- Teardown -----------------------------------------------------------
|
|
100
|
+
teardown_fn = None
|
|
101
|
+
if teardown_cmd:
|
|
102
|
+
_cmd = teardown_cmd # capture in closure
|
|
103
|
+
|
|
104
|
+
def _teardown(_session_dir: Path) -> None:
|
|
105
|
+
try:
|
|
106
|
+
# shell=True is intentional: teardown_cmd is a user-supplied shell string
|
|
107
|
+
# (e.g. "pkill -f my-agent") that requires shell interpretation.
|
|
108
|
+
subprocess.run(_cmd, shell=True, timeout=10, check=False) # nosec B602
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
log.warning("Teardown command %r failed: %s", _cmd, exc)
|
|
111
|
+
|
|
112
|
+
teardown_fn = _teardown
|
|
113
|
+
|
|
114
|
+
# --- MCP setup ----------------------------------------------------------
|
|
115
|
+
mcp_fn = None
|
|
116
|
+
if mcp_config is not None:
|
|
117
|
+
if mcp_config is True:
|
|
118
|
+
_fmt = "generic"
|
|
119
|
+
_path = None
|
|
120
|
+
else:
|
|
121
|
+
_fmt = mcp_config.get("format", "generic")
|
|
122
|
+
_path = mcp_config.get("path")
|
|
123
|
+
|
|
124
|
+
def _mcp_setup(session_dir: Path) -> None:
|
|
125
|
+
write_mcp_config(session_dir, fmt=_fmt, path=_path)
|
|
126
|
+
|
|
127
|
+
mcp_fn = _mcp_setup
|
|
128
|
+
|
|
129
|
+
return AgentAdapter(
|
|
130
|
+
name=name,
|
|
131
|
+
binary=binary,
|
|
132
|
+
setup=setup_fn,
|
|
133
|
+
launch_cmd=_make_launch_cmd,
|
|
134
|
+
teardown=teardown_fn,
|
|
135
|
+
mcp_setup=mcp_fn,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_custom_adapters() -> dict[str, AgentAdapter]:
|
|
140
|
+
"""Load custom adapters from the ``agents.custom`` config section.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Dict mapping adapter name → ``AgentAdapter``. Empty if no custom
|
|
144
|
+
adapters are configured or the config cannot be loaded.
|
|
145
|
+
"""
|
|
146
|
+
from studyctl.settings import load_settings
|
|
147
|
+
|
|
148
|
+
customs = getattr(load_settings().agents, "custom", None)
|
|
149
|
+
if not customs:
|
|
150
|
+
return {}
|
|
151
|
+
|
|
152
|
+
result: dict[str, AgentAdapter] = {}
|
|
153
|
+
for adapter_name, adapter_config in customs.items():
|
|
154
|
+
try:
|
|
155
|
+
result[adapter_name] = build_custom_adapter(adapter_name, adapter_config)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
log.warning("Failed to build custom adapter %r: %s", adapter_name, exc)
|
|
158
|
+
|
|
159
|
+
return result
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Shared helpers for local LLM adapters (Ollama and LM Studio).
|
|
2
|
+
|
|
3
|
+
Both adapters use Claude Code as the frontend but point it at a local
|
|
4
|
+
LLM backend via environment variables. These helpers are extracted here
|
|
5
|
+
to avoid duplication between ollama.py and lmstudio.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_local_llm_config(provider: str) -> tuple[str, str]:
|
|
12
|
+
"""Return (base_url, model) for a local LLM provider from config.
|
|
13
|
+
|
|
14
|
+
Falls back to sensible defaults if config isn't available.
|
|
15
|
+
"""
|
|
16
|
+
defaults = {
|
|
17
|
+
"ollama": ("http://localhost:4000", "qwen3-coder"), # LiteLLM proxy
|
|
18
|
+
"lmstudio": ("http://localhost:1234", "qwen3-coder"),
|
|
19
|
+
}
|
|
20
|
+
try:
|
|
21
|
+
from studyctl.settings import load_settings
|
|
22
|
+
|
|
23
|
+
cfg = getattr(load_settings().agents, provider, None)
|
|
24
|
+
if cfg and cfg.model:
|
|
25
|
+
return cfg.base_url or defaults[provider][0], cfg.model
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
return defaults[provider]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _local_llm_env_prefix(base_url: str, auth_token: str, model: str) -> str:
|
|
32
|
+
"""Build shell env var exports for a local LLM provider.
|
|
33
|
+
|
|
34
|
+
Tier-pins all Claude Code model tiers to the same model, since
|
|
35
|
+
local LLMs only serve one model at a time. Without this, Claude
|
|
36
|
+
tries to use different models for sub-agents and fast tasks.
|
|
37
|
+
"""
|
|
38
|
+
return (
|
|
39
|
+
f"export ANTHROPIC_BASE_URL={base_url} "
|
|
40
|
+
f"ANTHROPIC_AUTH_TOKEN={auth_token} "
|
|
41
|
+
f"ANTHROPIC_MODEL={model} "
|
|
42
|
+
f"ANTHROPIC_SMALL_FAST_MODEL={model} "
|
|
43
|
+
f"ANTHROPIC_DEFAULT_HAIKU_MODEL={model} "
|
|
44
|
+
f"ANTHROPIC_DEFAULT_SONNET_MODEL={model} "
|
|
45
|
+
f"ANTHROPIC_DEFAULT_OPUS_MODEL={model}; "
|
|
46
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Agent adapter Protocol and dataclass.
|
|
2
|
+
|
|
3
|
+
The AdapterProtocol defines the contract that every agent adapter
|
|
4
|
+
must satisfy. AgentAdapter is the concrete frozen dataclass that
|
|
5
|
+
implements it using callables for each behaviour.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class AdapterProtocol(Protocol):
|
|
20
|
+
"""Contract for agent adapters.
|
|
21
|
+
|
|
22
|
+
Any object with these attributes and methods can act as an adapter.
|
|
23
|
+
The AgentAdapter dataclass satisfies this Protocol, but custom
|
|
24
|
+
implementations can use any class as long as it provides these fields.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
binary: str
|
|
29
|
+
|
|
30
|
+
def setup(self, canonical_content: str, session_dir: Path) -> Path: ...
|
|
31
|
+
def launch_cmd(self, persona_path: Path, resume: bool) -> str: ...
|
|
32
|
+
def teardown(self, session_dir: Path) -> None: ...
|
|
33
|
+
def mcp_setup(self, session_dir: Path) -> None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AgentAdapter:
|
|
38
|
+
"""Configuration and behaviour for one AI coding agent.
|
|
39
|
+
|
|
40
|
+
Each field is either static data or a callable that handles
|
|
41
|
+
the agent-specific mechanism for persona injection and launch.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
binary: str
|
|
46
|
+
setup: Callable[[str, Path], Path]
|
|
47
|
+
"""(canonical_content, session_dir) -> persona_path"""
|
|
48
|
+
launch_cmd: Callable[[Path, bool], str]
|
|
49
|
+
"""(persona_path, resume) -> shell command string"""
|
|
50
|
+
teardown: Callable[[Path], None] | None = None
|
|
51
|
+
"""Optional cleanup for agents that write global state."""
|
|
52
|
+
mcp_setup: Callable[[Path], None] | None = None
|
|
53
|
+
"""Optional MCP config writer for the session directory."""
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Reusable persona injection strategies and MCP config writers.
|
|
2
|
+
|
|
3
|
+
These are the building blocks for constructing AgentAdapter instances.
|
|
4
|
+
Each strategy handles one specific mechanism (temp file, CWD file, MCP
|
|
5
|
+
config) so adapter definitions remain declarative and DRY.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import stat
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cli_flag_setup(canonical_content: str, _session_dir: Path) -> Path:
|
|
19
|
+
"""Write persona to a secure temp file for agents that accept --flag /path.
|
|
20
|
+
|
|
21
|
+
Creates a file with 0o600 permissions (owner read/write only) so the
|
|
22
|
+
persona content is not world-readable on multi-user systems.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
canonical_content: The rendered persona markdown content.
|
|
26
|
+
_session_dir: Unused; accepted to satisfy the setup callable signature.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Path to the temporary file. Caller is responsible for cleanup.
|
|
30
|
+
"""
|
|
31
|
+
fd, tmp = tempfile.mkstemp(suffix=".md", prefix="studyctl-persona-")
|
|
32
|
+
try:
|
|
33
|
+
os.write(fd, canonical_content.encode())
|
|
34
|
+
finally:
|
|
35
|
+
os.close(fd)
|
|
36
|
+
|
|
37
|
+
# Set secure permissions: owner read+write only
|
|
38
|
+
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
|
|
39
|
+
|
|
40
|
+
return Path(tmp)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cwd_file_setup(
|
|
44
|
+
canonical_content: str,
|
|
45
|
+
session_dir: Path,
|
|
46
|
+
*,
|
|
47
|
+
filename: str = "PERSONA.md",
|
|
48
|
+
) -> Path:
|
|
49
|
+
"""Write persona as a named file inside the session directory.
|
|
50
|
+
|
|
51
|
+
Used by agents (e.g. Kiro) that pick up context files from the CWD
|
|
52
|
+
rather than accepting an explicit flag.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
canonical_content: The rendered persona markdown content.
|
|
56
|
+
session_dir: Directory where the persona file will be written.
|
|
57
|
+
filename: Name for the persona file. Defaults to ``PERSONA.md``.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Path to the written file.
|
|
61
|
+
"""
|
|
62
|
+
dest = session_dir / filename
|
|
63
|
+
dest.write_text(canonical_content, encoding="utf-8")
|
|
64
|
+
return dest
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# MCP config writer
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _mcp_command() -> list[str]:
|
|
73
|
+
"""Return the command list for launching studyctl-mcp.
|
|
74
|
+
|
|
75
|
+
Checks PATH first so installed tool users get the fast path. Falls
|
|
76
|
+
back to ``uv run`` against the workspace package so development
|
|
77
|
+
checkouts work without a separate install step.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A list suitable for use as ``command + args`` in an MCP config.
|
|
81
|
+
"""
|
|
82
|
+
if shutil.which("studyctl-mcp"):
|
|
83
|
+
return ["studyctl-mcp"]
|
|
84
|
+
|
|
85
|
+
# Repo root is six levels up from this file:
|
|
86
|
+
# packages/studyctl/src/studyctl/adapters/_strategies.py
|
|
87
|
+
# ^adapters
|
|
88
|
+
# ^studyctl
|
|
89
|
+
# ^src
|
|
90
|
+
# ^studyctl (package)
|
|
91
|
+
# ^packages
|
|
92
|
+
# ^repo_root
|
|
93
|
+
repo_root = Path(__file__).parent.parent.parent.parent.parent.parent
|
|
94
|
+
return [
|
|
95
|
+
"uv",
|
|
96
|
+
"run",
|
|
97
|
+
"--project",
|
|
98
|
+
str(repo_root / "packages" / "studyctl"),
|
|
99
|
+
"studyctl-mcp",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write_mcp_config(
|
|
104
|
+
session_dir: Path,
|
|
105
|
+
*,
|
|
106
|
+
fmt: str = "generic",
|
|
107
|
+
path: str | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Write the MCP server configuration JSON for the given agent format.
|
|
110
|
+
|
|
111
|
+
Supported formats:
|
|
112
|
+
|
|
113
|
+
* ``"generic"`` — Claude Code / generic MCP schema at ``.mcp.json``
|
|
114
|
+
* ``"gemini"`` — Gemini CLI schema at ``.gemini/settings.json``
|
|
115
|
+
* ``"opencode"`` — OpenCode schema at ``.opencode/opencode.json``
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
session_dir: Root of the session workspace; config paths are
|
|
119
|
+
resolved relative to this directory.
|
|
120
|
+
fmt: Target config format. Must be one of the supported strings.
|
|
121
|
+
path: Override the default config file path (relative to
|
|
122
|
+
``session_dir``). If omitted the format-specific default is used.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If ``fmt`` is not a recognised format string.
|
|
126
|
+
"""
|
|
127
|
+
cmd = _mcp_command()
|
|
128
|
+
|
|
129
|
+
if fmt == "generic":
|
|
130
|
+
default_path = ".mcp.json"
|
|
131
|
+
config = {
|
|
132
|
+
"mcpServers": {
|
|
133
|
+
"studyctl-mcp": {
|
|
134
|
+
"command": cmd[0],
|
|
135
|
+
"args": cmd[1:],
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
elif fmt == "gemini":
|
|
140
|
+
default_path = ".gemini/settings.json"
|
|
141
|
+
config = {
|
|
142
|
+
"mcpServers": {
|
|
143
|
+
"studyctl-mcp": {
|
|
144
|
+
"command": cmd[0],
|
|
145
|
+
"args": cmd[1:],
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
elif fmt == "opencode":
|
|
150
|
+
default_path = ".opencode/opencode.json"
|
|
151
|
+
config = {
|
|
152
|
+
"mcp": {
|
|
153
|
+
"studyctl-mcp": {
|
|
154
|
+
"command": cmd,
|
|
155
|
+
"enabled": True,
|
|
156
|
+
"type": "local",
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else:
|
|
161
|
+
raise ValueError(f"Unknown MCP config format: {fmt!r}")
|
|
162
|
+
|
|
163
|
+
target = session_dir / (path or default_path)
|
|
164
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
target.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Claude adapter — persona via --append-system-prompt-file flag.
|
|
2
|
+
|
|
3
|
+
Claude Code accepts a temp file path via this flag. The file is written
|
|
4
|
+
with 0600 permissions so persona content is not world-readable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from studyctl.adapters._protocol import AgentAdapter
|
|
13
|
+
from studyctl.adapters._strategies import cli_flag_setup
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _claude_launch(persona_path: Path, resume: bool) -> str:
|
|
20
|
+
"""Build Claude launch command with absolute binary path.
|
|
21
|
+
|
|
22
|
+
Resolves to absolute path because tmux panes run non-interactive
|
|
23
|
+
shells which don't source .zshrc (~/.local/bin not in PATH).
|
|
24
|
+
"""
|
|
25
|
+
binary = shutil.which("claude") or "claude"
|
|
26
|
+
if resume:
|
|
27
|
+
return f"{binary} -r --append-system-prompt-file {persona_path}"
|
|
28
|
+
return f"{binary} --append-system-prompt-file {persona_path}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ADAPTER = AgentAdapter(
|
|
32
|
+
name="claude",
|
|
33
|
+
binary="claude",
|
|
34
|
+
setup=cli_flag_setup,
|
|
35
|
+
launch_cmd=_claude_launch,
|
|
36
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Gemini adapter — persona via GEMINI.md in session CWD.
|
|
2
|
+
|
|
3
|
+
Gemini CLI auto-loads GEMINI.md from the current working directory
|
|
4
|
+
(3-tier hierarchy: global, workspace, JIT). MCP config goes in
|
|
5
|
+
.gemini/settings.json in the session directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from studyctl.adapters._protocol import AgentAdapter
|
|
14
|
+
from studyctl.adapters._strategies import write_mcp_config
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _gemini_setup(canonical_content: str, session_dir: Path) -> Path:
|
|
21
|
+
"""Write GEMINI.md to session dir (auto-loaded by Gemini CLI from cwd)."""
|
|
22
|
+
persona_path = session_dir / "GEMINI.md"
|
|
23
|
+
persona_path.write_text(canonical_content)
|
|
24
|
+
return persona_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _gemini_launch(_persona_path: Path, resume: bool) -> str:
|
|
28
|
+
"""Build Gemini launch command. Gemini picks up GEMINI.md from cwd."""
|
|
29
|
+
binary = shutil.which("gemini") or "gemini"
|
|
30
|
+
if resume:
|
|
31
|
+
return f"{binary} -r"
|
|
32
|
+
return binary
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _gemini_mcp(session_dir: Path) -> None:
|
|
36
|
+
"""Write .gemini/settings.json with studyctl-mcp server config."""
|
|
37
|
+
write_mcp_config(session_dir, fmt="gemini")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ADAPTER = AgentAdapter(
|
|
41
|
+
name="gemini",
|
|
42
|
+
binary="gemini",
|
|
43
|
+
setup=_gemini_setup,
|
|
44
|
+
launch_cmd=_gemini_launch,
|
|
45
|
+
mcp_setup=_gemini_mcp,
|
|
46
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Kiro adapter — persona via ~/.kiro/agents/study-mentor.json.
|
|
2
|
+
|
|
3
|
+
Kiro loads agents natively from ~/.kiro/agents/. The setup function
|
|
4
|
+
writes canonical content to a temp persona file, then updates the
|
|
5
|
+
agent JSON's "prompt" field to reference it via file:// URI.
|
|
6
|
+
Teardown restores the original JSON from a backup.
|
|
7
|
+
|
|
8
|
+
Crash recovery: stale backups are restored on next setup to prevent
|
|
9
|
+
Kiro from permanently using a studyctl-managed prompt.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from studyctl.adapters._protocol import AgentAdapter
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Repo root: adapters/kiro.py is at
|
|
26
|
+
# packages/studyctl/src/studyctl/adapters/kiro.py
|
|
27
|
+
# So repo root is six levels up.
|
|
28
|
+
_REPO_ROOT = Path(__file__).parent.parent.parent.parent.parent.parent
|
|
29
|
+
|
|
30
|
+
KIRO_AGENTS_DIR = Path(os.environ.get("STUDYCTL_KIRO_AGENTS_DIR", Path.home() / ".kiro" / "agents"))
|
|
31
|
+
KIRO_AGENT_NAME = "study-mentor"
|
|
32
|
+
_KIRO_TEMPLATE = _REPO_ROOT / "agents" / "kiro" / "study-mentor.json"
|
|
33
|
+
_KIRO_BACKUP_SUFFIX = ".studyctl-backup"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _kiro_setup(canonical_content: str, _session_dir: Path) -> Path:
|
|
37
|
+
"""Write persona temp file and update Kiro agent JSON atomically."""
|
|
38
|
+
# 1. Write canonical content to a temp persona file
|
|
39
|
+
fd, persona_path = tempfile.mkstemp(
|
|
40
|
+
prefix="studyctl-kiro-persona-",
|
|
41
|
+
suffix=".md",
|
|
42
|
+
dir=tempfile.gettempdir(),
|
|
43
|
+
)
|
|
44
|
+
os.fchmod(fd, 0o600)
|
|
45
|
+
with os.fdopen(fd, "w") as f:
|
|
46
|
+
f.write(canonical_content)
|
|
47
|
+
|
|
48
|
+
# 2. Load the base agent template
|
|
49
|
+
if _KIRO_TEMPLATE.exists():
|
|
50
|
+
agent_def = json.loads(_KIRO_TEMPLATE.read_text())
|
|
51
|
+
else:
|
|
52
|
+
agent_def = {
|
|
53
|
+
"name": KIRO_AGENT_NAME,
|
|
54
|
+
"description": "Socratic study mentor",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# 3. Update prompt to reference the temp persona file
|
|
58
|
+
agent_def["prompt"] = f"file://{persona_path}"
|
|
59
|
+
|
|
60
|
+
# 4. Ensure target directory exists
|
|
61
|
+
KIRO_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
target = KIRO_AGENTS_DIR / f"{KIRO_AGENT_NAME}.json"
|
|
63
|
+
backup = target.with_suffix(target.suffix + _KIRO_BACKUP_SUFFIX)
|
|
64
|
+
|
|
65
|
+
# 4a. Recover from crash: if a backup exists, the previous session's
|
|
66
|
+
# teardown never ran. Restore the user's original config first.
|
|
67
|
+
if backup.exists():
|
|
68
|
+
logger.warning(
|
|
69
|
+
"Stale Kiro backup detected (previous session crashed?) — restoring %s",
|
|
70
|
+
backup,
|
|
71
|
+
)
|
|
72
|
+
os.replace(backup, target)
|
|
73
|
+
|
|
74
|
+
# 5. Backup existing agent JSON if present
|
|
75
|
+
if target.exists():
|
|
76
|
+
shutil.copy2(target, backup)
|
|
77
|
+
|
|
78
|
+
# 6. Atomic write: temp file in same dir → os.replace()
|
|
79
|
+
fd2, tmp_json = tempfile.mkstemp(
|
|
80
|
+
prefix=f"{KIRO_AGENT_NAME}-",
|
|
81
|
+
suffix=".json",
|
|
82
|
+
dir=str(KIRO_AGENTS_DIR),
|
|
83
|
+
)
|
|
84
|
+
with os.fdopen(fd2, "w") as f:
|
|
85
|
+
json.dump(agent_def, f, indent=2)
|
|
86
|
+
os.replace(tmp_json, target)
|
|
87
|
+
|
|
88
|
+
return Path(persona_path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _kiro_launch(_persona_path: Path, resume: bool) -> str:
|
|
92
|
+
"""Build Kiro launch command."""
|
|
93
|
+
binary = shutil.which("kiro-cli") or shutil.which("kiro") or "kiro-cli"
|
|
94
|
+
if resume:
|
|
95
|
+
return f"{binary} chat --agent {KIRO_AGENT_NAME} --resume"
|
|
96
|
+
return f"{binary} chat --agent {KIRO_AGENT_NAME}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _kiro_teardown(_session_dir: Path) -> None:
|
|
100
|
+
"""Restore the backed-up Kiro agent JSON."""
|
|
101
|
+
target = KIRO_AGENTS_DIR / f"{KIRO_AGENT_NAME}.json"
|
|
102
|
+
backup = target.with_suffix(target.suffix + _KIRO_BACKUP_SUFFIX)
|
|
103
|
+
if backup.exists():
|
|
104
|
+
os.replace(backup, target)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
ADAPTER = AgentAdapter(
|
|
108
|
+
name="kiro",
|
|
109
|
+
binary="kiro-cli",
|
|
110
|
+
setup=_kiro_setup,
|
|
111
|
+
launch_cmd=_kiro_launch,
|
|
112
|
+
teardown=_kiro_teardown,
|
|
113
|
+
)
|