impel-cli 0.18.12 → 0.18.13
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/README.md +6 -0
- package/package.json +1 -1
- package/src/commands/launch.js +49 -0
- package/src/commands/setup.js +40 -43
- package/src/commands/status.js +9 -2
- package/src/providerReadiness.js +122 -0
package/README.md
CHANGED
|
@@ -103,6 +103,12 @@ impel claude
|
|
|
103
103
|
impel codex
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
Before spawning the vendor CLI, Impel verifies that the selected tenant's live
|
|
107
|
+
model catalogue has a healthy, routable provider pool. A short-lived successful
|
|
108
|
+
check is reused for one minute. If a provider is unavailable, the command stops
|
|
109
|
+
with an actionable gateway message instead of opening a client that cannot
|
|
110
|
+
complete inference.
|
|
111
|
+
|
|
106
112
|
Arguments after the command are passed to the vendor CLI:
|
|
107
113
|
|
|
108
114
|
```sh
|
package/package.json
CHANGED
package/src/commands/launch.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
normalizeGatewayUrl,
|
|
13
13
|
redactSecretText,
|
|
14
14
|
resolveDefaultGateway,
|
|
15
|
+
saveConfig,
|
|
15
16
|
} from "../config.js";
|
|
16
17
|
import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "../claudeSetup.js";
|
|
17
18
|
import { withGitEnvironment } from "../skills.js";
|
|
@@ -22,6 +23,12 @@ import {
|
|
|
22
23
|
resolveNativeBinary,
|
|
23
24
|
} from "../nativeProcess.js";
|
|
24
25
|
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
26
|
+
import {
|
|
27
|
+
cacheProviderReadiness,
|
|
28
|
+
cachedProviderReadiness,
|
|
29
|
+
probeGatewayProviderReadiness,
|
|
30
|
+
providerAvailable,
|
|
31
|
+
} from "../providerReadiness.js";
|
|
25
32
|
|
|
26
33
|
const CLAUDE_DIRECT_AUTH_ENV = [
|
|
27
34
|
"ANTHROPIC_API_KEY",
|
|
@@ -81,6 +88,32 @@ function childExitCode(code, signal) {
|
|
|
81
88
|
return Number.isInteger(signalNumber) ? 128 + signalNumber : 1;
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
function localInformationRequest(argv) {
|
|
92
|
+
return argv.length === 1 && ["--help", "-h", "--version", "-V", "-v", "help"].includes(argv[0]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function assertLiveProviderReadiness({ config, gatewayUrl, credential, tenantId, tool, crossAppModels }) {
|
|
96
|
+
let readiness = cachedProviderReadiness(config, tenantId);
|
|
97
|
+
if (!readiness) {
|
|
98
|
+
readiness = await probeGatewayProviderReadiness(gatewayUrl, credential, tenantId);
|
|
99
|
+
if (cacheProviderReadiness(config, tenantId, readiness)) saveConfig(config);
|
|
100
|
+
}
|
|
101
|
+
const label = tool === "claude" ? "Claude" : "Codex";
|
|
102
|
+
if (!readiness.reachable) {
|
|
103
|
+
throw new Error(`could not verify live ${label} availability for tenant "${tenantId}" (${readiness.error || "gateway unreachable"})`);
|
|
104
|
+
}
|
|
105
|
+
if (readiness.rejected) throw new Error(`gateway credential was rejected (HTTP ${readiness.status})`);
|
|
106
|
+
if (!readiness.healthy) {
|
|
107
|
+
throw new Error(`could not verify live ${label} availability for tenant "${tenantId}" (${readiness.error || `HTTP ${readiness.status}`})`);
|
|
108
|
+
}
|
|
109
|
+
if (!providerAvailable(tool, readiness.providers, { crossAppModels })) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`${label} is currently unavailable for tenant "${tenantId}": the gateway advertises no ${label} models. `
|
|
112
|
+
+ `Ask a gateway administrator to restore the ${label} provider pool, then rerun \`${RUNTIME_BRAND.cli.command} setup\`.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
84
117
|
function runNativeCli(tool, argv, environment) {
|
|
85
118
|
const binary = resolveNativeBinary(tool, environment);
|
|
86
119
|
let invocation;
|
|
@@ -141,6 +174,22 @@ export async function cmdLaunch(tool, argv) {
|
|
|
141
174
|
return;
|
|
142
175
|
}
|
|
143
176
|
const gatewayCredential = tenantCredential(config.pat, tenantId);
|
|
177
|
+
if (!localInformationRequest(argv)) {
|
|
178
|
+
try {
|
|
179
|
+
await assertLiveProviderReadiness({
|
|
180
|
+
config,
|
|
181
|
+
gatewayUrl,
|
|
182
|
+
credential: gatewayCredential,
|
|
183
|
+
tenantId,
|
|
184
|
+
tool,
|
|
185
|
+
crossAppModels,
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error(`${RUNTIME_BRAND.cli.command} ${tool}: ${redactSecretText(error?.message || error)}`);
|
|
189
|
+
process.exitCode = 1;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
144
193
|
const environment = { ...process.env };
|
|
145
194
|
environment.IMPEL_TENANT_ID = tenantId;
|
|
146
195
|
let agentProfile;
|
package/src/commands/setup.js
CHANGED
|
@@ -23,6 +23,11 @@ import { runInstallRecovery } from "../installRecovery/engine.js";
|
|
|
23
23
|
import { refreshUpdateCache, updateNoticeLine } from "../updates.js";
|
|
24
24
|
import { restoreNativeProfiles } from "./use.js";
|
|
25
25
|
import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
26
|
+
import {
|
|
27
|
+
cacheProviderReadiness,
|
|
28
|
+
probeGatewayProviderReadiness,
|
|
29
|
+
providerAvailable,
|
|
30
|
+
} from "../providerReadiness.js";
|
|
26
31
|
|
|
27
32
|
const HELP = brandedText(`impel setup - prepare every accessible Impel tenant
|
|
28
33
|
|
|
@@ -52,52 +57,33 @@ export function resolveTenantChoice(listing, { requested = null, answer = null,
|
|
|
52
57
|
return selectDefaultTenant(listing, { currentTenantId });
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
async function probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs) {
|
|
56
|
-
const controller = new AbortController();
|
|
57
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
58
|
-
try {
|
|
59
|
-
const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
|
|
60
|
-
headers: {
|
|
61
|
-
accept: "application/json",
|
|
62
|
-
authorization: `Bearer ${tenantCredential(pat, tenantId)}`,
|
|
63
|
-
},
|
|
64
|
-
signal: controller.signal,
|
|
65
|
-
});
|
|
66
|
-
return {
|
|
67
|
-
reachable: true,
|
|
68
|
-
healthy: response.ok,
|
|
69
|
-
status: response.status,
|
|
70
|
-
rejected: [401, 403].includes(response.status),
|
|
71
|
-
};
|
|
72
|
-
} catch (error) {
|
|
73
|
-
return {
|
|
74
|
-
reachable: false,
|
|
75
|
-
error: error?.name === "AbortError"
|
|
76
|
-
? `timed out after ${Math.round(timeoutMs / 1000)}s`
|
|
77
|
-
: redactSecretText(error?.message || error),
|
|
78
|
-
};
|
|
79
|
-
} finally {
|
|
80
|
-
clearTimeout(timeout);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// A single short probe misreports a cold gateway (serverless cold start plus
|
|
85
|
-
// long-haul latency) as unreachable, and that transient verdict gets baked
|
|
86
|
-
// into the convergence failure message. Retry once with a longer deadline
|
|
87
|
-
// before concluding the gateway is down.
|
|
88
60
|
export async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch, timeoutsMs = [5_000, 10_000]) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
61
|
+
return probeGatewayProviderReadiness(
|
|
62
|
+
gatewayUrl,
|
|
63
|
+
tenantCredential(pat, tenantId),
|
|
64
|
+
tenantId,
|
|
65
|
+
fetchImpl,
|
|
66
|
+
timeoutsMs,
|
|
67
|
+
);
|
|
95
68
|
}
|
|
96
69
|
|
|
97
|
-
function markProbeFailures(report, probes) {
|
|
70
|
+
function markProbeFailures(report, probes, { crossAppModels = false } = {}) {
|
|
98
71
|
probes.forEach((probe, index) => {
|
|
99
|
-
if (probe.reachable && !probe.rejected && probe.healthy !== false) return;
|
|
100
72
|
const tenant = report.tenants[index];
|
|
73
|
+
if (probe.reachable && !probe.rejected && probe.healthy !== false) {
|
|
74
|
+
if (!Array.isArray(probe.providers)) return;
|
|
75
|
+
for (const client of ["claude", "codex"]) {
|
|
76
|
+
if (tenant.clients[client].cli !== "ready") continue;
|
|
77
|
+
if (!providerAvailable(client, probe.providers, { crossAppModels })) {
|
|
78
|
+
tenant.clients[client].cli = "unavailable";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const states = [tenant.clients.claude.cli, tenant.clients.codex.cli];
|
|
82
|
+
tenant.cli = states.includes("failed")
|
|
83
|
+
? "failed"
|
|
84
|
+
: states.includes("ready") ? "ready" : "unavailable";
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
101
87
|
tenant.cli = "failed";
|
|
102
88
|
tenant.status = "failed";
|
|
103
89
|
for (const client of Object.values(tenant.clients)) {
|
|
@@ -344,7 +330,12 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
344
330
|
) => {
|
|
345
331
|
const nextReport = await runConvergence(includeApps, tenants, confirmInstall);
|
|
346
332
|
const probes = await Promise.all(tenants.map((tenant) => io.probe(gatewayUrl, pat, tenant.id)));
|
|
347
|
-
|
|
333
|
+
let cacheChanged = false;
|
|
334
|
+
probes.forEach((probe, index) => {
|
|
335
|
+
cacheChanged = cacheProviderReadiness(config, tenants[index].id, probe) || cacheChanged;
|
|
336
|
+
});
|
|
337
|
+
if (cacheChanged) io.saveConfig(config);
|
|
338
|
+
return markProbeFailures(nextReport, probes, { crossAppModels: config.experimental?.crossAppModels === true });
|
|
348
339
|
};
|
|
349
340
|
let report = await verifyConvergence();
|
|
350
341
|
if (sharedFailure) report.passed = false;
|
|
@@ -476,7 +467,13 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
476
467
|
printReconciliationSummary(report);
|
|
477
468
|
console.log(`CLI tenant: ${selected.id}`);
|
|
478
469
|
if (!RUNTIME_BRAND.tenant.defaultId) console.log("Change it: impel tenant use <tenant>");
|
|
479
|
-
|
|
470
|
+
const selectedReport = report.tenants.find((tenant) => tenant.tenantId === selected.id);
|
|
471
|
+
const launchable = ["claude", "codex"]
|
|
472
|
+
.filter((client) => selectedReport?.clients?.[client]?.cli === "ready")
|
|
473
|
+
.map((client) => `${RUNTIME_BRAND.cli.command} ${client}`);
|
|
474
|
+
console.log(launchable.length
|
|
475
|
+
? `Launch: ${launchable.join(" | ")}`
|
|
476
|
+
: "Launch: no provider CLI is currently available for this tenant");
|
|
480
477
|
if (!report.passed || sharedFailure) {
|
|
481
478
|
if (sharedFailure) console.error(`Shared setup failure: ${sharedFailure}`);
|
|
482
479
|
console.error("Setup is incomplete. Fix the reported issue or rerun `impel setup`.");
|
package/src/commands/status.js
CHANGED
|
@@ -3,7 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { appPaths, CLAUDE_CONFIG_ID, readTenantManifest } from "../apps.js";
|
|
6
|
-
import { loadConfig, maskSecret } from "../config.js";
|
|
6
|
+
import { crossAppModelsEnabled, loadConfig, maskSecret } from "../config.js";
|
|
7
7
|
import { environmentValue, findNativeBinary } from "../nativeProcess.js";
|
|
8
8
|
import { tenantCliClientReadiness } from "../provisioning.js";
|
|
9
9
|
import { windowsTenantShortcutName } from "../shellEntries.js";
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { installedVersion, maybePrintUpdateNotice } from "../updates.js";
|
|
17
17
|
import { windowsClaudeUserData } from "../windowsApps.js";
|
|
18
18
|
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
19
|
+
import { cachedProviderReadiness, providerAvailable } from "../providerReadiness.js";
|
|
19
20
|
|
|
20
21
|
function readShellManifest(paths) {
|
|
21
22
|
try {
|
|
@@ -72,8 +73,9 @@ function desktopReadiness(tenant, {
|
|
|
72
73
|
return states;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
function cliState({ supported, profileReady, binaryReady }) {
|
|
76
|
+
function cliState({ supported, available = true, profileReady, binaryReady }) {
|
|
76
77
|
if (!supported) return "unsupported";
|
|
78
|
+
if (!available) return "unavailable";
|
|
77
79
|
return profileReady && binaryReady ? "ready" : "missing";
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -126,13 +128,18 @@ export async function cmdStatus(overrides = {}) {
|
|
|
126
128
|
for (const tenant of [...selected.tenants].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
127
129
|
const profiles = io.cliReadiness(tenant.id);
|
|
128
130
|
const desktop = io.desktopReadiness(tenant, overrides);
|
|
131
|
+
const providerReadiness = cachedProviderReadiness(config, tenant.id);
|
|
129
132
|
const claudeCli = cliState({
|
|
130
133
|
supported: !scopeKnown || scopes.has(PAT_SCOPE_CLAUDE),
|
|
134
|
+
available: !providerReadiness || providerAvailable("claude", providerReadiness.providers, {
|
|
135
|
+
crossAppModels: crossAppModelsEnabled(config),
|
|
136
|
+
}),
|
|
131
137
|
profileReady: profiles.claude,
|
|
132
138
|
binaryReady: binaries.claude,
|
|
133
139
|
});
|
|
134
140
|
const codexCli = cliState({
|
|
135
141
|
supported: !scopeKnown || scopes.has(PAT_SCOPE_CODEX),
|
|
142
|
+
available: !providerReadiness || providerAvailable("codex", providerReadiness.providers),
|
|
136
143
|
profileReady: profiles.codex,
|
|
137
144
|
binaryReady: binaries.codex,
|
|
138
145
|
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { redactSecretText } from "./config.js";
|
|
2
|
+
|
|
3
|
+
const PROVIDERS = new Set(["claude", "codex"]);
|
|
4
|
+
const POOL_STATES = new Set(["healthy", "exhausted", "rate_limited", "expired", "no_seat"]);
|
|
5
|
+
export const PROVIDER_READINESS_CACHE_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
function providersFromPayload(payload) {
|
|
8
|
+
if (!Array.isArray(payload?.data)) return { error: "gateway model catalog has no model data array" };
|
|
9
|
+
const statuses = {};
|
|
10
|
+
if (Object.hasOwn(payload, "provider_status")) {
|
|
11
|
+
const raw = payload.provider_status;
|
|
12
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
13
|
+
return { error: "gateway model catalog has malformed provider status metadata" };
|
|
14
|
+
}
|
|
15
|
+
for (const provider of PROVIDERS) {
|
|
16
|
+
if (!Object.hasOwn(raw, provider)) continue;
|
|
17
|
+
const status = raw[provider];
|
|
18
|
+
if (!status
|
|
19
|
+
|| typeof status !== "object"
|
|
20
|
+
|| Array.isArray(status)
|
|
21
|
+
|| !POOL_STATES.has(status.state)
|
|
22
|
+
|| typeof status.routable !== "boolean") {
|
|
23
|
+
return { error: "gateway model catalog has malformed provider status metadata" };
|
|
24
|
+
}
|
|
25
|
+
statuses[provider] = status;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const providers = [...new Set(payload.data
|
|
29
|
+
.filter((model) => model?.available !== false && model?.ready !== false && model?.readiness?.ready !== false)
|
|
30
|
+
.map((model) => model?.provider)
|
|
31
|
+
.filter((provider) => PROVIDERS.has(provider))
|
|
32
|
+
.filter((provider) => !statuses[provider]
|
|
33
|
+
|| (statuses[provider].state === "healthy" && statuses[provider].routable === true)))]
|
|
34
|
+
.sort();
|
|
35
|
+
return { providers };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function probeGatewayOnce(gatewayUrl, credential, tenantId, fetchImpl, timeoutMs) {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
|
|
43
|
+
headers: {
|
|
44
|
+
accept: "application/json",
|
|
45
|
+
authorization: `Bearer ${credential}`,
|
|
46
|
+
},
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
});
|
|
49
|
+
const result = {
|
|
50
|
+
reachable: true,
|
|
51
|
+
healthy: response.ok,
|
|
52
|
+
status: response.status,
|
|
53
|
+
rejected: [401, 403].includes(response.status),
|
|
54
|
+
};
|
|
55
|
+
if (!response.ok || typeof response.json !== "function") return result;
|
|
56
|
+
|
|
57
|
+
let payload;
|
|
58
|
+
try {
|
|
59
|
+
payload = await response.json();
|
|
60
|
+
} catch {
|
|
61
|
+
return { ...result, healthy: false, error: "gateway model catalog was not valid JSON" };
|
|
62
|
+
}
|
|
63
|
+
const catalog = providersFromPayload(payload);
|
|
64
|
+
if (catalog.error) {
|
|
65
|
+
return { ...result, healthy: false, error: catalog.error };
|
|
66
|
+
}
|
|
67
|
+
if (payload.org_id && payload.org_id !== tenantId) {
|
|
68
|
+
return { ...result, healthy: false, error: "gateway model catalog tenant did not match the selected tenant" };
|
|
69
|
+
}
|
|
70
|
+
return { ...result, providers: catalog.providers };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return {
|
|
73
|
+
reachable: false,
|
|
74
|
+
error: error?.name === "AbortError"
|
|
75
|
+
? `timed out after ${Math.round(timeoutMs / 1000)}s`
|
|
76
|
+
: redactSecretText(error?.message || error),
|
|
77
|
+
};
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Retry one cold/unreachable gateway probe with a longer deadline, but never
|
|
84
|
+
// retry an authoritative HTTP or model-catalog response.
|
|
85
|
+
export async function probeGatewayProviderReadiness(
|
|
86
|
+
gatewayUrl,
|
|
87
|
+
credential,
|
|
88
|
+
tenantId,
|
|
89
|
+
fetchImpl = fetch,
|
|
90
|
+
timeoutsMs = [5_000, 10_000],
|
|
91
|
+
) {
|
|
92
|
+
let probe = { reachable: false, error: "not probed" };
|
|
93
|
+
for (const timeoutMs of timeoutsMs) {
|
|
94
|
+
probe = await probeGatewayOnce(gatewayUrl, credential, tenantId, fetchImpl, timeoutMs);
|
|
95
|
+
if (probe.reachable) return probe;
|
|
96
|
+
}
|
|
97
|
+
return probe;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function cacheProviderReadiness(config, tenantId, probe, checkedAt = new Date()) {
|
|
101
|
+
if (!probe?.healthy || !Array.isArray(probe.providers)) return false;
|
|
102
|
+
config.providerReadiness ||= {};
|
|
103
|
+
config.providerReadiness[tenantId] = {
|
|
104
|
+
checkedAt: checkedAt.toISOString(),
|
|
105
|
+
providers: [...new Set(probe.providers.filter((provider) => PROVIDERS.has(provider)))].sort(),
|
|
106
|
+
};
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function cachedProviderReadiness(config, tenantId, now = Date.now()) {
|
|
111
|
+
const record = config?.providerReadiness?.[tenantId];
|
|
112
|
+
const checkedAt = Date.parse(record?.checkedAt || "");
|
|
113
|
+
if (!Number.isFinite(checkedAt) || checkedAt > now || now - checkedAt > PROVIDER_READINESS_CACHE_MS) return null;
|
|
114
|
+
if (!Array.isArray(record.providers) || record.providers.some((provider) => !PROVIDERS.has(provider))) return null;
|
|
115
|
+
return { healthy: true, reachable: true, providers: [...new Set(record.providers)].sort(), cached: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function providerAvailable(tool, providers, { crossAppModels = false } = {}) {
|
|
119
|
+
if (!Array.isArray(providers)) return false;
|
|
120
|
+
if (tool === "claude" && crossAppModels) return providers.length > 0;
|
|
121
|
+
return providers.includes(tool);
|
|
122
|
+
}
|