hngr 0.5.0 → 0.6.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
@@ -59,7 +59,15 @@ install replaces the existing target; `--force` is not needed.
59
59
 
60
60
  Interactive runs end with a borderless summary on stderr (canonical path, universal
61
61
  agents, symlinked/copied agents); stdout carries the JSON payload only when piped or when
62
- `--json` is passed.
62
+ `--json` is passed. After a successful interactive install the CLI asks once whether to
63
+ also install `system/find-capabilities` (the catalog's discovery skill, which finds both
64
+ Skills and MCP servers) for the same scope and agents; it is skipped silently when a copy
65
+ already exists in either scope, and a failed follow-up install only prints a warning.
66
+
67
+ MCP installs ask for the same scope and then for the MCP targets to write, with an
68
+ `All targets` entry; targets whose config already exists are preselected, and Claude
69
+ Code's `.mcp.json` is preselected for project installs. Without a terminal, pass
70
+ `--agent` (repeatable) or `--all`.
63
71
 
64
72
  ## Publish capabilities
65
73
 
@@ -50,6 +50,8 @@ const EXCLUDED_PATHS = [
50
50
  "/api/v1/auth/device/lookup",
51
51
  "/api/v1/auth/device/approve",
52
52
  "/api/v1/auth/device/deny",
53
+ "/api/v1/teams/{team_id}/invitations",
54
+ "/api/v1/team-invitations",
53
55
  "/api/v1/oauth"
54
56
  ];
55
57
  /**
@@ -1816,7 +1818,10 @@ const COMMANDS = deriveCommands({
1816
1818
  "team.deleted",
1817
1819
  "team.member_added",
1818
1820
  "team.member_role_changed",
1819
- "team.member_removed"
1821
+ "team.member_removed",
1822
+ "team.invitation_created",
1823
+ "team.invitation_revoked",
1824
+ "team.invitation_accepted"
1820
1825
  ]
1821
1826
  }
1822
1827
  },
@@ -2094,6 +2099,125 @@ const COMMANDS = deriveCommands({
2094
2099
  }
2095
2100
  }
2096
2101
  },
2102
+ "/api/v1/teams/{team_id}/invitations": {
2103
+ "parameters": [{ "$ref": "#/components/parameters/TeamID" }],
2104
+ "post": {
2105
+ "operationId": "createTeamInvitation",
2106
+ "summary": "Create an invitation link for a Team.",
2107
+ "description": "Allowed for an effective Team Admin and for a Platform Admin of an\nenabled Team. The Team must be enabled; a disabled Team answers 409 for\nevery caller, Platform Admins included. The invitation always adds\nmembers as `member`: the request carries no role and no use limit. The\nresponse is the only time the plaintext `token` and the absolute `url`\nare returned; the platform stores only the token's keyed digest and\nlater lists expose only `token_prefix`. `expires_in_days` defaults to 7\nand must be between 1 and 30.\n",
2108
+ "security": [{ "sessionCookie": [] }, { "bearerToken": [] }],
2109
+ "requestBody": {
2110
+ "required": true,
2111
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateTeamInvitationRequest" } } }
2112
+ },
2113
+ "responses": {
2114
+ "201": {
2115
+ "description": "The invitation and the one-time token and link.",
2116
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IssuedTeamInvitation" } } }
2117
+ },
2118
+ "400": { "$ref": "#/components/responses/Problem" },
2119
+ "401": { "$ref": "#/components/responses/Problem" },
2120
+ "403": { "$ref": "#/components/responses/Problem" },
2121
+ "404": { "$ref": "#/components/responses/Problem" },
2122
+ "409": { "$ref": "#/components/responses/Problem" },
2123
+ "default": { "$ref": "#/components/responses/Problem" }
2124
+ }
2125
+ },
2126
+ "get": {
2127
+ "operationId": "listTeamInvitations",
2128
+ "summary": "List a Team's invitation links, newest first.",
2129
+ "description": "Allowed for an effective Team Admin and for a Platform Admin. A\ndisabled Team stays readable and its invitations stay revocable.\nUnknown and deleted Teams answer 404; a caller who does not govern the\nTeam answers 403. The list\nnever contains the token or its digest, only `token_prefix`; `uses` is\nthe number of Users the link has actually added. `state` is derived by\nthe server with priority revoked, then expired, then active. The list\nis ordered by `created_at` then `id`, both descending, and paged by\ncursor without a total.\n",
2130
+ "security": [{ "sessionCookie": [] }, { "bearerToken": [] }],
2131
+ "parameters": [{ "$ref": "#/components/parameters/Cursor" }, { "$ref": "#/components/parameters/Limit" }],
2132
+ "responses": {
2133
+ "200": {
2134
+ "description": "One page of invitations.",
2135
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamInvitationList" } } }
2136
+ },
2137
+ "400": { "$ref": "#/components/responses/Problem" },
2138
+ "401": { "$ref": "#/components/responses/Problem" },
2139
+ "403": { "$ref": "#/components/responses/Problem" },
2140
+ "404": { "$ref": "#/components/responses/Problem" },
2141
+ "default": { "$ref": "#/components/responses/Problem" }
2142
+ }
2143
+ }
2144
+ },
2145
+ "/api/v1/teams/{team_id}/invitations/{invitation_id}": {
2146
+ "parameters": [{ "$ref": "#/components/parameters/TeamID" }, { "$ref": "#/components/parameters/InvitationID" }],
2147
+ "delete": {
2148
+ "operationId": "revokeTeamInvitation",
2149
+ "summary": "Revoke an invitation link.",
2150
+ "description": "Allowed for an effective Team Admin and for a Platform Admin, also\nwhile the Team is disabled: revoking changes no membership. Revoking an\nalready-revoked invitation answers 204 without changing `revoked_at`. An\ninvitation of another Team, or an unknown one, answers 404.\n",
2151
+ "security": [{ "sessionCookie": [] }, { "bearerToken": [] }],
2152
+ "responses": {
2153
+ "204": { "description": "The invitation is revoked." },
2154
+ "400": { "$ref": "#/components/responses/Problem" },
2155
+ "401": { "$ref": "#/components/responses/Problem" },
2156
+ "403": { "$ref": "#/components/responses/Problem" },
2157
+ "404": { "$ref": "#/components/responses/Problem" },
2158
+ "default": { "$ref": "#/components/responses/Problem" }
2159
+ }
2160
+ }
2161
+ },
2162
+ "/api/v1/team-invitations/preview": { "post": {
2163
+ "operationId": "previewTeamInvitation",
2164
+ "summary": "Preview an invitation link without joining.",
2165
+ "description": "Anonymous. Resolves the token to the Team it invites to, without\nrevealing the roster or anything else about the Team. A live Team\nanswers 200 even when the invitation is expired or revoked, with\n`state` carrying the reason; the Web page explains it. An unknown\ntoken answers 404, and a Team that has been deleted makes its links\nunknown too. `already_member` is resolved from the Session cookie when\nthe caller is signed in and is always false for an anonymous caller.\nThe endpoint is rate-limited per client address and every response is\n`no-store`.\n",
2166
+ "security": [],
2167
+ "requestBody": {
2168
+ "required": true,
2169
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamInvitationToken" } } }
2170
+ },
2171
+ "responses": {
2172
+ "200": {
2173
+ "description": "The Team and the invitation's derived state.",
2174
+ "headers": { "Cache-Control": {
2175
+ "description": "Always `no-store`.",
2176
+ "schema": { "type": "string" }
2177
+ } },
2178
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamInvitationPreview" } } }
2179
+ },
2180
+ "400": { "$ref": "#/components/responses/Problem" },
2181
+ "404": { "$ref": "#/components/responses/Problem" },
2182
+ "429": { "$ref": "#/components/responses/Problem" },
2183
+ "default": { "$ref": "#/components/responses/Problem" }
2184
+ }
2185
+ } },
2186
+ "/api/v1/team-invitations/accept": { "post": {
2187
+ "operationId": "acceptTeamInvitation",
2188
+ "summary": "Join a Team through an invitation link.",
2189
+ "description": "Session-only: a Bearer Access Token is never accepted, so an Agent\ncannot join through a link. Membership is always created with the\n`member` role; the request carries no role. A new membership answers\n201 and an existing one answers 200 without changing any role, which\nmakes replay safe. Both answer with the same `TeamMember` shape as\n`putTeamMember`. An unknown token or a deleted Team answers 404; an\nexpired or revoked invitation, or a disabled Team, answers 409.\n`uses` increments only when a membership is actually inserted, and the\nendpoint is rate-limited per client address. Every response is\n`no-store`.\n",
2190
+ "security": [{ "sessionCookie": [] }],
2191
+ "requestBody": {
2192
+ "required": true,
2193
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamInvitationToken" } } }
2194
+ },
2195
+ "responses": {
2196
+ "200": {
2197
+ "description": "The caller was already a member; nothing changed.",
2198
+ "headers": { "Cache-Control": {
2199
+ "description": "Always `no-store`.",
2200
+ "schema": { "type": "string" }
2201
+ } },
2202
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamMember" } } }
2203
+ },
2204
+ "201": {
2205
+ "description": "The caller was added to the Team as a member.",
2206
+ "headers": { "Cache-Control": {
2207
+ "description": "Always `no-store`.",
2208
+ "schema": { "type": "string" }
2209
+ } },
2210
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamMember" } } }
2211
+ },
2212
+ "400": { "$ref": "#/components/responses/Problem" },
2213
+ "401": { "$ref": "#/components/responses/Problem" },
2214
+ "403": { "$ref": "#/components/responses/Problem" },
2215
+ "404": { "$ref": "#/components/responses/Problem" },
2216
+ "409": { "$ref": "#/components/responses/Problem" },
2217
+ "429": { "$ref": "#/components/responses/Problem" },
2218
+ "default": { "$ref": "#/components/responses/Problem" }
2219
+ }
2220
+ } },
2097
2221
  "/api/v1/teams/{team_id}/members/{principal_id}": {
2098
2222
  "parameters": [{ "$ref": "#/components/parameters/TeamID" }, { "$ref": "#/components/parameters/PrincipalID" }],
2099
2223
  "put": {
@@ -2208,6 +2332,15 @@ const COMMANDS = deriveCommands({
2208
2332
  "format": "uuid"
2209
2333
  }
2210
2334
  },
2335
+ "InvitationID": {
2336
+ "name": "invitation_id",
2337
+ "in": "path",
2338
+ "required": true,
2339
+ "schema": {
2340
+ "type": "string",
2341
+ "format": "uuid"
2342
+ }
2343
+ },
2211
2344
  "TokenID": {
2212
2345
  "name": "token_id",
2213
2346
  "in": "path",
@@ -3854,6 +3987,141 @@ const COMMANDS = deriveCommands({
3854
3987
  "next_cursor": { "$ref": "#/components/schemas/NextCursor" }
3855
3988
  }
3856
3989
  },
3990
+ "TeamInvitationState": {
3991
+ "type": "string",
3992
+ "enum": [
3993
+ "active",
3994
+ "expired",
3995
+ "revoked"
3996
+ ],
3997
+ "description": "Derived by the server; revoked wins over expired, which wins over active."
3998
+ },
3999
+ "TeamInvitation": {
4000
+ "type": "object",
4001
+ "required": [
4002
+ "id",
4003
+ "token_prefix",
4004
+ "uses",
4005
+ "expires_at",
4006
+ "revoked_at",
4007
+ "state",
4008
+ "created_at",
4009
+ "created_by"
4010
+ ],
4011
+ "properties": {
4012
+ "id": {
4013
+ "type": "string",
4014
+ "format": "uuid"
4015
+ },
4016
+ "token_prefix": {
4017
+ "type": "string",
4018
+ "description": "The `hngi_` scheme plus the first 8 random characters, for recognising the link in a list."
4019
+ },
4020
+ "uses": {
4021
+ "type": "integer",
4022
+ "description": "How many Users the link has actually added to the Team."
4023
+ },
4024
+ "expires_at": {
4025
+ "type": "string",
4026
+ "format": "date-time"
4027
+ },
4028
+ "revoked_at": {
4029
+ "type": ["string", "null"],
4030
+ "format": "date-time"
4031
+ },
4032
+ "state": { "$ref": "#/components/schemas/TeamInvitationState" },
4033
+ "created_at": {
4034
+ "type": "string",
4035
+ "format": "date-time"
4036
+ },
4037
+ "created_by": {
4038
+ "type": ["string", "null"],
4039
+ "format": "uuid"
4040
+ }
4041
+ }
4042
+ },
4043
+ "TeamInvitationList": {
4044
+ "type": "object",
4045
+ "required": ["items", "next_cursor"],
4046
+ "properties": {
4047
+ "items": {
4048
+ "type": "array",
4049
+ "items": { "$ref": "#/components/schemas/TeamInvitation" }
4050
+ },
4051
+ "next_cursor": { "$ref": "#/components/schemas/NextCursor" }
4052
+ }
4053
+ },
4054
+ "IssuedTeamInvitation": {
4055
+ "description": "The TeamInvitation plus the plaintext token and link, returned only once.",
4056
+ "allOf": [{ "$ref": "#/components/schemas/TeamInvitation" }, {
4057
+ "type": "object",
4058
+ "required": ["token", "url"],
4059
+ "properties": {
4060
+ "token": { "type": "string" },
4061
+ "url": { "type": "string" }
4062
+ }
4063
+ }]
4064
+ },
4065
+ "CreateTeamInvitationRequest": {
4066
+ "type": "object",
4067
+ "properties": { "expires_in_days": {
4068
+ "type": "integer",
4069
+ "minimum": 1,
4070
+ "maximum": 30,
4071
+ "default": 7
4072
+ } },
4073
+ "additionalProperties": false
4074
+ },
4075
+ "TeamInvitationToken": {
4076
+ "type": "object",
4077
+ "description": "The opaque token a join link carries. It travels in the body, never in a path or query.",
4078
+ "required": ["token"],
4079
+ "properties": { "token": {
4080
+ "type": "string",
4081
+ "minLength": 1,
4082
+ "maxLength": 128
4083
+ } },
4084
+ "additionalProperties": false
4085
+ },
4086
+ "TeamInvitationPreview": {
4087
+ "type": "object",
4088
+ "description": "What a join page may show before the visitor acts. It never carries\nthe roster or the token. `already_member` is false for anonymous\ncallers. `state` is derived on the server with priority revoked, then\nexpired, then active.\n",
4089
+ "required": [
4090
+ "team",
4091
+ "state",
4092
+ "expires_at",
4093
+ "team_disabled",
4094
+ "already_member"
4095
+ ],
4096
+ "properties": {
4097
+ "team": {
4098
+ "type": "object",
4099
+ "required": [
4100
+ "id",
4101
+ "name",
4102
+ "display_name"
4103
+ ],
4104
+ "properties": {
4105
+ "id": {
4106
+ "type": "string",
4107
+ "format": "uuid"
4108
+ },
4109
+ "name": {
4110
+ "type": "string",
4111
+ "description": "The Team's Namespace name."
4112
+ },
4113
+ "display_name": { "type": ["string", "null"] }
4114
+ }
4115
+ },
4116
+ "state": { "$ref": "#/components/schemas/TeamInvitationState" },
4117
+ "expires_at": {
4118
+ "type": "string",
4119
+ "format": "date-time"
4120
+ },
4121
+ "team_disabled": { "type": "boolean" },
4122
+ "already_member": { "type": "boolean" }
4123
+ }
4124
+ },
3857
4125
  "CalVer": {
3858
4126
  "type": "string",
3859
4127
  "description": "`YYYY.MM.DD.N` in UTC; N counts from 0 per Capability per day.",
@@ -1,4 +1,4 @@
1
- import { t as commandCatalog } from "./catalog-Bv304SXJ.mjs";
1
+ import { t as commandCatalog } from "./catalog-r53AMymQ.mjs";
2
2
  // src/commands/commands.ts
3
3
  function run(ctx) {
4
4
  const catalog = commandCatalog();
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as kebabCase, i as normalizeCommandName, n as COMMANDS, o as COMMAND_ALIASES, r as findCommand, t as commandCatalog } from "./catalog-Bv304SXJ.mjs";
1
+ import { a as kebabCase, i as normalizeCommandName, n as COMMANDS, o as COMMAND_ALIASES, r as findCommand, t as commandCatalog } from "./catalog-r53AMymQ.mjs";
2
2
  import { C as HngrError, S as CancelledError, c as redactUrlForDisplay, g as resolveConnection, i as buildRequestUrl, o as redactQuery, r as apiRequest, s as redactSensitive, u as DEFAULT_SERVER_URL, w as renderError } from "./http-DgHX7Fux.mjs";
3
3
  import { t as collectPages } from "./pagination-BXyFxGYA.mjs";
4
4
  import { createRequire } from "node:module";
@@ -5279,7 +5279,7 @@ function isYargsInstance(y) {
5279
5279
  // ../../node_modules/.pnpm/yargs@18.2.0/node_modules/yargs/index.mjs
5280
5280
  const Yargs = YargsFactory(esm_default);
5281
5281
  // package.json
5282
- var version = "0.5.0";
5282
+ var version = "0.6.0";
5283
5283
  // src/generated/completion.ts
5284
5284
  function isCompletionShell(value) {
5285
5285
  return value === "bash" || value === "zsh" || value === "fish";
@@ -6429,6 +6429,23 @@ var y = class {
6429
6429
  }
6430
6430
  }
6431
6431
  };
6432
+ var r = class extends y {
6433
+ get cursor() {
6434
+ return this.value ? 0 : 1;
6435
+ }
6436
+ get _value() {
6437
+ return this.cursor === 0;
6438
+ }
6439
+ constructor(t) {
6440
+ super(t, false), this.value = !!t.initialValue, this.on("userInput", () => {
6441
+ this.value = this._value;
6442
+ }), this.on("confirm", (i) => {
6443
+ this.output.write(import_src.cursor.move(0, -1)), this.value = i, this.state = "submit", this.close();
6444
+ }), this.on("cursor", () => {
6445
+ this.value = !this.value;
6446
+ });
6447
+ }
6448
+ };
6432
6449
  var a = class extends y {
6433
6450
  options;
6434
6451
  cursor = 0;
@@ -6633,6 +6650,35 @@ const limitOptions = ({ cursor: l, options: e, style: w, output: p = process.std
6633
6650
  for (const t of s) for (const n of t) x.push(n);
6634
6651
  return c && x.push(M), x;
6635
6652
  };
6653
+ const confirm = (e) => {
6654
+ const a = e.active ?? "Yes", o = e.inactive ?? "No";
6655
+ return new r({
6656
+ active: a,
6657
+ inactive: o,
6658
+ signal: e.signal,
6659
+ input: e.input,
6660
+ output: e.output,
6661
+ initialValue: e.initialValue ?? true,
6662
+ render() {
6663
+ const i = e.withGuide ?? settings.withGuide, u = `${symbol(this.state)} `, l = i ? `${styleText("gray", S_BAR)} ` : "", f = wrapTextWithPrefix(e.output, e.message, l, u), s = `${i ? `${styleText("gray", S_BAR)}
6664
+ ` : ""}${f}
6665
+ `, c = this.value ? a : o;
6666
+ switch (this.state) {
6667
+ case "submit": return `${s}${i ? `${styleText("gray", S_BAR)} ` : ""}${styleText("dim", c)}`;
6668
+ case "cancel": return `${s}${i ? `${styleText("gray", S_BAR)} ` : ""}${styleText(["strikethrough", "dim"], c)}${i ? `
6669
+ ${styleText("gray", S_BAR)}` : ""}`;
6670
+ default: {
6671
+ const r = i ? `${styleText("cyan", S_BAR)} ` : "", g = i ? styleText("cyan", S_BAR_END) : "";
6672
+ return `${s}${r}${this.value ? `${styleText("green", S_RADIO_ACTIVE)} ${a}` : `${styleText("dim", S_RADIO_INACTIVE)} ${styleText("dim", a)}`}${e.vertical ? i ? `
6673
+ ${styleText("cyan", S_BAR)} ` : `
6674
+ ` : ` ${styleText("dim", "/")} `}${this.value ? `${styleText("dim", S_RADIO_INACTIVE)} ${styleText("dim", o)}` : `${styleText("green", S_RADIO_ACTIVE)} ${o}`}
6675
+ ${g}
6676
+ `;
6677
+ }
6678
+ }
6679
+ }
6680
+ }).prompt();
6681
+ };
6636
6682
  const MULTISELECT_INSTRUCTIONS = [
6637
6683
  `${styleText("dim", "↑/↓")} to navigate`,
6638
6684
  `${styleText("dim", "Space:")} select`,
@@ -6905,6 +6951,14 @@ function clackPrompter(streams = {}) {
6905
6951
  initialValue
6906
6952
  }));
6907
6953
  },
6954
+ async confirm(message, initialValue = true) {
6955
+ return unwrapPrompt(await confirm({
6956
+ message,
6957
+ input,
6958
+ output,
6959
+ initialValue
6960
+ }));
6961
+ },
6908
6962
  async multiselect(message, options, initialValues = [], hint) {
6909
6963
  if (hint) output.write(`${import_picocolors.default.dim(hint)}\n`);
6910
6964
  return unwrapPrompt(await multiselect({
@@ -7021,7 +7075,7 @@ function buildParser(onMatched) {
7021
7075
  parser.command("commands", "Print the command catalog", () => {}, (argv) => {
7022
7076
  onMatched({
7023
7077
  argv,
7024
- run: async (ctx) => (await import("./commands-CLqLwQHY.mjs")).run(ctx)
7078
+ run: async (ctx) => (await import("./commands-_R93Y2z-.mjs")).run(ctx)
7025
7079
  });
7026
7080
  });
7027
7081
  parser.command("auth", "Manage credentials", (auth) => {
@@ -7101,7 +7155,7 @@ function buildParser(onMatched) {
7101
7155
  }), (argv) => {
7102
7156
  onMatched({
7103
7157
  argv,
7104
- run: async (ctx, matchedArgv) => (await import("./skills-ZFmFyZtw.mjs")).add(ctx, matchedArgv)
7158
+ run: async (ctx, matchedArgv) => (await import("./skills-B92ip3mJ.mjs")).add(ctx, matchedArgv)
7105
7159
  });
7106
7160
  });
7107
7161
  skills.command("publish <paths..>", "Publish a local skill directory as a capability or a new version", (publish) => publish.option("namespace", {
@@ -7129,7 +7183,7 @@ function buildParser(onMatched) {
7129
7183
  }), (argv) => {
7130
7184
  onMatched({
7131
7185
  argv,
7132
- run: async (ctx, matchedArgv) => (await import("./skills-ZFmFyZtw.mjs")).publish(ctx, matchedArgv)
7186
+ run: async (ctx, matchedArgv) => (await import("./skills-B92ip3mJ.mjs")).publish(ctx, matchedArgv)
7133
7187
  });
7134
7188
  });
7135
7189
  return skills.demandCommand(1);
@@ -7151,7 +7205,7 @@ function buildParser(onMatched) {
7151
7205
  }), (argv) => {
7152
7206
  onMatched({
7153
7207
  argv,
7154
- run: async (ctx, matchedArgv) => (await import("./mcp-B0sbQABp.mjs")).add(ctx, matchedArgv)
7208
+ run: async (ctx, matchedArgv) => (await import("./mcp-DjSU9C8z.mjs")).add(ctx, matchedArgv)
7155
7209
  });
7156
7210
  });
7157
7211
  mcp.command("publish <paths..>", "Publish an MCP snapshot as a capability or a new version", (publish) => publish.option("namespace", {
@@ -7179,7 +7233,7 @@ function buildParser(onMatched) {
7179
7233
  }), (argv) => {
7180
7234
  onMatched({
7181
7235
  argv,
7182
- run: async (ctx, matchedArgv) => (await import("./mcp-B0sbQABp.mjs")).publish(ctx, matchedArgv)
7236
+ run: async (ctx, matchedArgv) => (await import("./mcp-DjSU9C8z.mjs")).publish(ctx, matchedArgv)
7183
7237
  });
7184
7238
  });
7185
7239
  return mcp.demandCommand(1);
@@ -1,8 +1,8 @@
1
1
  import { C as HngrError, g as resolveConnection, s as redactSensitive, t as apiFetch, x as writeFileAtomically } from "./http-DgHX7Fux.mjs";
2
- import { _ as resolveCapability, a as errorBody, c as runBatch, d as findAgentDefinition, g as parseCapabilityPath, i as runPublish, l as stringValues, m as MCP_KIND, n as explicitName, o as failedResult, p as downloadContent, r as readPublishFlags, s as positionalPaths, t as resolveInstallScope, u as AGENTS, v as resolveCapabilityVersion } from "./scope-DVfDeQi2.mjs";
2
+ import { S as resolveCapabilityVersion, _ as downloadContent, a as errorBody, b as parseCapabilityPath, c as runBatch, h as findAgentDefinition, i as runPublish, l as stringValues, m as AGENTS, n as explicitName, o as failedResult, p as pathExists, r as readPublishFlags, s as positionalPaths, t as resolveInstallScope, v as MCP_KIND, x as resolveCapability } from "./scope-D8k2oa_W.mjs";
3
3
  import { constants } from "node:fs";
4
4
  import { access, readFile, stat } from "node:fs/promises";
5
- import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { spawn } from "node:child_process";
7
7
  // src/mcp/claude-cli.ts
8
8
  const EXECUTABLE = "claude";
@@ -128,6 +128,12 @@ async function addJsonWithClaude(env, name, config) {
128
128
  // src/mcp/content.ts
129
129
  /** Server names Claude Code accepts as `mcpServers` keys. */
130
130
  const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
131
+ /**
132
+ * `__proto__` matches the pattern but is an accessor on `Object.prototype`:
133
+ * assigning it would silently change the merged object's prototype instead of
134
+ * recording a server.
135
+ */
136
+ const RESERVED_SERVER_NAMES = /* @__PURE__ */ new Set(["__proto__"]);
131
137
  const EXPECTED_SHAPE = "Expected an object shaped {\"mcpServers\": {\"<server-name>\": {\"command\": \"...\", \"args\": [...]}}}.";
132
138
  function invalid(title, detail = EXPECTED_SHAPE) {
133
139
  return new HngrError({
@@ -148,7 +154,7 @@ function validateSnapshot(body) {
148
154
  if (!("mcpServers" in body)) throw invalid("The MCP snapshot is missing \"mcpServers\"");
149
155
  const servers = body.mcpServers;
150
156
  if (!isPlainObject$1(servers)) throw invalid("The MCP snapshot's \"mcpServers\" is not an object");
151
- 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_-]+.");
157
+ for (const name of Object.keys(servers)) if (!SERVER_NAME_PATTERN.test(name) || RESERVED_SERVER_NAMES.has(name)) throw invalid(`Invalid MCP server name ${JSON.stringify(name)}`, "Server names must match [A-Za-z0-9_-]+ and must not be __proto__.");
152
158
  return {
153
159
  servers,
154
160
  ignoredKeys: Object.keys(body).filter((key) => key !== "mcpServers")
@@ -343,6 +349,48 @@ function usage(title, detail) {
343
349
  detail
344
350
  });
345
351
  }
352
+ const ALL_TARGETS = "__all__";
353
+ /** Target prompts need a TTY on both ends, a multi-select prompter, and human output mode. */
354
+ function canPrompt(ctx) {
355
+ return !ctx.flags.json && ctx.io.stdin.isTTY && ctx.io.stdout.isTTY && ctx.io.prompts?.multiselect !== void 0;
356
+ }
357
+ function noTargetSelectionError(global) {
358
+ return usage("No agent target selected", `Pass --agent <id> (repeatable) or --all. For example: hngr mcp add pdf --agent claude-code${global ? " --global" : ""}`);
359
+ }
360
+ /**
361
+ * One multi-select for the MCP targets supported at the chosen scope. Detection
362
+ * only preselects: an existing agent config (or its directory) is highlighted,
363
+ * and Claude Code is preselected at project scope because the current directory
364
+ * always exists. A non-TTY invocation never prompts and must pass --agent or --all.
365
+ */
366
+ async function selectTargets(ctx, argv, scope) {
367
+ const requested = stringValues(argv.agent);
368
+ const all = argv.all === true;
369
+ if (requested.length > 0 || all) return selectMcpTargets(requested, all, scope, process.cwd(), ctx.env);
370
+ const multiselect = ctx.io.prompts?.multiselect;
371
+ if (!canPrompt(ctx) || !multiselect) throw noTargetSelectionError(scope === "global");
372
+ const plans = selectMcpTargets([], true, scope, process.cwd(), ctx.env);
373
+ const options = [{
374
+ value: ALL_TARGETS,
375
+ label: "All targets",
376
+ hint: "install for every target below"
377
+ }];
378
+ const initialValues = [];
379
+ for (const plan of plans) {
380
+ options.push({
381
+ value: plan.agent,
382
+ label: plan.agent,
383
+ hint: plan.path
384
+ });
385
+ const configPath = plan.filePath ?? plan.path;
386
+ if (plan.kind === "claude-cli" ? await pathExists(configPath) : await pathExists(dirname(configPath))) initialValues.push(plan.agent);
387
+ }
388
+ const selected = await multiselect("Install targets", options, initialValues, "Existing agent configs are preselected");
389
+ const chosen = selected.includes(ALL_TARGETS) ? new Set(plans.map((plan) => plan.agent)) : new Set(selected);
390
+ const targets = plans.filter((plan) => chosen.has(plan.agent));
391
+ if (targets.length === 0) throw noTargetSelectionError(scope === "global");
392
+ return targets;
393
+ }
346
394
  function failureResult(parsed, err, version) {
347
395
  return {
348
396
  servers: [],
@@ -565,7 +613,7 @@ async function add(ctx, argv) {
565
613
  if (rawPaths.length === 0) throw usage("No capability path given", "Pass one or more `<name>` or `<namespace>/<name>` arguments.");
566
614
  const parsedPaths = rawPaths.map(parseMcpPath);
567
615
  const scope = await resolveInstallScope(ctx, argv.global === true);
568
- const targets = selectMcpTargets(stringValues(argv.agent), argv.all === true, scope, process.cwd(), ctx.env);
616
+ const targets = await selectTargets(ctx, argv, scope);
569
617
  const connection = await resolveConnection(ctx.flags, ctx.env);
570
618
  let approvalHintShown = false;
571
619
  const { results, failed } = await runBatch({
@@ -1,8 +1,9 @@
1
- import { C as HngrError, _ as scrubCredentials, a as toHngrError, g as resolveConnection, h as redactUrl, i as buildRequestUrl, l as apiErrorFromResponse, n as apiGet } from "./http-DgHX7Fux.mjs";
1
+ import { C as HngrError, _ as scrubCredentials, a as toHngrError, b as writeDirectoryAtomically, g as resolveConnection, h as redactUrl, i as buildRequestUrl, l as apiErrorFromResponse, n as apiGet } from "./http-DgHX7Fux.mjs";
2
2
  import { t as collectPages } from "./pagination-BXyFxGYA.mjs";
3
- import { readFile } from "node:fs/promises";
3
+ import { lstat, mkdir, readFile, rename, rm, symlink, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { join, resolve } from "node:path";
5
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
+ import { randomUUID } from "node:crypto";
6
7
  // ../client/src/capability-path.ts
7
8
  /** The Namespace every bare capability reference means. */
8
9
  const SYSTEM_NAMESPACE = "system";
@@ -323,6 +324,69 @@ const AGENTS = [
323
324
  function findAgentDefinition(id) {
324
325
  return AGENTS.find((agent) => agent.id === id);
325
326
  }
327
+ // src/skills/install.ts
328
+ async function pathExists(path) {
329
+ return lstat(path).then(() => true, () => false);
330
+ }
331
+ async function writeEntries(dest, entries) {
332
+ await mkdir(dest, { recursive: true });
333
+ for (const entry of entries) {
334
+ const path = resolve(dest, entry.path);
335
+ if (path !== dest && !path.startsWith(`${dest}${sep}`)) throw new HngrError({
336
+ type: "io",
337
+ title: `Refusing to write outside ${dest}`,
338
+ detail: `entry ${JSON.stringify(entry.path)} escapes the target directory`
339
+ });
340
+ await mkdir(dirname(path), { recursive: true });
341
+ await writeFile(path, entry.data, { mode: entry.mode & 73 ? 493 : 420 });
342
+ }
343
+ }
344
+ /**
345
+ * Writes a skill through "temp directory → validate → rename": the target is
346
+ * either absent or complete, never half-written. Re-installing replaces the
347
+ * previous directory, with the old one moved aside so a failed swap puts it
348
+ * back.
349
+ */
350
+ async function installAtomically(dest, entries) {
351
+ await writeDirectoryAtomically(dest, (temp) => writeEntries(temp, entries));
352
+ }
353
+ /**
354
+ * Creates a relative symlink to the canonical directory, falling back to a
355
+ * copy when the platform refuses the link (Windows without Developer Mode).
356
+ * Windows prefers a junction, which does not need elevated privileges; its
357
+ * target must be absolute. A temp link plus rename replaces an existing link
358
+ * atomically; re-installing replaces an existing directory or link.
359
+ */
360
+ async function linkAtomically(canonical, target, entries, options = {}) {
361
+ const exists = await pathExists(target);
362
+ await mkdir(dirname(target), { recursive: true });
363
+ const temp = resolve(dirname(target), `.hngr-${basename(target)}-${randomUUID()}.link`);
364
+ const windows = (options.platform ?? process.platform) === "win32";
365
+ const createLink = options.symlink ?? symlink;
366
+ try {
367
+ await createLink(windows ? canonical : relative(dirname(target), canonical), temp, windows ? "junction" : "dir");
368
+ if (exists) {
369
+ const stat = await lstat(target);
370
+ if (stat.isDirectory() && !stat.isSymbolicLink()) await rm(target, {
371
+ recursive: true,
372
+ force: true
373
+ });
374
+ }
375
+ await rename(temp, target);
376
+ return { mode: "symlink" };
377
+ } catch {
378
+ await rm(temp, { force: true }).catch(() => {});
379
+ await installAtomically(target, entries);
380
+ return {
381
+ mode: "copy",
382
+ warning: `warning: could not create a link at ${target}; copied instead`
383
+ };
384
+ }
385
+ }
386
+ /** Copies one target directory atomically, without a canonical store. */
387
+ async function copyAtomically(target, entries) {
388
+ await installAtomically(target, entries);
389
+ }
326
390
  // src/commands/batch.ts
327
391
  /** A repeatable string flag can arrive as one value or an array of values. */
328
392
  function stringValues(value) {
@@ -573,4 +637,4 @@ async function resolveInstallScope(ctx, global) {
573
637
  if (!select || ctx.flags.json || !ctx.io.stdin.isTTY || !ctx.io.stdout.isTTY) return "project";
574
638
  return await select("Install scope", SCOPE_OPTIONS, "project") === "global" ? "global" : "project";
575
639
  }
576
- export { resolveCapability as _, errorBody as a, runBatch as c, findAgentDefinition as d, homeDir as f, parseCapabilityPath as g, SKILL_KIND as h, runPublish as i, stringValues as l, MCP_KIND as m, explicitName as n, failedResult as o, downloadContent as p, readPublishFlags as r, positionalPaths as s, resolveInstallScope as t, AGENTS as u, resolveCapabilityVersion as v };
640
+ export { resolveCapabilityVersion as S, downloadContent as _, errorBody as a, parseCapabilityPath as b, runBatch as c, installAtomically as d, linkAtomically as f, homeDir as g, findAgentDefinition as h, runPublish as i, stringValues as l, AGENTS as m, explicitName as n, failedResult as o, pathExists as p, readPublishFlags as r, positionalPaths as s, resolveInstallScope as t, copyAtomically as u, MCP_KIND as v, resolveCapability as x, SKILL_KIND as y };
@@ -1,6 +1,6 @@
1
- import { C as HngrError, b as writeDirectoryAtomically, g as resolveConnection, r as apiRequest } from "./http-DgHX7Fux.mjs";
2
- import { _ as resolveCapability, a as errorBody, c as runBatch, f as homeDir, g as parseCapabilityPath, h as SKILL_KIND, i as runPublish, l as stringValues, n as explicitName, o as failedResult, p as downloadContent, r as readPublishFlags, s as positionalPaths, t as resolveInstallScope, u as AGENTS, v as resolveCapabilityVersion } from "./scope-DVfDeQi2.mjs";
3
- import { lstat, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
1
+ import { C as HngrError, g as resolveConnection, r as apiRequest } from "./http-DgHX7Fux.mjs";
2
+ import { S as resolveCapabilityVersion, _ as downloadContent, a as errorBody, b as parseCapabilityPath, c as runBatch, d as installAtomically, f as linkAtomically, g as homeDir, i as runPublish, l as stringValues, m as AGENTS, n as explicitName, o as failedResult, p as pathExists, r as readPublishFlags, s as positionalPaths, t as resolveInstallScope, u as copyAtomically, x as resolveCapability, y as SKILL_KIND } from "./scope-D8k2oa_W.mjs";
3
+ import { lstat, readFile, readdir } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { deflateRawSync, inflateRawSync } from "node:zlib";
@@ -88,69 +88,6 @@ function isReservedDirName(name) {
88
88
  function downloadSkillPackage(connection, capabilityId, version, signal) {
89
89
  return downloadContent(connection, capabilityId, version, "application/zip", signal);
90
90
  }
91
- // src/skills/install.ts
92
- async function pathExists(path) {
93
- return lstat(path).then(() => true, () => false);
94
- }
95
- async function writeEntries(dest, entries) {
96
- await mkdir(dest, { recursive: true });
97
- for (const entry of entries) {
98
- const path = resolve(dest, entry.path);
99
- if (path !== dest && !path.startsWith(`${dest}${sep}`)) throw new HngrError({
100
- type: "io",
101
- title: `Refusing to write outside ${dest}`,
102
- detail: `entry ${JSON.stringify(entry.path)} escapes the target directory`
103
- });
104
- await mkdir(dirname(path), { recursive: true });
105
- await writeFile(path, entry.data, { mode: entry.mode & 73 ? 493 : 420 });
106
- }
107
- }
108
- /**
109
- * Writes a skill through "temp directory → validate → rename": the target is
110
- * either absent or complete, never half-written. Re-installing replaces the
111
- * previous directory, with the old one moved aside so a failed swap puts it
112
- * back.
113
- */
114
- async function installAtomically(dest, entries) {
115
- await writeDirectoryAtomically(dest, (temp) => writeEntries(temp, entries));
116
- }
117
- /**
118
- * Creates a relative symlink to the canonical directory, falling back to a
119
- * copy when the platform refuses the link (Windows without Developer Mode).
120
- * Windows prefers a junction, which does not need elevated privileges; its
121
- * target must be absolute. A temp link plus rename replaces an existing link
122
- * atomically; re-installing replaces an existing directory or link.
123
- */
124
- async function linkAtomically(canonical, target, entries, options = {}) {
125
- const exists = await pathExists(target);
126
- await mkdir(dirname(target), { recursive: true });
127
- const temp = resolve(dirname(target), `.hngr-${basename(target)}-${randomUUID()}.link`);
128
- const windows = (options.platform ?? process.platform) === "win32";
129
- const createLink = options.symlink ?? symlink;
130
- try {
131
- await createLink(windows ? canonical : relative(dirname(target), canonical), temp, windows ? "junction" : "dir");
132
- if (exists) {
133
- const stat = await lstat(target);
134
- if (stat.isDirectory() && !stat.isSymbolicLink()) await rm(target, {
135
- recursive: true,
136
- force: true
137
- });
138
- }
139
- await rename(temp, target);
140
- return { mode: "symlink" };
141
- } catch {
142
- await rm(temp, { force: true }).catch(() => {});
143
- await installAtomically(target, entries);
144
- return {
145
- mode: "copy",
146
- warning: `warning: could not create a link at ${target}; copied instead`
147
- };
148
- }
149
- }
150
- /** Copies one target directory atomically, without a canonical store. */
151
- async function copyAtomically(target, entries) {
152
- await installAtomically(target, entries);
153
- }
154
91
  // src/skills/resolve.ts
155
92
  /** Parses `<name>` or `<namespace>/<name>`, with an optional `@YYYY.MM.DD.N`. */
156
93
  function parseSkillPath(raw) {
@@ -850,6 +787,47 @@ async function publish(ctx, argv) {
850
787
  items
851
788
  });
852
789
  }
790
+ const FIND_CAPABILITIES = "find-capabilities";
791
+ /** The follow-up question needs a yes/no prompter on both ends of a terminal. */
792
+ function canConfirm(ctx) {
793
+ return ctx.io.stdin.isTTY && ctx.io.stdout.isTTY && ctx.io.prompts?.confirm !== void 0;
794
+ }
795
+ /**
796
+ * Whether any scope the caller would install into already holds find-capabilities.
797
+ * A project install does not ask again when the global store has it, and the
798
+ * reverse, so the offer appears only when nothing is installed yet.
799
+ */
800
+ async function findCapabilitiesInstalled(ctx, agents, options) {
801
+ const scopes = options.dir ? [options.global] : [false, true];
802
+ for (const global of scopes) {
803
+ const plan = planInstall(process.cwd(), ctx.env, FIND_CAPABILITIES, agents, {
804
+ ...options,
805
+ global
806
+ });
807
+ const paths = [...plan.canonical ? [plan.canonical] : [], ...plan.targets.map((target) => target.path)];
808
+ for (const path of paths) if (await pathExists(path)) return true;
809
+ }
810
+ return false;
811
+ }
812
+ /**
813
+ * Offers the catalog's capability-discovery skill after a successful install so
814
+ * an agent that just gained one capability can find the rest, Skills and MCP
815
+ * servers alike. It is a convenience, not part of the request: an
816
+ * already-installed copy or a failed download never makes the add itself fail
817
+ * (a cancelled prompt still exits 130).
818
+ */
819
+ async function offerFindCapabilities(ctx, connection, destNames, agents, options) {
820
+ const confirm = ctx.io.prompts?.confirm;
821
+ if (!confirm || destNames.includes(FIND_CAPABILITIES)) return;
822
+ if (await findCapabilitiesInstalled(ctx, agents, options)) return;
823
+ if (!await confirm(`Also install ${FIND_CAPABILITIES} (discover more capabilities)?`, true)) return;
824
+ try {
825
+ await addOne(ctx, connection, parseSkillPath(FIND_CAPABILITIES), FIND_CAPABILITIES, agents, options.dir, options.global, options.copy, { version: null });
826
+ } catch (err) {
827
+ if (err instanceof HngrError && err.exitCode !== 1) throw err;
828
+ ctx.summary(`warning: could not install ${FIND_CAPABILITIES}: ${err instanceof Error ? err.message : String(err)}`);
829
+ }
830
+ }
853
831
  async function add(ctx, argv) {
854
832
  const rawPaths = positionalPaths(argv.paths);
855
833
  if (rawPaths.length === 0) throw usage("No skill path given", "Pass one or more `<name>` or `<namespace>/<name>` arguments.");
@@ -875,6 +853,11 @@ async function add(ctx, argv) {
875
853
  failure: failureResult
876
854
  });
877
855
  if (ctx.flags.json || !ctx.io.stdout.isTTY) ctx.writePayload({ results });
856
+ if (!ctx.flags.json && !ctx.flags.dryRun && !ctx.flags.quiet && !failed && canConfirm(ctx)) await offerFindCapabilities(ctx, connection, parsedPaths.map((parsed) => as ?? parsed.name), agents, {
857
+ dir,
858
+ global,
859
+ copy
860
+ });
878
861
  if (!ctx.flags.json && !ctx.flags.dryRun && !ctx.flags.quiet && !failed && ctx.io.stderr.isTTY) ctx.io.prompts?.outro?.("Done! Review skills before use; they run with full agent permissions.");
879
862
  return failed ? 1 : 0;
880
863
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hngr",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "The Hangar CLI: install skills and MCP servers into coding agents, and drive the Management API.",
5
5
  "keywords": [
6
6
  "hangar",