impel-cli 0.11.1 → 0.12.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
@@ -152,7 +152,9 @@ impel agents sync [claude|codex|all] Sync explicit tenant agents into na
152
152
 
153
153
  impel status Launcher readiness + native mode + gateway reachability
154
154
  impel app install [target] [--tenant <org>] Install isolated apps for one tenant
155
- impel app update [target] [--tenant <org>] Refresh that tenant's apps, configs, and models
155
+ impel app update [target] [--tenant <org>] [--force]
156
+ Refresh configs/models and rebuild only stale bundles;
157
+ --force performs a clean full rebuild
156
158
  impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
157
159
  (--stale-only: no-op unless 6h+ since last sync)
158
160
  impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
@@ -237,9 +239,10 @@ before running another protected command.
237
239
  One command brings everything current, in dependency order: the CLI itself
238
240
  (`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
239
241
  then — re-executing the freshly installed build — `impel app update all`
240
- (close running apps, install the CLI-pinned vendor builds, rebuild the vendored Impel apps)
242
+ (install the CLI-pinned vendor builds and rebuild only stale vendored Impel apps)
241
243
  followed by `impel skills sync all` and `impel agents sync all` across every
242
- managed profile.
244
+ managed profile. The app cascade skips its own skills/agents pass so those
245
+ profiles are synced once, in the dedicated bounded-concurrency steps.
243
246
 
244
247
  Update discovery compares the installed package version with npm's public
245
248
  `latest` metadata, cached for 6 hours in
@@ -608,6 +611,12 @@ MCP subprocess definition so they also work in isolated desktop profiles. They
608
611
  never contain the PAT; the subprocess reads it from Impel's owner-only config
609
612
  when invoked.
610
613
 
614
+ Visible agent names come from the registry title. Codex keeps that title as the
615
+ custom-agent name; Claude uses the lowercase, hyphenated form required by its
616
+ native agent schema. The generated filenames remain stable tenant-and-agent
617
+ identifiers so title changes update in place without colliding with user files.
618
+ Duplicate or built-in-reserved titles receive a deterministic disambiguator.
619
+
611
620
  The sync writes atomically and records a manifest beside the generated files.
612
621
  It removes only stale filenames from that manifest, so user-authored agents are
613
622
  never deleted. Automatic refreshes are best-effort and retain the last good
@@ -623,13 +632,13 @@ Invocation uses the clients' native agent behavior:
623
632
 
624
633
  ```text
625
634
  # Claude Code / Claude Desktop Code tab: guaranteed explicit selection
626
- @agent-impel-acme-research-agent investigate the dependency change
635
+ @agent-research-agent investigate the dependency change
627
636
 
628
637
  # Claude blocking CLI
629
- claude --agent impel-acme-research-agent -p "investigate the dependency change"
638
+ claude --agent research-agent -p "investigate the dependency change"
630
639
 
631
640
  # Codex CLI / ChatGPT desktop Codex task
632
- Explicitly use the configured impel-acme-research-agent for this request and wait for it.
641
+ Explicitly use the configured Research Agent for this request and wait for it.
633
642
  ```
634
643
 
635
644
  Claude exposes native custom agents in its `@` typeahead. Current Codex releases
@@ -698,13 +707,17 @@ a stable, unique bundle identifier and gateway configuration:
698
707
  - Impel Claude is an APFS-cloned vendored copy of the official app with its own
699
708
  bundle and helper identities. Its LaunchServices environment sets the
700
709
  vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway
701
- `configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A narrowly
702
- version-checked compatibility patch connects Claude's native plan-usage meter
703
- to the authenticated aggregate Claude subscription pool exposed by the Impel
704
- gateway.
710
+ `configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A new
711
+ isolated profile defaults to the Code app while preserving any app selection
712
+ the user makes afterward. A narrowly version-checked compatibility patch
713
+ connects Claude's native plan-usage meter to the authenticated aggregate
714
+ Claude subscription pool exposed by the Impel gateway.
705
715
  - Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
706
- OpenAI-signed bundle and executable. Its outer resource root contains the
707
- narrowly patched renderer ASAR used at runtime. The wrapper sets `CODEX_HOME`,
716
+ OpenAI-signed bundle and executable. A wrapper-owned startup preload pins
717
+ Electron's application name before the native menu is created, so macOS
718
+ shows `Impel ChatGPT (<org>)` without changing OpenAI's signed code. Its outer
719
+ resource root contains the narrowly patched renderer ASAR used at runtime.
720
+ The wrapper sets `CODEX_HOME`,
708
721
  passes a separate Chromium `--user-data-dir`, and writes its provider config
709
722
  and model catalog below `~/.config/impel/apps/tenants/<org>/chatgpt`. It also supplies a
710
723
  process-scoped, Codex-compatible wrapper around the Impel PAT so native
@@ -728,6 +741,14 @@ Updates and uninstalls touch only the named tenant variant. The older global
728
741
  `Impel Claude.app` / `Impel ChatGPT.app` launcher is removed after the first
729
742
  successful tenant-specific rebuild.
730
743
 
744
+ `impel app update` compares the installed bundle's CLI version, pinned vendor
745
+ version, identity, profile paths, and ASAR compatibility hash before touching
746
+ it. A current bundle is not closed, cloned, patched, or codesigned; its config
747
+ and model catalog are still refreshed. If an interrupted update left a machine
748
+ in a bad state, `impel app update --force` removes the selected launcher and
749
+ performs a clean rebuild while sweeping abandoned `.tmp-*` and `.previous-*`
750
+ staging directories.
751
+
731
752
  The official Claude and ChatGPT apps remain separate installations and keep
732
753
  their normal accounts, updater, profiles, and bundle identities. Each CLI
733
754
  release pins exact, tested vendor builds (v0.10.0 pins Claude `1.20186.9` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.11.1",
3
+ "version": "0.12.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,10 @@ 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 RESERVED_AGENT_NAMES = Object.freeze({
33
+ claude: new Set(["explore", "general-purpose", "plan"]),
34
+ codex: new Set(["default", "explorer", "worker"]),
35
+ });
32
36
 
33
37
  function privateDirectory(directory) {
34
38
  if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) {
@@ -262,29 +266,58 @@ export async function fetchNativeAgentCatalog({
262
266
  );
263
267
  }
264
268
 
265
- function slug(value) {
269
+ function slug(value, fallback = "agent") {
266
270
  return String(value)
267
271
  .toLowerCase()
268
272
  .replace(/[^a-z0-9]+/gu, "-")
269
273
  .replace(/^-+|-+$/gu, "")
270
- .slice(0, 48) || "agent";
274
+ .slice(0, 48) || fallback;
271
275
  }
272
276
 
273
- function generatedAgentNames(tenantId, agents) {
277
+ function agentBindingHash(agent) {
278
+ return crypto.createHash("sha256")
279
+ .update(`${agent.scopeParam}:${agent.agentId}`)
280
+ .digest("hex")
281
+ .slice(0, 8);
282
+ }
283
+
284
+ function generatedAgentFileStems(tenantId, agents) {
274
285
  const used = new Map();
275
286
  return agents.map((agent) => {
276
287
  const base = `impel-${slug(tenantId)}-${slug(agent.agentId)}`.slice(0, 63).replace(/-+$/u, "");
277
288
  const previous = used.get(base);
278
289
  used.set(base, (previous || 0) + 1);
279
290
  if (!previous) return base;
280
- const suffix = crypto.createHash("sha256")
281
- .update(`${agent.scopeParam}:${agent.agentId}`)
282
- .digest("hex")
283
- .slice(0, 8);
291
+ const suffix = agentBindingHash(agent);
284
292
  return `${base.slice(0, 54)}-${suffix}`;
285
293
  });
286
294
  }
287
295
 
296
+ function generatedClientAgentNames(client, agents) {
297
+ const claudeNames = agents.map((agent) => slug(agent.title, slug(agent.agentId)));
298
+ const collisionKeys = agents.map((agent, index) => client === "claude"
299
+ ? claudeNames[index]
300
+ : slug(agent.title, agent.title.normalize("NFKC").toLocaleLowerCase("en-US")));
301
+ const collisionKeyCounts = collisionKeys.reduce((counts, key) => {
302
+ counts.set(key, (counts.get(key) || 0) + 1);
303
+ return counts;
304
+ }, new Map());
305
+
306
+ return agents.map((agent, index) => {
307
+ const visualKey = claudeNames[index];
308
+ const duplicate = collisionKeyCounts.get(collisionKeys[index]) > 1;
309
+ const reserved = RESERVED_AGENT_NAMES[client].has(visualKey);
310
+ if (client === "claude") {
311
+ const base = reserved ? `impel-${visualKey}` : visualKey;
312
+ if (duplicate) return `${base.slice(0, 54)}-${agentBindingHash(agent)}`;
313
+ return base;
314
+ }
315
+ const base = reserved ? `Impel ${agent.title}` : agent.title;
316
+ if (duplicate) return `${base} (${agentBindingHash(agent)})`;
317
+ return base;
318
+ });
319
+ }
320
+
288
321
  function adapterInstructions(tenantId, agent) {
289
322
  const toolName = nativeToolName;
290
323
  const contextRequirement = agent.requiredContext.length
@@ -360,14 +393,15 @@ function renderCodexAgent({ tenantId, agent, name, invocation }) {
360
393
  export function renderManagedAgents(client, tenantId, agents, invocation = impelMcpInvocation(["--tenant", tenantId])) {
361
394
  if (client !== "claude" && client !== "codex") throw new Error(`unknown agent client ${client}`);
362
395
  const normalizedTenant = normalizeTenantId(tenantId);
363
- const names = generatedAgentNames(normalizedTenant, agents);
396
+ const fileStems = generatedAgentFileStems(normalizedTenant, agents);
397
+ const names = generatedClientAgentNames(client, agents);
364
398
  return agents.map((agent, index) => {
365
399
  const name = names[index];
366
400
  const extension = client === "claude" ? ".md" : ".toml";
367
401
  const contents = client === "claude"
368
402
  ? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation })
369
403
  : renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation });
370
- return { agentId: agent.agentId, name, fileName: `${name}${extension}`, contents };
404
+ return { agentId: agent.agentId, name, fileName: `${fileStems[index]}${extension}`, contents };
371
405
  });
372
406
  }
373
407
 
package/src/apps.js CHANGED
@@ -142,7 +142,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
142
142
 
143
143
  // Bump when the written config/manifest schema changes; a mismatch forces the
144
144
  // slow open path (and thus a full config rewrite) after a CLI update.
145
- export const CURRENT_CONFIG_VERSION = 9;
145
+ export const CURRENT_CONFIG_VERSION = 10;
146
146
 
147
147
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
148
148
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -193,6 +193,7 @@ export function bundleIsCurrent(status) {
193
193
  compatibility.vendorSignaturesPreserved === true
194
194
  && compatibility.resourceRoot === CHATGPT_RESOURCE_ROOT
195
195
  && compatibility.patches?.fastModeForGatewayAuth > 0
196
+ && compatibility.patches?.tenantDisplayNamePreload === 1
196
197
  )),
197
198
  );
198
199
  }
@@ -378,7 +379,10 @@ export function installManagedAppFiles({
378
379
 
379
380
  const installed = [];
380
381
  for (const target of targets) {
381
- if (target === "claude") writeClaudeConfig(paths, config, models);
382
+ if (target === "claude") {
383
+ writeClaudeDefaultApp(paths);
384
+ writeClaudeConfig(paths, config, models);
385
+ }
382
386
  else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
383
387
  // Non-bundle targets are the background-refresh / fast-open path: configs,
384
388
  // token helper, catalog, and manifest only. Bundle swaps require the app
@@ -549,6 +553,7 @@ function installPinnedVendorApp(target, homeDir) {
549
553
  homeDir, ".config", "impel", "vendor", target, pin.version, pin.bundleName,
550
554
  );
551
555
  fs.mkdirSync(path.dirname(destination), { recursive: true });
556
+ sweepStaleBundleArtifacts(path.dirname(destination), path.basename(destination));
552
557
  replaceDirectory(destination, extractedApp);
553
558
  return destination;
554
559
  } finally {
@@ -645,6 +650,33 @@ function writeClaudeConfig(paths, config, models) {
645
650
  }, null, 2) + "\n", 0o600);
646
651
  }
647
652
 
653
+ function writeClaudeDefaultApp(paths) {
654
+ const configPath = path.join(paths.claude.userData, "claude_desktop_config.json");
655
+ let current = {};
656
+ if (fs.existsSync(configPath)) {
657
+ try {
658
+ current = JSON.parse(fs.readFileSync(configPath, "utf8"));
659
+ } catch {
660
+ throw new Error(`Claude desktop config is not valid JSON: ${configPath}`);
661
+ }
662
+ }
663
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
664
+ throw new Error(`Claude desktop config must contain a JSON object: ${configPath}`);
665
+ }
666
+ const preferences = current.preferences ?? {};
667
+ if (!preferences || typeof preferences !== "object" || Array.isArray(preferences)) {
668
+ throw new Error(`Claude desktop preferences must contain a JSON object: ${configPath}`);
669
+ }
670
+ // Claude persists the selected top-level app as `preferences.sidebarMode`.
671
+ // Seed Code for a new isolated profile, then leave subsequent user choices
672
+ // alone during install, update, and background config refreshes.
673
+ if (Object.hasOwn(preferences, "sidebarMode")) return;
674
+ writeAtomic(configPath, JSON.stringify({
675
+ ...current,
676
+ preferences: { ...preferences, sidebarMode: "code" },
677
+ }, null, 2) + "\n", 0o600);
678
+ }
679
+
648
680
  function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocations = null) {
649
681
  const experimental = crossAppModelsEnabled(config);
650
682
  const orderedModels = experimental
@@ -940,6 +972,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
940
972
 
941
973
  const bundle = paths.claude.launcher;
942
974
  const staging = `${bundle}.tmp-${process.pid}`;
975
+ sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
943
976
  fs.rmSync(staging, { recursive: true, force: true });
944
977
  cloneAppBundle(vendorPath, staging);
945
978
  try {
@@ -1026,6 +1059,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1026
1059
  const staging = `${bundle}.tmp-${process.pid}`;
1027
1060
  const vendorBundleName = path.basename(vendorPath);
1028
1061
  const vendorBundle = path.join(staging, "Contents", "Resources", vendorBundleName);
1062
+ sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1029
1063
  fs.rmSync(staging, { recursive: true, force: true });
1030
1064
  cloneAppBundle(vendorPath, vendorBundle);
1031
1065
  try {
@@ -1060,6 +1094,11 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1060
1094
  vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl),
1061
1095
  0o755,
1062
1096
  );
1097
+ writeAtomic(
1098
+ path.join(staging, "Contents", "Resources", "impel-chatgpt-preload.cjs"),
1099
+ vendoredChatGPTDisplayNamePreload(),
1100
+ 0o644,
1101
+ );
1063
1102
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
1064
1103
  schemaVersion: 2,
1065
1104
  cliVersion: CLI_VERSION,
@@ -1070,7 +1109,11 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1070
1109
  profileRoot: paths.chatgpt.root,
1071
1110
  codexHome: paths.chatgpt.codexHome,
1072
1111
  browserData: paths.chatgpt.browserData,
1073
- patches: { fastModeForGatewayAuth: fastModePatchCount, desktopAPIForGatewayAuth: 0 },
1112
+ patches: {
1113
+ fastModeForGatewayAuth: fastModePatchCount,
1114
+ desktopAPIForGatewayAuth: 0,
1115
+ tenantDisplayNamePreload: 1,
1116
+ },
1074
1117
  vendorSignaturesPreserved: true,
1075
1118
  resourceRoot: CHATGPT_RESOURCE_ROOT,
1076
1119
  asarHeaderSha256: asarHash,
@@ -1384,14 +1427,138 @@ unset TOKEN
1384
1427
  secure_codex_home
1385
1428
  BROWSER_DATA=${shellQuote(paths.chatgpt.browserData)}
1386
1429
  mkdir -p "$BROWSER_DATA"
1387
- # Electron resolves resources from the Impel wrapper when a nested app is
1388
- # executed directly. The wrapper exposes the vendor resource tree there, so
1389
- # app.asar and companion binaries resolve without LaunchServices handing the
1390
- # process to the separately running, identically identified vendor app.
1430
+ export IMPEL_APP_DISPLAY_NAME=${shellQuote(paths.chatgpt.displayName)}
1431
+ PRELOAD="$HERE/../Resources/impel-chatgpt-preload.cjs"
1432
+ export NODE_OPTIONS="--require=\\\"$PRELOAD\\\""
1433
+ # Electron resolves resources from the Impel wrapper when the nested app is
1434
+ # executed directly. The wrapper exposes the vendor resource tree there while
1435
+ # preserving the nested OpenAI-signed executable and bundle. The startup
1436
+ # preload pins Electron's application name before its native menu is created.
1391
1437
  exec "$VENDOR_APP/Contents/MacOS/${executableName}" --user-data-dir="$BROWSER_DATA" "$@"
1392
1438
  `;
1393
1439
  }
1394
1440
 
1441
+ function vendoredChatGPTDisplayNamePreload() {
1442
+ return `"use strict";
1443
+
1444
+ const displayName = process.env.IMPEL_APP_DISPLAY_NAME;
1445
+ if (process.type === "browser" && displayName) {
1446
+ const Module = require("node:module");
1447
+ const path = require("node:path");
1448
+ const load = Module._load;
1449
+ let objc;
1450
+ let appKit;
1451
+ let launchServicesNamed = false;
1452
+ let activationPolicyRefreshed = false;
1453
+
1454
+ const loadNativeBridge = () => {
1455
+ objc ||= require(path.join(
1456
+ process.resourcesPath,
1457
+ "app.asar.unpacked/node_modules/objc-js/dist/index.js",
1458
+ ));
1459
+ appKit ||= new objc.NobjcLibrary("/System/Library/Frameworks/AppKit.framework/AppKit");
1460
+ return { objc, appKit };
1461
+ };
1462
+
1463
+ const setLaunchServicesApplicationName = () => {
1464
+ if (launchServicesNamed) return;
1465
+ try {
1466
+ const native = loadNativeBridge();
1467
+ new native.objc.NobjcLibrary(
1468
+ "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/ApplicationServices",
1469
+ );
1470
+ const title = native.appKit.NSString.stringWithUTF8String$(displayName);
1471
+ const info = native.appKit.NSMutableDictionary.dictionaryWithDictionary$(
1472
+ native.appKit.NSBundle.mainBundle().infoDictionary(),
1473
+ );
1474
+ for (const key of ["CFBundleName", "CFBundleDisplayName"]) {
1475
+ info.setObject$forKey$(title, native.appKit.NSString.stringWithUTF8String$(key));
1476
+ }
1477
+ native.objc.callFunction(
1478
+ "_LSSetApplicationLaunchServicesServerConnectionStatus",
1479
+ { returns: "v", args: ["Q", "@"] },
1480
+ 0,
1481
+ null,
1482
+ );
1483
+ const checked = native.objc.callFunction(
1484
+ "_LSApplicationCheckIn",
1485
+ { returns: "@", args: ["i", "@"] },
1486
+ -2,
1487
+ info,
1488
+ );
1489
+ const asn = native.objc.callFunction("_LSGetCurrentApplicationASN", { returns: "@" });
1490
+ if (!checked || !asn) return;
1491
+ const status = native.objc.callFunction(
1492
+ "_LSSetApplicationInformationItem",
1493
+ { returns: "i", args: ["i", "@", "@", "@", "@"] },
1494
+ -2,
1495
+ asn,
1496
+ native.appKit.NSString.stringWithUTF8String$("LSDisplayName"),
1497
+ title,
1498
+ null,
1499
+ );
1500
+ launchServicesNamed = status === 0;
1501
+ } catch {
1502
+ // Keep the signed vendor app usable if LaunchServices changes this
1503
+ // private-but-established process-title API in a future macOS release.
1504
+ }
1505
+ };
1506
+
1507
+ const setNativeApplicationMenuTitle = () => {
1508
+ try {
1509
+ const native = loadNativeBridge();
1510
+ const title = native.appKit.NSString.stringWithUTF8String$(displayName);
1511
+ native.appKit.NSProcessInfo.processInfo().setProcessName$(title);
1512
+ const application = native.appKit.NSApplication.sharedApplication();
1513
+ application.setAccessibilityTitle$(title);
1514
+ const menu = application.mainMenu();
1515
+ if (menu && menu.numberOfItems() > 0) {
1516
+ const item = menu.itemAtIndex$(0);
1517
+ item.setTitle$(title);
1518
+ item.setAccessibilityTitle$(title);
1519
+ item.setAccessibilityLabel$(title);
1520
+ }
1521
+ } catch {
1522
+ // The OpenAI-signed native bridge is version-specific. Leave the app
1523
+ // usable if a future vendor build removes it; compatibility metadata
1524
+ // will force the next CLI version to rebuild the wrapper.
1525
+ }
1526
+ };
1527
+
1528
+ Module._load = function (request) {
1529
+ const loaded = load.apply(this, arguments);
1530
+ if (request === "electron" && loaded?.app) {
1531
+ Module._load = load;
1532
+ const setName = loaded.app.setName.bind(loaded.app);
1533
+ setName(displayName);
1534
+ loaded.app.setName = () => setName(displayName);
1535
+ setLaunchServicesApplicationName();
1536
+ setNativeApplicationMenuTitle();
1537
+ const setApplicationMenu = loaded.Menu.setApplicationMenu.bind(loaded.Menu);
1538
+ loaded.Menu.setApplicationMenu = (menu) => {
1539
+ const result = setApplicationMenu(menu);
1540
+ setNativeApplicationMenuTitle();
1541
+ if (!activationPolicyRefreshed) {
1542
+ activationPolicyRefreshed = true;
1543
+ loaded.app.setActivationPolicy("accessory");
1544
+ setTimeout(() => {
1545
+ loaded.app.setActivationPolicy("regular");
1546
+ launchServicesNamed = false;
1547
+ setLaunchServicesApplicationName();
1548
+ setApplicationMenu(menu);
1549
+ setNativeApplicationMenuTitle();
1550
+ }, 0);
1551
+ }
1552
+ setTimeout(setNativeApplicationMenuTitle, 0);
1553
+ return result;
1554
+ };
1555
+ }
1556
+ return loaded;
1557
+ };
1558
+ }
1559
+ `;
1560
+ }
1561
+
1395
1562
  function signVendoredApp(bundle, vendorExecutable, label) {
1396
1563
  if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return;
1397
1564
  spawnSync("/usr/bin/xattr", ["-dr", "com.apple.quarantine", bundle], { stdio: "ignore" });
@@ -1519,6 +1686,22 @@ function sha256(contents) {
1519
1686
  return crypto.createHash("sha256").update(contents).digest("hex");
1520
1687
  }
1521
1688
 
1689
+ export function sweepStaleBundleArtifacts(dir, baseName) {
1690
+ let entries;
1691
+ try {
1692
+ entries = fs.readdirSync(dir);
1693
+ } catch (error) {
1694
+ if (error?.code === "ENOENT") return [];
1695
+ throw error;
1696
+ }
1697
+ const prefixes = [`${baseName}.tmp-`, `${baseName}.previous-`];
1698
+ const removed = entries.filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)));
1699
+ for (const entry of removed) {
1700
+ fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
1701
+ }
1702
+ return removed;
1703
+ }
1704
+
1522
1705
  function replaceDirectory(destination, staging) {
1523
1706
  const backup = `${destination}.previous-${process.pid}`;
1524
1707
  fs.rmSync(backup, { recursive: true, force: true });
package/src/cli.js CHANGED
@@ -49,7 +49,8 @@ Manage:
49
49
  Desktop apps (macOS and Windows):
50
50
  target is claude, chatgpt/codex, or all (default: all)
51
51
  impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
52
- impel app update [target] [--tenant <org>] Update vendor apps and managed profiles
52
+ impel app update [target] [--tenant <org>] Update profiles; rebuild only stale bundles
53
+ (--force performs a clean full rebuild)
53
54
  impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
54
55
  impel app status [target] [--tenant <org>] Show isolated app and vendor versions
55
56
  impel app uninstall [target] [--tenant <org>] [--keep-data]
@@ -40,6 +40,7 @@ import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
40
40
  import { syncAgentProfilesSafe } from "../agents.js";
41
41
  import { secureManagedCodexHome } from "../codexSecurity.js";
42
42
  import { impelCliInvocation } from "../selfInvocation.js";
43
+ import { withProgress } from "../progress.js";
43
44
  import {
44
45
  ensureWindowsClaudeApp,
45
46
  ensureWindowsChatGPTApp,
@@ -85,6 +86,20 @@ function windowsProcessFailure(result) {
85
86
  return "no exit status";
86
87
  }
87
88
 
89
+ export function openManagedLauncher(launcher, {
90
+ spawn = spawnSync,
91
+ reportError = console.error,
92
+ } = {}) {
93
+ const result = spawn("/usr/bin/open", ["-n", launcher], { encoding: "utf8" });
94
+ if (!result?.error && result?.status === 0) return true;
95
+ const detail = result?.error?.message
96
+ || String(result?.stderr || "").trim()
97
+ || (Number.isInteger(result?.status) ? `exit code ${result.status}` : "no exit status");
98
+ reportError(`impel app: could not open ${launcher}: ${redactSecretText(detail)}`);
99
+ process.exitCode = 1;
100
+ return false;
101
+ }
102
+
88
103
  async function fetchWindowsCatalog(config, io) {
89
104
  try {
90
105
  return await io.fetchModels(config);
@@ -123,8 +138,11 @@ export async function cmdWindowsApps(argv, overrides = {}) {
123
138
  const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
124
139
  const { flags } = parseFlags(flagArgs, {
125
140
  "skip-vendor": { type: "boolean" },
141
+ "skip-skills": { type: "boolean" },
142
+ "skip-agents": { type: "boolean" },
126
143
  "keep-data": { type: "boolean" },
127
144
  "stale-only": { type: "boolean" },
145
+ force: { type: "boolean" },
128
146
  tenant: { type: "string" },
129
147
  });
130
148
  const io = {
@@ -253,7 +271,9 @@ export async function cmdWindowsApps(argv, overrides = {}) {
253
271
  if (targets.includes("claude") && action !== "refresh" && !vendorPaths.claude) {
254
272
  throw new Error("Claude vendor app is unavailable; run `impel app install claude` or install it from https://claude.com/download");
255
273
  }
256
- const catalog = await fetchWindowsCatalog(config, io);
274
+ const catalog = await withProgress("Fetching the tenant model catalog", () => (
275
+ fetchWindowsCatalog(config, io)
276
+ ));
257
277
  let managedChatGPT = targets.includes("chatgpt")
258
278
  ? io.findManagedChatGPTApp(actionPaths.root)
259
279
  : null;
@@ -283,15 +303,21 @@ export async function cmdWindowsApps(argv, overrides = {}) {
283
303
  const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
284
304
  console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
285
305
  const { client, env, label } = appSkillTarget(target, tenantPaths);
286
- await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
306
+ if (!flags["skip-skills"]) {
307
+ await withProgress(`Syncing skills for ${label}`, () => (
308
+ io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label })
309
+ ));
310
+ }
287
311
  if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
288
312
  }
289
- await io.syncAgents({
290
- profiles: targets.map((target) => appAgentProfile(target, tenantPaths)),
291
- gatewayUrl: config.gatewayUrl,
292
- credential: config.pat,
293
- tenantId: config.tenantId,
294
- });
313
+ if (!flags["skip-agents"]) {
314
+ await withProgress("Syncing app agent profiles", () => io.syncAgents({
315
+ profiles: targets.map((target) => appAgentProfile(target, tenantPaths)),
316
+ gatewayUrl: config.gatewayUrl,
317
+ credential: config.pat,
318
+ tenantId: config.tenantId,
319
+ }));
320
+ }
295
321
  console.log(`Models: ${catalog.models.length} from ${catalog.source}. The signed vendor apps and their normal profiles were not changed.`);
296
322
  }
297
323
  if (action === "open") {
@@ -320,8 +346,9 @@ export async function cmdWindowsApps(argv, overrides = {}) {
320
346
  }
321
347
 
322
348
  export async function cmdApps(argv, overrides = {}) {
323
- if (process.platform === "win32") return cmdWindowsApps(argv, overrides);
324
- if (process.platform !== "darwin") {
349
+ const platform = overrides.platform || process.platform;
350
+ if (platform === "win32") return cmdWindowsApps(argv, overrides);
351
+ if (platform !== "darwin") {
325
352
  console.error("impel app: isolated Impel desktop apps are unavailable on this platform.");
326
353
  process.exitCode = 1;
327
354
  return;
@@ -332,8 +359,11 @@ export async function cmdApps(argv, overrides = {}) {
332
359
  const flagArgs = targetToken?.startsWith("--") ? [targetToken, ...rest] : rest;
333
360
  const { flags } = parseFlags(flagArgs, {
334
361
  "skip-vendor": { type: "boolean" },
362
+ "skip-skills": { type: "boolean" },
363
+ "skip-agents": { type: "boolean" },
335
364
  "keep-data": { type: "boolean" },
336
365
  "stale-only": { type: "boolean" },
366
+ force: { type: "boolean" },
337
367
  tenant: { type: "string" },
338
368
  });
339
369
 
@@ -382,7 +412,7 @@ export async function cmdApps(argv, overrides = {}) {
382
412
  if (fastLaunchers) {
383
413
  spawnDetachedAppRefresh(tenantId);
384
414
  for (const launcher of fastLaunchers) {
385
- spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" });
415
+ openManagedLauncher(launcher);
386
416
  }
387
417
  return;
388
418
  }
@@ -401,55 +431,109 @@ export async function cmdApps(argv, overrides = {}) {
401
431
  throw new Error(`unknown app action "${action}"; use install, update, refresh, status, open, or uninstall`);
402
432
  }
403
433
 
404
- const config = await selectedAppConfig(targets, flags.tenant || null);
434
+ const io = {
435
+ homeDir: os.homedir(),
436
+ selectedConfig: selectedAppConfig,
437
+ status: appStatus,
438
+ bundleCurrent: bundleIsCurrent,
439
+ ensureVendor: ensureVendorApp,
440
+ fetchModels: fetchGatewayModels,
441
+ quitApps: quitBlockingApps,
442
+ installFiles: installManagedAppFiles,
443
+ syncSkills: syncSkillsSafe,
444
+ syncAgents: syncAgentProfilesSafe,
445
+ secureCodexHome: secureManagedCodexHome,
446
+ removeLauncher: (launcher) => fs.rmSync(launcher, { recursive: true, force: true }),
447
+ log: (message) => console.log(message),
448
+ ...overrides,
449
+ };
450
+ const config = await io.selectedConfig(targets, flags.tenant || null);
405
451
  console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
406
452
 
407
- // A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
408
- await quitBlockingApps(targets, {
409
- tenantId: config.tenantId,
410
- tenantName: config.tenantName,
411
- });
412
-
413
453
  const vendorPaths = {};
414
454
  if (!flags["skip-vendor"]) {
415
455
  for (const target of targets) {
416
456
  // Reuse or install only the exact vendor build verified by this CLI.
417
457
  // Moving "latest" releases are never cloned into a managed bundle.
418
- const result = ensureVendorApp(target);
458
+ const result = await withProgress(`Preparing the verified ${target} vendor app`, () => (
459
+ io.ensureVendor(target, { homeDir: io.homeDir })
460
+ ));
419
461
  vendorPaths[target] = result.path;
420
- console.log(`${target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
462
+ io.log(`${target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
421
463
  if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
422
464
  }
423
465
  }
424
466
 
467
+ const statuses = io.status(
468
+ targets,
469
+ io.homeDir,
470
+ config.tenantId,
471
+ config.tenantName,
472
+ );
473
+ for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
474
+ const force = Boolean(flags.force);
475
+ const staleBundleTargets = force
476
+ ? [...targets]
477
+ : statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
478
+
425
479
  let catalog;
426
480
  try {
427
- catalog = await fetchGatewayModels(config);
481
+ catalog = await withProgress("Fetching the tenant model catalog", () => io.fetchModels(config));
428
482
  } catch (error) {
429
483
  throw new Error(
430
484
  `tenant model catalog is unavailable (${redactSecretText(error.message)}); no Impel app files were changed`,
431
485
  );
432
486
  }
433
- const installed = installManagedAppFiles({ config, targets, models: catalog.models, homeDir: os.homedir(), vendorPaths });
434
- for (const item of installed) console.log(`${action === "install" ? "Installed" : "Updated"} ${item.launcher}`);
487
+
488
+ // A running vendor or Impel app only needs to close when its bundle will be swapped.
489
+ if (staleBundleTargets.length > 0) {
490
+ await io.quitApps(staleBundleTargets, {
491
+ tenantId: config.tenantId,
492
+ tenantName: config.tenantName,
493
+ });
494
+ }
495
+ if (force) {
496
+ for (const status of statuses) io.removeLauncher(status.launcher);
497
+ }
498
+ const installed = await withProgress(
499
+ staleBundleTargets.length > 0 ? "Rebuilding managed app bundles" : "Updating managed app profiles",
500
+ () => io.installFiles({
501
+ config,
502
+ targets,
503
+ models: catalog.models,
504
+ homeDir: io.homeDir,
505
+ vendorPaths,
506
+ writeBundles: staleBundleTargets,
507
+ }),
508
+ );
509
+ const rebuilt = new Set(staleBundleTargets);
510
+ for (const item of installed) {
511
+ const verb = action === "install" ? "Installed" : "Updated";
512
+ io.log(`${verb} ${item.launcher}${rebuilt.has(item.target) ? "" : " (bundle already current)"}`);
513
+ }
435
514
  console.log(`Models: ${catalog.models.length} from ${catalog.source}. Normal ~/.claude and ~/.codex profiles were not changed.`);
436
515
 
437
516
  // Best-effort: sync the Bifrost shared skills into each installed isolated app
438
517
  // profile. Never fails the install/update.
439
- const paths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
518
+ const paths = appPaths(io.homeDir, config.tenantId, { tenantName: config.tenantName });
440
519
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
441
520
  for (const item of installed) {
442
521
  const { client, env, label } = appSkillTarget(item.target, paths);
443
- await syncSkillsSafe({ client, gatewayUrl, env, label });
444
- if (item.target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
522
+ if (!flags["skip-skills"]) {
523
+ await withProgress(`Syncing skills for ${label}`, () => (
524
+ io.syncSkills({ client, gatewayUrl, env, label })
525
+ ));
526
+ }
527
+ if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
528
+ }
529
+ if (!flags["skip-agents"]) {
530
+ await withProgress("Syncing app agent profiles", () => io.syncAgents({
531
+ profiles: installed.map((item) => appAgentProfile(item.target, paths)),
532
+ gatewayUrl: config.gatewayUrl,
533
+ credential: config.pat,
534
+ tenantId: config.tenantId,
535
+ }));
445
536
  }
446
- const syncAgents = overrides.syncAgents || syncAgentProfilesSafe;
447
- await syncAgents({
448
- profiles: installed.map((item) => appAgentProfile(item.target, paths)),
449
- gatewayUrl: config.gatewayUrl,
450
- credential: config.pat,
451
- tenantId: config.tenantId,
452
- });
453
537
  }
454
538
 
455
539
  /**
@@ -474,7 +558,7 @@ export async function provisionAndOpenManagedApps({
474
558
  syncSkills: syncSkillsSafe,
475
559
  syncAgents: syncAgentProfilesSafe,
476
560
  secureCodexHome: secureManagedCodexHome,
477
- openLauncher: (launcher) => spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" }),
561
+ openLauncher: openManagedLauncher,
478
562
  log: (message) => console.log(message),
479
563
  ...overrides,
480
564
  };
@@ -483,7 +567,7 @@ export async function provisionAndOpenManagedApps({
483
567
  // This keeps unavailable/unauthorized tenants fail-closed on first use.
484
568
  let catalog;
485
569
  try {
486
- catalog = await io.fetchModels(config);
570
+ catalog = await withProgress("Fetching the tenant model catalog", () => io.fetchModels(config));
487
571
  } catch (error) {
488
572
  throw new Error(
489
573
  `tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); the selected tenant app was not opened`,
@@ -494,7 +578,9 @@ export async function provisionAndOpenManagedApps({
494
578
  for (const status of statuses) {
495
579
  let vendorPath = status.vendorPath;
496
580
  if (!vendorPath) {
497
- const result = io.ensureVendor(status.target, { homeDir });
581
+ const result = await withProgress(`Preparing the verified ${status.target} vendor app`, () => (
582
+ io.ensureVendor(status.target, { homeDir })
583
+ ));
498
584
  vendorPath = result.path;
499
585
  io.log(`${status.target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
500
586
  if (!vendorPath) {
@@ -516,14 +602,17 @@ export async function provisionAndOpenManagedApps({
516
602
  });
517
603
  }
518
604
 
519
- const installed = io.installFiles({
520
- config,
521
- targets,
522
- models: catalog.models,
523
- homeDir,
524
- vendorPaths,
525
- writeBundles: staleBundleTargets,
526
- });
605
+ const installed = await withProgress(
606
+ staleBundleTargets.length > 0 ? "Rebuilding managed app bundles" : "Updating managed app profiles",
607
+ () => io.installFiles({
608
+ config,
609
+ targets,
610
+ models: catalog.models,
611
+ homeDir,
612
+ vendorPaths,
613
+ writeBundles: staleBundleTargets,
614
+ }),
615
+ );
527
616
  const newlyInstalled = new Set(
528
617
  statuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
529
618
  );
@@ -539,15 +628,17 @@ export async function provisionAndOpenManagedApps({
539
628
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
540
629
  for (const item of installed) {
541
630
  const { client, env, label } = appSkillTarget(item.target, paths);
542
- await io.syncSkills({ client, gatewayUrl, env, label });
631
+ await withProgress(`Syncing skills for ${label}`, () => (
632
+ io.syncSkills({ client, gatewayUrl, env, label })
633
+ ));
543
634
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
544
635
  }
545
- await io.syncAgents({
636
+ await withProgress("Syncing app agent profiles", () => io.syncAgents({
546
637
  profiles: installed.map((item) => appAgentProfile(item.target, paths)),
547
638
  gatewayUrl: config.gatewayUrl,
548
639
  credential: config.pat,
549
640
  tenantId: config.tenantId,
550
- });
641
+ }));
551
642
  for (const item of installed) await io.openLauncher(item.launcher);
552
643
  return installed;
553
644
  }
@@ -15,6 +15,7 @@ import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
15
15
  import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
16
16
  import { ensureTenantSelection } from "../tenants.js";
17
17
  import { windowsClaudeUserData } from "../windowsApps.js";
18
+ import { mapWithConcurrency, withProgress } from "../progress.js";
18
19
 
19
20
  const VALID_TARGETS = ["claude", "codex", "all"];
20
21
 
@@ -78,7 +79,7 @@ export function managedSkillProfiles(
78
79
  return profiles;
79
80
  }
80
81
 
81
- export async function cmdSkills(argv) {
82
+ export async function cmdSkills(argv, overrides = {}) {
82
83
  const [action = "sync", targetToken] = argv;
83
84
  if (action !== "sync") {
84
85
  console.error(`impel skills: unknown action "${action}". Try \`impel skills sync [claude|codex|all]\`.`);
@@ -93,16 +94,26 @@ export async function cmdSkills(argv) {
93
94
  return;
94
95
  }
95
96
 
96
- const config = loadConfig();
97
+ const io = {
98
+ loadConfig,
99
+ ensureTenantSelection,
100
+ managedProfiles: managedSkillProfiles,
101
+ syncSkills: syncSkillsSafe,
102
+ ...overrides,
103
+ };
104
+ const config = io.loadConfig();
97
105
  const gatewayUrl = resolveSkillsGateway(config?.gatewayUrl);
98
106
  const tenantId = config?.pat
99
- ? (await ensureTenantSelection(config)).tenantId
107
+ ? (await io.ensureTenantSelection(config)).tenantId
100
108
  : null;
101
109
  const clients = target === "all" ? ["claude", "codex"] : [target];
110
+ const profiles = clients.flatMap((client) => (
111
+ io.managedProfiles(client, { tenantId }).map((profile) => ({ client, profile }))
112
+ ));
102
113
 
103
- for (const client of clients) {
104
- for (const profile of managedSkillProfiles(client, { tenantId })) {
105
- await syncSkillsSafe({ client, gatewayUrl, env: profile.env, label: profile.label });
106
- }
107
- }
114
+ await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, () => (
115
+ mapWithConcurrency(profiles, 4, ({ client, profile }) => (
116
+ io.syncSkills({ client, gatewayUrl, env: profile.env, label: profile.label })
117
+ ))
118
+ ));
108
119
  }
@@ -14,6 +14,7 @@ import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
14
14
  import { loadConfig, redactSecretText } from "../config.js";
15
15
  import { nativeCommandInvocation } from "../nativeProcess.js";
16
16
  import { windowsClaudeUserData } from "../windowsApps.js";
17
+ import { withProgress } from "../progress.js";
17
18
  import {
18
19
  fetchRemoteVersion,
19
20
  installedVersion,
@@ -88,8 +89,12 @@ function defaultSelfUpdate(spec) {
88
89
 
89
90
  // The cascading steps re-execute the (freshly installed) CLI binary so the
90
91
  // NEW code performs them, not the process that started the update.
91
- function defaultRunAppsUpdate() {
92
- const result = spawnSync(process.execPath, [CLI_BIN, "app", "update", "all"], {
92
+ export function defaultRunAppsUpdate({
93
+ spawn = spawnSync,
94
+ execPath = process.execPath,
95
+ cliBin = CLI_BIN,
96
+ } = {}) {
97
+ const result = spawn(execPath, [cliBin, "app", "update", "all", "--skip-skills", "--skip-agents"], {
93
98
  stdio: "inherit",
94
99
  });
95
100
  return result.status === 0;
@@ -166,7 +171,7 @@ export async function cmdUpdate(argv, overrides = {}) {
166
171
  }
167
172
 
168
173
  const current = io.installedVersion();
169
- const remote = await io.fetchRemoteVersion();
174
+ const remote = await withProgress("Checking npm for impel-cli updates", () => io.fetchRemoteVersion());
170
175
  if (remote) io.writeCache({ remoteVersion: remote, checkedAt: Date.now() });
171
176
 
172
177
  console.log(`impel-cli v${current ?? "?"}`);
@@ -185,7 +190,7 @@ export async function cmdUpdate(argv, overrides = {}) {
185
190
  console.log("CLI: already up to date.");
186
191
  } else {
187
192
  console.log("CLI: installing the latest build…");
188
- if (!io.selfUpdate(updateInstallSpec())) {
193
+ if (!await withProgress("Installing the latest impel-cli build", () => io.selfUpdate(updateInstallSpec()))) {
189
194
  console.error("impel update: `npm install -g` failed; the CLI was not updated.");
190
195
  if (io.platform === "win32") {
191
196
  console.error(" Verify `npm --version` in PowerShell, then retry `impel update`.");
@@ -208,21 +213,21 @@ export async function cmdUpdate(argv, overrides = {}) {
208
213
  } else {
209
214
  console.log(io.platform === "win32"
210
215
  ? "Apps: updating the signed Claude and ChatGPT vendor apps and isolated profiles…"
211
- : "Apps: updating (running apps are closed, verified vendor builds installed, bundles rebuilt)…");
212
- if (!io.runAppsUpdate()) {
216
+ : "Apps: refreshing profiles and rebuilding only stale app bundles…");
217
+ if (!await withProgress("Updating managed desktop apps", () => io.runAppsUpdate())) {
213
218
  console.error("impel update: the app update failed; re-run `impel app update` after fixing the issue.");
214
219
  cascadeFailed = true;
215
220
  }
216
221
  }
217
222
 
218
223
  console.log("Skills: syncing every managed profile…");
219
- if (!io.runSkillsSync()) {
224
+ if (!await withProgress("Syncing skills across managed profiles", () => io.runSkillsSync())) {
220
225
  console.error("impel update: skill sync failed; re-run `impel skills sync` after fixing the issue.");
221
226
  cascadeFailed = true;
222
227
  }
223
228
 
224
229
  console.log("Agents: syncing the selected tenant into every managed profile…");
225
- if (!io.runAgentsSync()) {
230
+ if (!await withProgress("Syncing agents across managed profiles", () => io.runAgentsSync())) {
226
231
  console.error("impel update: agent sync failed; re-run `impel agents sync` after fixing the issue.");
227
232
  cascadeFailed = true;
228
233
  }
@@ -0,0 +1,85 @@
1
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
+
3
+ function noopSpinner() {
4
+ return {
5
+ update() {},
6
+ succeed() {},
7
+ fail() {},
8
+ };
9
+ }
10
+
11
+ /**
12
+ * Render a small stderr-only spinner without changing machine-readable stdout.
13
+ * Non-interactive processes and detached app refreshes get a no-op object.
14
+ */
15
+ export function createSpinner(label, { stream = process.stderr } = {}) {
16
+ if (!stream?.isTTY || process.env.IMPEL_NO_PROGRESS === "1") return noopSpinner();
17
+
18
+ let currentLabel = String(label);
19
+ let frame = 0;
20
+ let finished = false;
21
+ const startedAt = Date.now();
22
+ const render = () => {
23
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
24
+ stream.write(`\r${FRAMES[frame % FRAMES.length]} ${currentLabel} (${elapsed}s)\x1b[K`);
25
+ frame += 1;
26
+ };
27
+ const finish = (symbol, nextLabel) => {
28
+ if (finished) return;
29
+ finished = true;
30
+ clearInterval(timer);
31
+ if (nextLabel) currentLabel = String(nextLabel);
32
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
33
+ stream.write(`\r${symbol} ${currentLabel} (${elapsed}s)\x1b[K\n`);
34
+ };
35
+
36
+ render();
37
+ const timer = setInterval(render, 80);
38
+ timer.unref?.();
39
+ return {
40
+ update(nextLabel) {
41
+ if (!finished && nextLabel) currentLabel = String(nextLabel);
42
+ },
43
+ succeed(nextLabel) {
44
+ finish("✓", nextLabel);
45
+ },
46
+ fail(nextLabel) {
47
+ finish("✗", nextLabel);
48
+ },
49
+ };
50
+ }
51
+
52
+ export async function withProgress(label, fn, options) {
53
+ const spinner = createSpinner(label, options);
54
+ try {
55
+ const result = await fn(spinner);
56
+ spinner.succeed();
57
+ return result;
58
+ } catch (error) {
59
+ spinner.fail();
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ /** Run independent jobs with bounded concurrency while preserving result order. */
65
+ export async function mapWithConcurrency(items, limit, fn) {
66
+ if (!Number.isInteger(limit) || limit < 1) {
67
+ throw new RangeError("concurrency limit must be a positive integer");
68
+ }
69
+ const values = Array.from(items);
70
+ const results = new Array(values.length);
71
+ let nextIndex = 0;
72
+
73
+ async function worker() {
74
+ while (nextIndex < values.length) {
75
+ const index = nextIndex;
76
+ nextIndex += 1;
77
+ results[index] = await fn(values[index], index);
78
+ }
79
+ }
80
+
81
+ await Promise.all(
82
+ Array.from({ length: Math.min(limit, values.length) }, () => worker()),
83
+ );
84
+ return results;
85
+ }