llms-py 3.0.20__py3-none-any.whl → 3.0.22__py3-none-any.whl

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 (28) hide show
  1. llms/extensions/computer/__init__.py +16 -8
  2. llms/extensions/gallery/ui/index.mjs +2 -1
  3. llms/extensions/providers/openrouter.py +1 -1
  4. llms/extensions/skills/README.md +275 -0
  5. llms/extensions/skills/__init__.py +362 -3
  6. llms/extensions/skills/installer.py +415 -0
  7. llms/extensions/skills/ui/data/skills-top-5000.json +1 -0
  8. llms/extensions/skills/ui/index.mjs +572 -4
  9. llms/extensions/skills/ui/skills/create-plan/SKILL.md +6 -6
  10. llms/extensions/skills/ui/skills/skill-creator/LICENSE.txt +202 -0
  11. llms/extensions/skills/ui/skills/skill-creator/SKILL.md +356 -0
  12. llms/extensions/skills/ui/skills/skill-creator/references/output-patterns.md +82 -0
  13. llms/extensions/skills/ui/skills/skill-creator/references/workflows.md +28 -0
  14. llms/extensions/skills/ui/skills/skill-creator/scripts/init_skill.py +299 -0
  15. llms/extensions/skills/ui/skills/skill-creator/scripts/package_skill.py +111 -0
  16. llms/extensions/skills/ui/skills/skill-creator/scripts/quick_validate.py +98 -0
  17. llms/llms.json +15 -15
  18. llms/main.py +6 -5
  19. llms/providers.json +1 -1
  20. llms/ui/ai.mjs +1 -1
  21. llms/ui/app.css +55 -0
  22. llms/ui/ctx.mjs +6 -7
  23. {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/METADATA +1 -1
  24. {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/RECORD +28 -18
  25. {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/WHEEL +0 -0
  26. {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/entry_points.txt +0 -0
  27. {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/licenses/LICENSE +0 -0
  28. {llms_py-3.0.20.dist-info → llms_py-3.0.22.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,4 @@
1
- import { ref, inject, computed } from "vue"
1
+ import { ref, inject, computed, nextTick } from "vue"
2
2
  import { leftPart } from "@servicestack/client"
3
3
 
4
4
  let ext
@@ -119,8 +119,13 @@ const SkillSelector = {
119
119
  skills
120
120
  }))
121
121
 
122
- // Sort groups by name if needed, but for now rely on insertion order or backend order
123
- definedGroups.sort((a, b) => a.name.localeCompare(b.name))
122
+ // Sort groups: writable (~/.llms/.agents) first, then alphabetically
123
+ definedGroups.sort((a, b) => {
124
+ const aEditable = a.name === '~/.llms/.agents'
125
+ const bEditable = b.name === '~/.llms/.agents'
126
+ if (aEditable !== bEditable) return aEditable ? -1 : 1
127
+ return a.name.localeCompare(b.name)
128
+ })
124
129
 
125
130
  if (otherSkills.length > 0) {
126
131
  definedGroups.push({ name: '', skills: otherSkills })
@@ -213,6 +218,559 @@ const SkillSelector = {
213
218
  }
214
219
  }
215
220
 
221
+ // Skills Page Component - Full management interface
222
+ const SkillPage = {
223
+ template: `
224
+ <div class="h-full flex flex-col">
225
+ <div class="px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-shrink-0">
226
+ <div>
227
+ <h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Manage Skills</h1>
228
+ <p class="text-sm text-gray-500 dark:text-gray-400">{{ Object.keys(skills).length }} skills available</p>
229
+ </div>
230
+ <div class="flex items-center gap-2">
231
+ <button @click="showCreateDialog = true" type="button" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors">
232
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /></svg>
233
+ Create Skill
234
+ </button>
235
+ <button @click="$ctx.togglePath('/skills/store', { left:false })" type="button" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
236
+ <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M18.319 14.433A8.001 8.001 0 0 0 6.343 3.868a8 8 0 0 0 10.564 11.976l.043.045l4.242 4.243a1 1 0 1 0 1.415-1.415l-4.243-4.242zm-2.076-9.15a6 6 0 1 1-8.485 8.485a6 6 0 0 1 8.485-8.485" clip-rule="evenodd"/></svg>
237
+ Discover Skills
238
+ </button>
239
+ </div>
240
+ </div>
241
+ <div class="flex-1 flex min-h-0">
242
+ <div class="w-72 border-r border-gray-200 dark:border-gray-700 flex flex-col bg-gray-50 dark:bg-gray-800/50 flex-shrink-0">
243
+ <div class="p-2 border-b border-gray-200 dark:border-gray-700">
244
+ <input v-model="searchQuery" type="text" placeholder="Search installed skills..." class="w-full px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500" />
245
+ </div>
246
+ <div class="flex-1 overflow-y-auto">
247
+ <div v-for="group in skillGroups" :key="group.name" class="border-b border-gray-200 dark:border-gray-700 last:border-b-0">
248
+ <div class="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-100 dark:bg-gray-800 flex items-center justify-between"><span>{{ group.name || 'Other' }}</span><svg v-if="!isGroupEditable(group.name)" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" title="Read-only"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg></div>
249
+ <div class="py-1">
250
+ <div v-for="skill in group.skills" :key="skill.name">
251
+ <div @click="toggleSkillExpand(skill)" class="select-none w-full px-3 py-2 text-left text-sm transition-colors flex items-center gap-2 cursor-pointer" :class="selectedSkill?.name === skill.name ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-100 dark:hover:bg-gray-700'">
252
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-gray-400 transition-transform flex-shrink-0" :class="{ '-rotate-90': !isSkillExpanded(skill.name) }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" /></svg>
253
+ <span class="truncate font-medium flex-1" :class="selectedSkill?.name === skill.name ? 'text-blue-800 dark:text-blue-200' : 'text-gray-700 dark:text-gray-300'">{{ skill.name }}</span>
254
+ <span v-if="skill.files?.length" class="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300 font-medium">{{ skill.files.length }}</span>
255
+ </div>
256
+ <div v-show="isSkillExpanded(skill.name)" class="pl-4 bg-white dark:bg-gray-900/50">
257
+ <div v-if="isEditable(skill)" class="px-3 py-1 flex items-center gap-1 border-b border-gray-100 dark:border-gray-800">
258
+ <button @click.stop="selectSkill(skill); showAddFileDialog = true" type="button" title="Add File" class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-400 hover:text-gray-600 text-xs">+ file</button>
259
+ <button @click.stop="selectSkill(skill); confirmDeleteSkill()" type="button" title="Delete Skill" class="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-400 hover:text-red-500 text-xs ml-auto">delete</button>
260
+ </div>
261
+ <div v-for="node in getFileTree(skill)" :key="node.path">
262
+ <SkillFileNode :node="node" :skill="skill" :selected-file="selectedSkill?.name === skill.name ? selectedFile : null" :is-editable="isEditable(skill)" @select="onFileSelect(skill, $event)" @delete="onFileDelete(skill, $event)" />
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ <div class="flex-1 flex flex-col min-w-0">
271
+ <template v-if="selectedFile">
272
+ <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between bg-gray-50 dark:bg-gray-800">
273
+ <div class="flex items-center gap-2 min-w-0">
274
+ <span class="text-xs text-gray-500 dark:text-gray-400">{{ selectedSkill?.name }} /</span>
275
+ <span class="text-sm font-mono text-gray-700 dark:text-gray-300 truncate">{{ selectedFile }}</span>
276
+ <span v-if="isEditing" class="text-xs px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300">editing</span>
277
+ <span v-if="hasUnsavedChanges" class="text-xs text-orange-500">•</span>
278
+ </div>
279
+ <div class="flex items-center gap-2">
280
+ <template v-if="isEditing">
281
+ <button @click="saveFile" :disabled="saving" type="button" class="px-3 py-1 text-xs font-medium rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">{{ saving ? 'Saving...' : 'Save' }}</button>
282
+ <button @click="cancelEdit" type="button" class="px-3 py-1 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
283
+ </template>
284
+ <template v-else-if="isEditable(selectedSkill)">
285
+ <button @click="startEdit" type="button" class="px-3 py-1 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Edit</button>
286
+ </template>
287
+ </div>
288
+ </div>
289
+ <div class="flex-1 overflow-auto">
290
+ <div v-if="loadingFile" class="flex items-center justify-center h-full text-gray-500">Loading...</div>
291
+ <textarea v-else-if="isEditing" ref="editorRef" v-model="editContent" class="w-full h-full p-4 font-mono text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 resize-none focus:outline-none" spellcheck="false"></textarea>
292
+ <div v-else class="p-4 font-mono text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">{{ fileContent }}</div>
293
+ </div>
294
+ </template>
295
+ <template v-else-if="selectedSkill">
296
+ <div class="p-6">
297
+ <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">{{ selectedSkill.name }}</h2>
298
+ <p class="text-gray-600 dark:text-gray-400 mb-4">{{ selectedSkill.description }}</p>
299
+ <div class="grid grid-cols-2 gap-4 text-sm">
300
+ <div><span class="text-gray-500 dark:text-gray-400">Group:</span><span class="ml-2 text-gray-900 dark:text-gray-100">{{ selectedSkill.group }}</span></div>
301
+ <div><span class="text-gray-500 dark:text-gray-400">Files:</span><span class="ml-2 text-gray-900 dark:text-gray-100">{{ selectedSkill.files?.length || 0 }}</span></div>
302
+ <div class="col-span-2"><span class="text-gray-500 dark:text-gray-400">Location:</span><span class="ml-2 font-mono text-xs text-gray-900 dark:text-gray-100 break-all">{{ selectedSkill.location }}</span></div>
303
+ </div>
304
+ <div class="mt-6"><p class="text-sm text-gray-500 dark:text-gray-400">Select a file from the tree to view or edit its contents.</p></div>
305
+ </div>
306
+ </template>
307
+ <template v-else>
308
+ <div class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
309
+ <div class="text-center">
310
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" /></svg>
311
+ <p>Select a skill to view its files</p>
312
+ </div>
313
+ </div>
314
+ </template>
315
+ </div>
316
+ </div>
317
+ <div v-if="showCreateDialog" class="fixed inset-0 z-100 flex items-center justify-center bg-black/50" @click.self="showCreateDialog = false">
318
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
319
+ <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Create New Skill</h3></div>
320
+ <div class="p-4 space-y-4">
321
+ <div>
322
+ <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Skill Name</label>
323
+ <input :value="newSkillName" @input="onSkillNameInput" type="text" placeholder="my-new-skill" class="w-full px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500" @keyup.enter="createSkill" maxlength="40" />
324
+ <p class="mt-1 text-xs text-gray-500">Lowercase letters, numbers, and hyphens only. Max 40 characters.</p>
325
+ </div>
326
+ <div v-if="createError" class="p-3 rounded-md bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">{{ createError }}</div>
327
+ </div>
328
+ <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
329
+ <button @click="showCreateDialog = false" type="button" class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
330
+ <button @click="createSkill" :disabled="creating || !newSkillName.trim()" type="button" class="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">{{ creating ? 'Creating...' : 'Create' }}</button>
331
+ </div>
332
+ </div>
333
+ </div>
334
+ <div v-if="showAddFileDialog" class="fixed inset-0 z-100 flex items-center justify-center bg-black/50" @click.self="showAddFileDialog = false">
335
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
336
+ <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Add New File</h3></div>
337
+ <div class="p-4 space-y-4">
338
+ <div>
339
+ <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">File Path</label>
340
+ <input v-model="newFilePath" type="text" placeholder="scripts/my-script.py" class="w-full px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500" @keyup.enter="addFile" />
341
+ <p class="mt-1 text-xs text-gray-500">Relative path from skill root (e.g., scripts/helper.py)</p>
342
+ </div>
343
+ <div v-if="addFileError" class="p-3 rounded-md bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">{{ addFileError }}</div>
344
+ </div>
345
+ <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
346
+ <button @click="showAddFileDialog = false" type="button" class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
347
+ <button @click="addFile" :disabled="addingFile || !newFilePath.trim()" type="button" class="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">{{ addingFile ? 'Adding...' : 'Add' }}</button>
348
+ </div>
349
+ </div>
350
+ </div>
351
+ <div v-if="deleteConfirm" class="fixed inset-0 z-100 flex items-center justify-center bg-black/50" @click.self="deleteConfirm = null">
352
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm mx-4">
353
+ <div class="p-4">
354
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Confirm Delete</h3>
355
+ <p class="text-gray-600 dark:text-gray-400 text-sm">{{ deleteConfirm.type === 'skill' ? 'Delete skill "' + deleteConfirm.name + '"? This cannot be undone.' : 'Delete "' + deleteConfirm.path + '"?' }}</p>
356
+ </div>
357
+ <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
358
+ <button @click="deleteConfirm = null" type="button" class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
359
+ <button @click="executeDelete" :disabled="deleting" type="button" class="px-4 py-2 text-sm font-medium rounded-md bg-red-600 text-white hover:bg-red-700 disabled:opacity-50">{{ deleting ? 'Deleting...' : 'Delete' }}</button>
360
+ </div>
361
+ </div>
362
+ </div>
363
+ </div>
364
+ `,
365
+ setup() {
366
+ const ctx = inject('ctx')
367
+ const searchQuery = ref('')
368
+ const selectedSkill = ref(null)
369
+ const selectedFile = ref(null)
370
+ const fileContent = ref('')
371
+ const editContent = ref('')
372
+ const isEditing = ref(false)
373
+ const loadingFile = ref(false)
374
+ const saving = ref(false)
375
+ const showCreateDialog = ref(false)
376
+ const showAddFileDialog = ref(false)
377
+ const deleteConfirm = ref(null)
378
+ const newSkillName = ref('')
379
+ const creating = ref(false)
380
+ const createError = ref('')
381
+ const newFilePath = ref('')
382
+ const addingFile = ref(false)
383
+ const addFileError = ref('')
384
+ const deleting = ref(false)
385
+ const editorRef = ref(null)
386
+ const expandedSkills = ref({})
387
+ const skills = computed(() => ctx.state.skills || {})
388
+ const skillGroups = computed(() => {
389
+ const grouped = {}
390
+ const query = searchQuery.value.toLowerCase()
391
+ Object.values(skills.value).forEach(skill => {
392
+ if (query && !skill.name.toLowerCase().includes(query) && !skill.description?.toLowerCase().includes(query)) return
393
+ const group = skill.group || 'Other'
394
+ if (!grouped[group]) grouped[group] = []
395
+ grouped[group].push(skill)
396
+ })
397
+ return Object.entries(grouped).sort((a, b) => {
398
+ const aEditable = a[0] === '~/.llms/.agents'
399
+ const bEditable = b[0] === '~/.llms/.agents'
400
+ if (aEditable !== bEditable) return aEditable ? -1 : 1
401
+ return a[0].localeCompare(b[0])
402
+ }).map(([name, skills]) => ({ name, skills: skills.sort((a, b) => a.name.localeCompare(b.name)) }))
403
+ })
404
+ function getFileTree(skill) {
405
+ if (!skill?.files) return []
406
+ const files = [...skill.files].sort()
407
+ const tree = []
408
+ const dirs = {}
409
+ files.forEach(filePath => {
410
+ const parts = filePath.split('/')
411
+ if (parts.length === 1) {
412
+ tree.push({ name: filePath, path: filePath, isFile: true })
413
+ } else {
414
+ const dirName = parts[0]
415
+ if (!dirs[dirName]) { dirs[dirName] = { name: dirName, path: dirName, isFile: false, children: [] }; tree.push(dirs[dirName]) }
416
+ dirs[dirName].children.push({ name: parts.slice(1).join('/'), path: filePath, isFile: true })
417
+ }
418
+ })
419
+ return tree.sort((a, b) => { if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; return a.name.localeCompare(b.name) })
420
+ }
421
+ const hasUnsavedChanges = computed(() => isEditing.value && editContent.value !== fileContent.value)
422
+ function isGroupEditable(groupName) { return groupName === '~/.llms/.agents' }
423
+ function isEditable(skill) { return skill?.group === '~/.llms/.agents' }
424
+ function isSkillExpanded(name) { return !!expandedSkills.value[name] }
425
+ function toggleSkillExpand(skill) {
426
+ expandedSkills.value[skill.name] = !expandedSkills.value[skill.name]
427
+ if (expandedSkills.value[skill.name]) {
428
+ selectedSkill.value = skill
429
+ selectedFile.value = null
430
+ fileContent.value = ''
431
+ isEditing.value = false
432
+ }
433
+ }
434
+ function selectSkill(skill) {
435
+ if (hasUnsavedChanges.value && !confirm('Discard unsaved changes?')) return
436
+ selectedSkill.value = skill; selectedFile.value = null; fileContent.value = ''; isEditing.value = false
437
+ expandedSkills.value[skill.name] = true
438
+ }
439
+ async function selectFile(filePath) {
440
+ if (hasUnsavedChanges.value && !confirm('Discard unsaved changes?')) return
441
+ selectedFile.value = filePath; isEditing.value = false; loadingFile.value = true
442
+ try {
443
+ const res = await ext.getJson(`/file/${selectedSkill.value.name}/${filePath}`)
444
+ fileContent.value = res.response ? res.response.content : `Error: ${res.error?.message || 'Failed to load'}`
445
+ } catch (e) { fileContent.value = `Error: ${e.message}` }
446
+ finally { loadingFile.value = false }
447
+ }
448
+ function onFileSelect(skill, filePath) {
449
+ if (hasUnsavedChanges.value && !confirm('Discard unsaved changes?')) return
450
+ selectedSkill.value = skill
451
+ selectFile(filePath)
452
+ }
453
+ function onFileDelete(skill, filePath) {
454
+ selectedSkill.value = skill
455
+ confirmDeleteFile(filePath)
456
+ }
457
+ function startEdit() { editContent.value = fileContent.value; isEditing.value = true; nextTick(() => editorRef.value?.focus()) }
458
+ function cancelEdit() { if (hasUnsavedChanges.value && !confirm('Discard changes?')) return; isEditing.value = false; editContent.value = '' }
459
+ async function saveFile() {
460
+ saving.value = true
461
+ try {
462
+ const res = await ext.postJson(`/file/${selectedSkill.value.name}`, { path: selectedFile.value, content: editContent.value })
463
+ if (res.response) { fileContent.value = editContent.value; isEditing.value = false; if (res.response.skill) { ctx.setState({ skills: { ...skills.value, [res.response.skill.name]: res.response.skill } }); selectedSkill.value = res.response.skill } }
464
+ else { alert(`Error: ${res.error?.message || 'Unknown'}`) }
465
+ } catch (e) { alert(`Error: ${e.message}`) }
466
+ finally { saving.value = false }
467
+ }
468
+ function onSkillNameInput(e) {
469
+ // Sanitize to lowercase letters, numbers, and hyphens only
470
+ const sanitized = e.target.value.toLowerCase().replace(/[^a-z0-9-\s]/g, '').replace(/\s+/g, '-')
471
+ newSkillName.value = sanitized
472
+ // Update input value if sanitization changed it
473
+ if (e.target.value !== sanitized) {
474
+ e.target.value = sanitized
475
+ }
476
+ }
477
+ async function createSkill() {
478
+ createError.value = ''; creating.value = true
479
+ try {
480
+ const res = await ext.postJson('/create', { name: newSkillName.value.trim() })
481
+ if (res.response) {
482
+ ctx.setState({ skills: { ...skills.value, [res.response.skill.name]: res.response.skill } })
483
+ selectedSkill.value = res.response.skill
484
+ expandedSkills.value[res.response.skill.name] = true
485
+ showCreateDialog.value = false
486
+ newSkillName.value = ''
487
+ }
488
+ else { createError.value = res.error?.message || 'Failed' }
489
+ } catch (e) { createError.value = e.message }
490
+ finally { creating.value = false }
491
+ }
492
+ async function addFile() {
493
+ addFileError.value = ''; addingFile.value = true
494
+ try {
495
+ const res = await ext.postJson(`/file/${selectedSkill.value.name}`, { path: newFilePath.value.trim(), content: '' })
496
+ if (res.response) { if (res.response.skill) { ctx.setState({ skills: { ...skills.value, [res.response.skill.name]: res.response.skill } }); selectedSkill.value = res.response.skill }; selectedFile.value = newFilePath.value.trim(); fileContent.value = ''; showAddFileDialog.value = false; newFilePath.value = ''; startEdit() }
497
+ else { addFileError.value = res.error?.message || 'Failed' }
498
+ } catch (e) { addFileError.value = e.message }
499
+ finally { addingFile.value = false }
500
+ }
501
+ function confirmDeleteSkill() { deleteConfirm.value = { type: 'skill', name: selectedSkill.value.name } }
502
+ function confirmDeleteFile(filePath) { deleteConfirm.value = { type: 'file', path: filePath, skillName: selectedSkill.value.name } }
503
+ async function executeDelete() {
504
+ deleting.value = true
505
+ try {
506
+ if (deleteConfirm.value.type === 'skill') {
507
+ const res = await ext.deleteJson(`/skill/${deleteConfirm.value.name}`)
508
+ if (res.response?.deleted) { const s = { ...skills.value }; delete s[deleteConfirm.value.name]; ctx.setState({ skills: s }); selectedSkill.value = null; selectedFile.value = null; delete expandedSkills.value[deleteConfirm.value.name] }
509
+ else { alert(`Error: ${res.error?.message || 'Failed'}`) }
510
+ } else {
511
+ const res = await ext.deleteJson(`/file/${deleteConfirm.value.skillName}?path=${encodeURIComponent(deleteConfirm.value.path)}`)
512
+ if (res.response) { if (res.response.skill) { ctx.setState({ skills: { ...skills.value, [res.response.skill.name]: res.response.skill } }); selectedSkill.value = res.response.skill }; if (selectedFile.value === deleteConfirm.value.path) { selectedFile.value = null; fileContent.value = '' } }
513
+ else { alert(`Error: ${res.error?.message || 'Failed'}`) }
514
+ }
515
+ } catch (e) { alert(`Error: ${e.message}`) }
516
+ finally { deleting.value = false; deleteConfirm.value = null }
517
+ }
518
+ return { skills, searchQuery, skillGroups, selectedSkill, selectedFile, fileContent, editContent, isEditing, loadingFile, saving, hasUnsavedChanges, editorRef, showCreateDialog, showAddFileDialog, deleteConfirm, newSkillName, creating, createError, newFilePath, addingFile, addFileError, deleting, isEditable, isGroupEditable, selectSkill, selectFile, startEdit, cancelEdit, saveFile, createSkill, addFile, confirmDeleteSkill, confirmDeleteFile, executeDelete, expandedSkills, isSkillExpanded, toggleSkillExpand, getFileTree, onFileSelect, onFileDelete, onSkillNameInput }
519
+ }
520
+ }
521
+
522
+ const FileTreeNode = {
523
+ name: 'FileTreeNode',
524
+ template: `
525
+ <div>
526
+ <div v-if="node.isFile" @click="$emit('select', node.path)" class="group flex items-center gap-2 px-3 py-1 text-sm cursor-pointer transition-colors" :class="selectedFile === node.path ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'">
527
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
528
+ <span class="truncate flex-1">{{ node.name }}</span>
529
+ <button v-if="isEditable && node.path.toLowerCase() !== 'skill.md'" @click.stop="$emit('delete', node.path)" type="button" class="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-600">
530
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
531
+ </button>
532
+ </div>
533
+ <div v-else>
534
+ <div @click="expanded = !expanded" class="flex items-center gap-2 px-3 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700">
535
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400 transition-transform" :class="{ '-rotate-90': !expanded }" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
536
+ <span class="font-medium">{{ node.name }}/</span>
537
+ </div>
538
+ <div v-show="expanded" class="pl-4">
539
+ <FileTreeNode v-for="child in node.children" :key="child.path" :node="child" :selected-file="selectedFile" :is-editable="isEditable" @select="$emit('select', $event)" @delete="$emit('delete', $event)" />
540
+ </div>
541
+ </div>
542
+ </div>
543
+ `,
544
+ props: { node: { type: Object, required: true }, selectedFile: { type: String, default: null }, isEditable: { type: Boolean, default: false } },
545
+ emits: ['select', 'delete'],
546
+ setup() { return { expanded: ref(true) } }
547
+ }
548
+
549
+ const SkillFileNode = {
550
+ name: 'SkillFileNode',
551
+ template: `
552
+ <div>
553
+ <div v-if="node.isFile" @click="$emit('select', node.path)" class="group flex items-center gap-1.5 px-2 py-0.5 text-xs cursor-pointer transition-colors" :class="selectedFile === node.path ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'">
554
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
555
+ <span class="select-none truncate flex-1">{{ node.name }}</span>
556
+ <button v-if="isEditable && node.path.toLowerCase() !== 'skill.md'" @click.stop="$emit('delete', node.path)" type="button" class="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500">
557
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-2.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
558
+ </button>
559
+ </div>
560
+ <div v-else>
561
+ <div @click="expanded = !expanded" class="flex items-center gap-1.5 px-2 py-0.5 text-xs cursor-pointer text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700">
562
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 text-gray-400 transition-transform" :class="{ '-rotate-90': !expanded }" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
563
+ <span class="select-none font-medium">{{ node.name }}/</span>
564
+ </div>
565
+ <div v-show="expanded" class="pl-3">
566
+ <SkillFileNode v-for="child in node.children" :key="child.path" :node="child" :skill="skill" :selected-file="selectedFile" :is-editable="isEditable" @select="$emit('select', $event)" @delete="$emit('delete', $event)" />
567
+ </div>
568
+ </div>
569
+ </div>
570
+ `,
571
+ props: { node: { type: Object, required: true }, skill: { type: Object, required: true }, selectedFile: { type: String, default: null }, isEditable: { type: Boolean, default: false } },
572
+ emits: ['select', 'delete'],
573
+ setup() { return { expanded: ref(true) } }
574
+ }
575
+
576
+ // Skill Store Component - Search and install available skills
577
+ const SkillStore = {
578
+ template: `
579
+ <div class="h-full flex flex-col">
580
+ <div class="px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-shrink-0">
581
+ <div>
582
+ <h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Skill Store</h1>
583
+ <p class="text-sm text-gray-500 dark:text-gray-400">{{ total.toLocaleString() }} skills available</p>
584
+ </div>
585
+ <div class="flex items-center gap-2">
586
+ <button @click="$ctx.togglePath('/skills', { left:false })" type="button" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
587
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" /></svg>
588
+ Installed Skills
589
+ </button>
590
+ </div>
591
+ </div>
592
+ <div class="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
593
+ <div class="relative">
594
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
595
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
596
+ </svg>
597
+ <input v-model="searchQuery" @input="onSearchInput" type="text" placeholder="Search available skills..."
598
+ class="w-full pl-10 pr-4 py-2.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
599
+ <div v-if="searching" class="absolute right-3 top-1/2 -translate-y-1/2">
600
+ <svg class="animate-spin h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
601
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
602
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
603
+ </svg>
604
+ </div>
605
+ </div>
606
+ </div>
607
+ <div class="flex-1 overflow-y-auto">
608
+ <div v-if="results.length === 0 && !searching" class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
609
+ <div class="text-center">
610
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
611
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
612
+ </svg>
613
+ <p>{{ searchQuery ? 'No skills found' : 'Search for skills to install' }}</p>
614
+ </div>
615
+ </div>
616
+ <div v-else class="divide-y divide-gray-200 dark:divide-gray-700">
617
+ <div v-for="skill in results" :key="skill.id" class="p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
618
+ <div class="flex items-start justify-between gap-4">
619
+ <div class="min-w-0 flex-1">
620
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{{ skill.name }}</h3>
621
+ <div class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
622
+ <span class="inline-flex items-center gap-1">
623
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
624
+ <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
625
+ </svg>
626
+ {{ formatInstalls(skill.installs) }}
627
+ </span>
628
+ <span class="inline-flex items-center gap-1 truncate" :title="skill.topSource">
629
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
630
+ <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
631
+ </svg>
632
+ {{ skill.topSource }}
633
+ </span>
634
+ </div>
635
+ </div>
636
+ <div class="flex-shrink-0">
637
+ <button v-if="isInstalled(skill.id)" disabled type="button"
638
+ class="px-3 py-1.5 text-xs font-medium rounded-md bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed">
639
+ Installed
640
+ </button>
641
+ <button v-else-if="installing.has(skill.id)" disabled type="button"
642
+ class="px-3 py-1.5 text-xs font-medium rounded-md bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 cursor-wait inline-flex items-center gap-1.5">
643
+ <svg class="animate-spin h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
644
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
645
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
646
+ </svg>
647
+ Installing...
648
+ </button>
649
+ <button v-else @click="installSkill(skill)" type="button"
650
+ class="px-3 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors">
651
+ Install
652
+ </button>
653
+ </div>
654
+ </div>
655
+ <div v-if="installError[skill.id]" class="mt-2 text-xs text-red-600 dark:text-red-400">
656
+ {{ installError[skill.id] }}
657
+ </div>
658
+ </div>
659
+ </div>
660
+ <div v-if="results.length > 0 && results.length < total" class="p-4 flex justify-center">
661
+ <button @click="loadMore" :disabled="searching" type="button"
662
+ class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50">
663
+ {{ searching ? 'Loading...' : 'Load More' }}
664
+ </button>
665
+ </div>
666
+ </div>
667
+ </div>
668
+ `,
669
+ setup() {
670
+ const ctx = inject('ctx')
671
+ const searchQuery = ref('')
672
+ const results = ref([])
673
+ const total = ref(0)
674
+ const searching = ref(false)
675
+ const installing = ref(new Set())
676
+ const installError = ref({})
677
+ const offset = ref(0)
678
+ const limit = 50
679
+ let searchTimeout = null
680
+
681
+ const installedSkills = computed(() => ctx.state.skills || {})
682
+
683
+ function isInstalled(skillId) {
684
+ // Check if skill is already installed by comparing id/name
685
+ return Object.values(installedSkills.value).some(s =>
686
+ s.name === skillId || s.name === skillId.replace(/-/g, ' ')
687
+ )
688
+ }
689
+
690
+ function formatInstalls(count) {
691
+ if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M'
692
+ if (count >= 1000) return (count / 1000).toFixed(1) + 'k'
693
+ return count.toString()
694
+ }
695
+
696
+ async function search(append = false) {
697
+ searching.value = true
698
+ try {
699
+ const params = new URLSearchParams({
700
+ q: searchQuery.value,
701
+ limit: limit.toString(),
702
+ offset: (append ? offset.value : 0).toString()
703
+ })
704
+ const res = await ext.getJson(`/search?${params}`)
705
+ if (res.response) {
706
+ if (append) {
707
+ results.value = [...results.value, ...res.response.results]
708
+ } else {
709
+ results.value = res.response.results
710
+ offset.value = 0
711
+ }
712
+ total.value = res.response.total
713
+ offset.value = results.value.length
714
+ }
715
+ } catch (e) {
716
+ console.error('Search failed:', e)
717
+ } finally {
718
+ searching.value = false
719
+ }
720
+ }
721
+
722
+ function onSearchInput() {
723
+ if (searchTimeout) clearTimeout(searchTimeout)
724
+ searchTimeout = setTimeout(() => search(false), 300)
725
+ }
726
+
727
+ function loadMore() {
728
+ search(true)
729
+ }
730
+
731
+ async function installSkill(skill) {
732
+ installing.value = new Set([...installing.value, skill.id])
733
+ delete installError.value[skill.id]
734
+
735
+ try {
736
+ const res = await ext.postJson(`/install/${skill.id}`)
737
+ if (res.error) {
738
+ installError.value[skill.id] = res.error.message || 'Installation failed'
739
+ } else {
740
+ // Refresh installed skills
741
+ const api = await ext.getJson('/')
742
+ if (api.response) {
743
+ ctx.setState({ skills: api.response })
744
+ }
745
+ }
746
+ } catch (e) {
747
+ installError.value[skill.id] = e.message || 'Installation failed'
748
+ } finally {
749
+ const newSet = new Set(installing.value)
750
+ newSet.delete(skill.id)
751
+ installing.value = newSet
752
+ }
753
+ }
754
+
755
+ // Initial load - show popular skills
756
+ search(false)
757
+
758
+ return {
759
+ searchQuery,
760
+ results,
761
+ total,
762
+ searching,
763
+ installing,
764
+ installError,
765
+ isInstalled,
766
+ formatInstalls,
767
+ onSearchInput,
768
+ loadMore,
769
+ installSkill
770
+ }
771
+ }
772
+ }
773
+
216
774
  function codeFragment(s) {
217
775
  return "`" + s + "`"
218
776
  }
@@ -262,10 +820,20 @@ export default {
262
820
  install(ctx) {
263
821
  ext = ctx.scope("skills")
264
822
 
265
- ctx.components({ SkillSelector })
823
+ ctx.components({ SkillSelector, SkillPage, SkillStore, FileTreeNode, SkillFileNode })
266
824
 
267
825
  const svg = (attrs, title) => `<svg ${attrs} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${title ? "<title>" + title + "</title>" : ''}<path fill="currentColor" d="M20 17a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H9.46c.35.61.54 1.3.54 2h10v11h-9v2m4-10v2H9v13H7v-6H5v6H3v-8H1.5V9a2 2 0 0 1 2-2zM8 4a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2a2 2 0 0 1 2 2"/></svg>`
268
826
 
827
+ ctx.setLeftIcons({
828
+ skills: {
829
+ component: { template: svg([`@click="$ctx.togglePath('/skills', { left:false })"`].join(' ')) },
830
+ isActive({ path }) { return path === '/skills' }
831
+ }
832
+ })
833
+
834
+ ctx.routes.push({ path: '/skills', component: SkillPage, meta: { title: 'Manage Skills' } })
835
+ ctx.routes.push({ path: '/skills/store', component: SkillStore, meta: { title: 'Skill Store' } })
836
+
269
837
  ctx.setTopIcons({
270
838
  skills: {
271
839
  component: {
@@ -49,12 +49,12 @@ Throughout the entire workflow, operate in read-only mode. Do not write or updat
49
49
  - Out:
50
50
 
51
51
  ## Action items
52
- [ ] <Step 1>
53
- [ ] <Step 2>
54
- [ ] <Step 3>
55
- [ ] <Step 4>
56
- [ ] <Step 5>
57
- [ ] <Step 6>
52
+ - [ ] <Step 1>
53
+ - [ ] <Step 2>
54
+ - [ ] <Step 3>
55
+ - [ ] <Step 4>
56
+ - [ ] <Step 5>
57
+ - [ ] <Step 6>
58
58
 
59
59
  ## Open questions
60
60
  - <Question 1>