skills-viewer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/build/cli.js +35 -0
- package/build/server/errors.js +24 -0
- package/build/server/index.js +298 -0
- package/build/server/locale.js +14 -0
- package/build/server/manage.js +205 -0
- package/build/server/scan.js +444 -0
- package/build/server/summary.js +302 -0
- package/build/server/usage.js +165 -0
- package/build/shared/types.js +3 -0
- package/dist/assets/index-BF9RnD9N.js +67 -0
- package/dist/assets/index-D2xAPQHX.css +1 -0
- package/dist/index.html +13 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 watanabeyu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# skills-viewer
|
|
2
|
+
|
|
3
|
+
Browse, search and manage your [Claude Code](https://code.claude.com) skills, commands, agents and hooks in the browser.
|
|
4
|
+
|
|
5
|
+
Scans every project registered in `~/.claude.json` (plus user scope, plugins and built-ins), and serves a local web UI to explore them — with usage stats, AI-generated summaries, full SKILL.md rendering, same-name diffs, and copy/delete management.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx skills-viewer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
> Zero runtime dependencies. The published package ships a prebuilt UI; `npx` installs in seconds.
|
|
12
|
+
|
|
13
|
+
<!-- 相対パス参照: private リポジトリでも GitHub 上で表示でき、npmjs.com は repository フィールドを元に raw URL へ書き換えるため public 化後は npm でも表示される -->
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **All scopes in one view** — user (`~/.claude/skills`), every project's `.claude/skills` / `.claude/commands`, installed plugins, and Claude Code built-ins, grouped by source
|
|
20
|
+
- **Search / sort** — incremental search over name + description + usage; sort by name, usage count, last used, or updated date
|
|
21
|
+
- **Usage stats** — invocation counts and last-used dates aggregated from Claude Code session transcripts (`~/.claude/projects/`), covering both user-typed `/skill` calls and model-invoked Skill tool calls
|
|
22
|
+
- **AI summaries** — one-click summarization of each SKILL.md via `claude -p --model haiku`, cached by content hash in `~/.cache/skills-viewer/` so unchanged skills are never re-summarized
|
|
23
|
+
- **Detail pane** — master/detail layout with an Overview tab (description, usage, bundled files) and a rendered SKILL.md tab
|
|
24
|
+
- **Agents & hooks too** — `.claude/agents/*.md` and `hooks` entries from `settings.json` / `settings.local.json` are listed alongside skills with kind badges (hooks are read-only)
|
|
25
|
+
- **Manage** — copy a skill/command/agent to another project or to user scope (`-copy` suffix on name conflicts), or delete it — deletions go to the OS trash, not `rm`. Plugin and built-in entries are read-only
|
|
26
|
+
- **Same-name diff** — when the same skill name exists in multiple scopes, the detail page lists the other definitions and shows a line diff between them
|
|
27
|
+
- **Open in editor** — via URL scheme (VS Code / Cursor / Zed / Windsurf / custom, configurable in the ⚙ settings modal), or the OS default opener
|
|
28
|
+
- **English / 日本語** — UI language auto-detected from the browser and switchable in settings; AI summaries are generated in the selected language (CLI messages follow `LANG`)
|
|
29
|
+
- **URL routing** — `/skills/:id?tab=md`, search/sort/grouping in query params; links are shareable across reloads
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx skills-viewer # scan + open browser (default port 4763)
|
|
35
|
+
npx skills-viewer --port 5000 # custom port
|
|
36
|
+
npx skills-viewer --no-open # don't open the browser
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Run it from a project directory to have that project marked as “current” and listed first. Reloading the page rescans the filesystem.
|
|
40
|
+
|
|
41
|
+
## Security
|
|
42
|
+
|
|
43
|
+
- Binds to `127.0.0.1` only
|
|
44
|
+
- Mutating APIs require a per-run token that other origins cannot read (same-origin policy), and requests with a non-localhost `Origin` are rejected
|
|
45
|
+
- Copy/delete are restricted to `.claude/skills/` / `.claude/commands/` / `.claude/agents/` paths; plugin directories are never written to; deletions go to the OS trash
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pnpm install
|
|
51
|
+
pnpm dev # tsx watch API server (4763) + Vite dev server (5173, /api proxied)
|
|
52
|
+
pnpm build # web UI → dist/, server TS → build/
|
|
53
|
+
pnpm typecheck # web + server
|
|
54
|
+
pnpm start # serve dist/ + API (what npx users get)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Layout:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
src/
|
|
61
|
+
├─ cli.ts # bin entry
|
|
62
|
+
├─ server/ # zero-dep Node API (scan / usage / summary / manage) — TypeScript
|
|
63
|
+
└─ shared/ # types shared between server and web (single source)
|
|
64
|
+
web/ # React + TypeScript + Vite (devDependencies only — bundled into dist/)
|
|
65
|
+
build/ # compiled server JS shipped in the npm package (generated by prepack)
|
|
66
|
+
dist/ # prebuilt UI shipped in the npm package (generated by prepack)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Notes
|
|
70
|
+
|
|
71
|
+
- Usage stats only cover the transcript retention window of Claude Code (`cleanupPeriodDays`, default 30 days)
|
|
72
|
+
- Usage is attributed per calling project (worktrees roll up to their parent project by encoded-path prefix). When scopes share a name, the resolution order project > user > plugin > built-in is assumed
|
|
73
|
+
- The built-in skill list is hardcoded in `server/scan.js` (they live inside the Claude Code binary); check `/skills` inside Claude Code for the authoritative list
|
|
74
|
+
- AI summaries require a logged-in `claude` CLI
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
This is an unofficial community tool, not affiliated with Anthropic.
|
package/build/cli.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const server_1 = require("./server");
|
|
5
|
+
const locale_1 = require("./server/locale");
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
8
|
+
console.log((0, locale_1.srvMsg)(`skills-viewer — Claude Code の skills / commands / agents / hooks をブラウザで一望
|
|
9
|
+
|
|
10
|
+
Usage: skills-viewer [options]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--port <n> ポート指定 (既定 4763、使用中なら +1)
|
|
14
|
+
--no-open ブラウザを自動で開かない
|
|
15
|
+
-h, --help このヘルプ`, `skills-viewer — browse your Claude Code skills / commands / agents / hooks
|
|
16
|
+
|
|
17
|
+
Usage: skills-viewer [options]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--port <n> port to listen on (default 4763; +1 if in use)
|
|
21
|
+
--no-open do not open the browser automatically
|
|
22
|
+
-h, --help show this help`));
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
const portIdx = args.indexOf('--port');
|
|
26
|
+
const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 4763;
|
|
27
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
28
|
+
console.error((0, locale_1.srvMsg)('--port には 0〜65535 の整数を指定してください', '--port must be an integer between 0 and 65535'));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
(0, server_1.start)({
|
|
32
|
+
port,
|
|
33
|
+
open: !args.includes('--no-open'),
|
|
34
|
+
cwd: process.cwd(),
|
|
35
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* API エラーは言語非依存のコードで表現し、表示文言はクライアント側の辞書で解決する。
|
|
4
|
+
* message は code(+detail)そのままなので、curl 等の直接利用でも意味が取れる。
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.ApiError = void 0;
|
|
8
|
+
exports.toErrorBody = toErrorBody;
|
|
9
|
+
class ApiError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
detail;
|
|
12
|
+
constructor(code, detail = '') {
|
|
13
|
+
super(detail ? code + ': ' + detail : code);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.detail = detail;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.ApiError = ApiError;
|
|
19
|
+
/* handleApi の catch で使う共通整形 */
|
|
20
|
+
function toErrorBody(e) {
|
|
21
|
+
if (e instanceof ApiError)
|
|
22
|
+
return { error: e.code, detail: e.detail };
|
|
23
|
+
return { error: 'internal', detail: e instanceof Error ? e.message : String(e) };
|
|
24
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* HTTP サーバー: /api/* の JSON API + dist/ (SPA) の静的配信。
|
|
4
|
+
* セキュリティ: 127.0.0.1 バインド + 起動ごとのトークン(/api/token で同一オリジンにのみ配布。
|
|
5
|
+
* mutation 系はトークン必須。SOP により他オリジンのページはトークンを読めない)。
|
|
6
|
+
*/
|
|
7
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.start = start;
|
|
42
|
+
const fs = __importStar(require("node:fs"));
|
|
43
|
+
const path = __importStar(require("node:path"));
|
|
44
|
+
const http = __importStar(require("node:http"));
|
|
45
|
+
const crypto = __importStar(require("node:crypto"));
|
|
46
|
+
const node_child_process_1 = require("node:child_process");
|
|
47
|
+
const scan_1 = require("./scan");
|
|
48
|
+
const usage_1 = require("./usage");
|
|
49
|
+
const summary_1 = require("./summary");
|
|
50
|
+
const manage_1 = require("./manage");
|
|
51
|
+
const errors_1 = require("./errors");
|
|
52
|
+
const locale_1 = require("./locale");
|
|
53
|
+
const TOKEN = crypto.randomBytes(16).toString('hex');
|
|
54
|
+
const DIST = path.join(__dirname, '..', '..', 'dist');
|
|
55
|
+
const MIME = {
|
|
56
|
+
'.html': 'text/html; charset=utf-8',
|
|
57
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
58
|
+
'.css': 'text/css; charset=utf-8',
|
|
59
|
+
'.svg': 'image/svg+xml',
|
|
60
|
+
'.png': 'image/png',
|
|
61
|
+
'.ico': 'image/x-icon',
|
|
62
|
+
'.map': 'application/json',
|
|
63
|
+
'.woff2': 'font/woff2',
|
|
64
|
+
};
|
|
65
|
+
/*
|
|
66
|
+
* 使用実績を「正しい定義」に帰属させる。
|
|
67
|
+
* 呼び出し元プロジェクト(トランスクリプトのディレクトリ)を特定し、そのプロジェクトの
|
|
68
|
+
* skill → user → plugin → built-in の解決優先順で1つの定義にだけ加算する。
|
|
69
|
+
* 呼び出し元が未知(worktree・削除済みプロジェクト等)の場合は user 以降にフォールバック。
|
|
70
|
+
*/
|
|
71
|
+
function attributeUsage(sections) {
|
|
72
|
+
const byDir = (0, usage_1.scanUsageByDir)();
|
|
73
|
+
const lookupBySec = new Map();
|
|
74
|
+
for (const s of sections) {
|
|
75
|
+
const m = new Map();
|
|
76
|
+
for (const it of s.items) {
|
|
77
|
+
m.set(it.name, it);
|
|
78
|
+
const short = it.name.split(':').pop();
|
|
79
|
+
if (short && !m.has(short))
|
|
80
|
+
m.set(short, it);
|
|
81
|
+
}
|
|
82
|
+
lookupBySec.set(s.id, m);
|
|
83
|
+
}
|
|
84
|
+
const dirToProjSec = new Map();
|
|
85
|
+
for (const s of sections) {
|
|
86
|
+
if (s.source === 'project')
|
|
87
|
+
dirToProjSec.set((0, usage_1.encodeProjectPath)(s.note), s);
|
|
88
|
+
}
|
|
89
|
+
const globalOrder = ['user', 'plugin', 'builtin']
|
|
90
|
+
.map((id) => sections.find((s) => s.id === id))
|
|
91
|
+
.filter((s) => Boolean(s));
|
|
92
|
+
// worktree(例: monorepo-feat-847-…)は親プロジェクトのエンコード名 + '-' で始まるので、
|
|
93
|
+
// 完全一致しない場合は最長プレフィックス一致で親プロジェクトに帰属させる
|
|
94
|
+
const resolveProjSec = (dirName) => {
|
|
95
|
+
if (dirToProjSec.has(dirName))
|
|
96
|
+
return dirToProjSec.get(dirName);
|
|
97
|
+
let best = null, bestLen = 0;
|
|
98
|
+
for (const [enc, sec] of dirToProjSec) {
|
|
99
|
+
if (dirName.startsWith(enc + '-') && enc.length > bestLen) {
|
|
100
|
+
best = sec;
|
|
101
|
+
bestLen = enc.length;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return best;
|
|
105
|
+
};
|
|
106
|
+
for (const [dirName, names] of Object.entries(byDir)) {
|
|
107
|
+
const projSec = resolveProjSec(dirName);
|
|
108
|
+
const order = projSec ? [projSec, ...globalOrder] : globalOrder;
|
|
109
|
+
for (const [name, u] of Object.entries(names)) {
|
|
110
|
+
let target;
|
|
111
|
+
for (const sec of order) {
|
|
112
|
+
target = lookupBySec.get(sec.id)?.get(name);
|
|
113
|
+
if (target)
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
if (!target)
|
|
117
|
+
continue; // 既知の skill に該当しない(builtin CLI コマンド等)
|
|
118
|
+
target.typedCount = (target.typedCount || 0) + u.typed;
|
|
119
|
+
target.autoCount = (target.autoCount || 0) + u.auto;
|
|
120
|
+
target.useCount = (target.useCount || 0) + u.typed + u.auto;
|
|
121
|
+
target.lastUsed = Math.max(target.lastUsed || 0, u.last);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function collect(cwd, lang) {
|
|
126
|
+
const sections = (0, scan_1.scanSections)(cwd, lang);
|
|
127
|
+
attributeUsage(sections);
|
|
128
|
+
const summaries = (0, summary_1.loadSummaries)();
|
|
129
|
+
for (const sec of sections) {
|
|
130
|
+
for (const it of sec.items) {
|
|
131
|
+
const cached = summaries[it.path];
|
|
132
|
+
if (cached &&
|
|
133
|
+
cached.lang === lang && // 表示言語と違う要約は出さない(aiStale 側にカウントされる)
|
|
134
|
+
it.path &&
|
|
135
|
+
fs.existsSync(it.path) &&
|
|
136
|
+
cached.hash === (0, summary_1.contentHash)(it.path)) {
|
|
137
|
+
it.aiSummary = cached.summary;
|
|
138
|
+
if (cached.invocation) {
|
|
139
|
+
it.aiInvocation = cached.invocation;
|
|
140
|
+
it.aiInvocationReason = cached.invocationReason || '';
|
|
141
|
+
}
|
|
142
|
+
// 生成後に照合ルールが厳格化されても、現在の refs に無い関係は表示しない
|
|
143
|
+
const valid = (cached.relations || []).filter((r) => (it.refs || []).includes(r.name));
|
|
144
|
+
if (valid.length)
|
|
145
|
+
it.aiRelations = valid;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const aiStale = (0, summary_1.staleItems)(sections, lang).length;
|
|
150
|
+
const targets = [
|
|
151
|
+
{ label: 'user skills', sub: '~/.claude/skills/', path: scan_1.HOME },
|
|
152
|
+
...(0, scan_1.listProjects)(cwd)
|
|
153
|
+
.sort((a, b) => path.basename(a).localeCompare(path.basename(b)))
|
|
154
|
+
.map((p) => ({ label: path.basename(p), sub: p, path: p })),
|
|
155
|
+
];
|
|
156
|
+
return { generatedAt: new Date().toISOString(), cwd, sections, targets, aiStale };
|
|
157
|
+
}
|
|
158
|
+
/* DNS rebinding 対策: same-origin GET には Origin が付かないため Host 側も検証する */
|
|
159
|
+
function hostOk(req) {
|
|
160
|
+
const host = (req.headers.host || '').replace(/:\d+$/, '');
|
|
161
|
+
return host === '127.0.0.1' || host === 'localhost';
|
|
162
|
+
}
|
|
163
|
+
/* 同一オリジン以外からの API アクセスを拒否(Origin が付く場合のみ検証) */
|
|
164
|
+
function originOk(req) {
|
|
165
|
+
const origin = req.headers.origin;
|
|
166
|
+
if (!origin)
|
|
167
|
+
return true; // same-origin fetch / curl は Origin なし
|
|
168
|
+
try {
|
|
169
|
+
const h = new URL(origin).hostname;
|
|
170
|
+
return h === '127.0.0.1' || h === 'localhost';
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/* クライアント指定の表示言語(不明値は 'en' に落とす) */
|
|
177
|
+
function langOf(v) {
|
|
178
|
+
return v === 'ja' ? 'ja' : 'en';
|
|
179
|
+
}
|
|
180
|
+
function handleApi(req, res, cwd) {
|
|
181
|
+
const send = (code, obj) => {
|
|
182
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
183
|
+
res.end(JSON.stringify(obj));
|
|
184
|
+
};
|
|
185
|
+
if (!hostOk(req) || !originOk(req))
|
|
186
|
+
return send(403, { error: 'bad-origin', detail: '' });
|
|
187
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
188
|
+
if (req.method === 'GET') {
|
|
189
|
+
try {
|
|
190
|
+
if (url.pathname === '/api/token')
|
|
191
|
+
return send(200, { token: TOKEN });
|
|
192
|
+
if (url.pathname === '/api/skills')
|
|
193
|
+
return send(200, collect(cwd, langOf(url.searchParams.get('lang'))));
|
|
194
|
+
if (url.pathname === '/api/summary-status')
|
|
195
|
+
return send(200, (0, summary_1.summaryStatus)());
|
|
196
|
+
if (url.pathname === '/api/file') {
|
|
197
|
+
const real = (0, manage_1.assertReadableMd)(url.searchParams.get('src') || '');
|
|
198
|
+
return send(200, { content: fs.readFileSync(real, 'utf8') });
|
|
199
|
+
}
|
|
200
|
+
throw new errors_1.ApiError('unknown-endpoint', url.pathname);
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
return send(e instanceof errors_1.ApiError && e.code === 'unknown-endpoint' ? 404 : 400, (0, errors_1.toErrorBody)(e));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// mutation 系はトークン必須
|
|
207
|
+
if (req.headers['x-csb-token'] !== TOKEN)
|
|
208
|
+
return send(403, { error: 'bad-token', detail: '' });
|
|
209
|
+
let body = '';
|
|
210
|
+
req.on('data', (c) => {
|
|
211
|
+
body += c;
|
|
212
|
+
if (body.length > 1e6)
|
|
213
|
+
req.destroy();
|
|
214
|
+
});
|
|
215
|
+
req.on('end', () => {
|
|
216
|
+
let data;
|
|
217
|
+
try {
|
|
218
|
+
data = JSON.parse(body || '{}');
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return send(400, { error: 'bad-json', detail: '' });
|
|
222
|
+
}
|
|
223
|
+
const lang = langOf(data.lang);
|
|
224
|
+
try {
|
|
225
|
+
if (url.pathname === '/api/copy')
|
|
226
|
+
return send(200, (0, manage_1.doCopy)(data, cwd));
|
|
227
|
+
if (url.pathname === '/api/delete')
|
|
228
|
+
return send(200, (0, manage_1.doDelete)(data));
|
|
229
|
+
if (url.pathname === '/api/open')
|
|
230
|
+
return send(200, (0, manage_1.openInEditor)(data));
|
|
231
|
+
if (url.pathname === '/api/summarize-all')
|
|
232
|
+
return send(200, (0, summary_1.startSummarizeAll)((0, scan_1.scanSections)(cwd, lang), !!data.force, lang));
|
|
233
|
+
if (url.pathname === '/api/summarize') {
|
|
234
|
+
const real = (0, manage_1.assertReadableMd)(data.src);
|
|
235
|
+
// refs(関係候補)はスキャン結果から復元する
|
|
236
|
+
const sections = (0, scan_1.scanSections)(cwd, lang);
|
|
237
|
+
const item = sections.flatMap((s) => s.items).find((x) => x.path === real);
|
|
238
|
+
const name = data.name || item?.name || path.basename(path.dirname(real));
|
|
239
|
+
(0, summary_1.summarizeOne)({ path: real, name, refs: item?.refs || [] }, lang)
|
|
240
|
+
.then((analysis) => {
|
|
241
|
+
(0, summary_1.saveSummary)(real, name, analysis, lang);
|
|
242
|
+
send(200, { ok: true, ...analysis });
|
|
243
|
+
})
|
|
244
|
+
.catch((e) => send(400, (0, errors_1.toErrorBody)(e)));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
throw new errors_1.ApiError('unknown-endpoint', url.pathname);
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
return send(e instanceof errors_1.ApiError && e.code === 'unknown-endpoint' ? 404 : 400, (0, errors_1.toErrorBody)(e));
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function serveStatic(req, res) {
|
|
255
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
256
|
+
let fp = path.join(DIST, path.normalize(url.pathname));
|
|
257
|
+
if (!fp.startsWith(DIST)) {
|
|
258
|
+
res.writeHead(403);
|
|
259
|
+
return void res.end();
|
|
260
|
+
}
|
|
261
|
+
if (!fs.existsSync(fp) || fs.statSync(fp).isDirectory()) {
|
|
262
|
+
fp = path.join(DIST, 'index.html'); // SPA fallback (/skills/xxx など)
|
|
263
|
+
}
|
|
264
|
+
if (!fs.existsSync(fp)) {
|
|
265
|
+
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
266
|
+
// 開発者向けメッセージなので英語固定
|
|
267
|
+
return void res.end('dist/ not found. Run `pnpm build` first.');
|
|
268
|
+
}
|
|
269
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });
|
|
270
|
+
res.end(fs.readFileSync(fp));
|
|
271
|
+
}
|
|
272
|
+
function start({ port = 4763, open = true, cwd = process.cwd() } = {}, attempt = 0) {
|
|
273
|
+
const server = http.createServer((req, res) => {
|
|
274
|
+
if ((req.url || '').startsWith('/api/'))
|
|
275
|
+
return handleApi(req, res, cwd);
|
|
276
|
+
serveStatic(req, res);
|
|
277
|
+
});
|
|
278
|
+
server.on('error', (err) => {
|
|
279
|
+
if (err.code === 'EADDRINUSE' && attempt < 10) {
|
|
280
|
+
start({ port: port + 1, open, cwd }, attempt + 1);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
console.error(err.message);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
server.listen(port, '127.0.0.1', () => {
|
|
288
|
+
const url = `http://127.0.0.1:${port}`;
|
|
289
|
+
console.log(`skills-viewer: ${url} (cwd: ${cwd})`);
|
|
290
|
+
console.log((0, locale_1.srvMsg)('Ctrl+C で終了。ページ再読み込みで再スキャンされます。', 'Press Ctrl+C to quit. Reloading the page rescans.'));
|
|
291
|
+
if (open && process.platform === 'darwin')
|
|
292
|
+
(0, node_child_process_1.execFile)('open', [url], () => { });
|
|
293
|
+
if (open && process.platform === 'win32')
|
|
294
|
+
(0, node_child_process_1.execFile)('cmd', ['/c', 'start', url], () => { });
|
|
295
|
+
if (open && process.platform === 'linux')
|
|
296
|
+
(0, node_child_process_1.execFile)('xdg-open', [url], () => { });
|
|
297
|
+
});
|
|
298
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* CLI・サーバーコンソール出力の言語判定(環境変数ベース)。
|
|
4
|
+
* web UI の言語はクライアント側(navigator.language + 設定)で独立に管理される。
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.srvMsg = exports.serverLang = void 0;
|
|
8
|
+
function detect() {
|
|
9
|
+
const env = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '';
|
|
10
|
+
return /^ja([-_]|$)/i.test(env) ? 'ja' : 'en';
|
|
11
|
+
}
|
|
12
|
+
exports.serverLang = detect();
|
|
13
|
+
const srvMsg = (ja, en) => (exports.serverLang === 'ja' ? ja : en);
|
|
14
|
+
exports.srvMsg = srvMsg;
|