impel-cli 0.7.0

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,350 @@
1
+ // `impel setup` — end-to-end onboarding: token → tenant → platform clients →
2
+ // verify. macOS installs the isolated desktop apps; Windows ensures the two
3
+ // official vendor CLIs exist and prepares isolated CLI + desktop app profiles.
4
+ // Native Claude Code/Codex profiles are never touched.
5
+
6
+ import { parseFlags } from "../args.js";
7
+ import {
8
+ loadConfig,
9
+ saveConfig,
10
+ normalizeGatewayUrl,
11
+ resolveDefaultGateway,
12
+ resolveDefaultAppUrl,
13
+ maskSecret,
14
+ redactSecretText,
15
+ } from "../config.js";
16
+ import { promptSecret, promptText } from "../prompt.js";
17
+ import { fetchTenants, normalizeTenantId, tenantCredential } from "../tenants.js";
18
+ import { cmdApps } from "./apps.js";
19
+ import { prepareWindowsClis, windowsCliInstallCommand } from "../windowsSetup.js";
20
+
21
+ const HELP = `impel setup - guided end-to-end gateway setup
22
+
23
+ Runs the whole onboarding in one command: store your Personal Access Token,
24
+ pick your organization, prepare the Impel clients for this platform, and verify
25
+ the gateway. On macOS this installs isolated desktop apps. On Windows it
26
+ installs any missing official Claude Code/Codex packages, prepares the
27
+ tenant-isolated CLI profiles, and installs isolated desktop app profiles.
28
+ Native client profiles stay untouched and the vendor executables stay signed
29
+ and unmodified.
30
+
31
+ Usage:
32
+ impel setup Interactive wizard
33
+ impel setup --pat <pat> Non-interactive; add --tenant to skip the picker
34
+ impel setup --tenant <org> Preselect the organization
35
+ impel setup --skip-apps Skip the desktop-app installation step
36
+ impel setup --skip-clis On Windows, don't install missing vendor CLIs
37
+ `;
38
+
39
+ /**
40
+ * Pick the tenant for the wizard. Pure so it's testable: returns the tenant
41
+ * for an explicit request (or throws if unavailable), auto-selects a sole
42
+ * tenant, and otherwise resolves an interactive answer (number, id, slug, or
43
+ * empty = default).
44
+ */
45
+ export function resolveTenantChoice(listing, { requested = null, answer = null, currentTenantId = null } = {}) {
46
+ const byToken = (token) => {
47
+ const normalized = normalizeTenantId(token);
48
+ return listing.tenants.find((tenant) => tenant.id === normalized || tenant.slug === normalized) || null;
49
+ };
50
+
51
+ if (requested) {
52
+ const tenant = byToken(requested);
53
+ if (!tenant) throw new Error(`tenant "${requested}" is not available to this user`);
54
+ return tenant;
55
+ }
56
+ if (listing.tenants.length === 1) return listing.tenants[0];
57
+
58
+ const fallbackId = listing.tenants.some((tenant) => tenant.id === currentTenantId)
59
+ ? currentTenantId
60
+ : listing.defaultTenantId;
61
+ if (answer === null || answer === "") {
62
+ return listing.tenants.find((tenant) => tenant.id === fallbackId) || listing.tenants[0];
63
+ }
64
+ const index = /^\d+$/.test(answer) ? Number(answer) : NaN;
65
+ if (Number.isInteger(index) && index >= 1 && index <= listing.tenants.length) {
66
+ return listing.tenants[index - 1];
67
+ }
68
+ const tenant = byToken(answer);
69
+ if (!tenant) throw new Error(`tenant "${answer}" is not available to this user`);
70
+ return tenant;
71
+ }
72
+
73
+ /** Best-effort gateway probe; any HTTP response means the gateway is routing. */
74
+ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
75
+ const url = `${gatewayUrl}/anthropic/v1/messages`;
76
+ const controller = new AbortController();
77
+ const timeout = setTimeout(() => controller.abort(), 5000);
78
+ try {
79
+ const res = await fetchImpl(url, {
80
+ method: "POST",
81
+ headers: {
82
+ "content-type": "application/json",
83
+ ...(pat && tenantId ? { authorization: `Bearer ${tenantCredential(pat, tenantId)}` } : {}),
84
+ },
85
+ body: "{}",
86
+ signal: controller.signal,
87
+ });
88
+ return { reachable: true, status: res.status, rejected: res.status === 401 || res.status === 403 };
89
+ } catch (err) {
90
+ return {
91
+ reachable: false,
92
+ error: err?.name === "AbortError" ? "timed out after 5s" : redactSecretText(err?.message || err),
93
+ };
94
+ } finally {
95
+ clearTimeout(timeout);
96
+ }
97
+ }
98
+
99
+ export async function cmdSetup(argv, overrides = {}) {
100
+ const io = {
101
+ promptSecret,
102
+ promptText,
103
+ fetchTenants,
104
+ installApps: cmdApps,
105
+ prepareWindowsClis,
106
+ probe: probeGateway,
107
+ isTTY: process.stdin.isTTY,
108
+ platform: process.platform,
109
+ ...overrides,
110
+ };
111
+ const { flags, positionals } = parseFlags(argv, {
112
+ pat: { type: "string" },
113
+ tenant: { type: "string" },
114
+ gateway: { type: "string" },
115
+ app: { type: "string" },
116
+ "skip-apps": { type: "boolean" },
117
+ "skip-clis": { type: "boolean" },
118
+ help: { type: "boolean" },
119
+ });
120
+ if (flags.help) {
121
+ console.log(HELP);
122
+ return;
123
+ }
124
+ if (positionals.length > 0) {
125
+ console.error(
126
+ `impel setup: no longer takes a target ("${positionals[0]}"). Run bare \`impel setup\` — it installs the vendored Impel apps. To flip a native tool, use \`impel use gateway [claude|codex]\`.`
127
+ );
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+
132
+ const existing = loadConfig();
133
+ const gatewayUrl = normalizeGatewayUrl(flags.gateway || existing?.gatewayUrl || resolveDefaultGateway());
134
+ const appUrl = normalizeGatewayUrl(flags.app || existing?.appUrl || resolveDefaultAppUrl());
135
+
136
+ console.log("Impel gateway setup");
137
+ console.log(` gateway: ${gatewayUrl}`);
138
+ console.log(` app: ${appUrl}`);
139
+ console.log("");
140
+
141
+ // ── Step 1: token ────────────────────────────────────────────────────────
142
+ let pat = flags.pat;
143
+ if (!pat && existing?.pat) {
144
+ if (io.isTTY) {
145
+ const entered = await io.promptSecret(
146
+ `1/4 Token — one is already stored (${maskSecret(existing.pat)}). Press Enter to keep it, or paste a new one: `
147
+ );
148
+ pat = entered || existing.pat;
149
+ } else {
150
+ pat = existing.pat;
151
+ }
152
+ } else if (!pat) {
153
+ if (!io.isTTY) {
154
+ console.error("impel setup: no token stored and stdin is not a TTY; pass --pat <impel_pat_...>.");
155
+ process.exitCode = 1;
156
+ return;
157
+ }
158
+ console.log(`1/4 Token — create one at ${appUrl}/settings/gateway (step 3 on that page mints it).`);
159
+ pat = await io.promptSecret(" Paste your Personal Access Token (impel_pat_...): ");
160
+ }
161
+ if (!pat) {
162
+ console.error("impel setup: no token provided, aborting.");
163
+ process.exitCode = 1;
164
+ return;
165
+ }
166
+ if (!pat.startsWith("impel_pat_")) {
167
+ console.warn(
168
+ `impel: warning: that token doesn't start with "impel_pat_" — double check you copied the right value.`
169
+ );
170
+ }
171
+
172
+ const config = { ...(existing || {}), pat, gatewayUrl, appUrl, updatedAt: new Date().toISOString() };
173
+ saveConfig(config);
174
+ console.log(` ✓ stored in ~/.config/impel/config.json (mode 0600)`);
175
+ console.log("");
176
+
177
+ // ── Step 2: organization ─────────────────────────────────────────────────
178
+ // A successful tenant fetch also proves the token: the app verifies it
179
+ // against impel-identity before answering.
180
+ let listing;
181
+ try {
182
+ listing = await io.fetchTenants(config);
183
+ } catch (error) {
184
+ console.error(`impel setup: could not verify the token (${redactSecretText(error?.message || error)})`);
185
+ console.error(" Check the token and your network, then re-run `impel setup`.");
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+
190
+ let tenant;
191
+ try {
192
+ if (flags.tenant || listing.tenants.length === 1 || !io.isTTY) {
193
+ tenant = resolveTenantChoice(listing, {
194
+ requested: flags.tenant || null,
195
+ currentTenantId: existing?.tenantId || null,
196
+ });
197
+ } else {
198
+ console.log("2/4 Organization");
199
+ listing.tenants.forEach((candidate, index) => {
200
+ const markers = [
201
+ candidate.id === existing?.tenantId ? "current" : null,
202
+ candidate.id === listing.defaultTenantId ? "default" : null,
203
+ ].filter(Boolean);
204
+ console.log(` [${index + 1}] ${candidate.id}${markers.length ? ` (${markers.join(", ")})` : ""}\t${candidate.name}`);
205
+ });
206
+ const fallback = resolveTenantChoice(listing, { currentTenantId: existing?.tenantId || null });
207
+ const answer = await io.promptText(` Select [1-${listing.tenants.length}] (Enter for ${fallback.id}): `);
208
+ tenant = resolveTenantChoice(listing, { answer, currentTenantId: existing?.tenantId || null });
209
+ }
210
+ } catch (error) {
211
+ console.error(`impel setup: ${redactSecretText(error?.message || error)}`);
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+
216
+ config.tenantId = tenant.id;
217
+ if (listing.productAccess) config.productAccess = listing.productAccess;
218
+ else delete config.productAccess;
219
+ if (listing.scopes) config.scopes = listing.scopes;
220
+ else delete config.scopes;
221
+ config.tenantsUpdatedAt = new Date().toISOString();
222
+ saveConfig(config);
223
+ console.log(` ✓ tenant: ${tenant.id} (${tenant.name})`);
224
+ console.log("");
225
+
226
+ // ── Step 3: apps ─────────────────────────────────────────────────────────
227
+ // Order inside `impel app install`: close running Claude/ChatGPT apps (a
228
+ // running app makes the install fail), bring the vendor apps to the latest
229
+ // version, then build the vendored Impel bundles from that fresh copy.
230
+ // Native Claude Code and Codex profiles are never touched.
231
+ let platformSetupFailed = false;
232
+ if (io.platform === "win32") {
233
+ console.log(
234
+ flags["skip-clis"]
235
+ ? "3/4 Windows CLIs: preparing isolated profiles (vendor CLI installation skipped)…"
236
+ : "3/4 Windows CLIs: ensuring Claude Code and Codex are installed, then preparing isolated profiles…"
237
+ );
238
+ try {
239
+ const result = await io.prepareWindowsClis({
240
+ gatewayUrl,
241
+ tenantId: tenant.id,
242
+ skipInstall: Boolean(flags["skip-clis"]),
243
+ });
244
+ for (const [tool, label] of [["claude", "Claude Code"], ["codex", "Codex"]]) {
245
+ const binary = result.binaries[tool];
246
+ if (binary) console.log(` ✓ ${label}: ${redactSecretText(binary)}`);
247
+ else console.log(` ✗ ${label}: not found`);
248
+ }
249
+ if (result.missingAfter.length > 0 && !flags["skip-clis"]) {
250
+ platformSetupFailed = true;
251
+ if (result.installSucceeded === false) {
252
+ const failure = result.installFailure;
253
+ const detail = failure?.message
254
+ ? `: ${redactSecretText(failure.message)}`
255
+ : Number.isInteger(failure?.status)
256
+ ? ` (exit code ${failure.status})`
257
+ : failure?.signal
258
+ ? ` (signal ${failure.signal})`
259
+ : "";
260
+ console.error(` npm could not install the missing vendor CLIs${detail}.`);
261
+ console.error(" Retry this command in the same PowerShell window:");
262
+ console.error(` ${result.installCommand || windowsCliInstallCommand(result.missingAfter)}`);
263
+ } else {
264
+ console.error(" npm completed, but the installed commands are not discoverable.");
265
+ console.error(" Close and reopen PowerShell, then re-run `impel setup`.");
266
+ console.error(" If they are still missing, run `npm prefix --global` and add that directory to your user PATH.");
267
+ }
268
+ } else if (result.missingAfter.length > 0) {
269
+ console.log(" Vendor CLI installation was skipped; install it before using the matching launcher.");
270
+ } else {
271
+ console.log(" ✓ tenant-isolated Claude and Codex profiles are ready");
272
+ }
273
+ } catch (error) {
274
+ platformSetupFailed = true;
275
+ console.error(` ✗ Windows CLI setup failed (${redactSecretText(error?.message || error)})`);
276
+ console.error(" Fix the issue, then re-run `impel setup`.");
277
+ }
278
+ if (flags["skip-apps"]) {
279
+ console.log(" Windows desktop apps: skipped (--skip-apps).");
280
+ } else {
281
+ console.log(" Windows desktop apps: installing signed vendor apps and isolated Impel profiles…");
282
+ try {
283
+ const installed = await io.installApps(["install", "all"]);
284
+ if (installed === false) {
285
+ platformSetupFailed = true;
286
+ console.error(" ✗ Windows desktop app setup failed; fix the issue above, then re-run `impel app install all`.");
287
+ } else {
288
+ console.log(" ✓ Impel Claude and Impel ChatGPT profiles are ready");
289
+ }
290
+ } catch (error) {
291
+ platformSetupFailed = true;
292
+ console.error(` ✗ Windows desktop app setup failed (${redactSecretText(error?.message || error)})`);
293
+ console.error(" Fix the issue, then re-run `impel app install all`.");
294
+ }
295
+ }
296
+ } else if (flags["skip-apps"]) {
297
+ console.log("3/4 Apps: skipped (--skip-apps). Install later with `impel app install`.");
298
+ } else if (io.platform !== "darwin") {
299
+ console.log(
300
+ "3/4 Apps: skipped — isolated desktop apps are unavailable on this platform. Use `impel claude` / `impel codex` for isolated CLI sessions."
301
+ );
302
+ } else {
303
+ console.log(
304
+ "3/4 Installing the Impel desktop apps (running Claude/ChatGPT apps are closed, vendor apps updated first)…"
305
+ );
306
+ try {
307
+ await io.installApps(["install", "all"]);
308
+ console.log(" ✓ Impel Claude and Impel ChatGPT installed");
309
+ } catch (error) {
310
+ platformSetupFailed = true;
311
+ console.error(
312
+ ` ✗ app installation failed (${redactSecretText(error?.message || error)})`
313
+ );
314
+ console.error(" Fix the issue, then re-run `impel app install`.");
315
+ }
316
+ }
317
+ console.log("");
318
+
319
+ // ── Step 4: verify ───────────────────────────────────────────────────────
320
+ const probe = await io.probe(gatewayUrl, pat, tenant.id);
321
+ let verificationFailed = false;
322
+ if (probe.reachable && !probe.rejected) {
323
+ console.log(`4/4 Verify: ✓ gateway reachable (HTTP ${probe.status})`);
324
+ } else if (probe.rejected) {
325
+ verificationFailed = true;
326
+ console.log(`4/4 Verify: ✗ gateway reachable but it rejected the token (HTTP ${probe.status}).`);
327
+ console.log(" Mint a fresh token and re-run `impel setup`.");
328
+ process.exitCode = 1;
329
+ } else {
330
+ verificationFailed = true;
331
+ console.log(`4/4 Verify: ✗ gateway unreachable (${probe.error}).`);
332
+ console.log(" Check your network, then run `impel status` to retry.");
333
+ process.exitCode = 1;
334
+ }
335
+
336
+ if (platformSetupFailed) process.exitCode = 1;
337
+
338
+ console.log("");
339
+ if (platformSetupFailed || verificationFailed) {
340
+ console.log("Setup is incomplete; the token and tenant were saved, but the failed step above still needs attention.");
341
+ console.log("Fix it and re-run `impel setup`.");
342
+ return;
343
+ }
344
+ console.log("You're set. Try:");
345
+ if (io.platform === "darwin") console.log(" impel app open open the Impel desktop apps");
346
+ if (io.platform === "win32") console.log(" impel app open all open Claude and ChatGPT with isolated Impel profiles");
347
+ console.log(" impel claude isolated Claude Code through the gateway");
348
+ console.log(" impel codex isolated Codex through the gateway");
349
+ console.log(" impel status full diagnostics any time");
350
+ }
@@ -0,0 +1,108 @@
1
+ // `impel skills sync [claude|codex|all]` — explicitly refresh the Bifrost
2
+ // shared-skills marketplace into every managed installation of a client:
3
+ // - the native profile that `impel use gateway` manages (~/.claude, ~/.codex),
4
+ // - the isolated `impel claude` / `impel codex` CLI profile (cliProfiles.js),
5
+ // - the isolated desktop app profile (apps.js), when it is initialized.
6
+
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+
11
+ import { loadConfig } from "../config.js";
12
+ import { CODEX_HOME } from "../codexSetup.js";
13
+ import { tenantCliProfilePaths } from "../cliProfiles.js";
14
+ import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
15
+ import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
16
+ import { ensureTenantSelection } from "../tenants.js";
17
+ import { windowsClaudeUserData } from "../windowsApps.js";
18
+
19
+ const VALID_TARGETS = ["claude", "codex", "all"];
20
+
21
+ function normalizeSkillsTarget(token) {
22
+ if (token === undefined || token === "all") return "all";
23
+ if (token === "claude") return "claude";
24
+ if (token === "codex" || token === "codex-cli" || token === "codex-app") return "codex";
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * Managed profiles to sync for a client. Env overrides steer the client binary
30
+ * at the SAME profile each managed installation writes to. Isolated CLI/app
31
+ * profiles are only included when they already exist, so we never conjure a
32
+ * half-initialized profile just to sync skills into it.
33
+ */
34
+ export function managedSkillProfiles(
35
+ client,
36
+ {
37
+ homeDir = os.homedir(),
38
+ existsSync = fs.existsSync,
39
+ tenantId = null,
40
+ platform = process.platform,
41
+ environment = process.env,
42
+ } = {},
43
+ ) {
44
+ const profiles = [];
45
+ const cliPaths = tenantId ? tenantCliProfilePaths(tenantId) : null;
46
+ const claudeUserData = platform === "win32"
47
+ ? windowsClaudeUserData(environment, tenantId)
48
+ : null;
49
+ const paths = appPaths(homeDir, tenantId, { claudeUserData });
50
+
51
+ if (client === "claude") {
52
+ // Native ~/.claude — what `impel use gateway claude` manages. No env override
53
+ // so the binary resolves its config exactly as a bare `claude` would.
54
+ profiles.push({ label: "Claude Code (native profile)", env: {} });
55
+ if (cliPaths && existsSync(cliPaths.claudeConfigDir)) {
56
+ profiles.push({ label: "Impel isolated Claude (impel claude)", env: { CLAUDE_CONFIG_DIR: cliPaths.claudeConfigDir } });
57
+ }
58
+ const appInstalled = platform === "win32"
59
+ ? existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`))
60
+ : existsSync(paths.claude.launcher);
61
+ if (appInstalled) {
62
+ profiles.push({ label: "Impel Claude app", env: { CLAUDE_CONFIG_DIR: paths.claude.userData } });
63
+ }
64
+ } else {
65
+ // Native Codex home — `impel use gateway codex` manages CODEX_HOME (env or ~/.codex).
66
+ profiles.push({ label: "Codex CLI (native profile)", env: { CODEX_HOME } });
67
+ if (cliPaths && existsSync(cliPaths.codexHome)) {
68
+ profiles.push({ label: "Impel isolated Codex (impel codex)", env: { CODEX_HOME: cliPaths.codexHome } });
69
+ }
70
+ const appInstalled = platform === "win32"
71
+ ? existsSync(path.join(paths.chatgpt.codexHome, "config.toml"))
72
+ : existsSync(paths.chatgpt.launcher);
73
+ if (appInstalled) {
74
+ profiles.push({ label: "Impel ChatGPT app", env: { CODEX_HOME: paths.chatgpt.codexHome } });
75
+ }
76
+ }
77
+
78
+ return profiles;
79
+ }
80
+
81
+ export async function cmdSkills(argv) {
82
+ const [action = "sync", targetToken] = argv;
83
+ if (action !== "sync") {
84
+ console.error(`impel skills: unknown action "${action}". Try \`impel skills sync [claude|codex|all]\`.`);
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+
89
+ const target = normalizeSkillsTarget(targetToken);
90
+ if (target === null) {
91
+ console.error(`impel skills: unknown target "${targetToken}". Use \`claude\`, \`codex\`, or \`all\`.`);
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+
96
+ const config = loadConfig();
97
+ const gatewayUrl = resolveSkillsGateway(config?.gatewayUrl);
98
+ const tenantId = config?.pat
99
+ ? (await ensureTenantSelection(config)).tenantId
100
+ : null;
101
+ const clients = target === "all" ? ["claude", "codex"] : [target];
102
+
103
+ for (const client of clients) {
104
+ for (const profile of managedSkillProfiles(client, { tenantId })) {
105
+ await syncSkillsSafe({ client, gatewayUrl, env: profile.env, label: profile.label });
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,95 @@
1
+ import { loadConfig, resolveDefaultGateway, resolveDefaultAppUrl, maskSecret } from "../config.js";
2
+ import { detectClaudeMode } from "../claudeSetup.js";
3
+ import { detectCodexMode } from "../codexSetup.js";
4
+ import { ensureTenantSelection, productAccessLabel, tenantCredential } from "../tenants.js";
5
+ import { maybePrintUpdateNotice } from "../updates.js";
6
+ import { findNativeBinary } from "../nativeProcess.js";
7
+
8
+ function modeLabel(mode) {
9
+ return mode === "gateway" ? "GATEWAY" : "ACCOUNT";
10
+ }
11
+
12
+ export async function cmdStatus() {
13
+ const config = loadConfig();
14
+ const gatewayUrl = config?.gatewayUrl || resolveDefaultGateway();
15
+ const appUrl = config?.appUrl || resolveDefaultAppUrl();
16
+
17
+ console.log(`Gateway URL: ${gatewayUrl}`);
18
+ console.log(`App URL: ${appUrl}`);
19
+ console.log(`PAT stored: ${config?.pat ? `yes (${maskSecret(config.pat)})` : "no — run `impel auth`"}`);
20
+ let tenantId = config?.tenantId || null;
21
+ let productAccess = null;
22
+ let scopes = null;
23
+ let selectionFresh = false;
24
+ if (config?.pat) {
25
+ try {
26
+ const selected = await ensureTenantSelection(config, { refresh: true });
27
+ tenantId = selected.tenantId;
28
+ productAccess = selected.productAccess;
29
+ scopes = selected.scopes;
30
+ selectionFresh = true;
31
+ } catch {
32
+ // The reachability probe below still reports the underlying network/auth state.
33
+ }
34
+ }
35
+ const staleLabel = config?.pat && !selectionFresh ? " (cached selection; live check failed)" : "";
36
+ const unavailableReason = config?.pat ? "live check failed" : "run `impel auth`";
37
+ console.log(`Tenant: ${tenantId ? `${tenantId}${staleLabel}` : "not selected — run `impel tenant list`"}`);
38
+ console.log(`Access: ${selectionFresh ? productAccessLabel(productAccess) : `Unknown — ${unavailableReason}`}`);
39
+ console.log(`PAT scopes: ${selectionFresh && scopes?.length ? scopes.join(", ") : `unknown — ${unavailableReason}`}`);
40
+ const claudeBinary = findNativeBinary("claude");
41
+ const codexBinary = findNativeBinary("codex");
42
+ console.log("Isolated CLI launchers:");
43
+ console.log(
44
+ ` Claude: ${config?.pat && claudeBinary ? "READY (`impel claude`)" : !config?.pat ? "NOT READY — run `impel setup`" : "NOT READY — Claude Code is not installed"}`
45
+ );
46
+ console.log(
47
+ ` Codex: ${config?.pat && codexBinary ? "READY (`impel codex`)" : !config?.pat ? "NOT READY — run `impel setup`" : "NOT READY — Codex is not installed"}`
48
+ );
49
+
50
+ // Per-tool mode. Codex CLI and the Codex app/IDE share the same
51
+ // ~/.codex/config.toml, so they always report the same mode.
52
+ const claude = detectClaudeMode(gatewayUrl);
53
+ const codex = detectCodexMode();
54
+
55
+ console.log("");
56
+ console.log("Native profile mode (isolated launchers do not change this):");
57
+ console.log(` Claude Code: ${modeLabel(claude.mode)}`);
58
+ console.log(` Codex CLI: ${modeLabel(codex.mode)}`);
59
+ console.log(` Codex app: ${modeLabel(codex.mode)} (shares ~/.codex/config.toml with the CLI)`);
60
+ console.log(" Flip: `impel use gateway|account [claude|codex|all]` (aliases: `impel on` / `impel off`)");
61
+
62
+ maybePrintUpdateNotice();
63
+
64
+ const url = `${gatewayUrl}/anthropic/v1/messages`;
65
+ console.log("");
66
+ process.stdout.write(`Reachability: probing ${url} ... `);
67
+
68
+ const controller = new AbortController();
69
+ const timeout = setTimeout(() => controller.abort(), 5000);
70
+ try {
71
+ const res = await fetch(url, {
72
+ method: "POST",
73
+ headers: {
74
+ "content-type": "application/json",
75
+ ...(config?.pat && tenantId
76
+ ? { authorization: `Bearer ${tenantCredential(config.pat, tenantId)}` }
77
+ : {}),
78
+ },
79
+ body: "{}",
80
+ signal: controller.signal,
81
+ });
82
+ // Any HTTP response — even a 4xx from a malformed/empty test body — means
83
+ // the gateway is up and routing. This is a best-effort reachability
84
+ // check, not a validity check of the request itself.
85
+ console.log(`HTTP ${res.status} ${res.statusText || ""}`.trim());
86
+ if (res.status === 401 || res.status === 403) {
87
+ console.log(" (gateway is reachable; that status usually means the PAT was rejected)");
88
+ }
89
+ } catch (err) {
90
+ console.log("unreachable");
91
+ console.log(` ${err.name === "AbortError" ? "timed out after 5s" : err.message}`);
92
+ } finally {
93
+ clearTimeout(timeout);
94
+ }
95
+ }