@mandujs/cli 0.15.1 → 0.15.3

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.
Files changed (90) hide show
  1. package/README.ko.md +33 -33
  2. package/README.md +354 -354
  3. package/package.json +2 -2
  4. package/src/commands/check.ts +71 -7
  5. package/src/commands/contract.ts +173 -173
  6. package/src/commands/dev.ts +9 -42
  7. package/src/commands/guard-arch.ts +303 -303
  8. package/src/commands/init.ts +50 -5
  9. package/src/commands/monitor.ts +300 -300
  10. package/src/commands/openapi.ts +107 -107
  11. package/src/commands/registry.ts +1 -0
  12. package/src/commands/start.ts +9 -42
  13. package/src/errors/codes.ts +35 -35
  14. package/src/errors/index.ts +2 -2
  15. package/src/errors/messages.ts +143 -143
  16. package/src/hooks/index.ts +17 -17
  17. package/src/hooks/preaction.ts +256 -256
  18. package/src/main.ts +9 -7
  19. package/src/terminal/banner.ts +166 -166
  20. package/src/terminal/help.ts +306 -306
  21. package/src/terminal/index.ts +71 -71
  22. package/src/terminal/output.ts +295 -295
  23. package/src/terminal/palette.ts +30 -30
  24. package/src/terminal/progress.ts +327 -327
  25. package/src/terminal/stream-writer.ts +214 -214
  26. package/src/terminal/table.ts +354 -354
  27. package/src/terminal/theme.ts +142 -142
  28. package/src/util/bun.ts +6 -6
  29. package/src/util/fs.ts +23 -23
  30. package/src/util/handlers.ts +49 -5
  31. package/src/util/lockfile.ts +66 -0
  32. package/src/util/output.ts +22 -22
  33. package/src/util/port.ts +71 -71
  34. package/templates/default/AGENTS.md +96 -96
  35. package/templates/default/app/api/health/route.ts +13 -13
  36. package/templates/default/app/globals.css +49 -49
  37. package/templates/default/app/layout.tsx +27 -27
  38. package/templates/default/app/page.tsx +38 -38
  39. package/templates/default/src/client/shared/lib/utils.ts +16 -16
  40. package/templates/default/src/client/shared/ui/button.tsx +57 -57
  41. package/templates/default/src/client/shared/ui/card.tsx +1 -1
  42. package/templates/default/src/client/shared/ui/index.ts +21 -21
  43. package/templates/default/src/client/shared/ui/input.tsx +5 -1
  44. package/templates/default/tests/example.test.ts +58 -58
  45. package/templates/default/tests/helpers.ts +52 -52
  46. package/templates/default/tests/setup.ts +9 -9
  47. package/templates/default/tsconfig.json +23 -23
  48. package/templates/realtime-chat/AGENTS.md +96 -0
  49. package/templates/realtime-chat/app/api/chat/messages/route.ts +63 -0
  50. package/templates/realtime-chat/app/api/chat/stream/route.ts +85 -0
  51. package/templates/realtime-chat/app/api/health/route.ts +13 -0
  52. package/templates/realtime-chat/app/globals.css +49 -0
  53. package/templates/realtime-chat/app/layout.tsx +27 -0
  54. package/templates/realtime-chat/app/page.tsx +16 -0
  55. package/templates/realtime-chat/package.json +34 -0
  56. package/templates/realtime-chat/src/client/app/index.ts +1 -0
  57. package/templates/realtime-chat/src/client/entities/index.ts +1 -0
  58. package/templates/realtime-chat/src/client/features/chat/chat-api.ts +209 -0
  59. package/templates/realtime-chat/src/client/features/chat/realtime-chat-starter.client.tsx +89 -0
  60. package/templates/realtime-chat/src/client/features/chat/use-realtime-chat.ts +65 -0
  61. package/templates/realtime-chat/src/client/features/index.ts +1 -0
  62. package/templates/realtime-chat/src/client/pages/index.ts +1 -0
  63. package/templates/realtime-chat/src/client/shared/index.ts +1 -0
  64. package/templates/realtime-chat/src/client/shared/lib/utils.ts +16 -0
  65. package/templates/realtime-chat/src/client/shared/ui/button.tsx +57 -0
  66. package/templates/realtime-chat/src/client/shared/ui/card.tsx +78 -0
  67. package/templates/realtime-chat/src/client/shared/ui/index.ts +21 -0
  68. package/templates/realtime-chat/src/client/shared/ui/input.tsx +28 -0
  69. package/templates/realtime-chat/src/client/widgets/index.ts +1 -0
  70. package/templates/realtime-chat/src/server/api/index.ts +1 -0
  71. package/templates/realtime-chat/src/server/application/ai-adapter.ts +24 -0
  72. package/templates/realtime-chat/src/server/application/chat-store.ts +158 -0
  73. package/templates/realtime-chat/src/server/application/index.ts +1 -0
  74. package/templates/realtime-chat/src/server/core/index.ts +1 -0
  75. package/templates/realtime-chat/src/server/domain/index.ts +1 -0
  76. package/templates/realtime-chat/src/server/infra/index.ts +1 -0
  77. package/templates/realtime-chat/src/shared/contracts/chat.ts +29 -0
  78. package/templates/realtime-chat/src/shared/contracts/index.ts +1 -0
  79. package/templates/realtime-chat/src/shared/env/index.ts +1 -0
  80. package/templates/realtime-chat/src/shared/schema/index.ts +1 -0
  81. package/templates/realtime-chat/src/shared/types/index.ts +1 -0
  82. package/templates/realtime-chat/src/shared/utils/client/index.ts +1 -0
  83. package/templates/realtime-chat/src/shared/utils/server/index.ts +1 -0
  84. package/templates/realtime-chat/tests/chat-api.sse.test.ts +188 -0
  85. package/templates/realtime-chat/tests/chat-starter.test.ts +200 -0
  86. package/templates/realtime-chat/tests/chat-store.concurrency.test.ts +39 -0
  87. package/templates/realtime-chat/tests/example.test.ts +58 -0
  88. package/templates/realtime-chat/tests/helpers.ts +52 -0
  89. package/templates/realtime-chat/tests/setup.ts +9 -0
  90. package/templates/realtime-chat/tsconfig.json +23 -0
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "packageManager": "bun@1.2.0",
6
+ "engines": {
7
+ "bun": ">=1.0.0"
8
+ },
9
+ "scripts": {
10
+ "dev": "mandu dev",
11
+ "build": "mandu build",
12
+ "start": "mandu start",
13
+ "check": "mandu check",
14
+ "guard": "mandu guard",
15
+ "test": "bun test"
16
+ },
17
+ "dependencies": {
18
+ "@mandujs/core": "{{CORE_VERSION}}",
19
+ "@radix-ui/react-slot": "^1.1.0",
20
+ "class-variance-authority": "^0.7.0",
21
+ "clsx": "^2.1.1",
22
+ "react": "^19.2.0",
23
+ "react-dom": "^19.2.0",
24
+ "tailwind-merge": "^2.5.2"
25
+ },
26
+ "devDependencies": {
27
+ "@mandujs/cli": "{{CLI_VERSION}}",
28
+ "@tailwindcss/cli": "^4.1.0",
29
+ "@types/react": "^19.2.0",
30
+ "@types/react-dom": "^19.2.0",
31
+ "tailwindcss": "^4.1.0",
32
+ "typescript": "^5.0.0"
33
+ }
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,209 @@
1
+ import type {
2
+ ChatHistoryResponse,
3
+ ChatMessage,
4
+ ChatMessagePayload,
5
+ ChatMessageResponse,
6
+ ChatStreamEvent,
7
+ } from "@/shared/contracts/chat";
8
+
9
+ const API_BASE = "/api/chat";
10
+
11
+ export type ChatStreamConnectionState =
12
+ | "connecting"
13
+ | "connected"
14
+ | "reconnecting"
15
+ | "failed"
16
+ | "closed";
17
+
18
+ interface ChatStreamOptions {
19
+ maxRetries?: number;
20
+ baseDelayMs?: number;
21
+ maxDelayMs?: number;
22
+ jitterRatio?: number;
23
+ random?: () => number;
24
+ eventSourceFactory?: (url: string) => EventSource;
25
+ onConnectionStateChange?: (state: ChatStreamConnectionState) => void;
26
+ }
27
+
28
+ type ReconnectOptions = Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">>;
29
+
30
+ const DEFAULT_STREAM_OPTIONS: ReconnectOptions = {
31
+ maxRetries: 8,
32
+ baseDelayMs: 500,
33
+ maxDelayMs: 10_000,
34
+ jitterRatio: 0.25,
35
+ random: Math.random,
36
+ };
37
+
38
+ export async function sendChatMessage(payload: ChatMessagePayload): Promise<ChatMessageResponse> {
39
+ const response = await fetch(`${API_BASE}/messages`, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify(payload),
43
+ });
44
+
45
+ if (!response.ok) {
46
+ throw new Error(`Failed to send message: ${response.status}`);
47
+ }
48
+
49
+ return response.json() as Promise<ChatMessageResponse>;
50
+ }
51
+
52
+ export async function fetchChatHistory(): Promise<ChatHistoryResponse> {
53
+ const response = await fetch(`${API_BASE}/messages`);
54
+ if (!response.ok) {
55
+ throw new Error(`Failed to load history: ${response.status}`);
56
+ }
57
+
58
+ return response.json() as Promise<ChatHistoryResponse>;
59
+ }
60
+
61
+ export function mergeChatMessages(base: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
62
+ const merged = new Map<string, ChatMessage>();
63
+
64
+ for (const message of base) {
65
+ merged.set(message.id, message);
66
+ }
67
+
68
+ for (const message of incoming) {
69
+ merged.set(message.id, message);
70
+ }
71
+
72
+ return [...merged.values()].sort((a, b) => {
73
+ const byTime = a.createdAt.localeCompare(b.createdAt);
74
+ if (byTime !== 0) return byTime;
75
+ return a.id.localeCompare(b.id);
76
+ });
77
+ }
78
+
79
+ function toReconnectDelayMs(attempt: number, options: ReconnectOptions): number {
80
+ const exponentialDelay = Math.min(options.maxDelayMs, options.baseDelayMs * 2 ** attempt);
81
+ const jitterRange = exponentialDelay * options.jitterRatio;
82
+ const jitter = (options.random() * 2 - 1) * jitterRange;
83
+ return Math.max(0, Math.min(options.maxDelayMs, Math.round(exponentialDelay + jitter)));
84
+ }
85
+
86
+ export function openChatStream(
87
+ onEvent: (event: ChatStreamEvent) => void,
88
+ streamOptions: ChatStreamOptions = {},
89
+ ): () => void {
90
+ const options: ReconnectOptions = {
91
+ maxRetries: streamOptions.maxRetries ?? DEFAULT_STREAM_OPTIONS.maxRetries,
92
+ baseDelayMs: streamOptions.baseDelayMs ?? DEFAULT_STREAM_OPTIONS.baseDelayMs,
93
+ maxDelayMs: streamOptions.maxDelayMs ?? DEFAULT_STREAM_OPTIONS.maxDelayMs,
94
+ jitterRatio: streamOptions.jitterRatio ?? DEFAULT_STREAM_OPTIONS.jitterRatio,
95
+ random: streamOptions.random ?? DEFAULT_STREAM_OPTIONS.random,
96
+ };
97
+ const createSource = streamOptions.eventSourceFactory ?? ((url: string) => new EventSource(url));
98
+
99
+ let source: EventSource | null = null;
100
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
101
+ let reconnectAttempts = 0;
102
+ let isDisposed = false;
103
+ let lastEventId: string | null = null;
104
+
105
+ const setConnectionState = (state: ChatStreamConnectionState) => {
106
+ streamOptions.onConnectionStateChange?.(state);
107
+ };
108
+
109
+ const clearReconnectTimer = () => {
110
+ if (reconnectTimer) {
111
+ clearTimeout(reconnectTimer);
112
+ reconnectTimer = null;
113
+ }
114
+ };
115
+
116
+ const closeSource = () => {
117
+ if (!source) {
118
+ return;
119
+ }
120
+
121
+ source.onopen = null;
122
+ source.onmessage = null;
123
+ source.onerror = null;
124
+ source.close();
125
+ source = null;
126
+ };
127
+
128
+ const scheduleReconnect = () => {
129
+ if (isDisposed || reconnectTimer) {
130
+ return;
131
+ }
132
+
133
+ if (reconnectAttempts >= options.maxRetries) {
134
+ closeSource();
135
+ setConnectionState("failed");
136
+ return;
137
+ }
138
+
139
+ setConnectionState("reconnecting");
140
+ const delayMs = toReconnectDelayMs(reconnectAttempts, options);
141
+ reconnectAttempts += 1;
142
+
143
+ reconnectTimer = setTimeout(() => {
144
+ reconnectTimer = null;
145
+ connect();
146
+ }, delayMs);
147
+ };
148
+
149
+ const toStreamUrl = () => {
150
+ if (!lastEventId) return `${API_BASE}/stream`;
151
+ return `${API_BASE}/stream?lastEventId=${encodeURIComponent(lastEventId)}`;
152
+ };
153
+
154
+ const connect = () => {
155
+ if (isDisposed) {
156
+ return;
157
+ }
158
+
159
+ setConnectionState("connecting");
160
+ closeSource();
161
+ const currentSource = createSource(toStreamUrl());
162
+ source = currentSource;
163
+
164
+ currentSource.onopen = () => {
165
+ if (source !== currentSource || isDisposed) {
166
+ return;
167
+ }
168
+
169
+ reconnectAttempts = 0;
170
+ setConnectionState("connected");
171
+ };
172
+
173
+ currentSource.onmessage = (event) => {
174
+ if (source !== currentSource || isDisposed) {
175
+ return;
176
+ }
177
+
178
+ const maybeLastEventId = (event as MessageEvent).lastEventId;
179
+ if (typeof maybeLastEventId === "string" && maybeLastEventId.trim().length > 0) {
180
+ lastEventId = maybeLastEventId.trim();
181
+ }
182
+
183
+ try {
184
+ const parsed = JSON.parse(event.data) as ChatStreamEvent;
185
+ onEvent(parsed);
186
+ } catch {
187
+ // Ignore malformed SSE payloads.
188
+ }
189
+ };
190
+
191
+ currentSource.onerror = () => {
192
+ if (source !== currentSource || isDisposed) {
193
+ return;
194
+ }
195
+
196
+ closeSource();
197
+ scheduleReconnect();
198
+ };
199
+ };
200
+
201
+ connect();
202
+
203
+ return () => {
204
+ isDisposed = true;
205
+ clearReconnectTimer();
206
+ closeSource();
207
+ setConnectionState("closed");
208
+ };
209
+ }
@@ -0,0 +1,89 @@
1
+ "use client";
2
+
3
+ import { FormEvent, useMemo, useState } from "react";
4
+ import { Button, Input } from "@/client/shared/ui";
5
+ import { useRealtimeChat } from "./use-realtime-chat";
6
+
7
+ export function RealtimeChatStarter() {
8
+ const { messages, send, canSend, sending, connectionState } = useRealtimeChat();
9
+ const [text, setText] = useState("");
10
+ const messagesWithTime = useMemo(
11
+ () =>
12
+ messages.map((message) => ({
13
+ ...message,
14
+ displayTime: new Date(message.createdAt).toLocaleTimeString(),
15
+ })),
16
+ [messages],
17
+ );
18
+
19
+ const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
20
+ event.preventDefault();
21
+ const current = text;
22
+ setText("");
23
+ await send(current);
24
+ };
25
+
26
+ const showConnectionWarning = connectionState === "reconnecting" || connectionState === "failed";
27
+
28
+ return (
29
+ <section className="flex h-[70vh] flex-col rounded-xl border bg-card" aria-label="Realtime chat">
30
+ {showConnectionWarning ? (
31
+ <div className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-xs text-amber-900">
32
+ {connectionState === "failed"
33
+ ? "Live updates disconnected. Please refresh to reconnect."
34
+ : "Live updates are reconnecting..."}
35
+ </div>
36
+ ) : null}
37
+
38
+ <div
39
+ className="flex-1 space-y-3 overflow-y-auto p-4"
40
+ role="log"
41
+ aria-live="polite"
42
+ aria-label="Chat messages"
43
+ aria-relevant="additions text"
44
+ >
45
+ {messages.length === 0 ? (
46
+ <p className="text-sm text-muted-foreground" role="status">
47
+ No messages yet. Start chatting.
48
+ </p>
49
+ ) : (
50
+ messagesWithTime.map((message) => (
51
+ <div
52
+ key={message.id}
53
+ className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
54
+ message.role === "user"
55
+ ? "ml-auto bg-primary text-primary-foreground"
56
+ : "bg-muted"
57
+ }`}
58
+ >
59
+ <div>{message.text}</div>
60
+ <div className="mt-1 text-[10px] opacity-70">{message.displayTime}</div>
61
+ </div>
62
+ ))
63
+ )}
64
+ </div>
65
+
66
+ <form onSubmit={onSubmit} className="flex gap-2 border-t p-3" aria-label="Send chat message">
67
+ <Input
68
+ value={text}
69
+ onChange={(e) => setText(e.target.value)}
70
+ placeholder="Type your message..."
71
+ className="flex-1"
72
+ maxLength={500}
73
+ aria-label="Chat message input"
74
+ aria-describedby="chat-input-description"
75
+ />
76
+ <span id="chat-input-description" className="sr-only">
77
+ Press Enter to send your message.
78
+ </span>
79
+ <Button
80
+ type="submit"
81
+ aria-label="Send message"
82
+ disabled={!canSend || text.trim().length === 0}
83
+ >
84
+ {sending ? "Sending..." : "Send"}
85
+ </Button>
86
+ </form>
87
+ </section>
88
+ );
89
+ }
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import type { ChatMessage } from "@/shared/contracts/chat";
5
+ import {
6
+ mergeChatMessages,
7
+ openChatStream,
8
+ sendChatMessage,
9
+ type ChatStreamConnectionState,
10
+ } from "./chat-api";
11
+
12
+ export function useRealtimeChat() {
13
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
14
+ const [sending, setSending] = useState(false);
15
+ const [connectionState, setConnectionState] = useState<ChatStreamConnectionState>("connecting");
16
+
17
+ useEffect(() => {
18
+ let mounted = true;
19
+
20
+ const close = openChatStream((event) => {
21
+ if (!mounted) return;
22
+
23
+ if (event.type === "snapshot" && Array.isArray(event.data)) {
24
+ setMessages(event.data);
25
+ }
26
+
27
+ if (event.type === "message" && !Array.isArray(event.data)) {
28
+ setMessages((prev) => mergeChatMessages(prev, [event.data]));
29
+ }
30
+ }, {
31
+ onConnectionStateChange: (state) => {
32
+ if (mounted) {
33
+ setConnectionState(state);
34
+ }
35
+ },
36
+ });
37
+
38
+ return () => {
39
+ mounted = false;
40
+ close();
41
+ };
42
+ }, []);
43
+
44
+ const send = useCallback(async (text: string) => {
45
+ const trimmed = text.trim();
46
+ if (!trimmed) return;
47
+
48
+ setSending(true);
49
+ try {
50
+ await sendChatMessage({ text: trimmed });
51
+ } finally {
52
+ setSending(false);
53
+ }
54
+ }, []);
55
+
56
+ const canSend = useMemo(() => !sending, [sending]);
57
+
58
+ return {
59
+ messages,
60
+ send,
61
+ sending,
62
+ canSend,
63
+ connectionState,
64
+ };
65
+ }
@@ -0,0 +1 @@
1
+ export * from "./chat/use-realtime-chat";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ /**
5
+ * cn - Tailwind CSS 클래스 병합 유틸리티
6
+ *
7
+ * clsx로 조건부 클래스를 결합하고
8
+ * tailwind-merge로 충돌하는 클래스를 스마트하게 병합
9
+ *
10
+ * @example
11
+ * cn("px-4 py-2", isActive && "bg-primary", className)
12
+ * cn("text-sm", "text-lg") // → "text-lg" (충돌 해결)
13
+ */
14
+ export function cn(...inputs: ClassValue[]) {
15
+ return twMerge(clsx(inputs));
16
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+ import { cn } from "@/client/shared/lib/utils";
7
+
8
+ const buttonVariants = cva(
9
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
10
+ {
11
+ variants: {
12
+ variant: {
13
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
16
+ outline:
17
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
20
+ ghost: "hover:bg-accent hover:text-accent-foreground",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ size: {
24
+ default: "h-10 px-4 py-2",
25
+ sm: "h-9 rounded-md px-3",
26
+ lg: "h-11 rounded-md px-8",
27
+ icon: "h-10 w-10",
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: "default",
32
+ size: "default",
33
+ },
34
+ }
35
+ );
36
+
37
+ export interface ButtonProps
38
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
39
+ VariantProps<typeof buttonVariants> {
40
+ asChild?: boolean;
41
+ }
42
+
43
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
44
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
45
+ const Comp = asChild ? Slot : "button";
46
+ return (
47
+ <Comp
48
+ className={cn(buttonVariants({ variant, size, className }))}
49
+ ref={ref}
50
+ {...props}
51
+ />
52
+ );
53
+ }
54
+ );
55
+ Button.displayName = "Button";
56
+
57
+ export { Button, buttonVariants };
@@ -0,0 +1,78 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/client/shared/lib/utils";
3
+
4
+ const Card = React.forwardRef<
5
+ HTMLDivElement,
6
+ React.HTMLAttributes<HTMLDivElement>
7
+ >(({ className, ...props }, ref) => (
8
+ <div
9
+ ref={ref}
10
+ className={cn(
11
+ "rounded-lg border bg-card text-card-foreground shadow-sm",
12
+ className
13
+ )}
14
+ {...props}
15
+ />
16
+ ));
17
+ Card.displayName = "Card";
18
+
19
+ const CardHeader = React.forwardRef<
20
+ HTMLDivElement,
21
+ React.HTMLAttributes<HTMLDivElement>
22
+ >(({ className, ...props }, ref) => (
23
+ <div
24
+ ref={ref}
25
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
26
+ {...props}
27
+ />
28
+ ));
29
+ CardHeader.displayName = "CardHeader";
30
+
31
+ const CardTitle = React.forwardRef<
32
+ HTMLHeadingElement,
33
+ React.HTMLAttributes<HTMLHeadingElement>
34
+ >(({ className, ...props }, ref) => (
35
+ <h3
36
+ ref={ref}
37
+ className={cn(
38
+ "text-2xl font-semibold leading-none tracking-tight",
39
+ className
40
+ )}
41
+ {...props}
42
+ />
43
+ ));
44
+ CardTitle.displayName = "CardTitle";
45
+
46
+ const CardDescription = React.forwardRef<
47
+ HTMLParagraphElement,
48
+ React.HTMLAttributes<HTMLParagraphElement>
49
+ >(({ className, ...props }, ref) => (
50
+ <p
51
+ ref={ref}
52
+ className={cn("text-sm text-muted-foreground", className)}
53
+ {...props}
54
+ />
55
+ ));
56
+ CardDescription.displayName = "CardDescription";
57
+
58
+ const CardContent = React.forwardRef<
59
+ HTMLDivElement,
60
+ React.HTMLAttributes<HTMLDivElement>
61
+ >(({ className, ...props }, ref) => (
62
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
63
+ ));
64
+ CardContent.displayName = "CardContent";
65
+
66
+ const CardFooter = React.forwardRef<
67
+ HTMLDivElement,
68
+ React.HTMLAttributes<HTMLDivElement>
69
+ >(({ className, ...props }, ref) => (
70
+ <div
71
+ ref={ref}
72
+ className={cn("flex items-center p-6 pt-0", className)}
73
+ {...props}
74
+ />
75
+ ));
76
+ CardFooter.displayName = "CardFooter";
77
+
78
+ export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * UI Components
3
+ *
4
+ * shadcn/ui 스타일의 컴포넌트 라이브러리
5
+ * Radix UI primitives + Tailwind CSS + cva 기반
6
+ */
7
+
8
+ export { Button, buttonVariants } from "./button";
9
+ export type { ButtonProps } from "./button";
10
+
11
+ export {
12
+ Card,
13
+ CardHeader,
14
+ CardFooter,
15
+ CardTitle,
16
+ CardDescription,
17
+ CardContent,
18
+ } from "./card";
19
+
20
+ export { Input } from "./input";
21
+ export type { InputProps } from "./input";
@@ -0,0 +1,28 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/client/shared/lib/utils";
3
+
4
+ export interface InputProps
5
+ extends React.InputHTMLAttributes<HTMLInputElement> {}
6
+
7
+ const Input = React.forwardRef<HTMLInputElement, InputProps>(
8
+ ({ className, type, placeholder, "aria-label": ariaLabel, ...props }, ref) => {
9
+ const accessibleLabel = ariaLabel ?? (typeof placeholder === "string" ? placeholder : undefined);
10
+
11
+ return (
12
+ <input
13
+ type={type}
14
+ placeholder={placeholder}
15
+ aria-label={accessibleLabel}
16
+ className={cn(
17
+ "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
18
+ className
19
+ )}
20
+ ref={ref}
21
+ {...props}
22
+ />
23
+ );
24
+ }
25
+ );
26
+ Input.displayName = "Input";
27
+
28
+ export { Input };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import type { ChatMessage } from "@/shared/contracts/chat";
2
+
3
+ export interface AIChatAdapter {
4
+ complete(input: {
5
+ userText: string;
6
+ history: ChatMessage[];
7
+ }): Promise<string | null>;
8
+ }
9
+
10
+ class EchoAdapter implements AIChatAdapter {
11
+ async complete(input: { userText: string }): Promise<string> {
12
+ return `Echo: ${input.userText}`;
13
+ }
14
+ }
15
+
16
+ let adapter: AIChatAdapter = new EchoAdapter();
17
+
18
+ export function getAIAdapter(): AIChatAdapter {
19
+ return adapter;
20
+ }
21
+
22
+ export function setAIAdapter(next: AIChatAdapter): void {
23
+ adapter = next;
24
+ }