@nforma.ai/nforma 0.2.1
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.
- package/LICENSE +22 -0
- package/README.md +1024 -0
- package/agents/qgsd-codebase-mapper.md +764 -0
- package/agents/qgsd-debugger.md +1201 -0
- package/agents/qgsd-executor.md +472 -0
- package/agents/qgsd-integration-checker.md +443 -0
- package/agents/qgsd-phase-researcher.md +502 -0
- package/agents/qgsd-plan-checker.md +643 -0
- package/agents/qgsd-planner.md +1182 -0
- package/agents/qgsd-project-researcher.md +621 -0
- package/agents/qgsd-quorum-orchestrator.md +628 -0
- package/agents/qgsd-quorum-slot-worker.md +41 -0
- package/agents/qgsd-quorum-synthesizer.md +133 -0
- package/agents/qgsd-quorum-test-worker.md +37 -0
- package/agents/qgsd-quorum-worker.md +161 -0
- package/agents/qgsd-research-synthesizer.md +239 -0
- package/agents/qgsd-roadmapper.md +660 -0
- package/agents/qgsd-verifier.md +628 -0
- package/bin/accept-debug-invariant.cjs +165 -0
- package/bin/account-manager.cjs +719 -0
- package/bin/aggregate-requirements.cjs +466 -0
- package/bin/analyze-assumptions.cjs +757 -0
- package/bin/analyze-state-space.cjs +921 -0
- package/bin/attribute-trace-divergence.cjs +150 -0
- package/bin/auth-drivers/gh-cli.cjs +93 -0
- package/bin/auth-drivers/index.cjs +46 -0
- package/bin/auth-drivers/pool.cjs +67 -0
- package/bin/auth-drivers/simple.cjs +95 -0
- package/bin/autoClosePtoF.cjs +110 -0
- package/bin/blessed-terminal.cjs +350 -0
- package/bin/build-phase-index.cjs +472 -0
- package/bin/call-quorum-slot.cjs +541 -0
- package/bin/ccr-secure-config.cjs +99 -0
- package/bin/ccr-secure-start.cjs +83 -0
- package/bin/check-bundled-sdks.cjs +177 -0
- package/bin/check-coverage-guard.cjs +112 -0
- package/bin/check-liveness-fairness.cjs +95 -0
- package/bin/check-mcp-health.cjs +123 -0
- package/bin/check-provider-health.cjs +395 -0
- package/bin/check-results-exit.cjs +24 -0
- package/bin/check-spec-sync.cjs +360 -0
- package/bin/check-trace-redaction.cjs +271 -0
- package/bin/check-trace-schema-drift.cjs +99 -0
- package/bin/compareDrift.cjs +21 -0
- package/bin/conformance-schema.cjs +12 -0
- package/bin/count-scenarios.cjs +420 -0
- package/bin/debt-dedup.cjs +144 -0
- package/bin/debt-ledger.cjs +61 -0
- package/bin/debt-retention.cjs +76 -0
- package/bin/debt-state-machine.cjs +80 -0
- package/bin/detect-coverage-gaps.cjs +204 -0
- package/bin/detect-project-intent.cjs +362 -0
- package/bin/export-prism-constants.cjs +164 -0
- package/bin/extract-annotations.cjs +633 -0
- package/bin/extractFormalExpected.cjs +104 -0
- package/bin/fingerprint-drift.cjs +24 -0
- package/bin/fingerprint-issue.cjs +46 -0
- package/bin/formal-core.cjs +519 -0
- package/bin/formal-ref-linker.cjs +141 -0
- package/bin/formal-test-sync.cjs +788 -0
- package/bin/generate-formal-specs.cjs +588 -0
- package/bin/generate-petri-net.cjs +397 -0
- package/bin/generate-phase-spec.cjs +249 -0
- package/bin/generate-proposed-changes.cjs +194 -0
- package/bin/generate-tla-cfg.cjs +122 -0
- package/bin/generate-traceability-matrix.cjs +701 -0
- package/bin/generate-triage-bundle.cjs +300 -0
- package/bin/gh-account-rotate.cjs +34 -0
- package/bin/initialize-model-registry.cjs +105 -0
- package/bin/install-formal-tools.cjs +382 -0
- package/bin/install.js +2424 -0
- package/bin/isNumericThreshold.cjs +34 -0
- package/bin/issue-classifier.cjs +151 -0
- package/bin/levenshtein.cjs +74 -0
- package/bin/lint-formal-models.cjs +580 -0
- package/bin/load-baseline-requirements.cjs +275 -0
- package/bin/manage-agents-core.cjs +815 -0
- package/bin/migrate-formal-dir.cjs +172 -0
- package/bin/migrate-planning.cjs +206 -0
- package/bin/migrate-to-slots.cjs +255 -0
- package/bin/nForma.cjs +2726 -0
- package/bin/observe-config.cjs +353 -0
- package/bin/observe-debt-writer.cjs +140 -0
- package/bin/observe-handler-grafana.cjs +128 -0
- package/bin/observe-handler-internal.cjs +301 -0
- package/bin/observe-handler-logstash.cjs +153 -0
- package/bin/observe-handler-prometheus.cjs +185 -0
- package/bin/observe-handlers.cjs +436 -0
- package/bin/observe-registry.cjs +131 -0
- package/bin/observe-render.cjs +168 -0
- package/bin/planning-paths.cjs +167 -0
- package/bin/polyrepo.cjs +560 -0
- package/bin/prism-priority.cjs +153 -0
- package/bin/probe-quorum-slots.cjs +167 -0
- package/bin/promote-model.cjs +225 -0
- package/bin/propose-debug-invariants.cjs +165 -0
- package/bin/providers.json +392 -0
- package/bin/pty-proxy.py +129 -0
- package/bin/qgsd-solve.cjs +2477 -0
- package/bin/quorum-consensus-gate.cjs +238 -0
- package/bin/quorum-formal-context.cjs +183 -0
- package/bin/quorum-slot-dispatch.cjs +934 -0
- package/bin/read-policy.cjs +60 -0
- package/bin/requirement-map.cjs +63 -0
- package/bin/requirements-core.cjs +247 -0
- package/bin/resolve-cli.cjs +101 -0
- package/bin/review-mcp-logs.cjs +294 -0
- package/bin/run-account-manager-tlc.cjs +188 -0
- package/bin/run-account-pool-alloy.cjs +158 -0
- package/bin/run-alloy.cjs +153 -0
- package/bin/run-audit-alloy.cjs +187 -0
- package/bin/run-breaker-tlc.cjs +181 -0
- package/bin/run-formal-check.cjs +395 -0
- package/bin/run-formal-verify.cjs +701 -0
- package/bin/run-installer-alloy.cjs +188 -0
- package/bin/run-oauth-rotation-prism.cjs +132 -0
- package/bin/run-oscillation-tlc.cjs +202 -0
- package/bin/run-phase-tlc.cjs +228 -0
- package/bin/run-prism.cjs +446 -0
- package/bin/run-protocol-tlc.cjs +201 -0
- package/bin/run-quorum-composition-alloy.cjs +155 -0
- package/bin/run-sensitivity-sweep.cjs +231 -0
- package/bin/run-stop-hook-tlc.cjs +188 -0
- package/bin/run-tlc.cjs +467 -0
- package/bin/run-transcript-alloy.cjs +173 -0
- package/bin/run-uppaal.cjs +264 -0
- package/bin/secrets.cjs +134 -0
- package/bin/sensitivity-report.cjs +219 -0
- package/bin/sensitivity-sweep-feedback.cjs +194 -0
- package/bin/set-secret.cjs +29 -0
- package/bin/setup-telemetry-cron.sh +36 -0
- package/bin/sweepPtoF.cjs +63 -0
- package/bin/sync-baseline-requirements.cjs +290 -0
- package/bin/task-envelope.cjs +360 -0
- package/bin/telemetry-collector.cjs +229 -0
- package/bin/unified-mcp-server.mjs +735 -0
- package/bin/update-agents.cjs +369 -0
- package/bin/update-scoreboard.cjs +1134 -0
- package/bin/validate-debt-entry.cjs +207 -0
- package/bin/validate-invariant.cjs +419 -0
- package/bin/validate-memory.cjs +389 -0
- package/bin/validate-requirements-haiku.cjs +435 -0
- package/bin/validate-traces.cjs +438 -0
- package/bin/verify-formal-results.cjs +124 -0
- package/bin/verify-quorum-health.cjs +273 -0
- package/bin/write-check-result.cjs +106 -0
- package/bin/xstate-to-tla.cjs +483 -0
- package/bin/xstate-trace-walker.cjs +205 -0
- package/commands/qgsd/add-phase.md +43 -0
- package/commands/qgsd/add-requirement.md +24 -0
- package/commands/qgsd/add-todo.md +47 -0
- package/commands/qgsd/audit-milestone.md +37 -0
- package/commands/qgsd/check-todos.md +45 -0
- package/commands/qgsd/cleanup.md +18 -0
- package/commands/qgsd/close-formal-gaps.md +33 -0
- package/commands/qgsd/complete-milestone.md +136 -0
- package/commands/qgsd/debug.md +166 -0
- package/commands/qgsd/discuss-phase.md +83 -0
- package/commands/qgsd/execute-phase.md +117 -0
- package/commands/qgsd/fix-tests.md +27 -0
- package/commands/qgsd/formal-test-sync.md +32 -0
- package/commands/qgsd/health.md +22 -0
- package/commands/qgsd/help.md +22 -0
- package/commands/qgsd/insert-phase.md +32 -0
- package/commands/qgsd/join-discord.md +18 -0
- package/commands/qgsd/list-phase-assumptions.md +46 -0
- package/commands/qgsd/map-codebase.md +71 -0
- package/commands/qgsd/map-requirements.md +20 -0
- package/commands/qgsd/mcp-restart.md +176 -0
- package/commands/qgsd/mcp-set-model.md +134 -0
- package/commands/qgsd/mcp-setup.md +1371 -0
- package/commands/qgsd/mcp-status.md +274 -0
- package/commands/qgsd/mcp-update.md +238 -0
- package/commands/qgsd/new-milestone.md +44 -0
- package/commands/qgsd/new-project.md +42 -0
- package/commands/qgsd/observe.md +260 -0
- package/commands/qgsd/pause-work.md +38 -0
- package/commands/qgsd/plan-milestone-gaps.md +34 -0
- package/commands/qgsd/plan-phase.md +44 -0
- package/commands/qgsd/polyrepo.md +50 -0
- package/commands/qgsd/progress.md +24 -0
- package/commands/qgsd/queue.md +54 -0
- package/commands/qgsd/quick.md +133 -0
- package/commands/qgsd/quorum-test.md +275 -0
- package/commands/qgsd/quorum.md +707 -0
- package/commands/qgsd/reapply-patches.md +110 -0
- package/commands/qgsd/remove-phase.md +31 -0
- package/commands/qgsd/research-phase.md +189 -0
- package/commands/qgsd/resume-work.md +40 -0
- package/commands/qgsd/set-profile.md +34 -0
- package/commands/qgsd/settings.md +39 -0
- package/commands/qgsd/solve.md +565 -0
- package/commands/qgsd/sync-baselines.md +119 -0
- package/commands/qgsd/triage.md +233 -0
- package/commands/qgsd/update.md +37 -0
- package/commands/qgsd/verify-work.md +38 -0
- package/hooks/dist/config-loader.js +297 -0
- package/hooks/dist/conformance-schema.cjs +12 -0
- package/hooks/dist/gsd-context-monitor.js +64 -0
- package/hooks/dist/qgsd-check-update.js +62 -0
- package/hooks/dist/qgsd-circuit-breaker.js +682 -0
- package/hooks/dist/qgsd-precompact.js +156 -0
- package/hooks/dist/qgsd-prompt.js +653 -0
- package/hooks/dist/qgsd-session-start.js +122 -0
- package/hooks/dist/qgsd-slot-correlator.js +58 -0
- package/hooks/dist/qgsd-spec-regen.js +86 -0
- package/hooks/dist/qgsd-statusline.js +91 -0
- package/hooks/dist/qgsd-stop.js +553 -0
- package/hooks/dist/qgsd-token-collector.js +133 -0
- package/hooks/dist/unified-mcp-server.mjs +669 -0
- package/package.json +95 -0
- package/scripts/build-hooks.js +46 -0
- package/scripts/postinstall.js +48 -0
- package/scripts/secret-audit.sh +45 -0
- package/templates/qgsd.json +49 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure functions and shared helpers for manage-agents CLI and blessed TUI.
|
|
5
|
+
* No interactive dependencies (inquirer, blessed). This module provides:
|
|
6
|
+
* - File I/O helpers: readClaudeJson, writeClaudeJson, readProvidersJson, writeProvidersJson
|
|
7
|
+
* - Configuration parsing: readQgsdJson, readCcrConfigSafe, getGlobalMcpServers
|
|
8
|
+
* - Provider management: fetchProviderModels, probeProviderUrl, probeAllSlots, liveDashboard
|
|
9
|
+
* - Pure transformation functions exported in _pure namespace
|
|
10
|
+
*
|
|
11
|
+
* Consumers: bin/agents.cjs (blessed TUI)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const https = require('https');
|
|
18
|
+
const http = require('http');
|
|
19
|
+
const { spawnSync } = require('child_process');
|
|
20
|
+
const { resolveCli } = require('./resolve-cli.cjs');
|
|
21
|
+
const { updateAgents, getUpdateStatuses } = require('./update-agents.cjs');
|
|
22
|
+
|
|
23
|
+
// File paths
|
|
24
|
+
const CLAUDE_JSON_PATH = path.join(os.homedir(), '.claude.json');
|
|
25
|
+
const CLAUDE_JSON_TMP = CLAUDE_JSON_PATH + '.tmp';
|
|
26
|
+
const QGSD_JSON_PATH = path.join(os.homedir(), '.claude', 'qgsd.json');
|
|
27
|
+
const CCR_CONFIG_PATH = path.join(os.homedir(), '.claude-code-router', 'config.json');
|
|
28
|
+
const UPDATE_LOG_PATH = path.join(os.homedir(), '.claude', 'qgsd-update.log');
|
|
29
|
+
const PROVIDERS_JSON_PATH = path.join(__dirname, 'providers.json');
|
|
30
|
+
const PROVIDERS_JSON_TMP = PROVIDERS_JSON_PATH + '.tmp';
|
|
31
|
+
|
|
32
|
+
// Provider preset library — hardcoded table (user-extensible presets deferred to v0.11+)
|
|
33
|
+
// Source: MEMORY.md Provider Map (2026-02-22)
|
|
34
|
+
const PROVIDER_PRESETS = [
|
|
35
|
+
{ name: 'AkashML', value: 'https://api.akashml.com/v1', label: 'AkashML (api.akashml.com/v1)' },
|
|
36
|
+
{ name: 'Together.xyz', value: 'https://api.together.xyz/v1', label: 'Together.xyz (api.together.xyz/v1)' },
|
|
37
|
+
{ name: 'Fireworks.ai', value: 'https://api.fireworks.ai/inference/v1', label: 'Fireworks.ai (api.fireworks.ai/inference/v1)' },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const CCR_KEY_NAMES = [
|
|
41
|
+
{ key: 'AKASHML_API_KEY', label: 'AkashML API Key' },
|
|
42
|
+
{ key: 'TOGETHER_API_KEY', label: 'Together.xyz API Key' },
|
|
43
|
+
{ key: 'FIREWORKS_API_KEY', label: 'Fireworks API Key' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const POLICY_MENU_CHOICES = [
|
|
47
|
+
{ name: 'auto — check for updates on startup', value: 'auto' },
|
|
48
|
+
{ name: 'prompt — ask before updating', value: 'prompt' },
|
|
49
|
+
{ name: 'skip — never check for updates', value: 'skip' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const UPDATE_LOG_DEFAULT_MAX_AGE_MS = 86400000; // 24 hours
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Core helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function readClaudeJson() {
|
|
59
|
+
if (!fs.existsSync(CLAUDE_JSON_PATH)) {
|
|
60
|
+
throw new Error(`~/.claude.json not found at ${CLAUDE_JSON_PATH}`);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(fs.readFileSync(CLAUDE_JSON_PATH, 'utf8'));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(`~/.claude.json: ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeClaudeJson(data) {
|
|
70
|
+
fs.writeFileSync(CLAUDE_JSON_TMP, JSON.stringify(data, null, 2), 'utf8');
|
|
71
|
+
fs.renameSync(CLAUDE_JSON_TMP, CLAUDE_JSON_PATH);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getGlobalMcpServers(data) {
|
|
75
|
+
return data.mcpServers || {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fetch model list from a provider's /models endpoint (5s timeout, fail-silent)
|
|
79
|
+
function fetchProviderModels(baseUrl, apiKey) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
if (!baseUrl) return resolve(null);
|
|
82
|
+
let url;
|
|
83
|
+
try {
|
|
84
|
+
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
85
|
+
url = new URL(base + '/models');
|
|
86
|
+
} catch {
|
|
87
|
+
return resolve(null);
|
|
88
|
+
}
|
|
89
|
+
const lib = url.protocol === 'https:' ? https : http;
|
|
90
|
+
const req = lib.request(
|
|
91
|
+
{
|
|
92
|
+
hostname: url.hostname,
|
|
93
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
94
|
+
path: url.pathname + url.search,
|
|
95
|
+
method: 'GET',
|
|
96
|
+
headers: {
|
|
97
|
+
'Authorization': apiKey ? `Bearer ${apiKey}` : '',
|
|
98
|
+
'Accept': 'application/json',
|
|
99
|
+
},
|
|
100
|
+
timeout: 5000,
|
|
101
|
+
},
|
|
102
|
+
(res) => {
|
|
103
|
+
let body = '';
|
|
104
|
+
res.on('data', (c) => (body += c));
|
|
105
|
+
res.on('end', () => {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(body);
|
|
108
|
+
const list = (parsed.data || parsed.models || [])
|
|
109
|
+
.map((m) => (typeof m === 'string' ? m : m.id))
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.sort();
|
|
112
|
+
resolve(list.length ? list : null);
|
|
113
|
+
} catch {
|
|
114
|
+
resolve(null);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
req.on('error', () => resolve(null));
|
|
120
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
121
|
+
req.end();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Probe a provider URL — returns { healthy, latencyMs, statusCode, error }
|
|
126
|
+
// Counts HTTP 200/401/403/404/422 as healthy (provider is reachable).
|
|
127
|
+
function probeProviderUrl(baseUrl, apiKey) {
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
if (!baseUrl) return resolve({ healthy: false, latencyMs: 0, statusCode: null, error: 'No URL provided' });
|
|
130
|
+
let url;
|
|
131
|
+
try {
|
|
132
|
+
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
133
|
+
url = new URL(base + '/models');
|
|
134
|
+
} catch {
|
|
135
|
+
return resolve({ healthy: false, latencyMs: 0, statusCode: null, error: `Invalid URL: ${baseUrl}` });
|
|
136
|
+
}
|
|
137
|
+
const lib = url.protocol === 'https:' ? https : http;
|
|
138
|
+
const start = Date.now();
|
|
139
|
+
const req = lib.request(
|
|
140
|
+
{
|
|
141
|
+
hostname: url.hostname,
|
|
142
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
143
|
+
path: url.pathname + url.search,
|
|
144
|
+
method: 'GET',
|
|
145
|
+
headers: {
|
|
146
|
+
'Authorization': apiKey ? `Bearer ${apiKey}` : '',
|
|
147
|
+
'Accept': 'application/json',
|
|
148
|
+
'User-Agent': 'qgsd-manage-agents/1.0',
|
|
149
|
+
},
|
|
150
|
+
timeout: 7000,
|
|
151
|
+
},
|
|
152
|
+
(res) => {
|
|
153
|
+
const latencyMs = Date.now() - start;
|
|
154
|
+
res.resume();
|
|
155
|
+
res.on('end', () => {
|
|
156
|
+
const healthy = [200, 401, 403, 404, 422].includes(res.statusCode);
|
|
157
|
+
resolve({ healthy, latencyMs, statusCode: res.statusCode, error: null });
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
req.on('error', (e) => {
|
|
162
|
+
resolve({ healthy: false, latencyMs: Date.now() - start, statusCode: null, error: e.message });
|
|
163
|
+
});
|
|
164
|
+
req.on('timeout', () => {
|
|
165
|
+
req.destroy();
|
|
166
|
+
resolve({ healthy: false, latencyMs: Date.now() - start, statusCode: null, error: 'Timed out after 7000ms' });
|
|
167
|
+
});
|
|
168
|
+
req.end();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Classify a probeProviderUrl() result into a key validity verdict.
|
|
174
|
+
* probeResult: { healthy: bool, latencyMs: int, statusCode: int|null, error: string|null }
|
|
175
|
+
* Returns: 'ok' | 'invalid' | 'unreachable'
|
|
176
|
+
*/
|
|
177
|
+
function classifyProbeResult(probeResult) {
|
|
178
|
+
if (!probeResult || !probeResult.healthy) {
|
|
179
|
+
return 'unreachable';
|
|
180
|
+
}
|
|
181
|
+
if (probeResult.statusCode === 401) {
|
|
182
|
+
return 'invalid';
|
|
183
|
+
}
|
|
184
|
+
return 'ok';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Write key validity status to qgsd.json under agent_config[slotName].key_status.
|
|
189
|
+
*/
|
|
190
|
+
function writeKeyStatus(slotName, status, filePath) {
|
|
191
|
+
const qgsd = readQgsdJson(filePath);
|
|
192
|
+
if (!qgsd.agent_config) qgsd.agent_config = {};
|
|
193
|
+
if (!qgsd.agent_config[slotName]) qgsd.agent_config[slotName] = {};
|
|
194
|
+
qgsd.agent_config[slotName].key_status = {
|
|
195
|
+
status,
|
|
196
|
+
checkedAt: new Date().toISOString(),
|
|
197
|
+
};
|
|
198
|
+
writeQgsdJson(qgsd, filePath);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Format a Date.now() timestamp as HH:MM:SS.
|
|
203
|
+
* Returns '—' if ts is null/undefined/falsy.
|
|
204
|
+
* Pure function — no side effects.
|
|
205
|
+
*/
|
|
206
|
+
function formatTimestamp(ts) {
|
|
207
|
+
if (!ts) return '\u2014';
|
|
208
|
+
return new Date(ts).toTimeString().slice(0, 8);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build the full dashboard display as an array of strings.
|
|
213
|
+
* Pure function — no process.stdout.write, no side effects.
|
|
214
|
+
*/
|
|
215
|
+
function buildDashboardLines(slots, mcpServers, healthMap, lastUpdated) {
|
|
216
|
+
const lines = [];
|
|
217
|
+
lines.push(' QGSD Live Health Dashboard');
|
|
218
|
+
lines.push(' ' + '\u2500'.repeat(60));
|
|
219
|
+
lines.push('');
|
|
220
|
+
|
|
221
|
+
for (const slotName of slots) {
|
|
222
|
+
const cfg = mcpServers[slotName] || {};
|
|
223
|
+
const env = cfg.env || {};
|
|
224
|
+
const model = env.CLAUDE_DEFAULT_MODEL || '\u2014';
|
|
225
|
+
const provider = env.ANTHROPIC_BASE_URL
|
|
226
|
+
? env.ANTHROPIC_BASE_URL
|
|
227
|
+
.replace(/^https?:\/\//, '')
|
|
228
|
+
.replace(/\/v\d+\/?$/, '')
|
|
229
|
+
.replace(/\/.*$/, '')
|
|
230
|
+
: (cfg.command || '\u2014');
|
|
231
|
+
|
|
232
|
+
const probe = healthMap[slotName];
|
|
233
|
+
let status;
|
|
234
|
+
if (!probe) {
|
|
235
|
+
status = '\x1b[90m\u2014\x1b[0m';
|
|
236
|
+
} else if (probe.error === 'subprocess') {
|
|
237
|
+
status = '\x1b[90msubprocess\x1b[0m';
|
|
238
|
+
} else if (probe.healthy) {
|
|
239
|
+
status = '\x1b[32m\u2713 UP (' + probe.latencyMs + 'ms)\x1b[0m';
|
|
240
|
+
} else {
|
|
241
|
+
status = '\x1b[31m\u2717 DOWN\x1b[0m';
|
|
242
|
+
}
|
|
243
|
+
lines.push(
|
|
244
|
+
' ' +
|
|
245
|
+
slotName.padEnd(14) + ' ' +
|
|
246
|
+
provider.slice(0, 24).padEnd(24) + ' ' +
|
|
247
|
+
model.slice(0, 30).padEnd(30) + ' ' +
|
|
248
|
+
status
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
lines.push('');
|
|
253
|
+
|
|
254
|
+
const stale = lastUpdated && Date.now() - lastUpdated > 60_000;
|
|
255
|
+
const ts = formatTimestamp(lastUpdated);
|
|
256
|
+
lines.push(
|
|
257
|
+
' Last updated: ' + ts +
|
|
258
|
+
(stale ? ' \x1b[33m[stale]\x1b[0m' : '')
|
|
259
|
+
);
|
|
260
|
+
lines.push(' [space/r] refresh [q/Esc] exit');
|
|
261
|
+
return lines;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Return a likely upgrade model ID, or null if current is already latest
|
|
265
|
+
function detectUpgrade(current, available) {
|
|
266
|
+
if (!current || !available) return null;
|
|
267
|
+
// Match: optional-namespace/Name-VX.Y or Name-VX-Y
|
|
268
|
+
const vRe = /^(.*?)[-./]?v?(\d+(?:[._]\d+)?)$/i;
|
|
269
|
+
const cm = current.match(vRe);
|
|
270
|
+
if (!cm) return null;
|
|
271
|
+
const [, cBase, cVer] = cm;
|
|
272
|
+
const cV = parseFloat(cVer.replace('_', '.'));
|
|
273
|
+
let best = null;
|
|
274
|
+
let bestV = cV;
|
|
275
|
+
const prefixLen = Math.max(4, Math.floor(cBase.length * 0.6));
|
|
276
|
+
for (const m of available) {
|
|
277
|
+
const mm = m.match(vRe);
|
|
278
|
+
if (!mm) continue;
|
|
279
|
+
const [, mBase, mVer] = mm;
|
|
280
|
+
if (!mBase.toLowerCase().startsWith(cBase.toLowerCase().slice(0, prefixLen))) continue;
|
|
281
|
+
const mV = parseFloat(mVer.replace('_', '.'));
|
|
282
|
+
if (mV > bestV) { bestV = mV; best = m; }
|
|
283
|
+
}
|
|
284
|
+
return best;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Masked API key for display
|
|
288
|
+
function maskKey(key) {
|
|
289
|
+
if (!key) return '(not set)';
|
|
290
|
+
if (key.length <= 12) return '***';
|
|
291
|
+
return key.slice(0, 8) + '...' + key.slice(-4);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Short provider hostname for display
|
|
295
|
+
function shortProvider(cfg) {
|
|
296
|
+
if (cfg.env && cfg.env.ANTHROPIC_BASE_URL) {
|
|
297
|
+
return cfg.env.ANTHROPIC_BASE_URL
|
|
298
|
+
.replace(/^https?:\/\//, '')
|
|
299
|
+
.replace(/\/v\d+\/?$/, '')
|
|
300
|
+
.replace(/\/.*$/, '');
|
|
301
|
+
}
|
|
302
|
+
if (cfg.command === 'npx') return 'npx';
|
|
303
|
+
return cfg.command || '—';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Providers JSON helpers
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
function readProvidersJson() {
|
|
311
|
+
if (!fs.existsSync(PROVIDERS_JSON_PATH)) {
|
|
312
|
+
return { providers: [] };
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
return JSON.parse(fs.readFileSync(PROVIDERS_JSON_PATH, 'utf8'));
|
|
316
|
+
} catch (err) {
|
|
317
|
+
throw new Error(`providers.json: ${err.message}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function writeProvidersJson(data) {
|
|
322
|
+
fs.writeFileSync(PROVIDERS_JSON_TMP, JSON.stringify(data, null, 2), 'utf8');
|
|
323
|
+
fs.renameSync(PROVIDERS_JSON_TMP, PROVIDERS_JSON_PATH);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// QGSD JSON helpers
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
function readQgsdJson(filePath) {
|
|
331
|
+
const p = filePath || QGSD_JSON_PATH;
|
|
332
|
+
if (!fs.existsSync(p)) return {};
|
|
333
|
+
try {
|
|
334
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
335
|
+
} catch (_) {
|
|
336
|
+
return {};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function writeQgsdJson(data, filePath) {
|
|
341
|
+
const p = filePath || QGSD_JSON_PATH;
|
|
342
|
+
const tmp = p + '.tmp';
|
|
343
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
344
|
+
fs.renameSync(tmp, p);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function writeUpdatePolicy(slotName, policy, filePath) {
|
|
348
|
+
const qgsd = readQgsdJson(filePath);
|
|
349
|
+
if (!qgsd.agent_config) qgsd.agent_config = {};
|
|
350
|
+
if (!qgsd.agent_config[slotName]) qgsd.agent_config[slotName] = {};
|
|
351
|
+
qgsd.agent_config[slotName].update_policy = policy;
|
|
352
|
+
writeQgsdJson(qgsd, filePath);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Pure transformation functions
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
function deriveKeytarAccount(slotName) {
|
|
360
|
+
return 'ANTHROPIC_API_KEY_' + slotName.toUpperCase().replace(/-/g, '_');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function buildKeyStatus(authType, slotName, secretsLib) {
|
|
364
|
+
if (authType === 'sub') return '\x1b[36m[sub]\x1b[0m';
|
|
365
|
+
const account = deriveKeytarAccount(slotName);
|
|
366
|
+
if (secretsLib && secretsLib.hasKey(account)) return '\x1b[32m[key \u2713]\x1b[0m';
|
|
367
|
+
return '\x1b[90m[no key]\x1b[0m';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function buildAgentChoiceLabel(name, cfg, providerMap, agentCfg, secretsLib) {
|
|
371
|
+
cfg = cfg || {};
|
|
372
|
+
const env = cfg.env || {};
|
|
373
|
+
providerMap = providerMap || {};
|
|
374
|
+
agentCfg = agentCfg || {};
|
|
375
|
+
|
|
376
|
+
const slot = env.PROVIDER_SLOT;
|
|
377
|
+
const p = slot ? providerMap[slot] : null;
|
|
378
|
+
let model;
|
|
379
|
+
if (p) {
|
|
380
|
+
model = p.model || p.mainTool || '\u2014';
|
|
381
|
+
} else {
|
|
382
|
+
model = env.CLAUDE_DEFAULT_MODEL || cfg.command || '?';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const authType = agentCfg[name] && agentCfg[name].auth_type;
|
|
386
|
+
const keyStatus = buildKeyStatus(authType, name, secretsLib);
|
|
387
|
+
|
|
388
|
+
return `${name.padEnd(14)} ${model.slice(0, 36).padEnd(36)} ${keyStatus}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function applyKeyUpdate(updates, keytarAccount, newEnv, secretsLib) {
|
|
392
|
+
if (!('apiKey' in updates)) return newEnv;
|
|
393
|
+
if (updates.apiKey === '__REMOVE__') {
|
|
394
|
+
delete newEnv.ANTHROPIC_API_KEY;
|
|
395
|
+
if (secretsLib) secretsLib.delete('qgsd', keytarAccount);
|
|
396
|
+
} else {
|
|
397
|
+
delete newEnv.ANTHROPIC_API_KEY;
|
|
398
|
+
if (secretsLib) {
|
|
399
|
+
secretsLib.set('qgsd', keytarAccount, updates.apiKey);
|
|
400
|
+
} else {
|
|
401
|
+
newEnv.ANTHROPIC_API_KEY = updates.apiKey;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return newEnv;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function applyCcrProviderUpdate(subAction, selectedKey, keyValue, secretsLib) {
|
|
408
|
+
if (subAction === 'set') {
|
|
409
|
+
await secretsLib.set('qgsd', selectedKey, keyValue);
|
|
410
|
+
return { action: 'set', key: selectedKey };
|
|
411
|
+
}
|
|
412
|
+
if (subAction === 'remove') {
|
|
413
|
+
await secretsLib.delete('qgsd', selectedKey);
|
|
414
|
+
return { action: 'remove', key: selectedKey };
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function slotToFamily(slotName) {
|
|
420
|
+
return slotName.replace(/-\d+$/, '');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function getWlDisplay(family, scoreboardData) {
|
|
424
|
+
if (!scoreboardData) return '\u2014';
|
|
425
|
+
const entry = scoreboardData.models && scoreboardData.models[family];
|
|
426
|
+
if (!entry) return '\u2014';
|
|
427
|
+
return String(entry.tp || 0) + 'W/' + String(entry.fn || 0) + 'L';
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function readCcrConfigSafe(ccrConfigPath) {
|
|
431
|
+
const p = ccrConfigPath || CCR_CONFIG_PATH;
|
|
432
|
+
if (!fs.existsSync(p)) return null;
|
|
433
|
+
try {
|
|
434
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
435
|
+
} catch (_) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function getCcrProviderForSlot(model, ccrConfig) {
|
|
441
|
+
if (!ccrConfig || !model) return null;
|
|
442
|
+
for (const provider of (ccrConfig.providers || [])) {
|
|
443
|
+
if ((provider.models || []).includes(model)) {
|
|
444
|
+
return provider.name;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function getKeyInvalidBadge(slotName, agentConfig, hasKeyFn) {
|
|
451
|
+
const slotCfg = agentConfig && agentConfig[slotName];
|
|
452
|
+
if (!slotCfg) return '';
|
|
453
|
+
const ks = slotCfg.key_status;
|
|
454
|
+
if (!ks || ks.status !== 'invalid') return '';
|
|
455
|
+
if (!hasKeyFn || !hasKeyFn(slotName)) return '';
|
|
456
|
+
return ' [key invalid]';
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function findPresetForUrl(url) {
|
|
460
|
+
if (!url) return '__custom__';
|
|
461
|
+
const found = PROVIDER_PRESETS.find((p) => p.value === url);
|
|
462
|
+
return found ? found.value : '__custom__';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function buildCloneEntry(sourceCfg, newName) {
|
|
466
|
+
const sourceEnv = (sourceCfg && sourceCfg.env) || {};
|
|
467
|
+
const newEnv = {};
|
|
468
|
+
if (sourceEnv.ANTHROPIC_BASE_URL) newEnv.ANTHROPIC_BASE_URL = sourceEnv.ANTHROPIC_BASE_URL;
|
|
469
|
+
if (sourceEnv.CLAUDE_DEFAULT_MODEL) newEnv.CLAUDE_DEFAULT_MODEL = sourceEnv.CLAUDE_DEFAULT_MODEL;
|
|
470
|
+
if (sourceEnv.CLAUDE_MCP_TIMEOUT_MS) newEnv.CLAUDE_MCP_TIMEOUT_MS = sourceEnv.CLAUDE_MCP_TIMEOUT_MS;
|
|
471
|
+
newEnv.PROVIDER_SLOT = newName;
|
|
472
|
+
return {
|
|
473
|
+
type: (sourceCfg && sourceCfg.type) || 'stdio',
|
|
474
|
+
command: (sourceCfg && sourceCfg.command) || 'node',
|
|
475
|
+
args: (sourceCfg && sourceCfg.args) ? [...sourceCfg.args] : [],
|
|
476
|
+
env: newEnv,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function buildTimeoutChoices(slots, mcpServers, providersData) {
|
|
481
|
+
const providers = (providersData && providersData.providers) || [];
|
|
482
|
+
return slots.map((slotName) => {
|
|
483
|
+
const cfg = mcpServers[slotName] || {};
|
|
484
|
+
const providerSlot = (cfg.env && cfg.env.PROVIDER_SLOT) || slotName;
|
|
485
|
+
const provider = providers.find((p) => p.name === providerSlot) || null;
|
|
486
|
+
const currentMs = provider
|
|
487
|
+
? (provider.quorum_timeout_ms != null ? provider.quorum_timeout_ms
|
|
488
|
+
: provider.timeout_ms != null ? provider.timeout_ms
|
|
489
|
+
: null)
|
|
490
|
+
: null;
|
|
491
|
+
return { slotName, providerSlot, currentMs };
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function applyTimeoutUpdate(providersData, providerSlot, newTimeoutMs) {
|
|
496
|
+
const providers = (providersData.providers || []).map((p) =>
|
|
497
|
+
p.name === providerSlot ? { ...p, quorum_timeout_ms: newTimeoutMs } : { ...p }
|
|
498
|
+
);
|
|
499
|
+
return { ...providersData, providers };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function buildPolicyChoices(currentPolicy) {
|
|
503
|
+
return POLICY_MENU_CHOICES.map((c) => ({
|
|
504
|
+
...c,
|
|
505
|
+
name: c.value === currentPolicy
|
|
506
|
+
? c.name + ' \x1b[90m\u2190 current\x1b[0m'
|
|
507
|
+
: c.name,
|
|
508
|
+
}));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function validateTimeout(value) {
|
|
512
|
+
// Blank/empty/null/undefined means "keep current" — valid state, return ms: null
|
|
513
|
+
if (value === '' || value === null || value === undefined) {
|
|
514
|
+
return { valid: true, ms: null };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const parsed = parseInt(value, 10);
|
|
518
|
+
|
|
519
|
+
// Check if parsing failed (NaN) or result is non-positive
|
|
520
|
+
if (isNaN(parsed) || parsed <= 0) {
|
|
521
|
+
return { valid: false, error: `Timeout must be a positive number (got: ${value})` };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return { valid: true, ms: parsed };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function validateUpdatePolicy(policy) {
|
|
528
|
+
// Valid policies derived from POLICY_MENU_CHOICES
|
|
529
|
+
const validPolicies = POLICY_MENU_CHOICES.map((c) => c.value);
|
|
530
|
+
|
|
531
|
+
if (validPolicies.includes(policy)) {
|
|
532
|
+
return { valid: true, policy };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
valid: false,
|
|
537
|
+
error: `Unknown update policy '${policy}'. Valid values: ${validPolicies.join(', ')}`
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function buildUpdateLogEntry(slotName, status, detail) {
|
|
542
|
+
return JSON.stringify({
|
|
543
|
+
ts: new Date().toISOString(),
|
|
544
|
+
slot: slotName,
|
|
545
|
+
status,
|
|
546
|
+
detail: detail != null ? detail : null,
|
|
547
|
+
}) + '\n';
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function parseUpdateLogErrors(logContent, maxAgeMs) {
|
|
551
|
+
if (!logContent) return [];
|
|
552
|
+
const cutoff = Date.now() - (maxAgeMs != null ? maxAgeMs : UPDATE_LOG_DEFAULT_MAX_AGE_MS);
|
|
553
|
+
return logContent
|
|
554
|
+
.split('\n')
|
|
555
|
+
.filter(Boolean)
|
|
556
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } })
|
|
557
|
+
.filter(Boolean)
|
|
558
|
+
.filter((e) => e.status === 'ERROR' && new Date(e.ts).getTime() > cutoff);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function buildBackupPath(claudeJsonPath, isoTimestamp) {
|
|
562
|
+
return claudeJsonPath + '.pre-import.' + isoTimestamp;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function buildRedactedEnv(env) {
|
|
566
|
+
if (!env || typeof env !== 'object') return {};
|
|
567
|
+
const SENSITIVE_RE = /(_KEY|_SECRET|_TOKEN|_PASSWORD)$/i;
|
|
568
|
+
return Object.fromEntries(
|
|
569
|
+
Object.entries(env).map(([k, v]) => [k, SENSITIVE_RE.test(k) ? '__redacted__' : v])
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function buildExportData(claudeJsonData) {
|
|
574
|
+
const out = JSON.parse(JSON.stringify(claudeJsonData));
|
|
575
|
+
const servers = out.mcpServers || {};
|
|
576
|
+
for (const cfg of Object.values(servers)) {
|
|
577
|
+
if (cfg.env) cfg.env = buildRedactedEnv(cfg.env);
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function validateImportSchema(parsed) {
|
|
583
|
+
const errors = [];
|
|
584
|
+
const ALLOWED_COMMANDS = ['node', 'npx'];
|
|
585
|
+
const HOME_PATH_RE = /^\/(Users|home)\//;
|
|
586
|
+
|
|
587
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
588
|
+
errors.push('Root must be a JSON object');
|
|
589
|
+
return errors;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const servers = parsed.mcpServers || {};
|
|
593
|
+
for (const [slotName, cfg] of Object.entries(servers)) {
|
|
594
|
+
if (!cfg || typeof cfg !== 'object') continue;
|
|
595
|
+
if (cfg.command && !ALLOWED_COMMANDS.includes(cfg.command)) {
|
|
596
|
+
errors.push(`${slotName}: command must be "node" or "npx", got "${cfg.command}"`);
|
|
597
|
+
}
|
|
598
|
+
if (Array.isArray(cfg.args)) {
|
|
599
|
+
for (const arg of cfg.args) {
|
|
600
|
+
if (typeof arg === 'string' && HOME_PATH_RE.test(arg)) {
|
|
601
|
+
errors.push(`${slotName}: args must not contain absolute home paths (found: ${arg})`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return errors;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
// Async helpers: probing, health checks, updates
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Probe a single slot and persist key_status to qgsd.json if the provider responded.
|
|
615
|
+
* Does NOT persist for timeout/network errors (statusCode is null) -- this is
|
|
616
|
+
* a provider/network issue, not a key validity issue.
|
|
617
|
+
* @param {string} slotName - MCP server slot name (e.g. 'claude-1')
|
|
618
|
+
* @param {string} baseUrl - Provider base URL (e.g. 'https://api.akashml.com/v1')
|
|
619
|
+
* @param {string} apiKey - API key for authentication
|
|
620
|
+
* @returns {Promise<Object>} The raw probe result from probeProviderUrl
|
|
621
|
+
*/
|
|
622
|
+
async function probeAndPersistKey(slotName, baseUrl, apiKey) {
|
|
623
|
+
const probe = await probeProviderUrl(baseUrl, apiKey);
|
|
624
|
+
// Only persist status if provider actually responded (statusCode present).
|
|
625
|
+
// Do NOT persist for timeout/network errors (statusCode is null) -- this is
|
|
626
|
+
// a provider/network issue, not a key validity issue (see invariants.md).
|
|
627
|
+
if (probe.healthy || probe.statusCode) {
|
|
628
|
+
const status = classifyProbeResult(probe);
|
|
629
|
+
writeKeyStatus(slotName, status);
|
|
630
|
+
}
|
|
631
|
+
return probe;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function probeAllSlots(mcpServers, slots, secretsLib) {
|
|
635
|
+
const results = await Promise.all(slots.map(async (slotName) => {
|
|
636
|
+
const cfg = mcpServers[slotName] || {};
|
|
637
|
+
const env = cfg.env || {};
|
|
638
|
+
if (!env.ANTHROPIC_BASE_URL) {
|
|
639
|
+
return [slotName, { healthy: null, latencyMs: 0, statusCode: null, error: 'subprocess' }];
|
|
640
|
+
}
|
|
641
|
+
const account = deriveKeytarAccount(slotName);
|
|
642
|
+
let apiKey = env.ANTHROPIC_API_KEY || '';
|
|
643
|
+
if (secretsLib) {
|
|
644
|
+
try {
|
|
645
|
+
const k = await secretsLib.get('qgsd', account);
|
|
646
|
+
if (k) apiKey = k;
|
|
647
|
+
} catch (_) {}
|
|
648
|
+
}
|
|
649
|
+
const probe = await probeAndPersistKey(slotName, env.ANTHROPIC_BASE_URL, apiKey);
|
|
650
|
+
return [slotName, probe];
|
|
651
|
+
}));
|
|
652
|
+
return Object.fromEntries(results);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function runAutoUpdateCheck(getStatusesFn = getUpdateStatuses) {
|
|
656
|
+
const check = async () => {
|
|
657
|
+
let qgsd;
|
|
658
|
+
try { qgsd = readQgsdJson(); } catch { return; }
|
|
659
|
+
const agentConfig = qgsd.agent_config || {};
|
|
660
|
+
const autoSlots = Object.keys(agentConfig).filter(
|
|
661
|
+
(s) => agentConfig[s] && agentConfig[s].update_policy === 'auto'
|
|
662
|
+
);
|
|
663
|
+
if (!autoSlots.length) return;
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
fs.mkdirSync(path.dirname(UPDATE_LOG_PATH), { recursive: true });
|
|
667
|
+
} catch (_) {}
|
|
668
|
+
|
|
669
|
+
let statuses;
|
|
670
|
+
try {
|
|
671
|
+
statuses = await getStatusesFn();
|
|
672
|
+
} catch (err) {
|
|
673
|
+
for (const slot of autoSlots) {
|
|
674
|
+
fs.appendFileSync(UPDATE_LOG_PATH, buildUpdateLogEntry(slot, 'ERROR', `getUpdateStatuses failed: ${err.message}`));
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
let providerMap = {};
|
|
680
|
+
try {
|
|
681
|
+
const pdata = readProvidersJson();
|
|
682
|
+
for (const p of (pdata.providers || [])) providerMap[p.name] = p;
|
|
683
|
+
} catch (_) {}
|
|
684
|
+
|
|
685
|
+
for (const slot of autoSlots) {
|
|
686
|
+
const p = providerMap[slot];
|
|
687
|
+
const binName = p && p.cli ? path.basename(p.cli) : null;
|
|
688
|
+
const statusEntry = binName ? statuses.get(binName) : undefined;
|
|
689
|
+
let status, detail;
|
|
690
|
+
if (!statusEntry) {
|
|
691
|
+
status = 'SKIP';
|
|
692
|
+
detail = binName ? 'no update info available for slot' : 'no provider record for slot';
|
|
693
|
+
} else if (statusEntry.error) {
|
|
694
|
+
status = 'ERROR';
|
|
695
|
+
detail = statusEntry.error;
|
|
696
|
+
} else if (statusEntry.status === 'update-available') {
|
|
697
|
+
status = 'UPDATE_AVAILABLE';
|
|
698
|
+
detail = statusEntry.latest || null;
|
|
699
|
+
} else {
|
|
700
|
+
status = 'OK';
|
|
701
|
+
detail = statusEntry.current || null;
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
fs.appendFileSync(UPDATE_LOG_PATH, buildUpdateLogEntry(slot, status, detail));
|
|
705
|
+
} catch (_) {}
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const timeout = new Promise((resolve) => setTimeout(resolve, 20000));
|
|
710
|
+
try {
|
|
711
|
+
await Promise.race([check(), timeout]);
|
|
712
|
+
} catch (_) {}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function liveDashboard() {
|
|
716
|
+
const readline = require('readline');
|
|
717
|
+
const data = readClaudeJson();
|
|
718
|
+
const mcpServers = getGlobalMcpServers(data);
|
|
719
|
+
const slots = Object.keys(mcpServers);
|
|
720
|
+
|
|
721
|
+
let secretsLib = null;
|
|
722
|
+
try { secretsLib = require('./secrets.cjs'); } catch (_) {}
|
|
723
|
+
|
|
724
|
+
let lastProbe = null;
|
|
725
|
+
let lastUpdated = null;
|
|
726
|
+
|
|
727
|
+
const update = async () => {
|
|
728
|
+
lastProbe = await probeAllSlots(mcpServers, slots, secretsLib);
|
|
729
|
+
lastUpdated = Date.now();
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const render = () => {
|
|
733
|
+
const lines = buildDashboardLines(slots, mcpServers, lastProbe || {}, lastUpdated);
|
|
734
|
+
process.stdout.write('\x1b[2J\x1b[0;0H' + lines.join('\n') + '\n');
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
if (!process.stdout.isTTY) {
|
|
738
|
+
await update();
|
|
739
|
+
render();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
await update();
|
|
744
|
+
render();
|
|
745
|
+
|
|
746
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
747
|
+
rl.setRawMode(true);
|
|
748
|
+
|
|
749
|
+
return new Promise((resolve) => {
|
|
750
|
+
const onKey = async (chunk) => {
|
|
751
|
+
const char = String.fromCharCode(chunk);
|
|
752
|
+
if (char === 'q' || char === '\u001b') {
|
|
753
|
+
rl.setRawMode(false);
|
|
754
|
+
rl.close();
|
|
755
|
+
process.stdout.write('\x1b[?1049l');
|
|
756
|
+
resolve();
|
|
757
|
+
} else if (char === ' ' || char === 'r') {
|
|
758
|
+
await update();
|
|
759
|
+
render();
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
process.stdin.on('data', onKey);
|
|
763
|
+
process.stdout.write('\x1b[?1049h');
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ---------------------------------------------------------------------------
|
|
768
|
+
// Module exports
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
module.exports = { readClaudeJson, writeClaudeJson, getGlobalMcpServers };
|
|
772
|
+
|
|
773
|
+
module.exports._pure = {
|
|
774
|
+
deriveKeytarAccount,
|
|
775
|
+
maskKey,
|
|
776
|
+
buildKeyStatus,
|
|
777
|
+
buildAgentChoiceLabel,
|
|
778
|
+
applyKeyUpdate,
|
|
779
|
+
applyCcrProviderUpdate,
|
|
780
|
+
readQgsdJson,
|
|
781
|
+
writeQgsdJson,
|
|
782
|
+
slotToFamily,
|
|
783
|
+
getWlDisplay,
|
|
784
|
+
readCcrConfigSafe,
|
|
785
|
+
getCcrProviderForSlot,
|
|
786
|
+
getKeyInvalidBadge,
|
|
787
|
+
findPresetForUrl,
|
|
788
|
+
buildCloneEntry,
|
|
789
|
+
classifyProbeResult,
|
|
790
|
+
writeKeyStatus,
|
|
791
|
+
formatTimestamp,
|
|
792
|
+
buildDashboardLines,
|
|
793
|
+
buildTimeoutChoices,
|
|
794
|
+
applyTimeoutUpdate,
|
|
795
|
+
buildPolicyChoices,
|
|
796
|
+
validateTimeout,
|
|
797
|
+
validateUpdatePolicy,
|
|
798
|
+
buildUpdateLogEntry,
|
|
799
|
+
parseUpdateLogErrors,
|
|
800
|
+
probeAndPersistKey,
|
|
801
|
+
probeAllSlots,
|
|
802
|
+
liveDashboard,
|
|
803
|
+
runAutoUpdateCheck,
|
|
804
|
+
buildBackupPath,
|
|
805
|
+
buildRedactedEnv,
|
|
806
|
+
buildExportData,
|
|
807
|
+
validateImportSchema,
|
|
808
|
+
readProvidersJson,
|
|
809
|
+
writeProvidersJson,
|
|
810
|
+
writeUpdatePolicy,
|
|
811
|
+
detectUpgrade,
|
|
812
|
+
shortProvider,
|
|
813
|
+
fetchProviderModels,
|
|
814
|
+
probeProviderUrl,
|
|
815
|
+
};
|