jekyll-theme-zer0 1.27.0 → 1.29.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +812 -13
  3. data/_data/backlog.yml +356 -2
  4. data/_data/consumers.yml +252 -0
  5. data/_data/features.yml +234 -5
  6. data/_data/i18n/fr.yml +103 -0
  7. data/_data/i18n/manifest.yml +1529 -0
  8. data/_data/ingredient_densities.yml +122 -0
  9. data/_data/navigation/docs.yml +2 -0
  10. data/_data/navigation/main.yml +22 -0
  11. data/_data/recipe_courses.yml +64 -0
  12. data/_data/series.yml +19 -0
  13. data/_data/theme-manifest.yml +387 -277
  14. data/_data/ui-text.yml +8 -0
  15. data/_includes/README.md +36 -3
  16. data/_includes/analytics/google-tag-manager-body.html +11 -2
  17. data/_includes/analytics/google-tag-manager-head.html +16 -6
  18. data/_includes/analytics/posthog.html +33 -4
  19. data/_includes/components/abc-letter.html +43 -0
  20. data/_includes/components/background-customizer.html +2 -2
  21. data/_includes/components/book-card.html +42 -0
  22. data/_includes/components/book-nav.html +80 -0
  23. data/_includes/components/book-plate.html +31 -0
  24. data/_includes/components/book-toc.html +47 -0
  25. data/_includes/components/bookshelf.html +68 -0
  26. data/_includes/components/card-grid.html +60 -0
  27. data/_includes/components/cookie-consent.html +4 -4
  28. data/_includes/components/data-card.html +95 -0
  29. data/_includes/components/halfmoon.html +5 -1
  30. data/_includes/components/info-section.html +8 -3
  31. data/_includes/components/language-toggle.html +168 -21
  32. data/_includes/components/page-feedback.html +65 -2
  33. data/_includes/components/page-views-init.html +55 -0
  34. data/_includes/components/page-views.html +33 -0
  35. data/_includes/components/recipe-card.html +67 -0
  36. data/_includes/components/recipe-duration.html +50 -0
  37. data/_includes/components/recipe-grams.html +58 -0
  38. data/_includes/components/recipe-index.html +96 -0
  39. data/_includes/components/recipe-ingredients.html +90 -0
  40. data/_includes/components/recipe-meta.html +96 -0
  41. data/_includes/components/recipe-nutrition.html +57 -0
  42. data/_includes/components/recipe-qty.html +73 -0
  43. data/_includes/components/recipe-ratio.html +151 -0
  44. data/_includes/components/recipe-scaler.html +73 -0
  45. data/_includes/components/recipe-steps.html +86 -0
  46. data/_includes/components/recipe-temp.html +45 -0
  47. data/_includes/components/search-modal.html +2 -2
  48. data/_includes/components/shortcuts-modal.html +3 -0
  49. data/_includes/components/theme-controls-bar.html +10 -2
  50. data/_includes/components/theme-customizer.html +8 -2
  51. data/_includes/components/theme-info.html +8 -1
  52. data/_includes/content/jsonld-software.html +22 -3
  53. data/_includes/content/seo.html +9 -3
  54. data/_includes/core/color-mode-init.html +13 -4
  55. data/_includes/core/favicon.html +46 -0
  56. data/_includes/core/footer.html +18 -16
  57. data/_includes/core/head.html +21 -0
  58. data/_includes/core/header.html +7 -4
  59. data/_includes/custom/body-end.html +18 -0
  60. data/_includes/custom/body-start.html +17 -0
  61. data/_includes/custom/footer.html +18 -0
  62. data/_includes/custom/head.html +18 -0
  63. data/_includes/navigation/local-graph.html +28 -2
  64. data/_includes/navigation/nav-tree.html +3 -3
  65. data/_includes/navigation/section-sidebar.html +93 -11
  66. data/_includes/navigation/sidebar-config.html +21 -0
  67. data/_includes/navigation/sidebar-left.html +2 -1
  68. data/_includes/navigation/sidebar-nav.html +4 -0
  69. data/_includes/navigation/sidebar-pagetree.html +150 -0
  70. data/_includes/navigation/sidebar-right.html +2 -1
  71. data/_includes/navigation/unified-drawer.html +8 -2
  72. data/_includes/obsidian/full-graph.html +165 -136
  73. data/_includes/setup/wizard.html +173 -86
  74. data/_includes/stats/stats-metrics.html +2 -2
  75. data/_layouts/404.html +260 -0
  76. data/_layouts/README.md +4 -0
  77. data/_layouts/admin.html +2 -2
  78. data/_layouts/article.html +5 -0
  79. data/_layouts/book-abc.html +106 -0
  80. data/_layouts/book-story.html +91 -0
  81. data/_layouts/book.html +113 -0
  82. data/_layouts/collection.html +17 -5
  83. data/_layouts/cookbook.html +88 -0
  84. data/_layouts/default.html +4 -4
  85. data/_layouts/home.html +5 -2
  86. data/_layouts/landing.html +9 -0
  87. data/_layouts/news.html +155 -31
  88. data/_layouts/recipe.html +274 -0
  89. data/_layouts/root.html +38 -5
  90. data/_layouts/section.html +96 -25
  91. data/_sass/components/_book.scss +423 -0
  92. data/_sass/components/_callout.scss +1 -1
  93. data/_sass/components/_footer.scss +37 -1
  94. data/_sass/components/_page-views.scss +36 -0
  95. data/_sass/components/_recipe.scss +506 -0
  96. data/_sass/components/_setup-wizard.scss +235 -0
  97. data/_sass/components/_ui-enhancements.scss +6 -6
  98. data/_sass/core/_navbar.scss +263 -15
  99. data/_sass/core/_obsidian.scss +286 -6
  100. data/_sass/layouts/_landing.scss +2 -2
  101. data/_sass/theme/_backgrounds.scss +21 -8
  102. data/_sass/tokens/_color.scss +6 -0
  103. data/_sass/tokens/_index.scss +2 -0
  104. data/_sass/tokens/_radius.scss +21 -0
  105. data/_sass/tokens/_typography.scss +4 -0
  106. data/_sass/utilities/_focus.scss +14 -0
  107. data/assets/css/main.scss +4 -0
  108. data/assets/js/auto-hide-nav.js +5 -1
  109. data/assets/js/halfmoon.js +26 -0
  110. data/assets/js/modules/navigation/navbar.js +55 -0
  111. data/assets/js/obsidian-graph.js +702 -264
  112. data/assets/js/obsidian-local-graph.js +161 -54
  113. data/assets/js/page-views.js +372 -0
  114. data/assets/js/recipe-scaler.js +501 -0
  115. data/assets/js/search-modal.js +14 -1
  116. data/assets/js/setup-wizard.js +322 -20
  117. data/scripts/README.md +29 -0
  118. data/scripts/bin/audit-consumer +39 -7
  119. data/scripts/bin/giscus-discussions +213 -14
  120. data/scripts/bin/manifest +66 -16
  121. data/scripts/bin/validate +5 -1
  122. data/scripts/ci/agent_review_result.py +164 -0
  123. data/scripts/ci/test_agent_review_result.py +172 -0
  124. data/scripts/design-system-check.rb +170 -0
  125. data/scripts/install/README.md +47 -6
  126. data/scripts/install/ai/client.sh +302 -93
  127. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  128. data/scripts/install/ai/wizard.sh +10 -5
  129. data/scripts/install/apply.sh +7 -3
  130. data/scripts/install/cli.sh +54 -4
  131. data/scripts/install/config.sh +167 -0
  132. data/scripts/install/doctor.sh +38 -0
  133. data/scripts/install/plan.sh +10 -0
  134. data/scripts/install/spec.sh +15 -7
  135. data/scripts/install/template.sh +4 -0
  136. data/scripts/lib/audit.sh +42 -2
  137. data/scripts/lint-liquid-raw.rb +137 -0
  138. data/scripts/propagate.rb +277 -0
  139. data/scripts/test/lib/run_tests.sh +2 -1
  140. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  141. data/scripts/translate.rb +161 -15
  142. metadata +56 -2
@@ -1,52 +1,153 @@
1
1
  #!/bin/bash
2
2
  # =============================================================================
3
- # scripts/install/ai/client.sh — HTTP client for AI providers
3
+ # scripts/install/ai/client.sh — Multi-provider AI client
4
4
  # =============================================================================
5
- # Thin curl wrapper that:
6
- # - Targets OpenAI-compatible APIs (OpenAI, Azure OpenAI, Ollama)
7
- # - Enforces 30s timeout
8
- # - Redacts Authorization header from logs
9
- # - Returns raw JSON response body to stdout
5
+ # A thin curl/CLI wrapper that speaks to whichever AI provider is available.
6
+ # Three providers are supported, resolved in this order under `auto`:
10
7
  #
11
- # Provides:
12
- # ai_client_chat SYSTEM_PROMPT USER_PROMPT [JSON_SCHEMA]
13
- # → print raw API JSON response to stdout
14
- # → return 0 on success, 1 on error
8
+ # 1. claude-cli — the local, OAuth-authenticated `claude` CLI (Claude Code).
9
+ # Zero key handling: reuses the user's Claude Code session
10
+ # (`claude setup-token` / `claude login`). This is the
11
+ # flagship "Claude Code OAuth" integration.
12
+ # 2. anthropic — Anthropic Messages API over HTTPS. Auth resolves from:
13
+ # CLAUDE_CODE_OAUTH_TOKEN → OAuth bearer + beta header
14
+ # ANTHROPIC_API_KEY → x-api-key
15
+ # 3. openai — OpenAI-compatible /chat/completions (OpenAI, Azure, Ollama).
15
16
  #
16
- # ai_client_extract_text RESPONSE_JSON
17
- # → print the assistant's text content from a chat response
17
+ # Public API (stable — wizard.sh / suggest.sh / diagnose.sh depend on it):
18
+ # ai_client_available → 0 if a usable provider exists, else 1
19
+ # ai_client_provider → echo resolved provider slug (or "none")
20
+ # ai_client_model [PROVIDER] → echo model id for a provider ("" = default)
21
+ # ai_client_auth_source → echo human-readable auth description
22
+ # ai_client_chat SYS USER [SCHEMA] → print raw provider JSON to stdout
23
+ # ai_client_extract_text RESP → print assistant text (any provider shape)
18
24
  #
19
- # ai_client_available
20
- # → return 0 if OPENAI_API_KEY or OPENAI_BASE_URL is set, 1 otherwise
25
+ # Selection & tuning (all optional, env or config-file driven):
26
+ # ZER0_AI_PROVIDER auto | claude-cli | anthropic | openai | none
27
+ # ZER0_AI_MODEL Override model id for the active provider
28
+ # ZER0_AI_MAX_TOKENS Output cap for the anthropic path (default 2048)
29
+ # ZER0_NO_AI=1 Global kill-switch (disables every provider)
30
+ # OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL (openai path)
31
+ # ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL / ANTHROPIC_MODEL (anthropic path)
32
+ # CLAUDE_CODE_OAUTH_TOKEN (anthropic path, OAuth — takes precedence)
21
33
  #
22
- # Environment:
23
- # OPENAI_API_KEY Required for openai.com
24
- # OPENAI_BASE_URL Override base URL (Azure OpenAI / Ollama compatible)
25
- # Default: https://api.openai.com/v1
26
- # OPENAI_MODEL Model name (default: gpt-4o-mini)
27
- # ZER0_NO_AI Set to 1 to disable all AI calls (kill-switch)
34
+ # Contract: API keys are read from the environment ONLY — never logged, never
35
+ # accepted via flag, never written to disk. All network I/O is single-attempt
36
+ # with a 30s timeout and degrades gracefully (callers check the return code).
28
37
  #
29
- # Bash 3.2 compatible. No set -euo pipefail here.
38
+ # Bash 3.2 compatible. No `declare -A`, no `set -euo pipefail` here.
30
39
  # =============================================================================
31
40
  [[ -n "${_HAS_AI_CLIENT:-}" ]] && return 0
32
41
  _HAS_AI_CLIENT=1
33
42
 
34
- _AI_BASE_URL="${OPENAI_BASE_URL:-https://api.openai.com/v1}"
35
- _AI_MODEL="${OPENAI_MODEL:-gpt-4o-mini}"
36
- _AI_TIMEOUT=30
43
+ _AI_TIMEOUT="${ZER0_AI_TIMEOUT:-30}"
37
44
 
38
45
  # ---------------------------------------------------------------------------
39
- # ai_client_available
46
+ # Provider detection helpers
40
47
  # ---------------------------------------------------------------------------
48
+ _ai_have_claude_cli() { command -v claude >/dev/null 2>&1; }
49
+
50
+ # _ai_provider_usable PROVIDER → 0 if that provider has what it needs.
51
+ _ai_provider_usable() {
52
+ case "$1" in
53
+ claude-cli) _ai_have_claude_cli ;;
54
+ anthropic) [[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" || -n "${ANTHROPIC_API_KEY:-}" ]] ;;
55
+ openai) [[ -n "${OPENAI_API_KEY:-}" || -n "${OPENAI_BASE_URL:-}" ]] ;;
56
+ *) return 1 ;;
57
+ esac
58
+ }
59
+
60
+ # ai_client_provider → resolved provider slug. Honors an explicit choice
61
+ # (ZER0_AI_PROVIDER / SPEC_AI_PROVIDER); "auto"/"" picks the best available.
62
+ ai_client_provider() {
63
+ [[ "${ZER0_NO_AI:-0}" == "1" ]] && { echo "none"; return 0; }
64
+
65
+ local want="${ZER0_AI_PROVIDER:-${SPEC_AI_PROVIDER:-auto}}"
66
+ case "$want" in
67
+ claude|claude-cli|claude_cli) echo "claude-cli"; return 0 ;;
68
+ anthropic|claude-api) echo "anthropic"; return 0 ;;
69
+ openai|azure|ollama) echo "openai"; return 0 ;;
70
+ none|off|disabled) echo "none"; return 0 ;;
71
+ auto|"") ;; # fall through to auto-detect
72
+ *) log_warning "Unknown AI provider '$want' — falling back to auto" ;;
73
+ esac
74
+
75
+ if _ai_have_claude_cli; then echo "claude-cli"; return 0; fi
76
+ if [[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" || -n "${ANTHROPIC_API_KEY:-}" ]]; then
77
+ echo "anthropic"; return 0
78
+ fi
79
+ if [[ -n "${OPENAI_API_KEY:-}" || -n "${OPENAI_BASE_URL:-}" ]]; then
80
+ echo "openai"; return 0
81
+ fi
82
+ echo "none"
83
+ }
84
+
85
+ # ai_client_available → 0 if the resolved provider is actually usable.
41
86
  ai_client_available() {
42
87
  [[ "${ZER0_NO_AI:-0}" == "1" ]] && return 1
43
- [[ -n "${OPENAI_API_KEY:-}" ]] && return 0
44
- [[ -n "${OPENAI_BASE_URL:-}" ]] && return 0
45
- return 1
88
+ local p
89
+ p="$(ai_client_provider)"
90
+ [[ "$p" == "none" ]] && return 1
91
+ _ai_provider_usable "$p"
92
+ }
93
+
94
+ # ai_client_model [PROVIDER] → model id ("" means "use the provider default").
95
+ ai_client_model() {
96
+ local p="${1:-$(ai_client_provider)}"
97
+ if [[ -n "${ZER0_AI_MODEL:-}" ]]; then echo "$ZER0_AI_MODEL"; return 0; fi
98
+ if [[ -n "${SPEC_AI_MODEL:-}" ]]; then echo "$SPEC_AI_MODEL"; return 0; fi
99
+ case "$p" in
100
+ anthropic) echo "${ANTHROPIC_MODEL:-claude-sonnet-5}" ;;
101
+ claude-cli) echo "${ANTHROPIC_MODEL:-}" ;; # empty → CLI's own default
102
+ *) echo "${OPENAI_MODEL:-gpt-4o-mini}" ;;
103
+ esac
104
+ }
105
+
106
+ # ai_client_auth_source → short description of how the active provider authenticates.
107
+ ai_client_auth_source() {
108
+ case "$(ai_client_provider)" in
109
+ claude-cli) echo "claude CLI session (OAuth)" ;;
110
+ anthropic)
111
+ if [[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then echo "CLAUDE_CODE_OAUTH_TOKEN (OAuth)"
112
+ else echo "ANTHROPIC_API_KEY"; fi ;;
113
+ openai)
114
+ if [[ -n "${OPENAI_API_KEY:-}" ]]; then echo "OPENAI_API_KEY"
115
+ else echo "OPENAI_BASE_URL (keyless)"; fi ;;
116
+ *) echo "none" ;;
117
+ esac
118
+ }
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # JSON + sanitization helpers
122
+ # ---------------------------------------------------------------------------
123
+ # _ai_json_string STR → STR encoded as a JSON string literal (with quotes).
124
+ _ai_json_string() {
125
+ if command -v python3 >/dev/null 2>&1; then
126
+ printf '%s' "$1" | python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))"
127
+ elif command -v jq >/dev/null 2>&1; then
128
+ printf '%s' "$1" | jq -Rs .
129
+ else
130
+ printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' '\001' | sed 's/\o001/\\n/g')"
131
+ fi
132
+ }
133
+
134
+ # _ai_sanitize STR → strip likely secrets/PII before a payload leaves the host.
135
+ # Reads from stdin. BSD/GNU-sed compatible. (Contract: sanitize before send.)
136
+ _ai_sanitize() {
137
+ local home_esc
138
+ home_esc="$(printf '%s' "${HOME:-/nonexistent}" | sed 's:/:\\/:g')"
139
+ sed \
140
+ -e "s/${home_esc}/~/g" \
141
+ -e 's/sk-ant-[A-Za-z0-9_-]\{12,\}/[REDACTED_ANTHROPIC_KEY]/g' \
142
+ -e 's/sk-[A-Za-z0-9_-]\{20,\}/[REDACTED_API_KEY]/g' \
143
+ -e 's/ghp_[A-Za-z0-9]\{20,\}/[REDACTED_GITHUB_TOKEN]/g' \
144
+ -e 's/github_pat_[A-Za-z0-9_]\{20,\}/[REDACTED_GITHUB_PAT]/g' \
145
+ -e 's/[A-Za-z0-9._%+-]\{1,\}@[A-Za-z0-9.-]\{1,\}\.[A-Za-z]\{2,\}/[REDACTED_EMAIL]/g'
46
146
  }
47
147
 
48
148
  # ---------------------------------------------------------------------------
49
149
  # ai_client_chat SYSTEM_PROMPT USER_PROMPT [JSON_SCHEMA]
150
+ # Dispatches to the resolved provider. Prints raw provider JSON to stdout.
50
151
  # ---------------------------------------------------------------------------
51
152
  ai_client_chat() {
52
153
  local system_prompt="$1"
@@ -54,111 +155,219 @@ ai_client_chat() {
54
155
  local json_schema="${3:-}"
55
156
 
56
157
  if ! ai_client_available; then
57
- log_error "ai_client_chat: AI not available (set OPENAI_API_KEY or OPENAI_BASE_URL)"
158
+ log_error "ai_client_chat: no AI provider available (see 'install doctor')"
58
159
  return 1
59
160
  fi
60
161
 
61
- local api_key="${OPENAI_API_KEY:-}"
62
- local endpoint="${_AI_BASE_URL}/chat/completions"
162
+ # Sanitize the user-supplied context (never the system prompt — it's ours).
163
+ user_prompt="$(printf '%s' "$user_prompt" | _ai_sanitize)"
164
+
165
+ local provider
166
+ provider="$(ai_client_provider)"
167
+ log_debug "ai_client_chat: provider=${provider} model=$(ai_client_model "$provider")"
168
+
169
+ case "$provider" in
170
+ claude-cli) _ai_chat_claude_cli "$system_prompt" "$user_prompt" "$json_schema" ;;
171
+ anthropic) _ai_chat_anthropic "$system_prompt" "$user_prompt" "$json_schema" ;;
172
+ openai) _ai_chat_openai "$system_prompt" "$user_prompt" "$json_schema" ;;
173
+ *) log_error "ai_client_chat: unresolved provider '$provider'"; return 1 ;;
174
+ esac
175
+ }
176
+
177
+ # ---- claude-cli: shell out to the OAuth-authenticated Claude Code CLI --------
178
+ _ai_chat_claude_cli() {
179
+ local system_prompt="$1" user_prompt="$2"
180
+ local model prompt out
181
+ model="$(ai_client_model claude-cli)"
182
+
183
+ # Claude Code owns its own system prompt; append ours and put the request
184
+ # in the user turn. The whole thing is fed on stdin (no prompt on argv, so
185
+ # nothing sensitive lands in the process table).
186
+ prompt="$(printf '%s\n\n%s' "$system_prompt" "$user_prompt")"
187
+
188
+ local mflag
189
+ mflag=()
190
+ [[ -n "$model" ]] && mflag=(--model "$model")
191
+
192
+ if out="$(printf '%s' "$prompt" | claude -p --output-format json \
193
+ ${mflag[@]+"${mflag[@]}"} 2>/dev/null)"; then
194
+ printf '%s\n' "$out"
195
+ return 0
196
+ fi
197
+
198
+ # Fallback: plain-text output → wrap so ai_client_extract_text can read it.
199
+ if out="$(printf '%s' "$prompt" | claude -p ${mflag[@]+"${mflag[@]}"} 2>/dev/null)"; then
200
+ printf '{"result":%s}\n' "$(_ai_json_string "$out")"
201
+ return 0
202
+ fi
203
+
204
+ log_error "ai_client_chat: claude CLI call failed (is it logged in? try 'claude login')"
205
+ return 1
206
+ }
207
+
208
+ # ---- anthropic: Anthropic Messages API (OAuth token or API key) --------------
209
+ _ai_chat_anthropic() {
210
+ local system_prompt="$1" user_prompt="$2"
211
+ local base model endpoint
212
+ base="${ANTHROPIC_BASE_URL:-https://api.anthropic.com}"
213
+ model="$(ai_client_model anthropic)"
214
+ endpoint="${base%/}/v1/messages"
215
+
216
+ local auth
217
+ auth=()
218
+ if [[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
219
+ # OAuth tokens require the beta header and a Claude Code system identity.
220
+ auth=(-H "authorization: Bearer ${CLAUDE_CODE_OAUTH_TOKEN}" \
221
+ -H "anthropic-beta: oauth-2025-04-20")
222
+ system_prompt="$(printf '%s\n\n%s' \
223
+ "You are Claude Code, Anthropic's official CLI for Claude." "$system_prompt")"
224
+ elif [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then
225
+ auth=(-H "x-api-key: ${ANTHROPIC_API_KEY}")
226
+ else
227
+ log_error "ai_client_chat: anthropic provider needs CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY"
228
+ return 1
229
+ fi
230
+
231
+ local sys_escaped user_escaped body
232
+ sys_escaped="$(_ai_json_string "$system_prompt")"
233
+ user_escaped="$(_ai_json_string "$user_prompt")"
234
+ body="$(cat <<JSON
235
+ {
236
+ "model": "${model}",
237
+ "max_tokens": ${ZER0_AI_MAX_TOKENS:-2048},
238
+ "system": ${sys_escaped},
239
+ "messages": [ {"role": "user", "content": ${user_escaped}} ]
240
+ }
241
+ JSON
242
+ )"
243
+
244
+ log_debug "ai_client_chat: POST ${endpoint} model=${model} (anthropic)"
245
+ local resp
246
+ resp="$(curl -fsSL --max-time "$_AI_TIMEOUT" \
247
+ -H "content-type: application/json" \
248
+ -H "anthropic-version: 2023-06-01" \
249
+ ${auth[@]+"${auth[@]}"} \
250
+ -d "$body" "$endpoint" 2>&1)"
251
+ if [[ $? -ne 0 ]]; then
252
+ log_error "ai_client_chat: anthropic request failed"
253
+ return 1
254
+ fi
255
+ printf '%s\n' "$resp"
256
+ }
257
+
258
+ # ---- openai: OpenAI-compatible /chat/completions (unchanged behavior) --------
259
+ _ai_chat_openai() {
260
+ local system_prompt="$1" user_prompt="$2" json_schema="$3"
261
+ local base model endpoint api_key
262
+ base="${OPENAI_BASE_URL:-https://api.openai.com/v1}"
263
+ model="$(ai_client_model openai)"
264
+ endpoint="${base}/chat/completions"
265
+ api_key="${OPENAI_API_KEY:-}"
63
266
 
64
- # Build request body — escape for JSON
65
267
  local sys_escaped user_escaped
66
- sys_escaped=$(printf '%s' "$system_prompt" | python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" 2>/dev/null \
67
- || printf '%s' "$system_prompt" | sed 's/\\/\\\\/g; s/"/\\"/g; s/$/\\n/g' | tr -d '\n' | sed 's/\\n$//')
68
- user_escaped=$(printf '%s' "$user_prompt" | python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" 2>/dev/null \
69
- || printf '%s' "$user_prompt" | sed 's/\\/\\\\/g; s/"/\\"/g')
268
+ sys_escaped="$(_ai_json_string "$system_prompt")"
269
+ user_escaped="$(_ai_json_string "$user_prompt")"
70
270
 
71
271
  local body
72
272
  if [[ -n "$json_schema" ]]; then
73
- # Structured output (function calling)
74
- body=$(cat <<JSON
273
+ body="$(cat <<JSON
75
274
  {
76
- "model": "${_AI_MODEL}",
77
- "response_format": {
78
- "type": "json_schema",
79
- "json_schema": ${json_schema}
80
- },
275
+ "model": "${model}",
276
+ "response_format": { "type": "json_schema", "json_schema": ${json_schema} },
81
277
  "messages": [
82
278
  {"role": "system", "content": ${sys_escaped}},
83
279
  {"role": "user", "content": ${user_escaped}}
84
280
  ]
85
281
  }
86
282
  JSON
87
- )
283
+ )"
88
284
  else
89
- body=$(cat <<JSON
285
+ body="$(cat <<JSON
90
286
  {
91
- "model": "${_AI_MODEL}",
287
+ "model": "${model}",
92
288
  "messages": [
93
289
  {"role": "system", "content": ${sys_escaped}},
94
290
  {"role": "user", "content": ${user_escaped}}
95
291
  ]
96
292
  }
97
293
  JSON
98
- )
294
+ )"
99
295
  fi
100
296
 
101
- log_debug "ai_client_chat: POST ${endpoint} model=${_AI_MODEL}"
102
-
297
+ log_debug "ai_client_chat: POST ${endpoint} model=${model} (openai)"
103
298
  local resp
104
- resp=$(curl -fsSL \
105
- --max-time "$_AI_TIMEOUT" \
299
+ resp="$(curl -fsSL --max-time "$_AI_TIMEOUT" \
106
300
  -H "Content-Type: application/json" \
107
301
  ${api_key:+-H "Authorization: Bearer ${api_key}"} \
108
- -d "$body" \
109
- "$endpoint" 2>&1)
110
-
111
- local ret=$?
112
- if [[ $ret -ne 0 ]]; then
113
- log_error "ai_client_chat: HTTP request failed (exit $ret)"
302
+ -d "$body" "$endpoint" 2>&1)"
303
+ if [[ $? -ne 0 ]]; then
304
+ log_error "ai_client_chat: openai request failed"
114
305
  return 1
115
306
  fi
116
-
117
307
  printf '%s\n' "$resp"
118
- return 0
119
308
  }
120
309
 
121
310
  # ---------------------------------------------------------------------------
122
311
  # ai_client_extract_text RESPONSE_JSON
123
- # Extracts assistant text and strips markdown code fences if present.
312
+ # Normalizes across provider shapes and strips markdown code fences:
313
+ # openai → .choices[0].message.content
314
+ # anthropic → concatenated .content[].text
315
+ # claude → .result (claude -p --output-format json)
124
316
  # ---------------------------------------------------------------------------
125
317
  ai_client_extract_text() {
126
318
  local resp="$1"
127
- local text
319
+ local text=""
320
+ # Primary: jq. NOTE: test keys with plain truthiness (`if .choices`), NOT
321
+ # `// empty` — an empty stream in an `if` condition makes jq emit nothing.
128
322
  if command -v jq >/dev/null 2>&1; then
129
- text=$(printf '%s' "$resp" | jq -r '.choices[0].message.content // ""')
130
- else
131
- # Minimal awk extraction
132
- text=$(printf '%s' "$resp" | awk '
133
- /"content"[[:space:]]*:/ {
134
- line = $0
135
- sub(/.*"content"[[:space:]]*:[[:space:]]*"/, "", line)
136
- sub(/"[^"]*$/, "", line)
137
- gsub(/\\n/, "\n", line)
138
- print line
139
- exit
323
+ text="$(printf '%s' "$resp" | jq -r '
324
+ if .choices then (.choices[0].message.content // "")
325
+ elif .content then ([.content[]? | select(.type=="text") | .text] | join(""))
326
+ elif (.result != null) then .result
327
+ else "" end
328
+ ' 2>/dev/null)"
329
+ fi
330
+ # Secondary: python3 (handles the same three shapes robustly).
331
+ if [[ -z "$text" ]] && command -v python3 >/dev/null 2>&1; then
332
+ text="$(printf '%s' "$resp" | python3 -c '
333
+ import json, sys
334
+ try:
335
+ d = json.load(sys.stdin)
336
+ except Exception:
337
+ sys.exit(0)
338
+ if isinstance(d, dict):
339
+ if d.get("choices"):
340
+ print(d["choices"][0].get("message", {}).get("content", ""))
341
+ elif isinstance(d.get("content"), list):
342
+ print("".join(b.get("text", "") for b in d["content"]
343
+ if isinstance(b, dict) and b.get("type") == "text"))
344
+ elif d.get("result") is not None:
345
+ print(d["result"])
346
+ ' 2>/dev/null)"
347
+ fi
348
+ # Last resort: awk scan to the first unescaped closing quote of the value.
349
+ if [[ -z "$text" ]]; then
350
+ text="$(printf '%s' "$resp" | awk '
351
+ function extract(s, i,c,prev,out) {
352
+ out=""; prev=""
353
+ for (i=1; i<=length(s); i++) {
354
+ c=substr(s,i,1)
355
+ if (c=="\"" && prev!="\\") return out
356
+ out=out c; prev=(prev=="\\")?"":c
357
+ }
358
+ return out
140
359
  }
141
- ')
360
+ {
361
+ split("result content text", ks, " ")
362
+ for (k=1; k<=3; k++) {
363
+ if (match($0, "\"" ks[k] "\"[[:space:]]*:[[:space:]]*\"")) {
364
+ v=extract(substr($0, RSTART+RLENGTH))
365
+ gsub(/\\n/, "\n", v); gsub(/\\"/, "\"", v)
366
+ print v; exit
367
+ }
368
+ }
369
+ }
370
+ ')"
142
371
  fi
143
- # Strip markdown code fences (```json, ```, etc.) — remove any line starting with ```
144
372
  printf '%s\n' "$text" | sed '/^[[:space:]]*```/d'
145
373
  }
146
-
147
- # ---------------------------------------------------------------------------
148
- # _ai_json_object_body SYSTEM_PROMPT USER_PROMPT
149
- # Build a request body that forces json_object output (no schema needed)
150
- # ---------------------------------------------------------------------------
151
- _ai_json_object_body() {
152
- local sys_escaped="$1"
153
- local user_escaped="$2"
154
- cat <<JSON
155
- {
156
- "model": "${_AI_MODEL}",
157
- "response_format": {"type": "json_object"},
158
- "messages": [
159
- {"role": "system", "content": ${sys_escaped}},
160
- {"role": "user", "content": ${user_escaped}}
161
- ]
162
- }
163
- JSON
164
- }
@@ -119,7 +119,7 @@
119
119
  "description": "Metadata about AI involvement. Written by ai/wizard.sh; informational only.",
120
120
  "properties": {
121
121
  "used": { "type": "boolean", "default": false },
122
- "provider": { "type": "string", "default": "openai" },
122
+ "provider": { "type": "string", "default": "auto", "description": "Resolved AI provider: auto|claude-cli|anthropic|openai|none." },
123
123
  "model": { "type": "string", "default": "" },
124
124
  "tokens_estimated": { "type": "integer", "minimum": 0, "default": 0 },
125
125
  "spec_hash": { "type": "string", "default": "", "description": "sha256 of the spec JSON (without the ai.spec_hash field itself)." }
@@ -38,7 +38,8 @@ ai_wizard_run() {
38
38
  fi
39
39
 
40
40
  if ! ai_client_available; then
41
- log_error "AI wizard requires OPENAI_API_KEY or OPENAI_BASE_URL"
41
+ log_error "AI wizard needs an AI provider — install the 'claude' CLI (Claude Code OAuth),"
42
+ log_error "or set CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY / OPENAI_API_KEY. Run 'install doctor'."
42
43
  return 1
43
44
  fi
44
45
 
@@ -79,8 +80,12 @@ Output ONLY the JSON object, no prose, no markdown fences.
79
80
  PROMPT
80
81
  )
81
82
 
83
+ local _ai_provider _ai_model
84
+ _ai_provider="$(ai_client_provider)"
85
+ _ai_model="$(ai_client_model "$_ai_provider")"
86
+
82
87
  log_banner "AI Installation Wizard"
83
- log_info "Connecting to AI... (model: ${OPENAI_MODEL:-gpt-4o-mini})"
88
+ log_info "Connecting to AI (provider: ${_ai_provider}, auth: $(ai_client_auth_source)${_ai_model:+, model: ${_ai_model}})"
84
89
 
85
90
  local resp
86
91
  resp=$(ai_client_chat "$sys_prompt" "$user_prompt")
@@ -132,10 +137,10 @@ PROMPT
132
137
  fi
133
138
  # Apply platform defaults
134
139
  plan_apply_platform
135
- # Record AI metadata
140
+ # Record AI metadata (the provider that actually served this run)
136
141
  SPEC_AI_USED=true
137
- SPEC_AI_PROVIDER="${OPENAI_PROVIDER:-openai}"
138
- SPEC_AI_MODEL="${OPENAI_MODEL:-gpt-4o-mini}"
142
+ SPEC_AI_PROVIDER="$_ai_provider"
143
+ SPEC_AI_MODEL="$_ai_model"
139
144
  export SPEC_AI_USED SPEC_AI_PROVIDER SPEC_AI_MODEL
140
145
  # Write final spec
141
146
  spec_write "$(spec_path "$target")"
@@ -140,10 +140,14 @@ apply_run() {
140
140
  log_warning "Deploy task failed: $task (continuing)"
141
141
  done
142
142
 
143
- # Agent files
143
+ # Agent files — run once. Skip if `agents` already ran in the task loop
144
+ # above (SPEC_AGENTS being set does not mean it should run twice).
144
145
  if [[ -n "${SPEC_AGENTS:-}" ]]; then
145
- apply_task "agents" "$target" || \
146
- log_warning "Agents task failed (continuing)"
146
+ case " ${SPEC_TASKS} " in
147
+ *" agents "*) ;; # already applied as a task
148
+ *) apply_task "agents" "$target" || \
149
+ log_warning "Agents task failed (continuing)" ;;
150
+ esac
147
151
  fi
148
152
 
149
153
  if [[ -n "$failed_tasks" ]]; then
@@ -63,6 +63,7 @@ _cli_load_core() {
63
63
  _cli_require "fs.sh"
64
64
  _cli_require "template.sh"
65
65
  _cli_require "prompt.sh"
66
+ _cli_require "config.sh"
66
67
  _cli_require "spec.sh"
67
68
  _cli_require "plan.sh"
68
69
  _cli_require "apply.sh"
@@ -95,6 +96,9 @@ _cli_parse_flags() {
95
96
  _FLAG_AGENTS=""
96
97
  _FLAG_TASKS=""
97
98
  _FLAG_AI=0
99
+ _FLAG_AI_PROVIDER=""
100
+ _FLAG_AI_MODEL=""
101
+ _FLAG_CONFIG=""
98
102
  _FLAG_SPEC=""
99
103
  _FLAG_SCRAPE_URL=""
100
104
  _FLAG_SCRAPE_DEPTH=""
@@ -111,6 +115,7 @@ _cli_parse_flags() {
111
115
  _FLAG_SITE_URL _FLAG_SITE_AUTHOR _FLAG_SITE_EMAIL \
112
116
  _FLAG_GITHUB_USER _FLAG_GITHUB_REPO _FLAG_THEME_SOURCE \
113
117
  _FLAG_DEPLOY _FLAG_AGENTS _FLAG_TASKS _FLAG_AI _FLAG_SPEC \
118
+ _FLAG_AI_PROVIDER _FLAG_AI_MODEL _FLAG_CONFIG \
114
119
  _FLAG_SCRAPE_URL _FLAG_SCRAPE_DEPTH _FLAG_SCRAPE_MAX_PAGES
115
120
 
116
121
  while [[ $# -gt 0 ]]; do
@@ -123,6 +128,14 @@ _cli_parse_flags() {
123
128
  --skip-doctor) _FLAG_SKIP_DOCTOR=1 ;;
124
129
  --verbose|-v) _FLAG_VERBOSE=1 ;;
125
130
  --ai) _FLAG_AI=1 ;;
131
+ --yes|-y) _FLAG_AUTO_ACCEPT=1 ;;
132
+ --ai-provider) shift; _FLAG_AI_PROVIDER="${1:-}" ;;
133
+ --ai-provider=*) _FLAG_AI_PROVIDER="${1#--ai-provider=}" ;;
134
+ --ai-model) shift; _FLAG_AI_MODEL="${1:-}" ;;
135
+ --ai-model=*) _FLAG_AI_MODEL="${1#--ai-model=}" ;;
136
+ --config) shift; _FLAG_CONFIG="${1:-}" ;;
137
+ --config=*) _FLAG_CONFIG="${1#--config=}" ;;
138
+ --no-ai) _FLAG_AI_PROVIDER="none" ;;
126
139
  --profile) shift; _FLAG_PROFILE="${1:-}" ;;
127
140
  --profile=*) _FLAG_PROFILE="${1#--profile=}" ;;
128
141
  --output) shift; _FLAG_OUTPUT="${1:-}" ;;
@@ -241,6 +254,22 @@ _cmd_wizard() {
241
254
  fi
242
255
  }
243
256
 
257
+ _cmd_suggest() {
258
+ # install suggest [TARGET_DIR] [GOAL...]
259
+ # Recommends a profile + deploy target from an optional goal description.
260
+ # Uses the resolved AI provider; falls back to rule-based defaults.
261
+ local target="${_CLI_POS_0:-$(pwd)}"
262
+ local goal="${_CLI_POS_1:-}"
263
+ local sug="${_CLI_DIR}/ai/suggest.sh"
264
+ if [[ ! -f "$sug" ]]; then
265
+ log_error "suggest: module not available: $sug"
266
+ return 1
267
+ fi
268
+ # shellcheck source=/dev/null
269
+ source "$sug"
270
+ ai_suggest_run "$target" "$goal"
271
+ }
272
+
244
273
  _cmd_agents() {
245
274
  local target="${_CLI_TARGET:-$(pwd)}"
246
275
  local spec_file
@@ -299,8 +328,12 @@ _cmd_deploy() {
299
328
  fi
300
329
 
301
330
  # Caller explicitly asked for this deploy target — override spec defaults.
331
+ # Deploy is deploy-only: no base tasks and no agent rewrites (use
332
+ # `install agents` for those). SPEC_TASKS="" now serialises to [] rather
333
+ # than falling back to the full default list (which would clobber content).
302
334
  SPEC_DEPLOY="$target_name"
303
- SPEC_TASKS="" # only run deploy plugins
335
+ SPEC_TASKS=""
336
+ SPEC_AGENTS=""
304
337
 
305
338
  spec_write "$spec_file"
306
339
  apply_run "$spec_file"
@@ -460,9 +493,10 @@ Usage:
460
493
  Subcommands:
461
494
  init Install from flags + profile (default)
462
495
  wizard Interactive wizard (--ai for AI-driven spec)
496
+ suggest Recommend a profile + deploy target (AI or rule-based)
463
497
  agents Install AI agent files only
464
498
  deploy Configure deployment plugin(s)
465
- doctor Run pre-install health checks
499
+ doctor Run pre-install health checks (incl. AI provider status)
466
500
  scrape Crawl an existing site into ./.zer0/scrape (no install)
467
501
  diagnose Diagnose an existing install (--ai for suggestions)
468
502
  upgrade Re-apply spec to an existing install
@@ -494,7 +528,12 @@ Global options:
494
528
  --skip-doctor Skip pre-install health checks
495
529
  --verbose, -v Extra debug output
496
530
  --output json|human Log format (default: human)
531
+ --yes, -y Auto-confirm all yes/no prompts (alias of --auto-accept)
532
+ --config FILE Load installer settings from a YAML config file
497
533
  --ai Use AI assistant for wizard/diagnose
534
+ --ai-provider NAME AI provider: auto|claude-cli|anthropic|openai|none
535
+ --ai-model NAME Override the AI model id for the active provider
536
+ --no-ai Disable AI (equivalent to --ai-provider none)
498
537
  --spec FILE Load spec from FILE instead of detecting
499
538
  --scrape URL Import content from URL during init (adds 'scrape' task)
500
539
  --scrape-depth N Max crawl depth (default: 2)
@@ -503,9 +542,12 @@ Global options:
503
542
  Examples:
504
543
  install init . --profile blog --site-title "My Blog"
505
544
  install init ~/mysite --profile github-pages --github-user bamr87
506
- install wizard . --ai
545
+ install wizard . --ai # uses Claude Code OAuth if available
546
+ install wizard . --ai --ai-provider anthropic
547
+ install suggest . "a docs site for a Rust CLI"
548
+ install init . --config zer0.install.yml
507
549
  install agents . --copilot --claude
508
- install doctor .
550
+ install doctor . # includes AI provider status
509
551
  install diff .
510
552
  install upgrade . --force
511
553
  install plan . --profile docs
@@ -537,9 +579,17 @@ cli_main() {
537
579
  [[ "$_FLAG_VERBOSE" == "1" ]] && _LOG_VERBOSE=1 && export _LOG_VERBOSE
538
580
  [[ -n "$_FLAG_OUTPUT" ]] && _LOG_OUTPUT="$_FLAG_OUTPUT" && export _LOG_OUTPUT
539
581
 
582
+ # Propagate AI selection + config path to the environment so provider
583
+ # resolution and config discovery see them regardless of the spec flow
584
+ # (e.g. `doctor`, `suggest`, `wizard --ai`).
585
+ [[ -n "$_FLAG_AI_PROVIDER" ]] && export ZER0_AI_PROVIDER="$_FLAG_AI_PROVIDER"
586
+ [[ -n "$_FLAG_AI_MODEL" ]] && export ZER0_AI_MODEL="$_FLAG_AI_MODEL"
587
+ [[ -n "$_FLAG_CONFIG" ]] && export ZER0_CONFIG="$_FLAG_CONFIG"
588
+
540
589
  case "$subcommand" in
541
590
  init) _cmd_init ;;
542
591
  wizard) _cmd_wizard ;;
592
+ suggest) _cmd_suggest ;;
543
593
  agents) _cmd_agents ;;
544
594
  deploy) _cmd_deploy ;;
545
595
  doctor) _cmd_doctor ;;