jekyll-theme-zer0 1.27.0 → 1.28.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +386 -4
  3. data/_data/consumers.yml +147 -0
  4. data/_data/features.yml +77 -5
  5. data/_data/i18n/fr.yml +98 -0
  6. data/_data/i18n/manifest.yml +1502 -0
  7. data/_data/navigation/docs.yml +2 -0
  8. data/_data/navigation/main.yml +6 -0
  9. data/_data/series.yml +19 -0
  10. data/_data/theme-manifest.yml +348 -266
  11. data/_includes/README.md +21 -2
  12. data/_includes/analytics/google-tag-manager-body.html +11 -2
  13. data/_includes/analytics/google-tag-manager-head.html +16 -6
  14. data/_includes/analytics/posthog.html +33 -4
  15. data/_includes/components/abc-letter.html +43 -0
  16. data/_includes/components/book-card.html +42 -0
  17. data/_includes/components/book-nav.html +80 -0
  18. data/_includes/components/book-plate.html +31 -0
  19. data/_includes/components/book-toc.html +47 -0
  20. data/_includes/components/bookshelf.html +68 -0
  21. data/_includes/components/card-grid.html +60 -0
  22. data/_includes/components/data-card.html +95 -0
  23. data/_includes/components/halfmoon.html +5 -1
  24. data/_includes/components/page-feedback.html +65 -2
  25. data/_includes/components/theme-controls-bar.html +10 -2
  26. data/_includes/components/theme-customizer.html +8 -2
  27. data/_includes/content/seo.html +9 -3
  28. data/_includes/core/color-mode-init.html +13 -4
  29. data/_includes/core/favicon.html +46 -0
  30. data/_includes/core/head.html +9 -0
  31. data/_includes/custom/body-end.html +18 -0
  32. data/_includes/custom/body-start.html +17 -0
  33. data/_includes/custom/footer.html +18 -0
  34. data/_includes/custom/head.html +18 -0
  35. data/_includes/navigation/local-graph.html +28 -2
  36. data/_includes/navigation/nav-tree.html +3 -3
  37. data/_includes/navigation/sidebar-config.html +21 -0
  38. data/_includes/navigation/sidebar-nav.html +4 -0
  39. data/_includes/navigation/sidebar-pagetree.html +150 -0
  40. data/_includes/navigation/unified-drawer.html +8 -2
  41. data/_includes/obsidian/full-graph.html +165 -136
  42. data/_layouts/404.html +260 -0
  43. data/_layouts/README.md +2 -0
  44. data/_layouts/book-abc.html +106 -0
  45. data/_layouts/book-story.html +91 -0
  46. data/_layouts/book.html +113 -0
  47. data/_layouts/collection.html +17 -5
  48. data/_layouts/home.html +5 -2
  49. data/_layouts/landing.html +9 -0
  50. data/_layouts/news.html +155 -31
  51. data/_layouts/root.html +16 -2
  52. data/_layouts/section.html +57 -15
  53. data/_sass/components/_book.scss +423 -0
  54. data/_sass/core/_navbar.scss +13 -0
  55. data/_sass/core/_obsidian.scss +286 -6
  56. data/_sass/theme/_backgrounds.scss +21 -8
  57. data/assets/css/main.scss +1 -0
  58. data/assets/js/auto-hide-nav.js +5 -1
  59. data/assets/js/halfmoon.js +26 -0
  60. data/assets/js/obsidian-graph.js +702 -264
  61. data/assets/js/obsidian-local-graph.js +161 -54
  62. data/assets/js/search-modal.js +4 -1
  63. data/scripts/bin/manifest +31 -4
  64. data/scripts/bin/validate +5 -1
  65. data/scripts/install/README.md +47 -6
  66. data/scripts/install/ai/client.sh +302 -93
  67. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  68. data/scripts/install/ai/wizard.sh +10 -5
  69. data/scripts/install/apply.sh +7 -3
  70. data/scripts/install/cli.sh +54 -4
  71. data/scripts/install/config.sh +167 -0
  72. data/scripts/install/doctor.sh +38 -0
  73. data/scripts/install/plan.sh +10 -0
  74. data/scripts/install/spec.sh +15 -7
  75. data/scripts/install/template.sh +4 -0
  76. data/scripts/propagate.rb +277 -0
  77. data/scripts/translate.rb +90 -0
  78. metadata +27 -2
@@ -1,3 +1,4 @@
1
+ // Feature: ZER0-045
1
2
  /*
2
3
  * obsidian-local-graph.js
3
4
  *
@@ -12,8 +13,13 @@
12
13
  * Subgraph:
13
14
  * - center = current page (matched against entry.url, falling back to
14
15
  * normalized title/basename/aliases for permalink quirks)
15
- * - depth = configurable via data-depth attribute (default 1)
16
- * - direction = both incoming and outgoing wiki-links
16
+ * - depth = 1–3, user-adjustable in the panel (default from the
17
+ * data-depth attribute / `local_graph_depth` front matter)
18
+ * - direction = outgoing and/or incoming wiki-links, user-toggleable
19
+ *
20
+ * Depth + direction preferences persist to localStorage
21
+ * (zer0.obsidianLocalGraph.v1) and the graph re-renders in place when they
22
+ * change — same for Bootstrap color-mode (data-bs-theme) switches.
17
23
  *
18
24
  * If the current page is in the wiki-index but has no local links, the panel
19
25
  * stays available and renders a single-node graph for the current page.
@@ -26,6 +32,7 @@
26
32
  var PANEL_SELECTOR = '[data-obsidian-local-graph-panel]';
27
33
  var TOGGLE_SELECTOR = '[data-obsidian-local-graph-toggle]';
28
34
  var STATUS_SELECTOR = '[data-obsidian-local-graph-status]';
35
+ var STORAGE_KEY = 'zer0.obsidianLocalGraph.v1';
29
36
 
30
37
  // Cytoscape is vendored under assets/vendor/ (no runtime CDN — matches the
31
38
  // Bootstrap / Icons / Mermaid policy). The path is supplied by Liquid via
@@ -57,6 +64,28 @@
57
64
  .replace(/"/g, '"').replace(/'/g, ''');
58
65
  }
59
66
 
67
+ // Depth + direction preferences. Depth falls back to the page's data-depth
68
+ // (front-matter `local_graph_depth`), directions default to both on.
69
+ function loadPrefs(defaultDepth) {
70
+ var prefs = { depth: defaultDepth, outgoing: true, incoming: true };
71
+ try {
72
+ var saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || 'null');
73
+ if (saved && typeof saved === 'object') {
74
+ var d = parseInt(saved.depth, 10);
75
+ if (d >= 1 && d <= 3) prefs.depth = d;
76
+ if (typeof saved.outgoing === 'boolean') prefs.outgoing = saved.outgoing;
77
+ if (typeof saved.incoming === 'boolean') prefs.incoming = saved.incoming;
78
+ }
79
+ } catch (e) { /* private mode / corrupt payload — use defaults */ }
80
+ return prefs;
81
+ }
82
+
83
+ function savePrefs(prefs) {
84
+ try {
85
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
86
+ } catch (e) { /* storage unavailable — prefs are session-only */ }
87
+ }
88
+
60
89
  function companionElements(container) {
61
90
  return {
62
91
  panel: container.closest(PANEL_SELECTOR),
@@ -143,10 +172,11 @@
143
172
  return palette[name] || '#6c757d';
144
173
  }
145
174
 
146
- // BFS from the current entry up to `depth` hops, following both
147
- // outgoing edges and incoming edges (any other entry whose `outgoing`
148
- // includes one of our keys).
149
- function buildSubgraph(entries, lookup, current, depth) {
175
+ // BFS from the current entry up to `depth` hops. Directions are opt-in:
176
+ // opts.outgoing follows this page's [[links]], opts.incoming follows any
177
+ // other entry whose `outgoing` includes one of our keys.
178
+ function buildSubgraph(entries, lookup, current, depth, opts) {
179
+ opts = opts || { outgoing: true, incoming: true };
150
180
  var visited = Object.create(null);
151
181
  var queue = [{ entry: current, dist: 0 }];
152
182
  var nodes = [];
@@ -206,45 +236,49 @@
206
236
  if (item.dist >= depth) continue;
207
237
 
208
238
  // Outgoing edges
209
- (entry.outgoing || []).forEach(function (target) {
210
- var nk = normalize(target);
211
- var resolved = lookup.byKey[nk];
212
- if (resolved) {
213
- if (resolved.url === entry.url) return;
214
- addEdge(entry.url, resolved.url, false);
215
- if (!visited[resolved.url]) {
216
- queue.push({ entry: resolved, dist: item.dist + 1 });
239
+ if (opts.outgoing) {
240
+ (entry.outgoing || []).forEach(function (target) {
241
+ var nk = normalize(target);
242
+ var resolved = lookup.byKey[nk];
243
+ if (resolved) {
244
+ if (resolved.url === entry.url) return;
245
+ addEdge(entry.url, resolved.url, false);
246
+ if (!visited[resolved.url]) {
247
+ queue.push({ entry: resolved, dist: item.dist + 1 });
248
+ }
249
+ } else {
250
+ var brokenId = '__broken__:' + nk;
251
+ if (!visited[brokenId]) {
252
+ visited[brokenId] = true;
253
+ nodes.push({
254
+ group: 'nodes',
255
+ data: {
256
+ id: brokenId,
257
+ label: target,
258
+ url: null,
259
+ collection: 'broken',
260
+ color: '#dc3545',
261
+ broken: true
262
+ }
263
+ });
264
+ }
265
+ addEdge(entry.url, brokenId, true);
217
266
  }
218
- } else {
219
- var brokenId = '__broken__:' + nk;
220
- if (!visited[brokenId]) {
221
- visited[brokenId] = true;
222
- nodes.push({
223
- group: 'nodes',
224
- data: {
225
- id: brokenId,
226
- label: target,
227
- url: null,
228
- collection: 'broken',
229
- color: '#dc3545',
230
- broken: true
231
- }
232
- });
233
- }
234
- addEdge(entry.url, brokenId, true);
235
- }
236
- });
267
+ });
268
+ }
237
269
 
238
270
  // Incoming edges (anyone whose outgoing matches one of our keys)
239
- keysFor(entry).forEach(function (k) {
240
- (reverse[k] || []).forEach(function (src) {
241
- if (src.url === entry.url) return;
242
- addEdge(src.url, entry.url, false);
243
- if (!visited[src.url]) {
244
- queue.push({ entry: src, dist: item.dist + 1 });
245
- }
271
+ if (opts.incoming) {
272
+ keysFor(entry).forEach(function (k) {
273
+ (reverse[k] || []).forEach(function (src) {
274
+ if (src.url === entry.url) return;
275
+ addEdge(src.url, entry.url, false);
276
+ if (!visited[src.url]) {
277
+ queue.push({ entry: src, dist: item.dist + 1 });
278
+ }
279
+ });
246
280
  });
247
- });
281
+ }
248
282
  }
249
283
 
250
284
  return nodes.concat(edges);
@@ -299,6 +333,11 @@
299
333
  }
300
334
 
301
335
  function render(container, elements, currentUrl) {
336
+ // Re-renders (depth/direction/theme changes) replace the prior instance.
337
+ if (container.__obsidianLocalGraph) {
338
+ try { container.__obsidianLocalGraph.destroy(); } catch (e) { /* ignore */ }
339
+ container.__obsidianLocalGraph = null;
340
+ }
302
341
  var theme = readTheme();
303
342
  container.style.backgroundColor = theme.canvasBg;
304
343
  var motion = prefersReducedMotion() ? '0ms' : '160ms';
@@ -460,6 +499,59 @@
460
499
  return nav;
461
500
  }
462
501
 
502
+ // Reflect prefs into the depth radio group + direction switches.
503
+ function syncControls(prefs) {
504
+ var radio = document.getElementById('obsidian-lg-depth-' + prefs.depth);
505
+ if (radio) radio.checked = true;
506
+ var outgoing = document.getElementById('obsidian-lg-outgoing');
507
+ if (outgoing) outgoing.checked = prefs.outgoing;
508
+ var incoming = document.getElementById('obsidian-lg-incoming');
509
+ if (incoming) incoming.checked = prefs.incoming;
510
+ }
511
+
512
+ function wireControls(prefs, rebuild) {
513
+ [1, 2, 3].forEach(function (d) {
514
+ var radio = document.getElementById('obsidian-lg-depth-' + d);
515
+ if (!radio) return;
516
+ radio.addEventListener('change', function () {
517
+ if (!radio.checked) return;
518
+ prefs.depth = d;
519
+ savePrefs(prefs);
520
+ rebuild();
521
+ });
522
+ });
523
+ var outgoing = document.getElementById('obsidian-lg-outgoing');
524
+ if (outgoing) {
525
+ outgoing.addEventListener('change', function () {
526
+ prefs.outgoing = outgoing.checked;
527
+ savePrefs(prefs);
528
+ rebuild();
529
+ });
530
+ }
531
+ var incoming = document.getElementById('obsidian-lg-incoming');
532
+ if (incoming) {
533
+ incoming.addEventListener('change', function () {
534
+ prefs.incoming = incoming.checked;
535
+ savePrefs(prefs);
536
+ rebuild();
537
+ });
538
+ }
539
+ }
540
+
541
+ // Re-render on Bootstrap color-mode changes so canvas colors track the
542
+ // theme without a reload. Local graphs are small — a full rebuild is cheap.
543
+ function watchTheme(rebuild) {
544
+ var restyle = debounce(rebuild, 60);
545
+ var observer = new MutationObserver(restyle);
546
+ [document.documentElement, document.body].forEach(function (el) {
547
+ observer.observe(el, { attributes: true, attributeFilter: ['data-bs-theme'] });
548
+ });
549
+ if (window.matchMedia) {
550
+ var mq = window.matchMedia('(prefers-color-scheme: dark)');
551
+ if (mq.addEventListener) mq.addEventListener('change', restyle);
552
+ }
553
+ }
554
+
463
555
  function init() {
464
556
  var container = document.getElementById(CONTAINER_ID);
465
557
  if (!container) return;
@@ -478,8 +570,9 @@
478
570
  resizeGraph(container);
479
571
  }, 150));
480
572
 
481
- var depth = parseInt(container.getAttribute('data-depth') || '1', 10);
482
- if (!isFinite(depth) || depth < 1) depth = 1;
573
+ var defaultDepth = parseInt(container.getAttribute('data-depth') || '1', 10);
574
+ if (!isFinite(defaultDepth) || defaultDepth < 1) defaultDepth = 1;
575
+ var prefs = loadPrefs(Math.min(defaultDepth, 3));
483
576
  var indexUrl = container.getAttribute('data-index-url') ||
484
577
  ((document.querySelector('base') || {}).href || '/') +
485
578
  'assets/data/wiki-index.json';
@@ -496,17 +589,21 @@
496
589
  // Confirmed in-index: reveal the panel + FAB now.
497
590
  setPanelAvailable(container, true);
498
591
  setStatus(container, 'Loading graph…', false);
499
-
500
- var elements = buildSubgraph(entries, lookup, current, depth);
501
- var nodeCount = elements.filter(function (element) { return element.group === 'nodes'; }).length;
502
- var edgeCount = elements.filter(function (element) { return element.group === 'edges'; }).length;
503
- // Accessible text fallback (also the graceful degradation if cytoscape
504
- // can't load): a list of linked neighbours below the canvas.
505
- var fallback = renderTextFallback(container, elements, current);
506
-
507
- loadCytoscape(function (ok) {
508
- if (ok === false) {
509
- // Keep the text list visible; hide the empty canvas.
592
+ syncControls(prefs);
593
+
594
+ var graphAvailable = null; // unknown until loadCytoscape resolves
595
+
596
+ function rebuild() {
597
+ var elements = buildSubgraph(entries, lookup, current, prefs.depth, {
598
+ outgoing: prefs.outgoing,
599
+ incoming: prefs.incoming
600
+ });
601
+ var nodeCount = elements.filter(function (el) { return el.group === 'nodes'; }).length;
602
+ var edgeCount = elements.filter(function (el) { return el.group === 'edges'; }).length;
603
+ // Accessible text fallback (also the graceful degradation if
604
+ // cytoscape can't load): a list of linked neighbours below the canvas.
605
+ var fallback = renderTextFallback(container, elements, current);
606
+ if (graphAvailable === false) {
510
607
  container.hidden = true;
511
608
  setStatus(container, 'Showing linked pages (interactive graph unavailable).', false);
512
609
  return;
@@ -515,6 +612,16 @@
515
612
  setStatus(container, nodeCount + ' pages · ' + edgeCount + ' links', false);
516
613
  // Graph is the visual representation; keep the list for AT only.
517
614
  if (fallback) fallback.classList.add('visually-hidden');
615
+ }
616
+
617
+ wireControls(prefs, rebuild);
618
+ watchTheme(function () {
619
+ if (container.__obsidianLocalGraph) rebuild();
620
+ });
621
+
622
+ loadCytoscape(function (ok) {
623
+ graphAvailable = ok !== false;
624
+ rebuild();
518
625
  });
519
626
  })
520
627
  .catch(function (err) {
@@ -74,8 +74,11 @@
74
74
  if (typeof bootstrap === 'undefined') return;
75
75
  const cookieEl = document.getElementById('cookieSettingsModal');
76
76
  const infoEl = document.getElementById('info-section');
77
+ const drawerEl = document.getElementById('zer0UnifiedDrawer');
77
78
  afterModalClosed(cookieEl, () => {
78
- afterOffcanvasClosed(infoEl, showSearchModal);
79
+ afterOffcanvasClosed(infoEl, () => {
80
+ afterOffcanvasClosed(drawerEl, showSearchModal);
81
+ });
79
82
  });
80
83
  };
81
84
 
data/scripts/bin/manifest CHANGED
@@ -35,12 +35,15 @@ DESCRIPTION:
35
35
  Run automatically by scripts/bin/release before tagging.
36
36
 
37
37
  OPTIONS:
38
+ --check Exit non-zero if the committed manifest is stale; write
39
+ nothing. `generated_at` is ignored in the comparison.
38
40
  --dry-run Print manifest to stdout; don't write the file
39
41
  --verbose Show each file being processed
40
42
  --help, -h Show this help message
41
43
 
42
44
  EXAMPLES:
43
45
  ./scripts/bin/manifest # Regenerate _data/theme-manifest.yml
46
+ ./scripts/bin/manifest --check # CI gate: is the manifest current?
44
47
  ./scripts/bin/manifest --dry-run # Preview output
45
48
  ./scripts/bin/manifest --verbose # Show every file being hashed
46
49
  EOF
@@ -81,9 +84,11 @@ OPTIONAL_PLUGIN_PATHS=(
81
84
  # Argument parsing
82
85
  DRY_RUN=false
83
86
  VERBOSE=false
87
+ CHECK=false
84
88
 
85
89
  while [[ $# -gt 0 ]]; do
86
90
  case $1 in
91
+ --check) CHECK=true; DRY_RUN=true ;;
87
92
  --dry-run) DRY_RUN=true ;;
88
93
  --verbose) VERBOSE=true ;;
89
94
  --help|-h) show_usage; exit 0 ;;
@@ -93,7 +98,14 @@ while [[ $# -gt 0 ]]; do
93
98
  done
94
99
 
95
100
  # ---------------------------------------------------------------------------
96
- step "Generating theme manifest..."
101
+ # Under --dry-run stdout carries only the manifest YAML — the CI freshness gate
102
+ # (ci.yml "Theme manifest freshness") diffs that stdout straight against the
103
+ # committed file, so progress logs must not pollute it.
104
+ if [[ "$DRY_RUN" == "true" ]]; then
105
+ step "Generating theme manifest..." >&2
106
+ else
107
+ step "Generating theme manifest..."
108
+ fi
97
109
  cd "$REPO_ROOT"
98
110
 
99
111
  CURRENT_VERSION=$(get_current_version)
@@ -173,10 +185,25 @@ YAML
173
185
  )
174
186
 
175
187
  # ---------------------------------------------------------------------------
176
- if [[ "$DRY_RUN" == "true" ]]; then
177
- info "[DRY RUN] Would write _data/theme-manifest.yml:"
188
+ MANIFEST_FILE="$REPO_ROOT/_data/theme-manifest.yml"
189
+
190
+ if [[ "$CHECK" == "true" ]]; then
191
+ # Staleness = any difference other than the generation timestamp, which moves
192
+ # on every run and would otherwise report a false positive every time.
193
+ if [[ ! -f "$MANIFEST_FILE" ]]; then
194
+ error "Manifest missing: _data/theme-manifest.yml — run ./scripts/bin/manifest"
195
+ fi
196
+ if diff -u \
197
+ <(grep -v '^generated_at:' "$MANIFEST_FILE") \
198
+ <(echo "$MANIFEST_CONTENT" | grep -v '^generated_at:') >&2; then
199
+ success "Manifest is current (version $CURRENT_VERSION)" >&2
200
+ exit 0
201
+ fi
202
+ error "Manifest is stale — run ./scripts/bin/manifest and commit the result"
203
+ elif [[ "$DRY_RUN" == "true" ]]; then
204
+ info "[DRY RUN] Would write _data/theme-manifest.yml:" >&2
178
205
  echo "$MANIFEST_CONTENT"
179
206
  else
180
- echo "$MANIFEST_CONTENT" > "$REPO_ROOT/_data/theme-manifest.yml"
207
+ echo "$MANIFEST_CONTENT" > "$MANIFEST_FILE"
181
208
  success "Written: _data/theme-manifest.yml (version $CURRENT_VERSION)"
182
209
  fi
data/scripts/bin/validate CHANGED
@@ -391,7 +391,11 @@ if File.file?('_config_secrets_local.yml')
391
391
  assert(!tracked, '_config_secrets_local.yml must remain untracked and ignored')
392
392
  end
393
393
 
394
- ignored_dirs = %w[.git _site node_modules vendor .jekyll-cache .sass-cache test/fixtures]
394
+ # examples/ holds self-contained demo sites, each with its own _config.yml and
395
+ # _config_dev.yml. Those belong to the example, not to this site's config
396
+ # surface, so prune the tree rather than registering every example's configs in
397
+ # classified_files below (which would need editing for each new example).
398
+ ignored_dirs = %w[.git _site node_modules vendor .jekyll-cache .sass-cache test/fixtures examples]
395
399
  config_like_files = []
396
400
 
397
401
  Find.find('.') do |path|
@@ -86,8 +86,11 @@ This installer runs on macOS's `/bin/bash` (3.2). Restrictions:
86
86
  # Local development
87
87
  ./scripts/bin/install help
88
88
  ./scripts/bin/install init . --profile blog --site-title "My Blog"
89
- ./scripts/bin/install wizard . --ai
90
- ./scripts/bin/install doctor .
89
+ ./scripts/bin/install init . --config zer0.install.yml # config-file driven
90
+ ./scripts/bin/install wizard . --ai # Claude Code OAuth if available
91
+ ./scripts/bin/install wizard . --ai --ai-provider anthropic
92
+ ./scripts/bin/install suggest . "a docs site for a Rust CLI"
93
+ ./scripts/bin/install doctor . # shows the active AI provider
91
94
  ./scripts/bin/install diff .
92
95
  ./scripts/bin/install plan . --profile docs
93
96
 
@@ -110,17 +113,55 @@ curl -fsSL https://raw.githubusercontent.com/bamr87/zer0-mistakes/main/scripts/b
110
113
  3. Add template to `templates/config/` or `templates/pages/`
111
114
  4. List in the appropriate profile YAML under `templates/profiles/`
112
115
 
116
+ ## Config file layer
117
+
118
+ Sites can persist installer choices in a small YAML file instead of re-typing flags. `config.sh` discovers and merges these (low→high precedence):
119
+
120
+ 1. `~/.config/zer0/install.yml` (user-global)
121
+ 2. `<target>/zer0.install.yml` (project-local)
122
+ 3. `<target>/.zer0/config.yml` (project-local, hidden)
123
+ 4. `$ZER0_CONFIG` / `--config FILE` (explicit — wins)
124
+
125
+ The config layer sits between profile defaults and env/flag overrides:
126
+
127
+ ```
128
+ defaults < profile < CONFIG FILE < env vars < CLI flags
129
+ ```
130
+
131
+ Recognised keys (nested **or** flat/dotted both work): `profile`, `site.{title,description,author,email,url,timezone,locale}`, `github.{user,repo,pages_branch,enable_pages}`, `theme.{source,version}`, `deploy`, `agents`, `tasks`, `ai.provider`, `ai.model`. Example:
132
+
133
+ ```yaml
134
+ profile: github-pages
135
+ site:
136
+ title: "My Docs"
137
+ deploy: [github-pages]
138
+ ai:
139
+ provider: claude-cli # auto | claude-cli | anthropic | openai | none
140
+ ```
141
+
142
+ API keys are **never** read from config files — they stay in the environment.
143
+
113
144
  ## AI integration
114
145
 
115
146
  The AI path is a first-class citizen but never mandatory:
116
147
 
117
148
  - `ai_wizard_run` → interactive LLM spec generation
118
- - `ai_diagnose_run` → post-build error analysis
119
- - `ai_suggest_run` → profile + deploy recommendation
149
+ - `ai_diagnose_run` → post-build error analysis
150
+ - `ai_suggest_run` → profile + deploy recommendation (also `install suggest`)
151
+
152
+ All are guarded by the `ZER0_NO_AI=1` kill-switch and degrade gracefully to defaults or rule-based logic when AI is unavailable.
153
+
154
+ ### Providers (`ai/client.sh`)
155
+
156
+ The client is multi-provider. `ZER0_AI_PROVIDER` (or config `ai.provider`, or `--ai-provider`) selects one; the default `auto` resolves in this order:
120
157
 
121
- All three are guarded by `ZER0_NO_AI=1` kill-switch and degrade gracefully to defaults or rule-based logic when AI is unavailable.
158
+ | # | Provider | Auth | Notes |
159
+ |---|--------------|-------------------------------------------------|-------|
160
+ | 1 | `claude-cli` | the logged-in `claude` CLI (**Claude Code OAuth**) | Zero key handling — reuses `claude setup-token` / `claude login`. Preferred when the `claude` binary is on `PATH`. |
161
+ | 2 | `anthropic` | `CLAUDE_CODE_OAUTH_TOKEN` (OAuth) or `ANTHROPIC_API_KEY` | Anthropic Messages API over HTTPS. |
162
+ | 3 | `openai` | `OPENAI_API_KEY` (or `OPENAI_BASE_URL` for Azure/Ollama) | OpenAI-compatible `/chat/completions`. |
122
163
 
123
- To enable: set `OPENAI_API_KEY` (or `OPENAI_BASE_URL` for Azure/Ollama).
164
+ Model is auto-defaulted per provider and overridable with `ZER0_AI_MODEL` / `--ai-model` (`ANTHROPIC_MODEL` / `OPENAI_MODEL` also honoured). Run `install doctor` to see which provider is active. All calls are single-attempt with a 30s timeout; user context is sanitized before it leaves the host.
124
165
 
125
166
  ## Deploy plugins
126
167