impel-cli 0.7.3 → 0.9.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.
@@ -15,7 +15,13 @@ import {
15
15
  refreshUpdateCache,
16
16
  spawnDetachedAppRefresh,
17
17
  } from "../updates.js";
18
- import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
18
+ import {
19
+ assertProviderScopes,
20
+ ensureTenantSelection,
21
+ fetchTenants,
22
+ normalizeTenantId,
23
+ tenantCredential,
24
+ } from "../tenants.js";
19
25
  import {
20
26
  CURRENT_CONFIG_VERSION,
21
27
  CLAUDE_CONFIG_ID,
@@ -111,6 +117,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
111
117
  "skip-vendor": { type: "boolean" },
112
118
  "keep-data": { type: "boolean" },
113
119
  "stale-only": { type: "boolean" },
120
+ tenant: { type: "string" },
114
121
  });
115
122
  const io = {
116
123
  environment: process.env,
@@ -140,13 +147,20 @@ export async function cmdWindowsApps(argv, overrides = {}) {
140
147
  if (overrides.launchApp) io.launchClaudeApp = overrides.launchApp;
141
148
  if (overrides.userData) io.claudeUserData = overrides.userData;
142
149
  const stored = loadConfig();
143
- const storedUserData = io.claudeUserData(io.environment, stored?.tenantId || null);
144
- const paths = appPaths(io.homeDir, stored?.tenantId || null, { claudeUserData: storedUserData });
150
+ const localTenantId = flags.tenant
151
+ ? normalizeTenantId(flags.tenant)
152
+ : stored?.tenantId || null;
153
+ const localTenantName = localTenantId === stored?.tenantId ? stored?.tenantName : null;
154
+ const storedUserData = io.claudeUserData(io.environment, localTenantId);
155
+ const paths = appPaths(io.homeDir, localTenantId, {
156
+ claudeUserData: storedUserData,
157
+ tenantName: localTenantName,
158
+ });
145
159
  const claudeConfigPath = path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`);
146
160
  const chatgptConfigPath = path.join(paths.chatgpt.codexHome, "config.toml");
147
161
 
148
162
  if (action === "status") {
149
- console.log(`Tenant: ${stored?.tenantId || "not selected"}`);
163
+ console.log(`Tenant: ${localTenantId || "not selected"}`);
150
164
  if (targets.includes("claude")) {
151
165
  console.log("Impel Claude:");
152
166
  console.log(` profile: ${fs.existsSync(claudeConfigPath) ? paths.claude.userData : "not installed"}`);
@@ -167,7 +181,8 @@ export async function cmdWindowsApps(argv, overrides = {}) {
167
181
  io.removeApps(targets, {
168
182
  homeDir: io.homeDir,
169
183
  keepData: Boolean(flags["keep-data"]),
170
- tenantId: stored?.tenantId || null,
184
+ tenantId: localTenantId,
185
+ tenantName: localTenantName,
171
186
  claudeUserData: storedUserData,
172
187
  });
173
188
  if (targets.includes("chatgpt")) io.removeManagedChatGPTApp(paths.root);
@@ -186,9 +201,12 @@ export async function cmdWindowsApps(argv, overrides = {}) {
186
201
 
187
202
  try {
188
203
  if (action === "open") maybePrintUpdateNotice();
189
- const config = await io.selectedConfig(targets);
204
+ const config = await io.selectedConfig(targets, flags.tenant || null);
190
205
  const actionUserData = io.claudeUserData(io.environment, config.tenantId);
191
- const actionPaths = appPaths(io.homeDir, config.tenantId, { claudeUserData: actionUserData });
206
+ const actionPaths = appPaths(io.homeDir, config.tenantId, {
207
+ claudeUserData: actionUserData,
208
+ tenantName: config.tenantName,
209
+ });
192
210
  const vendorPaths = {};
193
211
  for (const target of targets) {
194
212
  const isClaude = target === "claude";
@@ -230,7 +248,10 @@ export async function cmdWindowsApps(argv, overrides = {}) {
230
248
  }
231
249
 
232
250
  const { userData } = configureWindowsApps(config, targets, vendorPaths, catalog, io);
233
- const tenantPaths = appPaths(io.homeDir, config.tenantId, { claudeUserData: userData });
251
+ const tenantPaths = appPaths(io.homeDir, config.tenantId, {
252
+ claudeUserData: userData,
253
+ tenantName: config.tenantName,
254
+ });
234
255
  if (action === "install" || action === "update" || action === "refresh") {
235
256
  const verb = action === "install" ? "Installed" : action === "update" ? "Updated" : "Refreshed";
236
257
  for (const target of targets) {
@@ -282,17 +303,25 @@ export async function cmdApps(argv, overrides = {}) {
282
303
  "skip-vendor": { type: "boolean" },
283
304
  "keep-data": { type: "boolean" },
284
305
  "stale-only": { type: "boolean" },
306
+ tenant: { type: "string" },
285
307
  });
286
308
 
287
- if (action === "status") return printStatus(targets);
309
+ if (action === "status") return printStatus(targets, flags.tenant || null);
288
310
  if (action === "refresh") {
289
- return refreshApps(targets, { staleOnly: Boolean(flags["stale-only"]) });
311
+ return refreshApps(targets, {
312
+ staleOnly: Boolean(flags["stale-only"]),
313
+ tenantId: flags.tenant || null,
314
+ });
290
315
  }
291
316
  if (action === "uninstall") {
292
317
  const config = loadConfig();
318
+ const tenantId = flags.tenant
319
+ ? normalizeTenantId(flags.tenant)
320
+ : config?.tenantId || null;
293
321
  removeManagedApps(targets, {
294
322
  keepData: Boolean(flags["keep-data"]),
295
- tenantId: config?.tenantId || null,
323
+ tenantId,
324
+ tenantName: tenantId === config?.tenantId ? config?.tenantName : null,
296
325
  });
297
326
  for (const target of targets) console.log(`Removed Impel ${target} app${flags["keep-data"] ? " (kept isolated data)" : " and isolated data"}.`);
298
327
  return;
@@ -304,19 +333,36 @@ export async function cmdApps(argv, overrides = {}) {
304
333
  // credentials, there is nothing to fetch or rebuild — launch immediately
305
334
  // and let a detached stale-only refresh keep configs/catalog/skills
306
335
  // converging in the background.
307
- const fastLaunchers = fastOpenLaunchers(targets);
336
+ const stored = loadConfig();
337
+ const tenantId = flags.tenant
338
+ ? normalizeTenantId(flags.tenant)
339
+ : stored?.tenantId || null;
340
+ const manifest = tenantId ? readTenantManifest(os.homedir(), tenantId) : null;
341
+ const openConfig = stored && tenantId
342
+ ? {
343
+ ...stored,
344
+ tenantId,
345
+ tenantName: tenantId === stored.tenantId
346
+ ? manifest?.tenantName || stored.tenantName || tenantId
347
+ : manifest?.tenantName || tenantId,
348
+ }
349
+ : stored;
350
+ const fastLaunchers = fastOpenLaunchers(targets, { config: openConfig });
308
351
  if (fastLaunchers) {
309
- spawnDetachedAppRefresh();
352
+ spawnDetachedAppRefresh(tenantId);
310
353
  for (const launcher of fastLaunchers) {
311
354
  spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" });
312
355
  }
313
356
  return;
314
357
  }
315
358
 
316
- const config = await selectedAppConfig(targets);
317
- const statuses = appStatus(targets, os.homedir(), config.tenantId);
359
+ const config = await selectedAppConfig(targets, flags.tenant || null);
360
+ const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
361
+ const legacyPaths = appPaths(os.homedir());
318
362
  for (const status of statuses) {
319
- if (!status.launcherInstalled) throw new Error(`${status.label} is not installed; run \`impel app install ${status.target}\``);
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
+ }
320
366
  if (!status.vendorPath) throw new Error(`${status.label} vendor app is unavailable; reinstall the vendor app first`);
321
367
  }
322
368
  let catalog;
@@ -332,7 +378,12 @@ export async function cmdApps(argv, overrides = {}) {
332
378
  const staleBundleTargets = statuses
333
379
  .filter((status) => !bundleIsCurrent(status))
334
380
  .map((status) => status.target);
335
- if (staleBundleTargets.length > 0) await quitBlockingApps(staleBundleTargets);
381
+ if (staleBundleTargets.length > 0) {
382
+ await quitBlockingApps(staleBundleTargets, {
383
+ tenantId: config.tenantId,
384
+ tenantName: config.tenantName,
385
+ });
386
+ }
336
387
  installManagedAppFiles({
337
388
  config,
338
389
  targets,
@@ -341,7 +392,7 @@ export async function cmdApps(argv, overrides = {}) {
341
392
  vendorPaths: Object.fromEntries(statuses.map((status) => [status.target, status.vendorPath])),
342
393
  writeBundles: staleBundleTargets,
343
394
  });
344
- for (const status of appStatus(targets, os.homedir(), config.tenantId)) {
395
+ for (const status of appStatus(targets, os.homedir(), config.tenantId, config.tenantName)) {
345
396
  spawnSync("/usr/bin/open", ["-n", status.launcher], { stdio: "inherit" });
346
397
  }
347
398
  return;
@@ -350,11 +401,14 @@ export async function cmdApps(argv, overrides = {}) {
350
401
  throw new Error(`unknown app action "${action}"; use install, update, refresh, status, open, or uninstall`);
351
402
  }
352
403
 
353
- const config = await selectedAppConfig(targets);
404
+ const config = await selectedAppConfig(targets, flags.tenant || null);
354
405
  console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
355
406
 
356
407
  // A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
357
- await quitBlockingApps(targets);
408
+ await quitBlockingApps(targets, {
409
+ tenantId: config.tenantId,
410
+ tenantName: config.tenantName,
411
+ });
358
412
 
359
413
  const vendorPaths = {};
360
414
  if (!flags["skip-vendor"]) {
@@ -382,7 +436,7 @@ export async function cmdApps(argv, overrides = {}) {
382
436
 
383
437
  // Best-effort: sync the Bifrost shared skills into each installed isolated app
384
438
  // profile. Never fails the install/update.
385
- const paths = appPaths(os.homedir(), config.tenantId);
439
+ const paths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
386
440
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
387
441
  for (const item of installed) {
388
442
  const { client, env, label } = appSkillTarget(item.target, paths);
@@ -415,7 +469,12 @@ export function fastOpenLaunchers(targets, { homeDir = os.homedir(), config = lo
415
469
  return null;
416
470
  }
417
471
  const launchers = [];
418
- for (const status of statuses ?? appStatus(targets, homeDir, config.tenantId)) {
472
+ for (const status of statuses ?? appStatus(
473
+ targets,
474
+ homeDir,
475
+ config.tenantId,
476
+ config.tenantName || manifest.tenantName,
477
+ )) {
419
478
  if (!bundleIsCurrent(status)) return null;
420
479
  if (!fs.existsSync(status.configPath)) return null;
421
480
  if (status.target === "claude" && !claudeConfigIsCurrent(status.configPath, config, gatewayUrl)) {
@@ -445,19 +504,29 @@ function claudeConfigIsCurrent(configPath, config, gatewayUrl) {
445
504
  // run (config writes only, never a bundle swap, never quits anything) and
446
505
  // silent on any failure: it is invoked detached from the apps' token helper,
447
506
  // so there is no user to talk to and nothing may leak to stdout.
448
- async function refreshApps(targets, { staleOnly = false } = {}) {
507
+ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {}) {
449
508
  const stored = loadConfig();
450
509
  if (!stored?.pat) return;
451
- const paths = appPaths(os.homedir(), stored.tenantId || null);
510
+ const requestedTenantId = tenantId
511
+ ? normalizeTenantId(tenantId)
512
+ : stored.tenantId || null;
513
+ const manifest = requestedTenantId
514
+ ? readTenantManifest(os.homedir(), requestedTenantId)
515
+ : null;
516
+ const paths = appPaths(os.homedir(), requestedTenantId, {
517
+ tenantName: requestedTenantId === stored.tenantId
518
+ ? stored.tenantName || manifest?.tenantName
519
+ : manifest?.tenantName,
520
+ });
452
521
  if (staleOnly && manifestIsFresh(paths, stored)) return;
453
522
 
454
523
  let config;
455
524
  try {
456
- config = await selectedAppConfig(targets);
525
+ config = await selectedAppConfig(targets, tenantId ? requestedTenantId : null);
457
526
  } catch {
458
527
  return;
459
528
  }
460
- const statuses = appStatus(targets, os.homedir(), config.tenantId);
529
+ const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
461
530
  const installedTargets = statuses.filter((status) => status.launcherInstalled);
462
531
  if (installedTargets.length === 0) return;
463
532
 
@@ -476,7 +545,7 @@ async function refreshApps(targets, { staleOnly = false } = {}) {
476
545
  writeBundles: false,
477
546
  });
478
547
 
479
- const tenantPaths = appPaths(os.homedir(), config.tenantId);
548
+ const tenantPaths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
480
549
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
481
550
  for (const status of installedTargets) {
482
551
  const { client, env, label } = appSkillTarget(status.target, tenantPaths);
@@ -505,10 +574,28 @@ function manifestIsFresh(paths, config, now = Date.now()) {
505
574
  }
506
575
  }
507
576
 
508
- async function selectedAppConfig(targets) {
577
+ async function selectedAppConfig(targets, tenantOverride = null) {
509
578
  const config = loadConfig();
510
579
  if (!config?.pat || !config?.gatewayUrl) throw new Error("run `impel auth` before using Impel apps");
511
- const selected = await ensureTenantSelection(config, { refresh: true });
580
+ let selected;
581
+ if (tenantOverride) {
582
+ const requested = normalizeTenantId(tenantOverride);
583
+ const listing = await fetchTenants(config);
584
+ const tenant = listing.tenants.find((candidate) => (
585
+ candidate.id === requested || candidate.slug === requested
586
+ ));
587
+ if (!tenant) throw new Error(`tenant "${requested}" is not available to this user`);
588
+ selected = {
589
+ config,
590
+ tenantId: tenant.id,
591
+ tenantName: tenant.name,
592
+ tenant,
593
+ productAccess: listing.productAccess,
594
+ scopes: listing.scopes,
595
+ };
596
+ } else {
597
+ selected = await ensureTenantSelection(config, { refresh: true });
598
+ }
512
599
  if (!selected.productAccess) {
513
600
  throw new Error("the control plane did not return a live product access entitlement; retry after it is upgraded");
514
601
  }
@@ -520,16 +607,24 @@ async function selectedAppConfig(targets) {
520
607
  return {
521
608
  ...config,
522
609
  tenantId,
610
+ tenantName: selected.tenantName || selected.tenant?.name || tenantId,
523
611
  productAccess: selected.productAccess,
524
612
  scopes: selected.scopes,
525
613
  pat: tenantCredential(config.pat, tenantId),
526
614
  };
527
615
  }
528
616
 
529
- function printStatus(targets) {
617
+ function printStatus(targets, tenantOverride = null) {
530
618
  const config = loadConfig();
531
- if (config?.tenantId) console.log(`Tenant: ${config.tenantId}`);
532
- for (const status of appStatus(targets, os.homedir(), config?.tenantId || null)) {
619
+ const tenantId = tenantOverride
620
+ ? normalizeTenantId(tenantOverride)
621
+ : config?.tenantId || null;
622
+ const manifest = tenantId ? readTenantManifest(os.homedir(), tenantId) : null;
623
+ const tenantName = tenantId === config?.tenantId
624
+ ? manifest?.tenantName || config?.tenantName
625
+ : manifest?.tenantName;
626
+ if (tenantId) console.log(`Tenant: ${tenantId}${tenantName && tenantName !== tenantId ? ` (${tenantName})` : ""}`);
627
+ for (const status of appStatus(targets, os.homedir(), tenantId, tenantName)) {
533
628
  console.log(`${status.label}:`);
534
629
  console.log(` launcher: ${status.launcherInstalled ? status.launcher : "not installed"}`);
535
630
  console.log(` vendor: ${status.vendorPath || "not installed"}${status.vendorVersion ? ` (${status.vendorVersion})` : ""}`);
@@ -53,6 +53,8 @@ export async function cmdAuth(argv) {
53
53
  ? existing.tenantId
54
54
  : listing.defaultTenantId
55
55
  );
56
+ config.tenantName = listing.tenants.find((tenant) => tenant.id === config.tenantId)?.name
57
+ || config.tenantId;
56
58
  if (listing.productAccess) config.productAccess = listing.productAccess;
57
59
  else delete config.productAccess;
58
60
  if (listing.scopes) config.scopes = listing.scopes;
@@ -62,8 +64,10 @@ export async function cmdAuth(argv) {
62
64
  if (flags.tenant) throw error;
63
65
  if (existing?.tenantId && existing.pat === pat) {
64
66
  config.tenantId = existing.tenantId;
67
+ config.tenantName = existing.tenantName || existing.tenantId;
65
68
  } else {
66
69
  delete config.tenantId;
70
+ delete config.tenantName;
67
71
  delete config.productAccess;
68
72
  delete config.scopes;
69
73
  }
@@ -0,0 +1,331 @@
1
+ import { parseFlags } from "../args.js";
2
+ import {
3
+ loadConfig,
4
+ normalizeGatewayUrl,
5
+ redactSecretText,
6
+ resolveDefaultAppUrl,
7
+ } from "../config.js";
8
+ import {
9
+ fetchTenants,
10
+ normalizeTenantId,
11
+ PAT_SCOPE_CLAUDE,
12
+ PAT_SCOPE_CODEX,
13
+ PAT_SCOPE_TASKS,
14
+ } from "../tenants.js";
15
+
16
+ const AGENT_PAT_LABEL_PREFIX = "Agent · ";
17
+ const MAX_PAT_LABEL_LENGTH = 120;
18
+ const MAX_PAT_TTL_DAYS = 365;
19
+ const PAT_CREATE_PATH = "/api/cli/pats";
20
+ const PAT_TOKEN_RE = /^impel_pat_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u;
21
+ const PAT_TOKEN_ID_RE = /^[A-Za-z0-9_-]{8,128}$/u;
22
+ const AGENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,111}$/u;
23
+ const CONTROL_CHARACTER_RE = /[\u0000-\u001F\u007F-\u009F]/u;
24
+ const PAT_SCOPES = [PAT_SCOPE_CLAUDE, PAT_SCOPE_CODEX, PAT_SCOPE_TASKS];
25
+ const PAT_SCOPE_SET = new Set(PAT_SCOPES);
26
+ const FLAG_NAMES = new Set([
27
+ "agent",
28
+ "app",
29
+ "help",
30
+ "json",
31
+ "label",
32
+ "org",
33
+ "scopes",
34
+ "tenant",
35
+ "ttl-days",
36
+ ]);
37
+
38
+ const HELP = `impel pat - mint a personal access token through Impel
39
+
40
+ Usage:
41
+ impel pat create --label <label> [options]
42
+ impel pat create --agent <agent-id> [options]
43
+
44
+ Aliases:
45
+ impel pats ...
46
+ impel pat mint ...
47
+
48
+ Options:
49
+ --org <org> Target org. Defaults to the selected tenant.
50
+ --tenant <org> Alias for --org.
51
+ --scopes <csv> Requested scopes: ${PAT_SCOPES.join(", ")}.
52
+ Defaults are chosen by the control plane for your access tier.
53
+ --ttl-days <days> Expiry from 1 to ${MAX_PAT_TTL_DAYS} days. Default: 90.
54
+ --app <url> Override the app/control-plane URL for this command.
55
+ --json Print the created PAT and metadata as JSON.
56
+
57
+ The stored PAT authenticates this request. The control plane verifies the owner,
58
+ 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.
60
+ `;
61
+
62
+ class PatCommandError extends Error {}
63
+
64
+ function fail(message) {
65
+ throw new PatCommandError(message);
66
+ }
67
+
68
+ function flagSpec() {
69
+ return {
70
+ agent: { type: "string" },
71
+ app: { type: "string" },
72
+ help: { type: "boolean" },
73
+ json: { type: "boolean" },
74
+ label: { type: "string" },
75
+ org: { type: "string" },
76
+ scopes: { type: "string" },
77
+ tenant: { type: "string" },
78
+ "ttl-days": { type: "string" },
79
+ };
80
+ }
81
+
82
+ function requireConfig(flags) {
83
+ const config = loadConfig();
84
+ if (!config?.pat) {
85
+ fail("impel pat: not authenticated. Run `impel setup` (or `impel auth`) first.");
86
+ }
87
+
88
+ const appUrl = normalizeGatewayUrl(flags.app || config.appUrl || resolveDefaultAppUrl());
89
+ let parsed;
90
+ try {
91
+ parsed = new URL(appUrl);
92
+ } catch {
93
+ fail("impel pat: --app must be an HTTP or HTTPS URL.");
94
+ }
95
+ if (
96
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:")
97
+ || parsed.username
98
+ || parsed.password
99
+ || parsed.pathname !== "/"
100
+ || parsed.search
101
+ || parsed.hash
102
+ ) {
103
+ fail("impel pat: --app must be a bare HTTP or HTTPS origin without credentials, a path, query, or fragment.");
104
+ }
105
+ return { appUrl, config };
106
+ }
107
+
108
+ function requestedScopes(value) {
109
+ if (value === undefined) return undefined;
110
+ const requested = String(value)
111
+ .split(",")
112
+ .map((scope) => scope.trim())
113
+ .filter(Boolean);
114
+ if (requested.length === 0) {
115
+ fail("impel pat: --scopes must contain at least one scope.");
116
+ }
117
+ for (const scope of requested) {
118
+ if (!PAT_SCOPE_SET.has(scope)) {
119
+ fail(`impel pat: unknown scope "${redactSecretText(scope)}".`);
120
+ }
121
+ }
122
+ const requestedSet = new Set(requested);
123
+ return PAT_SCOPES.filter((scope) => requestedSet.has(scope));
124
+ }
125
+
126
+ function requestedTtlDays(value) {
127
+ if (value === undefined) return undefined;
128
+ if (!/^\d+$/u.test(String(value))) {
129
+ fail(`impel pat: --ttl-days must be an integer from 1 to ${MAX_PAT_TTL_DAYS}.`);
130
+ }
131
+ const days = Number(value);
132
+ if (!Number.isSafeInteger(days) || days < 1 || days > MAX_PAT_TTL_DAYS) {
133
+ fail(`impel pat: --ttl-days must be an integer from 1 to ${MAX_PAT_TTL_DAYS}.`);
134
+ }
135
+ return days;
136
+ }
137
+
138
+ function requestedSubject(flags) {
139
+ const agentId = flags.agent === undefined ? null : String(flags.agent).trim();
140
+ const personalLabel = flags.label === undefined ? null : String(flags.label).trim();
141
+ if (agentId !== null && personalLabel !== null) {
142
+ fail("impel pat create: use either --label for a personal PAT or --agent for an agent PAT, not both.");
143
+ }
144
+ if (agentId !== null) {
145
+ if (!AGENT_ID_RE.test(agentId)) {
146
+ fail("impel pat create: --agent must be a valid agent id (letters, numbers, dots, dashes, or underscores).");
147
+ }
148
+ const label = `${AGENT_PAT_LABEL_PREFIX}${agentId}`;
149
+ if (label.length > MAX_PAT_LABEL_LENGTH) {
150
+ fail(`impel pat create: the generated agent label exceeds ${MAX_PAT_LABEL_LENGTH} characters.`);
151
+ }
152
+ return { agentId, kind: "agent", label };
153
+ }
154
+
155
+ if (!personalLabel) {
156
+ fail("impel pat create: --label is required for a personal PAT; use --agent <id> for an agent PAT.");
157
+ }
158
+ if (personalLabel.length > MAX_PAT_LABEL_LENGTH || CONTROL_CHARACTER_RE.test(personalLabel)) {
159
+ fail(`impel pat create: --label must be 1 to ${MAX_PAT_LABEL_LENGTH} characters without control characters.`);
160
+ }
161
+ return { agentId: null, kind: "personal", label: personalLabel };
162
+ }
163
+
164
+ async function requestedOrgId(flags, config, appUrl) {
165
+ if (flags.org !== undefined && flags.tenant !== undefined && flags.org !== flags.tenant) {
166
+ fail("impel pat: --org and --tenant must name the same org when both are passed.");
167
+ }
168
+ const requested = flags.org ?? flags.tenant;
169
+ try {
170
+ if (requested !== undefined) return normalizeTenantId(requested);
171
+ if (config.tenantId) return normalizeTenantId(config.tenantId);
172
+ const listing = await fetchTenants({ ...config, appUrl });
173
+ return listing.defaultTenantId;
174
+ } catch (error) {
175
+ fail(`impel pat: could not resolve the target org: ${redactSecretText(error?.message || error)}`);
176
+ }
177
+ }
178
+
179
+ function timestamp(value, { nullable = false } = {}) {
180
+ if (nullable && value === null) return null;
181
+ if (!Number.isSafeInteger(value) || Number.isNaN(new Date(value).getTime())) return undefined;
182
+ return value;
183
+ }
184
+
185
+ function normalizeCreatedPat(payload, expected) {
186
+ const value = payload?.pat && typeof payload.pat === "object" ? payload.pat : payload;
187
+ if (!value || typeof value !== "object") return null;
188
+ const scopes = Array.isArray(value.scopes)
189
+ && value.scopes.every((scope) => typeof scope === "string" && PAT_SCOPE_SET.has(scope))
190
+ ? [...new Set(value.scopes)]
191
+ : null;
192
+ const createdAt = timestamp(value.createdAt);
193
+ const expiresAt = timestamp(value.expiresAt, { nullable: true });
194
+ if (
195
+ typeof value.token !== "string"
196
+ || !PAT_TOKEN_RE.test(value.token)
197
+ || typeof value.tokenId !== "string"
198
+ || !PAT_TOKEN_ID_RE.test(value.tokenId)
199
+ || value.orgId !== expected.orgId
200
+ || value.label !== expected.label
201
+ || !scopes
202
+ || createdAt === undefined
203
+ || expiresAt === undefined
204
+ ) {
205
+ return null;
206
+ }
207
+ return {
208
+ token: value.token,
209
+ tokenId: value.tokenId,
210
+ orgId: value.orgId,
211
+ label: value.label,
212
+ scopes,
213
+ createdAt,
214
+ expiresAt,
215
+ };
216
+ }
217
+
218
+ async function createPat({ appUrl, currentPat, body }) {
219
+ const controller = new AbortController();
220
+ const timeout = setTimeout(() => controller.abort(), 10_000);
221
+ let response;
222
+ try {
223
+ response = await fetch(new URL(PAT_CREATE_PATH, appUrl), {
224
+ method: "POST",
225
+ headers: {
226
+ accept: "application/json",
227
+ authorization: `Bearer ${currentPat}`,
228
+ "content-type": "application/json",
229
+ },
230
+ body: JSON.stringify(body),
231
+ signal: controller.signal,
232
+ });
233
+ } catch (error) {
234
+ const message = error?.name === "AbortError" ? "request timed out" : error?.message || error;
235
+ fail(`impel pat: could not reach ${appUrl}: ${redactSecretText(message)}`);
236
+ } finally {
237
+ clearTimeout(timeout);
238
+ }
239
+
240
+ const text = await response.text();
241
+ let payload = null;
242
+ try {
243
+ payload = text ? JSON.parse(text) : null;
244
+ } catch {
245
+ payload = null;
246
+ }
247
+ 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.`);
250
+ }
251
+ const message = redactSecretText(payload?.error || `${response.status} ${response.statusText}`.trim());
252
+ fail(`impel pat: ${message}`);
253
+ }
254
+
255
+ const created = normalizeCreatedPat(payload, body);
256
+ if (!created) {
257
+ fail("impel pat: the control plane returned an invalid PAT creation response; the secret was not displayed.");
258
+ }
259
+ return created;
260
+ }
261
+
262
+ function printCreatedPat(created, subject, json) {
263
+ if (json) {
264
+ console.log(JSON.stringify({
265
+ kind: subject.kind,
266
+ ...(subject.agentId ? { agentId: subject.agentId } : {}),
267
+ ...created,
268
+ }, null, 2));
269
+ return;
270
+ }
271
+
272
+ if (subject.kind === "agent") {
273
+ console.log(`Created an agent PAT for ${subject.agentId} in ${created.orgId}.`);
274
+ } else {
275
+ console.log(`Created personal PAT ${JSON.stringify(created.label)} in ${created.orgId}.`);
276
+ }
277
+ console.log(`Scopes: ${created.scopes.join(", ") || "none"}`);
278
+ console.log(`Expires: ${created.expiresAt === null ? "never" : new Date(created.expiresAt).toISOString()}`);
279
+ console.log("");
280
+ console.log("Copy this token now. Impel cannot show the secret again:");
281
+ console.log(created.token);
282
+ }
283
+
284
+ export async function cmdPat(argv) {
285
+ const [action, ...rest] = argv;
286
+ if (!action || action === "help" || action === "--help" || action === "-h") {
287
+ console.log(HELP);
288
+ return;
289
+ }
290
+ if (action !== "create" && action !== "mint") {
291
+ console.error(`impel pat: unknown subcommand "${redactSecretText(action)}".`);
292
+ console.error(" Use `impel pat create --label <label>` or `impel pat create --agent <agent-id>`.");
293
+ process.exitCode = 1;
294
+ return;
295
+ }
296
+
297
+ const { flags, positionals } = parseFlags(rest, flagSpec());
298
+ try {
299
+ if (flags.help) {
300
+ console.log(HELP);
301
+ return;
302
+ }
303
+ const unknownFlag = Object.keys(flags).find((name) => !FLAG_NAMES.has(name));
304
+ if (unknownFlag) fail(`impel pat: unknown option --${redactSecretText(unknownFlag)}.`);
305
+ if (positionals.length > 0) {
306
+ fail(`impel pat create: unexpected argument "${redactSecretText(positionals[0])}".`);
307
+ }
308
+
309
+ const subject = requestedSubject(flags);
310
+ const scopes = requestedScopes(flags.scopes);
311
+ const ttlDays = requestedTtlDays(flags["ttl-days"]);
312
+ const { appUrl, config } = requireConfig(flags);
313
+ const orgId = await requestedOrgId(flags, config, appUrl);
314
+ const body = {
315
+ orgId,
316
+ label: subject.label,
317
+ ...(subject.agentId ? { agentId: subject.agentId } : {}),
318
+ ...(scopes ? { scopes } : {}),
319
+ ...(ttlDays ? { ttlDays } : {}),
320
+ };
321
+ const created = await createPat({ appUrl, currentPat: config.pat, body });
322
+ printCreatedPat(created, subject, flags.json === true);
323
+ } catch (error) {
324
+ if (error instanceof PatCommandError) {
325
+ console.error(error.message);
326
+ process.exitCode = 1;
327
+ return;
328
+ }
329
+ throw error;
330
+ }
331
+ }
@@ -214,6 +214,7 @@ export async function cmdSetup(argv, overrides = {}) {
214
214
  }
215
215
 
216
216
  config.tenantId = tenant.id;
217
+ config.tenantName = tenant.name;
217
218
  if (listing.productAccess) config.productAccess = listing.productAccess;
218
219
  else delete config.productAccess;
219
220
  if (listing.scopes) config.scopes = listing.scopes;
@@ -118,8 +118,12 @@ function anyAppInstalled(homeDir = os.homedir()) {
118
118
  || fs.existsSync(path.join(paths.chatgpt.codexHome, "config.toml"))
119
119
  );
120
120
  }
121
+ const legacyPaths = appPaths(homeDir);
121
122
  return (
122
- fs.existsSync(paths.claude.launcher) || fs.existsSync(paths.chatgpt.launcher)
123
+ fs.existsSync(paths.claude.launcher)
124
+ || fs.existsSync(paths.chatgpt.launcher)
125
+ || fs.existsSync(legacyPaths.claude.launcher)
126
+ || fs.existsSync(legacyPaths.chatgpt.launcher)
123
127
  );
124
128
  }
125
129