impel-cli 0.9.1 → 0.9.2

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
@@ -154,8 +154,8 @@ impel app update [target] [--tenant <org>] Refresh that tenant's apps, configs
154
154
  impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
155
155
  (--stale-only: no-op unless 6h+ since last sync)
156
156
  impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
157
- impel app open [target] [--tenant <org>] Launch isolated instances (fast path: opens
158
- immediately when nothing changed since last open;
157
+ impel app open [target] [--tenant <org>] Install/configure a tenant on first use, then launch
158
+ it (later opens are immediate when nothing changed;
159
159
  a detached stale-only refresh keeps things current)
160
160
  impel app uninstall [target] [--tenant <org>] [--keep-data]
161
161
  Remove one tenant's Impel-managed app files
@@ -292,9 +292,13 @@ On macOS, desktop app state is isolated under
292
292
  bundles in `~/Applications`, with names such as `Impel Claude (Acme)` and
293
293
  `Impel ChatGPT (Acme)`, stable tenant-specific bundle identifiers, and fixed
294
294
  tenant profile/auth paths. Different tenants can stay open concurrently.
295
- `impel app open --tenant <org>` refreshes and launches a tenant directly without
296
- changing the globally selected tenant. Switching tenants never deletes local
297
- history or projects; each tenant's preserved profile reopens independently.
295
+ `impel app open --tenant <org>` installs and configures a missing tenant variant
296
+ on first use, then launches it directly without changing the globally selected
297
+ tenant. Later opens use the existing isolated app and profile. Switching tenants
298
+ never deletes local history or projects; each tenant's preserved profile reopens
299
+ independently. The first-use setup still requires an authenticated user PAT and
300
+ live membership in the requested organization; it does not create a PAT per
301
+ tenant.
298
302
  Windows Claude Desktop keeps each
299
303
  tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
300
304
  signed vendor executable with that profile selected. Windows ChatGPT keeps its
@@ -460,8 +464,9 @@ the selected tenant's 3P gateway configuration beneath Anthropic's reserved
460
464
  normal Claude profile unchanged. `impel app install codex` installs/updates
461
465
  OpenAI's `OpenAI.Codex` Store package, stages its unchanged signed app payload
462
466
  inside Impel's shared app cache, and writes separate Codex and Chromium
463
- profiles. `impel app open claude` and `impel app open codex` launch those
464
- isolated profiles without changing either normal vendor profile.
467
+ profiles. `impel app open claude` and `impel app open codex` install a missing
468
+ official app and configure the selected tenant on first use, then launch the
469
+ isolated profile without changing either normal vendor profile.
465
470
 
466
471
  For Claude, the Impel PAT is supplied only to the launched child process as an
467
472
  authentication token and disappears when that process exits; it is never
@@ -602,10 +607,8 @@ Codex CLI and the Codex IDE extension / desktop app share the same
602
607
  ### Windows Claude and ChatGPT Desktop
603
608
 
604
609
  ```powershell
605
- impel app install claude
606
610
  impel app open claude
607
611
  impel app status claude
608
- impel app install codex
609
612
  impel app open codex
610
613
  impel app status codex
611
614
  ```
@@ -630,7 +633,8 @@ app-target names.
630
633
 
631
634
  ### macOS Claude and ChatGPT
632
635
 
633
- `impel app install` creates tenant-specific apps such as
636
+ The first `impel app open --tenant <org>` (or an explicit `impel app install`)
637
+ creates tenant-specific apps such as
634
638
  `Impel Claude (acme).app` and `Impel ChatGPT (acme).app` in `~/Applications`.
635
639
  Their visible bundle names use the organization display name, for example
636
640
  `Impel Claude (Acme)` and `Impel ChatGPT (Acme)`. Each tenant/product pair has
@@ -657,11 +661,10 @@ a stable, unique bundle identifier and gateway configuration:
657
661
  - Neither app changes `~/.claude`, `~/.claude.json`, `~/.codex`, or the
658
662
  normal vendor app's browser/profile data.
659
663
 
660
- Install or open several tenants without changing the current selection:
664
+ Open several tenants without changing the current selection. A missing variant
665
+ is installed and configured automatically before it opens:
661
666
 
662
667
  ```sh
663
- impel app install all --tenant acme
664
- impel app install all --tenant globex
665
668
  impel app open all --tenant acme
666
669
  impel app open all --tenant globex
667
670
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
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/cli.js CHANGED
@@ -29,7 +29,7 @@ Get started:
29
29
  Run:
30
30
  impel claude [args...] Launch Claude Code with an isolated Impel profile
31
31
  impel codex [args...] Launch Codex with an isolated Impel profile
32
- impel app open [target] [--tenant <org>] Open that tenant's isolated desktop app(s)
32
+ impel app open [target] [--tenant <org>] Install/configure on first use, then open the app(s)
33
33
  impel status Tenant, PAT, per-tool mode, gateway reachability
34
34
  impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
35
35
 
@@ -207,13 +207,26 @@ export async function cmdWindowsApps(argv, overrides = {}) {
207
207
  claudeUserData: actionUserData,
208
208
  tenantName: config.tenantName,
209
209
  });
210
+ const profileWasInstalled = {
211
+ claude: fs.existsSync(path.join(
212
+ actionPaths.claude.userData,
213
+ "configLibrary",
214
+ `${CLAUDE_CONFIG_ID}.json`,
215
+ )),
216
+ chatgpt: fs.existsSync(path.join(actionPaths.chatgpt.codexHome, "config.toml")),
217
+ };
210
218
  const vendorPaths = {};
211
219
  for (const target of targets) {
212
220
  const isClaude = target === "claude";
213
221
  const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
214
222
  const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
215
223
  let binary = find(io.environment);
216
- if ((action === "install" || action === "update") && !flags["skip-vendor"]) {
224
+ const shouldEnsureVendor = !flags["skip-vendor"] && (
225
+ action === "install"
226
+ || action === "update"
227
+ || (action === "open" && !binary)
228
+ );
229
+ if (shouldEnsureVendor) {
217
230
  const vendor = ensure({ update: action === "update" }, { environment: io.environment });
218
231
  binary = vendor.binary;
219
232
  console.log(`${target}: vendor app ${vendor.action}`);
@@ -252,9 +265,12 @@ export async function cmdWindowsApps(argv, overrides = {}) {
252
265
  claudeUserData: userData,
253
266
  tenantName: config.tenantName,
254
267
  });
255
- if (action === "install" || action === "update" || action === "refresh") {
256
- const verb = action === "install" ? "Installed" : action === "update" ? "Updated" : "Refreshed";
257
- for (const target of targets) {
268
+ const configuredTargets = action === "open"
269
+ ? targets.filter((target) => !profileWasInstalled[target])
270
+ : targets;
271
+ if (action === "install" || action === "update" || action === "refresh" || configuredTargets.length > 0) {
272
+ const verb = action === "update" ? "Updated" : action === "refresh" ? "Refreshed" : "Installed";
273
+ for (const target of configuredTargets) {
258
274
  const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
259
275
  console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
260
276
  const { client, env, label } = appSkillTarget(target, tenantPaths);
@@ -358,43 +374,12 @@ export async function cmdApps(argv, overrides = {}) {
358
374
 
359
375
  const config = await selectedAppConfig(targets, flags.tenant || null);
360
376
  const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
361
- const legacyPaths = appPaths(os.homedir());
362
- for (const status of statuses) {
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
- }
366
- if (!status.vendorPath) throw new Error(`${status.label} vendor app is unavailable; reinstall the vendor app first`);
367
- }
368
- let catalog;
369
- try {
370
- catalog = await fetchGatewayModels(config);
371
- } catch (error) {
372
- throw new Error(
373
- `tenant model catalog is unavailable (${redactSecretText(error.message)}); the selected tenant app was not opened`,
374
- );
375
- }
376
- // Only stale bundles pay the clone + codesign rebuild; a bundle swap needs
377
- // its app closed, and quitting is fine here since we relaunch right after.
378
- const staleBundleTargets = statuses
379
- .filter((status) => !bundleIsCurrent(status))
380
- .map((status) => status.target);
381
- if (staleBundleTargets.length > 0) {
382
- await quitBlockingApps(staleBundleTargets, {
383
- tenantId: config.tenantId,
384
- tenantName: config.tenantName,
385
- });
386
- }
387
- installManagedAppFiles({
377
+ await provisionAndOpenManagedApps({
388
378
  config,
389
379
  targets,
390
- models: catalog.models,
380
+ statuses,
391
381
  homeDir: os.homedir(),
392
- vendorPaths: Object.fromEntries(statuses.map((status) => [status.target, status.vendorPath])),
393
- writeBundles: staleBundleTargets,
394
382
  });
395
- for (const status of appStatus(targets, os.homedir(), config.tenantId, config.tenantName)) {
396
- spawnSync("/usr/bin/open", ["-n", status.launcher], { stdio: "inherit" });
397
- }
398
383
  return;
399
384
  }
400
385
  if (action !== "install" && action !== "update") {
@@ -445,6 +430,99 @@ export async function cmdApps(argv, overrides = {}) {
445
430
  }
446
431
  }
447
432
 
433
+ /**
434
+ * Complete the slow `app open` path. A first open is intentionally equivalent
435
+ * to an install for the selected tenant: verify or install the pinned vendor,
436
+ * build the isolated bundle/profile, sync skills, and only then launch it.
437
+ * Existing current installs never reach this helper because fastOpenLaunchers
438
+ * handles them without network or rebuild work.
439
+ */
440
+ export async function provisionAndOpenManagedApps({
441
+ config,
442
+ targets,
443
+ statuses,
444
+ homeDir = os.homedir(),
445
+ }, overrides = {}) {
446
+ const io = {
447
+ fetchModels: fetchGatewayModels,
448
+ ensureVendor: ensureVendorApp,
449
+ bundleCurrent: bundleIsCurrent,
450
+ quitApps: quitBlockingApps,
451
+ installFiles: installManagedAppFiles,
452
+ syncSkills: syncSkillsSafe,
453
+ secureCodexHome: secureManagedCodexHome,
454
+ openLauncher: (launcher) => spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" }),
455
+ log: (message) => console.log(message),
456
+ ...overrides,
457
+ };
458
+
459
+ // Resolve the live tenant catalog before installing or changing anything.
460
+ // This keeps unavailable/unauthorized tenants fail-closed on first use.
461
+ let catalog;
462
+ try {
463
+ catalog = await io.fetchModels(config);
464
+ } catch (error) {
465
+ throw new Error(
466
+ `tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); the selected tenant app was not opened`,
467
+ );
468
+ }
469
+
470
+ const vendorPaths = {};
471
+ for (const status of statuses) {
472
+ let vendorPath = status.vendorPath;
473
+ if (!vendorPath) {
474
+ const result = io.ensureVendor(status.target, { homeDir });
475
+ vendorPath = result.path;
476
+ io.log(`${status.target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
477
+ if (!vendorPath) {
478
+ throw new Error(`${status.target} verified vendor app is unavailable; retry the pinned download`);
479
+ }
480
+ }
481
+ vendorPaths[status.target] = vendorPath;
482
+ }
483
+
484
+ // Only stale or absent bundles pay the clone + codesign rebuild. The model
485
+ // catalog and vendor checks above finish before any running apps are closed.
486
+ const staleBundleTargets = statuses
487
+ .filter((status) => !io.bundleCurrent(status))
488
+ .map((status) => status.target);
489
+ if (staleBundleTargets.length > 0) {
490
+ await io.quitApps(staleBundleTargets, {
491
+ tenantId: config.tenantId,
492
+ tenantName: config.tenantName,
493
+ });
494
+ }
495
+
496
+ const installed = io.installFiles({
497
+ config,
498
+ targets,
499
+ models: catalog.models,
500
+ homeDir,
501
+ vendorPaths,
502
+ writeBundles: staleBundleTargets,
503
+ });
504
+ const newlyInstalled = new Set(
505
+ statuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
506
+ );
507
+ for (const item of installed) {
508
+ if (newlyInstalled.has(item.target)) {
509
+ io.log(`Installed and configured ${item.launcher} for tenant ${config.tenantId}.`);
510
+ }
511
+ }
512
+
513
+ // Match explicit install setup without penalizing repeat opens: this helper
514
+ // only runs when the zero-network fast path has detected drift or first use.
515
+ const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
516
+ const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
517
+ for (const item of installed) {
518
+ const { client, env, label } = appSkillTarget(item.target, paths);
519
+ await io.syncSkills({ client, gatewayUrl, env, label });
520
+ if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
521
+ }
522
+ for (const item of installed) await io.openLauncher(item.launcher);
523
+ return installed;
524
+ }
525
+
448
526
  const REFRESH_TTL_MS = 6 * 60 * 60 * 1000;
449
527
 
450
528
  /**