impel-cli 0.17.1 → 0.17.3

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.
@@ -17,7 +17,7 @@ import {
17
17
  reconcileAllTenants,
18
18
  selectDefaultTenant,
19
19
  } from "../provisioning.js";
20
- import { prepareWindowsClis } from "../windowsSetup.js";
20
+ import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
21
21
  import { runInstallRecovery } from "../installRecovery/engine.js";
22
22
  import { restoreNativeProfiles } from "./use.js";
23
23
 
@@ -33,7 +33,7 @@ Usage:
33
33
  impel setup --pat <pat> Non-interactive authentication
34
34
  impel setup --tenant <org> Choose the default tenant for CLI launches
35
35
  impel setup --skip-apps Skip desktop apps
36
- impel setup --skip-clis Windows: skip missing vendor CLI installation
36
+ impel setup --skip-clis Do not install missing vendor CLIs
37
37
  impel setup --no-recovery Disable automatic install recovery
38
38
  `;
39
39
 
@@ -53,16 +53,19 @@ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
53
53
  const controller = new AbortController();
54
54
  const timeout = setTimeout(() => controller.abort(), 5_000);
55
55
  try {
56
- const response = await fetchImpl(`${gatewayUrl}/anthropic/v1/messages`, {
57
- method: "POST",
56
+ const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
58
57
  headers: {
59
- "content-type": "application/json",
58
+ accept: "application/json",
60
59
  authorization: `Bearer ${tenantCredential(pat, tenantId)}`,
61
60
  },
62
- body: "{}",
63
61
  signal: controller.signal,
64
62
  });
65
- return { reachable: true, status: response.status, rejected: [401, 403].includes(response.status) };
63
+ return {
64
+ reachable: true,
65
+ healthy: response.ok,
66
+ status: response.status,
67
+ rejected: [401, 403].includes(response.status),
68
+ };
66
69
  } catch (error) {
67
70
  return {
68
71
  reachable: false,
@@ -75,7 +78,7 @@ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
75
78
 
76
79
  function markProbeFailures(report, probes) {
77
80
  probes.forEach((probe, index) => {
78
- if (probe.reachable && !probe.rejected) return;
81
+ if (probe.reachable && !probe.rejected && probe.healthy !== false) return;
79
82
  const tenant = report.tenants[index];
80
83
  tenant.cli = "failed";
81
84
  tenant.status = "failed";
@@ -84,7 +87,9 @@ function markProbeFailures(report, probes) {
84
87
  }
85
88
  tenant.errors.push(probe.rejected
86
89
  ? `credential rejected (HTTP ${probe.status})`
87
- : `gateway unreachable (${probe.error || "unknown error"})`);
90
+ : probe.reachable
91
+ ? `gateway readiness failed (HTTP ${probe.status})`
92
+ : `gateway unreachable (${probe.error || "unknown error"})`);
88
93
  });
89
94
  report.passed = report.tenants.every((tenant) => (
90
95
  ["ready", "unavailable"].includes(tenant.cli)
@@ -109,20 +114,6 @@ function mergeTenantReport(report, replacement) {
109
114
  return recomputeReport(report);
110
115
  }
111
116
 
112
- function describeWindowsCliFailure(prepared) {
113
- const labels = { claude: "Claude Code", codex: "Codex" };
114
- const details = [];
115
- for (const tool of prepared.missingAfter || []) {
116
- const installation = prepared.installations?.[tool];
117
- const failure = installation?.failure;
118
- const reason = failure?.message || failure?.code
119
- || (Number.isInteger(failure?.status) ? `exit ${failure.status}` : "not discoverable after installation");
120
- details.push(`${labels[tool] || tool}'s native installer failed: ${redactSecretText(reason)}`);
121
- if (installation?.command) details.push(`manual ${tool} installer: ${redactSecretText(installation.command)}`);
122
- }
123
- return details.join("; ") || `missing vendor CLIs: ${(prepared.missingAfter || []).join(", ")}`;
124
- }
125
-
126
117
  function confirmed(answer) {
127
118
  return /^(?:y|yes)$/iu.test(String(answer || "").trim());
128
119
  }
@@ -134,7 +125,7 @@ export async function cmdSetup(argv, overrides = {}) {
134
125
  promptSecret,
135
126
  promptText,
136
127
  fetchTenants,
137
- prepareWindowsClis,
128
+ preparePlatformClis,
138
129
  reconcile: reconcileAllTenants,
139
130
  probe: probeGateway,
140
131
  recoverInstall: runInstallRecovery,
@@ -144,6 +135,9 @@ export async function cmdSetup(argv, overrides = {}) {
144
135
  environment: process.env,
145
136
  ...overrides,
146
137
  };
138
+ if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
139
+ io.preparePlatformClis = overrides.prepareWindowsClis;
140
+ }
147
141
  const { flags, positionals } = parseFlags(argv, {
148
142
  pat: { type: "string" },
149
143
  tenant: { type: "string" },
@@ -234,11 +228,11 @@ export async function cmdSetup(argv, overrides = {}) {
234
228
 
235
229
  let sharedFailure = null;
236
230
  const prepareSharedClis = async ({ confirmedTools = null, inspectOnly = false } = {}) => {
237
- if (io.platform !== "win32") return { binaries: {}, missingAfter: [] };
238
231
  try {
239
- const inspected = await io.prepareWindowsClis({
232
+ const inspected = await io.preparePlatformClis({
240
233
  gatewayUrl,
241
234
  tenantId: selected.id,
235
+ platform: io.platform,
242
236
  skipInstall: true,
243
237
  });
244
238
  let prepared = inspected;
@@ -248,6 +242,7 @@ export async function cmdSetup(argv, overrides = {}) {
248
242
  installTools = [];
249
243
  if (io.isTTY) {
250
244
  for (const tool of inspected.missingAfter) {
245
+ if (!inspected.installCommands?.[tool]) continue;
251
246
  const label = tool === "claude" ? "Claude Code" : "Codex";
252
247
  if (confirmed(await io.promptText(`Install the official ${label} CLI for this user? [y/N] `))) {
253
248
  installTools.push(tool);
@@ -256,16 +251,17 @@ export async function cmdSetup(argv, overrides = {}) {
256
251
  }
257
252
  }
258
253
  if (installTools.length) {
259
- prepared = await io.prepareWindowsClis({
254
+ prepared = await io.preparePlatformClis({
260
255
  gatewayUrl,
261
256
  tenantId: selected.id,
257
+ platform: io.platform,
262
258
  skipInstall: false,
263
259
  installTools,
264
260
  });
265
261
  }
266
262
  }
267
263
  if (prepared.missingAfter.length) {
268
- sharedFailure = describeWindowsCliFailure(prepared);
264
+ sharedFailure = describeCliFailure(prepared);
269
265
  return prepared;
270
266
  }
271
267
  sharedFailure = null;
@@ -344,10 +340,13 @@ export async function cmdSetup(argv, overrides = {}) {
344
340
  },
345
341
  },
346
342
  }, overrides.recoveryOverrides || {});
347
- if (recovery.fixed) await prepareSharedClis({ inspectOnly: true });
343
+ if (recovery.fixed) {
344
+ await prepareSharedClis({ inspectOnly: true });
345
+ report = await verifyConvergence();
346
+ }
348
347
  }
349
348
 
350
- if (!flags["no-recovery"]) {
349
+ if (!sharedFailure && !flags["no-recovery"]) {
351
350
  for (const failed of report.tenants.filter((tenant) => tenant.status === "failed")) {
352
351
  const tenant = orderedTenants.find((candidate) => candidate.id === failed.tenantId);
353
352
  if (!tenant) continue;
@@ -30,6 +30,7 @@ Usage:
30
30
  impel update Update the CLI and reconcile every accessible tenant
31
31
  impel update --check Report whether an update is available; change nothing
32
32
  impel update --skip-apps Reconcile only isolated CLI profiles
33
+ impel update --skip-clis Do not install missing vendor CLIs
33
34
  impel update --no-recovery Disable local and hosted recovery for this run
34
35
  `;
35
36
 
@@ -85,6 +86,7 @@ function defaultSelfUpdate(spec) {
85
86
  // NEW code performs them, not the process that started the update.
86
87
  export function defaultRunConvergence({
87
88
  skipApps = false,
89
+ skipClis = false,
88
90
  noRecovery = false,
89
91
  spawn = spawnSync,
90
92
  execPath = process.execPath,
@@ -92,6 +94,7 @@ export function defaultRunConvergence({
92
94
  } = {}) {
93
95
  const args = [cliBin, "_converge"];
94
96
  if (skipApps) args.push("--skip-apps");
97
+ if (skipClis) args.push("--skip-clis");
95
98
  if (noRecovery) args.push("--no-recovery");
96
99
  const result = spawn(execPath, args, {
97
100
  stdio: "inherit",
@@ -157,6 +160,7 @@ export async function cmdUpdate(argv, overrides = {}) {
157
160
  const { flags } = parseFlags(argv, {
158
161
  check: { type: "boolean" },
159
162
  "skip-apps": { type: "boolean" },
163
+ "skip-clis": { type: "boolean" },
160
164
  "refresh-cache": { type: "boolean" },
161
165
  repair: { type: "boolean" },
162
166
  "no-recovery": { type: "boolean" },
@@ -263,6 +267,7 @@ export async function cmdUpdate(argv, overrides = {}) {
263
267
  console.log("Tenants: discovering, installing, repairing, and upgrading every accessible tenant…");
264
268
  const convergenceArgs = {
265
269
  skipApps: Boolean(flags["skip-apps"]),
270
+ skipClis: Boolean(flags["skip-clis"]),
266
271
  noRecovery: Boolean(flags["no-recovery"]),
267
272
  };
268
273
  if (!await io.runConvergence(convergenceArgs)) {
@@ -174,6 +174,7 @@ function announceUpload(session, failure) {
174
174
  );
175
175
  session.io.log("Sanitized payload preview:");
176
176
  session.io.log(JSON.stringify(failure, null, 2));
177
+ session.io.log(`Install recovery ID: ${session.fingerprint.slice(0, 16)}`);
177
178
  }
178
179
 
179
180
  function terminalReturn(session, status, summary, extras = {}) {
@@ -280,6 +281,8 @@ export async function runInstallRecovery(options, overrides = {}) {
280
281
  let inference;
281
282
  try {
282
283
  const seat = await io.resolveSeat(options.config);
284
+ seat.recoveryId = session.fingerprint.slice(0, 16);
285
+ seat.cliVersion = CLI_VERSION;
283
286
  inference = io.createInference(seat);
284
287
  io.log(`Install recovery: assisted diagnosis via ${inference.model} (org ${inference.orgId}).`);
285
288
  } catch (error) {
@@ -293,7 +296,7 @@ export async function runInstallRecovery(options, overrides = {}) {
293
296
  const messages = [
294
297
  { role: "user", content: buildFirstUserMessage(session, failure, deterministicSummary, goalReport) },
295
298
  ];
296
- const tools = installRecoveryToolDefinitions();
299
+ const tools = installRecoveryToolDefinitions(session.actionContext);
297
300
  const deadline = io.now() + RECOVERY_LIMITS.maxWallClockMs;
298
301
  const callCounts = new Map();
299
302
  let declinedSystemCalls = 0;
@@ -107,6 +107,7 @@ export function createRecoveryInference(seat, fetchImpl = fetch) {
107
107
  const modelCandidates = [seat.model, ...FALLBACK_MODELS.filter((model) => model !== seat.model)];
108
108
 
109
109
  async function completeOnce(model, { system, messages, tools }) {
110
+ const requestId = `impel-recovery-${seat.recoveryId || "untracked"}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
110
111
  return requestJson(
111
112
  url,
112
113
  {
@@ -116,7 +117,8 @@ export function createRecoveryInference(seat, fetchImpl = fetch) {
116
117
  accept: "application/json",
117
118
  authorization: `Bearer ${seat.bearer}`,
118
119
  "anthropic-version": "2023-06-01",
119
- "x-request-id": `impel-recovery-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
120
+ "user-agent": `impel-cli-install-recovery/${seat.cliVersion || "unknown"} recovery/${seat.recoveryId || "untracked"}`,
121
+ "x-request-id": requestId,
120
122
  },
121
123
  body: JSON.stringify({
122
124
  model,
@@ -105,7 +105,7 @@ export const INSTALL_RECOVERY_TOOLS = Object.freeze({
105
105
  install_vendor_cli: {
106
106
  risk: "system",
107
107
  description:
108
- "Run the official vendor installer for one missing CLI (Windows only). Uses the vendor's checksum-verifying native installer; requires the user's confirmation.",
108
+ "Run the official vendor installer for one missing CLI on macOS or Windows. Uses a checksum-verifying native installer and requires the user's confirmation.",
109
109
  schema: {
110
110
  tool: { type: "string", enum: ["claude", "codex"], required: true },
111
111
  },
@@ -138,8 +138,26 @@ export const INSTALL_RECOVERY_TOOLS = Object.freeze({
138
138
  },
139
139
  });
140
140
 
141
- export function installRecoveryToolDefinitions() {
142
- return Object.entries(INSTALL_RECOVERY_TOOLS).map(([name, tool]) => ({
141
+ function recoveryToolAvailable(name, context) {
142
+ if (!context) return true;
143
+ switch (name) {
144
+ case "probe_gateway": return typeof context.probeGateway === "function";
145
+ case "check_auth_helper": return Boolean(context.tenantId) && context.platform !== "win32";
146
+ case "repair_impel_profile": return typeof context.repairProfiles === "function";
147
+ case "retry_step": return typeof context.retryStep === "function";
148
+ case "install_vendor_cli":
149
+ return ["darwin", "win32"].includes(context.platform)
150
+ && typeof context.installVendorClis === "function";
151
+ case "install_vendor_app": return typeof context.installVendorApp === "function";
152
+ case "repair_user_path": return context.platform === "win32";
153
+ default: return true;
154
+ }
155
+ }
156
+
157
+ export function installRecoveryToolDefinitions(context = null) {
158
+ return Object.entries(INSTALL_RECOVERY_TOOLS)
159
+ .filter(([name]) => recoveryToolAvailable(name, context))
160
+ .map(([name, tool]) => ({
143
161
  name,
144
162
  description: tool.description,
145
163
  input_schema: {
@@ -155,7 +173,7 @@ export function installRecoveryToolDefinitions() {
155
173
  .map(([key]) => key),
156
174
  additionalProperties: false,
157
175
  },
158
- }));
176
+ }));
159
177
  }
160
178
 
161
179
  /** Validate a model-proposed call. Returns normalized input or null. */
@@ -469,7 +487,7 @@ async function retryStep(input, context) {
469
487
  }
470
488
 
471
489
  async function installVendorCli(input, context) {
472
- if (context.platform !== "win32" || typeof context.installVendorClis !== "function") {
490
+ if (!["darwin", "win32"].includes(context.platform) || typeof context.installVendorClis !== "function") {
473
491
  return result("failed", "Automated vendor CLI installation is unavailable on this platform.");
474
492
  }
475
493
  const outcome = await context.installVendorClis(input.tool);
@@ -0,0 +1,260 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
8
+ import { findNativeBinary } from "./nativeProcess.js";
9
+ import { syncSkillsSafe } from "./skills.js";
10
+
11
+ const MAX_INSTALLER_BYTES = 512 * 1024;
12
+ const INSTALLER_DOWNLOAD_TIMEOUT_MS = 30_000;
13
+ const INSTALLER_RUN_TIMEOUT_MS = 10 * 60 * 1_000;
14
+ const MAC_SIGNING_TEAMS = Object.freeze({ claude: "Q6L2SF6YDW", codex: "2DC432GLL2" });
15
+
16
+ /**
17
+ * The script bytes are pinned per impel-cli release. Both vendor scripts then
18
+ * verify the downloaded native binary against the vendor release checksum.
19
+ */
20
+ export const MAC_CLI_INSTALLERS = Object.freeze({
21
+ claude: Object.freeze({
22
+ url: "https://claude.ai/install.sh",
23
+ sha256: "b3f79015b54c751440a6488f07b1b64f9088742b9052bc1bd356d13108320d2a",
24
+ command: "curl -fsSL https://claude.ai/install.sh | bash -s stable",
25
+ args: Object.freeze(["stable"]),
26
+ environment: Object.freeze({}),
27
+ }),
28
+ codex: Object.freeze({
29
+ url: "https://chatgpt.com/codex/install.sh",
30
+ sha256: "1154e9daf713aacd1534efca8042bfd6665ad24bc1d1dfd86b8f439fe60a7a5d",
31
+ command: "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 CODEX_RELEASE=0.144.6 sh",
32
+ args: Object.freeze([]),
33
+ environment: Object.freeze({ CODEX_NON_INTERACTIVE: "1", CODEX_RELEASE: "0.144.6" }),
34
+ }),
35
+ });
36
+
37
+ function verifyMacCli(tool, binary, environment, run = spawnSync) {
38
+ try {
39
+ const result = run(binary, ["--version"], {
40
+ encoding: "utf8",
41
+ env: environment,
42
+ stdio: ["ignore", "pipe", "pipe"],
43
+ timeout: 15_000,
44
+ });
45
+ if (result?.status !== 0 || result?.error) return false;
46
+ const explicitOverride = tool === "claude" ? environment.IMPEL_CLAUDE_BIN : environment.IMPEL_CODEX_BIN;
47
+ if (explicitOverride && path.resolve(explicitOverride) === path.resolve(binary)) return true;
48
+ const resolved = fs.realpathSync(binary);
49
+ const verified = run("/usr/bin/codesign", ["--verify", "--strict", resolved], {
50
+ encoding: "utf8",
51
+ stdio: ["ignore", "pipe", "pipe"],
52
+ timeout: 15_000,
53
+ });
54
+ if (verified?.status !== 0 || verified?.error) return false;
55
+ const details = run("/usr/bin/codesign", ["-dv", "--verbose=4", resolved], {
56
+ encoding: "utf8",
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ timeout: 15_000,
59
+ });
60
+ return details?.status === 0
61
+ && String(details.stderr || details.stdout || "").includes(`TeamIdentifier=${MAC_SIGNING_TEAMS[tool]}`);
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function detectMacClis(find, environment, verify) {
68
+ const detect = (tool) => find(
69
+ tool,
70
+ environment,
71
+ "darwin",
72
+ "IMPEL",
73
+ (binary) => verify(tool, binary, environment),
74
+ );
75
+ return { claude: detect("claude"), codex: detect("codex") };
76
+ }
77
+
78
+ async function downloadPinnedInstaller(tool, { fetchImpl, fsImpl, installers }) {
79
+ const installer = installers[tool];
80
+ if (!installer) throw new Error(`unsupported macOS CLI installer: ${tool}`);
81
+ const controller = new AbortController();
82
+ const timeout = setTimeout(() => controller.abort(), INSTALLER_DOWNLOAD_TIMEOUT_MS);
83
+ let response;
84
+ try {
85
+ response = await fetchImpl(installer.url, { redirect: "follow", signal: controller.signal });
86
+ } finally {
87
+ clearTimeout(timeout);
88
+ }
89
+ if (!response.ok) throw new Error(`official ${tool} installer returned HTTP ${response.status}`);
90
+ const finalUrl = new URL(response.url || installer.url);
91
+ if (finalUrl.protocol !== "https:" || !["claude.ai", "chatgpt.com"].includes(finalUrl.hostname)) {
92
+ throw new Error(`official ${tool} installer redirected to an untrusted origin`);
93
+ }
94
+ const bytes = Buffer.from(await response.arrayBuffer());
95
+ if (bytes.length === 0 || bytes.length > MAX_INSTALLER_BYTES) {
96
+ throw new Error(`official ${tool} installer had an invalid size`);
97
+ }
98
+ const digest = crypto.createHash("sha256").update(bytes).digest("hex");
99
+ if (digest !== installer.sha256) {
100
+ throw new Error(`official ${tool} installer checksum did not match this impel-cli release`);
101
+ }
102
+ const source = bytes.toString("utf8");
103
+ if (!source.startsWith("#!")) throw new Error(`official ${tool} installer was not a shell script`);
104
+ const directory = fsImpl.mkdtempSync(path.join(os.tmpdir(), `impel-${tool}-installer-`));
105
+ const script = path.join(directory, "install.sh");
106
+ fsImpl.writeFileSync(script, bytes, { mode: 0o700 });
107
+ return { directory, script };
108
+ }
109
+
110
+ async function installMacCli(tool, io) {
111
+ const installer = io.installers[tool];
112
+ let downloaded = null;
113
+ let downloadError = null;
114
+ for (let attempt = 0; attempt < 2 && !downloaded; attempt += 1) {
115
+ try {
116
+ downloaded = await downloadPinnedInstaller(tool, io);
117
+ } catch (error) {
118
+ downloadError = error;
119
+ }
120
+ }
121
+ if (!downloaded) throw downloadError || new Error(`official ${tool} installer download failed`);
122
+ try {
123
+ const environment = {
124
+ ...vendorInstallerEnvironment(io.environment),
125
+ ...(installer.environment || {}),
126
+ };
127
+ return io.run("/bin/bash", [downloaded.script, ...(installer.args || [])], {
128
+ env: environment,
129
+ stdio: "inherit",
130
+ timeout: INSTALLER_RUN_TIMEOUT_MS,
131
+ });
132
+ } finally {
133
+ io.fsImpl.rmSync(downloaded.directory, { recursive: true, force: true });
134
+ }
135
+ }
136
+
137
+ function vendorInstallerEnvironment(environment) {
138
+ const allowed = new Set([
139
+ "HOME", "USER", "LOGNAME", "SHELL", "PATH", "TMPDIR", "LANG", "TERM",
140
+ "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
141
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
142
+ ]);
143
+ return Object.fromEntries(Object.entries(environment).filter(([name]) => (
144
+ allowed.has(name) || name.startsWith("LC_")
145
+ )));
146
+ }
147
+
148
+ function assertUserOwnedMacInstall(environment, fsImpl, geteuid) {
149
+ if (environment.SUDO_USER || geteuid() === 0) {
150
+ throw new Error("refusing to install vendor CLIs under sudo or as root on macOS");
151
+ }
152
+ const home = environment.HOME || os.homedir();
153
+ if (fsImpl.existsSync(home)) {
154
+ const owner = fsImpl.statSync(home).uid;
155
+ if (Number.isInteger(owner) && owner !== geteuid()) {
156
+ throw new Error("the macOS home directory is not owned by the current user");
157
+ }
158
+ }
159
+ }
160
+
161
+ function installationFailure(result, thrown = null) {
162
+ const error = thrown || result?.error;
163
+ return error
164
+ ? { code: error.code || null, message: error.message || String(error), status: null, signal: null }
165
+ : {
166
+ code: null,
167
+ message: null,
168
+ status: Number.isInteger(result?.status) ? result.status : null,
169
+ signal: result?.signal || null,
170
+ };
171
+ }
172
+
173
+ export async function prepareMacClis({
174
+ gatewayUrl,
175
+ tenantId,
176
+ skipInstall = false,
177
+ installTools = ["claude", "codex"],
178
+ } = {}, dependencies = {}) {
179
+ const io = {
180
+ environment: process.env,
181
+ find: findNativeBinary,
182
+ verify: verifyMacCli,
183
+ run: spawnSync,
184
+ fetchImpl: fetch,
185
+ fsImpl: fs,
186
+ geteuid: () => typeof process.geteuid === "function" ? process.geteuid() : null,
187
+ installers: MAC_CLI_INSTALLERS,
188
+ ensureClaudeProfile: ensureImpelClaudeProfile,
189
+ ensureCodexProfile: ensureImpelCodexProfile,
190
+ syncSkills: syncSkillsSafe,
191
+ ...dependencies,
192
+ };
193
+ const before = detectMacClis(io.find, io.environment, io.verify);
194
+ const missingBefore = Object.entries(before).filter(([, binary]) => !binary).map(([tool]) => tool);
195
+ const requested = new Set(installTools);
196
+ if (requested.size !== installTools.length || [...requested].some((tool) => !io.installers[tool])) {
197
+ throw new Error("installTools must contain unique supported macOS CLI names");
198
+ }
199
+
200
+ const installations = {};
201
+ if (missingBefore.length && !skipInstall) {
202
+ assertUserOwnedMacInstall(io.environment, io.fsImpl, io.geteuid);
203
+ for (const tool of missingBefore.filter((candidate) => requested.has(candidate))) {
204
+ try {
205
+ const result = await installMacCli(tool, io);
206
+ const succeeded = result?.status === 0 && !result?.error;
207
+ installations[tool] = {
208
+ attempted: true,
209
+ succeeded,
210
+ failure: succeeded ? null : installationFailure(result),
211
+ command: io.installers[tool].command,
212
+ };
213
+ } catch (error) {
214
+ installations[tool] = {
215
+ attempted: true,
216
+ succeeded: false,
217
+ failure: installationFailure(null, error),
218
+ command: io.installers[tool].command,
219
+ };
220
+ }
221
+ }
222
+ }
223
+
224
+ const installAttempted = Object.keys(installations).length > 0;
225
+ const binaries = installAttempted
226
+ ? detectMacClis(io.find, io.environment, io.verify)
227
+ : before;
228
+ const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId);
229
+ const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId);
230
+ if (binaries.claude) {
231
+ await io.syncSkills({
232
+ client: "claude",
233
+ gatewayUrl,
234
+ env: { CLAUDE_CONFIG_DIR: claudeProfile.configDir },
235
+ label: "Impel isolated Claude (impel claude)",
236
+ });
237
+ }
238
+ if (binaries.codex) {
239
+ await io.syncSkills({
240
+ client: "codex",
241
+ gatewayUrl,
242
+ env: { CODEX_HOME: codexProfile.codexHome },
243
+ label: "Impel isolated Codex (impel codex)",
244
+ });
245
+ }
246
+
247
+ return {
248
+ binaries,
249
+ missingBefore,
250
+ missingAfter: Object.entries(binaries).filter(([, binary]) => !binary).map(([tool]) => tool),
251
+ installAttempted,
252
+ installSucceeded: installAttempted
253
+ ? Object.values(installations).every((installation) => installation.succeeded)
254
+ : null,
255
+ installFailure: Object.values(installations).find((installation) => installation.failure)?.failure || null,
256
+ installations,
257
+ installCommands: Object.fromEntries(missingBefore.map((tool) => [tool, io.installers[tool].command])),
258
+ profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
259
+ };
260
+ }
@@ -75,12 +75,12 @@ function commonCandidates(tool, environment, platform) {
75
75
  const home = environmentValue(environment, "USERPROFILE") || environmentValue(environment, "HOME") || os.homedir();
76
76
  const locations = [];
77
77
 
78
- if (tool === "claude") {
78
+ if (tool === "claude" || tool === "codex") {
79
79
  locations.push(
80
- [paths.join(home, ".local", "bin"), "claude"],
81
- [paths.join(home, ".claude", "local"), "claude"],
80
+ [paths.join(home, ".local", "bin"), tool],
82
81
  );
83
82
  }
83
+ if (tool === "claude") locations.push([paths.join(home, ".claude", "local"), "claude"]);
84
84
 
85
85
  if (platform === "win32") {
86
86
  const appData = environmentValue(environment, "APPDATA");
@@ -0,0 +1,49 @@
1
+ import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
2
+ import { redactSecretText } from "./config.js";
3
+ import { findNativeBinary } from "./nativeProcess.js";
4
+ import { prepareMacClis } from "./macSetup.js";
5
+ import { prepareWindowsClis } from "./windowsSetup.js";
6
+
7
+ export async function preparePlatformClis(options = {}, dependencies = {}) {
8
+ const platform = options.platform || process.platform;
9
+ if (platform === "darwin") return prepareMacClis(options, dependencies);
10
+ if (platform === "win32") return prepareWindowsClis(options, dependencies);
11
+
12
+ const environment = dependencies.environment || process.env;
13
+ const find = dependencies.find || findNativeBinary;
14
+ const binaries = {
15
+ claude: find("claude", environment, platform),
16
+ codex: find("codex", environment, platform),
17
+ };
18
+ const claudeProfile = (dependencies.ensureClaudeProfile || ensureImpelClaudeProfile)(options.gatewayUrl, options.tenantId);
19
+ const codexProfile = (dependencies.ensureCodexProfile || ensureImpelCodexProfile)(options.gatewayUrl, options.tenantId);
20
+ const missingAfter = Object.entries(binaries).filter(([, binary]) => !binary).map(([tool]) => tool);
21
+ return {
22
+ binaries,
23
+ missingBefore: [...missingAfter],
24
+ missingAfter,
25
+ installAttempted: false,
26
+ installSucceeded: null,
27
+ installFailure: null,
28
+ installations: {},
29
+ installCommands: {},
30
+ profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
31
+ };
32
+ }
33
+
34
+ export function describeCliFailure(prepared) {
35
+ const labels = { claude: "Claude Code", codex: "Codex" };
36
+ const details = [];
37
+ for (const tool of prepared.missingAfter || []) {
38
+ const installation = prepared.installations?.[tool];
39
+ const failure = installation?.failure;
40
+ const reason = failure?.message || failure?.code
41
+ || (Number.isInteger(failure?.status)
42
+ ? `exit ${failure.status}`
43
+ : installation ? "not discoverable after installation" : "not installed or discoverable");
44
+ details.push(`${labels[tool] || tool}: ${redactSecretText(reason)}`);
45
+ const command = installation?.command || prepared.installCommands?.[tool];
46
+ if (command) details.push(`manual ${tool} installer: ${redactSecretText(command)}`);
47
+ }
48
+ return details.join("; ") || `missing vendor CLIs: ${(prepared.missingAfter || []).join(", ")}`;
49
+ }
@@ -0,0 +1,32 @@
1
+ function effectiveUserId() {
2
+ return typeof process.geteuid === "function" ? process.geteuid() : null;
3
+ }
4
+
5
+ export function commandMutatesUserState(argv = []) {
6
+ const [command, subcommand] = argv;
7
+ if (["setup", "update", "upgrade", "_converge"].includes(command)) return true;
8
+ if (command === "app" || command === "apps") {
9
+ return subcommand !== "status";
10
+ }
11
+ return false;
12
+ }
13
+
14
+ export function elevatedMacExecution({
15
+ argv = [],
16
+ platform = process.platform,
17
+ geteuid = effectiveUserId,
18
+ } = {}) {
19
+ return platform === "darwin"
20
+ && commandMutatesUserState(argv)
21
+ && geteuid() === 0;
22
+ }
23
+
24
+ export function refuseElevatedMacExecution(argv = [], dependencies = {}) {
25
+ if (!elevatedMacExecution({ argv, ...dependencies })) return false;
26
+ console.error(
27
+ "impel: do not run setup, update, or app management with sudo on macOS. "
28
+ + "Run the command as the signed-in user so Impel state and Keychain entries have the correct owner."
29
+ );
30
+ process.exitCode = 1;
31
+ return true;
32
+ }