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 +41 -14
- package/package.json +1 -1
- package/src/agents.js +43 -9
- package/src/apps.js +51 -2
- package/src/cli.js +2 -1
- package/src/commands/agents.js +6 -1
- package/src/commands/apps.js +242 -47
- package/src/commands/skills.js +36 -11
- package/src/commands/update.js +38 -19
- package/src/progress.js +106 -0
- package/src/skills.js +80 -29
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
|
+
`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).
|
|
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
|
-
(
|
|
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
|
-
|
|
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-
|
|
637
|
+
@agent-research-agent investigate the dependency change
|
|
627
638
|
|
|
628
639
|
# Claude blocking CLI
|
|
629
|
-
claude --agent
|
|
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
|
|
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
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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.
|
|
731
|
-
`
|
|
732
|
-
|
|
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
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) {
|
|
@@ -379,7 +379,10 @@ export function installManagedAppFiles({
|
|
|
379
379
|
|
|
380
380
|
const installed = [];
|
|
381
381
|
for (const target of targets) {
|
|
382
|
-
if (target === "claude")
|
|
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
|
|
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]
|
package/src/commands/agents.js
CHANGED
|
@@ -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",
|
|
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,
|
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 { 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
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
profiles
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
434
|
-
|
|
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(
|
|
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
|
-
|
|
444
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
|
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
|
}
|
package/src/commands/skills.js
CHANGED
|
@@ -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",
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -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
|
|
32
|
-
|
|
33
|
-
\`impel agents sync all\` across
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
211
|
-
: "Apps:
|
|
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
|
|
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
|
|
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
|
}
|
package/src/progress.js
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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:
|
|
238
|
+
missing: error?.code === "ENOENT",
|
|
188
239
|
status: null,
|
|
189
|
-
stdout
|
|
190
|
-
stderr:
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 =
|
|
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;
|