impel-cli 0.8.0 → 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.
package/README.md CHANGED
@@ -121,6 +121,9 @@ 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
124
127
  impel token [--tenant <org>] Print a selected-tenant bearer
125
128
  impel mcp Run the local MCP bridge used by Claude/Codex
126
129
  impel claude [claude args...] Launch isolated Impel Claude Code
@@ -183,6 +186,35 @@ Stores your PAT, gateway base URL, and app/control-plane URL in `~/.config/impel
183
186
  impel auth --pat impel_pat_xxxxxxxxxxxx --gateway https://gateway.useimpel.com --app https://www.useimpel.com
184
187
  ```
185
188
 
189
+ ### `impel pat create ...` (alias `impel pat mint ...`)
190
+
191
+ Mints a new PAT through the app/control-plane API, authenticated by the PAT
192
+ already stored by `impel auth`. Identity remains the minting authority: the
193
+ control plane verifies the current owner, target-org membership, requested
194
+ scopes, product access, and (when applicable) agent access before it requests a
195
+ new secret. The gateway never mints or verifies PATs.
196
+
197
+ Use `--label` for a personal token or `--agent` for a token associated with an
198
+ org agent. Agent PATs are still personal credentials: they act as you, are
199
+ org-scoped, and use the same stable `Agent · <agent-id>` association as the agent
200
+ setup UI.
201
+
202
+ ```sh
203
+ impel pat create --label "Rahul's laptop"
204
+ impel pat create --agent support-agent --org acme
205
+ impel pat create --label ci-automation --scopes codex-gateway,tasks --ttl-days 30 --json
206
+ ```
207
+
208
+ `--org` defaults to the selected tenant (`--tenant` is an alias). `--scopes`
209
+ accepts `claude-code-gateway`, `codex-gateway`, and `tasks`; when omitted, the
210
+ control plane chooses the defaults allowed by your access tier. `--ttl-days`
211
+ accepts 1–365 and defaults to 90 days.
212
+
213
+ The new secret is returned once on stdout and is deliberately **not** written to
214
+ the config file. To rotate this CLI onto it, explicitly run
215
+ `impel auth --pat <new-pat>` after copying it. `--json` includes the raw secret,
216
+ so treat redirected output as a credential.
217
+
186
218
  ### `impel update`
187
219
 
188
220
  One command brings everything current, in dependency order: the CLI itself
@@ -625,7 +657,7 @@ successful tenant-specific rebuild.
625
657
 
626
658
  The official Claude and ChatGPT apps remain separate installations and keep
627
659
  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
660
+ release pins exact, tested vendor builds (v0.9.0 pins Claude `1.20186.9` and
629
661
  ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
630
662
  an existing official app only when its version exactly matches; otherwise it
631
663
  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.0",
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,8 @@ 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
37
40
  impel tenant list|current|use <org> List or select the active organization
38
41
  impel tasks list|get|create|update|delete CRUD Impel tickets (aliases: task, ticket[s])
39
42
  impel token [--tenant <org>] Print the selected-tenant bearer
@@ -59,7 +62,7 @@ Native profile switching (advanced; setup never touches native tools):
59
62
 
60
63
  Env:
61
64
  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.
65
+ IMPEL_APP_URL Default app/control-plane URL for setup, auth, PATs, and tasks.
63
66
  IMPEL_SKIP_UPDATE_CHECK=1 Silence launch-time update notices.
64
67
 
65
68
  Config file:
@@ -101,6 +104,10 @@ export async function main(argv) {
101
104
  case "auth":
102
105
  return cmdAuth(rest);
103
106
 
107
+ case "pat":
108
+ case "pats":
109
+ return cmdPat(rest);
110
+
104
111
  case "token":
105
112
  return cmdToken(rest);
106
113
 
@@ -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
+ }