impel-cli 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -20
- package/package.json +1 -1
- package/src/agents.js +480 -0
- package/src/cli.js +10 -3
- package/src/commands/agents.js +96 -0
- package/src/commands/apps.js +151 -38
- package/src/commands/launch.js +14 -0
- package/src/commands/update.js +18 -4
- package/src/commands/use.js +23 -1
package/README.md
CHANGED
|
@@ -118,7 +118,8 @@ impel setup [--pat <pat>] [--tenant <org>] [--skip-apps] [--skip-clis]
|
|
|
118
118
|
apps, then installs the vendored Impel apps on macOS;
|
|
119
119
|
installs missing vendor CLIs on Windows)
|
|
120
120
|
impel update [--check] [--skip-apps] Update EVERYTHING: reinstall the CLI from npm, then
|
|
121
|
-
cascade to `app update all
|
|
121
|
+
cascade to `app update all`, `skills sync all`, and
|
|
122
|
+
`agents sync all`
|
|
122
123
|
impel auth [--pat <pat>] [--gateway <url>] [--app <url>]
|
|
123
124
|
Store your Impel PAT + URLs
|
|
124
125
|
impel pat create --label <label> [options] Mint a personal PAT through the control plane
|
|
@@ -147,15 +148,16 @@ impel use account [claude|codex|all] Revert to your own login (defaul
|
|
|
147
148
|
impel off [claude|codex|all] Alias for `impel use account`
|
|
148
149
|
|
|
149
150
|
impel skills sync [claude|codex|all] Sync Bifrost shared skills into managed clients (default: all)
|
|
151
|
+
impel agents sync [claude|codex|all] Sync the selected tenant's specialists as native agents
|
|
150
152
|
|
|
151
153
|
impel status Launcher readiness + native mode + gateway reachability
|
|
152
154
|
impel app install [target] [--tenant <org>] Install isolated apps for one tenant
|
|
153
155
|
impel app update [target] [--tenant <org>] Refresh that tenant's apps, configs, and models
|
|
154
|
-
impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
|
|
156
|
+
impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
|
|
155
157
|
(--stale-only: no-op unless 6h+ since last sync)
|
|
156
158
|
impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
|
|
157
|
-
impel app open [target] [--tenant <org>]
|
|
158
|
-
|
|
159
|
+
impel app open [target] [--tenant <org>] Install/configure a tenant on first use, then launch
|
|
160
|
+
it (later opens are immediate when nothing changed;
|
|
159
161
|
a detached stale-only refresh keeps things current)
|
|
160
162
|
impel app uninstall [target] [--tenant <org>] [--keep-data]
|
|
161
163
|
Remove one tenant's Impel-managed app files
|
|
@@ -236,7 +238,8 @@ One command brings everything current, in dependency order: the CLI itself
|
|
|
236
238
|
(`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
|
|
237
239
|
then — re-executing the freshly installed build — `impel app update all`
|
|
238
240
|
(close running apps, install the CLI-pinned vendor builds, rebuild the vendored Impel apps)
|
|
239
|
-
|
|
241
|
+
followed by `impel skills sync all` and `impel agents sync all` across every
|
|
242
|
+
managed profile.
|
|
240
243
|
|
|
241
244
|
Update discovery compares the installed package version with npm's public
|
|
242
245
|
`latest` metadata, cached for 6 hours in
|
|
@@ -248,8 +251,8 @@ remote is ahead, and refresh the cache in a detached background process when
|
|
|
248
251
|
it's stale (TTY-only, never blocks a launch; set `IMPEL_SKIP_UPDATE_CHECK=1`
|
|
249
252
|
to silence). The Impel desktop apps keep themselves current too: their token
|
|
250
253
|
helper kicks off a detached `impel app refresh --stale-only` on use, which
|
|
251
|
-
re-syncs configs, the model catalog, and
|
|
252
|
-
while the apps are running because it never swaps an app bundle.
|
|
254
|
+
re-syncs configs, the model catalog, skills, and native agents at most every six
|
|
255
|
+
hours — safe while the apps are running because it never swaps an app bundle.
|
|
253
256
|
|
|
254
257
|
### `impel tasks ...` (aliases `impel task`, `impel tickets`, `impel ticket`)
|
|
255
258
|
|
|
@@ -292,9 +295,13 @@ On macOS, desktop app state is isolated under
|
|
|
292
295
|
bundles in `~/Applications`, with names such as `Impel Claude (Acme)` and
|
|
293
296
|
`Impel ChatGPT (Acme)`, stable tenant-specific bundle identifiers, and fixed
|
|
294
297
|
tenant profile/auth paths. Different tenants can stay open concurrently.
|
|
295
|
-
`impel app open --tenant <org>`
|
|
296
|
-
|
|
297
|
-
|
|
298
|
+
`impel app open --tenant <org>` installs and configures a missing tenant variant
|
|
299
|
+
on first use, then launches it directly without changing the globally selected
|
|
300
|
+
tenant. Later opens use the existing isolated app and profile. Switching tenants
|
|
301
|
+
never deletes local history or projects; each tenant's preserved profile reopens
|
|
302
|
+
independently. The first-use setup still requires an authenticated user PAT and
|
|
303
|
+
live membership in the requested organization; it does not create a PAT per
|
|
304
|
+
tenant.
|
|
298
305
|
Windows Claude Desktop keeps each
|
|
299
306
|
tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
|
|
300
307
|
signed vendor executable with that profile selected. Windows ChatGPT keeps its
|
|
@@ -385,7 +392,9 @@ MCP entry point, reads the PAT from the owner-only Impel
|
|
|
385
392
|
config, and proxies JSON-RPC to `<gateway>/mcp`; the PAT is never written into
|
|
386
393
|
either client's MCP configuration. Available specialists are discovered at
|
|
387
394
|
runtime, so publishing a new opted-in specialist does not require another CLI
|
|
388
|
-
release.
|
|
395
|
+
release. The same authenticated catalog is rendered into native Claude Code
|
|
396
|
+
and Codex agent definitions. Each definition carries only the direct
|
|
397
|
+
`impel mcp --tenant <org>` subprocess invocation, never the PAT itself.
|
|
389
398
|
|
|
390
399
|
### Isolated CLI launchers
|
|
391
400
|
|
|
@@ -460,8 +469,9 @@ the selected tenant's 3P gateway configuration beneath Anthropic's reserved
|
|
|
460
469
|
normal Claude profile unchanged. `impel app install codex` installs/updates
|
|
461
470
|
OpenAI's `OpenAI.Codex` Store package, stages its unchanged signed app payload
|
|
462
471
|
inside Impel's shared app cache, and writes separate Codex and Chromium
|
|
463
|
-
profiles. `impel app open claude` and `impel app open codex`
|
|
464
|
-
|
|
472
|
+
profiles. `impel app open claude` and `impel app open codex` install a missing
|
|
473
|
+
official app and configure the selected tenant on first use, then launch the
|
|
474
|
+
isolated profile without changing either normal vendor profile.
|
|
465
475
|
|
|
466
476
|
For Claude, the Impel PAT is supplied only to the launched child process as an
|
|
467
477
|
authentication token and disappears when that process exits; it is never
|
|
@@ -579,6 +589,53 @@ best-effort: if a client CLI, its `plugin` subcommand, or the gateway is
|
|
|
579
589
|
unavailable, it logs a warning and moves on — it never fails the parent command.
|
|
580
590
|
Set `IMPEL_SKIP_SKILL_SYNC=1` to disable it entirely (offline/CI).
|
|
581
591
|
|
|
592
|
+
### `impel agents sync [claude|codex|all]`
|
|
593
|
+
|
|
594
|
+
Fetches the selected organization's opted-in specialist catalog through the
|
|
595
|
+
authenticated Impel MCP endpoint, then generates native custom-agent files for
|
|
596
|
+
every managed profile:
|
|
597
|
+
|
|
598
|
+
- Claude Code: `<CLAUDE_CONFIG_DIR>/agents/impel-managed/*.md`
|
|
599
|
+
- Codex / ChatGPT Codex mode: `<CODEX_HOME>/agents/impel-managed/*.toml`
|
|
600
|
+
|
|
601
|
+
Each generated agent is bound to one exact specialist `agentId` and
|
|
602
|
+
`scopeParam`. It verifies that specialist is still available, starts one
|
|
603
|
+
idempotent run, polls the durable run to a terminal state, and returns
|
|
604
|
+
`result.finalText` without repeating the work. The files include a tenant-bound
|
|
605
|
+
MCP subprocess definition so they also work in isolated desktop profiles. They
|
|
606
|
+
never contain the PAT; the subprocess reads it from Impel's owner-only config
|
|
607
|
+
when invoked.
|
|
608
|
+
|
|
609
|
+
The sync writes atomically and records a manifest beside the generated files.
|
|
610
|
+
It removes only stale filenames from that manifest, so user-authored agents are
|
|
611
|
+
never deleted. Automatic refreshes are best-effort and retain the last good
|
|
612
|
+
catalog when the gateway is unavailable; the explicit command exits non-zero
|
|
613
|
+
on failure. Set `IMPEL_SKIP_AGENT_SYNC=1` to keep the current generated catalog
|
|
614
|
+
without contacting the gateway.
|
|
615
|
+
|
|
616
|
+
Start a new task after syncing. Claude Code detects updates to an existing
|
|
617
|
+
`agents/` directory within a few seconds, but if the sync created that directory
|
|
618
|
+
for the first time while Claude was already running, restart Claude once.
|
|
619
|
+
|
|
620
|
+
Invocation uses the clients' native agent behavior:
|
|
621
|
+
|
|
622
|
+
```text
|
|
623
|
+
# Claude Code / Claude Desktop Code tab: guaranteed explicit selection
|
|
624
|
+
@agent-impel-acme-research-agent investigate the dependency change
|
|
625
|
+
|
|
626
|
+
# Claude blocking CLI
|
|
627
|
+
claude --agent impel-acme-research-agent -p "investigate the dependency change"
|
|
628
|
+
|
|
629
|
+
# Codex CLI / ChatGPT desktop Codex task
|
|
630
|
+
Use the configured impel-acme-research-agent subagent for this request and wait for it.
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
Claude exposes native custom agents in its `@` typeahead. Current Codex releases
|
|
634
|
+
load and display the generated native agents but do not provide an `@agent`
|
|
635
|
+
mention target; named spawning remains model-mediated and may also be limited by
|
|
636
|
+
the active MultiAgentV2 tool schema. Use `/agent` in Codex to inspect spawned
|
|
637
|
+
agent threads.
|
|
638
|
+
|
|
582
639
|
### `impel status`
|
|
583
640
|
|
|
584
641
|
Shows whether the isolated CLI launchers are ready, and whether each native
|
|
@@ -602,10 +659,8 @@ Codex CLI and the Codex IDE extension / desktop app share the same
|
|
|
602
659
|
### Windows Claude and ChatGPT Desktop
|
|
603
660
|
|
|
604
661
|
```powershell
|
|
605
|
-
impel app install claude
|
|
606
662
|
impel app open claude
|
|
607
663
|
impel app status claude
|
|
608
|
-
impel app install codex
|
|
609
664
|
impel app open codex
|
|
610
665
|
impel app status codex
|
|
611
666
|
```
|
|
@@ -630,7 +685,8 @@ app-target names.
|
|
|
630
685
|
|
|
631
686
|
### macOS Claude and ChatGPT
|
|
632
687
|
|
|
633
|
-
`impel app
|
|
688
|
+
The first `impel app open --tenant <org>` (or an explicit `impel app install`)
|
|
689
|
+
creates tenant-specific apps such as
|
|
634
690
|
`Impel Claude (acme).app` and `Impel ChatGPT (acme).app` in `~/Applications`.
|
|
635
691
|
Their visible bundle names use the organization display name, for example
|
|
636
692
|
`Impel Claude (Acme)` and `Impel ChatGPT (Acme)`. Each tenant/product pair has
|
|
@@ -657,11 +713,10 @@ a stable, unique bundle identifier and gateway configuration:
|
|
|
657
713
|
- Neither app changes `~/.claude`, `~/.claude.json`, `~/.codex`, or the
|
|
658
714
|
normal vendor app's browser/profile data.
|
|
659
715
|
|
|
660
|
-
|
|
716
|
+
Open several tenants without changing the current selection. A missing variant
|
|
717
|
+
is installed and configured automatically before it opens:
|
|
661
718
|
|
|
662
719
|
```sh
|
|
663
|
-
impel app install all --tenant acme
|
|
664
|
-
impel app install all --tenant globex
|
|
665
720
|
impel app open all --tenant acme
|
|
666
721
|
impel app open all --tenant globex
|
|
667
722
|
```
|
|
@@ -672,7 +727,7 @@ successful tenant-specific rebuild.
|
|
|
672
727
|
|
|
673
728
|
The official Claude and ChatGPT apps remain separate installations and keep
|
|
674
729
|
their normal accounts, updater, profiles, and bundle identities. Each CLI
|
|
675
|
-
release pins exact, tested vendor builds (v0.
|
|
730
|
+
release pins exact, tested vendor builds (v0.10.0 pins Claude `1.20186.9` and
|
|
676
731
|
ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
|
|
677
732
|
an existing official app only when its version exactly matches; otherwise it
|
|
678
733
|
downloads the pinned signed archive into Impel's managed vendor cache under
|
package/package.json
CHANGED
package/src/agents.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// Synchronizes tenant-scoped Impel specialists into the native custom-agent
|
|
2
|
+
// registries used by Claude Code and Codex. Unlike the public shared-skill
|
|
3
|
+
// marketplace, the specialist catalog is authenticated and organization
|
|
4
|
+
// specific, so it is fetched through the selected tenant's MCP session.
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import { normalizeGatewayUrl, redactSecretText } from "./config.js";
|
|
12
|
+
import { impelMcpInvocation } from "./selfInvocation.js";
|
|
13
|
+
import { normalizeTenantId } from "./tenants.js";
|
|
14
|
+
|
|
15
|
+
export const AGENT_SYNC_TTL_MS = 6 * 60 * 60 * 1000;
|
|
16
|
+
export const MANAGED_AGENT_DIRECTORY = "impel-managed";
|
|
17
|
+
export const MANAGED_AGENT_MANIFEST = ".manifest.json";
|
|
18
|
+
export const SPECIALIST_LIST_TOOL = "impel_specialists-list_specialists";
|
|
19
|
+
export const SPECIALIST_START_TOOL = "impel_specialists-start_specialist_run";
|
|
20
|
+
export const SPECIALIST_READ_TOOL = "impel_specialists-read_specialist_run";
|
|
21
|
+
export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
|
|
22
|
+
|
|
23
|
+
const SPECIALIST_TOOL_NAMES = [
|
|
24
|
+
SPECIALIST_LIST_TOOL,
|
|
25
|
+
SPECIALIST_START_TOOL,
|
|
26
|
+
SPECIALIST_READ_TOOL,
|
|
27
|
+
];
|
|
28
|
+
const SAFE_SPECIALIST_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
|
|
29
|
+
const SAFE_SCOPE_PARAM_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
|
|
30
|
+
const MAX_CATALOG_ITEMS = 500;
|
|
31
|
+
|
|
32
|
+
function privateDirectory(directory) {
|
|
33
|
+
if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) {
|
|
34
|
+
throw new Error(`refusing to use symlinked managed-agent directory ${directory}`);
|
|
35
|
+
}
|
|
36
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
37
|
+
try {
|
|
38
|
+
fs.chmodSync(directory, 0o700);
|
|
39
|
+
} catch {
|
|
40
|
+
// Best effort on Windows.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function atomicPrivateWrite(filePath, contents) {
|
|
45
|
+
privateDirectory(path.dirname(filePath));
|
|
46
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
|
|
49
|
+
fs.renameSync(temporaryPath, filePath);
|
|
50
|
+
try {
|
|
51
|
+
fs.chmodSync(filePath, 0o600);
|
|
52
|
+
} catch {
|
|
53
|
+
// Best effort on Windows.
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
try {
|
|
57
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
58
|
+
} catch {
|
|
59
|
+
// The normal rename path already removed it.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function boundedString(value, field, { max = 1024, pattern = null } = {}) {
|
|
65
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
66
|
+
throw new Error(`specialist catalog returned an invalid ${field}`);
|
|
67
|
+
}
|
|
68
|
+
const normalized = value.trim();
|
|
69
|
+
if (normalized.length > max || (pattern && !pattern.test(normalized))) {
|
|
70
|
+
throw new Error(`specialist catalog returned an invalid ${field}`);
|
|
71
|
+
}
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stringList(value, field) {
|
|
76
|
+
if (!Array.isArray(value) || value.length > 100) {
|
|
77
|
+
throw new Error(`specialist catalog returned an invalid ${field}`);
|
|
78
|
+
}
|
|
79
|
+
return value.map((item) => boundedString(item, field, { max: 512 }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizeSpecialistCatalog(payload, expectedTenantId) {
|
|
83
|
+
const tenantId = normalizeTenantId(expectedTenantId);
|
|
84
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
85
|
+
throw new Error("specialist catalog returned an invalid response");
|
|
86
|
+
}
|
|
87
|
+
if (normalizeTenantId(payload.orgId) !== tenantId) {
|
|
88
|
+
throw new Error("specialist catalog returned the wrong organization");
|
|
89
|
+
}
|
|
90
|
+
if (!Array.isArray(payload.specialists) || payload.specialists.length > MAX_CATALOG_ITEMS) {
|
|
91
|
+
throw new Error("specialist catalog returned an invalid specialists list");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const seenIds = new Set();
|
|
95
|
+
const specialists = payload.specialists.map((specialist) => {
|
|
96
|
+
if (!specialist || typeof specialist !== "object" || Array.isArray(specialist)) {
|
|
97
|
+
throw new Error("specialist catalog returned an invalid specialist");
|
|
98
|
+
}
|
|
99
|
+
const agentId = boundedString(specialist.agentId, "agentId", {
|
|
100
|
+
max: 160,
|
|
101
|
+
pattern: SAFE_SPECIALIST_ID_RE,
|
|
102
|
+
});
|
|
103
|
+
if (seenIds.has(agentId)) throw new Error(`specialist catalog returned duplicate agentId ${agentId}`);
|
|
104
|
+
seenIds.add(agentId);
|
|
105
|
+
return {
|
|
106
|
+
agentId,
|
|
107
|
+
title: boundedString(specialist.title, "title", { max: 160 }),
|
|
108
|
+
description: boundedString(specialist.description, "description", { max: 1024 }),
|
|
109
|
+
scopeParam: boundedString(specialist.scopeParam, "scopeParam", {
|
|
110
|
+
max: 160,
|
|
111
|
+
pattern: SAFE_SCOPE_PARAM_RE,
|
|
112
|
+
}),
|
|
113
|
+
provider: boundedString(specialist.provider, "provider", { max: 80 }),
|
|
114
|
+
capabilities: stringList(specialist.capabilities, "capabilities"),
|
|
115
|
+
exclusions: stringList(specialist.exclusions, "exclusions"),
|
|
116
|
+
requiredContext: stringList(specialist.requiredContext, "requiredContext"),
|
|
117
|
+
sideEffects: boundedString(specialist.sideEffects, "sideEffects", { max: 256 }),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
return { orgId: tenantId, specialists };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function decodeMcpResponse(contentType, body) {
|
|
124
|
+
if (!body.trim()) return [];
|
|
125
|
+
if (!String(contentType || "").toLowerCase().includes("text/event-stream")) {
|
|
126
|
+
return [JSON.parse(body)];
|
|
127
|
+
}
|
|
128
|
+
return body
|
|
129
|
+
.split(/\r?\n/u)
|
|
130
|
+
.filter((line) => line.startsWith("data:"))
|
|
131
|
+
.map((line) => line.slice(5).trim())
|
|
132
|
+
.filter((line) => line && line !== "[DONE]")
|
|
133
|
+
.map((line) => JSON.parse(line));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function postMcp({ endpoint, credential, sessionId, message, fetchImpl, timeoutMs }) {
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
139
|
+
let response;
|
|
140
|
+
try {
|
|
141
|
+
response = await fetchImpl(endpoint, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: {
|
|
144
|
+
Authorization: `Bearer ${credential}`,
|
|
145
|
+
"Content-Type": "application/json",
|
|
146
|
+
Accept: "application/json, text/event-stream",
|
|
147
|
+
...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify(message),
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const detail = error?.name === "AbortError" ? "request timed out" : error?.message || error;
|
|
154
|
+
throw new Error(`specialist MCP request failed: ${redactSecretText(detail)}`);
|
|
155
|
+
} finally {
|
|
156
|
+
clearTimeout(timeout);
|
|
157
|
+
}
|
|
158
|
+
const body = await response.text();
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
throw new Error(`specialist MCP returned HTTP ${response.status}`);
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
sessionId: response.headers.get("mcp-session-id") || sessionId || null,
|
|
164
|
+
messages: decodeMcpResponse(response.headers.get("content-type"), body),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function responseForId(messages, id) {
|
|
169
|
+
return messages.find((message) => message?.id === id) || null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function specialistPayloadFromToolResult(message) {
|
|
173
|
+
if (!message) throw new Error("specialist MCP returned no tool result");
|
|
174
|
+
if (message.error) {
|
|
175
|
+
throw new Error(redactSecretText(message.error.message || "specialist MCP tool failed"));
|
|
176
|
+
}
|
|
177
|
+
const result = message.result;
|
|
178
|
+
if (result?.isError) throw new Error("specialist MCP list tool returned an error");
|
|
179
|
+
if (result?.structuredContent && typeof result.structuredContent === "object") {
|
|
180
|
+
return result.structuredContent;
|
|
181
|
+
}
|
|
182
|
+
const text = result?.content?.find((item) => item?.type === "text" && typeof item.text === "string")?.text;
|
|
183
|
+
if (!text) throw new Error("specialist MCP list tool returned no catalog");
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(text);
|
|
186
|
+
} catch {
|
|
187
|
+
throw new Error("specialist MCP list tool returned invalid JSON");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function fetchSpecialistCatalog({
|
|
192
|
+
gatewayUrl,
|
|
193
|
+
credential,
|
|
194
|
+
tenantId,
|
|
195
|
+
fetchImpl = fetch,
|
|
196
|
+
timeoutMs = 20_000,
|
|
197
|
+
}) {
|
|
198
|
+
if (!credential) throw new Error("a tenant credential is required for agent sync");
|
|
199
|
+
const endpoint = `${normalizeGatewayUrl(gatewayUrl)}/mcp`;
|
|
200
|
+
let sessionId = null;
|
|
201
|
+
const initialized = await postMcp({
|
|
202
|
+
endpoint,
|
|
203
|
+
credential,
|
|
204
|
+
sessionId,
|
|
205
|
+
fetchImpl,
|
|
206
|
+
timeoutMs,
|
|
207
|
+
message: {
|
|
208
|
+
jsonrpc: "2.0",
|
|
209
|
+
id: 1,
|
|
210
|
+
method: "initialize",
|
|
211
|
+
params: {
|
|
212
|
+
protocolVersion: "2025-06-18",
|
|
213
|
+
capabilities: {},
|
|
214
|
+
clientInfo: { name: "impel-cli-agent-sync", version: "1.0.0" },
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
sessionId = initialized.sessionId;
|
|
219
|
+
const initializeMessage = responseForId(initialized.messages, 1);
|
|
220
|
+
if (initializeMessage?.error || !initializeMessage?.result) {
|
|
221
|
+
throw new Error("specialist MCP initialization failed");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const ready = await postMcp({
|
|
225
|
+
endpoint,
|
|
226
|
+
credential,
|
|
227
|
+
sessionId,
|
|
228
|
+
fetchImpl,
|
|
229
|
+
timeoutMs,
|
|
230
|
+
message: { jsonrpc: "2.0", method: "notifications/initialized", params: {} },
|
|
231
|
+
});
|
|
232
|
+
sessionId = ready.sessionId;
|
|
233
|
+
const listed = await postMcp({
|
|
234
|
+
endpoint,
|
|
235
|
+
credential,
|
|
236
|
+
sessionId,
|
|
237
|
+
fetchImpl,
|
|
238
|
+
timeoutMs,
|
|
239
|
+
message: {
|
|
240
|
+
jsonrpc: "2.0",
|
|
241
|
+
id: 2,
|
|
242
|
+
method: "tools/call",
|
|
243
|
+
params: { name: SPECIALIST_LIST_TOOL, arguments: {} },
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
return normalizeSpecialistCatalog(
|
|
247
|
+
specialistPayloadFromToolResult(responseForId(listed.messages, 2)),
|
|
248
|
+
tenantId,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function slug(value) {
|
|
253
|
+
return String(value)
|
|
254
|
+
.toLowerCase()
|
|
255
|
+
.replace(/[^a-z0-9]+/gu, "-")
|
|
256
|
+
.replace(/^-+|-+$/gu, "")
|
|
257
|
+
.slice(0, 48) || "agent";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function generatedAgentNames(tenantId, specialists) {
|
|
261
|
+
const used = new Map();
|
|
262
|
+
return specialists.map((specialist) => {
|
|
263
|
+
const base = `impel-${slug(tenantId)}-${slug(specialist.agentId)}`.slice(0, 63).replace(/-+$/u, "");
|
|
264
|
+
const previous = used.get(base);
|
|
265
|
+
used.set(base, (previous || 0) + 1);
|
|
266
|
+
if (!previous) return base;
|
|
267
|
+
const suffix = crypto.createHash("sha256").update(specialist.agentId).digest("hex").slice(0, 8);
|
|
268
|
+
return `${base.slice(0, 54)}-${suffix}`;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function adapterInstructions(tenantId, specialist) {
|
|
273
|
+
const toolName = nativeToolName;
|
|
274
|
+
const contextRequirement = specialist.requiredContext.length
|
|
275
|
+
? ` Required context keys are ${JSON.stringify(specialist.requiredContext)}; if any are absent, ask for them before starting the run.`
|
|
276
|
+
: "";
|
|
277
|
+
return [
|
|
278
|
+
`You are a thin transport adapter for the exact Impel specialist ${JSON.stringify(specialist.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
|
|
279
|
+
`Do not perform the assigned task yourself and do not delegate to any other specialist.`,
|
|
280
|
+
`First call ${toolName(SPECIALIST_LIST_TOOL)} and verify that the exact agentId is still available. If it is unavailable or its policy excludes the request, stop with that explicit error.`,
|
|
281
|
+
`Call ${toolName(SPECIALIST_START_TOOL)} exactly once with agentId ${JSON.stringify(specialist.agentId)}, scopeParam ${JSON.stringify(specialist.scopeParam)}, task set to the complete assigned task, context and contextKeys containing all supplied context, and one stable idempotencyKey that you reuse for this logical task.${contextRequirement}`,
|
|
282
|
+
`Then call ${toolName(SPECIALIST_READ_TOOL)} with the returned runId and waitSeconds 20 until the run reaches a terminal state.`,
|
|
283
|
+
`When it succeeds, return result.finalText faithfully as the answer. When it fails, return the durable runId, preserved output, and error. Never invent or independently synthesize a replacement result.`,
|
|
284
|
+
].join(" ");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function nativeToolName(toolName) {
|
|
288
|
+
return `mcp__${MANAGED_AGENT_MCP_SERVER}__${toolName}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderClaudeAgent({ tenantId, specialist, name, invocation }) {
|
|
292
|
+
const description = `Runs ${specialist.title} for Impel tenant ${tenantId}: ${specialist.description}`.slice(0, 900);
|
|
293
|
+
const lines = [
|
|
294
|
+
"---",
|
|
295
|
+
`name: ${JSON.stringify(name)}`,
|
|
296
|
+
`description: ${JSON.stringify(description)}`,
|
|
297
|
+
"model: inherit",
|
|
298
|
+
"tools:",
|
|
299
|
+
...SPECIALIST_TOOL_NAMES.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`),
|
|
300
|
+
"mcpServers:",
|
|
301
|
+
` - ${MANAGED_AGENT_MCP_SERVER}:`,
|
|
302
|
+
" type: stdio",
|
|
303
|
+
` command: ${JSON.stringify(invocation.command)}`,
|
|
304
|
+
" args:",
|
|
305
|
+
...invocation.args.map((argument) => ` - ${JSON.stringify(argument)}`),
|
|
306
|
+
" env:",
|
|
307
|
+
...Object.entries(invocation.env || {}).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`),
|
|
308
|
+
"---",
|
|
309
|
+
"",
|
|
310
|
+
adapterInstructions(tenantId, specialist),
|
|
311
|
+
"",
|
|
312
|
+
];
|
|
313
|
+
return lines.join("\n");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderCodexAgent({ tenantId, specialist, name, invocation }) {
|
|
317
|
+
const description = `Runs ${specialist.title} for Impel tenant ${tenantId}: ${specialist.description}`.slice(0, 900);
|
|
318
|
+
const envEntries = Object.entries(invocation.env || {})
|
|
319
|
+
.map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`)
|
|
320
|
+
.join(", ");
|
|
321
|
+
const lines = [
|
|
322
|
+
`name = ${JSON.stringify(name)}`,
|
|
323
|
+
`description = ${JSON.stringify(description)}`,
|
|
324
|
+
'sandbox_mode = "read-only"',
|
|
325
|
+
`developer_instructions = ${JSON.stringify(adapterInstructions(tenantId, specialist))}`,
|
|
326
|
+
"",
|
|
327
|
+
`[mcp_servers.${MANAGED_AGENT_MCP_SERVER}]`,
|
|
328
|
+
`command = ${JSON.stringify(invocation.command)}`,
|
|
329
|
+
`args = [${invocation.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
|
|
330
|
+
`enabled_tools = [${SPECIALIST_TOOL_NAMES.map((tool) => JSON.stringify(tool)).join(", ")}]`,
|
|
331
|
+
...(envEntries ? [`env = { ${envEntries} }`] : []),
|
|
332
|
+
"",
|
|
333
|
+
];
|
|
334
|
+
for (const tool of SPECIALIST_TOOL_NAMES) {
|
|
335
|
+
lines.push(`[mcp_servers.${MANAGED_AGENT_MCP_SERVER}.tools.${JSON.stringify(tool)}]`, 'approval_mode = "approve"', "");
|
|
336
|
+
}
|
|
337
|
+
return lines.join("\n");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function renderManagedAgents(client, tenantId, specialists, invocation = impelMcpInvocation(["--tenant", tenantId])) {
|
|
341
|
+
if (client !== "claude" && client !== "codex") throw new Error(`unknown agent client ${client}`);
|
|
342
|
+
const normalizedTenant = normalizeTenantId(tenantId);
|
|
343
|
+
const names = generatedAgentNames(normalizedTenant, specialists);
|
|
344
|
+
return specialists.map((specialist, index) => {
|
|
345
|
+
const name = names[index];
|
|
346
|
+
const extension = client === "claude" ? ".md" : ".toml";
|
|
347
|
+
const contents = client === "claude"
|
|
348
|
+
? renderClaudeAgent({ tenantId: normalizedTenant, specialist, name, invocation })
|
|
349
|
+
: renderCodexAgent({ tenantId: normalizedTenant, specialist, name, invocation });
|
|
350
|
+
return { agentId: specialist.agentId, name, fileName: `${name}${extension}`, contents };
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function agentProfileRoot(client, {
|
|
355
|
+
env = {},
|
|
356
|
+
environment = process.env,
|
|
357
|
+
homeDir = os.homedir(),
|
|
358
|
+
} = {}) {
|
|
359
|
+
if (client === "claude") {
|
|
360
|
+
return env.CLAUDE_CONFIG_DIR || environment.CLAUDE_CONFIG_DIR || path.join(homeDir, ".claude");
|
|
361
|
+
}
|
|
362
|
+
if (client === "codex") {
|
|
363
|
+
return env.CODEX_HOME || environment.CODEX_HOME || path.join(homeDir, ".codex");
|
|
364
|
+
}
|
|
365
|
+
throw new Error(`unknown agent client ${client}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function readManifest(manifestPath) {
|
|
369
|
+
try {
|
|
370
|
+
const value = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
371
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.files)) return null;
|
|
372
|
+
return value;
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error?.code === "ENOENT") return null;
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function profileIsFresh(profile, tenantId, now, ttlMs) {
|
|
380
|
+
const manifest = readManifest(path.join(profile.root, "agents", MANAGED_AGENT_DIRECTORY, MANAGED_AGENT_MANIFEST));
|
|
381
|
+
if (!manifest || manifest.tenantId !== tenantId) return false;
|
|
382
|
+
const syncedAt = Date.parse(manifest.syncedAt || "");
|
|
383
|
+
if (!Number.isFinite(syncedAt) || now - syncedAt >= ttlMs) return false;
|
|
384
|
+
return manifest.files.every((fileName) =>
|
|
385
|
+
typeof fileName === "string"
|
|
386
|
+
&& path.basename(fileName) === fileName
|
|
387
|
+
&& fs.existsSync(path.join(profile.root, "agents", MANAGED_AGENT_DIRECTORY, fileName))
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function syncAgentProfile({ client, root, label, tenantId, specialists, now = Date.now() }) {
|
|
392
|
+
const agentsDir = path.join(root, "agents");
|
|
393
|
+
for (const candidate of [root, agentsDir]) {
|
|
394
|
+
if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink()) {
|
|
395
|
+
throw new Error(`refusing to use symlinked native-agent path ${candidate}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const managedDir = path.join(agentsDir, MANAGED_AGENT_DIRECTORY);
|
|
399
|
+
privateDirectory(managedDir);
|
|
400
|
+
const manifestPath = path.join(managedDir, MANAGED_AGENT_MANIFEST);
|
|
401
|
+
const prior = readManifest(manifestPath);
|
|
402
|
+
const rendered = renderManagedAgents(client, tenantId, specialists);
|
|
403
|
+
|
|
404
|
+
for (const agent of rendered) {
|
|
405
|
+
atomicPrivateWrite(path.join(managedDir, agent.fileName), agent.contents);
|
|
406
|
+
}
|
|
407
|
+
const currentFiles = new Set(rendered.map((agent) => agent.fileName));
|
|
408
|
+
for (const stale of prior?.files || []) {
|
|
409
|
+
if (
|
|
410
|
+
typeof stale === "string"
|
|
411
|
+
&& path.basename(stale) === stale
|
|
412
|
+
&& !currentFiles.has(stale)
|
|
413
|
+
&& (stale.endsWith(".md") || stale.endsWith(".toml"))
|
|
414
|
+
) {
|
|
415
|
+
fs.rmSync(path.join(managedDir, stale), { force: true });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
atomicPrivateWrite(manifestPath, `${JSON.stringify({
|
|
419
|
+
version: 1,
|
|
420
|
+
tenantId,
|
|
421
|
+
client,
|
|
422
|
+
syncedAt: new Date(now).toISOString(),
|
|
423
|
+
files: [...currentFiles].sort(),
|
|
424
|
+
agents: rendered.map(({ agentId, name }) => ({ agentId, name })),
|
|
425
|
+
}, null, 2)}\n`);
|
|
426
|
+
return { client, root, label, synced: true, count: rendered.length, files: [...currentFiles] };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function syncAgentProfiles({
|
|
430
|
+
profiles,
|
|
431
|
+
gatewayUrl,
|
|
432
|
+
credential,
|
|
433
|
+
tenantId,
|
|
434
|
+
staleOnly = false,
|
|
435
|
+
ttlMs = AGENT_SYNC_TTL_MS,
|
|
436
|
+
now = Date.now(),
|
|
437
|
+
fetchCatalog = fetchSpecialistCatalog,
|
|
438
|
+
logger = console,
|
|
439
|
+
}) {
|
|
440
|
+
if (process.env.IMPEL_SKIP_AGENT_SYNC === "1" || process.env.IMPEL_SKIP_AGENT_SYNC === "true") {
|
|
441
|
+
return profiles.map((profile) => ({ ...profile, skipped: true, reason: "disabled" }));
|
|
442
|
+
}
|
|
443
|
+
const normalizedTenant = normalizeTenantId(tenantId);
|
|
444
|
+
const pending = staleOnly
|
|
445
|
+
? profiles.filter((profile) => !profileIsFresh(profile, normalizedTenant, now, ttlMs))
|
|
446
|
+
: profiles;
|
|
447
|
+
const skipped = profiles
|
|
448
|
+
.filter((profile) => !pending.includes(profile))
|
|
449
|
+
.map((profile) => ({ ...profile, skipped: true, reason: "fresh" }));
|
|
450
|
+
if (pending.length === 0) return skipped;
|
|
451
|
+
|
|
452
|
+
const catalog = await fetchCatalog({ gatewayUrl, credential, tenantId: normalizedTenant });
|
|
453
|
+
const results = [];
|
|
454
|
+
for (const profile of pending) {
|
|
455
|
+
const result = syncAgentProfile({
|
|
456
|
+
...profile,
|
|
457
|
+
tenantId: normalizedTenant,
|
|
458
|
+
specialists: catalog.specialists,
|
|
459
|
+
now,
|
|
460
|
+
});
|
|
461
|
+
logger.log(`Agents: ${profile.label || profile.client} up to date (${result.count} tenant specialist${result.count === 1 ? "" : "s"}).`);
|
|
462
|
+
results.push(result);
|
|
463
|
+
}
|
|
464
|
+
return [...results, ...skipped];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function syncAgentProfilesSafe(options) {
|
|
468
|
+
try {
|
|
469
|
+
return await syncAgentProfiles(options);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
const logger = options?.logger || console;
|
|
472
|
+
logger.warn(`impel: agent sync failed (${redactSecretText(error?.message || error)}); keeping the last good native-agent catalog.`);
|
|
473
|
+
return (options?.profiles || []).map((profile) => ({
|
|
474
|
+
...profile,
|
|
475
|
+
synced: false,
|
|
476
|
+
reason: "error",
|
|
477
|
+
error: redactSecretText(error?.message || error),
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { cmdApps } from "./commands/apps.js";
|
|
|
11
11
|
import { cmdMcp } from "./commands/mcp.js";
|
|
12
12
|
import { cmdLaunch } from "./commands/launch.js";
|
|
13
13
|
import { cmdSkills } from "./commands/skills.js";
|
|
14
|
+
import { cmdAgents } from "./commands/agents.js";
|
|
14
15
|
import { cmdTenant } from "./commands/tenant.js";
|
|
15
16
|
import { cmdDoctor } from "./commands/doctor.js";
|
|
16
17
|
import { cmdSetup } from "./commands/setup.js";
|
|
@@ -24,12 +25,12 @@ Get started:
|
|
|
24
25
|
platform clients, gateway verification
|
|
25
26
|
(--pat <pat> --tenant <org> --skip-apps/--skip-clis)
|
|
26
27
|
impel update Update everything: the CLI itself, then the
|
|
27
|
-
Impel apps and
|
|
28
|
+
Impel apps, skills, and agents (--check, --skip-apps)
|
|
28
29
|
|
|
29
30
|
Run:
|
|
30
31
|
impel claude [args...] Launch Claude Code with an isolated Impel profile
|
|
31
32
|
impel codex [args...] Launch Codex with an isolated Impel profile
|
|
32
|
-
impel app open [target] [--tenant <org>]
|
|
33
|
+
impel app open [target] [--tenant <org>] Install/configure on first use, then open the app(s)
|
|
33
34
|
impel status Tenant, PAT, per-tool mode, gateway reachability
|
|
34
35
|
impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
|
|
35
36
|
|
|
@@ -43,12 +44,13 @@ Manage:
|
|
|
43
44
|
impel token [--tenant <org>] Print the selected-tenant bearer
|
|
44
45
|
impel mcp Run the local Impel MCP stdio bridge
|
|
45
46
|
impel skills sync [claude|codex|all] Sync shared skills into managed clients
|
|
47
|
+
impel agents sync [claude|codex|all] Sync tenant specialists as native subagents
|
|
46
48
|
|
|
47
49
|
Desktop apps (macOS and Windows):
|
|
48
50
|
target is claude, chatgpt/codex, or all (default: all)
|
|
49
51
|
impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
|
|
50
52
|
impel app update [target] [--tenant <org>] Update vendor apps and managed profiles
|
|
51
|
-
impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
|
|
53
|
+
impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
|
|
52
54
|
impel app status [target] [--tenant <org>] Show isolated app and vendor versions
|
|
53
55
|
impel app uninstall [target] [--tenant <org>] [--keep-data]
|
|
54
56
|
Remove only that tenant's managed apps/data
|
|
@@ -65,6 +67,7 @@ Env:
|
|
|
65
67
|
IMPEL_GATEWAY_URL Default gateway URL when --gateway isn't passed.
|
|
66
68
|
IMPEL_APP_URL Default app/control-plane URL for setup, auth, PATs, and tasks.
|
|
67
69
|
IMPEL_SKIP_UPDATE_CHECK=1 Silence launch-time update notices.
|
|
70
|
+
IMPEL_SKIP_AGENT_SYNC=1 Keep the last native-agent catalog without refreshing it.
|
|
68
71
|
|
|
69
72
|
Config file:
|
|
70
73
|
~/.config/impel/config.json (mode 0600)
|
|
@@ -144,6 +147,10 @@ export async function main(argv) {
|
|
|
144
147
|
case "skill":
|
|
145
148
|
return cmdSkills(rest);
|
|
146
149
|
|
|
150
|
+
case "agents":
|
|
151
|
+
case "agent":
|
|
152
|
+
return cmdAgents(rest);
|
|
153
|
+
|
|
147
154
|
case "on":
|
|
148
155
|
return runUse("gateway", rest[0]);
|
|
149
156
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `impel agents sync [claude|codex|all]` — synchronize the selected tenant's
|
|
2
|
+
// opted-in Impel specialists into every managed native custom-agent registry.
|
|
3
|
+
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
import { agentProfileRoot, syncAgentProfiles } from "../agents.js";
|
|
7
|
+
import { loadConfig, normalizeGatewayUrl, redactSecretText, resolveDefaultGateway } from "../config.js";
|
|
8
|
+
import { ensureTenantSelection, tenantCredential } from "../tenants.js";
|
|
9
|
+
import { managedSkillProfiles } from "./skills.js";
|
|
10
|
+
|
|
11
|
+
const HELP = `impel agents - sync tenant specialists as native custom agents
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
impel agents sync [claude|codex|all]
|
|
15
|
+
|
|
16
|
+
The selected tenant's authenticated specialist catalog is written into every
|
|
17
|
+
managed native profile. The default target is all.`;
|
|
18
|
+
|
|
19
|
+
function normalizeAgentTarget(token) {
|
|
20
|
+
if (token === undefined || token === "all") return "all";
|
|
21
|
+
if (token === "claude") return "claude";
|
|
22
|
+
if (token === "codex" || token === "codex-cli" || token === "codex-app" || token === "chatgpt") {
|
|
23
|
+
return "codex";
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function managedAgentProfiles(client, options = {}) {
|
|
29
|
+
const homeDir = options.homeDir || os.homedir();
|
|
30
|
+
const environment = options.environment || process.env;
|
|
31
|
+
return managedSkillProfiles(client, options).map((profile) => ({
|
|
32
|
+
client,
|
|
33
|
+
label: profile.label,
|
|
34
|
+
root: agentProfileRoot(client, {
|
|
35
|
+
env: profile.env,
|
|
36
|
+
environment,
|
|
37
|
+
homeDir,
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function cmdAgents(argv, overrides = {}) {
|
|
43
|
+
const [action = "sync", targetToken] = argv;
|
|
44
|
+
if (["help", "--help", "-h"].includes(action)) {
|
|
45
|
+
console.log(HELP);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (action !== "sync") {
|
|
49
|
+
console.error(`impel agents: unknown action "${action}". Try \`impel agents sync [claude|codex|all]\`.`);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const target = normalizeAgentTarget(targetToken);
|
|
54
|
+
if (target === null) {
|
|
55
|
+
console.error(`impel agents: unknown target "${targetToken}". Use \`claude\`, \`codex\`, or \`all\`.`);
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const io = {
|
|
61
|
+
loadConfig,
|
|
62
|
+
ensureTenantSelection,
|
|
63
|
+
syncAgentProfiles,
|
|
64
|
+
managedAgentProfiles,
|
|
65
|
+
environment: process.env,
|
|
66
|
+
homeDir: os.homedir(),
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
const config = io.loadConfig();
|
|
70
|
+
if (!config?.pat) {
|
|
71
|
+
console.error("impel agents: not authenticated; run `impel setup` (or `impel auth`) first.");
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const selected = await io.ensureTenantSelection(config);
|
|
78
|
+
const tenantId = selected.tenantId;
|
|
79
|
+
const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
|
|
80
|
+
const clients = target === "all" ? ["claude", "codex"] : [target];
|
|
81
|
+
const profiles = clients.flatMap((client) => io.managedAgentProfiles(client, {
|
|
82
|
+
tenantId,
|
|
83
|
+
environment: io.environment,
|
|
84
|
+
homeDir: io.homeDir,
|
|
85
|
+
}));
|
|
86
|
+
await io.syncAgentProfiles({
|
|
87
|
+
profiles,
|
|
88
|
+
gatewayUrl,
|
|
89
|
+
credential: tenantCredential(config.pat, tenantId),
|
|
90
|
+
tenantId,
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(`impel agents: ${redactSecretText(error?.message || error)}`);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/commands/apps.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
removeManagedApps,
|
|
38
38
|
} from "../apps.js";
|
|
39
39
|
import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
|
|
40
|
+
import { syncAgentProfilesSafe } from "../agents.js";
|
|
40
41
|
import { secureManagedCodexHome } from "../codexSecurity.js";
|
|
41
42
|
import { impelCliInvocation } from "../selfInvocation.js";
|
|
42
43
|
import {
|
|
@@ -63,6 +64,13 @@ function appSkillTarget(target, paths) {
|
|
|
63
64
|
return { client: "codex", env: { CODEX_HOME: paths.chatgpt.codexHome }, label: "Impel ChatGPT app" };
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
function appAgentProfile(target, paths) {
|
|
68
|
+
if (target === "claude") {
|
|
69
|
+
return { client: "claude", root: paths.claude.userData, label: "Impel Claude app" };
|
|
70
|
+
}
|
|
71
|
+
return { client: "codex", root: paths.chatgpt.codexHome, label: "Impel ChatGPT app" };
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
function windowsAppTargets(targetToken) {
|
|
67
75
|
if (targetToken === undefined || targetToken === "all") return ["claude", "chatgpt"];
|
|
68
76
|
if (targetToken === "claude") return ["claude"];
|
|
@@ -137,6 +145,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
137
145
|
installFiles: installManagedAppFiles,
|
|
138
146
|
removeApps: removeManagedApps,
|
|
139
147
|
syncSkills: syncSkillsSafe,
|
|
148
|
+
syncAgents: syncAgentProfilesSafe,
|
|
140
149
|
selectedConfig: selectedAppConfig,
|
|
141
150
|
...overrides,
|
|
142
151
|
};
|
|
@@ -207,13 +216,26 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
207
216
|
claudeUserData: actionUserData,
|
|
208
217
|
tenantName: config.tenantName,
|
|
209
218
|
});
|
|
219
|
+
const profileWasInstalled = {
|
|
220
|
+
claude: fs.existsSync(path.join(
|
|
221
|
+
actionPaths.claude.userData,
|
|
222
|
+
"configLibrary",
|
|
223
|
+
`${CLAUDE_CONFIG_ID}.json`,
|
|
224
|
+
)),
|
|
225
|
+
chatgpt: fs.existsSync(path.join(actionPaths.chatgpt.codexHome, "config.toml")),
|
|
226
|
+
};
|
|
210
227
|
const vendorPaths = {};
|
|
211
228
|
for (const target of targets) {
|
|
212
229
|
const isClaude = target === "claude";
|
|
213
230
|
const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
|
|
214
231
|
const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
|
|
215
232
|
let binary = find(io.environment);
|
|
216
|
-
|
|
233
|
+
const shouldEnsureVendor = !flags["skip-vendor"] && (
|
|
234
|
+
action === "install"
|
|
235
|
+
|| action === "update"
|
|
236
|
+
|| (action === "open" && !binary)
|
|
237
|
+
);
|
|
238
|
+
if (shouldEnsureVendor) {
|
|
217
239
|
const vendor = ensure({ update: action === "update" }, { environment: io.environment });
|
|
218
240
|
binary = vendor.binary;
|
|
219
241
|
console.log(`${target}: vendor app ${vendor.action}`);
|
|
@@ -252,15 +274,24 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
252
274
|
claudeUserData: userData,
|
|
253
275
|
tenantName: config.tenantName,
|
|
254
276
|
});
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
277
|
+
const configuredTargets = action === "open"
|
|
278
|
+
? targets.filter((target) => !profileWasInstalled[target])
|
|
279
|
+
: targets;
|
|
280
|
+
if (action === "install" || action === "update" || action === "refresh" || configuredTargets.length > 0) {
|
|
281
|
+
const verb = action === "update" ? "Updated" : action === "refresh" ? "Refreshed" : "Installed";
|
|
282
|
+
for (const target of configuredTargets) {
|
|
258
283
|
const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
|
|
259
284
|
console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
|
|
260
285
|
const { client, env, label } = appSkillTarget(target, tenantPaths);
|
|
261
286
|
await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
|
|
262
287
|
if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
|
|
263
288
|
}
|
|
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
|
+
});
|
|
264
295
|
console.log(`Models: ${catalog.models.length} from ${catalog.source}. The signed vendor apps and their normal profiles were not changed.`);
|
|
265
296
|
}
|
|
266
297
|
if (action === "open") {
|
|
@@ -358,43 +389,12 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
358
389
|
|
|
359
390
|
const config = await selectedAppConfig(targets, flags.tenant || null);
|
|
360
391
|
const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
|
|
361
|
-
|
|
362
|
-
for (const status of statuses) {
|
|
363
|
-
if (!status.launcherInstalled && !fs.existsSync(legacyPaths[status.target].launcher)) {
|
|
364
|
-
throw new Error(`${status.label} is not installed; run \`impel app install ${status.target}\``);
|
|
365
|
-
}
|
|
366
|
-
if (!status.vendorPath) throw new Error(`${status.label} vendor app is unavailable; reinstall the vendor app first`);
|
|
367
|
-
}
|
|
368
|
-
let catalog;
|
|
369
|
-
try {
|
|
370
|
-
catalog = await fetchGatewayModels(config);
|
|
371
|
-
} catch (error) {
|
|
372
|
-
throw new Error(
|
|
373
|
-
`tenant model catalog is unavailable (${redactSecretText(error.message)}); the selected tenant app was not opened`,
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
// Only stale bundles pay the clone + codesign rebuild; a bundle swap needs
|
|
377
|
-
// its app closed, and quitting is fine here since we relaunch right after.
|
|
378
|
-
const staleBundleTargets = statuses
|
|
379
|
-
.filter((status) => !bundleIsCurrent(status))
|
|
380
|
-
.map((status) => status.target);
|
|
381
|
-
if (staleBundleTargets.length > 0) {
|
|
382
|
-
await quitBlockingApps(staleBundleTargets, {
|
|
383
|
-
tenantId: config.tenantId,
|
|
384
|
-
tenantName: config.tenantName,
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
installManagedAppFiles({
|
|
392
|
+
await provisionAndOpenManagedApps({
|
|
388
393
|
config,
|
|
389
394
|
targets,
|
|
390
|
-
|
|
395
|
+
statuses,
|
|
391
396
|
homeDir: os.homedir(),
|
|
392
|
-
|
|
393
|
-
writeBundles: staleBundleTargets,
|
|
394
|
-
});
|
|
395
|
-
for (const status of appStatus(targets, os.homedir(), config.tenantId, config.tenantName)) {
|
|
396
|
-
spawnSync("/usr/bin/open", ["-n", status.launcher], { stdio: "inherit" });
|
|
397
|
-
}
|
|
397
|
+
}, overrides);
|
|
398
398
|
return;
|
|
399
399
|
}
|
|
400
400
|
if (action !== "install" && action !== "update") {
|
|
@@ -443,6 +443,113 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
443
443
|
await syncSkillsSafe({ client, gatewayUrl, env, label });
|
|
444
444
|
if (item.target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
|
|
445
445
|
}
|
|
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
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Complete the slow `app open` path. A first open is intentionally equivalent
|
|
457
|
+
* to an install for the selected tenant: verify or install the pinned vendor,
|
|
458
|
+
* build the isolated bundle/profile, sync skills, and only then launch it.
|
|
459
|
+
* Existing current installs never reach this helper because fastOpenLaunchers
|
|
460
|
+
* handles them without network or rebuild work.
|
|
461
|
+
*/
|
|
462
|
+
export async function provisionAndOpenManagedApps({
|
|
463
|
+
config,
|
|
464
|
+
targets,
|
|
465
|
+
statuses,
|
|
466
|
+
homeDir = os.homedir(),
|
|
467
|
+
}, overrides = {}) {
|
|
468
|
+
const io = {
|
|
469
|
+
fetchModels: fetchGatewayModels,
|
|
470
|
+
ensureVendor: ensureVendorApp,
|
|
471
|
+
bundleCurrent: bundleIsCurrent,
|
|
472
|
+
quitApps: quitBlockingApps,
|
|
473
|
+
installFiles: installManagedAppFiles,
|
|
474
|
+
syncSkills: syncSkillsSafe,
|
|
475
|
+
syncAgents: syncAgentProfilesSafe,
|
|
476
|
+
secureCodexHome: secureManagedCodexHome,
|
|
477
|
+
openLauncher: (launcher) => spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" }),
|
|
478
|
+
log: (message) => console.log(message),
|
|
479
|
+
...overrides,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// Resolve the live tenant catalog before installing or changing anything.
|
|
483
|
+
// This keeps unavailable/unauthorized tenants fail-closed on first use.
|
|
484
|
+
let catalog;
|
|
485
|
+
try {
|
|
486
|
+
catalog = await io.fetchModels(config);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); the selected tenant app was not opened`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const vendorPaths = {};
|
|
494
|
+
for (const status of statuses) {
|
|
495
|
+
let vendorPath = status.vendorPath;
|
|
496
|
+
if (!vendorPath) {
|
|
497
|
+
const result = io.ensureVendor(status.target, { homeDir });
|
|
498
|
+
vendorPath = result.path;
|
|
499
|
+
io.log(`${status.target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
|
|
500
|
+
if (!vendorPath) {
|
|
501
|
+
throw new Error(`${status.target} verified vendor app is unavailable; retry the pinned download`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
vendorPaths[status.target] = vendorPath;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Only stale or absent bundles pay the clone + codesign rebuild. The model
|
|
508
|
+
// catalog and vendor checks above finish before any running apps are closed.
|
|
509
|
+
const staleBundleTargets = statuses
|
|
510
|
+
.filter((status) => !io.bundleCurrent(status))
|
|
511
|
+
.map((status) => status.target);
|
|
512
|
+
if (staleBundleTargets.length > 0) {
|
|
513
|
+
await io.quitApps(staleBundleTargets, {
|
|
514
|
+
tenantId: config.tenantId,
|
|
515
|
+
tenantName: config.tenantName,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const installed = io.installFiles({
|
|
520
|
+
config,
|
|
521
|
+
targets,
|
|
522
|
+
models: catalog.models,
|
|
523
|
+
homeDir,
|
|
524
|
+
vendorPaths,
|
|
525
|
+
writeBundles: staleBundleTargets,
|
|
526
|
+
});
|
|
527
|
+
const newlyInstalled = new Set(
|
|
528
|
+
statuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
|
|
529
|
+
);
|
|
530
|
+
for (const item of installed) {
|
|
531
|
+
if (newlyInstalled.has(item.target)) {
|
|
532
|
+
io.log(`Installed and configured ${item.launcher} for tenant ${config.tenantId}.`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Match explicit install setup without penalizing repeat opens: this helper
|
|
537
|
+
// only runs when the zero-network fast path has detected drift or first use.
|
|
538
|
+
const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
|
|
539
|
+
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
540
|
+
for (const item of installed) {
|
|
541
|
+
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
542
|
+
await io.syncSkills({ client, gatewayUrl, env, label });
|
|
543
|
+
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
544
|
+
}
|
|
545
|
+
await io.syncAgents({
|
|
546
|
+
profiles: installed.map((item) => appAgentProfile(item.target, paths)),
|
|
547
|
+
gatewayUrl: config.gatewayUrl,
|
|
548
|
+
credential: config.pat,
|
|
549
|
+
tenantId: config.tenantId,
|
|
550
|
+
});
|
|
551
|
+
for (const item of installed) await io.openLauncher(item.launcher);
|
|
552
|
+
return installed;
|
|
446
553
|
}
|
|
447
554
|
|
|
448
555
|
const REFRESH_TTL_MS = 6 * 60 * 60 * 1000;
|
|
@@ -552,6 +659,12 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
|
|
|
552
659
|
await syncSkillsSafe({ client, gatewayUrl, env, label });
|
|
553
660
|
if (status.target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
|
|
554
661
|
}
|
|
662
|
+
await syncAgentProfilesSafe({
|
|
663
|
+
profiles: installedTargets.map((status) => appAgentProfile(status.target, tenantPaths)),
|
|
664
|
+
gatewayUrl: config.gatewayUrl,
|
|
665
|
+
credential: config.pat,
|
|
666
|
+
tenantId: config.tenantId,
|
|
667
|
+
});
|
|
555
668
|
// Keep the update-notice cache warm from the same background slot.
|
|
556
669
|
try {
|
|
557
670
|
refreshUpdateCache();
|
package/src/commands/launch.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ensureImpelClaudeProfile,
|
|
6
6
|
ensureImpelCodexProfile,
|
|
7
7
|
} from "../cliProfiles.js";
|
|
8
|
+
import { syncAgentProfilesSafe } from "../agents.js";
|
|
8
9
|
import {
|
|
9
10
|
crossAppModelsEnabled,
|
|
10
11
|
loadConfig,
|
|
@@ -134,9 +135,11 @@ export async function cmdLaunch(tool, argv) {
|
|
|
134
135
|
const gatewayCredential = tenantCredential(config.pat, tenantId);
|
|
135
136
|
const environment = { ...process.env };
|
|
136
137
|
environment.IMPEL_TENANT_ID = tenantId;
|
|
138
|
+
let agentProfile;
|
|
137
139
|
|
|
138
140
|
if (tool === "claude") {
|
|
139
141
|
const profile = ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels });
|
|
142
|
+
agentProfile = { client: "claude", root: profile.configDir, label: "Impel isolated Claude (impel claude)" };
|
|
140
143
|
deleteEnvironmentKeys(environment, CLAUDE_DIRECT_AUTH_ENV);
|
|
141
144
|
delete environment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY;
|
|
142
145
|
environment.CLAUDE_CONFIG_DIR = profile.configDir;
|
|
@@ -150,12 +153,23 @@ export async function cmdLaunch(tool, argv) {
|
|
|
150
153
|
environment.ANTHROPIC_AUTH_TOKEN = gatewayCredential;
|
|
151
154
|
} else if (tool === "codex") {
|
|
152
155
|
const profile = ensureImpelCodexProfile(gatewayUrl, tenantId);
|
|
156
|
+
agentProfile = { client: "codex", root: profile.codexHome, label: "Impel isolated Codex (impel codex)" };
|
|
153
157
|
deleteEnvironmentKeys(environment, CODEX_DIRECT_AUTH_ENV);
|
|
154
158
|
environment.CODEX_HOME = profile.codexHome;
|
|
155
159
|
} else {
|
|
156
160
|
throw new Error(`unsupported CLI launcher: ${tool}`);
|
|
157
161
|
}
|
|
158
162
|
|
|
163
|
+
// Refresh at most every six hours. A network/catalog failure must not block
|
|
164
|
+
// the isolated client; the last successfully generated agents remain usable.
|
|
165
|
+
await syncAgentProfilesSafe({
|
|
166
|
+
profiles: [agentProfile],
|
|
167
|
+
gatewayUrl,
|
|
168
|
+
credential: gatewayCredential,
|
|
169
|
+
tenantId,
|
|
170
|
+
staleOnly: true,
|
|
171
|
+
});
|
|
172
|
+
|
|
159
173
|
const exitCode = await runNativeCli(tool, impelLaunchArguments(tool, argv), environment);
|
|
160
174
|
if (exitCode !== 0) process.exitCode = exitCode;
|
|
161
175
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// `impel update` — bring everything current in one command: the CLI itself
|
|
2
2
|
// (npm reinstall from the public package), then the managed desktop apps/profiles, then
|
|
3
|
-
// skills. The app step re-executes the freshly installed CLI so the new code
|
|
3
|
+
// skills and native tenant agents. The app step re-executes the freshly installed CLI so the new code
|
|
4
4
|
// performs it.
|
|
5
5
|
|
|
6
6
|
import fs from "node:fs";
|
|
@@ -29,11 +29,11 @@ const HELP = `impel update - update everything Impel in one command
|
|
|
29
29
|
|
|
30
30
|
Reinstalls impel-cli from npm, then cascades with the NEW build:
|
|
31
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\`
|
|
33
|
-
every managed profile.
|
|
32
|
+
profiles) when Impel apps are installed, then \`impel skills sync all\` and
|
|
33
|
+
\`impel agents sync all\` across every managed profile.
|
|
34
34
|
|
|
35
35
|
Usage:
|
|
36
|
-
impel update Update the CLI, then cascade to apps and
|
|
36
|
+
impel update Update the CLI, then cascade to apps, skills, and agents
|
|
37
37
|
impel update --check Report whether an update is available; change nothing
|
|
38
38
|
impel update --skip-apps Skip the desktop-app step of the cascade
|
|
39
39
|
`;
|
|
@@ -102,6 +102,13 @@ function defaultRunSkillsSync() {
|
|
|
102
102
|
return result.status === 0;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function defaultRunAgentsSync() {
|
|
106
|
+
const result = spawnSync(process.execPath, [CLI_BIN, "agents", "sync", "all"], {
|
|
107
|
+
stdio: "inherit",
|
|
108
|
+
});
|
|
109
|
+
return result.status === 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
105
112
|
function anyAppInstalled(homeDir = os.homedir()) {
|
|
106
113
|
const config = loadConfig();
|
|
107
114
|
const claudeUserData = process.platform === "win32"
|
|
@@ -136,6 +143,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
136
143
|
selfUpdate: defaultSelfUpdate,
|
|
137
144
|
runAppsUpdate: defaultRunAppsUpdate,
|
|
138
145
|
runSkillsSync: defaultRunSkillsSync,
|
|
146
|
+
runAgentsSync: defaultRunAgentsSync,
|
|
139
147
|
appsInstalled: anyAppInstalled,
|
|
140
148
|
platform: process.platform,
|
|
141
149
|
...overrides,
|
|
@@ -213,6 +221,12 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
213
221
|
cascadeFailed = true;
|
|
214
222
|
}
|
|
215
223
|
|
|
224
|
+
console.log("Agents: syncing the selected tenant into every managed profile…");
|
|
225
|
+
if (!io.runAgentsSync()) {
|
|
226
|
+
console.error("impel update: agent sync failed; re-run `impel agents sync` after fixing the issue.");
|
|
227
|
+
cascadeFailed = true;
|
|
228
|
+
}
|
|
229
|
+
|
|
216
230
|
if (cascadeFailed) {
|
|
217
231
|
process.exitCode = 1;
|
|
218
232
|
return;
|
package/src/commands/use.js
CHANGED
|
@@ -8,12 +8,15 @@
|
|
|
8
8
|
// model_provider) into the impel config, so turning it OFF restores exactly
|
|
9
9
|
// that — never a guess.
|
|
10
10
|
|
|
11
|
-
import { loadConfig, saveConfig, resolveDefaultGateway } from "../config.js";
|
|
11
|
+
import { loadConfig, saveConfig, redactSecretText, resolveDefaultGateway } from "../config.js";
|
|
12
|
+
import { syncAgentProfilesSafe } from "../agents.js";
|
|
13
|
+
import { ensureTenantSelection, tenantCredential } from "../tenants.js";
|
|
12
14
|
import {
|
|
13
15
|
applyClaudeGateway,
|
|
14
16
|
revertClaudeGateway,
|
|
15
17
|
isImpelApiKeyHelper,
|
|
16
18
|
isImpelClaudeBaseUrl,
|
|
19
|
+
CLAUDE_DIR,
|
|
17
20
|
} from "../claudeSetup.js";
|
|
18
21
|
import {
|
|
19
22
|
applyCodexGateway,
|
|
@@ -110,6 +113,25 @@ async function useGateway({ target, app }) {
|
|
|
110
113
|
if (wantsCodex(target)) {
|
|
111
114
|
await syncSkillsSafe({ client: "codex", gatewayUrl, env: { CODEX_HOME }, label: "Codex CLI (gateway)" });
|
|
112
115
|
}
|
|
116
|
+
try {
|
|
117
|
+
const selected = await ensureTenantSelection(config);
|
|
118
|
+
const agentProfiles = [
|
|
119
|
+
...(wantsClaude(target)
|
|
120
|
+
? [{ client: "claude", root: CLAUDE_DIR, label: "Claude Code (gateway)" }]
|
|
121
|
+
: []),
|
|
122
|
+
...(wantsCodex(target)
|
|
123
|
+
? [{ client: "codex", root: CODEX_HOME, label: "Codex CLI (gateway)" }]
|
|
124
|
+
: []),
|
|
125
|
+
];
|
|
126
|
+
await syncAgentProfilesSafe({
|
|
127
|
+
profiles: agentProfiles,
|
|
128
|
+
gatewayUrl,
|
|
129
|
+
credential: tenantCredential(config.pat, selected.tenantId),
|
|
130
|
+
tenantId: selected.tenantId,
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.warn(`impel: agent sync could not resolve the selected tenant (${redactSecretText(error?.message || error)}); continuing.`);
|
|
134
|
+
}
|
|
113
135
|
|
|
114
136
|
console.log("");
|
|
115
137
|
printGatewayNextSteps({ target, app });
|