@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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
**표시 텍스트의 대괄호는 엔티티로 바꾼다.** `[` 는 `[`, `]` 는 `]` 다.
|
|
65
|
+
제목에 모듈명을 대괄호로 붙이는 팀이 많은데, 조회한 `subject` 를 그대로 넣으면 링크 문법과 충돌해 깨진다.
|
|
66
|
+
|
|
67
|
+
| 입력 | 결과 |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `[my-project/524 [MOD] 한도 분리](dooray://...)` | 링크 깨짐 |
|
|
70
|
+
| `[my-project/524 [MOD] 한도 분리](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` |
|
|
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
|