impel-cli 0.7.2 → 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,10 +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. 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
244
252
  tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
245
253
  signed vendor executable with that profile selected. Windows ChatGPT keeps its
246
254
  Codex and browser state under the same tenant-scoped Impel apps root used on
@@ -575,21 +583,24 @@ app-target names.
575
583
 
576
584
  ### macOS Claude and ChatGPT
577
585
 
578
- `impel app install` creates `Impel Claude.app` and `Impel ChatGPT.app` in
579
- `~/Applications`. They have separate bundle identities and gateway
580
- 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:
581
591
 
582
592
  - Impel Claude is an APFS-cloned vendored copy of the official app with its own
583
593
  bundle and helper identities. Its LaunchServices environment sets the
584
- vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway `configLibrary` below
585
- `~/.config/impel/apps/claude`. A narrowly version-checked compatibility patch
586
- connects Claude's native plan-usage meter to the authenticated aggregate
587
- 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.
588
599
  - Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
589
600
  OpenAI-signed bundle and executable. Its outer resource root contains the
590
601
  narrowly patched renderer ASAR used at runtime. The wrapper sets `CODEX_HOME`,
591
602
  passes a separate Chromium `--user-data-dir`, and writes its provider config
592
- 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
593
604
  process-scoped, Codex-compatible wrapper around the Impel PAT so native
594
605
  `/status` requests can read the organization's aggregated subscription pool
595
606
  from the gateway. Its generated Codex config uses the same snapshot and
@@ -599,9 +610,22 @@ configuration:
599
610
  - Neither app changes `~/.claude`, `~/.claude.json`, `~/.codex`, or the
600
611
  normal vendor app's browser/profile data.
601
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
+
602
626
  The official Claude and ChatGPT apps remain separate installations and keep
603
627
  their normal accounts, updater, profiles, and bundle identities. Each CLI
604
- 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
605
629
  ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
606
630
  an existing official app only when its version exactly matches; otherwise it
607
631
  downloads the pinned signed archive into Impel's managed vendor cache under
@@ -713,3 +737,15 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
713
737
  - https://developers.openai.com/codex/config-advanced
714
738
  - https://developers.openai.com/codex/auth
715
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.2",
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,13 @@ 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
186
+ && compatibility.profileRoot === status.profileRoot
187
+ && (status.target !== "chatgpt" || (
188
+ compatibility.codexHome === status.codexHome
189
+ && compatibility.browserData === status.browserData
190
+ ))
147
191
  && managedBundleMatchesCompatibility(status, compatibility)
148
192
  && (status.target !== "chatgpt" || (
149
193
  compatibility.vendorSignaturesPreserved === true
@@ -177,24 +221,28 @@ function managedBundleMatchesCompatibility(status, compatibility) {
177
221
  // and codesign failures.
178
222
 
179
223
  /** osascript application names whose bundles install/update will touch. */
180
- export function blockingAppNames(targets) {
224
+ export function blockingAppNames(targets, { tenantId = null, tenantName = null } = {}) {
181
225
  const names = [];
182
226
  for (const target of targets) {
183
227
  const definition = APP_DEFINITIONS[target];
184
228
  for (const bundleName of definition.names) names.push(bundleName.replace(/\.app$/u, ""));
185
- 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);
186
233
  }
187
- return names;
234
+ return [...new Set(names)];
188
235
  }
189
236
 
190
237
  /**
191
238
  * pgrep/pkill -f pattern for one app's processes. Anchored on
192
- * "/<Name>.app/Contents/MacOS/" so "Claude" never matches the space-prefixed
193
- * "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.
194
242
  */
195
243
  export function appProcessPattern(name) {
196
244
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
197
- return `/${escaped}\\.app/Contents/MacOS/`;
245
+ return `/${escaped}\\.app/(.*/)?Contents/MacOS/`;
198
246
  }
199
247
 
200
248
  const QUIT_POLL_MS = 500;
@@ -205,6 +253,8 @@ const QUIT_POLL_MS = 500;
205
253
  * telling the user to close it themselves if even that fails.
206
254
  */
207
255
  export async function quitBlockingApps(targets, {
256
+ tenantId = null,
257
+ tenantName = null,
208
258
  log = console.log,
209
259
  isRunning = (name) =>
210
260
  spawnSync("/usr/bin/pgrep", ["-f", appProcessPattern(name)], { stdio: "ignore" }).status === 0,
@@ -229,7 +279,7 @@ export async function quitBlockingApps(targets, {
229
279
  return !isRunning(name);
230
280
  }
231
281
 
232
- for (const name of blockingAppNames(targets)) {
282
+ for (const name of blockingAppNames(targets, { tenantId, tenantName })) {
233
283
  if (!isRunning(name)) continue;
234
284
  log(`${name} is running; closing it before touching app bundles…`);
235
285
  requestQuit(name);
@@ -241,26 +291,33 @@ export async function quitBlockingApps(targets, {
241
291
  }
242
292
  }
243
293
 
244
- 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
+ } = {}) {
245
298
  const root = process.env.IMPEL_APP_HOME || path.join(homeDir, ".config", "impel", "apps");
246
299
  const tenantRoot = tenantId
247
300
  ? path.join(root, "tenants", normalizeTenantId(tenantId))
248
301
  : root;
302
+ const claudeIdentity = managedAppIdentity("claude", tenantId, tenantName);
303
+ const chatgptIdentity = managedAppIdentity("chatgpt", tenantId, tenantName);
249
304
  return {
250
305
  root,
251
306
  tenantRoot,
252
307
  tokenHelper: path.join(tenantRoot, "bin", "token"),
253
308
  claude: {
309
+ ...claudeIdentity,
254
310
  root: claudeUserData || path.join(tenantRoot, "claude"),
255
311
  userData: claudeUserData || path.join(tenantRoot, "claude", "user-data"),
256
- launcher: path.join(homeDir, "Applications", "Impel Claude.app"),
312
+ launcher: path.join(homeDir, "Applications", claudeIdentity.launcherName),
257
313
  },
258
314
  chatgpt: {
315
+ ...chatgptIdentity,
259
316
  root: path.join(tenantRoot, "chatgpt"),
260
317
  codexHome: path.join(tenantRoot, "chatgpt", "codex-home"),
261
318
  browserData: path.join(tenantRoot, "chatgpt", "browser-data"),
262
319
  catalog: path.join(tenantRoot, "chatgpt", "codex-home", "models.json"),
263
- launcher: path.join(homeDir, "Applications", "Impel ChatGPT.app"),
320
+ launcher: path.join(homeDir, "Applications", chatgptIdentity.launcherName),
264
321
  },
265
322
  };
266
323
  }
@@ -304,7 +361,10 @@ export function installManagedAppFiles({
304
361
  // true = rebuild every target's bundle; false = configs only; an array
305
362
  // limits the expensive rebuild to just the stale targets.
306
363
  const bundleTargets = new Set(writeBundles === true ? targets : writeBundles || []);
307
- const paths = appPaths(homeDir, config.tenantId, { claudeUserData });
364
+ const paths = appPaths(homeDir, config.tenantId, {
365
+ claudeUserData,
366
+ tenantName: config.tenantName,
367
+ });
308
368
  if (targets.includes("chatgpt")) secureAllManagedCodexHomes({ appsRoot: paths.root });
309
369
  fs.mkdirSync(paths.tenantRoot, { recursive: true, mode: 0o700 });
310
370
  if (writeTokenHelperFile) writeTokenHelper(paths.tokenHelper, config.tenantId);
@@ -329,16 +389,22 @@ export function installManagedAppFiles({
329
389
  }
330
390
  if (target === "chatgpt") writeVendoredChatGPTBundle(paths, resolvedVendorPaths.chatgpt, config.gatewayUrl);
331
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
+ }
332
397
  }
333
398
  installed.push({ target, launcher: paths[target].launcher });
334
399
  }
335
400
 
336
401
  writeAtomic(path.join(paths.tenantRoot, "manifest.json"), JSON.stringify({
337
- schemaVersion: 3,
402
+ schemaVersion: 4,
338
403
  configVersion: CURRENT_CONFIG_VERSION,
339
404
  modelCatalogVersion: 3,
340
405
  gatewayUrl: config.gatewayUrl,
341
406
  tenantId: config.tenantId,
407
+ tenantName: config.tenantName || config.tenantId || null,
342
408
  experiments: { crossAppModels: crossAppModelsEnabled(config) },
343
409
  targets,
344
410
  models: models.map((model) => model.id),
@@ -513,17 +579,24 @@ export function ensureVendorApp(target, { homeDir = os.homedir() } = {}) {
513
579
  return { path: installed, action: "installed", note: `verified ${pin.version}` };
514
580
  }
515
581
 
516
- export function appStatus(targets, homeDir = os.homedir(), tenantId = null) {
517
- 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 });
518
587
  return targets.map((target) => {
519
588
  const vendorPath = findVendorApp(target, homeDir);
520
589
  return {
521
590
  target,
522
- label: APP_DEFINITIONS[target].label,
591
+ label: paths[target].displayName,
523
592
  launcher: paths[target].launcher,
524
593
  launcherInstalled: fs.existsSync(paths[target].launcher),
594
+ bundleIdentifier: paths[target].bundleIdentifier,
595
+ profileRoot: target === "claude" ? paths.claude.userData : paths.chatgpt.root,
525
596
  vendorPath,
526
597
  vendorVersion: vendorPath ? bundleVersion(vendorPath) : null,
598
+ codexHome: target === "chatgpt" ? paths.chatgpt.codexHome : null,
599
+ browserData: target === "chatgpt" ? paths.chatgpt.browserData : null,
527
600
  configPath: target === "claude"
528
601
  ? path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)
529
602
  : path.join(paths.chatgpt.codexHome, "config.toml"),
@@ -535,9 +608,10 @@ export function removeManagedApps(targets, {
535
608
  homeDir = os.homedir(),
536
609
  keepData = false,
537
610
  tenantId = null,
611
+ tenantName = null,
538
612
  claudeUserData = null,
539
613
  } = {}) {
540
- const paths = appPaths(homeDir, tenantId, { claudeUserData });
614
+ const paths = appPaths(homeDir, tenantId, { claudeUserData, tenantName });
541
615
  for (const target of targets) {
542
616
  fs.rmSync(paths[target].launcher, { recursive: true, force: true });
543
617
  if (!keepData) fs.rmSync(paths[target].root, { recursive: true, force: true });
@@ -844,13 +918,14 @@ function writeTokenHelper(target, tenantId) {
844
918
  const node = shellQuote(process.execPath);
845
919
  const cli = shellQuote(cliPath);
846
920
  const tenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
921
+ const refreshTenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
847
922
  // The apps invoke this helper whenever they need auth, which makes it the
848
923
  // one hook that fires on Finder/Dock launches too. Piggyback a detached,
849
924
  // TTL-gated config/catalog/skills refresh (`app refresh --stale-only`) so
850
925
  // the managed apps keep themselves current without touching stdout — the
851
926
  // caller consumes stdout as the token.
852
927
  const script = `#!/bin/sh
853
- (${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 &)
854
929
  exec ${node} ${cli} token${tenantArgs}
855
930
  `;
856
931
  writeAtomic(target, script, 0o700);
@@ -892,27 +967,28 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
892
967
  const newAsarHash = asarHeaderHash(asarPath);
893
968
 
894
969
  updateBundleIdentity(plistPath, {
895
- displayName: "Impel Claude",
970
+ displayName: paths.claude.displayName,
896
971
  executable: executableName,
897
- identifier: "com.useimpel.claude",
898
- name: "Impel Claude",
972
+ identifier: paths.claude.bundleIdentifier,
973
+ name: paths.claude.displayName,
899
974
  });
900
975
  updateAsarIntegrityPlist(plistPath, oldAsarHash, newAsarHash);
901
976
  setPlistEnvironmentString(plistPath, "CLAUDE_USER_DATA_DIR", paths.claude.userData);
902
977
  rebrandElectronHelpers(staging, {
903
978
  fromName: "Claude",
904
- toName: "Impel Claude",
979
+ toName: paths.claude.displayName,
905
980
  fromIdentifier: "com.anthropic.claudefordesktop.helper",
906
- toIdentifier: "com.useimpel.claude.helper",
981
+ toIdentifier: paths.claude.helperBundleIdentifier,
907
982
  });
908
983
 
909
984
  const macos = path.join(staging, "Contents", "MacOS");
910
985
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
911
- schemaVersion: 1,
986
+ schemaVersion: 2,
912
987
  cliVersion: CLI_VERSION,
913
988
  vendorVersion: bundleVersion(vendorPath),
914
989
  sourceBundle: vendorPath,
915
- bundleIdentifier: "com.useimpel.claude",
990
+ bundleIdentifier: paths.claude.bundleIdentifier,
991
+ displayName: paths.claude.displayName,
916
992
  profileRoot: paths.claude.userData,
917
993
  launchMode: "LSEnvironment",
918
994
  patches: {
@@ -976,7 +1052,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
976
1052
  const asarHash = asarHeaderHash(runtimeAsarPath);
977
1053
  writeAtomic(
978
1054
  path.join(staging, "Contents", "Info.plist"),
979
- vendoredChatGPTWrapperPlist(vendorPath, asarHash),
1055
+ vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt),
980
1056
  0o644,
981
1057
  );
982
1058
  writeAtomic(
@@ -985,10 +1061,15 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
985
1061
  0o755,
986
1062
  );
987
1063
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
988
- schemaVersion: 1,
1064
+ schemaVersion: 2,
989
1065
  cliVersion: CLI_VERSION,
990
1066
  vendorVersion: bundleVersion(vendorPath),
991
1067
  sourceBundle: vendorPath,
1068
+ bundleIdentifier: paths.chatgpt.bundleIdentifier,
1069
+ displayName: paths.chatgpt.displayName,
1070
+ profileRoot: paths.chatgpt.root,
1071
+ codexHome: paths.chatgpt.codexHome,
1072
+ browserData: paths.chatgpt.browserData,
992
1073
  patches: { fastModeForGatewayAuth: fastModePatchCount, desktopAPIForGatewayAuth: 0 },
993
1074
  vendorSignaturesPreserved: true,
994
1075
  resourceRoot: CHATGPT_RESOURCE_ROOT,
@@ -1254,15 +1335,15 @@ function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toId
1254
1335
  }
1255
1336
  }
1256
1337
 
1257
- function vendoredChatGPTWrapperPlist(vendorPath, asarHash) {
1338
+ function vendoredChatGPTWrapperPlist(vendorPath, asarHash, identity) {
1258
1339
  return `<?xml version="1.0" encoding="UTF-8"?>
1259
1340
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1260
1341
  <plist version="1.0"><dict>
1261
- <key>CFBundleDisplayName</key><string>Impel ChatGPT</string>
1342
+ <key>CFBundleDisplayName</key><string>${xmlEscape(identity.displayName)}</string>
1262
1343
  <key>CFBundleExecutable</key><string>launch</string>
1263
1344
  <key>CFBundleIconFile</key><string>electron.icns</string>
1264
- <key>CFBundleIdentifier</key><string>com.useimpel.chatgpt</string>
1265
- <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>
1266
1347
  <key>CFBundlePackageType</key><string>APPL</string>
1267
1348
  <key>CFBundleShortVersionString</key><string>${xmlEscape(bundleVersion(vendorPath) || "1")}</string>
1268
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>
@@ -1361,9 +1442,14 @@ function signVendoredClaudeCapabilities(bundle) {
1361
1442
  try {
1362
1443
  const frameworks = path.join(bundle, "Contents", "Frameworks");
1363
1444
  const helpers = fs.readdirSync(frameworks, { withFileTypes: true })
1364
- .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
+ ));
1365
1451
  for (const helper of helpers) {
1366
- const entitlements = helper.name === "Impel Claude Helper.app" ? fullPath : jitPath;
1452
+ const entitlements = helper.name.endsWith(" Helper.app") ? fullPath : jitPath;
1367
1453
  const result = codesign([
1368
1454
  "--force", "--deep", "--options", "runtime", "--entitlements", entitlements, "--sign", "-",
1369
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,10 +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);
405
+ console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
354
406
 
355
407
  // A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
356
- await quitBlockingApps(targets);
408
+ await quitBlockingApps(targets, {
409
+ tenantId: config.tenantId,
410
+ tenantName: config.tenantName,
411
+ });
357
412
 
358
413
  const vendorPaths = {};
359
414
  if (!flags["skip-vendor"]) {
@@ -381,7 +436,7 @@ export async function cmdApps(argv, overrides = {}) {
381
436
 
382
437
  // Best-effort: sync the Bifrost shared skills into each installed isolated app
383
438
  // profile. Never fails the install/update.
384
- const paths = appPaths(os.homedir(), config.tenantId);
439
+ const paths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
385
440
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
386
441
  for (const item of installed) {
387
442
  const { client, env, label } = appSkillTarget(item.target, paths);
@@ -414,7 +469,12 @@ export function fastOpenLaunchers(targets, { homeDir = os.homedir(), config = lo
414
469
  return null;
415
470
  }
416
471
  const launchers = [];
417
- 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
+ )) {
418
478
  if (!bundleIsCurrent(status)) return null;
419
479
  if (!fs.existsSync(status.configPath)) return null;
420
480
  if (status.target === "claude" && !claudeConfigIsCurrent(status.configPath, config, gatewayUrl)) {
@@ -444,19 +504,29 @@ function claudeConfigIsCurrent(configPath, config, gatewayUrl) {
444
504
  // run (config writes only, never a bundle swap, never quits anything) and
445
505
  // silent on any failure: it is invoked detached from the apps' token helper,
446
506
  // so there is no user to talk to and nothing may leak to stdout.
447
- async function refreshApps(targets, { staleOnly = false } = {}) {
507
+ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {}) {
448
508
  const stored = loadConfig();
449
509
  if (!stored?.pat) return;
450
- 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
+ });
451
521
  if (staleOnly && manifestIsFresh(paths, stored)) return;
452
522
 
453
523
  let config;
454
524
  try {
455
- config = await selectedAppConfig(targets);
525
+ config = await selectedAppConfig(targets, tenantId ? requestedTenantId : null);
456
526
  } catch {
457
527
  return;
458
528
  }
459
- const statuses = appStatus(targets, os.homedir(), config.tenantId);
529
+ const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
460
530
  const installedTargets = statuses.filter((status) => status.launcherInstalled);
461
531
  if (installedTargets.length === 0) return;
462
532
 
@@ -475,7 +545,7 @@ async function refreshApps(targets, { staleOnly = false } = {}) {
475
545
  writeBundles: false,
476
546
  });
477
547
 
478
- const tenantPaths = appPaths(os.homedir(), config.tenantId);
548
+ const tenantPaths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
479
549
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
480
550
  for (const status of installedTargets) {
481
551
  const { client, env, label } = appSkillTarget(status.target, tenantPaths);
@@ -504,10 +574,28 @@ function manifestIsFresh(paths, config, now = Date.now()) {
504
574
  }
505
575
  }
506
576
 
507
- async function selectedAppConfig(targets) {
577
+ async function selectedAppConfig(targets, tenantOverride = null) {
508
578
  const config = loadConfig();
509
579
  if (!config?.pat || !config?.gatewayUrl) throw new Error("run `impel auth` before using Impel apps");
510
- 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
+ }
511
599
  if (!selected.productAccess) {
512
600
  throw new Error("the control plane did not return a live product access entitlement; retry after it is upgraded");
513
601
  }
@@ -519,16 +607,24 @@ async function selectedAppConfig(targets) {
519
607
  return {
520
608
  ...config,
521
609
  tenantId,
610
+ tenantName: selected.tenantName || selected.tenant?.name || tenantId,
522
611
  productAccess: selected.productAccess,
523
612
  scopes: selected.scopes,
524
613
  pat: tenantCredential(config.pat, tenantId),
525
614
  };
526
615
  }
527
616
 
528
- function printStatus(targets) {
617
+ function printStatus(targets, tenantOverride = null) {
529
618
  const config = loadConfig();
530
- if (config?.tenantId) console.log(`Tenant: ${config.tenantId}`);
531
- 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)) {
532
628
  console.log(`${status.label}:`);
533
629
  console.log(` launcher: ${status.launcherInstalled ? status.launcher : "not installed"}`);
534
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;
@@ -65,6 +65,7 @@ export async function cmdTenant(argv) {
65
65
  if (!tenantId) throw new Error("tenant use requires an org slug; run `impel tenant list`");
66
66
  const { tenant } = await selectTenant(config, tenantId);
67
67
  console.log(`Tenant -> ${tenant.id} (${tenant.name})`);
68
+ console.log("Local CLI and desktop history in other tenants is preserved and remains available when you switch back.");
68
69
 
69
70
  const launch = flags.launch;
70
71
  if (!launch) {
@@ -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) {