reborn-openalt 1.0.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 ADDED
@@ -0,0 +1,38 @@
1
+ # reborn-openalt
2
+
3
+ 유료 툴 이름을 대면 **openalternative.co** 에서 오픈소스 대체재를 읽어 오고,
4
+ 그게 **이 컴퓨터에서 실제로 깔리는지** 네 가지를 재 줍니다.
5
+
6
+ ```
7
+ npx --yes reborn-openalt --find notion 그 툴의 오픈소스 대체재를 사이트에서 읽어 옵니다
8
+ npx --yes reborn-openalt --check 이 컴퓨터를 잽니다 — Node 18+ · Git · Python 3.10+ · Docker
9
+ npx --yes reborn-openalt 클로드 코드용 안내 스킬(openalt-guide)을 깝니다
10
+ ```
11
+
12
+ `--yes` 를 빼면 클로드 코드 안에서 설치 여부를 묻는 자리에서 조용히 멈춥니다.
13
+
14
+ ## 왜 재 주나
15
+
16
+ 사이트는 「거의 모든 유료 툴의 오픈소스 대체 버전」을 보여 줍니다. 그건 사실입니다 —
17
+ 사이트맵 기준 오픈소스 도구 788개 · 대체 대상 843개(2026-09-05).
18
+
19
+ 그런데 **찾는 것과 띄우는 것은 다릅니다.** 코딩 계열은 npm, Aider 계열은 pip,
20
+ 셀프호스트형(Penpot · AppFlowy 서버판)은 docker compose 로 뜹니다.
21
+ 통로가 없으면 「무료」가 「못 띄움」이 됩니다. 그래서 네 통로를 이 컴퓨터에서 직접 잽니다.
22
+ 못 재면 「못 쟀다」고 합니다 — 「있다」고 하지 않습니다.
23
+
24
+ ## 사이트가 말하지 않는 것
25
+
26
+ - **「오픈소스」와 「무료」는 다른 말입니다.** 카드마다 Free · Freemium · Open Core 가 따로 붙습니다.
27
+ - **깊이가 툴마다 열 배 갈립니다.** Notion 20개 · Cursor 10개 · Photoshop 3개 · Canva 2개.
28
+ - **한국어·윈도우 지원은 사이트가 안 재 줍니다.** 각 저장소 README 에서 봐야 합니다.
29
+ - **AGPL-3.0** 은 서비스로 띄우면 소스 공개 의무가 붙습니다.
30
+
31
+ ## 우리 것이 아닙니다
32
+
33
+ OpenAlternative 는 Piotr Kulpinski 가 운영하는 디렉터리이고 우리는 제휴가 없습니다.
34
+ 같은 목록의 저장소 `piotrkulpinski/open-source-alternatives` 는 CC0-1.0 입니다.
35
+ 우리는 사이트를 대신 긁어 재배포하지 않습니다 — `--find` 는 페이지 하나에서 **이름만** 읽어 링크를 줍니다.
36
+ Claude Code · Cursor · Photoshop · Canva · Notion 은 각 회사의 상표입니다.
37
+
38
+ 문의 — https://mobility.rebornlabs.kr/cs
package/cli.mjs ADDED
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OpenAlternative 안내 설치기 — **유료 툴 이름을 대면 대체재를 읽어 오고**, 이 컴퓨터가 되는지 재 주고, 안내 스킬을 깐다.
4
+ *
5
+ * npx --yes reborn-openalt 안내 스킬을 깐다 (이게 전부다)
6
+ * npx --yes reborn-openalt --find notion ★사이트에서 그 툴의 오픈소스 대체재를 읽어 온다 (네트워크)
7
+ * npx --yes reborn-openalt --check ★깔지 않고 **이 컴퓨터를 실제로 잰다** (Node · Git · Python · Docker)
8
+ * npx --yes reborn-openalt --dry-run 무엇을 할지 보여주기만 한다
9
+ * npx --yes reborn-openalt --local 홈이 아니라 현재 폴더에 깐다
10
+ * npx --yes reborn-openalt --self-test 자기시험 (네트워크 안 씀)
11
+ *
12
+ * ★★우리 사이트가 아니다.
13
+ * openalternative.co 는 Piotr Kulpinski 가 운영하는 디렉터리다. 우리는 제휴가 없고,
14
+ * 사이트를 대신 긁어 **재배포하지 않는다** — --find 는 그 페이지 하나를 읽어 이름만 보여 주고 링크를 준다.
15
+ *
16
+ * ★★--check 가 이 설치기의 존재 이유다
17
+ * 참고본은 「이 사이트에서 다 찾을 수 있다」에서 멈춘다. 찾는 건 사실이다. 그런데 찾은 것이
18
+ * **이 컴퓨터에서 깔리느냐**는 다른 질문이다 — 코딩 계열은 npm, Aider 계열은 pip, 셀프호스트형은 docker 다.
19
+ * 그래서 말로 「준비물이 있습니다」 하지 않고 **지금 이 컴퓨터에서 네 개를 직접 재 준다.**
20
+ * 못 재면 「못 쟀다」고 한다 — 「있다」고 하지 않는다.
21
+ */
22
+ import { existsSync, statSync, readdirSync, readFileSync, mkdirSync, cpSync } from "node:fs";
23
+ import { homedir, platform } from "node:os";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { execFileSync } from "node:child_process";
27
+ import { TAIL, 링크, 마지막 } from "./tail.mjs";
28
+
29
+ const HOME = homedir();
30
+ const 여기 = dirname(fileURLToPath(import.meta.url));
31
+ const 인자 = process.argv.slice(2);
32
+ const 확인만 = 인자.includes("--check");
33
+ const 미리보기 = 인자.includes("--dry-run");
34
+ const 여기에 = 인자.includes("--local");
35
+ const 자기시험모드 = 인자.includes("--self-test");
36
+ /* ★옵션 값을 이름으로 읽지 않는다 [[positional-arg-must-skip-option-values]] */
37
+ const 찾기i = 인자.indexOf("--find");
38
+ const 찾을툴 = 찾기i >= 0 ? (인자[찾기i + 1] && !인자[찾기i + 1].startsWith("--") ? 인자[찾기i + 1] : "") : null;
39
+
40
+ const c = { g: "\x1b[32m", r: "\x1b[31m", y: "\x1b[33m", d: "\x1b[90m", cy: "\x1b[36m", b: "\x1b[1m", 0: "\x1b[0m" };
41
+ const 굵게 = (t) => String(t).replace(/\*\*(.+?)\*\*/g, `${c[0]}${c.b}$1${c[0]}${c.d}`);
42
+ const 콤마 = (n) => Number(n).toLocaleString("ko-KR");
43
+
44
+ const 퍼널 = "openalt";
45
+ const 스킬이름 = "openalt-guide";
46
+ const 원본 = join(여기, "skill");
47
+ const 대상뿌리 = 여기에 ? join(process.cwd(), ".claude", "skills") : join(HOME, ".claude", "skills");
48
+ const 대상 = join(대상뿌리, 스킬이름);
49
+
50
+ /* ══ 원장 ═══════════════════════════════════════════════════════ */
51
+
52
+ function 원장읽기() {
53
+ const p = join(원본, "openalt.json");
54
+ if (!existsSync(p)) return { 됨: false, 이유: `원장이 없다 — ${p}` };
55
+ try { return { 됨: true, d: JSON.parse(readFileSync(p, "utf8")) }; }
56
+ catch (e) { return { 됨: false, 이유: `원장을 못 읽었다 — ${e.message}` }; }
57
+ }
58
+
59
+ /** ★조건 없이 단정하는 말 [[kmong-no-unbounded-guarantee]] */
60
+ const 금칙 = [/완전\s*무료/, /무제한/, /평생/, /100\s*%\s*보장/, /누구나\s*바로/, /무조건/, /전액\s*환불/, /다\s*끊는다/];
61
+ const 금칙면제 = /(하지\s*않는다|말하지\s*마라|쓰지\s*않는다|아니다|틀리다|금칙|안\s*된다|하지\s*마라|말하지\s*않는다|은\s*아니다|되지\s*않는다|마라|않는다|다른\s*말이다)/;
62
+ /** ★검사기가 자기 인용을 막지 않게 낫표 안은 벗기고 잰다 [[forbidden-word-checker-blocks-its-own-quotes]] */
63
+ const 인용벗기기 = (s) => String(s).replace(/「[^」]*」/g, "○");
64
+
65
+ function 금칙검사(텍스트, 어디) {
66
+ const 문제 = [];
67
+ for (const 줄 of String(텍스트).split("\n")) {
68
+ const 벗긴 = 인용벗기기(줄);
69
+ if (금칙면제.test(벗긴)) continue;
70
+ for (const re of 금칙) if (re.test(벗긴)) 문제.push(`${어디}: 조건 없이 단정하는 말 — ${줄.trim().slice(0, 60)}`);
71
+ }
72
+ return 문제;
73
+ }
74
+
75
+ function 원장검사(d) {
76
+ const 문제 = [];
77
+ if (!/^https:\/\/openalternative\.co/.test(String(d?.사이트?.주소 || ""))) 문제.push("원장에 사이트 주소가 없다");
78
+ if (!String(d?.사이트?.["★면책"] || "").includes("우리 사이트가 아니다"))
79
+ 문제.push("★면책이 사라졌다 — 남의 사이트를 크게 세우면 귀속을 반드시 박는다");
80
+ if (!/^https:\/\/github\.com\//.test(String(d?.저장소?.주소 || ""))) 문제.push("목록 저장소 주소가 GitHub 이 아니다");
81
+ if (d?.저장소?.라이선스 !== "CC0-1.0") 문제.push("목록 저장소 라이선스 칸이 CC0-1.0 이 아니다 — 확인 없이 안내하지 않는다");
82
+ if (!(Number(d?.실측?.도구수) > 0) || !(Number(d?.실측?.대체대상수) > 0)) 문제.push("사이트맵 실측(도구수·대체대상수)이 비었다");
83
+ const 제품 = Array.isArray(d?.실측?.화면제품) ? d.실측.화면제품 : [];
84
+ if (제품.length !== 5) 문제.push(`화면제품이 ${제품.length}개다 — 참고본 로고 시퀀스는 다섯이다`);
85
+ for (const g of 제품) {
86
+ if (!g.슬러그 || !g.best || !(Number(g.개수) > 0)) 문제.push(`화면제품 ${g.이름} 에 슬러그·best·개수 중 빈 칸이 있다`);
87
+ }
88
+ const 관문 = Array.isArray(d?.관문) ? d.관문 : [];
89
+ if (관문.length !== Number(d?.실측?.필요도구수))
90
+ 문제.push(`관문 ${관문.length}개인데 원장은 필요도구수 ${d?.실측?.필요도구수} 라고 한다 — 둘이 어긋난다`);
91
+ for (const g of 관문) {
92
+ if (!g.재는법) 문제.push(`관문 ${g.키} 에 재는법이 없다 — 못 재면 있다고 말하게 된다`);
93
+ if (!g.없으면) 문제.push(`관문 ${g.키} 에 「없으면」이 없다 — 막힌 사람을 그냥 두는 것이다`);
94
+ if (!g.출처) 문제.push(`관문 ${g.키} 에 출처가 없다 — 지어낸 준비물과 구분이 안 된다`);
95
+ }
96
+ if (!Array.isArray(d?.["★막히는자리"]) || d["★막히는자리"].length < 3)
97
+ 문제.push("★막히는자리가 셋보다 적다 — 이 킷이 파는 것이 그것이다");
98
+ for (const m of d?.["★막히는자리"] || []) if (!m.근거) 문제.push(`막히는자리 「${m.제목}」 에 근거가 없다`);
99
+ 문제.push(...금칙검사(JSON.stringify(d, null, 1), "원장"));
100
+ return 문제;
101
+ }
102
+
103
+ /* ══ 이 컴퓨터를 잰다 ═══════════════════════════════════════════ */
104
+
105
+ function 실행파일있나(cmd) {
106
+ const 찾기 = platform() === "win32" ? "where" : "which";
107
+ try { execFileSync(찾기, [cmd], { stdio: "pipe", timeout: 4000 }); return true; }
108
+ catch { return false; }
109
+ }
110
+
111
+ /** 판 문자열을 실제로 받아 온다. 못 받으면 null — 「있다」고 단정하지 않는다. */
112
+ function 판재기(cmd, 인자들) {
113
+ try {
114
+ const out = execFileSync(cmd, 인자들, { stdio: "pipe", timeout: 12000, encoding: "utf8" });
115
+ const 첫줄 = String(out).split("\n").find((l) => l.trim()) || "";
116
+ return 첫줄.trim().slice(0, 80);
117
+ } catch { return null; }
118
+ }
119
+
120
+ function 판수(s) {
121
+ const m = String(s || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
122
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3] || 0)] : null;
123
+ }
124
+
125
+ function 하한충족(판, 하한) {
126
+ const a = 판수(판); const b = 판수(하한);
127
+ if (!a || !b) return null;
128
+ if (a[0] !== b[0]) return a[0] > b[0];
129
+ if (a[1] !== b[1]) return a[1] > b[1];
130
+ return a[2] >= b[2];
131
+ }
132
+
133
+ /** 관문 넷을 **직접 재서** 잰다. 전부 실행파일이라 재는 법이 하나다 — 판까지 읽는다. */
134
+ function 관문재기(D) {
135
+ const 결과 = [];
136
+ for (const g of D.관문) {
137
+ const 행 = { 키: g.키, 이름: g.이름, 있나: false, 명령: null, 판: null, 없으면: g.없으면, 왜: g.왜 };
138
+ /* ★윈도우의 python 은 py 런처로만 있는 경우가 있다 — 둘 다 두드린다 */
139
+ const 후보 = g.키 === "python" ? (platform() === "win32" ? ["python", "py"] : ["python3", "python"]) : [g.키];
140
+ for (const cmd of 후보) {
141
+ if (!실행파일있나(cmd)) continue;
142
+ 행.있나 = true; 행.명령 = cmd;
143
+ 행.판 = 판재기(cmd, ["--version"]);
144
+ if (행.판) break;
145
+ }
146
+ if (행.있나 && g.하한) { 행.하한 = g.하한; 행.하한충족 = 하한충족(행.판, g.하한); }
147
+ 결과.push(행);
148
+ }
149
+ return 결과;
150
+ }
151
+
152
+ function 이미깔렸나() {
153
+ const 자리 = [];
154
+ for (const 뿌리 of [join(HOME, ".claude", "skills"), join(process.cwd(), ".claude", "skills")]) {
155
+ const p = join(뿌리, 스킬이름);
156
+ if (existsSync(p) && statSync(p).isDirectory()) 자리.push({ 어디: p, 개수: readdirSync(p).length });
157
+ }
158
+ return 자리;
159
+ }
160
+
161
+ /* ══ --find — 사이트에서 대체재를 읽어 온다 ═══════════════════════ */
162
+
163
+ const 슬러그화 = (s) => String(s).trim().toLowerCase()
164
+ .replace(/^adobe\s+/, "").replace(/[^a-z0-9가-힣]+/g, "-").replace(/^-|-$/g, "");
165
+ const 한글별칭 = { "노션": "notion", "포토샵": "photoshop", "캔바": "canva", "커서": "cursor",
166
+ "클로드코드": "claude-code", "클로드-코드": "claude-code", "피그마": "figma", "슬랙": "slack",
167
+ "지라": "jira", "트렐로": "trello", "줌": "zoom", "옵시디언": "obsidian", "스포티파이": "spotify" };
168
+
169
+ /** 페이지 HTML 에서 이름만 뽑는다. 본문은 담지 않는다. */
170
+ export function 대체재파싱(html, 슬러그) {
171
+ const 제목 = /<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(html)?.[1]?.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim() || "";
172
+ const 개수 = Number(/collection of the (\d+) best open source alternatives/i.exec(html)?.[1] || 0);
173
+ const best = /best open source alternative to [^.]*? is\s*(?:<[^>]+>)*([^<.]+)/i.exec(html)?.[1]?.trim() || "";
174
+ /* ★대체재 이름은 h2 에, 대상 툴·「Written by」·꼬리 목록은 h3 에 있다(2026-09-05 실측). 문서 순서대로 훑는다. */
175
+ const 이름들 = [];
176
+ const re = /<(h2|h3)[^>]*>([\s\S]*?)<\/\1>/gi;
177
+ let m;
178
+ while ((m = re.exec(html))) {
179
+ const t = m[2].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
180
+ if (!t) continue;
181
+ if (/^People are looking for/i.test(t)) break;
182
+ if (m[1].toLowerCase() === "h3") continue; // 대상 툴 이름 · Written by
183
+ if (/curated collection|best open source/i.test(t)) continue; // 머리 문장
184
+ if (!이름들.includes(t)) 이름들.push(t);
185
+ if (개수 && 이름들.length >= 개수) break;
186
+ }
187
+ return { 제목, 개수, best, 이름들 };
188
+ }
189
+
190
+ async function 찾기(D, 툴) {
191
+ if (!툴) { console.log(`${c.r}✗ --find 뒤에 툴 이름을 주세요 — 예: --find notion${c[0]}`); process.exit(2); }
192
+ const 슬러그 = 한글별칭[툴.replace(/\s+/g, "")] || 슬러그화(툴);
193
+ const url = `${D.사이트.주소}/alternatives/${슬러그}`;
194
+ console.log(`\n${c.d}읽는 중 — ${url}${c[0]}`);
195
+ let html;
196
+ try {
197
+ const r = await fetch(url, { headers: { "user-agent": "Mozilla/5.0 reborn-openalt" }, signal: AbortSignal.timeout(20000) });
198
+ if (r.status === 404) { console.log(`${c.y}이 이름의 페이지가 없습니다${c[0]} ${c.d}— 사이트에서 직접 검색: ${D.사이트.주소}/?q=${encodeURIComponent(툴)}${c[0]}\n`); return; }
199
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
200
+ html = await r.text();
201
+ } catch (e) {
202
+ console.log(`${c.r}✗ 못 읽었습니다 — ${e.message}${c[0]} ${c.d}(브라우저로: ${url})${c[0]}\n`);
203
+ process.exitCode = 1; return;
204
+ }
205
+ const r = 대체재파싱(html, 슬러그);
206
+ if (!r.이름들.length) { console.log(`${c.y}페이지는 있는데 목록을 못 읽었습니다${c[0]} ${c.d}— 브라우저로 보세요: ${url}${c[0]}\n`); return; }
207
+ console.log(`\n${c.b}${r.제목 || 슬러그}${c[0]} ${c.d}${r.개수 ? `${r.개수}개` : ""}${c[0]}`);
208
+ r.이름들.forEach((n, i) => {
209
+ const 표 = n === r.best ? `${c.g}best${c[0]}` : `${c.d}${String(i + 1).padStart(2)}${c[0]}`;
210
+ console.log(` ${표} ${n}`);
211
+ });
212
+ console.log(`\n ${c.cy}${url}${c[0]}`);
213
+ console.log(`${c.d} 이름만 읽어 왔습니다. 라이선스 · 별 · 한국어/윈도우 지원은 각 저장소 README 에서 보세요.`);
214
+ console.log(` 이 컴퓨터에서 깔리는지는 ${c[0]}${c.b}npx --yes reborn-openalt --check${c[0]}${c.d} 로 잽니다.${c[0]}\n`);
215
+ }
216
+
217
+ /* ══ 보고 ═══════════════════════════════════════════════════════ */
218
+
219
+ function 환경보고(D, 잰것) {
220
+ const 됨 = 잰것.filter((x) => x.있나 && x.하한충족 !== false).length;
221
+ console.log(`\n${c.b}이 컴퓨터를 지금 재 봤습니다${c[0]} ${c.d}(오픈소스 대체재가 실제로 깔리는 통로 네 가지)${c[0]}\n`);
222
+
223
+ for (const x of 잰것) {
224
+ let 표; let 꼬리말 = "";
225
+ if (!x.있나) { 표 = `${c.r}없음${c[0]}`; 꼬리말 = `${c.d} — ${x.없으면}${c[0]}`; }
226
+ else if (x.하한충족 === false) { 표 = `${c.y}낮음${c[0]}`; 꼬리말 = `${c.d} — ${x.판} (${x.하한} 이상이 필요합니다)${c[0]}`; }
227
+ else if (!x.판) { 표 = `${c.y}못 쟀음${c[0]}`; 꼬리말 = `${c.d} — 실행은 되는데 판을 못 읽었습니다${c[0]}`; }
228
+ else { 표 = `${c.g}있음${c[0]}`; 꼬리말 = `${c.d} — ${x.판}${c[0]}`; }
229
+ console.log(` ${String(x.이름).padEnd(16)} ${표}${꼬리말}`);
230
+ }
231
+
232
+ console.log(`\n ${c.b}${됨}/${잰것.length}${c[0]} ${c.d}준비돼 있습니다.${c[0]}`);
233
+ if (됨 < 잰것.length) {
234
+ console.log(` ${c.d}★없는 통로로 깔리는 대체재는 ${c[0]}${c.b}찾아도 못 띄웁니다${c[0]}${c.d} — 「무료」가 「못 띄움」이 되는 자리입니다.${c[0]}`);
235
+ }
236
+
237
+ console.log(`\n${c.b}사이트가 말하지 않는 것${c[0]}`);
238
+ for (const m of D["★막히는자리"]) {
239
+ console.log(` ${c.b}· ${m.제목}${c[0]}`);
240
+ console.log(` ${c.d}${굵게(m.무엇)}${c[0]}`);
241
+ console.log(` ${c.d}근거 — ${m.근거}${c[0]}`);
242
+ }
243
+
244
+ const W = D.사이트, S = D.실측;
245
+ console.log(`\n${c.b}사이트${c[0]} ${c.d}${W.이름} · 오픈소스 도구 ${콤마(S.도구수)} · 대체 대상 ${콤마(S.대체대상수)} · 분야 ${콤마(S.분야수)} (사이트맵 · ${W.확인일} 확인)${c[0]}`);
246
+ console.log(` ${c.cy}${W.주소}${c[0]}`);
247
+ console.log(` ${c.d}${W["★면책"]}${c[0]}\n`);
248
+ }
249
+
250
+ function 꼬리(깔았나) {
251
+ if (!깔았나) return;
252
+ const t = TAIL[퍼널];
253
+ console.log();
254
+ for (const 줄 of t.이음) console.log(`${c.d}${굵게(줄)}${c[0]}`);
255
+ console.log(`\n ${c.b}${t.제목}${c[0]} ${c.d}— ${t.한줄}${c[0]}`);
256
+ console.log(` ${c.cy}${링크(퍼널)}${c[0]}`);
257
+ console.log(`${c.d}${마지막}${c[0]}\n`);
258
+ }
259
+
260
+ /* ══ 심기 ═══════════════════════════════════════════════════════ */
261
+
262
+ function 심기() {
263
+ mkdirSync(대상뿌리, { recursive: true });
264
+ cpSync(원본, 대상, { recursive: true });
265
+ for (const f of ["SKILL.md", "openalt.json"]) {
266
+ const p = join(대상, f);
267
+ if (!existsSync(p)) throw new Error(`심었는데 ${f} 이 없다 — ${p}`);
268
+ if (readFileSync(p, "utf8").length < 200) throw new Error(`${f} 이 너무 짧다 — 복사가 끊긴 것 같다`);
269
+ }
270
+ const 원장 = JSON.parse(readFileSync(join(대상, "openalt.json"), "utf8"));
271
+ if (!원장?.사이트?.주소) throw new Error("심은 원장에 사이트 주소가 없다 — 복사가 깨졌다");
272
+ if (!Array.isArray(원장?.관문) || 원장.관문.length < 4) throw new Error("심은 원장에 관문이 모자란다 — 복사가 깨졌다");
273
+ return 대상;
274
+ }
275
+
276
+ /* ══ 자기시험 — ★양방향 ═══════════════════════════════════════ */
277
+
278
+ function 자기시험() {
279
+ const 나쁨 = [];
280
+ const r = 원장읽기();
281
+ if (!r.됨) { console.log(`${c.r}✗ ${r.이유}${c[0]}`); process.exit(1); }
282
+ const D = r.d;
283
+
284
+ const 문제 = 원장검사(D);
285
+ if (문제.length) 나쁨.push(...문제.map((m) => `오탐 또는 진짜 문제: ${m}`));
286
+
287
+ const 흉내 = () => JSON.parse(JSON.stringify(D));
288
+
289
+ { const x = 흉내(); x.사이트["★면책"] = "우리 사이트입니다";
290
+ if (!원장검사(x).length) 나쁨.push("미탐: ★면책을 지웠는데 안 잡힌다"); }
291
+ { const x = 흉내(); delete x.관문[0].재는법;
292
+ if (!원장검사(x).length) 나쁨.push("미탐: 관문의 재는법을 지웠는데 안 잡힌다"); }
293
+ { const x = 흉내(); x.관문.pop();
294
+ if (!원장검사(x).length) 나쁨.push("미탐: 관문 하나를 빼도 개수 어긋남이 안 잡힌다"); }
295
+ { const x = 흉내(); delete x["★막히는자리"][0].근거;
296
+ if (!원장검사(x).length) 나쁨.push("미탐: 막히는자리의 근거를 지웠는데 안 잡힌다"); }
297
+ { const x = 흉내(); x["★막히는자리"][0].무엇 = "이 사이트 하나면 구독을 다 끊는다";
298
+ if (!원장검사(x).length) 나쁨.push("미탐: 조건 없이 단정하는 문구가 안 잡힌다"); }
299
+ { const x = 흉내(); x.실측.화면제품.pop();
300
+ if (!원장검사(x).length) 나쁨.push("미탐: 화면제품이 넷인데 안 잡힌다"); }
301
+ { const x = 흉내(); x.실측.도구수 = 0;
302
+ if (!원장검사(x).length) 나쁨.push("미탐: 도구수가 0 인데 안 잡힌다"); }
303
+ /* 오탐 — 금칙어를 부정문으로 적은 것은 잡으면 안 된다 */
304
+ { const x = 흉내(); x.안하는말.push("「완전 무료」 같은 말은 하지 않는다");
305
+ if (원장검사(x).length > 문제.length) 나쁨.push("오탐: 금칙어를 부정문으로 적었는데 잡힌다"); }
306
+
307
+ /* 파서 양방향 — 표본 HTML 로 잰다(네트워크 안 씀) */
308
+ const 표본 = `<h1>Open Source <span>Claude Code</span> Alternatives</h1>
309
+ <p>A curated collection of the 3 best open source alternatives to Claude Code. The best open source alternative to Claude Code is <a>OpenCode</a>.</p>
310
+ <h3>Written by X</h3><h3>Claude Code</h3><h2>OpenCode</h2><h2>pi</h2><h2>Zed</h2><h3>People are looking for alternatives to...</h3><h3>Spotify</h3>`;
311
+ const p = 대체재파싱(표본, "claude-code");
312
+ if (p.개수 !== 3 || p.best !== "OpenCode" || p.이름들.join(",") !== "OpenCode,pi,Zed")
313
+ 나쁨.push(`파서가 표본을 잘못 읽는다 — ${JSON.stringify(p)}`);
314
+ const q = 대체재파싱("<h1>x</h1><p>no list here</p>", "x");
315
+ if (q.이름들.length) 나쁨.push("파서가 없는 목록을 있다고 한다");
316
+
317
+ const 잰것 = 관문재기(D);
318
+ if (잰것.length !== D.관문.length) 나쁨.push(`관문재기가 ${잰것.length}줄을 냈다 — ${D.관문.length}줄이어야 한다`);
319
+
320
+ for (const f of ["SKILL.md", "openalt.json"]) {
321
+ if (!existsSync(join(원본, f))) 나쁨.push(`심을 원본에 ${f} 이 없다`);
322
+ }
323
+
324
+ if (나쁨.length) {
325
+ console.log(`\n${c.r}✗ 자기시험 ${나쁨.length}건${c[0]}`);
326
+ for (const m of 나쁨) console.log(` ${c.r}· ${m}${c[0]}`);
327
+ process.exit(1);
328
+ }
329
+ console.log(`\n${c.g}✓ 자기시험 통과${c[0]} ${c.d}— 원장 검사 양방향 · 파서 양방향 · 관문 ${잰것.length}줄 실측 · 심을 원본 확인${c[0]}\n`);
330
+ }
331
+
332
+ /* ══ 본체 ═══════════════════════════════════════════════════════ */
333
+
334
+ async function 본체() {
335
+ if (자기시험모드) return 자기시험();
336
+
337
+ const r = 원장읽기();
338
+ if (!r.됨) { console.log(`${c.r}✗ ${r.이유}${c[0]}`); process.exit(1); }
339
+ const D = r.d;
340
+
341
+ const 문제 = 원장검사(D);
342
+ if (문제.length) {
343
+ console.log(`${c.r}✗ 원장이 규격을 벗어났다 — 안내를 내보내지 않는다${c[0]}`);
344
+ for (const m of 문제) console.log(` ${c.r}· ${m}${c[0]}`);
345
+ process.exit(1);
346
+ }
347
+
348
+ if (찾을툴 !== null) return 찾기(D, 찾을툴);
349
+
350
+ const 잰것 = 관문재기(D);
351
+ 환경보고(D, 잰것);
352
+
353
+ if (확인만) {
354
+ console.log(`${c.d}재기만 했습니다. 대체재를 읽어 오려면 ${c[0]}${c.b}npx --yes reborn-openalt --find notion${c[0]}${c.d}, 안내 스킬을 깔려면 ${c[0]}${c.b}npx --yes reborn-openalt${c[0]}${c.d} 를 그대로 실행하세요.${c[0]}\n`);
355
+ return;
356
+ }
357
+
358
+ const 이미 = 이미깔렸나();
359
+ if (이미.length) {
360
+ console.log(`${c.y}이미 깔려 있습니다${c[0]} ${c.d}— ${이미.map((x) => x.어디).join(" · ")}${c[0]}`);
361
+ console.log(`${c.d}덮어씁니다.${c[0]}`);
362
+ }
363
+
364
+ if (미리보기) {
365
+ console.log(`${c.d}--dry-run — 실제로는 안 깝니다.${c[0]}`);
366
+ console.log(` 깔 자리 ${대상}`);
367
+ console.log(` 깔 파일 SKILL.md · openalt.json`);
368
+ console.log(` ${c.d}사이트 목록은 담지 않습니다. 안내 스킬만 깝니다.${c[0]}\n`);
369
+ return;
370
+ }
371
+
372
+ let 깔았나 = false;
373
+ try {
374
+ const 자리 = 심기();
375
+ 깔았나 = true;
376
+ console.log(`${c.g}✓ 안내 스킬을 깔았습니다${c[0]} ${c.d}— ${자리}${c[0]}`);
377
+ console.log(`${c.d}클로드 코드를 다시 켜면 ${c[0]}${c.b}${스킬이름}${c[0]}${c.d} 로 잡힙니다.${c[0]}`);
378
+ console.log(`${c.d}「노션 대신 뭐 쓰지」 같은 말에도 붙습니다.${c[0]}`);
379
+ } catch (e) {
380
+ console.log(`${c.r}✗ 못 깔았습니다 — ${e.message}${c[0]}`);
381
+ process.exitCode = 1;
382
+ }
383
+
384
+ 꼬리(깔았나);
385
+ }
386
+
387
+ if (process.argv[1] && /cli\.mjs$/.test(process.argv[1].replace(/\\/g, "/"))) {
388
+ 본체().catch((e) => { console.log(`${c.r}✗ ${e?.message || e}${c[0]}`); process.exit(1); });
389
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "reborn-openalt",
3
+ "version": "1.0.0",
4
+ "description": "유료 툴 이름을 대면 openalternative.co 에서 오픈소스 대체재를 읽어 오고(--find notion), 그게 이 컴퓨터에서 실제로 깔리는지 네 가지(Node 18+ · Git · Python 3.10+ · Docker)를 재 줍니다(--check). 클로드 코드용 안내 스킬 openalt-guide 를 깝니다. 사이트 목록은 담지 않습니다 — 안내만 합니다.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "agent-skills",
9
+ "open-source",
10
+ "alternatives",
11
+ "openalternative",
12
+ "notion",
13
+ "photoshop",
14
+ "canva",
15
+ "cursor",
16
+ "korean"
17
+ ],
18
+ "homepage": "https://rebornlabs.kr/openalt?utm_source=installer&utm_medium=cli&utm_campaign=openalt",
19
+ "bugs": {
20
+ "url": "https://mobility.rebornlabs.kr/cs"
21
+ },
22
+ "license": "MIT",
23
+ "author": "REBORN LABS",
24
+ "type": "module",
25
+ "bin": {
26
+ "reborn-openalt": "cli.mjs"
27
+ },
28
+ "files": [
29
+ "cli.mjs",
30
+ "tail.mjs",
31
+ "skill/",
32
+ "README.md",
33
+ "안내스킬.md"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "scripts": {
39
+ "test": "node cli.mjs --self-test",
40
+ "check": "node cli.mjs --check"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: openalt-guide
3
+ description: 유료 툴 이름을 대면 openalternative.co 에서 오픈소스 대체재를 찾아 주고, 그게 이 컴퓨터에서 실제로 깔리는지(Node · Git · Python · Docker)를 재 준다. "노션 대신 뭐 쓰지", "포토샵 무료 대체", "커서 오픈소스 버전", "구독 끊고 싶다", "오픈소스 대체재 찾아줘", "openalternative", "오픈알트" 같은 말이 나올 때 쓴다. 사이트를 대신 긁어 재배포하지 않는다 — 어디를 보면 되는지와 무엇이 막는지를 말한다.
4
+ ---
5
+
6
+ # openalt-guide — 유료 툴 → 오픈소스 대체재
7
+
8
+ ## 이 스킬이 하는 일
9
+ 1. 사용자가 말한 유료 툴 이름을 **슬러그**로 바꾼다 (`Claude Code → claude-code`, `Photoshop → photoshop`).
10
+ 2. `https://openalternative.co/alternatives/<슬러그>` 를 읽는다(WebFetch). 첫 문단에
11
+ 「A curated collection of the N best open source alternatives to X」와
12
+ 「The best open source alternative to X is Y」가 있다. **그 둘을 그대로 옮긴다.** 지어내지 않는다.
13
+ 3. 대체재마다 **GitHub 저장소**를 열어 셋을 확인한다 — 라이선스 · 별 · 마지막 커밋일.
14
+ 사이트 카드에는 한국어·윈도우 칸이 없으므로 README 에서 **한국어 UI · 윈도우 빌드 · 오프라인** 여부를 따로 본다.
15
+ 4. 이 컴퓨터가 되는지 잰다 — `npx --yes reborn-openalt --check` (Node 18+ · Git · Python 3.10+ · Docker).
16
+ 못 재면 「못 쟀다」고 한다. 「있다」고 하지 않는다.
17
+ 5. 답은 이 순서로 — **best 하나** → 나머지 이름 → 각각의 라이선스·별 → 이 컴퓨터에서 막히는 것 → 설치 명령.
18
+
19
+ ## 반드시 말하는 것 (원장 `openalt.json` 의 ★막히는자리)
20
+ - **「오픈소스」와 「무료」는 다른 말이다.** 카드에 Free · Freemium · Open Core 가 따로 붙는다.
21
+ - **깊이가 툴마다 갈린다.** Notion 20개 · Canva 2개 · Photoshop 3개.
22
+ - **AGPL-3.0** 은 서비스로 띄우면 소스 공개 의무가 붙는다. 회사용이면 라이선스 칸을 먼저.
23
+ - 한국어·윈도우 지원은 사이트가 안 재 준다 — README 를 본다.
24
+
25
+ ## 하지 않는 것
26
+ - 사이트 목록을 통째로 긁어 담지 않는다. 링크와 요지만.
27
+ - 「이 사이트 하나면 구독 다 끊는다」 · 「완전 무료」 · 「제휴」 같은 말을 하지 않는다.
28
+ - Claude Code · Cursor · Photoshop · Canva · Notion 은 각 회사의 상표다. 우리 것처럼 말하지 않는다.
29
+
30
+ ## 명령
31
+ ```
32
+ npx --yes reborn-openalt --find notion 이 툴의 오픈소스 대체재를 사이트에서 읽어 온다
33
+ npx --yes reborn-openalt --check 이 컴퓨터가 되는지 네 가지를 잰다
34
+ npx --yes reborn-openalt 이 안내 스킬을 ~/.claude/skills/ 에 깐다
35
+ ```
36
+ 사이트: https://openalternative.co · 목록 저장소: https://github.com/piotrkulpinski/open-source-alternatives (CC0-1.0)
@@ -0,0 +1,106 @@
1
+ {
2
+ "_doc": "OpenAlternative 안내 스킬 원장. 화면·대본·설치기·착지·DM 에 나가는 수치는 전부 여기서 온다. 지어내지 않는다.",
3
+ "_출처": "https://openalternative.co (sitemap/tools.xml · sitemap/alternatives.xml · sitemap/categories.xml · /alternatives/<slug> 페이지) + https://api.github.com/repos/piotrkulpinski/open-source-alternatives — 2026-09-05 확인",
4
+ "사이트": {
5
+ "이름": "OpenAlternative",
6
+ "주소": "https://openalternative.co",
7
+ "운영": "Piotr Kulpinski",
8
+ "한줄": "Open Source Alternatives to Popular Software",
9
+ "확인일": "2026-09-05",
10
+ "★면책": "우리 사이트가 아니다. OpenAlternative 는 Piotr Kulpinski 가 운영하는 디렉터리이고, 우리는 그 사이트와 제휴가 없다. 우리는 그 사이트를 대신 긁어 재배포하지 않는다 — 어디를 보면 되는지 안내하고, 이 컴퓨터에서 대체재가 실제로 깔리는지 재 준다."
11
+ },
12
+ "저장소": {
13
+ "이름": "open-source-alternatives",
14
+ "주인": "piotrkulpinski",
15
+ "풀이름": "piotrkulpinski/open-source-alternatives",
16
+ "주소": "https://github.com/piotrkulpinski/open-source-alternatives",
17
+ "라이선스": "CC0-1.0",
18
+ "별": 6684,
19
+ "별표기": "6.7k",
20
+ "포크": 322,
21
+ "포크표기": "322",
22
+ "한줄": "Curated list of open source alternatives to proprietary software.",
23
+ "마지막갱신": "2026-09-04",
24
+ "확인일": "2026-09-05",
25
+ "★면책": "우리가 만든 것이 아니다. 사이트 운영자가 같은 목록을 CC0-1.0 으로 공개해 둔 저장소이고, 우리는 그것을 재배포하지 않는다 — 안내만 한다."
26
+ },
27
+ "실측": {
28
+ "_규칙": "사이트맵에서 센 값이다. 화면의 페이지네이션(19쪽·21쪽)으로 어림하지 않는다.",
29
+ "도구수": 788,
30
+ "대체대상수": 843,
31
+ "분야수": 306,
32
+ "화면제품": [
33
+ { "이름": "Claude Code", "로고": "claude", "슬러그": "claude-code", "개수": 14, "best": "OpenCode", "이어서": ["pi", "OpenHands", "Zed", "Cline", "Aider"] },
34
+ { "이름": "Cursor", "로고": "cursor", "슬러그": "cursor", "개수": 10, "best": "Zed", "이어서": ["Neovim", "Cline", "Aider", "Kilo", "Tabby"] },
35
+ { "이름": "Photoshop", "로고": "photoshop", "슬러그": "photoshop", "개수": 3, "best": "Graphite", "이어서": ["Krita", "GIMP"] },
36
+ { "이름": "Canva", "로고": "canva", "슬러그": "canva", "개수": 2, "best": "Penpot", "이어서": ["Graphite"] },
37
+ { "이름": "Notion", "로고": "notion", "슬러그": "notion", "개수": 20, "best": "Macro Docs", "이어서": ["AppFlowy", "Affine", "Memos", "SiYuan", "Logseq"] }
38
+ ],
39
+ "_화면제품왜": "참고본 로고 시퀀스의 그 다섯이다(11.5~15.5초). best 는 사이트가 각 페이지 첫 문단에 「The best open source alternative to X is Y」로 적은 것을 그대로 옮겼다.",
40
+ "클로드코드목록": ["OpenCode", "pi", "OpenHands", "Zed", "Cline", "Aider"],
41
+ "필요도구수": 4,
42
+ "찾기명령": "npx --yes reborn-openalt --find notion",
43
+ "확인일": "2026-09-05"
44
+ },
45
+ "관문": [
46
+ {
47
+ "키": "node", "이름": "Node.js 18+", "재기유형": "exe", "하한": "18.0.0",
48
+ "재는법": "node --version",
49
+ "없으면": "nodejs.org/ko 에서 LTS 를 받는다",
50
+ "왜": "코딩 계열 대체재(OpenCode · Cline · Kilo · pi)가 npm 으로 깔린다. 이 설치기도 여기서 돈다",
51
+ "출처": "각 저장소 README 설치 절 — opencode.ai/docs · github.com/cline/cline"
52
+ },
53
+ {
54
+ "키": "git", "이름": "Git", "재기유형": "exe",
55
+ "재는법": "git --version",
56
+ "없으면": "git-scm.com 에서 받는다",
57
+ "왜": "저장소를 받는 통로다. 없으면 clone 에서 통째로 멈춘다",
58
+ "출처": "GitHub 에서 받는 모든 오픈소스의 공통 전제"
59
+ },
60
+ {
61
+ "키": "python", "이름": "Python 3.10+", "재기유형": "exe", "하한": "3.10.0",
62
+ "재는법": "python --version",
63
+ "없으면": "python.org 에서 3.12 를 받는다 (설치 시 Add to PATH)",
64
+ "왜": "Aider · OpenHands 계열이 pip/uv 로 깔린다",
65
+ "출처": "aider.chat/docs/install · docs.all-hands.dev"
66
+ },
67
+ {
68
+ "키": "docker", "이름": "Docker", "재기유형": "exe",
69
+ "재는법": "docker --version",
70
+ "없으면": "docs.docker.com/get-docker 에서 Docker Desktop",
71
+ "왜": "셀프호스트형(Penpot · AppFlowy 서버판 · Tabby 서버)은 docker compose 로 뜬다. 없으면 「무료」가 「못 띄움」이 된다",
72
+ "출처": "penpot.app/self-host · tabby.tabbyml.com/docs/installation"
73
+ }
74
+ ],
75
+ "★막히는자리": [
76
+ {
77
+ "제목": "★「오픈소스」와 「무료」는 다른 말이다",
78
+ "무엇": "카드마다 **Free · Freemium · Open Core** 가격표가 따로 붙는다. 코드는 열려 있어도 호스팅판은 값이 붙는 경우가 있다 — 사이트 자체가 그 셋을 구분해 표기한다",
79
+ "근거": "openalternative.co 도구 카드의 pricing 표기(Free / Freemium / Open Core) · /licenses 페이지 — 2026-09-05 확인"
80
+ },
81
+ {
82
+ "제목": "★대체재 깊이가 툴마다 열 배 갈린다",
83
+ "무엇": "Notion 은 20개인데 Canva 는 **2개**뿐이다. 「거의 모든 유료툴」은 사실이지만, 고를 폭은 툴마다 다르다 — 이름만 대고 고르면 안 되는 이유다",
84
+ "근거": "/alternatives/notion 20 · /alternatives/cursor 10 · /alternatives/photoshop 3 · /alternatives/canva 2 (2026-09-05 실측)"
85
+ },
86
+ {
87
+ "제목": "★한국어·윈도우 지원은 사이트가 안 재 준다",
88
+ "무엇": "카드에는 별·라이선스·가격표만 있다. 한국어 UI · 윈도우 빌드 · 오프라인 여부는 **각 저장소 README** 에서 따로 봐야 한다",
89
+ "근거": "도구 카드 항목 구성(설명 · 별 · 라이선스 · pricing) 실측 — 언어·OS 칸이 없다"
90
+ },
91
+ {
92
+ "제목": "★라이선스가 갈린다 — AGPL 은 서비스로 띄울 때 조건이 붙는다",
93
+ "무엇": "MIT · Apache-2.0 은 가져다 써도 되지만 **AGPL-3.0** 은 서비스로 띄우면 고친 소스를 공개할 의무가 따른다. 회사에서 쓸 거면 라이선스 칸을 먼저 본다",
94
+ "근거": "openalternative.co/licenses 분류 · GNU AGPL-3.0 §13"
95
+ }
96
+ ],
97
+ "안하는말": [
98
+ "「사이트 하나면 구독을 다 끊는다」고 말하지 않는다 — 대체재 깊이가 툴마다 다르다",
99
+ "「오픈소스 = 완전 무료」라고 말하지 않는다 — Freemium · Open Core 가 섞여 있다",
100
+ "OpenAlternative 와 제휴했다고 말하지 않는다 — 안내만 한다",
101
+ "사이트를 대신 긁어 목록을 통째로 담지 않는다 — 어디를 보면 되는지와, 이 컴퓨터에서 깔리는지만 잰다"
102
+ ],
103
+ "★라이선스": {
104
+ "상표": "Claude Code · Cursor · Photoshop · Canva · Notion 은 각 회사의 상표이고, OpenAlternative 는 그 운영자의 이름이다. 우리는 어느 쪽과도 제휴가 없다. 목록 저장소의 CC0-1.0 은 목록에만 걸리고 각 도구의 라이선스는 도구마다 다르다."
105
+ }
106
+ }
package/tail.mjs ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 「끝난 뒤 한 칸」 문안 — 정본은 `skillpack/tail_copy.mjs` 다.
3
+ * 사본이 여기 있는 이유는 하나뿐이다: npm 패키지는 폴더 밖을 import 할 수 없다.
4
+ * 고칠 일이 생기면 정본을 먼저 고치고 여기로 옮긴다. 반대로 하지 마라.
5
+ *
6
+ * 규율: ① 끝난 뒤 딱 한 칸 · ② 못 깔았으면 안 띄운다 · ③ 파일을 안 남긴다
7
+ * ④ 고지 블록 금지 · ⑤ 첫 줄은 방금 한 일에서 이어져야 한다
8
+ * ★클로드킷·자비스 풀스택은 여기서 말하지 않는다 — 스킬팩 구매 이후다(2026-08-24 전략).
9
+ */
10
+ export const 랜딩 = "https://rebornlabs.kr/skillpack";
11
+ export const 링크 = (퍼널) => `${랜딩}?utm_source=installer&utm_medium=cli&utm_campaign=${퍼널}`;
12
+
13
+ export const TAIL = {
14
+ openalt: {
15
+ 이음: [
16
+ "방금 한 일은 **유료 툴의 오픈소스 대체재를 어디서 찾고, 이 컴퓨터에서 깔리는지 재 본 것**입니다.",
17
+ "사이트는 공개돼 있어 저희를 안 거치셔도 됩니다. 막히는 자리는 **찾는 것이 아니라 띄우는 것**입니다.",
18
+ ],
19
+ 제목: "리본랩스 스킬팩",
20
+ 한줄: "같은 방식으로 골라 담은 48종을 한 줄로. 8,900원",
21
+ },
22
+ };
23
+
24
+ export const 마지막 = "목록과 직접 까는 명령까지 그 페이지에 그대로 적어 두었습니다. 안 사셔도 됩니다.";
@@ -0,0 +1,23 @@
1
+ # 안내 스킬 — openalt-guide
2
+
3
+ `npx --yes reborn-openalt` 가 `~/.claude/skills/openalt-guide/` 에 두 파일을 깝니다.
4
+
5
+ | 파일 | 무엇 |
6
+ |---|---|
7
+ | `SKILL.md` | 클로드 코드가 「노션 대신 뭐 쓰지」 같은 말에 스스로 붙는 안내 |
8
+ | `openalt.json` | 원장 — 사이트맵 실측(788 · 843 · 306) · 화면 제품 다섯과 best · 관문 넷 · 막히는 자리 넷 |
9
+
10
+ 스킬이 하는 일은 넷입니다 — ① 툴 이름을 슬러그로 ② `openalternative.co/alternatives/<슬러그>` 를 읽어 best 와 개수를 그대로 옮김
11
+ ③ 각 저장소 README 에서 라이선스 · 한국어 · 윈도우 여부 확인 ④ 이 컴퓨터가 되는지 `--check`.
12
+
13
+ 사이트 목록을 통째로 담지 않습니다. 저장소 코드도 담지 않습니다. 안내만 합니다.
14
+
15
+ ## ★끝 한 칸 — 스킬팩
16
+
17
+ 안내 스킬을 실제로 깔았을 때만, 끝난 뒤 딱 한 칸이 뜹니다(파일을 남기지 않습니다 · 못 깔았으면 안 뜹니다).
18
+
19
+ 리본랩스 스킬팩 — 같은 방식으로 골라 담은 48종을 한 줄로. 8,900원
20
+ https://rebornlabs.kr/skillpack?utm_source=installer&utm_medium=cli&utm_campaign=openalt
21
+
22
+ 목록과 직접 까는 명령까지 그 페이지에 그대로 적어 두었습니다. 안 사셔도 됩니다.
23
+ ★스킬팩 위의 상품들은 **여기서 말하지 않는다.** 그 안내는 스킬팩 구매 이후 자리다(2026-08-24 전략).