sh-ui-cli 0.75.0 → 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,19 @@
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
+ },
5
18
  {
6
19
  "version": "0.75.0",
7
20
  "date": "2026-05-11",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh-ui-cli",
3
- "version": "0.75.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.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 파싱 */