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.
@@ -0,0 +1,610 @@
1
+ import { S as HngrError, b as writeFileAtomically, g as resolveConnection, s as redactSensitive, t as apiFetch } from "./http-2HWxLIOq.mjs";
2
+ import { _ as resolveCapabilityVersion, a as failedResult, c as stringValues, f as downloadContent, g as resolveCapability, h as parseCapabilityPath, i as errorBody, l as AGENTS, n as readPublishFlags, o as positionalPaths, p as MCP_KIND, r as runPublish, s as runBatch, t as explicitName, u as findAgentDefinition } from "./publish-D7zaQl9x.mjs";
3
+ import { constants } from "node:fs";
4
+ import { access, readFile, stat } from "node:fs/promises";
5
+ import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ // src/mcp/claude-cli.ts
8
+ const EXECUTABLE = "claude";
9
+ function childEnvironment(env) {
10
+ const merged = {
11
+ ...process.env,
12
+ ...env
13
+ };
14
+ for (const [key, value] of Object.entries(merged)) if (value === void 0) delete merged[key];
15
+ return merged;
16
+ }
17
+ /** Finds an executable on the PATH from `env` (falling back to this process). */
18
+ async function findExecutable(name, env) {
19
+ const searchPath = env.PATH ?? process.env.PATH ?? "";
20
+ for (const dir of searchPath.split(delimiter)) {
21
+ if (dir === "") continue;
22
+ const candidate = join(dir, name);
23
+ try {
24
+ if (!(await stat(candidate)).isFile()) continue;
25
+ await access(candidate, constants.X_OK);
26
+ return candidate;
27
+ } catch {}
28
+ }
29
+ }
30
+ function run(executable, args, env) {
31
+ return new Promise((resolve, reject) => {
32
+ const child = spawn(executable, args, {
33
+ env: childEnvironment(env),
34
+ stdio: [
35
+ "ignore",
36
+ "pipe",
37
+ "pipe"
38
+ ]
39
+ });
40
+ let stdout = "";
41
+ let stderr = "";
42
+ child.stdout.on("data", (chunk) => {
43
+ stdout += chunk.toString("utf8");
44
+ });
45
+ child.stderr.on("data", (chunk) => {
46
+ stderr += chunk.toString("utf8");
47
+ });
48
+ child.on("error", (err) => {
49
+ reject(new HngrError({
50
+ type: "io",
51
+ title: "Could not run the Claude Code CLI",
52
+ detail: err.message
53
+ }));
54
+ });
55
+ child.on("close", (code) => {
56
+ resolve({
57
+ code: code ?? -1,
58
+ stdout,
59
+ stderr
60
+ });
61
+ });
62
+ });
63
+ }
64
+ function commandFailed(command, label, result) {
65
+ return new HngrError({
66
+ type: "io",
67
+ title: `claude ${command} failed for ${JSON.stringify(label)}`,
68
+ detail: result.stderr.trim() || result.stdout.trim() || `claude exited with code ${result.code === -1 ? "unknown" : result.code}.`
69
+ });
70
+ }
71
+ async function runChecked(executable, args, env, label) {
72
+ const result = await run(executable, args, env);
73
+ if (result.code !== 0) throw commandFailed(args.slice(0, 2).join(" "), label, result);
74
+ }
75
+ /**
76
+ * Whether Claude Code already has a server at user scope. `claude mcp get`
77
+ * reports the winning scope for a name; anything other than "User config" is
78
+ * indeterminate for user scope, so it fails rather than guessing.
79
+ */
80
+ async function userServerState(executable, name, env) {
81
+ const result = await run(executable, [
82
+ "mcp",
83
+ "get",
84
+ name
85
+ ], env);
86
+ const output = `${result.stdout}\n${result.stderr}`;
87
+ if (result.code !== 0) {
88
+ if (/No MCP server named/i.test(output)) return "absent";
89
+ throw commandFailed("mcp get", name, result);
90
+ }
91
+ if (/Scope:\s*User config/i.test(output)) return "present";
92
+ throw new HngrError({
93
+ type: "io",
94
+ title: `Could not determine whether ${JSON.stringify(name)} is installed at user scope`,
95
+ detail: `\`claude mcp get\` reported a non-user scope, so hngr will not overwrite it. Run \`claude mcp get ${name}\` to inspect it, or install at project scope (without --global).`
96
+ });
97
+ }
98
+ /**
99
+ * Installs one user-scope server through Claude Code itself.
100
+ * `~/.claude.json` is never edited directly: Claude Code owns that file and may
101
+ * hold live session state. A conflicting user-scope server fails without
102
+ * `force`; with `force` it is removed through the CLI before the new entry is
103
+ * added, so every write still goes through Claude Code.
104
+ */
105
+ async function addJsonWithClaude(env, name, config, force) {
106
+ const executable = await findExecutable(EXECUTABLE, env);
107
+ if (!executable) throw new HngrError({
108
+ type: "config",
109
+ title: "Claude Code CLI not found",
110
+ detail: "`claude` must be on PATH to install user-level MCP servers; install Claude Code or use project-level (no --global) installation."
111
+ });
112
+ if (await userServerState(executable, name, env) === "present") {
113
+ if (!force) throw new HngrError({
114
+ type: "io",
115
+ title: `MCP server ${JSON.stringify(name)} already exists in Claude Code's user config`,
116
+ detail: "Pass --force to replace it."
117
+ });
118
+ await runChecked(executable, [
119
+ "mcp",
120
+ "remove",
121
+ "--scope",
122
+ "user",
123
+ name
124
+ ], env, name);
125
+ }
126
+ await runChecked(executable, [
127
+ "mcp",
128
+ "add-json",
129
+ "--scope",
130
+ "user",
131
+ name,
132
+ JSON.stringify(config)
133
+ ], env, name);
134
+ }
135
+ // src/mcp/content.ts
136
+ /** Server names Claude Code accepts as `mcpServers` keys. */
137
+ const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
138
+ const EXPECTED_SHAPE = "Expected an object shaped {\"mcpServers\": {\"<server-name>\": {\"command\": \"...\", \"args\": [...]}}}.";
139
+ function invalid(title, detail = EXPECTED_SHAPE) {
140
+ return new HngrError({
141
+ type: "api",
142
+ title,
143
+ detail
144
+ });
145
+ }
146
+ function isPlainObject$1(value) {
147
+ return value !== null && typeof value === "object" && !Array.isArray(value);
148
+ }
149
+ /**
150
+ * Validates the snapshot shape. `mcpServers` must be an object and every
151
+ * server name must match `[A-Za-z0-9_-]+`; other top-level keys are ignored.
152
+ */
153
+ function validateSnapshot(body) {
154
+ if (!isPlainObject$1(body)) throw invalid("The MCP snapshot is not a JSON object");
155
+ if (!("mcpServers" in body)) throw invalid("The MCP snapshot is missing \"mcpServers\"");
156
+ const servers = body.mcpServers;
157
+ if (!isPlainObject$1(servers)) throw invalid("The MCP snapshot's \"mcpServers\" is not an object");
158
+ for (const name of Object.keys(servers)) if (!SERVER_NAME_PATTERN.test(name)) throw invalid(`Invalid MCP server name ${JSON.stringify(name)}`, "Server names must match [A-Za-z0-9_-]+.");
159
+ return {
160
+ servers,
161
+ ignoredKeys: Object.keys(body).filter((key) => key !== "mcpServers")
162
+ };
163
+ }
164
+ /**
165
+ * Downloads an MCP snapshot as `application/json` and validates its shape.
166
+ */
167
+ async function downloadMcpSnapshot(connection, capabilityId, version, signal) {
168
+ const download = await downloadContent(connection, capabilityId, version, "application/json", signal);
169
+ let body;
170
+ try {
171
+ body = JSON.parse(download.bytes.toString("utf8"));
172
+ } catch (err) {
173
+ throw invalid("The MCP snapshot is not valid JSON", err instanceof Error ? err.message : String(err));
174
+ }
175
+ return {
176
+ ...validateSnapshot(body),
177
+ etag: download.etag
178
+ };
179
+ }
180
+ // src/mcp/merge.ts
181
+ function ioError(title, detail) {
182
+ return new HngrError({
183
+ type: "io",
184
+ title,
185
+ detail
186
+ });
187
+ }
188
+ /**
189
+ * Parses an existing target document. A missing file is an empty document;
190
+ * malformed JSON or a non-object root is an error rather than silent replacement.
191
+ */
192
+ function readTargetDocument(raw, label) {
193
+ if (raw === void 0) return {};
194
+ let parsed;
195
+ try {
196
+ parsed = JSON.parse(raw);
197
+ } catch (err) {
198
+ throw ioError(`Could not parse ${label}`, err instanceof Error ? err.message : String(err));
199
+ }
200
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw ioError(`${label} is not a JSON object`, "The target file must contain a JSON object.");
201
+ return parsed;
202
+ }
203
+ /**
204
+ * Merges the incoming servers into the document's `mcpServers`. Existing
205
+ * servers and other top-level keys survive. A name collision fails unless
206
+ * `force`, which replaces only the colliding entries.
207
+ */
208
+ function mergeSnapshot(document, incoming, options) {
209
+ const existing = document.mcpServers;
210
+ if (existing !== void 0) {
211
+ if (existing === null || typeof existing !== "object" || Array.isArray(existing)) throw ioError(`"mcpServers" in ${options.label} is not an object`, "Fix the target file before installing MCP servers.");
212
+ }
213
+ const servers = { ...existing ?? {} };
214
+ const conflicts = [];
215
+ const added = [];
216
+ const replaced = [];
217
+ for (const [name, entry] of Object.entries(incoming)) if (Object.hasOwn(servers, name)) {
218
+ if (!options.force) {
219
+ conflicts.push(name);
220
+ continue;
221
+ }
222
+ servers[name] = entry;
223
+ replaced.push(name);
224
+ } else {
225
+ servers[name] = entry;
226
+ added.push(name);
227
+ }
228
+ if (conflicts.length > 0) throw ioError(`MCP ${conflicts.length === 1 ? "server" : "servers"} already configured in ${options.label}: ${conflicts.join(", ")}`, "Pass --force to replace only the conflicting entries.");
229
+ return {
230
+ document: {
231
+ ...document,
232
+ mcpServers: servers
233
+ },
234
+ added,
235
+ replaced
236
+ };
237
+ }
238
+ /** Two-space indentation plus a trailing newline. */
239
+ function serializeTargetDocument(document) {
240
+ return `${JSON.stringify(document, null, 2)}\n`;
241
+ }
242
+ // src/mcp/resolve.ts
243
+ /** Parses `<name>` or `<namespace>/<name>`, with an optional `@YYYY.MM.DD.N`. */
244
+ function parseMcpPath(raw) {
245
+ return parseCapabilityPath(raw, MCP_KIND);
246
+ }
247
+ function resolveMcp(connection, parsed, signal) {
248
+ return resolveCapability(connection, parsed, MCP_KIND, signal);
249
+ }
250
+ /** The pinned version, or the capability's Current Version. */
251
+ function resolveMcpVersion(capability, parsed) {
252
+ return resolveCapabilityVersion(capability, parsed, MCP_KIND);
253
+ }
254
+ // src/mcp/targets.ts
255
+ /** Ids accepted by `--agent`: agents with a JSON or Claude Code CLI adapter. */
256
+ const MCP_AGENT_IDS = AGENTS.filter((agent) => agent.mcp.kind === "json" || agent.mcp.kind === "claude-cli").map((agent) => agent.id);
257
+ function usage$1(title, detail) {
258
+ return new HngrError({
259
+ type: "usage",
260
+ title,
261
+ detail
262
+ });
263
+ }
264
+ function displayPath(path, cwd) {
265
+ const rel = relative(cwd, path);
266
+ if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) return rel.split(sep).join("/");
267
+ return path;
268
+ }
269
+ /** The reason an agent's MCP format is unsupported, or undefined. */
270
+ function unsupportedReason(agent) {
271
+ const support = findAgentDefinition(agent)?.mcp;
272
+ return support?.kind === "unsupported" ? support.reason : void 0;
273
+ }
274
+ /** The adapter for one agent at one scope, or null when none exists. */
275
+ function adapterFor(agent, scope, cwd, env) {
276
+ const support = findAgentDefinition(agent)?.mcp;
277
+ if (!support) return null;
278
+ if (support.kind === "claude-cli") {
279
+ if (scope === "project") {
280
+ const filePath = join(cwd, ".mcp.json");
281
+ return {
282
+ agent,
283
+ kind: "json",
284
+ path: displayPath(filePath, cwd),
285
+ filePath
286
+ };
287
+ }
288
+ return {
289
+ agent,
290
+ kind: "claude-cli",
291
+ path: support.configPath(env),
292
+ filePath: null
293
+ };
294
+ }
295
+ if (support.kind !== "json") return null;
296
+ if (scope === "project") {
297
+ if (support.project === null) return null;
298
+ const filePath = join(cwd, support.project);
299
+ return {
300
+ agent,
301
+ kind: "json",
302
+ path: displayPath(filePath, cwd),
303
+ filePath
304
+ };
305
+ }
306
+ if (support.global === null) return null;
307
+ const filePath = support.global(env);
308
+ return {
309
+ agent,
310
+ kind: "json",
311
+ path: filePath,
312
+ filePath
313
+ };
314
+ }
315
+ function unimplemented(agent, scope) {
316
+ if (agent === "devin" && scope === "project") return usage$1("Devin does not support project-level MCP configuration", "Devin's project-level MCP config is not documented; only the user-level ~/.config/devin/mcp_config.json is supported. Use --global.");
317
+ return usage$1(`MCP installation for ${agent} is not supported`, `Supported MCP targets: ${MCP_AGENT_IDS.join(", ")}.`);
318
+ }
319
+ function unsupportedFormatError(agents) {
320
+ const reasons = agents.map((agent) => `${agent}: ${unsupportedReason(agent) ?? ""}`);
321
+ return usage$1(`MCP installation for ${agents.join(", ")} is not supported`, `${reasons.join(" ")} Supported MCP targets: ${MCP_AGENT_IDS.join(", ")}.`);
322
+ }
323
+ /**
324
+ * Resolves `--agent` / `--all` into concrete targets. `--all` selects every
325
+ * agent that has an adapter for the requested scope, so project scope skips
326
+ * Devin. Unknown ids, known formats with another shape, and documented-but-
327
+ * unsupported scopes are usage errors and nothing is written.
328
+ */
329
+ function selectMcpTargets(requested, all, scope, cwd, env) {
330
+ if (requested.length > 0) {
331
+ const unsupported = [...new Set(requested)].filter((id) => unsupportedReason(id) !== void 0);
332
+ if (unsupported.length > 0) throw unsupportedFormatError(unsupported);
333
+ const known = new Set(MCP_AGENT_IDS);
334
+ const unknown = requested.filter((id) => !known.has(id));
335
+ if (unknown.length > 0) throw usage$1(`Unknown agent target: ${unknown.join(", ")}`, `Known targets: ${MCP_AGENT_IDS.join(", ")}.`);
336
+ return [...new Set(requested)].map((id) => {
337
+ const adapter = adapterFor(id, scope, cwd, env);
338
+ if (!adapter) throw unimplemented(id, scope);
339
+ return adapter;
340
+ });
341
+ }
342
+ if (all) {
343
+ const plans = [];
344
+ for (const id of MCP_AGENT_IDS) {
345
+ const adapter = adapterFor(id, scope, cwd, env);
346
+ if (adapter) plans.push(adapter);
347
+ }
348
+ if (plans.length === 0) throw usage$1("No MCP target is available for this scope");
349
+ return plans;
350
+ }
351
+ throw usage$1("No agent target selected", "Pass --agent <id> (repeatable) or --all. For example: hngr mcp add pdf --agent claude-code");
352
+ }
353
+ // src/commands/mcp.ts
354
+ function usage(title, detail) {
355
+ return new HngrError({
356
+ type: "usage",
357
+ title,
358
+ detail
359
+ });
360
+ }
361
+ function failureResult(parsed, err, version) {
362
+ return {
363
+ servers: [],
364
+ ...failedResult(parsed, err, version)
365
+ };
366
+ }
367
+ async function readExisting(path, label) {
368
+ try {
369
+ return await readFile(path, "utf8");
370
+ } catch (err) {
371
+ if (err.code === "ENOENT") return;
372
+ throw new HngrError({
373
+ type: "io",
374
+ title: `Could not read ${label}`,
375
+ detail: err instanceof Error ? err.message : String(err)
376
+ });
377
+ }
378
+ }
379
+ /**
380
+ * Dry-run output goes to stderr, so credentials inside server entries are
381
+ * redacted. The file is never written by dry-run, so this only affects the
382
+ * preview.
383
+ */
384
+ function previewJsonTarget(plan, existing, servers, force) {
385
+ const merged = mergeSnapshot(readTargetDocument(existing, plan.path), servers, {
386
+ force,
387
+ label: plan.path
388
+ });
389
+ return serializeTargetDocument(redactSensitive(merged.document));
390
+ }
391
+ /** Reports one target's failure without aborting the remaining targets. */
392
+ function reportTargetFailure(ctx, plan, err) {
393
+ const body = errorBody(err);
394
+ ctx.summary(`error: ${plan.agent} ${plan.path}: ${body.title}`);
395
+ }
396
+ async function installJsonTarget(plan, servers, force) {
397
+ const filePath = plan.filePath;
398
+ if (!filePath) throw new HngrError({
399
+ type: "io",
400
+ title: `No file path configured for ${plan.agent}`
401
+ });
402
+ const merged = mergeSnapshot(readTargetDocument(await readExisting(filePath, plan.path), plan.path), servers, {
403
+ force,
404
+ label: plan.path
405
+ });
406
+ await writeFileAtomically(filePath, serializeTargetDocument(merged.document), { label: plan.path });
407
+ }
408
+ async function addOne(ctx, connection, parsed, targets, state) {
409
+ const capability = await resolveMcp(connection, parsed);
410
+ const version = resolveMcpVersion(capability, parsed);
411
+ state.version = version;
412
+ const snapshot = await downloadMcpSnapshot(connection, capability.id, version);
413
+ if (snapshot.ignoredKeys.length > 0) ctx.summary(`warning: ignoring unsupported top-level keys in the MCP snapshot: ${snapshot.ignoredKeys.join(", ")}`);
414
+ const serverNames = Object.keys(snapshot.servers);
415
+ const etag = snapshot.etag ? ` (ETag ${snapshot.etag})` : "";
416
+ ctx.summary(`resolved ${parsed.namespace}/${parsed.name} to version ${version}${etag}`);
417
+ if (ctx.flags.dryRun) {
418
+ const dryTargets = [];
419
+ let failure = null;
420
+ for (const plan of targets) try {
421
+ if (plan.kind === "json") {
422
+ const preview = previewJsonTarget(plan, plan.filePath ? await readExisting(plan.filePath, plan.path) : void 0, snapshot.servers, ctx.flags.force);
423
+ ctx.detail(`dry-run: would write ${plan.path}\n${preview}`);
424
+ } else for (const [name, entry] of Object.entries(snapshot.servers)) ctx.detail(`dry-run: claude mcp add-json --scope user ${name} ${JSON.stringify(redactSensitive(entry))}`);
425
+ dryTargets.push({
426
+ agent: plan.agent,
427
+ path: plan.path,
428
+ status: "dry-run"
429
+ });
430
+ } catch (err) {
431
+ failure ??= err;
432
+ dryTargets.push({
433
+ agent: plan.agent,
434
+ path: plan.path,
435
+ status: "failed"
436
+ });
437
+ reportTargetFailure(ctx, plan, err);
438
+ }
439
+ return {
440
+ name: capability.name,
441
+ namespace: capability.namespace,
442
+ version,
443
+ status: failure ? "failed" : "dry-run",
444
+ servers: serverNames,
445
+ targets: dryTargets,
446
+ error: failure ? errorBody(failure) : null
447
+ };
448
+ }
449
+ const installedTargets = [];
450
+ let failure = null;
451
+ for (const plan of targets) try {
452
+ if (plan.kind === "json") await installJsonTarget(plan, snapshot.servers, ctx.flags.force);
453
+ else for (const [name, entry] of Object.entries(snapshot.servers)) await addJsonWithClaude(ctx.env, name, entry, ctx.flags.force);
454
+ installedTargets.push({
455
+ agent: plan.agent,
456
+ path: plan.path,
457
+ status: "installed"
458
+ });
459
+ } catch (err) {
460
+ failure ??= err;
461
+ installedTargets.push({
462
+ agent: plan.agent,
463
+ path: plan.path,
464
+ status: "failed"
465
+ });
466
+ reportTargetFailure(ctx, plan, err);
467
+ }
468
+ const verb = failure ? "partially installed" : "installed";
469
+ ctx.progress(`${verb} ${capability.namespace}/${capability.name}@${version} (${serverNames.join(", ")})`);
470
+ for (const target of installedTargets) ctx.progress(target.status === "failed" ? ` failed ${target.agent} ${target.path}` : ` ${target.agent} ${target.path}`);
471
+ return {
472
+ name: capability.name,
473
+ namespace: capability.namespace,
474
+ version,
475
+ status: failure ? "failed" : "installed",
476
+ servers: serverNames,
477
+ targets: installedTargets,
478
+ error: failure ? errorBody(failure) : null
479
+ };
480
+ }
481
+ const CAPABILITIES_PATH = "/api/v1/capabilities";
482
+ const SNAPSHOT_SHAPE = "Expected an object shaped {\"mcpServers\": {\"<server-name>\": {\"command\": \"...\", \"args\": [...]}}}.";
483
+ function isPlainObject(value) {
484
+ return value !== null && typeof value === "object" && !Array.isArray(value);
485
+ }
486
+ /** Reads and shape-checks a local snapshot; the whole object becomes `config`. */
487
+ async function readSnapshot(file) {
488
+ let raw;
489
+ try {
490
+ raw = await readFile(resolve(file), "utf8");
491
+ } catch (err) {
492
+ throw new HngrError({
493
+ type: "io",
494
+ title: `Could not read ${file}`,
495
+ detail: err instanceof Error ? err.message : String(err)
496
+ });
497
+ }
498
+ let body;
499
+ try {
500
+ body = JSON.parse(raw);
501
+ } catch (err) {
502
+ throw usage("The MCP snapshot is not valid JSON", err instanceof Error ? err.message : String(err));
503
+ }
504
+ if (!isPlainObject(body)) throw usage("The MCP snapshot is not a JSON object", SNAPSHOT_SHAPE);
505
+ if (!("mcpServers" in body)) throw usage("The MCP snapshot is missing \"mcpServers\"", SNAPSHOT_SHAPE);
506
+ if (!isPlainObject(body.mcpServers)) throw usage("The MCP snapshot's \"mcpServers\" is not an object", SNAPSHOT_SHAPE);
507
+ return body;
508
+ }
509
+ /** The MCP server that `mcp publish` sends as the JSON `config` field. */
510
+ function mcpPrepared(config, metadata) {
511
+ const serverCount = Object.keys(config.mcpServers).length;
512
+ const createBody = () => {
513
+ const body = {
514
+ namespace: metadata.namespace,
515
+ name: metadata.name,
516
+ description: metadata.description ?? "",
517
+ visibility: metadata.visibility
518
+ };
519
+ if (metadata.readme !== void 0) body.readme = metadata.readme;
520
+ if (metadata.tags.length > 0) body.tags = metadata.tags;
521
+ body.config = config;
522
+ return body;
523
+ };
524
+ const versionBody = () => {
525
+ const body = {};
526
+ if (metadata.readme !== void 0) body.readme = metadata.readme;
527
+ body.config = config;
528
+ return body;
529
+ };
530
+ return {
531
+ preview() {
532
+ const preview = {
533
+ namespace: metadata.namespace,
534
+ name: metadata.name
535
+ };
536
+ if (metadata.description !== void 0) preview.description = metadata.description;
537
+ preview.visibility = metadata.visibility;
538
+ if (metadata.readme !== void 0) preview.readme = metadata.readme;
539
+ if (metadata.tags.length > 0) preview.tags = metadata.tags;
540
+ preview.config = `<${serverCount} server${serverCount === 1 ? "" : "s"}>`;
541
+ return preview;
542
+ },
543
+ async create(connection) {
544
+ return (await apiFetch(connection, {
545
+ method: "POST",
546
+ path: CAPABILITIES_PATH,
547
+ body: createBody()
548
+ })).json();
549
+ },
550
+ async version(connection, capabilityId) {
551
+ const path = `${CAPABILITIES_PATH}/${encodeURIComponent(capabilityId)}/versions`;
552
+ return (await apiFetch(connection, {
553
+ method: "POST",
554
+ path,
555
+ body: versionBody()
556
+ })).json();
557
+ }
558
+ };
559
+ }
560
+ function defaultMcpName(file) {
561
+ const base = basename(resolve(file));
562
+ return base.toLowerCase().endsWith(".json") ? base.slice(0, -5) : base;
563
+ }
564
+ /**
565
+ * Publishes MCP snapshots. A file that is not valid JSON, or lacks an
566
+ * `mcpServers` object, fails before the capability lookup; an existing
567
+ * capability receives a new version unless --new is set.
568
+ */
569
+ async function publish(ctx, argv) {
570
+ const rawPaths = positionalPaths(argv.paths);
571
+ if (rawPaths.length === 0) throw usage("No MCP snapshot given", "Pass one or more JSON snapshot files.");
572
+ const flags = await readPublishFlags(argv, "mcp");
573
+ const forcedName = explicitName(argv, rawPaths.length);
574
+ const items = rawPaths.map((file) => ({
575
+ name: forcedName ?? defaultMcpName(file),
576
+ prepare: async (metadata) => mcpPrepared(await readSnapshot(file), metadata)
577
+ }));
578
+ return runPublish({
579
+ ctx,
580
+ kind: "mcp",
581
+ info: MCP_KIND,
582
+ ...flags,
583
+ items
584
+ });
585
+ }
586
+ async function add(ctx, argv) {
587
+ const rawPaths = positionalPaths(argv.paths);
588
+ if (rawPaths.length === 0) throw usage("No capability path given", "Pass one or more `<name>` or `<namespace>/<name>` arguments.");
589
+ const parsedPaths = rawPaths.map(parseMcpPath);
590
+ const scope = argv.global === true ? "global" : "project";
591
+ const targets = selectMcpTargets(stringValues(argv.agent), argv.all === true, scope, process.cwd(), ctx.env);
592
+ const connection = await resolveConnection(ctx.flags, ctx.env);
593
+ let approvalHintShown = false;
594
+ const { results, failed } = await runBatch({
595
+ ctx,
596
+ paths: parsedPaths,
597
+ run: (parsed, state) => addOne(ctx, connection, parsed, targets, state),
598
+ failure: failureResult,
599
+ onResult: (result) => {
600
+ if (approvalHintShown) return;
601
+ if (result.targets.some((target) => target.agent === "claude-code" && target.status === "installed") && scope === "project") {
602
+ ctx.summary("note: Claude Code will ask you to approve these MCP servers on first interactive use.");
603
+ approvalHintShown = true;
604
+ }
605
+ }
606
+ });
607
+ ctx.writePayload({ results });
608
+ return failed ? 1 : 0;
609
+ }
610
+ export { add, publish };
@@ -0,0 +1,32 @@
1
+ import { S as HngrError } from "./http-2HWxLIOq.mjs";
2
+ // src/pagination.ts
3
+ function asCursorPage(body, path) {
4
+ if (body === null || typeof body !== "object" || !Array.isArray(body.items) || !("next_cursor" in body)) throw new HngrError({
5
+ type: "api",
6
+ title: "The server returned an unexpected response shape",
7
+ detail: `Expected {items: [...], next_cursor: ...} from GET ${path}.`
8
+ });
9
+ return body;
10
+ }
11
+ /**
12
+ * Reads a cursor-paginated collection to the end and returns every item in
13
+ * server order. `fetchPage` receives the cursor to send (`undefined` for the
14
+ * first page); a server that repeats a cursor would loop forever, so it is an
15
+ * error instead.
16
+ */
17
+ async function collectPages(path, fetchPage) {
18
+ const items = [];
19
+ let cursor;
20
+ for (;;) {
21
+ const page = asCursorPage(await fetchPage(cursor), path);
22
+ items.push(...page.items);
23
+ if (page.next_cursor === null) return items;
24
+ if (page.next_cursor === cursor) throw new HngrError({
25
+ type: "api",
26
+ title: "Pagination did not advance",
27
+ detail: `GET ${path} returned the same next_cursor twice.`
28
+ });
29
+ cursor = page.next_cursor;
30
+ }
31
+ }
32
+ export { collectPages as t };