tokenbill-mcp 1.1.6 → 1.1.9
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 +1 -1
- package/package.json +5 -4
- package/uploader/toolbox.js +245 -0
- package/uploader/viewer.js +200 -2
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenbill-mcp",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "AI 쓰는 만큼 쌓이는
|
|
3
|
+
"version": "1.1.9",
|
|
4
|
+
"description": "내 AI 작업실 — AI 쓰는 만큼 쌓이는 기록으로 tokenbill.my 리더보드, 로컬 대화 뷰어, 작업 보드·스프린트를. Your AI workshop: a leaderboard, a local transcript viewer and a task board built from your AI work logs.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tokenbill-mcp": "uploader/index.js"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"uploader/",
|
|
10
|
-
"!uploader/duet/test/"
|
|
10
|
+
"!uploader/duet/test/",
|
|
11
|
+
"!uploader/test/"
|
|
11
12
|
],
|
|
12
13
|
"scripts": {
|
|
13
|
-
"test": "node --test uploader/duet/test/core.test.js",
|
|
14
|
+
"test": "node --test uploader/duet/test/core.test.js uploader/test/toolbox.test.js",
|
|
14
15
|
"smoke": "node uploader/duet/test/smoke.js"
|
|
15
16
|
},
|
|
16
17
|
"repository": {
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 스킬·MCP 관리기 — 이 PC에 흩어진 스킬과 MCP 설정을 찾아 모은다.
|
|
3
|
+
*
|
|
4
|
+
* 읽기만 하고(설치는 viewer 쪽에서 ~/.claude/skills 안으로만), 어떤 것도 밖으로 보내지 않는다.
|
|
5
|
+
* 토큰처럼 보이는 값은 찾는 즉시 마스킹해서 화면에도 원문이 가지 않게 한다.
|
|
6
|
+
*/
|
|
7
|
+
"use strict";
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const crypto = require("crypto");
|
|
12
|
+
|
|
13
|
+
const HOME = os.homedir();
|
|
14
|
+
const SKILLS_HOME = path.join(HOME, ".claude", "skills"); // Claude Code가 실제로 읽는 자리
|
|
15
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "__pycache__", ".venv", "venv", ".next", "target"]);
|
|
16
|
+
|
|
17
|
+
// ── 공통 ────────────────────────────────────────────────────
|
|
18
|
+
function* findSkillFiles(root, maxDepth) {
|
|
19
|
+
const stack = [[root, 0]];
|
|
20
|
+
let seen = 0;
|
|
21
|
+
while (stack.length) {
|
|
22
|
+
const [dir, depth] = stack.pop();
|
|
23
|
+
if (depth > maxDepth) continue;
|
|
24
|
+
if (++seen > 20000) return; // 폴더가 너무 많으면 여기서 멈춘다 (PC가 느려지지 않게)
|
|
25
|
+
let entries;
|
|
26
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
27
|
+
for (const e of entries) {
|
|
28
|
+
if (e.isFile() && e.name === "SKILL.md") yield path.join(dir, e.name);
|
|
29
|
+
else if (e.isDirectory() && !SKIP_DIRS.has(e.name)) stack.push([path.join(dir, e.name), depth + 1]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** SKILL.md 머리말에서 이름·설명을 꺼낸다. 없으면 폴더 이름으로 대신한다. */
|
|
35
|
+
function readSkillMeta(file) {
|
|
36
|
+
let text = "";
|
|
37
|
+
try { text = fs.readFileSync(file, "utf8").slice(0, 4000); } catch { return null; }
|
|
38
|
+
const dir = path.dirname(file);
|
|
39
|
+
const meta = { name: path.basename(dir), description: "" };
|
|
40
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
41
|
+
if (fm) {
|
|
42
|
+
const name = /^name:\s*(.+)$/m.exec(fm[1]);
|
|
43
|
+
if (name) meta.name = name[1].trim().replace(/^["']|["']$/g, "");
|
|
44
|
+
const lines = fm[1].split(/\r?\n/);
|
|
45
|
+
const at = lines.findIndex((l) => /^description:/.test(l));
|
|
46
|
+
if (at >= 0) {
|
|
47
|
+
let v = lines[at].replace(/^description:\s*/, "").trim();
|
|
48
|
+
if (v === "|" || v === ">" || v === "|-" || v === ">-") {
|
|
49
|
+
// YAML 블록 — 들여쓴 다음 줄들이 본문이다
|
|
50
|
+
const block = [];
|
|
51
|
+
for (let i = at + 1; i < lines.length && /^\s+\S/.test(lines[i]); i++) block.push(lines[i].trim());
|
|
52
|
+
v = block.join(" ");
|
|
53
|
+
}
|
|
54
|
+
meta.description = v.replace(/^["']|["']$/g, "");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!meta.description) {
|
|
58
|
+
// 제목·표·목록 기호로 시작하는 줄은 설명으로 쓰지 않는다 (표로 시작하는 SKILL.md 대비)
|
|
59
|
+
const body = text.replace(/^---[\s\S]*?---/, "").split(/\r?\n/)
|
|
60
|
+
.map((l) => l.trim())
|
|
61
|
+
.filter((l) => l.length > 8 && !/^[#|>`\-*=]/.test(l));
|
|
62
|
+
meta.description = (body[0] || "").slice(0, 160);
|
|
63
|
+
}
|
|
64
|
+
let mtime = 0, size = 0;
|
|
65
|
+
try { const st = fs.statSync(file); mtime = st.mtimeMs; size = st.size; } catch {}
|
|
66
|
+
return { ...meta, dir, file, mtime, size,
|
|
67
|
+
hash: crypto.createHash("sha1").update(text).digest("hex").slice(0, 12),
|
|
68
|
+
files: safeList(dir) };
|
|
69
|
+
}
|
|
70
|
+
function safeList(dir) {
|
|
71
|
+
try { return fs.readdirSync(dir).filter((n) => n !== "SKILL.md").slice(0, 12); } catch { return []; }
|
|
72
|
+
}
|
|
73
|
+
const short = (p) => p.replace(HOME, "~").replace(/\\/g, "/");
|
|
74
|
+
|
|
75
|
+
/** Claude Code가 기억하는 프로젝트 폴더들 — 저장소 안 .claude/skills를 여기서 찾는다. */
|
|
76
|
+
function claudeProjects() {
|
|
77
|
+
try {
|
|
78
|
+
const j = JSON.parse(fs.readFileSync(path.join(HOME, ".claude.json"), "utf8"));
|
|
79
|
+
return Object.keys(j.projects || {}).filter((p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
|
|
80
|
+
} catch { return []; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── 스킬 모으기 ─────────────────────────────────────────────
|
|
84
|
+
// Claude Code가 공식적으로 스킬을 읽는 자리만 본다.
|
|
85
|
+
// 개인: ~/.claude/skills/<이름>/SKILL.md — 어느 폴더에서 열든 읽힘
|
|
86
|
+
// 프로젝트: <저장소>/.claude/skills/<이름>/SKILL.md — 그 폴더에서 열 때만 읽힘
|
|
87
|
+
// 플러그인: ~/.claude/plugins/…/skills/<이름>/SKILL.md
|
|
88
|
+
// 다른 도구의 스킬 폴더나 내려받은 폴더는 Claude Code가 읽지 않으므로 목록에 넣지 않는다.
|
|
89
|
+
function scanSkills() {
|
|
90
|
+
const roots = [
|
|
91
|
+
{ root: SKILLS_HOME, depth: 2, source: "personal", label: "개인 — 어디서 열든" },
|
|
92
|
+
{ root: path.join(HOME, ".claude", "plugins"), depth: 6, source: "plugin", label: "플러그인" },
|
|
93
|
+
];
|
|
94
|
+
for (const p of claudeProjects()) {
|
|
95
|
+
roots.push({ root: path.join(p, ".claude", "skills"), depth: 2, source: "project",
|
|
96
|
+
label: `프로젝트 — ${path.basename(p)}에서 열 때만`, project: short(p) });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const found = [], seenDir = new Set();
|
|
100
|
+
for (const r of roots) {
|
|
101
|
+
for (const file of findSkillFiles(r.root, r.depth)) {
|
|
102
|
+
const dir = path.dirname(file);
|
|
103
|
+
if (seenDir.has(dir)) continue;
|
|
104
|
+
seenDir.add(dir);
|
|
105
|
+
const meta = readSkillMeta(file);
|
|
106
|
+
if (!meta) continue;
|
|
107
|
+
// 플러그인 폴더에서는 skills/ 아래 것만 스킬이다 (다른 SKILL.md 사본 제외)
|
|
108
|
+
if (r.source === "plugin" && path.basename(path.dirname(dir)) !== "skills") continue;
|
|
109
|
+
found.push({ ...meta, source: r.source, origin: r.label, dir_short: short(dir), project: r.project || "",
|
|
110
|
+
installed: r.source === "personal" });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// 같은 스킬이 여러 자리에 있으면 하나로 묶는다 (이름 기준, 사본 목록을 함께)
|
|
114
|
+
const byName = new Map();
|
|
115
|
+
for (const s of found.sort((a, b) => Number(b.installed) - Number(a.installed) || b.mtime - a.mtime)) {
|
|
116
|
+
const key = s.name.toLowerCase();
|
|
117
|
+
if (!byName.has(key)) byName.set(key, { ...s, copies: [] });
|
|
118
|
+
else byName.get(key).copies.push({ dir_short: s.dir_short, origin: s.origin, hash: s.hash, mtime: s.mtime });
|
|
119
|
+
}
|
|
120
|
+
const list = [...byName.values()].sort((a, b) =>
|
|
121
|
+
Number(b.installed) - Number(a.installed) || a.name.localeCompare(b.name, "ko"));
|
|
122
|
+
return { skills: list, installed_dir: short(SKILLS_HOME), total: found.length };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── MCP 모으기 ──────────────────────────────────────────────
|
|
126
|
+
const SECRET_KEYS = /(token|key|secret|password|pass|credential|api)/i;
|
|
127
|
+
const SECRET_VALUE = /^(tbu_|acv_|sk-|ghp_|github_pat_|npm_|xox[baprs]-)/i;
|
|
128
|
+
// `--token` 같은 플래그 바로 뒤 값만 비밀로 본다. 경로에 'token'이 들어간 것(…/tokenbill/…)과 헷갈리지 않게.
|
|
129
|
+
const SECRET_FLAG = /^--?[a-z0-9-]*(token|key|secret|password|pass|credential)$/i;
|
|
130
|
+
const isSecretArg = (args, i) => SECRET_VALUE.test(String(args[i])) || (i > 0 && SECRET_FLAG.test(String(args[i - 1])));
|
|
131
|
+
|
|
132
|
+
/** 토큰처럼 보이는 값을 가린다. 화면에도 원문은 보내지 않는다. */
|
|
133
|
+
function maskArgs(args) {
|
|
134
|
+
const list = args || [];
|
|
135
|
+
return list.map((v, i) => {
|
|
136
|
+
const a = String(v);
|
|
137
|
+
return isSecretArg(list, i) ? a.slice(0, 4) + "…" + a.slice(-2) : a;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function maskEnv(env) {
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const [k, v] of Object.entries(env || {})) {
|
|
143
|
+
out[k] = SECRET_KEYS.test(k) || SECRET_VALUE.test(String(v))
|
|
144
|
+
? String(v).slice(0, 4) + "…" + String(v).slice(-2) : v;
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function mcpEntry(name, cfg, scope, where) {
|
|
149
|
+
return {
|
|
150
|
+
name, scope, where: short(where),
|
|
151
|
+
type: cfg.type || (cfg.url ? "http" : "stdio"),
|
|
152
|
+
command: cfg.command || cfg.url || "",
|
|
153
|
+
args: maskArgs(cfg.args),
|
|
154
|
+
env: maskEnv(cfg.env),
|
|
155
|
+
has_secret: (cfg.args || []).some((a, i) => isSecretArg(cfg.args, i)) ||
|
|
156
|
+
Object.entries(cfg.env || {}).some(([k, v]) => SECRET_KEYS.test(k) || SECRET_VALUE.test(String(v))),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function scanMcp() {
|
|
160
|
+
const out = [];
|
|
161
|
+
const cfgPath = path.join(HOME, ".claude.json");
|
|
162
|
+
let j = null;
|
|
163
|
+
try { j = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
164
|
+
if (j) {
|
|
165
|
+
for (const [name, cfg] of Object.entries(j.mcpServers || {})) out.push(mcpEntry(name, cfg, "user", cfgPath));
|
|
166
|
+
for (const [proj, v] of Object.entries(j.projects || {})) {
|
|
167
|
+
for (const [name, cfg] of Object.entries((v && v.mcpServers) || {})) {
|
|
168
|
+
out.push(mcpEntry(name, cfg, "local", `${cfgPath} → ${path.basename(proj)}`));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const p of claudeProjects()) {
|
|
173
|
+
const f = path.join(p, ".mcp.json");
|
|
174
|
+
try {
|
|
175
|
+
const k = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
176
|
+
for (const [name, cfg] of Object.entries(k.mcpServers || {})) out.push(mcpEntry(name, cfg, "project", f));
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
return { servers: out, config: short(cfgPath) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 다른 PC로 옮길 때 쓰는 .mcp.json 템플릿 — 비밀값은 환경변수 자리표시자로. */
|
|
183
|
+
function mcpTemplate(names) {
|
|
184
|
+
const cfgPath = path.join(HOME, ".claude.json");
|
|
185
|
+
let j = {};
|
|
186
|
+
try { j = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
187
|
+
const pick = names && names.length ? names : Object.keys(j.mcpServers || {});
|
|
188
|
+
const servers = {};
|
|
189
|
+
for (const name of pick) {
|
|
190
|
+
const cfg = (j.mcpServers || {})[name];
|
|
191
|
+
if (!cfg) continue;
|
|
192
|
+
const args = (cfg.args || []).map((a, i) =>
|
|
193
|
+
isSecretArg(cfg.args, i) ? "${" + name.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_TOKEN}" : a);
|
|
194
|
+
const env = {};
|
|
195
|
+
for (const [k, v] of Object.entries(cfg.env || {})) {
|
|
196
|
+
env[k] = SECRET_KEYS.test(k) || SECRET_VALUE.test(String(v)) ? "${" + k + "}" : v;
|
|
197
|
+
}
|
|
198
|
+
servers[name] = { ...(cfg.type ? { type: cfg.type } : {}), command: cfg.command, args,
|
|
199
|
+
...(Object.keys(env).length ? { env } : {}) };
|
|
200
|
+
}
|
|
201
|
+
return { mcpServers: servers };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── 옮기기 (쓰기는 ~/.claude/skills 안에서만) ───────────────
|
|
205
|
+
function destFor(name) {
|
|
206
|
+
const clean = String(name || "").trim().replace(/[\\/:*?"<>|]/g, "-");
|
|
207
|
+
if (!clean || clean.startsWith(".")) throw new Error("스킬 이름이 이상합니다");
|
|
208
|
+
const dest = path.resolve(SKILLS_HOME, clean);
|
|
209
|
+
if (!dest.startsWith(path.resolve(SKILLS_HOME) + path.sep)) throw new Error("허용되지 않는 경로입니다");
|
|
210
|
+
return dest;
|
|
211
|
+
}
|
|
212
|
+
function copyDir(from, to) {
|
|
213
|
+
fs.mkdirSync(to, { recursive: true });
|
|
214
|
+
for (const e of fs.readdirSync(from, { withFileTypes: true })) {
|
|
215
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
216
|
+
const a = path.join(from, e.name), b = path.join(to, e.name);
|
|
217
|
+
if (e.isDirectory()) copyDir(a, b);
|
|
218
|
+
else if (e.isFile()) fs.copyFileSync(a, b);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** 스킬 폴더를 Claude Code가 읽는 자리로 복사한다. */
|
|
222
|
+
function installSkill(dir, { overwrite = false } = {}) {
|
|
223
|
+
const src = path.resolve(String(dir || ""));
|
|
224
|
+
if (!fs.existsSync(path.join(src, "SKILL.md"))) throw new Error("SKILL.md가 없는 폴더입니다");
|
|
225
|
+
// Claude Code 공식 자리(프로젝트의 .claude/skills)에 있는 것만 개인 폴더로 옮긴다
|
|
226
|
+
const official = claudeProjects().some((p) =>
|
|
227
|
+
src.toLowerCase().startsWith(path.resolve(p, ".claude", "skills").toLowerCase() + path.sep));
|
|
228
|
+
if (!official) throw new Error("Claude Code 스킬 폴더(.claude/skills)에 있는 스킬만 옮길 수 있습니다");
|
|
229
|
+
const meta = readSkillMeta(path.join(src, "SKILL.md"));
|
|
230
|
+
const dest = destFor(meta.name);
|
|
231
|
+
const exists = fs.existsSync(dest);
|
|
232
|
+
if (exists && !overwrite) return { ok: false, exists: true, name: meta.name, dest: short(dest) };
|
|
233
|
+
if (exists) fs.rmSync(dest, { recursive: true, force: true });
|
|
234
|
+
copyDir(src, dest);
|
|
235
|
+
return { ok: true, name: meta.name, dest: short(dest), from: short(src) };
|
|
236
|
+
}
|
|
237
|
+
/** 설치된 스킬만 지운다. */
|
|
238
|
+
function removeSkill(name) {
|
|
239
|
+
const dest = destFor(name);
|
|
240
|
+
if (!fs.existsSync(dest)) throw new Error("설치되어 있지 않습니다");
|
|
241
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
242
|
+
return { ok: true, name, dest: short(dest) };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { scanSkills, scanMcp, mcpTemplate, installSkill, removeSkill, SKILLS_HOME, short };
|
package/uploader/viewer.js
CHANGED
|
@@ -10,6 +10,7 @@ const fs = require("fs");
|
|
|
10
10
|
const path = require("path");
|
|
11
11
|
const os = require("os");
|
|
12
12
|
const http = require("http");
|
|
13
|
+
const toolbox = require("./toolbox");
|
|
13
14
|
|
|
14
15
|
const BASES = {
|
|
15
16
|
"claude-code": path.join(os.homedir(), ".claude", "projects"),
|
|
@@ -328,6 +329,31 @@ header .note{font-size:.72rem;color:var(--muted)}
|
|
|
328
329
|
#filters select{padding:5px 9px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--ink);font:inherit;font-size:.75rem;max-width:220px}
|
|
329
330
|
#sum{margin-left:auto;font-size:.73rem;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
330
331
|
main{flex:1;display:flex;min-height:0}
|
|
332
|
+
.hidden{display:none!important}
|
|
333
|
+
.vtabs{display:flex;gap:2px;margin-left:8px}
|
|
334
|
+
.vtabs button{border:0;background:none;font:inherit;font-size:.86rem;font-weight:600;color:var(--muted);padding:6px 12px;
|
|
335
|
+
border-bottom:2px solid transparent;cursor:pointer}
|
|
336
|
+
.vtabs button.on{color:var(--accent-ink);border-bottom-color:var(--accent)}
|
|
337
|
+
#toolbox{flex:1;overflow-y:auto;padding:18px 22px}
|
|
338
|
+
.tb-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:6px}
|
|
339
|
+
.tb-head h2{font-size:1rem;font-weight:700}
|
|
340
|
+
.tb-head .note2{font-size:.76rem;color:var(--muted)}
|
|
341
|
+
.tb-group{font-size:.74rem;font-weight:700;color:var(--muted);margin:16px 0 6px;letter-spacing:.03em}
|
|
342
|
+
.tb-card{background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:11px 14px;margin-bottom:7px;
|
|
343
|
+
display:flex;gap:12px;align-items:flex-start}
|
|
344
|
+
.tb-card .body{flex:1;min-width:0}
|
|
345
|
+
.tb-card .nm{font-size:.86rem;font-weight:600;display:flex;align-items:center;gap:7px;flex-wrap:wrap}
|
|
346
|
+
.tb-card .ds{font-size:.76rem;color:var(--ink-2);margin-top:3px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
|
347
|
+
.tb-card .pth{font-size:.68rem;color:var(--muted);font-family:ui-monospace,Consolas,monospace;margin-top:4px;word-break:break-all}
|
|
348
|
+
.tb-badge{font-size:.62rem;border-radius:999px;padding:1px 8px;border:1px solid var(--border);color:var(--muted)}
|
|
349
|
+
.tb-badge.ok{background:color-mix(in srgb,var(--good-ink) 12%,transparent);color:var(--good-ink);border-color:transparent}
|
|
350
|
+
.tb-badge.warn{background:color-mix(in srgb,var(--warn) 18%,transparent);color:var(--warn-ink);border-color:transparent}
|
|
351
|
+
.tb-pre{background:var(--chip);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:.7rem;
|
|
352
|
+
font-family:ui-monospace,Consolas,monospace;white-space:pre-wrap;word-break:break-all;margin-top:8px}
|
|
353
|
+
.btn2{border:1px solid var(--border);background:var(--surface);color:var(--ink);border-radius:7px;padding:5px 11px;
|
|
354
|
+
font:inherit;font-size:.76rem;font-weight:600;cursor:pointer;flex:none}
|
|
355
|
+
.btn2:hover{background:var(--chip)}
|
|
356
|
+
.btn2.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
331
357
|
#side{width:360px;flex:none;border-right:1px solid var(--grid);overflow-y:auto;padding:8px;background:var(--page)}
|
|
332
358
|
.sess{display:flex;gap:10px;align-items:flex-start;padding:9px 12px;border-radius:9px;border:1px solid var(--border);border-left:3px solid var(--grid);cursor:pointer;margin-bottom:5px;background:var(--surface)}
|
|
333
359
|
.sess:hover{background:var(--chip)}.sess.on{background:color-mix(in srgb,var(--accent) 10%,var(--surface));border-color:color-mix(in srgb,var(--accent) 40%,var(--border))}
|
|
@@ -350,8 +376,11 @@ main{flex:1;display:flex;min-height:0}
|
|
|
350
376
|
.sumcard .bigtok{margin-left:auto;font-size:.95rem;font-weight:700;color:var(--accent-ink);font-variant-numeric:tabular-nums}
|
|
351
377
|
@media(max-width:760px){#side{width:220px}}
|
|
352
378
|
</style></head><body>
|
|
353
|
-
<header><h1>Token<b>bill</b>
|
|
354
|
-
<span class="note">이 PC의
|
|
379
|
+
<header><h1>Token<b>bill</b> <span id="hTitle">로컬 뷰어</span></h1>
|
|
380
|
+
<span class="note">이 PC의 것만 읽습니다 — 아무것도 업로드되지 않아요</span>
|
|
381
|
+
<nav class="vtabs">
|
|
382
|
+
<button data-v="chat" class="on">대화</button><button data-v="skill">스킬</button><button data-v="mcp">MCP</button>
|
|
383
|
+
</nav>
|
|
355
384
|
<input id="q" placeholder="대화 내용 검색 (Enter)"></header>
|
|
356
385
|
<div id="filters">
|
|
357
386
|
<span id="chips"></span>
|
|
@@ -364,6 +393,7 @@ main{flex:1;display:flex;min-height:0}
|
|
|
364
393
|
<span id="sum"></span>
|
|
365
394
|
</div>
|
|
366
395
|
<main><div id="side"></div><div id="view"><p class="empty">왼쪽에서 세션을 선택하세요</p></div></main>
|
|
396
|
+
<section id="toolbox" class="hidden"></section>
|
|
367
397
|
<script>
|
|
368
398
|
(function(){
|
|
369
399
|
"use strict";
|
|
@@ -474,7 +504,151 @@ function loadSession(s){
|
|
|
474
504
|
projSel.addEventListener("change",function(){F.proj=this.value;refresh()});
|
|
475
505
|
perSel.addEventListener("change",function(){F.per=this.value;refresh()});
|
|
476
506
|
qIn.addEventListener("keydown",function(ev){if(ev.key==="Enter")refresh()});
|
|
507
|
+
/* ── 스킬·MCP 관리기 ── */
|
|
508
|
+
function toast(msg){
|
|
509
|
+
var t = document.getElementById("tbToast");
|
|
510
|
+
if (!t) { t = el("div",""); t.id = "tbToast"; document.body.appendChild(t);
|
|
511
|
+
t.style.cssText = "position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--page);"
|
|
512
|
+
+ "padding:9px 16px;border-radius:8px;font-size:.82rem;z-index:30;max-width:80vw;text-align:center"; }
|
|
513
|
+
t.textContent = msg; t.style.display = "block";
|
|
514
|
+
clearTimeout(t._h); t._h = setTimeout(function(){ t.style.display = "none"; }, 3200);
|
|
515
|
+
}
|
|
516
|
+
var tb = null; // 서버에서 받은 스캔 결과
|
|
517
|
+
function tbLoad(force){
|
|
518
|
+
if (tb && !force) return Promise.resolve(tb);
|
|
519
|
+
return fetch("/api/toolbox").then(function(r){return r.json()}).then(function(j){ tb = j; return j; });
|
|
520
|
+
}
|
|
521
|
+
function tbPost(path, body){
|
|
522
|
+
return fetch(path, { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body) })
|
|
523
|
+
.then(function(r){ return r.json(); });
|
|
524
|
+
}
|
|
525
|
+
function badge(text, cls){ var b = el("span","tb-badge"+(cls?" "+cls:""),text); return b; }
|
|
526
|
+
function card(nameNode, desc, pathText, actions){
|
|
527
|
+
var c = el("div","tb-card"), body = el("div","body");
|
|
528
|
+
body.appendChild(nameNode);
|
|
529
|
+
if (desc) body.appendChild(el("div","ds",desc));
|
|
530
|
+
if (pathText) body.appendChild(el("div","pth",pathText));
|
|
531
|
+
c.appendChild(body);
|
|
532
|
+
(actions||[]).forEach(function(a){ c.appendChild(a); });
|
|
533
|
+
return c;
|
|
534
|
+
}
|
|
535
|
+
function renderSkills(){
|
|
536
|
+
var box = document.getElementById("toolbox");
|
|
537
|
+
box.textContent = "";
|
|
538
|
+
var head = el("div","tb-head");
|
|
539
|
+
head.appendChild(el("h2","","스킬 " + tb.skills.length + "개"));
|
|
540
|
+
head.appendChild(el("span","note2","Claude Code가 공식적으로 읽는 자리만 보여줍니다 — 개인(" + tb.installed_dir + ") · 프로젝트(.claude/skills) · 플러그인"));
|
|
541
|
+
box.appendChild(head);
|
|
542
|
+
if (!tb.skills.length) {
|
|
543
|
+
box.appendChild(el("p","empty","아직 Claude Code 스킬이 없어요. " + tb.installed_dir + "/<이름>/SKILL.md 로 두면 어느 폴더에서든 읽힙니다."));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// 개인 → 프로젝트 → 플러그인 순서
|
|
547
|
+
var ORDER = { personal: 0, project: 1, plugin: 2 };
|
|
548
|
+
var groups = {};
|
|
549
|
+
tb.skills.forEach(function(s){ (groups[s.origin] = groups[s.origin] || []).push(s); });
|
|
550
|
+
Object.keys(groups).sort(function(a,b){
|
|
551
|
+
return (ORDER[groups[a][0].source] - ORDER[groups[b][0].source]) || a.localeCompare(b,"ko");
|
|
552
|
+
})
|
|
553
|
+
.forEach(function(g){
|
|
554
|
+
box.appendChild(el("div","tb-group", g + " · " + groups[g].length));
|
|
555
|
+
groups[g].forEach(function(s){
|
|
556
|
+
var nm = el("div","nm");
|
|
557
|
+
nm.appendChild(document.createTextNode(s.name));
|
|
558
|
+
if (s.source === "personal") nm.appendChild(badge("어디서나 사용","ok"));
|
|
559
|
+
if (s.source === "project") nm.appendChild(badge("이 폴더에서만"));
|
|
560
|
+
if (s.source === "plugin") nm.appendChild(badge("플러그인"));
|
|
561
|
+
if (s.copies.length) nm.appendChild(badge("사본 " + s.copies.length));
|
|
562
|
+
if (s.files.length) nm.appendChild(badge(s.files.join(", ").slice(0,40)));
|
|
563
|
+
// 플러그인 스킬은 플러그인이 관리한다 — 여기서 건드리지 않는다
|
|
564
|
+
if (s.source === "plugin") { box.appendChild(card(nm, s.description, s.dir_short, [])); return; }
|
|
565
|
+
var btn = el("button","btn2" + (s.installed ? "" : " primary"), s.installed ? "제거" : "어디서나 쓰기");
|
|
566
|
+
btn.title = s.installed ? "개인 스킬 폴더에서 지웁니다" : "개인 스킬 폴더(" + tb.installed_dir + ")로 복사해 어느 폴더에서든 쓰게 합니다";
|
|
567
|
+
btn.addEventListener("click", function(){
|
|
568
|
+
if (s.installed) {
|
|
569
|
+
if (!confirm(s.name + " 스킬을 " + tb.installed_dir + " 에서 지울까요?")) return;
|
|
570
|
+
tbPost("/api/skills/remove", { name: s.name }).then(function(r){
|
|
571
|
+
toast(r.error ? r.error : s.name + " 제거됨"); tbLoad(true).then(renderSkills);
|
|
572
|
+
});
|
|
573
|
+
} else {
|
|
574
|
+
tbPost("/api/skills/install", { dir: s.dir }).then(function(r){
|
|
575
|
+
if (r.exists && !r.ok) {
|
|
576
|
+
if (!confirm(s.name + " 이(가) 이미 있습니다. 덮어쓸까요?")) return;
|
|
577
|
+
return tbPost("/api/skills/install", { dir: s.dir, overwrite: true }).then(function(r2){
|
|
578
|
+
toast(r2.error ? r2.error : s.name + " 설치됨"); tbLoad(true).then(renderSkills);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
toast(r.error ? r.error : s.name + " 설치됨 → " + r.dest);
|
|
582
|
+
tbLoad(true).then(renderSkills);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
box.appendChild(card(nm, s.description, s.dir_short, [btn]));
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
function renderMcp(){
|
|
591
|
+
var box = document.getElementById("toolbox");
|
|
592
|
+
box.textContent = "";
|
|
593
|
+
var head = el("div","tb-head");
|
|
594
|
+
head.appendChild(el("h2","","MCP " + tb.servers.length + "개"));
|
|
595
|
+
head.appendChild(el("span","note2","설정 파일: " + tb.config + " · 토큰은 가려서 보여줍니다"));
|
|
596
|
+
box.appendChild(head);
|
|
597
|
+
var SCOPE = { user: "사용자 전역", local: "이 폴더에서만", project: "저장소 공유(.mcp.json)" };
|
|
598
|
+
tb.servers.forEach(function(s){
|
|
599
|
+
var nm = el("div","nm");
|
|
600
|
+
nm.appendChild(document.createTextNode(s.name));
|
|
601
|
+
nm.appendChild(badge(SCOPE[s.scope] || s.scope));
|
|
602
|
+
if (s.has_secret) nm.appendChild(badge("토큰 포함","warn"));
|
|
603
|
+
var cmd = s.command + " " + (s.args || []).join(" ");
|
|
604
|
+
var btn = el("button","btn2","등록 명령 복사");
|
|
605
|
+
btn.addEventListener("click", function(){
|
|
606
|
+
var line = "claude mcp add -s user " + s.name + " -- " + cmd;
|
|
607
|
+
if (navigator.clipboard) navigator.clipboard.writeText(line).then(function(){ toast("복사됨 — 토큰 자리는 직접 채우세요"); });
|
|
608
|
+
});
|
|
609
|
+
box.appendChild(card(nm, cmd, s.where, [btn]));
|
|
610
|
+
});
|
|
611
|
+
var tHead = el("div","tb-group","다른 PC로 옮기기");
|
|
612
|
+
box.appendChild(tHead);
|
|
613
|
+
var note = el("div","ds","아래 내용을 저장소의 .mcp.json 으로 저장하면 팀원·다른 PC가 같은 MCP를 씁니다. 토큰은 환경변수 자리표시자로 바꿔 두었습니다.");
|
|
614
|
+
box.appendChild(note);
|
|
615
|
+
var pre = el("pre","tb-pre","불러오는 중…");
|
|
616
|
+
box.appendChild(pre);
|
|
617
|
+
fetch("/api/mcp/template").then(function(r){return r.json()}).then(function(j){
|
|
618
|
+
pre.textContent = JSON.stringify(j, null, 2);
|
|
619
|
+
});
|
|
620
|
+
var copy = el("button","btn2","템플릿 복사");
|
|
621
|
+
copy.style.marginTop = "8px";
|
|
622
|
+
copy.addEventListener("click", function(){
|
|
623
|
+
if (navigator.clipboard) navigator.clipboard.writeText(pre.textContent).then(function(){ toast("템플릿 복사됨"); });
|
|
624
|
+
});
|
|
625
|
+
box.appendChild(copy);
|
|
626
|
+
}
|
|
627
|
+
var TITLES = { chat: "로컬 뷰어", skill: "스킬 관리", mcp: "MCP 설정" };
|
|
628
|
+
function setTab(v){
|
|
629
|
+
document.querySelectorAll(".vtabs button").forEach(function(b){ b.classList.toggle("on", b.dataset.v === v); });
|
|
630
|
+
var chat = v === "chat";
|
|
631
|
+
document.querySelector("main").classList.toggle("hidden", !chat);
|
|
632
|
+
document.getElementById("filters").classList.toggle("hidden", !chat);
|
|
633
|
+
document.getElementById("q").classList.toggle("hidden", !chat);
|
|
634
|
+
document.getElementById("toolbox").classList.toggle("hidden", chat);
|
|
635
|
+
document.getElementById("hTitle").textContent = TITLES[v];
|
|
636
|
+
try { localStorage.setItem("tb-viewer-tab", v); } catch (e) {}
|
|
637
|
+
if (!chat) tbLoad().then(v === "skill" ? renderSkills : renderMcp)
|
|
638
|
+
.catch(function(){ document.getElementById("toolbox").textContent = "불러오지 못했습니다"; });
|
|
639
|
+
}
|
|
640
|
+
document.querySelectorAll(".vtabs button").forEach(function(b){
|
|
641
|
+
b.addEventListener("click", function(){ setTab(b.dataset.v); });
|
|
642
|
+
});
|
|
477
643
|
loadList();
|
|
644
|
+
// 주소의 #skill·#mcp로 바로 들어올 수 있다 (포탈 도구 메뉴가 이 주소를 쓴다)
|
|
645
|
+
var fromHash = (location.hash || "").replace("#", "");
|
|
646
|
+
if (fromHash === "skill" || fromHash === "mcp" || fromHash === "chat") setTab(fromHash);
|
|
647
|
+
else { try { var saved = localStorage.getItem("tb-viewer-tab"); if (saved && saved !== "chat") setTab(saved); } catch (e) {} }
|
|
648
|
+
window.addEventListener("hashchange", function () {
|
|
649
|
+
var v = (location.hash || "").replace("#", "");
|
|
650
|
+
if (v === "skill" || v === "mcp" || v === "chat") setTab(v);
|
|
651
|
+
});
|
|
478
652
|
})();
|
|
479
653
|
</script></body></html>`;
|
|
480
654
|
|
|
@@ -526,6 +700,30 @@ function start(port, opts) {
|
|
|
526
700
|
const q = (u.searchParams.get("q") || "").trim();
|
|
527
701
|
return json(res, q.length >= 2 ? searchSessions(q) : []);
|
|
528
702
|
}
|
|
703
|
+
// ── 스킬·MCP 관리기 (전부 이 PC 안에서만) ──
|
|
704
|
+
if (u.pathname === "/api/toolbox") {
|
|
705
|
+
return json(res, { ...toolbox.scanSkills(), ...toolbox.scanMcp() });
|
|
706
|
+
}
|
|
707
|
+
if (u.pathname === "/api/mcp/template") {
|
|
708
|
+
const names = (u.searchParams.get("names") || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
709
|
+
return json(res, toolbox.mcpTemplate(names));
|
|
710
|
+
}
|
|
711
|
+
if (req.method === "POST" && (u.pathname === "/api/skills/install" || u.pathname === "/api/skills/remove")) {
|
|
712
|
+
let body = "";
|
|
713
|
+
req.on("data", (c) => { body += c; if (body.length > 1e5) req.destroy(); });
|
|
714
|
+
return req.on("end", () => {
|
|
715
|
+
try {
|
|
716
|
+
const b = JSON.parse(body || "{}");
|
|
717
|
+
const out = u.pathname.endsWith("install")
|
|
718
|
+
? toolbox.installSkill(b.dir, { overwrite: !!b.overwrite })
|
|
719
|
+
: toolbox.removeSkill(b.name);
|
|
720
|
+
json(res, out);
|
|
721
|
+
} catch (e) {
|
|
722
|
+
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
723
|
+
res.end(JSON.stringify({ error: String(e.message || e) }));
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
}
|
|
529
727
|
res.writeHead(404); res.end("not found");
|
|
530
728
|
} catch (e) {
|
|
531
729
|
res.writeHead(500); res.end(String(e.message || e));
|