@seed-design/cli 1.4.0-alpha.0 → 1.4.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.
@@ -1,10 +1,11 @@
1
- import { fetchDocsIndex } from "@/src/utils/fetch";
1
+ import { fetchDocsIndex, fetchLlmsTxt, tryFetchLlmsTxt } from "@/src/utils/fetch";
2
2
  import * as p from "@clack/prompts";
3
3
  import type { CAC } from "cac";
4
4
  import { z } from "zod";
5
5
  import { BASE_URL } from "../constants";
6
6
  import { analytics } from "../utils/analytics";
7
7
  import { highlight } from "../utils/color";
8
+ import { getRawConfig } from "../utils/get-config";
8
9
  import {
9
10
  CliCancelError,
10
11
  CliError,
@@ -18,12 +19,22 @@ const GITHUB_SNIPPET_BASE =
18
19
  "https://raw.githubusercontent.com/daangn/seed-design/refs/heads/dev/docs/registry";
19
20
 
20
21
  const docsOptionsSchema = z.object({
21
- query: z.string().optional(),
22
+ query: z
23
+ .union([z.string(), z.array(z.string())])
24
+ .optional()
25
+ .transform((query) => {
26
+ const normalized = Array.isArray(query) ? query.join(" ") : query;
27
+ const trimmed = normalized?.trim();
28
+ return trimmed ? trimmed : undefined;
29
+ }),
22
30
  baseUrl: z.string().optional(),
31
+ cwd: z.string().default(process.cwd()),
32
+ framework: z.enum(["react", "lynx"]).optional(),
33
+ raw: z.boolean(),
23
34
  });
24
35
 
25
- function buildSnippetUrl(registryId: string, snippetPath: string): string {
26
- return `${GITHUB_SNIPPET_BASE}/${registryId}/${snippetPath}`;
36
+ function buildSnippetUrl(registryPath: string, snippetPath: string): string {
37
+ return `${GITHUB_SNIPPET_BASE}/${registryPath}/${snippetPath}`;
27
38
  }
28
39
 
29
40
  function printDocsResult(item: DocsItem, baseUrl: string) {
@@ -33,14 +44,14 @@ function printDocsResult(item: DocsItem, baseUrl: string) {
33
44
  const lines = [item.id, `- docs: ${docLink}`, `- llms.txt: ${llmsLink}`];
34
45
 
35
46
  if (item.snippetKey && item.snippets && item.snippets.length > 0) {
36
- const [registryId] = item.snippetKey.split(":");
37
- if (registryId === "ui" || registryId === "breeze") {
47
+ const [registryPath] = item.snippetKey.split(":");
48
+ if (registryPath) {
38
49
  if (item.snippets.length === 1) {
39
- lines.push(`- snippet: ${buildSnippetUrl(registryId, item.snippets[0].path)}`);
50
+ lines.push(`- snippet: ${buildSnippetUrl(registryPath, item.snippets[0].path)}`);
40
51
  } else {
41
52
  lines.push("- snippet:");
42
53
  for (const snippet of item.snippets) {
43
- lines.push(` - ${snippet.label}: ${buildSnippetUrl(registryId, snippet.path)}`);
54
+ lines.push(` - ${snippet.label}: ${buildSnippetUrl(registryPath, snippet.path)}`);
44
55
  }
45
56
  }
46
57
  }
@@ -49,17 +60,152 @@ function printDocsResult(item: DocsItem, baseUrl: string) {
49
60
  p.log.message(lines.join("\n"));
50
61
  }
51
62
 
63
+ /**
64
+ * Compute the Levenshtein (edit) distance between two strings.
65
+ * Used to suggest similar valid paths when users make typos.
66
+ */
67
+ function levenshtein(a: string, b: string): number {
68
+ const m = a.length;
69
+ const n = b.length;
70
+ const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
71
+ Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
72
+ );
73
+ for (let i = 1; i <= m; i++) {
74
+ for (let j = 1; j <= n; j++) {
75
+ dp[i][j] = Math.min(
76
+ dp[i - 1][j] + 1,
77
+ dp[i][j - 1] + 1,
78
+ dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0),
79
+ );
80
+ }
81
+ }
82
+ return dp[m][n];
83
+ }
84
+
85
+ /**
86
+ * Find candidates similar to `input` within `maxDistance` edits, sorted by distance.
87
+ */
88
+ function findSimilar(input: string, candidates: string[], maxDistance = 3): string[] {
89
+ const q = input.toLowerCase();
90
+ return candidates
91
+ .map((c) => ({ value: c, dist: levenshtein(q, c.toLowerCase()) }))
92
+ .filter(({ dist }) => dist > 0 && dist <= maxDistance)
93
+ .sort((a, b) => a.dist - b.dist)
94
+ .map(({ value }) => value);
95
+ }
96
+
97
+ /**
98
+ * Build a suggestion hint from a path query by fuzzy-matching each segment
99
+ * against the docs index hierarchy.
100
+ */
101
+ function buildSuggestionHint(segments: string[], categories: DocsCategory[]): string | undefined {
102
+ if (segments.length === 0) return undefined;
103
+
104
+ const suggestions: string[] = [];
105
+
106
+ // Try to fuzzy-match the first segment against category IDs
107
+ const categoryIds = categories.map((c) => c.id);
108
+ const similarCategories = findSimilar(segments[0], categoryIds);
109
+
110
+ if (similarCategories.length === 0) {
111
+ // No similar category — try to find similar full paths across everything
112
+ const allPaths = categories.flatMap((cat) =>
113
+ cat.sections.flatMap((sec) => sec.items.map((item) => `${cat.id}/${sec.id}/${item.id}`)),
114
+ );
115
+ const fullQuery = segments.join("/");
116
+ const similarPaths = findSimilar(fullQuery, allPaths, 5);
117
+ if (similarPaths.length > 0) {
118
+ suggestions.push(...similarPaths.slice(0, 3));
119
+ }
120
+ } else {
121
+ const bestCat = categories.find((c) => c.id === similarCategories[0]);
122
+ if (bestCat && segments.length >= 2) {
123
+ const sectionIds = bestCat.sections.map((s) => s.id);
124
+ const similarSections = findSimilar(segments[1], sectionIds);
125
+
126
+ if (similarSections.length > 0) {
127
+ const bestSec = bestCat.sections.find((s) => s.id === similarSections[0]);
128
+ if (bestSec && segments.length >= 3) {
129
+ const itemIds = bestSec.items.map((i) => i.id);
130
+ const similarItems = findSimilar(segments[2], itemIds);
131
+ for (const item of similarItems.slice(0, 3)) {
132
+ suggestions.push(`${bestCat.id}/${bestSec.id}/${item}`);
133
+ }
134
+ } else {
135
+ for (const sec of similarSections.slice(0, 3)) {
136
+ suggestions.push(`${bestCat.id}/${sec}`);
137
+ }
138
+ }
139
+ } else {
140
+ // Section not found, search items within category
141
+ const allItemIds = bestCat.sections.flatMap((s) =>
142
+ s.items.map((i) => ({ path: `${bestCat.id}/${s.id}/${i.id}`, id: i.id })),
143
+ );
144
+ const similarItems = findSimilar(
145
+ segments[1],
146
+ allItemIds.map((x) => x.id),
147
+ );
148
+ for (const itemId of similarItems.slice(0, 3)) {
149
+ const found = allItemIds.find((x) => x.id === itemId);
150
+ if (found) suggestions.push(found.path);
151
+ }
152
+ }
153
+ } else {
154
+ for (const cat of similarCategories.slice(0, 3)) {
155
+ suggestions.push(cat);
156
+ }
157
+ }
158
+ }
159
+
160
+ if (suggestions.length === 0) return undefined;
161
+
162
+ const lines = ["", "💡 이것을 의미했나요?"];
163
+ for (const s of suggestions) {
164
+ lines.push(` - ${s}`);
165
+ }
166
+ return lines.join("\n");
167
+ }
168
+
52
169
  /**
53
170
  * Parse a path-style query into segments.
54
171
  * e.g. "react/components/action-button" → ["react", "components", "action-button"]
55
172
  */
56
173
  function parseQueryPath(query: string): string[] {
57
174
  return query
58
- .split("/")
175
+ .split(/[\/\s]+/)
59
176
  .map((s) => s.trim())
60
177
  .filter(Boolean);
61
178
  }
62
179
 
180
+ function normalizeRegistryKeySegment(segment: string): string {
181
+ const [, itemId] = segment.split(":");
182
+ return itemId ?? segment;
183
+ }
184
+
185
+ function normalizeDocsQuery({
186
+ query,
187
+ framework,
188
+ categoryIds,
189
+ }: {
190
+ query?: string;
191
+ framework?: string;
192
+ categoryIds: string[];
193
+ }): string | undefined {
194
+ if (!query) return undefined;
195
+
196
+ const segments = parseQueryPath(query).map(normalizeRegistryKeySegment);
197
+ if (!segments.length) return undefined;
198
+
199
+ const [firstSegment] = segments;
200
+ const isCategoryQuery = categoryIds.includes(firstSegment);
201
+
202
+ if (!isCategoryQuery && framework && categoryIds.includes(framework)) {
203
+ return [framework, ...segments].join("/");
204
+ }
205
+
206
+ return segments.join("/");
207
+ }
208
+
63
209
  /**
64
210
  * Search all items across all categories/sections.
65
211
  */
@@ -122,19 +268,31 @@ async function selectCategory(categories: DocsCategory[]): Promise<DocsCategory>
122
268
 
123
269
  export const docsCommand = (cli: CAC) => {
124
270
  cli
125
- .command("docs [query]", "문서 링크, llms.txt 링크, 스니펫 링크를 조회합니다")
271
+ .command("docs [...query]", "문서 링크, llms.txt 링크, 스니펫 링크를 조회합니다")
126
272
  .option("-u, --baseUrl <baseUrl>", `레지스트리의 기본 URL (기본값: ${BASE_URL})`, {
127
273
  default: BASE_URL,
128
274
  })
275
+ .option("--cwd <cwd>", "the working directory. defaults to the current directory.", {
276
+ default: process.cwd(),
277
+ })
278
+ .option("-f, --framework <framework>", "프레임워크 (react 또는 lynx)")
279
+ .option("--raw", "llms.txt 내용을 직접 가져와 출력합니다. LLM 파이프에 유용합니다.", {
280
+ default: false,
281
+ })
129
282
  .example("seed-design docs")
130
283
  .example("seed-design docs action-button")
131
284
  .example("seed-design docs react")
285
+ .example("seed-design docs lynx action-button")
132
286
  .example("seed-design docs react/components")
133
287
  .example("seed-design docs react/components/action-button")
288
+ .example("seed-design docs react/updates/changelog --raw")
134
289
  .action(async (query, opts) => {
135
290
  const startTime = Date.now();
136
291
  const verbose = isVerboseMode(opts);
137
- p.intro("seed-design docs");
292
+ const raw = opts.raw ?? false;
293
+ let trackCwd = process.cwd();
294
+
295
+ if (!raw) p.intro("seed-design docs");
138
296
 
139
297
  try {
140
298
  const parsed = docsOptionsSchema.safeParse({ query, ...opts });
@@ -143,12 +301,24 @@ export const docsCommand = (cli: CAC) => {
143
301
  }
144
302
 
145
303
  const { data: options } = parsed;
304
+ trackCwd = options.cwd;
146
305
  const baseUrl = options.baseUrl ?? BASE_URL;
306
+ const rawConfig = await getRawConfig(options.cwd).catch(() => null);
307
+ const framework = options.framework ?? rawConfig?.framework;
147
308
 
148
- const { start, stop } = p.spinner();
149
- start("문서 목록을 가져오고 있어요...");
309
+ if (options.raw && !options.query) {
310
+ throw new CliError({
311
+ message: "--raw 모드에서는 쿼리가 필요해요.",
312
+ hint: "예: `seed-design docs react/updates/changelog --raw`",
313
+ });
314
+ }
150
315
 
151
316
  const docsIndex = await (async () => {
317
+ if (raw) {
318
+ return await fetchDocsIndex({ baseUrl });
319
+ }
320
+ const { start, stop } = p.spinner();
321
+ start("문서 목록을 가져오고 있어요...");
152
322
  try {
153
323
  const index = await fetchDocsIndex({ baseUrl });
154
324
  stop("문서 목록을 가져왔어요.");
@@ -160,44 +330,67 @@ export const docsCommand = (cli: CAC) => {
160
330
  })();
161
331
 
162
332
  const { categories } = docsIndex;
163
- let selectedItem: DocsItem;
333
+ const docsQuery = normalizeDocsQuery({
334
+ query: options.query,
335
+ framework,
336
+ categoryIds: categories.map((category) => category.id),
337
+ });
338
+ let selectedItem: DocsItem | undefined;
164
339
 
165
- if (options.query) {
166
- const segments = parseQueryPath(options.query);
340
+ // In --raw mode, wrap index resolution in try-catch to allow fallback to direct URL
341
+ const resolveFromIndex = async (): Promise<DocsItem | undefined> => {
342
+ if (docsQuery) {
343
+ const segments = parseQueryPath(docsQuery);
167
344
 
168
- // Try to resolve as path: category / section / item
169
- const matchedCategory = categories.find((c) => c.id === segments[0]);
345
+ // Deep paths (more than category/section/item) can't be resolved from index
346
+ // e.g., react/updates/changelog/react/1.2.9 skip to fallback in --raw mode
347
+ if (raw && segments.length > 3) {
348
+ return undefined;
349
+ }
350
+
351
+ // Try to resolve as path: category / section / item
352
+ const matchedCategory = categories.find((c) => c.id === segments[0]);
170
353
 
171
- if (matchedCategory && segments.length >= 2) {
172
- const matchedSection = matchedCategory.sections.find((s) => s.id === segments[1]);
354
+ if (matchedCategory && segments.length >= 2) {
355
+ const matchedSection = matchedCategory.sections.find((s) => s.id === segments[1]);
173
356
 
174
- if (matchedSection && segments.length >= 3) {
175
- // Full path: category/section/item
176
- const matchedItem = matchedSection.items.find((i) => i.id === segments[2]);
177
- if (matchedItem) {
178
- selectedItem = matchedItem;
179
- } else {
357
+ if (matchedSection && segments.length >= 3) {
358
+ // Full path: category/section/item
359
+ const matchedItem = matchedSection.items.find((i) => i.id === segments[2]);
360
+ if (matchedItem) {
361
+ return matchedItem;
362
+ }
180
363
  // Item not found in section — search within the section
181
364
  const q = segments[2].toLowerCase();
182
365
  const matched = matchedSection.items.filter(
183
366
  (i) => i.id.toLowerCase().includes(q) || i.title.toLowerCase().includes(q),
184
367
  );
185
368
  if (matched.length === 0) {
369
+ const similarItems = findSimilar(
370
+ segments[2],
371
+ matchedSection.items.map((i) => i.id),
372
+ );
373
+ const suggestion =
374
+ similarItems.length > 0
375
+ ? `\n\n💡 이것을 의미했나요?\n${similarItems
376
+ .slice(0, 3)
377
+ .map((s) => ` - ${matchedCategory.id}/${matchedSection.id}/${s}`)
378
+ .join("\n")}`
379
+ : "";
186
380
  throw new CliError({
187
- message: `${highlight(options.query)}: 문서를 찾을 수 없어요.`,
381
+ message: `${highlight(docsQuery)}: 문서를 찾을 수 없어요.${suggestion}`,
188
382
  hint: `\`seed-design docs ${matchedCategory.id}/${matchedSection.id}\`로 목록을 확인해보세요.`,
189
383
  });
190
384
  }
191
385
  if (matched.length === 1) {
192
- selectedItem = matched[0];
193
- } else {
194
- selectedItem = await selectItem(matched);
386
+ return matched[0];
195
387
  }
388
+ return await selectItem(matched);
389
+ }
390
+ if (matchedSection) {
391
+ // category/section — select item within section
392
+ return await selectItem(matchedSection.items);
196
393
  }
197
- } else if (matchedSection) {
198
- // category/section — select item within section
199
- selectedItem = await selectItem(matchedSection.items);
200
- } else {
201
394
  // category/??? — search within category
202
395
  const q = segments[1].toLowerCase();
203
396
  const matched = matchedCategory.sections.flatMap((s) =>
@@ -209,61 +402,82 @@ export const docsCommand = (cli: CAC) => {
209
402
  );
210
403
 
211
404
  if (matched.length === 0) {
405
+ const sectionIds = matchedCategory.sections.map((s) => s.id);
406
+ const similarSections = findSimilar(segments[1], sectionIds);
407
+ const allItemIds = matchedCategory.sections.flatMap((s) =>
408
+ s.items.map((i) => ({
409
+ path: `${matchedCategory.id}/${s.id}/${i.id}`,
410
+ id: i.id,
411
+ })),
412
+ );
413
+ const similarItems = findSimilar(
414
+ segments[1],
415
+ allItemIds.map((x) => x.id),
416
+ );
417
+ const suggestions: string[] = [
418
+ ...similarSections.slice(0, 2).map((s) => `${matchedCategory.id}/${s}`),
419
+ ...similarItems
420
+ .slice(0, 2)
421
+ .map((id) => allItemIds.find((x) => x.id === id)?.path)
422
+ .filter((p): p is string => p != null),
423
+ ];
424
+ const suggestion =
425
+ suggestions.length > 0
426
+ ? `\n\n💡 이것을 의미했나요?\n${suggestions.map((s) => ` - ${s}`).join("\n")}`
427
+ : "";
212
428
  throw new CliError({
213
- message: `${highlight(options.query)}: 문서를 찾을 수 없어요.`,
429
+ message: `${highlight(docsQuery)}: 문서를 찾을 수 없어요.${suggestion}`,
214
430
  hint: `\`seed-design docs ${matchedCategory.id}\`로 목록을 확인해보세요.`,
215
431
  });
216
432
  }
217
433
  if (matched.length === 1) {
218
- selectedItem = matched[0].item;
219
- } else {
220
- const selected = await p.select({
221
- message: `${highlight(segments[1])}에 해당하는 항목을 선택해주세요`,
222
- options: matched.map(({ item, sectionLabel }) => ({
223
- label: `[${sectionLabel}] ${item.title}`,
224
- value: item,
225
- hint: item.description,
226
- })),
227
- });
228
- if (p.isCancel(selected)) throw new CliCancelError();
229
- selectedItem = selected;
434
+ return matched[0].item;
230
435
  }
436
+ const selected = await p.select({
437
+ message: `${highlight(segments[1])}에 해당하는 항목을 선택해주세요`,
438
+ options: matched.map(({ item, sectionLabel }) => ({
439
+ label: `[${sectionLabel}] ${item.title}`,
440
+ value: item,
441
+ hint: item.description,
442
+ })),
443
+ });
444
+ if (p.isCancel(selected)) throw new CliCancelError();
445
+ return selected;
231
446
  }
232
- } else if (matchedCategory) {
233
- // Single segment matching a category — drill into it
234
- if (matchedCategory.sections.length === 1) {
235
- selectedItem = await selectItem(matchedCategory.sections[0].items);
236
- } else {
447
+ if (matchedCategory) {
448
+ // Single segment matching a category — drill into it
449
+ if (matchedCategory.sections.length === 1) {
450
+ return await selectItem(matchedCategory.sections[0].items);
451
+ }
237
452
  const section = await selectSection(matchedCategory.sections);
238
- selectedItem = await selectItem(section.items);
453
+ return await selectItem(section.items);
239
454
  }
240
- } else {
241
455
  // No category match — global search
242
- const matched = searchAllItems(categories, options.query);
456
+ const matched = searchAllItems(categories, docsQuery);
243
457
 
244
458
  if (matched.length === 0) {
459
+ const suggestion = buildSuggestionHint(segments, categories);
245
460
  throw new CliError({
246
- message: `${highlight(options.query)}: 문서를 찾을 수 없어요.`,
461
+ message: `${highlight(docsQuery)}: 문서를 찾을 수 없어요.${suggestion ?? ""}`,
247
462
  hint: "`seed-design docs`로 전체 목록을 확인해보세요.",
248
463
  });
249
464
  }
250
465
  if (matched.length === 1) {
251
- selectedItem = matched[0].item;
252
- } else {
253
- const selected = await p.select({
254
- message: `${highlight(options.query)}에 해당하는 항목을 선택해주세요`,
255
- options: matched.map(({ item, categoryLabel, sectionLabel }) => ({
256
- label: `[${categoryLabel} > ${sectionLabel}] ${item.title}`,
257
- value: item,
258
- hint: item.description,
259
- })),
260
- });
261
- if (p.isCancel(selected)) throw new CliCancelError();
262
- selectedItem = selected;
466
+ return matched[0].item;
263
467
  }
468
+ const selected = await p.select({
469
+ message: `${highlight(docsQuery)}에 해당하는 항목을 선택해주세요`,
470
+ options: matched.map(({ item, categoryLabel, sectionLabel }) => ({
471
+ label: `[${categoryLabel} > ${sectionLabel}] ${item.title}`,
472
+ value: item,
473
+ hint: item.description,
474
+ })),
475
+ });
476
+ if (p.isCancel(selected)) throw new CliCancelError();
477
+ return selected;
264
478
  }
265
- } else {
266
- // Full interactive flow: category → section → item
479
+
480
+ // No query — full interactive flow: category → section → item
267
481
  const category = await selectCategory(categories);
268
482
 
269
483
  let section: DocsSection;
@@ -273,34 +487,94 @@ export const docsCommand = (cli: CAC) => {
273
487
  section = await selectSection(category.sections);
274
488
  }
275
489
 
276
- selectedItem = await selectItem(section.items);
490
+ return await selectItem(section.items);
491
+ };
492
+
493
+ // In --raw mode, swallow index resolution errors and fall back to direct URL fetch
494
+ if (raw) {
495
+ try {
496
+ selectedItem = await resolveFromIndex();
497
+ } catch (error) {
498
+ if (isCliCancelError(error)) throw error;
499
+ // index miss in raw mode → will use fallback
500
+ }
501
+ } else {
502
+ selectedItem = await resolveFromIndex();
277
503
  }
278
504
 
279
- printDocsResult(selectedItem, baseUrl);
280
- p.outro("완료했어요.");
505
+ if (raw) {
506
+ let content: string;
507
+ if (selectedItem) {
508
+ const llmsUrl = `${baseUrl}/llms${selectedItem.docUrl}.txt`;
509
+ content = await fetchLlmsTxt({ url: llmsUrl });
510
+ } else {
511
+ content = await tryFetchLlmsTxt({ baseUrl, query: docsQuery! });
512
+ }
513
+ console.log(content);
514
+ } else {
515
+ printDocsResult(selectedItem!, baseUrl);
516
+ p.outro("완료했어요.");
517
+ }
281
518
 
282
519
  const duration = Date.now() - startTime;
283
520
  try {
284
- await analytics.track(process.cwd(), {
285
- event: "docs",
521
+ await analytics.trackCommandOutcome(trackCwd, {
522
+ command: "docs",
523
+ status: "completed",
286
524
  properties: {
287
525
  query: options.query ?? null,
288
- item_id: selectedItem.id,
289
- has_snippet: !!(selectedItem.snippets && selectedItem.snippets.length > 0),
526
+ item_id: selectedItem?.id ?? options.query ?? null,
527
+ has_snippet: !!(selectedItem?.snippets && selectedItem.snippets.length > 0),
528
+ raw_mode: raw,
290
529
  duration_ms: duration,
291
530
  },
292
531
  });
293
532
  } catch (telemetryError) {
294
533
  if (verbose) {
295
- console.error("[Telemetry] docs tracking failed:", telemetryError);
534
+ console.error("[Telemetry] docs 이벤트 전송에 실패했어요:", telemetryError);
296
535
  }
297
536
  }
298
537
  } catch (error) {
299
538
  if (isCliCancelError(error)) {
300
- p.outro(highlight(error.message));
539
+ try {
540
+ await analytics.trackCommandOutcome(trackCwd, {
541
+ command: "docs",
542
+ status: "cancelled",
543
+ properties: {
544
+ raw_mode: raw,
545
+ duration_ms: Date.now() - startTime,
546
+ },
547
+ });
548
+ } catch (telemetryError) {
549
+ if (verbose) {
550
+ console.error("[Telemetry] docs 이벤트 전송에 실패했어요:", telemetryError);
551
+ }
552
+ }
553
+ if (!raw) p.outro(highlight(error.message));
301
554
  process.exit(0);
302
555
  }
303
556
 
557
+ try {
558
+ await analytics.trackCommandFailure(trackCwd, {
559
+ command: "docs",
560
+ error,
561
+ properties: {
562
+ raw_mode: raw,
563
+ duration_ms: Date.now() - startTime,
564
+ },
565
+ });
566
+ } catch (telemetryError) {
567
+ if (verbose) {
568
+ console.error("[Telemetry] docs 이벤트 전송에 실패했어요:", telemetryError);
569
+ }
570
+ }
571
+
572
+ if (raw) {
573
+ const msg = error instanceof Error ? error.message : String(error);
574
+ console.error(msg);
575
+ process.exit(1);
576
+ }
577
+
304
578
  handleCliError(error, {
305
579
  defaultMessage: "문서 조회에 실패했어요.",
306
580
  defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
@@ -32,6 +32,7 @@ export const initCommand = (cli: CAC) => {
32
32
  .action(async (opts) => {
33
33
  const startTime = Date.now();
34
34
  const verbose = isVerboseMode(opts);
35
+ const trackCwd = typeof opts?.cwd === "string" ? opts.cwd : process.cwd();
35
36
  p.intro("seed-design.json 파일 생성");
36
37
 
37
38
  try {
@@ -85,8 +86,9 @@ export const initCommand = (cli: CAC) => {
85
86
  // init 성공 이벤트 추적
86
87
  const duration = Date.now() - startTime;
87
88
  try {
88
- await analytics.track(options.cwd, {
89
- event: "init",
89
+ await analytics.trackCommandOutcome(options.cwd, {
90
+ command: "init",
91
+ status: "completed",
90
92
  properties: {
91
93
  tsx: config.tsx,
92
94
  rsc: config.rsc,
@@ -97,15 +99,42 @@ export const initCommand = (cli: CAC) => {
97
99
  });
98
100
  } catch (telemetryError) {
99
101
  if (verbose) {
100
- console.error("[Telemetry] init tracking failed:", telemetryError);
102
+ console.error("[Telemetry] init 이벤트 전송에 실패했어요:", telemetryError);
101
103
  }
102
104
  }
103
105
  } catch (error) {
104
106
  if (isCliCancelError(error)) {
107
+ try {
108
+ await analytics.trackCommandOutcome(trackCwd, {
109
+ command: "init",
110
+ status: "cancelled",
111
+ properties: {
112
+ duration_ms: Date.now() - startTime,
113
+ },
114
+ });
115
+ } catch (telemetryError) {
116
+ if (verbose) {
117
+ console.error("[Telemetry] init 이벤트 전송에 실패했어요:", telemetryError);
118
+ }
119
+ }
105
120
  p.outro(highlight(error.message));
106
121
  process.exit(0);
107
122
  }
108
123
 
124
+ try {
125
+ await analytics.trackCommandFailure(trackCwd, {
126
+ command: "init",
127
+ error,
128
+ properties: {
129
+ duration_ms: Date.now() - startTime,
130
+ },
131
+ });
132
+ } catch (telemetryError) {
133
+ if (verbose) {
134
+ console.error("[Telemetry] init 이벤트 전송에 실패했어요:", telemetryError);
135
+ }
136
+ }
137
+
109
138
  handleCliError(error, {
110
139
  defaultMessage: "seed-design.json 파일 생성에 실패했어요.",
111
140
  defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { addAllCommand } from "@/src/commands/add-all";
5
5
  import { compatCommand } from "@/src/commands/compat";
6
6
  import { docsCommand } from "@/src/commands/docs";
7
7
  import { initCommand } from "@/src/commands/init";
8
+
8
9
  import { getPackageInfo } from "@/src/utils/get-package-info";
9
10
  import { cac } from "cac";
10
11