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 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` and `skills sync 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
- and `impel skills sync all` across every managed profile.
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 skills at most every 6 hours — safe
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.9.0 pins Claude `1.20186.9` and
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agents.js 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 skills (--check, --skip-apps)
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
+ }
@@ -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();
@@ -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
  }
@@ -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\` across
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 skills
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;
@@ -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 });