impel-cli 0.20.56 → 0.20.58

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.
@@ -0,0 +1,160 @@
1
+ // `impel models sync|list` — the explicit surface over the CLI Codex model
2
+ // catalog layering (D13). `sync` fetches the tenant's gateway catalog and
3
+ // rewrites the isolated `impel codex` models.json (with provenance in
4
+ // models-manifest.json); `list` is near-free diagnostics over the same files:
5
+ // what the picker will show next launch, where it came from (gateway sync or
6
+ // the offline floor), and how stale it is. Launches never depend on this
7
+ // command — the same sync runs launch-layered, TTL-gated, and from the token
8
+ // heartbeat; this is the direct repair/inspection path.
9
+
10
+ import { parseFlags } from "../args.js";
11
+ import {
12
+ crossAppModelsEnabled,
13
+ loadConfig,
14
+ normalizeGatewayUrl,
15
+ redactSecretText,
16
+ resolveDefaultGateway,
17
+ } from "../config.js";
18
+ import { codexCatalogStatus, syncCodexModels } from "../modelSync.js";
19
+ import { MODEL_SYNC_TTL_MS } from "../modelsManifest.js";
20
+ import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
21
+ import { RUNTIME_BRAND } from "../runtimeBrand.js";
22
+
23
+ const HELP = `impel models - inspect or refresh the isolated Codex model catalog
24
+
25
+ Usage:
26
+ impel models list [--tenant <org>] [--json]
27
+ impel models sync [--tenant <org>] [--stale-only]
28
+
29
+ list shows the catalog \`impel codex\` will load next launch: each model, and
30
+ whether the file is the last gateway sync (with its age) or the built-in
31
+ offline floor. sync fetches the tenant's live gateway catalog and rewrites the
32
+ catalog atomically; --stale-only exits without fetching while the last sync is
33
+ inside its ${MODEL_SYNC_TTL_MS / (60 * 60 * 1000)}h window. Launches never block on this: they layer the same
34
+ sync after the offline floor write and keep the last good catalog on failure.
35
+ `;
36
+
37
+ function formatAge(ageMs) {
38
+ const minutes = Math.floor(ageMs / 60_000);
39
+ if (minutes < 1) return "just now";
40
+ if (minutes < 60) return `${minutes}m ago`;
41
+ const hours = Math.floor(minutes / 60);
42
+ if (hours < 48) return `${hours}h ago`;
43
+ return `${Math.floor(hours / 24)}d ago`;
44
+ }
45
+
46
+ export function describeCatalogStatus(status) {
47
+ if (status.catalogError === "missing") {
48
+ return `not initialized (run \`${RUNTIME_BRAND.cli.command} setup\` or \`${RUNTIME_BRAND.cli.command} codex\` first)`;
49
+ }
50
+ if (status.catalogError) return "unreadable (rerun the launcher to regenerate it)";
51
+ if (status.source === "floor") {
52
+ return `offline floor (never synced; run \`${RUNTIME_BRAND.cli.command} models sync\`)`;
53
+ }
54
+ const age = status.ageMs === null ? "unknown age" : formatAge(status.ageMs);
55
+ const version = status.manifest?.catalogVersion == null ? "" : `, catalog v${status.manifest.catalogVersion}`;
56
+ return `gateway sync ${age}${version}, ${status.fresh ? "fresh" : "stale"}`;
57
+ }
58
+
59
+ export async function cmdModels(argv, overrides = {}) {
60
+ const [action, ...rest] = argv;
61
+ if (action === undefined || ["help", "--help", "-h"].includes(action)) {
62
+ console.log(HELP);
63
+ return;
64
+ }
65
+ if (!["sync", "list"].includes(action)) {
66
+ console.error(`impel models: unknown action "${action}". Use \`sync\` or \`list\`.`);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ const { flags, positionals } = parseFlags(rest, {
71
+ tenant: { type: "string" },
72
+ "stale-only": { type: "boolean" },
73
+ json: { type: "boolean" },
74
+ });
75
+ if (positionals.length > 0) {
76
+ console.error(`impel models: unexpected argument "${positionals[0]}"`);
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+
81
+ const io = {
82
+ loadConfig,
83
+ ensureTenantSelection,
84
+ syncModels: syncCodexModels,
85
+ catalogStatus: codexCatalogStatus,
86
+ log: (message) => console.log(message),
87
+ ...overrides,
88
+ };
89
+ const config = io.loadConfig();
90
+ if (!config?.pat) {
91
+ console.error(`impel models: not authenticated. Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} auth\`) first.`);
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+
96
+ try {
97
+ const tenantId = flags.tenant
98
+ ? normalizeTenantId(flags.tenant)
99
+ : (await io.ensureTenantSelection(config)).tenantId;
100
+ const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
101
+ const crossAppModels = crossAppModelsEnabled(config);
102
+
103
+ if (action === "sync") {
104
+ const result = await io.syncModels({
105
+ gatewayUrl,
106
+ credential: tenantCredential(config.pat, tenantId),
107
+ tenantId,
108
+ crossAppModels,
109
+ staleOnly: Boolean(flags["stale-only"]),
110
+ });
111
+ if (result.skipped && result.reason === "uninitialized") {
112
+ console.error(
113
+ `impel models: the isolated Codex profile for tenant "${tenantId}" is not initialized. `
114
+ + `Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} codex\`) first.`,
115
+ );
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+ if (result.skipped) {
120
+ io.log(`Models: catalog for tenant ${tenantId} is fresh; nothing to sync.`);
121
+ return;
122
+ }
123
+ io.log(
124
+ `Models: synced ${result.models} model${result.models === 1 ? "" : "s"} for tenant ${tenantId}`
125
+ + `${result.catalogVersion == null ? "" : ` (catalog v${result.catalogVersion})`}. `
126
+ + "The picker updates on the next launch.",
127
+ );
128
+ return;
129
+ }
130
+
131
+ const status = io.catalogStatus(tenantId, { gatewayUrl, crossAppModels });
132
+ if (flags.json) {
133
+ io.log(JSON.stringify({
134
+ tenantId: status.tenantId,
135
+ catalogPath: status.catalogPath,
136
+ source: status.catalogError ? null : status.source,
137
+ catalogError: status.catalogError,
138
+ syncedAt: status.syncedAt,
139
+ catalogVersion: status.manifest?.catalogVersion ?? null,
140
+ fresh: status.fresh,
141
+ crossAppModels,
142
+ models: status.models.map((model) => model.slug),
143
+ }, null, 2));
144
+ return;
145
+ }
146
+ io.log(`Tenant: ${tenantId}`);
147
+ io.log(`Catalog: ${status.catalogPath}`);
148
+ io.log(`Status: ${describeCatalogStatus(status)}`);
149
+ if (crossAppModels) io.log("Experiments: cross-app models enabled");
150
+ if (status.models.length > 0) {
151
+ io.log(`Models (${status.models.length}):`);
152
+ for (const model of status.models) {
153
+ io.log(` ${model.slug}${model.display_name && model.display_name !== model.slug ? ` — ${model.display_name}` : ""}`);
154
+ }
155
+ }
156
+ } catch (error) {
157
+ console.error(`impel models: ${redactSecretText(error?.message || error)}`);
158
+ process.exitCode = 1;
159
+ }
160
+ }
@@ -0,0 +1,92 @@
1
+ // `impel _provision-vendor-clis` — put the reviewed vendor CLIs on the machine
2
+ // without authenticating.
3
+ //
4
+ // `impel setup` is the authenticated path: it needs a PAT, and it also writes
5
+ // this CLI's isolated Claude/Codex profiles. An enrolled managed device has
6
+ // neither. Its credential is the tenant credential the bootstrap wrote, and its
7
+ // profiles belong to the managed runtime (impel-apps), which supplies
8
+ // CLAUDE_CONFIG_DIR/CODEX_HOME, the gateway origin, and the credential at
9
+ // launch. The one thing the managed chain cannot do is put Claude Code on the
10
+ // machine — the managed `claude` package is Claude Desktop, and the runtime
11
+ // requires a separately installed vendor Claude Code CLI at the reviewed pin.
12
+ //
13
+ // So enrollment calls this: the same reviewed installers `impel setup` runs,
14
+ // the same version gate, and nothing else. It is intentionally quiet and it
15
+ // never throws — a device must finish enrolling even if a vendor endpoint is
16
+ // unreachable, and `impel setup`, `impel update`, and the next enrollment
17
+ // refresh all re-run the same installer.
18
+ import { parseFlags } from "../args.js";
19
+ import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
20
+ import { PINNED_VENDOR_CLI_VERSIONS } from "../vendorCliVersions.js";
21
+
22
+ const SUPPORTED_TOOLS = Object.freeze(["claude", "codex"]);
23
+
24
+ export function parseProvisionTools(value) {
25
+ if (value === undefined || value === true || value === null || value === "") return [...SUPPORTED_TOOLS];
26
+ const requested = String(value).split(",").map((tool) => tool.trim()).filter(Boolean);
27
+ const unsupported = requested.filter((tool) => !SUPPORTED_TOOLS.includes(tool));
28
+ if (unsupported.length) throw new Error(`unsupported vendor CLI: ${unsupported.join(", ")}`);
29
+ return [...new Set(requested)];
30
+ }
31
+
32
+ export async function cmdProvisionVendorClis(argv = [], overrides = {}) {
33
+ const io = {
34
+ platform: process.platform,
35
+ preparePlatformClis,
36
+ logger: console,
37
+ ...overrides,
38
+ };
39
+ const { flags } = parseFlags(argv, {
40
+ tools: { type: "string" },
41
+ json: { type: "boolean" },
42
+ "skip-install": { type: "boolean" },
43
+ });
44
+
45
+ let installTools;
46
+ try {
47
+ installTools = parseProvisionTools(flags.tools);
48
+ } catch (error) {
49
+ io.logger.error(`impel _provision-vendor-clis: ${error.message}`);
50
+ process.exitCode = 1;
51
+ return null;
52
+ }
53
+
54
+ let prepared;
55
+ try {
56
+ prepared = await io.preparePlatformClis({
57
+ platform: io.platform,
58
+ vendorClisOnly: true,
59
+ skipInstall: flags["skip-install"] === true,
60
+ installTools,
61
+ });
62
+ } catch (error) {
63
+ // Enrollment must not fail because a vendor endpoint did. Report and exit
64
+ // nonzero so a caller that cares can see it; the bootstrap ignores it.
65
+ const report = { ok: false, versions: PINNED_VENDOR_CLI_VERSIONS, error: String(error?.message || error) };
66
+ io.logger.error(flags.json ? JSON.stringify(report) : `impel _provision-vendor-clis: ${report.error}`);
67
+ process.exitCode = 1;
68
+ return report;
69
+ }
70
+
71
+ const installed = Object.fromEntries(
72
+ installTools.map((tool) => [tool, prepared.binaries?.[tool] || null]),
73
+ );
74
+ const missing = installTools.filter((tool) => !installed[tool]);
75
+ const report = {
76
+ ok: missing.length === 0,
77
+ versions: Object.fromEntries(installTools.map((tool) => [tool, PINNED_VENDOR_CLI_VERSIONS[tool]])),
78
+ installed,
79
+ missing,
80
+ installAttempted: prepared.installAttempted === true,
81
+ };
82
+
83
+ if (flags.json) io.logger.log(JSON.stringify(report));
84
+ else {
85
+ for (const tool of installTools) {
86
+ if (installed[tool]) io.logger.log(`${tool} ${PINNED_VENDOR_CLI_VERSIONS[tool]}: ${installed[tool]}`);
87
+ }
88
+ if (missing.length) io.logger.error(`impel _provision-vendor-clis: ${describeCliFailure(prepared, { commandName: "_provision-vendor-clis", isTTY: false, skipClis: flags["skip-install"] === true })}`);
89
+ }
90
+ if (!report.ok) process.exitCode = 1;
91
+ return report;
92
+ }
@@ -14,9 +14,17 @@ import {
14
14
  sessionOutboxStatus,
15
15
  touchSessionFlushLock,
16
16
  } from "../sessionCollector.js";
17
- import { loadConfig, redactSecretText } from "../config.js";
17
+ import {
18
+ crossAppModelsEnabled,
19
+ loadConfig,
20
+ normalizeGatewayUrl,
21
+ redactSecretText,
22
+ resolveDefaultGateway,
23
+ } from "../config.js";
24
+ import { tenantCliProfilePaths } from "../cliProfiles.js";
25
+ import { cliCodexCatalogStale } from "../modelSync.js";
18
26
  import { impelCliInvocation } from "../selfInvocation.js";
19
- import { spawnDetachedAppRefresh } from "../updates.js";
27
+ import { spawnDetachedAppRefresh, spawnDetachedModelSync } from "../updates.js";
20
28
  import { brandedEnvironmentName, RUNTIME_BRAND } from "../runtimeBrand.js";
21
29
 
22
30
  const MANAGED_SESSION_HOOK_FLAG = `${RUNTIME_BRAND.cli.command}-managed-session-hook-v1`;
@@ -71,6 +79,45 @@ export function maybeRepairManagedApps(tenantId, {
71
79
  return true;
72
80
  }
73
81
 
82
+ /**
83
+ * The model-catalog half of the token-helper heartbeat: when the isolated CLI
84
+ * Codex catalog for this tenant exists but is past its sync TTL (or its
85
+ * manifest no longer matches the file), kick one detached
86
+ * `models sync --stale-only`. Same shape as maybeRepairManagedApps — the
87
+ * vendor apps and Codex's auth command invoke `impel token` every ~5 minutes,
88
+ * so this is the channel that keeps catalogs converging on machines where
89
+ * nobody relaunches. The heartbeat lock stops repeated token calls from
90
+ * stacking sync children while the gateway is unreachable.
91
+ */
92
+ export function maybeSyncCliModels(tenantId, {
93
+ config = null,
94
+ catalogStale = cliCodexCatalogStale,
95
+ lockIsFresh = sessionFlushLockIsFresh,
96
+ touchLock = touchSessionFlushLock,
97
+ spawnModelSync = spawnDetachedModelSync,
98
+ } = {}) {
99
+ if (!tenantId) return false;
100
+ let stored = config;
101
+ if (stored === null) {
102
+ try {
103
+ stored = loadConfig();
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+ if (!stored?.pat) return false;
109
+ const gatewayUrl = normalizeGatewayUrl(stored.gatewayUrl || resolveDefaultGateway());
110
+ if (!catalogStale(tenantId, {
111
+ gatewayUrl,
112
+ crossAppModels: crossAppModelsEnabled(stored),
113
+ })) return false;
114
+ const lock = path.join(tenantCliProfilePaths(tenantId).codexHome, "models-sync-heartbeat");
115
+ if (lockIsFresh(lock, 60_000)) return false;
116
+ touchLock(lock);
117
+ spawnModelSync(tenantId);
118
+ return true;
119
+ }
120
+
74
121
  /**
75
122
  * The codex session-hook entry point: same repair, scoped to ChatGPT/Codex
76
123
  * drift exactly as before the helper was generalized (the codex hook fires on
@@ -127,7 +174,13 @@ export async function cmdSessions(argv) {
127
174
  config,
128
175
  flush: false,
129
176
  });
130
- if (flags.provider === "codex") maybeRepairManagedCodexApp(flags.tenant);
177
+ if (flags.provider === "codex") {
178
+ maybeRepairManagedCodexApp(flags.tenant);
179
+ // Same guaranteed-to-run channel, second artifact: keep the isolated
180
+ // CLI Codex model catalog converging from codex session events too
181
+ // (TTL + heartbeat-locked, detached `models sync --stale-only`).
182
+ maybeSyncCliModels(flags.tenant);
183
+ }
131
184
  // The Claude mirror of the codex repair above: Claude Desktop rewrites
132
185
  // its co-owned profile files too, and its session hooks are the only
133
186
  // Impel code guaranteed to still run afterward.
@@ -344,6 +344,7 @@ export async function cmdSetup(argv, overrides = {}) {
344
344
  tenantId: selected.id,
345
345
  platform: io.platform,
346
346
  skipInstall: true,
347
+ crossAppModels: config.experimental?.crossAppModels === true,
347
348
  });
348
349
  let prepared = inspected;
349
350
  if (inspected.missingAfter.length && !inspectOnly && !flags["skip-clis"]) {
@@ -376,6 +377,7 @@ export async function cmdSetup(argv, overrides = {}) {
376
377
  platform: io.platform,
377
378
  skipInstall: false,
378
379
  installTools,
380
+ crossAppModels: config.experimental?.crossAppModels === true,
379
381
  });
380
382
  }
381
383
  }
@@ -27,6 +27,7 @@ const HELP = `impel tasks - CRUD tickets in Impel
27
27
  Usage:
28
28
  impel tasks list [--org <org>] [--scope visible|done|all] [--json]
29
29
  impel tasks get <id> [--org <org>] [--json]
30
+ impel tasks projects [--org <org>] [--json]
30
31
  impel tasks create --title <title> [--description <md> | --description-file <path>] [options] [--json]
31
32
  impel tasks update <id> [options] [--json]
32
33
  impel tasks delete <id> --yes [--org <org>] [--json]
@@ -49,6 +50,7 @@ Options:
49
50
  --assignee <id|none> Assignee member id, or "none".
50
51
  --assigned-to <id|none> Alias for --assignee.
51
52
  --due <YYYY-MM-DD|empty> Due date, or empty string to clear.
53
+ --project <name|empty> Project name, or empty string to clear.
52
54
  --json Print raw JSON response.
53
55
  `;
54
56
 
@@ -74,6 +76,7 @@ function flagSpec() {
74
76
  assignee: { type: "string" },
75
77
  "assigned-to": { type: "string" },
76
78
  due: { type: "string" },
79
+ project: { type: "string" },
77
80
  yes: { type: "boolean" },
78
81
  };
79
82
  }
@@ -186,7 +189,22 @@ function descriptionFromFlags(flags) {
186
189
  return inline;
187
190
  }
188
191
 
189
- function mutationBody(flags, { requireTitle = false } = {}) {
192
+ function validateProject(value) {
193
+ if (value === undefined || value === "") return value;
194
+ if (value.length > 120) fail("impel tasks: project must be 120 characters or fewer.");
195
+ if (value.trim() !== value) {
196
+ fail("impel tasks: project cannot have leading or trailing whitespace.");
197
+ }
198
+ if ([...value].some((character) => {
199
+ const code = character.charCodeAt(0);
200
+ return code <= 31 || code === 127;
201
+ })) {
202
+ fail("impel tasks: project cannot contain control characters.");
203
+ }
204
+ return value;
205
+ }
206
+
207
+ export function taskMutationBody(flags, { requireTitle = false } = {}) {
190
208
  const body = {};
191
209
  if (flags.org !== undefined) body.orgId = flags.org;
192
210
 
@@ -219,6 +237,8 @@ function mutationBody(flags, { requireTitle = false } = {}) {
219
237
  if (assignedTo !== undefined) body.assignedTo = assignedTo;
220
238
 
221
239
  if (flags.due !== undefined) body.dueDate = flags.due;
240
+ const project = validateProject(flags.project);
241
+ if (project !== undefined) body.project = project;
222
242
 
223
243
  return body;
224
244
  }
@@ -262,6 +282,7 @@ function printTask(task, { orgId, markdown } = {}) {
262
282
  console.log(`Priority: ${task.priority || "none"}`);
263
283
  console.log(`Assignee: ${task.assignedTo || "unassigned"}`);
264
284
  console.log(`Labels: ${(task.labels || []).join(", ") || "none"}`);
285
+ console.log(`Project: ${task.project || "none"}`);
265
286
  if (task.latestRunId) console.log(`Run: ${task.latestRunId} (${task.runStatus || "unknown"})`);
266
287
  if (markdown) {
267
288
  console.log("");
@@ -294,6 +315,17 @@ export async function cmdTasks(argv) {
294
315
  return;
295
316
  }
296
317
 
318
+ case "projects": {
319
+ const payload = await requestJson({
320
+ flags,
321
+ path: "/api/cli/tasks/projects",
322
+ query: { orgId: flags.org },
323
+ });
324
+ if (flags.json) return printJson(payload);
325
+ for (const project of payload.projects || []) console.log(project);
326
+ return;
327
+ }
328
+
297
329
  case "get": {
298
330
  const issueId = positionals[0];
299
331
  if (!issueId) fail("impel tasks get: missing task id.");
@@ -308,7 +340,7 @@ export async function cmdTasks(argv) {
308
340
  }
309
341
 
310
342
  case "create": {
311
- const body = mutationBody(flags, { requireTitle: true });
343
+ const body = taskMutationBody(flags, { requireTitle: true });
312
344
  const payload = await requestJson({
313
345
  flags,
314
346
  path: "/api/cli/tasks",
@@ -324,7 +356,7 @@ export async function cmdTasks(argv) {
324
356
  case "update": {
325
357
  const issueId = positionals[0];
326
358
  if (!issueId) fail("impel tasks update: missing task id.");
327
- const body = mutationBody(flags);
359
+ const body = taskMutationBody(flags);
328
360
  if (Object.keys(body).filter((key) => key !== "orgId").length === 0) {
329
361
  fail("impel tasks update: pass at least one field to update.");
330
362
  }
@@ -1,7 +1,7 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { parseFlags } from "../args.js";
3
3
  import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
4
- import { maybeRepairManagedApps } from "./sessions.js";
4
+ import { maybeRepairManagedApps, maybeSyncCliModels } from "./sessions.js";
5
5
  import { RUNTIME_BRAND } from "../runtimeBrand.js";
6
6
 
7
7
  // This is the `apiKeyHelper` / auth-command contract: stdout (and only
@@ -12,6 +12,7 @@ export async function cmdToken(argv = [], overrides = {}) {
12
12
  loadConfig,
13
13
  ensureTenantSelection,
14
14
  repairManagedApps: maybeRepairManagedApps,
15
+ syncCliModels: maybeSyncCliModels,
15
16
  ...overrides,
16
17
  };
17
18
  const { flags } = parseFlags(argv, { tenant: { type: "string" } });
@@ -39,6 +40,15 @@ export async function cmdToken(argv = [], overrides = {}) {
39
40
  } catch {
40
41
  // Opportunistic only; the token contract is already fulfilled.
41
42
  }
43
+ // Same heartbeat, second surface: Codex invokes this helper every ~5
44
+ // minutes, so it is also where a stale isolated-CLI model catalog gets
45
+ // its detached `models sync --stale-only` (D13). Identical contract:
46
+ // after the token bytes, never stdout, never the exit code.
47
+ try {
48
+ io.syncCliModels(tenantId, { config });
49
+ } catch {
50
+ // Opportunistic only; the token contract is already fulfilled.
51
+ }
42
52
  } catch (error) {
43
53
  process.stderr.write(`${RUNTIME_BRAND.cli.command}: ${error.message}\n`);
44
54
  process.exitCode = 1;
@@ -12,6 +12,7 @@ const ALIASES = Object.freeze({
12
12
  tenants: "tenant",
13
13
  org: "tenant",
14
14
  skill: "skills",
15
+ model: "models",
15
16
  agent: "agents",
16
17
  upgrade: "update",
17
18
  on: "use",
@@ -39,6 +40,7 @@ function help(version) {
39
40
  if (enabled.has("mcp")) lines.push(` ${command} mcp Run the authenticated MCP transport`);
40
41
  if (enabled.has("sessions")) lines.push(` ${command} sessions ... Run managed session lifecycle hooks`);
41
42
  if (enabled.has("skills")) lines.push(` ${command} skills sync [...] Sync gateway skills into managed clients`);
43
+ if (enabled.has("models")) lines.push(` ${command} models list|sync Inspect or refresh the isolated Codex model catalog`);
42
44
  if (enabled.has("agents")) lines.push(` ${command} agents sync [...] Sync tenant agents into managed clients`);
43
45
  if (enabled.has("status")) lines.push(` ${command} status Show local readiness`);
44
46
  if (enabled.has("doctor")) lines.push(` ${command} doctor [...] Run gateway and provider diagnostics`);
@@ -0,0 +1,58 @@
1
+ // The authenticated tenant model catalog fetch (GET /v1/models), shared by
2
+ // the desktop app writers in apps.js and the CLI catalog sync in
3
+ // modelSync.js. Leaf module (no imports): the model-catalog side of the CLI
4
+ // must be loadable without pulling in the desktop bundle machinery, so this
5
+ // cannot live in apps.js. apps.js re-exports it for existing importers.
6
+
7
+ function isGatewayModel(model) {
8
+ return model && typeof model.id === "string" && (model.provider === "claude" || model.provider === "codex");
9
+ }
10
+
11
+ /**
12
+ * A tenant whose subscription genuinely has no Claude/Codex seats returns an
13
+ * explicitly marked empty catalog. Only lifecycle reconciliation may treat
14
+ * that as valid emptiness (allowEmpty) — every other caller keeps failing
15
+ * closed so a gateway outage can never masquerade as "no models".
16
+ */
17
+ function isExplicitNoSeatCatalog(payload) {
18
+ const statuses = payload?.provider_status;
19
+ return Boolean(statuses
20
+ && typeof statuses === "object"
21
+ && !Array.isArray(statuses)
22
+ && ["claude", "codex"].every((provider) => (
23
+ statuses[provider]?.state === "no_seat"
24
+ && statuses[provider]?.routable === false
25
+ )));
26
+ }
27
+
28
+ export async function fetchGatewayModels(config, fetchImpl = fetch, { allowEmpty = false } = {}) {
29
+ const controller = new AbortController();
30
+ const timeout = setTimeout(() => controller.abort(), 20000);
31
+ try {
32
+ const response = await fetchImpl(`${config.gatewayUrl}/v1/models`, {
33
+ headers: { authorization: `Bearer ${config.pat}` },
34
+ signal: controller.signal,
35
+ });
36
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
37
+ const payload = await response.json();
38
+ if (config.tenantId && payload?.org_id !== config.tenantId) {
39
+ throw new Error("gateway model catalog tenant did not match the selected tenant");
40
+ }
41
+ if (config.productAccess && payload?.product_access !== config.productAccess) {
42
+ throw new Error("gateway model catalog product access did not match the live entitlement");
43
+ }
44
+ if (!Array.isArray(payload?.data)) throw new Error("response has no model data array");
45
+ // The envelope is additive at schema version 3: overlay-aware gateways
46
+ // also send entitlement_available and org_defaults beside the filtered
47
+ // data[], whose per-model default/family_default flags are already
48
+ // rewritten to the tenant's effective defaults. Clients trust the served
49
+ // flags as-is and tolerate unknown envelope fields.
50
+ const models = payload.data.filter(isGatewayModel);
51
+ if (models.length === 0 && !(allowEmpty && isExplicitNoSeatCatalog(payload))) {
52
+ throw new Error("gateway returned no supported models");
53
+ }
54
+ return { models, source: "gateway", version: payload.version ?? null };
55
+ } finally {
56
+ clearTimeout(timeout);
57
+ }
58
+ }
package/src/macSetup.js CHANGED
@@ -26,7 +26,7 @@ export const MAC_CLI_INSTALLERS = Object.freeze({
26
26
  "https://claude.ai",
27
27
  "https://downloads.claude.ai",
28
28
  ]),
29
- sha256: "cde4f1702d3b1695f92b73d26888364e17bca476e17f0fd676484c951d36c125",
29
+ sha256: "3a68d3406cf674e17bed1733a4dcf37805e2e47d87417700007d7e1aa766a944",
30
30
  command: `curl -fsSL https://claude.ai/install.sh | bash -s ${PINNED_VENDOR_CLI_VERSIONS.claude}`,
31
31
  args: Object.freeze([PINNED_VENDOR_CLI_VERSIONS.claude]),
32
32
  environment: Object.freeze({}),
@@ -177,7 +177,12 @@ export async function prepareMacClis({
177
177
  credential = null,
178
178
  skipInstall = false,
179
179
  inspectOnly = false,
180
+ // Install the reviewed vendor binaries and stop there; see the same option
181
+ // on prepareWindowsClis for why an enrolled managed device wants the
182
+ // binaries without this CLI's isolated profiles.
183
+ vendorClisOnly = false,
180
184
  installTools = ["claude", "codex"],
185
+ crossAppModels = false,
181
186
  } = {}, dependencies = {}) {
182
187
  const io = {
183
188
  environment: process.env,
@@ -244,8 +249,27 @@ export async function prepareMacClis({
244
249
  const binaries = installAttempted
245
250
  ? detectMacClis(io.find, io.environment, io.verify)
246
251
  : before;
247
- const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId);
248
- const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId);
252
+ const missingAfter = Object.entries(binaries).filter(([, binary]) => !binary).map(([tool]) => tool);
253
+ const installSucceeded = installAttempted
254
+ ? Object.values(installations).every((installation) => installation.succeeded)
255
+ : null;
256
+ const installFailure = Object.values(installations).find((installation) => installation.failure)?.failure || null;
257
+ const installCommands = Object.fromEntries(missingBefore.map((tool) => [tool, io.installers[tool].command]));
258
+ if (vendorClisOnly) {
259
+ return {
260
+ binaries,
261
+ missingBefore,
262
+ missingAfter,
263
+ installAttempted,
264
+ installSucceeded,
265
+ installFailure,
266
+ installations,
267
+ installCommands,
268
+ profiles: null,
269
+ };
270
+ }
271
+ const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId, { crossAppModels });
272
+ const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId, { crossAppModels });
249
273
  if (binaries.claude) {
250
274
  await io.syncSkills({
251
275
  client: "claude",
@@ -277,14 +301,12 @@ export async function prepareMacClis({
277
301
  return {
278
302
  binaries,
279
303
  missingBefore,
280
- missingAfter: Object.entries(binaries).filter(([, binary]) => !binary).map(([tool]) => tool),
304
+ missingAfter,
281
305
  installAttempted,
282
- installSucceeded: installAttempted
283
- ? Object.values(installations).every((installation) => installation.succeeded)
284
- : null,
285
- installFailure: Object.values(installations).find((installation) => installation.failure)?.failure || null,
306
+ installSucceeded,
307
+ installFailure,
286
308
  installations,
287
- installCommands: Object.fromEntries(missingBefore.map((tool) => [tool, io.installers[tool].command])),
309
+ installCommands,
288
310
  profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
289
311
  };
290
312
  }
@@ -9,4 +9,9 @@
9
9
  // v44 disables the vendor background auto-updater in managed Claude CLI
10
10
  // profiles so a session on the reviewed release cannot move the shared
11
11
  // launcher past the pin (the Windows drift behind the gateway lockouts).
12
- export const CURRENT_CONFIG_VERSION = 44;
12
+ // v45 layers `impel models sync` over the isolated CLI Codex catalog: the
13
+ // profile writer keeps a manifest-verified synced catalog instead of always
14
+ // reasserting the FALLBACK_MODELS floor, records provenance in
15
+ // models-manifest.json, and (with cross-app models enabled) routes the CLI
16
+ // provider through the experimental OpenAI-compatible gateway path.
17
+ export const CURRENT_CONFIG_VERSION = 45;