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 +1 -1
- package/data/changelog/versions.json +26 -0
- package/data/registry/react/components/breadcrumb/index.module.tsx +33 -8
- package/data/registry/react/components/breadcrumb/index.tailwind.tsx +35 -10
- package/data/registry/react/components/breadcrumb/index.tsx +36 -8
- package/data/registry/react/components/sidebar/index.module.tsx +39 -32
- package/data/registry/react/components/sidebar/index.tailwind.tsx +37 -21
- package/data/registry/react/components/sidebar/index.tsx +49 -32
- package/data/summaries/react.json +2 -2
- package/package.json +3 -3
- package/src/mcp-init.mjs +161 -20
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
*
|
|
595
|
-
*
|
|
594
|
+
* 다른 엘리먼트(예: Next.js `Link`)로 대체. 자체로 `<button>` 을 렌더하므로
|
|
595
|
+
* 자식으로 또 다른 button/anchor 를 넣지 말 것. sh-ui 의 모든 슬롯 패턴은
|
|
596
|
+
* `render` 로 통일 (Base UI 표준).
|
|
596
597
|
*
|
|
597
|
-
*
|
|
598
|
+
* <SidebarMenuButton render={<Link href='/'>홈</Link>} />
|
|
598
599
|
*/
|
|
599
|
-
|
|
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
|
-
* 메뉴 한 줄을 누를 수 있는 버튼. `
|
|
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",
|
|
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 (
|
|
644
|
-
const child =
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
*
|
|
701
|
-
* @default false
|
|
706
|
+
* 다른 anchor 컴포넌트(예: Next.js `Link`)로 대체. sh-ui 의 슬롯 패턴은 `render` 로 통일.
|
|
702
707
|
*/
|
|
703
|
-
|
|
708
|
+
render?: React.ReactElement;
|
|
704
709
|
/** `SidebarTOC`의 활성 섹션 id 자동 동기화. 일치하면 `isActive`가 자동으로 `true`. */
|
|
705
710
|
sectionId?: string;
|
|
706
711
|
}
|
|
707
712
|
|
|
708
|
-
/** 서브 메뉴 항목 내부의
|
|
713
|
+
/** 서브 메뉴 항목 내부의 링크. `render` prop 으로 다른 엘리먼트 슬롯 가능. `sectionId`로 TOC 활성 연동. */
|
|
709
714
|
export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarMenuSubButtonProps>(
|
|
710
715
|
function SidebarMenuSubButton(
|
|
711
|
-
{ className, isActive, size = "md",
|
|
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 (
|
|
722
|
-
const child =
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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"
|
|
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
|
-
|
|
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",
|
|
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 (
|
|
382
|
-
const child =
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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",
|
|
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 (
|
|
431
|
-
const child =
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
*
|
|
595
|
-
*
|
|
594
|
+
* 다른 엘리먼트(예: Next.js `Link`)로 대체. 자체로 `<button>` 을 렌더하므로
|
|
595
|
+
* 자식으로 또 다른 button/anchor 를 넣지 말 것. sh-ui 의 모든 슬롯 패턴은
|
|
596
|
+
* `render` 로 통일 (Base UI 표준).
|
|
596
597
|
*
|
|
597
|
-
*
|
|
598
|
+
* <SidebarMenuButton render={<Link href='/'>홈</Link>} />
|
|
598
599
|
*/
|
|
599
|
-
|
|
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
|
-
* 메뉴 한 줄을 누를 수 있는 버튼. `
|
|
614
|
-
*
|
|
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",
|
|
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 (
|
|
644
|
-
const child =
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
*
|
|
701
|
-
*
|
|
710
|
+
* 다른 anchor 컴포넌트(예: Next.js `Link`)로 대체. sh-ui 의 모든 슬롯
|
|
711
|
+
* 패턴은 `render` 로 통일 (Base UI 표준).
|
|
712
|
+
*
|
|
713
|
+
* <SidebarMenuSubButton render={<Link href='/...'>서브</Link>} />
|
|
702
714
|
*/
|
|
703
|
-
|
|
715
|
+
render?: React.ReactElement;
|
|
704
716
|
/** `SidebarTOC`의 활성 섹션 id 자동 동기화. 일치하면 `isActive`가 자동으로 `true`. */
|
|
705
717
|
sectionId?: string;
|
|
706
718
|
}
|
|
707
719
|
|
|
708
|
-
/** 서브 메뉴 항목 내부의
|
|
720
|
+
/** 서브 메뉴 항목 내부의 링크. `render` prop 으로 다른 엘리먼트 슬롯 가능. `sectionId`로 TOC 활성 연동. */
|
|
709
721
|
export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarMenuSubButtonProps>(
|
|
710
722
|
function SidebarMenuSubButton(
|
|
711
|
-
{ className, isActive, size = "md",
|
|
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 (
|
|
722
|
-
const child =
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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"
|
|
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": "앱 사이드바 —
|
|
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.
|
|
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.
|
|
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
|
|
30
|
+
"zod": "^4.4.3"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"fs-extra": "^11.
|
|
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
|
-
// 동작:
|
|
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
|
|
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
|
|
250
|
+
const entry = await buildShUiEntry();
|
|
120
251
|
|
|
121
|
-
|
|
122
|
-
|
|
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 =
|
|
272
|
+
const verb = had ? "갱신" : "추가";
|
|
134
273
|
console.log(`✓ sh-ui MCP 엔트리 ${verb} → ${display}`);
|
|
135
274
|
console.log(` client: ${client} (scope: ${scope})`);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 파싱 */
|