impel-cli 0.14.3 → 0.15.1

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.
@@ -0,0 +1,258 @@
1
+ import fs from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { promptText } from "../prompt.js";
5
+ import {
6
+ deterministicInstallRecoveryAction,
7
+ executeInstallRecoveryAction,
8
+ installRecoveryActionMutates,
9
+ validateInstallRecoveryAction,
10
+ } from "./actions.js";
11
+ import {
12
+ clearInstallRecoveryCheckpoint,
13
+ loadInstallRecoveryCheckpoint,
14
+ saveInstallRecoveryCheckpoint,
15
+ } from "./checkpoint.js";
16
+ import { createInstallRecoveryClient } from "./client.js";
17
+ import {
18
+ installRecoveryFingerprint,
19
+ installRecoveryStateFingerprint,
20
+ sanitizeInstallFailureEnvelope,
21
+ } from "./redact.js";
22
+
23
+ const CLI_VERSION = JSON.parse(
24
+ fs.readFileSync(
25
+ fileURLToPath(new URL("../../package.json", import.meta.url)),
26
+ "utf8"
27
+ )
28
+ ).version;
29
+ const TERMINAL_STATUSES = new Set(["fixed", "blocked", "aborted"]);
30
+ const POLL_INTERVAL_MS = 1_000;
31
+
32
+ function recoveryDisabled(environment) {
33
+ return ["1", "true", "yes"].includes(
34
+ String(environment.IMPEL_DISABLE_INSTALL_RECOVERY || "").toLowerCase()
35
+ );
36
+ }
37
+
38
+ function yes(answer) {
39
+ return /^(?:y|yes)$/iu.test(String(answer || "").trim());
40
+ }
41
+
42
+ async function confirmMutation(action, io) {
43
+ if (!installRecoveryActionMutates(action.id)) return true;
44
+ if (!io.isTTY) return false;
45
+ const answer = await io.promptText(
46
+ `Install recovery proposes ${action.id}: ${action.reason}\nRun this allowlisted local action? [y/N] `
47
+ );
48
+ return yes(answer);
49
+ }
50
+
51
+ function checkpointFrom(state, fingerprint) {
52
+ return {
53
+ protocolVersion: 1,
54
+ sessionId: state.sessionId,
55
+ recoveryToken: state.recoveryToken,
56
+ fingerprint,
57
+ status: state.status,
58
+ turn: state.turn,
59
+ expiresAt: state.expiresAt,
60
+ updatedAt: Date.now(),
61
+ };
62
+ }
63
+
64
+ function terminalOutcome(status) {
65
+ if (status === "fixed") return "fixed";
66
+ if (status === "aborted") return "aborted";
67
+ return "blocked";
68
+ }
69
+
70
+ async function uploadConsent(io, explicit, failure) {
71
+ io.log(
72
+ "Install recovery can send a sanitized failure envelope to Impel. Credentials, home paths, email addresses, ANSI controls, and raw tokens are removed."
73
+ );
74
+ io.log(
75
+ "The hosted agent selects typed action IDs; the Impel CLI runs the corresponding reviewed commands on this machine after local policy and confirmation checks."
76
+ );
77
+ io.log("Sanitized payload preview:");
78
+ io.log(JSON.stringify(failure, null, 2));
79
+ if (explicit) return true;
80
+ if (!io.isTTY) return false;
81
+ return yes(await io.promptText("Send the sanitized failure and start assisted recovery? [y/N] "));
82
+ }
83
+
84
+ export async function runInstallRecovery(options, overrides = {}) {
85
+ const environment = options.environment || process.env;
86
+ const io = {
87
+ promptText,
88
+ isTTY: process.stdin.isTTY,
89
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
90
+ loadCheckpoint: loadInstallRecoveryCheckpoint,
91
+ saveCheckpoint: saveInstallRecoveryCheckpoint,
92
+ clearCheckpoint: clearInstallRecoveryCheckpoint,
93
+ client:
94
+ options.client ||
95
+ createInstallRecoveryClient({
96
+ appUrl: options.config.appUrl,
97
+ fetchImpl: options.fetchImpl || fetch,
98
+ }),
99
+ executeAction: executeInstallRecoveryAction,
100
+ now: () => Date.now(),
101
+ log: console.log,
102
+ warn: console.warn,
103
+ ...overrides,
104
+ };
105
+
106
+ if (options.noRecovery || recoveryDisabled(environment)) {
107
+ return { status: "disabled", fixed: false, uploaded: false };
108
+ }
109
+
110
+ const failure = sanitizeInstallFailureEnvelope(options.failure);
111
+ const fingerprint = installRecoveryFingerprint(failure);
112
+ const encounteredErrors = [failure.message];
113
+ const actionContext = {
114
+ ...(options.actionContext || {}),
115
+ platform: failure.platform,
116
+ environment,
117
+ isTTY: io.isTTY,
118
+ };
119
+
120
+ // Always attempt one deterministic, allowlisted local diagnostic first. A
121
+ // mutation still needs the same explicit confirmation as a hosted proposal.
122
+ const deterministicAction = deterministicInstallRecoveryAction(failure);
123
+ const deterministicConfirmed = await confirmMutation(deterministicAction, io);
124
+ const deterministicResult = await io.executeAction(deterministicAction, {
125
+ ...actionContext,
126
+ confirmed: deterministicConfirmed,
127
+ });
128
+ io.log(`Install recovery local check: ${deterministicResult.summary}`);
129
+ if (deterministicResult.outcome === "failed") {
130
+ encounteredErrors.push(deterministicResult.summary);
131
+ }
132
+
133
+ const consented = await uploadConsent(io, options.explicit === true, failure);
134
+ if (!consented) {
135
+ io.log("Install recovery stayed local; no diagnostic data was uploaded.");
136
+ return {
137
+ status: "local_only",
138
+ fixed: false,
139
+ uploaded: false,
140
+ localResult: deterministicResult,
141
+ };
142
+ }
143
+
144
+ let state;
145
+ const existing = io.loadCheckpoint();
146
+ if (existing?.fingerprint === fingerprint) {
147
+ state = existing;
148
+ io.log(`Resuming install recovery session ${existing.sessionId}.`);
149
+ } else {
150
+ state = await io.client.create(options.config.pat, {
151
+ protocolVersion: 1,
152
+ orgId: options.config.tenantId,
153
+ cliVersion: CLI_VERSION,
154
+ consent: true,
155
+ fingerprint,
156
+ stateFingerprint: installRecoveryStateFingerprint({
157
+ failure,
158
+ deterministicResult,
159
+ }),
160
+ failure: {
161
+ ...failure,
162
+ diagnostics: {
163
+ ...(failure.diagnostics || {}),
164
+ localCheck: deterministicResult.summary,
165
+ localCheckOutcome: deterministicResult.outcome,
166
+ },
167
+ },
168
+ });
169
+ io.saveCheckpoint(checkpointFrom(state, fingerprint));
170
+ }
171
+
172
+ const deadline = Math.min(
173
+ state.expiresAt || io.now() + 15 * 60 * 1_000,
174
+ io.now() + 15 * 60 * 1_000
175
+ );
176
+ let terminal = false;
177
+ try {
178
+ while (io.now() < deadline) {
179
+ if (!TERMINAL_STATUSES.has(state.status)) {
180
+ state = await io.client.read(state.sessionId, state.recoveryToken);
181
+ io.saveCheckpoint(checkpointFrom(state, fingerprint));
182
+ }
183
+
184
+ if (state.status === "awaiting_action") {
185
+ const action = validateInstallRecoveryAction(state.proposal?.action);
186
+ if (!action || !state.actionNonce) {
187
+ encounteredErrors.push("Hosted recovery returned an invalid typed action.");
188
+ state.status = "blocked";
189
+ state.terminalSummary = "Hosted recovery returned an invalid typed action.";
190
+ continue;
191
+ }
192
+ io.log(`Install recovery: ${state.proposal.userMessage || state.proposal.summary}`);
193
+ const confirmed = await confirmMutation(action, io);
194
+ const result = await io.executeAction(action, {
195
+ ...actionContext,
196
+ confirmed,
197
+ });
198
+ if (result.outcome === "failed") encounteredErrors.push(result.summary);
199
+ state = await io.client.submitResult(
200
+ state.sessionId,
201
+ state.recoveryToken,
202
+ {
203
+ actionNonce: state.actionNonce,
204
+ actionId: action.id,
205
+ outcome: result.outcome,
206
+ stateFingerprint: installRecoveryStateFingerprint({
207
+ failure,
208
+ actionId: action.id,
209
+ result,
210
+ }),
211
+ summary: result.summary,
212
+ exitCode: result.exitCode,
213
+ ...(result.stdout ? { stdout: result.stdout } : {}),
214
+ ...(result.stderr ? { stderr: result.stderr } : {}),
215
+ }
216
+ );
217
+ io.saveCheckpoint(checkpointFrom(state, fingerprint));
218
+ continue;
219
+ }
220
+
221
+ if (TERMINAL_STATUSES.has(state.status)) {
222
+ terminal = true;
223
+ const summary =
224
+ state.terminalSummary || state.proposal?.summary || "Install recovery finished.";
225
+ await io.client.complete(state.sessionId, state.recoveryToken, {
226
+ outcome: terminalOutcome(state.status),
227
+ summary,
228
+ encounteredErrors: [...new Set(encounteredErrors)].slice(0, 20),
229
+ });
230
+ io.log(`Install recovery ${state.status}: ${summary}`);
231
+ return {
232
+ status: state.status,
233
+ fixed: state.status === "fixed",
234
+ uploaded: true,
235
+ summary,
236
+ };
237
+ }
238
+
239
+ await io.sleep(POLL_INTERVAL_MS);
240
+ }
241
+ const summary = terminal
242
+ ? state.terminalSummary
243
+ : "Install recovery reached its 15-minute local safety limit.";
244
+ io.warn(summary);
245
+ return { status: "blocked", fixed: false, uploaded: true, summary };
246
+ } catch (error) {
247
+ io.warn(`Install recovery paused: ${error?.message || error}`);
248
+ io.warn("Re-run `impel setup --repair` to resume the short-lived session.");
249
+ return {
250
+ status: "paused",
251
+ fixed: false,
252
+ uploaded: true,
253
+ summary: String(error?.message || error),
254
+ };
255
+ } finally {
256
+ if (terminal) io.clearCheckpoint();
257
+ }
258
+ }
@@ -0,0 +1,83 @@
1
+ import crypto from "node:crypto";
2
+ import os from "node:os";
3
+
4
+ import { redactSecretText } from "../config.js";
5
+
6
+ const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu;
7
+ const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+\/-]{8,}/giu;
8
+ const SECRET_ASSIGNMENT_RE = /\b(api[_-]?key|authorization|password|secret|token)\s*[:=]\s*([^\s,;]+)/giu;
9
+ const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu;
10
+ const WINDOWS_USER_PATH_RE = /\b[A-Za-z]:\\Users\\[^\\\s]+/giu;
11
+
12
+ function escapeRegex(value) {
13
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
14
+ }
15
+
16
+ export function redactInstallRecoveryText(value, { homeDir = os.homedir() } = {}) {
17
+ let text = redactSecretText(value);
18
+ if (homeDir) {
19
+ text = text.replace(new RegExp(escapeRegex(homeDir), "giu"), "~");
20
+ }
21
+ return text
22
+ .replace(WINDOWS_USER_PATH_RE, "%USERPROFILE%")
23
+ .replace(JWT_RE, "[REDACTED TOKEN]")
24
+ .replace(BEARER_RE, "Bearer [REDACTED]")
25
+ .replace(SECRET_ASSIGNMENT_RE, (_match, name) => `${name}=[REDACTED]`)
26
+ .replace(EMAIL_RE, "[REDACTED EMAIL]")
27
+ .slice(0, 12_000);
28
+ }
29
+
30
+ function boundedText(value, max, options) {
31
+ return redactInstallRecoveryText(value, options).trim().slice(0, max);
32
+ }
33
+
34
+ export function sanitizeInstallFailureEnvelope(input, options = {}) {
35
+ const diagnostics = Object.fromEntries(
36
+ Object.entries(input?.diagnostics || {})
37
+ .slice(0, 24)
38
+ .map(([key, value]) => [
39
+ boundedText(key, 64, options) || "diagnostic",
40
+ boundedText(value, 2_000, options),
41
+ ])
42
+ );
43
+ return {
44
+ platform: ["win32", "darwin", "linux"].includes(input?.platform)
45
+ ? input.platform
46
+ : "unknown",
47
+ architecture: boundedText(input?.architecture || process.arch, 32, options),
48
+ step: boundedText(input?.step || "unknown", 128, options) || "unknown",
49
+ ...(input?.command ? { command: boundedText(input.command, 256, options) } : {}),
50
+ exitCode: Number.isInteger(input?.exitCode) ? input.exitCode : null,
51
+ signal: input?.signal ? boundedText(input.signal, 32, options) : null,
52
+ errorCode: input?.errorCode ? boundedText(input.errorCode, 96, options) : null,
53
+ message: boundedText(input?.message || "Installation failed.", 4_000, options)
54
+ || "Installation failed.",
55
+ ...(input?.stdout ? { stdout: boundedText(input.stdout, 12_000, options) } : {}),
56
+ ...(input?.stderr ? { stderr: boundedText(input.stderr, 12_000, options) } : {}),
57
+ ...(Object.keys(diagnostics).length ? { diagnostics } : {}),
58
+ };
59
+ }
60
+
61
+ function canonical(value) {
62
+ if (Array.isArray(value)) return value.map(canonical);
63
+ if (value && typeof value === "object") {
64
+ return Object.fromEntries(
65
+ Object.entries(value)
66
+ .filter(([, child]) => child !== undefined)
67
+ .sort(([left], [right]) => left.localeCompare(right))
68
+ .map(([key, child]) => [key, canonical(child)])
69
+ );
70
+ }
71
+ return value;
72
+ }
73
+
74
+ export function installRecoveryFingerprint(value) {
75
+ return crypto
76
+ .createHash("sha256")
77
+ .update(JSON.stringify(canonical(value)))
78
+ .digest("hex");
79
+ }
80
+
81
+ export function installRecoveryStateFingerprint(value) {
82
+ return installRecoveryFingerprint(value);
83
+ }
@@ -55,6 +55,7 @@ function overrideName(tool, environmentPrefix = "IMPEL") {
55
55
  if (tool === "claude") return `${environmentPrefix}_CLAUDE_BIN`;
56
56
  if (tool === "codex") return `${environmentPrefix}_CODEX_BIN`;
57
57
  if (tool === "npm") return `${environmentPrefix}_NPM_BIN`;
58
+ if (tool === "powershell") return `${environmentPrefix}_POWERSHELL_BIN`;
58
59
  return null;
59
60
  }
60
61
 
@@ -86,6 +87,7 @@ function commonCandidates(tool, environment, platform) {
86
87
  const localAppData = environmentValue(environment, "LOCALAPPDATA");
87
88
  const nvmSymlink = environmentValue(environment, "NVM_SYMLINK");
88
89
  const programFiles = environmentValue(environment, "ProgramFiles");
90
+ const systemRoot = environmentValue(environment, "SystemRoot");
89
91
  const nodeDirectory = path.win32.isAbsolute(process.execPath)
90
92
  ? path.win32.dirname(process.execPath)
91
93
  : null;
@@ -110,6 +112,12 @@ function commonCandidates(tool, environment, platform) {
110
112
  if (nvmSymlink) locations.push([nvmSymlink, "npm"]);
111
113
  if (programFiles) locations.push([paths.join(programFiles, "nodejs"), "npm"]);
112
114
  }
115
+
116
+ // Windows PowerShell 5.1 is present on supported Windows releases even
117
+ // when a customized PATH omits its standard directory.
118
+ if (tool === "powershell" && systemRoot) {
119
+ locations.push([paths.join(systemRoot, "System32", "WindowsPowerShell", "v1.0"), "powershell"]);
120
+ }
113
121
  }
114
122
 
115
123
  return locations.flatMap(([directory, name]) => binaryCandidates(directory, name, environment, platform));
@@ -121,10 +129,20 @@ export function findNativeBinary(
121
129
  environment = process.env,
122
130
  platform = process.platform,
123
131
  environmentPrefix = "IMPEL",
132
+ accept = null,
124
133
  ) {
134
+ const usable = (candidate) => {
135
+ if (!isExecutable(candidate, platform)) return false;
136
+ if (!accept) return true;
137
+ try {
138
+ return accept(candidate) === true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ };
125
143
  const override = overrideName(tool, environmentPrefix);
126
144
  const overriddenBinary = override ? environmentValue(environment, override) : null;
127
- if (overriddenBinary) return isExecutable(overriddenBinary, platform) ? overriddenBinary : null;
145
+ if (overriddenBinary) return usable(overriddenBinary) ? overriddenBinary : null;
128
146
 
129
147
  for (const rawDirectory of String(environmentValue(environment, "PATH") || "").split(pathDelimiter(platform))) {
130
148
  const expandedDirectory = platform === "win32"
@@ -133,11 +151,11 @@ export function findNativeBinary(
133
151
  const directory = stripPathEntryQuotes(expandedDirectory);
134
152
  if (!directory) continue;
135
153
  for (const candidate of binaryCandidates(directory, tool, environment, platform)) {
136
- if (isExecutable(candidate, platform)) return candidate;
154
+ if (usable(candidate)) return candidate;
137
155
  }
138
156
  }
139
157
 
140
- return commonCandidates(tool, environment, platform).find((candidate) => isExecutable(candidate, platform)) || null;
158
+ return commonCandidates(tool, environment, platform).find(usable) || null;
141
159
  }
142
160
 
143
161
  /** Resolve an executable for launch, preserving explicit overrides and useful ENOENT errors. */
@@ -1,29 +1,84 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
 
3
3
  import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
4
- import { findNativeBinary, nativeCommandInvocation } from "./nativeProcess.js";
4
+ import {
5
+ findNativeBinary,
6
+ nativeCommandInvocation,
7
+ nativeSpawnInvocation,
8
+ } from "./nativeProcess.js";
5
9
  import { syncSkillsSafe } from "./skills.js";
6
10
 
7
- export const WINDOWS_CLI_PACKAGES = {
8
- claude: "@anthropic-ai/claude-code@latest",
9
- codex: "@openai/codex@latest",
10
- };
11
+ const POWERSHELL_ARGS = [
12
+ "-NoLogo",
13
+ "-NoProfile",
14
+ "-NonInteractive",
15
+ "-ExecutionPolicy",
16
+ "Bypass",
17
+ "-Command",
18
+ ];
11
19
 
12
- export function windowsCliInstallCommand(tools = Object.keys(WINDOWS_CLI_PACKAGES)) {
13
- return `npm install --global ${tools.map((tool) => WINDOWS_CLI_PACKAGES[tool]).join(" ")}`;
20
+ /**
21
+ * Official, checksum-verifying native installers recommended by each vendor.
22
+ * Keep the copyable command separate from the script passed to PowerShell so
23
+ * setup errors can offer an exact manual recovery without exposing cmd.exe's
24
+ * internal escaping.
25
+ */
26
+ export const WINDOWS_CLI_INSTALLERS = Object.freeze({
27
+ claude: Object.freeze({
28
+ command: "powershell -ExecutionPolicy Bypass -NoProfile -Command \"irm https://claude.ai/install.ps1 | iex\"",
29
+ script: "Invoke-RestMethod -Uri 'https://claude.ai/install.ps1' | Invoke-Expression",
30
+ }),
31
+ codex: Object.freeze({
32
+ command: "powershell -ExecutionPolicy Bypass -NoProfile -Command \"$env:CODEX_NON_INTERACTIVE='1'; irm https://chatgpt.com/codex/install.ps1 | iex\"",
33
+ script: "$env:CODEX_NON_INTERACTIVE = '1'; Invoke-RestMethod -Uri 'https://chatgpt.com/codex/install.ps1' | Invoke-Expression",
34
+ }),
35
+ });
36
+
37
+ export function windowsCliInstallCommands(tools = Object.keys(WINDOWS_CLI_INSTALLERS)) {
38
+ return Object.fromEntries(tools.map((tool) => {
39
+ const installer = WINDOWS_CLI_INSTALLERS[tool];
40
+ if (!installer) throw new Error(`unsupported Windows CLI installer: ${tool}`);
41
+ return [tool, installer.command];
42
+ }));
14
43
  }
15
44
 
16
- function detectWindowsClis(find, environment) {
45
+ function verifyWindowsCli(_tool, binary, environment) {
46
+ try {
47
+ const invocation = nativeSpawnInvocation(binary, ["--version"], environment, "win32");
48
+ const result = spawnSync(invocation.command, invocation.args, {
49
+ encoding: "utf8",
50
+ env: environment,
51
+ stdio: ["ignore", "pipe", "pipe"],
52
+ timeout: 15_000,
53
+ windowsHide: true,
54
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
55
+ });
56
+ return result?.status === 0 && !result?.error;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function detectWindowsClis(find, environment, verify) {
63
+ const detect = (tool) => find(
64
+ tool,
65
+ environment,
66
+ "win32",
67
+ "IMPEL",
68
+ (binary) => verify(tool, binary, environment),
69
+ );
17
70
  return {
18
- claude: find("claude", environment, "win32"),
19
- codex: find("codex", environment, "win32"),
71
+ claude: detect("claude"),
72
+ codex: detect("codex"),
20
73
  };
21
74
  }
22
75
 
23
- function installMissingClis(missing, { environment, run }) {
76
+ function installCli(tool, { environment, run }) {
77
+ const installer = WINDOWS_CLI_INSTALLERS[tool];
78
+ if (!installer) throw new Error(`unsupported Windows CLI installer: ${tool}`);
24
79
  const invocation = nativeCommandInvocation(
25
- "npm",
26
- ["install", "--global", ...missing.map((tool) => WINDOWS_CLI_PACKAGES[tool])],
80
+ "powershell",
81
+ [...POWERSHELL_ARGS, installer.script],
27
82
  environment,
28
83
  "win32",
29
84
  );
@@ -57,10 +112,16 @@ function installationFailure(result, thrown = null) {
57
112
  * vendor CLIs, create the same tenant-isolated profiles used at launch time,
58
113
  * and sync the shared Impel skill bundle into each available client.
59
114
  */
60
- export async function prepareWindowsClis({ gatewayUrl, tenantId, skipInstall = false } = {}, dependencies = {}) {
115
+ export async function prepareWindowsClis({
116
+ gatewayUrl,
117
+ tenantId,
118
+ skipInstall = false,
119
+ installTools = ["claude", "codex"],
120
+ } = {}, dependencies = {}) {
61
121
  const io = {
62
122
  environment: process.env,
63
123
  find: findNativeBinary,
124
+ verify: verifyWindowsCli,
64
125
  run: spawnSync,
65
126
  ensureClaudeProfile: ensureImpelClaudeProfile,
66
127
  ensureCodexProfile: ensureImpelCodexProfile,
@@ -68,27 +129,52 @@ export async function prepareWindowsClis({ gatewayUrl, tenantId, skipInstall = f
68
129
  ...dependencies,
69
130
  };
70
131
 
71
- const before = detectWindowsClis(io.find, io.environment);
132
+ const before = detectWindowsClis(io.find, io.environment, io.verify);
72
133
  const missingBefore = Object.entries(before)
73
134
  .filter(([, binary]) => !binary)
74
135
  .map(([tool]) => tool);
75
- let installAttempted = false;
76
- let installSucceeded = null;
77
- let installFailure = null;
136
+ const installations = {};
137
+ const requestedInstallTools = new Set(installTools);
138
+ if (
139
+ requestedInstallTools.size !== installTools.length
140
+ || [...requestedInstallTools].some((tool) => !WINDOWS_CLI_INSTALLERS[tool])
141
+ ) {
142
+ throw new Error("installTools must contain unique supported Windows CLI names");
143
+ }
78
144
 
79
145
  if (missingBefore.length > 0 && !skipInstall) {
80
- installAttempted = true;
81
- try {
82
- const result = installMissingClis(missingBefore, io);
83
- installSucceeded = result?.status === 0 && !result?.error;
84
- if (!installSucceeded) installFailure = installationFailure(result);
85
- } catch (error) {
86
- installSucceeded = false;
87
- installFailure = installationFailure(null, error);
146
+ // Install independently: one broken vendor endpoint or local conflict must
147
+ // not prevent the other client from reaching a usable state.
148
+ for (const tool of missingBefore.filter((candidate) => requestedInstallTools.has(candidate))) {
149
+ try {
150
+ const result = installCli(tool, io);
151
+ const succeeded = result?.status === 0 && !result?.error;
152
+ installations[tool] = {
153
+ attempted: true,
154
+ succeeded,
155
+ failure: succeeded ? null : installationFailure(result),
156
+ command: WINDOWS_CLI_INSTALLERS[tool].command,
157
+ };
158
+ } catch (error) {
159
+ installations[tool] = {
160
+ attempted: true,
161
+ succeeded: false,
162
+ failure: installationFailure(null, error),
163
+ command: WINDOWS_CLI_INSTALLERS[tool].command,
164
+ };
165
+ }
88
166
  }
89
167
  }
90
168
 
91
- const binaries = installAttempted ? detectWindowsClis(io.find, io.environment) : before;
169
+ const attemptedInstallations = Object.values(installations);
170
+ const installAttempted = attemptedInstallations.length > 0;
171
+ const installSucceeded = installAttempted
172
+ ? attemptedInstallations.every((installation) => installation.succeeded)
173
+ : null;
174
+ const installFailure = attemptedInstallations.find((installation) => installation.failure)?.failure || null;
175
+ const binaries = installAttempted
176
+ ? detectWindowsClis(io.find, io.environment, io.verify)
177
+ : before;
92
178
  const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId);
93
179
  const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId);
94
180
 
@@ -116,7 +202,8 @@ export async function prepareWindowsClis({ gatewayUrl, tenantId, skipInstall = f
116
202
  installAttempted,
117
203
  installSucceeded,
118
204
  installFailure,
119
- installCommand: windowsCliInstallCommand(missingBefore),
205
+ installations,
206
+ installCommands: windowsCliInstallCommands(missingBefore),
120
207
  profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
121
208
  };
122
209
  }