impel-cli 0.15.2 → 0.16.0

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
@@ -115,15 +115,19 @@ checksum-verifying native installers, so Claude Code does not inherit its newer
115
115
  Node.js requirement from the deprecated npm package. On Linux, it prepares the
116
116
  isolated command-line workflow.
117
117
 
118
- If setup or the CLI self-update fails, Impel first runs a deterministic local
119
- check. In an interactive terminal it then offers assisted recovery; use
120
- `impel setup --repair` (or `impel update --repair`) to opt in explicitly. Before
121
- upload, the CLI prints the exact sanitized payload. The hosted agent returns a
122
- reviewed action ID, and the CLI runs its allowlisted command locally; free-form
123
- model commands are rejected and every mutation requires confirmation. Sessions
124
- expire after 15 minutes, mutation capabilities are
125
- single-use, and a private checkpoint lets `--repair` resume an interrupted run.
126
- Use `--no-recovery` or `IMPEL_DISABLE_INSTALL_RECOVERY=1` to keep recovery off.
118
+ If setup or the CLI self-update fails, Impel first runs deterministic local
119
+ repairs for known failure fingerprints offline, with no upload. If the
120
+ failure persists, an interactive terminal offers assisted recovery (use
121
+ `impel setup --repair` or `impel update --repair` to opt in explicitly): a
122
+ bounded local loop where a gateway-hosted model selects typed diagnostic and
123
+ repair tools and the CLI validates, approves, and executes them on this
124
+ machine. Before the first upload, the CLI prints the exact sanitized payload.
125
+ Free-form model commands are impossible by construction; repairs limited to
126
+ Impel-owned files need one approval per session, while vendor installers and
127
+ PATH edits always confirm individually. Recovery only reports success after
128
+ the failed steps re-pass their local health checks, and it stops after 12
129
+ model turns or 10 minutes. Use `--no-recovery` or
130
+ `IMPEL_DISABLE_INSTALL_RECOVERY=1` to keep recovery off.
127
131
 
128
132
  ### If something does not work
129
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agents.js CHANGED
@@ -29,6 +29,7 @@ const NATIVE_AGENT_TOOL_NAMES = [
29
29
  const SAFE_AGENT_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
30
30
  const SAFE_SCOPE_PARAM_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
31
31
  const MAX_CATALOG_ITEMS = 500;
32
+ const RETRYABLE_AGENT_CATALOG_ERROR = /request timed out|fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network error/iu;
32
33
  const RESERVED_AGENT_NAMES = Object.freeze({
33
34
  claude: new Set(["explore", "general-purpose", "plan"]),
34
35
  codex: new Set(["default", "explorer", "worker"]),
@@ -540,7 +541,14 @@ export async function syncAgentProfiles({
540
541
  .map((profile) => ({ ...profile, skipped: true, reason: "fresh" }));
541
542
  if (pending.length === 0) return skipped;
542
543
 
543
- const catalog = await fetchCatalog({ gatewayUrl, credential, tenantId: normalizedTenant });
544
+ let catalog;
545
+ try {
546
+ catalog = await fetchCatalog({ gatewayUrl, credential, tenantId: normalizedTenant });
547
+ } catch (error) {
548
+ if (!RETRYABLE_AGENT_CATALOG_ERROR.test(String(error?.message || error))) throw error;
549
+ logger.log("Agents: catalog request failed transiently; retrying once…");
550
+ catalog = await fetchCatalog({ gatewayUrl, credential, tenantId: normalizedTenant });
551
+ }
544
552
  const results = [];
545
553
  for (const profile of pending) {
546
554
  const result = syncAgentProfile({
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { parseFlags } from "../args.js";
7
7
  import {
8
+ crossAppModelsEnabled,
8
9
  loadConfig,
9
10
  saveConfig,
10
11
  normalizeGatewayUrl,
@@ -14,10 +15,11 @@ import {
14
15
  redactSecretText,
15
16
  } from "../config.js";
16
17
  import { promptSecret, promptText } from "../prompt.js";
18
+ import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "../cliProfiles.js";
17
19
  import { fetchTenants, normalizeTenantId, tenantCredential } from "../tenants.js";
18
20
  import { cmdApps } from "./apps.js";
19
21
  import { prepareWindowsClis, windowsCliInstallCommands } from "../windowsSetup.js";
20
- import { runInstallRecovery } from "../installRecovery/loop.js";
22
+ import { runInstallRecovery } from "../installRecovery/engine.js";
21
23
 
22
24
  const HELP = `impel setup - guided end-to-end gateway setup
23
25
 
@@ -410,14 +412,63 @@ export async function cmdSetup(argv, overrides = {}) {
410
412
  ])
411
413
  ),
412
414
  };
415
+
416
+ // Recovery ends as "fixed" only when every goal derived from the failed
417
+ // steps passes a fresh local check. App installs have no cheap external
418
+ // probe, so that goal tracks the most recent reviewed install result.
419
+ const appInstallState = { ok: false };
420
+ const installVendorApp = async (target) => {
421
+ const installed = await io.installApps(["install", target]);
422
+ appInstallState.ok = installed !== false;
423
+ return installed;
424
+ };
425
+ const goals = [];
426
+ if (io.platform === "win32" && setupFailures.some((failure) => /vendor_cli|windows_clis/iu.test(failure.step))) {
427
+ goals.push({
428
+ id: "windows-clis",
429
+ description: "Claude Code and Codex are installed and pass their version checks",
430
+ run: async () => {
431
+ const checked = await io.prepareWindowsClis({
432
+ gatewayUrl,
433
+ tenantId: tenant.id,
434
+ skipInstall: true,
435
+ });
436
+ return checked.missingAfter.length === 0
437
+ ? true
438
+ : { ok: false, detail: `still missing: ${checked.missingAfter.join(", ")}` };
439
+ },
440
+ });
441
+ }
442
+ if (setupFailures.some((failure) => /vendor_app/iu.test(failure.step))) {
443
+ goals.push({
444
+ id: "desktop-apps",
445
+ description: "The Impel desktop app profiles installed cleanly",
446
+ run: () => appInstallState.ok === true,
447
+ });
448
+ }
449
+ if (setupFailures.some((failure) => failure.step === "verify.gateway")) {
450
+ goals.push({
451
+ id: "gateway",
452
+ description: "The gateway accepts the stored credential",
453
+ run: async () => {
454
+ const checked = await io.probe(gatewayUrl, pat, tenant.id);
455
+ return checked.reachable && !checked.rejected
456
+ ? true
457
+ : { ok: false, detail: checked.error || `HTTP ${checked.status}` };
458
+ },
459
+ });
460
+ }
461
+
413
462
  try {
414
463
  const recovery = await io.recoverInstall(
415
464
  {
416
465
  failure: aggregateFailure,
417
466
  config,
467
+ goals,
418
468
  explicit: Boolean(flags.repair),
419
469
  noRecovery: Boolean(flags["no-recovery"]),
420
470
  actionContext: {
471
+ tenantId: tenant.id,
421
472
  probeGateway: () => io.probe(gatewayUrl, pat, tenant.id),
422
473
  installVendorClis: (tool) =>
423
474
  io.prepareWindowsClis({
@@ -427,15 +478,21 @@ export async function cmdSetup(argv, overrides = {}) {
427
478
  installTools: [tool],
428
479
  }),
429
480
  repairProfiles: async () => {
430
- if (io.platform !== "win32") return false;
431
- const result = await io.prepareWindowsClis({
432
- gatewayUrl,
433
- tenantId: tenant.id,
434
- skipInstall: true,
481
+ if (io.platform === "win32") {
482
+ const result = await io.prepareWindowsClis({
483
+ gatewayUrl,
484
+ tenantId: tenant.id,
485
+ skipInstall: true,
486
+ });
487
+ return result.missingAfter.length === 0;
488
+ }
489
+ ensureImpelClaudeProfile(gatewayUrl, tenant.id, {
490
+ crossAppModels: crossAppModelsEnabled(config),
435
491
  });
436
- return result.missingAfter.length === 0;
492
+ ensureImpelCodexProfile(gatewayUrl, tenant.id);
493
+ return true;
437
494
  },
438
- installVendorApp: (target) => io.installApps(["install", target]),
495
+ installVendorApp,
439
496
  retryStep: async (step) => {
440
497
  if (/vendor_cli|windows_clis/iu.test(step || "")) {
441
498
  const result = await io.prepareWindowsClis({
@@ -446,7 +503,7 @@ export async function cmdSetup(argv, overrides = {}) {
446
503
  return result.missingAfter.length === 0;
447
504
  }
448
505
  if (/vendor_app/iu.test(step || "")) {
449
- return (await io.installApps(["install", "all"])) !== false;
506
+ return (await installVendorApp("all")) !== false;
450
507
  }
451
508
  if (/gateway/iu.test(step || "")) {
452
509
  const result = await io.probe(gatewayUrl, pat, tenant.id);
@@ -462,23 +519,11 @@ export async function cmdSetup(argv, overrides = {}) {
462
519
  ...(overrides.recoveryOverrides || {}),
463
520
  }
464
521
  );
522
+ // "fixed" is only reported after every goal passed a fresh local check
523
+ // inside the engine, so no separate re-verification is needed here.
465
524
  if (recovery.fixed) {
466
- if (io.platform === "win32" && setupFailures.some((failure) => /vendor_cli|windows_clis/iu.test(failure.step))) {
467
- const checked = await io.prepareWindowsClis({
468
- gatewayUrl,
469
- tenantId: tenant.id,
470
- skipInstall: true,
471
- });
472
- platformSetupFailed = checked.missingAfter.length > 0;
473
- } else {
474
- platformSetupFailed = false;
475
- }
476
- if (setupFailures.some((failure) => failure.step === "verify.gateway")) {
477
- const checked = await io.probe(gatewayUrl, pat, tenant.id);
478
- verificationFailed = !checked.reachable || checked.rejected;
479
- } else {
480
- verificationFailed = false;
481
- }
525
+ platformSetupFailed = false;
526
+ verificationFailed = false;
482
527
  }
483
528
  } catch (error) {
484
529
  console.warn(`Install recovery could not start (${redactSecretText(error?.message || error)}).`);
@@ -16,7 +16,7 @@ import { nativeCommandInvocation } from "../nativeProcess.js";
16
16
  import { windowsClaudeUserData } from "../windowsApps.js";
17
17
  import { withProgress } from "../progress.js";
18
18
  import { installedAppTenantIds } from "./apps.js";
19
- import { runInstallRecovery } from "../installRecovery/loop.js";
19
+ import { runInstallRecovery } from "../installRecovery/engine.js";
20
20
  import {
21
21
  fetchRemoteVersion,
22
22
  installedVersion,
@@ -220,7 +220,10 @@ export async function cmdUpdate(argv, overrides = {}) {
220
220
  }
221
221
  const config = io.loadConfig();
222
222
  let recovered = false;
223
- if (config?.pat && config?.tenantId && config?.appUrl) {
223
+ if (config?.pat && config?.tenantId) {
224
+ // The goal tracks the most recent reviewed retry of the npm install:
225
+ // recovery may only end "fixed" once that retry actually succeeded.
226
+ const updateState = { ok: false };
224
227
  try {
225
228
  const recovery = await io.recoverInstall(
226
229
  {
@@ -232,11 +235,31 @@ export async function cmdUpdate(argv, overrides = {}) {
232
235
  message: "The npm-verified global impel-cli update failed.",
233
236
  },
234
237
  config,
238
+ goals: [
239
+ {
240
+ id: "cli-update",
241
+ description: "The global impel-cli npm update completed successfully",
242
+ run: () => updateState.ok === true,
243
+ },
244
+ ],
235
245
  explicit: Boolean(flags.repair),
236
246
  noRecovery: Boolean(flags["no-recovery"]),
237
247
  actionContext: {
238
- retryStep: async () =>
239
- io.selfUpdate(updateInstallSpec()) === true,
248
+ tenantId: config.tenantId,
249
+ probeGateway: async () => {
250
+ try {
251
+ const response = await fetch(`${config.gatewayUrl}/health`, {
252
+ signal: AbortSignal.timeout(5000),
253
+ });
254
+ return { reachable: true, status: response.status, rejected: false };
255
+ } catch (error) {
256
+ return { reachable: false, error: redactSecretText(error?.message || error) };
257
+ }
258
+ },
259
+ retryStep: async () => {
260
+ updateState.ok = io.selfUpdate(updateInstallSpec()) === true;
261
+ return updateState.ok;
262
+ },
240
263
  },
241
264
  },
242
265
  overrides.recoveryOverrides || {}
@@ -11,10 +11,9 @@ export const INSTALL_RECOVERY_CHECKPOINT_PATH = path.join(
11
11
  function validCheckpoint(value) {
12
12
  return Boolean(
13
13
  value &&
14
- value.protocolVersion === 1 &&
15
- typeof value.sessionId === "string" &&
16
- typeof value.recoveryToken === "string" &&
14
+ value.protocolVersion === 2 &&
17
15
  typeof value.fingerprint === "string" &&
16
+ typeof value.status === "string" &&
18
17
  typeof value.expiresAt === "number"
19
18
  );
20
19
  }