zer0-image-generator 0.6.0 → 0.8.0

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +106 -2
  4. data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
  5. data/lib/zer0_image_generator/abc.rb +75 -0
  6. data/lib/zer0_image_generator/all.rb +57 -0
  7. data/lib/zer0_image_generator/claude/client.rb +291 -0
  8. data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
  9. data/lib/zer0_image_generator/cli.rb +318 -0
  10. data/lib/zer0_image_generator/config.rb +192 -0
  11. data/lib/zer0_image_generator/constants.rb +177 -0
  12. data/lib/zer0_image_generator/content.rb +379 -0
  13. data/lib/zer0_image_generator/engine.rb +21 -0
  14. data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
  15. data/lib/zer0_image_generator/freesvg/client.rb +467 -0
  16. data/lib/zer0_image_generator/http.rb +264 -0
  17. data/lib/zer0_image_generator/library.rb +201 -0
  18. data/lib/zer0_image_generator/logging.rb +236 -0
  19. data/lib/zer0_image_generator/preview_generator.py +1072 -100
  20. data/lib/zer0_image_generator/prompt.rb +48 -0
  21. data/lib/zer0_image_generator/providers/base.rb +152 -0
  22. data/lib/zer0_image_generator/providers/gemini.rb +60 -0
  23. data/lib/zer0_image_generator/providers/local.rb +90 -0
  24. data/lib/zer0_image_generator/providers/openai.rb +102 -0
  25. data/lib/zer0_image_generator/providers/stability.rb +64 -0
  26. data/lib/zer0_image_generator/providers/xai.rb +81 -0
  27. data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
  28. data/lib/zer0_image_generator/providers.rb +22 -0
  29. data/lib/zer0_image_generator/runner.rb +573 -0
  30. data/lib/zer0_image_generator/settings.rb +386 -0
  31. data/lib/zer0_image_generator/stats.rb +61 -0
  32. data/lib/zer0_image_generator/support/py_random.rb +189 -0
  33. data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
  34. data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
  35. data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
  36. data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
  37. data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
  38. data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
  39. data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
  40. data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
  41. data/lib/zer0_image_generator/svg/lint.rb +400 -0
  42. data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
  43. data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
  44. data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
  45. data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
  46. data/lib/zer0_image_generator/version.rb +1 -1
  47. metadata +43 -2
@@ -88,6 +88,7 @@ import zlib
88
88
  import xml.etree.ElementTree as ET
89
89
  from concurrent.futures import ThreadPoolExecutor, as_completed
90
90
  from dataclasses import dataclass, field, replace
91
+ from datetime import datetime, timezone
91
92
  from pathlib import Path
92
93
  from typing import Any, Dict, List, Optional, Tuple
93
94
 
@@ -130,6 +131,10 @@ DEFAULTS: Dict[str, Any] = {
130
131
  "claude_model": "", # empty → DEFAULT_CLAUDE_MODEL
131
132
  "claude_effort": "low", # output_config.effort for brief/review calls
132
133
  # (short creative tasks; "" sends no effort)
134
+ "rasterizer": "auto", # SVG→PNG tool for the SVG providers (local,
135
+ # claude): auto|rsvg|inkscape|magick|playwright
136
+ # |none. "none" is SVG-only mode — the vector
137
+ # banner IS the deliverable and no PNG is made.
133
138
  }
134
139
 
135
140
  # Enhance mode (OpenAI /v1/images/edits — see OpenAIProvider.edit)
@@ -616,6 +621,33 @@ def collection_of(path: Path) -> str:
616
621
  return ""
617
622
 
618
623
 
624
+ def section_of(path: Path) -> str:
625
+ """The file's section — the directory immediately INSIDE its collection
626
+ (`pages/_posts/hacks/x.md` -> 'hacks') — or '' when the collection holds
627
+ its files flat. Sites that fold several kinds of writing into one
628
+ collection carry the editorial distinction here, and it is the strongest
629
+ single signal for what a banner should look like."""
630
+ resolved = Path(path).resolve()
631
+ parts = resolved.parts
632
+ for i, name in enumerate(parts):
633
+ if name.startswith("_") and len(name) > 1:
634
+ # parts[i+1] is a directory only when something follows it.
635
+ return parts[i + 1] if i + 2 < len(parts) else ""
636
+ return ""
637
+
638
+
639
+ def article_art_context(cf: "ContentFile", slug: str) -> Dict[str, str]:
640
+ """The page context the local renderer derives its art from."""
641
+ return {
642
+ "slug": slug,
643
+ "section": section_of(cf.path) or collection_of(cf.path),
644
+ "title": cf.title or "",
645
+ "description": cf.description or "",
646
+ "categories": cf.categories or "",
647
+ "body": cf.content or "",
648
+ }
649
+
650
+
619
651
  def collection_preview_overrides(
620
652
  collection_styles: Dict[str, Any], collection: str
621
653
  ) -> Dict[str, str]:
@@ -782,7 +814,7 @@ def resolve_settings(args: argparse.Namespace, config: Dict[str, Any]) -> Settin
782
814
  args.enhance_format or os.environ.get("ENHANCE_FORMAT")
783
815
  or ENHANCE_DEFAULTS["format"]
784
816
  ),
785
- rasterizer=args.rasterizer or "auto",
817
+ rasterizer=pick(args.rasterizer, "IMAGE_RASTERIZER", "rasterizer"),
786
818
  provider_explicit=args.provider is not None or bool(os.environ.get("AI_PROVIDER")),
787
819
  )
788
820
  return settings
@@ -1280,79 +1312,523 @@ def sanitize_svg(svg_text: str) -> Tuple[str, List[str]]:
1280
1312
  return ET.tostring(root, encoding="unicode"), warnings
1281
1313
 
1282
1314
 
1283
- def render_local_svg(title: str, seed: int) -> str:
1284
- """Deterministic retro-landscape SVG for the `local` provider (no network).
1315
+ # --- Content-aware banner art -----------------------------------------------
1316
+ # The `local` renderer used to draw ONE composition — a retro skyline under a
1317
+ # sun — re-coloured per slug. Every banner on a site therefore looked like
1318
+ # every other banner, and none of them had anything to do with what the article
1319
+ # said. These compositions replace it: the article's own context (its section,
1320
+ # tags, title and body) picks the composition AND the palette, so a shell hack
1321
+ # renders as a circuit schematic and a narrative essay as a flow field.
1322
+ #
1323
+ # Everything here is authored at the banner frame (SVG_WIDTH x SVG_HEIGHT) so
1324
+ # sanitize_svg's forced viewBox is a no-op rather than a squash, and every draw
1325
+ # goes through the seeded LCG — no clock, no `random`, no environment. Same
1326
+ # article in, same bytes out.
1327
+
1328
+ BANNER_PALETTES: Dict[str, List[str]] = {
1329
+ # [bg, far, mid, near, accent, glow]
1330
+ "neon": ["#0b0f17", "#171b2e", "#2b2a5e", "#d946ef", "#22d3ee", "#f5f3ff"],
1331
+ "ember": ["#120b16", "#2d1b2e", "#5c2a4a", "#b3324b", "#f2542d", "#ffd166"],
1332
+ "cyanic": ["#0b132b", "#1c2541", "#1c7293", "#5bc0be", "#9fd8d6", "#e8f1f2"],
1333
+ "moss": ["#101a14", "#1b2f22", "#2f5d3f", "#6f9e6f", "#a3c9a8", "#e9edc9"],
1334
+ "dusk": ["#1a1423", "#2c1f3d", "#5c3a72", "#a05fa8", "#f5a623", "#f7e8d3"],
1335
+ "slate": ["#0d1117", "#161b22", "#26303d", "#4b6178", "#7ea6c9", "#dbe7f0"],
1336
+ }
1337
+ BANNER_PALETTE_ORDER: List[str] = ["neon", "ember", "cyanic", "moss", "dusk", "slate"]
1338
+
1339
+ # Keyword -> composition. Scored against the article's section, tags, title and
1340
+ # body; the highest score wins, ties break on the content seed. Kept small and
1341
+ # legible on purpose — this is art direction, not a classifier.
1342
+ # Matched as WHOLE WORDS, so section names must appear in the form a section
1343
+ # directory actually uses ('hacks', not 'hack') alongside the topical terms.
1344
+ BANNER_KEYWORDS: List[Tuple[str, Tuple[str, ...]]] = [
1345
+ ("circuit", ("hack", "hacks", "shell", "bash", "zsh", "cli", "terminal",
1346
+ "command", "script", "docker", "container", "pipeline",
1347
+ "deploy", "git", "kernel", "server", "network", "port",
1348
+ "cache", "sed", "awk", "grep", "ssh", "make", "compile")),
1349
+ ("pixelscene", ("tool", "tools", "review", "reviews", "app", "editor",
1350
+ "game", "retro", "install", "cheatsheet", "productivity",
1351
+ "keyboard", "plugin", "extension", "terminal")),
1352
+ ("starfield", ("security", "threat", "secret", "secrets", "token", "auth",
1353
+ "permission", "permissions", "audit", "risk", "attack",
1354
+ "privacy", "explore", "journey", "discover", "vulnerability",
1355
+ "credential", "sandbox")),
1356
+ ("lowpoly", ("doc", "docs", "guide", "reference", "architecture", "design",
1357
+ "system", "structure", "schema", "diagram", "overview", "spec",
1358
+ "concept", "concepts", "glossary", "handbook")),
1359
+ ("flowfield", ("note", "notes", "essay", "story", "ai", "agent", "agents",
1360
+ "robot", "automation", "loop", "flow", "process", "thought",
1361
+ "retrospective", "lesson", "lessons", "journal", "reflection")),
1362
+ ]
1363
+ BANNER_COMPOSITION_ORDER: List[str] = [
1364
+ "circuit", "flowfield", "lowpoly", "pixelscene", "starfield",
1365
+ ]
1285
1366
 
1286
- Shares the claude provider's seed → palette/composition scheme so the two
1287
- stay visually kin per slug."""
1288
- pal = palette_for(seed)
1289
- variant = (seed >> 8) % len(COMPOSITION_VARIANTS)
1290
- rng = seed or 1
1367
+
1368
+ def _banner_lcg(seed: int):
1369
+ """glibc LCG masked to 31 bits — the engine's long-standing determinism
1370
+ seam, reused so banner art draws from the same stream shape as the rest."""
1371
+ state = seed or 1
1291
1372
 
1292
1373
  def nxt(bound: int) -> int:
1293
- nonlocal rng
1294
- rng = (rng * 1103515245 + 12345) & 0x7FFFFFFF
1295
- return rng % max(bound, 1)
1374
+ nonlocal state
1375
+ state = (state * 1103515245 + 12345) & 0x7FFFFFFF
1376
+ return state % max(bound, 1)
1377
+ return nxt
1378
+
1379
+
1380
+ def _num(value: float, places: int = 1) -> str:
1381
+ """Round for markup. Sub-pixel precision is invisible and triples size."""
1382
+ text = f"{value:.{places}f}".rstrip("0").rstrip(".")
1383
+ return text or "0"
1384
+
1385
+
1386
+ def _mix(a: str, b: str, t: float) -> str:
1387
+ """Blend two #rrggbb strings. Integer output, so the bytes are identical
1388
+ on every runtime regardless of float formatting."""
1389
+ t = min(max(t, 0.0), 1.0)
1390
+ out = "#"
1391
+ for i in (1, 3, 5):
1392
+ ca, cb = int(a[i:i + 2], 16), int(b[i:i + 2], 16)
1393
+ out += f"{int(ca + (cb - ca) * t + 0.5):02x}"
1394
+ return out
1395
+
1396
+
1397
+ # Trig by lookup table, built from a Taylor series rather than imported from
1398
+ # math: libm rounds the last bit differently across platforms and runtimes, and
1399
+ # the Ruby port is pinned byte-for-byte against this file. A 256-entry table is
1400
+ # 1.4 degrees of resolution — finer than a 1px step can express at this size.
1401
+ _BANNER_SIN: List[float] = []
1402
+ _BANNER_COS: List[float] = []
1403
+
1404
+
1405
+ def _build_banner_trig() -> None:
1406
+ tau = 6.283185307179586
1407
+ for i in range(256):
1408
+ a = (i / 256.0) * tau
1409
+ t = a - tau if a > 3.141592653589793 else a
1410
+ _BANNER_SIN.append(
1411
+ t - t ** 3 / 6 + t ** 5 / 120 - t ** 7 / 5040 + t ** 9 / 362880)
1412
+ _BANNER_COS.append(
1413
+ 1 - t ** 2 / 2 + t ** 4 / 24 - t ** 6 / 720 + t ** 8 / 40320
1414
+ - t ** 10 / 3628800)
1415
+
1416
+
1417
+ _build_banner_trig()
1418
+
1419
+
1420
+ def _bsin(angle: float) -> float:
1421
+ return _BANNER_SIN[int(angle / 6.283185307179586 * 256) & 255]
1422
+
1423
+
1424
+ def _bcos(angle: float) -> float:
1425
+ return _BANNER_COS[int(angle / 6.283185307179586 * 256) & 255]
1426
+
1427
+
1428
+ def _banner_noise(seed: int):
1429
+ """Smoothed value noise on a 17x17 lattice, sampled in unit coordinates."""
1430
+ nxt = _banner_lcg(seed ^ 0x9E3779B9)
1431
+ grid = [[nxt(1000) / 1000.0 for _ in range(17)] for _ in range(17)]
1432
+
1433
+ def at(x: float, y: float) -> float:
1434
+ gx, gy = x * 16.0, y * 16.0
1435
+ x0, y0 = int(gx), int(gy)
1436
+ fx, fy = gx - x0, gy - y0
1437
+ x1, y1 = min(x0 + 1, 16), min(y0 + 1, 16)
1438
+ # smoothstep, so the field has no directional creases
1439
+ sx, sy = fx * fx * (3 - 2 * fx), fy * fy * (3 - 2 * fy)
1440
+ a = grid[y0][x0] * (1 - sx) + grid[y0][x1] * sx
1441
+ b = grid[y1][x0] * (1 - sx) + grid[y1][x1] * sx
1442
+ return a * (1 - sy) + b * sy
1443
+ return at
1444
+
1445
+
1446
+ def _banner_frame(body: str, pal: List[str], title: str = "") -> str:
1447
+ """Wrap a composition in the banner root. `role="img"` plus a `<title>` as
1448
+ the FIRST child is the house rule (.claude/rules/svg.md) — without it a
1449
+ screen reader announces an unlabeled graphic."""
1450
+ label = (str(title) or "banner").replace("&", "&amp;") \
1451
+ .replace("<", "&lt;").replace(">", "&gt;")
1452
+ return (
1453
+ f'<svg xmlns="{SVG_NS}" viewBox="0 0 {SVG_WIDTH} {SVG_HEIGHT}" '
1454
+ f'width="{SVG_WIDTH}" height="{SVG_HEIGHT}" role="img">'
1455
+ f'<title>Preview banner: {label}</title>'
1456
+ f'<rect width="{SVG_WIDTH}" height="{SVG_HEIGHT}" fill="{pal[0]}" />'
1457
+ f'{body}</svg>'
1458
+ )
1459
+
1296
1460
 
1461
+ def banner_flowfield(seed: int, pal: List[str], title: str = "") -> str:
1462
+ """Streamlines advected through a noise field — organic and dense, with no
1463
+ dead space anywhere in the frame."""
1464
+ nxt = _banner_lcg(seed)
1465
+ noise = _banner_noise(seed)
1297
1466
  w, h = SVG_WIDTH, SVG_HEIGHT
1298
- safe_title = title.replace("&", "&amp;").replace("<", "&lt;")
1299
- parts = [
1300
- f'<svg xmlns="{SVG_NS}" viewBox="0 0 {w} {h}" width="{w}" height="{h}" '
1301
- f'role="img">',
1302
- f'<title>Preview banner: {safe_title}</title>',
1303
- f'<rect width="{w}" height="{h}" fill="{pal[0]}"/>',
1304
- ]
1305
- # Sky bands
1306
- band_h = h // 8
1307
- for i in range(3):
1308
- parts.append(
1309
- f'<rect y="{i * band_h}" width="{w}" height="{band_h}" '
1310
- f'fill="{pal[1]}" opacity="0.{25 + i * 12}"/>'
1311
- )
1312
- # Celestial body with stepped "pixel" rings
1313
- cx, cy, radius = w - 320 - nxt(400), 240 + nxt(160), 130 + nxt(80)
1314
- for i, ring_color in enumerate([pal[5], pal[4]]):
1315
- parts.append(
1316
- f'<circle cx="{cx}" cy="{cy}" r="{radius - i * 26}" fill="{ring_color}"/>'
1317
- )
1318
- # Scanline stripes across the disk (clipped to the circle's chord width)
1319
- for i in range(3):
1320
- y = cy + 12 + i * 26
1321
- dy = abs(y + 5 - cy)
1322
- if dy >= radius:
1467
+ inks = [pal[3], pal[4], pal[5], pal[2]]
1468
+ out: List[str] = []
1469
+ for _ in range(400):
1470
+ x, y = nxt(w + 240) - 120, nxt(h + 240) - 120
1471
+ pts = [(x, y)]
1472
+ # Advance in SMALL steps so the curve stays faithful to the field, but
1473
+ # record only every third one. Mapping the 0..1 noise onto a full turn
1474
+ # (not two) keeps adjacent steps from flipping ~180 degrees, which is
1475
+ # what would otherwise stutter the line into a zigzag.
1476
+ for i in range(96):
1477
+ angle = noise(min(max(x / w, 0.0), 1.0),
1478
+ min(max(y / h, 0.0), 1.0)) * 6.283185307179586
1479
+ x += _bcos(angle) * 9
1480
+ y += _bsin(angle) * 9
1481
+ if not (-140 < x < w + 140 and -140 < y < h + 140):
1482
+ break
1483
+ if i % 3 == 2:
1484
+ pts.append((x, y))
1485
+ if len(pts) < 5:
1486
+ continue
1487
+ ink = inks[nxt(len(inks))]
1488
+ width = 0.5 + nxt(18) / 10.0
1489
+ opacity = 0.16 + nxt(50) / 100.0
1490
+ # Whole pixels: at these stroke widths a decimal is invisible and costs
1491
+ # two bytes on each of ~12k coordinate pairs.
1492
+ d = "M" + " L".join(f"{int(px)} {int(py)}" for px, py in pts)
1493
+ out.append(f'<path d="{d}" fill="none" stroke="{ink}" '
1494
+ f'stroke-width="{_num(width)}" '
1495
+ f'stroke-opacity="{_num(opacity, 2)}" '
1496
+ f'stroke-linecap="round" />')
1497
+ return _banner_frame("".join(out), pal, title)
1498
+
1499
+
1500
+ def banner_lowpoly(seed: int, pal: List[str], title: str = "") -> str:
1501
+ """Faceted gradient field — calm and technical, fills the frame edge to
1502
+ edge."""
1503
+ nxt = _banner_lcg(seed)
1504
+ noise = _banner_noise(seed)
1505
+ w, h = SVG_WIDTH, SVG_HEIGHT
1506
+ cols, rows = 22, 15
1507
+ cw, ch = w / cols, h / rows
1508
+ lattice = []
1509
+ for r in range(rows + 1):
1510
+ row = []
1511
+ for c in range(cols + 1):
1512
+ # Edge vertices stay pinned, or the border shows through.
1513
+ jx = 0.0 if c in (0, cols) else (nxt(60) - 30) / 100.0 * cw
1514
+ jy = 0.0 if r in (0, rows) else (nxt(60) - 30) / 100.0 * ch
1515
+ row.append((c * cw + jx, r * ch + jy))
1516
+ lattice.append(row)
1517
+
1518
+ # Ramp CONTINUOUSLY down the frame and let the noise only shade each facet
1519
+ # slightly lighter or darker. Discrete colour stops band the field into
1520
+ # hard stripes and an accent threshold scatters floating shards.
1521
+ ramp = [pal[1], pal[2], pal[3], pal[4]]
1522
+
1523
+ def facet_color(t: float, n: float) -> str:
1524
+ pos = min(max(t, 0.0), 0.999) * (len(ramp) - 1)
1525
+ i = int(pos)
1526
+ base = _mix(ramp[i], ramp[i + 1], pos - i)
1527
+ if n > 0.5:
1528
+ return _mix(base, pal[5], (n - 0.5) * 0.22)
1529
+ return _mix(base, pal[0], (0.5 - n) * 0.30)
1530
+
1531
+ out: List[str] = []
1532
+ for r in range(rows):
1533
+ for c in range(cols):
1534
+ a, b = lattice[r][c], lattice[r][c + 1]
1535
+ d, e = lattice[r + 1][c], lattice[r + 1][c + 1]
1536
+ for tri in ((a, b, d), (b, e, d)):
1537
+ cx = (tri[0][0] + tri[1][0] + tri[2][0]) / 3
1538
+ cy = (tri[0][1] + tri[1][1] + tri[2][1]) / 3
1539
+ color = facet_color(cy / h, noise(cx / w, cy / h))
1540
+ points = " ".join(f"{_num(px)},{_num(py)}" for px, py in tri)
1541
+ # Stroke each facet in its own fill: float rounding otherwise
1542
+ # leaves hairline seams that render as stray light lines.
1543
+ out.append(f'<polygon points="{points}" fill="{color}" '
1544
+ f'stroke="{color}" stroke-width="1" />')
1545
+ return _banner_frame("".join(out), pal, title)
1546
+
1547
+
1548
+ def banner_circuit(seed: int, pal: List[str], title: str = "") -> str:
1549
+ """Orthogonal traces, pads, vias and chips — a schematic read for the
1550
+ shell/infrastructure end of a site."""
1551
+ nxt = _banner_lcg(seed)
1552
+ w, h = SVG_WIDTH, SVG_HEIGHT
1553
+ step = 32
1554
+ out: List[str] = []
1555
+
1556
+ substrate = []
1557
+ for x in range(0, w + 1, step * 2):
1558
+ substrate.append(f"M{x} 0 L{x} {h}")
1559
+ for y in range(0, h + 1, step * 2):
1560
+ substrate.append(f"M0 {y} L{w} {y}")
1561
+ out.append(f'<path d="{" ".join(substrate)}" stroke="{pal[1]}" '
1562
+ f'stroke-width="1" fill="none" stroke-opacity="0.9" />')
1563
+
1564
+ junctions: List[Tuple[int, int]] = []
1565
+ for _ in range(64):
1566
+ # Snap to the grid so every trace is orthogonal by construction.
1567
+ x = nxt(w // step) * step
1568
+ y = nxt(h // step) * step
1569
+ segments = [f"M{x} {y}"]
1570
+ junctions.append((x, y))
1571
+ for _ in range(3 + nxt(5)):
1572
+ if nxt(2):
1573
+ x = max(0, min(w, x + (nxt(11) - 5) * step))
1574
+ segments.append(f"L{x} {y}")
1575
+ else:
1576
+ y = max(0, min(h, y + (nxt(9) - 4) * step))
1577
+ segments.append(f"L{x} {y}")
1578
+ junctions.append((x, y))
1579
+ ink = pal[4] if nxt(3) else pal[3]
1580
+ out.append(f'<path d="{" ".join(segments)}" fill="none" stroke="{ink}" '
1581
+ f'stroke-width="{2 + nxt(3)}" stroke-opacity="0.85" '
1582
+ f'stroke-linecap="square" stroke-linejoin="miter" />')
1583
+
1584
+ for (px, py) in junctions:
1585
+ out.append(f'<circle cx="{px}" cy="{py}" r="7" fill="{pal[0]}" '
1586
+ f'stroke="{pal[4]}" stroke-width="3" />')
1587
+
1588
+ for _ in range(6):
1589
+ x = nxt(w // step - 6) * step
1590
+ y = nxt(h // step - 4) * step
1591
+ cw, chh = (3 + nxt(3)) * step, (2 + nxt(2)) * step
1592
+ out.append(f'<rect x="{x}" y="{y}" width="{cw}" height="{chh}" rx="6" '
1593
+ f'fill="{pal[2]}" stroke="{pal[5]}" stroke-width="2" />')
1594
+ for leg in range(cw // step * 2):
1595
+ out.append(f'<rect x="{x + 8 + leg * (step // 2)}" y="{y + chh}" '
1596
+ f'width="6" height="10" fill="{pal[5]}" />')
1597
+ return _banner_frame("".join(out), pal, title)
1598
+
1599
+
1600
+ def banner_starfield(seed: int, pal: List[str], title: str = "") -> str:
1601
+ """Constellations over layered ridges — the night-sky read."""
1602
+ nxt = _banner_lcg(seed)
1603
+ w, h = SVG_WIDTH, SVG_HEIGHT
1604
+ horizon = int(h * 0.72)
1605
+ out: List[str] = []
1606
+
1607
+ for _ in range(230):
1608
+ x, y = nxt(w), nxt(horizon)
1609
+ # Thin the field toward the horizon so the sky reads as deep.
1610
+ if nxt(horizon) < y * 0.75:
1323
1611
  continue
1324
- half = int((radius * radius - dy * dy) ** 0.5)
1325
- parts.append(f'<rect x="{cx - half}" y="{y}" width="{half * 2}" height="10" fill="{pal[0]}"/>')
1326
- # Layered terrain: three silhouette ranges of blocky steps
1327
- for layer, color in enumerate([pal[2], pal[3], pal[1]]):
1328
- base = h - 120 - layer * 170
1329
- x = -40
1330
- points = [f"-40,{h}"]
1331
- while x < w + 80:
1332
- peak = base - nxt(300) - (2 - layer) * 70
1333
- width_step = 90 + nxt(150)
1334
- points.append(f"{x},{peak}")
1335
- points.append(f"{x + width_step},{peak}")
1336
- x += width_step
1337
- points.append(f"{w + 80},{h}")
1338
- parts.append(f'<polygon points="{" ".join(points)}" fill="{color}"/>')
1339
- # Variant flourish: grid floor for even variants, stars for odd
1340
- if variant % 2 == 0:
1341
- for i in range(1, 7):
1342
- y = h - i * (i * 8)
1343
- parts.append(f'<rect y="{y}" width="{w}" height="3" fill="{pal[4]}" opacity="0.35"/>')
1612
+ out.append(f'<circle cx="{x}" cy="{y}" r="{1 + nxt(3)}" fill="{pal[5]}" '
1613
+ f'fill-opacity="{_num(0.3 + nxt(70) / 100.0, 2)}" />')
1614
+
1615
+ for _ in range(4):
1616
+ cx, cy = 120 + nxt(w - 240), 90 + nxt(horizon - 220)
1617
+ nodes = [(cx, cy)]
1618
+ for _ in range(3 + nxt(3)):
1619
+ cx += nxt(220) - 90
1620
+ cy += nxt(180) - 80
1621
+ nodes.append((cx, cy))
1622
+ d = "M" + " L".join(f"{nx} {ny}" for nx, ny in nodes)
1623
+ out.append(f'<path d="{d}" fill="none" stroke="{pal[4]}" '
1624
+ f'stroke-width="2" stroke-opacity="0.55" />')
1625
+ for (nx, ny) in nodes:
1626
+ out.append(f'<circle cx="{nx}" cy="{ny}" r="5" fill="{pal[4]}" />')
1627
+
1628
+ mx, my = w - 260 - nxt(200), 150 + nxt(120)
1629
+ out.append(f'<circle cx="{mx}" cy="{my}" r="86" fill="{pal[3]}" '
1630
+ f'fill-opacity="0.18" />')
1631
+ out.append(f'<circle cx="{mx}" cy="{my}" r="62" fill="{pal[5]}" />')
1632
+ out.append(f'<circle cx="{mx + 26}" cy="{my - 16}" r="56" fill="{pal[0]}" />')
1633
+
1634
+ # Aerial perspective: the FARTHEST ridge is hazy and light, each nearer one
1635
+ # steps darker. Every layer closes to the bottom of the frame, so the
1636
+ # foreground is ridge mass rather than a flat band of background.
1637
+ for layer in range(4):
1638
+ top = horizon - 40 + layer * 58
1639
+ points = [f"0,{h}"]
1640
+ x, y = 0, float(top)
1641
+ while x <= w:
1642
+ points.append(f"{x},{_num(y)}")
1643
+ x += 40 + nxt(80)
1644
+ y = top + nxt(56) - 24
1645
+ points.append(f"{w},{_num(float(top))}")
1646
+ points.append(f"{w},{h}")
1647
+ out.append(f'<polygon points="{" ".join(points)}" '
1648
+ f'fill="{_mix(pal[3], pal[0], layer / 3.0)}" />')
1649
+ return _banner_frame("".join(out), pal, title)
1650
+
1651
+
1652
+ def banner_pixelscene(seed: int, pal: List[str], title: str = "") -> str:
1653
+ """Pixel-art side-scroller on a strict integer grid, run-length merged."""
1654
+ nxt = _banner_lcg(seed)
1655
+ px = 16
1656
+ cols, rows = SVG_WIDTH // px, SVG_HEIGHT // px
1657
+ cells: List[List[Optional[str]]] = [[None] * cols for _ in range(rows)]
1658
+
1659
+ def put(cx: int, cy: int, color: str) -> None:
1660
+ if 0 <= cx < cols and 0 <= cy < rows:
1661
+ cells[cy][cx] = color
1662
+
1663
+ def block(cx: int, cy: int, bw: int, bh: int, color: str) -> None:
1664
+ for yy in range(cy, cy + bh):
1665
+ for xx in range(cx, cx + bw):
1666
+ put(xx, yy, color)
1667
+
1668
+ ground = rows - 12
1669
+ for _ in range(150):
1670
+ sx, sy = nxt(cols), nxt(ground - 4)
1671
+ if nxt(ground) > sy:
1672
+ put(sx, sy, pal[5] if nxt(4) else pal[3])
1673
+
1674
+ sun_x, sun_y = cols - 20 + nxt(8), 6 + nxt(5)
1675
+ for yy in range(-4, 5):
1676
+ for xx in range(-4, 5):
1677
+ d2 = xx * xx + yy * yy
1678
+ if d2 <= 16:
1679
+ put(sun_x + xx, sun_y + yy, pal[4])
1680
+ elif d2 <= 25:
1681
+ put(sun_x + xx, sun_y + yy, pal[3])
1682
+
1683
+ # `surface` is the row the ground starts at; fill from there to the bottom
1684
+ # so the land is solid mass rather than a one-cell scribble.
1685
+ surface = ground
1686
+ for cx in range(cols):
1687
+ if nxt(4) == 0:
1688
+ surface = max(ground - 5, min(rows - 4, surface + nxt(3) - 1))
1689
+ block(cx, surface, 1, rows - surface, pal[2])
1690
+ put(cx, surface, pal[3])
1691
+ if nxt(7) == 0:
1692
+ put(cx, surface + 2 + nxt(4), pal[1])
1693
+
1694
+ for _ in range(5):
1695
+ span = 5 + nxt(8)
1696
+ bx, by = nxt(max(cols - span, 1)), ground - 9 - nxt(16)
1697
+ for i in range(span):
1698
+ put(bx + i, by, pal[3])
1699
+ put(bx + i, by + 1, pal[1])
1700
+
1701
+ # The hero: 6x9 cells with a dark keyline, so it reads off any ground
1702
+ # colour and stays the only glow-coloured figure in frame.
1703
+ hx, hy = 10 + nxt(14), ground - 10
1704
+ block(hx - 1, hy - 1, 8, 11, pal[0])
1705
+ block(hx + 1, hy, 4, 2, pal[4])
1706
+ block(hx + 1, hy + 2, 4, 1, pal[1])
1707
+ block(hx, hy + 3, 6, 4, pal[5])
1708
+ block(hx + 2, hy + 4, 2, 2, pal[3])
1709
+ block(hx, hy + 7, 2, 3, pal[5])
1710
+ block(hx + 4, hy + 7, 2, 3, pal[5])
1711
+ block(hx + 6, hy + 1, 1, 6, pal[4])
1712
+
1713
+ # Merge horizontal runs of one colour into single rects: a 96x64 scene
1714
+ # stays a few KB instead of six thousand one-cell rects.
1715
+ out: List[str] = []
1716
+ for y in range(rows):
1717
+ x = 0
1718
+ while x < cols:
1719
+ color = cells[y][x]
1720
+ if color is None:
1721
+ x += 1
1722
+ continue
1723
+ run = 1
1724
+ while x + run < cols and cells[y][x + run] == color:
1725
+ run += 1
1726
+ out.append(f'<rect x="{x * px}" y="{y * px}" width="{run * px}" '
1727
+ f'height="{px}" fill="{color}" />')
1728
+ x += run
1729
+ body = f'<g shape-rendering="crispEdges">{"".join(out)}</g>'
1730
+ veil = "".join(f'<rect y="{y}" width="{SVG_WIDTH}" height="2" />'
1731
+ for y in range(0, SVG_HEIGHT, 6))
1732
+ body += f'<g fill="{pal[0]}" fill-opacity="0.22">{veil}</g>'
1733
+ return _banner_frame(body, pal, title)
1734
+
1735
+
1736
+ BANNER_COMPOSITIONS = {
1737
+ "circuit": banner_circuit,
1738
+ "flowfield": banner_flowfield,
1739
+ "lowpoly": banner_lowpoly,
1740
+ "pixelscene": banner_pixelscene,
1741
+ "starfield": banner_starfield,
1742
+ }
1743
+
1744
+
1745
+ _BANNER_WORD = re.compile(r"[a-z0-9]+")
1746
+
1747
+
1748
+ def _banner_words(text: str) -> List[str]:
1749
+ """Whole words, lowercased. Substring matching is not good enough here:
1750
+ a bare `"ci" in text` fires on 'decision', 'specific' and 'efficiency',
1751
+ which is enough to drag every article on a dev blog toward one look."""
1752
+ return _BANNER_WORD.findall(str(text or "").lower())
1753
+
1754
+
1755
+ def banner_signature(article: Dict[str, str]) -> str:
1756
+ """The text the art is derived from. Title and tags carry the topic; the
1757
+ section carries the editorial intent; a slice of the body keeps two posts
1758
+ with near-identical titles from colliding."""
1759
+ return " ".join([
1760
+ article.get("section", ""),
1761
+ article.get("categories", ""),
1762
+ article.get("title", ""),
1763
+ article.get("description", ""),
1764
+ article.get("body", "")[:600],
1765
+ ]).lower()
1766
+
1767
+
1768
+ def banner_seed_for(article: Dict[str, str]) -> int:
1769
+ """Seed from the article's CONTENT, not just its filename — so retitling or
1770
+ re-tagging a piece re-rolls its art, and two posts that merely sort next to
1771
+ each other don't come out kin."""
1772
+ key = banner_signature(article) + "|" + article.get("slug", "")
1773
+ return zlib.crc32(key.encode("utf-8"))
1774
+
1775
+
1776
+ def banner_theme_for(article: Dict[str, str]) -> Tuple[str, str]:
1777
+ """(composition, palette) for an article. Keyword hits decide; the seed only
1778
+ breaks ties and picks the palette, so the mapping is legible to a human
1779
+ reading the config rather than an opaque hash.
1780
+
1781
+ The fields are weighted, not pooled. A section name is a deliberate
1782
+ editorial choice and the tags and title are hand-written, so they carry
1783
+ most of the signal; the body is long, incidental, and full of words the
1784
+ author never chose, so each of its hits counts once and the whole field is
1785
+ capped. Pooling them all equally lets a 3,000-word article's stray
1786
+ vocabulary outvote its own title."""
1787
+ fields = (
1788
+ (article.get("section", ""), 6),
1789
+ (article.get("categories", ""), 3),
1790
+ (article.get("title", ""), 3),
1791
+ (article.get("description", ""), 2),
1792
+ (article.get("body", "")[:4000], 1),
1793
+ )
1794
+ scored = [(set(_banner_words(text)), weight) for text, weight in fields]
1795
+ seed = banner_seed_for(article)
1796
+
1797
+ best_name, best_score = "", 0
1798
+ for name, keywords in BANNER_KEYWORDS:
1799
+ wanted = set(keywords)
1800
+ score = 0
1801
+ for words, weight in scored:
1802
+ hits = len(wanted & words)
1803
+ # Cap the body's contribution: presence, not word count, is what
1804
+ # the low-weight field is evidence of.
1805
+ score += min(hits, 4) * weight if weight == 1 else hits * weight
1806
+ # Strictly greater, so ties fall to the earlier (more specific) entry.
1807
+ if score > best_score:
1808
+ best_name, best_score = name, score
1809
+ if not best_name:
1810
+ best_name = BANNER_COMPOSITION_ORDER[seed % len(BANNER_COMPOSITION_ORDER)]
1811
+
1812
+ palette = BANNER_PALETTE_ORDER[(seed >> 8) % len(BANNER_PALETTE_ORDER)]
1813
+ return best_name, palette
1814
+
1815
+
1816
+ def render_local_svg(title: str, seed: int,
1817
+ article: Optional[Dict[str, str]] = None) -> str:
1818
+ """Deterministic banner for the `local` provider (no network).
1819
+
1820
+ `article` carries the page's context (slug, section, title, tags, body); it
1821
+ selects the composition and palette. Without it — a caller that only has a
1822
+ slug — the art still renders, just chosen from the seed alone."""
1823
+ if article:
1824
+ composition, palette = banner_theme_for(article)
1825
+ art_seed = banner_seed_for(article)
1344
1826
  else:
1345
- for _ in range(40):
1346
- sx, sy = nxt(w), nxt(h // 2)
1347
- size = 3 + nxt(5)
1348
- parts.append(f'<rect x="{sx}" y="{sy}" width="{size}" height="{size}" fill="{pal[4]}"/>')
1349
- # CRT scanline veil + vignette bars
1350
- for y in range(0, h, 8):
1351
- parts.append(f'<rect y="{y}" width="{w}" height="1" fill="#000" opacity="0.10"/>')
1352
- parts.append(f'<rect width="{w}" height="26" fill="#000" opacity="0.35"/>')
1353
- parts.append(f'<rect y="{h - 26}" width="{w}" height="26" fill="#000" opacity="0.35"/>')
1354
- parts.append("</svg>")
1355
- return "".join(parts)
1827
+ composition = BANNER_COMPOSITION_ORDER[seed % len(BANNER_COMPOSITION_ORDER)]
1828
+ palette = BANNER_PALETTE_ORDER[(seed >> 8) % len(BANNER_PALETTE_ORDER)]
1829
+ art_seed = seed
1830
+ return BANNER_COMPOSITIONS[composition](
1831
+ art_seed, BANNER_PALETTES[palette], title)
1356
1832
 
1357
1833
 
1358
1834
  # =============================================================================
@@ -1708,6 +2184,21 @@ class Provider:
1708
2184
  def default_model(self) -> str:
1709
2185
  raise NotImplementedError
1710
2186
 
2187
+ def auth_description(self, env: Dict[str, str]) -> Optional[str]:
2188
+ """One line naming the credential this run will authenticate with, for
2189
+ the config banner. None (the default) means "there is nothing
2190
+ interesting to say" — a provider with exactly one possible credential."""
2191
+ return None
2192
+
2193
+ def output_stem(self, slug: str, ctx: "RunContext") -> str:
2194
+ """Filename stem this provider writes to, minus the extension.
2195
+
2196
+ Per-article by default. A provider that deliberately SHARES one image
2197
+ across many pages (the collection default) overrides this so the runner
2198
+ writes one file and stamps every page at it, instead of emitting N
2199
+ near-identical copies under N names."""
2200
+ return slug
2201
+
1711
2202
  def generate(self, prompt: str, settings: Settings, out_base: Path,
1712
2203
  ctx: "RunContext") -> ImageResult:
1713
2204
  raise NotImplementedError
@@ -1727,6 +2218,11 @@ class RunContext:
1727
2218
  env: Dict[str, str]
1728
2219
  anthropic: Optional[AnthropicClient] = None
1729
2220
  slug: str = ""
2221
+ # The page's own context (slug/section/title/categories/description/body),
2222
+ # set per file by the runner. The local renderer derives BOTH composition
2223
+ # and palette from it, which is what makes a banner about its article
2224
+ # instead of about its filename.
2225
+ article: Optional[Dict[str, str]] = None
1730
2226
 
1731
2227
  def claude(self) -> AnthropicClient:
1732
2228
  if self.anthropic is None:
@@ -1835,40 +2331,319 @@ class OpenAIProvider(Provider):
1835
2331
  return ImageResult(False, error=str(exc))
1836
2332
 
1837
2333
 
2334
+ # --- xAI (Grok) credentials --------------------------------------------------
2335
+ #
2336
+ # api.x.ai takes a plain `Authorization: Bearer <value>` for BOTH kinds of
2337
+ # credential, so the only thing that varies between a Grok OAuth access token
2338
+ # and a console API key is where the value comes from and what we tell the human
2339
+ # when it is rejected.
2340
+ #
2341
+ # Deliberately NOT implemented: minting or refreshing an OAuth token. xAI does
2342
+ # not publish its device-code/PKCE endpoints or a public client id, and the
2343
+ # credentials file belongs to whichever login tool wrote it — inventing a token
2344
+ # endpoint, or writing back into another tool's store, would be guesswork. The
2345
+ # engine CONSUMES a token that a Grok login already produced, treats an expired
2346
+ # one as absent, and falls back to the API key.
2347
+ XAI_API_BASE = "https://api.x.ai/v1"
2348
+
2349
+ # OAuth access token straight from the environment. XAI_ first (it matches the
2350
+ # vendor's own XAI_* naming), GROK_ accepted because that is what the
2351
+ # consumer-facing login is called.
2352
+ XAI_OAUTH_TOKEN_ENV: Tuple[str, ...] = ("XAI_OAUTH_TOKEN", "GROK_OAUTH_TOKEN")
2353
+
2354
+ # Explicit path to a credentials file written by a Grok OAuth login.
2355
+ XAI_CREDENTIALS_FILE_ENV: Tuple[str, ...] = (
2356
+ "XAI_OAUTH_CREDENTIALS", "GROK_OAUTH_CREDENTIALS",
2357
+ )
2358
+
2359
+ XAI_API_KEY_ENV = "XAI_API_KEY"
2360
+
2361
+ # Where a Grok login drops its token when no path is configured. Relative to the
2362
+ # home directory so a test (and a container) can point HOME elsewhere.
2363
+ XAI_DEFAULT_CREDENTIALS_FILES: Tuple[str, ...] = (
2364
+ ".grok/auth.json", ".config/grok/auth.json", ".xai/auth.json",
2365
+ )
2366
+
2367
+ # Credential files nest the token differently per tool; look at the top level
2368
+ # first, then inside the usual containers.
2369
+ XAI_NESTED_KEYS: Tuple[str, ...] = ("xai", "grok", "oauth", "tokens", "credentials")
2370
+
2371
+ # Treat a token that expires within the minute as already gone — a run can
2372
+ # easily outlive that, and the API key fallback is cheaper than a mid-run 401 on
2373
+ # every remaining page.
2374
+ XAI_EXPIRY_LEEWAY = 60.0
2375
+
2376
+ # Which rung(s) to try, as an env knob. `auto` (the default) is OAuth first with
2377
+ # the API key as the fallback; the other two pin one rung.
2378
+ XAI_AUTH_MODE_ENV = "XAI_AUTH"
2379
+ XAI_AUTH_MODES: Tuple[str, ...] = ("auto", "oauth", "api_key")
2380
+
2381
+ XAI_MISSING_HINT = (
2382
+ "The xAI provider needs a Grok OAuth token or an xAI API key — any ONE of:\n"
2383
+ " 1. XAI_OAUTH_TOKEN (or GROK_OAUTH_TOKEN) — an OAuth access token from a\n"
2384
+ " Grok login (SuperGrok / X Premium+)\n"
2385
+ " 2. a Grok OAuth credentials file — ~/.grok/auth.json by default, or the\n"
2386
+ " path in XAI_OAUTH_CREDENTIALS / GROK_OAUTH_CREDENTIALS\n"
2387
+ " 3. XAI_API_KEY — a key from console.x.ai (also the automatic fallback\n"
2388
+ " when an OAuth token is expired or rejected)\n"
2389
+ "Pin one rung with XAI_AUTH=oauth|api_key; the default tries OAuth first."
2390
+ )
2391
+
2392
+
2393
+ @dataclass
2394
+ class XAICredential:
2395
+ """One resolved way to authenticate against api.x.ai."""
2396
+ mode: str # "oauth" | "api_key" — which rung of the chain this is
2397
+ token: str
2398
+ source: str # human label for logs and fallback warnings
2399
+
2400
+ def headers(self) -> Dict[str, str]:
2401
+ return {"Authorization": f"Bearer {self.token}"}
2402
+
2403
+
2404
+ def xai_auth_mode(env: Dict[str, str]) -> str:
2405
+ raw = (env.get(XAI_AUTH_MODE_ENV) or "").strip().lower()
2406
+ if not raw:
2407
+ return "auto"
2408
+ if raw in XAI_AUTH_MODES:
2409
+ return raw
2410
+ debug(f"{XAI_AUTH_MODE_ENV}={raw} is not one of "
2411
+ f"{'|'.join(XAI_AUTH_MODES)}; using auto")
2412
+ return "auto"
2413
+
2414
+
2415
+ def xai_credential_chain(env: Dict[str, str]) -> List[XAICredential]:
2416
+ """The credentials to try, best rung first. Empty = provider unconfigured."""
2417
+ mode = xai_auth_mode(env)
2418
+ chain: List[XAICredential] = []
2419
+ if mode != "api_key":
2420
+ chain.extend(_xai_oauth_credentials(env))
2421
+ if mode != "oauth":
2422
+ api_key = _xai_api_key_credential(env)
2423
+ if api_key is not None:
2424
+ chain.append(api_key)
2425
+ return chain
2426
+
2427
+
2428
+ def xai_auth_describe(env: Dict[str, str]) -> Optional[str]:
2429
+ """One-line summary of how this run will authenticate (None when it cannot)."""
2430
+ chain = xai_credential_chain(env)
2431
+ if not chain:
2432
+ return None
2433
+ if len(chain) > 1:
2434
+ return f"{chain[0].source}, falling back to {chain[1].source}"
2435
+ return chain[0].source
2436
+
2437
+
2438
+ def xai_base_url(env: Dict[str, str]) -> str:
2439
+ """Base for the images endpoint. XAI_BASE_URL is xAI's own override and is
2440
+ honored here so an OAuth surface on a different host stays reachable."""
2441
+ configured = env.get("XAI_BASE_URL") or ""
2442
+ return configured.rstrip("/") if configured else XAI_API_BASE
2443
+
2444
+
2445
+ def _xai_oauth_credentials(env: Dict[str, str]) -> List[XAICredential]:
2446
+ for name in XAI_OAUTH_TOKEN_ENV:
2447
+ value = env.get(name)
2448
+ if value:
2449
+ return [XAICredential("oauth", value, f"{name} (Grok OAuth token)")]
2450
+ for path in _xai_credential_files(env):
2451
+ credential = _xai_file_credential(path)
2452
+ if credential is not None:
2453
+ return [credential]
2454
+ return []
2455
+
2456
+
2457
+ def _xai_api_key_credential(env: Dict[str, str]) -> Optional[XAICredential]:
2458
+ value = env.get(XAI_API_KEY_ENV)
2459
+ if not value:
2460
+ return None
2461
+ return XAICredential("api_key", value, f"{XAI_API_KEY_ENV} (xAI API key)")
2462
+
2463
+
2464
+ def _xai_credential_files(env: Dict[str, str]) -> List[str]:
2465
+ """Candidate credential-file paths: an explicit override wins outright, so a
2466
+ configured-but-missing path never silently reads someone else's token."""
2467
+ for name in XAI_CREDENTIALS_FILE_ENV:
2468
+ value = env.get(name)
2469
+ if value:
2470
+ return [value]
2471
+ home = _xai_home_dir(env)
2472
+ if not home:
2473
+ return []
2474
+ return [os.path.join(home, rel) for rel in XAI_DEFAULT_CREDENTIALS_FILES]
2475
+
2476
+
2477
+ def _xai_home_dir(env: Dict[str, str]) -> Optional[str]:
2478
+ """The env snapshot is the source of truth (tests and containers set HOME);
2479
+ the process's own home is only the last resort."""
2480
+ for name in ("HOME", "USERPROFILE"):
2481
+ value = env.get(name)
2482
+ if value:
2483
+ return value
2484
+ home = os.path.expanduser("~")
2485
+ # expanduser returns "~" unchanged when it cannot resolve a home directory.
2486
+ return home if home and home != "~" else None
2487
+
2488
+
2489
+ def _xai_file_credential(path: str) -> Optional[XAICredential]:
2490
+ """Read one credentials file. Anything unreadable, unparseable, tokenless or
2491
+ expired is "no credential here" — the chain moves on to the next rung."""
2492
+ if not os.path.isfile(path):
2493
+ return None
2494
+ try:
2495
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
2496
+ except json.JSONDecodeError as exc:
2497
+ debug(f"xAI OAuth: {path} is not valid JSON ({exc}); ignoring")
2498
+ return None
2499
+ except OSError as exc:
2500
+ debug(f"xAI OAuth: cannot read {path} ({exc}); ignoring")
2501
+ return None
2502
+ if not isinstance(data, dict):
2503
+ debug(f"xAI OAuth: {path} is not a JSON object; ignoring")
2504
+ return None
2505
+ node = _xai_token_node(data)
2506
+ if node is None:
2507
+ debug(f"xAI OAuth: no access_token in {path}; ignoring")
2508
+ return None
2509
+ if _xai_expired(node):
2510
+ debug(f"xAI OAuth: the token in {path} has expired; re-run your Grok login")
2511
+ return None
2512
+ return XAICredential("oauth", str(node["access_token"]),
2513
+ f"Grok OAuth token ({path})")
2514
+
2515
+
2516
+ def _xai_token_node(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
2517
+ """The dict actually holding `access_token`: the document itself, or one of
2518
+ the containers a login tool nests it under."""
2519
+ if _xai_token_string(data.get("access_token")):
2520
+ return data
2521
+ for key in XAI_NESTED_KEYS:
2522
+ nested = data.get(key)
2523
+ if isinstance(nested, dict) and _xai_token_string(nested.get("access_token")):
2524
+ return nested
2525
+ return None
2526
+
2527
+
2528
+ def _xai_token_string(value: Any) -> bool:
2529
+ return isinstance(value, str) and bool(value)
2530
+
2531
+
2532
+ def _xai_expired(node: Dict[str, Any], now: Optional[float] = None) -> bool:
2533
+ expires_at = _xai_absolute_expiry(node)
2534
+ if expires_at is None:
2535
+ return False
2536
+ return expires_at <= (time.time() if now is None else now) + XAI_EXPIRY_LEEWAY
2537
+
2538
+
2539
+ def _xai_absolute_expiry(node: Dict[str, Any]) -> Optional[float]:
2540
+ """Absolute expiry in epoch seconds, or None when the file records none."""
2541
+ for key in ("expires_at", "expiry", "expiration", "expires"):
2542
+ value = _xai_epoch_seconds(node.get(key))
2543
+ if value is not None:
2544
+ return value
2545
+ # `expires_in` is relative and only means something next to an issue time.
2546
+ lifetime = _xai_number(node.get("expires_in"))
2547
+ if lifetime is None:
2548
+ return None
2549
+ for key in ("created_at", "obtained_at", "issued_at", "issued"):
2550
+ issued = _xai_epoch_seconds(node.get(key))
2551
+ if issued is not None:
2552
+ return issued + lifetime
2553
+ return None
2554
+
2555
+
2556
+ def _xai_epoch_seconds(raw: Any) -> Optional[float]:
2557
+ """Accepts epoch seconds, epoch milliseconds, or an ISO-8601 timestamp."""
2558
+ value = _xai_number(raw)
2559
+ if value is not None:
2560
+ # 1e11 seconds is the year 5138; anything past it is milliseconds.
2561
+ return value / 1000.0 if value > 1e11 else value
2562
+ if not isinstance(raw, str) or not raw:
2563
+ return None
2564
+ text = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
2565
+ try:
2566
+ parsed = datetime.fromisoformat(text)
2567
+ except ValueError:
2568
+ return None
2569
+ if parsed.tzinfo is None:
2570
+ parsed = parsed.replace(tzinfo=timezone.utc)
2571
+ return parsed.timestamp()
2572
+
2573
+
2574
+ def _xai_number(raw: Any) -> Optional[float]:
2575
+ # bool is an int subclass; a JSON true/false is not a timestamp.
2576
+ if isinstance(raw, bool):
2577
+ return None
2578
+ if isinstance(raw, (int, float)):
2579
+ return float(raw)
2580
+ if not isinstance(raw, str) or not raw:
2581
+ return None
2582
+ try:
2583
+ return float(raw)
2584
+ except ValueError:
2585
+ return None
2586
+
2587
+
1838
2588
  class XAIProvider(Provider):
2589
+ """xAI (Grok) images/generations.
2590
+
2591
+ Two ways in, tried in order: a Grok OAuth access token, then the
2592
+ XAI_API_KEY. Both are Bearer credentials on the same endpoint, so the
2593
+ fallback is genuinely a retry with a different token — an OAuth token that
2594
+ has gone stale between runs costs one rejected request and then rides the
2595
+ API key instead of failing the page."""
1839
2596
  name = "xai"
1840
2597
 
2598
+ # Statuses that mean "this credential is no good" rather than "this request
2599
+ # is no good" — the only ones worth re-trying on the next rung.
2600
+ AUTH_FAILURE_STATUSES = (401, 403)
2601
+
2602
+ # xAI caps the prompt; the original engine slices the first 1000 chars.
2603
+ PROMPT_LIMIT = 1000
2604
+
1841
2605
  def is_configured(self, env: Dict[str, str]) -> bool:
1842
- return bool(env.get("XAI_API_KEY"))
2606
+ return bool(xai_credential_chain(env))
1843
2607
 
1844
2608
  def missing_hint(self, env: Dict[str, str]) -> str:
1845
- return "XAI_API_KEY environment variable is required for the xAI provider"
2609
+ return XAI_MISSING_HINT
2610
+
2611
+ def auth_description(self, env: Dict[str, str]) -> Optional[str]:
2612
+ return xai_auth_describe(env)
1846
2613
 
1847
2614
  def default_model(self) -> str:
1848
2615
  return "grok-2-image"
1849
2616
 
1850
2617
  def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
2618
+ chain = xai_credential_chain(ctx.env)
2619
+ if not chain:
2620
+ return ImageResult(False, error=XAI_MISSING_HINT)
1851
2621
  model = effective_model(settings, self)
1852
2622
  out_path = out_base.with_suffix(".png")
1853
- payload = {"model": model, "prompt": prompt[:1000], "n": 1} # 1024-char cap
1854
- try:
1855
- data = with_retries(
1856
- lambda: http_json(
1857
- "https://api.x.ai/v1/images/generations",
1858
- payload,
1859
- {"Authorization": f"Bearer {ctx.env['XAI_API_KEY']}"},
1860
- timeout=900,
1861
- ),
1862
- "xAI API",
1863
- )
1864
- entries = data.get("data") or []
1865
- if not entries or not _write_image_payload(entries[0], out_path):
1866
- return ImageResult(False, error="No image data in xAI response")
1867
- return ImageResult(True, "png", out_path)
1868
- except HttpStatusError as exc:
1869
- return ImageResult(False, error=f"xAI API error: {exc.message()}")
1870
- except Exception as exc:
1871
- return ImageResult(False, error=str(exc))
2623
+ payload = {"model": model, "prompt": prompt[:self.PROMPT_LIMIT], "n": 1}
2624
+ url = f"{xai_base_url(ctx.env)}/images/generations"
2625
+ for index, credential in enumerate(chain):
2626
+ debug(f"xAI generate: model={model} auth={credential.source}")
2627
+ try:
2628
+ data = with_retries(
2629
+ lambda: http_json(url, payload, credential.headers(), timeout=900),
2630
+ "xAI API",
2631
+ )
2632
+ entries = data.get("data") or []
2633
+ if not entries or not _write_image_payload(entries[0], out_path):
2634
+ return ImageResult(False, error="No image data in xAI response")
2635
+ return ImageResult(True, "png", out_path)
2636
+ except HttpStatusError as exc:
2637
+ fallback = chain[index + 1] if index + 1 < len(chain) else None
2638
+ if exc.status in self.AUTH_FAILURE_STATUSES and fallback is not None:
2639
+ warn(f"xAI rejected the {credential.source} (HTTP {exc.status}) — "
2640
+ f"retrying with {fallback.source}")
2641
+ continue
2642
+ return ImageResult(False, error=f"xAI API error: {exc.message()}")
2643
+ except Exception as exc:
2644
+ return ImageResult(False, error=str(exc))
2645
+ # Unreachable: the last rung has no fallback and always returns above.
2646
+ return ImageResult(False, error="No usable xAI credential")
1872
2647
 
1873
2648
 
1874
2649
  class StabilityProvider(Provider):
@@ -1977,11 +2752,17 @@ class SvgProviderMixin:
1977
2752
  if tool:
1978
2753
  svg_path.unlink(missing_ok=True)
1979
2754
  return ImageResult(True, "png", png_path)
1980
- warn(
1981
- "No SVG rasterizer available — keeping the .svg preview. Social "
1982
- "og:image works best as PNG: install librsvg (`brew install librsvg`) "
1983
- "or Playwright (`npx playwright install chromium`)."
1984
- )
2755
+ # `rasterizer: none` is a deliberate site policy (SVG-only), not a
2756
+ # missing dependency — say so instead of nagging about librsvg.
2757
+ if settings.rasterizer == "none":
2758
+ info(f"SVG-only mode (rasterizer: none) — wrote {svg_path.name}")
2759
+ else:
2760
+ warn(
2761
+ "No SVG rasterizer available — keeping the .svg preview. Social "
2762
+ "og:image works best as PNG: install librsvg (`brew install librsvg`) "
2763
+ "or Playwright (`npx playwright install chromium`), or set "
2764
+ "`preview_images.rasterizer: none` to make SVG-only the intent."
2765
+ )
1985
2766
  return ImageResult(True, "svg", svg_path)
1986
2767
 
1987
2768
 
@@ -1999,7 +2780,7 @@ class LocalProvider(Provider, SvgProviderMixin):
1999
2780
 
2000
2781
  def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
2001
2782
  seed = seed_for(ctx.slug or out_base.stem)
2002
- svg_text = render_local_svg(ctx.slug, seed)
2783
+ svg_text = render_local_svg(ctx.slug, seed, ctx.article)
2003
2784
  return self.finish_svg(svg_text, out_base, settings, ctx)
2004
2785
 
2005
2786
  def edit(self, image_path, prompt, settings, ctx, out_path=None) -> ImageResult:
@@ -2013,6 +2794,123 @@ class LocalProvider(Provider, SvgProviderMixin):
2013
2794
 
2014
2795
  # Renderers only — Claude is the orchestration layer (claude_article_brief /
2015
2796
  # claude_review_image) that sits in front of ANY of these, not a provider.
2797
+
2798
+ class DefaultProvider(Provider, SvgProviderMixin):
2799
+ """Key-free floor: ONE shared banner per collection/section.
2800
+
2801
+ The `local` provider draws a distinct procedural composition per article.
2802
+ That is right when the art is meant to be per-article, but when there is no
2803
+ AI credential anywhere it produces N near-identical images under N names —
2804
+ 91 flow fields that differ only by palette read as one picture repeated, and
2805
+ cost 91 files to say so. This provider is honest about that instead: pages
2806
+ that were never touched by an image model share a single deliberate banner
2807
+ for their section, and the repo carries one file per section rather than one
2808
+ per page.
2809
+
2810
+ Upgrade path: wire any renderer credential and `provider: auto` moves off
2811
+ this rung automatically.
2812
+ """
2813
+
2814
+ name = "default"
2815
+
2816
+ def is_configured(self, env: Dict[str, str]) -> bool:
2817
+ return True
2818
+
2819
+ def missing_hint(self, env: Dict[str, str]) -> str:
2820
+ return ""
2821
+
2822
+ def default_model(self) -> str:
2823
+ return "collection-default-svg"
2824
+
2825
+ def group_of(self, ctx: "RunContext") -> str:
2826
+ article = ctx.article or {}
2827
+ return str(article.get("section") or "").strip() or "default"
2828
+
2829
+ def output_stem(self, slug: str, ctx: "RunContext") -> str:
2830
+ # Every page in a section resolves to the same stem, so the runner
2831
+ # writes it once and stamps every page of that section at it.
2832
+ return f"section-{generate_filename(self.group_of(ctx)) or 'default'}"
2833
+
2834
+ def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
2835
+ group = self.group_of(ctx)
2836
+ # Seeded by the SECTION, not the article: the whole point is that the
2837
+ # image is shared, so it must not vary with whichever page happened to
2838
+ # trigger the write.
2839
+ seed = seed_for(group)
2840
+ composition, palette = banner_theme_for(
2841
+ {"slug": group, "section": group, "categories": group,
2842
+ "title": group, "description": "", "body": ""})
2843
+ svg_text = BANNER_COMPOSITIONS[composition](
2844
+ seed, BANNER_PALETTES[palette], group)
2845
+ return self.finish_svg(svg_text, out_base, settings, ctx)
2846
+
2847
+
2848
+ class ClaudeSvgProvider(Provider, SvgProviderMixin):
2849
+ """Claude authors the banner as SVG, from the article.
2850
+
2851
+ Claude cannot render pixels — the Anthropic API has no image endpoint — but
2852
+ it can WRITE vector markup, which is the one image format a language model
2853
+ can emit directly. That makes it the rung between "no credential at all"
2854
+ and "a raster image API": genuinely per-article art, no image-model spend.
2855
+
2856
+ The output goes through the same sanitizer as every other SVG, so a model
2857
+ that emits a <script>, a remote href, or a raster data: payload gets it
2858
+ stripped rather than committed.
2859
+ """
2860
+
2861
+ name = "claude"
2862
+
2863
+ def is_configured(self, env: Dict[str, str]) -> bool:
2864
+ return AnthropicClient(env).available()
2865
+
2866
+ def missing_hint(self, env: Dict[str, str]) -> str:
2867
+ return CLAUDE_CREDENTIAL_HINT
2868
+
2869
+ def default_model(self) -> str:
2870
+ return DEFAULT_CLAUDE_MODEL
2871
+
2872
+ def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
2873
+ article = ctx.article or {}
2874
+ instruction = (
2875
+ "Author a single SVG banner for this article. Reply with ONLY the "
2876
+ f"SVG markup, no prose and no code fence.\n\n"
2877
+ f"HARD REQUIREMENTS\n"
2878
+ f'- Root: <svg xmlns="{SVG_NS}" viewBox="0 0 {SVG_WIDTH} {SVG_HEIGHT}" '
2879
+ f'width="{SVG_WIDTH}" height="{SVG_HEIGHT}" role="img">\n'
2880
+ "- A <title> as the FIRST child describing the image.\n"
2881
+ "- Vector shapes only: no <script>, no <foreignObject>, no <image>, "
2882
+ "no external href, no data: payloads, no <text> (fonts will not "
2883
+ "resolve — convert lettering to paths or leave it out).\n"
2884
+ "- Fill the whole frame. No large empty regions.\n"
2885
+ "- Round coordinates to at most 2 decimals; stay under 200 KB.\n\n"
2886
+ f"ART DIRECTION\n{prompt}\n\n"
2887
+ f"ARTICLE\nTitle: {article.get('title', '')}\n"
2888
+ f"Section: {article.get('section', '')}\n"
2889
+ f"Tags: {article.get('categories', '')}\n"
2890
+ f"Description: {article.get('description', '')}\n"
2891
+ )
2892
+ try:
2893
+ reply = ctx.claude().complete(
2894
+ "You are an SVG illustrator. You reply with SVG markup only.",
2895
+ instruction,
2896
+ model=claude_model_for(settings),
2897
+ max_tokens=CLAUDE_MAX_TOKENS,
2898
+ effort=settings.claude_effort,
2899
+ )
2900
+ except Exception as exc: # noqa: BLE001 - any transport failure degrades
2901
+ return ImageResult(False, error=f"claude SVG authoring failed: {exc}")
2902
+ svg_text = _extract_svg(reply)
2903
+ if not svg_text:
2904
+ return ImageResult(False, error="claude returned no SVG markup")
2905
+ return self.finish_svg(svg_text, out_base, settings, ctx)
2906
+
2907
+
2908
+ def _extract_svg(text: str) -> str:
2909
+ """Pull the <svg>…</svg> out of a model reply, fence or no fence."""
2910
+ match = re.search(r"<svg\b.*?</svg>", str(text or ""), re.S | re.I)
2911
+ return match.group(0) if match else ""
2912
+
2913
+
2016
2914
  PROVIDERS: Dict[str, Provider] = {
2017
2915
  provider.name: provider
2018
2916
  for provider in (
@@ -2021,6 +2919,8 @@ PROVIDERS: Dict[str, Provider] = {
2021
2919
  StabilityProvider(),
2022
2920
  GeminiProvider(),
2023
2921
  LocalProvider(),
2922
+ DefaultProvider(),
2923
+ ClaudeSvgProvider(),
2024
2924
  )
2025
2925
  }
2026
2926
 
@@ -2110,7 +3010,15 @@ class Runner:
2110
3010
  warn(f"Cannot derive filename from title in {path}")
2111
3011
  self.stats.inc("errors")
2112
3012
  return
2113
- out_base = self.root / settings.output_dir / slug
3013
+ # The provider chooses the filename stem: per-article for the real
3014
+ # renderers, one shared file per collection for the key-free default.
3015
+ provider_for_paths = PROVIDERS.get(settings.provider)
3016
+ stem = slug
3017
+ if provider_for_paths is not None:
3018
+ stem = provider_for_paths.output_stem(
3019
+ slug, replace(self.ctx, slug=slug,
3020
+ article=article_art_context(cf, slug))) or slug
3021
+ out_base = self.root / settings.output_dir / stem
2114
3022
 
2115
3023
  # Style overrides apply only once an image/prompt is actually built.
2116
3024
  file_settings = file_preview_settings(settings, cf, self.authors)
@@ -2144,7 +3052,8 @@ class Runner:
2144
3052
  # local provider derives its deterministic seed from it). The copy
2145
3053
  # carries the shared AnthropicClient reference, which is stateless
2146
3054
  # after init and therefore thread-safe.
2147
- file_ctx = replace(self.ctx, slug=slug)
3055
+ file_ctx = replace(self.ctx, slug=slug,
3056
+ article=article_art_context(cf, slug))
2148
3057
  # ---- Produce: the selected raster model renders the brief ----
2149
3058
  result = provider.generate(prompt, file_settings, out_base, file_ctx)
2150
3059
 
@@ -2363,8 +3272,10 @@ def build_arg_parser() -> argparse.ArgumentParser:
2363
3272
  help="Process one collection by name, or 'all' for every "
2364
3273
  "configured collection")
2365
3274
  parser.add_argument("-p", "--provider",
2366
- choices=sorted(PROVIDERS.keys()),
2367
- help="AI provider (default: claude, via _config.yml)")
3275
+ choices=sorted(list(PROVIDERS.keys()) + ["auto"]),
3276
+ help="Renderer, or `auto` to pick the best one the "
3277
+ "environment can reach (raster API > claude SVG > "
3278
+ "shared collection default)")
2368
3279
  parser.add_argument("--model", help="Override the image/SVG model for the provider")
2369
3280
  parser.add_argument("--output-dir",
2370
3281
  help="Output directory for images (default: assets/images/previews)")
@@ -2393,7 +3304,8 @@ def build_arg_parser() -> argparse.ArgumentParser:
2393
3304
  "may request one refined regeneration (default: claude)")
2394
3305
  parser.add_argument("--rasterizer",
2395
3306
  choices=["auto", "rsvg", "inkscape", "magick", "playwright", "none"],
2396
- help="SVG→PNG tool for claude/local providers (default: auto)")
3307
+ help="SVG→PNG tool for claude/local providers (default: "
3308
+ "auto; `none` keeps the .svg and writes no PNG)")
2397
3309
  parser.add_argument("--style", help="Override image style prompt")
2398
3310
  parser.add_argument("--assets-prefix", help="Assets prefix for path normalization")
2399
3311
  parser.add_argument("--no-auto-prefix", action="store_true",
@@ -2425,6 +3337,60 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
2425
3337
  return args
2426
3338
 
2427
3339
 
3340
+ # Renderers that produce raster art, best first. `auto` walks this list before
3341
+ # falling back to the SVG rungs.
3342
+ AUTO_RASTER_ORDER: List[str] = ["openai", "xai", "stability", "gemini"]
3343
+
3344
+ # Env vars that constitute a real Claude credential. Deliberately NOT the same
3345
+ # test as AnthropicClient.available(), which also accepts a `claude` binary on
3346
+ # PATH: an installed CLI is not proof of a working login, and `auto` promoting
3347
+ # an unauthenticated machine off the default rung would fail every page at
3348
+ # generation time instead of quietly shipping the shared banner. An explicit
3349
+ # `--provider claude` still rides the CLI, because that is a human asserting it
3350
+ # works.
3351
+ CLAUDE_CREDENTIAL_ENV: Tuple[str, ...] = (
3352
+ "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY",
3353
+ )
3354
+
3355
+
3356
+ def _claude_credential_wired(env: Dict[str, str]) -> bool:
3357
+ return any(env.get(name) for name in CLAUDE_CREDENTIAL_ENV)
3358
+
3359
+
3360
+ def resolve_auto_provider(settings: Settings, env: Dict[str, str]) -> Settings:
3361
+ """Pick the best renderer the environment can actually reach.
3362
+
3363
+ The ladder, best rung first:
3364
+
3365
+ 1. a raster image API (OPENAI/XAI/STABILITY/GEMINI key) — and if a Claude
3366
+ credential is ALSO present, `prompt_engine: claude` already has Claude
3367
+ write the art brief that renderer works from, and review the result;
3368
+ 2. a Claude credential alone — Claude authors the banner as SVG, which is
3369
+ the one image format a language model can emit directly;
3370
+ 3. nothing wired — one shared, deliberate banner per collection.
3371
+
3372
+ Only `provider: auto` goes through this. An explicit provider is honored as
3373
+ written, so a site can always pin what it wants.
3374
+ """
3375
+ if settings.provider != "auto":
3376
+ return settings
3377
+
3378
+ for name in AUTO_RASTER_ORDER:
3379
+ provider = PROVIDERS.get(name)
3380
+ if provider is not None and provider.is_configured(env):
3381
+ claude = " (Claude writes the brief)" if _claude_credential_wired(env) else ""
3382
+ info(f"provider: auto → {name}{claude}")
3383
+ return replace(settings, provider=name)
3384
+
3385
+ if _claude_credential_wired(env):
3386
+ info("provider: auto → claude (no raster image key; Claude authors the SVG)")
3387
+ return replace(settings, provider="claude")
3388
+
3389
+ info("provider: auto → default (no image credential wired; "
3390
+ "one shared banner per collection)")
3391
+ return replace(settings, provider="default")
3392
+
3393
+
2428
3394
  def validate_credentials(settings: Settings, ctx: RunContext) -> None:
2429
3395
  """Credential checks are skipped for --list-missing/--dry-run (historical
2430
3396
  behavior), but an unknown provider name (from AI_PROVIDER / _config.yml —
@@ -2440,6 +3406,11 @@ def validate_credentials(settings: Settings, ctx: RunContext) -> None:
2440
3406
  return
2441
3407
  if not provider.is_configured(ctx.env):
2442
3408
  error_exit(provider.missing_hint(ctx.env))
3409
+ # Providers with more than one way in (xAI: OAuth token, then API key) say
3410
+ # which one this run picked, so a silent fallback is never a surprise.
3411
+ description = provider.auth_description(ctx.env)
3412
+ if description:
3413
+ info(f"{provider.name} auth: {description}")
2443
3414
 
2444
3415
 
2445
3416
  def main(argv: Optional[List[str]] = None) -> int:
@@ -2476,12 +3447,13 @@ def main(argv: Optional[List[str]] = None) -> int:
2476
3447
 
2477
3448
  print_header("🎨 Preview Image Generator")
2478
3449
  ctx = RunContext(project_root=project_root, env=dict(os.environ))
3450
+ settings = resolve_auto_provider(settings, ctx.env)
2479
3451
  validate_credentials(settings, ctx)
2480
3452
 
2481
3453
  # Claude orchestration (analyze/review) degrades gracefully: without a
2482
3454
  # Claude credential the run continues on template prompts, unreviewed.
2483
3455
  wants_claude = (
2484
- settings.provider != "local"
3456
+ settings.provider not in ("local", "default")
2485
3457
  and "claude" in (settings.prompt_engine, settings.review_engine)
2486
3458
  and not (settings.dry_run or settings.list_only)
2487
3459
  )