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.
Files changed (39) hide show
  1. {saga_cli-0.2.0 → saga_cli-0.3.0}/PKG-INFO +26 -5
  2. {saga_cli-0.2.0 → saga_cli-0.3.0}/README.md +25 -4
  3. {saga_cli-0.2.0 → saga_cli-0.3.0}/pyproject.toml +1 -1
  4. {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/assets/saga.css +38 -16
  5. {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/assets/saga.js +58 -21
  6. {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/assets/tokens.css +6 -8
  7. saga_cli-0.3.0/saga/cli.py +186 -0
  8. saga_cli-0.3.0/saga/diff.py +169 -0
  9. {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/generate.py +102 -58
  10. {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/model.py +11 -5
  11. saga_cli-0.3.0/saga/prompts/saga.md +59 -0
  12. {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/render.py +38 -13
  13. saga_cli-0.3.0/tests/cassettes/generate_anthropic.yaml +162 -0
  14. {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/test_cli.py +84 -5
  15. saga_cli-0.3.0/tests/test_diff.py +217 -0
  16. {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/test_generate.py +107 -8
  17. {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/test_model.py +5 -3
  18. {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/test_render.py +29 -5
  19. {saga_cli-0.2.0 → saga_cli-0.3.0}/uv.lock +1 -1
  20. saga_cli-0.2.0/saga/cli.py +0 -106
  21. saga_cli-0.2.0/saga/diff.py +0 -79
  22. saga_cli-0.2.0/saga/prompts/saga.md +0 -50
  23. saga_cli-0.2.0/tests/cassettes/generate_anthropic.yaml +0 -168
  24. saga_cli-0.2.0/tests/test_diff.py +0 -95
  25. {saga_cli-0.2.0 → saga_cli-0.3.0}/.github/CODEOWNERS +0 -0
  26. {saga_cli-0.2.0 → saga_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  27. {saga_cli-0.2.0 → saga_cli-0.3.0}/.gitignore +0 -0
  28. {saga_cli-0.2.0 → saga_cli-0.3.0}/CONTRIBUTING.md +0 -0
  29. {saga_cli-0.2.0 → saga_cli-0.3.0}/LICENSE +0 -0
  30. {saga_cli-0.2.0 → saga_cli-0.3.0}/RELEASING.md +0 -0
  31. {saga_cli-0.2.0 → saga_cli-0.3.0}/docs/example.html +0 -0
  32. {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/__init__.py +0 -0
  33. {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/__main__.py +0 -0
  34. {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/assets/base.css +0 -0
  35. {saga_cli-0.2.0 → saga_cli-0.3.0}/saga/comments.py +0 -0
  36. {saga_cli-0.2.0 → saga_cli-0.3.0}/skills/generate_saga/SKILL.md +0 -0
  37. {saga_cli-0.2.0 → saga_cli-0.3.0}/skills/read_saga_comments/SKILL.md +0 -0
  38. {saga_cli-0.2.0 → saga_cli-0.3.0}/tests/conftest.py +0 -0
  39. {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.2.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` | `main` | Base ref to diff against |
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` | `main` | Base ref to diff against |
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.2.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: 10px; }
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
- .saga-crumb-sep { margin: 0 8px; color: var(--text-faint); }
20
- .saga-crumb-current { color: var(--text); }
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
- margin-top: 6px;
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
- /* Deviations must be loud. */
92
- .saga-dev {
93
- color: var(--danger-ink); background: var(--danger-loud); border-color: var(--danger); font-weight: 600;
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
- /* Loud deviation banner. */
145
+ /* Differs-from-plan banner (amber attention, same family as low-confidence). */
124
146
  .saga-deviation {
125
- background: var(--danger-loud-bg); border: 1px solid var(--danger); border-left: 4px solid var(--danger);
126
- color: var(--danger-ink); border-radius: var(--radius); padding: 12px 16px; margin: 14px 0; font-size: var(--fs-base);
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(--ok); font-size: var(--fs-sm); margin: 8px 0; }
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
- renderVerdict(data.verdict);
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
- const parts = [
315
- v.chapters + (v.chapters === 1 ? ' chapter' : ' chapters'),
316
- v.deviations + (v.deviations === 1 ? ' deviation' : ' deviations'),
317
- v.low_confidence + ' low-confidence',
318
- ];
319
- if (v.qa && v.qa !== 'n/a') parts.push('QA ' + v.qa);
320
- $('saga-verdict').textContent = parts.join(' · ');
321
- // Shift the top status rail to the loudest state present:
322
- // deviation (red) > attention (amber) > clear (green).
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', 'saga-rail-dev');
326
- if (v.deviations > 0) rail.classList.add('saga-rail-dev');
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">⚠ Deviation</span>');
338
- if (ch.confidence === 'low') out.push('<span class="saga-badge saga-low">Low confidence</span>');
339
- if (ch.qa && ch.qa.status === 'green') out.push('<span class="saga-badge saga-qa">✓ QA</span>');
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>⚠ Deviation from plan.</strong> ' + esc(ch.deviation) + '</div>'
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">Low confidencethis chapter needs close review.</div>'
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 && ch.qa.note
399
- ? '<div class="saga-qanote">' + (ch.qa.status === 'green' ? '✓ ' : '') + esc(ch.qa.note) + '</div>'
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 strict three-light status system
5
- * — green (clear) · amber (attention) · red (deviation) is reserved for the
6
- * agent's own state and never used as chrome.
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); /* deviation */
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: light-dark(#fbe3e1, #b62324); /* loud deviation fill */
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) · red (deviation).
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()