@nuniapp/cli 0.1.7 → 0.1.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.
- package/README.md +3 -1
- package/dist/index.mjs +291 -58
- 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
|
|
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.
|
|
9
|
+
var version$1 = "0.1.9";
|
|
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), ""
|
|
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
|
-
/**
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
1512
|
+
query: {
|
|
1358
1513
|
type: "string",
|
|
1359
|
-
|
|
1360
|
-
description: "Defaults to open."
|
|
1514
|
+
description: "The words to look for."
|
|
1361
1515
|
},
|
|
1362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1413
|
-
const image = await (options.fetchImage ?? fetchImage)(
|
|
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
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
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
|
-
|
|
1746
|
-
writeFileSync(
|
|
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
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
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
|
-
}) :
|
|
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
|
|
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
|
|
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
|
|
2175
|
-
case "reopen": return await
|
|
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