impel-cli 0.11.2 → 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,10 +707,11 @@ 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
716
  OpenAI-signed bundle and executable. A wrapper-owned startup preload pins
707
717
  Electron's application name before the native menu is created, so macOS
@@ -731,6 +741,14 @@ Updates and uninstalls touch only the named tenant variant. The older global
731
741
  `Impel Claude.app` / `Impel ChatGPT.app` launcher is removed after the first
732
742
  successful tenant-specific rebuild.
733
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
+
734
752
  The official Claude and ChatGPT apps remain separate installations and keep
735
753
  their normal accounts, updater, profiles, and bundle identities. Each CLI
736
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.2",
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) {
@@ -379,7 +379,10 @@ export function installManagedAppFiles({
379
379
 
380
380
  const installed = [];
381
381
  for (const target of targets) {
382
- if (target === "claude") writeClaudeConfig(paths, config, models);
382
+ if (target === "claude") {
383
+ writeClaudeDefaultApp(paths);
384
+ writeClaudeConfig(paths, config, models);
385
+ }
383
386
  else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
384
387
  // Non-bundle targets are the background-refresh / fast-open path: configs,
385
388
  // token helper, catalog, and manifest only. Bundle swaps require the app
@@ -550,6 +553,7 @@ function installPinnedVendorApp(target, homeDir) {
550
553
  homeDir, ".config", "impel", "vendor", target, pin.version, pin.bundleName,
551
554
  );
552
555
  fs.mkdirSync(path.dirname(destination), { recursive: true });
556
+ sweepStaleBundleArtifacts(path.dirname(destination), path.basename(destination));
553
557
  replaceDirectory(destination, extractedApp);
554
558
  return destination;
555
559
  } finally {
@@ -646,6 +650,33 @@ function writeClaudeConfig(paths, config, models) {
646
650
  }, null, 2) + "\n", 0o600);
647
651
  }
648
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
+
649
680
  function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocations = null) {
650
681
  const experimental = crossAppModelsEnabled(config);
651
682
  const orderedModels = experimental
@@ -941,6 +972,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
941
972
 
942
973
  const bundle = paths.claude.launcher;
943
974
  const staging = `${bundle}.tmp-${process.pid}`;
975
+ sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
944
976
  fs.rmSync(staging, { recursive: true, force: true });
945
977
  cloneAppBundle(vendorPath, staging);
946
978
  try {
@@ -1027,6 +1059,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1027
1059
  const staging = `${bundle}.tmp-${process.pid}`;
1028
1060
  const vendorBundleName = path.basename(vendorPath);
1029
1061
  const vendorBundle = path.join(staging, "Contents", "Resources", vendorBundleName);
1062
+ sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1030
1063
  fs.rmSync(staging, { recursive: true, force: true });
1031
1064
  cloneAppBundle(vendorPath, vendorBundle);
1032
1065
  try {
@@ -1653,6 +1686,22 @@ function sha256(contents) {
1653
1686
  return crypto.createHash("sha256").update(contents).digest("hex");
1654
1687
  }
1655
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
+
1656
1705
  function replaceDirectory(destination, staging) {
1657
1706
  const backup = `${destination}.previous-${process.pid}`;
1658
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
+ }