aiakiv 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,15 +1,17 @@
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` 에 있습니다.
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`)으로 합니다. 첫 판에 있던 `setup` 명령은 없어졌고, 치면 이 안내를 보여 주고 끝납니다.
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` 에서 AiAkiv 항목을 찾아
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`, `$CODEX_HOME/skills`, 폴더의 `.claude/skills`, `.agents/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
- - 받은 키 평문은 **그 폴더의 설정 파일에만** 씁니다. 화면, 로그, 오류 문구, doctor 결과 파일에는 쓰지 않습니다
177
+ - 받은 키 평문은 **그 클라이언트의 설정 파일에만** 씁니다(bind 는 그 폴더의 설정 파일, setup 은 Gemini CLI 와
178
+ Antigravity 의 전역 설정 파일). 화면, 로그, 오류 문구, doctor 결과 파일에는 쓰지 않습니다
124
179
  (키 id 만, doctor 는 앞 8자 + `…`).
125
- - CLI 는 토큰이나 키를 홈 폴더 같은 자기 저장소에 두지 않습니다. `login` 명령이 없습니다.
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` | 폴더 스킬을 받는 tar.gz 주소(http 나 https). 시험은 로컬 서버로 바꿉니다. |
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}`);
@@ -148,8 +148,9 @@ export async function gitIgnoreStatus(ctx, dir, rel) {
148
148
  return { ignored, repo };
149
149
  }
150
150
  /** 키가 든 파일을 그 폴더의 `.gitignore` 에 더한다(git 저장소이거나 `.gitignore` 가 이미 있을 때만).
151
- * - "already": 이미 무시된다 - "added": `/<rel>` 줄을 더했다 - "warn": 더하지 않았다(사람에게 알린다) */
152
- export async function ensureGitIgnored(ctx, dir, rel) {
151
+ * - "already": 이미 무시된다 - "added": `line`(기본 `/<rel>`) 줄을 더했다 - "warn": 더하지 않았다(사람에게 알린다)
152
+ * `line` 은 파일 대신 폴더를 통째로 뺄 때 준다(console key 의 `/.aiakiv/`). */
153
+ export async function ensureGitIgnored(ctx, dir, rel, line = `/${rel}`) {
153
154
  const st = await gitIgnoreStatus(ctx, dir, rel);
154
155
  if (st.ignored)
155
156
  return "already";
@@ -157,7 +158,6 @@ export async function ensureGitIgnored(ctx, dir, rel) {
157
158
  const exists = existsSync(gi);
158
159
  if (!st.repo && !exists)
159
160
  return "warn";
160
- const line = `/${rel}`;
161
161
  const text = exists ? readFileSync(gi, "utf8") : "";
162
162
  // 같은 줄이 있는데도 무시되지 않으면(뒤의 `!` 줄 등) 더해도 소용없다. 사람에게 넘긴다.
163
163
  if (text.split(/\r?\n/).some((l) => l.trim() === line))
@@ -184,9 +184,11 @@ async function bindKey(ctx, client, project, dir, opts) {
184
184
  const control = controlBase(ctx.env);
185
185
  const state = newState();
186
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 });
187
+ const url = handoffUrl(control, {
188
+ project, port: lb.port, state, client, expiresDays: opts.expiresDays,
189
+ });
188
190
  ctx.out(`웹 콘솔에서 프로젝트 ${project} 의 키를 이 PC 로 넘깁니다(프로젝트가 없으면 그 화면에서 만들 수 있습니다).`);
189
- ctx.out("브라우저에서 로그인하고 확인 단추를 누르세요. 브라우저가 열리지 않으면 이 주소를 직접 여세요:");
191
+ ctx.out("브라우저에서 로그인하고 확인 버튼을 누르세요. 브라우저가 열리지 않으면 이 주소를 직접 여세요:");
190
192
  ctx.out(` ${url}`);
191
193
  if (!opts.noBrowser) {
192
194
  const opened = await ctx.openUrl(url);
@@ -205,9 +207,11 @@ async function bindKey(ctx, client, project, dir, opts) {
205
207
  return 1;
206
208
  }
207
209
  const res = await exchangeCode(ctx.fetch, control, { code, state, port: lb.port });
210
+ // exchangeCode(expect "project") 가 project 를 확인했다. 형만 좁힌다.
211
+ const resProject = res.project ?? project;
208
212
  const name = res.project_id
209
- ? folderServerName(res.project, res.project_id)
210
- : folderServerNameWithoutId(res.project).name;
213
+ ? folderServerName(resProject, res.project_id)
214
+ : folderServerNameWithoutId(resProject).name;
211
215
  const mcpUrl = safeMcpUrl(ctx, res.mcp_url);
212
216
  const headers = { Authorization: `Bearer ${res.plaintext}` };
213
217
  const rel = FOLDER_CONFIG[client];
@@ -228,14 +232,16 @@ async function bindKey(ctx, client, project, dir, opts) {
228
232
  ctx.out();
229
233
  ctx.out(`프로젝트 키를 받았습니다. 키 id ${res.key_id}`);
230
234
  if (res.created_project)
231
- ctx.out(`프로젝트 ${res.project} 를 새로 만들었습니다.`);
235
+ ctx.out(`프로젝트 ${resProject} 를 새로 만들었습니다.`);
232
236
  if (res.org_name)
233
237
  ctx.out(`팀 ${res.org_name}`);
238
+ if (scopeList(res.scopes).length)
239
+ ctx.out(`스코프 ${scopeList(res.scopes).join(",")}`);
234
240
  if (res.expires_at)
235
241
  ctx.out(`만료 ${res.expires_at}`);
236
- if (typeof res.superseded === "number" && res.superseded > 0) {
237
- ctx.out(`이전 키 ${res.superseded}개를 폐기했습니다. 다른 폴더에 같은 프로젝트 키를 넣어 두었다면 그 폴더도 다시 bind 하세요.`);
238
- }
242
+ const sup = supersededNotice(res.superseded);
243
+ if (sup)
244
+ ctx.out(sup);
239
245
  ctx.out(`${replaced ? "바꿨습니다" : "더했습니다"}: ${file}`);
240
246
  ctx.out(` 서버 이름 ${name}`);
241
247
  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
+ }
@@ -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
+ }