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.
- lemming/__init__.py +1 -0
- lemming/api/__init__.py +5 -0
- lemming/api/auth.py +34 -0
- lemming/api/auth_test.py +40 -0
- lemming/api/config.py +85 -0
- lemming/api/config_test.py +84 -0
- lemming/api/conftest.py +204 -0
- lemming/api/context.py +39 -0
- lemming/api/context_test.py +62 -0
- lemming/api/directories.py +57 -0
- lemming/api/directories_test.py +94 -0
- lemming/api/files.py +116 -0
- lemming/api/files_test.py +163 -0
- lemming/api/hooks.py +15 -0
- lemming/api/hooks_test.py +33 -0
- lemming/api/logging.py +16 -0
- lemming/api/logging_test.py +59 -0
- lemming/api/loop.py +45 -0
- lemming/api/loop_test.py +58 -0
- lemming/api/main.py +53 -0
- lemming/api/main_test.py +30 -0
- lemming/api/tasks.py +170 -0
- lemming/api/tasks_test.py +426 -0
- lemming/api.py +5 -0
- lemming/api_test.py +7 -0
- lemming/cli/__init__.py +12 -0
- lemming/cli/config.py +67 -0
- lemming/cli/config_test.py +50 -0
- lemming/cli/context.py +40 -0
- lemming/cli/context_test.py +49 -0
- lemming/cli/hooks.py +147 -0
- lemming/cli/hooks_test.py +50 -0
- lemming/cli/main.py +42 -0
- lemming/cli/main_test.py +21 -0
- lemming/cli/operations.py +226 -0
- lemming/cli/operations_test.py +44 -0
- lemming/cli/progress.py +54 -0
- lemming/cli/progress_test.py +40 -0
- lemming/cli/readability_cli.py +28 -0
- lemming/cli/readability_cli_test.py +57 -0
- lemming/cli/tasks.py +529 -0
- lemming/cli/tasks_test.py +168 -0
- lemming/cli.py +5 -0
- lemming/cli_test.py +22 -0
- lemming/conftest.py +13 -0
- lemming/hooks.py +145 -0
- lemming/hooks_test.py +180 -0
- lemming/integration_test.py +299 -0
- lemming/main.py +6 -0
- lemming/main_test.py +44 -0
- lemming/models.py +88 -0
- lemming/models_test.py +91 -0
- lemming/orchestrator.py +407 -0
- lemming/orchestrator_test.py +468 -0
- lemming/paths.py +245 -0
- lemming/paths_test.py +186 -0
- lemming/persistence.py +179 -0
- lemming/persistence_test.py +150 -0
- lemming/prompts/hooks/readability.md +42 -0
- lemming/prompts/hooks/roadmap.md +61 -0
- lemming/prompts/hooks/testing.md +44 -0
- lemming/prompts/taskrunner.md +69 -0
- lemming/prompts.py +354 -0
- lemming/prompts_test.py +421 -0
- lemming/providers.py +190 -0
- lemming/providers_test.py +73 -0
- lemming/runner.py +327 -0
- lemming/runner_test.py +378 -0
- lemming/tasks/__init__.py +75 -0
- lemming/tasks/lifecycle.py +331 -0
- lemming/tasks/lifecycle_test.py +312 -0
- lemming/tasks/operations.py +258 -0
- lemming/tasks/operations_test.py +172 -0
- lemming/tasks/progress.py +29 -0
- lemming/tasks/progress_test.py +22 -0
- lemming/tasks/queries.py +128 -0
- lemming/tasks/queries_test.py +233 -0
- lemming/web/dashboard.spec.js +350 -0
- lemming/web/dashboard.test.js +998 -0
- lemming/web/favicon.js +40 -0
- lemming/web/favicon.spec.js +242 -0
- lemming/web/files.html +375 -0
- lemming/web/files.spec.js +97 -0
- lemming/web/index.html +983 -0
- lemming/web/index.js +753 -0
- lemming/web/logs.html +358 -0
- lemming/web/logs.test.js +195 -0
- lemming/web/mancha.js +58 -0
- lemming/web/screenshots.spec.js +328 -0
- lemming_cli-0.1.0.dist-info/METADATA +314 -0
- lemming_cli-0.1.0.dist-info/RECORD +94 -0
- lemming_cli-0.1.0.dist-info/WHEEL +4 -0
- lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
lemming/cli/main_test.py
ADDED
|
@@ -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()
|