rootr-cli 0.17.1 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/rootr.js CHANGED
@@ -7,11 +7,12 @@ import { loadConfig, saveConfig, keyForWorkspace, maskKey, upsertKey, CONFIG_PAT
7
7
  import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../lib/credentials.js';
8
8
  import { RootrClient, RootrApiError } from '../lib/client.js';
9
9
  import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
10
+ import { t as tr, lang } from '../lib/i18n.js';
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
13
14
 
14
- const USAGE = `Rootr CLI v${pkg.version}
15
+ const USAGE_KO = `Rootr CLI v${pkg.version}
15
16
 
16
17
  사용법:
17
18
  rootr ls [path] 워크스페이스 트리 조회
@@ -100,6 +101,104 @@ const USAGE = `Rootr CLI v${pkg.version}
100
101
  경로 인자는 '/'로 시작해야 합니다 (예: /notes/todo.md). 그 외 값은 노드 id로 취급됩니다.
101
102
  `;
102
103
 
104
+ const USAGE_EN = `Rootr CLI v${pkg.version}
105
+
106
+ usage:
107
+ rootr ls [path] list the workspace tree
108
+ rootr read <path|id> [--json] read a document (markdown, or JSON)
109
+ rootr write <path|id> [--file <f>] [--if-match <etag>] replace a document (creates it if missing)
110
+ rootr append <path|id> [<text>] [--heading "## X"] [--file <f>] append safely to the end (or under a heading)
111
+ rootr edit <path|id> --find <text> --replace <text> [--all] replace text inside the body
112
+ rootr comments <path|id> [--block <id>] [--json] read comments (including ones left on the public page)
113
+ rootr search <query> search the workspace
114
+ rootr mv <path|id> <new path|folder id> move and/or rename a document or folder
115
+ rootr rm <path|id> [--yes] move to the trash (recoverable)
116
+ rootr restore <id> undo a delete (same as Ctrl+Z in the app — id, not path)
117
+ rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>] upload a file (+ insert it into a document)
118
+ rootr ws my workspaces (id\tname; unreachable ones are marked)
119
+ rootr use <id|name|url> switch the active workspace (remembers its key too)
120
+ rootr ask <question> [--workspace <w>] GraphRAG question answering (RCA), prints citations
121
+ rootr db create [--file <spec.json>] [--parent <path|id>] create a DATABASE — columns in order, rows included
122
+ rootr db rows <path|id> [--body] [--backlinks] [--json] list rows (--body: row bodies, --backlinks: rows pointing here)
123
+ rootr db lock <path|id> [--schema managers] [--rows own] [--locked "Approved,Score"] view/set table locks
124
+ rootr db notify <path|id> [--file rules.json] [--clear] view/save notification rules
125
+ rootr db buttons <path|id> [--file buttons.json] [--clear] view/save buttons that do work when pressed
126
+ rootr db button-run <path|id> <buttonId> [--rows id,id] press a button once (create rows, fill values, AI fill)
127
+ rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] list views; calendar week/month/year
128
+ rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] add a row (with its body)
129
+ rootr db row-body <path|id> <rowId> [--file <f>] write a row's body — reads stdin if no file
130
+ rootr db rows-new <path|id> --json '[{…},…]' | --file <file> insert many rows at once (one bad row cancels all)
131
+ rootr db rows-set <path|id> --ids <id,id> --values '<json>' same value on every chosen row (bulk date changes, …)
132
+ rootr db rows-each <path|id> --json '[{"rowId":…,"values":{…}},…]' a different value per row, in one call
133
+ rootr db rows-rm <path|id> --ids <id,id> [--yes] delete chosen rows at once — one blocked row rejects all
134
+ rootr db row-page <rowId> [--type RECORDING] give this row a typed detail page (recording, spreadsheet, signed doc…)
135
+ rootr db row-page <rowId> --node <path|id> attach an existing document to this row (points at it, does not move it)
136
+ rootr db row-page <rowId> --unlink unlink only — the document stays
137
+ rootr db row-page-type <path|id> <TYPE> default type for new rows — existing rows are untouched
138
+ rootr publish <path|id> [--children] [--view <id>] publish — works for every node type
139
+ rootr publish <path|id> --off stop publishing (children that came along are closed too)
140
+ rootr publish <path|id> --status is it public, at what URL, children included?
141
+ rootr publish <table> --layout board --skin paper how a published table looks (9 layouts × 6 skins)
142
+ rootr publish <doc> --comments [false] [--expires <ISO>] [--skin <skin>] documents only — comments, expiry, 16 skins
143
+ rootr readreq <path|id> [--json] read receipts — who has read it, completion rate
144
+ rootr readreq <path|id> --users <id,id> [--due <ISO>] [--ack] set who must read it (replaces the whole list)
145
+ rootr readreq <path|id> --clear clear the read requirement
146
+ rootr access <path|id> [--json] view visibility and permissions (needs MANAGE)
147
+ rootr access <path|id> --private|--public|--inherit change visibility
148
+ rootr access <path|id> --grant <userId> --level READ|WRITE|MANAGE grant a permission
149
+ rootr access <path|id> --revoke <userId> revoke a permission
150
+ rootr form new <path> --fields '<json>' [--collect db|sheet] create a form (18 question types: text·email·phone·url·rating·scale·time·file·matrix·ranking·csat·nps…)
151
+ rootr form show <path|id> [--json] read questions and settings (do this before changing them)
152
+ rootr form set <path|id> --fields '<json>' | --config '<json>' change questions/settings (settings merge)
153
+ rootr form collect <path|id> --to db|sheet create and wire a destination (a table gets a column per question)
154
+ rootr form responses <path|id> [--latest] [--json] read responses (--latest: newest one per person)
155
+ rootr form response-edit <responseId> --values '<json>' edit a submitted response (revalidated against the form)
156
+ rootr form response-rm <responseId> [--yes] delete one response (cannot be undone)
157
+ rootr sign new <path> [--file <f>] [--reviewer <userId>] [--line <line.json>] create a signed document (body + approval line)
158
+ rootr sign show <path|id> [--json] approval-line progress and signatures
159
+ rootr sign line <path|id> --file <line.json> rebuild the approval line (before submitting only)
160
+ signers are {"userId"} or {"email","displayName"} — an email can sign without an account
161
+ rootr sign code <path|id> | --bulk email a confirmation code to yourself (required before signing)
162
+ rootr sign submit <path|id> [--code <6 digits>] submit — the body freezes. --code is needed if you are the first signer
163
+ rootr sign approve <path|id> --code <6 digits> sign when it is your turn
164
+ rootr sign reject <path|id> --comment <reason> [--code <6 digits>] reject (reason required)
165
+ rootr sign reopen <path|id> reopen a draft nobody has signed yet
166
+ rootr sign revise <path|id> --note <reason> issue a revision (the original stays)
167
+ rootr sign verify <path|id> [--json] prove it is untampered (signatures, hashes, timestamps)
168
+ rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] list signed documents (--pending: waiting on you)
169
+ rootr sign bulk <id…> --code <6 digits> submit+sign many at once (one code, drafts included)
170
+ rootr sign ledger [--json] verify the whole workspace ledger (hash chain)
171
+ rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] read/save configuration
172
+ rootr config --add-key <k> [--label <name>] [--workspace <w>] register an extra per-workspace key
173
+ rootr feedback "<what is not working>" [--kind bug|friction|idea|praise] [--email <address>]
174
+ send feedback — no login, no key, and it never hits usage limits
175
+ rootr scaffold plan "<what the workspace is for>" build step 1 — what to ask, and the design rules
176
+ rootr scaffold review --file tree.json [--json] build step 2 — get graded before anything is created
177
+ rootr scaffold apply --file tree.json [--root /path] build step 3 — actually create it (grades come along)
178
+ rootr scaffold built [--root /path] [--json] after building — inspect the **shape** of what exists
179
+ rootr scaffold test [--root /path] [--json] after building — actually **exercise** it (relations, rollups, formulas, forms, sheets). Exits 1 on failure
180
+ rootr memory [--query <q>] [--kind <k>] [--json] recall team memory — the same list AI agents read
181
+ rootr memory add "<content>" [--kind instruction|person|project|reference] [--source <path|id>] [--related a,b] save a memory
182
+ rootr memory edit <id> [--content "<content>"] [--kind …] [--source …] [--related a,b] edit a memory (only the fields you pass)
183
+ rootr memory forget <id> [--hard] retire a memory (--hard deletes the row)
184
+ rootr mcp run the MCP stdio server (167 tools — see the README)
185
+ rootr --help this help
186
+ rootr --version print the version
187
+
188
+ Configuration precedence: env vars (ROOTR_API_KEY, ROOTR_WORKSPACE, ROOTR_BASE_URL) > ~/.rootr/config.json
189
+
190
+ Path arguments must start with '/' (e.g. /notes/todo.md). Anything else is treated as a node id.
191
+
192
+ Language: this help follows your locale. Set ROOTR_LANG=ko for Korean, ROOTR_LANG=en for English.
193
+ `;
194
+
195
+ const USAGE = lang() === 'ko' ? USAGE_KO : USAGE_EN;
196
+
197
+ /** 앞머리만 언어를 타고, 뒤의 명령 문법은 양쪽이 같다. */
198
+ function usage(syntax) {
199
+ return tr('\uC0AC\uC6A9\uBC95: ' + syntax, 'usage: ' + syntax);
200
+ }
201
+
103
202
  function parseArgs(argv) {
104
203
  const args = { _: [] };
105
204
  for (let i = 0; i < argv.length; i++) {
@@ -144,11 +243,11 @@ function makeClient() {
144
243
 
145
244
  function printErrorAndExit(err) {
146
245
  if (err instanceof RootrApiError) {
147
- console.error(`오류: ${err.message}`);
246
+ console.error(tr(`오류: ${err.message}`, `error: ${err.message}`));
148
247
  if (err.status) console.error(`(HTTP ${err.status})`);
149
- if (err.hint) console.error(`힌트: ${err.hint}`);
248
+ if (err.hint) console.error(tr(`힌트: ${err.hint}`, `hint: ${err.hint}`));
150
249
  } else {
151
- console.error(`오류: ${err.message || err}`);
250
+ console.error(tr(`오류: ${err.message || err}`, `error: ${err.message || err}`));
152
251
  }
153
252
  process.exit(1);
154
253
  }
@@ -183,7 +282,7 @@ async function cmdLs(args) {
183
282
  */
184
283
  async function cmdComments(args) {
185
284
  const target = args._[0];
186
- if (!target) throw new Error('사용법: rootr comments <path|id> [--block <blockId>] [--json]');
285
+ if (!target) throw new Error(usage(`rootr comments <path|id> [--block <blockId>] [--json]`));
187
286
 
188
287
  const client = makeClient();
189
288
  const id = requireResolved(await resolveTarget(client, target));
@@ -194,16 +293,16 @@ async function cmdComments(args) {
194
293
  return;
195
294
  }
196
295
  if (!threads.length) {
197
- console.log('댓글이 없습니다.');
296
+ console.log(tr('댓글이 없습니다.', `No comments.`));
198
297
  return;
199
298
  }
200
299
  for (const t of threads) {
201
300
  const when = String(t.createdAt ?? '').slice(0, 16).replace('T', ' ');
202
- console.log(`- ${t.author?.name ?? '(알 수 없음)'} · ${when}`);
301
+ console.log(tr(`- ${t.author?.name ?? '(알 수 없음)'} · ${when}`, `- ${t.author?.name ?? '(unknown)'} · ${when}`));
203
302
  console.log(` ${String(t.body ?? '').replace(/\n/g, '\n ')}`);
204
303
  for (const r of t.replies ?? []) {
205
304
  const rWhen = String(r.createdAt ?? '').slice(0, 16).replace('T', ' ');
206
- console.log(` ↳ ${r.author?.name ?? '(알 수 없음)'} · ${rWhen}`);
305
+ console.log(tr(` ↳ ${r.author?.name ?? '(알 수 없음)'} · ${rWhen}`, ` ↳ ${r.author?.name ?? '(unknown)'} · ${rWhen}`));
207
306
  console.log(` ${String(r.body ?? '').replace(/\n/g, '\n ')}`);
208
307
  }
209
308
  }
@@ -211,7 +310,7 @@ async function cmdComments(args) {
211
310
 
212
311
  async function cmdRead(args) {
213
312
  const target = args._[0];
214
- if (!target) throw new Error('사용법: rootr read <path|id> [--json]');
313
+ if (!target) throw new Error(usage(`rootr read <path|id> [--json]`));
215
314
 
216
315
  const client = makeClient();
217
316
  const resolved = await resolveTarget(client, target);
@@ -228,7 +327,7 @@ async function cmdRead(args) {
228
327
 
229
328
  async function cmdWrite(args) {
230
329
  const target = args._[0];
231
- if (!target) throw new Error('사용법: rootr write <path|id> [--file <f>] [--if-match <etag>]');
330
+ if (!target) throw new Error(usage(`rootr write <path|id> [--file <f>] [--if-match <etag>]`));
232
331
 
233
332
  let content;
234
333
  if (typeof args.file === 'string') {
@@ -242,7 +341,7 @@ async function cmdWrite(args) {
242
341
 
243
342
  if (!resolved.existed) {
244
343
  if (!resolved.path) {
245
- throw new Error(`대상을 찾을 수 없습니다: ${target}`);
344
+ throw new Error(tr(`대상을 찾을 수 없습니다: ${target}`, `Target not found: ${target}`));
246
345
  }
247
346
  // 생성 자체가 쓰기 — 곧바로 PUT을 또 하면 버전/추출만 이중으로 쌓인다
248
347
  const created = await client.createNode({
@@ -263,7 +362,7 @@ async function cmdWrite(args) {
263
362
 
264
363
  async function cmdAppend(args) {
265
364
  const target = args._[0];
266
- if (!target) throw new Error('사용법: rootr append <path|id> [<text>] [--heading "## X"] [--file <f>]');
365
+ if (!target) throw new Error(usage(`rootr append <path|id> [<text>] [--heading "## X"] [--file <f>]`));
267
366
 
268
367
  let content;
269
368
  if (typeof args._[1] === 'string') {
@@ -275,7 +374,7 @@ async function cmdAppend(args) {
275
374
  }
276
375
 
277
376
  if (!content) {
278
- throw new Error('추가할 내용이 없습니다 (텍스트 인자, --file, 또는 stdin 필요).');
377
+ throw new Error(tr('추가할 내용이 없습니다 (텍스트 인자, --file, 또는 stdin 필요).', `Nothing to append (needs a text argument, --file, or stdin).`));
279
378
  }
280
379
 
281
380
  const client = makeClient();
@@ -292,9 +391,9 @@ async function cmdAppend(args) {
292
391
 
293
392
  async function cmdEdit(args) {
294
393
  const target = args._[0];
295
- if (!target) throw new Error('사용법: rootr edit <path|id> --find <text> --replace <text> [--all]');
394
+ if (!target) throw new Error(usage(`rootr edit <path|id> --find <text> --replace <text> [--all]`));
296
395
  if (typeof args.find !== 'string' || typeof args.replace !== 'string') {
297
- throw new Error('--find와 --replace가 모두 필요합니다.');
396
+ throw new Error(tr('--find와 --replace가 모두 필요합니다.', `Both --find and --replace are required.`));
298
397
  }
299
398
 
300
399
  const client = makeClient();
@@ -312,7 +411,7 @@ async function cmdEdit(args) {
312
411
 
313
412
  async function cmdSearch(args) {
314
413
  const query = args._[0];
315
- if (!query) throw new Error('사용법: rootr search <query>');
414
+ if (!query) throw new Error(usage(`rootr search <query>`));
316
415
 
317
416
  const client = makeClient();
318
417
  const results = await client.search(query);
@@ -325,7 +424,7 @@ async function cmdSearch(args) {
325
424
  async function cmdMv(args) {
326
425
  const source = args._[0];
327
426
  const dest = args._[1];
328
- if (!source || !dest) throw new Error('사용법: rootr mv <path|id> <새 경로|폴더 id>');
427
+ if (!source || !dest) throw new Error(usage(tr(`rootr mv <path|id> <새 경로|폴더 id>`, `rootr mv <path|id> <new path|folder id>`)));
329
428
 
330
429
  const client = makeClient();
331
430
  const resolved = await resolveTarget(client, source);
@@ -334,12 +433,12 @@ async function cmdMv(args) {
334
433
  const { parentId, name } = await resolveMoveDestination(client, dest);
335
434
  const updated = await client.updateNode(id, { parentId, name });
336
435
 
337
- console.log(`이동했습니다: ${resolved.path || id} → ${updated.path || dest}`);
436
+ console.log(tr(`이동했습니다: ${resolved.path || id} → ${updated.path || dest}`, `Moved: ${resolved.path || id} → ${updated.path || dest}`));
338
437
  }
339
438
 
340
439
  async function cmdRm(args) {
341
440
  const target = args._[0];
342
- if (!target) throw new Error('사용법: rootr rm <path|id> [--yes]');
441
+ if (!target) throw new Error(usage(`rootr rm <path|id> [--yes]`));
343
442
 
344
443
  const client = makeClient();
345
444
  const resolved = await resolveTarget(client, target);
@@ -349,17 +448,17 @@ async function cmdRm(args) {
349
448
  // teammate's tree — confirm unless the caller opted out for scripting.
350
449
  if (!args.yes) {
351
450
  const label = resolved.path || resolved.name || id;
352
- const ok = await confirmPrompt(`"${label}" 을(를) 휴지통으로 옮깁니다. 계속할까요? [y/N] `);
451
+ const ok = await confirmPrompt(tr(`"${label}" 을(를) 휴지통으로 옮깁니다. 계속할까요? [y/N] `, `Move "${label}" to the trash. Continue? [y/N] `));
353
452
  if (!ok) {
354
- console.log('취소했습니다.');
453
+ console.log(tr('취소했습니다.', `Cancelled.`));
355
454
  return;
356
455
  }
357
456
  }
358
457
 
359
458
  await client.deleteNode(id);
360
- console.log(`휴지통으로 옮겼습니다: ${resolved.path || id}`);
459
+ console.log(tr(`휴지통으로 옮겼습니다: ${resolved.path || id}`, `Moved to the trash: ${resolved.path || id}`));
361
460
  // 되돌리려면 id 가 있어야 한다 — 휴지통에 든 것은 경로로는 못 찾는다.
362
- console.log(`되돌리려면: rootr restore ${id} (30일 뒤 자동 삭제)`);
461
+ console.log(tr(`되돌리려면: rootr restore ${id} (30일 뒤 자동 삭제)`, `To undo: rootr restore ${id} (deleted for good after 30 days)`));
363
462
  }
364
463
 
365
464
  /**
@@ -373,8 +472,8 @@ async function cmdRestore(args) {
373
472
  const id = args._[0];
374
473
  if (!id) {
375
474
  throw new Error(
376
- '사용법: rootr restore <id>\n' +
377
- ' (경로가 아니라 id 입니다 — 휴지통에 든 것은 경로로 못 찾습니다. rootr rm 이 찍어 준 id 를 쓰세요.)',
475
+ usage(`rootr restore <id>\n`) +
476
+ tr(' (경로가 아니라 id 입니다 — 휴지통에 든 것은 경로로 못 찾습니다. rootr rm 이 찍어 준 id 를 쓰세요.)', ` (that is an id, not a path — items in the trash cannot be found by path. Use the id rootr rm printed.)`),
378
477
  );
379
478
  }
380
479
  const client = makeClient();
@@ -383,7 +482,7 @@ async function cmdRestore(args) {
383
482
  console.log(JSON.stringify(node, null, 2));
384
483
  return;
385
484
  }
386
- console.log(`되돌렸습니다: ${node.path ?? node.name ?? id}`);
485
+ console.log(tr(`되돌렸습니다: ${node.path ?? node.name ?? id}`, `Restored: ${node.path ?? node.name ?? id}`));
387
486
  }
388
487
 
389
488
  /** Minimal y/N prompt. Non-interactive stdin (pipes, CI) answers "no". */
@@ -411,13 +510,13 @@ const MIME_BY_EXT = {
411
510
 
412
511
  async function cmdAttach(args) {
413
512
  const filePath = args._[0];
414
- if (!filePath) throw new Error('사용법: rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>]');
513
+ if (!filePath) throw new Error(usage(`rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>]`));
415
514
 
416
515
  const cfg = loadConfig();
417
516
  const workspace = args.workspace || cfg.workspace;
418
517
  if (!workspace) {
419
- throw new RootrApiError('워크스페이스가 설정되지 않았습니다.', {
420
- hint: '--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.',
518
+ throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
519
+ hint: tr('--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.', `Pass --workspace <id>, or set it with rootr config --workspace <id> or the ROOTR_WORKSPACE environment variable.`),
421
520
  });
422
521
  }
423
522
 
@@ -449,8 +548,8 @@ async function cmdWs() {
449
548
  const cfg = loadConfig();
450
549
  const candidates = candidateClients(cfg);
451
550
  if (!candidates.length) {
452
- throw new RootrApiError('이 컴퓨터에 등록된 Rootr 키가 없습니다.', {
453
- hint: 'rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.',
551
+ throw new RootrApiError(tr('이 컴퓨터에 등록된 Rootr 키가 없습니다.', `No Rootr key is registered on this machine.`), {
552
+ hint: tr('rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.', 'Create an account key (PAT) under rootr.io > Settings > API keys and register it with `rootr config --api-key <key>`.'),
454
553
  });
455
554
  }
456
555
  // Probing costs an API call per workspace, so only when it diagnoses something:
@@ -459,8 +558,8 @@ async function cmdWs() {
459
558
  const { workspaces, reachable, failures } = await surveyWorkspaces(candidates, { probe });
460
559
  if (!workspaces.length) {
461
560
  throw new RootrApiError(
462
- failures ? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키 만료/취소 가능성).' : '보이는 워크스페이스가 없습니다.',
463
- { hint: 'rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.' },
561
+ failures ? tr('등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키 만료/취소 가능성).', `Could not list workspaces with the registered keys (they may be expired or revoked).`) : tr('보이는 워크스페이스가 없습니다.', `No workspaces are visible.`),
562
+ { hint: tr('rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.', 'Create an account key (PAT) under rootr.io > Settings > API keys and register it with `rootr config --api-key <key>`.') },
464
563
  );
465
564
  }
466
565
  let blocked = false;
@@ -468,42 +567,45 @@ async function cmdWs() {
468
567
  const unreachable = probe && !reachable.has(w.id);
469
568
  blocked = blocked || unreachable;
470
569
  console.log(
471
- `${w.id === cfg.workspace ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}${unreachable ? '\t[등록된 키로는 접근 불가]' : ''}`,
570
+ tr(`${w.id === cfg.workspace ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}${unreachable ? '\t[등록된 키로는 접근 불가]' : ''}`, `${w.id === cfg.workspace ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}${unreachable ? '\t[not reachable with the registered keys]' : ''}`),
472
571
  );
473
572
  }
474
573
  if (blocked) {
475
574
  console.error(
476
- '\n"접근 불가" = 내 소속 워크스페이스지만 등록된 키가 워크스페이스 전용이라 못 엽니다.\n' +
477
- '계정 키(PAT)를 등록하면 전부 열립니다: rootr.io > 설정 > API 키 → `rootr config --api-key <키>`',
575
+ tr('\n"접근 불가" = 내 소속 워크스페이스지만 등록된 키가 워크스페이스 전용이라 못 엽니다.\n', `\n"not reachable" = a workspace you belong to, but the registered key is scoped to another one.\n`) +
576
+ tr(
577
+ '계정 키(PAT)를 등록하면 전부 열립니다: rootr.io > 설정 > API 키 → `rootr config --api-key <키>`',
578
+ 'Register an account key (PAT) and they all open: rootr.io > Settings > API keys → `rootr config --api-key <key>`',
579
+ ),
478
580
  );
479
581
  }
480
582
  }
481
583
 
482
584
  async function cmdUse(args) {
483
585
  const wanted = args._.join(' ').trim();
484
- if (!wanted) throw new Error('사용법: rootr use <워크스페이스 id | 이름 | rootr.io 주소>');
586
+ if (!wanted) throw new Error(usage(tr(`rootr use <워크스페이스 id | 이름 | rootr.io 주소>`, `rootr use <workspace id | name | rootr.io URL>`)));
485
587
  const cfg = loadConfig();
486
588
  const match = await resolveWorkspaceSwitch(wanted, cfg);
487
589
  if (match.error) throw new RootrApiError(match.error);
488
590
  rememberWorkspace(match, cfg);
489
- console.log(`활성 워크스페이스: "${match.workspace.name || match.workspace.slug || ''}" (${match.workspace.id})`);
591
+ console.log(tr(`활성 워크스페이스: "${match.workspace.name || match.workspace.slug || ''}" (${match.workspace.id})`, `Active workspace: "${match.workspace.name || match.workspace.slug || ''}" (${match.workspace.id})`));
490
592
  }
491
593
 
492
594
  async function cmdAsk(args) {
493
595
  const question = args._.join(' ');
494
- if (!question) throw new Error('사용법: rootr ask <question> [--workspace <w>]');
596
+ if (!question) throw new Error(usage(`rootr ask <question> [--workspace <w>]`));
495
597
 
496
598
  const cfg = loadConfig();
497
599
  const workspace = args.workspace || cfg.workspace;
498
600
  if (!workspace) {
499
- throw new RootrApiError('워크스페이스가 설정되지 않았습니다.', {
500
- hint: '--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.',
601
+ throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
602
+ hint: tr('--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.', `Pass --workspace <id>, or set it with rootr config --workspace <id> or the ROOTR_WORKSPACE environment variable.`),
501
603
  });
502
604
  }
503
605
 
504
606
  const client = new RootrClient({ ...cfg, workspace });
505
607
  const result = await client.ask(workspace, question);
506
- console.log(result.text || '(응답 없음)');
608
+ console.log(result.text || tr('(응답 없음)', `(no response)`));
507
609
  const citations = Array.isArray(result.citations) ? result.citations : [];
508
610
  if (citations.length) {
509
611
  console.log('\nCitations:');
@@ -525,7 +627,7 @@ async function cmdFeedback(args) {
525
627
  if (!body) body = (await readStdin()).trim();
526
628
  if (!body) {
527
629
  throw new Error(
528
- '사용법: rootr feedback "무엇을 하려다 무엇이 일어났는지" [--kind bug|friction|idea|praise]\n' +
630
+ usage(tr(`rootr feedback "무엇을 하려다 무엇이 일어났는지" [--kind bug|friction|idea|praise]\n`, `rootr feedback "what you tried and what happened" [--kind bug|friction|idea|praise]\n`)) +
529
631
  ' rootr feedback --file report.md / cat log | rootr feedback --kind bug',
530
632
  );
531
633
  }
@@ -534,7 +636,7 @@ async function cmdFeedback(args) {
534
636
  if (args.kind) {
535
637
  kind = KIND_MAP[String(args.kind).toLowerCase()];
536
638
  if (!kind) {
537
- throw new Error(`--kind 는 ${Object.keys(KIND_MAP).join('|')} 중 하나입니다.`);
639
+ throw new Error(tr(`--kind 는 ${Object.keys(KIND_MAP).join('|')} 중 하나입니다.`, `--kind must be one of ${Object.keys(KIND_MAP).join('|')}.`));
538
640
  }
539
641
  }
540
642
 
@@ -551,12 +653,12 @@ async function cmdFeedback(args) {
551
653
  client: 'cli',
552
654
  });
553
655
  if (result.deduped) {
554
- console.log(`이미 같은 제보가 접수돼 있어 합쳤습니다. (id: ${result.id})`);
656
+ console.log(tr(`이미 같은 제보가 접수돼 있어 합쳤습니다. (id: ${result.id})`, `The same report already exists, so it was merged. (id: ${result.id})`));
555
657
  return;
556
658
  }
557
- console.log(`제보를 보냈습니다. 고맙습니다. (id: ${result.id})`);
659
+ console.log(tr(`제보를 보냈습니다. 고맙습니다. (id: ${result.id})`, `Feedback sent. Thank you. (id: ${result.id})`));
558
660
  if (!args.email) {
559
- console.log('답을 받고 싶으면 --email <주소>를 함께 주세요.');
661
+ console.log(tr('답을 받고 싶으면 --email <주소>를 함께 주세요.', `Pass --email <address> if you want a reply.`));
560
662
  }
561
663
  }
562
664
 
@@ -570,7 +672,7 @@ async function cmdConfig(args) {
570
672
  workspace: typeof args.workspace === 'string' ? args.workspace : undefined,
571
673
  label: typeof args.label === 'string' ? args.label : undefined,
572
674
  });
573
- console.log(`키를 등록했습니다 (총 ${keys.length}개): ${CONFIG_PATH}`);
675
+ console.log(tr(`키를 등록했습니다 (총 ${keys.length}개): ${CONFIG_PATH}`, `Key registered (${keys.length} total): ${CONFIG_PATH}`));
574
676
  return;
575
677
  }
576
678
 
@@ -593,7 +695,7 @@ async function cmdConfig(args) {
593
695
  if (typeof args['base-url'] === 'string') partial.baseUrl = args['base-url'];
594
696
 
595
697
  saveConfig(partial);
596
- console.log(`설정을 저장했습니다: ${CONFIG_PATH}`);
698
+ console.log(tr(`설정을 저장했습니다: ${CONFIG_PATH}`, `Configuration saved: ${CONFIG_PATH}`));
597
699
  }
598
700
 
599
701
  async function cmdMcp() {
@@ -608,6 +710,28 @@ async function cmdMcp() {
608
710
 
609
711
  const MEMORY_KINDS = ['지시', '사람', '프로젝트', '참고'];
610
712
 
713
+ /**
714
+ * 저장되는 값은 한국어 네 가지로 고정이다 — 화면·MCP·이미 쌓인 행이 전부 그 값을 쓴다.
715
+ * 영어로 치는 사람을 위해 입력만 받아 준다(출력·저장은 그대로).
716
+ */
717
+ const MEMORY_KIND_ALIASES = {
718
+ instruction: '지시',
719
+ person: '사람',
720
+ people: '사람',
721
+ project: '프로젝트',
722
+ reference: '참고',
723
+ ref: '참고',
724
+ };
725
+
726
+ function normalizeMemoryKind(value) {
727
+ if (!value || typeof value !== 'string') return value;
728
+ return MEMORY_KIND_ALIASES[value.trim().toLowerCase()] ?? value;
729
+ }
730
+
731
+ function memoryKindChoices() {
732
+ return tr(MEMORY_KINDS.join('/'), Object.keys(MEMORY_KIND_ALIASES).filter((k) => k !== 'people' && k !== 'ref').join('/'));
733
+ }
734
+
611
735
  function parseRelated(val) {
612
736
  if (!val) return undefined;
613
737
  if (Array.isArray(val)) return val;
@@ -628,7 +752,7 @@ async function cmdMemory(args) {
628
752
  return;
629
753
  }
630
754
  if (!result.memories.length) {
631
- console.log('아직 기억이 없습니다. rootr memory add "한 줄 사실" 으로 남겨 보세요.');
755
+ console.log(tr('아직 기억이 없습니다. rootr memory add "한 줄 사실" 으로 남겨 보세요.', `No memories yet. Try: rootr memory add "a one-line fact"`));
632
756
  return;
633
757
  }
634
758
  for (const m of result.memories) {
@@ -641,9 +765,15 @@ async function cmdMemory(args) {
641
765
 
642
766
  if (sub === 'add') {
643
767
  const content = args._.join(' ');
644
- if (!content) throw new Error('사용법: rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b]');
768
+ if (!content) throw new Error(usage(tr(`rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b]`, `rootr memory add "<content>" [--kind instruction|person|project|reference] [--source <path|id>] [--related a,b]`)));
769
+ args.kind = normalizeMemoryKind(args.kind);
645
770
  if (args.kind && !MEMORY_KINDS.includes(args.kind)) {
646
- throw new Error(`종류는 ${MEMORY_KINDS.join('/')} 중 하나여야 합니다: ${args.kind}`);
771
+ throw new Error(
772
+ tr(
773
+ tr(`종류는 ${memoryKindChoices()} 중 하나여야 합니다: ${args.kind}`, `--kind must be one of ${memoryKindChoices()}: ${args.kind}`),
774
+ `--kind must be one of ${memoryKindChoices()}: ${args.kind}`,
775
+ ),
776
+ );
647
777
  }
648
778
  const result = await client.remember({
649
779
  content,
@@ -651,20 +781,26 @@ async function cmdMemory(args) {
651
781
  source: args.source,
652
782
  related: parseRelated(args.related),
653
783
  });
654
- console.log(`${result.replaced ? '기억을 갈아끼웠습니다' : '기억했습니다'}: ${result.memory.content}`);
655
- console.log(`id: ${result.memory.id} · 상태: ${result.memory.status}`);
784
+ console.log(tr(`${result.replaced ? '기억을 갈아끼웠습니다' : '기억했습니다'}: ${result.memory.content}`, `${result.replaced ? 'Memory replaced' : 'Remembered'}: ${result.memory.content}`));
785
+ console.log(tr(`id: ${result.memory.id} · 상태: ${result.memory.status}`, `id: ${result.memory.id} · status: ${result.memory.status}`));
656
786
  return;
657
787
  }
658
788
 
659
789
  if (sub === 'edit') {
660
790
  const id = args._[0];
661
- if (!id) throw new Error('사용법: rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b]');
791
+ if (!id) throw new Error(usage(tr(`rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b]`, `rootr memory edit <id> [--content "<content>"] [--kind …] [--source …] [--related a,b]`)));
662
792
  const content = args._.slice(1).join(' ') || args.content;
663
793
  if (!content && !args.kind && !args.source && !args.related) {
664
- throw new Error('고칠 칸이 하나도 없습니다 — --content/--kind/--source/--related 중 하나는 주어야 합니다.');
794
+ throw new Error(tr('고칠 칸이 하나도 없습니다 — --content/--kind/--source/--related 중 하나는 주어야 합니다.', `Nothing to change — pass at least one of --content/--kind/--source/--related.`));
665
795
  }
796
+ args.kind = normalizeMemoryKind(args.kind);
666
797
  if (args.kind && !MEMORY_KINDS.includes(args.kind)) {
667
- throw new Error(`종류는 ${MEMORY_KINDS.join('/')} 중 하나여야 합니다: ${args.kind}`);
798
+ throw new Error(
799
+ tr(
800
+ tr(`종류는 ${memoryKindChoices()} 중 하나여야 합니다: ${args.kind}`, `--kind must be one of ${memoryKindChoices()}: ${args.kind}`),
801
+ `--kind must be one of ${memoryKindChoices()}: ${args.kind}`,
802
+ ),
803
+ );
668
804
  }
669
805
  const result = await client.revise(id, {
670
806
  ...(content ? { content } : {}),
@@ -672,20 +808,25 @@ async function cmdMemory(args) {
672
808
  ...(args.source !== undefined ? { source: args.source } : {}),
673
809
  ...(args.related !== undefined ? { related: parseRelated(args.related) } : {}),
674
810
  });
675
- console.log(`고쳤습니다: ${result.content}`);
676
- console.log(`상태: ${result.status}${result.mergedIds?.length ? ` · 같은 사실 ${result.mergedIds.length}건을 폐기로 내렸습니다` : ''}`);
811
+ console.log(tr(`고쳤습니다: ${result.content}`, `Updated: ${result.content}`));
812
+ console.log(
813
+ tr(
814
+ `상태: ${result.status}${result.mergedIds?.length ? ` · 같은 사실 ${result.mergedIds.length}건을 폐기로 내렸습니다` : ''}`,
815
+ `status: ${result.status}${result.mergedIds?.length ? ` · retired ${result.mergedIds.length} duplicate facts` : ''}`,
816
+ ),
817
+ );
677
818
  return;
678
819
  }
679
820
 
680
821
  if (sub === 'forget') {
681
822
  const id = args._[0];
682
- if (!id) throw new Error('사용법: rootr memory forget <id> [--hard]');
823
+ if (!id) throw new Error(usage(`rootr memory forget <id> [--hard]`));
683
824
  await client.forget(id, { hard: args.hard === true });
684
- console.log(args.hard ? '기억을 지웠습니다.' : '기억을 폐기 상태로 내렸습니다.');
825
+ console.log(args.hard ? tr('기억을 지웠습니다.', `Memory deleted.`) : tr('기억을 폐기 상태로 내렸습니다.', `Memory retired.`));
685
826
  return;
686
827
  }
687
828
 
688
- throw new Error(`알 수 없는 하위명령: ${sub}\n사용법: rootr memory [ls|add|edit|forget] …`);
829
+ throw new Error(tr(`알 수 없는 하위명령: ${sub}\n사용법: rootr memory [ls|add|edit|forget] …`, `Unknown subcommand: ${sub}\nusage: rootr memory [ls|add|edit|forget] …`));
689
830
  }
690
831
 
691
832
  // ---- entry ------------------------------------------------------------
@@ -708,7 +849,7 @@ async function cmdAccess(args) {
708
849
  const target = args._[0];
709
850
  if (!target) {
710
851
  throw new Error(
711
- '사용법: rootr access <path|id> [--private|--public|--inherit] [--grant <userId> --level READ|WRITE|MANAGE] [--revoke <userId>]',
852
+ usage(`rootr access <path|id> [--private|--public|--inherit] [--grant <userId> --level READ|WRITE|MANAGE] [--revoke <userId>]`),
712
853
  );
713
854
  }
714
855
  const client = makeClient();
@@ -720,22 +861,22 @@ async function cmdAccess(args) {
720
861
  const visibility = args.private ? 'PRIVATE' : args.public ? 'PUBLIC' : null;
721
862
  await client.setNodeVisibility(id, visibility);
722
863
  console.log(
723
- `공개범위: ${visibility === 'PRIVATE' ? '비공개(만든 사람·관리자·따로 준 사람만)' : visibility === 'PUBLIC' ? '워크스페이스 전체' : '상위 폴더/워크스페이스 기본값을 따름'}`,
864
+ tr(`공개범위: ${visibility === 'PRIVATE' ? '비공개(만든 사람·관리자·따로 준 사람만)' : visibility === 'PUBLIC' ? '워크스페이스 전체' : '상위 폴더/워크스페이스 기본값을 따름'}`, `Visibility: ${visibility === 'PRIVATE' ? 'private (creator, managers, and people granted access)' : visibility === 'PUBLIC' ? 'the whole workspace' : 'inherits from the parent folder/workspace'}`),
724
865
  );
725
866
  changed = true;
726
867
  }
727
868
  if (typeof args.grant === 'string') {
728
869
  const level = String(args.level || '').toUpperCase();
729
870
  if (!['READ', 'WRITE', 'MANAGE'].includes(level)) {
730
- throw new Error('--grant 에는 --level READ|WRITE|MANAGE 를 함께 주세요.');
871
+ throw new Error(tr('--grant 에는 --level READ|WRITE|MANAGE 를 함께 주세요.', `Pass --level READ|WRITE|MANAGE together with --grant.`));
731
872
  }
732
873
  await client.grantNodePermission(id, { subjectType: group, subjectId: args.grant, level });
733
- console.log(`권한 부여: ${args.grant} → ${level}`);
874
+ console.log(tr(`권한 부여: ${args.grant} → ${level}`, `Granted: ${args.grant} → ${level}`));
734
875
  changed = true;
735
876
  }
736
877
  if (typeof args.revoke === 'string') {
737
878
  await client.revokeNodePermission(id, { subjectType: group, subjectId: args.revoke });
738
- console.log(`권한 회수: ${args.revoke}`);
879
+ console.log(tr(`권한 회수: ${args.revoke}`, `Revoked: ${args.revoke}`));
739
880
  changed = true;
740
881
  }
741
882
 
@@ -746,10 +887,10 @@ async function cmdAccess(args) {
746
887
  }
747
888
  if (!changed) {
748
889
  const eff = info.effectiveVisibility ?? info.visibility;
749
- console.log(`공개범위: ${eff === 'PRIVATE' ? '비공개' : '워크스페이스 전체'}${info.visibility === null ? ' (상속받음)' : ''}`);
890
+ console.log(tr(`공개범위: ${eff === 'PRIVATE' ? '비공개' : '워크스페이스 전체'}${info.visibility === null ? ' (상속받음)' : ''}`, `Visibility: ${eff === 'PRIVATE' ? 'private' : 'the whole workspace'}${info.visibility === null ? ' (inherited)' : ''}`));
750
891
  }
751
892
  const direct = info.direct ?? [];
752
- console.log(`따로 준 권한: ${direct.length ? '' : '없음'}`);
893
+ console.log(tr(`따로 준 권한: ${direct.length ? '' : '없음'}`, `Direct grants: ${direct.length ? '' : 'none'}`));
753
894
  for (const p of direct) console.log(` - ${p.subjectType} ${p.subjectId} → ${p.level}`);
754
895
  }
755
896
 
@@ -769,7 +910,7 @@ async function cmdForm(args) {
769
910
  if (sub === 'response-edit') return cmdFormResponseEdit(args);
770
911
  if (sub === 'response-rm') return cmdFormResponseRm(args);
771
912
  throw new Error(
772
- '사용법: rootr form new|show|set|collect|responses|response-edit|response-rm … (자세한 건 rootr --help)',
913
+ usage(tr(`rootr form new|show|set|collect|responses|response-edit|response-rm … (자세한 건 rootr --help)`, `rootr form new|show|set|collect|responses|response-edit|response-rm … (see rootr --help)`)),
773
914
  );
774
915
  }
775
916
 
@@ -792,13 +933,13 @@ function parseFieldsArg(args) {
792
933
  try {
793
934
  parsed = JSON.parse(args.fields);
794
935
  } catch {
795
- throw new Error('--fields 는 JSON 배열이어야 합니다. 예: [{"name":"이메일","type":"email","required":true}]');
936
+ throw new Error(tr('--fields 는 JSON 배열이어야 합니다. 예: [{"name":"이메일","type":"email","required":true}]', `--fields must be a JSON array. Example: [{"name":"Email","type":"email","required":true}]`));
796
937
  }
797
- if (!Array.isArray(parsed)) throw new Error('--fields 는 배열이어야 합니다.');
938
+ if (!Array.isArray(parsed)) throw new Error(tr('--fields 는 배열이어야 합니다.', `--fields must be an array.`));
798
939
  for (const [i, f] of parsed.entries()) {
799
- if (!f || typeof f.name !== 'string') throw new Error(`fields[${i}].name 이 필요합니다.`);
940
+ if (!f || typeof f.name !== 'string') throw new Error(tr(`fields[${i}].name 이 필요합니다.`, `fields[${i}].name is required.`));
800
941
  if (!FORM_FIELD_TYPES.includes(f.type)) {
801
- throw new Error(`fields[${i}].type 은 다음 중 하나여야 합니다: ${FORM_FIELD_TYPES.join(', ')}`);
942
+ throw new Error(tr(`fields[${i}].type 은 다음 중 하나여야 합니다: ${FORM_FIELD_TYPES.join(', ')}`, `fields[${i}].type must be one of: ${FORM_FIELD_TYPES.join(', ')}`));
802
943
  }
803
944
  }
804
945
  return parsed;
@@ -809,14 +950,14 @@ function parseConfigArg(args) {
809
950
  try {
810
951
  return JSON.parse(args.config);
811
952
  } catch {
812
- throw new Error('--config 는 JSON 객체여야 합니다.');
953
+ throw new Error(tr('--config 는 JSON 객체여야 합니다.', `--config must be a JSON object.`));
813
954
  }
814
955
  }
815
956
 
816
957
  async function cmdFormNew(args) {
817
958
  const target = args._[1];
818
959
  if (!target) {
819
- throw new Error("사용법: rootr form new <경로> --fields '<json>' [--config '<json>'] [--collect db|sheet]");
960
+ throw new Error(usage(tr("rootr form new <경로> --fields '<json>' [--config '<json>'] [--collect db|sheet]", "rootr form new <path> --fields '<json>' [--config '<json>'] [--collect db|sheet]")));
820
961
  }
821
962
  const client = makeClient();
822
963
  const created = await client.createForm(client.workspace, {
@@ -826,19 +967,19 @@ async function cmdFormNew(args) {
826
967
  config: parseConfigArg(args),
827
968
  });
828
969
  const id = created.id ?? created.nodeId;
829
- console.log(`폼을 만들었습니다: ${target}`);
970
+ console.log(tr(`폼을 만들었습니다: ${target}`, `Form created: ${target}`));
830
971
  console.log(` id: ${id}`);
831
972
  if (args.collect === 'db' || args.collect === 'sheet') {
832
973
  const to = args.collect === 'db' ? 'database' : 'spreadsheet';
833
974
  await client.request(`/forms/${encodeURIComponent(id)}/collect`, { method: 'POST', body: { to } });
834
- console.log(` 응답 모을 곳: ${args.collect === 'db' ? '표' : '스프레드시트'} 생성됨`);
975
+ console.log(tr(` 응답 모을 곳: ${args.collect === 'db' ? '표' : '스프레드시트'} 생성됨`, ` Responses collected into: a ${args.collect === 'db' ? 'table' : 'spreadsheet'} was created`));
835
976
  }
836
977
  }
837
978
 
838
979
  /** `rootr form show <path|id>` — 질문·설정을 그대로 본다(고치기 전에 읽으라고 있는 것). */
839
980
  async function cmdFormShow(args) {
840
981
  const target = args._[1];
841
- if (!target) throw new Error('사용법: rootr form show <path|id> [--json]');
982
+ if (!target) throw new Error(usage(`rootr form show <path|id> [--json]`));
842
983
  const client = makeClient();
843
984
  // 경로도 id도 받는다(이 저장소의 다른 명령과 같은 규칙).
844
985
  const id = await resolveDatabaseId(client, target);
@@ -847,32 +988,32 @@ async function cmdFormShow(args) {
847
988
  console.log(JSON.stringify(form, null, 2));
848
989
  return;
849
990
  }
850
- console.log(`${form.name} (응답 ${form.responseCount ?? 0}건, 방문 ${form.viewCount ?? 0}회)`);
991
+ console.log(tr(`${form.name} (응답 ${form.responseCount ?? 0}건, 방문 ${form.viewCount ?? 0}회)`, `${form.name} (${form.responseCount ?? 0} responses, ${form.viewCount ?? 0} views)`));
851
992
  for (const [i, f] of (form.fields ?? []).entries()) {
852
993
  const marks = [
853
- f.required ? '필수' : null,
854
- f.hidden ? '숨김' : null,
855
- f.page && f.page > 1 ? `${f.page}쪽` : null,
856
- f.showIf ? '조건부' : null,
994
+ f.required ? tr('필수', `required`) : null,
995
+ f.hidden ? tr('숨김', `hidden`) : null,
996
+ f.page && f.page > 1 ? tr(`${f.page}쪽`, `page ${f.page}`) : null,
997
+ f.showIf ? tr('조건부', `conditional`) : null,
857
998
  ].filter(Boolean);
858
999
  console.log(` ${i + 1}. ${f.name} [${f.type}]${marks.length ? ' · ' + marks.join('·') : ''}`);
859
1000
  if (f.help) console.log(` ${f.help}`);
860
1001
  }
861
1002
  const cfg = form.config ?? {};
862
1003
  const bits = [];
863
- if (cfg.publicSubmit && cfg.publicSubmit !== 'off') bits.push(`공개 제출: ${cfg.publicSubmit}`);
864
- if (cfg.closesAt) bits.push(`마감: ${cfg.closesAt}`);
865
- if (cfg.maxResponses) bits.push(`정원: ${cfg.maxResponses}`);
866
- if (Array.isArray(cfg.notifyEmails) && cfg.notifyEmails.length) bits.push(`알림: ${cfg.notifyEmails.join(', ')}`);
867
- if (cfg.webhookUrl) bits.push('웹훅 있음');
868
- if (cfg.theme) bits.push(`분위기: ${cfg.theme}`);
1004
+ if (cfg.publicSubmit && cfg.publicSubmit !== 'off') bits.push(tr(`공개 제출: ${cfg.publicSubmit}`, `public submission: ${cfg.publicSubmit}`));
1005
+ if (cfg.closesAt) bits.push(tr(`마감: ${cfg.closesAt}`, `closes: ${cfg.closesAt}`));
1006
+ if (cfg.maxResponses) bits.push(tr(`정원: ${cfg.maxResponses}`, `capacity: ${cfg.maxResponses}`));
1007
+ if (Array.isArray(cfg.notifyEmails) && cfg.notifyEmails.length) bits.push(tr(`알림: ${cfg.notifyEmails.join(', ')}`, `notify: ${cfg.notifyEmails.join(', ')}`));
1008
+ if (cfg.webhookUrl) bits.push(tr('웹훅 있음', `has a webhook`));
1009
+ if (cfg.theme) bits.push(tr(`분위기: ${cfg.theme}`, `theme: ${cfg.theme}`));
869
1010
  // 켜 놓고 잊기 쉬운 것들 — 안 보이면 "왜 두 번 못 내지?" 하고 한참 찾는다.
870
- if (cfg.oncePerPerson) bits.push('한 사람 한 번만');
871
- if (cfg.confirmEmailField) bits.push('확인 메일 보냄');
872
- if (cfg.intro) bits.push('안내문 있음');
873
- if (cfg.thankYou) bits.push('인사말 있음');
874
- if (cfg.redirectUrl) bits.push(`낸 뒤 이동: ${cfg.redirectUrl}`);
875
- if (bits.length) console.log(` 설정 — ${bits.join(' · ')}`);
1011
+ if (cfg.oncePerPerson) bits.push(tr('한 사람 한 번만', `one response per person`));
1012
+ if (cfg.confirmEmailField) bits.push(tr('확인 메일 보냄', `sends a confirmation email`));
1013
+ if (cfg.intro) bits.push(tr('안내문 있음', `has an intro`));
1014
+ if (cfg.thankYou) bits.push(tr('인사말 있음', `has a greeting`));
1015
+ if (cfg.redirectUrl) bits.push(tr(`낸 뒤 이동: ${cfg.redirectUrl}`, `after submit, go to: ${cfg.redirectUrl}`));
1016
+ if (bits.length) console.log(tr(` 설정 — ${bits.join(' · ')}`, ` settings — ${bits.join(' · ')}`));
876
1017
  }
877
1018
 
878
1019
  /**
@@ -886,15 +1027,15 @@ async function cmdFormSet(args) {
886
1027
  const fields = parseFieldsArg(args);
887
1028
  const config = parseConfigArg(args);
888
1029
  if (!target || (fields === undefined && config === undefined)) {
889
- throw new Error("사용법: rootr form set <path|id> --fields '<json>' | --config '<json>'");
1030
+ throw new Error(usage("rootr form set <path|id> --fields '<json>' | --config '<json>'"));
890
1031
  }
891
1032
  const client = makeClient();
892
1033
  const id = await resolveDatabaseId(client, target);
893
1034
  // 설정은 **보낸 것만** 바뀐다(서버가 합쳐 준다) — 지우려면 그 항목에 null.
894
1035
  await client.updateForm(id, { ...(fields ? { fields } : {}), ...(config ? { config } : {}) });
895
- console.log('폼을 고쳤습니다.');
896
- if (fields) console.log(` 질문 ${fields.length}개`);
897
- if (config) console.log(` 바꾼 설정: ${Object.keys(config).join(', ')}`);
1036
+ console.log(tr('폼을 고쳤습니다.', `Form updated.`));
1037
+ if (fields) console.log(tr(` 질문 ${fields.length}개`, ` ${fields.length} questions`));
1038
+ if (config) console.log(tr(` 바꾼 설정: ${Object.keys(config).join(', ')}`, ` settings changed: ${Object.keys(config).join(', ')}`));
898
1039
  }
899
1040
 
900
1041
  /**
@@ -905,10 +1046,10 @@ async function cmdFormSet(args) {
905
1046
  */
906
1047
  async function cmdFormCollect(args) {
907
1048
  const target = args._[1];
908
- if (!target) throw new Error('사용법: rootr form collect <path|id> --to db|sheet');
1049
+ if (!target) throw new Error(usage(`rootr form collect <path|id> --to db|sheet`));
909
1050
  const to = String(args.to || '').toLowerCase();
910
1051
  const map = { db: 'database', database: 'database', sheet: 'spreadsheet', spreadsheet: 'spreadsheet' };
911
- if (!map[to]) throw new Error('--to 에 db 또는 sheet 를 주세요.');
1052
+ if (!map[to]) throw new Error(tr('--to 에 db 또는 sheet 를 주세요.', `Pass db or sheet to --to.`));
912
1053
 
913
1054
  const client = makeClient();
914
1055
  const id = requireResolved(await resolveTarget(client, target));
@@ -919,14 +1060,14 @@ async function cmdFormCollect(args) {
919
1060
  }
920
1061
  console.log(
921
1062
  made.kind === 'database'
922
- ? `표를 만들어 연결했습니다: ${made.nodeId} (폼 질문 그대로 칸이 생겼습니다)`
923
- : `스프레드시트를 만들어 연결했습니다: ${made.nodeId}`,
1063
+ ? tr(`표를 만들어 연결했습니다: ${made.nodeId} (폼 질문 그대로 칸이 생겼습니다)`, `Created and wired a table: ${made.nodeId} (one column per question)`)
1064
+ : tr(`스프레드시트를 만들어 연결했습니다: ${made.nodeId}`, `Created and wired a spreadsheet: ${made.nodeId}`),
924
1065
  );
925
1066
  }
926
1067
 
927
1068
  async function cmdFormResponses(args) {
928
1069
  const target = args._[1];
929
- if (!target) throw new Error('사용법: rootr form responses <path|id> [--latest] [--json]');
1070
+ if (!target) throw new Error(usage(`rootr form responses <path|id> [--latest] [--json]`));
930
1071
  const client = makeClient();
931
1072
  const id = requireResolved(await resolveTarget(client, target));
932
1073
  const rows = await client.listFormResponses(id, { latestPerRespondent: Boolean(args.latest) });
@@ -936,26 +1077,26 @@ async function cmdFormResponses(args) {
936
1077
  return;
937
1078
  }
938
1079
  if (!rows.length) {
939
- console.log('아직 들어온 응답이 없습니다.');
1080
+ console.log(tr('아직 들어온 응답이 없습니다.', `No responses yet.`));
940
1081
  return;
941
1082
  }
942
- console.log(`응답 ${rows.length}건${args.latest ? ' (사람마다 최신 1건만)' : ''}`);
1083
+ console.log(tr(`응답 ${rows.length}건${args.latest ? ' (사람마다 최신 1건만)' : ''}`, `${rows.length} responses${args.latest ? ' (newest one per person)' : ''}`));
943
1084
  for (const r of rows) {
944
- const who = r.submitterUserId ? r.submitterUserId : '익명';
945
- console.log(`- ${r.id} ${new Date(r.createdAt).toISOString()} 제출자:${who}`);
1085
+ const who = r.submitterUserId ? r.submitterUserId : tr('익명', `anonymous`);
1086
+ console.log(tr(`- ${r.id} ${new Date(r.createdAt).toISOString()} 제출자:${who}`, `- ${r.id} ${new Date(r.createdAt).toISOString()} by:${who}`));
946
1087
  console.log(` ${JSON.stringify(r.values)}`);
947
1088
  }
948
1089
  }
949
1090
 
950
1091
  async function cmdFormResponseEdit(args) {
951
1092
  const responseId = args._[1];
952
- if (!responseId) throw new Error("사용법: rootr form response-edit <응답id> --values '<json>'");
1093
+ if (!responseId) throw new Error(usage(tr("rootr form response-edit <응답id> --values '<json>'", "rootr form response-edit <responseId> --values '<json>'")));
953
1094
  const raw = typeof args.values === 'string' ? args.values : await readStdin();
954
1095
  let values;
955
1096
  try {
956
1097
  values = JSON.parse(raw);
957
1098
  } catch {
958
- throw new Error('--values 를 JSON으로 읽을 수 없습니다. {"필드id":"값"} 형태여야 합니다.');
1099
+ throw new Error(tr('--values 를 JSON으로 읽을 수 없습니다. {"필드id":"값"} 형태여야 합니다.', `Could not read --values as JSON. It must look like {"fieldId":"value"}.`));
959
1100
  }
960
1101
  // 통째로 바뀐다 — 남기고 싶은 답도 같이 보내야 한다.
961
1102
  const result = await makeClient().updateFormResponse(responseId, values);
@@ -963,18 +1104,18 @@ async function cmdFormResponseEdit(args) {
963
1104
  console.log(JSON.stringify(result, null, 2));
964
1105
  return;
965
1106
  }
966
- console.log(`응답 수정됨: ${result.id ?? responseId}`);
1107
+ console.log(tr(`응답 수정됨: ${result.id ?? responseId}`, `Response updated: ${result.id ?? responseId}`));
967
1108
  console.log(` ${JSON.stringify(result.values ?? values)}`);
968
1109
  }
969
1110
 
970
1111
  async function cmdFormResponseRm(args) {
971
1112
  const responseId = args._[1];
972
- if (!responseId) throw new Error('사용법: rootr form response-rm <응답id> [--yes]');
1113
+ if (!responseId) throw new Error(usage(tr(`rootr form response-rm <응답id> [--yes]`, `rootr form response-rm <responseId> [--yes]`)));
973
1114
  if (!args.yes) {
974
- throw new Error('응답 삭제는 되돌릴 수 없습니다. 정말 지우려면 --yes 를 붙이세요.');
1115
+ throw new Error(tr('응답 삭제는 되돌릴 수 없습니다. 정말 지우려면 --yes 를 붙이세요.', `Deleting a response cannot be undone. Add --yes if you really mean it.`));
975
1116
  }
976
1117
  await makeClient().deleteFormResponse(responseId);
977
- console.log(`응답 삭제됨: ${responseId}`);
1118
+ console.log(tr(`응답 삭제됨: ${responseId}`, `Response deleted: ${responseId}`));
978
1119
  }
979
1120
 
980
1121
  /**
@@ -987,13 +1128,18 @@ async function cmdDbRowsSet(args) {
987
1128
  const target = args._[1];
988
1129
  const ids = typeof args.ids === 'string' ? args.ids.split(',').map((i) => i.trim()).filter(Boolean) : [];
989
1130
  if (!target || !ids.length || typeof args.values !== 'string') {
990
- throw new Error("사용법: rootr db rows-set <path|id> --ids <id,id> --values '<json>'");
1131
+ throw new Error(usage("rootr db rows-set <path|id> --ids <id,id> --values '<json>'"));
991
1132
  }
992
1133
  let values;
993
1134
  try {
994
1135
  values = JSON.parse(args.values);
995
1136
  } catch {
996
- throw new Error('--values 를 JSON으로 읽을 수 없습니다. 예: \'{"작성일":{"start":"2026-08-01"}}\'');
1137
+ throw new Error(
1138
+ tr(
1139
+ '--values 를 JSON으로 읽을 수 없습니다. 예: \'{"작성일":{"start":"2026-08-01"}}\'',
1140
+ '--values could not be read as JSON. Example: \'{"Created":{"start":"2026-08-01"}}\'',
1141
+ ),
1142
+ );
997
1143
  }
998
1144
  const client = makeClient();
999
1145
  const id = await resolveDatabaseId(client, target);
@@ -1004,7 +1150,7 @@ async function cmdDbRowsSet(args) {
1004
1150
  Object.entries(values).map(([k, v]) => [byName.get(k) ?? k, v]),
1005
1151
  );
1006
1152
  const res = await client.bulkUpdateRows(id, ids, resolved);
1007
- console.log(`${res.updated}개 행을 바꿨습니다.`);
1153
+ console.log(tr(`${res.updated}개 행을 바꿨습니다.`, `${res.updated} rows changed.`));
1008
1154
  }
1009
1155
 
1010
1156
  /**
@@ -1016,13 +1162,13 @@ async function cmdDbRowsSet(args) {
1016
1162
  async function cmdDbRowsNew(args) {
1017
1163
  const target = args._[1];
1018
1164
  const raw = readJsonArg(args, 'rows-new');
1019
- if (!target) throw new Error("사용법: rootr db rows-new <path|id> --json '<[{…},…]>' | --file <파일>");
1165
+ if (!target) throw new Error(usage(tr("rootr db rows-new <path|id> --json '<[{…},…]>' | --file <파일>", "rootr db rows-new <path|id> --json '<[{…},…]>' | --file <file>")));
1020
1166
  const rows = Array.isArray(raw) ? raw : [raw];
1021
- if (!rows.length) throw new Error('넣을 행이 없습니다.');
1167
+ if (!rows.length) throw new Error(tr('넣을 행이 없습니다.', `No rows to insert.`));
1022
1168
  const client = makeClient();
1023
1169
  const id = await resolveDatabaseId(client, target);
1024
1170
  const res = await client.bulkCreateRows(id, rows);
1025
- console.log(`${res.created}개 행을 넣었습니다.`);
1171
+ console.log(tr(`${res.created}개 행을 넣었습니다.`, `${res.created} rows inserted.`));
1026
1172
  }
1027
1173
 
1028
1174
  /**
@@ -1033,17 +1179,17 @@ async function cmdDbRowsNew(args) {
1033
1179
  async function cmdDbRowsEach(args) {
1034
1180
  const target = args._[1];
1035
1181
  const raw = readJsonArg(args, 'rows-each');
1036
- if (!target) throw new Error("사용법: rootr db rows-each <path|id> --json '<[{rowId,values},…]>' | --file <파일>");
1182
+ if (!target) throw new Error(usage(tr("rootr db rows-each <path|id> --json '<[{rowId,values},…]>' | --file <파일>", "rootr db rows-each <path|id> --json '<[{rowId,values},…]>' | --file <file>")));
1037
1183
  const updates = Array.isArray(raw) ? raw : [raw];
1038
1184
  for (const u of updates) {
1039
1185
  if (!u || typeof u.rowId !== 'string' || typeof u.values !== 'object' || !u.values) {
1040
- throw new Error('각 항목은 { "rowId": "…", "values": { … } } 형태여야 합니다.');
1186
+ throw new Error(tr('각 항목은 { "rowId": "…", "values": { … } } 형태여야 합니다.', `Each item must look like { "rowId": "…", "values": { … } }.`));
1041
1187
  }
1042
1188
  }
1043
1189
  const client = makeClient();
1044
1190
  const id = await resolveDatabaseId(client, target);
1045
1191
  const res = await client.bulkUpdateRowsEach(id, updates);
1046
- console.log(`${res.updated}개 행을 바꿨습니다.`);
1192
+ console.log(tr(`${res.updated}개 행을 바꿨습니다.`, `${res.updated} rows changed.`));
1047
1193
  }
1048
1194
 
1049
1195
  /** `--json '<...>'` 또는 `--file <파일>` 중 하나를 읽는다. 행이 많으면 파일이 편하다. */
@@ -1061,7 +1207,7 @@ async function cmdScaffold(args) {
1061
1207
  const client = makeClient();
1062
1208
  if (sub === 'plan') {
1063
1209
  const intent = args._.slice(1).join(' ').trim();
1064
- if (!intent) throw new Error('사용법: rootr scaffold plan "무엇을 위한 워크스페이스인지"');
1210
+ if (!intent) throw new Error(usage(tr(`rootr scaffold plan "무엇을 위한 워크스페이스인지"`, `rootr scaffold plan "what the workspace is for"`)));
1065
1211
  console.log(JSON.stringify(await client.scaffoldPlan(intent), null, 2));
1066
1212
  return;
1067
1213
  }
@@ -1074,14 +1220,14 @@ async function cmdScaffold(args) {
1074
1220
  }
1075
1221
  console.log(r.summary);
1076
1222
  for (const f of r.findings) {
1077
- const mark = f.level === 'error' ? '고쳐야 함' : f.level === 'warn' ? '약함' : '더 나아짐';
1223
+ const mark = f.level === 'error' ? tr('고쳐야 함', `needs work`) : f.level === 'warn' ? tr('약함', `weak`) : tr('더 나아짐', `improved`);
1078
1224
  console.log(` [${mark}] ${f.at} ${f.message}`);
1079
1225
  }
1080
1226
  return;
1081
1227
  }
1082
1228
  if (sub === 'built') {
1083
1229
  const ws = args.workspace || loadConfig().workspace;
1084
- if (!ws) throw new Error('워크스페이스가 없습니다: --workspace <id>');
1230
+ if (!ws) throw new Error(tr('워크스페이스가 없습니다: --workspace <id>', `No workspace: --workspace <id>`));
1085
1231
  const r = await client.scaffoldReviewBuilt(ws, typeof args.root === 'string' ? args.root : undefined);
1086
1232
  if (args.json) {
1087
1233
  console.log(JSON.stringify(r, null, 2));
@@ -1089,14 +1235,14 @@ async function cmdScaffold(args) {
1089
1235
  }
1090
1236
  console.log(r.summary);
1091
1237
  for (const f of r.findings) {
1092
- const mark = f.level === 'error' ? '고쳐야 함' : f.level === 'warn' ? '약함' : '더 나아짐';
1238
+ const mark = f.level === 'error' ? tr('고쳐야 함', `needs work`) : f.level === 'warn' ? tr('약함', `weak`) : tr('더 나아짐', `improved`);
1093
1239
  console.log(` [${mark}] ${f.at} ${f.message}`);
1094
1240
  }
1095
1241
  return;
1096
1242
  }
1097
1243
  if (sub === 'test') {
1098
1244
  const ws = args.workspace || loadConfig().workspace;
1099
- if (!ws) throw new Error('워크스페이스가 없습니다: --workspace <id>');
1245
+ if (!ws) throw new Error(tr('워크스페이스가 없습니다: --workspace <id>', `No workspace: --workspace <id>`));
1100
1246
  const r = await client.scaffoldSelfTest(ws, typeof args.root === 'string' ? args.root : undefined);
1101
1247
  if (args.json) {
1102
1248
  console.log(JSON.stringify(r, null, 2));
@@ -1104,7 +1250,7 @@ async function cmdScaffold(args) {
1104
1250
  }
1105
1251
  console.log(r.summary);
1106
1252
  for (const c of r.checks) {
1107
- console.log(` [${c.level === 'fail' ? '실패' : '경고'}] ${c.at} (${c.check}) ${c.message}`);
1253
+ console.log(tr(` [${c.level === 'fail' ? '실패' : '경고'}] ${c.at} (${c.check}) ${c.message}`, ` [${c.level === 'fail' ? 'FAIL' : 'WARN'}] ${c.at} (${c.check}) ${c.message}`));
1108
1254
  }
1109
1255
  if (r.failed) process.exitCode = 1;
1110
1256
  return;
@@ -1112,27 +1258,27 @@ async function cmdScaffold(args) {
1112
1258
  if (sub === 'apply') {
1113
1259
  const tree = readJsonArg(args, 'scaffold apply');
1114
1260
  const ws = args.workspace || loadConfig().workspace;
1115
- if (!ws) throw new Error('워크스페이스가 없습니다: --workspace <id> 또는 rootr config --workspace <id>');
1261
+ if (!ws) throw new Error(tr('워크스페이스가 없습니다: --workspace <id> 또는 rootr config --workspace <id>', `No workspace: --workspace <id>, or rootr config --workspace <id>`));
1116
1262
  const r = await client.scaffoldApply(ws, {
1117
1263
  rootPath: typeof args.root === 'string' ? args.root : undefined,
1118
1264
  tree: Array.isArray(tree) ? tree : tree.tree,
1119
1265
  });
1120
- console.log(`${r.created.length}개를 ${r.rootPath || '/'} 아래에 만들었습니다.`);
1121
- if (r.review) console.log(`검사: ${r.review.summary}`);
1266
+ console.log(tr(`${r.created.length}개를 ${r.rootPath || '/'} 아래에 만들었습니다.`, `Created ${r.created.length} items under ${r.rootPath || '/'}.`));
1267
+ if (r.review) console.log(tr(`검사: ${r.review.summary}`, `Review: ${r.review.summary}`));
1122
1268
  return;
1123
1269
  }
1124
- throw new Error('사용법: rootr scaffold plan|review|apply (review/apply 는 --file tree.json)');
1270
+ throw new Error(usage(tr(`rootr scaffold plan|review|apply (review/apply 는 --file tree.json)`, `rootr scaffold plan|review|apply (review/apply take --file tree.json)`)));
1125
1271
  }
1126
1272
 
1127
1273
  function readJsonArg(args, cmd) {
1128
1274
  let text = null;
1129
1275
  if (typeof args.file === 'string') text = readFileSync(args.file, 'utf8');
1130
1276
  else if (typeof args.json === 'string') text = args.json;
1131
- if (text == null) throw new Error(`${cmd}: --json 또는 --file 이 필요합니다.`);
1277
+ if (text == null) throw new Error(tr(`${cmd}: --json 또는 --file 이 필요합니다.`, `${cmd}: --json or --file is required.`));
1132
1278
  try {
1133
1279
  return JSON.parse(text);
1134
1280
  } catch {
1135
- throw new Error(`${cmd}: JSON으로 읽을 수 없습니다.`);
1281
+ throw new Error(tr(`${cmd}: JSON으로 읽을 수 없습니다.`, `${cmd}: could not read it as JSON.`));
1136
1282
  }
1137
1283
  }
1138
1284
 
@@ -1141,15 +1287,15 @@ async function cmdDbRowsRm(args) {
1141
1287
  const target = args._[1];
1142
1288
  const ids = typeof args.ids === 'string' ? args.ids.split(',').map((i) => i.trim()).filter(Boolean) : [];
1143
1289
  if (!target || !ids.length) {
1144
- throw new Error('사용법: rootr db rows-rm <path|id> --ids <id,id> [--yes]');
1290
+ throw new Error(usage(`rootr db rows-rm <path|id> --ids <id,id> [--yes]`));
1145
1291
  }
1146
1292
  if (!args.yes) {
1147
- throw new Error(`${ids.length}개 행을 지웁니다. 되돌릴 수 없으니 정말 지우려면 --yes 를 붙이세요.`);
1293
+ throw new Error(tr(`${ids.length}개 행을 지웁니다. 되돌릴 수 없으니 정말 지우려면 --yes 를 붙이세요.`, `This deletes ${ids.length} rows and cannot be undone. Add --yes if you really mean it.`));
1148
1294
  }
1149
1295
  const client = makeClient();
1150
1296
  const id = await resolveDatabaseId(client, target);
1151
1297
  const res = await client.bulkDeleteRows(id, ids);
1152
- console.log(`${res.deleted}개 행을 지웠습니다.`);
1298
+ console.log(tr(`${res.deleted}개 행을 지웠습니다.`, `${res.deleted} rows deleted.`));
1153
1299
  }
1154
1300
 
1155
1301
  /**
@@ -1177,7 +1323,7 @@ async function cmdPublish(args) {
1177
1323
  const target = args._[0];
1178
1324
  if (!target) {
1179
1325
  throw new Error(
1180
- '사용법: rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>] ' +
1326
+ usage(tr(`rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>] `, `rootr publish <path|id> [--children] [--view <id>] [--layout <layout>] [--skin <skin>] `)) +
1181
1327
  '[--comments] [--expires <ISO>] | --off | --status',
1182
1328
  );
1183
1329
  }
@@ -1190,24 +1336,24 @@ async function cmdPublish(args) {
1190
1336
  if (args.status) {
1191
1337
  const st = isDocument ? await client.getDocumentPublicStatus(id) : await client.nodePublicStatus(id);
1192
1338
  if (!isDocument && !st.publishable) {
1193
- console.log('이 종류는 공개할 수 없습니다(폴더).');
1339
+ console.log(tr('이 종류는 공개할 수 없습니다(폴더).', `This node type cannot be published (folder).`));
1194
1340
  return;
1195
1341
  }
1196
1342
  if (!st.isPublic) {
1197
- console.log(st.slug ? `닫혀 있습니다(주소는 남아 있음: /p/${st.slug})` : '아직 공개한 적이 없습니다.');
1343
+ console.log(st.slug ? tr(`닫혀 있습니다(주소는 남아 있음: /p/${st.slug})`, `Closed (the address is still reserved: /p/${st.slug})`) : tr('아직 공개한 적이 없습니다.', `Never published.`));
1198
1344
  return;
1199
1345
  }
1200
- console.log(`공개 중: /p/${st.slug}`);
1201
- if (st.expiresAt) console.log(` 만료: ${st.expiresAt}`);
1346
+ console.log(tr(`공개 중: /p/${st.slug}`, `Public: /p/${st.slug}`));
1347
+ if (st.expiresAt) console.log(tr(` 만료: ${st.expiresAt}`, ` expires: ${st.expiresAt}`));
1202
1348
  if (isDocument) {
1203
- console.log(` 댓글(편집 제안) 허용: ${st.allowCommits ? '예' : '아니오'}`);
1204
- if (st.publicConfig?.skin) console.log(` 스킨: ${st.publicConfig.skin}`);
1349
+ console.log(tr(` 댓글(편집 제안) 허용: ${st.allowCommits ? '예' : '아니오'}`, ` comments (edit suggestions) allowed: ${st.allowCommits ? 'yes' : 'no'}`));
1350
+ if (st.publicConfig?.skin) console.log(tr(` 스킨: ${st.publicConfig.skin}`, ` skin: ${st.publicConfig.skin}`));
1205
1351
  } else {
1206
- if (st.hasPassword) console.log(' 비밀번호 걸려 있음');
1207
- console.log(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`);
1208
- console.log(` 복제 허용: ${st.allowClone === false ? '아니오' : '예'}`);
1352
+ if (st.hasPassword) console.log(tr(' 비밀번호 걸려 있음', ` password protected`));
1353
+ console.log(tr(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`, ` children published too: ${st.includeChildren ? 'yes' : 'no'}`));
1354
+ console.log(tr(` 복제 허용: ${st.allowClone === false ? '아니오' : '예'}`, ` cloning allowed: ${st.allowClone === false ? 'no' : 'yes'}`));
1209
1355
  if (st.design && (st.design.layout || st.design.skin)) {
1210
- console.log(` 보이는 방식: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`);
1356
+ console.log(tr(` 보이는 방식: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`, ` looks like: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`));
1211
1357
  }
1212
1358
  }
1213
1359
  return;
@@ -1216,18 +1362,18 @@ async function cmdPublish(args) {
1216
1362
  if (args.off) {
1217
1363
  if (isDocument) await client.setDocumentPublic(id, { isPublic: false });
1218
1364
  else await client.setNodePublic(id, { isPublic: false });
1219
- console.log('공개를 닫았습니다. 딸려 나갔던 하위 문서도 같이 닫혔습니다(주소는 남아 있습니다).');
1365
+ console.log(tr('공개를 닫았습니다. 딸려 나갔던 하위 문서도 같이 닫혔습니다(주소는 남아 있습니다).', `Publishing stopped. Children that came along were closed too (the addresses stay reserved).`));
1220
1366
  return;
1221
1367
  }
1222
1368
 
1223
1369
  if (isDocument) {
1224
1370
  if (args.skin !== undefined && !DOC_SKINS.includes(args.skin)) {
1225
- throw new Error(`--skin 은 다음 중 하나여야 합니다: ${DOC_SKINS.join(', ')}`);
1371
+ throw new Error(tr(`--skin 은 다음 중 하나여야 합니다: ${DOC_SKINS.join(', ')}`, `--skin must be one of: ${DOC_SKINS.join(', ')}`));
1226
1372
  }
1227
1373
  const cur = await client.getDocumentPublicStatus(id);
1228
1374
  if (!cur.isPublic) {
1229
1375
  const res = await client.publishDocument(id);
1230
- console.log(`공개했습니다: /p/${res.slug}`);
1376
+ console.log(tr(`공개했습니다: /p/${res.slug}`, `Published: /p/${res.slug}`));
1231
1377
  }
1232
1378
  const patch = {};
1233
1379
  const comments = flagToBool(args.comments);
@@ -1237,10 +1383,10 @@ async function cmdPublish(args) {
1237
1383
  if (Object.keys(patch).length) {
1238
1384
  await client.setDocumentPublic(id, patch);
1239
1385
  if (patch.allowCommits !== undefined) {
1240
- console.log(` 댓글(편집 제안) 허용: ${patch.allowCommits ? '예' : '아니오'}`);
1386
+ console.log(tr(` 댓글(편집 제안) 허용: ${patch.allowCommits ? '예' : '아니오'}`, ` comments (edit suggestions) allowed: ${patch.allowCommits ? 'yes' : 'no'}`));
1241
1387
  }
1242
- if (patch.expiresAt !== undefined) console.log(` 만료: ${patch.expiresAt}`);
1243
- if (patch.skin !== undefined) console.log(` 스킨: ${patch.skin}`);
1388
+ if (patch.expiresAt !== undefined) console.log(tr(` 만료: ${patch.expiresAt}`, ` expires: ${patch.expiresAt}`));
1389
+ if (patch.skin !== undefined) console.log(tr(` 스킨: ${patch.skin}`, ` skin: ${patch.skin}`));
1244
1390
  }
1245
1391
  return;
1246
1392
  }
@@ -1251,20 +1397,20 @@ async function cmdPublish(args) {
1251
1397
  ...(typeof args.skin === 'string' ? { skin: args.skin } : {}),
1252
1398
  };
1253
1399
  if (design.layout && !DB_LAYOUTS.includes(design.layout)) {
1254
- throw new Error(`--layout 은 다음 중 하나여야 합니다: ${DB_LAYOUTS.join(', ')}`);
1400
+ throw new Error(tr(`--layout 은 다음 중 하나여야 합니다: ${DB_LAYOUTS.join(', ')}`, `--layout must be one of: ${DB_LAYOUTS.join(', ')}`));
1255
1401
  }
1256
1402
  if (design.skin && !DB_SKINS.includes(design.skin)) {
1257
- throw new Error(`--skin 은 다음 중 하나여야 합니다: ${DB_SKINS.join(', ')}`);
1403
+ throw new Error(tr(`--skin 은 다음 중 하나여야 합니다: ${DB_SKINS.join(', ')}`, `--skin must be one of: ${DB_SKINS.join(', ')}`));
1258
1404
  }
1259
1405
  const res = await client.publishNode(id, {
1260
1406
  ...(args.children ? { includeChildren: true } : {}),
1261
1407
  ...(typeof args.view === 'string' ? { view: args.view } : {}),
1262
1408
  ...(Object.keys(design).length ? { design } : {}),
1263
1409
  });
1264
- console.log(`공개했습니다: /p/${res.slug}`);
1265
- if (args.children) console.log(' 이 문서 아래 전부(손자·증손자 포함)도 각자 주소로 공개됩니다.');
1410
+ console.log(tr(`공개했습니다: /p/${res.slug}`, `Published: /p/${res.slug}`));
1411
+ if (args.children) console.log(tr(' 이 문서 아래 전부(손자·증손자 포함)도 각자 주소로 공개됩니다.', ` Everything under this document (grandchildren included) gets its own public address.`));
1266
1412
  if (Object.keys(design).length) {
1267
- console.log(` 보이는 방식: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`);
1413
+ console.log(tr(` 보이는 방식: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`, ` looks like: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`));
1268
1414
  }
1269
1415
  }
1270
1416
 
@@ -1278,7 +1424,7 @@ async function cmdReadReq(args) {
1278
1424
  const target = args._[0];
1279
1425
  if (!target) {
1280
1426
  throw new Error(
1281
- '사용법: rootr readreq <path|id> [--json] | --users <id,id> [--due <ISO>] [--ack] | --clear',
1427
+ usage(`rootr readreq <path|id> [--json] | --users <id,id> [--due <ISO>] [--ack] | --clear`),
1282
1428
  );
1283
1429
  }
1284
1430
  const client = makeClient();
@@ -1286,19 +1432,19 @@ async function cmdReadReq(args) {
1286
1432
 
1287
1433
  if (args.clear) {
1288
1434
  await client.clearReadRequirement(id);
1289
- console.log('읽음 확인 해제됨');
1435
+ console.log(tr('읽음 확인 해제됨', `Read requirement cleared`));
1290
1436
  return;
1291
1437
  }
1292
1438
 
1293
1439
  if (typeof args.users === 'string') {
1294
1440
  const userIds = args.users.split(',').map((u) => u.trim()).filter(Boolean);
1295
- if (!userIds.length) throw new Error('--users 에 사람 id를 쉼표로 주세요.');
1441
+ if (!userIds.length) throw new Error(tr('--users 에 사람 id를 쉼표로 주세요.', `Pass user ids to --users, comma separated.`));
1296
1442
  const res = await client.setReadRequirement(id, {
1297
1443
  userIds,
1298
1444
  dueAt: typeof args.due === 'string' ? args.due : null,
1299
1445
  requireExplicitAck: Boolean(args.ack),
1300
1446
  });
1301
- console.log(`읽어야 할 사람 ${userIds.length}명 지정됨${args.ack ? ' (확인 버튼까지 눌러야 함)' : ''}`);
1447
+ console.log(tr(`읽어야 할 사람 ${userIds.length}명 지정됨${args.ack ? ' (확인 버튼까지 눌러야 함)' : ''}`, `${userIds.length} people must read it${args.ack ? ' (they must press the acknowledge button too)' : ''}`));
1302
1448
  if (args.json) console.log(JSON.stringify(res, null, 2));
1303
1449
  return;
1304
1450
  }
@@ -1311,19 +1457,19 @@ async function cmdReadReq(args) {
1311
1457
  if (!res.requirement) {
1312
1458
  console.log(
1313
1459
  res.ancestorRequirementId
1314
- ? `이 문서 자체에는 없고, 위쪽(${res.ancestorRequirementId})에 걸려 있습니다.`
1315
- : '읽음 확인이 걸려 있지 않습니다.',
1460
+ ? tr(`이 문서 자체에는 없고, 위쪽(${res.ancestorRequirementId})에 걸려 있습니다.`, `Not on this document — it is set higher up (${res.ancestorRequirementId}).`)
1461
+ : tr('읽음 확인이 걸려 있지 않습니다.', `No read requirement is set.`),
1316
1462
  );
1317
1463
  return;
1318
1464
  }
1319
1465
  const r = res.requirement;
1320
1466
  console.log(
1321
- `${r.done}/${r.total}명 완료` +
1322
- ` · 마감: ${r.dueAt ?? '없음'}${r.overdue ? ' (지남)' : ''}` +
1323
- `${r.requireExplicitAck ? ' · 확인 버튼 필요' : ''}`,
1467
+ tr(`${r.done}/${r.total}명 완료`, `${r.done}/${r.total} done`) +
1468
+ tr(` · 마감: ${r.dueAt ?? '없음'}${r.overdue ? ' (지남)' : ''}`, ` · due: ${r.dueAt ?? 'none'}${r.overdue ? ' (overdue)' : ''}`) +
1469
+ tr(`${r.requireExplicitAck ? ' · 확인 버튼 필요' : ''}`, `${r.requireExplicitAck ? ' · acknowledge button required' : ''}`),
1324
1470
  );
1325
1471
  for (const target of r.targets ?? []) {
1326
- const mark = target.ackedAt ? '확인' : target.firstViewedAt ? '읽음' : '아직';
1472
+ const mark = target.ackedAt ? tr('확인', `acknowledged`) : target.firstViewedAt ? tr('읽음', `read`) : tr('아직', `not yet`);
1327
1473
  // 표·폴더에 걸었으면 "안의 문서를 몇 개 읽었나"가 진짜 진도다.
1328
1474
  const progress =
1329
1475
  r.childTotal > 0 ? ` ${target.readCount ?? 0}/${r.childTotal}` : '';
@@ -1355,7 +1501,7 @@ async function cmdDb(args) {
1355
1501
  if (sub === 'view') return cmdDbView(args);
1356
1502
  if (sub !== 'create') {
1357
1503
  throw new Error(
1358
- '사용법: rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify|buttons|button-run|view … (자세한 건 rootr --help)',
1504
+ usage(tr(`rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify|buttons|button-run|view … (자세한 건 rootr --help)`, `rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify|buttons|button-run|view … (see rootr --help)`)),
1359
1505
  );
1360
1506
  }
1361
1507
  const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
@@ -1363,25 +1509,25 @@ async function cmdDb(args) {
1363
1509
  try {
1364
1510
  spec = JSON.parse(raw);
1365
1511
  } catch {
1366
- throw new Error('스펙을 JSON으로 읽을 수 없습니다. { "name": "...", "properties": [...] } 형태여야 합니다.');
1512
+ throw new Error(tr('스펙을 JSON으로 읽을 수 없습니다. { "name": "...", "properties": [...] } 형태여야 합니다.', `Could not read the spec as JSON. It must look like { "name": "...", "properties": [...] }.`));
1367
1513
  }
1368
- if (!spec.name && !spec.path) throw new Error('스펙에 name 또는 path가 있어야 합니다.');
1514
+ if (!spec.name && !spec.path) throw new Error(tr('스펙에 name 또는 path가 있어야 합니다.', `The spec needs a name or a path.`));
1369
1515
 
1370
1516
  const client = makeClient();
1371
1517
  let parentId = spec.parentId;
1372
1518
  if (typeof args.parent === 'string') {
1373
1519
  const resolved = await resolveTarget(client, args.parent);
1374
- if (!resolved.existed) throw new Error(`부모를 찾을 수 없습니다: ${args.parent}`);
1520
+ if (!resolved.existed) throw new Error(tr(`부모를 찾을 수 없습니다: ${args.parent}`, `Parent not found: ${args.parent}`));
1375
1521
  parentId = resolved.id;
1376
1522
  }
1377
1523
  const ws = args.ws || client.workspace;
1378
- if (!ws) throw new Error('워크스페이스를 알 수 없습니다. --ws 를 주거나 rootr use 로 정하세요.');
1524
+ if (!ws) throw new Error(tr('워크스페이스를 알 수 없습니다. --ws 를 주거나 rootr use 로 정하세요.', `No workspace. Pass --ws, or pick one with rootr use.`));
1379
1525
 
1380
1526
  const db = await client.createDatabase(ws, { ...spec, parentId });
1381
1527
  const cols = (db.properties || []).map((p) => p.name).join(', ');
1382
- console.log(`표 생성: ${db.path ?? db.name} (${db.id})`);
1383
- if (cols) console.log(`컬럼 순서: ${cols}`);
1384
- if (typeof db.rowCount === 'number') console.log(`행: ${db.rowCount}개`);
1528
+ console.log(tr(`표 생성: ${db.path ?? db.name} (${db.id})`, `Table created: ${db.path ?? db.name} (${db.id})`));
1529
+ if (cols) console.log(tr(`컬럼 순서: ${cols}`, `Column order: ${cols}`));
1530
+ if (typeof db.rowCount === 'number') console.log(tr(`행: ${db.rowCount}개`, `Rows: ${db.rowCount}`));
1385
1531
  }
1386
1532
 
1387
1533
  /** 표의 행 목록. --body 를 주면 행마다 상세 내용(본문 메모)까지 가져온다. */
@@ -1392,7 +1538,7 @@ async function cmdDb(args) {
1392
1538
  */
1393
1539
  async function cmdDbLock(args) {
1394
1540
  const target = args._[1];
1395
- if (!target) throw new Error('사용법: rootr db lock <path|id> [--schema managers|open] [--rows own|all] [--locked "승인,점수"]');
1541
+ if (!target) throw new Error(usage(tr(`rootr db lock <path|id> [--schema managers|open] [--rows own|all] [--locked "승인,점수"]`, `rootr db lock <path|id> [--schema managers|open] [--rows own|all] [--locked "Approved,Score"]`)));
1396
1542
 
1397
1543
  const client = makeClient();
1398
1544
  const id = requireResolved(await resolveTarget(client, target));
@@ -1415,9 +1561,9 @@ async function cmdDbLock(args) {
1415
1561
  console.log(JSON.stringify(result, null, 2));
1416
1562
  return;
1417
1563
  }
1418
- console.log(`칸·뷰 잠금: ${result.schemaLock === 'managers' ? '관리자만' : '누구나'}`);
1419
- console.log(`행 보기 : ${result.rowScope === 'own' ? '본인이 만든 행만' : '전부'}`);
1420
- console.log(`잠긴 칸 : ${(result.lockedProps ?? []).length ? result.lockedProps.join(', ') : '없음'}`);
1564
+ console.log(tr(`칸·뷰 잠금: ${result.schemaLock === 'managers' ? '관리자만' : '누구나'}`, `Columns & views locked: ${result.schemaLock === 'managers' ? 'managers only' : 'anyone'}`));
1565
+ console.log(tr(`행 보기 : ${result.rowScope === 'own' ? '본인이 만든 행만' : '전부'}`, `Row visibility : ${result.rowScope === 'own' ? 'only rows you created' : 'all'}`));
1566
+ console.log(tr(`잠긴 칸 : ${(result.lockedProps ?? []).length ? result.lockedProps.join(', ') : '없음'}`, `Locked columns : ${(result.lockedProps ?? []).length ? result.lockedProps.join(', ') : 'none'}`));
1421
1567
  }
1422
1568
 
1423
1569
  /**
@@ -1428,21 +1574,21 @@ async function cmdDbLock(args) {
1428
1574
  */
1429
1575
  async function cmdDbNotify(args) {
1430
1576
  const target = args._[1];
1431
- if (!target) throw new Error('사용법: rootr db notify <path|id> [--file rules.json] [--clear] [--json]');
1577
+ if (!target) throw new Error(usage(`rootr db notify <path|id> [--file rules.json] [--clear] [--json]`));
1432
1578
 
1433
1579
  const client = makeClient();
1434
1580
  const id = requireResolved(await resolveTarget(client, target));
1435
1581
 
1436
1582
  if (args.clear) {
1437
1583
  const cleared = await client.setDatabaseNotifications(id, []);
1438
- console.log('알림 규칙을 모두 지웠습니다.');
1584
+ console.log(tr('알림 규칙을 모두 지웠습니다.', `All notification rules cleared.`));
1439
1585
  if (args.json) console.log(JSON.stringify(cleared, null, 2));
1440
1586
  return;
1441
1587
  }
1442
1588
  if (args.file) {
1443
1589
  const raw = readFileSync(args.file, 'utf8');
1444
1590
  const saved = await client.setDatabaseNotifications(id, JSON.parse(raw));
1445
- console.log(`알림 규칙 ${saved.watchers.length}개를 저장했습니다.`);
1591
+ console.log(tr(`알림 규칙 ${saved.watchers.length}개를 저장했습니다.`, `Saved ${saved.watchers.length} notification rules.`));
1446
1592
  if (args.json) console.log(JSON.stringify(saved, null, 2));
1447
1593
  return;
1448
1594
  }
@@ -1453,19 +1599,19 @@ async function cmdDbNotify(args) {
1453
1599
  return;
1454
1600
  }
1455
1601
  if (!watchers.length) {
1456
- console.log('알림 규칙이 없습니다.');
1602
+ console.log(tr('알림 규칙이 없습니다.', `No notification rules.`));
1457
1603
  return;
1458
1604
  }
1459
- const WHEN = { created: '행 추가', updated: '행 수정', deleted: '행 삭제' };
1605
+ const WHEN = { created: tr('행 추가', `row added`), updated: tr('행 수정', `row changed`), deleted: tr('행 삭제', `row deleted`) };
1460
1606
  for (const w of watchers) {
1461
1607
  const when = (w.events ?? []).map((e) => WHEN[e] ?? e).join(' · ');
1462
1608
  const f = w.filter;
1463
1609
  const only = f
1464
1610
  ? f.op === 'changed'
1465
- ? ` (${f.propId} 칸이 바뀌었을 때만)`
1611
+ ? tr(` (${f.propId} 칸이 바뀌었을 때만)`, ` (only when the ${f.propId} column changes)`)
1466
1612
  : ` (${f.propId} ${f.op === 'is' ? '=' : '⊃'} ${f.value})`
1467
1613
  : '';
1468
- console.log(`- ${when}${only} → ${(w.userIds ?? []).length}명`);
1614
+ console.log(tr(`- ${when}${only} → ${(w.userIds ?? []).length}명`, `- ${when}${only} → ${(w.userIds ?? []).length} people`));
1469
1615
  }
1470
1616
  }
1471
1617
 
@@ -1478,7 +1624,7 @@ async function cmdDbNotify(args) {
1478
1624
  */
1479
1625
  async function cmdDbView(args) {
1480
1626
  const target = args._[1];
1481
- if (!target) throw new Error('사용법: rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--json]');
1627
+ if (!target) throw new Error(usage(tr(`rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--json]`, `rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] [--json]`)));
1482
1628
 
1483
1629
  const client = makeClient();
1484
1630
  const id = await resolveDatabaseId(client, target);
@@ -1492,12 +1638,12 @@ async function cmdDbView(args) {
1492
1638
  return;
1493
1639
  }
1494
1640
  if (!views.length) {
1495
- console.log('뷰가 없습니다.');
1641
+ console.log(tr('뷰가 없습니다.', `No views.`));
1496
1642
  return;
1497
1643
  }
1498
- const SPAN = { week: '주', month: '달', year: '해' };
1644
+ const SPAN = { week: tr('주', `week`), month: tr('달', `month`), year: tr('해', `year`) };
1499
1645
  for (const v of views) {
1500
- const span = v.type === 'calendar' ? ` · ${SPAN[v.config?.calendarScale] ?? '달'} 단위` : '';
1646
+ const span = v.type === 'calendar' ? ` · ${SPAN[v.config?.calendarScale] ?? tr('달', `month`)} 단위` : '';
1501
1647
  console.log(`- ${v.name} (${v.type})${span} ${v.id}`);
1502
1648
  }
1503
1649
  return;
@@ -1507,13 +1653,13 @@ async function cmdDbView(args) {
1507
1653
  if (args.scale !== undefined) {
1508
1654
  const scale = String(args.scale);
1509
1655
  if (!['week', 'month', 'year'].includes(scale)) {
1510
- throw new Error('--scale 은 week · month · year 중 하나여야 합니다.');
1656
+ throw new Error(tr('--scale 은 week · month · year 중 하나여야 합니다.', `--scale must be one of week · month · year.`));
1511
1657
  }
1512
1658
  patch.calendarScale = scale;
1513
1659
  }
1514
1660
  if (typeof args.name === 'string') patch.name = args.name;
1515
1661
  if (!Object.keys(patch).length) {
1516
- throw new Error('바꿀 것을 주세요: --scale week|month|year 또는 --name 새이름');
1662
+ throw new Error(tr('바꿀 것을 주세요: --scale week|month|year 또는 --name 새이름', `Give something to change: --scale week|month|year, or --name <new name>`));
1517
1663
  }
1518
1664
 
1519
1665
  const view = await client.updateDatabaseView(id, viewRef, patch);
@@ -1521,9 +1667,9 @@ async function cmdDbView(args) {
1521
1667
  console.log(JSON.stringify(view, null, 2));
1522
1668
  return;
1523
1669
  }
1524
- const SPAN = { week: '주', month: '달', year: '해' };
1525
- const span = view.type === 'calendar' ? ` · ${SPAN[view.config?.calendarScale] ?? '달'} 단위` : '';
1526
- console.log(`뷰를 고쳤습니다: ${view.name} (${view.type})${span}`);
1670
+ const SPAN = { week: tr('주', `week`), month: tr('달', `month`), year: tr('해', `year`) };
1671
+ const span = view.type === 'calendar' ? ` · ${SPAN[view.config?.calendarScale] ?? tr('달', `month`)} 단위` : '';
1672
+ console.log(tr(`뷰를 고쳤습니다: ${view.name} (${view.type})${span}`, `View updated: ${view.name} (${view.type})${span}`));
1527
1673
  }
1528
1674
 
1529
1675
 
@@ -1540,14 +1686,14 @@ async function cmdDbView(args) {
1540
1686
  */
1541
1687
  async function cmdDbButtons(args) {
1542
1688
  const target = args._[1];
1543
- if (!target) throw new Error('사용법: rootr db buttons <path|id> [--file buttons.json] [--clear] [--json]');
1689
+ if (!target) throw new Error(usage(`rootr db buttons <path|id> [--file buttons.json] [--clear] [--json]`));
1544
1690
 
1545
1691
  const client = makeClient();
1546
1692
  const id = await resolveDatabaseId(client, target);
1547
1693
 
1548
1694
  if (args.clear) {
1549
1695
  const cleared = await client.setDatabaseButtons(id, []);
1550
- console.log('버튼을 모두 지웠습니다.');
1696
+ console.log(tr('버튼을 모두 지웠습니다.', `All buttons cleared.`));
1551
1697
  if (args.json) console.log(JSON.stringify(cleared, null, 2));
1552
1698
  return;
1553
1699
  }
@@ -1557,11 +1703,11 @@ async function cmdDbButtons(args) {
1557
1703
  try {
1558
1704
  buttons = JSON.parse(raw);
1559
1705
  } catch {
1560
- throw new Error('버튼 파일을 JSON으로 읽을 수 없습니다. [{ "label": "...", "steps": [...] }] 형태여야 합니다.');
1706
+ throw new Error(tr('버튼 파일을 JSON으로 읽을 수 없습니다. [{ "label": "...", "steps": [...] }] 형태여야 합니다.', `Could not read the button file as JSON. It must look like [{ "label": "...", "steps": [...] }].`));
1561
1707
  }
1562
- if (!Array.isArray(buttons)) throw new Error('버튼 파일은 배열이어야 합니다(버튼 목록 전체).');
1708
+ if (!Array.isArray(buttons)) throw new Error(tr('버튼 파일은 배열이어야 합니다(버튼 목록 전체).', `The button file must be an array (the whole button list).`));
1563
1709
  const saved = await client.setDatabaseButtons(id, buttons);
1564
- console.log(`버튼 ${saved.buttons.length}개를 저장했습니다.`);
1710
+ console.log(tr(`버튼 ${saved.buttons.length}개를 저장했습니다.`, `Saved ${saved.buttons.length} buttons.`));
1565
1711
  if (args.json) console.log(JSON.stringify(saved, null, 2));
1566
1712
  return;
1567
1713
  }
@@ -1572,16 +1718,16 @@ async function cmdDbButtons(args) {
1572
1718
  return;
1573
1719
  }
1574
1720
  if (!buttons.length) {
1575
- console.log('버튼이 없습니다.');
1721
+ console.log(tr('버튼이 없습니다.', `No buttons.`));
1576
1722
  return;
1577
1723
  }
1578
1724
  const WHAT = {
1579
- add_row: '행 만들기',
1580
- update_rows: '값 바꾸기',
1581
- ai_fill: 'AI로 칸 채우기',
1582
- open_row: '만든 행 열기',
1725
+ add_row: tr('행 만들기', `create rows`),
1726
+ update_rows: tr('값 바꾸기', `change values`),
1727
+ ai_fill: tr('AI로 칸 채우기', `fill a column with AI`),
1728
+ open_row: tr('만든 행 열기', `open the created row`),
1583
1729
  };
1584
- const WHERE = { created: '방금 만든 행', selected: '고른 행' };
1730
+ const WHERE = { created: tr('방금 만든 행', `the row just created`), selected: tr('고른 행', `the chosen rows`) };
1585
1731
  for (const b of buttons) {
1586
1732
  console.log(`${b.icon ? b.icon + ' ' : ''}${b.label} (${b.id})`);
1587
1733
  for (const [i, st] of (b.steps ?? []).entries()) {
@@ -1589,7 +1735,7 @@ async function cmdDbButtons(args) {
1589
1735
  const where = st.target ? ` · ${WHERE[st.target] ?? st.target}` : '';
1590
1736
  const detail =
1591
1737
  st.kind === 'ai_fill'
1592
- ? ` · ${st.propId} 칸 ← "${st.prompt}"`
1738
+ ? tr(` · ${st.propId} 칸 ← "${st.prompt}"`, ` · column ${st.propId} ← "${st.prompt}"`)
1593
1739
  : st.values
1594
1740
  ? ' · ' + Object.entries(st.values).map(([k, v]) => `${k}=${v}`).join(', ')
1595
1741
  : '';
@@ -1597,7 +1743,7 @@ async function cmdDbButtons(args) {
1597
1743
  }
1598
1744
  }
1599
1745
  if (myLevel && myLevel !== 'MANAGE' && myLevel !== 'FULL') {
1600
- console.log('\n(버튼을 고치려면 이 표의 관리 권한이 필요합니다. 누르는 것은 지금 권한으로도 됩니다.)');
1746
+ console.log(tr('\n(버튼을 고치려면 이 표의 관리 권한이 필요합니다. 누르는 것은 지금 권한으로도 됩니다.)', `\n(Changing buttons needs MANAGE on this table. Pressing them works with your current permission.)`));
1601
1747
  }
1602
1748
  }
1603
1749
 
@@ -1611,7 +1757,7 @@ async function cmdDbButtonRun(args) {
1611
1757
  const target = args._[1];
1612
1758
  const buttonId = args._[2];
1613
1759
  if (!target || !buttonId) {
1614
- throw new Error('사용법: rootr db button-run <path|id> <buttonId> [--rows id1,id2] [--json]');
1760
+ throw new Error(usage(`rootr db button-run <path|id> <buttonId> [--rows id1,id2] [--json]`));
1615
1761
  }
1616
1762
  const client = makeClient();
1617
1763
  const id = await resolveDatabaseId(client, target);
@@ -1628,19 +1774,25 @@ async function cmdDbButtonRun(args) {
1628
1774
  // created 는 **만든 행의 id 목록**이다(개수가 아니다) — 세어서 보여 준다.
1629
1775
  const madeCount = Array.isArray(r.created) ? r.created.length : (r.created ?? 0);
1630
1776
  const parts = [];
1631
- if (madeCount) parts.push(`행 ${madeCount}개 만듦`);
1632
- if (r.updated) parts.push(`행 ${r.updated}개 고침`);
1633
- if (r.filled) parts.push(`AI로 칸 ${r.filled}개 채움`);
1634
- console.log(parts.length ? parts.join(' · ') : '바뀐 것이 없습니다.');
1635
- if (r.openRowId) console.log(`열 행: ${r.openRowId}`);
1777
+ if (madeCount) parts.push(tr(`행 ${madeCount}개 만듦`, `${madeCount} rows created`));
1778
+ if (r.updated) parts.push(tr(`행 ${r.updated}개 고침`, `${r.updated} rows changed`));
1779
+ if (r.filled) parts.push(tr(`AI로 칸 ${r.filled}개 채움`, `${r.filled} columns filled by AI`));
1780
+ console.log(parts.length ? parts.join(' · ') : tr('바뀐 것이 없습니다.', `Nothing changed.`));
1781
+ if (r.openRowId) console.log(tr(`열 행: ${r.openRowId}`, `Row to open: ${r.openRowId}`));
1636
1782
  // AI가 못 채워도 버튼은 계속 돈다 — 조용히 넘어가면 사람이 왜 빈칸인지 모른다.
1637
- if (r.aiFailed) console.log(`AI가 못 채운 칸 ${r.aiFailed}개${r.aiError ? ` — ${r.aiError}` : ''}`);
1783
+ if (r.aiFailed)
1784
+ console.log(
1785
+ tr(
1786
+ `AI가 못 채운 칸 ${r.aiFailed}개${r.aiError ? ` — ${r.aiError}` : ''}`,
1787
+ `${r.aiFailed} columns the AI could not fill${r.aiError ? ` — ${r.aiError}` : ''}`,
1788
+ ),
1789
+ );
1638
1790
  }
1639
1791
 
1640
1792
 
1641
1793
  async function cmdDbRows(args) {
1642
1794
  const target = args._[1];
1643
- if (!target) throw new Error('사용법: rootr db rows <path|id> [--body] [--backlinks] [--json]');
1795
+ if (!target) throw new Error(usage(`rootr db rows <path|id> [--body] [--backlinks] [--json]`));
1644
1796
  const client = makeClient();
1645
1797
  const id = await resolveDatabaseId(client, target);
1646
1798
  const rows = await client.listRows(id, { withBody: !!args.body, backlinks: !!args.backlinks });
@@ -1669,13 +1821,13 @@ async function cmdDbRows(args) {
1669
1821
  async function cmdDbRowAdd(args) {
1670
1822
  const target = args._[1];
1671
1823
  if (!target || typeof args.values !== 'string') {
1672
- throw new Error("사용법: rootr db row-add <path|id> --values '{\"제목\":\"...\"}' [--body <md>|--body-file <f>]");
1824
+ throw new Error("사용법: rootr db row-add <path|id> --values tr('{\"제목\":\"...\"}', `{\"Title\":\"...\"}`) [--body <md>|--body-file <f>]");
1673
1825
  }
1674
1826
  let values;
1675
1827
  try {
1676
1828
  values = JSON.parse(args.values);
1677
1829
  } catch {
1678
- throw new Error('--values 는 JSON이어야 합니다. 예: --values \'{"제목":"킥오프"}\'');
1830
+ throw new Error(tr('--values 는 JSON이어야 합니다. 예: --values \'{"제목":"킥오프"}\'', `--values must be JSON. Example: --values '{"Title":"Kickoff"}'`));
1679
1831
  }
1680
1832
  const body =
1681
1833
  typeof args['body-file'] === 'string'
@@ -1686,8 +1838,8 @@ async function cmdDbRowAdd(args) {
1686
1838
  const client = makeClient();
1687
1839
  const id = await resolveDatabaseId(client, target);
1688
1840
  const row = await client.createRow(id, { values, body });
1689
- console.log(`행 추가: ${row.id}`);
1690
- if (row.pageNodeId) console.log(`상세 내용 문서: ${row.pageNodeId}`);
1841
+ console.log(tr(`행 추가: ${row.id}`, `Row added: ${row.id}`));
1842
+ if (row.pageNodeId) console.log(tr(`상세 내용 문서: ${row.pageNodeId}`, `Row body document: ${row.pageNodeId}`));
1691
1843
  }
1692
1844
 
1693
1845
  /** 행의 상세 내용(본문 메모)을 통째로 쓴다. 내용은 인자·--file·표준입력 순으로 받는다. */
@@ -1695,7 +1847,7 @@ async function cmdDbRowBody(args) {
1695
1847
  const target = args._[1];
1696
1848
  const rowId = args._[2];
1697
1849
  if (!target || !rowId) {
1698
- throw new Error('사용법: rootr db row-body <path|id> <rowId> [<본문>] [--file <f>]');
1850
+ throw new Error(usage(tr(`rootr db row-body <path|id> <rowId> [<본문>] [--file <f>]`, `rootr db row-body <path|id> <rowId> [<body>] [--file <f>]`)));
1699
1851
  }
1700
1852
  const body =
1701
1853
  typeof args.file === 'string'
@@ -1703,11 +1855,16 @@ async function cmdDbRowBody(args) {
1703
1855
  : args._[3] !== undefined
1704
1856
  ? String(args._[3])
1705
1857
  : await readStdin();
1706
- if (!body) throw new Error('쓸 내용이 없습니다 (본문 인자, --file, 또는 표준입력 필요).');
1858
+ if (!body) throw new Error(tr('쓸 내용이 없습니다 (본문 인자, --file, 또는 표준입력 필요).', `Nothing to write (needs a body argument, --file, or stdin).`));
1707
1859
  const client = makeClient();
1708
1860
  const id = await resolveDatabaseId(client, target);
1709
1861
  const row = await client.updateRow(id, rowId, { body });
1710
- console.log(`상세 내용 저장: 행 ${rowId}${row.pageNodeId ? ` → 문서 ${row.pageNodeId}` : ''}`);
1862
+ console.log(
1863
+ tr(
1864
+ `상세 내용 저장: 행 ${rowId}${row.pageNodeId ? ` → 문서 ${row.pageNodeId}` : ''}`,
1865
+ `Row body saved: row ${rowId}${row.pageNodeId ? ` → document ${row.pageNodeId}` : ''}`,
1866
+ ),
1867
+ );
1711
1868
  }
1712
1869
 
1713
1870
  /**
@@ -1720,7 +1877,7 @@ async function cmdDbRowPage(args) {
1720
1877
  const rowId = args._[1];
1721
1878
  if (!rowId) {
1722
1879
  throw new Error(
1723
- '사용법: rootr db row-page <rowId> [--type RECORDING | --node <path|id> | --unlink]',
1880
+ usage(`rootr db row-page <rowId> [--type RECORDING | --node <path|id> | --unlink]`),
1724
1881
  );
1725
1882
  }
1726
1883
  const client = makeClient();
@@ -1728,28 +1885,28 @@ async function cmdDbRowPage(args) {
1728
1885
  // ROW-PAGE-LINK — 연결만 끊는다. 문서는 그대로 남는다.
1729
1886
  if (args.unlink) {
1730
1887
  const res = await client.unlinkRowPage(rowId);
1731
- console.log(res.unlinked ? `연결을 끊었습니다(문서 ${res.unlinked}는 그대로 있습니다).` : '붙어 있는 문서가 없습니다.');
1888
+ console.log(res.unlinked ? tr(`연결을 끊었습니다(문서 ${res.unlinked}는 그대로 있습니다).`, `Unlinked (document ${res.unlinked} is still there).`) : tr('붙어 있는 문서가 없습니다.', `No document is attached.`));
1732
1889
  return;
1733
1890
  }
1734
1891
 
1735
1892
  // ROW-PAGE-LINK — 이미 있는 문서를 잇는다. 경로로 줘도 된다.
1736
1893
  if (typeof args.node === 'string') {
1737
1894
  if (typeof args.type === 'string') {
1738
- throw new Error('--type 과 --node 는 같이 못 씁니다. 새로 만들려면 --type, 있는 문서를 이으려면 --node 하나만 주세요.');
1895
+ throw new Error(tr('--type 과 --node 는 같이 못 씁니다. 새로 만들려면 --type, 있는 문서를 이으려면 --node 하나만 주세요.', `--type and --node cannot be used together. Use --type to create one, or --node to attach an existing document.`));
1739
1896
  }
1740
1897
  const resolved = await resolveTarget(client, args.node);
1741
- if (!resolved.existed) throw new Error(`이을 문서를 찾을 수 없습니다: ${args.node}`);
1898
+ if (!resolved.existed) throw new Error(tr(`이을 문서를 찾을 수 없습니다: ${args.node}`, `Could not find the document to attach: ${args.node}`));
1742
1899
  const linked = await client.linkRowPage(rowId, resolved.id);
1743
- console.log(`행 ${rowId}에 이었습니다: ${linked.type ?? ''} ${linked.nodeId}`.trim());
1900
+ console.log(tr(`행 ${rowId}에 이었습니다: ${linked.type ?? ''} ${linked.nodeId}`, `Attached to row ${rowId}: ${linked.type ?? ''} ${linked.nodeId}`).trim());
1744
1901
  return;
1745
1902
  }
1746
1903
 
1747
1904
  const type = typeof args.type === 'string' ? args.type.toUpperCase() : undefined;
1748
1905
  if (type && !ROW_PAGE_TYPES.includes(type)) {
1749
- throw new Error(`--type 은 ${ROW_PAGE_TYPES.join(' · ')} 중 하나여야 합니다.`);
1906
+ throw new Error(tr(`--type 은 ${ROW_PAGE_TYPES.join(' · ')} 중 하나여야 합니다.`, `--type must be one of ${ROW_PAGE_TYPES.join(' · ')}.`));
1750
1907
  }
1751
1908
  const page = await client.createRowPage(rowId, type);
1752
- console.log(`행 ${rowId} 상세: ${page.type ?? ''} ${page.nodeId}`.trim());
1909
+ console.log(tr(`행 ${rowId} 상세: ${page.type ?? ''} ${page.nodeId}`, `Row ${rowId} detail: ${page.type ?? ''} ${page.nodeId}`).trim());
1753
1910
  }
1754
1911
 
1755
1912
  /** `rootr db row-page-type <path|id> <TYPE>` — 새 행의 기본값을 바꾼다. */
@@ -1757,22 +1914,22 @@ async function cmdDbRowPageType(args) {
1757
1914
  const target = args._[1];
1758
1915
  const type = typeof args._[2] === 'string' ? String(args._[2]).toUpperCase() : undefined;
1759
1916
  if (!target || !type) {
1760
- throw new Error(`사용법: rootr db row-page-type <path|id> <${ROW_PAGE_TYPES.join('|')}>`);
1917
+ throw new Error(usage(`rootr db row-page-type <path|id> <${ROW_PAGE_TYPES.join('|')}>`));
1761
1918
  }
1762
1919
  if (!ROW_PAGE_TYPES.includes(type)) {
1763
- throw new Error(`타입은 ${ROW_PAGE_TYPES.join(' · ')} 중 하나여야 합니다.`);
1920
+ throw new Error(tr(`타입은 ${ROW_PAGE_TYPES.join(' · ')} 중 하나여야 합니다.`, `The type must be one of ${ROW_PAGE_TYPES.join(' · ')}.`));
1764
1921
  }
1765
1922
  const client = makeClient();
1766
1923
  const id = await resolveDatabaseId(client, target);
1767
1924
  await client.setDatabaseRowPageType(id, type);
1768
- console.log(`새 행 기본값: ${type} — 이미 만들어진 행은 그대로입니다.`);
1925
+ console.log(tr(`새 행 기본값: ${type} — 이미 만들어진 행은 그대로입니다.`, `Default for new rows: ${type} — existing rows are untouched.`));
1769
1926
  }
1770
1927
 
1771
1928
  /** 경로로 줘도 되게 — 표 인자도 문서 도구들과 같은 규칙을 따른다. */
1772
1929
  async function resolveDatabaseId(client, target) {
1773
1930
  if (typeof target === 'string' && target.startsWith('/')) {
1774
1931
  const resolved = await resolveTarget(client, target);
1775
- if (!resolved.existed) throw new Error(`표를 찾을 수 없습니다: ${target}`);
1932
+ if (!resolved.existed) throw new Error(tr(`표를 찾을 수 없습니다: ${target}`, `Table not found: ${target}`));
1776
1933
  return resolved.id;
1777
1934
  }
1778
1935
  return target;
@@ -1786,12 +1943,12 @@ async function resolveDatabaseId(client, target) {
1786
1943
  // 읽은 값을 `--code`로 넘긴다.
1787
1944
 
1788
1945
  const SIGN_USAGE =
1789
- '사용법: rootr sign new|show|line|code|submit|approve|reject|reopen|revise|verify|list|bulk|ledger …\n' +
1790
- ' (자세한 건 rootr --help)';
1946
+ usage(`rootr sign new|show|line|code|submit|approve|reject|reopen|revise|verify|list|bulk|ledger …\n`) +
1947
+ tr(' (자세한 건 rootr --help)', ` (see rootr --help)`);
1791
1948
 
1792
1949
  function signWorkspace(client, args) {
1793
1950
  const ws = args.ws || client.workspace;
1794
- if (!ws) throw new Error('워크스페이스를 알 수 없습니다. --ws 를 주거나 rootr use 로 정하세요.');
1951
+ if (!ws) throw new Error(tr('워크스페이스를 알 수 없습니다. --ws 를 주거나 rootr use 로 정하세요.', `No workspace. Pass --ws, or pick one with rootr use.`));
1795
1952
  return ws;
1796
1953
  }
1797
1954
 
@@ -1804,49 +1961,60 @@ async function resolveSignedId(client, target) {
1804
1961
  function requireCode(args, what) {
1805
1962
  if (typeof args.code !== 'string' || !args.code.trim()) {
1806
1963
  throw new Error(
1807
- `${what}에는 확인 코드가 필요합니다: 먼저 rootr sign code <path|id> 로 메일을 받고 --code <6자리> 로 넘기세요.`,
1964
+ tr(`${what}에는 확인 코드가 필요합니다: 먼저 rootr sign code <path|id> 로 메일을 받고 --code <6자리> 로 넘기세요.`, `${what} needs a confirmation code: run rootr sign code <path|id> to get one by email, then pass --code <6 digits>.`),
1808
1965
  );
1809
1966
  }
1810
1967
  return args.code.trim();
1811
1968
  }
1812
1969
 
1813
1970
  const SIGN_STATUS_KO = {
1814
- DRAFT: '작성 중',
1815
- IN_PROGRESS: '서명 진행 중',
1816
- COMPLETED: '완료',
1817
- REJECTED: '반려됨',
1818
- SUPERSEDED: '개정되어 대체됨',
1819
- VOIDED: '무효',
1971
+ DRAFT: tr('작성 중', `draft`),
1972
+ IN_PROGRESS: tr('서명 진행 중', `signing in progress`),
1973
+ COMPLETED: tr('완료', `complete`),
1974
+ REJECTED: tr('반려됨', `rejected`),
1975
+ SUPERSEDED: tr('개정되어 대체됨', `superseded by a revision`),
1976
+ VOIDED: tr('무효', `void`),
1820
1977
  };
1821
1978
 
1822
1979
  function printSignedState(st) {
1823
- console.log(`${st.name} [${SIGN_STATUS_KO[st.status] ?? st.status}]${st.mode === 'RESEARCH_NOTE' ? ' (연구노트)' : ''}`);
1824
- if (st.revisionNo) console.log(`개정 ${st.revisionNo}판${st.revisionNote ? ` — ${st.revisionNote}` : ''}`);
1825
- if (st.latestRevisionId) console.log(`※ 더 최신 판이 있습니다: ${st.latestRevisionId} (${st.latestRevisionNo}판)`);
1826
- console.log(st.lockedAt ? `잠김: ${st.lockedAt} (본문·결재라인 수정 불가)` : '잠기지 않음 (아직 고칠 수 있음)');
1827
- if (!st.contentIntact) console.log('⚠️ 본문이 서명 당시와 다릅니다 — rootr sign verify 로 확인하세요.');
1980
+ console.log(tr(`${st.name} [${SIGN_STATUS_KO[st.status] ?? st.status}]${st.mode === 'RESEARCH_NOTE' ? ' (연구노트)' : ''}`, `${st.name} [${SIGN_STATUS_KO[st.status] ?? st.status}]${st.mode === 'RESEARCH_NOTE' ? ' (research note)' : ''}`));
1981
+ if (st.revisionNo)
1982
+ console.log(
1983
+ tr(
1984
+ `개정 ${st.revisionNo}판${st.revisionNote ? ` — ${st.revisionNote}` : ''}`,
1985
+ `revision ${st.revisionNo}${st.revisionNote ? ` — ${st.revisionNote}` : ''}`,
1986
+ ),
1987
+ );
1988
+ if (st.latestRevisionId) console.log(tr(`※ 더 최신 판이 있습니다: ${st.latestRevisionId} (${st.latestRevisionNo}판)`, `※ A newer revision exists: ${st.latestRevisionId} (rev ${st.latestRevisionNo})`));
1989
+ console.log(st.lockedAt ? tr(`잠김: ${st.lockedAt} (본문·결재라인 수정 불가)`, `Frozen: ${st.lockedAt} (body and approval line can no longer change)`) : tr('잠기지 않음 (아직 고칠 수 있음)', `Not frozen (still editable)`));
1990
+ if (!st.contentIntact) console.log(tr('⚠️ 본문이 서명 당시와 다릅니다 — rootr sign verify 로 확인하세요.', `⚠️ The body differs from what was signed — check it with rootr sign verify.`));
1828
1991
  for (const step of st.steps ?? []) {
1829
- const here = step.order === st.currentStepOrder ? ' ← 지금 이 차례' : '';
1830
- const rule = step.rule === 'ANY' ? '아무나 1명' : step.rule === 'N_OF_M' ? `${step.requiredCount}명` : '전원';
1992
+ const here = step.order === st.currentStepOrder ? tr(' ← 지금 이 차례', ` ← your turn`) : '';
1993
+ const rule = step.rule === 'ANY' ? tr('아무나 1명', `any 1 person`) : step.rule === 'N_OF_M' ? tr(`${step.requiredCount}명`, `${step.requiredCount} people`) : tr('전원', `everyone`);
1831
1994
  console.log(`\n${step.order}. ${step.label} (${rule})${here}`);
1832
1995
  for (const m of step.members ?? []) {
1833
1996
  // 메일 결재자(SIGN-002)는 userId가 없다. 누구인지 화면에 보여야 하므로
1834
1997
  // 이름·주소로 대신 적고, 밖의 사람이라는 표시를 붙인다.
1835
- const who = m.userId ?? `${m.displayName ? `${m.displayName} <${m.email}>` : m.email} (메일 서명)`;
1836
- const sent = !m.userId && !m.invitedAt && m.state === 'PENDING' ? ' [요청 메일 아직 안 감]' : '';
1998
+ const who =
1999
+ m.userId ??
2000
+ tr(
2001
+ `${m.displayName ? `${m.displayName} <${m.email}>` : m.email} (메일 서명)`,
2002
+ `${m.displayName ? `${m.displayName} <${m.email}>` : m.email} (signs by email)`,
2003
+ );
2004
+ const sent = !m.userId && !m.invitedAt && m.state === 'PENDING' ? tr(' [요청 메일 아직 안 감]', ` [request email not sent yet]`) : '';
1837
2005
  console.log(` - ${who} ${m.role} ${m.state}${m.actedAt ? ` ${m.actedAt}` : ''}${sent}${m.comment ? ` "${m.comment}"` : ''}`);
1838
2006
  }
1839
2007
  }
1840
2008
  if (st.signatures?.length) {
1841
- console.log('\n서명:');
2009
+ console.log(tr('\n서명:', `\nSignatures:`));
1842
2010
  for (const sig of st.signatures) {
1843
2011
  console.log(
1844
2012
  ` #${sig.seq} ${sig.signerName} <${sig.signerEmail}> ${sig.role} ${sig.signedAt}` +
1845
- (sig.timestampPending ? ' [시점확인 대기중]' : ` [시점확인 ${sig.tsTime} · ${sig.tsaName}]`),
2013
+ (sig.timestampPending ? tr(' [시점확인 대기중]', ` [timestamp pending]`) : tr(` [시점확인 ${sig.tsTime} · ${sig.tsaName}]`, ` [timestamped ${sig.tsTime} · ${sig.tsaName}]`)),
1846
2014
  );
1847
2015
  }
1848
2016
  }
1849
- if (st.canSign) console.log('\n→ 지금 서명할 수 있습니다: rootr sign code <id> 후 rootr sign approve <id> --code <6자리>');
2017
+ if (st.canSign) console.log(tr('\n→ 지금 서명할 수 있습니다: rootr sign code <id> 후 rootr sign approve <id> --code <6자리>', `\n→ You can sign now: rootr sign code <id>, then rootr sign approve <id> --code <6 digits>`));
1850
2018
  }
1851
2019
 
1852
2020
  async function cmdSign(args) {
@@ -1856,7 +2024,7 @@ async function cmdSign(args) {
1856
2024
  switch (sub) {
1857
2025
  case 'new': {
1858
2026
  const target = args._[1];
1859
- if (!target) throw new Error('사용법: rootr sign new <경로> [--file <본문.md>] [--reviewer <userId>] [--line <line.json>]');
2027
+ if (!target) throw new Error(usage(tr(`rootr sign new <경로> [--file <본문.md>] [--reviewer <userId>] [--line <line.json>]`, `rootr sign new <path> [--file <body.md>] [--reviewer <userId>] [--line <line.json>]`)));
1860
2028
  const ws = signWorkspace(client, args);
1861
2029
  const content =
1862
2030
  typeof args.file === 'string'
@@ -1878,9 +2046,9 @@ async function cmdSign(args) {
1878
2046
  if (target.startsWith('/')) body.path = target;
1879
2047
  else body.name = target;
1880
2048
  const st = await client.createSignedDoc(ws, body);
1881
- console.log(`서명문서 생성: ${st.path ?? st.name} (${st.nodeId})`);
2049
+ console.log(tr(`서명문서 생성: ${st.path ?? st.name} (${st.nodeId})`, `Signed document created: ${st.path ?? st.name} (${st.nodeId})`));
1882
2050
  if (!st.steps?.length) {
1883
- console.log('결재라인이 아직 없습니다 — rootr sign line <id> --file <line.json> 으로 짜세요.');
2051
+ console.log(tr('결재라인이 아직 없습니다 — rootr sign line <id> --file <line.json> 으로 짜세요.', `No approval line yet — build one with rootr sign line <id> --file <line.json>.`));
1884
2052
  } else {
1885
2053
  printSignedState(st);
1886
2054
  }
@@ -1894,22 +2062,22 @@ async function cmdSign(args) {
1894
2062
  case 'line': {
1895
2063
  const id = await resolveSignedId(client, args._[1]);
1896
2064
  const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
1897
- if (!raw) throw new Error('결재라인 JSON이 없습니다 (--file 또는 표준입력).');
2065
+ if (!raw) throw new Error(tr('결재라인 JSON이 없습니다 (--file 또는 표준입력).', `No approval-line JSON (use --file or stdin).`));
1898
2066
  const parsed = JSON.parse(raw);
1899
2067
  const steps = Array.isArray(parsed) ? parsed : parsed.steps;
1900
2068
  const st = await client.setApprovalLine(id, steps);
1901
- console.log(`결재라인 ${st.steps.length}단계 저장됨.`);
2069
+ console.log(tr(`결재라인 ${st.steps.length}단계 저장됨.`, `Approval line saved: ${st.steps.length} steps.`));
1902
2070
  return printSignedState(st);
1903
2071
  }
1904
2072
  case 'code': {
1905
2073
  if (args.bulk) {
1906
2074
  const ws = signWorkspace(client, args);
1907
2075
  const out = await client.requestBulkSigningCode(ws);
1908
- console.log(`묶음 서명용 확인 코드를 ${out.sentTo ?? '메일'}로 보냈습니다. 10분 안에 --code 로 넘기세요.`);
2076
+ console.log(tr(`묶음 서명용 확인 코드를 ${out.sentTo ?? '메일'}로 보냈습니다. 10분 안에 --code 로 넘기세요.`, `A confirmation code for bulk signing was sent to ${out.sentTo ?? 'your email'}. Pass it with --code within 10 minutes.`));
1909
2077
  return;
1910
2078
  }
1911
2079
  const out = await client.requestSigningCode(await resolveSignedId(client, args._[1]));
1912
- console.log(`확인 코드를 ${out.sentTo ?? '메일'}로 보냈습니다. 10분 안에 --code 로 넘기세요.`);
2080
+ console.log(tr(`확인 코드를 ${out.sentTo ?? '메일'}로 보냈습니다. 10분 안에 --code 로 넘기세요.`, `A confirmation code was sent to ${out.sentTo ?? 'your email'}. Pass it with --code within 10 minutes.`));
1913
2081
  return;
1914
2082
  }
1915
2083
  case 'submit': {
@@ -1925,47 +2093,47 @@ async function cmdSign(args) {
1925
2093
  } catch (err) {
1926
2094
  if (String(err?.body?.code ?? '') === 'REAUTH_REQUIRED') {
1927
2095
  throw new Error(
1928
- '올리는 분이 1단계 서명자라 첫 서명이 함께 찍힙니다. ' +
1929
- `먼저 "rootr sign code ${args._[1]}" 로 메일을 받고 --code <6자리> 로 다시 넘기세요.`,
2096
+ tr('올리는 분이 1단계 서명자라 첫 서명이 함께 찍힙니다. ', `You are the first signer, so your signature is applied together with the submission. `) +
2097
+ tr(`먼저 "rootr sign code ${args._[1]}" 로 메일을 받고 --code <6자리> 로 다시 넘기세요.`, `Run "rootr sign code ${args._[1]}" first to get the email, then pass --code <6 digits>.`),
1930
2098
  );
1931
2099
  }
1932
2100
  throw err;
1933
2101
  }
1934
2102
  console.log(
1935
2103
  code
1936
- ? '올렸습니다 — 본문이 얼었고 기안자 서명이 찍혔습니다.'
1937
- : '올렸습니다 — 본문이 얼었고 첫 단계 서명자에게 차례가 넘어갔습니다.',
2104
+ ? tr('올렸습니다 — 본문이 얼었고 기안자 서명이 찍혔습니다.', `Submitted — the body is frozen and the author's signature is on it.`)
2105
+ : tr('올렸습니다 — 본문이 얼었고 첫 단계 서명자에게 차례가 넘어갔습니다.', `Submitted — the body is frozen and it is now the first signer's turn.`),
1938
2106
  );
1939
2107
  return printSignedState(st);
1940
2108
  }
1941
2109
  case 'approve':
1942
2110
  case 'sign': {
1943
2111
  const id = await resolveSignedId(client, args._[1]);
1944
- const st = await client.signSignedDoc(id, { code: requireCode(args, '서명') });
1945
- console.log('서명했습니다.');
2112
+ const st = await client.signSignedDoc(id, { code: requireCode(args, tr('서명', `signing`)) });
2113
+ console.log(tr('서명했습니다.', `Signed.`));
1946
2114
  return printSignedState(st);
1947
2115
  }
1948
2116
  case 'reject': {
1949
2117
  const id = await resolveSignedId(client, args._[1]);
1950
2118
  if (typeof args.comment !== 'string' || !args.comment.trim()) {
1951
- throw new Error('반려에는 사유가 필요합니다: --comment "근거 자료가 빠졌습니다"');
2119
+ throw new Error(tr('반려에는 사유가 필요합니다: --comment "근거 자료가 빠졌습니다"', `Rejecting needs a reason: --comment "supporting data is missing"`));
1952
2120
  }
1953
2121
  const st = await client.rejectSignedDoc(id, { comment: args.comment, code: args.code });
1954
- console.log('반려했습니다 — 작성자가 다시 고칠 수 있습니다.');
2122
+ console.log(tr('반려했습니다 — 작성자가 다시 고칠 수 있습니다.', `Rejected — the author can revise it.`));
1955
2123
  return printSignedState(st);
1956
2124
  }
1957
2125
  case 'reopen': {
1958
2126
  const st = await client.reopenSignedDoc(await resolveSignedId(client, args._[1]));
1959
- console.log('다시 열었습니다 — 본문과 결재라인을 고칠 수 있습니다.');
2127
+ console.log(tr('다시 열었습니다 — 본문과 결재라인을 고칠 수 있습니다.', `Reopened — the body and approval line can be changed again.`));
1960
2128
  return printSignedState(st);
1961
2129
  }
1962
2130
  case 'revise': {
1963
2131
  const id = await resolveSignedId(client, args._[1]);
1964
2132
  if (typeof args.note !== 'string' || !args.note.trim()) {
1965
- throw new Error('개정에는 사유가 필요합니다: --note "측정값 단위 정정"');
2133
+ throw new Error(tr('개정에는 사유가 필요합니다: --note "측정값 단위 정정"', `A revision needs a reason: --note "corrected the measurement unit"`));
1966
2134
  }
1967
2135
  const out = await client.reviseSignedDoc(id, args.note);
1968
- console.log(`개정본을 만들었습니다: ${out.nodeId} (${out.revisionNo}판). 원본은 그대로 남습니다.`);
2136
+ console.log(tr(`개정본을 만들었습니다: ${out.nodeId} (${out.revisionNo}판). 원본은 그대로 남습니다.`, `Revision created: ${out.nodeId} (rev ${out.revisionNo}). The original stays.`));
1969
2137
  return printSignedState(await client.getSignedDoc(out.nodeId));
1970
2138
  }
1971
2139
  case 'verify': {
@@ -1973,15 +2141,20 @@ async function cmdSign(args) {
1973
2141
  if (args.json) return console.log(JSON.stringify(v, null, 2));
1974
2142
  const mark = (ok) => (ok ? '✅' : '❌');
1975
2143
  console.log(`${v.name} [${SIGN_STATUS_KO[v.status] ?? v.status}]`);
1976
- console.log(`${mark(v.contentIntact)} 본문이 서명 당시 그대로다`);
1977
- console.log(`${mark(v.allSignaturesValid)} 서명이 전부 유효하고 체인이 이어진다`);
1978
- console.log(`${mark(v.allTimestamped)} 서명마다 공인시점확인이 붙어 있다`);
1979
- console.log(`${mark(v.ledgerIntact)} 워크스페이스 원장이 성하다${v.ledgerBrokenAtSeq ? ` (끊긴 지점 #${v.ledgerBrokenAtSeq})` : ''}`);
2144
+ console.log(tr(`${mark(v.contentIntact)} 본문이 서명 당시 그대로다`, `${mark(v.contentIntact)} the body is exactly what was signed`));
2145
+ console.log(tr(`${mark(v.allSignaturesValid)} 서명이 전부 유효하고 체인이 이어진다`, `${mark(v.allSignaturesValid)} every signature is valid and the chain is unbroken`));
2146
+ console.log(tr(`${mark(v.allTimestamped)} 서명마다 공인시점확인이 붙어 있다`, `${mark(v.allTimestamped)} every signature carries a trusted timestamp`));
2147
+ console.log(
2148
+ tr(
2149
+ `${mark(v.ledgerIntact)} 워크스페이스 원장이 성하다${v.ledgerBrokenAtSeq ? ` (끊긴 지점 #${v.ledgerBrokenAtSeq})` : ''}`,
2150
+ `${mark(v.ledgerIntact)} the workspace ledger is intact${v.ledgerBrokenAtSeq ? ` (breaks at #${v.ledgerBrokenAtSeq})` : ''}`,
2151
+ ),
2152
+ );
1980
2153
  for (const sig of v.signatures ?? []) {
1981
2154
  console.log(
1982
2155
  ` #${sig.seq} ${sig.signerName} ${sig.role} ${sig.signedAt} ` +
1983
- `${mark(sig.signatureValid)}서명 ${mark(sig.chainLinkValid)}체인 ${mark(sig.matchesCurrentContent)}현재본문` +
1984
- (sig.timestampPending ? ' [시점확인 대기중]' : ''),
2156
+ tr(`${mark(sig.signatureValid)}서명 ${mark(sig.chainLinkValid)}체인 ${mark(sig.matchesCurrentContent)}현재본문`, `${mark(sig.signatureValid)}signature ${mark(sig.chainLinkValid)}chain ${mark(sig.matchesCurrentContent)}current body`) +
2157
+ (sig.timestampPending ? tr(' [시점확인 대기중]', ` [timestamp pending]`) : ''),
1985
2158
  );
1986
2159
  }
1987
2160
  return;
@@ -1994,18 +2167,18 @@ async function cmdSign(args) {
1994
2167
  pendingMine: !!args.pending,
1995
2168
  });
1996
2169
  if (args.json) return console.log(JSON.stringify(rows, null, 2));
1997
- if (!rows.length) return console.log(args.pending ? '내 차례인 서명문서가 없습니다.' : '서명문서가 없습니다.');
2170
+ if (!rows.length) return console.log(args.pending ? tr('내 차례인 서명문서가 없습니다.', `No signed documents are waiting on you.`) : tr('서명문서가 없습니다.', `No signed documents.`));
1998
2171
  for (const r of rows) {
1999
- const waiting = r.waitingOn?.length ? ` 대기: ${r.waitingOn.join(', ')}` : '';
2000
- console.log(`${r.nodeId}\t${SIGN_STATUS_KO[r.status] ?? r.status}\t${r.path}${waiting}${r.canSign ? ' ← 내 차례' : ''}`);
2172
+ const waiting = r.waitingOn?.length ? tr(` 대기: ${r.waitingOn.join(', ')}`, ` waiting on: ${r.waitingOn.join(', ')}`) : '';
2173
+ console.log(tr(`${r.nodeId}\t${SIGN_STATUS_KO[r.status] ?? r.status}\t${r.path}${waiting}${r.canSign ? ' ← 내 차례' : ''}`, `${r.nodeId}\t${SIGN_STATUS_KO[r.status] ?? r.status}\t${r.path}${waiting}${r.canSign ? ' ← your turn' : ''}`));
2001
2174
  }
2002
2175
  return;
2003
2176
  }
2004
2177
  case 'bulk': {
2005
2178
  const ws = signWorkspace(client, args);
2006
2179
  const targets = args._.slice(1);
2007
- if (!targets.length) throw new Error('사용법: rootr sign bulk <id|경로 …> --code <6자리>');
2008
- const code = requireCode(args, '묶음 서명');
2180
+ if (!targets.length) throw new Error(usage(tr(`rootr sign bulk <id|경로 …> --code <6자리>`, `rootr sign bulk <id|path …> --code <6 digits>`)));
2181
+ const code = requireCode(args, tr('묶음 서명', `bulk signing`));
2009
2182
  const nodeIds = [];
2010
2183
  for (const t of targets) nodeIds.push(await resolveSignedId(client, t));
2011
2184
  const out = await client.bulkSignSignedDocs(ws, { nodeIds, code });
@@ -2015,20 +2188,28 @@ async function cmdSign(args) {
2015
2188
  // 얼리는 것은 되돌릴 수 없으니 "몇 건"이 아니라 "무엇을 했는지"를 보여준다.
2016
2189
  const n = (kind) => results.filter((r) => r.did === kind).length;
2017
2190
  const parts = [];
2018
- if (n('submitted+signed')) parts.push(`올리고 서명 ${n('submitted+signed')}건`);
2019
- if (n('submitted')) parts.push(`올리기만 ${n('submitted')}건`);
2020
- if (n('signed')) parts.push(`서명 ${n('signed')}건`);
2021
- console.log(`${parts.join(', ') || '처리 0건'}${failed.length ? `, 건너뜀 ${failed.length}건` : ''}`);
2022
- for (const f of failed) console.log(` 건너뜀 ${f.nodeId}: ${f.error}`);
2191
+ if (n('submitted+signed')) parts.push(tr(`올리고 서명 ${n('submitted+signed')}건`, `${n('submitted+signed')} submitted and signed`));
2192
+ if (n('submitted')) parts.push(tr(`올리기만 ${n('submitted')}건`, `${n('submitted')} submitted only`));
2193
+ if (n('signed')) parts.push(tr(`서명 ${n('signed')}건`, `${n('signed')} signed`));
2194
+ console.log(
2195
+ tr(
2196
+ `${parts.join(', ') || '처리 0건'}${failed.length ? `, 건너뜀 ${failed.length}건` : ''}`,
2197
+ `${parts.join(', ') || 'nothing processed'}${failed.length ? `, ${failed.length} skipped` : ''}`,
2198
+ ),
2199
+ );
2200
+ for (const f of failed) console.log(tr(` 건너뜀 ${f.nodeId}: ${f.error}`, ` skipped ${f.nodeId}: ${f.error}`));
2023
2201
  return;
2024
2202
  }
2025
2203
  case 'ledger': {
2026
2204
  const ws = signWorkspace(client, args);
2027
2205
  const v = await client.verifyLedger(ws);
2028
2206
  if (args.json) return console.log(JSON.stringify(v, null, 2));
2029
- console.log(`${v.ok ? '✅ 원장이 성합니다' : '❌ 원장이 끊겼습니다'} — 서명 ${v.count}건`);
2030
- if (v.headHash) console.log(`머리 해시: ${v.headHash}`);
2031
- if (!v.ok) console.log(`끊긴 지점: #${v.brokenAtSeq}${v.reason ? ` (${v.reason})` : ''}`);
2207
+ console.log(tr(`${v.ok ? '✅ 원장이 성합니다' : '❌ 원장이 끊겼습니다'} — 서명 ${v.count}건`, `${v.ok ? '✅ the ledger is intact' : '❌ the ledger is broken'} — ${v.count} signatures`));
2208
+ if (v.headHash) console.log(tr(`머리 해시: ${v.headHash}`, `Head hash: ${v.headHash}`));
2209
+ if (!v.ok)
2210
+ console.log(
2211
+ tr(`끊긴 지점: #${v.brokenAtSeq}${v.reason ? ` (${v.reason})` : ''}`, `Breaks at: #${v.brokenAtSeq}${v.reason ? ` (${v.reason})` : ''}`),
2212
+ );
2032
2213
  return;
2033
2214
  }
2034
2215
  default:
@@ -2102,7 +2283,7 @@ async function main() {
2102
2283
  case 'mcp':
2103
2284
  return cmdMcp(args);
2104
2285
  default:
2105
- console.error(`알 수 없는 명령: ${cmd}\n`);
2286
+ console.error(tr(`알 수 없는 명령: ${cmd}\n`, `Unknown command: ${cmd}\n`));
2106
2287
  console.log(USAGE);
2107
2288
  process.exit(1);
2108
2289
  }