huhaa-myskills 0.2.0 → 0.2.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.
Files changed (37) hide show
  1. package/{service/bin → bin}/huhaa-myskills.mjs +24 -44
  2. package/package.json +19 -28
  3. package/packages/scanner/package.json +15 -0
  4. package/packages/scanner/test/scanner.test.mjs +140 -0
  5. package/packages/server/package.json +15 -0
  6. package/{service/packages → packages}/server/src/index.mjs +10 -23
  7. package/packages/server/test/server.test.mjs +135 -0
  8. package/packages/web/index.html +12 -0
  9. package/packages/web/package.json +20 -0
  10. package/packages/web/src/App.vue +407 -0
  11. package/packages/web/src/lib/api.js +43 -0
  12. package/packages/web/src/main.js +6 -0
  13. package/packages/web/src/stores/i18n.js +149 -0
  14. package/packages/web/src/stores/skills.js +276 -0
  15. package/packages/web/src/styles.css +325 -0
  16. package/packages/web/vite.config.js +17 -0
  17. package/{service/scripts → scripts}/build-web.mjs +2 -2
  18. package/scripts/prepare-publish.mjs +3 -0
  19. package/{service/scripts → scripts}/verify.mjs +6 -4
  20. package/install-and-sync.sh +0 -209
  21. package/service/scripts/prepare-publish.mjs +0 -44
  22. package/service/scripts/restore-publish.mjs +0 -17
  23. /package/{service/bin → bin}/lib/paths.mjs +0 -0
  24. /package/{service/bin → bin}/lib/port.mjs +0 -0
  25. /package/{service/config → config}/sources.example.yaml +0 -0
  26. /package/{service/packages → packages}/scanner/src/adapters/file-docs.mjs +0 -0
  27. /package/{service/packages → packages}/scanner/src/adapters/hermes-plugin.mjs +0 -0
  28. /package/{service/packages → packages}/scanner/src/adapters/markdown-skill.mjs +0 -0
  29. /package/{service/packages → packages}/scanner/src/adapters/mcp-config.mjs +0 -0
  30. /package/{service/packages → packages}/scanner/src/index.mjs +0 -0
  31. /package/{service/packages → packages}/scanner/src/types.d.ts +0 -0
  32. /package/{service/packages → packages}/scanner/src/utils.mjs +0 -0
  33. /package/{service/packages → packages}/web/README.md +0 -0
  34. /package/{service/packages → packages}/web/dist/assets/index-CTh5OdBd.js +0 -0
  35. /package/{service/packages → packages}/web/dist/assets/index-CtoiGnQ7.css +0 -0
  36. /package/{service/packages → packages}/web/dist/index.html +0 -0
  37. /package/{service/scripts → scripts}/sync-skills.sh +0 -0
@@ -0,0 +1,407 @@
1
+ <script setup>
2
+ import { computed, onMounted } from 'vue';
3
+ import MarkdownIt from 'markdown-it';
4
+ import { useSkillsStore } from './stores/skills.js';
5
+ import { useI18nStore } from './stores/i18n.js';
6
+
7
+ const store = useSkillsStore();
8
+ const i18n = useI18nStore();
9
+ const t = i18n.t;
10
+ const md = new MarkdownIt({ html: false, linkify: true, typographer: true });
11
+
12
+ onMounted(() => store.load());
13
+
14
+ const detailHtml = computed(() => {
15
+ const raw = store.selected?.raw || store.selected?.preview || '';
16
+ return md.render(raw);
17
+ });
18
+
19
+ const activeFilterText = computed(() => {
20
+ const parts = [];
21
+ if (store.filters.editor) parts.push(`${t('editor')}:${store.filters.editor}`);
22
+ if (store.filters.kind) parts.push(`${t('kind')}:${store.filters.kind}`);
23
+ if (store.filters.source) parts.push(`${t('source')}:${store.filters.source}`);
24
+ if (store.filters.product) parts.push(`${t('product')}:${store.filters.product}`);
25
+ if (store.filters.brand) parts.push(`${t('brand')}:${store.filters.brand}`);
26
+ if (store.query) parts.push(`q:${store.query}`);
27
+ return parts.join(' · ');
28
+ });
29
+
30
+ const activeFilterChips = computed(() => {
31
+ const chips = [];
32
+ if (store.filters.editor) chips.push({ key: 'editor', label: t('editor'), value: store.filters.editor });
33
+ if (store.filters.kind) chips.push({ key: 'kind', label: t('kind'), value: store.filters.kind });
34
+ if (store.filters.source) chips.push({ key: 'source', label: t('source'), value: store.filters.source });
35
+ if (store.filters.product) chips.push({ key: 'product', label: t('product'), value: store.filters.product });
36
+ if (store.filters.brand) chips.push({ key: 'brand', label: t('brand'), value: store.filters.brand });
37
+ if (store.query.trim()) chips.push({ key: 'query', label: 'q', value: store.query.trim() });
38
+ return chips;
39
+ });
40
+
41
+ const detailMeta = computed(() => {
42
+ const s = store.selected || {};
43
+ return [
44
+ [t('editor'), s.editor || s.source],
45
+ [t('source'), s.source],
46
+ [t('kind'), s.kind],
47
+ [t('product'), s.product],
48
+ [t('brand'), s.brand],
49
+ [t('category'), s.category || t('uncategorized')],
50
+ [t('rootKind'), s.paths?.rootKind],
51
+ [t('relativePath'), s.paths?.rel],
52
+ [t('fileSize'), formatBytes((s.raw || s.preview || '').length)],
53
+ [t('updated'), s.updatedAt],
54
+ ].filter(([, value]) => value);
55
+ });
56
+
57
+ const detailBadges = computed(() => {
58
+ const s = store.selected || {};
59
+ const badges = [s.kind, s.source, s.editor, s.product, s.brand].filter(Boolean);
60
+ if (s.raw?.includes('[REDACTED]') || s.preview?.includes('[REDACTED]')) badges.push(t('redacted'));
61
+ if (s.parseError) badges.push(t('parseError'));
62
+ return [...new Set(badges)];
63
+ });
64
+
65
+ const usagePrompt = computed(() => buildUsagePrompt(store.selected));
66
+
67
+ function buildUsagePrompt(item) {
68
+ if (!item) return '';
69
+ const name = item.name || item.title || 'selected item';
70
+ const path = item.paths?.abs || '';
71
+ if (item.source === 'hermes') return `Use Hermes skill ${name}: skill_view(name='${name}')`;
72
+ if (item.source === 'claude-code') return `Use Claude Code skill ${name}. Local path: ${path}`;
73
+ if (item.source === 'codex') return `Use Codex instructions from ${path}`;
74
+ if (item.source === 'cursor') return `Use Cursor rule ${name}. Local path: ${path}`;
75
+ if (item.source === 'mcp-config') return `Use MCP server/tool ${name}. Config source: ${path}`;
76
+ if (item.source === 'project-runbook') return `Use runbook ${name}. Local path: ${path}`;
77
+ if (item.source === 'hermes-plugin') return `Use Hermes plugin ${name}. Local path: ${path}`;
78
+ return `Use ${name}. Local path: ${path}`;
79
+ }
80
+
81
+ function clearFilterChip(key) {
82
+ if (key === 'query') {
83
+ store.query = '';
84
+ return;
85
+ }
86
+ store.setFilter(key, '');
87
+ }
88
+
89
+ function highlighted(text) {
90
+ const source = String(text || '');
91
+ const terms = searchTerms(store.query);
92
+ if (!terms.length) return escapeHtml(source);
93
+ const escapedTerms = terms
94
+ .filter(term => term.length >= 2)
95
+ .map(escapeRegExp);
96
+ if (!escapedTerms.length) return escapeHtml(source);
97
+ const re = new RegExp(`(${escapedTerms.join('|')})`, 'ig');
98
+ return escapeHtml(source).replace(re, '<mark>$1</mark>');
99
+ }
100
+
101
+ function searchTerms(query) {
102
+ return String(query || '')
103
+ .split(/\s+/)
104
+ .filter(Boolean)
105
+ .filter(part => !/^(editor|kind|source|product|brand):/i.test(part))
106
+ .map(part => part.replace(/^['"]|['"]$/g, ''));
107
+ }
108
+
109
+ function escapeHtml(s) {
110
+ return String(s)
111
+ .replace(/&/g, '&amp;')
112
+ .replace(/</g, '&lt;')
113
+ .replace(/>/g, '&gt;')
114
+ .replace(/"/g, '&quot;')
115
+ .replace(/'/g, '&#39;');
116
+ }
117
+
118
+ function escapeRegExp(s) {
119
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
120
+ }
121
+
122
+ function formatBytes(n) {
123
+ if (!Number.isFinite(n)) return '-';
124
+ if (n < 1024) return `${n} B`;
125
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
126
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
127
+ }
128
+ </script>
129
+
130
+ <template>
131
+ <div class="shell">
132
+ <aside class="sidebar">
133
+ <div class="brand-block">
134
+ <div class="logo">H</div>
135
+ <div>
136
+ <h1>HuHaa-MySkills</h1>
137
+ <p>{{ t('appSubtitle') }}</p>
138
+ </div>
139
+ </div>
140
+
141
+ <div class="lang-switch">
142
+ <span>{{ t('language') }}</span>
143
+ <button :class="{ active: i18n.locale === 'zh' }" @click="i18n.setLocale('zh')">中文</button>
144
+ <button :class="{ active: i18n.locale === 'en' }" @click="i18n.setLocale('en')">EN</button>
145
+ </div>
146
+
147
+ <div v-if="store.stats" class="stats-card">
148
+ <div class="stat-main">{{ store.stats.total }}</div>
149
+ <div class="stat-label">{{ t('skillsLoaded') }}</div>
150
+ <div class="source-line" v-for="e in store.editors" :key="e.name">
151
+ <span>{{ e.name }}</span><strong>{{ e.count }}</strong>
152
+ </div>
153
+ </div>
154
+
155
+ <div class="selected-editor-card" v-if="store.filters.editor">
156
+ <div>
157
+ <span>{{ t('selectedEditor') }}</span>
158
+ <strong>{{ store.filters.editor }}</strong>
159
+ </div>
160
+ <button @click="store.setFilter('editor', '')">×</button>
161
+ </div>
162
+
163
+ <section class="filter-section nav-section">
164
+ <div class="section-head">
165
+ <h2>{{ t('editor') }}</h2>
166
+ <small>{{ t('frontSelect') }}</small>
167
+ </div>
168
+ <button
169
+ class="pill"
170
+ :class="{ active: !store.filters.editor }"
171
+ @click="store.setFilter('editor', '')"
172
+ >
173
+ <span>{{ t('all') }}</span><b>{{ store.stats?.total || 0 }}</b>
174
+ </button>
175
+ <button
176
+ v-for="e in store.editors"
177
+ :key="e.name"
178
+ class="pill"
179
+ :class="{ active: store.filters.editor === e.name }"
180
+ @click="store.setFilter('editor', e.name)"
181
+ >
182
+ <span>{{ e.name }}</span><b>{{ e.count }}</b>
183
+ </button>
184
+ </section>
185
+
186
+ </aside>
187
+
188
+ <main class="main">
189
+ <header class="topbar">
190
+ <div class="toolbar-card">
191
+ <div class="toolbar-main">
192
+ <div class="search-box">
193
+ <span class="search-icon">⌕</span>
194
+ <input
195
+ v-model="store.query"
196
+ type="search"
197
+ :placeholder="t('searchPlaceholder')"
198
+ />
199
+ </div>
200
+ <button class="btn soft" @click="store.clearFilters">{{ t('clear') }}</button>
201
+ <button class="btn primary" :disabled="store.reloadState?.scanning" @click="store.reload({ done: t('reloadDone') })">
202
+ {{ store.reloadState?.scanning ? t('reloading') : t('reload') }}
203
+ </button>
204
+ </div>
205
+
206
+ <div class="toolbar-facets">
207
+ <label class="facet">
208
+ <span>{{ t('kind') }}</span>
209
+ <select v-model="store.filters.kind">
210
+ <option value="">{{ t('all') }}</option>
211
+ <option v-for="o in store.kinds" :key="o.name" :value="o.name">{{ o.name }} ({{ o.count }})</option>
212
+ </select>
213
+ </label>
214
+ <label class="facet">
215
+ <span>{{ t('source') }}</span>
216
+ <select v-model="store.filters.source">
217
+ <option value="">{{ t('all') }}</option>
218
+ <option v-for="o in store.filterSources" :key="o.name" :value="o.name">{{ o.name }} ({{ o.count }})</option>
219
+ </select>
220
+ </label>
221
+ <label class="facet">
222
+ <span>{{ t('product') }}</span>
223
+ <select v-model="store.filters.product">
224
+ <option value="">{{ t('all') }}</option>
225
+ <option v-for="o in store.products" :key="o.name" :value="o.name">{{ o.name }} ({{ o.count }})</option>
226
+ </select>
227
+ </label>
228
+ <label class="facet">
229
+ <span>{{ t('brand') }}</span>
230
+ <select v-model="store.filters.brand">
231
+ <option value="">{{ t('all') }}</option>
232
+ <option v-for="o in store.brands" :key="o.name" :value="o.name">{{ o.name }} ({{ o.count }})</option>
233
+ </select>
234
+ </label>
235
+ <label class="facet compact">
236
+ <span>{{ t('sort') }}</span>
237
+ <select v-model="store.sortKey">
238
+ <option value="default">{{ t('sortDefault') }}</option>
239
+ <option value="name">{{ t('sortName') }}</option>
240
+ <option value="updated">{{ t('sortUpdated') }}</option>
241
+ <option value="kind">{{ t('sortKind') }}</option>
242
+ <option value="source">{{ t('sortSource') }}</option>
243
+ <option value="product">{{ t('sortProduct') }}</option>
244
+ </select>
245
+ </label>
246
+ </div>
247
+
248
+ <div class="toolbar-status">
249
+ <span class="filter-chip" :class="{ active: store.activeFilterCount }">
250
+ {{ store.activeFilterCount ? `${store.activeFilterCount} ${t('active')}` : t('noFilters') }}
251
+ </span>
252
+ <div v-if="activeFilterChips.length" class="active-chip-row" :title="activeFilterText">
253
+ <button
254
+ v-for="chip in activeFilterChips"
255
+ :key="`${chip.key}:${chip.value}`"
256
+ class="active-chip"
257
+ @click="clearFilterChip(chip.key)"
258
+ >
259
+ <span>{{ chip.label }}</span>
260
+ <strong>{{ chip.value }}</strong>
261
+ <b>×</b>
262
+ </button>
263
+ </div>
264
+ <span v-else class="active-filter">{{ t('searchHint') }}</span>
265
+ <span v-if="store.reloadState?.lastReloadAt" class="reload-state">
266
+ {{ t('lastReload') }} {{ new Date(store.reloadState.lastReloadAt).toLocaleTimeString() }}
267
+ </span>
268
+ </div>
269
+ </div>
270
+ </header>
271
+
272
+ <div v-if="store.error" class="error">{{ store.error }}</div>
273
+ <div v-if="store.loading" class="loading">{{ t('loading') }}</div>
274
+
275
+ <div class="content">
276
+ <section class="list-panel">
277
+ <div class="list-head">
278
+ <div>
279
+ <strong>{{ store.filtered.length }}</strong>
280
+ <span>{{ t('items') }}</span>
281
+ </div>
282
+ </div>
283
+ <button
284
+ v-for="item in store.filtered"
285
+ :key="item.id"
286
+ class="skill-card"
287
+ :class="{ selected: item.id === store.selectedId }"
288
+ @click="store.loadDetail(item.id)"
289
+ >
290
+ <div class="skill-title">
291
+ <span class="src" :class="`src-${item.source}`">{{ item.source }}</span>
292
+ <strong v-html="highlighted(item.name)"></strong>
293
+ </div>
294
+ <div class="skill-chipline">
295
+ <span>{{ item.editor || item.source }}</span>
296
+ <span>{{ item.kind || '-' }}</span>
297
+ <span v-if="item.product">{{ item.product }}</span>
298
+ </div>
299
+ <div class="skill-meta">
300
+ <span>{{ item.category || '-' }}</span>
301
+ <span v-if="item.brand">· {{ item.brand }}</span>
302
+ </div>
303
+ <p v-html="highlighted(item.description)"></p>
304
+ </button>
305
+ </section>
306
+
307
+ <section class="detail-panel" v-if="store.selected">
308
+ <div class="detail-head">
309
+ <div>
310
+ <div class="detail-kicker">
311
+ <span class="src" :class="`src-${store.selected.source}`">{{ store.selected.source }}</span>
312
+ <span>{{ store.selected.editor || store.selected.source }}</span>
313
+ <span>{{ store.selected.category || t('uncategorized') }}</span>
314
+ </div>
315
+ <h2>{{ store.selected.name }}</h2>
316
+ <div class="badge-row">
317
+ <span
318
+ v-for="badge in detailBadges"
319
+ :key="badge"
320
+ class="badge"
321
+ :class="{ danger: badge === t('parseError'), warn: badge === t('redacted') }"
322
+ >{{ badge }}</span>
323
+ </div>
324
+ <p>{{ store.selected.description }}</p>
325
+ </div>
326
+ </div>
327
+
328
+ <div class="actions">
329
+ <button @click="store.copySelected('path', { copied: t('copied'), bytes: t('bytes') })">{{ t('copyPath') }}</button>
330
+ <button @click="store.copySelected('dir', { copied: t('copied'), bytes: t('bytes') })">{{ t('copyDir') }}</button>
331
+ <button @click="store.copySelected('rel', { copied: t('copied'), bytes: t('bytes') })">{{ t('copyRel') }}</button>
332
+ <button @click="store.copySelected('prompt', { copied: t('copied'), bytes: t('bytes') })">{{ t('copyPrompt') }}</button>
333
+ <button @click="store.copySelected('name', { copied: t('copied'), bytes: t('bytes') })">{{ t('copyName') }}</button>
334
+ <button @click="store.copySelected('raw', { copied: t('copied'), bytes: t('bytes') })">{{ t('copyRaw') }}</button>
335
+ <button @click="store.openSelected('cursor', { opened: t('opened') })">{{ t('openCursor') }}</button>
336
+ <button @click="store.openSelected('finder', { opened: t('opened') })">{{ t('reveal') }}</button>
337
+ </div>
338
+
339
+ <div v-if="store.notice" class="notice">{{ store.notice }}</div>
340
+
341
+ <section class="usage-card">
342
+ <div class="usage-head">
343
+ <div>
344
+ <span>{{ t('usage') }}</span>
345
+ <h3>{{ t('howToUse') }}</h3>
346
+ </div>
347
+ <button @click="store.copySelected('prompt', { copied: t('copied'), bytes: t('bytes') })">{{ t('copyPrompt') }}</button>
348
+ </div>
349
+ <div class="usage-prompt"><code>{{ usagePrompt }}</code></div>
350
+ <div class="usage-grid">
351
+ <div>
352
+ <strong>{{ t('appliesTo') }}</strong>
353
+ <p>{{ store.selected.description || '-' }}</p>
354
+ </div>
355
+ <div>
356
+ <strong>{{ t('quickFacts') }}</strong>
357
+ <p>{{ store.selected.editor || store.selected.source }} · {{ store.selected.kind }}<span v-if="store.selected.product"> · {{ store.selected.product }}</span></p>
358
+ </div>
359
+ </div>
360
+ <div class="usage-block" v-if="store.selected.params?.length">
361
+ <strong>{{ t('params') }}</strong>
362
+ <div class="param-table">
363
+ <div v-for="p in store.selected.params" :key="p.name">
364
+ <code>{{ p.name }}</code>
365
+ <span>{{ p.type || '-' }}</span>
366
+ <b v-if="p.required">{{ t('required') }}</b>
367
+ <p>{{ p.description || p.default || '-' }}</p>
368
+ </div>
369
+ </div>
370
+ </div>
371
+ <div class="usage-block" v-if="store.selected.triggers?.length">
372
+ <strong>{{ t('triggers') }}</strong>
373
+ <span class="tag" v-for="tag in store.selected.triggers.slice(0, 12)" :key="tag">{{ tag }}</span>
374
+ </div>
375
+ <div class="usage-block" v-if="store.selected.links?.length">
376
+ <strong>{{ t('links') }}</strong>
377
+ <a class="usage-link" v-for="link in store.selected.links" :key="link.url" :href="link.url" target="_blank" rel="noreferrer">{{ link.label || link.url }}</a>
378
+ </div>
379
+ </section>
380
+
381
+ <dl class="kv">
382
+ <div>
383
+ <dt>{{ t('path') }}</dt>
384
+ <dd><code>{{ store.selected.paths?.abs }}</code></dd>
385
+ </div>
386
+ <div v-for="([label, value]) in detailMeta" :key="label">
387
+ <dt>{{ label }}</dt>
388
+ <dd>{{ value }}</dd>
389
+ </div>
390
+ <div v-if="store.selected.parseError" class="kv-error">
391
+ <dt>{{ t('parseError') }}</dt>
392
+ <dd>{{ store.selected.parseError }}</dd>
393
+ </div>
394
+ <div v-if="store.selected.triggers?.length">
395
+ <dt>{{ t('triggers') }}</dt>
396
+ <dd>
397
+ <span class="tag" v-for="t in store.selected.triggers.slice(0, 8)" :key="t">{{ t }}</span>
398
+ </dd>
399
+ </div>
400
+ </dl>
401
+
402
+ <article class="markdown" v-html="detailHtml"></article>
403
+ </section>
404
+ </div>
405
+ </main>
406
+ </div>
407
+ </template>
@@ -0,0 +1,43 @@
1
+ export async function getJson(url) {
2
+ let res;
3
+ try {
4
+ res = await fetch(url, { headers: { accept: 'application/json' } });
5
+ } catch (e) {
6
+ throw new Error(`GET ${url} failed: ${e.message || e}`);
7
+ }
8
+
9
+ const text = await res.text();
10
+ let data = null;
11
+ try {
12
+ data = text ? JSON.parse(text) : null;
13
+ } catch {
14
+ throw new Error(`GET ${url} returned non-JSON (${res.status}): ${text.slice(0, 160)}`);
15
+ }
16
+
17
+ if (!res.ok) throw new Error(data?.error || `GET ${url} failed: ${res.status} ${res.statusText}`);
18
+ return data;
19
+ }
20
+
21
+ export async function postJson(url, body) {
22
+ let res;
23
+ try {
24
+ res = await fetch(url, {
25
+ method: 'POST',
26
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
27
+ body: JSON.stringify(body),
28
+ });
29
+ } catch (e) {
30
+ throw new Error(`POST ${url} failed: ${e.message || e}`);
31
+ }
32
+
33
+ const text = await res.text();
34
+ let data = null;
35
+ try {
36
+ data = text ? JSON.parse(text) : null;
37
+ } catch {
38
+ throw new Error(`POST ${url} returned non-JSON (${res.status}): ${text.slice(0, 160)}`);
39
+ }
40
+
41
+ if (!res.ok) throw new Error(data?.error || `POST ${url} failed: ${res.status} ${res.statusText}`);
42
+ return data;
43
+ }
@@ -0,0 +1,6 @@
1
+ import { createApp } from 'vue';
2
+ import { createPinia } from 'pinia';
3
+ import App from './App.vue';
4
+ import './styles.css';
5
+
6
+ createApp(App).use(createPinia()).mount('#app');
@@ -0,0 +1,149 @@
1
+ import { defineStore } from 'pinia';
2
+
3
+ const messages = {
4
+ zh: {
5
+ appSubtitle: '本地技能中枢',
6
+ skillsLoaded: '已加载技能',
7
+ source: '来源',
8
+ editor: '所属编辑器',
9
+ kind: '类型',
10
+ product: '产品',
11
+ category: '目录',
12
+ brand: '品牌',
13
+ filters: '筛选',
14
+ active: '已启用',
15
+ selectedEditor: '当前编辑器',
16
+ frontSelect: '已选前置',
17
+ rootKind: '归属地',
18
+ relativePath: '相对路径',
19
+ fileSize: '文件大小',
20
+ redacted: '已脱敏',
21
+ parseError: '解析错误',
22
+ sort: '排序',
23
+ sortDefault: '默认',
24
+ sortName: '名称',
25
+ sortUpdated: '最近更新',
26
+ sortKind: '类型',
27
+ sortSource: '来源',
28
+ sortProduct: '产品',
29
+ all: '全部',
30
+ searchPlaceholder: '搜索名称 / 描述 / 编辑器 / 路径...',
31
+ searchHint: '支持 kind:mcp / source:mcp-config / product:xxx 等组合搜索',
32
+ clear: '清空',
33
+ noFilters: '无筛选',
34
+ items: '条',
35
+ uncategorized: '未分类',
36
+ copyPath: '复制路径',
37
+ copyDir: '复制目录',
38
+ copyRel: '复制相对路径',
39
+ copyPrompt: '复制调用提示',
40
+ copyName: '复制名称',
41
+ copyRaw: '复制全文',
42
+ openCursor: '用 Cursor 打开',
43
+ reveal: '在 Finder 中显示',
44
+ usage: '使用',
45
+ howToUse: '怎么调用',
46
+ appliesTo: '适用场景',
47
+ quickFacts: '快速信息',
48
+ params: '参数',
49
+ required: '必填',
50
+ links: '链接',
51
+ path: '路径',
52
+ triggers: '触发词',
53
+ updated: '更新于',
54
+ loading: '正在加载技能...',
55
+ reload: '重新扫描',
56
+ reloading: '正在重新扫描...',
57
+ reloadDone: '已重新扫描',
58
+ lastReload: '最后扫描',
59
+ liveConnected: '自动刷新已连接',
60
+ copied: '已复制',
61
+ opened: '已打开',
62
+ bytes: '字节',
63
+ language: '语言',
64
+ chinese: '中文',
65
+ english: 'English',
66
+ },
67
+ en: {
68
+ appSubtitle: 'local skill hub',
69
+ skillsLoaded: 'skills loaded',
70
+ source: 'Source',
71
+ editor: 'Editor',
72
+ kind: 'Kind',
73
+ product: 'Product',
74
+ category: 'Category',
75
+ brand: 'Brand',
76
+ filters: 'Filters',
77
+ active: 'active',
78
+ selectedEditor: 'Selected editor',
79
+ frontSelect: 'selected first',
80
+ rootKind: 'Root',
81
+ relativePath: 'Relative path',
82
+ fileSize: 'File size',
83
+ redacted: 'redacted',
84
+ parseError: 'parse error',
85
+ sort: 'Sort',
86
+ sortDefault: 'Default',
87
+ sortName: 'Name',
88
+ sortUpdated: 'Recently updated',
89
+ sortKind: 'Kind',
90
+ sortSource: 'Source',
91
+ sortProduct: 'Product',
92
+ all: 'All',
93
+ searchPlaceholder: 'Search name / description / editor / path...',
94
+ searchHint: 'Supports kind:mcp / source:mcp-config / product:xxx',
95
+ clear: 'Clear',
96
+ noFilters: 'no filters',
97
+ items: 'items',
98
+ uncategorized: 'uncategorized',
99
+ copyPath: 'Copy path',
100
+ copyDir: 'Copy folder',
101
+ copyRel: 'Copy relative path',
102
+ copyPrompt: 'Copy prompt',
103
+ copyName: 'Copy name',
104
+ copyRaw: 'Copy raw',
105
+ openCursor: 'Open Cursor',
106
+ reveal: 'Reveal',
107
+ usage: 'Usage',
108
+ howToUse: 'How to use',
109
+ appliesTo: 'Applies to',
110
+ quickFacts: 'Quick facts',
111
+ params: 'Params',
112
+ required: 'required',
113
+ links: 'Links',
114
+ path: 'path',
115
+ triggers: 'triggers',
116
+ updated: 'updated',
117
+ loading: 'Loading skills...',
118
+ reload: 'Reload',
119
+ reloading: 'Reloading...',
120
+ reloadDone: 'Reloaded',
121
+ lastReload: 'Last reload',
122
+ liveConnected: 'live reload connected',
123
+ copied: 'Copied',
124
+ opened: 'Opened',
125
+ bytes: 'bytes',
126
+ language: 'Language',
127
+ chinese: '中文',
128
+ english: 'English',
129
+ },
130
+ };
131
+
132
+ export const useI18nStore = defineStore('i18n', {
133
+ state: () => ({
134
+ locale: localStorage.getItem('huhaa-locale') || 'zh',
135
+ }),
136
+ getters: {
137
+ t: (state) => (key) => messages[state.locale]?.[key] || messages.zh[key] || key,
138
+ },
139
+ actions: {
140
+ setLocale(locale) {
141
+ this.locale = locale === 'en' ? 'en' : 'zh';
142
+ localStorage.setItem('huhaa-locale', this.locale);
143
+ document.documentElement.lang = this.locale === 'zh' ? 'zh-CN' : 'en';
144
+ },
145
+ toggle() {
146
+ this.setLocale(this.locale === 'zh' ? 'en' : 'zh');
147
+ },
148
+ },
149
+ });