@nuniapp/cli 0.1.8 → 0.1.10

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.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/dist/index.mjs +291 -58
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,9 +14,11 @@ Read, reply to and resolve comments from the terminal, or let your coding agent
14
14
  ```sh
15
15
  npx @nuniapp/cli@latest login # approve this terminal in the Nuni dashboard
16
16
  npx @nuniapp/cli@latest comments # open comments, newest first
17
+ npx @nuniapp/cli@latest comments --search "typo" --group element
18
+ npx @nuniapp/cli@latest pages # pages with open comments
17
19
  npx @nuniapp/cli@latest comment <id> # one comment with the element, DOM, console and screenshot
18
20
  npx @nuniapp/cli@latest reply <id> "message"
19
- npx @nuniapp/cli@latest resolve <id> --note "what changed"
21
+ npx @nuniapp/cli@latest resolve <id...> --note "what changed"
20
22
  claude mcp add nuni -- npx -y @nuniapp/cli@latest mcp
21
23
  ```
22
24
 
package/dist/index.mjs CHANGED
@@ -6,7 +6,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync,
6
6
  import { homedir } from "node:os";
7
7
  import { createInterface } from "node:readline";
8
8
  //#region package.json
9
- var version$1 = "0.1.8";
9
+ var version$1 = "0.1.10";
10
10
  //#endregion
11
11
  //#region ../shared/src/path.ts
12
12
  /** URL that opens the page with the comment focused by the widget. */
@@ -80,7 +80,16 @@ function buildCommentPrompt(comment, options = {}) {
80
80
  const includeContext = options.includeContext ?? true;
81
81
  const { anchor, page } = comment;
82
82
  const out = [];
83
- out.push("Fix this piece of feedback that was left on the live site with Nuni.", "", "## Comment", "", quote(comment.body), "", `From ${comment.authorName} on ${new Date(comment.createdAt).toISOString().slice(0, 10)}` + (comment.status === "resolved" ? " (already resolved)" : ""));
83
+ out.push("Fix this piece of feedback that was left on the live site with Nuni.", "", "## Comment", "", ...comment.body ? [quote(comment.body), ""] : [], ...comment.suggestion ? [
84
+ "Suggested text change. Replace:",
85
+ "",
86
+ quote(comment.suggestion.before),
87
+ "",
88
+ "With:",
89
+ "",
90
+ quote(comment.suggestion.after),
91
+ ""
92
+ ] : [], `From ${comment.authorName} on ${new Date(comment.createdAt).toISOString().slice(0, 10)}` + (comment.status === "resolved" ? comment.resolution === "outdated" ? " (closed as outdated)" : " (already resolved)" : ""));
84
93
  if (comment.replies?.length) {
85
94
  out.push("", `## Replies (${comment.replies.length})`, "");
86
95
  for (const reply of comment.replies) out.push(`${reply.authorName}${reply.isOwner ? " (owner)" : ""}:`, quote(reply.body), "");
@@ -92,6 +101,7 @@ function buildCommentPrompt(comment, options = {}) {
92
101
  out.push(`- Viewport: ${comment.viewport.w}×${comment.viewport.h} at ${comment.viewport.dpr}x`);
93
102
  if (includeContext && comment.userAgent) out.push(`- Browser: ${comment.userAgent}`);
94
103
  out.push("", "## Element", "");
104
+ if (comment.pinLostAt) out.push(`- Not found on the page since ${new Date(comment.pinLostAt).toISOString().slice(0, 10)}: it may have changed or been removed. Check whether the feedback still applies.`);
95
105
  out.push(`- Tag: <${anchor.tag}>`);
96
106
  if (anchor.text) out.push(`- Text: ${inline(anchor.text)}`);
97
107
  if (anchor.quote) out.push(`- Selected text: ${inline(anchor.quote.exact)}`);
@@ -119,8 +129,9 @@ function buildCommentPrompt(comment, options = {}) {
119
129
  out.push("", `## Failed requests (${context.network.length})`, "");
120
130
  for (const r of context.network) out.push(`- ${r.method} ${r.url} → ${r.status === 0 ? "network error" : r.status}`);
121
131
  }
132
+ if (comment.imageUrls?.length) out.push("", `## Images from ${comment.authorName} (${comment.imageUrls.length})`, "", ...comment.imageUrls.map((url) => `- ${url}`));
122
133
  if (includeContext && comment.screenshotUrl) out.push("", "## Screenshot", "", comment.screenshotUrl);
123
- out.push("", "## Steps", "", "1. Find the code that renders this element. Search for the text, the component name, the test ID or the selector.", "2. Make the change the comment asks for, and keep the fix focused on it.", "3. Tell me what you changed, so the comment can be resolved in Nuni.", ` If the Nuni CLI is signed in to this project (\`npx ${PACKAGES.cli}@latest login\`), resolve it with \`npx ${PACKAGES.cli}@latest resolve ${comment._id}\` or the \`resolve_comment\` MCP tool.`);
134
+ out.push("", "## Steps", "", "1. Find the code that renders this element. Search for the text, the component name, the test ID or the selector.", comment.suggestion ? "2. Replace the text exactly as suggested (wherever it comes from: markup, a component, a translation or content file), and keep the fix focused on it." : "2. Make the change the comment asks for, and keep the fix focused on it.", "3. Tell me what you changed, so the comment can be resolved in Nuni.", ` If the Nuni CLI is signed in to this project (\`npx ${PACKAGES.cli}@latest login\`), resolve it with \`npx ${PACKAGES.cli}@latest resolve ${comment._id}\` or the \`resolve_comment\` MCP tool.`);
124
135
  return out.join("\n");
125
136
  }
126
137
  //#endregion
@@ -237,17 +248,62 @@ function oneLine(text, max = 120) {
237
248
  const clean = text.replace(/\s+/g, " ").trim();
238
249
  return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
239
250
  }
240
- /** A compact, numbered list: enough to pick a comment, not the full context. */
251
+ /**
252
+ * What a comment is about, for grouping: the page plus the element's
253
+ * strongest selector, plus the words for a text comment.
254
+ */
255
+ function elementKey(c) {
256
+ const { selectors } = c.anchor;
257
+ const target = selectors.testId ? `[data-testid="${selectors.testId}"]` : selectors.id ? `#${selectors.id}` : selectors.css ?? selectors.path;
258
+ const quote = c.anchor.quote ? ` "${oneLine(c.anchor.quote.exact, 60)}"` : "";
259
+ return `${c.page.path} · ${target}${quote}`;
260
+ }
261
+ function describe(c, n) {
262
+ const element = c.anchor.text ? `<${c.anchor.tag}> "${oneLine(c.anchor.text, 60)}"` : `<${c.anchor.tag}>`;
263
+ return [
264
+ `${n}. ${c._id} · ${c.page.path} · ${c.authorName} · ${timeAgo(c.createdAt)}${c.pinLost ? " · element not found" : ""}`,
265
+ ...c.body ? [` "${oneLine(c.body)}"`] : [],
266
+ ...c.suggestion ? [` Suggests: "${oneLine(c.suggestion.before, 60)}" → "${oneLine(c.suggestion.after, 60)}"`] : [],
267
+ c.anchor.quote ? ` On the text "${oneLine(c.anchor.quote.exact, 60)}" in ${element}` : ` ${c.anchor.region ? "On an area of" : "On"} ${element}`
268
+ ];
269
+ }
270
+ /**
271
+ * A compact, numbered list: enough to pick a comment, not the full context.
272
+ * Grouped by page or by element, related comments sit together so they can
273
+ * be fixed in one go.
274
+ */
241
275
  function formatCommentList(comments, options) {
242
276
  if (!comments.length) return `No ${options.status} comments.`;
243
- const lines = [`${comments.length}${options.more ? "+" : ""} ${options.status} comment${comments.length === 1 ? "" : "s"}, newest first:`, ""];
244
- comments.forEach((c, i) => {
245
- const element = c.anchor.text ? `<${c.anchor.tag}> "${oneLine(c.anchor.text, 60)}"` : `<${c.anchor.tag}>`;
246
- lines.push(`${i + 1}. ${c._id} · ${c.page.path} · ${c.authorName} · ${timeAgo(c.createdAt)}`, ` "${oneLine(c.body)}"`, c.anchor.quote ? ` On the text "${oneLine(c.anchor.quote.exact, 60)}" in ${element}` : ` ${c.anchor.region ? "On an area of" : "On"} ${element}`);
247
- });
277
+ const lines = [`${comments.length}${options.more ? "+" : ""} ${options.status} comment${comments.length === 1 ? "" : "s"}, ${options.order ?? "newest first"}${options.groupBy ? `, grouped by ${options.groupBy}` : ""}:`, ""];
278
+ if (!options.groupBy) comments.forEach((c, i) => lines.push(...describe(c, i + 1)));
279
+ else {
280
+ const keyOf = options.groupBy === "page" ? (c) => c.page.path : elementKey;
281
+ const groups = /* @__PURE__ */ new Map();
282
+ for (const c of comments) {
283
+ const key = keyOf(c);
284
+ groups.set(key, [...groups.get(key) ?? [], c]);
285
+ }
286
+ let n = 0;
287
+ for (const [key, group] of groups) {
288
+ lines.push(`## ${key} (${group.length})`);
289
+ for (const c of group) lines.push(...describe(c, ++n));
290
+ lines.push("");
291
+ }
292
+ lines.pop();
293
+ }
248
294
  lines.push("", options.detailHint);
249
295
  return lines.join("\n");
250
296
  }
297
+ /** Pages with open comments, most first. */
298
+ function formatPageList(pages) {
299
+ if (!pages.length) return "No pages with open comments.";
300
+ const width = Math.max(...pages.map((p) => String(p.openCount).length));
301
+ return [
302
+ `${pages.length} page${pages.length === 1 ? "" : "s"} with open comments:`,
303
+ "",
304
+ ...pages.map((p) => `${String(p.openCount).padStart(width)} open ${p.path}`)
305
+ ].join("\n");
306
+ }
251
307
  //#endregion
252
308
  //#region src/project.ts
253
309
  const SKIP_DIRS = /* @__PURE__ */ new Set([
@@ -1264,6 +1320,10 @@ function createRemote() {
1264
1320
  ...options
1265
1321
  });
1266
1322
  }),
1323
+ listPages: (publicId, token) => call(() => client.query(api.comments.pagesForAgent, {
1324
+ publicId,
1325
+ sessionToken: token
1326
+ })),
1267
1327
  getComment: (publicId, token, id) => call(async () => {
1268
1328
  return await client.query(api.comments.getForOwner, {
1269
1329
  publicId,
@@ -1289,6 +1349,49 @@ function createRemote() {
1289
1349
  };
1290
1350
  }
1291
1351
  //#endregion
1352
+ //#region src/resolve.ts
1353
+ const message = (error) => error instanceof Error ? error.message : String(error);
1354
+ /**
1355
+ * Resolve comments one by one, each with the note as a reply. One that fails
1356
+ * (deleted, not in this project) doesn't stop the rest.
1357
+ */
1358
+ async function resolveComments(remote, session, ids, note) {
1359
+ const result = {
1360
+ resolved: [],
1361
+ failed: [],
1362
+ noteFailed: []
1363
+ };
1364
+ for (const id of [...new Set(ids)]) {
1365
+ try {
1366
+ await remote.setStatus(session.token, id, "resolved");
1367
+ } catch (error) {
1368
+ result.failed.push({
1369
+ id,
1370
+ error: message(error)
1371
+ });
1372
+ continue;
1373
+ }
1374
+ result.resolved.push(id);
1375
+ if (!note?.trim()) continue;
1376
+ try {
1377
+ await remote.reply(session.publicId, session.token, id, note.trim());
1378
+ } catch (error) {
1379
+ result.noteFailed.push({
1380
+ id,
1381
+ error: message(error)
1382
+ });
1383
+ }
1384
+ }
1385
+ return result;
1386
+ }
1387
+ function formatResolveResult({ resolved, failed, noteFailed }) {
1388
+ return [
1389
+ resolved.length ? `Resolved ${resolved.join(", ")}.` : "",
1390
+ ...failed.map((f) => `Couldn't resolve ${f.id}: ${f.error}`),
1391
+ ...noteFailed.map((f) => `Resolved ${f.id}, but couldn't post the note: ${f.error}`)
1392
+ ].filter(Boolean).join("\n");
1393
+ }
1394
+ //#endregion
1292
1395
  //#region src/mcp.ts
1293
1396
  /**
1294
1397
  * A Model Context Protocol server over stdio (newline-delimited JSON-RPC),
@@ -1306,6 +1409,7 @@ const PROTOCOL_VERSIONS = [
1306
1409
  "2024-11-05"
1307
1410
  ];
1308
1411
  const LOGIN$1 = `npx ${PACKAGES.cli}@latest login`;
1412
+ const MAX_RESOLVE = 50;
1309
1413
  var ToolError = class extends Error {};
1310
1414
  const text = (value) => ({ content: [{
1311
1415
  type: "text",
@@ -1346,51 +1450,96 @@ function createMcpServer(options) {
1346
1450
  token: credential.token
1347
1451
  };
1348
1452
  }
1453
+ const DETAIL_HINT = "Call get_comment with an id for the element, DOM, styles, console errors, failed requests and screenshot.";
1454
+ const listSchema = {
1455
+ status: {
1456
+ type: "string",
1457
+ enum: ["open", "resolved"],
1458
+ description: "Defaults to open."
1459
+ },
1460
+ page: {
1461
+ type: "string",
1462
+ description: "Only comments on this path, for example /pricing."
1463
+ },
1464
+ group_by: {
1465
+ type: "string",
1466
+ enum: ["page", "element"],
1467
+ description: "Put related comments together: by page, or by the element they are on (several comments about the same button, heading or text)."
1468
+ },
1469
+ limit: {
1470
+ type: "integer",
1471
+ minimum: 1,
1472
+ maximum: 50
1473
+ }
1474
+ };
1475
+ async function list(args, search) {
1476
+ const { publicId, token } = session();
1477
+ const status = args.status === "resolved" ? "resolved" : "open";
1478
+ const page = await remote.listComments(publicId, token, {
1479
+ status,
1480
+ path: typeof args.page === "string" ? args.page : void 0,
1481
+ search,
1482
+ limit: typeof args.limit === "number" ? args.limit : 20
1483
+ });
1484
+ return text(formatCommentList(page.comments, {
1485
+ status,
1486
+ more: Boolean(page.cursor),
1487
+ order: search ? "best match first" : void 0,
1488
+ groupBy: args.group_by === "page" || args.group_by === "element" ? args.group_by : void 0,
1489
+ detailHint: DETAIL_HINT
1490
+ }));
1491
+ }
1349
1492
  const tools = [
1350
1493
  {
1351
1494
  name: "list_comments",
1352
1495
  title: "List Nuni comments",
1353
- description: "List feedback comments left on the live site with Nuni, newest first. Each has an id, the page, the author and the element it is pinned to. Use get_comment for the full context.",
1496
+ description: "List feedback comments left on the live site with Nuni, newest first. Each has an id, the page, the author and the element it is pinned to. Group them by page or element to fix related ones together. Use get_comment for the full context.",
1497
+ inputSchema: {
1498
+ type: "object",
1499
+ properties: listSchema,
1500
+ additionalProperties: false
1501
+ },
1502
+ annotations: { readOnlyHint: true },
1503
+ run: (args) => list(args)
1504
+ },
1505
+ {
1506
+ name: "search_comments",
1507
+ title: "Search Nuni comments",
1508
+ description: "Find comments by the words in them or the author's name, best match first. For example \"typo\", \"button color\" or a person's name.",
1354
1509
  inputSchema: {
1355
1510
  type: "object",
1356
1511
  properties: {
1357
- status: {
1512
+ query: {
1358
1513
  type: "string",
1359
- enum: ["open", "resolved"],
1360
- description: "Defaults to open."
1514
+ description: "The words to look for."
1361
1515
  },
1362
- page: {
1363
- type: "string",
1364
- description: "Only comments on this path, for example /pricing."
1365
- },
1366
- limit: {
1367
- type: "integer",
1368
- minimum: 1,
1369
- maximum: 50
1370
- }
1516
+ ...listSchema
1371
1517
  },
1518
+ required: ["query"],
1372
1519
  additionalProperties: false
1373
1520
  },
1374
1521
  annotations: { readOnlyHint: true },
1375
- async run(args) {
1522
+ run: (args) => list(args, str(args, "query"))
1523
+ },
1524
+ {
1525
+ name: "list_pages",
1526
+ title: "List pages with Nuni comments",
1527
+ description: "The site's pages that have open comments, with how many each has, most first. Use a path with list_comments to work through one page.",
1528
+ inputSchema: {
1529
+ type: "object",
1530
+ properties: {},
1531
+ additionalProperties: false
1532
+ },
1533
+ annotations: { readOnlyHint: true },
1534
+ async run() {
1376
1535
  const { publicId, token } = session();
1377
- const status = args.status === "resolved" ? "resolved" : "open";
1378
- const page = await remote.listComments(publicId, token, {
1379
- status,
1380
- path: typeof args.page === "string" ? args.page : void 0,
1381
- limit: typeof args.limit === "number" ? args.limit : 20
1382
- });
1383
- return text(formatCommentList(page.comments, {
1384
- status,
1385
- more: Boolean(page.cursor),
1386
- detailHint: "Call get_comment with an id for the element, DOM, styles, console errors, failed requests and screenshot."
1387
- }));
1536
+ return text(formatPageList(await remote.listPages(publicId, token)));
1388
1537
  }
1389
1538
  },
1390
1539
  {
1391
1540
  name: "get_comment",
1392
1541
  title: "Get a Nuni comment",
1393
- description: "One comment with everything needed to fix it: the comment, the page link, the element (text, selectors, React component), its HTML and styles, console errors, failed requests and a screenshot.",
1542
+ description: "One comment with everything needed to fix it: the comment, the page link, the element (text, selectors, React component), its HTML and styles, console errors, failed requests, the images the commenter attached and a screenshot.",
1394
1543
  inputSchema: {
1395
1544
  type: "object",
1396
1545
  properties: { id: {
@@ -1409,8 +1558,8 @@ function createMcpServer(options) {
1409
1558
  type: "text",
1410
1559
  text: buildCommentPrompt(comment)
1411
1560
  }];
1412
- if (comment.screenshotUrl) {
1413
- const image = await (options.fetchImage ?? fetchImage)(comment.screenshotUrl);
1561
+ for (const url of [...comment.imageUrls ?? [], ...comment.screenshotUrl ? [comment.screenshotUrl] : []]) {
1562
+ const image = await (options.fetchImage ?? fetchImage)(url);
1414
1563
  if (image) content.push({
1415
1564
  type: "image",
1416
1565
  ...image
@@ -1470,6 +1619,39 @@ function createMcpServer(options) {
1470
1619
  return text(`Resolved ${id}.`);
1471
1620
  }
1472
1621
  },
1622
+ {
1623
+ name: "resolve_comments",
1624
+ title: "Resolve several Nuni comments",
1625
+ description: "Resolve several comments at once, for example all the ones a single change fixed. The note, if any, is posted as a reply on each. Comments that can't be resolved are listed; the rest still are.",
1626
+ inputSchema: {
1627
+ type: "object",
1628
+ properties: {
1629
+ ids: {
1630
+ type: "array",
1631
+ items: { type: "string" },
1632
+ minItems: 1,
1633
+ maxItems: MAX_RESOLVE
1634
+ },
1635
+ note: {
1636
+ type: "string",
1637
+ description: "Optional: what was changed, posted as a reply on each."
1638
+ }
1639
+ },
1640
+ required: ["ids"],
1641
+ additionalProperties: false
1642
+ },
1643
+ annotations: { idempotentHint: true },
1644
+ async run(args) {
1645
+ const ids = Array.isArray(args.ids) ? args.ids.filter((id) => typeof id === "string") : [];
1646
+ if (!ids.length) throw new ToolError(`"ids" is required`);
1647
+ if (ids.length > MAX_RESOLVE) throw new ToolError(`Resolve up to ${MAX_RESOLVE} comments at a time`);
1648
+ const result = await resolveComments(remote, session(), ids, typeof args.note === "string" ? args.note : void 0);
1649
+ return {
1650
+ ...text(formatResolveResult(result)),
1651
+ ...result.resolved.length ? {} : { isError: true }
1652
+ };
1653
+ }
1654
+ },
1473
1655
  {
1474
1656
  name: "reopen_comment",
1475
1657
  title: "Reopen a Nuni comment",
@@ -1522,7 +1704,7 @@ function createMcpServer(options) {
1522
1704
  title: "Nuni",
1523
1705
  version: options.version
1524
1706
  },
1525
- instructions: "Nuni comments are feedback pinned to elements on the live site. List them, read one with its full context, fix the code, then resolve it."
1707
+ instructions: "Nuni comments are feedback pinned to elements on the live site. List them (by page or grouped by element), search them, read one with its full context, fix the code, then resolve it. Resolve several at once when one change fixed them all."
1526
1708
  };
1527
1709
  break;
1528
1710
  }
@@ -1711,12 +1893,15 @@ async function whoami(ctx) {
1711
1893
  async function listComments(ctx, options) {
1712
1894
  const { publicId, token } = signedIn(ctx);
1713
1895
  if (options.status && options.status !== "open" && options.status !== "resolved") throw new CommandError(`--status must be open or resolved`);
1896
+ if (options.group && options.group !== "page" && options.group !== "element") throw new CommandError(`--group must be page or element`);
1714
1897
  const status = options.status ?? "open";
1715
1898
  const limit = options.limit ? Number(options.limit) : 20;
1716
1899
  if (!Number.isInteger(limit) || limit < 1) throw new CommandError(`--limit must be a positive number`);
1900
+ const search = options.search?.trim() || void 0;
1717
1901
  const page = await ctx.remote.listComments(publicId, token, {
1718
1902
  status,
1719
1903
  path: options.page,
1904
+ search,
1720
1905
  limit
1721
1906
  });
1722
1907
  if (ctx.json) {
@@ -1726,35 +1911,56 @@ async function listComments(ctx, options) {
1726
1911
  ctx.out(formatCommentList(page.comments, {
1727
1912
  status,
1728
1913
  more: Boolean(page.cursor),
1914
+ order: search ? "best match first" : void 0,
1915
+ groupBy: options.group,
1729
1916
  detailHint: `Full context: npx ${PACKAGES.cli}@latest comment <id>`
1730
1917
  }));
1731
1918
  return 0;
1732
1919
  }
1920
+ async function listPages(ctx) {
1921
+ const { publicId, token } = signedIn(ctx);
1922
+ const pages = await ctx.remote.listPages(publicId, token);
1923
+ ctx.out(ctx.json ? JSON.stringify(pages, null, 2) : formatPageList(pages));
1924
+ return 0;
1925
+ }
1733
1926
  async function showComment(ctx, id, options) {
1734
1927
  if (!id) throw new CommandError("Pass a comment id: comment <id>");
1735
1928
  const { publicId, token } = signedIn(ctx);
1736
1929
  const comment = await ctx.remote.getComment(publicId, token, id);
1737
1930
  if (!comment) throw new CommandError(`No comment ${id} in ${publicId}.`);
1738
1931
  let saved = null;
1739
- if (options.saveScreenshot && comment.screenshotUrl) {
1740
- const image = await fetchImage(comment.screenshotUrl);
1741
- if (image) {
1742
- const dir = resolve(ctx.cwd, options.saveScreenshot);
1932
+ const imageFiles = [];
1933
+ if (options.saveScreenshot) {
1934
+ const dir = resolve(ctx.cwd, options.saveScreenshot);
1935
+ const files = [...comment.screenshotUrl ? [{
1936
+ url: comment.screenshotUrl,
1937
+ name: `nuni-${comment._id}`
1938
+ }] : [], ...(comment.imageUrls ?? []).map((url, i) => ({
1939
+ url,
1940
+ name: `nuni-${comment._id}-image-${i + 1}`
1941
+ }))];
1942
+ for (const file of files) {
1943
+ const image = await fetchImage(file.url);
1944
+ if (!image) continue;
1743
1945
  mkdirSync(dir, { recursive: true });
1744
1946
  const ext = image.mimeType.split("/")[1] ?? "webp";
1745
- saved = join(dir, `nuni-${comment._id}.${ext}`);
1746
- writeFileSync(saved, Buffer.from(image.data, "base64"));
1947
+ const path = join(dir, `${file.name}.${ext}`);
1948
+ writeFileSync(path, Buffer.from(image.data, "base64"));
1949
+ if (file.url === comment.screenshotUrl) saved = path;
1950
+ else imageFiles.push(path);
1747
1951
  }
1748
1952
  }
1749
1953
  if (ctx.json) {
1750
1954
  ctx.out(JSON.stringify({
1751
1955
  ...comment,
1752
- screenshotFile: saved
1956
+ screenshotFile: saved,
1957
+ imageFiles
1753
1958
  }, null, 2));
1754
1959
  return 0;
1755
1960
  }
1756
1961
  ctx.out(buildCommentPrompt(comment));
1757
1962
  if (saved) ctx.out(`\nScreenshot saved to ${saved}`);
1963
+ for (const path of imageFiles) ctx.out(`Image saved to ${path}`);
1758
1964
  return 0;
1759
1965
  }
1760
1966
  async function reply(ctx, id, body) {
@@ -1767,16 +1973,36 @@ async function reply(ctx, id, body) {
1767
1973
  }) : `Replied to ${id}.`);
1768
1974
  return 0;
1769
1975
  }
1770
- async function setStatus(ctx, id, status, note) {
1771
- const verb = status === "resolved" ? "resolve" : "reopen";
1772
- if (!id) throw new CommandError(`Pass a comment id: ${verb} <id>`);
1773
- const { publicId, token } = signedIn(ctx);
1774
- await ctx.remote.setStatus(token, id, status);
1775
- if (note?.trim()) await ctx.remote.reply(publicId, token, id, note);
1976
+ async function markResolved(ctx, ids, note) {
1977
+ if (!ids.length) throw new CommandError("Pass a comment id: resolve <id...>");
1978
+ const session = signedIn(ctx);
1979
+ if (ids.length === 1) {
1980
+ const id = ids[0];
1981
+ await ctx.remote.setStatus(session.token, id, "resolved");
1982
+ if (note?.trim()) await ctx.remote.reply(session.publicId, session.token, id, note);
1983
+ ctx.out(ctx.json ? JSON.stringify({
1984
+ id,
1985
+ status: "resolved"
1986
+ }) : `Resolved ${id}.`);
1987
+ return 0;
1988
+ }
1989
+ const result = await resolveComments(ctx.remote, session, ids, note);
1990
+ if (ctx.json) ctx.out(JSON.stringify(result, null, 2));
1991
+ else {
1992
+ if (result.resolved.length) ctx.out(`Resolved ${result.resolved.join(", ")}.`);
1993
+ for (const f of result.failed) ctx.err(`Couldn't resolve ${f.id}: ${f.error}`);
1994
+ for (const f of result.noteFailed) ctx.err(`Resolved ${f.id}, but couldn't post the note: ${f.error}`);
1995
+ }
1996
+ return result.failed.length || result.noteFailed.length ? 1 : 0;
1997
+ }
1998
+ async function reopen(ctx, id) {
1999
+ if (!id) throw new CommandError(`Pass a comment id: reopen <id>`);
2000
+ const { token } = signedIn(ctx);
2001
+ await ctx.remote.setStatus(token, id, "open");
1776
2002
  ctx.out(ctx.json ? JSON.stringify({
1777
2003
  id,
1778
- status
1779
- }) : `${status === "resolved" ? "Resolved" : "Reopened"} ${id}.`);
2004
+ status: "open"
2005
+ }) : `Reopened ${id}.`);
1780
2006
  return 0;
1781
2007
  }
1782
2008
  //#endregion
@@ -2006,9 +2232,11 @@ Usage
2006
2232
 
2007
2233
  npx @nuniapp/cli@latest login sign in to this project's comments
2008
2234
  npx @nuniapp/cli@latest comments [--status open|resolved] [--page /path] [--limit 20]
2235
+ [--search "words"] [--group page|element]
2236
+ npx @nuniapp/cli@latest pages
2009
2237
  npx @nuniapp/cli@latest comment <id> [--save-screenshot <dir>]
2010
2238
  npx @nuniapp/cli@latest reply <id> "message"
2011
- npx @nuniapp/cli@latest resolve <id> [--note "what changed"]
2239
+ npx @nuniapp/cli@latest resolve <id...> [--note "what changed"]
2012
2240
  npx @nuniapp/cli@latest reopen <id>
2013
2241
  npx @nuniapp/cli@latest whoami | logout
2014
2242
  npx @nuniapp/cli@latest mcp MCP server for coding agents (stdio)
@@ -2018,10 +2246,11 @@ Commands
2018
2246
  prompt Print the ready-made prompt for Codex, Claude Code, Cursor, etc.
2019
2247
  id Print a new project ID
2020
2248
  login Approve this terminal in the Nuni dashboard (works over SSH too)
2021
- comments List comments, newest first
2249
+ comments List comments, newest first (or best match with --search)
2250
+ pages Pages with open comments, most first
2022
2251
  comment One comment with the element, DOM, styles, console and screenshot
2023
2252
  reply Reply in a comment's thread, as the owner
2024
- resolve Mark a comment resolved (with an optional note); reopen does the opposite
2253
+ resolve Mark comments resolved (with an optional note); reopen does the opposite
2025
2254
  mcp Run the MCP server: claude mcp add nuni -- npx -y @nuniapp/cli@latest mcp
2026
2255
 
2027
2256
  The project comes from --project, $NUNI_PROJECT, or the nuni_ ID in your code.
@@ -2042,6 +2271,8 @@ const OPTIONS = {
2042
2271
  limit: { type: "string" },
2043
2272
  "save-screenshot": { type: "string" },
2044
2273
  note: { type: "string" },
2274
+ search: { type: "string" },
2275
+ group: { type: "string" },
2045
2276
  json: {
2046
2277
  type: "boolean",
2047
2278
  default: false
@@ -2062,6 +2293,7 @@ const ACCOUNT_COMMANDS = /* @__PURE__ */ new Set([
2062
2293
  "logout",
2063
2294
  "whoami",
2064
2295
  "comments",
2296
+ "pages",
2065
2297
  "comment",
2066
2298
  "reply",
2067
2299
  "resolve",
@@ -2169,10 +2401,11 @@ async function runAccountCommand(command, positionals, values, options) {
2169
2401
  case "logout": return await logout(ctx);
2170
2402
  case "whoami": return await whoami(ctx);
2171
2403
  case "comments": return await listComments(ctx, values);
2404
+ case "pages": return await listPages(ctx);
2172
2405
  case "comment": return await showComment(ctx, arg, { saveScreenshot: values["save-screenshot"] });
2173
2406
  case "reply": return await reply(ctx, arg, positionals[2]);
2174
- case "resolve": return await setStatus(ctx, arg, "resolved", values.note);
2175
- case "reopen": return await setStatus(ctx, arg, "open");
2407
+ case "resolve": return await markResolved(ctx, positionals.slice(1), values.note);
2408
+ case "reopen": return await reopen(ctx, arg);
2176
2409
  default:
2177
2410
  await serveStdio(createMcpServer({
2178
2411
  remote: ctx.remote,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuniapp/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Set up Nuni in any project, and give coding agents its comments: login, list, read and resolve from the terminal or over MCP.",
5
5
  "license": "MIT",
6
6
  "type": "module",