sh-ui-cli 0.74.1 → 0.76.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/bin/sh-ui.mjs CHANGED
@@ -35,7 +35,7 @@ const usage = `사용법:
35
35
  (--apply 로 실제 적용)
36
36
  sh-ui mcp MCP 서버(stdio) 시작 — IDE-내 AI용
37
37
  sh-ui mcp init --client <name> IDE MCP 설정 파일에 sh-ui 엔트리 자동 추가
38
- (claude-code | cursor | claude-desktop)
38
+ (claude-code | cursor | claude-desktop | codex)
39
39
  옵션:
40
40
  --skip-install (add, rename-app) 외부 패키지 자동 설치 생략
41
41
  --diff (add) 파일을 쓰지 않고 변경 내역만 출력
@@ -2,6 +2,32 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
4
4
  "versions": [
5
+ {
6
+ "version": "0.76.0",
7
+ "date": "2026-05-11",
8
+ "title": "minor — `sh-ui mcp init` 가 codex CLI 도 자동 등록",
9
+ "type": "minor",
10
+ "highlights": [
11
+ "**`sh-ui mcp init --client codex` 신규 지원** — `~/.codex/config.toml` 의 `[mcp_servers.sh-ui]` 섹션을 자동 upsert. 이제 codex 사용자도 한 번에 sh-ui MCP 등록.",
12
+ "**TOML 텍스트 기반 머지** — 다른 섹션·주석·공백을 그대로 보존. `[mcp_servers.sh-ui]` 와 그 하위(`.env` 등)만 교체 후 새 블록을 append. 멱등 보장(연속 호출 결과 동일).",
13
+ "**inline-table 정의는 명시적 에러** — 기존 `[mcp_servers]` 부모 섹션 안에 `sh-ui = { ... }` 형태로 정의돼 있으면 자동 갱신 대신 명확한 메시지로 수동 정리 안내. 사용자 config 손상 방지.",
14
+ "claude-code / cursor / claude-desktop 동작은 변화 없음."
15
+ ],
16
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.76.0"
17
+ },
18
+ {
19
+ "version": "0.75.0",
20
+ "date": "2026-05-11",
21
+ "title": "minor — 슬롯 패턴 `render` 로 통일 (asChild 제거)",
22
+ "type": "minor",
23
+ "highlights": [
24
+ "**모든 슬롯 패턴이 `render` prop 으로 통일** — Base UI 가 표준으로 사용하는 패턴. 이전엔 Base UI 기반 컴포넌트(Dialog/Popover/Select/DropdownMenu/Accordion/Combobox/Tabs)는 `render`, sh-ui 자체 컴포넌트(Sidebar/Breadcrumb)는 `asChild` 로 혼재. 이번에 후자도 `render` 로 통일해 사용자 mental model 단순화.",
25
+ "**BreadcrumbLink 에 `render` prop 신규 추가** — 이전엔 슬롯 패턴 자체가 없어 Next Link 와 결합하려면 className 만 수동 복사해야 했음. 이제 `<BreadcrumbLink render={<Link href='/projects'>Projects</Link>} />` 정석.",
26
+ "**SidebarMenuButton·SidebarMenuSubButton 의 `asChild` 제거 → `render` 로 마이그레이션** — children 으로 자식 element 넘기던 패턴 대신 `render` prop. cloneElement 로 props/className/ref 자동 머지 동작은 동일. apps/docs 의 app-sidebar / sidebar-toc demo / sidebar API 문서까지 일괄 마이그.",
27
+ "**summary + JSDoc 갱신** — sidebar / breadcrumb summary 에 render prop 사용 패턴 명시. MCP `sh_ui_list_components` 와 `sh_ui_get_component` 둘 다 일관."
28
+ ],
29
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.75.0"
30
+ },
5
31
  {
6
32
  "version": "0.74.1",
7
33
  "date": "2026-05-11",
@@ -59,17 +59,42 @@ export const BreadcrumbItem = React.forwardRef<
59
59
 
60
60
  /* ───────── Link ───────── */
61
61
 
62
- /** 상위 단계로 이동하는 링크. 라우터 사용 시 `asChild` 패턴 대신 직접 `<a>` 속성으로 전달. */
62
+ export interface BreadcrumbLinkProps
63
+ extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
64
+ /**
65
+ * 다른 anchor 컴포넌트(예: Next.js `Link`)로 대체. sh-ui 의 모든 슬롯
66
+ * 패턴은 `render` 로 통일 (Base UI 표준).
67
+ *
68
+ * <BreadcrumbLink render={<Link href='/projects'>Projects</Link>} />
69
+ */
70
+ render?: React.ReactElement;
71
+ }
72
+
73
+ /** 상위 단계로 이동하는 링크. `render` prop 으로 다른 엘리먼트 슬롯 가능. */
63
74
  export const BreadcrumbLink = React.forwardRef<
64
75
  HTMLAnchorElement,
65
- React.AnchorHTMLAttributes<HTMLAnchorElement>
66
- >(function BreadcrumbLink({ className, ...props }, ref) {
76
+ BreadcrumbLinkProps
77
+ >(function BreadcrumbLink({ className, render, children, ...props }, ref) {
78
+ const mergedClass = cn(styles.breadcrumb__link, className);
79
+ if (render && React.isValidElement(render)) {
80
+ const child = render as React.ReactElement<{
81
+ className?: string;
82
+ children?: React.ReactNode;
83
+ }>;
84
+ return React.cloneElement(
85
+ child,
86
+ {
87
+ ref,
88
+ className: cn(child.props.className, mergedClass),
89
+ ...props,
90
+ } as Record<string, unknown>,
91
+ children ?? child.props.children,
92
+ );
93
+ }
67
94
  return (
68
- <a
69
- ref={ref}
70
- className={cn(styles.breadcrumb__link, className)}
71
- {...props}
72
- />
95
+ <a ref={ref} className={mergedClass} {...props}>
96
+ {children}
97
+ </a>
73
98
  );
74
99
  });
75
100
 
@@ -45,19 +45,44 @@ export const BreadcrumbItem = React.forwardRef<
45
45
  );
46
46
  });
47
47
 
48
+ export interface BreadcrumbLinkProps
49
+ extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
50
+ /**
51
+ * 다른 anchor 컴포넌트(예: Next.js `Link`)로 대체. anchor 중첩 방지 + 라우터
52
+ * 결합용. sh-ui 의 모든 슬롯 패턴은 `render` 로 통일 (Base UI 표준).
53
+ *
54
+ * <BreadcrumbLink render={<Link href='/projects'>Projects</Link>} />
55
+ */
56
+ render?: React.ReactElement;
57
+ }
58
+
48
59
  export const BreadcrumbLink = React.forwardRef<
49
60
  HTMLAnchorElement,
50
- React.AnchorHTMLAttributes<HTMLAnchorElement>
51
- >(function BreadcrumbLink({ className, ...props }, ref) {
61
+ BreadcrumbLinkProps
62
+ >(function BreadcrumbLink({ className, render, children, ...props }, ref) {
63
+ const mergedClass = cn(
64
+ "text-foreground-muted no-underline rounded-[calc(var(--radius)-2px)] px-0.5 transition-colors duration-[var(--duration-fast)] hover:text-foreground hover:underline hover:underline-offset-[3px] focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-ring focus-visible:outline-offset-2 motion-reduce:transition-none",
65
+ className,
66
+ );
67
+ if (render && React.isValidElement(render)) {
68
+ const child = render as React.ReactElement<{
69
+ className?: string;
70
+ children?: React.ReactNode;
71
+ }>;
72
+ return React.cloneElement(
73
+ child,
74
+ {
75
+ ref,
76
+ className: cn(child.props.className, mergedClass),
77
+ ...props,
78
+ } as Record<string, unknown>,
79
+ children ?? child.props.children,
80
+ );
81
+ }
52
82
  return (
53
- <a
54
- ref={ref}
55
- className={cn(
56
- "text-foreground-muted no-underline rounded-[calc(var(--radius)-2px)] px-0.5 transition-colors duration-[var(--duration-fast)] hover:text-foreground hover:underline hover:underline-offset-[3px] focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-ring focus-visible:outline-offset-2 motion-reduce:transition-none",
57
- className,
58
- )}
59
- {...props}
60
- />
83
+ <a ref={ref} className={mergedClass} {...props}>
84
+ {children}
85
+ </a>
61
86
  );
62
87
  });
63
88
 
@@ -59,17 +59,45 @@ export const BreadcrumbItem = React.forwardRef<
59
59
 
60
60
  /* ───────── Link ───────── */
61
61
 
62
- /** 상위 단계로 이동하는 링크. 라우터 사용 시 `asChild` 패턴 대신 직접 `<a>` 속성으로 전달. */
62
+ export interface BreadcrumbLinkProps
63
+ extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
64
+ /**
65
+ * 다른 anchor 컴포넌트(예: Next.js `Link`)로 대체. 자체로 `<a>` 를 렌더
66
+ * 하므로 자식으로 또 다른 `<a>` 를 넣으면 anchor 중첩(invalid HTML). 정석은
67
+ * Base UI 패턴과 동일한 `render` prop:
68
+ *
69
+ * <BreadcrumbLink render={<Link href='/projects'>Projects</Link>} />
70
+ *
71
+ * sh-ui 의 모든 슬롯 패턴은 `render` 로 통일 (Base UI 표준 따름).
72
+ */
73
+ render?: React.ReactElement;
74
+ }
75
+
76
+ /** 상위 단계로 이동하는 링크. `render` prop 으로 다른 엘리먼트(Next Link 등) 슬롯 가능. */
63
77
  export const BreadcrumbLink = React.forwardRef<
64
78
  HTMLAnchorElement,
65
- React.AnchorHTMLAttributes<HTMLAnchorElement>
66
- >(function BreadcrumbLink({ className, ...props }, ref) {
79
+ BreadcrumbLinkProps
80
+ >(function BreadcrumbLink({ className, render, children, ...props }, ref) {
81
+ const mergedClass = cn("sh-ui-breadcrumb__link", className);
82
+ if (render && React.isValidElement(render)) {
83
+ const child = render as React.ReactElement<{
84
+ className?: string;
85
+ children?: React.ReactNode;
86
+ }>;
87
+ return React.cloneElement(
88
+ child,
89
+ {
90
+ ref,
91
+ className: cn(child.props.className, mergedClass),
92
+ ...props,
93
+ } as Record<string, unknown>,
94
+ children ?? child.props.children,
95
+ );
96
+ }
67
97
  return (
68
- <a
69
- ref={ref}
70
- className={cn("sh-ui-breadcrumb__link", className)}
71
- {...props}
72
- />
98
+ <a ref={ref} className={mergedClass} {...props}>
99
+ {children}
100
+ </a>
73
101
  );
74
102
  });
75
103
 
@@ -591,12 +591,13 @@ export interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLB
591
591
  */
592
592
  size?: "sm" | "md" | "lg";
593
593
  /**
594
- * Radix asChild 패턴. children에 `<a>` 다른 요소를 넘겨 button 스타일만 입힐 때 사용.
595
- * Next.js Link와 결합 유용 (`<SidebarMenuButton asChild><Link href=...>`).
594
+ * 다른 엘리먼트(예: Next.js `Link`)로 대체. 자체로 `<button>` 렌더하므로
595
+ * 자식으로 다른 button/anchor 넣지 것. sh-ui 의 모든 슬롯 패턴은
596
+ * `render` 로 통일 (Base UI 표준).
596
597
  *
597
- * @default false
598
+ * <SidebarMenuButton render={<Link href='/'>홈</Link>} />
598
599
  */
599
- asChild?: boolean;
600
+ render?: React.ReactElement;
600
601
  /**
601
602
  * `SidebarTOC` 안에서 활성 섹션 id를 자동 동기화. 이 값과 TOC active id가 일치하면
602
603
  * `isActive`가 자동으로 `true`가 된다.
@@ -610,12 +611,13 @@ export interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLB
610
611
  }
611
612
 
612
613
  /**
613
- * 메뉴 한 줄을 누를 수 있는 버튼. `asChild`로 `<a>` 등 다른 요소에 스타일만 입힐 수 있고,
614
- * `sectionId`(SidebarTOC 활성 동기화) / `panelId`(SidebarPanel 토글)를 지정해 활성 상태를 자동 결정한다.
614
+ * 메뉴 한 줄을 누를 수 있는 버튼. `render` prop 으로 `<a>` 등 다른 엘리먼트로
615
+ * 슬롯 가능. `sectionId`(SidebarTOC 활성 동기화) / `panelId`(SidebarPanel 토글)를
616
+ * 지정해 활성 상태를 자동 결정한다.
615
617
  */
616
618
  export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(
617
619
  function SidebarMenuButton(
618
- { className, isActive, size = "md", asChild, sectionId, panelId, onClick, children, ...props },
620
+ { className, isActive, size = "md", render, sectionId, panelId, onClick, children, ...props },
619
621
  ref
620
622
  ) {
621
623
  const tocActive = useTOCActiveId();
@@ -640,15 +642,19 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
640
642
  styles[`sidebar__menu-button--${size}`],
641
643
  className);
642
644
 
643
- if (asChild && React.isValidElement(children)) {
644
- const child = children as React.ReactElement<Record<string, unknown>>;
645
- const merged: Record<string, unknown> = {
646
- ...props,
647
- onClick: handleClick,
648
- className: cn((child.props.className as string) || "", cls),
649
- "data-active": resolvedIsActive || undefined,
650
- };
651
- return React.cloneElement(child, merged);
645
+ if (render && React.isValidElement(render)) {
646
+ const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
647
+ return React.cloneElement(
648
+ child,
649
+ {
650
+ ref,
651
+ ...props,
652
+ onClick: handleClick,
653
+ className: cn(child.props.className, cls),
654
+ "data-active": resolvedIsActive || undefined,
655
+ } as Record<string, unknown>,
656
+ children ?? child.props.children,
657
+ );
652
658
  }
653
659
 
654
660
  return (
@@ -697,18 +703,17 @@ export interface SidebarMenuSubButtonProps extends React.AnchorHTMLAttributes<HT
697
703
  */
698
704
  size?: "sm" | "md";
699
705
  /**
700
- * Radix asChild 패턴. children에 다른 anchor 컴포넌트(예: Next.js Link) 넘길 사용.
701
- * @default false
706
+ * 다른 anchor 컴포넌트(예: Next.js `Link`) 대체. sh-ui 의 슬롯 패턴은 `render` 로 통일.
702
707
  */
703
- asChild?: boolean;
708
+ render?: React.ReactElement;
704
709
  /** `SidebarTOC`의 활성 섹션 id 자동 동기화. 일치하면 `isActive`가 자동으로 `true`. */
705
710
  sectionId?: string;
706
711
  }
707
712
 
708
- /** 서브 메뉴 항목 내부의 링크(`<a>`). `sectionId`로 SidebarTOC 활성 상태와 연동. */
713
+ /** 서브 메뉴 항목 내부의 링크. `render` prop 으로 다른 엘리먼트 슬롯 가능. `sectionId`로 TOC 활성 연동. */
709
714
  export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarMenuSubButtonProps>(
710
715
  function SidebarMenuSubButton(
711
- { className, isActive, size = "md", asChild, sectionId, children, ...props },
716
+ { className, isActive, size = "md", render, sectionId, children, ...props },
712
717
  ref
713
718
  ) {
714
719
  const tocActive = useTOCActiveId();
@@ -718,14 +723,18 @@ export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarM
718
723
  styles[`sidebar__menu-sub-button--${size}`],
719
724
  className);
720
725
 
721
- if (asChild && React.isValidElement(children)) {
722
- const child = children as React.ReactElement<Record<string, unknown>>;
723
- const merged: Record<string, unknown> = {
724
- ...props,
725
- className: cn((child.props.className as string) || "", cls),
726
- "data-active": resolvedIsActive || undefined,
727
- };
728
- return React.cloneElement(child, merged);
726
+ if (render && React.isValidElement(render)) {
727
+ const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
728
+ return React.cloneElement(
729
+ child,
730
+ {
731
+ ref,
732
+ ...props,
733
+ className: cn(child.props.className, cls),
734
+ "data-active": resolvedIsActive || undefined,
735
+ } as Record<string, unknown>,
736
+ children ?? child.props.children,
737
+ );
729
738
  }
730
739
 
731
740
  return (
@@ -930,9 +939,7 @@ export function SidebarCollapsibleContent({ children }: { children: React.ReactN
930
939
  * <SidebarTOC sectionIds={["intro", "install", "usage"]}>
931
940
  * <SidebarMenu>
932
941
  * <SidebarMenuItem>
933
- * <SidebarMenuButton sectionId="intro" asChild>
934
- * <a href="#intro">Intro</a>
935
- * </SidebarMenuButton>
942
+ * <SidebarMenuButton sectionId="intro" render={<a href="#intro">Intro</a>} />
936
943
  * </SidebarMenuItem>
937
944
  * ...
938
945
  * </SidebarMenu>
@@ -350,7 +350,12 @@ export function SidebarMenuItem({ className, ...props }: React.HTMLAttributes<HT
350
350
  export interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
351
351
  isActive?: boolean;
352
352
  size?: "sm" | "md" | "lg";
353
- asChild?: boolean;
353
+ /**
354
+ * 다른 엘리먼트(예: Next.js `Link`)로 대체. sh-ui 의 슬롯 패턴은 `render` 로 통일.
355
+ *
356
+ * <SidebarMenuButton render={<Link href='/'>홈</Link>} />
357
+ */
358
+ render?: React.ReactElement;
354
359
  sectionId?: string;
355
360
  panelId?: string;
356
361
  }
@@ -365,7 +370,7 @@ const menuButtonSize = {
365
370
  };
366
371
 
367
372
  export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(
368
- function SidebarMenuButton({ className, isActive, size = "md", asChild, sectionId, panelId, onClick, children, ...props }, ref) {
373
+ function SidebarMenuButton({ className, isActive, size = "md", render, sectionId, panelId, onClick, children, ...props }, ref) {
369
374
  const tocActive = useTOCActiveId();
370
375
  const ctx = React.useContext(SidebarContext);
371
376
  const panelActive = panelId != null && ctx?.activePanel === panelId;
@@ -378,15 +383,19 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
378
383
 
379
384
  const cls = cn(menuButtonBase, menuButtonSize[size], className);
380
385
 
381
- if (asChild && React.isValidElement(children)) {
382
- const child = children as React.ReactElement<Record<string, unknown>>;
383
- const merged: Record<string, unknown> = {
384
- ...props,
385
- onClick: handleClick,
386
- className: cn((child.props.className as string) || "", cls),
387
- "data-active": resolvedIsActive || undefined,
388
- };
389
- return React.cloneElement(child, merged);
386
+ if (render && React.isValidElement(render)) {
387
+ const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
388
+ return React.cloneElement(
389
+ child,
390
+ {
391
+ ref,
392
+ ...props,
393
+ onClick: handleClick,
394
+ className: cn(child.props.className, cls),
395
+ "data-active": resolvedIsActive || undefined,
396
+ } as Record<string, unknown>,
397
+ children ?? child.props.children,
398
+ );
390
399
  }
391
400
 
392
401
  return (
@@ -414,7 +423,10 @@ export function SidebarMenuSubItem({ className, ...props }: React.HTMLAttributes
414
423
  export interface SidebarMenuSubButtonProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
415
424
  isActive?: boolean;
416
425
  size?: "sm" | "md";
417
- asChild?: boolean;
426
+ /**
427
+ * 다른 anchor 컴포넌트(예: Next.js `Link`)로 대체. sh-ui 의 슬롯 패턴은 `render` 로 통일.
428
+ */
429
+ render?: React.ReactElement;
418
430
  sectionId?: string;
419
431
  }
420
432
 
@@ -422,19 +434,23 @@ const menuSubButtonBase =
422
434
  "flex items-center gap-[var(--space-2)] h-7 px-[var(--space-2)] rounded-[calc(var(--radius)-2px)] text-[0.8125rem] text-[var(--sidebar-fg)] no-underline transition-[background-color,color] duration-[var(--duration-fast)] min-w-0 [&>span]:flex-1 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:[text-overflow:ellipsis] [&>span]:whitespace-nowrap hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-accent-fg)] data-[active]:bg-primary data-[active]:text-primary-foreground data-[active]:font-semibold data-[active]:hover:bg-primary-hover motion-reduce:transition-none";
423
435
 
424
436
  export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarMenuSubButtonProps>(
425
- function SidebarMenuSubButton({ className, isActive, size = "md", asChild, sectionId, children, ...props }, ref) {
437
+ function SidebarMenuSubButton({ className, isActive, size = "md", render, sectionId, children, ...props }, ref) {
426
438
  const tocActive = useTOCActiveId();
427
439
  const resolvedIsActive = isActive ?? (sectionId != null ? tocActive === sectionId : undefined);
428
440
  const cls = cn(menuSubButtonBase, size === "sm" && "text-[length:var(--text-xs)]", className);
429
441
 
430
- if (asChild && React.isValidElement(children)) {
431
- const child = children as React.ReactElement<Record<string, unknown>>;
432
- const merged: Record<string, unknown> = {
433
- ...props,
434
- className: cn((child.props.className as string) || "", cls),
435
- "data-active": resolvedIsActive || undefined,
436
- };
437
- return React.cloneElement(child, merged);
442
+ if (render && React.isValidElement(render)) {
443
+ const child = render as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
444
+ return React.cloneElement(
445
+ child,
446
+ {
447
+ ref,
448
+ ...props,
449
+ className: cn(child.props.className, cls),
450
+ "data-active": resolvedIsActive || undefined,
451
+ } as Record<string, unknown>,
452
+ children ?? child.props.children,
453
+ );
438
454
  }
439
455
 
440
456
  return <a ref={ref} className={cls} data-active={resolvedIsActive || undefined} {...props}>{children}</a>;
@@ -591,12 +591,13 @@ export interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLB
591
591
  */
592
592
  size?: "sm" | "md" | "lg";
593
593
  /**
594
- * Radix asChild 패턴. children에 `<a>` 다른 요소를 넘겨 button 스타일만 입힐 때 사용.
595
- * Next.js Link와 결합 유용 (`<SidebarMenuButton asChild><Link href=...>`).
594
+ * 다른 엘리먼트(예: Next.js `Link`)로 대체. 자체로 `<button>` 렌더하므로
595
+ * 자식으로 다른 button/anchor 넣지 것. sh-ui 의 모든 슬롯 패턴은
596
+ * `render` 로 통일 (Base UI 표준).
596
597
  *
597
- * @default false
598
+ * <SidebarMenuButton render={<Link href='/'>홈</Link>} />
598
599
  */
599
- asChild?: boolean;
600
+ render?: React.ReactElement;
600
601
  /**
601
602
  * `SidebarTOC` 안에서 활성 섹션 id를 자동 동기화. 이 값과 TOC active id가 일치하면
602
603
  * `isActive`가 자동으로 `true`가 된다.
@@ -610,12 +611,14 @@ export interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLB
610
611
  }
611
612
 
612
613
  /**
613
- * 메뉴 한 줄을 누를 수 있는 버튼. `asChild`로 `<a>` 등 다른 요소에 스타일만 입힐 수 있고,
614
- * `sectionId`(SidebarTOC 활성 동기화) / `panelId`(SidebarPanel 토글)를 지정해 활성 상태를 자동 결정한다.
614
+ * 메뉴 한 줄을 누를 수 있는 버튼. `render` prop 으로 `<a>` 등 다른 엘리먼트로
615
+ * 슬롯 가능 (Next.js Link 결합). `sectionId`/`panelId` 활성 상태 자동 결정.
616
+ *
617
+ * <SidebarMenuButton render={<Link href='/projects'>All projects</Link>} />
615
618
  */
616
619
  export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(
617
620
  function SidebarMenuButton(
618
- { className, isActive, size = "md", asChild, sectionId, panelId, onClick, children, ...props },
621
+ { className, isActive, size = "md", render, sectionId, panelId, onClick, children, ...props },
619
622
  ref
620
623
  ) {
621
624
  const tocActive = useTOCActiveId();
@@ -640,15 +643,22 @@ export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenu
640
643
  `sh-ui-sidebar__menu-button--${size}`,
641
644
  className);
642
645
 
643
- if (asChild && React.isValidElement(children)) {
644
- const child = children as React.ReactElement<Record<string, unknown>>;
645
- const merged: Record<string, unknown> = {
646
- ...props,
647
- onClick: handleClick,
648
- className: cn((child.props.className as string) || "", cls),
649
- "data-active": resolvedIsActive || undefined,
650
- };
651
- return React.cloneElement(child, merged);
646
+ if (render && React.isValidElement(render)) {
647
+ const child = render as React.ReactElement<{
648
+ className?: string;
649
+ children?: React.ReactNode;
650
+ }>;
651
+ return React.cloneElement(
652
+ child,
653
+ {
654
+ ref,
655
+ ...props,
656
+ onClick: handleClick,
657
+ className: cn(child.props.className, cls),
658
+ "data-active": resolvedIsActive || undefined,
659
+ } as Record<string, unknown>,
660
+ children ?? child.props.children,
661
+ );
652
662
  }
653
663
 
654
664
  return (
@@ -697,18 +707,20 @@ export interface SidebarMenuSubButtonProps extends React.AnchorHTMLAttributes<HT
697
707
  */
698
708
  size?: "sm" | "md";
699
709
  /**
700
- * Radix asChild 패턴. children에 다른 anchor 컴포넌트(예: Next.js Link) 넘길 사용.
701
- * @default false
710
+ * 다른 anchor 컴포넌트(예: Next.js `Link`) 대체. sh-ui 의 모든 슬롯
711
+ * 패턴은 `render` 로 통일 (Base UI 표준).
712
+ *
713
+ * <SidebarMenuSubButton render={<Link href='/...'>서브</Link>} />
702
714
  */
703
- asChild?: boolean;
715
+ render?: React.ReactElement;
704
716
  /** `SidebarTOC`의 활성 섹션 id 자동 동기화. 일치하면 `isActive`가 자동으로 `true`. */
705
717
  sectionId?: string;
706
718
  }
707
719
 
708
- /** 서브 메뉴 항목 내부의 링크(`<a>`). `sectionId`로 SidebarTOC 활성 상태와 연동. */
720
+ /** 서브 메뉴 항목 내부의 링크. `render` prop 으로 다른 엘리먼트 슬롯 가능. `sectionId`로 TOC 활성 연동. */
709
721
  export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarMenuSubButtonProps>(
710
722
  function SidebarMenuSubButton(
711
- { className, isActive, size = "md", asChild, sectionId, children, ...props },
723
+ { className, isActive, size = "md", render, sectionId, children, ...props },
712
724
  ref
713
725
  ) {
714
726
  const tocActive = useTOCActiveId();
@@ -718,14 +730,21 @@ export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarM
718
730
  `sh-ui-sidebar__menu-sub-button--${size}`,
719
731
  className);
720
732
 
721
- if (asChild && React.isValidElement(children)) {
722
- const child = children as React.ReactElement<Record<string, unknown>>;
723
- const merged: Record<string, unknown> = {
724
- ...props,
725
- className: cn((child.props.className as string) || "", cls),
726
- "data-active": resolvedIsActive || undefined,
727
- };
728
- return React.cloneElement(child, merged);
733
+ if (render && React.isValidElement(render)) {
734
+ const child = render as React.ReactElement<{
735
+ className?: string;
736
+ children?: React.ReactNode;
737
+ }>;
738
+ return React.cloneElement(
739
+ child,
740
+ {
741
+ ref,
742
+ ...props,
743
+ className: cn(child.props.className, cls),
744
+ "data-active": resolvedIsActive || undefined,
745
+ } as Record<string, unknown>,
746
+ children ?? child.props.children,
747
+ );
729
748
  }
730
749
 
731
750
  return (
@@ -935,9 +954,7 @@ export function SidebarCollapsibleContent({ children }: { children: React.ReactN
935
954
  * <SidebarTOC sectionIds={["intro", "install", "usage"]}>
936
955
  * <SidebarMenu>
937
956
  * <SidebarMenuItem>
938
- * <SidebarMenuButton sectionId="intro" asChild>
939
- * <a href="#intro">Intro</a>
940
- * </SidebarMenuButton>
957
+ * <SidebarMenuButton sectionId="intro" render={<a href="#intro">Intro</a>} />
941
958
  * </SidebarMenuItem>
942
959
  * ...
943
960
  * </SidebarMenu>
@@ -27,9 +27,9 @@
27
27
  "tabs": "탭 — separate exports: Tabs / TabsList / TabsTrigger / TabsContent / TabsIndicator (Base UI). dot syntax(`Tabs.List`) 아님.",
28
28
  "accordion": "펼침/접힘 아코디언 — separate exports: Accordion / AccordionItem / AccordionTrigger / AccordionContent (Base UI). single/multiple 모드. AccordionTrigger 는 자체로 `<button>` — 다른 엘리먼트로 슬롯하려면 `render` prop 사용.",
29
29
  "carousel": "슬라이드 캐러셀 — Embla 기반, autoplay/autoscroll.",
30
- "sidebar": "앱 사이드바 — collapsible, SidebarMenu/SidebarGroup 조합.",
30
+ "sidebar": "앱 사이드바 — SidebarProvider / Sidebar / SidebarInset / SidebarTrigger / SidebarHeader/Content/Footer / SidebarGroup·GroupLabel·GroupContent / SidebarMenu·MenuItem·MenuButton / SidebarMenuSub·SubItem·SubButton / SidebarCollapsible 등. SidebarMenuButton·SidebarMenuSubButton 은 자체 button/anchor — Next Link 등 다른 엘리먼트로 슬롯하려면 `render` prop 사용 (`<SidebarMenuButton render={<Link href='/'>홈</Link>} />`). asChild 는 v0.75 에서 제거됨.",
31
31
  "header": "앱 헤더 — 로고/네비/액션 compound, 데스크탑 inline / 모바일 drawer 자동 전환. drawer focus trap·ESC·focus restore. HeaderNav value(controlled) / defaultValue+onValueChange(uncontrolled) 로 자식 HeaderItem active 자동 매칭 (aria-current 자동, match 커스터마이즈). HeaderMenu(서브메뉴, 데스크탑 portal dropdown / drawer collapsible) · HeaderNavGroup(섹션 라벨) · HeaderDesktopOnly/HeaderMobileOnly(가시성 토글, drawer 이동 없음). variant(solid/transparent/blur) · stickyHide(prefers-reduced-motion 존중, 컨테이너 스크롤 자동 감지) 정식 지원. backdrop-filter @supports 폴백.",
32
- "breadcrumb": "경로 내비게이션 — separate exports: Breadcrumb / BreadcrumbList / BreadcrumbItem / BreadcrumbLink / BreadcrumbPage / BreadcrumbSeparator / BreadcrumbEllipsis. dot syntax 아님. aria-current 자동.",
32
+ "breadcrumb": "경로 내비게이션 — separate exports: Breadcrumb / BreadcrumbList / BreadcrumbItem / BreadcrumbLink / BreadcrumbPage / BreadcrumbSeparator / BreadcrumbEllipsis. BreadcrumbLink 자체 `<a>` — Next Link 등 다른 엘리먼트로 슬롯하려면 `render` prop 사용 (`<BreadcrumbLink render={<Link href='/...'>Projects</Link>} />`). aria-current 자동.",
33
33
  "pagination": "페이지 단위 내비게이션 — separate exports: Pagination / PaginationContent / PaginationItem / PaginationLink / PaginationPrevious / PaginationNext / PaginationEllipsis. getPaginationRange 유틸 동봉. aria-current 자동.",
34
34
  "avatar": "프로필 아바타 — 이미지 fallback → initials (Base UI).",
35
35
  "badge": "상태 뱃지 — variant, size.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.74.1",
3
+ "version": "0.76.0",
4
4
  "description": "sh-ui CLI — 프로젝트 스캐폴드(create) + 컴포넌트 추가(add/list/remove) + IDE-내 AI용 MCP 서버",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,10 +27,10 @@
27
27
  "dependencies": {
28
28
  "@inquirer/prompts": "^7.0.0",
29
29
  "@modelcontextprotocol/sdk": "^1.0.0",
30
- "zod": "^4.3.6"
30
+ "zod": "^4.4.3"
31
31
  },
32
32
  "devDependencies": {
33
- "fs-extra": "^11.2.0",
33
+ "fs-extra": "^11.3.5",
34
34
  "vitest": "^3.2.4"
35
35
  },
36
36
  "publishConfig": {
package/src/mcp-init.mjs CHANGED
@@ -4,20 +4,25 @@
4
4
  // claude-code — project: <cwd>/.mcp.json, user: ~/.claude.json
5
5
  // cursor — project: <cwd>/.cursor/mcp.json, user: ~/.cursor/mcp.json
6
6
  // claude-desktop — user 만 (OS 별 경로 자동 분기)
7
+ // codex — user 만 (~/.codex/config.toml — TOML 포맷)
7
8
  //
8
9
  // 주의: Claude Code 의 user-scope 설정은 `~/.claude/mcp.json` 같은 별도
9
10
  // 파일이 아니라 사용자 settings 전체가 들어가는 단일 JSON `~/.claude.json`
10
11
  // 이다. 이 파일에는 mcpServers 외에도 projects·history 등 다른 키가 같이
11
- // 들어 있으므로, 머지 시 다른 키를 절대 건드리지 않아야 한다.
12
+ // 들어 있으므로, 머지 시 다른 키를 절대 건드리지 않아야 한다. codex 의
13
+ // config.toml 도 같은 원칙 — 다른 섹션은 건드리지 않고 `[mcp_servers.sh-ui]`
14
+ // 만 upsert 한다.
12
15
  //
13
- // 동작: 기존 JSON mcpServers.sh-ui 머지(있으면 덮어쓰기), 디렉토리 자동 생성.
16
+ // 동작: JSON 클라이언트는 mcpServers.sh-ui 머지(있으면 덮어쓰기). codex
17
+ // `[mcp_servers.sh-ui]` 섹션 텍스트를 통째로 교체(존재하지 않으면 append).
18
+ // 디렉토리는 자동 생성.
14
19
 
15
20
  import { readFile, writeFile, mkdir } from "node:fs/promises";
16
21
  import { existsSync } from "node:fs";
17
22
  import { dirname, resolve, relative } from "node:path";
18
23
  import { homedir, platform as osPlatform } from "node:os";
19
24
 
20
- const CLIENTS = ["claude-code", "cursor", "claude-desktop"];
25
+ const CLIENTS = ["claude-code", "cursor", "claude-desktop", "codex"];
21
26
 
22
27
  /**
23
28
  * `npx -y <cliName> mcp` 형태의 MCP 엔트리 빌더.
@@ -68,12 +73,138 @@ function resolveConfigPath(client, scope, cwd) {
68
73
  // linux + 기타
69
74
  return resolve(home, ".config", "Claude", "claude_desktop_config.json");
70
75
  }
76
+ if (client === "codex") {
77
+ if (scope !== "user") {
78
+ throw new Error(
79
+ "codex 는 user 스코프만 지원합니다. --scope user 또는 --scope 생략.",
80
+ );
81
+ }
82
+ return resolve(home, ".codex", "config.toml");
83
+ }
71
84
  throw new Error(`알 수 없는 클라이언트: ${client}. 허용: ${CLIENTS.join(", ")}`);
72
85
  }
73
86
 
74
- /** 클라이언트별 기본 스코프. claude-desktop user 강제. */
87
+ /** 클라이언트별 기본 스코프. claude-desktop·codex user 강제. */
75
88
  function defaultScope(client) {
76
- return client === "claude-desktop" ? "user" : "project";
89
+ return client === "claude-desktop" || client === "codex" ? "user" : "project";
90
+ }
91
+
92
+ /**
93
+ * codex `~/.codex/config.toml` 의 `[mcp_servers.<name>]` 섹션 upsert.
94
+ *
95
+ * 텍스트 기반 — 다른 섹션·주석·공백을 보존하기 위해 TOML 파서를 안 쓴다.
96
+ * `[mcp_servers.<name>]` 와 그 하위 (`[mcp_servers.<name>.env]` 등) 모두
97
+ * 제거 후, 새 블록을 파일 끝에 append.
98
+ *
99
+ * 한계: 사용자가 inline-table 형태(`[mcp_servers]` 부모 섹션 안에
100
+ * `sh-ui = { command = ... }`)로 정의해두면 그건 감지·정리하지 못한다.
101
+ * 그 경우 새 섹션과 충돌하므로 detect → 명시적 에러.
102
+ */
103
+ export function upsertCodexMcpServer(raw, name, entry) {
104
+ detectInlineMcpServer(raw, name);
105
+ const had = hasCodexMcpServerSection(raw, name);
106
+
107
+ const lines = raw.split("\n");
108
+ const out = [];
109
+ let i = 0;
110
+ while (i < lines.length) {
111
+ if (isOurSectionHeader(lines[i], name)) {
112
+ // 섹션 본문 끝(다음 헤더 직전 또는 EOF)까지 skip
113
+ i++;
114
+ while (i < lines.length && !isAnySectionHeader(lines[i])) i++;
115
+ // 새 블록을 끝에 붙일 거라, 우리가 지운 섹션 직전의 빈 줄은 정리
116
+ while (out.length && out[out.length - 1].trim() === "") out.pop();
117
+ } else {
118
+ out.push(lines[i]);
119
+ i++;
120
+ }
121
+ }
122
+
123
+ let head = out.join("\n").replace(/\s+$/, "");
124
+ const block = renderCodexBlock(name, entry);
125
+ const next = head === "" ? block + "\n" : head + "\n\n" + block + "\n";
126
+ return { text: next, had };
127
+ }
128
+
129
+ function isAnySectionHeader(line) {
130
+ return /^\s*\[[^\]]+\]\s*$/.test(line);
131
+ }
132
+
133
+ function isOurSectionHeader(line, name) {
134
+ const m = line.match(/^\s*\[([^\]]+)\]\s*$/);
135
+ if (!m) return false;
136
+ const path = m[1].trim();
137
+ const bare = `mcp_servers.${name}`;
138
+ const quoted = `mcp_servers."${name}"`;
139
+ return (
140
+ path === bare ||
141
+ path === quoted ||
142
+ path.startsWith(bare + ".") ||
143
+ path.startsWith(quoted + ".")
144
+ );
145
+ }
146
+
147
+ function hasCodexMcpServerSection(raw, name) {
148
+ return raw.split("\n").some((line) => {
149
+ const m = line.match(/^\s*\[([^\]]+)\]\s*$/);
150
+ if (!m) return false;
151
+ const path = m[1].trim();
152
+ return path === `mcp_servers.${name}` || path === `mcp_servers."${name}"`;
153
+ });
154
+ }
155
+
156
+ /** `[mcp_servers]` 부모 섹션 안에 `<name> =` 로 inline 정의돼 있으면 에러. */
157
+ function detectInlineMcpServer(raw, name) {
158
+ const lines = raw.split("\n");
159
+ let inMcpServers = false;
160
+ for (const line of lines) {
161
+ const headerMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
162
+ if (headerMatch) {
163
+ inMcpServers = headerMatch[1].trim() === "mcp_servers";
164
+ continue;
165
+ }
166
+ if (!inMcpServers) continue;
167
+ const keyRegex = new RegExp(`^\\s*(?:${escapeReg(name)}|"${escapeReg(name)}")\\s*=`);
168
+ if (keyRegex.test(line)) {
169
+ throw new Error(
170
+ `기존 config.toml 의 [mcp_servers] 섹션 안에 '${name}' 가 inline-table 로 정의돼 있습니다.\n` +
171
+ `자동 갱신을 지원하지 않으니, 해당 줄을 직접 제거한 뒤 다시 실행하세요.`,
172
+ );
173
+ }
174
+ }
175
+ }
176
+
177
+ function escapeReg(s) {
178
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
179
+ }
180
+
181
+ function renderCodexBlock(name, entry) {
182
+ const header = `[mcp_servers.${tomlBareOrQuoted(name)}]`;
183
+ const lines = [header];
184
+ lines.push(`command = ${tomlBasicString(entry.command)}`);
185
+ lines.push(`args = ${tomlInlineArray(entry.args)}`);
186
+ if (entry.env && Object.keys(entry.env).length > 0) {
187
+ lines.push("");
188
+ lines.push(`[mcp_servers.${tomlBareOrQuoted(name)}.env]`);
189
+ for (const [k, v] of Object.entries(entry.env)) {
190
+ lines.push(`${tomlBareOrQuoted(k)} = ${tomlBasicString(v)}`);
191
+ }
192
+ }
193
+ return lines.join("\n");
194
+ }
195
+
196
+ function tomlBareOrQuoted(key) {
197
+ return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlBasicString(key);
198
+ }
199
+
200
+ function tomlBasicString(s) {
201
+ // TOML basic string 은 JSON string 과 escape 규칙이 호환 (\" \\ \n \t \r \b \f \uXXXX).
202
+ // ASCII 위주 값(npx, sh-ui-cli, mcp 등)에 한해 안전.
203
+ return JSON.stringify(String(s));
204
+ }
205
+
206
+ function tomlInlineArray(arr) {
207
+ return `[${arr.map(tomlBasicString).join(", ")}]`;
77
208
  }
78
209
 
79
210
  /** JSON 읽기 (없으면 빈 객체). 깨진 JSON 은 명시적 에러. */
@@ -116,28 +247,38 @@ export async function mcpInit({ cwd, args }) {
116
247
  }
117
248
 
118
249
  const configPath = resolveConfigPath(client, scope, cwd);
119
- const config = await readJsonOrEmpty(configPath);
250
+ const entry = await buildShUiEntry();
120
251
 
121
- if (!config.mcpServers || typeof config.mcpServers !== "object") {
122
- config.mcpServers = {};
252
+ let had;
253
+ if (client === "codex") {
254
+ const raw = existsSync(configPath) ? await readFile(configPath, "utf8") : "";
255
+ const { text, had: existed } = upsertCodexMcpServer(raw, "sh-ui", entry);
256
+ had = existed;
257
+ await mkdir(dirname(configPath), { recursive: true });
258
+ await writeFile(configPath, text, "utf8");
259
+ } else {
260
+ const config = await readJsonOrEmpty(configPath);
261
+ if (!config.mcpServers || typeof config.mcpServers !== "object") {
262
+ config.mcpServers = {};
263
+ }
264
+ had = Boolean(config.mcpServers["sh-ui"]);
265
+ config.mcpServers["sh-ui"] = entry;
266
+ await mkdir(dirname(configPath), { recursive: true });
267
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
123
268
  }
124
269
 
125
- const before = config.mcpServers["sh-ui"];
126
- config.mcpServers["sh-ui"] = await buildShUiEntry();
127
-
128
- await mkdir(dirname(configPath), { recursive: true });
129
- await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
130
-
131
270
  const rel = relative(cwd, configPath);
132
271
  const display = rel.startsWith("..") ? configPath : rel;
133
- const verb = before ? "갱신" : "추가";
272
+ const verb = had ? "갱신" : "추가";
134
273
  console.log(`✓ sh-ui MCP 엔트리 ${verb} → ${display}`);
135
274
  console.log(` client: ${client} (scope: ${scope})`);
136
- if (client === "claude-code" || client === "cursor") {
137
- console.log(`\n다음 단계: ${client === "claude-code" ? "Claude Code" : "Cursor"} 를 재시작하면 sh-ui 툴이 활성화됩니다.`);
138
- } else {
139
- console.log(`\n다음 단계: Claude Desktop 을 종료 후 재시작하면 sh-ui 툴이 활성화됩니다.`);
140
- }
275
+ const restartTarget = {
276
+ "claude-code": "Claude Code",
277
+ cursor: "Cursor",
278
+ "claude-desktop": "Claude Desktop",
279
+ codex: "codex CLI 세션",
280
+ }[client];
281
+ console.log(`\n다음 단계: ${restartTarget} 를 재시작하면 sh-ui 툴이 활성화됩니다.`);
141
282
  }
142
283
 
143
284
  /** --key=value / --key value 파싱 */