impel-cli 0.11.2 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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>] Refresh that tenant's apps, configs, and models
155
+ impel app update [target] [--tenant <org>] [--force]
156
+ `all` without --tenant updates every installed tenant;
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
@@ -165,7 +167,9 @@ impel help Show help
165
167
  impel --version Show version
166
168
 
167
169
  For app commands, `target` is `claude`, `chatgpt`/`codex`, or `all` (the
168
- default). `--tenant` defaults to the currently selected organization.
170
+ default). `impel app update all` without `--tenant` updates every locally
171
+ installed tenant. Other app commands, a specific update target, or an explicit
172
+ `--tenant` operate on one organization and default to the current selection.
169
173
 
170
174
  # `impel setup` no longer takes a per-tool target and never flips native
171
175
  # configs; flip a native tool explicitly with `impel use gateway [claude|codex]`.
@@ -237,9 +241,10 @@ before running another protected command.
237
241
  One command brings everything current, in dependency order: the CLI itself
238
242
  (`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
239
243
  then — re-executing the freshly installed build — `impel app update all`
240
- (close running apps, install the CLI-pinned vendor builds, rebuild the vendored Impel apps)
244
+ (update every locally installed tenant and rebuild only stale vendored Impel apps)
241
245
  followed by `impel skills sync all` and `impel agents sync all` across every
242
- managed profile.
246
+ native and isolated CLI profile. Each tenant's app step syncs that tenant's app
247
+ profiles; the dedicated steps exclude apps, so every profile is synced once.
243
248
 
244
249
  Update discovery compares the installed package version with npm's public
245
250
  `latest` metadata, cached for 6 hours in
@@ -608,6 +613,12 @@ MCP subprocess definition so they also work in isolated desktop profiles. They
608
613
  never contain the PAT; the subprocess reads it from Impel's owner-only config
609
614
  when invoked.
610
615
 
616
+ Visible agent names come from the registry title. Codex keeps that title as the
617
+ custom-agent name; Claude uses the lowercase, hyphenated form required by its
618
+ native agent schema. The generated filenames remain stable tenant-and-agent
619
+ identifiers so title changes update in place without colliding with user files.
620
+ Duplicate or built-in-reserved titles receive a deterministic disambiguator.
621
+
611
622
  The sync writes atomically and records a manifest beside the generated files.
612
623
  It removes only stale filenames from that manifest, so user-authored agents are
613
624
  never deleted. Automatic refreshes are best-effort and retain the last good
@@ -623,13 +634,13 @@ Invocation uses the clients' native agent behavior:
623
634
 
624
635
  ```text
625
636
  # Claude Code / Claude Desktop Code tab: guaranteed explicit selection
626
- @agent-impel-acme-research-agent investigate the dependency change
637
+ @agent-research-agent investigate the dependency change
627
638
 
628
639
  # Claude blocking CLI
629
- claude --agent impel-acme-research-agent -p "investigate the dependency change"
640
+ claude --agent research-agent -p "investigate the dependency change"
630
641
 
631
642
  # Codex CLI / ChatGPT desktop Codex task
632
- Explicitly use the configured impel-acme-research-agent for this request and wait for it.
643
+ Explicitly use the configured Research Agent for this request and wait for it.
633
644
  ```
634
645
 
635
646
  Claude exposes native custom agents in its `@` typeahead. Current Codex releases
@@ -698,10 +709,11 @@ a stable, unique bundle identifier and gateway configuration:
698
709
  - Impel Claude is an APFS-cloned vendored copy of the official app with its own
699
710
  bundle and helper identities. Its LaunchServices environment sets the
700
711
  vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway
701
- `configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A narrowly
702
- version-checked compatibility patch connects Claude's native plan-usage meter
703
- to the authenticated aggregate Claude subscription pool exposed by the Impel
704
- gateway.
712
+ `configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A new
713
+ isolated profile defaults to the Code app while preserving any app selection
714
+ the user makes afterward. A narrowly version-checked compatibility patch
715
+ connects Claude's native plan-usage meter to the authenticated aggregate
716
+ Claude subscription pool exposed by the Impel gateway.
705
717
  - Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
706
718
  OpenAI-signed bundle and executable. A wrapper-owned startup preload pins
707
719
  Electron's application name before the native menu is created, so macOS
@@ -727,9 +739,24 @@ impel app open all --tenant acme
727
739
  impel app open all --tenant globex
728
740
  ```
729
741
 
730
- Updates and uninstalls touch only the named tenant variant. The older global
731
- `Impel Claude.app` / `Impel ChatGPT.app` launcher is removed after the first
732
- successful tenant-specific rebuild.
742
+ Updates with `--tenant` and all uninstalls touch only the named tenant variant.
743
+ `impel app update all` without `--tenant` discovers every locally installed
744
+ tenant and updates each sequentially; it does not install variants for tenants
745
+ that have never been installed. The older global `Impel Claude.app` /
746
+ `Impel ChatGPT.app` launcher is removed after the first successful
747
+ tenant-specific rebuild.
748
+
749
+ `impel app update` compares the installed bundle's CLI version, pinned vendor
750
+ version, identity, profile paths, and ASAR compatibility hash before touching
751
+ it. A current bundle is not closed, cloned, patched, or codesigned; its config
752
+ and model catalog are still refreshed. If an interrupted update left a machine
753
+ in a bad state, `impel app update --force` removes the selected launcher and
754
+ performs a clean rebuild while sweeping abandoned `.tmp-*` and `.previous-*`
755
+ staging directories.
756
+
757
+ Skills plugin subprocesses run asynchronously, so the TTY progress indicator
758
+ continues animating while Claude or Codex refreshes a marketplace. Skill status
759
+ lines temporarily clear and redraw the spinner instead of overwriting it.
733
760
 
734
761
  The official Claude and ChatGPT apps remain separate installations and keep
735
762
  their normal accounts, updater, profiles, and bundle identities. Each CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.11.2",
3
+ "version": "0.12.1",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
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) || "agent";
274
+ .slice(0, 48) || fallback;
271
275
  }
272
276
 
273
- function generatedAgentNames(tenantId, agents) {
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 = crypto.createHash("sha256")
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 names = generatedAgentNames(normalizedTenant, agents);
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: `${name}${extension}`, contents };
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 = 9;
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) {
@@ -379,7 +379,10 @@ export function installManagedAppFiles({
379
379
 
380
380
  const installed = [];
381
381
  for (const target of targets) {
382
- if (target === "claude") writeClaudeConfig(paths, config, models);
382
+ if (target === "claude") {
383
+ writeClaudeDefaultApp(paths);
384
+ writeClaudeConfig(paths, config, models);
385
+ }
383
386
  else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
384
387
  // Non-bundle targets are the background-refresh / fast-open path: configs,
385
388
  // token helper, catalog, and manifest only. Bundle swaps require the app
@@ -550,6 +553,7 @@ function installPinnedVendorApp(target, homeDir) {
550
553
  homeDir, ".config", "impel", "vendor", target, pin.version, pin.bundleName,
551
554
  );
552
555
  fs.mkdirSync(path.dirname(destination), { recursive: true });
556
+ sweepStaleBundleArtifacts(path.dirname(destination), path.basename(destination));
553
557
  replaceDirectory(destination, extractedApp);
554
558
  return destination;
555
559
  } finally {
@@ -646,6 +650,33 @@ function writeClaudeConfig(paths, config, models) {
646
650
  }, null, 2) + "\n", 0o600);
647
651
  }
648
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
+
649
680
  function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocations = null) {
650
681
  const experimental = crossAppModelsEnabled(config);
651
682
  const orderedModels = experimental
@@ -941,6 +972,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
941
972
 
942
973
  const bundle = paths.claude.launcher;
943
974
  const staging = `${bundle}.tmp-${process.pid}`;
975
+ sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
944
976
  fs.rmSync(staging, { recursive: true, force: true });
945
977
  cloneAppBundle(vendorPath, staging);
946
978
  try {
@@ -1027,6 +1059,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1027
1059
  const staging = `${bundle}.tmp-${process.pid}`;
1028
1060
  const vendorBundleName = path.basename(vendorPath);
1029
1061
  const vendorBundle = path.join(staging, "Contents", "Resources", vendorBundleName);
1062
+ sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1030
1063
  fs.rmSync(staging, { recursive: true, force: true });
1031
1064
  cloneAppBundle(vendorPath, vendorBundle);
1032
1065
  try {
@@ -1653,6 +1686,22 @@ function sha256(contents) {
1653
1686
  return crypto.createHash("sha256").update(contents).digest("hex");
1654
1687
  }
1655
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
+
1656
1705
  function replaceDirectory(destination, staging) {
1657
1706
  const backup = `${destination}.previous-${process.pid}`;
1658
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 vendor apps and managed profiles
52
+ impel app update [target] [--tenant <org>] Update profiles; all without --tenant updates
53
+ every installed tenant (--force: clean 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]
@@ -4,6 +4,7 @@
4
4
  import os from "node:os";
5
5
 
6
6
  import { agentProfileRoot, syncAgentProfiles } from "../agents.js";
7
+ import { parseFlags } from "../args.js";
7
8
  import { loadConfig, normalizeGatewayUrl, redactSecretText, resolveDefaultGateway } from "../config.js";
8
9
  import { ensureTenantSelection, tenantCredential } from "../tenants.js";
9
10
  import { managedSkillProfiles } from "./skills.js";
@@ -41,7 +42,8 @@ export function managedAgentProfiles(client, options = {}) {
41
42
  }
42
43
 
43
44
  export async function cmdAgents(argv, overrides = {}) {
44
- const [action = "sync", targetToken] = argv;
45
+ const [action = "sync", rawTarget, ...rest] = argv;
46
+ const targetToken = rawTarget?.startsWith("--") ? undefined : rawTarget;
45
47
  if (["help", "--help", "-h"].includes(action)) {
46
48
  console.log(HELP);
47
49
  return;
@@ -57,6 +59,8 @@ export async function cmdAgents(argv, overrides = {}) {
57
59
  process.exitCode = 1;
58
60
  return;
59
61
  }
62
+ const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
63
+ const { flags } = parseFlags(flagArgs, { "skip-apps": { type: "boolean" } });
60
64
 
61
65
  const io = {
62
66
  loadConfig,
@@ -83,6 +87,7 @@ export async function cmdAgents(argv, overrides = {}) {
83
87
  tenantId,
84
88
  environment: io.environment,
85
89
  homeDir: io.homeDir,
90
+ includeApps: !flags["skip-apps"],
86
91
  }));
87
92
  await io.syncAgentProfiles({
88
93
  profiles,
@@ -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 { createProgressLogger, withProgress } from "../progress.js";
43
44
  import {
44
45
  ensureWindowsClaudeApp,
45
46
  ensureWindowsChatGPTApp,
@@ -78,6 +79,102 @@ function windowsAppTargets(targetToken) {
78
79
  return null;
79
80
  }
80
81
 
82
+ function macTargetOrArtifactExists(paths, target, existsSync = fs.existsSync) {
83
+ const launcher = paths[target].launcher;
84
+ if (existsSync(launcher)) return true;
85
+ try {
86
+ const baseName = path.basename(launcher);
87
+ return fs.readdirSync(path.dirname(launcher)).some((entry) => (
88
+ entry.startsWith(`${baseName}.tmp-`) || entry.startsWith(`${baseName}.previous-`)
89
+ ));
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /** Locally installed tenant variants eligible for an all-target app update. */
96
+ export function installedAppTenantIds(targets, {
97
+ homeDir = os.homedir(),
98
+ platform = process.platform,
99
+ environment = process.env,
100
+ existsSync = fs.existsSync,
101
+ readdirSync = fs.readdirSync,
102
+ } = {}) {
103
+ const tenantsRoot = path.join(appPaths(homeDir).root, "tenants");
104
+ let entries;
105
+ try {
106
+ entries = readdirSync(tenantsRoot, { withFileTypes: true });
107
+ } catch (error) {
108
+ if (error?.code === "ENOENT") return [];
109
+ throw error;
110
+ }
111
+
112
+ const installed = [];
113
+ for (const entry of entries) {
114
+ if (!entry.isDirectory()) continue;
115
+ let tenantId;
116
+ try {
117
+ tenantId = normalizeTenantId(entry.name);
118
+ } catch {
119
+ continue;
120
+ }
121
+ const manifest = readTenantManifest(homeDir, tenantId);
122
+ const claudeUserData = platform === "win32"
123
+ ? windowsClaudeUserData(environment, tenantId)
124
+ : null;
125
+ const paths = appPaths(homeDir, tenantId, {
126
+ claudeUserData,
127
+ tenantName: manifest?.tenantName,
128
+ });
129
+ const hasTarget = targets.some((target) => {
130
+ if (platform !== "win32") return macTargetOrArtifactExists(paths, target, existsSync);
131
+ return target === "claude"
132
+ ? existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`))
133
+ : existsSync(path.join(paths.chatgpt.codexHome, "config.toml"));
134
+ });
135
+ if (hasTarget) installed.push(tenantId);
136
+ }
137
+ return installed.sort((left, right) => left.localeCompare(right));
138
+ }
139
+
140
+ async function maybeUpdateAllInstalledTenants(argv, overrides, platform) {
141
+ if (overrides.singleTenantUpdate) return null;
142
+ const [action = "status", rawTarget, ...rest] = argv;
143
+ if (action !== "update") return null;
144
+ const targetToken = rawTarget?.startsWith("--") ? undefined : rawTarget;
145
+ const targets = normalizeAppTarget(targetToken);
146
+ if (!targets || targets.length !== 2) return null;
147
+ const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
148
+ const { flags } = parseFlags(flagArgs, {
149
+ "skip-vendor": { type: "boolean" },
150
+ "skip-skills": { type: "boolean" },
151
+ "skip-agents": { type: "boolean" },
152
+ force: { type: "boolean" },
153
+ tenant: { type: "string" },
154
+ });
155
+ if (flags.tenant) return null;
156
+
157
+ const discover = overrides.installedTenantIds || installedAppTenantIds;
158
+ const tenantIds = discover(targets, {
159
+ homeDir: overrides.homeDir || os.homedir(),
160
+ platform,
161
+ environment: overrides.environment || process.env,
162
+ });
163
+ if (tenantIds.length === 0) return null;
164
+
165
+ const log = overrides.log || console.log;
166
+ log(`Updating all managed app targets across ${tenantIds.length} installed tenant${tenantIds.length === 1 ? "" : "s"}: ${tenantIds.join(", ")}`);
167
+ const runTenantUpdate = overrides.runTenantUpdate || cmdApps;
168
+ for (const tenantId of tenantIds) {
169
+ const result = await runTenantUpdate(
170
+ ["update", "all", ...flagArgs, "--tenant", tenantId],
171
+ { ...overrides, singleTenantUpdate: true },
172
+ );
173
+ if (result === false) return false;
174
+ }
175
+ return true;
176
+ }
177
+
81
178
  function windowsProcessFailure(result) {
82
179
  if (result?.error?.message) return redactSecretText(result.error.message);
83
180
  if (Number.isInteger(result?.status)) return `exit code ${result.status}`;
@@ -85,6 +182,20 @@ function windowsProcessFailure(result) {
85
182
  return "no exit status";
86
183
  }
87
184
 
185
+ export function openManagedLauncher(launcher, {
186
+ spawn = spawnSync,
187
+ reportError = console.error,
188
+ } = {}) {
189
+ const result = spawn("/usr/bin/open", ["-n", launcher], { encoding: "utf8" });
190
+ if (!result?.error && result?.status === 0) return true;
191
+ const detail = result?.error?.message
192
+ || String(result?.stderr || "").trim()
193
+ || (Number.isInteger(result?.status) ? `exit code ${result.status}` : "no exit status");
194
+ reportError(`impel app: could not open ${launcher}: ${redactSecretText(detail)}`);
195
+ process.exitCode = 1;
196
+ return false;
197
+ }
198
+
88
199
  async function fetchWindowsCatalog(config, io) {
89
200
  try {
90
201
  return await io.fetchModels(config);
@@ -123,8 +234,11 @@ export async function cmdWindowsApps(argv, overrides = {}) {
123
234
  const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
124
235
  const { flags } = parseFlags(flagArgs, {
125
236
  "skip-vendor": { type: "boolean" },
237
+ "skip-skills": { type: "boolean" },
238
+ "skip-agents": { type: "boolean" },
126
239
  "keep-data": { type: "boolean" },
127
240
  "stale-only": { type: "boolean" },
241
+ force: { type: "boolean" },
128
242
  tenant: { type: "string" },
129
243
  });
130
244
  const io = {
@@ -253,7 +367,9 @@ export async function cmdWindowsApps(argv, overrides = {}) {
253
367
  if (targets.includes("claude") && action !== "refresh" && !vendorPaths.claude) {
254
368
  throw new Error("Claude vendor app is unavailable; run `impel app install claude` or install it from https://claude.com/download");
255
369
  }
256
- const catalog = await fetchWindowsCatalog(config, io);
370
+ const catalog = await withProgress("Fetching the tenant model catalog", () => (
371
+ fetchWindowsCatalog(config, io)
372
+ ));
257
373
  let managedChatGPT = targets.includes("chatgpt")
258
374
  ? io.findManagedChatGPTApp(actionPaths.root)
259
375
  : null;
@@ -283,15 +399,27 @@ export async function cmdWindowsApps(argv, overrides = {}) {
283
399
  const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
284
400
  console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
285
401
  const { client, env, label } = appSkillTarget(target, tenantPaths);
286
- await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
402
+ if (!flags["skip-skills"]) {
403
+ await withProgress(`Syncing skills for ${label}`, (spinner) => (
404
+ io.syncSkills({
405
+ client,
406
+ gatewayUrl: resolveSkillsGateway(config.gatewayUrl),
407
+ env,
408
+ label,
409
+ logger: createProgressLogger(spinner),
410
+ })
411
+ ));
412
+ }
287
413
  if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
288
414
  }
289
- await io.syncAgents({
290
- profiles: targets.map((target) => appAgentProfile(target, tenantPaths)),
291
- gatewayUrl: config.gatewayUrl,
292
- credential: config.pat,
293
- tenantId: config.tenantId,
294
- });
415
+ if (!flags["skip-agents"]) {
416
+ await withProgress("Syncing app agent profiles", () => io.syncAgents({
417
+ profiles: targets.map((target) => appAgentProfile(target, tenantPaths)),
418
+ gatewayUrl: config.gatewayUrl,
419
+ credential: config.pat,
420
+ tenantId: config.tenantId,
421
+ }));
422
+ }
295
423
  console.log(`Models: ${catalog.models.length} from ${catalog.source}. The signed vendor apps and their normal profiles were not changed.`);
296
424
  }
297
425
  if (action === "open") {
@@ -320,8 +448,11 @@ export async function cmdWindowsApps(argv, overrides = {}) {
320
448
  }
321
449
 
322
450
  export async function cmdApps(argv, overrides = {}) {
323
- if (process.platform === "win32") return cmdWindowsApps(argv, overrides);
324
- if (process.platform !== "darwin") {
451
+ const platform = overrides.platform || process.platform;
452
+ const allTenantUpdate = await maybeUpdateAllInstalledTenants(argv, overrides, platform);
453
+ if (allTenantUpdate !== null) return allTenantUpdate;
454
+ if (platform === "win32") return cmdWindowsApps(argv, overrides);
455
+ if (platform !== "darwin") {
325
456
  console.error("impel app: isolated Impel desktop apps are unavailable on this platform.");
326
457
  process.exitCode = 1;
327
458
  return;
@@ -332,8 +463,11 @@ export async function cmdApps(argv, overrides = {}) {
332
463
  const flagArgs = targetToken?.startsWith("--") ? [targetToken, ...rest] : rest;
333
464
  const { flags } = parseFlags(flagArgs, {
334
465
  "skip-vendor": { type: "boolean" },
466
+ "skip-skills": { type: "boolean" },
467
+ "skip-agents": { type: "boolean" },
335
468
  "keep-data": { type: "boolean" },
336
469
  "stale-only": { type: "boolean" },
470
+ force: { type: "boolean" },
337
471
  tenant: { type: "string" },
338
472
  });
339
473
 
@@ -382,7 +516,7 @@ export async function cmdApps(argv, overrides = {}) {
382
516
  if (fastLaunchers) {
383
517
  spawnDetachedAppRefresh(tenantId);
384
518
  for (const launcher of fastLaunchers) {
385
- spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" });
519
+ openManagedLauncher(launcher);
386
520
  }
387
521
  return;
388
522
  }
@@ -401,55 +535,109 @@ export async function cmdApps(argv, overrides = {}) {
401
535
  throw new Error(`unknown app action "${action}"; use install, update, refresh, status, open, or uninstall`);
402
536
  }
403
537
 
404
- const config = await selectedAppConfig(targets, flags.tenant || null);
538
+ const io = {
539
+ homeDir: os.homedir(),
540
+ selectedConfig: selectedAppConfig,
541
+ status: appStatus,
542
+ bundleCurrent: bundleIsCurrent,
543
+ ensureVendor: ensureVendorApp,
544
+ fetchModels: fetchGatewayModels,
545
+ quitApps: quitBlockingApps,
546
+ installFiles: installManagedAppFiles,
547
+ syncSkills: syncSkillsSafe,
548
+ syncAgents: syncAgentProfilesSafe,
549
+ secureCodexHome: secureManagedCodexHome,
550
+ removeLauncher: (launcher) => fs.rmSync(launcher, { recursive: true, force: true }),
551
+ log: (message) => console.log(message),
552
+ ...overrides,
553
+ };
554
+ const config = await io.selectedConfig(targets, flags.tenant || null);
405
555
  console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
406
556
 
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
557
  const vendorPaths = {};
414
558
  if (!flags["skip-vendor"]) {
415
559
  for (const target of targets) {
416
560
  // Reuse or install only the exact vendor build verified by this CLI.
417
561
  // Moving "latest" releases are never cloned into a managed bundle.
418
- const result = ensureVendorApp(target);
562
+ const result = await withProgress(`Preparing the verified ${target} vendor app`, () => (
563
+ io.ensureVendor(target, { homeDir: io.homeDir })
564
+ ));
419
565
  vendorPaths[target] = result.path;
420
- console.log(`${target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
566
+ io.log(`${target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
421
567
  if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
422
568
  }
423
569
  }
424
570
 
571
+ const statuses = io.status(
572
+ targets,
573
+ io.homeDir,
574
+ config.tenantId,
575
+ config.tenantName,
576
+ );
577
+ for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
578
+ const force = Boolean(flags.force);
579
+ const staleBundleTargets = force
580
+ ? [...targets]
581
+ : statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
582
+
425
583
  let catalog;
426
584
  try {
427
- catalog = await fetchGatewayModels(config);
585
+ catalog = await withProgress("Fetching the tenant model catalog", () => io.fetchModels(config));
428
586
  } catch (error) {
429
587
  throw new Error(
430
588
  `tenant model catalog is unavailable (${redactSecretText(error.message)}); no Impel app files were changed`,
431
589
  );
432
590
  }
433
- const installed = installManagedAppFiles({ config, targets, models: catalog.models, homeDir: os.homedir(), vendorPaths });
434
- for (const item of installed) console.log(`${action === "install" ? "Installed" : "Updated"} ${item.launcher}`);
591
+
592
+ // A running vendor or Impel app only needs to close when its bundle will be swapped.
593
+ if (staleBundleTargets.length > 0) {
594
+ await io.quitApps(staleBundleTargets, {
595
+ tenantId: config.tenantId,
596
+ tenantName: config.tenantName,
597
+ });
598
+ }
599
+ if (force) {
600
+ for (const status of statuses) io.removeLauncher(status.launcher);
601
+ }
602
+ const installed = await withProgress(
603
+ staleBundleTargets.length > 0 ? "Rebuilding managed app bundles" : "Updating managed app profiles",
604
+ () => io.installFiles({
605
+ config,
606
+ targets,
607
+ models: catalog.models,
608
+ homeDir: io.homeDir,
609
+ vendorPaths,
610
+ writeBundles: staleBundleTargets,
611
+ }),
612
+ );
613
+ const rebuilt = new Set(staleBundleTargets);
614
+ for (const item of installed) {
615
+ const verb = action === "install" ? "Installed" : "Updated";
616
+ io.log(`${verb} ${item.launcher}${rebuilt.has(item.target) ? "" : " (bundle already current)"}`);
617
+ }
435
618
  console.log(`Models: ${catalog.models.length} from ${catalog.source}. Normal ~/.claude and ~/.codex profiles were not changed.`);
436
619
 
437
620
  // Best-effort: sync the Bifrost shared skills into each installed isolated app
438
621
  // profile. Never fails the install/update.
439
- const paths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
622
+ const paths = appPaths(io.homeDir, config.tenantId, { tenantName: config.tenantName });
440
623
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
441
624
  for (const item of installed) {
442
625
  const { client, env, label } = appSkillTarget(item.target, paths);
443
- await syncSkillsSafe({ client, gatewayUrl, env, label });
444
- if (item.target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
626
+ if (!flags["skip-skills"]) {
627
+ await withProgress(`Syncing skills for ${label}`, (spinner) => (
628
+ io.syncSkills({ client, gatewayUrl, env, label, logger: createProgressLogger(spinner) })
629
+ ));
630
+ }
631
+ if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
632
+ }
633
+ if (!flags["skip-agents"]) {
634
+ await withProgress("Syncing app agent profiles", () => io.syncAgents({
635
+ profiles: installed.map((item) => appAgentProfile(item.target, paths)),
636
+ gatewayUrl: config.gatewayUrl,
637
+ credential: config.pat,
638
+ tenantId: config.tenantId,
639
+ }));
445
640
  }
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
641
  }
454
642
 
455
643
  /**
@@ -474,7 +662,7 @@ export async function provisionAndOpenManagedApps({
474
662
  syncSkills: syncSkillsSafe,
475
663
  syncAgents: syncAgentProfilesSafe,
476
664
  secureCodexHome: secureManagedCodexHome,
477
- openLauncher: (launcher) => spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" }),
665
+ openLauncher: openManagedLauncher,
478
666
  log: (message) => console.log(message),
479
667
  ...overrides,
480
668
  };
@@ -483,7 +671,7 @@ export async function provisionAndOpenManagedApps({
483
671
  // This keeps unavailable/unauthorized tenants fail-closed on first use.
484
672
  let catalog;
485
673
  try {
486
- catalog = await io.fetchModels(config);
674
+ catalog = await withProgress("Fetching the tenant model catalog", () => io.fetchModels(config));
487
675
  } catch (error) {
488
676
  throw new Error(
489
677
  `tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); the selected tenant app was not opened`,
@@ -494,7 +682,9 @@ export async function provisionAndOpenManagedApps({
494
682
  for (const status of statuses) {
495
683
  let vendorPath = status.vendorPath;
496
684
  if (!vendorPath) {
497
- const result = io.ensureVendor(status.target, { homeDir });
685
+ const result = await withProgress(`Preparing the verified ${status.target} vendor app`, () => (
686
+ io.ensureVendor(status.target, { homeDir })
687
+ ));
498
688
  vendorPath = result.path;
499
689
  io.log(`${status.target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
500
690
  if (!vendorPath) {
@@ -516,14 +706,17 @@ export async function provisionAndOpenManagedApps({
516
706
  });
517
707
  }
518
708
 
519
- const installed = io.installFiles({
520
- config,
521
- targets,
522
- models: catalog.models,
523
- homeDir,
524
- vendorPaths,
525
- writeBundles: staleBundleTargets,
526
- });
709
+ const installed = await withProgress(
710
+ staleBundleTargets.length > 0 ? "Rebuilding managed app bundles" : "Updating managed app profiles",
711
+ () => io.installFiles({
712
+ config,
713
+ targets,
714
+ models: catalog.models,
715
+ homeDir,
716
+ vendorPaths,
717
+ writeBundles: staleBundleTargets,
718
+ }),
719
+ );
527
720
  const newlyInstalled = new Set(
528
721
  statuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
529
722
  );
@@ -539,15 +732,17 @@ export async function provisionAndOpenManagedApps({
539
732
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
540
733
  for (const item of installed) {
541
734
  const { client, env, label } = appSkillTarget(item.target, paths);
542
- await io.syncSkills({ client, gatewayUrl, env, label });
735
+ await withProgress(`Syncing skills for ${label}`, (spinner) => (
736
+ io.syncSkills({ client, gatewayUrl, env, label, logger: createProgressLogger(spinner) })
737
+ ));
543
738
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
544
739
  }
545
- await io.syncAgents({
740
+ await withProgress("Syncing app agent profiles", () => io.syncAgents({
546
741
  profiles: installed.map((item) => appAgentProfile(item.target, paths)),
547
742
  gatewayUrl: config.gatewayUrl,
548
743
  credential: config.pat,
549
744
  tenantId: config.tenantId,
550
- });
745
+ }));
551
746
  for (const item of installed) await io.openLauncher(item.launcher);
552
747
  return installed;
553
748
  }
@@ -8,6 +8,7 @@ import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
10
 
11
+ import { parseFlags } from "../args.js";
11
12
  import { loadConfig } from "../config.js";
12
13
  import { CODEX_HOME } from "../codexSetup.js";
13
14
  import { tenantCliProfilePaths } from "../cliProfiles.js";
@@ -15,6 +16,7 @@ import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
15
16
  import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
16
17
  import { ensureTenantSelection } from "../tenants.js";
17
18
  import { windowsClaudeUserData } from "../windowsApps.js";
19
+ import { createProgressLogger, mapWithConcurrency, withProgress } from "../progress.js";
18
20
 
19
21
  const VALID_TARGETS = ["claude", "codex", "all"];
20
22
 
@@ -39,6 +41,7 @@ export function managedSkillProfiles(
39
41
  tenantId = null,
40
42
  platform = process.platform,
41
43
  environment = process.env,
44
+ includeApps = true,
42
45
  } = {},
43
46
  ) {
44
47
  const profiles = [];
@@ -58,7 +61,7 @@ export function managedSkillProfiles(
58
61
  const appInstalled = platform === "win32"
59
62
  ? existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`))
60
63
  : existsSync(paths.claude.launcher);
61
- if (appInstalled) {
64
+ if (includeApps && appInstalled) {
62
65
  profiles.push({ label: "Impel Claude app", env: { CLAUDE_CONFIG_DIR: paths.claude.userData } });
63
66
  }
64
67
  } else {
@@ -70,7 +73,7 @@ export function managedSkillProfiles(
70
73
  const appInstalled = platform === "win32"
71
74
  ? existsSync(path.join(paths.chatgpt.codexHome, "config.toml"))
72
75
  : existsSync(paths.chatgpt.launcher);
73
- if (appInstalled) {
76
+ if (includeApps && appInstalled) {
74
77
  profiles.push({ label: "Impel ChatGPT app", env: { CODEX_HOME: paths.chatgpt.codexHome } });
75
78
  }
76
79
  }
@@ -78,8 +81,9 @@ export function managedSkillProfiles(
78
81
  return profiles;
79
82
  }
80
83
 
81
- export async function cmdSkills(argv) {
82
- const [action = "sync", targetToken] = argv;
84
+ export async function cmdSkills(argv, overrides = {}) {
85
+ const [action = "sync", rawTarget, ...rest] = argv;
86
+ const targetToken = rawTarget?.startsWith("--") ? undefined : rawTarget;
83
87
  if (action !== "sync") {
84
88
  console.error(`impel skills: unknown action "${action}". Try \`impel skills sync [claude|codex|all]\`.`);
85
89
  process.exitCode = 1;
@@ -92,17 +96,38 @@ export async function cmdSkills(argv) {
92
96
  process.exitCode = 1;
93
97
  return;
94
98
  }
99
+ const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
100
+ const { flags } = parseFlags(flagArgs, { "skip-apps": { type: "boolean" } });
95
101
 
96
- const config = loadConfig();
102
+ const io = {
103
+ loadConfig,
104
+ ensureTenantSelection,
105
+ managedProfiles: managedSkillProfiles,
106
+ syncSkills: syncSkillsSafe,
107
+ ...overrides,
108
+ };
109
+ const config = io.loadConfig();
97
110
  const gatewayUrl = resolveSkillsGateway(config?.gatewayUrl);
98
111
  const tenantId = config?.pat
99
- ? (await ensureTenantSelection(config)).tenantId
112
+ ? (await io.ensureTenantSelection(config)).tenantId
100
113
  : null;
101
114
  const clients = target === "all" ? ["claude", "codex"] : [target];
115
+ const profiles = clients.flatMap((client) => (
116
+ io.managedProfiles(client, {
117
+ tenantId,
118
+ includeApps: !flags["skip-apps"],
119
+ }).map((profile) => ({ client, profile }))
120
+ ));
102
121
 
103
- for (const client of clients) {
104
- for (const profile of managedSkillProfiles(client, { tenantId })) {
105
- await syncSkillsSafe({ client, gatewayUrl, env: profile.env, label: profile.label });
106
- }
107
- }
122
+ await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, (spinner) => (
123
+ mapWithConcurrency(profiles, 4, ({ client, profile }) => (
124
+ io.syncSkills({
125
+ client,
126
+ gatewayUrl,
127
+ env: profile.env,
128
+ label: profile.label,
129
+ logger: createProgressLogger(spinner),
130
+ })
131
+ ))
132
+ ));
108
133
  }
@@ -14,6 +14,8 @@ 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";
18
+ import { installedAppTenantIds } from "./apps.js";
17
19
  import {
18
20
  fetchRemoteVersion,
19
21
  installedVersion,
@@ -28,9 +30,9 @@ const CLI_BIN = fileURLToPath(new URL("../../bin/impel.js", import.meta.url));
28
30
  const HELP = `impel update - update everything Impel in one command
29
31
 
30
32
  Reinstalls impel-cli from npm, then cascades with the NEW build:
31
- \`impel app update all\` (update the platform's vendor apps and managed app
32
- profiles) when Impel apps are installed, then \`impel skills sync all\` and
33
- \`impel agents sync all\` across every managed profile.
33
+ \`impel app update all\` (update every locally installed tenant's vendor apps
34
+ and managed app profiles), then \`impel skills sync all\` and
35
+ \`impel agents sync all\` across native and isolated CLI profiles.
34
36
 
35
37
  Usage:
36
38
  impel update Update the CLI, then cascade to apps, skills, and agents
@@ -88,28 +90,45 @@ function defaultSelfUpdate(spec) {
88
90
 
89
91
  // The cascading steps re-execute the (freshly installed) CLI binary so the
90
92
  // NEW code performs them, not the process that started the update.
91
- function defaultRunAppsUpdate() {
92
- const result = spawnSync(process.execPath, [CLI_BIN, "app", "update", "all"], {
93
+ export function defaultRunAppsUpdate({
94
+ spawn = spawnSync,
95
+ execPath = process.execPath,
96
+ cliBin = CLI_BIN,
97
+ } = {}) {
98
+ const result = spawn(execPath, [cliBin, "app", "update", "all"], {
93
99
  stdio: "inherit",
94
100
  });
95
101
  return result.status === 0;
96
102
  }
97
103
 
98
- function defaultRunSkillsSync() {
99
- const result = spawnSync(process.execPath, [CLI_BIN, "skills", "sync", "all"], {
104
+ export function defaultRunSkillsSync({
105
+ spawn = spawnSync,
106
+ execPath = process.execPath,
107
+ cliBin = CLI_BIN,
108
+ } = {}) {
109
+ const result = spawn(execPath, [cliBin, "skills", "sync", "all", "--skip-apps"], {
100
110
  stdio: "inherit",
101
111
  });
102
112
  return result.status === 0;
103
113
  }
104
114
 
105
- function defaultRunAgentsSync() {
106
- const result = spawnSync(process.execPath, [CLI_BIN, "agents", "sync", "all"], {
115
+ export function defaultRunAgentsSync({
116
+ spawn = spawnSync,
117
+ execPath = process.execPath,
118
+ cliBin = CLI_BIN,
119
+ } = {}) {
120
+ const result = spawn(execPath, [cliBin, "agents", "sync", "all", "--skip-apps"], {
107
121
  stdio: "inherit",
108
122
  });
109
123
  return result.status === 0;
110
124
  }
111
125
 
112
126
  function anyAppInstalled(homeDir = os.homedir()) {
127
+ if (installedAppTenantIds(["claude", "chatgpt"], {
128
+ homeDir,
129
+ platform: process.platform,
130
+ environment: process.env,
131
+ }).length > 0) return true;
113
132
  const config = loadConfig();
114
133
  const claudeUserData = process.platform === "win32"
115
134
  ? windowsClaudeUserData(process.env, config?.tenantId || null)
@@ -166,7 +185,7 @@ export async function cmdUpdate(argv, overrides = {}) {
166
185
  }
167
186
 
168
187
  const current = io.installedVersion();
169
- const remote = await io.fetchRemoteVersion();
188
+ const remote = await withProgress("Checking npm for impel-cli updates", () => io.fetchRemoteVersion());
170
189
  if (remote) io.writeCache({ remoteVersion: remote, checkedAt: Date.now() });
171
190
 
172
191
  console.log(`impel-cli v${current ?? "?"}`);
@@ -185,7 +204,7 @@ export async function cmdUpdate(argv, overrides = {}) {
185
204
  console.log("CLI: already up to date.");
186
205
  } else {
187
206
  console.log("CLI: installing the latest build…");
188
- if (!io.selfUpdate(updateInstallSpec())) {
207
+ if (!await withProgress("Installing the latest impel-cli build", () => io.selfUpdate(updateInstallSpec()))) {
189
208
  console.error("impel update: `npm install -g` failed; the CLI was not updated.");
190
209
  if (io.platform === "win32") {
191
210
  console.error(" Verify `npm --version` in PowerShell, then retry `impel update`.");
@@ -197,7 +216,7 @@ export async function cmdUpdate(argv, overrides = {}) {
197
216
  console.log(`CLI: updated${remote ? ` to v${remote}` : ""}.`);
198
217
  }
199
218
 
200
- // ── Cascade: apps, then skills across every managed profile ────────────
219
+ // ── Cascade: every tenant app, then non-app managed profiles ───────────
201
220
  let cascadeFailed = false;
202
221
  if (flags["skip-apps"]) {
203
222
  console.log("Apps: skipped (--skip-apps).");
@@ -207,22 +226,22 @@ export async function cmdUpdate(argv, overrides = {}) {
207
226
  console.log("Apps: none installed; skipping (run `impel app install` or `impel setup`).");
208
227
  } else {
209
228
  console.log(io.platform === "win32"
210
- ? "Apps: updating the signed Claude and ChatGPT vendor apps and isolated profiles…"
211
- : "Apps: updating (running apps are closed, verified vendor builds installed, bundles rebuilt)…");
212
- if (!io.runAppsUpdate()) {
229
+ ? "Apps: updating every installed tenant's signed Claude and ChatGPT profiles…"
230
+ : "Apps: refreshing every installed tenant and rebuilding only stale app bundles…");
231
+ if (!await withProgress("Updating managed desktop apps", () => io.runAppsUpdate())) {
213
232
  console.error("impel update: the app update failed; re-run `impel app update` after fixing the issue.");
214
233
  cascadeFailed = true;
215
234
  }
216
235
  }
217
236
 
218
- console.log("Skills: syncing every managed profile…");
219
- if (!io.runSkillsSync()) {
237
+ console.log("Skills: syncing native and isolated CLI profiles…");
238
+ if (!await withProgress("Syncing skills across managed profiles", () => io.runSkillsSync())) {
220
239
  console.error("impel update: skill sync failed; re-run `impel skills sync` after fixing the issue.");
221
240
  cascadeFailed = true;
222
241
  }
223
242
 
224
- console.log("Agents: syncing the selected tenant into every managed profile…");
225
- if (!io.runAgentsSync()) {
243
+ console.log("Agents: syncing the selected tenant into native and isolated CLI profiles…");
244
+ if (!await withProgress("Syncing agents across managed profiles", () => io.runAgentsSync())) {
226
245
  console.error("impel update: agent sync failed; re-run `impel agents sync` after fixing the issue.");
227
246
  cascadeFailed = true;
228
247
  }
@@ -0,0 +1,106 @@
1
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
+
3
+ function noopSpinner() {
4
+ return {
5
+ active: false,
6
+ update() {},
7
+ writeLine() { return false; },
8
+ succeed() {},
9
+ fail() {},
10
+ };
11
+ }
12
+
13
+ /**
14
+ * Render a small stderr-only spinner without changing machine-readable stdout.
15
+ * Non-interactive processes and detached app refreshes get a no-op object.
16
+ */
17
+ export function createSpinner(label, { stream = process.stderr } = {}) {
18
+ if (!stream?.isTTY || process.env.IMPEL_NO_PROGRESS === "1") return noopSpinner();
19
+
20
+ let currentLabel = String(label);
21
+ let frame = 0;
22
+ let finished = false;
23
+ const startedAt = Date.now();
24
+ const render = () => {
25
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
26
+ stream.write(`\r${FRAMES[frame % FRAMES.length]} ${currentLabel} (${elapsed}s)\x1b[K`);
27
+ frame += 1;
28
+ };
29
+ const finish = (symbol, nextLabel) => {
30
+ if (finished) return;
31
+ finished = true;
32
+ clearInterval(timer);
33
+ if (nextLabel) currentLabel = String(nextLabel);
34
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
35
+ stream.write(`\r${symbol} ${currentLabel} (${elapsed}s)\x1b[K\n`);
36
+ };
37
+
38
+ render();
39
+ const timer = setInterval(render, 80);
40
+ timer.unref?.();
41
+ return {
42
+ active: true,
43
+ update(nextLabel) {
44
+ if (!finished && nextLabel) currentLabel = String(nextLabel);
45
+ },
46
+ writeLine(message) {
47
+ if (finished) return false;
48
+ stream.write(`\r\x1b[K${String(message)}\n`);
49
+ render();
50
+ return true;
51
+ },
52
+ succeed(nextLabel) {
53
+ finish("✓", nextLabel);
54
+ },
55
+ fail(nextLabel) {
56
+ finish("✗", nextLabel);
57
+ },
58
+ };
59
+ }
60
+
61
+ /** Keep ordinary log lines from overwriting an active progress display. */
62
+ export function createProgressLogger(spinner, fallback = console) {
63
+ const write = (method, args) => {
64
+ const message = args.map((value) => String(value)).join(" ");
65
+ if (!spinner?.writeLine?.(message)) fallback[method](...args);
66
+ };
67
+ return {
68
+ log: (...args) => write("log", args),
69
+ warn: (...args) => write("warn", args),
70
+ };
71
+ }
72
+
73
+ export async function withProgress(label, fn, options) {
74
+ const spinner = createSpinner(label, options);
75
+ try {
76
+ const result = await fn(spinner);
77
+ spinner.succeed();
78
+ return result;
79
+ } catch (error) {
80
+ spinner.fail();
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ /** Run independent jobs with bounded concurrency while preserving result order. */
86
+ export async function mapWithConcurrency(items, limit, fn) {
87
+ if (!Number.isInteger(limit) || limit < 1) {
88
+ throw new RangeError("concurrency limit must be a positive integer");
89
+ }
90
+ const values = Array.from(items);
91
+ const results = new Array(values.length);
92
+ let nextIndex = 0;
93
+
94
+ async function worker() {
95
+ while (nextIndex < values.length) {
96
+ const index = nextIndex;
97
+ nextIndex += 1;
98
+ results[index] = await fn(values[index], index);
99
+ }
100
+ }
101
+
102
+ await Promise.all(
103
+ Array.from({ length: Math.min(limit, values.length) }, () => worker()),
104
+ );
105
+ return results;
106
+ }
package/src/skills.js CHANGED
@@ -19,7 +19,7 @@
19
19
  // - Codex: `plugin marketplace add|upgrade`, `plugin add` (install + refresh)
20
20
  // so we keep a per-client command table rather than assuming one shape.
21
21
 
22
- import { spawnSync } from "node:child_process";
22
+ import { spawn } from "node:child_process";
23
23
 
24
24
  import { resolveDefaultGateway, normalizeGatewayUrl } from "./config.js";
25
25
  import { nativeCommandInvocation } from "./nativeProcess.js";
@@ -159,44 +159,95 @@ export function buildSkillCommands({ client, marketplaceSourceUrl: sourceUrl, ma
159
159
  return commands;
160
160
  }
161
161
 
162
- /** Default command runner: spawns the client binary non-interactively. */
163
- function defaultRun(bin, args, env) {
162
+ const SKILL_COMMAND_TIMEOUT_MS = 120_000;
163
+ const SKILL_COMMAND_OUTPUT_LIMIT = 10 * 1024 * 1024;
164
+
165
+ /** Default async command runner: keeps progress timers responsive while plugins sync. */
166
+ export function runSkillCommand(bin, args, env, {
167
+ spawnImpl = spawn,
168
+ timeoutMs = SKILL_COMMAND_TIMEOUT_MS,
169
+ } = {}) {
164
170
  const environment = { ...process.env, ...env };
165
171
  let invocation;
166
172
  try {
167
173
  invocation = nativeCommandInvocation(bin, args, environment);
168
174
  } catch (error) {
169
- return {
175
+ return Promise.resolve({
170
176
  ok: false,
171
177
  missing: false,
172
178
  status: null,
173
179
  stdout: "",
174
180
  stderr: error?.message || String(error),
175
- };
181
+ });
176
182
  }
177
- const result = spawnSync(invocation.command, invocation.args, {
178
- env: environment,
179
- encoding: "utf8",
180
- stdio: ["ignore", "pipe", "pipe"],
181
- timeout: 120000,
182
- windowsVerbatimArguments: invocation.windowsVerbatimArguments,
183
- });
184
- if (result.error) {
185
- return {
183
+
184
+ return new Promise((resolve) => {
185
+ let child;
186
+ try {
187
+ child = spawnImpl(invocation.command, invocation.args, {
188
+ env: environment,
189
+ stdio: ["ignore", "pipe", "pipe"],
190
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
191
+ });
192
+ } catch (error) {
193
+ resolve({
194
+ ok: false,
195
+ missing: error?.code === "ENOENT",
196
+ status: null,
197
+ stdout: "",
198
+ stderr: error?.message || String(error),
199
+ });
200
+ return;
201
+ }
202
+
203
+ let stdout = "";
204
+ let stderr = "";
205
+ let timedOut = false;
206
+ let settled = false;
207
+ const append = (current, chunk) => (
208
+ `${current}${String(chunk)}`.slice(-SKILL_COMMAND_OUTPUT_LIMIT)
209
+ );
210
+ child.stdout?.setEncoding?.("utf8");
211
+ child.stderr?.setEncoding?.("utf8");
212
+ child.stdout?.on("data", (chunk) => { stdout = append(stdout, chunk); });
213
+ child.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk); });
214
+
215
+ const finish = (result) => {
216
+ if (settled) return;
217
+ settled = true;
218
+ clearTimeout(timeout);
219
+ resolve(result);
220
+ };
221
+ const timeout = setTimeout(() => {
222
+ timedOut = true;
223
+ try {
224
+ child.kill();
225
+ } catch {
226
+ // The process may have exited between the timer firing and kill().
227
+ }
228
+ finish({
229
+ ok: false,
230
+ missing: false,
231
+ status: null,
232
+ stdout,
233
+ stderr: `skill command timed out after ${timeoutMs}ms`,
234
+ });
235
+ }, timeoutMs);
236
+ child.on("error", (error) => finish({
186
237
  ok: false,
187
- missing: result.error.code === "ENOENT",
238
+ missing: error?.code === "ENOENT",
188
239
  status: null,
189
- stdout: "",
190
- stderr: result.error.message || String(result.error),
191
- };
192
- }
193
- return {
194
- ok: result.status === 0,
195
- missing: false,
196
- status: result.status,
197
- stdout: result.stdout || "",
198
- stderr: result.stderr || "",
199
- };
240
+ stdout,
241
+ stderr: error?.message || String(error),
242
+ }));
243
+ child.on("close", (status) => finish({
244
+ ok: !timedOut && status === 0,
245
+ missing: false,
246
+ status,
247
+ stdout,
248
+ stderr: timedOut ? `skill command timed out after ${timeoutMs}ms` : stderr,
249
+ }));
250
+ });
200
251
  }
201
252
 
202
253
  function firstLine(text) {
@@ -237,7 +288,7 @@ export async function syncSkills({
237
288
  env = {},
238
289
  label,
239
290
  fetchImpl = fetch,
240
- run = defaultRun,
291
+ run = runSkillCommand,
241
292
  logger = console,
242
293
  } = {}) {
243
294
  const spec = CLIENT_SPECS[client];
@@ -254,7 +305,7 @@ export async function syncSkills({
254
305
 
255
306
  try {
256
307
  // Confirm the binary and its `plugin` subcommand exist before doing anything.
257
- const help = run(spec.bin, ["plugin", "--help"], env);
308
+ const help = await run(spec.bin, ["plugin", "--help"], env);
258
309
  if (help.missing) {
259
310
  logger.warn(`impel: skipping skill sync for ${displayLabel} — \`${spec.bin}\` CLI not found on PATH.`);
260
311
  return { client, label: displayLabel, skipped: true, reason: "binary-missing" };
@@ -272,7 +323,7 @@ export async function syncSkills({
272
323
  logger.log(`Skills: syncing ${SKILL_PLUGIN_NAME} for ${displayLabel}…`);
273
324
  const failures = [];
274
325
  for (const command of commands) {
275
- const result = run(spec.bin, command.args, env);
326
+ const result = await run(spec.bin, command.args, env);
276
327
  if (result.missing) {
277
328
  failures.push({ phase: command.phase, reason: `\`${spec.bin}\` disappeared mid-sync` });
278
329
  break;