@bifos/dooray-cli 0.10.1 → 0.12.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
@@ -50,7 +50,7 @@ dooray project members <project> # 멤버 목록
50
50
  dooray project workflows <project> # 워크플로우 목록
51
51
  dooray project groups <project> # 멤버 그룹 목록 (ID / Code)
52
52
  dooray project tags <project> # 태그 목록 (ID / Color / Name / Group / Mandatory)
53
- dooray project templates <project> # 템플릿 목록 (ID / Template Name, ADR-027)
53
+ dooray project templates <project> # 템플릿 목록 (ID / Template Name)
54
54
  ```
55
55
 
56
56
  > **태그 캐시 갱신**: 이전 버전에서 캐시한 태그가 색상 없이 표시되면 `dooray cache clear` 실행 후 다시 조회하세요.
@@ -59,7 +59,7 @@ dooray project templates <project> # 템플릿 목록 (ID / Template Na
59
59
 
60
60
  ```bash
61
61
  dooray member list <project> # 프로젝트 멤버 목록 (이름·organizationMemberId)
62
- dooray member get <organizationMemberId> # 멤버 상세 (cache 우회, ADR-021)
62
+ dooray member get <organizationMemberId> # 멤버 상세 (cache 우회)
63
63
 
64
64
  # organization 전체 멤버 검색
65
65
  dooray member search 홍길동 # 이름 검색
@@ -83,13 +83,34 @@ dooray post get <project> 42 --json # JSON 출력
83
83
  | 방식 | 예시 |
84
84
  |---|---|
85
85
  | `<project> <number>` | `dooray post get <project> 42` |
86
- | Dooray URL positional (`/task/to/<postId>`) | `dooray post get https://x.dooray.com/task/to/<postId>` |
87
- | Dooray URL positional (브라우저 주소창 복사본) | `dooray post get https://x.dooray.com/task/<projectId>/<postId>` |
86
+ | Dooray URL positional | `dooray post get https://x.dooray.com/task/to/<postId>` |
88
87
  | `--id <postId>` | `dooray post get --id <postId>` |
89
- | `--url <url>` | `dooray post get --url https://x.dooray.com/task/to/...` |
88
+ | `--url <url>` | `dooray post get --url https://x.dooray.com/task/to/<postId>` |
89
+
90
+ 지원 URL 형식 3종 (positional 첫 인자 / `--url` 공통):
91
+
92
+ - `https://*.dooray.com/task/to/<postId>`
93
+ - `https://*.dooray.com/task/<projectId>/<postId>` — 브라우저 주소창 복사본
94
+ - `https://*.dooray.com/project/tasks/<postId>` — 프로젝트 업무 목록에서 열기
90
95
 
91
96
  대상: `post get`/`edit`/`done`/`workflow`, `post comment list`/`add`/`edit`/`delete`, `post file list`/`upload`/`download`/`download-all`/`delete`.
92
- AI 에이전트는 사용자 메시지의 Dooray URL을 그대로 첫 인자로 전달하면 가장 빠르다 (ADR-020).
97
+ AI 에이전트는 사용자 메시지의 Dooray URL을 그대로 첫 인자로 전달하면 가장 빠르다.
98
+
99
+ **projectId 직접 입력**:
100
+
101
+ `member=me` 응답에 없는 프로젝트 (다른 팀 / 권한만 있는 프로젝트) 도 projectId (15+자리 numeric) 를 직접 입력하면 자동으로 cache 우회.
102
+
103
+ ```bash
104
+ # 코드 매칭 (기존)
105
+ dooray post search <project> "keyword"
106
+
107
+ # projectId 직접 입력 — member 아닌 프로젝트도 자동화 가능
108
+ dooray post search 1234567890123456789 "keyword"
109
+ dooray post list 1234567890123456789
110
+ dooray member list 1234567890123456789
111
+ ```
112
+
113
+ 권한 검증은 후속 API 호출 시점 — 권한 없으면 4xx.
93
114
 
94
115
  ### 업무 생성
95
116
 
@@ -115,7 +136,18 @@ dooray post create <project> \
115
136
  > mandatory-tag 정책 프로젝트(예: `<project>`)에서는 mandatory 그룹마다 1개 이상 `--tag`로 지정해야 한다.
116
137
  > 누락 시 클라이언트가 사전 검증으로 후보 목록과 함께 에러 출력.
117
138
 
118
- #### 템플릿 기반 정형 task (ADR-027)
139
+ > **`post create` 출력의 `.id` 는 internal postId (19자리 숫자)입니다.**
140
+ > 이 ID 로 후속 조회·수정·댓글 작업을 할 때는 **`--id <postId>`** 를 사용하세요.
141
+ > `<project> <업무번호>` 의 번호 자리에 postId 를 넣으면 안내 에러가 발생합니다.
142
+ >
143
+ > ```bash
144
+ > # 생성 후 바로 조회하는 패턴
145
+ > POST_ID=$(dooray post create <project> --title "..." --json | jq -r '.id')
146
+ > dooray post get --id "$POST_ID"
147
+ > dooray post comment add --id "$POST_ID" --body "첫 댓글"
148
+ > ```
149
+
150
+ #### 템플릿 기반 정형 task
119
151
 
120
152
  ```bash
121
153
  # 프로젝트의 템플릿 목록
@@ -166,7 +198,7 @@ dooray post comment add <project> 42 --body "댓글 내용"
166
198
  dooray post comment add <project> 42 --body-file ./comment.md
167
199
  ```
168
200
 
169
- > table 출력의 Creator 컬럼은 프로젝트 멤버 캐시로 자동 enrich되며, `--json`은 raw 응답을 유지한다 (ADR-021).
201
+ > table 출력의 Creator 컬럼은 프로젝트 멤버 캐시로 자동 enrich되며, `--json`은 raw 응답을 유지한다.
170
202
 
171
203
  #### 멘션·내부 링크 자동 삽입
172
204
 
@@ -190,7 +222,7 @@ dooray post comment add <project> <post-number> --body "..." --link-task <projec
190
222
  > `--mention` / `--mention-group` / `--link-task` / `--dry-run` 은 `post create`, `post edit`, `post comment add`, `post comment edit` 4 명령 모두 지원.
191
223
  > 이전 버전 캐시는 orgId가 없으므로 첫 호출 시 자동 갱신됩니다 (또는 `dooray cache clear`).
192
224
 
193
- #### 참조자(cc) / 담당자(to) 변경 (ADR-025)
225
+ #### 참조자(cc) / 담당자(to) 변경
194
226
 
195
227
  ```bash
196
228
  # 멤버/그룹 추가 (기존 참조자 유지 + dedupe)
@@ -209,7 +241,7 @@ interactive ($EDITOR) 모드에서는 위 6개 옵션이 무시되고 stderr 경
209
241
  `post edit --dry-run --json` 사용 시 출력에 `users: { to, cc }` 가 포함되어 API 호출 없이 변경 결과 미리보기 가능.
210
242
  `post create --dry-run` 은 본문만 출력하며 `users` 는 포함하지 않음.
211
243
 
212
- **그룹 cc / mention 사용 예 (Issue #76 fix)**:
244
+ **그룹 cc / mention 사용 예**:
213
245
 
214
246
  ```bash
215
247
  # code 부분일치
@@ -230,7 +262,7 @@ dooray post edit <project> <post-number> --cc-group dev-team --dry-run --json |
230
262
  # 출력 예: [{ "type": "group", "group": { "projectMemberGroupId": "...", "members": [] } }, ...]
231
263
  ```
232
264
 
233
- #### 생성 후 태그 변경 (Issue #66)
265
+ #### 생성 후 태그 변경
234
266
 
235
267
  ```bash
236
268
  # 기존 태그 유지 + 신규 추가 (dedupe)
@@ -244,7 +276,7 @@ dooray post edit --id <postId> --tag-remove "<group>: <name>"
244
276
  ```
245
277
 
246
278
  `--title` / `--body` 없이 단독 호출 가능 — 기존 본문은 자동 재전송.
247
- mandatory tag 그룹은 `post create` 와 동일하게 사전 검증 (ADR-019).
279
+ mandatory tag 그룹은 `post create` 와 동일하게 사전 검증.
248
280
 
249
281
  #### 상위 업무 변경 (`--parent`)
250
282
 
@@ -261,7 +293,7 @@ dooray post edit --id <postId> --title "<원제목>" --parent <other-parent-post
261
293
 
262
294
  interactive ($EDITOR) 모드에서 `--parent` 사용 시 무시 + stderr 경고.
263
295
  **parent 만 단독 변경하려면 `--title "<원제목>"` 동반 필요** — `post edit` 는 본문 변경(`--title`/`--body`) 동반 시에만 non-interactive 분기로 들어감.
264
- parent 변경은 그 분기 안에서만 수행됨. (Issue #60)
296
+ parent 변경은 그 분기 안에서만 수행됨.
265
297
 
266
298
  `--dry-run --json` 출력의 `parentChange` 필드는 **사용자 입력 원문 그대로** (`<project>/<number>` 또는 raw postId).
267
299
  resolver 처리 전 미리보기 값이며 실제 호출 대상 `postId` 가 아님.
@@ -269,7 +301,7 @@ dry-run 은 API 미호출 원칙을 유지해 `resolvePostRef` 도 건너뜀.
269
301
 
270
302
  #### `--to` / `--cc` / `--mention` 입력 형식 (자동 분기)
271
303
 
272
- 이름 외에도 이메일 / organizationMemberId 직접 입력 가능 — **동명이인 우회 + ID 직접 입력** (Issue #58):
304
+ 이름 외에도 이메일 / organizationMemberId 직접 입력 가능 — **동명이인 우회 + ID 직접 입력**:
273
305
 
274
306
  ```bash
275
307
  # 이름 (이전부터 지원, 부분일치)
@@ -353,7 +385,7 @@ dooray wiki page edit <project> <page-id> --body "..." | --body-file <path> #
353
385
  dooray wiki page edit <project> <page-id> # $EDITOR (플래그 없을 때)
354
386
  ```
355
387
 
356
- #### 위키 페이지 첨부파일 (Issue #70)
388
+ #### 위키 페이지 첨부파일
357
389
 
358
390
  post 의 `post file` 명령군과 동일 패턴 — `<project> <page-id>` 외에도 `--id`/`--url`/positional URL 지원.
359
391
 
@@ -383,11 +415,42 @@ dooray wiki page file list "https://<tenant>.dooray.com/wiki/<wikiId>/<pageId>"
383
415
  dooray wiki page file upload --id <pageId> --project <project> --file ./README.md
384
416
  ```
385
417
 
418
+ `--json` 옵션으로 자동화 파이프라인에서 동일 parse 코드를 사용할 수 있습니다:
419
+
420
+ ```bash
421
+ # download — { outputPath, fileName, size }
422
+ dooray wiki page file download <project> <page-id> --file-id <id> -o ./ --json
423
+
424
+ # download-all — { count, succeeded, failed } (부분 실패 시 exit 1)
425
+ dooray wiki page file download-all <project> <page-id> -o ./ --json | jq '.failed'
426
+
427
+ # delete — { fileId, status: "deleted" }
428
+ dooray wiki page file delete <project> <page-id> --file-id <id> --json
429
+
430
+ # upload (general) — res.result raw (--quiet 는 id 만)
431
+ dooray wiki page file upload <project> <page-id> --file ./report.pdf --json
432
+
433
+ # upload (inline_image) — res.result + markdownSnippet 필드 추가
434
+ dooray wiki page file upload <project> <page-id> --file ./diagram.png --type inline_image --json
435
+ # 출력 예:
436
+ # {
437
+ # "id": "<id>",
438
+ # "attachFileId": "<attachFileId>",
439
+ # "name": "diagram.png",
440
+ # "size": 12345,
441
+ # "type": "inline_image",
442
+ # "markdownSnippet": "![diagram.png](/wikis/<wikiId>/files/<attachFileId>)"
443
+ # }
444
+
445
+ # 자동화: markdownSnippet 을 jq 로 추출해 본문에 삽입
446
+ SNIPPET=$(dooray wiki page file upload <project> <page-id> --file ./diagram.png --type inline_image --json | jq -r '.markdownSnippet')
447
+ ```
448
+
386
449
  **주의**:
387
450
  - `upload` 시 multipart 필드 순서 (`type` → `file`) 가 중요.
388
- 클라이언트가 자동으로 강제 (ADR-029 참조)
451
+ 클라이언트가 자동으로 강제
389
452
  - `inline_image` 로 올린 파일은 본문에 markdown 으로 박혀야 위키에서 보임.
390
- upload stdout snippet복사해서 `dooray wiki page edit` 으로 본문에 직접 추가
453
+ `--json``markdownSnippet`복사하거나 jq 로 추출해 `dooray wiki page edit` 본문에 직접 추가
391
454
  - `delete` 는 confirm 없이 즉시 삭제 (실수 방지 책임은 호출자)
392
455
 
393
456
  #### 위키 페이지 댓글
@@ -474,6 +537,25 @@ dooray post file upload <project> <number> ./report.pdf
474
537
  dooray post file delete <project> <number> <file-id>
475
538
  ```
476
539
 
540
+ 자동화 스크립트에서 `--json` 옵션으로 구조화된 출력을 파이프로 가공할 수 있습니다:
541
+
542
+ ```bash
543
+ # download — { outputPath, fileName, size }
544
+ dooray post file download <project> <number> --file-id <id> -o ./ --json
545
+ # 출력: {"outputPath": "./<fileName>", "fileName": "...", "size": 12345}
546
+
547
+ # download-all — { count, succeeded, failed } (부분 실패 시 exit 1)
548
+ dooray post file download-all <project> <number> -o ./ --json | jq '.failed'
549
+ # 출력: [] 또는 [{"fileId": "...", "error": "..."}]
550
+
551
+ # delete — { fileId, status: "deleted" }
552
+ dooray post file delete <project> <number> --file-id <id> --json
553
+ # 출력: {"fileId": "...", "status": "deleted"}
554
+
555
+ # upload — res.result raw (--quiet 는 id 만)
556
+ dooray post file upload <project> <number> ./report.pdf --json
557
+ ```
558
+
477
559
  URL/`--id`/`--url` 모드에서는 sub-id를 옵션으로 전달:
478
560
  ```bash
479
561
  dooray post file download --url <url> --file-id <fileId> -o ./downloads
@@ -486,7 +568,7 @@ dooray post comment delete --url <url> --comment-id <commentId>
486
568
  ### 댓글 첨부 파일 (`post comment file *`)
487
569
 
488
570
  자동화로 댓글에 인라인 이미지 / 파일을 삽입할 때 사용.
489
- 4 명령 (list/upload/download/delete) 모두 `<project> <post-number> <comment-id>` 또는 `--id <postId> --comment-id <logId>` / `--url <url> --comment-id <logId>` 패턴 지원 (ADR-020).
571
+ 4 명령 (list/upload/download/delete) 모두 `<project> <post-number> <comment-id>` 또는 `--id <postId> --comment-id <logId>` / `--url <url> --comment-id <logId>` 패턴 지원.
490
572
 
491
573
  ```bash
492
574
  # 첨부 목록
@@ -503,7 +585,7 @@ dooray post comment file delete <project> <post-num> <comment-id> <file-id> --ye
503
585
  ```
504
586
 
505
587
  > Dooray REST API 가 댓글 전용 attachment endpoint 를 제공하지 않아 내부적으로
506
- > post-level files API 와 댓글 본문 PUT 의 합성으로 동작 (ADR-024). 단일 명령
588
+ > post-level files API 와 댓글 본문 PUT 의 합성으로 동작한다. 단일 명령
507
589
  > = 단일 파일 — 다중 파일은 호출자가 반복 호출.
508
590
 
509
591
  ## 출력 모드
@@ -550,7 +632,7 @@ dooray feedback --last --title "재현" --body "추가 설명" --dry-run # 미
550
632
  dooray feedback --dry-run
551
633
  ```
552
634
 
553
- > **개인정보 보호 (ADR-023)**: `--last` 모드에서 argv는 `--api-key`/`--token`/`Authorization` 등 시크릿 패턴을 자동 마스킹 후 저장합니다. cwd/env는 미저장.
635
+ > **개인정보 보호**: `--last` 모드에서 argv는 `--api-key`/`--token`/`Authorization` 등 시크릿 패턴을 자동 마스킹 후 저장합니다. cwd/env는 미저장.
554
636
 
555
637
  ## 캐시
556
638
 
package/dist/index.js CHANGED
@@ -1206,6 +1206,7 @@ var setupCommand = new import_commander4.Command("setup").description("\uB300\uD
1206
1206
  var import_commander5 = require("commander");
1207
1207
 
1208
1208
  // src/resolvers/project.ts
1209
+ var PROJECT_ID_RE = /^\d{15,}$/;
1209
1210
  async function fetchAllProjects(client, options) {
1210
1211
  const all = [];
1211
1212
  let page = 0;
@@ -1243,6 +1244,9 @@ async function ensurePrivateProjects(client) {
1243
1244
  return items;
1244
1245
  }
1245
1246
  async function resolveProject(client, input2) {
1247
+ if (PROJECT_ID_RE.test(input2)) {
1248
+ return input2;
1249
+ }
1246
1250
  const projects = await ensureProjects(client);
1247
1251
  const match = projects.find((p) => p.code === input2 || p.id === input2);
1248
1252
  if (match) return match.id;
@@ -1253,7 +1257,8 @@ async function resolveProject(client, input2) {
1253
1257
  }
1254
1258
  throw new DoorayCliError(
1255
1259
  `\uD504\uB85C\uC81D\uD2B8\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${input2}
1256
- \uAC1C\uC778 \uD504\uB85C\uC81D\uD2B8\uB77C\uBA74: dooray project list --type private \uB85C \uCE90\uC2DC\uB97C \uAC31\uC2E0\uD558\uC138\uC694`,
1260
+ \uAC1C\uC778 \uD504\uB85C\uC81D\uD2B8\uB77C\uBA74: dooray project list --type private \uB85C \uCE90\uC2DC\uB97C \uAC31\uC2E0\uD558\uC138\uC694
1261
+ member=me \uC751\uB2F5\uC5D0 \uC5C6\uB294 \uD504\uB85C\uC81D\uD2B8\uB294 projectId (15+\uC790\uB9AC numeric) \uC9C1\uC811 \uC785\uB825\uC73C\uB85C \uC6B0\uD68C \uAC00\uB2A5 (ADR-030)`,
1257
1262
  EXIT_PARAM_ERROR
1258
1263
  );
1259
1264
  }
@@ -1994,12 +1999,15 @@ async function resolvePost(client, projectId, postNumber) {
1994
1999
  // src/utils/dooray-url.ts
1995
2000
  var TASK_URL_RE = /^https?:\/\/[\w.-]+\.dooray\.com\/task\/to\/(\d+)(?:[/?#].*)?$/;
1996
2001
  var TASK_URL_ALT_RE = /^https?:\/\/[\w.-]+\.dooray\.com\/task\/(?:\d+)\/(\d+)(?:[/?#].*)?$/;
2002
+ var TASK_PROJECT_TASKS_RE = /^https?:\/\/[\w.-]+\.dooray\.com\/project\/tasks\/(\d+)(?:[/?#].*)?$/;
1997
2003
  var WIKI_URL_RE = /^https?:\/\/[\w.-]+\.dooray\.com\/wiki\/(\d+)\/(\d+)(?:[/?#].*)?$/;
1998
2004
  function parseDoorayTaskUrl(input2) {
1999
2005
  const m1 = TASK_URL_RE.exec(input2);
2000
2006
  if (m1) return m1[1];
2001
2007
  const m2 = TASK_URL_ALT_RE.exec(input2);
2002
2008
  if (m2) return m2[1];
2009
+ const m3 = TASK_PROJECT_TASKS_RE.exec(input2);
2010
+ if (m3) return m3[1];
2003
2011
  return null;
2004
2012
  }
2005
2013
  function parseDoorayWikiUrl(input2) {
@@ -2012,7 +2020,18 @@ function isLikelyDoorayUrl(input2) {
2012
2020
  }
2013
2021
 
2014
2022
  // src/resolvers/post-input.ts
2015
- var INPUT_HELP = "\uC5C5\uBB34\uB97C \uC2DD\uBCC4\uD560 \uC815\uBCF4\uAC00 \uBD80\uC871\uD569\uB2C8\uB2E4. \uB2E4\uC74C \uC911 \uD558\uB098\uB97C \uC785\uB825\uD558\uC138\uC694:\n - <project> <post-number> \uC608: tc-ocr 337\n - --id <postId> \uC608: --id 4319587406666362045\n - <Dooray URL> \uC608: https://x.dooray.com/task/to/4319587406666362045";
2023
+ function classifyPostInputToken(token) {
2024
+ if (/^https?:\/\//.test(token)) return "url";
2025
+ if (/^\d{15,}$/.test(token)) return "postId";
2026
+ if (/^\d+$/.test(token)) return "postNumber";
2027
+ return "project";
2028
+ }
2029
+ var URL_FORMAT_HINT = "\uC9C0\uC6D0 \uD615\uC2DD:\n https://x.dooray.com/task/to/{postId}\n https://x.dooray.com/task/{projectId}/{postId}\n https://x.dooray.com/project/tasks/{postId}";
2030
+ var INPUT_HELP = `\uC5C5\uBB34\uB97C \uC2DD\uBCC4\uD560 \uC815\uBCF4\uAC00 \uBD80\uC871\uD569\uB2C8\uB2E4. \uB2E4\uC74C \uC911 \uD558\uB098\uB97C \uC785\uB825\uD558\uC138\uC694:
2031
+ - <project> <post-number> \uC608: my-project 337
2032
+ - --id <postId> \uC608: --id 1234567890123456789
2033
+ - <Dooray URL>
2034
+ ${URL_FORMAT_HINT}`;
2016
2035
  async function resolveByPostId(client, postId) {
2017
2036
  const res = await client.getPostStandalone(postId);
2018
2037
  const d = res.result;
@@ -2043,13 +2062,28 @@ async function resolvePostInput(client, args) {
2043
2062
  if (!postId) {
2044
2063
  throw new DoorayCliError(
2045
2064
  `--url \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: "${urlOpt}"
2046
- \uC608: https://x.dooray.com/task/to/4319587406666362045`,
2065
+ ${URL_FORMAT_HINT}`,
2047
2066
  EXIT_PARAM_ERROR
2048
2067
  );
2049
2068
  }
2050
2069
  return resolveByPostId(client, postId);
2051
2070
  }
2052
2071
  if (idOpt) {
2072
+ const idType = classifyPostInputToken(idOpt);
2073
+ if (idType === "postNumber") {
2074
+ throw new DoorayCliError(
2075
+ `"${idOpt}" \uB294 \uC5C5\uBB34 \uBC88\uD638\uB85C \uBCF4\uC785\uB2C8\uB2E4. <project> <post-number> \uD615\uC2DD\uC744 \uC4F0\uC138\uC694:
2076
+ dooray post get <project> ${idOpt}`,
2077
+ EXIT_PARAM_ERROR
2078
+ );
2079
+ }
2080
+ if (idType === "url") {
2081
+ throw new DoorayCliError(
2082
+ `"${idOpt}" \uB294 URL \uD615\uC2DD\uC785\uB2C8\uB2E4. --url \uC635\uC158\uC744 \uC4F0\uC138\uC694:
2083
+ dooray post get --url "${idOpt}"`,
2084
+ EXIT_PARAM_ERROR
2085
+ );
2086
+ }
2053
2087
  return resolveByPostId(client, idOpt);
2054
2088
  }
2055
2089
  if (projectArg && !postNumberArg && isLikelyDoorayUrl(projectArg)) {
@@ -2057,13 +2091,28 @@ async function resolvePostInput(client, args) {
2057
2091
  if (!postId) {
2058
2092
  throw new DoorayCliError(
2059
2093
  `Dooray URL \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: "${projectArg}"
2060
- \uC608: https://x.dooray.com/task/to/4319587406666362045`,
2094
+ ${URL_FORMAT_HINT}`,
2061
2095
  EXIT_PARAM_ERROR
2062
2096
  );
2063
2097
  }
2064
2098
  return resolveByPostId(client, postId);
2065
2099
  }
2066
2100
  if (projectArg && postNumberArg) {
2101
+ const numType = classifyPostInputToken(postNumberArg);
2102
+ if (numType === "postId") {
2103
+ throw new DoorayCliError(
2104
+ `"${postNumberArg}" \uB294 \uB0B4\uBD80 ID(postId)\uB85C \uBCF4\uC785\uB2C8\uB2E4.
2105
+ \uC5C5\uBB34 \uBC88\uD638(#N)\uAC00 \uC544\uB2CC \uB0B4\uBD80 ID \uB85C \uC870\uD68C\uD558\uB824\uBA74 --id \uC635\uC158\uC744 \uC0AC\uC6A9\uD558\uC138\uC694:
2106
+ dooray post get --id ${postNumberArg}`,
2107
+ EXIT_PARAM_ERROR
2108
+ );
2109
+ }
2110
+ if (numType !== "postNumber") {
2111
+ throw new DoorayCliError(
2112
+ `<post-number>\uAC00 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: "${postNumberArg}"`,
2113
+ EXIT_PARAM_ERROR
2114
+ );
2115
+ }
2067
2116
  const projectId = await resolveProject(client, projectArg);
2068
2117
  const num = Number(postNumberArg);
2069
2118
  if (!Number.isFinite(num) || num <= 0) {
@@ -3767,7 +3816,49 @@ var fileListCommand = new import_commander29.Command("list").description("\uC5C5
3767
3816
  var import_commander30 = require("commander");
3768
3817
  var import_promises9 = require("fs/promises");
3769
3818
  var import_node_path6 = require("path");
3819
+
3820
+ // src/formatters/file-output.ts
3821
+ function emitDownloadResult(globalOpts, result) {
3822
+ if (globalOpts.json) {
3823
+ printJson(result);
3824
+ } else {
3825
+ process.stdout.write(`${result.outputPath}
3826
+ `);
3827
+ }
3828
+ }
3829
+ function emitDownloadAllResult(globalOpts, data) {
3830
+ if (globalOpts.json) {
3831
+ printJson(data);
3832
+ } else if (globalOpts.quiet) {
3833
+ for (const s of data.succeeded) {
3834
+ process.stdout.write(`${s.path}
3835
+ `);
3836
+ }
3837
+ if (data.failed.length > 0) {
3838
+ process.stderr.write(`${data.failed.length} failed
3839
+ `);
3840
+ }
3841
+ } else {
3842
+ process.stdout.write(`
3843
+ \uC644\uB8CC: ${data.succeeded.length}/${data.count}
3844
+ `);
3845
+ }
3846
+ }
3847
+ function emitDeleteResult(globalOpts, result) {
3848
+ if (globalOpts.json) {
3849
+ printJson({ fileId: result.fileId, status: "deleted" });
3850
+ } else if (globalOpts.quiet) {
3851
+ process.stdout.write(`${result.fileId}
3852
+ `);
3853
+ } else {
3854
+ process.stdout.write(`\uD30C\uC77C(${result.fileId})\uC774 \uC0AD\uC81C\uB418\uC5C8\uC2B5\uB2C8\uB2E4.
3855
+ `);
3856
+ }
3857
+ }
3858
+
3859
+ // src/commands/post/file/download.ts
3770
3860
  var fileDownloadCommand = new import_commander30.Command("download").description("\uCCA8\uBD80\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC").argument("[arg1]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC, Dooray URL, \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg2]", "\uC5C5\uBB34 \uBC88\uD638 \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg3]", "\uD30C\uC77C ID (positional \uBAA8\uB4DC)").option("--id <postId>", "Dooray post ID (project/post-number \uB300\uC2E0)").option("--url <url>", "Dooray \uC5C5\uBB34 URL (project/post-number \uB300\uC2E0)").option("--file-id <fileId>", "\uD30C\uC77C ID (positional \uB300\uCCB4)").option("-o, --output <dir>", "\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC", ".").action(async (arg1, arg2, arg3, opts) => {
3861
+ const globalOpts = fileDownloadCommand.optsWithGlobals();
3771
3862
  const config = await getConfigOrThrow();
3772
3863
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
3773
3864
  let projectArg;
@@ -3814,11 +3905,11 @@ var fileDownloadCommand = new import_commander30.Command("download").description
3814
3905
  urlOpt: opts.url
3815
3906
  });
3816
3907
  const { buffer, fileName } = await client.downloadPostFile(projectId, postId, fileId);
3817
- const outputPath = (0, import_node_path6.join)(opts.output, fileName);
3908
+ const safeName = (0, import_node_path6.basename)(decodeURIComponent(fileName));
3909
+ const outputPath = (0, import_node_path6.join)(opts.output, safeName);
3818
3910
  await (0, import_promises9.writeFile)(outputPath, Buffer.from(buffer));
3819
3911
  stopSpinner(true, "\uB2E4\uC6B4\uB85C\uB4DC \uC644\uB8CC");
3820
- process.stdout.write(`${outputPath}
3821
- `);
3912
+ emitDownloadResult(globalOpts, { outputPath, fileName: safeName, size: buffer.byteLength });
3822
3913
  });
3823
3914
 
3824
3915
  // src/commands/post/file/download-all.ts
@@ -3826,9 +3917,10 @@ var import_commander31 = require("commander");
3826
3917
  var import_promises10 = require("fs/promises");
3827
3918
  var import_node_path7 = require("path");
3828
3919
  var fileDownloadAllCommand = new import_commander31.Command("download-all").description("\uC5C5\uBB34\uC758 \uBAA8\uB4E0 \uCCA8\uBD80\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC").argument("[project]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (\uB610\uB294 \uCCAB \uC778\uC790\uC5D0 Dooray URL)").argument("[post-number]", "\uC5C5\uBB34 \uBC88\uD638 (project\uC640 \uD568\uAED8 \uC0AC\uC6A9)").option("--id <postId>", "Dooray post ID (project/post-number \uB300\uC2E0)").option("--url <url>", "Dooray \uC5C5\uBB34 URL (project/post-number \uB300\uC2E0)").option("-o, --output <dir>", "\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC", ".").action(async (project, postNumberStr, opts) => {
3920
+ const globalOpts = fileDownloadAllCommand.optsWithGlobals();
3829
3921
  const config = await getConfigOrThrow();
3830
3922
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
3831
- const spinner = startSpinner("\uCCA8\uBD80\uD30C\uC77C \uBAA9\uB85D \uC870\uD68C \uC911...");
3923
+ startSpinner("\uCCA8\uBD80\uD30C\uC77C \uBAA9\uB85D \uC870\uD68C \uC911...");
3832
3924
  const { projectId, postId } = await resolvePostInput(client, {
3833
3925
  projectArg: project,
3834
3926
  postNumberArg: postNumberStr,
@@ -3838,23 +3930,38 @@ var fileDownloadAllCommand = new import_commander31.Command("download-all").desc
3838
3930
  const res = await client.getPostFiles(projectId, postId);
3839
3931
  if (res.result.length === 0) {
3840
3932
  stopSpinner(true, "\uCCA8\uBD80\uD30C\uC77C \uC5C6\uC74C");
3841
- process.stdout.write("\uCCA8\uBD80\uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.\n");
3933
+ if (globalOpts.json) {
3934
+ printJson({ count: 0, succeeded: [], failed: [] });
3935
+ } else if (!globalOpts.quiet) {
3936
+ process.stdout.write("\uCCA8\uBD80\uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.\n");
3937
+ }
3842
3938
  return;
3843
3939
  }
3940
+ stopSpinner(true, `${res.result.length}\uAC1C \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2DC\uC791`);
3844
3941
  await (0, import_promises10.mkdir)(opts.output, { recursive: true });
3845
- const downloaded = [];
3942
+ const succeeded = [];
3943
+ const failed = [];
3846
3944
  for (const file of res.result) {
3847
- spinner.text = `\uB2E4\uC6B4\uB85C\uB4DC \uC911: ${file.name} (${downloaded.length + 1}/${res.result.length})`;
3848
- const { buffer, fileName } = await client.downloadPostFile(projectId, postId, file.id);
3849
- const outputPath = (0, import_node_path7.join)(opts.output, fileName);
3850
- await (0, import_promises10.writeFile)(outputPath, Buffer.from(buffer));
3851
- downloaded.push(outputPath);
3852
- }
3853
- stopSpinner(true, `${downloaded.length}\uAC1C \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC644\uB8CC`);
3854
- for (const path3 of downloaded) {
3855
- process.stdout.write(`${path3}
3945
+ try {
3946
+ const { buffer, fileName } = await client.downloadPostFile(projectId, postId, file.id);
3947
+ const safeName = (0, import_node_path7.basename)(decodeURIComponent(fileName));
3948
+ const outputPath = (0, import_node_path7.join)(opts.output, safeName);
3949
+ await (0, import_promises10.writeFile)(outputPath, Buffer.from(buffer));
3950
+ succeeded.push({ path: outputPath, fileName: safeName });
3951
+ if (!globalOpts.json && !globalOpts.quiet) {
3952
+ process.stdout.write(`\u2713 ${safeName}
3856
3953
  `);
3954
+ }
3955
+ } catch (e) {
3956
+ failed.push({ fileId: file.id, error: e instanceof Error ? e.message : String(e) });
3957
+ if (!globalOpts.json) {
3958
+ process.stderr.write(`\u2717 ${file.name} (${file.id}): ${e instanceof Error ? e.message : String(e)}
3959
+ `);
3960
+ }
3961
+ }
3857
3962
  }
3963
+ emitDownloadAllResult(globalOpts, { count: res.result.length, succeeded, failed });
3964
+ if (failed.length > 0) process.exitCode = 1;
3858
3965
  });
3859
3966
 
3860
3967
  // src/commands/post/file/upload.ts
@@ -3923,6 +4030,7 @@ var fileUploadCommand = new import_commander32.Command("upload").description("\u
3923
4030
  // src/commands/post/file/delete.ts
3924
4031
  var import_commander33 = require("commander");
3925
4032
  var fileDeleteCommand = new import_commander33.Command("delete").description("\uCCA8\uBD80\uD30C\uC77C \uC0AD\uC81C").argument("[arg1]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC, Dooray URL, \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg2]", "\uC5C5\uBB34 \uBC88\uD638 \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg3]", "\uD30C\uC77C ID (positional 3\uAC1C \uBAA8\uB4DC)").option("--id <postId>", "Dooray post ID (project/post-number \uB300\uC2E0)").option("--url <url>", "Dooray \uC5C5\uBB34 URL (project/post-number \uB300\uC2E0)").option("--file-id <fileId>", "\uD30C\uC77C ID (positional \uB300\uCCB4)").action(async (arg1, arg2, arg3, opts) => {
4033
+ const globalOpts = fileDeleteCommand.optsWithGlobals();
3926
4034
  const config = await getConfigOrThrow();
3927
4035
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
3928
4036
  let projectArg;
@@ -3970,8 +4078,7 @@ var fileDeleteCommand = new import_commander33.Command("delete").description("\u
3970
4078
  });
3971
4079
  await client.deletePostFile(projectId, postId, fileId);
3972
4080
  stopSpinner(true, "\uC0AD\uC81C \uC644\uB8CC");
3973
- process.stdout.write(`\uD30C\uC77C(${fileId})\uC774 \uC0AD\uC81C\uB418\uC5C8\uC2B5\uB2C8\uB2E4.
3974
- `);
4081
+ emitDeleteResult(globalOpts, { fileId });
3975
4082
  });
3976
4083
 
3977
4084
  // src/commands/wiki/list.ts
@@ -4278,7 +4385,15 @@ var wikiPageFileListCommand = new import_commander39.Command("list").description
4278
4385
 
4279
4386
  // src/commands/wiki/page-file/upload.ts
4280
4387
  var import_commander40 = require("commander");
4388
+
4389
+ // src/utils/wiki-snippet.ts
4390
+ function wikiInlineImageSnippet(wikiId, attachFileId, name) {
4391
+ return `![${name}](/wikis/${wikiId}/files/${attachFileId})`;
4392
+ }
4393
+
4394
+ // src/commands/wiki/page-file/upload.ts
4281
4395
  var wikiPageFileUploadCommand = new import_commander40.Command("upload").description("\uC704\uD0A4 \uD398\uC774\uC9C0 \uCCA8\uBD80\uD30C\uC77C \uC5C5\uB85C\uB4DC (multipart type \uC21C\uC11C \uAC15\uC81C, ADR-029)").argument("[arg1]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC, Dooray Wiki URL, \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C \uACBD\uB85C").argument("[arg2]", "page-id \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C \uACBD\uB85C").argument("[arg3]", "\uD30C\uC77C \uACBD\uB85C (positional 3\uAC1C \uBAA8\uB4DC)").option("--id <pageId>", "\uC704\uD0A4 \uD398\uC774\uC9C0 ID").option("--url <url>", "Dooray Wiki URL").option("--project <code>", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (--id \uBAA8\uB4DC\uC5D0\uC11C wikiId \uD574\uC11D\uC6A9)").option("--file <path>", "\uC5C5\uB85C\uB4DC\uD560 \uD30C\uC77C \uACBD\uB85C (positional \uB300\uCCB4)").option("--type <type>", "\uD30C\uC77C \uD0C0\uC785: general | inline_image (\uAE30\uBCF8 general)", "general").action(async (arg1, arg2, arg3, opts) => {
4396
+ const globalOpts = wikiPageFileUploadCommand.optsWithGlobals();
4282
4397
  const config = await getConfigOrThrow();
4283
4398
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
4284
4399
  const fileType = opts.type;
@@ -4335,18 +4450,35 @@ var wikiPageFileUploadCommand = new import_commander40.Command("upload").descrip
4335
4450
  try {
4336
4451
  const res = await client.uploadWikiPageFile(wikiId, pageId, filePath, fileType);
4337
4452
  stopSpinner(true, "\uC5C5\uB85C\uB4DC \uC644\uB8CC");
4338
- process.stdout.write(`attachFileId: ${res.result.attachFileId}
4453
+ if (globalOpts.json) {
4454
+ const payload = fileType === "inline_image" ? {
4455
+ ...res.result,
4456
+ markdownSnippet: wikiInlineImageSnippet(
4457
+ wikiId,
4458
+ res.result.attachFileId,
4459
+ res.result.name
4460
+ )
4461
+ } : res.result;
4462
+ printJson(payload);
4463
+ } else if (globalOpts.quiet) {
4464
+ process.stdout.write(`${res.result.id}
4339
4465
  `);
4340
- process.stdout.write(`name: ${res.result.name}
4466
+ } else {
4467
+ process.stdout.write(`attachFileId: ${res.result.attachFileId}
4341
4468
  `);
4342
- process.stdout.write(`size: ${res.result.size}
4469
+ process.stdout.write(`name: ${res.result.name}
4343
4470
  `);
4344
- process.stdout.write(`type: ${res.result.type}
4471
+ process.stdout.write(`size: ${res.result.size}
4345
4472
  `);
4346
- if (fileType === "inline_image") {
4347
- process.stdout.write("\n\uBCF8\uBB38 \uC0BD\uC785\uC6A9 markdown snippet (\uC9C1\uC811 wiki page edit \uC73C\uB85C \uBCF8\uBB38\uC5D0 \uBC15\uC73C\uC138\uC694):\n");
4348
- process.stdout.write(` ![${res.result.name}](/wikis/${wikiId}/files/${res.result.attachFileId})
4473
+ process.stdout.write(`type: ${res.result.type}
4349
4474
  `);
4475
+ if (fileType === "inline_image") {
4476
+ process.stdout.write("\n\uBCF8\uBB38 \uC0BD\uC785\uC6A9 markdown snippet (\uC9C1\uC811 wiki page edit \uC73C\uB85C \uBCF8\uBB38\uC5D0 \uBC15\uC73C\uC138\uC694):\n");
4477
+ process.stdout.write(
4478
+ ` ${wikiInlineImageSnippet(wikiId, res.result.attachFileId, res.result.name)}
4479
+ `
4480
+ );
4481
+ }
4350
4482
  }
4351
4483
  } catch (e) {
4352
4484
  stopSpinner(false);
@@ -4359,6 +4491,7 @@ var import_commander41 = require("commander");
4359
4491
  var import_promises11 = require("fs/promises");
4360
4492
  var import_node_path9 = require("path");
4361
4493
  var wikiPageFileDownloadCommand = new import_commander41.Command("download").description("\uC704\uD0A4 \uD398\uC774\uC9C0 \uCCA8\uBD80\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC").argument("[arg1]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC, Dooray Wiki URL, \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg2]", "page-id \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg3]", "\uD30C\uC77C ID (positional 3\uAC1C \uBAA8\uB4DC)").option("--id <pageId>", "\uC704\uD0A4 \uD398\uC774\uC9C0 ID").option("--url <url>", "Dooray Wiki URL").option("--project <code>", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (--id \uBAA8\uB4DC\uC5D0\uC11C wikiId \uD574\uC11D\uC6A9)").option("--file-id <fileId>", "\uD30C\uC77C ID (positional \uB300\uCCB4)").option("-o, --output <dir>", "\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC", ".").action(async (arg1, arg2, arg3, opts) => {
4494
+ const globalOpts = wikiPageFileDownloadCommand.optsWithGlobals();
4362
4495
  const config = await getConfigOrThrow();
4363
4496
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
4364
4497
  let projectArg;
@@ -4407,11 +4540,11 @@ var wikiPageFileDownloadCommand = new import_commander41.Command("download").des
4407
4540
  startSpinner("\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC911...");
4408
4541
  try {
4409
4542
  const { buffer, fileName } = await client.downloadWikiPageFile(wikiId, pageId, fileId);
4410
- const outputPath = (0, import_node_path9.join)(opts.output, (0, import_node_path9.basename)(fileName));
4543
+ const safeName = (0, import_node_path9.basename)(decodeURIComponent(fileName));
4544
+ const outputPath = (0, import_node_path9.join)(opts.output, safeName);
4411
4545
  await (0, import_promises11.writeFile)(outputPath, Buffer.from(buffer));
4412
4546
  stopSpinner(true, `\uB2E4\uC6B4\uB85C\uB4DC \uC644\uB8CC: ${outputPath}`);
4413
- process.stdout.write(`${outputPath}
4414
- `);
4547
+ emitDownloadResult(globalOpts, { outputPath, fileName: safeName, size: buffer.byteLength });
4415
4548
  } catch (e) {
4416
4549
  stopSpinner(false);
4417
4550
  throw e;
@@ -4423,6 +4556,7 @@ var import_commander42 = require("commander");
4423
4556
  var import_promises12 = require("fs/promises");
4424
4557
  var import_node_path10 = require("path");
4425
4558
  var wikiPageFileDownloadAllCommand = new import_commander42.Command("download-all").description("\uC704\uD0A4 \uD398\uC774\uC9C0\uC758 \uBAA8\uB4E0 \uCCA8\uBD80\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC (general + inline image)").argument("[project]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (\uB610\uB294 \uCCAB \uC778\uC790\uC5D0 Dooray Wiki URL)").argument("[page-id]", "\uC704\uD0A4 \uD398\uC774\uC9C0 ID (project\uC640 \uD568\uAED8 \uC0AC\uC6A9)").option("--id <pageId>", "\uC704\uD0A4 \uD398\uC774\uC9C0 ID (--project \uB3D9\uBC18 \uD544\uC694)").option("--url <url>", "Dooray Wiki URL").option("--project <code>", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (--id \uBAA8\uB4DC\uC5D0\uC11C wikiId \uD574\uC11D\uC6A9)").option("-o, --output <dir>", "\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC", ".").action(async (project, pageIdArg, opts) => {
4559
+ const globalOpts = wikiPageFileDownloadAllCommand.optsWithGlobals();
4426
4560
  const config = await getConfigOrThrow();
4427
4561
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
4428
4562
  const { wikiId, pageId } = await resolveWikiPageInput(client, {
@@ -4443,40 +4577,44 @@ var wikiPageFileDownloadAllCommand = new import_commander42.Command("download-al
4443
4577
  }
4444
4578
  if (allFiles.length === 0) {
4445
4579
  stopSpinner(true, "\uCCA8\uBD80\uD30C\uC77C \uC5C6\uC74C");
4446
- process.stdout.write("\uCCA8\uBD80\uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.\n");
4580
+ if (globalOpts.json) {
4581
+ printJson({ count: 0, succeeded: [], failed: [] });
4582
+ } else if (!globalOpts.quiet) {
4583
+ process.stdout.write("\uCCA8\uBD80\uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.\n");
4584
+ }
4447
4585
  return;
4448
4586
  }
4449
4587
  stopSpinner(true, `${allFiles.length}\uAC1C \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2DC\uC791`);
4450
4588
  await (0, import_promises12.mkdir)(opts.output, { recursive: true });
4451
- let successCount = 0;
4452
- const failures = [];
4589
+ const succeeded = [];
4590
+ const failed = [];
4453
4591
  for (const f of allFiles) {
4454
4592
  try {
4455
4593
  const { buffer, fileName } = await client.downloadWikiPageFile(wikiId, pageId, f.id);
4456
- await (0, import_promises12.writeFile)((0, import_node_path10.join)(opts.output, fileName), Buffer.from(buffer));
4457
- process.stdout.write(`\u2713 ${fileName}
4594
+ const safeName = (0, import_node_path10.basename)(decodeURIComponent(fileName));
4595
+ const outputPath = (0, import_node_path10.join)(opts.output, safeName);
4596
+ await (0, import_promises12.writeFile)(outputPath, Buffer.from(buffer));
4597
+ succeeded.push({ path: outputPath, fileName: safeName });
4598
+ if (!globalOpts.json && !globalOpts.quiet) {
4599
+ process.stdout.write(`\u2713 ${safeName}
4458
4600
  `);
4459
- successCount++;
4601
+ }
4460
4602
  } catch (e) {
4461
- failures.push({ fileId: f.id, error: e instanceof Error ? e.message : String(e) });
4462
- process.stderr.write(`\u2717 ${f.name} (${f.id}): ${e instanceof Error ? e.message : String(e)}
4603
+ failed.push({ fileId: f.id, error: e instanceof Error ? e.message : String(e) });
4604
+ if (!globalOpts.json) {
4605
+ process.stderr.write(`\u2717 ${f.name} (${f.id}): ${e instanceof Error ? e.message : String(e)}
4463
4606
  `);
4607
+ }
4464
4608
  }
4465
4609
  }
4466
- process.stdout.write(`
4467
- \uC644\uB8CC: ${successCount}/${allFiles.length}
4468
- `);
4469
- if (failures.length > 0) {
4470
- throw new DoorayCliError(
4471
- `${failures.length}\uAC1C \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (\uC131\uACF5: ${successCount}/${allFiles.length})`,
4472
- EXIT_API_ERROR
4473
- );
4474
- }
4610
+ emitDownloadAllResult(globalOpts, { count: allFiles.length, succeeded, failed });
4611
+ if (failed.length > 0) process.exitCode = 1;
4475
4612
  });
4476
4613
 
4477
4614
  // src/commands/wiki/page-file/delete.ts
4478
4615
  var import_commander43 = require("commander");
4479
4616
  var wikiPageFileDeleteCommand = new import_commander43.Command("delete").description("\uC704\uD0A4 \uD398\uC774\uC9C0 \uCCA8\uBD80\uD30C\uC77C \uC0AD\uC81C").argument("[arg1]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC, Dooray Wiki URL, \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg2]", "page-id \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg3]", "\uD30C\uC77C ID (positional 3\uAC1C \uBAA8\uB4DC)").option("--id <pageId>", "\uC704\uD0A4 \uD398\uC774\uC9C0 ID").option("--url <url>", "Dooray Wiki URL").option("--project <code>", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (--id \uBAA8\uB4DC\uC5D0\uC11C wikiId \uD574\uC11D\uC6A9)").option("--file-id <fileId>", "\uD30C\uC77C ID (positional \uB300\uCCB4)").action(async (arg1, arg2, arg3, opts) => {
4617
+ const globalOpts = wikiPageFileDeleteCommand.optsWithGlobals();
4480
4618
  const config = await getConfigOrThrow();
4481
4619
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
4482
4620
  let projectArg;
@@ -4526,8 +4664,7 @@ var wikiPageFileDeleteCommand = new import_commander43.Command("delete").descrip
4526
4664
  try {
4527
4665
  await client.deleteWikiPageFile(wikiId, pageId, fileId);
4528
4666
  stopSpinner(true, "\uC0AD\uC81C \uC644\uB8CC");
4529
- process.stdout.write(`\uD30C\uC77C(${fileId})\uC774 \uC0AD\uC81C\uB418\uC5C8\uC2B5\uB2C8\uB2E4.
4530
- `);
4667
+ emitDeleteResult(globalOpts, { fileId });
4531
4668
  } catch (e) {
4532
4669
  stopSpinner(false);
4533
4670
  throw e;
@@ -5575,7 +5712,7 @@ function sanitizeArgv(argv) {
5575
5712
 
5576
5713
  // src/index.ts
5577
5714
  var program = new import_commander61.Command();
5578
- program.name("dooray").description("Dooray REST API CLI").version("0.10.1").option("--json", "JSON \uD615\uC2DD\uC73C\uB85C \uCD9C\uB825").option("--quiet", "ID\uB9CC \uCD9C\uB825").option("--no-color", "\uC0C9\uC0C1 \uBE44\uD65C\uC131\uD654");
5715
+ program.name("dooray").description("Dooray REST API CLI").version("0.12.0").option("--json", "JSON \uD615\uC2DD\uC73C\uB85C \uCD9C\uB825").option("--quiet", "ID\uB9CC \uCD9C\uB825").option("--no-color", "\uC0C9\uC0C1 \uBE44\uD65C\uC131\uD654");
5579
5716
  program.hook("preAction", () => {
5580
5717
  const opts = program.opts();
5581
5718
  if (opts.color === false || process.env.NO_COLOR) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bifos/dooray-cli",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "description": "CLI tool for Dooray project management — AI agent & terminal friendly",
5
5
  "keywords": [
6
6
  "dooray",
@@ -11,14 +11,14 @@
11
11
  ],
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "https://github.com/jon890/dooray-cli.git"
14
+ "url": "git+https://github.com/jon890/dooray-cli.git"
15
15
  },
16
16
  "homepage": "https://github.com/jon890/dooray-cli#readme",
17
17
  "bugs": {
18
18
  "url": "https://github.com/jon890/dooray-cli/issues"
19
19
  },
20
20
  "bin": {
21
- "dooray": "./dist/index.js"
21
+ "dooray": "dist/index.js"
22
22
  },
23
23
  "files": [
24
24
  "dist",
@@ -47,8 +47,13 @@ dooray doctor # 설정 검증
47
47
 
48
48
  > **공통 (post 하위 16개 명령)**: 아래 명령은 `<project> <number>` 외에도 `--id <postId>`, `--url <url>`, 또는 첫 인자에 Dooray URL 을 직접 받는다.
49
49
  > `post get`/`edit`/`done`/`workflow`, `post comment list`/`add`/`edit`/`delete`, `post file list`/`upload`/`download`/`download-all`/`delete`, `post comment file list`/`upload`/`download`/`delete`.
50
- > URL 형식: `https://*.dooray.com/task/to/<postId>` 또는 브라우저 주소창 복사본 `https://*.dooray.com/task/<projectId>/<postId>`.
51
- > **사용자가 URL 줬으면 그대로인자로 전달**하는 것이 가장 빠른 경로 (resolve 단계 단축, ADR-020).
50
+ >
51
+ > 지원 URL 형식 3종 (positional 인자 / `--url` 공통):
52
+ > - `https://*.dooray.com/task/to/<postId>`
53
+ > - `https://*.dooray.com/task/<projectId>/<postId>` — 브라우저 주소창 복사본
54
+ > - `https://*.dooray.com/project/tasks/<postId>` — 프로젝트 업무 목록 → 업무 열기
55
+ >
56
+ > **사용자가 URL을 줬으면 그대로 첫 인자로 전달**하는 것이 가장 빠른 경로 (resolve 단계 단축).
52
57
 
53
58
  | 의도 | 커맨드 |
54
59
  |------|--------|
@@ -59,17 +64,17 @@ dooray doctor # 설정 검증
59
64
  | 프로젝트 멤버 그룹 목록 | `dooray project groups <project>` (ID / Code) |
60
65
  | 프로젝트 태그 목록 | `dooray project tags <project>` (ID / Color / Name / Group / Mandatory) |
61
66
  | 프로젝트 템플릿 목록 | `dooray project templates <project>` (id / templateName) |
62
- | 멤버 상세 (organizationMemberId) | `dooray member get <organizationMemberId>` (cache 우회, ADR-021) |
67
+ | 멤버 상세 (organizationMemberId) | `dooray member get <organizationMemberId>` (cache 우회) |
63
68
  | organization 전체 멤버 검색 | `dooray member search <keyword>` (이름 기본), `--email`(이메일 exact), `--user-code`(사번 like), `--user-code-exact`(사번 exact), `--page`/`--size` |
64
69
  | 업무 목록 조회 | `dooray post list <project>` |
65
- | 업무 검색 | `dooray post search <project> "<keyword>"` |
66
- | 업무 상세 보기 | `dooray post get <project> <number>` |
70
+ | 업무 검색 | `dooray post search <project|projectId> "<keyword>"` — projectId (15+자리 numeric) 직접 입력 시 cache 우회 |
71
+ | 업무 상세 보기 | `dooray post get <project> <number>` (번호) / `dooray post get --id <postId>` (internal ID) |
67
72
  | 업무 생성 | `dooray post create <project> --title "..." [--body "..." \| --body-file <path>]` (`--tag`/`--parent`/`--workflow`/`--milestone` 지원) |
68
- | 템플릿 기반 업무 생성 | `dooray post create <project> --template <name\|id>` — body/users/tags 자동 채움 (사용자 옵션 우선 override, ADR-027) |
73
+ | 템플릿 기반 업무 생성 | `dooray post create <project> --template <name\|id>` — body/users/tags 자동 채움 (사용자 옵션 우선 override) |
69
74
  | 업무 제목/본문 수정 | `dooray post edit <project> <number> --title "..." --body "..."` 또는 `--body-file <path>` |
70
75
  | 업무 완료 처리 | `dooray post done <project> <number>` |
71
76
  | 업무 워크플로우 변경 | `dooray post workflow <project> <number> <workflow>` |
72
- | 댓글 조회 | `dooray post comment list <project> <number>` (`--sort`, `--reverse`, `--latest`, `--since`, `--from-author` 필터. table: Creator 자동 채움, `--json`: raw, ADR-021) |
77
+ | 댓글 조회 | `dooray post comment list <project> <number>` (`--sort`, `--reverse`, `--latest`, `--since`, `--from-author` 필터. table: Creator 자동 채움, `--json`: raw) |
73
78
  | 최신 댓글 조회 | `dooray post comment latest <project> <number>` — 최신 댓글 1개 빠른 조회. `-n <N>`으로 N개 지정 |
74
79
  | 단일 댓글 조회 | `dooray post comment get <project> <number> <comment-id>` — 본문·메타·attachments 직접 fetch. `--id`/`--url` + `--comment-id` 모드 지원 |
75
80
  | 댓글 추가 | `dooray post comment add <project> <number> --body "..."` 또는 `--body-file <path>` |
@@ -83,7 +88,7 @@ dooray doctor # 설정 검증
83
88
  | 위키 페이지 수정 (본문) | `dooray wiki page edit <project> <page-id> --body "..."` 또는 `--body-file ./new.md` |
84
89
  | 위키 페이지 수정 (에디터) | `dooray wiki page edit <project> <page-id>` (플래그 없으면 $EDITOR 열림) |
85
90
  | 위키 페이지 첨부 목록 | `dooray wiki page file list <project> <page-id>` (general + inline 합산, type 컬럼) |
86
- | 위키 페이지 첨부 업로드 | `dooray wiki page file upload <project> <page-id> --file <path> [--type inline_image]` (multipart type 순서 ADR-029) |
91
+ | 위키 페이지 첨부 업로드 | `dooray wiki page file upload <project> <page-id> --file <path> [--type inline_image]` (multipart `type` 필드를 `file` 앞에 전송) |
87
92
  | 위키 페이지 첨부 다운로드 | `dooray wiki page file download <project> <page-id> --file-id <id> -o <dir>` |
88
93
  | 위키 페이지 첨부 일괄 다운로드 | `dooray wiki page file download-all <project> <page-id> -o <dir>` (files + images 전부) |
89
94
  | 위키 페이지 첨부 삭제 | `dooray wiki page file delete <project> <page-id> --file-id <id>` (confirm 없음) |
@@ -104,14 +109,15 @@ dooray doctor # 설정 검증
104
109
  | 전체 첨부파일 다운로드 | `dooray post file download-all <project> <number>` |
105
110
  | 첨부파일 업로드 | `dooray post file upload <project> <number> <file-path>` |
106
111
  | 첨부파일 삭제 | `dooray post file delete <project> <number> <file-id>` |
112
+ | file 명령군 자동화 파싱 | `dooray post file <verb> ... --json` — `download` = `{outputPath,fileName,size}`, `download-all` = `{count,succeeded,failed}` (부분 실패 시 exit 1), `delete` = `{fileId,status}`, `upload` = `res.result` raw |
107
113
  | 댓글 첨부 목록 | `dooray post comment file list <project> <number> <comment-id>` |
108
114
  | 댓글 파일 업로드 | `dooray post comment file upload <project> <number> <comment-id> <path>` |
109
115
  | 댓글 파일 다운로드 | `dooray post comment file download <project> <number> <comment-id> <file-id>` |
110
116
  | 댓글 파일 삭제 | `dooray post comment file delete <project> <number> <comment-id> <file-id> --yes` |
111
- | 참조자(cc) 멤버/그룹 추가 | `dooray post edit <project> <number> --cc-group <code>` — 기존 참조자 유지 + 그룹 추가 (dedupe, ADR-025) |
117
+ | 참조자(cc) 멤버/그룹 추가 | `dooray post edit <project> <number> --cc-group <code>` — 기존 참조자 유지 + 그룹 추가 (dedupe) |
112
118
  | 참조자 전체 교체 | `dooray post edit <project> <number> --cc-clear --cc <name>` — 기존 참조자 비우고 신규 멤버만 |
113
119
  | 신규 업무 + 그룹 cc | `dooray post create <project> --title "..." --cc-group <code>` — 생성 시 그룹 참조자 포함 |
114
- | `--cc-group <code\|id>` / `--mention-group <code\|id>` | 그룹 매칭 — 15+자리 numeric → id 직접 / 그 외 → code matchByName (부분일치, ADR-028) |
120
+ | `--cc-group <code\|id>` / `--mention-group <code\|id>` | 그룹 매칭 — 15+자리 numeric → id 직접 / 그 외 → code matchByName (부분일치) |
115
121
  | 상위 업무 설정/변경 | `dooray post edit <project> <number> --title "<원제목>" --parent <ref>` (`<ref>`: `<project>/<number>` 또는 raw postId. `--title` 필수, unset 미지원) |
116
122
  | `dooray post edit --id <postId> --tag <name>` | 태그 추가 (반복, dedupe) |
117
123
  | `dooray post edit --id <postId> --tag-clear --tag <name>` | 태그 전체 교체 |
@@ -186,7 +192,7 @@ dooray post comment add <project> 42 --body "진행 상황 업데이트: 80% 완
186
192
  ### 시나리오 — 댓글에 스크린샷 자동 첨부
187
193
 
188
194
  스크립트가 스크린샷을 댓글에 삽입하거나, 에이전트가 결과 파일을 첨부 댓글로 보고할 때 사용.
189
- Dooray REST API 가 댓글 전용 attachment endpoint 를 미지원하므로 내부적으로 post-level files API + 댓글 본문 PUT 합성으로 동작 (ADR-024).
195
+ Dooray REST API 가 댓글 전용 attachment endpoint 를 미지원하므로 내부적으로 post-level files API + 댓글 본문 PUT 합성으로 동작한다.
190
196
 
191
197
  ```bash
192
198
  # 1. 댓글을 먼저 만든다 (텍스트만, --json 으로 commentId 획득)
@@ -207,7 +213,17 @@ dooray wiki pages <project> --json
207
213
  dooray wiki page get <project> <pageId> --json
208
214
  ```
209
215
 
210
- ### 위키 페이지 첨부파일 스킬 파일 공유 (Issue #70)
216
+ ### 첨부파일 일괄 다운로드 실패 분리
217
+
218
+ ```bash
219
+ # --json 으로 구조화 출력 → jq 로 성공/실패 분리
220
+ RESULT=$(dooray post file download-all <project> <number> -o ./ --json)
221
+ echo "$RESULT" | jq -r '.failed[] | "\(.fileId): \(.error)"' >&2
222
+ echo "$RESULT" | jq -r '.succeeded[].path'
223
+ # exit code 1 이 설정되어 있으면 실패 있는 상태
224
+ ```
225
+
226
+ ### 위키 페이지 첨부파일 — 스킬 파일 팀 공유
211
227
 
212
228
  **스킬 파일 팀 공유**: 팀 위키에 스킬 파일 (예: `SKILL.md`) 을 `wiki page file upload` 로 첨부 → 팀원이 `wiki page file download-all` 로 일괄 받아 `~/.claude/skills/` 에 그대로 설치.
213
229
 
@@ -222,6 +238,30 @@ dooray wiki page file download-all <project> <page-id> -o ~/.claude/skills/my-sk
222
238
  dooray wiki page file list <project> <page-id>
223
239
  ```
224
240
 
241
+ ### 위키 페이지 인라인 이미지 업로드 후 본문 자동 삽입
242
+
243
+ `--type inline_image` 로 업로드 시 `--json` 응답에 `markdownSnippet` 필드가 포함됩니다.
244
+ jq 로 추출해 본문에 바로 삽입하는 자동화가 가능합니다.
245
+
246
+ ```bash
247
+ # 1. 인라인 이미지 업로드 — --json 으로 markdownSnippet 추출
248
+ SNIPPET=$(dooray wiki page file upload <project> <page-id> \
249
+ --file ./diagram.png --type inline_image --json \
250
+ | jq -r '.markdownSnippet')
251
+ # SNIPPET = "![diagram.png](/wikis/<wikiId>/files/<attachFileId>)"
252
+
253
+ # 2. 기존 본문 조회
254
+ CURRENT_BODY=$(dooray wiki page get <project> <page-id> --json | jq -r '.body.content')
255
+
256
+ # 3. snippet 을 본문 끝에 추가해 업데이트
257
+ NEW_BODY="${CURRENT_BODY}
258
+
259
+ ${SNIPPET}"
260
+ dooray wiki page edit <project> <page-id> --body "$NEW_BODY"
261
+ ```
262
+
263
+ **참고**: `general` 타입은 `markdownSnippet` 없음. `--quiet` 은 id 만 출력 (snippet 미포함).
264
+
225
265
  ### 위키 페이지 댓글 — 회의록 결정사항 자동 누적
226
266
 
227
267
  **회의록 결정사항 자동 누적**: 회의록 위키 페이지에 자동화 봇이 `wiki page comment add` 로 결정사항을 댓글로 누적, `wiki page comment list --latest 20` 으로 최근 토론 흐름 추적.
@@ -262,25 +302,39 @@ dooray wiki page comment latest <project> <page-id>
262
302
 
263
303
  ## 커맨드 상세
264
304
 
265
- ### 업무 식별 방식 (post 하위 16개 명령 공통, ADR-020)
305
+ ### 업무 식별 방식 (post 하위 16개 명령 공통)
266
306
 
267
307
  아래 16개 명령은 4가지 입력을 모두 받는다:
268
308
  `post get`/`edit`/`done`/`workflow`, `post comment list`/`add`/`edit`/`delete`, `post file list`/`upload`/`download`/`download-all`/`delete`, `post comment file list`/`upload`/`download`/`delete`.
269
309
 
270
310
  ```bash
271
- # (1) 기존 positional — 가장 익숙한 형태
311
+ # (1) 기존 positional — 가장 익숙한 형태 (<number> 는 업무 번호 #N)
272
312
  dooray post get <project> 42
273
313
 
274
314
  # (2) Dooray URL을 첫 인자로 — 사용자 메시지에서 URL을 그대로 복사할 때 최적
315
+ # 지원 형식 3종 모두 동일하게 작동
275
316
  dooray post get https://x.dooray.com/task/to/<postId>
317
+ dooray post get https://x.dooray.com/task/<projectId>/<postId>
318
+ dooray post get https://x.dooray.com/project/tasks/<postId>
276
319
 
277
- # (3) --id <postId>
320
+ # (3) --id <postId> — post create 결과의 .id 를 그대로 전달
278
321
  dooray post get --id <postId>
279
322
 
280
- # (4) --url <url>
323
+ # (4) --url <url> — URL 형식 3종 모두 지원
281
324
  dooray post get --url https://x.dooray.com/task/to/<postId>
282
325
  ```
283
326
 
327
+ > ⚠️ **`post create` 결과 `.id` 는 internal postId (19자리 숫자)입니다.**
328
+ > 이 숫자를 `<project> <업무번호>` 의 번호 자리에 넣으면 안내 에러가 발생합니다.
329
+ > 후속 조회·수정·댓글은 반드시 **`--id <postId>`** 를 사용하세요.
330
+ >
331
+ > ```bash
332
+ > POST_ID=$(dooray post create <project> --title "..." --json | jq -r '.id')
333
+ > dooray post get --id "$POST_ID" # ✅ --id 사용
334
+ > dooray post comment add --id "$POST_ID" --body "댓글" # ✅ --id 사용
335
+ > # dooray post get <project> "$POST_ID" # ❌ 안내 에러 발생
336
+ > ```
337
+
284
338
  **우선순위 / 충돌 규칙**: `--id`+`--url` 동시 지정 → 에러.
285
339
  `--id`/`--url`+positional 동시 지정 → 에러.
286
340
  URL/`--id`/`--url` 모드는 standalone API(`getPost(postId)`)로 resolve 단계를 단축.
@@ -339,7 +393,7 @@ dooray post edit <project> <number> --body "새 본문"
339
393
  dooray post edit <project> <number> --title "새 제목" --body-file ./updated.md
340
394
  ```
341
395
 
342
- ### 참조자(cc) / 담당자(to) 변경 — 멤버 · 그룹 (ADR-025)
396
+ ### 참조자(cc) / 담당자(to) 변경 — 멤버 · 그룹
343
397
 
344
398
  ```bash
345
399
  # 기존 참조자 유지 + 그룹 추가 (dedupe: organizationMemberId / projectMemberGroupId)
@@ -361,7 +415,7 @@ dooray post edit --id "$POST_ID" --cc-group qa-team --dry-run --json \
361
415
 
362
416
  > interactive (`$EDITOR`) 모드에서는 위 옵션이 무시되고 stderr 경고가 출력됩니다.
363
417
 
364
- ## 동명이인 우회 — 이메일 / memberId 직접 (Issue #58)
418
+ ## 동명이인 우회 — 이메일 / memberId 직접
365
419
 
366
420
  이름이 동일한 멤버가 여러 명이라 `--cc 홍길동` 이 모호로 실패할 때:
367
421
 
@@ -379,7 +433,7 @@ dooray post edit --id "$POST_ID" --cc "$MEMBER_ID"
379
433
  - 이메일 정규형 — searchMembers exact
380
434
  - 그 외 — 이름 부분일치
381
435
 
382
- ## 신규 업무 생성 후 그룹 cc 첨부 (ADR-025)
436
+ ## 신규 업무 생성 후 그룹 cc 첨부
383
437
 
384
438
  자동화 패턴:
385
439
  1. audit 리포트 분석
@@ -400,7 +454,7 @@ dooray post edit --id "$POST_ID" --cc-group qa-team
400
454
 
401
455
  ---
402
456
 
403
- ## 자식 업무 먼저 → 후속 부모 지정 (Issue #60)
457
+ ## 자식 업무 먼저 → 후속 부모 지정
404
458
 
405
459
  ```bash
406
460
  # 1. 자식 업무 생성 (parent 모르고)
@@ -414,7 +468,7 @@ dooray post edit --id "$CHILD_ID" --title "subtask A" --parent <project>/<parent
414
468
 
415
469
  ---
416
470
 
417
- ## 태그 사후 분류 자동화 (Issue #66)
471
+ ## 태그 사후 분류 자동화
418
472
 
419
473
  분류 분석 결과를 받아 태그를 재분류하는 자동화는 단독 호출 패턴이 효율적:
420
474
 
@@ -426,7 +480,7 @@ dooray post edit --id "$POST_ID" --tag "분류: $CATEGORY"
426
480
  ```
427
481
 
428
482
  태그만 변경하는 시나리오에서 `--title` / `--body` 강제 없음.
429
- mandatory 그룹은 친절한 에러 메시지로 안내 (ADR-019).
483
+ mandatory 그룹은 친절한 에러 메시지로 안내.
430
484
 
431
485
  ---
432
486
 
@@ -452,7 +506,7 @@ dooray post comment latest <project> <number>
452
506
 
453
507
  ---
454
508
 
455
- ## 그룹 멘션 / cc 시 AI agent 동선 (Issue #76, ADR-028)
509
+ ## 그룹 멘션 / cc 시 AI agent 동선
456
510
 
457
511
  자연어 그룹명을 사용자가 지칭했을 때 AI agent 의 의사결정 순서:
458
512
 
@@ -470,7 +524,7 @@ dooray post comment latest <project> <number>
470
524
  AI agent 가 자연어 의도와 가장 가까운 code 선택 후 재시도.
471
525
 
472
526
  3. **모든 컬럼이 빈값일 때 (response shape 이상) 회피**
473
- - ADR-028 fix 이후 거의 발생 안 함 (`fetchAllMemberGroups` 가 nested array 정규화)
527
+ - 최근 수정 이후 거의 발생 안 함 (`fetchAllMemberGroups` 가 nested array 정규화)
474
528
  - 만약 발생 시: 사용자에게 그룹 id (UI 의 그룹 URL 에서 19자리 numeric) 확인 요청
475
529
  - `--cc-group <id>` / `--mention-group <id>` 직접 입력
476
530
  - 또는 그룹 멤버를 개별 `--cc <member>` / `--mention <member>` 로 지정
@@ -554,7 +608,7 @@ dooray feedback --last --title "에러 제목" --body "추가 설명" --dry-run
554
608
  dooray feedback --last --title "에러 제목" --body "추가 설명" # 실제 등록
555
609
  ```
556
610
 
557
- > **참고**: `--last` 모드는 `trackLastRun: true` (ADR-023 opt-in)가 설정된 경우에만 직전 실패 명령이 자동 기록됨.
611
+ > **참고**: `--last` 모드는 `trackLastRun: true` (opt-in)가 설정된 경우에만 직전 실패 명령이 자동 기록됨.
558
612
  > argv는 시크릿 패턴(`--api-key`/`--token`/`Authorization`) 마스킹 후 저장.
559
613
 
560
614
  ## 에러 핸들링
@@ -571,7 +625,7 @@ CLI 에러 발생 시 복구 방법:
571
625
 
572
626
  ---
573
627
 
574
- ## 정형 task 자동화 (Issue #59 / ADR-027)
628
+ ## 정형 task 자동화
575
629
 
576
630
  매주 같은 형식의 task 를 만드는 자동화는 템플릿 + override 패턴이 효율적:
577
631
 
@@ -587,6 +641,24 @@ POST_ID=$(dooray post create <project> \
587
641
  템플릿 본문의 `${year}` / `${month}` 등 매크로는 Dooray 가 자동 치환 (`interpolation=true` 기본).
588
642
  사용자 정의 변수는 미지원 — 필요 시 client 측 string replace 로 처리.
589
643
 
644
+ ## projectId 직접 입력 시나리오
645
+
646
+ AI agent 가 `member=me` 응답에 없는 프로젝트의 업무를 다뤄야 할 때:
647
+
648
+ 1. **사용자가 projectId (15+자리 numeric) 를 줬으면 그대로 명령에 사용**:
649
+ ```bash
650
+ dooray post search 1234567890123456789 "keyword"
651
+ ```
652
+
653
+ 2. **사용자가 코드만 줬고 cache 매칭 실패 (member 아닌 프로젝트)**:
654
+ - 에러 메시지의 안내 확인
655
+ - 사용자에게 "프로젝트 ID 가 필요합니다 — Dooray UI 의 프로젝트 URL 에서 확인 가능" 요청
656
+ - 또는 `dooray project list --type private` 로 private 캐시 갱신 시도
657
+
658
+ 3. **권한 없는 projectId 입력 시**: resolver 통과 후 후속 API 4xx 발생 — 에러 메시지에서 권한 부재 확인 후 사용자에게 보고
659
+
660
+ 권한 검증이 resolver 단보다 한 단계 지연되는 trade-off — AI 친화적 자동화 우선.
661
+
590
662
  ## 캐시
591
663
 
592
664
  프로젝트, 멤버, 워크플로우, 위키 정보는 `~/.dooray/cache/`에 캐시된다.