impel-cli 0.7.3 → 0.9.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
@@ -121,6 +121,9 @@ impel update [--check] [--skip-apps] Update EVERYTHING: reinstall the CL
121
121
  cascade to `app update all` and `skills sync all`
122
122
  impel auth [--pat <pat>] [--gateway <url>] [--app <url>]
123
123
  Store your Impel PAT + URLs
124
+ impel pat create --label <label> [options] Mint a personal PAT through the control plane
125
+ impel pat create --agent <agent-id> [options]
126
+ Mint a PAT associated with an org agent
124
127
  impel token [--tenant <org>] Print a selected-tenant bearer
125
128
  impel mcp Run the local MCP bridge used by Claude/Codex
126
129
  impel claude [claude args...] Launch isolated Impel Claude Code
@@ -145,18 +148,22 @@ impel off [claude|codex|all] Alias for `impel use account`
145
148
  impel skills sync [claude|codex|all] Sync Bifrost shared skills into managed clients (default: all)
146
149
 
147
150
  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
151
+ impel app install [target] [--tenant <org>] Install isolated apps for one tenant
152
+ impel app update [target] [--tenant <org>] Refresh that tenant's apps, configs, and models
153
+ impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
151
154
  (--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
155
+ impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
156
+ impel app open [target] [--tenant <org>] Launch isolated instances (fast path: opens
154
157
  immediately when nothing changed since last open;
155
158
  a detached stale-only refresh keeps things current)
156
- impel app uninstall [target] [--keep-data] Remove Impel-managed app files
159
+ impel app uninstall [target] [--tenant <org>] [--keep-data]
160
+ Remove one tenant's Impel-managed app files
157
161
  impel help Show help
158
162
  impel --version Show version
159
163
 
164
+ For app commands, `target` is `claude`, `chatgpt`/`codex`, or `all` (the
165
+ default). `--tenant` defaults to the currently selected organization.
166
+
160
167
  # `impel setup` no longer takes a per-tool target and never flips native
161
168
  # configs; flip a native tool explicitly with `impel use gateway [claude|codex]`.
162
169
  ```
@@ -179,6 +186,35 @@ Stores your PAT, gateway base URL, and app/control-plane URL in `~/.config/impel
179
186
  impel auth --pat impel_pat_xxxxxxxxxxxx --gateway https://gateway.useimpel.com --app https://www.useimpel.com
180
187
  ```
181
188
 
189
+ ### `impel pat create ...` (alias `impel pat mint ...`)
190
+
191
+ Mints a new PAT through the app/control-plane API, authenticated by the PAT
192
+ already stored by `impel auth`. Identity remains the minting authority: the
193
+ control plane verifies the current owner, target-org membership, requested
194
+ scopes, product access, and (when applicable) agent access before it requests a
195
+ new secret. The gateway never mints or verifies PATs.
196
+
197
+ Use `--label` for a personal token or `--agent` for a token associated with an
198
+ org agent. Agent PATs are still personal credentials: they act as you, are
199
+ org-scoped, and use the same stable `Agent · <agent-id>` association as the agent
200
+ setup UI.
201
+
202
+ ```sh
203
+ impel pat create --label "Rahul's laptop"
204
+ impel pat create --agent support-agent --org acme
205
+ impel pat create --label ci-automation --scopes codex-gateway,tasks --ttl-days 30 --json
206
+ ```
207
+
208
+ `--org` defaults to the selected tenant (`--tenant` is an alias). `--scopes`
209
+ accepts `claude-code-gateway`, `codex-gateway`, and `tasks`; when omitted, the
210
+ control plane chooses the defaults allowed by your access tier. `--ttl-days`
211
+ accepts 1–365 and defaults to 90 days.
212
+
213
+ The new secret is returned once on stdout and is deliberately **not** written to
214
+ the config file. To rotate this CLI onto it, explicitly run
215
+ `impel auth --pat <new-pat>` after copying it. `--json` includes the raw secret,
216
+ so treat redirected output as a credential.
217
+
182
218
  ### `impel update`
183
219
 
184
220
  One command brings everything current, in dependency order: the CLI itself
@@ -237,12 +273,14 @@ not bleed between tenants. `impel tenant use <org> --launch claude|codex|apps`
237
273
  selects and launches in one command.
238
274
 
239
275
  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
276
+ `~/.config/impel/apps/tenants/<org>/`. Every installed tenant keeps its own app
277
+ bundles in `~/Applications`, with names such as `Impel Claude (Acme)` and
278
+ `Impel ChatGPT (Acme)`, stable tenant-specific bundle identifiers, and fixed
279
+ tenant profile/auth paths. Different tenants can stay open concurrently.
280
+ `impel app open --tenant <org>` refreshes and launches a tenant directly without
281
+ changing the globally selected tenant. Switching tenants never deletes local
282
+ history or projects; each tenant's preserved profile reopens independently.
283
+ Windows Claude Desktop keeps each
246
284
  tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
247
285
  signed vendor executable with that profile selected. Windows ChatGPT keeps its
248
286
  Codex and browser state under the same tenant-scoped Impel apps root used on
@@ -577,21 +615,24 @@ app-target names.
577
615
 
578
616
  ### macOS Claude and ChatGPT
579
617
 
580
- `impel app install` creates `Impel Claude.app` and `Impel ChatGPT.app` in
581
- `~/Applications`. They have separate bundle identities and gateway
582
- configuration:
618
+ `impel app install` creates tenant-specific apps such as
619
+ `Impel Claude (acme).app` and `Impel ChatGPT (acme).app` in `~/Applications`.
620
+ Their visible bundle names use the organization display name, for example
621
+ `Impel Claude (Acme)` and `Impel ChatGPT (Acme)`. Each tenant/product pair has
622
+ a stable, unique bundle identifier and gateway configuration:
583
623
 
584
624
  - Impel Claude is an APFS-cloned vendored copy of the official app with its own
585
625
  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.
626
+ vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway
627
+ `configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A narrowly
628
+ version-checked compatibility patch connects Claude's native plan-usage meter
629
+ to the authenticated aggregate Claude subscription pool exposed by the Impel
630
+ gateway.
590
631
  - Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
591
632
  OpenAI-signed bundle and executable. Its outer resource root contains the
592
633
  narrowly patched renderer ASAR used at runtime. The wrapper sets `CODEX_HOME`,
593
634
  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
635
+ and model catalog below `~/.config/impel/apps/tenants/<org>/chatgpt`. It also supplies a
595
636
  process-scoped, Codex-compatible wrapper around the Impel PAT so native
596
637
  `/status` requests can read the organization's aggregated subscription pool
597
638
  from the gateway. Its generated Codex config uses the same snapshot and
@@ -601,9 +642,22 @@ configuration:
601
642
  - Neither app changes `~/.claude`, `~/.claude.json`, `~/.codex`, or the
602
643
  normal vendor app's browser/profile data.
603
644
 
645
+ Install or open several tenants without changing the current selection:
646
+
647
+ ```sh
648
+ impel app install all --tenant acme
649
+ impel app install all --tenant globex
650
+ impel app open all --tenant acme
651
+ impel app open all --tenant globex
652
+ ```
653
+
654
+ Updates and uninstalls touch only the named tenant variant. The older global
655
+ `Impel Claude.app` / `Impel ChatGPT.app` launcher is removed after the first
656
+ successful tenant-specific rebuild.
657
+
604
658
  The official Claude and ChatGPT apps remain separate installations and keep
605
659
  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
660
+ release pins exact, tested vendor builds (v0.9.0 pins Claude `1.20186.9` and
607
661
  ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
608
662
  an existing official app only when its version exactly matches; otherwise it
609
663
  downloads the pinned signed archive into Impel's managed vendor cache under
@@ -715,3 +769,15 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
715
769
  - https://developers.openai.com/codex/config-advanced
716
770
  - https://developers.openai.com/codex/auth
717
771
  - https://developers.openai.com/codex/ide
772
+
773
+ ## Sources consulted for concurrent tenant apps
774
+
775
+ - Electron v43.1.0 release: https://github.com/electron/electron/releases/tag/v43.1.0
776
+ - Electron applies `--user-data-dir` before app JavaScript:
777
+ https://github.com/electron/electron/blob/b5c102b3f0f7e9c5be064ba337a3547a83cf7d09/shell/app/electron_main_delegate.cc#L247-L256
778
+ - Electron scopes its single-instance lock to the resolved user-data directory:
779
+ https://github.com/electron/electron/blob/b5c102b3f0f7e9c5be064ba337a3547a83cf7d09/shell/browser/api/electron_api_app.cc#L1049-L1073
780
+ - Electron macOS bundle rebranding requirements:
781
+ https://github.com/electron/electron/blob/b5c102b3f0f7e9c5be064ba337a3547a83cf7d09/docs/tutorial/application-distribution.md#L79-L96
782
+ - Apple `CFBundleIdentifier` reference:
783
+ 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.9.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
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
4
  import { cmdAuth } from "./commands/auth.js";
5
+ import { cmdPat } from "./commands/pat.js";
5
6
  import { cmdToken } from "./commands/token.js";
6
7
  import { cmdUse } from "./commands/use.js";
7
8
  import { cmdStatus } from "./commands/status.js";
@@ -28,12 +29,14 @@ Get started:
28
29
  Run:
29
30
  impel claude [args...] Launch Claude Code with an isolated Impel profile
30
31
  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)
32
+ impel app open [target] [--tenant <org>] Open that tenant's isolated desktop app(s)
32
33
  impel status Tenant, PAT, per-tool mode, gateway reachability
33
34
  impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
34
35
 
35
36
  Manage:
36
37
  impel auth [--pat <pat>] Store your PAT + URLs (--gateway/--app <url>)
38
+ impel pat create --label <label> Mint a personal PAT (--org/--scopes/--ttl-days)
39
+ impel pat create --agent <agent-id> Mint an org-scoped PAT associated with an agent
37
40
  impel tenant list|current|use <org> List or select the active organization
38
41
  impel tasks list|get|create|update|delete CRUD Impel tickets (aliases: task, ticket[s])
39
42
  impel token [--tenant <org>] Print the selected-tenant bearer
@@ -41,11 +44,13 @@ Manage:
41
44
  impel skills sync [claude|codex|all] Sync shared skills into managed clients
42
45
 
43
46
  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
47
+ target is claude, chatgpt/codex, or all (default: all)
48
+ impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
49
+ impel app update [target] [--tenant <org>] Update vendor apps and managed profiles
50
+ impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
51
+ impel app status [target] [--tenant <org>] Show isolated app and vendor versions
52
+ impel app uninstall [target] [--tenant <org>] [--keep-data]
53
+ Remove only that tenant's managed apps/data
49
54
 
50
55
  Native profile switching (advanced; setup never touches native tools):
51
56
  impel use gateway [claude|codex|all] Route native Claude Code/Codex through the gateway
@@ -57,7 +62,7 @@ Native profile switching (advanced; setup never touches native tools):
57
62
 
58
63
  Env:
59
64
  IMPEL_GATEWAY_URL Default gateway URL when --gateway isn't passed.
60
- IMPEL_APP_URL Default app/control-plane URL for setup, auth, and tasks.
65
+ IMPEL_APP_URL Default app/control-plane URL for setup, auth, PATs, and tasks.
61
66
  IMPEL_SKIP_UPDATE_CHECK=1 Silence launch-time update notices.
62
67
 
63
68
  Config file:
@@ -99,6 +104,10 @@ export async function main(argv) {
99
104
  case "auth":
100
105
  return cmdAuth(rest);
101
106
 
107
+ case "pat":
108
+ case "pats":
109
+ return cmdPat(rest);
110
+
102
111
  case "token":
103
112
  return cmdToken(rest);
104
113