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,215 @@
1
+ import { parseFlags } from "../args.js";
2
+ import { loadConfig, normalizeGatewayUrl, resolveDefaultGateway } from "../config.js";
3
+ import {
4
+ DEFAULT_TTFT_BUDGET_MS,
5
+ DOCTOR_PROVIDERS,
6
+ probeTenant,
7
+ recordSucceeded,
8
+ } from "../doctor.js";
9
+ import {
10
+ assertProviderScopes,
11
+ fetchTenants,
12
+ normalizeTenantId,
13
+ productAccessLabel,
14
+ } from "../tenants.js";
15
+
16
+ const HELP = `impel doctor - run synthetic, billable end-to-end gateway checks
17
+
18
+ Usage:
19
+ impel doctor [--tenant <org> | --all-tenants] [options]
20
+
21
+ Options:
22
+ --gateway <url> Invocation-only gateway (flag > IMPEL_GATEWAY_URL > saved config).
23
+ --providers claude,codex Providers to check. Default: both.
24
+ --attempts <n> Requests per provider (1-5). Default: 1.
25
+ --timeout-ms <n> Per-request timeout. Default: 120000.
26
+ --claude-ttft-ms <n> Claude TTFT warning budget. Default: 6000.
27
+ --codex-ttft-ms <n> Codex TTFT warning budget. Default: 5000.
28
+ --strict-latency Fail when any successful request misses its budget.
29
+ --json Print one sanitized JSON report.
30
+
31
+ The probe sends a synthetic marker only. It never prints your PAT or real prompt content.
32
+ `;
33
+
34
+ const DOCTOR_FLAGS = new Set([
35
+ "tenant",
36
+ "all-tenants",
37
+ "gateway",
38
+ "providers",
39
+ "attempts",
40
+ "timeout-ms",
41
+ "claude-ttft-ms",
42
+ "codex-ttft-ms",
43
+ "strict-latency",
44
+ "json",
45
+ ]);
46
+ const MAX_TOTAL_PROBES = 50;
47
+
48
+ function integerFlag(value, label, { min, max }) {
49
+ if (value === undefined) return null;
50
+ if (!/^\d+$/u.test(String(value))) throw new Error(`${label} must be an integer`);
51
+ const number = Number(value);
52
+ if (!Number.isSafeInteger(number) || number < min || number > max) {
53
+ throw new Error(`${label} must be between ${min} and ${max}`);
54
+ }
55
+ return number;
56
+ }
57
+
58
+ function parseProviders(value) {
59
+ if (value === undefined) return [...DOCTOR_PROVIDERS];
60
+ const providers = [...new Set(String(value).split(",").map((item) => item.trim()).filter(Boolean))];
61
+ if (!providers.length || providers.some((provider) => !DOCTOR_PROVIDERS.includes(provider))) {
62
+ throw new Error("--providers must be claude, codex, or claude,codex");
63
+ }
64
+ return providers;
65
+ }
66
+
67
+ function doctorGatewayUrl(value) {
68
+ const normalized = normalizeGatewayUrl(value);
69
+ let parsed;
70
+ try {
71
+ parsed = new URL(normalized);
72
+ } catch {
73
+ throw new Error("--gateway must be an absolute HTTP or HTTPS origin");
74
+ }
75
+ if (!["http:", "https:"].includes(parsed.protocol)
76
+ || !parsed.hostname
77
+ || parsed.username
78
+ || parsed.password
79
+ || parsed.pathname !== "/"
80
+ || parsed.search
81
+ || parsed.hash) {
82
+ throw new Error("--gateway must be an absolute HTTP or HTTPS origin without credentials, path, query, or fragment");
83
+ }
84
+ return parsed.origin;
85
+ }
86
+
87
+ function printHuman(report) {
88
+ for (const tenant of report.tenants) {
89
+ console.log(`Tenant: ${tenant.tenantId}`);
90
+ console.log(`Access: ${productAccessLabel(tenant.productAccess)}`);
91
+ console.log(`Catalog: ${tenant.catalog.status ?? "network error"} (${tenant.catalog.models} ready models, ${tenant.catalog.durationMs ?? "?"}ms)`);
92
+ if (tenant.catalog.error) console.log(` ERROR ${tenant.catalog.error}`);
93
+ for (const record of tenant.records) {
94
+ const ok = recordSucceeded(record);
95
+ const latency = record.ttftMs === null ? "TTFT n/a" : `TTFT ${record.ttftMs}ms, total ${record.totalMs}ms`;
96
+ console.log(
97
+ ` ${record.provider} #${record.attempt}: ${ok ? "PASS" : "FAIL"} `
98
+ + `(HTTP ${record.status ?? "n/a"}, ${latency}, account ${record.accountId ? "selected" : "missing"})`,
99
+ );
100
+ if (record.error) console.log(` ${record.error}`);
101
+ else if (!record.exactAck) console.log(" response did not exactly acknowledge the synthetic request marker");
102
+ else if (!record.responseId) console.log(" provider stream did not include a response id");
103
+ else if (!record.terminalEvent) console.log(" provider stream did not finish successfully");
104
+ else if (!record.requestId) console.log(" response did not include a gateway-owned request id");
105
+ else if (record.echoedClientRequestId !== record.clientRequestId) console.log(" response did not preserve the client correlation id");
106
+ else if (record.orgId !== tenant.tenantId) console.log(" response tenant attribution did not match the selected tenant");
107
+ else if (!record.contentType.toLowerCase().includes("text/event-stream")) console.log(" response was not a streaming event stream");
108
+ }
109
+ for (const [provider, summary] of Object.entries(tenant.providers)) {
110
+ if (summary.skipped) {
111
+ console.log(` ${provider}: SKIP (no subscription seat is available for this tenant)`);
112
+ continue;
113
+ }
114
+ console.log(
115
+ ` ${provider} latency: p50 ${summary.ttftP50Ms ?? "n/a"}ms / p95 ${summary.ttftP95Ms ?? "n/a"}ms `
116
+ + `(budget ${summary.ttftBudgetMs}ms; ${summary.withinLatencyBudget}/${summary.attempted} within)`,
117
+ );
118
+ }
119
+ console.log(`Result: ${tenant.passed ? "PASS" : "FAIL"}${tenant.latencyPassed === false ? " (latency warning)" : ""}`);
120
+ console.log("");
121
+ }
122
+ console.log(`Overall: ${report.passed ? "PASS" : "FAIL"}`);
123
+ }
124
+
125
+ export async function cmdDoctor(argv) {
126
+ if (argv.some((arg) => ["help", "--help", "-h"].includes(arg))) {
127
+ console.log(HELP);
128
+ return;
129
+ }
130
+ const { flags, positionals } = parseFlags(argv, {
131
+ tenant: { type: "string" },
132
+ "all-tenants": { type: "boolean" },
133
+ gateway: { type: "string" },
134
+ providers: { type: "string" },
135
+ attempts: { type: "string" },
136
+ "timeout-ms": { type: "string" },
137
+ "claude-ttft-ms": { type: "string" },
138
+ "codex-ttft-ms": { type: "string" },
139
+ "strict-latency": { type: "boolean" },
140
+ json: { type: "boolean" },
141
+ });
142
+ const unknownFlag = Object.keys(flags).find((flag) => !DOCTOR_FLAGS.has(flag));
143
+ if (unknownFlag) throw new Error(`unknown doctor option "--${unknownFlag}"`);
144
+ for (const flag of ["tenant", "gateway", "providers", "attempts", "timeout-ms", "claude-ttft-ms", "codex-ttft-ms"]) {
145
+ if (argv.includes(`--${flag}`) && flags[flag] === undefined) {
146
+ throw new Error(`--${flag} requires a value`);
147
+ }
148
+ }
149
+ for (const flag of ["all-tenants", "strict-latency", "json"]) {
150
+ if (flags[flag] !== undefined && flags[flag] !== true) {
151
+ throw new Error(`--${flag} does not take a value`);
152
+ }
153
+ }
154
+ if (positionals.length) throw new Error(`unexpected doctor argument "${positionals[0]}"`);
155
+ if (flags.tenant && flags["all-tenants"]) throw new Error("use --tenant or --all-tenants, not both");
156
+
157
+ const config = loadConfig();
158
+ if (!config?.pat) throw new Error("not authenticated; run `impel setup` (or `impel auth`) first");
159
+ const gatewayUrl = doctorGatewayUrl(
160
+ flags.gateway !== undefined
161
+ ? flags.gateway
162
+ : process.env.IMPEL_GATEWAY_URL || config.gatewayUrl || resolveDefaultGateway(),
163
+ );
164
+ const doctorConfig = { ...config, gatewayUrl };
165
+ const listing = await fetchTenants(config);
166
+ if (!listing.productAccess) {
167
+ throw new Error("the control plane did not return a live product access entitlement; retry after it is upgraded");
168
+ }
169
+
170
+ const requestedTenant = flags.tenant ? normalizeTenantId(flags.tenant) : null;
171
+ const selectedTenant = requestedTenant
172
+ ? listing.tenants.find((tenant) => tenant.id === requestedTenant || tenant.slug === requestedTenant)
173
+ : listing.tenants.find((tenant) => tenant.id === config.tenantId) || listing.tenants.find((tenant) => tenant.id === listing.defaultTenantId);
174
+ if (requestedTenant && !selectedTenant) throw new Error(`tenant "${requestedTenant}" is not available to this user`);
175
+ const tenants = flags["all-tenants"] ? listing.tenants : [selectedTenant];
176
+
177
+ const providers = parseProviders(flags.providers);
178
+ assertProviderScopes(listing.scopes, providers, { requireLive: true });
179
+ const attempts = integerFlag(flags.attempts, "--attempts", { min: 1, max: 5 }) ?? 1;
180
+ const timeoutMs = integerFlag(flags["timeout-ms"], "--timeout-ms", { min: 1_000, max: 300_000 }) ?? 120_000;
181
+ const ttftBudgets = {
182
+ claude: integerFlag(flags["claude-ttft-ms"], "--claude-ttft-ms", { min: 100, max: 300_000 }) ?? DEFAULT_TTFT_BUDGET_MS.claude,
183
+ codex: integerFlag(flags["codex-ttft-ms"], "--codex-ttft-ms", { min: 100, max: 300_000 }) ?? DEFAULT_TTFT_BUDGET_MS.codex,
184
+ };
185
+ const totalProbes = tenants.length * providers.length * attempts;
186
+ if (totalProbes > MAX_TOTAL_PROBES) {
187
+ throw new Error(
188
+ `doctor would send ${totalProbes} billable requests; narrow the tenants/providers/attempts to at most ${MAX_TOTAL_PROBES}`,
189
+ );
190
+ }
191
+
192
+ const tenantReports = [];
193
+ for (const tenant of tenants) {
194
+ tenantReports.push(await probeTenant({
195
+ config: doctorConfig,
196
+ tenantId: tenant.id,
197
+ productAccess: listing.productAccess,
198
+ providers,
199
+ attempts,
200
+ timeoutMs,
201
+ ttftBudgets,
202
+ strictLatency: Boolean(flags["strict-latency"]),
203
+ }));
204
+ }
205
+ const report = {
206
+ generatedAt: new Date().toISOString(),
207
+ synthetic: true,
208
+ strictLatency: Boolean(flags["strict-latency"]),
209
+ tenants: tenantReports,
210
+ passed: tenantReports.length > 0 && tenantReports.every((tenant) => tenant.passed),
211
+ };
212
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
213
+ else printHuman(report);
214
+ if (!report.passed) process.exitCode = 1;
215
+ }
@@ -0,0 +1,60 @@
1
+ import { crossAppModelsEnabled, loadConfig, saveConfig } from "../config.js";
2
+ import { assertProviderScopes, ensureTenantSelection } from "../tenants.js";
3
+
4
+ const HELP = `impel experimental cross-app-models enable|disable|status
5
+
6
+ Hidden experiment for Impel-managed desktop apps and isolated CLI launchers.
7
+ When enabled, Impel Claude/\`impel claude\` can use tenant-routable GPT models
8
+ and Impel ChatGPT/Codex/\`impel codex\` can use tenant-routable Claude models.
9
+ It does not modify native profiles or support the consumer ChatGPT app
10
+ (com.openai.chat).
11
+
12
+ Run \`impel app refresh all\` to refresh desktop configs. Reopen an Impel app
13
+ or isolated CLI to apply the experiment.`;
14
+
15
+ export async function cmdExperimental(argv, dependencies = {}) {
16
+ const read = dependencies.loadConfig || loadConfig;
17
+ const write = dependencies.saveConfig || saveConfig;
18
+ const select = dependencies.ensureTenantSelection || ensureTenantSelection;
19
+ const log = dependencies.log || console.log;
20
+ const [experiment, action, ...extra] = argv;
21
+
22
+ if ((experiment === "help" || experiment == null) && action == null && extra.length === 0) {
23
+ log(HELP);
24
+ return;
25
+ }
26
+ if (experiment !== "cross-app-models" || !["enable", "disable", "status"].includes(action) || extra.length > 0) {
27
+ throw new Error("usage: impel experimental cross-app-models enable|disable|status");
28
+ }
29
+
30
+ const config = read();
31
+ if (action === "status") {
32
+ log(`Cross-app models: ${crossAppModelsEnabled(config) ? "enabled" : "disabled"}`);
33
+ return;
34
+ }
35
+ if (action === "disable") {
36
+ if (config && crossAppModelsEnabled(config)) {
37
+ delete config.experimental.crossAppModels;
38
+ if (Object.keys(config.experimental).length === 0) delete config.experimental;
39
+ write(config);
40
+ }
41
+ log(
42
+ "Cross-app models disabled. Reopen an Impel app or isolated CLI; run `impel app refresh all` to refresh desktop configs.",
43
+ );
44
+ return;
45
+ }
46
+
47
+ if (!config?.pat) throw new Error("run `impel auth` before enabling cross-app models");
48
+ const selected = await select(config, { refresh: true });
49
+ assertProviderScopes(selected.scopes, ["claude", "codex"], { requireLive: true });
50
+ selected.config.experimental = {
51
+ ...(selected.config.experimental && typeof selected.config.experimental === "object"
52
+ ? selected.config.experimental
53
+ : {}),
54
+ crossAppModels: true,
55
+ };
56
+ write(selected.config);
57
+ log(
58
+ "Cross-app models enabled for Impel-managed apps and isolated CLIs. Reopen one or run `impel app refresh all` to refresh desktop configs.",
59
+ );
60
+ }
@@ -0,0 +1,161 @@
1
+ import { spawn } from "node:child_process";
2
+ import os from "node:os";
3
+
4
+ import {
5
+ ensureImpelClaudeProfile,
6
+ ensureImpelCodexProfile,
7
+ } from "../cliProfiles.js";
8
+ import {
9
+ crossAppModelsEnabled,
10
+ loadConfig,
11
+ normalizeGatewayUrl,
12
+ redactSecretText,
13
+ resolveDefaultGateway,
14
+ } from "../config.js";
15
+ import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "../claudeSetup.js";
16
+ import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
17
+ import { maybePrintUpdateNotice } from "../updates.js";
18
+ import {
19
+ nativeSpawnInvocation,
20
+ resolveNativeBinary,
21
+ } from "../nativeProcess.js";
22
+
23
+ const CLAUDE_DIRECT_AUTH_ENV = [
24
+ "ANTHROPIC_API_KEY",
25
+ "ANTHROPIC_AUTH_TOKEN",
26
+ "CLAUDE_CODE_USE_BEDROCK",
27
+ "CLAUDE_CODE_USE_FOUNDRY",
28
+ "CLAUDE_CODE_USE_VERTEX",
29
+ ];
30
+
31
+ const CODEX_DIRECT_AUTH_ENV = ["CODEX_ACCESS_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL"];
32
+ export { escapeWindowsBatchArgument, nativeSpawnInvocation, resolveNativeBinary } from "../nativeProcess.js";
33
+
34
+ export const IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS = `You are running in an Impel tenant-scoped session. Before starting any non-trivial task, call the Impel MCP tool list_specialists. If exactly one available specialist clearly matches the user's request, its capabilities and its exclusions, delegate the complete request by calling start_specialist_run exactly once with a stable idempotency key, then call read_specialist_run until it reaches a terminal state. When the run succeeds, use the specialist's result as your response instead of redoing the work. If no specialist clearly matches, the tools are unavailable, or the run fails, continue normally yourself. Do not delegate trivial requests, do not call a specialist excluded from the request, and never invent a specialist result.`;
35
+
36
+ export const IMPEL_CODEX_SPECIALIST_DELEGATION_INSTRUCTIONS =
37
+ `${IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS} ` +
38
+ `Codex can defer MCP tools behind tool_search. If an Impel specialist tool is not directly visible, call tool_search for its exact name before treating it as unavailable: impel_specialists-list_specialists for discovery, impel_specialists-start_specialist_run to delegate, and impel_specialists-read_specialist_run to poll the result. Use the returned tool for the same one-run delegation flow.`;
39
+
40
+ const IMPEL_CODEX_RUNTIME_OVERRIDES = [
41
+ // Codex models may select code mode even when the standalone
42
+ // `codex-code-mode-host` companion is not present in the vendor install.
43
+ // The embedded runtime is the vendor-supported equivalent and is scoped to
44
+ // this Impel invocation, so the user's native Codex profile is unaffected.
45
+ "features.code_mode_host=false",
46
+ ];
47
+
48
+ export function impelLaunchArguments(tool, argv) {
49
+ if (tool === "claude") {
50
+ return ["--append-system-prompt", IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS, ...argv];
51
+ }
52
+ if (tool === "codex") {
53
+ // `-c` is a Codex global option, so it must precede subcommands such as
54
+ // `exec`, `resume`, and `mcp`. JSON strings are valid TOML basic strings.
55
+ return [
56
+ "-c",
57
+ `developer_instructions=${JSON.stringify(IMPEL_CODEX_SPECIALIST_DELEGATION_INSTRUCTIONS)}`,
58
+ ...IMPEL_CODEX_RUNTIME_OVERRIDES.flatMap((override) => ["-c", override]),
59
+ ...argv,
60
+ ];
61
+ }
62
+ throw new Error(`unsupported CLI launcher: ${tool}`);
63
+ }
64
+
65
+ function deleteEnvironmentKeys(environment, keys) {
66
+ for (const key of keys) delete environment[key];
67
+ }
68
+
69
+ function childExitCode(code, signal) {
70
+ if (Number.isInteger(code)) return code;
71
+ const signalNumber = signal ? os.constants.signals[signal] : null;
72
+ return Number.isInteger(signalNumber) ? 128 + signalNumber : 1;
73
+ }
74
+
75
+ function runNativeCli(tool, argv, environment) {
76
+ const binary = resolveNativeBinary(tool, environment);
77
+ let invocation;
78
+ try {
79
+ invocation = nativeSpawnInvocation(binary, argv, environment);
80
+ } catch (error) {
81
+ console.error(`impel ${tool}: ${redactSecretText(error?.message || error)}`);
82
+ return Promise.resolve(1);
83
+ }
84
+
85
+ return new Promise((resolve) => {
86
+ const child = spawn(invocation.command, invocation.args, {
87
+ cwd: process.cwd(),
88
+ env: environment,
89
+ stdio: "inherit",
90
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
91
+ });
92
+ let settled = false;
93
+ child.once("error", (error) => {
94
+ if (settled) return;
95
+ settled = true;
96
+ console.error(
97
+ `impel ${tool}: could not launch \`${redactSecretText(binary)}\`: ${redactSecretText(error.message)}`,
98
+ );
99
+ resolve(127);
100
+ });
101
+ child.once("exit", (code, signal) => {
102
+ if (settled) return;
103
+ settled = true;
104
+ resolve(childExitCode(code, signal));
105
+ });
106
+ });
107
+ }
108
+
109
+ export async function cmdLaunch(tool, argv) {
110
+ const config = loadConfig();
111
+ if (!config?.pat) {
112
+ console.error(`impel ${tool}: not authenticated. Run \`impel auth\` first.`);
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ maybePrintUpdateNotice();
117
+
118
+ const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
119
+ const crossAppModels = tool === "claude" && crossAppModelsEnabled(config);
120
+ let tenantId;
121
+ try {
122
+ // PAT scopes are immutable. Refresh legacy configs once, then use the
123
+ // locally persisted scope list so launching the native CLI stays fast.
124
+ const selection = await ensureTenantSelection(config, { refresh: !Array.isArray(config.scopes) });
125
+ assertProviderScopes(selection.scopes, crossAppModels ? ["claude", "codex"] : [tool], {
126
+ requireLive: true,
127
+ });
128
+ tenantId = selection.tenantId;
129
+ } catch (error) {
130
+ console.error(`impel ${tool}: ${error.message}`);
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+ const gatewayCredential = tenantCredential(config.pat, tenantId);
135
+ const environment = { ...process.env };
136
+ environment.IMPEL_TENANT_ID = tenantId;
137
+
138
+ if (tool === "claude") {
139
+ const profile = ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels });
140
+ deleteEnvironmentKeys(environment, CLAUDE_DIRECT_AUTH_ENV);
141
+ delete environment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY;
142
+ environment.CLAUDE_CONFIG_DIR = profile.configDir;
143
+ environment.ANTHROPIC_BASE_URL = crossAppModels
144
+ ? impelCrossAppClaudeBaseUrl(gatewayUrl)
145
+ : impelClaudeBaseUrl(gatewayUrl);
146
+ if (crossAppModels) environment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
147
+ // Claude's external apiKeyHelper path validates Anthropic-shaped API keys.
148
+ // Impel PATs are bearer tokens, so keep the token process-scoped instead of
149
+ // writing it into Claude's isolated profile.
150
+ environment.ANTHROPIC_AUTH_TOKEN = gatewayCredential;
151
+ } else if (tool === "codex") {
152
+ const profile = ensureImpelCodexProfile(gatewayUrl, tenantId);
153
+ deleteEnvironmentKeys(environment, CODEX_DIRECT_AUTH_ENV);
154
+ environment.CODEX_HOME = profile.codexHome;
155
+ } else {
156
+ throw new Error(`unsupported CLI launcher: ${tool}`);
157
+ }
158
+
159
+ const exitCode = await runNativeCli(tool, impelLaunchArguments(tool, argv), environment);
160
+ if (exitCode !== 0) process.exitCode = exitCode;
161
+ }
@@ -0,0 +1,94 @@
1
+ import readline from "node:readline";
2
+
3
+ import { loadConfig, resolveDefaultGateway } from "../config.js";
4
+ import { parseFlags } from "../args.js";
5
+ import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
6
+
7
+ function rpcError(id, message) {
8
+ return JSON.stringify({
9
+ jsonrpc: "2.0",
10
+ id: id ?? null,
11
+ error: { code: -32000, message },
12
+ });
13
+ }
14
+
15
+ function responseLines(contentType, body) {
16
+ if (!contentType.toLowerCase().includes("text/event-stream")) {
17
+ return body.trim() ? [JSON.stringify(JSON.parse(body))] : [];
18
+ }
19
+ return body
20
+ .split(/\r?\n/)
21
+ .filter((line) => line.startsWith("data:"))
22
+ .map((line) => line.slice(5).trim())
23
+ .filter((line) => line && line !== "[DONE]")
24
+ .map((line) => JSON.stringify(JSON.parse(line)));
25
+ }
26
+
27
+ export async function cmdMcp(argv = []) {
28
+ const { flags } = parseFlags(argv, { tenant: { type: "string" } });
29
+ const config = loadConfig();
30
+ if (!config?.pat) {
31
+ throw new Error("not authenticated; run `impel setup` (or `impel auth`) first");
32
+ }
33
+ const gatewayUrl = config.gatewayUrl || resolveDefaultGateway();
34
+ const tenantId = flags.tenant
35
+ ? normalizeTenantId(flags.tenant)
36
+ : (await ensureTenantSelection(config)).tenantId;
37
+ const gatewayCredential = tenantCredential(config.pat, tenantId);
38
+ const endpoint = `${gatewayUrl}/mcp`;
39
+ const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
40
+ let sessionId;
41
+
42
+ for await (const line of input) {
43
+ const trimmed = line.trim();
44
+ if (!trimmed) continue;
45
+ let message;
46
+ try {
47
+ message = JSON.parse(trimmed);
48
+ } catch {
49
+ process.stdout.write(`${rpcError(null, "Invalid JSON-RPC request.")}\n`);
50
+ continue;
51
+ }
52
+
53
+ try {
54
+ const controller = new AbortController();
55
+ const timeout = setTimeout(() => controller.abort(), 70_000);
56
+ let response;
57
+ try {
58
+ response = await fetch(endpoint, {
59
+ method: "POST",
60
+ headers: {
61
+ Authorization: `Bearer ${gatewayCredential}`,
62
+ "Content-Type": "application/json",
63
+ Accept: "application/json, text/event-stream",
64
+ ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
65
+ },
66
+ body: JSON.stringify(message),
67
+ signal: controller.signal,
68
+ });
69
+ } finally {
70
+ clearTimeout(timeout);
71
+ }
72
+ sessionId = response.headers.get("mcp-session-id") || sessionId;
73
+ const body = await response.text();
74
+ if (!response.ok) {
75
+ process.stdout.write(
76
+ `${rpcError(message.id, `Impel MCP gateway returned HTTP ${response.status}.`)}\n`
77
+ );
78
+ continue;
79
+ }
80
+ for (const output of responseLines(
81
+ response.headers.get("content-type") || "application/json",
82
+ body
83
+ )) {
84
+ process.stdout.write(`${output}\n`);
85
+ }
86
+ } catch (error) {
87
+ const messageText =
88
+ error?.name === "AbortError"
89
+ ? "Impel MCP gateway timed out."
90
+ : `Impel MCP gateway request failed: ${error?.message || error}`;
91
+ process.stdout.write(`${rpcError(message.id, messageText)}\n`);
92
+ }
93
+ }
94
+ }