impel-cli 0.17.15 → 0.17.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.15",
3
+ "version": "0.17.16",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -261,9 +261,23 @@ async function maybeUpdateAllInstalledTenants(argv, overrides, platform) {
261
261
  return true;
262
262
  }
263
263
 
264
+ // winget exits with HRESULTs; the raw decimal (e.g. 2316632066) hides the
265
+ // recognizable 0x8A15xxxx APPINSTALLER facility, so render hex alongside it.
266
+ const WINGET_EXIT_HINTS = new Map([
267
+ [0x8A150002, "winget rejected the command-line arguments"],
268
+ [0x8A150014, "no package matched the winget query"],
269
+ [0x8A15001B, "the Microsoft Store client is blocked by policy"],
270
+ [0x8A15001C, "the Microsoft Store app is blocked by policy"],
271
+ ]);
272
+
264
273
  function windowsProcessFailure(result) {
265
274
  if (result?.error?.message) return redactSecretText(result.error.message);
266
- if (Number.isInteger(result?.status)) return `exit code ${result.status}`;
275
+ if (Number.isInteger(result?.status)) {
276
+ const status = result.status >>> 0;
277
+ if (status < 0x80000000) return `exit code ${result.status}`;
278
+ const hint = WINGET_EXIT_HINTS.get(status);
279
+ return `exit code ${result.status} (0x${status.toString(16).toUpperCase()}${hint ? ` — ${hint}` : ""})`;
280
+ }
267
281
  if (result?.signal) return `signal ${result.signal}`;
268
282
  return "no exit status";
269
283
  }
@@ -368,45 +382,62 @@ export async function reconcileWindowsTenantApps({
368
382
  const vendorPaths = {};
369
383
  const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
370
384
 
385
+ // A target that cannot proceed (declined confirmation, failed installer,
386
+ // missing vendor app) must not abort the other target's work: install
387
+ // recovery approves one vendor app at a time, and that approved install
388
+ // has to run even while its sibling stays missing.
389
+ const failed = [];
390
+ const fail = (target, error) => failed.push({ target, error });
391
+ const hasFailed = (target) => failed.some((failure) => failure.target === target);
392
+
371
393
  for (const target of actionTargets) {
372
394
  const isClaude = target === "claude";
395
+ const label = isClaude ? "Claude" : "ChatGPT/Codex";
373
396
  const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
374
397
  const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
375
398
  let binary = find(environment);
376
399
  if (!preparedTargets.has(target)) {
377
400
  const confirmed = await confirmVendorInstall(target, { platform: "win32", mode });
378
401
  if (!confirmed) {
379
- throw new Error(`${isClaude ? "Claude" : "ChatGPT/Codex"} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`);
402
+ fail(target, `${label} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`);
403
+ continue;
380
404
  }
381
405
  const vendor = ensure({ update: mode === "update" }, { environment });
382
406
  binary = vendor.binary;
383
407
  if (!binary) {
384
- const label = isClaude ? "Claude" : "ChatGPT/Codex";
385
- throw new Error(`${label} vendor installation failed (${windowsProcessFailure(vendor.result)})`);
408
+ fail(target, `${label} vendor installation failed (${windowsProcessFailure(vendor.result)})`);
409
+ continue;
386
410
  }
387
411
  }
388
412
  if (binary) vendorPaths[target] = binary;
389
413
  }
390
- if (actionTargets.includes("claude") && !vendorPaths.claude) {
391
- throw new Error("Claude vendor app is unavailable");
414
+ if (actionTargets.includes("claude") && !hasFailed("claude") && !vendorPaths.claude) {
415
+ fail("claude", "Claude vendor app is unavailable");
392
416
  }
393
- if (actionTargets.includes("chatgpt")) {
394
- let managed = io.findManagedChatGPTApp(paths.root);
395
- const stale = vendorPaths.chatgpt && !io.chatGPTStageIsCurrent(vendorPaths.chatgpt, paths.root);
396
- if (!managed || stale) {
397
- if (!vendorPaths.chatgpt) throw new Error("ChatGPT/Codex vendor app is unavailable");
398
- managed = io.stageChatGPTApp(vendorPaths.chatgpt, paths.root);
417
+ if (actionTargets.includes("chatgpt") && !hasFailed("chatgpt")) {
418
+ try {
419
+ let managed = io.findManagedChatGPTApp(paths.root);
420
+ const stale = vendorPaths.chatgpt && !io.chatGPTStageIsCurrent(vendorPaths.chatgpt, paths.root);
421
+ if (!managed || stale) {
422
+ if (!vendorPaths.chatgpt) throw new Error("ChatGPT/Codex vendor app is unavailable");
423
+ managed = io.stageChatGPTApp(vendorPaths.chatgpt, paths.root);
424
+ }
425
+ if (!managed) throw new Error("the managed ChatGPT/Codex app could not be staged");
426
+ } catch (error) {
427
+ fail("chatgpt", redactSecretText(error?.message || error));
399
428
  }
400
- if (!managed) throw new Error("the managed ChatGPT/Codex app could not be staged");
401
429
  }
430
+ const completedTargets = actionTargets.filter((target) => !hasFailed(target));
402
431
 
403
- configureWindowsApps(config, actionTargets, vendorPaths, catalog, {
404
- ...io,
405
- homeDir,
406
- environment,
407
- });
432
+ if (completedTargets.length) {
433
+ configureWindowsApps(config, completedTargets, vendorPaths, catalog, {
434
+ ...io,
435
+ homeDir,
436
+ environment,
437
+ });
438
+ }
408
439
  const agentTargets = [];
409
- for (const target of actionTargets) {
440
+ for (const target of completedTargets) {
410
441
  const { client, env, label } = appSkillTarget(target, paths, {
411
442
  platform: "win32",
412
443
  existsSync: io.existsSync,
@@ -431,11 +462,11 @@ export async function reconcileWindowsTenantApps({
431
462
  });
432
463
  }
433
464
  const verification = {
434
- claude: !actionTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
435
- chatgpt: !actionTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
465
+ claude: !completedTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
466
+ chatgpt: !completedTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
436
467
  };
437
468
  if (!Object.values(verification).every(Boolean)) throw new Error("Windows app profile verification failed");
438
- return { tenantId: config.tenantId, targets: actionTargets, unsupported, paths, verification };
469
+ return { tenantId: config.tenantId, targets: completedTargets, unsupported, paths, verification, failed };
439
470
  }
440
471
 
441
472
  /** Reconcile one explicit tenant's macOS app bundles and profiles. */
@@ -480,17 +511,27 @@ export async function reconcileMacTenantApps({
480
511
  const statuses = io.status(actionTargets, homeDir, config.tenantId, config.tenantName);
481
512
  for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
482
513
  const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
483
- {
484
- for (const target of actionTargets.filter((target) => !preparedTargets.has(target))) {
485
- if (vendorPaths[target]) continue;
486
- const confirmed = await confirmVendorInstall(target, { platform: "darwin", mode });
487
- if (!confirmed) throw new Error(`${target} vendor installation requires confirmation`);
488
- const result = await io.ensureVendor(target, { homeDir });
489
- vendorPaths[target] = result.path;
490
- if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
514
+ // As on Windows, a declined or failed vendor install skips only that
515
+ // target so the sibling app still converges (install recovery approves one
516
+ // vendor app at a time).
517
+ const failed = [];
518
+ const fail = (target, error) => failed.push({ target, error });
519
+ const hasFailed = (target) => failed.some((failure) => failure.target === target);
520
+ for (const target of actionTargets.filter((target) => !preparedTargets.has(target))) {
521
+ if (vendorPaths[target]) continue;
522
+ const confirmed = await confirmVendorInstall(target, { platform: "darwin", mode });
523
+ if (!confirmed) {
524
+ fail(target, `${target} vendor installation requires confirmation`);
525
+ continue;
491
526
  }
527
+ const result = await io.ensureVendor(target, { homeDir });
528
+ vendorPaths[target] = result.path;
529
+ if (!result.path) fail(target, `${target} verified vendor app is unavailable; retry the pinned download`);
492
530
  }
493
- const staleTargets = statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
531
+ const completedTargets = actionTargets.filter((target) => !hasFailed(target));
532
+ const staleTargets = statuses
533
+ .filter((status) => completedTargets.includes(status.target) && !io.bundleCurrent(status))
534
+ .map((status) => status.target);
494
535
  let closedApps = [];
495
536
  if (staleTargets.length) {
496
537
  const result = await io.quitApps(staleTargets, { tenantId: config.tenantId, tenantName: config.tenantName });
@@ -499,14 +540,14 @@ export async function reconcileMacTenantApps({
499
540
  const reopenTargets = new Set(staleTargets.filter((target) => closedApps.includes(
500
541
  managedLauncherName(target, { tenantId: config.tenantId, tenantName: config.tenantName }),
501
542
  )));
502
- const installed = await io.installFiles({
543
+ const installed = completedTargets.length ? await io.installFiles({
503
544
  config,
504
- targets: actionTargets,
545
+ targets: completedTargets,
505
546
  models: catalog.models,
506
547
  homeDir,
507
548
  vendorPaths,
508
549
  writeBundles: staleTargets,
509
- });
550
+ }) : [];
510
551
  const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
511
552
  const agentItems = [];
512
553
  for (const item of installed) {
@@ -528,7 +569,7 @@ export async function reconcileMacTenantApps({
528
569
  for (const item of installed) {
529
570
  if (reopenTargets.has(item.target)) io.openLauncher(item.launcher);
530
571
  }
531
- const verification = Object.fromEntries(actionTargets.map((target) => [
572
+ const verification = Object.fromEntries(completedTargets.map((target) => [
532
573
  target,
533
574
  io.existsSync(paths[target].launcher) && io.existsSync(
534
575
  target === "claude"
@@ -537,7 +578,7 @@ export async function reconcileMacTenantApps({
537
578
  ),
538
579
  ]));
539
580
  if (!Object.values(verification).every(Boolean)) throw new Error("macOS app bundle/profile verification failed");
540
- return { tenantId: config.tenantId, targets: actionTargets, unsupported, paths, verification, installed };
581
+ return { tenantId: config.tenantId, targets: completedTargets, unsupported, paths, verification, installed, failed };
541
582
  }
542
583
 
543
584
  export function reconcileTenantApps(options = {}, overrides = {}) {
@@ -2,7 +2,12 @@
2
2
 
3
3
  import { loadConfig, saveConfig, redactSecretText } from "../config.js";
4
4
  import { runInstallRecovery } from "../installRecovery/engine.js";
5
- import { printReconciliationSummary, reconcileAllTenants, selectDefaultTenant } from "../provisioning.js";
5
+ import {
6
+ printReconciliationSummary,
7
+ reconcileAllTenants,
8
+ selectDefaultTenant,
9
+ vendorAppTargetReady,
10
+ } from "../provisioning.js";
6
11
  import { fetchTenants } from "../tenants.js";
7
12
  import { promptText } from "../prompt.js";
8
13
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
@@ -258,11 +263,17 @@ export async function cmdConverge(argv = [], overrides = {}) {
258
263
  return ["ready", "unavailable"].includes(profileReport.tenants[0]?.cli);
259
264
  },
260
265
  retryStep: () => retrySafeState(),
261
- installVendorApp: (target) => retryTenant(async (product) => (
262
- target === "all"
263
- || product === target
264
- || (target === "codex" && product === "chatgpt")
265
- )),
266
+ // Succeed when the requested vendor app converged, even if the
267
+ // sibling app still fails: the goal check keeps demanding full
268
+ // tenant readiness, so recovery can approve one app at a time.
269
+ installVendorApp: async (target) => {
270
+ await retryTenant(async (product) => (
271
+ target === "all"
272
+ || product === target
273
+ || (target === "codex" && product === "chatgpt")
274
+ ));
275
+ return vendorAppTargetReady(retryReport, target);
276
+ },
266
277
  },
267
278
  }, overrides.recoveryOverrides || {});
268
279
  if (retryReport) mergeTenantReport(report, retryReport);
@@ -31,7 +31,12 @@ const CLAUDE_DIRECT_AUTH_ENV = [
31
31
  ];
32
32
 
33
33
  const CODEX_DIRECT_AUTH_ENV = ["CODEX_ACCESS_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL"];
34
- export { escapeWindowsBatchArgument, nativeSpawnInvocation, resolveNativeBinary } from "../nativeProcess.js";
34
+ export {
35
+ escapeWindowsBareArgument,
36
+ escapeWindowsBatchArgument,
37
+ nativeSpawnInvocation,
38
+ resolveNativeBinary,
39
+ } from "../nativeProcess.js";
35
40
 
36
41
  export const IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS = `You are running in an Impel tenant-scoped session. Before starting any non-trivial task, call the Impel MCP tool list_specialists. If exactly one available specialist clearly matches the user's request, its capabilities and its exclusions, delegate the complete request by calling start_specialist_run exactly once with a stable idempotency key, then call read_specialist_run until it reaches a terminal state. When the run succeeds, use the specialist's result as your response instead of redoing the work. If no specialist clearly matches, the tools are unavailable, or the run fails, continue normally yourself. Do not delegate trivial requests, do not call a specialist excluded from the request, and never invent a specialist result.`;
37
42
 
@@ -16,6 +16,7 @@ import {
16
16
  printReconciliationSummary,
17
17
  reconcileAllTenants,
18
18
  selectDefaultTenant,
19
+ vendorAppTargetReady,
19
20
  } from "../provisioning.js";
20
21
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
21
22
  import { runInstallRecovery } from "../installRecovery/engine.js";
@@ -49,9 +50,9 @@ export function resolveTenantChoice(listing, { requested = null, answer = null,
49
50
  return selectDefaultTenant(listing, { currentTenantId });
50
51
  }
51
52
 
52
- async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
53
+ async function probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs) {
53
54
  const controller = new AbortController();
54
- const timeout = setTimeout(() => controller.abort(), 5_000);
55
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
55
56
  try {
56
57
  const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
57
58
  headers: {
@@ -69,13 +70,28 @@ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
69
70
  } catch (error) {
70
71
  return {
71
72
  reachable: false,
72
- error: error?.name === "AbortError" ? "timed out after 5s" : redactSecretText(error?.message || error),
73
+ error: error?.name === "AbortError"
74
+ ? `timed out after ${Math.round(timeoutMs / 1000)}s`
75
+ : redactSecretText(error?.message || error),
73
76
  };
74
77
  } finally {
75
78
  clearTimeout(timeout);
76
79
  }
77
80
  }
78
81
 
82
+ // A single short probe misreports a cold gateway (serverless cold start plus
83
+ // long-haul latency) as unreachable, and that transient verdict gets baked
84
+ // into the convergence failure message. Retry once with a longer deadline
85
+ // before concluding the gateway is down.
86
+ export async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch, timeoutsMs = [5_000, 10_000]) {
87
+ let probe = { reachable: false, error: "not probed" };
88
+ for (const timeoutMs of timeoutsMs) {
89
+ probe = await probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs);
90
+ if (probe.reachable) return probe;
91
+ }
92
+ return probe;
93
+ }
94
+
79
95
  function markProbeFailures(report, probes) {
80
96
  probes.forEach((probe, index) => {
81
97
  if (probe.reachable && !probe.rejected && probe.healthy !== false) return;
@@ -406,11 +422,17 @@ export async function cmdSetup(argv, overrides = {}) {
406
422
  return ["ready", "unavailable"].includes(profileReport.tenants[0]?.cli);
407
423
  },
408
424
  retryStep: () => retrySafeState(),
409
- installVendorApp: (target) => retryTenant(async (product) => (
410
- target === "all"
411
- || product === target
412
- || (target === "codex" && product === "chatgpt")
413
- )),
425
+ // Succeed when the requested vendor app converged, even if the
426
+ // sibling app still fails: the goal check keeps demanding full
427
+ // tenant readiness, so recovery can approve one app at a time.
428
+ installVendorApp: async (target) => {
429
+ await retryTenant(async (product) => (
430
+ target === "all"
431
+ || product === target
432
+ || (target === "codex" && product === "chatgpt")
433
+ ));
434
+ return vendorAppTargetReady(retryReport, target);
435
+ },
414
436
  },
415
437
  }, overrides.recoveryOverrides || {});
416
438
  if (retryReport) mergeTenantReport(report, retryReport);
@@ -27,8 +27,13 @@ function isExecutable(filePath, platform = process.platform) {
27
27
  if (!fs.statSync(filePath).isFile()) return false;
28
28
  if (platform !== "win32") fs.accessSync(filePath, fs.constants.X_OK);
29
29
  return true;
30
- } catch {
31
- return false;
30
+ } catch (error) {
31
+ // Windows Store App Execution Aliases (winget.exe, python.exe, …) are
32
+ // zero-byte APPEXECLINK reparse points that CreateProcess launches fine,
33
+ // but fs.stat cannot read them on Node runtimes without libuv's
34
+ // AppExecLink fstat support. Those failures surface as EINVAL/UNKNOWN,
35
+ // never ENOENT, so an existing alias must still count as executable.
36
+ return platform === "win32" && ["EINVAL", "UNKNOWN"].includes(error?.code);
32
37
  }
33
38
  }
34
39
 
@@ -119,6 +124,12 @@ function commonCandidates(tool, environment, platform) {
119
124
  locations.push([paths.join(systemRoot, "System32", "WindowsPowerShell", "v1.0"), "powershell"]);
120
125
  }
121
126
 
127
+ // winget ships solely as a per-user App Execution Alias; cover its fixed
128
+ // home for environments whose PATH omits the WindowsApps directory.
129
+ if (tool === "winget" && localAppData) {
130
+ locations.push([paths.join(localAppData, "Microsoft", "WindowsApps"), "winget"]);
131
+ }
132
+
122
133
  // Git for Windows standard install roots; `cmd` holds the PATH-safe
123
134
  // git.exe. Codex shells out to git for marketplace clones, so skill
124
135
  // syncing must find git even when the parent shell's PATH predates the
@@ -183,13 +194,13 @@ export function resolveNativeBinary(
183
194
 
184
195
  // Windows cannot execute npm's .cmd/.bat shims directly. This is the escaping
185
196
  // strategy used by cross-spawn, inlined here to keep the CLI dependency-free.
186
- // Arguments are quoted individually and command metacharacters are escaped
187
- // twice because npm shims forward them through `%*`, causing a second parse.
197
+ // Arguments are quoted individually and command metacharacters are
198
+ // caret-escaped once per cmd parse the argument will go through.
188
199
  function escapeWindowsBatchCommand(value) {
189
200
  return String(value).replace(WINDOWS_SHELL_META_RE, "^$1");
190
201
  }
191
202
 
192
- export function escapeWindowsBatchArgument(value) {
203
+ function encodeWindowsCommandArgument(value, metaEscapePasses) {
193
204
  let escaped = String(value);
194
205
  if (WINDOWS_UNSAFE_LINE_RE.test(escaped)) {
195
206
  throw new Error("cannot safely forward an argument containing a NUL or line break through a Windows batch shim");
@@ -197,17 +208,36 @@ export function escapeWindowsBatchArgument(value) {
197
208
  escaped = escaped.replace(/(?=(\\+?)?)\1"/gu, "$1$1\\\"");
198
209
  escaped = escaped.replace(/(?=(\\+?)?)\1$/gu, "$1$1");
199
210
  escaped = `"${escaped}"`;
200
- escaped = escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
201
- return escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
211
+ for (let pass = 0; pass < metaEscapePasses; pass += 1) {
212
+ escaped = escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
213
+ }
214
+ return escaped;
215
+ }
216
+
217
+ // Batch shims re-read the command line through `%*`, so cmd parses these
218
+ // arguments twice and the metacharacter escapes must survive both passes.
219
+ export function escapeWindowsBatchArgument(value) {
220
+ return encodeWindowsCommandArgument(value, 2);
221
+ }
222
+
223
+ // A bare command name is resolved by cmd itself (covering App Execution
224
+ // Aliases such as winget.exe that PATH stat probing cannot verify), but the
225
+ // target program's command line is produced by cmd's single parse. Double
226
+ // escaping here would deliver literal carets — winget then fails with
227
+ // 0x8A150002 APPINSTALLER_CLI_ERROR_INVALID_CL_ARGUMENTS.
228
+ export function escapeWindowsBareArgument(value) {
229
+ return encodeWindowsCommandArgument(value, 1);
202
230
  }
203
231
 
204
232
  export function nativeSpawnInvocation(binary, argv, environment = process.env, platform = process.platform) {
233
+ const batchShim = platform === "win32" && WINDOWS_BATCH_EXTENSION_RE.test(binary);
205
234
  const bareWindowsCommand = (
206
235
  platform === "win32"
236
+ && !batchShim
207
237
  && WINDOWS_BARE_COMMAND_RE.test(binary)
208
238
  && path.win32.basename(binary) === binary
209
239
  );
210
- if (platform !== "win32" || (!WINDOWS_BATCH_EXTENSION_RE.test(binary) && !bareWindowsCommand)) {
240
+ if (platform !== "win32" || (!batchShim && !bareWindowsCommand)) {
211
241
  return { command: binary, args: argv, windowsVerbatimArguments: false };
212
242
  }
213
243
  if (WINDOWS_UNSAFE_LINE_RE.test(binary)) {
@@ -215,7 +245,7 @@ export function nativeSpawnInvocation(binary, argv, environment = process.env, p
215
245
  }
216
246
  const shellCommand = [
217
247
  escapeWindowsBatchCommand(pathApi(platform).normalize(binary)),
218
- ...argv.map(escapeWindowsBatchArgument),
248
+ ...argv.map(batchShim ? escapeWindowsBatchArgument : escapeWindowsBareArgument),
219
249
  ].join(" ");
220
250
  return {
221
251
  command: environmentValue(environment, "ComSpec") || "cmd.exe",
@@ -247,6 +247,7 @@ export async function reconcileAllTenants({
247
247
  for (const tenant of liveTenants) {
248
248
  const result = results.get(tenant.id);
249
249
  let supported = new Set();
250
+ let failedTargets = new Set();
250
251
  try {
251
252
  const installed = await io.reconcileApps({
252
253
  baseConfig: config,
@@ -260,10 +261,17 @@ export async function reconcileAllTenants({
260
261
  confirmVendorInstall,
261
262
  }, overrides.appOverrides || {});
262
263
  supported = new Set(installed.targets || []);
264
+ const failedApps = installed.failed || [];
265
+ failedTargets = new Set(failedApps.map((failure) => failure.target));
263
266
  for (const target of supported) vendorPreparedTargets.add(target);
264
- result.clients.claude.app = supported.has("claude") ? "ready" : "unsupported";
265
- result.clients.codex.app = supported.has("chatgpt") ? "ready" : "unsupported";
266
- result.apps = supported.size ? "ready" : "unavailable";
267
+ result.clients.claude.app = supported.has("claude")
268
+ ? "ready"
269
+ : failedTargets.has("claude") ? "failed" : "unsupported";
270
+ result.clients.codex.app = supported.has("chatgpt")
271
+ ? "ready"
272
+ : failedTargets.has("chatgpt") ? "failed" : "unsupported";
273
+ result.apps = failedTargets.size ? "failed" : supported.size ? "ready" : "unavailable";
274
+ for (const failure of failedApps) result.errors.push(redactSecretText(failure.error));
267
275
  } catch (error) {
268
276
  result.apps = "failed";
269
277
  for (const client of Object.values(result.clients)) {
@@ -276,9 +284,9 @@ export async function reconcileAllTenants({
276
284
  }
277
285
 
278
286
  if (supported.size === 0) {
279
- result.shell = "unavailable";
280
- result.clients.claude.shell = "unsupported";
281
- result.clients.codex.shell = "unsupported";
287
+ result.shell = failedTargets.size ? "failed" : "unavailable";
288
+ result.clients.claude.shell = failedTargets.has("claude") ? "failed" : "unsupported";
289
+ result.clients.codex.shell = failedTargets.has("chatgpt") ? "failed" : "unsupported";
282
290
  continue;
283
291
  }
284
292
  try {
@@ -293,12 +301,15 @@ export async function reconcileAllTenants({
293
301
  const entryProducts = new Set(entries.map((entry) => entry.product));
294
302
  result.clients.claude.shell = supported.has("claude")
295
303
  ? (entryProducts.has("claude") ? "ready" : "failed")
296
- : "unsupported";
304
+ : failedTargets.has("claude") ? "failed" : "unsupported";
297
305
  result.clients.codex.shell = supported.has("chatgpt")
298
306
  ? (entryProducts.has("chatgpt") ? "ready" : "failed")
299
- : "unsupported";
300
- result.shell = [...supported].every((target) => entryProducts.has(target)) ? "ready" : "failed";
301
- if (result.shell === "failed") throw new Error("operating-system app registration did not complete");
307
+ : failedTargets.has("chatgpt") ? "failed" : "unsupported";
308
+ const registered = [...supported].every((target) => entryProducts.has(target));
309
+ // A failed vendor target has no launchable entry, so the tenant's
310
+ // launch surface stays failed even when every present app registered.
311
+ result.shell = registered && !failedTargets.size ? "ready" : "failed";
312
+ if (!registered) throw new Error("operating-system app registration did not complete");
302
313
  } catch (error) {
303
314
  result.shell = "failed";
304
315
  for (const client of Object.values(result.clients)) {
@@ -327,6 +338,19 @@ export async function reconcileAllTenants({
327
338
  };
328
339
  }
329
340
 
341
+ /**
342
+ * Whether a single-tenant reconcile report shows the requested vendor app
343
+ * (claude, codex, or all) as converged. "unsupported" counts as converged:
344
+ * the tenant catalog offers nothing to install for that product.
345
+ */
346
+ export function vendorAppTargetReady(report, target) {
347
+ const clients = report?.tenants?.[0]?.clients;
348
+ if (!clients) return false;
349
+ const converged = (client) => ["ready", "unsupported"].includes(client?.app);
350
+ if (target === "all") return converged(clients.claude) && converged(clients.codex);
351
+ return converged(target === "claude" ? clients.claude : clients.codex);
352
+ }
353
+
330
354
  export function printReconciliationSummary(report, log = console.log) {
331
355
  log("Tenant readiness:");
332
356
  for (const tenant of report.tenants) {
package/src/skills.js CHANGED
@@ -44,6 +44,14 @@ export const SKILLS_FALLBACK_GATEWAY_URL = "https://gateway.useimpel.ai";
44
44
  const BENIGN_OUTPUT = /already (exist|install|add|present|configur)|up[ -]?to[ -]?date|no changes|nothing to (do|update)/i;
45
45
  const TRANSIENT_SKILL_OUTPUT = /timed? out|timeout of \d+ms exceeded|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network error|failed to download/i;
46
46
 
47
+ // Codex `plugin marketplace upgrade` refreshes only Git marketplaces. A
48
+ // registration recorded with a non-git source_type (an older Codex, or a
49
+ // git-less fallback at registration time) fails every upgrade with this error
50
+ // until the marketplace is re-registered — otherwise the profile keeps the
51
+ // snapshot cloned at registration forever and every sync warns.
52
+ const NON_GIT_MARKETPLACE_RE = /not configured as a git marketplace/i;
53
+ const MARKETPLACE_NOT_FOUND_RE = /not found|no marketplace/i;
54
+
47
55
  const CLIENT_SPECS = {
48
56
  claude: {
49
57
  bin: "claude",
@@ -345,6 +353,27 @@ async function runSkillCommandWithRetry(run, bin, args, env) {
345
353
  return run(bin, args, env);
346
354
  }
347
355
 
356
+ /**
357
+ * Heal a Codex marketplace whose stored registration cannot be upgraded by
358
+ * re-registering it: remove the stale record (which also drops its installed
359
+ * plugins) and add the current source. The install command that follows the
360
+ * refresh phase reinstalls the plugin from the fresh snapshot. Returns true
361
+ * only when the re-registration fully succeeded.
362
+ */
363
+ async function reregisterCodexMarketplace({ run, bin, env, marketplaceName, sourceUrl }) {
364
+ const removed = await run(bin, ["plugin", "marketplace", "remove", marketplaceName], env);
365
+ if (!removed.ok && !MARKETPLACE_NOT_FOUND_RE.test(`${removed.stdout}\n${removed.stderr}`)) {
366
+ return false;
367
+ }
368
+ const added = await runSkillCommandWithRetry(
369
+ run,
370
+ bin,
371
+ ["plugin", "marketplace", "add", sourceUrl],
372
+ env,
373
+ );
374
+ return added.ok;
375
+ }
376
+
348
377
  const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000;
349
378
 
350
379
  // The gateway serves marketplace.json from a serverless function with an 8-15s
@@ -494,9 +523,23 @@ export async function syncSkills({
494
523
  failures.push({ phase: command.phase, reason: `\`${spec.bin}\` disappeared mid-sync` });
495
524
  break;
496
525
  }
497
- if (!isBenign(result)) {
498
- failures.push({ phase: command.phase, reason: firstLine(result.stderr) || `exit ${result.status}` });
526
+ if (isBenign(result)) continue;
527
+ if (
528
+ client === "codex"
529
+ && command.phase === "refresh-marketplace"
530
+ && marketplaceName
531
+ && NON_GIT_MARKETPLACE_RE.test(`${result.stdout}\n${result.stderr}`)
532
+ ) {
533
+ const reregistered = await reregisterCodexMarketplace({
534
+ run,
535
+ bin: spec.bin,
536
+ env,
537
+ marketplaceName,
538
+ sourceUrl,
539
+ });
540
+ if (reregistered) continue;
499
541
  }
542
+ failures.push({ phase: command.phase, reason: firstLine(result.stderr) || `exit ${result.status}` });
500
543
  }
501
544
 
502
545
  if (failures.length > 0) {