aiakiv 0.3.1 → 0.3.3
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 +59 -18
- package/dist/bind.js +21 -2
- package/dist/config/json.js +11 -3
- package/dist/doctor.js +130 -23
- package/dist/main.js +26 -9
- package/dist/mcpProbe.js +25 -2
- package/dist/setup.js +9 -8
- package/dist/skills.js +91 -131
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/skills/aiakiv-cards/SKILL.md +142 -0
- package/skills/aiakiv-dashboard/SKILL.md +172 -0
- package/skills/aiakiv-graph-query/SKILL.md +280 -0
- package/skills/aiakiv-links/SKILL.md +123 -0
- package/skills/aiakiv-onboarding/SKILL.md +162 -0
- package/skills/aiakiv-save-and-recall/SKILL.md +166 -0
- package/skills/bundle.json +37 -0
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
|
|
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
|
|
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 를 돌려주지 않으므로,
|
|
@@ -23,7 +23,7 @@ import { classifyEntry } from "./doctor.js";
|
|
|
23
23
|
import { readTextIfExists, writeFileAtomic } from "./fsutil.js";
|
|
24
24
|
import { exchangeCode, handoffUrl, HANDOFF_TIMEOUT_MS, newState, oldKeysKeptNotice, scopeList, startLoopback, supersededNotice, } from "./handoff.js";
|
|
25
25
|
import { probeSaveTarget } from "./mcpProbe.js";
|
|
26
|
-
import {
|
|
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);
|
|
@@ -246,15 +246,16 @@ function manualSkillsLine(t) {
|
|
|
246
246
|
? `직접 실행하세요: ${skillsGlobalManualCommand(t.skillsAgent)}`
|
|
247
247
|
: `https://github.com/${SKILLS_REPO} 의 aiakiv/skills/ 아래 스킬 폴더들을 ${t.dir} 에 폴더째 복사하세요.`;
|
|
248
248
|
}
|
|
249
|
-
/** 전역 스킬 단계. 실패하면 false 와 함께 사람이 할 일을
|
|
250
|
-
|
|
249
|
+
/** 전역 스킬 단계. 이 CLI 에 든 스킬을 쓴다(네트워크를 쓰지 않는다). 실패하면 false 와 함께 사람이 할 일을
|
|
250
|
+
* 보여 준다(예외를 내지 않는다). `root` 는 시험용이다. */
|
|
251
|
+
export async function installGlobalSkills(ctx, clients, root = BUNDLED_SKILLS_DIR) {
|
|
251
252
|
const targets = clients.flatMap((c) => globalSkillsTargets(ctx, c).map((t) => ({ client: c, ...t })));
|
|
252
253
|
let pack;
|
|
253
254
|
try {
|
|
254
|
-
pack =
|
|
255
|
+
pack = loadBundledSkills(root);
|
|
255
256
|
}
|
|
256
257
|
catch (e) {
|
|
257
|
-
ctx.err(`전역 스킬을
|
|
258
|
+
ctx.err(`전역 스킬을 쓰지 못했습니다(${e.message}). 연결 설정은 이미 썼습니다.`);
|
|
258
259
|
for (const t of targets)
|
|
259
260
|
ctx.err(` ${GLOBAL_CLIENT_LABEL[t.client]}(${t.dir}): ${manualSkillsLine(t)}`);
|
|
260
261
|
return false;
|
|
@@ -263,10 +264,10 @@ export async function installGlobalSkills(ctx, clients) {
|
|
|
263
264
|
for (const t of targets) {
|
|
264
265
|
try {
|
|
265
266
|
const names = writeSkillsTo(t.dir, pack);
|
|
266
|
-
ctx.out(`전역 스킬 ${names.length}개: ${t.dir} (${GLOBAL_CLIENT_LABEL[t.client]},
|
|
267
|
+
ctx.out(`전역 스킬 ${names.length}개: ${t.dir} (${GLOBAL_CLIENT_LABEL[t.client]}, 스킬 버전 ${pack.version ?? "모름"})`);
|
|
267
268
|
const missing = SKILL_NAMES.filter((n) => !names.includes(n));
|
|
268
269
|
if (missing.length)
|
|
269
|
-
ctx.err(` 참고:
|
|
270
|
+
ctx.err(` 참고: 이 CLI 에 든 스킬에 ${missing.join(", ")} 이 없습니다.`);
|
|
270
271
|
}
|
|
271
272
|
catch (e) {
|
|
272
273
|
ok = false;
|
package/dist/skills.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// 폴더 스킬. 웹 콘솔 "폴더 ZIP 받기" 와 같은 자리에 스킬
|
|
1
|
+
// 폴더 스킬. 웹 콘솔 "폴더 ZIP 받기" 와 같은 자리에 스킬 여섯 개를 넣는다
|
|
2
2
|
// (정본은 k2g-console-web `src/lib/folderBundle.ts` 의 SKILL_DIR 와 assertSkillPath).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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,25 +14,30 @@
|
|
|
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
|
-
//
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
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
|
-
|
|
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",
|
|
25
33
|
"aiakiv-graph-query",
|
|
26
34
|
"aiakiv-links",
|
|
27
35
|
"aiakiv-cards",
|
|
28
|
-
|
|
29
|
-
"aiakiv-contents",
|
|
30
|
-
// BP-171 P3 가 만들고 P5 가 함께 발행한다. 그 전의 설치본에는 없어 "받은 스킬에 … 이 없습니다" 가 나온다.
|
|
36
|
+
// BP-171 P3 가 만들고 P5 가 함께 발행한다.
|
|
31
37
|
"aiakiv-dashboard",
|
|
32
38
|
];
|
|
39
|
+
// 새 스킬을 더할 때는 여기에 이름을 넣고 scripts/sync-skills.mjs 를 다시 돌린다. 이 목록과 정본의 스킬 폴더가
|
|
40
|
+
// 다르면 그 스크립트가 멈춘다.
|
|
33
41
|
/** 클라이언트별 폴더 스킬 자리(폴더 기준 상대 경로). */
|
|
34
42
|
export const FOLDER_SKILLS_DIR = {
|
|
35
43
|
claude: ".claude/skills",
|
|
@@ -44,143 +52,94 @@ export const SKILLS_AGENT = {
|
|
|
44
52
|
gemini: "gemini-cli",
|
|
45
53
|
cursor: "cursor",
|
|
46
54
|
};
|
|
47
|
-
/**
|
|
55
|
+
/** 스킬을 쓰지 못했을 때 사람이 그 폴더에서 칠 명령(Skills CLI 가 GitHub 저장소에서 받는다, 사람이 고를 때만). `--all` 과 `-g` 는 쓰지 않는다
|
|
48
56
|
* (`--all` 은 모든 에이전트 폴더에 퍼지고, `-g` 는 폴더가 아니라 전역에 넣는다). */
|
|
49
57
|
export function skillsManualCommand(agent) {
|
|
50
58
|
return `npx -y skills add ${SKILLS_REPO} -a ${agent} -s '*' -y`;
|
|
51
59
|
}
|
|
52
|
-
/** setup(전역 설치)에서
|
|
60
|
+
/** setup(전역 설치)에서 스킬을 쓰지 못했을 때 칠 명령. 전역이라 `-g` 를 붙인다. `agent` 는 Skills CLI 의
|
|
53
61
|
* 에이전트 이름(Skills CLI 1.5.18 에서 gemini-cli 의 전역 자리는 `~/.gemini/skills`, antigravity-cli 는
|
|
54
62
|
* `~/.gemini/antigravity-cli/skills` 로 setup 이 쓰는 자리와 같다). */
|
|
55
63
|
export function skillsGlobalManualCommand(agent) {
|
|
56
64
|
return `npx -y skills add ${SKILLS_REPO} -a ${agent} -s '*' -g -y`;
|
|
57
65
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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) {
|
|
66
|
+
/** 스킬 파일 경로(`<스킬>/<파일>`)의 검사. 절대 경로, 드라이브 문자, 역슬래시, 제어 문자, 빈 칸이나 `.`, `..`
|
|
67
|
+
* 를 거부하고, Windows 에서 쓸 수 없는 이름도 거부한다(콘솔 folderBundle.ts 의 assertSkillPath 와 같은 수준).
|
|
68
|
+
* 이름을 고치지 않는 까닭: 고치면 스킬 안의 상대 링크가 깨진다. scripts/sync-skills.mjs 도 이 검사를 쓴다. */
|
|
69
|
+
export function isSafeSkillPath(path) {
|
|
79
70
|
if (!path || path.startsWith("/") || path.includes("\\"))
|
|
80
71
|
return false;
|
|
81
72
|
if (/[\u0000-\u001f\u007f]/.test(path))
|
|
82
73
|
return false;
|
|
83
74
|
if (/^[A-Za-z]:/.test(path))
|
|
84
75
|
return false;
|
|
85
|
-
return path
|
|
86
|
-
|
|
87
|
-
|
|
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}/`;
|
|
76
|
+
return path
|
|
77
|
+
.split("/")
|
|
78
|
+
.every((seg) => seg !== "" && seg !== "." && seg !== ".." && !/[<>:"|?*]/.test(seg) && !/[. ]$/.test(seg));
|
|
100
79
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
80
|
+
const bundleError = (msg) => new Error(`이 CLI 에 든 스킬 묶음이 맞지 않습니다: ${msg}`);
|
|
81
|
+
/** npm 묶음 안의 스킬을 읽는다. 네트워크를 쓰지 않는다.
|
|
82
|
+
*
|
|
83
|
+
* `bundle.json` 에 적힌 파일만 읽고, 하나라도 경로가 안전하지 않거나, 이름이 SKILL_NAMES 밖이거나, 보통 파일이
|
|
84
|
+
* 아니거나, 크기나 sha256 이 목록과 다르면 아무것도 돌려주지 않고 오류를 낸다. 이름을 SKILL_NAMES 로 좁히는 것은
|
|
85
|
+
* 사용자가 손으로 만든 같은 이름의 스킬 폴더를 지키려는 것이다(2026-11-10 보안 점검 낮음 6). */
|
|
86
|
+
export function loadBundledSkills(root = BUNDLED_SKILLS_DIR) {
|
|
87
|
+
const listFile = join(root, SKILLS_BUNDLE_FILE);
|
|
88
|
+
let manifest;
|
|
104
89
|
try {
|
|
105
|
-
|
|
90
|
+
manifest = JSON.parse(readFileSync(listFile, "utf8"));
|
|
106
91
|
}
|
|
107
92
|
catch (e) {
|
|
108
|
-
throw
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
if (!
|
|
120
|
-
|
|
121
|
-
|
|
93
|
+
throw bundleError(`${listFile} 을 읽지 못했습니다(${e.message})`);
|
|
94
|
+
}
|
|
95
|
+
if (!manifest || !Array.isArray(manifest.files) || !manifest.files.length) {
|
|
96
|
+
throw bundleError(`${listFile} 에 파일 목록이 없습니다`);
|
|
97
|
+
}
|
|
98
|
+
const skills = new Map();
|
|
99
|
+
for (const f of manifest.files) {
|
|
100
|
+
const path = typeof f?.path === "string" ? f.path : "";
|
|
101
|
+
if (!isSafeSkillPath(path))
|
|
102
|
+
throw bundleError(`안전하지 않은 스킬 경로를 거부했습니다: ${String(f?.path)}`);
|
|
103
|
+
const [name, ...rest] = path.split("/");
|
|
104
|
+
if (!rest.length)
|
|
105
|
+
throw bundleError(`스킬 폴더 밖의 파일입니다: ${path}`);
|
|
106
|
+
if (!SKILL_NAMES.includes(name))
|
|
107
|
+
throw bundleError(`목록 밖의 스킬 이름입니다: ${name}`);
|
|
108
|
+
const abs = join(root, ...path.split("/"));
|
|
109
|
+
let st;
|
|
122
110
|
try {
|
|
123
|
-
|
|
111
|
+
st = lstatSync(abs);
|
|
124
112
|
}
|
|
125
113
|
catch {
|
|
126
|
-
|
|
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);
|
|
114
|
+
throw bundleError(`${path} 이 없습니다`);
|
|
160
115
|
}
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
116
|
+
if (!st.isFile())
|
|
117
|
+
throw bundleError(`${path} 이 보통 파일이 아닙니다(링크나 폴더)`);
|
|
118
|
+
const data = readFileSync(abs);
|
|
119
|
+
const sha256 = createHash("sha256").update(data).digest("hex");
|
|
120
|
+
if (data.length !== f.bytes || sha256 !== f.sha256)
|
|
121
|
+
throw bundleError(`${path} 의 내용이 목록과 다릅니다`);
|
|
122
|
+
const files = skills.get(name) ?? [];
|
|
123
|
+
files.push({ path: rest.join("/"), data });
|
|
124
|
+
skills.set(name, files);
|
|
125
|
+
}
|
|
126
|
+
for (const [name, files] of skills) {
|
|
127
|
+
if (!files.some((f) => f.path === "SKILL.md"))
|
|
128
|
+
throw bundleError(`${name} 에 SKILL.md 가 없습니다`);
|
|
129
|
+
}
|
|
130
|
+
const sorted = new Map([...skills].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
131
|
+
const version = typeof manifest.plugin_version === "string" ? manifest.plugin_version : undefined;
|
|
132
|
+
return { version, skills: sorted };
|
|
167
133
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
let r;
|
|
134
|
+
/** doctor 가 적는 한 줄: 이 CLI 에 든 스킬의 버전과 개수. 읽지 못하면 그 까닭. */
|
|
135
|
+
export function bundledSkillsLine(root = BUNDLED_SKILLS_DIR) {
|
|
171
136
|
try {
|
|
172
|
-
|
|
137
|
+
const pack = loadBundledSkills(root);
|
|
138
|
+
return `이 CLI 에 든 스킬: ${pack.skills.size}개, 스킬 버전 ${pack.version ?? "모름"}`;
|
|
173
139
|
}
|
|
174
140
|
catch (e) {
|
|
175
|
-
|
|
176
|
-
throw new Error(`${url} 에 연결하지 못했습니다(${e.message}${cause ? `: ${cause}` : ""})`);
|
|
141
|
+
return `이 CLI 에 든 스킬: 읽지 못함(${e.message})`;
|
|
177
142
|
}
|
|
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
143
|
}
|
|
185
144
|
function isInside(root, p) {
|
|
186
145
|
const rel = relative(root, p);
|
|
@@ -215,13 +174,13 @@ export function writeSkillsTo(rootDir, pack) {
|
|
|
215
174
|
const final = resolve(root, name);
|
|
216
175
|
const tmp = resolve(root, `.${name}.aiakiv-tmp-${process.pid}`);
|
|
217
176
|
if (!isInside(root, final) || !isInside(root, tmp))
|
|
218
|
-
throw new
|
|
177
|
+
throw new Error(`안전하지 않은 스킬 이름입니다: ${name}`);
|
|
219
178
|
rmSync(tmp, { recursive: true, force: true });
|
|
220
179
|
try {
|
|
221
180
|
for (const f of files) {
|
|
222
181
|
const p = resolve(tmp, ...f.path.split("/"));
|
|
223
182
|
if (!isInside(tmp, p))
|
|
224
|
-
throw new
|
|
183
|
+
throw new Error(`안전하지 않은 스킬 경로입니다: ${name}/${f.path}`);
|
|
225
184
|
mkdirSync(dirname(p), { recursive: true });
|
|
226
185
|
writeFileSync(p, f.data);
|
|
227
186
|
}
|
|
@@ -236,20 +195,21 @@ export function writeSkillsTo(rootDir, pack) {
|
|
|
236
195
|
}
|
|
237
196
|
return written;
|
|
238
197
|
}
|
|
239
|
-
/** bind 의 폴더 스킬 단계.
|
|
240
|
-
|
|
198
|
+
/** bind 의 폴더 스킬 단계. 이 CLI 에 든 스킬을 쓴다(네트워크를 쓰지 않는다). 실패하면 false 와 함께 사람이 칠
|
|
199
|
+
* 명령을 보여 준다(예외를 내지 않는다). `root` 는 시험용이다. */
|
|
200
|
+
export async function installFolderSkills(ctx, client, dir, root = BUNDLED_SKILLS_DIR) {
|
|
241
201
|
const rel = FOLDER_SKILLS_DIR[client];
|
|
242
202
|
try {
|
|
243
|
-
const pack =
|
|
203
|
+
const pack = loadBundledSkills(root);
|
|
244
204
|
const names = writeFolderSkills(dir, rel, pack);
|
|
245
|
-
ctx.out(`폴더 스킬 ${names.length}개: ${rel} (
|
|
205
|
+
ctx.out(`폴더 스킬 ${names.length}개: ${rel} (이 CLI 에 든 스킬, 스킬 버전 ${pack.version ?? "모름"})`);
|
|
246
206
|
const missing = SKILL_NAMES.filter((n) => !names.includes(n));
|
|
247
207
|
if (missing.length)
|
|
248
|
-
ctx.err(` 참고:
|
|
208
|
+
ctx.err(` 참고: 이 CLI 에 든 스킬에 ${missing.join(", ")} 이 없습니다.`);
|
|
249
209
|
return true;
|
|
250
210
|
}
|
|
251
211
|
catch (e) {
|
|
252
|
-
ctx.err(`폴더 스킬을
|
|
212
|
+
ctx.err(`폴더 스킬을 쓰지 못했습니다(${e.message}). 연결 설정은 이미 썼습니다.`);
|
|
253
213
|
ctx.err(` 그 폴더(${dir})에서 직접 실행하세요: ${skillsManualCommand(SKILLS_AGENT[client])}`);
|
|
254
214
|
return false;
|
|
255
215
|
}
|
package/dist/version.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// package.json 의 version 과 같아야 한다(tests/cli.test.mjs 가 확인).
|
|
2
|
-
export const VERSION = "0.3.
|
|
2
|
+
export const VERSION = "0.3.3";
|
|
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.
|
|
3
|
+
"version": "0.3.3",
|
|
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,142 @@
|
|
|
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
|
+
Creating a card works on every AiAkiv connection. No tool in the conversation
|
|
120
|
+
manages cards the user already made. Listing, deleting, search visibility and
|
|
121
|
+
filing tags are done by the user by hand in the AiAkiv web console
|
|
122
|
+
(app.aiakiv.com, Data, Cards). In a client with a shell, `npx aiakiv console`
|
|
123
|
+
does the same with the actions `list_cards`, `set_card_searchable`,
|
|
124
|
+
`set_card_tags` and `delete_card` (the `aiakiv-dashboard` skill covers the
|
|
125
|
+
command). Each of those needs the matching console AI permission the user
|
|
126
|
+
turned on. In a client without a shell, tell the user where in the web console
|
|
127
|
+
to do it.
|
|
128
|
+
|
|
129
|
+
- Claim you deleted a card or changed its search visibility only when the
|
|
130
|
+
console command actually returned `ok: true`. A `delete_card` error means the
|
|
131
|
+
card is still public.
|
|
132
|
+
- Search visibility is OFF by default. Turning it on lists the card in
|
|
133
|
+
card.aiakiv.com search by title and tags; turning it off later un-lists it,
|
|
134
|
+
but the card stays public to anyone with the address.
|
|
135
|
+
- Filing tags (`set_card_tags`) are not the `tags` field. Filing tags show on
|
|
136
|
+
the card page and group cards in search; they never change the image.
|
|
137
|
+
- The public nickname and the tag palette are set by the user in the web
|
|
138
|
+
console, not by you. A nickname is optional; without one nothing
|
|
139
|
+
identifying the user is published. Their email is never published.
|
|
140
|
+
- card.aiakiv.com search narrows three ways (text, author nickname, filing
|
|
141
|
+
tag), and the address bar tracks whatever is narrowed, so any view can be
|
|
142
|
+
shared as a link.
|