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/README.md +695 -0
- package/bin/impel.js +7 -0
- package/package.json +29 -0
- package/src/apps.js +1263 -0
- package/src/args.js +36 -0
- package/src/claudeSetup.js +207 -0
- package/src/cli.js +184 -0
- package/src/cliProfiles.js +216 -0
- package/src/codexSecurity.js +184 -0
- package/src/codexSetup.js +224 -0
- package/src/commands/apps.js +538 -0
- package/src/commands/auth.js +89 -0
- package/src/commands/doctor.js +215 -0
- package/src/commands/experimental.js +60 -0
- package/src/commands/launch.js +161 -0
- package/src/commands/mcp.js +94 -0
- package/src/commands/setup.js +350 -0
- package/src/commands/skills.js +108 -0
- package/src/commands/status.js +95 -0
- package/src/commands/tasks.js +359 -0
- package/src/commands/tenant.js +77 -0
- package/src/commands/token.js +25 -0
- package/src/commands/update.js +217 -0
- package/src/commands/use.js +208 -0
- package/src/config.js +98 -0
- package/src/doctor.js +546 -0
- package/src/nativeProcess.js +192 -0
- package/src/prompt.js +51 -0
- package/src/selfInvocation.js +21 -0
- package/src/skills.js +314 -0
- package/src/tenants.js +194 -0
- package/src/updates.js +181 -0
- package/src/windowsApps.js +439 -0
- package/src/windowsSetup.js +122 -0
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
|
+
}
|