mng-tutor 0.1.1__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.
- mng_tutor-0.1.1/.gitignore +270 -0
- mng_tutor-0.1.1/PKG-INFO +8 -0
- mng_tutor-0.1.1/conftest.py +15 -0
- mng_tutor-0.1.1/imbue/mng_tutor/__init__.py +0 -0
- mng_tutor-0.1.1/imbue/mng_tutor/checks.py +95 -0
- mng_tutor-0.1.1/imbue/mng_tutor/checks_test.py +43 -0
- mng_tutor-0.1.1/imbue/mng_tutor/cli.py +56 -0
- mng_tutor-0.1.1/imbue/mng_tutor/conftest.py +9 -0
- mng_tutor-0.1.1/imbue/mng_tutor/data_types.py +72 -0
- mng_tutor-0.1.1/imbue/mng_tutor/data_types_test.py +101 -0
- mng_tutor-0.1.1/imbue/mng_tutor/lessons.py +149 -0
- mng_tutor-0.1.1/imbue/mng_tutor/lessons_test.py +47 -0
- mng_tutor-0.1.1/imbue/mng_tutor/plugin.py +12 -0
- mng_tutor-0.1.1/imbue/mng_tutor/test_ratchets.py +255 -0
- mng_tutor-0.1.1/imbue/mng_tutor/tui.py +285 -0
- mng_tutor-0.1.1/pyproject.toml +29 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
.venv-*
|
|
142
|
+
env/
|
|
143
|
+
venv/
|
|
144
|
+
ENV/
|
|
145
|
+
env.bak/
|
|
146
|
+
venv.bak/
|
|
147
|
+
|
|
148
|
+
# Spyder project settings
|
|
149
|
+
.spyderproject
|
|
150
|
+
.spyproject
|
|
151
|
+
|
|
152
|
+
# Rope project settings
|
|
153
|
+
.ropeproject
|
|
154
|
+
|
|
155
|
+
# mkdocs documentation
|
|
156
|
+
/site
|
|
157
|
+
|
|
158
|
+
# mypy
|
|
159
|
+
.mypy_cache/
|
|
160
|
+
.dmypy.json
|
|
161
|
+
dmypy.json
|
|
162
|
+
|
|
163
|
+
# Pyre type checker
|
|
164
|
+
.pyre/
|
|
165
|
+
|
|
166
|
+
# pytype static type analyzer
|
|
167
|
+
.pytype/
|
|
168
|
+
|
|
169
|
+
# Cython debug symbols
|
|
170
|
+
cython_debug/
|
|
171
|
+
|
|
172
|
+
# PyCharm
|
|
173
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
174
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
175
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
176
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
177
|
+
.idea/
|
|
178
|
+
|
|
179
|
+
# Abstra
|
|
180
|
+
# Abstra is an AI-powered process automation framework.
|
|
181
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
182
|
+
# Learn more at https://abstra.io/docs
|
|
183
|
+
.abstra/
|
|
184
|
+
|
|
185
|
+
# Visual Studio Code
|
|
186
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
187
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
188
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
189
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
190
|
+
# .vscode/
|
|
191
|
+
|
|
192
|
+
# Ruff stuff:
|
|
193
|
+
.ruff_cache/
|
|
194
|
+
|
|
195
|
+
# PyPI configuration file
|
|
196
|
+
.pypirc
|
|
197
|
+
|
|
198
|
+
# Cursor
|
|
199
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
200
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
201
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
202
|
+
.cursorignore
|
|
203
|
+
.cursorindexingignore
|
|
204
|
+
|
|
205
|
+
# Marimo
|
|
206
|
+
marimo/_static/
|
|
207
|
+
marimo/_lsp/
|
|
208
|
+
__marimo__/
|
|
209
|
+
|
|
210
|
+
# task folders for local custom claude workflow
|
|
211
|
+
*/*/_tasks/
|
|
212
|
+
.sesskey
|
|
213
|
+
|
|
214
|
+
# Node.js
|
|
215
|
+
node_modules/
|
|
216
|
+
|
|
217
|
+
# Frontend build artifacts
|
|
218
|
+
frontend-dist/
|
|
219
|
+
|
|
220
|
+
# ignore review files
|
|
221
|
+
.reviews/
|
|
222
|
+
|
|
223
|
+
# files necessary to smuggle session id into reviewers
|
|
224
|
+
.claude/sessionid
|
|
225
|
+
|
|
226
|
+
# Claude Code local settings (session-specific permissions)
|
|
227
|
+
.claude/settings.local.json
|
|
228
|
+
|
|
229
|
+
# PR status and URL files written by main_claude_stop_hook.sh for status line display
|
|
230
|
+
.claude/pr_url
|
|
231
|
+
.claude/pr_status
|
|
232
|
+
|
|
233
|
+
# History of commits reviewed by main_claude_stop_hook.sh (to detect stuck agents)
|
|
234
|
+
.claude/reviewed_commits
|
|
235
|
+
|
|
236
|
+
# Local Claude settings backup files
|
|
237
|
+
.claude/*.bak
|
|
238
|
+
|
|
239
|
+
# Local mng settings (not committed)
|
|
240
|
+
.mng/settings.local.toml
|
|
241
|
+
|
|
242
|
+
# so the user can make their own notification script
|
|
243
|
+
scripts/notify_user.local.sh
|
|
244
|
+
|
|
245
|
+
# Test output files (slow tests, coverage summaries)
|
|
246
|
+
.test_output/
|
|
247
|
+
|
|
248
|
+
# Active session marker file (used to detect interrupted sessions)
|
|
249
|
+
.claude/active
|
|
250
|
+
|
|
251
|
+
# we stick the image build artifacts here
|
|
252
|
+
.mng/dev/build/
|
|
253
|
+
.mng/dev/secrets/
|
|
254
|
+
|
|
255
|
+
# Offload caches and local files
|
|
256
|
+
.offload/**
|
|
257
|
+
test-results/**
|
|
258
|
+
current.tar.gz
|
|
259
|
+
|
|
260
|
+
# Changelings deploy-time build artifacts
|
|
261
|
+
.changelings/
|
|
262
|
+
|
|
263
|
+
# Autofix working artifacts
|
|
264
|
+
.autofix/
|
|
265
|
+
|
|
266
|
+
# Demo recordings (asciinema .cast, .txt, and scripts)
|
|
267
|
+
.demos/
|
|
268
|
+
|
|
269
|
+
# for git worktrees from other repos
|
|
270
|
+
.external_worktrees/
|
mng_tutor-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Project-level conftest for mng-tutor.
|
|
2
|
+
|
|
3
|
+
When running tests from libs/mng_tutor/, this conftest provides the common pytest hooks
|
|
4
|
+
that would otherwise come from the monorepo root conftest.py (which is not discovered
|
|
5
|
+
when pytest runs from a subdirectory).
|
|
6
|
+
|
|
7
|
+
When running from the monorepo root, the root conftest.py registers the hooks first,
|
|
8
|
+
and this file's register_conftest_hooks() call is a no-op (guarded by a module-level flag).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from imbue.imbue_common.conftest_hooks import register_conftest_hooks
|
|
12
|
+
from imbue.mng.utils.logging import suppress_warnings
|
|
13
|
+
|
|
14
|
+
suppress_warnings()
|
|
15
|
+
register_conftest_hooks(globals())
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from typing import assert_never
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
|
|
6
|
+
from imbue.mng.api.list import list_agents
|
|
7
|
+
from imbue.mng.config.data_types import MngContext
|
|
8
|
+
from imbue.mng.errors import BaseMngError
|
|
9
|
+
from imbue.mng.interfaces.data_types import AgentDetails
|
|
10
|
+
from imbue.mng.primitives import AgentLifecycleState
|
|
11
|
+
from imbue.mng.primitives import AgentName
|
|
12
|
+
from imbue.mng.primitives import ErrorBehavior
|
|
13
|
+
from imbue.mng_tutor.data_types import AgentExistsCheck
|
|
14
|
+
from imbue.mng_tutor.data_types import AgentInStateCheck
|
|
15
|
+
from imbue.mng_tutor.data_types import AgentNotExistsCheck
|
|
16
|
+
from imbue.mng_tutor.data_types import FileExistsInAgentWorkDirCheck
|
|
17
|
+
from imbue.mng_tutor.data_types import StepCheck
|
|
18
|
+
from imbue.mng_tutor.data_types import TmuxSessionHasClientsCheck
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _find_agent_by_name(agent_name: AgentName, mng_ctx: MngContext) -> AgentDetails | None:
|
|
22
|
+
"""Find an agent by name, returning None if not found."""
|
|
23
|
+
result = list_agents(mng_ctx, is_streaming=False, error_behavior=ErrorBehavior.CONTINUE)
|
|
24
|
+
for agent in result.agents:
|
|
25
|
+
if agent.name == agent_name:
|
|
26
|
+
return agent
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _check_agent_exists(agent_name: AgentName, mng_ctx: MngContext) -> bool:
|
|
31
|
+
"""Check if an agent with the given name exists."""
|
|
32
|
+
return _find_agent_by_name(agent_name, mng_ctx) is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_agent_in_state(
|
|
36
|
+
agent_name: AgentName,
|
|
37
|
+
expected_states: tuple[AgentLifecycleState, ...],
|
|
38
|
+
mng_ctx: MngContext,
|
|
39
|
+
) -> bool:
|
|
40
|
+
"""Check if an agent is in one of the expected lifecycle states."""
|
|
41
|
+
agent = _find_agent_by_name(agent_name, mng_ctx)
|
|
42
|
+
if agent is None:
|
|
43
|
+
return False
|
|
44
|
+
return agent.state in expected_states
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _check_file_exists_in_work_dir(
|
|
48
|
+
agent_name: AgentName,
|
|
49
|
+
file_path: str,
|
|
50
|
+
mng_ctx: MngContext,
|
|
51
|
+
) -> bool:
|
|
52
|
+
"""Check if a file exists in the agent's working directory."""
|
|
53
|
+
agent = _find_agent_by_name(agent_name, mng_ctx)
|
|
54
|
+
if agent is None:
|
|
55
|
+
return False
|
|
56
|
+
full_path = agent.work_dir / file_path
|
|
57
|
+
return full_path.exists()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_tmux_session_has_clients(agent_name: AgentName, mng_ctx: MngContext) -> bool:
|
|
61
|
+
"""Check if the agent's tmux session has at least one attached client."""
|
|
62
|
+
session_name = f"{mng_ctx.config.prefix}{agent_name}"
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["tmux", "list-clients", "-t", session_name],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
)
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
return False
|
|
70
|
+
return len(result.stdout.strip()) > 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _execute_check(check: StepCheck, mng_ctx: MngContext) -> bool:
|
|
74
|
+
"""Execute the check logic for a single step."""
|
|
75
|
+
if isinstance(check, AgentExistsCheck):
|
|
76
|
+
return _check_agent_exists(check.agent_name, mng_ctx)
|
|
77
|
+
elif isinstance(check, AgentNotExistsCheck):
|
|
78
|
+
return not _check_agent_exists(check.agent_name, mng_ctx)
|
|
79
|
+
elif isinstance(check, AgentInStateCheck):
|
|
80
|
+
return _check_agent_in_state(check.agent_name, check.expected_states, mng_ctx)
|
|
81
|
+
elif isinstance(check, FileExistsInAgentWorkDirCheck):
|
|
82
|
+
return _check_file_exists_in_work_dir(check.agent_name, check.file_path, mng_ctx)
|
|
83
|
+
elif isinstance(check, TmuxSessionHasClientsCheck):
|
|
84
|
+
return _check_tmux_session_has_clients(check.agent_name, mng_ctx)
|
|
85
|
+
else:
|
|
86
|
+
assert_never(check)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def run_check(check: StepCheck, mng_ctx: MngContext) -> bool:
|
|
90
|
+
"""Execute a step check and return whether it passes."""
|
|
91
|
+
try:
|
|
92
|
+
return _execute_check(check, mng_ctx)
|
|
93
|
+
except (BaseMngError, OSError):
|
|
94
|
+
logger.debug("Check failed with exception for check type: {}", type(check).__name__)
|
|
95
|
+
return False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from imbue.mng.config.data_types import MngContext
|
|
4
|
+
from imbue.mng.primitives import AgentLifecycleState
|
|
5
|
+
from imbue.mng.primitives import AgentName
|
|
6
|
+
from imbue.mng_tutor.checks import run_check
|
|
7
|
+
from imbue.mng_tutor.data_types import AgentExistsCheck
|
|
8
|
+
from imbue.mng_tutor.data_types import AgentInStateCheck
|
|
9
|
+
from imbue.mng_tutor.data_types import AgentNotExistsCheck
|
|
10
|
+
from imbue.mng_tutor.data_types import FileExistsInAgentWorkDirCheck
|
|
11
|
+
from imbue.mng_tutor.data_types import TmuxSessionHasClientsCheck
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_agent_exists_check_returns_false_when_no_agents(temp_mng_ctx: MngContext) -> None:
|
|
15
|
+
check = AgentExistsCheck(agent_name=AgentName("nonexistent"))
|
|
16
|
+
assert run_check(check, temp_mng_ctx) is False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_agent_not_exists_check_returns_true_when_no_agents(temp_mng_ctx: MngContext) -> None:
|
|
20
|
+
check = AgentNotExistsCheck(agent_name=AgentName("nonexistent"))
|
|
21
|
+
assert run_check(check, temp_mng_ctx) is True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_agent_in_state_check_returns_false_when_no_agents(temp_mng_ctx: MngContext) -> None:
|
|
25
|
+
check = AgentInStateCheck(
|
|
26
|
+
agent_name=AgentName("nonexistent"),
|
|
27
|
+
expected_states=(AgentLifecycleState.RUNNING,),
|
|
28
|
+
)
|
|
29
|
+
assert run_check(check, temp_mng_ctx) is False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_file_exists_check_returns_false_when_no_agents(temp_mng_ctx: MngContext) -> None:
|
|
33
|
+
check = FileExistsInAgentWorkDirCheck(
|
|
34
|
+
agent_name=AgentName("nonexistent"),
|
|
35
|
+
file_path="hello.txt",
|
|
36
|
+
)
|
|
37
|
+
assert run_check(check, temp_mng_ctx) is False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.tmux
|
|
41
|
+
def test_tmux_session_has_clients_check_returns_false_when_no_session(temp_mng_ctx: MngContext) -> None:
|
|
42
|
+
check = TmuxSessionHasClientsCheck(agent_name=AgentName("nonexistent"))
|
|
43
|
+
assert run_check(check, temp_mng_ctx) is False
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from imbue.mng.cli.common_opts import CommonCliOptions
|
|
6
|
+
from imbue.mng.cli.common_opts import add_common_options
|
|
7
|
+
from imbue.mng.cli.common_opts import setup_command_context
|
|
8
|
+
from imbue.mng.cli.help_formatter import CommandHelpMetadata
|
|
9
|
+
from imbue.mng.cli.help_formatter import add_pager_help_option
|
|
10
|
+
from imbue.mng_tutor.lessons import ALL_LESSONS
|
|
11
|
+
from imbue.mng_tutor.tui import run_lesson_runner
|
|
12
|
+
from imbue.mng_tutor.tui import run_lesson_selector
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TutorCliOptions(CommonCliOptions):
|
|
16
|
+
"""Options for the tutor command."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command()
|
|
20
|
+
@add_common_options
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def tutor(ctx: click.Context, **kwargs: Any) -> None:
|
|
23
|
+
mng_ctx, output_opts, opts = setup_command_context(
|
|
24
|
+
ctx=ctx,
|
|
25
|
+
command_name="tutor",
|
|
26
|
+
command_class=TutorCliOptions,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Loop: select a lesson, run it, return to selector when done
|
|
30
|
+
lesson = run_lesson_selector(ALL_LESSONS)
|
|
31
|
+
while lesson is not None:
|
|
32
|
+
run_lesson_runner(lesson, mng_ctx)
|
|
33
|
+
lesson = run_lesson_selector(ALL_LESSONS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
CommandHelpMetadata(
|
|
37
|
+
key="tutor",
|
|
38
|
+
one_line_description="Interactive tutorial for learning mng commands",
|
|
39
|
+
synopsis="mng tutor [OPTIONS]",
|
|
40
|
+
description="""Launches an interactive tutorial that guides you through learning
|
|
41
|
+
mng commands step by step. Run this in a separate terminal from your
|
|
42
|
+
main working terminal.
|
|
43
|
+
|
|
44
|
+
Each lesson contains a series of steps with automatic completion detection.
|
|
45
|
+
Follow the instructions in the tutor terminal and complete each step in
|
|
46
|
+
your other terminal. The tutor automatically detects when each step is
|
|
47
|
+
complete and advances to the next one.""",
|
|
48
|
+
examples=(("Start the interactive tutor", "mng tutor"),),
|
|
49
|
+
see_also=(
|
|
50
|
+
("create", "Create a new agent"),
|
|
51
|
+
("connect", "Connect to an agent"),
|
|
52
|
+
("list", "List agents"),
|
|
53
|
+
),
|
|
54
|
+
).register()
|
|
55
|
+
|
|
56
|
+
add_pager_help_option(tutor)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Test fixtures for mng-tutor.
|
|
2
|
+
|
|
3
|
+
Uses shared plugin test fixtures from mng for common setup (plugin manager,
|
|
4
|
+
environment isolation, git repos, temp_mng_ctx, local_provider, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from imbue.mng.utils.plugin_testing import register_plugin_test_fixtures
|
|
8
|
+
|
|
9
|
+
register_plugin_test_fixtures(globals())
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from pydantic import Discriminator
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from imbue.imbue_common.frozen_model import FrozenModel
|
|
8
|
+
from imbue.mng.primitives import AgentLifecycleState
|
|
9
|
+
from imbue.mng.primitives import AgentName
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgentExistsCheck(FrozenModel):
|
|
13
|
+
"""Check that an agent with the given name exists."""
|
|
14
|
+
|
|
15
|
+
check_type: Literal["agent_exists"] = "agent_exists"
|
|
16
|
+
agent_name: AgentName = Field(description="Name of the agent to check for")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentNotExistsCheck(FrozenModel):
|
|
20
|
+
"""Check that an agent with the given name does not exist."""
|
|
21
|
+
|
|
22
|
+
check_type: Literal["agent_not_exists"] = "agent_not_exists"
|
|
23
|
+
agent_name: AgentName = Field(description="Name of the agent to check for")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AgentInStateCheck(FrozenModel):
|
|
27
|
+
"""Check that an agent is in one of the expected lifecycle states."""
|
|
28
|
+
|
|
29
|
+
check_type: Literal["agent_in_state"] = "agent_in_state"
|
|
30
|
+
agent_name: AgentName = Field(description="Name of the agent to check")
|
|
31
|
+
expected_states: tuple[AgentLifecycleState, ...] = Field(description="Acceptable lifecycle states")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FileExistsInAgentWorkDirCheck(FrozenModel):
|
|
35
|
+
"""Check that a file exists in an agent's working directory."""
|
|
36
|
+
|
|
37
|
+
check_type: Literal["file_exists_in_work_dir"] = "file_exists_in_work_dir"
|
|
38
|
+
agent_name: AgentName = Field(description="Name of the agent whose work_dir to check")
|
|
39
|
+
file_path: str = Field(description="Relative path within the agent's work_dir")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TmuxSessionHasClientsCheck(FrozenModel):
|
|
43
|
+
"""Check that an agent's tmux session has at least one attached client."""
|
|
44
|
+
|
|
45
|
+
check_type: Literal["tmux_session_has_clients"] = "tmux_session_has_clients"
|
|
46
|
+
agent_name: AgentName = Field(description="Name of the agent whose tmux session to check")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
StepCheck = Annotated[
|
|
50
|
+
AgentExistsCheck
|
|
51
|
+
| AgentNotExistsCheck
|
|
52
|
+
| AgentInStateCheck
|
|
53
|
+
| FileExistsInAgentWorkDirCheck
|
|
54
|
+
| TmuxSessionHasClientsCheck,
|
|
55
|
+
Discriminator("check_type"),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LessonStep(FrozenModel):
|
|
60
|
+
"""A single step in a tutorial lesson."""
|
|
61
|
+
|
|
62
|
+
heading: str = Field(description="Short heading for the step")
|
|
63
|
+
details: str = Field(description="Detailed instructions for completing the step")
|
|
64
|
+
check: StepCheck = Field(description="How to verify the step is complete")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Lesson(FrozenModel):
|
|
68
|
+
"""A complete tutorial lesson with ordered steps."""
|
|
69
|
+
|
|
70
|
+
title: str = Field(description="Title of the lesson")
|
|
71
|
+
description: str = Field(description="Brief description of what this lesson teaches")
|
|
72
|
+
steps: tuple[LessonStep, ...] = Field(description="Ordered steps to complete")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from imbue.mng.primitives import AgentLifecycleState
|
|
2
|
+
from imbue.mng.primitives import AgentName
|
|
3
|
+
from imbue.mng_tutor.data_types import AgentExistsCheck
|
|
4
|
+
from imbue.mng_tutor.data_types import AgentInStateCheck
|
|
5
|
+
from imbue.mng_tutor.data_types import AgentNotExistsCheck
|
|
6
|
+
from imbue.mng_tutor.data_types import FileExistsInAgentWorkDirCheck
|
|
7
|
+
from imbue.mng_tutor.data_types import Lesson
|
|
8
|
+
from imbue.mng_tutor.data_types import LessonStep
|
|
9
|
+
from imbue.mng_tutor.data_types import TmuxSessionHasClientsCheck
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_agent_exists_check_construction() -> None:
|
|
13
|
+
check = AgentExistsCheck(agent_name=AgentName("test-agent"))
|
|
14
|
+
assert check.check_type == "agent_exists"
|
|
15
|
+
assert check.agent_name == AgentName("test-agent")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_agent_not_exists_check_construction() -> None:
|
|
19
|
+
check = AgentNotExistsCheck(agent_name=AgentName("test-agent"))
|
|
20
|
+
assert check.check_type == "agent_not_exists"
|
|
21
|
+
assert check.agent_name == AgentName("test-agent")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_agent_in_state_check_construction() -> None:
|
|
25
|
+
check = AgentInStateCheck(
|
|
26
|
+
agent_name=AgentName("test-agent"),
|
|
27
|
+
expected_states=(AgentLifecycleState.RUNNING,),
|
|
28
|
+
)
|
|
29
|
+
assert check.check_type == "agent_in_state"
|
|
30
|
+
assert check.agent_name == AgentName("test-agent")
|
|
31
|
+
assert check.expected_states == (AgentLifecycleState.RUNNING,)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_agent_in_state_check_accepts_multiple_states() -> None:
|
|
35
|
+
check = AgentInStateCheck(
|
|
36
|
+
agent_name=AgentName("test-agent"),
|
|
37
|
+
expected_states=(AgentLifecycleState.RUNNING, AgentLifecycleState.WAITING),
|
|
38
|
+
)
|
|
39
|
+
assert len(check.expected_states) == 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_file_exists_check_construction() -> None:
|
|
43
|
+
check = FileExistsInAgentWorkDirCheck(
|
|
44
|
+
agent_name=AgentName("test-agent"),
|
|
45
|
+
file_path="hello.txt",
|
|
46
|
+
)
|
|
47
|
+
assert check.check_type == "file_exists_in_work_dir"
|
|
48
|
+
assert check.agent_name == AgentName("test-agent")
|
|
49
|
+
assert check.file_path == "hello.txt"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_tmux_session_has_clients_check_construction() -> None:
|
|
53
|
+
check = TmuxSessionHasClientsCheck(agent_name=AgentName("test-agent"))
|
|
54
|
+
assert check.check_type == "tmux_session_has_clients"
|
|
55
|
+
assert check.agent_name == AgentName("test-agent")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_lesson_step_with_agent_exists_check() -> None:
|
|
59
|
+
step = LessonStep(
|
|
60
|
+
heading="Create an agent",
|
|
61
|
+
details="Run `mng create test-agent`.",
|
|
62
|
+
check=AgentExistsCheck(agent_name=AgentName("test-agent")),
|
|
63
|
+
)
|
|
64
|
+
assert step.heading == "Create an agent"
|
|
65
|
+
assert isinstance(step.check, AgentExistsCheck)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_lesson_step_with_agent_in_state_check() -> None:
|
|
69
|
+
step = LessonStep(
|
|
70
|
+
heading="Stop the agent",
|
|
71
|
+
details="Run `mng stop test-agent`.",
|
|
72
|
+
check=AgentInStateCheck(
|
|
73
|
+
agent_name=AgentName("test-agent"),
|
|
74
|
+
expected_states=(AgentLifecycleState.STOPPED,),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
assert isinstance(step.check, AgentInStateCheck)
|
|
78
|
+
assert step.check.expected_states == (AgentLifecycleState.STOPPED,)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_lesson_construction() -> None:
|
|
82
|
+
lesson = Lesson(
|
|
83
|
+
title="Test Lesson",
|
|
84
|
+
description="A test lesson.",
|
|
85
|
+
steps=(
|
|
86
|
+
LessonStep(
|
|
87
|
+
heading="Step 1",
|
|
88
|
+
details="Do step 1.",
|
|
89
|
+
check=AgentExistsCheck(agent_name=AgentName("agent-1")),
|
|
90
|
+
),
|
|
91
|
+
LessonStep(
|
|
92
|
+
heading="Step 2",
|
|
93
|
+
details="Do step 2.",
|
|
94
|
+
check=AgentNotExistsCheck(agent_name=AgentName("agent-1")),
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
assert lesson.title == "Test Lesson"
|
|
99
|
+
assert len(lesson.steps) == 2
|
|
100
|
+
assert isinstance(lesson.steps[0].check, AgentExistsCheck)
|
|
101
|
+
assert isinstance(lesson.steps[1].check, AgentNotExistsCheck)
|