aiakiv 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +159 -2
- package/dist/bind.js +273 -0
- package/dist/browser.js +43 -0
- package/dist/cli.js +9 -0
- package/dist/clients/claude.js +43 -0
- package/dist/clients/codex.js +56 -0
- package/dist/clients/cursor.js +5 -0
- package/dist/clients/types.js +16 -0
- package/dist/config/json.js +151 -0
- package/dist/config/toml.js +81 -0
- package/dist/ctx.js +21 -0
- package/dist/doctor.js +425 -0
- package/dist/fsutil.js +21 -0
- package/dist/handoff.js +156 -0
- package/dist/main.js +133 -0
- package/dist/mcpProbe.js +131 -0
- package/dist/proc.js +102 -0
- package/dist/skills.js +244 -0
- package/dist/snippet.js +188 -0
- package/dist/tar.js +128 -0
- package/dist/urls.js +19 -0
- package/dist/version.js +2 -0
- package/package.json +26 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OnMinimum (AiAkiv)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,160 @@
|
|
|
1
|
-
#
|
|
1
|
+
# aiakiv
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
폴더 하나를 AiAkiv 프로젝트에 고정하는 명령줄 도구입니다(Claude Code, Codex, Gemini CLI, Cursor).
|
|
4
|
+
폴더 고정과 확인을 명령 두 개로 합니다. 설계는 K2G `docs/204_blue_print-165-aiakiv_cli_setup_and_key_handoff.md` 에 있습니다.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
npx aiakiv bind --client codex|claude|gemini|cursor --project <이름> [--dir <폴더>] [--expires-days N] [--no-browser] [--no-skills]
|
|
8
|
+
npx aiakiv doctor [--client <클라이언트>[,...]] [--dir <폴더>] [--out aiakiv-verification-test.md] [--no-probe]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
전역 설치(계정 연결과 전역 스킬)는 이 도구가 하지 않습니다. 설치 프롬프트나 플러그인 마켓
|
|
12
|
+
(`AiAkiv/aiakiv-plugins`)으로 합니다. 첫 판에 있던 `setup` 명령은 없어졌고, 치면 이 안내를 보여 주고 끝납니다.
|
|
13
|
+
|
|
14
|
+
## 필요한 것
|
|
15
|
+
|
|
16
|
+
- Node.js 18.3 이상(`npx` 포함). 없으면 설치합니다.
|
|
17
|
+
- Windows: `winget install OpenJS.NodeJS.LTS`
|
|
18
|
+
- macOS: `brew install node`
|
|
19
|
+
- Linux: 배포판 패키지(`sudo apt install nodejs npm` 등) 또는 nvm
|
|
20
|
+
- doctor 가 전역 등록 목록을 볼 때 쓰는 클라이언트 CLI(`claude`, `codex`). 없으면 "없음" 으로 적고 계속합니다.
|
|
21
|
+
Codex 는 PATH 에 없어도 Windows 앱 설치 위치(`%LOCALAPPDATA%\OpenAI\Codex\bin\<폴더>\codex.exe`)에서 찾습니다.
|
|
22
|
+
|
|
23
|
+
## bind: 폴더에 프로젝트 고정
|
|
24
|
+
|
|
25
|
+
bind 는 웹 콘솔의 "폴더 ZIP 받기" 와 같은 한 벌을 그 폴더에 직접 씁니다(정본은 k2g-console-web
|
|
26
|
+
`src/lib/folderBundle.ts`). ZIP 의 `README.md` 는 쓰지 않고, 안내는 화면에 냅니다.
|
|
27
|
+
|
|
28
|
+
| 클라이언트 | 연결 설정 | 함께 쓰는 것 |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| claude | `.mcp.json` | `.claude/settings.json` 의 `enabledMcpjsonServers` 에 서버 이름, 폴더 스킬 `.claude/skills/<이름>/` |
|
|
31
|
+
| cursor | `.cursor/mcp.json` | 폴더 스킬 `.agents/skills/<이름>/` |
|
|
32
|
+
| codex | `.codex/config.toml` | 폴더 스킬 `.agents/skills/<이름>/`, `.gitignore` 에 `/.codex/config.toml` |
|
|
33
|
+
| gemini | `.gemini/settings.json` | 폴더 스킬 `.agents/skills/<이름>/`, `.gitignore` 에 `/.gemini/settings.json` |
|
|
34
|
+
|
|
35
|
+
### 폴더 스킬
|
|
36
|
+
|
|
37
|
+
- 스킬 일곱 개(`aiakiv-onboarding`, `aiakiv-save-and-recall`, `aiakiv-graph-query`, `aiakiv-links`, `aiakiv-cards`,
|
|
38
|
+
`aiakiv-console`, `aiakiv-contents`)를 공개 저장소 [AiAkiv/aiakiv-plugins](https://github.com/AiAkiv/aiakiv-plugins)
|
|
39
|
+
에서 받습니다. 받는 주소는 `https://codeload.github.com/AiAkiv/aiakiv-plugins/tar.gz/refs/heads/main` 이고,
|
|
40
|
+
압축 파일 안의 플러그인 매니페스트(`aiakiv/.claude-plugin/plugin.json`)가 가리키는 `skills/` 아래만 꺼냅니다.
|
|
41
|
+
각 스킬 폴더의 참고 파일도 모두 복사하고, 판 번호는 그 매니페스트에서 읽어 화면에 냅니다
|
|
42
|
+
(예: `폴더 스킬 7개: .agents/skills (판 0.4.24)`).
|
|
43
|
+
- 자리는 콘솔 ZIP 과 같습니다. Claude Code 는 `.claude/skills`, Codex 와 Gemini CLI 는 `.agents/skills` 한 벌입니다
|
|
44
|
+
(둘이 같은 자리를 읽어 두 벌 두면 Codex 에서 겹쳐 보입니다).
|
|
45
|
+
- Cursor 도 `.agents/skills` 에 넣습니다. Cursor 문서(cursor.com/docs/context/skills)가 이 자리를 프로젝트 스킬
|
|
46
|
+
자리로 적고 있고, Skills CLI 의 cursor 설정도 같은 자리입니다(2026-10-14 확인). 콘솔 ZIP 에는 Cursor 가 없습니다.
|
|
47
|
+
- 같은 이름의 스킬 폴더는 통째로 바꾸고(그 안의 예전 파일은 사라집니다), 그 밖의 파일과 다른 스킬은 건드리지 않습니다.
|
|
48
|
+
같은 이름 자리가 심볼릭 링크나 정션이면 링크만 지우고 가리키던 곳은 그대로 둡니다.
|
|
49
|
+
- 압축 파일 안에 절대 경로나 `..` 가 든 경로가 하나라도 있거나, 스킬 자리 안에 심볼릭 링크, 하드 링크, Windows 에서
|
|
50
|
+
쓸 수 없는 이름이 있으면 스킬을 하나도 쓰지 않습니다.
|
|
51
|
+
- 받기에 실패해도 bind 는 멈추지 않습니다(연결 설정은 이미 썼고 종료 코드는 0). 대신 그 폴더에서 칠 명령을 보여 줍니다:
|
|
52
|
+
`npx -y skills add AiAkiv/aiakiv-plugins -a <claude-code|codex|gemini-cli|cursor> -s '*' -y`
|
|
53
|
+
(`--all` 과 `-g` 는 쓰지 않습니다. `--all` 은 모든 에이전트 폴더에 퍼지고, `-g` 는 전역에 넣습니다).
|
|
54
|
+
- `--no-skills` 면 스킬만 건너뜁니다. 연결 설정, `.claude/settings.json`, `.gitignore` 는 그대로 씁니다.
|
|
55
|
+
|
|
56
|
+
### Claude 의 enabledMcpjsonServers
|
|
57
|
+
|
|
58
|
+
`.claude/settings.json` 의 `enabledMcpjsonServers` 배열에 서버 이름을 더합니다. 폴더를 신뢰한 뒤에는 Claude Code 가
|
|
59
|
+
이 서버를 묻지 않고 켭니다. 다른 키와 다른 값은 그대로 두고, 이미 있으면 파일을 다시 쓰지 않습니다. 주석이나 끝 쉼표가
|
|
60
|
+
있는 파일은 읽되 다시 쓸 때 원본을 `<파일>.aiakiv-backup` 으로 남깁니다. 배열이 아니면 바꾸지 않고 알리기만 합니다.
|
|
61
|
+
|
|
62
|
+
### 키 파일과 .gitignore
|
|
63
|
+
|
|
64
|
+
Codex 와 Gemini 는 설정 파일에 키가 들어갑니다. 그 폴더가 git 저장소이거나 `.gitignore` 가 이미 있으면
|
|
65
|
+
`/.codex/config.toml`, `/.gemini/settings.json` 줄을 그 폴더의 `.gitignore` 에 더합니다(이미 무시되면 더하지 않습니다).
|
|
66
|
+
둘 다 아니면 더하지 않고 경고만 합니다.
|
|
67
|
+
|
|
68
|
+
### 연결 설정의 모양
|
|
69
|
+
|
|
70
|
+
| 클라이언트 | 설정 파일(폴더 기준) | 인증 | 쓰는 내용 |
|
|
71
|
+
|---|---|---|---|
|
|
72
|
+
| claude | `.mcp.json` | OAuth | `mcpServers["AiAkiv-<프로젝트>"] = {type: "http", url: ".../mcp?project=<이름>", headers?}` |
|
|
73
|
+
| cursor | `.cursor/mcp.json` | OAuth | claude 와 같은 모양 |
|
|
74
|
+
| codex | `.codex/config.toml` | 프로젝트 키 | `[mcp_servers.AiAkiv-<프로젝트>]` 의 `url`, `http_headers = { "Authorization" = "Bearer <키>" }` |
|
|
75
|
+
| gemini | `.gemini/settings.json` | 프로젝트 키 | `mcpServers["AiAkiv-<프로젝트>"] = {httpUrl, headers: {Authorization: "Bearer <키>"}}` |
|
|
76
|
+
|
|
77
|
+
- 서버 이름 규칙은 웹 콘솔(k2g-console-web `src/lib/mcpSnippet.ts`)과 같은 코드입니다(`src/snippet.ts`).
|
|
78
|
+
- 기존 설정 파일의 다른 항목은 그대로 두고, 같은 이름의 항목만 덮어씁니다. 읽을 수 없는 파일은 건드리지 않고 멈춥니다.
|
|
79
|
+
- JSON 설정 파일에 주석이나 끝 쉼표가 있으면(Cursor 의 `mcp.json` 은 이것을 받아 줍니다) 읽기는 하지만, 다시 쓸 때
|
|
80
|
+
표준 JSON 이 되면서 주석과 끝 쉼표가 사라집니다. 그때는 원본을 `<파일>.aiakiv-backup` 으로 남기고 알립니다.
|
|
81
|
+
원본에 예전 키가 들어 있을 수 있으니 확인한 뒤 지우세요.
|
|
82
|
+
|
|
83
|
+
### codex, gemini: 브라우저 인계로 키 받기
|
|
84
|
+
|
|
85
|
+
1. CLI 가 `127.0.0.1` 의 빈 포트에서 기다리고, 웹 콘솔의 인계 화면
|
|
86
|
+
(`<콘솔>/account/cli/handoff?project=…&port=…&state=…&client=…`)을 브라우저로 엽니다.
|
|
87
|
+
브라우저가 열리지 않으면 화면에 나온 주소를 직접 엽니다(`--no-browser` 는 열지 않고 주소만 보여 줍니다).
|
|
88
|
+
2. 웹 콘솔에서 로그인하고 확인 단추를 누릅니다. 프로젝트가 없으면 그 화면에서 만들 수 있습니다.
|
|
89
|
+
3. 콘솔이 일회용 코드를 들려 `http://127.0.0.1:<포트>/callback` 으로 돌려보내면, CLI 가 `state` 를 대조하고
|
|
90
|
+
`POST <콘솔>/api/cli/exchange` 로 코드를 키로 바꿉니다. 5분 안에 확인이 오지 않으면 끝냅니다.
|
|
91
|
+
4. 키를 그 폴더의 설정 파일에 쓰고, 화면에는 키 id, 서버 이름, 파일 경로만 냅니다.
|
|
92
|
+
|
|
93
|
+
### claude, cursor: 이름 규칙의 한계
|
|
94
|
+
|
|
95
|
+
OAuth 길은 키를 받지 않으므로 프로젝트 id 를 모릅니다. 콘솔은 영문이 아닌 글자가 든 이름이나 긴 이름에
|
|
96
|
+
프로젝트 id 조각을 붙이는데(`K2G-연구` 는 `AiAkiv-K2G-3f9a1c`), CLI 는 그 자리에 **프로젝트 이름의 해시 6자**를
|
|
97
|
+
붙입니다(`AiAkiv-K2G-<해시>`). 그래서 이런 이름은 콘솔이 주는 이름과 다릅니다. 서버 이름은 이 PC 안의 꼬리표라
|
|
98
|
+
동작은 같고, 같은 이름을 원하면 콘솔에서 복사하라고 안내합니다. 같은 프로젝트 주소를 가리키는 `AiAkiv…` 항목이
|
|
99
|
+
이미 있으면 그 이름을 그대로 씁니다. 영문 19자 이내의 이름은 콘솔과 똑같습니다.
|
|
100
|
+
|
|
101
|
+
이 길은 프로젝트가 있는지도 확인하지 않습니다. 없는 프로젝트면 도구를 부를 때 막히므로 콘솔에서 먼저 만듭니다.
|
|
102
|
+
|
|
103
|
+
## doctor: 확인
|
|
104
|
+
|
|
105
|
+
- 폴더의 설정 파일 네 개와 전역 `~/.cursor/mcp.json`, `~/.gemini/settings.json` 에서 AiAkiv 항목을 찾아
|
|
106
|
+
이름, 주소, 인증 방식(OAuth `?project=`, OAuth Main, API 키 Bearer, 환경 변수 키)을 적습니다.
|
|
107
|
+
- `claude mcp list`, `claude plugin list --json`, `codex mcp list --json` 에서 **AiAkiv 항목만** 옮깁니다.
|
|
108
|
+
명령이 없으면 "없음" 으로 적고 계속합니다. 전역 등록은 bind 와 상관없지만, 폴더 연결과 겹치는 연결을 사람이
|
|
109
|
+
알 수 있게 계속 봅니다.
|
|
110
|
+
- 스킬 위치(`~/.claude/skills`, `~/.claude/plugins/cache/aiakiv/aiakiv/*/skills`, `~/.agents/skills`,
|
|
111
|
+
`~/.cursor/skills`, `~/.gemini/skills`, `$CODEX_HOME/skills`, 폴더의 `.claude/skills`, `.agents/skills`)를 봅니다.
|
|
112
|
+
- bind 가 넣는 폴더 스킬 자리(`.claude/skills`, `.agents/skills`)에 일곱 개가 다 있는지 적습니다. 그 자리를 읽는
|
|
113
|
+
클라이언트가 이 폴더에 고정돼 있는데 빠진 스킬이 있으면 다음 단계에 bind 를 다시 하거나 칠 명령을 적습니다.
|
|
114
|
+
- 폴더 `.mcp.json` 의 AiAkiv 항목마다 `.claude/settings.json`(또는 `.claude/settings.local.json`)의
|
|
115
|
+
`enabledMcpjsonServers` 에 그 이름이 있는지 적습니다.
|
|
116
|
+
- 키로 묶인 항목은 MCP `get_save_target` 을 직접 불러 팀, 프로젝트, binding 을 적습니다
|
|
117
|
+
(`initialize`, `notifications/initialized`, `tools/call` 순서, 프로토콜 판 `2025-06-18`, 응답이 SSE 여도 읽음).
|
|
118
|
+
`--no-probe` 면 부르지 않습니다. OAuth 항목은 "클라이언트 안에서 get_save_target 을 부르라" 는 다음 단계를 적습니다.
|
|
119
|
+
- 결과는 `aiakiv-verification-test.md`(기본은 그 폴더)에 씁니다. 키로 묶인 연결이 실패하면 종료 코드 1 입니다.
|
|
120
|
+
|
|
121
|
+
## 키 평문을 내지 않는 원칙
|
|
122
|
+
|
|
123
|
+
- 받은 키 평문은 **그 폴더의 설정 파일에만** 씁니다. 화면, 로그, 오류 문구, doctor 결과 파일에는 쓰지 않습니다
|
|
124
|
+
(키 id 만, doctor 는 앞 8자 + `…`).
|
|
125
|
+
- CLI 는 토큰이나 키를 홈 폴더 같은 자기 저장소에 두지 않습니다. `login` 명령이 없습니다.
|
|
126
|
+
- macOS, Linux 에서는 키가 든 설정 파일을 주인만 읽게(0600) 씁니다.
|
|
127
|
+
- 키가 든 설정 파일은 git 저장소이거나 `.gitignore` 가 있는 폴더면 `.gitignore` 에 더하고, 아니면 경고합니다.
|
|
128
|
+
**키가 든 파일을 저장소에 올리지 마세요.**
|
|
129
|
+
|
|
130
|
+
## TOML 주석 주의
|
|
131
|
+
|
|
132
|
+
`.codex/config.toml` 에 **새 이름**을 더할 때는 파일 끝에 표 하나를 덧붙이고 다시 읽어 확인하므로 주석과 줄 모양이
|
|
133
|
+
그대로 남습니다. **같은 이름의 항목을 바꿀 때**(또는 `mcp_servers` 를 인라인 표로 적어 덧붙일 수 없을 때)는
|
|
134
|
+
파일을 읽어 고친 뒤 다시 쓰므로 **그 파일의 주석과 원래 줄 모양이 사라집니다**(값은 보존). 그렇게 됐을 때는
|
|
135
|
+
화면에 알립니다.
|
|
136
|
+
|
|
137
|
+
## 환경 변수
|
|
138
|
+
|
|
139
|
+
| 이름 | 기본값 | 뜻 |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `AIAKIV_MCP_URL` | `https://mcp.aiakiv.com/mcp` | MCP 주소. 끝의 `/mcp` 는 빼고 줘도 됩니다. |
|
|
142
|
+
| `AIAKIV_CONTROL_URL` | `https://app.aiakiv.com` | 웹 콘솔 주소(인계 화면과 키 교환). |
|
|
143
|
+
| `AIAKIV_SKILLS_TARBALL_URL` | `https://codeload.github.com/AiAkiv/aiakiv-plugins/tar.gz/refs/heads/main` | 폴더 스킬을 받는 tar.gz 주소(http 나 https). 시험은 로컬 서버로 바꿉니다. |
|
|
144
|
+
| `CODEX_HOME` | `~/.codex` | Codex 설정 위치(플러그인 캐시, 스킬 확인에 씁니다). |
|
|
145
|
+
|
|
146
|
+
## 개발
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
npm install
|
|
150
|
+
npm run build # tsc 가 dist/ 를 만듭니다
|
|
151
|
+
npm test # build 뒤 node --test (외부 시험 도구 없음)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
의존성은 TOML 파서 `smol-toml` 하나입니다. 인자 해석은 `node:util` 의 `parseArgs`, HTTP 는 내장 `http` 와 `fetch`,
|
|
155
|
+
폴더 스킬의 tar.gz 는 내장 `zlib` 와 작은 tar 읽기(`src/tar.ts`, ustar 와 pax 긴 이름),
|
|
156
|
+
브라우저 열기는 운영체제 명령(Windows `cmd /c start`, macOS `open`, Linux `xdg-open`)을 씁니다.
|
|
157
|
+
|
|
158
|
+
## 라이선스
|
|
159
|
+
|
|
160
|
+
MIT
|
package/dist/bind.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// `aiakiv bind --client … --project <이름> [--dir <폴더>]` — 폴더에 프로젝트를 고정한다.
|
|
2
|
+
//
|
|
3
|
+
// - claude, cursor: `.mcp.json`, `.cursor/mcp.json` 에 `?project=` 주소(OAuth). 키를 받지 않으므로
|
|
4
|
+
// 프로젝트 id 를 모른다. 서버 이름은 folderServerNameWithoutId(영문 19자 이내 이름은 콘솔과 같고,
|
|
5
|
+
// id 조각이 필요한 이름만 이름 해시로 대신한다). 같은 프로젝트 주소의 AiAkiv 항목이 이미 있으면
|
|
6
|
+
// 그 이름을 그대로 쓴다(콘솔에서 복사해 둔 항목과 겹치지 않게).
|
|
7
|
+
// - codex, gemini: 브라우저 인계(handoff.ts)로 프로젝트 키를 받아 `.codex/config.toml`,
|
|
8
|
+
// `.gemini/settings.json` 에 Bearer 로 쓴다. 평문은 그 파일에만 있다.
|
|
9
|
+
//
|
|
10
|
+
// 연결 설정 뒤에 웹 콘솔 "폴더 ZIP 받기" 와 같은 나머지를 더한다(정본 k2g-console-web
|
|
11
|
+
// `src/lib/folderBundle.ts`):
|
|
12
|
+
// - 폴더 스킬(skills.ts). `--no-skills` 면 건너뛴다. 받기에 실패해도 bind 는 성공으로 끝난다.
|
|
13
|
+
// - claude: `.claude/settings.json` 의 enabledMcpjsonServers 에 서버 이름(폴더를 신뢰한 뒤 묻지 않고 켠다).
|
|
14
|
+
// - codex, gemini: 키가 든 설정 파일을 `.gitignore` 에 더한다(git 저장소이거나 `.gitignore` 가 있을 때).
|
|
15
|
+
// ZIP 의 README.md 는 쓰지 않는다(안내는 화면에 낸다).
|
|
16
|
+
import { appendFileSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { CLAUDE_FOLDER_SETTINGS, FOLDER_CONFIG } from "./clients/types.js";
|
|
19
|
+
import { addToJsonArray, jsonServers, readJsonObject, upsertJsonServer } from "./config/json.js";
|
|
20
|
+
import { upsertCodexServer } from "./config/toml.js";
|
|
21
|
+
import { exchangeCode, handoffUrl, HANDOFF_TIMEOUT_MS, newState, startLoopback } from "./handoff.js";
|
|
22
|
+
import { installFolderSkills } from "./skills.js";
|
|
23
|
+
import { folderServerName, folderServerNameWithoutId, projectBinding } from "./snippet.js";
|
|
24
|
+
import { canonicalMcpUrl, controlBase, mcpBase } from "./urls.js";
|
|
25
|
+
function resolveDir(ctx, dir) {
|
|
26
|
+
const d = resolve(ctx.cwd, dir ?? ".");
|
|
27
|
+
if (!existsSync(d) || !statSync(d).isDirectory())
|
|
28
|
+
throw new Error(`폴더가 없습니다: ${d}`);
|
|
29
|
+
return d;
|
|
30
|
+
}
|
|
31
|
+
export async function runBind(ctx, opts) {
|
|
32
|
+
const project = opts.project;
|
|
33
|
+
if (!project.trim()) {
|
|
34
|
+
ctx.err("--project 에 프로젝트 이름을 주세요.");
|
|
35
|
+
return 2;
|
|
36
|
+
}
|
|
37
|
+
let dir;
|
|
38
|
+
try {
|
|
39
|
+
dir = resolveDir(ctx, opts.dir);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
ctx.err(e.message);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return opts.client === "claude" || opts.client === "cursor"
|
|
47
|
+
? await bindOAuth(ctx, opts.client, project, dir, opts)
|
|
48
|
+
: await bindKey(ctx, opts.client, project, dir, opts);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
ctx.err(e.message);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function bindOAuth(ctx, client, project, dir, opts) {
|
|
56
|
+
const file = join(dir, FOLDER_CONFIG[client]);
|
|
57
|
+
const b = projectBinding("oauth", mcpBase(ctx.env), project, "");
|
|
58
|
+
const servers = jsonServers(readJsonObject(file));
|
|
59
|
+
const reuse = Object.entries(servers).find(([n, e]) => /^AiAkiv/.test(n) && e.url === b.url)?.[0];
|
|
60
|
+
let name;
|
|
61
|
+
if (reuse) {
|
|
62
|
+
name = reuse;
|
|
63
|
+
ctx.out(`같은 프로젝트를 가리키는 항목 ${name} 이 이미 있어 그 이름을 씁니다.`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const n = folderServerNameWithoutId(project);
|
|
67
|
+
name = n.name;
|
|
68
|
+
if (n.needsId) {
|
|
69
|
+
ctx.out("프로젝트 이름에 영문이 아닌 글자가 있거나 길어서, 서버 이름에 콘솔은 프로젝트 id 조각을 붙입니다.");
|
|
70
|
+
ctx.out(`CLI 는 id 를 모르므로 이름에서 만든 조각을 붙였습니다: ${name}`);
|
|
71
|
+
ctx.out("콘솔의 '이 프로젝트 MCP 설정' 과 같은 이름을 원하면 거기서 복사하세요. 동작에는 차이가 없습니다.");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const entry = { type: "http", url: b.url, ...(Object.keys(b.headers).length ? { headers: b.headers } : {}) };
|
|
75
|
+
const r = upsertJsonServer(file, name, entry);
|
|
76
|
+
ctx.out(`${r.replaced ? "바꿨습니다" : "더했습니다"}: ${file}`);
|
|
77
|
+
ctx.out(` 서버 이름 ${name}`);
|
|
78
|
+
ctx.out(` 주소 ${b.url}`);
|
|
79
|
+
if (r.backup)
|
|
80
|
+
ctx.out(` 참고: 원래 파일에 주석이나 끝 쉼표가 있어 표준 JSON 으로 다시 썼습니다. 원본은 ${r.backup} 에 남겼습니다.`);
|
|
81
|
+
ctx.out("이 폴더 고정은 OAuth 입니다. 키를 받지 않고, 프로젝트가 있는지도 여기서는 확인하지 않습니다.");
|
|
82
|
+
ctx.out("프로젝트가 없으면 도구를 부를 때 막히니 웹 콘솔에서 먼저 만들어 두세요.");
|
|
83
|
+
const enabled = client === "claude" ? enableClaudeFolderServer(ctx, dir, name) : false;
|
|
84
|
+
if (opts.skills !== false)
|
|
85
|
+
await installFolderSkills(ctx, client, dir);
|
|
86
|
+
else
|
|
87
|
+
ctx.out("폴더 스킬: --no-skills 라 건너뜁니다.");
|
|
88
|
+
ctx.out("다음 할 일(사람):");
|
|
89
|
+
if (client === "claude") {
|
|
90
|
+
ctx.out(enabled
|
|
91
|
+
? " - 그 폴더에서 claude 를 실행하고, 이 폴더를 신뢰하겠냐고 물으면 신뢰한다고 답하세요(이 서버는 미리 켜 두었습니다)."
|
|
92
|
+
: " - 그 폴더에서 claude 를 실행하고, 이 폴더의 MCP 설정을 신뢰할지 물으면 승인하세요.");
|
|
93
|
+
ctx.out(` - /mcp 에서 ${name} 을 골라 브라우저로 로그인하세요.`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
ctx.out(` - Cursor 로 그 폴더를 열고 MCP 설정에서 ${name} 을 켠 뒤 안내에 따라 로그인하세요.`);
|
|
97
|
+
}
|
|
98
|
+
ctx.out(" - 클라이언트 안에서 get_save_target 을 불러 binding 이 folder 인지 확인하세요.");
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
/** `.claude/settings.json` 의 enabledMcpjsonServers 에 서버 이름을 더한다. 목록에 들어 있으면 true.
|
|
102
|
+
* 파일을 읽을 수 없으면 알리기만 하고 false(연결 설정은 이미 썼다). */
|
|
103
|
+
function enableClaudeFolderServer(ctx, dir, name) {
|
|
104
|
+
const file = join(dir, CLAUDE_FOLDER_SETTINGS);
|
|
105
|
+
try {
|
|
106
|
+
const r = addToJsonArray(file, "enabledMcpjsonServers", name);
|
|
107
|
+
ctx.out(r.added ? `더했습니다: ${file}` : `그대로 둡니다: ${file}`);
|
|
108
|
+
ctx.out(` enabledMcpjsonServers 에 ${name} ${r.added ? "을 더했습니다" : "이 이미 있습니다"}`);
|
|
109
|
+
if (r.backup)
|
|
110
|
+
ctx.out(` 참고: 원래 파일에 주석이나 끝 쉼표가 있어 표준 JSON 으로 다시 썼습니다. 원본은 ${r.backup} 에 남겼습니다.`);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
ctx.err(`${e.message}`);
|
|
115
|
+
ctx.err(` ${file} 의 enabledMcpjsonServers 에 "${name}" 을 직접 더하거나, Claude Code 가 물을 때 승인하세요.`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** 키가 든 설정 파일이 git 에서 빠져 있는가와, 그 폴더가 git 저장소인가. 알 수 없으면 둘 다 false. */
|
|
120
|
+
export async function gitIgnoreStatus(ctx, dir, rel) {
|
|
121
|
+
const git = ctx.which("git");
|
|
122
|
+
if (git) {
|
|
123
|
+
const r = await ctx.run(git, ["-C", dir, "check-ignore", "-q", rel], { timeoutMs: 15_000 });
|
|
124
|
+
if (r.code === 0)
|
|
125
|
+
return { ignored: true, repo: true };
|
|
126
|
+
if (r.code === 1)
|
|
127
|
+
return { ignored: false, repo: true };
|
|
128
|
+
// 128 = git 저장소가 아니다. 아래 단순 확인으로.
|
|
129
|
+
}
|
|
130
|
+
let repo = false;
|
|
131
|
+
for (let d = dir;; d = dirname(d)) {
|
|
132
|
+
if (existsSync(join(d, ".git"))) {
|
|
133
|
+
repo = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (dirname(d) === d)
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
const gi = join(dir, ".gitignore");
|
|
140
|
+
if (!existsSync(gi))
|
|
141
|
+
return { ignored: false, repo };
|
|
142
|
+
const first = rel.split("/")[0];
|
|
143
|
+
const wanted = new Set([rel, `/${rel}`, first, `${first}/`, `/${first}`, `/${first}/`]);
|
|
144
|
+
const ignored = readFileSync(gi, "utf8")
|
|
145
|
+
.split(/\r?\n/)
|
|
146
|
+
.map((l) => l.trim())
|
|
147
|
+
.some((l) => wanted.has(l));
|
|
148
|
+
return { ignored, repo };
|
|
149
|
+
}
|
|
150
|
+
/** 키가 든 파일을 그 폴더의 `.gitignore` 에 더한다(git 저장소이거나 `.gitignore` 가 이미 있을 때만).
|
|
151
|
+
* - "already": 이미 무시된다 - "added": `/<rel>` 줄을 더했다 - "warn": 더하지 않았다(사람에게 알린다) */
|
|
152
|
+
export async function ensureGitIgnored(ctx, dir, rel) {
|
|
153
|
+
const st = await gitIgnoreStatus(ctx, dir, rel);
|
|
154
|
+
if (st.ignored)
|
|
155
|
+
return "already";
|
|
156
|
+
const gi = join(dir, ".gitignore");
|
|
157
|
+
const exists = existsSync(gi);
|
|
158
|
+
if (!st.repo && !exists)
|
|
159
|
+
return "warn";
|
|
160
|
+
const line = `/${rel}`;
|
|
161
|
+
const text = exists ? readFileSync(gi, "utf8") : "";
|
|
162
|
+
// 같은 줄이 있는데도 무시되지 않으면(뒤의 `!` 줄 등) 더해도 소용없다. 사람에게 넘긴다.
|
|
163
|
+
if (text.split(/\r?\n/).some((l) => l.trim() === line))
|
|
164
|
+
return "warn";
|
|
165
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
166
|
+
const lead = text && !text.endsWith("\n") ? eol : "";
|
|
167
|
+
appendFileSync(gi, `${lead}${line}${eol}`, "utf8");
|
|
168
|
+
return "added";
|
|
169
|
+
}
|
|
170
|
+
function safeMcpUrl(ctx, given) {
|
|
171
|
+
if (typeof given === "string") {
|
|
172
|
+
try {
|
|
173
|
+
const u = new URL(given);
|
|
174
|
+
if (u.protocol === "https:" || u.protocol === "http:")
|
|
175
|
+
return given;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* 아래 기본값 */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return canonicalMcpUrl(ctx.env);
|
|
182
|
+
}
|
|
183
|
+
async function bindKey(ctx, client, project, dir, opts) {
|
|
184
|
+
const control = controlBase(ctx.env);
|
|
185
|
+
const state = newState();
|
|
186
|
+
const lb = await startLoopback(state, opts.timeoutMs ?? HANDOFF_TIMEOUT_MS);
|
|
187
|
+
const url = handoffUrl(control, { project, port: lb.port, state, client, expiresDays: opts.expiresDays });
|
|
188
|
+
ctx.out(`웹 콘솔에서 프로젝트 ${project} 의 키를 이 PC 로 넘깁니다(프로젝트가 없으면 그 화면에서 만들 수 있습니다).`);
|
|
189
|
+
ctx.out("브라우저에서 로그인하고 확인 단추를 누르세요. 브라우저가 열리지 않으면 이 주소를 직접 여세요:");
|
|
190
|
+
ctx.out(` ${url}`);
|
|
191
|
+
if (!opts.noBrowser) {
|
|
192
|
+
const opened = await ctx.openUrl(url);
|
|
193
|
+
if (!opened)
|
|
194
|
+
ctx.err("브라우저를 열지 못했습니다. 위 주소를 직접 여세요.");
|
|
195
|
+
}
|
|
196
|
+
ctx.out(`기다리는 중(최대 ${Math.round((opts.timeoutMs ?? HANDOFF_TIMEOUT_MS) / 60_000)}분, 127.0.0.1:${lb.port})...`);
|
|
197
|
+
let code;
|
|
198
|
+
try {
|
|
199
|
+
code = await lb.code;
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
lb.close();
|
|
203
|
+
ctx.err(e.message);
|
|
204
|
+
ctx.err("명령을 다시 실행하세요.");
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
const res = await exchangeCode(ctx.fetch, control, { code, state, port: lb.port });
|
|
208
|
+
const name = res.project_id
|
|
209
|
+
? folderServerName(res.project, res.project_id)
|
|
210
|
+
: folderServerNameWithoutId(res.project).name;
|
|
211
|
+
const mcpUrl = safeMcpUrl(ctx, res.mcp_url);
|
|
212
|
+
const headers = { Authorization: `Bearer ${res.plaintext}` };
|
|
213
|
+
const rel = FOLDER_CONFIG[client];
|
|
214
|
+
const file = join(dir, rel);
|
|
215
|
+
let rewritten = false;
|
|
216
|
+
let backup;
|
|
217
|
+
let replaced;
|
|
218
|
+
if (client === "codex") {
|
|
219
|
+
const r = upsertCodexServer(file, name, mcpUrl, headers, { secret: true });
|
|
220
|
+
rewritten = r.rewritten;
|
|
221
|
+
replaced = r.replaced;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
const r = upsertJsonServer(file, name, { httpUrl: mcpUrl, headers }, { secret: true });
|
|
225
|
+
replaced = r.replaced;
|
|
226
|
+
backup = r.backup;
|
|
227
|
+
}
|
|
228
|
+
ctx.out();
|
|
229
|
+
ctx.out(`프로젝트 키를 받았습니다. 키 id ${res.key_id}`);
|
|
230
|
+
if (res.created_project)
|
|
231
|
+
ctx.out(`프로젝트 ${res.project} 를 새로 만들었습니다.`);
|
|
232
|
+
if (res.org_name)
|
|
233
|
+
ctx.out(`팀 ${res.org_name}`);
|
|
234
|
+
if (res.expires_at)
|
|
235
|
+
ctx.out(`만료 ${res.expires_at}`);
|
|
236
|
+
if (typeof res.superseded === "number" && res.superseded > 0) {
|
|
237
|
+
ctx.out(`이전 키 ${res.superseded}개를 폐기했습니다. 다른 폴더에 같은 프로젝트 키를 넣어 두었다면 그 폴더도 다시 bind 하세요.`);
|
|
238
|
+
}
|
|
239
|
+
ctx.out(`${replaced ? "바꿨습니다" : "더했습니다"}: ${file}`);
|
|
240
|
+
ctx.out(` 서버 이름 ${name}`);
|
|
241
|
+
ctx.out(` 주소 ${mcpUrl}`);
|
|
242
|
+
if (backup) {
|
|
243
|
+
ctx.out(` 참고: 원래 파일에 주석이나 끝 쉼표가 있어 표준 JSON 으로 다시 썼습니다. 원본은 ${backup} 에 남겼습니다. 원본에는 예전 키가 들어 있을 수 있으니 확인한 뒤 지우세요.`);
|
|
244
|
+
}
|
|
245
|
+
if (rewritten)
|
|
246
|
+
ctx.out(" 참고: 같은 이름의 항목을 바꾸느라 파일을 다시 썼습니다. 이 파일에 있던 주석은 사라졌습니다.");
|
|
247
|
+
let ignore;
|
|
248
|
+
try {
|
|
249
|
+
ignore = await ensureGitIgnored(ctx, dir, rel);
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
ctx.err(`.gitignore 를 고치지 못했습니다(${e.message}).`);
|
|
253
|
+
ignore = "warn";
|
|
254
|
+
}
|
|
255
|
+
if (ignore === "added")
|
|
256
|
+
ctx.out(`더했습니다: ${join(dir, ".gitignore")} 에 /${rel}`);
|
|
257
|
+
if (ignore === "warn") {
|
|
258
|
+
ctx.err(`주의: ${rel} 이 .gitignore 에 없습니다. 키가 든 파일이니 저장소에 올리지 마세요(.gitignore 에 /${rel} 을 더하세요).`);
|
|
259
|
+
}
|
|
260
|
+
if (opts.skills !== false)
|
|
261
|
+
await installFolderSkills(ctx, client, dir);
|
|
262
|
+
else
|
|
263
|
+
ctx.out("폴더 스킬: --no-skills 라 건너뜁니다.");
|
|
264
|
+
ctx.out("다음 할 일:");
|
|
265
|
+
if (client === "codex") {
|
|
266
|
+
ctx.out(" - 그 폴더에서 codex 를 실행하고, 이 폴더를 신뢰하겠냐고 물으면 신뢰한다고 답하세요. 앱은 다시 시작하세요.");
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
ctx.out(" - 그 폴더에서 gemini 를 실행하세요.");
|
|
270
|
+
}
|
|
271
|
+
ctx.out(` - 확인: npx aiakiv doctor --dir "${dir}"`);
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// 브라우저 열기. `open` 패키지 대신 운영체제 명령을 직접 부른다.
|
|
2
|
+
//
|
|
3
|
+
// Windows 는 `cmd /c start "" "<주소>"` 인데, 주소를 명령줄에 그대로 넣으면 cmd 가 `&` 를 명령
|
|
4
|
+
// 구분자로, `%XX%` 를 환경 변수로 읽는다(프로젝트 이름은 퍼센트 인코딩돼 들어온다). 그래서 주소는
|
|
5
|
+
// 환경 변수 AIAKIV_OPEN_URL 로 넘기고 명령줄에는 "%AIAKIV_OPEN_URL%" 만 적는다. cmd 는 변수를
|
|
6
|
+
// 한 번만 펼치고 펼친 값 안의 `%` 는 다시 읽지 않으며, 큰따옴표 안의 `&` 는 글자로 둔다.
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
/** 운영체제별 브라우저 열기 명령. `verb` 는 시험용(Windows 에서 start 대신 echo 로 바꿔 본다). */
|
|
9
|
+
export function openCommand(url, platform, verb = 'start ""') {
|
|
10
|
+
if (platform === "win32") {
|
|
11
|
+
return {
|
|
12
|
+
file: "cmd.exe",
|
|
13
|
+
args: ["/d", "/c", `${verb} "%AIAKIV_OPEN_URL%"`],
|
|
14
|
+
env: { ...process.env, AIAKIV_OPEN_URL: url },
|
|
15
|
+
windowsVerbatimArguments: true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (platform === "darwin")
|
|
19
|
+
return { file: "open", args: [url] };
|
|
20
|
+
return { file: "xdg-open", args: [url] };
|
|
21
|
+
}
|
|
22
|
+
export function openUrl(url, platform) {
|
|
23
|
+
const c = openCommand(url, platform);
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
try {
|
|
26
|
+
const child = spawn(c.file, c.args, {
|
|
27
|
+
stdio: "ignore",
|
|
28
|
+
detached: platform !== "win32",
|
|
29
|
+
env: c.env ?? process.env,
|
|
30
|
+
windowsVerbatimArguments: c.windowsVerbatimArguments,
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
});
|
|
33
|
+
child.on("error", () => resolve(false));
|
|
34
|
+
child.on("spawn", () => {
|
|
35
|
+
child.unref();
|
|
36
|
+
resolve(true);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
resolve(false);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realCtx } from "./ctx.js";
|
|
3
|
+
import { main } from "./main.js";
|
|
4
|
+
main(process.argv.slice(2), realCtx()).then((code) => {
|
|
5
|
+
process.exitCode = code;
|
|
6
|
+
}, (e) => {
|
|
7
|
+
process.stderr.write(`오류: ${e?.message ?? String(e)}\n`);
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Claude Code. doctor 가 전역 등록 목록을 읽는 데 쓴다(겹치는 연결을 사람에게 알리는 용도).
|
|
2
|
+
// 확인한 명령: `claude mcp list`, `claude plugin list --json`.
|
|
3
|
+
/** `claude mcp list` 의 `<이름>: <주소 또는 명령> - <상태>` 줄을 읽는다. */
|
|
4
|
+
export function parseClaudeMcpList(text) {
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const line of text.split(/\r?\n/)) {
|
|
7
|
+
const m = /^(\S.*?): (\S.*) - (\S.*)$/.exec(line.trim());
|
|
8
|
+
if (m)
|
|
9
|
+
out.push({ name: m[1], target: m[2].replace(/\s+\((SSE|HTTP|stdio)\)$/i, ""), status: m[3] });
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
/** 연결 이름으로 종류를 가른다(binding 은 셋 다 main 이라 못 가른다). */
|
|
14
|
+
export function classifyClaudeName(name) {
|
|
15
|
+
if (/^claude\.ai /.test(name))
|
|
16
|
+
return "account";
|
|
17
|
+
if (/^plugin:/.test(name))
|
|
18
|
+
return "plugin";
|
|
19
|
+
if (/^AiAkiv-/.test(name))
|
|
20
|
+
return "folder";
|
|
21
|
+
return "direct";
|
|
22
|
+
}
|
|
23
|
+
export const CLAUDE_KIND_LABEL = {
|
|
24
|
+
account: "claude.ai 계정 커넥터(Main 을 따라감)",
|
|
25
|
+
plugin: "플러그인 연결(Main 을 따라감)",
|
|
26
|
+
folder: "폴더 연결(프로젝트에 고정)",
|
|
27
|
+
direct: "직접 등록한 연결",
|
|
28
|
+
};
|
|
29
|
+
/** `claude plugin list --json` 에서 설치된 플러그인 id 들. JSON 이 아니면 글자로 찾는다. */
|
|
30
|
+
export function installedPluginIds(text) {
|
|
31
|
+
try {
|
|
32
|
+
const v = JSON.parse(text);
|
|
33
|
+
if (Array.isArray(v))
|
|
34
|
+
return v.map((p) => String(p?.id ?? "")).filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* 아래 글자 찾기로 */
|
|
38
|
+
}
|
|
39
|
+
return [...text.matchAll(/❯\s+(\S+@\S+)/g)].map((m) => m[1]);
|
|
40
|
+
}
|
|
41
|
+
export function findClaude(ctx) {
|
|
42
|
+
return ctx.which("claude");
|
|
43
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Codex(ChatGPT 앱의 CLI). doctor 가 전역 등록 목록(`codex mcp list --json`)을 읽는 데 쓴다.
|
|
2
|
+
// codex 는 PATH 에 없을 수 있다. Windows 는 앱이 `%LOCALAPPDATA%\OpenAI\Codex\bin\<해시>\codex.exe` 에
|
|
3
|
+
// 두므로 그 아래를 훑는다(해시 폴더 이름은 판마다 바뀌어 고정하지 않는다).
|
|
4
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
function subdirs(dir) {
|
|
7
|
+
try {
|
|
8
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
9
|
+
.filter((d) => d.isDirectory())
|
|
10
|
+
.map((d) => join(dir, d.name));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function findCodex(ctx) {
|
|
17
|
+
const onPath = ctx.which("codex");
|
|
18
|
+
if (onPath)
|
|
19
|
+
return onPath;
|
|
20
|
+
if (ctx.platform !== "win32")
|
|
21
|
+
return null;
|
|
22
|
+
const local = ctx.env.LOCALAPPDATA ?? join(ctx.home, "AppData", "Local");
|
|
23
|
+
const found = subdirs(join(local, "OpenAI", "Codex", "bin"))
|
|
24
|
+
.map((d) => join(d, "codex.exe"))
|
|
25
|
+
.filter((p) => existsSync(p))
|
|
26
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
27
|
+
return found[0] ?? null;
|
|
28
|
+
}
|
|
29
|
+
export function codexHome(ctx) {
|
|
30
|
+
return ctx.env.CODEX_HOME?.trim() || join(ctx.home, ".codex");
|
|
31
|
+
}
|
|
32
|
+
/** `codex mcp list --json` 을 읽는다. JSON 이 아니면 표의 첫 열(이름)만. */
|
|
33
|
+
export function parseCodexMcpList(text) {
|
|
34
|
+
try {
|
|
35
|
+
const v = JSON.parse(text);
|
|
36
|
+
if (Array.isArray(v)) {
|
|
37
|
+
return v.map((s) => ({
|
|
38
|
+
name: String(s?.name ?? ""),
|
|
39
|
+
url: typeof s?.transport?.url === "string" ? s.transport.url : undefined,
|
|
40
|
+
enabled: typeof s?.enabled === "boolean" ? s.enabled : undefined,
|
|
41
|
+
})).filter((s) => s.name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* 표로 */
|
|
46
|
+
}
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const line of text.split(/\r?\n/)) {
|
|
49
|
+
const cols = line.trim().split(/\s{2,}/);
|
|
50
|
+
if (!cols[0] || cols[0] === "Name")
|
|
51
|
+
continue;
|
|
52
|
+
const url = cols.find((c) => /^https?:\/\//.test(c));
|
|
53
|
+
out.push({ name: cols[0], url });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const CLIENT_IDS = ["codex", "claude", "gemini", "cursor"];
|
|
2
|
+
/** 폴더 고정 설정 파일(폴더 기준 상대 경로). */
|
|
3
|
+
export const FOLDER_CONFIG = {
|
|
4
|
+
claude: ".mcp.json",
|
|
5
|
+
cursor: ".cursor/mcp.json",
|
|
6
|
+
codex: ".codex/config.toml",
|
|
7
|
+
gemini: ".gemini/settings.json",
|
|
8
|
+
};
|
|
9
|
+
/** Claude Code 가 폴더 `.mcp.json` 의 서버를 묻지 않고 켜는 목록(enabledMcpjsonServers)이 든 파일. */
|
|
10
|
+
export const CLAUDE_FOLDER_SETTINGS = ".claude/settings.json";
|
|
11
|
+
/** 같은 목록을 둘 수 있는 개인용 설정 파일(보통 git 에서 빠진다). doctor 는 둘 다 본다. */
|
|
12
|
+
export const CLAUDE_FOLDER_SETTINGS_LOCAL = ".claude/settings.local.json";
|
|
13
|
+
/** 이 연결이 AiAkiv 것인가 — 이름이나 주소로 가린다. */
|
|
14
|
+
export function looksLikeAiAkiv(name, url, mcpBase) {
|
|
15
|
+
return /aiakiv/i.test(name) || /aiakiv/i.test(url ?? "") || (!!url && url.startsWith(mcpBase));
|
|
16
|
+
}
|