hngr 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,85 @@
1
+ # hngr
2
+
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 against http://10.199.100.24:26434 (the
17
+ # built-in default), then an access token is written to
18
+ # ~/.config/hngr/config.json (0600).
19
+ hngr auth login
20
+
21
+ # A different deployment. The built-in default is plain http on a private
22
+ # network; any other host needs https:// (or an explicit --insecure).
23
+ hngr auth login --server https://hangar.example.com
24
+
25
+ # CI or containers: read an existing access token from stdin.
26
+ printf '%s' "$HANGAR_TOKEN_VALUE" | hngr auth login --token --server https://hangar.example.com
27
+
28
+ # Or skip the config file entirely. HANGAR_URL overrides the built-in default.
29
+ export HANGAR_URL=https://hangar.example.com
30
+ export HANGAR_TOKEN=hng_...
31
+ hngr auth status
32
+ ```
33
+
34
+ ## Install capabilities
35
+
36
+ A bare name means the `system` namespace; anything else uses `<namespace>/<name>`.
37
+
38
+ ```sh
39
+ # system namespace
40
+ hngr skills add pdf-tools --agent claude-code
41
+ hngr mcp add pdf-tools --agent claude-code
42
+
43
+ # another namespace
44
+ hngr skills add team-a/pdf-tools --agent claude-code
45
+
46
+ # pin a version
47
+ hngr skills add pdf-tools@2026.10.25.1 --agent claude-code
48
+ ```
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.
53
+
54
+ ## Publish capabilities
55
+
56
+ ```sh
57
+ # A directory containing SKILL.md; creates the capability, or publishes a new version
58
+ hngr skills publish ./my-skill --description "What it does" --visibility internal
59
+
60
+ # An MCP config snapshot with an mcpServers object
61
+ hngr mcp publish ./mcp.json --name my-mcp --description "What it does"
62
+
63
+ # Fail instead of creating a new version; preview without sending anything
64
+ hngr skills publish ./my-skill --description "..." --new
65
+ hngr mcp publish ./mcp.json --dry-run
66
+ ```
67
+
68
+ `--namespace` defaults to `system`; `--visibility` defaults to `private` and accepts
69
+ `public`, `internal` or `private`.
70
+
71
+ ## Browse and discover
72
+
73
+ ```sh
74
+ hngr capabilities list
75
+ hngr commands --json # machine-readable catalogue
76
+ hngr schema capabilities list
77
+ hngr completion bash # or zsh / fish
78
+ ```
79
+
80
+ ## Output contract
81
+
82
+ stdout carries the payload only (JSON, 2-space indent). Progress, summaries, warnings and
83
+ errors go to stderr. Errors are single-line JSON when stdout is not a TTY or `--json` is
84
+ passed. Exit codes: `0` success, `1` failure or usage error, `4` authentication required,
85
+ `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,327 @@
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";
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, [::1], the
42
+ * built-in default, 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 || server === "http://10.199.100.24:26434") 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
+ const server = assertServerUrlAllowed(ctx.flags.server ?? ctx.env.HANGAR_URL ?? stored?.server ?? "http://10.199.100.24:26434", ctx.flags.insecure);
96
+ if (stored?.token && !ctx.flags.force) try {
97
+ const me = asStoredPrincipal(await apiGet({
98
+ server,
99
+ token: stored.token
100
+ }, "/api/v1/me"));
101
+ ctx.progress(`already logged in as ${me.username}; pass --force to re-authenticate`);
102
+ return 0;
103
+ } catch (err) {
104
+ if (err instanceof HngrError && err.body.type === "api") throw err;
105
+ }
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
+ }
134
+ if (!canPrompt(ctx)) throw new HngrError({
135
+ type: "auth",
136
+ title: "Logging in needs a terminal",
137
+ detail: "Set HANGAR_TOKEN, or pass --token and pipe an access token on stdin, in non-interactive use."
138
+ }, 4);
139
+ const prompter = ctx.io.prompts;
140
+ if (!prompter) throw new HngrError({
141
+ type: "io",
142
+ title: "No interactive prompter available"
143
+ });
144
+ const username = await prompter.text("Username");
145
+ const password = await prompter.password("Password");
146
+ const cookie = sessionCookieOf(await apiFetch({ server }, {
147
+ method: "POST",
148
+ path: "/api/v1/login",
149
+ body: {
150
+ username,
151
+ password
152
+ }
153
+ }));
154
+ if (ctx.flags.force && stored) await revokePreviousToken(ctx, stored);
155
+ const minted = await mintAccessToken(server, cookie, cliTokenName());
156
+ const principal = asStoredPrincipal(await apiGet({
157
+ server,
158
+ token: minted.token
159
+ }, "/api/v1/me"));
160
+ let path;
161
+ try {
162
+ path = await writeStoredConfig(ctx.env, {
163
+ server,
164
+ token: minted.token,
165
+ token_id: minted.id,
166
+ token_name: minted.name,
167
+ token_prefix: minted.token_prefix,
168
+ principal,
169
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
170
+ });
171
+ } catch (err) {
172
+ if (!await revokeToken(server, minted.token, minted.id)) ctx.summary(SERVER_TOKEN_LINGERING_WARNING);
173
+ throw err;
174
+ }
175
+ summarizeLogin(ctx, principal.username, minted.name, minted.token_prefix, path);
176
+ return 0;
177
+ }
178
+ /** Reads the session cookie off the login response; missing cookie is an api error. */
179
+ function sessionCookieOf(response) {
180
+ const pair = (response.headers.getSetCookie()[0] ?? response.headers.get("set-cookie"))?.split(";")[0]?.trim();
181
+ if (!pair) throw new HngrError({
182
+ type: "api",
183
+ title: "The login response carried no session cookie",
184
+ detail: "Expected Set-Cookie from POST /api/v1/login."
185
+ });
186
+ return pair;
187
+ }
188
+ /** `hngr-cli@<hostname>`, capped at the server's 64-character name limit. */
189
+ function cliTokenName() {
190
+ return `hngr-cli@${hostname()}`.slice(0, 64);
191
+ }
192
+ /**
193
+ * Finds the stored token's id so logout can revoke it. A token list that is
194
+ * unavailable, malformed, or ambiguous leaves the id unknown; login itself
195
+ * still succeeds and logout warns that it could not revoke.
196
+ */
197
+ async function lookupTokenMetadata(server, token, prefix) {
198
+ let body;
199
+ try {
200
+ body = await apiGet({
201
+ server,
202
+ token
203
+ }, "/api/v1/me/tokens");
204
+ } catch (err) {
205
+ if (err instanceof HngrError || err instanceof SyntaxError) return;
206
+ throw err;
207
+ }
208
+ const items = body?.items;
209
+ if (!Array.isArray(items)) return;
210
+ const matches = items.filter((item) => item !== null && typeof item === "object" && typeof item.id === "string" && item.token_prefix === prefix);
211
+ if (matches.length !== 1) return;
212
+ const [match] = matches;
213
+ if (!match) return;
214
+ return typeof match.name === "string" ? {
215
+ id: match.id,
216
+ name: match.name
217
+ } : { id: match.id };
218
+ }
219
+ /**
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.
223
+ */
224
+ async function mintAccessToken(server, cookie, name) {
225
+ for (let attempt = 0; attempt < 2; attempt++) {
226
+ const response = await apiFetch({ server }, {
227
+ method: "POST",
228
+ path: "/api/v1/me/tokens",
229
+ headers: {
230
+ Cookie: cookie,
231
+ "Idempotency-Key": randomUUID()
232
+ },
233
+ body: { name }
234
+ });
235
+ const body = await response.json();
236
+ if (response.status === 201) {
237
+ if (typeof body.id !== "string" || typeof body.name !== "string" || typeof body.token_prefix !== "string" || typeof body.token !== "string") throw new HngrError({
238
+ type: "api",
239
+ title: "The server returned an unexpected token payload",
240
+ detail: "Expected {id, name, token_prefix, token} from POST /api/v1/me/tokens."
241
+ });
242
+ return {
243
+ id: body.id,
244
+ name: body.name,
245
+ token_prefix: body.token_prefix,
246
+ token: body.token
247
+ };
248
+ }
249
+ if (typeof body.id !== "string") throw new HngrError({
250
+ type: "api",
251
+ title: "The server returned an unexpected token replay payload",
252
+ detail: "Expected {id} from POST /api/v1/me/tokens."
253
+ });
254
+ await apiFetch({ server }, {
255
+ method: "DELETE",
256
+ path: `/api/v1/me/tokens/${encodeURIComponent(body.id)}`,
257
+ headers: { Cookie: cookie }
258
+ });
259
+ }
260
+ throw new HngrError({
261
+ type: "api",
262
+ title: "Minting an access token kept replaying",
263
+ detail: "POST /api/v1/me/tokens answered 200 twice under different idempotency keys."
264
+ });
265
+ }
266
+ const ENV_TOKEN_REFUSAL = "HANGAR_TOKEN is set, so stored credentials are read-only. Unset HANGAR_TOKEN to change them.";
267
+ /** Refuses to modify stored credentials while an environment token is active. */
268
+ function envTokenRefusal() {
269
+ return new HngrError({
270
+ type: "auth",
271
+ title: ENV_TOKEN_REFUSAL
272
+ });
273
+ }
274
+ /** Revokes one access token; 401/404 mean it is already gone. Returns false when unsure. */
275
+ async function revokeToken(server, bearer, tokenId) {
276
+ try {
277
+ await apiFetch({ server }, {
278
+ method: "DELETE",
279
+ path: `/api/v1/me/tokens/${encodeURIComponent(tokenId)}`,
280
+ headers: { Authorization: `Bearer ${bearer}` }
281
+ });
282
+ return true;
283
+ } catch (err) {
284
+ if (err instanceof HngrError && (err.body.status === 401 || err.body.status === 404)) return true;
285
+ return false;
286
+ }
287
+ }
288
+ const SERVER_TOKEN_LINGERING_WARNING = "warning: could not revoke the token on the server; the server token may still be valid";
289
+ async function logout(ctx) {
290
+ if (ctx.env.HANGAR_TOKEN) throw envTokenRefusal();
291
+ const config = await readStoredConfig(ctx.env);
292
+ if (!config?.token) throw notLoggedInError();
293
+ if (config.token_id && config.server) {
294
+ if (!await revokeToken(normalizeServerUrl(config.server), config.token, config.token_id)) ctx.summary(SERVER_TOKEN_LINGERING_WARNING);
295
+ } else ctx.summary("warning: no token id is stored, so the server token was not revoked; revoke it from the web console if needed");
296
+ await deleteStoredConfig(ctx.env);
297
+ ctx.progress("logged out");
298
+ return 0;
299
+ }
300
+ async function status(ctx, argv) {
301
+ const active = await activeToken(ctx);
302
+ if (!active) throw notLoggedInError();
303
+ const connection = await resolveConnection(ctx.flags, ctx.env);
304
+ const config = await readStoredConfig(ctx.env);
305
+ const source = active.source === "env" ? "env:HANGAR_TOKEN" : configFilePath(ctx.env);
306
+ const tokenPrefix = active.source === "config" && config?.token_prefix ? config.token_prefix : tokenPrefixOf(active.token);
307
+ let principal = config?.principal ?? null;
308
+ if (argv.offline !== true) try {
309
+ principal = await apiGet(connection, "/api/v1/me", void 0, { signal: AbortSignal.timeout(5e3) });
310
+ } catch (err) {
311
+ if (err instanceof HngrError && err.body.type === "network") ctx.summary("warning: the live GET /me check failed; showing the stored principal");
312
+ else throw err;
313
+ }
314
+ ctx.writePayload({
315
+ source,
316
+ server: connection.server,
317
+ principal,
318
+ token_prefix: tokenPrefix
319
+ });
320
+ return 0;
321
+ }
322
+ async function whoami(ctx) {
323
+ const connection = await resolveConnection(ctx.flags, ctx.env);
324
+ ctx.writePayload(await apiGet(connection, "/api/v1/me"));
325
+ return 0;
326
+ }
327
+ export { login, logout, status, token, whoami };