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 +66 -10
- package/dist/bind.js +19 -13
- 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 +273 -0
- package/dist/skills.js +14 -2
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/mcpProbe.js
CHANGED
|
@@ -117,7 +117,9 @@ export async function probeSaveTarget(fetchImpl, url, key, opts = {}) {
|
|
|
117
117
|
const t = saveTargetFromResult(result);
|
|
118
118
|
if (result.isError || !t)
|
|
119
119
|
return fail(call.status, "get_save_target 이 오류를 돌려줬습니다");
|
|
120
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
ok: true, team: str(t.team), project: str(t.project), binding: str(t.binding), account: str(t.account), raw: t,
|
|
122
|
+
};
|
|
121
123
|
}
|
|
122
124
|
catch (e) {
|
|
123
125
|
const err = e;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// 사람이 읽는 출력(표와 목록). `--json` 이 아닐 때만 쓴다. 응답 모양을 액션마다 알지 않고, 모양을
|
|
2
|
+
// 보고 그린다: 객체 배열은 표(핵심 칸 몇 개), 글자와 숫자는 "이름: 값", 나머지는 짧은 JSON.
|
|
3
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
|
+
/** 터미널 폭: 한글과 전각 문자는 두 칸. */
|
|
5
|
+
export function displayWidth(s) {
|
|
6
|
+
let w = 0;
|
|
7
|
+
for (const ch of s) {
|
|
8
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
9
|
+
const wide = (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
|
|
10
|
+
(c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) ||
|
|
11
|
+
(c >= 0xffe0 && c <= 0xffe6);
|
|
12
|
+
w += wide ? 2 : 1;
|
|
13
|
+
}
|
|
14
|
+
return w;
|
|
15
|
+
}
|
|
16
|
+
export function padEnd(s, width) {
|
|
17
|
+
return s + " ".repeat(Math.max(0, width - displayWidth(s)));
|
|
18
|
+
}
|
|
19
|
+
export function truncate(s, max = 48) {
|
|
20
|
+
return [...s].length > max ? `${[...s].slice(0, max - 1).join("")}…` : s;
|
|
21
|
+
}
|
|
22
|
+
const isScalar = (v) => v === null || ["string", "number", "boolean"].includes(typeof v);
|
|
23
|
+
/** 칸 하나를 글자로. */
|
|
24
|
+
export function cellText(v, max = 48) {
|
|
25
|
+
if (v === null || v === undefined || v === "")
|
|
26
|
+
return "-";
|
|
27
|
+
if (typeof v === "boolean")
|
|
28
|
+
return v ? "예" : "아니오";
|
|
29
|
+
if (typeof v === "string" || typeof v === "number")
|
|
30
|
+
return truncate(String(v), max);
|
|
31
|
+
if (Array.isArray(v) && v.every(isScalar))
|
|
32
|
+
return v.length ? truncate(v.map((x) => String(x)).join(", "), max) : "-";
|
|
33
|
+
return truncate(JSON.stringify(v), max);
|
|
34
|
+
}
|
|
35
|
+
/** 표. 머리줄, 구분줄, 행들. */
|
|
36
|
+
export function table(headers, rows) {
|
|
37
|
+
const widths = headers.map((h, i) => Math.max(displayWidth(h), ...rows.map((r) => displayWidth(r[i] ?? ""))));
|
|
38
|
+
const line = (cells) => cells.map((c, i) => (i === cells.length - 1 ? c : padEnd(c, widths[i]))).join(" ").trimEnd();
|
|
39
|
+
return [line(headers), widths.map((w) => "-".repeat(w)).join(" "), ...rows.map(line)];
|
|
40
|
+
}
|
|
41
|
+
// 표에 먼저 올릴 칸 이름(있으면 이 순서로). 그 밖의 칸은 처음 나온 순서로 뒤에 붙는다.
|
|
42
|
+
const PREFERRED = [
|
|
43
|
+
"name", "title", "key", "id", "link_id", "role", "status", "kind", "enabled", "is_main", "is_default",
|
|
44
|
+
"org_name", "counterparty_org_name", "email", "created_at", "ts", "action",
|
|
45
|
+
];
|
|
46
|
+
const MAX_COLUMNS = 6;
|
|
47
|
+
export function columnsFor(rows) {
|
|
48
|
+
const seen = [];
|
|
49
|
+
for (const r of rows) {
|
|
50
|
+
for (const [k, v] of Object.entries(r)) {
|
|
51
|
+
const simple = isScalar(v) || (Array.isArray(v) && v.every(isScalar));
|
|
52
|
+
if (simple && !seen.includes(k))
|
|
53
|
+
seen.push(k);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const ordered = [...PREFERRED.filter((k) => seen.includes(k)), ...seen.filter((k) => !PREFERRED.includes(k))];
|
|
57
|
+
return ordered.slice(0, MAX_COLUMNS);
|
|
58
|
+
}
|
|
59
|
+
export function objectTable(rows) {
|
|
60
|
+
const cols = columnsFor(rows);
|
|
61
|
+
if (!cols.length)
|
|
62
|
+
return rows.map((r) => ` ${truncate(JSON.stringify(r), 120)}`);
|
|
63
|
+
return table(cols, rows.map((r) => cols.map((c) => cellText(r[c])))).map((l) => ` ${l}`);
|
|
64
|
+
}
|
|
65
|
+
/** 결과 객체 하나를 줄들로. */
|
|
66
|
+
export function renderResult(body) {
|
|
67
|
+
if (!isObj(body))
|
|
68
|
+
return [cellText(body, 200)];
|
|
69
|
+
const lines = [];
|
|
70
|
+
for (const [k, v] of Object.entries(body)) {
|
|
71
|
+
if (Array.isArray(v) && v.length && v.every(isObj)) {
|
|
72
|
+
lines.push(`${k} (${v.length}개):`);
|
|
73
|
+
lines.push(...objectTable(v));
|
|
74
|
+
}
|
|
75
|
+
else if (Array.isArray(v) && !v.length) {
|
|
76
|
+
lines.push(`${k}: 없음`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
lines.push(`${k}: ${cellText(v, isScalar(v) ? 400 : 160)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return lines.length ? lines : ["(빈 응답)"];
|
|
83
|
+
}
|
|
84
|
+
/** `describe` 응답의 사람용 표: 이름, 읽기/쓰기, 권한, 위험, 접근 범위 넓힘, 인자. */
|
|
85
|
+
export function renderDescribe(body) {
|
|
86
|
+
const actions = isObj(body) && isObj(body.actions) ? body.actions : null;
|
|
87
|
+
if (!actions)
|
|
88
|
+
return renderResult(body);
|
|
89
|
+
const rows = Object.entries(actions).map(([name, raw]) => {
|
|
90
|
+
const a = isObj(raw) ? raw : {};
|
|
91
|
+
const params = isObj(a.params) ? Object.keys(a.params) : [];
|
|
92
|
+
return [
|
|
93
|
+
name,
|
|
94
|
+
a.kind === "write" ? "쓰기" : a.kind === "read" ? "읽기" : cellText(a.kind),
|
|
95
|
+
cellText(a.permission),
|
|
96
|
+
Array.isArray(a.risk) && a.risk.length ? a.risk.join(",") : "-",
|
|
97
|
+
a.widens_access === true ? "넓힘" : "-",
|
|
98
|
+
params.length ? params.join(", ") : "-",
|
|
99
|
+
];
|
|
100
|
+
});
|
|
101
|
+
const lines = table(["이름", "종류", "권한", "위험", "접근 범위", "인자"], rows);
|
|
102
|
+
lines.push("");
|
|
103
|
+
lines.push("인자 끝의 ? 는 선택입니다. 설명과 인자의 뜻은 --json 으로 봅니다.");
|
|
104
|
+
lines.push("접근 범위가 \"넓힘\" 인 쓰기는 누군가 새로 읽게 됩니다. 실행 전에 한 번 묻습니다(--yes 로 건너뜀).");
|
|
105
|
+
if (isObj(body) && typeof body.settings_url === "string")
|
|
106
|
+
lines.push(`권한 설정: ${body.settings_url}`);
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// `aiakiv setup --client gemini|antigravity[,…]` — 전역 설치(BP-165 P6, 2026-10-21 결정).
|
|
2
|
+
//
|
|
3
|
+
// Gemini CLI 와 Antigravity 만 한다(clients/global.ts). 흐름:
|
|
4
|
+
// 1. 고른 클라이언트의 전역 설정 파일을 먼저 읽는다. 읽을 수 없으면 키를 받기 전에 멈춘다.
|
|
5
|
+
// 2. 키 재사용 판정(`--new-key` 가 없을 때): 두 클라이언트의 전역 파일에서 `AiAkiv` 항목의 Bearer 키를
|
|
6
|
+
// 찾아 get_save_target 을 불러 본다. 성공하고 binding 이 main 이면 그 키를 다시 쓴다(브라우저를
|
|
7
|
+
// 열지 않는다). 서버 규칙상 계정 전체 키는 한 사람에게 하나만 살아 있어, 새로 받으면 앞선 키(다른 PC
|
|
8
|
+
// 것 포함)가 폐기된다. 그래서 쓸 수 있는 키가 있으면 새로 받지 않는다.
|
|
9
|
+
// 3. 아니면 브라우저 인계(kind=dynamic)로 키 하나를 받는다.
|
|
10
|
+
// 4. 고른 클라이언트 모두의 전역 설정 파일에 같은 키를 쓴다(다른 항목은 보존, 원자적 쓰기).
|
|
11
|
+
// 5. 전역 스킬(`--no-skills` 면 건너뜀). 실패해도 설정 쓰기는 성공으로 두고 칠 명령을 보여 준다.
|
|
12
|
+
// 6. 사람이 할 일을 안내한다.
|
|
13
|
+
//
|
|
14
|
+
// 키 평문은 화면, 오류 문구 어디에도 내지 않는다(키 id 만). get_save_target 은 키 id 를 돌려주지 않으므로,
|
|
15
|
+
// 새로 받을 때 키 id 와 키의 지문(sha256 앞 16자, 평문은 아니다)을 `~/.gemini/aiakiv-setup.json` 에 적어
|
|
16
|
+
// 두고, 다시 쓸 때 지문이 맞으면 그 id 를 보인다.
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { GLOBAL_CLIENT_IDS, GLOBAL_CLIENT_LABEL, GLOBAL_SERVER_NAME, geminiHome, globalConfigPath, globalEntry, globalSkillsTargets, } from "./clients/global.js";
|
|
20
|
+
import { ConfigError, jsonServers, readJsonObject, upsertJsonServer } from "./config/json.js";
|
|
21
|
+
import { expiryText } from "./consoleKeyFile.js";
|
|
22
|
+
import { classifyEntry } from "./doctor.js";
|
|
23
|
+
import { readTextIfExists, writeFileAtomic } from "./fsutil.js";
|
|
24
|
+
import { exchangeCode, handoffUrl, HANDOFF_TIMEOUT_MS, newState, scopeList, startLoopback, supersededNotice, } from "./handoff.js";
|
|
25
|
+
import { probeSaveTarget } from "./mcpProbe.js";
|
|
26
|
+
import { downloadSkillPack, SKILL_NAMES, SKILLS_REPO, skillsGlobalManualCommand, writeSkillsTo } from "./skills.js";
|
|
27
|
+
import { canonicalMcpUrl, controlBase } from "./urls.js";
|
|
28
|
+
import { VERSION } from "./version.js";
|
|
29
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
30
|
+
/** 새로 받은 키의 기록(평문 없음). `~/.gemini/aiakiv-setup.json`. */
|
|
31
|
+
export const SETUP_RECORD = "aiakiv-setup.json";
|
|
32
|
+
export function keyFingerprint(key) {
|
|
33
|
+
return createHash("sha256").update(key, "utf8").digest("hex").slice(0, 16);
|
|
34
|
+
}
|
|
35
|
+
export function setupRecordPath(ctx) {
|
|
36
|
+
return join(geminiHome(ctx), SETUP_RECORD);
|
|
37
|
+
}
|
|
38
|
+
function readSetupRecord(ctx) {
|
|
39
|
+
try {
|
|
40
|
+
const text = readTextIfExists(setupRecordPath(ctx));
|
|
41
|
+
if (text === null)
|
|
42
|
+
return null;
|
|
43
|
+
const v = JSON.parse(text);
|
|
44
|
+
if (!isObj(v) || typeof v.key_id !== "string" || typeof v.key_fingerprint !== "string")
|
|
45
|
+
return null;
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// 키 순서와 상관없이 같은 항목인가(같으면 파일을 다시 쓰지 않는다).
|
|
53
|
+
function canonical(v) {
|
|
54
|
+
if (Array.isArray(v))
|
|
55
|
+
return v.map(canonical);
|
|
56
|
+
if (isObj(v))
|
|
57
|
+
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])]));
|
|
58
|
+
return v;
|
|
59
|
+
}
|
|
60
|
+
const sameEntry = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
61
|
+
/** 고른 클라이언트의 전역 파일을 읽는다. 읽을 수 없거나 mcpServers 가 객체가 아니면 ConfigError(아무것도 쓰지 않는다). */
|
|
62
|
+
function readTargets(ctx, clients) {
|
|
63
|
+
return clients.map((client) => {
|
|
64
|
+
const file = globalConfigPath(ctx, client);
|
|
65
|
+
const doc = readJsonObject(file);
|
|
66
|
+
if (doc && doc.mcpServers !== undefined && !isObj(doc.mcpServers)) {
|
|
67
|
+
throw new ConfigError(`${file} 의 mcpServers 가 객체가 아닙니다. 파일은 바꾸지 않았습니다.`);
|
|
68
|
+
}
|
|
69
|
+
return { client, file, existing: jsonServers(doc)[GLOBAL_SERVER_NAME] };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** 두 클라이언트의 전역 파일에서 `AiAkiv` 항목의 Bearer 키를 찾아 get_save_target 으로 확인한다.
|
|
73
|
+
* 성공하고 binding 이 main 인 첫 키를 돌려준다. 못 쓰는 키는 이유를 한 줄씩 알린다(평문 없이). */
|
|
74
|
+
async function findReusableKey(ctx, url) {
|
|
75
|
+
const tried = new Set();
|
|
76
|
+
for (const client of GLOBAL_CLIENT_IDS) {
|
|
77
|
+
const file = globalConfigPath(ctx, client);
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = jsonServers(readJsonObject(file))[GLOBAL_SERVER_NAME];
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
ctx.err(`참고: ${e.message}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!raw)
|
|
87
|
+
continue;
|
|
88
|
+
const e = classifyEntry({ client, scope: "전역", file, name: GLOBAL_SERVER_NAME }, raw, ctx.env);
|
|
89
|
+
if (e.auth !== "apikey" || !e.key || tried.has(e.key))
|
|
90
|
+
continue;
|
|
91
|
+
tried.add(e.key);
|
|
92
|
+
const p = await probeSaveTarget(ctx.fetch, url, e.key, { version: VERSION });
|
|
93
|
+
if (p.ok && p.binding === "main")
|
|
94
|
+
return { key: e.key, from: file };
|
|
95
|
+
const why = p.ok
|
|
96
|
+
? `binding 이 ${p.binding ?? "?"} 라 계정 전체 키가 아닙니다`
|
|
97
|
+
: `확인 실패${p.status ? `(${p.status})` : ""}: ${p.reason}`;
|
|
98
|
+
ctx.out(`${file} 의 ${GLOBAL_SERVER_NAME} 키는 다시 쓸 수 없습니다(${why}).`);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
export async function runSetup(ctx, opts) {
|
|
103
|
+
try {
|
|
104
|
+
return await setup(ctx, opts);
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
ctx.err(e.message);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function newKeyByHandoff(ctx, opts, url) {
|
|
112
|
+
const control = controlBase(ctx.env);
|
|
113
|
+
const timeoutMs = opts.timeoutMs ?? HANDOFF_TIMEOUT_MS;
|
|
114
|
+
const state = newState();
|
|
115
|
+
const lb = await startLoopback(state, timeoutMs);
|
|
116
|
+
const hurl = handoffUrl(control, {
|
|
117
|
+
kind: "dynamic", port: lb.port, state, client: opts.clients.join(","), expiresDays: opts.expiresDays,
|
|
118
|
+
});
|
|
119
|
+
ctx.out("웹 콘솔에서 계정 전체 기억 키를 이 PC 로 받습니다. 이 키는 Main(저장 대상)을 따라갑니다.");
|
|
120
|
+
ctx.out("계정 전체 키는 한 사람에게 하나만 살아 있어, 새로 받으면 앞선 키(다른 PC 에 넣은 것 포함)는 폐기됩니다.");
|
|
121
|
+
ctx.out("브라우저에서 로그인하고 확인 버튼을 누르세요. 브라우저가 열리지 않으면 이 주소를 직접 여세요:");
|
|
122
|
+
ctx.out(` ${hurl}`);
|
|
123
|
+
if (!opts.noBrowser) {
|
|
124
|
+
const opened = await ctx.openUrl(hurl);
|
|
125
|
+
if (!opened)
|
|
126
|
+
ctx.err("브라우저를 열지 못했습니다. 위 주소를 직접 여세요.");
|
|
127
|
+
}
|
|
128
|
+
ctx.out(`기다리는 중(최대 ${Math.round(timeoutMs / 60_000)}분, 127.0.0.1:${lb.port})...`);
|
|
129
|
+
let code;
|
|
130
|
+
try {
|
|
131
|
+
code = await lb.code;
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
lb.close();
|
|
135
|
+
ctx.err(e.message);
|
|
136
|
+
ctx.err("명령을 다시 실행하세요.");
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
// 응답 kind 가 dynamic 이 아니면(서버가 옛 판) 여기서 HandoffError 로 끝난다. 받은 평문은 쓰지 않는다.
|
|
140
|
+
const res = await exchangeCode(ctx.fetch, control, { code, state, port: lb.port }, "dynamic");
|
|
141
|
+
const scopes = scopeList(res.scopes).join(",");
|
|
142
|
+
const expiresAt = typeof res.expires_at === "string" && res.expires_at ? res.expires_at : null;
|
|
143
|
+
ctx.out();
|
|
144
|
+
ctx.out(`계정 전체 키를 받았습니다. 키 id ${res.key_id}, 스코프 ${scopes || "?"}, 만료 ${expiryText(expiresAt)}.`);
|
|
145
|
+
const sup = supersededNotice(res.superseded, "dynamic");
|
|
146
|
+
if (sup)
|
|
147
|
+
ctx.out(sup);
|
|
148
|
+
if (typeof res.mcp_url === "string" && res.mcp_url && res.mcp_url !== url) {
|
|
149
|
+
ctx.err(`참고: 서버가 알려 준 MCP 주소(${res.mcp_url})와 설정에 쓰는 주소(${url})가 다릅니다. ` +
|
|
150
|
+
"AIAKIV_MCP_URL 이 맞는지 확인하세요.");
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const rec = {
|
|
154
|
+
key_id: res.key_id,
|
|
155
|
+
key_fingerprint: keyFingerprint(res.plaintext),
|
|
156
|
+
scopes,
|
|
157
|
+
expires_at: expiresAt,
|
|
158
|
+
issued_at: new Date().toISOString(),
|
|
159
|
+
};
|
|
160
|
+
writeFileAtomic(setupRecordPath(ctx), `${JSON.stringify(rec, null, 2)}\n`);
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
// 기록은 다시 쓸 때 키 id 를 보이는 데만 쓴다. 못 써도 설치는 계속한다.
|
|
164
|
+
ctx.err(`참고: ${setupRecordPath(ctx)} 에 키 id 를 적지 못했습니다(${e.message}).`);
|
|
165
|
+
}
|
|
166
|
+
return { key: res.plaintext, keyId: res.key_id, reused: false };
|
|
167
|
+
}
|
|
168
|
+
async function setup(ctx, opts) {
|
|
169
|
+
const url = canonicalMcpUrl(ctx.env);
|
|
170
|
+
const targets = readTargets(ctx, opts.clients);
|
|
171
|
+
ctx.out(`전역 설치: ${opts.clients.map((c) => GLOBAL_CLIENT_LABEL[c]).join(", ")}`);
|
|
172
|
+
let got = null;
|
|
173
|
+
if (!opts.newKey) {
|
|
174
|
+
const r = await findReusableKey(ctx, url);
|
|
175
|
+
if (r) {
|
|
176
|
+
const rec = readSetupRecord(ctx);
|
|
177
|
+
const keyId = rec && rec.key_fingerprint === keyFingerprint(r.key) ? rec.key_id : undefined;
|
|
178
|
+
got = { key: r.key, keyId, reused: true };
|
|
179
|
+
if (opts.expiresDays !== undefined) {
|
|
180
|
+
ctx.err("참고: 받아 둔 키를 다시 쓰므로 --expires-days 는 쓰이지 않았습니다. 새 키를 받으려면 --new-key 를 더하세요.");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!got) {
|
|
185
|
+
got = await newKeyByHandoff(ctx, opts, url);
|
|
186
|
+
if (!got)
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
let failed = false;
|
|
190
|
+
const keyFiles = [];
|
|
191
|
+
for (const t of targets) {
|
|
192
|
+
const entry = globalEntry(t.client, url, got.key);
|
|
193
|
+
const label = GLOBAL_CLIENT_LABEL[t.client];
|
|
194
|
+
if (t.existing && sameEntry(t.existing, entry)) {
|
|
195
|
+
ctx.out(`그대로 둡니다: ${t.file} (${label}, 같은 키와 주소가 이미 있습니다)`);
|
|
196
|
+
keyFiles.push(t.file);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const r = upsertJsonServer(t.file, GLOBAL_SERVER_NAME, entry, { secret: true });
|
|
201
|
+
keyFiles.push(t.file);
|
|
202
|
+
ctx.out(`${r.replaced ? "바꿨습니다" : "더했습니다"}: ${t.file} (${label})`);
|
|
203
|
+
ctx.out(` 서버 이름 ${GLOBAL_SERVER_NAME}`);
|
|
204
|
+
ctx.out(` 주소 ${url}`);
|
|
205
|
+
if (r.backup) {
|
|
206
|
+
ctx.out(` 참고: 원래 파일에 주석이나 끝 쉼표가 있어 표준 JSON 으로 다시 썼습니다. 원본은 ${r.backup} 에 남겼습니다. ` +
|
|
207
|
+
"원본에는 예전 키가 들어 있을 수 있으니 확인한 뒤 지우세요.");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
failed = true;
|
|
212
|
+
ctx.err(`${t.file} 에 쓰지 못했습니다(${e.message}).`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (got.reused) {
|
|
216
|
+
ctx.out(`이미 받아 둔 계정 전체 키를 다시 썼습니다(키 id ${got.keyId ?? "모름, 이 PC 의 setup 기록에 없습니다"}).`);
|
|
217
|
+
}
|
|
218
|
+
if (opts.skills !== false)
|
|
219
|
+
await installGlobalSkills(ctx, opts.clients);
|
|
220
|
+
else
|
|
221
|
+
ctx.out("전역 스킬: --no-skills 라 건너뜁니다.");
|
|
222
|
+
ctx.out("다음 할 일(사람):");
|
|
223
|
+
if (opts.clients.includes("gemini")) {
|
|
224
|
+
ctx.out(` - Gemini CLI: gemini 를 실행한 뒤 /mcp 에서 ${GLOBAL_SERVER_NAME} 가 연결됐는지 보고, ` +
|
|
225
|
+
"\"저장 대상 확인해 줘\" 라고 해서 binding 이 main 인지 확인하세요.");
|
|
226
|
+
}
|
|
227
|
+
if (opts.clients.includes("antigravity")) {
|
|
228
|
+
ctx.out(` - Antigravity: Agent 설정의 MCP Servers 에서 ${GLOBAL_SERVER_NAME} 가 보이는지 확인하세요. ` +
|
|
229
|
+
"앱이 켜져 있었다면 설정 파일을 바꿨으니 앱을 다시 시작하세요.");
|
|
230
|
+
}
|
|
231
|
+
ctx.out(` - 확인: npx aiakiv doctor --client ${opts.clients.join(",")}`);
|
|
232
|
+
// 홈 폴더 파일이라 .gitignore 로 빼 줄 수 없다. 마지막 줄로 알린다.
|
|
233
|
+
if (keyFiles.length) {
|
|
234
|
+
ctx.err(`주의: 키 평문이 ${keyFiles.join(", ")} 에 있습니다. ` +
|
|
235
|
+
`${keyFiles.length > 1 ? "이 파일들을" : "이 파일을"} 공유하거나 커밋하지 마세요.`);
|
|
236
|
+
}
|
|
237
|
+
return failed ? 1 : 0;
|
|
238
|
+
}
|
|
239
|
+
function manualSkillsLine(t) {
|
|
240
|
+
return t.skillsAgent
|
|
241
|
+
? `직접 실행하세요: ${skillsGlobalManualCommand(t.skillsAgent)}`
|
|
242
|
+
: `https://github.com/${SKILLS_REPO} 의 aiakiv/skills/ 아래 스킬 폴더들을 ${t.dir} 에 폴더째 복사하세요.`;
|
|
243
|
+
}
|
|
244
|
+
/** 전역 스킬 단계. 실패하면 false 와 함께 사람이 할 일을 보여 준다(예외를 내지 않는다). */
|
|
245
|
+
export async function installGlobalSkills(ctx, clients) {
|
|
246
|
+
const targets = clients.flatMap((c) => globalSkillsTargets(ctx, c).map((t) => ({ client: c, ...t })));
|
|
247
|
+
let pack;
|
|
248
|
+
try {
|
|
249
|
+
pack = await downloadSkillPack(ctx);
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
ctx.err(`전역 스킬을 받지 못했습니다(${e.message}). 연결 설정은 이미 썼습니다.`);
|
|
253
|
+
for (const t of targets)
|
|
254
|
+
ctx.err(` ${GLOBAL_CLIENT_LABEL[t.client]}(${t.dir}): ${manualSkillsLine(t)}`);
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
let ok = true;
|
|
258
|
+
for (const t of targets) {
|
|
259
|
+
try {
|
|
260
|
+
const names = writeSkillsTo(t.dir, pack);
|
|
261
|
+
ctx.out(`전역 스킬 ${names.length}개: ${t.dir} (${GLOBAL_CLIENT_LABEL[t.client]}, 판 ${pack.version ?? "모름"})`);
|
|
262
|
+
const missing = SKILL_NAMES.filter((n) => !names.includes(n));
|
|
263
|
+
if (missing.length)
|
|
264
|
+
ctx.err(` 참고: 받은 스킬에 ${missing.join(", ")} 이 없습니다.`);
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
ok = false;
|
|
268
|
+
ctx.err(`전역 스킬을 ${t.dir} 에 쓰지 못했습니다(${e.message}). 연결 설정은 이미 썼습니다.`);
|
|
269
|
+
ctx.err(` ${manualSkillsLine(t)}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return ok;
|
|
273
|
+
}
|
package/dist/skills.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// 폴더 스킬. 웹 콘솔 "폴더 ZIP 받기" 와 같은 자리에 스킬
|
|
1
|
+
// 폴더 스킬. 웹 콘솔 "폴더 ZIP 받기" 와 같은 자리에 스킬 여덟 개를 넣는다
|
|
2
2
|
// (정본은 k2g-console-web `src/lib/folderBundle.ts` 의 SKILL_DIR 와 assertSkillPath).
|
|
3
3
|
//
|
|
4
4
|
// 받는 곳은 공개 저장소 AiAkiv/aiakiv-plugins 의 tar.gz(GitHub codeload). 새 의존성 없이 Node 내장
|
|
@@ -27,6 +27,8 @@ export const SKILL_NAMES = [
|
|
|
27
27
|
"aiakiv-cards",
|
|
28
28
|
"aiakiv-console",
|
|
29
29
|
"aiakiv-contents",
|
|
30
|
+
// BP-171 P3 가 만들고 P5 가 함께 발행한다. 그 전의 설치본에는 없어 "받은 스킬에 … 이 없습니다" 가 나온다.
|
|
31
|
+
"aiakiv-dashboard",
|
|
30
32
|
];
|
|
31
33
|
/** 클라이언트별 폴더 스킬 자리(폴더 기준 상대 경로). */
|
|
32
34
|
export const FOLDER_SKILLS_DIR = {
|
|
@@ -47,6 +49,12 @@ export const SKILLS_AGENT = {
|
|
|
47
49
|
export function skillsManualCommand(agent) {
|
|
48
50
|
return `npx -y skills add ${SKILLS_REPO} -a ${agent} -s '*' -y`;
|
|
49
51
|
}
|
|
52
|
+
/** setup(전역 설치)에서 받기에 실패했을 때 칠 명령. 전역이라 `-g` 를 붙인다. `agent` 는 Skills CLI 의
|
|
53
|
+
* 에이전트 이름(Skills CLI 1.5.18 에서 gemini-cli 의 전역 자리는 `~/.gemini/skills`, antigravity-cli 는
|
|
54
|
+
* `~/.gemini/antigravity-cli/skills` 로 setup 이 쓰는 자리와 같다). */
|
|
55
|
+
export function skillsGlobalManualCommand(agent) {
|
|
56
|
+
return `npx -y skills add ${SKILLS_REPO} -a ${agent} -s '*' -g -y`;
|
|
57
|
+
}
|
|
50
58
|
// 내려받는 크기 한도(지금 저장소는 압축 50KB 안팎).
|
|
51
59
|
const MAX_GZ_BYTES = 20 * 1024 * 1024;
|
|
52
60
|
const MAX_TAR_BYTES = 64 * 1024 * 1024;
|
|
@@ -196,7 +204,11 @@ function removeExisting(p) {
|
|
|
196
204
|
/** `<dir>/<rel>/<스킬>/` 에 쓴다. 같은 이름 스킬 폴더는 통째로 바꾸고, 그 밖의 파일은 건드리지 않는다.
|
|
197
205
|
* 스킬마다 임시 폴더에 다 쓴 뒤 이름을 바꾸므로, 쓰다 멈춰도 예전 스킬이 반쯤 지워지지 않는다. */
|
|
198
206
|
export function writeFolderSkills(dir, rel, pack) {
|
|
199
|
-
|
|
207
|
+
return writeSkillsTo(resolve(dir, ...rel.split("/")), pack);
|
|
208
|
+
}
|
|
209
|
+
/** 스킬 자리 `<root>/<스킬>/` 에 쓴다(writeFolderSkills 와 같은 규칙). setup 은 홈 폴더 아래의 전역 자리를 준다. */
|
|
210
|
+
export function writeSkillsTo(rootDir, pack) {
|
|
211
|
+
const root = resolve(rootDir);
|
|
200
212
|
mkdirSync(root, { recursive: true });
|
|
201
213
|
const written = [];
|
|
202
214
|
for (const [name, files] of pack.skills) {
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// package.json 의 version 과 같아야 한다(tests/cli.test.mjs 가 확인).
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.2.0";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiakiv",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AiAkiv CLI: pin a folder to an AiAkiv project (connection, folder skills, settings) and
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "AiAkiv CLI: pin a folder to an AiAkiv project (connection, folder skills, settings), check it, and run console actions and a dashboard from the shell.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|