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/cli/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """CLI commands for managing project configuration."""
2
+
3
+ import click
4
+
5
+ from .. import tasks
6
+ from ..orchestrator import format_duration, parse_timeout
7
+ from .main import cli
8
+
9
+
10
+ @cli.group(name="config", short_help="Manage project configuration")
11
+ def config_group():
12
+ """Manages the configuration for the roadmap execution loop."""
13
+ pass
14
+
15
+
16
+ @config_group.command(name="list")
17
+ @click.pass_context
18
+ def config_list(ctx: click.Context):
19
+ """Shows the current project configuration."""
20
+ tasks_file = ctx.obj["TASKS_FILE"]
21
+ data = tasks.load_tasks(tasks_file)
22
+ c = data.config
23
+
24
+ click.secho(f"Configuration for {tasks_file}:", bold=True)
25
+ click.echo(f" Runner: {c.runner}")
26
+ click.echo(f" Retries: {c.retries}")
27
+ click.echo(f" Time limit: {format_duration(c.time_limit)}")
28
+ click.echo(
29
+ " Hooks: "
30
+ f"{', '.join(c.hooks) if c.hooks is not None else '(all)'}"
31
+ )
32
+
33
+
34
+ @config_group.command(name="set")
35
+ @click.argument(
36
+ "key",
37
+ type=click.Choice(["runner", "retries", "time_limit"]),
38
+ )
39
+ @click.argument("value")
40
+ @click.pass_context
41
+ def config_set(ctx: click.Context, key: str, value: str):
42
+ """Sets a configuration value.
43
+
44
+ Examples:
45
+ lemming config set runner aider
46
+ lemming config set retries 5
47
+ lemming config set time_limit 30m
48
+ """
49
+ tasks_file = ctx.obj["TASKS_FILE"]
50
+ data = tasks.load_tasks(tasks_file)
51
+
52
+ if key == "runner":
53
+ data.config.runner = value
54
+ elif key == "retries":
55
+ try:
56
+ data.config.retries = int(value)
57
+ except ValueError:
58
+ raise click.UsageError(f"Value for {key} must be an integer.")
59
+ elif key == "time_limit":
60
+ if value.lower() == "none":
61
+ data.config.time_limit = 0
62
+ else:
63
+ seconds = parse_timeout(value)
64
+ data.config.time_limit = int(seconds // 60)
65
+
66
+ tasks.save_tasks(tasks_file, data)
67
+ click.echo(f"Updated {key} to {value}")
@@ -0,0 +1,50 @@
1
+ import pathlib
2
+ import shutil
3
+ import tempfile
4
+ import unittest
5
+
6
+ import click.testing
7
+
8
+ from lemming import cli, tasks
9
+
10
+
11
+ class TestCLIConfig(unittest.TestCase):
12
+ def setUp(self):
13
+ self.cli_runner = click.testing.CliRunner()
14
+ self.test_dir = tempfile.mkdtemp()
15
+ self.test_tasks_file = pathlib.Path(self.test_dir) / "tasks_test.yml"
16
+ self.base_args = [
17
+ "--verbose",
18
+ "--tasks-file",
19
+ str(self.test_tasks_file),
20
+ ]
21
+
22
+ # Scaffold a valid file
23
+ data = tasks.Roadmap(
24
+ context="Initial context",
25
+ tasks=[],
26
+ )
27
+ tasks.save_tasks(self.test_tasks_file, data)
28
+
29
+ def tearDown(self):
30
+ shutil.rmtree(self.test_dir)
31
+
32
+ def test_config_list(self):
33
+ result = self.cli_runner.invoke(
34
+ cli.cli, self.base_args + ["config", "list"]
35
+ )
36
+ self.assertEqual(result.exit_code, 0)
37
+ self.assertIn("Runner:", result.output)
38
+
39
+ def test_config_set(self):
40
+ result = self.cli_runner.invoke(
41
+ cli.cli, self.base_args + ["config", "set", "runner", "new-runner"]
42
+ )
43
+ self.assertEqual(result.exit_code, 0)
44
+ self.assertIn("Updated runner to new-runner", result.output)
45
+ data = tasks.load_tasks(self.test_tasks_file)
46
+ self.assertEqual(data.config.runner, "new-runner")
47
+
48
+
49
+ if __name__ == "__main__":
50
+ unittest.main()
lemming/cli/context.py ADDED
@@ -0,0 +1,40 @@
1
+ """CLI command for viewing or setting the project context."""
2
+
3
+ import pathlib
4
+
5
+ import click
6
+
7
+ from .. import tasks
8
+ from .main import cli
9
+
10
+
11
+ @cli.command(short_help="[<text>] View or set the project context")
12
+ @click.argument("context_text", required=False)
13
+ @click.option(
14
+ "--file",
15
+ "-f",
16
+ type=click.Path(exists=True, path_type=pathlib.Path),
17
+ help="Read context from a file.",
18
+ )
19
+ @click.pass_context
20
+ def context(
21
+ ctx: click.Context, context_text: str | None, file: pathlib.Path | None
22
+ ):
23
+ """Sets or displays the global project-wide context and rules.
24
+
25
+ Args:
26
+ ctx: The click context holding shared CLI state.
27
+ context_text: The context string to set (optional).
28
+ file: A file path to read the context from (optional).
29
+ """
30
+ tasks_file = ctx.obj["TASKS_FILE"]
31
+
32
+ if file:
33
+ tasks.update_context(tasks_file, file.read_text(encoding="utf-8"))
34
+ click.echo("Project context updated.")
35
+ elif context_text:
36
+ tasks.update_context(tasks_file, context_text)
37
+ click.echo("Project context updated.")
38
+ else:
39
+ data = tasks.load_tasks(tasks_file)
40
+ click.echo(data.context or "No context set.")
@@ -0,0 +1,49 @@
1
+ import pathlib
2
+ import shutil
3
+ import tempfile
4
+ import unittest
5
+
6
+ import click.testing
7
+
8
+ from lemming import cli, tasks
9
+
10
+
11
+ class TestCLIContext(unittest.TestCase):
12
+ def setUp(self):
13
+ self.cli_runner = click.testing.CliRunner()
14
+ self.test_dir = tempfile.mkdtemp()
15
+ self.test_tasks_file = pathlib.Path(self.test_dir) / "tasks_test.yml"
16
+ self.base_args = [
17
+ "--verbose",
18
+ "--tasks-file",
19
+ str(self.test_tasks_file),
20
+ ]
21
+
22
+ # Scaffold a valid file
23
+ data = tasks.Roadmap(
24
+ context="Initial context",
25
+ tasks=[],
26
+ )
27
+ tasks.save_tasks(self.test_tasks_file, data)
28
+
29
+ def tearDown(self):
30
+ shutil.rmtree(self.test_dir)
31
+
32
+ def test_context_no_args(self):
33
+ result = self.cli_runner.invoke(cli.cli, self.base_args + ["context"])
34
+ self.assertEqual(result.exit_code, 0)
35
+ self.assertIn("Initial context", result.output)
36
+
37
+ def test_set_context(self):
38
+ result = self.cli_runner.invoke(
39
+ cli.cli, self.base_args + ["context", "Updated context via CLI"]
40
+ )
41
+ self.assertEqual(result.exit_code, 0)
42
+ self.assertIn("Project context updated.", result.output)
43
+
44
+ data = tasks.load_tasks(self.test_tasks_file)
45
+ self.assertEqual(data.context, "Updated context via CLI")
46
+
47
+
48
+ if __name__ == "__main__":
49
+ unittest.main()
lemming/cli/hooks.py ADDED
@@ -0,0 +1,147 @@
1
+ """CLI commands for managing orchestrator hooks."""
2
+
3
+ import click
4
+
5
+ from .. import prompts, tasks
6
+ from .main import cli
7
+
8
+
9
+ @cli.group(name="hooks", short_help="Manage orchestrator hooks")
10
+ def hooks_group():
11
+ """Manages orchestrator hooks."""
12
+ pass
13
+
14
+
15
+ @hooks_group.command(name="list")
16
+ @click.pass_context
17
+ def hooks_list(ctx: click.Context):
18
+ """Lists available orchestrator hooks."""
19
+ tasks_file = ctx.obj["TASKS_FILE"]
20
+ available = prompts.list_hooks(tasks_file)
21
+
22
+ data = tasks.load_tasks(tasks_file)
23
+ active = (
24
+ set(data.config.hooks)
25
+ if data.config.hooks is not None
26
+ else set(available)
27
+ )
28
+
29
+ click.secho("Available orchestrator hooks:", bold=True)
30
+ for h in available:
31
+ status = "[active]" if h in active else ""
32
+ click.echo(f" - {h:20} {status}")
33
+
34
+
35
+ @hooks_group.command(name="enable")
36
+ @click.argument("names", nargs=-1, required=True)
37
+ @click.pass_context
38
+ def hooks_enable(ctx: click.Context, names: tuple[str, ...]):
39
+ """Enables one or more orchestrator hooks."""
40
+ tasks_file = ctx.obj["TASKS_FILE"]
41
+ available = prompts.list_hooks(tasks_file)
42
+
43
+ data = tasks.load_tasks(tasks_file)
44
+ for name in names:
45
+ if name not in available:
46
+ click.echo(f"Error: Hook '{name}' not found.")
47
+ ctx.exit(1)
48
+
49
+ if data.config.hooks is None:
50
+ # If currently "all", it's already enabled. We don't transition
51
+ # to an explicit list here to keep the default behavior.
52
+ click.echo(
53
+ f"Hook '{name}' is already active "
54
+ "(all available hooks are enabled)."
55
+ )
56
+ continue
57
+
58
+ if name not in data.config.hooks:
59
+ data.config.hooks.append(name)
60
+ click.echo(f"Enabled hook: {name}")
61
+ else:
62
+ click.echo(f"Hook '{name}' is already enabled.")
63
+
64
+ tasks.save_tasks(tasks_file, data)
65
+
66
+
67
+ @hooks_group.command(name="disable")
68
+ @click.argument("names", nargs=-1, required=True)
69
+ @click.pass_context
70
+ def hooks_disable(ctx: click.Context, names: tuple[str, ...]):
71
+ """Disables one or more orchestrator hooks."""
72
+ tasks_file = ctx.obj["TASKS_FILE"]
73
+ available = prompts.list_hooks(tasks_file)
74
+
75
+ data = tasks.load_tasks(tasks_file)
76
+ for name in names:
77
+ if name not in available:
78
+ click.echo(f"Error: Hook '{name}' not found.")
79
+ ctx.exit(1)
80
+
81
+ if data.config.hooks is None:
82
+ # If currently "all", we transition to an explicit list minus
83
+ # the disabled one
84
+ data.config.hooks = [h for h in available if h != name]
85
+ else:
86
+ data.config.hooks = [h for h in data.config.hooks if h != name]
87
+
88
+ click.echo(f"Disabled hook: {name}")
89
+
90
+ tasks.save_tasks(tasks_file, data)
91
+
92
+
93
+ @hooks_group.command(name="set")
94
+ @click.argument("names", nargs=-1)
95
+ @click.pass_context
96
+ def hooks_set(ctx: click.Context, names: tuple[str, ...]):
97
+ """Sets the specific list of active orchestrator hooks.
98
+
99
+ Provide a space-separated list of hook names. If no names are provided,
100
+ all hooks will be disabled.
101
+
102
+ Examples:
103
+ lemming hooks set roadmap lint
104
+ lemming hooks set roadmap
105
+ lemming hooks set # Disables all hooks
106
+ """
107
+ tasks_file = ctx.obj["TASKS_FILE"]
108
+ available = prompts.list_hooks(tasks_file)
109
+
110
+ data = tasks.load_tasks(tasks_file)
111
+ new_hooks = []
112
+ for name in names:
113
+ if name not in available:
114
+ click.echo(f"Error: Hook '{name}' not found.")
115
+ ctx.exit(1)
116
+ new_hooks.append(name)
117
+
118
+ data.config.hooks = new_hooks
119
+ tasks.save_tasks(tasks_file, data)
120
+
121
+ if not new_hooks:
122
+ click.echo("All hooks disabled.")
123
+ else:
124
+ click.echo(f"Active hooks set to: {', '.join(new_hooks)}")
125
+
126
+
127
+ @hooks_group.command(name="reset")
128
+ @click.pass_context
129
+ def hooks_reset(ctx: click.Context):
130
+ """Resets hooks to run all available hooks by default."""
131
+ tasks_file = ctx.obj["TASKS_FILE"]
132
+ data = tasks.load_tasks(tasks_file)
133
+ data.config.hooks = None
134
+ tasks.save_tasks(tasks_file, data)
135
+ click.echo("Hooks reset to default (all available).")
136
+
137
+
138
+ @hooks_group.command(name="install")
139
+ @click.pass_context
140
+ def hooks_install(ctx: click.Context):
141
+ """Installs (symlinks) built-in hooks into the global hooks directory.
142
+
143
+ This makes them easily discoverable and overridable in
144
+ ~/.local/lemming/hooks/.
145
+ """
146
+ prompts.ensure_hooks_symlinked()
147
+ click.echo("Built-in hooks installed to ~/.local/lemming/hooks/")
@@ -0,0 +1,50 @@
1
+ import pathlib
2
+ import shutil
3
+ import tempfile
4
+ import unittest
5
+
6
+ import click.testing
7
+
8
+ from lemming import cli, tasks
9
+
10
+
11
+ class TestCLIHooks(unittest.TestCase):
12
+ def setUp(self):
13
+ self.cli_runner = click.testing.CliRunner()
14
+ self.test_dir = tempfile.mkdtemp()
15
+ self.test_tasks_file = pathlib.Path(self.test_dir) / "tasks_test.yml"
16
+ self.base_args = [
17
+ "--verbose",
18
+ "--tasks-file",
19
+ str(self.test_tasks_file),
20
+ ]
21
+
22
+ # Scaffold a valid file
23
+ data = tasks.Roadmap(
24
+ context="Initial context",
25
+ tasks=[],
26
+ )
27
+ tasks.save_tasks(self.test_tasks_file, data)
28
+
29
+ def tearDown(self):
30
+ shutil.rmtree(self.test_dir)
31
+
32
+ def test_hooks_list(self):
33
+ result = self.cli_runner.invoke(
34
+ cli.cli, self.base_args + ["hooks", "list"]
35
+ )
36
+ self.assertEqual(result.exit_code, 0)
37
+ self.assertIn("Available orchestrator hooks:", result.output)
38
+
39
+ def test_hooks_reset(self):
40
+ result = self.cli_runner.invoke(
41
+ cli.cli, self.base_args + ["hooks", "reset"]
42
+ )
43
+ self.assertEqual(result.exit_code, 0)
44
+ self.assertIn("Hooks reset to default", result.output)
45
+ data = tasks.load_tasks(self.test_tasks_file)
46
+ self.assertIsNone(data.config.hooks)
47
+
48
+
49
+ if __name__ == "__main__":
50
+ unittest.main()
lemming/cli/main.py ADDED
@@ -0,0 +1,42 @@
1
+ """Top-level click group and shared options for the Lemming CLI."""
2
+
3
+ import pathlib
4
+
5
+ import click
6
+
7
+ from .. import paths
8
+
9
+
10
+ @click.group()
11
+ @click.option(
12
+ "--tasks-file",
13
+ type=click.Path(path_type=pathlib.Path),
14
+ help=(
15
+ "Path to the tasks file (defaults to ./tasks.yml or "
16
+ "project-isolated tasks in ~/.local/lemming/<hash>/)."
17
+ ),
18
+ )
19
+ @click.option(
20
+ "--verbose",
21
+ "-v",
22
+ is_flag=True,
23
+ help="Show verbose output.",
24
+ )
25
+ @click.pass_context
26
+ def cli(ctx: click.Context, tasks_file: pathlib.Path | None, verbose: bool):
27
+ """Lemming: An autonomous, iterative task runner for AI agents.
28
+
29
+ Lemming orchestrates AI coding agents by walking through a structured
30
+ `tasks.yml` file. It manages the context, tracks task attempts, and
31
+ records progress.
32
+ """
33
+ ctx.ensure_object(dict)
34
+ if tasks_file is None:
35
+ tasks_file = paths.get_default_tasks_file()
36
+ tasks_file = tasks_file.resolve()
37
+
38
+ # Load .env files (global, then project-level); real env vars always win.
39
+ paths.load_dotenv(project_dir=paths.get_working_dir(tasks_file))
40
+
41
+ ctx.obj["TASKS_FILE"] = tasks_file
42
+ ctx.obj["VERBOSE"] = verbose
@@ -0,0 +1,21 @@
1
+ import unittest
2
+
3
+ import click.testing
4
+
5
+ from lemming.cli.main import cli
6
+
7
+
8
+ class TestCLIMain(unittest.TestCase):
9
+ def setUp(self):
10
+ self.cli_runner = click.testing.CliRunner()
11
+
12
+ def test_cli_help(self):
13
+ result = self.cli_runner.invoke(cli, ["--help"])
14
+ self.assertEqual(result.exit_code, 0)
15
+ self.assertIn(
16
+ "Lemming: An autonomous, iterative task runner", result.output
17
+ )
18
+
19
+
20
+ if __name__ == "__main__":
21
+ unittest.main()
@@ -0,0 +1,226 @@
1
+ """CLI commands for the orchestrator loop and the web interface."""
2
+
3
+ import copy
4
+ import os
5
+ import pathlib
6
+ import secrets
7
+ import sys
8
+ import threading
9
+ import time
10
+
11
+ import click
12
+
13
+ from .. import paths, providers, tasks
14
+ from ..orchestrator import parse_timeout, run_loop
15
+ from .main import cli
16
+
17
+
18
+ @cli.command(
19
+ short_help="Run the autonomous task execution loop",
20
+ )
21
+ @click.option(
22
+ "--retry-delay",
23
+ default=10,
24
+ help=(
25
+ "Seconds to wait before retrying a failed task (to handle rate limits)."
26
+ ),
27
+ )
28
+ @click.option(
29
+ "--yolo/--no-yolo",
30
+ default=True,
31
+ help="Run the runner in YOLO/auto-approve mode.",
32
+ )
33
+ @click.option(
34
+ "--env",
35
+ multiple=True,
36
+ help="Environment variables to set for the runner (e.g. --env KEY=VALUE).",
37
+ )
38
+ @click.option(
39
+ "--no-defaults",
40
+ is_flag=True,
41
+ help="Do not auto-inject default flags (like --yolo) based on runner name.",
42
+ )
43
+ @click.argument("runner_args", nargs=-1, type=click.UNPROCESSED)
44
+ @click.pass_context
45
+ def run(
46
+ ctx: click.Context,
47
+ retry_delay: int,
48
+ yolo: bool,
49
+ env: tuple,
50
+ no_defaults: bool,
51
+ runner_args: tuple,
52
+ ) -> None:
53
+ """Starts the orchestrator loop to autonomously execute pending tasks.
54
+
55
+ Args:
56
+ ctx: The click context.
57
+ retry_delay: Delay between retries.
58
+ yolo: If True, skip runner confirmations.
59
+ env: Environment variables to inject.
60
+ no_defaults: Skip default flag injection.
61
+ runner_args: Raw arguments passed directly to the runner.
62
+ """
63
+ tasks_file = ctx.obj["TASKS_FILE"]
64
+ verbose = ctx.obj["VERBOSE"]
65
+
66
+ # Determine the project's working directory
67
+ working_dir = paths.get_working_dir(tasks_file)
68
+
69
+ # Parse environment overrides
70
+ env_overrides = {}
71
+ for e in env:
72
+ if "=" in e:
73
+ k, v = e.split("=", 1)
74
+ env_overrides[k] = v
75
+ else:
76
+ env_overrides[e] = ""
77
+
78
+ if env_overrides:
79
+ os.environ.update(env_overrides)
80
+
81
+ tasks.acquire_loop_lock(tasks_file)
82
+ try:
83
+ run_loop(
84
+ tasks_file,
85
+ verbose,
86
+ retry_delay,
87
+ yolo,
88
+ no_defaults,
89
+ runner_args,
90
+ working_dir=working_dir,
91
+ )
92
+ finally:
93
+ tasks.release_loop_lock(tasks_file)
94
+
95
+
96
+ @cli.command(short_help="Launch the web interface")
97
+ @click.option("--port", default=8999, help="Port to run the server on.")
98
+ @click.option("--host", default="127.0.0.1", help="Host to bind the server to.")
99
+ @click.option(
100
+ "--tunnel",
101
+ default=None,
102
+ type=click.Choice(["cloudflare", "tailscale"]),
103
+ help="Expose via a public tunnel (cloudflare or tailscale).",
104
+ )
105
+ @click.option(
106
+ "--timeout",
107
+ default=None,
108
+ help=(
109
+ "Auto-shutdown after duration (e.g., '8h', '30m', '0' to disable)."
110
+ " Defaults to '8h' when --tunnel is used."
111
+ ),
112
+ )
113
+ @click.pass_context
114
+ def serve(
115
+ ctx: click.Context,
116
+ port: int,
117
+ host: str,
118
+ tunnel: str | None,
119
+ timeout: str | None,
120
+ ):
121
+ """Launches the local web dashboard for monitoring and interaction.
122
+
123
+ Optionally exposes it to the public internet via --tunnel.
124
+ """
125
+ # Lazy imports: the api package builds the FastAPI app and mounts static
126
+ # assets at import time, so keep the server stack out of CLI startup.
127
+ import uvicorn # noqa: PLC0415
128
+ import uvicorn.config # noqa: PLC0415
129
+
130
+ from .. import api # noqa: PLC0415
131
+
132
+ api.app.state.tasks_file = ctx.obj["TASKS_FILE"]
133
+ api.app.state.verbose = ctx.obj["VERBOSE"]
134
+ api.app.state.root = pathlib.Path.cwd().resolve()
135
+
136
+ tunnel_proc = None
137
+ if tunnel:
138
+ click.echo(f"[ Lemming ] Starting local server on port {port}...")
139
+ click.echo(
140
+ f"[ Lemming ] Initiating public tunnel via {tunnel.capitalize()}..."
141
+ )
142
+
143
+ tunnel_proc = (
144
+ providers.CloudflareProvider()
145
+ if tunnel == "cloudflare"
146
+ else providers.TailscaleProvider()
147
+ )
148
+ try:
149
+ public_url = tunnel_proc.start(port)
150
+ except Exception as e:
151
+ click.echo(f"[ Lemming ] Error starting tunnel: {e}", err=True)
152
+ sys.exit(1)
153
+
154
+ token = secrets.token_urlsafe(32)
155
+ api.app.state.share_token = token
156
+
157
+ click.echo("[ Lemming ] ")
158
+ click.echo("[ Lemming ] ⚠️ SECURITY WARNING ")
159
+ click.echo(
160
+ "[ Lemming ] Your Lemming instance is being exposed to the"
161
+ " public internet."
162
+ )
163
+ click.echo(
164
+ "[ Lemming ] Token-based authentication has been automatically"
165
+ " enabled."
166
+ )
167
+ click.echo("[ Lemming ] ")
168
+ click.echo(
169
+ "[ Lemming ] 🌐 Share this exact, secure link with the remote user:"
170
+ )
171
+ click.echo(f"[ Lemming ] 👉 {public_url}?token={token}")
172
+ click.echo("")
173
+ else:
174
+ click.echo(f"Launching Lemming UI at http://{host}:{port}")
175
+
176
+ # Default timeout to 8h for tunnel mode, 0 (disabled) for local mode.
177
+ timeout_str = timeout if timeout is not None else ("8h" if tunnel else "0")
178
+ timeout_seconds = parse_timeout(timeout_str)
179
+
180
+ if timeout_seconds > 0:
181
+ click.echo(
182
+ "[ Lemming ] The server will automatically shut down"
183
+ f" in {timeout_str}."
184
+ )
185
+
186
+ def monitor():
187
+ time.sleep(timeout_seconds)
188
+ click.echo(
189
+ "\n[ Lemming ] Timeout reached. Waiting for tasks to finish..."
190
+ )
191
+ if tunnel_proc:
192
+ tunnel_proc.stop()
193
+
194
+ tasks_file = api.app.state.tasks_file
195
+ while True:
196
+ project_data = tasks.get_project_data(tasks_file)
197
+ if not project_data.loop_running:
198
+ break
199
+ time.sleep(5)
200
+
201
+ click.echo("[ Lemming ] All tasks finished. Exiting.")
202
+ os._exit(0)
203
+
204
+ monitor_thread = threading.Thread(target=monitor, daemon=True)
205
+ monitor_thread.start()
206
+
207
+ if tunnel:
208
+ click.echo(
209
+ "[ Lemming ] Press Ctrl+C to manually close the tunnel and"
210
+ " shut down the server."
211
+ )
212
+
213
+ # Suppress repetitive access-log lines from UI polling endpoints.
214
+ log_config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
215
+ log_config["filters"] = {
216
+ "quiet_poll": {"()": "lemming.api.QuietPollFilter"},
217
+ }
218
+ log_config["handlers"]["access"]["filters"] = ["quiet_poll"]
219
+
220
+ try:
221
+ uvicorn.run(api.app, host=host, port=port, log_config=log_config)
222
+ except KeyboardInterrupt:
223
+ pass
224
+ finally:
225
+ if tunnel_proc:
226
+ tunnel_proc.stop()