hngr 0.0.0-stage → 0.1.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
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # hngr
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Hangar CLI. Install skills and MCP servers into coding agents, and drive the Hangar
4
+ Management API — from a terminal, a container, or CI.
5
+
6
+ Requires Node.js >= 22.
7
+
8
+ ```sh
9
+ npm install -g hngr
10
+ hngr --version
11
+ ```
12
+
13
+ ## Authenticate
14
+
15
+ ```sh
16
+ # Interactive: username and password, then an access token is written to
17
+ # ~/.config/hngr/config.json (0600).
18
+ hngr auth login --server https://hangar.example.com
19
+
20
+ # CI or containers: read an existing access token from stdin.
21
+ printf '%s' "$HANGAR_TOKEN_VALUE" | hngr auth login --token --server https://hangar.example.com
22
+
23
+ # Or skip the config file entirely.
24
+ export HANGAR_URL=https://hangar.example.com
25
+ export HANGAR_TOKEN=hng_...
26
+ hngr auth status
27
+ ```
28
+
29
+ ## Install capabilities
30
+
31
+ A bare name means the `system` namespace; anything else uses `<namespace>/<name>`.
32
+
33
+ ```sh
34
+ # system namespace
35
+ hngr skills add pdf-tools --agent claude-code
36
+ hngr mcp add pdf-tools --agent claude-code
37
+
38
+ # another namespace
39
+ hngr skills add team-a/pdf-tools --agent claude-code
40
+
41
+ # pin a version
42
+ hngr skills add pdf-tools@2026.10.25.1 --agent claude-code
43
+ ```
44
+
45
+ Non-interactive runs must pass `--agent` (repeatable) or `--all`. Use `--global` for
46
+ user-level directories, `--copy` to skip the canonical `.agents/skills` directory, and
47
+ `--dry-run` to preview without writing.
48
+
49
+ ## Publish capabilities
50
+
51
+ ```sh
52
+ # A directory containing SKILL.md; creates the capability, or publishes a new version
53
+ hngr skills publish ./my-skill --description "What it does" --visibility internal
54
+
55
+ # An MCP config snapshot with an mcpServers object
56
+ hngr mcp publish ./mcp.json --name my-mcp --description "What it does"
57
+
58
+ # Fail instead of creating a new version; preview without sending anything
59
+ hngr skills publish ./my-skill --description "..." --new
60
+ hngr mcp publish ./mcp.json --dry-run
61
+ ```
62
+
63
+ `--namespace` defaults to `system`; `--visibility` defaults to `private` and accepts
64
+ `public`, `internal` or `private`.
65
+
66
+ ## Browse and discover
67
+
68
+ ```sh
69
+ hngr capabilities list
70
+ hngr commands --json # machine-readable catalogue
71
+ hngr schema capabilities list
72
+ hngr completion bash # or zsh / fish
73
+ ```
74
+
75
+ ## Output contract
76
+
77
+ stdout carries the payload only (JSON, 2-space indent). Progress, summaries, warnings and
78
+ errors go to stderr. Errors are single-line JSON when stdout is not a TTY or `--json` is
79
+ passed. Exit codes: `0` success, `1` failure or usage error, `4` authentication required,
80
+ `130` cancelled.
package/bin/hngr.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { checkNodeVersion } from "./node-gate.js";
3
+
4
+ const check = checkNodeVersion(process.versions.node);
5
+ if (!check.ok) {
6
+ process.stderr.write(`${check.error}\n`);
7
+ process.exit(1);
8
+ }
9
+
10
+ // The bundle is only parsed after the version gate passes.
11
+ const { main } = await import("../dist/index.mjs");
12
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,5 @@
1
+ export declare const MIN_NODE_MAJOR: number;
2
+
3
+ export type NodeVersionCheck = { ok: true } | { ok: false; error: string };
4
+
5
+ export declare function checkNodeVersion(version: string): NodeVersionCheck;
@@ -0,0 +1,18 @@
1
+ export const MIN_NODE_MAJOR = 22;
2
+
3
+ /**
4
+ * Gate that runs before any bundled code is imported. Returns { ok: false,
5
+ * error } with an actionable message when the runtime is too old.
6
+ *
7
+ * @param {string} version process.versions.node, e.g. "22.1.0"
8
+ */
9
+ export function checkNodeVersion(version) {
10
+ const major = Number.parseInt(String(version).split(".")[0] ?? "0", 10);
11
+ if (Number.isNaN(major) || major < MIN_NODE_MAJOR) {
12
+ return {
13
+ ok: false,
14
+ error: `hngr requires Node.js >= ${MIN_NODE_MAJOR} (current: v${version}). Install a newer Node.js from https://nodejs.org and retry.`,
15
+ };
16
+ }
17
+ return { ok: true };
18
+ }
@@ -0,0 +1,334 @@
1
+ import { _ as writeStoredConfig, d as deleteStoredConfig, f as normalizeServerUrl, h as resolveConnection, m as redactUrl, n as apiGet, p as readStoredConfig, t as apiFetch, u as configFilePath, x as HngrError } from "./http-DpXNrkSL.mjs";
2
+ import { hostname } from "node:os";
3
+ import { randomUUID } from "node:crypto";
4
+ // src/commands/auth.ts
5
+ const AUTH_REMEDIATION = "Run `hngr auth login` or set HANGAR_TOKEN.";
6
+ /** The first 8 characters after the `hng_` prefix, for recognising a token. Not a credential. */
7
+ function tokenPrefixOf(token) {
8
+ return (token.startsWith("hng_") ? token.slice(4) : token).slice(0, 8);
9
+ }
10
+ /** Exit-4 error for commands that need credentials the caller does not have. */
11
+ function notLoggedInError() {
12
+ return new HngrError({
13
+ type: "auth",
14
+ title: "Not logged in",
15
+ detail: AUTH_REMEDIATION
16
+ }, 4);
17
+ }
18
+ /** The active token and where it came from; `HANGAR_TOKEN` wins over the config file. */
19
+ async function activeToken(ctx) {
20
+ if (ctx.env.HANGAR_TOKEN) return {
21
+ token: ctx.env.HANGAR_TOKEN,
22
+ source: "env"
23
+ };
24
+ const config = await readStoredConfig(ctx.env);
25
+ return config?.token ? {
26
+ token: config.token,
27
+ source: "config"
28
+ } : void 0;
29
+ }
30
+ async function token(ctx) {
31
+ const active = await activeToken(ctx);
32
+ if (!active) throw notLoggedInError();
33
+ ctx.io.stdout.write(`${active.token}\n`);
34
+ return 0;
35
+ }
36
+ /** Prompts only happen with both stdin and stdout on a TTY and a prompter available. */
37
+ function canPrompt(ctx) {
38
+ return ctx.io.stdin.isTTY && ctx.io.stdout.isTTY && ctx.io.prompts !== void 0;
39
+ }
40
+ /**
41
+ * Enforces https everywhere; http only for localhost, 127.0.0.1 and [::1],
42
+ * or with an explicit --insecure.
43
+ */
44
+ function assertServerUrlAllowed(raw, insecure) {
45
+ const server = normalizeServerUrl(raw);
46
+ const url = new URL(server);
47
+ if (url.protocol === "https:") return server;
48
+ if (url.protocol === "http:") {
49
+ if ([
50
+ "localhost",
51
+ "127.0.0.1",
52
+ "[::1]"
53
+ ].includes(url.hostname) || insecure) return server;
54
+ throw new HngrError({
55
+ type: "config",
56
+ title: `Refusing insecure server URL ${redactUrl(server)}`,
57
+ detail: "Use https://, or pass --insecure to allow http:// for non-local hosts."
58
+ });
59
+ }
60
+ throw new HngrError({
61
+ type: "config",
62
+ title: `Unsupported server URL scheme in ${redactUrl(server)}`,
63
+ detail: "Use an https:// server URL."
64
+ });
65
+ }
66
+ /** Maps the GET /me payload onto the stored principal shape; a bad shape is an api error. */
67
+ function asStoredPrincipal(body) {
68
+ const principal = body;
69
+ if (principal === null || typeof principal !== "object" || typeof principal.id !== "string" || typeof principal.username !== "string" || typeof principal.kind !== "string") throw new HngrError({
70
+ type: "api",
71
+ title: "The server returned an unexpected GET /me payload",
72
+ detail: "Expected {id, username, kind} strings."
73
+ });
74
+ return {
75
+ id: principal.id,
76
+ username: principal.username,
77
+ type: principal.kind
78
+ };
79
+ }
80
+ /** The login summary; never carries the token, password or cookie. */
81
+ function summarizeLogin(ctx, username, tokenName, tokenPrefix, configPath) {
82
+ ctx.progress(`logged in as ${username}`);
83
+ ctx.progress(tokenName ? `token ${tokenName} (prefix ${tokenPrefix})` : `token prefix ${tokenPrefix}`);
84
+ ctx.progress(`config ${configPath}`);
85
+ ctx.progress("source: config");
86
+ }
87
+ /** Best-effort revoke of the previous stored token on a --force re-login. */
88
+ async function revokePreviousToken(ctx, stored) {
89
+ if (!stored.token || !stored.token_id || !stored.server) return;
90
+ if (!await revokeToken(normalizeServerUrl(stored.server), stored.token, stored.token_id)) ctx.summary(SERVER_TOKEN_LINGERING_WARNING);
91
+ }
92
+ async function login(ctx, argv) {
93
+ if (ctx.env.HANGAR_TOKEN) throw envTokenRefusal();
94
+ const stored = await readStoredConfig(ctx.env);
95
+ let rawServer = ctx.flags.server ?? ctx.env.HANGAR_URL ?? stored?.server;
96
+ if (!rawServer && canPrompt(ctx)) rawServer = await ctx.io.prompts?.text("Server URL");
97
+ if (!rawServer) throw new HngrError({
98
+ type: "config",
99
+ title: "No Hangar server configured",
100
+ detail: "Pass --server <url>, set HANGAR_URL, or run `hngr auth login` interactively to store one."
101
+ });
102
+ const server = assertServerUrlAllowed(rawServer, ctx.flags.insecure);
103
+ if (stored?.token && !ctx.flags.force) try {
104
+ const me = asStoredPrincipal(await apiGet({
105
+ server,
106
+ token: stored.token
107
+ }, "/api/v1/me"));
108
+ ctx.progress(`already logged in as ${me.username}; pass --force to re-authenticate`);
109
+ return 0;
110
+ } catch (err) {
111
+ if (err instanceof HngrError && err.body.type === "api") throw err;
112
+ }
113
+ if (argv.token === true) {
114
+ const pasted = (await ctx.io.stdin.readAll()).trim();
115
+ if (!pasted) throw new HngrError({
116
+ type: "usage",
117
+ title: "No token on stdin",
118
+ detail: "Pipe an access token into `hngr auth login --token`."
119
+ });
120
+ const principal = asStoredPrincipal(await apiGet({
121
+ server,
122
+ token: pasted
123
+ }, "/api/v1/me"));
124
+ if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
125
+ const prefix = tokenPrefixOf(pasted);
126
+ const matched = await lookupTokenMetadata(server, pasted, prefix);
127
+ const path = await writeStoredConfig(ctx.env, {
128
+ server,
129
+ token: pasted,
130
+ ...matched ? {
131
+ token_id: matched.id,
132
+ token_name: matched.name
133
+ } : {},
134
+ token_prefix: prefix,
135
+ principal,
136
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
137
+ });
138
+ summarizeLogin(ctx, principal.username, matched?.name, prefix, path);
139
+ return 0;
140
+ }
141
+ if (!canPrompt(ctx)) throw new HngrError({
142
+ type: "auth",
143
+ title: "Logging in needs a terminal",
144
+ detail: "Set HANGAR_TOKEN, or pass --token and pipe an access token on stdin, in non-interactive use."
145
+ }, 4);
146
+ const prompter = ctx.io.prompts;
147
+ if (!prompter) throw new HngrError({
148
+ type: "io",
149
+ title: "No interactive prompter available"
150
+ });
151
+ const username = await prompter.text("Username");
152
+ const password = await prompter.password("Password");
153
+ const cookie = sessionCookieOf(await apiFetch({ server }, {
154
+ method: "POST",
155
+ path: "/api/v1/login",
156
+ body: {
157
+ username,
158
+ password
159
+ }
160
+ }));
161
+ if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
162
+ const minted = await mintAccessToken(server, cookie, cliTokenName());
163
+ const principal = asStoredPrincipal(await apiGet({
164
+ server,
165
+ token: minted.token
166
+ }, "/api/v1/me"));
167
+ let path;
168
+ try {
169
+ path = await writeStoredConfig(ctx.env, {
170
+ server,
171
+ token: minted.token,
172
+ token_id: minted.id,
173
+ token_name: minted.name,
174
+ token_prefix: minted.token_prefix,
175
+ principal,
176
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
177
+ });
178
+ } catch (err) {
179
+ if (!await revokeToken(server, minted.token, minted.id)) ctx.summary(SERVER_TOKEN_LINGERING_WARNING);
180
+ throw err;
181
+ }
182
+ summarizeLogin(ctx, principal.username, minted.name, minted.token_prefix, path);
183
+ return 0;
184
+ }
185
+ /** Reads the session cookie off the login response; missing cookie is an api error. */
186
+ function sessionCookieOf(response) {
187
+ const pair = (response.headers.getSetCookie()[0] ?? response.headers.get("set-cookie"))?.split(";")[0]?.trim();
188
+ if (!pair) throw new HngrError({
189
+ type: "api",
190
+ title: "The login response carried no session cookie",
191
+ detail: "Expected Set-Cookie from POST /api/v1/login."
192
+ });
193
+ return pair;
194
+ }
195
+ /** `hngr-cli@<hostname>`, capped at the server's 64-character name limit. */
196
+ function cliTokenName() {
197
+ return `hngr-cli@${hostname()}`.slice(0, 64);
198
+ }
199
+ /**
200
+ * Finds the stored token's id so logout can revoke it. A token list that is
201
+ * unavailable, malformed, or ambiguous leaves the id unknown; login itself
202
+ * still succeeds and logout warns that it could not revoke.
203
+ */
204
+ async function lookupTokenMetadata(server, token, prefix) {
205
+ let body;
206
+ try {
207
+ body = await apiGet({
208
+ server,
209
+ token
210
+ }, "/api/v1/me/tokens");
211
+ } catch (err) {
212
+ if (err instanceof HngrError || err instanceof SyntaxError) return;
213
+ throw err;
214
+ }
215
+ const items = body?.items;
216
+ if (!Array.isArray(items)) return;
217
+ const matches = items.filter((item) => item !== null && typeof item === "object" && typeof item.id === "string" && item.token_prefix === prefix);
218
+ if (matches.length !== 1) return;
219
+ const [match] = matches;
220
+ if (!match) return;
221
+ return typeof match.name === "string" ? {
222
+ id: match.id,
223
+ name: match.name
224
+ } : { id: match.id };
225
+ }
226
+ /**
227
+ * Mints an access token with the session cookie and a random Idempotency-Key.
228
+ * A replay response carries no token value: delete the replayed id and mint
229
+ * again under a fresh key.
230
+ */
231
+ async function mintAccessToken(server, cookie, name) {
232
+ for (let attempt = 0; attempt < 2; attempt++) {
233
+ const response = await apiFetch({ server }, {
234
+ method: "POST",
235
+ path: "/api/v1/me/tokens",
236
+ headers: {
237
+ Cookie: cookie,
238
+ "Idempotency-Key": randomUUID()
239
+ },
240
+ body: { name }
241
+ });
242
+ const body = await response.json();
243
+ if (response.status === 201) {
244
+ if (typeof body.id !== "string" || typeof body.name !== "string" || typeof body.token_prefix !== "string" || typeof body.token !== "string") throw new HngrError({
245
+ type: "api",
246
+ title: "The server returned an unexpected token payload",
247
+ detail: "Expected {id, name, token_prefix, token} from POST /api/v1/me/tokens."
248
+ });
249
+ return {
250
+ id: body.id,
251
+ name: body.name,
252
+ token_prefix: body.token_prefix,
253
+ token: body.token
254
+ };
255
+ }
256
+ if (typeof body.id !== "string") throw new HngrError({
257
+ type: "api",
258
+ title: "The server returned an unexpected token replay payload",
259
+ detail: "Expected {id} from POST /api/v1/me/tokens."
260
+ });
261
+ await apiFetch({ server }, {
262
+ method: "DELETE",
263
+ path: `/api/v1/me/tokens/${encodeURIComponent(body.id)}`,
264
+ headers: { Cookie: cookie }
265
+ });
266
+ }
267
+ throw new HngrError({
268
+ type: "api",
269
+ title: "Minting an access token kept replaying",
270
+ detail: "POST /api/v1/me/tokens answered 200 twice under different idempotency keys."
271
+ });
272
+ }
273
+ const ENV_TOKEN_REFUSAL = "HANGAR_TOKEN is set, so stored credentials are read-only. Unset HANGAR_TOKEN to change them.";
274
+ /** Refuses to modify stored credentials while an environment token is active. */
275
+ function envTokenRefusal() {
276
+ return new HngrError({
277
+ type: "auth",
278
+ title: ENV_TOKEN_REFUSAL
279
+ });
280
+ }
281
+ /** Revokes one access token; 401/404 mean it is already gone. Returns false when unsure. */
282
+ async function revokeToken(server, bearer, tokenId) {
283
+ try {
284
+ await apiFetch({ server }, {
285
+ method: "DELETE",
286
+ path: `/api/v1/me/tokens/${encodeURIComponent(tokenId)}`,
287
+ headers: { Authorization: `Bearer ${bearer}` }
288
+ });
289
+ return true;
290
+ } catch (err) {
291
+ if (err instanceof HngrError && (err.body.status === 401 || err.body.status === 404)) return true;
292
+ return false;
293
+ }
294
+ }
295
+ const SERVER_TOKEN_LINGERING_WARNING = "warning: could not revoke the token on the server; the server token may still be valid";
296
+ async function logout(ctx) {
297
+ if (ctx.env.HANGAR_TOKEN) throw envTokenRefusal();
298
+ const config = await readStoredConfig(ctx.env);
299
+ if (!config?.token) throw notLoggedInError();
300
+ if (config.token_id && config.server) {
301
+ if (!await revokeToken(normalizeServerUrl(config.server), config.token, config.token_id)) ctx.summary(SERVER_TOKEN_LINGERING_WARNING);
302
+ } else ctx.summary("warning: no token id is stored, so the server token was not revoked; revoke it from the web console if needed");
303
+ await deleteStoredConfig(ctx.env);
304
+ ctx.progress("logged out");
305
+ return 0;
306
+ }
307
+ async function status(ctx, argv) {
308
+ const active = await activeToken(ctx);
309
+ if (!active) throw notLoggedInError();
310
+ const connection = await resolveConnection(ctx.flags, ctx.env);
311
+ const config = await readStoredConfig(ctx.env);
312
+ const source = active.source === "env" ? "env:HANGAR_TOKEN" : configFilePath(ctx.env);
313
+ const tokenPrefix = active.source === "config" && config?.token_prefix ? config.token_prefix : tokenPrefixOf(active.token);
314
+ let principal = config?.principal ?? null;
315
+ if (argv.offline !== true) try {
316
+ principal = await apiGet(connection, "/api/v1/me", void 0, { signal: AbortSignal.timeout(5e3) });
317
+ } catch (err) {
318
+ if (err instanceof HngrError && err.body.type === "network") ctx.summary("warning: the live GET /me check failed; showing the stored principal");
319
+ else throw err;
320
+ }
321
+ ctx.writePayload({
322
+ source,
323
+ server: connection.server,
324
+ principal,
325
+ token_prefix: tokenPrefix
326
+ });
327
+ return 0;
328
+ }
329
+ async function whoami(ctx) {
330
+ const connection = await resolveConnection(ctx.flags, ctx.env);
331
+ ctx.writePayload(await apiGet(connection, "/api/v1/me"));
332
+ return 0;
333
+ }
334
+ export { login, logout, status, token, whoami };