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.
Files changed (44) hide show
  1. execforge-0.1.0.dist-info/METADATA +367 -0
  2. execforge-0.1.0.dist-info/RECORD +44 -0
  3. execforge-0.1.0.dist-info/WHEEL +5 -0
  4. execforge-0.1.0.dist-info/entry_points.txt +5 -0
  5. execforge-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. execforge-0.1.0.dist-info/top_level.txt +1 -0
  7. orchestrator/__init__.py +4 -0
  8. orchestrator/__main__.py +5 -0
  9. orchestrator/backends/__init__.py +1 -0
  10. orchestrator/backends/base.py +29 -0
  11. orchestrator/backends/factory.py +53 -0
  12. orchestrator/backends/llm_cli_backend.py +87 -0
  13. orchestrator/backends/mock_backend.py +34 -0
  14. orchestrator/backends/shell_backend.py +49 -0
  15. orchestrator/cli/__init__.py +1 -0
  16. orchestrator/cli/main.py +971 -0
  17. orchestrator/config.py +272 -0
  18. orchestrator/domain/__init__.py +1 -0
  19. orchestrator/domain/types.py +77 -0
  20. orchestrator/exceptions.py +18 -0
  21. orchestrator/git/__init__.py +1 -0
  22. orchestrator/git/service.py +202 -0
  23. orchestrator/logging_setup.py +53 -0
  24. orchestrator/prompts/__init__.py +1 -0
  25. orchestrator/prompts/parser.py +91 -0
  26. orchestrator/reporting/__init__.py +1 -0
  27. orchestrator/reporting/console.py +197 -0
  28. orchestrator/reporting/events.py +44 -0
  29. orchestrator/reporting/selection_result.py +15 -0
  30. orchestrator/services/__init__.py +1 -0
  31. orchestrator/services/agent_runner.py +831 -0
  32. orchestrator/services/agent_service.py +122 -0
  33. orchestrator/services/project_service.py +47 -0
  34. orchestrator/services/prompt_source_service.py +65 -0
  35. orchestrator/services/run_service.py +42 -0
  36. orchestrator/services/step_executor.py +100 -0
  37. orchestrator/services/task_service.py +155 -0
  38. orchestrator/storage/__init__.py +1 -0
  39. orchestrator/storage/db.py +29 -0
  40. orchestrator/storage/models.py +95 -0
  41. orchestrator/utils/__init__.py +1 -0
  42. orchestrator/utils/process.py +44 -0
  43. orchestrator/validation/__init__.py +1 -0
  44. orchestrator/validation/pipeline.py +52 -0
@@ -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)