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
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { parseFlags } from "../args.js";
|
|
6
|
+
import {
|
|
7
|
+
loadConfig,
|
|
8
|
+
crossAppModelsEnabled,
|
|
9
|
+
normalizeGatewayUrl,
|
|
10
|
+
redactSecretText,
|
|
11
|
+
resolveDefaultGateway,
|
|
12
|
+
} from "../config.js";
|
|
13
|
+
import {
|
|
14
|
+
maybePrintUpdateNotice,
|
|
15
|
+
refreshUpdateCache,
|
|
16
|
+
spawnDetachedAppRefresh,
|
|
17
|
+
} from "../updates.js";
|
|
18
|
+
import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
|
|
19
|
+
import {
|
|
20
|
+
CURRENT_CONFIG_VERSION,
|
|
21
|
+
CLAUDE_CONFIG_ID,
|
|
22
|
+
appPaths,
|
|
23
|
+
appStatus,
|
|
24
|
+
bundleIsCurrent,
|
|
25
|
+
ensureVendorApp,
|
|
26
|
+
fetchGatewayModels,
|
|
27
|
+
installManagedAppFiles,
|
|
28
|
+
normalizeAppTarget,
|
|
29
|
+
quitBlockingApps,
|
|
30
|
+
readTenantManifest,
|
|
31
|
+
removeManagedApps,
|
|
32
|
+
} from "../apps.js";
|
|
33
|
+
import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
|
|
34
|
+
import { secureManagedCodexHome } from "../codexSecurity.js";
|
|
35
|
+
import { impelCliInvocation } from "../selfInvocation.js";
|
|
36
|
+
import {
|
|
37
|
+
ensureWindowsClaudeApp,
|
|
38
|
+
ensureWindowsChatGPTApp,
|
|
39
|
+
findWindowsClaudeApp,
|
|
40
|
+
findWindowsChatGPTApp,
|
|
41
|
+
findManagedWindowsChatGPTApp,
|
|
42
|
+
launchWindowsClaudeApp,
|
|
43
|
+
launchWindowsChatGPTApp,
|
|
44
|
+
removeManagedWindowsChatGPTApp,
|
|
45
|
+
stageWindowsChatGPTApp,
|
|
46
|
+
windowsChatGPTStageIsCurrent,
|
|
47
|
+
windowsClaudeUserData,
|
|
48
|
+
} from "../windowsApps.js";
|
|
49
|
+
|
|
50
|
+
// Each isolated desktop app maps to a client CLI + the env override that points
|
|
51
|
+
// that CLI at the app's private profile, so skill syncing lands in the app's
|
|
52
|
+
// installation rather than a global one.
|
|
53
|
+
function appSkillTarget(target, paths) {
|
|
54
|
+
if (target === "claude") {
|
|
55
|
+
return { client: "claude", env: { CLAUDE_CONFIG_DIR: paths.claude.userData }, label: "Impel Claude app" };
|
|
56
|
+
}
|
|
57
|
+
return { client: "codex", env: { CODEX_HOME: paths.chatgpt.codexHome }, label: "Impel ChatGPT app" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function windowsAppTargets(targetToken) {
|
|
61
|
+
if (targetToken === undefined || targetToken === "all") return ["claude", "chatgpt"];
|
|
62
|
+
if (targetToken === "claude") return ["claude"];
|
|
63
|
+
if (targetToken === "chatgpt" || targetToken === "codex") return ["chatgpt"];
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function windowsProcessFailure(result) {
|
|
68
|
+
if (result?.error?.message) return redactSecretText(result.error.message);
|
|
69
|
+
if (Number.isInteger(result?.status)) return `exit code ${result.status}`;
|
|
70
|
+
if (result?.signal) return `signal ${result.signal}`;
|
|
71
|
+
return "no exit status";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fetchWindowsCatalog(config, io) {
|
|
75
|
+
try {
|
|
76
|
+
return await io.fetchModels(config);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new Error(`tenant model catalog is unavailable (${redactSecretText(error.message)}); the Impel app profiles were not changed`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function configureWindowsApps(config, targets, vendorPaths, catalog, io) {
|
|
83
|
+
const userData = io.claudeUserData(io.environment, config.tenantId);
|
|
84
|
+
const auth = impelCliInvocation(["token", "--tenant", config.tenantId]);
|
|
85
|
+
const mcp = impelCliInvocation(["mcp", "--tenant", config.tenantId]);
|
|
86
|
+
io.installFiles({
|
|
87
|
+
config,
|
|
88
|
+
targets,
|
|
89
|
+
models: catalog.models,
|
|
90
|
+
homeDir: io.homeDir,
|
|
91
|
+
vendorPaths,
|
|
92
|
+
writeBundles: false,
|
|
93
|
+
writeTokenHelperFile: false,
|
|
94
|
+
claudeUserData: userData,
|
|
95
|
+
chatgptInvocations: { auth, mcp },
|
|
96
|
+
});
|
|
97
|
+
return { catalog, userData };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function cmdWindowsApps(argv, overrides = {}) {
|
|
101
|
+
const [action = "status", rawTarget, ...rest] = argv;
|
|
102
|
+
const targetToken = rawTarget?.startsWith("--") ? undefined : rawTarget;
|
|
103
|
+
const targets = windowsAppTargets(targetToken);
|
|
104
|
+
if (!targets) {
|
|
105
|
+
console.error(`impel app: unknown app target "${targetToken}"; use claude, chatgpt, codex, or all.`);
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
|
|
110
|
+
const { flags } = parseFlags(flagArgs, {
|
|
111
|
+
"skip-vendor": { type: "boolean" },
|
|
112
|
+
"keep-data": { type: "boolean" },
|
|
113
|
+
"stale-only": { type: "boolean" },
|
|
114
|
+
});
|
|
115
|
+
const io = {
|
|
116
|
+
environment: process.env,
|
|
117
|
+
homeDir: os.homedir(),
|
|
118
|
+
findClaudeApp: findWindowsClaudeApp,
|
|
119
|
+
ensureClaudeApp: ensureWindowsClaudeApp,
|
|
120
|
+
launchClaudeApp: launchWindowsClaudeApp,
|
|
121
|
+
claudeUserData: windowsClaudeUserData,
|
|
122
|
+
findChatGPTApp: findWindowsChatGPTApp,
|
|
123
|
+
ensureChatGPTApp: ensureWindowsChatGPTApp,
|
|
124
|
+
findManagedChatGPTApp: findManagedWindowsChatGPTApp,
|
|
125
|
+
launchChatGPTApp: launchWindowsChatGPTApp,
|
|
126
|
+
removeManagedChatGPTApp: removeManagedWindowsChatGPTApp,
|
|
127
|
+
stageChatGPTApp: stageWindowsChatGPTApp,
|
|
128
|
+
chatGPTStageIsCurrent: windowsChatGPTStageIsCurrent,
|
|
129
|
+
fetchModels: fetchGatewayModels,
|
|
130
|
+
installFiles: installManagedAppFiles,
|
|
131
|
+
removeApps: removeManagedApps,
|
|
132
|
+
syncSkills: syncSkillsSafe,
|
|
133
|
+
selectedConfig: selectedAppConfig,
|
|
134
|
+
...overrides,
|
|
135
|
+
};
|
|
136
|
+
// Preserve the original Windows-Claude override names used by embedders and
|
|
137
|
+
// tests while exposing explicit per-app hooks for the new second target.
|
|
138
|
+
if (overrides.findApp) io.findClaudeApp = overrides.findApp;
|
|
139
|
+
if (overrides.ensureApp) io.ensureClaudeApp = overrides.ensureApp;
|
|
140
|
+
if (overrides.launchApp) io.launchClaudeApp = overrides.launchApp;
|
|
141
|
+
if (overrides.userData) io.claudeUserData = overrides.userData;
|
|
142
|
+
const stored = loadConfig();
|
|
143
|
+
const storedUserData = io.claudeUserData(io.environment, stored?.tenantId || null);
|
|
144
|
+
const paths = appPaths(io.homeDir, stored?.tenantId || null, { claudeUserData: storedUserData });
|
|
145
|
+
const claudeConfigPath = path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`);
|
|
146
|
+
const chatgptConfigPath = path.join(paths.chatgpt.codexHome, "config.toml");
|
|
147
|
+
|
|
148
|
+
if (action === "status") {
|
|
149
|
+
console.log(`Tenant: ${stored?.tenantId || "not selected"}`);
|
|
150
|
+
if (targets.includes("claude")) {
|
|
151
|
+
console.log("Impel Claude:");
|
|
152
|
+
console.log(` profile: ${fs.existsSync(claudeConfigPath) ? paths.claude.userData : "not installed"}`);
|
|
153
|
+
console.log(` vendor: ${io.findClaudeApp(io.environment) || "not installed"}`);
|
|
154
|
+
console.log(" open: impel app open claude");
|
|
155
|
+
}
|
|
156
|
+
if (targets.includes("chatgpt")) {
|
|
157
|
+
console.log("Impel ChatGPT:");
|
|
158
|
+
console.log(` profile: ${fs.existsSync(chatgptConfigPath) ? paths.chatgpt.root : "not installed"}`);
|
|
159
|
+
console.log(` vendor: ${io.findChatGPTApp(io.environment) || "not installed"}`);
|
|
160
|
+
console.log(` staged: ${io.findManagedChatGPTApp(paths.root) || "not installed"}`);
|
|
161
|
+
console.log(" open: impel app open codex");
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (action === "uninstall") {
|
|
167
|
+
io.removeApps(targets, {
|
|
168
|
+
homeDir: io.homeDir,
|
|
169
|
+
keepData: Boolean(flags["keep-data"]),
|
|
170
|
+
tenantId: stored?.tenantId || null,
|
|
171
|
+
claudeUserData: storedUserData,
|
|
172
|
+
});
|
|
173
|
+
if (targets.includes("chatgpt")) io.removeManagedChatGPTApp(paths.root);
|
|
174
|
+
for (const target of targets) {
|
|
175
|
+
const label = target === "claude" ? "Claude" : "ChatGPT";
|
|
176
|
+
console.log(`Removed Impel ${label} profile${flags["keep-data"] ? " data was retained" : " and isolated data"}.`);
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!["install", "update", "refresh", "open"].includes(action)) {
|
|
182
|
+
console.error(`impel app: unknown app action "${action}"; use install, update, refresh, status, open, or uninstall.`);
|
|
183
|
+
process.exitCode = 1;
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
if (action === "open") maybePrintUpdateNotice();
|
|
189
|
+
const config = await io.selectedConfig(targets);
|
|
190
|
+
const actionUserData = io.claudeUserData(io.environment, config.tenantId);
|
|
191
|
+
const actionPaths = appPaths(io.homeDir, config.tenantId, { claudeUserData: actionUserData });
|
|
192
|
+
const vendorPaths = {};
|
|
193
|
+
for (const target of targets) {
|
|
194
|
+
const isClaude = target === "claude";
|
|
195
|
+
const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
|
|
196
|
+
const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
|
|
197
|
+
let binary = find(io.environment);
|
|
198
|
+
if ((action === "install" || action === "update") && !flags["skip-vendor"]) {
|
|
199
|
+
const vendor = ensure({ update: action === "update" }, { environment: io.environment });
|
|
200
|
+
binary = vendor.binary;
|
|
201
|
+
console.log(`${target}: vendor app ${vendor.action}`);
|
|
202
|
+
if (!binary) {
|
|
203
|
+
const label = isClaude ? "Claude" : "ChatGPT/Codex";
|
|
204
|
+
const url = isClaude ? "https://claude.com/download" : "https://apps.microsoft.com/detail/9plm9xgg6vks";
|
|
205
|
+
throw new Error(
|
|
206
|
+
`${label} vendor installation failed (${windowsProcessFailure(vendor.result)}); install it from ${url}, then re-run`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (binary) vendorPaths[target] = binary;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (targets.includes("claude") && action !== "refresh" && !vendorPaths.claude) {
|
|
214
|
+
throw new Error("Claude vendor app is unavailable; run `impel app install claude` or install it from https://claude.com/download");
|
|
215
|
+
}
|
|
216
|
+
const catalog = await fetchWindowsCatalog(config, io);
|
|
217
|
+
let managedChatGPT = targets.includes("chatgpt")
|
|
218
|
+
? io.findManagedChatGPTApp(actionPaths.root)
|
|
219
|
+
: null;
|
|
220
|
+
const mustStageChatGPT = targets.includes("chatgpt")
|
|
221
|
+
&& (action === "install"
|
|
222
|
+
|| action === "update"
|
|
223
|
+
|| (action === "open" && (!managedChatGPT
|
|
224
|
+
|| (vendorPaths.chatgpt && !io.chatGPTStageIsCurrent(vendorPaths.chatgpt, actionPaths.root)))));
|
|
225
|
+
if (mustStageChatGPT) {
|
|
226
|
+
if (!vendorPaths.chatgpt) {
|
|
227
|
+
throw new Error("ChatGPT/Codex vendor app is unavailable; run `impel app install codex` or install it from https://apps.microsoft.com/detail/9plm9xgg6vks");
|
|
228
|
+
}
|
|
229
|
+
managedChatGPT = io.stageChatGPTApp(vendorPaths.chatgpt, actionPaths.root);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { userData } = configureWindowsApps(config, targets, vendorPaths, catalog, io);
|
|
233
|
+
const tenantPaths = appPaths(io.homeDir, config.tenantId, { claudeUserData: userData });
|
|
234
|
+
if (action === "install" || action === "update" || action === "refresh") {
|
|
235
|
+
const verb = action === "install" ? "Installed" : action === "update" ? "Updated" : "Refreshed";
|
|
236
|
+
for (const target of targets) {
|
|
237
|
+
const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
|
|
238
|
+
console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
|
|
239
|
+
const { client, env, label } = appSkillTarget(target, tenantPaths);
|
|
240
|
+
await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
|
|
241
|
+
if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
|
|
242
|
+
}
|
|
243
|
+
console.log(`Models: ${catalog.models.length} from ${catalog.source}. The signed vendor apps and their normal profiles were not changed.`);
|
|
244
|
+
}
|
|
245
|
+
if (action === "open") {
|
|
246
|
+
if (targets.includes("claude")) {
|
|
247
|
+
await io.launchClaudeApp(vendorPaths.claude, tenantPaths.claude.userData, { environment: io.environment });
|
|
248
|
+
console.log("Opened Impel Claude with its tenant-isolated Windows profile.");
|
|
249
|
+
}
|
|
250
|
+
if (targets.includes("chatgpt")) {
|
|
251
|
+
managedChatGPT ||= io.findManagedChatGPTApp(tenantPaths.root);
|
|
252
|
+
if (!managedChatGPT) throw new Error("Impel ChatGPT is not staged; run `impel app install codex`");
|
|
253
|
+
await io.launchChatGPTApp(managedChatGPT, {
|
|
254
|
+
browserData: tenantPaths.chatgpt.browserData,
|
|
255
|
+
codexHome: tenantPaths.chatgpt.codexHome,
|
|
256
|
+
credential: config.pat,
|
|
257
|
+
gatewayUrl: config.gatewayUrl,
|
|
258
|
+
}, { environment: io.environment });
|
|
259
|
+
console.log("Opened Impel ChatGPT with its tenant-isolated Windows profile.");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
console.error(`impel app: ${redactSecretText(error?.message || error)}`);
|
|
265
|
+
process.exitCode = 1;
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function cmdApps(argv, overrides = {}) {
|
|
271
|
+
if (process.platform === "win32") return cmdWindowsApps(argv, overrides);
|
|
272
|
+
if (process.platform !== "darwin") {
|
|
273
|
+
console.error("impel app: isolated Impel desktop apps are unavailable on this platform.");
|
|
274
|
+
process.exitCode = 1;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const [action = "status", targetToken, ...rest] = argv;
|
|
278
|
+
const targets = normalizeAppTarget(targetToken?.startsWith("--") ? undefined : targetToken);
|
|
279
|
+
if (!targets) throw new Error(`unknown app target "${targetToken}"; use claude, chatgpt, codex, or all`);
|
|
280
|
+
const flagArgs = targetToken?.startsWith("--") ? [targetToken, ...rest] : rest;
|
|
281
|
+
const { flags } = parseFlags(flagArgs, {
|
|
282
|
+
"skip-vendor": { type: "boolean" },
|
|
283
|
+
"keep-data": { type: "boolean" },
|
|
284
|
+
"stale-only": { type: "boolean" },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (action === "status") return printStatus(targets);
|
|
288
|
+
if (action === "refresh") {
|
|
289
|
+
return refreshApps(targets, { staleOnly: Boolean(flags["stale-only"]) });
|
|
290
|
+
}
|
|
291
|
+
if (action === "uninstall") {
|
|
292
|
+
const config = loadConfig();
|
|
293
|
+
removeManagedApps(targets, {
|
|
294
|
+
keepData: Boolean(flags["keep-data"]),
|
|
295
|
+
tenantId: config?.tenantId || null,
|
|
296
|
+
});
|
|
297
|
+
for (const target of targets) console.log(`Removed Impel ${target} app${flags["keep-data"] ? " (kept isolated data)" : " and isolated data"}.`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (action === "open") {
|
|
301
|
+
maybePrintUpdateNotice();
|
|
302
|
+
// Fast path: when the bundles were built by this CLI from the on-disk
|
|
303
|
+
// vendor versions and the written configs still match the stored
|
|
304
|
+
// credentials, there is nothing to fetch or rebuild — launch immediately
|
|
305
|
+
// and let a detached stale-only refresh keep configs/catalog/skills
|
|
306
|
+
// converging in the background.
|
|
307
|
+
const fastLaunchers = fastOpenLaunchers(targets);
|
|
308
|
+
if (fastLaunchers) {
|
|
309
|
+
spawnDetachedAppRefresh();
|
|
310
|
+
for (const launcher of fastLaunchers) {
|
|
311
|
+
spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" });
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const config = await selectedAppConfig(targets);
|
|
317
|
+
const statuses = appStatus(targets, os.homedir(), config.tenantId);
|
|
318
|
+
for (const status of statuses) {
|
|
319
|
+
if (!status.launcherInstalled) throw new Error(`${status.label} is not installed; run \`impel app install ${status.target}\``);
|
|
320
|
+
if (!status.vendorPath) throw new Error(`${status.label} vendor app is unavailable; reinstall the vendor app first`);
|
|
321
|
+
}
|
|
322
|
+
let catalog;
|
|
323
|
+
try {
|
|
324
|
+
catalog = await fetchGatewayModels(config);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`tenant model catalog is unavailable (${redactSecretText(error.message)}); the selected tenant app was not opened`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
// Only stale bundles pay the clone + codesign rebuild; a bundle swap needs
|
|
331
|
+
// its app closed, and quitting is fine here since we relaunch right after.
|
|
332
|
+
const staleBundleTargets = statuses
|
|
333
|
+
.filter((status) => !bundleIsCurrent(status))
|
|
334
|
+
.map((status) => status.target);
|
|
335
|
+
if (staleBundleTargets.length > 0) await quitBlockingApps(staleBundleTargets);
|
|
336
|
+
installManagedAppFiles({
|
|
337
|
+
config,
|
|
338
|
+
targets,
|
|
339
|
+
models: catalog.models,
|
|
340
|
+
homeDir: os.homedir(),
|
|
341
|
+
vendorPaths: Object.fromEntries(statuses.map((status) => [status.target, status.vendorPath])),
|
|
342
|
+
writeBundles: staleBundleTargets,
|
|
343
|
+
});
|
|
344
|
+
for (const status of appStatus(targets, os.homedir(), config.tenantId)) {
|
|
345
|
+
spawnSync("/usr/bin/open", ["-n", status.launcher], { stdio: "inherit" });
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (action !== "install" && action !== "update") {
|
|
350
|
+
throw new Error(`unknown app action "${action}"; use install, update, refresh, status, open, or uninstall`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const config = await selectedAppConfig(targets);
|
|
354
|
+
|
|
355
|
+
// A running vendor or Impel app makes the cask upgrade / bundle swap fail.
|
|
356
|
+
await quitBlockingApps(targets);
|
|
357
|
+
|
|
358
|
+
const vendorPaths = {};
|
|
359
|
+
if (!flags["skip-vendor"]) {
|
|
360
|
+
for (const target of targets) {
|
|
361
|
+
// Always bring the vendor (system) app to the latest version before
|
|
362
|
+
// cloning it into the vendored Impel bundle, so install and update both
|
|
363
|
+
// start from a current copy. A failed upgrade keeps the existing app.
|
|
364
|
+
const result = ensureVendorApp(target, { update: true });
|
|
365
|
+
vendorPaths[target] = result.path;
|
|
366
|
+
console.log(`${target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
|
|
367
|
+
if (!result.path) throw new Error(`${target} vendor app is unavailable; install it manually or fix Homebrew, then re-run`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let catalog;
|
|
372
|
+
try {
|
|
373
|
+
catalog = await fetchGatewayModels(config);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`tenant model catalog is unavailable (${redactSecretText(error.message)}); no Impel app files were changed`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const installed = installManagedAppFiles({ config, targets, models: catalog.models, homeDir: os.homedir(), vendorPaths });
|
|
380
|
+
for (const item of installed) console.log(`${action === "install" ? "Installed" : "Updated"} ${item.launcher}`);
|
|
381
|
+
console.log(`Models: ${catalog.models.length} from ${catalog.source}. Normal ~/.claude and ~/.codex profiles were not changed.`);
|
|
382
|
+
|
|
383
|
+
// Best-effort: sync the Bifrost shared skills into each installed isolated app
|
|
384
|
+
// profile. Never fails the install/update.
|
|
385
|
+
const paths = appPaths(os.homedir(), config.tenantId);
|
|
386
|
+
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
387
|
+
for (const item of installed) {
|
|
388
|
+
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
389
|
+
await syncSkillsSafe({ client, gatewayUrl, env, label });
|
|
390
|
+
if (item.target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const REFRESH_TTL_MS = 6 * 60 * 60 * 1000;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The zero-network, zero-rebuild open decision. Returns the launcher paths to
|
|
398
|
+
* open when every requested target is provably current — bundle built by this
|
|
399
|
+
* CLI from the on-disk vendor version, manifest matching the stored
|
|
400
|
+
* gateway/tenant/config schema, and (for Claude, whose config embeds the
|
|
401
|
+
* tenant credential) the written credential matching the stored PAT. Any
|
|
402
|
+
* doubt returns null and the slow path re-derives everything.
|
|
403
|
+
*/
|
|
404
|
+
export function fastOpenLaunchers(targets, { homeDir = os.homedir(), config = loadConfig(), statuses = null } = {}) {
|
|
405
|
+
if (!config?.pat || !config?.tenantId) return null;
|
|
406
|
+
const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
|
|
407
|
+
const manifest = readTenantManifest(homeDir, config.tenantId);
|
|
408
|
+
if (
|
|
409
|
+
!manifest
|
|
410
|
+
|| manifest.gatewayUrl !== gatewayUrl
|
|
411
|
+
|| manifest.tenantId !== config.tenantId
|
|
412
|
+
|| manifest.configVersion !== CURRENT_CONFIG_VERSION
|
|
413
|
+
|| manifest.experiments?.crossAppModels !== crossAppModelsEnabled(config)
|
|
414
|
+
) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
const launchers = [];
|
|
418
|
+
for (const status of statuses ?? appStatus(targets, homeDir, config.tenantId)) {
|
|
419
|
+
if (!bundleIsCurrent(status)) return null;
|
|
420
|
+
if (!fs.existsSync(status.configPath)) return null;
|
|
421
|
+
if (status.target === "claude" && !claudeConfigIsCurrent(status.configPath, config, gatewayUrl)) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
launchers.push(status.launcher);
|
|
425
|
+
}
|
|
426
|
+
return launchers;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function claudeConfigIsCurrent(configPath, config, gatewayUrl) {
|
|
430
|
+
try {
|
|
431
|
+
const body = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
432
|
+
return (
|
|
433
|
+
body.inferenceGatewayBaseUrl === (crossAppModelsEnabled(config)
|
|
434
|
+
? `${gatewayUrl}/experimental/anthropic`
|
|
435
|
+
: `${gatewayUrl}/anthropic`)
|
|
436
|
+
&& body.inferenceGatewayApiKey === tenantCredential(config.pat, config.tenantId)
|
|
437
|
+
);
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Best-effort config/catalog/skills refresh for installed apps — the
|
|
444
|
+
// background half of "the apps keep themselves current". Safe while the apps
|
|
445
|
+
// run (config writes only, never a bundle swap, never quits anything) and
|
|
446
|
+
// silent on any failure: it is invoked detached from the apps' token helper,
|
|
447
|
+
// so there is no user to talk to and nothing may leak to stdout.
|
|
448
|
+
async function refreshApps(targets, { staleOnly = false } = {}) {
|
|
449
|
+
const stored = loadConfig();
|
|
450
|
+
if (!stored?.pat) return;
|
|
451
|
+
const paths = appPaths(os.homedir(), stored.tenantId || null);
|
|
452
|
+
if (staleOnly && manifestIsFresh(paths, stored)) return;
|
|
453
|
+
|
|
454
|
+
let config;
|
|
455
|
+
try {
|
|
456
|
+
config = await selectedAppConfig(targets);
|
|
457
|
+
} catch {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const statuses = appStatus(targets, os.homedir(), config.tenantId);
|
|
461
|
+
const installedTargets = statuses.filter((status) => status.launcherInstalled);
|
|
462
|
+
if (installedTargets.length === 0) return;
|
|
463
|
+
|
|
464
|
+
let catalog;
|
|
465
|
+
try {
|
|
466
|
+
catalog = await fetchGatewayModels(config);
|
|
467
|
+
} catch {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
installManagedAppFiles({
|
|
471
|
+
config,
|
|
472
|
+
targets: installedTargets.map((status) => status.target),
|
|
473
|
+
models: catalog.models,
|
|
474
|
+
homeDir: os.homedir(),
|
|
475
|
+
vendorPaths: Object.fromEntries(installedTargets.map((status) => [status.target, status.vendorPath])),
|
|
476
|
+
writeBundles: false,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const tenantPaths = appPaths(os.homedir(), config.tenantId);
|
|
480
|
+
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
481
|
+
for (const status of installedTargets) {
|
|
482
|
+
const { client, env, label } = appSkillTarget(status.target, tenantPaths);
|
|
483
|
+
await syncSkillsSafe({ client, gatewayUrl, env, label });
|
|
484
|
+
if (status.target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
|
|
485
|
+
}
|
|
486
|
+
// Keep the update-notice cache warm from the same background slot.
|
|
487
|
+
try {
|
|
488
|
+
refreshUpdateCache();
|
|
489
|
+
} catch {
|
|
490
|
+
// opportunistic only
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function manifestIsFresh(paths, config, now = Date.now()) {
|
|
495
|
+
try {
|
|
496
|
+
const manifest = JSON.parse(
|
|
497
|
+
fs.readFileSync(path.join(paths.tenantRoot, "manifest.json"), "utf8")
|
|
498
|
+
);
|
|
499
|
+
const updatedAt = Date.parse(manifest?.updatedAt || "");
|
|
500
|
+
return Number.isFinite(updatedAt)
|
|
501
|
+
&& manifest?.experiments?.crossAppModels === crossAppModelsEnabled(config)
|
|
502
|
+
&& now - updatedAt < REFRESH_TTL_MS;
|
|
503
|
+
} catch {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function selectedAppConfig(targets) {
|
|
509
|
+
const config = loadConfig();
|
|
510
|
+
if (!config?.pat || !config?.gatewayUrl) throw new Error("run `impel auth` before using Impel apps");
|
|
511
|
+
const selected = await ensureTenantSelection(config, { refresh: true });
|
|
512
|
+
if (!selected.productAccess) {
|
|
513
|
+
throw new Error("the control plane did not return a live product access entitlement; retry after it is upgraded");
|
|
514
|
+
}
|
|
515
|
+
const providers = crossAppModelsEnabled(selected.config)
|
|
516
|
+
? ["claude", "codex"]
|
|
517
|
+
: targets.map((target) => (target === "claude" ? "claude" : "codex"));
|
|
518
|
+
assertProviderScopes(selected.scopes, providers, { requireLive: true });
|
|
519
|
+
const tenantId = selected.tenantId;
|
|
520
|
+
return {
|
|
521
|
+
...config,
|
|
522
|
+
tenantId,
|
|
523
|
+
productAccess: selected.productAccess,
|
|
524
|
+
scopes: selected.scopes,
|
|
525
|
+
pat: tenantCredential(config.pat, tenantId),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function printStatus(targets) {
|
|
530
|
+
const config = loadConfig();
|
|
531
|
+
if (config?.tenantId) console.log(`Tenant: ${config.tenantId}`);
|
|
532
|
+
for (const status of appStatus(targets, os.homedir(), config?.tenantId || null)) {
|
|
533
|
+
console.log(`${status.label}:`);
|
|
534
|
+
console.log(` launcher: ${status.launcherInstalled ? status.launcher : "not installed"}`);
|
|
535
|
+
console.log(` vendor: ${status.vendorPath || "not installed"}${status.vendorVersion ? ` (${status.vendorVersion})` : ""}`);
|
|
536
|
+
console.log(` config: ${status.configPath}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { parseFlags } from "../args.js";
|
|
2
|
+
import {
|
|
3
|
+
loadConfig,
|
|
4
|
+
saveConfig,
|
|
5
|
+
normalizeGatewayUrl,
|
|
6
|
+
resolveDefaultGateway,
|
|
7
|
+
resolveDefaultAppUrl,
|
|
8
|
+
maskSecret,
|
|
9
|
+
} from "../config.js";
|
|
10
|
+
import { promptSecret } from "../prompt.js";
|
|
11
|
+
import { fetchTenants, normalizeTenantId } from "../tenants.js";
|
|
12
|
+
|
|
13
|
+
export async function cmdAuth(argv) {
|
|
14
|
+
const { flags } = parseFlags(argv, {
|
|
15
|
+
pat: { type: "string" },
|
|
16
|
+
gateway: { type: "string" },
|
|
17
|
+
app: { type: "string" },
|
|
18
|
+
tenant: { type: "string" },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const existing = loadConfig();
|
|
22
|
+
|
|
23
|
+
let pat = flags.pat;
|
|
24
|
+
if (!pat) {
|
|
25
|
+
pat = await promptSecret("Impel Personal Access Token (impel_pat_...): ");
|
|
26
|
+
}
|
|
27
|
+
if (!pat) {
|
|
28
|
+
console.error("impel: no PAT provided, aborting.");
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!pat.startsWith("impel_pat_")) {
|
|
33
|
+
console.warn(
|
|
34
|
+
`impel: warning: that token doesn't start with "impel_pat_" — double check you copied the right value.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const gatewayUrl = normalizeGatewayUrl(flags.gateway || existing?.gatewayUrl || resolveDefaultGateway());
|
|
39
|
+
const appUrl = normalizeGatewayUrl(flags.app || existing?.appUrl || resolveDefaultAppUrl());
|
|
40
|
+
|
|
41
|
+
const config = { ...(existing || {}), pat, gatewayUrl, appUrl, updatedAt: new Date().toISOString() };
|
|
42
|
+
try {
|
|
43
|
+
const listing = await fetchTenants(config);
|
|
44
|
+
const requestedTenant = flags.tenant ? normalizeTenantId(flags.tenant) : null;
|
|
45
|
+
const selected = requestedTenant
|
|
46
|
+
? listing.tenants.find((tenant) => tenant.id === requestedTenant || tenant.slug === requestedTenant)
|
|
47
|
+
: null;
|
|
48
|
+
if (requestedTenant && !selected) {
|
|
49
|
+
throw new Error(`tenant "${requestedTenant}" is not available to this user`);
|
|
50
|
+
}
|
|
51
|
+
config.tenantId = selected?.id || (
|
|
52
|
+
listing.tenants.some((tenant) => tenant.id === existing?.tenantId)
|
|
53
|
+
? existing.tenantId
|
|
54
|
+
: listing.defaultTenantId
|
|
55
|
+
);
|
|
56
|
+
if (listing.productAccess) config.productAccess = listing.productAccess;
|
|
57
|
+
else delete config.productAccess;
|
|
58
|
+
if (listing.scopes) config.scopes = listing.scopes;
|
|
59
|
+
else delete config.scopes;
|
|
60
|
+
config.tenantsUpdatedAt = new Date().toISOString();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (flags.tenant) throw error;
|
|
63
|
+
if (existing?.tenantId && existing.pat === pat) {
|
|
64
|
+
config.tenantId = existing.tenantId;
|
|
65
|
+
} else {
|
|
66
|
+
delete config.tenantId;
|
|
67
|
+
delete config.productAccess;
|
|
68
|
+
delete config.scopes;
|
|
69
|
+
}
|
|
70
|
+
console.warn(`impel: tenant discovery deferred (${error.message})`);
|
|
71
|
+
}
|
|
72
|
+
saveConfig(config);
|
|
73
|
+
|
|
74
|
+
console.log(`impel: stored credentials in ~/.config/impel/config.json (mode 0600)`);
|
|
75
|
+
console.log(` gateway: ${gatewayUrl}`);
|
|
76
|
+
console.log(` app: ${appUrl}`);
|
|
77
|
+
console.log(` pat: ${maskSecret(pat)}`);
|
|
78
|
+
console.log(` tenant: ${config.tenantId || "not selected — run `impel tenant list`"}`);
|
|
79
|
+
if (config.productAccess) console.log(` access: ${config.productAccess}`);
|
|
80
|
+
console.log("");
|
|
81
|
+
const setupAction = process.platform === "darwin"
|
|
82
|
+
? "installs the Impel desktop apps"
|
|
83
|
+
: process.platform === "win32"
|
|
84
|
+
? "installs missing vendor CLIs and isolated Claude/ChatGPT Desktop profiles"
|
|
85
|
+
: "prepares the isolated CLI workflow";
|
|
86
|
+
console.log(`Next: run \`impel setup\` to finish onboarding (${setupAction}`);
|
|
87
|
+
console.log(" and verifies the gateway), or jump straight into `impel claude` /");
|
|
88
|
+
console.log(" `impel codex`. Native Claude and Codex profiles remain untouched.");
|
|
89
|
+
}
|