impel-cli 0.13.2 → 0.14.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.
- package/README.md +24 -4
- package/package.json +4 -1
- package/src/agents.js +10 -4
- package/src/gateway/index.js +454 -0
- package/src/nativeProcess.js +27 -11
package/README.md
CHANGED
|
@@ -25,6 +25,26 @@ of the protected Store directory and launches it with tenant-specific Codex and
|
|
|
25
25
|
browser profiles. Each approach keeps the user's normal app profile and
|
|
26
26
|
signed-in account untouched.
|
|
27
27
|
|
|
28
|
+
## Gateway-only vendor package
|
|
29
|
+
|
|
30
|
+
White-labelled gateway launchers import the deliberately narrow
|
|
31
|
+
`impel-cli/gateway` subpath. It exports only `createGatewayCli`; the resulting
|
|
32
|
+
CLI accepts `setup`, `auth`, `claude`, `codex`, `status`, `token`, help, and
|
|
33
|
+
version. Tasks, tenants, PAT minting, apps, agents, skills, MCP, updates, and
|
|
34
|
+
other Impel control-plane commands are not part of this package surface.
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { createGatewayCli } from "impel-cli/gateway";
|
|
38
|
+
|
|
39
|
+
const cli = createGatewayCli({ brand, entrypoint, version });
|
|
40
|
+
const exitCode = await cli.main(process.argv.slice(2));
|
|
41
|
+
if (exitCode) process.exitCode = exitCode;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The caller supplies a validated branding document, its own executable path,
|
|
45
|
+
and its package version. Gateway profile behavior and the command allowlist
|
|
46
|
+
remain in `impel-cli`, so vendor CLIs do not copy or fork those implementations.
|
|
47
|
+
|
|
28
48
|
## Start here
|
|
29
49
|
|
|
30
50
|
You do not need GitHub access to install Impel. You need an Impel account and a
|
|
@@ -610,8 +630,8 @@ every managed profile. This catalog is separate from automatic specialists: it
|
|
|
610
630
|
may include truthfully declared write-capable agents, but those agents require
|
|
611
631
|
explicit user selection before the server will start a run.
|
|
612
632
|
|
|
613
|
-
- Claude Code: `<CLAUDE_CONFIG_DIR>/agents
|
|
614
|
-
- Codex / ChatGPT Codex mode: `<CODEX_HOME>/agents/impel-
|
|
633
|
+
- Claude Code / Claude Desktop: `<CLAUDE_CONFIG_DIR>/agents/<agent-name>.md`
|
|
634
|
+
- Codex / ChatGPT Codex mode: `<CODEX_HOME>/agents/impel-<tenant>-<agent-id>.toml`
|
|
615
635
|
|
|
616
636
|
Each generated agent is bound to one exact `agentId` and
|
|
617
637
|
`scopeParam`. It verifies that the exact agent is still available, starts one
|
|
@@ -623,8 +643,8 @@ when invoked.
|
|
|
623
643
|
|
|
624
644
|
Visible agent names come from the registry title. Codex keeps that title as the
|
|
625
645
|
custom-agent name; Claude uses the lowercase, hyphenated form required by its
|
|
626
|
-
native agent schema
|
|
627
|
-
|
|
646
|
+
native agent schema and Desktop's `@` picker requires the Markdown filename to
|
|
647
|
+
match that name. Codex filenames remain stable tenant-and-agent identifiers.
|
|
628
648
|
Duplicate or built-in-reserved titles receive a deterministic disambiguator.
|
|
629
649
|
|
|
630
650
|
The sync writes atomically and records a manifest beside the generated files.
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "impel-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"impel": "bin/impel.js"
|
|
8
8
|
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./gateway": "./src/gateway/index.js"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"bin",
|
|
11
14
|
"src",
|
package/src/agents.js
CHANGED
|
@@ -398,10 +398,16 @@ export function renderManagedAgents(client, tenantId, agents, invocation = impel
|
|
|
398
398
|
return agents.map((agent, index) => {
|
|
399
399
|
const name = names[index];
|
|
400
400
|
const extension = client === "claude" ? ".md" : ".toml";
|
|
401
|
+
// Claude Desktop decides whether an SDK-reported agent is user-defined by
|
|
402
|
+
// comparing its reported name with the Markdown filename (without .md).
|
|
403
|
+
// A stable tenant/agent filename causes Desktop to classify the agent as a
|
|
404
|
+
// builtin and remove it from the @ mention picker. Claude names are already
|
|
405
|
+
// collision-safe, filesystem-safe identifiers, so use them as filenames.
|
|
406
|
+
const fileStem = client === "claude" ? name : fileStems[index];
|
|
401
407
|
const contents = client === "claude"
|
|
402
408
|
? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation })
|
|
403
409
|
: renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation });
|
|
404
|
-
return { agentId: agent.agentId, name, fileName: `${
|
|
410
|
+
return { agentId: agent.agentId, name, fileName: `${fileStem}${extension}`, contents };
|
|
405
411
|
});
|
|
406
412
|
}
|
|
407
413
|
|
|
@@ -432,7 +438,7 @@ function readManifest(manifestPath) {
|
|
|
432
438
|
|
|
433
439
|
function profileIsFresh(profile, tenantId, now, ttlMs) {
|
|
434
440
|
const manifest = readManifest(path.join(profile.root, "agents", MANAGED_AGENT_DIRECTORY, MANAGED_AGENT_MANIFEST));
|
|
435
|
-
if (!manifest || manifest.version !==
|
|
441
|
+
if (!manifest || manifest.version !== 3 || manifest.tenantId !== tenantId) return false;
|
|
436
442
|
const syncedAt = Date.parse(manifest.syncedAt || "");
|
|
437
443
|
if (!Number.isFinite(syncedAt) || now - syncedAt >= ttlMs) return false;
|
|
438
444
|
return manifest.files.every((fileName) =>
|
|
@@ -455,7 +461,7 @@ export function syncAgentProfile({ client, root, label, tenantId, agents, now =
|
|
|
455
461
|
const prior = readManifest(manifestPath);
|
|
456
462
|
const rendered = renderManagedAgents(client, tenantId, agents);
|
|
457
463
|
const priorFiles = new Set(prior?.files || []);
|
|
458
|
-
const priorUsesDiscoveryRoot = prior?.version === 2;
|
|
464
|
+
const priorUsesDiscoveryRoot = prior?.version === 2 || prior?.version === 3;
|
|
459
465
|
|
|
460
466
|
// Native clients discover standalone definitions directly under `agents/`.
|
|
461
467
|
// Preflight every destination before writing so an unmanaged file with the
|
|
@@ -497,7 +503,7 @@ export function syncAgentProfile({ client, root, label, tenantId, agents, now =
|
|
|
497
503
|
}
|
|
498
504
|
}
|
|
499
505
|
atomicPrivateWrite(manifestPath, `${JSON.stringify({
|
|
500
|
-
version:
|
|
506
|
+
version: 3,
|
|
501
507
|
tenantId,
|
|
502
508
|
client,
|
|
503
509
|
syncedAt: new Date(now).toISOString(),
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
|
|
7
|
+
import { nativeCommandInvocation } from "../nativeProcess.js";
|
|
8
|
+
|
|
9
|
+
const SAFE_ID = /^[a-z][a-z0-9-]{2,31}$/u;
|
|
10
|
+
const SAFE_NAMESPACE = /^[a-z][a-z0-9_-]{1,31}$/u;
|
|
11
|
+
const SAFE_PREFIX = /^[a-z][a-z0-9_]{2,31}_$/u;
|
|
12
|
+
const SAFE_ENVIRONMENT_PREFIX = /^[A-Z][A-Z0-9_]{1,31}$/u;
|
|
13
|
+
const SAFE_ROUTE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/u;
|
|
14
|
+
const ANSI_ESCAPE_RE = /\u001B(?:\][^\u0007\u001B]*(?:\u0007|\u001B\\)|\[[0-?]*[ -/]*[@-~]|[@-_])/gu;
|
|
15
|
+
const CONTROL_TEST_RE = /[\u0000-\u001F\u007F-\u009F]/u;
|
|
16
|
+
const CONTROL_RE = /[\u0000-\u001F\u007F-\u009F]/gu;
|
|
17
|
+
|
|
18
|
+
function validateText(name, value, max = 128) {
|
|
19
|
+
if (typeof value !== "string" || value.trim() !== value || !value || value.length > max || CONTROL_TEST_RE.test(value)) {
|
|
20
|
+
throw new Error(`gateway CLI ${name} is invalid`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateRoute(name, value) {
|
|
26
|
+
if (typeof value !== "string" || !SAFE_ROUTE.test(value) || value.includes("//")) {
|
|
27
|
+
throw new Error(`gateway CLI ${name} is invalid`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateBrand(input) {
|
|
33
|
+
if (!input || Array.isArray(input) || typeof input !== "object" || input.schemaVersion !== 1) {
|
|
34
|
+
throw new Error("gateway CLI brand schemaVersion must be 1");
|
|
35
|
+
}
|
|
36
|
+
const productID = String(input.product?.id || "");
|
|
37
|
+
const command = String(input.cli?.command || "");
|
|
38
|
+
const configNamespace = String(input.cli?.configNamespace || "");
|
|
39
|
+
const providerID = String(input.cli?.providerId || "");
|
|
40
|
+
const managedMarker = String(input.cli?.managedMarker || "");
|
|
41
|
+
const environmentPrefix = String(input.cli?.environmentPrefix || command.toUpperCase().replace(/-/gu, "_"));
|
|
42
|
+
const patPrefix = String(input.auth?.patPrefix || "");
|
|
43
|
+
if (!SAFE_ID.test(productID)) throw new Error("gateway CLI product.id is invalid");
|
|
44
|
+
if (!SAFE_ID.test(command)) throw new Error("gateway CLI command is invalid");
|
|
45
|
+
if (!SAFE_NAMESPACE.test(configNamespace)) throw new Error("gateway CLI configNamespace is invalid");
|
|
46
|
+
if (!SAFE_NAMESPACE.test(providerID)) throw new Error("gateway CLI providerId is invalid");
|
|
47
|
+
if (!SAFE_NAMESPACE.test(managedMarker)) throw new Error("gateway CLI managedMarker is invalid");
|
|
48
|
+
if (!SAFE_ENVIRONMENT_PREFIX.test(environmentPrefix)) throw new Error("gateway CLI environmentPrefix is invalid");
|
|
49
|
+
if (!SAFE_PREFIX.test(patPrefix)) throw new Error("gateway CLI PAT prefix is invalid");
|
|
50
|
+
|
|
51
|
+
const defaultOrigin = input.gateway?.defaultOrigin == null ? null : normalizeGatewayUrl(input.gateway.defaultOrigin);
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
schemaVersion: 1,
|
|
54
|
+
product: Object.freeze({ id: productID, displayName: validateText("product.displayName", input.product?.displayName) }),
|
|
55
|
+
cli: Object.freeze({ command, configNamespace, providerId: providerID, managedMarker, environmentPrefix }),
|
|
56
|
+
auth: Object.freeze({ patPrefix }),
|
|
57
|
+
gateway: Object.freeze({
|
|
58
|
+
defaultOrigin,
|
|
59
|
+
healthPath: validateRoute("healthPath", input.gateway?.healthPath),
|
|
60
|
+
modelsPath: validateRoute("modelsPath", input.gateway?.modelsPath),
|
|
61
|
+
anthropicBasePath: validateRoute("anthropicBasePath", input.gateway?.anthropicBasePath),
|
|
62
|
+
codexCliBasePath: validateRoute("codexCliBasePath", input.gateway?.codexCliBasePath),
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseFlags(argv, spec = {}) {
|
|
68
|
+
const flags = {};
|
|
69
|
+
const positionals = [];
|
|
70
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
71
|
+
const argument = argv[index];
|
|
72
|
+
if (argument === "--") {
|
|
73
|
+
positionals.push(...argv.slice(index + 1));
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
if (!argument.startsWith("--")) {
|
|
77
|
+
positionals.push(argument);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const equalsIndex = argument.indexOf("=");
|
|
81
|
+
const name = argument.slice(2, equalsIndex === -1 ? undefined : equalsIndex);
|
|
82
|
+
if (!Object.hasOwn(spec, name)) throw new Error(`unknown flag --${name}`);
|
|
83
|
+
let value;
|
|
84
|
+
if (equalsIndex !== -1) {
|
|
85
|
+
value = argument.slice(equalsIndex + 1);
|
|
86
|
+
} else if (spec[name].type === "boolean") {
|
|
87
|
+
value = true;
|
|
88
|
+
} else {
|
|
89
|
+
value = argv[index + 1];
|
|
90
|
+
index += 1;
|
|
91
|
+
}
|
|
92
|
+
if (spec[name].type === "string" && (value === undefined || value === "")) {
|
|
93
|
+
throw new Error(`--${name} requires a value`);
|
|
94
|
+
}
|
|
95
|
+
flags[name] = value;
|
|
96
|
+
}
|
|
97
|
+
return { flags, positionals };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeGatewayUrl(value) {
|
|
101
|
+
const normalized = String(value || "").trim().replace(/\/+$/u, "");
|
|
102
|
+
if (!normalized) throw new Error("a gateway URL is required");
|
|
103
|
+
const parsed = new URL(normalized);
|
|
104
|
+
const localHTTP = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
|
|
105
|
+
if (parsed.protocol !== "https:" && !localHTTP) {
|
|
106
|
+
throw new Error("gateway URL must use HTTPS (HTTP is allowed only for local testing)");
|
|
107
|
+
}
|
|
108
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
109
|
+
throw new Error("gateway URL must not contain credentials, query, or fragment");
|
|
110
|
+
}
|
|
111
|
+
return parsed.toString().replace(/\/+$/u, "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function joinRoute(origin, route) {
|
|
115
|
+
return `${origin}${route.startsWith("/") ? route : `/${route}`}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create a deliberately small, branded gateway launcher. This is the only
|
|
120
|
+
* public library surface intended for white-labelled vendor CLIs.
|
|
121
|
+
*/
|
|
122
|
+
export function createGatewayCli(options) {
|
|
123
|
+
const brand = validateBrand(options?.brand);
|
|
124
|
+
const entrypoint = String(options?.entrypoint || "");
|
|
125
|
+
const version = String(options?.version || "");
|
|
126
|
+
if (!entrypoint || !path.isAbsolute(entrypoint)) throw new Error("gateway CLI entrypoint must be an absolute path");
|
|
127
|
+
if (!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/u.test(version)) throw new Error("gateway CLI version is invalid");
|
|
128
|
+
|
|
129
|
+
const environment = options.environment || process.env;
|
|
130
|
+
const input = options.input || process.stdin;
|
|
131
|
+
const output = options.output || process.stdout;
|
|
132
|
+
const environmentName = (suffix) => `${brand.cli.environmentPrefix}_${suffix}`;
|
|
133
|
+
const configDirectory = environment[environmentName("CONFIG_DIR")] || path.join(os.homedir(), ".config", brand.cli.configNamespace);
|
|
134
|
+
const configPath = path.join(configDirectory, "config.json");
|
|
135
|
+
const profileRoot = path.join(configDirectory, "cli");
|
|
136
|
+
const gatewayRoutes = Object.freeze({
|
|
137
|
+
health(origin) { return joinRoute(origin, brand.gateway.healthPath); },
|
|
138
|
+
models(origin) { return joinRoute(origin, brand.gateway.modelsPath); },
|
|
139
|
+
anthropic(origin) { return joinRoute(origin, brand.gateway.anthropicBasePath); },
|
|
140
|
+
codex(origin) { return joinRoute(origin, brand.gateway.codexCliBasePath); },
|
|
141
|
+
});
|
|
142
|
+
const print = (line = "") => output.write(`${line}\n`);
|
|
143
|
+
|
|
144
|
+
function ensurePrivateDirectory(directory) {
|
|
145
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
146
|
+
const stat = fs.lstatSync(directory);
|
|
147
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${directory} must be a real private directory`);
|
|
148
|
+
try { fs.chmodSync(directory, 0o700); } catch {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function writePrivateFile(filePath, contents) {
|
|
152
|
+
ensurePrivateDirectory(path.dirname(filePath));
|
|
153
|
+
const temporary = `${filePath}.tmp-${process.pid}`;
|
|
154
|
+
try {
|
|
155
|
+
fs.writeFileSync(temporary, contents, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
156
|
+
fs.renameSync(temporary, filePath);
|
|
157
|
+
try { fs.chmodSync(filePath, 0o600); } catch {}
|
|
158
|
+
} finally {
|
|
159
|
+
try { fs.rmSync(temporary, { force: true }); } catch {}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function loadConfig() {
|
|
164
|
+
let raw;
|
|
165
|
+
try { raw = fs.readFileSync(configPath, "utf8"); }
|
|
166
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
167
|
+
let value;
|
|
168
|
+
try { value = JSON.parse(raw); }
|
|
169
|
+
catch { throw new Error(`${configPath} is not valid JSON; fix or remove it`); }
|
|
170
|
+
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error(`${configPath} must contain a JSON object`);
|
|
171
|
+
if (value.gatewayUrl) value.gatewayUrl = normalizeGatewayUrl(value.gatewayUrl);
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function saveConfig(config) {
|
|
176
|
+
writePrivateFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function maskSecret(value) {
|
|
180
|
+
const secret = String(value || "");
|
|
181
|
+
if (!secret) return "(none)";
|
|
182
|
+
if (secret.length <= 12) return "*".repeat(secret.length);
|
|
183
|
+
return `${secret.slice(0, brand.auth.patPrefix.length + 4)}...${secret.slice(-4)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function redactSecretText(value) {
|
|
187
|
+
const escapedPrefix = brand.auth.patPrefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
188
|
+
const tokenPattern = new RegExp(`${escapedPrefix}[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)?`, "gu");
|
|
189
|
+
return String(value ?? "")
|
|
190
|
+
.replace(tokenPattern, "[REDACTED GATEWAY CREDENTIAL]")
|
|
191
|
+
.replace(ANSI_ESCAPE_RE, "")
|
|
192
|
+
.replace(CONTROL_RE, " ");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveGateway(existing) {
|
|
196
|
+
const value = environment[environmentName("GATEWAY_URL")] || existing?.gatewayUrl || brand.gateway.defaultOrigin;
|
|
197
|
+
if (!value) {
|
|
198
|
+
throw new Error(`a gateway URL is required (pass --gateway or set ${environmentName("GATEWAY_URL")})`);
|
|
199
|
+
}
|
|
200
|
+
return normalizeGatewayUrl(value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function promptSecret(question) {
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
const rl = readline.createInterface({ input, output, terminal: input.isTTY });
|
|
206
|
+
if (input.isTTY) {
|
|
207
|
+
const originalWrite = rl._writeToOutput?.bind(rl);
|
|
208
|
+
if (originalWrite) {
|
|
209
|
+
rl._writeToOutput = (text) => originalWrite(text === question ? text : "*".repeat(text.length));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
rl.question(question, (answer) => {
|
|
213
|
+
rl.close();
|
|
214
|
+
output.write("\n");
|
|
215
|
+
resolve(answer.trim());
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function resolveAuthConfig(argv) {
|
|
221
|
+
const { flags } = parseFlags(argv, { gateway: { type: "string" } });
|
|
222
|
+
const existing = loadConfig();
|
|
223
|
+
const suppliedPAT = environment[environmentName("PAT")];
|
|
224
|
+
const pat = String(suppliedPAT || await promptSecret(`${brand.product.displayName} Personal Access Token: `)).trim();
|
|
225
|
+
if (!pat.startsWith(brand.auth.patPrefix) || /[\u0000-\u0020\u007f]/u.test(pat)) {
|
|
226
|
+
throw new Error(`PAT must start with ${brand.auth.patPrefix} and contain no whitespace`);
|
|
227
|
+
}
|
|
228
|
+
const gatewayUrl = flags.gateway ? normalizeGatewayUrl(flags.gateway) : resolveGateway(existing);
|
|
229
|
+
return { schemaVersion: 1, pat, gatewayUrl, updatedAt: new Date().toISOString() };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function persistAuthConfig(config, { quiet = false } = {}) {
|
|
233
|
+
saveConfig(config);
|
|
234
|
+
if (!quiet) {
|
|
235
|
+
print(`Stored ${brand.product.displayName} credentials in the private ${brand.cli.command} config.`);
|
|
236
|
+
print(` gateway: ${config.gatewayUrl}`);
|
|
237
|
+
print(` PAT: ${maskSecret(config.pat)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function checkGateway(gatewayUrl, pat) {
|
|
242
|
+
const requestOptions = { signal: AbortSignal.timeout(5000) };
|
|
243
|
+
const health = await fetch(gatewayRoutes.health(gatewayUrl), requestOptions);
|
|
244
|
+
if (!health.ok) throw new Error(`gateway health returned HTTP ${health.status}`);
|
|
245
|
+
const models = await fetch(gatewayRoutes.models(gatewayUrl), {
|
|
246
|
+
...requestOptions,
|
|
247
|
+
headers: { authorization: `Bearer ${pat}` },
|
|
248
|
+
});
|
|
249
|
+
if (!models.ok) throw new Error(`gateway authentication returned HTTP ${models.status}`);
|
|
250
|
+
const body = await models.json();
|
|
251
|
+
return { modelCount: Array.isArray(body?.data) ? body.data.length : null };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readJsonObject(filePath) {
|
|
255
|
+
if (!fs.existsSync(filePath)) return {};
|
|
256
|
+
try {
|
|
257
|
+
const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
258
|
+
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error();
|
|
259
|
+
return value;
|
|
260
|
+
} catch { throw new Error(`${filePath} must contain a JSON object`); }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function cliInvocation(args = []) {
|
|
264
|
+
return { command: process.execPath, args: [entrypoint, ...args] };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function ensureClaudeProfile(gatewayUrl) {
|
|
268
|
+
const configDir = path.join(profileRoot, "claude");
|
|
269
|
+
const settingsPath = path.join(configDir, "settings.json");
|
|
270
|
+
const settings = readJsonObject(settingsPath);
|
|
271
|
+
settings.env = {
|
|
272
|
+
...(settings.env && typeof settings.env === "object" && !Array.isArray(settings.env) ? settings.env : {}),
|
|
273
|
+
ANTHROPIC_BASE_URL: gatewayRoutes.anthropic(gatewayUrl),
|
|
274
|
+
};
|
|
275
|
+
delete settings.env.ANTHROPIC_API_KEY;
|
|
276
|
+
delete settings.env.ANTHROPIC_AUTH_TOKEN;
|
|
277
|
+
writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
278
|
+
return { configDir, settingsPath };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const startMark = `# >>> ${brand.cli.managedMarker} profile >>>`;
|
|
282
|
+
const endMark = `# <<< ${brand.cli.managedMarker} profile <<<`;
|
|
283
|
+
|
|
284
|
+
function stripManagedBlock(text, filePath) {
|
|
285
|
+
const start = text.indexOf(startMark);
|
|
286
|
+
if (start === -1) return text;
|
|
287
|
+
const end = text.indexOf(endMark, start);
|
|
288
|
+
if (end === -1) throw new Error(`${filePath} has an incomplete managed block`);
|
|
289
|
+
return text.slice(0, start) + text.slice(end + endMark.length);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function codexManagedBlock(gatewayUrl) {
|
|
293
|
+
const auth = cliInvocation(["token"]);
|
|
294
|
+
return [
|
|
295
|
+
startMark,
|
|
296
|
+
`# Generated by the ${brand.product.displayName} CLI. Settings outside this block are preserved.`,
|
|
297
|
+
`[model_providers.${brand.cli.providerId}]`,
|
|
298
|
+
`name = ${JSON.stringify(`${brand.product.displayName} Gateway`)}`,
|
|
299
|
+
`base_url = ${JSON.stringify(gatewayRoutes.codex(gatewayUrl))}`,
|
|
300
|
+
'wire_api = "responses"',
|
|
301
|
+
"",
|
|
302
|
+
`[model_providers.${brand.cli.providerId}.auth]`,
|
|
303
|
+
`command = ${JSON.stringify(auth.command)}`,
|
|
304
|
+
`args = [${auth.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
|
|
305
|
+
"timeout_ms = 5000",
|
|
306
|
+
"refresh_interval_ms = 300000",
|
|
307
|
+
"",
|
|
308
|
+
"[features]",
|
|
309
|
+
"shell_snapshot = false",
|
|
310
|
+
endMark,
|
|
311
|
+
].join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function ensureCodexProfile(gatewayUrl) {
|
|
315
|
+
const codexHome = path.join(profileRoot, "codex");
|
|
316
|
+
const filePath = path.join(codexHome, "config.toml");
|
|
317
|
+
const original = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
318
|
+
const outside = stripManagedBlock(original, filePath).trim();
|
|
319
|
+
const provider = brand.cli.providerId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
320
|
+
if (new RegExp(`^\\s*\\[model_providers\\.${provider}(?:\\.|\\])`, "mu").test(outside)) {
|
|
321
|
+
throw new Error(`${filePath} contains a ${brand.cli.providerId} provider outside the managed block`);
|
|
322
|
+
}
|
|
323
|
+
if (/^\s*\[features\]\s*$/mu.test(outside) || /^\s*features\.shell_snapshot\s*=/mu.test(outside)) {
|
|
324
|
+
throw new Error(`${filePath} defines features outside the managed block; move them into another isolated profile`);
|
|
325
|
+
}
|
|
326
|
+
const withoutProvider = outside.replace(/^\s*model_provider\s*=.*$/mu, "").trim();
|
|
327
|
+
const next = [`model_provider = ${JSON.stringify(brand.cli.providerId)}`, codexManagedBlock(gatewayUrl), withoutProvider]
|
|
328
|
+
.filter(Boolean).join("\n\n").concat("\n");
|
|
329
|
+
writePrivateFile(filePath, next);
|
|
330
|
+
return { codexHome, filePath };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function cmdAuth(argv, { quiet = false } = {}) {
|
|
334
|
+
const config = await resolveAuthConfig(argv);
|
|
335
|
+
persistAuthConfig(config, { quiet });
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function cmdSetup(argv) {
|
|
340
|
+
const config = await resolveAuthConfig(argv);
|
|
341
|
+
const result = await checkGateway(config.gatewayUrl, config.pat);
|
|
342
|
+
ensureClaudeProfile(config.gatewayUrl);
|
|
343
|
+
ensureCodexProfile(config.gatewayUrl);
|
|
344
|
+
persistAuthConfig(config, { quiet: true });
|
|
345
|
+
print(`${brand.product.displayName} gateway setup is ready.`);
|
|
346
|
+
print(` gateway: ${config.gatewayUrl}`);
|
|
347
|
+
if (result.modelCount !== null) print(` models: ${result.modelCount}`);
|
|
348
|
+
print(` launch: ${brand.cli.command} claude | ${brand.cli.command} codex`);
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function cmdStatus() {
|
|
353
|
+
const config = loadConfig();
|
|
354
|
+
if (!config?.pat || !config?.gatewayUrl) throw new Error(`not configured; run \`${brand.cli.command} setup\``);
|
|
355
|
+
print(`Gateway: ${config.gatewayUrl}`);
|
|
356
|
+
print(`PAT: ${maskSecret(config.pat)}`);
|
|
357
|
+
print(`Profiles: ${profileRoot}`);
|
|
358
|
+
try {
|
|
359
|
+
const result = await checkGateway(config.gatewayUrl, config.pat);
|
|
360
|
+
print(`Status: ready${result.modelCount === null ? "" : ` (${result.modelCount} models)`}`);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
throw new Error(redactSecretText(error?.message || error));
|
|
363
|
+
}
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function cmdToken() {
|
|
368
|
+
const config = loadConfig();
|
|
369
|
+
if (!config?.pat) throw new Error(`not authenticated; run \`${brand.cli.command} setup\` or \`${brand.cli.command} auth\``);
|
|
370
|
+
output.write(`${config.pat}\n`);
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function cleanLaunchEnvironment() {
|
|
375
|
+
const childEnvironment = { ...environment };
|
|
376
|
+
for (const name of [
|
|
377
|
+
environmentName("PAT"),
|
|
378
|
+
"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS",
|
|
379
|
+
"OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_API_KEY",
|
|
380
|
+
]) delete childEnvironment[name];
|
|
381
|
+
return childEnvironment;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function runNative(tool, argv, childEnvironment) {
|
|
385
|
+
return new Promise((resolve, reject) => {
|
|
386
|
+
const invocation = nativeCommandInvocation(
|
|
387
|
+
tool,
|
|
388
|
+
argv,
|
|
389
|
+
childEnvironment,
|
|
390
|
+
process.platform,
|
|
391
|
+
brand.cli.environmentPrefix,
|
|
392
|
+
);
|
|
393
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
394
|
+
env: childEnvironment,
|
|
395
|
+
stdio: "inherit",
|
|
396
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
397
|
+
});
|
|
398
|
+
child.once("error", reject);
|
|
399
|
+
child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function cmdLaunch(tool, argv) {
|
|
404
|
+
const config = loadConfig();
|
|
405
|
+
if (!config?.pat || !config?.gatewayUrl) throw new Error(`not configured; run \`${brand.cli.command} setup\``);
|
|
406
|
+
const childEnvironment = cleanLaunchEnvironment();
|
|
407
|
+
if (tool === "claude") {
|
|
408
|
+
const profile = ensureClaudeProfile(config.gatewayUrl);
|
|
409
|
+
childEnvironment.CLAUDE_CONFIG_DIR = profile.configDir;
|
|
410
|
+
childEnvironment.ANTHROPIC_BASE_URL = gatewayRoutes.anthropic(config.gatewayUrl);
|
|
411
|
+
childEnvironment.ANTHROPIC_AUTH_TOKEN = config.pat;
|
|
412
|
+
} else if (tool === "codex") {
|
|
413
|
+
const profile = ensureCodexProfile(config.gatewayUrl);
|
|
414
|
+
childEnvironment.CODEX_HOME = profile.codexHome;
|
|
415
|
+
} else {
|
|
416
|
+
throw new Error(`unsupported launcher ${tool}`);
|
|
417
|
+
}
|
|
418
|
+
return runNative(tool, argv, childEnvironment);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const help = `${brand.cli.command} — ${brand.product.displayName} gateway CLI
|
|
422
|
+
|
|
423
|
+
${brand.cli.command} setup --gateway <url> Configure, verify, and create isolated profiles
|
|
424
|
+
${brand.cli.command} auth --gateway <url> Store the PAT and gateway URL privately
|
|
425
|
+
${brand.cli.command} claude [args...] Launch isolated Claude Code
|
|
426
|
+
${brand.cli.command} codex [args...] Launch isolated Codex
|
|
427
|
+
${brand.cli.command} status Verify gateway authentication and model catalog
|
|
428
|
+
${brand.cli.command} token Auth-helper output; prints only the PAT
|
|
429
|
+
${brand.cli.command} help
|
|
430
|
+
${brand.cli.command} --version
|
|
431
|
+
|
|
432
|
+
Environment:
|
|
433
|
+
${environmentName("PAT")} PAT for non-interactive setup/auth; keep it out of command arguments
|
|
434
|
+
${environmentName("GATEWAY_URL")} Default gateway URL
|
|
435
|
+
${environmentName("CLAUDE_BIN")} Optional Claude executable override
|
|
436
|
+
${environmentName("CODEX_BIN")} Optional Codex executable override
|
|
437
|
+
`;
|
|
438
|
+
|
|
439
|
+
async function main(argv) {
|
|
440
|
+
const [command, ...rest] = argv;
|
|
441
|
+
switch (command) {
|
|
442
|
+
case undefined: case "help": case "--help": case "-h": print(help); return 0;
|
|
443
|
+
case "--version": case "-v": print(version); return 0;
|
|
444
|
+
case "setup": return cmdSetup(rest);
|
|
445
|
+
case "auth": return cmdAuth(rest);
|
|
446
|
+
case "token": return cmdToken();
|
|
447
|
+
case "status": return cmdStatus();
|
|
448
|
+
case "claude": case "codex": return cmdLaunch(command, rest);
|
|
449
|
+
default: throw new Error(`unknown command ${JSON.stringify(command)}; run \`${brand.cli.command} help\``);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return Object.freeze({ main });
|
|
454
|
+
}
|
package/src/nativeProcess.js
CHANGED
|
@@ -51,10 +51,10 @@ function binaryCandidates(directory, tool, environment, platform) {
|
|
|
51
51
|
return windowsPathExtensions(environment).map((extension) => `${base}${extension}`);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function overrideName(tool) {
|
|
55
|
-
if (tool === "claude") return
|
|
56
|
-
if (tool === "codex") return
|
|
57
|
-
if (tool === "npm") return
|
|
54
|
+
function overrideName(tool, environmentPrefix = "IMPEL") {
|
|
55
|
+
if (tool === "claude") return `${environmentPrefix}_CLAUDE_BIN`;
|
|
56
|
+
if (tool === "codex") return `${environmentPrefix}_CODEX_BIN`;
|
|
57
|
+
if (tool === "npm") return `${environmentPrefix}_NPM_BIN`;
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -116,8 +116,13 @@ function commonCandidates(tool, environment, platform) {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/** Resolve a real executable/shim if one is installed, otherwise return null. */
|
|
119
|
-
export function findNativeBinary(
|
|
120
|
-
|
|
119
|
+
export function findNativeBinary(
|
|
120
|
+
tool,
|
|
121
|
+
environment = process.env,
|
|
122
|
+
platform = process.platform,
|
|
123
|
+
environmentPrefix = "IMPEL",
|
|
124
|
+
) {
|
|
125
|
+
const override = overrideName(tool, environmentPrefix);
|
|
121
126
|
const overriddenBinary = override ? environmentValue(environment, override) : null;
|
|
122
127
|
if (overriddenBinary) return isExecutable(overriddenBinary, platform) ? overriddenBinary : null;
|
|
123
128
|
|
|
@@ -136,10 +141,15 @@ export function findNativeBinary(tool, environment = process.env, platform = pro
|
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
/** Resolve an executable for launch, preserving explicit overrides and useful ENOENT errors. */
|
|
139
|
-
export function resolveNativeBinary(
|
|
140
|
-
|
|
144
|
+
export function resolveNativeBinary(
|
|
145
|
+
tool,
|
|
146
|
+
environment = process.env,
|
|
147
|
+
platform = process.platform,
|
|
148
|
+
environmentPrefix = "IMPEL",
|
|
149
|
+
) {
|
|
150
|
+
const override = overrideName(tool, environmentPrefix);
|
|
141
151
|
const overriddenBinary = override ? environmentValue(environment, override) : null;
|
|
142
|
-
return overriddenBinary || findNativeBinary(tool, environment, platform) || tool;
|
|
152
|
+
return overriddenBinary || findNativeBinary(tool, environment, platform, environmentPrefix) || tool;
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
// Windows cannot execute npm's .cmd/.bat shims directly. This is the escaping
|
|
@@ -186,7 +196,13 @@ export function nativeSpawnInvocation(binary, argv, environment = process.env, p
|
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
/** Resolve a PATH command and produce a spawn-safe invocation for this platform. */
|
|
189
|
-
export function nativeCommandInvocation(
|
|
190
|
-
|
|
199
|
+
export function nativeCommandInvocation(
|
|
200
|
+
tool,
|
|
201
|
+
argv,
|
|
202
|
+
environment = process.env,
|
|
203
|
+
platform = process.platform,
|
|
204
|
+
environmentPrefix = "IMPEL",
|
|
205
|
+
) {
|
|
206
|
+
const binary = resolveNativeBinary(tool, environment, platform, environmentPrefix);
|
|
191
207
|
return { binary, ...nativeSpawnInvocation(binary, argv, environment, platform) };
|
|
192
208
|
}
|