impel-cli 0.17.12 → 0.17.14

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.12",
3
+ "version": "0.17.14",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,7 @@ import { syncAgentProfilesSafe } from "../agents.js";
43
43
  import { secureManagedCodexHome } from "../codexSecurity.js";
44
44
  import { impelCliInvocation } from "../selfInvocation.js";
45
45
  import { createProgressLogger, withProgress } from "../progress.js";
46
- import { findNativeBinary } from "../nativeProcess.js";
46
+ import { environmentValue, findNativeBinary } from "../nativeProcess.js";
47
47
  import {
48
48
  ensureWindowsClaudeApp,
49
49
  ensureWindowsChatGPTApp,
@@ -56,6 +56,7 @@ import {
56
56
  stageWindowsChatGPTApp,
57
57
  windowsChatGPTStageIsCurrent,
58
58
  windowsClaudeUserData,
59
+ windowsManagedChatGPTRoot,
59
60
  } from "../windowsApps.js";
60
61
 
61
62
  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.";
@@ -112,11 +113,26 @@ function appTargetLabel(displayName) {
112
113
  // Each isolated desktop app maps to a client CLI + the env override that points
113
114
  // that CLI at the app's private profile, so skill syncing lands in the app's
114
115
  // installation rather than a global one.
115
- function appSkillTarget(target, paths) {
116
+ function appSkillTarget(target, paths, {
117
+ platform = process.platform,
118
+ existsSync = fs.existsSync,
119
+ environment = process.env,
120
+ } = {}) {
116
121
  if (target === "claude") {
117
122
  return { client: "claude", env: { CLAUDE_CONFIG_DIR: paths.claude.userData }, label: appTargetLabel(paths.claude.displayName) };
118
123
  }
119
- return { client: "codex", env: { CODEX_HOME: paths.chatgpt.codexHome }, label: appTargetLabel(paths.chatgpt.displayName) };
124
+ const env = { CODEX_HOME: paths.chatgpt.codexHome };
125
+ // The staged Windows app ships the exact codex.exe the app itself runs, so
126
+ // plugin sync uses it instead of requiring a separately installed Codex CLI
127
+ // (a box without one would otherwise get no app skills at all) and the
128
+ // plugin state is always written by the same codex version that reads it.
129
+ // An operator's explicit IMPEL_CODEX_BIN stays authoritative — it is an
130
+ // override everywhere else the CLI resolves codex.
131
+ if (platform === "win32" && !environmentValue(environment, "IMPEL_CODEX_BIN")) {
132
+ const embedded = path.join(windowsManagedChatGPTRoot(paths.root), "resources", "codex.exe");
133
+ if (existsSync(embedded)) env.IMPEL_CODEX_BIN = embedded;
134
+ }
135
+ return { client: "codex", env, label: appTargetLabel(paths.chatgpt.displayName) };
120
136
  }
121
137
 
122
138
  function appAgentProfile(target, paths) {
@@ -335,6 +351,7 @@ export async function reconcileWindowsTenantApps({
335
351
  installFiles: installManagedAppFiles,
336
352
  syncSkills: syncSkillsSafe,
337
353
  syncAgents: syncAgentProfilesSafe,
354
+ secureCodexHome: secureManagedCodexHome,
338
355
  existsSync: fs.existsSync,
339
356
  findBinary: findNativeBinary,
340
357
  log: (message) => console.log(message),
@@ -389,12 +406,20 @@ export async function reconcileWindowsTenantApps({
389
406
  });
390
407
  const agentTargets = [];
391
408
  for (const target of actionTargets) {
392
- const { client, env, label } = appSkillTarget(target, paths);
393
- if (io.findBinary(client, environment, "win32")) {
409
+ const { client, env, label } = appSkillTarget(target, paths, {
410
+ platform: "win32",
411
+ existsSync: io.existsSync,
412
+ environment,
413
+ });
414
+ // Merge the sync env so an app-embedded binary (IMPEL_CODEX_BIN) satisfies
415
+ // the availability check even when no standalone CLI is installed.
416
+ if (io.findBinary(client, { ...environment, ...env }, "win32")) {
394
417
  await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
395
418
  agentTargets.push(target);
419
+ } else {
420
+ io.log(`Skipping skill/agent sync for ${label} — no ${client === "claude" ? "Claude Code" : "Codex"} binary is available to run plugin commands.`);
396
421
  }
397
- if (target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
422
+ if (target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
398
423
  }
399
424
  if (agentTargets.length) {
400
425
  await io.syncAgents({
@@ -719,7 +744,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
719
744
  for (const target of configuredTargets) {
720
745
  const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
721
746
  console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
722
- const { client, env, label } = appSkillTarget(target, tenantPaths);
747
+ const { client, env, label } = appSkillTarget(target, tenantPaths, { platform: "win32" });
723
748
  if (!flags["skip-skills"]) {
724
749
  await withProgress(`Syncing skills for ${label}`, (spinner) => (
725
750
  io.syncSkills({
@@ -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
  }
@@ -13,9 +13,10 @@ import { loadConfig } from "../config.js";
13
13
  import { CODEX_HOME } from "../codexSetup.js";
14
14
  import { tenantCliProfilePaths } from "../cliProfiles.js";
15
15
  import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
16
+ import { environmentValue } from "../nativeProcess.js";
16
17
  import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
17
18
  import { ensureTenantSelection } from "../tenants.js";
18
- import { windowsClaudeUserData } from "../windowsApps.js";
19
+ import { windowsClaudeUserData, windowsManagedChatGPTRoot } from "../windowsApps.js";
19
20
  import { createProgressLogger, mapWithConcurrency, withProgress } from "../progress.js";
20
21
 
21
22
  const VALID_TARGETS = ["claude", "codex", "all"];
@@ -74,7 +75,16 @@ export function managedSkillProfiles(
74
75
  ? existsSync(path.join(paths.chatgpt.codexHome, "config.toml"))
75
76
  : existsSync(paths.chatgpt.launcher);
76
77
  if (includeApps && appInstalled) {
77
- profiles.push({ label: "Impel ChatGPT app", env: { CODEX_HOME: paths.chatgpt.codexHome } });
78
+ const env = { CODEX_HOME: paths.chatgpt.codexHome };
79
+ // Mirror appSkillTarget (commands/apps.js): the staged Windows app
80
+ // bundles the codex.exe that reads this profile, so `impel skills sync`
81
+ // works on boxes without a standalone Codex CLI too. An operator's
82
+ // explicit IMPEL_CODEX_BIN stays authoritative.
83
+ if (platform === "win32" && !environmentValue(environment, "IMPEL_CODEX_BIN")) {
84
+ const embedded = path.join(windowsManagedChatGPTRoot(paths.root), "resources", "codex.exe");
85
+ if (existsSync(embedded)) env.IMPEL_CODEX_BIN = embedded;
86
+ }
87
+ profiles.push({ label: "Impel ChatGPT app", env });
78
88
  }
79
89
  }
80
90
 
@@ -118,6 +118,17 @@ function commonCandidates(tool, environment, platform) {
118
118
  if (tool === "powershell" && systemRoot) {
119
119
  locations.push([paths.join(systemRoot, "System32", "WindowsPowerShell", "v1.0"), "powershell"]);
120
120
  }
121
+
122
+ // Git for Windows standard install roots; `cmd` holds the PATH-safe
123
+ // git.exe. Codex shells out to git for marketplace clones, so skill
124
+ // syncing must find git even when the parent shell's PATH predates the
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.
127
+ if (tool === "git") {
128
+ if (programFiles) locations.push([paths.join(programFiles, "Git", "cmd"), "git"]);
129
+ if (localAppData) locations.push([paths.join(localAppData, "Programs", "Git", "cmd"), "git"]);
130
+ locations.push([paths.join(home, ".config", "impel", "tools", "git", "cmd"), "git"]);
131
+ }
121
132
  }
122
133
 
123
134
  return locations.flatMap(([directory, name]) => binaryCandidates(directory, name, environment, platform));
package/src/skills.js CHANGED
@@ -20,9 +20,10 @@
20
20
  // so we keep a per-client command table rather than assuming one shape.
21
21
 
22
22
  import { spawn } from "node:child_process";
23
+ import path from "node:path";
23
24
 
24
25
  import { resolveDefaultGateway, normalizeGatewayUrl } from "./config.js";
25
- import { nativeCommandInvocation } from "./nativeProcess.js";
26
+ import { findNativeBinary, nativeCommandInvocation } from "./nativeProcess.js";
26
27
 
27
28
  /** The bundled plugin the Bifrost registry publishes; contains every served skill. */
28
29
  export const SKILL_PLUGIN_NAME = "bifrost-all-skills";
@@ -183,18 +184,58 @@ export function buildSkillCommands({ client, marketplaceSourceUrl: sourceUrl, ma
183
184
  return commands;
184
185
  }
185
186
 
187
+ /**
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.
197
+ */
198
+ export function withGitEnvironment(env = {}, {
199
+ platform = process.platform,
200
+ baseEnvironment = process.env,
201
+ find = findNativeBinary,
202
+ } = {}) {
203
+ if (platform !== "win32") return { env, gitAvailable: true };
204
+ const merged = { ...baseEnvironment, ...env };
205
+ const git = find("git", merged, platform);
206
+ if (!git) return { env, gitAvailable: false };
207
+ const gitDirectory = path.win32.dirname(git);
208
+ const normalize = (entry) => entry.trim().replace(/[\\/]+$/u, "").toLowerCase();
209
+ const pathKey = Object.keys(merged).find((key) => key.toLowerCase() === "path") || "Path";
210
+ const currentPath = String(merged[pathKey] || "");
211
+ if (currentPath.split(";").some((entry) => normalize(entry) === normalize(gitDirectory))) {
212
+ return { env, gitAvailable: true };
213
+ }
214
+ return {
215
+ env: { ...env, [pathKey]: currentPath ? `${gitDirectory};${currentPath}` : gitDirectory },
216
+ gitAvailable: true,
217
+ };
218
+ }
219
+
186
220
  const SKILL_COMMAND_TIMEOUT_MS = 120_000;
187
221
  const SKILL_COMMAND_OUTPUT_LIMIT = 10 * 1024 * 1024;
188
222
 
223
+ // cmd.exe reports a nonexistent command with exit code 9009 (and this stderr
224
+ // line on English installs); the spawn itself succeeds, so without this check
225
+ // a missing CLI is misreported as "has no plugin command".
226
+ const WINDOWS_COMMAND_NOT_FOUND_STATUS = 9009;
227
+ const WINDOWS_COMMAND_NOT_FOUND_RE = /is not recognized as an internal or external command/iu;
228
+
189
229
  /** Default async command runner: keeps progress timers responsive while plugins sync. */
190
230
  export function runSkillCommand(bin, args, env, {
191
231
  spawnImpl = spawn,
192
232
  timeoutMs = SKILL_COMMAND_TIMEOUT_MS,
233
+ platform = process.platform,
193
234
  } = {}) {
194
235
  const environment = { ...process.env, ...env };
195
236
  let invocation;
196
237
  try {
197
- invocation = nativeCommandInvocation(bin, args, environment);
238
+ invocation = nativeCommandInvocation(bin, args, environment, platform);
198
239
  } catch (error) {
199
240
  return Promise.resolve({
200
241
  ok: false,
@@ -269,7 +310,14 @@ export function runSkillCommand(bin, args, env, {
269
310
  }));
270
311
  child.on("close", (status) => finish({
271
312
  ok: !timedOut && status === 0,
272
- missing: false,
313
+ // Only a FAILED cmd.exe-wrapped launch can mean "the CLI itself was not
314
+ // found"; a successful run owns its own stderr, and a directly spawned
315
+ // binary owns its exit codes, where the same phrasing could describe
316
+ // some inner command instead.
317
+ missing: status !== 0
318
+ && invocation.windowsVerbatimArguments
319
+ && (status === WINDOWS_COMMAND_NOT_FOUND_STATUS
320
+ || WINDOWS_COMMAND_NOT_FOUND_RE.test(stderr)),
273
321
  status,
274
322
  stdout,
275
323
  stderr: timedOut ? `skill command timed out after ${timeoutMs}ms` : stderr,
@@ -357,6 +405,8 @@ export async function syncSkills({
357
405
  fetchImpl = fetch,
358
406
  run = runSkillCommand,
359
407
  logger = console,
408
+ platform = process.platform,
409
+ findGit = findNativeBinary,
360
410
  } = {}) {
361
411
  const spec = CLIENT_SPECS[client];
362
412
  if (!spec) {
@@ -382,6 +432,17 @@ export async function syncSkills({
382
432
  return { client, label: displayLabel, skipped: true, reason: "no-plugin-subcommand" };
383
433
  }
384
434
 
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" };
443
+ }
444
+ env = gitReady.env;
445
+
385
446
  const manifestUrl = marketplaceUrl(gatewayUrl, client);
386
447
  const sourceUrl = marketplaceSourceUrl(gatewayUrl, client);
387
448
  let marketplaceName = await fetchMarketplaceName(manifestUrl, fetchImpl);
@@ -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
  }