@cmdforge/gen-apic-mp 0.0.1 → 0.0.2

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,486 @@
1
+ import { access, cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+
7
+ import { types } from "../shared/index.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ export class ApiCenterClient {
12
+ get serviceName() { return this.info.serviceName; }
13
+ get region() { return this.info.region; }
14
+ get workspaceName() { return this.info.workspaceName; }
15
+
16
+ constructor(public readonly info: types.WorkspaceInfo) { }
17
+
18
+ plane(plane: types.PlaneType) {
19
+ return `https://${this.serviceName}.${plane}.${this.region}.azure-apicenter.ms`;
20
+ }
21
+
22
+ path(plane: types.PlaneType, path: string) {
23
+ return `${this.plane(plane)}/${path}`;
24
+ }
25
+
26
+ url(plane: types.PlaneType, path: string, params: Record<string, unknown> = {}) {
27
+ const url = new URL(this.path(plane, path));
28
+ for (const [key, value] of Object.entries(params))
29
+ url.searchParams.set(key, `${value}`);
30
+ return url.toString();
31
+ }
32
+
33
+ workspace(path: string, params: Record<string, unknown> = {}) {
34
+ return this.url("data", `workspaces/${this.workspaceName}/${path}`, params);
35
+ }
36
+
37
+ async get<T>(url: string) {
38
+ const response = await fetch(url);
39
+ const body = await response.text();
40
+ try {
41
+ if (!response.ok) throw Error(`Could not read config from ${url}`);
42
+ return JSON.parse(body) as T;
43
+ } catch (e) {
44
+ throw Error(`url: ${url}, e: ${e}, body: ${body}`);
45
+ }
46
+ }
47
+
48
+ async *paged<T>(url: string) {
49
+ let nextLink = url;
50
+ do {
51
+ const result = await this.get<types.PagedResponse<T>>(nextLink);
52
+ if (result.value?.length > 0)
53
+ yield result.value;
54
+ nextLink = result.nextLink ?? "";
55
+ } while (nextLink);
56
+ }
57
+
58
+ async config() {
59
+ return this.get<types.PortalConfig>(this.url("portal", "config.json"));
60
+ }
61
+
62
+ apis($top = 50, $skip?: number) {
63
+ return this.paged<types.ApiAssetItem>(this.workspace("apis", { $top, $skip }));
64
+ }
65
+
66
+ async plugin(name: string) {
67
+ return this.get<types.PluginAsset>(this.workspace(`plugins/${name}`));
68
+ }
69
+
70
+ async mcp(resourceId: string) {
71
+ return this.get<types.McpAsset>(this.url("data", resourceId));
72
+ }
73
+
74
+ async v0Server(name: string) {
75
+ return this.get<types.V0ServerEntryResponse>(this.workspace(`v0/servers/${name}`));
76
+ }
77
+ }
78
+
79
+ function sanitizeName(value: string) {
80
+ return value
81
+ .trim()
82
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
83
+ .replace(/^-+|-+$/g, "") || "unnamed";
84
+ }
85
+
86
+ function asStringRecord(value: unknown) {
87
+ if (!value || typeof value !== "object" || Array.isArray(value))
88
+ return undefined;
89
+
90
+ const result: Record<string, string> = {};
91
+ for (const [key, item] of Object.entries(value)) {
92
+ if (item === undefined || item === null) continue;
93
+ result[key] = `${item}`;
94
+ }
95
+ return Object.keys(result).length > 0 ? result : undefined;
96
+ }
97
+
98
+ function asStringArray(value: unknown) {
99
+ if (!Array.isArray(value)) return undefined;
100
+ const result = value.map(item => `${item}`);
101
+ return result.length > 0 ? result : undefined;
102
+ }
103
+
104
+ function asObjectArray(value: unknown) {
105
+ if (!Array.isArray(value)) return [];
106
+ return value.map(asObject).filter((item): item is Record<string, unknown> => !!item);
107
+ }
108
+
109
+ function pickObject(value: unknown, keys: string[]) {
110
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
111
+ for (const key of keys) {
112
+ if (key in value) {
113
+ const candidate = (value as Record<string, unknown>)[key];
114
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate))
115
+ return candidate as Record<string, unknown>;
116
+ }
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function pickString(value: unknown, keys: string[]) {
122
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
123
+ for (const key of keys) {
124
+ const candidate = (value as Record<string, unknown>)[key];
125
+ if (typeof candidate === "string" && candidate.length > 0) return candidate;
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ function asObject(value: unknown) {
131
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
132
+ return value as Record<string, unknown>;
133
+ }
134
+
135
+ function pickTransport(entry: Record<string, unknown>) {
136
+ const transport = pickString(entry, ["transport", "type", "protocol"]);
137
+ if (!transport) return undefined;
138
+ if (transport === "stdio") return "stdio";
139
+ if (transport === "sse") return "sse";
140
+ if (transport === "streamable-http" || transport === "http" || transport === "https")
141
+ return "http";
142
+ return undefined;
143
+ }
144
+
145
+ function compactObject<T extends Record<string, unknown>>(value: T): T {
146
+ return Object.fromEntries(
147
+ Object.entries(value).filter(([, item]) => item !== undefined),
148
+ ) as T;
149
+ }
150
+
151
+ function argumentValue(argument: Record<string, unknown>) {
152
+ const value = argument.value;
153
+ if (value === undefined || value === null) return undefined;
154
+ return `${value}`;
155
+ }
156
+
157
+ function packageEnv(packageEntry: Record<string, unknown>) {
158
+ const environmentVariables = asObjectArray(packageEntry.environmentVariables);
159
+ if (environmentVariables.length === 0) return undefined;
160
+
161
+ const result: Record<string, string> = {};
162
+ for (const variable of environmentVariables) {
163
+ const name = pickString(variable, ["name"]);
164
+ const value = argumentValue(variable);
165
+ if (!name || value === undefined) continue;
166
+ result[name] = value;
167
+ }
168
+
169
+ return Object.keys(result).length > 0 ? result : undefined;
170
+ }
171
+
172
+ function packageArgs(packageEntry: Record<string, unknown>) {
173
+ const runtimeArguments = asObjectArray(packageEntry.runtimeArguments);
174
+ const packageArguments = asObjectArray(packageEntry.packageArguments);
175
+ const identifier = pickString(packageEntry, ["identifier"]);
176
+ const version = pickString(packageEntry, ["version"]);
177
+ const registryType = pickString(packageEntry, ["registryType"]);
178
+ const command = pickString(packageEntry, ["runtimeHint"])
179
+ ?? (registryType === "npm" ? "npx"
180
+ : registryType === "pypi" ? "uvx"
181
+ : registryType === "oci" ? "docker"
182
+ : registryType === "nuget" ? "dnx"
183
+ : undefined);
184
+
185
+ const args: string[] = [];
186
+
187
+ for (const argument of runtimeArguments) {
188
+ const type = pickString(argument, ["type"]);
189
+ const name = pickString(argument, ["name"]);
190
+ const value = argumentValue(argument);
191
+
192
+ if (type === "named" && name) {
193
+ if (value === undefined || value === "true") {
194
+ args.push(name);
195
+ } else if (value !== "false") {
196
+ args.push(name, value);
197
+ }
198
+ continue;
199
+ }
200
+
201
+ if (value !== undefined)
202
+ args.push(value);
203
+ }
204
+
205
+ if (identifier) {
206
+ if (command === "npx") {
207
+ args.push("-y", version ? `${identifier}@${version}` : identifier);
208
+ } else if (command === "uvx" || command === "dnx") {
209
+ args.push(version ? `${identifier}@${version}` : identifier);
210
+ } else if (command === "docker") {
211
+ args.push("run", "--rm", version ? `${identifier}:${version}` : identifier);
212
+ } else {
213
+ args.push(version ? `${identifier}@${version}` : identifier);
214
+ }
215
+ }
216
+
217
+ for (const argument of packageArguments) {
218
+ const type = pickString(argument, ["type"]);
219
+ const name = pickString(argument, ["name"]);
220
+ const value = argumentValue(argument);
221
+
222
+ if (type === "named" && name) {
223
+ if (value === undefined || value === "true") {
224
+ args.push(name);
225
+ } else if (value !== "false") {
226
+ args.push(name, value);
227
+ }
228
+ continue;
229
+ }
230
+
231
+ if (value !== undefined)
232
+ args.push(value);
233
+ }
234
+
235
+ return {
236
+ command,
237
+ args: args.length > 0 ? args : undefined,
238
+ env: packageEnv(packageEntry),
239
+ };
240
+ }
241
+
242
+ function normalizeServerEntry(entry: types.V0ServerJsonEntry): Record<string, unknown> | undefined {
243
+ const record = asObject(entry);
244
+ if (!record) return undefined;
245
+
246
+ const nestedServer = asObject(record.server);
247
+ if (!nestedServer) return record;
248
+
249
+ const remotes = asObjectArray(nestedServer.remotes);
250
+ const primaryRemote = remotes.length > 0 ? remotes[0] : undefined;
251
+ const packages = asObjectArray(nestedServer.packages);
252
+ const primaryPackage = packages.length > 0 ? packages[0] : undefined;
253
+ const packageTransport = asObject(primaryPackage?.transport);
254
+ const packageLaunch = primaryPackage ? packageArgs(primaryPackage) : undefined;
255
+
256
+ return compactObject({
257
+ ...record,
258
+ ...nestedServer,
259
+ transport: pickString(primaryRemote, ["type"])
260
+ ?? pickString(packageTransport, ["type", "transport"])
261
+ ?? pickString(nestedServer, ["transport", "type"]),
262
+ url: pickString(primaryRemote, ["url"])
263
+ ?? pickString(packageTransport, ["url"])
264
+ ?? pickString(nestedServer, ["url"]),
265
+ headers: primaryRemote?.headers ?? packageTransport?.headers ?? nestedServer.headers,
266
+ command: packageLaunch?.command ?? pickString(nestedServer, ["command", "cmd"]),
267
+ args: packageLaunch?.args ?? nestedServer.args,
268
+ env: packageLaunch?.env ?? nestedServer.env,
269
+ });
270
+ }
271
+
272
+ export function mcpServerNameFromV0Entry(entry: types.V0ServerJsonEntry) {
273
+ const record = normalizeServerEntry(entry);
274
+ const name = pickString(record, ["name"]);
275
+ if (!name) {
276
+ throw new Error(
277
+ `Could not determine MCP server name from v0 server.json entry:\n${JSON.stringify(entry, null, 2)}`,
278
+ );
279
+ }
280
+ return name;
281
+ }
282
+
283
+ export function mcpServerFromV0Entry(
284
+ entry: types.V0ServerJsonEntry,
285
+ ): types.McpServer {
286
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
287
+ throw new Error("v0 server.json entry must be an object");
288
+
289
+ const record = normalizeServerEntry(entry);
290
+ if (!record)
291
+ throw new Error("v0 server.json entry must be an object");
292
+
293
+ const transport = pickTransport(record);
294
+ const command = pickString(record, ["command", "cmd"]);
295
+ const url = pickString(record, ["url", "endpoint"]);
296
+
297
+ if (transport === "stdio" || command) {
298
+ if (!command) throw new Error("stdio MCP server entry is missing command");
299
+
300
+ return compactObject({
301
+ transport: "stdio",
302
+ command,
303
+ args: asStringArray(record.args),
304
+ env: asStringRecord(record.env),
305
+ cwd: pickString(record, ["cwd"]),
306
+ }) as types.McpServer;
307
+ }
308
+
309
+ if (transport === "http" || transport === "sse" || url) {
310
+ if (!url) throw new Error("HTTP MCP server entry is missing url");
311
+
312
+ return compactObject({
313
+ transport: transport === "sse" ? "sse" : "http",
314
+ url,
315
+ headers: asStringRecord(record.headers),
316
+ }) as types.McpServer;
317
+ }
318
+
319
+ throw new Error(
320
+ `Could not determine MCP server transport from v0 server.json entry:\n${JSON.stringify(entry, null, 2)}`,
321
+ );
322
+ }
323
+
324
+ export function mcpServersFromServerJson(serverJson: types.ServerJson) {
325
+ const entries = serverJson.mcpServers ?? serverJson.servers ?? {};
326
+ return Object.fromEntries(
327
+ Object.entries(entries).map(([name, entry]) => [name, mcpServerFromV0Entry(entry)]),
328
+ ) satisfies Record<string, types.McpServer>;
329
+ }
330
+
331
+ type PluginBuildResult = {
332
+ mcpJson: types.McpJson;
333
+ pluginJson: types.PluginJson;
334
+ marketplaceEntry: types.MarketplacePluginJson;
335
+ };
336
+
337
+ async function buildPlugin(
338
+ client: ApiCenterClient,
339
+ plugin: types.PluginAsset,
340
+ ): Promise<PluginBuildResult> {
341
+ const pluginName = sanitizeName(plugin.name);
342
+ const mcpServers: Record<string, types.McpServer> = {};
343
+
344
+ for (const resource of plugin.resources) {
345
+ if (resource.kind !== "mcp") continue;
346
+
347
+ const mcpAsset = await client.mcp(resource.resourceId);
348
+ const serverEntry = await client.v0Server(mcpAsset.name);
349
+ mcpServers[mcpServerNameFromV0Entry(serverEntry)] = mcpServerFromV0Entry(serverEntry);
350
+ }
351
+
352
+ const skills: string[] = [];
353
+ const pluginJson: types.PluginJson = {
354
+ name: plugin.name,
355
+ description: plugin.description || plugin.summary,
356
+ version: plugin.version,
357
+ };
358
+
359
+ if (Object.keys(mcpServers).length > 0)
360
+ pluginJson.mcpServers = ".mcp.json";
361
+
362
+ if (skills.length > 0)
363
+ pluginJson.skills = ["./skills/"];
364
+
365
+ return {
366
+ mcpJson: { mcpServers },
367
+ pluginJson,
368
+ marketplaceEntry: {
369
+ name: plugin.name,
370
+ version: plugin.version,
371
+ description: plugin.description || plugin.summary,
372
+ source: `plugins/${pluginName}`,
373
+ skills,
374
+ },
375
+ };
376
+ }
377
+
378
+ async function writeJson(path: string, value: unknown) {
379
+ await mkdir(dirname(path), { recursive: true });
380
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
381
+ }
382
+
383
+ async function zipDirectory(sourceDir: string, outputPath: string) {
384
+ await execFileAsync("zip", ["-rq", outputPath, "."], { cwd: sourceDir });
385
+ }
386
+
387
+ async function pathExists(path: string) {
388
+ try {
389
+ await access(path);
390
+ return true;
391
+ } catch {
392
+ return false;
393
+ }
394
+ }
395
+
396
+ export type GenerateMarketplaceGitOptions = {
397
+ unpack?: string;
398
+ };
399
+
400
+ export async function generateMarketplaceGit(
401
+ info: types.WorkspaceInfo,
402
+ options: GenerateMarketplaceGitOptions = {},
403
+ ) {
404
+ const client = new ApiCenterClient(info);
405
+ const config = await client.config();
406
+ const tempRoot = await mkdtemp(join(tmpdir(), "gen-apic-mp-"));
407
+ const marketplaceRoot = join(tempRoot, "marketplace");
408
+ const marketplacePlugins: types.MarketplacePluginJson[] = [];
409
+
410
+ try {
411
+ for await (const page of client.apis()) {
412
+ for (const item of page) {
413
+ if (item.kind !== "plugin") continue;
414
+
415
+ const pluginAsset = await client.plugin(item.name);
416
+ const built = await buildPlugin(client, pluginAsset);
417
+ const pluginDir = join(marketplaceRoot, "plugins", sanitizeName(pluginAsset.name));
418
+
419
+ marketplacePlugins.push(built.marketplaceEntry);
420
+ await writeJson(join(pluginDir, "plugin.json"), built.pluginJson);
421
+ await writeJson(join(pluginDir, ".mcp.json"), built.mcpJson);
422
+ }
423
+ }
424
+
425
+ const marketplaceJson: types.MarketplaceJson = {
426
+ name: config.title,
427
+ metadata: {
428
+ description: `MCP server plugins from ${config.title}`,
429
+ version: "1.0.0",
430
+ },
431
+ owner: {
432
+ name: config.title,
433
+ },
434
+ plugins: marketplacePlugins,
435
+ };
436
+
437
+ await writeJson(join(marketplaceRoot, ".github", "plugin", "marketplace.json"), marketplaceJson);
438
+ await writeJson(join(marketplaceRoot, ".claude-plugin", "marketplace.json"), marketplaceJson);
439
+
440
+ const unpackPath = options.unpack ? resolve(options.unpack) : undefined;
441
+ if (unpackPath) {
442
+ await mkdir(unpackPath, { recursive: true });
443
+ await cp(marketplaceRoot, unpackPath, { recursive: true, force: true });
444
+ }
445
+ const zipPath = unpackPath
446
+ ? undefined
447
+ : join(process.cwd(), `${sanitizeName(info.workspaceName)}-marketplace.zip`);
448
+ if (zipPath) {
449
+ await rm(zipPath, { force: true });
450
+ await zipDirectory(marketplaceRoot, zipPath);
451
+ }
452
+
453
+ const marketplacePreview = await readFile(
454
+ join(marketplaceRoot, ".github", "plugin", "marketplace.json"),
455
+ "utf8",
456
+ );
457
+
458
+ return {
459
+ zipPath,
460
+ unpackPath,
461
+ pluginCount: marketplacePlugins.length,
462
+ marketplaceJson: JSON.parse(marketplacePreview) as types.MarketplaceJson,
463
+ };
464
+ } finally {
465
+ await rm(tempRoot, { recursive: true, force: true });
466
+ }
467
+ }
468
+
469
+ const currentWorkingPackageSentinel = "__CURRENT_WORKING_PACKAGE__";
470
+
471
+ export async function resolveUnpackDirectory(value: string | undefined) {
472
+ if (!value) return undefined;
473
+
474
+ if (value === currentWorkingPackageSentinel) {
475
+ const packageJsonPath = join(process.cwd(), "package.json");
476
+ if (!await pathExists(packageJsonPath)) {
477
+ throw new Error(
478
+ `--unpack without a path defaults to the current directory, but no package.json was found at ${packageJsonPath}`,
479
+ );
480
+ }
481
+
482
+ return process.cwd();
483
+ }
484
+
485
+ return resolve(value);
486
+ }
@@ -0,0 +1 @@
1
+ export * as types from './types.js';
@@ -0,0 +1,164 @@
1
+ export type WorkspaceInfo = {
2
+ serviceName: string;
3
+ region: string;
4
+ workspaceName: string;
5
+ };
6
+
7
+ export type PortalConfig = {
8
+ dataApiHostName: string;
9
+ title: string;
10
+ capabilities: [];
11
+ };
12
+
13
+ export type JsonObject = Record<string, unknown>;
14
+
15
+ export type McpAssetItem = {
16
+ name: string;
17
+ title: string;
18
+ summary: string;
19
+ description: string;
20
+ kind: 'mcp';
21
+ lifecycleStage: string;
22
+ externalDocumentation: [];
23
+ contacts: [];
24
+ customProperties: JsonObject;
25
+ lastUpdated: string;
26
+ };
27
+
28
+ export type PluginAssetItem = {
29
+ name: string;
30
+ title: string;
31
+ description: string;
32
+ kind: 'plugin';
33
+ lifecycleStage: string;
34
+ externalDocumentation: [];
35
+ contacts: [];
36
+ customProperties: JsonObject;
37
+ lastUpdated: string;
38
+ };
39
+
40
+ export type ApiAssetItem =
41
+ | McpAssetItem
42
+ | PluginAssetItem;
43
+
44
+ export type PluginMcpResource = {
45
+ resourceId: string;
46
+ title: string;
47
+ summary: string;
48
+ kind: 'mcp';
49
+ };
50
+
51
+ export type PluginResource =
52
+ | PluginMcpResource;
53
+
54
+ export type PluginAsset = {
55
+ name: string;
56
+ title: string;
57
+ summary: string;
58
+ description: string;
59
+ version: string;
60
+ resources: PluginResource[];
61
+ customProperties?: JsonObject;
62
+ };
63
+
64
+ export type PagedResponse<T> = {
65
+ value: T[];
66
+ nextLink?: string;
67
+ };
68
+
69
+ export type PlaneType = 'portal' | 'data';
70
+
71
+ export type V0HttpTransport = {
72
+ transport?: 'http' | 'https' | 'sse' | 'streamable-http';
73
+ type?: 'http' | 'https' | 'sse' | 'streamable-http';
74
+ url: string;
75
+ headers?: Record<string, string>;
76
+ };
77
+
78
+ export type V0StdioTransport = {
79
+ transport?: 'stdio';
80
+ type?: 'stdio';
81
+ command: string;
82
+ args?: string[];
83
+ env?: Record<string, string>;
84
+ cwd?: string;
85
+ };
86
+
87
+ export type V0ServerJsonEntry =
88
+ | V0HttpTransport
89
+ | V0StdioTransport
90
+ | JsonObject;
91
+
92
+ export type ServerJson = {
93
+ mcpServers?: Record<string, V0ServerJsonEntry>;
94
+ servers?: Record<string, V0ServerJsonEntry>;
95
+ };
96
+
97
+ export type V0ServerEntryResponse = V0ServerJsonEntry;
98
+
99
+ export type McpServerStdio = {
100
+ transport: 'stdio';
101
+ command: string;
102
+ args?: string[];
103
+ env?: Record<string, string>;
104
+ cwd?: string;
105
+ };
106
+
107
+ export type McpServerHttp = {
108
+ transport: 'http' | 'sse';
109
+ url: string;
110
+ headers?: Record<string, string>;
111
+ };
112
+
113
+ export type McpServer = (McpServerStdio | McpServerHttp) & JsonObject;
114
+
115
+ export type McpJson = {
116
+ mcpServers: Record<string, McpServer>;
117
+ };
118
+
119
+ export type PluginJson = {
120
+ name: string;
121
+ description: string;
122
+ version: string;
123
+ mcpServers?: string;
124
+ skills?: string[];
125
+ };
126
+
127
+ export type MarketplacePluginJson = {
128
+ name: string;
129
+ version: string;
130
+ description: string;
131
+ source: string | {
132
+ source: string;
133
+ repo: string;
134
+ path: string;
135
+ };
136
+ author?: {
137
+ name: string;
138
+ url: string;
139
+ };
140
+ homepage?: string;
141
+ keywords?: string[];
142
+ license?: string;
143
+ repository?: string;
144
+ skills: string[];
145
+ };
146
+
147
+ export type MarketplaceJson = {
148
+ name: string;
149
+ metadata: {
150
+ description: string;
151
+ version: string;
152
+ };
153
+ owner: {
154
+ name: string;
155
+ email?: string;
156
+ };
157
+ plugins: MarketplacePluginJson[];
158
+ };
159
+
160
+ export type McpAsset = McpAssetItem & {
161
+ version?: string;
162
+ serverJson?: ServerJson;
163
+ customProperties: JsonObject;
164
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "declaration": true,
9
+ "declarationMap": true,
10
+ "sourceMap": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "rootDir": "./src",
14
+ "outDir": "./dist",
15
+ "types": [
16
+ "node"
17
+ ]
18
+ },
19
+ "include": [
20
+ "src"
21
+ ]
22
+ }