chainlesschain 0.47.8 → 0.47.9

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.
@@ -14,6 +14,14 @@ import {
14
14
  getMessages,
15
15
  joinRoom,
16
16
  getLoginState,
17
+ sendThreadReply,
18
+ getThreadMessages,
19
+ getThreadRoots,
20
+ createSpace,
21
+ addSpaceChild,
22
+ removeSpaceChild,
23
+ listSpaceChildren,
24
+ listSpaces,
17
25
  } from "../lib/matrix-bridge.js";
18
26
 
19
27
  export function registerMatrixCommand(program) {
@@ -165,4 +173,279 @@ export function registerMatrixCommand(program) {
165
173
  process.exit(1);
166
174
  }
167
175
  });
176
+
177
+ // ── Threads (MSC3440 / spec §11.38) ──────────────────────────────
178
+
179
+ const thread = matrix
180
+ .command("thread")
181
+ .description("Matrix threaded replies (m.thread relation)");
182
+
183
+ thread
184
+ .command("send <room-id> <root-event-id> <body>")
185
+ .description("Send a threaded reply referencing a root event")
186
+ .option("-t, --type <msgtype>", "Message type", "m.text")
187
+ .option(
188
+ "--reply-to <event-id>",
189
+ "Event the reply directly targets (defaults to root)",
190
+ )
191
+ .option(
192
+ "--no-fallback",
193
+ "Disable is_falling_back (non-thread clients won't see it as a reply)",
194
+ )
195
+ .option("--json", "Output as JSON")
196
+ .action(async (roomId, rootEventId, body, options) => {
197
+ try {
198
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
199
+ if (!ctx.db) {
200
+ logger.error("Database not available");
201
+ process.exit(1);
202
+ }
203
+ const db = ctx.db.getDatabase();
204
+ ensureMatrixTables(db);
205
+
206
+ const result = sendThreadReply(db, {
207
+ roomId,
208
+ rootEventId,
209
+ body,
210
+ msgtype: options.type,
211
+ inReplyTo: options.replyTo,
212
+ isFallingBack: options.fallback !== false,
213
+ });
214
+ if (options.json) {
215
+ console.log(JSON.stringify(result, null, 2));
216
+ } else {
217
+ logger.success(`Thread reply sent to ${chalk.cyan(roomId)}`);
218
+ logger.log(` ${chalk.bold("Root:")} ${rootEventId}`);
219
+ logger.log(` ${chalk.bold("Event:")} ${result.event.eventId}`);
220
+ }
221
+ await shutdown();
222
+ } catch (err) {
223
+ logger.error(`Failed: ${err.message}`);
224
+ process.exit(1);
225
+ }
226
+ });
227
+
228
+ thread
229
+ .command("list <room-id> <root-event-id>")
230
+ .description("List all replies in a thread")
231
+ .option("--json", "Output as JSON")
232
+ .action(async (roomId, rootEventId, options) => {
233
+ try {
234
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
235
+ if (!ctx.db) {
236
+ logger.error("Database not available");
237
+ process.exit(1);
238
+ }
239
+ const db = ctx.db.getDatabase();
240
+ ensureMatrixTables(db);
241
+
242
+ const messages = getThreadMessages(roomId, rootEventId);
243
+ if (options.json) {
244
+ console.log(JSON.stringify(messages, null, 2));
245
+ } else if (messages.length === 0) {
246
+ logger.info("No replies in this thread.");
247
+ } else {
248
+ for (const m of messages) {
249
+ logger.log(` ${chalk.gray(m.sender)} ${m.content.body}`);
250
+ }
251
+ }
252
+ await shutdown();
253
+ } catch (err) {
254
+ logger.error(`Failed: ${err.message}`);
255
+ process.exit(1);
256
+ }
257
+ });
258
+
259
+ thread
260
+ .command("roots <room-id>")
261
+ .description("List distinct thread roots within a room")
262
+ .option("--json", "Output as JSON")
263
+ .action(async (roomId, options) => {
264
+ try {
265
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
266
+ if (!ctx.db) {
267
+ logger.error("Database not available");
268
+ process.exit(1);
269
+ }
270
+ const db = ctx.db.getDatabase();
271
+ ensureMatrixTables(db);
272
+
273
+ const roots = getThreadRoots(roomId);
274
+ if (options.json) {
275
+ console.log(JSON.stringify(roots, null, 2));
276
+ } else if (roots.length === 0) {
277
+ logger.info("No threads found.");
278
+ } else {
279
+ for (const r of roots) {
280
+ logger.log(
281
+ ` ${chalk.cyan(r.rootEventId.slice(0, 16))}... replies=${r.replyCount} last=${r.lastReplyAt}`,
282
+ );
283
+ }
284
+ }
285
+ await shutdown();
286
+ } catch (err) {
287
+ logger.error(`Failed: ${err.message}`);
288
+ process.exit(1);
289
+ }
290
+ });
291
+
292
+ // ── Spaces (spec §11.34) ─────────────────────────────────────────
293
+
294
+ const space = matrix
295
+ .command("space")
296
+ .description("Matrix Spaces — hierarchical room grouping (m.space)");
297
+
298
+ space
299
+ .command("create <name>")
300
+ .description("Create a new Matrix Space")
301
+ .option("-t, --topic <text>", "Space topic / description")
302
+ .option("--json", "Output as JSON")
303
+ .action(async (name, options) => {
304
+ try {
305
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
306
+ if (!ctx.db) {
307
+ logger.error("Database not available");
308
+ process.exit(1);
309
+ }
310
+ const db = ctx.db.getDatabase();
311
+ ensureMatrixTables(db);
312
+
313
+ const result = createSpace(db, { name, topic: options.topic });
314
+ if (options.json) {
315
+ console.log(JSON.stringify(result, null, 2));
316
+ } else {
317
+ logger.success(`Space created: ${chalk.cyan(result.space.name)}`);
318
+ logger.log(` ${chalk.bold("Room ID:")} ${result.space.roomId}`);
319
+ }
320
+ await shutdown();
321
+ } catch (err) {
322
+ logger.error(`Failed: ${err.message}`);
323
+ process.exit(1);
324
+ }
325
+ });
326
+
327
+ space
328
+ .command("add-child <space-id> <child-room-id>")
329
+ .description("Add a child room to a Space")
330
+ .option("--via <server...>", "Homeserver(s) via which child is reachable")
331
+ .option("--json", "Output as JSON")
332
+ .action(async (spaceId, childRoomId, options) => {
333
+ try {
334
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
335
+ if (!ctx.db) {
336
+ logger.error("Database not available");
337
+ process.exit(1);
338
+ }
339
+ const db = ctx.db.getDatabase();
340
+ ensureMatrixTables(db);
341
+
342
+ const result = addSpaceChild(db, {
343
+ spaceId,
344
+ childRoomId,
345
+ via: options.via,
346
+ });
347
+ if (options.json) {
348
+ console.log(JSON.stringify(result, null, 2));
349
+ } else {
350
+ logger.success(
351
+ `Added ${chalk.cyan(childRoomId)} to space ${chalk.cyan(spaceId)}`,
352
+ );
353
+ logger.log(` ${chalk.bold("Via:")} ${result.via.join(", ")}`);
354
+ }
355
+ await shutdown();
356
+ } catch (err) {
357
+ logger.error(`Failed: ${err.message}`);
358
+ process.exit(1);
359
+ }
360
+ });
361
+
362
+ space
363
+ .command("remove-child <space-id> <child-room-id>")
364
+ .description("Remove a child room from a Space")
365
+ .action(async (spaceId, childRoomId) => {
366
+ try {
367
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
368
+ if (!ctx.db) {
369
+ logger.error("Database not available");
370
+ process.exit(1);
371
+ }
372
+ const db = ctx.db.getDatabase();
373
+ ensureMatrixTables(db);
374
+
375
+ const result = removeSpaceChild(db, { spaceId, childRoomId });
376
+ if (result.removed) {
377
+ logger.success(
378
+ `Removed ${chalk.cyan(childRoomId)} from ${chalk.cyan(spaceId)}`,
379
+ );
380
+ } else {
381
+ logger.info(`${childRoomId} was not a child of ${spaceId}`);
382
+ }
383
+ await shutdown();
384
+ } catch (err) {
385
+ logger.error(`Failed: ${err.message}`);
386
+ process.exit(1);
387
+ }
388
+ });
389
+
390
+ space
391
+ .command("children <space-id>")
392
+ .description("List all child rooms of a Space")
393
+ .option("--json", "Output as JSON")
394
+ .action(async (spaceId, options) => {
395
+ try {
396
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
397
+ if (!ctx.db) {
398
+ logger.error("Database not available");
399
+ process.exit(1);
400
+ }
401
+ const db = ctx.db.getDatabase();
402
+ ensureMatrixTables(db);
403
+
404
+ const children = listSpaceChildren(spaceId);
405
+ if (options.json) {
406
+ console.log(JSON.stringify(children, null, 2));
407
+ } else if (children.length === 0) {
408
+ logger.info("Space has no children.");
409
+ } else {
410
+ for (const c of children) {
411
+ logger.log(` ${chalk.cyan(c.childRoomId)} via=${c.via.join(",")}`);
412
+ }
413
+ }
414
+ await shutdown();
415
+ } catch (err) {
416
+ logger.error(`Failed: ${err.message}`);
417
+ process.exit(1);
418
+ }
419
+ });
420
+
421
+ space
422
+ .command("list")
423
+ .description("List all Spaces")
424
+ .option("--json", "Output as JSON")
425
+ .action(async (options) => {
426
+ try {
427
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
428
+ if (!ctx.db) {
429
+ logger.error("Database not available");
430
+ process.exit(1);
431
+ }
432
+ const db = ctx.db.getDatabase();
433
+ ensureMatrixTables(db);
434
+
435
+ const spaces = listSpaces();
436
+ if (options.json) {
437
+ console.log(JSON.stringify(spaces, null, 2));
438
+ } else if (spaces.length === 0) {
439
+ logger.info("No spaces found.");
440
+ } else {
441
+ for (const s of spaces) {
442
+ logger.log(` ${chalk.cyan(s.roomId)} ${s.name}`);
443
+ }
444
+ }
445
+ await shutdown();
446
+ } catch (err) {
447
+ logger.error(`Failed: ${err.message}`);
448
+ process.exit(1);
449
+ }
450
+ });
168
451
  }
@@ -3,6 +3,8 @@
3
3
  * chainlesschain mcp servers|connect|disconnect|tools|call
4
4
  */
5
5
 
6
+ import fs from "fs";
7
+ import path from "path";
6
8
  import chalk from "chalk";
7
9
  import ora from "ora";
8
10
  import { logger } from "../lib/logger.js";
@@ -12,6 +14,17 @@ import {
12
14
  validateMcpServer,
13
15
  annotateMcpCompatibility,
14
16
  } from "@chainlesschain/session-core";
17
+ import {
18
+ generateMcpServerScaffold,
19
+ SUPPORTED_TRANSPORTS,
20
+ } from "../lib/mcp-scaffold.js";
21
+ import {
22
+ CATALOG as REGISTRY_CATALOG,
23
+ CATEGORIES as REGISTRY_CATEGORIES,
24
+ listServers as registryListServers,
25
+ searchServers as registrySearchServers,
26
+ getServer as registryGetServer,
27
+ } from "../lib/mcp-registry.js";
15
28
 
16
29
  // Singleton MCP client for session reuse
17
30
  let mcpClient = null;
@@ -381,4 +394,335 @@ export function registerMcpCommand(program) {
381
394
  process.exit(1);
382
395
  }
383
396
  });
397
+
398
+ // mcp scaffold — generate a boilerplate MCP server project
399
+ mcp
400
+ .command("scaffold <name>")
401
+ .description("Scaffold a new MCP server project (stdio or http+sse)")
402
+ .option("-d, --description <text>", "Short description of the server")
403
+ .option(
404
+ "-t, --transport <kind>",
405
+ `Transport: ${SUPPORTED_TRANSPORTS.join("|")}`,
406
+ "stdio",
407
+ )
408
+ .option("-o, --output <dir>", "Target directory (defaults to ./<name>)")
409
+ .option("-a, --author <name>", "package.json author field")
410
+ .option("-p, --port <n>", "HTTP port (http transport only)", (v) =>
411
+ parseInt(v, 10),
412
+ )
413
+ .option("--force", "Overwrite existing files")
414
+ .option("--dry-run", "Print files that would be written, don't touch disk")
415
+ .option("--json", "Output as JSON")
416
+ .action(async (name, options) => {
417
+ try {
418
+ const { files, summary } = generateMcpServerScaffold({
419
+ name,
420
+ description: options.description,
421
+ transport: options.transport,
422
+ author: options.author,
423
+ port: options.port,
424
+ });
425
+
426
+ const targetDir = path.resolve(options.output || `./${summary.name}`);
427
+
428
+ if (options.dryRun) {
429
+ if (options.json) {
430
+ console.log(JSON.stringify({ targetDir, summary, files }, null, 2));
431
+ } else {
432
+ logger.log(
433
+ `${chalk.bold("Would write")} ${files.length} files to ${chalk.cyan(targetDir)}:`,
434
+ );
435
+ for (const f of files) {
436
+ logger.log(
437
+ ` ${chalk.cyan(f.path)} ` +
438
+ chalk.dim(`(${f.content.length} bytes)`),
439
+ );
440
+ }
441
+ }
442
+ return;
443
+ }
444
+
445
+ // Collision check — refuse to clobber unless --force.
446
+ if (!options.force && fs.existsSync(targetDir)) {
447
+ const clashing = files.filter((f) =>
448
+ fs.existsSync(path.join(targetDir, f.path)),
449
+ );
450
+ if (clashing.length > 0) {
451
+ logger.error(
452
+ `Refusing to overwrite existing files in ${targetDir}: ` +
453
+ clashing.map((f) => f.path).join(", ") +
454
+ `. Re-run with --force to overwrite.`,
455
+ );
456
+ process.exit(1);
457
+ }
458
+ }
459
+
460
+ fs.mkdirSync(targetDir, { recursive: true });
461
+ for (const f of files) {
462
+ const full = path.join(targetDir, f.path);
463
+ fs.mkdirSync(path.dirname(full), { recursive: true });
464
+ fs.writeFileSync(full, f.content, "utf-8");
465
+ }
466
+
467
+ if (options.json) {
468
+ console.log(
469
+ JSON.stringify(
470
+ {
471
+ targetDir,
472
+ summary,
473
+ files: files.map((f) => f.path),
474
+ },
475
+ null,
476
+ 2,
477
+ ),
478
+ );
479
+ } else {
480
+ logger.success(
481
+ `Scaffolded ${chalk.cyan(summary.name)} ` +
482
+ chalk.dim(
483
+ `(${summary.transport}${summary.port ? `, port ${summary.port}` : ""})`,
484
+ ),
485
+ );
486
+ logger.log(` ${chalk.bold("Path:")} ${targetDir}`);
487
+ for (const f of files) {
488
+ logger.log(` ${chalk.dim("+")} ${f.path}`);
489
+ }
490
+ logger.log("");
491
+ logger.log(chalk.bold("Next steps:"));
492
+ logger.log(
493
+ ` ${chalk.dim("$")} cd ${path.relative(process.cwd(), targetDir) || "."}`,
494
+ );
495
+ logger.log(` ${chalk.dim("$")} npm install`);
496
+ if (summary.transport === "stdio") {
497
+ logger.log(
498
+ ` ${chalk.dim("$")} cc mcp add ${summary.name} -c node -a "./index.js"`,
499
+ );
500
+ } else {
501
+ logger.log(` ${chalk.dim("$")} npm start`);
502
+ logger.log(
503
+ ` ${chalk.dim("$")} cc mcp add ${summary.name} -u http://localhost:${summary.port}/mcp`,
504
+ );
505
+ }
506
+ }
507
+ } catch (err) {
508
+ logger.error(`Scaffold failed: ${err.message}`);
509
+ process.exit(1);
510
+ }
511
+ });
512
+
513
+ // mcp registry — browse + search + one-shot install from the bundled catalog
514
+ const registry = mcp
515
+ .command("registry")
516
+ .description("Browse the curated catalog of community MCP servers");
517
+
518
+ registry
519
+ .command("list")
520
+ .description("List catalog entries (filter by category/tag/author)")
521
+ .option("-c, --category <name>", "Filter by category")
522
+ .option("-t, --tags <list>", "Filter by comma-separated tags (any match)")
523
+ .option("--author <name>", "Filter by author (substring, case-insensitive)")
524
+ .option("--sort <field>", "Sort by 'name' | 'rating' | 'category'", "name")
525
+ .option("--order <dir>", "'asc' | 'desc'", "asc")
526
+ .option("--limit <n>", "Max results", (v) => parseInt(v, 10))
527
+ .option("--offset <n>", "Pagination offset", (v) => parseInt(v, 10))
528
+ .option("--json", "Output as JSON")
529
+ .action((options) => {
530
+ try {
531
+ const tags = options.tags
532
+ ? options.tags
533
+ .split(",")
534
+ .map((t) => t.trim())
535
+ .filter(Boolean)
536
+ : undefined;
537
+ const { servers, total } = registryListServers({
538
+ category: options.category,
539
+ tags,
540
+ author: options.author,
541
+ sortBy: options.sort,
542
+ sortOrder: options.order,
543
+ limit: options.limit,
544
+ offset: options.offset,
545
+ });
546
+
547
+ if (options.json) {
548
+ console.log(JSON.stringify({ servers, total }, null, 2));
549
+ return;
550
+ }
551
+
552
+ if (servers.length === 0) {
553
+ logger.info("No catalog entries match your filters.");
554
+ return;
555
+ }
556
+
557
+ logger.log(
558
+ chalk.bold(`MCP Registry — ${servers.length}/${total} servers\n`),
559
+ );
560
+ for (const s of servers) {
561
+ logger.log(
562
+ ` ${chalk.cyan(s.name)} ${chalk.gray(`(${s.id})`)} ` +
563
+ chalk.dim(`★${s.rating ?? "-"} ${s.category}`),
564
+ );
565
+ logger.log(` ${chalk.gray(s.description)}`);
566
+ if (s.tags?.length) {
567
+ logger.log(
568
+ ` ${chalk.gray("tags:")} ${s.tags.slice(0, 5).join(", ")}`,
569
+ );
570
+ }
571
+ }
572
+ } catch (err) {
573
+ logger.error(`Registry list failed: ${err.message}`);
574
+ process.exit(1);
575
+ }
576
+ });
577
+
578
+ registry
579
+ .command("search <keyword>")
580
+ .description("Keyword search across name/description/tags")
581
+ .option("--json", "Output as JSON")
582
+ .action((keyword, options) => {
583
+ try {
584
+ const hits = registrySearchServers(keyword);
585
+ if (options.json) {
586
+ console.log(JSON.stringify({ hits, total: hits.length }, null, 2));
587
+ return;
588
+ }
589
+ if (hits.length === 0) {
590
+ logger.info(`No matches for "${keyword}".`);
591
+ return;
592
+ }
593
+ logger.log(chalk.bold(`${hits.length} matches for "${keyword}":\n`));
594
+ for (const s of hits) {
595
+ logger.log(
596
+ ` ${chalk.cyan(s.name)} ${chalk.gray(`(${s.id})`)} ` +
597
+ chalk.dim(s.category),
598
+ );
599
+ logger.log(` ${chalk.gray(s.description)}`);
600
+ }
601
+ } catch (err) {
602
+ logger.error(`Registry search failed: ${err.message}`);
603
+ process.exit(1);
604
+ }
605
+ });
606
+
607
+ registry
608
+ .command("show <idOrName>")
609
+ .description("Show full catalog entry (id or short name)")
610
+ .option("--json", "Output as JSON")
611
+ .action((idOrName, options) => {
612
+ try {
613
+ const entry = registryGetServer(idOrName);
614
+ if (!entry) {
615
+ logger.error(`Not found: "${idOrName}".`);
616
+ process.exit(1);
617
+ }
618
+ if (options.json) {
619
+ console.log(JSON.stringify(entry, null, 2));
620
+ return;
621
+ }
622
+ logger.log(
623
+ `${chalk.bold(entry.displayName)} ${chalk.gray(`(${entry.id})`)}`,
624
+ );
625
+ logger.log(` ${chalk.gray("Author:")} ${entry.author}`);
626
+ logger.log(` ${chalk.gray("Category:")} ${entry.category}`);
627
+ logger.log(` ${chalk.gray("Version:")} ${entry.version}`);
628
+ logger.log(` ${chalk.gray("Rating:")} ★${entry.rating ?? "-"}`);
629
+ logger.log(` ${chalk.gray("Package:")} ${entry.npmPackage}`);
630
+ logger.log(
631
+ ` ${chalk.gray("Command:")} ${entry.command} ${entry.args.join(" ")}`,
632
+ );
633
+ logger.log(` ${chalk.gray("Transport:")}${entry.transport}`);
634
+ if (entry.homepage) {
635
+ logger.log(` ${chalk.gray("Homepage:")} ${entry.homepage}`);
636
+ }
637
+ logger.log(`\n ${entry.description}`);
638
+ if (entry.tools?.length) {
639
+ logger.log(`\n ${chalk.bold("Tools:")} ${entry.tools.join(", ")}`);
640
+ }
641
+ logger.log(
642
+ `\n ${chalk.dim("Install:")} cc mcp registry install ${entry.name}`,
643
+ );
644
+ } catch (err) {
645
+ logger.error(`Registry show failed: ${err.message}`);
646
+ process.exit(1);
647
+ }
648
+ });
649
+
650
+ registry
651
+ .command("install <idOrName>")
652
+ .description("Install a catalog entry by registering it as an MCP server")
653
+ .option("--as <name>", "Override the stored server name")
654
+ .option("--auto-connect", "Mark the server to auto-connect on startup")
655
+ .option("--json", "Output as JSON")
656
+ .action(async (idOrName, options) => {
657
+ try {
658
+ const entry = registryGetServer(idOrName);
659
+ if (!entry) {
660
+ logger.error(
661
+ `Not found: "${idOrName}". Try 'cc mcp registry list' or 'search'.`,
662
+ );
663
+ process.exit(1);
664
+ }
665
+
666
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
667
+ if (!ctx.db) {
668
+ logger.error("Database not available");
669
+ process.exit(1);
670
+ }
671
+
672
+ const db = ctx.db.getDatabase();
673
+ const config = new MCPServerConfig(db);
674
+ const storedName = (options.as || entry.name).trim();
675
+
676
+ config.add(storedName, {
677
+ command: entry.command,
678
+ args: entry.args,
679
+ autoConnect: !!options.autoConnect,
680
+ });
681
+
682
+ if (options.json) {
683
+ console.log(
684
+ JSON.stringify(
685
+ {
686
+ name: storedName,
687
+ id: entry.id,
688
+ command: entry.command,
689
+ args: entry.args,
690
+ autoConnect: !!options.autoConnect,
691
+ },
692
+ null,
693
+ 2,
694
+ ),
695
+ );
696
+ } else {
697
+ logger.success(
698
+ `Installed ${chalk.cyan(storedName)} ` + chalk.dim(`(${entry.id})`),
699
+ );
700
+ logger.log(
701
+ ` ${chalk.gray("Command:")} ${entry.command} ${entry.args.join(" ")}`,
702
+ );
703
+ logger.log(` ${chalk.gray("Next:")} cc mcp connect ${storedName}`);
704
+ }
705
+
706
+ await shutdown();
707
+ } catch (err) {
708
+ logger.error(`Registry install failed: ${err.message}`);
709
+ process.exit(1);
710
+ }
711
+ });
712
+
713
+ registry
714
+ .command("categories")
715
+ .description("List available registry categories")
716
+ .option("--json", "Output as JSON")
717
+ .action((options) => {
718
+ if (options.json) {
719
+ console.log(JSON.stringify(REGISTRY_CATEGORIES, null, 2));
720
+ return;
721
+ }
722
+ logger.log(chalk.bold("Categories:"));
723
+ for (const c of REGISTRY_CATEGORIES) {
724
+ const count = REGISTRY_CATALOG.filter((s) => s.category === c).length;
725
+ logger.log(` ${chalk.cyan(c)} ${chalk.dim(`(${count})`)}`);
726
+ }
727
+ });
384
728
  }