impel-cli 0.12.0 → 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
@@ -153,7 +153,7 @@ impel agents sync [claude|codex|all] Sync explicit tenant agents into na
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
155
  impel app update [target] [--tenant <org>] [--force]
156
- Refresh configs/models and rebuild only stale bundles;
156
+ `all` without --tenant updates every installed tenant;
157
157
  --force performs a clean full rebuild
158
158
  impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
159
159
  (--stale-only: no-op unless 6h+ since last sync)
@@ -167,7 +167,9 @@ impel help Show help
167
167
  impel --version Show version
168
168
 
169
169
  For app commands, `target` is `claude`, `chatgpt`/`codex`, or `all` (the
170
- 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.
171
173
 
172
174
  # `impel setup` no longer takes a per-tool target and never flips native
173
175
  # configs; flip a native tool explicitly with `impel use gateway [claude|codex]`.
@@ -239,10 +241,10 @@ before running another protected command.
239
241
  One command brings everything current, in dependency order: the CLI itself
240
242
  (`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
241
243
  then — re-executing the freshly installed build — `impel app update all`
242
- (install the CLI-pinned vendor builds and rebuild only stale vendored Impel apps)
244
+ (update every locally installed tenant and rebuild only stale vendored Impel apps)
243
245
  followed by `impel skills sync all` and `impel agents sync all` across every
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.
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.
246
248
 
247
249
  Update discovery compares the installed package version with npm's public
248
250
  `latest` metadata, cached for 6 hours in
@@ -737,9 +739,12 @@ impel app open all --tenant acme
737
739
  impel app open all --tenant globex
738
740
  ```
739
741
 
740
- Updates and uninstalls touch only the named tenant variant. The older global
741
- `Impel Claude.app` / `Impel ChatGPT.app` launcher is removed after the first
742
- 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.
743
748
 
744
749
  `impel app update` compares the installed bundle's CLI version, pinned vendor
745
750
  version, identity, profile paths, and ASAR compatibility hash before touching
@@ -749,6 +754,10 @@ in a bad state, `impel app update --force` removes the selected launcher and
749
754
  performs a clean rebuild while sweeping abandoned `.tmp-*` and `.previous-*`
750
755
  staging directories.
751
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.
760
+
752
761
  The official Claude and ChatGPT apps remain separate installations and keep
753
762
  their normal accounts, updater, profiles, and bundle identities. Each CLI
754
763
  release pins exact, tested vendor builds (v0.10.0 pins Claude `1.20186.9` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.12.0",
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/cli.js CHANGED
@@ -49,8 +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 profiles; rebuild only stale bundles
53
- (--force performs a clean full rebuild)
52
+ impel app update [target] [--tenant <org>] Update profiles; all without --tenant updates
53
+ every installed tenant (--force: clean rebuild)
54
54
  impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
55
55
  impel app status [target] [--tenant <org>] Show isolated app and vendor versions
56
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,7 +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
+ import { createProgressLogger, withProgress } from "../progress.js";
44
44
  import {
45
45
  ensureWindowsClaudeApp,
46
46
  ensureWindowsChatGPTApp,
@@ -79,6 +79,102 @@ function windowsAppTargets(targetToken) {
79
79
  return null;
80
80
  }
81
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
+
82
178
  function windowsProcessFailure(result) {
83
179
  if (result?.error?.message) return redactSecretText(result.error.message);
84
180
  if (Number.isInteger(result?.status)) return `exit code ${result.status}`;
@@ -304,8 +400,14 @@ export async function cmdWindowsApps(argv, overrides = {}) {
304
400
  console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
305
401
  const { client, env, label } = appSkillTarget(target, tenantPaths);
306
402
  if (!flags["skip-skills"]) {
307
- await withProgress(`Syncing skills for ${label}`, () => (
308
- io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label })
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
+ })
309
411
  ));
310
412
  }
311
413
  if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
@@ -347,6 +449,8 @@ export async function cmdWindowsApps(argv, overrides = {}) {
347
449
 
348
450
  export async function cmdApps(argv, overrides = {}) {
349
451
  const platform = overrides.platform || process.platform;
452
+ const allTenantUpdate = await maybeUpdateAllInstalledTenants(argv, overrides, platform);
453
+ if (allTenantUpdate !== null) return allTenantUpdate;
350
454
  if (platform === "win32") return cmdWindowsApps(argv, overrides);
351
455
  if (platform !== "darwin") {
352
456
  console.error("impel app: isolated Impel desktop apps are unavailable on this platform.");
@@ -520,8 +624,8 @@ export async function cmdApps(argv, overrides = {}) {
520
624
  for (const item of installed) {
521
625
  const { client, env, label } = appSkillTarget(item.target, paths);
522
626
  if (!flags["skip-skills"]) {
523
- await withProgress(`Syncing skills for ${label}`, () => (
524
- io.syncSkills({ client, gatewayUrl, env, label })
627
+ await withProgress(`Syncing skills for ${label}`, (spinner) => (
628
+ io.syncSkills({ client, gatewayUrl, env, label, logger: createProgressLogger(spinner) })
525
629
  ));
526
630
  }
527
631
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
@@ -628,8 +732,8 @@ export async function provisionAndOpenManagedApps({
628
732
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
629
733
  for (const item of installed) {
630
734
  const { client, env, label } = appSkillTarget(item.target, paths);
631
- await withProgress(`Syncing skills for ${label}`, () => (
632
- io.syncSkills({ client, gatewayUrl, env, label })
735
+ await withProgress(`Syncing skills for ${label}`, (spinner) => (
736
+ io.syncSkills({ client, gatewayUrl, env, label, logger: createProgressLogger(spinner) })
633
737
  ));
634
738
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
635
739
  }
@@ -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,7 +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";
18
- import { mapWithConcurrency, withProgress } from "../progress.js";
19
+ import { createProgressLogger, mapWithConcurrency, withProgress } from "../progress.js";
19
20
 
20
21
  const VALID_TARGETS = ["claude", "codex", "all"];
21
22
 
@@ -40,6 +41,7 @@ export function managedSkillProfiles(
40
41
  tenantId = null,
41
42
  platform = process.platform,
42
43
  environment = process.env,
44
+ includeApps = true,
43
45
  } = {},
44
46
  ) {
45
47
  const profiles = [];
@@ -59,7 +61,7 @@ export function managedSkillProfiles(
59
61
  const appInstalled = platform === "win32"
60
62
  ? existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`))
61
63
  : existsSync(paths.claude.launcher);
62
- if (appInstalled) {
64
+ if (includeApps && appInstalled) {
63
65
  profiles.push({ label: "Impel Claude app", env: { CLAUDE_CONFIG_DIR: paths.claude.userData } });
64
66
  }
65
67
  } else {
@@ -71,7 +73,7 @@ export function managedSkillProfiles(
71
73
  const appInstalled = platform === "win32"
72
74
  ? existsSync(path.join(paths.chatgpt.codexHome, "config.toml"))
73
75
  : existsSync(paths.chatgpt.launcher);
74
- if (appInstalled) {
76
+ if (includeApps && appInstalled) {
75
77
  profiles.push({ label: "Impel ChatGPT app", env: { CODEX_HOME: paths.chatgpt.codexHome } });
76
78
  }
77
79
  }
@@ -80,7 +82,8 @@ export function managedSkillProfiles(
80
82
  }
81
83
 
82
84
  export async function cmdSkills(argv, overrides = {}) {
83
- const [action = "sync", targetToken] = argv;
85
+ const [action = "sync", rawTarget, ...rest] = argv;
86
+ const targetToken = rawTarget?.startsWith("--") ? undefined : rawTarget;
84
87
  if (action !== "sync") {
85
88
  console.error(`impel skills: unknown action "${action}". Try \`impel skills sync [claude|codex|all]\`.`);
86
89
  process.exitCode = 1;
@@ -93,6 +96,8 @@ export async function cmdSkills(argv, overrides = {}) {
93
96
  process.exitCode = 1;
94
97
  return;
95
98
  }
99
+ const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
100
+ const { flags } = parseFlags(flagArgs, { "skip-apps": { type: "boolean" } });
96
101
 
97
102
  const io = {
98
103
  loadConfig,
@@ -108,12 +113,21 @@ export async function cmdSkills(argv, overrides = {}) {
108
113
  : null;
109
114
  const clients = target === "all" ? ["claude", "codex"] : [target];
110
115
  const profiles = clients.flatMap((client) => (
111
- io.managedProfiles(client, { tenantId }).map((profile) => ({ client, profile }))
116
+ io.managedProfiles(client, {
117
+ tenantId,
118
+ includeApps: !flags["skip-apps"],
119
+ }).map((profile) => ({ client, profile }))
112
120
  ));
113
121
 
114
- await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, () => (
122
+ await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, (spinner) => (
115
123
  mapWithConcurrency(profiles, 4, ({ client, profile }) => (
116
- io.syncSkills({ client, gatewayUrl, env: profile.env, label: profile.label })
124
+ io.syncSkills({
125
+ client,
126
+ gatewayUrl,
127
+ env: profile.env,
128
+ label: profile.label,
129
+ logger: createProgressLogger(spinner),
130
+ })
117
131
  ))
118
132
  ));
119
133
  }
@@ -15,6 +15,7 @@ import { loadConfig, redactSecretText } from "../config.js";
15
15
  import { nativeCommandInvocation } from "../nativeProcess.js";
16
16
  import { windowsClaudeUserData } from "../windowsApps.js";
17
17
  import { withProgress } from "../progress.js";
18
+ import { installedAppTenantIds } from "./apps.js";
18
19
  import {
19
20
  fetchRemoteVersion,
20
21
  installedVersion,
@@ -29,9 +30,9 @@ const CLI_BIN = fileURLToPath(new URL("../../bin/impel.js", import.meta.url));
29
30
  const HELP = `impel update - update everything Impel in one command
30
31
 
31
32
  Reinstalls impel-cli from npm, then cascades with the NEW build:
32
- \`impel app update all\` (update the platform's vendor apps and managed app
33
- profiles) when Impel apps are installed, then \`impel skills sync all\` and
34
- \`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.
35
36
 
36
37
  Usage:
37
38
  impel update Update the CLI, then cascade to apps, skills, and agents
@@ -94,27 +95,40 @@ export function defaultRunAppsUpdate({
94
95
  execPath = process.execPath,
95
96
  cliBin = CLI_BIN,
96
97
  } = {}) {
97
- const result = spawn(execPath, [cliBin, "app", "update", "all", "--skip-skills", "--skip-agents"], {
98
+ const result = spawn(execPath, [cliBin, "app", "update", "all"], {
98
99
  stdio: "inherit",
99
100
  });
100
101
  return result.status === 0;
101
102
  }
102
103
 
103
- function defaultRunSkillsSync() {
104
- 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"], {
105
110
  stdio: "inherit",
106
111
  });
107
112
  return result.status === 0;
108
113
  }
109
114
 
110
- function defaultRunAgentsSync() {
111
- 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"], {
112
121
  stdio: "inherit",
113
122
  });
114
123
  return result.status === 0;
115
124
  }
116
125
 
117
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;
118
132
  const config = loadConfig();
119
133
  const claudeUserData = process.platform === "win32"
120
134
  ? windowsClaudeUserData(process.env, config?.tenantId || null)
@@ -202,7 +216,7 @@ export async function cmdUpdate(argv, overrides = {}) {
202
216
  console.log(`CLI: updated${remote ? ` to v${remote}` : ""}.`);
203
217
  }
204
218
 
205
- // ── Cascade: apps, then skills across every managed profile ────────────
219
+ // ── Cascade: every tenant app, then non-app managed profiles ───────────
206
220
  let cascadeFailed = false;
207
221
  if (flags["skip-apps"]) {
208
222
  console.log("Apps: skipped (--skip-apps).");
@@ -212,21 +226,21 @@ export async function cmdUpdate(argv, overrides = {}) {
212
226
  console.log("Apps: none installed; skipping (run `impel app install` or `impel setup`).");
213
227
  } else {
214
228
  console.log(io.platform === "win32"
215
- ? "Apps: updating the signed Claude and ChatGPT vendor apps and isolated profiles…"
216
- : "Apps: refreshing profiles and rebuilding only stale app bundles…");
229
+ ? "Apps: updating every installed tenant's signed Claude and ChatGPT profiles…"
230
+ : "Apps: refreshing every installed tenant and rebuilding only stale app bundles…");
217
231
  if (!await withProgress("Updating managed desktop apps", () => io.runAppsUpdate())) {
218
232
  console.error("impel update: the app update failed; re-run `impel app update` after fixing the issue.");
219
233
  cascadeFailed = true;
220
234
  }
221
235
  }
222
236
 
223
- console.log("Skills: syncing every managed profile…");
237
+ console.log("Skills: syncing native and isolated CLI profiles…");
224
238
  if (!await withProgress("Syncing skills across managed profiles", () => io.runSkillsSync())) {
225
239
  console.error("impel update: skill sync failed; re-run `impel skills sync` after fixing the issue.");
226
240
  cascadeFailed = true;
227
241
  }
228
242
 
229
- console.log("Agents: syncing the selected tenant into every managed profile…");
243
+ console.log("Agents: syncing the selected tenant into native and isolated CLI profiles…");
230
244
  if (!await withProgress("Syncing agents across managed profiles", () => io.runAgentsSync())) {
231
245
  console.error("impel update: agent sync failed; re-run `impel agents sync` after fixing the issue.");
232
246
  cascadeFailed = true;
package/src/progress.js CHANGED
@@ -2,7 +2,9 @@ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "
2
2
 
3
3
  function noopSpinner() {
4
4
  return {
5
+ active: false,
5
6
  update() {},
7
+ writeLine() { return false; },
6
8
  succeed() {},
7
9
  fail() {},
8
10
  };
@@ -37,9 +39,16 @@ export function createSpinner(label, { stream = process.stderr } = {}) {
37
39
  const timer = setInterval(render, 80);
38
40
  timer.unref?.();
39
41
  return {
42
+ active: true,
40
43
  update(nextLabel) {
41
44
  if (!finished && nextLabel) currentLabel = String(nextLabel);
42
45
  },
46
+ writeLine(message) {
47
+ if (finished) return false;
48
+ stream.write(`\r\x1b[K${String(message)}\n`);
49
+ render();
50
+ return true;
51
+ },
43
52
  succeed(nextLabel) {
44
53
  finish("✓", nextLabel);
45
54
  },
@@ -49,6 +58,18 @@ export function createSpinner(label, { stream = process.stderr } = {}) {
49
58
  };
50
59
  }
51
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
+
52
73
  export async function withProgress(label, fn, options) {
53
74
  const spinner = createSpinner(label, options);
54
75
  try {
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;