hlassiege/panopliepublic Fork
⑂ 77c51e8
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(ui): catalog editor, presets, scope configuration and sync review, git panel, marketplaces, settings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-23T12:31:04+02:00 Browse files
77c51e8 parent: 8d4155a
added packages/ui/app/app.config.ts +6 -0
new file mode 100644
@@ -0,0 +1,6 @@
1+export default defineAppConfig({
2+ ui: {
3+ // `secondary` is used for Codex so that agents never share a color with status badges (success/warning/info).
4+ colors: { secondary: 'violet' }
5+ }
6+})
new file mode 100644
@@ -0,0 +1,6 @@
1+export default defineAppConfig({
2+ ui: {
3+ // `secondary` is used for Codex so that agents never share a color with status badges (success/warning/info).
4+ colors: { secondary: 'violet' }
5+ }
6+})
modified packages/ui/app/components/AddProjectModal.vue +3 -85
@@ -1,38 +1,15 @@
11 <script setup lang="ts">
2-import type { FsListing } from '@skilly/core'
3-
42 const open = defineModel<boolean>('open', { default: false })
53
6-const { request } = useApi()
74 const { add } = useProjects()
85 const router = useRouter()
96 const toast = useToast()
107
11-const listing = ref<FsListing | null>(null)
12-const loading = ref(false)
13-const listError = ref<string | null>(null)
148 const pathInput = ref('')
159 const submitting = ref(false)
1610 const submitError = ref<string | null>(null)
17-const highlighted = ref<string | null>(null)
18-
1911 // The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked.
20-const selectedPath = computed(() => highlighted.value ?? listing.value?.path ?? null)
21-
22-const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : []))
23-
24-async function browse(path?: string) {
25- loading.value = true
26- listError.value = null
27- highlighted.value = null
28- try {
29- listing.value = await request<FsListing>('/api/fs/list', { query: { path }, silent: true })
30- } catch (e) {
31- listError.value = (e as Error).message
32- } finally {
33- loading.value = false
34- }
35-}
12+const selectedPath = ref<string | null>(null)
3613
3714 async function submit(path: string) {
3815 const p = path.trim()
@@ -52,16 +29,11 @@ async function submit(path: string) {
5229 }
5330 }
5431
55-function highlight(path: string) {
56- highlighted.value = path
57- pathInput.value = path
58-}
59-
32+watch(selectedPath, (p) => { if (p) pathInput.value = p })
6033 watch(open, (v) => {
6134 if (v) {
6235 pathInput.value = ''
6336 submitError.value = null
64- browse()
6537 }
6638 })
6739 </script>
@@ -75,7 +47,6 @@ watch(open, (v) => {
7547 >
7648 <template #body>
7749 <div class="space-y-4">
78- <!-- Paste a path -->
7950 <form class="flex gap-2" @submit.prevent="submit(pathInput)">
8051 <UInput
8152 v-model="pathInput"
@@ -89,60 +60,7 @@ watch(open, (v) => {
8960 </form>
9061 <UAlert v-if="submitError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="submitError" />
9162
92- <!-- Breadcrumb -->
93- <div class="flex flex-wrap items-center gap-1 text-sm">
94- <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" />
95- <template v-for="c in crumbs" :key="c.path">
96- <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" />
97- <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" />
98- </template>
99- </div>
100-
101- <!-- Folder list -->
102- <div class="h-72 overflow-y-auto rounded-lg border border-default">
103- <div v-if="loading" class="space-y-1 p-2">
104- <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" />
105- </div>
106- <div v-else-if="listError" class="p-3">
107- <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" />
108- </div>
109- <ul v-else-if="listing" class="divide-y divide-default text-sm">
110- <li v-if="listing.parent">
111- <button type="button" class="flex w-full items-center gap-2 px-3 py-2 hover:bg-elevated/60" @click="browse(listing.parent!)">
112- <UIcon name="i-lucide-corner-left-up" class="size-4 text-muted" />
113- <span class="text-muted">..</span>
114- </button>
115- </li>
116- <li
117- v-for="e in listing.entries"
118- :key="e.path"
119- class="flex items-center hover:bg-elevated/60"
120- :class="highlighted === e.path ? 'bg-primary/10' : ''"
121- >
122- <button
123- type="button"
124- class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left"
125- :title="e.path"
126- @click="highlight(e.path)"
127- @dblclick="browse(e.path)"
128- >
129- <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" />
130- <span class="flex-1 truncate">{{ e.name }}</span>
131- <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" />
132- </button>
133- <UButton
134- icon="i-lucide-chevron-right"
135- size="xs"
136- color="neutral"
137- variant="ghost"
138- class="mx-2 shrink-0"
139- :aria-label="`Open ${e.name}`"
140- @click="browse(e.path)"
141- />
142- </li>
143- <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li>
144- </ul>
145- </div>
63+ <FolderBrowser v-if="open" v-model:selected="selectedPath" />
14664 </div>
14765 </template>
14866
@@ -1,38 +1,15 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-import type { FsListing } from '@skilly/core'
3-
4 const open = defineModel<boolean>('open', { default: false })2 const open = defineModel<boolean>('open', { default: false })
5 3
6-const { request } = useApi()
7 const { add } = useProjects()4 const { add } = useProjects()
8 const router = useRouter()5 const router = useRouter()
9 const toast = useToast()6 const toast = useToast()
10 7
11-const listing = ref<FsListing | null>(null)
12-const loading = ref(false)
13-const listError = ref<string | null>(null)
14 const pathInput = ref('')8 const pathInput = ref('')
15 const submitting = ref(false)9 const submitting = ref(false)
16 const submitError = ref<string | null>(null)10 const submitError = ref<string | null>(null)
17-const highlighted = ref<string | null>(null)
18-
19 // The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked.11 // The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked.
20-const selectedPath = computed(() => highlighted.value ?? listing.value?.path ?? null)12+const selectedPath = ref<string | null>(null)
21-
22-const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : []))
23-
24-async function browse(path?: string) {
25- loading.value = true
26- listError.value = null
27- highlighted.value = null
28- try {
29- listing.value = await request<FsListing>('/api/fs/list', { query: { path }, silent: true })
30- } catch (e) {
31- listError.value = (e as Error).message
32- } finally {
33- loading.value = false
34- }
35-}
36 13
37 async function submit(path: string) {14 async function submit(path: string) {
38 const p = path.trim()15 const p = path.trim()
@@ -52,16 +29,11 @@ async function submit(path: string) {
52 }29 }
53 }30 }
54 31
55-function highlight(path: string) {32+watch(selectedPath, (p) => { if (p) pathInput.value = p })
56- highlighted.value = path
57- pathInput.value = path
58-}
59-
60 watch(open, (v) => {33 watch(open, (v) => {
61 if (v) {34 if (v) {
62 pathInput.value = ''35 pathInput.value = ''
63 submitError.value = null36 submitError.value = null
64- browse()
65 }37 }
66 })38 })
67 </script>39 </script>
@@ -75,7 +47,6 @@ watch(open, (v) => {
75 >47 >
76 <template #body>48 <template #body>
77 <div class="space-y-4">49 <div class="space-y-4">
78- <!-- Paste a path -->
79 <form class="flex gap-2" @submit.prevent="submit(pathInput)">50 <form class="flex gap-2" @submit.prevent="submit(pathInput)">
80 <UInput51 <UInput
81 v-model="pathInput"52 v-model="pathInput"
@@ -89,60 +60,7 @@ watch(open, (v) => {
89 </form>60 </form>
90 <UAlert v-if="submitError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="submitError" />61 <UAlert v-if="submitError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="submitError" />
91 62
92- <!-- Breadcrumb -->63+ <FolderBrowser v-if="open" v-model:selected="selectedPath" />
93- <div class="flex flex-wrap items-center gap-1 text-sm">
94- <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" />
95- <template v-for="c in crumbs" :key="c.path">
96- <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" />
97- <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" />
98- </template>
99- </div>
100-
101- <!-- Folder list -->
102- <div class="h-72 overflow-y-auto rounded-lg border border-default">
103- <div v-if="loading" class="space-y-1 p-2">
104- <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" />
105- </div>
106- <div v-else-if="listError" class="p-3">
107- <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" />
108- </div>
109- <ul v-else-if="listing" class="divide-y divide-default text-sm">
110- <li v-if="listing.parent">
111- <button type="button" class="flex w-full items-center gap-2 px-3 py-2 hover:bg-elevated/60" @click="browse(listing.parent!)">
112- <UIcon name="i-lucide-corner-left-up" class="size-4 text-muted" />
113- <span class="text-muted">..</span>
114- </button>
115- </li>
116- <li
117- v-for="e in listing.entries"
118- :key="e.path"
119- class="flex items-center hover:bg-elevated/60"
120- :class="highlighted === e.path ? 'bg-primary/10' : ''"
121- >
122- <button
123- type="button"
124- class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left"
125- :title="e.path"
126- @click="highlight(e.path)"
127- @dblclick="browse(e.path)"
128- >
129- <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" />
130- <span class="flex-1 truncate">{{ e.name }}</span>
131- <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" />
132- </button>
133- <UButton
134- icon="i-lucide-chevron-right"
135- size="xs"
136- color="neutral"
137- variant="ghost"
138- class="mx-2 shrink-0"
139- :aria-label="`Open ${e.name}`"
140- @click="browse(e.path)"
141- />
142- </li>
143- <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li>
144- </ul>
145- </div>
146 </div>64 </div>
147 </template>65 </template>
148 66
added packages/ui/app/components/AddToCatalogModal.vue +90 -0
new file mode 100644
@@ -0,0 +1,90 @@
1+<script setup lang="ts">
2+import type { ImportResult, InventoryItem, ItemStatus } from '@skilly/core'
3+
4+const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>()
5+const open = defineModel<boolean>('open', { default: false })
6+const emit = defineEmits<{ done: [] }>()
7+
8+const { request } = useApi()
9+const toast = useToast()
10+
11+const tags = ref<string[]>([])
12+const overwrite = ref(false)
13+const busy = ref(false)
14+const result = ref<ImportResult | null>(null)
15+const error = ref<string | null>(null)
16+
17+const differing = computed(() => props.items.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog'))
18+const toSend = computed(() => props.items.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog'))
19+
20+watch(open, (v) => {
21+ if (v) {
22+ tags.value = []
23+ overwrite.value = false
24+ result.value = null
25+ error.value = null
26+ }
27+})
28+
29+async function submit() {
30+ busy.value = true
31+ error.value = null
32+ try {
33+ result.value = await request<ImportResult>('/api/catalog/import', {
34+ method: 'POST',
35+ body: { scope: props.scope, itemIds: toSend.value.map(i => i.id), overwrite: overwrite.value, ...(tags.value.length ? { tags: tags.value } : {}) },
36+ silent: true
37+ })
38+ toast.add({ title: `${result.value.imported.length} added to the catalog`, icon: 'i-lucide-check', color: 'success' })
39+ emit('done')
40+ } catch (e) {
41+ error.value = (e as Error).message
42+ } finally {
43+ busy.value = false
44+ }
45+}
46+</script>
47+
48+<template>
49+ <UModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects." :ui="{ content: 'max-w-xl' }">
50+ <template #body>
51+ <div v-if="result" class="space-y-3 text-sm">
52+ <UAlert v-if="result.imported.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.imported.length} item(s) added`">
53+ <template #description>
54+ <ul class="list-disc pl-4"><li v-for="i in result.imported" :key="`${i.type}/${i.name}`">{{ CAT_META[i.type].singular }} {{ i.name }}</li></ul>
55+ </template>
56+ </UAlert>
57+ <UAlert v-if="result.skipped.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`${result.skipped.length} skipped`">
58+ <template #description>
59+ <ul class="list-disc pl-4"><li v-for="s in result.skipped" :key="s.id"><span class="font-mono text-xs">{{ s.id }}</span>: {{ s.reason }}</li></ul>
60+ </template>
61+ </UAlert>
62+ </div>
63+ <div v-else class="space-y-4">
64+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Import failed" :description="error" />
65+ <ul class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm">
66+ <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1.5">
67+ <AgentBadge :agent="i.agent" compact />
68+ <span class="truncate font-medium">{{ i.name }}</span>
69+ <span class="text-xs text-muted">{{ i.type }}</span>
70+ <UBadge v-if="statuses[i.id]?.state === 'differs-from-catalog'" label="differs from catalog" size="sm" color="warning" variant="subtle" class="ml-auto" />
71+ </li>
72+ </ul>
73+ <UAlert v-if="differing.length" color="warning" variant="subtle" icon="i-lucide-git-compare" :title="`${differing.length} item(s) already exist in the catalog with different content`">
74+ <template #description>
75+ <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-1" />
76+ </template>
77+ </UAlert>
78+ <UFormField label="Tags" description="Optional, applied to the imported items.">
79+ <UInputTags v-model="tags" class="w-full" placeholder="Add a tag, press Enter" />
80+ </UFormField>
81+ </div>
82+ </template>
83+ <template #footer>
84+ <div class="flex w-full justify-end gap-2">
85+ <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" />
86+ <UButton v-if="!result" :label="`Add ${toSend.length} to catalog`" icon="i-lucide-library" :loading="busy" :disabled="!toSend.length" @click="submit" />
87+ </div>
88+ </template>
89+ </UModal>
90+</template>
new file mode 100644
@@ -0,0 +1,90 @@
1+<script setup lang="ts">
2+import type { ImportResult, InventoryItem, ItemStatus } from '@skilly/core'
3+
4+const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>()
5+const open = defineModel<boolean>('open', { default: false })
6+const emit = defineEmits<{ done: [] }>()
7+
8+const { request } = useApi()
9+const toast = useToast()
10+
11+const tags = ref<string[]>([])
12+const overwrite = ref(false)
13+const busy = ref(false)
14+const result = ref<ImportResult | null>(null)
15+const error = ref<string | null>(null)
16+
17+const differing = computed(() => props.items.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog'))
18+const toSend = computed(() => props.items.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog'))
19+
20+watch(open, (v) => {
21+ if (v) {
22+ tags.value = []
23+ overwrite.value = false
24+ result.value = null
25+ error.value = null
26+ }
27+})
28+
29+async function submit() {
30+ busy.value = true
31+ error.value = null
32+ try {
33+ result.value = await request<ImportResult>('/api/catalog/import', {
34+ method: 'POST',
35+ body: { scope: props.scope, itemIds: toSend.value.map(i => i.id), overwrite: overwrite.value, ...(tags.value.length ? { tags: tags.value } : {}) },
36+ silent: true
37+ })
38+ toast.add({ title: `${result.value.imported.length} added to the catalog`, icon: 'i-lucide-check', color: 'success' })
39+ emit('done')
40+ } catch (e) {
41+ error.value = (e as Error).message
42+ } finally {
43+ busy.value = false
44+ }
45+}
46+</script>
47+
48+<template>
49+ <UModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects." :ui="{ content: 'max-w-xl' }">
50+ <template #body>
51+ <div v-if="result" class="space-y-3 text-sm">
52+ <UAlert v-if="result.imported.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.imported.length} item(s) added`">
53+ <template #description>
54+ <ul class="list-disc pl-4"><li v-for="i in result.imported" :key="`${i.type}/${i.name}`">{{ CAT_META[i.type].singular }} {{ i.name }}</li></ul>
55+ </template>
56+ </UAlert>
57+ <UAlert v-if="result.skipped.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`${result.skipped.length} skipped`">
58+ <template #description>
59+ <ul class="list-disc pl-4"><li v-for="s in result.skipped" :key="s.id"><span class="font-mono text-xs">{{ s.id }}</span>: {{ s.reason }}</li></ul>
60+ </template>
61+ </UAlert>
62+ </div>
63+ <div v-else class="space-y-4">
64+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Import failed" :description="error" />
65+ <ul class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm">
66+ <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1.5">
67+ <AgentBadge :agent="i.agent" compact />
68+ <span class="truncate font-medium">{{ i.name }}</span>
69+ <span class="text-xs text-muted">{{ i.type }}</span>
70+ <UBadge v-if="statuses[i.id]?.state === 'differs-from-catalog'" label="differs from catalog" size="sm" color="warning" variant="subtle" class="ml-auto" />
71+ </li>
72+ </ul>
73+ <UAlert v-if="differing.length" color="warning" variant="subtle" icon="i-lucide-git-compare" :title="`${differing.length} item(s) already exist in the catalog with different content`">
74+ <template #description>
75+ <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-1" />
76+ </template>
77+ </UAlert>
78+ <UFormField label="Tags" description="Optional, applied to the imported items.">
79+ <UInputTags v-model="tags" class="w-full" placeholder="Add a tag, press Enter" />
80+ </UFormField>
81+ </div>
82+ </template>
83+ <template #footer>
84+ <div class="flex w-full justify-end gap-2">
85+ <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" />
86+ <UButton v-if="!result" :label="`Add ${toSend.length} to catalog`" icon="i-lucide-library" :loading="busy" :disabled="!toSend.length" @click="submit" />
87+ </div>
88+ </template>
89+ </UModal>
90+</template>
modified packages/ui/app/components/AgentBadge.vue +1 -1
@@ -6,7 +6,7 @@ withDefaults(defineProps<{ agent: AgentId, compact?: boolean }>(), { compact: fa
66
77 <template>
88 <UBadge
9- :color="agent === 'claude' ? 'warning' : 'success'"
9+ :color="agent === 'claude' ? 'warning' : 'secondary'"
1010 variant="subtle"
1111 :size="compact ? 'sm' : 'md'"
1212 :icon="agent === 'claude' ? 'i-lucide-asterisk' : 'i-lucide-terminal'"
@@ -6,7 +6,7 @@ withDefaults(defineProps<{ agent: AgentId, compact?: boolean }>(), { compact: fa
6 6
7 <template>7 <template>
8 <UBadge8 <UBadge
9- :color="agent === 'claude' ? 'warning' : 'success'"9+ :color="agent === 'claude' ? 'warning' : 'secondary'"
10 variant="subtle"10 variant="subtle"
11 :size="compact ? 'sm' : 'md'"11 :size="compact ? 'sm' : 'md'"
12 :icon="agent === 'claude' ? 'i-lucide-asterisk' : 'i-lucide-terminal'"12 :icon="agent === 'claude' ? 'i-lucide-asterisk' : 'i-lucide-terminal'"
modified packages/ui/app/components/AppSidebar.vue +17 -15
@@ -7,11 +7,12 @@ const route = useRoute()
77 const linkClass = (active: boolean) =>
88 `flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${active ? 'bg-elevated text-highlighted font-medium' : 'text-muted hover:bg-elevated/60 hover:text-default'}`
99
10-const soon = [
11- { label: 'Catalog', icon: 'i-lucide-library' },
12- { label: 'Presets', icon: 'i-lucide-layers' },
13- { label: 'Marketplaces', icon: 'i-lucide-store' }
10+const library = [
11+ { label: 'Catalog', icon: 'i-lucide-library', to: '/catalog' },
12+ { label: 'Presets', icon: 'i-lucide-layers', to: '/presets' },
13+ { label: 'Marketplaces', icon: 'i-lucide-store', to: '/marketplaces' }
1414 ]
15+const isActive = (to: string) => route.path === to || route.path.startsWith(to + '/')
1516 </script>
1617
1718 <template>
@@ -62,17 +63,18 @@ const soon = [
6263 @click="$emit('add')"
6364 />
6465
65- <div class="mt-4 space-y-1">
66- <div
67- v-for="s in soon"
68- :key="s.label"
69- class="flex cursor-not-allowed items-center gap-2 rounded-md px-2 py-1.5 text-sm text-dimmed"
70- aria-disabled="true"
71- >
72- <UIcon :name="s.icon" class="size-4" />
73- <span class="flex-1">{{ s.label }}</span>
74- <UBadge label="Soon" size="sm" color="neutral" variant="subtle" />
75- </div>
66+ <div class="mt-4 px-2">
67+ <span class="text-xs font-semibold uppercase tracking-wide text-muted">Library</span>
7668 </div>
69+ <NuxtLink v-for="l in library" :key="l.to" :to="l.to" :class="linkClass(isActive(l.to))">
70+ <UIcon :name="l.icon" class="size-4" />
71+ {{ l.label }}
72+ </NuxtLink>
73+ <NuxtLink to="/settings" :class="linkClass(isActive('/settings'))">
74+ <UIcon name="i-lucide-settings" class="size-4" />
75+ Settings
76+ </NuxtLink>
77+
78+ <CatalogGitIndicator class="mt-3" />
7779 </nav>
7880 </template>
@@ -7,11 +7,12 @@ const route = useRoute()
7 const linkClass = (active: boolean) =>7 const linkClass = (active: boolean) =>
8 `flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${active ? 'bg-elevated text-highlighted font-medium' : 'text-muted hover:bg-elevated/60 hover:text-default'}`8 `flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${active ? 'bg-elevated text-highlighted font-medium' : 'text-muted hover:bg-elevated/60 hover:text-default'}`
9 9
10-const soon = [10+const library = [
11- { label: 'Catalog', icon: 'i-lucide-library' },11+ { label: 'Catalog', icon: 'i-lucide-library', to: '/catalog' },
12- { label: 'Presets', icon: 'i-lucide-layers' },12+ { label: 'Presets', icon: 'i-lucide-layers', to: '/presets' },
13- { label: 'Marketplaces', icon: 'i-lucide-store' }13+ { label: 'Marketplaces', icon: 'i-lucide-store', to: '/marketplaces' }
14 ]14 ]
15+const isActive = (to: string) => route.path === to || route.path.startsWith(to + '/')
15 </script>16 </script>
16 17
17 <template>18 <template>
@@ -62,17 +63,18 @@ const soon = [
62 @click="$emit('add')"63 @click="$emit('add')"
63 />64 />
64 65
65- <div class="mt-4 space-y-1">66+ <div class="mt-4 px-2">
66- <div67+ <span class="text-xs font-semibold uppercase tracking-wide text-muted">Library</span>
67- v-for="s in soon"
68- :key="s.label"
69- class="flex cursor-not-allowed items-center gap-2 rounded-md px-2 py-1.5 text-sm text-dimmed"
70- aria-disabled="true"
71- >
72- <UIcon :name="s.icon" class="size-4" />
73- <span class="flex-1">{{ s.label }}</span>
74- <UBadge label="Soon" size="sm" color="neutral" variant="subtle" />
75- </div>
76 </div>68 </div>
69+ <NuxtLink v-for="l in library" :key="l.to" :to="l.to" :class="linkClass(isActive(l.to))">
70+ <UIcon :name="l.icon" class="size-4" />
71+ {{ l.label }}
72+ </NuxtLink>
73+ <NuxtLink to="/settings" :class="linkClass(isActive('/settings'))">
74+ <UIcon name="i-lucide-settings" class="size-4" />
75+ Settings
76+ </NuxtLink>
77+
78+ <CatalogGitIndicator class="mt-3" />
77 </nav>79 </nav>
78 </template>80 </template>
added packages/ui/app/components/ApplyModal.vue +251 -0
new file mode 100644
@@ -0,0 +1,251 @@
1+<script setup lang="ts">
2+import type { ApplyResponse, ConflictResolution, FileContent, PlanAction, PlanActionKind, ScopeState, SyncPlan } from '@skilly/core'
3+
4+const props = defineProps<{ scope: string, plan: SyncPlan, home: boolean }>()
5+const open = defineModel<boolean>('open', { default: false })
6+const emit = defineEmits<{ applied: [state: ScopeState] }>()
7+
8+const { request } = useApi()
9+const toast = useToast()
10+
11+const KIND: Record<Exclude<PlanActionKind, 'unchanged'>, { label: string, icon: string, color: string }> = {
12+ conflict: { label: 'Conflicts', icon: 'i-lucide-triangle-alert', color: 'text-warning' },
13+ add: { label: 'To add', icon: 'i-lucide-plus', color: 'text-success' },
14+ update: { label: 'To update', icon: 'i-lucide-refresh-cw', color: 'text-info' },
15+ remove: { label: 'To remove', icon: 'i-lucide-trash-2', color: 'text-error' }
16+}
17+const KIND_ORDER = ['conflict', 'add', 'update', 'remove'] as const
18+
19+const actions = computed(() => props.plan.actions.filter(a => a.kind !== 'unchanged'))
20+const groups = computed(() => KIND_ORDER.map(k => ({ kind: k, rows: actions.value.filter(a => a.kind === k) })).filter(g => g.rows.length))
21+const conflicts = computed(() => actions.value.filter(a => a.kind === 'conflict'))
22+
23+const resolutions = ref<Record<string, ConflictResolution>>({})
24+const busy = ref(false)
25+const response = ref<ApplyResponse | null>(null)
26+const error = ref<string | null>(null)
27+const expanded = ref<Set<string>>(new Set())
28+
29+watch(open, (v) => {
30+ if (!v) return
31+ response.value = null
32+ error.value = null
33+ expanded.value = new Set()
34+ compare.value = {}
35+ // Safe default: never overwrite a manual change unless the user chooses to.
36+ resolutions.value = Object.fromEntries(conflicts.value.map(a => [a.id, 'keep-local' as ConflictResolution]))
37+})
38+
39+const CONFLICT_LABEL: Record<string, string> = {
40+ 'modified-locally': 'Edited in the scope since the last sync',
41+ 'exists-unmanaged': 'A file already exists here and is not managed by skilly',
42+ 'missing-in-catalog': 'No longer in the catalog'
43+}
44+
45+const choices = (a: PlanAction) => [
46+ { label: 'Keep local', value: 'keep-local', description: 'Leave the scope version as is' },
47+ { label: 'Take catalog', value: 'take-catalog', description: 'Overwrite with the catalog version', disabled: a.conflict === 'missing-in-catalog' },
48+ { label: 'Push to catalog', value: 'push-to-catalog', description: 'Copy the scope version into the catalog' }
49+]
50+
51+function toggle(id: string) {
52+ const s = new Set(expanded.value)
53+ if (s.has(id)) s.delete(id)
54+ else s.add(id)
55+ expanded.value = s
56+}
57+
58+/* ------------------------------------------------ Compare */
59+
60+interface Compare { loading: boolean, error?: string, local?: string, catalog?: string, note?: string }
61+const compare = ref<Record<string, Compare>>({})
62+
63+/** The scope file to compare, only when it is the item's own file (not a shared config file). */
64+function comparableFile(a: PlanAction): string | null {
65+ const norm = (f: string) => f.replace(/\\/g, '/')
66+ if (a.type === 'skill') return a.files.map(norm).find(f => f.endsWith(`/${a.name}/SKILL.md`)) ?? null
67+ if (a.type === 'rule' || a.type === 'agent') return a.files.map(norm).find(f => f.endsWith(`/${a.name}.md`)) ?? null
68+ return null
69+}
70+
71+async function loadCompare(a: PlanAction) {
72+ const file = comparableFile(a)
73+ if (!file) {
74+ compare.value = { ...compare.value, [a.id]: { loading: false, note: 'This item lives in a shared configuration file (or an AGENTS.md block): there is no standalone file to compare.' } }
75+ return
76+ }
77+ compare.value = { ...compare.value, [a.id]: { loading: true } }
78+ const out: Compare = { loading: false }
79+ try {
80+ out.local = (await request<FileContent>('/api/files', { query: { path: joinPath(props.plan.root, file) }, silent: true })).content
81+ } catch (e) {
82+ out.error = `Scope file: ${(e as Error).message}`
83+ }
84+ if (a.conflict === 'missing-in-catalog') out.note = 'The item no longer exists in the catalog.'
85+ else {
86+ try {
87+ out.catalog = (await request<{ content: string }>(`/api/catalog/items/${a.type}/${encodeURIComponent(a.name)}`, { silent: true })).content
88+ } catch (e) {
89+ out.error = [out.error, `Catalog: ${(e as Error).message}`].filter(Boolean).join(' / ')
90+ }
91+ }
92+ compare.value = { ...compare.value, [a.id]: out }
93+}
94+
95+function toggleCompare(a: PlanAction) {
96+ toggle(a.id)
97+ if (expanded.value.has(a.id) && !compare.value[a.id]) loadCompare(a)
98+}
99+
100+/** Lines of `text`, flagged when the other side has no identical line (cheap "diff-ish" highlighting). */
101+function marked(text: string | undefined, other: string | undefined) {
102+ const set = new Set((other ?? '').split(/\r?\n/))
103+ return (text ?? '').split(/\r?\n/).map(line => ({ line, diff: other !== undefined && !set.has(line) }))
104+}
105+
106+/* ------------------------------------------------ Apply */
107+
108+let appliedActions: PlanAction[] = []
109+async function apply() {
110+ appliedActions = [...props.plan.actions]
111+ busy.value = true
112+ error.value = null
113+ try {
114+ response.value = await request<ApplyResponse>(`/api/scopes/${encodeURIComponent(props.scope)}/apply`, {
115+ method: 'POST',
116+ body: { resolutions: resolutions.value },
117+ silent: true
118+ })
119+ emit('applied', response.value.state)
120+ if (!response.value.result.errors.length) toast.add({ title: 'Changes applied', icon: 'i-lucide-check', color: 'success' })
121+ } catch (e) {
122+ error.value = (e as Error).message
123+ } finally {
124+ busy.value = false
125+ }
126+}
127+
128+const label = (id: string) => {
129+ const a = appliedActions.find(x => x.id === id)
130+ return a ? `${a.type}/${a.name} (${AGENT_LABELS[a.agent]})` : id
131+}
132+</script>
133+
134+<template>
135+ <UModal v-model:open="open" title="Review changes" :description="response ? 'Result of the sync.' : 'Nothing is written until you click Apply.'" :ui="{ content: 'max-w-4xl' }">
136+ <template #body>
137+ <!-- Result -->
138+ <div v-if="response" class="space-y-3 text-sm">
139+ <UAlert
140+ :color="response.result.errors.length ? 'warning' : 'success'"
141+ variant="subtle"
142+ :icon="response.result.errors.length ? 'i-lucide-triangle-alert' : 'i-lucide-check'"
143+ :title="`${response.result.applied.length} applied, ${response.result.skipped.length} skipped, ${response.result.errors.length} error(s)`"
144+ />
145+ <UAlert v-if="response.result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Errors">
146+ <template #description>
147+ <ul class="list-disc space-y-0.5 pl-4"><li v-for="e in response.result.errors" :key="e.actionId"><span class="font-medium">{{ label(e.actionId) }}</span>: {{ e.message }}</li></ul>
148+ </template>
149+ </UAlert>
150+ <div v-if="response.result.skipped.length">
151+ <p class="mb-1 text-xs font-medium text-muted">Skipped</p>
152+ <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.skipped" :key="s">{{ label(s) }}</li></ul>
153+ </div>
154+ <div v-if="response.result.applied.length">
155+ <p class="mb-1 text-xs font-medium text-muted">Applied</p>
156+ <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.applied" :key="s">{{ label(s) }}</li></ul>
157+ </div>
158+ </div>
159+
160+ <!-- Review -->
161+ <div v-else class="space-y-4">
162+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Apply failed" :description="error" />
163+ <UAlert v-if="home" color="warning" variant="subtle" icon="i-lucide-house" title="This writes into your Home agent folders" description="Home is shared by all your projects. Review the files below." />
164+
165+ <UAlert v-if="plan.warnings.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="plan.warnings.length > 1 ? 'Warnings' : 'Warning'">
166+ <template #description>
167+ <ul class="list-disc space-y-0.5 pl-4"><li v-for="w in plan.warnings" :key="w">{{ w }}</li></ul>
168+ </template>
169+ </UAlert>
170+ <UAlert v-if="plan.missing.length" color="warning" variant="subtle" icon="i-lucide-circle-help" :title="`${plan.missing.length} item(s) not found in the catalog`">
171+ <template #description>
172+ <p class="mb-1">They are referenced by the configuration and will be skipped.</p>
173+ <div class="flex flex-wrap gap-1"><UBadge v-for="m in plan.missing" :key="`${m.type}/${m.name}`" :label="`${m.type}/${m.name}`" size="sm" color="warning" variant="outline" class="font-mono" /></div>
174+ </template>
175+ </UAlert>
176+
177+ <section v-for="g in groups" :key="g.kind" class="space-y-1.5">
178+ <h3 class="flex items-center gap-1.5 text-sm font-semibold">
179+ <UIcon :name="KIND[g.kind].icon" class="size-4" :class="KIND[g.kind].color" />{{ KIND[g.kind].label }} ({{ g.rows.length }})
180+ </h3>
181+ <ul class="divide-y divide-default overflow-hidden rounded-lg border border-default text-sm">
182+ <li v-for="a in g.rows" :key="a.id" class="px-3 py-2">
183+ <div class="flex items-start gap-2">
184+ <AgentBadge :agent="a.agent" compact class="mt-0.5" />
185+ <div class="min-w-0 flex-1">
186+ <div class="flex items-center gap-2">
187+ <UIcon :name="CAT_META[a.type].icon" class="size-3.5 shrink-0 text-muted" />
188+ <span class="truncate font-medium">{{ a.name }}</span>
189+ <UBadge v-if="a.catalogUpdated" label="catalog updated" size="sm" color="info" variant="subtle" />
190+ <UBadge v-if="a.conflict" :label="a.conflict" size="sm" color="warning" variant="subtle" />
191+ </div>
192+ <p class="text-xs text-muted">{{ a.conflict ? CONFLICT_LABEL[a.conflict] : a.reason }}<span v-if="a.conflict && a.reason"> - {{ a.reason }}</span></p>
193+ </div>
194+ <UButton
195+ v-if="a.files.length || a.configKeys.length"
196+ :label="`${a.files.length + a.configKeys.length} file${a.files.length + a.configKeys.length > 1 ? 's' : ''}`"
197+ :trailing-icon="expanded.has(a.id) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
198+ size="xs" color="neutral" variant="link" class="shrink-0"
199+ @click="a.kind === 'conflict' ? toggleCompare(a) : toggle(a.id)"
200+ />
201+ </div>
202+
203+ <ul v-if="expanded.has(a.id) && (a.files.length || a.configKeys.length)" class="mt-1.5 space-y-0.5 pl-9 font-mono text-[11px] text-muted">
204+ <li v-for="f in a.files" :key="f" class="break-all"><UIcon name="i-lucide-file" class="mr-1 size-3 align-text-bottom" />{{ f }}</li>
205+ <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all"><UIcon name="i-lucide-braces" class="mr-1 size-3 align-text-bottom" />{{ c.file }} : {{ c.key }}</li>
206+ </ul>
207+
208+ <!-- Conflict resolution -->
209+ <div v-if="a.kind === 'conflict'" class="mt-2 space-y-2 pl-9">
210+ <URadioGroup v-model="resolutions[a.id]" :items="choices(a)" orientation="horizontal" size="sm" :aria-label="`Resolution for ${a.name}`" />
211+ <div v-if="expanded.has(a.id)">
212+ <USkeleton v-if="compare[a.id]?.loading" class="h-24 w-full" />
213+ <template v-else-if="compare[a.id]">
214+ <UAlert v-if="compare[a.id]!.note" color="neutral" variant="subtle" icon="i-lucide-info" :title="compare[a.id]!.note" class="mb-2" />
215+ <UAlert v-if="compare[a.id]!.error" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="compare[a.id]!.error" class="mb-2" />
216+ <div v-if="compare[a.id]!.local !== undefined || compare[a.id]!.catalog !== undefined" class="grid gap-2 md:grid-cols-2">
217+ <div v-for="side in ([['Scope (local)', 'local', 'catalog'], ['Catalog', 'catalog', 'local']] as const)" :key="side[0]" class="min-w-0">
218+ <p class="mb-1 text-xs font-medium text-muted">{{ side[0] }}</p>
219+ <div class="max-h-64 overflow-auto rounded-lg border border-default bg-elevated/40 py-1 font-mono text-[11px] leading-relaxed">
220+ <div v-if="compare[a.id]![side[1]] === undefined" class="px-2 text-muted">Not available</div>
221+ <div
222+ v-for="(l, i) in marked(compare[a.id]![side[1]], compare[a.id]![side[2]])"
223+ :key="i"
224+ class="whitespace-pre-wrap break-all px-2"
225+ :class="l.diff ? (side[1] === 'local' ? 'bg-warning/15' : 'bg-info/15') : ''"
226+ >{{ l.line || ' ' }}</div>
227+ </div>
228+ </div>
229+ </div>
230+ </template>
231+ </div>
232+ </div>
233+ </li>
234+ </ul>
235+ </section>
236+
237+ <p v-if="!groups.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing to apply.</p>
238+ </div>
239+ </template>
240+
241+ <template #footer>
242+ <div class="flex w-full items-center justify-between gap-2">
243+ <span class="text-xs text-muted">{{ response ? '' : `${actions.length} change${actions.length > 1 ? 's' : ''}${conflicts.length ? `, ${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''}` : ''}` }}</span>
244+ <div class="flex gap-2">
245+ <UButton :label="response ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" />
246+ <UButton v-if="!response" label="Apply" icon="i-lucide-check" :loading="busy" :disabled="!actions.length" @click="apply" />
247+ </div>
248+ </div>
249+ </template>
250+ </UModal>
251+</template>
new file mode 100644
@@ -0,0 +1,251 @@
1+<script setup lang="ts">
2+import type { ApplyResponse, ConflictResolution, FileContent, PlanAction, PlanActionKind, ScopeState, SyncPlan } from '@skilly/core'
3+
4+const props = defineProps<{ scope: string, plan: SyncPlan, home: boolean }>()
5+const open = defineModel<boolean>('open', { default: false })
6+const emit = defineEmits<{ applied: [state: ScopeState] }>()
7+
8+const { request } = useApi()
9+const toast = useToast()
10+
11+const KIND: Record<Exclude<PlanActionKind, 'unchanged'>, { label: string, icon: string, color: string }> = {
12+ conflict: { label: 'Conflicts', icon: 'i-lucide-triangle-alert', color: 'text-warning' },
13+ add: { label: 'To add', icon: 'i-lucide-plus', color: 'text-success' },
14+ update: { label: 'To update', icon: 'i-lucide-refresh-cw', color: 'text-info' },
15+ remove: { label: 'To remove', icon: 'i-lucide-trash-2', color: 'text-error' }
16+}
17+const KIND_ORDER = ['conflict', 'add', 'update', 'remove'] as const
18+
19+const actions = computed(() => props.plan.actions.filter(a => a.kind !== 'unchanged'))
20+const groups = computed(() => KIND_ORDER.map(k => ({ kind: k, rows: actions.value.filter(a => a.kind === k) })).filter(g => g.rows.length))
21+const conflicts = computed(() => actions.value.filter(a => a.kind === 'conflict'))
22+
23+const resolutions = ref<Record<string, ConflictResolution>>({})
24+const busy = ref(false)
25+const response = ref<ApplyResponse | null>(null)
26+const error = ref<string | null>(null)
27+const expanded = ref<Set<string>>(new Set())
28+
29+watch(open, (v) => {
30+ if (!v) return
31+ response.value = null
32+ error.value = null
33+ expanded.value = new Set()
34+ compare.value = {}
35+ // Safe default: never overwrite a manual change unless the user chooses to.
36+ resolutions.value = Object.fromEntries(conflicts.value.map(a => [a.id, 'keep-local' as ConflictResolution]))
37+})
38+
39+const CONFLICT_LABEL: Record<string, string> = {
40+ 'modified-locally': 'Edited in the scope since the last sync',
41+ 'exists-unmanaged': 'A file already exists here and is not managed by skilly',
42+ 'missing-in-catalog': 'No longer in the catalog'
43+}
44+
45+const choices = (a: PlanAction) => [
46+ { label: 'Keep local', value: 'keep-local', description: 'Leave the scope version as is' },
47+ { label: 'Take catalog', value: 'take-catalog', description: 'Overwrite with the catalog version', disabled: a.conflict === 'missing-in-catalog' },
48+ { label: 'Push to catalog', value: 'push-to-catalog', description: 'Copy the scope version into the catalog' }
49+]
50+
51+function toggle(id: string) {
52+ const s = new Set(expanded.value)
53+ if (s.has(id)) s.delete(id)
54+ else s.add(id)
55+ expanded.value = s
56+}
57+
58+/* ------------------------------------------------ Compare */
59+
60+interface Compare { loading: boolean, error?: string, local?: string, catalog?: string, note?: string }
61+const compare = ref<Record<string, Compare>>({})
62+
63+/** The scope file to compare, only when it is the item's own file (not a shared config file). */
64+function comparableFile(a: PlanAction): string | null {
65+ const norm = (f: string) => f.replace(/\\/g, '/')
66+ if (a.type === 'skill') return a.files.map(norm).find(f => f.endsWith(`/${a.name}/SKILL.md`)) ?? null
67+ if (a.type === 'rule' || a.type === 'agent') return a.files.map(norm).find(f => f.endsWith(`/${a.name}.md`)) ?? null
68+ return null
69+}
70+
71+async function loadCompare(a: PlanAction) {
72+ const file = comparableFile(a)
73+ if (!file) {
74+ compare.value = { ...compare.value, [a.id]: { loading: false, note: 'This item lives in a shared configuration file (or an AGENTS.md block): there is no standalone file to compare.' } }
75+ return
76+ }
77+ compare.value = { ...compare.value, [a.id]: { loading: true } }
78+ const out: Compare = { loading: false }
79+ try {
80+ out.local = (await request<FileContent>('/api/files', { query: { path: joinPath(props.plan.root, file) }, silent: true })).content
81+ } catch (e) {
82+ out.error = `Scope file: ${(e as Error).message}`
83+ }
84+ if (a.conflict === 'missing-in-catalog') out.note = 'The item no longer exists in the catalog.'
85+ else {
86+ try {
87+ out.catalog = (await request<{ content: string }>(`/api/catalog/items/${a.type}/${encodeURIComponent(a.name)}`, { silent: true })).content
88+ } catch (e) {
89+ out.error = [out.error, `Catalog: ${(e as Error).message}`].filter(Boolean).join(' / ')
90+ }
91+ }
92+ compare.value = { ...compare.value, [a.id]: out }
93+}
94+
95+function toggleCompare(a: PlanAction) {
96+ toggle(a.id)
97+ if (expanded.value.has(a.id) && !compare.value[a.id]) loadCompare(a)
98+}
99+
100+/** Lines of `text`, flagged when the other side has no identical line (cheap "diff-ish" highlighting). */
101+function marked(text: string | undefined, other: string | undefined) {
102+ const set = new Set((other ?? '').split(/\r?\n/))
103+ return (text ?? '').split(/\r?\n/).map(line => ({ line, diff: other !== undefined && !set.has(line) }))
104+}
105+
106+/* ------------------------------------------------ Apply */
107+
108+let appliedActions: PlanAction[] = []
109+async function apply() {
110+ appliedActions = [...props.plan.actions]
111+ busy.value = true
112+ error.value = null
113+ try {
114+ response.value = await request<ApplyResponse>(`/api/scopes/${encodeURIComponent(props.scope)}/apply`, {
115+ method: 'POST',
116+ body: { resolutions: resolutions.value },
117+ silent: true
118+ })
119+ emit('applied', response.value.state)
120+ if (!response.value.result.errors.length) toast.add({ title: 'Changes applied', icon: 'i-lucide-check', color: 'success' })
121+ } catch (e) {
122+ error.value = (e as Error).message
123+ } finally {
124+ busy.value = false
125+ }
126+}
127+
128+const label = (id: string) => {
129+ const a = appliedActions.find(x => x.id === id)
130+ return a ? `${a.type}/${a.name} (${AGENT_LABELS[a.agent]})` : id
131+}
132+</script>
133+
134+<template>
135+ <UModal v-model:open="open" title="Review changes" :description="response ? 'Result of the sync.' : 'Nothing is written until you click Apply.'" :ui="{ content: 'max-w-4xl' }">
136+ <template #body>
137+ <!-- Result -->
138+ <div v-if="response" class="space-y-3 text-sm">
139+ <UAlert
140+ :color="response.result.errors.length ? 'warning' : 'success'"
141+ variant="subtle"
142+ :icon="response.result.errors.length ? 'i-lucide-triangle-alert' : 'i-lucide-check'"
143+ :title="`${response.result.applied.length} applied, ${response.result.skipped.length} skipped, ${response.result.errors.length} error(s)`"
144+ />
145+ <UAlert v-if="response.result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Errors">
146+ <template #description>
147+ <ul class="list-disc space-y-0.5 pl-4"><li v-for="e in response.result.errors" :key="e.actionId"><span class="font-medium">{{ label(e.actionId) }}</span>: {{ e.message }}</li></ul>
148+ </template>
149+ </UAlert>
150+ <div v-if="response.result.skipped.length">
151+ <p class="mb-1 text-xs font-medium text-muted">Skipped</p>
152+ <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.skipped" :key="s">{{ label(s) }}</li></ul>
153+ </div>
154+ <div v-if="response.result.applied.length">
155+ <p class="mb-1 text-xs font-medium text-muted">Applied</p>
156+ <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.applied" :key="s">{{ label(s) }}</li></ul>
157+ </div>
158+ </div>
159+
160+ <!-- Review -->
161+ <div v-else class="space-y-4">
162+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Apply failed" :description="error" />
163+ <UAlert v-if="home" color="warning" variant="subtle" icon="i-lucide-house" title="This writes into your Home agent folders" description="Home is shared by all your projects. Review the files below." />
164+
165+ <UAlert v-if="plan.warnings.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="plan.warnings.length > 1 ? 'Warnings' : 'Warning'">
166+ <template #description>
167+ <ul class="list-disc space-y-0.5 pl-4"><li v-for="w in plan.warnings" :key="w">{{ w }}</li></ul>
168+ </template>
169+ </UAlert>
170+ <UAlert v-if="plan.missing.length" color="warning" variant="subtle" icon="i-lucide-circle-help" :title="`${plan.missing.length} item(s) not found in the catalog`">
171+ <template #description>
172+ <p class="mb-1">They are referenced by the configuration and will be skipped.</p>
173+ <div class="flex flex-wrap gap-1"><UBadge v-for="m in plan.missing" :key="`${m.type}/${m.name}`" :label="`${m.type}/${m.name}`" size="sm" color="warning" variant="outline" class="font-mono" /></div>
174+ </template>
175+ </UAlert>
176+
177+ <section v-for="g in groups" :key="g.kind" class="space-y-1.5">
178+ <h3 class="flex items-center gap-1.5 text-sm font-semibold">
179+ <UIcon :name="KIND[g.kind].icon" class="size-4" :class="KIND[g.kind].color" />{{ KIND[g.kind].label }} ({{ g.rows.length }})
180+ </h3>
181+ <ul class="divide-y divide-default overflow-hidden rounded-lg border border-default text-sm">
182+ <li v-for="a in g.rows" :key="a.id" class="px-3 py-2">
183+ <div class="flex items-start gap-2">
184+ <AgentBadge :agent="a.agent" compact class="mt-0.5" />
185+ <div class="min-w-0 flex-1">
186+ <div class="flex items-center gap-2">
187+ <UIcon :name="CAT_META[a.type].icon" class="size-3.5 shrink-0 text-muted" />
188+ <span class="truncate font-medium">{{ a.name }}</span>
189+ <UBadge v-if="a.catalogUpdated" label="catalog updated" size="sm" color="info" variant="subtle" />
190+ <UBadge v-if="a.conflict" :label="a.conflict" size="sm" color="warning" variant="subtle" />
191+ </div>
192+ <p class="text-xs text-muted">{{ a.conflict ? CONFLICT_LABEL[a.conflict] : a.reason }}<span v-if="a.conflict && a.reason"> - {{ a.reason }}</span></p>
193+ </div>
194+ <UButton
195+ v-if="a.files.length || a.configKeys.length"
196+ :label="`${a.files.length + a.configKeys.length} file${a.files.length + a.configKeys.length > 1 ? 's' : ''}`"
197+ :trailing-icon="expanded.has(a.id) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
198+ size="xs" color="neutral" variant="link" class="shrink-0"
199+ @click="a.kind === 'conflict' ? toggleCompare(a) : toggle(a.id)"
200+ />
201+ </div>
202+
203+ <ul v-if="expanded.has(a.id) && (a.files.length || a.configKeys.length)" class="mt-1.5 space-y-0.5 pl-9 font-mono text-[11px] text-muted">
204+ <li v-for="f in a.files" :key="f" class="break-all"><UIcon name="i-lucide-file" class="mr-1 size-3 align-text-bottom" />{{ f }}</li>
205+ <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all"><UIcon name="i-lucide-braces" class="mr-1 size-3 align-text-bottom" />{{ c.file }} : {{ c.key }}</li>
206+ </ul>
207+
208+ <!-- Conflict resolution -->
209+ <div v-if="a.kind === 'conflict'" class="mt-2 space-y-2 pl-9">
210+ <URadioGroup v-model="resolutions[a.id]" :items="choices(a)" orientation="horizontal" size="sm" :aria-label="`Resolution for ${a.name}`" />
211+ <div v-if="expanded.has(a.id)">
212+ <USkeleton v-if="compare[a.id]?.loading" class="h-24 w-full" />
213+ <template v-else-if="compare[a.id]">
214+ <UAlert v-if="compare[a.id]!.note" color="neutral" variant="subtle" icon="i-lucide-info" :title="compare[a.id]!.note" class="mb-2" />
215+ <UAlert v-if="compare[a.id]!.error" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="compare[a.id]!.error" class="mb-2" />
216+ <div v-if="compare[a.id]!.local !== undefined || compare[a.id]!.catalog !== undefined" class="grid gap-2 md:grid-cols-2">
217+ <div v-for="side in ([['Scope (local)', 'local', 'catalog'], ['Catalog', 'catalog', 'local']] as const)" :key="side[0]" class="min-w-0">
218+ <p class="mb-1 text-xs font-medium text-muted">{{ side[0] }}</p>
219+ <div class="max-h-64 overflow-auto rounded-lg border border-default bg-elevated/40 py-1 font-mono text-[11px] leading-relaxed">
220+ <div v-if="compare[a.id]![side[1]] === undefined" class="px-2 text-muted">Not available</div>
221+ <div
222+ v-for="(l, i) in marked(compare[a.id]![side[1]], compare[a.id]![side[2]])"
223+ :key="i"
224+ class="whitespace-pre-wrap break-all px-2"
225+ :class="l.diff ? (side[1] === 'local' ? 'bg-warning/15' : 'bg-info/15') : ''"
226+ >{{ l.line || ' ' }}</div>
227+ </div>
228+ </div>
229+ </div>
230+ </template>
231+ </div>
232+ </div>
233+ </li>
234+ </ul>
235+ </section>
236+
237+ <p v-if="!groups.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing to apply.</p>
238+ </div>
239+ </template>
240+
241+ <template #footer>
242+ <div class="flex w-full items-center justify-between gap-2">
243+ <span class="text-xs text-muted">{{ response ? '' : `${actions.length} change${actions.length > 1 ? 's' : ''}${conflicts.length ? `, ${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''}` : ''}` }}</span>
244+ <div class="flex gap-2">
245+ <UButton :label="response ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" />
246+ <UButton v-if="!response" label="Apply" icon="i-lucide-check" :loading="busy" :disabled="!actions.length" @click="apply" />
247+ </div>
248+ </div>
249+ </template>
250+ </UModal>
251+</template>
added packages/ui/app/components/CatalogGitDialog.vue +96 -0
new file mode 100644
@@ -0,0 +1,96 @@
1+<script setup lang="ts">
2+const { git, refresh } = useCatalog()
3+const { open, busy, error, run } = useCatalogGit()
4+
5+const remote = ref('')
6+const message = ref('')
7+
8+watch(open, (v) => {
9+ if (!v) return
10+ error.value = null
11+ refresh()
12+})
13+watch(() => git.value?.remote, (r) => { remote.value = r ?? '' }, { immediate: true })
14+
15+const dirty = computed(() => git.value?.dirty ?? [])
16+const conflicts = computed(() => git.value?.conflicts ?? [])
17+const ready = computed(() => !!git.value?.isRepo)
18+
19+async function commit() {
20+ if (await run('commit', { message: message.value.trim() }, 'Committed')) message.value = ''
21+}
22+</script>
23+
24+<template>
25+ <UModal v-model:open="open" title="Catalog git" description="Commit, pull and push the catalog repository." :ui="{ content: 'max-w-xl' }">
26+ <template #body>
27+ <div v-if="!git" class="text-sm text-muted">The catalog is not available yet.</div>
28+ <div v-else-if="!git.isRepo" class="text-sm text-muted">The catalog folder is not a git repository.</div>
29+ <div v-else class="space-y-5">
30+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Git error" :description="error" class="whitespace-pre-wrap" />
31+
32+ <!-- Status -->
33+ <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
34+ <div><span class="text-muted">Branch </span><span class="font-mono">{{ git.branch ?? '-' }}</span></div>
35+ <div class="truncate"><span class="text-muted">Remote </span><span class="font-mono" :title="git.remote">{{ git.remote ?? 'none' }}</span></div>
36+ <div class="flex gap-3">
37+ <span :class="git.ahead ? 'text-info' : 'text-muted'">{{ git.ahead }} ahead</span>
38+ <span :class="git.behind ? 'text-info' : 'text-muted'">{{ git.behind }} behind</span>
39+ </div>
40+ <div class="truncate" :title="git.lastCommit?.message">
41+ <span class="text-muted">Last commit </span>
42+ <span v-if="git.lastCommit"><span class="font-mono">{{ git.lastCommit.hash.slice(0, 7) }}</span> {{ git.lastCommit.message }}</span>
43+ <span v-else class="text-muted">none</span>
44+ </div>
45+ </div>
46+
47+ <!-- Remote -->
48+ <form class="space-y-1" @submit.prevent="run('remote', { url: remote.trim() }, 'Remote updated')">
49+ <label class="text-xs font-medium text-muted">Remote URL (any git host)</label>
50+ <div class="flex gap-2">
51+ <UInput v-model="remote" placeholder="git@github.com:you/agent-kit.git" class="flex-1" :ui="{ base: 'font-mono' }" />
52+ <UButton type="submit" label="Set remote" color="neutral" variant="outline" :loading="busy === 'remote'" :disabled="!remote.trim() || remote.trim() === git.remote" />
53+ </div>
54+ </form>
55+
56+ <!-- Conflicts -->
57+ <div v-if="conflicts.length" class="space-y-2">
58+ <h3 class="flex items-center gap-1.5 text-sm font-semibold text-error"><UIcon name="i-lucide-triangle-alert" class="size-4" />Conflicts ({{ conflicts.length }})</h3>
59+ <ul class="divide-y divide-default rounded-lg border border-default text-sm">
60+ <li v-for="c in conflicts" :key="c" class="flex items-center gap-2 px-3 py-2">
61+ <span class="min-w-0 flex-1 truncate font-mono text-xs" :title="c">{{ c }}</span>
62+ <UButton label="Keep mine" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'ours' }, 'Conflict resolved')" />
63+ <UButton label="Use remote" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'theirs' }, 'Conflict resolved')" />
64+ </li>
65+ </ul>
66+ </div>
67+
68+ <!-- Commit -->
69+ <div class="space-y-2">
70+ <h3 class="text-sm font-semibold">Uncommitted changes ({{ dirty.length }})</h3>
71+ <ul v-if="dirty.length" class="max-h-32 overflow-auto rounded-lg border border-default text-xs">
72+ <li v-for="d in dirty" :key="d" class="truncate px-3 py-1 font-mono">{{ d }}</li>
73+ </ul>
74+ <p v-else class="text-xs text-muted">Working tree clean.</p>
75+ <form class="flex gap-2" @submit.prevent="commit">
76+ <UInput v-model="message" placeholder="Commit message" class="flex-1" :disabled="!dirty.length" />
77+ <UButton type="submit" label="Commit" icon="i-lucide-git-commit-horizontal" :loading="busy === 'commit'" :disabled="!dirty.length || !message.trim()" />
78+ </form>
79+ </div>
80+ </div>
81+ </template>
82+
83+ <template #footer>
84+ <div class="flex w-full flex-wrap items-center justify-between gap-2">
85+ <div class="flex gap-2">
86+ <UButton label="Pull" icon="i-lucide-arrow-down" color="neutral" variant="outline" :loading="busy === 'pull'" :disabled="!ready || !git?.remote || !!busy" @click="run('pull', undefined, 'Pulled')" />
87+ <UButton label="Push" icon="i-lucide-arrow-up" color="neutral" variant="outline" :loading="busy === 'push'" :disabled="!ready || !git?.remote || !!busy" @click="run('push', undefined, 'Pushed')" />
88+ </div>
89+ <div class="flex gap-2">
90+ <UButton label="Close" color="neutral" variant="ghost" @click="open = false" />
91+ <UButton label="Sync" icon="i-lucide-refresh-cw" :loading="busy === 'sync'" :disabled="!ready || !git?.remote || !!busy" @click="run('sync', undefined, 'Catalog synced')" />
92+ </div>
93+ </div>
94+ </template>
95+ </UModal>
96+</template>
new file mode 100644
@@ -0,0 +1,96 @@
1+<script setup lang="ts">
2+const { git, refresh } = useCatalog()
3+const { open, busy, error, run } = useCatalogGit()
4+
5+const remote = ref('')
6+const message = ref('')
7+
8+watch(open, (v) => {
9+ if (!v) return
10+ error.value = null
11+ refresh()
12+})
13+watch(() => git.value?.remote, (r) => { remote.value = r ?? '' }, { immediate: true })
14+
15+const dirty = computed(() => git.value?.dirty ?? [])
16+const conflicts = computed(() => git.value?.conflicts ?? [])
17+const ready = computed(() => !!git.value?.isRepo)
18+
19+async function commit() {
20+ if (await run('commit', { message: message.value.trim() }, 'Committed')) message.value = ''
21+}
22+</script>
23+
24+<template>
25+ <UModal v-model:open="open" title="Catalog git" description="Commit, pull and push the catalog repository." :ui="{ content: 'max-w-xl' }">
26+ <template #body>
27+ <div v-if="!git" class="text-sm text-muted">The catalog is not available yet.</div>
28+ <div v-else-if="!git.isRepo" class="text-sm text-muted">The catalog folder is not a git repository.</div>
29+ <div v-else class="space-y-5">
30+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Git error" :description="error" class="whitespace-pre-wrap" />
31+
32+ <!-- Status -->
33+ <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
34+ <div><span class="text-muted">Branch </span><span class="font-mono">{{ git.branch ?? '-' }}</span></div>
35+ <div class="truncate"><span class="text-muted">Remote </span><span class="font-mono" :title="git.remote">{{ git.remote ?? 'none' }}</span></div>
36+ <div class="flex gap-3">
37+ <span :class="git.ahead ? 'text-info' : 'text-muted'">{{ git.ahead }} ahead</span>
38+ <span :class="git.behind ? 'text-info' : 'text-muted'">{{ git.behind }} behind</span>
39+ </div>
40+ <div class="truncate" :title="git.lastCommit?.message">
41+ <span class="text-muted">Last commit </span>
42+ <span v-if="git.lastCommit"><span class="font-mono">{{ git.lastCommit.hash.slice(0, 7) }}</span> {{ git.lastCommit.message }}</span>
43+ <span v-else class="text-muted">none</span>
44+ </div>
45+ </div>
46+
47+ <!-- Remote -->
48+ <form class="space-y-1" @submit.prevent="run('remote', { url: remote.trim() }, 'Remote updated')">
49+ <label class="text-xs font-medium text-muted">Remote URL (any git host)</label>
50+ <div class="flex gap-2">
51+ <UInput v-model="remote" placeholder="git@github.com:you/agent-kit.git" class="flex-1" :ui="{ base: 'font-mono' }" />
52+ <UButton type="submit" label="Set remote" color="neutral" variant="outline" :loading="busy === 'remote'" :disabled="!remote.trim() || remote.trim() === git.remote" />
53+ </div>
54+ </form>
55+
56+ <!-- Conflicts -->
57+ <div v-if="conflicts.length" class="space-y-2">
58+ <h3 class="flex items-center gap-1.5 text-sm font-semibold text-error"><UIcon name="i-lucide-triangle-alert" class="size-4" />Conflicts ({{ conflicts.length }})</h3>
59+ <ul class="divide-y divide-default rounded-lg border border-default text-sm">
60+ <li v-for="c in conflicts" :key="c" class="flex items-center gap-2 px-3 py-2">
61+ <span class="min-w-0 flex-1 truncate font-mono text-xs" :title="c">{{ c }}</span>
62+ <UButton label="Keep mine" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'ours' }, 'Conflict resolved')" />
63+ <UButton label="Use remote" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'theirs' }, 'Conflict resolved')" />
64+ </li>
65+ </ul>
66+ </div>
67+
68+ <!-- Commit -->
69+ <div class="space-y-2">
70+ <h3 class="text-sm font-semibold">Uncommitted changes ({{ dirty.length }})</h3>
71+ <ul v-if="dirty.length" class="max-h-32 overflow-auto rounded-lg border border-default text-xs">
72+ <li v-for="d in dirty" :key="d" class="truncate px-3 py-1 font-mono">{{ d }}</li>
73+ </ul>
74+ <p v-else class="text-xs text-muted">Working tree clean.</p>
75+ <form class="flex gap-2" @submit.prevent="commit">
76+ <UInput v-model="message" placeholder="Commit message" class="flex-1" :disabled="!dirty.length" />
77+ <UButton type="submit" label="Commit" icon="i-lucide-git-commit-horizontal" :loading="busy === 'commit'" :disabled="!dirty.length || !message.trim()" />
78+ </form>
79+ </div>
80+ </div>
81+ </template>
82+
83+ <template #footer>
84+ <div class="flex w-full flex-wrap items-center justify-between gap-2">
85+ <div class="flex gap-2">
86+ <UButton label="Pull" icon="i-lucide-arrow-down" color="neutral" variant="outline" :loading="busy === 'pull'" :disabled="!ready || !git?.remote || !!busy" @click="run('pull', undefined, 'Pulled')" />
87+ <UButton label="Push" icon="i-lucide-arrow-up" color="neutral" variant="outline" :loading="busy === 'push'" :disabled="!ready || !git?.remote || !!busy" @click="run('push', undefined, 'Pushed')" />
88+ </div>
89+ <div class="flex gap-2">
90+ <UButton label="Close" color="neutral" variant="ghost" @click="open = false" />
91+ <UButton label="Sync" icon="i-lucide-refresh-cw" :loading="busy === 'sync'" :disabled="!ready || !git?.remote || !!busy" @click="run('sync', undefined, 'Catalog synced')" />
92+ </div>
93+ </div>
94+ </template>
95+ </UModal>
96+</template>
added packages/ui/app/components/CatalogGitIndicator.vue +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+<script setup lang="ts">
2+const { git, exists } = useCatalog()
3+const { open, busy, run } = useCatalogGit()
4+
5+const parts = computed(() => {
6+ const g = git.value
7+ if (!g?.isRepo) return []
8+ const p: { icon: string, label: string, color: string, title: string }[] = []
9+ if (g.conflicts.length) p.push({ icon: 'i-lucide-triangle-alert', label: String(g.conflicts.length), color: 'text-error', title: `${g.conflicts.length} conflict(s)` })
10+ if (g.dirty.length) p.push({ icon: 'i-lucide-circle-dot', label: String(g.dirty.length), color: 'text-warning', title: `${g.dirty.length} uncommitted change(s)` })
11+ if (g.ahead) p.push({ icon: 'i-lucide-arrow-up', label: String(g.ahead), color: 'text-info', title: `${g.ahead} commit(s) to push` })
12+ if (g.behind) p.push({ icon: 'i-lucide-arrow-down', label: String(g.behind), color: 'text-info', title: `${g.behind} commit(s) to pull` })
13+ return p
14+})
15+</script>
16+
17+<template>
18+ <div v-if="exists && git" class="flex items-center gap-1 rounded-md border border-default px-1.5 py-1">
19+ <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs hover:bg-elevated/60" title="Catalog git status" @click="open = true">
20+ <UIcon name="i-lucide-git-branch" class="size-3.5 shrink-0 text-muted" />
21+ <span v-if="!git.isRepo" class="truncate text-muted">Not a git repo</span>
22+ <template v-else>
23+ <span class="truncate text-muted">{{ git.branch ?? 'catalog' }}</span>
24+ <span v-if="!parts.length" class="flex items-center gap-0.5 text-success" title="Clean and up to date"><UIcon name="i-lucide-check" class="size-3.5" /></span>
25+ <span v-for="p in parts" :key="p.icon" class="flex items-center gap-0.5 tabular-nums" :class="p.color" :title="p.title">
26+ <UIcon :name="p.icon" class="size-3" />{{ p.label }}
27+ </span>
28+ </template>
29+ </button>
30+ <UTooltip v-if="git.isRepo" :text="git.remote ? 'Commit, pull and push' : 'Set a remote in the git dialog to sync'">
31+ <UButton
32+ icon="i-lucide-refresh-cw"
33+ size="xs"
34+ color="neutral"
35+ variant="ghost"
36+ aria-label="Sync catalog"
37+ :loading="busy === 'sync'"
38+ :disabled="!git.remote || !!busy"
39+ @click="run('sync', undefined, 'Catalog synced')"
40+ />
41+ </UTooltip>
42+ </div>
43+</template>
new file mode 100644
@@ -0,0 +1,43 @@
1+<script setup lang="ts">
2+const { git, exists } = useCatalog()
3+const { open, busy, run } = useCatalogGit()
4+
5+const parts = computed(() => {
6+ const g = git.value
7+ if (!g?.isRepo) return []
8+ const p: { icon: string, label: string, color: string, title: string }[] = []
9+ if (g.conflicts.length) p.push({ icon: 'i-lucide-triangle-alert', label: String(g.conflicts.length), color: 'text-error', title: `${g.conflicts.length} conflict(s)` })
10+ if (g.dirty.length) p.push({ icon: 'i-lucide-circle-dot', label: String(g.dirty.length), color: 'text-warning', title: `${g.dirty.length} uncommitted change(s)` })
11+ if (g.ahead) p.push({ icon: 'i-lucide-arrow-up', label: String(g.ahead), color: 'text-info', title: `${g.ahead} commit(s) to push` })
12+ if (g.behind) p.push({ icon: 'i-lucide-arrow-down', label: String(g.behind), color: 'text-info', title: `${g.behind} commit(s) to pull` })
13+ return p
14+})
15+</script>
16+
17+<template>
18+ <div v-if="exists && git" class="flex items-center gap-1 rounded-md border border-default px-1.5 py-1">
19+ <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs hover:bg-elevated/60" title="Catalog git status" @click="open = true">
20+ <UIcon name="i-lucide-git-branch" class="size-3.5 shrink-0 text-muted" />
21+ <span v-if="!git.isRepo" class="truncate text-muted">Not a git repo</span>
22+ <template v-else>
23+ <span class="truncate text-muted">{{ git.branch ?? 'catalog' }}</span>
24+ <span v-if="!parts.length" class="flex items-center gap-0.5 text-success" title="Clean and up to date"><UIcon name="i-lucide-check" class="size-3.5" /></span>
25+ <span v-for="p in parts" :key="p.icon" class="flex items-center gap-0.5 tabular-nums" :class="p.color" :title="p.title">
26+ <UIcon :name="p.icon" class="size-3" />{{ p.label }}
27+ </span>
28+ </template>
29+ </button>
30+ <UTooltip v-if="git.isRepo" :text="git.remote ? 'Commit, pull and push' : 'Set a remote in the git dialog to sync'">
31+ <UButton
32+ icon="i-lucide-refresh-cw"
33+ size="xs"
34+ color="neutral"
35+ variant="ghost"
36+ aria-label="Sync catalog"
37+ :loading="busy === 'sync'"
38+ :disabled="!git.remote || !!busy"
39+ @click="run('sync', undefined, 'Catalog synced')"
40+ />
41+ </UTooltip>
42+ </div>
43+</template>
added packages/ui/app/components/CatalogItemEditor.vue +405 -0
new file mode 100644
@@ -0,0 +1,405 @@
1+<script setup lang="ts">
2+import type { CatalogItemDetail, CatalogItemType, HookTemplate, McpTemplate, SaveItemInput } from '@skilly/core'
3+
4+/** Catalog item editor: Markdown (skill / rule / agent) or form + YAML (mcp / hook). `name` is undefined for a new item. */
5+const props = defineProps<{ type: CatalogItemType, name?: string }>()
6+
7+const { request } = useApi()
8+const catalog = useCatalog()
9+const scopeNames = useScopeNames()
10+const toast = useToast()
11+
12+const isNew = computed(() => !props.name)
13+const meta = computed(() => CAT_META[props.type])
14+const isMd = computed(() => meta.value.markdown)
15+
16+const loading = ref(!!props.name)
17+const loadError = ref<string | null>(null)
18+const detail = ref<CatalogItemDetail | null>(null)
19+
20+const form = reactive({ name: props.name ?? '', content: '', tags: [] as string[] })
21+const mcp = ref<McpTemplate>({ name: '', transport: 'stdio', command: '', args: [] })
22+const hook = ref<HookTemplate>({ name: '', event: 'PreToolUse', handler: { type: 'command', command: '' } })
23+const extraFiles = ref<Record<string, string>>({})
24+const deleteFiles = ref<string[]>([])
25+const original = ref('')
26+const view = ref<'edit' | 'split' | 'preview'>('split')
27+const saving = ref(false)
28+const saveError = ref<string | null>(null)
29+
30+const yaml = computed(() => {
31+ if (props.type === 'mcp') return mcpToYaml({ ...mcp.value, name: form.name })
32+ if (props.type === 'hook') return hookToYaml({ ...hook.value, name: form.name })
33+ return ''
34+})
35+const mainContent = computed(() => (isMd.value ? form.content : yaml.value))
36+
37+const snapshot = () => JSON.stringify([form.name, mainContent.value, form.tags, extraFiles.value, deleteFiles.value])
38+const dirty = computed(() => snapshot() !== original.value)
39+
40+function seed(d: CatalogItemDetail | null) {
41+ detail.value = d
42+ extraFiles.value = {}
43+ deleteFiles.value = []
44+ if (d) {
45+ form.name = d.item.name
46+ form.content = d.content
47+ form.tags = [...d.item.tags]
48+ if (props.type === 'mcp' && d.template) mcp.value = structuredClone(toRaw(d.template)) as McpTemplate
49+ if (props.type === 'hook' && d.template) hook.value = structuredClone(toRaw(d.template)) as HookTemplate
50+ } else if (isMd.value) {
51+ form.content = newItemContent(props.type, form.name || 'new-item')
52+ }
53+ nextTick(() => { original.value = snapshot() })
54+}
55+
56+async function load() {
57+ if (isNew.value) return seed(null)
58+ loading.value = true
59+ loadError.value = null
60+ try {
61+ seed(await request<CatalogItemDetail>(`/api/catalog/items/${props.type}/${encodeURIComponent(props.name!)}`, { silent: true }))
62+ } catch (e) {
63+ loadError.value = (e as Error).message
64+ } finally {
65+ loading.value = false
66+ }
67+}
68+
69+onMounted(() => {
70+ if (!catalog.data.value) catalog.refresh()
71+ load()
72+})
73+
74+/* ------------------------------------------------ Markdown fields */
75+
76+const description = computed({
77+ get: () => getFm(form.content, 'description'),
78+ set: (v: string) => { form.content = setFm(form.content, 'description', v) }
79+})
80+function onName(v: string) {
81+ form.name = v
82+ if (isMd.value) form.content = setFm(form.content, 'name', v)
83+}
84+// New item: the name typed first also seeds the template when the content is still the untouched one.
85+const fmName = computed(() => getFm(form.content, 'name'))
86+
87+const parsed = computed(() => splitFrontmatter(form.content))
88+const previewHtml = computed(() => renderMarkdown(parsed.value.body))
89+
90+/* ------------------------------------------------ Validation */
91+
92+const problems = computed(() => {
93+ const p: string[] = []
94+ if (!form.name) p.push('A name is required.')
95+ else if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.')
96+ else if ((isNew.value || form.name !== props.name) && catalog.findItem(props.type, form.name)) p.push(`A ${meta.value.singular.toLowerCase()} named "${form.name}" already exists.`)
97+ if (isMd.value) {
98+ if (!parsed.value.frontmatter) p.push('The file has no frontmatter block (--- ... ---).')
99+ if (!description.value.trim()) p.push('The frontmatter needs a description.')
100+ } else if (props.type === 'mcp') {
101+ if (mcp.value.transport === 'stdio' && !mcp.value.command?.trim()) p.push('A command is required for stdio servers.')
102+ if (mcp.value.transport === 'http' && !mcp.value.url?.trim()) p.push('A URL is required for http servers.')
103+ } else if (!hook.value.event || !hook.value.handler.command.trim()) {
104+ p.push('A hook needs an event and a command.')
105+ }
106+ return p
107+})
108+const nameMismatch = computed(() => isMd.value && !!parsed.value.frontmatter && fmName.value !== form.name && !!form.name)
109+const mismatchTitle = computed(() => `Frontmatter name is ${fmName.value ? '"' + fmName.value + '"' : 'missing'}`)
110+const renaming = computed(() => !isNew.value && form.name !== props.name)
111+
112+/* ------------------------------------------------ Save */
113+
114+async function save() {
115+ if (problems.value.length) return
116+ saving.value = true
117+ saveError.value = null
118+ const content = isMd.value && nameMismatch.value ? setFm(form.content, 'name', form.name) : mainContent.value
119+ const body: SaveItemInput = {
120+ type: props.type,
121+ name: form.name,
122+ content,
123+ tags: form.tags,
124+ ...(props.type === 'skill' ? { files: { ...extraFiles.value }, deleteFiles: [...deleteFiles.value] } : {}),
125+ ...(renaming.value ? { previousName: props.name } : {})
126+ }
127+ try {
128+ await request(`/api/catalog/items/${props.type}/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true })
129+ toast.add({ title: isNew.value ? 'Created' : 'Saved', description: form.name, icon: 'i-lucide-check', color: 'success' })
130+ await catalog.refresh()
131+ if (isNew.value || renaming.value) {
132+ original.value = snapshot()
133+ await navigateTo(`/catalog/${props.type}/${encodeURIComponent(form.name)}`, { replace: true })
134+ } else {
135+ form.content = content
136+ await load()
137+ }
138+ } catch (e) {
139+ saveError.value = (e as Error).message
140+ } finally {
141+ saving.value = false
142+ }
143+}
144+
145+onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?'))
146+
147+/* ------------------------------------------------ Delete */
148+
149+const delOpen = ref(false)
150+const deleting = ref(false)
151+const delConflict = ref<string | null>(null)
152+const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : []))
153+
154+async function doDelete(force = false) {
155+ deleting.value = true
156+ try {
157+ await request(`/api/catalog/items/${props.type}/${encodeURIComponent(props.name!)}`, { method: 'DELETE', query: force ? { force: '1' } : undefined, silent: true })
158+ original.value = snapshot()
159+ toast.add({ title: 'Deleted', description: props.name, icon: 'i-lucide-check', color: 'success' })
160+ delOpen.value = false
161+ await catalog.refresh()
162+ await navigateTo(`/catalog?type=${props.type}`)
163+ } catch (e) {
164+ const err = e as { status?: number, message: string }
165+ if (err.status === 409) delConflict.value = err.message
166+ else toast.add({ title: 'Delete failed', description: err.message, color: 'error', icon: 'i-lucide-circle-alert' })
167+ } finally {
168+ deleting.value = false
169+ }
170+}
171+function askDelete() {
172+ delConflict.value = null
173+ delOpen.value = true
174+}
175+
176+/* ------------------------------------------------ Skill files */
177+
178+const existingFiles = computed(() => (detail.value?.files ?? []).filter(f => f.path !== 'SKILL.md'))
179+const viewFile = ref<string | null>(null)
180+const newFile = reactive({ path: '', content: '' })
181+const newFileError = computed(() => {
182+ const p = newFile.path.trim()
183+ if (!p) return null
184+ if (/^([/\\]|[a-zA-Z]:)/.test(p) || p.split(/[\\/]/).includes('..')) return 'Use a relative path inside the skill folder.'
185+ if (p === 'SKILL.md') return 'SKILL.md is edited above.'
186+ if (extraFiles.value[p] !== undefined || existingFiles.value.some(f => f.path === p)) return 'This file already exists.'
187+ return null
188+})
189+function addFile() {
190+ if (!newFile.path.trim() || newFileError.value) return
191+ extraFiles.value = { ...extraFiles.value, [newFile.path.trim().replace(/\\/g, '/')]: newFile.content }
192+ newFile.path = ''
193+ newFile.content = ''
194+}
195+function dropNew(p: string) {
196+ const { [p]: _gone, ...rest } = extraFiles.value
197+ extraFiles.value = rest
198+}
199+function toggleDelete(p: string) {
200+ deleteFiles.value = deleteFiles.value.includes(p) ? deleteFiles.value.filter(x => x !== p) : [...deleteFiles.value, p]
201+}
202+
203+const VIEWS = [
204+ { label: 'Edit', value: 'edit' },
205+ { label: 'Split', value: 'split' },
206+ { label: 'Preview', value: 'preview' }
207+]
208+const TA = 'block w-full resize-y rounded-lg border border-default bg-default p-3 font-mono text-xs leading-relaxed outline-none focus:border-primary focus:ring-1 focus:ring-primary'
209+</script>
210+
211+<template>
212+ <UDashboardPanel :id="`catalog-item-${type}`">
213+ <template #header>
214+ <UDashboardNavbar icon="i-lucide-library">
215+ <template #title>
216+ <NuxtLink :to="`/catalog?type=${type}`" class="text-muted hover:text-default">Catalog</NuxtLink>
217+ <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />
218+ <span class="text-muted">{{ meta.label }}</span>
219+ <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />
220+ <span class="truncate">{{ isNew ? 'New' : name }}</span>
221+ </template>
222+ <template #right>
223+ <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" />
224+ <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" />
225+ <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" />
226+ </template>
227+ </UDashboardNavbar>
228+ </template>
229+
230+ <template #body>
231+ <div v-if="loading" class="space-y-3">
232+ <USkeleton class="h-10 w-1/2" />
233+ <USkeleton class="h-72 w-full" />
234+ </div>
235+ <UAlert v-else-if="loadError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load this item" :description="loadError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: load }]" />
236+
237+ <div v-else class="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
238+ <!-- Main column -->
239+ <div class="min-w-0 space-y-4">
240+ <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" />
241+
242+ <div class="grid gap-4 sm:grid-cols-2">
243+ <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required>
244+ <UInput :model-value="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" @update:model-value="onName" />
245+ </UFormField>
246+ <UFormField v-if="isMd" label="Description" required :description="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined">
247+ <UInput v-model="description" class="w-full" />
248+ </UFormField>
249+ </div>
250+
251+ <UAlert v-if="renaming" color="info" variant="subtle" icon="i-lucide-info" title="Renaming" description="The item is renamed in the catalog. Scopes that reference the old name will report it as missing until they are updated." />
252+
253+ <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" title="Fix before saving">
254+ <template #description>
255+ <ul class="list-disc pl-4">
256+ <li v-for="p in problems" :key="p">{{ p }}</li>
257+ </ul>
258+ </template>
259+ </UAlert>
260+ <UAlert
261+ v-else-if="nameMismatch"
262+ color="warning"
263+ variant="subtle"
264+ icon="i-lucide-triangle-alert"
265+ :title="mismatchTitle"
266+ description="It will be set to the item name when you save."
267+ />
268+
269+ <!-- Markdown editor -->
270+ <template v-if="isMd">
271+ <div class="flex items-center justify-between">
272+ <h2 class="text-sm font-semibold">{{ type === 'skill' ? 'SKILL.md' : `${name ?? form.name}.md` }}</h2>
273+ <UTabs v-model="view" :items="VIEWS" :content="false" size="xs" variant="pill" color="neutral" class="w-auto" />
274+ </div>
275+ <div class="grid gap-3" :class="view === 'split' ? 'lg:grid-cols-2' : ''">
276+ <textarea v-if="view !== 'preview'" v-model="form.content" :class="TA" class="h-[32rem]" spellcheck="false" aria-label="Markdown content" />
277+ <div v-if="view !== 'edit'" class="h-[32rem] overflow-auto rounded-lg border border-default p-4">
278+ <div v-if="parsed.frontmatter" class="mb-3 overflow-hidden rounded-lg border border-default">
279+ <table class="w-full text-xs">
280+ <tbody class="divide-y divide-default">
281+ <tr v-for="(v, k) in parsed.frontmatter" :key="k">
282+ <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th>
283+ <td class="px-2 py-1.5 break-words">{{ v }}</td>
284+ </tr>
285+ </tbody>
286+ </table>
287+ </div>
288+ <!-- eslint-disable-next-line vue/no-v-html -->
289+ <div class="md-body" v-html="previewHtml" />
290+ </div>
291+ </div>
292+ </template>
293+
294+ <!-- MCP / hook form -->
295+ <template v-else>
296+ <div class="grid items-start gap-6 lg:grid-cols-2">
297+ <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" />
298+ <HookForm v-else v-model="hook" />
299+ <div class="space-y-2">
300+ <h2 class="text-sm font-semibold">{{ type === 'mcp' ? `mcp/${form.name || 'name'}.yaml` : `hooks/${form.name || 'name'}.yaml` }} <span class="font-normal text-muted">(read-only)</span></h2>
301+ <pre class="overflow-auto rounded-lg border border-default bg-elevated p-3 font-mono text-xs">{{ yaml }}</pre>
302+ <p v-if="type === 'hook'" class="text-xs text-muted">Hooks are stored in the catalog; enabling them per project is not available yet.</p>
303+ </div>
304+ </div>
305+ </template>
306+ </div>
307+
308+ <!-- Side column -->
309+ <aside class="space-y-5">
310+ <section class="space-y-1.5">
311+ <h3 class="text-sm font-semibold">Tags</h3>
312+ <UInputTags v-model="form.tags" placeholder="Add a tag, press Enter" class="w-full" />
313+ </section>
314+
315+ <section v-if="!isNew" class="space-y-1.5">
316+ <h3 class="text-sm font-semibold">Used by</h3>
317+ <ul v-if="usedBy.length" class="space-y-1 text-sm">
318+ <li v-for="s in usedBy" :key="s">
319+ <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline">
320+ <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" />
321+ {{ scopeLabel(s, scopeNames) }}
322+ </NuxtLink>
323+ </li>
324+ </ul>
325+ <p v-else class="text-xs text-muted">Not used by any scope.</p>
326+ </section>
327+
328+ <section v-if="detail" class="space-y-1 text-xs text-muted">
329+ <h3 class="text-sm font-semibold text-default">Details</h3>
330+ <p>Origin: <span class="text-default">{{ detail.item.origin?.kind ?? 'created' }}</span><span v-if="detail.item.origin?.source"> ({{ detail.item.origin.source }})</span></p>
331+ <p>Updated: <span class="text-default">{{ formatDate(detail.item.updatedAt) }}</span></p>
332+ <p class="break-all font-mono" :title="detail.item.path">{{ detail.item.path }}</p>
333+ </section>
334+
335+ <!-- Skill files -->
336+ <section v-if="type === 'skill'" class="space-y-2">
337+ <h3 class="text-sm font-semibold">Extra files</h3>
338+ <ul v-if="existingFiles.length || Object.keys(extraFiles).length" class="divide-y divide-default rounded-lg border border-default text-xs">
339+ <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-1 px-2 py-1" :class="deleteFiles.includes(f.path) ? 'opacity-50' : ''">
340+ <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left font-mono hover:text-highlighted" :class="deleteFiles.includes(f.path) ? 'line-through' : ''" @click="viewFile = f.path">
341+ <UIcon name="i-lucide-file" class="size-3.5 shrink-0 text-muted" /><span class="truncate">{{ f.path }}</span>
342+ </button>
343+ <span class="shrink-0 text-dimmed">{{ formatBytes(f.sizeBytes) }}</span>
344+ <UButton :icon="deleteFiles.includes(f.path) ? 'i-lucide-undo-2' : 'i-lucide-trash-2'" size="xs" color="neutral" variant="ghost" :aria-label="deleteFiles.includes(f.path) ? 'Keep file' : 'Delete file'" @click="toggleDelete(f.path)" />
345+ </li>
346+ <li v-for="(_c, p) in extraFiles" :key="p" class="flex items-center gap-1 px-2 py-1">
347+ <UIcon name="i-lucide-file-plus" class="size-3.5 shrink-0 text-success" />
348+ <span class="min-w-0 flex-1 truncate font-mono">{{ p }}</span>
349+ <UBadge label="new" size="sm" color="success" variant="subtle" />
350+ <UButton icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Discard new file" @click="dropNew(p as string)" />
351+ </li>
352+ </ul>
353+ <p v-else class="text-xs text-muted">SKILL.md only.</p>
354+
355+ <div class="space-y-1.5 rounded-lg border border-dashed border-default p-2">
356+ <UInput v-model="newFile.path" size="sm" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono' }" />
357+ <p v-if="newFileError" class="text-xs text-error">{{ newFileError }}</p>
358+ <textarea v-model="newFile.content" :class="TA" class="h-24" placeholder="Text content" spellcheck="false" aria-label="New file content" />
359+ <UButton label="Add file" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" />
360+ </div>
361+ </section>
362+ </aside>
363+ </div>
364+
365+ <!-- Skill file viewer -->
366+ <USlideover :open="!!viewFile" :title="viewFile ?? ''" :ui="{ content: 'sm:max-w-2xl' }" @update:open="(v: boolean) => { if (!v) viewFile = null }">
367+ <template #body>
368+ <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" />
369+ </template>
370+ </USlideover>
371+
372+ <!-- Delete -->
373+ <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the item from the catalog.">
374+ <template #body>
375+ <div class="space-y-3 text-sm">
376+ <p>
377+ <strong>{{ name }}</strong> will be deleted from the catalog folder. Scopes where it was already written keep their files,
378+ but the item will be reported as missing in the catalog.
379+ </p>
380+ <UAlert
381+ v-if="delConflict || usedBy.length"
382+ color="warning"
383+ variant="subtle"
384+ icon="i-lucide-triangle-alert"
385+ :title="delConflict ? 'This item is used' : 'Used by'"
386+ >
387+ <template #description>
388+ <p v-if="delConflict" class="mb-1">{{ delConflict }}</p>
389+ <ul class="list-disc pl-4">
390+ <li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li>
391+ </ul>
392+ </template>
393+ </UAlert>
394+ </div>
395+ </template>
396+ <template #footer>
397+ <div class="flex w-full justify-end gap-2">
398+ <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" />
399+ <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" />
400+ </div>
401+ </template>
402+ </UModal>
403+ </template>
404+ </UDashboardPanel>
405+</template>
new file mode 100644
@@ -0,0 +1,405 @@
1+<script setup lang="ts">
2+import type { CatalogItemDetail, CatalogItemType, HookTemplate, McpTemplate, SaveItemInput } from '@skilly/core'
3+
4+/** Catalog item editor: Markdown (skill / rule / agent) or form + YAML (mcp / hook). `name` is undefined for a new item. */
5+const props = defineProps<{ type: CatalogItemType, name?: string }>()
6+
7+const { request } = useApi()
8+const catalog = useCatalog()
9+const scopeNames = useScopeNames()
10+const toast = useToast()
11+
12+const isNew = computed(() => !props.name)
13+const meta = computed(() => CAT_META[props.type])
14+const isMd = computed(() => meta.value.markdown)
15+
16+const loading = ref(!!props.name)
17+const loadError = ref<string | null>(null)
18+const detail = ref<CatalogItemDetail | null>(null)
19+
20+const form = reactive({ name: props.name ?? '', content: '', tags: [] as string[] })
21+const mcp = ref<McpTemplate>({ name: '', transport: 'stdio', command: '', args: [] })
22+const hook = ref<HookTemplate>({ name: '', event: 'PreToolUse', handler: { type: 'command', command: '' } })
23+const extraFiles = ref<Record<string, string>>({})
24+const deleteFiles = ref<string[]>([])
25+const original = ref('')
26+const view = ref<'edit' | 'split' | 'preview'>('split')
27+const saving = ref(false)
28+const saveError = ref<string | null>(null)
29+
30+const yaml = computed(() => {
31+ if (props.type === 'mcp') return mcpToYaml({ ...mcp.value, name: form.name })
32+ if (props.type === 'hook') return hookToYaml({ ...hook.value, name: form.name })
33+ return ''
34+})
35+const mainContent = computed(() => (isMd.value ? form.content : yaml.value))
36+
37+const snapshot = () => JSON.stringify([form.name, mainContent.value, form.tags, extraFiles.value, deleteFiles.value])
38+const dirty = computed(() => snapshot() !== original.value)
39+
40+function seed(d: CatalogItemDetail | null) {
41+ detail.value = d
42+ extraFiles.value = {}
43+ deleteFiles.value = []
44+ if (d) {
45+ form.name = d.item.name
46+ form.content = d.content
47+ form.tags = [...d.item.tags]
48+ if (props.type === 'mcp' && d.template) mcp.value = structuredClone(toRaw(d.template)) as McpTemplate
49+ if (props.type === 'hook' && d.template) hook.value = structuredClone(toRaw(d.template)) as HookTemplate
50+ } else if (isMd.value) {
51+ form.content = newItemContent(props.type, form.name || 'new-item')
52+ }
53+ nextTick(() => { original.value = snapshot() })
54+}
55+
56+async function load() {
57+ if (isNew.value) return seed(null)
58+ loading.value = true
59+ loadError.value = null
60+ try {
61+ seed(await request<CatalogItemDetail>(`/api/catalog/items/${props.type}/${encodeURIComponent(props.name!)}`, { silent: true }))
62+ } catch (e) {
63+ loadError.value = (e as Error).message
64+ } finally {
65+ loading.value = false
66+ }
67+}
68+
69+onMounted(() => {
70+ if (!catalog.data.value) catalog.refresh()
71+ load()
72+})
73+
74+/* ------------------------------------------------ Markdown fields */
75+
76+const description = computed({
77+ get: () => getFm(form.content, 'description'),
78+ set: (v: string) => { form.content = setFm(form.content, 'description', v) }
79+})
80+function onName(v: string) {
81+ form.name = v
82+ if (isMd.value) form.content = setFm(form.content, 'name', v)
83+}
84+// New item: the name typed first also seeds the template when the content is still the untouched one.
85+const fmName = computed(() => getFm(form.content, 'name'))
86+
87+const parsed = computed(() => splitFrontmatter(form.content))
88+const previewHtml = computed(() => renderMarkdown(parsed.value.body))
89+
90+/* ------------------------------------------------ Validation */
91+
92+const problems = computed(() => {
93+ const p: string[] = []
94+ if (!form.name) p.push('A name is required.')
95+ else if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.')
96+ else if ((isNew.value || form.name !== props.name) && catalog.findItem(props.type, form.name)) p.push(`A ${meta.value.singular.toLowerCase()} named "${form.name}" already exists.`)
97+ if (isMd.value) {
98+ if (!parsed.value.frontmatter) p.push('The file has no frontmatter block (--- ... ---).')
99+ if (!description.value.trim()) p.push('The frontmatter needs a description.')
100+ } else if (props.type === 'mcp') {
101+ if (mcp.value.transport === 'stdio' && !mcp.value.command?.trim()) p.push('A command is required for stdio servers.')
102+ if (mcp.value.transport === 'http' && !mcp.value.url?.trim()) p.push('A URL is required for http servers.')
103+ } else if (!hook.value.event || !hook.value.handler.command.trim()) {
104+ p.push('A hook needs an event and a command.')
105+ }
106+ return p
107+})
108+const nameMismatch = computed(() => isMd.value && !!parsed.value.frontmatter && fmName.value !== form.name && !!form.name)
109+const mismatchTitle = computed(() => `Frontmatter name is ${fmName.value ? '"' + fmName.value + '"' : 'missing'}`)
110+const renaming = computed(() => !isNew.value && form.name !== props.name)
111+
112+/* ------------------------------------------------ Save */
113+
114+async function save() {
115+ if (problems.value.length) return
116+ saving.value = true
117+ saveError.value = null
118+ const content = isMd.value && nameMismatch.value ? setFm(form.content, 'name', form.name) : mainContent.value
119+ const body: SaveItemInput = {
120+ type: props.type,
121+ name: form.name,
122+ content,
123+ tags: form.tags,
124+ ...(props.type === 'skill' ? { files: { ...extraFiles.value }, deleteFiles: [...deleteFiles.value] } : {}),
125+ ...(renaming.value ? { previousName: props.name } : {})
126+ }
127+ try {
128+ await request(`/api/catalog/items/${props.type}/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true })
129+ toast.add({ title: isNew.value ? 'Created' : 'Saved', description: form.name, icon: 'i-lucide-check', color: 'success' })
130+ await catalog.refresh()
131+ if (isNew.value || renaming.value) {
132+ original.value = snapshot()
133+ await navigateTo(`/catalog/${props.type}/${encodeURIComponent(form.name)}`, { replace: true })
134+ } else {
135+ form.content = content
136+ await load()
137+ }
138+ } catch (e) {
139+ saveError.value = (e as Error).message
140+ } finally {
141+ saving.value = false
142+ }
143+}
144+
145+onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?'))
146+
147+/* ------------------------------------------------ Delete */
148+
149+const delOpen = ref(false)
150+const deleting = ref(false)
151+const delConflict = ref<string | null>(null)
152+const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : []))
153+
154+async function doDelete(force = false) {
155+ deleting.value = true
156+ try {
157+ await request(`/api/catalog/items/${props.type}/${encodeURIComponent(props.name!)}`, { method: 'DELETE', query: force ? { force: '1' } : undefined, silent: true })
158+ original.value = snapshot()
159+ toast.add({ title: 'Deleted', description: props.name, icon: 'i-lucide-check', color: 'success' })
160+ delOpen.value = false
161+ await catalog.refresh()
162+ await navigateTo(`/catalog?type=${props.type}`)
163+ } catch (e) {
164+ const err = e as { status?: number, message: string }
165+ if (err.status === 409) delConflict.value = err.message
166+ else toast.add({ title: 'Delete failed', description: err.message, color: 'error', icon: 'i-lucide-circle-alert' })
167+ } finally {
168+ deleting.value = false
169+ }
170+}
171+function askDelete() {
172+ delConflict.value = null
173+ delOpen.value = true
174+}
175+
176+/* ------------------------------------------------ Skill files */
177+
178+const existingFiles = computed(() => (detail.value?.files ?? []).filter(f => f.path !== 'SKILL.md'))
179+const viewFile = ref<string | null>(null)
180+const newFile = reactive({ path: '', content: '' })
181+const newFileError = computed(() => {
182+ const p = newFile.path.trim()
183+ if (!p) return null
184+ if (/^([/\\]|[a-zA-Z]:)/.test(p) || p.split(/[\\/]/).includes('..')) return 'Use a relative path inside the skill folder.'
185+ if (p === 'SKILL.md') return 'SKILL.md is edited above.'
186+ if (extraFiles.value[p] !== undefined || existingFiles.value.some(f => f.path === p)) return 'This file already exists.'
187+ return null
188+})
189+function addFile() {
190+ if (!newFile.path.trim() || newFileError.value) return
191+ extraFiles.value = { ...extraFiles.value, [newFile.path.trim().replace(/\\/g, '/')]: newFile.content }
192+ newFile.path = ''
193+ newFile.content = ''
194+}
195+function dropNew(p: string) {
196+ const { [p]: _gone, ...rest } = extraFiles.value
197+ extraFiles.value = rest
198+}
199+function toggleDelete(p: string) {
200+ deleteFiles.value = deleteFiles.value.includes(p) ? deleteFiles.value.filter(x => x !== p) : [...deleteFiles.value, p]
201+}
202+
203+const VIEWS = [
204+ { label: 'Edit', value: 'edit' },
205+ { label: 'Split', value: 'split' },
206+ { label: 'Preview', value: 'preview' }
207+]
208+const TA = 'block w-full resize-y rounded-lg border border-default bg-default p-3 font-mono text-xs leading-relaxed outline-none focus:border-primary focus:ring-1 focus:ring-primary'
209+</script>
210+
211+<template>
212+ <UDashboardPanel :id="`catalog-item-${type}`">
213+ <template #header>
214+ <UDashboardNavbar icon="i-lucide-library">
215+ <template #title>
216+ <NuxtLink :to="`/catalog?type=${type}`" class="text-muted hover:text-default">Catalog</NuxtLink>
217+ <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />
218+ <span class="text-muted">{{ meta.label }}</span>
219+ <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />
220+ <span class="truncate">{{ isNew ? 'New' : name }}</span>
221+ </template>
222+ <template #right>
223+ <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" />
224+ <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" />
225+ <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" />
226+ </template>
227+ </UDashboardNavbar>
228+ </template>
229+
230+ <template #body>
231+ <div v-if="loading" class="space-y-3">
232+ <USkeleton class="h-10 w-1/2" />
233+ <USkeleton class="h-72 w-full" />
234+ </div>
235+ <UAlert v-else-if="loadError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load this item" :description="loadError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: load }]" />
236+
237+ <div v-else class="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
238+ <!-- Main column -->
239+ <div class="min-w-0 space-y-4">
240+ <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" />
241+
242+ <div class="grid gap-4 sm:grid-cols-2">
243+ <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required>
244+ <UInput :model-value="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" @update:model-value="onName" />
245+ </UFormField>
246+ <UFormField v-if="isMd" label="Description" required :description="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined">
247+ <UInput v-model="description" class="w-full" />
248+ </UFormField>
249+ </div>
250+
251+ <UAlert v-if="renaming" color="info" variant="subtle" icon="i-lucide-info" title="Renaming" description="The item is renamed in the catalog. Scopes that reference the old name will report it as missing until they are updated." />
252+
253+ <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" title="Fix before saving">
254+ <template #description>
255+ <ul class="list-disc pl-4">
256+ <li v-for="p in problems" :key="p">{{ p }}</li>
257+ </ul>
258+ </template>
259+ </UAlert>
260+ <UAlert
261+ v-else-if="nameMismatch"
262+ color="warning"
263+ variant="subtle"
264+ icon="i-lucide-triangle-alert"
265+ :title="mismatchTitle"
266+ description="It will be set to the item name when you save."
267+ />
268+
269+ <!-- Markdown editor -->
270+ <template v-if="isMd">
271+ <div class="flex items-center justify-between">
272+ <h2 class="text-sm font-semibold">{{ type === 'skill' ? 'SKILL.md' : `${name ?? form.name}.md` }}</h2>
273+ <UTabs v-model="view" :items="VIEWS" :content="false" size="xs" variant="pill" color="neutral" class="w-auto" />
274+ </div>
275+ <div class="grid gap-3" :class="view === 'split' ? 'lg:grid-cols-2' : ''">
276+ <textarea v-if="view !== 'preview'" v-model="form.content" :class="TA" class="h-[32rem]" spellcheck="false" aria-label="Markdown content" />
277+ <div v-if="view !== 'edit'" class="h-[32rem] overflow-auto rounded-lg border border-default p-4">
278+ <div v-if="parsed.frontmatter" class="mb-3 overflow-hidden rounded-lg border border-default">
279+ <table class="w-full text-xs">
280+ <tbody class="divide-y divide-default">
281+ <tr v-for="(v, k) in parsed.frontmatter" :key="k">
282+ <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th>
283+ <td class="px-2 py-1.5 break-words">{{ v }}</td>
284+ </tr>
285+ </tbody>
286+ </table>
287+ </div>
288+ <!-- eslint-disable-next-line vue/no-v-html -->
289+ <div class="md-body" v-html="previewHtml" />
290+ </div>
291+ </div>
292+ </template>
293+
294+ <!-- MCP / hook form -->
295+ <template v-else>
296+ <div class="grid items-start gap-6 lg:grid-cols-2">
297+ <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" />
298+ <HookForm v-else v-model="hook" />
299+ <div class="space-y-2">
300+ <h2 class="text-sm font-semibold">{{ type === 'mcp' ? `mcp/${form.name || 'name'}.yaml` : `hooks/${form.name || 'name'}.yaml` }} <span class="font-normal text-muted">(read-only)</span></h2>
301+ <pre class="overflow-auto rounded-lg border border-default bg-elevated p-3 font-mono text-xs">{{ yaml }}</pre>
302+ <p v-if="type === 'hook'" class="text-xs text-muted">Hooks are stored in the catalog; enabling them per project is not available yet.</p>
303+ </div>
304+ </div>
305+ </template>
306+ </div>
307+
308+ <!-- Side column -->
309+ <aside class="space-y-5">
310+ <section class="space-y-1.5">
311+ <h3 class="text-sm font-semibold">Tags</h3>
312+ <UInputTags v-model="form.tags" placeholder="Add a tag, press Enter" class="w-full" />
313+ </section>
314+
315+ <section v-if="!isNew" class="space-y-1.5">
316+ <h3 class="text-sm font-semibold">Used by</h3>
317+ <ul v-if="usedBy.length" class="space-y-1 text-sm">
318+ <li v-for="s in usedBy" :key="s">
319+ <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline">
320+ <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" />
321+ {{ scopeLabel(s, scopeNames) }}
322+ </NuxtLink>
323+ </li>
324+ </ul>
325+ <p v-else class="text-xs text-muted">Not used by any scope.</p>
326+ </section>
327+
328+ <section v-if="detail" class="space-y-1 text-xs text-muted">
329+ <h3 class="text-sm font-semibold text-default">Details</h3>
330+ <p>Origin: <span class="text-default">{{ detail.item.origin?.kind ?? 'created' }}</span><span v-if="detail.item.origin?.source"> ({{ detail.item.origin.source }})</span></p>
331+ <p>Updated: <span class="text-default">{{ formatDate(detail.item.updatedAt) }}</span></p>
332+ <p class="break-all font-mono" :title="detail.item.path">{{ detail.item.path }}</p>
333+ </section>
334+
335+ <!-- Skill files -->
336+ <section v-if="type === 'skill'" class="space-y-2">
337+ <h3 class="text-sm font-semibold">Extra files</h3>
338+ <ul v-if="existingFiles.length || Object.keys(extraFiles).length" class="divide-y divide-default rounded-lg border border-default text-xs">
339+ <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-1 px-2 py-1" :class="deleteFiles.includes(f.path) ? 'opacity-50' : ''">
340+ <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left font-mono hover:text-highlighted" :class="deleteFiles.includes(f.path) ? 'line-through' : ''" @click="viewFile = f.path">
341+ <UIcon name="i-lucide-file" class="size-3.5 shrink-0 text-muted" /><span class="truncate">{{ f.path }}</span>
342+ </button>
343+ <span class="shrink-0 text-dimmed">{{ formatBytes(f.sizeBytes) }}</span>
344+ <UButton :icon="deleteFiles.includes(f.path) ? 'i-lucide-undo-2' : 'i-lucide-trash-2'" size="xs" color="neutral" variant="ghost" :aria-label="deleteFiles.includes(f.path) ? 'Keep file' : 'Delete file'" @click="toggleDelete(f.path)" />
345+ </li>
346+ <li v-for="(_c, p) in extraFiles" :key="p" class="flex items-center gap-1 px-2 py-1">
347+ <UIcon name="i-lucide-file-plus" class="size-3.5 shrink-0 text-success" />
348+ <span class="min-w-0 flex-1 truncate font-mono">{{ p }}</span>
349+ <UBadge label="new" size="sm" color="success" variant="subtle" />
350+ <UButton icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Discard new file" @click="dropNew(p as string)" />
351+ </li>
352+ </ul>
353+ <p v-else class="text-xs text-muted">SKILL.md only.</p>
354+
355+ <div class="space-y-1.5 rounded-lg border border-dashed border-default p-2">
356+ <UInput v-model="newFile.path" size="sm" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono' }" />
357+ <p v-if="newFileError" class="text-xs text-error">{{ newFileError }}</p>
358+ <textarea v-model="newFile.content" :class="TA" class="h-24" placeholder="Text content" spellcheck="false" aria-label="New file content" />
359+ <UButton label="Add file" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" />
360+ </div>
361+ </section>
362+ </aside>
363+ </div>
364+
365+ <!-- Skill file viewer -->
366+ <USlideover :open="!!viewFile" :title="viewFile ?? ''" :ui="{ content: 'sm:max-w-2xl' }" @update:open="(v: boolean) => { if (!v) viewFile = null }">
367+ <template #body>
368+ <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" />
369+ </template>
370+ </USlideover>
371+
372+ <!-- Delete -->
373+ <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the item from the catalog.">
374+ <template #body>
375+ <div class="space-y-3 text-sm">
376+ <p>
377+ <strong>{{ name }}</strong> will be deleted from the catalog folder. Scopes where it was already written keep their files,
378+ but the item will be reported as missing in the catalog.
379+ </p>
380+ <UAlert
381+ v-if="delConflict || usedBy.length"
382+ color="warning"
383+ variant="subtle"
384+ icon="i-lucide-triangle-alert"
385+ :title="delConflict ? 'This item is used' : 'Used by'"
386+ >
387+ <template #description>
388+ <p v-if="delConflict" class="mb-1">{{ delConflict }}</p>
389+ <ul class="list-disc pl-4">
390+ <li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li>
391+ </ul>
392+ </template>
393+ </UAlert>
394+ </div>
395+ </template>
396+ <template #footer>
397+ <div class="flex w-full justify-end gap-2">
398+ <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" />
399+ <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" />
400+ </div>
401+ </template>
402+ </UModal>
403+ </template>
404+ </UDashboardPanel>
405+</template>
added packages/ui/app/components/CatalogSetupCard.vue +42 -0
new file mode 100644
@@ -0,0 +1,42 @@
1+<script setup lang="ts">
2+const { request } = useApi()
3+const { init, refresh } = useCatalog()
4+const toast = useToast()
5+const path = ref('')
6+const creating = ref(false)
7+
8+onMounted(async () => {
9+ try {
10+ path.value = (await request<{ catalogPath: string }>('/api/settings', { silent: true })).catalogPath
11+ } catch { /* shown by the page */ }
12+})
13+
14+async function create() {
15+ creating.value = true
16+ try {
17+ await init()
18+ toast.add({ title: 'Catalog created', description: path.value, icon: 'i-lucide-check', color: 'success' })
19+ await refresh()
20+ } catch {
21+ // toast shown by useApi
22+ } finally {
23+ creating.value = false
24+ }
25+}
26+</script>
27+
28+<template>
29+ <div class="mx-auto flex max-w-xl flex-col items-center gap-3 rounded-xl border border-dashed border-default px-6 py-12 text-center">
30+ <UIcon name="i-lucide-library" class="size-10 text-dimmed" />
31+ <h2 class="font-semibold">No catalog yet</h2>
32+ <p class="text-sm text-muted">
33+ The catalog is a git repository holding your skills, rules, MCP servers, hooks, agents and presets.
34+ Create one at the location below, or point skilly to an existing repository in Settings.
35+ </p>
36+ <p class="max-w-full truncate rounded bg-elevated px-2 py-1 font-mono text-xs" :title="path">{{ path || '...' }}</p>
37+ <div class="flex gap-2">
38+ <UButton label="Create catalog" icon="i-lucide-folder-plus" :loading="creating" :disabled="!path" @click="create" />
39+ <UButton label="Change location" to="/settings" icon="i-lucide-settings" color="neutral" variant="outline" />
40+ </div>
41+ </div>
42+</template>
new file mode 100644
@@ -0,0 +1,42 @@
1+<script setup lang="ts">
2+const { request } = useApi()
3+const { init, refresh } = useCatalog()
4+const toast = useToast()
5+const path = ref('')
6+const creating = ref(false)
7+
8+onMounted(async () => {
9+ try {
10+ path.value = (await request<{ catalogPath: string }>('/api/settings', { silent: true })).catalogPath
11+ } catch { /* shown by the page */ }
12+})
13+
14+async function create() {
15+ creating.value = true
16+ try {
17+ await init()
18+ toast.add({ title: 'Catalog created', description: path.value, icon: 'i-lucide-check', color: 'success' })
19+ await refresh()
20+ } catch {
21+ // toast shown by useApi
22+ } finally {
23+ creating.value = false
24+ }
25+}
26+</script>
27+
28+<template>
29+ <div class="mx-auto flex max-w-xl flex-col items-center gap-3 rounded-xl border border-dashed border-default px-6 py-12 text-center">
30+ <UIcon name="i-lucide-library" class="size-10 text-dimmed" />
31+ <h2 class="font-semibold">No catalog yet</h2>
32+ <p class="text-sm text-muted">
33+ The catalog is a git repository holding your skills, rules, MCP servers, hooks, agents and presets.
34+ Create one at the location below, or point skilly to an existing repository in Settings.
35+ </p>
36+ <p class="max-w-full truncate rounded bg-elevated px-2 py-1 font-mono text-xs" :title="path">{{ path || '...' }}</p>
37+ <div class="flex gap-2">
38+ <UButton label="Create catalog" icon="i-lucide-folder-plus" :loading="creating" :disabled="!path" @click="create" />
39+ <UButton label="Change location" to="/settings" icon="i-lucide-settings" color="neutral" variant="outline" />
40+ </div>
41+ </div>
42+</template>
modified packages/ui/app/components/CountChips.vue +4 -4
@@ -1,13 +1,13 @@
11 <script setup lang="ts">
22 import type { ItemType } from '@skilly/core'
33
4-defineProps<{ counts: Record<ItemType, number> }>()
4+defineProps<{ counts: Record<ItemType, number>, compact?: boolean }>()
55 </script>
66
77 <template>
8- <div class="flex flex-wrap gap-x-4 gap-y-1.5">
9- <div v-for="t in TYPE_ORDER" :key="t" class="flex items-center gap-1.5 text-sm" :class="counts[t] ? '' : 'text-dimmed'">
10- <UIcon :name="TYPE_META[t].icon" class="size-4" />
8+ <div class="flex flex-wrap" :class="compact ? 'gap-x-3 gap-y-1' : 'gap-x-4 gap-y-1.5'">
9+ <div v-for="t in TYPE_ORDER" :key="t" class="flex items-center gap-1" :class="[counts[t] ? '' : 'text-dimmed', compact ? 'text-xs' : 'text-sm']" :title="TYPE_META[t].label">
10+ <UIcon :name="TYPE_META[t].icon" class="size-3.5" />
1111 <span class="font-medium tabular-nums">{{ counts[t] ?? 0 }}</span>
1212 <span class="text-muted">{{ TYPE_META[t].label }}</span>
1313 </div>
@@ -1,13 +1,13 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 import type { ItemType } from '@skilly/core'2 import type { ItemType } from '@skilly/core'
3 3
4-defineProps<{ counts: Record<ItemType, number> }>()4+defineProps<{ counts: Record<ItemType, number>, compact?: boolean }>()
5 </script>5 </script>
6 6
7 <template>7 <template>
8- <div class="flex flex-wrap gap-x-4 gap-y-1.5">8+ <div class="flex flex-wrap" :class="compact ? 'gap-x-3 gap-y-1' : 'gap-x-4 gap-y-1.5'">
9- <div v-for="t in TYPE_ORDER" :key="t" class="flex items-center gap-1.5 text-sm" :class="counts[t] ? '' : 'text-dimmed'">9+ <div v-for="t in TYPE_ORDER" :key="t" class="flex items-center gap-1" :class="[counts[t] ? '' : 'text-dimmed', compact ? 'text-xs' : 'text-sm']" :title="TYPE_META[t].label">
10- <UIcon :name="TYPE_META[t].icon" class="size-4" />10+ <UIcon :name="TYPE_META[t].icon" class="size-3.5" />
11 <span class="font-medium tabular-nums">{{ counts[t] ?? 0 }}</span>11 <span class="font-medium tabular-nums">{{ counts[t] ?? 0 }}</span>
12 <span class="text-muted">{{ TYPE_META[t].label }}</span>12 <span class="text-muted">{{ TYPE_META[t].label }}</span>
13 </div>13 </div>
added packages/ui/app/components/EnvRows.vue +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+<script setup lang="ts">
2+import type { McpEnvValue } from '@skilly/core'
3+
4+/** Editable key -> (env var | literal) rows. Used for MCP `env` and `headers`. */
5+const props = defineProps<{ modelValue: Record<string, McpEnvValue> | undefined, keyLabel: string, keyPlaceholder: string }>()
6+const emit = defineEmits<{ 'update:modelValue': [v: Record<string, McpEnvValue>] }>()
7+
8+interface Row { key: string, mode: 'env' | 'literal', val: string }
9+
10+// Rows are the source of truth while editing (keys may be empty or duplicated until the user is done).
11+const rows = ref<Row[]>(Object.entries(props.modelValue ?? {}).map(([key, v]) => ({
12+ key,
13+ mode: v.fromEnv !== undefined ? 'env' : 'literal',
14+ val: v.fromEnv ?? v.value ?? ''
15+})))
16+
17+function push() {
18+ const out: Record<string, McpEnvValue> = {}
19+ for (const r of rows.value) {
20+ if (!r.key.trim()) continue
21+ out[r.key.trim()] = r.mode === 'env' ? { fromEnv: r.val } : { value: r.val }
22+ }
23+ emit('update:modelValue', out)
24+}
25+
26+const hasLiteral = computed(() => rows.value.some(r => r.mode === 'literal' && r.val))
27+
28+function add() {
29+ rows.value.push({ key: '', mode: 'env', val: '' })
30+}
31+function removeRow(i: number) {
32+ rows.value.splice(i, 1)
33+ push()
34+}
35+
36+const MODES = [
37+ { label: 'From env var', value: 'env' },
38+ { label: 'Literal value', value: 'literal' }
39+]
40+</script>
41+
42+<template>
43+ <div class="space-y-2">
44+ <div v-for="(r, i) in rows" :key="i" class="flex items-center gap-2">
45+ <UInput v-model="r.key" :placeholder="keyPlaceholder" :aria-label="keyLabel" class="w-48" :ui="{ base: 'font-mono' }" @update:model-value="push" />
46+ <USelect v-model="r.mode" :items="MODES" class="w-40" @update:model-value="push" />
47+ <UInput
48+ v-model="r.val"
49+ :placeholder="r.mode === 'env' ? 'ENV_VAR_NAME' : 'value (stored in clear)'"
50+ class="flex-1"
51+ :ui="{ base: 'font-mono' }"
52+ @update:model-value="push"
53+ />
54+ <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" aria-label="Remove row" @click="removeRow(i)" />
55+ </div>
56+ <UButton label="Add row" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" @click="add" />
57+ <UAlert
58+ v-if="hasLiteral"
59+ color="warning"
60+ variant="subtle"
61+ icon="i-lucide-triangle-alert"
62+ title="Literal values are stored in clear in the catalog"
63+ description="The catalog is a git repository that may be shared. Use a literal value only for non-secret settings; for tokens and keys, choose 'From env var'."
64+ />
65+ </div>
66+</template>
new file mode 100644
@@ -0,0 +1,66 @@
1+<script setup lang="ts">
2+import type { McpEnvValue } from '@skilly/core'
3+
4+/** Editable key -> (env var | literal) rows. Used for MCP `env` and `headers`. */
5+const props = defineProps<{ modelValue: Record<string, McpEnvValue> | undefined, keyLabel: string, keyPlaceholder: string }>()
6+const emit = defineEmits<{ 'update:modelValue': [v: Record<string, McpEnvValue>] }>()
7+
8+interface Row { key: string, mode: 'env' | 'literal', val: string }
9+
10+// Rows are the source of truth while editing (keys may be empty or duplicated until the user is done).
11+const rows = ref<Row[]>(Object.entries(props.modelValue ?? {}).map(([key, v]) => ({
12+ key,
13+ mode: v.fromEnv !== undefined ? 'env' : 'literal',
14+ val: v.fromEnv ?? v.value ?? ''
15+})))
16+
17+function push() {
18+ const out: Record<string, McpEnvValue> = {}
19+ for (const r of rows.value) {
20+ if (!r.key.trim()) continue
21+ out[r.key.trim()] = r.mode === 'env' ? { fromEnv: r.val } : { value: r.val }
22+ }
23+ emit('update:modelValue', out)
24+}
25+
26+const hasLiteral = computed(() => rows.value.some(r => r.mode === 'literal' && r.val))
27+
28+function add() {
29+ rows.value.push({ key: '', mode: 'env', val: '' })
30+}
31+function removeRow(i: number) {
32+ rows.value.splice(i, 1)
33+ push()
34+}
35+
36+const MODES = [
37+ { label: 'From env var', value: 'env' },
38+ { label: 'Literal value', value: 'literal' }
39+]
40+</script>
41+
42+<template>
43+ <div class="space-y-2">
44+ <div v-for="(r, i) in rows" :key="i" class="flex items-center gap-2">
45+ <UInput v-model="r.key" :placeholder="keyPlaceholder" :aria-label="keyLabel" class="w-48" :ui="{ base: 'font-mono' }" @update:model-value="push" />
46+ <USelect v-model="r.mode" :items="MODES" class="w-40" @update:model-value="push" />
47+ <UInput
48+ v-model="r.val"
49+ :placeholder="r.mode === 'env' ? 'ENV_VAR_NAME' : 'value (stored in clear)'"
50+ class="flex-1"
51+ :ui="{ base: 'font-mono' }"
52+ @update:model-value="push"
53+ />
54+ <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" aria-label="Remove row" @click="removeRow(i)" />
55+ </div>
56+ <UButton label="Add row" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" @click="add" />
57+ <UAlert
58+ v-if="hasLiteral"
59+ color="warning"
60+ variant="subtle"
61+ icon="i-lucide-triangle-alert"
62+ title="Literal values are stored in clear in the catalog"
63+ description="The catalog is a git repository that may be shared. Use a literal value only for non-secret settings; for tokens and keys, choose 'From env var'."
64+ />
65+ </div>
66+</template>
added packages/ui/app/components/FolderBrowser.vue +88 -0
new file mode 100644
@@ -0,0 +1,88 @@
1+<script setup lang="ts">
2+import type { FsListing } from '@skilly/core'
3+
4+/** Folder browser backed by /api/fs/list. `selected` is the highlighted folder, falling back to the folder being browsed. */
5+const props = defineProps<{ start?: string, height?: string }>()
6+const selected = defineModel<string | null>('selected', { default: null })
7+
8+
9+const { request } = useApi()
10+const listing = ref<FsListing | null>(null)
11+const loading = ref(false)
12+const listError = ref<string | null>(null)
13+const highlighted = ref<string | null>(null)
14+
15+const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : []))
16+
17+async function browse(path?: string) {
18+ loading.value = true
19+ listError.value = null
20+ highlighted.value = null
21+ try {
22+ listing.value = await request<FsListing>('/api/fs/list', { query: { path }, silent: true })
23+ selected.value = listing.value.path
24+ } catch (e) {
25+ listError.value = (e as Error).message
26+ } finally {
27+ loading.value = false
28+ }
29+}
30+
31+function highlight(path: string) {
32+ highlighted.value = path
33+ selected.value = path
34+}
35+
36+function reset() { browse(props.start || undefined) }
37+defineExpose({ reset })
38+onMounted(reset)
39+</script>
40+
41+<template>
42+ <div class="space-y-3">
43+ <div class="flex flex-wrap items-center gap-1 text-sm">
44+ <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" />
45+ <template v-for="c in crumbs" :key="c.path">
46+ <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" />
47+ <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" />
48+ </template>
49+ </div>
50+
51+ <div class="overflow-y-auto rounded-lg border border-default" :class="height ?? 'h-72'">
52+ <div v-if="loading" class="space-y-1 p-2">
53+ <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" />
54+ </div>
55+ <div v-else-if="listError" class="p-3">
56+ <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" />
57+ </div>
58+ <ul v-else-if="listing" class="divide-y divide-default text-sm">
59+ <li v-if="listing.parent">
60+ <button type="button" class="flex w-full items-center gap-2 px-3 py-2 hover:bg-elevated/60" @click="browse(listing.parent!)">
61+ <UIcon name="i-lucide-corner-left-up" class="size-4 text-muted" />
62+ <span class="text-muted">..</span>
63+ </button>
64+ </li>
65+ <li
66+ v-for="e in listing.entries"
67+ :key="e.path"
68+ class="flex items-center hover:bg-elevated/60"
69+ :class="highlighted === e.path ? 'bg-primary/10' : ''"
70+ >
71+ <button
72+ type="button"
73+ class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left"
74+ :title="e.path"
75+ @click="highlight(e.path)"
76+ @dblclick="browse(e.path)"
77+ >
78+ <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" />
79+ <span class="flex-1 truncate">{{ e.name }}</span>
80+ <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" />
81+ </button>
82+ <UButton icon="i-lucide-chevron-right" size="xs" color="neutral" variant="ghost" class="mx-2 shrink-0" :aria-label="`Open ${e.name}`" @click="browse(e.path)" />
83+ </li>
84+ <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li>
85+ </ul>
86+ </div>
87+ </div>
88+</template>
new file mode 100644
@@ -0,0 +1,88 @@
1+<script setup lang="ts">
2+import type { FsListing } from '@skilly/core'
3+
4+/** Folder browser backed by /api/fs/list. `selected` is the highlighted folder, falling back to the folder being browsed. */
5+const props = defineProps<{ start?: string, height?: string }>()
6+const selected = defineModel<string | null>('selected', { default: null })
7+
8+
9+const { request } = useApi()
10+const listing = ref<FsListing | null>(null)
11+const loading = ref(false)
12+const listError = ref<string | null>(null)
13+const highlighted = ref<string | null>(null)
14+
15+const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : []))
16+
17+async function browse(path?: string) {
18+ loading.value = true
19+ listError.value = null
20+ highlighted.value = null
21+ try {
22+ listing.value = await request<FsListing>('/api/fs/list', { query: { path }, silent: true })
23+ selected.value = listing.value.path
24+ } catch (e) {
25+ listError.value = (e as Error).message
26+ } finally {
27+ loading.value = false
28+ }
29+}
30+
31+function highlight(path: string) {
32+ highlighted.value = path
33+ selected.value = path
34+}
35+
36+function reset() { browse(props.start || undefined) }
37+defineExpose({ reset })
38+onMounted(reset)
39+</script>
40+
41+<template>
42+ <div class="space-y-3">
43+ <div class="flex flex-wrap items-center gap-1 text-sm">
44+ <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" />
45+ <template v-for="c in crumbs" :key="c.path">
46+ <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" />
47+ <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" />
48+ </template>
49+ </div>
50+
51+ <div class="overflow-y-auto rounded-lg border border-default" :class="height ?? 'h-72'">
52+ <div v-if="loading" class="space-y-1 p-2">
53+ <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" />
54+ </div>
55+ <div v-else-if="listError" class="p-3">
56+ <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" />
57+ </div>
58+ <ul v-else-if="listing" class="divide-y divide-default text-sm">
59+ <li v-if="listing.parent">
60+ <button type="button" class="flex w-full items-center gap-2 px-3 py-2 hover:bg-elevated/60" @click="browse(listing.parent!)">
61+ <UIcon name="i-lucide-corner-left-up" class="size-4 text-muted" />
62+ <span class="text-muted">..</span>
63+ </button>
64+ </li>
65+ <li
66+ v-for="e in listing.entries"
67+ :key="e.path"
68+ class="flex items-center hover:bg-elevated/60"
69+ :class="highlighted === e.path ? 'bg-primary/10' : ''"
70+ >
71+ <button
72+ type="button"
73+ class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left"
74+ :title="e.path"
75+ @click="highlight(e.path)"
76+ @dblclick="browse(e.path)"
77+ >
78+ <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" />
79+ <span class="flex-1 truncate">{{ e.name }}</span>
80+ <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" />
81+ </button>
82+ <UButton icon="i-lucide-chevron-right" size="xs" color="neutral" variant="ghost" class="mx-2 shrink-0" :aria-label="`Open ${e.name}`" @click="browse(e.path)" />
83+ </li>
84+ <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li>
85+ </ul>
86+ </div>
87+ </div>
88+</template>
added packages/ui/app/components/FolderPickerModal.vue +29 -0
new file mode 100644
@@ -0,0 +1,29 @@
1+<script setup lang="ts">
2+const open = defineModel<boolean>('open', { default: false })
3+defineProps<{ title?: string, start?: string }>()
4+const emit = defineEmits<{ pick: [path: string] }>()
5+const selected = ref<string | null>(null)
6+
7+function confirm() {
8+ if (!selected.value) return
9+ emit('pick', selected.value)
10+ open.value = false
11+}
12+</script>
13+
14+<template>
15+ <UModal v-model:open="open" :title="title ?? 'Choose a folder'" description="Browse to a folder, then select it." :ui="{ content: 'max-w-2xl' }">
16+ <template #body>
17+ <FolderBrowser v-if="open" v-model:selected="selected" :start="start" />
18+ </template>
19+ <template #footer>
20+ <div class="flex w-full items-center justify-between gap-2">
21+ <span class="truncate font-mono text-xs text-muted" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span>
22+ <div class="flex shrink-0 gap-2">
23+ <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" />
24+ <UButton label="Select this folder" icon="i-lucide-check" :disabled="!selected" @click="confirm" />
25+ </div>
26+ </div>
27+ </template>
28+ </UModal>
29+</template>
new file mode 100644
@@ -0,0 +1,29 @@
1+<script setup lang="ts">
2+const open = defineModel<boolean>('open', { default: false })
3+defineProps<{ title?: string, start?: string }>()
4+const emit = defineEmits<{ pick: [path: string] }>()
5+const selected = ref<string | null>(null)
6+
7+function confirm() {
8+ if (!selected.value) return
9+ emit('pick', selected.value)
10+ open.value = false
11+}
12+</script>
13+
14+<template>
15+ <UModal v-model:open="open" :title="title ?? 'Choose a folder'" description="Browse to a folder, then select it." :ui="{ content: 'max-w-2xl' }">
16+ <template #body>
17+ <FolderBrowser v-if="open" v-model:selected="selected" :start="start" />
18+ </template>
19+ <template #footer>
20+ <div class="flex w-full items-center justify-between gap-2">
21+ <span class="truncate font-mono text-xs text-muted" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span>
22+ <div class="flex shrink-0 gap-2">
23+ <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" />
24+ <UButton label="Select this folder" icon="i-lucide-check" :disabled="!selected" @click="confirm" />
25+ </div>
26+ </div>
27+ </template>
28+ </UModal>
29+</template>
added packages/ui/app/components/HookForm.vue +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+<script setup lang="ts">
2+import type { AgentId, HookTemplate } from '@skilly/core'
3+
4+/** Form for a hook template. The parent owns the object (v-model) and renders the YAML. */
5+const t = defineModel<HookTemplate>({ required: true })
6+
7+const events = computed(() => (HOOK_EVENTS.includes(t.value.event) || !t.value.event ? HOOK_EVENTS : [t.value.event, ...HOOK_EVENTS]))
8+
9+function patch(p: Partial<HookTemplate>) {
10+ t.value = { ...t.value, ...p }
11+}
12+function patchHandler(p: Partial<HookTemplate['handler']>) {
13+ t.value = { ...t.value, handler: { ...t.value.handler, ...p } }
14+}
15+
16+const agents = computed(() => t.value.agents ?? [])
17+function toggleAgent(a: AgentId, on: boolean | 'indeterminate') {
18+ const set = new Set(agents.value)
19+ if (on === true) set.add(a)
20+ else set.delete(a)
21+ patch({ agents: set.size ? [...set] : undefined })
22+}
23+</script>
24+
25+<template>
26+ <div class="space-y-4">
27+ <UFormField label="Description">
28+ <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v })" />
29+ </UFormField>
30+
31+ <div class="grid gap-4 sm:grid-cols-2">
32+ <UFormField label="Event" required>
33+ <USelect :model-value="t.event" :items="events" placeholder="Choose an event" class="w-full" @update:model-value="(v: string) => patch({ event: v })" />
34+ </UFormField>
35+ <UFormField label="Matcher" description="Tool name or regex; empty matches everything.">
36+ <UInput :model-value="t.matcher ?? ''" class="w-full" placeholder="Edit|Write" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ matcher: v || undefined })" />
37+ </UFormField>
38+ </div>
39+
40+ <UFormField label="Command" required description="Shell command run by the agent. skilly never executes it.">
41+ <UInput :model-value="t.handler.command" class="w-full" placeholder="prettier --write $FILE" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patchHandler({ command: v })" />
42+ </UFormField>
43+
44+ <div class="grid gap-4 sm:grid-cols-2">
45+ <UFormField label="Timeout (seconds)">
46+ <UInputNumber :model-value="t.handler.timeout" :min="1" class="w-40" @update:model-value="(v: number | null | undefined) => patchHandler({ timeout: v || undefined })" />
47+ </UFormField>
48+ <UFormField label="Agents" description="None selected means all target agents.">
49+ <div class="flex gap-4 pt-1">
50+ <UCheckbox label="Claude Code" :model-value="agents.includes('claude')" @update:model-value="(v) => toggleAgent('claude', v)" />
51+ <UCheckbox label="Codex" :model-value="agents.includes('codex')" @update:model-value="(v) => toggleAgent('codex', v)" />
52+ </div>
53+ </UFormField>
54+ </div>
55+ </div>
56+</template>
new file mode 100644
@@ -0,0 +1,56 @@
1+<script setup lang="ts">
2+import type { AgentId, HookTemplate } from '@skilly/core'
3+
4+/** Form for a hook template. The parent owns the object (v-model) and renders the YAML. */
5+const t = defineModel<HookTemplate>({ required: true })
6+
7+const events = computed(() => (HOOK_EVENTS.includes(t.value.event) || !t.value.event ? HOOK_EVENTS : [t.value.event, ...HOOK_EVENTS]))
8+
9+function patch(p: Partial<HookTemplate>) {
10+ t.value = { ...t.value, ...p }
11+}
12+function patchHandler(p: Partial<HookTemplate['handler']>) {
13+ t.value = { ...t.value, handler: { ...t.value.handler, ...p } }
14+}
15+
16+const agents = computed(() => t.value.agents ?? [])
17+function toggleAgent(a: AgentId, on: boolean | 'indeterminate') {
18+ const set = new Set(agents.value)
19+ if (on === true) set.add(a)
20+ else set.delete(a)
21+ patch({ agents: set.size ? [...set] : undefined })
22+}
23+</script>
24+
25+<template>
26+ <div class="space-y-4">
27+ <UFormField label="Description">
28+ <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v })" />
29+ </UFormField>
30+
31+ <div class="grid gap-4 sm:grid-cols-2">
32+ <UFormField label="Event" required>
33+ <USelect :model-value="t.event" :items="events" placeholder="Choose an event" class="w-full" @update:model-value="(v: string) => patch({ event: v })" />
34+ </UFormField>
35+ <UFormField label="Matcher" description="Tool name or regex; empty matches everything.">
36+ <UInput :model-value="t.matcher ?? ''" class="w-full" placeholder="Edit|Write" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ matcher: v || undefined })" />
37+ </UFormField>
38+ </div>
39+
40+ <UFormField label="Command" required description="Shell command run by the agent. skilly never executes it.">
41+ <UInput :model-value="t.handler.command" class="w-full" placeholder="prettier --write $FILE" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patchHandler({ command: v })" />
42+ </UFormField>
43+
44+ <div class="grid gap-4 sm:grid-cols-2">
45+ <UFormField label="Timeout (seconds)">
46+ <UInputNumber :model-value="t.handler.timeout" :min="1" class="w-40" @update:model-value="(v: number | null | undefined) => patchHandler({ timeout: v || undefined })" />
47+ </UFormField>
48+ <UFormField label="Agents" description="None selected means all target agents.">
49+ <div class="flex gap-4 pt-1">
50+ <UCheckbox label="Claude Code" :model-value="agents.includes('claude')" @update:model-value="(v) => toggleAgent('claude', v)" />
51+ <UCheckbox label="Codex" :model-value="agents.includes('codex')" @update:model-value="(v) => toggleAgent('codex', v)" />
52+ </div>
53+ </UFormField>
54+ </div>
55+ </div>
56+</template>
added packages/ui/app/components/ItemPicker.vue +80 -0
new file mode 100644
@@ -0,0 +1,80 @@
1+<script setup lang="ts">
2+import type { CatalogItem, CatalogItemType } from '@skilly/core'
3+
4+/**
5+ * Checkbox picker over the catalog, per type with search.
6+ * `locked` maps `type/name` to a label: those rows are shown checked and disabled (already provided elsewhere).
7+ */
8+const props = withDefaults(defineProps<{
9+ items: CatalogItem[]
10+ types?: CatalogItemType[]
11+ locked?: Record<string, string>
12+ height?: string
13+}>(), { types: () => CAT_TYPES, locked: () => ({}), height: 'h-72' })
14+
15+const selected = defineModel<Partial<Record<CatalogItemType, string[]>>>({ required: true })
16+
17+const tab = ref<CatalogItemType>(props.types[0] ?? 'skill')
18+const search = ref('')
19+const LIMIT = 200
20+const showAll = ref(false)
21+
22+watch([tab, search], () => { showAll.value = false })
23+
24+const available = computed(() => props.items.filter(i => i.type === tab.value).sort((a, b) => a.name.localeCompare(b.name)))
25+const filtered = computed(() => {
26+ const q = search.value.trim().toLowerCase()
27+ return q ? available.value.filter(i => `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) : available.value
28+})
29+const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT)))
30+
31+const tabs = computed(() => props.types.map(t => ({
32+ label: CAT_META[t].label,
33+ icon: CAT_META[t].icon,
34+ value: t,
35+ badge: { label: String(selected.value[t]?.length ?? 0), color: (selected.value[t]?.length ? 'primary' : 'neutral') as 'primary' | 'neutral', variant: 'subtle' as const }
36+})))
37+
38+const isOn = (name: string) => !!selected.value[tab.value]?.includes(name)
39+
40+function toggle(name: string, on: boolean | 'indeterminate') {
41+ const cur = new Set(selected.value[tab.value] ?? [])
42+ if (on === true) cur.add(name)
43+ else cur.delete(name)
44+ selected.value = { ...selected.value, [tab.value]: [...cur] }
45+}
46+</script>
47+
48+<template>
49+ <div class="space-y-3">
50+ <UTabs v-model="tab" :items="tabs" :content="false" variant="link" size="sm" class="w-full" />
51+ <UInput v-model="search" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-full" />
52+ <div class="overflow-y-auto rounded-lg border border-default" :class="height">
53+ <ul v-if="shown.length" class="divide-y divide-default">
54+ <li v-for="i in shown" :key="i.name" class="flex items-start gap-2 px-3 py-2 hover:bg-elevated/50">
55+ <UCheckbox
56+ class="mt-0.5"
57+ :model-value="!!locked[itemKey(i.type, i.name)] || isOn(i.name)"
58+ :disabled="!!locked[itemKey(i.type, i.name)]"
59+ :aria-label="i.name"
60+ @update:model-value="(v) => toggle(i.name, v)"
61+ />
62+ <div class="min-w-0 flex-1">
63+ <div class="flex items-center gap-2">
64+ <span class="truncate text-sm font-medium">{{ i.name }}</span>
65+ <UBadge v-if="locked[itemKey(i.type, i.name)]" :label="locked[itemKey(i.type, i.name)]" size="sm" color="neutral" variant="subtle" />
66+ <UBadge v-for="t in i.tags.slice(0, 3)" :key="t" :label="t" size="sm" color="neutral" variant="outline" />
67+ </div>
68+ <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p>
69+ </div>
70+ </li>
71+ <li v-if="filtered.length > shown.length" class="p-2 text-center">
72+ <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" />
73+ </li>
74+ </ul>
75+ <p v-else class="p-6 text-center text-sm text-muted">
76+ {{ search ? 'No result.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog.` }}
77+ </p>
78+ </div>
79+ </div>
80+</template>
new file mode 100644
@@ -0,0 +1,80 @@
1+<script setup lang="ts">
2+import type { CatalogItem, CatalogItemType } from '@skilly/core'
3+
4+/**
5+ * Checkbox picker over the catalog, per type with search.
6+ * `locked` maps `type/name` to a label: those rows are shown checked and disabled (already provided elsewhere).
7+ */
8+const props = withDefaults(defineProps<{
9+ items: CatalogItem[]
10+ types?: CatalogItemType[]
11+ locked?: Record<string, string>
12+ height?: string
13+}>(), { types: () => CAT_TYPES, locked: () => ({}), height: 'h-72' })
14+
15+const selected = defineModel<Partial<Record<CatalogItemType, string[]>>>({ required: true })
16+
17+const tab = ref<CatalogItemType>(props.types[0] ?? 'skill')
18+const search = ref('')
19+const LIMIT = 200
20+const showAll = ref(false)
21+
22+watch([tab, search], () => { showAll.value = false })
23+
24+const available = computed(() => props.items.filter(i => i.type === tab.value).sort((a, b) => a.name.localeCompare(b.name)))
25+const filtered = computed(() => {
26+ const q = search.value.trim().toLowerCase()
27+ return q ? available.value.filter(i => `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) : available.value
28+})
29+const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT)))
30+
31+const tabs = computed(() => props.types.map(t => ({
32+ label: CAT_META[t].label,
33+ icon: CAT_META[t].icon,
34+ value: t,
35+ badge: { label: String(selected.value[t]?.length ?? 0), color: (selected.value[t]?.length ? 'primary' : 'neutral') as 'primary' | 'neutral', variant: 'subtle' as const }
36+})))
37+
38+const isOn = (name: string) => !!selected.value[tab.value]?.includes(name)
39+
40+function toggle(name: string, on: boolean | 'indeterminate') {
41+ const cur = new Set(selected.value[tab.value] ?? [])
42+ if (on === true) cur.add(name)
43+ else cur.delete(name)
44+ selected.value = { ...selected.value, [tab.value]: [...cur] }
45+}
46+</script>
47+
48+<template>
49+ <div class="space-y-3">
50+ <UTabs v-model="tab" :items="tabs" :content="false" variant="link" size="sm" class="w-full" />
51+ <UInput v-model="search" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-full" />
52+ <div class="overflow-y-auto rounded-lg border border-default" :class="height">
53+ <ul v-if="shown.length" class="divide-y divide-default">
54+ <li v-for="i in shown" :key="i.name" class="flex items-start gap-2 px-3 py-2 hover:bg-elevated/50">
55+ <UCheckbox
56+ class="mt-0.5"
57+ :model-value="!!locked[itemKey(i.type, i.name)] || isOn(i.name)"
58+ :disabled="!!locked[itemKey(i.type, i.name)]"
59+ :aria-label="i.name"
60+ @update:model-value="(v) => toggle(i.name, v)"
61+ />
62+ <div class="min-w-0 flex-1">
63+ <div class="flex items-center gap-2">
64+ <span class="truncate text-sm font-medium">{{ i.name }}</span>
65+ <UBadge v-if="locked[itemKey(i.type, i.name)]" :label="locked[itemKey(i.type, i.name)]" size="sm" color="neutral" variant="subtle" />
66+ <UBadge v-for="t in i.tags.slice(0, 3)" :key="t" :label="t" size="sm" color="neutral" variant="outline" />
67+ </div>
68+ <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p>
69+ </div>
70+ </li>
71+ <li v-if="filtered.length > shown.length" class="p-2 text-center">
72+ <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" />
73+ </li>
74+ </ul>
75+ <p v-else class="p-6 text-center text-sm text-muted">
76+ {{ search ? 'No result.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog.` }}
77+ </p>
78+ </div>
79+ </div>
80+</template>
modified packages/ui/app/components/ItemTable.vue +96 -34
@@ -1,10 +1,25 @@
11 <script setup lang="ts">
22 import type {
3- AgentDetails, HookDetails, InstructionsDetails, InventoryItem, ItemType, McpDetails, RuleDetails, SkillDetails
3+ AgentDetails, CatalogRef, HookDetails, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails, RuleDetails, SkillDetails
44 } from '@skilly/core'
55
6-defineProps<{ type: ItemType, items: InventoryItem[], filtered: boolean }>()
7-defineEmits<{ select: [item: InventoryItem] }>()
6+const props = defineProps<{
7+ type: ItemType
8+ items: InventoryItem[]
9+ filtered: boolean
10+ statuses?: Record<string, ItemStatus>
11+ /** Show checkboxes and row actions (catalog import, remove from global). */
12+ actionable?: boolean
13+ /** Home scope: enables "Remove from global" on skills, rules and agents. */
14+ home?: boolean
15+}>()
16+const selection = defineModel<string[]>('selection', { default: () => [] })
17+const emit = defineEmits<{
18+ select: [item: InventoryItem]
19+ addToCatalog: [item: InventoryItem]
20+ openCatalog: [ref: CatalogRef]
21+ removeGlobal: [item: InventoryItem]
22+}>()
823
924 const skill = (i: InventoryItem) => i.details as SkillDetails
1025 const rule = (i: InventoryItem) => i.details as RuleDetails
@@ -20,6 +35,26 @@ const VISIBILITY = {
2035 'off': { label: 'off', color: 'neutral' }
2136 } as const
2237
38+const canImport = (i: InventoryItem) => {
39+ const st = props.statuses?.[i.id]?.state
40+ return !!props.actionable && i.type !== 'instructions' && (st === 'not-in-catalog' || st === 'differs-from-catalog')
41+}
42+const canRemoveGlobal = (i: InventoryItem) => !!props.actionable && !!props.home && ['skill', 'rule', 'agent'].includes(i.type)
43+const selectable = (i: InventoryItem) => canImport(i) || canRemoveGlobal(i)
44+
45+function toggleRow(id: string, on: boolean | 'indeterminate') {
46+ selection.value = on === true ? [...new Set([...selection.value, id])] : selection.value.filter(x => x !== id)
47+}
48+const selectableIds = computed(() => props.items.filter(selectable).map(i => i.id))
49+const allState = computed(() => {
50+ const n = selectableIds.value.filter(id => selection.value.includes(id)).length
51+ return n === 0 ? false : n === selectableIds.value.length ? true : 'indeterminate' as const
52+})
53+function toggleAll(on: boolean | 'indeterminate') {
54+ const rest = selection.value.filter(id => !selectableIds.value.includes(id))
55+ selection.value = on === true ? [...rest, ...selectableIds.value] : rest
56+}
57+
2358 const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'
2459 const TD = 'px-3 py-2 align-middle'
2560 const MAX_ENV = 4
@@ -36,26 +71,36 @@ const MAX_ENV = 4
3671 </p>
3772 </div>
3873
74+ <!-- table-fixed with the same widths for the leading columns keeps columns aligned from one tab to the next -->
3975 <div v-else class="overflow-x-auto rounded-xl border border-default">
40- <table class="w-full min-w-[720px] text-sm">
76+ <table class="w-full min-w-[960px] table-fixed text-sm">
4177 <thead class="border-b border-default bg-elevated/40">
42- <tr v-if="type === 'skill'">
43- <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Visibility</th><th :class="TH">Origin</th>
44- </tr>
45- <tr v-else-if="type === 'rule'">
46- <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Applies to</th>
47- </tr>
48- <tr v-else-if="type === 'mcp'">
49- <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Transport</th><th :class="TH">Command / URL</th><th :class="TH">Env vars</th><th :class="TH">Scope</th><th :class="TH">Status</th>
50- </tr>
51- <tr v-else-if="type === 'hook'">
52- <th :class="TH">Event</th><th :class="TH">Agent</th><th :class="TH">Matcher</th><th :class="TH">Command</th><th :class="TH">Handler</th>
53- </tr>
54- <tr v-else-if="type === 'agent'">
55- <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Model</th>
56- </tr>
57- <tr v-else>
58- <th :class="TH">File</th><th :class="TH">Agent</th><th :class="TH">Size</th><th :class="TH">Status</th><th :class="TH">Imports</th>
78+ <tr>
79+ <th v-if="actionable" :class="TH" class="w-10">
80+ <UCheckbox v-if="selectableIds.length" :model-value="allState" aria-label="Select all" @update:model-value="toggleAll" />
81+ </th>
82+ <th :class="TH" class="w-[26%]">{{ type === 'hook' ? 'Event' : type === 'instructions' ? 'File' : 'Name' }}</th>
83+ <th :class="TH" class="w-16">Agent</th>
84+ <th :class="TH" class="w-44">Status</th>
85+ <template v-if="type === 'skill'">
86+ <th :class="TH" class="w-28">Visibility</th><th :class="TH">Origin</th>
87+ </template>
88+ <template v-else-if="type === 'rule'">
89+ <th :class="TH">Applies to</th>
90+ </template>
91+ <template v-else-if="type === 'mcp'">
92+ <th :class="TH" class="w-24">Transport</th><th :class="TH">Command / URL</th><th :class="TH">Env vars</th><th :class="TH" class="w-20">Scope</th><th :class="TH" class="w-24">State</th>
93+ </template>
94+ <template v-else-if="type === 'hook'">
95+ <th :class="TH" class="w-28">Matcher</th><th :class="TH">Command</th><th :class="TH" class="w-24">Handler</th>
96+ </template>
97+ <template v-else-if="type === 'agent'">
98+ <th :class="TH">Model</th>
99+ </template>
100+ <template v-else>
101+ <th :class="TH" class="w-20">Size</th><th :class="TH" class="w-28">Loaded</th><th :class="TH">Imports</th>
102+ </template>
103+ <th v-if="actionable" :class="TH" class="w-28"><span class="sr-only">Actions</span></th>
59104 </tr>
60105 </thead>
61106
@@ -68,21 +113,24 @@ const MAX_ENV = 4
68113 @click="$emit('select', item)"
69114 @keydown.enter="$emit('select', item)"
70115 >
116+ <td v-if="actionable" :class="TD" @click.stop>
117+ <UCheckbox v-if="selectable(item)" :model-value="selection.includes(item.id)" :aria-label="`Select ${item.name}`" @update:model-value="(v) => toggleRow(item.id, v)" />
118+ </td>
119+
71120 <!-- Hooks lead with the event, others with the name -->
72- <td :class="TD" class="max-w-md">
73- <div class="flex items-start gap-2">
74- <div class="min-w-0">
75- <div class="flex items-center gap-1.5">
76- <span class="truncate font-medium text-highlighted">{{ type === 'hook' ? hook(item).event : item.name }}</span>
77- <UTooltip v-if="item.warnings.length" :text="item.warnings.join('\n')">
78- <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" />
79- </UTooltip>
80- </div>
81- <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p>
121+ <td :class="TD">
122+ <div class="min-w-0">
123+ <div class="flex items-center gap-1.5">
124+ <span class="truncate font-medium text-highlighted" :title="type === 'hook' ? hook(item).event : item.name">{{ type === 'hook' ? hook(item).event : item.name }}</span>
125+ <UTooltip v-if="item.warnings.length" :text="item.warnings.join('\n')">
126+ <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" />
127+ </UTooltip>
82128 </div>
129+ <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p>
83130 </div>
84131 </td>
85132 <td :class="TD"><AgentBadge :agent="item.agent" compact /></td>
133+ <td :class="TD"><StatusBadge :status="statuses?.[item.id]" /></td>
86134
87135 <!-- skills -->
88136 <template v-if="type === 'skill'">
@@ -119,7 +167,7 @@ const MAX_ENV = 4
119167 <!-- mcp -->
120168 <template v-else-if="type === 'mcp'">
121169 <td :class="TD"><UBadge :label="mcp(item).transport" color="neutral" variant="subtle" size="sm" /></td>
122- <td :class="TD" class="max-w-xs">
170+ <td :class="TD">
123171 <span class="block truncate font-mono text-xs" :title="mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].join(' ')">
124172 {{ mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].filter(Boolean).join(' ') }}
125173 </span>
@@ -138,8 +186,8 @@ const MAX_ENV = 4
138186
139187 <!-- hooks -->
140188 <template v-else-if="type === 'hook'">
141- <td :class="TD"><span class="font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td>
142- <td :class="TD" class="max-w-sm">
189+ <td :class="TD"><span class="block truncate font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td>
190+ <td :class="TD">
143191 <span class="block truncate font-mono text-xs" :title="hook(item).command">{{ hook(item).command ?? '-' }}</span>
144192 </td>
145193 <td :class="TD"><UBadge :label="hook(item).handlerType" color="neutral" variant="subtle" size="sm" /></td>
@@ -166,6 +214,20 @@ const MAX_ENV = 4
166214 </div>
167215 </td>
168216 </template>
217+
218+ <td v-if="actionable" :class="TD" @click.stop>
219+ <div class="flex justify-end gap-0.5">
220+ <UTooltip v-if="canImport(item)" text="Add to catalog">
221+ <UButton icon="i-lucide-library" size="xs" color="neutral" variant="ghost" aria-label="Add to catalog" @click="emit('addToCatalog', item)" />
222+ </UTooltip>
223+ <UTooltip v-if="statuses?.[item.id]?.catalogRef" text="Open in catalog">
224+ <UButton icon="i-lucide-external-link" size="xs" color="neutral" variant="ghost" aria-label="Open in catalog" @click="emit('openCatalog', statuses![item.id]!.catalogRef!)" />
225+ </UTooltip>
226+ <UTooltip v-if="canRemoveGlobal(item)" text="Remove from global">
227+ <UButton icon="i-lucide-globe-lock" size="xs" color="neutral" variant="ghost" aria-label="Remove from global" @click="emit('removeGlobal', item)" />
228+ </UTooltip>
229+ </div>
230+ </td>
169231 </tr>
170232 </tbody>
171233 </table>
@@ -1,10 +1,25 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 import type {2 import type {
3- AgentDetails, HookDetails, InstructionsDetails, InventoryItem, ItemType, McpDetails, RuleDetails, SkillDetails3+ AgentDetails, CatalogRef, HookDetails, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails, RuleDetails, SkillDetails
4 } from '@skilly/core'4 } from '@skilly/core'
5 5
6-defineProps<{ type: ItemType, items: InventoryItem[], filtered: boolean }>()6+const props = defineProps<{
7-defineEmits<{ select: [item: InventoryItem] }>()7+ type: ItemType
8+ items: InventoryItem[]
9+ filtered: boolean
10+ statuses?: Record<string, ItemStatus>
11+ /** Show checkboxes and row actions (catalog import, remove from global). */
12+ actionable?: boolean
13+ /** Home scope: enables "Remove from global" on skills, rules and agents. */
14+ home?: boolean
15+}>()
16+const selection = defineModel<string[]>('selection', { default: () => [] })
17+const emit = defineEmits<{
18+ select: [item: InventoryItem]
19+ addToCatalog: [item: InventoryItem]
20+ openCatalog: [ref: CatalogRef]
21+ removeGlobal: [item: InventoryItem]
22+}>()
8 23
9 const skill = (i: InventoryItem) => i.details as SkillDetails24 const skill = (i: InventoryItem) => i.details as SkillDetails
10 const rule = (i: InventoryItem) => i.details as RuleDetails25 const rule = (i: InventoryItem) => i.details as RuleDetails
@@ -20,6 +35,26 @@ const VISIBILITY = {
20 'off': { label: 'off', color: 'neutral' }35 'off': { label: 'off', color: 'neutral' }
21 } as const36 } as const
22 37
38+const canImport = (i: InventoryItem) => {
39+ const st = props.statuses?.[i.id]?.state
40+ return !!props.actionable && i.type !== 'instructions' && (st === 'not-in-catalog' || st === 'differs-from-catalog')
41+}
42+const canRemoveGlobal = (i: InventoryItem) => !!props.actionable && !!props.home && ['skill', 'rule', 'agent'].includes(i.type)
43+const selectable = (i: InventoryItem) => canImport(i) || canRemoveGlobal(i)
44+
45+function toggleRow(id: string, on: boolean | 'indeterminate') {
46+ selection.value = on === true ? [...new Set([...selection.value, id])] : selection.value.filter(x => x !== id)
47+}
48+const selectableIds = computed(() => props.items.filter(selectable).map(i => i.id))
49+const allState = computed(() => {
50+ const n = selectableIds.value.filter(id => selection.value.includes(id)).length
51+ return n === 0 ? false : n === selectableIds.value.length ? true : 'indeterminate' as const
52+})
53+function toggleAll(on: boolean | 'indeterminate') {
54+ const rest = selection.value.filter(id => !selectableIds.value.includes(id))
55+ selection.value = on === true ? [...rest, ...selectableIds.value] : rest
56+}
57+
23 const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'58 const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'
24 const TD = 'px-3 py-2 align-middle'59 const TD = 'px-3 py-2 align-middle'
25 const MAX_ENV = 460 const MAX_ENV = 4
@@ -36,26 +71,36 @@ const MAX_ENV = 4
36 </p>71 </p>
37 </div>72 </div>
38 73
74+ <!-- table-fixed with the same widths for the leading columns keeps columns aligned from one tab to the next -->
39 <div v-else class="overflow-x-auto rounded-xl border border-default">75 <div v-else class="overflow-x-auto rounded-xl border border-default">
40- <table class="w-full min-w-[720px] text-sm">76+ <table class="w-full min-w-[960px] table-fixed text-sm">
41 <thead class="border-b border-default bg-elevated/40">77 <thead class="border-b border-default bg-elevated/40">
42- <tr v-if="type === 'skill'">78+ <tr>
43- <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Visibility</th><th :class="TH">Origin</th>79+ <th v-if="actionable" :class="TH" class="w-10">
44- </tr>80+ <UCheckbox v-if="selectableIds.length" :model-value="allState" aria-label="Select all" @update:model-value="toggleAll" />
45- <tr v-else-if="type === 'rule'">81+ </th>
46- <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Applies to</th>82+ <th :class="TH" class="w-[26%]">{{ type === 'hook' ? 'Event' : type === 'instructions' ? 'File' : 'Name' }}</th>
47- </tr>83+ <th :class="TH" class="w-16">Agent</th>
48- <tr v-else-if="type === 'mcp'">84+ <th :class="TH" class="w-44">Status</th>
49- <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Transport</th><th :class="TH">Command / URL</th><th :class="TH">Env vars</th><th :class="TH">Scope</th><th :class="TH">Status</th>85+ <template v-if="type === 'skill'">
50- </tr>86+ <th :class="TH" class="w-28">Visibility</th><th :class="TH">Origin</th>
51- <tr v-else-if="type === 'hook'">87+ </template>
52- <th :class="TH">Event</th><th :class="TH">Agent</th><th :class="TH">Matcher</th><th :class="TH">Command</th><th :class="TH">Handler</th>88+ <template v-else-if="type === 'rule'">
53- </tr>89+ <th :class="TH">Applies to</th>
54- <tr v-else-if="type === 'agent'">90+ </template>
55- <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Model</th>91+ <template v-else-if="type === 'mcp'">
56- </tr>92+ <th :class="TH" class="w-24">Transport</th><th :class="TH">Command / URL</th><th :class="TH">Env vars</th><th :class="TH" class="w-20">Scope</th><th :class="TH" class="w-24">State</th>
57- <tr v-else>93+ </template>
58- <th :class="TH">File</th><th :class="TH">Agent</th><th :class="TH">Size</th><th :class="TH">Status</th><th :class="TH">Imports</th>94+ <template v-else-if="type === 'hook'">
95+ <th :class="TH" class="w-28">Matcher</th><th :class="TH">Command</th><th :class="TH" class="w-24">Handler</th>
96+ </template>
97+ <template v-else-if="type === 'agent'">
98+ <th :class="TH">Model</th>
99+ </template>
100+ <template v-else>
101+ <th :class="TH" class="w-20">Size</th><th :class="TH" class="w-28">Loaded</th><th :class="TH">Imports</th>
102+ </template>
103+ <th v-if="actionable" :class="TH" class="w-28"><span class="sr-only">Actions</span></th>
59 </tr>104 </tr>
60 </thead>105 </thead>
61 106
@@ -68,21 +113,24 @@ const MAX_ENV = 4
68 @click="$emit('select', item)"113 @click="$emit('select', item)"
69 @keydown.enter="$emit('select', item)"114 @keydown.enter="$emit('select', item)"
70 >115 >
116+ <td v-if="actionable" :class="TD" @click.stop>
117+ <UCheckbox v-if="selectable(item)" :model-value="selection.includes(item.id)" :aria-label="`Select ${item.name}`" @update:model-value="(v) => toggleRow(item.id, v)" />
118+ </td>
119+
71 <!-- Hooks lead with the event, others with the name -->120 <!-- Hooks lead with the event, others with the name -->
72- <td :class="TD" class="max-w-md">121+ <td :class="TD">
73- <div class="flex items-start gap-2">122+ <div class="min-w-0">
74- <div class="min-w-0">123+ <div class="flex items-center gap-1.5">
75- <div class="flex items-center gap-1.5">124+ <span class="truncate font-medium text-highlighted" :title="type === 'hook' ? hook(item).event : item.name">{{ type === 'hook' ? hook(item).event : item.name }}</span>
76- <span class="truncate font-medium text-highlighted">{{ type === 'hook' ? hook(item).event : item.name }}</span>125+ <UTooltip v-if="item.warnings.length" :text="item.warnings.join('\n')">
77- <UTooltip v-if="item.warnings.length" :text="item.warnings.join('\n')">126+ <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" />
78- <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" />127+ </UTooltip>
79- </UTooltip>
80- </div>
81- <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p>
82 </div>128 </div>
129+ <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p>
83 </div>130 </div>
84 </td>131 </td>
85 <td :class="TD"><AgentBadge :agent="item.agent" compact /></td>132 <td :class="TD"><AgentBadge :agent="item.agent" compact /></td>
133+ <td :class="TD"><StatusBadge :status="statuses?.[item.id]" /></td>
86 134
87 <!-- skills -->135 <!-- skills -->
88 <template v-if="type === 'skill'">136 <template v-if="type === 'skill'">
@@ -119,7 +167,7 @@ const MAX_ENV = 4
119 <!-- mcp -->167 <!-- mcp -->
120 <template v-else-if="type === 'mcp'">168 <template v-else-if="type === 'mcp'">
121 <td :class="TD"><UBadge :label="mcp(item).transport" color="neutral" variant="subtle" size="sm" /></td>169 <td :class="TD"><UBadge :label="mcp(item).transport" color="neutral" variant="subtle" size="sm" /></td>
122- <td :class="TD" class="max-w-xs">170+ <td :class="TD">
123 <span class="block truncate font-mono text-xs" :title="mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].join(' ')">171 <span class="block truncate font-mono text-xs" :title="mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].join(' ')">
124 {{ mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].filter(Boolean).join(' ') }}172 {{ mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].filter(Boolean).join(' ') }}
125 </span>173 </span>
@@ -138,8 +186,8 @@ const MAX_ENV = 4
138 186
139 <!-- hooks -->187 <!-- hooks -->
140 <template v-else-if="type === 'hook'">188 <template v-else-if="type === 'hook'">
141- <td :class="TD"><span class="font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td>189+ <td :class="TD"><span class="block truncate font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td>
142- <td :class="TD" class="max-w-sm">190+ <td :class="TD">
143 <span class="block truncate font-mono text-xs" :title="hook(item).command">{{ hook(item).command ?? '-' }}</span>191 <span class="block truncate font-mono text-xs" :title="hook(item).command">{{ hook(item).command ?? '-' }}</span>
144 </td>192 </td>
145 <td :class="TD"><UBadge :label="hook(item).handlerType" color="neutral" variant="subtle" size="sm" /></td>193 <td :class="TD"><UBadge :label="hook(item).handlerType" color="neutral" variant="subtle" size="sm" /></td>
@@ -166,6 +214,20 @@ const MAX_ENV = 4
166 </div>214 </div>
167 </td>215 </td>
168 </template>216 </template>
217+
218+ <td v-if="actionable" :class="TD" @click.stop>
219+ <div class="flex justify-end gap-0.5">
220+ <UTooltip v-if="canImport(item)" text="Add to catalog">
221+ <UButton icon="i-lucide-library" size="xs" color="neutral" variant="ghost" aria-label="Add to catalog" @click="emit('addToCatalog', item)" />
222+ </UTooltip>
223+ <UTooltip v-if="statuses?.[item.id]?.catalogRef" text="Open in catalog">
224+ <UButton icon="i-lucide-external-link" size="xs" color="neutral" variant="ghost" aria-label="Open in catalog" @click="emit('openCatalog', statuses![item.id]!.catalogRef!)" />
225+ </UTooltip>
226+ <UTooltip v-if="canRemoveGlobal(item)" text="Remove from global">
227+ <UButton icon="i-lucide-globe-lock" size="xs" color="neutral" variant="ghost" aria-label="Remove from global" @click="emit('removeGlobal', item)" />
228+ </UTooltip>
229+ </div>
230+ </td>
169 </tr>231 </tr>
170 </tbody>232 </tbody>
171 </table>233 </table>
added packages/ui/app/components/McpForm.vue +60 -0
new file mode 100644
@@ -0,0 +1,60 @@
1+<script setup lang="ts">
2+import type { McpTemplate } from '@skilly/core'
3+
4+/** Form for an MCP server template. The parent owns the object (v-model) and renders the YAML. */
5+const t = defineModel<McpTemplate>({ required: true })
6+
7+const TRANSPORTS = [
8+ { label: 'stdio (local command)', value: 'stdio' },
9+ { label: 'http (remote URL)', value: 'http' }
10+]
11+
12+const args = computed({
13+ get: () => t.value.args ?? [],
14+ set: (v: string[]) => { t.value = { ...t.value, args: v } }
15+})
16+const bearer = computed({
17+ get: () => t.value.auth?.bearerFromEnv ?? '',
18+ set: (v: string) => { t.value = { ...t.value, auth: v ? { bearerFromEnv: v } : undefined } }
19+})
20+
21+function patch(p: Partial<McpTemplate>) {
22+ t.value = { ...t.value, ...p }
23+}
24+</script>
25+
26+<template>
27+ <div class="space-y-4">
28+ <UFormField label="Description">
29+ <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v })" />
30+ </UFormField>
31+
32+ <UFormField label="Transport">
33+ <USelect :model-value="t.transport" :items="TRANSPORTS" class="w-64" @update:model-value="(v: string) => patch({ transport: v as 'stdio' | 'http' })" />
34+ </UFormField>
35+
36+ <template v-if="t.transport === 'stdio'">
37+ <UFormField label="Command" required>
38+ <UInput :model-value="t.command ?? ''" class="w-full" placeholder="npx" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ command: v })" />
39+ </UFormField>
40+ <UFormField label="Arguments" description="Press Enter to add each argument.">
41+ <UInputTags v-model="args" class="w-full" placeholder="-y, @upstash/context7-mcp" />
42+ </UFormField>
43+ <UFormField label="Environment variables" description="Variables passed to the server process.">
44+ <EnvRows :model-value="t.env" key-label="Variable name" key-placeholder="API_KEY" @update:model-value="(v) => patch({ env: v })" />
45+ </UFormField>
46+ </template>
47+
48+ <template v-else>
49+ <UFormField label="URL" required>
50+ <UInput :model-value="t.url ?? ''" class="w-full" placeholder="https://example.com/mcp" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ url: v })" />
51+ </UFormField>
52+ <UFormField label="Bearer token env var" description="Name of the environment variable holding the token (never the token itself).">
53+ <UInput v-model="bearer" class="w-72" placeholder="GITHUB_TOKEN" :ui="{ base: 'font-mono' }" />
54+ </UFormField>
55+ <UFormField label="Headers">
56+ <EnvRows :model-value="t.headers" key-label="Header name" key-placeholder="X-Api-Key" @update:model-value="(v) => patch({ headers: v })" />
57+ </UFormField>
58+ </template>
59+ </div>
60+</template>
new file mode 100644
@@ -0,0 +1,60 @@
1+<script setup lang="ts">
2+import type { McpTemplate } from '@skilly/core'
3+
4+/** Form for an MCP server template. The parent owns the object (v-model) and renders the YAML. */
5+const t = defineModel<McpTemplate>({ required: true })
6+
7+const TRANSPORTS = [
8+ { label: 'stdio (local command)', value: 'stdio' },
9+ { label: 'http (remote URL)', value: 'http' }
10+]
11+
12+const args = computed({
13+ get: () => t.value.args ?? [],
14+ set: (v: string[]) => { t.value = { ...t.value, args: v } }
15+})
16+const bearer = computed({
17+ get: () => t.value.auth?.bearerFromEnv ?? '',
18+ set: (v: string) => { t.value = { ...t.value, auth: v ? { bearerFromEnv: v } : undefined } }
19+})
20+
21+function patch(p: Partial<McpTemplate>) {
22+ t.value = { ...t.value, ...p }
23+}
24+</script>
25+
26+<template>
27+ <div class="space-y-4">
28+ <UFormField label="Description">
29+ <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v })" />
30+ </UFormField>
31+
32+ <UFormField label="Transport">
33+ <USelect :model-value="t.transport" :items="TRANSPORTS" class="w-64" @update:model-value="(v: string) => patch({ transport: v as 'stdio' | 'http' })" />
34+ </UFormField>
35+
36+ <template v-if="t.transport === 'stdio'">
37+ <UFormField label="Command" required>
38+ <UInput :model-value="t.command ?? ''" class="w-full" placeholder="npx" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ command: v })" />
39+ </UFormField>
40+ <UFormField label="Arguments" description="Press Enter to add each argument.">
41+ <UInputTags v-model="args" class="w-full" placeholder="-y, @upstash/context7-mcp" />
42+ </UFormField>
43+ <UFormField label="Environment variables" description="Variables passed to the server process.">
44+ <EnvRows :model-value="t.env" key-label="Variable name" key-placeholder="API_KEY" @update:model-value="(v) => patch({ env: v })" />
45+ </UFormField>
46+ </template>
47+
48+ <template v-else>
49+ <UFormField label="URL" required>
50+ <UInput :model-value="t.url ?? ''" class="w-full" placeholder="https://example.com/mcp" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ url: v })" />
51+ </UFormField>
52+ <UFormField label="Bearer token env var" description="Name of the environment variable holding the token (never the token itself).">
53+ <UInput v-model="bearer" class="w-72" placeholder="GITHUB_TOKEN" :ui="{ base: 'font-mono' }" />
54+ </UFormField>
55+ <UFormField label="Headers">
56+ <EnvRows :model-value="t.headers" key-label="Header name" key-placeholder="X-Api-Key" @update:model-value="(v) => patch({ headers: v })" />
57+ </UFormField>
58+ </template>
59+ </div>
60+</template>
added packages/ui/app/components/PresetEditor.vue +225 -0
new file mode 100644
@@ -0,0 +1,225 @@
1+<script setup lang="ts">
2+import type { CatalogItemType, Preset } from '@skilly/core'
3+
4+/** Preset editor. `name` is undefined for a new preset. */
5+const props = defineProps<{ name?: string }>()
6+
7+const { request } = useApi()
8+const catalog = useCatalog()
9+const scopeNames = useScopeNames()
10+const toast = useToast()
11+
12+const isNew = computed(() => !props.name)
13+const ready = ref(false)
14+const form = reactive({
15+ name: props.name ?? '',
16+ description: '',
17+ includes: [] as string[],
18+ items: {} as Partial<Record<CatalogItemType, string[]>>
19+})
20+const original = ref('')
21+const saving = ref(false)
22+const saveError = ref<string | null>(null)
23+const notFound = ref(false)
24+
25+const snapshot = () => JSON.stringify([form.name, form.description, form.includes, form.items])
26+const dirty = computed(() => snapshot() !== original.value)
27+
28+async function init() {
29+ if (!catalog.data.value) await catalog.refresh()
30+ if (props.name) {
31+ const p = catalog.presets.value.find(x => x.name === props.name)
32+ if (!p) notFound.value = true
33+ else {
34+ form.description = p.description ?? ''
35+ form.includes = [...p.includes]
36+ form.items = structuredClone(toRaw(p.items))
37+ }
38+ }
39+ original.value = snapshot()
40+ ready.value = true
41+}
42+onMounted(init)
43+
44+const includeChoices = computed(() => catalog.presets.value.map(p => p.name).filter(n => n !== props.name && n !== form.name))
45+const renaming = computed(() => !isNew.value && form.name !== props.name)
46+
47+const problems = computed(() => {
48+ const p: string[] = []
49+ if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-".')
50+ else if ((isNew.value || renaming.value) && catalog.presets.value.some(x => x.name === form.name)) p.push(`A preset named "${form.name}" already exists.`)
51+ return p
52+})
53+
54+const resolved = computed(() => resolveItems(
55+ catalog.presets.value.filter(p => p.name !== props.name),
56+ [],
57+ {},
58+ { name: form.name || props.name || '(this preset)', includes: form.includes, items: form.items }
59+))
60+const resolvedByType = computed(() => CAT_TYPES.map(t => ({
61+ type: t,
62+ rows: resolved.value.entries.filter(e => e.type === t).sort((a, b) => a.name.localeCompare(b.name))
63+})).filter(g => g.rows.length))
64+const selfName = computed(() => form.name || props.name || '(this preset)')
65+const missingItems = (t: CatalogItemType, n: string) => !catalog.findItem(t, n)
66+
67+async function save() {
68+ if (problems.value.length) return
69+ saving.value = true
70+ saveError.value = null
71+ const items: Partial<Record<CatalogItemType, string[]>> = {}
72+ for (const t of CAT_TYPES) if (form.items[t]?.length) items[t] = [...form.items[t]!].sort()
73+ const body: Preset & { previousName?: string } = {
74+ name: form.name,
75+ description: form.description || undefined,
76+ includes: form.includes,
77+ items,
78+ ...(renaming.value ? { previousName: props.name } : {})
79+ }
80+ try {
81+ await request(`/api/catalog/presets/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true })
82+ toast.add({ title: isNew.value ? 'Preset created' : 'Preset saved', description: form.name, icon: 'i-lucide-check', color: 'success' })
83+ await catalog.refresh()
84+ original.value = snapshot()
85+ if (isNew.value || renaming.value) await navigateTo(`/presets/${encodeURIComponent(form.name)}`, { replace: true })
86+ } catch (e) {
87+ saveError.value = (e as Error).message
88+ } finally {
89+ saving.value = false
90+ }
91+}
92+
93+onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?'))
94+
95+const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : []))
96+const delOpen = ref(false)
97+const deleting = ref(false)
98+const delConflict = ref<string | null>(null)
99+
100+async function doDelete(force = false) {
101+ deleting.value = true
102+ try {
103+ await request(`/api/catalog/presets/${encodeURIComponent(props.name!)}`, { method: 'DELETE', query: force ? { force: '1' } : undefined, silent: true })
104+ original.value = snapshot()
105+ toast.add({ title: 'Preset deleted', description: props.name, icon: 'i-lucide-check', color: 'success' })
106+ delOpen.value = false
107+ await catalog.refresh()
108+ await navigateTo('/presets')
109+ } catch (e) {
110+ const err = e as { status?: number, message: string }
111+ if (err.status === 409) delConflict.value = err.message
112+ else toast.add({ title: 'Delete failed', description: err.message, color: 'error', icon: 'i-lucide-circle-alert' })
113+ } finally {
114+ deleting.value = false
115+ }
116+}
117+function askDelete() {
118+ delConflict.value = null
119+ delOpen.value = true
120+}
121+</script>
122+
123+<template>
124+ <UDashboardPanel id="preset-editor">
125+ <template #header>
126+ <UDashboardNavbar icon="i-lucide-layers">
127+ <template #title>
128+ <NuxtLink to="/presets" class="text-muted hover:text-default">Presets</NuxtLink>
129+ <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />
130+ <span class="truncate">{{ isNew ? 'New' : name }}</span>
131+ </template>
132+ <template #right>
133+ <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" />
134+ <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" />
135+ <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" />
136+ </template>
137+ </UDashboardNavbar>
138+ </template>
139+
140+ <template #body>
141+ <USkeleton v-if="!ready" class="h-72 w-full" />
142+ <UAlert v-else-if="notFound" color="warning" variant="subtle" icon="i-lucide-circle-help" title="Preset not found" description="It may have been renamed or deleted." :actions="[{ label: 'Back to presets', to: '/presets', color: 'neutral', variant: 'outline' }]" />
143+
144+ <div v-else class="grid items-start gap-6 xl:grid-cols-2">
145+ <div class="min-w-0 space-y-4">
146+ <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" />
147+ <div class="grid gap-4 sm:grid-cols-2">
148+ <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required>
149+ <UInput v-model="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" />
150+ </UFormField>
151+ <UFormField label="Description">
152+ <UInput v-model="form.description" class="w-full" />
153+ </UFormField>
154+ </div>
155+ <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="problems.join(' ')" />
156+
157+ <UFormField label="Included presets" description="Their items are added to this preset (resolved recursively).">
158+ <USelectMenu v-model="form.includes" :items="includeChoices" multiple placeholder="None" class="w-full" />
159+ </UFormField>
160+
161+ <div class="space-y-2">
162+ <h3 class="text-sm font-semibold">Items</h3>
163+ <ItemPicker v-model="form.items" :items="catalog.items.value" />
164+ </div>
165+ </div>
166+
167+ <div class="min-w-0 space-y-4">
168+ <section class="space-y-2">
169+ <h3 class="text-sm font-semibold">Resolved content <span class="font-normal text-muted">({{ resolved.entries.length }} items)</span></h3>
170+ <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Unknown preset(s): ${resolved.missingPresets.join(', ')}`" />
171+ <UAlert v-if="resolved.cycles.length" color="warning" variant="subtle" icon="i-lucide-repeat" :title="`Include cycle ignored: ${resolved.cycles.join(', ')}`" />
172+ <p v-if="!resolvedByType.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing selected yet.</p>
173+ <div v-for="g in resolvedByType" :key="g.type" class="overflow-hidden rounded-lg border border-default">
174+ <div class="flex items-center gap-2 bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted">
175+ <UIcon :name="CAT_META[g.type].icon" class="size-3.5" />{{ CAT_META[g.type].label }} ({{ g.rows.length }})
176+ </div>
177+ <ul class="divide-y divide-default text-sm">
178+ <li v-for="r in g.rows" :key="r.name" class="flex items-center gap-2 px-3 py-1.5">
179+ <NuxtLink v-if="!missingItems(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate hover:underline">{{ r.name }}</NuxtLink>
180+ <span v-else class="truncate">{{ r.name }}</span>
181+ <UBadge v-if="missingItems(r.type, r.name)" label="missing" size="sm" color="warning" variant="subtle" />
182+ <span class="ml-auto flex shrink-0 gap-1">
183+ <UBadge v-for="v in r.via.filter(v => v !== selfName)" :key="v" :label="`via ${v}`" size="sm" color="neutral" variant="subtle" />
184+ </span>
185+ </li>
186+ </ul>
187+ </div>
188+ </section>
189+
190+ <section v-if="!isNew" class="space-y-1.5">
191+ <h3 class="text-sm font-semibold">Used by</h3>
192+ <ul v-if="usedBy.length" class="space-y-1 text-sm">
193+ <li v-for="s in usedBy" :key="s">
194+ <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline">
195+ <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" />{{ scopeLabel(s, scopeNames) }}
196+ </NuxtLink>
197+ </li>
198+ </ul>
199+ <p v-else class="text-xs text-muted">Not enabled in any scope.</p>
200+ </section>
201+ </div>
202+ </div>
203+
204+ <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the preset from the catalog.">
205+ <template #body>
206+ <div class="space-y-3 text-sm">
207+ <p>Items of the preset are kept in the catalog. Scopes that enabled it will report the preset as missing.</p>
208+ <UAlert v-if="delConflict || usedBy.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="delConflict ? 'This preset is used' : 'Used by'">
209+ <template #description>
210+ <p v-if="delConflict" class="mb-1">{{ delConflict }}</p>
211+ <ul class="list-disc pl-4"><li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li></ul>
212+ </template>
213+ </UAlert>
214+ </div>
215+ </template>
216+ <template #footer>
217+ <div class="flex w-full justify-end gap-2">
218+ <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" />
219+ <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" />
220+ </div>
221+ </template>
222+ </UModal>
223+ </template>
224+ </UDashboardPanel>
225+</template>
new file mode 100644
@@ -0,0 +1,225 @@
1+<script setup lang="ts">
2+import type { CatalogItemType, Preset } from '@skilly/core'
3+
4+/** Preset editor. `name` is undefined for a new preset. */
5+const props = defineProps<{ name?: string }>()
6+
7+const { request } = useApi()
8+const catalog = useCatalog()
9+const scopeNames = useScopeNames()
10+const toast = useToast()
11+
12+const isNew = computed(() => !props.name)
13+const ready = ref(false)
14+const form = reactive({
15+ name: props.name ?? '',
16+ description: '',
17+ includes: [] as string[],
18+ items: {} as Partial<Record<CatalogItemType, string[]>>
19+})
20+const original = ref('')
21+const saving = ref(false)
22+const saveError = ref<string | null>(null)
23+const notFound = ref(false)
24+
25+const snapshot = () => JSON.stringify([form.name, form.description, form.includes, form.items])
26+const dirty = computed(() => snapshot() !== original.value)
27+
28+async function init() {
29+ if (!catalog.data.value) await catalog.refresh()
30+ if (props.name) {
31+ const p = catalog.presets.value.find(x => x.name === props.name)
32+ if (!p) notFound.value = true
33+ else {
34+ form.description = p.description ?? ''
35+ form.includes = [...p.includes]
36+ form.items = structuredClone(toRaw(p.items))
37+ }
38+ }
39+ original.value = snapshot()
40+ ready.value = true
41+}
42+onMounted(init)
43+
44+const includeChoices = computed(() => catalog.presets.value.map(p => p.name).filter(n => n !== props.name && n !== form.name))
45+const renaming = computed(() => !isNew.value && form.name !== props.name)
46+
47+const problems = computed(() => {
48+ const p: string[] = []
49+ if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-".')
50+ else if ((isNew.value || renaming.value) && catalog.presets.value.some(x => x.name === form.name)) p.push(`A preset named "${form.name}" already exists.`)
51+ return p
52+})
53+
54+const resolved = computed(() => resolveItems(
55+ catalog.presets.value.filter(p => p.name !== props.name),
56+ [],
57+ {},
58+ { name: form.name || props.name || '(this preset)', includes: form.includes, items: form.items }
59+))
60+const resolvedByType = computed(() => CAT_TYPES.map(t => ({
61+ type: t,
62+ rows: resolved.value.entries.filter(e => e.type === t).sort((a, b) => a.name.localeCompare(b.name))
63+})).filter(g => g.rows.length))
64+const selfName = computed(() => form.name || props.name || '(this preset)')
65+const missingItems = (t: CatalogItemType, n: string) => !catalog.findItem(t, n)
66+
67+async function save() {
68+ if (problems.value.length) return
69+ saving.value = true
70+ saveError.value = null
71+ const items: Partial<Record<CatalogItemType, string[]>> = {}
72+ for (const t of CAT_TYPES) if (form.items[t]?.length) items[t] = [...form.items[t]!].sort()
73+ const body: Preset & { previousName?: string } = {
74+ name: form.name,
75+ description: form.description || undefined,
76+ includes: form.includes,
77+ items,
78+ ...(renaming.value ? { previousName: props.name } : {})
79+ }
80+ try {
81+ await request(`/api/catalog/presets/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true })
82+ toast.add({ title: isNew.value ? 'Preset created' : 'Preset saved', description: form.name, icon: 'i-lucide-check', color: 'success' })
83+ await catalog.refresh()
84+ original.value = snapshot()
85+ if (isNew.value || renaming.value) await navigateTo(`/presets/${encodeURIComponent(form.name)}`, { replace: true })
86+ } catch (e) {
87+ saveError.value = (e as Error).message
88+ } finally {
89+ saving.value = false
90+ }
91+}
92+
93+onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?'))
94+
95+const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : []))
96+const delOpen = ref(false)
97+const deleting = ref(false)
98+const delConflict = ref<string | null>(null)
99+
100+async function doDelete(force = false) {
101+ deleting.value = true
102+ try {
103+ await request(`/api/catalog/presets/${encodeURIComponent(props.name!)}`, { method: 'DELETE', query: force ? { force: '1' } : undefined, silent: true })
104+ original.value = snapshot()
105+ toast.add({ title: 'Preset deleted', description: props.name, icon: 'i-lucide-check', color: 'success' })
106+ delOpen.value = false
107+ await catalog.refresh()
108+ await navigateTo('/presets')
109+ } catch (e) {
110+ const err = e as { status?: number, message: string }
111+ if (err.status === 409) delConflict.value = err.message
112+ else toast.add({ title: 'Delete failed', description: err.message, color: 'error', icon: 'i-lucide-circle-alert' })
113+ } finally {
114+ deleting.value = false
115+ }
116+}
117+function askDelete() {
118+ delConflict.value = null
119+ delOpen.value = true
120+}
121+</script>
122+
123+<template>
124+ <UDashboardPanel id="preset-editor">
125+ <template #header>
126+ <UDashboardNavbar icon="i-lucide-layers">
127+ <template #title>
128+ <NuxtLink to="/presets" class="text-muted hover:text-default">Presets</NuxtLink>
129+ <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />
130+ <span class="truncate">{{ isNew ? 'New' : name }}</span>
131+ </template>
132+ <template #right>
133+ <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" />
134+ <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" />
135+ <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" />
136+ </template>
137+ </UDashboardNavbar>
138+ </template>
139+
140+ <template #body>
141+ <USkeleton v-if="!ready" class="h-72 w-full" />
142+ <UAlert v-else-if="notFound" color="warning" variant="subtle" icon="i-lucide-circle-help" title="Preset not found" description="It may have been renamed or deleted." :actions="[{ label: 'Back to presets', to: '/presets', color: 'neutral', variant: 'outline' }]" />
143+
144+ <div v-else class="grid items-start gap-6 xl:grid-cols-2">
145+ <div class="min-w-0 space-y-4">
146+ <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" />
147+ <div class="grid gap-4 sm:grid-cols-2">
148+ <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required>
149+ <UInput v-model="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" />
150+ </UFormField>
151+ <UFormField label="Description">
152+ <UInput v-model="form.description" class="w-full" />
153+ </UFormField>
154+ </div>
155+ <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="problems.join(' ')" />
156+
157+ <UFormField label="Included presets" description="Their items are added to this preset (resolved recursively).">
158+ <USelectMenu v-model="form.includes" :items="includeChoices" multiple placeholder="None" class="w-full" />
159+ </UFormField>
160+
161+ <div class="space-y-2">
162+ <h3 class="text-sm font-semibold">Items</h3>
163+ <ItemPicker v-model="form.items" :items="catalog.items.value" />
164+ </div>
165+ </div>
166+
167+ <div class="min-w-0 space-y-4">
168+ <section class="space-y-2">
169+ <h3 class="text-sm font-semibold">Resolved content <span class="font-normal text-muted">({{ resolved.entries.length }} items)</span></h3>
170+ <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Unknown preset(s): ${resolved.missingPresets.join(', ')}`" />
171+ <UAlert v-if="resolved.cycles.length" color="warning" variant="subtle" icon="i-lucide-repeat" :title="`Include cycle ignored: ${resolved.cycles.join(', ')}`" />
172+ <p v-if="!resolvedByType.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing selected yet.</p>
173+ <div v-for="g in resolvedByType" :key="g.type" class="overflow-hidden rounded-lg border border-default">
174+ <div class="flex items-center gap-2 bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted">
175+ <UIcon :name="CAT_META[g.type].icon" class="size-3.5" />{{ CAT_META[g.type].label }} ({{ g.rows.length }})
176+ </div>
177+ <ul class="divide-y divide-default text-sm">
178+ <li v-for="r in g.rows" :key="r.name" class="flex items-center gap-2 px-3 py-1.5">
179+ <NuxtLink v-if="!missingItems(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate hover:underline">{{ r.name }}</NuxtLink>
180+ <span v-else class="truncate">{{ r.name }}</span>
181+ <UBadge v-if="missingItems(r.type, r.name)" label="missing" size="sm" color="warning" variant="subtle" />
182+ <span class="ml-auto flex shrink-0 gap-1">
183+ <UBadge v-for="v in r.via.filter(v => v !== selfName)" :key="v" :label="`via ${v}`" size="sm" color="neutral" variant="subtle" />
184+ </span>
185+ </li>
186+ </ul>
187+ </div>
188+ </section>
189+
190+ <section v-if="!isNew" class="space-y-1.5">
191+ <h3 class="text-sm font-semibold">Used by</h3>
192+ <ul v-if="usedBy.length" class="space-y-1 text-sm">
193+ <li v-for="s in usedBy" :key="s">
194+ <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline">
195+ <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" />{{ scopeLabel(s, scopeNames) }}
196+ </NuxtLink>
197+ </li>
198+ </ul>
199+ <p v-else class="text-xs text-muted">Not enabled in any scope.</p>
200+ </section>
201+ </div>
202+ </div>
203+
204+ <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the preset from the catalog.">
205+ <template #body>
206+ <div class="space-y-3 text-sm">
207+ <p>Items of the preset are kept in the catalog. Scopes that enabled it will report the preset as missing.</p>
208+ <UAlert v-if="delConflict || usedBy.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="delConflict ? 'This preset is used' : 'Used by'">
209+ <template #description>
210+ <p v-if="delConflict" class="mb-1">{{ delConflict }}</p>
211+ <ul class="list-disc pl-4"><li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li></ul>
212+ </template>
213+ </UAlert>
214+ </div>
215+ </template>
216+ <template #footer>
217+ <div class="flex w-full justify-end gap-2">
218+ <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" />
219+ <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" />
220+ </div>
221+ </template>
222+ </UModal>
223+ </template>
224+ </UDashboardPanel>
225+</template>
added packages/ui/app/components/RemoveFromGlobalModal.vue +113 -0
new file mode 100644
@@ -0,0 +1,113 @@
1+<script setup lang="ts">
2+import type { InventoryItem, RemoveFromGlobalResult } from '@skilly/core'
3+
4+const props = defineProps<{ items: InventoryItem[] }>()
5+const open = defineModel<boolean>('open', { default: false })
6+const emit = defineEmits<{ done: [] }>()
7+
8+const { request } = useApi()
9+const { projects } = useProjects()
10+const toast = useToast()
11+
12+const chosen = ref<string[]>([])
13+const search = ref('')
14+const busy = ref(false)
15+const result = ref<RemoveFromGlobalResult | null>(null)
16+const error = ref<string | null>(null)
17+
18+const list = computed(() => {
19+ const q = search.value.trim().toLowerCase()
20+ return projects.value.filter(p => p.exists && (!q || `${p.name} ${p.path}`.toLowerCase().includes(q)))
21+})
22+
23+watch(open, (v) => {
24+ if (v) {
25+ chosen.value = []
26+ search.value = ''
27+ result.value = null
28+ error.value = null
29+ }
30+})
31+
32+function toggle(id: string, on: boolean | 'indeterminate') {
33+ chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id)
34+}
35+
36+async function submit() {
37+ busy.value = true
38+ error.value = null
39+ try {
40+ result.value = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', {
41+ method: 'POST',
42+ body: { itemIds: props.items.map(i => i.id), enableIn: chosen.value },
43+ silent: true
44+ })
45+ toast.add({
46+ title: `${result.value.removed.length} removed from Home`,
47+ color: result.value.errors.length ? 'warning' : 'success',
48+ icon: 'i-lucide-check'
49+ })
50+ emit('done')
51+ } catch (e) {
52+ error.value = (e as Error).message
53+ } finally {
54+ busy.value = false
55+ }
56+}
57+
58+const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id
59+</script>
60+
61+<template>
62+ <UModal v-model:open="open" title="Remove from global" description="Move these items out of Home and into the catalog." :ui="{ content: 'max-w-xl' }">
63+ <template #body>
64+ <div v-if="result" class="space-y-3 text-sm">
65+ <UAlert v-if="result.removed.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.removed.length} item(s) removed from Home`" :description="result.removed.map(nameOf).join(', ')" />
66+ <UAlert v-if="result.imported.length" color="info" variant="subtle" icon="i-lucide-library" :title="`${result.imported.length} item(s) in the catalog`" :description="result.imported.map(i => `${i.type}/${i.name}`).join(', ')" />
67+ <UAlert v-if="result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="`${result.errors.length} error(s)`">
68+ <template #description>
69+ <ul class="list-disc pl-4"><li v-for="e in result.errors" :key="e.id"><span class="font-medium">{{ nameOf(e.id) }}</span>: {{ e.message }}</li></ul>
70+ </template>
71+ </UAlert>
72+ </div>
73+ <div v-else class="space-y-4">
74+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Request failed" :description="error" />
75+ <ol class="list-decimal space-y-1 pl-5 text-sm text-muted">
76+ <li>Each item is <strong class="text-default">copied to the catalog</strong> (unless an identical one is already there).</li>
77+ <li>It is <strong class="text-default">enabled in the projects you choose</strong> below and written into them.</li>
78+ <li>It is then <strong class="text-default">removed from Home</strong>. Symbolic links are only unlinked: their target is never deleted.</li>
79+ </ol>
80+
81+ <div>
82+ <p class="mb-1 text-xs font-medium text-muted">Items ({{ items.length }})</p>
83+ <ul class="max-h-28 divide-y divide-default overflow-auto rounded-lg border border-default text-sm">
84+ <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1">
85+ <AgentBadge :agent="i.agent" compact /><span class="truncate font-medium">{{ i.name }}</span><span class="text-xs text-muted">{{ i.type }}</span>
86+ <UBadge v-if="i.source.symlinkTarget" label="symlink" size="sm" color="neutral" variant="subtle" class="ml-auto" />
87+ </li>
88+ </ul>
89+ </div>
90+
91+ <div class="space-y-1.5">
92+ <p class="text-xs font-medium text-muted">Enable in these projects ({{ chosen.length }} selected)</p>
93+ <UInput v-if="projects.length > 6" v-model="search" icon="i-lucide-search" size="sm" placeholder="Search projects..." class="w-full" />
94+ <ul v-if="list.length" class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm">
95+ <li v-for="p in list" :key="p.id" class="flex items-center gap-2 px-3 py-1.5 hover:bg-elevated/50">
96+ <UCheckbox :model-value="chosen.includes(p.id)" :aria-label="p.name" @update:model-value="(v) => toggle(p.id, v)" />
97+ <span class="font-medium">{{ p.name }}</span>
98+ <span class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</span>
99+ </li>
100+ </ul>
101+ <p v-else class="rounded-lg border border-dashed border-default p-4 text-center text-xs text-muted">No project available. The items will only be moved to the catalog.</p>
102+ <p v-if="!chosen.length" class="text-xs text-warning">No project selected: the items will disappear from Home and be available in the catalog only.</p>
103+ </div>
104+ </div>
105+ </template>
106+ <template #footer>
107+ <div class="flex w-full justify-end gap-2">
108+ <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" />
109+ <UButton v-if="!result" label="Remove from global" icon="i-lucide-globe-lock" color="error" :loading="busy" @click="submit" />
110+ </div>
111+ </template>
112+ </UModal>
113+</template>
new file mode 100644
@@ -0,0 +1,113 @@
1+<script setup lang="ts">
2+import type { InventoryItem, RemoveFromGlobalResult } from '@skilly/core'
3+
4+const props = defineProps<{ items: InventoryItem[] }>()
5+const open = defineModel<boolean>('open', { default: false })
6+const emit = defineEmits<{ done: [] }>()
7+
8+const { request } = useApi()
9+const { projects } = useProjects()
10+const toast = useToast()
11+
12+const chosen = ref<string[]>([])
13+const search = ref('')
14+const busy = ref(false)
15+const result = ref<RemoveFromGlobalResult | null>(null)
16+const error = ref<string | null>(null)
17+
18+const list = computed(() => {
19+ const q = search.value.trim().toLowerCase()
20+ return projects.value.filter(p => p.exists && (!q || `${p.name} ${p.path}`.toLowerCase().includes(q)))
21+})
22+
23+watch(open, (v) => {
24+ if (v) {
25+ chosen.value = []
26+ search.value = ''
27+ result.value = null
28+ error.value = null
29+ }
30+})
31+
32+function toggle(id: string, on: boolean | 'indeterminate') {
33+ chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id)
34+}
35+
36+async function submit() {
37+ busy.value = true
38+ error.value = null
39+ try {
40+ result.value = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', {
41+ method: 'POST',
42+ body: { itemIds: props.items.map(i => i.id), enableIn: chosen.value },
43+ silent: true
44+ })
45+ toast.add({
46+ title: `${result.value.removed.length} removed from Home`,
47+ color: result.value.errors.length ? 'warning' : 'success',
48+ icon: 'i-lucide-check'
49+ })
50+ emit('done')
51+ } catch (e) {
52+ error.value = (e as Error).message
53+ } finally {
54+ busy.value = false
55+ }
56+}
57+
58+const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id
59+</script>
60+
61+<template>
62+ <UModal v-model:open="open" title="Remove from global" description="Move these items out of Home and into the catalog." :ui="{ content: 'max-w-xl' }">
63+ <template #body>
64+ <div v-if="result" class="space-y-3 text-sm">
65+ <UAlert v-if="result.removed.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.removed.length} item(s) removed from Home`" :description="result.removed.map(nameOf).join(', ')" />
66+ <UAlert v-if="result.imported.length" color="info" variant="subtle" icon="i-lucide-library" :title="`${result.imported.length} item(s) in the catalog`" :description="result.imported.map(i => `${i.type}/${i.name}`).join(', ')" />
67+ <UAlert v-if="result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="`${result.errors.length} error(s)`">
68+ <template #description>
69+ <ul class="list-disc pl-4"><li v-for="e in result.errors" :key="e.id"><span class="font-medium">{{ nameOf(e.id) }}</span>: {{ e.message }}</li></ul>
70+ </template>
71+ </UAlert>
72+ </div>
73+ <div v-else class="space-y-4">
74+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Request failed" :description="error" />
75+ <ol class="list-decimal space-y-1 pl-5 text-sm text-muted">
76+ <li>Each item is <strong class="text-default">copied to the catalog</strong> (unless an identical one is already there).</li>
77+ <li>It is <strong class="text-default">enabled in the projects you choose</strong> below and written into them.</li>
78+ <li>It is then <strong class="text-default">removed from Home</strong>. Symbolic links are only unlinked: their target is never deleted.</li>
79+ </ol>
80+
81+ <div>
82+ <p class="mb-1 text-xs font-medium text-muted">Items ({{ items.length }})</p>
83+ <ul class="max-h-28 divide-y divide-default overflow-auto rounded-lg border border-default text-sm">
84+ <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1">
85+ <AgentBadge :agent="i.agent" compact /><span class="truncate font-medium">{{ i.name }}</span><span class="text-xs text-muted">{{ i.type }}</span>
86+ <UBadge v-if="i.source.symlinkTarget" label="symlink" size="sm" color="neutral" variant="subtle" class="ml-auto" />
87+ </li>
88+ </ul>
89+ </div>
90+
91+ <div class="space-y-1.5">
92+ <p class="text-xs font-medium text-muted">Enable in these projects ({{ chosen.length }} selected)</p>
93+ <UInput v-if="projects.length > 6" v-model="search" icon="i-lucide-search" size="sm" placeholder="Search projects..." class="w-full" />
94+ <ul v-if="list.length" class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm">
95+ <li v-for="p in list" :key="p.id" class="flex items-center gap-2 px-3 py-1.5 hover:bg-elevated/50">
96+ <UCheckbox :model-value="chosen.includes(p.id)" :aria-label="p.name" @update:model-value="(v) => toggle(p.id, v)" />
97+ <span class="font-medium">{{ p.name }}</span>
98+ <span class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</span>
99+ </li>
100+ </ul>
101+ <p v-else class="rounded-lg border border-dashed border-default p-4 text-center text-xs text-muted">No project available. The items will only be moved to the catalog.</p>
102+ <p v-if="!chosen.length" class="text-xs text-warning">No project selected: the items will disappear from Home and be available in the catalog only.</p>
103+ </div>
104+ </div>
105+ </template>
106+ <template #footer>
107+ <div class="flex w-full justify-end gap-2">
108+ <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" />
109+ <UButton v-if="!result" label="Remove from global" icon="i-lucide-globe-lock" color="error" :loading="busy" @click="submit" />
110+ </div>
111+ </template>
112+ </UModal>
113+</template>
added packages/ui/app/components/ScopeConfigBar.vue +69 -0
new file mode 100644
@@ -0,0 +1,69 @@
1+<script setup lang="ts">
2+import type { AgentId, Preset, ScopeConfig } from '@skilly/core'
3+
4+/** Targets and preset toggles, always visible above the tabs. Changes are emitted as a new config. */
5+const props = defineProps<{ config: ScopeConfig, presets: Preset[], catalogExists: boolean, saving: boolean }>()
6+const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>()
7+
8+const AGENT_LIST: AgentId[] = ['claude', 'codex']
9+
10+function toggleTarget(a: AgentId, on: boolean | 'indeterminate') {
11+ const c = cloneConfig(props.config)
12+ c.targets = on === true ? [...new Set([...c.targets, a])] : c.targets.filter(t => t !== a)
13+ emit('update:config', c)
14+}
15+function togglePreset(name: string) {
16+ const c = cloneConfig(props.config)
17+ c.presets = toggleIn(c.presets, name, !c.presets.includes(name))
18+ emit('update:config', c)
19+}
20+
21+const known = computed(() => new Set(props.presets.map(p => p.name)))
22+const unknown = computed(() => props.config.presets.filter(p => !known.value.has(p)))
23+const count = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length ?? 0), 0)
24+</script>
25+
26+<template>
27+ <div class="space-y-3 rounded-xl border border-default p-3">
28+ <div class="flex flex-wrap items-center gap-x-6 gap-y-2">
29+ <div class="flex items-center gap-3">
30+ <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span>
31+ <UCheckbox
32+ v-for="a in AGENT_LIST"
33+ :key="a"
34+ :model-value="config.targets.includes(a)"
35+ :label="AGENT_LABELS[a]"
36+ :aria-label="`Write files for ${AGENT_LABELS[a]}`"
37+ @update:model-value="(v) => toggleTarget(a, v)"
38+ />
39+ </div>
40+ <span v-if="!config.targets.length" class="text-xs text-warning">No target agent: nothing will be written.</span>
41+ <span class="ml-auto flex items-center gap-1.5 text-xs text-muted">
42+ <template v-if="saving"><UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />Saving...</template>
43+ <template v-else><UIcon name="i-lucide-check" class="size-3.5" />Saved</template>
44+ </span>
45+ </div>
46+
47+ <div class="flex flex-wrap items-center gap-2">
48+ <span class="mr-1 text-xs font-medium uppercase tracking-wide text-muted">Presets</span>
49+ <UTooltip v-for="p in presets" :key="p.name" :text="p.description || `${count(p)} items`">
50+ <UButton
51+ :label="p.name"
52+ :icon="config.presets.includes(p.name) ? 'i-lucide-check' : 'i-lucide-plus'"
53+ size="xs"
54+ :color="config.presets.includes(p.name) ? 'primary' : 'neutral'"
55+ :variant="config.presets.includes(p.name) ? 'soft' : 'outline'"
56+ :aria-pressed="config.presets.includes(p.name)"
57+ @click="togglePreset(p.name)"
58+ />
59+ </UTooltip>
60+ <UTooltip v-for="p in unknown" :key="p" text="Enabled in the configuration but not found in the catalog">
61+ <UButton :label="p" icon="i-lucide-triangle-alert" size="xs" color="warning" variant="soft" @click="togglePreset(p)" />
62+ </UTooltip>
63+ <span v-if="!presets.length && !unknown.length" class="text-xs text-muted">
64+ {{ catalogExists ? 'No preset yet.' : 'No catalog yet.' }}
65+ <NuxtLink :to="catalogExists ? '/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink>
66+ </span>
67+ </div>
68+ </div>
69+</template>
new file mode 100644
@@ -0,0 +1,69 @@
1+<script setup lang="ts">
2+import type { AgentId, Preset, ScopeConfig } from '@skilly/core'
3+
4+/** Targets and preset toggles, always visible above the tabs. Changes are emitted as a new config. */
5+const props = defineProps<{ config: ScopeConfig, presets: Preset[], catalogExists: boolean, saving: boolean }>()
6+const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>()
7+
8+const AGENT_LIST: AgentId[] = ['claude', 'codex']
9+
10+function toggleTarget(a: AgentId, on: boolean | 'indeterminate') {
11+ const c = cloneConfig(props.config)
12+ c.targets = on === true ? [...new Set([...c.targets, a])] : c.targets.filter(t => t !== a)
13+ emit('update:config', c)
14+}
15+function togglePreset(name: string) {
16+ const c = cloneConfig(props.config)
17+ c.presets = toggleIn(c.presets, name, !c.presets.includes(name))
18+ emit('update:config', c)
19+}
20+
21+const known = computed(() => new Set(props.presets.map(p => p.name)))
22+const unknown = computed(() => props.config.presets.filter(p => !known.value.has(p)))
23+const count = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length ?? 0), 0)
24+</script>
25+
26+<template>
27+ <div class="space-y-3 rounded-xl border border-default p-3">
28+ <div class="flex flex-wrap items-center gap-x-6 gap-y-2">
29+ <div class="flex items-center gap-3">
30+ <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span>
31+ <UCheckbox
32+ v-for="a in AGENT_LIST"
33+ :key="a"
34+ :model-value="config.targets.includes(a)"
35+ :label="AGENT_LABELS[a]"
36+ :aria-label="`Write files for ${AGENT_LABELS[a]}`"
37+ @update:model-value="(v) => toggleTarget(a, v)"
38+ />
39+ </div>
40+ <span v-if="!config.targets.length" class="text-xs text-warning">No target agent: nothing will be written.</span>
41+ <span class="ml-auto flex items-center gap-1.5 text-xs text-muted">
42+ <template v-if="saving"><UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />Saving...</template>
43+ <template v-else><UIcon name="i-lucide-check" class="size-3.5" />Saved</template>
44+ </span>
45+ </div>
46+
47+ <div class="flex flex-wrap items-center gap-2">
48+ <span class="mr-1 text-xs font-medium uppercase tracking-wide text-muted">Presets</span>
49+ <UTooltip v-for="p in presets" :key="p.name" :text="p.description || `${count(p)} items`">
50+ <UButton
51+ :label="p.name"
52+ :icon="config.presets.includes(p.name) ? 'i-lucide-check' : 'i-lucide-plus'"
53+ size="xs"
54+ :color="config.presets.includes(p.name) ? 'primary' : 'neutral'"
55+ :variant="config.presets.includes(p.name) ? 'soft' : 'outline'"
56+ :aria-pressed="config.presets.includes(p.name)"
57+ @click="togglePreset(p.name)"
58+ />
59+ </UTooltip>
60+ <UTooltip v-for="p in unknown" :key="p" text="Enabled in the configuration but not found in the catalog">
61+ <UButton :label="p" icon="i-lucide-triangle-alert" size="xs" color="warning" variant="soft" @click="togglePreset(p)" />
62+ </UTooltip>
63+ <span v-if="!presets.length && !unknown.length" class="text-xs text-muted">
64+ {{ catalogExists ? 'No preset yet.' : 'No catalog yet.' }}
65+ <NuxtLink :to="catalogExists ? '/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink>
66+ </span>
67+ </div>
68+ </div>
69+</template>
added packages/ui/app/components/ScopeConfigPanel.vue +282 -0
new file mode 100644
@@ -0,0 +1,282 @@
1+<script setup lang="ts">
2+import type { AgentId, CatalogItem, CatalogItemType, Preset, ScopeConfig } from '@skilly/core'
3+
4+/** "Configuration" tab: enabled items (exclude, automatic / on demand) and hidden global items. */
5+const props = defineProps<{ config: ScopeConfig, items: CatalogItem[], presets: Preset[], home: boolean }>()
6+const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>()
7+
8+const { home: homeInv, refresh: refreshHome, loading: homeLoading } = useHomeInventory()
9+
10+// Home manages skills, rules and agents (its MCP servers and hooks are read-only); projects manage every type.
11+const ADD_TYPES = computed<CatalogItemType[]>(() => (props.home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook']))
12+
13+const resolved = computed(() => resolveItems(props.presets, props.config.presets, props.config.add))
14+const rows = computed(() => resolved.value.entries
15+ .slice()
16+ .sort((a, b) => CAT_TYPES.indexOf(a.type) - CAT_TYPES.indexOf(b.type) || a.name.localeCompare(b.name)))
17+const inCatalog = (t: CatalogItemType, n: string) => props.items.some(i => i.type === t && i.name === n)
18+
19+function matchOf(list: string[] | undefined, name: string): { exact: boolean, pattern?: string } | null {
20+ const l = list ?? []
21+ if (l.includes(name)) return { exact: true }
22+ const g = l.find(p => p.includes('*') && globMatch(p, name))
23+ return g ? { exact: false, pattern: g } : null
24+}
25+
26+function mutate(fn: (c: ScopeConfig) => void) {
27+ const c = cloneConfig(props.config)
28+ fn(c)
29+ emit('update:config', c)
30+}
31+
32+function setIncluded(t: CatalogItemType, name: string, included: boolean) {
33+ mutate((c) => {
34+ const list = toggleIn(c.exclude[t] ?? [], name, !included)
35+ if (list.length) c.exclude[t] = list
36+ else delete c.exclude[t]
37+ })
38+}
39+function removeAdded(t: CatalogItemType, name: string) {
40+ mutate((c) => {
41+ const list = (c.add[t] ?? []).filter(x => x !== name)
42+ if (list.length) c.add[t] = list
43+ else delete c.add[t]
44+ })
45+}
46+function setOnDemand(name: string, on: boolean) {
47+ mutate((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, name, on) })
48+}
49+
50+/* ------------------------------------------------ Add items */
51+
52+const pickerOpen = ref(false)
53+const pickerSel = ref<Partial<Record<CatalogItemType, string[]>>>({})
54+const locked = computed(() => {
55+ const m: Record<string, string> = {}
56+ for (const e of resolved.value.entries) {
57+ const preset = e.via.find(v => v !== 'add')
58+ if (preset) m[`${e.type}/${e.name}`] = `via ${preset}`
59+ }
60+ return m
61+})
62+const pickable = computed(() => props.items.filter(i => ADD_TYPES.value.includes(i.type)))
63+
64+function openPicker() {
65+ pickerSel.value = structuredClone(toRaw(props.config.add))
66+ pickerOpen.value = true
67+}
68+function confirmPicker() {
69+ mutate((c) => {
70+ for (const t of ADD_TYPES.value) {
71+ const l = pickerSel.value[t] ?? []
72+ if (l.length) c.add[t] = l
73+ else delete c.add[t]
74+ }
75+ })
76+ pickerOpen.value = false
77+}
78+
79+/* ------------------------------------------------ Hide global (projects) */
80+
81+const hideTab = ref<'skills' | 'mcp'>('skills')
82+const hideSearch = ref('')
83+const HIDE_LIMIT = 100
84+
85+onMounted(() => {
86+ if (!props.home && !homeInv.value) refreshHome()
87+})
88+
89+const homeNames = computed(() => {
90+ const out: Record<'skills' | 'mcp', Map<string, Set<AgentId>>> = { skills: new Map(), mcp: new Map() }
91+ for (const i of homeInv.value?.items ?? []) {
92+ const bucket = i.type === 'skill' ? out.skills : i.type === 'mcp' ? out.mcp : null
93+ if (!bucket) continue
94+ bucket.set(i.name, (bucket.get(i.name) ?? new Set()).add(i.agent))
95+ }
96+ return out
97+})
98+const hideRows = computed(() => {
99+ const q = hideSearch.value.trim().toLowerCase()
100+ return [...homeNames.value[hideTab.value].entries()]
101+ .filter(([n]) => !q || n.toLowerCase().includes(q))
102+ .sort((a, b) => a[0].localeCompare(b[0]))
103+})
104+const hideTabs = computed(() => [
105+ { label: 'Skills', value: 'skills', icon: TYPE_META.skill.icon, badge: { label: String(homeNames.value.skills.size), color: 'neutral' as const, variant: 'subtle' as const } },
106+ { label: 'MCP servers', value: 'mcp', icon: TYPE_META.mcp.icon, badge: { label: String(homeNames.value.mcp.size), color: 'neutral' as const, variant: 'subtle' as const } }
107+])
108+function setHidden(name: string, on: boolean | 'indeterminate') {
109+ mutate((c) => { c.hideGlobal[hideTab.value] = toggleIn(c.hideGlobal[hideTab.value], name, on === true) })
110+}
111+
112+const patterns = computed(() => {
113+ const out: { where: string, pattern: string }[] = []
114+ for (const [t, l] of Object.entries(props.config.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push({ where: `exclude ${t}`, pattern: p })
115+ for (const p of props.config.onDemand.skills) if (p.includes('*')) out.push({ where: 'on demand', pattern: p })
116+ if (!props.home) {
117+ for (const p of props.config.hideGlobal.skills) if (p.includes('*')) out.push({ where: 'hide skills', pattern: p })
118+ for (const p of props.config.hideGlobal.mcp) if (p.includes('*')) out.push({ where: 'hide mcp', pattern: p })
119+ }
120+ return out
121+})
122+</script>
123+
124+<template>
125+ <div class="space-y-8">
126+ <!-- Enabled items -->
127+ <section class="space-y-3">
128+ <div class="flex items-center gap-3">
129+ <div>
130+ <h2 class="font-semibold">Enabled items <span class="font-normal text-muted">({{ rows.length }})</span></h2>
131+ <p class="text-xs text-muted">From enabled presets and items added one by one. Files are written when you apply the changes.</p>
132+ </div>
133+ <UButton class="ml-auto" label="Add items" icon="i-lucide-plus" :disabled="!items.length" @click="openPicker" />
134+ </div>
135+ <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Preset(s) not found in the catalog: ${resolved.missingPresets.join(', ')}`" />
136+ <UAlert v-if="home" color="neutral" variant="subtle" icon="i-lucide-info" title="Home MCP servers and hooks are not managed in v1" description="Their entries in your user configuration are listed read-only in the MCP and Hooks tabs." />
137+
138+ <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-10 text-center">
139+ <UIcon name="i-lucide-layers" class="size-8 text-dimmed" />
140+ <p class="text-sm text-muted">Nothing enabled yet. Toggle a preset above, or add items one by one.</p>
141+ </div>
142+ <div v-else class="overflow-x-auto rounded-xl border border-default">
143+ <table class="w-full min-w-[720px] table-fixed text-sm">
144+ <thead class="border-b border-default bg-elevated/40">
145+ <tr>
146+ <th class="w-[26%] px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Name</th>
147+ <th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Source</th>
148+ <th class="w-52 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">
149+ <span class="inline-flex items-center gap-1">
150+ Mode
151+ <UTooltip :content="{ side: 'top' }">
152+ <UIcon name="i-lucide-circle-help" class="size-3.5" />
153+ <template #content>
154+ <p class="max-w-xs whitespace-normal text-xs normal-case">
155+ Automatic: the agent sees the skill and uses it when relevant. On demand: installed but hidden from the agent;
156+ you invoke it yourself with /name in Claude Code or $name in Codex.
157+ </p>
158+ </template>
159+ </UTooltip>
160+ </span>
161+ </th>
162+ <th class="w-36 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Include</th>
163+ </tr>
164+ </thead>
165+ <tbody class="divide-y divide-default">
166+ <tr v-for="r in rows" :key="`${r.type}/${r.name}`" :class="matchOf(config.exclude[r.type], r.name) ? 'opacity-60' : ''">
167+ <td class="px-3 py-2 align-middle">
168+ <div class="flex items-center gap-2">
169+ <UIcon :name="CAT_META[r.type].icon" class="size-4 shrink-0 text-muted" />
170+ <NuxtLink v-if="inCatalog(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate font-medium hover:underline">{{ r.name }}</NuxtLink>
171+ <span v-else class="truncate font-medium">{{ r.name }}</span>
172+ <UTooltip v-if="!inCatalog(r.type, r.name)" text="Not found in the catalog: it will be skipped">
173+ <UBadge label="missing" size="sm" color="warning" variant="subtle" />
174+ </UTooltip>
175+ </div>
176+ </td>
177+ <td class="px-3 py-2 align-middle">
178+ <div class="flex flex-wrap gap-1">
179+ <UBadge v-for="v in r.via" :key="v" :label="v === 'add' ? 'added' : `preset: ${v}`" size="sm" :color="v === 'add' ? 'primary' : 'neutral'" variant="subtle" />
180+ </div>
181+ </td>
182+ <td class="px-3 py-2 align-middle">
183+ <template v-if="r.type === 'skill'">
184+ <UFieldGroup size="xs">
185+ <UButton
186+ label="Automatic"
187+ :color="matchOf(config.onDemand.skills, r.name) ? 'neutral' : 'primary'"
188+ :variant="matchOf(config.onDemand.skills, r.name) ? 'outline' : 'soft'"
189+ :disabled="matchOf(config.onDemand.skills, r.name)?.exact === false"
190+ @click="setOnDemand(r.name, false)"
191+ />
192+ <UButton
193+ label="On demand"
194+ :color="matchOf(config.onDemand.skills, r.name) ? 'primary' : 'neutral'"
195+ :variant="matchOf(config.onDemand.skills, r.name) ? 'soft' : 'outline'"
196+ @click="setOnDemand(r.name, true)"
197+ />
198+ </UFieldGroup>
199+ <p v-if="matchOf(config.onDemand.skills, r.name)?.exact === false" class="mt-0.5 font-mono text-[11px] text-muted">matches {{ matchOf(config.onDemand.skills, r.name)!.pattern }}</p>
200+ </template>
201+ <span v-else class="text-xs text-dimmed">-</span>
202+ </td>
203+ <td class="px-3 py-2 align-middle">
204+ <div class="flex items-center gap-2">
205+ <template v-if="r.via.some(v => v !== 'add')">
206+ <USwitch
207+ size="sm"
208+ :model-value="!matchOf(config.exclude[r.type], r.name)"
209+ :disabled="matchOf(config.exclude[r.type], r.name)?.exact === false"
210+ :aria-label="`Include ${r.name}`"
211+ @update:model-value="(v: boolean) => setIncluded(r.type, r.name, v)"
212+ />
213+ <span class="text-xs text-muted">{{ matchOf(config.exclude[r.type], r.name) ? (matchOf(config.exclude[r.type], r.name)!.exact ? 'excluded' : matchOf(config.exclude[r.type], r.name)!.pattern) : 'included' }}</span>
214+ </template>
215+ <UButton v-if="r.via.includes('add')" icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Remove from this scope" class="ml-auto" @click="removeAdded(r.type, r.name)" />
216+ </div>
217+ </td>
218+ </tr>
219+ </tbody>
220+ </table>
221+ </div>
222+ <p v-if="patterns.length" class="flex flex-wrap items-center gap-1.5 text-xs text-muted">
223+ Patterns kept as is:
224+ <UBadge v-for="(p, i) in patterns" :key="i" :label="`${p.where}: ${p.pattern}`" size="sm" color="neutral" variant="outline" class="font-mono" />
225+ </p>
226+ </section>
227+
228+ <!-- Hide global (projects only) -->
229+ <section v-if="!home" class="space-y-3">
230+ <div>
231+ <h2 class="font-semibold">Hide global items</h2>
232+ <p class="text-xs text-muted">Hide Home skills and MCP servers in this project so they do not clutter its context.</p>
233+ </div>
234+ <UAlert
235+ color="neutral"
236+ variant="subtle"
237+ icon="i-lucide-info"
238+ title="Agent support"
239+ description="Claude Code can hide global skills (skillOverrides) and MCP servers (deniedMcpServers) per project. Codex cannot hide them per project: it can only disable them globally in ~/.codex/config.toml."
240+ />
241+ <div class="flex flex-wrap items-center gap-3">
242+ <UTabs v-model="hideTab" :items="hideTabs" :content="false" variant="pill" color="neutral" size="sm" class="w-auto" />
243+ <UInput v-model="hideSearch" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-64" />
244+ </div>
245+ <USkeleton v-if="homeLoading && !homeInv" class="h-24 w-full" />
246+ <p v-else-if="!hideRows.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">No global {{ hideTab === 'skills' ? 'skill' : 'MCP server' }} found in Home.</p>
247+ <ul v-else class="divide-y divide-default overflow-hidden rounded-xl border border-default">
248+ <li v-for="[name, agents] in hideRows.slice(0, HIDE_LIMIT)" :key="name" class="flex items-center gap-3 px-3 py-1.5">
249+ <USwitch
250+ size="sm"
251+ :model-value="!!matchOf(config.hideGlobal[hideTab], name)"
252+ :disabled="matchOf(config.hideGlobal[hideTab], name)?.exact === false"
253+ :aria-label="`Hide ${name}`"
254+ @update:model-value="(v: boolean) => setHidden(name, v)"
255+ />
256+ <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ name }}</span>
257+ <span v-if="matchOf(config.hideGlobal[hideTab], name)?.exact === false" class="font-mono text-[11px] text-muted">matches {{ matchOf(config.hideGlobal[hideTab], name)!.pattern }}</span>
258+ <div class="flex gap-1">
259+ <UTooltip v-for="a in [...agents]" :key="a" :text="a === 'claude' ? 'Can be hidden' : 'Codex cannot hide it per project'">
260+ <span :class="a === 'claude' ? '' : 'opacity-50'"><AgentBadge :agent="a" compact /></span>
261+ </UTooltip>
262+ </div>
263+ </li>
264+ <li v-if="hideRows.length > HIDE_LIMIT" class="p-2 text-center text-xs text-muted">{{ hideRows.length - HIDE_LIMIT }} more: refine the search.</li>
265+ </ul>
266+ </section>
267+
268+ <!-- Add items -->
269+ <UModal v-model:open="pickerOpen" title="Add items" description="Pick items from the catalog to enable in this scope." :ui="{ content: 'max-w-2xl' }">
270+ <template #body>
271+ <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" />
272+ <p v-if="home" class="mt-2 text-xs text-muted">Home can receive skills, rules and agents. Its MCP servers and hooks are not managed in v1.</p>
273+ </template>
274+ <template #footer>
275+ <div class="flex w-full justify-end gap-2">
276+ <UButton label="Cancel" color="neutral" variant="outline" @click="pickerOpen = false" />
277+ <UButton label="Apply selection" icon="i-lucide-check" @click="confirmPicker" />
278+ </div>
279+ </template>
280+ </UModal>
281+ </div>
282+</template>
new file mode 100644
@@ -0,0 +1,282 @@
1+<script setup lang="ts">
2+import type { AgentId, CatalogItem, CatalogItemType, Preset, ScopeConfig } from '@skilly/core'
3+
4+/** "Configuration" tab: enabled items (exclude, automatic / on demand) and hidden global items. */
5+const props = defineProps<{ config: ScopeConfig, items: CatalogItem[], presets: Preset[], home: boolean }>()
6+const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>()
7+
8+const { home: homeInv, refresh: refreshHome, loading: homeLoading } = useHomeInventory()
9+
10+// Home manages skills, rules and agents (its MCP servers and hooks are read-only); projects manage every type.
11+const ADD_TYPES = computed<CatalogItemType[]>(() => (props.home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook']))
12+
13+const resolved = computed(() => resolveItems(props.presets, props.config.presets, props.config.add))
14+const rows = computed(() => resolved.value.entries
15+ .slice()
16+ .sort((a, b) => CAT_TYPES.indexOf(a.type) - CAT_TYPES.indexOf(b.type) || a.name.localeCompare(b.name)))
17+const inCatalog = (t: CatalogItemType, n: string) => props.items.some(i => i.type === t && i.name === n)
18+
19+function matchOf(list: string[] | undefined, name: string): { exact: boolean, pattern?: string } | null {
20+ const l = list ?? []
21+ if (l.includes(name)) return { exact: true }
22+ const g = l.find(p => p.includes('*') && globMatch(p, name))
23+ return g ? { exact: false, pattern: g } : null
24+}
25+
26+function mutate(fn: (c: ScopeConfig) => void) {
27+ const c = cloneConfig(props.config)
28+ fn(c)
29+ emit('update:config', c)
30+}
31+
32+function setIncluded(t: CatalogItemType, name: string, included: boolean) {
33+ mutate((c) => {
34+ const list = toggleIn(c.exclude[t] ?? [], name, !included)
35+ if (list.length) c.exclude[t] = list
36+ else delete c.exclude[t]
37+ })
38+}
39+function removeAdded(t: CatalogItemType, name: string) {
40+ mutate((c) => {
41+ const list = (c.add[t] ?? []).filter(x => x !== name)
42+ if (list.length) c.add[t] = list
43+ else delete c.add[t]
44+ })
45+}
46+function setOnDemand(name: string, on: boolean) {
47+ mutate((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, name, on) })
48+}
49+
50+/* ------------------------------------------------ Add items */
51+
52+const pickerOpen = ref(false)
53+const pickerSel = ref<Partial<Record<CatalogItemType, string[]>>>({})
54+const locked = computed(() => {
55+ const m: Record<string, string> = {}
56+ for (const e of resolved.value.entries) {
57+ const preset = e.via.find(v => v !== 'add')
58+ if (preset) m[`${e.type}/${e.name}`] = `via ${preset}`
59+ }
60+ return m
61+})
62+const pickable = computed(() => props.items.filter(i => ADD_TYPES.value.includes(i.type)))
63+
64+function openPicker() {
65+ pickerSel.value = structuredClone(toRaw(props.config.add))
66+ pickerOpen.value = true
67+}
68+function confirmPicker() {
69+ mutate((c) => {
70+ for (const t of ADD_TYPES.value) {
71+ const l = pickerSel.value[t] ?? []
72+ if (l.length) c.add[t] = l
73+ else delete c.add[t]
74+ }
75+ })
76+ pickerOpen.value = false
77+}
78+
79+/* ------------------------------------------------ Hide global (projects) */
80+
81+const hideTab = ref<'skills' | 'mcp'>('skills')
82+const hideSearch = ref('')
83+const HIDE_LIMIT = 100
84+
85+onMounted(() => {
86+ if (!props.home && !homeInv.value) refreshHome()
87+})
88+
89+const homeNames = computed(() => {
90+ const out: Record<'skills' | 'mcp', Map<string, Set<AgentId>>> = { skills: new Map(), mcp: new Map() }
91+ for (const i of homeInv.value?.items ?? []) {
92+ const bucket = i.type === 'skill' ? out.skills : i.type === 'mcp' ? out.mcp : null
93+ if (!bucket) continue
94+ bucket.set(i.name, (bucket.get(i.name) ?? new Set()).add(i.agent))
95+ }
96+ return out
97+})
98+const hideRows = computed(() => {
99+ const q = hideSearch.value.trim().toLowerCase()
100+ return [...homeNames.value[hideTab.value].entries()]
101+ .filter(([n]) => !q || n.toLowerCase().includes(q))
102+ .sort((a, b) => a[0].localeCompare(b[0]))
103+})
104+const hideTabs = computed(() => [
105+ { label: 'Skills', value: 'skills', icon: TYPE_META.skill.icon, badge: { label: String(homeNames.value.skills.size), color: 'neutral' as const, variant: 'subtle' as const } },
106+ { label: 'MCP servers', value: 'mcp', icon: TYPE_META.mcp.icon, badge: { label: String(homeNames.value.mcp.size), color: 'neutral' as const, variant: 'subtle' as const } }
107+])
108+function setHidden(name: string, on: boolean | 'indeterminate') {
109+ mutate((c) => { c.hideGlobal[hideTab.value] = toggleIn(c.hideGlobal[hideTab.value], name, on === true) })
110+}
111+
112+const patterns = computed(() => {
113+ const out: { where: string, pattern: string }[] = []
114+ for (const [t, l] of Object.entries(props.config.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push({ where: `exclude ${t}`, pattern: p })
115+ for (const p of props.config.onDemand.skills) if (p.includes('*')) out.push({ where: 'on demand', pattern: p })
116+ if (!props.home) {
117+ for (const p of props.config.hideGlobal.skills) if (p.includes('*')) out.push({ where: 'hide skills', pattern: p })
118+ for (const p of props.config.hideGlobal.mcp) if (p.includes('*')) out.push({ where: 'hide mcp', pattern: p })
119+ }
120+ return out
121+})
122+</script>
123+
124+<template>
125+ <div class="space-y-8">
126+ <!-- Enabled items -->
127+ <section class="space-y-3">
128+ <div class="flex items-center gap-3">
129+ <div>
130+ <h2 class="font-semibold">Enabled items <span class="font-normal text-muted">({{ rows.length }})</span></h2>
131+ <p class="text-xs text-muted">From enabled presets and items added one by one. Files are written when you apply the changes.</p>
132+ </div>
133+ <UButton class="ml-auto" label="Add items" icon="i-lucide-plus" :disabled="!items.length" @click="openPicker" />
134+ </div>
135+ <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Preset(s) not found in the catalog: ${resolved.missingPresets.join(', ')}`" />
136+ <UAlert v-if="home" color="neutral" variant="subtle" icon="i-lucide-info" title="Home MCP servers and hooks are not managed in v1" description="Their entries in your user configuration are listed read-only in the MCP and Hooks tabs." />
137+
138+ <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-10 text-center">
139+ <UIcon name="i-lucide-layers" class="size-8 text-dimmed" />
140+ <p class="text-sm text-muted">Nothing enabled yet. Toggle a preset above, or add items one by one.</p>
141+ </div>
142+ <div v-else class="overflow-x-auto rounded-xl border border-default">
143+ <table class="w-full min-w-[720px] table-fixed text-sm">
144+ <thead class="border-b border-default bg-elevated/40">
145+ <tr>
146+ <th class="w-[26%] px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Name</th>
147+ <th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Source</th>
148+ <th class="w-52 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">
149+ <span class="inline-flex items-center gap-1">
150+ Mode
151+ <UTooltip :content="{ side: 'top' }">
152+ <UIcon name="i-lucide-circle-help" class="size-3.5" />
153+ <template #content>
154+ <p class="max-w-xs whitespace-normal text-xs normal-case">
155+ Automatic: the agent sees the skill and uses it when relevant. On demand: installed but hidden from the agent;
156+ you invoke it yourself with /name in Claude Code or $name in Codex.
157+ </p>
158+ </template>
159+ </UTooltip>
160+ </span>
161+ </th>
162+ <th class="w-36 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Include</th>
163+ </tr>
164+ </thead>
165+ <tbody class="divide-y divide-default">
166+ <tr v-for="r in rows" :key="`${r.type}/${r.name}`" :class="matchOf(config.exclude[r.type], r.name) ? 'opacity-60' : ''">
167+ <td class="px-3 py-2 align-middle">
168+ <div class="flex items-center gap-2">
169+ <UIcon :name="CAT_META[r.type].icon" class="size-4 shrink-0 text-muted" />
170+ <NuxtLink v-if="inCatalog(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate font-medium hover:underline">{{ r.name }}</NuxtLink>
171+ <span v-else class="truncate font-medium">{{ r.name }}</span>
172+ <UTooltip v-if="!inCatalog(r.type, r.name)" text="Not found in the catalog: it will be skipped">
173+ <UBadge label="missing" size="sm" color="warning" variant="subtle" />
174+ </UTooltip>
175+ </div>
176+ </td>
177+ <td class="px-3 py-2 align-middle">
178+ <div class="flex flex-wrap gap-1">
179+ <UBadge v-for="v in r.via" :key="v" :label="v === 'add' ? 'added' : `preset: ${v}`" size="sm" :color="v === 'add' ? 'primary' : 'neutral'" variant="subtle" />
180+ </div>
181+ </td>
182+ <td class="px-3 py-2 align-middle">
183+ <template v-if="r.type === 'skill'">
184+ <UFieldGroup size="xs">
185+ <UButton
186+ label="Automatic"
187+ :color="matchOf(config.onDemand.skills, r.name) ? 'neutral' : 'primary'"
188+ :variant="matchOf(config.onDemand.skills, r.name) ? 'outline' : 'soft'"
189+ :disabled="matchOf(config.onDemand.skills, r.name)?.exact === false"
190+ @click="setOnDemand(r.name, false)"
191+ />
192+ <UButton
193+ label="On demand"
194+ :color="matchOf(config.onDemand.skills, r.name) ? 'primary' : 'neutral'"
195+ :variant="matchOf(config.onDemand.skills, r.name) ? 'soft' : 'outline'"
196+ @click="setOnDemand(r.name, true)"
197+ />
198+ </UFieldGroup>
199+ <p v-if="matchOf(config.onDemand.skills, r.name)?.exact === false" class="mt-0.5 font-mono text-[11px] text-muted">matches {{ matchOf(config.onDemand.skills, r.name)!.pattern }}</p>
200+ </template>
201+ <span v-else class="text-xs text-dimmed">-</span>
202+ </td>
203+ <td class="px-3 py-2 align-middle">
204+ <div class="flex items-center gap-2">
205+ <template v-if="r.via.some(v => v !== 'add')">
206+ <USwitch
207+ size="sm"
208+ :model-value="!matchOf(config.exclude[r.type], r.name)"
209+ :disabled="matchOf(config.exclude[r.type], r.name)?.exact === false"
210+ :aria-label="`Include ${r.name}`"
211+ @update:model-value="(v: boolean) => setIncluded(r.type, r.name, v)"
212+ />
213+ <span class="text-xs text-muted">{{ matchOf(config.exclude[r.type], r.name) ? (matchOf(config.exclude[r.type], r.name)!.exact ? 'excluded' : matchOf(config.exclude[r.type], r.name)!.pattern) : 'included' }}</span>
214+ </template>
215+ <UButton v-if="r.via.includes('add')" icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Remove from this scope" class="ml-auto" @click="removeAdded(r.type, r.name)" />
216+ </div>
217+ </td>
218+ </tr>
219+ </tbody>
220+ </table>
221+ </div>
222+ <p v-if="patterns.length" class="flex flex-wrap items-center gap-1.5 text-xs text-muted">
223+ Patterns kept as is:
224+ <UBadge v-for="(p, i) in patterns" :key="i" :label="`${p.where}: ${p.pattern}`" size="sm" color="neutral" variant="outline" class="font-mono" />
225+ </p>
226+ </section>
227+
228+ <!-- Hide global (projects only) -->
229+ <section v-if="!home" class="space-y-3">
230+ <div>
231+ <h2 class="font-semibold">Hide global items</h2>
232+ <p class="text-xs text-muted">Hide Home skills and MCP servers in this project so they do not clutter its context.</p>
233+ </div>
234+ <UAlert
235+ color="neutral"
236+ variant="subtle"
237+ icon="i-lucide-info"
238+ title="Agent support"
239+ description="Claude Code can hide global skills (skillOverrides) and MCP servers (deniedMcpServers) per project. Codex cannot hide them per project: it can only disable them globally in ~/.codex/config.toml."
240+ />
241+ <div class="flex flex-wrap items-center gap-3">
242+ <UTabs v-model="hideTab" :items="hideTabs" :content="false" variant="pill" color="neutral" size="sm" class="w-auto" />
243+ <UInput v-model="hideSearch" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-64" />
244+ </div>
245+ <USkeleton v-if="homeLoading && !homeInv" class="h-24 w-full" />
246+ <p v-else-if="!hideRows.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">No global {{ hideTab === 'skills' ? 'skill' : 'MCP server' }} found in Home.</p>
247+ <ul v-else class="divide-y divide-default overflow-hidden rounded-xl border border-default">
248+ <li v-for="[name, agents] in hideRows.slice(0, HIDE_LIMIT)" :key="name" class="flex items-center gap-3 px-3 py-1.5">
249+ <USwitch
250+ size="sm"
251+ :model-value="!!matchOf(config.hideGlobal[hideTab], name)"
252+ :disabled="matchOf(config.hideGlobal[hideTab], name)?.exact === false"
253+ :aria-label="`Hide ${name}`"
254+ @update:model-value="(v: boolean) => setHidden(name, v)"
255+ />
256+ <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ name }}</span>
257+ <span v-if="matchOf(config.hideGlobal[hideTab], name)?.exact === false" class="font-mono text-[11px] text-muted">matches {{ matchOf(config.hideGlobal[hideTab], name)!.pattern }}</span>
258+ <div class="flex gap-1">
259+ <UTooltip v-for="a in [...agents]" :key="a" :text="a === 'claude' ? 'Can be hidden' : 'Codex cannot hide it per project'">
260+ <span :class="a === 'claude' ? '' : 'opacity-50'"><AgentBadge :agent="a" compact /></span>
261+ </UTooltip>
262+ </div>
263+ </li>
264+ <li v-if="hideRows.length > HIDE_LIMIT" class="p-2 text-center text-xs text-muted">{{ hideRows.length - HIDE_LIMIT }} more: refine the search.</li>
265+ </ul>
266+ </section>
267+
268+ <!-- Add items -->
269+ <UModal v-model:open="pickerOpen" title="Add items" description="Pick items from the catalog to enable in this scope." :ui="{ content: 'max-w-2xl' }">
270+ <template #body>
271+ <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" />
272+ <p v-if="home" class="mt-2 text-xs text-muted">Home can receive skills, rules and agents. Its MCP servers and hooks are not managed in v1.</p>
273+ </template>
274+ <template #footer>
275+ <div class="flex w-full justify-end gap-2">
276+ <UButton label="Cancel" color="neutral" variant="outline" @click="pickerOpen = false" />
277+ <UButton label="Apply selection" icon="i-lucide-check" @click="confirmPicker" />
278+ </div>
279+ </template>
280+ </UModal>
281+ </div>
282+</template>
modified packages/ui/app/components/ScopeView.vue +231 -35
@@ -1,20 +1,29 @@
11 <script setup lang="ts">
2-import type { AgentId, InventoryItem, ItemType, ScopeInventory } from '@skilly/core'
2+import type { AgentId, CatalogRef, InventoryItem, ItemType, ScopeConfig, ScopeState } from '@skilly/core'
33
44 const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>()
55
66 const { request } = useApi()
77 const { projects, remove, refresh: refreshProjects } = useProjects()
8+const catalog = useCatalog()
89 const toast = useToast()
910 const router = useRouter()
1011
11-const inventory = ref<ScopeInventory | null>(null)
12+const scopeId = computed(() => (props.kind === 'home' ? 'home' : props.projectId!))
13+const state = ref<ScopeState | null>(null)
14+const inventory = computed(() => state.value?.inventory ?? null)
15+const statuses = computed(() => state.value?.statuses ?? {})
16+const plan = computed(() => state.value?.plan ?? null)
1217 const loading = ref(true)
1318 const error = ref<string | null>(null)
1419
20+// Local editable copy of the config: edits are shown at once and saved (debounced) in the background.
21+const config = ref<ScopeConfig | null>(null)
22+const saving = ref(false)
23+
1524 const agentFilter = ref<'all' | AgentId>('all')
1625 const search = ref('')
17-const tab = ref<ItemType>('skill')
26+const tab = ref<ItemType | 'config'>('skill')
1827 const selected = ref<InventoryItem | null>(null)
1928 const detailOpen = ref(false)
2029 const removeOpen = ref(false)
@@ -29,8 +38,8 @@ const folderMissing = computed(() => props.kind === 'project' && project.value &
2938 async function load(showLoading = true) {
3039 if (showLoading) loading.value = true
3140 try {
32- const url = props.kind === 'home' ? '/api/home' : `/api/projects/${encodeURIComponent(props.projectId!)}/inventory`
33- inventory.value = await request<ScopeInventory>(url, { silent: true })
41+ state.value = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/state`, { silent: true })
42+ config.value = state.value.config ? cloneConfig(state.value.config) : null
3443 error.value = null
3544 } catch (e) {
3645 error.value = (e as Error).message
@@ -46,7 +55,53 @@ async function rescan() {
4655 if (!error.value) toast.add({ title: 'Rescan complete', icon: 'i-lucide-check', color: 'success' })
4756 }
4857
49-onMounted(() => load())
58+onMounted(async () => {
59+ if (!catalog.data.value) catalog.refresh()
60+ await load()
61+ if (config.value) tab.value = 'config'
62+})
63+
64+/* ------------------------------------------------ Config */
65+
66+let timer: ReturnType<typeof setTimeout> | undefined
67+let seq = 0
68+
69+function saveConfig(next: ScopeConfig, immediate = false) {
70+ config.value = next
71+ const mine = ++seq
72+ saving.value = true
73+ clearTimeout(timer)
74+ const run = async () => {
75+ try {
76+ const res = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/config`, { method: 'PUT', body: next, silent: true })
77+ state.value = res
78+ // Only adopt the server copy when no newer edit happened meanwhile.
79+ if (mine === seq) config.value = res.config ? cloneConfig(res.config) : next
80+ } catch (e) {
81+ toast.add({ title: 'Could not save the configuration', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
82+ if (mine === seq) await load(false)
83+ } finally {
84+ if (mine === seq) saving.value = false
85+ }
86+ }
87+ if (immediate) run()
88+ else timer = setTimeout(run, 400)
89+}
90+onBeforeUnmount(() => clearTimeout(timer))
91+
92+const setupTargets = ref<AgentId[]>([])
93+watch(inventory, (inv) => {
94+ if (!setupTargets.value.length) setupTargets.value = inv?.agentsDetected.length ? [...inv.agentsDetected] : ['claude', 'codex']
95+}, { immediate: true })
96+function toggleSetupTarget(a: AgentId, on: boolean | 'indeterminate') {
97+ setupTargets.value = on === true ? [...new Set([...setupTargets.value, a])] : setupTargets.value.filter(x => x !== a)
98+}
99+async function enableManagement() {
100+ saveConfig(emptyConfig(setupTargets.value), true)
101+ tab.value = 'config'
102+}
103+
104+/* ------------------------------------------------ Inventory */
50105
51106 const matches = (item: InventoryItem, q: string) => {
52107 const d = item.details
@@ -76,12 +131,19 @@ const byType = computed(() => {
76131 return m
77132 })
78133
79-const tabs = computed(() => TYPE_ORDER.map(t => ({
80- label: TYPE_META[t].label,
81- icon: TYPE_META[t].icon,
82- value: t,
83- badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const }
84-})))
134+const enabledCount = computed(() => (config.value ? resolveItems(catalog.presets.value, config.value.presets, config.value.add).entries.length : 0))
135+
136+const tabs = computed(() => [
137+ ...(config.value
138+ ? [{ label: 'Configuration', icon: 'i-lucide-sliders-horizontal', value: 'config', badge: { label: String(enabledCount.value), color: 'neutral' as const, variant: 'subtle' as const } }]
139+ : []),
140+ ...TYPE_ORDER.map(t => ({
141+ label: TYPE_META[t].label,
142+ icon: TYPE_META[t].icon,
143+ value: t,
144+ badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const }
145+ }))
146+])
85147
86148 const agentOptions = [
87149 { label: 'All', value: 'all' },
@@ -117,6 +179,51 @@ async function confirmRemove() {
117179 removing.value = false
118180 }
119181 }
182+
183+/* ------------------------------------------------ Selection & actions */
184+
185+const selection = ref<string[]>([])
186+watch(tab, () => { selection.value = [] })
187+const selectedItems = computed(() => (inventory.value?.items ?? []).filter(i => selection.value.includes(i.id)))
188+const importable = computed(() => selectedItems.value.filter(i => ['not-in-catalog', 'differs-from-catalog'].includes(statuses.value[i.id]?.state ?? '')))
189+const removable = computed(() => (props.kind === 'home' ? selectedItems.value.filter(i => ['skill', 'rule', 'agent'].includes(i.type)) : []))
190+
191+const importItems = ref<InventoryItem[]>([])
192+const importOpen = ref(false)
193+function askImport(items: InventoryItem[]) {
194+ importItems.value = items
195+ importOpen.value = true
196+}
197+const globalItems = ref<InventoryItem[]>([])
198+const globalOpen = ref(false)
199+function askRemoveGlobal(items: InventoryItem[]) {
200+ globalItems.value = items
201+ globalOpen.value = true
202+}
203+function openInCatalog(ref: CatalogRef) {
204+ navigateTo(`/catalog/${ref.type}/${encodeURIComponent(ref.name)}`)
205+}
206+async function afterCatalogChange() {
207+ selection.value = []
208+ await Promise.all([load(false), catalog.refresh()])
209+}
210+
211+/* ------------------------------------------------ Plan */
212+
213+const pending = computed(() => (plan.value?.actions ?? []).filter(a => a.kind !== 'unchanged'))
214+const conflictCount = computed(() => pending.value.filter(a => a.kind === 'conflict').length)
215+const applyOpen = ref(false)
216+function onApplied(next: ScopeState) {
217+ state.value = next
218+ config.value = next.config ? cloneConfig(next.config) : null
219+ selection.value = []
220+ if (props.kind === 'project') refreshProjects()
221+}
222+
223+watch(config, (c) => { if (!c && tab.value === 'config') tab.value = 'skill' })
224+
225+const homeReadOnly = computed(() => props.kind === 'home' && (tab.value === 'mcp' || tab.value === 'hook'))
226+const AGENT_LIST: AgentId[] = ['claude', 'codex']
120227 </script>
121228
122229 <template>
@@ -175,7 +282,7 @@ async function confirmRemove() {
175282 color="error"
176283 variant="subtle"
177284 icon="i-lucide-circle-alert"
178- title="Could not load the inventory"
285+ title="Could not load this scope"
179286 :description="error"
180287 :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }]"
181288 />
@@ -237,39 +344,128 @@ async function confirmRemove() {
237344 </template>
238345 </UAlert>
239346
240- <!-- Filters -->
241- <div class="flex flex-wrap items-center gap-3">
242- <UTabs
243- v-model="agentFilter"
244- :items="agentOptions"
245- :content="false"
246- size="sm"
247- variant="pill"
248- color="neutral"
249- class="w-auto"
250- />
251- <UInput
252- v-model="search"
253- icon="i-lucide-search"
254- placeholder="Search name, description, command..."
255- class="w-72"
256- >
257- <template v-if="search" #trailing>
258- <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" />
259- </template>
260- </UInput>
347+ <!-- Not managed yet -->
348+ <div v-if="!config" class="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-xl border border-dashed border-default p-4">
349+ <div class="min-w-0 flex-1 basis-72">
350+ <h2 class="flex items-center gap-2 font-semibold"><UIcon name="i-lucide-wand-sparkles" class="size-4 text-primary" />{{ kind === 'home' ? 'Manage Home with skilly' : 'Manage this project with skilly' }}</h2>
351+ <p class="text-sm text-muted">
352+ Enable presets and catalog items, switch skills to on demand and keep files in sync.
353+ {{ kind === 'home' ? 'Settings are stored in ~/.skilly/home.json.' : 'A skilly.json file is created at the project root; commit it with the project.' }}
354+ </p>
355+ </div>
356+ <div class="flex items-center gap-4">
357+ <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span>
358+ <UCheckbox v-for="a in AGENT_LIST" :key="a" :model-value="setupTargets.includes(a)" :label="AGENT_LABELS[a]" @update:model-value="(v) => toggleSetupTarget(a, v)" />
359+ </div>
360+ <UButton label="Start managing" icon="i-lucide-check" :loading="saving" :disabled="!setupTargets.length" @click="enableManagement" />
261361 </div>
262362
363+ <!-- Targets and presets -->
364+ <ScopeConfigBar
365+ v-else
366+ :config="config"
367+ :presets="catalog.presets.value"
368+ :catalog-exists="catalog.exists.value"
369+ :saving="saving"
370+ @update:config="saveConfig"
371+ />
372+
263373 <!-- Type tabs -->
264374 <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" />
265375
266- <ItemTable :type="tab" :items="byType[tab]" :filtered="!!search || agentFilter !== 'all'" @select="open" />
376+ <!-- Configuration tab -->
377+ <ScopeConfigPanel
378+ v-if="tab === 'config' && config"
379+ :config="config"
380+ :items="catalog.items.value"
381+ :presets="catalog.presets.value"
382+ :home="kind === 'home'"
383+ @update:config="saveConfig"
384+ />
385+
386+ <!-- Inventory tabs -->
387+ <template v-else>
388+ <div class="flex flex-wrap items-center gap-3">
389+ <UTabs
390+ v-model="agentFilter"
391+ :items="agentOptions"
392+ :content="false"
393+ size="sm"
394+ variant="pill"
395+ color="neutral"
396+ class="w-auto"
397+ />
398+ <UInput
399+ v-model="search"
400+ icon="i-lucide-search"
401+ placeholder="Search name, description, command..."
402+ class="w-72"
403+ >
404+ <template v-if="search" #trailing>
405+ <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" />
406+ </template>
407+ </UInput>
408+ </div>
409+
410+ <UAlert
411+ v-if="homeReadOnly"
412+ color="neutral"
413+ variant="subtle"
414+ icon="i-lucide-info"
415+ :title="`Home ${tab === 'mcp' ? 'MCP servers' : 'hooks'} are not managed in v1`"
416+ description="They are entries of your user configuration files, shown read-only. You can still add them to the catalog."
417+ />
418+
419+ <!-- Bulk actions -->
420+ <div v-if="selection.length" class="flex flex-wrap items-center gap-2 rounded-lg border border-primary/40 bg-primary/5 px-3 py-2 text-sm">
421+ <span class="font-medium">{{ selection.length }} selected</span>
422+ <UButton v-if="importable.length" :label="`Add ${importable.length} to catalog`" icon="i-lucide-library" size="xs" @click="askImport(importable)" />
423+ <UButton v-if="removable.length" :label="`Remove ${removable.length} from global`" icon="i-lucide-globe-lock" size="xs" color="error" variant="soft" @click="askRemoveGlobal(removable)" />
424+ <UButton label="Clear" size="xs" color="neutral" variant="ghost" class="ml-auto" @click="selection = []" />
425+ </div>
426+
427+ <ItemTable
428+ v-model:selection="selection"
429+ :type="tab as ItemType"
430+ :items="byType[tab as ItemType]"
431+ :filtered="!!search || agentFilter !== 'all'"
432+ :statuses="statuses"
433+ :home="kind === 'home'"
434+ actionable
435+ @select="open"
436+ @add-to-catalog="(i) => askImport([i])"
437+ @open-catalog="openInCatalog"
438+ @remove-global="(i) => askRemoveGlobal([i])"
439+ />
440+ </template>
441+
442+ <!-- Pending changes -->
443+ <div
444+ v-if="plan && (pending.length || plan.missing.length)"
445+ class="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 rounded-xl border bg-default/95 px-4 py-3 shadow-lg backdrop-blur"
446+ :class="conflictCount ? 'border-warning' : 'border-primary'"
447+ >
448+ <UIcon :name="conflictCount ? 'i-lucide-triangle-alert' : 'i-lucide-git-pull-request-arrow'" class="size-5 shrink-0" :class="conflictCount ? 'text-warning' : 'text-primary'" />
449+ <div class="min-w-0 flex-1 text-sm">
450+ <template v-if="pending.length">
451+ <span class="font-semibold">{{ pending.length }} change{{ pending.length > 1 ? 's' : '' }} to apply</span>
452+ <span v-if="conflictCount" class="text-warning"> - {{ conflictCount }} conflict{{ conflictCount > 1 ? 's' : '' }} to resolve</span>
453+ </template>
454+ <span v-else class="font-semibold">{{ plan.missing.length }} item(s) referenced but not found in the catalog</span>
455+ <span v-if="saving" class="ml-2 text-xs text-muted">saving...</span>
456+ </div>
457+ <UButton label="Review and apply" icon="i-lucide-list-checks" :disabled="saving || !pending.length" @click="applyOpen = true" />
458+ </div>
267459 </div>
268460 </template>
269461 </UDashboardPanel>
270462
271463 <ItemDetail v-model:open="detailOpen" :item="selected" />
272464
465+ <ApplyModal v-if="plan" v-model:open="applyOpen" :scope="scopeId" :plan="plan" :home="kind === 'home'" @applied="onApplied" />
466+ <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="afterCatalogChange" />
467+ <RemoveFromGlobalModal v-model:open="globalOpen" :items="globalItems" @done="afterCatalogChange" />
468+
273469 <UModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly.">
274470 <template #body>
275471 <p class="text-sm">
@@ -1,20 +1,29 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-import type { AgentId, InventoryItem, ItemType, ScopeInventory } from '@skilly/core'2+import type { AgentId, CatalogRef, InventoryItem, ItemType, ScopeConfig, ScopeState } from '@skilly/core'
3 3
4 const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>()4 const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>()
5 5
6 const { request } = useApi()6 const { request } = useApi()
7 const { projects, remove, refresh: refreshProjects } = useProjects()7 const { projects, remove, refresh: refreshProjects } = useProjects()
8+const catalog = useCatalog()
8 const toast = useToast()9 const toast = useToast()
9 const router = useRouter()10 const router = useRouter()
10 11
11-const inventory = ref<ScopeInventory | null>(null)12+const scopeId = computed(() => (props.kind === 'home' ? 'home' : props.projectId!))
13+const state = ref<ScopeState | null>(null)
14+const inventory = computed(() => state.value?.inventory ?? null)
15+const statuses = computed(() => state.value?.statuses ?? {})
16+const plan = computed(() => state.value?.plan ?? null)
12 const loading = ref(true)17 const loading = ref(true)
13 const error = ref<string | null>(null)18 const error = ref<string | null>(null)
14 19
20+// Local editable copy of the config: edits are shown at once and saved (debounced) in the background.
21+const config = ref<ScopeConfig | null>(null)
22+const saving = ref(false)
23+
15 const agentFilter = ref<'all' | AgentId>('all')24 const agentFilter = ref<'all' | AgentId>('all')
16 const search = ref('')25 const search = ref('')
17-const tab = ref<ItemType>('skill')26+const tab = ref<ItemType | 'config'>('skill')
18 const selected = ref<InventoryItem | null>(null)27 const selected = ref<InventoryItem | null>(null)
19 const detailOpen = ref(false)28 const detailOpen = ref(false)
20 const removeOpen = ref(false)29 const removeOpen = ref(false)
@@ -29,8 +38,8 @@ const folderMissing = computed(() => props.kind === 'project' && project.value &
29 async function load(showLoading = true) {38 async function load(showLoading = true) {
30 if (showLoading) loading.value = true39 if (showLoading) loading.value = true
31 try {40 try {
32- const url = props.kind === 'home' ? '/api/home' : `/api/projects/${encodeURIComponent(props.projectId!)}/inventory`41+ state.value = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/state`, { silent: true })
33- inventory.value = await request<ScopeInventory>(url, { silent: true })42+ config.value = state.value.config ? cloneConfig(state.value.config) : null
34 error.value = null43 error.value = null
35 } catch (e) {44 } catch (e) {
36 error.value = (e as Error).message45 error.value = (e as Error).message
@@ -46,7 +55,53 @@ async function rescan() {
46 if (!error.value) toast.add({ title: 'Rescan complete', icon: 'i-lucide-check', color: 'success' })55 if (!error.value) toast.add({ title: 'Rescan complete', icon: 'i-lucide-check', color: 'success' })
47 }56 }
48 57
49-onMounted(() => load())58+onMounted(async () => {
59+ if (!catalog.data.value) catalog.refresh()
60+ await load()
61+ if (config.value) tab.value = 'config'
62+})
63+
64+/* ------------------------------------------------ Config */
65+
66+let timer: ReturnType<typeof setTimeout> | undefined
67+let seq = 0
68+
69+function saveConfig(next: ScopeConfig, immediate = false) {
70+ config.value = next
71+ const mine = ++seq
72+ saving.value = true
73+ clearTimeout(timer)
74+ const run = async () => {
75+ try {
76+ const res = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/config`, { method: 'PUT', body: next, silent: true })
77+ state.value = res
78+ // Only adopt the server copy when no newer edit happened meanwhile.
79+ if (mine === seq) config.value = res.config ? cloneConfig(res.config) : next
80+ } catch (e) {
81+ toast.add({ title: 'Could not save the configuration', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
82+ if (mine === seq) await load(false)
83+ } finally {
84+ if (mine === seq) saving.value = false
85+ }
86+ }
87+ if (immediate) run()
88+ else timer = setTimeout(run, 400)
89+}
90+onBeforeUnmount(() => clearTimeout(timer))
91+
92+const setupTargets = ref<AgentId[]>([])
93+watch(inventory, (inv) => {
94+ if (!setupTargets.value.length) setupTargets.value = inv?.agentsDetected.length ? [...inv.agentsDetected] : ['claude', 'codex']
95+}, { immediate: true })
96+function toggleSetupTarget(a: AgentId, on: boolean | 'indeterminate') {
97+ setupTargets.value = on === true ? [...new Set([...setupTargets.value, a])] : setupTargets.value.filter(x => x !== a)
98+}
99+async function enableManagement() {
100+ saveConfig(emptyConfig(setupTargets.value), true)
101+ tab.value = 'config'
102+}
103+
104+/* ------------------------------------------------ Inventory */
50 105
51 const matches = (item: InventoryItem, q: string) => {106 const matches = (item: InventoryItem, q: string) => {
52 const d = item.details107 const d = item.details
@@ -76,12 +131,19 @@ const byType = computed(() => {
76 return m131 return m
77 })132 })
78 133
79-const tabs = computed(() => TYPE_ORDER.map(t => ({134+const enabledCount = computed(() => (config.value ? resolveItems(catalog.presets.value, config.value.presets, config.value.add).entries.length : 0))
80- label: TYPE_META[t].label,135+
81- icon: TYPE_META[t].icon,136+const tabs = computed(() => [
82- value: t,137+ ...(config.value
83- badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const }138+ ? [{ label: 'Configuration', icon: 'i-lucide-sliders-horizontal', value: 'config', badge: { label: String(enabledCount.value), color: 'neutral' as const, variant: 'subtle' as const } }]
84-})))139+ : []),
140+ ...TYPE_ORDER.map(t => ({
141+ label: TYPE_META[t].label,
142+ icon: TYPE_META[t].icon,
143+ value: t,
144+ badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const }
145+ }))
146+])
85 147
86 const agentOptions = [148 const agentOptions = [
87 { label: 'All', value: 'all' },149 { label: 'All', value: 'all' },
@@ -117,6 +179,51 @@ async function confirmRemove() {
117 removing.value = false179 removing.value = false
118 }180 }
119 }181 }
182+
183+/* ------------------------------------------------ Selection & actions */
184+
185+const selection = ref<string[]>([])
186+watch(tab, () => { selection.value = [] })
187+const selectedItems = computed(() => (inventory.value?.items ?? []).filter(i => selection.value.includes(i.id)))
188+const importable = computed(() => selectedItems.value.filter(i => ['not-in-catalog', 'differs-from-catalog'].includes(statuses.value[i.id]?.state ?? '')))
189+const removable = computed(() => (props.kind === 'home' ? selectedItems.value.filter(i => ['skill', 'rule', 'agent'].includes(i.type)) : []))
190+
191+const importItems = ref<InventoryItem[]>([])
192+const importOpen = ref(false)
193+function askImport(items: InventoryItem[]) {
194+ importItems.value = items
195+ importOpen.value = true
196+}
197+const globalItems = ref<InventoryItem[]>([])
198+const globalOpen = ref(false)
199+function askRemoveGlobal(items: InventoryItem[]) {
200+ globalItems.value = items
201+ globalOpen.value = true
202+}
203+function openInCatalog(ref: CatalogRef) {
204+ navigateTo(`/catalog/${ref.type}/${encodeURIComponent(ref.name)}`)
205+}
206+async function afterCatalogChange() {
207+ selection.value = []
208+ await Promise.all([load(false), catalog.refresh()])
209+}
210+
211+/* ------------------------------------------------ Plan */
212+
213+const pending = computed(() => (plan.value?.actions ?? []).filter(a => a.kind !== 'unchanged'))
214+const conflictCount = computed(() => pending.value.filter(a => a.kind === 'conflict').length)
215+const applyOpen = ref(false)
216+function onApplied(next: ScopeState) {
217+ state.value = next
218+ config.value = next.config ? cloneConfig(next.config) : null
219+ selection.value = []
220+ if (props.kind === 'project') refreshProjects()
221+}
222+
223+watch(config, (c) => { if (!c && tab.value === 'config') tab.value = 'skill' })
224+
225+const homeReadOnly = computed(() => props.kind === 'home' && (tab.value === 'mcp' || tab.value === 'hook'))
226+const AGENT_LIST: AgentId[] = ['claude', 'codex']
120 </script>227 </script>
121 228
122 <template>229 <template>
@@ -175,7 +282,7 @@ async function confirmRemove() {
175 color="error"282 color="error"
176 variant="subtle"283 variant="subtle"
177 icon="i-lucide-circle-alert"284 icon="i-lucide-circle-alert"
178- title="Could not load the inventory"285+ title="Could not load this scope"
179 :description="error"286 :description="error"
180 :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }]"287 :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }]"
181 />288 />
@@ -237,39 +344,128 @@ async function confirmRemove() {
237 </template>344 </template>
238 </UAlert>345 </UAlert>
239 346
240- <!-- Filters -->347+ <!-- Not managed yet -->
241- <div class="flex flex-wrap items-center gap-3">348+ <div v-if="!config" class="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-xl border border-dashed border-default p-4">
242- <UTabs349+ <div class="min-w-0 flex-1 basis-72">
243- v-model="agentFilter"350+ <h2 class="flex items-center gap-2 font-semibold"><UIcon name="i-lucide-wand-sparkles" class="size-4 text-primary" />{{ kind === 'home' ? 'Manage Home with skilly' : 'Manage this project with skilly' }}</h2>
244- :items="agentOptions"351+ <p class="text-sm text-muted">
245- :content="false"352+ Enable presets and catalog items, switch skills to on demand and keep files in sync.
246- size="sm"353+ {{ kind === 'home' ? 'Settings are stored in ~/.skilly/home.json.' : 'A skilly.json file is created at the project root; commit it with the project.' }}
247- variant="pill"354+ </p>
248- color="neutral"355+ </div>
249- class="w-auto"356+ <div class="flex items-center gap-4">
250- />357+ <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span>
251- <UInput358+ <UCheckbox v-for="a in AGENT_LIST" :key="a" :model-value="setupTargets.includes(a)" :label="AGENT_LABELS[a]" @update:model-value="(v) => toggleSetupTarget(a, v)" />
252- v-model="search"359+ </div>
253- icon="i-lucide-search"360+ <UButton label="Start managing" icon="i-lucide-check" :loading="saving" :disabled="!setupTargets.length" @click="enableManagement" />
254- placeholder="Search name, description, command..."
255- class="w-72"
256- >
257- <template v-if="search" #trailing>
258- <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" />
259- </template>
260- </UInput>
261 </div>361 </div>
262 362
363+ <!-- Targets and presets -->
364+ <ScopeConfigBar
365+ v-else
366+ :config="config"
367+ :presets="catalog.presets.value"
368+ :catalog-exists="catalog.exists.value"
369+ :saving="saving"
370+ @update:config="saveConfig"
371+ />
372+
263 <!-- Type tabs -->373 <!-- Type tabs -->
264 <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" />374 <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" />
265 375
266- <ItemTable :type="tab" :items="byType[tab]" :filtered="!!search || agentFilter !== 'all'" @select="open" />376+ <!-- Configuration tab -->
377+ <ScopeConfigPanel
378+ v-if="tab === 'config' && config"
379+ :config="config"
380+ :items="catalog.items.value"
381+ :presets="catalog.presets.value"
382+ :home="kind === 'home'"
383+ @update:config="saveConfig"
384+ />
385+
386+ <!-- Inventory tabs -->
387+ <template v-else>
388+ <div class="flex flex-wrap items-center gap-3">
389+ <UTabs
390+ v-model="agentFilter"
391+ :items="agentOptions"
392+ :content="false"
393+ size="sm"
394+ variant="pill"
395+ color="neutral"
396+ class="w-auto"
397+ />
398+ <UInput
399+ v-model="search"
400+ icon="i-lucide-search"
401+ placeholder="Search name, description, command..."
402+ class="w-72"
403+ >
404+ <template v-if="search" #trailing>
405+ <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" />
406+ </template>
407+ </UInput>
408+ </div>
409+
410+ <UAlert
411+ v-if="homeReadOnly"
412+ color="neutral"
413+ variant="subtle"
414+ icon="i-lucide-info"
415+ :title="`Home ${tab === 'mcp' ? 'MCP servers' : 'hooks'} are not managed in v1`"
416+ description="They are entries of your user configuration files, shown read-only. You can still add them to the catalog."
417+ />
418+
419+ <!-- Bulk actions -->
420+ <div v-if="selection.length" class="flex flex-wrap items-center gap-2 rounded-lg border border-primary/40 bg-primary/5 px-3 py-2 text-sm">
421+ <span class="font-medium">{{ selection.length }} selected</span>
422+ <UButton v-if="importable.length" :label="`Add ${importable.length} to catalog`" icon="i-lucide-library" size="xs" @click="askImport(importable)" />
423+ <UButton v-if="removable.length" :label="`Remove ${removable.length} from global`" icon="i-lucide-globe-lock" size="xs" color="error" variant="soft" @click="askRemoveGlobal(removable)" />
424+ <UButton label="Clear" size="xs" color="neutral" variant="ghost" class="ml-auto" @click="selection = []" />
425+ </div>
426+
427+ <ItemTable
428+ v-model:selection="selection"
429+ :type="tab as ItemType"
430+ :items="byType[tab as ItemType]"
431+ :filtered="!!search || agentFilter !== 'all'"
432+ :statuses="statuses"
433+ :home="kind === 'home'"
434+ actionable
435+ @select="open"
436+ @add-to-catalog="(i) => askImport([i])"
437+ @open-catalog="openInCatalog"
438+ @remove-global="(i) => askRemoveGlobal([i])"
439+ />
440+ </template>
441+
442+ <!-- Pending changes -->
443+ <div
444+ v-if="plan && (pending.length || plan.missing.length)"
445+ class="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 rounded-xl border bg-default/95 px-4 py-3 shadow-lg backdrop-blur"
446+ :class="conflictCount ? 'border-warning' : 'border-primary'"
447+ >
448+ <UIcon :name="conflictCount ? 'i-lucide-triangle-alert' : 'i-lucide-git-pull-request-arrow'" class="size-5 shrink-0" :class="conflictCount ? 'text-warning' : 'text-primary'" />
449+ <div class="min-w-0 flex-1 text-sm">
450+ <template v-if="pending.length">
451+ <span class="font-semibold">{{ pending.length }} change{{ pending.length > 1 ? 's' : '' }} to apply</span>
452+ <span v-if="conflictCount" class="text-warning"> - {{ conflictCount }} conflict{{ conflictCount > 1 ? 's' : '' }} to resolve</span>
453+ </template>
454+ <span v-else class="font-semibold">{{ plan.missing.length }} item(s) referenced but not found in the catalog</span>
455+ <span v-if="saving" class="ml-2 text-xs text-muted">saving...</span>
456+ </div>
457+ <UButton label="Review and apply" icon="i-lucide-list-checks" :disabled="saving || !pending.length" @click="applyOpen = true" />
458+ </div>
267 </div>459 </div>
268 </template>460 </template>
269 </UDashboardPanel>461 </UDashboardPanel>
270 462
271 <ItemDetail v-model:open="detailOpen" :item="selected" />463 <ItemDetail v-model:open="detailOpen" :item="selected" />
272 464
465+ <ApplyModal v-if="plan" v-model:open="applyOpen" :scope="scopeId" :plan="plan" :home="kind === 'home'" @applied="onApplied" />
466+ <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="afterCatalogChange" />
467+ <RemoveFromGlobalModal v-model:open="globalOpen" :items="globalItems" @done="afterCatalogChange" />
468+
273 <UModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly.">469 <UModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly.">
274 <template #body>470 <template #body>
275 <p class="text-sm">471 <p class="text-sm">
added packages/ui/app/components/StatusBadge.vue +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+<script setup lang="ts">
2+import type { ItemStatus } from '@skilly/core'
3+
4+defineProps<{ status?: ItemStatus }>()
5+</script>
6+
7+<template>
8+ <div v-if="status" class="flex flex-wrap gap-1">
9+ <UTooltip :text="STATUS_META[status.state].hint">
10+ <UBadge :label="STATUS_META[status.state].label" :color="STATUS_META[status.state].color" :icon="STATUS_META[status.state].icon" variant="subtle" size="sm" />
11+ </UTooltip>
12+ <UTooltip v-if="status.managed" text="Written by skilly: it keeps this file up to date.">
13+ <UBadge label="managed" icon="i-lucide-link" color="neutral" variant="outline" size="sm" />
14+ </UTooltip>
15+ </div>
16+ <span v-else class="text-xs text-dimmed">-</span>
17+</template>
new file mode 100644
@@ -0,0 +1,17 @@
1+<script setup lang="ts">
2+import type { ItemStatus } from '@skilly/core'
3+
4+defineProps<{ status?: ItemStatus }>()
5+</script>
6+
7+<template>
8+ <div v-if="status" class="flex flex-wrap gap-1">
9+ <UTooltip :text="STATUS_META[status.state].hint">
10+ <UBadge :label="STATUS_META[status.state].label" :color="STATUS_META[status.state].color" :icon="STATUS_META[status.state].icon" variant="subtle" size="sm" />
11+ </UTooltip>
12+ <UTooltip v-if="status.managed" text="Written by skilly: it keeps this file up to date.">
13+ <UBadge label="managed" icon="i-lucide-link" color="neutral" variant="outline" size="sm" />
14+ </UTooltip>
15+ </div>
16+ <span v-else class="text-xs text-dimmed">-</span>
17+</template>
modified packages/ui/app/composables/useApi.ts +4 -4
@@ -9,9 +9,9 @@ export class ApiRequestError extends Error {
99 }
1010
1111 export interface ApiOptions {
12- method?: 'GET' | 'POST' | 'DELETE'
13- body?: Record<string, unknown>
14- query?: Record<string, string | undefined>
12+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
13+ body?: unknown
14+ query?: Record<string, string | number | boolean | undefined>
1515 /** Do not show an error toast (the caller handles the error itself). */
1616 silent?: boolean
1717 }
@@ -23,7 +23,7 @@ export function useApi() {
2323 try {
2424 return await $fetch<T>(path, {
2525 method: opts.method ?? 'GET',
26- body: opts.body,
26+ body: opts.body as Record<string, unknown> | undefined,
2727 query: opts.query,
2828 headers: { 'x-skilly-token': getToken() }
2929 })
@@ -9,9 +9,9 @@ export class ApiRequestError extends Error {
9 }9 }
10 10
11 export interface ApiOptions {11 export interface ApiOptions {
12- method?: 'GET' | 'POST' | 'DELETE'12+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
13- body?: Record<string, unknown>13+ body?: unknown
14- query?: Record<string, string | undefined>14+ query?: Record<string, string | number | boolean | undefined>
15 /** Do not show an error toast (the caller handles the error itself). */15 /** Do not show an error toast (the caller handles the error itself). */
16 silent?: boolean16 silent?: boolean
17 }17 }
@@ -23,7 +23,7 @@ export function useApi() {
23 try {23 try {
24 return await $fetch<T>(path, {24 return await $fetch<T>(path, {
25 method: opts.method ?? 'GET',25 method: opts.method ?? 'GET',
26- body: opts.body,26+ body: opts.body as Record<string, unknown> | undefined,
27 query: opts.query,27 query: opts.query,
28 headers: { 'x-skilly-token': getToken() }28 headers: { 'x-skilly-token': getToken() }
29 })29 })
added packages/ui/app/composables/useCatalog.ts +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+import type { CatalogItem, CatalogResponse, GitStatus, Preset } from '@skilly/core'
2+
3+/** Shared catalog snapshot (items, presets, usage, git status). */
4+export function useCatalog() {
5+ const { request } = useApi()
6+ const data = useState<CatalogResponse | null>('skilly-catalog', () => null)
7+ const loading = useState('skilly-catalog-loading', () => false)
8+ const error = useState<string | null>('skilly-catalog-error', () => null)
9+
10+ async function refresh() {
11+ loading.value = true
12+ try {
13+ data.value = await request<CatalogResponse>('/api/catalog', { silent: true })
14+ error.value = null
15+ } catch (e) {
16+ error.value = (e as Error).message
17+ } finally {
18+ loading.value = false
19+ }
20+ }
21+
22+ async function init() {
23+ data.value = await request<CatalogResponse>('/api/catalog/init', { method: 'POST' })
24+ error.value = null
25+ }
26+
27+ const exists = computed(() => !!data.value?.snapshot.exists)
28+ const items = computed<CatalogItem[]>(() => data.value?.snapshot.items ?? [])
29+ const presets = computed<Preset[]>(() => data.value?.snapshot.presets ?? [])
30+ const git = computed<GitStatus | null>(() => data.value?.git ?? null)
31+ const usage = computed(() => data.value?.usage ?? {})
32+
33+ const usedBy = (type: string, name: string) => usage.value[itemKey(type, name)] ?? []
34+ const presetUsedBy = (name: string) => usage.value[`preset/${name}`] ?? []
35+ const findItem = (type: string, name: string) => items.value.find(i => i.type === type && i.name === name)
36+
37+ function setGit(g: GitStatus) {
38+ if (data.value) data.value = { ...data.value, git: g }
39+ }
40+
41+ return { data, loading, error, refresh, init, exists, items, presets, git, usage, usedBy, presetUsedBy, findItem, setGit }
42+}
43+
44+/** Project id -> name, for rendering the scopes listed in `usage`. */
45+export function useScopeNames() {
46+ const { projects } = useProjects()
47+ return computed(() => Object.fromEntries(projects.value.map(p => [p.id, p.name])) as Record<string, string>)
48+}
new file mode 100644
@@ -0,0 +1,48 @@
1+import type { CatalogItem, CatalogResponse, GitStatus, Preset } from '@skilly/core'
2+
3+/** Shared catalog snapshot (items, presets, usage, git status). */
4+export function useCatalog() {
5+ const { request } = useApi()
6+ const data = useState<CatalogResponse | null>('skilly-catalog', () => null)
7+ const loading = useState('skilly-catalog-loading', () => false)
8+ const error = useState<string | null>('skilly-catalog-error', () => null)
9+
10+ async function refresh() {
11+ loading.value = true
12+ try {
13+ data.value = await request<CatalogResponse>('/api/catalog', { silent: true })
14+ error.value = null
15+ } catch (e) {
16+ error.value = (e as Error).message
17+ } finally {
18+ loading.value = false
19+ }
20+ }
21+
22+ async function init() {
23+ data.value = await request<CatalogResponse>('/api/catalog/init', { method: 'POST' })
24+ error.value = null
25+ }
26+
27+ const exists = computed(() => !!data.value?.snapshot.exists)
28+ const items = computed<CatalogItem[]>(() => data.value?.snapshot.items ?? [])
29+ const presets = computed<Preset[]>(() => data.value?.snapshot.presets ?? [])
30+ const git = computed<GitStatus | null>(() => data.value?.git ?? null)
31+ const usage = computed(() => data.value?.usage ?? {})
32+
33+ const usedBy = (type: string, name: string) => usage.value[itemKey(type, name)] ?? []
34+ const presetUsedBy = (name: string) => usage.value[`preset/${name}`] ?? []
35+ const findItem = (type: string, name: string) => items.value.find(i => i.type === type && i.name === name)
36+
37+ function setGit(g: GitStatus) {
38+ if (data.value) data.value = { ...data.value, git: g }
39+ }
40+
41+ return { data, loading, error, refresh, init, exists, items, presets, git, usage, usedBy, presetUsedBy, findItem, setGit }
42+}
43+
44+/** Project id -> name, for rendering the scopes listed in `usage`. */
45+export function useScopeNames() {
46+ const { projects } = useProjects()
47+ return computed(() => Object.fromEntries(projects.value.map(p => [p.id, p.name])) as Record<string, string>)
48+}
added packages/ui/app/composables/useCatalogGit.ts +34 -0
new file mode 100644
@@ -0,0 +1,34 @@
1+import type { GitStatus } from '@skilly/core'
2+
3+export type GitAction = 'remote' | 'commit' | 'pull' | 'push' | 'sync' | 'resolve'
4+
5+/** Catalog git actions shared by the sidebar indicator and the git dialog. */
6+export function useCatalogGit() {
7+ const { request } = useApi()
8+ const { setGit, refresh } = useCatalog()
9+ const toast = useToast()
10+ const open = useState('skilly-git-open', () => false)
11+ const busy = useState<GitAction | null>('skilly-git-busy', () => null)
12+ const error = useState<string | null>('skilly-git-error', () => null)
13+
14+ async function run(action: GitAction, body?: unknown, successTitle?: string): Promise<boolean> {
15+ busy.value = action
16+ error.value = null
17+ try {
18+ const status = await request<GitStatus>(`/api/catalog/git/${action}`, { method: 'POST', body, silent: true })
19+ setGit(status)
20+ if (successTitle) toast.add({ title: successTitle, icon: 'i-lucide-check', color: 'success' })
21+ // Pull / sync / resolve can change the catalog content.
22+ if (action !== 'remote' && action !== 'commit' && action !== 'push') await refresh()
23+ return true
24+ } catch (e) {
25+ error.value = (e as Error).message
26+ toast.add({ title: `Git ${action} failed`, description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
27+ return false
28+ } finally {
29+ busy.value = null
30+ }
31+ }
32+
33+ return { open, busy, error, run }
34+}
new file mode 100644
@@ -0,0 +1,34 @@
1+import type { GitStatus } from '@skilly/core'
2+
3+export type GitAction = 'remote' | 'commit' | 'pull' | 'push' | 'sync' | 'resolve'
4+
5+/** Catalog git actions shared by the sidebar indicator and the git dialog. */
6+export function useCatalogGit() {
7+ const { request } = useApi()
8+ const { setGit, refresh } = useCatalog()
9+ const toast = useToast()
10+ const open = useState('skilly-git-open', () => false)
11+ const busy = useState<GitAction | null>('skilly-git-busy', () => null)
12+ const error = useState<string | null>('skilly-git-error', () => null)
13+
14+ async function run(action: GitAction, body?: unknown, successTitle?: string): Promise<boolean> {
15+ busy.value = action
16+ error.value = null
17+ try {
18+ const status = await request<GitStatus>(`/api/catalog/git/${action}`, { method: 'POST', body, silent: true })
19+ setGit(status)
20+ if (successTitle) toast.add({ title: successTitle, icon: 'i-lucide-check', color: 'success' })
21+ // Pull / sync / resolve can change the catalog content.
22+ if (action !== 'remote' && action !== 'commit' && action !== 'push') await refresh()
23+ return true
24+ } catch (e) {
25+ error.value = (e as Error).message
26+ toast.add({ title: `Git ${action} failed`, description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
27+ return false
28+ } finally {
29+ busy.value = null
30+ }
31+ }
32+
33+ return { open, busy, error, run }
34+}
modified packages/ui/app/layouts/default.vue +7 -2
@@ -1,8 +1,12 @@
11 <script setup lang="ts">
22 const { refresh } = useProjects()
3+const { refresh: refreshCatalog } = useCatalog()
34 const addOpen = useState('skilly-add-open', () => false)
45
5-onMounted(refresh)
6+onMounted(() => {
7+ refresh()
8+ refreshCatalog()
9+})
610 </script>
711
812 <template>
@@ -22,7 +26,7 @@ onMounted(refresh)
2226
2327 <template #footer>
2428 <div class="flex w-full items-center justify-between">
25- <span class="text-xs text-muted">Read-only inventory</span>
29+ <span class="text-xs text-muted">skilly</span>
2630 <UColorModeButton />
2731 </div>
2832 </template>
@@ -31,5 +35,6 @@ onMounted(refresh)
3135 <slot />
3236
3337 <AddProjectModal v-model:open="addOpen" />
38+ <CatalogGitDialog />
3439 </UDashboardGroup>
3540 </template>
@@ -1,8 +1,12 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 const { refresh } = useProjects()2 const { refresh } = useProjects()
3+const { refresh: refreshCatalog } = useCatalog()
3 const addOpen = useState('skilly-add-open', () => false)4 const addOpen = useState('skilly-add-open', () => false)
4 5
5-onMounted(refresh)6+onMounted(() => {
7+ refresh()
8+ refreshCatalog()
9+})
6 </script>10 </script>
7 11
8 <template>12 <template>
@@ -22,7 +26,7 @@ onMounted(refresh)
22 26
23 <template #footer>27 <template #footer>
24 <div class="flex w-full items-center justify-between">28 <div class="flex w-full items-center justify-between">
25- <span class="text-xs text-muted">Read-only inventory</span>29+ <span class="text-xs text-muted">skilly</span>
26 <UColorModeButton />30 <UColorModeButton />
27 </div>31 </div>
28 </template>32 </template>
@@ -31,5 +35,6 @@ onMounted(refresh)
31 <slot />35 <slot />
32 36
33 <AddProjectModal v-model:open="addOpen" />37 <AddProjectModal v-model:open="addOpen" />
38+ <CatalogGitDialog />
34 </UDashboardGroup>39 </UDashboardGroup>
35 </template>40 </template>
added packages/ui/app/pages/catalog/[type]/[name].vue +11 -0
new file mode 100644
@@ -0,0 +1,11 @@
1+<script setup lang="ts">
2+import type { CatalogItemType } from '@skilly/core'
3+
4+const route = useRoute()
5+const type = computed(() => String(route.params.type) as CatalogItemType)
6+const name = computed(() => String(route.params.name))
7+</script>
8+
9+<template>
10+ <CatalogItemEditor :key="`${type}/${name}`" :type="type" :name="name" />
11+</template>
new file mode 100644
@@ -0,0 +1,11 @@
1+<script setup lang="ts">
2+import type { CatalogItemType } from '@skilly/core'
3+
4+const route = useRoute()
5+const type = computed(() => String(route.params.type) as CatalogItemType)
6+const name = computed(() => String(route.params.name))
7+</script>
8+
9+<template>
10+ <CatalogItemEditor :key="`${type}/${name}`" :type="type" :name="name" />
11+</template>
added packages/ui/app/pages/catalog/index.vue +147 -0
new file mode 100644
@@ -0,0 +1,147 @@
1+<script setup lang="ts">
2+import type { CatalogItem, CatalogItemType } from '@skilly/core'
3+
4+const { data, loading, error, refresh, exists, items, usage, git } = useCatalog()
5+const { open: gitOpen } = useCatalogGit()
6+const names = useScopeNames()
7+const route = useRoute()
8+
9+const tab = ref<CatalogItemType>(CAT_TYPES.includes(route.query.type as CatalogItemType) ? route.query.type as CatalogItemType : 'skill')
10+const search = ref('')
11+const tagFilter = ref<string[]>([])
12+
13+onMounted(refresh)
14+
15+const byType = computed(() => {
16+ const m = Object.fromEntries(CAT_TYPES.map(t => [t, [] as CatalogItem[]])) as Record<CatalogItemType, CatalogItem[]>
17+ for (const i of items.value) m[i.type]?.push(i)
18+ return m
19+})
20+
21+const allTags = computed(() => [...new Set(byType.value[tab.value].flatMap(i => i.tags))].sort())
22+
23+const rows = computed(() => {
24+ const q = search.value.trim().toLowerCase()
25+ return byType.value[tab.value]
26+ .filter(i => (!q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q))
27+ && tagFilter.value.every(t => i.tags.includes(t)))
28+ .sort((a, b) => a.name.localeCompare(b.name))
29+})
30+
31+const tabs = computed(() => CAT_TYPES.map(t => ({
32+ label: CAT_META[t].label,
33+ icon: CAT_META[t].icon,
34+ value: t,
35+ badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const }
36+})))
37+
38+watch(tab, () => { tagFilter.value = [] })
39+
40+const ORIGIN = {
41+ created: { label: 'created', icon: 'i-lucide-pencil-line' },
42+ import: { label: 'imported', icon: 'i-lucide-download' },
43+ marketplace: { label: 'marketplace', icon: 'i-lucide-store' }
44+} as const
45+
46+const usedBy = (i: CatalogItem) => usage.value[itemKey(i.type, i.name)] ?? []
47+const go = (i: CatalogItem) => navigateTo(`/catalog/${i.type}/${encodeURIComponent(i.name)}`)
48+const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'
49+</script>
50+
51+<template>
52+ <UDashboardPanel id="catalog">
53+ <template #header>
54+ <UDashboardNavbar title="Catalog" icon="i-lucide-library">
55+ <template #right>
56+ <template v-if="exists">
57+ <UButton v-if="git?.isRepo" label="Git" icon="i-lucide-git-branch" color="neutral" variant="outline" @click="gitOpen = true" />
58+ <UButton :label="`New ${CAT_META[tab].singular.toLowerCase()}`" icon="i-lucide-plus" :to="`/catalog/new?type=${tab}`" />
59+ </template>
60+ </template>
61+ </UDashboardNavbar>
62+ </template>
63+
64+ <template #body>
65+ <div v-if="loading && !data" class="space-y-3">
66+ <USkeleton class="h-10 w-full" />
67+ <USkeleton v-for="i in 5" :key="i" class="h-12 w-full" />
68+ </div>
69+ <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refresh }]" />
70+ <CatalogSetupCard v-else-if="data && !exists" />
71+
72+ <div v-else-if="data" class="flex flex-col gap-4">
73+ <UAlert
74+ v-if="data.snapshot.errors.length"
75+ color="warning"
76+ variant="subtle"
77+ icon="i-lucide-triangle-alert"
78+ :title="`${data.snapshot.errors.length} item(s) could not be read`"
79+ >
80+ <template #description>
81+ <ul class="list-disc pl-4 text-xs">
82+ <li v-for="e in data.snapshot.errors" :key="e.path"><span class="font-mono">{{ e.path }}</span>: {{ e.message }}</li>
83+ </ul>
84+ </template>
85+ </UAlert>
86+
87+ <div class="flex flex-wrap items-center gap-3">
88+ <UInput v-model="search" icon="i-lucide-search" placeholder="Search name, description, tag..." class="w-72">
89+ <template v-if="search" #trailing>
90+ <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" />
91+ </template>
92+ </UInput>
93+ <USelectMenu v-if="allTags.length" v-model="tagFilter" :items="allTags" multiple placeholder="Filter by tag" icon="i-lucide-tag" class="w-52" />
94+ <UButton v-if="tagFilter.length" label="Clear tags" size="xs" color="neutral" variant="link" @click="tagFilter = []" />
95+ </div>
96+
97+ <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" />
98+
99+ <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center">
100+ <UIcon :name="search || tagFilter.length ? 'i-lucide-search-x' : CAT_META[tab].icon" class="size-8 text-dimmed" />
101+ <p class="text-sm text-muted">
102+ {{ search || tagFilter.length ? 'No result matches the current filters.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog yet.` }}
103+ </p>
104+ <p v-if="!search && !tagFilter.length" class="text-xs text-muted">Create one, add items from a project with "Add to catalog", or import from Marketplaces.</p>
105+ </div>
106+
107+ <div v-else class="overflow-x-auto rounded-xl border border-default">
108+ <table class="w-full min-w-[720px] table-fixed text-sm">
109+ <thead class="border-b border-default bg-elevated/40">
110+ <tr>
111+ <th :class="TH" class="w-[34%]">Name</th>
112+ <th :class="TH">Tags</th>
113+ <th :class="TH" class="w-32">Origin</th>
114+ <th :class="TH" class="w-28">Used by</th>
115+ </tr>
116+ </thead>
117+ <tbody class="divide-y divide-default">
118+ <tr v-for="i in rows" :key="i.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="go(i)" @keydown.enter="go(i)">
119+ <td class="px-3 py-2 align-middle">
120+ <span class="block truncate font-medium text-highlighted">{{ i.name }}</span>
121+ <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p>
122+ </td>
123+ <td class="px-3 py-2 align-middle">
124+ <div class="flex flex-wrap gap-1">
125+ <UBadge v-for="t in i.tags" :key="t" :label="t" color="neutral" variant="outline" size="sm" />
126+ <span v-if="!i.tags.length" class="text-xs text-dimmed">-</span>
127+ </div>
128+ </td>
129+ <td class="px-3 py-2 align-middle">
130+ <UTooltip :text="i.origin?.source ? `Source: ${i.origin.source}` : 'Created in skilly'">
131+ <UBadge :label="ORIGIN[i.origin?.kind ?? 'created'].label" :icon="ORIGIN[i.origin?.kind ?? 'created'].icon" color="neutral" variant="subtle" size="sm" />
132+ </UTooltip>
133+ </td>
134+ <td class="px-3 py-2 align-middle">
135+ <UTooltip v-if="usedBy(i).length" :text="usedBy(i).map(s => scopeLabel(s, names)).join(', ')">
136+ <span class="text-xs tabular-nums">{{ usedBy(i).length }} scope{{ usedBy(i).length > 1 ? 's' : '' }}</span>
137+ </UTooltip>
138+ <span v-else class="text-xs text-dimmed">unused</span>
139+ </td>
140+ </tr>
141+ </tbody>
142+ </table>
143+ </div>
144+ </div>
145+ </template>
146+ </UDashboardPanel>
147+</template>
new file mode 100644
@@ -0,0 +1,147 @@
1+<script setup lang="ts">
2+import type { CatalogItem, CatalogItemType } from '@skilly/core'
3+
4+const { data, loading, error, refresh, exists, items, usage, git } = useCatalog()
5+const { open: gitOpen } = useCatalogGit()
6+const names = useScopeNames()
7+const route = useRoute()
8+
9+const tab = ref<CatalogItemType>(CAT_TYPES.includes(route.query.type as CatalogItemType) ? route.query.type as CatalogItemType : 'skill')
10+const search = ref('')
11+const tagFilter = ref<string[]>([])
12+
13+onMounted(refresh)
14+
15+const byType = computed(() => {
16+ const m = Object.fromEntries(CAT_TYPES.map(t => [t, [] as CatalogItem[]])) as Record<CatalogItemType, CatalogItem[]>
17+ for (const i of items.value) m[i.type]?.push(i)
18+ return m
19+})
20+
21+const allTags = computed(() => [...new Set(byType.value[tab.value].flatMap(i => i.tags))].sort())
22+
23+const rows = computed(() => {
24+ const q = search.value.trim().toLowerCase()
25+ return byType.value[tab.value]
26+ .filter(i => (!q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q))
27+ && tagFilter.value.every(t => i.tags.includes(t)))
28+ .sort((a, b) => a.name.localeCompare(b.name))
29+})
30+
31+const tabs = computed(() => CAT_TYPES.map(t => ({
32+ label: CAT_META[t].label,
33+ icon: CAT_META[t].icon,
34+ value: t,
35+ badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const }
36+})))
37+
38+watch(tab, () => { tagFilter.value = [] })
39+
40+const ORIGIN = {
41+ created: { label: 'created', icon: 'i-lucide-pencil-line' },
42+ import: { label: 'imported', icon: 'i-lucide-download' },
43+ marketplace: { label: 'marketplace', icon: 'i-lucide-store' }
44+} as const
45+
46+const usedBy = (i: CatalogItem) => usage.value[itemKey(i.type, i.name)] ?? []
47+const go = (i: CatalogItem) => navigateTo(`/catalog/${i.type}/${encodeURIComponent(i.name)}`)
48+const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'
49+</script>
50+
51+<template>
52+ <UDashboardPanel id="catalog">
53+ <template #header>
54+ <UDashboardNavbar title="Catalog" icon="i-lucide-library">
55+ <template #right>
56+ <template v-if="exists">
57+ <UButton v-if="git?.isRepo" label="Git" icon="i-lucide-git-branch" color="neutral" variant="outline" @click="gitOpen = true" />
58+ <UButton :label="`New ${CAT_META[tab].singular.toLowerCase()}`" icon="i-lucide-plus" :to="`/catalog/new?type=${tab}`" />
59+ </template>
60+ </template>
61+ </UDashboardNavbar>
62+ </template>
63+
64+ <template #body>
65+ <div v-if="loading && !data" class="space-y-3">
66+ <USkeleton class="h-10 w-full" />
67+ <USkeleton v-for="i in 5" :key="i" class="h-12 w-full" />
68+ </div>
69+ <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refresh }]" />
70+ <CatalogSetupCard v-else-if="data && !exists" />
71+
72+ <div v-else-if="data" class="flex flex-col gap-4">
73+ <UAlert
74+ v-if="data.snapshot.errors.length"
75+ color="warning"
76+ variant="subtle"
77+ icon="i-lucide-triangle-alert"
78+ :title="`${data.snapshot.errors.length} item(s) could not be read`"
79+ >
80+ <template #description>
81+ <ul class="list-disc pl-4 text-xs">
82+ <li v-for="e in data.snapshot.errors" :key="e.path"><span class="font-mono">{{ e.path }}</span>: {{ e.message }}</li>
83+ </ul>
84+ </template>
85+ </UAlert>
86+
87+ <div class="flex flex-wrap items-center gap-3">
88+ <UInput v-model="search" icon="i-lucide-search" placeholder="Search name, description, tag..." class="w-72">
89+ <template v-if="search" #trailing>
90+ <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" />
91+ </template>
92+ </UInput>
93+ <USelectMenu v-if="allTags.length" v-model="tagFilter" :items="allTags" multiple placeholder="Filter by tag" icon="i-lucide-tag" class="w-52" />
94+ <UButton v-if="tagFilter.length" label="Clear tags" size="xs" color="neutral" variant="link" @click="tagFilter = []" />
95+ </div>
96+
97+ <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" />
98+
99+ <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center">
100+ <UIcon :name="search || tagFilter.length ? 'i-lucide-search-x' : CAT_META[tab].icon" class="size-8 text-dimmed" />
101+ <p class="text-sm text-muted">
102+ {{ search || tagFilter.length ? 'No result matches the current filters.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog yet.` }}
103+ </p>
104+ <p v-if="!search && !tagFilter.length" class="text-xs text-muted">Create one, add items from a project with "Add to catalog", or import from Marketplaces.</p>
105+ </div>
106+
107+ <div v-else class="overflow-x-auto rounded-xl border border-default">
108+ <table class="w-full min-w-[720px] table-fixed text-sm">
109+ <thead class="border-b border-default bg-elevated/40">
110+ <tr>
111+ <th :class="TH" class="w-[34%]">Name</th>
112+ <th :class="TH">Tags</th>
113+ <th :class="TH" class="w-32">Origin</th>
114+ <th :class="TH" class="w-28">Used by</th>
115+ </tr>
116+ </thead>
117+ <tbody class="divide-y divide-default">
118+ <tr v-for="i in rows" :key="i.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="go(i)" @keydown.enter="go(i)">
119+ <td class="px-3 py-2 align-middle">
120+ <span class="block truncate font-medium text-highlighted">{{ i.name }}</span>
121+ <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p>
122+ </td>
123+ <td class="px-3 py-2 align-middle">
124+ <div class="flex flex-wrap gap-1">
125+ <UBadge v-for="t in i.tags" :key="t" :label="t" color="neutral" variant="outline" size="sm" />
126+ <span v-if="!i.tags.length" class="text-xs text-dimmed">-</span>
127+ </div>
128+ </td>
129+ <td class="px-3 py-2 align-middle">
130+ <UTooltip :text="i.origin?.source ? `Source: ${i.origin.source}` : 'Created in skilly'">
131+ <UBadge :label="ORIGIN[i.origin?.kind ?? 'created'].label" :icon="ORIGIN[i.origin?.kind ?? 'created'].icon" color="neutral" variant="subtle" size="sm" />
132+ </UTooltip>
133+ </td>
134+ <td class="px-3 py-2 align-middle">
135+ <UTooltip v-if="usedBy(i).length" :text="usedBy(i).map(s => scopeLabel(s, names)).join(', ')">
136+ <span class="text-xs tabular-nums">{{ usedBy(i).length }} scope{{ usedBy(i).length > 1 ? 's' : '' }}</span>
137+ </UTooltip>
138+ <span v-else class="text-xs text-dimmed">unused</span>
139+ </td>
140+ </tr>
141+ </tbody>
142+ </table>
143+ </div>
144+ </div>
145+ </template>
146+ </UDashboardPanel>
147+</template>
added packages/ui/app/pages/catalog/new.vue +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+<script setup lang="ts">
2+import type { CatalogItemType } from '@skilly/core'
3+
4+const route = useRoute()
5+const type = computed<CatalogItemType>(() => (CAT_TYPES.includes(route.query.type as CatalogItemType) ? route.query.type as CatalogItemType : 'skill'))
6+</script>
7+
8+<template>
9+ <CatalogItemEditor :key="type" :type="type" />
10+</template>
new file mode 100644
@@ -0,0 +1,10 @@
1+<script setup lang="ts">
2+import type { CatalogItemType } from '@skilly/core'
3+
4+const route = useRoute()
5+const type = computed<CatalogItemType>(() => (CAT_TYPES.includes(route.query.type as CatalogItemType) ? route.query.type as CatalogItemType : 'skill'))
6+</script>
7+
8+<template>
9+ <CatalogItemEditor :key="type" :type="type" />
10+</template>
modified packages/ui/app/pages/index.vue +75 -44
@@ -1,14 +1,45 @@
11 <script setup lang="ts">
2-import type { ItemType, ScopeInventory } from '@skilly/core'
2+import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core'
33
44 const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()
55 const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory()
6+const { request } = useApi()
67 const addOpen = useState('skilly-add-open', () => false)
78
8-onMounted(() => {
9+interface Sync { label: string, color: 'success' | 'warning' | 'neutral' | 'error', icon: string }
10+const syncs = ref<Record<string, Sync | null>>({})
11+
12+function toSync(s: ScopeState): Sync {
13+ if (!s.config) return { label: 'not managed', color: 'neutral', icon: 'i-lucide-circle-dashed' }
14+ const pending = (s.plan?.actions ?? []).filter(a => a.kind !== 'unchanged')
15+ const conflicts = pending.filter(a => a.kind === 'conflict').length
16+ if (conflicts) return { label: `${conflicts} conflict${conflicts > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-triangle-alert' }
17+ if (pending.length) return { label: `${pending.length} change${pending.length > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-refresh-cw' }
18+ return { label: 'in sync', color: 'success', icon: 'i-lucide-check' }
19+}
20+
21+// Sync state needs a scan of each scope: fetch lazily, three at a time, and tolerate failures (the card then shows counts only).
22+async function loadSyncs() {
23+ const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)]
24+ const queue = [...ids]
25+ const worker = async () => {
26+ for (let id = queue.shift(); id; id = queue.shift()) {
27+ try {
28+ syncs.value[id] = toSync(await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true }))
29+ } catch {
30+ syncs.value[id] = null
31+ }
32+ }
33+ }
34+ await Promise.all([worker(), worker(), worker()])
35+}
36+
37+onMounted(async () => {
938 if (!home.value) refreshHome()
1039 // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
11- if (loaded.value) refreshProjects()
40+ if (loaded.value) await refreshProjects()
41+ else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) })
42+ loadSyncs()
1243 })
1344
1445 function countItems(inv: ScopeInventory): Record<ItemType, number> {
@@ -36,59 +67,59 @@ const homeCounts = computed(() => (home.value ? countItems(home.value) : null))
3667 Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files.
3768 </p>
3869
39- <div class="grid gap-4 md:grid-cols-2">
70+ <div class="grid gap-3 md:grid-cols-2">
4071 <!-- Home -->
41- <UCard>
42- <template #header>
43- <div class="flex items-center gap-2">
44- <UIcon name="i-lucide-house" class="size-5 text-primary" />
45- <h2 class="font-semibold">Home</h2>
46- <span class="text-xs text-muted">user-level configuration</span>
47- </div>
48- </template>
49- <div v-if="homeLoading && !home" class="space-y-2">
50- <USkeleton class="h-4 w-2/3" />
51- <USkeleton class="h-4 w-full" />
72+ <NuxtLink to="/home" class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary">
73+ <div class="flex items-center gap-2">
74+ <UIcon name="i-lucide-house" class="size-4 shrink-0 text-primary" />
75+ <h2 class="font-semibold">Home</h2>
76+ <span class="truncate text-xs text-muted">user-level configuration</span>
77+ <UBadge v-if="syncs.home" :label="syncs.home.label" :color="syncs.home.color" :icon="syncs.home.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
78+ <USkeleton v-else-if="syncs.home === undefined" class="ml-auto h-5 w-16" />
5279 </div>
53- <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" />
54- <div v-else-if="home && homeCounts" class="space-y-3">
80+ <div v-if="homeLoading && !home" class="mt-2 space-y-2"><USkeleton class="h-4 w-full" /></div>
81+ <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" class="mt-2" />
82+ <div v-else-if="home && homeCounts" class="mt-2 space-y-2">
5583 <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p>
56- <div class="flex gap-2">
57- <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" />
58- <span v-if="!home.agentsDetected.length" class="text-sm text-muted">No agent detected</span>
84+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
85+ <span class="flex gap-1">
86+ <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" compact />
87+ <span v-if="!home.agentsDetected.length" class="text-xs text-muted">No agent</span>
88+ </span>
89+ <CountChips :counts="homeCounts" compact />
5990 </div>
60- <CountChips :counts="homeCounts" />
6191 </div>
62- <template #footer>
63- <UButton to="/home" label="Open" trailing-icon="i-lucide-arrow-right" color="neutral" variant="soft" size="sm" />
64- </template>
65- </UCard>
92+ </NuxtLink>
6693
6794 <!-- Projects -->
6895 <template v-if="loaded">
69- <UCard v-for="p in projects" :key="p.id">
70- <template #header>
71- <div class="flex items-center gap-2">
72- <UIcon name="i-lucide-folder" class="size-5 text-primary" />
73- <h2 class="truncate font-semibold">{{ p.name }}</h2>
74- <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" />
75- </div>
76- </template>
77- <div class="space-y-3">
96+ <NuxtLink
97+ v-for="p in projects"
98+ :key="p.id"
99+ :to="`/projects/${encodeURIComponent(p.id)}`"
100+ class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"
101+ >
102+ <div class="flex items-center gap-2">
103+ <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-primary" />
104+ <h2 class="truncate font-semibold">{{ p.name }}</h2>
105+ <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" class="ml-auto shrink-0" />
106+ <UBadge v-else-if="syncs[p.id]" :label="syncs[p.id]!.label" :color="syncs[p.id]!.color" :icon="syncs[p.id]!.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
107+ <USkeleton v-else-if="syncs[p.id] === undefined" class="ml-auto h-5 w-16" />
108+ </div>
109+ <div class="mt-2 space-y-2">
78110 <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p>
79- <div class="flex gap-2">
80- <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" />
81- <span v-if="!p.agentsDetected.length" class="text-sm text-muted">No agent detected</span>
111+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
112+ <span class="flex gap-1">
113+ <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact />
114+ <span v-if="!p.agentsDetected.length" class="text-xs text-muted">No agent</span>
115+ </span>
116+ <CountChips :counts="p.counts" compact />
82117 </div>
83- <CountChips :counts="p.counts" />
84118 </div>
85- <template #footer>
86- <UButton :to="`/projects/${encodeURIComponent(p.id)}`" label="Open" trailing-icon="i-lucide-arrow-right" color="neutral" variant="soft" size="sm" />
87- </template>
88- </UCard>
119+ </NuxtLink>
89120 </template>
90121 <template v-else>
91- <USkeleton class="h-48 w-full" />
122+ <USkeleton class="h-24 w-full" />
92123 </template>
93124 </div>
94125
@@ -109,7 +140,7 @@ const homeCounts = computed(() => (home.value ? countItems(home.value) : null))
109140 <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" />
110141 <h3 class="font-semibold">Add your first project</h3>
111142 <p class="max-w-md text-sm text-muted">
112- Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there.
143+ Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog.
113144 </p>
114145 <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
115146 </div>
@@ -1,14 +1,45 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-import type { ItemType, ScopeInventory } from '@skilly/core'2+import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core'
3 3
4 const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()4 const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()
5 const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory()5 const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory()
6+const { request } = useApi()
6 const addOpen = useState('skilly-add-open', () => false)7 const addOpen = useState('skilly-add-open', () => false)
7 8
8-onMounted(() => {9+interface Sync { label: string, color: 'success' | 'warning' | 'neutral' | 'error', icon: string }
10+const syncs = ref<Record<string, Sync | null>>({})
11+
12+function toSync(s: ScopeState): Sync {
13+ if (!s.config) return { label: 'not managed', color: 'neutral', icon: 'i-lucide-circle-dashed' }
14+ const pending = (s.plan?.actions ?? []).filter(a => a.kind !== 'unchanged')
15+ const conflicts = pending.filter(a => a.kind === 'conflict').length
16+ if (conflicts) return { label: `${conflicts} conflict${conflicts > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-triangle-alert' }
17+ if (pending.length) return { label: `${pending.length} change${pending.length > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-refresh-cw' }
18+ return { label: 'in sync', color: 'success', icon: 'i-lucide-check' }
19+}
20+
21+// Sync state needs a scan of each scope: fetch lazily, three at a time, and tolerate failures (the card then shows counts only).
22+async function loadSyncs() {
23+ const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)]
24+ const queue = [...ids]
25+ const worker = async () => {
26+ for (let id = queue.shift(); id; id = queue.shift()) {
27+ try {
28+ syncs.value[id] = toSync(await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true }))
29+ } catch {
30+ syncs.value[id] = null
31+ }
32+ }
33+ }
34+ await Promise.all([worker(), worker(), worker()])
35+}
36+
37+onMounted(async () => {
9 if (!home.value) refreshHome()38 if (!home.value) refreshHome()
10 // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.39 // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
11- if (loaded.value) refreshProjects()40+ if (loaded.value) await refreshProjects()
41+ else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) })
42+ loadSyncs()
12 })43 })
13 44
14 function countItems(inv: ScopeInventory): Record<ItemType, number> {45 function countItems(inv: ScopeInventory): Record<ItemType, number> {
@@ -36,59 +67,59 @@ const homeCounts = computed(() => (home.value ? countItems(home.value) : null))
36 Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files.67 Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files.
37 </p>68 </p>
38 69
39- <div class="grid gap-4 md:grid-cols-2">70+ <div class="grid gap-3 md:grid-cols-2">
40 <!-- Home -->71 <!-- Home -->
41- <UCard>72+ <NuxtLink to="/home" class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary">
42- <template #header>73+ <div class="flex items-center gap-2">
43- <div class="flex items-center gap-2">74+ <UIcon name="i-lucide-house" class="size-4 shrink-0 text-primary" />
44- <UIcon name="i-lucide-house" class="size-5 text-primary" />75+ <h2 class="font-semibold">Home</h2>
45- <h2 class="font-semibold">Home</h2>76+ <span class="truncate text-xs text-muted">user-level configuration</span>
46- <span class="text-xs text-muted">user-level configuration</span>77+ <UBadge v-if="syncs.home" :label="syncs.home.label" :color="syncs.home.color" :icon="syncs.home.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
47- </div>78+ <USkeleton v-else-if="syncs.home === undefined" class="ml-auto h-5 w-16" />
48- </template>
49- <div v-if="homeLoading && !home" class="space-y-2">
50- <USkeleton class="h-4 w-2/3" />
51- <USkeleton class="h-4 w-full" />
52 </div>79 </div>
53- <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" />80+ <div v-if="homeLoading && !home" class="mt-2 space-y-2"><USkeleton class="h-4 w-full" /></div>
54- <div v-else-if="home && homeCounts" class="space-y-3">81+ <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" class="mt-2" />
82+ <div v-else-if="home && homeCounts" class="mt-2 space-y-2">
55 <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p>83 <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p>
56- <div class="flex gap-2">84+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
57- <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" />85+ <span class="flex gap-1">
58- <span v-if="!home.agentsDetected.length" class="text-sm text-muted">No agent detected</span>86+ <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" compact />
87+ <span v-if="!home.agentsDetected.length" class="text-xs text-muted">No agent</span>
88+ </span>
89+ <CountChips :counts="homeCounts" compact />
59 </div>90 </div>
60- <CountChips :counts="homeCounts" />
61 </div>91 </div>
62- <template #footer>92+ </NuxtLink>
63- <UButton to="/home" label="Open" trailing-icon="i-lucide-arrow-right" color="neutral" variant="soft" size="sm" />
64- </template>
65- </UCard>
66 93
67 <!-- Projects -->94 <!-- Projects -->
68 <template v-if="loaded">95 <template v-if="loaded">
69- <UCard v-for="p in projects" :key="p.id">96+ <NuxtLink
70- <template #header>97+ v-for="p in projects"
71- <div class="flex items-center gap-2">98+ :key="p.id"
72- <UIcon name="i-lucide-folder" class="size-5 text-primary" />99+ :to="`/projects/${encodeURIComponent(p.id)}`"
73- <h2 class="truncate font-semibold">{{ p.name }}</h2>100+ class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"
74- <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" />101+ >
75- </div>102+ <div class="flex items-center gap-2">
76- </template>103+ <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-primary" />
77- <div class="space-y-3">104+ <h2 class="truncate font-semibold">{{ p.name }}</h2>
105+ <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" class="ml-auto shrink-0" />
106+ <UBadge v-else-if="syncs[p.id]" :label="syncs[p.id]!.label" :color="syncs[p.id]!.color" :icon="syncs[p.id]!.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
107+ <USkeleton v-else-if="syncs[p.id] === undefined" class="ml-auto h-5 w-16" />
108+ </div>
109+ <div class="mt-2 space-y-2">
78 <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p>110 <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p>
79- <div class="flex gap-2">111+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
80- <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" />112+ <span class="flex gap-1">
81- <span v-if="!p.agentsDetected.length" class="text-sm text-muted">No agent detected</span>113+ <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact />
114+ <span v-if="!p.agentsDetected.length" class="text-xs text-muted">No agent</span>
115+ </span>
116+ <CountChips :counts="p.counts" compact />
82 </div>117 </div>
83- <CountChips :counts="p.counts" />
84 </div>118 </div>
85- <template #footer>119+ </NuxtLink>
86- <UButton :to="`/projects/${encodeURIComponent(p.id)}`" label="Open" trailing-icon="i-lucide-arrow-right" color="neutral" variant="soft" size="sm" />
87- </template>
88- </UCard>
89 </template>120 </template>
90 <template v-else>121 <template v-else>
91- <USkeleton class="h-48 w-full" />122+ <USkeleton class="h-24 w-full" />
92 </template>123 </template>
93 </div>124 </div>
94 125
@@ -109,7 +140,7 @@ const homeCounts = computed(() => (home.value ? countItems(home.value) : null))
109 <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" />140 <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" />
110 <h3 class="font-semibold">Add your first project</h3>141 <h3 class="font-semibold">Add your first project</h3>
111 <p class="max-w-md text-sm text-muted">142 <p class="max-w-md text-sm text-muted">
112- Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there.143+ Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog.
113 </p>144 </p>
114 <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />145 <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
115 </div>146 </div>
added packages/ui/app/pages/marketplaces/index.vue +386 -0
new file mode 100644
@@ -0,0 +1,386 @@
1+<script setup lang="ts">
2+import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core'
3+
4+const { request } = useApi()
5+const catalog = useCatalog()
6+const toast = useToast()
7+
8+const sources = ref<MarketplaceSource[]>([])
9+const sourcesLoading = ref(true)
10+const sourcesError = ref<string | null>(null)
11+const selectedId = ref<string | null>(null)
12+const selected = computed(() => sources.value.find(s => s.id === selectedId.value) ?? null)
13+
14+const skills = ref<MarketplaceSkill[]>([])
15+const skillsLoading = ref(false)
16+const skillsError = ref<string | null>(null)
17+const refreshing = ref(false)
18+const search = ref('')
19+const picked = ref<Set<string>>(new Set())
20+const LIMIT = 300
21+const showAll = ref(false)
22+
23+async function loadSources() {
24+ sourcesLoading.value = true
25+ try {
26+ sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })
27+ sourcesError.value = null
28+ if (!selectedId.value && sources.value.length) await select(sources.value[0]!.id)
29+ } catch (e) {
30+ sourcesError.value = (e as Error).message
31+ } finally {
32+ sourcesLoading.value = false
33+ }
34+}
35+
36+async function loadSkills() {
37+ if (!selectedId.value) return
38+ skillsLoading.value = true
39+ skillsError.value = null
40+ try {
41+ skills.value = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(selectedId.value)}/skills`, { silent: true })
42+ } catch (e) {
43+ skills.value = []
44+ skillsError.value = (e as Error).message
45+ } finally {
46+ skillsLoading.value = false
47+ }
48+}
49+
50+async function select(id: string) {
51+ selectedId.value = id
52+ picked.value = new Set()
53+ search.value = ''
54+ showAll.value = false
55+ await loadSkills()
56+}
57+
58+async function refreshSource() {
59+ if (!selected.value) return
60+ refreshing.value = true
61+ try {
62+ const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/refresh`, { method: 'POST', silent: true })
63+ sources.value = sources.value.map(s => (s.id === updated.id ? updated : s))
64+ await loadSkills()
65+ toast.add({ title: 'Source refreshed', description: updated.name, icon: 'i-lucide-check', color: 'success' })
66+ } catch (e) {
67+ toast.add({ title: 'Refresh failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
68+ } finally {
69+ refreshing.value = false
70+ }
71+}
72+
73+onMounted(() => {
74+ loadSources()
75+ if (!catalog.data.value) catalog.refresh()
76+})
77+
78+/* ------------------------------------------------ List */
79+
80+const filtered = computed(() => {
81+ const q = search.value.trim().toLowerCase()
82+ return q ? skills.value.filter(s => `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q)) : skills.value
83+})
84+const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT)))
85+const groups = computed(() => {
86+ if (selected.value?.kind !== 'claude-marketplace') return [{ plugin: null as string | null, rows: shown.value }]
87+ const m = new Map<string, MarketplaceSkill[]>()
88+ for (const s of shown.value) {
89+ const k = s.plugin ?? '(no plugin)'
90+ m.set(k, [...(m.get(k) ?? []), s])
91+ }
92+ return [...m.entries()].map(([plugin, rows]) => ({ plugin, rows }))
93+})
94+
95+function toggle(path: string, on: boolean | 'indeterminate') {
96+ const s = new Set(picked.value)
97+ if (on === true) s.add(path)
98+ else s.delete(path)
99+ picked.value = s
100+}
101+function toggleGroup(rows: MarketplaceSkill[], on: boolean | 'indeterminate') {
102+ const s = new Set(picked.value)
103+ for (const r of rows) (on === true ? s.add(r.path) : s.delete(r.path))
104+ picked.value = s
105+}
106+const groupState = (rows: MarketplaceSkill[]) => {
107+ const n = rows.filter(r => picked.value.has(r.path)).length
108+ return n === 0 ? false : n === rows.length ? true : 'indeterminate' as const
109+}
110+
111+/* ------------------------------------------------ Import */
112+
113+const differing = computed(() => skills.value.filter(s => picked.value.has(s.path) && s.inCatalog === 'different'))
114+const confirmOpen = ref(false)
115+const importing = ref(false)
116+
117+function startImport() {
118+ if (differing.value.length) confirmOpen.value = true
119+ else doImport(false)
120+}
121+
122+async function doImport(overwrite: boolean) {
123+ if (!selected.value) return
124+ importing.value = true
125+ try {
126+ const res = await request<ImportResult>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/import`, {
127+ method: 'POST',
128+ body: { paths: [...picked.value], overwrite },
129+ silent: true
130+ })
131+ confirmOpen.value = false
132+ toast.add({
133+ title: `${res.imported.length} imported`,
134+ description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog.',
135+ icon: 'i-lucide-check',
136+ color: res.skipped.length ? 'warning' : 'success'
137+ })
138+ picked.value = new Set()
139+ await Promise.all([loadSkills(), catalog.refresh()])
140+ } catch (e) {
141+ toast.add({ title: 'Import failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
142+ } finally {
143+ importing.value = false
144+ }
145+}
146+
147+/* ------------------------------------------------ Add / remove source */
148+
149+const addOpen = ref(false)
150+const adding = ref(false)
151+const addError = ref<string | null>(null)
152+const addForm = reactive({ name: '', kind: 'git' as MarketplaceSource['kind'], url: '', ref: '' })
153+const KINDS = [
154+ { label: 'Git repository (skill folders)', value: 'git' },
155+ { label: 'Claude plugin marketplace', value: 'claude-marketplace' }
156+]
157+
158+async function addSource() {
159+ adding.value = true
160+ addError.value = null
161+ try {
162+ const created = await request<MarketplaceSource>('/api/marketplaces', {
163+ method: 'POST',
164+ body: { name: addForm.name.trim(), kind: addForm.kind, url: addForm.url.trim(), ...(addForm.ref.trim() ? { ref: addForm.ref.trim() } : {}) },
165+ silent: true
166+ })
167+ addOpen.value = false
168+ Object.assign(addForm, { name: '', kind: 'git', url: '', ref: '' })
169+ await loadSources()
170+ await select(created.id)
171+ } catch (e) {
172+ addError.value = (e as Error).message
173+ } finally {
174+ adding.value = false
175+ }
176+}
177+
178+const removeTarget = ref<MarketplaceSource | null>(null)
179+async function removeSource() {
180+ const s = removeTarget.value
181+ if (!s) return
182+ try {
183+ await request(`/api/marketplaces/${encodeURIComponent(s.id)}`, { method: 'DELETE' })
184+ removeTarget.value = null
185+ if (selectedId.value === s.id) { selectedId.value = null; skills.value = [] }
186+ await loadSources()
187+ } catch { /* toast shown */ }
188+}
189+
190+/* ------------------------------------------------ Preview */
191+
192+const previewOpen = ref(false)
193+const preview = ref<MarketplaceSkillDetail | null>(null)
194+const previewLoading = ref(false)
195+const previewError = ref<string | null>(null)
196+const previewHtml = computed(() => (preview.value ? renderMarkdown(splitFrontmatter(preview.value.content).body) : ''))
197+const previewFm = computed(() => (preview.value ? splitFrontmatter(preview.value.content).frontmatter : null))
198+
199+async function openPreview(s: MarketplaceSkill) {
200+ previewOpen.value = true
201+ preview.value = null
202+ previewError.value = null
203+ previewLoading.value = true
204+ try {
205+ preview.value = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true })
206+ } catch (e) {
207+ previewError.value = (e as Error).message
208+ } finally {
209+ previewLoading.value = false
210+ }
211+}
212+
213+const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${formatDate(s.lastFetchedAt)}` : 'never fetched')
214+</script>
215+
216+<template>
217+ <UDashboardPanel id="marketplaces">
218+ <template #header>
219+ <UDashboardNavbar title="Marketplaces" icon="i-lucide-store">
220+ <template #right>
221+ <UButton label="Add source" icon="i-lucide-plus" color="neutral" variant="outline" @click="addOpen = true" />
222+ </template>
223+ </UDashboardNavbar>
224+ </template>
225+
226+ <template #body>
227+ <USkeleton v-if="sourcesLoading && !sources.length" class="h-64 w-full" />
228+ <UAlert v-else-if="sourcesError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load marketplaces" :description="sourcesError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: loadSources }]" />
229+
230+ <div v-else class="grid items-start gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
231+ <!-- Sources -->
232+ <aside class="space-y-1">
233+ <div
234+ v-for="s in sources"
235+ :key="s.id"
236+ class="group flex cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 transition-colors"
237+ :class="s.id === selectedId ? 'border-primary bg-primary/5' : 'border-default hover:bg-elevated/50'"
238+ tabindex="0"
239+ @click="select(s.id)"
240+ @keydown.enter="select(s.id)"
241+ >
242+ <UIcon :name="s.kind === 'git' ? 'i-lucide-git-fork' : 'i-lucide-puzzle'" class="mt-0.5 size-4 shrink-0 text-muted" />
243+ <div class="min-w-0 flex-1">
244+ <div class="flex items-center gap-1.5">
245+ <span class="truncate text-sm font-medium">{{ s.name }}</span>
246+ <UBadge v-if="s.builtIn" label="built-in" size="sm" color="neutral" variant="subtle" />
247+ </div>
248+ <p class="truncate text-xs text-muted" :title="s.url">{{ s.url }}</p>
249+ </div>
250+ <UButton v-if="!s.builtIn" icon="i-lucide-trash-2" size="xs" color="neutral" variant="ghost" aria-label="Remove source" @click.stop="removeTarget = s" />
251+ </div>
252+ </aside>
253+
254+ <!-- Selected source -->
255+ <section v-if="selected" class="min-w-0 space-y-4">
256+ <div class="flex flex-wrap items-center gap-3">
257+ <div class="min-w-0">
258+ <h2 class="truncate font-semibold">{{ selected.name }}</h2>
259+ <p class="text-xs text-muted">{{ selected.kind === 'git' ? 'Git repository' : 'Claude plugin marketplace' }}<span v-if="selected.ref"> - {{ selected.ref }}</span> - {{ sinceFetched(selected) }}</p>
260+ </div>
261+ <UButton class="ml-auto" label="Refresh" icon="i-lucide-refresh-cw" color="neutral" variant="outline" :loading="refreshing" @click="refreshSource" />
262+ </div>
263+ <UAlert v-if="refreshing" color="info" variant="subtle" icon="i-lucide-loader" title="Fetching the repository" description="Cloning or updating can take tens of seconds for large repositories." />
264+
265+ <div class="flex flex-wrap items-center gap-3">
266+ <UInput v-model="search" icon="i-lucide-search" placeholder="Search skills..." class="w-72" />
267+ <span class="text-xs text-muted">{{ filtered.length }} skill{{ filtered.length === 1 ? '' : 's' }}</span>
268+ <UButton class="ml-auto" :label="`Import ${picked.size || ''}`.trim()" icon="i-lucide-download" :disabled="!picked.size" :loading="importing" @click="startImport" />
269+ </div>
270+
271+ <div v-if="skillsLoading" class="space-y-2">
272+ <UAlert color="info" variant="subtle" icon="i-lucide-loader" title="Loading skills" description="The first load clones the repository; this can take a while." />
273+ <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />
274+ </div>
275+ <UAlert v-else-if="skillsError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the skills" :description="skillsError" :actions="[{ label: 'Refresh source', color: 'neutral', variant: 'outline', onClick: refreshSource }]" />
276+ <div v-else-if="!filtered.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center">
277+ <UIcon :name="search ? 'i-lucide-search-x' : 'i-lucide-store'" class="size-8 text-dimmed" />
278+ <p class="text-sm text-muted">{{ search ? 'No skill matches the search.' : 'No skill found. Try Refresh.' }}</p>
279+ </div>
280+
281+ <div v-else class="space-y-3">
282+ <div v-for="g in groups" :key="g.plugin ?? 'all'" class="overflow-hidden rounded-xl border border-default">
283+ <div v-if="g.plugin" class="flex items-center gap-2 border-b border-default bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted">
284+ <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select plugin ${g.plugin}`" @update:model-value="(v) => toggleGroup(g.rows, v)" />
285+ <UIcon name="i-lucide-puzzle" class="size-3.5" />{{ g.plugin }} ({{ g.rows.length }})
286+ </div>
287+ <ul class="divide-y divide-default">
288+ <li v-for="s in g.rows" :key="s.path" class="flex items-start gap-3 px-3 py-2 hover:bg-elevated/40">
289+ <UCheckbox class="mt-0.5" :model-value="picked.has(s.path)" :aria-label="`Select ${s.name}`" @update:model-value="(v) => toggle(s.path, v)" />
290+ <button type="button" class="min-w-0 flex-1 text-left" @click="openPreview(s)">
291+ <div class="flex items-center gap-2">
292+ <span class="truncate text-sm font-medium text-highlighted">{{ s.name }}</span>
293+ <UBadge v-if="s.inCatalog === 'same'" label="In catalog" icon="i-lucide-check" size="sm" color="success" variant="subtle" />
294+ <UBadge v-else-if="s.inCatalog === 'different'" label="In catalog (different)" icon="i-lucide-git-compare" size="sm" color="warning" variant="subtle" />
295+ </div>
296+ <p v-if="s.description" class="line-clamp-2 text-xs text-muted">{{ s.description }}</p>
297+ <p class="truncate font-mono text-[11px] text-dimmed">{{ s.path }}</p>
298+ </button>
299+ <UButton icon="i-lucide-eye" size="xs" color="neutral" variant="ghost" aria-label="Preview" @click="openPreview(s)" />
300+ </li>
301+ </ul>
302+ </div>
303+ <div v-if="filtered.length > shown.length" class="text-center">
304+ <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" />
305+ </div>
306+ </div>
307+ </section>
308+ </div>
309+
310+ <!-- Preview -->
311+ <USlideover v-model:open="previewOpen" :title="preview?.name ?? 'Preview'" :description="preview?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }">
312+ <template #body>
313+ <div v-if="previewLoading" class="space-y-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div>
314+ <UAlert v-else-if="previewError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the preview" :description="previewError" />
315+ <div v-else-if="preview" class="space-y-4">
316+ <div v-if="previewFm" class="overflow-hidden rounded-lg border border-default">
317+ <table class="w-full text-xs">
318+ <tbody class="divide-y divide-default">
319+ <tr v-for="(v, k) in previewFm" :key="k">
320+ <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th>
321+ <td class="px-2 py-1.5 break-words">{{ v }}</td>
322+ </tr>
323+ </tbody>
324+ </table>
325+ </div>
326+ <!-- eslint-disable-next-line vue/no-v-html -->
327+ <div class="md-body" v-html="previewHtml" />
328+ <div v-if="preview.files.length" class="space-y-1">
329+ <h3 class="text-sm font-semibold">Files ({{ preview.files.length }})</h3>
330+ <ul class="max-h-48 overflow-auto rounded-lg border border-default text-xs">
331+ <li v-for="f in preview.files" :key="f" class="flex items-center gap-2 px-3 py-1.5 font-mono"><UIcon name="i-lucide-file" class="size-3.5 text-muted" />{{ f }}</li>
332+ </ul>
333+ </div>
334+ </div>
335+ </template>
336+ </USlideover>
337+
338+ <!-- Overwrite confirmation -->
339+ <UModal v-model:open="confirmOpen" title="Overwrite catalog items?" description="Some selected skills already exist in the catalog with different content.">
340+ <template #body>
341+ <ul class="list-disc space-y-0.5 pl-4 text-sm">
342+ <li v-for="s in differing" :key="s.path">{{ s.name }}</li>
343+ </ul>
344+ <p class="mt-3 text-sm text-muted">Overwriting replaces the catalog version with the marketplace version. Local edits in the catalog are lost (git history keeps them).</p>
345+ </template>
346+ <template #footer>
347+ <div class="flex w-full justify-end gap-2">
348+ <UButton label="Cancel" color="neutral" variant="outline" @click="confirmOpen = false" />
349+ <UButton label="Overwrite and import" color="warning" :loading="importing" @click="doImport(true)" />
350+ </div>
351+ </template>
352+ </UModal>
353+
354+ <!-- Add source -->
355+ <UModal v-model:open="addOpen" title="Add a source" description="A git repository containing skills, or a Claude Code plugin marketplace.">
356+ <template #body>
357+ <form id="add-source" class="space-y-3" @submit.prevent="addSource">
358+ <UAlert v-if="addError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="addError" />
359+ <UFormField label="Name" required><UInput v-model="addForm.name" class="w-full" placeholder="My skills" /></UFormField>
360+ <UFormField label="Kind"><USelect v-model="addForm.kind" :items="KINDS" class="w-full" /></UFormField>
361+ <UFormField label="URL" required><UInput v-model="addForm.url" class="w-full" placeholder="https://github.com/owner/repo.git" :ui="{ base: 'font-mono' }" /></UFormField>
362+ <UFormField label="Ref" description="Branch or tag; default branch when empty."><UInput v-model="addForm.ref" class="w-full" placeholder="main" :ui="{ base: 'font-mono' }" /></UFormField>
363+ </form>
364+ </template>
365+ <template #footer>
366+ <div class="flex w-full justify-end gap-2">
367+ <UButton label="Cancel" color="neutral" variant="outline" @click="addOpen = false" />
368+ <UButton type="submit" form="add-source" label="Add source" :loading="adding" :disabled="!addForm.name.trim() || !addForm.url.trim()" />
369+ </div>
370+ </template>
371+ </UModal>
372+
373+ <UModal :open="!!removeTarget" title="Remove source" description="Imported catalog items are kept." @update:open="(v: boolean) => { if (!v) removeTarget = null }">
374+ <template #body>
375+ <p class="text-sm">Remove <strong>{{ removeTarget?.name }}</strong> from the list of sources?</p>
376+ </template>
377+ <template #footer>
378+ <div class="flex w-full justify-end gap-2">
379+ <UButton label="Cancel" color="neutral" variant="outline" @click="removeTarget = null" />
380+ <UButton label="Remove" color="error" @click="removeSource" />
381+ </div>
382+ </template>
383+ </UModal>
384+ </template>
385+ </UDashboardPanel>
386+</template>
new file mode 100644
@@ -0,0 +1,386 @@
1+<script setup lang="ts">
2+import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core'
3+
4+const { request } = useApi()
5+const catalog = useCatalog()
6+const toast = useToast()
7+
8+const sources = ref<MarketplaceSource[]>([])
9+const sourcesLoading = ref(true)
10+const sourcesError = ref<string | null>(null)
11+const selectedId = ref<string | null>(null)
12+const selected = computed(() => sources.value.find(s => s.id === selectedId.value) ?? null)
13+
14+const skills = ref<MarketplaceSkill[]>([])
15+const skillsLoading = ref(false)
16+const skillsError = ref<string | null>(null)
17+const refreshing = ref(false)
18+const search = ref('')
19+const picked = ref<Set<string>>(new Set())
20+const LIMIT = 300
21+const showAll = ref(false)
22+
23+async function loadSources() {
24+ sourcesLoading.value = true
25+ try {
26+ sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })
27+ sourcesError.value = null
28+ if (!selectedId.value && sources.value.length) await select(sources.value[0]!.id)
29+ } catch (e) {
30+ sourcesError.value = (e as Error).message
31+ } finally {
32+ sourcesLoading.value = false
33+ }
34+}
35+
36+async function loadSkills() {
37+ if (!selectedId.value) return
38+ skillsLoading.value = true
39+ skillsError.value = null
40+ try {
41+ skills.value = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(selectedId.value)}/skills`, { silent: true })
42+ } catch (e) {
43+ skills.value = []
44+ skillsError.value = (e as Error).message
45+ } finally {
46+ skillsLoading.value = false
47+ }
48+}
49+
50+async function select(id: string) {
51+ selectedId.value = id
52+ picked.value = new Set()
53+ search.value = ''
54+ showAll.value = false
55+ await loadSkills()
56+}
57+
58+async function refreshSource() {
59+ if (!selected.value) return
60+ refreshing.value = true
61+ try {
62+ const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/refresh`, { method: 'POST', silent: true })
63+ sources.value = sources.value.map(s => (s.id === updated.id ? updated : s))
64+ await loadSkills()
65+ toast.add({ title: 'Source refreshed', description: updated.name, icon: 'i-lucide-check', color: 'success' })
66+ } catch (e) {
67+ toast.add({ title: 'Refresh failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
68+ } finally {
69+ refreshing.value = false
70+ }
71+}
72+
73+onMounted(() => {
74+ loadSources()
75+ if (!catalog.data.value) catalog.refresh()
76+})
77+
78+/* ------------------------------------------------ List */
79+
80+const filtered = computed(() => {
81+ const q = search.value.trim().toLowerCase()
82+ return q ? skills.value.filter(s => `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q)) : skills.value
83+})
84+const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT)))
85+const groups = computed(() => {
86+ if (selected.value?.kind !== 'claude-marketplace') return [{ plugin: null as string | null, rows: shown.value }]
87+ const m = new Map<string, MarketplaceSkill[]>()
88+ for (const s of shown.value) {
89+ const k = s.plugin ?? '(no plugin)'
90+ m.set(k, [...(m.get(k) ?? []), s])
91+ }
92+ return [...m.entries()].map(([plugin, rows]) => ({ plugin, rows }))
93+})
94+
95+function toggle(path: string, on: boolean | 'indeterminate') {
96+ const s = new Set(picked.value)
97+ if (on === true) s.add(path)
98+ else s.delete(path)
99+ picked.value = s
100+}
101+function toggleGroup(rows: MarketplaceSkill[], on: boolean | 'indeterminate') {
102+ const s = new Set(picked.value)
103+ for (const r of rows) (on === true ? s.add(r.path) : s.delete(r.path))
104+ picked.value = s
105+}
106+const groupState = (rows: MarketplaceSkill[]) => {
107+ const n = rows.filter(r => picked.value.has(r.path)).length
108+ return n === 0 ? false : n === rows.length ? true : 'indeterminate' as const
109+}
110+
111+/* ------------------------------------------------ Import */
112+
113+const differing = computed(() => skills.value.filter(s => picked.value.has(s.path) && s.inCatalog === 'different'))
114+const confirmOpen = ref(false)
115+const importing = ref(false)
116+
117+function startImport() {
118+ if (differing.value.length) confirmOpen.value = true
119+ else doImport(false)
120+}
121+
122+async function doImport(overwrite: boolean) {
123+ if (!selected.value) return
124+ importing.value = true
125+ try {
126+ const res = await request<ImportResult>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/import`, {
127+ method: 'POST',
128+ body: { paths: [...picked.value], overwrite },
129+ silent: true
130+ })
131+ confirmOpen.value = false
132+ toast.add({
133+ title: `${res.imported.length} imported`,
134+ description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog.',
135+ icon: 'i-lucide-check',
136+ color: res.skipped.length ? 'warning' : 'success'
137+ })
138+ picked.value = new Set()
139+ await Promise.all([loadSkills(), catalog.refresh()])
140+ } catch (e) {
141+ toast.add({ title: 'Import failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
142+ } finally {
143+ importing.value = false
144+ }
145+}
146+
147+/* ------------------------------------------------ Add / remove source */
148+
149+const addOpen = ref(false)
150+const adding = ref(false)
151+const addError = ref<string | null>(null)
152+const addForm = reactive({ name: '', kind: 'git' as MarketplaceSource['kind'], url: '', ref: '' })
153+const KINDS = [
154+ { label: 'Git repository (skill folders)', value: 'git' },
155+ { label: 'Claude plugin marketplace', value: 'claude-marketplace' }
156+]
157+
158+async function addSource() {
159+ adding.value = true
160+ addError.value = null
161+ try {
162+ const created = await request<MarketplaceSource>('/api/marketplaces', {
163+ method: 'POST',
164+ body: { name: addForm.name.trim(), kind: addForm.kind, url: addForm.url.trim(), ...(addForm.ref.trim() ? { ref: addForm.ref.trim() } : {}) },
165+ silent: true
166+ })
167+ addOpen.value = false
168+ Object.assign(addForm, { name: '', kind: 'git', url: '', ref: '' })
169+ await loadSources()
170+ await select(created.id)
171+ } catch (e) {
172+ addError.value = (e as Error).message
173+ } finally {
174+ adding.value = false
175+ }
176+}
177+
178+const removeTarget = ref<MarketplaceSource | null>(null)
179+async function removeSource() {
180+ const s = removeTarget.value
181+ if (!s) return
182+ try {
183+ await request(`/api/marketplaces/${encodeURIComponent(s.id)}`, { method: 'DELETE' })
184+ removeTarget.value = null
185+ if (selectedId.value === s.id) { selectedId.value = null; skills.value = [] }
186+ await loadSources()
187+ } catch { /* toast shown */ }
188+}
189+
190+/* ------------------------------------------------ Preview */
191+
192+const previewOpen = ref(false)
193+const preview = ref<MarketplaceSkillDetail | null>(null)
194+const previewLoading = ref(false)
195+const previewError = ref<string | null>(null)
196+const previewHtml = computed(() => (preview.value ? renderMarkdown(splitFrontmatter(preview.value.content).body) : ''))
197+const previewFm = computed(() => (preview.value ? splitFrontmatter(preview.value.content).frontmatter : null))
198+
199+async function openPreview(s: MarketplaceSkill) {
200+ previewOpen.value = true
201+ preview.value = null
202+ previewError.value = null
203+ previewLoading.value = true
204+ try {
205+ preview.value = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true })
206+ } catch (e) {
207+ previewError.value = (e as Error).message
208+ } finally {
209+ previewLoading.value = false
210+ }
211+}
212+
213+const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${formatDate(s.lastFetchedAt)}` : 'never fetched')
214+</script>
215+
216+<template>
217+ <UDashboardPanel id="marketplaces">
218+ <template #header>
219+ <UDashboardNavbar title="Marketplaces" icon="i-lucide-store">
220+ <template #right>
221+ <UButton label="Add source" icon="i-lucide-plus" color="neutral" variant="outline" @click="addOpen = true" />
222+ </template>
223+ </UDashboardNavbar>
224+ </template>
225+
226+ <template #body>
227+ <USkeleton v-if="sourcesLoading && !sources.length" class="h-64 w-full" />
228+ <UAlert v-else-if="sourcesError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load marketplaces" :description="sourcesError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: loadSources }]" />
229+
230+ <div v-else class="grid items-start gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
231+ <!-- Sources -->
232+ <aside class="space-y-1">
233+ <div
234+ v-for="s in sources"
235+ :key="s.id"
236+ class="group flex cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 transition-colors"
237+ :class="s.id === selectedId ? 'border-primary bg-primary/5' : 'border-default hover:bg-elevated/50'"
238+ tabindex="0"
239+ @click="select(s.id)"
240+ @keydown.enter="select(s.id)"
241+ >
242+ <UIcon :name="s.kind === 'git' ? 'i-lucide-git-fork' : 'i-lucide-puzzle'" class="mt-0.5 size-4 shrink-0 text-muted" />
243+ <div class="min-w-0 flex-1">
244+ <div class="flex items-center gap-1.5">
245+ <span class="truncate text-sm font-medium">{{ s.name }}</span>
246+ <UBadge v-if="s.builtIn" label="built-in" size="sm" color="neutral" variant="subtle" />
247+ </div>
248+ <p class="truncate text-xs text-muted" :title="s.url">{{ s.url }}</p>
249+ </div>
250+ <UButton v-if="!s.builtIn" icon="i-lucide-trash-2" size="xs" color="neutral" variant="ghost" aria-label="Remove source" @click.stop="removeTarget = s" />
251+ </div>
252+ </aside>
253+
254+ <!-- Selected source -->
255+ <section v-if="selected" class="min-w-0 space-y-4">
256+ <div class="flex flex-wrap items-center gap-3">
257+ <div class="min-w-0">
258+ <h2 class="truncate font-semibold">{{ selected.name }}</h2>
259+ <p class="text-xs text-muted">{{ selected.kind === 'git' ? 'Git repository' : 'Claude plugin marketplace' }}<span v-if="selected.ref"> - {{ selected.ref }}</span> - {{ sinceFetched(selected) }}</p>
260+ </div>
261+ <UButton class="ml-auto" label="Refresh" icon="i-lucide-refresh-cw" color="neutral" variant="outline" :loading="refreshing" @click="refreshSource" />
262+ </div>
263+ <UAlert v-if="refreshing" color="info" variant="subtle" icon="i-lucide-loader" title="Fetching the repository" description="Cloning or updating can take tens of seconds for large repositories." />
264+
265+ <div class="flex flex-wrap items-center gap-3">
266+ <UInput v-model="search" icon="i-lucide-search" placeholder="Search skills..." class="w-72" />
267+ <span class="text-xs text-muted">{{ filtered.length }} skill{{ filtered.length === 1 ? '' : 's' }}</span>
268+ <UButton class="ml-auto" :label="`Import ${picked.size || ''}`.trim()" icon="i-lucide-download" :disabled="!picked.size" :loading="importing" @click="startImport" />
269+ </div>
270+
271+ <div v-if="skillsLoading" class="space-y-2">
272+ <UAlert color="info" variant="subtle" icon="i-lucide-loader" title="Loading skills" description="The first load clones the repository; this can take a while." />
273+ <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />
274+ </div>
275+ <UAlert v-else-if="skillsError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the skills" :description="skillsError" :actions="[{ label: 'Refresh source', color: 'neutral', variant: 'outline', onClick: refreshSource }]" />
276+ <div v-else-if="!filtered.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center">
277+ <UIcon :name="search ? 'i-lucide-search-x' : 'i-lucide-store'" class="size-8 text-dimmed" />
278+ <p class="text-sm text-muted">{{ search ? 'No skill matches the search.' : 'No skill found. Try Refresh.' }}</p>
279+ </div>
280+
281+ <div v-else class="space-y-3">
282+ <div v-for="g in groups" :key="g.plugin ?? 'all'" class="overflow-hidden rounded-xl border border-default">
283+ <div v-if="g.plugin" class="flex items-center gap-2 border-b border-default bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted">
284+ <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select plugin ${g.plugin}`" @update:model-value="(v) => toggleGroup(g.rows, v)" />
285+ <UIcon name="i-lucide-puzzle" class="size-3.5" />{{ g.plugin }} ({{ g.rows.length }})
286+ </div>
287+ <ul class="divide-y divide-default">
288+ <li v-for="s in g.rows" :key="s.path" class="flex items-start gap-3 px-3 py-2 hover:bg-elevated/40">
289+ <UCheckbox class="mt-0.5" :model-value="picked.has(s.path)" :aria-label="`Select ${s.name}`" @update:model-value="(v) => toggle(s.path, v)" />
290+ <button type="button" class="min-w-0 flex-1 text-left" @click="openPreview(s)">
291+ <div class="flex items-center gap-2">
292+ <span class="truncate text-sm font-medium text-highlighted">{{ s.name }}</span>
293+ <UBadge v-if="s.inCatalog === 'same'" label="In catalog" icon="i-lucide-check" size="sm" color="success" variant="subtle" />
294+ <UBadge v-else-if="s.inCatalog === 'different'" label="In catalog (different)" icon="i-lucide-git-compare" size="sm" color="warning" variant="subtle" />
295+ </div>
296+ <p v-if="s.description" class="line-clamp-2 text-xs text-muted">{{ s.description }}</p>
297+ <p class="truncate font-mono text-[11px] text-dimmed">{{ s.path }}</p>
298+ </button>
299+ <UButton icon="i-lucide-eye" size="xs" color="neutral" variant="ghost" aria-label="Preview" @click="openPreview(s)" />
300+ </li>
301+ </ul>
302+ </div>
303+ <div v-if="filtered.length > shown.length" class="text-center">
304+ <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" />
305+ </div>
306+ </div>
307+ </section>
308+ </div>
309+
310+ <!-- Preview -->
311+ <USlideover v-model:open="previewOpen" :title="preview?.name ?? 'Preview'" :description="preview?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }">
312+ <template #body>
313+ <div v-if="previewLoading" class="space-y-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div>
314+ <UAlert v-else-if="previewError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the preview" :description="previewError" />
315+ <div v-else-if="preview" class="space-y-4">
316+ <div v-if="previewFm" class="overflow-hidden rounded-lg border border-default">
317+ <table class="w-full text-xs">
318+ <tbody class="divide-y divide-default">
319+ <tr v-for="(v, k) in previewFm" :key="k">
320+ <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th>
321+ <td class="px-2 py-1.5 break-words">{{ v }}</td>
322+ </tr>
323+ </tbody>
324+ </table>
325+ </div>
326+ <!-- eslint-disable-next-line vue/no-v-html -->
327+ <div class="md-body" v-html="previewHtml" />
328+ <div v-if="preview.files.length" class="space-y-1">
329+ <h3 class="text-sm font-semibold">Files ({{ preview.files.length }})</h3>
330+ <ul class="max-h-48 overflow-auto rounded-lg border border-default text-xs">
331+ <li v-for="f in preview.files" :key="f" class="flex items-center gap-2 px-3 py-1.5 font-mono"><UIcon name="i-lucide-file" class="size-3.5 text-muted" />{{ f }}</li>
332+ </ul>
333+ </div>
334+ </div>
335+ </template>
336+ </USlideover>
337+
338+ <!-- Overwrite confirmation -->
339+ <UModal v-model:open="confirmOpen" title="Overwrite catalog items?" description="Some selected skills already exist in the catalog with different content.">
340+ <template #body>
341+ <ul class="list-disc space-y-0.5 pl-4 text-sm">
342+ <li v-for="s in differing" :key="s.path">{{ s.name }}</li>
343+ </ul>
344+ <p class="mt-3 text-sm text-muted">Overwriting replaces the catalog version with the marketplace version. Local edits in the catalog are lost (git history keeps them).</p>
345+ </template>
346+ <template #footer>
347+ <div class="flex w-full justify-end gap-2">
348+ <UButton label="Cancel" color="neutral" variant="outline" @click="confirmOpen = false" />
349+ <UButton label="Overwrite and import" color="warning" :loading="importing" @click="doImport(true)" />
350+ </div>
351+ </template>
352+ </UModal>
353+
354+ <!-- Add source -->
355+ <UModal v-model:open="addOpen" title="Add a source" description="A git repository containing skills, or a Claude Code plugin marketplace.">
356+ <template #body>
357+ <form id="add-source" class="space-y-3" @submit.prevent="addSource">
358+ <UAlert v-if="addError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="addError" />
359+ <UFormField label="Name" required><UInput v-model="addForm.name" class="w-full" placeholder="My skills" /></UFormField>
360+ <UFormField label="Kind"><USelect v-model="addForm.kind" :items="KINDS" class="w-full" /></UFormField>
361+ <UFormField label="URL" required><UInput v-model="addForm.url" class="w-full" placeholder="https://github.com/owner/repo.git" :ui="{ base: 'font-mono' }" /></UFormField>
362+ <UFormField label="Ref" description="Branch or tag; default branch when empty."><UInput v-model="addForm.ref" class="w-full" placeholder="main" :ui="{ base: 'font-mono' }" /></UFormField>
363+ </form>
364+ </template>
365+ <template #footer>
366+ <div class="flex w-full justify-end gap-2">
367+ <UButton label="Cancel" color="neutral" variant="outline" @click="addOpen = false" />
368+ <UButton type="submit" form="add-source" label="Add source" :loading="adding" :disabled="!addForm.name.trim() || !addForm.url.trim()" />
369+ </div>
370+ </template>
371+ </UModal>
372+
373+ <UModal :open="!!removeTarget" title="Remove source" description="Imported catalog items are kept." @update:open="(v: boolean) => { if (!v) removeTarget = null }">
374+ <template #body>
375+ <p class="text-sm">Remove <strong>{{ removeTarget?.name }}</strong> from the list of sources?</p>
376+ </template>
377+ <template #footer>
378+ <div class="flex w-full justify-end gap-2">
379+ <UButton label="Cancel" color="neutral" variant="outline" @click="removeTarget = null" />
380+ <UButton label="Remove" color="error" @click="removeSource" />
381+ </div>
382+ </template>
383+ </UModal>
384+ </template>
385+ </UDashboardPanel>
386+</template>
added packages/ui/app/pages/presets/[name].vue +8 -0
new file mode 100644
@@ -0,0 +1,8 @@
1+<script setup lang="ts">
2+const route = useRoute()
3+const name = computed(() => String(route.params.name))
4+</script>
5+
6+<template>
7+ <PresetEditor :key="name" :name="name" />
8+</template>
new file mode 100644
@@ -0,0 +1,8 @@
1+<script setup lang="ts">
2+const route = useRoute()
3+const name = computed(() => String(route.params.name))
4+</script>
5+
6+<template>
7+ <PresetEditor :key="name" :name="name" />
8+</template>
added packages/ui/app/pages/presets/index.vue +80 -0
new file mode 100644
@@ -0,0 +1,80 @@
1+<script setup lang="ts">
2+import type { CatalogItemType } from '@skilly/core'
3+
4+const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCatalog()
5+const names = useScopeNames()
6+
7+onMounted(refresh)
8+
9+const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name)))
10+const count = (p: (typeof presets.value)[number], t: CatalogItemType) => p.items[t]?.length ?? 0
11+const total = (p: (typeof presets.value)[number]) => CAT_TYPES.reduce((n, t) => n + count(p, t), 0)
12+const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'
13+</script>
14+
15+<template>
16+ <UDashboardPanel id="presets">
17+ <template #header>
18+ <UDashboardNavbar title="Presets" icon="i-lucide-layers">
19+ <template #right>
20+ <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/presets/new" />
21+ </template>
22+ </UDashboardNavbar>
23+ </template>
24+ <template #body>
25+ <div v-if="loading && !data" class="space-y-3">
26+ <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />
27+ </div>
28+ <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" />
29+ <CatalogSetupCard v-else-if="data && !exists" />
30+ <div v-else class="space-y-4">
31+ <p class="text-sm text-muted">A preset is a named selection of catalog items. Enable it in a project to write all its items at once.</p>
32+ <div v-if="!sorted.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center">
33+ <UIcon name="i-lucide-layers" class="size-8 text-dimmed" />
34+ <p class="text-sm text-muted">No preset yet.</p>
35+ <UButton label="Create a preset" icon="i-lucide-plus" to="/presets/new" size="sm" />
36+ </div>
37+ <div v-else class="overflow-x-auto rounded-xl border border-default">
38+ <table class="w-full min-w-[720px] table-fixed text-sm">
39+ <thead class="border-b border-default bg-elevated/40">
40+ <tr>
41+ <th :class="TH" class="w-[30%]">Name</th>
42+ <th :class="TH">Items</th>
43+ <th :class="TH" class="w-40">Includes</th>
44+ <th :class="TH" class="w-28">Used by</th>
45+ </tr>
46+ </thead>
47+ <tbody class="divide-y divide-default">
48+ <tr v-for="p in sorted" :key="p.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="navigateTo(`/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/presets/${encodeURIComponent(p.name)}`)">
49+ <td class="px-3 py-2 align-middle">
50+ <span class="block truncate font-medium text-highlighted">{{ p.name }}</span>
51+ <p v-if="p.description" class="line-clamp-1 text-xs text-muted" :title="p.description">{{ p.description }}</p>
52+ </td>
53+ <td class="px-3 py-2 align-middle">
54+ <div class="flex flex-wrap gap-x-3 gap-y-1 text-xs">
55+ <span v-for="t in CAT_TYPES.filter(t => count(p, t))" :key="t" class="flex items-center gap-1">
56+ <UIcon :name="CAT_META[t].icon" class="size-3.5 text-muted" /><span class="tabular-nums font-medium">{{ count(p, t) }}</span><span class="text-muted">{{ CAT_META[t].label }}</span>
57+ </span>
58+ <span v-if="!total(p)" class="text-dimmed">empty</span>
59+ </div>
60+ </td>
61+ <td class="px-3 py-2 align-middle">
62+ <div class="flex flex-wrap gap-1">
63+ <UBadge v-for="i in p.includes" :key="i" :label="i" size="sm" color="neutral" variant="outline" />
64+ <span v-if="!p.includes.length" class="text-xs text-dimmed">-</span>
65+ </div>
66+ </td>
67+ <td class="px-3 py-2 align-middle">
68+ <UTooltip v-if="presetUsedBy(p.name).length" :text="presetUsedBy(p.name).map(s => scopeLabel(s, names)).join(', ')">
69+ <span class="text-xs tabular-nums">{{ presetUsedBy(p.name).length }} scope{{ presetUsedBy(p.name).length > 1 ? 's' : '' }}</span>
70+ </UTooltip>
71+ <span v-else class="text-xs text-dimmed">unused</span>
72+ </td>
73+ </tr>
74+ </tbody>
75+ </table>
76+ </div>
77+ </div>
78+ </template>
79+ </UDashboardPanel>
80+</template>
new file mode 100644
@@ -0,0 +1,80 @@
1+<script setup lang="ts">
2+import type { CatalogItemType } from '@skilly/core'
3+
4+const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCatalog()
5+const names = useScopeNames()
6+
7+onMounted(refresh)
8+
9+const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name)))
10+const count = (p: (typeof presets.value)[number], t: CatalogItemType) => p.items[t]?.length ?? 0
11+const total = (p: (typeof presets.value)[number]) => CAT_TYPES.reduce((n, t) => n + count(p, t), 0)
12+const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'
13+</script>
14+
15+<template>
16+ <UDashboardPanel id="presets">
17+ <template #header>
18+ <UDashboardNavbar title="Presets" icon="i-lucide-layers">
19+ <template #right>
20+ <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/presets/new" />
21+ </template>
22+ </UDashboardNavbar>
23+ </template>
24+ <template #body>
25+ <div v-if="loading && !data" class="space-y-3">
26+ <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />
27+ </div>
28+ <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" />
29+ <CatalogSetupCard v-else-if="data && !exists" />
30+ <div v-else class="space-y-4">
31+ <p class="text-sm text-muted">A preset is a named selection of catalog items. Enable it in a project to write all its items at once.</p>
32+ <div v-if="!sorted.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center">
33+ <UIcon name="i-lucide-layers" class="size-8 text-dimmed" />
34+ <p class="text-sm text-muted">No preset yet.</p>
35+ <UButton label="Create a preset" icon="i-lucide-plus" to="/presets/new" size="sm" />
36+ </div>
37+ <div v-else class="overflow-x-auto rounded-xl border border-default">
38+ <table class="w-full min-w-[720px] table-fixed text-sm">
39+ <thead class="border-b border-default bg-elevated/40">
40+ <tr>
41+ <th :class="TH" class="w-[30%]">Name</th>
42+ <th :class="TH">Items</th>
43+ <th :class="TH" class="w-40">Includes</th>
44+ <th :class="TH" class="w-28">Used by</th>
45+ </tr>
46+ </thead>
47+ <tbody class="divide-y divide-default">
48+ <tr v-for="p in sorted" :key="p.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="navigateTo(`/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/presets/${encodeURIComponent(p.name)}`)">
49+ <td class="px-3 py-2 align-middle">
50+ <span class="block truncate font-medium text-highlighted">{{ p.name }}</span>
51+ <p v-if="p.description" class="line-clamp-1 text-xs text-muted" :title="p.description">{{ p.description }}</p>
52+ </td>
53+ <td class="px-3 py-2 align-middle">
54+ <div class="flex flex-wrap gap-x-3 gap-y-1 text-xs">
55+ <span v-for="t in CAT_TYPES.filter(t => count(p, t))" :key="t" class="flex items-center gap-1">
56+ <UIcon :name="CAT_META[t].icon" class="size-3.5 text-muted" /><span class="tabular-nums font-medium">{{ count(p, t) }}</span><span class="text-muted">{{ CAT_META[t].label }}</span>
57+ </span>
58+ <span v-if="!total(p)" class="text-dimmed">empty</span>
59+ </div>
60+ </td>
61+ <td class="px-3 py-2 align-middle">
62+ <div class="flex flex-wrap gap-1">
63+ <UBadge v-for="i in p.includes" :key="i" :label="i" size="sm" color="neutral" variant="outline" />
64+ <span v-if="!p.includes.length" class="text-xs text-dimmed">-</span>
65+ </div>
66+ </td>
67+ <td class="px-3 py-2 align-middle">
68+ <UTooltip v-if="presetUsedBy(p.name).length" :text="presetUsedBy(p.name).map(s => scopeLabel(s, names)).join(', ')">
69+ <span class="text-xs tabular-nums">{{ presetUsedBy(p.name).length }} scope{{ presetUsedBy(p.name).length > 1 ? 's' : '' }}</span>
70+ </UTooltip>
71+ <span v-else class="text-xs text-dimmed">unused</span>
72+ </td>
73+ </tr>
74+ </tbody>
75+ </table>
76+ </div>
77+ </div>
78+ </template>
79+ </UDashboardPanel>
80+</template>
added packages/ui/app/pages/presets/new.vue +3 -0
new file mode 100644
@@ -0,0 +1,3 @@
1+<template>
2+ <PresetEditor />
3+</template>
new file mode 100644
@@ -0,0 +1,3 @@
1+<template>
2+ <PresetEditor />
3+</template>
added packages/ui/app/pages/settings.vue +84 -0
new file mode 100644
@@ -0,0 +1,84 @@
1+<script setup lang="ts">
2+import type { Settings } from '@skilly/core'
3+
4+const { request } = useApi()
5+const { refresh: refreshCatalog, data: catalog } = useCatalog()
6+const toast = useToast()
7+
8+const loaded = ref<Settings | null>(null)
9+const form = reactive<Settings>({ catalogPath: '', autoSync: false })
10+const error = ref<string | null>(null)
11+const saving = ref(false)
12+const pickerOpen = ref(false)
13+
14+const dirty = computed(() => !!loaded.value && (form.catalogPath !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync))
15+
16+function apply(s: Settings) {
17+ loaded.value = s
18+ form.catalogPath = s.catalogPath
19+ form.autoSync = s.autoSync
20+}
21+
22+onMounted(async () => {
23+ try {
24+ apply(await request<Settings>('/api/settings', { silent: true }))
25+ } catch (e) {
26+ error.value = (e as Error).message
27+ }
28+})
29+
30+async function save() {
31+ saving.value = true
32+ try {
33+ apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync } }))
34+ await refreshCatalog()
35+ toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' })
36+ } catch {
37+ // toast shown by useApi
38+ } finally {
39+ saving.value = false
40+ }
41+}
42+</script>
43+
44+<template>
45+ <UDashboardPanel id="settings">
46+ <template #header>
47+ <UDashboardNavbar title="Settings" icon="i-lucide-settings" />
48+ </template>
49+ <template #body>
50+ <div class="mx-auto w-full max-w-2xl space-y-6">
51+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load settings" :description="error" />
52+ <USkeleton v-else-if="!loaded" class="h-40 w-full" />
53+ <template v-else>
54+ <UCard>
55+ <template #header>
56+ <h2 class="font-semibold">Catalog</h2>
57+ </template>
58+ <div class="space-y-4">
59+ <UFormField label="Catalog folder" description="A folder (usually a git repository) holding your skills, rules, MCP servers, hooks, agents and presets. Point it to an existing clone to reuse it.">
60+ <div class="flex gap-2">
61+ <UInput v-model="form.catalogPath" class="flex-1" :ui="{ base: 'font-mono' }" />
62+ <UButton label="Browse" icon="i-lucide-folder-search" color="neutral" variant="outline" @click="pickerOpen = true" />
63+ </div>
64+ </UFormField>
65+ <p v-if="catalog && form.catalogPath === loaded.catalogPath" class="text-xs" :class="catalog.snapshot.exists ? 'text-success' : 'text-warning'">
66+ {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page.' }}
67+ </p>
68+ <p v-else-if="dirty" class="text-xs text-muted">Saving re-opens the catalog at the new location.</p>
69+
70+ <USwitch v-model="form.autoSync" label="Automatic sync" description="Commit, pull and push the catalog automatically after changes (needs a git remote)." />
71+ </div>
72+ <template #footer>
73+ <div class="flex justify-end gap-2">
74+ <UButton label="Reset" color="neutral" variant="ghost" :disabled="!dirty" @click="apply(loaded!)" />
75+ <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" />
76+ </div>
77+ </template>
78+ </UCard>
79+ </template>
80+ </div>
81+ <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p) => form.catalogPath = p" />
82+ </template>
83+ </UDashboardPanel>
84+</template>
new file mode 100644
@@ -0,0 +1,84 @@
1+<script setup lang="ts">
2+import type { Settings } from '@skilly/core'
3+
4+const { request } = useApi()
5+const { refresh: refreshCatalog, data: catalog } = useCatalog()
6+const toast = useToast()
7+
8+const loaded = ref<Settings | null>(null)
9+const form = reactive<Settings>({ catalogPath: '', autoSync: false })
10+const error = ref<string | null>(null)
11+const saving = ref(false)
12+const pickerOpen = ref(false)
13+
14+const dirty = computed(() => !!loaded.value && (form.catalogPath !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync))
15+
16+function apply(s: Settings) {
17+ loaded.value = s
18+ form.catalogPath = s.catalogPath
19+ form.autoSync = s.autoSync
20+}
21+
22+onMounted(async () => {
23+ try {
24+ apply(await request<Settings>('/api/settings', { silent: true }))
25+ } catch (e) {
26+ error.value = (e as Error).message
27+ }
28+})
29+
30+async function save() {
31+ saving.value = true
32+ try {
33+ apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync } }))
34+ await refreshCatalog()
35+ toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' })
36+ } catch {
37+ // toast shown by useApi
38+ } finally {
39+ saving.value = false
40+ }
41+}
42+</script>
43+
44+<template>
45+ <UDashboardPanel id="settings">
46+ <template #header>
47+ <UDashboardNavbar title="Settings" icon="i-lucide-settings" />
48+ </template>
49+ <template #body>
50+ <div class="mx-auto w-full max-w-2xl space-y-6">
51+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load settings" :description="error" />
52+ <USkeleton v-else-if="!loaded" class="h-40 w-full" />
53+ <template v-else>
54+ <UCard>
55+ <template #header>
56+ <h2 class="font-semibold">Catalog</h2>
57+ </template>
58+ <div class="space-y-4">
59+ <UFormField label="Catalog folder" description="A folder (usually a git repository) holding your skills, rules, MCP servers, hooks, agents and presets. Point it to an existing clone to reuse it.">
60+ <div class="flex gap-2">
61+ <UInput v-model="form.catalogPath" class="flex-1" :ui="{ base: 'font-mono' }" />
62+ <UButton label="Browse" icon="i-lucide-folder-search" color="neutral" variant="outline" @click="pickerOpen = true" />
63+ </div>
64+ </UFormField>
65+ <p v-if="catalog && form.catalogPath === loaded.catalogPath" class="text-xs" :class="catalog.snapshot.exists ? 'text-success' : 'text-warning'">
66+ {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page.' }}
67+ </p>
68+ <p v-else-if="dirty" class="text-xs text-muted">Saving re-opens the catalog at the new location.</p>
69+
70+ <USwitch v-model="form.autoSync" label="Automatic sync" description="Commit, pull and push the catalog automatically after changes (needs a git remote)." />
71+ </div>
72+ <template #footer>
73+ <div class="flex justify-end gap-2">
74+ <UButton label="Reset" color="neutral" variant="ghost" :disabled="!dirty" @click="apply(loaded!)" />
75+ <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" />
76+ </div>
77+ </template>
78+ </UCard>
79+ </template>
80+ </div>
81+ <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p) => form.catalogPath = p" />
82+ </template>
83+ </UDashboardPanel>
84+</template>
added packages/ui/app/utils/catalog.ts +118 -0
new file mode 100644
@@ -0,0 +1,118 @@
1+import type { CatalogItemType, HookTemplate, ItemStatus, McpEnvValue, McpTemplate } from '@skilly/core'
2+
3+export const CAT_TYPES: CatalogItemType[] = ['skill', 'rule', 'agent', 'mcp', 'hook']
4+
5+export const CAT_META: Record<CatalogItemType, { label: string, singular: string, icon: string, markdown: boolean }> = {
6+ skill: { label: 'Skills', singular: 'Skill', icon: 'i-lucide-sparkles', markdown: true },
7+ rule: { label: 'Rules', singular: 'Rule', icon: 'i-lucide-scroll-text', markdown: true },
8+ agent: { label: 'Agents', singular: 'Agent', icon: 'i-lucide-bot', markdown: true },
9+ mcp: { label: 'MCP', singular: 'MCP server', icon: 'i-lucide-plug', markdown: false },
10+ hook: { label: 'Hooks', singular: 'Hook', icon: 'i-lucide-webhook', markdown: false }
11+}
12+
13+export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/
14+
15+export const itemKey = (type: string, name: string) => `${type}/${name}`
16+
17+export const STATUS_META: Record<ItemStatus['state'], { label: string, color: 'success' | 'warning' | 'info' | 'neutral', icon: string, hint: string }> = {
18+ 'in-sync': { label: 'in sync', color: 'success', icon: 'i-lucide-check', hint: 'Identical to the catalog version.' },
19+ 'modified': { label: 'modified', color: 'warning', icon: 'i-lucide-pencil', hint: 'Edited in this scope since the last sync.' },
20+ 'catalog-updated': { label: 'catalog updated', color: 'info', icon: 'i-lucide-arrow-down-to-line', hint: 'The catalog has a newer version.' },
21+ 'not-in-catalog': { label: 'not in catalog', color: 'neutral', icon: 'i-lucide-circle-dashed', hint: 'Local item, absent from the catalog.' },
22+ 'differs-from-catalog': { label: 'differs from catalog', color: 'warning', icon: 'i-lucide-git-compare', hint: 'Same name as a catalog item, different content, not managed by skilly.' }
23+}
24+
25+/** `*` wildcard match on names, same semantics as the config globs. */
26+export function globMatch(pattern: string, name: string): boolean {
27+ if (!pattern.includes('*')) return pattern === name
28+ const escape = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, c => '\\' + c)
29+ const re = new RegExp('^' + pattern.split('*').map(escape).join('.*') + '$')
30+ return re.test(name)
31+}
32+
33+export const scopeLabel = (id: string, names: Record<string, string>) => (id === 'home' ? 'Home' : names[id] ?? id)
34+export const scopeLink = (id: string) => (id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`)
35+
36+/* ---------------------------------------------------------------- YAML */
37+
38+const RESERVED = /^(true|false|null|yes|no|on|off|~|[-+]?[0-9][0-9._]*(e[-+]?[0-9]+)?)$/i
39+
40+function scalar(v: string | number | boolean): string {
41+ if (typeof v !== 'string') return String(v)
42+ if (v !== '' && !RESERVED.test(v) && /^[A-Za-z0-9_./$@%][A-Za-z0-9_./:@%$=+-]*$/.test(v)) return v
43+ return JSON.stringify(v)
44+}
45+
46+const flowList = (a: string[]) => `[${a.map(scalar).join(', ')}]`
47+
48+function envLines(key: string, map: Record<string, McpEnvValue> | undefined): string[] {
49+ const entries = Object.entries(map ?? {})
50+ if (!entries.length) return []
51+ return [`${key}:`, ...entries.map(([k, v]) => {
52+ const body = v.fromEnv !== undefined ? `fromEnv: ${scalar(v.fromEnv)}` : `value: ${scalar(v.value ?? '')}`
53+ return ` ${scalar(k)}: { ${body} }`
54+ })]
55+}
56+
57+export function mcpToYaml(t: McpTemplate): string {
58+ const l: string[] = [`name: ${scalar(t.name)}`]
59+ if (t.description) l.push(`description: ${scalar(t.description)}`)
60+ l.push(`transport: ${t.transport}`)
61+ if (t.transport === 'stdio') {
62+ if (t.command) l.push(`command: ${scalar(t.command)}`)
63+ if (t.args?.length) l.push(`args: ${flowList(t.args)}`)
64+ l.push(...envLines('env', t.env))
65+ } else {
66+ if (t.url) l.push(`url: ${scalar(t.url)}`)
67+ l.push(...envLines('headers', t.headers))
68+ if (t.auth?.bearerFromEnv) l.push('auth:', ` bearerFromEnv: ${scalar(t.auth.bearerFromEnv)}`)
69+ }
70+ return l.join('\n') + '\n'
71+}
72+
73+export function hookToYaml(t: HookTemplate): string {
74+ const l: string[] = [`name: ${scalar(t.name)}`]
75+ if (t.description) l.push(`description: ${scalar(t.description)}`)
76+ l.push(`event: ${scalar(t.event)}`)
77+ if (t.matcher) l.push(`matcher: ${scalar(t.matcher)}`)
78+ l.push('handler:', ' type: command', ` command: ${scalar(t.handler.command)}`)
79+ if (t.handler.timeout) l.push(` timeout: ${t.handler.timeout}`)
80+ if (t.agents?.length) l.push(`agents: ${flowList(t.agents)}`)
81+ return l.join('\n') + '\n'
82+}
83+
84+/* ---------------------------------------------------------- Frontmatter */
85+
86+const fmValue = (v: string) => (/^[^\s:#'"[\]{}&*!|>%@`,-][^:#]*$/.test(v.trim()) && v.trim() === v ? v : JSON.stringify(v))
87+
88+export function getFm(content: string, key: string): string {
89+ return splitFrontmatter(content).frontmatter?.[key] ?? ''
90+}
91+
92+/** Sets a top-level frontmatter key, creating the block when missing. Other lines are left untouched. */
93+export function setFm(content: string, key: string, value: string): string {
94+ const line = `${key}: ${fmValue(value)}`
95+ const m = /^(---\r?\n)([\s\S]*?)(\r?\n---[ \t]*(?:\r?\n|$))/.exec(content)
96+ if (!m) return `---\n${line}\n---\n\n${content}`
97+ const lines = m[2]!.split(/\r?\n/)
98+ const idx = lines.findIndex(l => l.startsWith(`${key}:`))
99+ if (idx === -1) lines.push(line)
100+ else {
101+ let end = idx + 1
102+ while (end < lines.length && /^\s+\S/.test(lines[end]!)) end++
103+ lines.splice(idx, end - idx, line)
104+ }
105+ return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length)
106+}
107+
108+export function newItemContent(type: CatalogItemType, name: string): string {
109+ const head = `---\nname: ${name}\ndescription: \n---\n\n`
110+ if (type === 'skill') return head.replace('description: \n', 'description: ""\n') + `# ${name}\n\nDescribe when and how the agent should use this skill.\n`
111+ if (type === 'rule') return head.replace('description: \n', 'description: ""\n') + '- \n'
112+ return head.replace('description: \n', 'description: ""\n') + 'You are a specialized subagent. Describe your role here.\n'
113+}
114+
115+export const HOOK_EVENTS = [
116+ 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop', 'SubagentStop',
117+ 'SessionStart', 'SessionEnd', 'Notification', 'PreCompact', 'PermissionRequest'
118+]
new file mode 100644
@@ -0,0 +1,118 @@
1+import type { CatalogItemType, HookTemplate, ItemStatus, McpEnvValue, McpTemplate } from '@skilly/core'
2+
3+export const CAT_TYPES: CatalogItemType[] = ['skill', 'rule', 'agent', 'mcp', 'hook']
4+
5+export const CAT_META: Record<CatalogItemType, { label: string, singular: string, icon: string, markdown: boolean }> = {
6+ skill: { label: 'Skills', singular: 'Skill', icon: 'i-lucide-sparkles', markdown: true },
7+ rule: { label: 'Rules', singular: 'Rule', icon: 'i-lucide-scroll-text', markdown: true },
8+ agent: { label: 'Agents', singular: 'Agent', icon: 'i-lucide-bot', markdown: true },
9+ mcp: { label: 'MCP', singular: 'MCP server', icon: 'i-lucide-plug', markdown: false },
10+ hook: { label: 'Hooks', singular: 'Hook', icon: 'i-lucide-webhook', markdown: false }
11+}
12+
13+export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/
14+
15+export const itemKey = (type: string, name: string) => `${type}/${name}`
16+
17+export const STATUS_META: Record<ItemStatus['state'], { label: string, color: 'success' | 'warning' | 'info' | 'neutral', icon: string, hint: string }> = {
18+ 'in-sync': { label: 'in sync', color: 'success', icon: 'i-lucide-check', hint: 'Identical to the catalog version.' },
19+ 'modified': { label: 'modified', color: 'warning', icon: 'i-lucide-pencil', hint: 'Edited in this scope since the last sync.' },
20+ 'catalog-updated': { label: 'catalog updated', color: 'info', icon: 'i-lucide-arrow-down-to-line', hint: 'The catalog has a newer version.' },
21+ 'not-in-catalog': { label: 'not in catalog', color: 'neutral', icon: 'i-lucide-circle-dashed', hint: 'Local item, absent from the catalog.' },
22+ 'differs-from-catalog': { label: 'differs from catalog', color: 'warning', icon: 'i-lucide-git-compare', hint: 'Same name as a catalog item, different content, not managed by skilly.' }
23+}
24+
25+/** `*` wildcard match on names, same semantics as the config globs. */
26+export function globMatch(pattern: string, name: string): boolean {
27+ if (!pattern.includes('*')) return pattern === name
28+ const escape = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, c => '\\' + c)
29+ const re = new RegExp('^' + pattern.split('*').map(escape).join('.*') + '$')
30+ return re.test(name)
31+}
32+
33+export const scopeLabel = (id: string, names: Record<string, string>) => (id === 'home' ? 'Home' : names[id] ?? id)
34+export const scopeLink = (id: string) => (id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`)
35+
36+/* ---------------------------------------------------------------- YAML */
37+
38+const RESERVED = /^(true|false|null|yes|no|on|off|~|[-+]?[0-9][0-9._]*(e[-+]?[0-9]+)?)$/i
39+
40+function scalar(v: string | number | boolean): string {
41+ if (typeof v !== 'string') return String(v)
42+ if (v !== '' && !RESERVED.test(v) && /^[A-Za-z0-9_./$@%][A-Za-z0-9_./:@%$=+-]*$/.test(v)) return v
43+ return JSON.stringify(v)
44+}
45+
46+const flowList = (a: string[]) => `[${a.map(scalar).join(', ')}]`
47+
48+function envLines(key: string, map: Record<string, McpEnvValue> | undefined): string[] {
49+ const entries = Object.entries(map ?? {})
50+ if (!entries.length) return []
51+ return [`${key}:`, ...entries.map(([k, v]) => {
52+ const body = v.fromEnv !== undefined ? `fromEnv: ${scalar(v.fromEnv)}` : `value: ${scalar(v.value ?? '')}`
53+ return ` ${scalar(k)}: { ${body} }`
54+ })]
55+}
56+
57+export function mcpToYaml(t: McpTemplate): string {
58+ const l: string[] = [`name: ${scalar(t.name)}`]
59+ if (t.description) l.push(`description: ${scalar(t.description)}`)
60+ l.push(`transport: ${t.transport}`)
61+ if (t.transport === 'stdio') {
62+ if (t.command) l.push(`command: ${scalar(t.command)}`)
63+ if (t.args?.length) l.push(`args: ${flowList(t.args)}`)
64+ l.push(...envLines('env', t.env))
65+ } else {
66+ if (t.url) l.push(`url: ${scalar(t.url)}`)
67+ l.push(...envLines('headers', t.headers))
68+ if (t.auth?.bearerFromEnv) l.push('auth:', ` bearerFromEnv: ${scalar(t.auth.bearerFromEnv)}`)
69+ }
70+ return l.join('\n') + '\n'
71+}
72+
73+export function hookToYaml(t: HookTemplate): string {
74+ const l: string[] = [`name: ${scalar(t.name)}`]
75+ if (t.description) l.push(`description: ${scalar(t.description)}`)
76+ l.push(`event: ${scalar(t.event)}`)
77+ if (t.matcher) l.push(`matcher: ${scalar(t.matcher)}`)
78+ l.push('handler:', ' type: command', ` command: ${scalar(t.handler.command)}`)
79+ if (t.handler.timeout) l.push(` timeout: ${t.handler.timeout}`)
80+ if (t.agents?.length) l.push(`agents: ${flowList(t.agents)}`)
81+ return l.join('\n') + '\n'
82+}
83+
84+/* ---------------------------------------------------------- Frontmatter */
85+
86+const fmValue = (v: string) => (/^[^\s:#'"[\]{}&*!|>%@`,-][^:#]*$/.test(v.trim()) && v.trim() === v ? v : JSON.stringify(v))
87+
88+export function getFm(content: string, key: string): string {
89+ return splitFrontmatter(content).frontmatter?.[key] ?? ''
90+}
91+
92+/** Sets a top-level frontmatter key, creating the block when missing. Other lines are left untouched. */
93+export function setFm(content: string, key: string, value: string): string {
94+ const line = `${key}: ${fmValue(value)}`
95+ const m = /^(---\r?\n)([\s\S]*?)(\r?\n---[ \t]*(?:\r?\n|$))/.exec(content)
96+ if (!m) return `---\n${line}\n---\n\n${content}`
97+ const lines = m[2]!.split(/\r?\n/)
98+ const idx = lines.findIndex(l => l.startsWith(`${key}:`))
99+ if (idx === -1) lines.push(line)
100+ else {
101+ let end = idx + 1
102+ while (end < lines.length && /^\s+\S/.test(lines[end]!)) end++
103+ lines.splice(idx, end - idx, line)
104+ }
105+ return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length)
106+}
107+
108+export function newItemContent(type: CatalogItemType, name: string): string {
109+ const head = `---\nname: ${name}\ndescription: \n---\n\n`
110+ if (type === 'skill') return head.replace('description: \n', 'description: ""\n') + `# ${name}\n\nDescribe when and how the agent should use this skill.\n`
111+ if (type === 'rule') return head.replace('description: \n', 'description: ""\n') + '- \n'
112+ return head.replace('description: \n', 'description: ""\n') + 'You are a specialized subagent. Describe your role here.\n'
113+}
114+
115+export const HOOK_EVENTS = [
116+ 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop', 'SubagentStop',
117+ 'SessionStart', 'SessionEnd', 'Notification', 'PreCompact', 'PermissionRequest'
118+]
added packages/ui/app/utils/resolve.ts +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+import type { CatalogItemType, Preset } from '@skilly/core'
2+
3+export interface ResolvedEntry {
4+ type: CatalogItemType
5+ name: string
6+ /** Presets that provide the item directly, plus `add` for explicit additions. */
7+ via: string[]
8+}
9+
10+export interface Resolved {
11+ entries: ResolvedEntry[]
12+ /** Included / enabled presets that are not in the catalog. */
13+ missingPresets: string[]
14+ /** Presets reached twice through an include cycle (ignored). */
15+ cycles: string[]
16+}
17+
18+/**
19+ * Client-side mirror of the server resolution: union of the items of `roots` (presets, recursively including
20+ * their `includes`) and of the explicit `add` lists. Exclusions are not applied here.
21+ */
22+export function resolveItems(
23+ presets: Preset[],
24+ roots: string[],
25+ add: Partial<Record<CatalogItemType, string[]>> = {},
26+ inline?: Preset
27+): Resolved {
28+ const byName = new Map(presets.map(p => [p.name, p]))
29+ const map = new Map<string, ResolvedEntry>()
30+ const missing = new Set<string>()
31+ const cycles = new Set<string>()
32+
33+ const put = (type: CatalogItemType, name: string, via: string) => {
34+ const k = `${type}/${name}`
35+ const e = map.get(k) ?? { type, name, via: [] }
36+ if (!e.via.includes(via)) e.via.push(via)
37+ map.set(k, e)
38+ }
39+ const visit = (p: Preset, stack: string[]) => {
40+ for (const [type, names] of Object.entries(p.items) as [CatalogItemType, string[] | undefined][]) {
41+ for (const n of names ?? []) put(type, n, p.name)
42+ }
43+ for (const inc of p.includes) {
44+ if (stack.includes(inc)) { cycles.add(inc); continue }
45+ const child = byName.get(inc)
46+ if (!child) { missing.add(inc); continue }
47+ visit(child, [...stack, inc])
48+ }
49+ }
50+
51+ if (inline) visit(inline, [inline.name])
52+ for (const r of roots) {
53+ const p = byName.get(r)
54+ if (!p) { missing.add(r); continue }
55+ visit(p, [r])
56+ }
57+ for (const [type, names] of Object.entries(add) as [CatalogItemType, string[] | undefined][]) {
58+ for (const n of names ?? []) put(type, n, 'add')
59+ }
60+
61+ return { entries: [...map.values()], missingPresets: [...missing], cycles: [...cycles] }
62+}
new file mode 100644
@@ -0,0 +1,62 @@
1+import type { CatalogItemType, Preset } from '@skilly/core'
2+
3+export interface ResolvedEntry {
4+ type: CatalogItemType
5+ name: string
6+ /** Presets that provide the item directly, plus `add` for explicit additions. */
7+ via: string[]
8+}
9+
10+export interface Resolved {
11+ entries: ResolvedEntry[]
12+ /** Included / enabled presets that are not in the catalog. */
13+ missingPresets: string[]
14+ /** Presets reached twice through an include cycle (ignored). */
15+ cycles: string[]
16+}
17+
18+/**
19+ * Client-side mirror of the server resolution: union of the items of `roots` (presets, recursively including
20+ * their `includes`) and of the explicit `add` lists. Exclusions are not applied here.
21+ */
22+export function resolveItems(
23+ presets: Preset[],
24+ roots: string[],
25+ add: Partial<Record<CatalogItemType, string[]>> = {},
26+ inline?: Preset
27+): Resolved {
28+ const byName = new Map(presets.map(p => [p.name, p]))
29+ const map = new Map<string, ResolvedEntry>()
30+ const missing = new Set<string>()
31+ const cycles = new Set<string>()
32+
33+ const put = (type: CatalogItemType, name: string, via: string) => {
34+ const k = `${type}/${name}`
35+ const e = map.get(k) ?? { type, name, via: [] }
36+ if (!e.via.includes(via)) e.via.push(via)
37+ map.set(k, e)
38+ }
39+ const visit = (p: Preset, stack: string[]) => {
40+ for (const [type, names] of Object.entries(p.items) as [CatalogItemType, string[] | undefined][]) {
41+ for (const n of names ?? []) put(type, n, p.name)
42+ }
43+ for (const inc of p.includes) {
44+ if (stack.includes(inc)) { cycles.add(inc); continue }
45+ const child = byName.get(inc)
46+ if (!child) { missing.add(inc); continue }
47+ visit(child, [...stack, inc])
48+ }
49+ }
50+
51+ if (inline) visit(inline, [inline.name])
52+ for (const r of roots) {
53+ const p = byName.get(r)
54+ if (!p) { missing.add(r); continue }
55+ visit(p, [r])
56+ }
57+ for (const [type, names] of Object.entries(add) as [CatalogItemType, string[] | undefined][]) {
58+ for (const n of names ?? []) put(type, n, 'add')
59+ }
60+
61+ return { entries: [...map.values()], missingPresets: [...missing], cycles: [...cycles] }
62+}
added packages/ui/app/utils/scopeConfig.ts +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+import type { AgentId, CatalogItemType, ScopeConfig } from '@skilly/core'
2+
3+export function emptyConfig(targets: AgentId[]): ScopeConfig {
4+ return { version: 1, targets, presets: [], add: {}, exclude: {}, onDemand: { skills: [] }, hideGlobal: { skills: [], mcp: [] } }
5+}
6+
7+export const cloneConfig = (c: ScopeConfig): ScopeConfig => JSON.parse(JSON.stringify(c))
8+
9+/** Returns a copy of `c` where `field[type]` is replaced by `list` (an empty list removes the key). */
10+export function withTypeList(c: ScopeConfig, field: 'add' | 'exclude', type: CatalogItemType, list: string[]): ScopeConfig {
11+ const next = cloneConfig(c)
12+ const map = { ...next[field] }
13+ if (list.length) map[type] = list
14+ else delete map[type]
15+ next[field] = map
16+ return next
17+}
18+
19+export const toggleIn = (list: string[], name: string, on: boolean) =>
20+ on ? (list.includes(name) ? list : [...list, name]) : list.filter(x => x !== name)
new file mode 100644
@@ -0,0 +1,20 @@
1+import type { AgentId, CatalogItemType, ScopeConfig } from '@skilly/core'
2+
3+export function emptyConfig(targets: AgentId[]): ScopeConfig {
4+ return { version: 1, targets, presets: [], add: {}, exclude: {}, onDemand: { skills: [] }, hideGlobal: { skills: [], mcp: [] } }
5+}
6+
7+export const cloneConfig = (c: ScopeConfig): ScopeConfig => JSON.parse(JSON.stringify(c))
8+
9+/** Returns a copy of `c` where `field[type]` is replaced by `list` (an empty list removes the key). */
10+export function withTypeList(c: ScopeConfig, field: 'add' | 'exclude', type: CatalogItemType, list: string[]): ScopeConfig {
11+ const next = cloneConfig(c)
12+ const map = { ...next[field] }
13+ if (list.length) map[type] = list
14+ else delete map[type]
15+ next[field] = map
16+ return next
17+}
18+
19+export const toggleIn = (list: string[], name: string, on: boolean) =>
20+ on ? (list.includes(name) ? list : [...list, name]) : list.filter(x => x !== name)