seokang-sk 0.2.0 → 0.2.2

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/README.MD CHANGED
@@ -11,7 +11,18 @@
11
11
 
12
12
  `sk`는 최종적으로 `~/.agents/skills`의 로컬 저장소를 기준으로 동작해야 한다.
13
13
 
14
- 즉, npm으로 배포한 뒤에도 `~/.agents/skills/sk-feature-workflow/SKILL.md` 같은 스킬 파일을 로컬에서 바로 수정할 수 있어야 한다. 배포 패키지는 설치 진입점과 디스패치만 담당하고, 실제 기준 원본은 로컬 저장소에 남는다.
14
+ 즉, npm으로 배포한 뒤에도 `~/.agents/skills/skills/sk-feature-workflow/SKILL.md` 같은 스킬 파일을 로컬에서 바로 수정할 수 있어야 한다. 배포 패키지는 설치 진입점과 디스패치만 담당하고, 실제 기준 원본은 로컬 저장소에 남는다.
15
+
16
+ 중요:
17
+ - `~/.agents/skills`는 이 저장소의 repo root다.
18
+ - 실제 skill 폴더는 `~/.agents/skills/skills/` 아래에 둔다.
19
+ - `docs/`, `codex-config/`, `scripts/`는 지원 폴더이고, `skills/`가 skill 묶음 폴더다.
20
+
21
+ ## 30초 구조 요약
22
+
23
+ - repo root는 `~/.agents/skills`다.
24
+ - 실제 skill은 `~/.agents/skills/skills/sk-이름/SKILL.md` 형태로 둔다.
25
+ - `docs/`, `codex-config/`, `scripts/`는 지원 폴더이며, `skills/`만 skill 묶음 폴더다.
15
26
 
16
27
  ## 처음 설치
17
28
 
@@ -23,6 +34,12 @@ sk init
23
34
  sk status
24
35
  ```
25
36
 
37
+ - DB safe-read login-path alias를 프로젝트 track 기준으로 맞추려면 아래 명령을 사용한다.
38
+
39
+ ```bash
40
+ sk setup --configure-db-safe-read
41
+ ```
42
+
26
43
  - Notion 문서까지 함께 쓸 계획이면 설치 후 한 번 더 아래 명령을 실행한다.
27
44
 
28
45
  ```bash
@@ -31,7 +48,8 @@ sk setup --configure-notion
31
48
 
32
49
  - `Records` / `Projects` 데이터베이스 URL에 Notion id가 들어 있으면 `data source id`는 setup 중 자동으로 추출한다.
33
50
 
34
- - Notion 문서 기록이 Codex에서 실패하면 아래 명령으로 MCP 인증을 다시 연결한다.
51
+ - Notion 문서 기록이 `invalid_grant`, `unauthorized`, `forbidden` 같은 인증 오류로 실패하면 아래 명령으로 MCP 인증을 다시 연결한다.
52
+ - `Tool ... not found`처럼 특정 Notion tool만 빠진 경우는 로그인 문제가 아닐 수 있으니, exact error를 남기고 남아 있는 Notion MCP tool로 우회 가능한지 먼저 확인한다.
35
53
 
36
54
  ```bash
37
55
  codex mcp login notion
@@ -53,15 +71,16 @@ codex mcp login notion
53
71
 
54
72
  ```text
55
73
  .
56
- |- sk-feature-workflow # 기능 작업 메인 오케스트레이터
57
- |- sk-bugfix-workflow # 버그 수정 워크플로
58
- |- sk-refactor # 리팩토링 워크플로
59
- |- sk-plan # 기획/설계 응답 워크플로
60
- |- sk-db # DB 변경 워크플로
61
- |- sk-design-system # 디자인 시스템 점검/구축
62
- |- sk-docs # 변경 문서 작성
63
- |- sk-git # git add/commit/push 워크플로
64
- |- sk-deploy # 배포 워크플로
74
+ |- skills/
75
+ | |- sk-feature-workflow # 기능 작업 메인 오케스트레이터
76
+ | |- sk-bugfix-workflow # 버그 수정 워크플로
77
+ | |- sk-refactor # 리팩토링 워크플로
78
+ | |- sk-plan # 기획/설계 응답 워크플로
79
+ | |- sk-db # DB 변경 워크플로
80
+ | |- sk-design-system # 디자인 시스템 점검/구축
81
+ | |- sk-docs # 변경 문서 작성
82
+ | |- sk-git # git add/commit/push 워크플로
83
+ | |- sk-deploy # 배포 워크플로
65
84
  |- codex-config/
66
85
  | |- config/ # ~/.codex/config.toml 공용 원본
67
86
  | |- agents/ # ~/.codex/agents 공용 원본
@@ -69,6 +88,14 @@ codex mcp login notion
69
88
  |- scripts/ # 공용 설치 / 보조 스크립트
70
89
  ```
71
90
 
91
+ - 실제 skill 엔트리는 `skills/sk-*/SKILL.md`다.
92
+
93
+ ## 어디를 수정하나
94
+
95
+ - skill 동작 변경: `skills/sk-*/SKILL.md`
96
+ - 설치/런타임 설정 변경: `codex-config/**`, `scripts/**`
97
+ - 사용법/운영 가이드 변경: `docs/**`, `README.MD`
98
+
72
99
  ---
73
100
 
74
101
  ## 목적
@@ -178,7 +205,7 @@ sh ./sk status
178
205
  - `docs/**` 인덱스/개요 문서
179
206
  - 현재 요청과 직접 관련된 상세 문서
180
207
  - 문서가 많으면 전부 읽으려 하지 말고, 인덱스/개요 → 관련 상세 순서로 좁혀 읽는다.
181
- - `.agents/skills`에서는 `README.MD`, `docs/**`, 수정 대상 `SKILL.md`, 관련 setup/config 문서를 기본 세트로 본다.
208
+ - `.agents/skills`에서는 `README.MD`, `docs/**`, `skills/**`, 수정 대상 `SKILL.md`, 관련 setup/config 문서를 기본 세트로 본다.
182
209
 
183
210
  ---
184
211
 
@@ -304,8 +331,8 @@ LOCAL_JAR_PATH=./build/libs/km-admin-api.jar
304
331
 
305
332
  `seokang-skill` 프로젝트에서는 아래 스크립트/템플릿을 사용한다.
306
333
 
307
- - `sk-deploy/deploy-npm.sh`
308
- - `sk-deploy/deploy.local.conf.template.npm`
334
+ - `skills/sk-deploy/deploy-npm.sh`
335
+ - `skills/sk-deploy/deploy.local.conf.template.npm`
309
336
 
310
337
  ---
311
338
 
@@ -12,7 +12,19 @@
12
12
 
13
13
  `sk`는 최종적으로 `~/.agents/skills`의 로컬 저장소를 기준으로 동작해야 한다.
14
14
 
15
- 그래서 npm으로 배포한 뒤에도 `~/.agents/skills/sk-feature-workflow/SKILL.md`를 바로 수정할 수 있다. 배포 패키지는 설치 진입점과 디스패치만 담당하고, 실제 기준 원본은 여전히 로컬 저장소다.
15
+ 그래서 npm으로 배포한 뒤에도 `~/.agents/skills/skills/sk-feature-workflow/SKILL.md`를 바로 수정할 수 있다. 배포 패키지는 설치 진입점과 디스패치만 담당하고, 실제 기준 원본은 여전히 로컬 저장소다.
16
+
17
+ 중요:
18
+
19
+ - `~/.agents/skills`는 repo root다.
20
+ - 실제 skill은 `~/.agents/skills/skills/` 아래에 둔다.
21
+ - `docs/`, `codex-config/`, `scripts/`는 지원 폴더이고, `skills/`가 skill 묶음 폴더다.
22
+
23
+ ## 하지 말아야 할 구조 변경
24
+
25
+ - `sk-*` 디렉터리를 `skills/` 밖 여기저기에 흩어 놓지 않는다.
26
+ - `SKILL.md`를 루트 밖 공용 문서 폴더로 합치지 않는다.
27
+ - 프로젝트별 상태 폴더 `.sk/`와 이 저장소의 skill 디렉터리를 같은 개념으로 보지 않는다.
16
28
 
17
29
  ## 처음 설치
18
30
 
@@ -196,6 +208,11 @@ sk check
196
208
 
197
209
  이 명령은 source와 실제 설치 대상이 일치하는지 확인한다.
198
210
 
211
+ - DB safe-read를 자주 쓰는 환경이면 `mysql`/`mysql_config_editor`의 PATH 또는 full path fallback, `~/.mylogin.cnf`, login-path alias 상태도 함께 보여준다.
212
+ - 출력이 `PATH 미노출`이더라도 full path fallback이 보이면 곧바로 `mysql 없음`으로 판단하지 않는다.
213
+ - 현재 프로젝트에서 legacy alias만 보이면 `sk setup --configure-db-safe-read`로 track alias migration을 바로 진행할 수 있다.
214
+ - `DB client` 경고가 뜨면 alias보다 먼저 client 설치/PATH를 해결해야 한다. 현재 메시지에서 바로 다음 액션까지 확인할 수 있다.
215
+
199
216
  ### 4-3. 현재 상태 보기
200
217
 
201
218
  ```bash
@@ -204,7 +221,56 @@ sk status
204
221
 
205
222
  이 명령은 현재 프로젝트 기준 `.sk` 상태, 메모 파일 존재 여부, 최근 backup을 보여준다.
206
223
 
207
- ### 4-4. 현재 프로젝트 `.sk` 초기화
224
+ ### 4-4. DB safe-read alias 맞추기
225
+
226
+ 현재 프로젝트 track 기준 DB login-path alias를 `sk` 안에서 맞추려면 아래처럼 실행한다.
227
+
228
+ ```bash
229
+ sk setup --configure-db-safe-read
230
+ ```
231
+
232
+ - 예: `slk-api` 프로젝트에서 실행하면 `codex-db-slk-server` alias를 기준으로 맞춘다.
233
+ - 이미 `codex_ro`만 있으면 host / port / user를 재사용하고, 비밀번호만 `mysql_config_editor` 프롬프트에서 다시 입력한다.
234
+ - track을 직접 지정하고 싶으면 `--track <name>`을 붙일 수 있다.
235
+
236
+ ```bash
237
+ sk setup --configure-db-safe-read --track slk-server
238
+ ```
239
+
240
+ ### 4-5. DB safe-read 선행 조건
241
+
242
+ `sk setup --configure-db-safe-read`가 바로 동작하려면 `mysql`과 `mysql_config_editor`가 먼저 준비돼 있어야 한다.
243
+
244
+ 가장 짧은 확인 순서는 아래다.
245
+
246
+ 1. `sk check`로 현재 상태가 `client 부재`, `PATH 미노출`, `alias 없음` 중 어디인지 먼저 본다.
247
+ 2. `DB client` 경고면 client 설치/PATH를 먼저 해결한다.
248
+ 3. 그다음 `sk check`를 다시 실행한다.
249
+ 4. client가 잡히면 `sk setup --configure-db-safe-read`로 alias를 만든다.
250
+
251
+ #### macOS
252
+
253
+ ```bash
254
+ brew install mysql-client
255
+ sk check
256
+ sk setup --configure-db-safe-read
257
+ ```
258
+
259
+ PATH를 따로 추가하지 않아도 `sk`는 Homebrew 표준 full path fallback을 먼저 시도한다.
260
+
261
+ #### Windows Git Bash
262
+
263
+ MySQL client tools(`mysql`, `mysql_config_editor`)가 있어야 한다. MariaDB 설치본은 `mysql_config_editor`가 빠질 수 있으니 `sk check`로 먼저 확인한다. 설치 후 Git Bash에서 아래처럼 PATH를 맞춘다.
264
+
265
+ ```bash
266
+ export PATH="/c/Program Files/MySQL/MySQL Server 8.0/bin:$PATH"
267
+ sk check
268
+ sk setup --configure-db-safe-read
269
+ ```
270
+
271
+ `sk check`에서 client fallback 경로가 보이면 PATH 미노출일 뿐이므로 즉시 alias 단계로 넘어가도 된다.
272
+
273
+ ### 4-6. 현재 프로젝트 `.sk` 초기화
208
274
 
209
275
  ```bash
210
276
  cd /path/to/project
@@ -24,6 +24,8 @@ sk setup --configure-notion
24
24
  추출이 안 되는 특수 URL만 `workspace.json`에서 수동으로 보완하면 된다.
25
25
 
26
26
  필요하면 이어서 아래 명령으로 Codex의 Notion MCP 인증도 연결한다.
27
+ 단, 이 재로그인은 `invalid_grant`, `unauthorized`, `forbidden` 같은 인증/세션 오류일 때의 기본 대응이다.
28
+ `Tool ... not found`처럼 특정 MCP tool만 누락된 경우는 로그인 문제가 아닐 수 있으므로, exact error를 남기고 다른 Notion MCP tool로 우회 가능한지 먼저 본다.
27
29
 
28
30
  ```bash
29
31
  codex mcp login notion
@@ -56,5 +58,6 @@ codex mcp login notion
56
58
  - public skill 본문에 개인 Notion URL, DB id, collection id를 직접 적지 않는다.
57
59
  - Notion 작업 전에는 먼저 local config 파일 존재 여부와 `configured` 상태를 확인한다.
58
60
  - local config가 없거나 `configured=false`면 Notion 기록 시도를 생략하고 Markdown fallback을 기본 출력으로 사용한다.
61
+ - `Tool ... not found`처럼 특정 Notion MCP tool만 빠진 경우는 Notion 전체 장애로 단정하지 않는다. local config의 data source id와 project/history 위치가 있으면 `fetch`/`search`/`create_pages`/`update_page` 같은 생존 tool로 먼저 우회 시도한다.
59
62
  - fallback Markdown를 나중에 Notion으로 옮길 때는 `Projects > History`의 `Date`를 이관 완료 시점의 로컬 datetime으로 다시 저장하고, `date:Date:is_datetime=1`을 유지한 뒤에만 fallback 파일을 제거한다.
60
63
  - 사용자가 이후 Notion 기록도 원하면 그때 `sk setup --configure-notion`을 안내하고, Notion MCP 실패 여부와 구분해서 보고한다.
@@ -21,10 +21,16 @@ The most important rule is:
21
21
 
22
22
  That is why editing a file like:
23
23
 
24
- `~/.agents/skills/sk-feature-workflow/SKILL.md`
24
+ `~/.agents/skills/skills/sk-feature-workflow/SKILL.md`
25
25
 
26
26
  stays simple. You are editing the real source file that `sk` will delegate to, not a hidden copy inside the npm install directory.
27
27
 
28
+ One more rule matters here:
29
+
30
+ - `~/.agents/skills` is the repo root
31
+ - actual skill folders live under `~/.agents/skills/skills`
32
+ - support files like `docs/`, `codex-config/`, and `scripts/` stay at repo root
33
+
28
34
  ## Install flow
29
35
 
30
36
  Recommended first-time setup:
@@ -64,6 +70,8 @@ It only needs enough code to:
64
70
 
65
71
  The full skills, config, agents, docs, and repo-local helpers stay in `~/.agents/skills`.
66
72
 
73
+ That includes the skill directories themselves under `~/.agents/skills/skills`, which Codex can still discover from the canonical user skills root.
74
+
67
75
  ## Editing model
68
76
 
69
77
  After setup, improvements happen in one place only:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seokang-sk",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "SeoKang Codex 공용 설정과 스킬을 설치하는 CLI",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Seokang/seokang-codex-setting",
package/scripts/sk.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from 'node:child_process';
4
- import { createReadStream, existsSync } from 'node:fs';
4
+ import { createReadStream, existsSync, readdirSync } from 'node:fs';
5
5
  import {
6
6
  copyFile,
7
7
  mkdir,
@@ -29,6 +29,63 @@ const NOTION_CONFIG_FILE = 'workspace.json';
29
29
  const GUIDE_RELATIVE_PATH = join('docs', 'sk-cli-guide.md');
30
30
  const DEFAULT_BOOTSTRAP_REPO = 'https://github.com/Seokang/seokang-codex-setting.git';
31
31
  const CANONICAL_SKILLS_SEGMENTS = ['.agents', 'skills'];
32
+ function windowsMysqlCandidates(binaryName) {
33
+ if (process.platform !== 'win32') return [];
34
+
35
+ const candidates = [];
36
+ const programRoots = [
37
+ 'C:\\Program Files',
38
+ 'C:\\Program Files (x86)',
39
+ ];
40
+
41
+ for (const programRoot of programRoots) {
42
+ const mysqlRoot = join(programRoot, 'MySQL');
43
+ if (existsSync(mysqlRoot)) {
44
+ for (const entry of readdirSync(mysqlRoot, { withFileTypes: true })) {
45
+ if (!entry.isDirectory()) continue;
46
+ candidates.push(join(mysqlRoot, entry.name, 'bin', binaryName));
47
+ }
48
+ }
49
+
50
+ if (!existsSync(programRoot)) continue;
51
+ for (const entry of readdirSync(programRoot, { withFileTypes: true })) {
52
+ if (!entry.isDirectory()) continue;
53
+ if (!entry.name.toLowerCase().startsWith('mariadb')) continue;
54
+ candidates.push(join(programRoot, entry.name, 'bin', binaryName));
55
+ }
56
+ }
57
+
58
+ return [...new Set(candidates)];
59
+ }
60
+
61
+ const MYSQL_BIN_CANDIDATES = [
62
+ '/opt/homebrew/opt/mysql-client/bin/mysql',
63
+ '/usr/local/opt/mysql-client/bin/mysql',
64
+ '/c/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe',
65
+ '/c/Program Files/MySQL/MySQL Server 8.0/bin/mysql',
66
+ '/c/Program Files/MariaDB 11.4/bin/mysql.exe',
67
+ '/c/Program Files/MariaDB 11.4/bin/mysql',
68
+ ...windowsMysqlCandidates('mysql.exe'),
69
+ ];
70
+ const MYSQL_CONFIG_EDITOR_CANDIDATES = [
71
+ '/opt/homebrew/opt/mysql-client/bin/mysql_config_editor',
72
+ '/usr/local/opt/mysql-client/bin/mysql_config_editor',
73
+ '/c/Program Files/MySQL/MySQL Server 8.0/bin/mysql_config_editor.exe',
74
+ '/c/Program Files/MySQL/MySQL Server 8.0/bin/mysql_config_editor',
75
+ ...windowsMysqlCandidates('mysql_config_editor.exe'),
76
+ ];
77
+ const LEGACY_DB_LOGIN_PATH = 'codex_ro';
78
+ const DB_TRACK_RULES = [
79
+ { track: 'slk-server', needles: ['slk-api', 'slk-server'] },
80
+ { track: 'server', needles: ['km-admin-api', '/server', '\\server'] },
81
+ { track: 'price-spinks', needles: ['price-advisor-spinks', 'price-spinks', 'spinks'] },
82
+ { track: 'price-sealy', needles: ['price-advisor-sealy', 'price-sealy', 'sealy'] },
83
+ { track: 'price', needles: ['price-advisor', '/price', '\\price'] },
84
+ { track: 'office-web', needles: ['kwang-admin', 'admin-web', 'office-web'] },
85
+ { track: 'office-app', needles: ['office-app', '/app', '\\app'] },
86
+ { track: 'flow-frame', needles: ['flow-frame'] },
87
+ { track: 'seokang-skill', needles: ['.agents/skills', 'seokang-skill', 'sellink docs'] },
88
+ ];
32
89
 
33
90
  function canonicalRepoRoot() {
34
91
  return resolve(process.env.SK_REPO_ROOT || join(homedir(), ...CANONICAL_SKILLS_SEGMENTS));
@@ -58,6 +115,357 @@ function canRun(command, args = ['--version']) {
58
115
  return !result.error && result.status === 0;
59
116
  }
60
117
 
118
+ function detectExecutable(commandName, candidates = []) {
119
+ if (canRun(commandName)) {
120
+ return { source: 'path', path: commandName };
121
+ }
122
+
123
+ for (const candidate of candidates) {
124
+ if (candidate && canRun(candidate)) {
125
+ return { source: 'fallback', path: candidate };
126
+ }
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ function summarizeAliases(aliasNames) {
133
+ if (aliasNames.length === 0) return '없음';
134
+ if (aliasNames.length <= 3) return aliasNames.join(', ');
135
+ return `${aliasNames.slice(0, 3).join(', ')} 외 ${aliasNames.length - 3}개`;
136
+ }
137
+
138
+ function inferTrackFromPath(cwd = process.cwd()) {
139
+ const normalized = resolve(cwd).replaceAll('\\', '/').toLowerCase();
140
+ for (const rule of DB_TRACK_RULES) {
141
+ if (rule.needles.some((needle) => normalized.includes(needle))) {
142
+ return rule.track;
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function dbTargetAlias(track) {
149
+ return track ? `codex-db-${track}` : 'codex-db-default';
150
+ }
151
+
152
+ function dbSafeReadSetupCommand(track) {
153
+ return track ? `sk setup --configure-db-safe-read --track ${track}` : 'sk setup --configure-db-safe-read';
154
+ }
155
+
156
+ function mysqlClientInstallHint() {
157
+ if (process.platform === 'darwin') {
158
+ return 'macOS: 먼저 "brew install mysql-client"를 실행하고, 필요하면 "/opt/homebrew/opt/mysql-client/bin" PATH를 확인하세요.';
159
+ }
160
+
161
+ if (process.platform === 'win32') {
162
+ return 'Windows Git Bash: MySQL client tools(mysql, mysql_config_editor)를 설치하고 "/c/Program Files/MySQL/.../bin"을 PATH에 추가하세요. MariaDB 설치본은 mysql_config_editor가 빠질 수 있으니 같이 확인하세요.';
163
+ }
164
+
165
+ return '먼저 mysql client tools(mysql, mysql_config_editor)를 설치하고 PATH를 맞추세요.';
166
+ }
167
+
168
+ function mysqlClientRecoveryHint(track) {
169
+ return `${mysqlClientInstallHint()} 그다음 "sk check"로 client 상태를 다시 확인하고, alias가 없으면 "${dbSafeReadSetupCommand(track)}"를 실행하세요.`;
170
+ }
171
+
172
+ function normalizeMysqlConfigValue(rawValue = '') {
173
+ const value = String(rawValue).trim();
174
+ if (value.startsWith('"') && value.endsWith('"')) {
175
+ return value.slice(1, -1);
176
+ }
177
+ return value;
178
+ }
179
+
180
+ function parseMysqlConfigEditorOutput(output = '') {
181
+ const profiles = {};
182
+ let currentAlias = null;
183
+
184
+ for (const line of String(output).split(/\r?\n/)) {
185
+ const trimmed = line.trim();
186
+ if (!trimmed) continue;
187
+
188
+ const aliasMatch = trimmed.match(/^\[([^\]]+)\]$/);
189
+ if (aliasMatch) {
190
+ currentAlias = aliasMatch[1];
191
+ profiles[currentAlias] = {};
192
+ continue;
193
+ }
194
+
195
+ if (!currentAlias) continue;
196
+
197
+ const fieldMatch = trimmed.match(/^([a-zA-Z0-9_-]+)\s*=\s*(.+)$/);
198
+ if (!fieldMatch) continue;
199
+
200
+ profiles[currentAlias][fieldMatch[1]] = normalizeMysqlConfigValue(fieldMatch[2]);
201
+ }
202
+
203
+ return profiles;
204
+ }
205
+
206
+ function chooseDbSourceProfile(profiles, targetAlias) {
207
+ const preferredAliases = ['codex-db-default', LEGACY_DB_LOGIN_PATH];
208
+ for (const alias of preferredAliases) {
209
+ if (alias === targetAlias) continue;
210
+ if (profiles[alias]?.host && profiles[alias]?.user) {
211
+ return { alias, ...profiles[alias] };
212
+ }
213
+ }
214
+
215
+ for (const [alias, profile] of Object.entries(profiles)) {
216
+ if (alias === targetAlias) continue;
217
+ if (profile?.host && profile?.user) {
218
+ return { alias, ...profile };
219
+ }
220
+ }
221
+
222
+ return null;
223
+ }
224
+
225
+ async function promptForDbSafeReadProfile({ targetAlias, track }) {
226
+ const reader = createInterface({
227
+ input: process.stdin,
228
+ output: process.stdout,
229
+ });
230
+
231
+ console.log('sk setup (user) - DB safe-read 설정');
232
+ console.log(` 🎯 대상 alias: ${targetAlias}${track ? ` (track=${track})` : ''}`);
233
+ console.log(' 기존 alias를 복사할 수 없어 host / port / user를 먼저 입력받습니다.');
234
+ console.log(' 비밀번호는 이어지는 mysql_config_editor 프롬프트에서만 입력합니다.');
235
+
236
+ try {
237
+ const host = (await promptWithInterface(reader, 'DB host: ')).trim();
238
+ const port = (await promptWithInterface(reader, 'DB port [3306]: ')).trim();
239
+ const user = (await promptWithInterface(reader, 'DB user: ')).trim();
240
+
241
+ if (!host || !user) {
242
+ fail('DB safe-read 설정을 진행하려면 host와 user가 필요합니다.');
243
+ }
244
+
245
+ return {
246
+ host,
247
+ port: port || '3306',
248
+ user,
249
+ sourceAlias: null,
250
+ };
251
+ } finally {
252
+ reader.close();
253
+ }
254
+ }
255
+
256
+ async function ensureDbSafeReadConfig({
257
+ configure,
258
+ dryRun,
259
+ force,
260
+ verbose,
261
+ trackOverride,
262
+ contextCwd,
263
+ }) {
264
+ if (!configure) {
265
+ return { configured: false, changed: false, message: '요청 없음' };
266
+ }
267
+
268
+ const track = trackOverride || inferTrackFromPath(contextCwd);
269
+ const targetAlias = dbTargetAlias(track);
270
+ const editor = detectExecutable('mysql_config_editor', MYSQL_CONFIG_EDITOR_CANDIDATES);
271
+ if (!editor) {
272
+ fail(`DB safe-read 설정을 진행하려면 mysql_config_editor가 필요합니다. ${mysqlClientRecoveryHint(track)}`);
273
+ }
274
+
275
+ const currentAliasesResult = spawnSync(editor.path, ['print', '--all'], { encoding: 'utf-8' });
276
+ if (currentAliasesResult.error) {
277
+ fail(`DB alias 목록을 읽지 못했습니다: ${currentAliasesResult.error.message}`);
278
+ }
279
+ if (currentAliasesResult.status !== 0) {
280
+ const stderr = typeof currentAliasesResult.stderr === 'string' ? currentAliasesResult.stderr.trim() : '';
281
+ fail(`DB alias 목록을 읽지 못했습니다.${stderr ? ` ${stderr}` : ''}`);
282
+ }
283
+
284
+ const profiles = parseMysqlConfigEditorOutput(currentAliasesResult.stdout);
285
+ if (profiles[targetAlias] && !force) {
286
+ return {
287
+ configured: true,
288
+ changed: false,
289
+ targetAlias,
290
+ track,
291
+ message: `${targetAlias} alias가 이미 있습니다.`,
292
+ };
293
+ }
294
+
295
+ let profileInput = chooseDbSourceProfile(profiles, targetAlias);
296
+ if (!profileInput && dryRun) {
297
+ return {
298
+ configured: false,
299
+ changed: false,
300
+ targetAlias,
301
+ track,
302
+ message: `${targetAlias} alias를 만들려면 host / port / user 입력이 필요합니다.`,
303
+ };
304
+ }
305
+
306
+ if (!profileInput) {
307
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
308
+ fail('기존 DB alias를 복사할 수 없어서 입력이 필요합니다. TTY에서 "sk setup --configure-db-safe-read"를 다시 실행하세요.');
309
+ }
310
+ profileInput = await promptForDbSafeReadProfile({ targetAlias, track });
311
+ }
312
+
313
+ const host = profileInput.host;
314
+ const port = profileInput.port || '3306';
315
+ const user = profileInput.user;
316
+ const sourceAlias = profileInput.alias || profileInput.sourceAlias || null;
317
+
318
+ if (dryRun) {
319
+ return {
320
+ configured: false,
321
+ changed: false,
322
+ targetAlias,
323
+ track,
324
+ message: `${targetAlias} alias를 준비합니다. ${sourceAlias ? `${sourceAlias}의 host/user/port를 재사용` : 'host/user/port를 입력받아 사용'}`,
325
+ };
326
+ }
327
+
328
+ if (verbose) {
329
+ console.log(` 🗄️ DB alias 생성: ${targetAlias}${sourceAlias ? ` (기준=${sourceAlias})` : ''}`);
330
+ }
331
+
332
+ // 비밀번호는 mysql_config_editor 프롬프트에서만 입력해 user-local secret으로 유지한다.
333
+ runCommandOrFail(editor.path, [
334
+ 'set',
335
+ `--login-path=${targetAlias}`,
336
+ `--host=${host}`,
337
+ `--port=${port}`,
338
+ `--user=${user}`,
339
+ '--password',
340
+ ]);
341
+
342
+ const mysql = detectExecutable('mysql', MYSQL_BIN_CANDIDATES);
343
+ if (!mysql) {
344
+ return {
345
+ configured: true,
346
+ changed: true,
347
+ targetAlias,
348
+ track,
349
+ message: `${targetAlias} alias를 만들었습니다. mysql PATH/fallback 확인은 다음 sk check에서 확인하세요.`,
350
+ };
351
+ }
352
+
353
+ const verifyResult = spawnSync(mysql.path, [
354
+ `--login-path=${targetAlias}`,
355
+ '--connect-timeout=5',
356
+ '-N',
357
+ '-B',
358
+ '-e',
359
+ 'SELECT 1',
360
+ ], { encoding: 'utf-8' });
361
+
362
+ if (verifyResult.error || verifyResult.status !== 0) {
363
+ const stderr = typeof verifyResult.stderr === 'string' ? verifyResult.stderr.trim() : '';
364
+ return {
365
+ configured: true,
366
+ changed: true,
367
+ targetAlias,
368
+ track,
369
+ message: `${targetAlias} alias를 만들었지만 즉시 연결 검증은 실패했습니다.${stderr ? ` ${stderr}` : ''}`,
370
+ };
371
+ }
372
+
373
+ return {
374
+ configured: true,
375
+ changed: true,
376
+ targetAlias,
377
+ track,
378
+ message: `${targetAlias} alias를 만들고 SELECT 1 검증까지 통과했습니다.`,
379
+ };
380
+ }
381
+
382
+ function buildDbSafeReadChecks(cwd = process.cwd()) {
383
+ const mysql = detectExecutable('mysql', MYSQL_BIN_CANDIDATES);
384
+ const editor = detectExecutable('mysql_config_editor', MYSQL_CONFIG_EDITOR_CANDIDATES);
385
+ const checks = [];
386
+ const track = inferTrackFromPath(cwd);
387
+ const targetAlias = dbTargetAlias(track);
388
+
389
+ if (!mysql && !editor) {
390
+ checks.push(check('warn', 'DB client', `mysql, mysql_config_editor를 찾지 못했습니다. ${mysqlClientRecoveryHint(track)}`));
391
+ } else if (mysql?.source === 'path' && editor?.source === 'path') {
392
+ checks.push(check('pass', 'DB client', 'mysql, mysql_config_editor가 PATH에 있습니다.'));
393
+ } else if (mysql && editor) {
394
+ checks.push(check(
395
+ 'pass',
396
+ 'DB client',
397
+ `PATH 미노출이지만 fallback으로 사용 가능합니다. mysql=${mysql.path} | mysql_config_editor=${editor.path}`,
398
+ ));
399
+ } else {
400
+ const parts = [];
401
+ if (mysql) {
402
+ parts.push(mysql.source === 'path'
403
+ ? 'mysql=PATH'
404
+ : `mysql=fallback(${mysql.path})`);
405
+ } else {
406
+ parts.push('mysql=missing');
407
+ }
408
+ if (editor) {
409
+ parts.push(editor.source === 'path'
410
+ ? 'mysql_config_editor=PATH'
411
+ : `mysql_config_editor=fallback(${editor.path})`);
412
+ } else {
413
+ parts.push('mysql_config_editor=missing');
414
+ }
415
+ const missingDetail = !mysql
416
+ ? 'safe-read 실행에는 mysql이 필요합니다.'
417
+ : 'login-path alias setup에는 mysql_config_editor가 필요합니다.';
418
+ checks.push(check('warn', 'DB client', `${parts.join(' | ')}. ${missingDetail} ${mysqlClientRecoveryHint(track)}`));
419
+ }
420
+
421
+ const loginFile = join(homedir(), '.mylogin.cnf');
422
+ if (!editor) {
423
+ checks.push(check('warn', 'DB login-path', `mysql_config_editor를 찾지 못해 login-path alias를 확인할 수 없습니다. ${mysqlClientRecoveryHint(track)}`));
424
+ return checks;
425
+ }
426
+
427
+ if (!existsSync(loginFile)) {
428
+ checks.push(check('warn', 'DB login-path', `${loginFile} 파일이 없습니다. 먼저 "${dbSafeReadSetupCommand(track)}"로 login-path setup을 진행하세요.`));
429
+ return checks;
430
+ }
431
+
432
+ const result = spawnSync(editor.path, ['print', '--all'], { encoding: 'utf-8' });
433
+ if (result.error) {
434
+ checks.push(check('warn', 'DB login-path', `alias 확인 실패: ${result.error.message}`));
435
+ return checks;
436
+ }
437
+ if (result.status !== 0) {
438
+ const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
439
+ checks.push(check('warn', 'DB login-path', `alias 확인 실패(종료 코드 ${result.status})${stderr ? `: ${stderr}` : ''}`));
440
+ return checks;
441
+ }
442
+
443
+ const aliasNames = (typeof result.stdout === 'string' ? result.stdout : '')
444
+ .split(/\r?\n/)
445
+ .map((line) => line.trim())
446
+ .filter((line) => line.startsWith('[') && line.endsWith(']'))
447
+ .map((line) => line.slice(1, -1));
448
+
449
+ const trackAliases = aliasNames.filter((alias) => alias.startsWith('codex-db-'));
450
+ if (trackAliases.includes(targetAlias)) {
451
+ checks.push(check('pass', 'DB login-path', `track alias 확인: ${targetAlias}`));
452
+ return checks;
453
+ }
454
+
455
+ if (trackAliases.length > 0) {
456
+ checks.push(check('warn', 'DB login-path', `다른 track alias는 있지만 현재 track alias(${targetAlias})는 없습니다. 현재 프로젝트에서 "${dbSafeReadSetupCommand(track)}"로 맞출 수 있습니다.`));
457
+ return checks;
458
+ }
459
+
460
+ if (aliasNames.includes(LEGACY_DB_LOGIN_PATH)) {
461
+ checks.push(check('warn', 'DB login-path', `legacy alias만 확인: ${LEGACY_DB_LOGIN_PATH}. 현재 프로젝트에서 "${dbSafeReadSetupCommand(track)}"로 ${targetAlias} alias를 만들 수 있습니다.`));
462
+ return checks;
463
+ }
464
+
465
+ checks.push(check('warn', 'DB login-path', `사용 가능한 alias를 확인하지 못했습니다. 현재 alias=${summarizeAliases(aliasNames)}. 현재 프로젝트에서 "${dbSafeReadSetupCommand(track)}"를 실행하세요.`));
466
+ return checks;
467
+ }
468
+
61
469
  function runCommandOrFail(command, args, options = {}) {
62
470
  const result = spawnSync(command, args, {
63
471
  stdio: 'inherit',
@@ -121,7 +529,7 @@ function ensureFullBootstrapHistory(repoDir, verbose) {
121
529
 
122
530
  function resolveLocalDispatcher(command) {
123
531
  if (process.env.SK_DISABLE_FORWARD === '1') return null;
124
- if (command === 'setup' || command === 'setup-internal') return null;
532
+ if (command === 'setup-internal') return null;
125
533
  if (hasManagedSources(REPO_ROOT)) return null;
126
534
 
127
535
  const localScript = canonicalScriptPath();
@@ -219,7 +627,7 @@ function printUsage() {
219
627
  ' sk search "<query>" 로컬 Codex 세션 기록 검색',
220
628
  '',
221
629
  '📝 사용법',
222
- ' sk setup [--dir <path>] [--repo <url>] [--dry-run] [--verbose] [--configure-notion] [--skip-notion]',
630
+ ' sk setup [--dir <path>] [--cwd <path>] [--repo <url>] [--dry-run] [--verbose] [--configure-notion] [--configure-db-safe-read] [--track <name>] [--skip-notion]',
223
631
  ' sk init [--cwd <path>] [--dry-run] [--force] [--verbose]',
224
632
  ' sk check [--cwd <path>] [--json]',
225
633
  ' sk status [--cwd <path>] [--json]',
@@ -233,6 +641,7 @@ function printUsage() {
233
641
  ' - 배포된 npm 패키지는 설치 진입점과 디스패처 역할만 합니다.',
234
642
  ` - setup은 수정 가능한 원본 저장소를 ${canonicalRepoRoot()} 경로에 준비합니다.`,
235
643
  ' - setup은 로컬 원본 저장소를 기준으로 config, rules, 공용 agent를 동기화하고 check까지 실행합니다.',
644
+ ' - setup에 --configure-db-safe-read를 붙이면 현재 프로젝트(track) 기준 DB login-path alias를 sk 안에서 맞출 수 있습니다.',
236
645
  ' - init은 현재 프로젝트 루트에 .sk 상태 파일만 준비합니다.',
237
646
  ' - setup은 ~/.codex/sk/notion/workspace.json 아래에 Notion 설정 파일도 만들 수 있습니다.',
238
647
  ` - 프로젝트 상태는 ${STATE_DIRNAME}/ 아래에 저장됩니다.`,
@@ -240,7 +649,7 @@ function printUsage() {
240
649
  ` - 공용 메모는 ${STATE_DIRNAME}/${NOTEPAD_FILE} 에 저장됩니다.`,
241
650
  ' - 다른 프로젝트를 대상으로 하려면 cd 대신 init/status/memory에 --cwd <path>를 사용할 수 있습니다.',
242
651
  ' - user 설정은 $CODEX_HOME 또는 ~/.codex를 대상으로 사용합니다.',
243
- ' - Notion 기록이 Codex에서 실패하면 "codex mcp login notion"으로 MCP 인증을 다시 연결하세요.',
652
+ ' - Notion 기록이 invalid_grant/unauthorized/forbidden 같은 인증 오류로 실패하면 "codex mcp login notion"으로 MCP 인증을 다시 연결하세요.',
244
653
  ` - 자세한 가이드: ${guidePath}`,
245
654
  ].join('\n'),
246
655
  );
@@ -273,12 +682,12 @@ function parseFlags(args) {
273
682
  continue;
274
683
  }
275
684
 
276
- if (token === '--json' || token === '--dry-run' || token === '--verbose' || token === '--case-sensitive' || token === '--force' || token === '--configure-notion' || token === '--skip-notion') {
685
+ if (token === '--json' || token === '--dry-run' || token === '--verbose' || token === '--case-sensitive' || token === '--force' || token === '--configure-notion' || token === '--configure-db' || token === '--configure-db-safe-read' || token === '--skip-notion') {
277
686
  flags.set(token.slice(2), true);
278
687
  continue;
279
688
  }
280
689
 
281
- if (token === '--scope' || token === '--cwd' || token === '--dir' || token === '--repo' || token === '--limit' || token === '--since' || token === '--project' || token === '--context' || token === '--session' || token === '--category' || token === '--priority') {
690
+ if (token === '--scope' || token === '--cwd' || token === '--dir' || token === '--repo' || token === '--limit' || token === '--since' || token === '--project' || token === '--context' || token === '--session' || token === '--category' || token === '--priority' || token === '--track' || token === '--db-cwd') {
282
691
  const next = args[index + 1];
283
692
  if (!next || next.startsWith('-')) {
284
693
  fail(`${token} 뒤에 값이 필요합니다.`);
@@ -291,6 +700,10 @@ function parseFlags(args) {
291
700
  fail(`알 수 없는 옵션입니다: ${token}`);
292
701
  }
293
702
 
703
+ if (flags.get('configure-db-safe-read')) {
704
+ flags.set('configure-db', true);
705
+ }
706
+
294
707
  return { flags, rest };
295
708
  }
296
709
 
@@ -659,11 +1072,17 @@ async function writeScopeFile(scope, cwd, dryRun) {
659
1072
 
660
1073
  function buildInternalSetupArgs(flags, targetDir) {
661
1074
  const args = ['setup-internal', '--scope', 'user', '--cwd', targetDir];
662
- for (const name of ['dry-run', 'verbose', 'configure-notion', 'skip-notion']) {
1075
+ for (const name of ['dry-run', 'verbose', 'configure-notion', 'configure-db', 'skip-notion']) {
663
1076
  if (flags.get(name)) {
664
1077
  args.push(`--${name}`);
665
1078
  }
666
1079
  }
1080
+ if (flags.get('track')) {
1081
+ args.push('--track', String(flags.get('track')));
1082
+ }
1083
+ if (flags.get('db-cwd')) {
1084
+ args.push('--db-cwd', String(flags.get('db-cwd')));
1085
+ }
667
1086
  return args;
668
1087
  }
669
1088
 
@@ -927,7 +1346,11 @@ async function runSetup(flags) {
927
1346
  const dryRun = Boolean(flags.get('dry-run'));
928
1347
  const verbose = Boolean(flags.get('verbose'));
929
1348
  const configureNotion = Boolean(flags.get('configure-notion'));
1349
+ const configureDb = Boolean(flags.get('configure-db'));
930
1350
  const skipNotion = Boolean(flags.get('skip-notion'));
1351
+ const force = Boolean(flags.get('force'));
1352
+ const dbContextCwd = resolve(String(flags.get('db-cwd') || requestedCwd));
1353
+ const trackOverride = flags.get('track') ? String(flags.get('track')) : null;
931
1354
  const sources = await validateSources();
932
1355
  const target = targetPaths(scope, cwd);
933
1356
  const mergedConfig = await buildMergedConfig();
@@ -984,6 +1407,14 @@ async function runSetup(flags) {
984
1407
  configure: configureNotion,
985
1408
  skipNotion,
986
1409
  });
1410
+ const dbSummary = await ensureDbSafeReadConfig({
1411
+ configure: configureDb,
1412
+ dryRun,
1413
+ force,
1414
+ verbose,
1415
+ trackOverride,
1416
+ contextCwd: dbContextCwd,
1417
+ });
987
1418
 
988
1419
  const scopePath = await writeScopeFile(scope, cwd, dryRun);
989
1420
  const stateSummary = await ensureProjectStateFiles({ cwd, dryRun, verbose });
@@ -996,6 +1427,9 @@ async function runSetup(flags) {
996
1427
  console.log(` 📏 규칙 파일: ${target.rulesPath}`);
997
1428
  console.log(` 🤖 에이전트: ${target.agentsDir}`);
998
1429
  console.log(` 📝 Notion 설정: ${notionSummary.path}`);
1430
+ if (configureDb) {
1431
+ console.log(` 🗄️ DB safe-read: ${dbSummary.message}`);
1432
+ }
999
1433
  console.log(` 🗂️ 상태 파일: ${scopePath}`);
1000
1434
  console.log(` 🧠 프로젝트 메모리: ${projectMemoryPath(cwd)}`);
1001
1435
  console.log(` 📒 메모장: ${notepadPath(cwd)}`);
@@ -1009,7 +1443,7 @@ async function runSetup(flags) {
1009
1443
  );
1010
1444
  if (notionSummary.configured) {
1011
1445
  console.log(' ✅ Notion 워크스페이스 설정이 준비되었습니다.');
1012
- console.log(' 🔐 Codex에서 Notion 문서 기록이 실패하면 아래 명령으로 MCP 인증을 다시 연결하세요.');
1446
+ console.log(' 🔐 Codex에서 Notion 문서 기록이 invalid_grant/unauthorized/forbidden 같은 인증 오류로 실패하면 아래 명령으로 MCP 인증을 다시 연결하세요.');
1013
1447
  console.log(' codex mcp login notion');
1014
1448
  } else {
1015
1449
  console.log(' ⚠️ Notion 설정이 아직 완전하지 않습니다. 준비되면 "sk setup --configure-notion"을 다시 실행하세요.');
@@ -1017,6 +1451,7 @@ async function runSetup(flags) {
1017
1451
  }
1018
1452
 
1019
1453
  async function runSetupCommand(flags) {
1454
+ const invocationCwd = resolveWorkingDirectory(flags);
1020
1455
  const targetDir = resolve(String(flags.get('dir') || canonicalRepoRoot()));
1021
1456
  const repoUrl = String(flags.get('repo') || DEFAULT_BOOTSTRAP_REPO);
1022
1457
  const verbose = Boolean(flags.get('verbose'));
@@ -1077,12 +1512,16 @@ async function runSetupCommand(flags) {
1077
1512
  SK_DISABLE_FORWARD: '1',
1078
1513
  SK_REPO_ROOT: targetDir,
1079
1514
  };
1515
+ if (!flags.get('db-cwd')) {
1516
+ flags.set('db-cwd', invocationCwd);
1517
+ }
1080
1518
 
1081
1519
  console.log(`sk setup (${repoStatus === 'cloned' ? '새로 복제됨' : repoStatus === 'reused' ? '기존 저장소 재사용' : repoStatus === 'current-source' ? '현재 저장소 사용' : repoStatus})`);
1082
1520
  console.log(` 📁 기준 저장소: ${targetDir}`);
1083
1521
  console.log(` 🔗 원본: ${repoUrl}`);
1084
1522
  runCommandOrFail(process.execPath, [localScript, ...buildInternalSetupArgs(flags, targetDir)], { env: sharedEnv });
1085
- runCommandOrFail(process.execPath, [localScript, 'check', '--cwd', targetDir], { env: sharedEnv });
1523
+ const verifyCwd = flags.get('configure-db') ? invocationCwd : targetDir;
1524
+ runCommandOrFail(process.execPath, [localScript, 'check', '--cwd', verifyCwd], { env: sharedEnv });
1086
1525
 
1087
1526
  console.log(' ✅ 완료: setup이 끝났습니다.');
1088
1527
  if (!hasManagedSources(REPO_ROOT)) {
@@ -1208,6 +1647,8 @@ async function buildDoctorResult(cwd = process.cwd()) {
1208
1647
  ? check('pass', '메모장', notepadPath(cwd))
1209
1648
  : check('warn', '메모장', `${notepadPath(cwd)} 파일이 없습니다.`));
1210
1649
 
1650
+ checks.push(...buildDbSafeReadChecks(cwd));
1651
+
1211
1652
  if (!existsSync(target.notionConfigPath)) {
1212
1653
  checks.push(check('warn', 'Notion 설정', `${target.notionConfigPath} 파일이 없습니다. "sk setup --configure-notion"을 실행하세요.`));
1213
1654
  } else if (!notionConfig || !notionWorkspaceConfigured(notionConfig)) {
@@ -1297,7 +1738,7 @@ async function runStatus(flags) {
1297
1738
  console.log(` ${icon} ${entry.name}: ${entry.message}`);
1298
1739
  }
1299
1740
  if (status.notion_config_exists && status.notion_configured) {
1300
- console.log(' 💡 Notion 기록이 Codex에서 실패하면 "codex mcp login notion"으로 MCP 인증을 다시 연결하세요.');
1741
+ console.log(' 💡 Notion 기록이 invalid_grant/unauthorized/forbidden 같은 인증 오류로 실패하면 "codex mcp login notion"으로 MCP 인증을 다시 연결하세요.');
1301
1742
  }
1302
1743
  if (status.checks.some((entry) => entry.status === 'fail')) {
1303
1744
  process.exitCode = 1;