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.
- package/RELEASE_NOTES.md +58 -0
- package/package.json +1 -1
- package/src/apps.js +6 -104
- package/src/cli.js +13 -0
- package/src/cliProfiles.js +28 -6
- package/src/commands/apps.js +43 -0
- package/src/commands/converge.js +2 -0
- package/src/commands/doctor.js +41 -3
- package/src/commands/launch.js +16 -2
- package/src/commands/models.js +160 -0
- package/src/commands/provisionVendorClis.js +92 -0
- package/src/commands/sessions.js +56 -3
- package/src/commands/setup.js +2 -0
- package/src/commands/tasks.js +35 -3
- package/src/commands/token.js +11 -1
- package/src/extension/index.js +2 -0
- package/src/gatewayModels.js +58 -0
- package/src/macSetup.js +31 -9
- package/src/managedProfileVersion.js +6 -1
- package/src/modelCatalog.js +109 -1
- package/src/modelSync.js +197 -0
- package/src/modelsManifest.js +132 -0
- package/src/platformSetup.js +7 -4
- package/src/provisioning.js +3 -1
- package/src/runtimeBrand.js +1 -1
- package/src/updates.js +10 -0
- package/src/windowsSetup.js +30 -5
|
@@ -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
|
+
}
|
package/src/commands/sessions.js
CHANGED
|
@@ -14,9 +14,17 @@ import {
|
|
|
14
14
|
sessionOutboxStatus,
|
|
15
15
|
touchSessionFlushLock,
|
|
16
16
|
} from "../sessionCollector.js";
|
|
17
|
-
import {
|
|
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")
|
|
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.
|
package/src/commands/setup.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/tasks.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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
|
}
|
package/src/commands/token.js
CHANGED
|
@@ -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;
|
package/src/extension/index.js
CHANGED
|
@@ -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: "
|
|
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
|
|
248
|
-
const
|
|
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
|
|
304
|
+
missingAfter,
|
|
281
305
|
installAttempted,
|
|
282
|
-
installSucceeded
|
|
283
|
-
|
|
284
|
-
: null,
|
|
285
|
-
installFailure: Object.values(installations).find((installation) => installation.failure)?.failure || null,
|
|
306
|
+
installSucceeded,
|
|
307
|
+
installFailure,
|
|
286
308
|
installations,
|
|
287
|
-
installCommands
|
|
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
|
-
|
|
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;
|