@bifos/dooray-cli 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,15 @@ dooray setup
32
32
  dooray doctor # 설정이 제대로 됐는지 확인
33
33
  ```
34
34
 
35
+ 개별 값만 바꾸려면 `dooray config set` 을 쓴다. 값 자리에 `-` 를 주면 stdin 에서 읽는다.
36
+
37
+ ```bash
38
+ printf '%s' "$TOKEN" | dooray config set api-key -
39
+ ```
40
+
41
+ 토큰을 명령 인자로 넘기면 셸 기록과 프로세스 목록에 남는다. 에이전트가 대신 실행하면 실행 로그에도 남는다.
42
+ stdin 으로 받은 값은 양끝 공백을 지운 뒤 저장하고, 비어 있으면 저장하지 않고 종료 코드 3 으로 끝낸다.
43
+
35
44
  에이전트에서 쓰려면 스킬을 설치한다. Claude Code 가 이 CLI 의 사용법을 알게 된다.
36
45
 
37
46
  ```bash
@@ -41,6 +50,41 @@ dooray skill status
41
50
 
42
51
  CLI 를 새 버전으로 올린 뒤에는 `dooray skill update` 를 실행해야 스킬도 갱신된다.
43
52
 
53
+ ## Dooray 문체 페르소나
54
+
55
+ `dooray-persona` 스킬은 Dooray에 쌓인 본인 업무 글과 댓글을 모아 개인 업무 문체 문서를 만든다.
56
+ 완성한 문서를 AI 에이전트의 규칙으로 연결하면 업무와 댓글 초안을 본인 문체에 맞춰 작성할 수 있다.
57
+
58
+ 이 스킬은 사용자 글을 로컬에서 분석하는 별도 워크플로우이므로 `dooray skill install`의 설치 대상이 아니다.
59
+ 저장소를 내려받은 뒤 스킬 디렉터리를 `~/.claude/skills/` 아래에 링크하거나 복사한다.
60
+
61
+ ```bash
62
+ git clone https://github.com/jon890/dooray-cli.git
63
+ cd dooray-cli
64
+ mkdir -p ~/.claude/skills
65
+ ln -s "$PWD/skills/dooray-persona" ~/.claude/skills/dooray-persona
66
+ ```
67
+
68
+ 링크 대신 복사해서 사용하려면 마지막 명령을 다음 명령으로 바꾼다.
69
+
70
+ ```bash
71
+ cp -R skills/dooray-persona ~/.claude/skills/
72
+ ```
73
+
74
+ 설정 파일은 `~/.claude/dooray-persona.config.json`이며, 최초 실행에서는 후보 프로젝트를 탐색해 대상을 고른 뒤 본인 글을 수집한다.
75
+ 인증은 `dooray setup`이 만든 `~/.dooray/config.json`을 읽어 사용하므로 토큰을 따로 입력하지 않는다.
76
+
77
+ ### 터미널에 익숙하지 않은 동료에게 넘기기
78
+
79
+ `skills/dooray-persona/references/bootstrap.md`에 붙여넣기용 프롬프트가 있다.
80
+ 그 블록을 복사해 전달하면 받는 사람은 Claude Code에 한 번 붙여넣는 것으로 CLI 설치, 인증 설정, 스킬 연결, 수집, 문서 생성, 주입까지 진행한다.
81
+
82
+ 받는 사람이 직접 해야 하는 것은 둘이다. Claude Code 설치와 Dooray 개인 인증 토큰 발급이다.
83
+ 토큰 발급은 웹 로그인이 필요해 자동화할 수 없고, 프롬프트가 발급 화면 주소까지만 안내한다.
84
+
85
+ Claude 데스크톱 앱은 사용자 컴퓨터의 파일과 명령을 기본 상태로 다루지 못한다.
86
+ 문서는 Claude Code에서 만들고, 완성한 문서를 데스크톱 앱의 프로젝트 지식이나 스타일 설정에 붙여넣어 쓴다.
87
+
44
88
  ## 사용법
45
89
 
46
90
  설정을 마치면 에이전트에게 한국어로 시키면 된다.
package/dist/index.js CHANGED
@@ -157,15 +157,62 @@ async function clearMailCredentials() {
157
157
  return hadCredentials;
158
158
  }
159
159
 
160
+ // src/utils/body-input.ts
161
+ var import_promises2 = require("fs/promises");
162
+ async function readBodyInput(opts) {
163
+ if (opts.body != null && opts.bodyFile != null) {
164
+ throw new DoorayCliError(
165
+ "--body\uC640 --body-file\uC740 \uD568\uAED8 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.",
166
+ EXIT_PARAM_ERROR
167
+ );
168
+ }
169
+ if (opts.bodyFile) {
170
+ if (opts.bodyFile === "-") return readStdin();
171
+ return (0, import_promises2.readFile)(opts.bodyFile, "utf-8");
172
+ }
173
+ if (opts.body === "-") return readStdin();
174
+ return opts.body ?? "";
175
+ }
176
+ async function readBodyInputOrNull(opts) {
177
+ if (opts.body == null && opts.bodyFile == null) return null;
178
+ return readBodyInput(opts);
179
+ }
180
+ async function readStdin() {
181
+ if (process.stdin.isTTY) {
182
+ throw new DoorayCliError(
183
+ "stdin\uC5D0\uC11C \uC77D\uC73C\uB824\uBA74 \uD30C\uC774\uD504\uB85C \uB370\uC774\uD130\uB97C \uC804\uB2EC\uD574\uC8FC\uC138\uC694.",
184
+ EXIT_PARAM_ERROR
185
+ );
186
+ }
187
+ const chunks = [];
188
+ for await (const chunk of process.stdin) {
189
+ chunks.push(chunk);
190
+ }
191
+ return Buffer.concat(chunks).toString("utf-8");
192
+ }
193
+
194
+ // src/utils/config-value.ts
195
+ async function resolveConfigValue(raw, read = readStdin) {
196
+ if (raw !== "-") return raw;
197
+ const value = (await read()).trim();
198
+ if (value === "") {
199
+ throw new DoorayCliError(
200
+ "stdin \uC73C\uB85C \uBC1B\uC740 \uAC12\uC774 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.",
201
+ EXIT_PARAM_ERROR
202
+ );
203
+ }
204
+ return value;
205
+ }
206
+
160
207
  // src/commands/config.ts
161
208
  function maskApiKey(key) {
162
209
  if (key.length <= 8) return "****";
163
210
  return key.slice(0, 4) + "****" + key.slice(-4);
164
211
  }
165
212
  var configCommand = new import_commander.Command("config").description("CLI \uC124\uC815 \uAD00\uB9AC");
166
- configCommand.command("set").description("\uC124\uC815 \uAC12 \uC800\uC7A5").argument("<key>", "\uC124\uC815 \uD0A4 (api-key, base-url)").argument("<value>", "\uC124\uC815 \uAC12").action(async (key, value) => {
213
+ configCommand.command("set").description("\uC124\uC815 \uAC12 \uC800\uC7A5").argument("<key>", "\uC124\uC815 \uD0A4 (api-key, base-url)").argument("<value>", "\uC124\uC815 \uAC12 (`-` \uC774\uBA74 stdin \uC5D0\uC11C \uC77D\uC74C)").action(async (key, value) => {
167
214
  try {
168
- await setConfigValue(key, value);
215
+ await setConfigValue(key, await resolveConfigValue(value));
169
216
  console.log(import_chalk.default.green(`\u2713 ${key} \uC124\uC815 \uC644\uB8CC`));
170
217
  } catch (err) {
171
218
  if (err instanceof DoorayCliError) {
@@ -205,7 +252,7 @@ var import_commander2 = require("commander");
205
252
  var import_chalk2 = __toESM(require("chalk"));
206
253
 
207
254
  // src/cache/store.ts
208
- var import_promises2 = require("fs/promises");
255
+ var import_promises3 = require("fs/promises");
209
256
  var import_node_path2 = require("path");
210
257
  var import_node_os2 = require("os");
211
258
  var CACHE_DIR = (0, import_node_path2.join)((0, import_node_os2.homedir)(), ".dooray", "cache");
@@ -220,11 +267,11 @@ var MEMBER_GROUPS_DIR = (0, import_node_path2.join)(CACHE_DIR, "member-groups");
220
267
  var WIKIS_PATH = (0, import_node_path2.join)(CACHE_DIR, "wikis.json");
221
268
  var TEMPLATES_DIR = (0, import_node_path2.join)(CACHE_DIR, "templates");
222
269
  async function ensureDir2(dir) {
223
- await (0, import_promises2.mkdir)(dir, { recursive: true });
270
+ await (0, import_promises3.mkdir)(dir, { recursive: true });
224
271
  }
225
272
  async function readJson(path6) {
226
273
  try {
227
- const raw = await (0, import_promises2.readFile)(path6, "utf-8");
274
+ const raw = await (0, import_promises3.readFile)(path6, "utf-8");
228
275
  return JSON.parse(raw);
229
276
  } catch {
230
277
  return null;
@@ -233,7 +280,7 @@ async function readJson(path6) {
233
280
  async function writeJson(path6, data) {
234
281
  const dir = (0, import_node_path2.dirname)(path6);
235
282
  await ensureDir2(dir);
236
- await (0, import_promises2.writeFile)(path6, JSON.stringify(data, null, 2) + "\n");
283
+ await (0, import_promises3.writeFile)(path6, JSON.stringify(data, null, 2) + "\n");
237
284
  }
238
285
  function isExpired(updatedAt, ttlMs) {
239
286
  if (!updatedAt) return true;
@@ -322,7 +369,7 @@ async function setWikis(items) {
322
369
  }
323
370
  async function clearCache() {
324
371
  try {
325
- await (0, import_promises2.rm)(CACHE_DIR, { recursive: true, force: true });
372
+ await (0, import_promises3.rm)(CACHE_DIR, { recursive: true, force: true });
326
373
  } catch {
327
374
  }
328
375
  }
@@ -331,31 +378,31 @@ async function getCacheStats() {
331
378
  const projectCount = projects?.data.length ?? 0;
332
379
  let memberProjectCount = 0;
333
380
  try {
334
- const files = await (0, import_promises2.readdir)(MEMBERS_DIR);
381
+ const files = await (0, import_promises3.readdir)(MEMBERS_DIR);
335
382
  memberProjectCount = files.filter((f) => f.endsWith(".json")).length;
336
383
  } catch {
337
384
  }
338
385
  let workflowProjectCount = 0;
339
386
  try {
340
- const files = await (0, import_promises2.readdir)(WORKFLOWS_DIR);
387
+ const files = await (0, import_promises3.readdir)(WORKFLOWS_DIR);
341
388
  workflowProjectCount = files.filter((f) => f.endsWith(".json")).length;
342
389
  } catch {
343
390
  }
344
391
  let tagProjectCount = 0;
345
392
  try {
346
- const files = await (0, import_promises2.readdir)(TAGS_DIR);
393
+ const files = await (0, import_promises3.readdir)(TAGS_DIR);
347
394
  tagProjectCount = files.filter((f) => f.endsWith(".json")).length;
348
395
  } catch {
349
396
  }
350
397
  let milestoneProjectCount = 0;
351
398
  try {
352
- const files = await (0, import_promises2.readdir)(MILESTONES_DIR);
399
+ const files = await (0, import_promises3.readdir)(MILESTONES_DIR);
353
400
  milestoneProjectCount = files.filter((f) => f.endsWith(".json")).length;
354
401
  } catch {
355
402
  }
356
403
  let memberGroupProjectCount = 0;
357
404
  try {
358
- const files = await (0, import_promises2.readdir)(MEMBER_GROUPS_DIR);
405
+ const files = await (0, import_promises3.readdir)(MEMBER_GROUPS_DIR);
359
406
  memberGroupProjectCount = files.filter((f) => f.endsWith(".json")).length;
360
407
  } catch {
361
408
  }
@@ -378,14 +425,14 @@ cacheCommand.command("refresh").description("\uCE90\uC2DC \uAC31\uC2E0 (API \uD0
378
425
  var import_commander3 = require("commander");
379
426
  var import_chalk3 = __toESM(require("chalk"));
380
427
  var import_path = __toESM(require("path"));
381
- var import_promises4 = __toESM(require("fs/promises"));
428
+ var import_promises5 = __toESM(require("fs/promises"));
382
429
 
383
430
  // src/skill/context.ts
384
431
  var import_node_path3 = __toESM(require("path"));
385
432
  var import_node_os3 = require("os");
386
433
 
387
434
  // src/version.ts
388
- var CLI_VERSION = true ? "0.17.0" : "0.0.0-dev";
435
+ var CLI_VERSION = true ? "0.18.0" : "0.0.0-dev";
389
436
 
390
437
  // src/skill/context.ts
391
438
  function resolveSkillDataRoot(homeDir, xdgDataHome = process.env.XDG_DATA_HOME) {
@@ -925,7 +972,7 @@ async function installSkill(context, options = {}) {
925
972
 
926
973
  // src/api/client.ts
927
974
  var import_ky = __toESM(require("ky"));
928
- var import_promises3 = require("fs/promises");
975
+ var import_promises4 = require("fs/promises");
929
976
  var import_node_path6 = require("path");
930
977
 
931
978
  // src/utils/dooray-message.ts
@@ -937,6 +984,87 @@ function normalizeDoorayMessage(raw) {
937
984
  }
938
985
  }
939
986
 
987
+ // src/api/rate-limiter.ts
988
+ var DEFAULT_BURST_CAPACITY = 20;
989
+ var DEFAULT_REPLENISH_RATE = 5;
990
+ function defaultSleep(ms) {
991
+ return new Promise((resolve) => setTimeout(resolve, ms));
992
+ }
993
+ function parseRateLimitHeaders(headers) {
994
+ const read = (name) => {
995
+ const raw = headers.get(name);
996
+ if (raw == null) return void 0;
997
+ const value = Number(raw);
998
+ return Number.isFinite(value) && value >= 0 ? value : void 0;
999
+ };
1000
+ return {
1001
+ remaining: read("x-ratelimit-remaining"),
1002
+ burstCapacity: read("x-ratelimit-burst-capacity"),
1003
+ replenishRate: read("x-ratelimit-replenish-rate")
1004
+ };
1005
+ }
1006
+ function createTokenBucket(options = {}) {
1007
+ const now2 = options.now ?? (() => Date.now());
1008
+ const sleep = options.sleep ?? defaultSleep;
1009
+ const positiveOr = (value, fallback) => value != null && value > 0 ? value : fallback;
1010
+ let capacity = positiveOr(options.capacity, DEFAULT_BURST_CAPACITY);
1011
+ let replenishRate = positiveOr(options.replenishRate, DEFAULT_REPLENISH_RATE);
1012
+ let tokens = capacity;
1013
+ let updatedAt = now2();
1014
+ let queue = Promise.resolve();
1015
+ function projected(at) {
1016
+ const elapsed = Math.max(0, at - updatedAt);
1017
+ return Math.min(capacity, tokens + elapsed / 1e3 * replenishRate);
1018
+ }
1019
+ function refill() {
1020
+ const at = now2();
1021
+ if (at <= updatedAt) return;
1022
+ tokens = projected(at);
1023
+ updatedAt = at;
1024
+ }
1025
+ async function take() {
1026
+ for (; ; ) {
1027
+ refill();
1028
+ if (tokens >= 1) {
1029
+ tokens -= 1;
1030
+ return;
1031
+ }
1032
+ const waitMs = Math.max((1 - tokens) / replenishRate * 1e3, 10);
1033
+ await sleep(waitMs);
1034
+ }
1035
+ }
1036
+ return {
1037
+ acquire() {
1038
+ const next = queue.then(take);
1039
+ queue = next.then(
1040
+ () => void 0,
1041
+ () => void 0
1042
+ );
1043
+ return next;
1044
+ },
1045
+ sync(snapshot) {
1046
+ if (snapshot.burstCapacity != null && snapshot.burstCapacity > 0) {
1047
+ capacity = snapshot.burstCapacity;
1048
+ }
1049
+ if (snapshot.replenishRate != null && snapshot.replenishRate > 0) {
1050
+ replenishRate = snapshot.replenishRate;
1051
+ }
1052
+ if (snapshot.remaining != null) {
1053
+ refill();
1054
+ tokens = Math.min(tokens, Math.min(snapshot.remaining, capacity));
1055
+ updatedAt = now2();
1056
+ }
1057
+ },
1058
+ drain() {
1059
+ tokens = 0;
1060
+ updatedAt = now2();
1061
+ },
1062
+ get available() {
1063
+ return projected(now2());
1064
+ }
1065
+ };
1066
+ }
1067
+
940
1068
  // src/api/client.ts
941
1069
  function joinIds(ids) {
942
1070
  return ids && ids.length > 0 ? ids.join(",") : void 0;
@@ -968,10 +1096,42 @@ var DoorayApiClient = class {
968
1096
  constructor(apiKey, baseUrl) {
969
1097
  this.authHeader = `dooray-api ${apiKey}`;
970
1098
  this.baseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
1099
+ const bucket = createTokenBucket();
971
1100
  this.api = import_ky.default.create({
972
1101
  prefix: baseUrl,
973
1102
  headers: {
974
1103
  Authorization: this.authHeader
1104
+ },
1105
+ // 429 는 재시도할 가치가 있지만, 동시 요청이 한꺼번에 재시도하면 같은 결과가 반복된다.
1106
+ // jitter 로 재시도 시점을 흩고, 아래 훅이 재시도분도 버킷을 거치게 한다.
1107
+ retry: {
1108
+ limit: 3,
1109
+ statusCodes: [408, 413, 429, 500, 502, 503, 504],
1110
+ backoffLimit: 8e3,
1111
+ jitter: true
1112
+ },
1113
+ hooks: {
1114
+ // 훅 순서는 실측했다 — beforeRequest → afterResponse → beforeRetry → afterResponse.
1115
+ // beforeRequest 는 첫 요청에만 돌고 재시도에는 돌지 않으므로,
1116
+ // beforeRetry 에서도 토큰을 받아야 재시도가 버킷을 우회하지 않는다.
1117
+ beforeRequest: [
1118
+ async () => {
1119
+ await bucket.acquire();
1120
+ }
1121
+ ],
1122
+ beforeRetry: [
1123
+ async ({ error }) => {
1124
+ if (error instanceof import_ky.HTTPError && error.response.status === 429) {
1125
+ bucket.drain();
1126
+ }
1127
+ await bucket.acquire();
1128
+ }
1129
+ ],
1130
+ afterResponse: [
1131
+ ({ response }) => {
1132
+ bucket.sync(parseRateLimitHeaders(response.headers));
1133
+ }
1134
+ ]
975
1135
  }
976
1136
  });
977
1137
  }
@@ -1345,7 +1505,7 @@ var DoorayApiClient = class {
1345
1505
  async uploadPostFile(projectId, postId, filePath) {
1346
1506
  try {
1347
1507
  const fileName = (0, import_node_path6.basename)(filePath);
1348
- const fileBuffer = await (0, import_promises3.readFile)(filePath);
1508
+ const fileBuffer = await (0, import_promises4.readFile)(filePath);
1349
1509
  const formData = new FormData();
1350
1510
  formData.append("file", new Blob([fileBuffer]), fileName);
1351
1511
  const url = `${this.baseUrl}project/v1/projects/${projectId}/posts/${postId}/files`;
@@ -1390,7 +1550,7 @@ var DoorayApiClient = class {
1390
1550
  async uploadWikiPageFile(wikiId, pageId, filePath, type) {
1391
1551
  try {
1392
1552
  const fileName = (0, import_node_path6.basename)(filePath);
1393
- const fileBuffer = await (0, import_promises3.readFile)(filePath);
1553
+ const fileBuffer = await (0, import_promises4.readFile)(filePath);
1394
1554
  const buildFormData = () => {
1395
1555
  const fd = new FormData();
1396
1556
  fd.append("type", type);
@@ -1651,7 +1811,7 @@ var doctorCommand = new import_commander3.Command("doctor").description("\uC124\
1651
1811
  process.env.HOME ?? process.env.USERPROFILE ?? "",
1652
1812
  ".claude"
1653
1813
  );
1654
- const claudeDirExists = await import_promises4.default.access(claudeDir).then(() => true).catch(() => false);
1814
+ const claudeDirExists = await import_promises5.default.access(claudeDir).then(() => true).catch(() => false);
1655
1815
  if (claudeDirExists) {
1656
1816
  console.log(import_chalk3.default.bold("\n\u{1F527} Claude Code \uC2A4\uD0AC\n"));
1657
1817
  const skillStatus = await inspectSkill(createSkillManagerContext());
@@ -1673,7 +1833,7 @@ var doctorCommand = new import_commander3.Command("doctor").description("\uC124\
1673
1833
  var import_commander4 = require("commander");
1674
1834
  var import_chalk4 = __toESM(require("chalk"));
1675
1835
  var import_path2 = __toESM(require("path"));
1676
- var import_promises5 = __toESM(require("fs/promises"));
1836
+ var import_promises6 = __toESM(require("fs/promises"));
1677
1837
  var setupCommand = new import_commander4.Command("setup").description("\uB300\uD654\uD615 \uCD08\uAE30 \uC124\uC815 \uB9C8\uBC95\uC0AC").action(async () => {
1678
1838
  const { input: input2, select, password, confirm: confirm2 } = await import("@inquirer/prompts");
1679
1839
  const existing = await getConfig();
@@ -1750,7 +1910,7 @@ var setupCommand = new import_commander4.Command("setup").description("\uB300\uD
1750
1910
  process.env.HOME ?? process.env.USERPROFILE ?? "",
1751
1911
  ".claude"
1752
1912
  );
1753
- const claudeDirExists = await import_promises5.default.access(claudeDir).then(() => true).catch(() => false);
1913
+ const claudeDirExists = await import_promises6.default.access(claudeDir).then(() => true).catch(() => false);
1754
1914
  if (claudeDirExists) {
1755
1915
  const isNpx = /_npx[/\\]/.test(__dirname) || /\.npm[/\\]_npx/.test(__dirname) || /npx-/.test(__dirname);
1756
1916
  if (isNpx) {
@@ -2992,7 +3152,7 @@ async function resolvePostRef(client, ref) {
2992
3152
 
2993
3153
  // src/editor/index.ts
2994
3154
  var import_node_child_process = require("child_process");
2995
- var import_promises6 = __toESM(require("fs/promises"));
3155
+ var import_promises7 = __toESM(require("fs/promises"));
2996
3156
  var import_tmp = __toESM(require("tmp"));
2997
3157
  var import_js_yaml = require("js-yaml");
2998
3158
  function openInEditor(content) {
@@ -3006,7 +3166,7 @@ function openInEditor(content) {
3006
3166
  const tmpFile = import_tmp.default.fileSync({ prefix: "dooray-", postfix: ".md" });
3007
3167
  return new Promise(async (resolve, reject) => {
3008
3168
  try {
3009
- await import_promises6.default.writeFile(tmpFile.name, content, "utf-8");
3169
+ await import_promises7.default.writeFile(tmpFile.name, content, "utf-8");
3010
3170
  const child = (0, import_node_child_process.spawn)(editor2, [tmpFile.name], {
3011
3171
  stdio: "inherit"
3012
3172
  });
@@ -3022,7 +3182,7 @@ function openInEditor(content) {
3022
3182
  EXIT_PARAM_ERROR
3023
3183
  );
3024
3184
  }
3025
- const result = await import_promises6.default.readFile(tmpFile.name, "utf-8");
3185
+ const result = await import_promises7.default.readFile(tmpFile.name, "utf-8");
3026
3186
  resolve(result);
3027
3187
  } catch (e) {
3028
3188
  reject(e);
@@ -3115,40 +3275,6 @@ function parseWikiFrontmatter(content) {
3115
3275
  };
3116
3276
  }
3117
3277
 
3118
- // src/utils/body-input.ts
3119
- var import_promises7 = require("fs/promises");
3120
- async function readBodyInput(opts) {
3121
- if (opts.body != null && opts.bodyFile != null) {
3122
- throw new DoorayCliError(
3123
- "--body\uC640 --body-file\uC740 \uD568\uAED8 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.",
3124
- EXIT_PARAM_ERROR
3125
- );
3126
- }
3127
- if (opts.bodyFile) {
3128
- if (opts.bodyFile === "-") return readStdin();
3129
- return (0, import_promises7.readFile)(opts.bodyFile, "utf-8");
3130
- }
3131
- if (opts.body === "-") return readStdin();
3132
- return opts.body ?? "";
3133
- }
3134
- async function readBodyInputOrNull(opts) {
3135
- if (opts.body == null && opts.bodyFile == null) return null;
3136
- return readBodyInput(opts);
3137
- }
3138
- async function readStdin() {
3139
- if (process.stdin.isTTY) {
3140
- throw new DoorayCliError(
3141
- "stdin\uC5D0\uC11C \uC77D\uC73C\uB824\uBA74 \uD30C\uC774\uD504\uB85C \uB370\uC774\uD130\uB97C \uC804\uB2EC\uD574\uC8FC\uC138\uC694.",
3142
- EXIT_PARAM_ERROR
3143
- );
3144
- }
3145
- const chunks = [];
3146
- for await (const chunk of process.stdin) {
3147
- chunks.push(chunk);
3148
- }
3149
- return Buffer.concat(chunks).toString("utf-8");
3150
- }
3151
-
3152
3278
  // src/utils/attachment-check.ts
3153
3279
  var import_node_readline = __toESM(require("readline"));
3154
3280
  function extractAttachmentReferences(body) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bifos/dooray-cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "CLI tool for Dooray project management — AI agent & terminal friendly",
5
5
  "keywords": [
6
6
  "dooray",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "files": [
24
24
  "dist",
25
- "skills",
25
+ "skills/dooray-cli",
26
26
  "README.md"
27
27
  ],
28
28
  "engines": {
@@ -41,6 +41,7 @@ NHN Dooray REST API 를 래핑한 CLI 다. 이 파일은 라우터이므로, 작
41
41
  - 조회는 `--json` 으로 먼저 실행해 응답 구조를 확인한 뒤 쓰기 명령으로 넘어간다
42
42
  - 쓰기 명령은 대상 ID 를 명시하고, 지원하면 `--dry-run` 으로 먼저 확인한다
43
43
  - 이름 기반 조회(멤버·그룹·워크플로우·태그)는 부분일치를 지원한다. 모호하면 에러와 후보 목록이 나오므로 임의로 고르지 말고 사용자에게 확인한다
44
+ - 멤버를 이름으로 찾는 것은 그 프로젝트의 멤버로 한정된다. 비멤버는 이메일이나 memberId 로 지정한다 — [post.md](references/post.md)
44
45
  - 실패하면 [common.md](references/common.md) 의 에러 처리 표와 대조한다
45
46
 
46
47
  ## 파일 명령의 `--json` 스키마
@@ -22,6 +22,15 @@ dooray config set api-key <YOUR_API_TOKEN> # https://{org}.dooray.com/setting/
22
22
  dooray doctor # 설정 검증
23
23
  ```
24
24
 
25
+ 값 자리에 `-` 를 주면 stdin 에서 읽는다. 토큰을 명령 인자로 넘기지 않을 때 쓴다.
26
+
27
+ ```bash
28
+ printf '%s' "$TOKEN" | dooray config set api-key -
29
+ ```
30
+
31
+ 인자로 넘긴 값은 셸 기록과 프로세스 목록에 남는다.
32
+ stdin 값은 양끝 공백을 지운 뒤 저장하고, 비어 있으면 저장하지 않고 종료 코드 3 으로 끝낸다.
33
+
25
34
  ## Claude Code 스킬 관리
26
35
 
27
36
  ```bash
@@ -61,6 +61,17 @@ title 속성도 없다.
61
61
  title 은 workflow class 다 — `registered` / `working` / `closed` / `backlog`.
62
62
  클릭하면 브라우저가 아니라 Dooray 앱 안에서 이동하며 workflow 상태가 함께 보인다.
63
63
 
64
+ **표시 텍스트의 대괄호는 엔티티로 바꾼다.** `[` 는 `&#91;`, `]` 는 `&#93;` 다.
65
+ 제목에 모듈명을 대괄호로 붙이는 팀이 많은데, 조회한 `subject` 를 그대로 넣으면 링크 문법과 충돌해 깨진다.
66
+
67
+ | 입력 | 결과 |
68
+ | --- | --- |
69
+ | `[my-project/524 [MOD] 한도 분리](dooray://...)` | 링크 깨짐 |
70
+ | `[my-project/524 &#91;MOD&#93; 한도 분리](dooray://...)` | 정상 |
71
+
72
+ 치환 대상은 표시 텍스트뿐이고 URL 은 해당 없다.
73
+ `--link-task` 옵션을 쓰면 CLI 가 알아서 치환하므로 손으로 조립할 때만 신경 쓴다.
74
+
64
75
  ### 위키 페이지
65
76
 
66
77
  ```markdown
@@ -68,13 +79,31 @@ title 은 workflow class 다 — `registered` / `working` / `closed` / `backlog`
68
79
  ```
69
80
 
70
81
  업무 링크와 같은 구조이고 경로만 `pages/{pageId}` 로 다르다. title 은 페이지 상태다.
82
+ 표시 텍스트의 대괄호 치환도 업무 링크와 같다.
71
83
 
72
84
  ### ID 를 얻는 곳
73
85
 
74
86
  | ID | 얻는 방법 |
75
87
  | --- | --- |
76
88
  | `orgId` | `~/.dooray/cache/me.json` 의 `data.orgId` |
77
- | `memberId` | `dooray member search <name>` 또는 `dooray member get <id>` |
89
+ | `memberId` | 응답에 있으면 값을 쓴다 (아래 참조). 없을 때만 `dooray member search <name>` |
78
90
  | `groupId` | `dooray project groups <project>` |
79
91
  | `postId` | `dooray post get <project> <number> --json` 의 `id` |
80
92
  | `pageId` | `dooray wiki page get <project> <page-id> --json` 의 `id` |
93
+
94
+ ### 답장 대상은 이름으로 찾지 않는다
95
+
96
+ 답장할 상대의 ID 는 이미 조회 응답 안에 있다. 검색할 필요가 없다.
97
+
98
+ | 대상 | 응답의 위치 |
99
+ | --- | --- |
100
+ | 업무 작성자 | `post get ... --json` 의 `users.from.member.organizationMemberId` |
101
+ | 댓글 작성자 | `post comment list ... --json` 의 `creator.member.organizationMemberId` |
102
+
103
+ 이 값을 쓰면 표시 이름이 한자든 영문이든 닉네임이든 항상 정확하다.
104
+
105
+ **검색 결과가 1건이어도 확정 근거로 삼지 않는다.**
106
+ 표시 이름이 한자로 되어 있으면 한글 이름 검색에 원 작성자가 아예 안 잡히고, 표기가 비슷한 **다른 사람만** 걸린다.
107
+ 후보가 여럿이면 모호하다는 신호라도 있지만, 하나뿐이면 오히려 확신하게 되는 것이 함정이다.
108
+
109
+ 이름 검색은 대상이 응답에 없을 때만 쓰고, 그때도 이메일이나 사번으로 교차 확인한다.
@@ -130,6 +130,20 @@ dooray post edit --id "$POST_ID" --cc "$MEMBER_ID"
130
130
  - 이메일 형태 → exact 매칭
131
131
  - 그 외 → 이름 부분일치
132
132
 
133
+ **이름은 그 프로젝트의 멤버만 찾는다.** 이메일과 memberId 는 조직 전체에서 찾는다.
134
+
135
+ 그래서 프로젝트 멤버가 아닌 사람을 이름으로 지정하면 실패한다.
136
+
137
+ ```bash
138
+ dooray post create <project> --to 홍길동 # 프로젝트 멤버가 아니면 실패
139
+ dooray post create <project> --to user@example.com # 통과
140
+ ```
141
+
142
+ 에러에 붙는 "사용 가능한 멤버 (N/M)" 은 CLI 의 조회 한계가 아니라 **그 프로젝트의 멤버 목록**이다.
143
+ 일부만 가져온 것으로 오해하기 쉬운 표기다.
144
+
145
+ 대상이 그 프로젝트 멤버인지 확실하지 않으면 처음부터 이메일을 쓴다. 이메일은 `dooray member search <이름>` 으로 찾는다.
146
+
133
147
  ## 부모 업무 지정
134
148
 
135
149
  ```bash