impel-cli 0.7.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.
@@ -0,0 +1,359 @@
1
+ import fs from "node:fs";
2
+
3
+ import { parseFlags } from "../args.js";
4
+ import { loadConfig, resolveDefaultAppUrl, normalizeGatewayUrl, redactSecretText } from "../config.js";
5
+ import {
6
+ ensureTenantSelection,
7
+ PAT_SCOPE_TASKS,
8
+ PRODUCT_ACCESS_GATEWAY,
9
+ PRODUCT_ACCESS_WORKSPACE,
10
+ } from "../tenants.js";
11
+
12
+ const VALID_PROGRESS = new Set(["none", "todo", "progress", "review", "done"]);
13
+ const VALID_PRIORITY = new Set(["none", "urgent", "high", "medium", "low"]);
14
+ const VALID_LABELS = new Set(["feature", "bug", "engineering", "design", "product"]);
15
+
16
+ const HELP = `impel tasks - CRUD tickets in Impel
17
+
18
+ Usage:
19
+ impel tasks list [--org <org>] [--scope visible|done|all] [--json]
20
+ impel tasks get <id> [--org <org>] [--json]
21
+ impel tasks create --title <title> [--description <md> | --description-file <path>] [options] [--json]
22
+ impel tasks update <id> [options] [--json]
23
+ impel tasks delete <id> --yes [--org <org>] [--json]
24
+
25
+ Aliases:
26
+ impel task ...
27
+ impel tickets ...
28
+ impel ticket ...
29
+
30
+ Options:
31
+ --org <org> Target org id. Defaults to the selected tenant.
32
+ --app <url> Override app URL for this command.
33
+ --title <title> Ticket title.
34
+ --description <markdown> Ticket body markdown.
35
+ --description-file <path> Read ticket body markdown from a file.
36
+ --description-mode replace|append Description update mode. Default: replace.
37
+ --progress none|todo|progress|review|done
38
+ --priority none|urgent|high|medium|low
39
+ --labels <csv> Full label set, e.g. feature,engineering. Empty clears labels.
40
+ --assignee <id|none> Assignee member id, or "none".
41
+ --assigned-to <id|none> Alias for --assignee.
42
+ --due <YYYY-MM-DD|empty> Due date, or empty string to clear.
43
+ --json Print raw JSON response.
44
+ `;
45
+
46
+ class UsageError extends Error {}
47
+
48
+ function fail(message) {
49
+ throw new UsageError(message);
50
+ }
51
+
52
+ function flagSpec() {
53
+ return {
54
+ app: { type: "string" },
55
+ org: { type: "string" },
56
+ scope: { type: "string" },
57
+ json: { type: "boolean" },
58
+ title: { type: "string" },
59
+ description: { type: "string" },
60
+ "description-file": { type: "string" },
61
+ "description-mode": { type: "string" },
62
+ progress: { type: "string" },
63
+ priority: { type: "string" },
64
+ labels: { type: "string" },
65
+ assignee: { type: "string" },
66
+ "assigned-to": { type: "string" },
67
+ due: { type: "string" },
68
+ yes: { type: "boolean" },
69
+ };
70
+ }
71
+
72
+ function requireConfig(flags) {
73
+ const config = loadConfig();
74
+ if (!config?.pat) {
75
+ fail("impel: not authenticated. Run `impel setup` (or `impel auth`) first.");
76
+ }
77
+ const appUrl = normalizeGatewayUrl(flags.app || config.appUrl || resolveDefaultAppUrl());
78
+ return { config, pat: config.pat, appUrl };
79
+ }
80
+
81
+ async function requestJson({ flags, path, query, method = "GET", body }) {
82
+ const { config, pat, appUrl } = requireConfig(flags);
83
+ const url = new URL(path, appUrl);
84
+ const selected = await ensureTenantSelection(config, { refresh: true });
85
+ if (selected.productAccess === PRODUCT_ACCESS_GATEWAY) {
86
+ fail("impel tasks: Gateway members do not have workspace task access.");
87
+ }
88
+ if (selected.productAccess !== PRODUCT_ACCESS_WORKSPACE) {
89
+ fail("impel tasks: the control plane did not return a live Workspace entitlement; retry after it is upgraded.");
90
+ }
91
+ if (!Array.isArray(selected.scopes)) {
92
+ fail("impel tasks: the control plane did not return live PAT scopes; retry after it is upgraded.");
93
+ }
94
+ if (!selected.scopes.includes(PAT_SCOPE_TASKS)) {
95
+ fail("impel tasks: this PAT is missing the \"tasks\" scope; create a fresh PAT in Impel Gateway setup, then run `impel auth --pat <pat>`.");
96
+ }
97
+ const selectedOrgId = flags.org || selected.tenantId;
98
+ const resolvedQuery = { ...(query || {}) };
99
+ if (Object.hasOwn(resolvedQuery, "orgId") && !resolvedQuery.orgId) {
100
+ resolvedQuery.orgId = selectedOrgId;
101
+ }
102
+ const resolvedBody = body && !body.orgId && selectedOrgId
103
+ ? { ...body, orgId: selectedOrgId }
104
+ : body;
105
+ for (const [key, value] of Object.entries(resolvedQuery)) {
106
+ if (value !== undefined && value !== null && value !== "") {
107
+ url.searchParams.set(key, value);
108
+ }
109
+ }
110
+
111
+ let res;
112
+ try {
113
+ res = await fetch(url, {
114
+ method,
115
+ headers: {
116
+ accept: "application/json",
117
+ authorization: `Bearer ${pat}`,
118
+ ...(resolvedBody === undefined ? {} : { "content-type": "application/json" }),
119
+ },
120
+ body: resolvedBody === undefined ? undefined : JSON.stringify(resolvedBody),
121
+ });
122
+ } catch (err) {
123
+ fail(`impel tasks: could not reach ${appUrl}: ${redactSecretText(err.message)}`);
124
+ }
125
+
126
+ const text = await res.text();
127
+ let payload = null;
128
+ try {
129
+ payload = text ? JSON.parse(text) : null;
130
+ } catch {
131
+ payload = { error: text || res.statusText };
132
+ }
133
+
134
+ if (!res.ok) {
135
+ const message = redactSecretText(payload?.error || `${res.status} ${res.statusText}`.trim());
136
+ fail(`impel tasks: ${message}`);
137
+ }
138
+ return payload;
139
+ }
140
+
141
+ function parseCsv(value, valid, label) {
142
+ if (value === undefined) return undefined;
143
+ const items = String(value)
144
+ .split(",")
145
+ .map((item) => item.trim())
146
+ .filter(Boolean);
147
+ for (const item of items) {
148
+ if (!valid.has(item)) {
149
+ fail(`impel tasks: invalid ${label} "${item}".`);
150
+ }
151
+ }
152
+ return items;
153
+ }
154
+
155
+ function validateOne(value, valid, label) {
156
+ if (value === undefined) return undefined;
157
+ if (!valid.has(value)) fail(`impel tasks: invalid ${label} "${value}".`);
158
+ return value;
159
+ }
160
+
161
+ function descriptionFromFlags(flags) {
162
+ const inline = flags.description;
163
+ const file = flags["description-file"];
164
+ if (inline !== undefined && file !== undefined) {
165
+ fail("impel tasks: use either --description or --description-file, not both.");
166
+ }
167
+ if (file !== undefined) {
168
+ try {
169
+ return fs.readFileSync(file, "utf8");
170
+ } catch (err) {
171
+ fail(`impel tasks: could not read ${file}: ${err.message}`);
172
+ }
173
+ }
174
+ return inline;
175
+ }
176
+
177
+ function mutationBody(flags, { requireTitle = false } = {}) {
178
+ const body = {};
179
+ if (flags.org !== undefined) body.orgId = flags.org;
180
+
181
+ if (flags.title !== undefined) body.title = flags.title;
182
+ if (requireTitle && !body.title?.trim()) {
183
+ fail("impel tasks create: --title is required.");
184
+ }
185
+
186
+ const description = descriptionFromFlags(flags);
187
+ if (description !== undefined) body.descriptionMarkdown = description;
188
+
189
+ const mode = flags["description-mode"];
190
+ if (mode !== undefined) {
191
+ if (mode !== "replace" && mode !== "append") {
192
+ fail('impel tasks: --description-mode must be "replace" or "append".');
193
+ }
194
+ body.descriptionMode = mode;
195
+ }
196
+
197
+ const progress = validateOne(flags.progress, VALID_PROGRESS, "progress");
198
+ if (progress !== undefined) body.progress = progress;
199
+
200
+ const priority = validateOne(flags.priority, VALID_PRIORITY, "priority");
201
+ if (priority !== undefined) body.priority = priority;
202
+
203
+ const labels = parseCsv(flags.labels, VALID_LABELS, "label");
204
+ if (labels !== undefined) body.labels = labels;
205
+
206
+ const assignedTo = flags.assignee ?? flags["assigned-to"];
207
+ if (assignedTo !== undefined) body.assignedTo = assignedTo;
208
+
209
+ if (flags.due !== undefined) body.dueDate = flags.due;
210
+
211
+ return body;
212
+ }
213
+
214
+ function printJson(payload) {
215
+ console.log(JSON.stringify(payload, null, 2));
216
+ }
217
+
218
+ function truncate(value, max) {
219
+ const text = String(value ?? "");
220
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text;
221
+ }
222
+
223
+ function printTable(tasks) {
224
+ if (!tasks.length) {
225
+ console.log("No tasks found.");
226
+ return;
227
+ }
228
+ const rows = tasks.map((task) => [
229
+ task.issueId,
230
+ task.progress || "none",
231
+ task.priority || "none",
232
+ task.assignedTo || "unassigned",
233
+ truncate(task.title || "Untitled", 80),
234
+ ]);
235
+ const headers = ["ID", "Progress", "Priority", "Assignee", "Title"];
236
+ const widths = headers.map((header, i) =>
237
+ Math.max(header.length, ...rows.map((row) => String(row[i]).length))
238
+ );
239
+ const line = (cols) =>
240
+ cols.map((col, i) => String(col).padEnd(widths[i])).join(" ").trimEnd();
241
+ console.log(line(headers));
242
+ console.log(line(widths.map((width) => "-".repeat(width))));
243
+ for (const row of rows) console.log(line(row));
244
+ }
245
+
246
+ function printTask(task, { orgId, markdown } = {}) {
247
+ console.log(`${task.issueId}: ${task.title || "Untitled"}`);
248
+ if (orgId) console.log(`Org: ${orgId}`);
249
+ console.log(`Progress: ${task.progress || "none"}`);
250
+ console.log(`Priority: ${task.priority || "none"}`);
251
+ console.log(`Assignee: ${task.assignedTo || "unassigned"}`);
252
+ console.log(`Labels: ${(task.labels || []).join(", ") || "none"}`);
253
+ if (task.latestRunId) console.log(`Run: ${task.latestRunId} (${task.runStatus || "unknown"})`);
254
+ if (markdown) {
255
+ console.log("");
256
+ console.log(markdown);
257
+ }
258
+ }
259
+
260
+ export async function cmdTasks(argv) {
261
+ const [subcommand, ...rest] = argv;
262
+ if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
263
+ console.log(HELP);
264
+ return;
265
+ }
266
+
267
+ const { flags, positionals } = parseFlags(rest, flagSpec());
268
+ try {
269
+ switch (subcommand) {
270
+ case "list": {
271
+ const scope = flags.scope || "all";
272
+ if (!["visible", "done", "all"].includes(scope)) {
273
+ fail("impel tasks list: --scope must be visible, done, or all.");
274
+ }
275
+ const payload = await requestJson({
276
+ flags,
277
+ path: "/api/cli/tasks",
278
+ query: { orgId: flags.org, scope },
279
+ });
280
+ if (flags.json) return printJson(payload);
281
+ printTable(payload.tasks || []);
282
+ return;
283
+ }
284
+
285
+ case "get": {
286
+ const issueId = positionals[0];
287
+ if (!issueId) fail("impel tasks get: missing task id.");
288
+ const payload = await requestJson({
289
+ flags,
290
+ path: `/api/cli/tasks/${encodeURIComponent(issueId)}`,
291
+ query: { orgId: flags.org },
292
+ });
293
+ if (flags.json) return printJson(payload);
294
+ printTask(payload.task, { orgId: payload.orgId, markdown: payload.markdown });
295
+ return;
296
+ }
297
+
298
+ case "create": {
299
+ const body = mutationBody(flags, { requireTitle: true });
300
+ const payload = await requestJson({
301
+ flags,
302
+ path: "/api/cli/tasks",
303
+ method: "POST",
304
+ body,
305
+ });
306
+ if (flags.json) return printJson(payload);
307
+ console.log(`Created ${payload.task.issueId}`);
308
+ printTask(payload.task, { orgId: payload.orgId });
309
+ return;
310
+ }
311
+
312
+ case "update": {
313
+ const issueId = positionals[0];
314
+ if (!issueId) fail("impel tasks update: missing task id.");
315
+ const body = mutationBody(flags);
316
+ if (Object.keys(body).filter((key) => key !== "orgId").length === 0) {
317
+ fail("impel tasks update: pass at least one field to update.");
318
+ }
319
+ const payload = await requestJson({
320
+ flags,
321
+ path: `/api/cli/tasks/${encodeURIComponent(issueId)}`,
322
+ method: "PATCH",
323
+ body,
324
+ });
325
+ if (flags.json) return printJson(payload);
326
+ console.log(`Updated ${payload.task.issueId}`);
327
+ printTask(payload.task, { orgId: payload.orgId });
328
+ return;
329
+ }
330
+
331
+ case "delete": {
332
+ const issueId = positionals[0];
333
+ if (!issueId) fail("impel tasks delete: missing task id.");
334
+ if (!flags.yes) {
335
+ fail("impel tasks delete: refusing to delete without --yes.");
336
+ }
337
+ const payload = await requestJson({
338
+ flags,
339
+ path: `/api/cli/tasks/${encodeURIComponent(issueId)}`,
340
+ query: { orgId: flags.org },
341
+ method: "DELETE",
342
+ });
343
+ if (flags.json) return printJson(payload);
344
+ console.log(`Deleted ${payload.issueId}`);
345
+ return;
346
+ }
347
+
348
+ default:
349
+ fail(`impel tasks: unknown subcommand "${subcommand}".`);
350
+ }
351
+ } catch (err) {
352
+ if (err instanceof UsageError) {
353
+ console.error(err.message);
354
+ process.exitCode = 1;
355
+ return;
356
+ }
357
+ throw err;
358
+ }
359
+ }
@@ -0,0 +1,77 @@
1
+ import { parseFlags } from "../args.js";
2
+ import { loadConfig } from "../config.js";
3
+ import { ensureTenantSelection, productAccessLabel, selectTenant } from "../tenants.js";
4
+ import { cmdApps } from "./apps.js";
5
+ import { cmdLaunch } from "./launch.js";
6
+
7
+ const HELP = `impel tenant - select the organization used by Impel sessions
8
+
9
+ Usage:
10
+ impel tenant list
11
+ impel tenant current
12
+ impel tenant use <org-slug> [--launch claude|codex|apps]
13
+
14
+ Aliases: impel tenants ..., impel org ...
15
+ `;
16
+
17
+ function requireConfig() {
18
+ const config = loadConfig();
19
+ if (!config?.pat) throw new Error("not authenticated; run `impel setup` (or `impel auth`) first");
20
+ return config;
21
+ }
22
+
23
+ function printTenants(listing, selectedId) {
24
+ console.log(`Access: ${productAccessLabel(listing.productAccess)}`);
25
+ for (const tenant of listing.tenants) {
26
+ const markers = [
27
+ tenant.id === selectedId ? "selected" : null,
28
+ tenant.id === listing.defaultTenantId ? "default" : null,
29
+ ].filter(Boolean);
30
+ console.log(`${tenant.id}${markers.length ? ` (${markers.join(", ")})` : ""}\t${tenant.name}`);
31
+ }
32
+ }
33
+
34
+ export async function cmdTenant(argv) {
35
+ const [action = "current", ...rest] = argv;
36
+ if (["help", "--help", "-h"].includes(action)) {
37
+ console.log(HELP);
38
+ return;
39
+ }
40
+ const config = requireConfig();
41
+
42
+ if (action === "list") {
43
+ const selected = await ensureTenantSelection(config, { refresh: true });
44
+ printTenants(
45
+ {
46
+ tenants: selected.tenants,
47
+ defaultTenantId: selected.defaultTenantId,
48
+ // productAccess is global to the user, not an org role.
49
+ productAccess: selected.productAccess,
50
+ },
51
+ selected.tenantId,
52
+ );
53
+ return;
54
+ }
55
+
56
+ if (action === "current") {
57
+ const selected = await ensureTenantSelection(config, { refresh: true });
58
+ console.log(selected.tenantId);
59
+ return;
60
+ }
61
+
62
+ if (action !== "use") throw new Error(`unknown tenant action "${action}"; use list, current, or use`);
63
+ const { flags, positionals } = parseFlags(rest, { launch: { type: "string" } });
64
+ const tenantId = positionals[0];
65
+ if (!tenantId) throw new Error("tenant use requires an org slug; run `impel tenant list`");
66
+ const { tenant } = await selectTenant(config, tenantId);
67
+ console.log(`Tenant -> ${tenant.id} (${tenant.name})`);
68
+
69
+ const launch = flags.launch;
70
+ if (!launch) {
71
+ console.log("Next: `impel claude`, `impel codex`, or `impel app open`.");
72
+ return;
73
+ }
74
+ if (launch === "claude" || launch === "codex") return cmdLaunch(launch, []);
75
+ if (launch === "apps") return cmdApps(["open", "all"]);
76
+ throw new Error(`unknown launch target "${launch}"; use claude, codex, or apps`);
77
+ }
@@ -0,0 +1,25 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { parseFlags } from "../args.js";
3
+ import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
4
+
5
+ // This is the `apiKeyHelper` / auth-command contract: stdout (and only
6
+ // stdout) must be exactly the bearer token, nothing else. Both Claude Code's
7
+ // apiKeyHelper and Codex CLI's `model_providers.<id>.auth.command` call this.
8
+ export async function cmdToken(argv = []) {
9
+ const { flags } = parseFlags(argv, { tenant: { type: "string" } });
10
+ const config = loadConfig();
11
+ if (!config?.pat) {
12
+ process.stderr.write("impel: not authenticated. Run `impel setup` (or `impel auth`) first.\n");
13
+ process.exitCode = 1;
14
+ return;
15
+ }
16
+ try {
17
+ const tenantId = flags.tenant
18
+ ? normalizeTenantId(flags.tenant)
19
+ : (await ensureTenantSelection(config)).tenantId;
20
+ process.stdout.write(`${tenantCredential(config.pat, tenantId)}\n`);
21
+ } catch (error) {
22
+ process.stderr.write(`impel: ${error.message}\n`);
23
+ process.exitCode = 1;
24
+ }
25
+ }
@@ -0,0 +1,217 @@
1
+ // `impel update` — bring everything current in one command: the CLI itself
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
4
+ // performs it.
5
+
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import { parseFlags } from "../args.js";
13
+ import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
14
+ import { loadConfig, redactSecretText } from "../config.js";
15
+ import { nativeCommandInvocation } from "../nativeProcess.js";
16
+ import { windowsClaudeUserData } from "../windowsApps.js";
17
+ import {
18
+ fetchRemoteVersion,
19
+ installedVersion,
20
+ isNewerVersion,
21
+ refreshUpdateCache,
22
+ updateInstallSpec,
23
+ writeUpdateCache,
24
+ } from "../updates.js";
25
+
26
+ const CLI_BIN = fileURLToPath(new URL("../../bin/impel.js", import.meta.url));
27
+
28
+ const HELP = `impel update - update everything Impel in one command
29
+
30
+ Reinstalls impel-cli from npm, then cascades with the NEW build:
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.
34
+
35
+ Usage:
36
+ impel update Update the CLI, then cascade to apps and skills
37
+ impel update --check Report whether an update is available; change nothing
38
+ impel update --skip-apps Skip the desktop-app step of the cascade
39
+ `;
40
+
41
+ function reportProcessFailure(stage, result) {
42
+ if (result?.error) {
43
+ console.error(`impel update: ${stage} could not start (${redactSecretText(result.error.message || result.error)}).`);
44
+ } else if (Number.isInteger(result?.status)) {
45
+ console.error(`impel update: ${stage} exited with code ${result.status}.`);
46
+ } else if (result?.signal) {
47
+ console.error(`impel update: ${stage} stopped on signal ${result.signal}.`);
48
+ } else {
49
+ console.error(`impel update: ${stage} failed without an exit status.`);
50
+ }
51
+ }
52
+
53
+ // npm verifies the registry tarball's integrity before replacing the global
54
+ // package. No GitHub account or repository credential is involved.
55
+ export function installUpdateFromRegistry(spec = updateInstallSpec(), dependencies = {}) {
56
+ const io = {
57
+ spawnSync,
58
+ platform: process.platform,
59
+ environment: process.env,
60
+ ...dependencies,
61
+ };
62
+ try {
63
+ const installInvocation = nativeCommandInvocation(
64
+ "npm",
65
+ ["install", "-g", spec],
66
+ io.environment,
67
+ io.platform,
68
+ );
69
+ const installed = io.spawnSync(installInvocation.command, installInvocation.args, {
70
+ stdio: "inherit",
71
+ env: io.environment,
72
+ windowsVerbatimArguments: installInvocation.windowsVerbatimArguments,
73
+ });
74
+ if (installed.status !== 0 || installed.error) {
75
+ reportProcessFailure("`npm install --global`", installed);
76
+ return false;
77
+ }
78
+ return true;
79
+ } catch (error) {
80
+ console.error(`impel update: npm install failed (${redactSecretText(error?.message || error)}).`);
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function defaultSelfUpdate(spec) {
86
+ return installUpdateFromRegistry(spec);
87
+ }
88
+
89
+ // The cascading steps re-execute the (freshly installed) CLI binary so the
90
+ // NEW code performs them, not the process that started the update.
91
+ function defaultRunAppsUpdate() {
92
+ const result = spawnSync(process.execPath, [CLI_BIN, "app", "update", "all"], {
93
+ stdio: "inherit",
94
+ });
95
+ return result.status === 0;
96
+ }
97
+
98
+ function defaultRunSkillsSync() {
99
+ const result = spawnSync(process.execPath, [CLI_BIN, "skills", "sync", "all"], {
100
+ stdio: "inherit",
101
+ });
102
+ return result.status === 0;
103
+ }
104
+
105
+ function anyAppInstalled(homeDir = os.homedir()) {
106
+ const config = loadConfig();
107
+ const claudeUserData = process.platform === "win32"
108
+ ? windowsClaudeUserData(process.env, config?.tenantId || null)
109
+ : null;
110
+ const paths = appPaths(homeDir, config?.tenantId || null, { claudeUserData });
111
+ if (process.platform === "win32") {
112
+ return (
113
+ fs.existsSync(path.join(
114
+ paths.claude.userData,
115
+ "configLibrary",
116
+ `${CLAUDE_CONFIG_ID}.json`,
117
+ ))
118
+ || fs.existsSync(path.join(paths.chatgpt.codexHome, "config.toml"))
119
+ );
120
+ }
121
+ return (
122
+ fs.existsSync(paths.claude.launcher) || fs.existsSync(paths.chatgpt.launcher)
123
+ );
124
+ }
125
+
126
+ export async function cmdUpdate(argv, overrides = {}) {
127
+ const io = {
128
+ fetchRemoteVersion,
129
+ installedVersion,
130
+ refreshUpdateCache,
131
+ writeCache: writeUpdateCache,
132
+ selfUpdate: defaultSelfUpdate,
133
+ runAppsUpdate: defaultRunAppsUpdate,
134
+ runSkillsSync: defaultRunSkillsSync,
135
+ appsInstalled: anyAppInstalled,
136
+ platform: process.platform,
137
+ ...overrides,
138
+ };
139
+ const { flags } = parseFlags(argv, {
140
+ check: { type: "boolean" },
141
+ "skip-apps": { type: "boolean" },
142
+ "refresh-cache": { type: "boolean" },
143
+ help: { type: "boolean" },
144
+ });
145
+ if (flags.help) {
146
+ console.log(HELP);
147
+ return;
148
+ }
149
+ // Internal mode used by the detached launch-time check: refresh the cached
150
+ // registry metadata and exit silently.
151
+ if (flags["refresh-cache"]) {
152
+ await io.refreshUpdateCache();
153
+ return;
154
+ }
155
+
156
+ const current = io.installedVersion();
157
+ const remote = await io.fetchRemoteVersion();
158
+ if (remote) io.writeCache({ remoteVersion: remote, checkedAt: Date.now() });
159
+
160
+ console.log(`impel-cli v${current ?? "?"}`);
161
+ console.log(`npm latest: ${remote ? `v${remote}` : "unknown — registry check failed"}`);
162
+
163
+ const updateAvailable = Boolean(current && remote && isNewerVersion(remote, current));
164
+ const upToDate = Boolean(current && remote && !updateAvailable);
165
+ if (flags.check) {
166
+ console.log(upToDate ? "Up to date." : "Update available: run `impel update`.");
167
+ if (!remote) process.exitCode = 1;
168
+ return;
169
+ }
170
+
171
+ // ── CLI ────────────────────────────────────────────────────────────────
172
+ if (upToDate) {
173
+ console.log("CLI: already up to date.");
174
+ } else {
175
+ console.log("CLI: installing the latest build…");
176
+ if (!io.selfUpdate(updateInstallSpec())) {
177
+ console.error("impel update: `npm install -g` failed; the CLI was not updated.");
178
+ if (io.platform === "win32") {
179
+ console.error(" Verify `npm --version` in PowerShell, then retry `impel update`.");
180
+ console.error(" Manual recovery: `npm install --global impel-cli@latest`.");
181
+ }
182
+ process.exitCode = 1;
183
+ return;
184
+ }
185
+ console.log(`CLI: updated${remote ? ` to v${remote}` : ""}.`);
186
+ }
187
+
188
+ // ── Cascade: apps, then skills across every managed profile ────────────
189
+ let cascadeFailed = false;
190
+ if (flags["skip-apps"]) {
191
+ console.log("Apps: skipped (--skip-apps).");
192
+ } else if (io.platform !== "darwin" && io.platform !== "win32") {
193
+ console.log("Apps: skipped — isolated desktop apps are unavailable on this platform.");
194
+ } else if (!io.appsInstalled()) {
195
+ console.log("Apps: none installed; skipping (run `impel app install` or `impel setup`).");
196
+ } else {
197
+ console.log(io.platform === "win32"
198
+ ? "Apps: updating the signed Claude and ChatGPT vendor apps and isolated profiles…"
199
+ : "Apps: updating (running apps are closed, vendor apps updated, bundles rebuilt)…");
200
+ if (!io.runAppsUpdate()) {
201
+ console.error("impel update: the app update failed; re-run `impel app update` after fixing the issue.");
202
+ cascadeFailed = true;
203
+ }
204
+ }
205
+
206
+ console.log("Skills: syncing every managed profile…");
207
+ if (!io.runSkillsSync()) {
208
+ console.error("impel update: skill sync failed; re-run `impel skills sync` after fixing the issue.");
209
+ cascadeFailed = true;
210
+ }
211
+
212
+ if (cascadeFailed) {
213
+ process.exitCode = 1;
214
+ return;
215
+ }
216
+ console.log("Everything is up to date.");
217
+ }