lemming-cli 0.1.0__py3-none-any.whl

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.
Files changed (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
lemming/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Lemming: an autonomous, iterative task runner for AI agents."""
@@ -0,0 +1,5 @@
1
+ """HTTP API for the lemming web UI, built on FastAPI."""
2
+
3
+ from .main import QuietPollFilter, app
4
+
5
+ __all__ = ["app", "QuietPollFilter"]
lemming/api/auth.py ADDED
@@ -0,0 +1,34 @@
1
+ """Share-token authentication middleware for remotely shared servers."""
2
+
3
+ import fastapi
4
+
5
+
6
+ async def share_token_middleware(request: fastapi.Request, call_next):
7
+ """Require a share token for non-local requests when one is configured.
8
+
9
+ Accepts the token via the ``token`` query parameter (persisting it in a
10
+ cookie) or via the ``lemming_share_token`` cookie. Requests from
11
+ localhost bypass the check. Returns 401 when the token is missing or
12
+ invalid.
13
+ """
14
+ share_token = getattr(request.app.state, "share_token", None)
15
+ if not share_token:
16
+ return await call_next(request)
17
+
18
+ host = request.headers.get("host", "")
19
+ if host.startswith("127.0.0.1") or host.startswith("localhost"):
20
+ return await call_next(request)
21
+
22
+ token = request.query_params.get("token")
23
+ if token == share_token:
24
+ response = await call_next(request)
25
+ response.set_cookie(
26
+ key="lemming_share_token", value=token, httponly=True
27
+ )
28
+ return response
29
+
30
+ cookie_token = request.cookies.get("lemming_share_token")
31
+ if cookie_token == share_token:
32
+ return await call_next(request)
33
+
34
+ return fastapi.Response("Unauthorized", status_code=401)
@@ -0,0 +1,40 @@
1
+ import fastapi.testclient
2
+
3
+ from lemming import api
4
+
5
+
6
+ def test_share_token_middleware():
7
+ # Setup test client
8
+ original_token = getattr(api.app.state, "share_token", None)
9
+ try:
10
+ api.app.state.share_token = "secret123"
11
+ # We need a fresh client for each test that modifies app state
12
+ # middleware if it uses the app state, but here TestClient is
13
+ # created with api.app
14
+ client = fastapi.testclient.TestClient(api.app)
15
+
16
+ # Missing token -> 401
17
+ response = client.get("/api/data")
18
+ assert response.status_code == 401
19
+
20
+ # Valid token via query
21
+ response = client.get("/api/data?token=secret123")
22
+ assert response.status_code == 200
23
+ assert "lemming_share_token=secret123" in response.headers.get(
24
+ "set-cookie", ""
25
+ )
26
+
27
+ # Valid token via cookie
28
+ client.cookies.set("lemming_share_token", "secret123")
29
+ response = client.get("/api/data")
30
+ assert response.status_code == 200
31
+
32
+ # Local bypass via host header
33
+ response = client.get("/api/data", headers={"host": "127.0.0.1:8999"})
34
+ assert response.status_code == 200
35
+
36
+ response = client.get("/api/data", headers={"host": "localhost:8999"})
37
+ assert response.status_code == 200
38
+ finally:
39
+ # Restore
40
+ api.app.state.share_token = original_token
lemming/api/config.py ADDED
@@ -0,0 +1,85 @@
1
+ """API routes for runner discovery, project configuration, and loop runs."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+
7
+ import fastapi
8
+ import pydantic
9
+
10
+ from .. import models, tasks
11
+ from . import context
12
+
13
+ router = fastapi.APIRouter()
14
+
15
+
16
+ @router.get("/api/runners")
17
+ def get_runners():
18
+ """List the names of all known task runners."""
19
+ return list(models.KNOWN_RUNNERS)
20
+
21
+
22
+ @router.post("/api/context")
23
+ def update_context(
24
+ request: fastapi.Request, update: dict, project: str | None = None
25
+ ):
26
+ """Update the shared project context passed to task runners."""
27
+ tasks_file = context.resolve_tasks_file(request.app.state, project)
28
+ tasks.update_context(tasks_file, update.get("context", ""))
29
+ return {"status": "ok"}
30
+
31
+
32
+ @router.post("/api/config")
33
+ def update_config(
34
+ request: fastapi.Request,
35
+ config: tasks.RoadmapConfig,
36
+ project: str | None = None,
37
+ ):
38
+ """Replace the roadmap configuration and return the saved value."""
39
+ tasks_file = context.resolve_tasks_file(request.app.state, project)
40
+ with tasks.lock_tasks(tasks_file):
41
+ data = tasks.load_tasks(tasks_file)
42
+ data.config = config
43
+ tasks.save_tasks(tasks_file, data)
44
+ return data.config
45
+
46
+
47
+ class RunRequest(pydantic.BaseModel):
48
+ """Request body for starting the orchestrator loop."""
49
+
50
+ env: dict[str, str] | None = None
51
+
52
+
53
+ @router.post("/api/run")
54
+ def run_loop(
55
+ request: fastapi.Request,
56
+ run_request: RunRequest,
57
+ project: str | None = None,
58
+ ):
59
+ """Start the orchestrator loop as a detached background process."""
60
+ tasks_file = context.resolve_tasks_file(request.app.state, project)
61
+ project_dir = context.resolve_project_dir(request.app.state, project)
62
+
63
+ # Use sys.executable -m lemming.main to ensure we use the same environment
64
+ # and pass the explicit tasks file.
65
+ cmd = [
66
+ sys.executable,
67
+ "-m",
68
+ "lemming.main",
69
+ ]
70
+ if getattr(request.app.state, "verbose", False):
71
+ cmd.append("--verbose")
72
+ cmd.extend(
73
+ [
74
+ "--tasks-file",
75
+ str(tasks_file),
76
+ "run",
77
+ ]
78
+ )
79
+
80
+ env = os.environ.copy()
81
+ if run_request.env:
82
+ env.update(run_request.env)
83
+
84
+ subprocess.Popen(cmd, start_new_session=True, env=env, cwd=project_dir)
85
+ return {"status": "started"}
@@ -0,0 +1,84 @@
1
+ from unittest.mock import patch
2
+
3
+ from lemming import api
4
+
5
+
6
+ def test_run_loop(client, test_tasks):
7
+ with patch("subprocess.Popen") as mock_popen:
8
+ response = client.post("/api/run", json={"env": {"KEY": "VALUE"}})
9
+ assert response.status_code == 200
10
+ assert response.json() == {"status": "started"}
11
+
12
+ args = mock_popen.call_args[0][0]
13
+ assert "run" in args
14
+ # Check that we didn't pass redundant flags
15
+ assert "--runner" not in args
16
+ assert "--retries" not in args
17
+ assert "--hook" not in args
18
+
19
+
20
+ def test_get_runners(client):
21
+ response = client.get("/api/runners")
22
+ assert response.status_code == 200
23
+ assert response.json() == ["agy", "aider", "claude", "codex"]
24
+
25
+
26
+ def test_project_context_isolation(client, test_tasks):
27
+ """Context is isolated per project."""
28
+ root = api.app.state.root
29
+ subdir = root / "ctx_test"
30
+ subdir.mkdir(exist_ok=True)
31
+
32
+ # Set context for sub-project
33
+ response = client.post(
34
+ "/api/context",
35
+ json={"context": "Sub-project context"},
36
+ params={"project": "ctx_test"},
37
+ )
38
+ assert response.status_code == 200
39
+
40
+ # Root context should be unchanged
41
+ res = client.get("/api/data")
42
+ assert res.status_code == 200
43
+ assert res.json()["context"] == "Initial context"
44
+
45
+ # Sub-project context should be set
46
+ res = client.get("/api/data", params={"project": "ctx_test"})
47
+ assert res.status_code == 200
48
+ assert res.json()["context"] == "Sub-project context"
49
+
50
+
51
+ def test_run_loop_with_project(client, test_tasks):
52
+ """POST /api/run with project param uses the correct tasks file."""
53
+ root = api.app.state.root
54
+ subdir = root / "run_project"
55
+ subdir.mkdir(exist_ok=True)
56
+ (subdir / "tasks.yml").touch()
57
+
58
+ with patch("subprocess.Popen") as mock_popen:
59
+ response = client.post(
60
+ "/api/run",
61
+ json={"runner": "claude"},
62
+ params={"project": "run_project"},
63
+ )
64
+ assert response.status_code == 200
65
+ args = mock_popen.call_args[0][0]
66
+ # The tasks file should be for the sub-project, not the default
67
+ tasks_file_idx = args.index("--tasks-file") + 1
68
+ assert "run_project" in args[tasks_file_idx]
69
+
70
+
71
+ def test_run_loop_with_project_cwd(test_workspace, client):
72
+ root_dir, subproject_dir = test_workspace
73
+
74
+ with patch("subprocess.Popen") as mock_popen:
75
+ response = client.post(
76
+ "/api/run",
77
+ json={"runner": "echo", "retries": 1},
78
+ params={"project": "my-subproject"},
79
+ )
80
+ assert response.status_code == 200
81
+
82
+ # Verify Popen was called with the correct cwd
83
+ _, kwargs = mock_popen.call_args
84
+ assert str(kwargs["cwd"]) == str(subproject_dir)
@@ -0,0 +1,204 @@
1
+ """Shared pytest fixtures for the API test suite."""
2
+
3
+ import os
4
+ import pathlib
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ import time
9
+
10
+ import fastapi.testclient
11
+ import pytest
12
+
13
+ from lemming import api, paths, tasks
14
+
15
+
16
+ @pytest.fixture
17
+ def client():
18
+ """A TestClient bound to the lemming FastAPI app."""
19
+ return fastapi.testclient.TestClient(api.app)
20
+
21
+
22
+ @pytest.fixture
23
+ def test_tasks():
24
+ """A temporary tasks file with sample tasks, wired into the app state."""
25
+ # Create a temporary directory and a tasks file
26
+ test_dir = tempfile.mkdtemp()
27
+ test_tasks_file = pathlib.Path(test_dir) / "tasks_test.yml"
28
+
29
+ # Scaffold a valid file
30
+ data = tasks.Roadmap(
31
+ context="Initial context",
32
+ tasks=[
33
+ tasks.Task(
34
+ id="task1",
35
+ description="Completed Task",
36
+ status=tasks.TaskStatus.COMPLETED,
37
+ attempts=1,
38
+ progress=["All good"],
39
+ completed_at=123456789.0,
40
+ ),
41
+ tasks.Task(
42
+ id="task2",
43
+ description="Pending Task",
44
+ status=tasks.TaskStatus.PENDING,
45
+ attempts=0,
46
+ progress=[],
47
+ ),
48
+ tasks.Task(
49
+ id="task3",
50
+ description="In Progress Task",
51
+ status=tasks.TaskStatus.IN_PROGRESS,
52
+ attempts=1,
53
+ progress=[],
54
+ pid=os.getpid(),
55
+ last_heartbeat=time.time(),
56
+ ),
57
+ ],
58
+ )
59
+ tasks.save_tasks(test_tasks_file, data)
60
+
61
+ # Override the TASKS_FILE and root in the api module
62
+ original_tasks_file = api.app.state.tasks_file
63
+ original_root = api.app.state.root
64
+ original_auto_start = api.app.state.disable_auto_start
65
+ api.app.state.tasks_file = test_tasks_file
66
+ api.app.state.root = pathlib.Path(test_dir).resolve()
67
+ api.app.state.disable_auto_start = True
68
+
69
+ yield test_tasks_file
70
+
71
+ # Restore the originals
72
+ api.app.state.tasks_file = original_tasks_file
73
+ api.app.state.root = original_root
74
+ api.app.state.disable_auto_start = original_auto_start
75
+ shutil.rmtree(test_dir)
76
+
77
+
78
+ @pytest.fixture
79
+ def git_repo():
80
+ """A temporary git repo with tracked and gitignored files as the root."""
81
+ # Create a temporary directory and initialize a git repo
82
+ test_dir = tempfile.mkdtemp()
83
+ orig_cwd = os.getcwd()
84
+ original_root = api.app.state.root
85
+ os.chdir(test_dir)
86
+ api.app.state.root = pathlib.Path(test_dir).resolve()
87
+
88
+ # Clear cached git repo check from previous tests
89
+ if hasattr(paths.in_git_repo, "_result"):
90
+ del paths.in_git_repo._result
91
+
92
+ subprocess.run(["git", "init"], check=True)
93
+ subprocess.run(
94
+ ["git", "config", "user.email", "you@example.com"], check=True
95
+ )
96
+ subprocess.run(["git", "config", "user.name", "Your Name"], check=True)
97
+
98
+ # Create some files
99
+ (pathlib.Path(test_dir) / "file1.txt").write_text("content1")
100
+ (pathlib.Path(test_dir) / "dir1").mkdir()
101
+ (pathlib.Path(test_dir) / "dir1" / "file2.txt").write_text("content2")
102
+
103
+ # Create .gitignore and ignore some files
104
+ (pathlib.Path(test_dir) / ".gitignore").write_text(
105
+ "ignored.txt\nnode_modules/"
106
+ )
107
+ (pathlib.Path(test_dir) / "ignored.txt").write_text("should be ignored")
108
+ (pathlib.Path(test_dir) / "node_modules").mkdir()
109
+ (pathlib.Path(test_dir) / "node_modules" / "some_file.txt").write_text(
110
+ "ignored"
111
+ )
112
+
113
+ yield pathlib.Path(test_dir)
114
+
115
+ # Clear cached git repo check and restore cwd
116
+ if hasattr(paths.in_git_repo, "_result"):
117
+ del paths.in_git_repo._result
118
+ os.chdir(orig_cwd)
119
+ api.app.state.root = original_root
120
+ shutil.rmtree(test_dir)
121
+
122
+
123
+ @pytest.fixture
124
+ def non_git_dir():
125
+ """A temporary directory that is NOT a git repo."""
126
+ test_dir = tempfile.mkdtemp()
127
+ orig_cwd = os.getcwd()
128
+ original_root = api.app.state.root
129
+ os.chdir(test_dir)
130
+ api.app.state.root = pathlib.Path(test_dir).resolve()
131
+
132
+ # Clear cached git repo check
133
+ if hasattr(paths.in_git_repo, "_result"):
134
+ del paths.in_git_repo._result
135
+
136
+ # Create files (including one that would be "ignored" if git were present)
137
+ (pathlib.Path(test_dir) / "file1.txt").write_text("content1")
138
+ (pathlib.Path(test_dir) / "ignored.txt").write_text("not actually ignored")
139
+
140
+ yield pathlib.Path(test_dir)
141
+
142
+ if hasattr(paths.in_git_repo, "_result"):
143
+ del paths.in_git_repo._result
144
+ os.chdir(orig_cwd)
145
+ api.app.state.root = original_root
146
+ shutil.rmtree(test_dir)
147
+
148
+
149
+ @pytest.fixture
150
+ def temp_repo(tmp_path, monkeypatch):
151
+ """Create a temporary directory and set it as the API root."""
152
+ root = tmp_path / "repo"
153
+ root.mkdir()
154
+
155
+ # Mock api.app.state.root
156
+ original_root = api.app.state.root
157
+ api.app.state.root = root
158
+
159
+ yield root
160
+
161
+ # Restore original root
162
+ api.app.state.root = original_root
163
+
164
+
165
+ @pytest.fixture
166
+ def test_workspace():
167
+ """A temporary root with a subproject, with auto-start enabled."""
168
+ # Create a temporary root directory
169
+ root_dir = pathlib.Path(tempfile.mkdtemp()).resolve()
170
+
171
+ # Create a subproject directory
172
+ subproject_dir = root_dir / "my-subproject"
173
+ subproject_dir.mkdir()
174
+
175
+ # Set up some tasks in the subproject
176
+ sub_tasks_file = subproject_dir / "tasks.yml"
177
+ data = tasks.Roadmap(
178
+ context="Subproject context",
179
+ tasks=[
180
+ tasks.Task(
181
+ id="sub1",
182
+ description="Sub Task 1",
183
+ status=tasks.TaskStatus.PENDING,
184
+ ),
185
+ ],
186
+ )
187
+ tasks.save_tasks(sub_tasks_file, data)
188
+
189
+ # Override app state
190
+ original_root = api.app.state.root
191
+ original_tasks_file = api.app.state.tasks_file
192
+ original_auto_start = api.app.state.disable_auto_start
193
+
194
+ api.app.state.root = root_dir
195
+ api.app.state.tasks_file = root_dir / "tasks.yml"
196
+ api.app.state.disable_auto_start = False # Enable auto-start for testing
197
+
198
+ yield root_dir, subproject_dir
199
+
200
+ # Restore app state
201
+ api.app.state.root = original_root
202
+ api.app.state.tasks_file = original_tasks_file
203
+ api.app.state.disable_auto_start = original_auto_start
204
+ shutil.rmtree(root_dir)
lemming/api/context.py ADDED
@@ -0,0 +1,39 @@
1
+ """Helpers to resolve per-project directories and tasks files."""
2
+
3
+ import pathlib
4
+
5
+ import fastapi
6
+
7
+ from .. import paths
8
+
9
+
10
+ def resolve_project_dir(
11
+ app_state: fastapi.datastructures.State, project: str | None = None
12
+ ) -> pathlib.Path:
13
+ """Resolve a project query parameter to its absolute directory path."""
14
+ if not project:
15
+ return app_state.root
16
+
17
+ root = app_state.root
18
+ target = (root / project).resolve()
19
+ if not target.is_relative_to(root):
20
+ raise fastapi.HTTPException(403, "Path is outside the server root")
21
+ if not target.is_dir():
22
+ raise fastapi.HTTPException(400, "Not a directory")
23
+
24
+ return target
25
+
26
+
27
+ def resolve_tasks_file(
28
+ app_state: fastapi.datastructures.State, project: str | None = None
29
+ ) -> pathlib.Path:
30
+ """Resolve a project query parameter to a tasks file path.
31
+
32
+ When *project* is ``None`` or empty the server-wide default is returned.
33
+ Otherwise the value is treated as a relative directory under the server
34
+ root and the tasks file path is derived deterministically.
35
+ """
36
+ if not project:
37
+ return app_state.tasks_file
38
+
39
+ return paths.get_tasks_file_for_dir(resolve_project_dir(app_state, project))
@@ -0,0 +1,62 @@
1
+ import pathlib
2
+
3
+ import fastapi
4
+ import pytest
5
+
6
+ from lemming.api import context
7
+
8
+
9
+ class MockAppState:
10
+ def __init__(self, root: pathlib.Path, tasks_file: pathlib.Path):
11
+ self.root = root
12
+ self.tasks_file = tasks_file
13
+
14
+
15
+ def test_resolve_project_dir(tmp_path):
16
+ root = tmp_path / "root"
17
+ root.mkdir()
18
+ project = root / "project"
19
+ project.mkdir()
20
+
21
+ app_state = MockAppState(root, root / "tasks.yml")
22
+
23
+ # Default (no project)
24
+ assert context.resolve_project_dir(app_state) == root
25
+
26
+ # Specific project
27
+ assert context.resolve_project_dir(app_state, "project") == project
28
+
29
+ # Outside root
30
+ with pytest.raises(fastapi.HTTPException) as excinfo:
31
+ context.resolve_project_dir(app_state, "..")
32
+ assert excinfo.value.status_code == 403
33
+
34
+ # Not a directory
35
+ not_dir = root / "file.txt"
36
+ not_dir.write_text("hello")
37
+ with pytest.raises(fastapi.HTTPException) as excinfo:
38
+ context.resolve_project_dir(app_state, "file.txt")
39
+ assert excinfo.value.status_code == 400
40
+
41
+
42
+ def test_resolve_tasks_file(tmp_path):
43
+ root = tmp_path / "root"
44
+ root.mkdir()
45
+ project = root / "project"
46
+ project.mkdir()
47
+
48
+ default_tasks = root / "tasks.yml"
49
+ app_state = MockAppState(root, default_tasks)
50
+
51
+ # Default
52
+ assert context.resolve_tasks_file(app_state) == default_tasks
53
+
54
+ # Project (should use get_tasks_file_for_dir logic)
55
+ # Since project / tasks.yml doesn't exist, it should be in lemming home
56
+ # with a hash
57
+ tasks_file = context.resolve_tasks_file(app_state, "project")
58
+ assert tasks_file.name == "tasks.yml"
59
+ assert (
60
+ tasks_file.parent.name != "project"
61
+ ) # It's a hash, not the project name
62
+ assert tasks_file.is_absolute()
@@ -0,0 +1,57 @@
1
+ """API routes for browsing and creating directories under the root."""
2
+
3
+ import fastapi
4
+ import pydantic
5
+
6
+ router = fastapi.APIRouter()
7
+
8
+
9
+ @router.get("/api/directories")
10
+ def list_directories(request: fastapi.Request, path: str = ""):
11
+ """List subdirectories under the server root for the project picker."""
12
+ root = request.app.state.root
13
+ target = (root / path).resolve() if path else root
14
+ if not target.is_relative_to(root):
15
+ raise fastapi.HTTPException(403, "Path is outside the server root")
16
+ if not target.is_dir():
17
+ raise fastapi.HTTPException(400, "Not a directory")
18
+
19
+ dirs = []
20
+ for item in sorted(target.iterdir()):
21
+ if item.is_dir() and not item.name.startswith("."):
22
+ rel = item.relative_to(root)
23
+ dirs.append({"name": item.name, "path": str(rel)})
24
+ return {"path": path, "directories": dirs}
25
+
26
+
27
+ class CreateDirectoryRequest(pydantic.BaseModel):
28
+ """Request body for creating a directory under the server root."""
29
+
30
+ path: str = ""
31
+ name: str
32
+
33
+
34
+ @router.post("/api/directories")
35
+ def create_directory(
36
+ request: fastapi.Request, dir_request: CreateDirectoryRequest
37
+ ):
38
+ """Create a new directory under the server root."""
39
+ root = request.app.state.root
40
+ parent = (root / dir_request.path).resolve() if dir_request.path else root
41
+ if not parent.is_relative_to(root):
42
+ raise fastapi.HTTPException(403, "Path is outside the server root")
43
+ if not parent.is_dir():
44
+ raise fastapi.HTTPException(400, "Parent is not a directory")
45
+
46
+ new_dir = parent / dir_request.name
47
+ if not new_dir.resolve().is_relative_to(root):
48
+ raise fastapi.HTTPException(
49
+ 403, "Target path is outside the server root"
50
+ )
51
+
52
+ if new_dir.exists():
53
+ raise fastapi.HTTPException(400, "Directory already exists")
54
+
55
+ new_dir.mkdir()
56
+ rel = new_dir.relative_to(root)
57
+ return {"name": new_dir.name, "path": str(rel)}