rootr-cli 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -482,7 +482,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
482
482
  | 도구 | 설명 |
483
483
  |---|---|
484
484
  | `rootr_create_issue_tracker` | 이슈 트래커 생성 |
485
- | `rootr_list_issues` | 이슈 목록(state/label/type/assignee/검색어 필터) |
485
+ | `rootr_list_issues` | 이슈 목록(state/label/type/assignee/검색어 필터). 나눠 받기: `limit`(1~200)·`cursor`(직전 `nextCursor`) → `nextCursor`·`total` |
486
486
  | `rootr_create_issue` | 이슈 생성(labels/type/parentIssueId 지원) |
487
487
  | `rootr_get_issue` | 이슈 상세 조회 |
488
488
  | `rootr_update_issue` | 이슈 갱신(닫기: state=CLOSED + stateReason) |
@@ -490,12 +490,14 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
490
490
  | `rootr_update_issue_tracker` | 트래커 설정 — **외부 참여**(`read` 보기만 · `login` 로그인한 사람만 작성 · `anyone` 누구나 작성), 라벨·유형 |
491
491
  | `rootr_submit_public_issue` | **웹게시한** 트래커에 밖에서 이슈 올리기(공개 주소로, `asGuest`+`guestName` 이면 손님으로, 페이지 블록은 `blockId`, `files:[{filename, contentBase64}]` 첨부) |
492
492
  | `rootr_comment_public_issue` | 웹게시한 트래커의 이슈에 댓글(`files` 첨부, `read:true` 면 공개 댓글 읽기) |
493
- | `rootr_search_public_issues` | 웹게시한 트래커를 밖에서 찾기 — `q`(제목·본문·#번호·쓴 사람), `labels`(모두 가진 것), `state`, `sort`(newest·oldest·comments) |
493
+ | `rootr_search_public_issues` | 웹게시한 트래커를 밖에서 찾기 — `q`(제목·본문·#번호·쓴 사람), `labels`(모두 가진 것), `state`, `sort`(newest·oldest·comments), `limit`·`cursor` 로 나눠 받기 |
494
494
 
495
495
  CLI: `rootr issue tracker <트래커> --participation anyone` · `rootr issue list|create|comment …` ·
496
496
  `rootr issue list <공개 주소> --q "결제" --label 버그` · `rootr issue submit <공개 주소> --title "…" --name <이름> --file 화면.png --file 로그.txt` ·
497
497
  `rootr issue reply <공개 주소> <#번호> "<댓글>" [--file <경로>]`.
498
- 첨부는 그림(png·jpg·gif·webp)·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일 10MB·글 하나에 10개까지입니다(확장자와 파일 첫 바이트가 맞아야 함).
498
+ 목록은 **나눠 받습니다**(CLI 0.35+): 깃발이 없으면 예전처럼 최신 500건, `--limit 1~200` 은 한 페이지(맨 아래에 다음 `--cursor` 를 알려 줌),
499
+ `--all` 은 끝까지 이어 받습니다 — 예: `rootr issue list /지원/피드백 --state ALL --all --json`.
500
+ 첨부는 그림(png·jpg·gif·webp)·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일 75MB(손님 25MB)·글 하나에 10개까지입니다(확장자와 파일 첫 바이트가 맞아야 함).
499
501
  외부 참여는 **트래커 설정**이라 페이지(PAGE)에 `issues` 블록으로 끼운 트래커도 같은 규칙을 따릅니다.
500
502
 
501
503
  #### 웹훅 (webhooks:manage 스코프)
package/README.md CHANGED
@@ -314,9 +314,17 @@ rootr issue submit https://rootr.io/ko/p/AbCd123 --title "Button does nothing" -
314
314
  rootr issue submit https://rootr.io/ko/p/AbCd123 --title "Crash" --file shot.png --file app.log --name "Kim" # with files
315
315
  rootr issue reply https://rootr.io/ko/p/AbCd123 12 "Same here" --name "Lee"
316
316
  rootr issue list https://rootr.io/ko/p/AbCd123 --q "payment" --label bug --sort comments # search a published tracker
317
+ rootr issue list /support/feedback --state ALL --limit 100 # one page; the last line prints the next --cursor
318
+ rootr issue list /support/feedback --state ALL --limit 100 --cursor <next cursor>
319
+ rootr issue list /support/feedback --state ALL --all --json # every issue, page by page (200 at a time)
317
320
  ```
318
321
 
319
- Outside attachments: images (png·jpg·gif·webp), pdf, txt·csv·log, docx·xlsx·pptx, zip — 10MB per file, 10 per
322
+ Issue lists are **paged** (CLI 0.35+): without flags you get the newest 500 as before; `--limit 1..200` gives
323
+ one page and prints `--cursor <…>` for the next, `--all` follows the cursors to the end. MCP
324
+ `rootr_list_issues` / `rootr_search_public_issues` take `limit` and `cursor` and return `nextCursor` (null on the
325
+ last page) and `total`.
326
+
327
+ Outside attachments: images (png·jpg·gif·webp), pdf, txt·csv·log, docx·xlsx·pptx, zip — 75MB per file signed in (25MB as a guest), 10 per
320
328
  issue/comment; the extension and the file's first bytes must agree. MCP: `rootr_submit_public_issue` /
321
329
  `rootr_comment_public_issue` take `files: [{ filename, contentBase64 }]` (contents, never a path), and
322
330
  `rootr_search_public_issues` searches a published tracker the way a visitor does.
package/bin/rootr.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  import { setGlobalDocMailSink } from '../lib/context.js';
19
19
  import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
20
20
  import { t as tr, lang } from '../lib/i18n.js';
21
+ import { issuePageArgs, collectIssuePages } from '../lib/issue-paging.js';
21
22
  import { domainPaymentHelp, slotLine } from '../lib/domain-billing.js';
22
23
 
23
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -89,11 +90,12 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
89
90
  rootr issue tracker <트래커> [--participation read|login|anyone] [--json] 트래커 설정 보기·외부 참여 바꾸기(MANAGE)
90
91
  rootr issue list <트래커|공개 주소> [--state OPEN|CLOSED|ALL] [--q <검색어>] [--label <라벨>]… [--sort newest|oldest|comments] [--json]
91
92
  이슈 목록·검색(공개 주소면 밖에서 — 제목·본문·#번호·쓴 사람, 페이지 블록은 --block)
93
+ [--limit <1~200>] [--cursor <다음 커서>] [--all] 나눠 받기 — 깃발 없으면 최신 500건, --all 은 끝까지
92
94
  rootr issue create <트래커> --title "<제목>" [--body "<본문>"] 이슈 올리기(내 계정으로)
93
95
  rootr issue comment <트래커> <#번호> "<댓글>" 이슈에 댓글
94
96
  rootr issue submit <공개 주소> --title "<제목>" [--body …] [--file <경로>]… [--name <이름>] [--block <블록 id>] [--password <비번>]
95
97
  웹게시한 트래커에 밖에서 이슈 올리기(--name 이면 키 없이 손님으로, 페이지 블록은 --block)
96
- --file: 그림·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일 10MB·글 하나에 10개
98
+ --file: 그림·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일 75MB(손님 25MB)·글 하나에 10개
97
99
  rootr issue reply <공개 주소> <#번호> "<댓글>" [--file <경로>]… [--name <이름>] [--block <블록 id>] [--password <비번>] 웹게시한 트래커 이슈에 댓글
98
100
  rootr booking show <표> 방문자 일정 등록(예약) 설정 보기
99
101
  rootr booking set <표> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 예약 켜기·규칙
@@ -245,11 +247,12 @@ usage:
245
247
  rootr issue tracker <tracker> [--participation read|login|anyone] [--json] show tracker settings / change outside participation (MANAGE)
246
248
  rootr issue list <tracker|public URL> [--state OPEN|CLOSED|ALL] [--q <text>] [--label <name>]… [--sort newest|oldest|comments] [--json]
247
249
  list/search issues (a public URL searches a PUBLISHED tracker from outside — title, body, #number, author; --block for a page)
250
+ [--limit <1-200>] [--cursor <next cursor>] [--all] paging — no flag = newest 500, --all = everything
248
251
  rootr issue create <tracker> --title "<title>" [--body "<body>"] file an issue (as your account)
249
252
  rootr issue comment <tracker> <#number> "<comment>" comment on an issue
250
253
  rootr issue submit <public URL> --title "<title>" [--body …] [--file <path>]… [--name <name>] [--block <block id>] [--password <pw>]
251
254
  file an issue on a PUBLISHED tracker from outside (--name = as a guest, no key; --block for a tracker on a page)
252
- --file: images·pdf·txt·csv·log·docx·xlsx·pptx·zip, 10MB per file, 10 per issue/comment
255
+ --file: images·pdf·txt·csv·log·docx·xlsx·pptx·zip, 75MB per file (guest 25MB), 10 per issue/comment
253
256
  rootr issue reply <public URL> <#number> "<comment>" [--file <path>]… [--name <name>] [--block <block id>] [--password <pw>] comment on an issue of a published tracker
254
257
  rootr booking show <table> show visitor booking settings
255
258
  rootr booking set <table> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 turn on + rules
@@ -2216,6 +2219,23 @@ function issuesOf(res) {
2216
2219
  return Array.isArray(res) ? res : Array.isArray(res?.issues) ? res.issues : [];
2217
2220
  }
2218
2221
 
2222
+ /** 목록 아래 한 줄 — 몇 건 중 몇 건, 다음 페이지 받는 법. */
2223
+ function issuePageFooter(res, shown) {
2224
+ const total = typeof res?.total === 'number' ? res.total : res?.truncated?.total;
2225
+ if (res?.nextCursor) {
2226
+ console.log(
2227
+ tr(
2228
+ `… 전체 ${total ?? '?'}건 중 ${shown}건. 다음 페이지: 같은 조건에 --cursor ${res.nextCursor} (끝까지: --all)`,
2229
+ `… ${shown} of ${total ?? '?'}. Next page: same filters plus --cursor ${res.nextCursor} (everything: --all)`,
2230
+ ),
2231
+ );
2232
+ } else if (res?.truncated) {
2233
+ console.log(tr(`… ${res.truncated.total}건 중 ${res.truncated.shown}건 (나머지는 --limit/--cursor 또는 --all)`, `… ${res.truncated.shown} of ${res.truncated.total} (rest: --limit/--cursor or --all)`));
2234
+ } else if (typeof total === 'number' && total > shown) {
2235
+ console.log(tr(`… 전체 ${total}건 중 ${shown}건`, `… ${shown} of ${total}`));
2236
+ }
2237
+ }
2238
+
2219
2239
  function issueNumberOf(raw) {
2220
2240
  const n = Number(String(raw ?? '').replace(/^#/, ''));
2221
2241
  if (!Number.isSafeInteger(n) || n < 1) throw new Error(tr(`이슈 번호가 아닙니다: ${raw}`, `Not an issue number: ${raw}`));
@@ -2238,7 +2258,7 @@ function publicIssueFilesOf(args) {
2238
2258
  }
2239
2259
  const data = readFileSync(p);
2240
2260
  if (data.length > PUBLIC_ISSUE_FILE_MAX_BYTES) {
2241
- throw new Error(tr(`파일이 너무 큽니다: ${filename} — 한 파일에 10MB까지`, `File too large: ${filename} — 10MB per file`));
2261
+ throw new Error(tr(`파일이 너무 큽니다: ${filename} — 한 파일에 75MB까지`, `File too large: ${filename} — 75MB per file`));
2242
2262
  }
2243
2263
  return { filename, mimeType, data };
2244
2264
  });
@@ -2283,19 +2303,26 @@ async function cmdIssue(args) {
2283
2303
  }
2284
2304
  if (sub === 'list') {
2285
2305
  const target = args._[1];
2286
- if (!target) throw new Error(usage('rootr issue list <path|id|공개 주소> [--state OPEN|CLOSED|ALL] [--q <text>] [--label <이름>]… [--sort newest|oldest|comments] [--json]'));
2306
+ if (!target) throw new Error(usage('rootr issue list <path|id|공개 주소> [--state OPEN|CLOSED|ALL] [--q <text>] [--label <이름>]… [--sort newest|oldest|comments] [--limit <1~200>] [--cursor <다음 커서>] [--all] [--json]'));
2307
+ const pg = issuePageArgs(args);
2287
2308
  // ISSUE-PUBLIC-SEARCH — 공개 주소(…/p/{slug})나 --block 이면 웹게시한 트래커를 **밖에서** 찾는다(키 없이, 서버 검색).
2288
2309
  if (/\/p\//.test(String(target)) || typeof args.block === 'string') {
2289
2310
  const slug = publicSlugOf(target);
2290
2311
  const pass = typeof args.password === 'string' ? await client.unlockPublicNode(slug, args.password) : undefined;
2291
- const res = await client.searchPublicIssues(slug, {
2292
- blockId: typeof args.block === 'string' ? args.block : undefined,
2293
- q: typeof args.q === 'string' ? args.q : undefined,
2294
- state: typeof args.state === 'string' ? args.state : undefined,
2295
- labels: args.__all.label || [],
2296
- sort: typeof args.sort === 'string' ? args.sort : undefined,
2297
- pass,
2298
- });
2312
+ const res = await collectIssuePages(
2313
+ ({ limit, cursor }) =>
2314
+ client.searchPublicIssues(slug, {
2315
+ blockId: typeof args.block === 'string' ? args.block : undefined,
2316
+ q: typeof args.q === 'string' ? args.q : undefined,
2317
+ state: typeof args.state === 'string' ? args.state : undefined,
2318
+ labels: args.__all.label || [],
2319
+ sort: typeof args.sort === 'string' ? args.sort : undefined,
2320
+ pass,
2321
+ limit,
2322
+ cursor,
2323
+ }),
2324
+ pg,
2325
+ );
2299
2326
  if (args.json) return console.log(JSON.stringify(res, null, 2));
2300
2327
  const list = res?.issues || [];
2301
2328
  if (!list.length) return console.log(tr('조건에 맞는 이슈가 없습니다.', 'No matching issues.'));
@@ -2304,17 +2331,23 @@ async function cmdIssue(args) {
2304
2331
  const labels = (i.labels || []).map((l) => l.name).join(',');
2305
2332
  console.log(`#${i.number}\t${i.state}\t${i.title}${labels ? `\t[${labels}]` : ''}${who ? `\t${who}` : ''}${i.commentCount ? `\t💬${i.commentCount}` : ''}`);
2306
2333
  }
2307
- if (res.truncated) console.log(tr(`… ${res.truncated.total}건 중 ${res.truncated.shown}건`, `… ${res.truncated.shown} of ${res.truncated.total}`));
2334
+ issuePageFooter(res, list.length);
2308
2335
  return;
2309
2336
  }
2310
2337
  const id = await resolveNodeId(client, target);
2311
- const list = issuesOf(await client.listIssues(id, { state: args.state, q: typeof args.q === 'string' ? args.q : undefined }));
2312
- if (args.json) return console.log(JSON.stringify(list, null, 2));
2338
+ const res = await collectIssuePages(
2339
+ ({ limit, cursor }) => client.listIssues(id, { state: args.state, q: typeof args.q === 'string' ? args.q : undefined, limit, cursor }),
2340
+ pg,
2341
+ );
2342
+ const list = issuesOf(res);
2343
+ // --json: 깃발 없이는 예전처럼 배열(스크립트 호환), --all 도 배열(전부). --limit/--cursor 면 다음 커서가 필요하니 { issues, nextCursor, total }.
2344
+ if (args.json) return console.log(JSON.stringify(pg.paged && !pg.all ? { issues: list, nextCursor: res?.nextCursor ?? null, total: res?.total } : list, null, 2));
2313
2345
  if (!list.length) return console.log(tr('이슈가 없습니다.', 'No issues.'));
2314
2346
  for (const i of list) {
2315
2347
  const who = i.guestName ? tr(`${i.guestName} (외부)`, `${i.guestName} (guest)`) : i.author?.name ?? '';
2316
2348
  console.log(`#${i.number}\t${i.state}\t${i.title}${who ? `\t${who}` : ''}${i.commentCount ? `\t💬${i.commentCount}` : ''}`);
2317
2349
  }
2350
+ issuePageFooter(res, list.length);
2318
2351
  return;
2319
2352
  }
2320
2353
  if (sub === 'create') {
package/lib/client.js CHANGED
@@ -119,7 +119,8 @@ function hintFor(status, body) {
119
119
  * ISSUE-PUBLIC-ATTACH — 공개 이슈트래커 첨부의 안전선(서버 issue-public-attachments.ts 와 같은 값). 서버가 다시 판정하지만, 보내기 전에
120
120
  * 걸러 큰 파일을 헛되이 싣지 않게 한다.
121
121
  */
122
- export const PUBLIC_ISSUE_FILE_MAX_BYTES = 10 * 1024 * 1024;
122
+ /** 계정으로 올릴 때의 상한(서버와 같음, 손님은 25MB — 서버가 가른다). */
123
+ export const PUBLIC_ISSUE_FILE_MAX_BYTES = 75 * 1024 * 1024;
123
124
  export const PUBLIC_ISSUE_FILES_PER_POST = 10;
124
125
  const PUBLIC_ISSUE_MIME = {
125
126
  png: 'image/png',
@@ -1037,19 +1038,22 @@ export class RootrClient {
1037
1038
  * ISSUE-PUBLIC-SEARCH — 공개 트래커(또는 페이지 블록)의 목록을 서버에서 찾는다. `q` 는 제목·본문·번호(#12)·(이름을 싣는 모드면) 쓴 사람,
1038
1039
  * `labels` 는 이름(모두 가진 것), `state` OPEN|CLOSED, `sort` newest|oldest|comments. 키 없이 부른다(공개 읽기).
1039
1040
  */
1040
- async searchPublicIssues(slug, { blockId, q, state, labels, sort, pass } = {}) {
1041
+ async searchPublicIssues(slug, { blockId, q, state, labels, sort, pass, limit, cursor } = {}) {
1041
1042
  const qs = new URLSearchParams();
1042
1043
  if (q) qs.set('q', q);
1043
1044
  if (state && String(state).toUpperCase() !== 'ALL') qs.set('state', String(state).toUpperCase());
1044
1045
  for (const l of [].concat(labels || [])) if (l) qs.append('label', l);
1045
1046
  if (sort) qs.set('sort', sort);
1047
+ // ISSUE-PAGING — limit(1~200)·cursor(직전 응답 nextCursor). 둘 다 없으면 서버가 예전처럼 500건.
1048
+ if (limit !== undefined && limit !== null) qs.set('limit', String(limit));
1049
+ if (cursor) qs.set('cursor', cursor);
1046
1050
  if (pass) qs.set('pass', pass);
1047
1051
  const tail = qs.toString();
1048
1052
  return this.request(`${this._publicIssuesBase(slug, blockId)}${tail ? `?${tail}` : ''}`, { noAuth: true });
1049
1053
  }
1050
1054
 
1051
1055
  /**
1052
- * ISSUE-PUBLIC-ATTACH — 공개 트래커(또는 페이지 블록)에 새 이슈·댓글용 파일을 올린다(본문 = 바이트). 10MB·허용 형식은 서버가 판정한다.
1056
+ * ISSUE-PUBLIC-ATTACH — 공개 트래커(또는 페이지 블록)에 새 이슈·댓글용 파일을 올린다(본문 = 바이트). 크기(계정 75MB·손님 25MB)·허용 형식은 서버가 판정한다.
1053
1057
  * 돌려받은 `markdown` 을 이슈·댓글 본문 끝에 붙여 보낸다(`withPublicAttachments`).
1054
1058
  */
1055
1059
  async uploadPublicIssueFile(slug, { blockId, filename, mimeType, data, guestName, pass, asGuest } = {}) {
@@ -1072,13 +1076,16 @@ export class RootrClient {
1072
1076
  return this.request(`${this._publicIssuesBase(slug, blockId)}/${encodeURIComponent(String(number))}${qs}`, { noAuth: true });
1073
1077
  }
1074
1078
 
1075
- async listIssues(trackerId, { state, label, type, assigneeId, q } = {}) {
1079
+ async listIssues(trackerId, { state, label, type, assigneeId, q, limit, cursor } = {}) {
1076
1080
  const params = new URLSearchParams();
1077
1081
  if (state) params.set('state', state);
1078
1082
  if (label) params.set('label', label);
1079
1083
  if (type) params.set('type', type);
1080
1084
  if (assigneeId) params.set('assigneeId', assigneeId);
1081
1085
  if (q) params.set('q', q);
1086
+ // ISSUE-PAGING — 응답 { issues, nextCursor, total }. 둘 다 없으면 예전처럼 최신 500개.
1087
+ if (limit !== undefined && limit !== null) params.set('limit', String(limit));
1088
+ if (cursor) params.set('cursor', cursor);
1082
1089
  const qs = params.toString();
1083
1090
  return this.request(`/issue-trackers/${encodeURIComponent(trackerId)}/issues${qs ? `?${qs}` : ''}`);
1084
1091
  }
@@ -0,0 +1,44 @@
1
+ import { t as tr } from './i18n.js';
2
+
3
+ /**
4
+ * ISSUE-PAGING — `--limit <1~200>` · `--cursor <다음 커서>` · `--all`(끝까지). 깃발이 없으면 예전처럼 한 번(서버가 최신 500개).
5
+ * `--all` 은 200개씩 nextCursor 가 없을 때까지 받는다. 옛 서버(페이지 모르는)는 nextCursor 를 안 주므로 한 번에 멈춘다.
6
+ */
7
+ export function issuePageArgs(args) {
8
+ const out = { all: args.all === true, limit: undefined, cursor: undefined };
9
+ if (args.limit !== undefined) {
10
+ const n = Number(args.limit);
11
+ if (args.limit === true || !Number.isInteger(n) || n < 1 || n > 200) {
12
+ throw new Error(tr('--limit 은 1~200 사이 정수입니다(예: --limit 100). 끝까지 받으려면 --all.', '--limit must be an integer 1–200 (e.g. --limit 100). Use --all to fetch everything.'));
13
+ }
14
+ out.limit = n;
15
+ }
16
+ if (args.cursor !== undefined) {
17
+ if (typeof args.cursor !== 'string' || !args.cursor) throw new Error(tr('--cursor 에는 직전 결과의 다음 커서를 넣으세요.', '--cursor needs the previous result\'s next cursor.'));
18
+ out.cursor = args.cursor;
19
+ }
20
+ out.paged = out.all || out.limit !== undefined || out.cursor !== undefined;
21
+ return out;
22
+ }
23
+
24
+ /** 페이지를 이어 받는다. fetchPage({limit, cursor}) → { issues, nextCursor?, total?, truncated? }. */
25
+ export async function collectIssuePages(fetchPage, { all, limit, cursor }) {
26
+ if (!all) return fetchPage({ limit, cursor });
27
+ const size = limit ?? 200;
28
+ let cur = cursor;
29
+ let last = null;
30
+ const issues = [];
31
+ for (let guard = 0; guard < 100_000; guard++) {
32
+ const res = await fetchPage({ limit: size, cursor: cur });
33
+ issues.push(...(Array.isArray(res?.issues) ? res.issues : Array.isArray(res) ? res : []));
34
+ last = res;
35
+ const next = res?.nextCursor;
36
+ if (!next || next === cur) break;
37
+ cur = next;
38
+ }
39
+ const out = { ...(last && !Array.isArray(last) ? last : {}), issues, nextCursor: null };
40
+ delete out.truncated;
41
+ if (typeof out.total !== 'number') out.total = issues.length;
42
+ return out;
43
+ }
44
+
@@ -31,6 +31,26 @@ function slugOf(target) {
31
31
  return m ? decodeURIComponent(m[1]) : t;
32
32
  }
33
33
 
34
+ /**
35
+ * ISSUE-PAGING — 다음 페이지가 있으면 에이전트가 놓치지 않게 한 줄 안내를 붙인다(서버 응답은 그대로 두고 `next` 만 더한다).
36
+ * 옛 서버(페이지 모르는)는 nextCursor 가 없어 안내도 없다 — 대신 truncated 가 있으면 그걸로 알린다.
37
+ */
38
+ export function withPageHint(result) {
39
+ if (!result || typeof result !== 'object' || !Array.isArray(result.issues)) return result;
40
+ const n = result.issues.length;
41
+ if (result.nextCursor) {
42
+ const total = typeof result.total === 'number' ? ` of ${result.total}` : '';
43
+ return {
44
+ ...result,
45
+ next: `More issues remain (this page ${n}${total}). Call again with cursor="${result.nextCursor}" and the same filters to get the next page.`,
46
+ };
47
+ }
48
+ if (result.truncated && result.nextCursor === undefined) {
49
+ return { ...result, next: `Only ${result.truncated.shown} of ${result.truncated.total} returned — pass limit (1–200) and then cursor=<nextCursor> to page through the rest.` };
50
+ }
51
+ return result;
52
+ }
53
+
34
54
  /** Issue tracker tools: create tracker, list/create/get/update issues, comment. */
35
55
  export function registerIssueTools(server) {
36
56
  server.registerTool(
@@ -68,8 +88,11 @@ export function registerIssueTools(server) {
68
88
  {
69
89
  title: 'List issues in a tracker',
70
90
  description:
71
- 'List issues in a Rootr (루터) issue tracker, optionally filtered by state (OPEN/CLOSED/ALL), label id, ' +
72
- 'type id, or a text query. Use rootr_get_issue for full details of one issue.',
91
+ 'List issues in a Rootr (루터) issue tracker, newest number first, optionally filtered by state (OPEN/CLOSED/ALL), ' +
92
+ 'label id, type id, or a text query. Paged: pass `limit` (1–200) and, for the next page, `cursor` = the previous ' +
93
+ 'result\'s `nextCursor` (keep the same filters). Without limit/cursor you get the newest 500. Returns ' +
94
+ '{ issues, nextCursor, total } — `nextCursor` null means the last page; `total` = all issues matching the filters. ' +
95
+ 'Use rootr_get_issue for full details of one issue.',
73
96
  annotations: { readOnlyHint: true, destructiveHint: false },
74
97
  inputSchema: {
75
98
  trackerId: z.string().describe(`Issue tracker node id${NODE_TARGET_HINT}`),
@@ -77,14 +100,16 @@ export function registerIssueTools(server) {
77
100
  q: z.string().optional().describe('Free-text search within issues'),
78
101
  label: z.string().optional().describe('Label id to filter by'),
79
102
  type: z.string().optional().describe('Issue type id to filter by'),
103
+ limit: z.number().int().min(1).max(200).optional().describe('Page size 1–200 (omit with cursor too = newest 500, as before)'),
104
+ cursor: z.string().max(400).optional().describe('`nextCursor` from the previous call, unchanged (same filters) — next page'),
80
105
  },
81
106
  },
82
- async ({ trackerId, state, q, label, type }) => {
107
+ async ({ trackerId, state, q, label, type, limit, cursor }) => {
83
108
  try {
84
109
  const client = makeClient();
85
110
  trackerId = await resolveNodeId(client, trackerId);
86
- const result = await client.listIssues(trackerId, { state, q, label, type });
87
- return jsonResult(result);
111
+ const result = await client.listIssues(trackerId, { state, q, label, type, limit, cursor });
112
+ return jsonResult(withPageHint(result));
88
113
  } catch (err) {
89
114
  return errorResult(err);
90
115
  }
@@ -264,7 +289,7 @@ export function registerIssueTools(server) {
264
289
  /**
265
290
  * ISSUE-PUBLIC-ATTACH — 공개 이슈·댓글에 붙일 파일. **내용(base64)만** 받는다 — 파일 경로는 받지 않는다: 원격 커넥터는 이 도구를 API 서버
266
291
  * 안에서 돌리므로 경로를 읽으면 서버 파일을 남의 공개 트래커로 내보내는 구멍이 된다(rootr-remote-mcp-runs-in-api-process). 로컬에서도
267
- * 같은 모양이라 원격/로컬을 가를 필요가 없다. 크기 상한은 서버와 같은 10MB(base64 로 약 13.4MB), 글 하나에 10개.
292
+ * 같은 모양이라 원격/로컬을 가를 필요가 없다. 크기 상한은 서버와 같다 — 계정 키로 부르면 75MB, 손님이면 25MB, 글 하나에 10개.
268
293
  */
269
294
  const PUBLIC_FILES = z
270
295
  .array(
@@ -273,14 +298,14 @@ export function registerIssueTools(server) {
273
298
  contentBase64: z
274
299
  .string()
275
300
  .max(Math.ceil(PUBLIC_ISSUE_FILE_MAX_BYTES / 3) * 4 + 4)
276
- .describe('The file bytes, base64-encoded (≤10MB decoded)'),
301
+ .describe('The file bytes, base64-encoded (≤75MB decoded with an account key; ≤25MB as a guest)'),
277
302
  mimeType: z.string().max(120).optional().describe('Optional — guessed from the extension'),
278
303
  }),
279
304
  )
280
305
  .max(PUBLIC_ISSUE_FILES_PER_POST)
281
306
  .optional()
282
307
  .describe(
283
- `Files to attach (≤${PUBLIC_ISSUE_FILES_PER_POST}, ≤10MB each): ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}. ` +
308
+ `Files to attach (≤${PUBLIC_ISSUE_FILES_PER_POST}, ≤75MB each with an account key, ≤25MB as a guest): ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}. ` +
284
309
  'Images show inside the text, other files as a download link. svg/html/executables/video are refused, and the file\'s ' +
285
310
  'first bytes must match its extension. Content only — no file paths.',
286
311
  );
@@ -291,7 +316,7 @@ export function registerIssueTools(server) {
291
316
  for (const f of files || []) {
292
317
  const data = Buffer.from(f.contentBase64, 'base64');
293
318
  if (!data.length) throw new RootrApiError(`빈 파일입니다: ${f.filename}`);
294
- if (data.length > PUBLIC_ISSUE_FILE_MAX_BYTES) throw new RootrApiError(`파일이 너무 큽니다: ${f.filename} — 한 파일에 10MB까지`);
319
+ if (data.length > PUBLIC_ISSUE_FILE_MAX_BYTES) throw new RootrApiError(`파일이 너무 큽니다: ${f.filename} — 한 파일에 75MB까지`);
295
320
  const mimeType = f.mimeType || publicIssueMimeOf(f.filename);
296
321
  if (!mimeType) {
297
322
  throw new RootrApiError(`이 형식은 올릴 수 없습니다: ${f.filename}`, { hint: `허용: ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}` });
@@ -311,8 +336,9 @@ export function registerIssueTools(server) {
311
336
  'what an outside visitor sees. `q` matches title, body, number ("#12" or "12") and, when the tracker shows authors ' +
312
337
  '(participation login/anyone), the author name; `labels` = label names (issues having all of them); `state` OPEN|CLOSED; ' +
313
338
  '`sort` newest (default) | oldest | comments. Returns { participation, issues:[{number,title,state,body,labels,createdAt,' +
314
- 'closedAt,commentCount,author}], truncated? } (≤500 per call). No key needed. For a tracker in YOUR workspace prefer ' +
315
- 'rootr_list_issues (internal fields).',
339
+ 'closedAt,commentCount,author}], truncated?, nextCursor?, total? }. Paged: `limit` (1–200) and `cursor` = the previous ' +
340
+ 'result\'s `nextCursor` (same q/state/labels/sort); without them you get up to 500 (truncated tells you there were more). ' +
341
+ 'No key needed. For a tracker in YOUR workspace prefer rootr_list_issues (internal fields).',
316
342
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
317
343
  inputSchema: {
318
344
  url: PUBLIC_TARGET.url,
@@ -322,14 +348,16 @@ export function registerIssueTools(server) {
322
348
  state: z.enum(['OPEN', 'CLOSED', 'ALL']).optional().describe('Default ALL'),
323
349
  labels: z.array(z.string().max(100)).max(20).optional().describe('Label names — issues having all of them'),
324
350
  sort: z.enum(['newest', 'oldest', 'comments']).optional(),
351
+ limit: z.number().int().min(1).max(200).optional().describe('Page size 1–200'),
352
+ cursor: z.string().max(400).optional().describe('`nextCursor` from the previous call, unchanged (same q/state/labels/sort)'),
325
353
  },
326
354
  },
327
- async ({ url, blockId, password, q, state, labels, sort }) => {
355
+ async ({ url, blockId, password, q, state, labels, sort, limit, cursor }) => {
328
356
  try {
329
357
  const client = makeClient();
330
358
  const slug = slugOf(url);
331
359
  const pass = password ? await client.unlockPublicNode(slug, password) : undefined;
332
- return jsonResult(await client.searchPublicIssues(slug, { blockId, q, state, labels, sort, pass }));
360
+ return jsonResult(withPageHint(await client.searchPublicIssues(slug, { blockId, q, state, labels, sort, pass, limit, cursor })));
333
361
  } catch (err) {
334
362
  return errorResult(err);
335
363
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Official Rootr CLI + MCP server — read, write, edit and search Rootr documents from the shell or from an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {