saga-cli 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {saga_cli-0.2.0 → saga_cli-0.3.0}/PKG-INFO +26 -5
- {saga_cli-0.2.0 → saga_cli-0.3.0}/README.md +25 -4
- {saga_cli-0.2.0 → saga_cli-0.3.0}/pyproject.toml +1 -1
- {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/assets/saga.css +38 -16
- {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/assets/saga.js +58 -21
- {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/assets/tokens.css +6 -8
- saga_cli-0.3.0/saga/cli.py +186 -0
- saga_cli-0.3.0/saga/diff.py +169 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/generate.py +102 -58
- {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/model.py +11 -5
- saga_cli-0.3.0/saga/prompts/saga.md +59 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/render.py +38 -13
- saga_cli-0.3.0/tests/cassettes/generate_anthropic.yaml +162 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/test_cli.py +84 -5
- saga_cli-0.3.0/tests/test_diff.py +217 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/test_generate.py +107 -8
- {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/test_model.py +5 -3
- {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/test_render.py +29 -5
- {saga_cli-0.2.0 → saga_cli-0.3.0}/uv.lock +1 -1
- saga_cli-0.2.0/saga/cli.py +0 -106
- saga_cli-0.2.0/saga/diff.py +0 -79
- saga_cli-0.2.0/saga/prompts/saga.md +0 -50
- saga_cli-0.2.0/tests/cassettes/generate_anthropic.yaml +0 -168
- saga_cli-0.2.0/tests/test_diff.py +0 -95
- {saga_cli-0.2.0 → saga_cli-0.3.0}/.github/CODEOWNERS +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/.gitignore +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/CONTRIBUTING.md +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/LICENSE +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/RELEASING.md +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/docs/example.html +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/__init__.py +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/__main__.py +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/assets/base.css +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/comments.py +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/skills/generate_saga/SKILL.md +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/skills/read_saga_comments/SKILL.md +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/conftest.py +0 -0
- {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/test_comments.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: saga-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Generate a self-contained static HTML saga of a git diff — a chapter-by-chapter guided tour of a change.
|
|
5
5
|
Project-URL: Homepage, https://github.com/JakeBeresford/saga
|
|
6
6
|
Project-URL: Repository, https://github.com/JakeBeresford/saga
|
|
@@ -42,6 +42,8 @@ the data). Open it offline, email it, commit it, or drop it on any static host.
|
|
|
42
42
|
|
|
43
43
|
- Python 3.11+
|
|
44
44
|
- `git`
|
|
45
|
+
- The [`gh`](https://cli.github.com) CLI, authenticated — only for reviewing a PR
|
|
46
|
+
by URL (`saga <pr-url>`) or pushing review comments
|
|
45
47
|
- An API key for your chosen provider, in the standard environment variable:
|
|
46
48
|
`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OPENROUTER_API_KEY` — or a running
|
|
47
49
|
local server (Ollama / LM Studio) and no key, with a `local/` model
|
|
@@ -83,14 +85,32 @@ Run it from anywhere with `--repo`:
|
|
|
83
85
|
saga --repo ~/src/some-project --base main --head my-feature -o out.html
|
|
84
86
|
```
|
|
85
87
|
|
|
88
|
+
### From a GitHub PR URL
|
|
89
|
+
|
|
90
|
+
Point saga straight at a pull request by passing its URL as the first argument.
|
|
91
|
+
No checkout is needed and it works from any directory:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
saga https://github.com/owner/repo/pull/5
|
|
95
|
+
saga https://github.com/owner/repo/pull/5 --model openai/gpt-4o -o pr5.html
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This fetches the PR's diff, commits, and branch names with the
|
|
99
|
+
[`gh`](https://cli.github.com) CLI (so `gh` must be installed and authenticated),
|
|
100
|
+
then builds the saga exactly as it would for a local branch. In this mode the PR
|
|
101
|
+
defines the change set, so `--base`, `--head`, and `--repo` are ignored.
|
|
102
|
+
|
|
103
|
+
The optional positional argument is a GitHub PR URL (as above); all flags below
|
|
104
|
+
apply to both modes.
|
|
105
|
+
|
|
86
106
|
| Flag | Default | Meaning |
|
|
87
107
|
| ---------------------- | --------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
88
|
-
| `--base` |
|
|
89
|
-
| `--head` | current branch | Head ref to walk through
|
|
108
|
+
| `--base` | auto-detected | Base ref to diff against (defaults to the repo's default branch, e.g. `origin/main`); local mode only |
|
|
109
|
+
| `--head` | current branch | Head ref to walk through; local mode only |
|
|
90
110
|
| `--intent PATH` | — | Optional plan/spec describing the change's intent, for plan-aware narration and deviation flagging |
|
|
91
111
|
| `--model` | `anthropic/claude-opus-4-8` | `provider/model` string (see [Providers](#providers)); also `$SAGA_MODEL` |
|
|
92
112
|
| `-o, --output` | `saga.html` | Output file |
|
|
93
|
-
| `--repo` | cwd | A path inside the target git repo
|
|
113
|
+
| `--repo` | cwd | A path inside the target git repo; local mode only |
|
|
94
114
|
| `--open` / `--no-open` | on | Open the result in a browser (on by default; `--no-open` to disable) |
|
|
95
115
|
|
|
96
116
|
## Providers
|
|
@@ -194,7 +214,8 @@ it on GitHub. Requires the [`gh`](https://cli.github.com) CLI, authenticated.
|
|
|
194
214
|
|
|
195
215
|
## How it works
|
|
196
216
|
|
|
197
|
-
1. `diff.py` computes `git diff base...head` (no checkout) and the commit list
|
|
217
|
+
1. `diff.py` computes `git diff base...head` (no checkout) and the commit list —
|
|
218
|
+
or, given a PR URL, fetches the same diff and metadata from GitHub via `gh`.
|
|
198
219
|
2. `model.py` splits the diff into stable-id hunks (`h0, h1, …`).
|
|
199
220
|
3. `generate.py` sends the labeled diff + commits (+ optional intent) to the chosen
|
|
200
221
|
model via `instructor`, which returns chapters as schema-validated JSON. Coverage
|
|
@@ -15,6 +15,8 @@ the data). Open it offline, email it, commit it, or drop it on any static host.
|
|
|
15
15
|
|
|
16
16
|
- Python 3.11+
|
|
17
17
|
- `git`
|
|
18
|
+
- The [`gh`](https://cli.github.com) CLI, authenticated — only for reviewing a PR
|
|
19
|
+
by URL (`saga <pr-url>`) or pushing review comments
|
|
18
20
|
- An API key for your chosen provider, in the standard environment variable:
|
|
19
21
|
`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OPENROUTER_API_KEY` — or a running
|
|
20
22
|
local server (Ollama / LM Studio) and no key, with a `local/` model
|
|
@@ -56,14 +58,32 @@ Run it from anywhere with `--repo`:
|
|
|
56
58
|
saga --repo ~/src/some-project --base main --head my-feature -o out.html
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
### From a GitHub PR URL
|
|
62
|
+
|
|
63
|
+
Point saga straight at a pull request by passing its URL as the first argument.
|
|
64
|
+
No checkout is needed and it works from any directory:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
saga https://github.com/owner/repo/pull/5
|
|
68
|
+
saga https://github.com/owner/repo/pull/5 --model openai/gpt-4o -o pr5.html
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This fetches the PR's diff, commits, and branch names with the
|
|
72
|
+
[`gh`](https://cli.github.com) CLI (so `gh` must be installed and authenticated),
|
|
73
|
+
then builds the saga exactly as it would for a local branch. In this mode the PR
|
|
74
|
+
defines the change set, so `--base`, `--head`, and `--repo` are ignored.
|
|
75
|
+
|
|
76
|
+
The optional positional argument is a GitHub PR URL (as above); all flags below
|
|
77
|
+
apply to both modes.
|
|
78
|
+
|
|
59
79
|
| Flag | Default | Meaning |
|
|
60
80
|
| ---------------------- | --------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
61
|
-
| `--base` |
|
|
62
|
-
| `--head` | current branch | Head ref to walk through
|
|
81
|
+
| `--base` | auto-detected | Base ref to diff against (defaults to the repo's default branch, e.g. `origin/main`); local mode only |
|
|
82
|
+
| `--head` | current branch | Head ref to walk through; local mode only |
|
|
63
83
|
| `--intent PATH` | — | Optional plan/spec describing the change's intent, for plan-aware narration and deviation flagging |
|
|
64
84
|
| `--model` | `anthropic/claude-opus-4-8` | `provider/model` string (see [Providers](#providers)); also `$SAGA_MODEL` |
|
|
65
85
|
| `-o, --output` | `saga.html` | Output file |
|
|
66
|
-
| `--repo` | cwd | A path inside the target git repo
|
|
86
|
+
| `--repo` | cwd | A path inside the target git repo; local mode only |
|
|
67
87
|
| `--open` / `--no-open` | on | Open the result in a browser (on by default; `--no-open` to disable) |
|
|
68
88
|
|
|
69
89
|
## Providers
|
|
@@ -167,7 +187,8 @@ it on GitHub. Requires the [`gh`](https://cli.github.com) CLI, authenticated.
|
|
|
167
187
|
|
|
168
188
|
## How it works
|
|
169
189
|
|
|
170
|
-
1. `diff.py` computes `git diff base...head` (no checkout) and the commit list
|
|
190
|
+
1. `diff.py` computes `git diff base...head` (no checkout) and the commit list —
|
|
191
|
+
or, given a PR URL, fetches the same diff and metadata from GitHub via `gh`.
|
|
171
192
|
2. `model.py` splits the diff into stable-id hunks (`h0, h1, …`).
|
|
172
193
|
3. `generate.py` sends the labeled diff + commits (+ optional intent) to the chosen
|
|
173
194
|
model via `instructor`, which returns chapters as schema-validated JSON. Coverage
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "saga-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Generate a self-contained static HTML saga of a git diff — a chapter-by-chapter guided tour of a change."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -13,16 +13,42 @@
|
|
|
13
13
|
}
|
|
14
14
|
.saga-theme-toggle:hover { border-color: var(--accent); color: var(--text); }
|
|
15
15
|
|
|
16
|
-
.saga-crumbs { font-size: var(--fs-sm); color: var(--text-dim); margin-bottom:
|
|
16
|
+
.saga-crumbs { font-size: var(--fs-sm); color: var(--text-dim); margin-bottom: 8px; }
|
|
17
17
|
.saga-crumbs a { color: var(--accent); text-decoration: none; }
|
|
18
18
|
.saga-crumbs a:hover { color: var(--accent-bright); text-decoration: underline; }
|
|
19
|
-
|
|
20
|
-
.
|
|
19
|
+
|
|
20
|
+
.header h1 { line-height: 1.2; }
|
|
21
|
+
|
|
22
|
+
.saga-summary {
|
|
23
|
+
color: var(--text-dim);
|
|
24
|
+
font-size: var(--fs-md);
|
|
25
|
+
line-height: 1.45;
|
|
26
|
+
margin-top: 4px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.saga-statusline {
|
|
30
|
+
display: flex;
|
|
31
|
+
justify-content: space-between;
|
|
32
|
+
align-items: baseline;
|
|
33
|
+
gap: 8px 24px;
|
|
34
|
+
flex-wrap: wrap;
|
|
35
|
+
margin-top: 10px;
|
|
36
|
+
}
|
|
21
37
|
|
|
22
38
|
.saga-verdict {
|
|
23
39
|
color: var(--text-dim);
|
|
24
40
|
font-size: var(--fs-sm);
|
|
25
|
-
|
|
41
|
+
font-family: var(--font-mono);
|
|
42
|
+
letter-spacing: var(--tracking-mono);
|
|
43
|
+
}
|
|
44
|
+
.saga-verdict .saga-flag { color: var(--warn); }
|
|
45
|
+
/* diff add/remove hues, matching the diff body's green/red */
|
|
46
|
+
.saga-verdict .saga-add { color: var(--ok); }
|
|
47
|
+
.saga-verdict .saga-del { color: var(--danger); }
|
|
48
|
+
|
|
49
|
+
.saga-meta {
|
|
50
|
+
color: var(--text-faint);
|
|
51
|
+
font-size: var(--fs-xs);
|
|
26
52
|
font-family: var(--font-mono);
|
|
27
53
|
letter-spacing: var(--tracking-mono);
|
|
28
54
|
}
|
|
@@ -86,15 +112,11 @@
|
|
|
86
112
|
border: 1px solid var(--line); color: var(--text-dim); background: var(--surface-2);
|
|
87
113
|
}
|
|
88
114
|
.saga-plan { color: var(--text-dim); }
|
|
89
|
-
.saga-qa { color: var(--ok); border-color: var(--ok-solid); background: var(--ok-tint); }
|
|
90
115
|
.saga-read { color: var(--accent); border-color: var(--accent-solid); background: var(--accent-tint); }
|
|
91
|
-
/*
|
|
92
|
-
.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
/* Low confidence flagged for close review (amber). */
|
|
96
|
-
.saga-low {
|
|
97
|
-
color: var(--warn-ink); background: var(--warn); border-color: var(--warn-solid); font-weight: 600;
|
|
116
|
+
/* Agent attention flags (QA, differs-from-plan, closer-look) share one calm
|
|
117
|
+
amber; the label carries the meaning, not the colour. */
|
|
118
|
+
.saga-qa, .saga-dev, .saga-low {
|
|
119
|
+
color: var(--warn); border-color: var(--warn-solid); background: var(--warn-tint);
|
|
98
120
|
}
|
|
99
121
|
|
|
100
122
|
/* --- chapter reader --- */
|
|
@@ -120,10 +142,10 @@
|
|
|
120
142
|
.saga-chapter-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
121
143
|
.saga-chapter-title { font-size: var(--fs-xl); font-weight: 600; margin: 0; }
|
|
122
144
|
|
|
123
|
-
/*
|
|
145
|
+
/* Differs-from-plan banner (amber attention, same family as low-confidence). */
|
|
124
146
|
.saga-deviation {
|
|
125
|
-
background: var(--
|
|
126
|
-
color: var(--
|
|
147
|
+
background: var(--warn-tint); border: 1px solid var(--warn-solid); border-left: 4px solid var(--warn);
|
|
148
|
+
color: var(--warn); border-radius: var(--radius); padding: 12px 16px; margin: 14px 0; font-size: var(--fs-base);
|
|
127
149
|
}
|
|
128
150
|
.saga-lowconf {
|
|
129
151
|
background: var(--warn-tint); border: 1px solid var(--warn-solid); border-left: 4px solid var(--warn);
|
|
@@ -139,7 +161,7 @@
|
|
|
139
161
|
font-family: var(--font-mono); font-size: var(--fs-sm);
|
|
140
162
|
}
|
|
141
163
|
|
|
142
|
-
.saga-qanote { color: var(--
|
|
164
|
+
.saga-qanote { color: var(--warn); font-size: var(--fs-sm); margin: 8px 0; }
|
|
143
165
|
|
|
144
166
|
.saga-readmark {
|
|
145
167
|
display: inline-flex; align-items: center; gap: 6px;
|
|
@@ -303,28 +303,65 @@
|
|
|
303
303
|
chapters = data.chapters || [];
|
|
304
304
|
comments = loadComments();
|
|
305
305
|
initTheme();
|
|
306
|
-
|
|
306
|
+
renderHead();
|
|
307
|
+
renderVerdict(data.verdict, data.stats);
|
|
307
308
|
renderTOC();
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
// --- header (title, summary, provenance) ----------------------------
|
|
312
|
+
|
|
313
|
+
function renderHead() {
|
|
314
|
+
if (data.title) $('saga-title').textContent = data.title;
|
|
315
|
+
const sum = $('saga-summary');
|
|
316
|
+
if (sum && data.summary) { sum.textContent = data.summary; sum.hidden = false; }
|
|
317
|
+
const meta = $('saga-meta');
|
|
318
|
+
if (meta) {
|
|
319
|
+
const parts = [];
|
|
320
|
+
if (data.commit_sha) parts.push(data.commit_sha.slice(0, 7));
|
|
321
|
+
const rel = relativeTime(data.generated_at);
|
|
322
|
+
if (rel) parts.push('generated ' + rel);
|
|
323
|
+
meta.textContent = parts.join(' · ');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function relativeTime(iso) {
|
|
328
|
+
if (!iso) return '';
|
|
329
|
+
const then = new Date(iso).getTime();
|
|
330
|
+
if (isNaN(then)) return '';
|
|
331
|
+
const secs = Math.round((Date.now() - then) / 1000);
|
|
332
|
+
if (secs < 60) return 'just now';
|
|
333
|
+
const units = [['year', 31536000], ['month', 2592000], ['week', 604800],
|
|
334
|
+
['day', 86400], ['hour', 3600], ['minute', 60]];
|
|
335
|
+
for (const [name, size] of units) {
|
|
336
|
+
const n = Math.floor(secs / size);
|
|
337
|
+
if (n >= 1) return n + ' ' + name + (n === 1 ? '' : 's') + ' ago';
|
|
338
|
+
}
|
|
339
|
+
return 'just now';
|
|
340
|
+
}
|
|
341
|
+
|
|
310
342
|
// --- verdict line --------------------------------------------------
|
|
311
343
|
|
|
312
|
-
function renderVerdict(v) {
|
|
344
|
+
function renderVerdict(v, stats) {
|
|
313
345
|
if (!v) return;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
346
|
+
// Scope segments read neutral; attention flags echo the amber rail so a
|
|
347
|
+
// reviewer can spot what needs a look. All text is generated (no user
|
|
348
|
+
// content), so building innerHTML here is injection-safe.
|
|
349
|
+
const seg = [{ t: v.chapters + (v.chapters === 1 ? ' chapter' : ' chapters') }];
|
|
350
|
+
if (stats && stats.files) seg.push({ t: stats.files + (stats.files === 1 ? ' file' : ' files') });
|
|
351
|
+
if (stats && (stats.added || stats.removed)) {
|
|
352
|
+
seg.push({ html: '<span class="saga-add">+' + (stats.added || 0) + '</span> ' +
|
|
353
|
+
'<span class="saga-del">−' + (stats.removed || 0) + '</span>' });
|
|
354
|
+
}
|
|
355
|
+
if (v.deviations > 0) seg.push({ t: v.deviations + (v.deviations === 1 ? ' differs from plan' : ' differ from plan'), flag: true });
|
|
356
|
+
if (v.low_confidence > 0) seg.push({ t: v.low_confidence + (v.low_confidence === 1 ? ' needs a closer look' : ' need a closer look'), flag: true });
|
|
357
|
+
$('saga-verdict').innerHTML = seg
|
|
358
|
+
.map((s) => (s.html ? s.html : s.flag ? '<span class="saga-flag">' + s.t + '</span>' : s.t))
|
|
359
|
+
.join(' · ');
|
|
360
|
+
// Two-tier status rail: amber when anything is flagged, else green.
|
|
323
361
|
const rail = $('saga-rail');
|
|
324
362
|
if (rail) {
|
|
325
|
-
rail.classList.remove('saga-rail-ok', 'saga-rail-attn'
|
|
326
|
-
if (v.deviations > 0) rail.classList.add('saga-rail-
|
|
327
|
-
else if (v.low_confidence > 0 || v.qa === 'attention') rail.classList.add('saga-rail-attn');
|
|
363
|
+
rail.classList.remove('saga-rail-ok', 'saga-rail-attn');
|
|
364
|
+
if (v.deviations > 0 || v.low_confidence > 0) rail.classList.add('saga-rail-attn');
|
|
328
365
|
else rail.classList.add('saga-rail-ok');
|
|
329
366
|
}
|
|
330
367
|
}
|
|
@@ -334,9 +371,9 @@
|
|
|
334
371
|
function badges(ch, read) {
|
|
335
372
|
const out = [];
|
|
336
373
|
if (ch.plan_step) out.push('<span class="saga-badge saga-plan">' + esc(ch.plan_step) + '</span>');
|
|
337
|
-
if (ch.deviation) out.push('<span class="saga-badge saga-dev"
|
|
338
|
-
if (ch.confidence === 'low') out.push('<span class="saga-badge saga-low">
|
|
339
|
-
if (ch.qa
|
|
374
|
+
if (ch.deviation) out.push('<span class="saga-badge saga-dev">Differs from plan</span>');
|
|
375
|
+
if (ch.confidence === 'low') out.push('<span class="saga-badge saga-low">Needs a closer look</span>');
|
|
376
|
+
if (ch.qa) out.push('<span class="saga-badge saga-qa">⚠ QA</span>');
|
|
340
377
|
if (read) out.push('<span class="saga-badge saga-read">✓ Read</span>');
|
|
341
378
|
return out.join('');
|
|
342
379
|
}
|
|
@@ -390,13 +427,13 @@
|
|
|
390
427
|
reader.hidden = false;
|
|
391
428
|
|
|
392
429
|
const devBanner = ch.deviation
|
|
393
|
-
? '<div class="saga-deviation"><strong
|
|
430
|
+
? '<div class="saga-deviation"><strong>Differs from the plan.</strong> ' + esc(ch.deviation) + ' Worth confirming this was intentional.</div>'
|
|
394
431
|
: '';
|
|
395
432
|
const lowBanner = ch.confidence === 'low'
|
|
396
|
-
? '<div class="saga-lowconf">
|
|
433
|
+
? '<div class="saga-lowconf"><strong>Needs a closer look.</strong> The walkthrough is unsure here — read the diff rather than just trusting the summary.</div>'
|
|
397
434
|
: '';
|
|
398
|
-
const qaLine = ch.qa
|
|
399
|
-
? '<div class="saga-qanote"
|
|
435
|
+
const qaLine = ch.qa
|
|
436
|
+
? '<div class="saga-qanote">⚠ Manual QA: ' + esc(ch.qa) + '</div>'
|
|
400
437
|
: '';
|
|
401
438
|
|
|
402
439
|
// Class-based (not id-based) so the same nav can render at top and bottom.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/* Saga — design tokens ("instrument ink").
|
|
2
2
|
*
|
|
3
3
|
* Single source of truth for the saga page. A cool CYAN accent means
|
|
4
|
-
* "a human can act here" (links, focus), and a
|
|
5
|
-
* — green (clear) · amber (attention)
|
|
6
|
-
*
|
|
4
|
+
* "a human can act here" (links, focus), and a two-light status system
|
|
5
|
+
* — green (clear) · amber (attention) — signals the agent's own state and is
|
|
6
|
+
* never used as chrome. Red is reserved for errors and destructive actions.
|
|
7
7
|
*
|
|
8
8
|
* Every colour is a light-dark(light, dark) pair. The page follows the OS by
|
|
9
9
|
* default (color-scheme: light dark); the reader's toggle forces a scheme by
|
|
@@ -49,12 +49,11 @@
|
|
|
49
49
|
--warn-ink: light-dark(#ffffff, #1b1200); /* text on a solid amber fill */
|
|
50
50
|
--warn-tint: light-dark(rgba(154,103,0,0.12), rgba(224,168,62,0.15));
|
|
51
51
|
|
|
52
|
-
--danger: light-dark(#c62828, #f2685f); /*
|
|
52
|
+
--danger: light-dark(#c62828, #f2685f); /* errors, destructive */
|
|
53
53
|
--danger-bright: light-dark(#a51f1f, #ff9c94);
|
|
54
54
|
--danger-ink: light-dark(#5c1512, #ffe1de); /* text on a loud red fill */
|
|
55
55
|
--danger-tint: light-dark(rgba(198,40,40,0.10), rgba(242,104,95,0.14));
|
|
56
|
-
--danger-loud:
|
|
57
|
-
--danger-loud-bg:light-dark(#fdecea, #3d1518); /* loud deviation panel bg */
|
|
56
|
+
--danger-loud-bg:light-dark(#fdecea, #3d1518); /* error panel bg */
|
|
58
57
|
--danger-loud-border:light-dark(#c62828, #f2685f);
|
|
59
58
|
|
|
60
59
|
/* --- diff shading (derived from status hues) --- */
|
|
@@ -129,7 +128,7 @@
|
|
|
129
128
|
/* --- signature: the status rail ------------------------------------
|
|
130
129
|
* A thin backlit edge across the top of the page. It shifts colour to
|
|
131
130
|
* the most important state present:
|
|
132
|
-
* accent (idle) · green (clear) · amber (attention)
|
|
131
|
+
* accent (idle) · green (clear) · amber (attention).
|
|
133
132
|
*/
|
|
134
133
|
.saga-rail {
|
|
135
134
|
height: 2px;
|
|
@@ -138,4 +137,3 @@
|
|
|
138
137
|
}
|
|
139
138
|
.saga-rail-ok { --rail: var(--ok); }
|
|
140
139
|
.saga-rail-attn { --rail: var(--warn); }
|
|
141
|
-
.saga-rail-dev { --rail: var(--danger); }
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Generate a self-contained PR saga as a static HTML page.
|
|
2
|
+
|
|
3
|
+
Installs a ``saga`` command usable from any repo. Needs Python 3, git, and
|
|
4
|
+
an API key for the chosen provider (``ANTHROPIC_API_KEY`` / ``OPENAI_API_KEY`` /
|
|
5
|
+
``OPENROUTER_API_KEY``) — or, with a ``local/`` model, a running Ollama or
|
|
6
|
+
LM Studio server and no key, or the ``claude-cli`` model, a logged-in Claude
|
|
7
|
+
Code CLI (e.g. a Claude subscription, no key). Run from inside the repo you
|
|
8
|
+
want to review:
|
|
9
|
+
|
|
10
|
+
saga --base main --head my-feature -o saga.html --open
|
|
11
|
+
|
|
12
|
+
Or point it straight at a pull request by URL (via the ``gh`` CLI; no checkout
|
|
13
|
+
needed, works from any directory):
|
|
14
|
+
|
|
15
|
+
saga https://github.com/owner/repo/pull/5 --model openai/gpt-4o
|
|
16
|
+
|
|
17
|
+
Defaults: base=auto-detected (e.g. origin/main), head=current branch (HEAD),
|
|
18
|
+
output=saga.html, model=anthropic/claude-opus-4-8 (override --model or $SAGA_MODEL).
|
|
19
|
+
Pass --intent PATH to give the model a plan/spec for richer, plan-aware narration.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import webbrowser
|
|
25
|
+
from importlib.metadata import version as package_version
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import typer
|
|
29
|
+
from typer.core import TyperArgument, TyperGroup
|
|
30
|
+
|
|
31
|
+
from .comments import comments_app
|
|
32
|
+
from .diff import (
|
|
33
|
+
compute_diff,
|
|
34
|
+
current_branch,
|
|
35
|
+
default_base,
|
|
36
|
+
pr_diff,
|
|
37
|
+
repo_root_from,
|
|
38
|
+
rev_parse,
|
|
39
|
+
)
|
|
40
|
+
from .generate import generate
|
|
41
|
+
from .model import SagaError
|
|
42
|
+
from .render import render
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SagaGroup(TyperGroup):
|
|
46
|
+
"""Let the top-level command take an optional positional target (a PR URL)
|
|
47
|
+
while still dispatching subcommands like ``saga comments``.
|
|
48
|
+
|
|
49
|
+
Click would otherwise make the group's positional argument swallow a
|
|
50
|
+
subcommand name, and it forbids options after a positional. So: when the
|
|
51
|
+
first token names a subcommand, drop the positional for that parse; otherwise
|
|
52
|
+
allow options to follow the target (``saga <url> --model …``).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def parse_args(self, ctx, args):
|
|
56
|
+
if args and args[0] in self.commands:
|
|
57
|
+
saved = self.params
|
|
58
|
+
self.params = [p for p in saved if not isinstance(p, TyperArgument)]
|
|
59
|
+
try:
|
|
60
|
+
return super().parse_args(ctx, args)
|
|
61
|
+
finally:
|
|
62
|
+
self.params = saved
|
|
63
|
+
ctx.allow_interspersed_args = True
|
|
64
|
+
return super().parse_args(ctx, args)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
app = typer.Typer(
|
|
68
|
+
name="saga",
|
|
69
|
+
help="Generate a self-contained PR saga as static HTML.",
|
|
70
|
+
add_completion=False,
|
|
71
|
+
cls=SagaGroup,
|
|
72
|
+
)
|
|
73
|
+
app.add_typer(comments_app, name="comments")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _version_callback(value: bool) -> None:
|
|
77
|
+
if value:
|
|
78
|
+
typer.echo(f"saga {package_version('saga-cli')}")
|
|
79
|
+
raise typer.Exit()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.callback(invoke_without_command=True)
|
|
83
|
+
def main(
|
|
84
|
+
ctx: typer.Context,
|
|
85
|
+
target: str | None = typer.Argument(
|
|
86
|
+
None,
|
|
87
|
+
help=(
|
|
88
|
+
"a GitHub PR URL to build a saga for "
|
|
89
|
+
"(e.g. https://github.com/owner/repo/pull/5); "
|
|
90
|
+
"omit to use local git refs"
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
version: bool = typer.Option(
|
|
94
|
+
False,
|
|
95
|
+
"--version",
|
|
96
|
+
help="show the installed saga version and exit",
|
|
97
|
+
is_eager=True,
|
|
98
|
+
callback=_version_callback,
|
|
99
|
+
),
|
|
100
|
+
base: str | None = typer.Option(
|
|
101
|
+
None, help="base ref (default: auto-detected, e.g. origin/main)"
|
|
102
|
+
),
|
|
103
|
+
head: str = typer.Option("HEAD", help="head ref to walk through (default: HEAD)"),
|
|
104
|
+
intent: Path | None = typer.Option(
|
|
105
|
+
None, help="optional path to a plan/spec describing the change's intent"
|
|
106
|
+
),
|
|
107
|
+
model: str = typer.Option(
|
|
108
|
+
"anthropic/claude-opus-4-8",
|
|
109
|
+
envvar="SAGA_MODEL",
|
|
110
|
+
help=(
|
|
111
|
+
"provider/model to use, e.g. anthropic/claude-opus-4-8, openai/gpt-4o, "
|
|
112
|
+
"openrouter/anthropic/claude-3.5-sonnet, local/qwen2.5-coder:14b, "
|
|
113
|
+
"claude-cli or claude-cli/sonnet "
|
|
114
|
+
"(local/ targets Ollama or LM Studio; see $SAGA_LOCAL_BASE_URL. "
|
|
115
|
+
"claude-cli routes through your logged-in Claude Code CLI) "
|
|
116
|
+
"(default: $SAGA_MODEL or anthropic/claude-opus-4-8)"
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
output: Path = typer.Option(
|
|
120
|
+
Path("saga.html"),
|
|
121
|
+
"-o",
|
|
122
|
+
"--output",
|
|
123
|
+
help="output HTML path (default: saga.html)",
|
|
124
|
+
),
|
|
125
|
+
repo: Path = typer.Option(
|
|
126
|
+
Path.cwd(), "--repo", help="path inside the target git repo (default: cwd)"
|
|
127
|
+
),
|
|
128
|
+
open_browser: bool = typer.Option(
|
|
129
|
+
True,
|
|
130
|
+
"--open/--no-open",
|
|
131
|
+
help="open the result in a browser (default: on; use --no-open to disable)",
|
|
132
|
+
),
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Generate a self-contained PR saga as static HTML."""
|
|
135
|
+
if ctx.invoked_subcommand is not None:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
intent_text = None
|
|
139
|
+
if intent is not None:
|
|
140
|
+
try:
|
|
141
|
+
intent_text = intent.read_text()
|
|
142
|
+
except OSError as e:
|
|
143
|
+
typer.echo(f"error: could not read intent file: {e}", err=True)
|
|
144
|
+
raise typer.Exit(1) from e
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
if target is not None:
|
|
148
|
+
# PR mode: fetch the diff from GitHub; base/head/sha come from the PR,
|
|
149
|
+
# so --base/--head and a local checkout are not needed.
|
|
150
|
+
typer.echo(f"Fetching PR {target} …", err=True)
|
|
151
|
+
pr = pr_diff(target)
|
|
152
|
+
diff = pr.diff
|
|
153
|
+
resolved_base, resolved_head, commit_sha = pr.base, pr.head, pr.head_sha
|
|
154
|
+
else:
|
|
155
|
+
# Local mode: diff two refs straight from git.
|
|
156
|
+
repo_root = repo_root_from(repo)
|
|
157
|
+
if repo_root is None:
|
|
158
|
+
typer.echo(f"error: {repo} is not inside a git repository.", err=True)
|
|
159
|
+
raise typer.Exit(1)
|
|
160
|
+
resolved_base = base if base is not None else default_base(repo_root)
|
|
161
|
+
resolved_head = current_branch(repo_root) if head == "HEAD" else head
|
|
162
|
+
diff = compute_diff(repo_root, resolved_base, resolved_head)
|
|
163
|
+
commit_sha = rev_parse(repo_root, resolved_head)
|
|
164
|
+
|
|
165
|
+
typer.echo(f"Generating saga for {resolved_base}...{resolved_head} …", err=True)
|
|
166
|
+
saga = generate(
|
|
167
|
+
diff,
|
|
168
|
+
base=resolved_base,
|
|
169
|
+
head=resolved_head,
|
|
170
|
+
commit_sha=commit_sha,
|
|
171
|
+
model=model,
|
|
172
|
+
intent=intent_text,
|
|
173
|
+
)
|
|
174
|
+
html = render(saga, diff)
|
|
175
|
+
except SagaError as e:
|
|
176
|
+
typer.echo(f"error: {e}", err=True)
|
|
177
|
+
raise typer.Exit(1) from e
|
|
178
|
+
|
|
179
|
+
output.write_text(html)
|
|
180
|
+
typer.echo(f"Wrote {output} ({len(saga.chapters)} chapters).", err=True)
|
|
181
|
+
if open_browser:
|
|
182
|
+
webbrowser.open(output.resolve().as_uri())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
app()
|