impel-cli 0.11.1 → 0.12.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 +33 -12
- package/package.json +1 -1
- package/src/agents.js +43 -9
- package/src/apps.js +190 -7
- package/src/cli.js +2 -1
- package/src/commands/apps.js +138 -47
- package/src/commands/skills.js +19 -8
- package/src/commands/update.js +13 -8
- package/src/progress.js +85 -0
package/README.md
CHANGED
|
@@ -152,7 +152,9 @@ impel agents sync [claude|codex|all] Sync explicit tenant agents into na
|
|
|
152
152
|
|
|
153
153
|
impel status Launcher readiness + native mode + gateway reachability
|
|
154
154
|
impel app install [target] [--tenant <org>] Install isolated apps for one tenant
|
|
155
|
-
impel app update [target] [--tenant <org>]
|
|
155
|
+
impel app update [target] [--tenant <org>] [--force]
|
|
156
|
+
Refresh configs/models and rebuild only stale bundles;
|
|
157
|
+
--force performs a clean full rebuild
|
|
156
158
|
impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
|
|
157
159
|
(--stale-only: no-op unless 6h+ since last sync)
|
|
158
160
|
impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
|
|
@@ -237,9 +239,10 @@ before running another protected command.
|
|
|
237
239
|
One command brings everything current, in dependency order: the CLI itself
|
|
238
240
|
(`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
|
|
239
241
|
then — re-executing the freshly installed build — `impel app update all`
|
|
240
|
-
(
|
|
242
|
+
(install the CLI-pinned vendor builds and rebuild only stale vendored Impel apps)
|
|
241
243
|
followed by `impel skills sync all` and `impel agents sync all` across every
|
|
242
|
-
managed profile.
|
|
244
|
+
managed profile. The app cascade skips its own skills/agents pass so those
|
|
245
|
+
profiles are synced once, in the dedicated bounded-concurrency steps.
|
|
243
246
|
|
|
244
247
|
Update discovery compares the installed package version with npm's public
|
|
245
248
|
`latest` metadata, cached for 6 hours in
|
|
@@ -608,6 +611,12 @@ MCP subprocess definition so they also work in isolated desktop profiles. They
|
|
|
608
611
|
never contain the PAT; the subprocess reads it from Impel's owner-only config
|
|
609
612
|
when invoked.
|
|
610
613
|
|
|
614
|
+
Visible agent names come from the registry title. Codex keeps that title as the
|
|
615
|
+
custom-agent name; Claude uses the lowercase, hyphenated form required by its
|
|
616
|
+
native agent schema. The generated filenames remain stable tenant-and-agent
|
|
617
|
+
identifiers so title changes update in place without colliding with user files.
|
|
618
|
+
Duplicate or built-in-reserved titles receive a deterministic disambiguator.
|
|
619
|
+
|
|
611
620
|
The sync writes atomically and records a manifest beside the generated files.
|
|
612
621
|
It removes only stale filenames from that manifest, so user-authored agents are
|
|
613
622
|
never deleted. Automatic refreshes are best-effort and retain the last good
|
|
@@ -623,13 +632,13 @@ Invocation uses the clients' native agent behavior:
|
|
|
623
632
|
|
|
624
633
|
```text
|
|
625
634
|
# Claude Code / Claude Desktop Code tab: guaranteed explicit selection
|
|
626
|
-
@agent-
|
|
635
|
+
@agent-research-agent investigate the dependency change
|
|
627
636
|
|
|
628
637
|
# Claude blocking CLI
|
|
629
|
-
claude --agent
|
|
638
|
+
claude --agent research-agent -p "investigate the dependency change"
|
|
630
639
|
|
|
631
640
|
# Codex CLI / ChatGPT desktop Codex task
|
|
632
|
-
Explicitly use the configured
|
|
641
|
+
Explicitly use the configured Research Agent for this request and wait for it.
|
|
633
642
|
```
|
|
634
643
|
|
|
635
644
|
Claude exposes native custom agents in its `@` typeahead. Current Codex releases
|
|
@@ -698,13 +707,17 @@ a stable, unique bundle identifier and gateway configuration:
|
|
|
698
707
|
- Impel Claude is an APFS-cloned vendored copy of the official app with its own
|
|
699
708
|
bundle and helper identities. Its LaunchServices environment sets the
|
|
700
709
|
vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway
|
|
701
|
-
`configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
710
|
+
`configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A new
|
|
711
|
+
isolated profile defaults to the Code app while preserving any app selection
|
|
712
|
+
the user makes afterward. A narrowly version-checked compatibility patch
|
|
713
|
+
connects Claude's native plan-usage meter to the authenticated aggregate
|
|
714
|
+
Claude subscription pool exposed by the Impel gateway.
|
|
705
715
|
- Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
|
|
706
|
-
OpenAI-signed bundle and executable.
|
|
707
|
-
|
|
716
|
+
OpenAI-signed bundle and executable. A wrapper-owned startup preload pins
|
|
717
|
+
Electron's application name before the native menu is created, so macOS
|
|
718
|
+
shows `Impel ChatGPT (<org>)` without changing OpenAI's signed code. Its outer
|
|
719
|
+
resource root contains the narrowly patched renderer ASAR used at runtime.
|
|
720
|
+
The wrapper sets `CODEX_HOME`,
|
|
708
721
|
passes a separate Chromium `--user-data-dir`, and writes its provider config
|
|
709
722
|
and model catalog below `~/.config/impel/apps/tenants/<org>/chatgpt`. It also supplies a
|
|
710
723
|
process-scoped, Codex-compatible wrapper around the Impel PAT so native
|
|
@@ -728,6 +741,14 @@ Updates and uninstalls touch only the named tenant variant. The older global
|
|
|
728
741
|
`Impel Claude.app` / `Impel ChatGPT.app` launcher is removed after the first
|
|
729
742
|
successful tenant-specific rebuild.
|
|
730
743
|
|
|
744
|
+
`impel app update` compares the installed bundle's CLI version, pinned vendor
|
|
745
|
+
version, identity, profile paths, and ASAR compatibility hash before touching
|
|
746
|
+
it. A current bundle is not closed, cloned, patched, or codesigned; its config
|
|
747
|
+
and model catalog are still refreshed. If an interrupted update left a machine
|
|
748
|
+
in a bad state, `impel app update --force` removes the selected launcher and
|
|
749
|
+
performs a clean rebuild while sweeping abandoned `.tmp-*` and `.previous-*`
|
|
750
|
+
staging directories.
|
|
751
|
+
|
|
731
752
|
The official Claude and ChatGPT apps remain separate installations and keep
|
|
732
753
|
their normal accounts, updater, profiles, and bundle identities. Each CLI
|
|
733
754
|
release pins exact, tested vendor builds (v0.10.0 pins Claude `1.20186.9` and
|
package/package.json
CHANGED
package/src/agents.js
CHANGED
|
@@ -29,6 +29,10 @@ const NATIVE_AGENT_TOOL_NAMES = [
|
|
|
29
29
|
const SAFE_AGENT_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
|
|
30
30
|
const SAFE_SCOPE_PARAM_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
|
|
31
31
|
const MAX_CATALOG_ITEMS = 500;
|
|
32
|
+
const RESERVED_AGENT_NAMES = Object.freeze({
|
|
33
|
+
claude: new Set(["explore", "general-purpose", "plan"]),
|
|
34
|
+
codex: new Set(["default", "explorer", "worker"]),
|
|
35
|
+
});
|
|
32
36
|
|
|
33
37
|
function privateDirectory(directory) {
|
|
34
38
|
if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) {
|
|
@@ -262,29 +266,58 @@ export async function fetchNativeAgentCatalog({
|
|
|
262
266
|
);
|
|
263
267
|
}
|
|
264
268
|
|
|
265
|
-
function slug(value) {
|
|
269
|
+
function slug(value, fallback = "agent") {
|
|
266
270
|
return String(value)
|
|
267
271
|
.toLowerCase()
|
|
268
272
|
.replace(/[^a-z0-9]+/gu, "-")
|
|
269
273
|
.replace(/^-+|-+$/gu, "")
|
|
270
|
-
.slice(0, 48) ||
|
|
274
|
+
.slice(0, 48) || fallback;
|
|
271
275
|
}
|
|
272
276
|
|
|
273
|
-
function
|
|
277
|
+
function agentBindingHash(agent) {
|
|
278
|
+
return crypto.createHash("sha256")
|
|
279
|
+
.update(`${agent.scopeParam}:${agent.agentId}`)
|
|
280
|
+
.digest("hex")
|
|
281
|
+
.slice(0, 8);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function generatedAgentFileStems(tenantId, agents) {
|
|
274
285
|
const used = new Map();
|
|
275
286
|
return agents.map((agent) => {
|
|
276
287
|
const base = `impel-${slug(tenantId)}-${slug(agent.agentId)}`.slice(0, 63).replace(/-+$/u, "");
|
|
277
288
|
const previous = used.get(base);
|
|
278
289
|
used.set(base, (previous || 0) + 1);
|
|
279
290
|
if (!previous) return base;
|
|
280
|
-
const suffix =
|
|
281
|
-
.update(`${agent.scopeParam}:${agent.agentId}`)
|
|
282
|
-
.digest("hex")
|
|
283
|
-
.slice(0, 8);
|
|
291
|
+
const suffix = agentBindingHash(agent);
|
|
284
292
|
return `${base.slice(0, 54)}-${suffix}`;
|
|
285
293
|
});
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
function generatedClientAgentNames(client, agents) {
|
|
297
|
+
const claudeNames = agents.map((agent) => slug(agent.title, slug(agent.agentId)));
|
|
298
|
+
const collisionKeys = agents.map((agent, index) => client === "claude"
|
|
299
|
+
? claudeNames[index]
|
|
300
|
+
: slug(agent.title, agent.title.normalize("NFKC").toLocaleLowerCase("en-US")));
|
|
301
|
+
const collisionKeyCounts = collisionKeys.reduce((counts, key) => {
|
|
302
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
303
|
+
return counts;
|
|
304
|
+
}, new Map());
|
|
305
|
+
|
|
306
|
+
return agents.map((agent, index) => {
|
|
307
|
+
const visualKey = claudeNames[index];
|
|
308
|
+
const duplicate = collisionKeyCounts.get(collisionKeys[index]) > 1;
|
|
309
|
+
const reserved = RESERVED_AGENT_NAMES[client].has(visualKey);
|
|
310
|
+
if (client === "claude") {
|
|
311
|
+
const base = reserved ? `impel-${visualKey}` : visualKey;
|
|
312
|
+
if (duplicate) return `${base.slice(0, 54)}-${agentBindingHash(agent)}`;
|
|
313
|
+
return base;
|
|
314
|
+
}
|
|
315
|
+
const base = reserved ? `Impel ${agent.title}` : agent.title;
|
|
316
|
+
if (duplicate) return `${base} (${agentBindingHash(agent)})`;
|
|
317
|
+
return base;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
288
321
|
function adapterInstructions(tenantId, agent) {
|
|
289
322
|
const toolName = nativeToolName;
|
|
290
323
|
const contextRequirement = agent.requiredContext.length
|
|
@@ -360,14 +393,15 @@ function renderCodexAgent({ tenantId, agent, name, invocation }) {
|
|
|
360
393
|
export function renderManagedAgents(client, tenantId, agents, invocation = impelMcpInvocation(["--tenant", tenantId])) {
|
|
361
394
|
if (client !== "claude" && client !== "codex") throw new Error(`unknown agent client ${client}`);
|
|
362
395
|
const normalizedTenant = normalizeTenantId(tenantId);
|
|
363
|
-
const
|
|
396
|
+
const fileStems = generatedAgentFileStems(normalizedTenant, agents);
|
|
397
|
+
const names = generatedClientAgentNames(client, agents);
|
|
364
398
|
return agents.map((agent, index) => {
|
|
365
399
|
const name = names[index];
|
|
366
400
|
const extension = client === "claude" ? ".md" : ".toml";
|
|
367
401
|
const contents = client === "claude"
|
|
368
402
|
? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation })
|
|
369
403
|
: renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation });
|
|
370
|
-
return { agentId: agent.agentId, name, fileName: `${
|
|
404
|
+
return { agentId: agent.agentId, name, fileName: `${fileStems[index]}${extension}`, contents };
|
|
371
405
|
});
|
|
372
406
|
}
|
|
373
407
|
|
package/src/apps.js
CHANGED
|
@@ -142,7 +142,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
|
142
142
|
|
|
143
143
|
// Bump when the written config/manifest schema changes; a mismatch forces the
|
|
144
144
|
// slow open path (and thus a full config rewrite) after a CLI update.
|
|
145
|
-
export const CURRENT_CONFIG_VERSION =
|
|
145
|
+
export const CURRENT_CONFIG_VERSION = 10;
|
|
146
146
|
|
|
147
147
|
/** Parse the tenant's install manifest, or null when absent/corrupt. */
|
|
148
148
|
export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
|
|
@@ -193,6 +193,7 @@ export function bundleIsCurrent(status) {
|
|
|
193
193
|
compatibility.vendorSignaturesPreserved === true
|
|
194
194
|
&& compatibility.resourceRoot === CHATGPT_RESOURCE_ROOT
|
|
195
195
|
&& compatibility.patches?.fastModeForGatewayAuth > 0
|
|
196
|
+
&& compatibility.patches?.tenantDisplayNamePreload === 1
|
|
196
197
|
)),
|
|
197
198
|
);
|
|
198
199
|
}
|
|
@@ -378,7 +379,10 @@ export function installManagedAppFiles({
|
|
|
378
379
|
|
|
379
380
|
const installed = [];
|
|
380
381
|
for (const target of targets) {
|
|
381
|
-
if (target === "claude")
|
|
382
|
+
if (target === "claude") {
|
|
383
|
+
writeClaudeDefaultApp(paths);
|
|
384
|
+
writeClaudeConfig(paths, config, models);
|
|
385
|
+
}
|
|
382
386
|
else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
|
|
383
387
|
// Non-bundle targets are the background-refresh / fast-open path: configs,
|
|
384
388
|
// token helper, catalog, and manifest only. Bundle swaps require the app
|
|
@@ -549,6 +553,7 @@ function installPinnedVendorApp(target, homeDir) {
|
|
|
549
553
|
homeDir, ".config", "impel", "vendor", target, pin.version, pin.bundleName,
|
|
550
554
|
);
|
|
551
555
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
556
|
+
sweepStaleBundleArtifacts(path.dirname(destination), path.basename(destination));
|
|
552
557
|
replaceDirectory(destination, extractedApp);
|
|
553
558
|
return destination;
|
|
554
559
|
} finally {
|
|
@@ -645,6 +650,33 @@ function writeClaudeConfig(paths, config, models) {
|
|
|
645
650
|
}, null, 2) + "\n", 0o600);
|
|
646
651
|
}
|
|
647
652
|
|
|
653
|
+
function writeClaudeDefaultApp(paths) {
|
|
654
|
+
const configPath = path.join(paths.claude.userData, "claude_desktop_config.json");
|
|
655
|
+
let current = {};
|
|
656
|
+
if (fs.existsSync(configPath)) {
|
|
657
|
+
try {
|
|
658
|
+
current = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
659
|
+
} catch {
|
|
660
|
+
throw new Error(`Claude desktop config is not valid JSON: ${configPath}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
|
664
|
+
throw new Error(`Claude desktop config must contain a JSON object: ${configPath}`);
|
|
665
|
+
}
|
|
666
|
+
const preferences = current.preferences ?? {};
|
|
667
|
+
if (!preferences || typeof preferences !== "object" || Array.isArray(preferences)) {
|
|
668
|
+
throw new Error(`Claude desktop preferences must contain a JSON object: ${configPath}`);
|
|
669
|
+
}
|
|
670
|
+
// Claude persists the selected top-level app as `preferences.sidebarMode`.
|
|
671
|
+
// Seed Code for a new isolated profile, then leave subsequent user choices
|
|
672
|
+
// alone during install, update, and background config refreshes.
|
|
673
|
+
if (Object.hasOwn(preferences, "sidebarMode")) return;
|
|
674
|
+
writeAtomic(configPath, JSON.stringify({
|
|
675
|
+
...current,
|
|
676
|
+
preferences: { ...preferences, sidebarMode: "code" },
|
|
677
|
+
}, null, 2) + "\n", 0o600);
|
|
678
|
+
}
|
|
679
|
+
|
|
648
680
|
function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocations = null) {
|
|
649
681
|
const experimental = crossAppModelsEnabled(config);
|
|
650
682
|
const orderedModels = experimental
|
|
@@ -940,6 +972,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
|
940
972
|
|
|
941
973
|
const bundle = paths.claude.launcher;
|
|
942
974
|
const staging = `${bundle}.tmp-${process.pid}`;
|
|
975
|
+
sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
|
|
943
976
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
944
977
|
cloneAppBundle(vendorPath, staging);
|
|
945
978
|
try {
|
|
@@ -1026,6 +1059,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1026
1059
|
const staging = `${bundle}.tmp-${process.pid}`;
|
|
1027
1060
|
const vendorBundleName = path.basename(vendorPath);
|
|
1028
1061
|
const vendorBundle = path.join(staging, "Contents", "Resources", vendorBundleName);
|
|
1062
|
+
sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
|
|
1029
1063
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
1030
1064
|
cloneAppBundle(vendorPath, vendorBundle);
|
|
1031
1065
|
try {
|
|
@@ -1060,6 +1094,11 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1060
1094
|
vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl),
|
|
1061
1095
|
0o755,
|
|
1062
1096
|
);
|
|
1097
|
+
writeAtomic(
|
|
1098
|
+
path.join(staging, "Contents", "Resources", "impel-chatgpt-preload.cjs"),
|
|
1099
|
+
vendoredChatGPTDisplayNamePreload(),
|
|
1100
|
+
0o644,
|
|
1101
|
+
);
|
|
1063
1102
|
writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
|
|
1064
1103
|
schemaVersion: 2,
|
|
1065
1104
|
cliVersion: CLI_VERSION,
|
|
@@ -1070,7 +1109,11 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1070
1109
|
profileRoot: paths.chatgpt.root,
|
|
1071
1110
|
codexHome: paths.chatgpt.codexHome,
|
|
1072
1111
|
browserData: paths.chatgpt.browserData,
|
|
1073
|
-
patches: {
|
|
1112
|
+
patches: {
|
|
1113
|
+
fastModeForGatewayAuth: fastModePatchCount,
|
|
1114
|
+
desktopAPIForGatewayAuth: 0,
|
|
1115
|
+
tenantDisplayNamePreload: 1,
|
|
1116
|
+
},
|
|
1074
1117
|
vendorSignaturesPreserved: true,
|
|
1075
1118
|
resourceRoot: CHATGPT_RESOURCE_ROOT,
|
|
1076
1119
|
asarHeaderSha256: asarHash,
|
|
@@ -1384,14 +1427,138 @@ unset TOKEN
|
|
|
1384
1427
|
secure_codex_home
|
|
1385
1428
|
BROWSER_DATA=${shellQuote(paths.chatgpt.browserData)}
|
|
1386
1429
|
mkdir -p "$BROWSER_DATA"
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
#
|
|
1430
|
+
export IMPEL_APP_DISPLAY_NAME=${shellQuote(paths.chatgpt.displayName)}
|
|
1431
|
+
PRELOAD="$HERE/../Resources/impel-chatgpt-preload.cjs"
|
|
1432
|
+
export NODE_OPTIONS="--require=\\\"$PRELOAD\\\""
|
|
1433
|
+
# Electron resolves resources from the Impel wrapper when the nested app is
|
|
1434
|
+
# executed directly. The wrapper exposes the vendor resource tree there while
|
|
1435
|
+
# preserving the nested OpenAI-signed executable and bundle. The startup
|
|
1436
|
+
# preload pins Electron's application name before its native menu is created.
|
|
1391
1437
|
exec "$VENDOR_APP/Contents/MacOS/${executableName}" --user-data-dir="$BROWSER_DATA" "$@"
|
|
1392
1438
|
`;
|
|
1393
1439
|
}
|
|
1394
1440
|
|
|
1441
|
+
function vendoredChatGPTDisplayNamePreload() {
|
|
1442
|
+
return `"use strict";
|
|
1443
|
+
|
|
1444
|
+
const displayName = process.env.IMPEL_APP_DISPLAY_NAME;
|
|
1445
|
+
if (process.type === "browser" && displayName) {
|
|
1446
|
+
const Module = require("node:module");
|
|
1447
|
+
const path = require("node:path");
|
|
1448
|
+
const load = Module._load;
|
|
1449
|
+
let objc;
|
|
1450
|
+
let appKit;
|
|
1451
|
+
let launchServicesNamed = false;
|
|
1452
|
+
let activationPolicyRefreshed = false;
|
|
1453
|
+
|
|
1454
|
+
const loadNativeBridge = () => {
|
|
1455
|
+
objc ||= require(path.join(
|
|
1456
|
+
process.resourcesPath,
|
|
1457
|
+
"app.asar.unpacked/node_modules/objc-js/dist/index.js",
|
|
1458
|
+
));
|
|
1459
|
+
appKit ||= new objc.NobjcLibrary("/System/Library/Frameworks/AppKit.framework/AppKit");
|
|
1460
|
+
return { objc, appKit };
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1463
|
+
const setLaunchServicesApplicationName = () => {
|
|
1464
|
+
if (launchServicesNamed) return;
|
|
1465
|
+
try {
|
|
1466
|
+
const native = loadNativeBridge();
|
|
1467
|
+
new native.objc.NobjcLibrary(
|
|
1468
|
+
"/System/Library/Frameworks/ApplicationServices.framework/Versions/A/ApplicationServices",
|
|
1469
|
+
);
|
|
1470
|
+
const title = native.appKit.NSString.stringWithUTF8String$(displayName);
|
|
1471
|
+
const info = native.appKit.NSMutableDictionary.dictionaryWithDictionary$(
|
|
1472
|
+
native.appKit.NSBundle.mainBundle().infoDictionary(),
|
|
1473
|
+
);
|
|
1474
|
+
for (const key of ["CFBundleName", "CFBundleDisplayName"]) {
|
|
1475
|
+
info.setObject$forKey$(title, native.appKit.NSString.stringWithUTF8String$(key));
|
|
1476
|
+
}
|
|
1477
|
+
native.objc.callFunction(
|
|
1478
|
+
"_LSSetApplicationLaunchServicesServerConnectionStatus",
|
|
1479
|
+
{ returns: "v", args: ["Q", "@"] },
|
|
1480
|
+
0,
|
|
1481
|
+
null,
|
|
1482
|
+
);
|
|
1483
|
+
const checked = native.objc.callFunction(
|
|
1484
|
+
"_LSApplicationCheckIn",
|
|
1485
|
+
{ returns: "@", args: ["i", "@"] },
|
|
1486
|
+
-2,
|
|
1487
|
+
info,
|
|
1488
|
+
);
|
|
1489
|
+
const asn = native.objc.callFunction("_LSGetCurrentApplicationASN", { returns: "@" });
|
|
1490
|
+
if (!checked || !asn) return;
|
|
1491
|
+
const status = native.objc.callFunction(
|
|
1492
|
+
"_LSSetApplicationInformationItem",
|
|
1493
|
+
{ returns: "i", args: ["i", "@", "@", "@", "@"] },
|
|
1494
|
+
-2,
|
|
1495
|
+
asn,
|
|
1496
|
+
native.appKit.NSString.stringWithUTF8String$("LSDisplayName"),
|
|
1497
|
+
title,
|
|
1498
|
+
null,
|
|
1499
|
+
);
|
|
1500
|
+
launchServicesNamed = status === 0;
|
|
1501
|
+
} catch {
|
|
1502
|
+
// Keep the signed vendor app usable if LaunchServices changes this
|
|
1503
|
+
// private-but-established process-title API in a future macOS release.
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
const setNativeApplicationMenuTitle = () => {
|
|
1508
|
+
try {
|
|
1509
|
+
const native = loadNativeBridge();
|
|
1510
|
+
const title = native.appKit.NSString.stringWithUTF8String$(displayName);
|
|
1511
|
+
native.appKit.NSProcessInfo.processInfo().setProcessName$(title);
|
|
1512
|
+
const application = native.appKit.NSApplication.sharedApplication();
|
|
1513
|
+
application.setAccessibilityTitle$(title);
|
|
1514
|
+
const menu = application.mainMenu();
|
|
1515
|
+
if (menu && menu.numberOfItems() > 0) {
|
|
1516
|
+
const item = menu.itemAtIndex$(0);
|
|
1517
|
+
item.setTitle$(title);
|
|
1518
|
+
item.setAccessibilityTitle$(title);
|
|
1519
|
+
item.setAccessibilityLabel$(title);
|
|
1520
|
+
}
|
|
1521
|
+
} catch {
|
|
1522
|
+
// The OpenAI-signed native bridge is version-specific. Leave the app
|
|
1523
|
+
// usable if a future vendor build removes it; compatibility metadata
|
|
1524
|
+
// will force the next CLI version to rebuild the wrapper.
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
|
|
1528
|
+
Module._load = function (request) {
|
|
1529
|
+
const loaded = load.apply(this, arguments);
|
|
1530
|
+
if (request === "electron" && loaded?.app) {
|
|
1531
|
+
Module._load = load;
|
|
1532
|
+
const setName = loaded.app.setName.bind(loaded.app);
|
|
1533
|
+
setName(displayName);
|
|
1534
|
+
loaded.app.setName = () => setName(displayName);
|
|
1535
|
+
setLaunchServicesApplicationName();
|
|
1536
|
+
setNativeApplicationMenuTitle();
|
|
1537
|
+
const setApplicationMenu = loaded.Menu.setApplicationMenu.bind(loaded.Menu);
|
|
1538
|
+
loaded.Menu.setApplicationMenu = (menu) => {
|
|
1539
|
+
const result = setApplicationMenu(menu);
|
|
1540
|
+
setNativeApplicationMenuTitle();
|
|
1541
|
+
if (!activationPolicyRefreshed) {
|
|
1542
|
+
activationPolicyRefreshed = true;
|
|
1543
|
+
loaded.app.setActivationPolicy("accessory");
|
|
1544
|
+
setTimeout(() => {
|
|
1545
|
+
loaded.app.setActivationPolicy("regular");
|
|
1546
|
+
launchServicesNamed = false;
|
|
1547
|
+
setLaunchServicesApplicationName();
|
|
1548
|
+
setApplicationMenu(menu);
|
|
1549
|
+
setNativeApplicationMenuTitle();
|
|
1550
|
+
}, 0);
|
|
1551
|
+
}
|
|
1552
|
+
setTimeout(setNativeApplicationMenuTitle, 0);
|
|
1553
|
+
return result;
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
return loaded;
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
`;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1395
1562
|
function signVendoredApp(bundle, vendorExecutable, label) {
|
|
1396
1563
|
if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return;
|
|
1397
1564
|
spawnSync("/usr/bin/xattr", ["-dr", "com.apple.quarantine", bundle], { stdio: "ignore" });
|
|
@@ -1519,6 +1686,22 @@ function sha256(contents) {
|
|
|
1519
1686
|
return crypto.createHash("sha256").update(contents).digest("hex");
|
|
1520
1687
|
}
|
|
1521
1688
|
|
|
1689
|
+
export function sweepStaleBundleArtifacts(dir, baseName) {
|
|
1690
|
+
let entries;
|
|
1691
|
+
try {
|
|
1692
|
+
entries = fs.readdirSync(dir);
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
if (error?.code === "ENOENT") return [];
|
|
1695
|
+
throw error;
|
|
1696
|
+
}
|
|
1697
|
+
const prefixes = [`${baseName}.tmp-`, `${baseName}.previous-`];
|
|
1698
|
+
const removed = entries.filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)));
|
|
1699
|
+
for (const entry of removed) {
|
|
1700
|
+
fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
|
|
1701
|
+
}
|
|
1702
|
+
return removed;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1522
1705
|
function replaceDirectory(destination, staging) {
|
|
1523
1706
|
const backup = `${destination}.previous-${process.pid}`;
|
|
1524
1707
|
fs.rmSync(backup, { recursive: true, force: true });
|
package/src/cli.js
CHANGED
|
@@ -49,7 +49,8 @@ Manage:
|
|
|
49
49
|
Desktop apps (macOS and Windows):
|
|
50
50
|
target is claude, chatgpt/codex, or all (default: all)
|
|
51
51
|
impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
|
|
52
|
-
impel app update [target] [--tenant <org>] Update
|
|
52
|
+
impel app update [target] [--tenant <org>] Update profiles; rebuild only stale bundles
|
|
53
|
+
(--force performs a clean full rebuild)
|
|
53
54
|
impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
|
|
54
55
|
impel app status [target] [--tenant <org>] Show isolated app and vendor versions
|
|
55
56
|
impel app uninstall [target] [--tenant <org>] [--keep-data]
|
package/src/commands/apps.js
CHANGED
|
@@ -40,6 +40,7 @@ import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
|
|
|
40
40
|
import { syncAgentProfilesSafe } from "../agents.js";
|
|
41
41
|
import { secureManagedCodexHome } from "../codexSecurity.js";
|
|
42
42
|
import { impelCliInvocation } from "../selfInvocation.js";
|
|
43
|
+
import { withProgress } from "../progress.js";
|
|
43
44
|
import {
|
|
44
45
|
ensureWindowsClaudeApp,
|
|
45
46
|
ensureWindowsChatGPTApp,
|
|
@@ -85,6 +86,20 @@ function windowsProcessFailure(result) {
|
|
|
85
86
|
return "no exit status";
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
export function openManagedLauncher(launcher, {
|
|
90
|
+
spawn = spawnSync,
|
|
91
|
+
reportError = console.error,
|
|
92
|
+
} = {}) {
|
|
93
|
+
const result = spawn("/usr/bin/open", ["-n", launcher], { encoding: "utf8" });
|
|
94
|
+
if (!result?.error && result?.status === 0) return true;
|
|
95
|
+
const detail = result?.error?.message
|
|
96
|
+
|| String(result?.stderr || "").trim()
|
|
97
|
+
|| (Number.isInteger(result?.status) ? `exit code ${result.status}` : "no exit status");
|
|
98
|
+
reportError(`impel app: could not open ${launcher}: ${redactSecretText(detail)}`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
async function fetchWindowsCatalog(config, io) {
|
|
89
104
|
try {
|
|
90
105
|
return await io.fetchModels(config);
|
|
@@ -123,8 +138,11 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
123
138
|
const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
|
|
124
139
|
const { flags } = parseFlags(flagArgs, {
|
|
125
140
|
"skip-vendor": { type: "boolean" },
|
|
141
|
+
"skip-skills": { type: "boolean" },
|
|
142
|
+
"skip-agents": { type: "boolean" },
|
|
126
143
|
"keep-data": { type: "boolean" },
|
|
127
144
|
"stale-only": { type: "boolean" },
|
|
145
|
+
force: { type: "boolean" },
|
|
128
146
|
tenant: { type: "string" },
|
|
129
147
|
});
|
|
130
148
|
const io = {
|
|
@@ -253,7 +271,9 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
253
271
|
if (targets.includes("claude") && action !== "refresh" && !vendorPaths.claude) {
|
|
254
272
|
throw new Error("Claude vendor app is unavailable; run `impel app install claude` or install it from https://claude.com/download");
|
|
255
273
|
}
|
|
256
|
-
const catalog = await
|
|
274
|
+
const catalog = await withProgress("Fetching the tenant model catalog", () => (
|
|
275
|
+
fetchWindowsCatalog(config, io)
|
|
276
|
+
));
|
|
257
277
|
let managedChatGPT = targets.includes("chatgpt")
|
|
258
278
|
? io.findManagedChatGPTApp(actionPaths.root)
|
|
259
279
|
: null;
|
|
@@ -283,15 +303,21 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
283
303
|
const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
|
|
284
304
|
console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
|
|
285
305
|
const { client, env, label } = appSkillTarget(target, tenantPaths);
|
|
286
|
-
|
|
306
|
+
if (!flags["skip-skills"]) {
|
|
307
|
+
await withProgress(`Syncing skills for ${label}`, () => (
|
|
308
|
+
io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label })
|
|
309
|
+
));
|
|
310
|
+
}
|
|
287
311
|
if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
|
|
288
312
|
}
|
|
289
|
-
|
|
290
|
-
profiles
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
313
|
+
if (!flags["skip-agents"]) {
|
|
314
|
+
await withProgress("Syncing app agent profiles", () => io.syncAgents({
|
|
315
|
+
profiles: targets.map((target) => appAgentProfile(target, tenantPaths)),
|
|
316
|
+
gatewayUrl: config.gatewayUrl,
|
|
317
|
+
credential: config.pat,
|
|
318
|
+
tenantId: config.tenantId,
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
295
321
|
console.log(`Models: ${catalog.models.length} from ${catalog.source}. The signed vendor apps and their normal profiles were not changed.`);
|
|
296
322
|
}
|
|
297
323
|
if (action === "open") {
|
|
@@ -320,8 +346,9 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
320
346
|
}
|
|
321
347
|
|
|
322
348
|
export async function cmdApps(argv, overrides = {}) {
|
|
323
|
-
|
|
324
|
-
if (
|
|
349
|
+
const platform = overrides.platform || process.platform;
|
|
350
|
+
if (platform === "win32") return cmdWindowsApps(argv, overrides);
|
|
351
|
+
if (platform !== "darwin") {
|
|
325
352
|
console.error("impel app: isolated Impel desktop apps are unavailable on this platform.");
|
|
326
353
|
process.exitCode = 1;
|
|
327
354
|
return;
|
|
@@ -332,8 +359,11 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
332
359
|
const flagArgs = targetToken?.startsWith("--") ? [targetToken, ...rest] : rest;
|
|
333
360
|
const { flags } = parseFlags(flagArgs, {
|
|
334
361
|
"skip-vendor": { type: "boolean" },
|
|
362
|
+
"skip-skills": { type: "boolean" },
|
|
363
|
+
"skip-agents": { type: "boolean" },
|
|
335
364
|
"keep-data": { type: "boolean" },
|
|
336
365
|
"stale-only": { type: "boolean" },
|
|
366
|
+
force: { type: "boolean" },
|
|
337
367
|
tenant: { type: "string" },
|
|
338
368
|
});
|
|
339
369
|
|
|
@@ -382,7 +412,7 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
382
412
|
if (fastLaunchers) {
|
|
383
413
|
spawnDetachedAppRefresh(tenantId);
|
|
384
414
|
for (const launcher of fastLaunchers) {
|
|
385
|
-
|
|
415
|
+
openManagedLauncher(launcher);
|
|
386
416
|
}
|
|
387
417
|
return;
|
|
388
418
|
}
|
|
@@ -401,55 +431,109 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
401
431
|
throw new Error(`unknown app action "${action}"; use install, update, refresh, status, open, or uninstall`);
|
|
402
432
|
}
|
|
403
433
|
|
|
404
|
-
const
|
|
434
|
+
const io = {
|
|
435
|
+
homeDir: os.homedir(),
|
|
436
|
+
selectedConfig: selectedAppConfig,
|
|
437
|
+
status: appStatus,
|
|
438
|
+
bundleCurrent: bundleIsCurrent,
|
|
439
|
+
ensureVendor: ensureVendorApp,
|
|
440
|
+
fetchModels: fetchGatewayModels,
|
|
441
|
+
quitApps: quitBlockingApps,
|
|
442
|
+
installFiles: installManagedAppFiles,
|
|
443
|
+
syncSkills: syncSkillsSafe,
|
|
444
|
+
syncAgents: syncAgentProfilesSafe,
|
|
445
|
+
secureCodexHome: secureManagedCodexHome,
|
|
446
|
+
removeLauncher: (launcher) => fs.rmSync(launcher, { recursive: true, force: true }),
|
|
447
|
+
log: (message) => console.log(message),
|
|
448
|
+
...overrides,
|
|
449
|
+
};
|
|
450
|
+
const config = await io.selectedConfig(targets, flags.tenant || null);
|
|
405
451
|
console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
|
|
406
452
|
|
|
407
|
-
// A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
|
|
408
|
-
await quitBlockingApps(targets, {
|
|
409
|
-
tenantId: config.tenantId,
|
|
410
|
-
tenantName: config.tenantName,
|
|
411
|
-
});
|
|
412
|
-
|
|
413
453
|
const vendorPaths = {};
|
|
414
454
|
if (!flags["skip-vendor"]) {
|
|
415
455
|
for (const target of targets) {
|
|
416
456
|
// Reuse or install only the exact vendor build verified by this CLI.
|
|
417
457
|
// Moving "latest" releases are never cloned into a managed bundle.
|
|
418
|
-
const result =
|
|
458
|
+
const result = await withProgress(`Preparing the verified ${target} vendor app`, () => (
|
|
459
|
+
io.ensureVendor(target, { homeDir: io.homeDir })
|
|
460
|
+
));
|
|
419
461
|
vendorPaths[target] = result.path;
|
|
420
|
-
|
|
462
|
+
io.log(`${target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
|
|
421
463
|
if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
|
|
422
464
|
}
|
|
423
465
|
}
|
|
424
466
|
|
|
467
|
+
const statuses = io.status(
|
|
468
|
+
targets,
|
|
469
|
+
io.homeDir,
|
|
470
|
+
config.tenantId,
|
|
471
|
+
config.tenantName,
|
|
472
|
+
);
|
|
473
|
+
for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
|
|
474
|
+
const force = Boolean(flags.force);
|
|
475
|
+
const staleBundleTargets = force
|
|
476
|
+
? [...targets]
|
|
477
|
+
: statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
|
|
478
|
+
|
|
425
479
|
let catalog;
|
|
426
480
|
try {
|
|
427
|
-
catalog = await
|
|
481
|
+
catalog = await withProgress("Fetching the tenant model catalog", () => io.fetchModels(config));
|
|
428
482
|
} catch (error) {
|
|
429
483
|
throw new Error(
|
|
430
484
|
`tenant model catalog is unavailable (${redactSecretText(error.message)}); no Impel app files were changed`,
|
|
431
485
|
);
|
|
432
486
|
}
|
|
433
|
-
|
|
434
|
-
|
|
487
|
+
|
|
488
|
+
// A running vendor or Impel app only needs to close when its bundle will be swapped.
|
|
489
|
+
if (staleBundleTargets.length > 0) {
|
|
490
|
+
await io.quitApps(staleBundleTargets, {
|
|
491
|
+
tenantId: config.tenantId,
|
|
492
|
+
tenantName: config.tenantName,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
if (force) {
|
|
496
|
+
for (const status of statuses) io.removeLauncher(status.launcher);
|
|
497
|
+
}
|
|
498
|
+
const installed = await withProgress(
|
|
499
|
+
staleBundleTargets.length > 0 ? "Rebuilding managed app bundles" : "Updating managed app profiles",
|
|
500
|
+
() => io.installFiles({
|
|
501
|
+
config,
|
|
502
|
+
targets,
|
|
503
|
+
models: catalog.models,
|
|
504
|
+
homeDir: io.homeDir,
|
|
505
|
+
vendorPaths,
|
|
506
|
+
writeBundles: staleBundleTargets,
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
const rebuilt = new Set(staleBundleTargets);
|
|
510
|
+
for (const item of installed) {
|
|
511
|
+
const verb = action === "install" ? "Installed" : "Updated";
|
|
512
|
+
io.log(`${verb} ${item.launcher}${rebuilt.has(item.target) ? "" : " (bundle already current)"}`);
|
|
513
|
+
}
|
|
435
514
|
console.log(`Models: ${catalog.models.length} from ${catalog.source}. Normal ~/.claude and ~/.codex profiles were not changed.`);
|
|
436
515
|
|
|
437
516
|
// Best-effort: sync the Bifrost shared skills into each installed isolated app
|
|
438
517
|
// profile. Never fails the install/update.
|
|
439
|
-
const paths = appPaths(
|
|
518
|
+
const paths = appPaths(io.homeDir, config.tenantId, { tenantName: config.tenantName });
|
|
440
519
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
441
520
|
for (const item of installed) {
|
|
442
521
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
443
|
-
|
|
444
|
-
|
|
522
|
+
if (!flags["skip-skills"]) {
|
|
523
|
+
await withProgress(`Syncing skills for ${label}`, () => (
|
|
524
|
+
io.syncSkills({ client, gatewayUrl, env, label })
|
|
525
|
+
));
|
|
526
|
+
}
|
|
527
|
+
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
528
|
+
}
|
|
529
|
+
if (!flags["skip-agents"]) {
|
|
530
|
+
await withProgress("Syncing app agent profiles", () => io.syncAgents({
|
|
531
|
+
profiles: installed.map((item) => appAgentProfile(item.target, paths)),
|
|
532
|
+
gatewayUrl: config.gatewayUrl,
|
|
533
|
+
credential: config.pat,
|
|
534
|
+
tenantId: config.tenantId,
|
|
535
|
+
}));
|
|
445
536
|
}
|
|
446
|
-
const syncAgents = overrides.syncAgents || syncAgentProfilesSafe;
|
|
447
|
-
await syncAgents({
|
|
448
|
-
profiles: installed.map((item) => appAgentProfile(item.target, paths)),
|
|
449
|
-
gatewayUrl: config.gatewayUrl,
|
|
450
|
-
credential: config.pat,
|
|
451
|
-
tenantId: config.tenantId,
|
|
452
|
-
});
|
|
453
537
|
}
|
|
454
538
|
|
|
455
539
|
/**
|
|
@@ -474,7 +558,7 @@ export async function provisionAndOpenManagedApps({
|
|
|
474
558
|
syncSkills: syncSkillsSafe,
|
|
475
559
|
syncAgents: syncAgentProfilesSafe,
|
|
476
560
|
secureCodexHome: secureManagedCodexHome,
|
|
477
|
-
openLauncher:
|
|
561
|
+
openLauncher: openManagedLauncher,
|
|
478
562
|
log: (message) => console.log(message),
|
|
479
563
|
...overrides,
|
|
480
564
|
};
|
|
@@ -483,7 +567,7 @@ export async function provisionAndOpenManagedApps({
|
|
|
483
567
|
// This keeps unavailable/unauthorized tenants fail-closed on first use.
|
|
484
568
|
let catalog;
|
|
485
569
|
try {
|
|
486
|
-
catalog = await io.fetchModels(config);
|
|
570
|
+
catalog = await withProgress("Fetching the tenant model catalog", () => io.fetchModels(config));
|
|
487
571
|
} catch (error) {
|
|
488
572
|
throw new Error(
|
|
489
573
|
`tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); the selected tenant app was not opened`,
|
|
@@ -494,7 +578,9 @@ export async function provisionAndOpenManagedApps({
|
|
|
494
578
|
for (const status of statuses) {
|
|
495
579
|
let vendorPath = status.vendorPath;
|
|
496
580
|
if (!vendorPath) {
|
|
497
|
-
const result =
|
|
581
|
+
const result = await withProgress(`Preparing the verified ${status.target} vendor app`, () => (
|
|
582
|
+
io.ensureVendor(status.target, { homeDir })
|
|
583
|
+
));
|
|
498
584
|
vendorPath = result.path;
|
|
499
585
|
io.log(`${status.target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
|
|
500
586
|
if (!vendorPath) {
|
|
@@ -516,14 +602,17 @@ export async function provisionAndOpenManagedApps({
|
|
|
516
602
|
});
|
|
517
603
|
}
|
|
518
604
|
|
|
519
|
-
const installed =
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
605
|
+
const installed = await withProgress(
|
|
606
|
+
staleBundleTargets.length > 0 ? "Rebuilding managed app bundles" : "Updating managed app profiles",
|
|
607
|
+
() => io.installFiles({
|
|
608
|
+
config,
|
|
609
|
+
targets,
|
|
610
|
+
models: catalog.models,
|
|
611
|
+
homeDir,
|
|
612
|
+
vendorPaths,
|
|
613
|
+
writeBundles: staleBundleTargets,
|
|
614
|
+
}),
|
|
615
|
+
);
|
|
527
616
|
const newlyInstalled = new Set(
|
|
528
617
|
statuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
|
|
529
618
|
);
|
|
@@ -539,15 +628,17 @@ export async function provisionAndOpenManagedApps({
|
|
|
539
628
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
540
629
|
for (const item of installed) {
|
|
541
630
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
542
|
-
await
|
|
631
|
+
await withProgress(`Syncing skills for ${label}`, () => (
|
|
632
|
+
io.syncSkills({ client, gatewayUrl, env, label })
|
|
633
|
+
));
|
|
543
634
|
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
544
635
|
}
|
|
545
|
-
await io.syncAgents({
|
|
636
|
+
await withProgress("Syncing app agent profiles", () => io.syncAgents({
|
|
546
637
|
profiles: installed.map((item) => appAgentProfile(item.target, paths)),
|
|
547
638
|
gatewayUrl: config.gatewayUrl,
|
|
548
639
|
credential: config.pat,
|
|
549
640
|
tenantId: config.tenantId,
|
|
550
|
-
});
|
|
641
|
+
}));
|
|
551
642
|
for (const item of installed) await io.openLauncher(item.launcher);
|
|
552
643
|
return installed;
|
|
553
644
|
}
|
package/src/commands/skills.js
CHANGED
|
@@ -15,6 +15,7 @@ import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
|
|
|
15
15
|
import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
|
|
16
16
|
import { ensureTenantSelection } from "../tenants.js";
|
|
17
17
|
import { windowsClaudeUserData } from "../windowsApps.js";
|
|
18
|
+
import { mapWithConcurrency, withProgress } from "../progress.js";
|
|
18
19
|
|
|
19
20
|
const VALID_TARGETS = ["claude", "codex", "all"];
|
|
20
21
|
|
|
@@ -78,7 +79,7 @@ export function managedSkillProfiles(
|
|
|
78
79
|
return profiles;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
export async function cmdSkills(argv) {
|
|
82
|
+
export async function cmdSkills(argv, overrides = {}) {
|
|
82
83
|
const [action = "sync", targetToken] = argv;
|
|
83
84
|
if (action !== "sync") {
|
|
84
85
|
console.error(`impel skills: unknown action "${action}". Try \`impel skills sync [claude|codex|all]\`.`);
|
|
@@ -93,16 +94,26 @@ export async function cmdSkills(argv) {
|
|
|
93
94
|
return;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
const
|
|
97
|
+
const io = {
|
|
98
|
+
loadConfig,
|
|
99
|
+
ensureTenantSelection,
|
|
100
|
+
managedProfiles: managedSkillProfiles,
|
|
101
|
+
syncSkills: syncSkillsSafe,
|
|
102
|
+
...overrides,
|
|
103
|
+
};
|
|
104
|
+
const config = io.loadConfig();
|
|
97
105
|
const gatewayUrl = resolveSkillsGateway(config?.gatewayUrl);
|
|
98
106
|
const tenantId = config?.pat
|
|
99
|
-
? (await ensureTenantSelection(config)).tenantId
|
|
107
|
+
? (await io.ensureTenantSelection(config)).tenantId
|
|
100
108
|
: null;
|
|
101
109
|
const clients = target === "all" ? ["claude", "codex"] : [target];
|
|
110
|
+
const profiles = clients.flatMap((client) => (
|
|
111
|
+
io.managedProfiles(client, { tenantId }).map((profile) => ({ client, profile }))
|
|
112
|
+
));
|
|
102
113
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, () => (
|
|
115
|
+
mapWithConcurrency(profiles, 4, ({ client, profile }) => (
|
|
116
|
+
io.syncSkills({ client, gatewayUrl, env: profile.env, label: profile.label })
|
|
117
|
+
))
|
|
118
|
+
));
|
|
108
119
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -14,6 +14,7 @@ import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
|
|
|
14
14
|
import { loadConfig, redactSecretText } from "../config.js";
|
|
15
15
|
import { nativeCommandInvocation } from "../nativeProcess.js";
|
|
16
16
|
import { windowsClaudeUserData } from "../windowsApps.js";
|
|
17
|
+
import { withProgress } from "../progress.js";
|
|
17
18
|
import {
|
|
18
19
|
fetchRemoteVersion,
|
|
19
20
|
installedVersion,
|
|
@@ -88,8 +89,12 @@ function defaultSelfUpdate(spec) {
|
|
|
88
89
|
|
|
89
90
|
// The cascading steps re-execute the (freshly installed) CLI binary so the
|
|
90
91
|
// NEW code performs them, not the process that started the update.
|
|
91
|
-
function defaultRunAppsUpdate(
|
|
92
|
-
|
|
92
|
+
export function defaultRunAppsUpdate({
|
|
93
|
+
spawn = spawnSync,
|
|
94
|
+
execPath = process.execPath,
|
|
95
|
+
cliBin = CLI_BIN,
|
|
96
|
+
} = {}) {
|
|
97
|
+
const result = spawn(execPath, [cliBin, "app", "update", "all", "--skip-skills", "--skip-agents"], {
|
|
93
98
|
stdio: "inherit",
|
|
94
99
|
});
|
|
95
100
|
return result.status === 0;
|
|
@@ -166,7 +171,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
166
171
|
}
|
|
167
172
|
|
|
168
173
|
const current = io.installedVersion();
|
|
169
|
-
const remote = await io.fetchRemoteVersion();
|
|
174
|
+
const remote = await withProgress("Checking npm for impel-cli updates", () => io.fetchRemoteVersion());
|
|
170
175
|
if (remote) io.writeCache({ remoteVersion: remote, checkedAt: Date.now() });
|
|
171
176
|
|
|
172
177
|
console.log(`impel-cli v${current ?? "?"}`);
|
|
@@ -185,7 +190,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
185
190
|
console.log("CLI: already up to date.");
|
|
186
191
|
} else {
|
|
187
192
|
console.log("CLI: installing the latest build…");
|
|
188
|
-
if (!io.selfUpdate(updateInstallSpec())) {
|
|
193
|
+
if (!await withProgress("Installing the latest impel-cli build", () => io.selfUpdate(updateInstallSpec()))) {
|
|
189
194
|
console.error("impel update: `npm install -g` failed; the CLI was not updated.");
|
|
190
195
|
if (io.platform === "win32") {
|
|
191
196
|
console.error(" Verify `npm --version` in PowerShell, then retry `impel update`.");
|
|
@@ -208,21 +213,21 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
208
213
|
} else {
|
|
209
214
|
console.log(io.platform === "win32"
|
|
210
215
|
? "Apps: updating the signed Claude and ChatGPT vendor apps and isolated profiles…"
|
|
211
|
-
: "Apps:
|
|
212
|
-
if (!io.runAppsUpdate()) {
|
|
216
|
+
: "Apps: refreshing profiles and rebuilding only stale app bundles…");
|
|
217
|
+
if (!await withProgress("Updating managed desktop apps", () => io.runAppsUpdate())) {
|
|
213
218
|
console.error("impel update: the app update failed; re-run `impel app update` after fixing the issue.");
|
|
214
219
|
cascadeFailed = true;
|
|
215
220
|
}
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
console.log("Skills: syncing every managed profile…");
|
|
219
|
-
if (!io.runSkillsSync()) {
|
|
224
|
+
if (!await withProgress("Syncing skills across managed profiles", () => io.runSkillsSync())) {
|
|
220
225
|
console.error("impel update: skill sync failed; re-run `impel skills sync` after fixing the issue.");
|
|
221
226
|
cascadeFailed = true;
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
console.log("Agents: syncing the selected tenant into every managed profile…");
|
|
225
|
-
if (!io.runAgentsSync()) {
|
|
230
|
+
if (!await withProgress("Syncing agents across managed profiles", () => io.runAgentsSync())) {
|
|
226
231
|
console.error("impel update: agent sync failed; re-run `impel agents sync` after fixing the issue.");
|
|
227
232
|
cascadeFailed = true;
|
|
228
233
|
}
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
+
|
|
3
|
+
function noopSpinner() {
|
|
4
|
+
return {
|
|
5
|
+
update() {},
|
|
6
|
+
succeed() {},
|
|
7
|
+
fail() {},
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render a small stderr-only spinner without changing machine-readable stdout.
|
|
13
|
+
* Non-interactive processes and detached app refreshes get a no-op object.
|
|
14
|
+
*/
|
|
15
|
+
export function createSpinner(label, { stream = process.stderr } = {}) {
|
|
16
|
+
if (!stream?.isTTY || process.env.IMPEL_NO_PROGRESS === "1") return noopSpinner();
|
|
17
|
+
|
|
18
|
+
let currentLabel = String(label);
|
|
19
|
+
let frame = 0;
|
|
20
|
+
let finished = false;
|
|
21
|
+
const startedAt = Date.now();
|
|
22
|
+
const render = () => {
|
|
23
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
24
|
+
stream.write(`\r${FRAMES[frame % FRAMES.length]} ${currentLabel} (${elapsed}s)\x1b[K`);
|
|
25
|
+
frame += 1;
|
|
26
|
+
};
|
|
27
|
+
const finish = (symbol, nextLabel) => {
|
|
28
|
+
if (finished) return;
|
|
29
|
+
finished = true;
|
|
30
|
+
clearInterval(timer);
|
|
31
|
+
if (nextLabel) currentLabel = String(nextLabel);
|
|
32
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
33
|
+
stream.write(`\r${symbol} ${currentLabel} (${elapsed}s)\x1b[K\n`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
render();
|
|
37
|
+
const timer = setInterval(render, 80);
|
|
38
|
+
timer.unref?.();
|
|
39
|
+
return {
|
|
40
|
+
update(nextLabel) {
|
|
41
|
+
if (!finished && nextLabel) currentLabel = String(nextLabel);
|
|
42
|
+
},
|
|
43
|
+
succeed(nextLabel) {
|
|
44
|
+
finish("✓", nextLabel);
|
|
45
|
+
},
|
|
46
|
+
fail(nextLabel) {
|
|
47
|
+
finish("✗", nextLabel);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function withProgress(label, fn, options) {
|
|
53
|
+
const spinner = createSpinner(label, options);
|
|
54
|
+
try {
|
|
55
|
+
const result = await fn(spinner);
|
|
56
|
+
spinner.succeed();
|
|
57
|
+
return result;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
spinner.fail();
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Run independent jobs with bounded concurrency while preserving result order. */
|
|
65
|
+
export async function mapWithConcurrency(items, limit, fn) {
|
|
66
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
67
|
+
throw new RangeError("concurrency limit must be a positive integer");
|
|
68
|
+
}
|
|
69
|
+
const values = Array.from(items);
|
|
70
|
+
const results = new Array(values.length);
|
|
71
|
+
let nextIndex = 0;
|
|
72
|
+
|
|
73
|
+
async function worker() {
|
|
74
|
+
while (nextIndex < values.length) {
|
|
75
|
+
const index = nextIndex;
|
|
76
|
+
nextIndex += 1;
|
|
77
|
+
results[index] = await fn(values[index], index);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await Promise.all(
|
|
82
|
+
Array.from({ length: Math.min(limit, values.length) }, () => worker()),
|
|
83
|
+
);
|
|
84
|
+
return results;
|
|
85
|
+
}
|