impel-cli 0.20.53 → 0.20.54

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/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.54 — Restore ChatGPT Store compatibility and scope skills per tenant
4
+
5
+ - Restores managed ChatGPT/Codex installation on Windows by pinning Store
6
+ package `26.814.5167.0` (embedded Codex `0.148.0-alpha.15`) — the build the
7
+ Store actually fulfills, after it stopped serving the previous package.
8
+ Retains exact package, publisher, executable, and runtime verification.
9
+ - Holds the macOS ChatGPT desktop pin at `26.810.41047` (embedded Codex
10
+ `0.148.0-alpha.9`) rather than advancing it with Windows. Build
11
+ `26.814.41957` breaks the managed Tasks board: its view is destroyed instead
12
+ of hidden when returning to the app. The pins are deliberately split for this
13
+ release and rejoin once a macOS build passes the launch smoke.
14
+ - Syncs skills with the selected tenant's credential so tenant-scoped skills
15
+ reach managed profiles. An uncredentialed fetch returns the gateway's
16
+ globals-only view with a 200, so the previous sync silently installed global
17
+ skills only.
18
+ - Reconciles each managed profile against the fetched view, removing skills
19
+ this CLI installed that the tenant no longer receives, and leaving
20
+ user-installed skills untouched.
21
+ - Leaves installed skills byte-identical and exits non-zero when the gateway
22
+ cannot determine the tenant, so an identity outage can never be mistaken for
23
+ an empty view.
24
+
3
25
  ## 0.20.53 — Launch distributed managed CLIs
4
26
 
5
27
  - Adds `impel claude` and `impel codex` fallback launch through an installed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.53",
3
+ "version": "0.20.54",
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
@@ -105,17 +105,38 @@ export const PINNED_VENDOR_APPS = Object.freeze({
105
105
  }),
106
106
  }),
107
107
  chatgpt: Object.freeze({
108
+ // DELIBERATELY SPLIT FROM THE WINDOWS PIN BELOW (2026-08-18).
109
+ //
110
+ // These normally move together, and the default should remain one
111
+ // coordinated build. macOS is held one build back because ChatGPT
112
+ // 26.814.41957 changed its onboarding flow and the managed Tasks board's
113
+ // WebContentsView is destroyed rather than hidden when navigating back to
114
+ // the app: the launch smoke fails reproducibly with zero surviving board
115
+ // targets. That is a real incompatibility with the desktop-tasks
116
+ // integration, not a flaky check, so the newer macOS build is not shipped
117
+ // until it is fixed.
118
+ //
119
+ // Windows cannot wait: the Store stopped fulfilling the old package
120
+ // entirely, so holding both would leave Windows unable to install at all.
121
+ // Rejoin the two as soon as a macOS build passes the launch smoke.
108
122
  version: "26.810.41047",
109
123
  codexVersion: "0.148.0-alpha.9",
110
124
  bundleName: "ChatGPT.app",
111
125
  windows: Object.freeze({
112
126
  storeProductId: "9PLM9XGG6VKS",
113
127
  packageName: "OpenAI.Codex",
114
- // The Microsoft Store manifest identifies this exact reviewed build.
115
- // Keep one Windows package version so the manifest contract and local
116
- // AppX validation cannot silently drift apart again.
117
- packageVersion: "26.810.4967.0",
118
- codexVersion: "0.148.0-alpha.9",
128
+ // The exact AppX version Windows registers for this Store product, and
129
+ // the only version production validates: ensureWindowsChatGPTApp installs
130
+ // by storeProductId and then fail-closed-checks the REGISTERED package
131
+ // against this value. updateManifestUrl below is advertisement, read by
132
+ // the contract test alone and never by the install path.
133
+ //
134
+ // Those two disagree as of 2026-08-18: the manifest advertises
135
+ // 26.814.5517.0 while the Store installs 26.814.5167.0. Pin what actually
136
+ // installs — a pin matching only the advertisement would fail closed on
137
+ // every real machine.
138
+ packageVersion: "26.814.5167.0",
139
+ codexVersion: "0.148.0-alpha.15",
119
140
  publisherId: "2p2nqsd0c76g0",
120
141
  executable: "app\\ChatGPT.exe",
121
142
  updateManifestUrl: "https://persistent.oaistatic.com/codex-app-prod/windows-store-update.json",
@@ -42,6 +42,7 @@ import {
42
42
  readTenantManifest,
43
43
  removeManagedApps,
44
44
  } from "../apps.js";
45
+ import { resolveSkillCredential } from "../skillBundle.js";
45
46
  import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
46
47
  import { syncAgentProfilesSafe } from "../agents.js";
47
48
  import { secureManagedCodexHome } from "../codexSecurity.js";
@@ -490,7 +491,7 @@ export async function reconcileWindowsTenantApps({
490
491
  // Merge the sync env so an app-embedded binary (IMPEL_CODEX_BIN) satisfies
491
492
  // the availability check even when no standalone CLI is installed.
492
493
  if (io.findBinary(client, { ...environment, ...env }, "win32")) {
493
- if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label, homeDir: os.homedir() });
494
+ if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), credential: resolveSkillCredential(config), env, label, homeDir: os.homedir() });
494
495
  agentTargets.push(target);
495
496
  } else {
496
497
  io.log(`Skipping skill/agent sync for ${label} — no ${client === "claude" ? "Claude Code" : "Codex"} binary is available to run plugin commands.`);
@@ -611,7 +612,7 @@ export async function reconcileMacTenantApps({
611
612
  for (const item of installed) {
612
613
  const { client, env, label } = appSkillTarget(item.target, paths);
613
614
  if (io.findBinary(client, environment, "darwin")) {
614
- if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label, homeDir: os.homedir() });
615
+ if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), credential: resolveSkillCredential(config), env, label, homeDir: os.homedir() });
615
616
  agentItems.push(item);
616
617
  }
617
618
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
@@ -859,6 +860,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
859
860
  io.syncSkills({
860
861
  client,
861
862
  gatewayUrl: resolveSkillsGateway(config.gatewayUrl),
863
+ credential: resolveSkillCredential(config),
862
864
  env,
863
865
  label,
864
866
  homeDir: os.homedir(),
@@ -1119,7 +1121,7 @@ export async function cmdApps(argv, overrides = {}) {
1119
1121
  const { client, env, label } = appSkillTarget(item.target, paths);
1120
1122
  if (RUNTIME_BRAND.features.skills && !flags["skip-skills"]) {
1121
1123
  await withProgress(`Syncing skills for ${label}`, (spinner) => (
1122
- io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
1124
+ io.syncSkills({ client, gatewayUrl, credential: resolveSkillCredential(config), env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
1123
1125
  ));
1124
1126
  }
1125
1127
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
@@ -1250,7 +1252,7 @@ export async function provisionAndOpenManagedApps({
1250
1252
  const { client, env, label } = appSkillTarget(item.target, paths);
1251
1253
  if (RUNTIME_BRAND.features.skills) {
1252
1254
  await withProgress(`Syncing skills for ${label}`, (spinner) => (
1253
- io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
1255
+ io.syncSkills({ client, gatewayUrl, credential: resolveSkillCredential(config), env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
1254
1256
  ));
1255
1257
  }
1256
1258
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
@@ -1422,7 +1424,7 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
1422
1424
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
1423
1425
  for (const status of supportedStatuses) {
1424
1426
  const { client, env, label } = appSkillTarget(status.target, tenantPaths);
1425
- if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir() });
1427
+ if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl, credential: resolveSkillCredential(config), env, label, homeDir: os.homedir() });
1426
1428
  if (status.target === "chatgpt") io.secureCodexHome(tenantPaths.chatgpt.codexHome);
1427
1429
  }
1428
1430
  if (RUNTIME_BRAND.features.agents) {
@@ -12,6 +12,7 @@ import { promptText } from "../prompt.js";
12
12
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
13
13
  import { restoreNativeProfiles } from "./use.js";
14
14
  import { createTenantRecoveryReportTracker } from "./tenantRecoveryReport.js";
15
+ import { resolveSkillCredential } from "../skillBundle.js";
15
16
 
16
17
  const RETRYABLE_TENANT_DISCOVERY_ERROR = /could not reach|request timed out|fetch failed|ECONNRESET|ECONNABORTED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|network error|socket/iu;
17
18
 
@@ -152,6 +153,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
152
153
  const inspected = await io.preparePlatformClis({
153
154
  gatewayUrl: config.gatewayUrl,
154
155
  tenantId: selected.id,
156
+ credential: resolveSkillCredential({ pat: config.pat, tenantId: selected.id }),
155
157
  platform: io.platform,
156
158
  skipInstall: true,
157
159
  });
@@ -183,6 +185,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
183
185
  prepared = await io.preparePlatformClis({
184
186
  gatewayUrl: config.gatewayUrl,
185
187
  tenantId: selected.id,
188
+ credential: resolveSkillCredential({ pat: config.pat, tenantId: selected.id }),
186
189
  platform: io.platform,
187
190
  skipInstall: false,
188
191
  installTools,
@@ -234,6 +237,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
234
237
  skipInstall: true,
235
238
  // Goals re-run before every system-risk step: the probe must be
236
239
  // pure discovery — no installs, profile writes, or skill syncs.
240
+ // Deliberately no credential: nothing here reaches a skills fetch.
237
241
  inspectOnly: true,
238
242
  });
239
243
  return prepared.missingAfter.length === 0;
@@ -249,6 +253,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
249
253
  return io.preparePlatformClis({
250
254
  gatewayUrl: config.gatewayUrl,
251
255
  tenantId: selected.id,
256
+ credential: resolveSkillCredential({ pat: config.pat, tenantId: selected.id }),
252
257
  platform: io.platform,
253
258
  skipInstall: true,
254
259
  });
@@ -14,6 +14,7 @@ import { CODEX_HOME } from "../codexSetup.js";
14
14
  import { tenantCliProfilePaths } from "../cliProfiles.js";
15
15
  import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
16
16
  import { environmentValue } from "../nativeProcess.js";
17
+ import { resolveSkillCredential } from "../skillBundle.js";
17
18
  import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
18
19
  import { ensureTenantSelection } from "../tenants.js";
19
20
  import { windowsClaudeUserData, windowsManagedChatGPTRoot } from "../windowsApps.js";
@@ -113,6 +114,7 @@ export async function cmdSkills(argv, overrides = {}) {
113
114
  loadConfig,
114
115
  ensureTenantSelection,
115
116
  managedProfiles: managedSkillProfiles,
117
+ resolveCredential: resolveSkillCredential,
116
118
  syncSkills: syncSkillsSafe,
117
119
  ...overrides,
118
120
  };
@@ -121,6 +123,10 @@ export async function cmdSkills(argv, overrides = {}) {
121
123
  const tenantId = config?.pat
122
124
  ? (await io.ensureTenantSelection(config)).tenantId
123
125
  : null;
126
+ // Bound to the tenant just selected, not to the bare PAT: on a multi-tenant
127
+ // account the PAT alone resolves to identity's default tenant, which is not
128
+ // necessarily the one these profiles are configured for.
129
+ const credential = io.resolveCredential(config);
124
130
  const clients = target === "all" ? ["claude", "codex"] : [target];
125
131
  const profiles = clients.flatMap((client) => (
126
132
  io.managedProfiles(client, {
@@ -129,15 +135,27 @@ export async function cmdSkills(argv, overrides = {}) {
129
135
  }).map((profile) => ({ client, profile }))
130
136
  ));
131
137
 
132
- await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, (spinner) => (
138
+ const results = await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, (spinner) => (
133
139
  mapWithConcurrency(profiles, 4, ({ client, profile }) => (
134
140
  io.syncSkills({
135
141
  client,
136
142
  gatewayUrl,
143
+ credential,
137
144
  env: profile.env,
138
145
  label: profile.label,
139
146
  logger: createProgressLogger(spinner),
140
147
  })
141
148
  ))
142
149
  ));
150
+
151
+ // Contract §2: an unavailable gateway must exit non-zero. It is reported
152
+ // rather than thrown because the other profiles' syncs are still valid work
153
+ // and their results have already been printed.
154
+ if (results.some((result) => result?.reason === "unavailable")) {
155
+ console.error(
156
+ "impel skills: the gateway could not determine this machine's tenant. "
157
+ + "Installed skills were left unchanged; re-run `impel skills sync` once it recovers.",
158
+ );
159
+ process.exitCode = 1;
160
+ }
143
161
  }
package/src/macSetup.js CHANGED
@@ -171,6 +171,10 @@ function installationFailure(result, thrown = null) {
171
171
  export async function prepareMacClis({
172
172
  gatewayUrl,
173
173
  tenantId,
174
+ // Tenant credential for the authenticated skills fetch. Optional: an
175
+ // unauthenticated setup still provisions profiles, it just receives the
176
+ // gateway's globals-only view (see syncSkills).
177
+ credential = null,
174
178
  skipInstall = false,
175
179
  inspectOnly = false,
176
180
  installTools = ["claude", "codex"],
@@ -246,6 +250,7 @@ export async function prepareMacClis({
246
250
  await io.syncSkills({
247
251
  client: "claude",
248
252
  gatewayUrl,
253
+ credential,
249
254
  env: {
250
255
  CLAUDE_CONFIG_DIR: claudeProfile.configDir,
251
256
  IMPEL_CLAUDE_BIN: binaries.claude,
@@ -259,6 +264,7 @@ export async function prepareMacClis({
259
264
  await io.syncSkills({
260
265
  client: "codex",
261
266
  gatewayUrl,
267
+ credential,
262
268
  env: {
263
269
  CODEX_HOME: codexProfile.codexHome,
264
270
  IMPEL_CODEX_BIN: binaries.codex,
@@ -7,6 +7,7 @@ import { crossAppModelsEnabled, redactSecretText } from "./config.js";
7
7
  import { ensureImpelClaudeProfile, ensureImpelCodexProfile, tenantCliProfilePaths } from "./cliProfiles.js";
8
8
  import { mapWithConcurrency } from "./progress.js";
9
9
  import { registerTenantShellEntries } from "./shellEntries.js";
10
+ import { resolveSkillCredential } from "./skillBundle.js";
10
11
  import { resolveSkillsGateway, syncSkillsSafe } from "./skills.js";
11
12
  import {
12
13
  normalizeTenantId,
@@ -128,6 +129,10 @@ async function prepareTenantCli(config, tenant, io, binaries) {
128
129
  if (RUNTIME_BRAND.features.skills) await io.syncSkills({
129
130
  client,
130
131
  gatewayUrl,
132
+ // Scoped to THIS tenant, not the config's currently selected one: this
133
+ // loop provisions every accessible tenant, so a credential built from
134
+ // the selected tenant would give every profile the same tenant's view.
135
+ credential: resolveSkillCredential({ pat: config.pat, tenantId: tenant.id }),
131
136
  env: definition.env(profile),
132
137
  label: `Impel ${client === "claude" ? "Claude" : "Codex"} CLI (${tenant.id})`,
133
138
  // Lets Windows spawns use a git-safe cwd (see skillSyncSpawnDirectory).
@@ -0,0 +1,389 @@
1
+ // Fetches the selected tenant's skills from the Bifrost registry and
2
+ // reconciles them into a managed client profile's `skills/` directory.
3
+ //
4
+ // Contract: impel-gateway `docs/skills-tenant-scope-cli-contract.md`.
5
+ //
6
+ // Why this exists at all: the gateway filters every skills-serving surface by
7
+ // the requesting tenant, and "no credential" is not an error there — it is the
8
+ // globals-only view, served with a 200. The vendor plugin commands
9
+ // (`claude plugin install`, `codex plugin add`) fetch the gateway themselves
10
+ // and cannot be given a credential, so every profile they populate receives
11
+ // globals only. This module is the authenticated path: it asks the gateway for
12
+ // the bundle AS THE TENANT and writes the answer to disk itself.
13
+ //
14
+ // Three gateway outcomes, deliberately NOT unified (contract §2):
15
+ //
16
+ // 200 + view -> authoritative; install it and remove managed skills
17
+ // absent from it.
18
+ // 200 + globals -> also authoritative (the credential was refused, not
19
+ // unanswered). Reconcile, but say so: on a machine that
20
+ // expected scoped skills this usually means the credential
21
+ // never arrived.
22
+ // 503 unavailable -> identity could not answer. CHANGE NOTHING ON DISK.
23
+ //
24
+ // The 503 rule is the load-bearing one. An identity outage rendered as an empty
25
+ // view is indistinguishable from "your tenant's skills were all removed", and
26
+ // the removal pass below would then faithfully delete every working skill on
27
+ // the machine. So a failed fetch must never reach reconciliation as an empty
28
+ // collection — it aborts before touching anything.
29
+
30
+ import crypto from "node:crypto";
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+ import zlib from "node:zlib";
34
+
35
+ import { normalizeGatewayUrl, redactSecretText } from "./config.js";
36
+ import { normalizeTenantId, tenantCredential } from "./tenants.js";
37
+
38
+ /** Marks the skills this CLI installed, so reconciliation never touches a user's own. */
39
+ export const MANAGED_SKILL_MANIFEST = ".impel-managed-skills.json";
40
+ export const MANAGED_SKILL_MANIFEST_VERSION = 1;
41
+
42
+ /** The tenant-filtered bundle of every skill the requesting tenant may see. */
43
+ const BUNDLE_PATH = "/api/skills/serve/all/download.zip";
44
+
45
+ const BUNDLE_FETCH_TIMEOUT_MS = 60_000;
46
+ // The gateway serves this from a serverless function with a cold TTFB, and the
47
+ // bundle is a megabyte-scale zip. One retry covers a cold start; more would
48
+ // just extend an outage the caller should hear about promptly.
49
+ const BUNDLE_FETCH_ATTEMPTS = 2;
50
+
51
+ // A decompressed bundle is a few megabytes. This ceiling is far above that and
52
+ // exists only so a malformed or hostile response cannot exhaust memory.
53
+ const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
54
+ const MAX_ENTRY_BYTES = 16 * 1024 * 1024;
55
+ const MAX_ENTRIES = 4096;
56
+
57
+ // Skill directory names are the first path segment of every bundle entry and
58
+ // become directories under the profile. Constrain them to what the registry
59
+ // actually publishes so no entry can traverse, hide, or collide with the
60
+ // manifest.
61
+ const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
62
+
63
+ /**
64
+ * Thrown when the gateway could not determine the tenant (contract §2, row 3).
65
+ *
66
+ * A distinct type rather than a flag because the whole point is that callers
67
+ * must not treat it like an empty view: an `instanceof` check is harder to
68
+ * collapse by accident than an `if (result.skills.length === 0)`.
69
+ */
70
+ export class SkillsUnavailableError extends Error {
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = "SkillsUnavailableError";
74
+ }
75
+ }
76
+
77
+ /** The bundle URL for a gateway origin. */
78
+ export function skillBundleUrl(gatewayUrl) {
79
+ return `${normalizeGatewayUrl(gatewayUrl)}${BUNDLE_PATH}`;
80
+ }
81
+
82
+ /**
83
+ * Fetch the tenant's skill bundle.
84
+ *
85
+ * The credential rides an `Authorization: Bearer` header. It is a tenant
86
+ * credential (PAT scoped to one tenant id), never the bare PAT: the bare PAT
87
+ * resolves to whatever tenant identity picks for it, which on a multi-tenant
88
+ * account is not necessarily the tenant the caller selected.
89
+ *
90
+ * Returns the raw zip bytes. Throws SkillsUnavailableError for anything that
91
+ * means "the answer is unknown" — a 5xx, a transport failure, or a timeout —
92
+ * so no caller can mistake an outage for an empty tenant.
93
+ */
94
+ export async function fetchSkillBundle({
95
+ gatewayUrl,
96
+ credential,
97
+ fetchImpl = fetch,
98
+ timeoutMs = BUNDLE_FETCH_TIMEOUT_MS,
99
+ attempts = BUNDLE_FETCH_ATTEMPTS,
100
+ }) {
101
+ if (!credential) {
102
+ // Deliberately not a silent globals-only fetch. A caller that reached here
103
+ // without a credential has a bug, and quietly serving it the narrow view is
104
+ // exactly the failure this module exists to end.
105
+ throw new SkillsUnavailableError("a tenant credential is required to fetch tenant-scoped skills");
106
+ }
107
+ const url = skillBundleUrl(gatewayUrl);
108
+ let lastError = null;
109
+ for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
110
+ const controller = new AbortController();
111
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
112
+ try {
113
+ const response = await fetchImpl(url, {
114
+ headers: { authorization: `Bearer ${credential}`, accept: "application/zip" },
115
+ signal: controller.signal,
116
+ });
117
+ if (response.status >= 500) {
118
+ // The gateway's "identity could not answer" branch. Retry once — a cold
119
+ // start looks the same from here — then surface it as unavailable.
120
+ lastError = new SkillsUnavailableError(
121
+ `the gateway could not determine this machine's tenant (HTTP ${response.status})`,
122
+ );
123
+ continue;
124
+ }
125
+ if (!response.ok) {
126
+ // A 4xx is an answer: the credential is rejected or the route is gone.
127
+ // Still not something to reconcile against, but it will not fix itself
128
+ // on a retry, so fail immediately and name it.
129
+ throw new SkillsUnavailableError(`the skills registry rejected this request (HTTP ${response.status})`);
130
+ }
131
+ const bytes = Buffer.from(await response.arrayBuffer());
132
+ if (bytes.length > MAX_BUNDLE_BYTES) {
133
+ throw new SkillsUnavailableError("the skills registry returned an implausibly large bundle");
134
+ }
135
+ return bytes;
136
+ } catch (error) {
137
+ if (error instanceof SkillsUnavailableError && !String(error.message).includes("HTTP 5")) {
138
+ throw error;
139
+ }
140
+ lastError = error?.name === "AbortError"
141
+ ? new SkillsUnavailableError(`the skills registry did not respond within ${timeoutMs}ms`)
142
+ : error;
143
+ } finally {
144
+ clearTimeout(timeout);
145
+ }
146
+ }
147
+ throw lastError instanceof SkillsUnavailableError
148
+ ? lastError
149
+ : new SkillsUnavailableError(
150
+ `could not reach the skills registry: ${redactSecretText(lastError?.message || lastError)}`,
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Read a zip archive into `{ path, contents }` entries.
156
+ *
157
+ * Written here rather than shelling out to `unzip`/`tar`: this CLI is
158
+ * dependency-free by design, and Windows has no dependable bundled unzip.
159
+ *
160
+ * The central directory is the authority for what the archive contains — the
161
+ * local headers are not, because the gateway streams entries with data
162
+ * descriptors (bit 3 set), which leaves the local header's sizes zeroed.
163
+ */
164
+ export function readZipEntries(buffer) {
165
+ const eocd = findEndOfCentralDirectory(buffer);
166
+ if (!eocd) throw new Error("the skills bundle is not a readable zip archive");
167
+ const { entryCount, directoryOffset } = eocd;
168
+ if (entryCount > MAX_ENTRIES) throw new Error("the skills bundle declares too many entries");
169
+
170
+ const entries = [];
171
+ let offset = directoryOffset;
172
+ for (let index = 0; index < entryCount; index += 1) {
173
+ if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) {
174
+ throw new Error("the skills bundle has a malformed central directory");
175
+ }
176
+ const method = buffer.readUInt16LE(offset + 10);
177
+ const compressedSize = buffer.readUInt32LE(offset + 20);
178
+ const uncompressedSize = buffer.readUInt32LE(offset + 24);
179
+ const nameLength = buffer.readUInt16LE(offset + 28);
180
+ const extraLength = buffer.readUInt16LE(offset + 30);
181
+ const commentLength = buffer.readUInt16LE(offset + 32);
182
+ const localOffset = buffer.readUInt32LE(offset + 42);
183
+ const name = buffer.toString("utf8", offset + 46, offset + 46 + nameLength);
184
+ offset += 46 + nameLength + extraLength + commentLength;
185
+
186
+ // Directory entries carry a trailing slash and no content.
187
+ if (name.endsWith("/")) continue;
188
+ if (uncompressedSize > MAX_ENTRY_BYTES) {
189
+ throw new Error(`the skills bundle entry "${name}" is implausibly large`);
190
+ }
191
+ entries.push({
192
+ path: name,
193
+ contents: inflateEntry(buffer, localOffset, method, compressedSize, name),
194
+ });
195
+ }
196
+ return entries;
197
+ }
198
+
199
+ function findEndOfCentralDirectory(buffer) {
200
+ // The EOCD is at the tail, after a comment of up to 64 KiB.
201
+ const earliest = Math.max(0, buffer.length - (0xffff + 22));
202
+ for (let index = buffer.length - 22; index >= earliest; index -= 1) {
203
+ if (buffer.readUInt32LE(index) !== 0x06054b50) continue;
204
+ return {
205
+ entryCount: buffer.readUInt16LE(index + 10),
206
+ directoryOffset: buffer.readUInt32LE(index + 16),
207
+ };
208
+ }
209
+ return null;
210
+ }
211
+
212
+ function inflateEntry(buffer, localOffset, method, compressedSize, name) {
213
+ if (localOffset + 30 > buffer.length || buffer.readUInt32LE(localOffset) !== 0x04034b50) {
214
+ throw new Error(`the skills bundle entry "${name}" has a malformed header`);
215
+ }
216
+ // Only the name/extra lengths are trustworthy in a local header when a data
217
+ // descriptor is in play; the sizes come from the central directory above.
218
+ const nameLength = buffer.readUInt16LE(localOffset + 26);
219
+ const extraLength = buffer.readUInt16LE(localOffset + 28);
220
+ const start = localOffset + 30 + nameLength + extraLength;
221
+ const payload = buffer.subarray(start, start + compressedSize);
222
+ if (method === 0) return Buffer.from(payload);
223
+ if (method !== 8) throw new Error(`the skills bundle entry "${name}" uses an unsupported compression method`);
224
+ return zlib.inflateRawSync(payload);
225
+ }
226
+
227
+ /**
228
+ * Group flat zip entries into skills, keyed by their top-level directory.
229
+ *
230
+ * Rejects rather than sanitizes anything that does not look like a published
231
+ * skill file. A bundle is a trusted-ish source, but it is still remote input
232
+ * being written to a path derived from its own contents, so absolute paths,
233
+ * `..` segments, and backslash separators are refused outright — silently
234
+ * "cleaning" such a path is how a traversal becomes a write outside the
235
+ * profile.
236
+ */
237
+ export function groupBundleSkills(entries) {
238
+ const skills = new Map();
239
+ for (const entry of entries) {
240
+ const normalized = String(entry.path).replace(/^\.\//u, "");
241
+ if (!normalized
242
+ || normalized.includes("\\")
243
+ || normalized.startsWith("/")
244
+ || /^[A-Za-z]:/u.test(normalized)
245
+ || normalized.split("/").some((segment) => segment === ".." || segment === "." || segment === "")) {
246
+ throw new Error(`the skills bundle contains an unsafe path "${entry.path}"`);
247
+ }
248
+ const [name, ...rest] = normalized.split("/");
249
+ if (!SKILL_NAME_RE.test(name)) {
250
+ throw new Error(`the skills bundle contains an unsafe skill name "${name}"`);
251
+ }
252
+ if (rest.length === 0) continue; // A stray top-level file is not part of any skill.
253
+ if (!skills.has(name)) skills.set(name, []);
254
+ skills.get(name).push({ path: rest.join("/"), contents: entry.contents });
255
+ }
256
+ // A directory with no SKILL.md is not a skill either client will load, and
257
+ // writing it would leave an inert directory that reconciliation then has to
258
+ // reason about forever.
259
+ for (const [name, files] of skills) {
260
+ if (!files.some((file) => file.path === "SKILL.md")) skills.delete(name);
261
+ }
262
+ return skills;
263
+ }
264
+
265
+ /** Stable digest of one skill's full contents, used to skip unchanged writes. */
266
+ export function skillDigest(files) {
267
+ const hash = crypto.createHash("sha256");
268
+ for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
269
+ hash.update(file.path);
270
+ hash.update("\0");
271
+ hash.update(file.contents);
272
+ hash.update("\0");
273
+ }
274
+ return hash.digest("hex");
275
+ }
276
+
277
+ function readManagedManifest(manifestPath) {
278
+ let raw;
279
+ try {
280
+ const stat = fs.lstatSync(manifestPath);
281
+ if (stat.isSymbolicLink() || !stat.isFile()) return null;
282
+ raw = fs.readFileSync(manifestPath, "utf8");
283
+ } catch {
284
+ return null;
285
+ }
286
+ try {
287
+ const manifest = JSON.parse(raw);
288
+ if (manifest?.version !== MANAGED_SKILL_MANIFEST_VERSION || !Array.isArray(manifest.skills)) return null;
289
+ // Only well-formed names survive; a corrupted entry must not become a
290
+ // deletion target on the next pass.
291
+ manifest.skills = manifest.skills.filter((name) => typeof name === "string" && SKILL_NAME_RE.test(name));
292
+ return manifest;
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+
298
+ function removeSkillDirectory(directory) {
299
+ // Never follow a symlink out of the profile: remove the link itself.
300
+ let stat;
301
+ try {
302
+ stat = fs.lstatSync(directory);
303
+ } catch {
304
+ return;
305
+ }
306
+ if (stat.isSymbolicLink()) fs.unlinkSync(directory);
307
+ else fs.rmSync(directory, { recursive: true, force: true });
308
+ }
309
+
310
+ function writeSkill(skillsDir, name, files) {
311
+ const target = path.join(skillsDir, name);
312
+ // Replace wholesale rather than merging: a skill that dropped a file upstream
313
+ // must not keep a stale copy of it locally.
314
+ removeSkillDirectory(target);
315
+ for (const file of files) {
316
+ const filePath = path.join(target, file.path);
317
+ // Belt and braces over groupBundleSkills' checks: assert the resolved path
318
+ // is still inside the skill directory after the OS has had its say.
319
+ const resolved = path.resolve(filePath);
320
+ if (resolved !== path.resolve(target) && !resolved.startsWith(path.resolve(target) + path.sep)) {
321
+ throw new Error(`refusing to write skill file outside ${target}`);
322
+ }
323
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
324
+ fs.writeFileSync(filePath, file.contents);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Reconcile one profile's `skills/` directory against the fetched view.
330
+ *
331
+ * Reconciliation, not append (contract §3): a skill this CLI installed that has
332
+ * left the tenant's view is removed. The manifest is what bounds that — only
333
+ * names recorded in a previous managed sync are deletion candidates, so a skill
334
+ * the user wrote or installed by other means is never touched, even if it
335
+ * shares a name with something the registry once served.
336
+ */
337
+ export function reconcileSkills(skillsDir, skills, { now = new Date() } = {}) {
338
+ fs.mkdirSync(skillsDir, { recursive: true });
339
+ const manifestPath = path.join(skillsDir, MANAGED_SKILL_MANIFEST);
340
+ const prior = readManagedManifest(manifestPath);
341
+ const priorDigests = prior?.digests && typeof prior.digests === "object" ? prior.digests : {};
342
+ const previouslyManaged = new Set(prior?.skills || []);
343
+
344
+ const installed = [];
345
+ const updated = [];
346
+ const digests = {};
347
+ for (const [name, files] of [...skills].sort(([left], [right]) => left.localeCompare(right))) {
348
+ const digest = skillDigest(files);
349
+ digests[name] = digest;
350
+ installed.push(name);
351
+ const unchanged = priorDigests[name] === digest
352
+ && previouslyManaged.has(name)
353
+ && fs.existsSync(path.join(skillsDir, name, "SKILL.md"));
354
+ if (unchanged) continue;
355
+ writeSkill(skillsDir, name, files);
356
+ updated.push(name);
357
+ }
358
+
359
+ const present = new Set(installed);
360
+ const removed = [];
361
+ for (const name of previouslyManaged) {
362
+ if (present.has(name)) continue;
363
+ removeSkillDirectory(path.join(skillsDir, name));
364
+ removed.push(name);
365
+ }
366
+
367
+ fs.writeFileSync(manifestPath, `${JSON.stringify({
368
+ version: MANAGED_SKILL_MANIFEST_VERSION,
369
+ syncedAt: now.toISOString(),
370
+ skills: installed,
371
+ digests,
372
+ }, null, 2)}\n`);
373
+
374
+ return { installed, updated, removed: removed.sort() };
375
+ }
376
+
377
+ /**
378
+ * Resolve the tenant credential for a skills fetch, or null when the CLI is not
379
+ * authenticated. Callers degrade to a warning rather than failing: an
380
+ * unauthenticated machine legitimately has no tenant view to ask for.
381
+ */
382
+ export function resolveSkillCredential(config) {
383
+ if (!config?.pat || !config?.tenantId) return null;
384
+ try {
385
+ return tenantCredential(config.pat, normalizeTenantId(config.tenantId));
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
package/src/skills.js CHANGED
@@ -21,10 +21,18 @@
21
21
 
22
22
  import { spawn } from "node:child_process";
23
23
  import fs from "node:fs";
24
+ import os from "node:os";
24
25
  import path from "node:path";
25
26
 
26
27
  import { resolveDefaultGateway, normalizeGatewayUrl } from "./config.js";
27
28
  import { findNativeBinary, nativeCommandInvocation } from "./nativeProcess.js";
29
+ import {
30
+ fetchSkillBundle,
31
+ groupBundleSkills,
32
+ readZipEntries,
33
+ reconcileSkills,
34
+ SkillsUnavailableError,
35
+ } from "./skillBundle.js";
28
36
  import { findReviewedVendorCliBinary } from "./vendorCliBinaries.js";
29
37
  import { PINNED_VENDOR_CLI_VERSIONS } from "./vendorCliVersions.js";
30
38
 
@@ -269,6 +277,180 @@ export function skillSyncSpawnDirectory({
269
277
  }
270
278
  }
271
279
 
280
+ /**
281
+ * The `skills/` directory the client reads for a profile described by `env`.
282
+ *
283
+ * Both clients discover skills from a plain directory under their profile root
284
+ * — `<CLAUDE_CONFIG_DIR>/skills` and `<CODEX_HOME>/skills` — which is what lets
285
+ * the authenticated sync install a tenant's view without going through the
286
+ * vendor plugin machinery at all. An absent override means the native profile,
287
+ * exactly as a bare `claude` or `codex` would resolve it.
288
+ */
289
+ export function profileSkillsDirectory(client, env = {}, { homeDir = os.homedir() } = {}) {
290
+ return path.join(profileRoot(client, env, { homeDir }), "skills");
291
+ }
292
+
293
+ /**
294
+ * The profile root a client resolves for these env overrides.
295
+ *
296
+ * Callers that manage the NATIVE profile pass no override, meaning "wherever a
297
+ * bare `claude`/`codex` would look". That is a resolution, not an absence, and
298
+ * it has to be computed rather than left implicit: `runSkillCommand` merges
299
+ * `process.env` into every child, so an ambient `CLAUDE_CONFIG_DIR` — which is
300
+ * exactly what an `impel claude` session sets — would otherwise silently
301
+ * redirect a native-profile command at the isolated profile.
302
+ */
303
+ export function profileRoot(client, env = {}, { homeDir = os.homedir() } = {}) {
304
+ if (client === "claude") return env.CLAUDE_CONFIG_DIR || path.join(homeDir, ".claude");
305
+ return env.CODEX_HOME || process.env.CODEX_HOME || path.join(homeDir, ".codex");
306
+ }
307
+
308
+ /**
309
+ * Env overrides that pin a vendor command to exactly `root`.
310
+ *
311
+ * Without this, "no override" means "inherit", and the inherited value wins.
312
+ * Pinning makes the profile a command reads identical to the one this module
313
+ * inspected and wrote — otherwise cleanup can detect state in one profile and
314
+ * mutate another.
315
+ */
316
+ function profilePinnedEnv(client, env, root) {
317
+ return client === "claude"
318
+ ? { ...env, CLAUDE_CONFIG_DIR: root }
319
+ : { ...env, CODEX_HOME: root };
320
+ }
321
+
322
+ /**
323
+ * Install the tenant's skills straight into a managed profile (IMP-576).
324
+ *
325
+ * This is the authenticated path, and it is the only one that can see a
326
+ * tenant-scoped skill: the vendor plugin commands below fetch the gateway
327
+ * themselves with no credential, and the gateway answers an uncredentialed
328
+ * request with the globals-only view — a 200, not an error. So a profile
329
+ * populated only by those commands silently receives globals forever.
330
+ *
331
+ * Returns a result the caller reports, or `null` when there is no credential to
332
+ * sync with, which tells the caller to fall back to the unauthenticated plugin
333
+ * path rather than leaving the profile with nothing.
334
+ */
335
+ /**
336
+ * Retire the globals-only plugin the pre-IMP-576 sync installed.
337
+ *
338
+ * Every machine that ran an older impel-cli has `bifrost-all-skills` installed
339
+ * as a vendor plugin, holding the unauthenticated (globals-only) view. Once the
340
+ * authenticated path writes those same skills into the profile directly, the
341
+ * plugin copy is both redundant and stale — the client would load
342
+ * `research-software` twice, from two different snapshots.
343
+ *
344
+ * Best-effort by design: this is cleanup of a superseded mechanism, and a
345
+ * machine with no vendor binary has no plugin to remove anyway. Failures are
346
+ * silent because the authenticated sync has already succeeded by this point,
347
+ * and a warning here would report a healthy profile as degraded.
348
+ */
349
+ async function retireLegacySkillPlugin({ runCommand, commandBinary, env, client, homeDir, root }) {
350
+ if (!commandBinary) return;
351
+ // Pin the child to the profile this sync inspected. Detection reads `root`
352
+ // directly, so an inherited override would let us find the plugin in one
353
+ // profile and uninstall from another.
354
+ env = profilePinnedEnv(client, env, root);
355
+ // The exact ids to uninstall come from the profile's own registry, never from
356
+ // a guess. Claude registers the plugin as PLUGIN@MARKETPLACE and rejects the
357
+ // bare id with "not found in installed plugins" once it was installed from a
358
+ // named marketplace — the same trap buildSkillCommands already documents for
359
+ // `plugin update`. Reading the registry also means a profile carrying an
360
+ // older marketplace name is still cleaned up.
361
+ const plugins = installedLegacySkillPlugins(client, env, { homeDir, root });
362
+ if (plugins.length === 0) return;
363
+ try {
364
+ const help = await runCommand(commandBinary, ["plugin", "--help"], env);
365
+ if (help.missing || !help.ok) return;
366
+ for (const plugin of plugins) {
367
+ await runCommand(commandBinary, ["plugin", "remove", plugin], env);
368
+ }
369
+ } catch {
370
+ // The profile is already correct; a failed cleanup is not worth surfacing.
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Ids under which the pre-IMP-576 bundled plugin is currently installed.
376
+ *
377
+ * Claude records installs in `plugins/installed_plugins.json` keyed by
378
+ * `PLUGIN@MARKETPLACE`; Codex records marketplaces in config.toml and its
379
+ * `plugin remove` accepts the bare plugin name, so only Claude needs the
380
+ * qualified lookup. An unreadable or absent registry yields nothing to do.
381
+ */
382
+ export function installedLegacySkillPlugins(client, env = {}, { homeDir = os.homedir(), root = null } = {}) {
383
+ if (client !== "claude") return [SKILL_PLUGIN_NAME];
384
+ const profile = root || profileRoot(client, env, { homeDir });
385
+ let registry;
386
+ try {
387
+ registry = JSON.parse(fs.readFileSync(path.join(profile, "plugins", "installed_plugins.json"), "utf8"));
388
+ } catch {
389
+ return [];
390
+ }
391
+ const plugins = registry?.plugins;
392
+ if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) return [];
393
+ return Object.keys(plugins).filter((id) => id === SKILL_PLUGIN_NAME || id.startsWith(`${SKILL_PLUGIN_NAME}@`));
394
+ }
395
+
396
+ async function syncTenantSkills({
397
+ client,
398
+ gatewayUrl,
399
+ credential,
400
+ env,
401
+ homeDir,
402
+ fetchImpl,
403
+ logger,
404
+ displayLabel,
405
+ runCommand,
406
+ commandBinary,
407
+ }) {
408
+ if (!credential) return null;
409
+ let bundle;
410
+ try {
411
+ bundle = await fetchSkillBundle({ gatewayUrl, credential, fetchImpl });
412
+ } catch (error) {
413
+ if (error instanceof SkillsUnavailableError) {
414
+ // Contract §2: an unavailable answer must leave the installed set exactly
415
+ // as it is. Returning here — before reconcileSkills is ever reached — is
416
+ // what guarantees that; there is no partial-write path to unwind.
417
+ logger.warn(
418
+ `impel: skill sync for ${displayLabel} could not be completed (${error.message}); `
419
+ + "installed skills were left unchanged.",
420
+ );
421
+ return { client, label: displayLabel, synced: false, reason: "unavailable", changed: false };
422
+ }
423
+ throw error;
424
+ }
425
+
426
+ const skills = groupBundleSkills(readZipEntries(bundle));
427
+ // Resolved once and reused for the write, the detection, and the removal, so
428
+ // those three can never disagree about which profile they are operating on.
429
+ const resolvedHome = homeDir || os.homedir();
430
+ const root = profileRoot(client, env, { homeDir: resolvedHome });
431
+ const { installed, updated, removed } = reconcileSkills(path.join(root, "skills"), skills);
432
+ // Only after the tenant view is safely on disk: removing the legacy plugin
433
+ // first would leave a machine with no skills at all if the write then failed.
434
+ await retireLegacySkillPlugin({ runCommand, commandBinary, env, client, homeDir: resolvedHome, root });
435
+ const changes = [
436
+ ...(updated.length ? [`${updated.length} updated`] : []),
437
+ ...(removed.length ? [`${removed.length} removed`] : []),
438
+ ];
439
+ logger.log(
440
+ `Skills: ${displayLabel} up to date (${installed.length} skill${installed.length === 1 ? "" : "s"}`
441
+ + `${changes.length ? `, ${changes.join(", ")}` : ""}).`,
442
+ );
443
+ return {
444
+ client,
445
+ label: displayLabel,
446
+ synced: true,
447
+ reason: "tenant-scoped",
448
+ installed,
449
+ updated,
450
+ removed,
451
+ };
452
+ }
453
+
272
454
  const SKILL_COMMAND_TIMEOUT_MS = 120_000;
273
455
  const SKILL_COMMAND_OUTPUT_LIMIT = 10 * 1024 * 1024;
274
456
 
@@ -495,6 +677,7 @@ export async function syncSkills({
495
677
  gatewayUrl,
496
678
  env = {},
497
679
  label,
680
+ credential = null,
498
681
  fetchImpl = fetch,
499
682
  run = runSkillCommand,
500
683
  logger = console,
@@ -515,24 +698,68 @@ export async function syncSkills({
515
698
  return { client, label: displayLabel, skipped: true, reason: "disabled" };
516
699
  }
517
700
 
701
+ // Callers that manage real profiles pass their home directory so Windows
702
+ // spawns happen from a git-safe cwd (see skillSyncSpawnDirectory).
703
+ const spawnDirectory = skillSyncSpawnDirectory({ platform, homeDir });
704
+ const runCommand = spawnDirectory
705
+ ? (commandBin, commandArgs, commandEnv) => run(commandBin, commandArgs, commandEnv, { cwd: spawnDirectory })
706
+ : run;
707
+
518
708
  let commandBinary = spec.bin;
709
+ let vendorBinaryMissing = false;
519
710
  if (platform === "darwin" && run === runSkillCommand) {
520
711
  commandBinary = findVendorBinary(client, { ...process.env, ...env }, platform);
521
- if (!commandBinary) {
712
+ vendorBinaryMissing = !commandBinary;
713
+ }
714
+
715
+ // The authenticated path first: it is the only one that can see this tenant's
716
+ // scoped skills. It needs no vendor binary — it writes the profile directly —
717
+ // so a version-drifted or entirely absent vendor CLI must not stop it. That is
718
+ // why the binary check above only records its result here instead of returning.
719
+ if (credential) {
720
+ try {
721
+ const result = await syncTenantSkills({
722
+ client,
723
+ gatewayUrl,
724
+ credential,
725
+ env,
726
+ homeDir,
727
+ fetchImpl,
728
+ logger,
729
+ displayLabel,
730
+ runCommand,
731
+ commandBinary,
732
+ });
733
+ if (result) return result;
734
+ } catch (error) {
735
+ // Reaching here means the bundle was fetched but could not be read or
736
+ // written — a malformed archive, or an unwritable profile. Fall through
737
+ // to the unauthenticated plugin path so the profile still gets globals
738
+ // rather than nothing, and say plainly that scoped skills are missing.
522
739
  logger.warn(
523
- `impel: skipping skill sync for ${displayLabel} reviewed ${spec.label} `
524
- + `v${PINNED_VENDOR_CLI_VERSIONS[client]} is not installed; run \`impel update\`.`,
740
+ `impel: tenant-scoped skill sync for ${displayLabel} failed (${error?.message || error}); `
741
+ + "falling back to the shared marketplace, which serves global skills only.",
525
742
  );
526
- return { client, label: displayLabel, skipped: true, reason: "binary-version-mismatch" };
527
743
  }
744
+ } else {
745
+ // No credential is not a failure — an unauthenticated machine has no tenant
746
+ // view to ask for — but it is worth naming, because the gateway answers an
747
+ // uncredentialed fetch with a 200 and the narrow view, so the profile below
748
+ // will look successfully synced while missing every scoped skill.
749
+ logger.warn(
750
+ `impel: syncing global skills only for ${displayLabel} — no tenant credential is available. `
751
+ + "Run `impel setup` to receive this tenant's scoped skills.",
752
+ );
528
753
  }
529
754
 
530
- // Callers that manage real profiles pass their home directory so Windows
531
- // spawns happen from a git-safe cwd (see skillSyncSpawnDirectory).
532
- const spawnDirectory = skillSyncSpawnDirectory({ platform, homeDir });
533
- const runCommand = spawnDirectory
534
- ? (commandBin, commandArgs, commandEnv) => run(commandBin, commandArgs, commandEnv, { cwd: spawnDirectory })
535
- : run;
755
+ // Only the unauthenticated plugin path below actually needs the vendor CLI.
756
+ if (vendorBinaryMissing) {
757
+ logger.warn(
758
+ `impel: skipping skill sync for ${displayLabel} — reviewed ${spec.label} `
759
+ + `v${PINNED_VENDOR_CLI_VERSIONS[client]} is not installed; run \`impel update\`.`,
760
+ );
761
+ return { client, label: displayLabel, skipped: true, reason: "binary-version-mismatch" };
762
+ }
536
763
 
537
764
  try {
538
765
  // Confirm the binary and its `plugin` subcommand exist before doing anything.
@@ -120,6 +120,10 @@ function installationFailure(result, thrown = null) {
120
120
  export async function prepareWindowsClis({
121
121
  gatewayUrl,
122
122
  tenantId,
123
+ // Tenant credential for the authenticated skills fetch. Optional: an
124
+ // unauthenticated setup still provisions profiles, it just receives the
125
+ // gateway's globals-only view (see syncSkills).
126
+ credential = null,
123
127
  skipInstall = false,
124
128
  inspectOnly = false,
125
129
  installTools = ["claude", "codex"],
@@ -222,6 +226,7 @@ export async function prepareWindowsClis({
222
226
  await io.syncSkills({
223
227
  client: "claude",
224
228
  gatewayUrl,
229
+ credential,
225
230
  env: { CLAUDE_CONFIG_DIR: claudeProfile.configDir },
226
231
  label: "Impel isolated Claude (impel claude)",
227
232
  // Lets Windows spawns use a git-safe cwd (see skillSyncSpawnDirectory).
@@ -232,6 +237,7 @@ export async function prepareWindowsClis({
232
237
  await io.syncSkills({
233
238
  client: "codex",
234
239
  gatewayUrl,
240
+ credential,
235
241
  env: { CODEX_HOME: codexProfile.codexHome },
236
242
  label: "Impel isolated Codex (impel codex)",
237
243
  homeDir: os.homedir(),