impel-cli 0.9.2 → 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 +59 -7
- package/package.json +1 -1
- package/src/agents.js +480 -0
- package/src/cli.js +9 -2
- package/src/commands/agents.js +96 -0
- package/src/commands/apps.js +36 -1
- 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,11 +148,12 @@ 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
159
|
impel app open [target] [--tenant <org>] Install/configure a tenant on first use, then launch
|
|
@@ -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
|
|
|
@@ -389,7 +392,9 @@ MCP entry point, reads the PAT from the owner-only Impel
|
|
|
389
392
|
config, and proxies JSON-RPC to `<gateway>/mcp`; the PAT is never written into
|
|
390
393
|
either client's MCP configuration. Available specialists are discovered at
|
|
391
394
|
runtime, so publishing a new opted-in specialist does not require another CLI
|
|
392
|
-
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.
|
|
393
398
|
|
|
394
399
|
### Isolated CLI launchers
|
|
395
400
|
|
|
@@ -584,6 +589,53 @@ best-effort: if a client CLI, its `plugin` subcommand, or the gateway is
|
|
|
584
589
|
unavailable, it logs a warning and moves on — it never fails the parent command.
|
|
585
590
|
Set `IMPEL_SKIP_SKILL_SYNC=1` to disable it entirely (offline/CI).
|
|
586
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
|
+
|
|
587
639
|
### `impel status`
|
|
588
640
|
|
|
589
641
|
Shows whether the isolated CLI launchers are ready, and whether each native
|
|
@@ -675,7 +727,7 @@ successful tenant-specific rebuild.
|
|
|
675
727
|
|
|
676
728
|
The official Claude and ChatGPT apps remain separate installations and keep
|
|
677
729
|
their normal accounts, updater, profiles, and bundle identities. Each CLI
|
|
678
|
-
release pins exact, tested vendor builds (v0.
|
|
730
|
+
release pins exact, tested vendor builds (v0.10.0 pins Claude `1.20186.9` and
|
|
679
731
|
ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
|
|
680
732
|
an existing official app only when its version exactly matches; otherwise it
|
|
681
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,7 +25,7 @@ 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
|
|
@@ -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
|
};
|
|
@@ -277,6 +286,12 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
277
286
|
await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
|
|
278
287
|
if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
|
|
279
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
|
+
});
|
|
280
295
|
console.log(`Models: ${catalog.models.length} from ${catalog.source}. The signed vendor apps and their normal profiles were not changed.`);
|
|
281
296
|
}
|
|
282
297
|
if (action === "open") {
|
|
@@ -379,7 +394,7 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
379
394
|
targets,
|
|
380
395
|
statuses,
|
|
381
396
|
homeDir: os.homedir(),
|
|
382
|
-
});
|
|
397
|
+
}, overrides);
|
|
383
398
|
return;
|
|
384
399
|
}
|
|
385
400
|
if (action !== "install" && action !== "update") {
|
|
@@ -428,6 +443,13 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
428
443
|
await syncSkillsSafe({ client, gatewayUrl, env, label });
|
|
429
444
|
if (item.target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
|
|
430
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
|
+
});
|
|
431
453
|
}
|
|
432
454
|
|
|
433
455
|
/**
|
|
@@ -450,6 +472,7 @@ export async function provisionAndOpenManagedApps({
|
|
|
450
472
|
quitApps: quitBlockingApps,
|
|
451
473
|
installFiles: installManagedAppFiles,
|
|
452
474
|
syncSkills: syncSkillsSafe,
|
|
475
|
+
syncAgents: syncAgentProfilesSafe,
|
|
453
476
|
secureCodexHome: secureManagedCodexHome,
|
|
454
477
|
openLauncher: (launcher) => spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" }),
|
|
455
478
|
log: (message) => console.log(message),
|
|
@@ -519,6 +542,12 @@ export async function provisionAndOpenManagedApps({
|
|
|
519
542
|
await io.syncSkills({ client, gatewayUrl, env, label });
|
|
520
543
|
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
521
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
|
+
});
|
|
522
551
|
for (const item of installed) await io.openLauncher(item.launcher);
|
|
523
552
|
return installed;
|
|
524
553
|
}
|
|
@@ -630,6 +659,12 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
|
|
|
630
659
|
await syncSkillsSafe({ client, gatewayUrl, env, label });
|
|
631
660
|
if (status.target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
|
|
632
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
|
+
});
|
|
633
668
|
// Keep the update-notice cache warm from the same background slot.
|
|
634
669
|
try {
|
|
635
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 });
|