impel-cli 0.17.8 → 0.17.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.8",
3
+ "version": "0.17.9",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -104,6 +104,39 @@ const CLAUDE_AGENT_MENTION_SELECT = 'onSelect:t=>(e(String(t)),Promise.resolve(n
104
104
  const IMPEL_CLAUDE_BOUND_AGENT_MENTION_SELECT = 'onSelect:t=>"string"==typeof y?Promise.resolve({chipText:String(t)}):(e(String(t)),Promise.resolve(null))';
105
105
  const LEGACY_CLAUDE_SAFE_STORAGE_NAME = "Claude";
106
106
  const CLAUDE_SAFE_STORAGE_METADATA = "safe-storage.json";
107
+ // The pinned Claude renderer decides desktop-vs-web mode SOLELY by matching its
108
+ // own Electron user-agent against /claude(nest|gov)?\/([^ ]+)/i (functions o_,
109
+ // x_, i_ in ion-dist). Electron derives that UA product token from app.name as
110
+ // `${app.name}/${version}`, and our Safe Storage rename patch sets app.name to
111
+ // this Safe Storage name. So the name MUST yield a "…Claude/<version>" token or
112
+ // the app runs in web mode, where /epitaxy is rewritten to /code and the
113
+ // sunset claude_code_web gate strands every session on /code/disabled ("Code
114
+ // with Claude anywhere"). The token matches iff "claude" is immediately
115
+ // followed by "/", i.e. the app.name ends in "Claude". Keep tenant scoping by
116
+ // putting the tenant BEFORE the word, never after it.
117
+ const CLAUDE_UA_DESKTOP_PATTERN = /claude(nest|gov)?\/[^ ]+/iu;
118
+
119
+ /** The Electron UA token Electron builds from this app.name at the pinned version. */
120
+ function claudeUserAgentToken(appName) {
121
+ return `${appName}/${PINNED_VENDOR_APPS.claude.version}`;
122
+ }
123
+
124
+ /** Whether an app.name keeps the renderer in desktop mode (see pattern above). */
125
+ export function claudeAppNameKeepsDesktopMode(appName) {
126
+ return CLAUDE_UA_DESKTOP_PATTERN.test(claudeUserAgentToken(appName));
127
+ }
128
+
129
+ /** Tenant-scoped Safe Storage / app name that still ends in "Claude". */
130
+ function impelClaudeSafeStorageName(tenantId) {
131
+ return tenantId
132
+ ? `Impel [${normalizeTenantId(tenantId)}] Claude`
133
+ : "Impel Claude";
134
+ }
135
+
136
+ // The pre-0.17.9 format put the tenant AFTER "Claude" ("Impel Claude [tenant]"),
137
+ // which breaks the desktop-mode UA match. Recognize exactly the names this CLI
138
+ // used to write so we can heal them without touching an unrelated custom name.
139
+ const LEGACY_TRAPPED_CLAUDE_NAME = /^Impel Claude( \[[^\]]+\])?$/u;
107
140
 
108
141
  export const FALLBACK_MODELS = [
109
142
  { id: "claude-opus-4-8", provider: "claude", display_name: "Claude Opus 4.8", family: "opus", family_default: true, default: true, context_window: 200000 },
@@ -174,7 +207,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
174
207
 
175
208
  // Bump when the written config/manifest schema changes; a mismatch forces the
176
209
  // slow open path (and thus a full config rewrite) after a CLI update.
177
- export const CURRENT_CONFIG_VERSION = 17;
210
+ export const CURRENT_CONFIG_VERSION = 18;
178
211
 
179
212
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
180
213
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -183,7 +216,7 @@ export const CURRENT_CONFIG_VERSION = 17;
183
216
  // — which is what made every `impel update` re-trigger macOS permission
184
217
  // prompts. Bump this ONLY when a code change alters the bytes of a built
185
218
  // bundle; leave it alone for changes that don't touch bundle contents.
186
- export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-21.1";
219
+ export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-21.2";
187
220
 
188
221
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
189
222
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -494,20 +527,38 @@ function readClaudeSafeStorageMetadata(paths) {
494
527
  }
495
528
 
496
529
  /**
497
- * Electron keys macOS Safe Storage by app.name. Existing profiles already have
498
- * encrypted data under `Claude Safe Storage`, so changing their name would make
499
- * that data unreadable. New profiles can safely start with an immutable,
500
- * tenant-specific namespace and never touch the vendor/shared Keychain item.
530
+ * Electron keys macOS Safe Storage by app.name, AND derives the renderer's
531
+ * user-agent (hence its desktop-vs-web mode) from it. Existing profiles already
532
+ * have encrypted data under `Claude Safe Storage`, so changing their name would
533
+ * make that data unreadable; they keep the legacy name (which is UA-safe). New
534
+ * profiles get an immutable, tenant-specific namespace that still ends in
535
+ * "Claude" so the renderer stays in desktop mode.
536
+ *
537
+ * A profile provisioned by 0.17.4–0.17.8 was written with the old
538
+ * "Impel Claude [tenant]" format, which forces the renderer into web mode and
539
+ * strands it on /code/disabled. Heal exactly those names in place: such
540
+ * profiles never worked, so they carry no Safe Storage secrets worth keeping.
501
541
  */
502
542
  export function ensureClaudeSafeStorageName(paths, tenantId = null) {
503
543
  const existing = readClaudeSafeStorageMetadata(paths);
544
+ if (existing && claudeAppNameKeepsDesktopMode(existing.appName)) return existing.appName;
545
+ const metadataPath = path.join(paths.claude.root, CLAUDE_SAFE_STORAGE_METADATA);
546
+ if (existing && LEGACY_TRAPPED_CLAUDE_NAME.test(existing.appName)) {
547
+ const healedName = impelClaudeSafeStorageName(tenantId);
548
+ writeAtomic(metadataPath, `${JSON.stringify({
549
+ schemaVersion: 1,
550
+ appName: healedName,
551
+ mode: existing.mode === "legacy" ? "legacy" : "tenant",
552
+ }, null, 2)}\n`, 0o600);
553
+ return healedName;
554
+ }
504
555
  if (existing) return existing.appName;
505
556
  const hasExistingProfile = fs.existsSync(paths.claude.userData)
506
557
  && fs.readdirSync(paths.claude.userData).length > 0;
507
558
  const appName = hasExistingProfile
508
559
  ? LEGACY_CLAUDE_SAFE_STORAGE_NAME
509
- : `Impel Claude${tenantId ? ` [${normalizeTenantId(tenantId)}]` : ""}`;
510
- writeAtomic(path.join(paths.claude.root, CLAUDE_SAFE_STORAGE_METADATA), `${JSON.stringify({
560
+ : impelClaudeSafeStorageName(tenantId);
561
+ writeAtomic(metadataPath, `${JSON.stringify({
511
562
  schemaVersion: 1,
512
563
  appName,
513
564
  mode: hasExistingProfile ? "legacy" : "tenant",
@@ -966,6 +1017,12 @@ function writeClaudeConfig(paths, config, models) {
966
1017
  // with an inference config present the pinned app selects 3P and hides the
967
1018
  // inapplicable Claude.ai sign-in option.
968
1019
  disableDeploymentModeChooser: true,
1020
+ // The bundle is pinned to an exact vendor version whose renderer patches
1021
+ // this CLI depends on. Left enabled, the app's Squirrel auto-updater pulls
1022
+ // newer builds from api.anthropic.com (only a re-signing mismatch has been
1023
+ // stopping the install), which would silently move a tenant off the pinned
1024
+ // version and reintroduce the /code/disabled desktop trap fixed here.
1025
+ disableAutoUpdates: true,
969
1026
  };
970
1027
  writeAtomic(path.join(dir, `${CLAUDE_CONFIG_ID}.json`), JSON.stringify(body, null, 2) + "\n", 0o600);
971
1028
  writeAtomic(path.join(dir, "_meta.json"), JSON.stringify({
@@ -99,21 +99,31 @@ export function selectCatalogAppTargets(
99
99
  return supported;
100
100
  }
101
101
 
102
+ // Turn a tenant-aware bundle display name into an app-profile sync label:
103
+ // "Impel Claude (cibi)" -> "Impel Claude app (cibi)", "Impel Claude" ->
104
+ // "Impel Claude app". This keeps app-target sync/agent lines attributable to a
105
+ // tenant, matching the "Impel Claude CLI (cibi)" convention the CLI targets use,
106
+ // instead of the ambiguous tenant-less "Impel Claude app" repeated per tenant.
107
+ function appTargetLabel(displayName) {
108
+ const match = displayName.match(/^(.*?)( \(.*\))$/u);
109
+ return match ? `${match[1]} app${match[2]}` : `${displayName} app`;
110
+ }
111
+
102
112
  // Each isolated desktop app maps to a client CLI + the env override that points
103
113
  // that CLI at the app's private profile, so skill syncing lands in the app's
104
114
  // installation rather than a global one.
105
115
  function appSkillTarget(target, paths) {
106
116
  if (target === "claude") {
107
- return { client: "claude", env: { CLAUDE_CONFIG_DIR: paths.claude.userData }, label: "Impel Claude app" };
117
+ return { client: "claude", env: { CLAUDE_CONFIG_DIR: paths.claude.userData }, label: appTargetLabel(paths.claude.displayName) };
108
118
  }
109
- return { client: "codex", env: { CODEX_HOME: paths.chatgpt.codexHome }, label: "Impel ChatGPT app" };
119
+ return { client: "codex", env: { CODEX_HOME: paths.chatgpt.codexHome }, label: appTargetLabel(paths.chatgpt.displayName) };
110
120
  }
111
121
 
112
122
  function appAgentProfile(target, paths) {
113
123
  if (target === "claude") {
114
- return { client: "claude", root: paths.claude.userData, label: "Impel Claude app" };
124
+ return { client: "claude", root: paths.claude.userData, label: appTargetLabel(paths.claude.displayName) };
115
125
  }
116
- return { client: "codex", root: paths.chatgpt.codexHome, label: "Impel ChatGPT app" };
126
+ return { client: "codex", root: paths.chatgpt.codexHome, label: appTargetLabel(paths.chatgpt.displayName) };
117
127
  }
118
128
 
119
129
  function windowsAppTargets(targetToken) {
@@ -75,9 +75,12 @@ function keychainCandidates(appsRoot) {
75
75
  const names = new Set();
76
76
  const tenantsRoot = path.join(appsRoot, "tenants");
77
77
  for (const tenantId of listEntries(tenantsRoot)) {
78
- for (const target of ["Impel Claude", "Impel ChatGPT"]) {
79
- names.add(`${target} [${tenantId}] Safe Storage`);
80
- }
78
+ // Cover both app-name eras: the current UA-safe "Impel [tenant] Claude"
79
+ // and the pre-0.17.9 "Impel Claude [tenant]" that may have left an orphaned
80
+ // Keychain item behind.
81
+ names.add(`Impel [${tenantId}] Claude Safe Storage`);
82
+ names.add(`Impel Claude [${tenantId}] Safe Storage`);
83
+ names.add(`Impel ChatGPT [${tenantId}] Safe Storage`);
81
84
  const metadataPath = path.join(tenantsRoot, tenantId, "claude", "safe-storage.json");
82
85
  try {
83
86
  const appName = JSON.parse(fs.readFileSync(metadataPath, "utf8"))?.appName;
package/src/skills.js CHANGED
@@ -27,6 +27,13 @@ import { nativeCommandInvocation } from "./nativeProcess.js";
27
27
  /** The bundled plugin the Bifrost registry publishes; contains every served skill. */
28
28
  export const SKILL_PLUGIN_NAME = "bifrost-all-skills";
29
29
 
30
+ // The marketplace `name` the gateway declares in every served marketplace.json
31
+ // (identical for the Claude and Codex flavors). Used only as a last resort when
32
+ // BOTH the manifest fetch and the `marketplace list --json` recovery fail:
33
+ // Codex's `plugin add` rejects a bare plugin id, so a known-name @-qualified
34
+ // install still has a chance where a bare add is guaranteed to error.
35
+ export const SKILL_MARKETPLACE_FALLBACK_NAME = "bifrost-skills";
36
+
30
37
  /** Final fallback only — prefer the configured gateway (see resolveSkillsGateway). */
31
38
  export const SKILLS_FALLBACK_GATEWAY_URL = "https://gateway.useimpel.ai";
32
39
 
@@ -150,32 +157,27 @@ export function buildSkillCommands({ client, marketplaceSourceUrl: sourceUrl, ma
150
157
  args: ["plugin", "update", name ? `${plugin}@${name}` : plugin],
151
158
  });
152
159
  } else {
153
- // Codex's `plugin add` requires a marketplace (PLUGIN@MARKETPLACE). Without a
154
- // name we can only best-effort a bare add; with a name we install, upgrade
155
- // the snapshot, and re-add to pin the refreshed bundle.
160
+ // Codex resolves `plugin add PLUGIN@MARKETPLACE` from the marketplace
161
+ // snapshot cloned at registration time; a re-registration is a no-op that
162
+ // does NOT refresh it. So upgrade the snapshot BEFORE installing — installing
163
+ // first against a snapshot that predates the current bundle fails with
164
+ // "plugin … was not found in marketplace …", then the old code's trailing
165
+ // re-add silently fixed it, producing a spurious warning. With the name we
166
+ // upgrade then install once; without it, an @-qualified install against the
167
+ // known fallback name still beats a bare add, which Codex always rejects.
168
+ const marketplaceName = name || SKILL_MARKETPLACE_FALLBACK_NAME;
156
169
  if (name) {
157
- commands.push({
158
- phase: "install",
159
- description: `install ${plugin}`,
160
- args: ["plugin", "add", `${plugin}@${name}`],
161
- });
162
170
  commands.push({
163
171
  phase: "refresh-marketplace",
164
172
  description: "refresh marketplace",
165
173
  args: ["plugin", "marketplace", "upgrade", name],
166
174
  });
167
- commands.push({
168
- phase: "refresh-plugin",
169
- description: `update ${plugin}`,
170
- args: ["plugin", "add", `${plugin}@${name}`],
171
- });
172
- } else {
173
- commands.push({
174
- phase: "install",
175
- description: `install ${plugin}`,
176
- args: ["plugin", "add", plugin],
177
- });
178
175
  }
176
+ commands.push({
177
+ phase: "install",
178
+ description: `install ${plugin}`,
179
+ args: ["plugin", "add", `${plugin}@${marketplaceName}`],
180
+ });
179
181
  }
180
182
 
181
183
  return commands;
@@ -292,10 +294,19 @@ async function runSkillCommandWithRetry(run, bin, args, env) {
292
294
  return run(bin, args, env);
293
295
  }
294
296
 
295
- /** Fetch the marketplace.json and return its registered name, or null on any failure. */
296
- export async function fetchMarketplaceName(url, fetchImpl = fetch) {
297
+ const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000;
298
+
299
+ // The gateway serves marketplace.json from a serverless function with an 8-15s
300
+ // cold TTFB. The vendor Claude CLI aborts its OWN marketplace fetch at a
301
+ // hardcoded 10s, so a cold gateway makes `plugin marketplace update` time out.
302
+ // Fetching here first (with a generous timeout and a retry) both resolves the
303
+ // name and warms the endpoint so the vendor CLI's later fetch hits a warm cache.
304
+ // A process-lifetime memo keeps a multi-tenant run from re-racing the cold path.
305
+ const marketplaceNameCache = new Map();
306
+
307
+ async function fetchMarketplaceNameOnce(url, fetchImpl, timeoutMs) {
297
308
  const controller = new AbortController();
298
- const timeout = setTimeout(() => controller.abort(), 10000);
309
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
299
310
  try {
300
311
  const response = await fetchImpl(url, { signal: controller.signal });
301
312
  if (!response?.ok) return null;
@@ -308,6 +319,25 @@ export async function fetchMarketplaceName(url, fetchImpl = fetch) {
308
319
  }
309
320
  }
310
321
 
322
+ /** Fetch the marketplace.json and return its registered name, or null on any failure. */
323
+ export async function fetchMarketplaceName(url, fetchImpl = fetch, {
324
+ timeoutMs = MARKETPLACE_FETCH_TIMEOUT_MS,
325
+ useCache = true,
326
+ } = {}) {
327
+ if (useCache && marketplaceNameCache.has(url)) return marketplaceNameCache.get(url);
328
+ let name = await fetchMarketplaceNameOnce(url, fetchImpl, timeoutMs);
329
+ if (name === null) name = await fetchMarketplaceNameOnce(url, fetchImpl, timeoutMs);
330
+ // Only memoize a resolved name: a transient failure must not poison later
331
+ // syncs in the same run, but a warmed name is stable for the process lifetime.
332
+ if (useCache && name !== null) marketplaceNameCache.set(url, name);
333
+ return name;
334
+ }
335
+
336
+ /** Test-only: drop the per-process marketplace-name memo. */
337
+ export function clearMarketplaceNameCache() {
338
+ marketplaceNameCache.clear();
339
+ }
340
+
311
341
  /**
312
342
  * Idempotently sync the Bifrost shared-skills plugin into one managed client
313
343
  * profile. Targets the SAME binary + profile the caller manages by passing the