diff --git a/src/resources/extensions/gsd/doctor-providers.ts b/src/resources/extensions/gsd/doctor-providers.ts new file mode 100644 index 000000000..84fa05f5b --- /dev/null +++ b/src/resources/extensions/gsd/doctor-providers.ts @@ -0,0 +1,343 @@ +/** + * GSD Doctor — Provider & Integration Health Checks + * + * Fast, deterministic checks for external service configuration. + * Checks key presence in auth.json and environment variables — no HTTP calls, + * no network I/O, always sub-10ms. + * + * Covers: + * - LLM providers required by the effective model preferences (per phase) + * - Remote questions channel if configured (Slack/Discord/Telegram token) + * - Optional search/tool integrations (Brave, Tavily, Jina, Context7) + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { AuthStorage } from "@gsd/pi-coding-agent"; +import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { getAuthPath, PROVIDER_REGISTRY, type ProviderCategory } from "./key-manager.js"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type ProviderCheckStatus = "ok" | "warning" | "error" | "unconfigured"; + +export interface ProviderCheckResult { + /** Provider id from PROVIDER_REGISTRY (e.g. "anthropic", "slack_bot") */ + name: string; + /** Human-readable label */ + label: string; + /** Functional grouping */ + category: ProviderCategory; + status: ProviderCheckStatus; + message: string; + /** Optional extra detail (e.g. which env var to set) */ + detail?: string; + /** True if this provider is actively required by preferences */ + required: boolean; +} + +// ── Model → Provider ID mapping ─────────────────────────────────────────────── + +/** + * Infer the auth provider ID from a model string. + * Handles plain model IDs ("claude-sonnet-4-6") and prefixed ones ("openrouter/deepseek"). + */ +function modelToProviderId(model: string): string | null { + if (!model) return null; + + // Explicit provider prefix (e.g. "openrouter/deepseek-r1") + if (model.includes("/")) { + const prefix = model.split("/")[0].toLowerCase(); + // Map known prefixes to registry IDs + const prefixMap: Record = { + openrouter: "openrouter", + groq: "groq", + mistral: "mistral", + google: "google", + anthropic: "anthropic", + openai: "openai", + }; + if (prefixMap[prefix]) return prefixMap[prefix]; + } + + const lower = model.toLowerCase(); + if (lower.startsWith("claude")) return "anthropic"; + if (lower.startsWith("gpt-") || lower.startsWith("o1") || lower.startsWith("o3")) return "openai"; + if (lower.startsWith("gemini")) return "google"; + if (lower.startsWith("llama") || lower.startsWith("mixtral")) return "groq"; + if (lower.startsWith("grok")) return "xai"; + if (lower.startsWith("mistral") || lower.startsWith("codestral")) return "mistral"; + + return null; +} + +/** Collect all model strings from effective preferences across all phases. */ +function collectConfiguredModelProviders(): Set { + const providers = new Set(); + + try { + const loaded = loadEffectiveGSDPreferences(); + const models = loaded?.preferences?.models; + if (!models) { + // Default: Anthropic + providers.add("anthropic"); + return providers; + } + + const modelEntries = typeof models === "object" ? Object.values(models) : []; + for (const entry of modelEntries) { + const modelId = typeof entry === "string" ? entry + : typeof entry === "object" && entry !== null && "model" in entry + ? String((entry as { model: unknown }).model) + : null; + if (modelId) { + const pid = modelToProviderId(modelId); + if (pid) providers.add(pid); + } + } + } catch { + // Preferences not readable — assume Anthropic as default + providers.add("anthropic"); + } + + if (providers.size === 0) providers.add("anthropic"); + return providers; +} + +// ── Key resolution ───────────────────────────────────────────────────────────── + +interface KeyLookup { + found: boolean; + source: "auth.json" | "env" | "none"; + backedOff: boolean; +} + +function resolveKey(providerId: string): KeyLookup { + const info = PROVIDER_REGISTRY.find(p => p.id === providerId); + + // Check auth.json + const authPath = getAuthPath(); + if (existsSync(authPath)) { + try { + const auth = AuthStorage.create(authPath); + const creds = auth.getCredentialsForProvider(providerId); + if (creds.length > 0) { + // Filter out empty placeholder keys (from skipped onboarding) + const hasRealKey = creds.some(c => + c.type === "oauth" || (c.type === "api_key" && (c as { key?: string }).key) + ); + if (hasRealKey) { + return { + found: true, + source: "auth.json", + backedOff: auth.areAllCredentialsBackedOff(providerId), + }; + } + } + } catch { + // auth.json malformed — fall through to env check + } + } + + // Check environment variable + if (info?.envVar && process.env[info.envVar]) { + return { found: true, source: "env", backedOff: false }; + } + + return { found: false, source: "none", backedOff: false }; +} + +// ── Individual check groups ──────────────────────────────────────────────────── + +function checkLlmProviders(): ProviderCheckResult[] { + const required = collectConfiguredModelProviders(); + const results: ProviderCheckResult[] = []; + + for (const providerId of required) { + const info = PROVIDER_REGISTRY.find(p => p.id === providerId); + const label = info?.label ?? providerId; + const lookup = resolveKey(providerId); + + if (!lookup.found) { + const envVar = info?.envVar ?? `${providerId.toUpperCase()}_API_KEY`; + results.push({ + name: providerId, + label, + category: "llm", + status: "error", + message: `${label} — no API key found`, + detail: info?.hasOAuth + ? `Run /gsd keys to authenticate` + : `Set ${envVar} or run /gsd keys`, + required: true, + }); + } else if (lookup.backedOff) { + results.push({ + name: providerId, + label, + category: "llm", + status: "warning", + message: `${label} — all credentials backed off (rate limited)`, + detail: `GSD will retry automatically`, + required: true, + }); + } else { + results.push({ + name: providerId, + label, + category: "llm", + status: "ok", + message: `${label} — key present (${lookup.source})`, + required: true, + }); + } + } + + return results; +} + +function checkRemoteQuestionsProvider(): ProviderCheckResult | null { + try { + const loaded = loadEffectiveGSDPreferences(); + const rq = loaded?.preferences?.remote_questions; + if (!rq) return null; + + const channel = rq.channel as string | undefined; + if (!channel) return null; + + const providerMap: Record = { + slack: "slack_bot", + discord: "discord_bot", + telegram: "telegram_bot", + }; + + const providerId = providerMap[channel.toLowerCase()]; + if (!providerId) return null; + + const info = PROVIDER_REGISTRY.find(p => p.id === providerId); + const label = info?.label ?? channel; + const lookup = resolveKey(providerId); + + if (!lookup.found) { + return { + name: providerId, + label, + category: "remote", + status: "warning", + message: `${label} — channel configured but token not found`, + detail: info?.envVar ? `Set ${info.envVar} or run /gsd keys` : `Run /gsd keys to configure`, + required: true, + }; + } + + return { + name: providerId, + label, + category: "remote", + status: "ok", + message: `${label} — token present (${lookup.source})`, + required: true, + }; + } catch { + return null; + } +} + +function checkOptionalProviders(): ProviderCheckResult[] { + const optional = ["brave", "tavily", "jina", "context7"] as const; + const results: ProviderCheckResult[] = []; + + for (const providerId of optional) { + const info = PROVIDER_REGISTRY.find(p => p.id === providerId); + if (!info) continue; + + const lookup = resolveKey(providerId); + results.push({ + name: providerId, + label: info.label, + category: info.category as ProviderCategory, + status: lookup.found ? "ok" : "unconfigured", + message: lookup.found + ? `${info.label} — key present (${lookup.source})` + : `${info.label} — not configured (optional)`, + detail: !lookup.found && info.envVar ? `Set ${info.envVar} to enable` : undefined, + required: false, + }); + } + + return results; +} + +// ── Public API ───────────────────────────────────────────────────────────────── + +/** + * Run all provider checks: required LLM keys, remote questions channel, optional tools. + * Fast (sub-10ms) — reads auth.json and env vars only, no network I/O. + */ +export function runProviderChecks(): ProviderCheckResult[] { + const results: ProviderCheckResult[] = []; + + results.push(...checkLlmProviders()); + + const remoteCheck = checkRemoteQuestionsProvider(); + if (remoteCheck) results.push(remoteCheck); + + results.push(...checkOptionalProviders()); + + return results; +} + +/** + * Format provider check results as a human-readable report string. + */ +export function formatProviderReport(results: ProviderCheckResult[]): string { + if (results.length === 0) return "No provider checks run."; + + const lines: string[] = []; + + const groups: Record = {}; + for (const r of results) { + (groups[r.category] ??= []).push(r); + } + + const categoryLabels: Record = { + llm: "LLM Providers", + remote: "Notifications", + search: "Search", + tool: "Tools", + }; + + for (const [cat, items] of Object.entries(groups)) { + lines.push(`${categoryLabels[cat] ?? cat}:`); + for (const item of items) { + const icon = item.status === "ok" ? "✓" + : item.status === "warning" ? "⚠" + : item.status === "error" ? "✗" + : "·"; + lines.push(` ${icon} ${item.message}`); + if (item.detail && item.status !== "ok") { + lines.push(` ${item.detail}`); + } + } + } + + return lines.join("\n"); +} + +/** + * Summarise check results to a compact widget-friendly string. + * Returns null if all required providers are ok. + */ +export function summariseProviderIssues(results: ProviderCheckResult[]): string | null { + const errors = results.filter(r => r.required && r.status === "error"); + const warnings = results.filter(r => r.required && r.status === "warning"); + + if (errors.length === 0 && warnings.length === 0) return null; + + const parts: string[] = []; + if (errors.length > 0) parts.push(`✗ ${errors[0].label} key missing`); + if (warnings.length > 0 && errors.length === 0) parts.push(`⚠ ${warnings[0].label} backed off`); + if (errors.length + warnings.length > 1) parts.push(`(+${errors.length + warnings.length - 1} more)`); + + return parts.join(" "); +} diff --git a/src/resources/extensions/gsd/health-widget.ts b/src/resources/extensions/gsd/health-widget.ts new file mode 100644 index 000000000..994e959b3 --- /dev/null +++ b/src/resources/extensions/gsd/health-widget.ts @@ -0,0 +1,167 @@ +/** + * GSD Health Widget — always-on ambient health signal rendered belowEditor. + * + * Shows a compact 1-2 line summary: progress score, budget, provider key + * status, and doctor/environment issue count. Refreshes every 60 seconds. + * Quiet when everything is healthy; turns amber/red when issues arise. + * + * Widget key: "gsd-health", placement: "belowEditor" + */ + +import type { ExtensionContext } from "@gsd/pi-coding-agent"; +import { runProviderChecks, summariseProviderIssues } from "./doctor-providers.js"; +import { runEnvironmentChecks } from "./doctor-environment.js"; +import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js"; +import { projectRoot } from "./commands.js"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +interface HealthWidgetData { + hasProject: boolean; + budgetCeiling: number | undefined; + budgetSpent: number; + providerIssue: string | null; // compact summary from summariseProviderIssues() + environmentErrorCount: number; + environmentWarningCount: number; + lastRefreshed: number; +} + +// ── Data loader ──────────────────────────────────────────────────────────────── + +function loadHealthWidgetData(basePath: string): HealthWidgetData { + let hasProject = false; + let budgetCeiling: number | undefined; + let budgetSpent = 0; + let providerIssue: string | null = null; + let environmentErrorCount = 0; + let environmentWarningCount = 0; + + try { + const prefs = loadEffectiveGSDPreferences(); + budgetCeiling = prefs?.preferences?.budget_ceiling; + + const ledger = loadLedgerFromDisk(basePath); + if (ledger) { + hasProject = true; + const totals = getProjectTotals(ledger.units ?? []); + budgetSpent = totals.cost; + } + } catch { /* non-fatal */ } + + try { + const providerResults = runProviderChecks(); + providerIssue = summariseProviderIssues(providerResults); + } catch { /* non-fatal */ } + + try { + const envResults = runEnvironmentChecks(basePath); + for (const r of envResults) { + if (r.status === "error") environmentErrorCount++; + else if (r.status === "warning") environmentWarningCount++; + } + } catch { /* non-fatal */ } + + return { + hasProject, + budgetCeiling, + budgetSpent, + providerIssue, + environmentErrorCount, + environmentWarningCount, + lastRefreshed: Date.now(), + }; +} + +// ── Rendering ────────────────────────────────────────────────────────────────── + +function formatCost(n: number): string { + return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`; +} + +/** + * Build compact health lines for the widget. + * Returns a string array suitable for setWidget(). + */ +export function buildHealthLines(data: HealthWidgetData): string[] { + if (!data.hasProject) { + return [" GSD No project loaded — run /gsd to start"]; + } + + const parts: string[] = []; + + // System status signal + const totalIssues = data.environmentErrorCount + data.environmentWarningCount + (data.providerIssue ? 1 : 0); + if (totalIssues === 0) { + parts.push("● System OK"); + } else if (data.environmentErrorCount > 0 || data.providerIssue?.includes("✗")) { + parts.push(`✗ ${totalIssues} issue${totalIssues > 1 ? "s" : ""}`); + } else { + parts.push(`⚠ ${totalIssues} warning${totalIssues > 1 ? "s" : ""}`); + } + + // Budget + if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) { + const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100); + parts.push(`Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`); + } else if (data.budgetSpent > 0) { + parts.push(`Spent: ${formatCost(data.budgetSpent)}`); + } + + // Provider issue (if any) + if (data.providerIssue) { + parts.push(data.providerIssue); + } + + // Environment issues + if (data.environmentErrorCount > 0) { + parts.push(`Env: ${data.environmentErrorCount} error${data.environmentErrorCount > 1 ? "s" : ""}`); + } else if (data.environmentWarningCount > 0) { + parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`); + } + + return [` ${parts.join(" │ ")}`]; +} + +// ── Widget init ──────────────────────────────────────────────────────────────── + +const REFRESH_INTERVAL_MS = 60_000; + +/** + * Initialize the always-on gsd-health widget (belowEditor). + * Call once from the extension entry point after context is available. + */ +export function initHealthWidget(ctx: ExtensionContext): void { + if (!ctx.hasUI) return; + + const basePath = projectRoot(); + + // String-array fallback — used in RPC mode (factory is a no-op there) + const initialData = loadHealthWidgetData(basePath); + ctx.ui.setWidget("gsd-health", buildHealthLines(initialData), { placement: "belowEditor" }); + + // Factory-based widget for TUI mode — replaces the string-array above + ctx.ui.setWidget("gsd-health", (_tui, _theme) => { + let data = initialData; + let cachedLines: string[] | undefined; + + const refreshTimer = setInterval(() => { + try { + data = loadHealthWidgetData(basePath); + cachedLines = undefined; + _tui.requestRender(); + } catch { /* non-fatal */ } + }, REFRESH_INTERVAL_MS); + + return { + render(_width: number): string[] { + if (!cachedLines) cachedLines = buildHealthLines(data); + return cachedLines; + }, + invalidate(): void { cachedLines = undefined; }, + dispose(): void { + clearInterval(refreshTimer); + }, + }; + }, { placement: "belowEditor" }); +} diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 58fd21a1b..b92317a7f 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -607,6 +607,12 @@ export default function (pi: ExtensionAPI) { // Load tool API keys from auth.json into environment loadToolApiKeys(); + // Always-on health widget — ambient system health signal below the editor + try { + const { initHealthWidget } = await import("./health-widget.js"); + initHealthWidget(ctx); + } catch { /* non-fatal — widget is best-effort */ } + // Notify remote questions status if configured try { const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([ diff --git a/src/resources/extensions/gsd/tests/doctor-providers.test.ts b/src/resources/extensions/gsd/tests/doctor-providers.test.ts new file mode 100644 index 000000000..a4431e3e7 --- /dev/null +++ b/src/resources/extensions/gsd/tests/doctor-providers.test.ts @@ -0,0 +1,298 @@ +/** + * doctor-providers.test.ts — Tests for provider & integration health checks. + * + * Tests: + * - LLM provider key detection from env vars + * - LLM provider key detection from auth.json + * - Missing required provider → error status + * - Backed-off credentials → warning status + * - Remote questions channel check (configured vs missing token) + * - Optional provider unconfigured status + * - formatProviderReport output + * - summariseProviderIssues compaction + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + runProviderChecks, + formatProviderReport, + summariseProviderIssues, + type ProviderCheckResult, +} from "../doctor-providers.ts"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function withEnv(vars: Record, fn: () => void): void { + const saved: Record = {}; + for (const [k, v] of Object.entries(vars)) { + saved[k] = process.env[k]; + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + try { + fn(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +// ─── formatProviderReport ───────────────────────────────────────────────────── + +test("formatProviderReport returns fallback for empty results", () => { + const out = formatProviderReport([]); + assert.equal(out, "No provider checks run."); +}); + +test("formatProviderReport shows ok icon for ok status", () => { + const results: ProviderCheckResult[] = [{ + name: "anthropic", + label: "Anthropic (Claude)", + category: "llm", + status: "ok", + message: "Anthropic (Claude) — key present (env)", + required: true, + }]; + const out = formatProviderReport(results); + assert.ok(out.includes("✓"), "should include checkmark for ok"); + assert.ok(out.includes("Anthropic"), "should include provider name"); +}); + +test("formatProviderReport shows error icon and detail for error status", () => { + const results: ProviderCheckResult[] = [{ + name: "anthropic", + label: "Anthropic (Claude)", + category: "llm", + status: "error", + message: "Anthropic (Claude) — no API key found", + detail: "Set ANTHROPIC_API_KEY or run /gsd keys", + required: true, + }]; + const out = formatProviderReport(results); + assert.ok(out.includes("✗"), "should include cross for error"); + assert.ok(out.includes("ANTHROPIC_API_KEY"), "should include detail"); +}); + +test("formatProviderReport shows warning icon for warning status", () => { + const results: ProviderCheckResult[] = [{ + name: "slack_bot", + label: "Slack Bot", + category: "remote", + status: "warning", + message: "Slack Bot — channel configured but token not found", + required: true, + }]; + const out = formatProviderReport(results); + assert.ok(out.includes("⚠"), "should include warning icon"); +}); + +test("formatProviderReport groups by category", () => { + const results: ProviderCheckResult[] = [ + { name: "anthropic", label: "Anthropic", category: "llm", status: "ok", message: "ok", required: true }, + { name: "brave", label: "Brave Search", category: "search", status: "unconfigured", message: "not configured", required: false }, + ]; + const out = formatProviderReport(results); + assert.ok(out.includes("LLM Providers"), "should have LLM section"); + assert.ok(out.includes("Search"), "should have Search section"); +}); + +test("formatProviderReport omits detail for ok status", () => { + const results: ProviderCheckResult[] = [{ + name: "openai", + label: "OpenAI", + category: "llm", + status: "ok", + message: "OpenAI — key present (env)", + detail: "should not appear", + required: true, + }]; + const out = formatProviderReport(results); + assert.ok(!out.includes("should not appear"), "detail should not show for ok"); +}); + +// ─── summariseProviderIssues ────────────────────────────────────────────────── + +test("summariseProviderIssues returns null when no required issues", () => { + const results: ProviderCheckResult[] = [ + { name: "anthropic", label: "Anthropic", category: "llm", status: "ok", message: "ok", required: true }, + { name: "brave", label: "Brave", category: "search", status: "unconfigured", message: "not configured", required: false }, + ]; + assert.equal(summariseProviderIssues(results), null); +}); + +test("summariseProviderIssues returns error summary for missing required key", () => { + const results: ProviderCheckResult[] = [{ + name: "anthropic", + label: "Anthropic (Claude)", + category: "llm", + status: "error", + message: "no key", + required: true, + }]; + const summary = summariseProviderIssues(results); + assert.ok(summary !== null, "should return a summary"); + assert.ok(summary!.includes("Anthropic"), "should name the provider"); + assert.ok(summary!.includes("✗"), "should use error icon"); +}); + +test("summariseProviderIssues returns warning for backed-off required provider", () => { + const results: ProviderCheckResult[] = [{ + name: "anthropic", + label: "Anthropic (Claude)", + category: "llm", + status: "warning", + message: "backed off", + required: true, + }]; + const summary = summariseProviderIssues(results); + assert.ok(summary !== null, "should return summary"); + assert.ok(summary!.includes("⚠"), "should use warning icon"); +}); + +test("summariseProviderIssues appends count when multiple issues", () => { + const results: ProviderCheckResult[] = [ + { name: "anthropic", label: "Anthropic", category: "llm", status: "error", message: "err", required: true }, + { name: "openai", label: "OpenAI", category: "llm", status: "error", message: "err", required: true }, + { name: "google", label: "Google", category: "llm", status: "error", message: "err", required: true }, + ]; + const summary = summariseProviderIssues(results); + assert.ok(summary!.includes("+2 more"), "should show overflow count"); +}); + +test("summariseProviderIssues ignores unconfigured optional providers", () => { + const results: ProviderCheckResult[] = [ + { name: "anthropic", label: "Anthropic", category: "llm", status: "ok", message: "ok", required: true }, + { name: "brave", label: "Brave", category: "search", status: "unconfigured", message: "nc", required: false }, + { name: "tavily", label: "Tavily", category: "search", status: "unconfigured", message: "nc", required: false }, + ]; + assert.equal(summariseProviderIssues(results), null, "optional missing providers should not raise issue"); +}); + +// ─── runProviderChecks — env var detection ──────────────────────────────────── + +test("runProviderChecks detects Anthropic key from ANTHROPIC_API_KEY env var", () => { + // Isolate from real HOME so loadEffectiveGSDPreferences returns null (default → anthropic) + // and auth.json lookups hit an empty directory. + const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-env-test-"))); + withEnv({ ANTHROPIC_API_KEY: "sk-ant-test-key", HOME: tmpHome }, () => { + try { + const results = runProviderChecks(); + const anthropic = results.find(r => r.name === "anthropic"); + assert.ok(anthropic, "anthropic result should exist"); + assert.equal(anthropic!.status, "ok", "should be ok when env var set"); + assert.ok(anthropic!.message.includes("env"), "should report env source"); + } finally { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); +}); + +test("runProviderChecks returns error for Anthropic when no key present", () => { + const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-test-"))); + withEnv({ ANTHROPIC_API_KEY: undefined, HOME: tmpHome }, () => { + try { + const results = runProviderChecks(); + const anthropic = results.find(r => r.name === "anthropic"); + assert.ok(anthropic, "anthropic should be present (default required)"); + assert.equal(anthropic!.status, "error", "should be error when no key"); + } finally { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); +}); + +test("runProviderChecks optional providers have required=false", () => { + const results = runProviderChecks(); + const optional = results.filter(r => ["brave", "tavily", "jina", "context7"].includes(r.name)); + for (const r of optional) { + assert.equal(r.required, false, `${r.name} should not be required`); + } +}); + +test("runProviderChecks optional providers show unconfigured when no key", () => { + withEnv( + { BRAVE_API_KEY: undefined, TAVILY_API_KEY: undefined, JINA_API_KEY: undefined, CONTEXT7_API_KEY: undefined }, + () => { + const origHome = process.env.HOME; + process.env.HOME = mkdtempSync(join(tmpdir(), "gsd-providers-test-")); + try { + const results = runProviderChecks(); + const brave = results.find(r => r.name === "brave"); + assert.ok(brave, "brave should be present"); + assert.equal(brave!.status, "unconfigured", "should be unconfigured"); + } finally { + rmSync(process.env.HOME!, { recursive: true, force: true }); + process.env.HOME = origHome; + } + } + ); +}); + +test("runProviderChecks optional providers show ok when key set", () => { + withEnv({ BRAVE_API_KEY: "test-brave-key" }, () => { + const results = runProviderChecks(); + const brave = results.find(r => r.name === "brave"); + assert.ok(brave, "brave should be present"); + assert.equal(brave!.status, "ok", "should be ok when env var set"); + }); +}); + +// ─── runProviderChecks — auth.json detection ───────────────────────────────── + +test("runProviderChecks detects key from auth.json", () => { + withEnv({ ANTHROPIC_API_KEY: undefined }, () => { + const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-test-"))); + const agentDir = join(tmpHome, ".gsd", "agent"); + mkdirSync(agentDir, { recursive: true }); + + // AuthStorage persists credentials with provider ID as the top-level key: + // { "anthropic": { "type": "api_key", "key": "..." } } + const authData = { + anthropic: { type: "api_key", key: "sk-ant-from-auth-json" }, + }; + writeFileSync(join(agentDir, "auth.json"), JSON.stringify(authData)); + + withEnv({ HOME: tmpHome }, () => { + const results = runProviderChecks(); + const anthropic = results.find(r => r.name === "anthropic"); + assert.ok(anthropic, "anthropic should be present"); + assert.equal(anthropic!.status, "ok", "should be ok with auth.json key"); + assert.ok(anthropic!.message.includes("auth.json"), "should report auth.json source"); + }); + + rmSync(tmpHome, { recursive: true, force: true }); + }); +}); + +test("runProviderChecks ignores empty placeholder keys in auth.json", () => { + withEnv({ ANTHROPIC_API_KEY: undefined }, () => { + const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-test-"))); + const agentDir = join(tmpHome, ".gsd", "agent"); + mkdirSync(agentDir, { recursive: true }); + + // Empty key — what onboarding writes when user skips + const authData = { + anthropic: { type: "api_key", key: "" }, + }; + writeFileSync(join(agentDir, "auth.json"), JSON.stringify(authData)); + + withEnv({ HOME: tmpHome }, () => { + const results = runProviderChecks(); + const anthropic = results.find(r => r.name === "anthropic"); + assert.ok(anthropic, "anthropic should be present"); + assert.equal(anthropic!.status, "error", "empty placeholder key should count as not configured"); + }); + + rmSync(tmpHome, { recursive: true, force: true }); + }); +}); diff --git a/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts b/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts index b4bea28a8..d4ba9ede6 100644 --- a/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts +++ b/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts @@ -119,6 +119,9 @@ function mockData(overrides: Partial = {}): VisualizerData { toolCalls: 40, assistantMessages: 20, userMessages: 12, + providers: [], + skillSummary: { total: 0, warningCount: 0, criticalCount: 0, topIssue: null }, + environmentIssues: [], }, discussion: [], stats: { missingCount: 0, missingSlices: [], updatedCount: 0, updatedSlices: [], recentEntries: [] }, diff --git a/src/resources/extensions/gsd/tests/visualizer-views.test.ts b/src/resources/extensions/gsd/tests/visualizer-views.test.ts index 6d7ff4698..e899cd379 100644 --- a/src/resources/extensions/gsd/tests/visualizer-views.test.ts +++ b/src/resources/extensions/gsd/tests/visualizer-views.test.ts @@ -60,6 +60,9 @@ function makeVisualizerData(overrides: Partial = {}): Visualizer toolCalls: 0, assistantMessages: 0, userMessages: 0, + providers: [], + skillSummary: { total: 0, warningCount: 0, criticalCount: 0, topIssue: null }, + environmentIssues: [], }, discussion: [], stats: { @@ -503,6 +506,9 @@ console.log("\n=== renderAgentView ==="); truncationRate: 15.5, continueHereRate: 5.0, tierBreakdown: [], tierSavingsLine: "", toolCalls: 20, assistantMessages: 15, userMessages: 8, + providers: [], + skillSummary: { total: 0, warningCount: 0, criticalCount: 0, topIssue: null }, + environmentIssues: [], }, captures: { entries: [], pendingCount: 3, totalCount: 5 }, }); @@ -669,6 +675,9 @@ console.log("\n=== renderHealthView ==="); toolCalls: 50, assistantMessages: 30, userMessages: 15, + providers: [], + skillSummary: { total: 0, warningCount: 0, criticalCount: 0, topIssue: null }, + environmentIssues: [], }, }); @@ -693,6 +702,9 @@ console.log("\n=== renderHealthView ==="); truncationRate: 0, continueHereRate: 0, tierBreakdown: [], tierSavingsLine: "", toolCalls: 0, assistantMessages: 0, userMessages: 0, + providers: [], + skillSummary: { total: 0, warningCount: 0, criticalCount: 0, topIssue: null }, + environmentIssues: [], }, }); diff --git a/src/resources/extensions/gsd/visualizer-data.ts b/src/resources/extensions/gsd/visualizer-data.ts index f3a0d465f..587a3d4a6 100644 --- a/src/resources/extensions/gsd/visualizer-data.ts +++ b/src/resources/extensions/gsd/visualizer-data.ts @@ -18,6 +18,9 @@ import { } from './metrics.js'; import { loadAllCaptures, countPendingCaptures } from './captures.js'; import { loadEffectiveGSDPreferences } from './preferences.js'; +import { runProviderChecks, type ProviderCheckResult } from './doctor-providers.js'; +import { generateSkillHealthReport } from './skill-health.js'; +import { runEnvironmentChecks, type EnvironmentCheckResult } from './doctor-environment.js'; import type { Phase } from './types.js'; import type { CaptureEntry } from './captures.js'; @@ -142,6 +145,22 @@ export interface CapturesInfo { totalCount: number; } +export interface ProviderStatusSummary { + name: string; + label: string; + category: string; + ok: boolean; + required: boolean; + message: string; +} + +export interface SkillSummaryInfo { + total: number; + warningCount: number; + criticalCount: number; + topIssue: string | null; +} + export interface HealthInfo { budgetCeiling: number | undefined; tokenProfile: string; @@ -152,6 +171,9 @@ export interface HealthInfo { toolCalls: number; assistantMessages: number; userMessages: number; + providers: ProviderStatusSummary[]; + skillSummary: SkillSummaryInfo; + environmentIssues: import("./doctor-environment.js").EnvironmentCheckResult[]; } export interface VisualizerData { @@ -538,7 +560,7 @@ function loadKnowledge(basePath: string): KnowledgeInfo { // ─── Health Loader ──────────────────────────────────────────────────────────── -function loadHealth(units: UnitMetrics[], totals: ProjectTotals | null): HealthInfo { +function loadHealth(units: UnitMetrics[], totals: ProjectTotals | null, basePath: string): HealthInfo { const prefs = loadEffectiveGSDPreferences(); const budgetCeiling = prefs?.preferences?.budget_ceiling; const tokenProfile = prefs?.preferences?.token_profile ?? 'standard'; @@ -553,6 +575,39 @@ function loadHealth(units: UnitMetrics[], totals: ProjectTotals | null): HealthI const tierBreakdown = aggregateByTier(units); const tierSavingsLine = formatTierSavings(units); + // Provider checks — fast (auth.json + env vars only, no network) + let providers: ProviderStatusSummary[] = []; + try { + providers = runProviderChecks().map((r: ProviderCheckResult) => ({ + name: r.name, + label: r.label, + category: r.category, + ok: r.status === "ok" || r.status === "unconfigured", + required: r.required, + message: r.message, + })); + } catch { /* non-fatal */ } + + // Skill health summary + let skillSummary: SkillSummaryInfo = { total: 0, warningCount: 0, criticalCount: 0, topIssue: null }; + try { + const report = generateSkillHealthReport(basePath); + const warnings = report.suggestions.filter(s => s.severity === "warning"); + const criticals = report.suggestions.filter(s => s.severity === "critical"); + skillSummary = { + total: report.skills.length, + warningCount: warnings.length, + criticalCount: criticals.length, + topIssue: report.suggestions[0]?.message ?? null, + }; + } catch { /* non-fatal */ } + + // Environment issues (from doctor-environment.ts, #1221) + let environmentIssues: EnvironmentCheckResult[] = []; + try { + environmentIssues = runEnvironmentChecks(basePath).filter(r => r.status !== "ok"); + } catch { /* non-fatal */ } + return { budgetCeiling, tokenProfile, @@ -563,6 +618,9 @@ function loadHealth(units: UnitMetrics[], totals: ProjectTotals | null): HealthI toolCalls: totals?.toolCalls ?? 0, assistantMessages: totals?.assistantMessages ?? 0, userMessages: totals?.userMessages ?? 0, + providers, + skillSummary, + environmentIssues, }; } @@ -780,7 +838,7 @@ export async function loadVisualizerData(basePath: string): Promise 0) { + lines.push(""); + lines.push(th.fg("accent", th.bold("Environment"))); + lines.push(""); + for (const r of health.environmentIssues) { + const icon = r.status === "error" ? th.fg("error", "✗") : th.fg("warning", "⚠"); + lines.push(` ${icon} ${th.fg("text", r.message)}`); + if (r.detail) lines.push(` ${th.fg("dim", r.detail)}`); + } + } + + // Providers section + if (health.providers?.length > 0) { + lines.push(""); + lines.push(th.fg("accent", th.bold("Providers"))); + lines.push(""); + const categoryOrder = ["llm", "remote", "search", "tool"]; + const categoryLabels: Record = { llm: "LLM", remote: "Notifications", search: "Search", tool: "Tools" }; + const grouped = new Map(); + for (const p of health.providers) { + const cat = p.category; + if (!grouped.has(cat)) grouped.set(cat, []); + grouped.get(cat)!.push(p); + } + for (const cat of categoryOrder) { + const items = grouped.get(cat); + if (!items || items.length === 0) continue; + lines.push(` ${th.fg("dim", categoryLabels[cat] ?? cat)}`); + for (const p of items) { + const icon = p.ok ? th.fg("success", "✓") : th.fg("error", "✗"); + const msg = p.ok ? th.fg("dim", p.message) : th.fg("text", p.message); + lines.push(` ${icon} ${msg}`); + } + } + } + + // Skills section + if (health.skillSummary?.total > 0) { + lines.push(""); + lines.push(th.fg("accent", th.bold("Skills"))); + lines.push(""); + const { total, warningCount, criticalCount, topIssue } = health.skillSummary; + const issueColor = criticalCount > 0 ? "error" : warningCount > 0 ? "warning" : "success"; + const issueTag = criticalCount > 0 + ? `${criticalCount} critical` + : warningCount > 0 + ? `${warningCount} warning${warningCount > 1 ? "s" : ""}` + : "all healthy"; + lines.push(` ${th.fg("text", String(total))} skills tracked · ${th.fg(issueColor, issueTag)}`); + if (topIssue) lines.push(` ${th.fg("warning", "⚠")} ${th.fg("dim", topIssue)}`); + lines.push(` ${th.fg("dim", "→ /gsd skill-health for full report")}`); + } + return lines; }