execforge 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.
- execforge-0.1.0.dist-info/METADATA +367 -0
- execforge-0.1.0.dist-info/RECORD +44 -0
- execforge-0.1.0.dist-info/WHEEL +5 -0
- execforge-0.1.0.dist-info/entry_points.txt +5 -0
- execforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- execforge-0.1.0.dist-info/top_level.txt +1 -0
- orchestrator/__init__.py +4 -0
- orchestrator/__main__.py +5 -0
- orchestrator/backends/__init__.py +1 -0
- orchestrator/backends/base.py +29 -0
- orchestrator/backends/factory.py +53 -0
- orchestrator/backends/llm_cli_backend.py +87 -0
- orchestrator/backends/mock_backend.py +34 -0
- orchestrator/backends/shell_backend.py +49 -0
- orchestrator/cli/__init__.py +1 -0
- orchestrator/cli/main.py +971 -0
- orchestrator/config.py +272 -0
- orchestrator/domain/__init__.py +1 -0
- orchestrator/domain/types.py +77 -0
- orchestrator/exceptions.py +18 -0
- orchestrator/git/__init__.py +1 -0
- orchestrator/git/service.py +202 -0
- orchestrator/logging_setup.py +53 -0
- orchestrator/prompts/__init__.py +1 -0
- orchestrator/prompts/parser.py +91 -0
- orchestrator/reporting/__init__.py +1 -0
- orchestrator/reporting/console.py +197 -0
- orchestrator/reporting/events.py +44 -0
- orchestrator/reporting/selection_result.py +15 -0
- orchestrator/services/__init__.py +1 -0
- orchestrator/services/agent_runner.py +831 -0
- orchestrator/services/agent_service.py +122 -0
- orchestrator/services/project_service.py +47 -0
- orchestrator/services/prompt_source_service.py +65 -0
- orchestrator/services/run_service.py +42 -0
- orchestrator/services/step_executor.py +100 -0
- orchestrator/services/task_service.py +155 -0
- orchestrator/storage/__init__.py +1 -0
- orchestrator/storage/db.py +29 -0
- orchestrator/storage/models.py +95 -0
- orchestrator/utils/__init__.py +1 -0
- orchestrator/utils/process.py +44 -0
- orchestrator/validation/__init__.py +1 -0
- orchestrator/validation/pipeline.py +52 -0
orchestrator/cli/main.py
ADDED
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from typer import Context
|
|
9
|
+
|
|
10
|
+
from orchestrator.config import (
|
|
11
|
+
AppConfig,
|
|
12
|
+
ensure_app_dirs,
|
|
13
|
+
get_app_paths,
|
|
14
|
+
load_config,
|
|
15
|
+
save_config,
|
|
16
|
+
)
|
|
17
|
+
from orchestrator.config import (
|
|
18
|
+
config_to_display_dict,
|
|
19
|
+
get_config_schema,
|
|
20
|
+
reset_config_values,
|
|
21
|
+
update_config_values,
|
|
22
|
+
)
|
|
23
|
+
from orchestrator.exceptions import ConfigError, OrchestratorError
|
|
24
|
+
from orchestrator.git.service import GitService
|
|
25
|
+
from orchestrator.logging_setup import configure_logging
|
|
26
|
+
from orchestrator.reporting.console import ConsoleReporter
|
|
27
|
+
from orchestrator.services.agent_runner import AgentRunner
|
|
28
|
+
from orchestrator.services.agent_service import AgentService
|
|
29
|
+
from orchestrator.services.project_service import ProjectService
|
|
30
|
+
from orchestrator.services.prompt_source_service import PromptSourceService
|
|
31
|
+
from orchestrator.services.run_service import RunService
|
|
32
|
+
from orchestrator.services.task_service import TaskService
|
|
33
|
+
from orchestrator.storage.db import init_db, make_engine, session_scope
|
|
34
|
+
from orchestrator.storage.models import ProjectRepoORM, PromptSourceORM
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
app = typer.Typer(
|
|
38
|
+
help=(
|
|
39
|
+
"ExecForge runs tasks from a prompt source repo against a project repo.\n\n"
|
|
40
|
+
"Typical flow:\n"
|
|
41
|
+
" 1) execforge init\n"
|
|
42
|
+
" 2) execforge prompt-source add ... && execforge prompt-source sync ...\n"
|
|
43
|
+
" 3) execforge project add ...\n"
|
|
44
|
+
" 4) execforge agent add ...\n"
|
|
45
|
+
" 5) execforge agent run <agent> or execforge agent loop <agent>\n\n"
|
|
46
|
+
"Examples:\n"
|
|
47
|
+
" execforge agent list\n"
|
|
48
|
+
" execforge agent run ollama-test\n"
|
|
49
|
+
" execforge agent loop ollama-test --all-eligible-prompts\n"
|
|
50
|
+
" execforge run list\n"
|
|
51
|
+
" execforge status"
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
prompt_source_app = typer.Typer(help="Manage prompt sources")
|
|
55
|
+
project_app = typer.Typer(help="Manage project repositories")
|
|
56
|
+
agent_app = typer.Typer(
|
|
57
|
+
help=(
|
|
58
|
+
"Manage and run agents.\n\n"
|
|
59
|
+
"Examples:\n"
|
|
60
|
+
" execforge agent\n"
|
|
61
|
+
" execforge agent list --compact\n"
|
|
62
|
+
" execforge agent run ollama-test\n"
|
|
63
|
+
" execforge agent loop ollama-test --all-eligible-prompts"
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
task_app = typer.Typer(help="Inspect discovered tasks")
|
|
67
|
+
run_app = typer.Typer(
|
|
68
|
+
help=(
|
|
69
|
+
"Inspect execution runs (history and status), not agent execution.\n\n"
|
|
70
|
+
"Examples:\n"
|
|
71
|
+
" execforge run list\n"
|
|
72
|
+
" execforge run list --limit 100\n"
|
|
73
|
+
" execforge agent run ollama-test"
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
config_app = typer.Typer(help="Configuration commands")
|
|
77
|
+
|
|
78
|
+
app.add_typer(prompt_source_app, name="prompt-source")
|
|
79
|
+
app.add_typer(project_app, name="project")
|
|
80
|
+
app.add_typer(agent_app, name="agent")
|
|
81
|
+
app.add_typer(task_app, name="task")
|
|
82
|
+
app.add_typer(run_app, name="run")
|
|
83
|
+
app.add_typer(config_app, name="config")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@agent_app.callback(invoke_without_command=True)
|
|
87
|
+
def agent_root(ctx: Context):
|
|
88
|
+
"""Default to listing agents when no subcommand is provided."""
|
|
89
|
+
if ctx.invoked_subcommand is None:
|
|
90
|
+
agent_list()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@config_app.callback(invoke_without_command=True)
|
|
94
|
+
def config_root(ctx: Context):
|
|
95
|
+
"""Default to showing config when no subcommand is provided."""
|
|
96
|
+
if ctx.invoked_subcommand is None:
|
|
97
|
+
config_show()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _runtime(console_debug: bool = False, force_debug_logging: bool = False):
|
|
101
|
+
paths = get_app_paths()
|
|
102
|
+
ensure_app_dirs(paths)
|
|
103
|
+
config = load_config(paths)
|
|
104
|
+
level = "DEBUG" if force_debug_logging else config.log_level
|
|
105
|
+
log_path = configure_logging(paths.logs_dir, level, console_debug=console_debug)
|
|
106
|
+
engine = make_engine(str(paths.db_file))
|
|
107
|
+
init_db(engine)
|
|
108
|
+
git = GitService(timeout_seconds=config.default_timeout_seconds)
|
|
109
|
+
return paths, config, engine, git, log_path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _detect_backend_binaries() -> dict[str, bool]:
|
|
113
|
+
return {
|
|
114
|
+
"claude": shutil.which("claude") is not None,
|
|
115
|
+
"codex": shutil.which("codex") is not None,
|
|
116
|
+
"opencode": shutil.which("opencode") is not None,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _wizard_model_settings(
|
|
121
|
+
profile: str, command_template: str, detected: dict[str, bool]
|
|
122
|
+
) -> dict[str, object]:
|
|
123
|
+
if profile == "mock":
|
|
124
|
+
return {
|
|
125
|
+
"backend_priority": ["mock", "shell"],
|
|
126
|
+
"backends": {
|
|
127
|
+
"shell": {"enabled": True},
|
|
128
|
+
"claude": {"enabled": False},
|
|
129
|
+
"codex": {"enabled": False},
|
|
130
|
+
"opencode": {"enabled": False},
|
|
131
|
+
"mock": {"enabled": True},
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if profile == "shell":
|
|
136
|
+
model_settings: dict[str, object] = {
|
|
137
|
+
"backend_priority": ["shell", "mock"],
|
|
138
|
+
"backends": {
|
|
139
|
+
"shell": {"enabled": True},
|
|
140
|
+
"claude": {"enabled": False},
|
|
141
|
+
"codex": {"enabled": False},
|
|
142
|
+
"opencode": {"enabled": False},
|
|
143
|
+
"mock": {"enabled": True},
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
if command_template:
|
|
147
|
+
model_settings["command_template"] = command_template
|
|
148
|
+
return model_settings
|
|
149
|
+
|
|
150
|
+
# auto-multi
|
|
151
|
+
model_settings = {
|
|
152
|
+
"backend_priority": ["codex", "claude", "opencode", "shell", "mock"],
|
|
153
|
+
"backends": {
|
|
154
|
+
"shell": {"enabled": True},
|
|
155
|
+
"claude": {"enabled": detected["claude"]},
|
|
156
|
+
"codex": {"enabled": detected["codex"]},
|
|
157
|
+
"opencode": {"enabled": detected["opencode"]},
|
|
158
|
+
"mock": {"enabled": True},
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
if command_template:
|
|
162
|
+
model_settings["command_template"] = command_template
|
|
163
|
+
return model_settings
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command("init")
|
|
167
|
+
def init_cmd(interactive: bool = typer.Option(True, "--interactive/--no-interactive")):
|
|
168
|
+
"""Initialize app directories, DB, and optional starter resources."""
|
|
169
|
+
paths = get_app_paths()
|
|
170
|
+
if interactive and not paths.root.exists():
|
|
171
|
+
create_home = typer.confirm(
|
|
172
|
+
f"Create ExecForge home folder at '{paths.root}'?", default=True
|
|
173
|
+
)
|
|
174
|
+
if not create_home:
|
|
175
|
+
typer.echo("Initialization cancelled.")
|
|
176
|
+
raise typer.Exit(code=1)
|
|
177
|
+
ensure_app_dirs(paths)
|
|
178
|
+
if not paths.config_file.exists():
|
|
179
|
+
save_config(paths, AppConfig())
|
|
180
|
+
|
|
181
|
+
engine = make_engine(str(paths.db_file))
|
|
182
|
+
init_db(engine)
|
|
183
|
+
typer.echo(f"Initialized ExecForge home at {paths.root}")
|
|
184
|
+
typer.echo(
|
|
185
|
+
"Created: app.db, config.toml, logs/, prompt-sources/, runs/, cache/, locks/"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if not interactive:
|
|
189
|
+
typer.echo("Next steps:")
|
|
190
|
+
typer.echo(" 1) execforge prompt-source add <name> <repo-url>")
|
|
191
|
+
typer.echo(" 2) execforge project add <name> <local-path>")
|
|
192
|
+
typer.echo(
|
|
193
|
+
" 3) execforge agent add <name> <prompt-source-name-or-id> <project-name-or-id>"
|
|
194
|
+
)
|
|
195
|
+
typer.echo(" 4) execforge agent run <name-or-id>")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
with session_scope(engine) as session:
|
|
199
|
+
git = GitService()
|
|
200
|
+
ps_service = PromptSourceService(session, paths, git)
|
|
201
|
+
proj_service = ProjectService(session, git)
|
|
202
|
+
agent_service = AgentService(session)
|
|
203
|
+
|
|
204
|
+
typer.echo("")
|
|
205
|
+
typer.echo("Welcome to ExecForge setup.")
|
|
206
|
+
typer.echo(
|
|
207
|
+
"This wizard creates a usable prompt source, project repo, and agent."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
prompt_name = typer.prompt("Prompt source name", default="default-prompts")
|
|
211
|
+
existing_source = ps_service.get(prompt_name)
|
|
212
|
+
if existing_source:
|
|
213
|
+
source = existing_source
|
|
214
|
+
typer.echo(f"Using existing prompt source #{source.id}: {source.name}")
|
|
215
|
+
else:
|
|
216
|
+
repo_url = typer.prompt("Prompt source git URL (or local git path)")
|
|
217
|
+
branch = typer.prompt("Prompt source branch", default="main")
|
|
218
|
+
folder_scope = typer.prompt(
|
|
219
|
+
"Prompt folder scope, repo-relative (blank for repo root, no leading /)",
|
|
220
|
+
default="",
|
|
221
|
+
)
|
|
222
|
+
source = ps_service.add(
|
|
223
|
+
prompt_name, repo_url, branch=branch, folder_scope=folder_scope or None
|
|
224
|
+
)
|
|
225
|
+
typer.echo(f"Created prompt source #{source.id}: {source.name}")
|
|
226
|
+
|
|
227
|
+
if typer.confirm("Sync prompt source now?", default=True):
|
|
228
|
+
bootstrap_missing_branch = typer.confirm(
|
|
229
|
+
"If the configured branch does not exist remotely, create and push it?",
|
|
230
|
+
default=False,
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
ps_service.sync(
|
|
234
|
+
source, bootstrap_missing_branch=bootstrap_missing_branch
|
|
235
|
+
)
|
|
236
|
+
discovered = TaskService(session).discover_and_upsert(source)
|
|
237
|
+
typer.echo(f"Sync complete, discovered {discovered} task file(s)")
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
message = str(exc)
|
|
240
|
+
if (
|
|
241
|
+
"Remote branch" in message
|
|
242
|
+
and "not found" in message
|
|
243
|
+
and not bootstrap_missing_branch
|
|
244
|
+
):
|
|
245
|
+
if typer.confirm(
|
|
246
|
+
f"Branch '{source.branch}' is missing on origin. Create and push it now?",
|
|
247
|
+
default=False,
|
|
248
|
+
):
|
|
249
|
+
try:
|
|
250
|
+
ps_service.sync(source, bootstrap_missing_branch=True)
|
|
251
|
+
discovered = TaskService(session).discover_and_upsert(
|
|
252
|
+
source
|
|
253
|
+
)
|
|
254
|
+
typer.echo(
|
|
255
|
+
f"Sync complete, discovered {discovered} task file(s)"
|
|
256
|
+
)
|
|
257
|
+
except Exception as retry_exc:
|
|
258
|
+
typer.echo(
|
|
259
|
+
f"Warning: prompt source sync failed: {retry_exc}"
|
|
260
|
+
)
|
|
261
|
+
typer.echo(
|
|
262
|
+
"You can retry later with: execforge prompt-source sync <name> --bootstrap-missing-branch"
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
typer.echo(f"Warning: prompt source sync failed: {exc}")
|
|
266
|
+
typer.echo(
|
|
267
|
+
"You can retry later with: execforge prompt-source sync <name> --bootstrap-missing-branch"
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
typer.echo(f"Warning: prompt source sync failed: {exc}")
|
|
271
|
+
typer.echo(
|
|
272
|
+
"You can retry later with: execforge prompt-source sync <name> --bootstrap-missing-branch"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
project_name = typer.prompt("Project repo name", default="default-project")
|
|
276
|
+
existing_project = proj_service.get(project_name)
|
|
277
|
+
if existing_project:
|
|
278
|
+
project = existing_project
|
|
279
|
+
typer.echo(f"Using existing project repo #{project.id}: {project.name}")
|
|
280
|
+
else:
|
|
281
|
+
project_path = typer.prompt(
|
|
282
|
+
"Local project repo path", default=str(Path.cwd())
|
|
283
|
+
)
|
|
284
|
+
while True:
|
|
285
|
+
try:
|
|
286
|
+
project = proj_service.add(project_name, project_path)
|
|
287
|
+
break
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
typer.echo(f"That path could not be added: {exc}")
|
|
290
|
+
project_path = typer.prompt("Enter a valid local git repo path")
|
|
291
|
+
typer.echo(f"Created project repo #{project.id}: {project.name}")
|
|
292
|
+
|
|
293
|
+
detected = _detect_backend_binaries()
|
|
294
|
+
typer.echo("Detected backend CLIs:")
|
|
295
|
+
typer.echo(f" - claude: {'yes' if detected['claude'] else 'no'}")
|
|
296
|
+
typer.echo(f" - codex: {'yes' if detected['codex'] else 'no'}")
|
|
297
|
+
typer.echo(f" - opencode: {'yes' if detected['opencode'] else 'no'}")
|
|
298
|
+
|
|
299
|
+
profile = (
|
|
300
|
+
typer.prompt(
|
|
301
|
+
"Execution profile [auto/shell/mock]",
|
|
302
|
+
default="auto",
|
|
303
|
+
)
|
|
304
|
+
.strip()
|
|
305
|
+
.lower()
|
|
306
|
+
)
|
|
307
|
+
if profile not in {"auto", "shell", "mock"}:
|
|
308
|
+
profile = "auto"
|
|
309
|
+
|
|
310
|
+
default_command = typer.prompt(
|
|
311
|
+
"Default shell command template (optional, used when a shell step has no command)",
|
|
312
|
+
default="",
|
|
313
|
+
)
|
|
314
|
+
model_settings = _wizard_model_settings(
|
|
315
|
+
profile=profile, command_template=default_command, detected=detected
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
validation_policy: list[dict] = []
|
|
319
|
+
if typer.confirm("Add a validation command after each run?", default=False):
|
|
320
|
+
validation_cmd = typer.prompt("Validation command (example: pytest -q)")
|
|
321
|
+
validation_policy.append(
|
|
322
|
+
{"type": "command", "name": "post-run", "command": validation_cmd}
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
safety_settings = {
|
|
326
|
+
"dry_run": False,
|
|
327
|
+
"max_files_changed": 100,
|
|
328
|
+
"max_commits_per_run": 1,
|
|
329
|
+
"require_clean_working_tree": False,
|
|
330
|
+
"allow_push": False,
|
|
331
|
+
"allow_branch_create": True,
|
|
332
|
+
"allowed_commands": ["python", "pytest", "bash", "sh"],
|
|
333
|
+
"timeout_seconds": 900,
|
|
334
|
+
"max_retries": 0,
|
|
335
|
+
"stop_on_validation_failure": True,
|
|
336
|
+
"pull_project_before_run": True,
|
|
337
|
+
"commit_after_each_step": True,
|
|
338
|
+
"approval_mode": "semi-auto",
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
agent_name = typer.prompt("Agent name", default="default-agent")
|
|
342
|
+
existing_agent = agent_service.get(agent_name)
|
|
343
|
+
if existing_agent:
|
|
344
|
+
agent = existing_agent
|
|
345
|
+
typer.echo(f"Using existing agent #{agent.id}: {agent.name}")
|
|
346
|
+
else:
|
|
347
|
+
agent = agent_service.add(
|
|
348
|
+
name=agent_name,
|
|
349
|
+
prompt_source_id=source.id,
|
|
350
|
+
project_repo_id=project.id,
|
|
351
|
+
execution_backend="multi",
|
|
352
|
+
model_settings=model_settings,
|
|
353
|
+
validation_policy=validation_policy,
|
|
354
|
+
safety_settings=safety_settings,
|
|
355
|
+
)
|
|
356
|
+
typer.echo(f"Created agent #{agent.id}: {agent.name}")
|
|
357
|
+
|
|
358
|
+
typer.echo("")
|
|
359
|
+
typer.echo("Setup complete.")
|
|
360
|
+
typer.echo("Try these commands next:")
|
|
361
|
+
typer.echo(f" execforge prompt-source sync {source.name}")
|
|
362
|
+
typer.echo(" execforge task list")
|
|
363
|
+
typer.echo(f" execforge agent run {agent.name}")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@prompt_source_app.command("add")
|
|
367
|
+
def prompt_source_add(
|
|
368
|
+
name: str,
|
|
369
|
+
repo_url: str,
|
|
370
|
+
branch: str = "main",
|
|
371
|
+
folder_scope: str = "",
|
|
372
|
+
sync_strategy: str = "ff-only",
|
|
373
|
+
clone_path: str = "",
|
|
374
|
+
):
|
|
375
|
+
"""Add a new prompt source definition."""
|
|
376
|
+
paths, _, engine, git, _ = _runtime()
|
|
377
|
+
with session_scope(engine) as session:
|
|
378
|
+
svc = PromptSourceService(session, paths, git)
|
|
379
|
+
item = svc.add(
|
|
380
|
+
name=name,
|
|
381
|
+
repo_url=repo_url,
|
|
382
|
+
branch=branch,
|
|
383
|
+
folder_scope=folder_scope or None,
|
|
384
|
+
sync_strategy=sync_strategy,
|
|
385
|
+
clone_path=clone_path or None,
|
|
386
|
+
)
|
|
387
|
+
typer.echo(f"Added prompt source #{item.id}: {item.name}")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@prompt_source_app.command("list")
|
|
391
|
+
def prompt_source_list():
|
|
392
|
+
"""List configured prompt sources."""
|
|
393
|
+
paths, _, engine, git, _ = _runtime()
|
|
394
|
+
with session_scope(engine) as session:
|
|
395
|
+
svc = PromptSourceService(session, paths, git)
|
|
396
|
+
for item in svc.list():
|
|
397
|
+
typer.echo(
|
|
398
|
+
f"{item.id}\t{item.name}\t{item.branch}\t{item.local_clone_path}\tactive={item.active}"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@prompt_source_app.command("sync")
|
|
403
|
+
def prompt_source_sync(
|
|
404
|
+
source: str,
|
|
405
|
+
bootstrap_missing_branch: bool = typer.Option(
|
|
406
|
+
False,
|
|
407
|
+
"--bootstrap-missing-branch/--no-bootstrap-missing-branch",
|
|
408
|
+
help="Create and push prompt branch if it does not exist on origin",
|
|
409
|
+
),
|
|
410
|
+
):
|
|
411
|
+
"""Sync a prompt source and discover task files."""
|
|
412
|
+
paths, _, engine, git, _ = _runtime()
|
|
413
|
+
with session_scope(engine) as session:
|
|
414
|
+
svc = PromptSourceService(session, paths, git)
|
|
415
|
+
item = svc.get(source)
|
|
416
|
+
if not item:
|
|
417
|
+
raise typer.Exit(code=2)
|
|
418
|
+
try:
|
|
419
|
+
svc.sync(item, bootstrap_missing_branch=bootstrap_missing_branch)
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
message = str(exc)
|
|
422
|
+
if (
|
|
423
|
+
"Remote branch" in message
|
|
424
|
+
and "not found" in message
|
|
425
|
+
and not bootstrap_missing_branch
|
|
426
|
+
):
|
|
427
|
+
typer.echo(message)
|
|
428
|
+
typer.echo(
|
|
429
|
+
"Tip: re-run with --bootstrap-missing-branch to create and push the branch on origin"
|
|
430
|
+
)
|
|
431
|
+
raise typer.Exit(code=2)
|
|
432
|
+
raise
|
|
433
|
+
count = TaskService(session).discover_and_upsert(item)
|
|
434
|
+
typer.echo(
|
|
435
|
+
f"Synced prompt source '{item.name}' and discovered {count} task files"
|
|
436
|
+
)
|
|
437
|
+
if count == 0:
|
|
438
|
+
typer.echo(
|
|
439
|
+
"Hint: no task files found. Check folder scope and task file format."
|
|
440
|
+
)
|
|
441
|
+
else:
|
|
442
|
+
typer.echo("Next: execforge task list")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@project_app.command("add")
|
|
446
|
+
def project_add(
|
|
447
|
+
name: str,
|
|
448
|
+
local_path: str,
|
|
449
|
+
default_branch: str = "main",
|
|
450
|
+
allowed_branch_pattern: str = "agent/*",
|
|
451
|
+
):
|
|
452
|
+
"""Register a local project repository."""
|
|
453
|
+
_, _, engine, git, _ = _runtime()
|
|
454
|
+
with session_scope(engine) as session:
|
|
455
|
+
item = ProjectService(session, git).add(
|
|
456
|
+
name, local_path, default_branch, allowed_branch_pattern
|
|
457
|
+
)
|
|
458
|
+
typer.echo(f"Added project repo #{item.id}: {item.name}")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@project_app.command("list")
|
|
462
|
+
def project_list():
|
|
463
|
+
"""List registered project repositories."""
|
|
464
|
+
_, _, engine, git, _ = _runtime()
|
|
465
|
+
with session_scope(engine) as session:
|
|
466
|
+
for item in ProjectService(session, git).list():
|
|
467
|
+
typer.echo(
|
|
468
|
+
f"{item.id}\t{item.name}\t{item.local_path}\tdefault={item.default_branch}"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@agent_app.command("add")
|
|
473
|
+
def agent_add(
|
|
474
|
+
name: str,
|
|
475
|
+
prompt_source: str,
|
|
476
|
+
project_repo: str,
|
|
477
|
+
execution_backend: str = "multi",
|
|
478
|
+
command_template: str = "",
|
|
479
|
+
enable_claude: bool = False,
|
|
480
|
+
enable_codex: bool = False,
|
|
481
|
+
enable_opencode: bool = False,
|
|
482
|
+
enable_mock: bool = False,
|
|
483
|
+
):
|
|
484
|
+
"""Create an agent using prompt source and project (name or id)."""
|
|
485
|
+
paths, _, engine, git, _ = _runtime()
|
|
486
|
+
model_settings: dict[str, object] = {
|
|
487
|
+
"backend_priority": ["codex", "claude", "opencode", "shell", "mock"],
|
|
488
|
+
"backends": {
|
|
489
|
+
"shell": {"enabled": True},
|
|
490
|
+
"claude": {"enabled": enable_claude},
|
|
491
|
+
"codex": {"enabled": enable_codex},
|
|
492
|
+
"opencode": {"enabled": enable_opencode},
|
|
493
|
+
"mock": {"enabled": True},
|
|
494
|
+
},
|
|
495
|
+
}
|
|
496
|
+
if command_template:
|
|
497
|
+
model_settings["command_template"] = command_template
|
|
498
|
+
safety_settings = {
|
|
499
|
+
"dry_run": False,
|
|
500
|
+
"max_files_changed": 100,
|
|
501
|
+
"max_commits_per_run": 1,
|
|
502
|
+
"require_clean_working_tree": False,
|
|
503
|
+
"allow_push": False,
|
|
504
|
+
"allow_branch_create": True,
|
|
505
|
+
"allowed_commands": ["python", "pytest", "bash", "sh"],
|
|
506
|
+
"timeout_seconds": 900,
|
|
507
|
+
"max_retries": 0,
|
|
508
|
+
"stop_on_validation_failure": True,
|
|
509
|
+
"pull_project_before_run": True,
|
|
510
|
+
"commit_after_each_step": True,
|
|
511
|
+
"approval_mode": "semi-auto",
|
|
512
|
+
}
|
|
513
|
+
with session_scope(engine) as session:
|
|
514
|
+
prompt_service = PromptSourceService(session, paths, git)
|
|
515
|
+
project_service = ProjectService(session, git)
|
|
516
|
+
|
|
517
|
+
source = prompt_service.get(prompt_source)
|
|
518
|
+
if not source:
|
|
519
|
+
typer.echo(f"Prompt source not found: {prompt_source}")
|
|
520
|
+
raise typer.Exit(code=2)
|
|
521
|
+
|
|
522
|
+
project = project_service.get(project_repo)
|
|
523
|
+
if not project:
|
|
524
|
+
typer.echo(f"Project repo not found: {project_repo}")
|
|
525
|
+
raise typer.Exit(code=2)
|
|
526
|
+
|
|
527
|
+
item = AgentService(session).add(
|
|
528
|
+
name=name,
|
|
529
|
+
prompt_source_id=source.id,
|
|
530
|
+
project_repo_id=project.id,
|
|
531
|
+
execution_backend=execution_backend,
|
|
532
|
+
model_settings=model_settings,
|
|
533
|
+
safety_settings=safety_settings,
|
|
534
|
+
)
|
|
535
|
+
typer.echo(f"Added agent #{item.id}: {item.name}")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@agent_app.command("list")
|
|
539
|
+
def agent_list(
|
|
540
|
+
compact: bool = typer.Option(
|
|
541
|
+
False, "--compact", help="Show one-line summary instead of full JSON blocks"
|
|
542
|
+
),
|
|
543
|
+
):
|
|
544
|
+
"""List agents with full config blocks."""
|
|
545
|
+
_, _, engine, _, _ = _runtime()
|
|
546
|
+
with session_scope(engine) as session:
|
|
547
|
+
agents = AgentService(session).list()
|
|
548
|
+
for idx, a in enumerate(agents, start=1):
|
|
549
|
+
prompt_source = session.get(PromptSourceORM, a.prompt_source_id)
|
|
550
|
+
project = session.get(ProjectRepoORM, a.project_repo_id)
|
|
551
|
+
|
|
552
|
+
if compact:
|
|
553
|
+
typer.echo(
|
|
554
|
+
f"{a.name}\tbackend={a.execution_backend}\tprompt={prompt_source.name if prompt_source else '?'}\tproject={project.name if project else '?'}\tactive={a.active}"
|
|
555
|
+
)
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
payload = {
|
|
559
|
+
"name": a.name,
|
|
560
|
+
"active": a.active,
|
|
561
|
+
"execution_backend": a.execution_backend,
|
|
562
|
+
"task_selector_strategy": a.task_selector_strategy,
|
|
563
|
+
"autonomy_level": a.autonomy_level,
|
|
564
|
+
"max_steps": a.max_steps,
|
|
565
|
+
"push_policy": a.push_policy,
|
|
566
|
+
"prompt_source": {
|
|
567
|
+
"name": prompt_source.name if prompt_source else None,
|
|
568
|
+
"repo_url": prompt_source.repo_url if prompt_source else None,
|
|
569
|
+
"branch": prompt_source.branch if prompt_source else None,
|
|
570
|
+
"folder_scope": prompt_source.folder_scope
|
|
571
|
+
if prompt_source
|
|
572
|
+
else None,
|
|
573
|
+
"sync_strategy": prompt_source.sync_strategy
|
|
574
|
+
if prompt_source
|
|
575
|
+
else None,
|
|
576
|
+
"active": prompt_source.active if prompt_source else None,
|
|
577
|
+
},
|
|
578
|
+
"project": {
|
|
579
|
+
"name": project.name if project else None,
|
|
580
|
+
"local_path": project.local_path if project else None,
|
|
581
|
+
"default_branch": project.default_branch if project else None,
|
|
582
|
+
"allowed_branch_pattern": project.allowed_branch_pattern
|
|
583
|
+
if project
|
|
584
|
+
else None,
|
|
585
|
+
"active": project.active if project else None,
|
|
586
|
+
},
|
|
587
|
+
"model_settings": json.loads(a.model_settings_json or "{}"),
|
|
588
|
+
"safety_settings": json.loads(a.safety_settings_json or "{}"),
|
|
589
|
+
"validation_policy": json.loads(a.validation_policy_json or "[]"),
|
|
590
|
+
"commit_policy": json.loads(a.commit_policy_json or "{}"),
|
|
591
|
+
}
|
|
592
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
593
|
+
if idx < len(agents):
|
|
594
|
+
typer.echo("")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@agent_app.command("update")
|
|
598
|
+
def agent_update(
|
|
599
|
+
agent: str,
|
|
600
|
+
set_pair: list[str] = typer.Option(
|
|
601
|
+
[],
|
|
602
|
+
"--set",
|
|
603
|
+
"-s",
|
|
604
|
+
help="Update agent config with key=value (repeatable)",
|
|
605
|
+
),
|
|
606
|
+
):
|
|
607
|
+
"""Update agent configuration values."""
|
|
608
|
+
if not set_pair:
|
|
609
|
+
typer.echo("No updates provided. Use --set key=value")
|
|
610
|
+
raise typer.Exit(code=2)
|
|
611
|
+
updates: dict[str, str] = {}
|
|
612
|
+
for pair in set_pair:
|
|
613
|
+
if "=" not in pair:
|
|
614
|
+
typer.echo(f"Invalid --set value '{pair}', expected key=value")
|
|
615
|
+
raise typer.Exit(code=2)
|
|
616
|
+
k, v = pair.split("=", 1)
|
|
617
|
+
updates[k.strip()] = v.strip()
|
|
618
|
+
|
|
619
|
+
_, _, engine, _, _ = _runtime()
|
|
620
|
+
with session_scope(engine) as session:
|
|
621
|
+
svc = AgentService(session)
|
|
622
|
+
item = svc.get(agent)
|
|
623
|
+
if not item:
|
|
624
|
+
typer.echo("Agent not found")
|
|
625
|
+
raise typer.Exit(code=2)
|
|
626
|
+
updated = svc.update(item, updates)
|
|
627
|
+
typer.echo(f"Updated agent '{updated.name}'")
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@agent_app.command("delete")
|
|
631
|
+
def agent_delete(
|
|
632
|
+
agent: str,
|
|
633
|
+
yes: bool = typer.Option(False, "--yes", help="Delete without confirmation"),
|
|
634
|
+
):
|
|
635
|
+
"""Permanently delete an agent and its run history."""
|
|
636
|
+
_, _, engine, _, _ = _runtime()
|
|
637
|
+
with session_scope(engine) as session:
|
|
638
|
+
svc = AgentService(session)
|
|
639
|
+
item = svc.get(agent)
|
|
640
|
+
if not item:
|
|
641
|
+
typer.echo("Agent not found")
|
|
642
|
+
raise typer.Exit(code=2)
|
|
643
|
+
|
|
644
|
+
if not yes:
|
|
645
|
+
confirmed = typer.confirm(
|
|
646
|
+
f"Permanently delete agent '{item.name}' and its run history?",
|
|
647
|
+
default=False,
|
|
648
|
+
)
|
|
649
|
+
if not confirmed:
|
|
650
|
+
typer.echo("Cancelled")
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
svc.delete_full(item)
|
|
654
|
+
typer.echo(f"Deleted agent '{agent}'")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@agent_app.command("run")
|
|
658
|
+
def agent_run(
|
|
659
|
+
agent: str,
|
|
660
|
+
verbose: bool = typer.Option(
|
|
661
|
+
False, "--verbose", help="Show backend/selection details"
|
|
662
|
+
),
|
|
663
|
+
debug: bool = typer.Option(False, "--debug", help="Show debug stream logs"),
|
|
664
|
+
):
|
|
665
|
+
"""Run one execution cycle for an agent."""
|
|
666
|
+
paths, config, engine, git, log_path = _runtime(
|
|
667
|
+
console_debug=debug, force_debug_logging=debug
|
|
668
|
+
)
|
|
669
|
+
mode = "debug" if debug else ("verbose" if verbose else "default")
|
|
670
|
+
with session_scope(engine) as session:
|
|
671
|
+
svc = AgentService(session)
|
|
672
|
+
item = svc.get(agent)
|
|
673
|
+
if not item:
|
|
674
|
+
typer.echo("Agent not found")
|
|
675
|
+
raise typer.Exit(code=2)
|
|
676
|
+
result = AgentRunner(
|
|
677
|
+
session,
|
|
678
|
+
paths,
|
|
679
|
+
config,
|
|
680
|
+
git,
|
|
681
|
+
reporter=ConsoleReporter(mode=mode),
|
|
682
|
+
log_path=str(log_path),
|
|
683
|
+
).run_once(item)
|
|
684
|
+
if debug:
|
|
685
|
+
typer.echo(json.dumps(result, indent=2))
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
@agent_app.command("loop")
|
|
689
|
+
def agent_loop(
|
|
690
|
+
agent: str,
|
|
691
|
+
interval_seconds: int = 30,
|
|
692
|
+
max_iterations: int = 0,
|
|
693
|
+
verbose: bool = typer.Option(
|
|
694
|
+
False, "--verbose", help="Show backend/selection details"
|
|
695
|
+
),
|
|
696
|
+
debug: bool = typer.Option(False, "--debug", help="Show debug stream logs"),
|
|
697
|
+
only_new_prompts: bool = typer.Option(
|
|
698
|
+
True,
|
|
699
|
+
"--only-new-prompts/--all-eligible-prompts",
|
|
700
|
+
help="Ignore tasks that already existed when loop started (default: only new prompts)",
|
|
701
|
+
),
|
|
702
|
+
reset_only_new_baseline: bool = typer.Option(
|
|
703
|
+
False,
|
|
704
|
+
"--reset-only-new-baseline",
|
|
705
|
+
help="Reset baseline for first loop run, then continue only-new mode",
|
|
706
|
+
),
|
|
707
|
+
):
|
|
708
|
+
"""Run an agent continuously on a polling interval."""
|
|
709
|
+
paths, config, engine, git, log_path = _runtime(
|
|
710
|
+
console_debug=debug, force_debug_logging=debug
|
|
711
|
+
)
|
|
712
|
+
mode = "debug" if debug else ("verbose" if verbose else "default")
|
|
713
|
+
with session_scope(engine) as session:
|
|
714
|
+
svc = AgentService(session)
|
|
715
|
+
item = svc.get(agent)
|
|
716
|
+
if not item:
|
|
717
|
+
typer.echo("Agent not found")
|
|
718
|
+
raise typer.Exit(code=2)
|
|
719
|
+
AgentRunner(
|
|
720
|
+
session,
|
|
721
|
+
paths,
|
|
722
|
+
config,
|
|
723
|
+
git,
|
|
724
|
+
reporter=ConsoleReporter(mode=mode),
|
|
725
|
+
log_path=str(log_path),
|
|
726
|
+
).run_loop(
|
|
727
|
+
item,
|
|
728
|
+
interval_seconds=interval_seconds,
|
|
729
|
+
max_iterations=max_iterations or None,
|
|
730
|
+
only_new_prompts=only_new_prompts,
|
|
731
|
+
reset_only_new_baseline=reset_only_new_baseline,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@task_app.command("list")
|
|
736
|
+
def task_list(status: str = ""):
|
|
737
|
+
"""List discovered tasks, optionally filtered by status."""
|
|
738
|
+
_, _, engine, _, _ = _runtime()
|
|
739
|
+
with session_scope(engine) as session:
|
|
740
|
+
tasks = TaskService(session).list(status or None)
|
|
741
|
+
for t in tasks:
|
|
742
|
+
ref = t.external_id or f"task-{t.id}"
|
|
743
|
+
typer.echo(f"{t.id}\t{ref}\t{t.status}\t{t.priority}\t{t.title}")
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
@task_app.command("inspect")
|
|
747
|
+
def task_inspect(task_id: int):
|
|
748
|
+
"""Inspect details for a single task."""
|
|
749
|
+
_, _, engine, _, _ = _runtime()
|
|
750
|
+
with session_scope(engine) as session:
|
|
751
|
+
task = TaskService(session).get(task_id)
|
|
752
|
+
if not task:
|
|
753
|
+
typer.echo("Task not found")
|
|
754
|
+
raise typer.Exit(code=2)
|
|
755
|
+
typer.echo(f"id: {task.id}")
|
|
756
|
+
typer.echo(f"title: {task.title}")
|
|
757
|
+
typer.echo(f"status: {task.status}")
|
|
758
|
+
typer.echo(f"priority: {task.priority}")
|
|
759
|
+
typer.echo(f"source: {task.source_path}")
|
|
760
|
+
typer.echo("description:")
|
|
761
|
+
typer.echo(task.description)
|
|
762
|
+
parsed = TaskService(session).parse_raw_task(task)
|
|
763
|
+
if parsed.steps:
|
|
764
|
+
typer.echo("steps:")
|
|
765
|
+
for step in parsed.steps:
|
|
766
|
+
prefs = (
|
|
767
|
+
",".join(step.tool_preferences)
|
|
768
|
+
if step.tool_preferences
|
|
769
|
+
else "(default-priority)"
|
|
770
|
+
)
|
|
771
|
+
typer.echo(f" - {step.id} [{step.type}] tools={prefs}")
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
@task_app.command("set-status")
|
|
775
|
+
def task_set_status(task_id: int, status: str):
|
|
776
|
+
"""Update task status (todo, ready, in_progress, done, failed, blocked)."""
|
|
777
|
+
_, _, engine, _, _ = _runtime()
|
|
778
|
+
with session_scope(engine) as session:
|
|
779
|
+
service = TaskService(session)
|
|
780
|
+
try:
|
|
781
|
+
task = service.set_status_by_id(task_id, status)
|
|
782
|
+
except ValueError as exc:
|
|
783
|
+
typer.echo(str(exc))
|
|
784
|
+
raise typer.Exit(code=2)
|
|
785
|
+
if not task:
|
|
786
|
+
typer.echo("Task not found")
|
|
787
|
+
raise typer.Exit(code=2)
|
|
788
|
+
ref = task.external_id or f"task-{task.id}"
|
|
789
|
+
typer.echo(f"Updated {ref} to status={task.status}")
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
@task_app.command("retry")
|
|
793
|
+
def task_retry(task_id: int):
|
|
794
|
+
"""Set a task back to todo so it can run again."""
|
|
795
|
+
task_set_status(task_id=task_id, status="todo")
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@run_app.command("list")
|
|
799
|
+
def run_list(limit: int = 30):
|
|
800
|
+
"""List recent execution runs."""
|
|
801
|
+
_, _, engine, _, _ = _runtime()
|
|
802
|
+
with session_scope(engine) as session:
|
|
803
|
+
runs = RunService(session).list(limit=limit)
|
|
804
|
+
for r in runs:
|
|
805
|
+
typer.echo(
|
|
806
|
+
f"{r.id}\tagent={r.agent_id}\ttask={r.task_id}\tstatus={r.status}\tstart={r.started_at}\tcommit={r.commit_sha or '-'}"
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
@config_app.command("show")
|
|
811
|
+
def config_show():
|
|
812
|
+
"""Show current app configuration (sensitive fields masked)."""
|
|
813
|
+
paths = get_app_paths()
|
|
814
|
+
config = load_config(paths)
|
|
815
|
+
typer.echo(f"home: {paths.root}")
|
|
816
|
+
typer.echo(f"db: {paths.db_file}")
|
|
817
|
+
typer.echo(f"logs: {paths.logs_dir}")
|
|
818
|
+
typer.echo(
|
|
819
|
+
json.dumps(config_to_display_dict(config, mask_sensitive=True), indent=2)
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@config_app.command("set")
|
|
824
|
+
def config_set(
|
|
825
|
+
key: str | None = typer.Argument(None),
|
|
826
|
+
value: str | None = typer.Argument(None),
|
|
827
|
+
set_pair: list[str] = typer.Option(
|
|
828
|
+
[],
|
|
829
|
+
"--set",
|
|
830
|
+
"-s",
|
|
831
|
+
help="Set config using key=value (repeatable)",
|
|
832
|
+
),
|
|
833
|
+
):
|
|
834
|
+
"""Set one or more app configuration values."""
|
|
835
|
+
updates: dict[str, str] = {}
|
|
836
|
+
if key and value is not None:
|
|
837
|
+
updates[key] = value
|
|
838
|
+
for pair in set_pair:
|
|
839
|
+
if "=" not in pair:
|
|
840
|
+
typer.echo(f"Invalid --set value '{pair}', expected key=value")
|
|
841
|
+
raise typer.Exit(code=2)
|
|
842
|
+
k, v = pair.split("=", 1)
|
|
843
|
+
updates[k.strip()] = v.strip()
|
|
844
|
+
if not updates:
|
|
845
|
+
typer.echo("No config updates provided")
|
|
846
|
+
raise typer.Exit(code=2)
|
|
847
|
+
|
|
848
|
+
paths = get_app_paths()
|
|
849
|
+
updated = update_config_values(paths, updates)
|
|
850
|
+
typer.echo("Updated config:")
|
|
851
|
+
typer.echo(
|
|
852
|
+
json.dumps(config_to_display_dict(updated, mask_sensitive=True), indent=2)
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
@config_app.command("reset")
|
|
857
|
+
def config_reset(
|
|
858
|
+
key: list[str] = typer.Argument([], help="Config key(s) to reset"),
|
|
859
|
+
all_keys: bool = typer.Option(
|
|
860
|
+
False, "--all", help="Reset all keys to default values"
|
|
861
|
+
),
|
|
862
|
+
):
|
|
863
|
+
"""Reset one or more config keys to defaults."""
|
|
864
|
+
if not all_keys and not key:
|
|
865
|
+
typer.echo("Specify at least one key or pass --all")
|
|
866
|
+
raise typer.Exit(code=2)
|
|
867
|
+
|
|
868
|
+
paths = get_app_paths()
|
|
869
|
+
updated = reset_config_values(paths, keys=None if all_keys else key)
|
|
870
|
+
typer.echo("Reset config:")
|
|
871
|
+
typer.echo(
|
|
872
|
+
json.dumps(config_to_display_dict(updated, mask_sensitive=True), indent=2)
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
@config_app.command("keys")
|
|
877
|
+
def config_keys():
|
|
878
|
+
"""List editable config keys and metadata."""
|
|
879
|
+
schema = get_config_schema()
|
|
880
|
+
for key, spec in schema.items():
|
|
881
|
+
sensitive = "yes" if spec.sensitive else "no"
|
|
882
|
+
typer.echo(
|
|
883
|
+
f"{key}\ttype={spec.value_type.__name__}\tsensitive={sensitive}\tdefault={spec.default}"
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
@app.command("doctor")
|
|
888
|
+
def doctor():
|
|
889
|
+
"""Run environment and dependency health checks."""
|
|
890
|
+
paths, _, engine, git, log_path = _runtime()
|
|
891
|
+
typer.echo("Doctor")
|
|
892
|
+
typer.echo(f" App home: {paths.root}")
|
|
893
|
+
typer.echo(f" DB file: {paths.db_file}")
|
|
894
|
+
typer.echo(f" Log file: {log_path}")
|
|
895
|
+
with session_scope(engine):
|
|
896
|
+
typer.echo(" SQLite: OK")
|
|
897
|
+
try:
|
|
898
|
+
git.ensure_git_repo(Path.cwd())
|
|
899
|
+
typer.echo(f" Git: OK ({Path.cwd()} is a repo)")
|
|
900
|
+
except Exception:
|
|
901
|
+
typer.echo(" Git: WARN (cwd is not a git repo)")
|
|
902
|
+
typer.echo(
|
|
903
|
+
" Hint: run commands from your project repo when testing git behavior"
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
@app.command("status")
|
|
908
|
+
def status():
|
|
909
|
+
"""Show a quick summary of current setup and last run."""
|
|
910
|
+
paths, _, engine, _, _ = _runtime()
|
|
911
|
+
with session_scope(engine) as session:
|
|
912
|
+
prompt_sources = PromptSourceService(session, paths, GitService()).list()
|
|
913
|
+
projects = ProjectService(session, GitService()).list()
|
|
914
|
+
agents = AgentService(session).list()
|
|
915
|
+
runs = RunService(session).list(limit=1)
|
|
916
|
+
|
|
917
|
+
typer.echo("Execforge Status")
|
|
918
|
+
typer.echo(f" Home: {paths.root}")
|
|
919
|
+
typer.echo(f" Prompt sources: {len(prompt_sources)}")
|
|
920
|
+
typer.echo(f" Project repos: {len(projects)}")
|
|
921
|
+
typer.echo(f" Agents: {len(agents)}")
|
|
922
|
+
if runs:
|
|
923
|
+
last = runs[0]
|
|
924
|
+
typer.echo(
|
|
925
|
+
f" Last run: #{last.id} status={last.status} task={last.task_id} started={last.started_at}"
|
|
926
|
+
)
|
|
927
|
+
else:
|
|
928
|
+
typer.echo(" Last run: none")
|
|
929
|
+
|
|
930
|
+
if not prompt_sources:
|
|
931
|
+
typer.echo(" Next: execforge prompt-source add <name> <repo-url>")
|
|
932
|
+
elif not projects:
|
|
933
|
+
typer.echo(" Next: execforge project add <name> <local-path>")
|
|
934
|
+
elif not agents:
|
|
935
|
+
typer.echo(
|
|
936
|
+
" Next: execforge agent add <name> <prompt-source-name-or-id> <project-name-or-id>"
|
|
937
|
+
)
|
|
938
|
+
else:
|
|
939
|
+
typer.echo(" Next: execforge agent run <agent-name>")
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
@app.command("start")
|
|
943
|
+
def start():
|
|
944
|
+
"""Quick guidance for first-time and daily use."""
|
|
945
|
+
typer.echo("Execforge Start")
|
|
946
|
+
typer.echo(" 1) execforge init")
|
|
947
|
+
typer.echo(" 2) execforge prompt-source add <name> <repo-url>")
|
|
948
|
+
typer.echo(" 3) execforge prompt-source sync <name>")
|
|
949
|
+
typer.echo(" 4) execforge project add <name> <local-path>")
|
|
950
|
+
typer.echo(
|
|
951
|
+
" 5) execforge agent add <name> <prompt-source-name-or-id> <project-name-or-id>"
|
|
952
|
+
)
|
|
953
|
+
typer.echo(" 6) execforge agent run <name> or execforge agent loop <name>")
|
|
954
|
+
typer.echo("")
|
|
955
|
+
typer.echo("Run `execforge status` to see what is already configured.")
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
@app.callback()
|
|
959
|
+
def root_callback():
|
|
960
|
+
"""Autonomous repo orchestration CLI."""
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def main() -> None:
|
|
964
|
+
try:
|
|
965
|
+
app()
|
|
966
|
+
except ConfigError as exc:
|
|
967
|
+
typer.echo(f"Configuration error: {exc}")
|
|
968
|
+
raise typer.Exit(code=2)
|
|
969
|
+
except OrchestratorError as exc:
|
|
970
|
+
typer.echo(f"Error: {exc}")
|
|
971
|
+
raise typer.Exit(code=1)
|