impel-cli 0.7.3 → 0.8.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
@@ -145,18 +145,22 @@ impel off [claude|codex|all] Alias for `impel use account`
145
145
  impel skills sync [claude|codex|all] Sync Bifrost shared skills into managed clients (default: all)
146
146
 
147
147
  impel status Launcher readiness + native mode + gateway reachability
148
- impel app install [claude|chatgpt|codex|all] Install isolated apps
149
- impel app update [claude|chatgpt|codex|all] Refresh apps, configs, and models
150
- impel app refresh [claude|chatgpt|codex|all] Configs/catalog/skills only; safe while apps run
148
+ impel app install [target] [--tenant <org>] Install isolated apps for one tenant
149
+ impel app update [target] [--tenant <org>] Refresh that tenant's apps, configs, and models
150
+ impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
151
151
  (--stale-only: no-op unless 6h+ since last sync)
152
- impel app status [claude|chatgpt|codex|all] Show launcher/vendor status
153
- impel app open [claude|chatgpt|codex|all] Launch isolated instances (fast path: opens
152
+ impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
153
+ impel app open [target] [--tenant <org>] Launch isolated instances (fast path: opens
154
154
  immediately when nothing changed since last open;
155
155
  a detached stale-only refresh keeps things current)
156
- impel app uninstall [target] [--keep-data] Remove Impel-managed app files
156
+ impel app uninstall [target] [--tenant <org>] [--keep-data]
157
+ Remove one tenant's Impel-managed app files
157
158
  impel help Show help
158
159
  impel --version Show version
159
160
 
161
+ For app commands, `target` is `claude`, `chatgpt`/`codex`, or `all` (the
162
+ default). `--tenant` defaults to the currently selected organization.
163
+
160
164
  # `impel setup` no longer takes a per-tool target and never flips native
161
165
  # configs; flip a native tool explicitly with `impel use gateway [claude|codex]`.
162
166
  ```
@@ -237,12 +241,14 @@ not bleed between tenants. `impel tenant use <org> --launch claude|codex|apps`
237
241
  selects and launches in one command.
238
242
 
239
243
  On macOS, desktop app state is isolated under
240
- `~/.config/impel/apps/tenants/<org>/`. The app bundle in `~/Applications` is a
241
- launcher for the currently opened tenant; `impel app open` refreshes that
242
- launcher, the tenant-scoped bearer, model catalog, and specialist MCP arguments
243
- before starting a new isolated instance. Switching tenants never deletes local
244
- history or projects; switch back to the original tenant to reopen that tenant's
245
- preserved profile. Windows Claude Desktop keeps each
244
+ `~/.config/impel/apps/tenants/<org>/`. Every installed tenant keeps its own app
245
+ bundles in `~/Applications`, with names such as `Impel Claude (Acme)` and
246
+ `Impel ChatGPT (Acme)`, stable tenant-specific bundle identifiers, and fixed
247
+ tenant profile/auth paths. Different tenants can stay open concurrently.
248
+ `impel app open --tenant <org>` refreshes and launches a tenant directly without
249
+ changing the globally selected tenant. Switching tenants never deletes local
250
+ history or projects; each tenant's preserved profile reopens independently.
251
+ Windows Claude Desktop keeps each
246
252
  tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
247
253
  signed vendor executable with that profile selected. Windows ChatGPT keeps its
248
254
  Codex and browser state under the same tenant-scoped Impel apps root used on
@@ -577,21 +583,24 @@ app-target names.
577
583
 
578
584
  ### macOS Claude and ChatGPT
579
585
 
580
- `impel app install` creates `Impel Claude.app` and `Impel ChatGPT.app` in
581
- `~/Applications`. They have separate bundle identities and gateway
582
- configuration:
586
+ `impel app install` creates tenant-specific apps such as
587
+ `Impel Claude (acme).app` and `Impel ChatGPT (acme).app` in `~/Applications`.
588
+ Their visible bundle names use the organization display name, for example
589
+ `Impel Claude (Acme)` and `Impel ChatGPT (Acme)`. Each tenant/product pair has
590
+ a stable, unique bundle identifier and gateway configuration:
583
591
 
584
592
  - Impel Claude is an APFS-cloned vendored copy of the official app with its own
585
593
  bundle and helper identities. Its LaunchServices environment sets the
586
- vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway `configLibrary` below
587
- `~/.config/impel/apps/claude`. A narrowly version-checked compatibility patch
588
- connects Claude's native plan-usage meter to the authenticated aggregate
589
- Claude subscription pool exposed by the Impel gateway.
594
+ vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway
595
+ `configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A narrowly
596
+ version-checked compatibility patch connects Claude's native plan-usage meter
597
+ to the authenticated aggregate Claude subscription pool exposed by the Impel
598
+ gateway.
590
599
  - Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
591
600
  OpenAI-signed bundle and executable. Its outer resource root contains the
592
601
  narrowly patched renderer ASAR used at runtime. The wrapper sets `CODEX_HOME`,
593
602
  passes a separate Chromium `--user-data-dir`, and writes its provider config
594
- and model catalog below `~/.config/impel/apps/chatgpt`. It also supplies a
603
+ and model catalog below `~/.config/impel/apps/tenants/<org>/chatgpt`. It also supplies a
595
604
  process-scoped, Codex-compatible wrapper around the Impel PAT so native
596
605
  `/status` requests can read the organization's aggregated subscription pool
597
606
  from the gateway. Its generated Codex config uses the same snapshot and
@@ -601,9 +610,22 @@ configuration:
601
610
  - Neither app changes `~/.claude`, `~/.claude.json`, `~/.codex`, or the
602
611
  normal vendor app's browser/profile data.
603
612
 
613
+ Install or open several tenants without changing the current selection:
614
+
615
+ ```sh
616
+ impel app install all --tenant acme
617
+ impel app install all --tenant globex
618
+ impel app open all --tenant acme
619
+ impel app open all --tenant globex
620
+ ```
621
+
622
+ Updates and uninstalls touch only the named tenant variant. The older global
623
+ `Impel Claude.app` / `Impel ChatGPT.app` launcher is removed after the first
624
+ successful tenant-specific rebuild.
625
+
604
626
  The official Claude and ChatGPT apps remain separate installations and keep
605
627
  their normal accounts, updater, profiles, and bundle identities. Each CLI
606
- release pins exact, tested vendor builds (v0.7.2 pins Claude `1.20186.9` and
628
+ release pins exact, tested vendor builds (v0.8.0 pins Claude `1.20186.9` and
607
629
  ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
608
630
  an existing official app only when its version exactly matches; otherwise it
609
631
  downloads the pinned signed archive into Impel's managed vendor cache under
@@ -715,3 +737,15 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
715
737
  - https://developers.openai.com/codex/config-advanced
716
738
  - https://developers.openai.com/codex/auth
717
739
  - https://developers.openai.com/codex/ide
740
+
741
+ ## Sources consulted for concurrent tenant apps
742
+
743
+ - Electron v43.1.0 release: https://github.com/electron/electron/releases/tag/v43.1.0
744
+ - Electron applies `--user-data-dir` before app JavaScript:
745
+ https://github.com/electron/electron/blob/b5c102b3f0f7e9c5be064ba337a3547a83cf7d09/shell/app/electron_main_delegate.cc#L247-L256
746
+ - Electron scopes its single-instance lock to the resolved user-data directory:
747
+ https://github.com/electron/electron/blob/b5c102b3f0f7e9c5be064ba337a3547a83cf7d09/shell/browser/api/electron_api_app.cc#L1049-L1073
748
+ - Electron macOS bundle rebranding requirements:
749
+ https://github.com/electron/electron/blob/b5c102b3f0f7e9c5be064ba337a3547a83cf7d09/docs/tutorial/application-distribution.md#L79-L96
750
+ - Apple `CFBundleIdentifier` reference:
751
+ https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleidentifier
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.7.3",
3
+ "version": "0.8.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/apps.js CHANGED
@@ -21,11 +21,13 @@ const CLI_VERSION = JSON.parse(
21
21
  const APP_DEFINITIONS = {
22
22
  claude: {
23
23
  label: "Impel Claude",
24
+ bundleIdentifier: "com.useimpel.claude",
24
25
  names: ["Claude.app"],
25
26
  executableNames: ["Claude"],
26
27
  },
27
28
  chatgpt: {
28
29
  label: "Impel ChatGPT",
30
+ bundleIdentifier: "com.useimpel.chatgpt",
29
31
  names: ["ChatGPT.app", "Codex.app"],
30
32
  executableNames: ["ChatGPT", "Codex"],
31
33
  },
@@ -103,9 +105,44 @@ export function normalizeAppTarget(value) {
103
105
  return null;
104
106
  }
105
107
 
108
+ function tenantAppName(value, fallback) {
109
+ const name = redactSecretText(String(value || fallback))
110
+ .normalize("NFC")
111
+ .replace(/[/:]/gu, "-")
112
+ .replace(/\s+/gu, " ")
113
+ .trim()
114
+ .slice(0, 128);
115
+ return name || fallback;
116
+ }
117
+
118
+ /** Stable macOS name and LaunchServices identity for one tenant-bound app. */
119
+ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
120
+ const definition = APP_DEFINITIONS[target];
121
+ if (!definition) throw new Error(`unknown app target "${redactSecretText(target)}"`);
122
+ if (!tenantId) {
123
+ return {
124
+ displayName: definition.label,
125
+ launcherName: `${definition.label}.app`,
126
+ bundleIdentifier: definition.bundleIdentifier,
127
+ helperBundleIdentifier: `${definition.bundleIdentifier}.helper`,
128
+ };
129
+ }
130
+ const normalizedTenantId = normalizeTenantId(tenantId);
131
+ const displayName = `${definition.label} (${tenantAppName(tenantName, normalizedTenantId)})`;
132
+ const tenantKey = crypto.createHash("sha256").update(normalizedTenantId, "utf8").digest("hex").slice(0, 20);
133
+ return {
134
+ displayName,
135
+ // Keep the on-disk path stable if an organization is renamed; Finder,
136
+ // Launchpad, and the Dock use CFBundleDisplayName below for the human name.
137
+ launcherName: `${definition.label} (${normalizedTenantId}).app`,
138
+ bundleIdentifier: `${definition.bundleIdentifier}.tenant.${tenantKey}`,
139
+ helperBundleIdentifier: `${definition.bundleIdentifier}.tenant.${tenantKey}.helper`,
140
+ };
141
+ }
142
+
106
143
  // Bump when the written config/manifest schema changes; a mismatch forces the
107
144
  // slow open path (and thus a full config rewrite) after a CLI update.
108
- export const CURRENT_CONFIG_VERSION = 8;
145
+ export const CURRENT_CONFIG_VERSION = 9;
109
146
 
110
147
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
111
148
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -144,6 +181,8 @@ export function bundleIsCurrent(status) {
144
181
  compatibility
145
182
  && compatibility.cliVersion === CLI_VERSION
146
183
  && compatibility.vendorVersion === status.vendorVersion
184
+ && compatibility.bundleIdentifier === status.bundleIdentifier
185
+ && compatibility.displayName === status.label
147
186
  && compatibility.profileRoot === status.profileRoot
148
187
  && (status.target !== "chatgpt" || (
149
188
  compatibility.codexHome === status.codexHome
@@ -182,24 +221,28 @@ function managedBundleMatchesCompatibility(status, compatibility) {
182
221
  // and codesign failures.
183
222
 
184
223
  /** osascript application names whose bundles install/update will touch. */
185
- export function blockingAppNames(targets) {
224
+ export function blockingAppNames(targets, { tenantId = null, tenantName = null } = {}) {
186
225
  const names = [];
187
226
  for (const target of targets) {
188
227
  const definition = APP_DEFINITIONS[target];
189
228
  for (const bundleName of definition.names) names.push(bundleName.replace(/\.app$/u, ""));
190
- names.push(definition.label);
229
+ names.push(managedAppIdentity(target, tenantId, tenantName).launcherName.replace(/\.app$/u, ""));
230
+ // The pre-v0.8 launcher was global. Close it too while a tenant-specific
231
+ // bundle replaces/migrates it, but never close another tenant's variant.
232
+ if (tenantId) names.push(definition.label);
191
233
  }
192
- return names;
234
+ return [...new Set(names)];
193
235
  }
194
236
 
195
237
  /**
196
238
  * pgrep/pkill -f pattern for one app's processes. Anchored on
197
- * "/<Name>.app/Contents/MacOS/" so "Claude" never matches the space-prefixed
198
- * "Impel Claude.app" path (and vice versa).
239
+ * "/<Name>.app/.../Contents/MacOS/" so "Claude" never matches the
240
+ * space-prefixed tenant launcher (and vice versa), while nested Electron
241
+ * executables and helpers still count as processes belonging to the wrapper.
199
242
  */
200
243
  export function appProcessPattern(name) {
201
244
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
202
- return `/${escaped}\\.app/Contents/MacOS/`;
245
+ return `/${escaped}\\.app/(.*/)?Contents/MacOS/`;
203
246
  }
204
247
 
205
248
  const QUIT_POLL_MS = 500;
@@ -210,6 +253,8 @@ const QUIT_POLL_MS = 500;
210
253
  * telling the user to close it themselves if even that fails.
211
254
  */
212
255
  export async function quitBlockingApps(targets, {
256
+ tenantId = null,
257
+ tenantName = null,
213
258
  log = console.log,
214
259
  isRunning = (name) =>
215
260
  spawnSync("/usr/bin/pgrep", ["-f", appProcessPattern(name)], { stdio: "ignore" }).status === 0,
@@ -234,7 +279,7 @@ export async function quitBlockingApps(targets, {
234
279
  return !isRunning(name);
235
280
  }
236
281
 
237
- for (const name of blockingAppNames(targets)) {
282
+ for (const name of blockingAppNames(targets, { tenantId, tenantName })) {
238
283
  if (!isRunning(name)) continue;
239
284
  log(`${name} is running; closing it before touching app bundles…`);
240
285
  requestQuit(name);
@@ -246,26 +291,33 @@ export async function quitBlockingApps(targets, {
246
291
  }
247
292
  }
248
293
 
249
- export function appPaths(homeDir = os.homedir(), tenantId = null, { claudeUserData = null } = {}) {
294
+ export function appPaths(homeDir = os.homedir(), tenantId = null, {
295
+ claudeUserData = null,
296
+ tenantName = null,
297
+ } = {}) {
250
298
  const root = process.env.IMPEL_APP_HOME || path.join(homeDir, ".config", "impel", "apps");
251
299
  const tenantRoot = tenantId
252
300
  ? path.join(root, "tenants", normalizeTenantId(tenantId))
253
301
  : root;
302
+ const claudeIdentity = managedAppIdentity("claude", tenantId, tenantName);
303
+ const chatgptIdentity = managedAppIdentity("chatgpt", tenantId, tenantName);
254
304
  return {
255
305
  root,
256
306
  tenantRoot,
257
307
  tokenHelper: path.join(tenantRoot, "bin", "token"),
258
308
  claude: {
309
+ ...claudeIdentity,
259
310
  root: claudeUserData || path.join(tenantRoot, "claude"),
260
311
  userData: claudeUserData || path.join(tenantRoot, "claude", "user-data"),
261
- launcher: path.join(homeDir, "Applications", "Impel Claude.app"),
312
+ launcher: path.join(homeDir, "Applications", claudeIdentity.launcherName),
262
313
  },
263
314
  chatgpt: {
315
+ ...chatgptIdentity,
264
316
  root: path.join(tenantRoot, "chatgpt"),
265
317
  codexHome: path.join(tenantRoot, "chatgpt", "codex-home"),
266
318
  browserData: path.join(tenantRoot, "chatgpt", "browser-data"),
267
319
  catalog: path.join(tenantRoot, "chatgpt", "codex-home", "models.json"),
268
- launcher: path.join(homeDir, "Applications", "Impel ChatGPT.app"),
320
+ launcher: path.join(homeDir, "Applications", chatgptIdentity.launcherName),
269
321
  },
270
322
  };
271
323
  }
@@ -309,7 +361,10 @@ export function installManagedAppFiles({
309
361
  // true = rebuild every target's bundle; false = configs only; an array
310
362
  // limits the expensive rebuild to just the stale targets.
311
363
  const bundleTargets = new Set(writeBundles === true ? targets : writeBundles || []);
312
- const paths = appPaths(homeDir, config.tenantId, { claudeUserData });
364
+ const paths = appPaths(homeDir, config.tenantId, {
365
+ claudeUserData,
366
+ tenantName: config.tenantName,
367
+ });
313
368
  if (targets.includes("chatgpt")) secureAllManagedCodexHomes({ appsRoot: paths.root });
314
369
  fs.mkdirSync(paths.tenantRoot, { recursive: true, mode: 0o700 });
315
370
  if (writeTokenHelperFile) writeTokenHelper(paths.tokenHelper, config.tenantId);
@@ -334,16 +389,22 @@ export function installManagedAppFiles({
334
389
  }
335
390
  if (target === "chatgpt") writeVendoredChatGPTBundle(paths, resolvedVendorPaths.chatgpt, config.gatewayUrl);
336
391
  else writeVendoredClaudeBundle(paths, resolvedVendorPaths.claude, config.gatewayUrl);
392
+ if (config.tenantId) {
393
+ // A successful tenant-specific rebuild supersedes the old global
394
+ // launcher. Other tenant launchers have distinct names and survive.
395
+ fs.rmSync(appPaths(homeDir)[target].launcher, { recursive: true, force: true });
396
+ }
337
397
  }
338
398
  installed.push({ target, launcher: paths[target].launcher });
339
399
  }
340
400
 
341
401
  writeAtomic(path.join(paths.tenantRoot, "manifest.json"), JSON.stringify({
342
- schemaVersion: 3,
402
+ schemaVersion: 4,
343
403
  configVersion: CURRENT_CONFIG_VERSION,
344
404
  modelCatalogVersion: 3,
345
405
  gatewayUrl: config.gatewayUrl,
346
406
  tenantId: config.tenantId,
407
+ tenantName: config.tenantName || config.tenantId || null,
347
408
  experiments: { crossAppModels: crossAppModelsEnabled(config) },
348
409
  targets,
349
410
  models: models.map((model) => model.id),
@@ -518,18 +579,22 @@ export function ensureVendorApp(target, { homeDir = os.homedir() } = {}) {
518
579
  return { path: installed, action: "installed", note: `verified ${pin.version}` };
519
580
  }
520
581
 
521
- export function appStatus(targets, homeDir = os.homedir(), tenantId = null) {
522
- const paths = appPaths(homeDir, tenantId);
582
+ export function appStatus(targets, homeDir = os.homedir(), tenantId = null, tenantName = null) {
583
+ const manifestName = tenantId && !tenantName
584
+ ? readTenantManifest(homeDir, tenantId)?.tenantName
585
+ : null;
586
+ const paths = appPaths(homeDir, tenantId, { tenantName: tenantName || manifestName });
523
587
  return targets.map((target) => {
524
588
  const vendorPath = findVendorApp(target, homeDir);
525
589
  return {
526
590
  target,
527
- label: APP_DEFINITIONS[target].label,
591
+ label: paths[target].displayName,
528
592
  launcher: paths[target].launcher,
529
593
  launcherInstalled: fs.existsSync(paths[target].launcher),
594
+ bundleIdentifier: paths[target].bundleIdentifier,
595
+ profileRoot: target === "claude" ? paths.claude.userData : paths.chatgpt.root,
530
596
  vendorPath,
531
597
  vendorVersion: vendorPath ? bundleVersion(vendorPath) : null,
532
- profileRoot: target === "claude" ? paths.claude.userData : paths.chatgpt.root,
533
598
  codexHome: target === "chatgpt" ? paths.chatgpt.codexHome : null,
534
599
  browserData: target === "chatgpt" ? paths.chatgpt.browserData : null,
535
600
  configPath: target === "claude"
@@ -543,9 +608,10 @@ export function removeManagedApps(targets, {
543
608
  homeDir = os.homedir(),
544
609
  keepData = false,
545
610
  tenantId = null,
611
+ tenantName = null,
546
612
  claudeUserData = null,
547
613
  } = {}) {
548
- const paths = appPaths(homeDir, tenantId, { claudeUserData });
614
+ const paths = appPaths(homeDir, tenantId, { claudeUserData, tenantName });
549
615
  for (const target of targets) {
550
616
  fs.rmSync(paths[target].launcher, { recursive: true, force: true });
551
617
  if (!keepData) fs.rmSync(paths[target].root, { recursive: true, force: true });
@@ -852,13 +918,14 @@ function writeTokenHelper(target, tenantId) {
852
918
  const node = shellQuote(process.execPath);
853
919
  const cli = shellQuote(cliPath);
854
920
  const tenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
921
+ const refreshTenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
855
922
  // The apps invoke this helper whenever they need auth, which makes it the
856
923
  // one hook that fires on Finder/Dock launches too. Piggyback a detached,
857
924
  // TTL-gated config/catalog/skills refresh (`app refresh --stale-only`) so
858
925
  // the managed apps keep themselves current without touching stdout — the
859
926
  // caller consumes stdout as the token.
860
927
  const script = `#!/bin/sh
861
- (${node} ${cli} app refresh --stale-only </dev/null >/dev/null 2>&1 &)
928
+ (${node} ${cli} app refresh --stale-only${refreshTenantArgs} </dev/null >/dev/null 2>&1 &)
862
929
  exec ${node} ${cli} token${tenantArgs}
863
930
  `;
864
931
  writeAtomic(target, script, 0o700);
@@ -900,27 +967,28 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
900
967
  const newAsarHash = asarHeaderHash(asarPath);
901
968
 
902
969
  updateBundleIdentity(plistPath, {
903
- displayName: "Impel Claude",
970
+ displayName: paths.claude.displayName,
904
971
  executable: executableName,
905
- identifier: "com.useimpel.claude",
906
- name: "Impel Claude",
972
+ identifier: paths.claude.bundleIdentifier,
973
+ name: paths.claude.displayName,
907
974
  });
908
975
  updateAsarIntegrityPlist(plistPath, oldAsarHash, newAsarHash);
909
976
  setPlistEnvironmentString(plistPath, "CLAUDE_USER_DATA_DIR", paths.claude.userData);
910
977
  rebrandElectronHelpers(staging, {
911
978
  fromName: "Claude",
912
- toName: "Impel Claude",
979
+ toName: paths.claude.displayName,
913
980
  fromIdentifier: "com.anthropic.claudefordesktop.helper",
914
- toIdentifier: "com.useimpel.claude.helper",
981
+ toIdentifier: paths.claude.helperBundleIdentifier,
915
982
  });
916
983
 
917
984
  const macos = path.join(staging, "Contents", "MacOS");
918
985
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
919
- schemaVersion: 1,
986
+ schemaVersion: 2,
920
987
  cliVersion: CLI_VERSION,
921
988
  vendorVersion: bundleVersion(vendorPath),
922
989
  sourceBundle: vendorPath,
923
- bundleIdentifier: "com.useimpel.claude",
990
+ bundleIdentifier: paths.claude.bundleIdentifier,
991
+ displayName: paths.claude.displayName,
924
992
  profileRoot: paths.claude.userData,
925
993
  launchMode: "LSEnvironment",
926
994
  patches: {
@@ -984,7 +1052,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
984
1052
  const asarHash = asarHeaderHash(runtimeAsarPath);
985
1053
  writeAtomic(
986
1054
  path.join(staging, "Contents", "Info.plist"),
987
- vendoredChatGPTWrapperPlist(vendorPath, asarHash),
1055
+ vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt),
988
1056
  0o644,
989
1057
  );
990
1058
  writeAtomic(
@@ -993,10 +1061,12 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
993
1061
  0o755,
994
1062
  );
995
1063
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
996
- schemaVersion: 1,
1064
+ schemaVersion: 2,
997
1065
  cliVersion: CLI_VERSION,
998
1066
  vendorVersion: bundleVersion(vendorPath),
999
1067
  sourceBundle: vendorPath,
1068
+ bundleIdentifier: paths.chatgpt.bundleIdentifier,
1069
+ displayName: paths.chatgpt.displayName,
1000
1070
  profileRoot: paths.chatgpt.root,
1001
1071
  codexHome: paths.chatgpt.codexHome,
1002
1072
  browserData: paths.chatgpt.browserData,
@@ -1265,15 +1335,15 @@ function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toId
1265
1335
  }
1266
1336
  }
1267
1337
 
1268
- function vendoredChatGPTWrapperPlist(vendorPath, asarHash) {
1338
+ function vendoredChatGPTWrapperPlist(vendorPath, asarHash, identity) {
1269
1339
  return `<?xml version="1.0" encoding="UTF-8"?>
1270
1340
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1271
1341
  <plist version="1.0"><dict>
1272
- <key>CFBundleDisplayName</key><string>Impel ChatGPT</string>
1342
+ <key>CFBundleDisplayName</key><string>${xmlEscape(identity.displayName)}</string>
1273
1343
  <key>CFBundleExecutable</key><string>launch</string>
1274
1344
  <key>CFBundleIconFile</key><string>electron.icns</string>
1275
- <key>CFBundleIdentifier</key><string>com.useimpel.chatgpt</string>
1276
- <key>CFBundleName</key><string>Impel ChatGPT</string>
1345
+ <key>CFBundleIdentifier</key><string>${xmlEscape(identity.bundleIdentifier)}</string>
1346
+ <key>CFBundleName</key><string>${xmlEscape(identity.displayName)}</string>
1277
1347
  <key>CFBundlePackageType</key><string>APPL</string>
1278
1348
  <key>CFBundleShortVersionString</key><string>${xmlEscape(bundleVersion(vendorPath) || "1")}</string>
1279
1349
  <key>ElectronAsarIntegrity</key><dict><key>Resources/app.asar</key><dict><key>algorithm</key><string>SHA256</string><key>hash</key><string>${xmlEscape(asarHash)}</string></dict></dict>
@@ -1372,9 +1442,14 @@ function signVendoredClaudeCapabilities(bundle) {
1372
1442
  try {
1373
1443
  const frameworks = path.join(bundle, "Contents", "Frameworks");
1374
1444
  const helpers = fs.readdirSync(frameworks, { withFileTypes: true })
1375
- .filter((entry) => entry.isDirectory() && /^Impel Claude Helper(?: \([^)]+\))?\.app$/u.test(entry.name));
1445
+ .filter((entry) => (
1446
+ entry.isDirectory()
1447
+ && entry.name.startsWith("Impel Claude")
1448
+ && entry.name.includes(" Helper")
1449
+ && entry.name.endsWith(".app")
1450
+ ));
1376
1451
  for (const helper of helpers) {
1377
- const entitlements = helper.name === "Impel Claude Helper.app" ? fullPath : jitPath;
1452
+ const entitlements = helper.name.endsWith(" Helper.app") ? fullPath : jitPath;
1378
1453
  const result = codesign([
1379
1454
  "--force", "--deep", "--options", "runtime", "--entitlements", entitlements, "--sign", "-",
1380
1455
  path.join(frameworks, helper.name),
package/src/cli.js CHANGED
@@ -28,7 +28,7 @@ Get started:
28
28
  Run:
29
29
  impel claude [args...] Launch Claude Code with an isolated Impel profile
30
30
  impel codex [args...] Launch Codex with an isolated Impel profile
31
- impel app open [claude|chatgpt|codex|all] Open the isolated Impel desktop app(s)
31
+ impel app open [target] [--tenant <org>] Open that tenant's isolated desktop app(s)
32
32
  impel status Tenant, PAT, per-tool mode, gateway reachability
33
33
  impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
34
34
 
@@ -41,11 +41,13 @@ Manage:
41
41
  impel skills sync [claude|codex|all] Sync shared skills into managed clients
42
42
 
43
43
  Desktop apps (macOS and Windows):
44
- impel app install [claude|chatgpt|codex|all] Install the isolated Impel desktop app/profile
45
- impel app update [claude|chatgpt|codex|all] Update vendor apps and managed profiles
46
- impel app refresh [claude|chatgpt|codex|all] Configs/catalog/skills only; safe while apps run
47
- impel app status [claude|chatgpt|codex|all] Show isolated app and vendor versions
48
- impel app uninstall [target] [--keep-data] Remove only Impel-managed apps/data
44
+ target is claude, chatgpt/codex, or all (default: all)
45
+ impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
46
+ impel app update [target] [--tenant <org>] Update vendor apps and managed profiles
47
+ impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
48
+ impel app status [target] [--tenant <org>] Show isolated app and vendor versions
49
+ impel app uninstall [target] [--tenant <org>] [--keep-data]
50
+ Remove only that tenant's managed apps/data
49
51
 
50
52
  Native profile switching (advanced; setup never touches native tools):
51
53
  impel use gateway [claude|codex|all] Route native Claude Code/Codex through the gateway
@@ -15,7 +15,13 @@ import {
15
15
  refreshUpdateCache,
16
16
  spawnDetachedAppRefresh,
17
17
  } from "../updates.js";
18
- import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
18
+ import {
19
+ assertProviderScopes,
20
+ ensureTenantSelection,
21
+ fetchTenants,
22
+ normalizeTenantId,
23
+ tenantCredential,
24
+ } from "../tenants.js";
19
25
  import {
20
26
  CURRENT_CONFIG_VERSION,
21
27
  CLAUDE_CONFIG_ID,
@@ -111,6 +117,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
111
117
  "skip-vendor": { type: "boolean" },
112
118
  "keep-data": { type: "boolean" },
113
119
  "stale-only": { type: "boolean" },
120
+ tenant: { type: "string" },
114
121
  });
115
122
  const io = {
116
123
  environment: process.env,
@@ -140,13 +147,20 @@ export async function cmdWindowsApps(argv, overrides = {}) {
140
147
  if (overrides.launchApp) io.launchClaudeApp = overrides.launchApp;
141
148
  if (overrides.userData) io.claudeUserData = overrides.userData;
142
149
  const stored = loadConfig();
143
- const storedUserData = io.claudeUserData(io.environment, stored?.tenantId || null);
144
- const paths = appPaths(io.homeDir, stored?.tenantId || null, { claudeUserData: storedUserData });
150
+ const localTenantId = flags.tenant
151
+ ? normalizeTenantId(flags.tenant)
152
+ : stored?.tenantId || null;
153
+ const localTenantName = localTenantId === stored?.tenantId ? stored?.tenantName : null;
154
+ const storedUserData = io.claudeUserData(io.environment, localTenantId);
155
+ const paths = appPaths(io.homeDir, localTenantId, {
156
+ claudeUserData: storedUserData,
157
+ tenantName: localTenantName,
158
+ });
145
159
  const claudeConfigPath = path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`);
146
160
  const chatgptConfigPath = path.join(paths.chatgpt.codexHome, "config.toml");
147
161
 
148
162
  if (action === "status") {
149
- console.log(`Tenant: ${stored?.tenantId || "not selected"}`);
163
+ console.log(`Tenant: ${localTenantId || "not selected"}`);
150
164
  if (targets.includes("claude")) {
151
165
  console.log("Impel Claude:");
152
166
  console.log(` profile: ${fs.existsSync(claudeConfigPath) ? paths.claude.userData : "not installed"}`);
@@ -167,7 +181,8 @@ export async function cmdWindowsApps(argv, overrides = {}) {
167
181
  io.removeApps(targets, {
168
182
  homeDir: io.homeDir,
169
183
  keepData: Boolean(flags["keep-data"]),
170
- tenantId: stored?.tenantId || null,
184
+ tenantId: localTenantId,
185
+ tenantName: localTenantName,
171
186
  claudeUserData: storedUserData,
172
187
  });
173
188
  if (targets.includes("chatgpt")) io.removeManagedChatGPTApp(paths.root);
@@ -186,9 +201,12 @@ export async function cmdWindowsApps(argv, overrides = {}) {
186
201
 
187
202
  try {
188
203
  if (action === "open") maybePrintUpdateNotice();
189
- const config = await io.selectedConfig(targets);
204
+ const config = await io.selectedConfig(targets, flags.tenant || null);
190
205
  const actionUserData = io.claudeUserData(io.environment, config.tenantId);
191
- const actionPaths = appPaths(io.homeDir, config.tenantId, { claudeUserData: actionUserData });
206
+ const actionPaths = appPaths(io.homeDir, config.tenantId, {
207
+ claudeUserData: actionUserData,
208
+ tenantName: config.tenantName,
209
+ });
192
210
  const vendorPaths = {};
193
211
  for (const target of targets) {
194
212
  const isClaude = target === "claude";
@@ -230,7 +248,10 @@ export async function cmdWindowsApps(argv, overrides = {}) {
230
248
  }
231
249
 
232
250
  const { userData } = configureWindowsApps(config, targets, vendorPaths, catalog, io);
233
- const tenantPaths = appPaths(io.homeDir, config.tenantId, { claudeUserData: userData });
251
+ const tenantPaths = appPaths(io.homeDir, config.tenantId, {
252
+ claudeUserData: userData,
253
+ tenantName: config.tenantName,
254
+ });
234
255
  if (action === "install" || action === "update" || action === "refresh") {
235
256
  const verb = action === "install" ? "Installed" : action === "update" ? "Updated" : "Refreshed";
236
257
  for (const target of targets) {
@@ -282,17 +303,25 @@ export async function cmdApps(argv, overrides = {}) {
282
303
  "skip-vendor": { type: "boolean" },
283
304
  "keep-data": { type: "boolean" },
284
305
  "stale-only": { type: "boolean" },
306
+ tenant: { type: "string" },
285
307
  });
286
308
 
287
- if (action === "status") return printStatus(targets);
309
+ if (action === "status") return printStatus(targets, flags.tenant || null);
288
310
  if (action === "refresh") {
289
- return refreshApps(targets, { staleOnly: Boolean(flags["stale-only"]) });
311
+ return refreshApps(targets, {
312
+ staleOnly: Boolean(flags["stale-only"]),
313
+ tenantId: flags.tenant || null,
314
+ });
290
315
  }
291
316
  if (action === "uninstall") {
292
317
  const config = loadConfig();
318
+ const tenantId = flags.tenant
319
+ ? normalizeTenantId(flags.tenant)
320
+ : config?.tenantId || null;
293
321
  removeManagedApps(targets, {
294
322
  keepData: Boolean(flags["keep-data"]),
295
- tenantId: config?.tenantId || null,
323
+ tenantId,
324
+ tenantName: tenantId === config?.tenantId ? config?.tenantName : null,
296
325
  });
297
326
  for (const target of targets) console.log(`Removed Impel ${target} app${flags["keep-data"] ? " (kept isolated data)" : " and isolated data"}.`);
298
327
  return;
@@ -304,19 +333,36 @@ export async function cmdApps(argv, overrides = {}) {
304
333
  // credentials, there is nothing to fetch or rebuild — launch immediately
305
334
  // and let a detached stale-only refresh keep configs/catalog/skills
306
335
  // converging in the background.
307
- const fastLaunchers = fastOpenLaunchers(targets);
336
+ const stored = loadConfig();
337
+ const tenantId = flags.tenant
338
+ ? normalizeTenantId(flags.tenant)
339
+ : stored?.tenantId || null;
340
+ const manifest = tenantId ? readTenantManifest(os.homedir(), tenantId) : null;
341
+ const openConfig = stored && tenantId
342
+ ? {
343
+ ...stored,
344
+ tenantId,
345
+ tenantName: tenantId === stored.tenantId
346
+ ? manifest?.tenantName || stored.tenantName || tenantId
347
+ : manifest?.tenantName || tenantId,
348
+ }
349
+ : stored;
350
+ const fastLaunchers = fastOpenLaunchers(targets, { config: openConfig });
308
351
  if (fastLaunchers) {
309
- spawnDetachedAppRefresh();
352
+ spawnDetachedAppRefresh(tenantId);
310
353
  for (const launcher of fastLaunchers) {
311
354
  spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" });
312
355
  }
313
356
  return;
314
357
  }
315
358
 
316
- const config = await selectedAppConfig(targets);
317
- const statuses = appStatus(targets, os.homedir(), config.tenantId);
359
+ const config = await selectedAppConfig(targets, flags.tenant || null);
360
+ const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
361
+ const legacyPaths = appPaths(os.homedir());
318
362
  for (const status of statuses) {
319
- if (!status.launcherInstalled) throw new Error(`${status.label} is not installed; run \`impel app install ${status.target}\``);
363
+ if (!status.launcherInstalled && !fs.existsSync(legacyPaths[status.target].launcher)) {
364
+ throw new Error(`${status.label} is not installed; run \`impel app install ${status.target}\``);
365
+ }
320
366
  if (!status.vendorPath) throw new Error(`${status.label} vendor app is unavailable; reinstall the vendor app first`);
321
367
  }
322
368
  let catalog;
@@ -332,7 +378,12 @@ export async function cmdApps(argv, overrides = {}) {
332
378
  const staleBundleTargets = statuses
333
379
  .filter((status) => !bundleIsCurrent(status))
334
380
  .map((status) => status.target);
335
- if (staleBundleTargets.length > 0) await quitBlockingApps(staleBundleTargets);
381
+ if (staleBundleTargets.length > 0) {
382
+ await quitBlockingApps(staleBundleTargets, {
383
+ tenantId: config.tenantId,
384
+ tenantName: config.tenantName,
385
+ });
386
+ }
336
387
  installManagedAppFiles({
337
388
  config,
338
389
  targets,
@@ -341,7 +392,7 @@ export async function cmdApps(argv, overrides = {}) {
341
392
  vendorPaths: Object.fromEntries(statuses.map((status) => [status.target, status.vendorPath])),
342
393
  writeBundles: staleBundleTargets,
343
394
  });
344
- for (const status of appStatus(targets, os.homedir(), config.tenantId)) {
395
+ for (const status of appStatus(targets, os.homedir(), config.tenantId, config.tenantName)) {
345
396
  spawnSync("/usr/bin/open", ["-n", status.launcher], { stdio: "inherit" });
346
397
  }
347
398
  return;
@@ -350,11 +401,14 @@ export async function cmdApps(argv, overrides = {}) {
350
401
  throw new Error(`unknown app action "${action}"; use install, update, refresh, status, open, or uninstall`);
351
402
  }
352
403
 
353
- const config = await selectedAppConfig(targets);
404
+ const config = await selectedAppConfig(targets, flags.tenant || null);
354
405
  console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
355
406
 
356
407
  // A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
357
- await quitBlockingApps(targets);
408
+ await quitBlockingApps(targets, {
409
+ tenantId: config.tenantId,
410
+ tenantName: config.tenantName,
411
+ });
358
412
 
359
413
  const vendorPaths = {};
360
414
  if (!flags["skip-vendor"]) {
@@ -382,7 +436,7 @@ export async function cmdApps(argv, overrides = {}) {
382
436
 
383
437
  // Best-effort: sync the Bifrost shared skills into each installed isolated app
384
438
  // profile. Never fails the install/update.
385
- const paths = appPaths(os.homedir(), config.tenantId);
439
+ const paths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
386
440
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
387
441
  for (const item of installed) {
388
442
  const { client, env, label } = appSkillTarget(item.target, paths);
@@ -415,7 +469,12 @@ export function fastOpenLaunchers(targets, { homeDir = os.homedir(), config = lo
415
469
  return null;
416
470
  }
417
471
  const launchers = [];
418
- for (const status of statuses ?? appStatus(targets, homeDir, config.tenantId)) {
472
+ for (const status of statuses ?? appStatus(
473
+ targets,
474
+ homeDir,
475
+ config.tenantId,
476
+ config.tenantName || manifest.tenantName,
477
+ )) {
419
478
  if (!bundleIsCurrent(status)) return null;
420
479
  if (!fs.existsSync(status.configPath)) return null;
421
480
  if (status.target === "claude" && !claudeConfigIsCurrent(status.configPath, config, gatewayUrl)) {
@@ -445,19 +504,29 @@ function claudeConfigIsCurrent(configPath, config, gatewayUrl) {
445
504
  // run (config writes only, never a bundle swap, never quits anything) and
446
505
  // silent on any failure: it is invoked detached from the apps' token helper,
447
506
  // so there is no user to talk to and nothing may leak to stdout.
448
- async function refreshApps(targets, { staleOnly = false } = {}) {
507
+ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {}) {
449
508
  const stored = loadConfig();
450
509
  if (!stored?.pat) return;
451
- const paths = appPaths(os.homedir(), stored.tenantId || null);
510
+ const requestedTenantId = tenantId
511
+ ? normalizeTenantId(tenantId)
512
+ : stored.tenantId || null;
513
+ const manifest = requestedTenantId
514
+ ? readTenantManifest(os.homedir(), requestedTenantId)
515
+ : null;
516
+ const paths = appPaths(os.homedir(), requestedTenantId, {
517
+ tenantName: requestedTenantId === stored.tenantId
518
+ ? stored.tenantName || manifest?.tenantName
519
+ : manifest?.tenantName,
520
+ });
452
521
  if (staleOnly && manifestIsFresh(paths, stored)) return;
453
522
 
454
523
  let config;
455
524
  try {
456
- config = await selectedAppConfig(targets);
525
+ config = await selectedAppConfig(targets, tenantId ? requestedTenantId : null);
457
526
  } catch {
458
527
  return;
459
528
  }
460
- const statuses = appStatus(targets, os.homedir(), config.tenantId);
529
+ const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
461
530
  const installedTargets = statuses.filter((status) => status.launcherInstalled);
462
531
  if (installedTargets.length === 0) return;
463
532
 
@@ -476,7 +545,7 @@ async function refreshApps(targets, { staleOnly = false } = {}) {
476
545
  writeBundles: false,
477
546
  });
478
547
 
479
- const tenantPaths = appPaths(os.homedir(), config.tenantId);
548
+ const tenantPaths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
480
549
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
481
550
  for (const status of installedTargets) {
482
551
  const { client, env, label } = appSkillTarget(status.target, tenantPaths);
@@ -505,10 +574,28 @@ function manifestIsFresh(paths, config, now = Date.now()) {
505
574
  }
506
575
  }
507
576
 
508
- async function selectedAppConfig(targets) {
577
+ async function selectedAppConfig(targets, tenantOverride = null) {
509
578
  const config = loadConfig();
510
579
  if (!config?.pat || !config?.gatewayUrl) throw new Error("run `impel auth` before using Impel apps");
511
- const selected = await ensureTenantSelection(config, { refresh: true });
580
+ let selected;
581
+ if (tenantOverride) {
582
+ const requested = normalizeTenantId(tenantOverride);
583
+ const listing = await fetchTenants(config);
584
+ const tenant = listing.tenants.find((candidate) => (
585
+ candidate.id === requested || candidate.slug === requested
586
+ ));
587
+ if (!tenant) throw new Error(`tenant "${requested}" is not available to this user`);
588
+ selected = {
589
+ config,
590
+ tenantId: tenant.id,
591
+ tenantName: tenant.name,
592
+ tenant,
593
+ productAccess: listing.productAccess,
594
+ scopes: listing.scopes,
595
+ };
596
+ } else {
597
+ selected = await ensureTenantSelection(config, { refresh: true });
598
+ }
512
599
  if (!selected.productAccess) {
513
600
  throw new Error("the control plane did not return a live product access entitlement; retry after it is upgraded");
514
601
  }
@@ -520,16 +607,24 @@ async function selectedAppConfig(targets) {
520
607
  return {
521
608
  ...config,
522
609
  tenantId,
610
+ tenantName: selected.tenantName || selected.tenant?.name || tenantId,
523
611
  productAccess: selected.productAccess,
524
612
  scopes: selected.scopes,
525
613
  pat: tenantCredential(config.pat, tenantId),
526
614
  };
527
615
  }
528
616
 
529
- function printStatus(targets) {
617
+ function printStatus(targets, tenantOverride = null) {
530
618
  const config = loadConfig();
531
- if (config?.tenantId) console.log(`Tenant: ${config.tenantId}`);
532
- for (const status of appStatus(targets, os.homedir(), config?.tenantId || null)) {
619
+ const tenantId = tenantOverride
620
+ ? normalizeTenantId(tenantOverride)
621
+ : config?.tenantId || null;
622
+ const manifest = tenantId ? readTenantManifest(os.homedir(), tenantId) : null;
623
+ const tenantName = tenantId === config?.tenantId
624
+ ? manifest?.tenantName || config?.tenantName
625
+ : manifest?.tenantName;
626
+ if (tenantId) console.log(`Tenant: ${tenantId}${tenantName && tenantName !== tenantId ? ` (${tenantName})` : ""}`);
627
+ for (const status of appStatus(targets, os.homedir(), tenantId, tenantName)) {
533
628
  console.log(`${status.label}:`);
534
629
  console.log(` launcher: ${status.launcherInstalled ? status.launcher : "not installed"}`);
535
630
  console.log(` vendor: ${status.vendorPath || "not installed"}${status.vendorVersion ? ` (${status.vendorVersion})` : ""}`);
@@ -53,6 +53,8 @@ export async function cmdAuth(argv) {
53
53
  ? existing.tenantId
54
54
  : listing.defaultTenantId
55
55
  );
56
+ config.tenantName = listing.tenants.find((tenant) => tenant.id === config.tenantId)?.name
57
+ || config.tenantId;
56
58
  if (listing.productAccess) config.productAccess = listing.productAccess;
57
59
  else delete config.productAccess;
58
60
  if (listing.scopes) config.scopes = listing.scopes;
@@ -62,8 +64,10 @@ export async function cmdAuth(argv) {
62
64
  if (flags.tenant) throw error;
63
65
  if (existing?.tenantId && existing.pat === pat) {
64
66
  config.tenantId = existing.tenantId;
67
+ config.tenantName = existing.tenantName || existing.tenantId;
65
68
  } else {
66
69
  delete config.tenantId;
70
+ delete config.tenantName;
67
71
  delete config.productAccess;
68
72
  delete config.scopes;
69
73
  }
@@ -214,6 +214,7 @@ export async function cmdSetup(argv, overrides = {}) {
214
214
  }
215
215
 
216
216
  config.tenantId = tenant.id;
217
+ config.tenantName = tenant.name;
217
218
  if (listing.productAccess) config.productAccess = listing.productAccess;
218
219
  else delete config.productAccess;
219
220
  if (listing.scopes) config.scopes = listing.scopes;
@@ -118,8 +118,12 @@ function anyAppInstalled(homeDir = os.homedir()) {
118
118
  || fs.existsSync(path.join(paths.chatgpt.codexHome, "config.toml"))
119
119
  );
120
120
  }
121
+ const legacyPaths = appPaths(homeDir);
121
122
  return (
122
- fs.existsSync(paths.claude.launcher) || fs.existsSync(paths.chatgpt.launcher)
123
+ fs.existsSync(paths.claude.launcher)
124
+ || fs.existsSync(paths.chatgpt.launcher)
125
+ || fs.existsSync(legacyPaths.claude.launcher)
126
+ || fs.existsSync(legacyPaths.chatgpt.launcher)
123
127
  );
124
128
  }
125
129
 
package/src/config.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // "gatewayUrl": "https://gateway.useimpel.com",
7
7
  // "appUrl": "https://www.useimpel.com",
8
8
  // "tenantId": "impel",
9
+ // "tenantName": "Impel",
9
10
  // "experimental": { "crossAppModels": true },
10
11
  // "updatedAt": "2026-07-08T00:00:00.000Z"
11
12
  // }
package/src/tenants.js CHANGED
@@ -148,6 +148,12 @@ export async function ensureTenantSelection(config, { refresh = false } = {}) {
148
148
  return {
149
149
  config,
150
150
  tenantId,
151
+ tenantName: config.tenantName || tenantId,
152
+ tenant: {
153
+ id: tenantId,
154
+ slug: tenantId,
155
+ name: config.tenantName || tenantId,
156
+ },
151
157
  tenants: null,
152
158
  defaultTenantId: null,
153
159
  productAccess: normalizeProductAccess(config.productAccess, { allowMissing: true }),
@@ -159,7 +165,9 @@ export async function ensureTenantSelection(config, { refresh = false } = {}) {
159
165
  const current = config.tenantId && listing.tenants.some((tenant) => tenant.id === config.tenantId)
160
166
  ? config.tenantId
161
167
  : listing.defaultTenantId;
168
+ const tenant = listing.tenants.find((candidate) => candidate.id === current);
162
169
  config.tenantId = current;
170
+ config.tenantName = tenant.name;
163
171
  if (listing.productAccess) config.productAccess = listing.productAccess;
164
172
  else delete config.productAccess;
165
173
  if (listing.scopes) config.scopes = listing.scopes;
@@ -169,6 +177,8 @@ export async function ensureTenantSelection(config, { refresh = false } = {}) {
169
177
  return {
170
178
  config,
171
179
  tenantId: current,
180
+ tenantName: tenant.name,
181
+ tenant,
172
182
  tenants: listing.tenants,
173
183
  defaultTenantId: listing.defaultTenantId,
174
184
  productAccess: listing.productAccess,
@@ -184,6 +194,7 @@ export async function selectTenant(config, tenantId) {
184
194
  throw new Error(`tenant "${selected}" is not available to this user`);
185
195
  }
186
196
  config.tenantId = tenant.id;
197
+ config.tenantName = tenant.name;
187
198
  if (listing.productAccess) config.productAccess = listing.productAccess;
188
199
  else delete config.productAccess;
189
200
  if (listing.scopes) config.scopes = listing.scopes;
package/src/updates.js CHANGED
@@ -164,8 +164,13 @@ export function spawnDetachedCacheRefresh() {
164
164
  }
165
165
 
166
166
  /** Background config/catalog/skills convergence; no-ops inside the TTL. */
167
- export function spawnDetachedAppRefresh() {
168
- spawnDetached(["app", "refresh", "--stale-only"]);
167
+ export function spawnDetachedAppRefresh(tenantId = null) {
168
+ spawnDetached([
169
+ "app",
170
+ "refresh",
171
+ "--stale-only",
172
+ ...(tenantId ? ["--tenant", tenantId] : []),
173
+ ]);
169
174
  }
170
175
 
171
176
  function spawnDetached(args) {