impel-cli 0.15.0 → 0.15.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
@@ -118,9 +118,10 @@ isolated command-line workflow.
118
118
  If setup or the CLI self-update fails, Impel first runs a deterministic local
119
119
  check. In an interactive terminal it then offers assisted recovery; use
120
120
  `impel setup --repair` (or `impel update --repair`) to opt in explicitly. Before
121
- upload, the CLI prints the exact sanitized payload. The hosted agent can only
122
- return a reviewed action ID; the CLI rejects free-form commands and asks before
123
- every mutation. Sessions expire after 15 minutes, mutation capabilities are
121
+ upload, the CLI prints the exact sanitized payload. The hosted agent returns a
122
+ reviewed action ID, and the CLI runs its allowlisted command locally; free-form
123
+ model commands are rejected and every mutation requires confirmation. Sessions
124
+ expire after 15 minutes, mutation capabilities are
124
125
  single-use, and a private checkpoint lets `--repair` resume an interrupted run.
125
126
  Use `--no-recovery` or `IMPEL_DISABLE_INSTALL_RECOVERY=1` to keep recovery off.
126
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.15.0",
3
+ "version": "0.15.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/apps.js CHANGED
@@ -114,6 +114,16 @@ export function normalizeAppTarget(value) {
114
114
  return null;
115
115
  }
116
116
 
117
+ /** Return only app targets backed by at least one live tenant model. */
118
+ export function appTargetsSupportedByModels(targets, models, config = {}) {
119
+ if (!Array.isArray(models) || models.length === 0) return [];
120
+ if (crossAppModelsEnabled(config)) return [...targets];
121
+ const providers = new Set(models.map((model) => model?.provider));
122
+ return targets.filter((target) => providers.has(
123
+ target === "claude" ? "claude" : "codex",
124
+ ));
125
+ }
126
+
117
127
  function tenantAppName(value, fallback) {
118
128
  const name = redactSecretText(String(value || fallback))
119
129
  .normalize("NFC")
@@ -151,7 +161,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
151
161
 
152
162
  // Bump when the written config/manifest schema changes; a mismatch forces the
153
163
  // slow open path (and thus a full config rewrite) after a CLI update.
154
- export const CURRENT_CONFIG_VERSION = 10;
164
+ export const CURRENT_CONFIG_VERSION = 11;
155
165
 
156
166
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
157
167
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -604,6 +614,11 @@ export function installManagedAppFiles({
604
614
  tenantName: config.tenantName || config.tenantId || null,
605
615
  experiments: { crossAppModels: crossAppModelsEnabled(config) },
606
616
  targets,
617
+ availableTargets: appTargetsSupportedByModels(
618
+ ["claude", "chatgpt"],
619
+ models,
620
+ config,
621
+ ),
607
622
  models: models.map((model) => model.id),
608
623
  updatedAt: new Date().toISOString(),
609
624
  }, null, 2) + "\n", 0o600);
@@ -25,6 +25,7 @@ import {
25
25
  import {
26
26
  CURRENT_CONFIG_VERSION,
27
27
  CLAUDE_CONFIG_ID,
28
+ appTargetsSupportedByModels,
28
29
  appPaths,
29
30
  appStatus,
30
31
  bundleIsCurrent,
@@ -58,6 +59,45 @@ import {
58
59
 
59
60
  const CLAUDE_KEYCHAIN_NOTICE = "Claude Keychain: enter your Mac login password and choose Always Allow on the first prompt for this tenant; Allow is temporary.";
60
61
 
62
+ const APP_CAPABILITIES = Object.freeze({
63
+ claude: Object.freeze({ label: "Impel Claude", provider: "Claude" }),
64
+ chatgpt: Object.freeze({ label: "Impel ChatGPT", provider: "Codex" }),
65
+ });
66
+
67
+ /**
68
+ * Select targets the live tenant catalog can actually run. An explicit target
69
+ * remains fail-closed; `all` degrades to the available app with a clear notice.
70
+ */
71
+ export function selectCatalogAppTargets(
72
+ requestedTargets,
73
+ models,
74
+ config,
75
+ { log = console.log } = {},
76
+ ) {
77
+ const supported = appTargetsSupportedByModels(
78
+ requestedTargets,
79
+ models,
80
+ config,
81
+ );
82
+ const supportedSet = new Set(supported);
83
+ const unavailable = requestedTargets.filter((target) => !supportedSet.has(target));
84
+ if (unavailable.length === 0) return supported;
85
+
86
+ if (requestedTargets.length === 1 || supported.length === 0) {
87
+ const target = unavailable[0];
88
+ const capability = APP_CAPABILITIES[target];
89
+ throw new Error(
90
+ `${capability.label} is unavailable because the selected tenant has no ${capability.provider} models; choose a tenant with ${capability.provider} access or install the other app`,
91
+ );
92
+ }
93
+
94
+ for (const target of unavailable) {
95
+ const capability = APP_CAPABILITIES[target];
96
+ log(`Skipping ${capability.label}: the selected tenant has no ${capability.provider} models.`);
97
+ }
98
+ return supported;
99
+ }
100
+
61
101
  // Each isolated desktop app maps to a client CLI + the env override that points
62
102
  // that CLI at the app's private profile, so skill syncing lands in the app's
63
103
  // installation rather than a global one.
@@ -328,6 +368,14 @@ export async function cmdWindowsApps(argv, overrides = {}) {
328
368
  try {
329
369
  if (action === "open") maybePrintUpdateNotice();
330
370
  const config = await io.selectedConfig(targets, flags.tenant || null);
371
+ const catalog = await withProgress("Fetching the tenant model catalog", () => (
372
+ fetchWindowsCatalog(config, io)
373
+ ));
374
+ const actionTargets = selectCatalogAppTargets(
375
+ targets,
376
+ catalog.models,
377
+ config,
378
+ );
331
379
  const actionUserData = io.claudeUserData(io.environment, config.tenantId);
332
380
  const actionPaths = appPaths(io.homeDir, config.tenantId, {
333
381
  claudeUserData: actionUserData,
@@ -342,7 +390,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
342
390
  chatgpt: fs.existsSync(path.join(actionPaths.chatgpt.codexHome, "config.toml")),
343
391
  };
344
392
  const vendorPaths = {};
345
- for (const target of targets) {
393
+ for (const target of actionTargets) {
346
394
  const isClaude = target === "claude";
347
395
  const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
348
396
  const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
@@ -367,16 +415,13 @@ export async function cmdWindowsApps(argv, overrides = {}) {
367
415
  if (binary) vendorPaths[target] = binary;
368
416
  }
369
417
 
370
- if (targets.includes("claude") && action !== "refresh" && !vendorPaths.claude) {
418
+ if (actionTargets.includes("claude") && action !== "refresh" && !vendorPaths.claude) {
371
419
  throw new Error("Claude vendor app is unavailable; run `impel app install claude` or install it from https://claude.com/download");
372
420
  }
373
- const catalog = await withProgress("Fetching the tenant model catalog", () => (
374
- fetchWindowsCatalog(config, io)
375
- ));
376
- let managedChatGPT = targets.includes("chatgpt")
421
+ let managedChatGPT = actionTargets.includes("chatgpt")
377
422
  ? io.findManagedChatGPTApp(actionPaths.root)
378
423
  : null;
379
- const mustStageChatGPT = targets.includes("chatgpt")
424
+ const mustStageChatGPT = actionTargets.includes("chatgpt")
380
425
  && (action === "install"
381
426
  || action === "update"
382
427
  || (action === "open" && (!managedChatGPT
@@ -388,14 +433,14 @@ export async function cmdWindowsApps(argv, overrides = {}) {
388
433
  managedChatGPT = io.stageChatGPTApp(vendorPaths.chatgpt, actionPaths.root);
389
434
  }
390
435
 
391
- const { userData } = configureWindowsApps(config, targets, vendorPaths, catalog, io);
436
+ const { userData } = configureWindowsApps(config, actionTargets, vendorPaths, catalog, io);
392
437
  const tenantPaths = appPaths(io.homeDir, config.tenantId, {
393
438
  claudeUserData: userData,
394
439
  tenantName: config.tenantName,
395
440
  });
396
441
  const configuredTargets = action === "open"
397
- ? targets.filter((target) => !profileWasInstalled[target])
398
- : targets;
442
+ ? actionTargets.filter((target) => !profileWasInstalled[target])
443
+ : actionTargets;
399
444
  if (action === "install" || action === "update" || action === "refresh" || configuredTargets.length > 0) {
400
445
  const verb = action === "update" ? "Updated" : action === "refresh" ? "Refreshed" : "Installed";
401
446
  for (const target of configuredTargets) {
@@ -417,7 +462,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
417
462
  }
418
463
  if (!flags["skip-agents"]) {
419
464
  await withProgress("Syncing app agent profiles", () => io.syncAgents({
420
- profiles: targets.map((target) => appAgentProfile(target, tenantPaths)),
465
+ profiles: actionTargets.map((target) => appAgentProfile(target, tenantPaths)),
421
466
  gatewayUrl: config.gatewayUrl,
422
467
  credential: config.pat,
423
468
  tenantId: config.tenantId,
@@ -426,11 +471,11 @@ export async function cmdWindowsApps(argv, overrides = {}) {
426
471
  console.log(`Models: ${catalog.models.length} from ${catalog.source}. The signed vendor apps and their normal profiles were not changed.`);
427
472
  }
428
473
  if (action === "open") {
429
- if (targets.includes("claude")) {
474
+ if (actionTargets.includes("claude")) {
430
475
  await io.launchClaudeApp(vendorPaths.claude, tenantPaths.claude.userData, { environment: io.environment });
431
476
  console.log("Opened Impel Claude with its tenant-isolated Windows profile.");
432
477
  }
433
- if (targets.includes("chatgpt")) {
478
+ if (actionTargets.includes("chatgpt")) {
434
479
  managedChatGPT ||= io.findManagedChatGPTApp(tenantPaths.root);
435
480
  if (!managedChatGPT) throw new Error("Impel ChatGPT is not staged; run `impel app install codex`");
436
481
  await io.launchChatGPTApp(managedChatGPT, {
@@ -558,9 +603,24 @@ export async function cmdApps(argv, overrides = {}) {
558
603
  const config = await io.selectedConfig(targets, flags.tenant || null);
559
604
  console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
560
605
 
606
+ let catalog;
607
+ try {
608
+ catalog = await withProgress("Fetching the tenant model catalog", () => io.fetchModels(config));
609
+ } catch (error) {
610
+ throw new Error(
611
+ `tenant model catalog is unavailable (${redactSecretText(error.message)}); no Impel app files were changed`,
612
+ );
613
+ }
614
+ const actionTargets = selectCatalogAppTargets(
615
+ targets,
616
+ catalog.models,
617
+ config,
618
+ { log: io.log },
619
+ );
620
+
561
621
  const vendorPaths = {};
562
622
  if (!flags["skip-vendor"]) {
563
- for (const target of targets) {
623
+ for (const target of actionTargets) {
564
624
  // Reuse or install only the exact vendor build verified by this CLI.
565
625
  // Moving "latest" releases are never cloned into a managed bundle.
566
626
  const result = await withProgress(`Preparing the verified ${target} vendor app`, () => (
@@ -573,7 +633,7 @@ export async function cmdApps(argv, overrides = {}) {
573
633
  }
574
634
 
575
635
  const statuses = io.status(
576
- targets,
636
+ actionTargets,
577
637
  io.homeDir,
578
638
  config.tenantId,
579
639
  config.tenantName,
@@ -581,18 +641,9 @@ export async function cmdApps(argv, overrides = {}) {
581
641
  for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
582
642
  const force = Boolean(flags.force);
583
643
  const staleBundleTargets = force
584
- ? [...targets]
644
+ ? [...actionTargets]
585
645
  : statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
586
646
 
587
- let catalog;
588
- try {
589
- catalog = await withProgress("Fetching the tenant model catalog", () => io.fetchModels(config));
590
- } catch (error) {
591
- throw new Error(
592
- `tenant model catalog is unavailable (${redactSecretText(error.message)}); no Impel app files were changed`,
593
- );
594
- }
595
-
596
647
  // A running vendor or Impel app only needs to close when its bundle will be
597
648
  // swapped. Remember which managed launchers we closed so the update can
598
649
  // reopen them afterward and feel in-place (the fast path never quits at all).
@@ -616,7 +667,7 @@ export async function cmdApps(argv, overrides = {}) {
616
667
  staleBundleTargets.length > 0 ? "Rebuilding managed app bundles" : "Updating managed app profiles",
617
668
  () => io.installFiles({
618
669
  config,
619
- targets,
670
+ targets: actionTargets,
620
671
  models: catalog.models,
621
672
  homeDir: io.homeDir,
622
673
  vendorPaths,
@@ -705,8 +756,17 @@ export async function provisionAndOpenManagedApps({
705
756
  );
706
757
  }
707
758
 
759
+ const actionTargets = selectCatalogAppTargets(
760
+ targets,
761
+ catalog.models,
762
+ config,
763
+ { log: io.log },
764
+ );
765
+ const actionTargetSet = new Set(actionTargets);
766
+ const actionStatuses = statuses.filter((status) => actionTargetSet.has(status.target));
767
+
708
768
  const vendorPaths = {};
709
- for (const status of statuses) {
769
+ for (const status of actionStatuses) {
710
770
  let vendorPath = status.vendorPath;
711
771
  if (!vendorPath) {
712
772
  const result = await withProgress(`Preparing the verified ${status.target} vendor app`, () => (
@@ -723,7 +783,7 @@ export async function provisionAndOpenManagedApps({
723
783
 
724
784
  // Only stale or absent bundles pay the clone + codesign rebuild. The model
725
785
  // catalog and vendor checks above finish before any running apps are closed.
726
- const staleBundleTargets = statuses
786
+ const staleBundleTargets = actionStatuses
727
787
  .filter((status) => !io.bundleCurrent(status))
728
788
  .map((status) => status.target);
729
789
  if (staleBundleTargets.length > 0) {
@@ -737,7 +797,7 @@ export async function provisionAndOpenManagedApps({
737
797
  staleBundleTargets.length > 0 ? "Rebuilding managed app bundles" : "Updating managed app profiles",
738
798
  () => io.installFiles({
739
799
  config,
740
- targets,
800
+ targets: actionTargets,
741
801
  models: catalog.models,
742
802
  homeDir,
743
803
  vendorPaths,
@@ -745,7 +805,7 @@ export async function provisionAndOpenManagedApps({
745
805
  }),
746
806
  );
747
807
  const newlyInstalled = new Set(
748
- statuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
808
+ actionStatuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
749
809
  );
750
810
  for (const item of installed) {
751
811
  if (newlyInstalled.has(item.target)) {
@@ -802,13 +862,30 @@ export function fastOpenLaunchers(targets, { homeDir = os.homedir(), config = lo
802
862
  ) {
803
863
  return null;
804
864
  }
865
+ const availableTargets = Array.isArray(manifest.availableTargets)
866
+ ? manifest.availableTargets
867
+ : [];
868
+ const candidateTargets = targets.length > 1
869
+ ? targets.filter((target) => availableTargets.includes(target))
870
+ : targets;
871
+ if (
872
+ candidateTargets.length === 0
873
+ || candidateTargets.some((target) => !availableTargets.includes(target))
874
+ || candidateTargets.some((target) => !manifest.targets?.includes(target))
875
+ ) {
876
+ return null;
877
+ }
878
+ const candidateTargetSet = new Set(candidateTargets);
879
+ const candidateStatuses = statuses
880
+ ? statuses.filter((status) => candidateTargetSet.has(status.target))
881
+ : appStatus(
882
+ candidateTargets,
883
+ homeDir,
884
+ config.tenantId,
885
+ config.tenantName || manifest.tenantName,
886
+ );
805
887
  const launchers = [];
806
- for (const status of statuses ?? appStatus(
807
- targets,
808
- homeDir,
809
- config.tenantId,
810
- config.tenantName || manifest.tenantName,
811
- )) {
888
+ for (const status of candidateStatuses) {
812
889
  if (!bundleIsCurrent(status)) return null;
813
890
  if (!fs.existsSync(status.configPath)) return null;
814
891
  if (status.target === "claude" && !claudeConfigIsCurrent(status.configPath, config, gatewayUrl)) {
@@ -870,24 +947,33 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
870
947
  } catch {
871
948
  return;
872
949
  }
950
+ const supportedTargetSet = new Set(appTargetsSupportedByModels(
951
+ installedTargets.map((status) => status.target),
952
+ catalog.models,
953
+ config,
954
+ ));
955
+ const supportedStatuses = installedTargets.filter((status) => (
956
+ supportedTargetSet.has(status.target)
957
+ ));
958
+ if (supportedStatuses.length === 0) return;
873
959
  installManagedAppFiles({
874
960
  config,
875
- targets: installedTargets.map((status) => status.target),
961
+ targets: supportedStatuses.map((status) => status.target),
876
962
  models: catalog.models,
877
963
  homeDir: os.homedir(),
878
- vendorPaths: Object.fromEntries(installedTargets.map((status) => [status.target, status.vendorPath])),
964
+ vendorPaths: Object.fromEntries(supportedStatuses.map((status) => [status.target, status.vendorPath])),
879
965
  writeBundles: false,
880
966
  });
881
967
 
882
968
  const tenantPaths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
883
969
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
884
- for (const status of installedTargets) {
970
+ for (const status of supportedStatuses) {
885
971
  const { client, env, label } = appSkillTarget(status.target, tenantPaths);
886
972
  await syncSkillsSafe({ client, gatewayUrl, env, label });
887
973
  if (status.target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
888
974
  }
889
975
  await syncAgentProfilesSafe({
890
- profiles: installedTargets.map((status) => appAgentProfile(status.target, tenantPaths)),
976
+ profiles: supportedStatuses.map((status) => appAgentProfile(status.target, tenantPaths)),
891
977
  gatewayUrl: config.gatewayUrl,
892
978
  credential: config.pat,
893
979
  tenantId: config.tenantId,
@@ -329,7 +329,7 @@ export async function cmdSetup(argv, overrides = {}) {
329
329
  message: "Windows desktop app setup returned an unsuccessful result.",
330
330
  });
331
331
  } else {
332
- console.log(" ✓ Impel Claude and Impel ChatGPT profiles are ready");
332
+ console.log(" ✓ available Impel desktop app profiles are ready");
333
333
  }
334
334
  } catch (error) {
335
335
  platformSetupFailed = true;
@@ -354,7 +354,7 @@ export async function cmdSetup(argv, overrides = {}) {
354
354
  );
355
355
  try {
356
356
  await io.installApps(["install", "all"]);
357
- console.log(" ✓ Impel Claude and Impel ChatGPT installed");
357
+ console.log(" ✓ available Impel desktop apps installed");
358
358
  } catch (error) {
359
359
  platformSetupFailed = true;
360
360
  console.error(
@@ -72,7 +72,7 @@ async function uploadConsent(io, explicit, failure) {
72
72
  "Install recovery can send a sanitized failure envelope to Impel. Credentials, home paths, email addresses, ANSI controls, and raw tokens are removed."
73
73
  );
74
74
  io.log(
75
- "The hosted agent can only propose typed action IDs; it cannot run commands or access this machine."
75
+ "The hosted agent selects typed action IDs; the Impel CLI runs the corresponding reviewed commands on this machine after local policy and confirmation checks."
76
76
  );
77
77
  io.log("Sanitized payload preview:");
78
78
  io.log(JSON.stringify(failure, null, 2));
package/src/skills.js CHANGED
@@ -74,6 +74,23 @@ export function resolveMarketplaceName(marketplace) {
74
74
  return typeof name === "string" && name.trim() ? name.trim() : null;
75
75
  }
76
76
 
77
+ /** Resolve a marketplace name from Claude's local `marketplace list --json`. */
78
+ export function resolveConfiguredMarketplaceName(output, sourceUrl) {
79
+ let marketplaces;
80
+ try {
81
+ marketplaces = JSON.parse(String(output || ""));
82
+ } catch {
83
+ return null;
84
+ }
85
+ if (!Array.isArray(marketplaces)) return null;
86
+ const normalizedSource = String(sourceUrl || "").replace(/\/+$/u, "");
87
+ const marketplace = marketplaces.find((candidate) => (
88
+ typeof candidate?.url === "string"
89
+ && candidate.url.replace(/\/+$/u, "") === normalizedSource
90
+ ));
91
+ return resolveMarketplaceName(marketplace);
92
+ }
93
+
77
94
  /**
78
95
  * Resolve the gateway URL for skill serving. Reuses the CLI's configured gateway
79
96
  * (the caller passes `config.gatewayUrl`), then the env/default from config.js,
@@ -317,11 +334,38 @@ export async function syncSkills({
317
334
 
318
335
  const manifestUrl = marketplaceUrl(gatewayUrl, client);
319
336
  const sourceUrl = marketplaceSourceUrl(gatewayUrl, client);
320
- const marketplaceName = await fetchMarketplaceName(manifestUrl, fetchImpl);
321
- const commands = buildSkillCommands({ client, marketplaceSourceUrl: sourceUrl, marketplaceName });
337
+ let marketplaceName = await fetchMarketplaceName(manifestUrl, fetchImpl);
322
338
 
323
339
  logger.log(`Skills: syncing ${SKILL_PLUGIN_NAME} for ${displayLabel}…`);
324
340
  const failures = [];
341
+ const registerCommand = buildSkillCommands({
342
+ client,
343
+ marketplaceSourceUrl: sourceUrl,
344
+ marketplaceName,
345
+ })[0];
346
+ const registerResult = await run(spec.bin, registerCommand.args, env);
347
+ if (registerResult.missing) {
348
+ failures.push({ phase: registerCommand.phase, reason: `\`${spec.bin}\` disappeared mid-sync` });
349
+ } else if (!isBenign(registerResult)) {
350
+ failures.push({
351
+ phase: registerCommand.phase,
352
+ reason: firstLine(registerResult.stderr) || `exit ${registerResult.status}`,
353
+ });
354
+ }
355
+
356
+ // The public manifest fetch is deliberately best-effort and can time out.
357
+ // Once Claude has registered the marketplace, its local JSON index gives us
358
+ // the same dynamic name so refreshes can still use PLUGIN@MARKETPLACE.
359
+ if (!marketplaceName && client === "claude" && !registerResult.missing) {
360
+ const listed = await run(spec.bin, ["plugin", "marketplace", "list", "--json"], env);
361
+ if (listed.ok) marketplaceName = resolveConfiguredMarketplaceName(listed.stdout, sourceUrl);
362
+ }
363
+
364
+ const commands = registerResult.missing ? [] : buildSkillCommands({
365
+ client,
366
+ marketplaceSourceUrl: sourceUrl,
367
+ marketplaceName,
368
+ }).slice(1);
325
369
  for (const command of commands) {
326
370
  const result = await run(spec.bin, command.args, env);
327
371
  if (result.missing) {