forgeo-cli 0.3.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.
forgeo/runs.py ADDED
@@ -0,0 +1,73 @@
1
+ """Durable run history: one JSON line per finished cycle in ``runs.jsonl``.
2
+
3
+ The file lives next to the backlog (``runs.jsonl`` beside ``backlog.json``)
4
+ so Forgeo, the CLI, and the web API all read the same records. Reading
5
+ tolerates a missing file and skips corrupt lines with a warning, so a broken
6
+ ``runs.jsonl`` never breaks a cycle or the API.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ from pydantic import ValidationError
15
+
16
+ from forgeo.models import RunRecord
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def runs_path_for(backlog_path: str | Path) -> Path:
22
+ """The ``runs.jsonl`` path that sits next to the backlog file."""
23
+ return Path(backlog_path).with_name("runs.jsonl")
24
+
25
+
26
+ class RunRecorder:
27
+ """Appends :class:`RunRecord` rows to a JSON-lines file and reads them back."""
28
+
29
+ def __init__(self, path: str | Path) -> None:
30
+ self.path = Path(path)
31
+
32
+ def append(self, record: RunRecord) -> None:
33
+ """Append ``record`` as one JSON line.
34
+
35
+ A write failure is logged and never raised, so recording can never
36
+ break a Forgeo cycle.
37
+ """
38
+ try:
39
+ self.path.parent.mkdir(parents=True, exist_ok=True)
40
+ with self.path.open("a", encoding="utf-8") as handle:
41
+ handle.write(record.model_dump_json() + "\n")
42
+ except OSError as exc:
43
+ logger.error("Could not write run record to %s: %s", self.path, exc)
44
+
45
+ def read(self, limit: int | None = None) -> list[RunRecord]:
46
+ """Return the newest ``limit`` records, newest first.
47
+
48
+ A missing file yields an empty list; corrupt lines are skipped with a
49
+ warning. ``limit=None`` returns every readable record.
50
+ """
51
+ try:
52
+ text = self.path.read_text(encoding="utf-8", errors="replace")
53
+ except OSError:
54
+ return []
55
+ records: list[RunRecord] = []
56
+ for line_no, line in enumerate(text.splitlines(), start=1):
57
+ if not line.strip():
58
+ continue
59
+ try:
60
+ records.append(RunRecord.model_validate_json(line))
61
+ except ValidationError:
62
+ logger.warning(
63
+ "Skipping corrupt run record in %s on line %s", self.path, line_no
64
+ )
65
+ records.sort(key=lambda record: record.finished_at, reverse=True)
66
+ if limit is None:
67
+ return records
68
+ return records[: max(0, limit)]
69
+
70
+ def read_last(self) -> RunRecord | None:
71
+ """Return the most recent record, or ``None`` when none exists."""
72
+ records = self.read(limit=1)
73
+ return records[0] if records else None
forgeo/setup.py ADDED
@@ -0,0 +1,185 @@
1
+ """Guided first-time setup: ``forgeo init``.
2
+
3
+ Walks the user through the three decisions Forgeo needs before it can
4
+ work on a repository:
5
+
6
+ 1. Forgeo folder — where the backlog, ``BLOCKER.md`` and the log live
7
+ (inside the project, gitignored by default);
8
+ 2. the coding agent command — any shell command that reads ``$FORGEO_TASK``
9
+ and works in the repository (e.g. ``claude -p "$FORGEO_TASK"``);
10
+ 3. the refactoring prompt — the default is offered; a custom one can be
11
+ pasted instead.
12
+
13
+ The result is written as ``forgeo.yaml`` next to the project.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+ from rich.console import Console
23
+ from rich.markup import escape
24
+ from rich.panel import Panel
25
+ from rich.prompt import Confirm, Prompt
26
+
27
+ from forgeo.models import DEFAULT_REFACTOR_PROMPT
28
+
29
+ DEFAULT_FORGEO_DIR = ".forgeo"
30
+ DEFAULT_AGENT_COMMAND = 'aider --message "$FORGEO_TASK"'
31
+
32
+ SetupInput = Callable[[str], str]
33
+
34
+
35
+ def _ask_text(input_fn: SetupInput | None, prompt: str, default: str | None = None) -> str:
36
+ """Free-text question; ``input_fn`` replaces the terminal in tests."""
37
+ if input_fn is not None:
38
+ return input_fn(prompt)
39
+ if default is None:
40
+ return Prompt.ask(prompt)
41
+ return Prompt.ask(prompt, default=default)
42
+
43
+
44
+ def _ask_yes_no(input_fn: SetupInput | None, prompt: str, default: bool = True) -> bool:
45
+ """Yes/no question; ``input_fn`` replaces the terminal in tests."""
46
+ if input_fn is not None:
47
+ return input_fn(prompt).strip().lower() in ("y", "yes")
48
+ return Confirm.ask(prompt, default=default)
49
+
50
+
51
+ def _ask_multiline(input_fn: SetupInput | None, prompt: str, console: Console) -> str:
52
+ """Multi-line answer; an empty line finishes it."""
53
+ if input_fn is None:
54
+ console.print(prompt)
55
+ prompt = "[dim](paste a line; an empty line finishes)[/dim]"
56
+ lines = []
57
+ while True:
58
+ line = input_fn(prompt) if input_fn is not None else Prompt.ask(prompt)
59
+ if not line.strip():
60
+ break
61
+ lines.append(line.strip())
62
+ return "\n".join(lines)
63
+
64
+
65
+ def add_gitignore(project_root: Path, line: str) -> bool:
66
+ """Append ``line`` to ``<project_root>/.gitignore`` when absent."""
67
+ path = project_root / ".gitignore"
68
+ if path.exists():
69
+ content = path.read_text(encoding="utf-8")
70
+ if line in content.splitlines():
71
+ return False
72
+ content = content.rstrip("\n") + "\n" + line + "\n"
73
+ else:
74
+ content = line + "\n"
75
+ path.write_text(content, encoding="utf-8")
76
+ return True
77
+
78
+
79
+ def run_setup(
80
+ base_dir: Path,
81
+ config_path: Path,
82
+ *,
83
+ console: Console | None = None,
84
+ input_fn: SetupInput | None = None,
85
+ ) -> dict[str, object] | None:
86
+ """Interactively collect the configuration and write it to ``config_path``.
87
+
88
+ Args:
89
+ base_dir: Directory the config lives in (the project root); all
90
+ generated paths are relative to it.
91
+ config_path: Where to write the YAML config.
92
+ console: Rich console for output (a new one when omitted).
93
+ input_fn: Replacement for the terminal prompts (tests).
94
+
95
+ Returns the written YAML payload, or ``None`` when the setup was aborted.
96
+ """
97
+ out = console or Console()
98
+ root = base_dir.resolve()
99
+ if not (root / ".git").exists():
100
+ out.print(
101
+ "[yellow]Warning: no .git directory here — Forgeo works on a git "
102
+ "repository.[/yellow]"
103
+ )
104
+
105
+ forgeo_dir = _ask_text(
106
+ input_fn,
107
+ f"[bold]Forgeo folder[/bold] for backlog, BLOCKER.md and logs "
108
+ f"[default {DEFAULT_FORGEO_DIR}]",
109
+ default=DEFAULT_FORGEO_DIR,
110
+ ).strip()
111
+ forgeo_dir = forgeo_dir.removeprefix("./").rstrip("/") or DEFAULT_FORGEO_DIR
112
+ if Path(forgeo_dir).is_absolute():
113
+ out.print("[red]Forgeo folder must live inside the project. Aborting.[/red]")
114
+ return None
115
+ if ".." in Path(forgeo_dir).parts:
116
+ out.print(
117
+ "[yellow]Note: Forgeo folder escapes the project root — the "
118
+ "gitignore rule will not protect it.[/yellow]"
119
+ )
120
+
121
+ command = _ask_text(
122
+ input_fn,
123
+ f"[bold]Coding agent command[/bold] [default {DEFAULT_AGENT_COMMAND}]",
124
+ default=DEFAULT_AGENT_COMMAND,
125
+ ).strip() or DEFAULT_AGENT_COMMAND
126
+ if "$FORGEO_TASK" not in command:
127
+ out.print(
128
+ "[yellow]Note: the command never references $FORGEO_TASK, so the "
129
+ "agent will not receive the task text.[/yellow]"
130
+ )
131
+
132
+ if _ask_yes_no(input_fn, "[bold]Use the default refactor prompt?[/bold]", default=True):
133
+ refactor_prompt = DEFAULT_REFACTOR_PROMPT
134
+ else:
135
+ out.print("[bold]Your refactor prompt[/bold] (used when the backlog is empty):")
136
+ refactor_prompt = (
137
+ _ask_multiline(input_fn, "[dim](paste a line; empty line finishes)[/dim]", out)
138
+ or DEFAULT_REFACTOR_PROMPT
139
+ )
140
+
141
+ if _ask_yes_no(
142
+ input_fn,
143
+ f"[bold]Add '{escape(forgeo_dir)}/' to .gitignore?[/bold]",
144
+ default=True,
145
+ ):
146
+ if add_gitignore(root, forgeo_dir + "/"):
147
+ out.print(f"[green]Added {forgeo_dir}/ to .gitignore.[/green]")
148
+ else:
149
+ out.print(f"[dim]{forgeo_dir}/ already in .gitignore.[/dim]")
150
+
151
+ payload = {
152
+ "name": root.name or "my-forgeo",
153
+ "repo": ".",
154
+ "interval_minutes": 60,
155
+ "branch": "main",
156
+ "backlog": f"{forgeo_dir}/backlog.json",
157
+ "blocker_file": f"{forgeo_dir}/BLOCKER.md",
158
+ "agent_command": command,
159
+ "refactor_prompt": refactor_prompt,
160
+ "log_file": f"{forgeo_dir}/forgeo.log",
161
+ }
162
+
163
+ config_path.parent.mkdir(parents=True, exist_ok=True)
164
+ body = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
165
+ config_path.write_text(
166
+ "# Forgeo configuration — generated by `forgeo init`.\n"
167
+ "# Relative paths resolve against this file's directory.\n"
168
+ "# Re-run `forgeo init --force` to regenerate. See README.md for all keys.\n\n"
169
+ + body,
170
+ encoding="utf-8",
171
+ )
172
+ (root / forgeo_dir).mkdir(parents=True, exist_ok=True)
173
+
174
+ out.print(
175
+ Panel.fit(
176
+ f"[bold]Forgeo configured[/bold] in {config_path}\n"
177
+ f"[bold]Repo:[/bold] {root}\n"
178
+ f"[bold]Backlog:[/bold] {(root / forgeo_dir) / 'backlog.json'}\n"
179
+ f"[bold]Agent:[/bold] {escape(command)}\n"
180
+ f"[bold]Next:[/bold] forgeo start --config {config_path.name}",
181
+ title="Forgeo",
182
+ border_style="green",
183
+ )
184
+ )
185
+ return payload
@@ -0,0 +1,380 @@
1
+ /* Extra component styles for the central dashboard (home + instance pages).
2
+ The dark theme, board, status columns and task cards come from style.css. */
3
+
4
+ /* ---------- Instance list (home) ---------- */
5
+
6
+ .instance-list {
7
+ flex: 1;
8
+ padding: 32px 0 40px;
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: 14px;
12
+ }
13
+
14
+ .instance-card {
15
+ display: block;
16
+ background: var(--surface);
17
+ border: 1px solid var(--border);
18
+ border-radius: var(--radius);
19
+ padding: 16px 18px;
20
+ color: var(--text);
21
+ text-decoration: none;
22
+ transition: transform 0.15s ease, border-color 0.18s ease, background 0.18s ease;
23
+ }
24
+
25
+ .instance-card:hover {
26
+ transform: translateY(-1px);
27
+ background: var(--surface-hover);
28
+ border-color: var(--border-strong);
29
+ }
30
+
31
+ .instance-card__head {
32
+ display: flex;
33
+ align-items: baseline;
34
+ justify-content: space-between;
35
+ gap: 10px;
36
+ margin-bottom: 12px;
37
+ }
38
+
39
+ .instance-card__name {
40
+ font-size: 16px;
41
+ font-weight: 600;
42
+ letter-spacing: 0.01em;
43
+ }
44
+
45
+ .instance-card__grid {
46
+ display: grid;
47
+ grid-template-columns: repeat(3, minmax(0, 1fr));
48
+ gap: 14px;
49
+ }
50
+
51
+ .instance-card__info {
52
+ display: flex;
53
+ flex-direction: column;
54
+ gap: 2px;
55
+ min-width: 0;
56
+ }
57
+
58
+ .instance-card__label {
59
+ font-size: 11px;
60
+ text-transform: uppercase;
61
+ letter-spacing: 0.09em;
62
+ color: var(--text-faint);
63
+ }
64
+
65
+ .instance-card__value {
66
+ font-size: 13px;
67
+ color: var(--text-muted);
68
+ font-variant-numeric: tabular-nums;
69
+ white-space: nowrap;
70
+ overflow: hidden;
71
+ text-overflow: ellipsis;
72
+ }
73
+
74
+ .instance-card__counts {
75
+ display: flex;
76
+ gap: 8px;
77
+ flex-wrap: wrap;
78
+ }
79
+
80
+ .count-chip {
81
+ font-size: 11px;
82
+ font-weight: 600;
83
+ letter-spacing: 0.06em;
84
+ padding: 3px 9px;
85
+ border-radius: 999px;
86
+ border: 1px solid var(--border);
87
+ color: var(--text-muted);
88
+ font-variant-numeric: tabular-nums;
89
+ }
90
+
91
+ .count-chip--OPEN {
92
+ color: var(--open);
93
+ background: rgba(143, 183, 255, 0.1);
94
+ border-color: transparent;
95
+ }
96
+ .count-chip--BLOCKED {
97
+ color: var(--blocked);
98
+ background: rgba(240, 168, 77, 0.16);
99
+ border-color: transparent;
100
+ }
101
+ .count-chip--COMPLETED {
102
+ color: var(--completed);
103
+ background: rgba(111, 206, 147, 0.1);
104
+ border-color: transparent;
105
+ }
106
+ .count-chip--FAILED {
107
+ color: var(--failed);
108
+ background: rgba(224, 108, 117, 0.1);
109
+ border-color: transparent;
110
+ }
111
+
112
+ /* ---------- Daemon badge (instance page header) ---------- */
113
+
114
+ .daemon-badge {
115
+ font-size: 11px;
116
+ font-weight: 600;
117
+ letter-spacing: 0.08em;
118
+ text-transform: uppercase;
119
+ padding: 3px 9px;
120
+ border-radius: 999px;
121
+ white-space: nowrap;
122
+ color: var(--text-muted);
123
+ border: 1px solid var(--border);
124
+ }
125
+
126
+ .daemon-badge--running {
127
+ color: var(--completed);
128
+ background: rgba(111, 206, 147, 0.1);
129
+ border-color: rgba(111, 206, 147, 0.35);
130
+ }
131
+
132
+ .daemon-badge--stopped {
133
+ color: var(--failed);
134
+ background: rgba(224, 108, 117, 0.1);
135
+ border-color: rgba(224, 108, 117, 0.35);
136
+ }
137
+
138
+ /* ---------- Tabs ---------- */
139
+
140
+ .tabs {
141
+ display: flex;
142
+ gap: 6px;
143
+ padding: 14px 0 0;
144
+ border-bottom: 1px solid var(--border);
145
+ overflow-x: auto;
146
+ }
147
+
148
+ .tab {
149
+ appearance: none;
150
+ background: none;
151
+ border: none;
152
+ border-bottom: 2px solid transparent;
153
+ color: var(--text-muted);
154
+ font: inherit;
155
+ font-size: 13px;
156
+ font-weight: 600;
157
+ letter-spacing: 0.04em;
158
+ padding: 10px 14px 12px;
159
+ cursor: pointer;
160
+ white-space: nowrap;
161
+ transition: color 0.15s ease, border-color 0.15s ease;
162
+ }
163
+
164
+ .tab:hover {
165
+ color: var(--text);
166
+ }
167
+
168
+ .tab.is-active {
169
+ color: var(--text);
170
+ border-bottom-color: var(--accent);
171
+ }
172
+
173
+ .tab-panel {
174
+ flex: 1;
175
+ padding: 20px 0 40px;
176
+ }
177
+
178
+ .tab-panel[hidden] {
179
+ display: none;
180
+ }
181
+
182
+ /* ---------- Code panels (logs / blocker / config) ---------- */
183
+
184
+ .code-panel {
185
+ margin: 0;
186
+ background: var(--surface);
187
+ border: 1px solid var(--border);
188
+ border-radius: var(--radius);
189
+ padding: 16px 18px;
190
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
191
+ font-size: 12.5px;
192
+ line-height: 1.6;
193
+ color: var(--text-muted);
194
+ white-space: pre-wrap;
195
+ word-break: break-word;
196
+ max-height: 70vh;
197
+ overflow: auto;
198
+ }
199
+
200
+ /* ---------- Run table ---------- */
201
+
202
+ .table-wrap {
203
+ background: var(--surface);
204
+ border: 1px solid var(--border);
205
+ border-radius: var(--radius);
206
+ overflow: auto;
207
+ }
208
+
209
+ .run-table {
210
+ width: 100%;
211
+ border-collapse: collapse;
212
+ font-size: 13px;
213
+ }
214
+
215
+ .run-table th,
216
+ .run-table td {
217
+ text-align: left;
218
+ padding: 10px 14px;
219
+ border-bottom: 1px solid var(--border);
220
+ white-space: nowrap;
221
+ font-variant-numeric: tabular-nums;
222
+ }
223
+
224
+ .run-table th {
225
+ font-size: 11px;
226
+ text-transform: uppercase;
227
+ letter-spacing: 0.09em;
228
+ color: var(--text-faint);
229
+ }
230
+
231
+ .run-table tbody tr:last-child td {
232
+ border-bottom: none;
233
+ }
234
+
235
+ .run-table tbody tr:hover {
236
+ background: var(--surface-hover);
237
+ }
238
+
239
+ .run-table .mono {
240
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
241
+ font-size: 12px;
242
+ }
243
+
244
+ /* ---------- Footer ---------- */
245
+
246
+ .footer-link {
247
+ color: var(--accent);
248
+ font-weight: 600;
249
+ text-decoration: none;
250
+ }
251
+
252
+ .footer-link:hover {
253
+ text-decoration: underline;
254
+ }
255
+
256
+ /* ---------- Task detail modal: edit mode ---------- */
257
+
258
+ #task-modal-view {
259
+ display: flex;
260
+ flex-direction: column;
261
+ gap: 16px;
262
+ }
263
+
264
+ #task-modal-view[hidden] {
265
+ display: none;
266
+ }
267
+
268
+ .modal__head-actions {
269
+ flex: none;
270
+ display: flex;
271
+ align-items: center;
272
+ gap: 8px;
273
+ }
274
+
275
+ .modal__btn {
276
+ appearance: none;
277
+ background: var(--surface-hover);
278
+ border: 1px solid var(--border);
279
+ border-radius: var(--radius-sm);
280
+ color: var(--text-muted);
281
+ font: inherit;
282
+ font-size: 13px;
283
+ font-weight: 600;
284
+ padding: 6px 14px;
285
+ cursor: pointer;
286
+ transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
287
+ }
288
+
289
+ .modal__btn:hover {
290
+ color: var(--text);
291
+ border-color: var(--border-strong);
292
+ background: var(--border);
293
+ }
294
+
295
+ .modal__btn--primary {
296
+ background: var(--accent-dim);
297
+ border-color: rgba(124, 140, 255, 0.45);
298
+ color: var(--accent);
299
+ }
300
+
301
+ .modal__btn--primary:hover {
302
+ background: rgba(124, 140, 255, 0.24);
303
+ border-color: var(--accent);
304
+ color: var(--accent);
305
+ }
306
+
307
+ .modal__edit {
308
+ display: flex;
309
+ flex-direction: column;
310
+ gap: 14px;
311
+ }
312
+
313
+ .modal__edit[hidden] {
314
+ display: none;
315
+ }
316
+
317
+ .modal__edit-field {
318
+ display: flex;
319
+ flex-direction: column;
320
+ gap: 5px;
321
+ }
322
+
323
+ .modal__edit-field label {
324
+ font-size: 11px;
325
+ font-weight: 600;
326
+ text-transform: uppercase;
327
+ letter-spacing: 0.09em;
328
+ color: var(--text-faint);
329
+ }
330
+
331
+ .modal__edit-field input,
332
+ .modal__edit-field textarea {
333
+ width: 100%;
334
+ background: var(--bg);
335
+ border: 1px solid var(--border);
336
+ border-radius: var(--radius-sm);
337
+ color: var(--text);
338
+ font: inherit;
339
+ font-size: 14px;
340
+ padding: 8px 10px;
341
+ outline: none;
342
+ resize: vertical;
343
+ transition: border-color 0.18s ease, background 0.18s ease;
344
+ }
345
+
346
+ .modal__edit-field input:focus,
347
+ .modal__edit-field textarea:focus {
348
+ border-color: var(--accent);
349
+ background: var(--surface-hover);
350
+ }
351
+
352
+ .modal__edit-actions {
353
+ display: flex;
354
+ justify-content: flex-end;
355
+ gap: 8px;
356
+ }
357
+
358
+ .modal__error {
359
+ margin: 0;
360
+ font-size: 13px;
361
+ color: var(--failed);
362
+ }
363
+
364
+ .modal__error[hidden] {
365
+ display: none;
366
+ }
367
+
368
+ /* ---------- Responsive ---------- */
369
+
370
+ @media (max-width: 860px) {
371
+ .instance-card__grid {
372
+ grid-template-columns: 1fr 1fr;
373
+ }
374
+ }
375
+
376
+ @media (max-width: 560px) {
377
+ .instance-card__grid {
378
+ grid-template-columns: 1fr;
379
+ }
380
+ }