@neocage/council 1.3.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.
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
6
+ cd "${REPO_DIR}"
7
+
8
+ echo "== Council Simulation Checklist =="
9
+
10
+ pass() { echo "[PASS] $1"; }
11
+ fail() { echo "[FAIL] $1"; exit 1; }
12
+ warn() { echo "[WARN] $1"; }
13
+
14
+ # --- File existence checks ---
15
+
16
+ [[ -f "SKILL.md" ]] || fail "SKILL.md is missing"
17
+ pass "SKILL.md exists"
18
+ [[ -f "SKILL.codex.md" ]] || fail "SKILL.codex.md is missing"
19
+ pass "SKILL.codex.md exists"
20
+ [[ -f "SKILL.gemini.md" ]] || fail "SKILL.gemini.md is missing"
21
+ pass "SKILL.gemini.md exists"
22
+
23
+ if compgen -G "agents/council-*.md" >/dev/null; then
24
+ agent_count=$(compgen -G "agents/council-*.md" | wc -l | tr -d ' ')
25
+ pass "Agent definitions found (count=${agent_count})"
26
+ else
27
+ fail "No agent definitions found under agents/council-*.md"
28
+ fi
29
+
30
+ [[ -f "configs/provider-model-slots.example.yaml" ]] || fail "configs/provider-model-slots.example.yaml is missing"
31
+ pass "Provider/model slot template exists"
32
+
33
+ # --- Plugin manifest checks ---
34
+
35
+ [[ -f ".claude-plugin/plugin.json" ]] || fail ".claude-plugin/plugin.json is missing"
36
+ [[ -f ".claude-plugin/marketplace.json" ]] || fail ".claude-plugin/marketplace.json is missing"
37
+ [[ -e "skills/council/SKILL.md" ]] || fail "skills/council/SKILL.md (plugin skill path) is missing or a broken symlink"
38
+ pass "Plugin manifests and skill path present"
39
+
40
+ changelog_version=$(sed -nE 's/^## \[([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p' CHANGELOG.md | head -1)
41
+ plugin_version=$(sed -nE 's/.*"version": "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' .claude-plugin/plugin.json | head -1)
42
+ marketplace_version=$(sed -nE 's/.*"version": "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' .claude-plugin/marketplace.json | head -1)
43
+ if [[ "$plugin_version" != "$changelog_version" ]] || [[ "$marketplace_version" != "$changelog_version" ]]; then
44
+ fail "Version drift: CHANGELOG=${changelog_version} plugin.json=${plugin_version} marketplace.json=${marketplace_version} — bump the manifests when releasing"
45
+ fi
46
+ pass "Plugin manifest versions match CHANGELOG (${changelog_version})"
47
+
48
+ [[ -f "CLAUDE.md" ]] || warn "CLAUDE.md is missing (recommended for project conventions)"
49
+
50
+ # --- SKILL.md content checks ---
51
+
52
+ grep -q "exploration-orthogonal" SKILL.md || fail "exploration-orthogonal profile missing in SKILL.md"
53
+ pass "exploration-orthogonal profile documented in SKILL.md"
54
+
55
+ grep -q "execution-lean" SKILL.md || fail "execution-lean profile missing in SKILL.md"
56
+ pass "execution-lean profile documented in SKILL.md"
57
+
58
+ grep -q -- "--models" SKILL.md || fail "--models flag missing in SKILL.md"
59
+ pass "--models flag documented in SKILL.md"
60
+
61
+ grep -q -- "--quick" SKILL.md || fail "--quick flag missing in SKILL.md"
62
+ pass "--quick mode documented in SKILL.md"
63
+
64
+ grep -q -- "--duo" SKILL.md || fail "--duo flag missing in SKILL.md"
65
+ pass "--duo mode documented in SKILL.md"
66
+
67
+ grep -q "CHECKPOINT" SKILL.md || fail "Execution checkpoints missing in SKILL.md"
68
+ pass "Execution checkpoints present in SKILL.md"
69
+
70
+ grep -q "VERIFY" SKILL.md || fail "Verification steps missing in SKILL.md"
71
+ pass "Verification steps present in SKILL.md"
72
+
73
+ # Round 2 anonymization (issue #17) — protect against silent regression
74
+ grep -q "ANONYMIZED" SKILL.md || fail "Round 2 anonymization missing in SKILL.md (issue #17)"
75
+ grep -q "Member A" SKILL.md || fail "Member-label vocabulary missing in SKILL.md (issue #17)"
76
+ pass "Round 2 anonymization wired in SKILL.md"
77
+
78
+ grep -q "anonymiz" SKILL.codex.md || fail "Round 2 anonymization missing in SKILL.codex.md (issue #17)"
79
+ pass "Round 2 anonymization wired in SKILL.codex.md"
80
+
81
+ # Anti-conformity directive (issue #19) — must be in every Round 2 prompt
82
+ ac_count_skill=$(grep -c "Anti-conformity directive" SKILL.md || true)
83
+ if [[ "$ac_count_skill" -lt 3 ]]; then
84
+ fail "Anti-conformity directive missing from one or more Round 2 prompts in SKILL.md (issue #19; expected ≥3 occurrences, found ${ac_count_skill})"
85
+ fi
86
+ pass "Anti-conformity directive present in all 3 Round 2 prompts in SKILL.md"
87
+
88
+ grep -q "Anti-conformity directive" SKILL.codex.md || fail "Anti-conformity directive missing in SKILL.codex.md (issue #19)"
89
+ pass "Anti-conformity directive present in SKILL.codex.md"
90
+
91
+ # Chairman role (issue #18) — must be wired into STEP 1.7, STEP 7, flags, and Codex
92
+ grep -q "STEP 1.7" SKILL.md || fail "Chairman selection step missing in SKILL.md (issue #18)"
93
+ grep -q -- "--chairman" SKILL.md || fail "--chairman flag missing in SKILL.md (issue #18)"
94
+ grep -q "CHAIRMAN" SKILL.md || fail "Chairman synthesis step missing in SKILL.md (issue #18)"
95
+ pass "Chairman role wired in SKILL.md (STEP 1.7 + --chairman flag + synthesis step)"
96
+
97
+ grep -q -i "chairman" SKILL.codex.md || fail "Chairman role missing in SKILL.codex.md (issue #18)"
98
+ pass "Chairman role wired in SKILL.codex.md"
99
+
100
+ grep -q "chairman_defaults" configs/auto-route-defaults.yaml || fail "chairman_defaults block missing in auto-route-defaults.yaml (issue #18)"
101
+ pass "Chairman defaults configured in auto-route-defaults.yaml"
102
+
103
+ # Verdict actionability sections (issue #21)
104
+ grep -q "Acceptable Compromises" SKILL.md || fail "Acceptable Compromises section missing in SKILL.md (issue #21)"
105
+ grep -q "Kill Criteria" SKILL.md || fail "Kill Criteria section missing in SKILL.md (issue #21)"
106
+ grep -q "Concrete Next Step" SKILL.md || fail "Concrete Next Step section missing in SKILL.md (issue #21)"
107
+ pass "Verdict actionability sections present in SKILL.md (Acceptable Compromises / Kill Criteria / Concrete Next Step)"
108
+
109
+ grep -q "Acceptable Compromises" SKILL.codex.md || fail "Acceptable Compromises missing in SKILL.codex.md (issue #21)"
110
+ grep -q "Kill Criteria" SKILL.codex.md || fail "Kill Criteria missing in SKILL.codex.md (issue #21)"
111
+ grep -q "Concrete Next Step" SKILL.codex.md || fail "Concrete Next Step missing in SKILL.codex.md (issue #21)"
112
+ pass "Verdict actionability sections present in SKILL.codex.md"
113
+
114
+ # OpenAI-compatible API archetype (issue #16) — must be wired in dispatch + routing
115
+ grep -q "openai_compatible_api" SKILL.md || fail "openai_compatible_api archetype missing in SKILL.md (issue #16)"
116
+ grep -q "base_url" SKILL.md || fail "base_url handling missing in SKILL.md (issue #16)"
117
+ grep -q "api_key_env" SKILL.md || fail "api_key_env handling missing in SKILL.md (issue #16)"
118
+ pass "openai_compatible_api archetype wired in SKILL.md (dispatch + base_url + api_key_env)"
119
+
120
+ grep -q "openai_compatible_api\|openai-compatible\|OpenAI-Compatible" SKILL.codex.md || fail "openai_compatible_api archetype missing in SKILL.codex.md (issue #16)"
121
+ pass "openai_compatible_api archetype wired in SKILL.codex.md"
122
+
123
+ # Session Metadata schema (issue #7 Phase 1)
124
+ grep -q "Session Metadata" SKILL.md || fail "Session Metadata block missing in SKILL.md (issue #7)"
125
+ grep -q "schema_version: 1" SKILL.md || fail "schema_version: 1 marker missing in SKILL.md Session Metadata (issue #7)"
126
+ pass "Session Metadata schema wired in SKILL.md"
127
+
128
+ grep -q "Session Metadata\|session metadata\|session_metadata" SKILL.codex.md || fail "Session Metadata missing in SKILL.codex.md (issue #7)"
129
+ pass "Session Metadata referenced in SKILL.codex.md"
130
+
131
+ # --- SKILL.gemini.md parity checks ---
132
+ # The Gemini variant is the least-exercised of the three coordinator files and
133
+ # is exactly where feature drift shipped once before. Every protocol feature
134
+ # checked for codex above is checked for gemini here.
135
+
136
+ grep -q "anonymiz" SKILL.gemini.md || fail "Round 2 anonymization missing in SKILL.gemini.md (issue #17)"
137
+ pass "Round 2 anonymization wired in SKILL.gemini.md"
138
+
139
+ grep -q "Anti-conformity directive" SKILL.gemini.md || fail "Anti-conformity directive missing in SKILL.gemini.md (issue #19)"
140
+ pass "Anti-conformity directive present in SKILL.gemini.md"
141
+
142
+ grep -q -i "chairman" SKILL.gemini.md || fail "Chairman role missing in SKILL.gemini.md (issue #18)"
143
+ pass "Chairman role wired in SKILL.gemini.md"
144
+
145
+ grep -q "Acceptable Compromises" SKILL.gemini.md || fail "Acceptable Compromises missing in SKILL.gemini.md (issue #21)"
146
+ grep -q "Kill Criteria" SKILL.gemini.md || fail "Kill Criteria missing in SKILL.gemini.md (issue #21)"
147
+ grep -q "Concrete Next Step" SKILL.gemini.md || fail "Concrete Next Step missing in SKILL.gemini.md (issue #21)"
148
+ pass "Verdict actionability sections present in SKILL.gemini.md"
149
+
150
+ grep -q "openai_compatible_api\|openai-compatible\|OpenAI-Compatible" SKILL.gemini.md || fail "openai_compatible_api archetype missing in SKILL.gemini.md (issue #16)"
151
+ pass "openai_compatible_api archetype wired in SKILL.gemini.md"
152
+
153
+ grep -q "Session Metadata\|session metadata\|session_metadata" SKILL.gemini.md || fail "Session Metadata missing in SKILL.gemini.md (issue #7)"
154
+ pass "Session Metadata referenced in SKILL.gemini.md"
155
+
156
+ # --- Structured stance / weighted tally parity (PR #36) ---
157
+ # All three coordinator files must carry the STANCE line, the Vote Tally
158
+ # verdict field, and the 1.5x domain-weight seat. This is the check that
159
+ # would have caught the Gemini regression.
160
+
161
+ for skill_file in SKILL.md SKILL.codex.md SKILL.gemini.md; do
162
+ grep -q "STANCE:" "${skill_file}" || fail "Structured STANCE line missing in ${skill_file} (PR #36)"
163
+ grep -q "Vote Tally" "${skill_file}" || fail "Vote Tally verdict field missing in ${skill_file} (PR #36)"
164
+ grep -q "1\.5" "${skill_file}" || fail "1.5x domain-weight seat missing in ${skill_file} (PR #36)"
165
+ grep -q "2/3" "${skill_file}" || fail "2/3 consensus threshold missing in ${skill_file} (PR #36)"
166
+ done
167
+ pass "Structured stance + weighted tally present in all three SKILL files"
168
+
169
+ # --- Agent structure checks ---
170
+
171
+ required_sections=("Identity" "Grounding Protocol" "Analytical Method" "What You See" "What You Tend to Miss" "When Deliberating" "Output Format (Council Round 2)" "Output Format (Standalone)")
172
+
173
+ agent_structure_ok=true
174
+ for agent_file in agents/council-*.md; do
175
+ agent_name=$(basename "${agent_file}" .md)
176
+ for section in "${required_sections[@]}"; do
177
+ if ! grep -q "## ${section}" "${agent_file}" 2>/dev/null; then
178
+ # Some sections use slightly different headers, try partial match
179
+ section_word=$(echo "${section}" | awk '{print $1}')
180
+ if ! grep -qi "${section_word}" "${agent_file}" 2>/dev/null; then
181
+ warn "${agent_name}: missing section '${section}'"
182
+ agent_structure_ok=false
183
+ fi
184
+ fi
185
+ done
186
+ done
187
+
188
+ if [[ "${agent_structure_ok}" == true ]]; then
189
+ pass "All agents have consistent section structure"
190
+ else
191
+ warn "Some agents have inconsistent section structure (see warnings above)"
192
+ fi
193
+
194
+ # --- Grounding protocol placement check ---
195
+
196
+ grounding_early=true
197
+ for agent_file in agents/council-*.md; do
198
+ agent_name=$(basename "${agent_file}" .md)
199
+ grounding_line=$(grep -n "## Grounding Protocol" "${agent_file}" 2>/dev/null | head -1 | cut -d: -f1 || echo "999")
200
+ method_line=$(grep -n "## Analytical Method" "${agent_file}" 2>/dev/null | head -1 | cut -d: -f1 || echo "0")
201
+ if [[ "${grounding_line}" -gt "${method_line}" ]] && [[ "${method_line}" -gt 0 ]]; then
202
+ warn "${agent_name}: Grounding Protocol appears after Analytical Method (should be before)"
203
+ grounding_early=false
204
+ fi
205
+ done
206
+
207
+ if [[ "${grounding_early}" == true ]]; then
208
+ pass "Grounding protocols placed before Analytical Method in all agents"
209
+ fi
210
+
211
+ # --- Triad member validation ---
212
+
213
+ for member_name in aristotle socrates feynman ada sun-tzu machiavelli aurelius lao-tzu torvalds musashi watts karpathy sutskever kahneman meadows munger taleb rams; do
214
+ if [[ ! -f "agents/council-${member_name}.md" ]]; then
215
+ fail "Missing agent file for triad member: council-${member_name}.md"
216
+ fi
217
+ done
218
+ pass "All triad member agent files present"
219
+
220
+ # --- Verdict template dedup check ---
221
+
222
+ if grep -q "^{" demos/verdict-template.md 2>/dev/null; then
223
+ warn "demos/verdict-template.md still contains template placeholders (should point to SKILL.md)"
224
+ fi
225
+ pass "Verdict template dedup check done"
226
+
227
+ # --- Auto-routing checks ---
228
+
229
+ [[ -f "scripts/detect-providers.sh" ]] || fail "scripts/detect-providers.sh is missing"
230
+ [[ -x "scripts/detect-providers.sh" ]] || fail "scripts/detect-providers.sh is not executable"
231
+ pass "detect-providers.sh exists and is executable"
232
+
233
+ if detect_output="$(bash scripts/detect-providers.sh 2>/dev/null)"; then
234
+ if echo "$detect_output" | grep -q '"provider_count"'; then
235
+ pass "detect-providers.sh produces valid JSON"
236
+ else
237
+ fail "detect-providers.sh output missing provider_count field"
238
+ fi
239
+ else
240
+ fail "detect-providers.sh exited with error"
241
+ fi
242
+
243
+ [[ -f "configs/auto-route-defaults.yaml" ]] || fail "configs/auto-route-defaults.yaml is missing"
244
+ pass "Auto-route defaults config exists"
245
+
246
+ grep -q -- "--no-auto-route" SKILL.md || fail "--no-auto-route flag missing in SKILL.md"
247
+ pass "--no-auto-route flag documented in SKILL.md"
248
+
249
+ grep -q -- "--dry-route" SKILL.md || fail "--dry-route flag missing in SKILL.md"
250
+ pass "--dry-route flag documented in SKILL.md"
251
+
252
+ affinity_count=0
253
+ for agent_file in agents/council-*.md; do
254
+ if grep -q "provider_affinity" "$agent_file" 2>/dev/null; then
255
+ ((affinity_count+=1))
256
+ fi
257
+ done
258
+ if [[ "$affinity_count" -eq "$agent_count" ]]; then
259
+ pass "All agents have provider_affinity in frontmatter"
260
+ else
261
+ warn "Only ${affinity_count}/${agent_count} agents have provider_affinity"
262
+ fi
263
+
264
+ reasoning_method_count=0
265
+ for agent_file in agents/council-*.md; do
266
+ if grep -q "reasoning_method" "$agent_file" 2>/dev/null; then
267
+ ((reasoning_method_count+=1))
268
+ fi
269
+ done
270
+ if [[ "$reasoning_method_count" -eq "$agent_count" ]]; then
271
+ pass "All agents have reasoning_method in frontmatter"
272
+ else
273
+ warn "Only ${reasoning_method_count}/${agent_count} agents have reasoning_method"
274
+ fi
275
+
276
+ # --- Install script checks ---
277
+
278
+ if command -v shellcheck >/dev/null 2>&1; then
279
+ shellcheck install.sh
280
+ pass "shellcheck passed for install.sh"
281
+ else
282
+ warn "shellcheck not installed; skipped"
283
+ fi
284
+
285
+ TMP_LOG_DIR="$(mktemp -d)"
286
+ trap 'rm -rf "${TMP_LOG_DIR}"' EXIT
287
+
288
+ ./install.sh --dry-run >"${TMP_LOG_DIR}/dry-run.log"
289
+ pass "install.sh --dry-run completed"
290
+
291
+ grep -q "Installed .* council agents" "${TMP_LOG_DIR}/dry-run.log" || fail "install dry-run output missing agent install summary"
292
+ pass "install summary output present"
293
+
294
+ ./install.sh --dry-run --copy-configs >"${TMP_LOG_DIR}/dry-run-configs.log"
295
+ pass "install.sh --dry-run --copy-configs completed"
296
+
297
+ grep -q "Installed .* config files" "${TMP_LOG_DIR}/dry-run-configs.log" || fail "copy-configs dry-run output missing config install summary"
298
+ pass "config summary output present"
299
+
300
+ ./install.sh --dry-run --codex >"${TMP_LOG_DIR}/dry-run-codex.log"
301
+ pass "install.sh --dry-run --codex completed"
302
+
303
+ grep -q "Installed Codex skill to" "${TMP_LOG_DIR}/dry-run-codex.log" || fail "codex dry-run output missing Codex skill summary"
304
+ pass "Codex install summary output present"
305
+
306
+ ./install.sh --dry-run --gemini >"${TMP_LOG_DIR}/dry-run-gemini.log"
307
+ pass "install.sh --dry-run --gemini completed"
308
+
309
+ grep -q "gemini-extension.json" "${TMP_LOG_DIR}/dry-run-gemini.log" || fail "gemini dry-run output missing gemini-extension.json manifest step"
310
+ pass "Gemini install manifest step present"
311
+
312
+ echo
313
+ echo "Checklist complete."
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Council of Cognitive Excellence — Provider Detection Script
5
+ # Detects available LLM providers and outputs structured JSON to stdout.
6
+ # Usage: ./scripts/detect-providers.sh
7
+
8
+ TIMEOUT_SECONDS=5
9
+
10
+ # --- Helper functions ---
11
+
12
+ json_provider() {
13
+ local name="$1" available="$2" exec_method="$3" binary="$4" models="$5"
14
+ printf '{"name":"%s","available":%s,"exec_method":"%s","binary":"%s","models":[%s]}' \
15
+ "$name" "$available" "$exec_method" "$binary" "$models"
16
+ }
17
+
18
+ check_command() {
19
+ command -v "$1" 2>/dev/null || echo ""
20
+ }
21
+
22
+ # macOS-compatible timeout using perl (no coreutils required)
23
+ run_with_timeout() {
24
+ perl -e '
25
+ use POSIX ":sys_wait_h";
26
+ my $timeout = shift @ARGV;
27
+ my $pid = fork();
28
+ if ($pid == 0) { exec @ARGV; exit 1; }
29
+ my $elapsed = 0;
30
+ while ($elapsed < $timeout) {
31
+ if (waitpid($pid, WNOHANG) > 0) { exit ($? >> 8); }
32
+ select(undef, undef, undef, 0.1);
33
+ $elapsed += 0.1;
34
+ }
35
+ kill("TERM", $pid);
36
+ waitpid($pid, 0);
37
+ exit 124;
38
+ ' "$TIMEOUT_SECONDS" "$@" 2>/dev/null
39
+ }
40
+
41
+ # --- Detect each provider ---
42
+
43
+ providers=()
44
+
45
+ # Anthropic — always available (host runtime)
46
+ providers+=("$(json_provider "anthropic" "true" "subagent" "native" '"opus","sonnet","haiku"')")
47
+
48
+ # OpenAI via Codex CLI
49
+ codex_bin="$(check_command codex)"
50
+ if [[ -n "$codex_bin" ]] && run_with_timeout codex --version >/dev/null 2>&1; then
51
+ providers+=("$(json_provider "openai" "true" "codex_exec" "$codex_bin" '"gpt-5.4"')")
52
+ else
53
+ providers+=("$(json_provider "openai" "false" "codex_exec" "${codex_bin:-not_found}" '')")
54
+ fi
55
+
56
+ # Google via Gemini CLI
57
+ gemini_bin="$(check_command gemini)"
58
+ if [[ -n "$gemini_bin" ]] && run_with_timeout gemini --version >/dev/null 2>&1; then
59
+ providers+=("$(json_provider "google" "true" "gemini_cli" "$gemini_bin" '"gemini-3-pro"')")
60
+ else
61
+ providers+=("$(json_provider "google" "false" "gemini_cli" "${gemini_bin:-not_found}" '')")
62
+ fi
63
+
64
+ # Ollama (local models)
65
+ ollama_bin="$(check_command ollama)"
66
+ ollama_available=false
67
+ ollama_models=""
68
+ if [[ -n "$ollama_bin" ]]; then
69
+ # Check if ollama server is running by listing models
70
+ if model_list="$(run_with_timeout ollama list 2>/dev/null)"; then
71
+ ollama_available=true
72
+ # Parse model names from 'ollama list' output (skip header line, take first column)
73
+ ollama_models="$(echo "$model_list" | tail -n +2 | awk '{print $1}' | head -5 | \
74
+ sed 's/.*/"&"/' | paste -sd ',' -)"
75
+ fi
76
+ fi
77
+ providers+=("$(json_provider "ollama" "$ollama_available" "ollama_run" "${ollama_bin:-not_found}" "$ollama_models")")
78
+
79
+ # Cursor via Cursor CLI (cursor-agent) — model aggregator: serves GPT-5.x,
80
+ # Claude, Gemini, and Grok families through one binary + CURSOR_API_KEY.
81
+ cursor_bin="$(check_command cursor-agent)"
82
+ if [[ -n "$cursor_bin" ]] && run_with_timeout cursor-agent --version >/dev/null 2>&1; then
83
+ # Cross-family defaults so a Cursor seat adds real diversity, not a
84
+ # second Anthropic-biased model. Verify live IDs with `cursor-agent --list-models`.
85
+ providers+=("$(json_provider "cursor_cli" "true" "cursor_cli" "$cursor_bin" '"gpt-5.4-high","claude-opus-4-7-thinking-high","gemini-3-pro","grok-4"')")
86
+ else
87
+ providers+=("$(json_provider "cursor_cli" "false" "cursor_cli" "${cursor_bin:-not_found}" '')")
88
+ fi
89
+
90
+ # NVIDIA NIM (OpenAI-compatible hosted endpoint at build.nvidia.com)
91
+ # Detection: NVIDIA_API_KEY env var + optional reachability check.
92
+ nim_available=false
93
+ nim_models=""
94
+ nim_endpoint="https://integrate.api.nvidia.com/v1"
95
+ if [[ -n "${NVIDIA_API_KEY:-}" ]]; then
96
+ # Verify the key is not a placeholder (real keys start with nvapi-)
97
+ if [[ "${NVIDIA_API_KEY}" =~ ^nvapi- ]]; then
98
+ # Optional: confirm catalog reachability. Skip if curl missing or offline.
99
+ # The auth header is passed via process substitution so the key never
100
+ # appears in curl's argv (visible to other local users via `ps`).
101
+ if command -v curl >/dev/null 2>&1; then
102
+ if run_with_timeout curl -sf -o /dev/null \
103
+ -H @<(printf 'Authorization: Bearer %s\n' "${NVIDIA_API_KEY}") \
104
+ "${nim_endpoint}/models" 2>/dev/null; then
105
+ nim_available=true
106
+ fi
107
+ else
108
+ # Curl unavailable; trust env var presence + key prefix as availability signal.
109
+ nim_available=true
110
+ fi
111
+ # Default model suggestions (verify live IDs at build.nvidia.com/models).
112
+ nim_models='"deepseek-ai/deepseek-v4-pro","moonshotai/kimi-k2.6","minimaxai/minimax-m2.7","z-ai/glm-5.1","qwen/qwen3.5-397b-a17b"'
113
+ fi
114
+ fi
115
+ providers+=("$(json_provider "nvidia_nim" "$nim_available" "openai_compatible_api" "$nim_endpoint" "$nim_models")")
116
+
117
+ # --- Build JSON output ---
118
+
119
+ available_count=0
120
+ for p in "${providers[@]}"; do
121
+ if echo "$p" | grep -q '"available":true'; then
122
+ ((available_count+=1))
123
+ fi
124
+ done
125
+
126
+ multi_provider=false
127
+ if [[ "$available_count" -ge 2 ]]; then
128
+ multi_provider=true
129
+ fi
130
+
131
+ # Assemble providers array
132
+ provider_json=""
133
+ for i in "${!providers[@]}"; do
134
+ if [[ $i -gt 0 ]]; then
135
+ provider_json+=","
136
+ fi
137
+ provider_json+="${providers[$i]}"
138
+ done
139
+
140
+ printf '{"providers":[%s],"provider_count":%d,"multi_provider":%s}\n' \
141
+ "$provider_json" "$available_count" "$multi_provider"
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """Generate self-hosted GitHub star-history charts (light + dark SVG).
3
+
4
+ Replaces the api.star-history.com embed, whose backend lowercases repo
5
+ owners and serves an empty chart for this repo. Fetches stargazer
6
+ timestamps via `gh api` (works locally when authenticated and in CI via
7
+ GH_TOKEN) and renders two dependency-free SVGs tuned for GitHub's light
8
+ and dark README surfaces, selected with a <picture> element.
9
+
10
+ Usage: python3 scripts/gen-star-history.py <owner/repo> <out_prefix>
11
+ Writes <out_prefix>-light.svg and <out_prefix>-dark.svg
12
+ """
13
+
14
+ import json
15
+ import subprocess
16
+ import sys
17
+ from datetime import datetime, timezone
18
+
19
+ W, H = 800, 400
20
+ MARGIN = {"l": 56, "r": 24, "t": 52, "b": 40}
21
+
22
+ THEMES = {
23
+ "light": {
24
+ "line": "#2563eb", "area_opacity": 0.10,
25
+ "ink": "#1f2328", "muted": "#59636e", "grid": "#d1d9e0",
26
+ },
27
+ "dark": {
28
+ "line": "#4493f8", "area_opacity": 0.14,
29
+ "ink": "#e6edf3", "muted": "#8b949e", "grid": "#30363d",
30
+ },
31
+ }
32
+
33
+ MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
34
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
35
+
36
+
37
+ def fetch_star_dates(repo: str) -> list[datetime]:
38
+ out = subprocess.run(
39
+ ["gh", "api", f"repos/{repo}/stargazers", "--paginate",
40
+ "-H", "Accept: application/vnd.github.star+json",
41
+ "-q", ".[].starred_at"],
42
+ capture_output=True, text=True, check=True,
43
+ ).stdout.split()
44
+ dates = sorted(datetime.fromisoformat(d.replace("Z", "+00:00")) for d in out)
45
+ if not dates:
46
+ raise SystemExit(f"no stargazer data returned for {repo}")
47
+ return dates
48
+
49
+
50
+ def build_series(dates: list[datetime]) -> list[tuple[datetime, int]]:
51
+ # Cumulative count per day, then evenly sampled to <= 160 points.
52
+ daily: dict[str, int] = {}
53
+ for i, d in enumerate(dates, start=1):
54
+ daily[d.strftime("%Y-%m-%d")] = i
55
+ pts = [(datetime.fromisoformat(k).replace(tzinfo=timezone.utc), v)
56
+ for k, v in sorted(daily.items())]
57
+ if len(pts) > 160:
58
+ step = (len(pts) - 1) / 159
59
+ pts = [pts[round(i * step)] for i in range(160)]
60
+ return pts
61
+
62
+
63
+ def nice_step(max_v: int) -> int:
64
+ for step in (50, 100, 250, 500, 1000, 2000, 5000, 10000):
65
+ if max_v / step <= 6:
66
+ return step
67
+ return 20000
68
+
69
+
70
+ def fmt_k(v: int) -> str:
71
+ if v >= 1000:
72
+ return f"{v/1000:g}K"
73
+ return str(v)
74
+
75
+
76
+ def render(repo: str, pts: list[tuple[datetime, int]], theme: dict) -> str:
77
+ x0, x1 = pts[0][0].timestamp(), pts[-1][0].timestamp()
78
+ y_max_raw = pts[-1][1]
79
+ step = nice_step(y_max_raw)
80
+ y_max = ((y_max_raw // step) + 1) * step
81
+ plot_w = W - MARGIN["l"] - MARGIN["r"]
82
+ plot_h = H - MARGIN["t"] - MARGIN["b"]
83
+
84
+ def sx(ts: float) -> float:
85
+ return MARGIN["l"] + (ts - x0) / max(x1 - x0, 1) * plot_w
86
+
87
+ def sy(v: float) -> float:
88
+ return MARGIN["t"] + plot_h - v / y_max * plot_h
89
+
90
+ line_d = " ".join(
91
+ f"{'M' if i == 0 else 'L'}{sx(d.timestamp()):.1f},{sy(v):.1f}"
92
+ for i, (d, v) in enumerate(pts)
93
+ )
94
+ base_y = sy(0)
95
+ area_d = f"{line_d} L{sx(x1):.1f},{base_y:.1f} L{sx(x0):.1f},{base_y:.1f} Z"
96
+
97
+ grid, ylabels = [], []
98
+ for v in range(step, y_max + 1, step):
99
+ y = sy(v)
100
+ grid.append(f'<line x1="{MARGIN["l"]}" y1="{y:.1f}" x2="{W - MARGIN["r"]}" y2="{y:.1f}" '
101
+ f'stroke="{theme["grid"]}" stroke-width="1"/>')
102
+ ylabels.append(f'<text x="{MARGIN["l"] - 8}" y="{y + 4:.1f}" text-anchor="end" '
103
+ f'fill="{theme["muted"]}" font-size="12">{fmt_k(v)}</text>')
104
+
105
+ xlabels = []
106
+ seen = set()
107
+ for d, _ in pts:
108
+ key = (d.year, d.month)
109
+ if key in seen:
110
+ continue
111
+ seen.add(key)
112
+ ts = datetime(d.year, d.month, 1, tzinfo=timezone.utc).timestamp()
113
+ if ts < x0:
114
+ ts = x0
115
+ label = MONTHS[d.month - 1] + (f" '{str(d.year)[2:]}" if d.month == 1 or len(seen) == 1 else "")
116
+ xlabels.append(f'<text x="{sx(ts):.1f}" y="{H - MARGIN["b"] + 20}" text-anchor="middle" '
117
+ f'fill="{theme["muted"]}" font-size="12">{label}</text>')
118
+
119
+ end_x, end_y = sx(x1), sy(y_max_raw)
120
+ label_anchor = "end"
121
+ label_x = end_x - 10
122
+ updated = pts[-1][0].strftime("%b %-d, %Y")
123
+
124
+ font = "font-family=\"-apple-system,'Segoe UI',Helvetica,Arial,sans-serif\""
125
+ return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" width="{W}" height="{H}" role="img" aria-label="Cumulative GitHub stars for {repo}: {y_max_raw:,} as of {updated}">
126
+ <g {font}>
127
+ <text x="{MARGIN["l"]}" y="24" fill="{theme["ink"]}" font-size="15" font-weight="600">{repo} — GitHub stars</text>
128
+ <text x="{W - MARGIN["r"] - 8}" y="24" text-anchor="end" fill="{theme["muted"]}" font-size="11">updated {updated}</text>
129
+ {''.join(grid)}
130
+ <line x1="{MARGIN["l"]}" y1="{base_y:.1f}" x2="{W - MARGIN["r"]}" y2="{base_y:.1f}" stroke="{theme["grid"]}" stroke-width="1"/>
131
+ {''.join(ylabels)}
132
+ {''.join(xlabels)}
133
+ <path d="{area_d}" fill="{theme["line"]}" fill-opacity="{theme["area_opacity"]}" stroke="none"/>
134
+ <path d="{line_d}" fill="none" stroke="{theme["line"]}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
135
+ <circle cx="{end_x:.1f}" cy="{end_y:.1f}" r="4" fill="{theme["line"]}"/>
136
+ <text x="{label_x:.1f}" y="{end_y - 10:.1f}" text-anchor="{label_anchor}" fill="{theme["ink"]}" font-size="13" font-weight="600">{y_max_raw:,} ★</text>
137
+ </g>
138
+ </svg>
139
+ '''
140
+
141
+
142
+ def main() -> None:
143
+ if len(sys.argv) != 3:
144
+ raise SystemExit(__doc__)
145
+ repo, prefix = sys.argv[1], sys.argv[2]
146
+ pts = build_series(fetch_star_dates(repo))
147
+ for mode, theme in THEMES.items():
148
+ path = f"{prefix}-{mode}.svg"
149
+ with open(path, "w") as f:
150
+ f.write(render(repo, pts, theme))
151
+ print(f"wrote {path} ({pts[-1][1]} stars, {len(pts)} points)")
152
+
153
+
154
+ if __name__ == "__main__":
155
+ main()