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.
@@ -0,0 +1,215 @@
1
+ // CLI 콘솔 입구 `POST {control}/api/cli/console` 부르기(BP-171 §3), 키 찾기(§5.4), 종료 코드(§5.1).
2
+ //
3
+ // 서버 계약(K2G api.post_cli_console, P1):
4
+ // - 머리글 `Authorization: Bearer k2g_...`, 본문 `{"action", "data"}`. `caller` 는 보내지 않는다.
5
+ // - 404 = 서버에서 입구가 꺼져 있다(본문 없음). 401 = 키 없음, 틀림, 폐기, 만료. 403 = 키는 맞지만
6
+ // 이 입구에서 쓸 수 없다(static_key_required, scope_required, account_pending_deletion, forbidden).
7
+ // 429 = 속도 제한. 500 = control_error. 200 = 앱 계약(결과 객체 또는 `{"error": {...}}`).
8
+ // - verify 가 성공한 응답에는 `X-AiAkiv-Key-Id` 머리글이 있다. 어느 키를 썼는지는 이 값으로만 보인다.
9
+ //
10
+ // 키 평문은 stdout, stderr, 오류 문구 어디에도 내지 않는다.
11
+ import { relative } from "node:path";
12
+ import { CONSOLE_KEY_FILE, LEGACY_KEY_FILE_MESSAGE, readConsoleKeyFile } from "./consoleKeyFile.js";
13
+ import { maskSecret, scanConfigs } from "./doctor.js";
14
+ import { controlBase } from "./urls.js";
15
+ /** 종료 코드 표(§5.1 "P2 에서 정한다"). `--help` 에도 같은 표가 있다. */
16
+ export const EXIT = {
17
+ OK: 0,
18
+ /** 500, 연결 실패, 응답을 읽을 수 없음. */
19
+ SERVER: 1,
20
+ /** 인자, --yes 없음, 키 없음, 서버가 요청 모양을 거절(400, 413). */
21
+ USAGE: 2,
22
+ /** 401. */
23
+ AUTH: 3,
24
+ /** 403 전부. */
25
+ REFUSED: 4,
26
+ /** 429. */
27
+ RATE: 5,
28
+ /** 200 `console_permission_disabled`. */
29
+ PERMISSION_OFF: 6,
30
+ /** 404(서버에서 입구가 꺼져 있다). */
31
+ ENTRANCE_OFF: 7,
32
+ /** 그 밖의 200 오류 봉투(unknown_action, invalid_data, forbidden 등). */
33
+ APP_ERROR: 8,
34
+ };
35
+ export const TIMEOUT_MS = 20_000;
36
+ export const KEY_ID_HEADER = "x-aiakiv-key-id";
37
+ export const NO_KEY_MESSAGE = "AiAkiv 키를 찾지 못했습니다. 이 폴더에서 `npx aiakiv bind` 또는 `npx aiakiv console key` 를 먼저 실행하세요" +
38
+ "(또는 환경 변수 AIAKIV_API_KEY).";
39
+ /** ③ 폴더의 bind 키 설정 파일(codex, gemini)에서 프로젝트 키. 전역 파일은 보지 않는다. */
40
+ export function findFolderProjectKey(ctx, dir) {
41
+ const notes = [];
42
+ const scan = scanConfigs(ctx, dir, ["codex", "gemini"]);
43
+ const keyed = scan.entries.filter((e) => e.scope === "폴더" && e.key);
44
+ if (!keyed.length)
45
+ return { source: null, notes };
46
+ const first = keyed[0];
47
+ const where = `${relative(dir, first.file).replace(/\\/g, "/")} 의 ${first.name}`;
48
+ if (new Set(keyed.map((e) => e.key)).size > 1) {
49
+ notes.push(`참고: 이 폴더의 설정 파일에 키가 ${keyed.length}개 있어 첫 번째(${where})를 씁니다. ` +
50
+ "다른 키를 쓰려면 AIAKIV_API_KEY 를 주세요.");
51
+ }
52
+ return { source: { key: first.key, kind: "project", label: `프로젝트 키(폴더 설정), ${where}` }, notes };
53
+ }
54
+ /** 키 읽기 순서(§5.4): ① AIAKIV_API_KEY ② <dir>/.aiakiv/console.json(계정 키) ③ <dir> 의 bind 키 설정 파일.
55
+ * ② 가 옛 판(R0) 파일이면 쓰지 않고 안내를 남긴 뒤 ③ 으로 간다. */
56
+ export function findConsoleKey(ctx, dir) {
57
+ const notes = [];
58
+ const env = (ctx.env.AIAKIV_API_KEY ?? "").trim();
59
+ if (env)
60
+ return { source: { key: env, kind: "env", label: "환경 변수 AIAKIV_API_KEY" }, notes };
61
+ try {
62
+ const f = readConsoleKeyFile(dir);
63
+ if (f?.legacy) {
64
+ notes.push(`참고: ${CONSOLE_KEY_FILE} 은 ${LEGACY_KEY_FILE_MESSAGE} 이 파일은 쓰지 않고 다음 자리의 키를 찾습니다.`);
65
+ }
66
+ else if (f) {
67
+ const base = controlBase(ctx.env);
68
+ if (f.control_url && f.control_url.replace(/\/+$/, "") !== base) {
69
+ notes.push(`참고: 이 키는 ${f.control_url} 에서 받았습니다. 지금 요청 주소는 ${base} 입니다(AIAKIV_CONTROL_URL 로 바꿉니다).`);
70
+ }
71
+ return { source: { key: f.key, kind: "account", label: `계정 키, ${CONSOLE_KEY_FILE}`, keyId: f.key_id }, notes };
72
+ }
73
+ }
74
+ catch (e) {
75
+ notes.push(`참고: ${e.message} 다음 자리의 키를 찾습니다.`);
76
+ }
77
+ const folder = findFolderProjectKey(ctx, dir);
78
+ return { source: folder.source, notes: [...notes, ...folder.notes] };
79
+ }
80
+ /** 어느 키를 썼는지 평문 없이: 응답 머리글의 키 id, 없으면 파일의 key_id, 그것도 없으면 앞 8자. */
81
+ export function keyLabel(source, headerKeyId) {
82
+ const id = headerKeyId || source.keyId;
83
+ return `${id ? `키 id ${id}` : `키 ${maskSecret(source.key)}`} (${source.label})`;
84
+ }
85
+ export async function callConsole(ctx, key, action, data) {
86
+ const url = `${controlBase(ctx.env)}/api/cli/console`;
87
+ let res;
88
+ try {
89
+ res = await ctx.fetch(url, {
90
+ method: "POST",
91
+ headers: {
92
+ Authorization: `Bearer ${key}`,
93
+ "Content-Type": "application/json",
94
+ Accept: "application/json",
95
+ },
96
+ body: JSON.stringify({ action, data }),
97
+ signal: AbortSignal.timeout(TIMEOUT_MS),
98
+ });
99
+ }
100
+ catch (e) {
101
+ const err = e;
102
+ const why = err.name === "TimeoutError"
103
+ ? `${TIMEOUT_MS / 1000}초 안에 답이 없습니다`
104
+ : `${err.message}${err.cause?.message ? `: ${err.cause.message}` : ""}`;
105
+ return { status: 0, body: undefined, networkError: `웹 콘솔(${controlBase(ctx.env)})에 연결하지 못했습니다(${why}).` };
106
+ }
107
+ const keyId = res.headers.get(KEY_ID_HEADER) ?? undefined;
108
+ let text = "";
109
+ try {
110
+ text = await res.text();
111
+ }
112
+ catch {
113
+ /* 본문 없음으로 */
114
+ }
115
+ let body;
116
+ if (text.trim()) {
117
+ try {
118
+ body = JSON.parse(text);
119
+ }
120
+ catch {
121
+ body = undefined;
122
+ }
123
+ }
124
+ return { status: res.status, body, keyId };
125
+ }
126
+ export const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
127
+ /** 본문의 `{"error": {...}}` 또는 413 류의 `{"detail": {"error": code}}`. 없으면 null. */
128
+ export function errorOf(body) {
129
+ if (!isObj(body))
130
+ return null;
131
+ if (isObj(body.error))
132
+ return body.error;
133
+ if (typeof body.error === "string")
134
+ return { code: body.error };
135
+ if (isObj(body.detail) && typeof body.detail.error === "string") {
136
+ return { code: body.detail.error, message: body.detail.detail };
137
+ }
138
+ return null;
139
+ }
140
+ /** 응답 → 종료 코드와 사람용 한 줄. */
141
+ export function classify(r) {
142
+ const err = errorOf(r.body);
143
+ const code = typeof err?.code === "string" ? err.code : undefined;
144
+ const msg = typeof err?.message === "string" ? err.message : undefined;
145
+ const tail = `${code ? ` ${code}` : ""}${msg ? `: ${msg}` : ""}`;
146
+ if (r.status === 0)
147
+ return { exit: EXIT.SERVER, summary: r.networkError ?? "연결하지 못했습니다." };
148
+ if (r.status === 404) {
149
+ return { exit: EXIT.ENTRANCE_OFF, summary: "서버에서 CLI 콘솔 입구가 아직 열리지 않았습니다(404)." };
150
+ }
151
+ if (r.status === 401) {
152
+ return {
153
+ exit: EXIT.AUTH,
154
+ error: err ?? undefined,
155
+ summary: `인증에 실패했습니다(401${tail}). 키가 없거나 틀렸거나 폐기, 만료됐습니다. 프로젝트 키는 같은 프로젝트를 ` +
156
+ "다른 폴더에서 새로 받으면, 계정 키는 새로 받으면 앞선 키가 폐기됩니다. 프로젝트 키는 `npx aiakiv bind`, " +
157
+ "계정 키는 `npx aiakiv console key` 로 다시 받으세요.",
158
+ };
159
+ }
160
+ if (r.status === 403)
161
+ return { exit: EXIT.REFUSED, error: err ?? undefined, summary: `거절됐습니다(403${tail}).` };
162
+ if (r.status === 429) {
163
+ return { exit: EXIT.RATE, error: err ?? undefined, summary: `요청이 너무 많습니다(429${tail}). 잠시 뒤 다시 하세요.` };
164
+ }
165
+ if (r.status === 400 || r.status === 411 || r.status === 413) {
166
+ return { exit: EXIT.USAGE, error: err ?? undefined, summary: `서버가 요청을 받지 않았습니다(${r.status}${tail}).` };
167
+ }
168
+ if (r.status !== 200) {
169
+ return { exit: EXIT.SERVER, error: err ?? undefined, summary: `서버 오류입니다(${r.status}${tail}).` };
170
+ }
171
+ if (!isObj(r.body))
172
+ return { exit: EXIT.SERVER, summary: "서버 응답을 JSON 객체로 읽을 수 없습니다." };
173
+ if (err) {
174
+ if (code === "console_permission_disabled") {
175
+ const perm = typeof err.permission === "string" ? err.permission : "?";
176
+ return {
177
+ exit: EXIT.PERMISSION_OFF,
178
+ error: err,
179
+ summary: `권한 ${perm} 이 꺼져 있습니다. 콘솔의 "AI 권한" 에서 사용자가 켜야 합니다.`,
180
+ };
181
+ }
182
+ return { exit: EXIT.APP_ERROR, error: err, summary: `오류${code ? `(${code})` : ""}${msg ? `: ${msg}` : ""}` };
183
+ }
184
+ return { exit: EXIT.OK };
185
+ }
186
+ /** 403 scope_required(scope manage) 의 사람용 안내. 서버 message 는 요약 줄에, settings_url 은 마지막 줄에 따로 나간다. */
187
+ export const SCOPE_REQUIRED_HINT = "이 키는 프로젝트 키라 관리 작업을 못 합니다. `npx aiakiv console key` 로 계정 키를 받으세요(브라우저에서 확인).";
188
+ /** 사람용 오류 줄들(stderr): 한 줄 요약, settings_url, 그 밖의 도움 칸. */
189
+ export function failureLines(o) {
190
+ const lines = [];
191
+ if (o.summary)
192
+ lines.push(o.summary);
193
+ const e = o.error;
194
+ if (!e)
195
+ return lines;
196
+ // 다른 경우의 서버 문구는 요약에 이미 들어 있다. 권한 꺼짐만 서버 문구를 따로 보인다.
197
+ if (o.exit === EXIT.PERMISSION_OFF && typeof e.message === "string")
198
+ lines.push(` ${e.message}`);
199
+ if (e.code === "scope_required" && e.scope === "manage") {
200
+ lines.push(` ${SCOPE_REQUIRED_HINT}`);
201
+ }
202
+ if (e.code === "static_key_required") {
203
+ lines.push(" Main 을 따라가는 키(동적 키)는 쓸 수 없습니다. 프로젝트 키는 `npx aiakiv bind` 로, " +
204
+ "관리 작업용 계정 키는 `npx aiakiv console key` 로 받으세요.");
205
+ }
206
+ if (typeof e.field === "string" && e.field)
207
+ lines.push(` 고칠 칸: ${e.field}`);
208
+ if (Array.isArray(e.available_actions)) {
209
+ lines.push(` 쓸 수 있는 액션: ${e.available_actions.join(", ")}`);
210
+ lines.push(" 인자는 `npx aiakiv console describe` 로 봅니다.");
211
+ }
212
+ if (typeof e.settings_url === "string" && e.settings_url)
213
+ lines.push(` 설정: ${e.settings_url}`);
214
+ return lines;
215
+ }
@@ -0,0 +1,64 @@
1
+ // CLI 전용 키 파일 `<폴더>/.aiakiv/console.json`(BP-171 §4.3, §5.4 의 2번).
2
+ //
3
+ // `npx aiakiv console key` 가 계정 키(관리 작업 전용, 기억은 못 읽는다)를 이 파일에 쓴다. 모양(R1):
4
+ // {"key": "k2g_...", "key_id": "...", "kind": "account", "scopes": "manage",
5
+ // "control_url": "...", "issued_at": "...", "expires_at": "..." | null}
6
+ // R0 판은 프로젝트 키를 담았다(`project`, `org_name` 칸, `kind` 없음). 그런 파일은 `legacy` 로
7
+ // 표시해 돌려주고, 부르는 쪽이 쓰지 않고 다시 받으라고 안내한다. kind 가 "account" 가 아니면 모두
8
+ // 옛 판으로 본다(`project` 칸이 있는 R0 파일이 여기에 든다).
9
+ // `control_url` 은 키를 받은 웹 콘솔 주소다(기록용). 요청은 언제나 AIAKIV_CONTROL_URL(없으면 기본
10
+ // 주소)로 보낸다 — 파일이 주소를 정하게 두면, 폴더에 심어 둔 파일이 다른 곳의 키를 엉뚱한 곳으로
11
+ // 보내게 만들 수 있다.
12
+ //
13
+ // 키 평문은 이 파일에만 있다. 화면, 오류 문구에 옮기지 않는다.
14
+ import { join } from "node:path";
15
+ import { readTextIfExists, writeFileAtomic } from "./fsutil.js";
16
+ /** 폴더 기준 상대 경로. */
17
+ export const CONSOLE_KEY_DIR = ".aiakiv";
18
+ export const CONSOLE_KEY_FILE = ".aiakiv/console.json";
19
+ export const LEGACY_KEY_FILE_MESSAGE = "옛 판 키 파일입니다. `npx aiakiv console key` 로 다시 받으세요.";
20
+ /** 만료 표시: ISO 면 날짜(YYYY-MM-DD)만, 없으면 "없음". */
21
+ export function expiryText(expiresAt) {
22
+ if (!expiresAt)
23
+ return "없음";
24
+ const m = /^\d{4}-\d\d-\d\d/.exec(expiresAt);
25
+ return m ? m[0] : expiresAt;
26
+ }
27
+ export function consoleKeyPath(dir) {
28
+ return join(dir, ...CONSOLE_KEY_FILE.split("/"));
29
+ }
30
+ const optStr = (v) => (typeof v === "string" && v ? v : undefined);
31
+ /** 파일이 없으면 null. 읽을 수 없거나 key 가 없으면 Error(문구에 파일 내용을 넣지 않는다). */
32
+ export function readConsoleKeyFile(dir) {
33
+ const file = consoleKeyPath(dir);
34
+ const text = readTextIfExists(file);
35
+ if (text === null)
36
+ return null;
37
+ let v;
38
+ try {
39
+ v = JSON.parse(text);
40
+ }
41
+ catch {
42
+ throw new Error(`${file} 을 JSON 으로 읽을 수 없습니다.`);
43
+ }
44
+ const o = v;
45
+ if (!o || typeof o !== "object" || Array.isArray(o) || typeof o.key !== "string" || !o.key.trim()) {
46
+ throw new Error(`${file} 에 key 가 없습니다.`);
47
+ }
48
+ return {
49
+ key: o.key.trim(),
50
+ key_id: optStr(o.key_id),
51
+ kind: optStr(o.kind),
52
+ scopes: optStr(o.scopes),
53
+ control_url: optStr(o.control_url),
54
+ issued_at: optStr(o.issued_at),
55
+ expires_at: optStr(o.expires_at) ?? null,
56
+ legacy: o.kind !== "account",
57
+ };
58
+ }
59
+ /** 쓰기. 키가 든 파일이라 POSIX 에서는 주인만 읽게(0600) 둔다. */
60
+ export function writeConsoleKeyFile(dir, data) {
61
+ const file = consoleKeyPath(dir);
62
+ writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`, { secret: true });
63
+ return file;
64
+ }
package/dist/ctx.js CHANGED
@@ -3,10 +3,23 @@
3
3
  import { homedir } from "node:os";
4
4
  import { openUrl } from "./browser.js";
5
5
  import { run, which } from "./proc.js";
6
+ async function askLine(question) {
7
+ const { createInterface } = await import("node:readline/promises");
8
+ // 질문은 stderr 로 — `--json` 의 stdout 을 더럽히지 않는다.
9
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
10
+ try {
11
+ return await rl.question(question);
12
+ }
13
+ finally {
14
+ rl.close();
15
+ }
16
+ }
6
17
  export function realCtx() {
7
18
  const env = process.env;
8
19
  const platform = process.platform;
9
20
  return {
21
+ interactive: !!process.stdin.isTTY,
22
+ ask: askLine,
10
23
  env,
11
24
  cwd: process.cwd(),
12
25
  home: homedir(),
@@ -0,0 +1,197 @@
1
+ // `aiakiv dashboard [--json] [--dir <폴더>]`(BP-171 §5.2) — 한 화면 요약.
2
+ //
3
+ // 읽기 액션 여섯(list_teams, list_projects, list_permissions, list_links, list_cards,
4
+ // list_target_history limit 5)과 MCP get_save_target 한 번. 계정 키(.aiakiv/console.json)는 MCP 가
5
+ // 거절하므로 그 키로는 get_save_target 을 부르지 않는다(BP-171 §4.3). 그 폴더에 프로젝트 키(bind 가 쓴
6
+ // codex, gemini 설정)가 따로 있으면 그 키로 부르고, 없으면 저장 대상 칸을 건너뛴다. 환경 변수 키는
7
+ // 종류를 알 수 없어 그대로 부른다(계정 키면 그 칸에 MCP 의 거절 이유가 적힌다). 한 칸이 실패해도(권한 꺼짐, 카드 앱
8
+ // 미등록, 403 등) 그 칸에 이유를 적고 나머지는 그린다. 401(키 문제), 404(입구 꺼짐), 연결 실패는
9
+ // 다른 칸도 같은 결과이므로 거기서 멈춘다(여섯 번을 다 두드리지 않는다).
10
+ //
11
+ // 개수: 응답에 전체 수(total)가 있으면 그것을 쓴다. 팀, 프로젝트, 연결, 권한 목록은 서버가 페이지를
12
+ // 나누지 않고 전부 주므로(control list_orgs, list_projects, list_links, 권한 카탈로그) 받은 개수가
13
+ // 전체다. 그 밖에 전체 수가 없으면 "받은 N개(더 있을 수 있음)" 로 적는다.
14
+ import { callConsole, classify, EXIT, failureLines, findFolderProjectKey, isObj, keyLabel, } from "./consoleApi.js";
15
+ import { consoleDir, consoleKeyOrNull } from "./console.js";
16
+ import { probeSaveTarget } from "./mcpProbe.js";
17
+ import { displayWidth, padEnd, truncate } from "./render.js";
18
+ import { canonicalMcpUrl } from "./urls.js";
19
+ import { VERSION } from "./version.js";
20
+ const arr = (v) => (Array.isArray(v) ? v.filter(isObj) : []);
21
+ const s = (v) => (typeof v === "string" ? v : v == null ? "" : String(v));
22
+ const MAX_NAMES = 6;
23
+ function names(items) {
24
+ if (!items.length)
25
+ return "";
26
+ const shown = items.slice(0, MAX_NAMES).join(", ");
27
+ return items.length > MAX_NAMES ? `${shown} 외 ${items.length - MAX_NAMES}개` : shown;
28
+ }
29
+ function countText(body, list, complete) {
30
+ const n = arr(body[list]).length;
31
+ if (typeof body.total === "number")
32
+ return `전체 ${body.total}개`;
33
+ if (complete)
34
+ return `${n}개`;
35
+ return `받은 ${n}개(더 있을 수 있음)`;
36
+ }
37
+ const CELLS = [
38
+ {
39
+ field: "teams", title: "팀", action: "list_teams", data: {},
40
+ render: (b) => {
41
+ const items = arr(b.orgs).map((o) => `${s(o.name) || s(o.id)}${o.role ? `(${s(o.role)})` : ""}`);
42
+ return [`${countText(b, "orgs", true)}${items.length ? `: ${names(items)}` : ""}`];
43
+ },
44
+ },
45
+ {
46
+ field: "projects", title: "프로젝트", action: "list_projects", data: {},
47
+ render: (b) => {
48
+ const items = arr(b.projects).map((p) => {
49
+ const marks = [p.is_main ? "Main" : "", p.is_default ? "기본" : "", p.read_only ? "읽기 전용" : ""].filter(Boolean);
50
+ return `${s(p.name) || s(p.id)}${marks.length ? `(${marks.join(", ")})` : ""}`;
51
+ });
52
+ const head = `${countText(b, "projects", true)}${items.length ? `: ${names(items)}` : ""}`;
53
+ return b.main_paused === true ? [head, "Main 이 꺼져 있습니다(Main 을 따라가는 연결은 거절됩니다)."] : [head];
54
+ },
55
+ },
56
+ {
57
+ field: "permissions", title: "켜진 권한", action: "list_permissions", data: {},
58
+ render: (b) => {
59
+ if (b.unavailable === true)
60
+ return ["지금 권한 표를 읽을 수 없습니다(편집은 모두 거절됩니다)."];
61
+ const perms = arr(b.permissions);
62
+ const reads = perms.filter((p) => p.kind === "read");
63
+ const edits = perms.filter((p) => p.kind !== "read");
64
+ const on = (ps) => ps.filter((p) => p.enabled === true);
65
+ const editOn = on(edits).map((p) => s(p.name));
66
+ const lines = [
67
+ `읽기 ${on(reads).length}/${reads.length} 켜짐, 편집 ${editOn.length}/${edits.length} 켜짐` +
68
+ `${editOn.length ? `: ${names(editOn)}` : ""}`,
69
+ ];
70
+ if (typeof b.settings_url === "string")
71
+ lines.push(`바꾸는 곳: ${b.settings_url}`);
72
+ return lines;
73
+ },
74
+ },
75
+ {
76
+ field: "links", title: "연결", action: "list_links", data: {},
77
+ render: (b) => {
78
+ const by = new Map();
79
+ for (const l of arr(b.items))
80
+ by.set(s(l.status) || "?", (by.get(s(l.status) || "?") ?? 0) + 1);
81
+ const parts = [...by].map(([k, n]) => `${k} ${n}`);
82
+ return [`${countText(b, "items", true)}${parts.length ? `(${parts.join(", ")})` : ""}`];
83
+ },
84
+ },
85
+ {
86
+ field: "cards", title: "카드", action: "list_cards", data: {},
87
+ render: (b) => {
88
+ if (b.available === false)
89
+ return [`아직 쓸 수 없습니다(${s(b.unavailable_reason) || "준비 중"}).`];
90
+ return [countText(b, "items", false)];
91
+ },
92
+ },
93
+ {
94
+ field: "target_history", title: "최근 저장 대상 이력", action: "list_target_history", data: { limit: 5 },
95
+ render: (b) => {
96
+ const items = arr(b.items);
97
+ const lines = [`${countText(b, "items", false)}${items.length ? `, 최근 ${items.length}개:` : ""}`];
98
+ for (const it of items) {
99
+ lines.push(` ${[s(it.ts), s(it.action), truncate(s(it.reason), 60)].filter(Boolean).join(" ")}`);
100
+ }
101
+ return lines;
102
+ },
103
+ },
104
+ ];
105
+ /** 일찍 멈출 결과: 키 문제(401), 입구 꺼짐(404), 연결 실패. 다른 칸도 같다. */
106
+ function stopsAll(r, o) {
107
+ return o.exit === EXIT.AUTH || o.exit === EXIT.ENTRANCE_OFF || r.status === 0;
108
+ }
109
+ /** 실패한 칸의 `--json` 값: 본문이 JSON 객체면 그대로, 아니면 이유를 담은 오류 봉투. */
110
+ function failedCellJson(r, o) {
111
+ if (isObj(r.body))
112
+ return r.body;
113
+ return { error: { code: r.status ? `http_${r.status}` : "connection_failed", status: r.status || null, message: o.summary ?? "" } };
114
+ }
115
+ /** 실패한 칸의 사람용 줄: 이유 한 줄과, 있으면 설정 주소. */
116
+ function cellFailure(o) {
117
+ const lines = [o.summary ?? "실패했습니다."];
118
+ const url = o.error?.settings_url;
119
+ if (typeof url === "string" && url)
120
+ lines.push(`설정: ${url}`);
121
+ return lines;
122
+ }
123
+ function probeJson(p) {
124
+ if (!p)
125
+ return { skipped: "account_key" };
126
+ if (p.ok)
127
+ return p.raw ?? { team: p.team, project: p.project, binding: p.binding, account: p.account };
128
+ return { error: { code: "mcp_probe_failed", status: p.status ?? null, message: p.reason } };
129
+ }
130
+ export const ACCOUNT_KEY_NO_TARGET = "계정 키: 저장 대상 없음(프로젝트 키로 확인)";
131
+ /** 저장 대상 칸에 쓸 키. 계정 키면 그 폴더의 프로젝트 키(없으면 null = 건너뜀), 그 밖에는 같은 키. */
132
+ function saveTargetKey(ctx, dir, source) {
133
+ if (source.kind !== "account")
134
+ return source;
135
+ const folder = findFolderProjectKey(ctx, dir);
136
+ for (const n of folder.notes)
137
+ ctx.err(n);
138
+ return folder.source;
139
+ }
140
+ export async function runDashboard(ctx, opts) {
141
+ const dir = consoleDir(ctx, opts.dir);
142
+ const source = consoleKeyOrNull(ctx, dir);
143
+ if (!source)
144
+ return EXIT.USAGE;
145
+ const results = [];
146
+ let keyId;
147
+ for (const cell of CELLS) {
148
+ const r = await callConsole(ctx, source.key, cell.action, cell.data);
149
+ const o = classify(r);
150
+ keyId ??= r.keyId;
151
+ if (stopsAll(r, o)) {
152
+ if (opts.json && r.body !== undefined)
153
+ ctx.out(JSON.stringify(r.body, null, 2));
154
+ for (const l of failureLines(o))
155
+ ctx.err(l);
156
+ ctx.err(`사용한 ${keyLabel(source, keyId)}`);
157
+ return o.exit;
158
+ }
159
+ results.push({ cell, r, o });
160
+ }
161
+ const targetKey = saveTargetKey(ctx, dir, source);
162
+ const probe = targetKey
163
+ ? await probeSaveTarget(ctx.fetch, canonicalMcpUrl(ctx.env), targetKey.key, { version: VERSION })
164
+ : null;
165
+ const anyOk = results.some((x) => x.o.exit === EXIT.OK);
166
+ const exit = anyOk ? EXIT.OK : results[0].o.exit;
167
+ if (opts.json) {
168
+ const out = { save_target: probeJson(probe) };
169
+ for (const { cell, r, o } of results)
170
+ out[cell.field] = o.exit === EXIT.OK ? r.body : failedCellJson(r, o);
171
+ ctx.out(JSON.stringify(out, null, 2));
172
+ return exit;
173
+ }
174
+ const width = Math.max(...["저장 대상", ...CELLS.map((c) => c.title)].map(displayWidth)) + 2;
175
+ const row = (title, lines) => {
176
+ lines.forEach((l, i) => ctx.out(`${padEnd(i ? "" : title, width)}${l}`));
177
+ };
178
+ ctx.out(`AiAkiv 대시보드, 사용한 ${keyLabel(source, keyId)}`);
179
+ ctx.out();
180
+ // 계정 키 대신 폴더의 프로젝트 키로 확인했으면 어느 키인지 덧붙인다.
181
+ const via = targetKey && targetKey !== source ? [`(${targetKey.label} 로 확인)`] : [];
182
+ row("저장 대상", !probe
183
+ ? [ACCOUNT_KEY_NO_TARGET]
184
+ : probe.ok
185
+ ? [`팀 ${probe.team ?? "?"}, 프로젝트 ${probe.project ?? "?"}, binding ${probe.binding ?? "?"}`, ...via]
186
+ : [`읽지 못했습니다${probe.status ? `(${probe.status})` : ""}: ${probe.reason}`, ...via]);
187
+ for (const { cell, r, o } of results) {
188
+ if (o.exit === EXIT.OK && isObj(r.body))
189
+ row(cell.title, cell.render(r.body));
190
+ else
191
+ row(cell.title, cellFailure(o));
192
+ }
193
+ if (!anyOk) {
194
+ ctx.err("모든 칸이 실패했습니다.");
195
+ }
196
+ return exit;
197
+ }