rootr-cli 0.17.1 → 0.17.3

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