impel-cli 0.17.13 → 0.17.15

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
@@ -40,9 +40,12 @@ surfaces for each tenant. You do not need to repeat setup tenant by tenant.
40
40
  3. Preserves the current CLI tenant when it is still accessible, or selects the
41
41
  control-plane default.
42
42
  4. Creates isolated Claude and Codex CLI profiles for every supported tenant.
43
- 5. Installs or repairs supported tenant desktop apps on macOS and Windows.
44
- 6. Registers tenant apps with Finder/Spotlight or Windows Start/Search.
45
- 7. Verifies every supported tenant surface and prints a tenant-sorted summary.
43
+ 5. On Windows machines without git, installs a pinned, checksum-verified
44
+ MinGit into `~/.config/impel/tools/git` (no admin rights, no system PATH
45
+ change) so Claude and Codex can install the Impel skills marketplace.
46
+ 6. Installs or repairs supported tenant desktop apps on macOS and Windows.
47
+ 7. Registers tenant apps with Finder/Spotlight or Windows Start/Search.
48
+ 8. Verifies every supported tenant surface and prints a tenant-sorted summary.
46
49
 
47
50
  Use `--tenant` only to choose the default for CLI launches. It does not limit
48
51
  which tenants setup prepares:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.13",
3
+ "version": "0.17.15",
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
@@ -1355,6 +1355,29 @@ function mergeManagedChatGPTToml(current, managed) {
1355
1355
  return `${managed}\n${remainder ? `\n${remainder.replace(/\s*$/u, "")}\n` : ""}`;
1356
1356
  }
1357
1357
 
1358
+ /**
1359
+ * True when a managed ChatGPT profile exists but its config no longer routes
1360
+ * inference through the Impel gateway. The Codex desktop app rewrites
1361
+ * config.toml with its own settings writer (model picker, project trust,
1362
+ * migrations) and can drop the managed top-level `model_provider` key; without
1363
+ * it the built-in ChatGPT provider derives its inference endpoint from
1364
+ * chatgpt.com and sends the Impel token there, which chatgpt.com rejects with
1365
+ * 403 "Unknown personal access token". Stale-only refreshes treat this drift
1366
+ * as staleness so the profile heals immediately instead of waiting out the
1367
+ * manifest TTL.
1368
+ */
1369
+ export function managedChatGPTConfigDrifted(paths) {
1370
+ let current;
1371
+ try {
1372
+ current = fs.readFileSync(path.join(paths.chatgpt.codexHome, "config.toml"), "utf8");
1373
+ } catch {
1374
+ return false; // No managed profile; install/update owns creating one.
1375
+ }
1376
+ return !current.includes(CHATGPT_CONFIG_START)
1377
+ || readTopLevelTomlString(current, "model_provider") !== "impel"
1378
+ || !readTopLevelTomlString(current, "chatgpt_base_url");
1379
+ }
1380
+
1358
1381
  function readTopLevelTomlString(toml, key) {
1359
1382
  const topLevel = toml.split(/^\s*\[/mu, 1)[0];
1360
1383
  const match = topLevel.match(new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")\\s*$`, "mu"));
@@ -32,6 +32,7 @@ import {
32
32
  ensureVendorApp,
33
33
  fetchGatewayModels,
34
34
  installManagedAppFiles,
35
+ managedChatGPTConfigDrifted,
35
36
  managedLauncherName,
36
37
  normalizeAppTarget,
37
38
  quitBlockingApps,
@@ -662,7 +663,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
662
663
  claudeUserData: io.claudeUserData(io.environment, staleTenantId),
663
664
  tenantName: staleTenantId === stored.tenantId ? stored.tenantName : null,
664
665
  });
665
- if (manifestIsFresh(stalePaths, stored)) return true;
666
+ if (manifestIsFresh(stalePaths, stored) && !managedChatGPTConfigDrifted(stalePaths)) return true;
666
667
  }
667
668
  }
668
669
  const config = await io.selectedConfig(targets, flags.tenant || null);
@@ -1241,7 +1242,7 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
1241
1242
  ? stored.tenantName || manifest?.tenantName
1242
1243
  : manifest?.tenantName,
1243
1244
  });
1244
- if (staleOnly && manifestIsFresh(paths, stored)) return;
1245
+ if (staleOnly && manifestIsFresh(paths, stored) && !managedChatGPTConfigDrifted(paths)) return;
1245
1246
 
1246
1247
  let config;
1247
1248
  try {
@@ -14,6 +14,7 @@ import {
14
14
  resolveDefaultGateway,
15
15
  } from "../config.js";
16
16
  import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "../claudeSetup.js";
17
+ import { withGitEnvironment } from "../skills.js";
17
18
  import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
18
19
  import { maybePrintUpdateNotice } from "../updates.js";
19
20
  import {
@@ -170,6 +171,10 @@ export async function cmdLaunch(tool, argv) {
170
171
  staleOnly: true,
171
172
  });
172
173
 
173
- const exitCode = await runNativeCli(tool, impelLaunchArguments(tool, argv), environment);
174
+ // Both vendor CLIs shell out to `git` for plugin/marketplace operations; on
175
+ // a fresh Windows machine the installed git (typically the Impel-managed
176
+ // MinGit) sits off the inherited PATH, so repair it for the child session.
177
+ const { env: launchEnvironment } = withGitEnvironment(environment, { baseEnvironment: environment });
178
+ const exitCode = await runNativeCli(tool, impelLaunchArguments(tool, argv), launchEnvironment);
174
179
  if (exitCode !== 0) process.exitCode = exitCode;
175
180
  }
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
+ import path from "node:path";
2
3
 
3
4
  import { parseFlags } from "../args.js";
5
+ import { appPaths, managedChatGPTConfigDrifted } from "../apps.js";
4
6
  import {
5
7
  clearSessionFlushLock,
6
8
  collectSessionHook,
@@ -13,6 +15,7 @@ import {
13
15
  } from "../sessionCollector.js";
14
16
  import { loadConfig, redactSecretText } from "../config.js";
15
17
  import { impelCliInvocation } from "../selfInvocation.js";
18
+ import { spawnDetachedAppRefresh } from "../updates.js";
16
19
 
17
20
  const SPEC = {
18
21
  provider: { type: "string" },
@@ -22,6 +25,31 @@ const SPEC = {
22
25
  "impel-managed-session-hook-v1": { type: "boolean" },
23
26
  };
24
27
 
28
+ /**
29
+ * Repair a managed ChatGPT profile the desktop app just rewrote out from under
30
+ * the gateway. Hooks are the only Impel code guaranteed to run while the app
31
+ * is broken (the app stops calling the provider token helper once the managed
32
+ * `model_provider` key is gone), so each codex hook event checks for drift and
33
+ * kicks one detached stale-only refresh — which the drift-aware staleness gate
34
+ * turns into a real repair. The heartbeat lock keeps repeated hook events from
35
+ * stacking refresh children while one is already running.
36
+ */
37
+ export function maybeRepairManagedCodexApp(tenantId, {
38
+ drifted = managedChatGPTConfigDrifted,
39
+ lockIsFresh = sessionFlushLockIsFresh,
40
+ touchLock = touchSessionFlushLock,
41
+ spawnRefresh = spawnDetachedAppRefresh,
42
+ } = {}) {
43
+ if (!tenantId) return false;
44
+ const paths = appPaths(undefined, tenantId);
45
+ if (!drifted(paths)) return false;
46
+ const lock = path.join(paths.tenantRoot, "app-repair-heartbeat");
47
+ if (lockIsFresh(lock, 60_000)) return false;
48
+ touchLock(lock);
49
+ spawnRefresh(tenantId);
50
+ return true;
51
+ }
52
+
25
53
  function startDetachedFlush({ provider, tenant, session }) {
26
54
  const invocation = impelCliInvocation([
27
55
  "sessions",
@@ -65,6 +93,7 @@ export async function cmdSessions(argv) {
65
93
  config,
66
94
  flush: false,
67
95
  });
96
+ if (flags.provider === "codex") maybeRepairManagedCodexApp(flags.tenant);
68
97
  if (config && (config.tenantId === flags.tenant || process.env.IMPEL_SESSIONS_DEV_ORG_ID)) {
69
98
  // Hooks fire on every session event; only spawn a flush child when no
70
99
  // live one is already polling this session's outbox (heartbeat lock).
@@ -122,10 +122,12 @@ function commonCandidates(tool, environment, platform) {
122
122
  // Git for Windows standard install roots; `cmd` holds the PATH-safe
123
123
  // git.exe. Codex shells out to git for marketplace clones, so skill
124
124
  // syncing must find git even when the parent shell's PATH predates the
125
- // Git installation.
125
+ // Git installation. The Impel-managed MinGit (provisioned by setup/update
126
+ // on machines with no system Git) comes last so a user's own install wins.
126
127
  if (tool === "git") {
127
128
  if (programFiles) locations.push([paths.join(programFiles, "Git", "cmd"), "git"]);
128
129
  if (localAppData) locations.push([paths.join(localAppData, "Programs", "Git", "cmd"), "git"]);
130
+ locations.push([paths.join(home, ".config", "impel", "tools", "git", "cmd"), "git"]);
129
131
  }
130
132
  }
131
133
 
package/src/skills.js CHANGED
@@ -185,15 +185,17 @@ export function buildSkillCommands({ client, marketplaceSourceUrl: sourceUrl, ma
185
185
  }
186
186
 
187
187
  /**
188
- * Codex registers marketplaces by shelling out to `git clone` (Claude fetches
189
- * its manifest over plain HTTP), so a Windows machine without git breaks
190
- * exactly the Codex half of skill syncing. Resolve git up front: when a
191
- * standard Git for Windows install exists but the inherited PATH cannot see
192
- * it, prepend its directory for the child commands; when git is genuinely
193
- * absent, report that so the caller can skip with an actionable message
194
- * instead of surfacing codex's raw "failed to run git clone" error.
188
+ * Both vendor CLIs shell out to `git` while syncing skills — Codex clones the
189
+ * marketplace repository at registration and Claude clones the plugin source
190
+ * at install so a Windows machine without git breaks skill syncing for both.
191
+ * Resolve git up front: when an installed git (a user's Git for Windows or the
192
+ * Impel-managed MinGit) exists but the inherited PATH cannot see it, prepend
193
+ * its directory for the child commands; when git is genuinely absent, report
194
+ * that so the caller can skip with an actionable message instead of surfacing
195
+ * the vendors' raw "Command 'git' not found" / "failed to run git clone"
196
+ * errors.
195
197
  */
196
- export function withCodexGitEnvironment(env = {}, {
198
+ export function withGitEnvironment(env = {}, {
197
199
  platform = process.platform,
198
200
  baseEnvironment = process.env,
199
201
  find = findNativeBinary,
@@ -430,18 +432,16 @@ export async function syncSkills({
430
432
  return { client, label: displayLabel, skipped: true, reason: "no-plugin-subcommand" };
431
433
  }
432
434
 
433
- if (client === "codex") {
434
- const gitReady = withCodexGitEnvironment(env, { platform, find: findGit });
435
- if (!gitReady.gitAvailable) {
436
- logger.warn(
437
- `impel: skipping skill sync for ${displayLabel} Codex fetches its skills marketplace with \`git\`, `
438
- + "which is not installed. Install Git for Windows (https://git-scm.com/download/win), "
439
- + "then re-run `impel update`."
440
- );
441
- return { client, label: displayLabel, skipped: true, reason: "git-missing" };
442
- }
443
- env = gitReady.env;
435
+ const gitReady = withGitEnvironment(env, { platform, find: findGit });
436
+ if (!gitReady.gitAvailable) {
437
+ logger.warn(
438
+ `impel: skipping skill sync for ${displayLabel} — ${spec.label} installs skills with \`git\`, `
439
+ + "which is not installed. Run `impel setup` to install it automatically, or install "
440
+ + "Git for Windows (https://git-scm.com/download/win), then re-run `impel update`."
441
+ );
442
+ return { client, label: displayLabel, skipped: true, reason: "git-missing" };
444
443
  }
444
+ env = gitReady.env;
445
445
 
446
446
  const manifestUrl = marketplaceUrl(gatewayUrl, client);
447
447
  const sourceUrl = marketplaceSourceUrl(gatewayUrl, client);
@@ -0,0 +1,187 @@
1
+ // Provisions a pinned, checksum-verified MinGit into the Impel-managed tools
2
+ // directory (~/.config/impel/tools/git) so brand-new Windows machines get a
3
+ // working `git` without an admin install or a system-wide PATH change. MinGit
4
+ // is git-for-windows' official minimal distribution for automation; the vendor
5
+ // Claude Code and Codex CLIs only need clone/fetch over HTTPS to install the
6
+ // Bifrost skills marketplace. Discovery lives in nativeProcess.js: the managed
7
+ // `cmd` directory is a standard common candidate for the `git` tool, behind
8
+ // PATH and behind a user's own Git for Windows install.
9
+
10
+ import { spawnSync } from "node:child_process";
11
+ import { createHash } from "node:crypto";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+
16
+ import { nativeCommandInvocation, nativeSpawnInvocation } from "./nativeProcess.js";
17
+
18
+ /**
19
+ * The exact MinGit release the CLI may install. Never use `latest` or an
20
+ * unverified archive: every architecture URL and SHA-256 changes together in a
21
+ * reviewed release, mirroring the vendor-app pin rules in AGENTS.md.
22
+ */
23
+ export const PINNED_MINGIT = Object.freeze({
24
+ version: "2.55.0.3",
25
+ architectures: Object.freeze({
26
+ x64: Object.freeze({
27
+ url: "https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.3/MinGit-2.55.0.3-64-bit.zip",
28
+ sha256: "f48e2d2dc74a24454adc6d8fd0ac25bf9c2386f19cfb06202b9465aaad4f9f05",
29
+ }),
30
+ arm64: Object.freeze({
31
+ url: "https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.3/MinGit-2.55.0.3-arm64.zip",
32
+ sha256: "f7748965d5068e81ad93ca1923650db6742d6e22332b1ae7567a841c59f6bde5",
33
+ }),
34
+ }),
35
+ });
36
+
37
+ const MINGIT_DOWNLOAD_TIMEOUT_MS = 600_000;
38
+ const MINGIT_EXTRACT_TIMEOUT_MS = 300_000;
39
+ const MINGIT_VERIFY_TIMEOUT_MS = 30_000;
40
+
41
+ // Same non-interactive PowerShell posture as the vendor CLI installers in
42
+ // windowsSetup.js (kept local: windowsSetup imports this module).
43
+ const POWERSHELL_ARGS = [
44
+ "-NoLogo",
45
+ "-NoProfile",
46
+ "-NonInteractive",
47
+ "-ExecutionPolicy",
48
+ "Bypass",
49
+ "-Command",
50
+ ];
51
+
52
+ /** Root of the Impel-managed MinGit install. */
53
+ export function managedGitRoot(homeDir = os.homedir()) {
54
+ return path.join(homeDir, ".config", "impel", "tools", "git");
55
+ }
56
+
57
+ /** The PATH-safe git.exe inside a MinGit root (MinGit unpacks `cmd/` at top level). */
58
+ export function managedGitBinary(root) {
59
+ return path.join(root, "cmd", "git.exe");
60
+ }
61
+
62
+ function powershellQuote(value) {
63
+ return String(value).replace(/'/gu, "''");
64
+ }
65
+
66
+ function firstLine(text) {
67
+ return String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "";
68
+ }
69
+
70
+ async function downloadArchive(url, fetchImpl, timeoutMs) {
71
+ const controller = new AbortController();
72
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
73
+ try {
74
+ const response = await fetchImpl(url, { signal: controller.signal, redirect: "follow" });
75
+ if (!response?.ok) throw new Error(`MinGit download failed (HTTP ${response?.status ?? "error"})`);
76
+ return Buffer.from(await response.arrayBuffer());
77
+ } catch (error) {
78
+ if (error?.name === "AbortError") throw new Error(`MinGit download timed out after ${timeoutMs}ms`);
79
+ throw error;
80
+ } finally {
81
+ clearTimeout(timeout);
82
+ }
83
+ }
84
+
85
+ function extractArchive(archivePath, destination, environment, run) {
86
+ const script = "$ProgressPreference = 'SilentlyContinue'; "
87
+ + `Expand-Archive -LiteralPath '${powershellQuote(archivePath)}' `
88
+ + `-DestinationPath '${powershellQuote(destination)}' -Force`;
89
+ const invocation = nativeCommandInvocation(
90
+ "powershell",
91
+ [...POWERSHELL_ARGS, script],
92
+ environment,
93
+ "win32",
94
+ );
95
+ const result = run(invocation.command, invocation.args, {
96
+ encoding: "utf8",
97
+ env: environment,
98
+ stdio: ["ignore", "pipe", "pipe"],
99
+ timeout: MINGIT_EXTRACT_TIMEOUT_MS,
100
+ windowsHide: true,
101
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
102
+ });
103
+ if (result?.error) throw new Error(`MinGit extraction failed (${result.error.message || result.error})`);
104
+ if (result?.status !== 0) {
105
+ const detail = firstLine(result?.stderr) || `exit ${result?.status}`;
106
+ throw new Error(`MinGit extraction failed (${detail})`);
107
+ }
108
+ }
109
+
110
+ function verifyGitBinary(binary, environment, run) {
111
+ const invocation = nativeSpawnInvocation(binary, ["--version"], environment, "win32");
112
+ const result = run(invocation.command, invocation.args, {
113
+ encoding: "utf8",
114
+ env: environment,
115
+ stdio: ["ignore", "pipe", "pipe"],
116
+ timeout: MINGIT_VERIFY_TIMEOUT_MS,
117
+ windowsHide: true,
118
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
119
+ });
120
+ if (result?.error) throw new Error(`extracted git failed to run (${result.error.message || result.error})`);
121
+ if (result?.status !== 0 || !/git version/u.test(String(result?.stdout || ""))) {
122
+ throw new Error(`extracted git failed verification (${firstLine(result?.stderr) || `exit ${result?.status}`})`);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Ensure the Impel-managed MinGit exists. Never throws: setup and update call
128
+ * this on every Windows run, and a failed download must degrade to the same
129
+ * "git is missing" skill-sync skip the machine already had — not break
130
+ * convergence. Returns `{ installed, binary, ... }`.
131
+ */
132
+ export async function provisionWindowsGit({
133
+ homeDir = os.homedir(),
134
+ architecture = process.arch,
135
+ platform = process.platform,
136
+ environment = process.env,
137
+ fetchImpl = fetch,
138
+ run = spawnSync,
139
+ logger = console,
140
+ pin = PINNED_MINGIT,
141
+ downloadTimeoutMs = MINGIT_DOWNLOAD_TIMEOUT_MS,
142
+ } = {}) {
143
+ if (platform !== "win32") return { installed: false, binary: null, reason: "unsupported-platform" };
144
+
145
+ const target = managedGitRoot(homeDir);
146
+ const existing = managedGitBinary(target);
147
+ if (fs.existsSync(existing)) return { installed: false, binary: existing, reason: "already-present" };
148
+
149
+ // 32-bit Node on a 64-bit OS still wants the 64-bit git: they are separate
150
+ // processes, and git-for-windows no longer targets 32-bit-only Windows.
151
+ const release = pin.architectures[architecture] || pin.architectures.x64;
152
+ const staging = `${target}.staging`;
153
+ const archive = `${target}.download.zip`;
154
+ logger.log(`Git: no usable git found; installing MinGit ${pin.version} into the Impel tools directory…`);
155
+ try {
156
+ fs.mkdirSync(path.dirname(target), { recursive: true });
157
+ fs.rmSync(staging, { recursive: true, force: true });
158
+ fs.rmSync(archive, { force: true });
159
+
160
+ const payload = await downloadArchive(release.url, fetchImpl, downloadTimeoutMs);
161
+ const digest = createHash("sha256").update(payload).digest("hex");
162
+ if (digest !== release.sha256) {
163
+ throw new Error(`MinGit checksum mismatch (expected ${release.sha256}, got ${digest})`);
164
+ }
165
+ fs.writeFileSync(archive, payload);
166
+ extractArchive(archive, staging, environment, run);
167
+ verifyGitBinary(managedGitBinary(staging), environment, run);
168
+
169
+ // Replace atomically-enough: verified staging swaps in via a same-volume
170
+ // rename, so discovery never sees a half-extracted tree.
171
+ fs.rmSync(target, { recursive: true, force: true });
172
+ fs.renameSync(staging, target);
173
+ const binary = managedGitBinary(target);
174
+ logger.log(`Git: MinGit ${pin.version} ready (${binary}).`);
175
+ return { installed: true, binary, version: pin.version };
176
+ } catch (error) {
177
+ const reason = error?.message || String(error);
178
+ logger.warn(
179
+ `impel: could not install the managed Git (${reason}). Skill syncing needs git — `
180
+ + "install Git for Windows (https://git-scm.com/download/win), then re-run `impel update`.",
181
+ );
182
+ return { installed: false, binary: null, failure: reason };
183
+ } finally {
184
+ fs.rmSync(staging, { recursive: true, force: true });
185
+ fs.rmSync(archive, { force: true });
186
+ }
187
+ }
@@ -7,6 +7,7 @@ import {
7
7
  nativeSpawnInvocation,
8
8
  } from "./nativeProcess.js";
9
9
  import { syncSkillsSafe } from "./skills.js";
10
+ import { provisionWindowsGit } from "./windowsGit.js";
10
11
 
11
12
  const POWERSHELL_ARGS = [
12
13
  "-NoLogo",
@@ -126,6 +127,8 @@ export async function prepareWindowsClis({
126
127
  ensureClaudeProfile: ensureImpelClaudeProfile,
127
128
  ensureCodexProfile: ensureImpelCodexProfile,
128
129
  syncSkills: syncSkillsSafe,
130
+ provisionGit: provisionWindowsGit,
131
+ logger: console,
129
132
  ...dependencies,
130
133
  };
131
134
 
@@ -142,6 +145,19 @@ export async function prepareWindowsClis({
142
145
  throw new Error("installTools must contain unique supported Windows CLI names");
143
146
  }
144
147
 
148
+ // Both vendor CLIs need `git` to install the skills marketplace, and the
149
+ // target machines are brand new — provision the Impel-managed MinGit before
150
+ // any skill sync, on every setup/update pass (idempotent once installed).
151
+ // A failure degrades to the skill-sync "git is missing" skip, never a throw.
152
+ let gitBinary = io.find("git", io.environment, "win32");
153
+ let gitProvision = null;
154
+ if (!gitBinary) {
155
+ gitProvision = await io.provisionGit({ environment: io.environment, logger: io.logger });
156
+ gitBinary = gitProvision?.installed
157
+ ? gitProvision.binary
158
+ : io.find("git", io.environment, "win32");
159
+ }
160
+
145
161
  if (missingBefore.length > 0 && !skipInstall) {
146
162
  // Install independently: one broken vendor endpoint or local conflict must
147
163
  // not prevent the other client from reaching a usable state.
@@ -205,5 +221,6 @@ export async function prepareWindowsClis({
205
221
  installations,
206
222
  installCommands: windowsCliInstallCommands(missingBefore),
207
223
  profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
224
+ git: { binary: gitBinary || null, provision: gitProvision },
208
225
  };
209
226
  }