aiakiv 0.3.0 → 0.3.2

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/mcpProbe.js CHANGED
@@ -56,6 +56,29 @@ export function saveTargetFromResult(result) {
56
56
  return null;
57
57
  }
58
58
  const str = (v) => (typeof v === "string" ? v : v == null ? undefined : String(v));
59
+ const REDIRECTS = [301, 302, 303, 307, 308];
60
+ const MAX_REDIRECTS = 5;
61
+ /** 넘겨주기(redirect)는 같은 출처(방식, 호스트, 포트) 안의 307, 308 만 따라간다(서버의 `/` → `/mcp` 308 같은 것).
62
+ * 다른 출처로 넘기면 키를 싣지 않고 멈춘다. fetch 에 맡기면 Node 18 초기 판은 다른 호스트로 넘어갈 때도
63
+ * Authorization 을 그대로 실었다. 301, 302, 303 은 POST 를 GET 으로 바꾸는 응답이라 따라가지 않고 그대로 돌려준다. */
64
+ async function fetchSameOrigin(fetchImpl, url, init) {
65
+ let current = url;
66
+ for (let hop = 0;; hop += 1) {
67
+ const res = await fetchImpl(current, { ...init, redirect: "manual" });
68
+ if (!REDIRECTS.includes(res.status))
69
+ return res;
70
+ const location = res.headers.get("location");
71
+ if (!location || hop >= MAX_REDIRECTS || (res.status !== 307 && res.status !== 308))
72
+ return res;
73
+ const next = new URL(location, current);
74
+ if (next.origin !== new URL(current).origin) {
75
+ await res.text().catch(() => "");
76
+ throw new Error(`서버가 다른 주소(${next.origin})로 넘기려 해서 키를 보내지 않고 멈췄습니다`);
77
+ }
78
+ await res.text().catch(() => "");
79
+ current = next.href;
80
+ }
81
+ }
59
82
  export async function probeSaveTarget(fetchImpl, url, key, opts = {}) {
60
83
  const timeoutMs = opts.timeoutMs ?? 20_000;
61
84
  let session = null;
@@ -67,7 +90,7 @@ export async function probeSaveTarget(fetchImpl, url, key, opts = {}) {
67
90
  "MCP-Protocol-Version": protocol,
68
91
  ...(session ? { "Mcp-Session-Id": session } : {}),
69
92
  });
70
- const post = async (msg) => fetchImpl(url, {
93
+ const post = async (msg) => fetchSameOrigin(fetchImpl, url, {
71
94
  method: "POST",
72
95
  headers: headers(),
73
96
  body: JSON.stringify(msg),
@@ -127,7 +150,7 @@ export async function probeSaveTarget(fetchImpl, url, key, opts = {}) {
127
150
  }
128
151
  finally {
129
152
  if (session) {
130
- await fetchImpl(url, { method: "DELETE", headers: headers(), signal: AbortSignal.timeout(5_000) }).catch(() => undefined);
153
+ await fetchSameOrigin(fetchImpl, url, { method: "DELETE", headers: headers(), signal: AbortSignal.timeout(5_000) }).catch(() => undefined);
131
154
  }
132
155
  }
133
156
  }
package/dist/setup.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // 것 포함)가 폐기된다. 그래서 쓸 수 있는 키가 있으면 새로 받지 않는다.
9
9
  // 3. 아니면 브라우저 인계(kind=dynamic)로 키 하나를 받는다.
10
10
  // 4. 고른 클라이언트 모두의 전역 설정 파일에 같은 키를 쓴다(다른 항목은 보존, 원자적 쓰기).
11
- // 5. 전역 스킬(`--no-skills` 면 건너뜀). 실패해도 설정 쓰기는 성공으로 두고 칠 명령을 보여 준다.
11
+ // 5. 전역 스킬(이 CLI 의 npm 묶음에 든 것, `--no-skills` 면 건너뜀). 실패해도 설정 쓰기는 성공으로 두고 칠 명령을 보여 준다.
12
12
  // 6. 사람이 할 일을 안내한다.
13
13
  //
14
14
  // 키 평문은 화면, 오류 문구 어디에도 내지 않는다(키 id 만). get_save_target 은 키 id 를 돌려주지 않으므로,
@@ -21,9 +21,9 @@ import { ConfigError, jsonServers, readJsonObject, upsertJsonServer } from "./co
21
21
  import { expiryText } from "./consoleKeyFile.js";
22
22
  import { classifyEntry } from "./doctor.js";
23
23
  import { readTextIfExists, writeFileAtomic } from "./fsutil.js";
24
- import { exchangeCode, handoffUrl, HANDOFF_TIMEOUT_MS, newState, scopeList, startLoopback, supersededNotice, } from "./handoff.js";
24
+ import { exchangeCode, handoffUrl, HANDOFF_TIMEOUT_MS, newState, oldKeysKeptNotice, scopeList, startLoopback, supersededNotice, } from "./handoff.js";
25
25
  import { probeSaveTarget } from "./mcpProbe.js";
26
- import { downloadSkillPack, SKILL_NAMES, SKILLS_REPO, skillsGlobalManualCommand, writeSkillsTo } from "./skills.js";
26
+ import { BUNDLED_SKILLS_DIR, loadBundledSkills, SKILL_NAMES, SKILLS_REPO, skillsGlobalManualCommand, writeSkillsTo, } from "./skills.js";
27
27
  import { canonicalMcpUrl, controlBase } from "./urls.js";
28
28
  import { VERSION } from "./version.js";
29
29
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -145,6 +145,9 @@ async function newKeyByHandoff(ctx, opts, url) {
145
145
  const sup = supersededNotice(res.superseded, "dynamic");
146
146
  if (sup)
147
147
  ctx.out(sup);
148
+ const kept = oldKeysKeptNotice(res.old_keys_kept);
149
+ if (kept)
150
+ ctx.out(kept);
148
151
  if (typeof res.mcp_url === "string" && res.mcp_url && res.mcp_url !== url) {
149
152
  ctx.err(`참고: 서버가 알려 준 MCP 주소(${res.mcp_url})와 설정에 쓰는 주소(${url})가 다릅니다. ` +
150
153
  "AIAKIV_MCP_URL 이 맞는지 확인하세요.");
@@ -243,15 +246,16 @@ function manualSkillsLine(t) {
243
246
  ? `직접 실행하세요: ${skillsGlobalManualCommand(t.skillsAgent)}`
244
247
  : `https://github.com/${SKILLS_REPO} 의 aiakiv/skills/ 아래 스킬 폴더들을 ${t.dir} 에 폴더째 복사하세요.`;
245
248
  }
246
- /** 전역 스킬 단계. 실패하면 false 와 함께 사람이 할 일을 보여 준다(예외를 내지 않는다). */
247
- export async function installGlobalSkills(ctx, clients) {
249
+ /** 전역 스킬 단계. 이 CLI 에 든 스킬을 쓴다(네트워크를 쓰지 않는다). 실패하면 false 와 함께 사람이 할 일을
250
+ * 보여 준다(예외를 내지 않는다). `root` 는 시험용이다. */
251
+ export async function installGlobalSkills(ctx, clients, root = BUNDLED_SKILLS_DIR) {
248
252
  const targets = clients.flatMap((c) => globalSkillsTargets(ctx, c).map((t) => ({ client: c, ...t })));
249
253
  let pack;
250
254
  try {
251
- pack = await downloadSkillPack(ctx);
255
+ pack = loadBundledSkills(root);
252
256
  }
253
257
  catch (e) {
254
- ctx.err(`전역 스킬을 받지 못했습니다(${e.message}). 연결 설정은 이미 썼습니다.`);
258
+ ctx.err(`전역 스킬을 쓰지 못했습니다(${e.message}). 연결 설정은 이미 썼습니다.`);
255
259
  for (const t of targets)
256
260
  ctx.err(` ${GLOBAL_CLIENT_LABEL[t.client]}(${t.dir}): ${manualSkillsLine(t)}`);
257
261
  return false;
@@ -260,10 +264,10 @@ export async function installGlobalSkills(ctx, clients) {
260
264
  for (const t of targets) {
261
265
  try {
262
266
  const names = writeSkillsTo(t.dir, pack);
263
- ctx.out(`전역 스킬 ${names.length}개: ${t.dir} (${GLOBAL_CLIENT_LABEL[t.client]}, 판 ${pack.version ?? "모름"})`);
267
+ ctx.out(`전역 스킬 ${names.length}개: ${t.dir} (${GLOBAL_CLIENT_LABEL[t.client]}, 스킬 버전 ${pack.version ?? "모름"})`);
264
268
  const missing = SKILL_NAMES.filter((n) => !names.includes(n));
265
269
  if (missing.length)
266
- ctx.err(` 참고: 받은 스킬에 ${missing.join(", ")} 이 없습니다.`);
270
+ ctx.err(` 참고: 이 CLI 에 든 스킬에 ${missing.join(", ")} 이 없습니다.`);
267
271
  }
268
272
  catch (e) {
269
273
  ok = false;
package/dist/skills.js CHANGED
@@ -1,9 +1,12 @@
1
1
  // 폴더 스킬. 웹 콘솔 "폴더 ZIP 받기" 와 같은 자리에 스킬 여덟 개를 넣는다
2
2
  // (정본은 k2g-console-web `src/lib/folderBundle.ts` 의 SKILL_DIR 와 assertSkillPath).
3
3
  //
4
- // 받는 곳은 공개 저장소 AiAkiv/aiakiv-plugins 의 tar.gz(GitHub codeload). 새 의존성 없이 Node 내장
5
- // zlib 로 풀고 tar.ts 로 읽어, 플러그인 매니페스트(`.claude-plugin/plugin.json`)의 `skills` 자리 아래만
6
- // 꺼낸다. 판 번호도 그 매니페스트에서 읽는다.
4
+ // 스킬은 이 CLI 의 npm 묶음 안 `skills/` 에 들어 있고, 네트워크에서 받지 않는다(2026-11-10 대표 결정 7).
5
+ // 예전에는 공개 저장소 AiAkiv/aiakiv-plugins 의 가지 끝(main)을 그때그때 받아, 그 저장소가 바뀌면 확인 없이
6
+ // 사용자 PC 에 새 내용이 깔렸다. 이제 받는 곳은 npm 하나이고, 묶음이 바뀌지 않았는지는 npm 이 확인한다.
7
+ // 정본은 K2G `packaging/plugins/aiakiv/skills/` 이고, `scripts/sync-skills.mjs` 가 그것을 `skills/` 로 옮겨
8
+ // 적으며 파일 목록과 sha256 을 `skills/bundle.json` 에 쓴다. 설치할 때는 그 목록에 적힌 파일만 읽고, 크기나
9
+ // sha256 이 다르면 하나도 쓰지 않는다. 스킬 버전은 그 목록의 plugin_version(플러그인 매니페스트의 version)이다.
7
10
  //
8
11
  // 자리(클라이언트별, 폴더 기준):
9
12
  // - Claude Code: `.claude/skills/<이름>/`
@@ -11,14 +14,19 @@
11
14
  // - Cursor: `.agents/skills/<이름>/`. Skills CLI 1.5.18 의 cursor 설정(skillsDir)과 Cursor 문서
12
15
  // (cursor.com/docs/context/skills 의 "Project-level") 둘 다 이 자리다(2026-10-14 확인).
13
16
  //
14
- // 안전: 압축 파일 안의 경로가 절대 경로이거나 `..` 를 품으면 통째로 거부한다. 스킬 자리 안의 심볼릭
15
- // 링크, 하드 링크, 특수 파일도 거부한다. 받기나 풀기에 실패해도 bind 는 멈추지 않는다(설정은 이미 썼다).
16
- import { existsSync, lstatSync, mkdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
17
- import { dirname, relative, resolve, sep } from "node:path";
18
- import { gunzipSync } from "node:zlib";
19
- import { readTar, TarError } from "./tar.js";
17
+ // 안전: 목록의 경로가 절대 경로이거나 `..` 를 품거나 Windows 에서 쓸 수 없는 이름이면 통째로 거부한다.
18
+ // 목록의 파일이 심볼릭 링크나 보통 파일이 아니어도 거부한다. 읽기나 쓰기에 실패해도 bind 는 멈추지 않는다
19
+ // (설정은 이미 썼다).
20
+ import { createHash } from "node:crypto";
21
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
22
+ import { dirname, join, relative, resolve, sep } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ /** 사람이 손으로 칠 Skills CLI 명령이 받는 공개 저장소. CLI 자신은 여기서 받지 않는다. */
20
25
  export const SKILLS_REPO = "AiAkiv/aiakiv-plugins";
21
- export const DEFAULT_SKILLS_TARBALL_URL = "https://codeload.github.com/AiAkiv/aiakiv-plugins/tar.gz/refs/heads/main";
26
+ /** npm 묶음 안의 스킬 폴더(`dist/skills.js` 기준 `../skills/`). */
27
+ export const BUNDLED_SKILLS_DIR = fileURLToPath(new URL("../skills/", import.meta.url));
28
+ /** 묶음 안 스킬의 파일 목록(경로, 크기, sha256)과 스킬 버전. */
29
+ export const SKILLS_BUNDLE_FILE = "bundle.json";
22
30
  export const SKILL_NAMES = [
23
31
  "aiakiv-onboarding",
24
32
  "aiakiv-save-and-recall",
@@ -27,9 +35,11 @@ export const SKILL_NAMES = [
27
35
  "aiakiv-cards",
28
36
  "aiakiv-console",
29
37
  "aiakiv-contents",
30
- // BP-171 P3 가 만들고 P5 가 함께 발행한다. 그 전의 설치본에는 없어 "받은 스킬에 … 이 없습니다" 가 나온다.
38
+ // BP-171 P3 가 만들고 P5 가 함께 발행한다.
31
39
  "aiakiv-dashboard",
32
40
  ];
41
+ // 새 스킬을 더할 때는 여기에 이름을 넣고 scripts/sync-skills.mjs 를 다시 돌린다. 이 목록과 정본의 스킬 폴더가
42
+ // 다르면 그 스크립트가 멈춘다.
33
43
  /** 클라이언트별 폴더 스킬 자리(폴더 기준 상대 경로). */
34
44
  export const FOLDER_SKILLS_DIR = {
35
45
  claude: ".claude/skills",
@@ -44,143 +54,94 @@ export const SKILLS_AGENT = {
44
54
  gemini: "gemini-cli",
45
55
  cursor: "cursor",
46
56
  };
47
- /** 받기에 실패했을 때 사람이 그 폴더에서 칠 명령. `--all` 과 `-g` 는 쓰지 않는다
57
+ /** 스킬을 쓰지 못했을 때 사람이 그 폴더에서 칠 명령(Skills CLI 가 GitHub 저장소에서 받는다, 사람이 고를 때만). `--all` 과 `-g` 는 쓰지 않는다
48
58
  * (`--all` 은 모든 에이전트 폴더에 퍼지고, `-g` 는 폴더가 아니라 전역에 넣는다). */
49
59
  export function skillsManualCommand(agent) {
50
60
  return `npx -y skills add ${SKILLS_REPO} -a ${agent} -s '*' -y`;
51
61
  }
52
- /** setup(전역 설치)에서 받기에 실패했을 때 칠 명령. 전역이라 `-g` 를 붙인다. `agent` 는 Skills CLI 의
62
+ /** setup(전역 설치)에서 스킬을 쓰지 못했을 때 칠 명령. 전역이라 `-g` 를 붙인다. `agent` 는 Skills CLI 의
53
63
  * 에이전트 이름(Skills CLI 1.5.18 에서 gemini-cli 의 전역 자리는 `~/.gemini/skills`, antigravity-cli 는
54
64
  * `~/.gemini/antigravity-cli/skills` 로 setup 이 쓰는 자리와 같다). */
55
65
  export function skillsGlobalManualCommand(agent) {
56
66
  return `npx -y skills add ${SKILLS_REPO} -a ${agent} -s '*' -g -y`;
57
67
  }
58
- // 내려받는 크기 한도(지금 저장소는 압축 50KB 안팎).
59
- const MAX_GZ_BYTES = 20 * 1024 * 1024;
60
- const MAX_TAR_BYTES = 64 * 1024 * 1024;
61
- const DOWNLOAD_TIMEOUT_MS = 60_000;
62
- /** 받는 주소. `AIAKIV_SKILLS_TARBALL_URL` 로 바꿀 수 있다(시험은 로컬 http 서버). */
63
- export function skillsTarballUrl(env) {
64
- const raw = (env.AIAKIV_SKILLS_TARBALL_URL ?? "").trim() || DEFAULT_SKILLS_TARBALL_URL;
65
- let u;
66
- try {
67
- u = new URL(raw);
68
- }
69
- catch {
70
- throw new Error(`AIAKIV_SKILLS_TARBALL_URL 이 주소가 아닙니다: ${raw}`);
71
- }
72
- if (u.protocol !== "https:" && u.protocol !== "http:") {
73
- throw new Error(`AIAKIV_SKILLS_TARBALL_URL 은 http 나 https 주소여야 합니다: ${raw}`);
74
- }
75
- return raw;
76
- }
77
- /** 압축 파일 안 경로의 기본 검사(콘솔 zip.ts 의 isSafeZipPath 와 같은 수준). 디렉터리의 끝 `/` 는 뗀 뒤 본다. */
78
- export function isSafeArchivePath(path) {
68
+ /** 스킬 파일 경로(`<스킬>/<파일>`)의 검사. 절대 경로, 드라이브 문자, 역슬래시, 제어 문자, 빈 칸이나 `.`, `..`
69
+ * 를 거부하고, Windows 에서 쓸 수 없는 이름도 거부한다(콘솔 folderBundle.ts 의 assertSkillPath 와 같은 수준).
70
+ * 이름을 고치지 않는 까닭: 고치면 스킬 안의 상대 링크가 깨진다. scripts/sync-skills.mjs 도 이 검사를 쓴다. */
71
+ export function isSafeSkillPath(path) {
79
72
  if (!path || path.startsWith("/") || path.includes("\\"))
80
73
  return false;
81
74
  if (/[\u0000-\u001f\u007f]/.test(path))
82
75
  return false;
83
76
  if (/^[A-Za-z]:/.test(path))
84
77
  return false;
85
- return path.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
86
- }
87
- // 스킬 파일 경로 — 위 검사에 더해 Windows 에서 풀리지 않는 이름도 거부한다(folderBundle.ts 의 assertSkillPath).
88
- // 이름을 고치지 않는 까닭: 고치면 스킬 안의 상대 링크가 깨진다.
89
- function assertSkillPath(path) {
90
- const ok = isSafeArchivePath(path) && path.split("/").every((seg) => !/[<>:"|?*]/.test(seg) && !/[. ]$/.test(seg));
91
- if (!ok)
92
- throw new TarError(`안전하지 않은 스킬 경로를 거부했습니다: ${path}`);
93
- }
94
- // 매니페스트의 skills 값(`./skills/`)을 압축 파일 안의 접두어로.
95
- function skillsPrefix(pluginRoot, field) {
96
- const rel = (typeof field === "string" ? field : "skills").replace(/^\.\//, "").replace(/\/+$/, "");
97
- if (!isSafeArchivePath(rel))
98
- throw new TarError(`플러그인 매니페스트의 skills 경로가 안전하지 않습니다: ${String(field)}`);
99
- return `${pluginRoot}${rel}/`;
78
+ return path
79
+ .split("/")
80
+ .every((seg) => seg !== "" && seg !== "." && seg !== ".." && !/[<>:"|?*]/.test(seg) && !/[. ]$/.test(seg));
100
81
  }
101
- /** tar.gz 바이트에서 스킬을 꺼낸다. 위험한 경로가 하나라도 있으면 TarError. */
102
- export function readSkillPack(gz) {
103
- let tar;
82
+ const bundleError = (msg) => new Error(`이 CLI 에 든 스킬 묶음이 맞지 않습니다: ${msg}`);
83
+ /** npm 묶음 안의 스킬을 읽는다. 네트워크를 쓰지 않는다.
84
+ *
85
+ * `bundle.json` 에 적힌 파일만 읽고, 하나라도 경로가 안전하지 않거나, 이름이 SKILL_NAMES 밖이거나, 보통 파일이
86
+ * 아니거나, 크기나 sha256 이 목록과 다르면 아무것도 돌려주지 않고 오류를 낸다. 이름을 SKILL_NAMES 로 좁히는 것은
87
+ * 사용자가 손으로 만든 같은 이름의 스킬 폴더를 지키려는 것이다(2026-11-10 보안 점검 낮음 6). */
88
+ export function loadBundledSkills(root = BUNDLED_SKILLS_DIR) {
89
+ const listFile = join(root, SKILLS_BUNDLE_FILE);
90
+ let manifest;
104
91
  try {
105
- tar = gunzipSync(gz, { maxOutputLength: MAX_TAR_BYTES });
92
+ manifest = JSON.parse(readFileSync(listFile, "utf8"));
106
93
  }
107
94
  catch (e) {
108
- throw new TarError(`압축을 풀 수 없습니다(${e.message}).`);
109
- }
110
- const entries = readTar(tar).map((e) => ({ ...e, name: e.type === "dir" ? e.name.replace(/\/+$/, "") : e.name }));
111
- for (const e of entries) {
112
- if (!isSafeArchivePath(e.name))
113
- throw new TarError(`압축 파일에 안전하지 않은 경로가 있어 거부했습니다: ${e.name}`);
114
- }
115
- // 플러그인 매니페스트 후보(저장소 맨 위의 marketplace.json 이 아니라 plugin.json).
116
- const candidates = [];
117
- for (const e of entries) {
118
- const m = /^(.*\/)?\.claude-plugin\/plugin\.json$/.exec(e.name);
119
- if (!m || e.type !== "file")
120
- continue;
121
- let manifest;
95
+ throw bundleError(`${listFile} 을 읽지 못했습니다(${e.message})`);
96
+ }
97
+ if (!manifest || !Array.isArray(manifest.files) || !manifest.files.length) {
98
+ throw bundleError(`${listFile} 에 파일 목록이 없습니다`);
99
+ }
100
+ const skills = new Map();
101
+ for (const f of manifest.files) {
102
+ const path = typeof f?.path === "string" ? f.path : "";
103
+ if (!isSafeSkillPath(path))
104
+ throw bundleError(`안전하지 않은 스킬 경로를 거부했습니다: ${String(f?.path)}`);
105
+ const [name, ...rest] = path.split("/");
106
+ if (!rest.length)
107
+ throw bundleError(`스킬 폴더 밖의 파일입니다: ${path}`);
108
+ if (!SKILL_NAMES.includes(name))
109
+ throw bundleError(`목록 밖의 스킬 이름입니다: ${name}`);
110
+ const abs = join(root, ...path.split("/"));
111
+ let st;
122
112
  try {
123
- manifest = JSON.parse(e.data.toString("utf8"));
113
+ st = lstatSync(abs);
124
114
  }
125
115
  catch {
126
- continue;
127
- }
128
- candidates.push({
129
- prefix: skillsPrefix(m[1] ?? "", manifest.skills),
130
- version: typeof manifest.version === "string" ? manifest.version : undefined,
131
- });
132
- }
133
- // 매니페스트가 없으면 저장소 맨 위의 skills/(GitHub tar 는 `<저장소>-<가지>/` 아래에 모두 둔다).
134
- if (!candidates.length) {
135
- const tops = new Set(entries.map((e) => (e.name.includes("/") ? e.name.split("/")[0] : "")));
136
- const top = tops.size === 1 && !tops.has("") ? `${[...tops][0]}/` : "";
137
- candidates.push({ prefix: `${top}skills/` });
138
- }
139
- for (const c of candidates) {
140
- const skills = new Map();
141
- for (const e of entries) {
142
- if (!e.name.startsWith(c.prefix))
143
- continue;
144
- const rel = e.name.slice(c.prefix.length);
145
- if (e.type === "dir")
146
- continue;
147
- if (e.type !== "file")
148
- throw new TarError(`스킬 자리에 링크나 특수 파일이 있어 거부했습니다: ${e.name}`);
149
- assertSkillPath(rel);
150
- const [name, ...rest] = rel.split("/");
151
- if (!rest.length)
152
- continue; // skills/ 바로 아래 파일(스킬이 아니다)
153
- const files = skills.get(name) ?? [];
154
- files.push({ path: rest.join("/"), data: e.data });
155
- skills.set(name, files);
156
- }
157
- for (const [name, files] of skills) {
158
- if (!files.some((f) => f.path === "SKILL.md"))
159
- skills.delete(name);
116
+ throw bundleError(`${path} 이 없습니다`);
160
117
  }
161
- if (skills.size) {
162
- const sorted = new Map([...skills].sort(([a], [b]) => a.localeCompare(b)));
163
- return { version: c.version, skills: sorted };
164
- }
165
- }
166
- throw new TarError("압축 파일에서 스킬(SKILL.md 가 든 폴더)을 찾지 못했습니다.");
118
+ if (!st.isFile())
119
+ throw bundleError(`${path} 이 보통 파일이 아닙니다(링크나 폴더)`);
120
+ const data = readFileSync(abs);
121
+ const sha256 = createHash("sha256").update(data).digest("hex");
122
+ if (data.length !== f.bytes || sha256 !== f.sha256)
123
+ throw bundleError(`${path} 의 내용이 목록과 다릅니다`);
124
+ const files = skills.get(name) ?? [];
125
+ files.push({ path: rest.join("/"), data });
126
+ skills.set(name, files);
127
+ }
128
+ for (const [name, files] of skills) {
129
+ if (!files.some((f) => f.path === "SKILL.md"))
130
+ throw bundleError(`${name} 에 SKILL.md 가 없습니다`);
131
+ }
132
+ const sorted = new Map([...skills].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
133
+ const version = typeof manifest.plugin_version === "string" ? manifest.plugin_version : undefined;
134
+ return { version, skills: sorted };
167
135
  }
168
- export async function downloadSkillPack(ctx) {
169
- const url = skillsTarballUrl(ctx.env);
170
- let r;
136
+ /** doctor 가 적는 한 줄: 이 CLI 에 든 스킬의 버전과 개수. 읽지 못하면 그 까닭. */
137
+ export function bundledSkillsLine(root = BUNDLED_SKILLS_DIR) {
171
138
  try {
172
- r = await ctx.fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
139
+ const pack = loadBundledSkills(root);
140
+ return `이 CLI 에 든 스킬: ${pack.skills.size}개, 스킬 버전 ${pack.version ?? "모름"}`;
173
141
  }
174
142
  catch (e) {
175
- const cause = e.cause?.message;
176
- throw new Error(`${url} 에 연결하지 못했습니다(${e.message}${cause ? `: ${cause}` : ""})`);
143
+ return `이 CLI 에 든 스킬: 읽지 못함(${e.message})`;
177
144
  }
178
- if (!r.ok)
179
- throw new Error(`${url} 이 ${r.status} 로 답했습니다`);
180
- const gz = Buffer.from(await r.arrayBuffer());
181
- if (gz.length > MAX_GZ_BYTES)
182
- throw new Error(`${url} 의 크기가 한도(${MAX_GZ_BYTES} 바이트)를 넘습니다`);
183
- return readSkillPack(gz);
184
145
  }
185
146
  function isInside(root, p) {
186
147
  const rel = relative(root, p);
@@ -215,13 +176,13 @@ export function writeSkillsTo(rootDir, pack) {
215
176
  const final = resolve(root, name);
216
177
  const tmp = resolve(root, `.${name}.aiakiv-tmp-${process.pid}`);
217
178
  if (!isInside(root, final) || !isInside(root, tmp))
218
- throw new TarError(`안전하지 않은 스킬 이름입니다: ${name}`);
179
+ throw new Error(`안전하지 않은 스킬 이름입니다: ${name}`);
219
180
  rmSync(tmp, { recursive: true, force: true });
220
181
  try {
221
182
  for (const f of files) {
222
183
  const p = resolve(tmp, ...f.path.split("/"));
223
184
  if (!isInside(tmp, p))
224
- throw new TarError(`안전하지 않은 스킬 경로입니다: ${name}/${f.path}`);
185
+ throw new Error(`안전하지 않은 스킬 경로입니다: ${name}/${f.path}`);
225
186
  mkdirSync(dirname(p), { recursive: true });
226
187
  writeFileSync(p, f.data);
227
188
  }
@@ -236,20 +197,21 @@ export function writeSkillsTo(rootDir, pack) {
236
197
  }
237
198
  return written;
238
199
  }
239
- /** bind 의 폴더 스킬 단계. 실패하면 false 와 함께 사람이 칠 명령을 보여 준다(예외를 내지 않는다). */
240
- export async function installFolderSkills(ctx, client, dir) {
200
+ /** bind 의 폴더 스킬 단계. 이 CLI 에 든 스킬을 쓴다(네트워크를 쓰지 않는다). 실패하면 false 와 함께 사람이 칠
201
+ * 명령을 보여 준다(예외를 내지 않는다). `root` 는 시험용이다. */
202
+ export async function installFolderSkills(ctx, client, dir, root = BUNDLED_SKILLS_DIR) {
241
203
  const rel = FOLDER_SKILLS_DIR[client];
242
204
  try {
243
- const pack = await downloadSkillPack(ctx);
205
+ const pack = loadBundledSkills(root);
244
206
  const names = writeFolderSkills(dir, rel, pack);
245
- ctx.out(`폴더 스킬 ${names.length}개: ${rel} (판 ${pack.version ?? "모름"})`);
207
+ ctx.out(`폴더 스킬 ${names.length}개: ${rel} (이 CLI 에 든 스킬, 스킬 버전 ${pack.version ?? "모름"})`);
246
208
  const missing = SKILL_NAMES.filter((n) => !names.includes(n));
247
209
  if (missing.length)
248
- ctx.err(` 참고: 받은 스킬에 ${missing.join(", ")} 이 없습니다.`);
210
+ ctx.err(` 참고: 이 CLI 에 든 스킬에 ${missing.join(", ")} 이 없습니다.`);
249
211
  return true;
250
212
  }
251
213
  catch (e) {
252
- ctx.err(`폴더 스킬을 받지 못했습니다(${e.message}). 연결 설정은 이미 썼습니다.`);
214
+ ctx.err(`폴더 스킬을 쓰지 못했습니다(${e.message}). 연결 설정은 이미 썼습니다.`);
253
215
  ctx.err(` 그 폴더(${dir})에서 직접 실행하세요: ${skillsManualCommand(SKILLS_AGENT[client])}`);
254
216
  return false;
255
217
  }
package/dist/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // package.json 의 version 과 같아야 한다(tests/cli.test.mjs 가 확인).
2
- export const VERSION = "0.3.0";
2
+ export const VERSION = "0.3.2";
3
3
  // 이 CLI 판이 `aiakiv enterprise precheck` 에서 쓰는 유료 기업판 엔진 이미지 판(aiakiv/engine-enterprise:<판>).
4
4
  // K2G packaging/selfhost/build.sh 가 `<이미지>:<판>` 으로 태그를 붙이고(앞에 v 없음), 엔진 판은 K2G
5
5
  // pyproject.toml 의 version(이미지의 AIAKIV_ENGINE_VERSION)이다. 엔진 판을 올리면 이 값도 같이 올린다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiakiv",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
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",
@@ -8,7 +8,8 @@
8
8
  "aiakiv": "dist/cli.js"
9
9
  },
10
10
  "files": [
11
- "dist"
11
+ "dist",
12
+ "skills"
12
13
  ],
13
14
  "engines": {
14
15
  "node": ">=18.3"
@@ -0,0 +1,152 @@
1
+ ---
2
+ name: aiakiv-cards
3
+ description: >-
4
+ Create a public AiAkiv card from the user's memory with create_aiakiv_card.
5
+ Use when the user asks to make a card, share a memory as a link or page,
6
+ publish a summary card, or asks what AiAkiv cards are or how to fix or delete
7
+ one. A card is a one-page public site (card.aiakiv.com) written by YOU from
8
+ memory search results and baked by the card service. It is live on the open
9
+ web the moment it is created.
10
+ ---
11
+
12
+ # AiAkiv cards
13
+
14
+ <!-- shared-create-section: begin. This block is identical in every edition of the aiakiv-cards skill. -->
15
+ A **card** is a one-page public summary of the user's AiAkiv memory: a title,
16
+ 3 to 5 bullets, a one-line conclusion and an optional longer body, plus two
17
+ share images (a square one and a wide 1200x630 one) with the short address
18
+ stamped in. Anyone with the address can view it, no login. You create it with
19
+ `create_aiakiv_card`. The server never calls a model: **you write the
20
+ content**.
21
+
22
+ ## The one fact that governs everything
23
+
24
+ **Creating IS publishing.** The card page and both images are live on the open
25
+ web the instant `create_aiakiv_card` returns. The card is unlisted (nobody
26
+ finds it without the address) but NOT private. There is no draft state, no
27
+ editing and no undo except deleting the card (see "Managing cards" below). So
28
+ filter before you create, and relay the response's `notice` to the user first,
29
+ verbatim.
30
+
31
+ ## Procedure
32
+
33
+ 1. **Search memory first** (`search_memory`, then `get_memory_content` when a
34
+ summary is not enough). The card summarizes what is actually stored; do not
35
+ invent content. Set `source_count` to the number of memories you actually
36
+ used.
37
+ 2. **Filter for sensitivity**: personal names, emails, internal addresses,
38
+ decisions not yet public.
39
+ 3. **Get consent to publish.** A request to make a card is consent to publish
40
+ that card. If the content touches anything sensitive, or you had to choose
41
+ what to leave out, show the user the draft and wait for an explicit yes
42
+ before creating.
43
+ 4. **Write to fit the images, not just the limits** (see "Fitting the images").
44
+ Aim to fit on the first try: creating again makes a NEW card at a new
45
+ address and leaves the first one published, and counted against the user's
46
+ card quota, until the user deletes it.
47
+ 5. **Create** with `create_aiakiv_card`, passing the fields below directly.
48
+ There is no format lookup step before it.
49
+
50
+ ## Fields and limits
51
+
52
+ The card service checks these after it trims whitespace and drops empty items.
53
+
54
+ - `title`: one line, up to 80 characters. Required.
55
+ - `bullets`: 3 to 5 key points, one line each, up to 120 characters each. Required.
56
+ - `conclusion`: one line, up to 200 characters. Required.
57
+ - `source_count`: how many memories the card was written from, an integer of 0 or more. Defaults to 0.
58
+ - `tags`: up to 3 search terms for card.aiakiv.com, up to 20 characters each. They are drawn into the images and **cannot be changed later**. Defaults to none.
59
+ - `body`: optional longer text shown on the page but not in the images, up to 30,000 characters of restricted markdown: paragraphs, `##` headings, `-` and `1.` lists, `>` quotes, inline and fenced code, bold, italic, http(s) links and `---`. Anything else shows as plain text.
60
+
61
+ Title, bullets, conclusion and tags are single lines: line breaks in them
62
+ collapse to spaces.
63
+
64
+ ## Fitting the images
65
+
66
+ Bullets share one fixed block of vertical space, so their count and length
67
+ trade against each other. A line holds about 31 Korean characters (Latin
68
+ letters and spaces fit more) and each bullet gets up to 3 lines, but when the
69
+ total does not fit, every bullet is cut to fewer lines and shortened with an
70
+ ellipsis. Staying under 120 characters is not enough.
71
+
72
+ What fits both images: a title of up to about 20 Korean characters, a
73
+ conclusion of up to about 30 Korean characters, and 3 or 4 bullets of up to
74
+ about 40 Korean characters each. With 5 bullets the last one is left out of
75
+ the wide image. The page always shows the full text; only the images crop.
76
+
77
+ ## Handling the response
78
+
79
+ - **`notice` comes first.** Show it to the user before the link and before any
80
+ summary, uncompressed. It states what just became public and how to take it
81
+ down.
82
+ - **`url`** is the card page: show it verbatim as a clickable link. `og_url`
83
+ (wide) and `square_url` (square) are the share images.
84
+ - **`fit`** is present only when an image cut something. Report it as is:
85
+ `bullets_truncated` and `bullets_dropped` are original bullet numbers,
86
+ `title_truncated` or `conclusion_truncated` may also appear, and `hint` says
87
+ how much to shorten. Let the user decide whether to shorten and create again,
88
+ and if they do, remind them the first card stays up until they delete it.
89
+ - **Errors** come as `{error, message or hint, field}`. On any error no card was
90
+ made; never say it was.
91
+ - `invalid_card`: fix the named `field` and try again.
92
+ - `payload_too_large` or `request_too_large`: shorten the body.
93
+ - `quota_exceeded`: the response carries `window`, `limit` and `used`. Tell
94
+ the user which limit was reached; do not retry.
95
+ - Anything else: relay it and stop.
96
+
97
+ ## What you must not do
98
+
99
+ - Create a card the user did not ask for ("summarize this" is not a card request).
100
+ - Fill a card with content that is not in memory.
101
+ - Quietly create again after a `fit` warning; that leaves two public cards.
102
+ - Say a card was made when the response was an error.
103
+ - Paste the card's address anywhere on the user's behalf; sharing is their call.
104
+
105
+ ## Sharing, for the user
106
+
107
+ - KakaoTalk, Slack, Discord, Notion and blogs unfurl the bare address into a
108
+ card. Instagram and some board sites do not: upload the square or wide image
109
+ instead; the address is stamped inside it.
110
+ - Deleting removes the page immediately, but other sites' preview caches can
111
+ linger. Deleting the account does NOT delete cards, so delete them first.
112
+ <!-- shared-create-section: end -->
113
+
114
+ ## Managing cards
115
+
116
+ `create_aiakiv_card` only creates. Editing a card's text is not possible
117
+ anywhere; create a new card instead (the old one stays up until deleted).
118
+
119
+ Listing, deleting, search visibility and filing tags for cards the user
120
+ already made go through `run_aiakiv_app_action(app="console")`: `list_cards`,
121
+ `set_card_searchable`, `set_card_tags` and `delete_card`. Each needs the
122
+ matching console AI permission the user turned on; see the aiakiv-console
123
+ skill. The user can do the same things by hand in the AiAkiv web console
124
+ (app.aiakiv.com, Data, Cards).
125
+
126
+ Creating a card works on every AiAkiv connection. Managing cards through the
127
+ AI is an app feature, and not every connection has app features. If
128
+ `run_aiakiv_app_action` is not in your tool list, or a call comes back with
129
+ "This tool is not available on this connection.", do not call it again. Tell
130
+ the user that this connection does not support app features, that all of
131
+ this can be done by hand in the web console (the dashboard) under Data,
132
+ Cards, and that having the AI do it takes a connection that has app features
133
+ (a folder connection or an API key connection). An account connection with the
134
+ plain address `https://mcp.aiakiv.com/mcp`, including the AiAkiv plugin's own
135
+ connection, can create cards but does not show `run_aiakiv_app_action`. In a
136
+ client with a shell, `npx aiakiv console` (the `aiakiv-dashboard` skill) can
137
+ manage cards too. The aiakiv-console skill has the full wording.
138
+
139
+ - Claim you deleted a card or changed its search visibility only when the
140
+ console call actually returned `ok: true`. A `delete_card` error means the
141
+ card is still public.
142
+ - Search visibility is OFF by default. Turning it on lists the card in
143
+ card.aiakiv.com search by title and tags; turning it off later un-lists it,
144
+ but the card stays public to anyone with the address.
145
+ - Filing tags (`set_card_tags`) are not the `tags` field. Filing tags show on
146
+ the card page and group cards in search; they never change the image.
147
+ - The public nickname and the tag palette are set by the user in the web
148
+ console, not by you. A nickname is optional; without one nothing
149
+ identifying the user is published. Their email is never published.
150
+ - card.aiakiv.com search narrows three ways (text, author nickname, filing
151
+ tag), and the address bar tracks whatever is narrowed, so any view can be
152
+ shared as a link.