feat: always-on health widget and visualizer health tab expansion (#1286)

This commit is contained in:
Jeremy McSpadden 2026-03-18 19:04:21 -05:00 committed by GitHub
parent 652ac385b7
commit 5e7f42c686
8 changed files with 943 additions and 2 deletions

View file

@ -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<string, string> = {
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<string> {
const providers = new Set<string>();
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<string, string> = {
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<string, ProviderCheckResult[]> = {};
for (const r of results) {
(groups[r.category] ??= []).push(r);
}
const categoryLabels: Record<string, string> = {
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(" ");
}

View file

@ -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" });
}

View file

@ -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([

View file

@ -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<string, string | undefined>, fn: () => void): void {
const saved: Record<string, string | undefined> = {};
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 });
});
});

View file

@ -119,6 +119,9 @@ function mockData(overrides: Partial<VisualizerData> = {}): 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: [] },

View file

@ -60,6 +60,9 @@ function makeVisualizerData(overrides: Partial<VisualizerData> = {}): 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: [],
},
});

View file

@ -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<VisualizerDa
totalCount: allCaptures.length,
};
const health = loadHealth(units, totals);
const health = loadHealth(units, totals, basePath);
const stats = buildVisualizerStats(milestones, changelog.entries);
const discussion = loadDiscussionState(basePath, milestones);

View file

@ -1113,5 +1113,59 @@ export function renderHealthView(
lines.push(` Tool calls: ${th.fg("text", String(health.toolCalls))}`);
lines.push(` Messages: ${th.fg("text", String(health.assistantMessages))} sent / ${th.fg("text", String(health.userMessages))} received`);
// Environment section — issues only (from doctor-environment.ts, #1221)
if (health.environmentIssues?.length > 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<string, string> = { llm: "LLM", remote: "Notifications", search: "Search", tool: "Tools" };
const grouped = new Map<string, typeof health.providers>();
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;
}