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.
package/src/args.js ADDED
@@ -0,0 +1,36 @@
1
+ // Tiny dependency-free flag parser. Supports `--flag value` and `--flag=value`.
2
+ // `spec` maps flag name -> { type: "string" | "boolean" }.
3
+
4
+ export function parseFlags(argv, spec = {}) {
5
+ const flags = {};
6
+ const positionals = [];
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const arg = argv[i];
9
+ if (arg === "--") {
10
+ positionals.push(...argv.slice(i + 1));
11
+ break;
12
+ }
13
+ if (!arg.startsWith("--")) {
14
+ positionals.push(arg);
15
+ continue;
16
+ }
17
+ const eqIdx = arg.indexOf("=");
18
+ let name;
19
+ let value;
20
+ if (eqIdx !== -1) {
21
+ name = arg.slice(2, eqIdx);
22
+ value = arg.slice(eqIdx + 1);
23
+ } else {
24
+ name = arg.slice(2);
25
+ const isBoolean = spec[name]?.type === "boolean";
26
+ if (isBoolean) {
27
+ value = true;
28
+ } else {
29
+ value = argv[i + 1];
30
+ i += 1;
31
+ }
32
+ }
33
+ flags[name] = value;
34
+ }
35
+ return { flags, positionals };
36
+ }
@@ -0,0 +1,207 @@
1
+ // Applies / reverts / detects the Impel-gateway config in ~/.claude/settings.json.
2
+ //
3
+ // Gateway mode adds exactly two keys — `apiKeyHelper: "impel token"` and
4
+ // `env.ANTHROPIC_BASE_URL: "<gateway>/anthropic"` — and nothing else. Reverting
5
+ // removes ONLY those two (and only when they still point at Impel), restoring
6
+ // any prior values that were backed up when gateway mode was turned on.
7
+
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ import { IMPEL_MANAGED_MCP_ENV, impelMcpInvocation } from "./selfInvocation.js";
13
+
14
+ export const CLAUDE_DIR = path.join(os.homedir(), ".claude");
15
+ export const CLAUDE_SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
16
+ export const CLAUDE_USER_CONFIG_PATH = path.join(os.homedir(), ".claude.json");
17
+
18
+ export const IMPEL_API_KEY_HELPER = "impel token";
19
+ export const IMPEL_MCP_SERVER_NAME = "impel";
20
+ const LEGACY_IMPEL_MCP_SERVER = { type: "stdio", command: "impel", args: ["mcp"] };
21
+
22
+ function impelMcpServer() {
23
+ return impelMcpInvocation();
24
+ }
25
+
26
+ /** The ANTHROPIC_BASE_URL value Impel sets for a given gateway. */
27
+ export function impelClaudeBaseUrl(gatewayUrl) {
28
+ return `${gatewayUrl}/anthropic`;
29
+ }
30
+
31
+ /** Experimental Anthropic-compatible route that can dispatch both providers. */
32
+ export function impelCrossAppClaudeBaseUrl(gatewayUrl) {
33
+ return `${gatewayUrl}/experimental/anthropic`;
34
+ }
35
+
36
+ export function isImpelApiKeyHelper(value) {
37
+ return value === IMPEL_API_KEY_HELPER;
38
+ }
39
+
40
+ export function isImpelClaudeBaseUrl(value, gatewayUrl) {
41
+ return value != null && value === impelClaudeBaseUrl(gatewayUrl);
42
+ }
43
+
44
+ function readSettings() {
45
+ if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) return { settings: {}, exists: false };
46
+ const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8").trim();
47
+ if (!raw) return { settings: {}, exists: true };
48
+ try {
49
+ return { settings: JSON.parse(raw), exists: true };
50
+ } catch {
51
+ throw new Error(
52
+ `${CLAUDE_SETTINGS_PATH} exists but isn't valid JSON. Fix or back it up, then re-run.`
53
+ );
54
+ }
55
+ }
56
+
57
+ function writeSettings(settings) {
58
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
59
+ fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
60
+ }
61
+
62
+ function readUserConfig() {
63
+ if (!fs.existsSync(CLAUDE_USER_CONFIG_PATH)) return { config: {}, exists: false };
64
+ const raw = fs.readFileSync(CLAUDE_USER_CONFIG_PATH, "utf8").trim();
65
+ if (!raw) return { config: {}, exists: true };
66
+ try {
67
+ return { config: JSON.parse(raw), exists: true };
68
+ } catch {
69
+ throw new Error(
70
+ `${CLAUDE_USER_CONFIG_PATH} exists but isn't valid JSON. Fix or back it up, then re-run.`
71
+ );
72
+ }
73
+ }
74
+
75
+ function writeUserConfig(config) {
76
+ fs.writeFileSync(CLAUDE_USER_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", {
77
+ mode: 0o600,
78
+ });
79
+ }
80
+
81
+ function isImpelMcpServer(value) {
82
+ if (!value || !Array.isArray(value.args)) return false;
83
+ const current = impelMcpServer();
84
+ return (
85
+ (value.command === current.command &&
86
+ value.args.length === current.args.length &&
87
+ value.args.every((argument, index) => argument === current.args[index])) ||
88
+ (value.type === "stdio" &&
89
+ value.env?.[IMPEL_MANAGED_MCP_ENV] === "1" &&
90
+ value.args.at(-1) === "mcp") ||
91
+ (value.command === LEGACY_IMPEL_MCP_SERVER.command &&
92
+ value.args.length === 1 &&
93
+ value.args[0] === "mcp")
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Detects whether Claude Code is currently routed through Impel.
99
+ * Gateway mode = our apiKeyHelper OR our base URL is present.
100
+ */
101
+ export function detectClaudeMode(gatewayUrl) {
102
+ const { settings, exists } = readSettings();
103
+ const apiKeyHelper = settings.apiKeyHelper;
104
+ const baseUrl = settings.env?.ANTHROPIC_BASE_URL;
105
+ const isGateway =
106
+ isImpelApiKeyHelper(apiKeyHelper) || isImpelClaudeBaseUrl(baseUrl, gatewayUrl);
107
+ return { mode: isGateway ? "gateway" : "account", exists, apiKeyHelper, baseUrl };
108
+ }
109
+
110
+ /**
111
+ * Turns ON gateway mode. Returns the prior values so the caller can back them
112
+ * up. Merges — every other key in settings.json is left untouched.
113
+ */
114
+ export function applyClaudeGateway(gatewayUrl) {
115
+ const { settings } = readSettings();
116
+ const { config: userConfig } = readUserConfig();
117
+ const priorMcpServer = userConfig.mcpServers?.[IMPEL_MCP_SERVER_NAME];
118
+ if (priorMcpServer && !isImpelMcpServer(priorMcpServer)) {
119
+ throw new Error(
120
+ `${CLAUDE_USER_CONFIG_PATH} already has an mcpServers.${IMPEL_MCP_SERVER_NAME} entry that wasn't written by impel-cli. Remove or rename it, then re-run.`
121
+ );
122
+ }
123
+
124
+ const priorApiKeyHelper = settings.apiKeyHelper;
125
+ const priorBaseUrl = settings.env?.ANTHROPIC_BASE_URL;
126
+
127
+ settings.apiKeyHelper = IMPEL_API_KEY_HELPER;
128
+ settings.env = {
129
+ ...(settings.env || {}),
130
+ ANTHROPIC_BASE_URL: impelClaudeBaseUrl(gatewayUrl),
131
+ };
132
+
133
+ writeSettings(settings);
134
+ userConfig.mcpServers = {
135
+ ...(userConfig.mcpServers || {}),
136
+ [IMPEL_MCP_SERVER_NAME]: impelMcpServer(),
137
+ };
138
+ writeUserConfig(userConfig);
139
+
140
+ return {
141
+ path: CLAUDE_SETTINGS_PATH,
142
+ apiKeyHelper: settings.apiKeyHelper,
143
+ baseUrl: settings.env.ANTHROPIC_BASE_URL,
144
+ priorApiKeyHelper,
145
+ priorBaseUrl,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Turns OFF gateway mode: removes ONLY Impel's apiKeyHelper / base URL (never a
151
+ * user's own), then restores any backed-up prior values. `backup` may contain
152
+ * `apiKeyHelper` and/or `ANTHROPIC_BASE_URL` (null = "there was nothing here").
153
+ */
154
+ export function revertClaudeGateway(gatewayUrl, backup = {}) {
155
+ const { settings, exists } = readSettings();
156
+ const { config: userConfig, exists: userConfigExists } = readUserConfig();
157
+
158
+ let changed = false;
159
+ let removedHelper = false;
160
+ let removedBaseUrl = false;
161
+ let removedMcpServer = false;
162
+
163
+ if (isImpelApiKeyHelper(settings.apiKeyHelper)) {
164
+ removedHelper = true;
165
+ if (backup.apiKeyHelper != null) settings.apiKeyHelper = backup.apiKeyHelper;
166
+ else delete settings.apiKeyHelper;
167
+ changed = true;
168
+ }
169
+
170
+ const currentBaseUrl = settings.env?.ANTHROPIC_BASE_URL;
171
+ if (isImpelClaudeBaseUrl(currentBaseUrl, gatewayUrl)) {
172
+ removedBaseUrl = true;
173
+ if (backup.ANTHROPIC_BASE_URL != null) {
174
+ settings.env.ANTHROPIC_BASE_URL = backup.ANTHROPIC_BASE_URL;
175
+ } else {
176
+ delete settings.env.ANTHROPIC_BASE_URL;
177
+ if (settings.env && Object.keys(settings.env).length === 0) delete settings.env;
178
+ }
179
+ changed = true;
180
+ }
181
+
182
+ if (changed) writeSettings(settings);
183
+
184
+ if (isImpelMcpServer(userConfig.mcpServers?.[IMPEL_MCP_SERVER_NAME])) {
185
+ removedMcpServer = true;
186
+ if (backup.mcpServer != null) {
187
+ userConfig.mcpServers[IMPEL_MCP_SERVER_NAME] = backup.mcpServer;
188
+ } else {
189
+ delete userConfig.mcpServers[IMPEL_MCP_SERVER_NAME];
190
+ if (Object.keys(userConfig.mcpServers).length === 0) {
191
+ delete userConfig.mcpServers;
192
+ }
193
+ }
194
+ writeUserConfig(userConfig);
195
+ }
196
+
197
+ return {
198
+ path: CLAUDE_SETTINGS_PATH,
199
+ exists: exists || userConfigExists,
200
+ changed: changed || removedMcpServer,
201
+ removedHelper,
202
+ removedBaseUrl,
203
+ removedMcpServer,
204
+ restoredApiKeyHelper: removedHelper && backup.apiKeyHelper != null ? backup.apiKeyHelper : null,
205
+ restoredBaseUrl: removedBaseUrl && backup.ANTHROPIC_BASE_URL != null ? backup.ANTHROPIC_BASE_URL : null,
206
+ };
207
+ }
package/src/cli.js ADDED
@@ -0,0 +1,184 @@
1
+ import fs from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { cmdAuth } from "./commands/auth.js";
5
+ import { cmdToken } from "./commands/token.js";
6
+ import { cmdUse } from "./commands/use.js";
7
+ import { cmdStatus } from "./commands/status.js";
8
+ import { cmdTasks } from "./commands/tasks.js";
9
+ import { cmdApps } from "./commands/apps.js";
10
+ import { cmdMcp } from "./commands/mcp.js";
11
+ import { cmdLaunch } from "./commands/launch.js";
12
+ import { cmdSkills } from "./commands/skills.js";
13
+ import { cmdTenant } from "./commands/tenant.js";
14
+ import { cmdDoctor } from "./commands/doctor.js";
15
+ import { cmdSetup } from "./commands/setup.js";
16
+ import { cmdUpdate } from "./commands/update.js";
17
+ import { cmdExperimental } from "./commands/experimental.js";
18
+
19
+ const HELP = `impel — the Impel gateway CLI: isolated Claude Code, Codex, and desktop apps
20
+
21
+ Get started:
22
+ impel setup Guided end-to-end onboarding: token, tenant,
23
+ platform clients, gateway verification
24
+ (--pat <pat> --tenant <org> --skip-apps/--skip-clis)
25
+ impel update Update everything: the CLI itself, then the
26
+ Impel apps and skills (--check, --skip-apps)
27
+
28
+ Run:
29
+ impel claude [args...] Launch Claude Code with an isolated Impel profile
30
+ impel codex [args...] Launch Codex with an isolated Impel profile
31
+ impel app open [claude|chatgpt|codex|all] Open the isolated Impel desktop app(s)
32
+ impel status Tenant, PAT, per-tool mode, gateway reachability
33
+ impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
34
+
35
+ Manage:
36
+ impel auth [--pat <pat>] Store your PAT + URLs (--gateway/--app <url>)
37
+ impel tenant list|current|use <org> List or select the active organization
38
+ impel tasks list|get|create|update|delete CRUD Impel tickets (aliases: task, ticket[s])
39
+ impel token [--tenant <org>] Print the selected-tenant bearer
40
+ impel mcp Run the local Impel MCP stdio bridge
41
+ impel skills sync [claude|codex|all] Sync shared skills into managed clients
42
+
43
+ Desktop apps (macOS and Windows):
44
+ impel app install [claude|chatgpt|codex|all] Install the isolated Impel desktop app/profile
45
+ impel app update [claude|chatgpt|codex|all] Update vendor apps and managed profiles
46
+ impel app refresh [claude|chatgpt|codex|all] Configs/catalog/skills only; safe while apps run
47
+ impel app status [claude|chatgpt|codex|all] Show isolated app and vendor versions
48
+ impel app uninstall [target] [--keep-data] Remove only Impel-managed apps/data
49
+
50
+ Native profile switching (advanced; setup never touches native tools):
51
+ impel use gateway [claude|codex|all] Route native Claude Code/Codex through the gateway
52
+ impel use account [claude|codex|all] Revert to your own Anthropic/OpenAI login
53
+ impel on / impel off Aliases for the two commands above
54
+
55
+ impel help Show this help
56
+ impel --version Show the installed version
57
+
58
+ Env:
59
+ IMPEL_GATEWAY_URL Default gateway URL when --gateway isn't passed.
60
+ IMPEL_APP_URL Default app/control-plane URL for setup, auth, and tasks.
61
+ IMPEL_SKIP_UPDATE_CHECK=1 Silence launch-time update notices.
62
+
63
+ Config file:
64
+ ~/.config/impel/config.json (mode 0600)
65
+ `;
66
+
67
+ function printVersion() {
68
+ const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
69
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
70
+ console.log(pkg.version);
71
+ }
72
+
73
+ // Accepts "claude" | "codex" | "all" | undefined; returns a normalized target
74
+ // or null if the token isn't a valid target. `codex-cli`/`codex-app` normalize
75
+ // to `codex` (they share the same config file).
76
+ function normalizeTarget(token) {
77
+ if (token === undefined) return "all";
78
+ if (["claude", "codex", "all"].includes(token)) return token;
79
+ if (token === "codex-cli" || token === "codex-app") return "codex";
80
+ return null;
81
+ }
82
+
83
+ export async function main(argv) {
84
+ const [cmd, ...rest] = argv;
85
+
86
+ switch (cmd) {
87
+ case undefined:
88
+ case "help":
89
+ case "--help":
90
+ case "-h":
91
+ console.log(HELP);
92
+ return;
93
+
94
+ case "--version":
95
+ case "-v":
96
+ printVersion();
97
+ return;
98
+
99
+ case "auth":
100
+ return cmdAuth(rest);
101
+
102
+ case "token":
103
+ return cmdToken(rest);
104
+
105
+ case "mcp":
106
+ return cmdMcp(rest);
107
+
108
+ case "claude":
109
+ case "codex":
110
+ return cmdLaunch(cmd, rest);
111
+
112
+ case "status":
113
+ return cmdStatus();
114
+
115
+ case "doctor":
116
+ return cmdDoctor(rest);
117
+
118
+ case "tasks":
119
+ case "task":
120
+ case "tickets":
121
+ case "ticket":
122
+ return cmdTasks(rest);
123
+
124
+ case "tenant":
125
+ case "tenants":
126
+ case "org":
127
+ return cmdTenant(rest);
128
+
129
+ case "app":
130
+ case "apps":
131
+ return cmdApps(rest);
132
+
133
+ case "skills":
134
+ case "skill":
135
+ return cmdSkills(rest);
136
+
137
+ case "on":
138
+ return runUse("gateway", rest[0]);
139
+
140
+ case "off":
141
+ return runUse("account", rest[0]);
142
+
143
+ case "use": {
144
+ const [mode, targetToken] = rest;
145
+ if (mode !== "gateway" && mode !== "account") {
146
+ console.error(`impel use: expected \`gateway\` or \`account\`, got "${mode ?? ""}".`);
147
+ console.error(" Try `impel use gateway` (alias `impel on`) or `impel use account` (alias `impel off`).");
148
+ process.exitCode = 1;
149
+ return;
150
+ }
151
+ return runUse(mode, targetToken);
152
+ }
153
+
154
+ case "setup":
155
+ // The old per-tool aliases (setup claude|codex-cli|codex-app) flipped
156
+ // native configs; that swap is no longer part of setup. cmdSetup rejects
157
+ // positional targets with a pointer to `impel use gateway`.
158
+ return cmdSetup(rest);
159
+
160
+ case "update":
161
+ case "upgrade":
162
+ return cmdUpdate(rest);
163
+
164
+ // Intentionally omitted from the public help while the contract is gated
165
+ // server-side and limited to Impel-managed desktop apps.
166
+ case "experimental":
167
+ return cmdExperimental(rest);
168
+
169
+ default:
170
+ console.error(`impel: unknown command "${cmd}"\n`);
171
+ console.log(HELP);
172
+ process.exitCode = 1;
173
+ }
174
+ }
175
+
176
+ function runUse(mode, targetToken) {
177
+ const target = normalizeTarget(targetToken);
178
+ if (target === null) {
179
+ console.error(`impel: unknown target "${targetToken}". Use \`claude\`, \`codex\`, or \`all\`.`);
180
+ process.exitCode = 1;
181
+ return;
182
+ }
183
+ return cmdUse({ mode, target });
184
+ }
@@ -0,0 +1,216 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "./claudeSetup.js";
5
+ import { impelCodexBaseUrl } from "./codexSetup.js";
6
+ import {
7
+ hardenManagedCodexToml,
8
+ secureAllManagedCodexHomes,
9
+ secureManagedCodexHome,
10
+ } from "./codexSecurity.js";
11
+ import { CONFIG_DIR } from "./config.js";
12
+ import { normalizeTenantId } from "./tenants.js";
13
+ import { impelCliInvocation, impelMcpInvocation } from "./selfInvocation.js";
14
+
15
+ export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
16
+
17
+ export function tenantCliProfilePaths(tenantId) {
18
+ const root = path.join(IMPEL_CLI_PROFILES_DIR, "tenants", normalizeTenantId(tenantId));
19
+ return {
20
+ root,
21
+ claudeConfigDir: path.join(root, "claude"),
22
+ codexHome: path.join(root, "codex"),
23
+ };
24
+ }
25
+
26
+ const CODEX_START_MARK = "# >>> impel-cli isolated profile block >>>";
27
+ const CODEX_END_MARK = "# <<< impel-cli isolated profile block <<<";
28
+ const CODEX_PROVIDER_LINE_RE = /^model_provider[ \t]*=[ \t]*"([^"]*)"[ \t]*$/m;
29
+ const IMPEL_SPECIALIST_CODEX_APPROVAL_TOOLS = [
30
+ "impel_specialists-list_specialist_runs",
31
+ "impel_specialists-list_specialists",
32
+ "impel_specialists-read_specialist_run",
33
+ "impel_specialists-start_specialist_run",
34
+ ];
35
+
36
+ // API-backed Claude profiles need the Fable family registered explicitly for
37
+ // it to appear in /model. Opus remains the initial selection; later choices
38
+ // made by the user remain authoritative.
39
+ export const IMPEL_DEFAULT_CLAUDE_MODEL = "opus[1m]";
40
+ export const IMPEL_FABLE_MODEL = "claude-fable-5";
41
+
42
+ function ensurePrivateDirectory(directory) {
43
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
44
+ try {
45
+ fs.chmodSync(directory, 0o700);
46
+ } catch {
47
+ // Best effort on platforms where chmod is unavailable.
48
+ }
49
+ }
50
+
51
+ function writePrivateFile(filePath, contents) {
52
+ ensurePrivateDirectory(path.dirname(filePath));
53
+ const temporaryPath = `${filePath}.tmp-${process.pid}`;
54
+ try {
55
+ fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
56
+ fs.renameSync(temporaryPath, filePath);
57
+ try {
58
+ fs.chmodSync(filePath, 0o600);
59
+ } catch {
60
+ // Best effort on platforms where chmod is unavailable.
61
+ }
62
+ } finally {
63
+ try {
64
+ fs.rmSync(temporaryPath, { force: true });
65
+ } catch {
66
+ // The rename already removed the temporary file in the normal case.
67
+ }
68
+ }
69
+ }
70
+
71
+ function readJsonObject(filePath) {
72
+ if (!fs.existsSync(filePath)) return {};
73
+ const raw = fs.readFileSync(filePath, "utf8").trim();
74
+ if (!raw) return {};
75
+ try {
76
+ const value = JSON.parse(raw);
77
+ if (!value || Array.isArray(value) || typeof value !== "object") throw new Error();
78
+ return value;
79
+ } catch {
80
+ throw new Error(`${filePath} exists but isn't a valid JSON object. Fix or remove it, then re-run.`);
81
+ }
82
+ }
83
+
84
+ export function ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels = false } = {}) {
85
+ const configDir = tenantCliProfilePaths(tenantId).claudeConfigDir;
86
+ ensurePrivateDirectory(configDir);
87
+ const settingsPath = path.join(configDir, "settings.json");
88
+ const userConfigPath = path.join(configDir, ".claude.json");
89
+ const settings = readJsonObject(settingsPath);
90
+ const userConfig = readJsonObject(userConfigPath);
91
+
92
+ // The isolated launcher supplies the Impel PAT only to the child process.
93
+ // Remove older launcher-generated helpers so Claude does not validate the
94
+ // bearer token as an Anthropic-shaped API key before sending it upstream.
95
+ if (settings.apiKeyHelper === "impel token") delete settings.apiKeyHelper;
96
+ if (typeof settings.model !== "string" || !settings.model.trim()) {
97
+ settings.model = IMPEL_DEFAULT_CLAUDE_MODEL;
98
+ }
99
+ const managedEnvironment = {
100
+ ...(settings.env && typeof settings.env === "object" && !Array.isArray(settings.env)
101
+ ? settings.env
102
+ : {}),
103
+ ANTHROPIC_BASE_URL: crossAppModels
104
+ ? impelCrossAppClaudeBaseUrl(gatewayUrl)
105
+ : impelClaudeBaseUrl(gatewayUrl),
106
+ ANTHROPIC_DEFAULT_FABLE_MODEL: IMPEL_FABLE_MODEL,
107
+ ANTHROPIC_DEFAULT_FABLE_MODEL_NAME: "Fable",
108
+ ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION:
109
+ "Fable 5 · Most capable for your hardest and longest-running tasks",
110
+ };
111
+ if (crossAppModels) {
112
+ // Claude Code 2.1.129+ queries <ANTHROPIC_BASE_URL>/v1/models and adds
113
+ // gateway-discovered entries to /model. The experimental gateway returns
114
+ // reversible anthropic.* aliases for GPT IDs because Claude Code filters
115
+ // discovered IDs that do not begin with "claude" or "anthropic".
116
+ managedEnvironment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
117
+ } else {
118
+ delete managedEnvironment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY;
119
+ }
120
+ settings.env = managedEnvironment;
121
+ userConfig.mcpServers = {
122
+ ...(userConfig.mcpServers &&
123
+ typeof userConfig.mcpServers === "object" &&
124
+ !Array.isArray(userConfig.mcpServers)
125
+ ? userConfig.mcpServers
126
+ : {}),
127
+ impel: impelMcpInvocation(["--tenant", tenantId]),
128
+ };
129
+
130
+ writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
131
+ writePrivateFile(userConfigPath, `${JSON.stringify(userConfig, null, 2)}\n`);
132
+
133
+ return { configDir, settingsPath, userConfigPath };
134
+ }
135
+
136
+ function stripCodexManagedBlock(text, configPath) {
137
+ const start = text.indexOf(CODEX_START_MARK);
138
+ if (start === -1) return text;
139
+ const end = text.indexOf(CODEX_END_MARK, start);
140
+ if (end === -1) {
141
+ throw new Error(
142
+ `${configPath} has an incomplete Impel profile block. Fix or remove it, then re-run.`
143
+ );
144
+ }
145
+ return text.slice(0, start) + text.slice(end + CODEX_END_MARK.length);
146
+ }
147
+
148
+ function splitTomlPreamble(text) {
149
+ const table = text.match(/^\s*\[/m);
150
+ if (!table) return { preamble: text, rest: "" };
151
+ return { preamble: text.slice(0, table.index), rest: text.slice(table.index) };
152
+ }
153
+
154
+ function codexManagedBlock(gatewayUrl, tenantId) {
155
+ const auth = impelCliInvocation(["token", "--tenant", tenantId]);
156
+ const mcp = impelCliInvocation(["mcp", "--tenant", tenantId]);
157
+ const lines = [
158
+ CODEX_START_MARK,
159
+ "# Generated for `impel codex`. Other profile settings outside this block are preserved.",
160
+ "[model_providers.impel]",
161
+ 'name = "Impel Gateway"',
162
+ `base_url = ${JSON.stringify(impelCodexBaseUrl(gatewayUrl))}`,
163
+ 'wire_api = "responses"',
164
+ "",
165
+ "[model_providers.impel.auth]",
166
+ `command = ${JSON.stringify(auth.command)}`,
167
+ `args = [${auth.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
168
+ "timeout_ms = 5000",
169
+ "refresh_interval_ms = 300000",
170
+ "",
171
+ "[mcp_servers.impel]",
172
+ `command = ${JSON.stringify(mcp.command)}`,
173
+ `args = [${mcp.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
174
+ "",
175
+ ];
176
+ for (const tool of IMPEL_SPECIALIST_CODEX_APPROVAL_TOOLS) {
177
+ lines.push(
178
+ `[mcp_servers.impel.tools.${JSON.stringify(tool)}]`,
179
+ 'approval_mode = "approve"',
180
+ "",
181
+ );
182
+ }
183
+ lines.push(CODEX_END_MARK);
184
+ return lines.join("\n");
185
+ }
186
+
187
+ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
188
+ secureAllManagedCodexHomes({ cliRoot: IMPEL_CLI_PROFILES_DIR });
189
+ const codexHome = tenantCliProfilePaths(tenantId).codexHome;
190
+ secureManagedCodexHome(codexHome);
191
+ const configPath = path.join(codexHome, "config.toml");
192
+ const original = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
193
+ const withoutManagedBlock = stripCodexManagedBlock(original, configPath);
194
+
195
+ if (/^(\[model_providers\.impel(?:\.|\])|\[mcp_servers\.impel\])/m.test(withoutManagedBlock)) {
196
+ throw new Error(
197
+ `${configPath} contains an Impel provider or MCP table outside the managed profile block. ` +
198
+ "Remove or rename that table, then re-run."
199
+ );
200
+ }
201
+
202
+ const { preamble, rest } = splitTomlPreamble(withoutManagedBlock);
203
+ const providerLine = 'model_provider = "impel"';
204
+ const nextPreamble = CODEX_PROVIDER_LINE_RE.test(preamble)
205
+ ? preamble.replace(CODEX_PROVIDER_LINE_RE, providerLine)
206
+ : `${preamble.trimEnd()}${preamble.trim() ? "\n" : ""}${providerLine}\n`;
207
+ const restText = rest.trim();
208
+ const next = [nextPreamble.trimEnd(), codexManagedBlock(gatewayUrl, tenantId), restText]
209
+ .filter(Boolean)
210
+ .join("\n\n")
211
+ .concat("\n");
212
+
213
+ writePrivateFile(configPath, hardenManagedCodexToml(next, configPath));
214
+ secureManagedCodexHome(codexHome);
215
+ return { codexHome, configPath };
216
+ }