aiakiv 0.1.0 → 0.2.1
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 +66 -10
- package/dist/bind.js +22 -14
- package/dist/clients/global.js +50 -0
- package/dist/console.js +227 -0
- package/dist/consoleApi.js +215 -0
- package/dist/consoleKeyFile.js +64 -0
- package/dist/ctx.js +13 -0
- package/dist/dashboard.js +197 -0
- package/dist/doctor.js +103 -23
- package/dist/handoff.js +55 -6
- package/dist/main.js +202 -25
- package/dist/mcpProbe.js +3 -1
- package/dist/render.js +108 -0
- package/dist/setup.js +275 -0
- package/dist/skills.js +14 -2
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# aiakiv
|
|
2
2
|
|
|
3
3
|
폴더 하나를 AiAkiv 프로젝트에 고정하는 명령줄 도구입니다(Claude Code, Codex, Gemini CLI, Cursor).
|
|
4
|
-
|
|
4
|
+
Gemini CLI 와 Antigravity 는 전역 설치도 합니다. 설계는 K2G `docs/204_blue_print-165-aiakiv_cli_setup_and_key_handoff.md` 에 있습니다.
|
|
5
5
|
|
|
6
6
|
```
|
|
7
7
|
npx aiakiv bind --client codex|claude|gemini|cursor --project <이름> [--dir <폴더>] [--expires-days N] [--no-browser] [--no-skills]
|
|
8
|
+
npx aiakiv setup --client gemini|antigravity[,...] [--expires-days N] [--no-browser] [--no-skills] [--new-key]
|
|
8
9
|
npx aiakiv doctor [--client <클라이언트>[,...]] [--dir <폴더>] [--out aiakiv-verification-test.md] [--no-probe]
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
전역 설치(계정 연결과 전역 스킬)는
|
|
12
|
-
(`AiAkiv/aiakiv-plugins`)
|
|
12
|
+
전역 설치(계정 연결과 전역 스킬)는 Gemini CLI 와 Antigravity 만 `setup` 으로 합니다. 그 밖은 Claude Code 는
|
|
13
|
+
플러그인 마켓(`AiAkiv/aiakiv-plugins`), ChatGPT 와 Codex 는 설치 프롬프트, Cursor 는 홈페이지의 설치 링크를 씁니다.
|
|
14
|
+
`setup --client claude` 처럼 다른 클라이언트를 주면 이 안내를 보여 주고 아무것도 하지 않습니다(종료 코드 2).
|
|
13
15
|
|
|
14
16
|
## 필요한 것
|
|
15
17
|
|
|
@@ -85,7 +87,7 @@ Codex 와 Gemini 는 설정 파일에 키가 들어갑니다. 그 폴더가 git
|
|
|
85
87
|
1. CLI 가 `127.0.0.1` 의 빈 포트에서 기다리고, 웹 콘솔의 인계 화면
|
|
86
88
|
(`<콘솔>/account/cli/handoff?project=…&port=…&state=…&client=…`)을 브라우저로 엽니다.
|
|
87
89
|
브라우저가 열리지 않으면 화면에 나온 주소를 직접 엽니다(`--no-browser` 는 열지 않고 주소만 보여 줍니다).
|
|
88
|
-
2. 웹 콘솔에서 로그인하고 확인
|
|
90
|
+
2. 웹 콘솔에서 로그인하고 확인 버튼을 누릅니다. 프로젝트가 없으면 그 화면에서 만들 수 있습니다.
|
|
89
91
|
3. 콘솔이 일회용 코드를 들려 `http://127.0.0.1:<포트>/callback` 으로 돌려보내면, CLI 가 `state` 를 대조하고
|
|
90
92
|
`POST <콘솔>/api/cli/exchange` 로 코드를 키로 바꿉니다. 5분 안에 확인이 오지 않으면 끝냅니다.
|
|
91
93
|
4. 키를 그 폴더의 설정 파일에 쓰고, 화면에는 키 id, 서버 이름, 파일 경로만 냅니다.
|
|
@@ -100,15 +102,67 @@ OAuth 길은 키를 받지 않으므로 프로젝트 id 를 모릅니다. 콘솔
|
|
|
100
102
|
|
|
101
103
|
이 길은 프로젝트가 있는지도 확인하지 않습니다. 없는 프로젝트면 도구를 부를 때 막히므로 콘솔에서 먼저 만듭니다.
|
|
102
104
|
|
|
105
|
+
Antigravity 는 앱 전체에 설정 파일 하나라 폴더 고정이 없습니다. `bind --client antigravity` 는 사용 오류이고
|
|
106
|
+
`setup --client antigravity` 를 쓰라고 안내합니다.
|
|
107
|
+
|
|
108
|
+
## setup: Gemini CLI 와 Antigravity 전역 설치
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
npx aiakiv setup --client gemini|antigravity[,...] [--expires-days N] [--no-browser] [--no-skills] [--new-key]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`--client` 는 쉼표 목록을 받고, `gemini` 와 `antigravity` 만 됩니다. 순서는 이렇습니다.
|
|
115
|
+
|
|
116
|
+
1. 고른 클라이언트의 전역 설정 파일을 먼저 읽습니다. 읽을 수 없는 파일이 있으면 키를 받기 전에 멈춥니다(종료 코드 1).
|
|
117
|
+
2. 키 재사용 판정(`--new-key` 가 없을 때). 아래 두 전역 파일에서 서버 이름 `AiAkiv` 항목의 `Authorization: Bearer <키>`
|
|
118
|
+
를 찾아 MCP `get_save_target` 을 불러 봅니다. 성공하고 `binding` 이 `main` 이면 그 키를 다시 쓰고 브라우저를 열지
|
|
119
|
+
않습니다. 401 이거나 `binding` 이 다르거나 키가 없으면 새로 받습니다(이유를 한 줄 알립니다).
|
|
120
|
+
3. 새로 받을 때는 브라우저 인계(`<콘솔>/account/cli/handoff?kind=dynamic&port=…&state=…&client=<클라이언트들>`)로
|
|
121
|
+
**Main(저장 대상)을 따라가는 계정 전체 기억 키** 하나를 받습니다(`kind: "dynamic"`, 스코프 `read,write`).
|
|
122
|
+
4. 고른 클라이언트 모두의 전역 설정 파일에 같은 키를 씁니다. 다른 항목과 다른 최상위 키는 그대로 두고, 같은 키와
|
|
123
|
+
주소가 이미 있으면 파일을 다시 쓰지 않습니다.
|
|
124
|
+
5. 전역 스킬을 씁니다(`--no-skills` 면 건너뜀).
|
|
125
|
+
6. 사람이 할 일을 안내합니다.
|
|
126
|
+
|
|
127
|
+
계정 전체 키는 **한 사람에게 하나만 살아 있습니다**. 새로 받으면 앞선 키(다른 PC 에 넣어 둔 것 포함)는 서버가 폐기하고,
|
|
128
|
+
CLI 는 폐기된 개수와 키 id 를 알립니다. 그래서 쓸 수 있는 키가 이미 있으면 새로 받지 않습니다. 새 키가 꼭 필요하면
|
|
129
|
+
`--new-key` 를 줍니다. `--expires-days` 는 새로 받을 때만 쓰이고, 없으면 만료가 없습니다.
|
|
130
|
+
|
|
131
|
+
| 클라이언트 | 전역 설정 파일 | 쓰는 내용 | 전역 스킬 |
|
|
132
|
+
|---|---|---|---|
|
|
133
|
+
| gemini | `~/.gemini/settings.json` | `mcpServers.AiAkiv = {httpUrl: "<MCP 주소>", headers: {Authorization: "Bearer <키>"}}` | `~/.gemini/skills/<스킬>/` |
|
|
134
|
+
| antigravity | `~/.gemini/config/mcp_config.json` | `mcpServers.AiAkiv = {serverUrl: "<MCP 주소>", headers: {Authorization: "Bearer <키>"}}` | `~/.gemini/config/skills/<스킬>/`, 그리고 `~/.gemini/antigravity-cli/` 가 있으면 `~/.gemini/antigravity-cli/skills/<스킬>/` |
|
|
135
|
+
|
|
136
|
+
- MCP 주소는 `AIAKIV_MCP_URL`(기본 `https://mcp.aiakiv.com/mcp`, 물음표 표시 없는 주소)입니다.
|
|
137
|
+
- 서버 이름은 전역이라 `AiAkiv` 입니다. 폴더의 `AiAkiv-<프로젝트>` 와 겹치지 않습니다.
|
|
138
|
+
- Antigravity 는 `serverUrl` 만 받습니다(`url`, `httpUrl`, `type` 은 거부합니다). 파일이 없으면 폴더째 만듭니다.
|
|
139
|
+
- 전역 스킬은 폴더 스킬과 같은 묶음을 한 번 받아 자리마다 씁니다. 같은 이름 스킬 폴더는 통째로 바꾸고 나머지는 둡니다.
|
|
140
|
+
받기에 실패해도 설정 쓰기는 성공으로 두고, Gemini CLI 는 `npx -y skills add AiAkiv/aiakiv-plugins -a gemini-cli -s '*' -g -y`
|
|
141
|
+
를, Antigravity 는 스킬 폴더를 직접 복사하라고 안내합니다(Skills CLI 의 antigravity 전역 자리는 `~/.gemini/antigravity/skills`
|
|
142
|
+
라 setup 의 자리와 다릅니다. `~/.gemini/antigravity-cli/skills` 는 `-a antigravity-cli` 로 안내합니다).
|
|
143
|
+
- `get_save_target` 은 키 id 를 돌려주지 않으므로, 새로 받을 때 키 id 와 키의 지문(sha256 앞 16자, 평문 아님)을
|
|
144
|
+
`~/.gemini/aiakiv-setup.json` 에 적어 둡니다. 키를 다시 쓸 때 지문이 맞으면 "이미 받아 둔 계정 전체 키를 다시 썼습니다(키 id …)"
|
|
145
|
+
로 id 를 보입니다.
|
|
146
|
+
- 홈 폴더 파일이라 `.gitignore` 로 뺄 수 없습니다. 마지막 줄에 "키 평문이 <파일> 에 있습니다. 이 파일을 공유하거나
|
|
147
|
+
커밋하지 마세요." 를 냅니다.
|
|
148
|
+
- 사람이 할 일: Gemini CLI 는 `gemini` 실행 뒤 `/mcp` 에서 AiAkiv 가 연결됐는지, "저장 대상 확인해 줘" 로 `binding` 이 `main`
|
|
149
|
+
인지 봅니다. Antigravity 는 Agent 설정의 MCP Servers 에서 AiAkiv 가 보이는지 봅니다(설정 파일을 바꿨으니 앱을 다시 시작).
|
|
150
|
+
|
|
103
151
|
## doctor: 확인
|
|
104
152
|
|
|
105
|
-
- 폴더의 설정 파일 네 개와 전역 `~/.cursor/mcp.json`, `~/.gemini/settings.json`
|
|
106
|
-
이름, 주소, 인증 방식(OAuth `?project=`, OAuth Main, API 키 Bearer, 환경 변수 키)을
|
|
153
|
+
- 폴더의 설정 파일 네 개와 전역 `~/.cursor/mcp.json`, `~/.gemini/settings.json`, `~/.gemini/config/mcp_config.json`
|
|
154
|
+
(Antigravity) 에서 AiAkiv 항목을 찾아 이름, 주소, 인증 방식(OAuth `?project=`, OAuth Main, API 키 Bearer, 환경 변수 키)을
|
|
155
|
+
적습니다. `--client` 는 `codex`, `claude`, `gemini`, `cursor`, `antigravity` 를 받습니다(기본은 모두).
|
|
156
|
+
폴더가 홈 폴더면 `.gemini/settings.json` 은 전역 파일로 한 번만 봅니다.
|
|
157
|
+
- setup 이 쓴 전역 `AiAkiv` 항목은 `get_save_target` 의 `binding` 이 `main` 이어야 합니다. 401 이면
|
|
158
|
+
`npx aiakiv setup --client …` 로 다시 받으라고, `main` 이 아니면 `--new-key` 로 다시 받으라고 적습니다.
|
|
159
|
+
`--client gemini` 나 `--client antigravity` 를 직접 줬는데 전역 연결이 없으면 setup 명령을 적습니다.
|
|
107
160
|
- `claude mcp list`, `claude plugin list --json`, `codex mcp list --json` 에서 **AiAkiv 항목만** 옮깁니다.
|
|
108
161
|
명령이 없으면 "없음" 으로 적고 계속합니다. 전역 등록은 bind 와 상관없지만, 폴더 연결과 겹치는 연결을 사람이
|
|
109
162
|
알 수 있게 계속 봅니다.
|
|
110
163
|
- 스킬 위치(`~/.claude/skills`, `~/.claude/plugins/cache/aiakiv/aiakiv/*/skills`, `~/.agents/skills`,
|
|
111
|
-
`~/.cursor/skills`, `~/.gemini/skills`,
|
|
164
|
+
`~/.cursor/skills`, `~/.gemini/skills`, `~/.gemini/config/skills`, `~/.gemini/antigravity-cli/skills`,
|
|
165
|
+
`$CODEX_HOME/skills`, 폴더의 `.claude/skills`, `.agents/skills`)를 봅니다.
|
|
112
166
|
- bind 가 넣는 폴더 스킬 자리(`.claude/skills`, `.agents/skills`)에 일곱 개가 다 있는지 적습니다. 그 자리를 읽는
|
|
113
167
|
클라이언트가 이 폴더에 고정돼 있는데 빠진 스킬이 있으면 다음 단계에 bind 를 다시 하거나 칠 명령을 적습니다.
|
|
114
168
|
- 폴더 `.mcp.json` 의 AiAkiv 항목마다 `.claude/settings.json`(또는 `.claude/settings.local.json`)의
|
|
@@ -120,9 +174,11 @@ OAuth 길은 키를 받지 않으므로 프로젝트 id 를 모릅니다. 콘솔
|
|
|
120
174
|
|
|
121
175
|
## 키 평문을 내지 않는 원칙
|
|
122
176
|
|
|
123
|
-
- 받은 키 평문은 **그
|
|
177
|
+
- 받은 키 평문은 **그 클라이언트의 설정 파일에만** 씁니다(bind 는 그 폴더의 설정 파일, setup 은 Gemini CLI 와
|
|
178
|
+
Antigravity 의 전역 설정 파일). 화면, 로그, 오류 문구, doctor 결과 파일에는 쓰지 않습니다
|
|
124
179
|
(키 id 만, doctor 는 앞 8자 + `…`).
|
|
125
|
-
- CLI 는 토큰이나 키를
|
|
180
|
+
- CLI 는 토큰이나 키를 자기 저장소에 두지 않습니다. `login` 명령이 없습니다. setup 이 남기는
|
|
181
|
+
`~/.gemini/aiakiv-setup.json` 에는 키 id 와 지문만 있고 평문은 없습니다.
|
|
126
182
|
- macOS, Linux 에서는 키가 든 설정 파일을 주인만 읽게(0600) 씁니다.
|
|
127
183
|
- 키가 든 설정 파일은 git 저장소이거나 `.gitignore` 가 있는 폴더면 `.gitignore` 에 더하고, 아니면 경고합니다.
|
|
128
184
|
**키가 든 파일을 저장소에 올리지 마세요.**
|
|
@@ -140,7 +196,7 @@ OAuth 길은 키를 받지 않으므로 프로젝트 id 를 모릅니다. 콘솔
|
|
|
140
196
|
|---|---|---|
|
|
141
197
|
| `AIAKIV_MCP_URL` | `https://mcp.aiakiv.com/mcp` | MCP 주소. 끝의 `/mcp` 는 빼고 줘도 됩니다. |
|
|
142
198
|
| `AIAKIV_CONTROL_URL` | `https://app.aiakiv.com` | 웹 콘솔 주소(인계 화면과 키 교환). |
|
|
143
|
-
| `AIAKIV_SKILLS_TARBALL_URL` | `https://codeload.github.com/AiAkiv/aiakiv-plugins/tar.gz/refs/heads/main` | 폴더
|
|
199
|
+
| `AIAKIV_SKILLS_TARBALL_URL` | `https://codeload.github.com/AiAkiv/aiakiv-plugins/tar.gz/refs/heads/main` | 폴더 스킬과 전역 스킬(setup)을 받는 tar.gz 주소(http 나 https). 시험은 로컬 서버로 바꿉니다. |
|
|
144
200
|
| `CODEX_HOME` | `~/.codex` | Codex 설정 위치(플러그인 캐시, 스킬 확인에 씁니다). |
|
|
145
201
|
|
|
146
202
|
## 개발
|
package/dist/bind.js
CHANGED
|
@@ -18,11 +18,11 @@ import { dirname, join, resolve } from "node:path";
|
|
|
18
18
|
import { CLAUDE_FOLDER_SETTINGS, FOLDER_CONFIG } from "./clients/types.js";
|
|
19
19
|
import { addToJsonArray, jsonServers, readJsonObject, upsertJsonServer } from "./config/json.js";
|
|
20
20
|
import { upsertCodexServer } from "./config/toml.js";
|
|
21
|
-
import { exchangeCode, handoffUrl, HANDOFF_TIMEOUT_MS, newState, startLoopback } from "./handoff.js";
|
|
21
|
+
import { exchangeCode, handoffUrl, HANDOFF_TIMEOUT_MS, newState, scopeList, startLoopback, supersededNotice, } from "./handoff.js";
|
|
22
22
|
import { installFolderSkills } from "./skills.js";
|
|
23
23
|
import { folderServerName, folderServerNameWithoutId, projectBinding } from "./snippet.js";
|
|
24
24
|
import { canonicalMcpUrl, controlBase, mcpBase } from "./urls.js";
|
|
25
|
-
function resolveDir(ctx, dir) {
|
|
25
|
+
export function resolveDir(ctx, dir) {
|
|
26
26
|
const d = resolve(ctx.cwd, dir ?? ".");
|
|
27
27
|
if (!existsSync(d) || !statSync(d).isDirectory())
|
|
28
28
|
throw new Error(`폴더가 없습니다: ${d}`);
|
|
@@ -93,7 +93,9 @@ async function bindOAuth(ctx, client, project, dir, opts) {
|
|
|
93
93
|
ctx.out(` - /mcp 에서 ${name} 을 골라 브라우저로 로그인하세요.`);
|
|
94
94
|
}
|
|
95
95
|
else {
|
|
96
|
-
ctx.out(` - Cursor 로 그 폴더를 열고 MCP
|
|
96
|
+
ctx.out(` - Cursor 로 그 폴더를 열고 설정(Ctrl+Shift+J)의 Tools & MCP 에서 ${name} 을 켠 뒤 안내에 따라 로그인하세요. ` +
|
|
97
|
+
"Cursor 는 폴더 서버를 켜기 전에는 연결하지 않습니다. 전역 AiAkiv 연결도 있으면 채팅에서 " +
|
|
98
|
+
`${name} 이라고 이름을 지목하세요.`);
|
|
97
99
|
}
|
|
98
100
|
ctx.out(" - 클라이언트 안에서 get_save_target 을 불러 binding 이 folder 인지 확인하세요.");
|
|
99
101
|
return 0;
|
|
@@ -148,8 +150,9 @@ export async function gitIgnoreStatus(ctx, dir, rel) {
|
|
|
148
150
|
return { ignored, repo };
|
|
149
151
|
}
|
|
150
152
|
/** 키가 든 파일을 그 폴더의 `.gitignore` 에 더한다(git 저장소이거나 `.gitignore` 가 이미 있을 때만).
|
|
151
|
-
* - "already": 이미 무시된다 - "added": `/<rel>` 줄을 더했다 - "warn": 더하지 않았다(사람에게 알린다)
|
|
152
|
-
|
|
153
|
+
* - "already": 이미 무시된다 - "added": `line`(기본 `/<rel>`) 줄을 더했다 - "warn": 더하지 않았다(사람에게 알린다)
|
|
154
|
+
* `line` 은 파일 대신 폴더를 통째로 뺄 때 준다(console key 의 `/.aiakiv/`). */
|
|
155
|
+
export async function ensureGitIgnored(ctx, dir, rel, line = `/${rel}`) {
|
|
153
156
|
const st = await gitIgnoreStatus(ctx, dir, rel);
|
|
154
157
|
if (st.ignored)
|
|
155
158
|
return "already";
|
|
@@ -157,7 +160,6 @@ export async function ensureGitIgnored(ctx, dir, rel) {
|
|
|
157
160
|
const exists = existsSync(gi);
|
|
158
161
|
if (!st.repo && !exists)
|
|
159
162
|
return "warn";
|
|
160
|
-
const line = `/${rel}`;
|
|
161
163
|
const text = exists ? readFileSync(gi, "utf8") : "";
|
|
162
164
|
// 같은 줄이 있는데도 무시되지 않으면(뒤의 `!` 줄 등) 더해도 소용없다. 사람에게 넘긴다.
|
|
163
165
|
if (text.split(/\r?\n/).some((l) => l.trim() === line))
|
|
@@ -184,9 +186,11 @@ async function bindKey(ctx, client, project, dir, opts) {
|
|
|
184
186
|
const control = controlBase(ctx.env);
|
|
185
187
|
const state = newState();
|
|
186
188
|
const lb = await startLoopback(state, opts.timeoutMs ?? HANDOFF_TIMEOUT_MS);
|
|
187
|
-
const url = handoffUrl(control, {
|
|
189
|
+
const url = handoffUrl(control, {
|
|
190
|
+
project, port: lb.port, state, client, expiresDays: opts.expiresDays,
|
|
191
|
+
});
|
|
188
192
|
ctx.out(`웹 콘솔에서 프로젝트 ${project} 의 키를 이 PC 로 넘깁니다(프로젝트가 없으면 그 화면에서 만들 수 있습니다).`);
|
|
189
|
-
ctx.out("브라우저에서 로그인하고 확인
|
|
193
|
+
ctx.out("브라우저에서 로그인하고 확인 버튼을 누르세요. 브라우저가 열리지 않으면 이 주소를 직접 여세요:");
|
|
190
194
|
ctx.out(` ${url}`);
|
|
191
195
|
if (!opts.noBrowser) {
|
|
192
196
|
const opened = await ctx.openUrl(url);
|
|
@@ -205,9 +209,11 @@ async function bindKey(ctx, client, project, dir, opts) {
|
|
|
205
209
|
return 1;
|
|
206
210
|
}
|
|
207
211
|
const res = await exchangeCode(ctx.fetch, control, { code, state, port: lb.port });
|
|
212
|
+
// exchangeCode(expect "project") 가 project 를 확인했다. 형만 좁힌다.
|
|
213
|
+
const resProject = res.project ?? project;
|
|
208
214
|
const name = res.project_id
|
|
209
|
-
? folderServerName(
|
|
210
|
-
: folderServerNameWithoutId(
|
|
215
|
+
? folderServerName(resProject, res.project_id)
|
|
216
|
+
: folderServerNameWithoutId(resProject).name;
|
|
211
217
|
const mcpUrl = safeMcpUrl(ctx, res.mcp_url);
|
|
212
218
|
const headers = { Authorization: `Bearer ${res.plaintext}` };
|
|
213
219
|
const rel = FOLDER_CONFIG[client];
|
|
@@ -228,14 +234,16 @@ async function bindKey(ctx, client, project, dir, opts) {
|
|
|
228
234
|
ctx.out();
|
|
229
235
|
ctx.out(`프로젝트 키를 받았습니다. 키 id ${res.key_id}`);
|
|
230
236
|
if (res.created_project)
|
|
231
|
-
ctx.out(`프로젝트 ${
|
|
237
|
+
ctx.out(`프로젝트 ${resProject} 를 새로 만들었습니다.`);
|
|
232
238
|
if (res.org_name)
|
|
233
239
|
ctx.out(`팀 ${res.org_name}`);
|
|
240
|
+
if (scopeList(res.scopes).length)
|
|
241
|
+
ctx.out(`스코프 ${scopeList(res.scopes).join(",")}`);
|
|
234
242
|
if (res.expires_at)
|
|
235
243
|
ctx.out(`만료 ${res.expires_at}`);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
244
|
+
const sup = supersededNotice(res.superseded);
|
|
245
|
+
if (sup)
|
|
246
|
+
ctx.out(sup);
|
|
239
247
|
ctx.out(`${replaced ? "바꿨습니다" : "더했습니다"}: ${file}`);
|
|
240
248
|
ctx.out(` 서버 이름 ${name}`);
|
|
241
249
|
ctx.out(` 주소 ${mcpUrl}`);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// 전역 설치(setup) 대상: Gemini CLI 와 Antigravity 둘뿐이다(BP-165 P6, 2026-10-21 결정).
|
|
2
|
+
// Claude Code 는 플러그인 마켓, ChatGPT 와 Codex 는 설치 프롬프트, Cursor 는 홈페이지의 설치 링크,
|
|
3
|
+
// Grok 은 웹 커넥터가 있어 전역 설치를 이 CLI 가 하지 않는다.
|
|
4
|
+
//
|
|
5
|
+
// 두 클라이언트 모두 서버 이름은 `AiAkiv`(폴더의 `AiAkiv-<프로젝트>` 와 구분)이고, 같은 계정 전체 기억 키
|
|
6
|
+
// (Main 을 따라감, binding main)를 Bearer 로 쓴다. 키 이름만 다르다.
|
|
7
|
+
// - Gemini CLI: `~/.gemini/settings.json` 의 `mcpServers.AiAkiv = {httpUrl, headers}`
|
|
8
|
+
// - Antigravity: `~/.gemini/config/mcp_config.json` 의 `mcpServers.AiAkiv = {serverUrl, headers}`
|
|
9
|
+
// (antigravity.google/docs/mcp: IDE 와 CLI 가 함께 읽는 전역 파일. `url`, `httpUrl`, `type` 은 거부한다.)
|
|
10
|
+
// 스킬 자리:
|
|
11
|
+
// - Gemini CLI: `~/.gemini/skills/<스킬>/`
|
|
12
|
+
// - Antigravity: `~/.gemini/config/skills/<스킬>/`(앱과 IDE 공통), 그리고 `~/.gemini/antigravity-cli/`
|
|
13
|
+
// 폴더가 이미 있으면 `~/.gemini/antigravity-cli/skills/<스킬>/` 에도.
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
export const GLOBAL_CLIENT_IDS = ["gemini", "antigravity"];
|
|
17
|
+
/** 전역 설정의 서버 이름. */
|
|
18
|
+
export const GLOBAL_SERVER_NAME = "AiAkiv";
|
|
19
|
+
export const GLOBAL_CLIENT_LABEL = {
|
|
20
|
+
gemini: "Gemini CLI",
|
|
21
|
+
antigravity: "Antigravity",
|
|
22
|
+
};
|
|
23
|
+
/** 전역 설치를 하지 않는 클라이언트를 setup 에 주었을 때의 안내. */
|
|
24
|
+
export const SETUP_ONLY_GLOBAL_CLIENTS = "전역 설치는 Gemini CLI 와 Antigravity 만 합니다. Claude Code 는 플러그인 마켓, ChatGPT 와 Codex 는 설치 프롬프트, " +
|
|
25
|
+
"Cursor 는 홈페이지의 설치 링크를 쓰세요.";
|
|
26
|
+
export function geminiHome(ctx) {
|
|
27
|
+
return join(ctx.home, ".gemini");
|
|
28
|
+
}
|
|
29
|
+
/** 전역 MCP 설정 파일. */
|
|
30
|
+
export function globalConfigPath(ctx, client) {
|
|
31
|
+
return client === "gemini"
|
|
32
|
+
? join(geminiHome(ctx), "settings.json")
|
|
33
|
+
: join(geminiHome(ctx), "config", "mcp_config.json");
|
|
34
|
+
}
|
|
35
|
+
/** `mcpServers.AiAkiv` 에 쓸 항목. Antigravity 는 반드시 `serverUrl` 이다. */
|
|
36
|
+
export function globalEntry(client, url, key) {
|
|
37
|
+
const headers = { Authorization: `Bearer ${key}` };
|
|
38
|
+
return client === "gemini" ? { httpUrl: url, headers } : { serverUrl: url, headers };
|
|
39
|
+
}
|
|
40
|
+
/** 전역 스킬 자리. */
|
|
41
|
+
export function globalSkillsTargets(ctx, client) {
|
|
42
|
+
const g = geminiHome(ctx);
|
|
43
|
+
if (client === "gemini")
|
|
44
|
+
return [{ dir: join(g, "skills"), skillsAgent: "gemini-cli" }];
|
|
45
|
+
const out = [{ dir: join(g, "config", "skills") }];
|
|
46
|
+
if (existsSync(join(g, "antigravity-cli"))) {
|
|
47
|
+
out.push({ dir: join(g, "antigravity-cli", "skills"), skillsAgent: "antigravity-cli" });
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
package/dist/console.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// `aiakiv console <액션>`, `aiakiv console describe`, `aiakiv console key`(BP-171 §5).
|
|
2
|
+
//
|
|
3
|
+
// - 범용 통로: 액션 이름을 코드에 적지 않는다. 별칭 넷만 바꾸고 나머지 이름은 그대로 서버로 보낸다
|
|
4
|
+
// (모르는 이름은 서버가 unknown_action 으로 답한다). 액션의 종류와 인자는 서버 describe 가 정본이다.
|
|
5
|
+
// - 접근 범위를 넓히는 쓰기(describe 의 widens_access=true)는 실행 전에 describe 를 한 번 읽어 확인하고,
|
|
6
|
+
// 터미널이면 묻는다. 터미널이 아니고 --yes 도 없으면 실행하지 않는다(AI 가 셸에서 부를 때 사람 확인을
|
|
7
|
+
// 거치게 하는 장치).
|
|
8
|
+
// - console key: 브라우저 인계(kind=account)로 계정 키를 받아 `.aiakiv/console.json` 에 쓴다(BP-171 §4).
|
|
9
|
+
// 계정 키는 관리 작업 전용이고(스코프 manage 하나), 팀도 프로젝트도 없으며 기억은 읽지 못한다.
|
|
10
|
+
//
|
|
11
|
+
// 키 평문은 화면 어디에도 내지 않는다.
|
|
12
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { ensureGitIgnored, resolveDir } from "./bind.js";
|
|
15
|
+
import { callConsole, classify, EXIT, failureLines, findConsoleKey, isObj, keyLabel, NO_KEY_MESSAGE, } from "./consoleApi.js";
|
|
16
|
+
import { CONSOLE_KEY_DIR, CONSOLE_KEY_FILE, expiryText, writeConsoleKeyFile } from "./consoleKeyFile.js";
|
|
17
|
+
import { exchangeCode, handoffUrl, HANDOFF_TIMEOUT_MS, newState, scopeList, startLoopback, supersededNotice, } from "./handoff.js";
|
|
18
|
+
import { renderDescribe, renderResult } from "./render.js";
|
|
19
|
+
import { controlBase } from "./urls.js";
|
|
20
|
+
/** 별칭은 이 넷뿐이다(§5.3). 나머지는 이름 그대로 보낸다. */
|
|
21
|
+
export const ALIASES = {
|
|
22
|
+
teams: "list_teams",
|
|
23
|
+
projects: "list_projects",
|
|
24
|
+
links: "list_links",
|
|
25
|
+
permissions: "list_permissions",
|
|
26
|
+
};
|
|
27
|
+
export class ConsoleUsageError extends Error {
|
|
28
|
+
}
|
|
29
|
+
/** 명령의 폴더. 없으면 사용 오류. */
|
|
30
|
+
export function consoleDir(ctx, dir) {
|
|
31
|
+
const d = resolve(ctx.cwd, dir ?? ".");
|
|
32
|
+
if (!existsSync(d) || !statSync(d).isDirectory())
|
|
33
|
+
throw new ConsoleUsageError(`폴더가 없습니다: ${d}`);
|
|
34
|
+
return d;
|
|
35
|
+
}
|
|
36
|
+
/** `--data` 값: JSON 객체 하나, 또는 `@파일`(현재 폴더 기준). 없으면 `{}`. */
|
|
37
|
+
export function parseData(ctx, raw) {
|
|
38
|
+
if (raw === undefined)
|
|
39
|
+
return {};
|
|
40
|
+
let text = raw;
|
|
41
|
+
let where = "--data";
|
|
42
|
+
if (raw.startsWith("@")) {
|
|
43
|
+
const file = resolve(ctx.cwd, raw.slice(1));
|
|
44
|
+
where = file;
|
|
45
|
+
try {
|
|
46
|
+
text = readFileSync(file, "utf8").replace(/^/, "");
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
throw new ConsoleUsageError(`--data 파일을 읽지 못했습니다: ${file} (${e.code ?? e.message})`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
let v;
|
|
53
|
+
try {
|
|
54
|
+
v = JSON.parse(text);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
throw new ConsoleUsageError(`${where} 를 JSON 으로 읽을 수 없습니다(${e.message}).`);
|
|
58
|
+
}
|
|
59
|
+
if (!isObj(v))
|
|
60
|
+
throw new ConsoleUsageError(`${where} 는 JSON 객체 하나여야 합니다(예: '{"org_id": "..."}').`);
|
|
61
|
+
return v;
|
|
62
|
+
}
|
|
63
|
+
/** 키를 찾는다. 참고 문구는 stderr 로, 키가 없으면 null(호출한 쪽이 종료 2). */
|
|
64
|
+
export function consoleKeyOrNull(ctx, dir) {
|
|
65
|
+
const { source, notes } = findConsoleKey(ctx, dir);
|
|
66
|
+
for (const n of notes)
|
|
67
|
+
ctx.err(n);
|
|
68
|
+
if (!source)
|
|
69
|
+
ctx.err(NO_KEY_MESSAGE);
|
|
70
|
+
return source;
|
|
71
|
+
}
|
|
72
|
+
/** 응답 본문을 그대로(들여쓰기 2) stdout 에. 본문이 없으면 아무것도 내지 않는다. */
|
|
73
|
+
function printJsonBody(ctx, r) {
|
|
74
|
+
if (r.body !== undefined)
|
|
75
|
+
ctx.out(JSON.stringify(r.body, null, 2));
|
|
76
|
+
}
|
|
77
|
+
const YES = new Set(["y", "yes", "예", "네"]);
|
|
78
|
+
export const WIDENS_NEEDS_YES = "이 작업은 접근 범위를 넓힙니다. 사용자에게 확인한 뒤 --yes 로 다시 실행하세요.";
|
|
79
|
+
export async function runConsole(ctx, opts) {
|
|
80
|
+
const asked = opts.action.trim();
|
|
81
|
+
if (!asked)
|
|
82
|
+
throw new ConsoleUsageError("액션 이름이 필요합니다. 목록은 npx aiakiv console describe 로 봅니다.");
|
|
83
|
+
const action = ALIASES[asked.toLowerCase()] ?? asked;
|
|
84
|
+
const isDescribe = action.toLowerCase() === "describe";
|
|
85
|
+
if (isDescribe && opts.data !== undefined)
|
|
86
|
+
throw new ConsoleUsageError("describe 는 --data 를 받지 않습니다.");
|
|
87
|
+
const data = parseData(ctx, opts.data);
|
|
88
|
+
const dir = consoleDir(ctx, opts.dir);
|
|
89
|
+
const source = consoleKeyOrNull(ctx, dir);
|
|
90
|
+
if (!source)
|
|
91
|
+
return EXIT.USAGE;
|
|
92
|
+
// 접근 범위를 넓히는 쓰기인지 describe 로 확인한다(--yes 면 물을 일이 없으니 건너뛴다).
|
|
93
|
+
if (!isDescribe && !opts.yes) {
|
|
94
|
+
const d = await callConsole(ctx, source.key, "describe", {});
|
|
95
|
+
const o = classify(d);
|
|
96
|
+
if (o.exit !== EXIT.OK) {
|
|
97
|
+
if (opts.json)
|
|
98
|
+
printJsonBody(ctx, d);
|
|
99
|
+
for (const l of failureLines(o))
|
|
100
|
+
ctx.err(l);
|
|
101
|
+
ctx.err(`실행하기 전에 describe 를 읽지 못해 ${action} 을 보내지 않았습니다. 사용한 ${keyLabel(source, d.keyId)}`);
|
|
102
|
+
return o.exit;
|
|
103
|
+
}
|
|
104
|
+
const actions = isObj(d.body) && isObj(d.body.actions) ? d.body.actions : null;
|
|
105
|
+
if (!actions) {
|
|
106
|
+
ctx.err(`describe 응답에 액션표가 없어 ${action} 을 보내지 않았습니다.`);
|
|
107
|
+
return EXIT.SERVER;
|
|
108
|
+
}
|
|
109
|
+
const spec = actions[action.toLowerCase()];
|
|
110
|
+
if (isObj(spec) && spec.kind === "write" && spec.widens_access === true) {
|
|
111
|
+
if (!ctx.interactive || !ctx.ask) {
|
|
112
|
+
ctx.err(`${action}: ${WIDENS_NEEDS_YES}`);
|
|
113
|
+
return EXIT.USAGE;
|
|
114
|
+
}
|
|
115
|
+
const answer = await ctx.ask(`${action} 은 접근 범위를 넓힙니다(누군가 메모리나 팀을 새로 읽게 됩니다). 계속할까요? [y/N] `);
|
|
116
|
+
if (!YES.has(answer.trim().toLowerCase())) {
|
|
117
|
+
ctx.err("실행하지 않았습니다.");
|
|
118
|
+
return EXIT.USAGE;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const r = await callConsole(ctx, source.key, action, data);
|
|
123
|
+
const o = classify(r);
|
|
124
|
+
if (opts.json) {
|
|
125
|
+
printJsonBody(ctx, r);
|
|
126
|
+
// stdout 이 비는 경우(404, 연결 실패, JSON 아닌 본문)만 stderr 에 이유를 낸다.
|
|
127
|
+
if (o.exit !== EXIT.OK && r.body === undefined)
|
|
128
|
+
for (const l of failureLines(o))
|
|
129
|
+
ctx.err(l);
|
|
130
|
+
return o.exit;
|
|
131
|
+
}
|
|
132
|
+
if (o.exit !== EXIT.OK) {
|
|
133
|
+
for (const l of failureLines(o))
|
|
134
|
+
ctx.err(l);
|
|
135
|
+
ctx.err(`사용한 ${keyLabel(source, r.keyId)}`);
|
|
136
|
+
return o.exit;
|
|
137
|
+
}
|
|
138
|
+
for (const l of isDescribe ? renderDescribe(r.body) : renderResult(r.body))
|
|
139
|
+
ctx.out(l);
|
|
140
|
+
ctx.out();
|
|
141
|
+
ctx.out(`사용한 ${keyLabel(source, r.keyId)}`);
|
|
142
|
+
return EXIT.OK;
|
|
143
|
+
}
|
|
144
|
+
/** `console key`: 브라우저 인계(kind=account, client=cli)로 계정 키를 받아 `.aiakiv/console.json` 에 쓴다. */
|
|
145
|
+
export async function runConsoleKey(ctx, opts) {
|
|
146
|
+
let dir;
|
|
147
|
+
try {
|
|
148
|
+
dir = resolveDir(ctx, opts.dir);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
ctx.err(e.message);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
return await consoleKey(ctx, dir, opts);
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
ctx.err(e.message);
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function consoleKey(ctx, dir, opts) {
|
|
163
|
+
const control = controlBase(ctx.env);
|
|
164
|
+
const timeoutMs = opts.timeoutMs ?? HANDOFF_TIMEOUT_MS;
|
|
165
|
+
const state = newState();
|
|
166
|
+
const lb = await startLoopback(state, timeoutMs);
|
|
167
|
+
const url = handoffUrl(control, {
|
|
168
|
+
kind: "account", port: lb.port, state, client: "cli", expiresDays: opts.expiresDays,
|
|
169
|
+
});
|
|
170
|
+
ctx.out("웹 콘솔에서 계정 관리 키를 이 폴더로 받습니다. 이 키로는 기억을 읽거나 쓸 수 없고 관리 작업만 합니다.");
|
|
171
|
+
ctx.out("브라우저에서 로그인하고 확인 버튼을 누르세요. 브라우저가 열리지 않으면 이 주소를 직접 여세요:");
|
|
172
|
+
ctx.out(` ${url}`);
|
|
173
|
+
if (!opts.noBrowser) {
|
|
174
|
+
const opened = await ctx.openUrl(url);
|
|
175
|
+
if (!opened)
|
|
176
|
+
ctx.err("브라우저를 열지 못했습니다. 위 주소를 직접 여세요.");
|
|
177
|
+
}
|
|
178
|
+
ctx.out(`기다리는 중(최대 ${Math.round(timeoutMs / 60_000)}분, 127.0.0.1:${lb.port})...`);
|
|
179
|
+
let code;
|
|
180
|
+
try {
|
|
181
|
+
code = await lb.code;
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
lb.close();
|
|
185
|
+
ctx.err(e.message);
|
|
186
|
+
ctx.err("명령을 다시 실행하세요.");
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
// 응답 kind 가 account 가 아니면(서버가 옛 판) 여기서 HandoffError 로 끝난다. 받은 평문은 쓰지 않는다.
|
|
190
|
+
const res = await exchangeCode(ctx.fetch, control, { code, state, port: lb.port }, "account");
|
|
191
|
+
const scopes = scopeList(res.scopes).join(",");
|
|
192
|
+
const expiresAt = typeof res.expires_at === "string" && res.expires_at ? res.expires_at : null;
|
|
193
|
+
const file = writeConsoleKeyFile(dir, {
|
|
194
|
+
key: res.plaintext,
|
|
195
|
+
key_id: res.key_id,
|
|
196
|
+
kind: "account",
|
|
197
|
+
scopes,
|
|
198
|
+
control_url: control,
|
|
199
|
+
issued_at: new Date().toISOString(),
|
|
200
|
+
expires_at: expiresAt,
|
|
201
|
+
});
|
|
202
|
+
ctx.out();
|
|
203
|
+
ctx.out(`계정 키를 받았습니다. 키 id ${res.key_id}, 스코프 ${scopes || "?"}, 만료 ${expiryText(expiresAt)}.`);
|
|
204
|
+
const sup = supersededNotice(res.superseded, "account");
|
|
205
|
+
if (sup)
|
|
206
|
+
ctx.out(sup);
|
|
207
|
+
ctx.out(`썼습니다: ${file}`);
|
|
208
|
+
let ignore;
|
|
209
|
+
try {
|
|
210
|
+
ignore = await ensureGitIgnored(ctx, dir, CONSOLE_KEY_FILE, `/${CONSOLE_KEY_DIR}/`);
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
ctx.err(`.gitignore 를 고치지 못했습니다(${e.message}).`);
|
|
214
|
+
ignore = "warn";
|
|
215
|
+
}
|
|
216
|
+
if (ignore === "added")
|
|
217
|
+
ctx.out(`더했습니다: ${join(dir, ".gitignore")} 에 /${CONSOLE_KEY_DIR}/`);
|
|
218
|
+
if (ignore === "warn") {
|
|
219
|
+
ctx.err(`주의: ${CONSOLE_KEY_DIR}/ 가 .gitignore 에 없습니다. 키가 든 파일이니 저장소에 올리지 마세요` +
|
|
220
|
+
`(.gitignore 에 /${CONSOLE_KEY_DIR}/ 을 더하세요).`);
|
|
221
|
+
}
|
|
222
|
+
ctx.out("다음 할 일:");
|
|
223
|
+
ctx.out(` - 확인: npx aiakiv dashboard --dir "${dir}"`);
|
|
224
|
+
ctx.out(" - 관리 작업은 콘솔 \"AI 권한\" 에서 켠 권한 안에서만 됩니다(편집 권한은 처음에 꺼져 있습니다).");
|
|
225
|
+
ctx.out(" - 기억을 읽고 쓰는 연결은 프로젝트 키(npx aiakiv bind)로 합니다. 이 키로는 기억을 읽을 수 없습니다.");
|
|
226
|
+
return 0;
|
|
227
|
+
}
|