impel-cli 0.9.0 → 0.9.2

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
@@ -124,6 +124,7 @@ impel auth [--pat <pat>] [--gateway <url>] [--app <url>]
124
124
  impel pat create --label <label> [options] Mint a personal PAT through the control plane
125
125
  impel pat create --agent <agent-id> [options]
126
126
  Mint a PAT associated with an org agent
127
+ impel pat revoke <token-id> --yes Irreversibly revoke one of your PATs
127
128
  impel token [--tenant <org>] Print a selected-tenant bearer
128
129
  impel mcp Run the local MCP bridge used by Claude/Codex
129
130
  impel claude [claude args...] Launch isolated Impel Claude Code
@@ -153,8 +154,8 @@ impel app update [target] [--tenant <org>] Refresh that tenant's apps, configs
153
154
  impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
154
155
  (--stale-only: no-op unless 6h+ since last sync)
155
156
  impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
156
- impel app open [target] [--tenant <org>] Launch isolated instances (fast path: opens
157
- immediately when nothing changed since last open;
157
+ impel app open [target] [--tenant <org>] Install/configure a tenant on first use, then launch
158
+ it (later opens are immediate when nothing changed;
158
159
  a detached stale-only refresh keeps things current)
159
160
  impel app uninstall [target] [--tenant <org>] [--keep-data]
160
161
  Remove one tenant's Impel-managed app files
@@ -186,7 +187,7 @@ Stores your PAT, gateway base URL, and app/control-plane URL in `~/.config/impel
186
187
  impel auth --pat impel_pat_xxxxxxxxxxxx --gateway https://gateway.useimpel.com --app https://www.useimpel.com
187
188
  ```
188
189
 
189
- ### `impel pat create ...` (alias `impel pat mint ...`)
190
+ ### `impel pat create ...` / `impel pat revoke ...`
190
191
 
191
192
  Mints a new PAT through the app/control-plane API, authenticated by the PAT
192
193
  already stored by `impel auth`. Identity remains the minting authority: the
@@ -215,6 +216,20 @@ the config file. To rotate this CLI onto it, explicitly run
215
216
  `impel auth --pat <new-pat>` after copying it. `--json` includes the raw secret,
216
217
  so treat redirected output as a credential.
217
218
 
219
+ Revoke a token by the token ID printed when it was created:
220
+
221
+ ```sh
222
+ impel pat revoke personal1 --yes
223
+ impel pat revoke personal1 --yes --json
224
+ ```
225
+
226
+ Revocation is immediate and irreversible, so `--yes` is required. A full PAT is
227
+ also accepted for recovery from older mint output, but using the token ID avoids
228
+ putting a secret in shell history. The CLI extracts the ID without echoing the
229
+ secret, authenticates with the currently stored PAT, and leaves the local config
230
+ unchanged. If you revoke the stored PAT itself, authenticate again with a live PAT
231
+ before running another protected command.
232
+
218
233
  ### `impel update`
219
234
 
220
235
  One command brings everything current, in dependency order: the CLI itself
@@ -277,9 +292,13 @@ On macOS, desktop app state is isolated under
277
292
  bundles in `~/Applications`, with names such as `Impel Claude (Acme)` and
278
293
  `Impel ChatGPT (Acme)`, stable tenant-specific bundle identifiers, and fixed
279
294
  tenant profile/auth paths. Different tenants can stay open concurrently.
280
- `impel app open --tenant <org>` refreshes and launches a tenant directly without
281
- changing the globally selected tenant. Switching tenants never deletes local
282
- history or projects; each tenant's preserved profile reopens independently.
295
+ `impel app open --tenant <org>` installs and configures a missing tenant variant
296
+ on first use, then launches it directly without changing the globally selected
297
+ tenant. Later opens use the existing isolated app and profile. Switching tenants
298
+ never deletes local history or projects; each tenant's preserved profile reopens
299
+ independently. The first-use setup still requires an authenticated user PAT and
300
+ live membership in the requested organization; it does not create a PAT per
301
+ tenant.
283
302
  Windows Claude Desktop keeps each
284
303
  tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
285
304
  signed vendor executable with that profile selected. Windows ChatGPT keeps its
@@ -445,8 +464,9 @@ the selected tenant's 3P gateway configuration beneath Anthropic's reserved
445
464
  normal Claude profile unchanged. `impel app install codex` installs/updates
446
465
  OpenAI's `OpenAI.Codex` Store package, stages its unchanged signed app payload
447
466
  inside Impel's shared app cache, and writes separate Codex and Chromium
448
- profiles. `impel app open claude` and `impel app open codex` launch those
449
- isolated profiles without changing either normal vendor profile.
467
+ profiles. `impel app open claude` and `impel app open codex` install a missing
468
+ official app and configure the selected tenant on first use, then launch the
469
+ isolated profile without changing either normal vendor profile.
450
470
 
451
471
  For Claude, the Impel PAT is supplied only to the launched child process as an
452
472
  authentication token and disappears when that process exits; it is never
@@ -587,10 +607,8 @@ Codex CLI and the Codex IDE extension / desktop app share the same
587
607
  ### Windows Claude and ChatGPT Desktop
588
608
 
589
609
  ```powershell
590
- impel app install claude
591
610
  impel app open claude
592
611
  impel app status claude
593
- impel app install codex
594
612
  impel app open codex
595
613
  impel app status codex
596
614
  ```
@@ -615,7 +633,8 @@ app-target names.
615
633
 
616
634
  ### macOS Claude and ChatGPT
617
635
 
618
- `impel app install` creates tenant-specific apps such as
636
+ The first `impel app open --tenant <org>` (or an explicit `impel app install`)
637
+ creates tenant-specific apps such as
619
638
  `Impel Claude (acme).app` and `Impel ChatGPT (acme).app` in `~/Applications`.
620
639
  Their visible bundle names use the organization display name, for example
621
640
  `Impel Claude (Acme)` and `Impel ChatGPT (Acme)`. Each tenant/product pair has
@@ -642,11 +661,10 @@ a stable, unique bundle identifier and gateway configuration:
642
661
  - Neither app changes `~/.claude`, `~/.claude.json`, `~/.codex`, or the
643
662
  normal vendor app's browser/profile data.
644
663
 
645
- Install or open several tenants without changing the current selection:
664
+ Open several tenants without changing the current selection. A missing variant
665
+ is installed and configured automatically before it opens:
646
666
 
647
667
  ```sh
648
- impel app install all --tenant acme
649
- impel app install all --tenant globex
650
668
  impel app open all --tenant acme
651
669
  impel app open all --tenant globex
652
670
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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/cli.js CHANGED
@@ -29,7 +29,7 @@ Get started:
29
29
  Run:
30
30
  impel claude [args...] Launch Claude Code with an isolated Impel profile
31
31
  impel codex [args...] Launch Codex with an isolated Impel profile
32
- impel app open [target] [--tenant <org>] Open that tenant's isolated desktop app(s)
32
+ impel app open [target] [--tenant <org>] Install/configure on first use, then open the app(s)
33
33
  impel status Tenant, PAT, per-tool mode, gateway reachability
34
34
  impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
35
35
 
@@ -37,6 +37,7 @@ Manage:
37
37
  impel auth [--pat <pat>] Store your PAT + URLs (--gateway/--app <url>)
38
38
  impel pat create --label <label> Mint a personal PAT (--org/--scopes/--ttl-days)
39
39
  impel pat create --agent <agent-id> Mint an org-scoped PAT associated with an agent
40
+ impel pat revoke <token-id> --yes Irreversibly revoke one of your PATs
40
41
  impel tenant list|current|use <org> List or select the active organization
41
42
  impel tasks list|get|create|update|delete CRUD Impel tickets (aliases: task, ticket[s])
42
43
  impel token [--tenant <org>] Print the selected-tenant bearer
@@ -207,13 +207,26 @@ export async function cmdWindowsApps(argv, overrides = {}) {
207
207
  claudeUserData: actionUserData,
208
208
  tenantName: config.tenantName,
209
209
  });
210
+ const profileWasInstalled = {
211
+ claude: fs.existsSync(path.join(
212
+ actionPaths.claude.userData,
213
+ "configLibrary",
214
+ `${CLAUDE_CONFIG_ID}.json`,
215
+ )),
216
+ chatgpt: fs.existsSync(path.join(actionPaths.chatgpt.codexHome, "config.toml")),
217
+ };
210
218
  const vendorPaths = {};
211
219
  for (const target of targets) {
212
220
  const isClaude = target === "claude";
213
221
  const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
214
222
  const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
215
223
  let binary = find(io.environment);
216
- if ((action === "install" || action === "update") && !flags["skip-vendor"]) {
224
+ const shouldEnsureVendor = !flags["skip-vendor"] && (
225
+ action === "install"
226
+ || action === "update"
227
+ || (action === "open" && !binary)
228
+ );
229
+ if (shouldEnsureVendor) {
217
230
  const vendor = ensure({ update: action === "update" }, { environment: io.environment });
218
231
  binary = vendor.binary;
219
232
  console.log(`${target}: vendor app ${vendor.action}`);
@@ -252,9 +265,12 @@ export async function cmdWindowsApps(argv, overrides = {}) {
252
265
  claudeUserData: userData,
253
266
  tenantName: config.tenantName,
254
267
  });
255
- if (action === "install" || action === "update" || action === "refresh") {
256
- const verb = action === "install" ? "Installed" : action === "update" ? "Updated" : "Refreshed";
257
- for (const target of targets) {
268
+ const configuredTargets = action === "open"
269
+ ? targets.filter((target) => !profileWasInstalled[target])
270
+ : targets;
271
+ if (action === "install" || action === "update" || action === "refresh" || configuredTargets.length > 0) {
272
+ const verb = action === "update" ? "Updated" : action === "refresh" ? "Refreshed" : "Installed";
273
+ for (const target of configuredTargets) {
258
274
  const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
259
275
  console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
260
276
  const { client, env, label } = appSkillTarget(target, tenantPaths);
@@ -358,43 +374,12 @@ export async function cmdApps(argv, overrides = {}) {
358
374
 
359
375
  const config = await selectedAppConfig(targets, flags.tenant || null);
360
376
  const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
361
- const legacyPaths = appPaths(os.homedir());
362
- for (const status of statuses) {
363
- if (!status.launcherInstalled && !fs.existsSync(legacyPaths[status.target].launcher)) {
364
- throw new Error(`${status.label} is not installed; run \`impel app install ${status.target}\``);
365
- }
366
- if (!status.vendorPath) throw new Error(`${status.label} vendor app is unavailable; reinstall the vendor app first`);
367
- }
368
- let catalog;
369
- try {
370
- catalog = await fetchGatewayModels(config);
371
- } catch (error) {
372
- throw new Error(
373
- `tenant model catalog is unavailable (${redactSecretText(error.message)}); the selected tenant app was not opened`,
374
- );
375
- }
376
- // Only stale bundles pay the clone + codesign rebuild; a bundle swap needs
377
- // its app closed, and quitting is fine here since we relaunch right after.
378
- const staleBundleTargets = statuses
379
- .filter((status) => !bundleIsCurrent(status))
380
- .map((status) => status.target);
381
- if (staleBundleTargets.length > 0) {
382
- await quitBlockingApps(staleBundleTargets, {
383
- tenantId: config.tenantId,
384
- tenantName: config.tenantName,
385
- });
386
- }
387
- installManagedAppFiles({
377
+ await provisionAndOpenManagedApps({
388
378
  config,
389
379
  targets,
390
- models: catalog.models,
380
+ statuses,
391
381
  homeDir: os.homedir(),
392
- vendorPaths: Object.fromEntries(statuses.map((status) => [status.target, status.vendorPath])),
393
- writeBundles: staleBundleTargets,
394
382
  });
395
- for (const status of appStatus(targets, os.homedir(), config.tenantId, config.tenantName)) {
396
- spawnSync("/usr/bin/open", ["-n", status.launcher], { stdio: "inherit" });
397
- }
398
383
  return;
399
384
  }
400
385
  if (action !== "install" && action !== "update") {
@@ -445,6 +430,99 @@ export async function cmdApps(argv, overrides = {}) {
445
430
  }
446
431
  }
447
432
 
433
+ /**
434
+ * Complete the slow `app open` path. A first open is intentionally equivalent
435
+ * to an install for the selected tenant: verify or install the pinned vendor,
436
+ * build the isolated bundle/profile, sync skills, and only then launch it.
437
+ * Existing current installs never reach this helper because fastOpenLaunchers
438
+ * handles them without network or rebuild work.
439
+ */
440
+ export async function provisionAndOpenManagedApps({
441
+ config,
442
+ targets,
443
+ statuses,
444
+ homeDir = os.homedir(),
445
+ }, overrides = {}) {
446
+ const io = {
447
+ fetchModels: fetchGatewayModels,
448
+ ensureVendor: ensureVendorApp,
449
+ bundleCurrent: bundleIsCurrent,
450
+ quitApps: quitBlockingApps,
451
+ installFiles: installManagedAppFiles,
452
+ syncSkills: syncSkillsSafe,
453
+ secureCodexHome: secureManagedCodexHome,
454
+ openLauncher: (launcher) => spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" }),
455
+ log: (message) => console.log(message),
456
+ ...overrides,
457
+ };
458
+
459
+ // Resolve the live tenant catalog before installing or changing anything.
460
+ // This keeps unavailable/unauthorized tenants fail-closed on first use.
461
+ let catalog;
462
+ try {
463
+ catalog = await io.fetchModels(config);
464
+ } catch (error) {
465
+ throw new Error(
466
+ `tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); the selected tenant app was not opened`,
467
+ );
468
+ }
469
+
470
+ const vendorPaths = {};
471
+ for (const status of statuses) {
472
+ let vendorPath = status.vendorPath;
473
+ if (!vendorPath) {
474
+ const result = io.ensureVendor(status.target, { homeDir });
475
+ vendorPath = result.path;
476
+ io.log(`${status.target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
477
+ if (!vendorPath) {
478
+ throw new Error(`${status.target} verified vendor app is unavailable; retry the pinned download`);
479
+ }
480
+ }
481
+ vendorPaths[status.target] = vendorPath;
482
+ }
483
+
484
+ // Only stale or absent bundles pay the clone + codesign rebuild. The model
485
+ // catalog and vendor checks above finish before any running apps are closed.
486
+ const staleBundleTargets = statuses
487
+ .filter((status) => !io.bundleCurrent(status))
488
+ .map((status) => status.target);
489
+ if (staleBundleTargets.length > 0) {
490
+ await io.quitApps(staleBundleTargets, {
491
+ tenantId: config.tenantId,
492
+ tenantName: config.tenantName,
493
+ });
494
+ }
495
+
496
+ const installed = io.installFiles({
497
+ config,
498
+ targets,
499
+ models: catalog.models,
500
+ homeDir,
501
+ vendorPaths,
502
+ writeBundles: staleBundleTargets,
503
+ });
504
+ const newlyInstalled = new Set(
505
+ statuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
506
+ );
507
+ for (const item of installed) {
508
+ if (newlyInstalled.has(item.target)) {
509
+ io.log(`Installed and configured ${item.launcher} for tenant ${config.tenantId}.`);
510
+ }
511
+ }
512
+
513
+ // Match explicit install setup without penalizing repeat opens: this helper
514
+ // only runs when the zero-network fast path has detected drift or first use.
515
+ const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
516
+ const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
517
+ for (const item of installed) {
518
+ const { client, env, label } = appSkillTarget(item.target, paths);
519
+ await io.syncSkills({ client, gatewayUrl, env, label });
520
+ if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
521
+ }
522
+ for (const item of installed) await io.openLauncher(item.launcher);
523
+ return installed;
524
+ }
525
+
448
526
  const REFRESH_TTL_MS = 6 * 60 * 60 * 1000;
449
527
 
450
528
  /**
@@ -16,8 +16,9 @@ import {
16
16
  const AGENT_PAT_LABEL_PREFIX = "Agent · ";
17
17
  const MAX_PAT_LABEL_LENGTH = 120;
18
18
  const MAX_PAT_TTL_DAYS = 365;
19
- const PAT_CREATE_PATH = "/api/cli/pats";
19
+ const PAT_COLLECTION_PATH = "/api/cli/pats";
20
20
  const PAT_TOKEN_RE = /^impel_pat_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u;
21
+ const PAT_TOKEN_INPUT_RE = /^impel_pat_([A-Za-z0-9_-]{8,128})\.[A-Za-z0-9_-]+$/u;
21
22
  const PAT_TOKEN_ID_RE = /^[A-Za-z0-9_-]{8,128}$/u;
22
23
  const AGENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,111}$/u;
23
24
  const CONTROL_CHARACTER_RE = /[\u0000-\u001F\u007F-\u009F]/u;
@@ -33,13 +34,28 @@ const FLAG_NAMES = new Set([
33
34
  "scopes",
34
35
  "tenant",
35
36
  "ttl-days",
37
+ "yes",
36
38
  ]);
37
39
 
38
- const HELP = `impel pat - mint a personal access token through Impel
40
+ const CREATE_FLAG_NAMES = new Set([
41
+ "agent",
42
+ "app",
43
+ "help",
44
+ "json",
45
+ "label",
46
+ "org",
47
+ "scopes",
48
+ "tenant",
49
+ "ttl-days",
50
+ ]);
51
+ const REVOKE_FLAG_NAMES = new Set(["app", "help", "json", "yes"]);
52
+
53
+ const HELP = `impel pat - manage personal access tokens through Impel
39
54
 
40
55
  Usage:
41
56
  impel pat create --label <label> [options]
42
57
  impel pat create --agent <agent-id> [options]
58
+ impel pat revoke <token-id> --yes [options]
43
59
 
44
60
  Aliases:
45
61
  impel pats ...
@@ -52,11 +68,13 @@ Options:
52
68
  Defaults are chosen by the control plane for your access tier.
53
69
  --ttl-days <days> Expiry from 1 to ${MAX_PAT_TTL_DAYS} days. Default: 90.
54
70
  --app <url> Override the app/control-plane URL for this command.
55
- --json Print the created PAT and metadata as JSON.
71
+ --json Print the result as JSON.
72
+ --yes Confirm irreversible PAT revocation.
56
73
 
57
74
  The stored PAT authenticates this request. The control plane verifies the owner,
58
75
  organization membership, requested scopes, and agent access before identity mints
59
- the new secret. The new PAT is shown once and is not stored automatically.
76
+ the new secret or revokes the selected token. New PATs are shown once and are not
77
+ stored automatically. Revoke accepts a token id or a full PAT and never echoes a secret.
60
78
  `;
61
79
 
62
80
  class PatCommandError extends Error {}
@@ -76,9 +94,17 @@ function flagSpec() {
76
94
  scopes: { type: "string" },
77
95
  tenant: { type: "string" },
78
96
  "ttl-days": { type: "string" },
97
+ yes: { type: "boolean" },
79
98
  };
80
99
  }
81
100
 
101
+ function rejectUnsupportedFlags(flags, allowed, action) {
102
+ const unsupported = Object.keys(flags).find((name) => !allowed.has(name));
103
+ if (unsupported) {
104
+ fail(`impel pat ${action}: option --${redactSecretText(unsupported)} is not supported.`);
105
+ }
106
+ }
107
+
82
108
  function requireConfig(flags) {
83
109
  const config = loadConfig();
84
110
  if (!config?.pat) {
@@ -135,6 +161,18 @@ function requestedTtlDays(value) {
135
161
  return days;
136
162
  }
137
163
 
164
+ function requestedTokenId(value) {
165
+ if (value === undefined) {
166
+ fail("impel pat revoke: missing token id.");
167
+ }
168
+ const requested = String(value).trim();
169
+ const tokenId = PAT_TOKEN_INPUT_RE.exec(requested)?.[1] || requested;
170
+ if (!PAT_TOKEN_ID_RE.test(tokenId)) {
171
+ fail("impel pat revoke: expected a valid token id or full Impel PAT.");
172
+ }
173
+ return tokenId;
174
+ }
175
+
138
176
  function requestedSubject(flags) {
139
177
  const agentId = flags.agent === undefined ? null : String(flags.agent).trim();
140
178
  const personalLabel = flags.label === undefined ? null : String(flags.label).trim();
@@ -215,19 +253,19 @@ function normalizeCreatedPat(payload, expected) {
215
253
  };
216
254
  }
217
255
 
218
- async function createPat({ appUrl, currentPat, body }) {
256
+ async function requestControlPlane({ appUrl, currentPat, path, method, body, feature }) {
219
257
  const controller = new AbortController();
220
258
  const timeout = setTimeout(() => controller.abort(), 10_000);
221
259
  let response;
222
260
  try {
223
- response = await fetch(new URL(PAT_CREATE_PATH, appUrl), {
224
- method: "POST",
261
+ response = await fetch(new URL(path, appUrl), {
262
+ method,
225
263
  headers: {
226
264
  accept: "application/json",
227
265
  authorization: `Bearer ${currentPat}`,
228
- "content-type": "application/json",
266
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
229
267
  },
230
- body: JSON.stringify(body),
268
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
231
269
  signal: controller.signal,
232
270
  });
233
271
  } catch (error) {
@@ -245,13 +283,24 @@ async function createPat({ appUrl, currentPat, body }) {
245
283
  payload = null;
246
284
  }
247
285
  if (!response.ok) {
248
- if (response.status === 404) {
249
- fail(`impel pat: PAT minting is not available on ${appUrl}; update the Impel control plane and try again.`);
286
+ if (response.status === 404 && !payload?.error) {
287
+ fail(`impel pat: PAT ${feature} is not available on ${appUrl}; update the Impel control plane and try again.`);
250
288
  }
251
289
  const message = redactSecretText(payload?.error || `${response.status} ${response.statusText}`.trim());
252
290
  fail(`impel pat: ${message}`);
253
291
  }
292
+ return payload;
293
+ }
254
294
 
295
+ async function createPat({ appUrl, currentPat, body }) {
296
+ const payload = await requestControlPlane({
297
+ appUrl,
298
+ currentPat,
299
+ path: PAT_COLLECTION_PATH,
300
+ method: "POST",
301
+ body,
302
+ feature: "minting",
303
+ });
255
304
  const created = normalizeCreatedPat(payload, body);
256
305
  if (!created) {
257
306
  fail("impel pat: the control plane returned an invalid PAT creation response; the secret was not displayed.");
@@ -259,6 +308,17 @@ async function createPat({ appUrl, currentPat, body }) {
259
308
  return created;
260
309
  }
261
310
 
311
+ async function revokePat({ appUrl, currentPat, tokenId }) {
312
+ await requestControlPlane({
313
+ appUrl,
314
+ currentPat,
315
+ path: `${PAT_COLLECTION_PATH}/${encodeURIComponent(tokenId)}`,
316
+ method: "DELETE",
317
+ feature: "revocation",
318
+ });
319
+ return { revoked: true, tokenId };
320
+ }
321
+
262
322
  function printCreatedPat(created, subject, json) {
263
323
  if (json) {
264
324
  console.log(JSON.stringify({
@@ -274,6 +334,7 @@ function printCreatedPat(created, subject, json) {
274
334
  } else {
275
335
  console.log(`Created personal PAT ${JSON.stringify(created.label)} in ${created.orgId}.`);
276
336
  }
337
+ console.log(`Token ID: ${created.tokenId}`);
277
338
  console.log(`Scopes: ${created.scopes.join(", ") || "none"}`);
278
339
  console.log(`Expires: ${created.expiresAt === null ? "never" : new Date(created.expiresAt).toISOString()}`);
279
340
  console.log("");
@@ -281,15 +342,23 @@ function printCreatedPat(created, subject, json) {
281
342
  console.log(created.token);
282
343
  }
283
344
 
345
+ function printRevokedPat(revoked, json) {
346
+ if (json) {
347
+ console.log(JSON.stringify(revoked, null, 2));
348
+ return;
349
+ }
350
+ console.log(`Revoked PAT ${revoked.tokenId}.`);
351
+ }
352
+
284
353
  export async function cmdPat(argv) {
285
354
  const [action, ...rest] = argv;
286
355
  if (!action || action === "help" || action === "--help" || action === "-h") {
287
356
  console.log(HELP);
288
357
  return;
289
358
  }
290
- if (action !== "create" && action !== "mint") {
359
+ if (action !== "create" && action !== "mint" && action !== "revoke") {
291
360
  console.error(`impel pat: unknown subcommand "${redactSecretText(action)}".`);
292
- console.error(" Use `impel pat create --label <label>` or `impel pat create --agent <agent-id>`.");
361
+ console.error(" Use `impel pat create --label <label>`, `impel pat create --agent <agent-id>`, or `impel pat revoke <token-id> --yes`.");
293
362
  process.exitCode = 1;
294
363
  return;
295
364
  }
@@ -302,6 +371,23 @@ export async function cmdPat(argv) {
302
371
  }
303
372
  const unknownFlag = Object.keys(flags).find((name) => !FLAG_NAMES.has(name));
304
373
  if (unknownFlag) fail(`impel pat: unknown option --${redactSecretText(unknownFlag)}.`);
374
+
375
+ if (action === "revoke") {
376
+ rejectUnsupportedFlags(flags, REVOKE_FLAG_NAMES, "revoke");
377
+ const tokenId = requestedTokenId(positionals[0]);
378
+ if (positionals.length > 1) {
379
+ fail(`impel pat revoke: unexpected argument "${redactSecretText(positionals[1])}".`);
380
+ }
381
+ if (flags.yes !== true) {
382
+ fail("impel pat revoke: refusing to revoke without --yes.");
383
+ }
384
+ const { appUrl, config } = requireConfig(flags);
385
+ const revoked = await revokePat({ appUrl, currentPat: config.pat, tokenId });
386
+ printRevokedPat(revoked, flags.json === true);
387
+ return;
388
+ }
389
+
390
+ rejectUnsupportedFlags(flags, CREATE_FLAG_NAMES, "create");
305
391
  if (positionals.length > 0) {
306
392
  fail(`impel pat create: unexpected argument "${redactSecretText(positionals[0])}".`);
307
393
  }