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/dist/doctor.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // `aiakiv doctor [--client …] [--dir <폴더>] [--out <파일>]` — 설정 파일, 클라이언트 등록 목록,
2
2
  // 스킬 위치를 확인하고 키로 묶인 연결은 get_save_target 을 직접 불러 본다. 결과는 마크다운 파일로.
3
- // bind 가 쓰는 나머지(폴더 스킬 일곱 개, Claude 의 enabledMcpjsonServers)도 그 폴더에 있는지 본다.
3
+ // bind 가 쓰는 나머지(폴더 스킬 여덟 개, Claude 의 enabledMcpjsonServers)도 그 폴더에 있는지 본다.
4
4
  // 전역 등록 목록은 bind 와 상관없지만 겹치는 연결을 사람에게 알리려고 계속 본다.
5
+ // setup 이 쓰는 전역 파일(Gemini CLI `~/.gemini/settings.json`, Antigravity `~/.gemini/config/mcp_config.json`)의
6
+ // 키 항목은 get_save_target 으로 binding main 인지 본다(clients/global.ts).
5
7
  //
6
8
  // 키와 토큰 평문은 화면에도 파일에도 쓰지 않는다(앞 8자 + …). 클라이언트 목록은 다른 서비스의
7
9
  // 주소(키가 든 주소가 있을 수 있다)를 옮기지 않도록 AiAkiv 항목만 고른다.
@@ -10,15 +12,26 @@ import { join, resolve } from "node:path";
10
12
  import { classifyClaudeName, CLAUDE_KIND_LABEL, findClaude, installedPluginIds, parseClaudeMcpList, } from "./clients/claude.js";
11
13
  import { codexHome, findCodex, parseCodexMcpList } from "./clients/codex.js";
12
14
  import { cursorGlobalConfig } from "./clients/cursor.js";
15
+ import { globalConfigPath, GLOBAL_CLIENT_IDS, GLOBAL_SERVER_NAME } from "./clients/global.js";
13
16
  import { CLAUDE_FOLDER_SETTINGS, CLAUDE_FOLDER_SETTINGS_LOCAL, CLIENT_IDS, FOLDER_CONFIG, looksLikeAiAkiv, } from "./clients/types.js";
14
17
  import { jsonServers, jsonStringArray, readJsonObject } from "./config/json.js";
15
18
  import { readTomlObject, tomlServers } from "./config/toml.js";
19
+ import { CONSOLE_KEY_FILE, expiryText, LEGACY_KEY_FILE_MESSAGE, readConsoleKeyFile } from "./consoleKeyFile.js";
16
20
  import { probeSaveTarget } from "./mcpProbe.js";
17
21
  import { failReason } from "./proc.js";
18
22
  import { FOLDER_SKILLS_DIR, SKILL_NAMES, SKILLS_AGENT, skillsManualCommand } from "./skills.js";
19
23
  import { controlBase, mcpBase } from "./urls.js";
20
24
  import { VERSION } from "./version.js";
21
25
  export const DEFAULT_REPORT = "aiakiv-verification-test.md";
26
+ export const DOCTOR_CLIENT_IDS = [...CLIENT_IDS, "antigravity"];
27
+ /** 폴더 고정(bind)이 있는 클라이언트인가. */
28
+ export function isFolderClient(c) {
29
+ return CLIENT_IDS.includes(c);
30
+ }
31
+ /** setup 이 전역 파일을 쓰는 클라이언트인가. */
32
+ function isSetupClient(c) {
33
+ return GLOBAL_CLIENT_IDS.includes(c);
34
+ }
22
35
  /** 키 가리기: 앞 8자 + …(짧으면 전부 가린다). */
23
36
  export function maskSecret(v) {
24
37
  return v.length > 12 ? `${v.slice(0, 8)}…` : "…";
@@ -77,12 +90,23 @@ export function scanConfigs(ctx, dir, clients) {
77
90
  const entries = [];
78
91
  const notes = [];
79
92
  const files = [];
80
- for (const c of clients)
81
- files.push({ client: c, scope: "폴더", file: join(dir, FOLDER_CONFIG[c]) });
93
+ for (const c of clients) {
94
+ if (isFolderClient(c))
95
+ files.push({ client: c, scope: "폴더", file: join(dir, FOLDER_CONFIG[c]) });
96
+ }
82
97
  if (clients.includes("cursor"))
83
98
  files.push({ client: "cursor", scope: "전역", file: cursorGlobalConfig(ctx) });
84
- if (clients.includes("gemini")) {
85
- files.push({ client: "gemini", scope: "전역", file: join(ctx.home, ".gemini", "settings.json") });
99
+ for (const c of GLOBAL_CLIENT_IDS) {
100
+ if (!clients.includes(c))
101
+ continue;
102
+ const file = globalConfigPath(ctx, c);
103
+ // 폴더가 홈이면 폴더의 `.gemini/settings.json` 이 곧 전역 파일이다. 전역으로 한 번만 본다
104
+ // (setup 이 쓴 계정 전체 키를 폴더 고정 키로 잘못 읽지 않게).
105
+ for (let i = files.length - 1; i >= 0; i -= 1) {
106
+ if (files[i].scope === "폴더" && resolve(files[i].file) === resolve(file))
107
+ files.splice(i, 1);
108
+ }
109
+ files.push({ client: c, scope: "전역", file });
86
110
  }
87
111
  for (const f of files) {
88
112
  if (!existsSync(f.file))
@@ -184,6 +208,8 @@ export function skillLocations(ctx, dir) {
184
208
  join(ctx.home, ".agents", "skills"),
185
209
  join(ctx.home, ".cursor", "skills"),
186
210
  join(ctx.home, ".gemini", "skills"),
211
+ join(ctx.home, ".gemini", "config", "skills"),
212
+ join(ctx.home, ".gemini", "antigravity-cli", "skills"),
187
213
  join(codexHome(ctx), "skills"),
188
214
  join(dir, ".claude", "skills"),
189
215
  join(dir, ".agents", "skills"),
@@ -208,19 +234,29 @@ export function skillLocations(ctx, dir) {
208
234
  }
209
235
  return out;
210
236
  }
211
- function probeLine(p) {
237
+ /** setup 이 쓴 전역 항목인가(Gemini CLI, Antigravity 의 전역 파일의 `AiAkiv`). 이 항목의 키는 binding main 이어야
238
+ * 한다. 같은 파일의 다른 이름(`AiAkiv-<프로젝트>`, 홈 폴더에서 bind 한 경우 등)은 프로젝트 키로 본다. */
239
+ function isSetupEntry(e) {
240
+ return e.scope === "전역" && isSetupClient(e.client) && e.name === GLOBAL_SERVER_NAME;
241
+ }
242
+ function probeLine(p, e) {
212
243
  if (p.ok)
213
244
  return `팀 ${p.team ?? "?"}, 프로젝트 ${p.project ?? "?"}, binding ${p.binding ?? "?"}`;
214
- const hint = p.status === 401
215
- ? " (키가 폐기됐거나 틀립니다. 같은 프로젝트를 다른 폴더에서 bind 하면 이전 키가 폐기되니, 이 폴더에서 bind 를 다시 하세요)"
216
- : "";
245
+ let hint = "";
246
+ if (p.status === 401) {
247
+ hint = isSetupEntry(e)
248
+ ? ` (키가 폐기됐거나 틀립니다. 계정 전체 키는 한 사람에게 하나라 다른 PC 에서 setup 을 하면 이 키가 폐기됩니다. ` +
249
+ `npx aiakiv setup --client ${e.client} 로 다시 받으세요)`
250
+ : " (키가 폐기됐거나 틀립니다. 같은 프로젝트를 다른 폴더에서 bind 하면 이전 키가 폐기되니, 이 폴더에서 bind 를 다시 하세요)";
251
+ }
217
252
  return `실패${p.status ? `(${p.status})` : ""}: ${p.reason}${hint}`;
218
253
  }
219
- /** bind 가 스킬을 넣는 폴더 자리마다 일곱 개가 있는지. */
254
+ /** bind 가 스킬을 넣는 폴더 자리마다 여덟 개가 있는지. 폴더 고정이 없는 클라이언트(antigravity)는 뺀다. */
220
255
  export function folderSkillChecks(dir, clients) {
221
256
  const byRel = new Map();
222
- for (const c of clients)
257
+ for (const c of clients.filter(isFolderClient)) {
223
258
  byRel.set(FOLDER_SKILLS_DIR[c], [...(byRel.get(FOLDER_SKILLS_DIR[c]) ?? []), c]);
259
+ }
224
260
  return [...byRel].map(([rel, cs]) => {
225
261
  const names = aiakivSkillsIn(join(dir, ...rel.split("/")));
226
262
  return { rel, clients: cs, names, missing: SKILL_NAMES.filter((n) => !names.includes(n)) };
@@ -251,6 +287,21 @@ export function claudeEnabledChecks(dir, entries) {
251
287
  notes,
252
288
  };
253
289
  }
290
+ /** `.aiakiv/console.json`(console key 가 쓴 계정 키 파일)이 있으면 한 줄. 평문은 싣지 않는다. 없으면 null.
291
+ * 옛 판(R0, 프로젝트 키를 담은 파일)이면 다시 받으라고 적는다(console, dashboard 도 그 파일은 쓰지 않는다). */
292
+ function consoleKeyLine(dir) {
293
+ try {
294
+ const f = readConsoleKeyFile(dir);
295
+ if (!f)
296
+ return null;
297
+ if (f.legacy)
298
+ return `${CONSOLE_KEY_FILE}: ${LEGACY_KEY_FILE_MESSAGE}`;
299
+ return `CLI 계정 키(key_id ${f.key_id ?? maskSecret(f.key)}, 만료 ${expiryText(f.expires_at)}): ${CONSOLE_KEY_FILE}`;
300
+ }
301
+ catch (e) {
302
+ return `CLI 계정 키 파일을 읽을 수 없습니다: ${e.message}`;
303
+ }
304
+ }
254
305
  function howLabel(e) {
255
306
  if (e.auth === "apikey")
256
307
  return `${AUTH_LABEL[e.auth]} ${e.keyMasked}`;
@@ -267,7 +318,7 @@ export async function runDoctor(ctx, opts) {
267
318
  ctx.err(`폴더가 없습니다: ${dir}`);
268
319
  return 1;
269
320
  }
270
- const clients = opts.clients?.length ? opts.clients : [...CLIENT_IDS];
321
+ const clients = opts.clients?.length ? opts.clients : [...DOCTOR_CLIENT_IDS];
271
322
  const outFile = opts.out ? resolve(ctx.cwd, opts.out) : join(dir, DEFAULT_REPORT);
272
323
  const scan = scanConfigs(ctx, dir, clients);
273
324
  const probes = new Map();
@@ -285,6 +336,7 @@ export async function runDoctor(ctx, opts) {
285
336
  const skills = skillLocations(ctx, dir);
286
337
  const folderSkills = folderSkillChecks(dir, clients);
287
338
  const enabled = claudeEnabledChecks(dir, scan.entries);
339
+ const consoleKey = consoleKeyLine(dir);
288
340
  const md = [];
289
341
  md.push("# AiAkiv 확인 기록", "");
290
342
  md.push(`- 일시: ${new Date().toISOString()}`);
@@ -309,12 +361,14 @@ export async function runDoctor(ctx, opts) {
309
361
  if (keyed.length) {
310
362
  for (const e of keyed) {
311
363
  const p = probes.get(e);
312
- md.push(`- ${e.name}(${e.file}): ${typeof p === "string" ? p : probeLine(p)}`);
364
+ md.push(`- ${e.name}(${e.file}): ${typeof p === "string" ? p : probeLine(p, e)}`);
313
365
  }
314
366
  }
315
367
  else
316
368
  md.push("키로 묶인 연결이 없습니다.");
317
369
  md.push("");
370
+ if (consoleKey)
371
+ md.push("## CLI 계정 키", "", `- ${consoleKey}`, "");
318
372
  md.push("## 클라이언트 등록 목록", "");
319
373
  for (const s of lists) {
320
374
  md.push(`### ${s.title}`, "");
@@ -326,7 +380,7 @@ export async function runDoctor(ctx, opts) {
326
380
  if (skills.length) {
327
381
  for (const s of skills) {
328
382
  const missing = SKILL_NAMES.filter((n) => !s.names.includes(n));
329
- md.push(`- ${s.root}: ${s.names.length}개${missing.length ? `(빠짐: ${missing.join(", ")})` : "(일곱 개 모두)"}`);
383
+ md.push(`- ${s.root}: ${s.names.length}개${missing.length ? `(빠짐: ${missing.join(", ")})` : "(여덟 개 모두)"}`);
330
384
  }
331
385
  }
332
386
  else
@@ -334,11 +388,13 @@ export async function runDoctor(ctx, opts) {
334
388
  md.push("");
335
389
  const skillState = (s) => !s.names.length
336
390
  ? "없음"
337
- : `${s.names.length}개${s.missing.length ? `(빠짐: ${s.missing.join(", ")})` : "(일곱 개 모두)"}`;
338
- md.push("## 폴더 스킬(bind 가 넣는 자리)", "");
339
- for (const s of folderSkills)
340
- md.push(`- ${s.rel}(${s.clients.join(", ")}): ${skillState(s)}`);
341
- md.push("");
391
+ : `${s.names.length}개${s.missing.length ? `(빠짐: ${s.missing.join(", ")})` : "(여덟 개 모두)"}`;
392
+ if (folderSkills.length) {
393
+ md.push("## 폴더 스킬(bind 가 넣는 자리)", "");
394
+ for (const s of folderSkills)
395
+ md.push(`- ${s.rel}(${s.clients.join(", ")}): ${skillState(s)}`);
396
+ md.push("");
397
+ }
342
398
  if (enabled.checks.length || enabled.notes.length) {
343
399
  md.push("## Claude 폴더 서버 켜기(enabledMcpjsonServers)", "");
344
400
  for (const c of enabled.checks) {
@@ -360,12 +416,30 @@ export async function runDoctor(ctx, opts) {
360
416
  for (const [e, p] of probes) {
361
417
  if (typeof p === "string")
362
418
  continue;
419
+ if (isSetupEntry(e)) {
420
+ // setup 이 쓰는 계정 전체 키는 Main 을 따라간다(binding main).
421
+ if (p.ok && p.binding !== "main") {
422
+ next.push(`${e.name}(${e.client} 전역): binding 이 ${p.binding} 입니다. 계정 전체 키라면 main 이 나와야 합니다. ` +
423
+ `npx aiakiv setup --client ${e.client} --new-key 로 다시 받으세요.`);
424
+ }
425
+ if (!p.ok) {
426
+ next.push(`${e.name}(${e.client} 전역): 키 연결이 실패했습니다. npx aiakiv setup --client ${e.client} 로 다시 받으세요.`);
427
+ }
428
+ continue;
429
+ }
363
430
  if (p.ok && p.binding !== "api-key") {
364
431
  next.push(`${e.name}: binding 이 ${p.binding} 입니다. 프로젝트 고정 키라면 api-key 가 나와야 합니다.`);
365
432
  }
366
433
  if (!p.ok)
367
434
  next.push(`${e.name}: 키 연결이 실패했습니다. 키가 폐기됐으면 npx aiakiv bind 로 다시 받으세요.`);
368
435
  }
436
+ // --client 로 setup 대상을 직접 고른 경우에만, 전역 연결이 없으면 설치 명령을 알린다(기본 전체 확인에서는
437
+ // 폴더만 쓰는 사람에게 소음이 된다).
438
+ for (const c of (opts.clients ?? []).filter(isSetupClient)) {
439
+ if (scan.entries.some((e) => e.scope === "전역" && e.client === c))
440
+ continue;
441
+ next.push(`${c} 전역 연결(${globalConfigPath(ctx, c)})이 없습니다. npx aiakiv setup --client ${c} 로 설치하세요.`);
442
+ }
369
443
  // 폴더 스킬은 그 자리를 읽는 클라이언트가 이 폴더에 고정돼 있을 때만 다음 단계로 낸다.
370
444
  for (const s of folderSkills) {
371
445
  const bound = s.clients.filter((c) => scan.entries.some((e) => e.scope === "폴더" && e.client === c));
@@ -381,9 +455,12 @@ export async function runDoctor(ctx, opts) {
381
455
  "npx aiakiv bind --client claude 를 다시 하세요.");
382
456
  }
383
457
  if (!scan.entries.length) {
384
- next.push("폴더에 프로젝트를 고정하려면 npx aiakiv bind --client <클라이언트> --project <이름>");
458
+ if (clients.some(isFolderClient)) {
459
+ next.push("폴더에 프로젝트를 고정하려면 npx aiakiv bind --client <클라이언트> --project <이름>");
460
+ }
385
461
  if (!skills.length) {
386
- next.push("전역 설치(계정 연결과 전역 스킬)는 설치 프롬프트나 플러그인 마켓으로 하세요.");
462
+ next.push("전역 설치는 Gemini CLI 와 Antigravity 만 npx aiakiv setup --client gemini,antigravity 로 합니다. " +
463
+ "그 밖은 설치 프롬프트나 플러그인 마켓을 쓰세요.");
387
464
  }
388
465
  }
389
466
  md.push("## 다음 단계", "");
@@ -404,7 +481,7 @@ export async function runDoctor(ctx, opts) {
404
481
  ctx.out(" 설정 파일에 AiAkiv 연결 없음");
405
482
  for (const e of scan.entries) {
406
483
  const p = probes.get(e);
407
- const tail = p === undefined ? "" : `, 확인 결과 ${typeof p === "string" ? p : probeLine(p)}`;
484
+ const tail = p === undefined ? "" : `, 확인 결과 ${typeof p === "string" ? p : probeLine(p, e)}`;
408
485
  ctx.out(` [${e.scope} ${e.client}] ${e.name}: ${howLabel(e)}${tail}`);
409
486
  }
410
487
  for (const n of scan.notes)
@@ -412,7 +489,10 @@ export async function runDoctor(ctx, opts) {
412
489
  for (const s of lists)
413
490
  ctx.out(` ${s.title}: ${s.lines.join(" / ")}`);
414
491
  ctx.out(` 스킬: ${skills.length ? skills.map((s) => `${s.root}(${s.names.length})`).join(", ") : "찾지 못함"}`);
415
- ctx.out(` 폴더 스킬: ${folderSkills.map((s) => `${s.rel} ${skillState(s)}`).join(", ")}`);
492
+ if (folderSkills.length)
493
+ ctx.out(` 폴더 스킬: ${folderSkills.map((s) => `${s.rel} ${skillState(s)}`).join(", ")}`);
494
+ if (consoleKey)
495
+ ctx.out(` ${consoleKey}`);
416
496
  for (const c of enabled.checks) {
417
497
  ctx.out(` enabledMcpjsonServers ${c.name}: ${c.in.length ? c.in.join(", ") : "없음"}`);
418
498
  }
package/dist/handoff.js CHANGED
@@ -1,8 +1,13 @@
1
- // 브라우저 인계로 프로젝트 키 받기(BP-165 §4).
1
+ // 브라우저 인계로 키 받기(BP-165 §4, BP-171 §4.2).
2
2
  //
3
3
  // 1. 127.0.0.1 의 빈 포트에 작은 HTTP 서버를 연다. state 는 무작위 24바이트.
4
- // 2. 웹 콘솔의 인계 화면 `/account/cli/handoff?project&port&state&client[&expires_days]` 을 연다.
5
- // 3. 사람이 단추를 누르면 콘솔이 `http://127.0.0.1:<port>/callback?code&state` 로 돌려보낸다.
4
+ // 2. 웹 콘솔의 인계 화면을 연다. 모양은 셋이다.
5
+ // - 프로젝트 키(bind): `/account/cli/handoff?project&port&state&client[&expires_days]`
6
+ // - 계정 키(console key): `/account/cli/handoff?kind=account&port&state&client=cli[&expires_days]`.
7
+ // project 는 보내지 않는다(계정 키는 팀도 저장 좌표도 없다).
8
+ // - 계정 전체 기억 키(setup): `/account/cli/handoff?kind=dynamic&port&state&client=<클라이언트들>[&expires_days]`.
9
+ // Main 을 따라가는 키라 project 를 보내지 않는다(BP-165 P6).
10
+ // 3. 사람이 버튼을 누르면 콘솔이 `http://127.0.0.1:<port>/callback?code&state` 로 돌려보낸다.
6
11
  // state 가 다르면 400 을 주고 계속 기다린다(엉뚱한 요청 하나로 흐름이 끊기지 않게).
7
12
  // 4. `POST /api/cli/exchange {code, state, port}` 로 코드를 키로 바꾼다. 평문은 이 응답에만 있다.
8
13
  //
@@ -15,7 +20,16 @@ export function newState() {
15
20
  return randomBytes(24).toString("base64url");
16
21
  }
17
22
  export function handoffUrl(control, p) {
18
- const q = new URLSearchParams({ project: p.project, port: String(p.port), state: p.state });
23
+ const q = new URLSearchParams();
24
+ if (p.kind === "account" || p.kind === "dynamic")
25
+ q.set("kind", p.kind);
26
+ else {
27
+ if (!p.project)
28
+ throw new Error("프로젝트 키 인계에는 프로젝트 이름이 필요합니다.");
29
+ q.set("project", p.project);
30
+ }
31
+ q.set("port", String(p.port));
32
+ q.set("state", p.state);
19
33
  if (p.expiresDays !== undefined)
20
34
  q.set("expires_days", String(p.expiresDays));
21
35
  q.set("client", p.client);
@@ -94,6 +108,10 @@ export function startLoopback(state, timeoutMs = HANDOFF_TIMEOUT_MS) {
94
108
  });
95
109
  });
96
110
  }
111
+ /** 계정 키를 달라고 했는데 응답이 계정 키가 아닐 때(서버가 옛 판). */
112
+ export const ACCOUNT_KEY_UNSUPPORTED = "서버가 계정 키를 지원하지 않습니다. 웹 콘솔을 쓰세요.";
113
+ /** 계정 전체 기억 키를 달라고 했는데 응답이 그 키가 아닐 때(서버가 옛 판). */
114
+ export const DYNAMIC_KEY_UNSUPPORTED = "서버가 계정 전체 키를 아직 지원하지 않습니다. 받은 키는 쓰지 않았습니다. 웹 콘솔의 설치 안내를 쓰세요.";
97
115
  export class HandoffError extends Error {
98
116
  }
99
117
  async function shortReason(res) {
@@ -114,7 +132,10 @@ async function shortReason(res) {
114
132
  return "";
115
133
  }
116
134
  }
117
- export async function exchangeCode(fetchImpl, control, body) {
135
+ /** `expect`: 어떤 키를 받으려 했는가. "project" 면 응답에 project 가 있어야 하고, "account" 면
136
+ * 응답 kind 가 "account" 여야 한다(아니면 ACCOUNT_KEY_UNSUPPORTED, 받은 평문은 버린다).
137
+ * "dynamic" 이면 응답 kind 가 "dynamic" 이어야 한다(아니면 DYNAMIC_KEY_UNSUPPORTED). */
138
+ export async function exchangeCode(fetchImpl, control, body, expect = "project") {
118
139
  let res;
119
140
  try {
120
141
  res = await fetchImpl(`${control}/api/cli/exchange`, {
@@ -147,10 +168,38 @@ export async function exchangeCode(fetchImpl, control, body) {
147
168
  }
148
169
  const o = j;
149
170
  // 필드 확인. 오류 문구에 응답 값을 넣지 않는다(평문이 들어 있다).
150
- for (const k of ["key_id", "plaintext", "project"]) {
171
+ const required = expect === "project" ? ["key_id", "plaintext", "project"] : ["key_id", "plaintext"];
172
+ for (const k of required) {
151
173
  if (typeof o?.[k] !== "string" || !o[k]) {
152
174
  throw new HandoffError(`키 교환 응답에 ${k} 가 없습니다.`);
153
175
  }
154
176
  }
177
+ if (expect === "account" && o.kind !== "account")
178
+ throw new HandoffError(ACCOUNT_KEY_UNSUPPORTED);
179
+ if (expect === "dynamic" && o.kind !== "dynamic")
180
+ throw new HandoffError(DYNAMIC_KEY_UNSUPPORTED);
155
181
  return o;
156
182
  }
183
+ /** 교환 응답의 `scopes`(서버는 "read,write" 나 "manage" 같은 글자, 옛 모양은 배열)를 목록으로. */
184
+ export function scopeList(scopes) {
185
+ const raw = Array.isArray(scopes) ? scopes : typeof scopes === "string" ? scopes.split(",") : [];
186
+ return raw.map((s) => String(s).trim()).filter(Boolean);
187
+ }
188
+ /** 앞선 키를 폐기했다는 안내. 0, 빈 목록, 숫자나 목록이 아니면 null(줄을 내지 않는다).
189
+ * 프로젝트 키(bind)는 같은 프로젝트의 키, 계정 키(console key)는 그 사람의 앞선 계정 키,
190
+ * 계정 전체 키(setup)는 그 사람의 앞선 계정 전체 키(다른 PC 것 포함)가 폐기된다.
191
+ * `n` 이 키 id 목록이면 개수와 함께 id 를 적는다(id 는 평문이 아니다). */
192
+ export function supersededNotice(n, kind = "project") {
193
+ const ids = Array.isArray(n) ? n.filter((x) => typeof x === "string" && !!x) : [];
194
+ const count = Array.isArray(n) ? n.length : typeof n === "number" ? n : 0;
195
+ if (!(count > 0))
196
+ return null;
197
+ const idText = ids.length ? `(키 id ${ids.join(", ")})` : "";
198
+ if (kind === "dynamic") {
199
+ return `앞선 계정 전체 키 ${count}개${idText}를 폐기했습니다. 다른 PC 에 넣어 둔 계정 전체 키도 더는 쓸 수 없으니, ` +
200
+ "그 PC 에서도 setup 을 다시 하세요.";
201
+ }
202
+ return kind === "account"
203
+ ? `앞선 계정 키 ${count}개${idText}를 폐기했습니다.`
204
+ : `이전 키 ${count}개${idText}를 폐기했습니다. 다른 폴더에 같은 프로젝트 키를 넣어 두었다면 그 폴더도 다시 bind 하세요.`;
205
+ }
package/dist/main.js CHANGED
@@ -1,37 +1,96 @@
1
1
  // 인자 해석과 명령 고르기. 인자 해석은 Node 내장 util.parseArgs(외부 라이브러리 없음).
2
2
  import { parseArgs } from "node:util";
3
+ import { GLOBAL_CLIENT_IDS, SETUP_ONLY_GLOBAL_CLIENTS } from "./clients/global.js";
3
4
  import { CLIENT_IDS } from "./clients/types.js";
4
5
  import { runBind } from "./bind.js";
5
- import { DEFAULT_REPORT, runDoctor } from "./doctor.js";
6
+ import { ConsoleUsageError, runConsole, runConsoleKey } from "./console.js";
7
+ import { runDashboard } from "./dashboard.js";
8
+ import { DEFAULT_REPORT, DOCTOR_CLIENT_IDS, runDoctor } from "./doctor.js";
9
+ import { runSetup } from "./setup.js";
6
10
  import { DEFAULT_SKILLS_TARBALL_URL } from "./skills.js";
7
11
  import { DEFAULT_CONTROL_URL, DEFAULT_MCP_URL } from "./urls.js";
8
12
  import { VERSION } from "./version.js";
9
- export const HELP = `aiakiv ${VERSION}: 폴더에 AiAkiv 프로젝트를 고정하고 확인하는 명령
13
+ export const HELP = `aiakiv ${VERSION}: 폴더에 AiAkiv 프로젝트를 고정하고 확인하고, 전역 설치와 콘솔 작업을 하는 명령
10
14
 
11
15
  사용법:
12
16
  npx aiakiv bind --client codex|claude|gemini|cursor --project <이름> [--dir <폴더>]
13
17
  [--expires-days N] [--no-browser] [--no-skills]
18
+ npx aiakiv setup --client gemini|antigravity[,…] [--expires-days N] [--no-browser] [--no-skills] [--new-key]
14
19
  npx aiakiv doctor [--client <클라이언트>[,…]] [--dir <폴더>] [--out ${DEFAULT_REPORT}] [--no-probe]
20
+ npx aiakiv console <액션> [--data '<json>' | --data @파일.json] [--json] [--dir <폴더>] [--yes]
21
+ npx aiakiv console describe [--json] [--dir <폴더>]
22
+ npx aiakiv console key [--expires-days N] [--dir <폴더>] [--no-browser]
23
+ npx aiakiv dashboard [--json] [--dir <폴더>]
15
24
 
16
25
  bind 폴더에 프로젝트를 고정합니다. 웹 콘솔의 "폴더 ZIP 받기" 와 같은 한 벌을 그 폴더에 직접 씁니다.
17
- claude, cursor 는 ?project= 주소(OAuth)를 쓰고, codex, gemini 는 브라우저에서 확인 단추를 눌러
26
+ claude, cursor 는 ?project= 주소(OAuth)를 쓰고, codex, gemini 는 브라우저에서 확인 버튼을 눌러
18
27
  프로젝트 키를 받아 씁니다. 키 평문은 화면에 내지 않고 그 폴더의 설정 파일에만 씁니다.
19
- 함께 쓰는 것: 폴더 스킬 일곱 개(claude 는 .claude/skills, 나머지는 .agents/skills,
28
+ 함께 쓰는 것: 폴더 스킬 여덟 개(claude 는 .claude/skills, 나머지는 .agents/skills,
20
29
  --no-skills 면 건너뜀), claude 는 .claude/settings.json 의 enabledMcpjsonServers,
21
30
  codex, gemini 는 키가 든 설정 파일을 .gitignore 에(git 저장소이거나 .gitignore 가 있을 때).
31
+ Antigravity 는 앱 전체에 설정 하나라 폴더 고정이 없습니다(setup 을 쓰세요).
32
+ setup Gemini CLI 와 Antigravity 의 전역 설치를 합니다. 브라우저에서 확인 버튼을 눌러 Main(저장 대상)을
33
+ 따라가는 계정 전체 기억 키를 받고, 고른 클라이언트 모두의 전역 설정에 서버 이름 AiAkiv 로 씁니다.
34
+ Gemini CLI 는 ~/.gemini/settings.json(httpUrl), Antigravity 는 ~/.gemini/config/mcp_config.json
35
+ (serverUrl). 전역 스킬도 씁니다(Gemini CLI 는 ~/.gemini/skills, Antigravity 는 ~/.gemini/config/skills
36
+ 와, ~/.gemini/antigravity-cli 가 있으면 그 아래 skills. --no-skills 면 건너뜀).
37
+ 계정 전체 키는 한 사람에게 하나만 살아 있어, 새로 받으면 앞선 키(다른 PC 것 포함)는 폐기됩니다.
38
+ 그래서 두 전역 파일에 쓸 수 있는 키(binding main)가 이미 있으면 브라우저를 열지 않고 그 키를
39
+ 다시 씁니다. --new-key 면 새로 받습니다. --expires-days 가 없으면 만료가 없습니다.
22
40
  doctor 설정 파일, 클라이언트 등록 목록, 스킬 위치를 확인하고 결과를 마크다운 파일로 씁니다.
23
41
  키로 묶인 연결은 get_save_target 을 직접 불러 팀, 프로젝트, binding 을 적습니다.
42
+ --client 는 bind 의 넷과 antigravity 를 받습니다(기본은 모두). setup 이 쓴 전역 키는 binding 이
43
+ main 이어야 합니다.
44
+ console 웹 콘솔의 작업(팀, 초대, 프로젝트, Main 전환, 연결, 카드 등)을 실행합니다. 액션 이름과
45
+ 인자는 console describe 가 보여 줍니다. 별칭: teams, projects, links, permissions.
46
+ 읽기는 bind 가 받은 프로젝트 키(그 키의 팀만)나 계정 키로 됩니다. 쓰기는 계정 키와, 콘솔
47
+ "AI 권한" 에서 켠 권한이 둘 다 있어야 합니다. 접근 범위를 넓히는 쓰기는 실행 전에 한 번 묻고,
48
+ 터미널이 아니면 --yes 가 있어야 실행합니다. --json 은 서버 응답을 그대로 냅니다(오류 응답도).
49
+ console key
50
+ 계정 관리 키를 브라우저 확인으로 받아 .aiakiv/console.json 에 씁니다. 이 키로는 기억을
51
+ 읽거나 쓸 수 없고 관리 작업만 합니다. 기본 만료 90일. 프로젝트나 팀을 고르지 않고, 속한
52
+ 모든 팀을 다룹니다(.gitignore 에 /.aiakiv/ 를 더합니다). 다시 받으면 앞선 계정 키는 폐기됩니다.
53
+ dashboard
54
+ 팀, 프로젝트, 저장 대상, 켜진 권한, 연결, 카드, 최근 저장 대상 이력을 한 화면에 보입니다.
55
+ 한 칸이 실패해도 나머지는 그립니다. 계정 키로는 저장 대상을 읽지 않고, 그 폴더에 프로젝트
56
+ 키가 있으면 그 키로 확인합니다.
24
57
 
25
- 전역 설치(계정 연결과 전역 스킬)는 이 명령이 하지 않습니다. 설치 프롬프트나 플러그인 마켓을 쓰세요.
58
+ console 과 dashboard 가 키를 찾는 순서: 환경 변수 AIAKIV_API_KEY, <폴더>/.aiakiv/console.json(계정 키),
59
+ <폴더>의 .codex/config.toml 과 .gemini/settings.json(프로젝트 키). 키 평문은 화면에 내지 않고 키 id 와
60
+ 종류만 보입니다.
61
+
62
+ console 과 dashboard 의 종료 코드:
63
+ 0 성공
64
+ 1 서버 오류(500, 연결 실패, 읽을 수 없는 응답)
65
+ 2 사용 오류(인자, --yes 없음, 키 없음)
66
+ 3 인증 실패(401: 키가 틀렸거나 폐기, 만료됨)
67
+ 4 거절(403: scope_required, static_key_required, account_pending_deletion, forbidden).
68
+ scope_required 는 프로젝트 키로 관리 작업을 한 경우이고, 계정 키를 console key 로 받습니다
69
+ 5 속도 제한(429)
70
+ 6 권한 꺼짐(console_permission_disabled, 콘솔 "AI 권한" 에서 켭니다)
71
+ 7 서버에서 CLI 콘솔 입구가 아직 열리지 않음(404)
72
+ 8 그 밖의 오류 응답(unknown_action, invalid_data 등)
73
+
74
+ 전역 설치(계정 연결과 전역 스킬)는 Gemini CLI 와 Antigravity 만 setup 으로 합니다. 그 밖은 Claude Code 는
75
+ 플러그인 마켓, ChatGPT 와 Codex 는 설치 프롬프트, Cursor 는 홈페이지의 설치 링크를 쓰세요.
26
76
 
27
77
  환경 변수:
28
78
  AIAKIV_MCP_URL MCP 주소(기본 ${DEFAULT_MCP_URL})
29
79
  AIAKIV_CONTROL_URL 웹 콘솔 주소(기본 ${DEFAULT_CONTROL_URL})
30
- AIAKIV_SKILLS_TARBALL_URL 폴더 스킬을 받는 주소(기본 ${DEFAULT_SKILLS_TARBALL_URL})
80
+ AIAKIV_API_KEY console, dashboard 가 먼저 쓰는 키
81
+ AIAKIV_SKILLS_TARBALL_URL 폴더 스킬과 전역 스킬을 받는 주소(기본 ${DEFAULT_SKILLS_TARBALL_URL})
31
82
 
32
83
  공통: -h, --help -v, --version`;
33
84
  class UsageError extends Error {
34
85
  }
86
+ function parseExpiresDays(v) {
87
+ if (v === undefined)
88
+ return undefined;
89
+ const n = Number(v);
90
+ if (!Number.isInteger(n) || n < 1)
91
+ throw new UsageError("--expires-days 는 1 이상의 정수입니다.");
92
+ return n;
93
+ }
35
94
  function parseClient(v, required) {
36
95
  if (v === undefined) {
37
96
  if (required)
@@ -43,6 +102,39 @@ function parseClient(v, required) {
43
102
  }
44
103
  return v;
45
104
  }
105
+ /** `--client a,b --client c` 를 이름 목록으로(중복은 한 번만). */
106
+ function clientList(values) {
107
+ const out = [];
108
+ for (const c of (values ?? []).flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean)) {
109
+ if (!out.includes(c))
110
+ out.push(c);
111
+ }
112
+ return out;
113
+ }
114
+ /** setup 의 --client. gemini, antigravity 만 받는다. 다른 클라이언트면 갈 곳을 알린다. */
115
+ function parseSetupClients(values) {
116
+ const names = clientList(values);
117
+ if (!names.length)
118
+ throw new UsageError(`--client 가 필요합니다(${GLOBAL_CLIENT_IDS.join(", ")} 중에서, 쉼표로 여럿).`);
119
+ for (const n of names) {
120
+ if (GLOBAL_CLIENT_IDS.includes(n))
121
+ continue;
122
+ if (CLIENT_IDS.includes(n) || n === "chatgpt" || n === "grok") {
123
+ throw new UsageError(`${SETUP_ONLY_GLOBAL_CLIENTS} (받은 값: ${n})`);
124
+ }
125
+ throw new UsageError(`--client 는 ${GLOBAL_CLIENT_IDS.join(", ")} 중에서 고릅니다(받은 값: ${n}).`);
126
+ }
127
+ return names;
128
+ }
129
+ /** doctor 의 --client. bind 의 넷과 antigravity. */
130
+ function parseDoctorClients(values) {
131
+ return clientList(values).map((c) => {
132
+ if (!DOCTOR_CLIENT_IDS.includes(c)) {
133
+ throw new UsageError(`--client 는 ${DOCTOR_CLIENT_IDS.join(", ")} 중에서 고릅니다(받은 값: ${c}).`);
134
+ }
135
+ return c;
136
+ });
137
+ }
46
138
  export async function main(argv, ctx) {
47
139
  const [cmd, ...rest] = argv;
48
140
  try {
@@ -55,11 +147,38 @@ export async function main(argv, ctx) {
55
147
  return 0;
56
148
  }
57
149
  if (cmd === "setup") {
58
- // 첫 판(P2 v1)에 있던 명령. 옛 안내를 따라 친 사람에게 갈 곳을 알린다.
59
- throw new UsageError("setup 명령은 없어졌습니다. 전역 설치(계정 연결과 전역 스킬)는 설치 프롬프트나 플러그인 마켓으로 하고, " +
60
- "폴더 고정은 bind 를 쓰세요.");
150
+ // 전역 설치. Gemini CLI 와 Antigravity 만(BP-165 P6). 첫 판(P2 v1)의 setup 과는 다른 명령이다.
151
+ const { values, positionals } = parseArgs({
152
+ args: rest,
153
+ options: {
154
+ client: { type: "string", multiple: true },
155
+ "expires-days": { type: "string" },
156
+ "no-browser": { type: "boolean", default: false },
157
+ "no-skills": { type: "boolean", default: false },
158
+ "new-key": { type: "boolean", default: false },
159
+ help: { type: "boolean", short: "h" },
160
+ },
161
+ strict: true,
162
+ allowPositionals: true,
163
+ });
164
+ if (values.help)
165
+ return (ctx.out(HELP), 0);
166
+ if (positionals.length)
167
+ throw new UsageError(`setup 은 위치 인자를 받지 않습니다: ${positionals.join(" ")}`);
168
+ const clients = parseSetupClients(values.client);
169
+ return await runSetup(ctx, {
170
+ clients,
171
+ expiresDays: parseExpiresDays(values["expires-days"]),
172
+ noBrowser: values["no-browser"],
173
+ skills: !values["no-skills"],
174
+ newKey: values["new-key"],
175
+ });
61
176
  }
62
177
  if (cmd === "bind") {
178
+ if (rest.some((a) => /^--manage(=|$)/.test(a))) {
179
+ // R0(P2) 에 있던 플래그. 옛 안내를 따라 친 사람에게 갈 곳을 알린다(BP-171 §10 결정 10).
180
+ throw new UsageError("bind --manage 는 없어졌습니다. 관리 작업용 계정 키는 npx aiakiv console key 로 받습니다.");
181
+ }
63
182
  const { values } = parseArgs({
64
183
  args: rest,
65
184
  options: {
@@ -75,18 +194,16 @@ export async function main(argv, ctx) {
75
194
  });
76
195
  if (values.help)
77
196
  return (ctx.out(HELP), 0);
197
+ if (values.client === "antigravity") {
198
+ throw new UsageError("Antigravity 는 앱 전체에 설정 하나라 폴더 고정이 없습니다. npx aiakiv setup --client antigravity 를 쓰세요.");
199
+ }
78
200
  const client = parseClient(values.client, true);
79
201
  if (!values.project?.trim())
80
202
  throw new UsageError("--project <이름> 이 필요합니다.");
81
- let expiresDays;
82
- if (values["expires-days"] !== undefined) {
83
- expiresDays = Number(values["expires-days"]);
84
- if (!Number.isInteger(expiresDays) || expiresDays < 1) {
85
- throw new UsageError("--expires-days 는 1 이상의 정수입니다.");
86
- }
87
- if (client === "claude" || client === "cursor") {
88
- ctx.err(`참고: ${client} 는 키를 받지 않으므로 --expires-days 는 쓰이지 않습니다.`);
89
- }
203
+ const expiresDays = parseExpiresDays(values["expires-days"]);
204
+ const oauth = client === "claude" || client === "cursor";
205
+ if (oauth && expiresDays !== undefined) {
206
+ ctx.err(`참고: ${client} 는 키를 받지 않으므로 --expires-days 는 쓰이지 않습니다.`);
90
207
  }
91
208
  return await runBind(ctx, {
92
209
  client,
@@ -111,18 +228,78 @@ export async function main(argv, ctx) {
111
228
  });
112
229
  if (values.help)
113
230
  return (ctx.out(HELP), 0);
114
- const clients = (values.client ?? [])
115
- .flatMap((c) => c.split(","))
116
- .map((c) => c.trim())
117
- .filter(Boolean)
118
- .map((c) => parseClient(c, true));
231
+ const clients = parseDoctorClients(values.client);
119
232
  return await runDoctor(ctx, { clients, dir: values.dir, out: values.out, noProbe: values["no-probe"] });
120
233
  }
121
- throw new UsageError(`모르는 명령입니다: ${cmd}. bind, doctor 중 하나를 쓰세요(도움말: --help).`);
234
+ if (cmd === "console" && rest[0] === "key") {
235
+ const dropped = rest.slice(1).map((a) => /^--(project|manage|team)(=|$)/.exec(a)?.[1]).find(Boolean);
236
+ if (dropped) {
237
+ throw new UsageError(`console key 는 --${dropped} 를 받지 않습니다. 계정 키는 프로젝트나 팀 없이 받습니다.`);
238
+ }
239
+ const { values, positionals } = parseArgs({
240
+ args: rest.slice(1),
241
+ options: {
242
+ "expires-days": { type: "string" },
243
+ dir: { type: "string" },
244
+ "no-browser": { type: "boolean", default: false },
245
+ help: { type: "boolean", short: "h" },
246
+ },
247
+ strict: true,
248
+ allowPositionals: true,
249
+ });
250
+ if (values.help)
251
+ return (ctx.out(HELP), 0);
252
+ if (positionals.length)
253
+ throw new UsageError(`console key 는 위치 인자를 받지 않습니다: ${positionals.join(" ")}`);
254
+ return await runConsoleKey(ctx, {
255
+ expiresDays: parseExpiresDays(values["expires-days"]),
256
+ dir: values.dir,
257
+ noBrowser: values["no-browser"],
258
+ });
259
+ }
260
+ if (cmd === "console") {
261
+ const { values, positionals } = parseArgs({
262
+ args: rest,
263
+ options: {
264
+ data: { type: "string" },
265
+ json: { type: "boolean", default: false },
266
+ dir: { type: "string" },
267
+ yes: { type: "boolean", default: false },
268
+ help: { type: "boolean", short: "h" },
269
+ },
270
+ strict: true,
271
+ allowPositionals: true,
272
+ });
273
+ if (values.help)
274
+ return (ctx.out(HELP), 0);
275
+ if (positionals.length !== 1) {
276
+ throw new UsageError(positionals.length
277
+ ? `액션은 하나만 줍니다(받은 값: ${positionals.join(" ")}).`
278
+ : "액션 이름이 필요합니다. 목록은 npx aiakiv console describe 로 봅니다.");
279
+ }
280
+ return await runConsole(ctx, {
281
+ action: positionals[0], data: values.data, json: values.json, dir: values.dir, yes: values.yes,
282
+ });
283
+ }
284
+ if (cmd === "dashboard") {
285
+ const { values } = parseArgs({
286
+ args: rest,
287
+ options: {
288
+ json: { type: "boolean", default: false },
289
+ dir: { type: "string" },
290
+ help: { type: "boolean", short: "h" },
291
+ },
292
+ strict: true,
293
+ });
294
+ if (values.help)
295
+ return (ctx.out(HELP), 0);
296
+ return await runDashboard(ctx, { json: values.json, dir: values.dir });
297
+ }
298
+ throw new UsageError(`모르는 명령입니다: ${cmd}. bind, setup, doctor, console, dashboard 중 하나를 쓰세요(도움말: --help).`);
122
299
  }
123
300
  catch (e) {
124
301
  const err = e;
125
- if (e instanceof UsageError || (err.code ?? "").startsWith("ERR_PARSE_ARGS")) {
302
+ if (e instanceof UsageError || e instanceof ConsoleUsageError || (err.code ?? "").startsWith("ERR_PARSE_ARGS")) {
126
303
  ctx.err(err.message);
127
304
  ctx.err("도움말: npx aiakiv --help");
128
305
  return 2;