hngr 0.2.0 → 0.4.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
@@ -47,9 +47,12 @@ hngr skills add team-a/pdf-tools --agent claude-code
47
47
  hngr skills add pdf-tools@2026.10.25.1 --agent claude-code
48
48
  ```
49
49
 
50
- Non-interactive runs must pass `--agent` (repeatable) or `--all`. Use `--global` for
51
- user-level directories, `--copy` to skip the canonical `.agents/skills` directory, and
52
- `--dry-run` to preview without writing.
50
+ Non-interactive runs must pass `--agent` (repeatable) or `--all`. In an interactive
51
+ terminal, omitting `--global` first asks whether to install for the current project or
52
+ the user; without a terminal it defaults to the project. Use `--global` to skip the
53
+ question, `--copy` to skip the canonical `.agents/skills` directory, and `--dry-run` to
54
+ preview without writing. Re-running an install replaces the existing target; `--force`
55
+ is not needed.
53
56
 
54
57
  ## Publish capabilities
55
58
 
@@ -1,6 +1,7 @@
1
- import { S as HngrError, d as configFilePath, f as deleteStoredConfig, g as resolveConnection, h as redactUrl, m as readStoredConfig, n as apiGet, p as normalizeServerUrl, t as apiFetch, v as writeStoredConfig } from "./http-2HWxLIOq.mjs";
1
+ import { C as HngrError, d as configFilePath, f as deleteStoredConfig, g as resolveConnection, h as redactUrl, m as readStoredConfig, n as apiGet, p as normalizeServerUrl, t as apiFetch, v as storedAudience, y as writeStoredConfig } from "./http-DgHX7Fux.mjs";
2
2
  import { hostname } from "node:os";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { spawn } from "node:child_process";
4
5
  // src/commands/auth.ts
5
6
  const AUTH_REMEDIATION = "Run `hngr auth login` or set HANGAR_TOKEN.";
6
7
  /** The first 8 characters after the `hng_` prefix, for recognising a token. Not a credential. */
@@ -27,12 +28,33 @@ async function activeToken(ctx) {
27
28
  source: "config"
28
29
  } : void 0;
29
30
  }
30
- async function token(ctx) {
31
+ async function token(ctx, argv) {
32
+ if (argv.audience === "mcp") return mintMCPToken(ctx);
31
33
  const active = await activeToken(ctx);
32
34
  if (!active) throw notLoggedInError();
33
35
  ctx.io.stdout.write(`${active.token}\n`);
34
36
  return 0;
35
37
  }
38
+ /**
39
+ * Mints a static MCP token with the active credential and prints it once.
40
+ * The stored config is never rewritten, so the same machine keeps its
41
+ * management login while handing out MCP tokens.
42
+ *
43
+ * The caller's kind is deliberately not checked here: the server owns the
44
+ * owner rule (a Principal can manage its own tokens), so the request must go
45
+ * out and its Problem, if any, reaches the user through the usual mapping.
46
+ */
47
+ async function mintMCPToken(ctx) {
48
+ const connection = await resolveConnection(ctx.flags, ctx.env);
49
+ if (!connection.token) throw notLoggedInError();
50
+ const minted = await mintAccessToken(connection.server, {
51
+ name: cliMCPTokenName(),
52
+ audience: "mcp",
53
+ headers: { Authorization: `Bearer ${connection.token}` }
54
+ });
55
+ ctx.io.stdout.write(`${minted.token}\n`);
56
+ return 0;
57
+ }
36
58
  /** Prompts only happen with both stdin and stdout on a TTY and a prompter available. */
37
59
  function canPrompt(ctx) {
38
60
  return ctx.io.stdin.isTTY && ctx.io.stdout.isTTY && ctx.io.prompts !== void 0;
@@ -103,37 +125,43 @@ async function login(ctx, argv) {
103
125
  } catch (err) {
104
126
  if (err instanceof HngrError && err.body.type === "api") throw err;
105
127
  }
106
- if (argv.token === true) {
107
- const pasted = (await ctx.io.stdin.readAll()).trim();
108
- if (!pasted) throw new HngrError({
109
- type: "usage",
110
- title: "No token on stdin",
111
- detail: "Pipe an access token into `hngr auth login --token`."
112
- });
113
- const principal = asStoredPrincipal(await apiGet({
114
- server,
115
- token: pasted
116
- }, "/api/v1/me"));
117
- if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
118
- const prefix = tokenPrefixOf(pasted);
119
- const matched = await lookupTokenMetadata(server, pasted, prefix);
120
- const path = await writeStoredConfig(ctx.env, {
121
- server,
122
- token: pasted,
123
- ...matched ? {
124
- token_id: matched.id,
125
- token_name: matched.name
126
- } : {},
127
- token_prefix: prefix,
128
- principal,
129
- created_at: (/* @__PURE__ */ new Date()).toISOString()
130
- });
131
- summarizeLogin(ctx, principal.username, matched?.name, prefix, path);
132
- return 0;
133
- }
128
+ if (argv.token === true) return loginWithToken(ctx, server, stored);
129
+ if (argv.password === true) return loginWithPassword(ctx, server, stored);
130
+ return loginDevice(ctx, server, stored);
131
+ }
132
+ async function loginWithToken(ctx, server, stored) {
133
+ const pasted = (await ctx.io.stdin.readAll()).trim();
134
+ if (!pasted) throw new HngrError({
135
+ type: "usage",
136
+ title: "No token on stdin",
137
+ detail: "Pipe an access token into `hngr auth login --token`."
138
+ });
139
+ const principal = asStoredPrincipal(await apiGet({
140
+ server,
141
+ token: pasted
142
+ }, "/api/v1/me"));
143
+ if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
144
+ const prefix = tokenPrefixOf(pasted);
145
+ const matched = await lookupTokenMetadata(server, pasted, prefix);
146
+ const path = await writeStoredConfig(ctx.env, {
147
+ server,
148
+ token: pasted,
149
+ ...matched ? {
150
+ token_id: matched.id,
151
+ token_name: matched.name
152
+ } : {},
153
+ token_prefix: prefix,
154
+ audience: "management",
155
+ principal,
156
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
157
+ });
158
+ summarizeLogin(ctx, principal.username, matched?.name, prefix, path);
159
+ return 0;
160
+ }
161
+ async function loginWithPassword(ctx, server, stored) {
134
162
  if (!canPrompt(ctx)) throw new HngrError({
135
163
  type: "auth",
136
- title: "Logging in needs a terminal",
164
+ title: "Logging in with a password needs a terminal",
137
165
  detail: "Set HANGAR_TOKEN, or pass --token and pipe an access token on stdin, in non-interactive use."
138
166
  }, 4);
139
167
  const prompter = ctx.io.prompts;
@@ -152,7 +180,11 @@ async function login(ctx, argv) {
152
180
  }
153
181
  }));
154
182
  if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
155
- const minted = await mintAccessToken(server, cookie, cliTokenName());
183
+ const minted = await mintAccessToken(server, {
184
+ name: cliTokenName(),
185
+ audience: "management",
186
+ headers: { Cookie: cookie }
187
+ });
156
188
  const principal = asStoredPrincipal(await apiGet({
157
189
  server,
158
190
  token: minted.token
@@ -165,6 +197,7 @@ async function login(ctx, argv) {
165
197
  token_id: minted.id,
166
198
  token_name: minted.name,
167
199
  token_prefix: minted.token_prefix,
200
+ audience: "management",
168
201
  principal,
169
202
  created_at: (/* @__PURE__ */ new Date()).toISOString()
170
203
  });
@@ -175,6 +208,144 @@ async function login(ctx, argv) {
175
208
  summarizeLogin(ctx, principal.username, minted.name, minted.token_prefix, path);
176
209
  return 0;
177
210
  }
211
+ const DEVICE_CODE_PATH = "/api/v1/auth/device/code";
212
+ const DEVICE_TOKEN_PATH = "/api/v1/auth/device/token";
213
+ /** Suggested when the device flow cannot finish; the password flow still works. */
214
+ const PASSWORD_REMEDIATION = "Run `hngr auth login --password` to sign in with a username and password instead.";
215
+ /** Validates the create response; a bad shape is an api error, not a crash. */
216
+ function asDeviceCode(body) {
217
+ const value = body;
218
+ if (value === null || typeof value !== "object" || typeof value.device_code !== "string" || typeof value.user_code !== "string" || typeof value.verification_uri !== "string" || typeof value.verification_uri_complete !== "string" || typeof value.expires_in !== "number" || typeof value.interval !== "number" || value.interval <= 0) throw new HngrError({
219
+ type: "api",
220
+ title: "The server returned an unexpected device authorization payload",
221
+ detail: "Expected {device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval} from POST /api/v1/auth/device/code."
222
+ });
223
+ return value;
224
+ }
225
+ /** Validates the poll success response; the token is stored only when complete. */
226
+ function asDeviceToken(body) {
227
+ const value = body;
228
+ if (value === null || typeof value !== "object" || typeof value.token !== "string" || typeof value.token_id !== "string" || typeof value.token_prefix !== "string" || typeof value.name !== "string") throw new HngrError({
229
+ type: "api",
230
+ title: "The server returned an unexpected device token payload",
231
+ detail: "Expected {token, token_id, token_prefix, name, principal} from POST /api/v1/auth/device/token."
232
+ });
233
+ return value;
234
+ }
235
+ function deviceDeniedError() {
236
+ return new HngrError({
237
+ type: "auth",
238
+ title: "The login request was denied",
239
+ detail: PASSWORD_REMEDIATION
240
+ });
241
+ }
242
+ function deviceExpiredError() {
243
+ return new HngrError({
244
+ type: "auth",
245
+ title: "The login request expired",
246
+ detail: PASSWORD_REMEDIATION
247
+ });
248
+ }
249
+ function defaultSleep(ms) {
250
+ return new Promise((resolve) => setTimeout(resolve, ms));
251
+ }
252
+ /** Starts the platform opener without waiting for it; failures are silent. */
253
+ function openInBrowser(url) {
254
+ const [command, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
255
+ try {
256
+ const child = spawn(command, args, {
257
+ detached: true,
258
+ stdio: "ignore"
259
+ });
260
+ child.on("error", () => {});
261
+ child.unref();
262
+ } catch {}
263
+ }
264
+ /** The device name shown to the approver. */
265
+ function deviceName() {
266
+ return hostname().slice(0, 128);
267
+ }
268
+ /**
269
+ * Polls until the request leaves the pending state. `slow_down` extends the
270
+ * interval permanently by five seconds; network failures are retried until the
271
+ * advertised lifetime runs out. Sleeps and the clock are injectable for tests.
272
+ */
273
+ async function pollDeviceToken(ctx, server, deviceCode, expiresIn, interval) {
274
+ const now = ctx.io.now ?? Date.now;
275
+ const sleep = ctx.io.sleep ?? defaultSleep;
276
+ const deadline = now() + expiresIn * 1e3;
277
+ let waitSeconds = interval;
278
+ for (;;) {
279
+ if (now() >= deadline) throw deviceExpiredError();
280
+ try {
281
+ return asDeviceToken(await (await apiFetch({ server }, {
282
+ method: "POST",
283
+ path: DEVICE_TOKEN_PATH,
284
+ body: { device_code: deviceCode }
285
+ })).json());
286
+ } catch (err) {
287
+ if (!(err instanceof HngrError)) throw err;
288
+ const problem = String(err.body.type);
289
+ if (problem.endsWith("/authorization-pending")) {
290
+ await sleep(waitSeconds * 1e3);
291
+ continue;
292
+ }
293
+ if (problem.endsWith("/slow-down")) {
294
+ waitSeconds += 5;
295
+ await sleep(waitSeconds * 1e3);
296
+ continue;
297
+ }
298
+ if (problem.endsWith("/access-denied")) throw deviceDeniedError();
299
+ if (problem.endsWith("/expired-token")) throw deviceExpiredError();
300
+ if (err.body.type === "network") {
301
+ await sleep(waitSeconds * 1e3);
302
+ continue;
303
+ }
304
+ throw err;
305
+ }
306
+ }
307
+ }
308
+ /** The browser-based login: request a code, wait for approval, store the token. */
309
+ async function loginDevice(ctx, server, stored) {
310
+ const device = asDeviceCode(await (await apiFetch({ server }, {
311
+ method: "POST",
312
+ path: DEVICE_CODE_PATH,
313
+ body: {
314
+ device_name: deviceName(),
315
+ token_name: cliTokenName()
316
+ }
317
+ })).json());
318
+ ctx.detail("To approve this login from a browser:");
319
+ ctx.io.stderr.write(`open ${device.verification_uri}\n`);
320
+ ctx.io.stderr.write(`enter the user code: ${device.user_code}\n`);
321
+ if (ctx.io.stderr.isTTY) try {
322
+ await (ctx.io.openBrowser ?? openInBrowser)(device.verification_uri_complete);
323
+ } catch {}
324
+ const minted = await pollDeviceToken(ctx, server, device.device_code, device.expires_in, device.interval);
325
+ if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
326
+ const principal = asStoredPrincipal(await apiGet({
327
+ server,
328
+ token: minted.token
329
+ }, "/api/v1/me"));
330
+ let path;
331
+ try {
332
+ path = await writeStoredConfig(ctx.env, {
333
+ server,
334
+ token: minted.token,
335
+ token_id: minted.token_id,
336
+ token_name: minted.name,
337
+ token_prefix: minted.token_prefix,
338
+ audience: "management",
339
+ principal,
340
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
341
+ });
342
+ } catch (err) {
343
+ if (!await revokeToken(server, minted.token, minted.token_id)) ctx.summary(SERVER_TOKEN_LINGERING_WARNING);
344
+ throw err;
345
+ }
346
+ summarizeLogin(ctx, principal.username, minted.name, minted.token_prefix, path);
347
+ return 0;
348
+ }
178
349
  /** Reads the session cookie off the login response; missing cookie is an api error. */
179
350
  function sessionCookieOf(response) {
180
351
  const pair = (response.headers.getSetCookie()[0] ?? response.headers.get("set-cookie"))?.split(";")[0]?.trim();
@@ -189,6 +360,10 @@ function sessionCookieOf(response) {
189
360
  function cliTokenName() {
190
361
  return `hngr-cli@${hostname()}`.slice(0, 64);
191
362
  }
363
+ /** `hngr-mcp@<hostname>`, so MCP tokens are recognisable in the token list. */
364
+ function cliMCPTokenName() {
365
+ return `hngr-mcp@${hostname()}`.slice(0, 64);
366
+ }
192
367
  /**
193
368
  * Finds the stored token's id so logout can revoke it. A token list that is
194
369
  * unavailable, malformed, or ambiguous leaves the id unknown; login itself
@@ -217,20 +392,24 @@ async function lookupTokenMetadata(server, token, prefix) {
217
392
  } : { id: match.id };
218
393
  }
219
394
  /**
220
- * Mints an access token with the session cookie and a random Idempotency-Key.
221
- * A replay response carries no token value: delete the replayed id and mint
222
- * again under a fresh key.
395
+ * Mints an access token with the caller's credentials and a random
396
+ * Idempotency-Key. The caller passes either a session cookie (login) or a
397
+ * Bearer token (minting for an audience). A replay response carries no token
398
+ * value: delete the replayed id and mint again under a fresh key.
223
399
  */
224
- async function mintAccessToken(server, cookie, name) {
400
+ async function mintAccessToken(server, options) {
225
401
  for (let attempt = 0; attempt < 2; attempt++) {
226
402
  const response = await apiFetch({ server }, {
227
403
  method: "POST",
228
404
  path: "/api/v1/me/tokens",
229
405
  headers: {
230
- Cookie: cookie,
406
+ ...options.headers,
231
407
  "Idempotency-Key": randomUUID()
232
408
  },
233
- body: { name }
409
+ body: {
410
+ name: options.name,
411
+ audience: options.audience
412
+ }
234
413
  });
235
414
  const body = await response.json();
236
415
  if (response.status === 201) {
@@ -254,7 +433,7 @@ async function mintAccessToken(server, cookie, name) {
254
433
  await apiFetch({ server }, {
255
434
  method: "DELETE",
256
435
  path: `/api/v1/me/tokens/${encodeURIComponent(body.id)}`,
257
- headers: { Cookie: cookie }
436
+ headers: options.headers
258
437
  });
259
438
  }
260
439
  throw new HngrError({
@@ -304,6 +483,7 @@ async function status(ctx, argv) {
304
483
  const config = await readStoredConfig(ctx.env);
305
484
  const source = active.source === "env" ? "env:HANGAR_TOKEN" : configFilePath(ctx.env);
306
485
  const tokenPrefix = active.source === "config" && config?.token_prefix ? config.token_prefix : tokenPrefixOf(active.token);
486
+ const audience = active.source === "config" ? storedAudience(config) : "management";
307
487
  let principal = config?.principal ?? null;
308
488
  if (argv.offline !== true) try {
309
489
  principal = await apiGet(connection, "/api/v1/me", void 0, { signal: AbortSignal.timeout(5e3) });
@@ -315,7 +495,8 @@ async function status(ctx, argv) {
315
495
  source,
316
496
  server: connection.server,
317
497
  principal,
318
- token_prefix: tokenPrefix
498
+ token_prefix: tokenPrefix,
499
+ audience
319
500
  });
320
501
  return 0;
321
502
  }