impel-cli 0.8.0 → 0.9.1

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
@@ -121,6 +121,10 @@ impel update [--check] [--skip-apps] Update EVERYTHING: reinstall the CL
121
121
  cascade to `app update all` and `skills sync all`
122
122
  impel auth [--pat <pat>] [--gateway <url>] [--app <url>]
123
123
  Store your Impel PAT + URLs
124
+ impel pat create --label <label> [options] Mint a personal PAT through the control plane
125
+ impel pat create --agent <agent-id> [options]
126
+ Mint a PAT associated with an org agent
127
+ impel pat revoke <token-id> --yes Irreversibly revoke one of your PATs
124
128
  impel token [--tenant <org>] Print a selected-tenant bearer
125
129
  impel mcp Run the local MCP bridge used by Claude/Codex
126
130
  impel claude [claude args...] Launch isolated Impel Claude Code
@@ -183,6 +187,49 @@ Stores your PAT, gateway base URL, and app/control-plane URL in `~/.config/impel
183
187
  impel auth --pat impel_pat_xxxxxxxxxxxx --gateway https://gateway.useimpel.com --app https://www.useimpel.com
184
188
  ```
185
189
 
190
+ ### `impel pat create ...` / `impel pat revoke ...`
191
+
192
+ Mints a new PAT through the app/control-plane API, authenticated by the PAT
193
+ already stored by `impel auth`. Identity remains the minting authority: the
194
+ control plane verifies the current owner, target-org membership, requested
195
+ scopes, product access, and (when applicable) agent access before it requests a
196
+ new secret. The gateway never mints or verifies PATs.
197
+
198
+ Use `--label` for a personal token or `--agent` for a token associated with an
199
+ org agent. Agent PATs are still personal credentials: they act as you, are
200
+ org-scoped, and use the same stable `Agent · <agent-id>` association as the agent
201
+ setup UI.
202
+
203
+ ```sh
204
+ impel pat create --label "Rahul's laptop"
205
+ impel pat create --agent support-agent --org acme
206
+ impel pat create --label ci-automation --scopes codex-gateway,tasks --ttl-days 30 --json
207
+ ```
208
+
209
+ `--org` defaults to the selected tenant (`--tenant` is an alias). `--scopes`
210
+ accepts `claude-code-gateway`, `codex-gateway`, and `tasks`; when omitted, the
211
+ control plane chooses the defaults allowed by your access tier. `--ttl-days`
212
+ accepts 1–365 and defaults to 90 days.
213
+
214
+ The new secret is returned once on stdout and is deliberately **not** written to
215
+ the config file. To rotate this CLI onto it, explicitly run
216
+ `impel auth --pat <new-pat>` after copying it. `--json` includes the raw secret,
217
+ so treat redirected output as a credential.
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
+
186
233
  ### `impel update`
187
234
 
188
235
  One command brings everything current, in dependency order: the CLI itself
@@ -625,7 +672,7 @@ successful tenant-specific rebuild.
625
672
 
626
673
  The official Claude and ChatGPT apps remain separate installations and keep
627
674
  their normal accounts, updater, profiles, and bundle identities. Each CLI
628
- release pins exact, tested vendor builds (v0.8.0 pins Claude `1.20186.9` and
675
+ release pins exact, tested vendor builds (v0.9.0 pins Claude `1.20186.9` and
629
676
  ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
630
677
  an existing official app only when its version exactly matches; otherwise it
631
678
  downloads the pinned signed archive into Impel's managed vendor cache under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
4
  import { cmdAuth } from "./commands/auth.js";
5
+ import { cmdPat } from "./commands/pat.js";
5
6
  import { cmdToken } from "./commands/token.js";
6
7
  import { cmdUse } from "./commands/use.js";
7
8
  import { cmdStatus } from "./commands/status.js";
@@ -34,6 +35,9 @@ Run:
34
35
 
35
36
  Manage:
36
37
  impel auth [--pat <pat>] Store your PAT + URLs (--gateway/--app <url>)
38
+ impel pat create --label <label> Mint a personal PAT (--org/--scopes/--ttl-days)
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
37
41
  impel tenant list|current|use <org> List or select the active organization
38
42
  impel tasks list|get|create|update|delete CRUD Impel tickets (aliases: task, ticket[s])
39
43
  impel token [--tenant <org>] Print the selected-tenant bearer
@@ -59,7 +63,7 @@ Native profile switching (advanced; setup never touches native tools):
59
63
 
60
64
  Env:
61
65
  IMPEL_GATEWAY_URL Default gateway URL when --gateway isn't passed.
62
- IMPEL_APP_URL Default app/control-plane URL for setup, auth, and tasks.
66
+ IMPEL_APP_URL Default app/control-plane URL for setup, auth, PATs, and tasks.
63
67
  IMPEL_SKIP_UPDATE_CHECK=1 Silence launch-time update notices.
64
68
 
65
69
  Config file:
@@ -101,6 +105,10 @@ export async function main(argv) {
101
105
  case "auth":
102
106
  return cmdAuth(rest);
103
107
 
108
+ case "pat":
109
+ case "pats":
110
+ return cmdPat(rest);
111
+
104
112
  case "token":
105
113
  return cmdToken(rest);
106
114
 
@@ -0,0 +1,417 @@
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_COLLECTION_PATH = "/api/cli/pats";
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;
22
+ const PAT_TOKEN_ID_RE = /^[A-Za-z0-9_-]{8,128}$/u;
23
+ const AGENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,111}$/u;
24
+ const CONTROL_CHARACTER_RE = /[\u0000-\u001F\u007F-\u009F]/u;
25
+ const PAT_SCOPES = [PAT_SCOPE_CLAUDE, PAT_SCOPE_CODEX, PAT_SCOPE_TASKS];
26
+ const PAT_SCOPE_SET = new Set(PAT_SCOPES);
27
+ const FLAG_NAMES = new Set([
28
+ "agent",
29
+ "app",
30
+ "help",
31
+ "json",
32
+ "label",
33
+ "org",
34
+ "scopes",
35
+ "tenant",
36
+ "ttl-days",
37
+ "yes",
38
+ ]);
39
+
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
54
+
55
+ Usage:
56
+ impel pat create --label <label> [options]
57
+ impel pat create --agent <agent-id> [options]
58
+ impel pat revoke <token-id> --yes [options]
59
+
60
+ Aliases:
61
+ impel pats ...
62
+ impel pat mint ...
63
+
64
+ Options:
65
+ --org <org> Target org. Defaults to the selected tenant.
66
+ --tenant <org> Alias for --org.
67
+ --scopes <csv> Requested scopes: ${PAT_SCOPES.join(", ")}.
68
+ Defaults are chosen by the control plane for your access tier.
69
+ --ttl-days <days> Expiry from 1 to ${MAX_PAT_TTL_DAYS} days. Default: 90.
70
+ --app <url> Override the app/control-plane URL for this command.
71
+ --json Print the result as JSON.
72
+ --yes Confirm irreversible PAT revocation.
73
+
74
+ The stored PAT authenticates this request. The control plane verifies the owner,
75
+ organization membership, requested scopes, and agent access before identity mints
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.
78
+ `;
79
+
80
+ class PatCommandError extends Error {}
81
+
82
+ function fail(message) {
83
+ throw new PatCommandError(message);
84
+ }
85
+
86
+ function flagSpec() {
87
+ return {
88
+ agent: { type: "string" },
89
+ app: { type: "string" },
90
+ help: { type: "boolean" },
91
+ json: { type: "boolean" },
92
+ label: { type: "string" },
93
+ org: { type: "string" },
94
+ scopes: { type: "string" },
95
+ tenant: { type: "string" },
96
+ "ttl-days": { type: "string" },
97
+ yes: { type: "boolean" },
98
+ };
99
+ }
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
+
108
+ function requireConfig(flags) {
109
+ const config = loadConfig();
110
+ if (!config?.pat) {
111
+ fail("impel pat: not authenticated. Run `impel setup` (or `impel auth`) first.");
112
+ }
113
+
114
+ const appUrl = normalizeGatewayUrl(flags.app || config.appUrl || resolveDefaultAppUrl());
115
+ let parsed;
116
+ try {
117
+ parsed = new URL(appUrl);
118
+ } catch {
119
+ fail("impel pat: --app must be an HTTP or HTTPS URL.");
120
+ }
121
+ if (
122
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:")
123
+ || parsed.username
124
+ || parsed.password
125
+ || parsed.pathname !== "/"
126
+ || parsed.search
127
+ || parsed.hash
128
+ ) {
129
+ fail("impel pat: --app must be a bare HTTP or HTTPS origin without credentials, a path, query, or fragment.");
130
+ }
131
+ return { appUrl, config };
132
+ }
133
+
134
+ function requestedScopes(value) {
135
+ if (value === undefined) return undefined;
136
+ const requested = String(value)
137
+ .split(",")
138
+ .map((scope) => scope.trim())
139
+ .filter(Boolean);
140
+ if (requested.length === 0) {
141
+ fail("impel pat: --scopes must contain at least one scope.");
142
+ }
143
+ for (const scope of requested) {
144
+ if (!PAT_SCOPE_SET.has(scope)) {
145
+ fail(`impel pat: unknown scope "${redactSecretText(scope)}".`);
146
+ }
147
+ }
148
+ const requestedSet = new Set(requested);
149
+ return PAT_SCOPES.filter((scope) => requestedSet.has(scope));
150
+ }
151
+
152
+ function requestedTtlDays(value) {
153
+ if (value === undefined) return undefined;
154
+ if (!/^\d+$/u.test(String(value))) {
155
+ fail(`impel pat: --ttl-days must be an integer from 1 to ${MAX_PAT_TTL_DAYS}.`);
156
+ }
157
+ const days = Number(value);
158
+ if (!Number.isSafeInteger(days) || days < 1 || days > MAX_PAT_TTL_DAYS) {
159
+ fail(`impel pat: --ttl-days must be an integer from 1 to ${MAX_PAT_TTL_DAYS}.`);
160
+ }
161
+ return days;
162
+ }
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
+
176
+ function requestedSubject(flags) {
177
+ const agentId = flags.agent === undefined ? null : String(flags.agent).trim();
178
+ const personalLabel = flags.label === undefined ? null : String(flags.label).trim();
179
+ if (agentId !== null && personalLabel !== null) {
180
+ fail("impel pat create: use either --label for a personal PAT or --agent for an agent PAT, not both.");
181
+ }
182
+ if (agentId !== null) {
183
+ if (!AGENT_ID_RE.test(agentId)) {
184
+ fail("impel pat create: --agent must be a valid agent id (letters, numbers, dots, dashes, or underscores).");
185
+ }
186
+ const label = `${AGENT_PAT_LABEL_PREFIX}${agentId}`;
187
+ if (label.length > MAX_PAT_LABEL_LENGTH) {
188
+ fail(`impel pat create: the generated agent label exceeds ${MAX_PAT_LABEL_LENGTH} characters.`);
189
+ }
190
+ return { agentId, kind: "agent", label };
191
+ }
192
+
193
+ if (!personalLabel) {
194
+ fail("impel pat create: --label is required for a personal PAT; use --agent <id> for an agent PAT.");
195
+ }
196
+ if (personalLabel.length > MAX_PAT_LABEL_LENGTH || CONTROL_CHARACTER_RE.test(personalLabel)) {
197
+ fail(`impel pat create: --label must be 1 to ${MAX_PAT_LABEL_LENGTH} characters without control characters.`);
198
+ }
199
+ return { agentId: null, kind: "personal", label: personalLabel };
200
+ }
201
+
202
+ async function requestedOrgId(flags, config, appUrl) {
203
+ if (flags.org !== undefined && flags.tenant !== undefined && flags.org !== flags.tenant) {
204
+ fail("impel pat: --org and --tenant must name the same org when both are passed.");
205
+ }
206
+ const requested = flags.org ?? flags.tenant;
207
+ try {
208
+ if (requested !== undefined) return normalizeTenantId(requested);
209
+ if (config.tenantId) return normalizeTenantId(config.tenantId);
210
+ const listing = await fetchTenants({ ...config, appUrl });
211
+ return listing.defaultTenantId;
212
+ } catch (error) {
213
+ fail(`impel pat: could not resolve the target org: ${redactSecretText(error?.message || error)}`);
214
+ }
215
+ }
216
+
217
+ function timestamp(value, { nullable = false } = {}) {
218
+ if (nullable && value === null) return null;
219
+ if (!Number.isSafeInteger(value) || Number.isNaN(new Date(value).getTime())) return undefined;
220
+ return value;
221
+ }
222
+
223
+ function normalizeCreatedPat(payload, expected) {
224
+ const value = payload?.pat && typeof payload.pat === "object" ? payload.pat : payload;
225
+ if (!value || typeof value !== "object") return null;
226
+ const scopes = Array.isArray(value.scopes)
227
+ && value.scopes.every((scope) => typeof scope === "string" && PAT_SCOPE_SET.has(scope))
228
+ ? [...new Set(value.scopes)]
229
+ : null;
230
+ const createdAt = timestamp(value.createdAt);
231
+ const expiresAt = timestamp(value.expiresAt, { nullable: true });
232
+ if (
233
+ typeof value.token !== "string"
234
+ || !PAT_TOKEN_RE.test(value.token)
235
+ || typeof value.tokenId !== "string"
236
+ || !PAT_TOKEN_ID_RE.test(value.tokenId)
237
+ || value.orgId !== expected.orgId
238
+ || value.label !== expected.label
239
+ || !scopes
240
+ || createdAt === undefined
241
+ || expiresAt === undefined
242
+ ) {
243
+ return null;
244
+ }
245
+ return {
246
+ token: value.token,
247
+ tokenId: value.tokenId,
248
+ orgId: value.orgId,
249
+ label: value.label,
250
+ scopes,
251
+ createdAt,
252
+ expiresAt,
253
+ };
254
+ }
255
+
256
+ async function requestControlPlane({ appUrl, currentPat, path, method, body, feature }) {
257
+ const controller = new AbortController();
258
+ const timeout = setTimeout(() => controller.abort(), 10_000);
259
+ let response;
260
+ try {
261
+ response = await fetch(new URL(path, appUrl), {
262
+ method,
263
+ headers: {
264
+ accept: "application/json",
265
+ authorization: `Bearer ${currentPat}`,
266
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
267
+ },
268
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
269
+ signal: controller.signal,
270
+ });
271
+ } catch (error) {
272
+ const message = error?.name === "AbortError" ? "request timed out" : error?.message || error;
273
+ fail(`impel pat: could not reach ${appUrl}: ${redactSecretText(message)}`);
274
+ } finally {
275
+ clearTimeout(timeout);
276
+ }
277
+
278
+ const text = await response.text();
279
+ let payload = null;
280
+ try {
281
+ payload = text ? JSON.parse(text) : null;
282
+ } catch {
283
+ payload = null;
284
+ }
285
+ if (!response.ok) {
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.`);
288
+ }
289
+ const message = redactSecretText(payload?.error || `${response.status} ${response.statusText}`.trim());
290
+ fail(`impel pat: ${message}`);
291
+ }
292
+ return payload;
293
+ }
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
+ });
304
+ const created = normalizeCreatedPat(payload, body);
305
+ if (!created) {
306
+ fail("impel pat: the control plane returned an invalid PAT creation response; the secret was not displayed.");
307
+ }
308
+ return created;
309
+ }
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
+
322
+ function printCreatedPat(created, subject, json) {
323
+ if (json) {
324
+ console.log(JSON.stringify({
325
+ kind: subject.kind,
326
+ ...(subject.agentId ? { agentId: subject.agentId } : {}),
327
+ ...created,
328
+ }, null, 2));
329
+ return;
330
+ }
331
+
332
+ if (subject.kind === "agent") {
333
+ console.log(`Created an agent PAT for ${subject.agentId} in ${created.orgId}.`);
334
+ } else {
335
+ console.log(`Created personal PAT ${JSON.stringify(created.label)} in ${created.orgId}.`);
336
+ }
337
+ console.log(`Token ID: ${created.tokenId}`);
338
+ console.log(`Scopes: ${created.scopes.join(", ") || "none"}`);
339
+ console.log(`Expires: ${created.expiresAt === null ? "never" : new Date(created.expiresAt).toISOString()}`);
340
+ console.log("");
341
+ console.log("Copy this token now. Impel cannot show the secret again:");
342
+ console.log(created.token);
343
+ }
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
+
353
+ export async function cmdPat(argv) {
354
+ const [action, ...rest] = argv;
355
+ if (!action || action === "help" || action === "--help" || action === "-h") {
356
+ console.log(HELP);
357
+ return;
358
+ }
359
+ if (action !== "create" && action !== "mint" && action !== "revoke") {
360
+ console.error(`impel pat: unknown subcommand "${redactSecretText(action)}".`);
361
+ console.error(" Use `impel pat create --label <label>`, `impel pat create --agent <agent-id>`, or `impel pat revoke <token-id> --yes`.");
362
+ process.exitCode = 1;
363
+ return;
364
+ }
365
+
366
+ const { flags, positionals } = parseFlags(rest, flagSpec());
367
+ try {
368
+ if (flags.help) {
369
+ console.log(HELP);
370
+ return;
371
+ }
372
+ const unknownFlag = Object.keys(flags).find((name) => !FLAG_NAMES.has(name));
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");
391
+ if (positionals.length > 0) {
392
+ fail(`impel pat create: unexpected argument "${redactSecretText(positionals[0])}".`);
393
+ }
394
+
395
+ const subject = requestedSubject(flags);
396
+ const scopes = requestedScopes(flags.scopes);
397
+ const ttlDays = requestedTtlDays(flags["ttl-days"]);
398
+ const { appUrl, config } = requireConfig(flags);
399
+ const orgId = await requestedOrgId(flags, config, appUrl);
400
+ const body = {
401
+ orgId,
402
+ label: subject.label,
403
+ ...(subject.agentId ? { agentId: subject.agentId } : {}),
404
+ ...(scopes ? { scopes } : {}),
405
+ ...(ttlDays ? { ttlDays } : {}),
406
+ };
407
+ const created = await createPat({ appUrl, currentPat: config.pat, body });
408
+ printCreatedPat(created, subject, flags.json === true);
409
+ } catch (error) {
410
+ if (error instanceof PatCommandError) {
411
+ console.error(error.message);
412
+ process.exitCode = 1;
413
+ return;
414
+ }
415
+ throw error;
416
+ }
417
+ }