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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.18.12",
3
+ "version": "0.18.13",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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;
@@ -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
- let probe = { reachable: false, error: "not probed" };
90
- for (const timeoutMs of timeoutsMs) {
91
- probe = await probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs);
92
- if (probe.reachable) return probe;
93
- }
94
- return probe;
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
- return markProbeFailures(nextReport, probes);
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
- console.log("Launch: impel claude | impel codex");
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`.");
@@ -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
+ }